功能
- 提供供听力前台界面的数据接口、增删改查
- 有两个应用层项目:后台应用层Listening.Admin.WebAPI(增删改查的微服务)、前台应用层Listening.Main.WebAPI(普通用户访问的界面提供数据的微服务)。共享同一个领域层、基础设施层
领域层
设计思路
查询:仓储接口里面,是和业务结合紧密的代码,这里全部是满足各种业务需求的查询
更新、隐藏、显示、删除:更新各个子字段的方法、数据的隐藏显示方法、删除方法(软删除)写在了实体类中,控制器的更新、数据隐藏显示、删除方法里面直接调用实体类中的更新方法
新增、排序:领域服务里面,是新增数据、排序数据,这种通用逻辑,和业务关系不大的
实体类
实体类(充血模型)
分类(Category)、专辑(Album)、音频(Episode)3个实体。由于我们可以直接访问某个专辑或者某个音频,因此我们这里把这3个实体放到3个聚合之中。
Album和Category属于两个聚合,因此它们之间只能通过聚合根的标识符引用,因此我们只为Album类定义了代表Category主键的CategoryId属性,而没有定义Category类型的主键。Album类,通过CategoryId属性引用Category实体Id(不是实体对象)。同理,Episode类,通过CategoryId属性引用Album实体Id
分类>专辑>音频(包含关系)
注意,设计为三个独立的聚合,因此它们之间只能通过聚合根的标识符引用,而不是通过对象引用。只在子添加父聚合的Id即可!配置类、DbContext不需要为此进行单独配置
Category、album、Episode都是聚合根,而不像订单是聚合根、而订单明细是子实体一样。因为订单和订单明细是整体和部分的关系,一起出现,不会把订单明细单独访问,但是Episode是可以单独访问的,而且Episode还可以移动到其他album。因此不能在这三个实体之间引用。具体讨论见:
DDD实践问题之 - 关于论坛的帖子回复统计信息的更新的思考-阿里云开发者社
public record Album : AggregateRootEntity, IAggregateRoot
{
private Album() { }
/// <summary>
/// 用户是否可见(完善后才显示,或者已经显示了,但是发现内部有问题,就先隐藏,调整了再发布)
/// </summary>
public bool IsVisible { get; private set; }
/// <summary>
/// 标题
/// </summary>
public MultilingualString Name { get; private set; }
/// <summary>
/// 列表中的显示序号
/// </summary>
public int SequenceNumber { get; private set; }
public Guid CategoryId { get; private set; }
public static Album Create(Guid id, int sequenceNumber, MultilingualString name, Guid categoryId)
{
Album album = new Album();
album.Id = id;
album.SequenceNumber = sequenceNumber;
album.Name = name;
album.CategoryId = categoryId;
album.IsVisible = false;//Album新建以后默认不可见,需要手动Show
return album;
}
public Album ChangeSequenceNumber(int value)
{
this.SequenceNumber = value;
return this;
}
public Album ChangeName(MultilingualString value)
{
this.Name = value;
return this;
}
public Album Hide()
{
this.IsVisible = false;
return this;
}
public Album Show()
{
this.IsVisible = true;
return this;
}
}
public record Category : AggregateRootEntity, IAggregateRoot
{
private Category() { }
/// <summary>
/// 在所有Category中的显示序号,越小越靠前
/// </summary>
public int SequenceNumber { get; private set; }
public MultilingualString Name { get; private set; }
/// <summary>
/// 封面图片。现在一般都不会直接把图片保存到数据库中(Blob),而是只是保存图片的路径。
/// </summary>
public Uri CoverUrl { get; private set; }
public static Category Create(Guid id, int sequenceNumber, MultilingualString name, Uri coverUrl)
{
Category category = new();
category.Id = id;
category.SequenceNumber = sequenceNumber;
category.Name = name;
category.CoverUrl = coverUrl;
//category.AddDomainEvent(new CategoryCreatedEventArgs { NewObj = category });
return category;
}
public Category ChangeSequenceNumber(int value)
{
this.SequenceNumber = value;
return this;
}
public Category ChangeName(MultilingualString value)
{
this.Name = value;
return this;
}
public Category ChangeCoverUrl(Uri value)
{
//todo: 做项目的时候,不管这个事件是否有被用到,都尽量publish。
this.CoverUrl = value;
return this;
}
}
音频Episode。指的一个音频片段,不是一句话
EF Core中的实体和DDD的中的实体不一样。DDD中的实体是Data Object,是和数据库表以及字段一一对应的,EF Core中的实体更像领域模型,DO是藏在EF Core框架中的。
网站上线后,又提出来一个需求“非m4a文件上传后先转码再发布”,如果用面向数据库的开发,就要在Episode表搞一个字段“表示”是否已发布,在发布之前,AudioUrl等属性都是无效的。
按照DDD的思想,就额外拆分出一个“待发布Episode”,转换完成后,再把“待发布Episode”的数据导入Episode,这样就不用对Episode实体做改变。
我们应该定义一个Sentence[]类型的属性,然后再编写EF Core的ValueConverter来完成数据库中的字幕文本和Sentence[]之间的转换。不过这样设计的话,就会导致我们每次从数据库中读取Episode的时候,都会执行字幕文本转换的代码,这会降低程序的性能。因此我们做了一个折衷的设计,Episode中直接定义字符串类型的Subtitle属性用来保存字幕原文。我们再定义一个ParseSubtitle方法来完成字幕文本到Sentence[]的转换,当程序需要转换字幕原文时,我们再调用ParseSubtitle方法。
注意,ParseSubtitle即解析字幕到值对象的方法仍然抽离出去,在实体类中只是调用
Builder嵌套类型:简化创建对象的赋值,发出对象被创建的事件
public record Episode : AggregateRootEntity, IAggregateRoot
{
private Episode() { }
public int SequenceNumber { get; private set; }//序号
public MultilingualString Name { get; private set; }//标题
public Guid AlbumId { get; private set; }//专辑Id,因为Episode和Album都是聚合根,因此不能直接做对象引用。
public Uri AudioUrl { get; private set; }//音频路径
/// <summary>
///这是音频的实际长度(秒)
///因为IE、旧版Edge、部分手机内置浏览器(小米等)中对于部分音频,
///计算的duration以及currentTime和实际的不一致,因此需要根据服务器端
///计算出来的实际长度,在客户端做按比例校正
///所以服务器端需要储存这个,以便给到浏览器
/// </summary>
public double DurationInSecond { get; private set; }//音频时长(秒数)
//因为启用了<Nullable>enable</Nullable>,所以string是不可空,Migration会默认这个,string?是可空
public string Subtitle { get; private set; }//原文字幕内容
public string SubtitleType { get; private set; }//原文字幕格式
/// <summary>
/// 用户是否可见(如果发现内部有问题,就先隐藏)
/// </summary>
public bool IsVisible { get; private set; }
/*
public static Episode Create(Guid id, int sequenceNumber, MultilingualString name, Guid albumId, Uri audioUrl,
double durationInSecond, string subtitleType, string subtitle)
{
var parser = SubtitleParserFactory.GetParser(subtitleType);
if (parser == null)
{
throw new ArgumentOutOfRangeException(nameof(subtitleType), $"subtitleType={subtitleType} is not supported.");
}
//新建的时候默认可见
Episode episode = new Episode()
{
Id = id,
AlbumId = albumId,
DurationInSecond = durationInSecond,
AudioUrl = audioUrl,
Name = name,
SequenceNumber = sequenceNumber,
Subtitle = subtitle,
SubtitleType = subtitleType,
IsVisible = true
};
episode.AddDomainEvent(new EpisodeCreatedEvent(episode));
return episode;
}*/
public Episode ChangeSequenceNumber(int value)
{
this.SequenceNumber = value;
this.AddDomainEventIfAbsent(new EpisodeUpdatedEvent(this));
return this;
}
public Episode ChangeName(MultilingualString value)
{
this.Name = value;
this.AddDomainEventIfAbsent(new EpisodeUpdatedEvent(this));
return this;
}
public Episode ChangeSubtitle(string subtitleType, string subtitle)
{
var parser = SubtitleParserFactory.GetParser(subtitleType);
if (parser == null)
{
throw new ArgumentOutOfRangeException(nameof(subtitleType), $"subtitleType={subtitleType} is not supported.");
}
this.SubtitleType = subtitleType;
this.Subtitle = subtitle;
this.AddDomainEventIfAbsent(new EpisodeUpdatedEvent(this));
return this;
}
public Episode Hide()
{
this.IsVisible = false;
this.AddDomainEventIfAbsent(new EpisodeUpdatedEvent(this));
return this;
}
public Episode Show()
{
this.IsVisible = true;
this.AddDomainEventIfAbsent(new EpisodeUpdatedEvent(this));
return this;
}
public override void SoftDelete()
{
base.SoftDelete();
this.AddDomainEvent(new EpisodeDeletedEvent(this.Id));
}
public IEnumerable<Sentence> ParseSubtitle()
{
var parser = SubtitleParserFactory.GetParser(this.SubtitleType);
return parser.Parse(this.Subtitle);
}
public class Builder
{
private Guid id;
private int sequenceNumber;
private MultilingualString name;
private Guid albumId;
private Uri audioUrl;
private double durationInSecond;
private string subtitle;
private string subtitleType;
public Builder Id(Guid value)
{
this.id = value;
return this;
}
public Builder SequenceNumber(int value)
{
this.sequenceNumber = value;
return this;
}
public Builder Name(MultilingualString value)
{
this.name = value;
return this;
}
public Builder AlbumId(Guid value)
{
this.albumId = value;
return this;
}
public Builder AudioUrl(Uri value)
{
this.audioUrl = value;
return this;
}
public Builder DurationInSecond(double value)
{
this.durationInSecond = value;
return this;
}
public Builder Subtitle(string value)
{
this.subtitle = value;
return this;
}
public Builder SubtitleType(string value)
{
this.subtitleType = value;
return this;
}
public Episode Build()
{
if (id == Guid.Empty)
{
throw new ArgumentOutOfRangeException(nameof(id));
}
if (name == null)
{
throw new ArgumentNullException(nameof(name));
}
if (albumId == Guid.Empty)
{
throw new ArgumentOutOfRangeException(nameof(albumId));
}
if (audioUrl == null)
{
throw new ArgumentNullException(nameof(audioUrl));
}
if (durationInSecond <= 0)
{
throw new ArgumentOutOfRangeException(nameof(durationInSecond));
}
if (subtitle == null)
{
throw new ArgumentNullException(nameof(subtitle));
}
if (subtitleType == null)
{
throw new ArgumentNullException(nameof(subtitleType));
}
Episode e = new Episode();
e.Id = id;
e.SequenceNumber = sequenceNumber;
e.Name = name;
e.AlbumId = albumId;
e.AudioUrl = audioUrl;
e.DurationInSecond = durationInSecond;
e.Subtitle = subtitle;
e.SubtitleType = subtitleType;
e.IsVisible = true;
e.AddDomainEvent(new EpisodeCreatedEvent(e));
return e;
}
}
}
我们可以把Episode的所有属性都设置为可读可写的,然后把Episode的无参构造方法设置为公开的,不过这样就会导致对象有处于非法状态的可能性。这里采用Builder模式编写了专门用来构建Episode对象的Builder类,Builder类是定义在Episode类内部的类,因此Builder类可以为Episode类的私有属性赋值。
var builder = new Episode.Builder();
builder.Id(id).SequenceNumber(maxSeq + 1).Name(name).AlbumId(albumId)
.AudioUrl(audioUrl).DurationInSecond(durationInSecond)
.SubtitleType(subtitleType).Subtitle(subtitle);
Episode episode = builder.Build();
值对象
为了表示字幕文件中每一句话的起止时间和原文内容,我们定义了一个值对象Sentence。
public record Sentence(TimeSpan StartTime, TimeSpan EndTime, string Value);
防腐层
虽然这个是在实体类中调用的,但是只注入接口即可。所以更规范的做法是在基础设施层写实现。
用于解析给定的字幕文件,将subtitle字幕解析为IEnumerable
为了能够适应不同格式的字幕文件的解析,我们定义了字幕解析器接口ISubtitleParser
interface ISubtitleParser
{
/// <summary>
/// 本解析器是否能够解析typeName这个类型的字幕
/// </summary>
/// <param name="typeName"></param>
/// <returns></returns>
bool Accept(string typeName);
/// <summary>
/// 解析这个字幕subtitle
/// </summary>
/// <param name="subtitle"></param>
/// <returns></returns>
IEnumerable<Sentence> Parse(string subtitle);
}
json解析器:json格式
class JsonParser : ISubtitleParser
{
public bool Accept(string typeName)
{
return typeName.Equals("json", StringComparison.OrdinalIgnoreCase);
}
public IEnumerable<Sentence> Parse(string subtitle)
{
return JsonSerializer.Deserialize<IEnumerable<Sentence>>(subtitle);
}
}
lrc解析器:parser for *.lrc files。lrc格式的开源库Opportunity.LrcParser.Lyrics解析
class LrcParser : ISubtitleParser
{
public bool Accept(string typeName)
{
return typeName.Equals("lrc", StringComparison.OrdinalIgnoreCase);
}
public IEnumerable<Sentence> Parse(string subtitle)
{
var lyrics = Lyrics.Parse(subtitle);
if (lyrics.Exceptions.Count > 0)
{
throw new ApplicationException("lrc解析失败");
}
lyrics.Lyrics.PreApplyOffset();//应用上[offset:500]这样的偏移
return FromLrc(lyrics.Lyrics);
}
private static Sentence[] FromLrc(Lyrics<Line> lyrics)
{
var lines = lyrics.Lines;
Sentence[] sentences = new Sentence[lines.Count];
for (int i = 0; i < lines.Count - 1; i++)
{
var line = lines[i];
var nextLine = lines[i + 1];
Sentence sentence = new Sentence(line.Timestamp.TimeOfDay, nextLine.Timestamp.TimeOfDay, line.Content);
sentences[i] = sentence;
}
//last line
var lastLine = lines.Last();
TimeSpan lastLineStartTime = lastLine.Timestamp.TimeOfDay;
//lrc没有结束时间,就极端假定最后一句耗时1分钟
TimeSpan lastLineEndTime = lastLineStartTime.Add(TimeSpan.FromMinutes(1));
var lastSentence = new Sentence(lastLineStartTime, lastLineEndTime, lastLine.Content);
sentences[sentences.Count() - 1] = lastSentence;
return sentences;
}
}
srt解析器:parser for *.srt files and *.vtt files。SubtitlesParser开源库解析
class SrtParser : ISubtitleParser
{
public bool Accept(string typeName)
{
return typeName.Equals("srt", StringComparison.OrdinalIgnoreCase)
|| typeName.Equals("vtt", StringComparison.OrdinalIgnoreCase);
}
public IEnumerable<Sentence> Parse(string subtitle)
{
var srtParser = new SubtitlesParser.Classes.Parsers.SubParser();
using (MemoryStream ms = new MemoryStream(Encoding.UTF8.GetBytes(subtitle)))
{
var items = srtParser.ParseStream(ms);
return items.Select(s => new Sentence(TimeSpan.FromMilliseconds(s.StartTime),
TimeSpan.FromMilliseconds(s.EndTime), String.Join(" ", s.Lines)));
}
}
}
用于创建解析器的工厂类
SubtitleParserFactory是用来根据字幕的格式名称来获取解析器类的工厂类,我们调用这个类的GetParser方法就可以获得指定格式的ISubtitleParser接口实现类。
static class SubtitleParserFactory
{
private static List<ISubtitleParser> parsers = new();
static SubtitleParserFactory()
{
//通过反射扫描本程序集中的所有实现了ISubtitleParser接口的类,创建一个包含所有解析器的列表
var parserTypes = typeof(SubtitleParserFactory).Assembly.GetTypes().Where(t => typeof(ISubtitleParser).IsAssignableFrom(t) && !t.IsAbstract);
//创建这些对象,添加到parsers
foreach (var parserType in parserTypes)
{
ISubtitleParser parser = (ISubtitleParser)Activator.CreateInstance(parserType);
parsers.Add(parser);
}
}
//返回一个可以解析这种格式的解析器
public static ISubtitleParser? GetParser(string typeName)
{
//遍历所有解析器,挨个问他们“能解析这个格式吗”,碰到一个能解析的,就会把解析器返回
foreach (var parser in parsers)
{
if (parser.Accept(typeName))
{
return parser;
}
}
return null;
}
}
上端调用
//使用时
var parser=SubtitleParserFactory.GetParser("lrc");
parser.Parse(...);
领域事件
| 事件名称 | 说明 |
|---|---|
| EpisodeCreatedEvent | 对象创建 |
| EpisodeDeletedEvent | 对象删除 |
| EpisodeUpdatedEvent | 对象修改 |
领域事件
public record EpisodeCreatedEvent(Episode Value) : INotification;
public record EpisodeDeletedEvent(Guid Id) : INotification;
public record EpisodeUpdatedEvent(Episode Value) : INotification;
仓储接口
public interface IListeningRepository
{
public Task<Category?> GetCategoryByIdAsync(Guid categoryId);
public Task<Category[]> GetCategoriesAsync();
public Task<int> GetMaxSeqOfCategoriesAsync();//获取最大序号
public Task<Album?> GetAlbumByIdAsync(Guid albumId);
public Task<int> GetMaxSeqOfAlbumsAsync(Guid categoryId);
public Task<Album[]> GetAlbumsByCategoryIdAsync(Guid categoryId);
public Task<Episode?> GetEpisodeByIdAsync(Guid episodeId);
public Task<int> GetMaxSeqOfEpisodesAsync(Guid albumId);
public Task<Episode[]> GetEpisodesByAlbumIdAsync(Guid albumId);
}
领域服务
领域服务里面是添加一条数据、对数据排序。只改变对象,并没有保存到数据库
public class ListeningDomainService
{
private readonly IListeningRepository repository;
public ListeningDomainService(IListeningRepository repository)
{
this.repository = repository;
}
public async Task<Album> AddAlbumAsync(Guid categoryId, MultilingualString name)
{
int maxSeq = await repository.GetMaxSeqOfAlbumsAsync(categoryId);
var id = Guid.NewGuid();
return Album.Create(id, maxSeq + 1, name, categoryId);
}
public async Task SortAlbumsAsync(Guid categoryId, Guid[] sortedAlbumIds)
{
var albums = await repository.GetAlbumsByCategoryIdAsync(categoryId);
var idsInDB = albums.Select(a => a.Id);
if (!idsInDB.SequenceIgnoredEqual(sortedAlbumIds))
{
throw new Exception($"提交的待排序Id中必须是categoryId={categoryId}分类下所有的Id");
}
int seqNum = 1;
//一个in语句一次性取出来更快,不过在非性能关键节点,业务语言比性能更重要
foreach (Guid albumId in sortedAlbumIds)
{
var album = await repository.GetAlbumByIdAsync(albumId);
if (album == null)
{
throw new Exception($"albumId={albumId}不存在");
}
album.ChangeSequenceNumber(seqNum);//顺序改序号
seqNum++;
}
}
public async Task<Category> AddCategoryAsync(MultilingualString name, Uri coverUrl)
{
int maxSeq = await repository.GetMaxSeqOfCategoriesAsync();
var id = Guid.NewGuid();
return Category.Create(id, maxSeq + 1, name, coverUrl);
}
public async Task SortCategoriesAsync(Guid[] sortedCategoryIds)
{
var categories = await repository.GetCategoriesAsync();
var idsInDB = categories.Select(a => a.Id);
if (!idsInDB.SequenceIgnoredEqual(sortedCategoryIds))
{
throw new Exception("提交的待排序Id中必须是所有的分类Id");
}
int seqNum = 1;
//一个in语句一次性取出来更快,不过在非性能关键节点,业务语言比性能更重要
foreach (Guid catId in sortedCategoryIds)
{
var cat = await repository.GetCategoryByIdAsync(catId);
if (cat == null)
{
throw new Exception($"categoryId={catId}不存在");
}
cat.ChangeSequenceNumber(seqNum);//顺序改序号
seqNum++;
}
}
public async Task<Episode> AddEpisodeAsync(MultilingualString name,
Guid albumId, Uri audioUrl, double durationInSecond,
string subtitleType, string subtitle)
{
int maxSeq = await repository.GetMaxSeqOfEpisodesAsync(albumId);
var id = Guid.NewGuid();
/*
Episode episode = Episode.Create(id, maxSeq + 1, name, albumId,
audioUrl,durationInSecond, subtitleType, subtitle);*/
var builder = new Episode.Builder();
builder.Id(id).SequenceNumber(maxSeq + 1).Name(name).AlbumId(albumId)
.AudioUrl(audioUrl).DurationInSecond(durationInSecond)
.SubtitleType(subtitleType).Subtitle(subtitle);
return builder.Build();
}
public async Task SortEpisodesAsync(Guid albumId, Guid[] sortedEpisodeIds)
{
var episodes = await repository.GetEpisodesByAlbumIdAsync(albumId);
var idsInDB = episodes.Select(a => a.Id);
if (!sortedEpisodeIds.SequenceIgnoredEqual(idsInDB))
{
throw new Exception($"提交的待排序Id中必须是albumId={albumId}专辑下所有的Id");
}
int seqNum = 1;
foreach (Guid episodeId in sortedEpisodeIds)
{
var episode = await repository.GetEpisodeByIdAsync(episodeId);
if (episode == null)
{
throw new Exception($"episodeId={episodeId}不存在");
}
episode.ChangeSequenceNumber(seqNum);//顺序改序号
seqNum++;
}
}
}
模块自注册
class ModuleInitializer : IModuleInitializer
{
public void Initialize(IServiceCollection services)
{
services.AddScoped<ListeningDomainService>();
}
}
领域层详细
1、Category实体
分类Category
| 属性名 | 类型 | 功能 | 说明 |
|---|---|---|---|
| SequenceNumber | 序号 | 可以通过修改序号来改变显示顺序,越小越靠前 | |
| Name | MultilingualString值对象 | 标题可以是中英文 | |
| CoverUrl | 封面图片 | 现在一般都不会直接把图片以Blob保存到数据库中,而是只是保存图片的路径 |
| 方法名 | 功能 | 说明 |
|---|---|---|
| Create | 静态方法:创建一个Category实体 | |
| ChangeSequenceNumber | 修改序号 | |
| ChangeName | 修改名字 | |
| ChangeCoverUrl | 修改封面 | 做项目时,不管这个事件是否被用到,尽量发出领域事件 |
2、Album实体
专辑Album
| 属性名 | 类型 | 功能 | 说明 |
|---|---|---|---|
| Name | MultilingualString | ||
| IsVisible | 用户是否可见 | 完善后才显示,或者已经显示了,但是发现内部有问题,就先隐藏,调整了再发布 | |
| SequenceNumber | 列表中的显示序号 | ||
| CategoryId | Guid | 聚合之间,通过聚合根来引用 |
| 方法名 | 功能 | 说明 |
|---|---|---|
| Hide、Show | 隐藏、显示 | 数据有问题时,可以暂时隐藏(上架、下架用) |
| Create | ||
| ChangeSequenceNumber | ||
| ChangeName |
3、Episode实体
| 属性名 | 类型 | 功能 | 说明 |
|---|---|---|---|
| SequenceNumber | 序号 | 音频,这个先后顺序很重要 | |
| Name | 标题 | ||
| AlbumId | 专辑Id | ||
| AudioUrl | Uri | 音频的路径 | |
| DurationInSecond | double | 音频时长 | 前端需要,用作冗余,省去每次拿到音频再计算 |
| Subtitle | string | 原文字幕内容 | 直接是src、lrt字幕文件的原文文本,这样存是基于性能优化的考虑 |
| SubtitleType | string | 原文字幕格式 | src、lrt |
| IsVisible | 用户是否可见 | 如果发现内部有问题,就先隐藏 |
Sentence值对象
| 属性名 | 类型 | 说明 |
|---|---|---|
| StartTime、EndTime | TimeSpan | |
| Value | string |
方法
| 方法名 | 功能 | 返回类型 | 说明 |
|---|---|---|---|
| ParseSubtitle | 将字幕文件解析为句子对象 | IEnumerable | |
| ChangeSequenceNumber | 修改序号 | ||
| ChangeSubtitle | 修改字幕文件内容 | ||
| ChangeName | 修改标题 | ||
| Hide、Show | 上架、下架 | ||
| SoftDelete | 软删除 |
仓储层IListeningRepository
| 方法名 | 说明 | 补充 |
|---|---|---|
| GetCategoryByIdAsync | 获得指定Id的类别 | |
| GetCategoriesAsync | 获得所有类别 | |
| GetMaxSeqOfCategoriesAsync | 获取最大序号 | |
| GetAlbumByIdAsync | 获得指定Id的专辑 | |
| GetMaxSeqOfAlbumsAsync | 获得指定categoryId下专辑的最大序号 | 因为新增一个Album序号要递增 |
| GetAlbumsByCategoryIdAsync | 获取CategoryId下的所有专辑 | |
| GetEpisodeByIdAsync | 根据episodeId获得音频 | |
| GetMaxSeqOfEpisodesAsync | 获得albumId下音频的最大序号 | |
| GetEpisodesByAlbumIdAsync | 获得albumId下的所有音频 |
领域服务ListeningDomainService
| 方法名 | 说明 | 参数 | 补充 |
|---|---|---|---|
| AddAlbumAsync | 在指定categoryId下增加一个Album专辑 | ||
| SortAlbumsAsync | 把categoryId下的所有Album专辑排序 | (Guid categoryId, Guid[] sortedAlbumIds) | 改变的是序号属性 |
| AddCategoryAsync | 创建一个Category类别 | ||
| SortCategoriesAsync | 把所有Category类别排序 | (Guid[] sortedCategoryIds) | 改变的是序号属性 |
| AddEpisodeAsync | 在指定albumId专辑Id下添加一张音频 | ||
| SortEpisodesAsync | 把指定albumId专辑Id所有Episode音频排序 | (Guid albumId, Guid[] sortedEpisodeIds) | 改变的是序号属性 |
基础设施层
配置类
Guid主键不要建聚集索引
class AlbumConfig : IEntityTypeConfiguration<Album>
{
public void Configure(EntityTypeBuilder<Album> builder)
{
builder.ToTable("T_Albums");
builder.HasKey(e => e.Id).IsClustered(false);//对于Guid主键,不要建聚集索引,否则插入性能很差
builder.OwnsOneMultilingualString(e => e.Name);//OwnsOneMultilingualString封装的方法
builder.HasIndex(e => new { e.CategoryId, e.IsDeleted });//为Id、软删除属性建立复合索引
}
}
class CategoryConfig : IEntityTypeConfiguration<Category>
{
public void Configure(EntityTypeBuilder<Category> builder)
{
builder.ToTable("T_Categories");
builder.HasKey(e => e.Id).IsClustered(false);
builder.OwnsOneMultilingualString(e => e.Name);
builder.Property(e => e.CoverUrl).IsRequired(false).HasMaxLength(500).IsUnicode();
}
}
class EpisodeConfig : IEntityTypeConfiguration<Episode>
{
public void Configure(EntityTypeBuilder<Episode> builder)
{
builder.ToTable("T_Episodes");
builder.HasKey(e => e.Id).IsClustered(false);//Guid类型不要聚集索引,否则会影响性能
builder.HasIndex(e => new { e.AlbumId, e.IsDeleted });//索引不要忘了加上IsDeleted,否则会影响性能
builder.OwnsOneMultilingualString(e => e.Name);
//尽量用标准的、Provider无关的这些FluentAPI去配置,不要和数据库耦合
//如果真的需要在IEntityTypeConfiguration中判断数据库类型
//那么就定义一个接口提供DbContext属性,仿照ApplyConfigurationsFromAssembly写一个给IEntityTypeConfiguration
//实现类注入DbContext,然后Dbcontext.Database.IsSqlServer();
builder.Property(e => e.AudioUrl).HasMaxLength(1000).IsUnicode().IsRequired();
builder.Property(e => e.Subtitle).HasMaxLength(int.MaxValue).IsUnicode().IsRequired();
builder.Property(e => e.SubtitleType).HasMaxLength(10).IsUnicode(false).IsRequired();
}
}
仓储接口实现
GetMaxSeqOfCategoriesAsync:获取最大Category编号,考虑到最开始数据库中没有数据时的情况,对linq做调整
int? maxSeq = await dbCtx.Query<Category>().MaxAsync(c => (int?)c.SequenceNumber);
return maxSeq ?? 0;
完整代码
public class ListeningRepository : IListeningRepository
{
private readonly ListeningDbContext dbCtx;
public ListeningRepository(ListeningDbContext dbCtx)
{
this.dbCtx = dbCtx;
}
public async Task<Category?> GetCategoryByIdAsync(Guid categoryId)
{
var album = await dbCtx.FindAsync<Category>(categoryId);
return album;
}
public Task<Category[]> GetCategoriesAsync()
{
return dbCtx.Categories.OrderBy(e => e.SequenceNumber).ToArrayAsync();
}
public async Task<Album?> GetAlbumByIdAsync(Guid albumId)
{
var album = await dbCtx.FindAsync<Album>(albumId);
return album;
}
public async Task<int> GetMaxSeqOfAlbumsAsync(Guid categoryId)
{
//MaxAsync(c => (int?)c.SequenceNumber) 这样可以处理一条数据都没有的问题
int? maxSeq = await dbCtx.Query<Album>().Where(c => c.CategoryId == categoryId)
.MaxAsync(c => (int?)c.SequenceNumber);
return maxSeq ?? 0;
}
public Task<Album[]> GetAlbumsByCategoryIdAsync(Guid categoryId)
{
return dbCtx.Albums.OrderBy(e => e.SequenceNumber).Where(a => a.CategoryId == categoryId).ToArrayAsync();
}
public async Task<int> GetMaxSeqOfCategoriesAsync()
{
int? maxSeq = await dbCtx.Query<Category>().MaxAsync(c => (int?)c.SequenceNumber);
return maxSeq ?? 0;
}
public async Task<Episode?> GetEpisodeByIdAsync(Guid episodeId)
{
return await dbCtx.Episodes.SingleOrDefaultAsync(e => e.Id == episodeId);
}
public async Task<int> GetMaxSeqOfEpisodesAsync(Guid albumId)
{
int? maxSeq = await dbCtx.Query<Episode>().Where(e => e.AlbumId == albumId)
.MaxAsync(e => (int?)e.SequenceNumber);
return maxSeq ?? 0;
}
public Task<Episode[]> GetEpisodesByAlbumIdAsync(Guid albumId)
{
return dbCtx.Episodes.OrderBy(e => e.SequenceNumber).Where(a => a.AlbumId == albumId).ToArrayAsync();
}
}
DbContext
public class ListeningDbContext : BaseDbContext
{
public DbSet<Category> Categories { get; private set; }//不要忘了写set,否则拿到的DbContext的Categories为null
public DbSet<Album> Albums { get; private set; }
public DbSet<Episode> Episodes { get; private set; }
public ListeningDbContext(DbContextOptions<ListeningDbContext> options, IMediator mediator) : base(options, mediator)
{
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.ApplyConfigurationsFromAssembly(this.GetType().Assembly);
modelBuilder.EnableSoftDeletionGlobalFilter();
}
}
模块自注册
class ModuleInitializer : IModuleInitializer
{
public void Initialize(IServiceCollection services)
{
services.AddScoped<IListeningRepository, ListeningRepository>();
}
}
前台应用层
1、前台应用层项目主要为前端页面提供分类、专辑、音频的数据。
2、因为前台页面被访问的频率很高,而且对于任何用户来讲,它们请求相同的听力资源获得的响应都是一样的,为了降低数据库的压力,我们在把从数据库中读取的数据放入了缓存。内存缓存更适合这里。
3、EpisodeController类的两个操作方法的返回值都是EpisodeVM类型,而非仓储返回的Episode类型。理由:
1)Episode类中有一些不希望被暴露给客户端的属性,比如Subtitle(听力字幕的文本)、CreationTime(创建时间)等。
2)我们希望把听力的字幕文本在服务器端解析,而不是在客户端再去解析。
3)在加载音频的列表的时候,不需要加载听力的字幕,以避免增加网络流量的消耗,而在加载某一个音频的时候才需要加载听力的字幕。
返回类ViewModel
ViewModels:AlbumVM、CategoryVM、EpisodeVM等,只给前端展示实体的部分属性
public record AlbumVM(Guid Id, MultilingualString Name, Guid CategoryId)
{
public static AlbumVM? Create(Album? a)
{
if (a == null)
{
return null;
}
return new AlbumVM(a.Id, a.Name, a.CategoryId);
}
public static AlbumVM[] Create(Album[] items)
{
return items.Select(e => Create(e)!).ToArray();
}
}
public record CategoryVM(Guid Id, MultilingualString Name, Uri CoverUrl)
{
public static CategoryVM? Create(Category? e)
{
if (e == null)
{
return null;
}
return new CategoryVM(e.Id, e.Name, e.CoverUrl);
}
public static CategoryVM[] Create(Category[] items)
{
return items.Select(e => Create(e)!).ToArray();
}
}
public record EpisodeVM(Guid Id, MultilingualString Name, Guid AlbumId, Uri AudioUrl, double DurationInSecond, IEnumerable<SentenceVM>? Sentences)
{
public static EpisodeVM? Create(Episode? e, bool loadSubtitle)
{
if (e == null)
{
return null;
}
List<SentenceVM> sentenceVMs = new();
if (loadSubtitle)
{
var sentences = e.ParseSubtitle();
foreach (Sentence s in sentences)
{
SentenceVM vm = new SentenceVM(s.StartTime.TotalSeconds, s.EndTime.TotalSeconds, s.Value);
sentenceVMs.Add(vm);
}
}
return new EpisodeVM(e.Id, e.Name, e.AlbumId, e.AudioUrl, e.DurationInSecond, sentenceVMs);
}
public static EpisodeVM[] Create(Episode[] items, bool loadSubtitle)
{
return items.Select(e => Create(e, loadSubtitle)!).ToArray();
}
}
值对象也来一个viewmodel,为了简化前端把TimeSpan格式字符串转换为毫秒数的麻烦,在服务器端直接把TimeSpan转换为double
public record SentenceVM(double StartTime, double EndTime, string Value);
控制器
专辑
[Route("[controller]/[action]")]
[ApiController]
public class AlbumController : ControllerBase
{
private readonly IListeningRepository repository;
private readonly IMemoryCacheHelper cacheHelper;
public AlbumController(IListeningRepository repository, IMemoryCacheHelper cacheHelper)
{
this.repository = repository;
this.cacheHelper = cacheHelper;
}
[HttpGet]
[Route("{id}")]
public async Task<ActionResult<AlbumVM>> FindById([RequiredGuid] Guid id)
{
var album = await cacheHelper.GetOrCreateAsync($"AlbumController.FindById.{id}",
async (e) => AlbumVM.Create(await repository.GetAlbumByIdAsync(id)));
if (album == null)
{
return NotFound();
}
return album;
}
[HttpGet]
[Route("{categoryId}")]
public async Task<ActionResult<AlbumVM[]>> FindByCategoryId([RequiredGuid] Guid categoryId)
{
//写到单独的local函数的好处是避免回调中代码太复杂
Task<Album[]> FindDataAsync()
{
return repository.GetAlbumsByCategoryIdAsync(categoryId);
}
var task = cacheHelper.GetOrCreateAsync($"AlbumController.FindByCategoryId.{categoryId}",
async (e) => AlbumVM.Create(await FindDataAsync()));
return await task;
}
}
分类
[Route("[controller]/[action]")]
[ApiController]
//供后台用的增删改查接口不用缓存
public class CategoryController : ControllerBase
{
//尽管这里的FindAll、FindById和Admin.WebAPI中类似,但是不应该复用。因为Admin.WebAPI中的操作没有缓存,
//而Main.WebAPI中的则由缓存
private readonly IListeningRepository repository;
private readonly IMemoryCacheHelper cacheHelper;
public CategoryController(IListeningRepository repository, IMemoryCacheHelper cacheHelper)
{
this.repository = repository;
this.cacheHelper = cacheHelper;
}
[HttpGet]
public async Task<ActionResult<CategoryVM[]>> FindAll()
{
Task<Category[]> FindData()
{
return repository.GetCategoriesAsync();
}
//用AOP来进行缓存控制看起来更优美(可以用国产的AspectCore或者Castle DynamicProxy),但是这样反而不灵活,因为缓存对于灵活性要求更高,所以用这种直接用ICacheHelper的不优美的方式更实用。
var task = cacheHelper.GetOrCreateAsync($"CategoryController.FindAll",
async (e) => CategoryVM.Create(await FindData()));
return await task;
}
[HttpGet]
[Route("{id}")]
public async Task<ActionResult<CategoryVM?>> FindById([RequiredGuid] Guid id)
{
var cat = await cacheHelper.GetOrCreateAsync($"CategoryController.FindById.{id}",
async (e) => CategoryVM.Create(await repository.GetCategoryByIdAsync(id)));
//返回ValueTask的需要await的一下
if (cat == null)
{
return NotFound($"没有Id={id}的Category");
}
else
{
return cat;
}
}
}
音频
[Route("[controller]/[action]")]
[ApiController]
public class EpisodeController : ControllerBase
{
private readonly IListeningRepository repository;
private readonly IMemoryCacheHelper cacheHelper;
public EpisodeController(IMemoryCacheHelper cacheHelper, IListeningRepository repository)
{
this.cacheHelper = cacheHelper;
this.repository = repository;
}
[HttpGet]
[Route("{id}")]
public async Task<ActionResult<EpisodeVM>> FindById([RequiredGuid] Guid id)
{
var episode = await cacheHelper.GetOrCreateAsync($"EpisodeController.FindById.{id}",
async (e) => EpisodeVM.Create(await repository.GetEpisodeByIdAsync(id), true));
if (episode == null)
{
return NotFound($"没有Id={id}的Episode");
}
return episode;
}
[HttpGet]
[Route("{albumId}")]
public async Task<ActionResult<EpisodeVM[]>> FindByAlbumId([RequiredGuid] Guid albumId)
{
Task<Episode[]> FindData()
{
return repository.GetEpisodesByAlbumIdAsync(albumId);
}
//加载Episode列表的,默认不加载Subtitle,这样降低流量大小
var task = cacheHelper.GetOrCreateAsync($"EpisodeController.FindByAlbumId.{albumId}",
async (e) => EpisodeVM.Create(await FindData(), false));
return await task;
}
}
服务与管道模型
Program.cs
UseDeveloperExceptionPage?这个中间件是干嘛的
var builder = WebApplication.CreateBuilder(args);
builder.ConfigureDbConfiguration();
builder.ConfigureExtraServices(new InitializerOptions
{
LogFilePath = "e:/temp/Listening.Main.log",
EventBusQueueName = "Listening.Main"
});
// Add services to the container.
//builder.Services.AddScoped<IListeningRepository, ListeningRepository>();
builder.Services.AddControllers();
builder.Services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1", new() { Title = "Listening.Main.WebAPI", Version = "v1" });
});
var app = builder.Build();
// Configure the HTTP request pipeline.
if (builder.Environment.IsDevelopment())
{
app.UseDeveloperExceptionPage();
app.UseSwagger();
app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "Listening.Main.WebAPI v1"));
}
app.UseZackDefault();
app.MapControllers();
app.Run();
VM详细
CategoryVM的设计:record类型
| 属性 | 类型 | 说明 |
|---|---|---|
| Id | ||
| Name | ||
| CoverUrl | Uri | 封面图片地址 |
| 方法名 | 说明 | 返回类型 | 参数 | 补充 |
|---|---|---|---|---|
| Create | 将Category实体转换为VM对象 | CategoryVM? | (Category? e) | 静态方法 |
| Create | 将Category[ ]实体数组转换为VM对象数组 | CategoryVM[] | (Category[] items) | 静态方法 |
AlbumVM的设计:record类型
| 属性名 | 类型 | 说明 |
|---|---|---|
| Id | Guid | 专辑Id |
| Name | MultilingualString | |
| CategoryId | Guid | 类别Id |
| 方法名 | 说明 | 返回类型 | 参数 | 补充 |
|---|---|---|---|---|
| Create | 创建一个专辑的VM对象 | AlbumVM? | (Album? a) | 静态方法,VM对象用于返回给前端 |
| Create | 创建一组专辑的VM对象数组 | AlbumVM[] | (Album[] items) | 静态方法 |
EpisodeVM
| 属性 | 类型 | 说明 |
|---|---|---|
| Id | ||
| Name | ||
| AlbumId | ||
| AudioUrl | Uri | 音频路径 |
| DurationInSecond | double | 音频时长 |
| Sentences | IEnumerable? | 音频对应的句子 |
| 方法 | 说明 | 参数 | 返回类型 | 补充 |
|---|---|---|---|---|
| Create | (Episode? e, bool loadSubtitle) | EpisodeVM? | 静态,loadSubtitle为true,则返回的EpisodeVM中的Sentences属性包含内容,否则为空 | |
| Create | (Episode[] items, bool loadSubtitle) | EpisodeVM[] | 静态, |
SentenseVM的设计
| 属性 | 类型 | 说明 |
|---|---|---|
| StartTime、EndTime | double | |
| Value | string |
控制器详细
AlbumController
| 方法名 | 说明 | 参数 | 返回类型 | 补充 | |
|---|---|---|---|---|---|
| FindByCategoryId | 通过类别Id查找专辑 | ([RequiredGuid] Guid categoryId) | Task<ActionResult<AlbumVM[]>> | 通过内存缓存提高加载数据的性能 | |
| FindById | 通过专辑Id查找专辑 | ([RequiredGuid] Guid id) | Task<ActionResult> |
CategoryController
说明:尽管这里的FindAll、FindById和Admin.WebAPI中类似,但是不应该复用。因为Admin.WebAPI中的操作没有缓存,而Main.WebAPI中的则有缓存
| 方法名 | 说明 | 参数 | 返回类型 |
|---|---|---|---|
| FindAll | 查找所有分类 | () | Task<ActionResult<CategoryVM[]>> |
| FindById | 通过分类Id查找分类 | ([RequiredGuid] Guid id) | Task<ActionResult<CategoryVM?>> |
EpisodeController
| 方法名 | 说明 | 参数 | 返回类型 |
|---|---|---|---|
| FindById | 通过音频Id查找音频 | ([RequiredGuid] Guid id) | Task<ActionResult> |
| FindByAlbumId | 通过专辑Id查找音频 | ([RequiredGuid] Guid albumId) | Task<ActionResult<EpisodeVM[]>> |
管理后台应用层
实体
请求及校验类
专辑
public record AlbumAddRequest(MultilingualString Name, Guid CategoryId);
//把校验规则写到单独的文件,也是DDD的一种原则
public class AlbumAddRequestValidator : AbstractValidator<AlbumAddRequest>
{
public AlbumAddRequestValidator(ListeningDbContext dbCtx)
{
RuleFor(x => x.Name).NotEmpty();
RuleFor(x => x.Name.Chinese).NotNull().Length(1, 200);
RuleFor(x => x.Name.English).NotNull().Length(1, 200);
///验证CategoryId是否存在
RuleFor(x => x.CategoryId).MustAsync((cId, ct) => dbCtx.Query<Category>().AnyAsync(c => c.Id == cId))
.WithMessage(c => $"CategoryId={c.CategoryId}不存在");
}
}
public class AlbumsSortRequest
{
public Guid[] SortedAlbumIds { get; set; }
}
public class AlbumsSortRequestValidator : AbstractValidator<AlbumsSortRequest>
{
public AlbumsSortRequestValidator()
{
RuleFor(r => r.SortedAlbumIds).NotNull().NotEmpty().NotContains(Guid.Empty)
.NotDuplicated();
}
}
public record AlbumUpdateRequest(MultilingualString Name);
public class AlbumUpdateRequestValidator : AbstractValidator<AlbumUpdateRequest>
{
public AlbumUpdateRequestValidator()
{
RuleFor(x => x.Name).NotEmpty();
RuleFor(x => x.Name.Chinese).NotNull().Length(1, 200);
RuleFor(x => x.Name.English).NotNull().Length(1, 200);
}
}
分类
public class CategoriesSortRequest
{
/// <summary>
/// 排序后的类别Id
/// </summary>
public Guid[] SortedCategoryIds { get; set; }
}
public class CategoriesSortRequestValidator : AbstractValidator<CategoriesSortRequest>
{
public CategoriesSortRequestValidator()
{
RuleFor(r => r.SortedCategoryIds).NotNull().NotEmpty().NotContains(Guid.Empty)
.NotDuplicated();
}
}
//启用了<Nullable>enable</Nullable>,所以string ChineseName就是非可空,会自动校验
public record CategoryAddRequest(MultilingualString Name, Uri CoverUrl);
public class CategoryAddRequestValidator : AbstractValidator<CategoryAddRequest>
{
public CategoryAddRequestValidator()
{
RuleFor(x => x.Name).NotEmpty();
RuleFor(x => x.Name.Chinese).NotNull().Length(1, 200);
RuleFor(x => x.Name.English).NotNull().Length(1, 200);
RuleFor(x => x.CoverUrl).Length(5, 500);//CoverUrl允许为空
}
}
//定义只是偶然和CategoryAddRequest一样,所以不应该复用它
public record CategoryUpdateRequest(MultilingualString Name, Uri CoverUrl);
public class CategoryUpdateRequestValidator : AbstractValidator<CategoryUpdateRequest>
{
public CategoryUpdateRequestValidator()
{
RuleFor(x => x.Name).NotEmpty();
RuleFor(x => x.Name.Chinese).NotNull().Length(1, 200);
RuleFor(x => x.Name.English).NotNull().Length(1, 200);
RuleFor(x => x.CoverUrl).Length(5, 500);
}
}
音频
public record EpisodeAddRequest(MultilingualString Name, Guid AlbumId,
Uri AudioUrl, double DurationInSecond, string Subtitle, string SubtitleType);
public class EpisodeAddRequestValidator : AbstractValidator<EpisodeAddRequest>
{
public EpisodeAddRequestValidator(ListeningDbContext ctx)
{
RuleFor(x => x.Name).NotEmpty();
RuleFor(x => x.Name.Chinese).NotNull().Length(1, 200);
RuleFor(x => x.Name.English).NotNull().Length(1, 200);
///验证CategoryId是否存在
RuleFor(x => x.AlbumId).MustAsync((cId, ct) => ctx.Query<Album>().AnyAsync(c => c.Id == cId))
.WithMessage(c => $"AlbumId={c.AlbumId}不存在");
RuleFor(x => x.AudioUrl).NotEmptyUri().Length(1, 1000);
RuleFor(x => x.DurationInSecond).GreaterThan(0);
RuleFor(x => x.SubtitleType).NotEmpty().Length(1, 10);
RuleFor(x => x.Subtitle).NotEmpty().NotEmpty();
}
}
public class EpisodesSortRequest
{
public Guid[] SortedEpisodeIds { get; set; }
}
public class EpisodesSortRequestValidator : AbstractValidator<EpisodesSortRequest>
{
public EpisodesSortRequestValidator()
{
RuleFor(r => r.SortedEpisodeIds).NotNull().NotEmpty().NotContains(Guid.Empty).NotDuplicated();
}
}
/// <summary>
/// Episode的音频不能修改,否则会让代码复杂很多,主流视频网站也都是这样干的。
/// </summary>
/// <param name="Name"></param>
/// <param name="SubtitleType"></param>
/// <param name="Subtitle"></param>
public record EpisodeUpdateRequest(MultilingualString Name, string SubtitleType, string Subtitle);
public class EpisodeUpdateRequestValidator : AbstractValidator<EpisodeUpdateRequest>
{
private ListeningDbContext ctx;
public EpisodeUpdateRequestValidator(ListeningDbContext ctx)
{
this.ctx = ctx;
RuleFor(x => x.Name).NotEmpty();
RuleFor(x => x.Name.Chinese).NotNull().Length(1, 200);
RuleFor(x => x.Name.English).NotNull().Length(1, 200);
RuleFor(x => x.SubtitleType).NotEmpty().Length(1, 10);
RuleFor(x => x.Subtitle).NotEmpty().NotEmpty(); ;
}
}
响应类
public record EpisodeForFindByAlbumId(Guid Id, int SequenceNumber, MultilingualString Name, Uri AudioUrl, double DurationInSecond, DateTime CreationTime, bool IsVisible, string EncodingStatus);
因为这是后台系统,所以不在乎把 Episode全部内容返回给客户端的问题,以后如果开放给外部系统再定义ViewModel
控制器
CategoryController、AlbumController、EpisodeController分别提供了对分类、专辑、音频进行管理的接口。
管理员端的后台管理没有使用缓存
[Route("[controller]/[action]")]
[ApiController]
[Authorize(Roles = "Admin")]
[UnitOfWork(typeof(ListeningDbContext))]
public class AlbumController : ControllerBase
{
private readonly ListeningDbContext dbCtx;
private IListeningRepository repository;
private readonly ListeningDomainService domainService;
public AlbumController(ListeningDbContext dbCtx, ListeningDomainService domainService, IListeningRepository repository)
{
this.dbCtx = dbCtx;
this.domainService = domainService;
this.repository = repository;
}
[HttpGet]
[Route("{id}")]
public async Task<ActionResult<Album?>> FindById([RequiredGuid] Guid id)
{
var album = await repository.GetAlbumByIdAsync(id);
return album;
}
[HttpGet]
[Route("{categoryId}")]
public Task<Album[]> FindByCategoryId([RequiredGuid] Guid categoryId)
{
return repository.GetAlbumsByCategoryIdAsync(categoryId);
}
[HttpPost]
public async Task<ActionResult<Guid>> Add(AlbumAddRequest req)
{
Album album = await domainService.AddAlbumAsync(req.CategoryId, req.Name);
dbCtx.Add(album);
return album.Id;
}
[HttpPut]
[Route("{id}")]
public async Task<ActionResult> Update([RequiredGuid] Guid id, AlbumUpdateRequest request)
{
var album = await repository.GetAlbumByIdAsync(id);
if (album == null)
{
return NotFound("id没找到");
}
album.ChangeName(request.Name);
return Ok();
}
[HttpDelete]
[Route("{id}")]
public async Task<ActionResult> DeleteById([RequiredGuid] Guid id)
{
var album = await repository.GetAlbumByIdAsync(id);
if (album == null)
{
//这样做仍然是幂等的,因为“调用N次,确保服务器处于与第一次调用相同的状态。”与响应无关
return NotFound($"没有Id={id}的Album");
}
album.SoftDelete();//软删除
return Ok();
}
[HttpPut]
[Route("{id}")]
public async Task<ActionResult> Hide([RequiredGuid] Guid id)
{
var album = await repository.GetAlbumByIdAsync(id);
if (album == null)
{
return NotFound($"没有Id={id}的Album");
}
album.Hide();
return Ok();
}
[HttpPut]
[Route("{id}")]
public async Task<ActionResult> Show([RequiredGuid] Guid id)
{
var album = await repository.GetAlbumByIdAsync(id);
if (album == null)
{
return NotFound($"没有Id={id}的Album");
}
album.Show();
return Ok();
}
[HttpPut]
[Route("{categoryId}")]
public async Task<ActionResult> Sort([RequiredGuid] Guid categoryId, AlbumsSortRequest req)
{
await domainService.SortAlbumsAsync(categoryId, req.SortedAlbumIds);
return Ok();
}
}
[Route("[controller]/[action]")]
[Authorize(Roles = "Admin")]
[ApiController]
[UnitOfWork(typeof(ListeningDbContext))]
//供后台用的增删改查接口不用缓存
public class CategoryController : ControllerBase
{
private IListeningRepository repository;
private readonly ListeningDbContext dbContext;
private readonly ListeningDomainService domainService;
public CategoryController(ListeningDbContext dbContext, ListeningDomainService domainService, IListeningRepository repository)
{
this.dbContext = dbContext;
this.domainService = domainService;
this.repository = repository;
}
[HttpGet]
public Task<Category[]> FindAll()
{
return repository.GetCategoriesAsync();
}
[HttpGet]
[Route("{id}")]
public async Task<ActionResult<Category?>> FindById([RequiredGuid] Guid id)
{
//返回ValueTask的需要await的一下
var cat = await repository.GetCategoryByIdAsync(id);
if (cat == null)
{
return NotFound($"没有Id={id}的Category");
}
else
{
return cat;
}
}
[HttpPost]
public async Task<ActionResult<Guid>> Add(CategoryAddRequest req)
{
var category = await domainService.AddCategoryAsync(req.Name, req.CoverUrl);
dbContext.Add(category);
return category.Id;
}
[HttpPut]
[Route("{id}")]
public async Task<ActionResult> Update([RequiredGuid] Guid id, CategoryUpdateRequest request)
{
var cat = await repository.GetCategoryByIdAsync(id);
if (cat == null)
{
return NotFound("id不存在");
}
cat.ChangeName(request.Name);
cat.ChangeCoverUrl(request.CoverUrl);
return Ok();
}
[HttpDelete]
[Route("{id}")]
public async Task<ActionResult> DeleteById([RequiredGuid] Guid id)
{
var cat = await repository.GetCategoryByIdAsync(id);
if (cat == null)
{
//这样做仍然是幂等的,因为“调用N次,确保服务器处于与第一次调用相同的状态。”与响应无关
return NotFound($"没有Id={id}的Category");
}
cat.SoftDelete();//软删除
return Ok();
}
[HttpPut]
public async Task<ActionResult> Sort(CategoriesSortRequest req)
{
await domainService.SortCategoriesAsync(req.SortedCategoryIds);
return Ok();
}
}
Add:如果是m4a格式文件,直接存入数据库。如果不是m4a格式,发布集成事件通知转码服务器进行转码
[Route("[controller]/[action]")]
[ApiController]
[Authorize(Roles = "Admin")]
[UnitOfWork(typeof(ListeningDbContext))]
public class EpisodeController : ControllerBase
{
private IListeningRepository repository;
private readonly ListeningDbContext dbContext;
private readonly EncodingEpisodeHelper encodingEpisodeHelper;
private readonly IEventBus eventBus;
private readonly ListeningDomainService domainService;
public EpisodeController(ListeningDbContext dbContext,
EncodingEpisodeHelper encodingEpisodeHelper,
IEventBus eventBus, ListeningDomainService domainService, IListeningRepository repository)
{
this.dbContext = dbContext;
this.encodingEpisodeHelper = encodingEpisodeHelper;
this.eventBus = eventBus;
this.domainService = domainService;
this.repository = repository;
}
[HttpPost]
public async Task<ActionResult<Guid>> Add(EpisodeAddRequest req)
{
//如果上传的是m4a,不用转码,直接存到数据库
if (req.AudioUrl.ToString().EndsWith("m4a", StringComparison.OrdinalIgnoreCase))
{
Episode episode = await domainService.AddEpisodeAsync(req.Name, req.AlbumId,
req.AudioUrl, req.DurationInSecond, req.SubtitleType, req.Subtitle);
dbContext.Add(episode);
return episode.Id;
}
else
{
//非m4a文件需要先转码,为了避免非法数据污染业务数据,增加业务逻辑麻烦,按照DDD的原则,不完整的Episode不能插入数据库
//先临时插入Redis,转码完成再插入数据库
Guid episodeId = Guid.NewGuid();
EncodingEpisodeInfo encodingEpisode = new EncodingEpisodeInfo(episodeId, req.Name, req.AlbumId, req.DurationInSecond, req.Subtitle, req.SubtitleType, "Created");
await encodingEpisodeHelper.AddEncodingEpisodeAsync(episodeId, encodingEpisode);
//通知转码
eventBus.Publish("MediaEncoding.Created", new { MediaId = episodeId, MediaUrl = req.AudioUrl, OutputFormat = "m4a", SourceSystem = "Listening" });//启动转码
return episodeId;
}
}
[HttpPut]
[Route("{id}")]
public async Task<ActionResult> Update([RequiredGuid] Guid id, EpisodeUpdateRequest request)
{
var episode = await repository.GetEpisodeByIdAsync(id);
if (episode == null)
{
return NotFound("id没找到");
}
episode.ChangeName(request.Name);
episode.ChangeSubtitle(request.SubtitleType, request.Subtitle);
return Ok();
}
[HttpDelete]
[Route("{id}")]
public async Task<ActionResult> DeleteById([RequiredGuid] Guid id)
{
var album = await repository.GetEpisodeByIdAsync(id);
if (album == null)
{
//这样做仍然是幂等的,因为“调用N次,确保服务器处于与第一次调用相同的状态。”与响应无关
return NotFound($"没有Id={id}的Episode");
}
album.SoftDelete();//软删除
return Ok();
}
[HttpGet]
[Route("{id}")]
public async Task<ActionResult<Episode>> FindById([RequiredGuid] Guid id)
{
//因为这是后台系统,所以不在乎把 Episode全部内容返回给客户端的问题,以后如果开放给外部系统再定义ViewModel
var episode = await repository.GetEpisodeByIdAsync(id);
if (episode == null)
{
return NotFound($"没有Id={id}的Episode");
}
return episode;
}
[HttpGet]
[Route("{albumId}")]
public Task<Episode[]> FindByAlbumId([RequiredGuid] Guid albumId)
{
return repository.GetEpisodesByAlbumIdAsync(albumId);
}
//获取albumId下所有的转码任务
[HttpGet]
[Route("{albumId}")]
public async Task<ActionResult<EncodingEpisodeInfo[]>> FindEncodingEpisodesByAlbumId([RequiredGuid] Guid albumId)
{
List<EncodingEpisodeInfo> list = new List<EncodingEpisodeInfo>();
var episodeIds = await encodingEpisodeHelper.GetEncodingEpisodeIdsAsync(albumId);
foreach (Guid episodeId in episodeIds)
{
var encodingEpisode = await encodingEpisodeHelper.GetEncodingEpisodeAsync(episodeId);
if (!encodingEpisode.Status.EqualsIgnoreCase("Completed"))//不显示已经完成的
{
list.Add(encodingEpisode);
}
}
return list.ToArray();
}
[HttpPut]
[Route("{id}")]
public async Task<ActionResult> Hide([RequiredGuid] Guid id)
{
var episode = await repository.GetEpisodeByIdAsync(id);
if (episode == null)
{
return NotFound($"没有Id={id}的Category");
}
episode.Hide();
return Ok();
}
[HttpPut]
[Route("{id}")]
public async Task<ActionResult> Show([RequiredGuid] Guid id)
{
var episode = await repository.GetEpisodeByIdAsync(id);
if (episode == null)
{
return NotFound($"没有Id={id}的Category");
}
episode.Show();
return Ok();
}
[HttpPut]
[Route("{albumId}")]
public async Task<ActionResult> Sort([RequiredGuid] Guid albumId, EpisodesSortRequest req)
{
await domainService.SortEpisodesAsync(albumId, req.SortedEpisodeIds);
return Ok();
}
}
事件推送
1、为了能够让前端页面随时看到转码状态的变化,我们通过SignalR来把转码状态推送给前端。转码服务会在转码开始、转码失败、转码完成等事件出现的时候,发布名字分别为MediaEncoding.Started、MediaEncoding.Failed、MediaEncoding.Completed等集成事件,因此我们只要监听这些集成事件,然后把转码状态的变化推送到前端页面即可。
2、我们新建音频的时候,还要通知搜索服务收录新建的音频的原文。我们在新建音频的时候,会发布领域事件EpisodeCreatedEvent,但是领域事件只能被微服务内的代码监听到,而搜索服务属于一个独立的微服务,因此我们需要监听EpisodeCreatedEvent这个领域事件,然后再发布一个集成事件。
领域事件
事件消费者
EpisodeCreatedEventHandler:监听EpisodeCreatedEvent领域事件,把领域事件转发为集成事件,让其他微服务听到(搜索服务需要用到,实现搜索索引、记录日志等功能)
public class EpisodeCreatedEventHandler : INotificationHandler<EpisodeCreatedEvent>
{
private readonly IEventBus eventBus;
public EpisodeCreatedEventHandler(IEventBus eventBus)
{
this.eventBus = eventBus;
}
public Task Handle(EpisodeCreatedEvent notification, CancellationToken cancellationToken)
{
//把领域事件转发为集成事件,让其他微服务听到
//在领域事件处理中集中进行更新缓存等处理,而不是写到Controller中。因为项目中有可能不止一个地方操作领域对象,这样就就统一了操作。
var episode = notification.Value;
var sentences = episode.ParseSubtitle();
eventBus.Publish("ListeningEpisode.Created", new { Id = episode.Id, episode.Name, Sentences = sentences, episode.AlbumId, episode.Subtitle, episode.SubtitleType });//发布集成事件,实现搜索索引、记录日志等功能
return Task.CompletedTask;
}
}
EpisodeDeletedEventHandler:通知搜索服务,这个东西被删了,不应该被搜到
public class EpisodeDeletedEventHandler : INotificationHandler<EpisodeDeletedEvent>
{
private readonly IEventBus eventBus;
public EpisodeDeletedEventHandler(IEventBus eventBus)
{
this.eventBus = eventBus;
}
public Task Handle(EpisodeDeletedEvent notification, CancellationToken cancellationToken)
{
var id = notification.Id;
eventBus.Publish("ListeningEpisode.Deleted", new { Id = id });
return Task.CompletedTask;
}
}
EpisodeUpdatedEventHandler:东西改了,跟着改变
public class EpisodeUpdatedEventHandler : INotificationHandler<EpisodeUpdatedEvent>
{
private readonly IEventBus eventBus;
public EpisodeUpdatedEventHandler(IEventBus eventBus)
{
this.eventBus = eventBus;
}
public Task Handle(EpisodeUpdatedEvent notification, CancellationToken cancellationToken)
{
var episode = notification.Value;
//todo:如果音频地址改变,则重新转码
//考虑不能修改音频地址,因为主流视频网站也都是这样做的。不过要考虑转码成功后修改AudioUrl的问题,应该的是:新增的时候不是真的新增,而是接入队列,因为这时候的Episode如果插入数据库是非法状态,如果允许非法数据插入数据库,就要对逻辑做复杂处理,所以转码之后才能插入数据库。
if (episode.IsVisible)
{
var sentences = episode.ParseSubtitle();
eventBus.Publish("ListeningEpisode.Updated", new { Id = episode.Id, episode.Name, Sentences = sentences, episode.AlbumId, episode.Subtitle, episode.SubtitleType });
}
else
{
//被隐藏
eventBus.Publish("ListeningEpisode.Hidden", new { Id = episode.Id });
}
return Task.CompletedTask;
}
}
集成事件
事件消费者
MediaEncodingStatusChangeIntegrationHandler:集成事件,转码服务器在转码开始、失败、已存在、成功等都会发出集成事件,这里收听转码服务发出的集成事件,把状态通过SignalR推送给客户端,从而显示“转码进度”
HandleDynamic方法:可能消息发给别人或发给我,如果不是发给我Listening的则跳过。如果开始了,在redis中把这条任务修改为开始转码了,并且通过Hub通知前端开始转码。如果转码完成,从redis中取出这条任务的详细信息,创建一个episdoe,插入到数据库,手动savechange,通知前端任务完成
//收听转码服务发出的集成事件
//把状态通过SignalR推送给客户端,从而显示“转码进度”
[EventName("MediaEncoding.Started")]
[EventName("MediaEncoding.Failed")]
[EventName("MediaEncoding.Duplicated")]
[EventName("MediaEncoding.Completed")]
class MediaEncodingStatusChangeIntegrationHandler : DynamicIntegrationEventHandler
{
private readonly ListeningDbContext dbContext;
private readonly IListeningRepository repository;
private readonly EncodingEpisodeHelper encHelper;
private readonly IHubContext<EpisodeEncodingStatusHub> hubContext;
public MediaEncodingStatusChangeIntegrationHandler(ListeningDbContext dbContext,
EncodingEpisodeHelper encHelper,
IHubContext<EpisodeEncodingStatusHub> hubContext, IListeningRepository repository)
{
this.dbContext = dbContext;
this.encHelper = encHelper;
this.hubContext = hubContext;
this.repository = repository;
}
public override async Task HandleDynamic(string eventName, dynamic eventData)
{
string sourceSystem = eventData.SourceSystem;
if (sourceSystem != "Listening")//可能是别的系统的转码消息
{
return;
}
Guid id = Guid.Parse(eventData.Id);//EncodingItem的Id就是Episode 的Id
switch (eventName)
{
case "MediaEncoding.Started":
await encHelper.UpdateEpisodeStatusAsync(id, "Started");
await hubContext.Clients.All.SendAsync("OnMediaEncodingStarted", id);//通知前端刷新
break;
case "MediaEncoding.Failed":
await encHelper.UpdateEpisodeStatusAsync(id, "Failed");
//todo: 这样做有问题,这样就会把消息发送给所有打开这个界面的人,应该用connectionId、userId等进行过滤,
await hubContext.Clients.All.SendAsync("OnMediaEncodingFailed", id);
break;
case "MediaEncoding.Duplicated":
await encHelper.UpdateEpisodeStatusAsync(id, "Completed");
await hubContext.Clients.All.SendAsync("OnMediaEncodingCompleted", id);//通知前端刷新
break;
case "MediaEncoding.Completed":
//转码完成,则从Redis中把暂存的Episode信息取出来,然后正式地插入Episode表中
await encHelper.UpdateEpisodeStatusAsync(id, "Completed");
Uri outputUrl = new Uri(eventData.OutputUrl);
var encItem = await encHelper.GetEncodingEpisodeAsync(id);
Guid albumId = encItem.AlbumId;
int maxSeq = await repository.GetMaxSeqOfEpisodesAsync(albumId);
/*
Episode episode = Episode.Create(id, maxSeq.Value + 1, encodingEpisode.Name, albumId, outputUrl,
encodingEpisode.DurationInSecond, encodingEpisode.SubtitleType, encodingEpisode.Subtitle);*/
var builder = new Episode.Builder();
builder.Id(id).SequenceNumber(maxSeq + 1).Name(encItem.Name)
.AlbumId(albumId).AudioUrl(outputUrl)
.DurationInSecond(encItem.DurationInSecond)
.SubtitleType(encItem.SubtitleType).Subtitle(encItem.Subtitle);
var episdoe = builder.Build();
dbContext.Add(episdoe);
await dbContext.SaveChangesAsync();
await hubContext.Clients.All.SendAsync("OnMediaEncodingCompleted", id);//通知前端刷新
break;
default:
throw new ArgumentOutOfRangeException(nameof(eventName));
}
}
}
ReIndexAllEventHandler:重建索引,把所有的Episode让搜索引擎重新收录一遍
[EventName("SearchService.ReIndexAll")]
//让搜索引擎服务器,重新收录所有的Episode
public class ReIndexAllEventHandler : IIntegrationEventHandler
{
private readonly ListeningDbContext dbContext;
private readonly IEventBus eventBus;
public ReIndexAllEventHandler(ListeningDbContext dbContext, IEventBus eventBus)
{
this.dbContext = dbContext;
this.eventBus = eventBus;
}
public Task Handle(string eventName, string eventData)
{
foreach (var episode in dbContext.Query<Episode>())
{
if (episode.IsVisible)
{
var sentences = episode.ParseSubtitle();
eventBus.Publish("ListeningEpisode.Updated", new { Id = episode.Id, episode.Name, Sentences = sentences, episode.AlbumId, episode.Subtitle, episode.SubtitleType });
}
}
return Task.CompletedTask;
}
}
集线器:通过signalR推送给前端,上传完成、准备转码、正在转码
public class EpisodeEncodingStatusHub : Hub
{
}
工具类,操作redis的
public class EncodingEpisodeHelper
{
private readonly IConnectionMultiplexer redisConn;
public EncodingEpisodeHelper(IConnectionMultiplexer redisConn)
{
this.redisConn = redisConn;
}
//一个kv对中保存这个albumId下所有的转码中的episodeId
private static string GetKeyForEncodingEpisodeIdsOfAlbum(Guid albumId)
{
return $"Listening.EncodingEpisodeIdsOfAlbum.{albumId}";
}
private static string GetStatusKeyForEpisode(Guid episodeId)
{
string redisKey = $"Listening.EncodingEpisode.{episodeId}";
return redisKey;
}
/// <summary>
/// 增加待转码的任务的详细信息。先暂存到redis中
/// </summary>
/// <param name="albumId"></param>
/// <param name="episode"></param>
/// <returns></returns>
public async Task AddEncodingEpisodeAsync(Guid episodeId, EncodingEpisodeInfo episode)
{
string redisKeyForEpisode = GetStatusKeyForEpisode(episodeId);
var db = redisConn.GetDatabase();
await db.StringSetAsync(redisKeyForEpisode, episode.ToJsonString());//保存转码任务详细信息,供完成后插入数据库
string keyForEncodingEpisodeIdsOfAlbum = GetKeyForEncodingEpisodeIdsOfAlbum(episode.AlbumId);
await db.SetAddAsync(keyForEncodingEpisodeIdsOfAlbum, episodeId.ToString());//保存这个album下所有待转码的episodeId
}
/// <summary>
/// 获取这个albumId下所有转码任务
/// </summary>
/// <param name="albumId"></param>
/// <returns></returns>
public async Task<IEnumerable<Guid>> GetEncodingEpisodeIdsAsync(Guid albumId)
{
string keyForEncodingEpisodeIdsOfAlbum = GetKeyForEncodingEpisodeIdsOfAlbum(albumId);
var db = redisConn.GetDatabase();
var values = await db.SetMembersAsync(keyForEncodingEpisodeIdsOfAlbum);
return values.Select(v => Guid.Parse(v));
}
/// <summary>
/// 删除一个Episode任务
/// </summary>
/// <param name="db"></param>
/// <param name="episodeId"></param>
/// <param name="albumId"></param>
/// <returns></returns>
public async Task RemoveEncodingEpisodeAsync(Guid episodeId, Guid albumId)
{
string redisKeyForEpisode = GetStatusKeyForEpisode(episodeId);
var db = redisConn.GetDatabase();
await db.KeyDeleteAsync(redisKeyForEpisode);
string keyForEncodingEpisodeIdsOfAlbum = GetKeyForEncodingEpisodeIdsOfAlbum(albumId);
await db.SetRemoveAsync(keyForEncodingEpisodeIdsOfAlbum, episodeId.ToString());
}
/// <summary>
/// 修改Episode的转码状态
/// </summary>
/// <param name="db"></param>
/// <param name="episodeId"></param>
/// <param name="status"></param>
/// <returns></returns>
public async Task UpdateEpisodeStatusAsync(Guid episodeId, string status)
{
string redisKeyForEpisode = GetStatusKeyForEpisode(episodeId);
var db = redisConn.GetDatabase();
string json = await db.StringGetAsync(redisKeyForEpisode);
EncodingEpisodeInfo episode = json.ParseJson<EncodingEpisodeInfo>()!;
episode = episode with { Status = status };
await db.StringSetAsync(redisKeyForEpisode, episode.ToJsonString());
}
/// <summary>
/// 获得Episode的转码状态
/// </summary>
/// <param name="db"></param>
/// <param name="episodeId"></param>
/// <returns></returns>
public async Task<EncodingEpisodeInfo> GetEncodingEpisodeAsync(Guid episodeId)
{
string redisKey = GetStatusKeyForEpisode(episodeId);
var db = redisConn.GetDatabase();
string json = await db.StringGetAsync(redisKey);
EncodingEpisodeInfo episode = json.ParseJson<EncodingEpisodeInfo>()!;
return episode;
}
}
转码任务实体:暂存到redis的Episode对象
public record EncodingEpisodeInfo(Guid Id, MultilingualString Name, Guid AlbumId, double DurationInSecond, string Subtitle, string SubtitleType, string Status);
服务及管道模型
服务及管道模型
注入工具类
builder.Services.AddScoped<EncodingEpisodeHelper>();
SignalR
builder.Services.AddSignalR();
//...
app.MapHub<EpisodeEncodingStatusHub>("/Hubs/EpisodeEncodingStatusHub");
完整代码
var builder = WebApplication.CreateBuilder(args);
builder.ConfigureDbConfiguration();
builder.ConfigureExtraServices(new InitializerOptions
{
LogFilePath = "e:/temp/Listening.Admin.log",
EventBusQueueName = "Listening.Admin"
});
builder.Services.AddScoped<EncodingEpisodeHelper>();
builder.Services.AddControllers();
builder.Services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1", new() { Title = "Listening.Admin.WebAPI", Version = "v1" });
});
builder.Services.AddSignalR();
var app = builder.Build();
// Configure the HTTP request pipeline.
if (builder.Environment.IsDevelopment())
{
app.UseDeveloperExceptionPage();
app.UseSwagger();
app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "Listening.Admin.WebAPI v1"));
}
app.MapHub<EpisodeEncodingStatusHub>("/Hubs/EpisodeEncodingStatusHub");
app.UseZackDefault();
app.MapControllers();
app.Run();
Add的两个版本
1、为Episode实体增加一个bool类型的IsReady属性,用来表示这个音频是否可以被正常使用了。无论用户上传的音频是否为M4A格式,我们都会把它立即插入数据库中,只不过非M4A格式的音频的IsReady属性的值为false,只有转码完成后IsReady属性的值才被更新为true
缺点:程序中存在状态非法的Episode实体,代码中到处充斥着对IsReady属性的特殊处理,前台不能显示IsReady=0的,删除不能随便删(在转码中),修改时不能动IsReady=0的
DDD原则:一个实体不应该有处于不完整状态的时刻,这样就能减少程序中对于不完整状态进行处理的代码。因此一个没有完成转码的Episode对象就不应该存在于数据库中。
不应该有处于不完整状态的实体
2、一个没有完成转码的Episode对象就不应该存在于数据库中。如果用户上传的音频不是M4A格式,程序则不会立即把这个音频插入数据库,而是把用户提交的数据先暂存到Redis中,然后再启动音频转码,再转码完成后,程序再把Redis中暂存的音频数据保存到数据库中。
这样Episode对象一直处于完整状态,也就是数据库中的音频记录一定是可用的,代码就简单很多了。这就是DDD给系统设计带来的一个很大的好处
控制器详细
CategoryController
| 方法名 | 说明 | 参数 | 返回类型 | 补充 |
|---|---|---|---|---|
| FindAll | 查找所有分类 | () | Task<Category[]> | |
| FindById | 通过专辑Id查找分类 | ([RequiredGuid] Guid id) | Task<ActionResult<Category?>> | |
| Add | 添加一条分类 | (CategoryAddRequest req) | Task<ActionResult> | |
| Update | 更新一条分类 | ([RequiredGuid] Guid id, CategoryUpdateRequest request) | Task | 这里把专辑Id,更新的数据分开传入 |
| DeleteById | 删除一条分类 | ([RequiredGuid] Guid id) | Task | |
| Sort | 对分类排序 | (CategoriesSortRequest req) | Task |
AlbumController
| 方法名 | 说明 | 参数 | 返回类型 | 补充 |
|---|---|---|---|---|
| FindById | 通过Id查找专辑 | FindById([RequiredGuid] Guid id) | Task<ActionResult<Album?>> | |
| FindByCategoryId | 通关分类Id查找专辑 | ([RequiredGuid] Guid categoryId) | Task<Album[]> | |
| Add | 添加一条专辑 | (AlbumAddRequest req) | Task<ActionResult> | |
| Update | 更新一条专辑 | ([RequiredGuid] Guid id, AlbumUpdateRequest request) | Task | |
| DeleteById | 删除一条专辑 | ([RequiredGuid] Guid id) | Task | |
| Hide、Show | 显示、隐藏一条专辑 | ([RequiredGuid] Guid id) | Task | |
| Sort | 对专辑排序 | ([RequiredGuid] Guid categoryId, AlbumsSortRequest req) | Task |
EpisodeController
| 方法名 | 说明 | 参数 | 返回类型 |
|---|---|---|---|
| Add | 添加一条音频 | (EpisodeAddRequest req) | Task<ActionResult> |
| Update | 更新一条音频 | ([RequiredGuid] Guid id, EpisodeUpdateRequest request) | Task |
| DeleteById | 删除一条音频 | ([RequiredGuid] Guid id) | Task |
| FindById | 通过Id查找一条音频 | ([RequiredGuid] Guid id) | Task<ActionResult> |
| FindByAlbumId | 通过专辑查找音频 | ([RequiredGuid] Guid albumId) | Task<Episode[]> |
| FindEncodingEpisodesByAlbumId | 获取albumId下所有的转码任务 | ([RequiredGuid] Guid albumId) | Task<ActionResult<EncodingEpisodeInfo[]>> |
| Hide、Show | 音频隐藏、显示 | ([RequiredGuid] Guid id) | Task |
| Sort | 音频排序 | ([RequiredGuid] Guid albumId, EpisodesSortRequest req) | Task |