基于.NetCore开发博客项目 StarBlog - (22) 开发博客文章相关接口

基于,netcore,开发,博客,项目,starblog,文章,相关,接口 · 浏览次数 : 386

小编点评

## 生成内容指南 **1. 概述** * 介绍StarBlog项目以及其功能 * 强调本系列文章的写作目标和内容 * 概述文章结构和页面设计 **2. 核心功能** * **Markdown渲染** * 使用.NetCore开发博客项目 StarBlog - (17) 实现本地Typora文章打包上传 * 支持Markdown渲染方案探索 * **图片处理** * 实现图片批量导入 * 支持图片瀑布流 * **分类与标签** * 使用分类层级结构展示 * 实现标签搜索功能 * **数据统计** * 建立访问统计页面 * 实现动态页面统计 **3. 页面设计** * **文章列表页面** * 使用 Razor页面动态编译 * 支持分类层级结构展示 * **文章详情页面** * 使用 Razor页面动态编译 * 实现图片显示优化 * 支持文章详情页面的分类与标签搜索 **4. 开发框架** * **.NetCore开发博客项目 StarBlog - (1)** * 使用.NetCore开发博客项目 StarBlog - (1)环境准备和创建项目 * 建立模型设计 * 使用markdown博客批量导入 * **.NetCore开发博客项目 StarBlog - (2)** * 使用.NetCore开发博客项目 StarBlog - (2)环境准备和创建项目 * 建立数据库模型 * **.NetCore开发博客项目 StarBlog - (3)** * 使用.NetCore开发博客项目 StarBlog - (3)模型设计 * 建立markdown博客数据统计页面 * **.NetCore开发博客项目 StarBlog - (4)** * 使用.NetCore开发博客项目 StarBlog - (4)模型设计 * 建立Markdown博客页面显示优化页面 * **.NetCore开发博客项目 StarBlog - (5)** * 使用.NetCore开发博客项目 StarBlog - (5)模型设计 * 建立RESTFul API接口 * **.NetCore开发博客项目 StarBlog - (6)** * 使用.NetCore开发博客项目 StarBlog - (6)页面开发之博客文章列表 * 建立页面详情页面 * **.NetCore开发博客项目 StarBlog - (7)** * 使用.NetCore开发博客项目 StarBlog - (7)页面开发之文章详情页面 * 建立分类层级结构展示页面 * **.NetCore开发博客项目 StarBlog - (8)** * 使用.NetCore开发博客项目 StarBlog - (8)模型设计 * 建立数据库访问统计页面 * **.NetCore开发博客项目 StarBlog - (9)** * 使用.NetCore开发博客项目 StarBlog - (9)模型设计 * 建立图片批量导入页面 * **.NetCore开发博客项目 StarBlog - (10)** * 使用.NetCore开发博客项目 StarBlog - (10)模型设计 * 建立图片瀑布流页面 * **.NetCore开发博客项目 StarBlog - (11)** * 使用.NetCore开发博客项目 StarBlog - (11)模型设计 * 建立页面访问统计页面 * **.NetCore开发博客项目 StarBlog - (12)** * 使用.NetCore开发博客项目 StarBlog - (12) Razor页面动态编译 * 实现主题切换功能 * **.NetCore开发博客项目 StarBlog - (13)** * 使用.NetCore开发博客项目 StarBlog - (13) 加入友情链接功能 * **.NetCore开发博客项目 StarBlog - (14)** * 使用.NetCore开发博客项目 StarBlog - (14) 实现主题切换功能 * **.NetCore开发博客项目 StarBlog - (15)** * 使用.NetCore开发博客项目 StarBlog - (15) 生成随机尺寸图片 * **.NetCore开发博客项目 StarBlog - (16)** * 使用.NetCore开发博客项目 StarBlog - (16) 一些新功能 (监控/统计/配置/初始化) * **.NetCore开发博客项目 StarBlog - (17)** * 使用.NetCore开发博客项目 StarBlog - (17) 自动下载文章里的外部图片 * **.NetCore开发博客项目 StarBlog - (18)** * 使用.NetCore开发博客项目 StarBlog - (18) 实现本地Typora文章打包上传 * **.NetCore开发博客项目 StarBlog - (19)** * 使用.NetCore开发博客项目 StarBlog - (19) Markdown渲染方案探索 * **.NetCore开发博客项目 StarBlog - (20)** * 使用.NetCore开发博客项目 StarBlog - (20) 图片显示优化 * **.NetCore开发博客项目 StarBlog - (21)** * 使用.NetCore开发博客项目 StarBlog - (21) 开始开发RESTFul接口

正文

前言

本文介绍博客文章相关接口的开发,作为接口开发介绍的第一篇,会写得比较详细,以抛砖引玉,后面的其他接口就粗略带过了,着重于WebApi开发的周边设施。

涉及到的接口:文章CRUD、置顶文章、推荐文章等。

开始前先介绍下AspNetCore框架的基础概念,MVC模式(前后端不分离)、WebApi模式(前后端分离),都是有Controller的。

区别在前者的Controller集成自 Controller 类,后者继承自 ControllerBase 类。

无论博客前台,还是接口,大部分逻辑都是通用的,因此我把这些逻辑封装在 service 中,以减少冗余代码。

文章CRUD

在之前的文章里,已经实现了文章列表、文章详情的功能,等于是CRUD里的 R (Retrieve) “查”功能已经实现。

相关代码在 StarBlog.Web/Services/PostService.cs 文件中。

PS:根据RESTFul规范,CRUD不同的操作对应不同的HTTP方法

在AspNetCore中,可以通过在 Action 上加上 [HttpPost][HttpDelete("{id}")] 这样的特性来标记接口使用的HTTP方法和URL。

现在需要实现“增删改”的功能。

增和改 (Create/Update)

因为这俩功能差不多,所以放在一起实现,很多ORM也是把 InsertUpdate 合在一起,即 InsertOrUpdate

DTO

在计算机编程中,数据传输对象 (data transfer object,DTO)是在2个进程中携带数据的对象。因为进程间通信通常用于远程接口(如web服务)的昂贵操作。成本的主体是客户和服务器之间的来回通信时间。为降低这种调用次数,使用DTO聚合本来需要多次通信传输的数据。

DAO与业务对象或数据访问对象的区别是:DTO的数据的变异子与访问子(mutator和accessor)、语法分析(parser)、序列化(serializer)时不会有任何存储、获取、序列化和反序列化的异常。即DTO是简单对象,不含任何业务逻辑,但可包含序列化和反序列化以用于传输数据。

by Wikipedia

添加文章只需要 Post 模型的其中几个属性就行,不适合把整个 Post 模型作为参数,所以,首先要定义一个DTO作为添加文章的参数。

文件路径 StarBlog.Web/ViewModels/Blog/PostCreationDto.cs

public class PostCreationDto {
    /// <summary>
    /// 标题
    /// </summary>
    public string? Title { get; set; }

    /// <summary>
    /// 梗概
    /// </summary>
    public string? Summary { get; set; }

    /// <summary>
    /// 内容(markdown格式)
    /// </summary>
    public string? Content { get; set; }
    
    /// <summary>
    /// 分类ID
    /// </summary>
    public int CategoryId { get; set; }
}

AutoMapper

有了DTO作为参数,在保存文章的时候,我们需要手动把DTO对象里面的属性,一个个赋值到 Post 对象上,像这样:

var post = new Post {
    Id = Guid.NewGuid(),
	Title = dto.Title,
    Summary = dto.Summary,
    Content = dto.Content,
    CategoryId = dto.CategoryId
};

一个俩个还好,接口多了的话,大量重复的代码会很烦人,而且也容易出错。

还好我们可以用AutoMapper组件来实现对象自动映射。

通过nuget安装 AutoMapper.Extensions.Microsoft.DependencyInjection 这个包

注册服务:

builder.Services.AddAutoMapper(typeof(Program));

然后再创建对应的Profile(配置),如果没有特殊配置其实也可以不添加这个配置文件,执行默认的映射行为即可。

作为例子,本文简单介绍一下,创建 StarBlog.Web/Properties/AutoMapper/PostProfile.cs 文件

public class PostProfile : Profile {
    public PostProfile() {
        CreateMap<PostUpdateDto, Post>();
        CreateMap<PostCreationDto, Post>();
    }
}

在构造方法里执行 CreateMap 配置从左到右的映射关系。

上面的代码配置了从 PostUpdateDto / PostCreationDto 这两个对象到 Post 对象的映射关系。

如果有些字段不要映射的,可以这样写:

public class PostProfile : Profile {
    private readonly List<string> _unmapped = new List<string> {
        "Categories",
    };
    public PostProfile() {
        CreateMap<PostUpdateDto, Post>();
        CreateMap<PostCreationDto, Post>();
        ShouldMapProperty = property => !_unmapped.Contains(property.Name);
    }
}

其他代码不变,修改 _unmapped 这个字段就行。

接着在 Controller 里注入 IMapper 对象

private readonly IMapper _mapper;

使用方法很简单

var post = _mapper.Map<Post>(dto);

传入一个 PostCreationDto 类型的 dto,可以得到 Post 对象。

Controller

先上Controller的代码

[Authorize]
[ApiController]
[Route("Api/[controller]")]
[ApiExplorerSettings(GroupName = "blog")]
public class BlogPostController : ControllerBase {
    private readonly IMapper _mapper;
    private readonly PostService _postService;
    private readonly BlogService _blogService;
    
    public BlogPostController(PostService postService, BlogService blogService, IMapper mapper) {
        _postService = postService;
        _blogService = blogService;
        _mapper = mapper;
    }
}

加在Controller上面的四个特性,挨个介绍

  • Authorize 表示这个controller下面的所有接口需要登录才能访问
  • ApiController 表示这是个WebApi Controller
  • Route 指定了这个Controller的路由模板,即下面的接口全是以 Api/BlogPostController 开头
  • ApiExplorerSettings 接口分组,在swagger文档里看会更清晰

接下来,添加和修改是俩接口,分开说。

添加

很容易,直接上代码了

[HttpPost]
public async Task<ApiResponse<Post>> Add(PostCreationDto dto, [FromServices] CategoryService categoryService) {
    // 使用 AutoMapper,前面介绍过的
    var post = _mapper.Map<Post>(dto);
    // 获取文章分类,如果不存在就返回报错信息
    var category = categoryService.GetById(dto.CategoryId);
    if (category == null) return ApiResponse.BadRequest($"分类 {dto.CategoryId} 不存在!");

    // 生成文章的ID、创建、更新时间
    post.Id = GuidUtils.GuidTo16String();
    post.CreationTime = DateTime.Now;
    post.LastUpdateTime = DateTime.Now;
    // 设置文章状态为已发布
    post.IsPublish = true;

    // 获取分类的层级结构
    post.Categories = categoryService.GetCategoryBreadcrumb(category);

    return new ApiResponse<Post>(await _postService.InsertOrUpdateAsync(post));
}

就是这个 Add 方法

目前 CategoryService 只需要在这个添加的接口里用到,所以不用整个Controller注入,在 Add 方法里使用 [FromServices] 特性注入。

后面有个获取分类的层级结构,因为StarBlog的设计是支持多级分类,为了在前台展示文章分类层级的时候减少运算量,所以我把文章的分类层级结构(形式是分类ID用逗号分隔开,如:1,3,5,7,9)直接存入数据库,空间换时间。

最后,执行 PostService 里的 InsertOrUpdateAsync 方法,解析处理文章内容,并将文章存入数据库。

PS:本项目的接口返回值已经做统一包装处理,可以看到大量使用 ApiResponse 作为返回值,这个后续文章会介绍。

修改

噢,还有 修改文章(Update) 的接口,修改使用 PUT 方法

[HttpPut("{id}")]
public async Task<ApiResponse<Post>> Update(string id, PostUpdateDto dto) {
    // 先获取文章对象
    var post = _postService.GetById(id);
    if (post == null) return ApiResponse.NotFound($"博客 {id} 不存在");

	// 在已有对象的基础上进行映射
    post = _mapper.Map(dto, post);
    // 更新修改时间
    post.LastUpdateTime = DateTime.Now;
    
    return new ApiResponse<Post>(await _postService.InsertOrUpdateAsync(post));
}

依然很简单,里面注释写得很清楚了

AutoMapper可以对已有对象的基础上进行映射

  • mapper.Map(source) 得到一个全新的对象
  • mapper.Map(source, dest) 在 dest 对象的基础上修改

搞定。

Service

作为一个多层架构项目,核心逻辑依然放在 Service 里

并且这里是添加和修改二合一,优雅~

public async Task<Post> InsertOrUpdateAsync(Post post) {
    var postId = post.Id;
    // 是新文章的话,先保存到数据库
    if (await _postRepo.Where(a => a.Id == postId).CountAsync() == 0) {
        post = await _postRepo.InsertAsync(post);
    }

    // 检查文章中的外部图片,下载并进行替换
    // todo 将外部图片下载放到异步任务中执行,以免保存文章的时候太慢
    post.Content = await MdExternalUrlDownloadAsync(post);
    // 修改文章时,将markdown中的图片地址替换成相对路径再保存
    post.Content = MdImageLinkConvert(post, false);

    // 处理完内容再更新一次
    await _postRepo.UpdateAsync(post);
    return post;
}

另外,这部分代码在之前的markdown渲染和自动下载外部图片的相关文章里已经介绍过了,本文不再重复。详情可以看本系列的第17篇文章。

删 (Delete)

没什么好说的,直接上代码

StarBlog.Web/Services/PostService.cs

public int Delete(string id) {
    return _postRepo.Delete(a => a.Id == id);
}

StarBlog.Web/Apis/Blog/BlogPostController.cs

[HttpDelete("{id}")]
public ApiResponse Delete(string id) {
    var post = _postService.GetById(id);
    if (post == null) return ApiResponse.NotFound($"博客 {id} 不存在");
    var rows = _postService.Delete(id);
    return ApiResponse.Ok($"删除了 {rows} 篇博客");
}

查 (Retrieve)

查,分成两种,一种是列表,一种是单个。

单个

先说单个的,比较容易。

StarBlog.Web/Services/PostService.cs

public Post? GetById(string id) {
    // 获取文章的时候对markdown中的图片地址解析,加上完整地址返回给前端
    var post = _postRepo.Where(a => a.Id == id).Include(a => a.Category).First();
    if (post != null) post.Content = MdImageLinkConvert(post, true);

    return post;
}

StarBlog.Web/Apis/Blog/BlogPostController.cs

[AllowAnonymous]
[HttpGet("{id}")]
public ApiResponse<Post> Get(string id) {
    var post = _postService.GetById(id);
    return post == null ? ApiResponse.NotFound() : new ApiResponse<Post>(post);
}

这里接口加了个 [AllowAnonymous],表示这接口不用登录也能访问。

列表

最简单的就是直接返回全部文章列表。

[HttpGet]
public List<Post> GetAll() {
    return _postService.GetAll();
}

完整功能比较复杂,需要过滤筛选、排序、分页等功能,这些功能在之前第6篇已经介绍过了。详见:基于.NetCore开发博客项目 StarBlog - (6) 页面开发之博客文章列表

文章的相关操作

单纯的CRUD是无法满足功能需求的

所以要在RESTFul接口的接触上,配合一些RPC风格接口,实现我们需要的功能。

设置推荐文章

有一个模型专门管理推荐文章,名为 FeaturedPost

要设置推荐文章,直接往里面添加数据就行了。反之,取消就是删除对应的记录。

上代码

StarBlog.Web/Services/PostService.cs

public FeaturedPost AddFeaturedPost(Post post) {
    var item = _fPostRepo.Where(a => a.PostId == post.Id).First();
    if (item != null) return item;
    item = new FeaturedPost {PostId = post.Id};
    _fPostRepo.Insert(item);
    return item;
}

StarBlog.Web/Apis/Blog/BlogPostController.cs

[HttpPost("{id}/[action]")]
public ApiResponse<FeaturedPost> SetFeatured(string id) {
    var post = _postService.GetById(id);
    return post == null
        ? ApiResponse.NotFound()
        : new ApiResponse<FeaturedPost>(_blogService.AddFeaturedPost(post));
}

配置完URL就是:Api/BlogPost/{id}/SetFeatured

取消推荐文章

上面那个推荐的逆向操作

service这样写

public int DeleteFeaturedPost(Post post) {
    var item = _fPostRepo.Where(a => a.PostId == post.Id).First();
    return item == null ? 0 : _fPostRepo.Delete(item);
}

controller酱子

[HttpPost("{id}/[action]")]
public ApiResponse CancelFeatured(string id) {
    var post = _postService.GetById(id);
    if (post == null) return ApiResponse.NotFound($"博客 {id} 不存在");
    var rows = _blogService.DeleteFeaturedPost(post);
    return ApiResponse.Ok($"delete {rows} rows.");
}

设置置顶

StarBlog设计为只允许一篇置顶文章

设置新的置顶文章,会把原有的顶掉

service代码

/// <returns>返回 <see cref="TopPost"/> 对象和删除原有置顶博客的行数</returns>
public (TopPost, int) SetTopPost(Post post) {
    var rows = _topPostRepo.Select.ToDelete().ExecuteAffrows();
    var item = new TopPost {PostId = post.Id};
    _topPostRepo.Insert(item);
    return (item, rows);
}

先删除已有置顶文章,再添加新的进去。返回值用了元组语法。

controller代码

[HttpPost("{id}/[action]")]
public ApiResponse<TopPost> SetTop(string id) {
    var post = _postService.GetById(id);
    if (post == null) return ApiResponse.NotFound($"博客 {id} 不存在");
    var (data, rows) = _blogService.SetTopPost(post);
    return new ApiResponse<TopPost> {Data = data, Message = $"ok. deleted {rows} old topPosts."};
}

就这样,简简单单。

上传图片

场景:在后台编辑文章,会插入一些图片。

这个接口因为要上传文件,所以使用FormData接收参数,前端发起请求需要注意。

这是controller代码:

[HttpPost("{id}/[action]")]
public ApiResponse UploadImage(string id, IFormFile file) {
    var post = _postService.GetById(id);
    if (post == null) return ApiResponse.NotFound($"博客 {id} 不存在");
    var imgUrl = _postService.UploadImage(post, file);
    return ApiResponse.Ok(new {
        imgUrl,
        imgName = Path.GetFileNameWithoutExtension(imgUrl)
    });
}

后面的 PostService.UploadImage() 方法,本文(囿于篇幅关系)先不介绍了,留个坑,放在后面图片管理接口里一起介绍哈~

博客的相关操作

刚才基本是在对文章做CRUD,别忘了还有个 BlogController 呢~😏

功能就是获取推荐、获取置顶、博客文章总览、打包上传之类的。

这里也大概介绍一下。

获取推荐、置顶的service代码:

public List<Post> GetFeaturedPosts() {
    return _fPostRepo.Select.Include(a => a.Post.Category)
        .ToList(a => a.Post);
}
public Post? GetTopOnePost() {
    return _topPostRepo.Select.Include(a => a.Post.Category).First()?.Post;
}

controller太简单,就不写了。

总览信息

这里没封装到service里,感觉其他地方不会用到,拒绝过度封装。

直接从ORM读取,文章、分类、图片、推荐等的数量。

PS:要做展示大屏的话,这些应该还是不够的,后续再增加(flag立下了)

public BlogOverview Overview() {
    return new BlogOverview {
        PostsCount = _postRepo.Select.Count(),
        CategoriesCount = _categoryRepo.Select.Count(),
        PhotosCount = _photoRepo.Select.Count(),
        FeaturedPostsCount = _fPostRepo.Select.Count(),
        FeaturedCategoriesCount = _fCategoryRepo.Select.Count(),
        FeaturedPhotosCount = _fPhotoRepo.Select.Count()
    };
}

打包上传

这个功能是:把本地写完的markdown文件连同图片等资源一起打包zip上传,StarBlog解析markdown并将图片附件处理后存入数据库,实现很方便的本地写文章,博客发表功能。

具体实现已经在之前的文章里介绍过了,这里就不重复啦,详情可以查看本系列的第18篇文章。基于.NetCore开发博客项目 StarBlog - (18) 实现本地Typora文章打包上传

小结

AspNetCore WebApi的开发有很多东西可以写的,在开发过程中我也在不断学习,有很多好玩的新功能、骚操作是在后面才加入StarBlog项目的,但为了保证本系列文章阅读的连贯性,即使某功能在文章撰写时已经实现,也可能不会加入介绍。这些我会在后面单独写一篇文章来介绍(绝不是在水哦),以提升读者的阅读体验。

还有,作为新手向教程,我会尽量写得比较详细(废话比较多),导致篇幅较长,但但仍无法面面俱到介绍AspNetCore的全部细节,建议边看边学的读者搭配AspNetCore官方文档或教材阅读~

系列文章

与基于.NetCore开发博客项目 StarBlog - (22) 开发博客文章相关接口相似的内容:

基于.NetCore开发博客项目 StarBlog - (22) 开发博客文章相关接口

## 前言 本文介绍博客文章相关接口的开发,作为接口开发介绍的第一篇,会写得比较详细,以抛砖引玉,后面的其他接口就粗略带过了,着重于WebApi开发的周边设施。 涉及到的接口:文章CRUD、置顶文章、推荐文章等。 开始前先介绍下AspNetCore框架的基础概念,MVC模式(前后端不分离)、WebA

基于.NetCore开发博客项目 StarBlog - (19) Markdown渲染方案探索

## 前言 笔者认为,一个博客网站,最核心的是阅读体验。 在开发StarBlog的过程中,最耗时的恰恰也是文章的展示部分功能。 最开始还没研究出来如何很好的使用后端渲染,所以只能先用Editor.md组件做前端渲染,过渡一下。前端渲染我是不满意的,因为性能较差,页面加载出来还会闪一下,有割裂感,影响

基于.NetCore开发博客项目 StarBlog - (20) 图片显示优化

## 前言 我的服务器带宽比较高,博客部署在上面访问的时候几乎没感觉有加载延迟,就没做图片这块的优化,不过最近有小伙伴说博客的图片加载比较慢,那就来把图片优化完善一下吧~ 目前有两个地方需要完善 - 图片瀑布流 - 图片缩略图 ## 图片瀑布流 关于瀑布流之前的文章有介绍: [基于.NetCore开

基于.NetCore开发博客项目 StarBlog - (21) 开始开发RESTFul接口

## 前言 最近电脑坏了,开源项目的进度也受到一些影响 这篇酝酿很久了,作为本系列第二部分(API接口开发)的第一篇,得想一个好的开头,想着想着就鸽了好久,索性不扯那么多了,直接开写吧~ ## 关于RESTFul 网上很多相关的文章都要把RESTFul历史来龙去脉给复制一遍,所以我这就不重复了,现在

基于.NetCore开发博客项目 StarBlog - (23) 文章列表接口分页、过滤、搜索、排序

## 前言 上一篇留的坑,火速补上。 在之前的第6篇中,已经有初步介绍,本文做一些补充,已经搞定这部分的同学可以快速跳过,[基于.NetCore开发博客项目 StarBlog - (6) 页面开发之博客文章列表](https://www.cnblogs.com/deali/p/16286780.ht

基于.NetCore开发博客项目 StarBlog - (24) 统一接口数据返回格式

## 前言 开发接口,是给客户端(Web前端、App)用的,前面说的RESTFul,是接口的规范,有了统一的接口风格,客户端开发人员在访问后端功能的时候能更快找到需要的接口,能写出可维护性更高的代码。 而接口的数据返回格式也是接口规范的重要一环,不然一个接口返回JSON,一个返回纯字符串,客户端对接

基于.NetCore开发博客项目 StarBlog - (25) 图片接口与文件上传

## 前言 上传文件的接口设计有两种风格,一种是整个项目只设置一个接口用来上传,然后其他需要用到文件的地方,都只存一个引用ID;另一种是每个需要文件的地方单独管理各自的文件。这俩各有优劣吧,本项目中选择的是后者的风格,文章图片和照片模块又要能CRUD又要批量导入,还是各自管理文件比较好。 ## 图片

基于.NetCore开发博客项目 StarBlog - (26) 集成Swagger接口文档

## 前言 这是StarBlog系列在2023年的第一篇更新😃~ 在之前的文章里,我们已经完成了部分接口的开发,接下来需要使用 curl、Postman 这类工具对这些接口进行测试,但接口一多,每次测试都要一个个填入地址和对应参数会比较麻烦… 我们需要一种直观的方式来汇总项目里的所有接口,并且如果

基于.NetCore开发博客项目 StarBlog - (27) 使用JWT保护接口

## 前言 这是StarBlog系列在2023年的第二篇更新😂 这几个月都在忙,更新变得很不勤快,但是拖着不更新我的心里更慌,很久没写,要开头就变得很难😑 说回正题,之前的文章里,我们已经把博客关键的接口都开发完成了,但还少了一个最关键的「认证授权」,少了这东西,网站就跟筛子一样,谁都可以来添加

基于.NetCore开发博客项目 StarBlog - (28) 开发友情链接相关接口

## 前言 之前介绍的友情链接功能,只实现了友情链接的展示和管理接口。 还缺失友情链接申请、审核管理、通知,现在把这块功能补全。 Model 什么的之前那篇文章都有,本文直接补全逻辑代码~ 详见: [基于.NetCore开发博客项目 StarBlog - (13) 加入友情链接功能](https:/