LaraShop开发笔记

Online store in B2C mode by Laravel v5.7

项目更新地址:https://github.com/Damoclesword/LaraShop

用户模块开发

解决因为watch-poll导致CPU负载过高

"watch-poll": "npm run watch -- --watch-poll=5000",

建议在本机跑watch-poll,不要在vagrant容器中跑

快速将数据库字段值转换成常见的数据类型

// App\Models\User

protected $casts = ['email_verified' => 'boolean']

$casts 属性提供了一个便利的方法来将数据库字段值转换为常见的数据类型,$casts 属性应是一个数组,且数组的键是那些需要被转换的字段名,值则是你希望转换的数据类型。支持转换的数据类型有: integerrealfloatdoublestringbooleanobjectarraycollectiondatedatetimetimestamp

image-20181117145345800
image-20181117145345800

Laravel中如何判断Request是AJAX

/**
 * Determine if the current request probably expects a JSON response.
 */
if($request->expectsJson()) {
    // ...
}

用户验证邮箱的步骤

原理:在通知发送前,将用户的email作为key,将由随机字符串组成的token作为value,并将这对键值对存入Cache中;用户收到邮件后,点击“验证邮箱”链接,通过参数中的email取出Cache中的token,并与穿过来的参数中的token进行比对。

  1. 先定义消息通知类EmailVerificationNotification

    class EmailVerificationNotification extends Notification implements ShouldQueue
    {
        use Queueable;
        
        ...
            
        public function via($notifiable)
        {
            return ['mail'];
        }
    
        public function toMail($notifiable)
        {
            $token = Str::random('16');
    
            // Put key to Cache, and set the number of minutes
            Cache::add('email_verification_' . $notifiable->email, $token, 30);
    
            $url = route('email_verification.verify', ['email' => $notifiable->email, 'token' => $token]);
    
            return (new MailMessage)
                ->greeting('尊敬的 '.$notifiable->name .' '. '您好:')
                ->subject('注册成功,请验证您的邮箱')
                ->line('请点击下方链接验证您的邮箱')
                ->action('验证邮箱', $url);
        }
    }
  2. EmailVerificationController中定义验证邮箱链接的逻辑与手动发送验证邮件的逻辑

    class EmailVerificationController extends Controller
    {
        ...
            
        public function verify(Request $request)
        {
            $email = $request->input('email');
            $token = $request->input('token');
            $cache_key = 'email_verification_' . $email;
    
            // Verification URL is error
            if (!$email || !$token) {
                throw new \Exception('验证链接错误');
            }
    
            // Token is error or out-of-date
            if ($token != Cache::get($cache_key)) {
                throw new \Exception('验证链接不正确或者已过期');
            }
    
            // Can't find this user
            if (!$user = User::where('email', $email)->first()) {
                throw new \Exception('该用户不存在,验证失败');
            }
    
            // Remove Items from the cache
            Cache::forget($cache_key);
    
            $user->update(['email_verified' => true]);
    
            return view('pages.success', ['msg' => '恭喜您验证邮箱成功']);
        }
    
        public function send(Request $request)
        {
            $user = $request->user();
    
            if($user->email_verified) {
                throw new \Exception('你已经验证过邮箱了');
            }
    
            // Send notification manually
            $user->notify(new EmailVerificationNotification());
    
            return view('pages.success', ['msg' => '邮件发送成功']);
        }
    }
    
  3. 设置监听器,在注册事件发生后,执行发送邮件

    class RegisteredListener implements ShouldQueue
    {
        ...
            
        public function handle(Registered $event)
        {
            // Can not use $event->user()
            $user = $event->user;
    
            $user->notify(new EmailVerificationNotification());
        }
    }
  4. App\Providers\EventServiceProvider中将前面定义监听器与Auth中的注册成功事件绑定

    use Illuminate\Auth\Events\Registered;
    ...
    class EventServiceProvider extends ServiceProvider
    {
        protected $listen = [
            ...
            Registered::class => [
                RegisteredListener::class,
            ]
        ];
    
        ...
            
    }

重命名Factory文件

重命名工厂文件之后需要执行 composer dumpautoload,否则会找不到对应的工厂文件。

如何自定义框架抛出异常的处理方式

// App\Exceptions\Handler.php

public function render($request, Exception $exception)
{
    // If throw Illuminate\Auth\Access\AuthorizationException, redirect to 'root'
    if ($exception instanceof AuthorizationException) {
        return redirect()->route('root');
    }
    
    return parent::render($request, $exception);
}

商品模块开发

商品SKU的概念

SKU = Stock Keeping Unit(库存量单位),也可以称为『单品』。对一种商品而言,当其品牌、型号、配置、等级、花色、包装容量、单位、生产日期、保质期、用途、价格、产地等属性中任一属性与其他商品存在不同时,可称为一个单品。

多维SKU的数据库设计

首先是product表:

字段名称描述字段类型索引
id自增长IDunsigned int主键
title商品名称varchar/
description商品详情text/
image商品封面图片文件路径varchar/
on_sale商品是否上架boolean/
rating商品平均评分float, default 5/
sold_count销量unsigned int, default 0/
review_count评论数量unsigned int, default 0/
priceSKU最低价格decimal(10, 2)/

这里注意,价格必须用decimal为单位保证精度,总精度为10,小数位精度为2

再来设计product_skus

字段名称描述字段类型stit索引
id自增IDunsigned int主键
titleSKU名称varchar/
descriptionSKU描述varchar/
priceSKU价格decimal(10, 2)/
stock库存unsigned int/
attributes商品属性(多维),与product_attributes_val表对应varchar/
product_id所属商品IDunsigned int外键product表的id

product_sku_attributes

字段名称描述字段类型索引
id自增IDunsigned int主键
product_id所属的商品IDunsigned int外键product表的id
name属性名字varchar/

product_attr_value

字段名称描述字段类型索引
symbol该属性的唯一序号标记unsigned int设置为主键
product_id所属商品IDunsigned int外键product表的id,和attr_value联合唯一索引
value属性值varcharproduct_id联合唯一索引
attr_id所属的属性IDunsigned int外键product_sku_attributes表的id

Model::query()和Model::all()的区别

  1. Model::all()返回的是Collection对象

image-20181122103043311
image-20181122103043311

image-20181122103057608
image-20181122103057608

  1. Model::query()返回的是Query Builder对象,需要调用get()方法才能转换成Collection,不过两者执行的SQL语句是不一样的,下面这句话,对应执行的SQL语句为select ... from ... where ..., 而Model::all()方法执行的仅仅是是select ... from,它的where筛选是在集合类中进行的操作而不是进行SQL筛选。

image-20181122103147927
image-20181122103147927

image-20181122103209419
image-20181122103209419

image-20181122103226193
image-20181122103226193

image-20181122103238926
image-20181122103238926

Model::query()->where(...)Model::where(...)写法一致

搜索和排序都放在一个form中

<form action="{{ route('products.index') }}" class="form-inline search-form">
    <input type="text" class="form-control input-sm" name="search" placeholder="搜索">
    <button class="btn btn-primary btn-sm">搜索</button>
    <select name="order" class="form-control input-sm pull-right">
        <option value="">排序方式</option>
        <option value="price_asc">价格从低到高</option>
        <option value="price_desc">价格从高到低</option>
        <option value="sold_count_desc">销量从高到低</option>
        <option value="sold_count_asc">销量从低到高</option>
        <option value="rating_desc">评价从高到低</option>
        <option value="rating_asc">评价从低到高</option>
    </select>
</form>

这样做的好处是修改了order,搜索的参数依旧不会变,还是这个搜索结果下的排序。

对于orWhereHas的理解

示例代码如下,此处的orWhereHas实际上是增加自定义条件到相应关联的约束中,因为此处product表和sku表是一对多的关系。

$builder = Product::where('on_sale', true);

// Deal with Search Request
if ($search = $request->input('search', '')) {
    $like = '%' . $search . '%';

    $builder->where(function ($query) use ($like) {
        $query->where('title', 'like', $like)
            ->orWhere('description', 'like', $like)
            ->orWhereHas('skus', function ($query) use ($like) {
                $query->where('title', 'like', $like)
                    ->orWhere('description', 'like', $like);
            });
    });
}

可以看到执行的SQL语句如下,关联部分就是or exists后面的部分:

select * from `products` where `on_sale` = 1 and (`title` like '%nisi%' or `description` like '%nisi%' or exists (select * from `product_skus` where `products`.`id` = `product_skus`.`product_id` and (`title` like '%nisi%' or `description` like '%nisi%'))) limit 16 offset 0

订单模块开发

“订单”数据库表设计

先定义orders

字段名称描述类型索引
id自增长IDunsigned int主键
no订单流水号varcharunique
user_id下单的用户IDunsigned int外键
addressjson格式的收货地址JSON/
total_amount订单总金额decimal/
remark订单备注text, null/
paid_at支付时间datetime, null/
payment_method支付方式varchar, null/
payment_no支付平台订单号varchar, null/
refund_status退款状态varchar/
refund_no退款单号varchar, null唯一
closed订单是否关闭tinyint, default 0/
reviewed订单是否已经评价tinyint, default 0/
ship_status物流状态varchar/
ship_data物流数据text, null/
extra其他额外数据text, null/

这里我们把收货地址用 JSON 格式保存而不是直接用一个外键连接到地址表,假如用户用地址 A 创建了一个订单,然后又修改了地址 A,那么用外键连接的方式这个订单的地址就会变成新地址,这并不符合正常的逻辑,所以我们需要用 JSON 格式把下单时的地址快照进订单,这样无论用户是修改还是删除之前的地址,都不会影响到之前的订单。

再定义order_items表:

字段名称描述类型索引
id自增长IDunsigned int主键
order_id所属订单IDunsigned int外键
product_id对应商品IDunsigned int外键
product_sku_id对应商品SKU的IDunsigned int外键
amount数量unsigned int/
price单价decimal/
rating用户打分unsigned int, null/
review用户评价text, null/
reviewed_at评价时间timestamp, null/

给模型添加数据时直接和某模型关联

associate,仅仅对belongsTo方法的模型有效, 如cart -> belongsTo -> user

$cart->user()->associate($user);
$cart->product_sku()->associate($sku_id);

减库存?

减库存的时候不能直接update(['stock' => $sku_stock - $amount]),这样会导致负库存的发生。

public function decreaseStock($amount)
{
    if ($amount < 0) {
        throw new InternalException('减库存的数量不可小于0');
    }

    return $this->newQuery()->where('id', $this->id)
        ->where('stock', '>=', $amount)
        ->decrement('stock', $amount);
}

可以通过return返回的行数确定是否减库存成功

使用预加载和使用延迟加载有什么区别?

使用预加载一般都是在加载一批数据的时候,可以只需要一条...in (...)的SQL语句就可以查出所要的数据,可以避免N+1问题的出现,如官方代码:

$books = App\Book::with(['author.contacts'])->get();

而延迟加载一般用于一条数据的时候:

$books = App\Book::all();

if ($someCondition) {
    $books->load('author', 'publisher');  // 单条数据加载关联
}

附上大神的解答:

就拿 Order 来说,在返回 Order 列表时需要用『预加载』,这个时候 Laravel 只需要一条 SQL 就能查出所有 Order 的 Items。

而『延迟预加载』通常在返回一条 Order 使用,就是你贴出来的这个代码,这种情况下你想用『预加载』也是可以的,效果是一样的。

使用Services模式

Service模式将Controller中的业务逻辑代码迁移至Service类中,解决了因为业务逻辑量大和复杂导致的Controller过于臃肿的问题,并且符合 SOLID 的单一责任原则。

单一职责原则:

如果一个类承担的职责过多,就等于把这些职责耦合在一起了。一个职责的变化可能会削弱或者抑制这个类完成其他职责的能力。这种耦合会导致脆弱的设计,当发生变化时,设计会遭受到意想不到的破坏。而如果想要避免这种现象的发生,就要尽可能的遵守单一职责原则。此原则的核心就是解耦和增强内聚性

Service还可以使用Laravel的依赖注入功能,这样大大提高了Service部分代码的测试性和程序的健壮性。

具体封装方式:https://laravel-china.org/courses/laravel-shop/5.5/encapsulation-business-code/1748

支付模块开发

获取关联模型与获取关联关系的区别

  1. $order->items
  2. $order->items()

二者的区别在于:$order->items()获取的是关联关系,这一步还没有做SQL查询,通常是准备做进一步的查询;$order->items获取的是关联的模型,SQL已经查询完毕,已经从数据库中获取到关联的数据。

比如一对多的$order->items获取到的就是一个Collection集合,集合里的每个元素都是items模型。

为何使用axios时不需要添加csrf_token?

../resources/assets/js/bootstrap.js中已经给出了答案:

window.axios = require('axios');

window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';

let token = document.head.querySelector('meta[name="csrf-token"]');

if (token) {
    window.axios.defaults.headers.common['X-CSRF-TOKEN'] = token.content;
} else {
    console.error('CSRF token not found: https://laravel.com/docs/csrf#csrf-x-csrf-token');
}

优惠券模块开发

“优惠券”数据库表设计

设计表coupon_codes

字段名称描述类型索引
id自增IDunsigned int主键
name优惠券的标题varchar/
description优惠券的基本信息描述varchar/
code优惠券码varchar唯一索引
type优惠券类型,支持固定金额和百分比折扣varchar/
value折扣值(百分比或者固定金额)decimal/
total全站可兑换的数量unsigned int/
used全站已兑换的数目unsigned int/
min_amount使用该优惠券的最低订单金额decimal/
not_before优惠券开始使用时间datetime, null/
not_after优惠券截止时间datetime, null/
enabled优惠券是否可用(true or false)tinyint/

然后为orders表增加字段coupon_code_id (此处先将订单捆绑优惠券)

字段名称描述类型索引
coupon_code_id优惠券idunsigned int外键,对应coupon_codesid字段

验证字段唯一性:编辑状态

某个字段的索引为唯一性索引,假如在编辑状态下未编辑此字段而提交的话,该字段的“验证”会报唯一性错误。

查看Laravel文档后得知:

unique:table,column,except,idColumn

强迫 Unique 规则忽略指定 ID :**

有时,你可能希望在进行字段唯一性验证时忽略指定 ID 。例如, 在「更新个人资料」页面会包含用户名、邮箱和地点。这时你会想要验证更新的 E-mail 值是否唯一。如果用户仅更改了用户名字段而没有改 E-mail 字段,就不需要抛出验证错误,因为此用户已经是这个 E-mail 的拥有者了。

使用 Rule 类定义规则来指示验证器忽略用户的 ID。这个例子中通过数组来指定验证规则,而不是使用 | 字符来分隔:

use Illuminate\Validation\Rule;

Validator::make($data, [
 'email' => [
     'required',
     Rule::unique('users')->ignore($user->id),
 ],
]);

如果你的数据表使用的主键名称不是 id ,那就在调用 ignore 方法时指定字段的名称:

'email' => Rule::unique('users')->ignore($user->id, 'user_id')

错误码

错误码解释
401验证身份错误
400未验证邮箱
422表单校验错误

商品类目设计

字段名称描述类型索引
id自增长IDunsigned int主键
name分类名称varchar/
parent_id父分类的IDunsigned int, null外键
is_directory是否拥有子分类tinyint/
level当前分类层级unsigned int/
path该分类所有父分类idvarchar/

为何要添加一个path字段?

类目树如下:

手机配件(1)
├─ 耳机(2)
│   └─ 蓝牙耳机(3)
└─ 移动电源(4)

现在我们再来逐一分析刚刚的几个场景:

场景一,查询『蓝牙耳机』的所有祖先类目:取出 path 字段的值 -1-2-,以 - 为分隔符分割字符串并过滤掉空值,得到数组 [1, 2] ,然后使用 Category::whereIn('id', [1, 2])->orderBy('level')->get() 即可获得排好序的所有父类目。

场景二,查询『手机配件』的所有后代类目:取出自己的 path-,然后追加上自己的 ID 字段得到 -1-,然后使用 Category::where('path', 'like', '-1-%')->get() 即可获得所有后代类目。

场景三,判断『移动电源』与『蓝牙耳机』是否有祖孙关系:取出两者中 level 值较大的类目『蓝牙耳机』的 path-1-2- 并赋值给变量 $highLevelPath,取另外一个类目的 path 值并追加该类目的 ID 得 -1-4- 并赋值给变量 $lowLevelPath,然后只需要判断变量 $highLevelPath 是否以 $lowLevelPath 开头,如果是则有祖孙关系。

用访问器定义的属性的访问问题

用访问器定义的属性,在Model中不能用$this->attributes['attr']来访问,而是直接用$this->attr

添加新评论