diff --git a/laravel/laravel5/10.md b/laravel/laravel5/10.md new file mode 100644 index 0000000..2682710 --- /dev/null +++ b/laravel/laravel5/10.md @@ -0,0 +1,450 @@ +# 退款-申请退款 +1. 创建申请退款的请求类 `php artisan make:request ApplyRefundRequest` +``` + 'required', + ]; + } + + public function attributes() + { + return [ + 'reason' => '原因', + ]; + } +} +``` +2. 增加申请退款的方法 app/Http/Controllers/OrdersController@applyRefund +``` +use App\Http\Requests\ApplyRefundRequest; + +... + + /** + * 申请退款 + */ + public function applyRefund(Order $order, ApplyRefundRequest $request) + { + // 校验订单是否属于当前用户 + $this->authorize('own', $order); + // 判断订单是否已付款 + if (!$order->paid_at) { + throw new InvalidRequestException('该订单未支付,不可退款'); + } + // 判断订单退款状态是否正确 + if ($order->refund_status !== Order::REFUND_STATUS_PENDING) { + throw new InvalidRequestException('该订单已经申请过退款,请勿重复申请'); + } + // 将用户输入的退款理由放到订单的 extra 字段中 + $extra = $order->extra ?: []; + $extra['refund_reason'] = $request->input('reason'); + // 将订单退款状态改为已申请退款 + $order->update([ + 'refund_status' => Order::REFUND_STATUS_APPLIED, + 'extra' => $extra, + ]); + + return $order; + } +``` +3. 增加路由 `Route::post('orders/{order}/apply_refund', 'OrdersController@applyRefund')->name('orders.apply_refund'); //申请退款` +4. 增加入口链接 ../orders/show.blade.php(同时在视图层作一次判断:如果成功申请退款则展示退款状态) +``` +# 展示退款状态 +{{-- 物流信息 --}} +@if($order->ship_data) + ... +@endif +{{-- 退款信息 --}} +@if($order->paid_at && $order->refund_status !== \App\Models\Order::REFUND_STATUS_PENDING) +
+
退款状态:
+
+ {{ \App\Models\Order::$refundStatusMap[$order->refund_status] }} +
+
+
+
退款理由:
+
+ {{ $order->extra['refund_reason'] }} +
+
+@endif + +# 显示退款按钮 +{{-- 申请退款 --}} +@if($order->paid_at && $order->refund_status === \App\Models\Order::REFUND_STATUS_PENDING) +
+ +
+@endif + +# js +// 申请退款 +$('#btn-apply-refund').click(function () { + swal({ + text: '请输入退款理由', + content: "input", + }) + .then(function (input) { + // 当用户点击 swal 弹出框上的按钮时触发这个函数 + if(!input) { + swal('退款理由不可空', '', 'error'); + return; + } + // 请求退款接口 + axios.post('{{ route('orders.apply_refund', [$order->id]) }}', {reason: input}) + .then(function () { + swal('申请退款成功', '', 'success').then(function () { + // 用户点击弹框上按钮时重新加载页面 + location.reload(); + }); + }); + }); +}); +``` + +# 退款-拒绝退款 +* 创建后台的请求类校验运营人员处理退款的请求 `php artisan make:request Admin/HandleRefundRequest` +``` + ['required', 'boolean'], + 'reason' => ['required_if:agree,false'], // 拒绝退款时需要输入拒绝理由 + ]; + } +} +``` +* 在后台控制器增加方法 app/Admin/Controllers/OrdersController@handleRefund +``` +use App\Http\Requests\Admin\HandleRefundRequest; + +... + + /** + * 处理退款 + */ + public function handleRefund(Order $order, HandleRefundRequest $request) + { + // 判断订单状态是否正确 + if ($order->refund_status !== Order::REFUND_STATUS_APPLIED) { + throw new InvalidRequestException('订单状态不正确'); + } + // 是否同意退款 + if ($request->input('agree')) { + // 同意退款的逻辑这里先留空 + // todo + } else { + // 将拒绝退款理由放到订单的 extra 字段中 + $extra = $order->extra ?: []; + $extra['refund_disagree_reason'] = $request->input('reason'); + // 将订单的退款状态改为未退款 + $order->update([ + 'refund_status' => Order::REFUND_STATUS_PENDING, + 'extra' => $extra, + ]); + } + + return $order; + } +``` +* 增加后台路由 app/Admin/routes.php `$router->post('orders/{order}/refund', 'OrdersController@handleRefund')->name('admin.orders.handle_refund'); //退款处理` +* 添加同意/拒绝退款的按钮 ../admin/orders/show.blade.php +``` +... + +# html + + @if($order->refund_status !== \App\Models\Order::REFUND_STATUS_PENDING) + + 退款状态: + {{ \App\Models\Order::$refundStatusMap[$order->refund_status] }},理由:{{ $order->extra['refund_reason'] }} + + @if($order->refund_status === \App\Models\Order::REFUND_STATUS_APPLIED) + + + @endif + + + @endif + + +# js + +``` + +* 前台显示拒绝退款理由 ../orders/show.blade.php +``` +{{-- 退款信息 --}} +@if(isset($order->extra['refund_disagree_reason'])) +
+ 拒绝退款理由: +
{{ $order->extra['refund_disagree_reason'] }}
+
+``` + +# 完成退款功能-支付宝 +* 无论支付宝还是微信退款,都需要一个退款订单号,在 Order 模型中增加一个生成退款订单号的方法 +``` + /** + * 生成退款订单号 + */ + public static function getAvailableRefundNo() + { + do { + // Uuid类可以用来生成大概率不重复的字符串 + $no = Uuid::uuid4()->getHex(); + // 为了避免重复我们在生成之后在数据库中查询看看是否已经存在相同的退款订单号 + } while (self::query()->where('refund_no', $no)->exists()); + + return $no; + } +``` +* 完善 app/Admin/Controllers/OrdersController@handleRefund 方法 +``` +use App\Exceptions\InternalException; + +... + + /** + * 处理退款 + */ + public function handleRefund(Order $order, HandleRefundRequest $request) + { + ... + + if ($request->input('agree')) { + // 调用退款逻辑 + $this->_refundOrder($order); + } + + ... + + return $order; + } + + /** + * 同意退款 + */ + protected function _refundOrder(Order $order) + { + // 判断该订单的支付方式 + switch ($order->payment_method) { + case 'wechat': + // 微信的先留空 + // todo + break; + case 'alipay': + // 用我们刚刚写的方法来生成一个退款订单号 + $refundNo = Order::getAvailableRefundNo(); + // 调用支付宝支付实例的 refund 方法 + $ret = app('alipay')->refund([ + 'out_trade_no' => $order->no, // 之前的订单流水号 + 'refund_amount' => $order->total_amount, // 退款金额,单位元 + 'out_request_no' => $refundNo, // 退款订单号 + ]); + // 根据支付宝的文档,如果返回值里有 sub_code 字段说明退款失败 + if ($ret->sub_code) { + // 将退款失败的保存存入 extra 字段 + $extra = $order->extra; + $extra['refund_failed_code'] = $ret->sub_code; + // 将订单的退款状态标记为退款失败 + $order->update([ + 'refund_no' => $refundNo, + 'refund_status' => Order::REFUND_STATUS_FAILED, + 'extra' => $extra, + ]); + } else { + // 将订单的退款状态标记为退款成功并保存退款订单号 + $order->update([ + 'refund_no' => $refundNo, + 'refund_status' => Order::REFUND_STATUS_SUCCESS, + ]); + } + break; + default: + // 原则上不可能出现,这个只是为了代码健壮性 + throw new InternalException('未知订单支付方式:'.$order->payment_method); + break; + } + } +``` +> 这里退款逻辑写在了 `_refundOrder()` 方法中 +* ../admin/orders/show.blade.php 前端工作处理 +``` + // 同意按钮的点击事件 + $('#btn-refund-agree').click(function () { + swal({ + title: '确认要将款项退还给用户?', + type: 'warning', + showCancelButton: true, + closeOnConfirm: false, + confirmButtonText: "确认", + cancelButtonText: "取消", + }, function (ret) { + // 用户点击取消,不做任何操作 + if (!ret) { + return; + } + $.ajax({ + url: '{{ route('admin.orders.handle_refund', [$order->id]) }}', + type: 'POST', + data: JSON.stringify({ + agree: true, // 代表同意退款 + _token: LA.token, + }), + contentType: 'application/json', + success: function (data) { + swal({ + title: '操作成功', + type: 'success' + }, function () { + location.reload(); + }); + } + }); + }); + }); +``` + +# 退款-微信 +* OrdersController@_refundOrder +``` +... + case 'wechat': + // 生成退款订单号 + $refundNo = Order::getAvailableRefundNo(); + app('wechat_pay')->refund([ + 'out_trade_no' => $order->no, // 之前的订单流水号 + 'total_fee' => $order->total_amount * 100, //原订单金额,单位分 + 'refund_fee' => $order->total_amount * 100, // 要退款的订单金额,单位分 + 'out_refund_no' => $refundNo, // 退款订单号 + // 微信支付的退款结果并不是实时返回的,而是通过退款回调来通知,因此这里需要配上退款回调接口地址 + 'notify_url' => route('payment.wechat.refund_notify'), + ]); + // 将订单状态改成退款中 + $order->update([ + 'refund_no' => $refundNo, + 'refund_status' => Order::REFUND_STATUS_PROCESSING, + ]); + break; +``` +> 微信退款后,不是实时返回的,而是在微信那边处理之后,再通知我们,所以我们得有个接收通知的接口函数 +* app/Http/Controllers/OrdersController@wechatRefundNotify +``` + /** + * 微信退款通知 + */ + public function wechatRefundNotify(Request $request) + { + // 给微信的失败响应 + $failXml = ''; + $data = app('wechat_pay')->verify(null, true); + + // 没有找到对应的订单,原则上不可能发生,保证代码健壮性 + if(!$order = Order::where('no', $data['out_trade_no'])->first()) { + return $failXml; + } + + if ($data['refund_status'] === 'SUCCESS') { + // 退款成功,将订单退款状态改成退款成功 + $order->update([ + 'refund_status' => Order::REFUND_STATUS_SUCCESS, + ]); + } else { + // 退款失败,将具体状态存入 extra 字段,并表退款状态改成失败 + $extra = $order->extra; + $extra['refund_failed_code'] = $data['refund_status']; + $order->update([ + 'refund_status' => Order::REFUND_STATUS_FAILED, + ]); + } + + return app('wechat_pay')->success(); + } +``` +* 为这个方法配置一条路由 `Route::post('payment/wechat/refund_notify', 'PaymentController@wechatRefundNotify')->name('payment.wechat.refund_notify'); //微信退款-后端回调` +* 别忘了把这个 url 加入到 csrf 白名单 app/Http/Midlleware/VerifyCsrfToken.php +``` + /** + * 取消 csrf 认证 + */ + protected $except = [ + 'payment/alipay/notify', + 'payment/wechat/notify', + 'payment/wechat/refund_notify', + ]; +``` +> 微信退款和支付宝支付、微信支付等逻辑是一样的,发起退款之后,微信作了相关处理,再请求我们的接口,完成后台逻辑。 + +> 而支付宝是直接 `$ret = app('alipay')->refund()` 返回 $ret 。 + +> 可以将支付宝理解为同步的(我们发起请求->支付宝服务器响应把钱还给用户->返回的数据我们可以同步被变量接收) + +> 而微信是异步的(我们发起请求->微信服务器收到我们的请求,然后这条线就断了。然后微信会自己处理好把钱还给用户->然后微信再请求我们的接口) + +* 在成功测试了支付宝、微信的支付和退款操作之后,记得把 app/Providers/AppServiceProvider.php 的后端回调地址变回我们的路由地址 `$config['notify_url'] = route('payment.alipay.notify'); //后端回调`, `$config['notify_url'] = route('payment.wechat.notify');`。 \ No newline at end of file diff --git a/laravel/laravel5/11.md b/laravel/laravel5/11.md new file mode 100644 index 0000000..a0bbf9b --- /dev/null +++ b/laravel/laravel5/11.md @@ -0,0 +1,762 @@ +# 优惠券功能开发-准备工作 +1. 创建模型和迁移文件 `php artisan make:model Models/CouponCode -m` + * 迁移文件 + ``` + // up() + $table->increments('id'); + $table->string('name')->comments('标题'); + $table->string('code')->unique()->comments('兑换码'); + $table->string('type')->comments('优惠类型'); //(固定减少价格|百分比折扣) + $table->decimal('value')->comments('折扣值'); //(根据优惠类型不同改变) + $table->unsignedInteger('total')->comments('优惠券数量'); + $table->unsignedInteger('used')->default(0)->comments('已使用数量'); + $table->decimal('min_amount', 10, 2)->comments('最低可享受优惠的订单金额'); + $table->datetime('not_before')->nullable()->comments('不可用时间'); + $table->datetime('not_after')->nullable()->comments('可用时间'); + $table->boolean('enabled')->comments('是否生效'); + $table->timestamps(); + ``` + * 模型 + ``` + /** + * 优惠券类型 + */ + const TYPE_FIXED = 'fixed'; + const TYPE_PERCENT = 'percent'; + + /** + * 类型名称地图 + */ + public static $typeMap = [ + self::TYPE_FIXED => '固定金额', + self::TYPE_PERCENT => '比例', + ]; + + /** + * 可填字段 + */ + protected $fillable = [ + 'name', + 'code', + 'type', + 'value', + 'total', + 'used', + 'min_amount', + 'not_before', + 'not_after', + 'enabled', + ]; + + /** + * 字段数据类型自动转换 + */ + protected $casts = [ + 'enabled' => 'boolean', + ]; + + /** + * 字段数据类型转时间对象 + */ + protected $dates = [ + 'not_before', + 'not_after' + ]; + ``` +2. 订单增加一个优惠券id外键字段:`php artisan make:migration orders_add_coupon_code_id --table=orders` + * 迁移文件 + ``` + //up() + $table->unsignedInteger('coupon_code_id')->nullable()->after('paid_at'); + $table->foreign('coupon_code_id')->references('id')->on('coupon_codes')->onDelete('set null'); + + //down() + $table->dropForeign(['coupon_code_id']); + $table->dropColumn('coupon_code_id'); + ``` + * 在 Order 模型中声明关系 + ``` + /** + * n:1 CouponCode + */ + public function couponCode() + { + return $this->belongsTo(CouponCode::class); + } + ``` +> 此时对数据库结构的操作结束,可以跑迁移了 `php artisan migrate` +3. 后台管理 + * 控制器 `php artisan admin:make CouponCodesController --model=App\\Models\\CouponCode` + ``` + /** + * 优惠券列表 + */ + public function index() + { + return Admin::content(function (Content $content) { + $content->header('优惠券列表'); + $content->body($this->grid()); + }); + } + + /** + * 列表表格内容 + */ + protected function grid() + { + return Admin::grid(CouponCode::class, function (Grid $grid) { + // 默认按创建时间倒序排序 + $grid->model()->orderBy('created_at', 'desc'); + $grid->id('ID')->sortable(); + $grid->name('名称'); + $grid->code('优惠码'); + $grid->type('类型')->display(function($value) { + return CouponCode::$typeMap[$value]; + }); + // 根据不同的折扣类型用对应的方式来展示 + $grid->value('折扣')->display(function($value) { + return $this->type === CouponCode::TYPE_FIXED ? '¥'.$value : $value.'%'; + }); + $grid->min_amount('最低金额'); + $grid->total('总量'); + $grid->used('已用'); + $grid->enabled('是否启用')->display(function($value) { + return $value ? '是' : '否'; + }); + $grid->created_at('创建时间'); + + $grid->actions(function ($actions) { + $actions->disableView(); + }); + }); + } + ``` + * 配置路由 app/Admin/routes.php `$router->get('coupon_codes', 'CouponCodesController@index');` + * 登陆后台,添加菜单,略。 +4. 示例数据填充 + * 模型增加方法:生成可用的优惠券代码 + ``` + use Illuminate\Support\Str; + + ... + + /** + * 生成可用的优惠券代码 + */ + public static function findAvailableCode($length = 16) + { + do { + // 生成一个指定长度的随机字符串,并转成大写 + $code = strtoupper(Str::random($length)); + // 如果生成的码已存在就继续循环 + } while (self::query()->where('code', $code)->exists()); + + return $code; + } + ``` + * 创建模型工厂 `php artisan make:factory CouponCodeFactory --model=Models/CouponCode` + ``` + define(App\Models\CouponCode::class, function (Faker $faker) { + // 首先随机取得一个类型 + $type = $faker->randomElement(array_keys(App\Models\CouponCode::$typeMap)); + // 根据取得的类型生成对应折扣 + $value = $type === App\Models\CouponCode::TYPE_FIXED ? random_int(1, 200) : random_int(1, 50); + + // 如果是固定金额,则最低订单金额必须要比优惠金额高 0.01 元 + if ($type === App\Models\CouponCode::TYPE_FIXED) { + $minAmount = $value + 0.01; + } else { + // 如果是百分比折扣,有 50% 概率不需要最低订单金额 + if (random_int(0, 100) < 50) { + $minAmount = 0; + } else { + $minAmount = random_int(100, 1000); + } + } + + return [ + 'name' => join(' ', $faker->words), // 随机生成名称 + 'code' => App\Models\CouponCode::findAvailableCode(), // 调用优惠码生成方法 + 'type' => $type, + 'value' => $value, + 'total' => 1000, + 'used' => 0, + 'min_amount' => $minAmount, + 'not_before' => null, + 'not_after' => null, + 'enabled' => true, + ]; + }); + ``` + * 进入 tinker `php artisan tinker` 全局函数调用模型工厂 , 创建虚拟数据: `factory(App\Models\CouponCode::class, 10)->create()` + > 此时10条示例的优惠券就已经创建成功了 +5. 在 CouponCode 模型中设置 `set{Xxx}Attribute()` 方法,展示通俗的优惠信息(比如 满多少减多少) + ``` + /** + * 格式化显示优惠券优惠详情 + * 此时调用 $couponCode->description 可以得出 “满多少减多少”或者“优惠多少%” + */ + protected $appends = ['description']; + public function getDescriptionAttribute() + { + $str = ''; + + if ($this->min_amount > 0) { + $str = '满'.str_replace('.00', '', $this->min_amount); + } + if ($this->type === self::TYPE_PERCENT) { + return $str.'优惠'.str_replace('.00', '', $this->value).'%'; + } + + return $str.'减'.str_replace('.00', '', $this->value); + } + ``` + * 改写 CouponCodesController@grid 方法,在后台的优惠券表格中展示 description 属性 + ``` + /** + * 列表表格内容 + */ + protected function grid() + { + return Admin::grid(CouponCode::class, function (Grid $grid) { + $grid->model()->orderBy('created_at', 'desc'); + $grid->id('ID')->sortable(); + $grid->name('名称'); + $grid->code('优惠码'); + $grid->description('描述'); + $grid->column('usage', '用量')->display(function ($value) { + return "{$this->used} / {$this->total}"; + }); + $grid->enabled('是否启用')->display(function ($value) { + return $value ? '是' : '否'; + }); + $grid->created_at('创建时间'); + }); + } + ``` + +# 优惠券的 CURD +* 新增优惠券 + * CouponCodesController + ``` + /** + * 新增优惠券 + */ + public function create() + { + return Admin::content(function (Content $content) { + $content->header('新增优惠券'); + $content->body($this->form()); + }); + } + + /** + * 新增和编辑表单 + */ + protected function form() + { + return Admin::form(CouponCode::class, function (Form $form) { + $form->display('id', 'ID'); + $form->text('name', '名称')->rules('required'); + // 为了保证优惠码统一,但是编辑时又需要排除自己优惠码 + $form->text('code', '优惠码')->rules(function($form) { + // 如果 $form->model()->id 不为空,代表是编辑操作 + if ($id = $form->model()->id) { + return 'nullable|unique:coupon_codes,code,'.$id.',id'; + } else { + return 'nullable|unique:coupon_codes'; + } + }); + $form->radio('type', '类型')->options(CouponCode::$typeMap)->rules('required'); + $form->text('value', '折扣')->rules(function ($form) { + if ($form->type === CouponCode::TYPE_PERCENT) { + // 如果选择了百分比折扣类型,那么折扣范围只能是 1 ~ 99 + return 'required|numeric|between:1,99'; + } else { + // 否则只要大等于 0.01 即可 + return 'required|numeric|min:0.01'; + } + }); + $form->text('total', '总量')->rules('required|numeric|min:0'); + $form->text('min_amount', '最低金额')->rules('required|numeric|min:0'); + $form->datetime('not_before', '开始时间'); + $form->datetime('not_after', '结束时间'); + $form->radio('enabled', '启用')->options(['1' => '是', '0' => '否']); + + $form->saving(function (Form $form) { + if (!$form->code) { + $form->code = CouponCode::findAvailableCode(); + } + }); + }); + } + ``` + * 配置路由 routes.php + ``` + $router->get('coupon_codes/create', 'CouponCodesController@create'); //添加优惠券 + $router->post('coupon_codes', 'CouponCodesController@store'); //保存新的优惠券 + ``` +* 编辑优惠券 + * 新增 CouponCodesController@edit + ``` + public function edit($id) + { + return Admin::content(function (Content $content) use ($id) { + $content->header('编辑优惠券'); + $content->body($this->form()->edit($id)); + }); + } + ``` + * 配置路由 + ``` + $router->get('coupon_codes/{id}/edit', 'CouponCodesController@edit'); //编辑优惠券 + $router->put('coupon_codes/{id}', 'CouponCodesController@update'); //更新优惠券 + ``` +* 删除优惠券,直接增加一条路由即可 `$router->delete('coupon_codes/{id}', 'CouponCodesController@destroy'); //删除优惠券` + +# 优惠券前台-检查优惠券 +* 创建优惠券控制器 `php artisan make:controller CouponCodesController` +``` +first()) { + abort(404); + } + + // 如果优惠券没有启用,则等同于优惠券不存在 + if (!$record->enabled) { + abort(404); + } + + if ($record->total - $record->used <= 0) { + return response()->json(['msg' => '该优惠券已被兑完'], 403); + } + + if ($record->not_before && $record->not_before->gt(Carbon::now())) { + return response()->json(['msg' => '该优惠券现在还不能使用'], 403); + } + + if ($record->not_after && $record->not_after->lt(Carbon::now())) { + return response()->json(['msg' => '该优惠券已过期'], 403); + } + + return $record; + } +} +``` +* 配置路由 routes/web.php `Route::get('coupon_codes/{code}', 'CouponCodesController@show')->name('coupon_codes.show'); //优惠券信息` (写在校验邮箱后的用户可访问的路由组中) +* 在购物车列表页面增加一个优惠券信息 ../cart/index.blade.php +``` + // 优惠券检查按钮 + $('#btn-check-coupon').click(function () { + // 获取用户输入的优惠码 + var code = $('input[name=coupon_code]').val(); + // 如果没有输入则弹框提示 + if(!code) { + swal('请输入优惠码', '', 'warning'); + return; + } + // 调用检查接口 + axios.get('/coupon_codes/' + encodeURIComponent(code)) + .then(function (response) { // then 方法的第一个参数是回调,请求成功时会被调用 + $('#coupon_desc').text(response.data.description); // 输出优惠信息 + $('input[name=coupon_code]').prop('readonly', true); // 禁用输入框 + $('#btn-cancel-coupon').show(); // 显示 取消 按钮 + $('#btn-check-coupon').hide(); // 隐藏 检查 按钮 + }, function (error) { + // 如果返回码是 404,说明优惠券不存在 + if(error.response.status === 404) { + swal('优惠码不存在', '', 'error'); + } else if (error.response.status === 403) { + // 如果返回码是 403,说明有其他条件不满足 + swal(error.response.data.msg, '', 'error'); + } else { + // 其他错误 + swal('系统内部错误', '', 'error'); + } + }) + }); + + // 取消使用优惠券 + $('#btn-cancel-coupon').click(function () { + $('#coupon_desc').text(''); // 隐藏优惠信息 + $('input[name=coupon_code]').prop('readonly', false); // 启用输入框 + $('#btn-cancel-coupon').hide(); // 隐藏 取消 按钮 + $('#btn-check-coupon').show(); // 显示 检查 按钮 + }); +``` + +# 使用优惠券下单 +* ../cart/index.blade.php 中,点击 “提交订单按钮” 中触发的点击事件增加一层逻辑:带上优惠券信息 +``` + // 生成订单 + $('.btn-create-order').click(function () { + // 构建请求参数,将用户选择的地址的 id 和备注内容写入请求参数 + var req = { + address_id: $('#order-form').find('select[name=address]').val(), + items: [], + remark: $('#order-form').find('textarea[name=remark]').val(), + coupon_code: $('input[name=coupon_code]').val(), // <=从优惠码输入框中获取优惠码 + }; + + ... +``` +* 创建一个异常抛送类 `php artisan make:exception CouponCodeUnavailableException` => 用于抛送优惠券引发的错误信息 +``` +expectsJson()) { + return response()->json(['msg' => $this->message], $this->code); + } + // 否则返回上一页并带上错误信息 + return redirect()->back()->withErrors(['coupon_code' => $this->message]); + } +} +``` +* app/Exceptions/Handler.php 中声明上面的异常处理类不需要记录在日志中 +``` + /** + * 以下声明的异常类抛出错误时不写入系统日志 + */ + protected $dontReport = [ + InvalidRequestException::class, + CouponCodeUnavailableException::class, // <= 新添加的 + ]; +``` +* 在 CouponCode 模型中增加一个检查优惠券错误的原因,抛送错误提示的方法 +``` +use Carbon\Carbon; +use App\Exceptions\CouponCodeUnavailableException; + +... + + /** + * 检查优惠券是否可用,不可用则抛送异常提示 + */ + public function checkAvailable($orderAmount = null) + { + if (!$this->enabled) { + throw new CouponCodeUnavailableException('优惠券不存在'); + } + + if ($this->total - $this->used <= 0) { + throw new CouponCodeUnavailableException('该优惠券已被兑完'); + } + + if ($this->not_before && $this->not_before->gt(Carbon::now())) { + throw new CouponCodeUnavailableException('该优惠券现在还不能使用'); + } + + if ($this->not_after && $this->not_after->lt(Carbon::now())) { + throw new CouponCodeUnavailableException('该优惠券已过期'); + } + + if (!is_null($orderAmount) && $orderAmount < $this->min_amount) { + throw new CouponCodeUnavailableException('订单金额不满足该优惠券最低金额'); + } + } +``` +* 重写 app/Http/Controllers/CouponCodesController@show (不在控制器层中写验证逻辑,而是调用模型中的方法验证) +``` +first()) { + throw new CouponCodeUnavailableException('优惠券不存在'); + } + + $record->checkAvailable(); + + return $record; + } +} +``` +* 在 CouponCode 模型中增加方法:计算使用了优惠券之后的订单的金额,增加/减少优惠券数量 +``` + /** + * 使用优惠券之后计算金额 + */ + public function getAdjustedPrice($orderAmount) + { + // 固定金额 + if ($this->type === self::TYPE_FIXED) { + // 为了保证系统健壮性,我们需要订单金额最少为 0.01 元 + return max(0.01, $orderAmount - $this->value); + } + + return number_format($orderAmount * (100 - $this->value) / 100, 2, '.', ''); + } + + /** + * 减少/增加优惠券可使用次数 + */ + public function changeUsed($increase = true) + { + // 传入 true 代表使用了优惠券,增加该优惠券被使用次数 + if ($increase) { + // 与检查 SKU 库存类似,这里需要检查当前用量是否已经超过总量 + return $this->newQuery()->where('id', $this->id)->where('used', '<', $this->total)->increment('used'); + } else { + return $this->decrement('used'); + } + } +``` +* 编辑 app/Service/OrderService.php (我们之前封装的订单创建服务类,创建订单的逻辑都写在里面) +``` +use App\Models\CouponCode; +use App\Exceptions\CouponCodeUnavailableException; + +... + + public function store(User $user, UserAddress $address, $remark, $items, CouponCode $coupon = null) // <= 这里注入优惠券,默认为空(因为有的订单可能没用优惠券) + { + // 如果传入了优惠券,则先检查是否可用 + if ($coupon) { + // 但此时我们还没有计算出订单总金额,因此先不校验 + $coupon->checkAvailable(); + } + + // 开启一个数据库事务 + $order = \DB::transaction(function () use ($user, $address, $remark, $items, $coupon) { // <= 这里同样把参数 $coupon 给进去 + + // 遍历用户提交的 SKU + foreach ($items as $data) { + + ... + + } + + // 遍历完之后,已经计算出总价,接下来需要校验优惠券的可用性 + if ($coupon) { + // 总金额已经计算出来了,检查是否符合优惠券规则 + $coupon->checkAvailable($totalAmount); + // 把订单金额修改为优惠后的金额 + $totalAmount = $coupon->getAdjustedPrice($totalAmount); + // 将订单与优惠券关联 + $order->couponCode()->associate($coupon); + // 增加优惠券的用量,需判断返回值 + if ($coupon->changeUsed() <= 0) { + throw new CouponCodeUnavailableException('该优惠券已被兑完'); + } + } +``` +* 编辑 app/Http/Controllers/OrdersController@store 方法, +``` + /** + * 生成订单 + */ + public function store(OrderRequest $request, OrderService $orderService) + { + $user = $request->user(); + $address = UserAddress::find($request->input('address_id')); + $coupon = null; // <= 默认的优惠券 + + // 如果用户提交了优惠码 + if ($code = $request->input('coupon_code')) { + $coupon = CouponCode::where('code', $code)->first(); + if (!$coupon) { + throw new CouponCodeUnavailableException('优惠券不存在'); + } + } + + // 参数中加入 $coupon 变量 + return $orderService->store($user, $address, $request->input('remark'), $request->input('items'), $coupon); + } +``` + +# 完善和优化逻辑 +1. 一个优惠券,一个用户只能使用一次,编辑 CouponCode 模型中的 checkAvailable 方法:验证用户是否用过优惠券 + ``` + /** + * 检查优惠券是否可用,不可用则抛送异常提示 + */ + public function checkAvailable(User $user, $orderAmount = null) // <= 这里多一个参数 $user + { + if (!$this->enabled) { + throw new CouponCodeUnavailableException('优惠券不存在'); + } + + if ($this->total - $this->used <= 0) { + throw new CouponCodeUnavailableException('该优惠券已被兑完'); + } + + if ($this->not_before && $this->not_before->gt(Carbon::now())) { + throw new CouponCodeUnavailableException('该优惠券现在还不能使用'); + } + + if ($this->not_after && $this->not_after->lt(Carbon::now())) { + throw new CouponCodeUnavailableException('该优惠券已过期'); + } + + if (!is_null($orderAmount) && $orderAmount < $this->min_amount) { + throw new CouponCodeUnavailableException('订单金额不满足该优惠券最低金额'); + } + + // 判断用户是否使用过该优惠券 + $used = Order::where('user_id', $user->id) + ->where('coupon_code_id', $this->id) + ->where(function($query) { + $query->where(function($query) { + $query->whereNull('paid_at') + ->where('closed', false); + })->orWhere(function($query) { + $query->whereNotNull('paid_at') + ->where('refund_status', Order::REFUND_STATUS_PENDING); + }); + }) + ->exists(); + if ($used) { + throw new CouponCodeUnavailableException('你已经使用过这张优惠券了'); + } + } + ``` + * 同时还需要完善 CouponCodesController@show 以及 OrderService@store 中的逻辑 + ``` + # CouponCodesController@show + /** + * 检查优惠券 + */ + public function show($code) + { + if (!$record = CouponCode::where('code', $code)->first()) { + throw new CouponCodeUnavailableException('优惠券不存在'); + } + + $record->checkAvailable($request->user()); // <= 这里把用户传过去 + + return $record; + } + + # OrderService@store + public function store(User $user, UserAddress $address, $remark, $items, CouponCode $coupon = null) + { + if ($coupon) { + $coupon->checkAvailable($user); // <= 这里检查 + } + + $order = \DB::transaction(function () use ($user, $address, $remark, $items, $coupon) { + + ... + + if ($coupon) { + $coupon->checkAvailable($user, $totalAmount); // <= 这里同时检查订单金额是否达到可用优惠券的最低价 + + ... + + } + } + } + ``` +2. 在订单页面展示优惠券信息 ../orders/show.blade.php +``` +
+ @if($order->couponCode) +
+ 优惠信息: +
{{ $order->couponCode->description }}
+
+ @endif +
+ 订单总价: +
¥{{ $order->total_amount }}
+
+``` +3. 当订单金额不满足优惠券使用最低价格的时候展示错误信息 ../cart/index.blade.php +``` + // 生成订单 + $('.btn-create-order').click(function () { + // 构建请求参数,将用户选择的地址的 id 和备注内容写入请求参数 + var req = { + address_id: $('#order-form').find('select[name=address]').val(), + items: [], + remark: $('#order-form').find('textarea[name=remark]').val(), + coupon_code: $('input[name=coupon_code]').val(), // <=从优惠码输入框中获取优惠码 + }; + + ... + + axios.post('{{ route('orders.store') }}', req) + .then(function (response) { + swal('订单提交成功', '', 'success') + .then(() => { + location.href = '/orders/' + response.data.id; + }); + }, function (error) { + + ... + + // **添加这里** + } else if (error.response.status === 403) { // 这里判断状态 403 + swal(error.response.data.msg, '', 'error'); + } else { + swal('系统错误', '', 'error'); + } + }); + }); +``` +* 关闭订单的时候,如果订单使用了优惠券,那么把优惠券的使用次数减回去。 +``` + public function handle() + { + // 判断对应的订单是否已经被支付 + // 如果已经支付则不需要关闭订单,直接退出 + if ($this->order->paid_at) { + return; + } + // 通过事务执行 sql + \DB::transaction(function() { + + ... + + // **减少优惠券已用次数** + if ($this->order->couponCode) { + $this->order->couponCode->changeUsed(false); + } + }); + } +``` \ No newline at end of file diff --git a/laravel/laravel5/12.md b/laravel/laravel5/12.md new file mode 100644 index 0000000..c741ec9 --- /dev/null +++ b/laravel/laravel5/12.md @@ -0,0 +1,211 @@ +> 11 节结束后,整个项目的功能就已经全部完成了,接下来处理一些小逻辑 + +# 后台权限分配 +1. 以 admin 的身份登陆后台管理,创建权限:商品管理、订单管理、优惠券管理 +2. 将权限交给之前创建的运营角色即可 + +# 虚拟数据填充 +1. 用户数据 + * UserFactory + ``` + return [ + 'name' => $faker->name, + 'email' => $faker->unique()->safeEmail, + 'password' => '$2y$10$TKh8H1.PfQx37YgCzwiKb.KjNyWgaHb9cbcoQgdIVFlYg7B77UdFm', // secret + 'remember_token' => str_random(10), + 'email_verified' => true, // <= 因为数据表中增加了字段,这里也虚拟生成 + ]; + ``` + * 创建 Seeder `php artisan make:seed UsersTableSeeder` + ``` + // run() + // 生成 100 条假数据 + factory(\App\Models\User::class, 100)->create(); + ``` +2. 收货地址 + * 模型工厂之前开发时写过了。 + * 创建 Seeder `php artisan make:seed UserAddressesTableSeeder` + ``` + use App\Models\User; + use App\Models\UserAddress; + + ... + + public function run() + { + User::all()->each(function (User $user) { + factory(UserAddress::class, random_int(1, 3))->create(['user_id' => $user->id]); + }); + } + ``` +3. 优惠券 + * 模型工厂写过了 + * `php artisan make:seed CouponCodesTableSeeder` + ``` + // run() + factory(\App\Models\CouponCode::class, 20)->create(); + ``` +4. 订单 + * 创建模型工厂 `php artisan make:factory OrderFactory --model=Models/Order` => 大订单 + ``` + define(Order::class, function (Faker $faker) { + // 随机取一个用户 + $user = User::query()->inRandomOrder()->first(); + // 随机取一个该用户的地址 + $address = $user->addresses()->inRandomOrder()->first(); + // 10% 的概率把订单标记为退款 + $refund = random_int(0, 10) < 1; + // 随机生成发货状态 + $ship = $faker->randomElement(array_keys(Order::$shipStatusMap)); + // 优惠券 + $coupon = null; + // 30% 概率该订单使用了优惠券 + if (random_int(0, 10) < 3) { + // 为了避免出现逻辑错误,我们只选择没有最低金额限制的优惠券 + $coupon = CouponCode::query()->where('min_amount', 0)->inRandomOrder()->first(); + // 增加优惠券的使用量 + $coupon->changeUsed(); + } + + return [ + 'address' => [ + 'address' => $address->full_address, + 'zip' => $address->zip, + 'contact_name' => $address->contact_name, + 'contact_phone' => $address->contact_phone, + ], + 'total_amount' => 0, + 'remark' => $faker->sentence, + 'paid_at' => $faker->dateTimeBetween('-30 days'), // 30天前到现在任意时间点 + 'payment_method' => $faker->randomElement(['wechat', 'alipay']), + 'payment_no' => $faker->uuid, + 'refund_status' => $refund ? Order::REFUND_STATUS_SUCCESS : Order::REFUND_STATUS_PENDING, + 'refund_no' => $refund ? Order::getAvailableRefundNo() : null, + 'closed' => false, + 'reviewed' => random_int(0, 10) > 2, + 'ship_status' => $ship, + 'ship_data' => $ship === Order::SHIP_STATUS_PENDING ? null : [ + 'express_company' => $faker->company, + 'express_no' => $faker->uuid, + ], + 'extra' => $refund ? ['refund_reason' => $faker->sentence] : [], + 'user_id' => $user->id, + 'coupon_code_id' => $coupon ? $coupon->id : null, + ]; + }); + ``` + * 创建模型工厂 `php artisan make:factory OrderItemFactory --model=Models/OrderItem` => 订单详情 + ``` + define(OrderItem::class, function (Faker $faker) { + // 从数据库随机取一条商品 + $product = Product::query()->where('on_sale', true)->inRandomOrder()->first(); + // 从该商品的 SKU 中随机取一条 + $sku = $product->skus()->inRandomOrder()->first(); + + return [ + 'amount' => random_int(1, 5), // 购买数量随机 1 - 5 份 + 'price' => $sku->price, + 'rating' => null, + 'review' => null, + 'reviewed_at' => null, + 'product_id' => $product->id, + 'product_sku_id' => $sku->id, + ]; + }); + ``` + * 创建 Seeder `php artisan make:seeder OrdersSeeder` + ``` + create(); + // 被购买的商品,用于后面更新商品销量和评分 + $products = collect([]); + foreach ($orders as $order) { + // 每笔订单随机 1 - 3 个商品 + $items = factory(OrderItem::class, random_int(1, 3))->create([ + 'order_id' => $order->id, + 'rating' => $order->reviewed ? random_int(1, 5) : null, // 随机评分 1 - 5 + 'review' => $order->reviewed ? $faker->sentence : null, + 'reviewed_at' => $order->reviewed ? $faker->dateTimeBetween($order->paid_at) : null, // 评价时间不能早于支付时间 + ]); + + // 计算总价 + $total = $items->sum(function (OrderItem $item) { + return $item->price * $item->amount; + }); + + // 如果有优惠券,则计算优惠后价格 + if ($order->couponCode) { + $total = $order->couponCode->getAdjustedPrice($total); + } + + // 更新订单总价 + $order->update([ + 'total_amount' => $total, + ]); + + // 将这笔订单的商品合并到商品集合中 + $products = $products->merge($items->pluck('product')); + } + + // 根据商品 ID 过滤掉重复的商品 + $products->unique('id')->each(function (Product $product) { + // 查出该商品的销量、评分、评价数 + $result = OrderItem::query() + ->where('product_id', $product->id) + ->whereHas('order', function ($query) { + $query->whereNotNull('paid_at'); + }) + ->first([ + \DB::raw('count(*) as review_count'), + \DB::raw('avg(rating) as rating'), + \DB::raw('sum(amount) as sold_count'), + ]); + + $product->update([ + 'rating' => $result->rating ?: 5, // 如果某个商品没有评分,则默认为 5 分 + 'review_count' => $result->review_count, + 'sold_count' => $result->sold_count, + ]); + }); + } + } + ``` +5. 刷新数据库 + * 现在 DatabaseSeeder.php 中注册我们上面写的 Seeder + ``` + public function run() + { + $this->call(UsersTableSeeder::class); //用户表 + $this->call(UserAddressesTableSeeder::class); //收货地址表 + $this->call(ProductsSeeder::class); //商品(sku)表 + $this->call(OrdersSeerder::class); //订单(item)表 + $this->call(CouponCodesTableSeeder::class); //优惠券 + } + ``` + * 执行迁移并刷新数据库 `php artisan migrate:refresh --seed` \ No newline at end of file diff --git a/laravel/laravel5/6.md b/laravel/laravel5/6.md new file mode 100644 index 0000000..f3c7a79 --- /dev/null +++ b/laravel/laravel5/6.md @@ -0,0 +1,288 @@ +# 购物车功能 +> 购物车的数据通常会保存到 Session 或者数据库。对于拥有多个端(网页、App)的电商网站来说为了保障用户体验会使用数据库来保存购物车中的数据,这样用户在网页端加入购物车的商品也能在 App 中看到。 + +# 创建模型和数据表 +* 命令 `php artisan make:model Models/CartItem -m` +``` +$table->increments('id'); +$table->unsignedInteger('user_id'); +$table->foreign('user_id')->references('id')->on('users')->onDelete('cascade'); +$table->unsignedInteger('product_sku_id'); +$table->foreign('product_sku_id')->references('id')->on('product_skus')->onDelete('cascade'); +$table->unsignedInteger('amount'); //数量 +``` +* 编辑模型 CartItem +``` + /** + * 可填字段 + */ + protected $fillable = ['amount']; + + /** + * 禁用时间戳 + */ + public $timestamps = false; + + /** + * n:1 User + */ + public function user() + { + return $this->belongsTo(User::class); + } + + /** + * n:1 ProductSku + */ + public function productSku() + { + return $this->belongsTo(ProductSku::class); + } +``` +* 在 User 模型中绑定多对一关系 +``` + /** + * 购物车 + * 1:n CartItem + */ + public function cartItems() + { + return $this->hasMany(CartItem::class); + } +``` +> 最后执行迁移 `php artisan migrate` + +# 添加商品到购物车 +* 创建控制器 `php artisan make:controller CartController` +* 创建请求类 `php artisan make:request AddCartRequest` => 添加商品的时候要校验一下\ +``` + [ + 'required', + function ($attribute, $value, $fail) { + if (!$sku = ProductSku::find($value)) { + $fail('该商品不存在'); + return; + } + if (!$sku->product->on_sale) { + $fail('该商品未上架'); + return; + } + if ($sku->stock === 0) { + $fail('该商品已售完'); + return; + } + if ($this->input('amount') > 0 && $sku->stock < $this->input('amount')) { + $fail('该商品库存不足'); + return; + } + }, + ], + 'amount' => ['required', 'integer', 'min:1'], + ]; + } + + /** + * 字段名称 + */ + public function attributes() + { + return [ + 'amount' => '商品数量' + ]; + } + + /** + * 提示信息 + */ + public function messages() + { + return [ + 'sku_id.required' => '请选择商品' + ]; + } +} +``` +> 注意继承之前创建的基础请求类 Request 而不是自动创建时溜出来的 FormRequest +* 编辑控制器的添加方法 CartController@add +``` +// 引用请求类和模型 +use app\Http\Request\AddCartRequest; +use App\Models\CartItem; + +... + + /** + * 添加商品到购物车 + */ + public function add(AddCartRequest $request) + { + // 获取请求用户、商品id、添加的数量 + $user = $request->user(); + $skuId = $request->input('sku_id'); + $amount = $request->input('amount'); + + // 判断商品是否已经存在 + if ($cart = $user->cartItems()->where('product_sku_id', $skuId)->first()) { + // 如果存在则直接叠加商品数量 + $cart->update([ + 'amount' => $cart->amount + $amount, + ]); + } else { + // 否则创建一个新的购物车记录 + $cart = new CartItem(['amount' => $amount]); + $cart->user()->associate($user); + $cart->productSku()->associate($skuId); + $cart->save(); + } + + return []; + } +``` +> `$cart->user()->associate($user)` => 关联 $cart 和 $user (也就相当于做了一次赋值 `$cart->user_id = $user->id`) +* 给这个方法配置路由 +``` +// 以下是已验证邮箱的用户可以访问的路由 +Route::group(['middleware' => 'email_verified'], function() { + ... + Route::post('cart', 'CartController@add')->name('cart.add'); //添加商品到购物车 +}); +``` +* 处理视图 ../products/show.blade.php +``` + // 添加商品到购物车 + $('.btn-add-to-cart').click(function () { + // 请求加入购物车接口 + axios.post('{{ route('cart.add') }}', { + sku_id: $('label.active input[name=skus]').val(), + amount: $('.cart_amount input').val(), + }) + .then(function () { // 请求成功执行此回调 + swal('加入购物车成功', '', 'success'); + }, function (error) { // 请求失败执行此回调 + if (error.response.status === 401) { + + // http 状态码为 401 代表用户未登陆 + swal('请先登录', '', 'error'); + + } else if (error.response.status === 422) { + + // http 状态码为 422 代表用户输入校验失败 + var html = '
'; + _.each(error.response.data.errors, function (errors) { + _.each(errors, function (error) { + html += error + '
'; + }) + }); + html += '
'; + swal({ content: $(html)[0], icon: 'error' }) + } else { + + // 其他情况应该是系统挂了 + swal('系统错误', '', 'error'); + } + }) + }); +``` +* laravel 中关于 http 请求响应状态码 + * `200` => 响应成功,用 axios 请求接口,返回200,那么就会直接调用 `.then()` + * `401` => 响应失败,这是因为没有登陆,从中间件就排出去了,返回的状态吗可以通过 `error.response.status` 读取 + * `422` => 响应失败,这是因为 AddCartRequest 校验数据失败 + * `500` 以及其他状态码 => 这种问题通常是后台的问题,所以去查看控制器层的相关逻辑排错 + +# 查看购物车 +* 添加方法 CartController@index +``` + /** + * 购物车列表 + */ + public function index(Request $request) + { + // $request->user() => 读取当前请求用户 + // $cartItems() => 读取档期那用户的购物车 + // with(['productSku.product']) => 防止 N+1 查询,查询当前购物车对应的商品 SKU 信息和商品信息 + $cartItems = $request->user()->cartItems()->with(['productSku.product'])->get(); + + return view('cart.index', ['cartItems' => $cartItems]); + } +``` +* 配置路由 `Route::get('cart', 'CartController@index')->name('cart.index'); //购物车信息` +* 前端视图 (../cart/index.blade.php) 样式参考教程. +* 最后在 ../layouts/_header.blade.php 中已登陆用户部分添加一个入口链接即可. + +# 将商品移除购物车 +* 添加删除方法 CartController@remove +``` +use App\Models\ProductSku; + +... + + /** + * 从购物车中删除 + */ + public function remove(ProductSku $sku, Request $request) + { + $request->user()->cartItems()->where('product_sku_id', $sku->id)->delete(); + + return []; + } +``` +* 新增路由 `Route::delete('cart/{sku}', 'CartController@remove')->name('cart.remove'); //从购物车中删除商品` +* 视图 ../cart/index.blade.php 中增加前端js逻辑代码 +``` +@section('scriptsAfterJs') + +@endsection +``` \ No newline at end of file diff --git a/laravel/laravel5/7.md b/laravel/laravel5/7.md new file mode 100644 index 0000000..63227b7 --- /dev/null +++ b/laravel/laravel5/7.md @@ -0,0 +1,884 @@ +# 订单系统 +> 由于我们的一笔订单支持多个商品 SKU,因此我们需要 orders 和 order_items 两张表,orders 保存用户、金额、收货地址等信息,order_items 则保存商品 SKU ID、数量以及与 orders 表的关联. +* 创建模型和数据表 `php artisan make:model Models/Order -m`, `php artisan make:model Models/OrderItem -m` + * orders 表的迁移 + ``` + $table->increments('id'); + $table->string('no')->unique()->comments('流水号'); + $table->unsignedInteger('user_id')->comments('购买用户'); + $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade'); + $table->text('address')->comments('收获地址 json 快照'); + $table->decimal('total_amount')->comments('总价'); + $table->text('remark')->nullable()->comments('备注'); + $table->dateTime('paid_at')->nullable()->comments('支付时间'); + $table->string('payment_method')->nullable()->comments('支付方式'); + $table->string('payment_no')->nullable()->comments('支付平台订单号'); + $table->string('refund_status')->default(\App\Models\Order::REFUND_STATUS_PENDING)->comments('退款状态'); + $table->string('refund_no')->nullable()->comments('退款单号'); + $table->boolean('closed')->default(false)->comments('订单是否关闭'); + $table->boolean('reviewed')->default(false)->comments('订单是否评价'); + $table->string('ship_status')->default(\App\Models\Order::SHIP_STATUS_PENDING)->comments('物流状态'); + $table->text('ship_data')->nullable()->comments('物流数据'); + $table->text('extra')->nullable()->comments('额外数据'); + $table->timestamps(); + ``` + > 这里退款状态和物流状态都是读取的 Order 模型中的静态常量,详情参考下面模型处的代码 + * order_items 表的迁移 + ``` + $table->increments('id'); + $table->unsignedInteger('order_id')->comments('订单外键'); + $table->foreign('order_id')->references('id')->on('orders')->onDelete('cascade'); + $table->unsignedInteger('product_id')->comments('商品外键'); + $table->foreign('product_id')->references('id')->on('products')->onDelete('cascade'); + $table->unsignedInteger('product_sku_id')->comments('商品 SKU 外键'); + $table->foreign('product_sku_id')->references('id')->on('product_skus')->onDelete('cascade'); + $table->unsignedInteger('amount')->comments('购买数量'); + $table->decimal('price', 10, 2)->comments('商品单价'); + $table->unsignedInteger('rating')->nullable()->comments('用户打分'); + $table->text('review')->nullable()->comments('用户评价'); + $table->timestamp('reviewed_at')->nullable()->comments('评价时间'); + ``` + * Order 模型 + ``` + /** + * 退款状态 + */ + const REFUND_STATUS_PENDING = 'pending'; + const REFUND_STATUS_APPLIED = 'applied'; + const REFUND_STATUS_PROCESSING = 'processing'; + const REFUND_STATUS_SUCCESS = 'success'; + const REFUND_STATUS_FAILED = 'failed'; + + /** + * 物流状态 + */ + const SHIP_STATUS_PENDING = 'pending'; + const SHIP_STATUS_DELIVERED = 'delivered'; + const SHIP_STATUS_RECEIVED = 'received'; + + /** + * 退款状态对应中文名称 + */ + public static $refundStatusMap = [ + self::REFUND_STATUS_PENDING => '未退款', + self::REFUND_STATUS_APPLIED => '已申请退款', + self::REFUND_STATUS_PROCESSING => '退款中', + self::REFUND_STATUS_SUCCESS => '退款成功', + self::REFUND_STATUS_FAILED => '退款失败', + ]; + + /** + * 物流状态对应中文名称 + */ + public static $shipStatusMap = [ + self::SHIP_STATUS_PENDING => '未发货', + self::SHIP_STATUS_DELIVERED => '已发货', + self::SHIP_STATUS_RECEIVED => '已收货', + ]; + + /** + * 可填字段 + */ + protected $fillable = [ + 'no', + 'address', + 'total_amount', + 'remark', + 'paid_at', + 'payment_method', + 'payment_no', + 'refund_status', + 'refund_no', + 'closed', + 'reviewed', + 'ship_status', + 'ship_data', + 'extra', + ]; + + /** + * 字段数据类型自动转换 + */ + protected $casts = [ + 'closed' => 'boolean', + 'reviewed' => 'boolean', + 'address' => 'json', + 'ship_data' => 'json', + 'extra' => 'json', + ]; + + /** + * 时间字段转换 + */ + protected $dates = [ + 'paid_at', + ]; + + /** + * 引导函数 + */ + protected static function boot() + { + parent::boot(); + + // 监听模型创建事件,在写入数据库之前触发 + static::creating(function ($model) { + // 如果模型的 no 字段为空 + if (!$model->no) { + // 调用 findAvailableNo 生成订单流水号 + $model->no = static::findAvailableNo(); + // 如果生成失败,则终止创建订单 + if (!$model->no) { + return false; + } + } + }); + } + + /** + * n:1 User + */ + public function user() + { + return $this->belongsTo(User::class); + } + + /** + * 1:n OrderItems + */ + public function items() + { + return $this->hasMany(OrderItem::class); + } + + /** + * 生成订单流水号 + */ + public static function findAvailableNo() + { + // 订单流水号前缀 + $prefix = date('YmdHis'); + for ($i = 0; $i < 10; $i++) { + // 随机生成 6 位的数字 + $no = $prefix.str_pad(random_int(0, 999999), 6, '0', STR_PAD_LEFT); + // 判断是否已经存在 + if (!static::query()->where('no', $no)->exists()) { + return $no; + } + } + \Log::warning('find order no failed'); + + return false; + } + ``` + * OrderItem 模型 + ``` + /** + * 可填字段 + */ + protected $fillable = ['amount', 'price', 'rating', 'review', 'reviewed_at']; + + /** + * 字段自动转时间 + */ + protected $dates = ['reviewed_at']; + + /** + * 创建和保存时不写入时间戳 + */ + public $timestamps = false; + + /** + * n:1 Product + */ + public function product() + { + return $this->belongsTo(Product::class); + } + + /** + * n:1 ProductSku + */ + public function productSku() + { + return $this->belongsTo(ProductSku::class); + } + + /** + * n:1 Order + */ + public function order() + { + return $this->belongsTo(Order::class); + } + ``` + +# 生成订单 +1. 编辑 CartController@index => 在访问购物车页面的时,顺便把当前用户的收获地址也传过去,方便用户下单时选择收货地址 +``` + /** + * 购物车列表 + */ + public function index(Request $request) + { + // $request->user() => 读取当前请求用户 + // $cartItems() => 读取当前用户的购物车 + // with(['productSku.product']) => 防止 N+1 查询,查询当前购物车对应的商品 SKU 信息和商品信息 + $cartItems = $request->user()->cartItems()->with(['productSku.product'])->get(); + + // 获取收货地址 + $addresses = $request->user()->addresses()->orderBy('last_used_at', 'desc')->get(); + + return view('cart.index', [ + 'cartItems' => $cartItems, + 'addresses' => $addresses, + ]); + } +``` +2. 在视图 ../cart/index.blade.php 中增加表单(省略) +3. 创建控制器 `php artisan make:controller OrdersController` +4. 创建请求类以验证数据 `php artisan make:request OrderRequest`, +``` + ['required', Rule::exists('user_addresses', 'id')->where('user_id', $this->user()->id)], + 'items' => ['required', 'array'], + 'items.*.sku_id' => [ // 检查 items 数组下每一个子数组的 sku_id 参数 + 'required', + // 自定义校验规则($要验证的字段名, $字段值, $fail这里代指一个抛出错误 $errors 的函数) + function ($attribute, $value, $fail) { + // 找到商品 SKU 对象 + $sku = ProductSku::find($value); + // 判断商品是否存在 + if (!$sku) { + $fail('该商品不存在'); + return; + } + // 判断商品是否在售 + if (!$sku->product->on_sale) { + $fail('该商品未上架'); + return; + } + // 判断是否还有货 + if ($sku->stock === 0) { + $fail('该商品已售完'); + return; + } + // 获取当前索引 + preg_match('/items\.(\d+)\.sku_id/', $attribute, $m); + $index = $m[1]; + // 根据索引找到用户所提交的购买数量 + $amount = $this->input('items')[$index]['amount']; + // 判断用户要买的数量是否小于库存 + if ($amount > 0 && $amount > $sku->stock) { + $fail('该商品库存不足'); + return; + } + }, + ], + 'items.*.amount' => ['required', 'integer', 'min:1'], + ]; + } +} +``` +> 验证规则可以用数组的形式写 `'字段' => ['规则1', '规则2', ...]` + +> `Rule::exists('user_addresses', 'id')->where('user_id', $this->user()->id)` => 通过 `$this->user()->id` 获取发起请求的用户,以这个参数作为 where 条件,查询数据库,判断收获地址是否存在. + +> `items.*.sku_id` => 为 items 字段(是一个数组)下的所有元素中的 sku_id (这里 * 就代表所有元素) 制定校验规则.(最后会遍历一次 items 数组) + +> 自定义验证规则 `'字段' => ['规则1, function ($attribute, $value, $fail) {}']` => 第二个参数就是验证规则,它的三个参数: `$attribute` => 当前被验证的字段名(就是 '字段' => [...] 里面的 '字段' 这两个字), `$val` => 字段值, `$fail` => 应该是一个抛送错误提示信息的对象。 + +> `$fail('xxx')` => 生成一条错误提示信息。 + +> `preg_match('/items\.(\d+)\.sku_id/', $attribute, $m);` => 这里的 $arrtibute 就是遍历过程中的字段名 item.0.sku_id ,这里就是为了将 0 这个下标提取出来(存为 `$index` ),然后通过 `$this->input('items')[$index]['amount']` 来读取用户要购买的这个商品 SKU 的数量,再拿去比较,看看是否小于存库. + +1. 完成 OrdersController@store => 生成订单 +``` +user(); + // 开启一个数据库事务 + $order = \DB::transaction(function () use ($user, $request) { + // 读取收货地址 + $address = UserAddress::find($request->input('address_id')); + // 更新此地址的最后使用时间 + $address->update(['last_used_at' => Carbon::now()]); + // 创建一个订单 + $order = new Order([ + 'address' => [ // 将地址信息放入订单中 + 'address' => $address->full_address, + 'zip' => $address->zip, + 'contact_name' => $address->contact_name, + 'contact_phone' => $address->contact_phone, + ], + 'remark' => $request->input('remark'), + 'total_amount' => 0, + ]); + // 订单关联到当前用户 + $order->user()->associate($user); + // 写入数据库 + $order->save(); + // 此时创建了一个写有 用户id, 收获地址快照json(数组自动转成了 json), 购买备注, 以及总金额为0的一个订单. + + // 现在开始处理 order_items 表的写入,完成之后更新订单总价 + // 初始化订单总价 + $totalAmount = 0; + // 获取提交的 SKU 信息 + $items = $request->input('items'); + // 遍历用户提交的 SKU + foreach ($items as $data) { + // 获取当前被遍历的 SKU + $sku = ProductSku::find($data['sku_id']); + // 创建一个 OrderItem 并直接与当前订单关联 + $item = $order->items()->make([ + 'amount' => $data['amount'], //写入数量 + 'price' => $sku->price, //写入单价 + ]); + // 关联商品id + $item->product()->associate($sku->product_id); + // 关联 SKU id + $item->productSku()->associate($sku); + // 保存一条数据到 order_items 表 + $item->save(); + // 增加总价 + $totalAmount += $sku->price * $data['amount']; + // 减少库存,同时防止超卖 + if ($sku->decreaseStock($data['amount']) <= 0) { + throw new InvalidRequestException('该商品库存不足'); + } + } + // 此时根据订单信息,写入了多条订单详情数据到 order_items 表中 + + // 更新订单总金额 + $order->update(['total_amount' => $totalAmount]); + + // 将下单的商品从购物车中移除 + $skuIds = collect($request->input('items'))->pluck('sku_id'); + $user->cartItems()->whereIn('product_sku_id', $skuIds)->delete(); + + // 结束事务,返回 $order + return $order; + }); + + return $order; + } +} +``` + +> `\DB::transaction()` => 开启数据库事务,接收一个闭包函数作参数,在闭包中可以对多张数据表进行操控,最后一起保存:在每一次生成订单的事务中,我们先操作 user_addresses 表(获取用户下单时选择的收获地址,然后更新该收获地址的最新使用时间), 然后操作 orders 表(写入一条新的订单数据:购买用户id, 收货地址json快照, 购买备注, 并且设置总金额为0), 然后遍历用户下单时传过来的商品SKU 信息(遍历每一条数据,写入 order_items 表:写入购买的商品, 具体的商品型号sku, 购买的数量, 单价. 同时每一次遍历结束前,计算叠加一次最新总价,减少库存), 然后再次操作 orders 表:更新之前创建的订单的总金额, 最后操作 cart_items 表(请阔购物车). + +> `$item = $order->items()->make([])` => 这里其实只是创建了一个对象,但是没有入库,最后 `$item->save()` 才是正式入库了. + +> `$item->product()->associate($sku->product_id);` 在正式入库前, 我们通过 `..->associate()` 写入外键. + +> 减少库存的方法 `$sku->decreaseStock($data['amount'])` 写在 ProductSku 模型中, 详情稍后说明, 这个函数的返回值是操作的数据库受影响的行数,所以最后判断是否写入成功(失败0条数据受影响)就是判断函数的返回值是否大于0 + +> 如果减少库存操作出错,那么 `throw new InvalidRequestException('该商品库存不足');` => 抛出异常,且由于在数据库事务中执行,那么会直接回滚数据库到之前的状态. + +> `collect($request->input('items'))->pluck('sku_id');` => 遍历传递过来的 items ,读取里面的 sku_id, 返回值就是所有 sku_id 字段组成的数组. + +6. 关于减少/增加库存的方法,定义在 ProductSku 模型中 +``` +use App\Exceptions\InternalException; // <= 这里用内部报错 + +... + + /** + * 减少库存 + */ + public function decreaseStock($amount) + { + // 做一次判断:判断要减少的库存数量得大于0 + if ($amount < 0) { + throw new InternalException('减库存不可小于0'); + } + + // 操作数据库: + return $this->newQuery()->where('id', $this->id)->where('stock', '>=', $amount)->decrement('stock', $amount); + } + + /** + * 增加库存 + */ + public function addStock($amount) + { + if ($amount < 0) { + throw new InternalException('加库存不可小于0'); + } + $this->increment('stock', $amount); + } +``` +7. 配置路由 `Route::post('orders', 'OrdersController@store')->name('orders.store'); //生成订单` +8. 完善前端逻辑 ../crat/index.blade.php +``` +# 写在全选/反选后面 + + // 生成订单 + $('.btn-create-order').click(function () { + // 构建请求参数,将用户选择的地址的 id 和备注内容写入请求参数 + var req = { + address_id: $('#order-form').find('select[name=address]').val(), + items: [], + remark: $('#order-form').find('textarea[name=remark]').val(), + }; + // 遍历 标签内所有带有 data-id 属性的 标签,也就是每一个购物车中的商品 SKU + $('table tr[data-id]').each(function () { + // 获取当前行的单选框 + var $checkbox = $(this).find('input[name=select][type=checkbox]'); + // 如果单选框被禁用或者没有被选中则跳过 + if ($checkbox.prop('disabled') || !$checkbox.prop('checked')) { + return; + } + // 获取当前行中数量输入框 + var $input = $(this).find('input[name=amount]'); + // 如果用户将数量设为 0 或者不是一个数字,则也跳过 + if ($input.val() == 0 || isNaN($input.val())) { + return; + } + // 把 SKU id 和数量存入请求参数数组中 + req.items.push({ + sku_id: $(this).data('id'), + amount: $input.val(), + }) + }); + axios.post('{{ route('orders.store') }}', req) + .then(function (response) { + swal('订单提交成功', '', 'success') + .then(() => { + location.href = '/orders/' + response.data.id; + }); + }, function (error) { + if (error.response.status === 422) { + // http 状态码为 422 代表用户输入校验失败 + var html = '
'; + _.each(error.response.data.errors, function (errors) { + _.each(errors, function (error) { + html += error + '
'; + }) + }); + html += '
'; + swal({ content: $(html)[0], icon: 'error' }) + } else { + // 其他情况应该是系统挂了 + swal('系统错误', '', 'error'); + } + }); + }); +``` + +# 关闭未支付的订单 +> 下单完成后,无论用户支付还是没支付,都会导致库存减少。恶意用户可以通过无限下单,但不支付,导致商品库存被占,其他用户买不了。为了杜绝这一情况的发生,用延时任务完成 +1. 为了使用 redis ,安装 predis 扩展 + * 命令 `composer require predis/predis` + * 编辑配置文件 .env + ``` + QUEUE_DRIVER=redis + ``` +2. 创建任务 `php artisan make:job CloseOrder` => 创建的文件位于 app/Jobs +``` +order = $order; + // 设置延迟的时间,delay() 方法的参数代表多少秒之后执行 + $this->delay($delay); + } + + // 定义这个任务类具体的执行逻辑 + // 当队列处理器从队列中取出任务时,会调用 handle() 方法 + public function handle() + { + // 判断对应的订单是否已经被支付 + // 如果已经支付则不需要关闭订单,直接退出 + if ($this->order->paid_at) { + return; + } + // 通过事务执行 sql + \DB::transaction(function() { + // 将订单的 closed 字段标记为 true,即关闭订单 + $this->order->update(['closed' => true]); + // 循环遍历订单中的商品 SKU,将订单中的数量加回到 SKU 的库存中去 + foreach ($this->order->items as $item) { + $item->productSku->addStock($item->amount); + } + }); + } +} +``` +3. 在 OrdersController@store 方法中触发这个任务 +``` +use App\Jobs\CloseOrder; + +... + + // 写在最后 return $order 之前 + // 开始延时计划任务:在一段时间后关闭订单 + $this->dispatch(new CloseOrder($order, config('app.order_ttl'))); +``` +> 这里的一段时间定义在 config/app.php 中 `'order_ttl' => 30,` (单位秒),方便测试这里写30秒,以后根据需要设置时间即可。 +4. 测试 + * 开启队列任务监视 `php artisan queue:work` + * 然后在网页中,打开某个商品的详情,记住数量,添加到购物车,下单,此时数量减少,然后注意控制台,当出现 + ``` + [执行时间] Processing: App\Jobs\CloseOrder + [执行时间] Processed: App\Jobs\CloseOrder + ``` + > 此时说明订单已经关闭,订单对应的商品的数量回到之前的数量。 + +# 用户订单列表 +1. 控制器 OrdersController@index +``` + /** + * 用户订单列表 + */ + public function index(Request $request) + { + $orders = Order::query() + ->where('user_id', $request->user()->id) //查询当前用户的订单 + ->orderBy('created_at', 'desc') //根据创建时间排序 + ->with(['items.product', 'items.productSku']) //顺便把商品 SKU 信息查出来防止 N+1 查询 + ->paginate(); + + return view('orders.index', ['orders' => $orders]); + } +``` +2. 路由 `Route::get('orders', 'OrdersController@index')->name('orders.index'); //用户订单列表` +3. 视图( ../orders/index.blade.php )详情和样式参考教程,唯一需要注意的就是订单状态部分 +``` +@if($order->paid_at) + @if($order->refund_status === \App\Models\Order::REFUND_STATUS_PENDING) + 已支付 + @else + {{ \App\Models\Order::$refundStatusMap[$order->refund_status] }} + @endif +@elseif($order->closed) + 已关闭 +@else + 未支付 +
请于 {{ $order->created_at->addSeconds(config('app.order_ttl'))->format('H:i') }} 前完成支付 +
否则订单将自动关闭 +@endif +``` +> `$order->created_at->addSeconds(config('app.order_ttl'))->format('H:i')` => `$order->created_at` 订单创建时间, `addSeconds(config('app.order_ttl'))` 增加秒数, `->format('H:i')` 变为 “小时:分钟” 的格式。(最后显示的就是订单关闭的时间) +4. 增加入口链接“我的订单” ../layouts/_header.blade.php(略) + +# 用户订单详情 +1. 控制器 OrdersController@show +``` + /** + * 用户订单详情 + */ + public function show(Order $order, Request $request) + { + return view('orders.show', ['order' => $order->load(['items.productSku', 'items.product'])]); + } +``` +2. 路由 `Route::get('orders/{order}', 'OrdersController@show')->name('orders.show'); //用户订单详情` +3. 视图 ( ../orders/show.blade.php ),略 +4. 增加入口链接 ../orders/index.blade.php,略 +5. 确保当前用户只能查看自己的订单 + * 创建授权策略类 `php artisan make:policy OrderPolicy --model=Models/Order` + ``` + /** + * 确保操作的订单是自己的 + */ + public function own(User $user, Order $order) + { + return $order->user_id == $user->id; + } + ``` + * 注册授权策略类 app/Providers/AuthServiceProvider.php + ``` + protected $policies = [ + 'App\Model' => 'App\Policies\ModelPolicy', + \App\Models\UserAddress::class => \App\Policies\UserAddressPolicy::class, + \App\Models\Order::class => \App\Policies\OrderPolicy::class, + ]; + ``` + * 在控制器层 OrdersController@show 中授权 `$this->authorize('own', $order);` + +# 封装业务代码 +> 我们已经完成了购物车功能和下单功能,但是我们会发现我们在 Controller 里面写了大量的包含复杂逻辑的业务代码,这是一个坏习惯,这样子随着需求的增加,我们的控制器很快就变得臃肿。如果以后我们要开发 App 端,这些代码可能需要在 Api 的 Controller 里再重复一遍,假如出现业务逻辑的调整就需要修改两个或更多地方,这明显是不合理的。因此我们需要对 逻辑复杂 的 业务代码 进行封装。 +* 创建一个文件夹 app/Services => 用于存放相关业务逻辑的封装代码文件 +* 购物车 app/Services/CartService.php +``` +cartItems()->with(['productSku.product'])->get(); + } + + /** + * 添加商品 + */ + public function add($skuId, $amount) + { + $user = Auth::user(); + // 从数据库中查询该商品是否已经在购物车中 + if ($item = $user->cartItems()->where('product_sku_id', $skuId)->first()) { + // 如果存在则直接叠加商品数量 + $item->update([ + 'amount' => $item->amount + $amount, + ]); + } else { + // 否则创建一个新的购物车记录 + $item = new CartItem(['amount' => $amount]); + $item->user()->associate($user); + $item->productSku()->associate($skuId); + $item->save(); + } + + return $item; + } + + /** + * 删除商品 + */ + public function remove($skuIds) + { + // 可以传单个 ID,也可以传 ID 数组 + if (!is_array($skuIds)) { + $skuIds = [$skuIds]; + } + Auth::user()->cartItems()->whereIn('product_sku_id', $skuIds)->delete(); + } +} +``` +* 改写 CartController +``` +cartService = $cartService; + } + + /** + * 用户购物车列表 + */ + public function index(Request $request) + { + $cartItems = $this->cartService->get(); + $addresses = $request->user()->addresses()->orderBy('last_used_at', 'desc')->get(); + + return view('cart.index', ['cartItems' => $cartItems, 'addresses' => $addresses]); + } + + /** + * 购物车:添加商品 + */ + public function add(AddCartRequest $request) + { + $this->cartService->add($request->input('sku_id'), $request->input('amount')); + + return []; + } + + /** + * 购物车:减少商品 + */ + public function remove(ProductSku $sku, Request $request) + { + $this->cartService->remove($sku->id); + + return []; + } +} +``` +> 1, `use App\Services\CartService;` => 引用我们封装的操作类 +> 2, 定义一个变量 `protected $cartService;` 然后在构造函数的参数列表中实例化上面的操作类 `public function __construct(CartService $cartService)` 在内部将这个操作类赋给 `$this->cartService = $cartService`, 此时就可以用 $this->cartService 来调用我们写在 app/Services/CartService.php 中的方法来操作购物车了。 + +* 改写 OrdersController@store +``` +use App\Services\CartService; + + /** + * 生成订单 + */ + public function store(OrderRequest $request, CartService $cartService) + { + ... + + // 开启一个数据库事务 + $order = \DB::transaction(function () use ($user, $request) { + ... + + // 将下单的商品从购物车中移除 + $skuIds = collect($request->input('items'))->pluck('sku_id')->all(); + $cartService->remove($skuIds); + + ... + }); + + ... + } +``` +> 为什么在 CartController 里面要实例化,而在 OrdersController 中在方法参数列表中实例化的原因是因为 CartController 每个方法都要用 CartService 提供的方法,而 OrdersController 中就只在生成订单的方法中,生成完毕后清空购物车中。 +--------------------------------------------------------------------------------- +* 封装 OrdersController 中的相关业务代码,首先新建一个 app/Services/OrderService.php +``` +update(['last_used_at' => Carbon::now()]); + // 创建一个订单 + $order = new Order([ + 'address' => [ // 将地址信息放入订单中 + 'address' => $address->full_address, + 'zip' => $address->zip, + 'contact_name' => $address->contact_name, + 'contact_phone' => $address->contact_phone, + ], + 'remark' => $remark, + 'total_amount' => 0, + ]); + // 订单关联到当前用户 + $order->user()->associate($user); + // 写入数据库 + $order->save(); + + $totalAmount = 0; + // 遍历用户提交的 SKU + foreach ($items as $data) { + $sku = ProductSku::find($data['sku_id']); + // 创建一个 OrderItem 并直接与当前订单关联 + $item = $order->items()->make([ + 'amount' => $data['amount'], + 'price' => $sku->price, + ]); + $item->product()->associate($sku->product_id); + $item->productSku()->associate($sku); + $item->save(); + $totalAmount += $sku->price * $data['amount']; + if ($sku->decreaseStock($data['amount']) <= 0) { + throw new InvalidRequestException('该商品库存不足'); + } + } + // 更新订单总金额 + $order->update(['total_amount' => $totalAmount]); + + // 将下单的商品从购物车中移除 + $skuIds = collect($items)->pluck('sku_id')->all(); + app(CartService::class)->remove($skuIds); + + return $order; + }); + + // 这里我们直接使用 dispatch 函数 + dispatch(new CloseOrder($order, config('app.order_ttl'))); + + return $order; + } +} +``` +> 1,在之前的写法中,我们是直接用 `$request->user()` 来读取发起请求的用户,但是要注意 **Request 不可以出现在控制器和中间件以外的地方** + +> 2,这里直接用 `app(CartService::class)` 来实例化了 CartService 这个我们在上一步封装的购物车操作类。在我们代码里调用封装的库时一定 **不可以** 使用 new 关键字来初始化,而是应该通过 Laravel 的容器来初始化,因为在之后的开发过程中 CartService 类的构造函数可能会发生变化,比如注入了其他的类,如果我们使用 new 来初始化的话,就需要在每个调用此类的地方进行修改;而使用 app() 或者自动解析注入等方式 Laravel 则会自动帮我们处理掉这些依赖。(说白了你要 new 的话可能由于你 new 的这个 CartService 内部还 new 了其他不少类,导致项目运行效率大打折扣) + +> 3,这里的 `dispatch(new CloseOrder($order, config('app.order_ttl')))` 是一个全局助手函数(控制器里面可以用 `$this->dispatch() 调用`,但是这里不行,所以 Laravel 给我们提供了这么一个全局函数,直接调就行) + +* 最后处理 OrdersController.php +``` + /** + * 生成订单 + */ + public function store(OrderRequest $request, OrderService $orderService) + { + $user = $request->user(); + $address = UserAddress::find($request->input('address_id')); + + return $orderService->store($user, $address, $request->input('remark'), $request->input('items')); + } +``` +> 接上面注意事项的第1点:因为不能在 OrderService 里面通过 `$request->user()` 读发起请求的用户,所以只好在控制器中先读好 `$user = $request->user();` 然后再作为实参传给 OrderService 中的 store() 方法。 + +* Laravel - Service 模型的 [参考文档](https://oomusou.io/laravel/service/) \ No newline at end of file diff --git a/laravel/laravel5/8.md b/laravel/laravel5/8.md new file mode 100644 index 0000000..b9853c3 --- /dev/null +++ b/laravel/laravel5/8.md @@ -0,0 +1,815 @@ +# 微信和支付宝支付扩展 +* [yansongda/pay](https://yansongda.gitbooks.io/pay/) 安装命令 `composer require yansongda/pay` +* 创建一个配置文件 config/pay.php +``` + [ + 'app_id' => '', + 'ali_public_key' => '', + 'private_key' => '', + 'log' => [ + 'file' => storage_path('logs/alipay.log'), + ], + ], + + /** + * 微信支付需要的参数 + */ + 'wechat' => [ + 'app_id' => '', + 'mch_id' => '', + 'key' => '', + 'cert_client' => '', + 'cert_key' => '', + 'log' => [ + 'file' => storage_path('logs/wechat_pay.log'), + ], + ], +]; +``` +> 相关参数在后续开发中配置 +* 注入容器:编辑 app/Providers/AppServiceProvider@register +``` +use Monolog\Logger; +use Yansongda\Pay\Pay; + +... + + /** + * 注册服务容器对象 + */ + public function register() + { + // 注入 alipay 对象 + $this->app->singleton('alipay', function () { + // 获取配置文件信息(config/pay.php 中的 alipay 数组的信息) + $config = config('pay.alipay'); + // 判断当前项目运行环境是否为测试环境 + if (app()->environment() !== 'production') { + $config['mode'] = 'dev'; //设置配置为开发模式 + $config['log']['level'] = Logger::DEBUG; //以 debug 级别记录日志 + } else { //否则为生产环境(项目在上线运营) + $config['log']['level'] = Logger::WARNING; //否则以 warning 级别记录日志 + } + // 返回一个实例化 alipay 支付宝支付对象(由 yansongda/pay 扩展提供) + return Pay::alipay($config); + }); + + // 注入 wechat_pay 对象 + $this->app->singleton('wechat_pay', function () { + // 获取配置文件信息 + $config = config('pay.wechat'); + // 判断当前项目是否为测试环境 + if (app()->environment() !== 'production') { + $config['log']['level'] = Logger::DEBUG; + // 因为微信支付没有 dev 开发模式,所以这里之配置日志即可 + } else { + $config['log']['level'] = Logger::WARNING; + } + // 返回一个实例化 wechat 微信支付对象 + return Pay::wechat($config); + }); + } +``` +* 测试:进入 tinker ,调用 `app('alipay')` 或者 `app('wechat_pay')` 看看是否能够成功的实例化支付对象。(在项目开发中,随时可以调用全局函数 `app('alipay')` 等直接实例化一个支付对象。) + +# 支付宝支付 +* 访问 [生成密钥方法](https://opensupport.alipay.com/support/knowledge/20069/201602048385?ant_source=zsearch) 选择合适的版本下载密钥生成工具 + * 下好之后解压打开,选择密钥格式为 PKCS1, 长度 2048, 点击生成密钥 +* 注册支付宝[沙箱应用](https://openhome.alipay.com/platform/appDaily.htm?tab=info) => 一个假的支付接口,用于开发测试 + * 点击必看部分中的 “设置应用公钥” + * 点击弹窗中的 “设置应用公钥” => 将密钥生成工具生成的公钥复制到里面,点保存 + * 点击查看支付宝公钥,确认是否设置成功 +------------------------------------------------------------ +* 编辑 config/pay.php +``` +'app_id' => '支付宝沙箱应用网页上的 APPID', +'ali_public_key' => '支付宝沙箱应用网页上的 支付宝公钥', +'private_key' => '本地密钥生成工具生成的私钥', +``` +------------------------------------------------------------ +* 测试: + * 增加一条测试路由 routes/web.php + ``` + // 支付测试 + Route::get('alipay', function() { + return app('alipay')->web([ + 'out_trade_no' => time(), //订单流水 + 'total_amount' => '1', //订单金额(单位元) + 'subject' => 'test subject - 测试', //订单标题 + ]); + }); + ``` + * 访问 `项目网站/alipay` => 会自动跳转到支付宝支付页面,支付的账号密码可以在 [沙箱账号](https://openhome.alipay.com/platform/appDaily.htm?tab=account) 中查看,最后可以完成一次支付。 +-------------------------------------------------------------- + +# 完成支付宝支付功能 +* 创建一个支付控制器 `php artisan make:controller PaymentController` +``` +use App\Models\Order; +use App\Exceptions\InvalidRequestException; + +... + + /** + * 跳转到支付宝支付页面 + */ + public function payByAlipay(Order $order, Request $request) + { + // 判断订单是否属于当前用户 + $this->authorize('own', $order); + + // 订单已支付或者已关闭 + if ($order->paid_at || $order->closed) { + throw new InvalidRequestException('订单状态不正确'); + } + + // 调用支付宝的网页支付 + return app('alipay')->web([ + 'out_trade_no' => $order->no, // 订单编号,需保证在商户端不重复 + 'total_amount' => $order->total_amount, // 订单金额,单位元,支持小数点后两位 + 'subject' => '支付 Laravel Shop 的订单:'.$order->no, // 订单标题 + ]); + } + + /** + * 支付宝-前端回调(浏览器) + */ + public function alipayReturn() + { + // 校验提交的参数是否合法 + $data = app('alipay')->verify(); + dd($data); + } + + /** + * 支付宝-后端回调(数据库) + */ + public function alipayNotify() + { + $data = app('alipay')->verify(); + \Log::debug('Alipay notify', $data->all()); + } +``` +> **前端回调** 当用户支付成功之后支付宝会让用户浏览器跳转回项目页面并带上支付成功的参数,也就是说前端回调依赖于用户浏览器,**如果用户在跳转之前关闭浏览器,将无法收到前端回调**。 + +> `app('alipay')->verify()` => 验证提交的参数是否合法:支付宝的前端跳转会带有数据签名,通过校验数据签名可以判断参数是否被恶意用户篡改。同时该方法还会返回解析后的参数。 + +> **后端回调** 支付成功之后支付宝的服务器会用订单相关数据作为参数请求项目的接口,不依赖用户浏览器。(支付宝请求我们项目的某个方法) + +> `\Log::debug('Alipay notify', $data->all());` => 因为不能直接 `dd()` 打印服务器端回调接收的参数,所以需要通过日志的方式来保存 +------------------------------------------------------------------ +* 配置路由 routes/web.php +``` +Route::group(['middleware' => 'email_verified'], function() { + + ... + + Route::get('payment/{order}/alipay', 'PaymentController@payByAlipay')->name('payment.alipay'); //支付宝支付 + Route::get('payment/alipay/return', 'PaymentController@alipayReturn')->name('payment.alipay.return'); //支付宝-前端回调 +}); +Route::post('payment/alipay/notify', 'PaymentController@alipayNotify')->name('payment.alipay.notify');// 支付宝-后端回调 +``` +> 这里前端回调要写在已登陆且已验证邮箱的用户可以访问的路由组中,而后端回调不可以。 +* 编辑 app/Providers/AppServiceProvider@register => 设置回调路由 +``` + // 注入 alipay 对象 + $this->app->singleton('alipay', function () { + // 获取配置文件信息(config/pay.php 中的 alipay 数组的信息) + $config = config('pay.alipay'); + + // **设置回调路由** + $config['notify_url'] = route('payment.alipay.notify'); //后端回调 + $config['return_url'] = route('payment.alipay.return'); //前端回调 + + // 判断当前项目运行环境是否为测试环境 + if (app()->environment() !== 'production') { + $config['mode'] = 'dev'; //设置配置为开发模式 + $config['log']['level'] = Logger::DEBUG; //以 debug 级别记录日志 + } else { //否则为生产环境(项目在上线运营) + $config['log']['level'] = Logger::WARNING; //否则以 warning 级别记录日志 + } + // 返回一个实例化 alipay 支付宝支付对象(由 yansongda/pay 扩展提供) + return Pay::alipay($config); + }); +``` +> 这里可以用 `route('路由名称')` => 来读取我们的前后端回调的接口路由。 +------------------------------------------------------------- +* 测试 +> 这里有个问题需要注意:我们的项目在本地开发,在浏览器环境下,支付成功后,可以完成前端回调,但是后端不行(因为服务器没在互联网上),所以需要一些特别的手段: +* [requestbin](https://requestbin.fullcontact.com/) => 是一个免费开源的网站,任何人都可以在上面申请一个专属的 URL(通常有效期 48 小时),对这个 URL 的任何类型的请求都会被记录下来,URL 的创建者可以看到请求的具体信息,包含请求时间、请求头、请求具体数据等。 + * 打开后点击页面上的 “Create a RequestBin” => 就创建了一台可以接收支付宝后端回调信息的的服务器。 + * 然后复制 “Bin URL” ,编辑 app/Providers/AppServiceProvider@register 支付宝对象注入那一部分, 把后端回调地址改成(别关闭) + ``` + $config['notify_url'] = '新开的 Bin URL'; // <=后端回调 + ``` + * 此时再下单、支付一次,前端回调将打印支付宝回传过来的信息,而后端信息可以在 RequestBin 网页中右上角的圆点处打开查看. + * 注意此时页面中的 RAW BODY 这一段代码,复制它,然后在控制台中输入 `curl -XPOST http://shop.test/payment/alipay/notify -d'复制 raw body 内容'` => 用 curl 请求这个钩子,看看具体的内容 + * 但其实此时仍然会出错,输入命令之后会返回给我们一个 html 文本信息, title是 **Page Expired** => 原因是因为这个请求动作没有带上 csrf 认证,因此我们需要在 app/Http/Middleware/VerifyCsrfToken.php 中配置:禁用对这个钩子的 csrf 认证 + ``` + /** + * 取消 csrf 认证 + */ + protected $except = [ + 'payment/alipay/notify', // <= 当请求这个 url 时,不需要 csrf 认证. + ]; + ``` + * 再次输入 curl 命令请求,会返回空,因为 PaymentController@alipayNotify 方法,即后端回调方法,是记录日志的形式接收支付宝回调钩子的具体信息的: `\Log::debug('Alipay notify', $data->all());` + * 那么此时输入 `less storage/logs/laravel.log` => 查看 laravel 日志,按快捷键 shift + P 即可跳到最后面查看支付宝支付成功的信息,大概是这样的. + ``` + Alipay notify {"gmt_create":"2018-08-28 13:40:49","charset":"GBK","gmt_payment":"2018-08-28 13:40:58","notify_time":"2018-08-28 13:45:37","subject":"支付 Laravel Shop 的订单:20180828054030470977","sign":"Z84wtT+PxXwKoCZf84rK9K5696B1SY7/2XuyJHBS1dZ3u+/4O+CBEmfJ2r9IO7GULcGy+x0lg64G5+cd93TsJqjrUZCrnltzfCWUYFniTVWo/XMi54s5YmKLjz9zsynj8neDNPuP9bQ3jNO8BFNVP2ejqbftkbOzKQE9mcYln1uT8G4d4f/eht2rITib20+I7grO0yMXCORwNRxCYv3PRuED9lOoA9vmh/dS06vvmIHoYuFPgD/4dpGuZjz0KFA19JjmaCG5RjpuNuRg9sCKblJzNr5mXkDhC+GmQpDV8khT8sQ5IgjLaXFeUX07X0whxirDkSKAlvyy0/d5sukpUA==","buyer_id":"2088102176547557","invoice_amount":"562.00","version":"1.0","notify_id":"0406353a7930a33f9ad174578aeacd2k8u","fund_bill_list":"[{\"amount\":\"562.00\",\"fundChannel\":\"ALIPAYACCOUNT\"}]","notify_type":"trade_status_sync","out_trade_no":"20180828054030470977","total_amount":"562.00","trade_status":"TRADE_SUCCESS","trade_no":"2018082821001004550200676168","auth_app_id":"2016091600524507","receipt_amount":"562.00","point_amount":"0.00","app_id":"2016091600524507","buyer_pay_amount":"562.00","sign_type":"RSA2","seller_id":"2088102175885689"} + ``` + > 如果上面的信息无误,按 Q 退出查看日志,此时前端回调(支付完成后浏览器页面返回我们的项目),以及后端回调(支付完成后支付宝隔一段时间就会自动请求我们的后端接口一次) + +* 完成前端,后端回调 +``` + /** + * 支付宝-前端回调(浏览器) + */ + public function alipayReturn() + { + // 校验数据 + try { + app('alipay')->verify(); + } catch (\Exception $e) { //如果出错则抛出异常 + return view('pages.error', ['msg' => '数据不正确']); + } + + // 否则就跳转到通知页面告诉用户支付成功 + return view('pages.success', ['msg' => '付款成功']); + } + + /** + * 支付宝-后端回调(修改数据库,通知支付宝不用继续请求这个接口了) + */ + public function alipayNotify() + { + // 校验输入参数 + $data = app('alipay')->verify(); + // $data->out_trade_no 拿到订单流水号,并在数据库中查询 + $order = Order::where('no', $data->out_trade_no)->first(); + // 正常来说不太可能出现支付了一笔不存在的订单,这个判断只是加强系统健壮性。 + if (!$order) { + return 'fail'; + } + // 如果这笔订单的状态已经是已支付 + if ($order->paid_at) { + // 返回数据给支付宝 + return app('alipay')->success(); + } + + // 修改订单状态 + $order->update([ + 'paid_at' => Carbon::now(), // 支付时间 + 'payment_method' => 'alipay', // 支付方式 + 'payment_no' => $data->trade_no, // 支付宝订单号 + ]); + + // 告诉支付宝,不用再继续请求我们的接口了,我们已经收到了用户成功地通过支付宝支付的信息,且更新了我们的数据库了 + return app('alipay')->success(); + } +``` +> 此时后端回调还是要用 requestBin 进行处理. + +# 支付宝支付的总结 +1. 支付流程: + * 用户生成订单 + * 拉起支付 => 当用户点击支付宝支付时 , 请求支付页面 PaymentController@payByAlipay , 会跳转到支付宝的支付页面. + * 在支付宝支付的页面不用我们处理 , 那是支付宝里面的事 + * 在用户支付完成后, 支付宝那边会发送两个请求到我们这边: 前端回调(回到我们的支付成功提示页面), 后端回调(告诉后台已经支付成功) + * 前端回调我们只需要抛出一个提示信息,告诉用户:已经支付成功. + * 后端回调我们则需要更新数据库,同时告诉支付宝,我们收到了用户支付成功的消息,不用再请求我们的后端回调方法了(支付宝没有收到我们成功的消息会一直隔一段时间发送请求) + * 在支付宝请求我们的后端回调方法时,是不带 csrf 认证 token 的,目前的解决方法就是配置 app/Http/Middleware/VerifyCsrfToken@`$except` => 添加我们的后端接口 url,告诉 laravel 当别人请求这个地址的时候,不用校验是否有 csrf_token +2. 具体支付操作 + * 实例化支付宝对象 + 1. 我们将不会改变的配置信息写在文件 config/pay.php 中 + ``` + /** + * 支付宝支付需要的参数 + */ + 'alipay' => [ + 'app_id' => '支付宝中注册的应用id', + 'ali_public_key' => '支付公钥', + 'private_key' => '服务器私钥', + 'log' => [ //日志 + 'file' => storage_path('logs/alipay.log'), + ], + ] + ``` + 2. 我们将可能会改变的信息和实例化支付对象的过程写在 app/Providers/AppServiceProvider.php 中 + ``` + // 注入 alipay 对象 + $this->app->singleton('alipay', function () { + // 获取配置文件信息(config/pay.php 中的 alipay 数组的信息) + $config = config('pay.alipay'); + // 设置回调路由 + $config['notify_url'] = 'http://requestbin.fullcontact.com/1f0uvg61'; //后端回调 + $config['return_url'] = route('payment.alipay.return'); //前端回调 + // 判断当前项目运行环境是否为测试环境 + if (app()->environment() !== 'production') { + $config['mode'] = 'dev'; //设置配置为开发模式 + $config['log']['level'] = Logger::DEBUG; //以 debug 级别记录日志 + } else { //否则为生产环境(项目在上线运营) + $config['log']['level'] = Logger::WARNING; //否则以 warning 级别记录日志 + } + // 返回一个实例化 alipay 支付宝支付对象(由 yansongda/pay 扩展提供) + return Pay::alipay($config); + }); + ``` + > `$config = config('pay.alipay')` => 读取 config/pay.php 中的 alipay 数组的信息(不经常改变的支付宝支付配置信息), 存储在 $config 变量中 + + > `$config['notify_url']` 和 `$config['return_url']` 这是在继续完成配置信息:配置后端回调的地址和前端回调的地址 + + > `app()->enviroment()` => 如果项目是生产环境已投入运营,那么这个函数的返回值是 `'production'`,我们判断这个函数的返回值,看看项目的运行环境,根据生产环境的不同我们需要给支付宝支付对象配置一些不同的信息,比如 `$config['mode']` 参数.(生产环境在 .env `APP_ENV=local` 这一参数中修改) + + > `Pay::alipay($config)` => 这是在实例化一个由 yansongda/pay 扩展提供的支付宝支付对象(里面为我们封装了拉起支付等方法,只需要我们传指定参数即可) + * 拉起支付(展示支付页面): + ``` + /** + * 跳转到支付宝支付页面 + */ + public function payByAlipay(Order $order, Request $request) + { + // 判断订单是否属于当前用户 + $this->authorize('own', $order); + + // 订单已支付或者已关闭 + if ($order->paid_at || $order->closed) { + throw new InvalidRequestException('订单状态不正确'); + } + + // 调用支付宝的网页支付 + return app('alipay')->web([ + 'out_trade_no' => $order->no, // 订单编号,需保证在商户端不重复 + 'total_amount' => $order->total_amount, // 订单金额,单位元,支持小数点后两位 + 'subject' => '支付 Laravel Shop 的订单:'.$order->no, // 订单标题 + ]); + } + ``` + > 前两步无需多言,就是验证一下订单是否可用 + + > 调用支付宝的网页支付过程中, 首先 `app('alipay')` => 通过注入服务容器,我们直接用这个函数实例化了一个支付宝支付的对象 + + > `..->web()` => 接收一个数组作为参数,数组需要给3个值: 订单编号 `out_trade_no`, 总金额 `total_amount`, 订单标题 `subject` , 这些参数都可以从当前订单中读取 + + * 回调函数的处理 + ``` + /** + * 支付宝-前端回调(浏览器) + */ + public function alipayReturn() + { + // 校验数据 + try { + app('alipay')->verify(); + } catch (\Exception $e) { //如果出错则抛出异常 + return view('pages.error', ['msg' => '数据不正确']); + } + + // 否则就跳转到通知页面告诉用户支付成功 + return view('pages.success', ['msg' => '付款成功']); + } + + /** + * 支付宝-后端回调(修改数据库,通知支付宝不用继续请求这个接口了) + */ + public function alipayNotify() + { + // 校验输入参数 + $data = app('alipay')->verify(); + // $data->out_trade_no 拿到订单流水号,并在数据库中查询 + $order = Order::where('no', $data->out_trade_no)->first(); + // 正常来说不太可能出现支付了一笔不存在的订单,这个判断只是加强系统健壮性。 + if (!$order) { + return 'fail'; + } + // 如果这笔订单的状态已经是已支付 + if ($order->paid_at) { + // 返回数据给支付宝 + return app('alipay')->success(); + } + + // 修改订单状态 + $order->update([ + 'paid_at' => Carbon::now(), // 支付时间 + 'payment_method' => 'alipay', // 支付方式 + 'payment_no' => $data->trade_no, // 支付宝订单号 + ]); + + // 告诉支付宝,不用再继续请求我们的接口了,我们已经收到了用户成功地通过支付宝支付的信息,且更新了我们的数据库了 + return app('alipay')->success(); + } + ``` + > 前端回调是在浏览器中进行的,后端回调是在互联网上(支付宝服务器和我们服务器的后台互相沟通)进行的. + + > `app('alipay')->verify();` => 校验请求回调的参数(支付宝服务器请求我们的服务器,回调一定是正确的,这一步是为了防止恶意用户请求我们的两个回调方法) + + > `return app('alipay')->success();` => 这是告诉支付宝,我们后端的处理已经完成了,你也不用再请求我们这个方法了. + + * 本地开发后端回调需要处理的就是因为服务器不在互联网上,所以需要用 requestBin 接收支付宝的请求,然后我们自己复制支付宝请求的相关内容,在自己本地进行处理. + +# 微信支付-准备工作 +> 微信支付需要商业资质,所以暂时无法完成,仅作了解 +1. 注册账号 + * 访问 [微信支付商户平台](https://pay.weixin.qq.com/),注册 + * 点击上方 "产品中心"->选择页面中的 "扫码支付"-> 开通扫码支付-> 点击产品设置-> 记住商户号 + * 然后跳转到 "账户中心"->左侧选择 "API 安全"-> 点击页面中的 "下载证书" + * 将下载好的文件中的 apiclient_cert.pem 以及 apiclient_key.pem 放到项目的 resources/wechat_pay/ 中(新建一个目录) + * 回到 "API 安全" 页, 选择页面中的 "设置密钥", 这个密钥可以在 [这里](http://www.unit-conversion.info/texttools/random-string-generator/) 生成, 页面中 length 填32,点击 "Generate" 生成一个32位的随机数,填写到微信 "设置密钥" 中去 +2. 编辑配置文件 +``` + /** + * 微信支付需要的参数 + */ + 'wechat' => [ + 'app_id' => 'wx*******', // 公众号 app id + 'mch_id' => '14*****', // 第一步获取到的商户号 + 'key' => '******', // 刚刚设置的 API 密钥 + 'cert_client' => resource_path('wechat_pay/apiclient_cert.pem'), + 'cert_key' => resource_path('wechat_pay/apiclient_key.pem'), + 'log' => [ + 'file' => storage_path('logs/wechat_pay.log'), + ], + ], +``` + +# 微信支付 +* PaymentController => 拉起支付和支付回调 +``` + /** + * 微信支付-拉起网页扫码支付 + */ + public function payByWechat(Order $order, Request $request) + { + // 校验权限 + $this->authorize('own', $order); + // 校验订单状态 + if ($order->paid_at || $order->closed) { + throw new InvalidRequestException('订单状态不正确'); + } + // scan 方法为拉起微信扫码支付 + return app('wechat_pay')->scan([ + 'out_trade_no' => $order->no, // 商户订单流水号,与支付宝 out_trade_no 一样 + 'total_fee' => $order->total_amount * 100, // 与支付宝不同,微信支付的金额单位是分。 + 'body' => '支付 Laravel Shop 的订单:'.$order->no, // 订单描述 + ]); + } + + /** + * 微信支付-回调函数 + */ + public function wechatNotify() + { + // 校验回调参数是否正确 + $data = app('wechat_pay')->verify(); + // 找到对应的订单 + $order = Order::where('no', $data->out_trade_no)->first(); + // 订单不存在则告知微信支付 + if (!$order) { + return 'fail'; + } + // 订单已支付 + if ($order->paid_at) { + // 告知微信支付此订单已处理 + return app('wechat_pay')->success(); + } + + // 将订单标记为已支付 + $order->update([ + 'paid_at' => Carbon::now(), + 'payment_method' => 'wechat', + 'payment_no' => $data->transaction_id, + ]); + + return app('wechat_pay')->success(); + } +``` +* 配置路由 routes/web.php +``` + Route::group(['middleware' => 'email_verified'], function () { + + ... + + Route::get('payment/{order}/wechat', 'PaymentController@payByWechat')->name('payment.wechat'); //微信扫码支付 + }); + Route::post('payment/wechat/notify', 'PaymentController@wechatNotify')->name('payment.wechat.notify'); //微信支付-后端回调 +``` +> 同理,扫码支付需要写在用户已登陆的路由组里,而后端回调不可以. +* 在服务容器的注入中,设置配置信息 app/Providers/AppServiceProvider@register +``` + // 注入 wechat_pay 对象 + $this->app->singleton('wechat_pay', function () { + // 获取配置文件信息 + $config = config('pay.wechat'); + // **配置后端回调地址** + $config['notify_url'] = route('payment.wechat.notify'); + // 判断当前项目是否为测试环境 + if (app()->environment() !== 'production') { + $config['log']['level'] = Logger::DEBUG; + // 因为微信支付没有 dev 开发模式,所以这里之配置日志即可 + } else { + $config['log']['level'] = Logger::WARNING; + } + // 返回一个实例化 wechat 微信支付对象 + return Pay::wechat($config); + }); +``` +* 同时在 app/Http/Middleware/VerifyCsrfToken.php 中声明后端回调地址不需要 csrf 认证 +``` + /** + * 取消 csrf 认证 + */ + protected $except = [ + 'payment/alipay/notify', // 支付宝 + 'payment/wechat/notify', // 微信 + ]; +``` +------------------------------------------------------------------------ +* 在视图层添加微信支付按钮 ../orders/show.blade.php +``` +
+ 支付宝支付 + 微信支付 +
+``` +-------------------------------------------------------------------------- +* 测试,点击微信支付,会返回一段 json => (这其实是微信返回的二维码),可以在 [这里](https://cli.im/) 将这个 json 中的code_url 转为二维码 +* 扫描该二维码,就可以完成支付了. +* 如果要用 requestBin 进行后端回调的测试,那么 + 1. 在 app/Providers/AppServiceProvider@register 中设置回调地址为 requestBin 的地址 + 2. 扫码支付成功后, requestBin 服务器中捕获到的数据是一个 xml 文本,用 `curl -XPOST http://shop.test/payment/wechat/notify -d'XML请求包'` 来请求一次,就可以调用后端回调,更新我们的数据库 + +# 完善微信支付:生成二维码 +1. 安装二维码生成扩展 `composer require endroid/qr-code` +2. 改写 PaymentController@payByWechat +``` +use Endroid\QrCode\QrCode; // <= 引用二维码扩展 + +... + + /** + * 微信支付-拉起网页扫码支付 + */ + public function payByWechat(Order $order, Request $request) + { + // 授权 + $this->authorize('own', $order); + + // 确保订单状态 + if ($order->paid_at || $order->closed) { + throw new InvalidRequestException('订单状态不正确'); + } + + // 实例化支付对象(但不直接 return 而是保存在变量中) + $wechatOrder = app('wechat_pay')->scan([ + 'out_trade_no' => $order->no, + 'total_fee' => $order->total_amount * 100, + 'body' => '支付 Laravel Shop 的订单:'.$order->no, + ]); + + // 生成二维码 + $qrCode = new QrCode($wechatOrder->code_url); + + // 将生成的二维码图片数据以字符串形式输出,并带上相应的响应类型 + return response($qrCode->writeString(), 200, ['Content-Type' => $qrCode->getContentType()]); + } +``` +* 编辑 ../orders/show.blade.php +``` +{{-- 按钮换一下 --}} + + +{{-- 前端代码:异步请求生成二维码让用户支付 --}} +@section('scriptsAfterJs') + +@endsection +``` + +# 支付后的逻辑 +> 支付之后要给订单中的商品增加销量,比如我们要发邮件给用户告知订单支付成功。 + +> 商品增加销量和发送邮件并不会影响到订单的支付状态,即使这两个操作失败了也不影响我们后续的业务流程,对于此类需求我们通常使用异步事件来解决。 + +1. 创建支付成功事件 `php artisan make:event OrderPaid` => 位于 app/Events/ +``` +order = $order; + } + + public function getOrder() + { + return $this->order; + } +} +``` +> 事件本身不需要有逻辑,只需要包含相关的信息即可,在我们这个场景里就只需要一个订单对象。 +2. 编辑 PaymentController : 增加一个事件触发方法, 编辑支付宝和微信支付支付成功后的后台回调,在返回前触发事件 +``` + /** + * 支付完成后异步触发事件更新商品被购买次数 + */ + protected function afterPaid(Order $order) + { + event(new OrderPaid($order)); // 触发一个新的事件 + } + + /** + * 微信支付-回调函数 + */ + public function wechatNotify() + { + ... + + + // 触发事件 + $this->afterPaid($order); + + return app('wechat_pay')->success(); + } + + // 支付宝同理 +``` +> `event(new 事件类(参数))` => 触发事件 +3. 创建事件监听器,处理逻辑 `php artisan make:listener UpdateProductSoldCount --event=OrderPaid` => 位于 app/Listeners +``` +getOrder(); + // 循环遍历订单的商品 + foreach ($order->items as $item) { + $product = $item->product; + // 计算对应商品的销量 + $soldCount = OrderItem::query() + ->where('product_id', $product->id) + ->whereHas('order', function ($query) { + $query->whereNotNull('paid_at'); // 关联的订单状态是已支付 + })->sum('amount'); + // 更新商品销量 + $product->update([ + 'sold_count' => $soldCount, + ]); + } + } +} +``` +4. 关联事件和监听器 app/Providers/EventServiceProvider.php +``` + /** + * 关联事件和监听器 + */ + protected $listen = [ + 'App\Events\Event' => [ + 'App\Listeners\EventListener', + ], + Registered::class => [ + RegisteredListener::class, + ], + // **添加这里** + \App\Events\OrderPaid::class => [ + \App\Listeners\UpdateProductSoldCount::class, + ], + ]; +``` +5. 捋一下逻辑:控制器写一个方法 `new 一个事件类` => 但是事件类本身没有动作 => 所以需要事件监听器来完成触发事件之后的动作 => 同时要记得绑定事件类和事件监听器.(为什么不直接写逻辑代码,而是调事件的原因接下来说) +---------------------------------------------------------------- +1. 创建支付成功后的邮件通知 `php artisan make:notification OrderPaidNotification` +``` +order = $order; + } + + // 我们只需要通过邮件通知,因此这里只需要一个 mail 即可 + public function via($notifiable) + { + return ['mail']; + } + + public function toMail($notifiable) + { + return (new MailMessage) + ->subject('订单支付成功') // 邮件标题 + ->greeting($this->order->user->name.'您好:') // 欢迎词 + ->line('您于 '.$this->order->created_at->format('m-d H:i').' 创建的订单已经支付成功。') // 邮件内容 + ->action('查看订单', route('orders.show', [$this->order->id])) // 邮件中的按钮及对应链接 + ->success(); // 按钮的色调 + } +} +``` +2. 创建监听器 `php artisan make:listener SendOrderPaidMail --event=OrderPaid` +``` +getOrder(); + // 调用 notify 方法来发送通知 + $order->user->notify(new OrderPaidNotification($order)); + } +} +``` +3. 关联监听器和事件 EventServiceProvider.php +``` + protected $listen = [ + 'App\Events\Event' => [ + 'App\Listeners\EventListener', + ], + Registered::class => [ + RegisteredListener::class, + ], + \App\Events\OrderPaid::class => [ + \App\Listeners\UpdateProductSoldCount::class, + \App\Listeners\SendOrderPaidMail::class, // <= 这里 + ], + ]; +``` +4. 事件没有逻辑代码,而是触发事件时用监听器的原因: + * 一是因为一个事件可能触发多个动作,所以写多个监听器就好(这里触发支付完订单之后的动作,需要增加销量,同时发送邮件通知用户) + * 二是因为监听器里面可以异步执行 +--------------------------------------------------------------------- +5. 测试 + 1. 再次完成一次购买(记得后台接口请求一次) + 2. 输入命令 `php artisan queue:work` 开始执行队列中的任务 \ No newline at end of file diff --git a/laravel/laravel5/9.md b/laravel/laravel5/9.md new file mode 100644 index 0000000..bb4cd0b --- /dev/null +++ b/laravel/laravel5/9.md @@ -0,0 +1,472 @@ +# 后台-订单列表 +* 创建控制器 `php artisan admin:make OrdersController --model=App\\Models\\Order` +``` +header('订单列表'); + $content->body($this->grid()); + }); + } + + /** + * 订单表格 + */ + protected function grid() + { + return Admin::grid(Order::class, function (Grid $grid) { + // 只展示已支付的订单,并且默认按支付时间倒序排序 + $grid->model()->whereNotNull('paid_at')->orderBy('paid_at', 'desc'); + + $grid->no('订单流水号'); + // 展示关联关系的字段时,使用 column 方法 + $grid->column('user.name', '买家'); + $grid->total_amount('总金额')->sortable(); + $grid->paid_at('支付时间')->sortable(); + $grid->ship_status('物流')->display(function($value) { + return Order::$shipStatusMap[$value]; + }); + $grid->refund_status('退款状态')->display(function($value) { + return Order::$refundStatusMap[$value]; + }); + // 禁用创建按钮,后台不需要创建订单 + $grid->disableCreateButton(); + $grid->actions(function ($actions) { + // 禁用删除和编辑按钮 + $actions->disableDelete(); + $actions->disableEdit(); + }); + $grid->tools(function ($tools) { + // 禁用批量删除按钮 + $tools->batch(function ($batch) { + $batch->disableDelete(); + }); + }); + }); + } +} +``` +* 配置路由 app/Admin/routes.php +``` +$router->get('orders', 'OrdersController@index')->name('admin.orders.index'); //订单列表 +``` + +# 后台-订单详情 +* app/Admin/Controllers/OrdersController@show +``` + /** + * 订单详情 + */ + public function show(Order $order) + { + return Admin::content(function (Content $content) use ($order) { + $content->header('查看订单'); + // body 方法可以接受 Laravel 的视图作为参数 + $content->body(view('admin.orders.show', ['order' => $order])); + }); + } +``` +> 这里用 `$content-body(view())` => 指定后台视图 +* 配置路由 +``` +$router->get('orders/{order}', 'OrdersController@show')->name('admin.orders.show'); //订单详情 +``` +* 视图 (../admin/orders/show.blade.php )详情参考教程,略。 + +# 后台-订单发货 +* OrdersController@ship +``` +use Illuminate\Http\Request; +use App\Exceptions\InvalidRequestException; + +... + + /** + * 订单发货 + */ + public function ship(Order $order, Request $request) + { + // 判断当前订单是否已支付 + if (!$order->paid_at) { + throw new InvalidRequestException('该订单未付款'); + } + + // 判断当前订单发货状态是否为未发货 + if ($order->ship_status !== Order::SHIP_STATUS_PENDING) { + throw new InvalidRequestException('该订单已发货'); + } + + // Laravel 5.5 之后 validate 方法可以返回校验过的值 + $data = $this->validate($request, [ + 'express_company' => ['required'], + 'express_no' => ['required'], + ], [], [ + 'express_company' => '物流公司', + 'express_no' => '物流单号', + ]); + + // 将订单发货状态改为已发货,并存入物流信息 + $order->update([ + 'ship_status' => Order::SHIP_STATUS_DELIVERED, + // 我们在 Order 模型的 $casts 属性里指明了 ship_data 是一个数组 + // 因此这里可以直接把数组传过去 + 'ship_data' => $data, + ]); + + // 返回上一页 + return redirect()->back(); + } +``` +* 配置路由 +``` +$router->post('orders/{order}/ship', 'OrdersController@ship')->name('admin.orders.ship'); //订单发货 +``` +* 后台订单详情页做一个判断:如果商品物流状态处于未发货的状态,则增加一个发货表单(要求填写物流公司和物流单号),否则就显示物流公司和物流单号。 + +# 前台-确认收货 +* app/Http/OrdersController@received +``` + /** + * 确认收货 + */ + public function received(Order $order, Request $request) + { + // 校验权限 + $this->authorize('own', $order); + + // 判断订单的发货状态是否为已发货 + if ($order->ship_status !== Order::SHIP_STATUS_DELIVERED) { + throw new InvalidRequestException('发货状态不正确'); + } + + // 更新发货状态为已收到 + $order->update(['ship_status' => Order::SHIP_STATUS_RECEIVED]); + + // 返回订单信息 + return $order; + } +``` +* 配置路由 routes/web.php +``` +Route::post('orders/{order}/received', 'OrdersController@received')->name('orders.received'); //确认收货 +``` +* 订单详情视图 (../orders/show.blade.php) 在订单编号下面增加当前物流状态,同时在支付按钮那一部分作一个判断:如果已支付且已发货则显示确认收获按钮 +``` +{{-- 确认收货 --}} +@if($order->ship_status === \App\Models\Order::SHIP_STATUS_DELIVERED) + +@endif + +... + + // 确认收货按钮 + $('#btn-receive').click(function() { + // 弹出确认框 + swal({ + title: "确认已经收到商品?", + icon: "warning", + buttons: true, + dangerMode: true, + buttons: ['取消', '确认收到'], + }) + .then(function(ret) { + // 如果点击取消按钮则不做任何操作 + if (!ret) { + return; + } + // ajax 提交确认操作 + axios.post('{{ route('orders.received', [$order->id]) }}') + .then(function () { + // 刷新页面 + location.reload(); + }) + }); + }); +``` + +# 用户-商品评价 +* 创建一个 Request => 用于验证评价内容 `php artisan make:request SendReviewRequest` +``` + ['required', 'array'], + 'reviews.*.id' => [ + 'required', + Rule::exists('order_items', 'id')->where('order_id', $this->route('order')->id) + ], + 'reviews.*.rating' => ['required', 'integer', 'between:1,5'], + 'reviews.*.review' => ['required'], + ]; + } + + /** + * 字段名称 + */ + public function attributes() + { + return [ + 'reviews.*.rating' => '评分', + 'reviews.*.review' => '评价', + ]; + } +} +``` +> `Rule::exists()` => 判断用户提交的 ID 是否属于此订单 + +> `$this->route('order')` => 获得当前路由对应的订单对象 + +* 由于评价系统基于订单,所以将评价页面 (review) 和提交评价方法 (sendReview) 都写在 OrdersController 中 +``` +use Carbon\Carbon; +use App\Http\Requests\SendReviewRequest; + +... + + /** + * 评价页面 + */ + public function review(Order $order) + { + // 校验权限 + $this->authorize('own', $order); + // 判断是否已经支付 + if (!$order->paid_at) { + throw new InvalidRequestException('该订单未支付,不可评价'); + } + // 使用 load 方法加载关联数据,避免 N + 1 性能问题 + return view('orders.review', ['order' => $order->load(['items.productSku', 'items.product'])]); + } + + /** + * 保存评论 + */ + public function sendReview(Order $order, SendReviewRequest $request) + { + // 校验权限 + $this->authorize('own', $order); + if (!$order->paid_at) { + throw new InvalidRequestException('该订单未支付,不可评价'); + } + + // 判断是否已经评价 + if ($order->reviewed) { + throw new InvalidRequestException('该订单已评价,不可重复提交'); + } + $reviews = $request->input('reviews'); + + // 开启事务 + \DB::transaction(function () use ($reviews, $order) { + // 遍历用户提交的数据 + foreach ($reviews as $review) { + $orderItem = $order->items()->find($review['id']); + // 保存评分和评价 + $orderItem->update([ + 'rating' => $review['rating'], + 'review' => $review['review'], + 'reviewed_at' => Carbon::now(), + ]); + } + // 将订单标记为已评价 + $order->update(['reviewed' => true]); + }); + + return redirect()->back(); + } +``` +* 配置路由 routes/web.php +``` +Route::get('orders/{order}/review', 'OrdersController@review')->name('orders.review.show'); //商品评价页 +Route::post('orders/{order}/review', 'OrdersController@sendReview')->name('orders.review.store'); //保存评价 +``` +* 评价页面视图 (../orders/review.blade.php) 详情和样式参考教程,有个打分星星值得学习。 +* 增加入口 ../orders/index.blade.php , show.blade.php 同理(只是还需要判断是否确认收货) +``` +@if($order->paid_at) + + {{ $order->reviewed ? '查看评价' : '评价' }} + +@endif +``` +* 更新商品评分 + 1. 创建事件 `php artisan make:event OrderReviewd` => 事件没有逻辑,而是当触发事件时,执行的是监听器中的代码,因此我们在事件中只需要确定并且获取操作需要的数据即可(这里我们需要订单的详细信息,即订单对象) + ``` + order = $order; + } + + public function getOrder() + { + return $this->order; + } + } + ``` + 2. 创建监听器 `php artisan make:listener UpdateProductRating --event=OrderReviewd` + ``` + getOrder()->items()->with(['product'])->get(); + foreach ($items as $item) { + $result = OrderItem::query() + ->where('product_id', $item->product_id) + ->whereHas('order', function ($query) { + $query->whereNotNull('paid_at'); + }) + ->first([ + DB::raw('count(*) as review_count'), + DB::raw('avg(rating) as rating') + ]); + // 更新商品的评分和评价数 + $item->product->update([ + 'rating' => $result->rating, + 'review_count' => $result->review_count, + ]); + } + } + } + ``` + 3. 在 app/Providers/EventServiceProvider.php 中关联事件和监听器 + ``` + protected $listen = [ + + ... + + \App\Events\OrderReviewd::class => [ + \App\Listeners\UpdateProductRating::class, + ], + ]; + ``` + 4. 在控制器层触发事件 + ``` + use App\Events\OrderReviewd; + + ... + + /** + * 保存评论 + */ + public function sendReview(Order $order, SendReviewRequest $request) + { + ... + + // 开启事务 + \DB::transaction(function () use ($reviews, $order) { + + ... + + // 触发事件,更新评分 + event(new OrderReviewd($order)); + }); + + return redirect()->back(); + } + ``` + +# 前台-展示商品评价和评分 +* 完善 ProdocutsController@show +``` +use App\Models\OrderItem; + +... + + /** + * 商品详情 + */ + public function show(Product $product, Request $request) + { + // 判断商品是否已经上架,如果没有上架则抛出异常。 + if (!$product->on_sale) { + throw new InvalidRequestException('商品未上架'); + } + + // 默认当前商品没有被喜欢(没登陆的用户也需要看到的是 “收藏” 按钮) + $favored = false; + + // 判断一下当前用户是否登陆,如果已登陆,那么判断一下是否喜欢该商品 + if($user = $request->user()) { + $favored = boolval($user->favoriteProducts()->find($product->id)); // boolval() => 将参数转为布尔类型 + } + + // 获取评价 + $reviews = OrderItem::query() + ->with(['order.user', 'productSku']) // 预先加载关联关系 + ->where('product_id', $product->id) + ->whereNotNull('reviewed_at') // 筛选出已评价的 + ->orderBy('reviewed_at', 'desc') // 按评价时间倒序 + ->limit(10) // 取出 10 条 + ->get(); + + // 跳转到视图 + return view('products.show', [ + 'product' => $product, + 'favored' => $favored, + 'reviews' => $reviews + ]); + } +``` \ No newline at end of file diff --git "a/laravel/\346\257\225\344\270\232\350\256\276\350\256\241-\346\224\271/1.md" "b/laravel/\346\257\225\344\270\232\350\256\276\350\256\241-\346\224\271/1.md" new file mode 100644 index 0000000..b2f6978 --- /dev/null +++ "b/laravel/\346\257\225\344\270\232\350\256\276\350\256\241-\346\224\271/1.md" @@ -0,0 +1,196 @@ +# 思路整理-用户 +1. 后台管理员 + * 可以管理用户 + * 可以管理门店 + * 可以管理评论 + * 可以下架已上架的商品 + * 可以管理订单 + > 后台通过 laravel-admin 实现权限分配和管理 +2. 商户管理员 + * 可以管理商品 + * 可以管理订单 +3. 普通用户 + * 游客用户可以查看门店,商品详情等 + * 而登陆的用户可以操作购物车,并且创建订单 + > 商户管理员和普通用户用 laravel-permission 分级管理 + +# 创建项目 +1. `code ~/Homestead/Homestead.yaml` => 增加 homestead 中的站点和数据库 +``` +# 站点配置 +sites: + # gydiner + - map: gydiner.test + to: /home/vagrant/Code/gydiner/public + +# 数据库名称 +databases: + # gydiner + - gydiner +``` +2. `notepad C:/Windows/System32/Drivers/etc/hosts` => 增加 hosts , 以便在 windows 中访问项目(需要以管理员身份执行命令) +``` +192.168.10.10 gydiner.test +``` +3. `cd ~/Homestead && vagrant up` 开启 homestead , `vagrant ssh` 登陆 homestead , `cd ~/Code` 切换到存放代码的目录, `composer create-project laravel/laravel gydiner --prefer-dist "5.5.*"` => 创建名为 gydiner (广元食客) 的项目。 +4. `cd ~/Homestead && vagrant provision` => 让 homestead 读取最新的配置文件信息, 此时会读取 Homestead.yaml 配置信息,创建新的虚拟站点和数据库。 +5. 访问 http://gydiner.test/ 即可看到部署好的 laravel 框架,用编辑器打开项目,作一些配置 + * 编辑 .env + ``` + APP_NAME=gydiner + ... + APP_URL=http://gydiner.test/ + ... + DB_DATABASE=gydiner + ``` + * 编辑 config/app.php => 设置时区和语言 + ``` + 'timezone' => 'Asia/Shanghai', + 'locale' => 'zh-CN', + ``` + +# 全局辅助函数(助手函数) +1. 创建 bootstrap/helpers.php + ``` + name('root'); //首页 +``` +3. 视图:创建 ../pages/root.blade.php => 继承 app.blade.php, 填充 content。 + +# Yarn 与前端扩展(可选) +> 所有样式都写在 resources/assets/sass/app.scss 中,但是现在还没装 npm 包,不能用 laravel-mix 编译写好的 app.scss 样式, 如果不用 Yarn, 可以直接配置 cnpm, 然后执行 `cnpm install` +1. `yarn config set registry https://registry.npm.taobao.org` 设置 Yarn 下载地址为淘宝镜像 +2. 装扩展 `yarn install --no-bin-links` +3. 修改 package.json +``` +"scripts":{ + "dev": "npm run development", + "development": "NODE_ENV=development node_modules/webpack/bin/webpack.js --progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js", + "watch": "NODE_ENV=development node_modules/webpack/bin/webpack.js --watch --progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js", + "watch-poll": "npm run watch -- --watch-poll", + "hot": "NODE_ENV=development node_modules/webpack-dev-server/bin/webpack-dev-server.js --inline --hot --config=node_modules/laravel-mix/setup/webpack.config.js", + "prod": "npm run production", + "production": "NODE_ENV=production node_modules/webpack/bin/webpack.js --progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js" +}, +``` +> 在 windows.homestead 中用 Yarn 都得加参数 --no-bin-links + +# 用户认证脚手架 +* 创建一个 Seeder `php artisan make:seeder UsersTableSeeder` +``` +times(10)->create(); + + // 找到第1条数据并配置 + $user = User::find(1); + $user->name = '示例用户'; + $user->email = 'woshimiexiaoming@foxmail.com'; + $user->password = bcrypt('ceshi'); + $user->save(); + } +} +``` +> 模型工厂自带,所以不用做,写完了之后需要在 DatabaseSeeder@run 中 `$this->call(UsersTableSeeder::class);`,完成之后执行 `php artisan migrate --seed` + +* 执行命令 `php artisan make:auth` 生成 Laravel 自带的用户认证脚手架,会问是否覆盖布局视图,因为已经写好了所以选 No . +> 汉化一下视图,就完成了用户功能(登陆、注册) + +* 编辑路由文件 routes/web.php,不需要 `'/home'` 路由,同时全局查找替换用户认证功能相关控制器的跳转地址为 `'/'` + +* 编辑 ../layouts/_header.blade.php 视图,给登陆和注册按钮绑定正确的路由地址。 + +* 安装中文包 `composer require "overtrue/laravel-lang"` + +* 配置163邮件提供的免费 smtp 服务 (.env) +``` +MAIL_DRIVER=smtp +MAIL_HOST=smtp.163.com +MAIL_PORT=25 +MAIL_USERNAME=邮箱地址 +MAIL_PASSWORD=smtp服务密码 +MAIL_ENCRYPTION=加密(默认写 null) +MAIL_FROM_ADDRESS=发件人邮箱 +MAIL_FROM_NAME=发件人地址 +``` + +# 提交代码到 GitHub +1. `git init` => 初始化本地 git +2. `git add .`, `git commit -m "git commit -m "创建项目,布局模板,首页,前端扩展,用户认证脚手架"` => 作一次本地提交 +3. `git remote add origin git@github.com:prohorry-me/gydiner.git` => 配置远程git地址(需要[新建 Git 仓库](https://github.com/new)) +4. `git push` => 推送代码到远程仓库 \ No newline at end of file diff --git "a/laravel/\346\257\225\344\270\232\350\256\276\350\256\241-\346\224\271/10.md" "b/laravel/\346\257\225\344\270\232\350\256\276\350\256\241-\346\224\271/10.md" new file mode 100644 index 0000000..e6a7041 --- /dev/null +++ "b/laravel/\346\257\225\344\270\232\350\256\276\350\256\241-\346\224\271/10.md" @@ -0,0 +1,274 @@ +# Cache 实现购物车 +1. 创建控制器 `php artisan make:controller CartController`, 内容如下 +``` +user()->id; + + // 获取购物车信息 + $cart = Cache::get($cartKey); + + // 如果购物车信息为空 + if(!$cart) { + $cart = $this->initializeCart($product); //初始化购物车 + $cart['total_price'] = $this->getTotalPrice($cart); //计算总价 + Cache::set($cartKey, $cart, 360); //保存信息6小时。 + + session()->flash('success', '您正在【' . $product->shop->name . '】中购买食品, 已添加【' . $product->name . '】到购物车'); + return redirect()->back(); + } + + // 如果购物车不为空,那么首先判断是否在本店购物(因为是美食 o2o ,几乎没有人同时在两家店点餐,所以一家店一个购物车) + if($cart['shop_id'] != $product->shop->id) { + $cart = $this->initializeCart($product); + $cart['total_price'] = $this->getTotalPrice($cart); + Cache::set($cartKey, $cart, 360); + + session()->flash('success', '您已换到【' . $product->shop->name . '】中购买食品, 已添加【' . $product->name . '】到购物车'); + return redirect()->back(); + + // 如果是在本店购物 + }else { + // 遍历购物车找商品:如果找到,增加已在购物车中的商品的数量 + foreach($cart['data'] as $index => $data) { + if($data['product_id'] == $product->id) { + $cart['data'][$index]['product_count'] += 1; + $cart['total_price'] = $this->getTotalPrice($cart); + Cache::set($cartKey, $cart, 360); + + session()->flash('success', '增加了1个【' . $product->name . '】'); + return redirect()->back(); + } + } + + // 没找到,则说明添加的商品在购物车中不存在 + $cart['data'][] = [ + 'product_image' => $product->image, + 'product_id' => $product->id, + 'product_name' => $product->name, + 'product_price' => $product->price, + 'product_count' => 1, + ]; + + $cart['total_price'] = $this->getTotalPrice($cart); + Cache::set($cartKey, $cart, 360); + + session()->flash('success', '已添加【' . $product->name . '】到购物车'); + return redirect()->back(); + } + } + + /** + * 初始化购物车 + */ + public function initializeCart($product) + { + $cart = [ + 'shop_id' => $product->shop->id, + 'shop_name' => $product->shop->name, + 'data' => [ + [ + 'product_image' => $product->image, + 'product_id' => $product->id, + 'product_name' => $product->name, + 'product_price' => $product->price, + 'product_count' => 1, //默认一个商品 + ], + ], + ]; + + return $cart; + } + + /** + * 计算总价 + */ + public function getTotalPrice($cart) + { + $totalPrice = 0; + foreach($cart['data'] as $index => $data) { + // 计算总价 + $totalPrice += $data['product_count'] * $data['product_price']; + } + + return $totalPrice; + } + + /** + * 购物车详情页 + */ + public function show(Request $request) + { + // 配置购物车名称 + $cartKey = 'cart' . $request->user()->id; + + // 获取购物车信息 + $cart = Cache::get($cartKey); + + return view('cart.show', [ + 'cart' => $cart, + ]); + } + + /** + * 减少(删除) 购物车中的商品 + */ + public function reduce(Request $request, $product) + { + // 配置购物车名称 + $cartKey = 'cart' . $request->user()->id; + + // 获取购物车信息 + $cart = Cache::get($cartKey); + + foreach($cart['data'] as $index => $data) { + if($data['product_id'] == $product) { + $cart['data'][$index]['product_count'] -= 1; + + // 当商品数量小于0时,从数组中剔除商品 + if($cart['data'][$index]['product_count'] <= 0) { + unset($cart['data'][$index]); + } + + $cart['total_price'] = $this->getTotalPrice($cart); + Cache::set($cartKey, $cart, 360); + + session()->flash('success', '减少商品成功'); + return redirect()->back(); + } + } + } +} +``` +> Cache 是存储在服务器上的缓存信息,可以通过 `Cache::set($key, $value, 时间单位分钟)` 来保存信息。$value 可以是任意类型的值。同时也可以通过 `Cache::get($key)` 来获取信息。 我们的购物车标识则为 `'cart'` 加上 `用户id` + +> 添加逻辑较为复杂:首先尝试读取 Cache 中的购物车信息。如果没有,则初始化购物车,然后计算总价,最后保存在 Cache 中。且项目中,一个商店对应一个购物车,不支持跨店购买。 + +> 减少(删除)逻辑则需要注意,不能让商品个数为0和负数,所以当数量小于等于0时,剔除数组。 + +2. 配置路由 +``` +Route::get('/cart/show', 'CartController@show')->name('cart.show'); //购物车页面 +Route::get('/cart/add/{product}', 'CartController@add')->name('cart.add'); //往购物车里面添加商品或者增加数量 +Route::get('/cart/reduce/{product}', 'CartController@reduce')->name('cart.reduce'); //从购物车中减少某商品 +``` + +3. CartController@show 方法对应的视图就是一个表格,显示购物车信息。 + +4. 在 app/Http/ShopsController@show => 前台控制器上,则需要 +``` +use Cache; + + /** + * 门店详情 + */ + public function show(Request $request, Shop $shop) + { + // 默认当前商店没有被喜欢(没登陆的用户也需要看到的是 “收藏” 按钮) + $favored = false; + $cart = []; // <=购物车默认为空 + + // 判断一下当前用户是否登陆,如果已登陆,那么判断一下是否喜欢该商店 + if($user = $request->user()) { + $favored = boolval($user->favoriteShops()->find($shop->id)); // boolval() => 将参数转为布尔类型 + + // 获取购物车信息 + $cartKey = 'cart' . $user->id; + $cart = Cache::get($cartKey); + } + + return view('shops.show', [ + 'shop' => $shop, + 'favored' => $favored, + 'cart' => $cart + ]); + } +``` +> 如果用户登陆则再获取购物车信息,交给视图 + +* 视图 ../shops/show.blade.php 通过判断 $cart 的值,更改购物车按钮的样式,显示购物车金额(空 or 购物车金额) + +# 购物车页面增加一个表单用于提交订单 +* ../cart/show.blade.php +``` + + {{ csrf_field() }} +
+
+ + +
+
+ +
+
+
+
+
+

订单总价:¥ {{ $cart['total_price'] }}

+
+
+ +
+
+ +``` +> 没写 action 是先把表单写在这,这个表单用于之后生成订单,先完成其他功能。 + +# 选择/生成收货地址 +* 上面的表单有一个按钮 +``` + +``` +* 这个按钮触发一个模态框,里面有一个表单用于添加收货地址 `` => 访问的是 `Route::post('/user_addresses/in_cart_store', 'UserAddressesController@inCartStore')->name('user_addresses.inCartStore');` 这条新增的路由 +* 方法 UserAddressesController@inCartStore +``` + /** + * 购物车页面添加收货地址 + */ + public function inCartStore(UserAddressRequest $request) + { + $request->user() + ->userAddresses() + ->create($request->post()); + + + session()->flash('success', '添加新的收货地址成功,请您选择收货地址'); + return redirect()->route('cart.show'); + } +``` +> 其实和 store 几乎一样,不过最后需要重定向回购物车页面 \ No newline at end of file diff --git "a/laravel/\346\257\225\344\270\232\350\256\276\350\256\241-\346\224\271/11.md" "b/laravel/\346\257\225\344\270\232\350\256\276\350\256\241-\346\224\271/11.md" new file mode 100644 index 0000000..b1fa35a --- /dev/null +++ "b/laravel/\346\257\225\344\270\232\350\256\276\350\256\241-\346\224\271/11.md" @@ -0,0 +1,398 @@ +# 订单模块:开发准备 +1. `php artisan make:model Order -m` => 创建 Order 模型和迁移文件 +2. 迁移文件 +``` + // 订单信息 + $table->increments('id'); + $table->string('no')->unique(); //订单流水号 + $table->unsignedInteger('user_id'); //外键:购买用户id + $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade'); + $table->unsignedInteger('shop_id'); //外键:卖出商店id + $table->foreign('shop_id')->references('id')->on('shops')->onDelete('cascade'); + $table->string('address'); //收货地址 + $table->text('data'); //订单详情 + $table->decimal('total_price'); //订单总价 + $table->text('remark'); //订单备注 + + // 支付信息 + $table->boolean('closed')->default(false); //订单是否关闭,默认处于未关闭状态,给用户半个小时时间支付。 + $table->boolean('paid')->default(false); //是否支付 + $table->dateTime('paid_at')->nullable(); //支付时间 + $table->string('paid_by')->nullable(); //支付方式 + $table->string('payment_number')->nullable(); //支付平台提供的订单号 + + // 评论信息 + $table->boolean('reviewed')->default(false); //是否评论 + + // 物流信息 + $table->string('ship_status')->default(\App\Order::SHIP_STATUS_PENDING); //物流状态 + $table->text('ship_data')->nullable(); //物流信息 + $table->text('extra')->nullable(); //额外数据 + + $table->timestamps(); +``` +3. Order 模型 +``` + /** + * 物流状态 + */ + const SHIP_STATUS_PENDING = 'pending'; + const SHIP_STATUS_DELIVERED = 'delivered'; + const SHIP_STATUS_RECEIVED = 'received'; + + /** + * 物流状态对应中文名称 + */ + public static $shipStatusMap = [ + self::SHIP_STATUS_PENDING => '未发货', + self::SHIP_STATUS_DELIVERED => '已发货', + self::SHIP_STATUS_RECEIVED => '已收货', + ]; + + /** + * 可填字段 + */ + protected $fillable = [ + 'no', 'user_id', 'shop_id', 'address', 'data', 'total_price', 'remark', + 'closed', 'paid', 'paid_at', 'paid_by', 'payment_no', + 'reviewed', + 'ship_status', 'ship_data', 'extra', + ]; + + /** + * 引导函数 + */ + protected static function boot() + { + parent::boot(); + + // 监听模型创建事件,在写入数据库之前触发 + static::creating(function ($model) { + // 如果模型的 no 字段为空 + if (!$model->no) { + // 调用 findAvailableNo 生成订单流水号 + $model->no = static::findAvailableNo(); + // 如果生成失败,则终止创建订单 + if (!$model->no) { + return false; + } + } + }); + } + + /** + * 生成可用的订单流水号 + */ + public static function findAvailableNo() + { + // 订单流水号前缀 + $prefix = date('YmdHis'); + for ($i = 0; $i < 10; $i++) { + // 随机生成 6 位的数字拼接前缀 + $no = $prefix . str_pad(random_int(0, 999999), 6, '0', STR_PAD_LEFT); + // 判断是否已经存在 + if (!static::query()->where('no', $no)->exists()) { + return $no; + } + } + \Log::warning('find order no failed'); + + return false; + } +``` +> 需要注意的就是生成流水订单号的方法 `findAvailableNo()` => 先生成日期前缀,然后再拼接6位随机数。 + +> `str_pad(原字符串, 总长度, 要拼接的字符, 这里给一个常量选择从左还是从右))` => 比如生了了随机整数 128, 最后会变成 000128 + +> `statis::query()` => 调用模型的静态方法生成查询构造器来排除数据库中有相同的流水号 + +> \Log 这个类是直接调用 \ 命名空间下的 Log 类来记录日志 + +> 为了让这个函数自动调用,需要写一个 boot() 方法:引导函数,内部 `parent::boot();` 调用父类的 boot 方法开始监控模型事件:`static::creating()` 接收一个回调函数函数内部再尝试生成订单流水号,最终的效果就是:在尝试创建新的模型实例时,会自动实例生成一个流水订单号(`$order->no` 就自动生成值) + +4. 模型关系绑定 +``` +# Order + /** + * n:1 User + * n:1 Shop + */ + public function user() + { + return $this->belongsTo(User::class); + } + public function shop() + { + return $this->belongsTo(Shop::class); + } + +# User, Shop + /** + * 1:n Order + */ + public function orders() + { + return $this->hasMany(Order::class); + } +``` + +5. 最后执行迁移 + +# 生成订单 +1. 创建控制器 `php artisan make:controller OrdersController --model=Order` => 去掉 create, edit, update, destroy 方法 => 这个控制器只让普通用户使用:生成订单,查看自己的订单,查看订单的详情 +``` + ['index', 'store', 'show']]); //用户操作订单的资源路由` +> 昨天已经完成了购物车结算页面的表单: 只需要在 ../cart/show.blade.php 中指定表单提交的指向为 `{{ route('orders.store') }}` 即可。 +4. 完成 OrdersController@store + * `php artisan make:request OrderRequest` + ``` + 'required', + 'remark' => 'nullable|max:50', + ]; + } + + public function messages() + { + return [ + 'address.required' => '请务必选择收货地址', + 'remark.max' => '备注不能超过50字', + ]; + } + } + ``` + * OrdersController@store + ``` + use Cache; + use App\Product; + use App\Http\Requests\OrderRequest; + + ... + + /** + * 生成新的订单 + */ + public function store(OrderRequest $request) + { + // 获取请求用户 + $user = $request->user(); + + // 获取购物车信息 + $cartKey = 'cart' . $user->id; + $cart = Cache::get($cartKey); + + // 判断购物车信息 + if(!$cart) { + session()->flash('danger', '购物车为空'); + + return redirect()->back(); + } + + // 校验购物车中的商品是否有下架的商品 + foreach($cart['data'] as $index => $value) { + $product = Product::find($value['product_id']); + + // 如果有下架或者被删除的商品商品 + if(!$product || !$product->on_sale) { + unset($cart['data'][$index]); //清除商品 + // 重新算总价 + $cart['total_price'] = 0; + foreach($cart['data'] as $data) { + // 计算总价 + $cart['total_price'] += $data['product_count'] * $data['product_price']; + } + + // 重新写入 Cache + if($cart['data']) { + Cache::set($cartKey, $cart, 360); + }else { + Cache::forget($cartKey); + } + + session()->flash('danger', '由于网络原因,您选购的某些商品已处于下架状态。已从购物车中移除。对此我们表示抱歉,请您确认订单后重新下单。'); + + return redirect()->back(); + } + } + + // 处理和拼装数据 + $order = $cart; + $order['data'] = \json_encode($order['data']); + $order['address'] = $request->post()['address']; + $order['remark'] = $request->post()['remark'] ? $request->post()['remark'] : '无备注'; + + // 创建新数据 + $order = $user->orders()->create($order); + + // 清空购物车 + Cache::forget($cartKey); + + // 跳转到页面 + return redirect()->route('orders.show', $order->id); + } + ``` +# 关闭未支付订单订单 +1. `php artisan make:job CloseOrder` => 创建任务 +``` +order = $order; + // 设置延迟的时间,delay() 方法的参数代表多少秒之后执行 + $this->delay($delay); + } + + /** + * handle 方法名为保留名,所有逻辑都写里面 + */ + public function handle() + { + // 判断对应的订单是否已经被支付 + if ($this->order->paid) { + return; //如果已经支付则不需要关闭订单,直接退出 + } + $this->order->update(['closed' => true]); //关闭订单 + } +} +``` +> 注意引用模型即可。 + +2. 在 OrdersController.php 中完成 +``` +use App\Jobs\CloseOrder; + +... + + /** + * 生成新的订单 + */ + public function store(OrderRequest $request) + { + ... + + // 开始计时:准备关闭订单 + $this->dispatch(new CloseOrder($order, 30)); + + // 跳转到页面 + return redirect()->route('orders.show', $order->id); + } +``` +> 在 return 之前推送定时任务到后台 `$this->dispatch()` +> `new CloseOrder($order, 30)` 第二参数是倒计时时间,单位秒,我们设置为30秒用于测试 + +> 测试就是打开队列任务 `php artisan queue:work` + +3. 30秒就关闭订单当然是错误的,我们在 config/app.php 中定义一个配置项 `'order_close_after_time' => 1800,`,然后在 OrdersController@store 方法中 `$this->dispatch(new CloseOrder($order, config('app.order_close_after_time')));` 设置为半小时后关闭订单。 + +> `config('文件名.配置项')` => 读取 config/文件名.php 这个配置文件中的配置项的值 + +# 订单详情 +* 完成 OrdersController@show +``` + /** + * 展示订单详情(未支付前提供付款链接,支付后提供评论链接) + */ + public function show(Order $order) + { + // 判断订单是否关闭 + if($order->closed) { + session()->flash('danger', '超过最晚支付时间,订单已关闭'); + + return redirect('/'); + } + + return view('orders.show', [ + 'order' => $order, + ]); + } +``` +* 视图就是展示订单详情的视图 ../orders/show.blade.php,略 + +# 订单列表 +* 完成 OrdersController@index +``` + /** + * 订单列表 + */ + public function index(Request $request) + { + $orders = $request->user()->orders()->orderBy('craeted_at', 'desc')->paginate(15); + + return view('order.index', [ + 'orders' => $orders, + ]); + } +``` +* 视图 ../orders/index.blade.php,略 +* 在 ../layouts/_header.blade.php 中增加入口 + +> 最后修复一个小问题: `$time->diffForHumans()` 显示为中文 +``` +# app/Providers/AppServiceProvider.php +use Carbon\Carbon; //引用 Carbon 类 + +... + + public function boot() + { + Carbon::setLocale('zh'); // <= 设置为中文 + } +``` \ No newline at end of file diff --git "a/laravel/\346\257\225\344\270\232\350\256\276\350\256\241-\346\224\271/12.md" "b/laravel/\346\257\225\344\270\232\350\256\276\350\256\241-\346\224\271/12.md" new file mode 100644 index 0000000..bf88d54 --- /dev/null +++ "b/laravel/\346\257\225\344\270\232\350\256\276\350\256\241-\346\224\271/12.md" @@ -0,0 +1,257 @@ +# 更新之前的逻辑: +* 下单之后忘记了更新地址最新的使用时间: + 1. 修改 ../cart/show.blade.php ,遍历当前用户收货地址以共选择时,传的不再直接是 userAddress 因为这样传过来在 OrdersController@store 方法中得用 where 去查,非常慢,所以需要用 pk 去查找。 + 2. 在 OrdersController@store 改写一下:用事务处理两张数据表(生成订单,以及修改收货地址的 last_used_at 字段) + ``` + use Carbon\Carbon; // <= 引用时间助手类 + + /** + * 生成新的订单 + */ + public function store(OrderRequest $request) + { + ... + + // 处理和拼装数据 + ... + // 处理地址 + $address = UserAddress::find($request->post()['address']); // <= 这里找出来用户选的收货地址 + $order['address'] = $address->address . ':' . $address->contact . '-' . $address->phone; + + // 创建订单,更新地址使用时间 + $myOrder = \DB::transaction(function () use ($user, $cartKey, $order, $address) { + $myOrder = $user->orders()->create($order); //插入数据 + Cache::forget($cartKey); //清空购物车 + $address->update(['last_used_at' => Carbon::now()]); //更新收货地址最新使用时间 + + // 返回新的订单对象 + return $myOrder; + }); + + + ... + } + ``` + 1. 在 CartController@show + ``` + public function show(Request $request) + { + // 获取购物车信息 + ... + + $addresses = $request->user()->userAddresses()->orderBy('last_used_at', 'desc')->get(); //获取收货地址 + + return view('cart.show', [ + ... + ]); + } + ``` + > 查找收货地址时用 last_used_at 字段进行排序 +------------------------------------------------- +* 第二个问题:显示物流状态,在 ../orders/ 下的两个视图可能都要用,存的是英文,但是我们在 Order 模型中定义了中文对照地图,所以直接 `{{ \App\Order::$shipStatusMap[$order->ship_status] }}` 这样调用 App 空间下的 Order 类的静态地图变成中文。 +------------------------------------------------- +* 第三个问题:告知用户订单关闭的时间,提醒用户抓紧时间付款,同样是 ../orders/ 下的 index 和 show 视图中 +``` +未付款,请于 + + {{ $order->created_at->addSeconds(config('app.order_close_after_time'))->format('H:i') }} + +前完成付款 +``` +> 这里直接用 `时间数据->addSeconds(要增加的时间,单位秒)->format('格式化为时:分的形式')` 展示订单关闭的时间。 +----------------------------------------------------------------- +* 第四个问题:保证用户只能查看自己的订单详情 `php artisan make:policy OrderPolicy --model=Order` => 创建授权策略类 + ``` + public function own(User $user, Order $order) + { + return $user->id === $order->user_id; + } + ``` + > 记得在 AuthServiceProvider 中注册,然后在 OrdersController@show 中授权 `$this->authorize('own', $order);` + +# 集成支付宝支付功能(沙箱) +> [参考文档](https://github.com/prohorry-me/notes/blob/master/laravel/laravel5/8.md) +1. 引用扩展 `composer require yansongda/pay` +2. 配置文件,放在 config/alipay.php 中 +``` + [ + 'app_id' => '', + 'ali_public_key' => '', + 'private_key' => '', + 'log' => [ + 'file' => storage_path('logs/alipay.log'), + ], + ], +]; +``` +3. app/Providers/AppServiceProvider@register => 注入服务 +``` +use Monolog\Logger; +use Yansongda\Pay\Pay; + + public function register() + { + // 注入 alipay 对象 + $this->app->singleton('alipay', function () { + // 获取配置文件信息(config/pay.php 中的 alipay 数组的信息) + $config = config('pay.alipay'); + // 判断当前项目运行环境是否为测试环境 + if (app()->environment() !== 'production') { + $config['mode'] = 'dev'; //设置配置为开发模式 + $config['log']['level'] = Logger::DEBUG; //以 debug 级别记录日志 + } else { //否则为生产环境(项目在上线运营) + $config['log']['level'] = Logger::WARNING; //否则以 warning 级别记录日志 + } + // 返回一个实例化 alipay 支付宝支付对象(由 yansongda/pay 扩展提供) + return Pay::alipay($config); + }); + } +``` +> 这样一来就可以直接用 `app('alipay')` 实例化一个支付对象 + +4. 创建支付控制器 `php artisan make:controller PaymentController` +``` +use App\Order; +use App\Exceptions\InvalidRequestException; + + ... + + /** + * 跳转到支付宝支付页面 + */ + public function payByAlipay(Order $order, Request $request) + { + // 判断订单是否属于当前用户 + $this->authorize('own', $order); + + // 订单已支付或者已关闭 + if ($order->paid_at || $order->closed) { + throw new InvalidRequestException('订单状态不正确'); + } + + // 调用支付宝的网页支付 + return app('alipay')->web([ + 'out_trade_no' => $order->no, // 订单编号,需保证在商户端不重复 + 'total_amount' => $order->total_price, // 订单金额,单位元,支持小数点后两位 + 'subject' => '支付 广元食客 的订单:'.$order->no, // 订单标题 + ]); + } + + /** + * 支付宝-前端回调(浏览器) + */ + public function alipayReturn() + { + // 校验数据 + try { + app('alipay')->verify(); + } catch (\Exception $e) { //如果出错则抛出异常 + return view('pages.error', ['msg' => '数据不正确']); + } + + // 否则就跳转到通知页面告诉用户支付成功 + return view('pages.success', ['msg' => '付款成功']); + } + + /** + * 支付宝-后端回调(数据库) + */ + public function alipayNotify() + { + // 暂时先记录一下即可 + $data = app('alipay')->verify(); + \Log::debug('Alipay notify', $data->all()); + } +``` +2. 配置路由 routes/web.php +``` +Route::group(['middleware' => 'email_verified'], function() { + + ... + + Route::get('payment/{order}/alipay', 'PaymentController@payByAlipay')->name('payment.alipay'); //支付宝支付 + Route::get('payment/alipay/return', 'PaymentController@alipayReturn')->name('payment.alipay.return'); //支付宝-前端回调 +}); + +... + +Route::post('payment/alipay/notify', 'PaymentController@alipayNotify')->name('payment.alipay.notify');// 支付宝-后端回调 +``` + +> 这里后端回调必须写在外面,因为后端回调是完成支付后,支付宝的服务器给我们自动发送消息,但是我们不能要求支付宝还有我们系统的用户账号并且验证过邮箱。 +3. 编辑 app/Providers/AppServiceProvider@register => 设置回调路由 +``` + $this->app->singleton('alipay', function () { + // 获取配置文件信息(config/pay.php 中的 alipay 数组的信息) + + ... + + // **设置回调路由** + $config['notify_url'] = route('payment.alipay.notify'); //后端回调 + $config['return_url'] = route('payment.alipay.return'); //前端回调 + + ... + + } +``` +------------------------ +> 上面后端回调没写,现在完成后端回调 +1. 在 app/Http/Middleware/VerifyCsrfToken.php 声明支付宝后端回调不接受 csrf 筛选 +``` + protected $except = [ + 'payment/alipay/notify', // 支付宝不接受监听 + ]; +``` +2. 完成 PaymentController@alipayNotify +``` + /** + * 支付宝-后端回调(数据库) + */ + public function alipayNotify() + { + // 由于我们没有 csrf 监听,所以任何人都可以请求我们这个方法,但是 yongsda/pay 扩展提供了一个方法接收并校验参数,确保我们接收的都是支付宝后台发来的数据 + $data = app('alipay')->verify(); + // $data->out_trade_no 拿到订单流水号,并在数据库中查询 + $order = Order::where('no', $data->out_trade_no)->first(); + // 判断订是否存在 + if (!$order) { + return 'fail'; + } + // 如果这笔订单的状态已经是已支付 + if ($order->paid) { + // 返回数据给支付宝 + return app('alipay')->success(); + } + + \DB::transaction(function () use ($order, $data) { + // 修改订单状态 + $order->update([ + 'paid' => true, //已支付 + 'paid_at' => Carbon::now(), // 支付时间 + 'paid_by' => 'alipay', // 支付方式 + 'payment_no' => $data->trade_no, // 支付宝订单号 + ]); + + // 处理门店信息 + $shop = $order->shop; + $shop->income += $order->total_price; + $shop->sold_count += 1; + $shop->save(); + }); + + // 告诉支付宝,不用再继续请求我们的接口了,我们已经收到了用户成功地通过支付宝支付的信息,且更新了我们的数据库了 + return app('alipay')->success(); + } +``` +> 如果项目在本地,则需要利用 requestBin 来测试后端回调,参考教程,后来我觉得太麻烦,直接把弄到自己的服务器上了 + +# 部署项目在服务器上 +1. 部署项目参考 [这里](https://github.com/prohorry-me/notes/blob/master/%E7%B3%BB%E7%BB%9F%E5%92%8C%E8%BD%AF%E4%BB%B6%E4%BD%BF%E7%94%A8/%E8%85%BE%E8%AE%AF%E4%BA%91%E4%BD%BF%E7%94%A8.md) +2. 补充说明: + 1. 要发送邮件需要解封25端口,进入控制台,点击用户名进行申请解封。 \ No newline at end of file diff --git "a/laravel/\346\257\225\344\270\232\350\256\276\350\256\241-\346\224\271/13.md" "b/laravel/\346\257\225\344\270\232\350\256\276\350\256\241-\346\224\271/13.md" new file mode 100644 index 0000000..854ab5b --- /dev/null +++ "b/laravel/\346\257\225\344\270\232\350\256\276\350\256\241-\346\224\271/13.md" @@ -0,0 +1,86 @@ +# 商家查看已付款的订单列表,并准备发货 +1. `php artisan make:controller shopsAdmin/OrdersController` => 创建控制器 +``` +authorize('hasShop', $request->user()); + + $shop = $request->user()->shop; + $orders = $shop->orders()->orderBy('updated_at', 'desc')->paginate(6); + + return view('orders.manage.shopsAdmin.index', [ + 'shop' => $shop, + 'orders' => $orders, + ]); + } +} +``` +2. 路由 routes/web.php `Route::get('shops_admin/orders', 'shopsAdmin\OrdersController@index')->name('shops_admin.orders.index'); //后台订单列表` +3. 视图 ../orders/manage/shopsAdmin/index.blade.php ,略 + +# 收货发货功能 +> 商家发货 +1. shopsAdmin/OrdersController@ship +``` + /** + * 发货 + */ + public function ship(Request $request, Order $order) + { + $this->authorize('hasShop', $request->user()); + + // 验证数据 + $data = $this->validate($request, [ + 'sender' => 'required', + 'contact' => [ + 'required', + function($attribute, $value, $fail) { + if(!preg_match("/^1[345678]{1}\d{9}$/", $value)){ + $fail('请填写正确的送货人联系方式'); + } + } + ], + ]); + + + $ship['ship_data'] = $data['sender'] . ':' . $data['contact']; + $ship['ship_status'] = Order::SHIP_STATUS_DELIVERED; + + $order->update($ship); + + return redirect()->back(); + } +``` +2. 路由 `Route::post('shops_admin/orders/{order}/ship', 'shopsAdmin\OrdersController@ship')->name('shops_admin.orders.ship'); //点击发货` +--------------------------------------------------------------- +> 用户收货 +1. OrdersController@confirm +``` + /** + * 确认收货 + */ + public function confirm(Order $order) + { + $this->authorize('own', $order); + + $order->update([ + 'ship_status' => Order::SHIP_STATUS_RECEIVED, + ]); + + session()->flash('success', '确认收货成功,祝您用餐愉快'); + return redirect('/'); + } +``` +2. 路由 `Route::get('/orders/confirm/{order}', 'OrdersController@confirm')->name('orders.confirm'); //确认收货` +----------------------------------------------------------------- +> 更新各个视图的物流状态显示,通过 `@if ... @elseif @else` 判断并准确显示物流状态。 \ No newline at end of file diff --git "a/laravel/\346\257\225\344\270\232\350\256\276\350\256\241-\346\224\271/2.md" "b/laravel/\346\257\225\344\270\232\350\256\276\350\256\241-\346\224\271/2.md" new file mode 100644 index 0000000..b1b1c22 --- /dev/null +++ "b/laravel/\346\257\225\344\270\232\350\256\276\350\256\241-\346\224\271/2.md" @@ -0,0 +1,333 @@ +# 汉化密码重置邮件 +> 密码重置邮件是英文的,需要重写通知内容为中文 +1. 创建一个新的通知类 `php artisan make:notification ResetPassword` +``` +token = $token; + } + + public function via($notifiable) + { + return ['mail']; + } + + public function toMail($notifiable) + { + return (new MailMessage) + ->greeting('广元食客-重置密码') + ->subject('重置密码') + ->line('这是一封密码重置邮件,如果是您本人操作,请点击以下按钮继续:') + ->action('重置密码', url(http://github.com/route('password.reset',%20$this-%3Etoken,%20false))) + ->line('如果您并没有执行此操作,您可以选择忽略此邮件。') + ->salutation('祝您生活愉快'); + } +} +``` +2. 编辑 User 模型,重写 Notifiable@sendPasswordResetNotification +``` +notify(new ResetPassword($token)); + } +} +``` +3. `php artisan vendor:publish --tag=laravel-notifications` => 将默认的邮件视图模板拿出来,汉化最下面的 `{{-- Subcopy --}}` 部分 + +# 用户邮箱认证 +1. `php artisan make:migration add_email_verified_to_users --table=users` => 创建迁移文件,用于在 users 表中增加一个字段 "email_verified" + * 迁移文件 + ``` + // up() + $table->boolean('email_verified')->default(false)->after('remember_token'); // <= 在 remember_token 后面增加 email_verified 字段 + + // down() + $table->dropColumn('email_verified'); // <=逆向操作删除这个字段 + ``` + * 配置 User 模型 + ``` + /** + * 可填字段 + */ + protected $fillable = [ + 'name', 'email', 'password', 'email_verified' // <= 声明新增的 email_verified 字段可填 + ]; + ``` +> 记得执行 `php artisan migrate` + +1. `php artisan make:middleware CheckIfEmailVerified` => 创建中间件:用于过滤没有验证邮箱的用户的请求 + * 编辑中间件文件 app/Http/Middleware/ 目录下 + ``` + public function handle($request, Closure $next) + { + // 如果发起请求的用户的 email_verified 字段为 false 证明没有验证邮箱 + if (!$request->user()->email_verified) { + // 那么就重定向到路由 email_verify_notice => 一个提示用户验证邮箱的页面 + return redirect(route('email_verify_notice')); + } + + // 否则就放行该请求 + return $next($request); + } + ``` + * 配置路由 + ``` + /** + * 用户已登陆后可访问的路由组 + */ + Route::group(['middleware' => 'auth'], function() { + Route::get('/email_verify_notice', 'PagesController@emailVerifyNotice')->name('email_verify_notice'); //提示用户去验证自己的邮箱 + + /** + * 用户已登陆且已经验证邮箱后可访问的路由组 + */ + Route::group(['middleware' => 'email_verified'], function() { + Route::get('/test', function() { //这条路由用于测试 + return '邮箱已经成功验证'; + }); + }); + }); + ``` + * 编辑方法 PagesController@emailVerifyNotice + ``` + /** + * 提示邮件认证页 + */ + public function emailVerifyNotice(Request $request) + { + return view('pages.email_verify_notice'); + } + ``` + * 编辑视图 ../pages/email_verify_notice.blade.php => 一个提示用户认证自己邮箱的页面,同时这个页面会有一个主动发送邮件的按钮,在后面说 + * 编辑 app/Http/Kernel.php => 注册中间件,取名为 `email_verified` + ``` + protected $routeMiddleware = [ + ... + 'email_verified' => \App\Http\Middleware\CheckIfEmailVerified::class, + ]; + ``` + > 此时实现的逻辑就是访问 `http://gydiner.test/test` 时,如果用户没有验证邮箱,会自动跳转到 email_verify_notice 路由 +2. `php artisan make:notification EmailVerificationNotification` => 创建一个利用邮件通知用户验证自己的邮箱的通知类 +``` +email, $token, 30); + + // 拼接验证地址 + $url = route('email_verification.verify', ['email' => $notifiable->email, 'token' => $token]); + + return (new MailMessage) + ->greeting('广元食客-验证邮箱') + ->subject('验证邮箱') + ->line('这是一封验证邮箱邮件,如果是您本人操作,请点击以下按钮继续:') + ->action('验证邮箱', $url) + ->line('如果您并没有执行此操作,您可以选择忽略此邮件。') + ->salutation('祝您生活愉快'); + } +} +``` +4. `php artisan make:controller EmailVerificationController` => 验证邮箱的控制器 + * 控制器内容 + ``` + input('email'); + $token = $request->input('token'); + + // 如果有一个为空说明不是一个合法的验证链接,直接抛出异常。 + if (!$email || !$token) { + throw new Exception('验证链接不正确'); + } + + // 从缓存中读取数据,我们把从 url 中获取的 `token` 与缓存中的值做对比 + // 如果缓存不存在或者返回的值与 url 中的 `token` 不一致就抛出异常。 + if ($token != Cache::get('email_verification_'.$email)) { + throw new Exception('验证链接不正确或已过期'); + } + + // 根据邮箱从数据库中获取对应的用户 + // 通常来说能通过 token 校验的情况下不可能出现用户不存在 + // 但是为了代码的健壮性我们还是需要做这个判断 + if (!$user = User::where('email', $email)->first()) { + throw new Exception('用户不存在'); + } + + // 将指定的 key 从缓存中删除,由于已经完成了验证,这个缓存就没有必要继续保留。 + Cache::forget('email_verification_'.$email); + // 最关键的,要把对应用户的 `email_verified` 字段改为 `true`。 + $user->update(['email_verified' => true]); + + // 最后告知用户邮箱验证成功。 + return view('pages.success', ['msg' => '邮箱验证成功']); + } + } + ``` + * 配置路由 + ``` + Route::group(['middleware' => 'auth'], function() { + Route::get('/email_verify_notice', 'PagesController@emailVerifyNotice')->name('email_verify_notice'); //提示用户去验证自己的邮箱 + Route::get('/email_verification/verify', 'EmailVerificationController@verify')->name('email_verification.verify'); //验证邮件 + ... + } + ``` + * 视图 ../pages/success.blade.php => 就是一个提示用户验证成功的静态页面 +5. 主动发送认证邮件 + > 主动认证用于:网络问题导致注册时没有自动发送邮件,所以主动发送 + * EmailVerificationController@send + ``` + /** + * 主动发起邮箱认证邮件 + */ + public function send(Request $request) + { + $user = $request->user(); + // 判断用户是否已经激活 + if ($user->email_verified) { + throw new Exception('你已经验证过邮箱了'); + } + // 调用 notify() 方法用来发送我们定义好的通知类 + $user->notify(new EmailVerificationNotification()); + + return view('pages.success', ['msg' => '邮件发送成功']); + } + ``` + * ../pages/email_verify_notice.blade 中的按钮就是访问的此方法。 +6. 注册时自动发送认证邮件 + > 使用事件监听器:当用户注册账号成功时,抛送一个任务给后台,让他异步发送一封验证邮件给用户 + * 装 predis 扩展 `composer require "predis/predis:~1.0"` + > 完成之后配置 .env 文件 `QUEUE_DRIVER=redis` + * 创建监听器 `php artisan make:listener RegisteredListener` => 位于 app/Listeners/ 下 + ``` + user; + // 调用 notify 发送通知 + $user->notify(new EmailVerificationNotification()); + } + } + ``` + * 事件监听器需要注册,编辑 app/Providers/EventServiceProvider.php + ``` + protected $listen = [ + 'App\Events\Event' => [ + 'App\Listeners\EventListener', + ], + \Illuminate\Auth\Events\Registered::class => [ + \App\Listeners\RegisteredListener::class, + ], + ]; + ``` + > `事件类 => [...事件监听器]` => 事件类触发时,会自动执行事件监听器。(同时一个事件可以有多个监听器,所以是数组) + + > 测试时需要开启队列任务的监听 `php artisan queue:listen` +7. 逻辑总结 + 1. 筛选请求的逻辑是这样的:创建中间件,注册中间件,然后在路由配置文件 routes/web.php 中以路由组的形式配置路由(过滤请求) + 2. 邮箱认证的逻辑是这样的:创建邮件通知类(通知的逻辑中生成验证码存储在 Cache 中,并且把邮箱和验证码拼成邮件发送过去的按钮的地址),用户可以主动发送邮件(EmailVerificationController@send)或者注册时自动异步发送邮件(Registered 事件触发事件监听器 RegisteredListener),邮件认证则是通过验证邮箱的按钮访问 EmailVerificationController@verify 方法(比对 Cache 和 邮件地址然后更新数据库)。 +8. 整理代码,编辑 routes/web.php +``` +/** + * 用户已登陆且已经验证邮箱后可访问的路由组 + */ + Route::group(['middleware' => 'email_verified'], function() { + //... 清空之前的测试路由 + }); +``` \ No newline at end of file diff --git "a/laravel/\346\257\225\344\270\232\350\256\276\350\256\241-\346\224\271/3.md" "b/laravel/\346\257\225\344\270\232\350\256\276\350\256\241-\346\224\271/3.md" new file mode 100644 index 0000000..f9ff632 --- /dev/null +++ "b/laravel/\346\257\225\344\270\232\350\256\276\350\256\241-\346\224\271/3.md" @@ -0,0 +1,460 @@ +# 集成验证码功能 +1. 安装扩展 `composer require "mews/captcha:~2.0"` +2. 发布资源 `php artisan vendor:publish --provider='Mews\Captcha\CaptchaServiceProvider'` +3. 在视图上( ../auth/register.blade.php )增加 +``` +
+ + + +
+ +
+
+ + + @if ($errors->has('captcha')) + + {{ $errors->first('captcha') }} + + @endif +
+
+``` +> 这里的 captcha-inputs 是全局的验证码输入组的样式,写在 app.scss 中。 + +4. 编辑 app/Http/Controllers/Auth/RegisterController@validator => 添加验证规则和错误提示 +``` + protected function validator(array $data) + { + return Validator::make($data, [ + 'name' => 'required|string|max:255', + 'email' => 'required|string|email|max:255|unique:users', + 'password' => 'required|string|min:6|confirmed', + 'captcha' => 'required|captcha' // <= 添加验证规则 + + // **配置错误提示** + ], [ + 'captcha.required' => '验证码不能为空', + 'captcha.captcha' => '验证码不正确' + ]); + } +``` + +# 配置后台管理模块 +* 安装和配置 laravel-admin 扩展 + * 安装扩展 `composer require encore/laravel-admin "1.5.*"` + * 发布资源 `php artisan vendor:publish --provider="Encore\Admin\AdminServiceProvider"` + * 执行数据库迁移,创建默认账号,菜单等 `php artisan admin:install` + * 执行 linux 命令 `rm -rf resources/lang/ar/ resources/lang/en/admin.php resources/lang/es/ resources/lang/fr/ resources/lang/he/ resources/lang/ja/ resources/lang/nl/ resources/lang/pl/ resources/lang/pt/ resources/lang/ru/ resources/lang/tr/ resources/lang/zh-TW/ resources/lang/pt-BR/ resources/lang/fa/` => 删除没用的语言包, `rm -f app/Admin/Controllers/ExampleController.php` => 删除扩展提供的举例控制器 + * 编辑 config/admin.php, [参考文件](https://github.com/prohorry-me/gydiner/blob/master/config/admin.php) + * 进入后台 http://gydiner.test/admin => 默认用户名和密码都是 admin,编辑 menu,汉化菜单。 + +# 在后台配置用户管理 +1. 创建后台用户管理控制器 `php artisan admin:make UsersController --model=App\\User` => 位于 app/Admin/Controllers/ +``` +header('用户列表'); //标题 + $content->body($this->grid()); //表格 + }); + } + + /** + * 首页表格 + */ + protected function grid() + { + // 根据回调函数,在页面上用表格的形式展示用户记录 + return Admin::grid(User::class, function (Grid $grid) { + + // 创建一个列名为 ID 的列,内容是用户的 id 字段,并且可以在前端页面点击排序 + $grid->id('ID')->sortable(); + + // 创建一个列名为 用户名 的列,内容是用户的 name 字段。下面的 email() 和 created_at() 同理 + $grid->name('用户名'); + + $grid->email('邮箱'); + + $grid->email_verified('已验证邮箱')->display(function ($value) { + return $value ? '是' : '否'; + })->sortable(); // ..->sortable() 可排序 + + $grid->created_at('注册时间'); + + // 不在页面显示 `新建` 按钮,因为我们不需要在后台新建用户 + $grid->disableCreateButton(); + + $grid->actions(function ($actions) { + // 不在每一行后面展示查看按钮 + $actions->disableView(); + + // 不在每一行后面展示删除按钮 + // $actions->disableDelete(); + + // 不在每一行后面展示编辑按钮 + $actions->disableEdit(); + }); + + // 配置工具栏 + $grid->tools(function ($tools) { + + // 禁用批量删除按钮 + $tools->batch(function ($batch) { + $batch->disableDelete(); + }); + }); + }); + } + + /** + * 删除用户功能 + */ + public function destroy(User $user) + { + $user->delete(); + + return [ + 'status' => 'success', + 'message' => '删除成功', + ]; + } +} +``` +2. 配置路由 app/Admin/routes.php +``` + config('admin.route.prefix'), + 'namespace' => config('admin.route.namespace'), + 'middleware' => config('admin.route.middleware'), +], function (Router $router) { + // 路由 + $router->get('/', 'HomeController@index'); + $router->get('users', 'UsersController@index'); + $router->delete('users/{user}', 'UsersController@destroy'); +}); +``` +3. 在后台页面侧边栏增加菜单:进入后台,选择后台管理,选择菜单,右侧新增一个路径为 /users 的新菜单项即可。 + +# 完成收货地址模块 +1. `php artisan make:model UserAddress -m` => 创建模型和迁移文件 + * 编辑迁移文件 + ``` + // up + $table->increments('id'); + $table->unsignedInteger('user_id'); //外键:用户id + $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade'); //保证删除用户的时候也能删除用户的收货地址 + $table->string('address'); //具体地址 + $table->string('alias'); //收货地址别名: “家”, “学校”,... + $table->string('contact'); //联系人 + $table->string('phone'); //联系人电话 + $table->dateTime('last_used_at')->nullable(); //最近使用时间 + $table->timestamps(); + ``` + > 最近使用时间用于后期用户创建订单的时候,根据这个字段进行排序。 + * 编辑模型文件 + ``` + # UserAddress + /** + * 可填字段 + */ + protected $fillable = [ + 'name', 'email', 'password', 'email_verified', 'contact', 'phone' + ]; + + /** + * 声明的字段自动转换为时间 Carbon 时间对象 + */ + protected $dates = ['last_used_at']; + + # User + /** + * 1:n UserAddress + */ + public function userAddresses() + { + return $this->hasMany(UserAddress::class); + } + ``` + > 完成后执行迁移 `php artisan migrate` + ----------------------------------------------------------------------------------- + * 生成模型工厂文件 `php artisan make:factory UserAddressFactory --model=UserAddress` + ``` + return [ + 'address' => $faker->address, + 'alias' => $faker->name, + 'contact' => $faker->name, + 'phone' => $faker->phoneNumber, + ]; + ``` + * 生成填充类 `php artisan make:seeder UserAddressesTableSeeder` + ``` + use App\User; + use App\UserAddress; + + ... + + public function run() + { + User::all()->each(function (User $user) { + factory(UserAddress::class, random_int(1, 3))->create(['user_id' => $user->id]); + }); + } + ``` + > 这里是先用 `User::all()` 拿到所有用户的数据并且用 `->each()` 遍历,最终每个用户会随机生成1到3条收货地址的数据。 + + > 调用填充类填充数据 `php artisan db:seed --class=UserAddressesTableSeeder` + +2. `php artisan make:controller UserAddressesController --model=UserAddress` => 创建资源控制器,把 show 方法删了。 + * 配置路由 routes/web.php + ``` + /** + * 用户已登陆后可访问的路由组 + */ + Route::group(['middleware' => 'auth'], function() { + + /** + * 用户已登陆且已经验证邮箱后可访问的路由组 + */ + Route::group(['middleware' => 'email_verified'], function() { + Route::resource('/user_addresses', 'UserAddressesController', ['except' => 'show']); //收货地址资源路由 + }); + }); + ``` + * 在 ../layouts/_header.blade.php 中增加一个入口链接 + +3. 收货地址 CURD + * 收货地址列表 + * 方法 UserAddressesController@index + ``` + /** + * 收货地址列表 + */ + public function index(Request $request) + { + // 查询用户的收货地址 + $userAddresses = $request->user() //获取当前用户 + ->userAddresses() //根据绑定关系,获取当前用户的收货地址 + ->orderBy('last_used_at', 'desc') //根据最近使用时间排序 + ->orderBy('created_at', 'desc') //然后根据创建时间排序 + ->paginate(6); //生成分页对象 + + // 跳转到收货地址列表 + return view('user_addresses.index', [ + 'user_addresses' => $userAddresses, + ]); + } + ``` + * 视图 ( ../user_addresses/index.blade.php ) => 用表格的形式遍历显示收货地址,只需要注意添加一个分页的链接 `{!! $userAddresses->links() !!}` 即可 + * 新增收货地址 + * `php artisan make:request Request` => 创建请求基类 + ``` + 创建收货地址请求类,用于验证添加和编辑收货地址时用户提交的表单数据 + ``` + ['required', 'min:10', 'max:100'], + 'alias' => ['required', 'min:2', 'max:50'], + 'contact' => ['required', 'min:2', 'max:50'], + 'phone' => [ + 'required', + function($attribute, $value, $fail) { // <= 自定义验证规则验证手机号码 + if(!preg_match("/^1[345678]{1}\d{9}$/", $value)){ + $fail('请填写正确的手机号码'); + } + } + ], + ]; + } + + /** + * 字段中文名称 + */ + public function attributes() + { + return [ + 'address' => '收货地址', + 'alias' => '地址别名', + 'contact' => '联系人', + 'phone' => '联系电话' + ]; + } + } + ``` + * 完成方法 UserAddressesController@create && store + ``` + use App\Http\Requests\UserAddressRequest; + + ... + + /** + * 新增收货地址 + */ + public function create() + { + return view('user_addresses.create'); + } + + /** + * 保存收货地址 + */ + public function store(UserAddressRequest $request) // <= 在参数列表中验证数据 + { + // 保存新的收货地址 + $request->user() + ->userAddresses() + ->craete($request->post()); // 获取所有 post 数据,并且保存 + + // 重定向到收货地址列表 + return redirect()->route('user_addresses.index'); + } + ``` + * 创建视图 ( ../user_addresses/create.blade.php ) => 一个表单,需要带上 csrf 认证 `{{ csrf_field() }}` + * 在收货地址列表 ( ../user_addresses/index.blade.php ) 中增加 “新增收货地址” 入口链接。 + + * 编辑收货地址 + * 完成方法 UserAddressesController@edit 以及 update + ``` + /** + * 编辑收货地址 + */ + public function edit(UserAddress $userAddress) + { + return view('user_addresses.edit', [ + 'userAddress' => $userAddress, + ]); + } + + /** + * 更新收货地址 + */ + public function update(UserAddressRequest $request, UserAddress $userAddress) + { + $userAddress->update($request->post()); + + return redirect()->route('user_addresses.index'); + } + ``` + * 创建视图 ( ../user_addresses/edit.blade.php ) => 和 create 一样,就是在表单项的 value 中给上初始值,同时添加 `{{ method_field('PUT') }}` => 伪造 PUT 方法。 + * 在 ( ../user_addresses/index.blade.php ) 中添加入口链接。 + +# 删除收货地址 +> 单独说因为需要用到一个前端插件 sweetalert +* 安装前端插件 `cnpm install sweetalert --save` +* 编辑 resources/assets/js/bootstrap.js => 引入插件,让其能投入使用 +``` +require('sweetalert'); +``` +* 之前在布局模板 ../layouts/app.blade.php 中声明了一个占位符 `@yield('scripts')`, 在后面的代码中,我们把 sweetalert 的逻辑代码写在这里面。比如这里的删除按钮的前端逻辑 ../user_address/index.blade.php 中 +``` + + # 这里添加一个类 delete-user-address 方便找到按钮 + # 以及一个 data-id 来获取要删除的收货地址的主键id + +@section('scripts') + +@endsection +``` +* 完成后台逻辑 UserAddressesController@destroy +``` + /** + * 删除收货地址 + */ + public function destroy(UserAddress $userAddress) + { + $userAddress->delete(); + + return []; // <= 这里返回空数组,因为是接口的形式在响应前端的请求 + } +``` \ No newline at end of file diff --git "a/laravel/\346\257\225\344\270\232\350\256\276\350\256\241-\346\224\271/4.md" "b/laravel/\346\257\225\344\270\232\350\256\276\350\256\241-\346\224\271/4.md" new file mode 100644 index 0000000..155166f --- /dev/null +++ "b/laravel/\346\257\225\344\270\232\350\256\276\350\256\241-\346\224\271/4.md" @@ -0,0 +1,272 @@ +# 完善收货地址模块 +> 昨天忘记完成收货地址的授权认证了:需要确保当前用户只能修改自己的收货地址。 + +* `php artisan make:policy UserAddressPolicy --model=UserAddress` => 创建授权类 +``` +id === $userAddress->user_id; + } +} + +``` +* 编辑 app/Providers/AuthServiceProvider.php => 注册授权策略 +``` +protected $policies = [ + 'App\Model' => 'App\Policies\ModelPolicy', + \App\UserAddress::class => \App\Policies\UserAddressPolicy::class, // <= 添加 +]; +``` +* 编辑 UserAddressesController@edit, update, destroy 方法,添加代码 `$this->authorize('own', $userAddress);` => 在执行其他操作前授权一下。 + + +# 商户模块-模型、数据表 +1. `php artisan make:model Shop -m` => 创建商店模型、同时创建建表的迁移文件 +* 迁移文件 +``` + // 基本信息 + $table->increments('id'); + $table->unsignedInteger('user_id'); //外键:店长id + $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade'); + $table->string('name'); //商店名称 + $table->string('address'); //商店地址 + $table->string('image')->default(''); //商店LOGO + $table->text('desc'); //商店简介 + $table->unsignedInteger('phone')->default(0); //门店联系电话 + $table->string('keyword')->default(''); //主要经营:用于查询检索 + + // 法律信息 + $table->string('licence'); //门店营业执照 + $table->string('faren'); //商店店长(法人) + $table->string('id_card'); //法人身份证 + $table->string('faren_phone'); //法人联系电话 + $table->boolean('read_legal_file')->default(false); //阅读了开店须知的相关法律文件 + + // 认证信息 + $table->boolean('activated')->default(false); //是否激活 + $table->string('activation_token')->nullable(); //激活码,已经激活时内容为 '已激活' + + // 排序 + $table->unsignedInteger('rating')->nullable(); //商店评分 + $table->unsignedInteger('sold_count')->default(0); //总共卖出(订单数) + $table->unsignedInteger('review_count')->default(0); //总共被评价次数 + + // 营业相关 + $table->boolean('full_day')->default(true); //是否全天营业 + $table->unsignedInteger('open_time')->default(0); // 由于默认全天营业所以开门和打烊时间默认为0 + $table->unsignedInteger('close_time')->default(0); // 由于默认全天营业所以开门和打烊时间默认为0 + $table->unsignedDecimal('income', 10, 2)->default(0); //营业额:用于结算提现 + $table->timestamps(); +``` +* 模型和模型关系 +``` +# User + /** + * 1:1 Shop + */ + public function shop() + { + return $this->hasOne(Shop::class); + } + +# Shop + /** + * 可填字段 + */ + protected $fillable = [ + 'name', 'address', 'image', 'desc', 'phone', + 'licence', 'faren', 'id_card', 'faren_phone', 'read_legal_file', + 'activated', 'activation_token', + 'full_day', 'open_time', 'close_time', 'income' + ]; + + /** + * 隐藏字段 + */ + protected $hidden = [ + 'faren', 'id_card', 'faren_phone' + ]; + + /** + * 1:1 User + */ + public function user() + { + return $this->belongsTo(User::class); + } +``` +> 完成之后执行迁移 `php artisan migrate` + +# 商户注册 +1. 确定逻辑 + * 已验证邮箱的登陆用户,可以通过“我要开店”按钮申请在我们的项目上开设自己的门店,然后给用户提供一个表单,这个表单只需要填写 + 1. 门店的营业执照 + 2. 法人的姓名、身份证、联系电话 + 3. 是否已经阅读了我们在表单下方提供的“开店须知” + 4. 门店的名称、地址。 + > 同时在创建的时候还会在后台程序中自动生成激活码 + * 在用户提交表单之后,我们的后台可以看到一个没有通过审核的门店,此时我们则可以审核用户提交的门店信息是否合法,然后允许他开店,那么我们在后台点击“通过审核”的时候: + 1. 发送一封带激活码的邮件给用户:让他激活自己的门店 + * 在用户激活自己的门店的时候,又是一个新表单,需要用户填写: + 1. 门店的后续信息:LOGO、简介、联系电话、主要经营的项目(火锅,中餐,小吃等) + 2. 营业信息:是否全天营业、不是的话需要填写营业时间 + +2. `php artisan make:controller ShopsController --model=Shop` => 创建资源控制器 +3. 配置路由 routes/web.php +``` +... + +/** + * 用户已登陆后可访问的路由组 + */ +Route::group(['middleware' => 'auth'], function() { + ... + + /** + * 用户已登陆且已经验证邮箱后可访问的路由组 + */ + Route::group(['middleware' => 'email_verified'], function() { + ... + + Route::resource('shops', 'ShopsController', ['except' => ['index', 'show']]); //门店管理资源路由(创建、编辑、删除) + }); +}); + +Route::resource('shops', 'ShopsController', ['only' => ['index', 'show']]); //展示门店资源路由(列表、详情) +``` +> 将 index 和 show 写外面是因为:游客也可以游览商店列表和商店详情。 + +> 写后面是因为:如果先读路由只支持 index 和 show 那么是找不到 shops.create, edit, ... 登路由的 +----------------------------------------------------------------------- +* 完成第一步:用户点击“我要开店”按钮,进入开店界面,填写相关信息(主要是法律信息),将数据存于数据库中,等待后台审核 + 1. 控制器 ShopsController@create => 提供开店页面 + ``` + /** + * 申请开店页面 + */ + public function create(Request $request) + { + // 跳转到开店申请页面,并且将用户信息传过去(用于展示用户信息) + return view('shops.manage.create', [ + 'user' => $request->user(), + ]); + } + ``` + 2. 视图 ../shops/manage/create.blade.php => manage/ 路径下存放的都是以店长身份进行门店操作的相关视图。 + * 就是一个表单 + * 需要注意的第一点就是营业执照的上传功能(详情参考 github 上我的 Demo) + * 需要注意的第二点就是用模态框给用户展示“开店须知” + 3. 入口链接放在 ../layouts/_footer.blade.php 中 + 4. `php artisan make:request CreateShopRequest` => 创建一个请求类验证用户申请开店表单的数据,[详情](https://github.com/prohorry-me/gydiner/blob/master/app/Http/Requests/CreateShopRequest.php),这里有一个验证身份证号码是否正确的自定义验证规则函数。 + 5. 完成 ShopsController@store 方法 => 保存申请开店的门店信息,但是此时门店不可用,只是在后台可以显示出来 + ``` + use Exception; + + ... + + /** + * 将申请信息存储在数据库中 + */ + public function store(CreateShopRequest $request) + { + // 判断是否开设有门店 + if($request->user()->shop) { + throw new Exception('您已申请过或者开设有门店'); + } + + // 处理数据 + $data = $request->post(); + $data['activation_token'] = str_random(16); //生成16位随机验证码 + $data['read_legal_file'] = true; //只要用户通过了验证,说明已经阅读并同意了“开店须知” + $data['desc'] = ''; //商店简介需要一个默认值 + + // 创建门店 + $request->user()->shop()->create($data); + + // 跳转到提示地址 + return view('pages.success', [ + 'msg' => '您已成功发送申请,请耐心等待审核' + ]); + } + ``` + 6. 在 EmailVerificationController 以及 ShopsController 中都用了 `thorw new Exception()` 来抛送异常,但是这样抛送的是系统错误级别的异常,而其实这两个异常应该是用户操作上的异常,因此我们应该新建一个异常类来完成错误的抛送和提示 + * 创建用户级别的异常类 `php artisan make:exception InvalidRequestException` => app/Exceptions/ 目录下 + ``` + $this->message]); + } + } + ``` + * 创建系统级别的异常类 `php artisan make:exception InternalException` + ``` + msgForUser = $msgForUser; + } + + public function render() + { + return view('pages.error', ['msg' => $this->msgForUser]); + } + } + ``` + > 用户操作错误产生的异常,需要告诉用户到底错误发生在什么地方,而系统内部错误则默认抛出“系统内部错误”的提示,但是我们还会写一个 `$message` 给系统内部日志 + + > 用户操作错误产生的异常,不需要记录日志,而系统级别的错误,需要记录日志 + * 在 app/Exceptions/Handler.php 中声明日志的记录 + ``` + protected $dontReport = [ + InvalidRequestException::class, + ]; + ``` + * 需要在控制器层投入使用(ShopsController 和 EmailVerificationController :两个控制器中的任何错误都是用户误操作引起的,所以只需要) + ``` + use App\Exceptions\InvalidRequestException; // <= 把之前引用的 Exception(laravel自己的报错工具) 替换成我们自己创建的异常类 + + ... + + throw new InvalidRequestException('错误提示信息'); // <= 这里同理 + ``` + * 最后我们还需要定义视图 ../pages/error.blade.php => 跟 success.blade.php 一样,就是展示错误提示信息。 \ No newline at end of file diff --git "a/laravel/\346\257\225\344\270\232\350\256\276\350\256\241-\346\224\271/5.md" "b/laravel/\346\257\225\344\270\232\350\256\276\350\256\241-\346\224\271/5.md" new file mode 100644 index 0000000..2e9f61e --- /dev/null +++ "b/laravel/\346\257\225\344\270\232\350\256\276\350\256\241-\346\224\271/5.md" @@ -0,0 +1,440 @@ +# 在后台展示商店列表 +* 创建控制器 `php artisan admin:make ShopsController --model=App\\Shop` +``` +header('商店管理'); //标题 + $content->body($this->grid()); //表格 + }); + } + + /** + * 首页表格 + */ + protected function grid() + { + // 根据回调函数,在页面上用表格的形式展示用户记录 + return Admin::grid(User::class, function (Grid $grid) { + $grid->id('ID')->sortable(); + + $grid->name('商店名称'); + + $grid->address('商店地址'); + + $grid->phone('联系电话'); + + $grid->faren_phone('法人电话'); + + $grid->created_at('注册时间'); + + $grid->disableCreateButton(); // 禁用新建按钮 + + // 配置工具栏 + $grid->tools(function ($tools) { + + // 禁用批量删除按钮 + $tools->batch(function ($batch) { + $batch->disableDelete(); + }); + }); + }); + } +} +``` +* 配置路由 `$router->get('shops', 'ShopsController@index');` + +* 在后台管理中增加菜单 + +# 展示商店详情和激活商店按钮 +* ShopsController@show +``` + /** + * 查看商店详情 + */ + public function show(Shop $shop) + { + return Admin::content(function (Content $content) use ($shop) { + $content->body(view('shops.admin.show', [ + 'shop' => $shop, + ])); + }); + } +``` +* 配置路由 `$router->get('shops/{shop}', 'ShopsController@show');` +* 编辑视图 ../shops/admin/show.blade.php => 增加下面的用于激活的按钮 +``` +# 激活按钮 +@if(!$shop->activated) +

没有过审

+ +@else +

运营中

+@endif + +# 激活按钮的点击事件 + +``` +> laravel_admin 扩展的前端资源中自带 v1 版本的 swal ,所以写法有所改变 + +> 同时这个扩展不带 `axios`,所以只能用 jquery 的 `$.ajax` 请求后台的激活方法, success 回调中的 (data) 就是我们返回的数组, (自动转json) + +* 配置一条后台路由 `$router->post('shops/{shop}/active', 'ShopsController@active');` +> 就完成了后台激活按钮的点击事件。 + +# 后台发送激活邮件 +1. `php artisan make:notification ShopActivationNotifaction` +``` +shop = $shop; + } + + public function via($notifiable) + { + return ['mail']; + } + + public function toMail($notifiable) + { + // 生成验证码 + $this->shop->activation_token = str_random(16); + $this->shop->save(); + + return (new MailMessage) + ->greeting('广元食客-激活商店') + ->subject('激活商店') + ->line('亲爱的商家,您好,您的开店申请已通过管理员审核,请点击下面的按钮完善商店信息') + ->action('激活商店', url(route('shops.active', [ + 'shop' => $this->shop->id, + 'token' => $this->shop->activation_token, + ]))) + ->line('如果您并没有申请开店,有可能是他人冒用了您的邮箱,对您的打扰深表歉意') + ->salutation('祝您生活愉快'); + } +} +``` +> 这里在构造函数中实例化了一次当前被激活的商店并交给成员变量 `$shop` 保管,是为了在 `toMail()` 方法中,发送邮件前,生成激活码并保存,然后在邮件按钮上带上商店id和激活码,发送给用户(所以之前前台申请开店的方法不要再生成16位激活码了,而是在这里生成) +2. 完成后台的 ShopsController@active 方法:发送激活邮件 +``` +use App\Notifications\ShopActivationNotifaction; // <= 引用上面的通知类 + +... + + /** + * 门店激活 + */ + public function active(Shop $shop) + { + // 有以下三种情况 + // 1, 我们没有发送过激活邮件(activation_token = null && activated = false) + if(!$shop->activation_token && !$shop->activated) { + // 获取商店用户并发送邮件(在发送邮件的过程中生成激活码) + $shop->user->notify(new ShopActivationNotifaction($shop)); + return [ + 'type' => 'success', + 'title' => '成功发送激活邮件', + ]; + } + + // 2, 我们发送了激活邮件,但是商户还没有点击邮件去激活(activation_token 有值 && activated = false) + if($shop->activation_token && !$shop->activated) { + return [ + 'type' => 'warning', + 'title' => '已发送过激活邮件, 商户暂时还为完成激活操作', + ]; + } + + // 3, 商户已激活 (activation_token = '已激活' && activated = true) + if($shop->activation_token == '已激活' && $shop->activated) { + return [ + 'type' => 'danger', + 'title' => '商户已激活', + ]; + } + } +``` +3. 配置一条前台路由 shops.active (商户从激活邮件中点击“激活商店”按钮访问的页面:提供一个完善商户信息的表单) +``` +/** + * 用户已登陆后可访问的路由组 + */ +Route::group(['middleware' => 'auth'], function() { + ... + + /** + * 用户已登陆且已经验证邮箱后可访问的路由组 + */ + Route::group(['middleware' => 'email_verified'], function() { + ... + Route::get('/shops/{shop}/{token}/active', 'ShopsController@active')->name('shops.active'); //激活商店 + Route::resource('/shops', 'ShopsController', ['except' => ['index', 'show']]); //门店管理资源路由(创建、编辑、删除) + ... + }); +}); +``` +> 写在资源路由前面,防止路由提前被匹配 + +# 商户完善资料 +> 上面的代码完成了:后台管理员查看商店详情,如果合法,详情页面有一个点击激活的按钮,点击之后会发送一封邮件给申请开店的商户的邮箱,邮件中带一个激活按钮,此时商户点击按钮,就会跳转到 shops.active +* 前台的 ShopsController@active +``` + /** + * 激活门店 + */ + public function active(Request $request) + { + $shop = Shop::find($request->shop); + $token = $request->token; + + // 比对激活码 + if($shop->activation_token === $token) { + // 跳转到激活页面 + return view('shops.manage.active', [ + 'shop' => $shop, + ]); + }else { + if($shop->activated) { + throw new InvalidRequestException('您已激活门店!'); + }else { + throw new InvalidRequestException('禁止非法访问!'); + } + } + } +``` +* 完成视图:../shops/manage/active.blade.php, 有以下需要注意的店 + 1. 表单需要支持图片上传功能、以及它的 action `` + 2. keyword是这样的 + ``` + ... + + + ... + ``` + 3. 全天营业有一个功能:勾选全天营业,则隐藏时间选择,否则显示营业时间选择器 + ``` + # html +
+ + +
+ + # js + // 是否全天营业,不是的话需要显示时间选择器 + $("#full_time").change(function() { + $("#open_close_time").fadeToggle(500); + }) + ``` + 4. **simditor** => 编辑器的集成,可以参考 [之前的笔记](https://github.com/prohorry-me/notes/blob/master/laravel/laravel2/%E6%80%BB%E7%BB%93.md) + * 上传图片的 [Handlers] 代码:(https://github.com/prohorry-me/larabbs/blob/master/app/Handlers/ImageUploadHandler.php) + * 需要用composer 装 php 图片裁剪插件: `composer require intervention/image` + * 我将控制器层的逻辑写在 PhotosController@simditor 方法中,[详情参考]() + * 最后配置一条编辑器的图片上传功能的路由 `Route::post('/photos/simditor', 'PhotosController@simditor')->name('photos.simditor'); //图片上传(simiditor编辑器` (写在验证邮箱后的用户可以访问的路由中) +------------------------------------------------------------------------- +* web.php 配置一条路由:对应上面的表单,用于保存完善后的商户信息,正式激活商店。 +``` +Route::post('/shops/{shop}/do_active', 'ShopsController@doActive')->name('shops.doActive'); //激活商店 +``` +* `php artisan make:request ActiveShopRequest` => 验证用户提交的信息 +``` + ['required'], + 'desc' => ['required', 'min:15'], + 'phone' => [ + 'required', + function($attribute, $value, $fail) { + if(!preg_match("/^1[345678]{1}\d{9}$/", $value)){ + $fail('请填写正确的手机号码'); + } + } + ], + 'keyword' => ['required'], + 'captcha' => ['required', 'captcha'] + ]; + } + + /** + * 字段中文名称 + */ + public function attributes() + { + return [ + 'image' => '商店 logo', + 'desc' => '商店简介', + 'phone' => '门店电话', + ]; + } + + /** + * 错误提示信息 + */ + public function messages() + { + return [ + 'captcha.required' => '必须填写验证码', + 'captcha.captcha' => '请输入正确的验证码', + 'keyword.required' => '请至少选择一项经营的项目,可多选', + ]; + } +} +``` +> `full_time` 和 `open_time`, `close_time` 都不验证,在控制器层直接进行处理 + +* ShopsController@doActive +``` +use App\Http\Requests\ActiveShopRequest; + +... + + /** + * 激活商户:保存商户的详情信息 + */ + public function doActive(ActiveShopRequest $request, Shop $shop) + { + // 获取数据 + $data = $request->post(); + + // 1, 处理 keyword + $keyword = ''; + for($i=0; $iname; // 最后的结果就是 x个主要经营的项目+店名 + + // 2, 处理营业时间 + if(isset($data['full_day'])) { + $data['full_day'] = true; + unset($data['open_time']); + unset($data['close_time']); + }else { + $data['full_day'] = false; + $data['open_time'] = $data['open_time'] ? str_replace(':', '', $data['open_time']) : '0'; + $data['close_time'] = $data['close_time'] ? str_replace(':', '', $data['close_time']) : '0'; + } + + // 3,把验证码字段拿掉 + unset($data['captcha']); + + // 4,处理激活 + $data['activated'] = true; + $data['activation_token'] = '已激活'; + + // 更新数据 + $shop->update($data); + + return view('pages.success', [ + 'msg' => '您已成功激活门店', + ]); + } +``` +* 排除 Bug 和优化 + 1. 之前建表的时候将门店电话字段数据类型设置为了 Int ,是极其错误的做法,应该设置为 string + 2. 后台的样式:查看商店详情、营业执照的时候,应该限制图片的宽度 + 3. 后台给商户发送激活邮件的邮件类应该实现 ShouldQueue 接口 `class ShopActivationNotifaction extends Notification implements ShouldQueue` => 使其异步操作,提高管理员的用户体验。 \ No newline at end of file diff --git "a/laravel/\346\257\225\344\270\232\350\256\276\350\256\241-\346\224\271/6.md" "b/laravel/\346\257\225\344\270\232\350\256\276\350\256\241-\346\224\271/6.md" new file mode 100644 index 0000000..85e5524 --- /dev/null +++ "b/laravel/\346\257\225\344\270\232\350\256\276\350\256\241-\346\224\271/6.md" @@ -0,0 +1,175 @@ +# 修复之前的BUG +1. 完善数据表结构:开店和打烊时间设置为 varchar 类型,默认为 00:00 +2. 前台的 ShopsController@doActive 方法修改逻辑,直接存时间即可。 +3. 为了代码结构更合理,创建一个新的后台管理控制器,将激活商店的两个方法,以及后续需要编写的商店后台首页、编辑、更新商店信息,注销商店等方法写在 shopsAdmin/ShopsController 中。 + * 创建控制器 `php artisan make:controller shopsAdmin/ShopsController`,内容如下 + ``` + user()->shop; + $token = $request->token; + + // 比对激活码 + if($shop->activation_token === $token) { + // 跳转到激活页面 + return view('shops.manage.shopsAdmin.active', [ + 'shop' => $shop, + ]); + }else { + if($shop->activated) { + throw new InvalidRequestException('您已激活门店!'); + }else { + throw new InvalidRequestException('禁止非法访问!'); + } + } + } + + /** + * 激活商户:保存商户的详情信息 + */ + public function doActive(ActiveShopRequest $request, Shop $shop) + { + // 验证商店于用户id是否匹配 + if($request->user()->id != $shop->user_id) { + throw new InvalidRequestException('请登陆正确的账号!'); + } + + // 获取数据 + $data = $request->post(); + + // 1, 处理 keyword + $keyword = ''; + for($i=0; $iname; // 最后的结果就是 x个主要经营的项目+店名 + + // 2, 处理营业时间 + if(isset($data['full_day'])) { + $data['full_day'] = true; + unset($data['open_time']); + unset($data['close_time']); + }else { + $data['full_day'] = false; + $data['open_time'] = $data['open_time'] ? $data['open_time'] : '00:00'; + $data['close_time'] = $data['close_time'] ? $data['close_time'] : '00:00'; + } + + // 3,把验证码字段拿掉 + unset($data['captcha']); + + // 4,处理激活 + $data['activated'] = true; + $data['activation_token'] = '已激活'; + + // 更新数据 + $shop->update($data); + + return view('pages.success', [ + 'msg' => '您已成功激活门店', + ]); + } + } + ``` + > 这里就是将之前面向前台写的 ShopsController 中的激活页面、激活方法剪切到这里来了,因此删除之前 ShopsController 中冗余的代码 + + > 唯一需要注意的就是要在这个 ShopsController 中 `use App\Http\Controllers\Controller; // <= 引用控制器基类` 才能继承 + + > 还有就是迁移了一下视图,将 “申请开店” 的视图放在 ../shops/manage/ 目录中, 而其他后台视图放在 ../shops/manage/shopAdmin/ 目录中,只有 index, show 两个视图放在 ../shops/ + + * 路由重新配置 routes/web.php + ``` + /** + * 用户已登陆且已经验证邮箱后可访问的路由组 + */ + Route::group(['middleware' => 'email_verified'], function() { + ... + Route::get('/shops/{shop}/{token}/active', 'shopsAdmin\ShopsController@active')->name('shops_admin.active'); //激活商店 + Route::post('/shops/{shop}/do_active', 'shopsAdmin\ShopsController@doActive')->name('shops_admin.doActive'); //激活商店 + Route::resource('/shops', 'ShopsController', ['only' => ['create', 'store']]); // 申请开店第一步的两个路由 + ... + }); + + Route::resource('/shops', 'ShopsController', ['only' => ['index', 'show']]); //展示门店资源路由(列表、详情) + ``` + > 之前的前台资源路由,现在仅支持“申请开店第一步”:创建一个待激活的商店 + + > 而激活方法则迁移到 `shopsAdmin\ShopsController` 这个商店后台控制器中 + +# 商户后台首页 +> 这篇开发日志后面的 ShopsController 都是说的 shopsAdmin\ShopsController 这个商店后台管理控制器 +* ShopsController@amin => 后台管理首页 +``` + /** + * 后台首页 + */ + public function admin(Request $request) + { + $shop = $request->user()->shop; + + return view('shops.manage.shopsAdmin.admin', [ + 'shop' => $shop, + ]); + } +``` +* 配置路由 `Route::get('/shops_admin/index', 'shopsAdmin\ShopsController@index')->name('shops_admin.index'); //商店后台首页` => 写在两条激活路由下面 +* 配置视图 + * 视图结构: + * 布局视图 ../layouts/shops_admin.blade.php,有两部分:侧边栏展示商户的大致样式,右侧为具体内容 + * 侧边栏直接在布局视图中 include 进去,名为 ../layouts/_menu.blade.php + * 右侧内容则声明占位符 `@yield('content')` + * 装一个插件:设置侧边栏的导航按钮激活样式 `composer require "hieu-le/active:~3.5"`,具体使用可以参考之前的 [笔记](https://github.com/prohorry-me/notes/blob/master/laravel/laravel2/%E6%80%BB%E7%BB%93.md) 中记录的各种扩展的使用的第6点 + * 后台首页的具体内容就是显示商店的信息,以及提供一个编辑按钮,省略。 +* 增加入口:在 ../layouts/_header.blade.php 中增加一个商店管理的入口 +``` +# 判断一下当前用户有没有开设商店 +@if(Auth::user()->shop) +
  • + 商店管理 +
  • +@endif +``` +* 用 Policy 限制其下没有商店的用户访问后台 + 1. 创建 Policy `php artisan make:policy UserPolicy` + ``` + shop; + } + } + ``` + * 在 app/Providers/AuthServiceProvider.php 中注册 Policy + ``` + protected $policies = [ + 'App\Model' => 'App\Policies\ModelPolicy', + \App\UserAddress::class => \App\Policies\UserAddressPolicy::class, + \App\User::class => \App\Policies\UserPolicy::class, // <= 新增的 + ]; + ``` + * 在 ShopsController 每个方法最前面都加上 `$this->authorize('hasShop', $request->user());` \ No newline at end of file diff --git "a/laravel/\346\257\225\344\270\232\350\256\276\350\256\241-\346\224\271/7.md" "b/laravel/\346\257\225\344\270\232\350\256\276\350\256\241-\346\224\271/7.md" new file mode 100644 index 0000000..be24db7 --- /dev/null +++ "b/laravel/\346\257\225\344\270\232\350\256\276\350\256\241-\346\224\271/7.md" @@ -0,0 +1,132 @@ +# 编辑商户信息 +1. shopsAdmin\ShopsController@edit => 编辑页面 +``` + /** + * 编辑商店信息 + */ + public function edit(Request $request) + { + $this->authorize('hasShop', $request->user()); + + $shop = $request->user()->shop; + + // 将商店的关键字弄成数组,传递给页面用户判断 checkbox 是否 checked + $shopKeyword = \explode(',', $shop->keyword); + + return view('shops.manage.shopsAdmin.edit', [ + 'shop' => $shop, + 'shopKeyword' => $shopKeyword + ]); + } +``` +2. 路由 +``` +# 这里将昨天的后台商业也合并未了资源路由。 + +Route::resource('/shops_admin', 'shopsAdmin\ShopsController', ['only' => ['index', 'edit', 'update', 'destroy']]); //商店后台管理 +``` +3. 视图 ../shops/manage/shopsAdmin/edit.blade.php,表单和激活页面的一样,仅提供可修改的内容,唯一多一层逻辑就是 keyword 的选中状态用 `@if(in_array('火锅', $shopKeyword))` +4. 在 ../../index.blade.php 中增加编辑入口,略 +> 在 _menu 视图中确保在编辑页面也让“商店管理”链接处于激活状态 `class="{{ active_class((if_route('shops_admin.index')) || if_route('shops_admin.edit')) }}"` +5. shopsController@update => 更新数据库 +``` + /** + * 更新商店信息 + */ + public function update(ActiveAndUpdateShopRequest $request) + { + $this->authorize('hasShop', $request->user()); + $shop = $request->user()->shop; + + // 获取数据 + $data = $request->post(); + + // 1, 处理 keyword + $keyword = ''; + for($i=0; $iname; // 最后的结果就是 x个主要经营的项目+店名 + + // 2, 处理营业时间 + if(isset($data['full_day'])) { + $data['full_day'] = true; + $data['open_time'] = '00:00'; + $data['close_time'] = '00:00'; + }else { + $data['full_day'] = false; + $data['open_time'] = $data['open_time'] ? $data['open_time'] : '00:00'; + $data['close_time'] = $data['close_time'] ? $data['close_time'] : '00:00'; + } + + // 3,把验证码字段拿掉 + unset($data['captcha']); + + // 更新 + $shop->update($data); + + return redirect()->route('shops_admin.index'); + } +``` +> 这里因为验证表单的逻辑和激活时的一样,所以直接用 ActiveShopRequest 来验证数据,将请求验证类改名(请求类的文件名、类名; 控制器层的引用名,参数列表中的名字),最后改为 ActiveAndUpdateShopRequest + +> 其他的逻辑是一样的,只是不用设置商店为激活状态。 + +> 这里修改了一下时间的逻辑:之前是如果设置为全天营业,那么就不管close_time 和 update_time,现在则将这两个时间设置为 00:00 + +> 编辑和更新的方法中都没有用依赖注入实例化 $shop,而是通过请求的用户去拿 $shop。但是表单中还是需要指定参数,是为了符合路由要求。 + +* 修复BUG:发现不能正确写进 keyword ,因为 Shop 模型中没有声明 keyword 为可填字段,添加即可。 + +# 注销 +1. ShopsController@destroy +``` + /** + * 用户因某些原因,自己注销自己的商店 + */ + public function destroy(Request $request) + { + $this->authorize('hasShop', $request->user()); + $shop = $request->user()->shop; + + $shop->delete(); + + return[]; + } +``` + +2. 前台 ../shops/manage/shops_admin/index.blade.php 用 swal 完成逻辑 +``` + + +... + +@section('scripts') + +@endsection +``` +> 只要在 blade 视图中,任何地方都可以用 {{ }} 来写 php 代码,调用全局函数等。这里就用 `{{ env('APP_URL') }}` 读取了 .env 中配置的项目地址。 \ No newline at end of file diff --git "a/laravel/\346\257\225\344\270\232\350\256\276\350\256\241-\346\224\271/8.md" "b/laravel/\346\257\225\344\270\232\350\256\276\350\256\241-\346\224\271/8.md" new file mode 100644 index 0000000..0860b88 --- /dev/null +++ "b/laravel/\346\257\225\344\270\232\350\256\276\350\256\241-\346\224\271/8.md" @@ -0,0 +1,288 @@ +# 商品表 +* 创建模型和迁移文件 `php artisan make:model Product -m` + * 迁移文件 + ``` + $table->increments('id'); + $table->unsignedInteger('shop_id'); //外键:所属商店ip + $table->foreign('shop_id')->references('id')->on('shops')->onDelete('cascade'); + $table->string('name'); //名称 + $table->string('image'); //图片 + $table->text('desc'); //简介 + $table->unsignedDecimal('price', 6, 2); //价格 + $table->boolean('on_sale')->default(true); //是否在售 + $table->timestamps(); + ``` + * 模型 + ``` + # Product + protected $fillable = [ + 'name', 'image', 'desc', 'price', 'on_sale', + ]; + + /** + * n:1 Shop + */ + public function shop() + { + return $this->belongsTo(Shop::class); + } + + # Shop + /** + * 1:n Product + */ + public function products() + { + return $this->hasMany(Product::class); + } + ``` + > 最后执行迁移 `php artisan migrate` + +# 商品 CURD +1. 控制器 `php artisan make:controller shopsAdmin/ProductsController` +2. 路由 routes/web.php : `Route::resource('shops_admin/products', 'shopsAdmin\ProductsController', ['except'=> 'show']); //后台商品管理,不要 show 方法` +3. 商品列表 +``` + /** + * 商品列表 + */ + public function index(Request $request) + { + $this->authorize('hasShop', $request->user()); + + $shop = $request->user()->shop; + $products = $shop->products()->orderBy('id', 'desc')->paginate(10); + + return view('products.manage.shopsAdmin.index', [ + 'shop' => $shop, + 'products' => $products + ]); + } +``` +4. 视图 ../products/manage/shopsAdmin/index.blade.php,用表格的形式展示商品。 +5. _menu 视图增加入口 `` +-------------------------------------------------------------------------------------------------- +* ProductsController@create => 创建商品的页面 +``` + /** + * 添加商品 + */ + public function create(Request $request) + { + $this->authorize('hasShop', $request->user()); + + $shop = $request->user()->shop; + + return view('products.manage.shopsAdmin.create', [ + 'shop' => $shop + ]); + } +``` +* 视图:一张表单,略。 +* 增加入口,在商品列表页的视图中增加一个添加商品的按钮即可 +* ProductsController@store => 保存新增的商品,在此之前需要,但再次之前 `php artisan make:request ProductRequest` => 用于新增和编辑商品的时候校验数据 +``` + ['required'], + 'name' => ['required', 'min:2', 'max:15'], + 'price' => [ + 'required', + function($attribute, $value, $fail) { + if(!preg_match("/^[0-9]+(.[0-9]{1,2})?$/", $value)){ + $fail('请填写正确的金额,最多2位小数'); + } + } + ], + 'desc' => ['required', 'min:15'], + 'captcha' => ['required', 'captcha'] + ]; + } + + public function messages() + { + return [ + 'image.required' => '必须上传商品图片', + 'captcha.required' => '必须填写验证码', + 'captcha.captcha' => '请输入正确的验证码', + ]; + } + + public function attributes() + { + return [ + 'name' => '商品名称', + 'price' => '商品价格', + 'desc' => '商品简介' + ]; + } +} +``` +* 完成 ProductsController@store 方法 +``` +use App\Http\Requests\ProductRequest; + +... + + /** + * 保存商品 + */ + public function store(ProductRequest $request) + { + $this->authorize('hasShop', $request->user()); + + // 接收数据 + $data = $request->post(); + + // 处理一下 直接上架字段 + if(isset($data['on_sale'])) { + $data['on_sale'] = true; + }else { + $data['on_sale'] = false; + } + + $shop = $request->user()->shop; + $shop->products()->create($data); + + return redirect()->route('products.index'); + } +``` +* 改进一下:无论是编辑资料,还是添加或编辑商品,又或者后面要开发的购物车功能,都没有一个操作成功后的提示,所以: + 1. 新建视图 ../layouts/_msg.blade.php + ``` +
    + @if (Session::has('message')) +
    + + {{ Session::get('message') }} +
    + @endif + + @if (Session::has('success')) +
    + + {{ Session::get('success') }} +
    + @endif + + @if (Session::has('danger')) +
    + + {{ Session::get('danger') }} +
    + @endif +
    + ``` + 2. 在布局视图上(app.blade.php 和 shops_admin.blade.php 中合理的位置引入) + 3. 控制器层发消息 `session()->flash('success|message|danger', 'msg')` 在重定向先存储提示消息到 session 闪存即可 +----------------------------------------------------------------------------------- +* ProductsController@edit, update +``` + /** + * 编辑 + */ + public function edit(Request $request, Product $product) + { + $shop = $request->user()->shop; + + return view('products.manage.shopsAdmin.edit', [ + 'shop' => $shop, + 'product' => $product + ]); + } + + /** + * 更新 + */ + public function update(ProductRequest $request, Product $product) + { + + // 接收数据 + $data = $request->post(); + + // 处理一下 直接上架字段 + if(isset($data['on_sale'])) { + $data['on_sale'] = true; + }else { + $data['on_sale'] = false; + } + + $product->update($data); + + session()->flash('success', '修改商品信息成功'); + return redirect()->route('products.index'); + } +``` +* edit 视图和创建视图一样,就是需要改一下标题、表单指向、伪造 put 方法、给表单项默认值。 +---------------------------------------------------------------------------------- +# 删除商品 +1. ../products/manage/shosAdmin/index.blade.php => 列表视图上增加删除按钮和隐藏表单以及填充 scripts ,增加删除判断 +``` + + +@section('scripts') + +@endsection +``` +2. ProductsController@destroy +``` + /** + * 删除商品 + */ + public function destroy(Request $request, Product $product) + { + $product->delete(); + + session()->flash('danger', '删除商品成功'); + return redirect()->route('products.index'); + } +``` +------------------------------------------------------------------------------------- +* 完成授权 `php artisan make:policy ProductPolicy --model=Product` +``` + public function own(User $user, Product $product) + { + return $user->shop->id === $product->shop_id; + } +``` +* 注册授权 app/Providers/AuthServiceProvider +``` +protected $policies = [ + 'App\Model' => 'App\Policies\ModelPolicy', + \App\UserAddress::class => \App\Policies\UserAddressPolicy::class, + \App\User::class => \App\Policies\UserPolicy::class, + \App\Product::class => \App\Policies\ProductPolicy::class, // <= 注册 +]; +``` +* 在控制器层 ProductsController@edit, update, destroy 方法最前面添加 `$this->authorize('own', $product);` 进行授权 +------------------------------------------------------------------------------------- +> 之前写了个注销门店的方法,但是我觉得那样不好,店长点击注销,确定,然后门店直接就没了,这其实非常危险。我想换一种:用户点击注销,是在申请注销(此时门店不再对外公开,不再接收订单),然后我们后台如果同意,需要给他提现、确保他完成了所有订单,最后由后台管理员去删。 +* 删除 ../shops/manage/shopsAdmin/index.blade.php 中的删除按钮和相关js逻辑代码 +* 删除 shopsAdmin/ShopsController@destroy 方法 +* 编辑路由 `Route::resource('/shops_admin', 'shopsAdmin\ShopsController', ['only' => ['index', 'edit', 'update']]); //商店后台管理` (取消 'destroy' ) diff --git "a/laravel/\346\257\225\344\270\232\350\256\276\350\256\241-\346\224\271/9.md" "b/laravel/\346\257\225\344\270\232\350\256\276\350\256\241-\346\224\271/9.md" new file mode 100644 index 0000000..df5e15c --- /dev/null +++ "b/laravel/\346\257\225\344\270\232\350\256\276\350\256\241-\346\224\271/9.md" @@ -0,0 +1,234 @@ +# 前台 +> 确定逻辑:在首页增加一个入口链接,给用户一个输入框,让用户输入关键字,搜索后台数据,然后将用户带到前台的 ShopsController@index 方法中,给用户展示搜索结果,然后用户可以通过点击某个商店,查看商店详情,每个商店页面除了展示自身的信息之外,还展示其下拥有的商品列表,在页面上提供一个购物车功能,供用户选购商品,生成订单 + +1. 确定路由 `Route::resource('/shops', 'ShopsController', ['only' => ['index', 'show']]); //展示门店资源路由(列表、详情)` +2. 编辑 root.blade.php ,增加一个 get 方式,请求 shops.index 路由的表单:根据关键字搜索商店 +3. 完成 app/Http/ShopsController@index, show 方法: +``` + /** + * 首页搜索后显示门店列表 + */ + public function index(Request $request) + { + if(!$request->keyword) { + return redirect('/'); + } + + $keyword = '%' . $request->keyword . '%'; + + $shops = Shop::where('activated', '=', true) + ->where('keyword', 'like', $keyword) + ->orderBy('sold_count', 'desc') + ->orderBy('rating', 'desc') + ->orderBy('id', 'desc') + ->paginate(20); + + return view('shops.index', [ + 'shops' => $shops, + ]); + } + + /** + * 门店详情 + */ + public function show(Shop $shop) + { + return view('shops.show', [ + 'shop' => $shop + ]); + } +``` +4. 完成视图 + * ../shops/index.blade.php => 显示商店列表,略 + * ../shops/show.blade.php => 显示商店详情,有一点需要注意,我们在一个页面同时显示商店其下拥有的商品,以及商店收到的评论: + ``` +
    +
    + +
    + + {{-- 判断路由参数的不同,跳转到不同的页面 --}} + @if (!if_query('show', 'reviews')) + @include('products._products', ['products' => $shop->products]) + @endif + @if (if_query('show', 'reviews')) + 展示评论,暂时没完成 + @endif +
    + ``` + > 通过 `if_query($key, $value)` 判断路由的显性参数(url地址上显示的),来挂在不同的子视图。 + * 这里我们暂时只完成了 ../products/_products.blade.php 视图:展示门店商品列表。 + +# 用户收藏商店功能 +> 方便用户下次访问 +1. `php artisan make:migration create_user_favorite_shops_table --create=user_favorite_shops` => 创建关系表 user_favorite_shops +``` +$table->increments('id'); +$table->unsignedInteger('user_id'); +$table->foreign('user_id')->references('id')->on('users')->onDelete('cascade'); +$table->unsignedInteger('shop_id'); +$table->foreign('shop_id')->references('id')->on('shops')->onDelete('cascade'); +$table->timestamps(); +``` +2. 绑定模型关系 +``` +# User 模型 + /** + * n:n Shop + * 收藏的商店 + */ + public function favoriteShops() + { + return $this->belongsToMany(Shop::class, 'user_favorite_shops') + ->withTimestamps() + ->orderBy('user_favorite_shops.created_at', 'desc'); + } +``` +> n:n 关系绑定 `$this->belongsToMany(模型类, 中间表名)` + +> 通常用户会更希望找到最后收藏的那家店,所以用 `withTimestamps()` 获取中间表时间戳, 然后 `orderBy(中间表.created_at, 'desc')` 来排序 + +> 执行迁移 `php artisan migrate` +--------------------------------------------------------------------------------------- +* app/Http/ShopsController 中申明收藏方法和取消收藏方法 +``` + /** + * 收藏商店 + */ + public function favor(Request $request, Shop $shop) + { + // 获取发起请求的用户 + $user = $request->user(); + + // 看看用户是否已经收藏了商品 + if ($user->favoriteShops()->find($shop->id)) { + return []; + } + + // 没有收藏就收藏 + $user->favoriteShops()->attach($shop); + + return []; + } + + /** + * 取消收藏 + */ + public function disfavor(Request $request, Shop $shop) + { + $user = $request->user(); + + // 取消收藏 + $user->favoriteShops()->detach($shop); + + return []; + } +``` +> 注意我们这里都返回空数组是因为我们在前台用 swal 显示提示信息。 +* 配置路由 +``` +Route::post('shops/{shop}/favorite', 'ShopsController@favor')->name('shops.favor'); //收藏shop +Route::delete('shops/{shop}/favorite', 'ShopsController@disfavor')->name('shops.disfavor'); //取消收藏shop +``` +> 写在验证邮箱后的用户可访问的路由组中 +------------------------------------------------------------------------ +* ../shops/show.blade.php => 完成收藏功能 +``` +# html 中的收藏按钮 + + +# js 中的逻辑代码 +@section('scripts') + +@endsection +``` +* 取消收藏功能:先在 ShopsController@show 中做一下处理:获取当前用户是否收藏了正在查看的商店 +``` + /** + * 门店详情 + */ + public function show(Request $request, Shop $shop) + { + // 默认当前商店没有被喜欢(没登陆的用户也需要看到的是 “收藏” 按钮) + $favored = false; + + // 判断一下当前用户是否登陆,如果已登陆,那么判断一下是否喜欢该商店 + if($user = $request->user()) { + $favored = boolval($user->favoriteShops()->find($shop->id)); // boolval() => 将参数转为布尔类型 + } + + return view('shops.show', [ + 'shop' => $shop, + 'favored' => $favored + ]); + } +``` +* 回到 ../shops/show.blade.php => 完成取消收藏按钮的功能 +``` +# html 先判断 $favored 的值,显示 “收藏” / “取消收藏” 按钮 +@if($favored) + +@else + +@endif + +# js 完成逻辑 +// 取消收藏按钮的点击事件 +$('.btn-disfavor').click(function () { + axios.delete('{{ route('shops.disfavor', ['shop' => $shop->id]) }}') + .then(function () { + swal('操作成功', '', 'success') + .then(function () { + location.reload(); + }); + }); +}); +``` +------------------------------------------------------------------------ +* 展示用户喜欢的商店 +1. 控制器 ShopsController@favorites +``` + /** + * 喜欢的商店 + */ + public function favorites(Request $request) + { + $shops = $request->user()->favoriteShops()->paginate(6); + + return view('shops.favorites', [ + 'shops' => $shops + ]); + } +``` +2. 路由 `Route::get('shops/favorites', 'ShopsController@favorites')->name('shops.favorites'); //收藏的商店` +3. 视图 ../shops/favorites.blade.php 和 index.blade.php 一样,就是多一个提示用户他共收藏有多少家店的这么一段话。 +4. 在 ../layouts/_header.blade.php 中增加入口链接。 \ No newline at end of file diff --git "a/laravel/\346\257\225\344\270\232\350\256\276\350\256\241/\345\274\200\345\217\221\346\227\245\345\277\22710.md" "b/laravel/\346\257\225\344\270\232\350\256\276\350\256\241/\345\274\200\345\217\221\346\227\245\345\277\22710.md" new file mode 100644 index 0000000..df0dcc2 --- /dev/null +++ "b/laravel/\346\257\225\344\270\232\350\256\276\350\256\241/\345\274\200\345\217\221\346\227\245\345\277\22710.md" @@ -0,0 +1,79 @@ +# 完成支付功能-准备工作 +1. 安装 [yansongda/laravel-pay](https://github.com/yansongda/laravel-pay) => 国内大神写的,集成了微信和支付宝支付功能: `composer require yansongda/pay` +2. 创建一个配置文件 config/pay.php :这是待会要用的配置信息 +``` + [ + 'app_id' => '', + 'ali_public_key' => '', + 'private_key' => '', + 'log' => [ + 'file' => storage_path('logs/alipay.log'), + ], + ], + + + // 微信支付 + 'wechat' => [ + 'app_id' => '', + 'mch_id' => '', + 'key' => '', + 'cert_client' => '', + 'cert_key' => '', + 'log' => [ + 'file' => storage_path('logs/wechat_pay.log'), + ], + ], +]; +``` +> 等下再填这些参数(需要去注册和申请) +3. 将支付操作类实例注入容器中 app/Providers/AppServiceProvider.php +``` + public function register() + { + // 这是注册的 sudosu + if (app()->isLocal()) { + $this->app->register(\VIACreative\SudoSu\ServiceProvider::class); + } + + // 注册支付宝支付 + $this->app->singleton('alipay', function () { + $config = config('pay.alipay'); + // 判断当前项目运行环境是否为线上环境 + if (app()->environment() !== 'production') { + $config['mode'] = 'dev'; + $config['log']['level'] = Logger::DEBUG; + } else { + $config['log']['level'] = Logger::WARNING; + } + // 调用 Yansongda\Pay 来创建一个支付宝支付对象 + return Pay::alipay($config); + }); + + // 注册微信支付 + $this->app->singleton('wechat_pay', function () { + $config = config('pay.wechat'); + if (app()->environment() !== 'production') { + $config['log']['level'] = Logger::DEBUG; + } else { + $config['log']['level'] = Logger::WARNING; + } + // 调用 Yansongda\Pay 来创建一个微信支付对象 + return Pay::wechat($config); + }); + } +``` +> 此时我们就可以用 `app('alipay')` 来取得对应的支付操作类的实例。 + +> `$this->app->singleton()` => 往服务容器中注入一个单例对象,第一次从容器中取对象时会调用回调函数来生成对应的对象并保存到容器中,之后再去取的时候直接将容器中的对象返回。 + +> `app()->environment()` => 获取当前运行的环境,线上环境会返回 production。 + +> 对于支付宝,如果项目运行环境不是线上环境,则启用开发模式,并且将日志级别设置为 DEBUG。由于微信支付没有开发模式,所以仅仅将日志级别设置为 DEBUG。 +4. 测试是否注入成功: +``` + +``` \ No newline at end of file diff --git "a/\347\263\273\347\273\237\345\222\214\350\275\257\344\273\266\344\275\277\347\224\250/\350\205\276\350\256\257\344\272\221\344\275\277\347\224\250.md" "b/\347\263\273\347\273\237\345\222\214\350\275\257\344\273\266\344\275\277\347\224\250/\350\205\276\350\256\257\344\272\221\344\275\277\347\224\250.md" new file mode 100644 index 0000000..db08439 --- /dev/null +++ "b/\347\263\273\347\273\237\345\222\214\350\275\257\344\273\266\344\275\277\347\224\250/\350\205\276\350\256\257\344\272\221\344\275\277\347\224\250.md" @@ -0,0 +1,18 @@ +> 购买 cvm (云主机) 过程不说了(其实我是免费领的15天,好用了再续费) + +# 买好了之后要做的事 +1. 下载一个脚 putty 的软件 => ssh 工具,帮我们登陆虚拟机的,其实还能用腾讯云的网页版,速度也不慢,就是看起来不专业。 +2. 到腾讯云控制台的 [这里](https://console.cloud.tencent.com/cvm/index) 看我们的主机,有一个“更多”,“密码/密钥”,“重置密码” => 默认密码太长。 +3. 腾讯云控制台右上角有一个铃铛,那是通知,修改密码完成后,就可以看到他发的通知,公网ip就是我们以后操作和访问的ip地址了。 + +# 部署一下我们的小项目 +1. 登陆服务器: + * 打开桌面应用 putty ,输入我们服务器的公网 ip 地址到 HostName 那一栏中,这样就会打开一个控制台,在请求登陆这个 ip (即可我们的服务器) + * 我是买的 ubuntu 系统,所以用户名默认为 ubuntu,这个登陆账号在重置密码的时候应该可以看到 + * 密码就是我们重置密码填写的新密码 +2. 安装我们需要的软件 + * laravel 需要 nginx php mysql ,还包括 redis 以及其他可能用到的数据库、 php 各种扩展、 composer 等,如果一个一个去装会很蛋疼,要是有 [一键部署脚本](https://github.com/summerblue/laravel-ubuntu-init) 就好了。 + * 安装完成之后会给你一个 mysql 的密码,我觉得麻烦不想改,直接从控制台里面复制出来了。 + * 安装完成后访问公网ip,会显示 “Welcome to nginx!” 说明部署成功,如果不行重启一下再试试。 +3. 接着就是切换到 /var/www 路径下,用 git 把我们的项目搞下来 `git clone 项目托管的地址` +4. 完成配置 \ No newline at end of file