diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..8f0de65 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false + +[*.{yml,yaml}] +indent_size = 2 + +[docker-compose.yml] +indent_size = 4 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d5c399e --- /dev/null +++ b/.env.example @@ -0,0 +1,52 @@ +APP_NAME=moon-shop +APP_ENV=product +APP_KEY= +APP_DEBUG=true +APP_LOG_LEVEL=debug +# 请务必修改此项为自己的 URL +APP_URL=http://shop.shiguopeng.cn + +# 数据库设置 +DB_CONNECTION=mysql +DB_HOST= +DB_PORT=3306 +DB_DATABASE= +DB_USERNAME= +DB_PASSWORD= + + +# 缓存, session, 队列驱动设置 +BROADCAST_DRIVER=log +CACHE_DRIVER=file +SESSION_DRIVER=file +QUEUE_DRIVER=database + +# redis 数据库配置 +REDIS_HOST= +REDIS_PASSWORD= +REDIS_PORT=6379 + +# 邮箱设置 +MAIL_DRIVER=smtp +MAIL_HOST=smtp.qq.com +MAIL_PORT=465 +MAIL_USERNAME= +MAIL_PASSWORD= +MAIL_FROM_ADDRESS= +MAIL_FROM_NAME=MondayShop +MAIL_ENCRYPTION=ssl + +# 第三方互联登录 +OAUTH_GITHUB_ID= +OAUTH_GITHUB_SECRET= +OAUTH_QQ_ID= +OAUTH_WEIBO_ID= + +# 支付宝支付 +ALIAPY_APP_ID= + + +JWT_SECRET= + +# Docker 运行必须是 0.0.0.0 +LARAVELS_LISTEN_IP=0.0.0.0 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..fcb21d3 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,11 @@ +* text=auto eol=lf + +*.blade.php diff=html +*.css diff=css +*.html diff=html +*.md diff=markdown +*.php diff=php + +/.github export-ignore +CHANGELOG.md export-ignore +.styleci.yml export-ignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b2313e6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +/.phpunit.cache +/node_modules +/public/build +/public/hot +/public/storage +/storage/*.key +/vendor +.env +.env.backup +.env.production +.phpunit.result.cache +Homestead.json +Homestead.yaml +auth.json +npm-debug.log +yarn-error.log +/.fleet +/.idea +/.vscode +rr diff --git a/README.md b/README.md index 699d866..6b16e09 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,306 @@ -# aquaculture +# seth-shi/monday-shop +* !!! 非`Docker`运行请使用`v1`[https://github.com/seth-shi/monday-shop/tree/1.0](https://github.com/seth-shi/monday-shop/tree/1.0) + +## QQ 群 +* `584453488` + +## 部署(新新新) +1. 下载源码(也可直接下载压缩包, 然后解压) + * `git clone https://github.com/seth-shi/monday-shop.git` +2. 修改配置 + * `cp .env.example .env` + * 修改`.env`文件中数据库,域名等配置信息 +3. 构建镜像 + * `docker build . -t monday-shop` +4. 运行 + * `docker run -d --net=host --name monday-shop-service monday-shop` +5. 执行安装命令 + * `docker exec monday-shop-service php artisan moon:install` + +## 目录说明 +* [演示地址](#演示地址) +* [页面展示](#页面展示) +* [特色](#Feature) +* [安装](#Installation) +* [命令行功能](#Commands) +* [秒杀处理逻辑](#秒杀处理逻辑) +* [API文档(新)](#API) +* [依赖的 Packages](#Packages) +* [文章引用](#Reference) +* [错误/注意点](#Notice) +* [协议](#License) + +## 演示地址 +[演示地址:http://shop.shiguopeng.cn](http://shop.shiguopeng.cn) + +[后台地址:http://shop.shiguopeng.cn/admin](http://shop.shiguopeng.cn/admin) +* 账号:`admin` +* 密码:`admin` +**** +* 测试支付功能 + * 下载[支付宝沙箱](https://sandbox.alipaydev.com/user/downloadApp.htm) + * 账号:`eyxweq5099@sandbox.com` + * 密码:`111111` + * 支付密码:`111111` + * 当前账户余额:`9999999.99` + * 余额不足,请联系我及时充值 + +## 页面展示 +![PC首页](public/media/pc_index.png) +![支付](public/media/pay.gif) +![个人设置](public/media/map_center.png) +![个人中心](public/media/center.png) +![积分详情](public/media/score_detail.png) +![后台仪表盘](public/media/admin/dash_board.png) +![后台订单列表](public/media/admin/orders.png) +![用户喜好数据](public/media/admin/user_like.png) + +## Feature +- [x] **库存问题** + * [x] 普通订单使用乐观锁防止超卖 + * [X] 秒杀订单使用`Redis`队列防止超卖 +- [x] **首页数据全走缓存(推荐使用`Redis`驱动)** + * [x] 未登录的首页,零数据库查询,通过缓存驱动 + * [x] 计划任务每分钟会更新一次首页数据 + * [x] 开启秒杀模块,零数据库查询,通过`Redis`驱动 + * [x] 登录之后首页零数据库查询,`Session`驱动数据 +- [x] **积分功能** + * [x] 每日首次登录(访问网站)得到积分(bitmap 计算) + * [x] 连续登录 n 天得到积分(bitmap 计算) + * [x] 当天浏览商品数量 n 个得到积分(bitmap 计算) + * [x] 后台可新增 n+ 积分种类 + * [x] 完成订单可获得金钱等比例积分 +- [x] **优惠券功能** + * [x] 满减优惠 + * [x] 积分兑换满减优惠券 + * [x] 发放兑换码,兑换优惠券 +- [x] **物流功能** + * [x] 运费设置 + * [x] 快递物流 +- [x] **秒杀功能** + * 秒杀过期,自动回退库存 + * 使用延时队列,当订单超过三十分钟(可配置)未付款,自动取消订单 + * 秒杀商品,如果用户收藏,发送邮件提醒活动 + * 后台秒杀模块的开启关闭 + * 秒杀的商品数量,皆通过`Redis`读取 +- [x] **第三方授权登录 + 登录回跳** + * `Github` + * `QQ` + * 微博 +- [x] **第三方支付(支持自动适应手机,web 支付)** + - [x] 支付宝支付,退款 + - [ ] 微信支付 +- [x] **购物车** + * 使用`H5`本地存储 + * 登录之后同时显示本地购物车和数据库购物车数量 + * 用户登录之后会询问是否需要持久化到数据库 +- [x] **商品搜索** + * 使用**ElasticSearch**全文索引 + * 支持拼音首字母 + * `AJAX`无刷新显示 +- [x] **订阅模块** + * 每周定时推送一封邮件包含最受欢迎,最新,最火卖商品 +- [x] **分类排序** + * 后台使用拖动排序,可以设置在商城首页优先展示的分类 +- [x] **订单模块** + * 订单下单 + * 买家支付 + * 后台发货 / 卖家申请退款 + * 买确认收货 / 后台确认收货 + * 买家确认订单获取积分 + * 用户下订单之后可以评论 +- [x] **站内消息** + * 消息通知 + * 多模板类型通知, 兑换码通知、文章通知等等 + * 轮询通知消息,一点即达 +- [x] **数据统计** + * 每天晚上一点进行站点数据统计 +- [x] 全文搜索 +- [x] **响应式网站** + + +## Commands +| 命令 | 一句话描述 | +| ----- | --- | +|`php artisan moon:install`|安装应用程序| +|`php artisan add:shop-to-search`|生成全文索引| +|`php artisan moon:uninstall`|卸载网站(清空数据库,缓存,路由)| +|`php artisan moon:cache`|执行缓存(缓存配置,路由,类映射)| +|`php artisan moon:clear`|清除缓存| +|`php artisan moon:copy`|复制项目内置的静态资源| +|`php artisan moon:delete`|删除项目及上传的基本静态资源| +|`php artisan moon:export`|导出用户数据到json文件| +|`php artisan moon:count-site`|统计站点任务(每天夜里一点执行)| +|`php artisan moon:del-seckills`|删除秒杀数据 (每小时自动执行一次)| +|`php artisan moon:moon:del-score-data`|删除积分缓存数据 (每天夜里 0 点执行)| +|`php artisan moon:update-home`|更新首页数据 (每分钟自动执行一次)| +|`php artisan moon:send-subscribes`|发送订阅邮件 (每个礼拜六早上八点)| +|`php artisan queue:work --tries=3`|监听队列(邮件发送,处理过期的秒杀数据 !!!| + +## 秒杀处理逻辑 +```php + +## 初始化抢购数据 +number, 9)); + +?> + +## 抢购 +id(); + +// 判断是否已经开始了秒杀 + +// 返回 0,代表当前用户已经抢购过了 +if (0 == Redis::hset("seckills:{$id}:users:{$userId}", 'id', $userId)) { + + return responseJson(403, '你已经抢购过了'); +} + +// 如果从队列中读取到了 null,代表已经没有库存 +if (is_null(Redis::lpop("seckills:{$id}:queue"))) { + + return responseJson(403, '已经抢购完了'); +} + +// 这里就可以开始入库订单 + +?> + +## 利用 crontab 定时扫描过期数据,回滚库存,删除过期 redis (可选) +where('end_at', '<', date('Y-m-d H:i:s')) + ->get() + ->map(function (Seckill $seckill) { + + // 先模糊查找到所有用户 key + $ids = Redis::keys("seckills:{$seckill->id}:*"); + Redis::del($ids); + + // 回滚库存 + // 做更多的事 + }; + +?> + +``` + +## API +* 接口响应数据说明 + * 响应的数据格式总是保证拥有基本元素(`code`, `msg`, `data`) + * `code` 请参考接口全局状态码说明 + * `msg` 此次请求消息,如果返回状态码为非成功,可直接展示`msg` + * `data` 如果为列表页将会一个数组类型(如商品列表),否则为一个对象类型(商品详情) + * 如有额外扩展字段, 将于基本元素平级, 如分页的`count` +```json +{ + "code": 401, + "msg": "无效的token", + "data": [] +} +``` +* 刷新`token`说明 + * 为了保证安全性,`token`的有效时间为`60`分钟 + * 当旧的`token`失效时,服务器会主动刷新,并在响应头加入`Authorization` + * 这时候旧的`token`将会加入黑名单不能再使用, 请将在响应头中新的`token`保存使用 + * 当服务器主动刷新之后,会有一个期限(`2`周).服务器将无法再刷新,将返回`402`状态码,请重新登录账户 +* `token`使用流程说明 +```javascript +// 全局请求类 +function request(_method, _url, _param, _func) { + + $.ajax({ + method: _method, + url: _url, + data: _param, + beforeSend: function (xhr) { + console.log(xhr); + xhr.setRequestHeader('Authorization', localStorage.getItem('api_token')) + }, + complete: function (xhr, a, b) { + + if (xhr.getResponseHeader('Authorization')) { + localStorage.setItem('api_token', xhr.getResponseHeader('Authorization')) + } + }, + success: function (res) { + + // token 永久过期 + if (res.code === 402) { + // 跳去登录页面 + return false; + } + // 更多状态码判断 + } + }); +} + +// 第一次登录保存 token, 之后使用全局类请求数据即可 + +``` + +* 接口全局状态码说明(建议封装一个全局请求类或者中间件,统一处理全局状态码) + * `200` + * 请求数据成功 + * `401` + * 身份验证出错(未登录就请求数据) + * 非法无效的`token` + * `token`已被加入黑名单(一般不会出现这个问题,出现这个问题那么就是你刷新 token 的逻辑有问题) + * `402` + * `token`已完全失效,后台暂设为 2 周,再也无法刷新,请重新登录账户 + * `500` + * 服务器出错,具体请参考响应的消息 +* __接口文档__(重要的事情说三遍) + +[接口文档](http://shop.shiguopeng.cn/docs.html) + +[接口文档](http://shop.shiguopeng.cn/docs.html) + +[接口文档](http://shop.shiguopeng.cn/docs.html) +![](public/media/api_example.gif) + +## Packages +| 扩展包 | 一句话描述 | 在本项目中的使用案例 | +| --- | --- | --- | +|[z-song/laravel-admin](https://github.com/z-song/laravel-admin)|后台|快速搭建后台系统| +|[mews/captcha](https://github.com/mewebstudio/captcha)|验证码|登录注册功能使用验证码验证| +|[overtrue/laravel-socialite](https://github.com/overtrue/laravel-socialite)|第三方登录|用户登录可以使用Github,QQ,新浪微博| +|[intervention/image](https://github.com/Intervention/image)|图片处理|是为 Laravel 定制的图片处理工具,加水印| +|[webpatser/laravel-uuid](https://github.com/webpatser/laravel-uuid)|uuid生成|商品添加增加一个uuid,订单号| +|[renatomarinho/laravel-page-speed](https://github.com/renatomarinho/laravel-page-speed)|压缩页面DOM|打包优化您的网站自动导致35%以上的优化(已移除使用)| +|[overtrue/laravel-pinyin](https://github.com/overtrue/laravel-pinyin)|汉语拼音翻译|分类首字母查询| +|[acelaya/doctrine-enum-type](https://github.com/acelaya/doctrine-enum-type)|枚举|优化代码中的映射| + +## Reference +* [Laravel 的中大型專案架構](https://old-oomusou.goodjack.tw/laravel/architecture/) +* [十个 Laravel 5 程序优化技巧](https://laravel-china.org/articles/2020/ten-laravel-5-program-optimization-techniques) +* [十个 Laravel 5 程序优化技巧](http://www.ruanyifeng.com/blog/2018/10/restful-api-best-practices.html) +* [服务器做了两个优化 CPU 使用率减低 40%(使用缓存优化访问量不写数据库)](https://learnku.com/articles/13366/the-server-has-made-two-optimization-and-the-cpu-utilization-rate-has-been-reduced-by-40) + +## Notice +* 建议开启`bcmath`扩展保证字符串数字运算正确 +* 监听队列如果长时间没反应,或者一直重复任务 + * 数据库没配置好,导致队列任务表连接不上 + * 邮件配置出错,导致发送邮件一直失败 +* `composer install`安装不上依赖 + * 请删除`composer.lock`文件,重新运行`composer install` +* `SQLSTATE[HY000]: General error: 1215 Cannot add foreign key constraint` + * 数据库引擎切换到`InnoDB` +* `composer install` 安装依赖错误 + * `composer.lock`锁定了镜像源,删除`composer.lock`再执行即可 +## License +The MIT License (MIT) diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..034e848 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,21 @@ +# Security Policy + +## Supported Versions + +Use this section to tell people about which versions of your project are +currently being supported with security updates. + +| Version | Supported | +| ------- | ------------------ | +| 5.1.x | :white_check_mark: | +| 5.0.x | :x: | +| 4.0.x | :white_check_mark: | +| < 4.0 | :x: | + +## Reporting a Vulnerability + +Use this section to tell people how to report a vulnerability. + +Tell them where to go, how often they can expect to get an update on a +reported vulnerability, what to expect if the vulnerability is accepted or +declined, etc. diff --git a/app/Admin/Actions/Post/ActiveUserAction.php b/app/Admin/Actions/Post/ActiveUserAction.php new file mode 100644 index 0000000..9ac9534 --- /dev/null +++ b/app/Admin/Actions/Post/ActiveUserAction.php @@ -0,0 +1,22 @@ +is_active = 1; + $model->save(); + + return $this->response()->success('操作成功.')->refresh(); + } + +} diff --git a/app/Admin/Actions/Post/DividerAction.php b/app/Admin/Actions/Post/DividerAction.php new file mode 100644 index 0000000..054f834 --- /dev/null +++ b/app/Admin/Actions/Post/DividerAction.php @@ -0,0 +1,28 @@ +response()->success('Success message.')->refresh(); + } + + /** + * Render row action. + * + * @return string + */ + public function render() + { + return '
  • '; + } +} diff --git a/app/Admin/Actions/Post/ForceDeleteProductAction.php b/app/Admin/Actions/Post/ForceDeleteProductAction.php new file mode 100644 index 0000000..259b8cf --- /dev/null +++ b/app/Admin/Actions/Post/ForceDeleteProductAction.php @@ -0,0 +1,30 @@ +forceDelete(); + + return $this->response()->success('操作成功.')->refresh(); + } + + + public function retrieveModel(Request $request) + { + if (!$key = $request->get('_key')) { + return false; + } + + return Product::query()->withTrashed()->findOrFail($key); + } +} diff --git a/app/Admin/Actions/Post/OrderReceivedAction.php b/app/Admin/Actions/Post/OrderReceivedAction.php new file mode 100644 index 0000000..72a07e1 --- /dev/null +++ b/app/Admin/Actions/Post/OrderReceivedAction.php @@ -0,0 +1,34 @@ +status != OrderStatusEnum::PAID) { + + return back()->withErrors('订单未付款', 'error'); + } + + if ($order->ship_status != OrderShipStatusEnum::DELIVERED) { + + return back()->withErrors('订单未发货', 'error'); + } + + $order->ship_status = OrderShipStatusEnum::RECEIVED; + $order->save(); + + return $this->response()->success('确认收货成功.')->refresh(); + } +} diff --git a/app/Admin/Actions/Post/OrderRefundAction.php b/app/Admin/Actions/Post/OrderRefundAction.php new file mode 100644 index 0000000..8d43a10 --- /dev/null +++ b/app/Admin/Actions/Post/OrderRefundAction.php @@ -0,0 +1,59 @@ +status != OrderStatusEnum::APPLY_REFUND) { + return $this->response()->error('订单当前状态禁止退款'); + } + + $pay = Pay::alipay(config('pay.ali')); + + // 退款数据 + $refundData = [ + 'out_trade_no' => $order->no, + 'trade_no' => $order->pay_no, + 'refund_amount' => $order->pay_amount, + 'refund_reason' => '正常退款', + ]; + + try { + + // 将订单状态改为退款 + $response = $pay->refund($refundData); + $order->pay_refund_fee = $response->get('refund_fee'); + $order->pay_trade_no = $response->get('trade_no'); + $order->status = OrderStatusEnum::REFUND; + $order->save(); + + } catch (\Exception $e) { + + // 调用异常的处理 + // abort(500, $e->getMessage()); + return $this->response()->error('服务器异常,请稍后再试'); + } + + return $this->response()->success('退款成功.')->refresh(); + } + + public function dialog() + { + $this->confirm('退款会直接把钱退回到支付账户,是否继续'); + } +} diff --git a/app/Admin/Actions/Post/OrderShipAction.php b/app/Admin/Actions/Post/OrderShipAction.php new file mode 100644 index 0000000..db820af --- /dev/null +++ b/app/Admin/Actions/Post/OrderShipAction.php @@ -0,0 +1,44 @@ +status != OrderStatusEnum::PAID) { + + return $this->response()->error('订单未付款'); + } + + $company = $request->input('company'); + $no = $request->input('no'); + if (empty($company) || empty($no)) { + + return $this->response()->error('必填项不能为空'); + } + + $order->ship_status = OrderShipStatusEnum::DELIVERED; + $order->express_company = $company; + $order->express_no = $no; + $order->save(); + + return $this->response()->success('发货成功.')->refresh(); + } + + public function form() + { + $this->text('company', '物流公司')->required(); + $this->text('no', '物流单号')->required(); + } +} diff --git a/app/Admin/Actions/Post/ProductStatusAction.php b/app/Admin/Actions/Post/ProductStatusAction.php new file mode 100644 index 0000000..b739169 --- /dev/null +++ b/app/Admin/Actions/Post/ProductStatusAction.php @@ -0,0 +1,46 @@ +name = $name; + + return $this; + } + + public function handle(Product $product) + { + // $model ... + // 如果商品已经下架 + if ($product->trashed()) { + + // 重新上架 + $product->restore(); + } else { + + $product->delete(); + } + + + return $this->response()->success('操作成功.')->refresh(); + } + + + public function retrieveModel(Request $request) + { + if (!$key = $request->get('_key')) { + return false; + } + + return Product::query()->withTrashed()->findOrFail($key); + } +} diff --git a/app/Admin/Controllers/AdminController.php b/app/Admin/Controllers/AdminController.php new file mode 100644 index 0000000..872da8b --- /dev/null +++ b/app/Admin/Controllers/AdminController.php @@ -0,0 +1,118 @@ +header(trans('admin.administrator')) + ->description(trans('admin.list')) + ->body($this->adminGrid()->render()); + } + + protected function adminGrid() + { + $userModel = config('admin.database.users_model'); + + $grid = new Grid(new $userModel()); + + $grid->column('id', 'ID')->sortable(); + $grid->column('username', trans('admin.username')); + $grid->column('name', trans('admin.name')); + $grid->column('login_ip', '登录ip'); + $grid->roles(trans('admin.roles'))->pluck('name')->label(); + $grid->column('created_at', trans('admin.created_at')); + $grid->column('updated_at', trans('admin.updated_at')); + + $grid->actions(function (Grid\Displayers\DropdownActions $actions) { + if ($actions->getKey() == 1) { + $actions->disableDelete(); + } + }); + + + $grid->tools(function (Grid\Tools $tools) { + $tools->batch(function (Grid\Tools\BatchActions $actions) { + $actions->disableDelete(); + }); + }); + + return $grid; + } + + + public function indexLogs(Content $content) + { + return $content + ->header(trans('admin.operation_log')) + ->description(trans('admin.list')) + ->body($this->logGrid()); + } + + /** + * @return Grid + */ + protected function logGrid() + { + $grid = new Grid(new OperationLog()); + + $grid->model()->orderBy('id', 'DESC'); + + $grid->column('id', 'ID')->sortable(); + $grid->column('user.name', '用户'); + $grid->column('method', '方法')->display(function ($method) { + $color = array_get(OperationLog::$methodColors, $method, 'grey'); + + return "$method"; + }); + $grid->column('path', '路径')->label('info'); + $grid->column('ip', '地址')->label('primary'); + $grid->column('description', '描述')->limit(20)->modal(function ($model) { + + return new Box('详情', $model->description ?: ' '); + }); + $grid->column('input', '输入数据')->limit(20)->expand(function ($model) { + + $input = json_decode($model->input, true); + $input = array_except($input, ['_pjax', '_token', '_method', '_previous_']); + $codes = empty($input) ? + '{}' : + '
    '.json_encode($input, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE).'
    '; + + return new Box('详情', $codes); + }); + + $grid->column('created_at', trans('admin.created_at')); + + $grid->actions(function (Grid\Displayers\Actions $actions) { + $actions->disableEdit(); + $actions->disableView(); + }); + + $grid->disableCreateButton(); + + $grid->filter(function (Grid\Filter $filter) { + $userModel = config('admin.database.users_model'); + + $filter->equal('user_id', 'User')->select($userModel::all()->pluck('name', 'id')); + $filter->equal('method')->select(array_combine(OperationLog::$methods, OperationLog::$methods)); + $filter->like('path'); + $filter->equal('ip'); + }); + + return $grid; + } +} diff --git a/app/Admin/Controllers/ArticleNotificationController.php b/app/Admin/Controllers/ArticleNotificationController.php new file mode 100644 index 0000000..3bbcdd5 --- /dev/null +++ b/app/Admin/Controllers/ArticleNotificationController.php @@ -0,0 +1,99 @@ +model()->latest(); + + $grid->column('id', __('Id')); + $grid->column('title', __('Title')); + $grid->column('created_at', __('Created at')); + + $grid->filter(function (Grid\Filter $filter) { + + $filter->like('id', __('Id')); + $filter->like('title', __('Title')); + }); + + $grid->actions(function (Grid\Displayers\DropdownActions $actions) { + + $actions->disableEdit(); + }); + + return $grid; + } + + /** + * Make a show builder. + * + * @param mixed $id + * @return Show + */ + protected function detail($id) + { + $show = new Show(ArticleNotification::findOrFail($id)); + + $show->field('id', __('Id')); + $show->field('title', __('Title')); + $show->field('content', __('Content'))->unescape(); + $show->field('created_at', __('Created at')); + + return $show; + } + + /** + * Make a form builder. + * + * @return Form + */ + protected function form() + { + $form = new Form(new ArticleNotification); + + $form->text('title', __('Title'))->help('请再三确认再发布,不可修改'); + $form->kindeditor('content', __('Content')); + + $form->saving(function (Form $form) { + + if (app()->environment('dev')) { + + admin_toastr('开发环境不允许操作', 'error'); + return back()->withInput(); + } + }); + + $form->deleting(function (Form $form) { + + if (app()->environment('dev')) { + + admin_toastr('开发环境不允许操作', 'error'); + return back()->withInput(); + } + }); + + return $form; + } +} diff --git a/app/Admin/Controllers/AuthController.php b/app/Admin/Controllers/AuthController.php new file mode 100644 index 0000000..6ede048 --- /dev/null +++ b/app/Admin/Controllers/AuthController.php @@ -0,0 +1,87 @@ +only([$this->username(), 'password']); + + /** @var \Illuminate\Validation\Validator $validator */ + $validator = Validator::make($credentials, [ + $this->username() => 'required', + 'password' => 'required', + ]); + + if ($validator->fails()) { + return back()->withInput()->withErrors($validator); + } + + if ($this->guard()->attempt($credentials)) { + + // 验证是否登录的 ip + $this->authenticated($this->guard()->user()); + + // 记录登录日期 + return $this->sendLoginResponse($request); + } + + return back()->withInput()->withErrors([ + $this->username() => $this->getFailedLoginMessage(), + ]); + } + + /** + * 登录之后提示 ip 地址 + * + * @param Administrator $user + */ + protected function authenticated(Administrator $user) + { + $ip = request()->getClientIp(); + + // 如果两次 ip 不一样, 提示风险 + if (! is_null($user->login_ip) && $ip != $user->login_ip) { + + admin_info('上一次登录的地址与本次不同,如果不是本人操作,建议及时修改密码'); + } + + $user->login_ip = $ip; + $user->save(); + } + + public function putSetting() + { + $form = $this->settingForm(); + + $form->submitted(function (Form $form) { + + if (app()->environment('dev')) { + + admin_toastr('开发环境不允许操作', 'error'); + return back()->withInput(); + } + }); + + return $form->update(Admin::user()->id); + } +} diff --git a/app/Admin/Controllers/CarController.php b/app/Admin/Controllers/CarController.php new file mode 100644 index 0000000..c8cb307 --- /dev/null +++ b/app/Admin/Controllers/CarController.php @@ -0,0 +1,90 @@ +column('id', __('Id')); + + $grid->column('user_id', __('User id')); + $grid->column('product_id', __('Product id')); + + $grid->column('user.name', '用户'); + $grid->column('product.name', '商品'); + $grid->column('number', __('Number')); + + + $grid->column('created_at', '收藏时间'); + + $grid->disableActions(); + $grid->disableCreateButton(); + + $grid->filter(function (Grid\Filter $filter) { + + $filter->equal('user_id', '用户ID'); + $filter->equal('product_id', '商品ID'); + $filter->like('user.name', '用户名'); + $filter->like('product.name', '商品'); + }); + + return $grid; + } + + /** + * Make a show builder. + * + * @param mixed $id + * @return Show + */ + protected function detail($id) + { + $show = new Show(Car::findOrFail($id)); + + $show->field('id', __('Id')); + $show->field('number', __('Number')); + $show->field('product_id', __('Product id')); + $show->field('user_id', __('User id')); + $show->field('created_at', __('Created at')); + $show->field('updated_at', __('Updated at')); + + return $show; + } + + /** + * Make a form builder. + * + * @return Form + */ + protected function form() + { + $form = new Form(new Car); + + $form->number('number', __('Number'))->default(1); + $form->number('product_id', __('Product id')); + $form->number('user_id', __('User id')); + + return $form; + } +} diff --git a/app/Admin/Controllers/CategoryController.php b/app/Admin/Controllers/CategoryController.php new file mode 100644 index 0000000..c2f3d53 --- /dev/null +++ b/app/Admin/Controllers/CategoryController.php @@ -0,0 +1,197 @@ +header('商品分类') + ->description('') + ->row(function (Row $row) { + + // 只能在同一级排序拖动,不允许二级 + $row->column(6, Category::tree(function (Tree $tree) { + + $tree->disableCreate(); + + $tree->nestable(['maxDepth' => 1]) + ->branch(function ($branch) { + + $icon = ""; + + return $icon . ' ' . $branch['title']; + }); + })); + + // 新建表单 + $row->column(6, function (Column $column) { + $form = new \Encore\Admin\Widgets\Form(); + $form->action(admin_base_path('categories')); + + $form->text('title', '分类名')->rules('required|unique:categories,title'); + $form->icon('icon', '图标')->default('fa-bars')->rules('required'); + $form->image('thumb', '缩略图')->uniqueName()->rules('required'); + $form->hidden('_token')->default(csrf_token()); + + $column->append((new Box('新增', $form))->style('success')); + }); + }); + } + + /** + * Show interface. + * + * @param mixed $id + * @param Content $content + * @return Content + */ + public function show($id, Content $content) + { + return $content + ->header('详情') + ->description('') + ->body($this->detail($id)); + } + + /** + * Edit interface. + * + * @param mixed $id + * @param Content $content + * @return Content + */ + public function edit($id, Content $content) + { + return $content + ->header('编辑') + ->description('') + ->body($this->form()->edit($id)); + } + + + + /** + * Make a show builder. + * + * @param mixed $id + * @return Show + */ + protected function detail($id) + { + $show = new Show(Category::findOrFail($id)); + + $show->field('id'); + $show->field('title', '分类名'); + $show->field('thumb', '缩略图')->unescape()->as(function ($thumb) { + return image($thumb); + }); + $show->field('description', '描述'); + $show->field('order', '排序'); + $show->field('created_at', '创建时间'); + $show->field('updated_at', '修改时间'); + + return $show; + } + + /** + * Make a form builder. + * + * @return Form + */ + protected function form() + { + $form = new Form(new Category); + + $form->text('title', '分类名'); + $form->icon('icon', '图标'); + $form->image('thumb', '缩略图'); + $form->text('description', '描述'); + + $form->saving(function (Form $form) { + + if (app()->environment('dev')) { + + admin_toastr('开发环境不允许操作', 'error'); + return back()->withInput(); + } + }); + + + return $form; + } + + + /** + * 分类下有商品,不允许删除 + * + * @param $id + * @return \Illuminate\Http\JsonResponse + */ + public function destroy($id) + { + if (app()->environment('dev')) { + + return response()->json(['status' => false, 'message' => '开发环境不允许操作']); + } + /** + * @var $category Category + */ + $category = Category::query()->findOrFail($id); + + if ($category->products()->exists()) { + return response()->json(['status' => false, 'message' => '分类下有商品存在,不允许删除']); + } + + if ($category->delete()) { + $data = [ + 'status' => true, + 'message' => trans('admin.delete_succeeded'), + ]; + } else { + $data = [ + 'status' => false, + 'message' => trans('admin.delete_failed'), + ]; + } + + return response()->json($data); + } + + /** + * 商品下拉列表 + * + * @param Request $request + * @return \Illuminate\Support\Collection + */ + public function getProducts(Request $request) + { + $id = $request->get('q'); + + $category = Category::query()->findOrFail($id); + + // 尽量促销卖得少的商品 + return $category->products()->orderBy('sale_count', 'desc')->get(['id', 'name as text']); + } +} diff --git a/app/Admin/Controllers/CommentController.php b/app/Admin/Controllers/CommentController.php new file mode 100644 index 0000000..25a096d --- /dev/null +++ b/app/Admin/Controllers/CommentController.php @@ -0,0 +1,73 @@ +header('评论列表') + ->description('') + ->body($this->grid()); + } + + /** + * Make a grid builder. + * + * @return Grid + */ + protected function grid() + { + $grid = new Grid(new Comment); + + $grid->model()->latest(); + + $grid->column('id'); + $grid->column('order_id', '订单'); + $grid->column('product.name', '商品'); + $grid->column('user.name', '用户'); + $grid->column('content', '评论内容'); + $grid->column('score', '评分'); + $grid->column('created_at', '创建时间'); + $grid->column('updated_at', '修改时间'); + + $grid->filter(function (Grid\Filter $filter) { + + $filter->disableIdFilter(); + $filter->where(function ($query) { + + $collections = User::query() + ->where('name', 'like', "%{$this->input}%") + ->pluck('id'); + $query->whereIn('user_id', $collections->all()); + }, '用户'); + $filter->where(function ($query) { + + $collections = Product::query() + ->where('name', 'like', "%{$this->input}%") + ->pluck('id'); + + $query->whereIn('product_id', $collections->all()); + }, '商品'); + }); + + return $grid; + } +} diff --git a/app/Admin/Controllers/CouponCodeController.php b/app/Admin/Controllers/CouponCodeController.php new file mode 100644 index 0000000..80ada3a --- /dev/null +++ b/app/Admin/Controllers/CouponCodeController.php @@ -0,0 +1,190 @@ +model()->latest(); + + $grid->column('id', __('Id')); + $grid->column('code', '兑换码'); + $grid->column('user_id', __('User id')); + $grid->column('template_id', '优惠券ID'); + + $grid->column('user.name', '会员名'); + $grid->column('template.title', '优惠券名'); + + $grid->column('used_at', __('Used at')); + $grid->column('notification_at', '上一次发送通知时间'); + $grid->column('created_at', __('Created at')); + + + $grid->filter(function (Grid\Filter $filter) { + + $filter->equal('code', '兑换码'); + $filter->like('user.name', '会员名'); + $filter->equal('template.title', '优惠券名'); + $filter->between('used_at')->datetime(); + }); + + $grid->actions(function (Grid\Displayers\Actions $actions) { + + $actions->disableEdit(); + $actions->disableView(); + }); + + return $grid; + } + + + public function store() + { + $request = request(); + + /** + * 发放的优惠券不能为空 + * @var $template CouponTemplate + */ + $template = CouponTemplate::query()->findOrFail($request->input('template_id')); + $today = Carbon::today(); + if ($today->gt(Carbon::make($template->end_date))) { + + admin_error('优惠券已过期'); + return back()->withInput(); + } + + // 如果会员 ID 不为空,则代表是指定会员发放 + if ($userIds = $request->input('user_ids')) { + + // 使用空格拆分 id + $ids = array_values(array_filter(explode(' ', $userIds))); + $users = User::query()->findMany($ids); + } + // 否则则是条件范围发放 + else { + + $query = User::query(); + // 性别 + $sex = array_filter($request->input('user_sex')); + // 会员来源 + $sources = array_filter($request->input('user_source')); + // 登录次数 + $loginCount = (int)$request->input('login_count'); + // 会员总积分 + $scoreAll = (int)$request->input('user_score'); + + $query->whereIn('sex', $sex) + ->whereIn('source', $sources) + ->where('login_count', '>=', $loginCount) + ->where('score_all', '>=', $scoreAll); + $users = $query->get(); + } + + + // 开始根据用户发放 + $now = Carbon::now()->toDateTimeString(); + $notifications = collect(); + $codes = $users->map(function (User $user) use ($template, $notifications, $now) { + + $code = strtoupper(str_random(16)); + + $notification = [ + 'id' => Uuid::uuid4()->toString(), + 'type' => CouponCodeNotification::class, + 'notifiable_id' => $user->id, + 'notifiable_type' => get_class($user), + 'data' => json_encode((new CouponCodeNotification($template, $code))->toArray($user), JSON_UNESCAPED_UNICODE), + 'created_at' => $now, + 'updated_at' => $now, + ]; + $notifications->push($notification); + + return [ + 'code' => $code, + 'user_id' => $user->id, + 'template_id' => $template->id, + 'notification_at' => $now, + 'created_at' => $now, + 'updated_at' => $now, + ]; + }); + + + $size = 1000; + foreach (array_chunk($codes->all(), $size, true) as $chunk) { + // 优惠券码 + CouponCode::query()->insert($codes->all()); + } + foreach (array_chunk($notifications->all(), $size, true) as $chunk) { + // 通知 + DatabaseNotification::query()->insert($chunk); + } + + admin_toastr("发布成功,总共有{$users->count()}位会员符合发放条件"); + return response()->redirectTo(admin_url('/coupon_codes')); + } + + /** + * Make a form builder. + * + * @return Form + */ + protected function form() + { + $form = new Form(new CouponCode); + + $today = Carbon::today()->toDateString(); + + $form->divider('指定会员发放'); + $form->text('user_ids', '会员')->help('请输入会员的ID, 多个会员用空格隔开,如果为空则代表是范围发放'); + + $form->divider('范围发放'); + $templates = CouponTemplate::query()->where('end_date', '>=', $today)->pluck('title', 'id'); + $form->checkbox('user_sex', '会员性别')->options([UserSexEnum::MAN => '男', UserSexEnum::WOMAN => '女'])->canCheckAll(); + $form->checkbox('user_source', '会员来源')->options([ + UserSourceEnum::MOON => '前台注册', + UserSourceEnum::GITHUB => 'Github', + UserSourceEnum::QQ => 'QQ', + UserSourceEnum::WEIBO => '微博', + ])->canCheckAll(); + $form->number('login_count', '会员登录次数')->default(0)->help('会员登录次数大于等于给定的次数'); + $form->number('user_score', '会员总积分')->default(0)->help('会员给定的积分大于等于总积分'); + + $form->divider('优惠券'); + + $form->select('template_id', '优惠券')->help('发放的优惠券')->options($templates)->required(); + + return $form; + } +} diff --git a/app/Admin/Controllers/CouponLogController.php b/app/Admin/Controllers/CouponLogController.php new file mode 100644 index 0000000..de28c64 --- /dev/null +++ b/app/Admin/Controllers/CouponLogController.php @@ -0,0 +1,57 @@ +model()->latest(); + + $grid->column('id', __('Id')); + $grid->column('user_id', '用户 ID'); + $grid->column('user.name', '用户昵称'); + $grid->column('title', __('Title')); + $grid->column('amount', '满减金额'); + $grid->column('full_amount', __('Full amount')); + $grid->column('start_date', __('Start date')); + $grid->column('end_date', __('End date')); + $grid->column('is_used', '是否使用')->display(function () { + + return YesNoTransform::trans(! is_null($this->used_at)); + }); + $grid->column('used_at', '使用时间'); + $grid->column('created_at', __('Created at')); + + $grid->disableActions(); + $grid->disableCreateButton(); + + $grid->filter(function (Grid\Filter $filter) { + + $filter->like('title', __('Title')); + }); + + return $grid; + } +} diff --git a/app/Admin/Controllers/CouponTemplateController.php b/app/Admin/Controllers/CouponTemplateController.php new file mode 100644 index 0000000..4bf97b3 --- /dev/null +++ b/app/Admin/Controllers/CouponTemplateController.php @@ -0,0 +1,86 @@ +model()->latest('start_date'); + + $grid->column('id', __('Id')); + $grid->column('title', __('Title')); + $grid->column('amount', '优惠金额'); + $grid->column('full_amount', __('Full amount')); + $grid->column('score', '需兑换积分'); + $grid->column('exp_date', '有效日期')->display(function () { + + return $this->start_date . ' ~ ' . $this->end_date; + }); + $today = Carbon::today(); + $grid->column('overtime', '是否有效(未过期)')->display(function () use ($today) { + + $endDate = Carbon::make($this->end_date); + $startDate = Carbon::make($this->start_date); + // 如果没有结束日期,代表永远不过期 + if (is_null($endDate) || is_null($startDate)) { + + $isOver = true; + } else { + + $isOver = $today->gte($startDate) && $today->lte($endDate); + } + + return YesNoTransform::trans($isOver); + }); + $grid->column('created_at', __('Created at')); + + $grid->filter(function (Grid\Filter $filter) { + + $filter->like('title', __('Title')); + }); + + return $grid; + } + + /** + * Make a form builder. + * + * @return Form + */ + protected function form() + { + $form = new Form(new CouponTemplate); + + $form->text('title', __('Title')); + $form->decimal('amount', '优惠金额'); + $form->decimal('full_amount', __('Full amount')); + $form->number('score', __('Score'))->default(0)->help('设置为 0 代表无需积分即可兑换优惠券'); + $form->date('start_date', __('Start date'))->default(date('Y-m-d')); + $form->date('end_date', __('End date'))->default(Carbon::today()->addMonth()); + + return $form; + } +} diff --git a/app/Admin/Controllers/HomeController.php b/app/Admin/Controllers/HomeController.php new file mode 100644 index 0000000..5904f2a --- /dev/null +++ b/app/Admin/Controllers/HomeController.php @@ -0,0 +1,84 @@ +toJson(JSON_UNESCAPED_UNICODE)); + + // 使用 echart + Admin::js('/js/echarts.min.js'); + + return $content + ->header('仪表盘') + ->row(function (Row $row) use ($service) { + + /** + * 今日统计,今天的特殊,需要从缓存 redis 中读取 + * + * @var $todaySite SiteCount + */ + $now = Carbon::now(); + $today = $now->toDateString(); + $todaySite = SiteCount::query()->firstOrNew(['date' => $today]); + $todaySite = $service->syncByCache($todaySite); + + // 七日统计 + $lastWeekDate = $now->subDay(7); + $weekSites = SiteCount::query() + ->where('date', '!=', $today) + ->where('date', '>', $lastWeekDate) + ->get() + ->push($todaySite) + ->sortBy('date'); + + + // 本月统计 + $month = $now->format('Y-m'); + $monthSites = SiteCount::query() + ->where('date', '!=', $today) + ->where('date', '>', $month) + ->get() + ->push($todaySite) + ->sortBy('date'); + + + $row->column(4, new Box('今日用户注册来源', new Div('todayRegister'))); + $row->column(4, new Box('七日用户注册来源', new Div('weekRegister'))); + $row->column(4, new Box('本月用户注册来源', new Div('monthRegister'))); + + $row->column(4, new Box('今日订单', new Div('todayOrders'))); + $row->column(4, new Box('近期订单量', new Div('weekSites'))); + $row->column(4, new Box('交易金额', new Div('saleMoney'))); + + $allSites = compact('todaySite', 'weekSites', 'monthSites'); + $row->column(12, view('admin.chars.echart', $allSites)); + }); + } + + + /** + * 自定义 404 页面 + * + * @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View + */ + public function noFound() + { + return redirect('admin'); + } +} diff --git a/app/Admin/Controllers/LevelController.php b/app/Admin/Controllers/LevelController.php new file mode 100644 index 0000000..8bb210b --- /dev/null +++ b/app/Admin/Controllers/LevelController.php @@ -0,0 +1,192 @@ +header('列表') + ->description('') + ->body($this->grid()); + } + + /** + * Show interface. + * + * @param mixed $id + * @param Content $content + * @return Content + */ + public function show($id, Content $content) + { + return $content + ->header('详情') + ->description('') + ->body($this->detail($id)); + } + + /** + * Edit interface. + * + * @param mixed $id + * @param Content $content + * @return Content + */ + public function edit($id, Content $content) + { + return $content + ->header('Edit') + ->description('') + ->body($this->form()->edit($id)); + } + + /** + * Create interface. + * + * @param Content $content + * @return Content + */ + public function create(Content $content) + { + return $content + ->header('新建') + ->description('') + ->body($this->form()); + } + + /** + * Make a grid builder. + * + * @return Grid + */ + protected function grid() + { + $grid = new Grid(new Level); + + $grid->model()->orderBy('min_score', 'desc'); + + + $grid->column('id', 'id'); + $grid->column('icon', '图标')->image('', 90, 90); + $grid->column('name', '名字')->editable(); + $grid->column('level', '等级'); + $grid->column('min_score', '分阶'); + $grid->column('created_at', '创建时间'); + $grid->column('updated_at', '更新时间'); + + + $grid->actions(function (Grid\Displayers\Actions $actions) { + + $level = $actions->row; + if (! $level->can_delete) { + $actions->disableDelete(); + } + }); + + return $grid; + } + + /** + * Make a show builder. + * + * @param mixed $id + * @return Show + */ + protected function detail($id) + { + $show = new Show(Level::findOrFail($id)); + + $show->field('id', 'id'); + $show->field('icon', '图标')->image(); + $show->field('name', '名字'); + $show->field('level', '等级'); + $show->field('min_score', '分阶'); + $show->field('created_at', '创建时间'); + $show->field('updated_at', '更新时间'); + + return $show; + } + + /** + * Make a form builder. + * + * @return Form + */ + protected function form() + { + $form = new Form(new Level); + + $icon = $form->image('icon', '图标')->uniqueName(); + if (! windows_os()) { + $icon->resize(160, 160);; + } + + $form->text('name', '名字'); + $form->number('level', '等级'); + $form->number('min_score', '积分'); + + $form->saving(function (Form $form) { + + if (app()->environment('dev')) { + + admin_toastr('开发环境不允许操作', 'error'); + return back()->withInput(); + } + }); + + + return $form; + } + + /** + * Remove the specified resource from storage. + * + * @param int $id + * @return \Illuminate\Http\Response + * @throws \Exception + */ + public function destroy($id) + { + $level = Level::query()->findOrFail($id); + + if (! $level->can_delete) { + + return response()->json([ + 'status' => false, + 'message' => '这个等级不允许删除', + ]); + } + + if ($level->delete()) { + $data = [ + 'status' => true, + 'message' => trans('admin.delete_succeeded'), + ]; + } else { + $data = [ + 'status' => false, + 'message' => trans('admin.delete_failed'), + ]; + } + + return response()->json($data); + } +} diff --git a/app/Admin/Controllers/OrderController.php b/app/Admin/Controllers/OrderController.php new file mode 100644 index 0000000..4753203 --- /dev/null +++ b/app/Admin/Controllers/OrderController.php @@ -0,0 +1,267 @@ +header('订单列表') + ->description('') + ->body($this->grid()); + } + + /** + * Show interface. + * + * @param mixed $id + * @param Content $content + * @return Content + */ + public function show($id, Content $content) + { + return $content + ->header('详情') + ->description('') + ->body($this->detail($id)); + } + + + + + /** + * Make a grid builder. + * + * @return Grid + */ + protected function grid() + { + $grid = new Grid(new Order); + + $grid->model()->withTrashed()->latest(); + + $grid->column('id'); + $grid->column('no', '流水号'); + $grid->column('user.name', '用户'); + $grid->column('origin_amount', '订单原价'); + $grid->column('post_amount', '邮费'); + $grid->column('coupon_amount', '优惠'); + $grid->column('amount', '总价'); + $grid->column('status', '状态')->display(function ($status) { + + // 如果订单是付款, 那么就修改为物流状态 + if ($status == OrderStatusEnum::PAID) { + + return OrderShipStatusTransform::trans($this->ship_status); + } + + return OrderStatusTransform::trans($status); + }); + $grid->column('type', '订单类型')->display(function ($type) { + + return OrderTypeTransform::trans($type); + }); + + $grid->column('pay_type', '支付方式')->display(function ($type) { + + return OrderPayTypeTransform::trans($type); + }); + $grid->column('pay_no', '支付流水号'); + $grid->column('paid_at', '支付时间'); + $grid->column('consignee_name', '收货人姓名'); + $grid->column('consignee_phone', '收货人手机'); + $grid->column('created_at', '创建时间'); + + $grid->disableRowSelector(); + $grid->disableCreateButton(); + $grid->actions(function (Grid\Displayers\DropdownActions $actions) { + + /** + * @var $order Order + */ + $order = $actions->row; + + + // 如果出现了申请,显示可以退款按钮 + if ($order->status == OrderStatusEnum::APPLY_REFUND) { + + $actions->add(new OrderRefundAction()); + } elseif ($order->status == OrderStatusEnum::PAID) { + + if ($order->ship_status == OrderShipStatusEnum::PENDING) { + + $actions->add(new OrderShipAction()); + } elseif ($order->ship_status == OrderShipStatusEnum::DELIVERED) { + + $actions->add(new OrderReceivedAction()); + } + + } + + $actions->disableEdit(); + }); + + $grid->filter(function (Filter $filter) { + + $filter->disableIdFilter(); + $filter->like('no', '流水号'); + $filter->where(function ($query) { + + $users = User::query()->where('name', 'like', "%{$this->input}%")->pluck('id'); + $query->whereIn('user_id', $users->all()); + }, '用户'); + }); + + return $grid; + } + + /** + * Make a show builder. + * + * @param mixed $id + * @return Show + */ + protected function detail($id) + { + $show = new Show(Order::query()->withTrashed()->findOrFail($id)); + + $show->field('id'); + $show->field('no', '流水号'); + $show->field('user', '用户')->as(function ($user) { + return optional($user)->name; + }); + + + $show->divider(); + + $show->field('amount', '总计'); + $show->field('status', '状态')->as(function ($status) { + + // 如果订单是付款, 那么就修改为物流状态 + if ($status == OrderStatusEnum::PAID) { + + return OrderShipStatusTransform::trans($this->ship_status); + } + + return OrderStatusTransform::trans($status); + }); + $show->field('type', '订单类型')->as(function ($type) { + + return OrderTypeTransform::trans($type); + }); + + $show->divider(); + + $show->field('express_company', '物流公司'); + $show->field('express_no', '物流单号'); + + $show->divider(); + + $show->field('consignee_name', '收货人'); + $show->field('consignee_phone', '收货人手机'); + $show->field('consignee_address', '收货地址'); + + $show->divider(); + + $show->field('pay_type', '支付类型')->as(function ($type) { + + return OrderPayTypeTransform::trans($type); + }); + $show->field('refund_reason', '退款理由'); + $show->field('pay_trade_no', '退款单号'); + $show->field('pay_no', '支付单号'); + $show->field('paid_at', '支付时间'); + $show->field('created_at', '创建时间'); + $show->field('updated_at', '修改时间'); + + // 详情 + $show->details('详情', function (Grid $details) { + + $details->column('id'); + $details->column('product.name', '商品名字'); + $details->column('price', '单价'); + $details->column('number', '数量'); + $details->column('is_commented', '是否评论')->display(function ($is) { + + return YesNoTransform::trans($is); + }); + $details->column('total', '小计'); + + $details->disableRowSelector(); + $details->disableCreateButton(); + $details->disableFilter(); + $details->disableActions(); + }); + + return $show; + } + + /** + * 后台删除订单就是真的删除了 + * @param $id + * @return \Illuminate\Http\JsonResponse + */ + public function destroy($id) + { + try { + + DB::transaction(function () use ($id) { + /** + * @var $order Order + */ + $order = Order::withTrashed()->findOrFail($id); + $order->details()->delete(); + $order->forceDelete(); + }); + + $data = [ + 'status' => true, + 'message' => trans('admin.delete_succeeded'), + ]; + } catch (\Throwable $e) { + $data = [ + 'status' => false, + 'message' => trans('admin.delete_failed') . $e->getMessage(), + ]; + } + + return response()->json($data); + } +} diff --git a/app/Admin/Controllers/ProductController.php b/app/Admin/Controllers/ProductController.php new file mode 100644 index 0000000..3eaa888 --- /dev/null +++ b/app/Admin/Controllers/ProductController.php @@ -0,0 +1,249 @@ +header('商品列表') + ->description('') + ->body($this->grid()); + } + + /** + * Show interface. + * + * @param mixed $id + * @param Content $content + * @return Content + */ + public function show($id, Content $content) + { + return $content + ->header('详情') + ->description('') + ->body($this->detail($id)); + } + + /** + * Edit interface. + * + * @param mixed $id + * @param Content $content + * @return Content + */ + public function edit($id, Content $content) + { + return $content + ->header('编辑') + ->description('') + ->body($this->form()->edit($id)); + } + + /** + * Create interface. + * + * @param Content $content + * @return Content + */ + public function create(Content $content) + { + return $content + ->header('添加商品') + ->description('为你的商城添加一个商品') + ->body($this->form()); + } + + /** + * Make a grid builder. + * + * @return Grid + */ + protected function grid() + { + $grid = new Grid(new Product); + + $grid->model()->withTrashed()->latest(); + + $grid->column('id'); + $grid->column('category.title', '商品类别'); + $grid->column('name', '商品名')->display(function ($name) { + return str_limit($name, 30); + }); + $grid->column('thumb', '首图')->image('', 50, 50); + $grid->column('price', '价格')->display(function ($price) { + + return $price . '/' . $this->original_price; + }); + $grid->column('view_count', '浏览次数')->sortable(); + $grid->column('sale_count', '售出数量')->sortable(); + $grid->column('count', '库存量')->sortable(); + $grid->column('deleted_at', '是否上架')->display(function ($isAlive) { + + return YesNoTransform::trans(is_null($isAlive)); + }); + $grid->column('created_at', '创建时间'); + $grid->column('updated_at', '修改时间'); + + + // 查询过滤 + $grid->filter(function (Grid\Filter $filter) { + + $categories = Category::query() + ->orderBy('order') + ->latest() + ->pluck('title', 'id') + ->all(); + + $filter->disableIdFilter(); + $filter->equal('category_id', '分类')->select($categories); + $filter->equal('id', 'ID'); + $filter->equal('uuid', 'UUID'); + $filter->like('name', '商品名字'); + }); + + + // 增加一个上架,下架功能 + $grid->actions(function (Grid\Displayers\DropdownActions $actions) { + + + $actions->disableDelete(); + $actions->add(new ForceDeleteProductAction()); + $actions->add(new DividerAction()); + + $name = is_null($actions->row->deleted_at) ? + "下架": + "上架"; + + $statusAction = new ProductStatusAction(); + $statusAction->setName($name); + + $actions->add($statusAction); + }); + + return $grid; + } + + /** + * Make a show builder. + * + * @param mixed $id + * @return Show + */ + protected function detail($id) + { + $show = new Show(Product::query()->withTrashed()->findOrFail($id)); + + $show->field('id'); + $show->field('category.title', '商品类别'); + $show->field('name', '商品名'); + $show->field('title', '卖点'); + $show->field('thumb', '缩略图')->image(); + $show->field('price', '价格')->as(function ($price) { + return $price . '/' . $this->original_price; + }); + $show->field('view_count', '浏览次数'); + $show->field('sale_count', '售出数量'); + $show->field('count', '库存量'); + $show->field('deleted_at', '是否上架')->as(function ($isAlive) { + return is_null($isAlive) ? '上架' : '下架'; + }); + $show->field('created_at', '创建时间'); + $show->field('updated_at', '修改时间'); + + + return $show; + } + + /** + * Make a form builder. + * + * @return Form + */ + protected function form() + { + $form = new Form(new Product); + + + $form->select('category_id', '类别')->options(Category::selectOrderAll())->rules('required|exists:categories,id'); + $form->text('name', '商品名字')->rules(function (Form $form) { + + $rules = 'required|max:50|unique:products,name'; + if ($id = $form->model()->id) { + $rules .= ',' . $id; + } + + return $rules; + }); + $form->textarea('title', '卖点')->rules('required|max:199'); + $form->currency('price', '销售价')->symbol('$')->rules('required|numeric'); + $form->currency('original_price', '原价')->symbol('$')->rules('required|numeric'); + $form->number('count', '库存量')->rules('required|integer|min:0'); + + $form->image('thumb', '缩略图')->uniqueName()->move('products/thumb')->rules('required'); + $form->multipleImage('pictures', '轮播图')->uniqueName()->move('products/lists'); + + $form->editor('detail.content', '详情')->rules('required'); + + $form->saving(function (Form $form) { + + if (app()->environment('dev')) { + + admin_toastr('开发环境不允许操作', 'error'); + return back()->withInput(); + } + }); + + return $form; + } + + + public function destroy($id) + { + $product = Product::query()->withTrashed()->findOrFail($id); + + if (app()->environment('dev')) { + + admin_toastr('开发环境不允许操作', 'error'); + return back()->withInput(); + } + + if ($product->forceDelete()) { + $data = [ + 'status' => true, + 'message' => trans('admin.delete_succeeded'), + ]; + } else { + $data = [ + 'status' => false, + 'message' => trans('admin.delete_failed'), + ]; + } + + return response()->json($data); + } +} diff --git a/app/Admin/Controllers/ProductLikeController.php b/app/Admin/Controllers/ProductLikeController.php new file mode 100644 index 0000000..bacb031 --- /dev/null +++ b/app/Admin/Controllers/ProductLikeController.php @@ -0,0 +1,59 @@ +model()->latest(); + + $grid->column('user_id', __('User id')); + $grid->column('product_id', __('Product id')); + + $grid->column('user.name', '用户'); + $grid->column('created_at', '收藏时间'); + $grid->column('product.name', '商品'); + $grid->column('product.price', '价格')->display(function ($price) { + + return $price . '/' . $this->product['original_price']; + }); + $grid->column('product.thumb', '首图')->image('', 50, 50); + + + $grid->disableActions(); + $grid->disableCreateButton(); + $grid->disableBatchActions(); + + $grid->filter(function (Grid\Filter $filter) { + + $filter->disableIdFilter(); + $filter->like('user_id', '用户ID'); + $filter->like('product_id', '商品ID'); + $filter->like('user.name', '用户名'); + $filter->equal('product.name', '商品'); + }); + + return $grid; + } +} diff --git a/app/Admin/Controllers/ScoreLogController.php b/app/Admin/Controllers/ScoreLogController.php new file mode 100644 index 0000000..5b46229 --- /dev/null +++ b/app/Admin/Controllers/ScoreLogController.php @@ -0,0 +1,50 @@ +model()->latest(); + + $grid->column('id', __('Id')); + $grid->column('user_id', __('User id')); + $grid->column('user.name', '用户名'); + $grid->column('description', __('Description')); + $grid->column('score', __('Score')); + $grid->column('created_at', __('Created at')); + $grid->column('updated_at', __('Updated at')); + + $grid->disableActions(); + $grid->disableCreateButton(); + + $grid->filter(function (Grid\Filter $filter) { + + $filter->like('user.name', '用户名'); + $filter->equal('score', '积分'); + }); + + return $grid; + } +} diff --git a/app/Admin/Controllers/ScoreRuleController.php b/app/Admin/Controllers/ScoreRuleController.php new file mode 100644 index 0000000..beb2f6c --- /dev/null +++ b/app/Admin/Controllers/ScoreRuleController.php @@ -0,0 +1,227 @@ +header('列表') + ->description(':xxx 是变量模板,建议不要操作') + ->body($this->grid()); + } + + /** + * Show interface. + * + * @param mixed $id + * @param Content $content + * @return Content + */ + public function show($id, Content $content) + { + return $content + ->header('详情') + ->description('') + ->body($this->detail($id)); + } + + /** + * Edit interface. + * + * @param mixed $id + * @param Content $content + * @return Content + */ + public function edit($id, Content $content) + { + return $content + ->header('Edit') + ->description('') + ->body($this->form($id)->edit($id)); + } + + /** + * Create interface. + * + * @param Content $content + * @return Content + */ + public function create(Content $content) + { + return $content + ->header('新建') + ->description('') + ->body($this->form()); + } + + /** + * Make a grid builder. + * + * @return Grid + */ + protected function grid() + { + $grid = new Grid(new ScoreRule); + + $grid->model()->latest(); + + $grid->column('id', 'id'); + $grid->column('description', '描述'); + $grid->column('replace_text', '替换文本'); + $grid->column('score', '积分'); + $grid->column('times', '次数')->display(function ($times) { + + return $times ? $times : ''; + }); + + $grid->column('created_at', '创建时间'); + $grid->column('updated_at', '修改时间'); + + $grid->actions(function (Grid\Displayers\Actions $actions) { + + $rule = $actions->row; + if (! $rule->can_delete) { + $actions->disableDelete(); + } + + }); + + return $grid; + } + + /** + * Make a show builder. + * + * @param mixed $id + * @return Show + */ + protected function detail($id) + { + $show = new Show(ScoreRule::findOrFail($id)); + + $show->field('id', 'id'); + $show->field('description', '描述'); + $show->field('replace_text', '替换文本'); + $show->field('score', '积分'); + $show->field('times', '次数')->as(function ($times) { + + return $times ? $times : ''; + }); + $show->field('created_at', '创建时间'); + $show->field('updated_at', '修改时间'); + + return $show; + } + + /** + * Make a form builder. + * + * @param null $id + * @return Form + */ + protected function form($id = null) + { + $form = new Form(new ScoreRule); + + // 只有新增才能改变类型 + $options = [ScoreRuleIndexEnum::CONTINUE_LOGIN => '连续登录', ScoreRuleIndexEnum::VISITED_PRODUCT => '每日浏览商品']; + + if (is_null($id)) { + + $form->select('index_code', '类型')->options($options)->rules('required'); + } + $form->number('score', '积分'); + + + if (! is_null($id)) { + + // 只有当时连续登录和修改的才有次数 + $scoreRule = ScoreRule::query()->findOrFail($id); + $form->textarea('replace_text', '替换文本'); + $form->textarea('description', '描述'); + + if (array_key_exists($scoreRule->index_code, $options)) { + + $form->number('times', '次数'); + } + + } else { + + $form->number('times', '次数'); + } + + + $form->saving(function (Form $form) { + + // 如果是新建复制模板 + if (! $form->model()->exists) { + + + $rule = ScoreRule::query() + ->where('can_delete', 0) + ->where('index_code', $form->index_code) + ->firstOrFail(); + + $form->model()->description = $rule->description; + $form->model()->replace_text = $rule->replace_text; + } + + }); + + return $form; + } + + /** + * Remove the specified resource from storage. + * + * @param int $id + * @return \Illuminate\Http\Response + * @throws \Exception + */ + public function destroy($id) + { + $rule = ScoreRule::query()->findOrFail($id); + + if (! $rule->can_delete) { + + return response()->json([ + 'status' => false, + 'message' => '这个等级不允许删除', + ]); + } + + if ($rule->delete()) { + $data = [ + 'status' => true, + 'message' => trans('admin.delete_succeeded'), + ]; + } else { + $data = [ + 'status' => false, + 'message' => trans('admin.delete_failed'), + ]; + } + + return response()->json($data); + } +} diff --git a/app/Admin/Controllers/SeckillController.php b/app/Admin/Controllers/SeckillController.php new file mode 100644 index 0000000..bc60c64 --- /dev/null +++ b/app/Admin/Controllers/SeckillController.php @@ -0,0 +1,222 @@ +header('秒杀列表') + ->description('') + ->body($this->grid()); + } + + /** + * Create interface. + * + * @param Content $content + * @return Content + */ + public function create(Content $content) + { + return $content + ->header('新建秒杀') + ->description('') + ->body($this->form()); + } + + /** + * Make a grid builder. + * + * @return Grid + */ + protected function grid() + { + $grid = new Grid(new Seckill); + + $grid->model()->latest(); + + $grid->column('id'); + + $grid->column('product.id', '商品ID'); + $grid->column('product.name', '商品名字')->display(function ($name) { + return str_limit($name, 30); + }); + $grid->column('product.thumb', '商品图')->display(function ($thumb) { + return image($thumb); + }); + + $grid->column('price', '秒杀价'); + $grid->column('number', '秒杀数量'); + $grid->column('start_at', '开始时间'); + $grid->column('end_at', '结束时间'); + $grid->column('rollback_count', '回滚量'); + $grid->column('is_rollback', '是否回滚数量')->display(function ($is) { + return $is ? '是' : '否'; + }); + $grid->column('created_at', '创建时间'); + $grid->column('updated_at', '修改时间'); + + $grid->actions(function (Grid\Displayers\Actions $actions) { + + $actions->disableView(); + $actions->disableEdit(); + }); + + return $grid; + } + + + + /** + * Make a form builder. + * + * @return Form + */ + protected function form() + { + $form = new Form(new Seckill); + + $categories = Category::selectOrderAll(); + $form->select('category_id', '分类') + ->options($categories) + ->load('product_id', admin_url('api/products')); + $form->select('product_id', '秒杀商品'); + + $form->number('price', '秒杀价') + ->default(1); + $form->number('number', '秒杀数量') + ->default(1) + ->help('保证商品的库存数量大于此数量,会从库存中减去'); + + $now = Carbon::now(); + $form->datetime('start_at', '开始时间') + ->default($now->format('Y-m-d H:00:00')); + $form->datetime('end_at', '结束时间') + ->default($now->addHour(2)->format('Y-m-d H:00:00')) + ->rules('required|date|after_or_equal:start_at'); + + return $form; + } + + /** + * @param Request $request + * @return \Illuminate\Http\JsonResponse|\Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector + * @throws \Exception + */ + public function store(Request $request) + { + $number = $request->input('number', 0); + $product = Product::query()->findOrFail($request->input('product_id')); + + if ($number > $product->count) { + + return back()->withInput()->withErrors(['number' => '秒杀数量不能大于库存数量']); + } + + DB::beginTransaction(); + + try { + + $attributes = $request->only(['category_id', 'product_id', 'price', 'number', 'start_at', 'end_at']); + Seckill::create($attributes); + + // 减去库存数量 + $product->decrement('count', $number); + + } catch (\Error $e) { + + DB::rollBack(); + return back()->withInput()->withErrors(['category_id' => $e->getMessage()]); + } + + DB::commit(); + + admin_toastr('添加成功'); + return back(); + } + + /** + * Remove the specified resource from storage. + * + * @param int $id + * @return \Illuminate\Http\Response + * @throws \Exception + */ + public function destroy($id) + { + /** + * @var $seckill Seckill + * @var $product Product + */ + $seckill = Seckill::query()->findOrFail($id); + + + // 如果已经到了开始抢购时间,就不能删除了 + $now = Carbon::now(); + $startTime = Carbon::make($seckill->start_at); + $endTime = Carbon::make($seckill->end_at); + + // 如果正处于抢购的时间,不允许删除 + if ($now->gte($startTime) && $now->lte($endTime)) { + + return response()->json([ + 'status' => false, + 'message' => '秒杀已经开始,不能删除', + ]); + } + + DB::beginTransaction(); + + + try { + + // 先把 redis 数据删除掉 + // 虽然过期会自动清理,但是如果用户是 + // 删除还没有开始的秒杀,只能在这里手动清理 + \Redis::connection()->del([$seckill->getRedisModelKey(), $seckill->getRedisQueueKey()]); + + + $seckill->delete(); + + $data = [ + 'status' => false, + 'message' => trans('admin.delete_succeeded'), + ]; + + } catch (\Exception $e) { + + $data = [ + 'status' => true, + 'message' => trans('admin.delete_failed'), + ]; + DB::rollBack(); + } + + DB::commit(); + return response()->json($data); + } +} diff --git a/app/Admin/Controllers/SettingController.php b/app/Admin/Controllers/SettingController.php new file mode 100644 index 0000000..b9ec991 --- /dev/null +++ b/app/Admin/Controllers/SettingController.php @@ -0,0 +1,99 @@ +header('配置列表') + ->description('') + ->body(function (Row $row) { + + + $settingKeys = $this->getSettingKeys(); + + $settings = []; + foreach ($settingKeys as $key) { + + $settings[$key] = setting(new SettingKeyEnum($key)); + } + + $form = new \Encore\Admin\Widgets\Form($settings); + $form->action(admin_url('settings')); + $form->method('POST'); + $form->hidden('_token', csrf_token()); + + + $form->text(SettingKeyEnum::USER_INIT_PASSWORD, '会员初始密码')->required(); + $form->decimal(SettingKeyEnum::UN_PAY_CANCEL_TIME, '订单自动取消时间')->required()->help('单位:分钟'); + $form->decimal(SettingKeyEnum::POST_AMOUNT, '邮费')->required()->help('设置为 0 免邮'); + + $states = [ + 'on' => ['value' => 1, 'text' => '打开', 'color' => 'success'], + 'off' => ['value' => 0, 'text' => '关闭', 'color' => 'danger'], + ]; + + $seckillStatus = \setting(new SettingKeyEnum(SettingKeyEnum::IS_OPEN_SECKILL)) ? '开启' : '关闭'; + $seckillHelp = "秒杀现在是 {$seckillStatus} 状态"; + $form->switch(SettingKeyEnum::IS_OPEN_SECKILL, '是否开启秒杀')->states($states)->help($seckillHelp); + + $row->column(12, new Box('网站配置', $form)); + }); + } + + public function store(Request $request) + { + // 对秒杀这个特殊的 key 做处理 + $val = strtolower($request->input(SettingKeyEnum::IS_OPEN_SECKILL)) == 'on' ? 1 : 0; + $request->offsetSet(SettingKeyEnum::IS_OPEN_SECKILL, $val); + + $this->validate($request, [ + SettingKeyEnum::USER_INIT_PASSWORD => 'required|string', + SettingKeyEnum::UN_PAY_CANCEL_TIME => 'required|integer|min:0', + SettingKeyEnum::IS_OPEN_SECKILL => 'required|int|in:0,1', + SettingKeyEnum::POST_AMOUNT => 'required|numeric|min:0', + ]); + + + $settingKeys = $this->getSettingKeys(); + foreach ($settingKeys as $key) { + + Setting::query()->where('key', $key)->update(['value' => $request->input($key)]); + } + + admin_success('修改成功'); + return back(); + } + + private function getSettingKeys() + { + return [ + SettingKeyEnum::USER_INIT_PASSWORD, + SettingKeyEnum::UN_PAY_CANCEL_TIME, + SettingKeyEnum::POST_AMOUNT, + SettingKeyEnum::IS_OPEN_SECKILL, + ]; + } +} diff --git a/app/Admin/Controllers/UploadController.php b/app/Admin/Controllers/UploadController.php new file mode 100644 index 0000000..3087669 --- /dev/null +++ b/app/Admin/Controllers/UploadController.php @@ -0,0 +1,39 @@ +setFileInput('pictures') + ->setMaxSize('10M') + ->setExtensions(['jpg', 'jpeg', 'png', 'bmp', 'gif']) + ->validate() + ->storeMulti('upload/editor', compact('disk')); + + $files = collect($files)->map(function ($file) use ($disk) { + return Storage::disk($disk)->url($file); + })->all(); + + + } catch (UploadException $e) { + + return ['errno' => 1, 'msg' => $e->getMessage()]; + } + + return ['errno' => 0, 'data' => $files]; + } +} diff --git a/app/Admin/Controllers/UserController.php b/app/Admin/Controllers/UserController.php new file mode 100644 index 0000000..a99a70c --- /dev/null +++ b/app/Admin/Controllers/UserController.php @@ -0,0 +1,285 @@ +header('会员列表') + ->description('') + ->body($this->grid()); + } + + /** + * Show interface. + * + * @param mixed $id + * @param Content $content + * @return Content + */ + public function show($id, Content $content) + { + return $content + ->header('详情') + ->description('') + ->body($this->detail($id)); + } + + /** + * Edit interface. + * + * @param mixed $id + * @param Content $content + * @return Content + */ + public function edit($id, Content $content) + { + return $content + ->header('Edit') + ->description('') + ->body($this->form()->edit($id)); + } + + /** + * Create interface. + * + * @param Content $content + * @return Content + */ + public function create(Content $content) + { + return $content + ->header('新建') + ->description('') + ->body($this->form()); + } + + /** + * Make a grid builder. + * + * @return Grid + */ + protected function grid() + { + $grid = new Grid(new User); + + $levels = Level::query()->orderBy('min_score', 'desc')->get(); + + // 排序最新的 + $grid->model()->latest(); + + $grid->column('id', 'Id'); + $grid->column('name', '用户名'); + $grid->column('sex', '性别')->display(function ($sex) { + + return UserSexTransform::trans($sex); + }); + $grid->column('email', '邮箱')->display(function ($email) { + return str_limit($email, 20); + }); + $grid->column('avatar', '头像')->image('', 50, 50); + $grid->column('github_name', 'Github'); + $grid->column('qq_name', 'QQ'); + $grid->column('weibo_name', '微博'); + $grid->column('level', '等级')->display(function () use ($levels) { + + $level = $levels->where('min_score', '<=', $this->score_all)->first(); + return optional($level)->name; + }); + $grid->column('score_all', '总积分')->sortable(); + $grid->column('score_now', '剩余积分')->sortable(); + $grid->column('login_ip', '登录地址'); + $grid->column('login_count', '登录次数')->sortable(); + $grid->column('is_active', '是否激活')->display(function ($isActive) { + + return YesNoTransform::trans($isActive); + }); + $grid->column('created_at', '创建时间'); + $grid->column('updated_at', '修改时间'); + + $grid->actions(function (Grid\Displayers\DropdownActions $actions) { + + if (! $actions->row->is_active) { + + $actions->add(new ActiveUserAction()); + } + }); + + // 筛选功能 + $levelOptions = $levels->pluck('name', 'id'); + $grid->filter(function (Filter $filter) use ($levelOptions) { + $filter->disableIdFilter(); + $filter->like('name', '用户名'); + $filter->like('email', '邮箱'); + + $filter->where(function ($query) { + + // 找到这个等级 + $level = Level::query()->findOrFail($this->input); + // 找到下一个等级 + $high = Level::query()->where('min_score', '>', $level->min_score)->orderBy('min_score', 'asc')->first(); + + + $query->where('score_all', '>=', $level->min_score); + if (! is_null($high)) { + $query->where('score_all', '<', $high->min_score); + } + + }, '等级')->select($levelOptions); + }); + + return $grid; + } + + /** + * Make a show builder. + * + * @param mixed $id + * @return Show + */ + protected function detail($id) + { + $show = new Show(User::findOrFail($id)); + + + $show->field('id', 'Id'); + $show->field('name', '用户名'); + $show->field('sex', '性别')->as(function ($sex) { + + return UserSexTransform::trans($sex); + }); + $show->field('email', '邮箱'); + $show->field('avatar', '头像')->image(); + $show->field('github_name', 'Github昵称'); + $show->field('qq_name', 'QQ昵称'); + $show->field('weibo_name', '微博昵称'); + $show->field('login_ip', '登录地址'); + $show->field('login_count', '登录次数'); + $show->field('is_active', '是否激活')->as(function ($isActive) { + + return YesNoTransform::trans($isActive); + })->unescape(); + $show->field('created_at', '创建时间'); + $show->field('updated_at', '修改时间'); + + $show->addresses('收货地址', function (Grid $grid) { + + $grid->model()->latest(); + $grid->column('name', '收货人'); + $grid->column('phone', '收货人联系方式'); + $grid->column('detail_address', '详细地址'); + $grid->column('is_default', '是否默认')->display(function ($is) { + + return YesNoTransform::trans($is); + }); + $grid->column('created_at', '创建时间'); + + $grid->disableActions(); + $grid->disableCreateButton(); + $grid->disableFilter(); + $grid->disableTools(); + $grid->disableRowSelector(); + }); + + $show->scoreLogs('积分', function (Grid $grid) { + + $grid->model()->latest(); + $grid->column('description', '描述'); + $grid->column('score', '积分'); + $grid->column('created_at', '创建时间'); + + $grid->disableActions(); + $grid->disableCreateButton(); + $grid->disableFilter(); + $grid->disableTools(); + $grid->disableRowSelector(); + }); + + return $show; + } + + /** + * Make a form builder. + * + * @return Form + */ + protected function form() + { + // 前台用户注册必须要有这个 token,兼容一下 + $form = new Form(tap(new User, function ($user) { + $user->active_token = str_random(60); + })); + + $form->text('name', '用户名')->rules(function (Form $form) { + $rules = 'required|unique:users,id'; + + // 更新操作 + if (! is_null($id = $form->model()->getKey())) { + $rules .= ",{$id}"; + } + + return $rules; + }); + + $sexOptions = [UserSexEnum::MAN => '男', UserSexEnum::WOMAN => '女']; + $form->select('sex', '性别')->rules(['required', Rule::in(array_keys($sexOptions))])->options($sexOptions)->default(1); + $form->email('email', '邮箱')->rules(function (Form $form) { + $rules = 'required|email|unique:users,email'; + + // 更新操作 + if (! is_null($id = $form->model()->getKey())) { + $rules .= ",{$id}"; + } + + return $rules; + }); + // dd(windows_os()); + $form->password('password', '密码'); + $avatar = $form->image('avatar', '头像')->uniqueName()->move('avatars'); + + if (! windows_os()) { + $avatar->resize(160, 160);; + } + + $form->switch('is_active', '激活'); + + // 加密密码 + $form->saving(function (Form $form) { + + if ($form->password) { + $form->password = bcrypt($form->password); + } else { + $form->password = $form->model()->password; + } + + }); + + return $form; + } +} diff --git a/app/Admin/Extensions/Div.php b/app/Admin/Extensions/Div.php new file mode 100644 index 0000000..1841d52 --- /dev/null +++ b/app/Admin/Extensions/Div.php @@ -0,0 +1,44 @@ +id = $id; + + if (! is_null($width)) { + $this->width = $width; + } + + if (! is_null($height)) { + $this->height = $height; + } + } + + + public function render() + { + return <<
    +div; + + } +} diff --git a/app/Admin/Extensions/WangEditor.php b/app/Admin/Extensions/WangEditor.php new file mode 100644 index 0000000..18a99a4 --- /dev/null +++ b/app/Admin/Extensions/WangEditor.php @@ -0,0 +1,56 @@ +formatName($this->column); + $token = csrf_token(); + $url = admin_base_path('upload/editor'); + + $this->script = <<id}'); +editor.customConfig.zIndex = 0 +editor.customConfig.uploadFileName = 'pictures[]' +// 配置服务器端地址 +editor.customConfig.uploadImgServer = '{$url}' +editor.customConfig.uploadImgParams = { + _token: '{$token}' +} +// 文件改变添加内容到隐藏域 +editor.customConfig.onchange = function (html) { + $('input[name=\'$name\']').val(html); +} +// 监听上传错误 +editor.customConfig.uploadImgHooks = { + fail: function (xhr, editor) { + var response = $.parseJSON(xhr.response); + + alert(response.msg); + } +} +editor.create() + +EOT; + return parent::render(); + } +} diff --git a/app/Admin/Transforms/OrderPayTypeTransform.php b/app/Admin/Transforms/OrderPayTypeTransform.php new file mode 100644 index 0000000..fc5db64 --- /dev/null +++ b/app/Admin/Transforms/OrderPayTypeTransform.php @@ -0,0 +1,26 @@ +" + : ""; + } +} diff --git a/app/Admin/bootstrap.php b/app/Admin/bootstrap.php new file mode 100644 index 0000000..aec7fe9 --- /dev/null +++ b/app/Admin/bootstrap.php @@ -0,0 +1,8 @@ + config('admin.route.prefix'), + 'namespace' => config('admin.route.namespace'), + 'middleware' => config('admin.route.middleware'), +], function (Router $router) { + + $router->fallback('HomeController@noFound'); + $router->get('/', 'HomeController@index'); + + // 覆盖默认的用户管理 + $router->get('auth/users', 'AdminController@index'); + // 覆盖默认的操作日志 + $router->get('auth/logs', 'AdminController@indexLogs'); + + // 系统的配置 + $router->resource('settings', 'SettingController')->only('index', 'store'); + + // 商品上架下架 + $router->get('products/{id}/push', 'ProductController@pushProduct'); + + // 分类 + // 商品 + // 秒杀的商品管理 + $router->resource('categories', 'CategoryController'); + $router->resource('products', 'ProductController'); + $router->resource('seckills', 'SeckillController')->only('index', 'create', 'store', 'destroy'); + + // 商品发货 + // 管理员帮忙确认收货 + $router->post('orders/{order}/ship', 'OrderController@ship'); + $router->patch('orders/{order}/shipped', 'OrderController@confirmShip'); + + // 退款 + // 订单 + // 评论 + $router->get('orders/{order}/refund', 'OrderController@refund'); + $router->resource('orders', 'OrderController'); + $router->resource('comments', 'CommentController'); + + // 会员管理 + $router->resource('users', 'UserController'); + + // 积分日志 + $router->get('score_logs', 'ScoreLogController@index'); + // 用户购物车数据 + $router->get('cars', 'CarController@index'); + // 用户收藏数据 + $router->get('user_like_products', 'ProductLikeController@index'); + + + // 积分规则, 积分等级 + $router->resource('score_rules', 'ScoreRuleController'); + $router->resource('levels', 'LevelController'); + + // 优惠券管理 + $router->resource('coupon_templates', 'CouponTemplateController'); + // 优惠券 + $router->resource('coupon_logs', 'CouponLogController')->only('index'); + // 优惠券兑换码 + $router->resource('coupon_codes', 'CouponCodeController')->only('index', 'create', 'store', 'destroy'); + + // 发布文章通知 + $router->resource('article_notifications', 'ArticleNotificationController')->only('index', 'create', 'store', 'show', 'destroy'); + + // 富文本图片上传 + $router->post('upload/editor', 'UploadController@uploadByEditor'); + // 通过分类异步加载商品下拉列表 + $router->get('api/products', 'CategoryController@getProducts'); +}); diff --git a/app/Console/Commands/AddShopToEsSearchCommand.php b/app/Console/Commands/AddShopToEsSearchCommand.php new file mode 100644 index 0000000..739bdc5 --- /dev/null +++ b/app/Console/Commands/AddShopToEsSearchCommand.php @@ -0,0 +1,91 @@ +ping([ + 'client' => [ + 'timeout' => 5, + 'connect_timeout' => 5 + ] + ]); + } catch (\Exception $exception) { + + $this->info($exception->getMessage()); + $this->info('无法连接到 elasticsearch 服务器,请配置 config/elasticsearch.php 文件'); + $this->info('默认使用 MySQL 的模糊搜索'); + $this->info('配置完毕后可运行: php artisan add:shop-to-search 添加索引'); + return; + } + + + // 新建商品索引 + if (Product::indexExists()) { + + Product::deleteIndex(); + $this->info('删除索引'); + } + Product::createIndex(); + $this->info('新建索引成功'); + + + // 开始导入数据 + $query = Product::query(); + + $count = $query->count(); + $handle = 0; + + $query->with('category')->chunk(1000, function (Collection $models) use ($count, &$handle) { + + $models->map(function (Product $product) use ($count, &$handle) { + + $product->addToIndex($product->getSearchData()); + + ++ $handle; + echo "\r {$handle}/$count"; + }); + }); + + echo PHP_EOL; + $this->info('索引生成完毕'); + } +} diff --git a/app/Console/Commands/BaseCommand.php b/app/Console/Commands/BaseCommand.php new file mode 100644 index 0000000..900f338 --- /dev/null +++ b/app/Console/Commands/BaseCommand.php @@ -0,0 +1,40 @@ +info('----------'); + $this->info($command); + + $output = shell_exec($command); + + $this->info($output); + } + +} diff --git a/app/Console/Commands/CacheOptimize.php b/app/Console/Commands/CacheOptimize.php new file mode 100644 index 0000000..d447a20 --- /dev/null +++ b/app/Console/Commands/CacheOptimize.php @@ -0,0 +1,44 @@ +call('config:cache'); + // 优化路由 + $this->call('route:cache'); + } +} diff --git a/app/Console/Commands/ClearCache.php b/app/Console/Commands/ClearCache.php new file mode 100644 index 0000000..0820a0d --- /dev/null +++ b/app/Console/Commands/ClearCache.php @@ -0,0 +1,42 @@ +call('config:clear'); + $this->call('route:clear'); + $this->call('view:clear'); + } +} diff --git a/app/Console/Commands/CopyFile.php b/app/Console/Commands/CopyFile.php new file mode 100644 index 0000000..38a15db --- /dev/null +++ b/app/Console/Commands/CopyFile.php @@ -0,0 +1,66 @@ +filesystem = $filesystem; + + parent::__construct(); + } + + /** + * 把静态资源发布到 public/storage 目录 + * + * @return mixed + */ + public function handle() + { + // 图片的静态目录 + $from = storage_path('app/resources/products'); + $to = storage_path('app/public/products'); + $this->filesystem->copyDirectory($from, $to); + + // 默认头像 + $from = storage_path('app/resources/avatars'); + $to = storage_path('app/public/avatars'); + $this->filesystem->copyDirectory($from, $to); + + // 默认头像 + $from = storage_path('app/resources/images'); + $to = storage_path('app/public/images'); + $this->filesystem->copyDirectory($from, $to); + + $this->info('copy file success'); + } +} diff --git a/app/Console/Commands/CountSite.php b/app/Console/Commands/CountSite.php new file mode 100644 index 0000000..ad732de --- /dev/null +++ b/app/Console/Commands/CountSite.php @@ -0,0 +1,59 @@ +subDay(1)->toDateString(); + + /** + * 防止一天运行多次,所以采用增加 + * + * @var $site SiteCount + */ + $site = SiteCount::query()->firstOrNew(compact('date')); + $site = $service->syncByCache($site, true); + $site->save(); + + createSystemLog('系统统计站点数据', $site->toArray()); + } +} diff --git a/app/Console/Commands/DelExpireScoreData.php b/app/Console/Commands/DelExpireScoreData.php new file mode 100644 index 0000000..65e1c4c --- /dev/null +++ b/app/Console/Commands/DelExpireScoreData.php @@ -0,0 +1,53 @@ +subDay()->toDateString(); + + $serve = new ScoreLogServe(); + + Cache::delete($serve->loginKey($yesterday)); + + createSystemLog("系统删除{$yesterday}过期积分统计数据", ['date' => $yesterday]); + } +} diff --git a/app/Console/Commands/DelExpireSecKill.php b/app/Console/Commands/DelExpireSecKill.php new file mode 100644 index 0000000..a7fbf4d --- /dev/null +++ b/app/Console/Commands/DelExpireSecKill.php @@ -0,0 +1,92 @@ +where('is_rollback', 0) + ->where('end_at', '<', Carbon::now()->toDateTimeString()) + ->get() + ->map(function (Seckill $seckill) use ($rollbacks) { + + // 1. 回滚数量到商品 + // 2. 设置为过期 + $product = $seckill->product()->first(); + + + // 获取 redis 数量 + $jsonSeckill = Redis::get($seckill->getRedisModelKey()); + $redisSeckill = json_decode($jsonSeckill, true); + // 获取剩余秒杀量 + $surplus = Redis::llen($seckill->getRedisQueueKey()); + + // 恢复剩余的库存量 + // 恢复库存数量 + if ($redisSeckill['sale_count'] != 0) { + $product->increment('sale_count', $redisSeckill['sale_count']); + } + + if ($surplus != 0) { + $product->increment('count', $surplus); + } + + + // 同步 redis 数据到数据库中 + $seckill->sale_count += $redisSeckill['sale_count']; + $seckill->rollback_count += $surplus; + $seckill->is_rollback = 1; + $seckill->save(); + + $rollbacks->push($seckill); + + // 删除掉秒杀数据 + $ids = Redis::connection()->keys("seckills:{$seckill->id}:*"); + Redis::del($ids); + }); + + if ($rollbacks->isNotEmpty()) { + + createSystemLog('系统回滚秒杀数据', $rollbacks->toArray()); + } + } +} diff --git a/app/Console/Commands/DeleteFile.php b/app/Console/Commands/DeleteFile.php new file mode 100644 index 0000000..9ac5590 --- /dev/null +++ b/app/Console/Commands/DeleteFile.php @@ -0,0 +1,46 @@ +info('delete file success'); + } +} diff --git a/app/Console/Commands/ExportDatabaseCommand.php b/app/Console/Commands/ExportDatabaseCommand.php new file mode 100644 index 0000000..b6aae85 --- /dev/null +++ b/app/Console/Commands/ExportDatabaseCommand.php @@ -0,0 +1,56 @@ +oldest() + ->get() + ->transform(function (User $user) { + + // 先排除自身主键 + $user->offsetUnset($user->getKeyName()); + + return $user; + }); + + file_put_contents(\UsersTableSeeder::DATA_PATH, $users->toJson(JSON_UNESCAPED_UNICODE)); + } +} diff --git a/app/Console/Commands/InstallShop.php b/app/Console/Commands/InstallShop.php new file mode 100644 index 0000000..0a98115 --- /dev/null +++ b/app/Console/Commands/InstallShop.php @@ -0,0 +1,71 @@ +call('key:generate'); + // 删除上一次保留的数据表 + $this->call('migrate:reset'); + // 删除上一次保留的文件 + $this->call('moon:delete'); + + + Product::$addToSearch = false; + + /**************************************** + * 1. 迁移数据表 + * 2. 数据库迁移 + * 3. 复制静态资源 + * 4. 创建软链接 + */ + $this->call('migrate'); + $this->call('db:seed'); + $this->call('moon:copy'); + $this->call('storage:link'); + + // 更新首页数据,防止上一次遗留 + $this->call('moon:update-home'); + + // 生成全文索引 + $this->call('add:shop-to-search'); + + // 直接开启监听队列 + // $this->info('queue starting please don`t close cmd windows!!!'); + // $this->call('queue:work', ['--tries' => '3']); + } +} diff --git a/app/Console/Commands/SendSubscribeEmail.php b/app/Console/Commands/SendSubscribeEmail.php new file mode 100644 index 0000000..136c1c6 --- /dev/null +++ b/app/Console/Commands/SendSubscribeEmail.php @@ -0,0 +1,55 @@ +where('is_subscribe', 1)->pluck('email'); + + $mails->map(function ($realMail) { + + $email = encrypt($realMail); + $url = route('site.email', compact('email')); + // 不要一次 to 多个用户,会暴露其他人的邮箱 + Mail::to($realMail)->send(new SubscribesNotice($url)); + }); + + createSystemLog('系统发送订阅消息, 发送的用户:' . $mails->implode(', '), $mails->toArray()); + } +} diff --git a/app/Console/Commands/SyncProducViewCommand.php b/app/Console/Commands/SyncProducViewCommand.php new file mode 100644 index 0000000..5510965 --- /dev/null +++ b/app/Console/Commands/SyncProducViewCommand.php @@ -0,0 +1,58 @@ +toDateString(); + + $products = Product::query()->where('today_has_view', 1)->get(); + + $products->map(function (Product $product) use ($yesterday) { + + + $viewCount = Cache::pull($product->getViewCountKey($yesterday), 0); + $product->view_count += $viewCount; + $product->today_has_view = 0; + $product->save(); + }); + + createSystemLog("系统同步{$yesterday}商品浏览量", ['date' => $yesterday]); + } +} diff --git a/app/Console/Commands/UninstallShop.php b/app/Console/Commands/UninstallShop.php new file mode 100644 index 0000000..01c6854 --- /dev/null +++ b/app/Console/Commands/UninstallShop.php @@ -0,0 +1,45 @@ +call('moon:clear'); + $this->call('migrate:reset'); + + // delete all upload static resources + $this->call('moon:delete'); + } +} diff --git a/app/Console/Commands/UpdateCacheHomeData.php b/app/Console/Commands/UpdateCacheHomeData.php new file mode 100644 index 0000000..b0dcdea --- /dev/null +++ b/app/Console/Commands/UpdateCacheHomeData.php @@ -0,0 +1,55 @@ +command(SendSubscribeEmail::class)->saturdays()->at('8:00'); + // 每天统计注册人数, 销售数量 + $schedule->command(CountSite::class)->dailyAt('01:00'); + // 每小时执行一次, 回滚秒杀过期的数据 + $schedule->command(DelExpireSecKill::class)->hourly(); + + // 每天夜里十二点执行删除昨天过期的积分数据 + $schedule->command(DelExpireScoreData::class)->dailyAt('00:00'); + // 每天夜里十二点同步商品浏览量 + $schedule->command(SyncProducViewCommand::class)->dailyAt('00:10'); + + // 每分钟更新一次首页数据 + $schedule->command(UpdateCacheHomeData::class)->everyMinute(); + } + + /** + * Register the commands for the application. + * + * @return void + */ + protected function commands() + { + $this->load(__DIR__.'/Commands'); + + require base_path('routes/console.php'); + } +} diff --git a/app/Enums/HomeCacheEnum.php b/app/Enums/HomeCacheEnum.php new file mode 100644 index 0000000..609c975 --- /dev/null +++ b/app/Enums/HomeCacheEnum.php @@ -0,0 +1,17 @@ + + */ + protected $dontFlash = [ + 'current_password', + 'password', + 'password_confirmation', + ]; + + /** + * Register the exception handling callbacks for the application. + */ + public function register(): void + { + $this->reportable(function (Throwable $e) { + // + }); + + $this->renderable(function (\Exception $exception, Request $request) { + if ($request->is('api*')) { + + if ($exception instanceof JWTException) { + + $mapExceptions = [ + TokenInvalidException::class => '无效的token', + TokenBlacklistedException::class => 'token 已被加入黑名单,请重新登录' + ]; + + $msg = $mapExceptions[get_class($exception)] ?? $exception->getMessage(); + return responseJsonAsUnAuthorized($msg); + } + // 拦截表单验证错误抛出的异常 + elseif ($exception instanceof ValidationException) { + + return responseJsonAsBadRequest($exception->validator->errors()->first()); + } + + + return responseJsonAsServerError($exception->getMessage()); + } + + + return responseJsonAsServerError($exception->getMessage(), null); + }); + } +} diff --git a/app/Exceptions/OrderException.php b/app/Exceptions/OrderException.php new file mode 100644 index 0000000..6900b5b --- /dev/null +++ b/app/Exceptions/OrderException.php @@ -0,0 +1,15 @@ +input('username'); + $password = $request->input('password'); + + $user = User::query() + ->whereNotNull('name') + ->where('name', $username) + ->first(); + + if (is_null($user)) { + return responseJsonAsBadRequest('用户名或者密码错误'); + } + + if (! Hash::check($password, $user->getAuthPassword())) { + return responseJsonAsBadRequest('用户名或者密码错误'); + } + + + return responseJson(200, '登录成功', $this->getToken($user)); + } + + /** + * 注销的接口 + * + * @return \Illuminate\Http\JsonResponse + */ + public function logout() + { + auth('api')->logout(); + + return responseJsonAsDeleted('注销成功'); + } + + /** + * 注册的接口 + * + * @param RegisterUserRequest $request + * @return \Illuminate\Http\JsonResponse + */ + public function register(RegisterUserRequest $request) + { + $username = $request->input('username'); + $password = $request->input('password'); + + if (User::query()->where('name', $username)->exists()) { + + return responseJsonAsBadRequest('用户名已经存在, 请换一个用户名'); + } + + $user = new User(); + $user->name = $username; + $user->password = $password; + $user->sex = UserSexEnum::MAN; + $user->is_init_email = 1; + // api 注册的用户默认激活 + $user->is_active = UserStatusEnum::ACTIVE; + $user->save(); + + + return responseJson(201, '注册成功', $this->getToken($user)); + } + + + + /** + * 拼接 token + * + * @param User $user + * @return array + */ + protected function getToken(User $user) + { + // 换取 token + $prefix = 'Bearer'; + $token = auth('api')->login($user); + $me = new OwnResource($user); + + return compact('prefix', 'token', 'me'); + } +} diff --git a/app/Http/Controllers/Api/V1/CategoryController.php b/app/Http/Controllers/Api/V1/CategoryController.php new file mode 100644 index 0000000..35a7ed9 --- /dev/null +++ b/app/Http/Controllers/Api/V1/CategoryController.php @@ -0,0 +1,73 @@ +getPageParameters(); + + $query = Category::query(); + + if ($title = $serve->input('name')) { + + $query->where('title', 'like', "%{$title}%"); + } + + + $count = $query->count(); + $categories = $query->orderBy('order')->limit($limit)->offset($offset)->get(); + $categories = CategoreResource::collection($categories); + + return responseJson(200, 'success', $categories, compact('count')); + } + + + public function getProducts(PageServe $serve, $category) + { + list($limit, $offset) = $serve->getPageParameters(); + + // 排序的字段和排序的值 + $orderField = $serve->input('order_field'); + $orderValue = $serve->input('order_value'); + + /** + * @var $category Category + */ + $category = Category::query()->findOrFail($category); + + $query = $category->products(); + + + if ($name = $serve->input('name')) { + + $query->where('name', 'like', "%{$name}%"); + } + + // 获取排序的字段 + $allFields = ['created_at', 'sale_count', 'view_count']; + $orderField = in_array($orderField, $allFields) ? + $orderField : + array_first($allFields); + $orderValue = $orderValue === 'asc' ? 'asc' : 'desc'; + + + // 获取数据 + $count = $query->count(); + $products = $query->orderBy($orderField, $orderValue) + ->limit($limit) + ->offset($offset) + ->get(); + $products = ProductResource::collection($products); + + return responseJson(200, 'success', $products, compact('count')); + } +} diff --git a/app/Http/Controllers/Api/V1/HomeController.php b/app/Http/Controllers/Api/V1/HomeController.php new file mode 100644 index 0000000..66e1b62 --- /dev/null +++ b/app/Http/Controllers/Api/V1/HomeController.php @@ -0,0 +1,14 @@ +user(); + + return responseJson(200, 'success', new OwnResource($me)); + } + + public function scoreLogs(PageServe $serve) + { + list($limit, $offset) = $serve->getPageParameters(); + /** + * @var $me User + */ + $me = auth()->user(); + + $query = $me->scoreLogs(); + + $count = $query->count(); + $scoreLogs = $me->scoreLogs() + ->latest() + ->offset($offset) + ->limit($limit) + ->get(); + + + return responseJson(200, 'success', ScoreLogResource::collection($scoreLogs), compact('count')); + } +} diff --git a/app/Http/Controllers/Api/V1/ProductController.php b/app/Http/Controllers/Api/V1/ProductController.php new file mode 100644 index 0000000..c3b1d88 --- /dev/null +++ b/app/Http/Controllers/Api/V1/ProductController.php @@ -0,0 +1,25 @@ +where('uuid', $uuid)->firstOrFail(); + $product->load('detail'); + + // 直接使用缓存 + $today = Carbon::today()->toDateString(); + Cache::increment($product->getViewCountKey($today)); + + return responseJson(200, 'success', new ProductResource($product)); + } +} diff --git a/app/Http/Controllers/Auth/AuthLoginController.php b/app/Http/Controllers/Auth/AuthLoginController.php new file mode 100644 index 0000000..0ac5f25 --- /dev/null +++ b/app/Http/Controllers/Auth/AuthLoginController.php @@ -0,0 +1,161 @@ +allow) || ! config()->has("socialite.{$driver}")) { + + abort(403, '未知的第三方登录'); + } + + $socialite = new SocialiteManager(config('socialite'), request()); + + return $socialite->driver($driver)->redirect(); + } + + + /** + * 第三方授权认证回调 + * + * @param $driver + * @return \Illuminate\Contracts\View\Factory|\Illuminate\Http\RedirectResponse|\Illuminate\View\View + */ + public function handleCallback($driver) + { + + if (! in_array($driver, $this->allow) || ! config()->has("socialite.{$driver}")) { + + abort(403, '未知的第三方登录'); + } + + try { + + $socialite = new SocialiteManager(config('socialite'), request()); + $socialiteUser = $socialite->driver($driver)->user(); + } catch (AuthorizeFailedException $e) { + + return view('hint.error', ['status' => $e->getMessage(), 'url' => route('login')]); + } + + /** + * 处理第三方登录用户信息 + * + * @var $user User + */ + $user = $this->findOrCreateMatchUser($socialiteUser); + + // 如果用户已经登录的,作为绑定账号。跳转到个人中心页面 + if (auth()->check()) { + + return redirect('/user/setting')->with('status', '绑定成功'); + } + + // 第三方如果没有登录,那么主动登录 + auth()->login($user, true); + // 登录次数 + $user->increment('login_count'); + + // 如果 session 中有跳转 url,则跳转 + return redirect()->intended(); + } + + /** + * 找到数据库匹配的记录,并存储用户 + * + * @param \Overtrue\Socialite\User $socialiteUser + * @return mixed + */ + protected function findOrCreateMatchUser(\Overtrue\Socialite\User $socialiteUser) + { + // 新建用户 + $driver = strtoupper($socialiteUser->getProviderName()); + $idField = "{$driver}_id"; + $nameField = "{$driver}_name"; + + /** + * 如果是已经登录的用户 + * @var $user User + */ + if ($user = auth()->user()) { + $user->setAttribute($idField, $socialiteUser->getId()) + ->setAttribute($nameField, $socialiteUser->getName()) + ->save(); + + return $user; + } + + // 如果用户没有登录,就是使用第三方账号登录 + // 如果数据库没有记录就创建,有就修改一下显示名 + $user = User::query()->firstOrNew([$idField => $socialiteUser->getId()]); + $user->$nameField = $socialiteUser->getName(); + // 用户的来源 + $sources = UserSourceEnum::toArray(); + $user->source = $sources[$driver] ?? array_first($sources); + + // 如果用户不存在 + if (! $user->exists) { + + if ($socialiteUser->getAvatar()) { + $user->avatar = $socialiteUser->getAvatar(); + } + + // 用户的密码是初始的,可以不用输入旧密码修 + //// 使用第三方登录的用户,默认激活 + $user->is_active = 1; + $user->is_init_name = 1; + $user->is_init_email = 1; + $user->is_init_password = 1; + } + + $user->save(); + + return $user; + } + + + /** + * 解绑第三方账号 + * + * @param $driver + * @param Request $request + * @return \Illuminate\Http\RedirectResponse + */ + public function unBind($driver, Request $request) + { + if (! in_array($driver, $this->allow) || ! config()->has("socialite.{$driver}")) { + + return back()->withErrors(['msg' => '未知的第三方登录']); + } + + + // 可以做更多的判断,如用 QQ 注册的不能解绑之类的 + /** + * @var $user User + */ + $idField = "{$driver}_id"; + $nameField = "{$driver}_name"; + $user = $request->user(); + $user->setAttribute($idField, null)->setAttribute($nameField, null)->save(); + + + return back()->with('status', '解绑成功'); + } +} diff --git a/app/Http/Controllers/Auth/ForgotPasswordController.php b/app/Http/Controllers/Auth/ForgotPasswordController.php new file mode 100644 index 0000000..41fc004 --- /dev/null +++ b/app/Http/Controllers/Auth/ForgotPasswordController.php @@ -0,0 +1,39 @@ +middleware('guest'); + } + + protected function guard() + { + return auth()->guard(); + } + +} diff --git a/app/Http/Controllers/Auth/LoginController.php b/app/Http/Controllers/Auth/LoginController.php new file mode 100644 index 0000000..4b79f19 --- /dev/null +++ b/app/Http/Controllers/Auth/LoginController.php @@ -0,0 +1,167 @@ +middleware('guest')->except('logout'); + } + + + /** + * 登录页面 + * + * @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View + */ + public function showLoginForm() + { + $lastUrl = URL::previous(); + + + // 记录上一次的 url,用于登录之后的回跳 + if (!str_is($this->except, $lastUrl)) { + + session()->put('url.intended', $lastUrl); + } + + return view('auth.login'); + } + + /** + * @param Request $request + * @return \Illuminate\Http\RedirectResponse|\Illuminate\Http\Response|\Illuminate\Routing\Redirector|\Symfony\Component\HttpFoundation\Response|void + * @throws \Illuminate\Validation\ValidationException + */ + public function login(Request $request, UserService $userService) + { + $this->validateLogin($request); + + // 如果超过限制登录次数 + if ($this->hasTooManyLoginAttempts($request)) { + $this->fireLockoutEvent($request); + + $this->sendLockoutResponse($request); + } + + /** + * @var $user User + */ + $credentials = $this->credentials($request); + $user = User::query()->where($credentials)->first(); + + if ($user instanceof User && \Hash::check($request->input('password'), $user->password)) { + + // 如果用户没有激活 + if ($user->is_active == UserStatusEnum::UN_ACTIVE) { + + // 显示 再次发送激活链接 + return redirect('login')->withInput() + ->withErrors([ + $this->username() => $userService->getActiveLink($user) + ]); + } + + // 登录用户 + auth()->login($user, $request->has('remember')); + $user->increment('login_count'); + return $this->sendLoginResponse($request); + } + + // 如果登录尝试不成功,我们将增加数量 + // 登录并将用户重定向到登录表单。当然,当这个 + // 超过最大尝试次数的用户将被锁定。 + $this->incrementLoginAttempts($request); + + return $this->sendFailedLoginResponse($request); + } + + + protected function sendFailedLoginResponse(Request $request) + { + throw ValidationException::withMessages([ + $this->username() => [trans('auth.failed')], + ]); + } + + protected function sendLoginResponse(Request $request) + { + $request->session()->regenerate(); + + $this->clearLoginAttempts($request); + + return redirect()->intended($this->redirectPath()); + } + /** + * 登录使用用户名还是邮箱 + * @param Request $request + * @return array + */ + protected function credentials(Request $request) + { + $input = $request->input($this->username()); + $field = filter_var($input, FILTER_VALIDATE_EMAIL) ? 'email' : 'name'; + + return [ + $field => $input, + ]; + } + + protected function validateLogin(Request $request) + { + $this->validate($request, [ + $this->username() => 'required|string', + 'password' => 'required|string', + ], [ + $this->username() . '.required' => '账号不能为空', + $this->username() . '.string' => '账号必须是正确的字符串', + 'password.required' => '密码不能为空' + ]); + } + + /** + * Log the user out of the application. + * + * @param \Illuminate\Http\Request $request + * @return \Illuminate\Http\Response + */ + public function logout(Request $request) + { + auth()->logout(); + + $request->session()->invalidate(); + + return redirect('/'); + } + + protected function username() + { + return 'account'; + } +} diff --git a/app/Http/Controllers/Auth/RegisterController.php b/app/Http/Controllers/Auth/RegisterController.php new file mode 100644 index 0000000..523db3d --- /dev/null +++ b/app/Http/Controllers/Auth/RegisterController.php @@ -0,0 +1,145 @@ +middleware('guest'); + } + + + /** + * 核心注册方法 + * + * @param Request $request + * @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector + * @throws \Illuminate\Validation\ValidationException + */ + public function register(Request $request) + { + $this->validator($request->all())->validate(); + + if (strtolower($request->input('captcha')) != strtolower(session('captcha'))) { + + return redirect('/register')->withErrors(['captcha' => '验证码不正确']); + } + + + event(new Registered($user = $this->create($request->all()))); + + $this->registered($request, $user); + return redirect('/login')->with('status', '注册成功'); + } + + /** + * registered event (send email) + * @param Request $request + * @param $user + */ + protected function registered(Request $request, $user) + { + Mail::to($user->email) + ->queue(new UserRegister($user)); + } + + + protected function validator(array $data) + { + return Validator::make($data, [ + 'name' => 'required|string|max:50|unique:users', + 'email' => 'required|string|email|max:50|unique:users', + 'password' => 'required|string|min:5|confirmed', + 'sex' => ['required', Rule::in([UserSexEnum::MAN, UserSexEnum::WOMAN])], + 'captcha' => 'required', + ], [ + 'name.required' => '用户名不能为空', + 'name.max' => '用户名不能超过50个字符', + 'name.unique' => '用户名已经被占用', + 'email.unique' => '邮箱已经被占用', + 'email.required' => '邮箱不能为空', + 'email.email' => '邮箱格式不正确', + 'password.min' => '密码最少六位数', + 'password.required' => '密码不能为空', + 'password.confirmed' => '两次密码不一致', + 'captcha.required' => '验证码不能为空', + 'sex.in' => '性别错误', + ]); + } + + protected function create(array $data) + { + // email_active, + return User::query()->create([ + 'name' => $data['name'], + 'email' => $data['email'], + 'sex' => $data['sex'], + 'password' => bcrypt($data['password']), + 'active_token' => str_random(60), + 'is_active' => 1, + ]); + + } + + protected function redirectTo() + { + return 'register'; + } + + + /** + * @return string + */ + public function captcha() + { + $builder = (new CaptchaBuilder(4))->build(150, 46); + + session()->put('captcha', $builder->getPhrase()); + + return $builder->get(); + } + + protected function guard() + { + return auth()->guard(); + } +} diff --git a/app/Http/Controllers/Auth/ResetPasswordController.php b/app/Http/Controllers/Auth/ResetPasswordController.php new file mode 100644 index 0000000..4ac611d --- /dev/null +++ b/app/Http/Controllers/Auth/ResetPasswordController.php @@ -0,0 +1,79 @@ +middleware('guest'); + } + + public function validationErrorMessages() + { + return [ + 'token.required' => '重置密码的token不是对应这个邮箱', + 'email.required' => '邮件地址不正确', + 'email.email' => '邮件地址不正确', + 'password.required' => '密码不能为空', + 'password.confirmed' => '两次密码不一致', + 'password.min' => '密码不能少于六位数', + ]; + } + + /** + * rewrite Illuminate\Foundation\Auth\ResetsPasswords::resetPassword not login + * @param $user + * @param $password + */ + public function resetPassword($user, $password) + { + $user->password = Hash::make($password); + $user->setRememberToken(Str::random(60)); + $user->save(); + + // event(new PasswordReset($user)); + // $this->guard()->login($user); + } + + public function redirectTo() + { + return 'password/reset'; + } + + protected function guard() + { + return auth()->guard(); + } +} diff --git a/app/Http/Controllers/Auth/UserController.php b/app/Http/Controllers/Auth/UserController.php new file mode 100644 index 0000000..f5d2d2c --- /dev/null +++ b/app/Http/Controllers/Auth/UserController.php @@ -0,0 +1,39 @@ +where('active_token', $token)->first()) { + $user->is_active = 1; + // 重新生成激活token + $user->active_token = str_random(60); + $user->save(); + + return view('hint.success', ['status' => "{$user->name} 账户激活成功!", 'url' => url('login')]); + } else { + return view('hint.error', ['status' => '无效的token']); + } + } + + public function sendActiveMail($id) + { + if ($user = User::query()->find($id)) { + // again send active link, join queue + Mail::to($user->email) + ->queue(new UserRegister($user)); + + return view('hint.success', ['status' => '发送邮件成功', 'url' => route('login')]); + + } + + return view('hint.error', ['status' => '用户名或者密码错误']); + } +} diff --git a/app/Http/Controllers/CarController.php b/app/Http/Controllers/CarController.php new file mode 100644 index 0000000..d1a0bd5 --- /dev/null +++ b/app/Http/Controllers/CarController.php @@ -0,0 +1,105 @@ +middleware('user.auth')->only('store', 'destroy'); + } + + /** + * 购物车列表 + * + * @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View + */ + public function index() + { + $cars = collect(); + + /** + * @var $user User + */ + if ($user = \auth()->user()) { + // 直接获取当前登录用户的购物车 + $cars = $user->cars()->with('product')->get(); + } + + return view('cars.index', compact('cars')); + } + + /** + * 添加购物车 + * @param Request $request + * @return array + */ + public function store(Request $request) + { + /** + * @var $car Car + * @var $product Product + * @var $user User + */ + $product = Product::query()->where('uuid', $request->input('product_id'))->firstOrFail(); + + $user = auth()->user(); + $car = $user->cars()->firstOrNew([ + 'user_id' => \auth()->id(), + 'product_id' => $product->id + ]); + + + // 如果是同步,则只是赋值,如果是添加购物车则是添加 + $change = 0; + $number = $request->input('number', 1); + + if ($request->input('action') == 'sync') { + + $change = $number - $car->number; + $car->number = $number; + } else { + + $car->number += $number; + } + + if ($car->number > $product->count) { + + return responseJson(403, '库存不足'); + } + + + $car->save(); + + return responseJson(200, '加入购物车成功', compact('change')); + } + + + /** + * @param $id + * @return array + */ + public function destroy($id) + { + try { + /** + * @var $user User + */ + $user = auth()->user(); + $car = $user->cars()->whereKey($id)->firstOrFail(); + $car->delete(); + + } catch (\Exception $e) { + + dd($e); + return responseJson(500, '服务器异常,请稍后再试'); + } + + return responseJson(200, '删除成功'); + } +} diff --git a/app/Http/Controllers/CategoryController.php b/app/Http/Controllers/CategoryController.php new file mode 100644 index 0000000..a20c69c --- /dev/null +++ b/app/Http/Controllers/CategoryController.php @@ -0,0 +1,40 @@ +latest()->paginate(30); + + + return view('categories.index', compact('categories')); + } + + /** + * 分类详情 + * + * @param Request $request + * @param Category $category + * @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View + */ + public function show(Request $request, Category $category) + { + $orderBy = $request->input('orderBy', 'created_at'); + $categoryProducts = $category->products()->withCount('users')->orderBy($orderBy, 'desc')->paginate(10); + + + return view('categories.show', compact('category', 'categoryProducts')); + } +} diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php new file mode 100644 index 0000000..03e02a2 --- /dev/null +++ b/app/Http/Controllers/Controller.php @@ -0,0 +1,13 @@ +input('template_id'); + + /** + * @var $user User + */ + $user = auth()->user(); + if (is_null($user)) { + + return responseJsonAsUnAuthorized('请先登录再领取优惠券'); + } + + $template = CouponTemplate::query()->find($templateId); + if (is_null($template)) { + + return responseJsonAsBadRequest('无效的优惠券'); + } + + // 判断优惠券是否过期 + $today = Carbon::today(); + $endDate = Carbon::make($template->end_date); + if ($today->gt($endDate)) { + + return responseJsonAsBadRequest('优惠券已过期'); + } + + if ($user->score_now < $template->score) { + + return responseJsonAsBadRequest("积分不足{$template->score},请先去获取积分"); + } + + // 这里我会只让每个用户只能领取一张优惠券 + if ($user->coupons()->where('template_id', $templateId)->exists()) { + + return responseJsonAsBadRequest('你已经领取过优惠券了'); + } + + DB::beginTransaction(); + + try { + + // 用户减少积分 + if ($template->score > 0) { + + $user->score_now -= $template->score; + $user->save(); + // 生成积分日志 + $log = new ScoreLog(); + $log->user_id = $user->getKey(); + $log->description = "领取优惠券"; + $log->score = -1 * $template->score; + $log->save(); + } + + // 开始领取优惠券 + $coupon = new UserHasCoupon(); + $coupon->template_id = $template->getKey(); + $coupon->user_id = $user->getKey(); + + $coupon->title = $template->title; + $coupon->amount = $template->amount; + $coupon->full_amount = $template->full_amount; + $coupon->start_date = $template->start_date; + $coupon->end_date = $template->end_date; + $coupon->save(); + + } catch (\Exception $e) { + + DB::rollBack(); + return responseJsonAsServerError('服务器异常,请稍后再试'); + } + + DB::commit(); + + return responseJson(200, '领取成功'); + } +} diff --git a/app/Http/Controllers/CouponTemplateController.php b/app/Http/Controllers/CouponTemplateController.php new file mode 100644 index 0000000..089b599 --- /dev/null +++ b/app/Http/Controllers/CouponTemplateController.php @@ -0,0 +1,28 @@ +toDateString(); + + // 只查询未过期的 + // 标记已经领取过的 + $templates = CouponTemplate::query() + ->withCount(['coupons' => function ($b) { + + $b->where('user_id', auth()->id()); + }]) + ->where('end_date', '>=', $today) + ->get(); + + + return view('coupons.templates', compact('templates')); + } +} diff --git a/app/Http/Controllers/HomeController.php b/app/Http/Controllers/HomeController.php new file mode 100644 index 0000000..bf49e03 --- /dev/null +++ b/app/Http/Controllers/HomeController.php @@ -0,0 +1,67 @@ +user()) { + + $loginUser->load('subscribe'); + } + + // 查询优惠券 + $couponTemplates = HomeCacheDataUtil::couponTemplates(); + + $setting = new SettingKeyEnum(SettingKeyEnum::IS_OPEN_SECKILL); + $isOpenSeckill = setting($setting) == 1; + + return view( + 'homes.index', + compact('categories', 'hotProducts', 'latestProducts', 'users', 'secKills', 'loginUser', 'isOpenSeckill', 'couponTemplates') + ); + } + + + public function unSubscribe($email) + { + + try { + $email = decrypt($email); + } catch (\Exception $e) { + + return view('hint.error', ['status' => '未知的账号']); + } + + Subscribe::query()->where('email', $email)->update(['is_subscribe' => 0]); + return view('hint.success', ['status' => '已取消订阅']); + } +} diff --git a/app/Http/Controllers/PaymentNotificationController.php b/app/Http/Controllers/PaymentNotificationController.php new file mode 100644 index 0000000..137e498 --- /dev/null +++ b/app/Http/Controllers/PaymentNotificationController.php @@ -0,0 +1,95 @@ +config = config('pay.ali'); + } + + + /** + * 后台通知的接口 + * + * @param Request $request + * @return \Symfony\Component\HttpFoundation\Response + */ + public function payNotify(Request $request) + { + $alipay = Pay::alipay($this->config); + + // TODO , 加一个轮询接口配合后台通知修改订单状态 + // 后台异步通知接口有可能会因为网络问题接收不到 + // 使用轮询插接订单状态,如果支付了停止轮询 + try{ + $data = $alipay->verify(); // 是的,验签就这么简单! + + // 验证 app_id + // 可:判断total_amount是否确实为该订单的实际金额(即商户订单创建时的金额); + if ($data->get('app_id') == $this->config['app_id']) { + + // 支付成功 + if ($data->get('trade_status') == 'TRADE_SUCCESS') { + + // 更新订单 + $order = Order::query()->where('no', $data->get('out_trade_no'))->firstOrFail(); + $order->paid_at = $data->get('notify_time'); + $order->pay_no = $data->get('trade_no'); + $order->pay_amount = $data->get('receipt_amount'); + $order->status = OrderStatusEnum::PAID; + $order->pay_type = OrderPayTypeEnum::ALI; + $order->save(); + } + } + + Log::debug('Alipay notify', $data->all()); + } catch (\Exception $e) { + + Log::debug('Alipay notify', $e->getMessage()); + } + + return $alipay->success(); + } + + + /** + * 前台跳转的接口 + * + * @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View + */ + public function payReturn() + { + $latestProducts = Product::query()->latest()->take(9)->get(); + + $order = null; + + try { + + $data = Pay::alipay($this->config)->verify(); + + $order = Order::query()->where('no', $data->get('out_trade_no'))->firstOrFail(); + + } catch (\Exception $e) { + + } + + return view('user.payments.result', compact('order', 'latestProducts')); + } + + +} diff --git a/app/Http/Controllers/ProductController.php b/app/Http/Controllers/ProductController.php new file mode 100644 index 0000000..1a5f867 --- /dev/null +++ b/app/Http/Controllers/ProductController.php @@ -0,0 +1,147 @@ +inRandomOrder()->take(9)->get(['uuid', 'name'])->split(3); + $pinyins = ProductPinYin::query()->orderBy('pinyin')->pluck('pinyin'); + + return view('products.index', compact('products', 'pinyins')); + } + + + /** + * ajax 通过商品首字母查询商品 + * @param $pinyin + * @return mixed + */ + public function getProductsByPinyin($pinyin) + { + $products = Product::query()->where('first_pinyin', $pinyin)->get(['id', 'name'])->split(3); + + return $products; + } + + + /** + * 商品搜索 + * + * @param Request $request + * @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View + */ + public function search(Request $request) + { + $keyword = $request->input('keyword', ''); + $page = abs((int)$request->get('page', 1)); + $limit = (int)$request->get('limit', 20); + $offset = (int) ($page - 1) * $limit; + + // 全文索引 + try { + + $parameters = [ + 'multi_match' => [ + 'query' => $keyword, + 'fields' => ['title', 'body'], + ] + ]; + + $count = Product::searchCount($parameters); + $searchCount = $count['count'] ?? 0; + $searchResult = Product::search($parameters, $limit, $offset); + $filterIds = Collection::make($searchResult['hits']['hits'] ?? [])->pluck('_source.id'); + $models = Product::query()->findMany($filterIds); + + $products = new LengthAwarePaginator($models, $searchCount, $limit, $page); + + } catch (\Exception $e) { + + $products = Product::query()->withCount('users')->where('name', 'like', "%{$keyword}%")->paginate($limit); + } + + return view('products.search', compact('products')); + } + + /** + * 单个商品显示 + * + * @param $uuid + * @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View + */ + public function show($uuid) + { + /** + * @var $user User|null + */ + $product = Product::query()->where('uuid', $uuid)->firstOrFail(); + + + if (! $product->today_has_view) { + $product->today_has_view = true; + $product->save(); + } + // 直接使用缓存 + $today = Carbon::today()->toDateString(); + Cache::increment($product->getViewCountKey($today)); + + + // 商品浏览次数 + 1 + $user = auth()->user(); + + // 同类商品推荐 + $recommendProducts = Product::query() + ->where('category_id', $product->category_id) + ->take(5) + ->get(); + + // 加载出详情,收藏的人数, 评论 + $product->load([ + 'detail', + 'users', + 'comments' => function ($query) { + $query->latest(); + }, + 'comments.user' + ]); + $product->userIsLike = $product->users()->where('id', auth()->id())->exists(); + + // 如果登录返回所有地址列表,如果没有,则返回一个空集合 + if ($user) { + + // 浏览商品增加积分 + (new ScoreLogServe)->visitedProductAddScore($user, $product); + } + + return view('products.show', compact('product', 'recommendProducts')); + } + + /** + * @return \Illuminate\Contracts\Auth\StatefulGuard + */ + protected function guard() + { + return auth()->guard(); + } +} diff --git a/app/Http/Controllers/User/AddressController.php b/app/Http/Controllers/User/AddressController.php new file mode 100644 index 0000000..ba85772 --- /dev/null +++ b/app/Http/Controllers/User/AddressController.php @@ -0,0 +1,121 @@ +user()->addresses; + + // Provincial and municipal regions + $provinces = DB::table('provinces')->get(); + $cities = DB::table('cities')->where('province_id', $provinces->first()->id)->get(); + + return view('user.addresses.index', compact('addresses', 'provinces', 'cities')); + } + + + public function store(AddressRequest $request) + { + $addressesData = $this->getFormatRequest($request); + + // + /** + * @var $user User + */ + $user = auth()->user(); + $user->addresses()->create($addressesData); + + return back()->with('status', '创建成功'); + } + + + public function show(Address $address) + { + return $address; + } + + + public function edit(Address $address) + { + if (auth()->id() != $address->user_id) { + + abort(403, '非法操作'); + } + + $addresses = auth()->user()->addresses; + // Provincial and municipal regions + $provinces = DB::table('provinces')->get(); + $cities = DB::table('cities')->where('province_id', $address->province_id)->get(); + + return view('user.addresses.edit', compact('addresses', 'address', 'provinces', 'cities')); + } + + + public function update(AddressRequest $request, Address $address) + { + if (auth()->id() != $address->user_id) { + + abort(403, '非法操作'); + } + + $addressesData = $this->getFormatRequest($request); + + $address->update($addressesData); + + return back()->with('status', '修改成功'); + } + + public function destroy(Address $address) + { + if (auth()->id() != $address->user_id) { + + return responseJson(400, '非法操作'); + } + + $address->delete(); + + return responseJson(200, '删除成功'); + } + + + public function setDefaultAddress(Address $address) + { + if (auth()->id() != $address->user_id) { + + return responseJson(400, '非法操作'); + } + + Address::query()->where('user_id', $address->user_id)->update(['is_default' => 0]); + $address->is_default = 1; + + if ($address->save()) { + + return responseJson(0, '设置成功'); + } + + return responseJson(400, '请稍后再试!'); + } + + + protected function getFormatRequest(Request $request) + { + return $request->only(['name', 'phone', 'province_id', 'city_id','detail_address']); + } + + + public function getCities(Request $request) + { + return DB::table('cities')->where('province_id', $request->input('province_id'))->get(); + } +} diff --git a/app/Http/Controllers/User/LikesController.php b/app/Http/Controllers/User/LikesController.php new file mode 100644 index 0000000..c386fae --- /dev/null +++ b/app/Http/Controllers/User/LikesController.php @@ -0,0 +1,65 @@ + 1, + 'msg' => '服务器异常,请稍后再试', + ]; + + + public function index() + { + /** + * @var $user User + */ + $user = auth()->user(); + + $likesProducts = $user->products() + ->where('user_id', auth()->id()) + ->withCount('users') + ->latest() + ->paginate(10); + + return view('user.products.likes', compact('likesProducts')); + } + + + public function toggle($uuid) + { + /** + * @var $product Product + */ + $product = Product::query() + ->where('uuid', $uuid) + ->firstOrFail(); + + + $user = auth()->id(); + + if ($product->users()->where('user_id', $user)->exists()) { + + $product->users()->detach($user); + + return response()->json([ + 'code' => 200, + 'msg' => '欢迎下次收藏' + ]); + } + + $product->users()->attach($user); + + return response()->json([ + 'code' => 201, + 'msg' => '收藏成功' + ]); +} +} diff --git a/app/Http/Controllers/User/NotificationController.php b/app/Http/Controllers/User/NotificationController.php new file mode 100644 index 0000000..6fbba21 --- /dev/null +++ b/app/Http/Controllers/User/NotificationController.php @@ -0,0 +1,149 @@ +user(); + + + switch ($request->input('tab', 1)) { + + case 2: + $query = $user->notifications(); + break; + case 3: + $query = $user->readNotifications(); + break; + case 1: + default: + $query = $user->unreadNotifications(); + break; + } + + $notifications = $query->paginate(); + + foreach ($notifications as $notification) { + + $notification->title = NotificationServe::getTitle($notification); + } + + $unreadCount = $user->unreadNotifications()->count(); + $readCount = $user->readNotifications()->count(); + + return view('user.notifications.index', compact('notifications', 'unreadCount', 'readCount')); + } + + public function read($id) + { + /** + * @var $user User + */ + $user = auth()->user(); + + /** + * @var $notification DatabaseNotification + */ + $notification = $user->notifications()->find($id); + if (is_null($notification)) { + + return responseJsonAsBadRequest('无效的通知'); + } + + $notification->markAsRead(); + + return responseJson(); + } + + public function readAll() + { + /** + * @var $user User + */ + $user = auth()->user(); + + + $count = $user->unreadNotifications()->update(['read_at' => Carbon::now()]); + + + return responseJson(200, "本次已读{$count}条消息"); + } + + public function show($id) + { + + /** + * @var $user User + */ + $user = auth()->user(); + + /** + * @var $notification DatabaseNotification + */ + $notification = $user->notifications()->find($id); + if (is_null($notification)) { + + return abort(403, '无效的通知'); + } + + // 查看是否有上一条下一条 + $last = $user->notifications()->where('created_at', '<', $notification->created_at)->first(); + $next = $user->notifications()->where('created_at', '>', $notification->created_at)->first(); + // 标记为已读 + $notification->markAsRead(); + + $view = NotificationServe::getView($notification); + if (! view()->exists($view)) { + + abort(404, '未知的的消息'); + } + + $notification->title = NotificationServe::getTitle($notification); + $data = $notification->data; + + return view('user.notifications.show', compact('last', 'next', 'notification', 'view', 'data')); + } + + + public function getUnreadCount() + { + /** + * @var $user User + */ + $user = auth()->user(); + + /** + * @var $notification DatabaseNotification + */ + $count = $user->unreadNotifications()->count(); + + + $title = ''; + $content = ''; + $id = null; + if ($count > 0) { + + $notification = $user->unreadNotifications()->first(); + + // 前端弹窗内容和标题相反显示,所以变量名会有点怪 + $id = $notification->id; + $title = NotificationServe::getContent($notification); + $content = NotificationServe::getTitle($notification); + } + + return responseJson(200, 'success', compact('count', 'title', 'content', 'id')); + } +} diff --git a/app/Http/Controllers/User/OrderController.php b/app/Http/Controllers/User/OrderController.php new file mode 100644 index 0000000..0bbee4d --- /dev/null +++ b/app/Http/Controllers/User/OrderController.php @@ -0,0 +1,326 @@ +getScoreRatio(); + + /** + * @var $user User + */ + $user = auth()->user(); + $query = $user->orders(); + + switch (request('tab', 0)) { + + case 0: + break; + case 1: + // 待付款 + $query->where('status', OrderStatusEnum::UN_PAY); + break; + case 2: + // 未发货 + $query->where('status', OrderStatusEnum::PAID)->where('ship_status', OrderShipStatusEnum::PENDING); + break; + case 3: + // 待收货 + $query->where('status', OrderStatusEnum::PAID)->where('ship_status', OrderShipStatusEnum::DELIVERED); + break; + case 4: + // 待评价 + $query->where('status', OrderStatusEnum::PAID)->where('ship_status', OrderShipStatusEnum::RECEIVED); + break; + } + + $orders = $query->latest() + ->with('details', 'details.product') + ->get() + ->map( + function (Order $order) use ($scoreRatio) { + + // 可以或得到的积分 + $order->score = ceil($order->amount*$scoreRatio); + + // 完成按钮必须是已经支付和确认收货 + $order->status_text = OrderStatusTransform::trans($order->status); + // 如果订单是付款了则显示发货状态 + if ($order->status == OrderStatusEnum::PAID) { + + // 如果发货了,则显示发货信息 + $order->status_text = OrderShipStatusTransform::trans($order->ship_status); + } + + $buttonServe = new OrderStatusButtonServe($order); + switch ($order->status) { + // 未支付的 + case OrderStatusEnum::UN_PAY: + + $buttonServe->payButton()->cancelOrderButton(); + break; + case OrderStatusEnum::PAID: + // 已经确认收获了 + if ($order->ship_status == OrderShipStatusEnum::RECEIVED) { + + $buttonServe->completeButton(); + } elseif ($order->ship_status == OrderShipStatusEnum::DELIVERED) { + + $buttonServe->shipButton(); + } else { + + $buttonServe->refundButton(); + } + break; + + // 手动取消的订单 + // 已经完成的订单 + // 超时取消的订单 + case OrderStatusEnum::UN_PAY_CANCEL: + case OrderStatusEnum::COMPLETED: + case OrderStatusEnum::TIMEOUT_CANCEL: + $buttonServe->replyBuyButton()->deleteButton(); + break; + } + + $order->buttons = $buttonServe->getButtons(); + + return $order; + } + ); + + + // 查询订单总量 + $unPayCount = $user->orders()->where('status', OrderStatusEnum::UN_PAY)->count(); + $shipPendingCount = $user->orders()->where('status', OrderStatusEnum::PAID)->where('ship_status', OrderShipStatusEnum::PENDING)->count(); + $shipDeliveredCount = $user->orders()->where('status', OrderStatusEnum::PAID)->where('ship_status', OrderShipStatusEnum::DELIVERED)->count(); + $shipReceivedCount = $user->orders()->where('status', OrderStatusEnum::PAID)->where('ship_status', OrderShipStatusEnum::RECEIVED)->count(); + $ordersCount = $user->orders()->count(); + + return view('user.orders.index', compact('orders', 'unPayCount', 'shipPendingCount', 'shipDeliveredCount', 'shipReceivedCount', 'ordersCount')); + } + + + + + public function show(Order $order) + { + if ($order->isNotUser(auth()->id())) { + abort(403, '你没有权限'); + } + + $order->ship_send = $order->ship_status == OrderShipStatusEnum::DELIVERED; + $order->confirm_ship = $order->ship_status == OrderShipStatusEnum::RECEIVED; + + if ($order->confirm_ship) { + + $order->ship_send = true; + } + + $order->completed = $order->status == OrderShipStatusEnum::RECEIVED; + + return view('user.orders.show', compact('order')); + } + + + public function completeOrder(Order $order, Request $request) + { + $star = intval($request->input('star')); + $content = $request->input('content'); + if ($star < 0 || $star > 5) { + + return responseJsonAsBadRequest('无效的评分'); + } + + if (empty($content)) { + + return responseJsonAsBadRequest('请至少些一些内容吧'); + } + + // 判断是当前用户的订单才可以删除 + $user = auth()->user(); + if ($order->isNotUser($user->id)) { + + return responseJsonAsBadRequest('你没有权限'); + } + + // 只有付完款的订单,而且必须是未完成的, 确认收货 + if ( + // 必须是已经付款,且已经确认收货的 + ! ($order->status == OrderStatusEnum::PAID && $order->ship_status == OrderShipStatusEnum::RECEIVED) + ) { + return responseJsonAsBadRequest('订单当前状态不能完成'); + } + + + $orderDetails = $order->details()->get(); + $comments = $orderDetails->map(function (OrderDetail $orderDetail) use ($user, $content, $star) { + + return [ + 'order_id' => $orderDetail->order_id, + 'order_detail_id' => $orderDetail->id, + 'product_id' => $orderDetail->product_id, + 'user_id' => $user->id, + 'score' => $star, + 'content' => $content, + ]; + }); + + DB::beginTransaction(); + + try { + + // 订单完成 + $order->status = OrderStatusEnum::COMPLETED; + $order->save(); + + // 评论内容 + Comment::query()->insert($comments->all()); + + OrderDetail::query()->where('order_id', $order->id)->update(['is_commented' => true]); + + // 完成订单增加积分 + (new ScoreLogServe)->completeOrderAddScore($order); + + DB::commit(); + } catch (\Exception $e) { + + return responseJsonAsServerError('服务器异常,请稍后再试'); + } + + + return responseJson(200, '完成订单已增加积分'); + } + + + public function confirmShip(Order $order) + { + // 判断是当前用户的订单才可以删除 + if ($order->isNotUser(auth()->id())) { + abort(403, '你没有权限'); + } + + if ($order->status != OrderStatusEnum::PAID) { + + return back()->withErrors('订单未付款'); + } + + if ($order->ship_status != OrderShipStatusEnum::DELIVERED) { + + return back()->withErrors('订单未发货'); + } + + $order->ship_status = OrderShipStatusEnum::RECEIVED; + $order->save(); + + return back()->with('status', '收货成功'); + } + + + public function cancelOrder(Order $order) + { + // 判断是当前用户的订单才可以删除 + if ($order->isNotUser(auth()->id())) { + abort(403, '你没有权限'); + } + + if ($order->status != OrderStatusEnum::UN_PAY) { + + return back()->withErrors('未付款的订单才能取消'); + } + + + + $pay = Pay::alipay(config('pay.ali')); + + try { + $orderData = [ + 'out_trade_no' => $order->no, + ]; + $result = $pay->cancel($orderData); + + $order->status = OrderStatusEnum::UN_PAY_CANCEL; + $order->save(); + + } catch (\Exception $e) { + + return back()->withErrors('服务器异常,请稍后再试'); + } + + + return back()->with('status', '取消成功'); + } + + /** + * 获取积分和钱的换比例 + * + * @return mixed + */ + protected function getScoreRatio() + { + $scoreRule = ScoreRule::query()->where('index_code', ScoreRuleIndexEnum::COMPLETE_ORDER)->firstOrFail(); + + return $scoreRule->score ?? 1; + } + + + /** + * 地址是否存在 + * + * @param $address + * @return bool + */ + protected function hasAddress($address) + { + return Address::query() + ->where('user_id', auth()->id()) + ->where('id', $address) + ->exists(); + } + + + public function destroy($id) + { + /** + * @var $order Order + */ + $order = Order::query()->findOrFail($id); + // 判断是当前用户的订单才可以删除 + if ($order->isNotUser(auth()->id())) { + abort(403, '你没有权限'); + } + + // 支付的订单不能删除 + if (! in_array($order->status, [OrderStatusEnum::UN_PAY_CANCEL, OrderStatusEnum::TIMEOUT_CANCEL, OrderStatusEnum::COMPLETED])) { + + abort(403, '订单不能删除'); + } + + $order->delete(); + + return back()->with('status', '删除成功'); + } + +} diff --git a/app/Http/Controllers/User/PaymentController.php b/app/Http/Controllers/User/PaymentController.php new file mode 100644 index 0000000..522f56f --- /dev/null +++ b/app/Http/Controllers/User/PaymentController.php @@ -0,0 +1,67 @@ +findOrFail($id); + + // 生成支付信息 + return $this->buildPayForm($masterOrder, (new Agent)->isMobile()); + } + + /** + * 生成支付订单 + * + * @param Order $order + * @param $isMobile + * @return \Symfony\Component\HttpFoundation\Response + */ + protected function buildPayForm(Order $order, $isMobile) + { + // 创建订单 + $order = [ + 'out_trade_no' => $order->no, + 'total_amount' => $order->amount, + 'subject' => $order->name, + ]; + + $pay = Pay::alipay(config('pay.ali')); + + if ($isMobile) { + + return $pay->wap($order); + } + + return $pay->web($order); + } + + +} diff --git a/app/Http/Controllers/User/RefundController.php b/app/Http/Controllers/User/RefundController.php new file mode 100644 index 0000000..f6542a6 --- /dev/null +++ b/app/Http/Controllers/User/RefundController.php @@ -0,0 +1,44 @@ +isNotUser(auth()->id())) { + + return responseJson(403, '不是自己的订单'); + } + + // 订单必须是已经支付,而且还没有发货的 + if ($order->status != OrderStatusEnum::PAID) { + + return responseJson(403, '订单还没有付款'); + } + + if ($order->ship_status != OrderShipStatusEnum::PENDING) { + + return responseJson(403, '订单已经发货'); + } + + // 保存退款理由 + $order->status = OrderStatusEnum::APPLY_REFUND; + $order->refund_reason = $request->input('refund_reason'); + $order->save(); + + return responseJson(200, '申请已提交,等待后台管理员审核'); + } +} diff --git a/app/Http/Controllers/User/SeckillController.php b/app/Http/Controllers/User/SeckillController.php new file mode 100644 index 0000000..c169083 --- /dev/null +++ b/app/Http/Controllers/User/SeckillController.php @@ -0,0 +1,235 @@ +getSeckill($seckill); + + $product = $redisSeckill->product; + + /** + * @var $user User + * 如果登录返回所有地址列表,如果没有,则返回一个空集合 + */ + $addresses = collect(); + if ($user = auth()->user()) { + + $addresses = $user->addresses()->get(); + } + + return view('seckills.show', compact('redisSeckill', 'product', 'addresses')); + } + + /** + * 抢购秒杀 + * + * @param Request $request + * @param $id + * @return \Illuminate\Http\JsonResponse + * @throws \Exception + */ + public function storeSeckill(Request $request, $id) + { + /** + * 直接从 session 中读取 id,不经过数据库 + * + * @var $user User + * @var $auth SessionGuard + */ + $seckill = new Seckill(compact('id')); + $auth = auth('web'); + $userId = session()->get($auth->getName()); + + try { + + if (! $request->has('address_id')) { + + throw new \Exception('必须选择一个地址'); + } + + // 验证是否有这个秒杀 + // 验证秒杀活动是否已经结束 + $redisSeckill = $this->redisSeckill = $this->getSeckill($seckill); + + if (! $redisSeckill->is_start) { + throw new \Exception('秒杀未开始'); + } + + } catch (\Exception $e) { + + return responseJson(402, $e->getMessage()); + } + +// // 返回 0,代表之前已经设置过了,代表已经抢过 +// if (0 == Redis::hset($seckill->getUsersKey($userId), 'id', $userId)) { +// +// return responseJson(403, '你已经抢购过了'); +// } + + // 开始抢购逻辑,如果从队列中读取不到了,代表已经抢购完成 + if (is_null(Redis::lpop($seckill->getRedisQueueKey()))) { + + return responseJson(403, '已经抢购完了'); + } + + + DB::beginTransaction(); + + try { + + $product = $redisSeckill->product; + if (is_null($product)) { + return responseJson(400, '商品已下架'); + } + + // 已经通过抢购请求,可以查询数据库 + // 在这里验证一下地址是不是本人的 + $user = auth()->user(); + $address = Address::query()->where('user_id', $user->id)->find($request->input('address_id')); + + if (is_null($address)) { + return responseJson(400, '无效的收货地址'); + } + + // 创建一个秒杀主表订单和明细表订单,默认数量一个 + $masterOrder = ($orderUtil = new OrderUtil([['product' => $product]]))->make($user->id, $address); + $masterOrder->type = OrderTypeEnum::SEC_KILL; + $masterOrder->amount = $redisSeckill->price; + $masterOrder->save(); + + // 创建订单明细 + $details = $orderUtil->getDetails(); + data_set($details, '*.order_id', $masterOrder->id); + OrderDetail::query()->insert($details); + + + // 当订单超过三十分钟未付款,自动取消订单 + $setting = new SettingKeyEnum(SettingKeyEnum::UN_PAY_CANCEL_TIME); + $delay = Carbon::now()->addMinute(setting($setting, 30)); + CancelUnPayOrder::dispatch($masterOrder)->delay($delay); + + // 生成支付信息 + $form = $this->buildPayForm($masterOrder, (new Agent)->isMobile())->getContent(); + + } catch (\Exception $e) { + + DB::rollBack(); + + // 回滚一个秒杀数量 + Redis::lpush($seckill->getRedisQueueKey(), 9); + // 把当前用户踢出,给他继续抢购 + Redis::del($seckill->getUsersKey($userId)); + + return responseJson(403, $e->getMessage()); + } + + DB::commit(); + + // 数量减 - + $redisSeckill->sale_count += 1; + $redisSeckill->number -= 1; + Redis::set($seckill->getRedisModelKey(), json_encode($redisSeckill)); + // 存储抢购成功的用户名 + $user = auth()->user(); + Redis::hset($seckill->getUsersKey($userId), 'name', $user->hidden_name); + + + + return responseJson(200, '抢购成功', compact('form')); + } + + public function getSeckillUsers($id) + { + $seckill = new Seckill(compact('id')); + $keys = Redis::keys($seckill->getUsersKey('*')); + + $users = collect(); + foreach ($keys as $key) { + + $users->push(Redis::hget($key, 'name')); + } + + return responseJson(200, 'success', $users); + } + + /** + * 从 redis 中获取秒杀的数据 + * + * @param Seckill $seckill + * @return mixed + */ + protected function getSeckill(Seckill $seckill) + { + /** + * @var $product Product + */ + $json = Redis::get($seckill->getRedisModelKey()); + $redisSeckill = json_decode($json); + + if (is_null($redisSeckill)) { + + abort(403, "没有这个秒杀活动"); + } + + // 得到这些时间 + $now = Carbon::now(); + $endAt = Carbon::make($redisSeckill->end_at); + + if ($now->gt($endAt)) { + + abort(403, "秒杀已经结束"); + } + + // 秒杀是否已经开始 + $startAt = Carbon::make($redisSeckill->start_at); + $redisSeckill->is_start = $now->gt($startAt); + // 开始倒计时 + $redisSeckill->diff_time = $startAt->getTimestamp() - time(); + + return $redisSeckill; + } + + /** + * 重写父类的构建订单明细 + * + * @param Product $product + * @param $number + * @return array + */ + protected function buildOrderDetail(Product $product, $number) + { + $attribute = [ + 'product_id' => $product->id, + 'number' => $number + ]; + + // 价格为秒杀的价格, 直接从 redis 中读取 + $attribute['price'] = ceilTwoPrice($this->redisSeckill->price); + $attribute['total'] = ceilTwoPrice($attribute['price'] * $attribute['number']); + + return $attribute; + } +} diff --git a/app/Http/Controllers/User/StoreOrderController.php b/app/Http/Controllers/User/StoreOrderController.php new file mode 100644 index 0000000..66c2da4 --- /dev/null +++ b/app/Http/Controllers/User/StoreOrderController.php @@ -0,0 +1,218 @@ +user(); + + + $cars = $request->input('cars', []); + $ids = $request->input('ids'); + $numbers = $request->input('numbers'); + + if (count($ids) === 0 || count($ids) !== count($numbers)) { + + return back()->with('error', '无效的商品'); + } + + $products = Product::query()->whereIn('uuid', $ids)->get(); + if ($products->count() === 0 || $products->count() !== count($numbers)) { + + return back()->with('error', '无效的商品'); + } + + $totalAmount = 0; + $products->transform(function (Product $product, $i) use ($numbers, &$totalAmount) { + + $product->number = $numbers[$i]; + $product->total_amount = round($product->price * $product->number); + $totalAmount += $product->total_amount; + + return $product; + }); + + // 增加邮费 + $postAmount = \setting(new SettingKeyEnum(SettingKeyEnum::POST_AMOUNT)); + $totalAmount += $postAmount; + + /** + * @var $user User + */ + $user = auth()->user(); + $addresses = $user->addresses()->latest()->get(); + + // 可用的优惠券 + $today = Carbon::today()->toDateString(); + $coupons = $user->coupons() + ->where('start_date', '<=', $today) + ->where('end_date', '>=', $today) + ->whereNull('used_at') + ->where('full_amount', '<=', $totalAmount) + ->latest() + ->get(); + + return view('orders.create', compact('products', 'cars', 'addresses', 'totalAmount', 'coupons', 'postAmount')); + } + + public function store(Request $request) + { + if (($response = $this->validateRequest($request)) instanceof Response) { + + return $response; + } + + + list($ids, $numbers, $productMap, $address, $couponModel) = $response; + + // 构建出订单所需的详情 + $detailsData = Collection::make($ids)->map(function ($id, $index) use ($numbers, $productMap) { + + return [ + 'number' => $numbers[$index], + 'product' => $productMap[$id] + ]; + }); + + + DB::beginTransaction(); + + try { + + + $masterOrder = ($orderUtil = new OrderUtil($detailsData))->make(auth()->id(), $address); + + if (! is_null($couponModel) && $masterOrder->amount < $couponModel->full_amount) { + + throw new \Exception('优惠券门槛金额为 ' . $couponModel->full_amount); + } + + // 订单价格等于原价 - 优惠价格 + if (! is_null($couponModel)) { + + $masterOrder->amount = $masterOrder->amount > $couponModel->amount ? + ($masterOrder->amount - $couponModel->amount) : 0.01; + + $couponModel->used_at = Carbon::now()->toDateTimeString(); + $couponModel->save(); + + $masterOrder->coupon_id = $couponModel->id; + $masterOrder->coupon_amount = $couponModel->amount; + } + + $masterOrder->save(); + + // 创建订单明细 + $details = $orderUtil->getDetails(); + data_set($details, '*.order_id', $masterOrder->id); + OrderDetail::query()->insert($details); + + // 如果存在购物车,把购物车删除 + $cars = $request->input('cars'); + if (is_array($cars) && ! empty($cars)) { + + Car::query()->where('user_id', auth()->id())->where('id', $cars)->delete(); + } + + // 当订单超过三十分钟未付款,自动取消订单 + $settingKey = new SettingKeyEnum(SettingKeyEnum::UN_PAY_CANCEL_TIME); + $delay = Carbon::now()->addMinute(setting($settingKey, 30)); + CancelUnPayOrder::dispatch($masterOrder)->delay($delay); + + DB::commit(); + + } catch (\Exception $e) { + + DB::rollBack(); + return responseJsonAsBadRequest($e->getMessage()); + } + + return responseJson(200, '创建订单成功', ['order_id' => $masterOrder->id]); + } + + + + private function validateRequest(Request $request) + { + /** + * @var $user User + * @var $address Address + */ + $user = auth()->user(); + + $ids = $request->input('ids'); + $numbers = $request->input('numbers'); + + if (count($ids) === 0 || count($ids) !== count($numbers)) { + + return responseJsonAsBadRequest('无效的商品'); + } + + $productMap = Product::query()->whereIn('uuid', $ids)->get()->mapWithKeys(function (Product $product) { + + return [$product->uuid => $product]; + }); + if (count($productMap) === 0 || count($productMap) !== count($numbers)) { + + return responseJsonAsBadRequest('无效的商品.'); + } + + + $address = $user->addresses()->find($request->input('address_id')); + if (is_null($address)) { + + return responseJsonAsBadRequest('请选择收货地址'); + } + + + // 查看是否有优惠券的价格 + $couponModel = null; + if ($couponId = $request->input('coupon_id')) { + + $couponModel = $user->coupons()->find($couponId); + if (is_null($couponModel)) { + + return responseJsonAsBadRequest('无效的优惠券'); + } + + $today = Carbon::today(); + $startDate = Carbon::make($couponModel->start_date) ?? Carbon::tomorrow(); + $endDate = Carbon::make($couponModel->end_date) ?? Carbon::yesterday(); + if ($today->lt($startDate) || $today->gt($endDate)) { + + return responseJsonAsBadRequest('优惠券已过使用期'); + } + + if (! is_null($couponModel->used_at)) { + + return responseJsonAsBadRequest('优惠券已使用过'); + } + } + + return [$ids, $numbers, $productMap, $address, $couponModel]; + } +} diff --git a/app/Http/Controllers/User/UserController.php b/app/Http/Controllers/User/UserController.php new file mode 100644 index 0000000..ecc5ce1 --- /dev/null +++ b/app/Http/Controllers/User/UserController.php @@ -0,0 +1,275 @@ +user(); + + // 获取优惠券数量 + $today = Carbon::today()->toDateString(); + $user->coupons_count = $user->coupons()->where('end_date', '>=', $today)->whereNull('used_at')->count(); + $user->cars_count = $user->cars()->sum('number'); + $user->orders_count = $user->orders()->count(); + $user->likeProducts = $user->products()->latest()->take(9)->get(); + $user->notifications_count = $user->unreadNotifications()->count(); + $user->addresses_count = $user->addresses()->count(); + $user->like_products_count = $user->products()->count(); + + // 查出用户的等级 + $level = Level::query() + ->where('min_score', '<=', $user->score_all) + ->orderBy('min_score', 'desc') + ->first(); + + // 获取所有积分记录 + $scoreLogs = $user->scoreLogs()->latest()->limit(5)->get(); + + return view('user.homes.index', compact('user', 'level', 'scoreLogs')); + } + + + public function indexScores() + { + /** + * @var $user User + */ + $user = $this->user(); + + /** + * 显示所有可能的任务 + * + * @var $rules Collection + */ + $rules = Cache::rememberForever(ScoreRule::CACHE_KEY, function () { + + return ScoreRule::query() + ->whereIn('index_code', ScoreRule::OPEN_RULES) + ->orderBy('index_code') + ->orderBy('score') + ->get(); + }); + + // 连续登录, 浏览商品特殊处理 + $loginDays = $user->login_days; + // 浏览的数量 + $visitedNumber = (new ScoreLogServe)->getUserVisitedNumber(Carbon::today()->toDateString(), $user->id); + + // 如果 completed_times === times 那么代表这个任务完成了 + $rules->transform(function (ScoreRule $rule) use ($loginDays, $visitedNumber) { + + if ($rule->index_code == ScoreRuleIndexEnum::CONTINUE_LOGIN) { + + $rule->completed_times = $loginDays > $rule->times ? $rule->times : $loginDays; + + } elseif ($rule->index_code == ScoreRuleIndexEnum::VISITED_PRODUCT) { + + $rule->completed_times = $visitedNumber > $rule->times ? $rule->times : $visitedNumber; + } + + $rule->plan = ($rule->completed_times / $rule->times) * 100; + + return $rule; + }); + + $logs = $user->scoreLogs()->latest()->paginate(10); + + return view('user.scores.index', compact('user', 'logs', 'rules')); + } + + + public function setting() + { + $user = $this->user(); + + return view('user.users.setting', compact('user')); + } + + + public function update(Request $request) + { + $user = $this->user(); + + $this->validate( + $request, [ + 'avatar' => 'required', + 'sex' => 'in:0,1', + ], [ + 'avatar.required' => '头像不能为空', + 'sex.in' => '性别格式不对', + ] + ); + + // 除了第三方授权登录的用户导致没有名字之外 + // 其他用户是不允许修改用户名和邮箱 + $user->sex = $request->input('sex'); + $user->avatar = $request->input('avatar'); + + + // 如果当前用户第一次修改用户名 + if ($user->is_init_name && $request->filled('name')) { + + $name = $request->input('name'); + + if (User::query()->where('name', $name)->exists()) { + + return back()->withErrors('用户名已经存在'); + } + + $user->name = $name; + $user->is_init_name = false; + } + + // 如果当前用户第一次修改邮箱 + if ($user->is_init_email && $request->filled('email')) { + + $email = $request->input('email'); + + if (User::query()->where('email', $email)->exists()) { + + return back()->withErrors('邮箱已经存在'); + } + + $user->email = $email; + $user->is_init_email = false; + } + + // 初始用户可以修改邮箱 + $user->save(); + + return back()->with('status', '修改成功'); + } + + + public function subscribe(Request $request) + { + $subscribeModel = $this->user()->subscribe()->firstOr(function () { + + return new Subscribe(); + }); + + $subscribeModel->email = $request->input('email'); + // 如果已经存在了记录 + if ($subscribeModel->exists) { + + // 如果是已经有数据的, 代表已经订阅过了 + $subscribeModel->is_subscribe = ! $subscribeModel->is_subscribe; + } else { + $subscribeModel->is_subscribe = 1; + $subscribeModel->user_id = $this->user()->id; + } + $subscribeModel->save(); + + if ($subscribeModel->is_subscribe) { + return responseJson(201, '订阅成功'); + } + + return responseJson(200, '欢迎下次再订阅'); + } + + /** + * 用户上传头像 + * + * @param UploadServe $uploadServe + * @return array + */ + public function uploadAvatar(UploadServe $uploadServe) + { + $disk = 'public'; + + try { + $link = $uploadServe->setFileInput('file') + ->setMaxSize('5M') + ->setExtensions(['jpg', 'jpeg', 'png', 'bmp', 'gif']) + ->validate() + ->store('avatars', compact('disk')); + + } catch (UploadException $e) { + + return [ + 'code' => 302, + 'msg' => $e->getMessage(), + 'data' => [] + ]; + } + + + return [ + 'code' => 0, + 'msg' => '图片上传成功', + 'data' => ['src' => $link, 'link' => \Storage::url($link)] + ]; + } + + + public function showPasswordForm() + { + $user = $this->user(); + + return view('user.users.password', compact('user')); + } + + public function updatePassword(Request $request) + { + $this->validate( + $request, [ + 'password' => 'required|min:6|confirmed', + ], [ + 'old_password.required' => '旧密码不能为空', + 'password.required' => '新密码不能为空', + 'password.min' => '新密码必须大于6位', + 'password.confirmed' => '两次密码不一致', + ] + ); + + $user = $request->user(); + // 如果是从未设置过密码就就不用验证旧密码 + if (! $user->is_init_password && ! $this->validatePassword($request->input('old_password'))) { + return back()->withErrors(['old_password' => '旧密码不正确']); + } + + // 设置过密码之后,再也不是初始密码 + $user->is_init_password = false; + $user->password = Hash::make($request->input('password')); + $user->save(); + + return back()->with('status', '密码修改成功'); + } + + private function validatePassword($oldPassword) + { + return Hash::check($oldPassword, $this->user()->password); + } + + /** + * @return User|null + */ + protected function user() + { + return auth()->user(); + } + + +} diff --git a/app/Http/Controllers/User/UserCouponController.php b/app/Http/Controllers/User/UserCouponController.php new file mode 100644 index 0000000..6444197 --- /dev/null +++ b/app/Http/Controllers/User/UserCouponController.php @@ -0,0 +1,128 @@ +user(); + + $query = $user->coupons(); + + switch ($request->input('tab', 1)) { + + // 可使用的 + case 1: + $today = Carbon::today()->toDateString(); + $query->where('end_date', '>=', $today)->whereNull('used_at'); + break; + // 已使用的 + case 2: + $query->whereNotNull('used_at'); + break; + // 未使用过期的 + case 3: + $today = Carbon::today()->toDateString(); + $query->whereNull('used_at')->where('end_date', '<', $today); + break; + } + + $coupons = $query->latest()->paginate(); + + $today = Carbon::today(); + foreach ($coupons as $coupon) { + + if (! is_null($coupon->used_at)) { + + $coupon->used = true; + $coupon->show_title = '已使用'; + } + // 过期的 + elseif ($today->gt(Carbon::make($coupon->end_date))) { + + $coupon->used = true; + $coupon->show_title = '已过期'; + } else { + + $coupon->used = false; + $coupon->show_title = '去使用'; + } + + } + + return view('user.coupons.index', compact('coupons')); + } + + + public function exchangeCoupon(Request $request) + { + $code = $request->input('code'); + if (strlen($code) !== 16) { + + return responseJsonAsBadRequest('兑换码必须是16位'); + } + + $codeModel = CouponCode::query()->where('code', $code)->first(); + if (is_null($codeModel)) { + + return responseJsonAsBadRequest('无效的兑换码'); + } + + $user = auth()->user(); + if ($codeModel->user_id != $user->id) { + + return responseJsonAsBadRequest('兑换码并非发放给你,请勿使用'); + } + + if (! is_null($codeModel->used_at)) { + + return responseJsonAsBadRequest('兑换码已使用'); + } + + /** + * @var $template CouponTemplate + */ + $template = $codeModel->template()->first(); + if (is_null($template)) { + + return responseJsonAsServerError('兑换码已过期'); + } + + $today = Carbon::today(); + if ($today->gt(Carbon::make($template->end_date))) { + + return responseJsonAsServerError('兑换码已过期'); + } + + + $codeModel->used_at = Carbon::now()->toDateTimeString(); + $codeModel->save(); + + // 开始领取优惠券 + $coupon = new UserHasCoupon(); + $coupon->template_id = $template->getKey(); + $coupon->user_id = $user->id; + + $coupon->title = $template->title; + $coupon->amount = $template->amount; + $coupon->full_amount = $template->full_amount; + $coupon->start_date = $template->start_date; + $coupon->end_date = $template->end_date; + $coupon->save(); + + + return responseJson(200, "兑换{$template->title}成功"); + } +} diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php new file mode 100644 index 0000000..4635332 --- /dev/null +++ b/app/Http/Kernel.php @@ -0,0 +1,86 @@ + [ + \App\Http\Middleware\EncryptCookies::class, + \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class, + \Illuminate\Session\Middleware\StartSession::class, + \Illuminate\View\Middleware\ShareErrorsFromSession::class, + \App\Http\Middleware\VerifyCsrfToken::class, + \Illuminate\Routing\Middleware\SubstituteBindings::class, + + // 用于记录用户连续登录天数的中间件 + 'auth.login.score', + ], + + 'api' => [ + \Illuminate\Routing\Middleware\ThrottleRequests::class.':api', + \Illuminate\Routing\Middleware\SubstituteBindings::class, + ], + ]; + + /** + * The application's route middleware. + * + * These middleware may be assigned to groups or used individually. + * + * @var array + */ + protected $routeMiddleware = [ + 'auth' => \App\Http\Middleware\Authenticate::class, + 'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class, + 'auth.session' => \Illuminate\Session\Middleware\AuthenticateSession::class, + 'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class, + 'can' => \Illuminate\Auth\Middleware\Authorize::class, + 'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class, + 'password.confirm' => \Illuminate\Auth\Middleware\RequirePassword::class, + 'precognitive' => \Illuminate\Foundation\Http\Middleware\HandlePrecognitiveRequests::class, + 'signed' => \App\Http\Middleware\ValidateSignature::class, + 'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class, + 'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class, + 'bindings' => \Illuminate\Routing\Middleware\SubstituteBindings::class, + + // 用户登录验证 + 'user.auth' => UserAuth::class, + // 用户购物车 + 'user.cars' => ShareCarSum::class, + + // api 刷新token + 'auth.api.refresh' => AuthRefreshToken::class, + 'auth.login.score' => RecordUserLoginDays::class, + ]; +} diff --git a/app/Http/Middleware/AuthRefreshToken.php b/app/Http/Middleware/AuthRefreshToken.php new file mode 100644 index 0000000..1752f5a --- /dev/null +++ b/app/Http/Middleware/AuthRefreshToken.php @@ -0,0 +1,75 @@ +auth->parser()->hasToken()) { + + return responseJsonAsUnAuthorized('未在请求中找到 token 验证信息'); + } + + + // 设置过期时间 + try { + /**************************************** + * 尝试通过 tokne 登录,如果正常,就获取到用户 + * 无法正确的登录,抛出 token 异常 + ****************************************/ + if ($this->auth->parseToken()->authenticate()) { + + return $next($request); + } + + return responseJsonAsNoFound('用户信息找不到'); + } + // token 过期之后抛出的异常,尝试刷新 token + catch (TokenExpiredException $e) { + + try { + /**************************************** + * token 过期的异常,尝试刷新 token + * 使用 id 一次性登录以保证此次请求的成功 + ****************************************/ + $token = $this->auth->refresh(); + $id = $this->auth->getPayload()->get('sub'); + auth()->onceUsingId($id); + + // 在响应头中返回新的 token + return $this->setAuthenticationHeader($next($request), $token); + } + // 已经无法刷新了,或者会被直接加入黑名单 + catch (JWTException $e) { + + /**************************************** + * 如果捕获到此异常,即代表 refresh 也过期了, + * 用户无法刷新令牌,需要重新登录。 + ****************************************/ + return responseJsonAsAccountExpired('登录已过期,请重新登录'); + } + } + + // 更多的异常不会捕获, 直接再 Handler.php 处理 + } +} diff --git a/app/Http/Middleware/Authenticate.php b/app/Http/Middleware/Authenticate.php new file mode 100644 index 0000000..d4ef644 --- /dev/null +++ b/app/Http/Middleware/Authenticate.php @@ -0,0 +1,17 @@ +expectsJson() ? null : route('login'); + } +} diff --git a/app/Http/Middleware/EncryptCookies.php b/app/Http/Middleware/EncryptCookies.php new file mode 100644 index 0000000..867695b --- /dev/null +++ b/app/Http/Middleware/EncryptCookies.php @@ -0,0 +1,17 @@ + + */ + protected $except = [ + // + ]; +} diff --git a/app/Http/Middleware/PreventRequestsDuringMaintenance.php b/app/Http/Middleware/PreventRequestsDuringMaintenance.php new file mode 100644 index 0000000..74cbd9a --- /dev/null +++ b/app/Http/Middleware/PreventRequestsDuringMaintenance.php @@ -0,0 +1,17 @@ + + */ + protected $except = [ + // + ]; +} diff --git a/app/Http/Middleware/RecordUserLoginDays.php b/app/Http/Middleware/RecordUserLoginDays.php new file mode 100644 index 0000000..a9ba74b --- /dev/null +++ b/app/Http/Middleware/RecordUserLoginDays.php @@ -0,0 +1,33 @@ +user(); + + if (! is_null($user)) { + + (new ScoreLogServe)->loginAddScore($user); + } + + return $next($request); + } +} diff --git a/app/Http/Middleware/RedirectIfAuthenticated.php b/app/Http/Middleware/RedirectIfAuthenticated.php new file mode 100644 index 0000000..afc78c4 --- /dev/null +++ b/app/Http/Middleware/RedirectIfAuthenticated.php @@ -0,0 +1,30 @@ +check()) { + return redirect(RouteServiceProvider::HOME); + } + } + + return $next($request); + } +} diff --git a/app/Http/Middleware/ShareCarSum.php b/app/Http/Middleware/ShareCarSum.php new file mode 100644 index 0000000..b0ed5da --- /dev/null +++ b/app/Http/Middleware/ShareCarSum.php @@ -0,0 +1,57 @@ +view = $view; + } + + /** + * Handle an incoming request. + * + * @param \Illuminate\Http\Request $request + * @param \Closure $next + * @return mixed + */ + public function handle($request, Closure $next) + { + // 如果用户已经登录,那么就是他的数量,如果用户没有登录,那么就是 0 + $sum = 0; + /** + * @var $user User + */ + if ($user = auth()->user()) { + + $sum = $user->cars()->sum('number'); + } + + + $this->view->share( + 'carSum', $sum + ); + + + return $next($request); + } +} diff --git a/app/Http/Middleware/TrimStrings.php b/app/Http/Middleware/TrimStrings.php new file mode 100644 index 0000000..88cadca --- /dev/null +++ b/app/Http/Middleware/TrimStrings.php @@ -0,0 +1,19 @@ + + */ + protected $except = [ + 'current_password', + 'password', + 'password_confirmation', + ]; +} diff --git a/app/Http/Middleware/TrustHosts.php b/app/Http/Middleware/TrustHosts.php new file mode 100644 index 0000000..c9c58bd --- /dev/null +++ b/app/Http/Middleware/TrustHosts.php @@ -0,0 +1,20 @@ + + */ + public function hosts(): array + { + return [ + $this->allSubdomainsOfApplicationUrl(), + ]; + } +} diff --git a/app/Http/Middleware/TrustProxies.php b/app/Http/Middleware/TrustProxies.php new file mode 100644 index 0000000..3391630 --- /dev/null +++ b/app/Http/Middleware/TrustProxies.php @@ -0,0 +1,28 @@ +|string|null + */ + protected $proxies; + + /** + * The headers that should be used to detect proxies. + * + * @var int + */ + protected $headers = + Request::HEADER_X_FORWARDED_FOR | + Request::HEADER_X_FORWARDED_HOST | + Request::HEADER_X_FORWARDED_PORT | + Request::HEADER_X_FORWARDED_PROTO | + Request::HEADER_X_FORWARDED_AWS_ELB; +} diff --git a/app/Http/Middleware/UserAuth.php b/app/Http/Middleware/UserAuth.php new file mode 100644 index 0000000..a557dfb --- /dev/null +++ b/app/Http/Middleware/UserAuth.php @@ -0,0 +1,28 @@ +check()) { + + return redirect()->guest('login')->with('status', '请登录账号再操作'); + } + + + return $next($request); + } +} diff --git a/app/Http/Middleware/ValidateSignature.php b/app/Http/Middleware/ValidateSignature.php new file mode 100644 index 0000000..093bf64 --- /dev/null +++ b/app/Http/Middleware/ValidateSignature.php @@ -0,0 +1,22 @@ + + */ + protected $except = [ + // 'fbclid', + // 'utm_campaign', + // 'utm_content', + // 'utm_medium', + // 'utm_source', + // 'utm_term', + ]; +} diff --git a/app/Http/Middleware/VerifyCsrfToken.php b/app/Http/Middleware/VerifyCsrfToken.php new file mode 100644 index 0000000..9e86521 --- /dev/null +++ b/app/Http/Middleware/VerifyCsrfToken.php @@ -0,0 +1,17 @@ + + */ + protected $except = [ + // + ]; +} diff --git a/app/Http/Requests/AddressRequest.php b/app/Http/Requests/AddressRequest.php new file mode 100644 index 0000000..bdde90c --- /dev/null +++ b/app/Http/Requests/AddressRequest.php @@ -0,0 +1,45 @@ + 'required', + 'phone'=>'regex:/^1[34578][0-9]{9}$/', + 'province_id' => 'required', + 'city_id' => 'required', + 'detail_address' => 'required', + ]; + } + + public function messages() + { + return [ + 'name.required' => '收货人名字不能为空', + 'phone.regex' => '手机号码格式不正确', + 'province_id.required' => '省区不能为空', + 'city_id.required' => '城市不能为空', + 'detail_address.required' => '详细收货地址不能为空', + ]; + } +} diff --git a/app/Http/Requests/AdminRequest.php b/app/Http/Requests/AdminRequest.php new file mode 100644 index 0000000..05ccad3 --- /dev/null +++ b/app/Http/Requests/AdminRequest.php @@ -0,0 +1,52 @@ + 'required|unique:admins', + 'password' => 'required|min:6|confirmed', + 'roles' => 'required|array' + ]; + + if ($this->method() == 'PUT') { + $rules['name'] = 'required'; + $rules['password'] = ''; + } + + return $rules; + } + + public function messages() + { + return [ + 'name.required' => '管理员名字不能为空', + 'name.unique' => '管理员名字已经存在', + 'password.required' => '密码不能为空', + 'password.min' => '密码最少六位', + 'password.confirmed' => '两次密码不相同', + 'roles.required' => '角色不能为空', + 'roles.array' => '角色不符合规范', + ]; + } +} diff --git a/app/Http/Requests/CategoryRequest.php b/app/Http/Requests/CategoryRequest.php new file mode 100644 index 0000000..6e29644 --- /dev/null +++ b/app/Http/Requests/CategoryRequest.php @@ -0,0 +1,53 @@ + 'required|unique:categories', + 'thumb' => 'required', + 'parent_id' => Rule::notIn(['-1']), + 'description' => 'required|min:10', + ]; + + if ($this->method() == 'PUT') { + $rules['name'] = 'required'; + $rules['thumb'] = ''; + } + + return $rules; + } + + public function messages() + { + return [ + 'name.required' => '分类名称不能为空', + 'name.unique' => '分类名已经存在', + 'thumb.required' => '请选择分类缩略图', + 'parent_id.not_in' => '请选择一个分类', + 'description.required' => '分类描述不能为空', + 'description.min' => '分类描述不能少于10个字', + ]; + } +} diff --git a/app/Http/Requests/LoginRequest.php b/app/Http/Requests/LoginRequest.php new file mode 100644 index 0000000..d2ada4d --- /dev/null +++ b/app/Http/Requests/LoginRequest.php @@ -0,0 +1,31 @@ + 'required', + 'password' => 'required', + ]; + } +} diff --git a/app/Http/Requests/PermissionRequest.php b/app/Http/Requests/PermissionRequest.php new file mode 100644 index 0000000..63169f3 --- /dev/null +++ b/app/Http/Requests/PermissionRequest.php @@ -0,0 +1,44 @@ + 'required|unique:permissions,name' + ]; + + if ($this->method() == 'PUT') { + $rules['name'] = 'required'; + } + + return $rules; + } + + public function messages() + { + return [ + 'name.required' => '权限名称不能为空', + 'name.unique' => '权限已存在' + ]; + } +} diff --git a/app/Http/Requests/ProductRequest.php b/app/Http/Requests/ProductRequest.php new file mode 100644 index 0000000..e84e784 --- /dev/null +++ b/app/Http/Requests/ProductRequest.php @@ -0,0 +1,95 @@ + "required|exists:categories,id", + "name" => "required|unique:products", + "title" => "required|max:50", + "price" => "required|numeric", + "original_price" => "required|numeric", + + // product_details table field + "unit" => 'required', + "count" => 'required|numeric', + "description" => "required|min:10", + + + // attribute table field + "attribute" => 'required|array', + "attribute.*" => 'required', + "items" => 'required|array', + "items.*" => 'required', + "markup" => 'required|array', + "markup.*" => 'required|numeric', + + // product_images table field + "link" => 'required|array', + ]; + + if ($this->method() == 'PUT') { + $rules['name'] = 'required'; + } + + + return $rules; + } + + public function messages() + { + return [ + "category_id.required" => "请选择商品分裂", + "category_id.exists" => "请选择一个正确的分类", + "name.required" => "商品名字不能为空", + "name.unique" => "商品名字已经存在", + "title.required" => "商品描述不能为空", + "title.max" => "商品描述不能大于50个字", + "price.required" => "商品销售价格不能为空", + "price.numeric" => "商品销售价格必须是数字", + "original_price.required" => "商品展示价格不能为空", + "original_price.numeric" => "商品展示价格必须是数字", + + "unit.required" => '商品计数单位不能为空', + "count.required" => '商品库存量不能为空', + "count.numeric" => '商品库存量必须是数字', + "description.required" => "商品详情不能为空", + "description.min" => "商品详情请在10个字以上", + + "attribute.required" => '商品的属性不能为空', + "attribute.array" => '商品的属性不符合规格', + "items.required" => '商品的属性的值不能为空', + "items.array" => '商品的属性的值不符合规格', + "markup.required" => '价格浮动不能为空', + "markup.array" => '价格浮动不符合规格', + "attribute.*.required" => '商品属性不能为空', + "items.*.required" => '商品属性的值不能为空', + "markup.*.required" => '商品价格浮动不能为空', + "markup.*.numeric" => '商品价格浮动必须是数字', + + "link.required" => '必须上传商品图片', + "link.array" => '商品图片不符合规格', + ]; + } +} diff --git a/app/Http/Requests/RegisterUserRequest.php b/app/Http/Requests/RegisterUserRequest.php new file mode 100644 index 0000000..bf43ec8 --- /dev/null +++ b/app/Http/Requests/RegisterUserRequest.php @@ -0,0 +1,31 @@ + 'required', + 'password' => 'required', + ]; + } +} diff --git a/app/Http/Requests/RoleRequest.php b/app/Http/Requests/RoleRequest.php new file mode 100644 index 0000000..ac408e2 --- /dev/null +++ b/app/Http/Requests/RoleRequest.php @@ -0,0 +1,47 @@ + 'required|unique:roles,name', + 'permission' => 'required|array' + ]; + + if ($this->method() == 'PUT') { + $rules['name'] = 'required'; + } + + return $rules; + } + + public function messages() + { + return [ + 'name.required' => '角色名不能为空', + 'name.unique' => '角色名已经存在', + 'permission.required' => '权限不能为空', + 'permission.array' => '权限格式错误', + ]; + } +} diff --git a/app/Http/Requests/StoreAdminPost.php b/app/Http/Requests/StoreAdminPost.php new file mode 100644 index 0000000..ef55f0d --- /dev/null +++ b/app/Http/Requests/StoreAdminPost.php @@ -0,0 +1,44 @@ + 'required|min:3', + 'password' => 'required|min:5', + 'captcha' => 'required|captcha', + ]; + } + + public function messages() + { + return [ + 'name.required' => '用户名不能为空', + 'name.min' => '用户名不能少于五位', + 'password.required' => '密码不能少于五位', + 'password.min' => '密码不能少于五位', + 'captcha.required' => '验证码不能为空', + 'captcha.captcha' => '验证码不正确', + ]; + } +} diff --git a/app/Http/Requests/StoreSeckillRequest.php b/app/Http/Requests/StoreSeckillRequest.php new file mode 100644 index 0000000..56d672e --- /dev/null +++ b/app/Http/Requests/StoreSeckillRequest.php @@ -0,0 +1,30 @@ + 'required|exists:categories,id', + 'product_id' => 'required|exists:products,id', + 'number' => 'required|integer|min:1', + 'start_at' => 'required|date', + 'end_at' => 'required|date|after_or_equal:start_at', + ]; + } +} diff --git a/app/Http/Resources/CategoreResource.php b/app/Http/Resources/CategoreResource.php new file mode 100644 index 0000000..288a54d --- /dev/null +++ b/app/Http/Resources/CategoreResource.php @@ -0,0 +1,25 @@ + $this->id, + 'name' => (string)$this->title, + 'description' => (string)$this->description, + 'icon' => (string)$this->icon, + 'thumb' => assertUrl((string)$this->thumb), + ]; + } +} diff --git a/app/Http/Resources/OwnResource.php b/app/Http/Resources/OwnResource.php new file mode 100644 index 0000000..0fa0d8b --- /dev/null +++ b/app/Http/Resources/OwnResource.php @@ -0,0 +1,37 @@ + $this->name, + 'sex' => $this->sex, + 'email' => (string)$this->email, + 'avatar' => (string)$this->avatar, + 'github_name' => (string)$this->github_name, + 'qq_name' => (string)$this->qq_name, + 'weibo_name' => (string)$this->weibo_name, + + 'score_all' => (int)$this->score_all, + 'score_now' => (int)$this->score_now, + + 'login_days' => (int)$this->login_days, + 'last_login_date' => (string)$this->last_login_date, + + 'is_init_name' => (bool)$this->is_init_name, + 'is_init_email' => (bool)$this->is_init_email, + 'is_init_password' => (bool)$this->is_init_password, + ]; + } +} diff --git a/app/Http/Resources/ProductResource.php b/app/Http/Resources/ProductResource.php new file mode 100644 index 0000000..6dc8017 --- /dev/null +++ b/app/Http/Resources/ProductResource.php @@ -0,0 +1,47 @@ + (string)$this->uuid, + 'name' => (string)$this->name, + 'title' => (string)$this->title, + 'price' => (double)$this->price, + 'original_price' => (double)$this->original_price, + 'thumb' => $this->thumb, + 'sale_count' => (int)$this->sale_count, + 'count' => (int)$this->count, + 'view_count' => (int)$this->view_count, + 'created_at' => (string)$this->created_at, + + 'content' => $this->whenLoaded('detail', function () { + + return (string)optional($this->detail)->content; + }), + 'pictures' => $this->whenLoaded('detail', function () { + + return $this->assertPictures($this->pictures); + }), + ]; + } + + protected function assertPictures($pictures) + { + return collect($pictures)->map(function ($uri) { + + return assertUrl($uri); + }); + } +} diff --git a/app/Http/Resources/ScoreLogResource.php b/app/Http/Resources/ScoreLogResource.php new file mode 100644 index 0000000..62cf0eb --- /dev/null +++ b/app/Http/Resources/ScoreLogResource.php @@ -0,0 +1,24 @@ + $this->id, + 'description' => $this->description, + 'score' => $this->score, + 'created_at' => (string)$this->created_at + ]; + } +} diff --git a/app/Jobs/CancelUnPayOrder.php b/app/Jobs/CancelUnPayOrder.php new file mode 100644 index 0000000..e56e750 --- /dev/null +++ b/app/Jobs/CancelUnPayOrder.php @@ -0,0 +1,72 @@ +order = $order; + } + + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + // 查询数据库最新状态 + $nowOrder = $this->order->refresh(); + + // 如果现在还是没有付款,那么则取消订单 + if ($nowOrder->status == OrderStatusEnum::UN_PAY) { + + // 如果订单还是没有付款 + // 未付款设置为取消状态, + $this->order->status = OrderStatusEnum::UN_PAY_CANCEL; + $this->order->save(); + + // 回退优惠券 + if ( + !is_null($this->order->coupon_id) && + $coupon = UserHasCoupon::query()->find($this->order->coupon_id) + ) { + + $coupon->used_at = null; + $coupon->save(); + } + + // 回滚库存 + $this->order + ->details() + ->with('product') + ->get() + ->map(function (OrderDetail $detail) { + + // 不回滚出售数量 + $product = $detail->product; + $product->increment('count', $detail->number); + }); + } + } +} diff --git a/app/Jobs/InstallShopWarn.php b/app/Jobs/InstallShopWarn.php new file mode 100644 index 0000000..29737f8 --- /dev/null +++ b/app/Jobs/InstallShopWarn.php @@ -0,0 +1,42 @@ +user = $user; + } + + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + $time = date('Y-m-d H:i:s'); + $msg = '网站已启动|启动时间: ' . $time; + Log::info($msg); + } +} diff --git a/app/Jobs/RemindUsersHasSeckill.php b/app/Jobs/RemindUsersHasSeckill.php new file mode 100644 index 0000000..47f2a79 --- /dev/null +++ b/app/Jobs/RemindUsersHasSeckill.php @@ -0,0 +1,54 @@ +seckill = $seckill; + } + + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + /** + * @var $product Product + */ + // 拿出收藏的用户 + $product = $this->seckill->product; + + $product->users() + ->get() + ->map(function (User $user) use ($product) { + + Mail::to($user->email)->send(new RemindUserHasSeckillEmail($this->seckill, $product)); + }); + + + } +} diff --git a/app/Listeners/EventListener.php b/app/Listeners/EventListener.php new file mode 100644 index 0000000..dbf3337 --- /dev/null +++ b/app/Listeners/EventListener.php @@ -0,0 +1,31 @@ +product = $product; + $this->seckill = $seckill; + } + + /** + * Build the message. + * + * @return $this + */ + public function build() + { + return $this->markdown('emails.seckills'); + } +} diff --git a/app/Mail/ResetPassword.php b/app/Mail/ResetPassword.php new file mode 100644 index 0000000..9257c5e --- /dev/null +++ b/app/Mail/ResetPassword.php @@ -0,0 +1,39 @@ +token = $token; + } + + /** + * Build the message. + * + * @return $this + */ + public function build() + { + $url = url('password/reset', $this->token); + + return $this->view('emails.password.reset', compact('url')); + } +} diff --git a/app/Mail/SubscribesNotice.php b/app/Mail/SubscribesNotice.php new file mode 100644 index 0000000..8b56b25 --- /dev/null +++ b/app/Mail/SubscribesNotice.php @@ -0,0 +1,28 @@ +unSubUrl = $url; + } + + + public function build() + { + $latest = Product::query()->latest()->first(); + $hottest = Product::query()->orderBy('sale_count', 'desc')->first(); + $likest = Product::query()->withCount('users')->orderBy('users_count', 'desc')->first(); + + return $this->markdown('emails.subscribes', compact('likest', 'latest', 'hottest')); + } +} diff --git a/app/Mail/UserRegister.php b/app/Mail/UserRegister.php new file mode 100644 index 0000000..c81a085 --- /dev/null +++ b/app/Mail/UserRegister.php @@ -0,0 +1,38 @@ +user = $user; + } + + /** + * Build the message. + * + * @return $this + */ + public function build() + { + return $this->view('emails.register'); + } +} diff --git a/app/Models/Address.php b/app/Models/Address.php new file mode 100644 index 0000000..c53519d --- /dev/null +++ b/app/Models/Address.php @@ -0,0 +1,68 @@ +belongsTo(User::class); + } + + public function orders() + { + return $this->hasMany(Order::class); + } + + public function format() + { + return optional($this->province)->name . optional($this->city)->name. $this->detail_address; + } + + public function province() + { + return $this->belongsTo(Province::class); + } + + public function city() + { + return $this->belongsTo(City::class); + } +} diff --git a/app/Models/AdminUser.php b/app/Models/AdminUser.php new file mode 100644 index 0000000..9879898 --- /dev/null +++ b/app/Models/AdminUser.php @@ -0,0 +1,32 @@ +environment('dev')) { + + throw new \Exception('开发环境不允许操作'); + } + }); + + + self::deleting(function () { + + if (app()->environment('dev')) { + + throw new \Exception('开发环境不允许操作'); + } + }); + } +} diff --git a/app/Models/ArticleNotification.php b/app/Models/ArticleNotification.php new file mode 100644 index 0000000..0ba4017 --- /dev/null +++ b/app/Models/ArticleNotification.php @@ -0,0 +1,48 @@ +get(); + + $now = Carbon::now(); + $notifications = $users->map(function (User $user) use ($now, $article) { + + $notification = [ + 'id' => Uuid::uuid4()->toString(), + 'type' => ArticleTitleNotification::class, + 'notifiable_id' => $user->id, + 'notifiable_type' => get_class($user), + 'data' => json_encode((new ArticleTitleNotification($article))->toArray($user), JSON_UNESCAPED_UNICODE), + 'created_at' => $now, + 'updated_at' => $now, + ]; + return $notification; + }); + + $size = 1000; + foreach (array_chunk($notifications->all(), $size, true) as $chunk) { + // 通知 + DatabaseNotification::query()->insert($chunk); + } + }); + } +} diff --git a/app/Models/Car.php b/app/Models/Car.php new file mode 100644 index 0000000..539d014 --- /dev/null +++ b/app/Models/Car.php @@ -0,0 +1,49 @@ +belongsTo(User::class); + } + + public function product() + { + return $this->belongsTo(Product::class)->withDefault(function () { + + $product = new Product(); + $product->name = '商品已下架'; + $product->thumb = get404Image(); + $product->price = 0; + }); + } +} diff --git a/app/Models/Category.php b/app/Models/Category.php new file mode 100644 index 0000000..b1a2c73 --- /dev/null +++ b/app/Models/Category.php @@ -0,0 +1,63 @@ +hasMany(Product::class); + } + + + public static function orderAll() + { + return self::query()->orderBy('order')->latest()->get(); + } + + public static function selectOrderAll() + { + return self::query()->orderBy('order')->latest()->pluck('title', 'id'); + } +} diff --git a/app/Models/City.php b/app/Models/City.php new file mode 100644 index 0000000..8724c34 --- /dev/null +++ b/app/Models/City.php @@ -0,0 +1,24 @@ +belongsTo(Product::class); + } + + public function user() + { + return $this->belongsTo(User::class)->withDefault(function () { + + return new User(['avatar' => 'avatars/default/' . array_random(User::DEFAULT_AVATARS)]); + }); + } +} diff --git a/app/Models/CouponCode.php b/app/Models/CouponCode.php new file mode 100644 index 0000000..bee0bd0 --- /dev/null +++ b/app/Models/CouponCode.php @@ -0,0 +1,18 @@ +belongsTo(User::class, 'user_id'); + } + + public function template() + { + return $this->belongsTo(CouponTemplate::class, 'template_id'); + } +} diff --git a/app/Models/CouponTemplate.php b/app/Models/CouponTemplate.php new file mode 100644 index 0000000..850ec85 --- /dev/null +++ b/app/Models/CouponTemplate.php @@ -0,0 +1,60 @@ +hasMany(UserHasCoupon::class, 'template_id'); + } + + public function getAmountAttribute($value) + { + if ($value == intval($value)) { + + $value = intval($value); + } + + return $value; + } + + public function getFullAmountAttribute($value) + { + if ($value == intval($value)) { + + $value = intval($value); + } + + return $value; + } +} diff --git a/app/Models/Level.php b/app/Models/Level.php new file mode 100644 index 0000000..38ee769 --- /dev/null +++ b/app/Models/Level.php @@ -0,0 +1,34 @@ +hasMany(OrderDetail::class); + } + + public function address() + { + return $this->belongsTo(Address::class); + } + + public function user() + { + return $this->belongsTo(User::class); + } + + public function isNotUser($id) + { + return $this->user_id != $id; + } + + public static function boot() + { + parent::boot(); + + + // 自动生成订单的订单号 + static::creating(function ($model) { + + if (is_null($model->no)) { + $model->no = static::findAvailableNo($model->user_id); + } + }); + + static::created(function ($model) { + + // 订单成交量 + Cache::increment(SiteCountCacheEnum::ORDER_COUNT); + }); + + static::saved(function ($model) { + + // 支付 + if ($model->status == OrderStatusEnum::PAID) { + // 订单成交量 + Cache::increment(SiteCountCacheEnum::PAY_ORDER_COUNT); + + $currMoney = Cache::get(SiteCountCacheEnum::SALE_ORDER_COUNT, 0); + if (function_exists('bcadd')) { + $money = bcadd($currMoney, $model->pay_amount); + } else { + $money = $currMoney + $model->pay_amount; + } + + Cache::set(SiteCountCacheEnum::SALE_ORDER_COUNT, $money); + } + // 退款 + elseif ($model->status == OrderStatusEnum::REFUND) { + + $currMoney = Cache::get(SiteCountCacheEnum::SALE_ORDER_COUNT, 0); + if (function_exists('bcsub')) { + $money = bcsub($currMoney, $model->pay_refund_fee); + } else { + $money = $currMoney - $model->pay_refund_fee; + } + + Cache::increment(SiteCountCacheEnum::REFUND_ORDER_COUNT); + Cache::set(SiteCountCacheEnum::SALE_ORDER_COUNT, $money); + } + + }); + } + + /** + * @param string $userId + * @param int $try + * @return string + * @throws \Exception + */ + public static function findAvailableNo($userId = '000000000', $try = 5) + { + $prefix = date('YmdHis'); + $suffix = fixStrLength($userId, 9); + + for ($i = 0; $i < $try; ++ $i) { + $no = $prefix . fixStrLength(random_int(0, 9999), 5) . $suffix; + + if (self::query()->where('no', $no)->doesntExist()) { + return $no; + } + } + + throw new \Exception('流水号生成失败'); + } +} diff --git a/app/Models/OrderDetail.php b/app/Models/OrderDetail.php new file mode 100644 index 0000000..5f7f9ac --- /dev/null +++ b/app/Models/OrderDetail.php @@ -0,0 +1,63 @@ +belongsTo(Product::class)->withDefault([ + 'name' => '商品已下架', + 'thumb' => assertUrl('products/404.jpg') + ]); + } + + public function order() + { + return $this->belongsTo(Order::class); + } + + public function comment() + { + return $this->hasOne(Comment::class); + } +} diff --git a/app/Models/Product.php b/app/Models/Product.php new file mode 100644 index 0000000..70f78b0 --- /dev/null +++ b/app/Models/Product.php @@ -0,0 +1,260 @@ + 'json', + ]; + + + + public function getThumbAttribute($thumb) + { + return assertUrl($thumb); + } + + public function comments() + { + return $this->hasMany(Comment::class); + } + + public function category() + { + return $this->belongsTo(Category::class); + } + + public function cars() + { + return $this->hasMany(Car::class); + } + + public function users() + { + return $this->belongsToMany(User::class, 'likes_products')->withTimestamps(); + } + + public function detail() + { + return $this->hasOne(ProductDetail::class); + } + + + public function orderDetail() + { + return $this->hasOne(orderDetail::class); + } + + /** + * 使用 uuid 注入 + * + * @return string + */ + public function getRouteKeyName() + { + return 'uuid'; + } + + public function getViewCountAttribute() + { + $date = Carbon::today()->toDateString(); + + return $this->attributes['view_count'] + Cache::get($this->getViewCountKey($date), 0); + } + + public function getViewCountKey($date) + { + return "moon:products_cache_{$date}:view_count_{$this->id}"; + } + + public static function boot() + { + parent::boot(); + + + // 自动生成商品的 uuid, 拼音 + static::saving(function (Product $model) { + + if (is_null($model->uuid)) { + $model->uuid = Uuid::uuid4()->toString(); + } + + if (is_null($model->pinyin)) { + + /** + * @var $pinyin Pinyin + */ + $pinyin = app(Pinyin::class); + + $model->pinyin = $pinyin->permalink($model->name); + $model->first_pinyin = substr($model->pinyin, 0, 1); + } + + + if ($model->isDirty('first_pinyin')) { + + // 建立拼音表 + ProductPinYin::query()->firstOrCreate(['pinyin' => $model->first_pinyin]); + } + + if (self::$addToSearch) { + try { + + $model->addToIndex($model->getSearchData()); + } catch (\Exception $e) { + + } + } + + }); + + static::deleted(function (Product $model) { + + // 没有这个拼音了,删去 + if (Product::query()->where('first_pinyin', $model->first_pinyin)->doesntExist()) { + ProductPinYin::query()->where('pinyin', $model->first_pinyin)->delete(); + } + + if (self::$addToSearch) { + try { + + $model->removeFromIndex(); + } catch (\Exception $e) { + + } + } + }); + + + // 从软删除中恢复 + static::restored(function (Product $model) { + + // 建立拼音表 + ProductPinYin::query()->firstOrCreate(['pinyin' => $model->first_pinyin]); + + if (self::$addToSearch) { + try { + + $model->addToIndex($this->getSearchData()); + } catch (\Exception $e) { + + } + } + }); + } + + public function getSearchData() + { + $categoryName = $this->category->title ?? ''; + $title = $this->name . ' ' . $this->title; + $text = str_replace(["\t", "\r", "\n"], ['', '', ''], strip_tags($this->detail->content ?? '')); + + return [ + 'id' => $this->id, + 'title' => $title, + 'body' => $text . ' ' . $categoryName + ]; + } + + + public function getIndexName() + { + return 'product'; + } + + public function getMappingProperties() + { + return [ + 'id' => [ + 'type' => 'integer' + ], + 'title' => [ + 'type' => 'text', + 'analyzer' => 'ik_max_word', + 'search_analyzer' => 'ik_smart', + 'index' => true, + ], + 'body' => [ + 'type' => 'text', + 'analyzer' => 'ik_max_word', + 'search_analyzer' => 'ik_smart', + 'index' => true, + ] + ]; + } +} diff --git a/app/Models/ProductDetail.php b/app/Models/ProductDetail.php new file mode 100644 index 0000000..ab32720 --- /dev/null +++ b/app/Models/ProductDetail.php @@ -0,0 +1,36 @@ +belongsTo(Product::class); + } +} diff --git a/app/Models/ProductHasUser.php b/app/Models/ProductHasUser.php new file mode 100644 index 0000000..1cf652e --- /dev/null +++ b/app/Models/ProductHasUser.php @@ -0,0 +1,38 @@ +belongsTo(Product::class, 'product_id'); + } + + public function user() + { + return $this->belongsTo(User::class, 'user_id'); + } +} diff --git a/app/Models/ProductPinYin.php b/app/Models/ProductPinYin.php new file mode 100644 index 0000000..c54294a --- /dev/null +++ b/app/Models/ProductPinYin.php @@ -0,0 +1,26 @@ +belongsTo(User::class, 'user_id'); + } +} diff --git a/app/Models/ScoreRule.php b/app/Models/ScoreRule.php new file mode 100644 index 0000000..df230d1 --- /dev/null +++ b/app/Models/ScoreRule.php @@ -0,0 +1,84 @@ +addMinutes(5), + function () use ($code) { + return ScoreRule::query() + ->where('index_code', $code) + ->orderByDesc('times') + ->get(); + } + ); + + /** + * @var $rule ScoreRule + */ + foreach ($allRules as $rule) { + if ($times == $rule->times) { + return $rule; + } + } + + return null; + } + + public static function boot() + { + parent::bootTraits(); + + // 每当规则修改的时候, 移除掉缓存 + self::saved(function () { + + Cache::forget(self::CACHE_KEY); + }); + self::deleted(function () { + + Cache::forget(self::CACHE_KEY); + }); + } +} diff --git a/app/Models/SearchAble/ElasticSearchTrait.php b/app/Models/SearchAble/ElasticSearchTrait.php new file mode 100644 index 0000000..b7abcc4 --- /dev/null +++ b/app/Models/SearchAble/ElasticSearchTrait.php @@ -0,0 +1,258 @@ +setHosts($configs['hosts']); + + self::$client = $builder->build(); + } + + return self::$client; + } + + public static function info() + { + $instance = new static; + + + $index = array( + 'index' => $instance->getIndexName(), + 'client' => [ + 'timeout' => 5, // ten second timeout + 'connect_timeout' => 5 + ] + ); + + return self::client()->indices()->get($index); + } + + public static function indexExists() + { + $instance = new static; + + + $index = array( + 'index' => $instance->getIndexName(), + 'client' => [ + 'timeout' => 5, // ten second timeout + 'connect_timeout' => 5 + ] + ); + + return self::client()->indices()->exists($index); + } + + + public static function deleteIndex() + { + $instance = new static; + + + $index = array( + 'index' => $instance->getIndexName(), + 'client' => [ + 'timeout' => 5, // ten second timeout + 'connect_timeout' => 5 + ] + ); + + return self::client()->indices()->delete($index); + } + + public static function createIndex($shards = null, $replicas = null) + { + /** + * @var $instance ElasticSearchTrait + */ + $instance = new static; + + $client = self::client(); + + $index = array( + 'index' => $instance->getIndexName(), + ); + + $settings = $instance->getIndexSettings(); + if (!is_null($settings)) { + $index['body']['settings'] = $settings; + } + + if (!is_null($shards)) { + $index['body']['settings']['number_of_shards'] = $shards; + } + + if (!is_null($replicas)) { + $index['body']['settings']['number_of_replicas'] = $replicas; + } + + $mappingProperties = $instance->getMappingProperties(); + if (!is_null($mappingProperties)) { + $index['body']['mappings'] = [ + '_source' => array('enabled' => true), + 'properties' => $mappingProperties, + ]; + } + + return $client->indices()->create($index); + } + + /** + * @return mixed + * @throws Exception + */ + public function addToIndex($body = []) + { + if (!$this->exists) { + throw new Exception('Document does not exist.'); + } + + $params = $this->getBasicEsParams(); + + // Get our document body data. + $params['body'] = $body; + + // The id for the document must always mirror the + // key for this model, even if it is set to something + // other than an auto-incrementing value. That way we + // can do things like remove the document from + // the index, or get the document from the index. + $params['id'] = $this->getKey(); + + return self::client()->index($params); + } + + public function removeFromIndex() + { + return self::client()->delete($this->getBasicEsParams()); + } + + /** + * query: { + * "match": { + * "tweet": "elasticsearch" + * } + * } + * 排序:[ + * { "post_date" : {"order" : "asc"}}, + * "user", + * { "name" : "desc" }, + * { "age" : "desc" }, + * "_score" + * ] + * @param $query + * @param $limit + * @param $offset + * @param null $sort + * @param null $sourceFields + * @param null $aggregations + * @return array + */ + public static function search($query, $limit = null, $offset = null, $sort = null, $sourceFields = null, $aggregations = null) + { + $instance = new static; + + $params = $instance->getBasicEsParams(true, true, true, $limit, $offset); + + if (!empty($sourceFields)) { + $params['body']['_source']['include'] = false; + } + + if (!empty($query)) { + $params['body']['query'] = $query; + } + + if (!empty($aggregations)) { + $params['body']['aggs'] = $aggregations; + } + + if (!empty($sort)) { + $params['body']['sort'] = $sort; + } + + $result = self::client()->search($params); + return $result; + } + + + public static function searchCount($query) + { + $instance = new static; + + $params = $instance->getBasicEsParams(true, true, true, null, null); + + + if (!empty($query)) { + $params['body']['query'] = $query; + } + + + $result = self::client()->count($params); + return $result; + } + + public function getBasicEsParams($getIdIfPossible = true, $getSourceIfPossible = false, $getTimestampIfPossible = false, $limit = null, $offset = null) + { + $params = array( + 'index' => $this->getIndexName(), + 'client' => [ + 'timeout' => 5, // ten second timeout + 'connect_timeout' => 5 + ] + ); + + if ($getIdIfPossible && $this->getKey()) { + $params['id'] = $this->getKey(); + } + + + if (is_numeric($limit)) { + $params['size'] = $limit; + } + + if (is_numeric($offset)) { + $params['from'] = $offset; + } + + return $params; + } + + public function getIndexName() + { + $indexName = config('elasticsearch.default_index', 'default'); + + return $indexName; + } + + public function getIndexSettings() + { + return null; + } + + public function getMappingProperties() + { + return []; + } + +} diff --git a/app/Models/Seckill.php b/app/Models/Seckill.php new file mode 100644 index 0000000..45aaabb --- /dev/null +++ b/app/Models/Seckill.php @@ -0,0 +1,130 @@ + 'double' + ]; + + protected $guarded = []; + + /** + * 一个商品可以同时有多个商品同时秒杀, + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function product() + { + return $this->belongsTo(Product::class, 'product_id', 'id'); + } + + + public static function boot() + { + parent::boot(); + + + // 存入 redis + static::created(function (Seckill $seckill) { + + // 从数据库中取出, 因为有一些默认值 + $seckill = Seckill::query()->find($seckill->id); + $seckill->load('product'); + + // 以后的数据都将从 redis 取出,直至秒杀结束 + Redis::set($seckill->getRedisModelKey(), $seckill->toJson()); + // 填充一个 redis 队列,数量为抢购的数量,后面的 9 无意义 + // 当去队列的值时,只需要判断是否为 null,就可以得知还有没有数量 + $fill = array_fill(0, $seckill->number, 9); + Redis::lpush($seckill->getRedisQueueKey(), $fill); + + // 秒杀商品,如果用户收藏,发送邮件提醒活动 + RemindUsersHasSeckill::dispatch($seckill); + }); + } + + + /** + * 获取所有的 redis key + * + * @return array + */ + public function getAllRedisKey() + { + // 用户保存的 hset 需要匹配出来一个一个删 + return [$this->getRedisModelKey(), $this->getRedisQueueKey()]; + } + + /** + * 存储抢到的用户 + * + * @param $id + * @return string + */ + public function getUsersKey($id) + { + return "seckills:{$this->id}:users:{$id}"; + } + + + /** + * 存储模型 json 字符串 + * + * @return string + */ + public function getRedisModelKey() + { + return "seckills:{$this->id}:model"; + } + + /** + * 存储一个 秒杀数量的队列 + * + * @return string + */ + public function getRedisQueueKey() + { + return "seckills:{$this->id}:queue"; + } +} diff --git a/app/Models/Setting.php b/app/Models/Setting.php new file mode 100644 index 0000000..3685e75 --- /dev/null +++ b/app/Models/Setting.php @@ -0,0 +1,52 @@ +index_code), $setting->value); + }); + } + + public static function cacheKey($name) + { + return self::CACHE_KEY . $name; + } +} diff --git a/app/Models/SiteCount.php b/app/Models/SiteCount.php new file mode 100644 index 0000000..b36ccb6 --- /dev/null +++ b/app/Models/SiteCount.php @@ -0,0 +1,44 @@ +belongsTo(User::class); + } +} diff --git a/app/Models/User.php b/app/Models/User.php new file mode 100644 index 0000000..b5f20f4 --- /dev/null +++ b/app/Models/User.php @@ -0,0 +1,264 @@ +name)) { + return 'xxx'; + } + + $lastStr = mb_substr($this->name, 0, 1, 'utf-8'); + + $hiddenStr = str_repeat('*', mb_strlen($this->name, 'utf-8') - 1); + + return $lastStr . $hiddenStr; + } + + + public function coupons() + { + return $this->hasMany(UserHasCoupon::class, 'user_id'); + } + + public function scoreLogs() + { + return $this->hasMany(ScoreLog::class, 'user_id'); + } + + public function addresses() + { + return $this->hasMany(Address::class); + } + + public function subscribe() + { + return $this->hasOne(Subscribe::class); + } + + public function cars() + { + return $this->hasMany(Car::class); + } + + public function orders() + { + return $this->hasMany(Order::class); + } + + /** + * 获取订单明细用以评论 + */ + public function orderDetails() + { + return $this->hasManyThrough(OrderDetail::class, Order::class); + } + + public function products() + { + return $this->belongsToMany(Product::class, 'likes_products'); + } + + + /** + * rewrite send reset password email + * @param string $token + */ + public function sendPasswordResetNotification($token) + { + Mail::to($this->email) + ->queue(new ResetPassword($token)); + } + + /** + * 初始化头像 + */ + public static function boot() + { + parent::boot(); + + static::creating(function ($model) { + + if (! isset($model->attributes['avatar'])) { + $model->attributes['avatar'] = 'avatars/default/' . array_random(User::DEFAULT_AVATARS); + } + + if (! isset($model->attributes['password'])) { + + $setting = new SettingKeyEnum(SettingKeyEnum::USER_INIT_PASSWORD); + $model->attributes['password'] = bcrypt(setting($setting, '123456')); + } + + }); + + static::created(function ($model) { + + // 用户注册之后,得到注册的来源 + // 存入 redis 缓存,每日更新到统计表 + $source = UserSourceEnum::search($model->source) ?: UserSourceEnum::search(UserSourceEnum::MOON); + + $registerKey = SiteCountCacheEnum::MOON_REGISTER_COUNT; + switch ($source) { + case 'qq': + $registerKey = SiteCountCacheEnum::QQ_REGISTER_COUNT; + break; + case 'weibo': + $registerKey = SiteCountCacheEnum::WEIBO_REGISTER_COUNT; + break; + case 'github': + $registerKey = SiteCountCacheEnum::GITHUB_REGISTERED_COUNT; + break; + default: + break; + } + + Cache::increment($registerKey); + Cache::increment(SiteCountCacheEnum::REGISTERED_COUNT); + }); + } + + + public function getJWTCustomClaims() + { + return []; + } + + public function getJWTIdentifier() + { + return $this->getKey(); + } +} diff --git a/app/Models/UserHasCoupon.php b/app/Models/UserHasCoupon.php new file mode 100644 index 0000000..d83398c --- /dev/null +++ b/app/Models/UserHasCoupon.php @@ -0,0 +1,45 @@ +belongsTo(User::class, 'user_id'); + } + +} diff --git a/app/Notifications/ArticleTitleNotification.php b/app/Notifications/ArticleTitleNotification.php new file mode 100644 index 0000000..c58cce8 --- /dev/null +++ b/app/Notifications/ArticleTitleNotification.php @@ -0,0 +1,58 @@ +article = $article; + } + + /** + * Get the notification's delivery channels. + * + * @param mixed $notifiable + * @return array + */ + public function via($notifiable) + { + return ['database']; + } + + /** + * Get the mail representation of the notification. + * + * @param mixed $notifiable + * @return \Illuminate\Notifications\Messages\MailMessage + */ + public function toMail($notifiable) + { + return (new MailMessage) + ->line('The introduction to the notification.') + ->action('Notification Action', url('/')) + ->line('Thank you for using our application!'); + } + + /** + * Get the array representation of the notification. + * + * @param mixed $notifiable + * @return array + */ + public function toArray($notifiable) + { + return [ + 'id' => $this->article->id, + 'title' => $this->article->title, + ]; + } +} diff --git a/app/Notifications/CouponCodeNotification.php b/app/Notifications/CouponCodeNotification.php new file mode 100644 index 0000000..f6d6a4d --- /dev/null +++ b/app/Notifications/CouponCodeNotification.php @@ -0,0 +1,50 @@ +template = $template; + $this->code = $code; + } + + /** + * Get the notification's delivery channels. + * + * @param mixed $notifiable + * @return array + */ + public function via($notifiable) + { + return ['database']; + } + + + /** + * Get the array representation of the notification. + * + * @param mixed $notifiable + * @return array + */ + public function toArray($notifiable) + { + return [ + 'title' => $this->template->title . '兑换码', + 'start_date' => $this->template->start_date, + 'end_date' => $this->template->end_date, + 'code' => $this->code, + ]; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php new file mode 100644 index 0000000..013c809 --- /dev/null +++ b/app/Providers/AppServiceProvider.php @@ -0,0 +1,35 @@ + 'App\Policies\ModelPolicy', + ]; + + /** + * Register any authentication / authorization services. + * + * @return void + */ + public function boot() + { + $this->registerPolicies(); + } +} diff --git a/app/Providers/BroadcastServiceProvider.php b/app/Providers/BroadcastServiceProvider.php new file mode 100644 index 0000000..352cce4 --- /dev/null +++ b/app/Providers/BroadcastServiceProvider.php @@ -0,0 +1,21 @@ + [ + 'App\Listeners\EventListener', + ], + + ]; + + /** + * Register any events for your application. + * + * @return void + */ + public function boot() + { + parent::boot(); + + // + } +} diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php new file mode 100644 index 0000000..5e90b06 --- /dev/null +++ b/app/Providers/RouteServiceProvider.php @@ -0,0 +1,42 @@ +by($request->user()?->id ?: $request->ip()); + }); + + $this->routes(function () { + Route::middleware('api') + ->prefix('api') + ->namespace('App\Http\Controllers') + ->group(base_path('routes/api.php')); + + Route::middleware('web') + ->namespace('App\Http\Controllers') + ->group(base_path('routes/web.php')); + }); + } +} diff --git a/app/Services/NotificationServe.php b/app/Services/NotificationServe.php new file mode 100644 index 0000000..57eb9d7 --- /dev/null +++ b/app/Services/NotificationServe.php @@ -0,0 +1,59 @@ +type) { + + case CouponCodeNotification::class: + $view = 'code'; + break; + case ArticleTitleNotification::class: + $view = 'article'; + break; + } + $view = "user.notifications.types.{$view}"; + + return $view; + } + + public static function getTitle(DatabaseNotification $notification) + { + $data = $notification->data; + + if (isset($data['title'])) { + + return $data['title']; + } + + return self::getContent($notification); + } + + public static function getContent(DatabaseNotification $notification) + { + $title = ''; + switch ($notification->type) { + + case CouponCodeNotification::class: + $title = "你获得了新的优惠券兑换码,火速前往"; + break; + case ArticleTitleNotification::class: + $title = "有新的文章发布了,火速查看"; + break; + default: + $title = '默认消息'; + break; + } + + return $title; + } +} diff --git a/app/Services/OrderStatusButtonServe.php b/app/Services/OrderStatusButtonServe.php new file mode 100644 index 0000000..470f00b --- /dev/null +++ b/app/Services/OrderStatusButtonServe.php @@ -0,0 +1,107 @@ +order = $order; + } + + + public function cancelOrderButton() + { + $url = url("/user/orders/{$this->order->id}/cancel"); + + $this->buttons[] = <<