基于 Laravel + Vue 构建 API 驱动的前后端分离应用系列(二十一) —— 咖啡店标签后端 API 接口功能实现

接下来的两篇教程中,我们会咖啡店实现打标签功能,这个标签与咖啡店、标签和用户相关联,我们将最终根据用户为每个咖啡店标记的标签数量为排序条件为每个咖啡店构建标签云,由于功能相对而言比较复杂,我们分两篇教程来实现,首先实现后端相关 API 接口,从后端角度来说,这也是一个多对多关联,只不过在中间表中需要引入用户 ID 以便区分不同用户打的标签。

实现标签系统后,我们就可以通过多种维度对咖啡店进行筛选,比如在首页构建一个简单的咖啡店分拣组件。

第一步:创建标签表

在实现标签功能之前,首先需要创建相应的数据表,这里,我们需要创建两张表,一张用于存储标签,一张用于存储标签、咖啡店、用户三者关联关系。在项目根目录下,运行如下命令来创建标签表数据库迁移,指定表名为 tags

php artisan make:migration create_tags_table --create=tags

然后编辑新创建的 CreateTagsTable 迁移类的 up 方法,我们只是在这张表中新增一个 tag 字段用于存储标签名:

public function up()
{
    Schema::create('tags', function (Blueprint $table) {
        $table->increments('id');
        $table->string('name')->unique();
        $table->timestamps();
    });
}

需要注意的是,name 字段上定义了唯一索引,意味着标签值在标签表中是唯一的。

第二步:创建中间表

接下来创建存储标签、咖啡店、用户三者关联关系的中间表 cafes_users_tags

php artisan make:migration create_cafes_users_tags_table --create=cafes_users_tags

编辑新创建的 CreateCafesUsersTagsTable 迁移类的 up 方法如下:

public function up()
{
    Schema::create('cafes_users_tags', function (Blueprint $table) {
        $table->integer('cafe_id')->unsigned();
        $table->integer('user_id')->unsigned();
        $table->integer('tag_id')->unsigned();
        $table->primary(['cafe_id', 'user_id', 'tag_id'], 'cafes_users_tags_primary');
        $table->timestamps();
    });
}

一个用户只能不能重复给一个咖啡店打相同的标签,如果不需要知道谁打的标签,可以去掉 user_id 字段,但这样就无法为每个咖啡店统计同一个标签的数量了。

创建并编写好上面两个迁移类之后,就可以运行迁移命令在数据库中创建数据表了:

php artisan migrate

第三步:创建标签模型类

创建数据表之后,接下来创建相应的模型类并定义关联关系,这里我们需要创建一个 Tag 模型类映射 tags 表:

php artisan make:model Models/Tag

编写新生成的 app/Models/Tag.php 模型类代码如下:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Tag extends Model
{
    protected $fillable = [
        'name'
    ];

    public function cafes()
    {
        return $this->belongsToMany(Cafe::class, 'cafes_users_tags', 'tag_id', 'user_id');
    }
}

我们在 Tag 类中定义了一个 $fillable 属性用于支持批量赋值,以及 cafes 方法用来表示标签与咖啡店之间的多对多关联。

第四步:在咖啡店模型类中定义与标签的关联关系

相对的,我们还需要在咖啡店模型类 app/Models/Cafe.php 中定于咖啡店与标签之间的多对多关联方法 tags

public function tags()
{
    return $this->belongsToMany(Tag::class, 'cafes_users_tags', 'cafe_id', 'tag_id');
}

这样,我们就可以在查询咖啡店时获取咖啡店的标签了。

第五步:定义咖啡店标签路由

接下来在 routes/api.php 路由文件中定义给咖啡店添加标签和删除标签的路由:

/*
|-------------------------------------------------------------------------------
| 添加标签到指定咖啡店
|-------------------------------------------------------------------------------
| 请求URL:            /api/v1/cafes/{id}/tags
| 控制器方法:     API\CafesController@postAddTags
| 请求方式:         POST
| 功能描述:    用户为某个咖啡店添加标签
*/
Route::post('/cafes/{id}/tags', 'API\CafesController@postAddTags');

/*
|-------------------------------------------------------------------------------
| 删除指定咖啡店上的指定标签
|-------------------------------------------------------------------------------
| 请求URL:            /api/v1/cafes/{id}/tags/{tagID}
| 控制器方法:     API\CafesController@deleteCafeTag
| 请求方式:         DELETE
| 功能描述:    用户从某个咖啡店上删除标签
*/
Route::delete('/cafes/{id}/tags/{tagID}', 'API\CafesController@deleteCafeTag');

第六步:控制器方法实现

紧接着我们在控制器 app/Http/Controllers/API/CafesController.php 中创建上述路由定义中的两个控制器方法:

/**
 * 给咖啡店添加标签
 * @param $request
 * @param $cafeID
 * @return JsonResponse
 */
public function postAddTags(Request $request, $cafeID)
{

}

/**
 * 删除咖啡店上的指定标签
 * @param $cafeID
 * @param $tagID
 * @return Response
 */
public function deleteCafeTag($cafeID, $tagID)
{

}

然后像地理编码一样,我们创建一个 app/Utilities/Tagger.php 类用于处理新增标签逻辑,编写 Tagger 类代码如下:

<?php

namespace App\Utilities;

use App\Models\Tag;

class Tagger
{
    public static function tagCafe($cafe, $tags, $userId)
    {
        // 遍历标签数据,分别存储每个标签,并建立其余咖啡店的关联
        foreach ($tags as $tag) {
            $name = trim($tag);
            // 如果标签已经存在则直接获取其实例
            $newCafeTag = Tag::firstOrNew(array('name' => $name));
            $newCafeTag->name = $name;
            $newCafeTag->save();
            // 将标签和咖啡店关联起来
            $cafe->tags()->syncWithoutDetaching([$newCafeTag->id => ['user_id' => $userId]]);
        }
    }
}

我们在 Tagger 类中实现了一个静态方法 tagCafe 方法用于实现标签的插入以及与咖啡店的关联。

然后回到控制器,编写 postAddTags 方法如下:

/**
 * 给咖啡店添加标签
 * @param $request
 * @param $cafeID
 * @return JsonResponse
 */
public function postAddTags(Request $request, $cafeID)
{
    // 从请求中获取标签信息
    $tags = $request->input('tags');
    $cafe = Cafe::find($cafeID);

    // 处理新增标签并建立标签与咖啡店之间的关联
    Tagger::tagCafe($cafe, $tags, Auth::user()->id);

    // 返回标签
    $cafe = Cafe::where('id', '=', $cafeID)
        ->with('brewMethods')
        ->with('userLike')
        ->with('tags')
        ->first();

    return response()->json($cafe, 201);
}

至于 deleteCafeTag 方法,我们直接删除中间表中的关联记录即可:

public function deleteCafeTag($cafeID, $tagID)
{
    DB::table('cafes_users_tags')->where('cafe_id', $cafeID)->where('tag_id', $tagID)->where('user_id', Auth::user()->id)->delete();
    return response(null, 204);
}

需要注意的是,上面两个控制器方法都涉及到用户ID,所以这两个方法都需要登录后才能访问,不过由于路由定义在了应用 auth:api 中间件的路由群组中,所以后面实现环节可以忽略这一点。另外,如果你直接拷贝上述代码的话,不要忘了为相关类补足命名空间引用。

这样,我们就已经完成了登录用户为指定咖啡店添加标签和删除标签的后端 API 接口,接下来,为了优化用户体验,我们在前端输入标签时,往往会提供自动提示功能,类似这种:

下面我们就来为输入标签自动提示提供后端 API。

第七步:标签自动完成路由

在路由文件 routes/api.php 中新增一个路由:

/*
|-------------------------------------------------------------------------------
| 搜索标签(自动提示/补全)
|-------------------------------------------------------------------------------
| 请求URL:            /api/v1/tags
| 控制器:     API\TagsController@getTags
| 请求方式:         GET
| 功能描述:   根据输入词提供标签补全功能
*/
Route::get('/tags', 'API\TagsController@getTags');

然后在 app/Http/Controllers/API 目录下创建一个新的控制器 TagsController

php artisan make:controller API/TagsController

编写 TagsController 控制器类代码如下,我们编写了一个 getTags 方法用于实现标签的模糊搜索,如果没有提供搜索词,则返回所有标签(如果量大的话,可选择性提供数量最多的若干个标签):

<?php

namespace App\Http\Controllers\API;

use App\Models\Tag;
use Illuminate\Http\Request;
use App\Http\Controllers\Controller;

class TagsController extends Controller
{
    public function getTags()
    {
        $query = Request::get('search');

        if ($query == null || $query == '') {
            $tags = Tag::all();
        } else {
            $tags = Tag::where('name', 'LIKE', $query . '%')->get();
        }

        return response()->json($tags);
    }
}

这样,当用户在前端输入标签时,我们就可以通过相应的 JavaScript 事件处理函数将输入字符传递到后端 API 进行查询,如果有结果的话,就可以以下拉列表的方式将查询结果展示给用户进行选择了。

第八步:更新新增咖啡店处理方法

最后,我们更新 app/Http/Controllers/API/CafesController.php 中的新增咖啡店方法 postNewCafe,因为在下一篇教程中我们将会在新增咖啡店页面里输入并提交标签数据:

// 冲泡方法
$brewMethods = $locations[0]['methodsAvailable'];
// 标签信息
$tags = $locations[0]['tags'];
// 保存与此咖啡店关联的所有冲泡方法(保存关联关系)
$parentCafe->brewMethods()->sync($brewMethods);
// 绑定咖啡店与标签
Tagger::tagCafe($parentCafe, $tags, $request->user()->id);

以上是总店的处理代码,同理,每个分店也有自己的标签数据,在冲泡方法之后添加标签数据处理代码:

$cafe->brewMethods()->sync($locations[$i]['methodsAvailable']);
Tagger::tagCafe($cafe, $locations[$i]['tags'], $request->user()->id);

至此,所有后端 API 接口都已经创建/更新好了,下一篇教程中,我们将实现前端标签输入组件及在咖啡店详情页显示标签功能。

学院君 has written 1043 articles

Laravel学院院长,终身学习者

积分:125957 等级:P12 职业:手艺人 城市:杭州

9 条回复

  1. Mr_White_DT Mr_White_DT says:
    @ 学院君

    cafe模型的tags方法可以理解,tag模型的cafes方法为啥第四个参数是user_id,还不是很理解。。。

    $cafe = Cafe::where('id', '=', $cafeID)->with('tags')->first是获取当前咖啡店的所有标签,是不是就是你说的咖啡店维度?那用户维度怎么理解呢?

  2. Mr_White_DT Mr_White_DT says:

    存储标签、咖啡店、用户三者关联关系的中间表 cafes_users_tags这张表怎么理解呢,标签和咖啡店之间是多对多关联这个可以理解,但是为什么要用到用户呢?

  3. Mr_White_DT Mr_White_DT says:

    学院君,在创建标签表的时候你用了name字段表示标签的名称,在model里也是name,但是你在标签控制器查找标签的时候用了tag,你github中建表用的是tag,模型是name,控制器是tag。。。

登录后才能进行评论,立即登录?