diff options
author | crupest <crupest@outlook.com> | 2021-02-12 22:39:57 +0800 |
---|---|---|
committer | GitHub <noreply@github.com> | 2021-02-12 22:39:57 +0800 |
commit | 94eba6cda12010c8657f0e7c5e6977a8d3b46e8c (patch) | |
tree | d6638f60df77ed97fc70f1750f90019c2bb9e9c1 /BackEnd/Timeline/Services | |
parent | a759460ad757922e761a07504bcdea7eeaa07860 (diff) | |
parent | 83910122bfd0aa9bd207b6d5f631774415312716 (diff) | |
download | timeline-94eba6cda12010c8657f0e7c5e6977a8d3b46e8c.tar.gz timeline-94eba6cda12010c8657f0e7c5e6977a8d3b46e8c.tar.bz2 timeline-94eba6cda12010c8657f0e7c5e6977a8d3b46e8c.zip |
Merge pull request #267 from crupest/backend
春节大换血 Spring festival big change.
Diffstat (limited to 'BackEnd/Timeline/Services')
11 files changed, 513 insertions, 366 deletions
diff --git a/BackEnd/Timeline/Services/BasicUserService.cs b/BackEnd/Timeline/Services/BasicUserService.cs index fbbb6677..de0829ee 100644 --- a/BackEnd/Timeline/Services/BasicUserService.cs +++ b/BackEnd/Timeline/Services/BasicUserService.cs @@ -29,6 +29,14 @@ namespace Timeline.Services /// <exception cref="ArgumentException">Thrown when <paramref name="username"/> is of bad format.</exception>
/// <exception cref="UserNotExistException">Thrown when the user with given username does not exist.</exception>
Task<long> GetUserIdByUsername(string username);
+
+ /// <summary>
+ /// Get the username modified time of a user.
+ /// </summary>
+ /// <param name="userId">User id.</param>
+ /// <returns>The time.</returns>
+ /// <exception cref="UserNotExistException">Thrown when user does not exist.</exception>
+ Task<DateTime> GetUsernameLastModifiedTime(long userId);
}
public class BasicUserService : IBasicUserService
@@ -62,5 +70,26 @@ namespace Timeline.Services return entity.Id;
}
+
+ public async Task<DateTime> GetUsernameLastModifiedTime(long userId)
+ {
+ var entity = await _database.Users.Where(u => u.Id == userId).Select(u => new { u.UsernameChangeTime }).SingleOrDefaultAsync();
+
+ if (entity is null)
+ throw new UserNotExistException(userId);
+
+ return entity.UsernameChangeTime;
+ }
+ }
+
+ public static class BasicUserServiceExtensions
+ {
+ public static async Task ThrowIfUserNotExist(this IBasicUserService service, long userId)
+ {
+ if (!await service.CheckUserExistence(userId))
+ {
+ throw new UserNotExistException(userId);
+ }
+ }
}
}
diff --git a/BackEnd/Timeline/Services/DataManager.cs b/BackEnd/Timeline/Services/DataManager.cs index d447b0d5..b697630c 100644 --- a/BackEnd/Timeline/Services/DataManager.cs +++ b/BackEnd/Timeline/Services/DataManager.cs @@ -22,29 +22,30 @@ namespace Timeline.Services /// increases its ref count and returns a tag to the entry.
/// </summary>
/// <param name="data">The data. Can't be null.</param>
+ /// <param name="saveDatabaseChange">If true save database change. Otherwise it does not save database change.</param>
/// <returns>The tag of the created entry.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="data"/> is null.</exception>
- public Task<string> RetainEntry(byte[] data);
+ public Task<string> RetainEntry(byte[] data, bool saveDatabaseChange = true);
/// <summary>
/// Decrease the the ref count of the entry.
/// Remove it if ref count is zero.
/// </summary>
/// <param name="tag">The tag of the entry.</param>
+ /// <param name="saveDatabaseChange">If true save database change. Otherwise it does not save database change.</param>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="tag"/> is null.</exception>
/// <remarks>
/// It's no-op if entry with tag does not exist.
/// </remarks>
- public Task FreeEntry(string tag);
+ public Task FreeEntry(string tag, bool saveDatabaseChange = true);
/// <summary>
- /// Retrieve the entry with given tag.
+ /// Retrieve the entry with given tag. If not exist, returns null.
/// </summary>
/// <param name="tag">The tag of the entry.</param>
- /// <returns>The data of the entry.</returns>
+ /// <returns>The data of the entry. If not exist, returns null.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="tag"/> is null.</exception>
- /// <exception cref="InvalidOperationException">Thrown when entry with given tag does not exist.</exception>
- public Task<byte[]> GetEntry(string tag);
+ public Task<byte[]?> GetEntry(string tag);
}
public class DataManager : IDataManager
@@ -58,7 +59,7 @@ namespace Timeline.Services _eTagGenerator = eTagGenerator;
}
- public async Task<string> RetainEntry(byte[] data)
+ public async Task<string> RetainEntry(byte[] data, bool saveDatabaseChange = true)
{
if (data == null)
throw new ArgumentNullException(nameof(data));
@@ -81,11 +82,14 @@ namespace Timeline.Services {
entity.Ref += 1;
}
- await _database.SaveChangesAsync();
+
+ if (saveDatabaseChange)
+ await _database.SaveChangesAsync();
+
return tag;
}
- public async Task FreeEntry(string tag)
+ public async Task FreeEntry(string tag, bool saveDatabaseChange)
{
if (tag == null)
throw new ArgumentNullException(nameof(tag));
@@ -102,21 +106,37 @@ namespace Timeline.Services {
entity.Ref -= 1;
}
- await _database.SaveChangesAsync();
+
+ if (saveDatabaseChange)
+ await _database.SaveChangesAsync();
}
}
- public async Task<byte[]> GetEntry(string tag)
+ public async Task<byte[]?> GetEntry(string tag)
{
if (tag == null)
throw new ArgumentNullException(nameof(tag));
var entity = await _database.Data.Where(d => d.Tag == tag).Select(d => new { d.Data }).SingleOrDefaultAsync();
- if (entity == null)
- throw new InvalidOperationException(Resources.Services.DataManager.ExceptionEntryNotExist);
+ if (entity is null)
+ return null;
return entity.Data;
}
}
+
+ public static class DataManagerExtensions
+ {
+ /// <summary>
+ /// Try to get an entry and throw <see cref="DatabaseCorruptedException"/> if not exist.
+ /// </summary>
+ public static async Task<byte[]> GetEntryAndCheck(this IDataManager dataManager, string tag, string notExistMessage)
+ {
+ var data = await dataManager.GetEntry(tag);
+ if (data is null)
+ throw new DatabaseCorruptedException($"Can't get data of tag {tag}. {notExistMessage}");
+ return data;
+ }
+ }
}
diff --git a/BackEnd/Timeline/Services/Exceptions/TimelinePostNoDataException.cs b/BackEnd/Timeline/Services/Exceptions/TimelinePostNoDataException.cs deleted file mode 100644 index c4b6bf62..00000000 --- a/BackEnd/Timeline/Services/Exceptions/TimelinePostNoDataException.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System;
-
-namespace Timeline.Services.Exceptions
-{
- [Serializable]
- public class TimelinePostNoDataException : Exception
- {
- public TimelinePostNoDataException() : this(null, null) { }
- public TimelinePostNoDataException(string? message) : this(message, null) { }
- public TimelinePostNoDataException(string? message, Exception? inner) : base(Resources.Services.Exceptions.TimelineNoDataException.AppendAdditionalMessage(message), inner) { }
- protected TimelinePostNoDataException(
- System.Runtime.Serialization.SerializationInfo info,
- System.Runtime.Serialization.StreamingContext context) : base(info, context) { }
- }
-}
diff --git a/BackEnd/Timeline/Services/Migration/CustomMigrationManager.cs b/BackEnd/Timeline/Services/Migration/CustomMigrationManager.cs new file mode 100644 index 00000000..f6f156cc --- /dev/null +++ b/BackEnd/Timeline/Services/Migration/CustomMigrationManager.cs @@ -0,0 +1,55 @@ +using System.Collections.Generic;
+using System.Threading.Tasks;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+using Timeline.Entities;
+
+namespace Timeline.Services.Migration
+{
+ public interface ICustomMigrationManager
+ {
+ Task Migrate();
+ }
+
+ public class CustomMigrationManager : ICustomMigrationManager
+ {
+ private IEnumerable<ICustomMigration> _migrations;
+ private DatabaseContext _database;
+
+ private ILogger<CustomMigrationManager> _logger;
+
+ public CustomMigrationManager(IEnumerable<ICustomMigration> migrations, DatabaseContext database, ILogger<CustomMigrationManager> logger)
+ {
+ _migrations = migrations;
+ _database = database;
+ _logger = logger;
+ }
+
+ public async Task Migrate()
+ {
+ foreach (var migration in _migrations)
+ {
+ var name = migration.GetName();
+ var did = await _database.Migrations.AnyAsync(m => m.Name == name);
+
+ _logger.LogInformation("Found custom migration '{0}'. Did: {1}.", name, did);
+
+ if (!did)
+ {
+ _logger.LogWarning("Begin custom migration '{0}'.", name);
+
+ await using var transaction = await _database.Database.BeginTransactionAsync();
+
+ await migration.Execute(_database);
+
+ _database.Migrations.Add(new MigrationEntity { Name = name });
+ await _database.SaveChangesAsync();
+
+ await transaction.CommitAsync();
+
+ _logger.LogWarning("End custom migration '{0}'.", name);
+ }
+ }
+ }
+ }
+}
diff --git a/BackEnd/Timeline/Services/Migration/ICustomMigration.cs b/BackEnd/Timeline/Services/Migration/ICustomMigration.cs new file mode 100644 index 00000000..1f47df1e --- /dev/null +++ b/BackEnd/Timeline/Services/Migration/ICustomMigration.cs @@ -0,0 +1,11 @@ +using System.Threading.Tasks;
+using Timeline.Entities;
+
+namespace Timeline.Services.Migration
+{
+ public interface ICustomMigration
+ {
+ string GetName();
+ Task Execute(DatabaseContext database);
+ }
+}
diff --git a/BackEnd/Timeline/Services/Migration/MigationServiceCollectionExtensions.cs b/BackEnd/Timeline/Services/Migration/MigationServiceCollectionExtensions.cs new file mode 100644 index 00000000..0e6f6c0a --- /dev/null +++ b/BackEnd/Timeline/Services/Migration/MigationServiceCollectionExtensions.cs @@ -0,0 +1,14 @@ +using Microsoft.Extensions.DependencyInjection;
+
+namespace Timeline.Services.Migration
+{
+ public static class MigrationServiceCollectionExtensions
+ {
+ public static IServiceCollection AddCustomMigration(this IServiceCollection services)
+ {
+ services.AddScoped<ICustomMigrationManager, CustomMigrationManager>();
+ services.AddScoped<ICustomMigration, TimelinePostContentToDataMigration>();
+ return services;
+ }
+ }
+}
\ No newline at end of file diff --git a/BackEnd/Timeline/Services/Migration/TimelinePostContentToDataMigration.cs b/BackEnd/Timeline/Services/Migration/TimelinePostContentToDataMigration.cs new file mode 100644 index 00000000..de2e2183 --- /dev/null +++ b/BackEnd/Timeline/Services/Migration/TimelinePostContentToDataMigration.cs @@ -0,0 +1,69 @@ +using System.Text;
+using System.Threading.Tasks;
+using Microsoft.EntityFrameworkCore;
+using SixLabors.ImageSharp;
+using Timeline.Entities;
+using Timeline.Models;
+
+namespace Timeline.Services.Migration
+{
+ public class TimelinePostContentToDataMigration : ICustomMigration
+ {
+ private readonly IDataManager _dataManager;
+
+ public TimelinePostContentToDataMigration(IDataManager dataManager)
+ {
+ _dataManager = dataManager;
+ }
+
+ public string GetName() => "TimelinePostContentToData";
+
+ public async Task Execute(DatabaseContext database)
+ {
+#pragma warning disable CS0618
+ var postEntities = await database.TimelinePosts.ToListAsync();
+
+ foreach (var postEntity in postEntities)
+ {
+ if (postEntity.Content is null)
+ {
+ postEntity.Deleted = true;
+ }
+ else
+ {
+ if (postEntity.ContentType == "text")
+ {
+ var tag = await _dataManager.RetainEntry(Encoding.UTF8.GetBytes(postEntity.Content), false);
+ database.TimelinePostData.Add(new TimelinePostDataEntity
+ {
+ DataTag = tag,
+ Kind = MimeTypes.TextPlain,
+ Index = 0,
+ PostId = postEntity.Id,
+ LastUpdated = postEntity.LastUpdated
+ });
+ }
+ else
+ {
+ var data = await _dataManager.GetEntryAndCheck(postEntity.Content, "Old image content does not have corresponding data with the tag.");
+ var format = Image.DetectFormat(data);
+ database.TimelinePostData.Add(new TimelinePostDataEntity
+ {
+ DataTag = postEntity.Content,
+ Kind = format.DefaultMimeType,
+ Index = 0,
+ PostId = postEntity.Id,
+ LastUpdated = postEntity.LastUpdated
+ });
+ }
+ }
+ postEntity.Content = null;
+ postEntity.ContentType = null;
+ postEntity.ExtraContent = null;
+ }
+
+ await database.SaveChangesAsync();
+#pragma warning restore CS0618
+ }
+ }
+}
diff --git a/BackEnd/Timeline/Services/TimelinePostCreateDataException.cs b/BackEnd/Timeline/Services/TimelinePostCreateDataException.cs new file mode 100644 index 00000000..10a09de7 --- /dev/null +++ b/BackEnd/Timeline/Services/TimelinePostCreateDataException.cs @@ -0,0 +1,16 @@ +namespace Timeline.Services
+{
+ [System.Serializable]
+ public class TimelinePostCreateDataException : System.Exception
+ {
+ public TimelinePostCreateDataException() { }
+ public TimelinePostCreateDataException(string message) : base(message) { }
+ public TimelinePostCreateDataException(string message, System.Exception inner) : base(message, inner) { }
+ public TimelinePostCreateDataException(long index, string? message, System.Exception? inner = null) : base($"Data at index {index} is invalid.{(message is null ? "" : " " + message)}", inner) { Index = index; }
+ protected TimelinePostCreateDataException(
+ System.Runtime.Serialization.SerializationInfo info,
+ System.Runtime.Serialization.StreamingContext context) : base(info, context) { }
+
+ public long Index { get; }
+ }
+}
diff --git a/BackEnd/Timeline/Services/TimelinePostDataNotExistException.cs b/BackEnd/Timeline/Services/TimelinePostDataNotExistException.cs new file mode 100644 index 00000000..c70f5d9c --- /dev/null +++ b/BackEnd/Timeline/Services/TimelinePostDataNotExistException.cs @@ -0,0 +1,25 @@ +using System;
+
+namespace Timeline.Services
+{
+ [Serializable]
+ public class TimelinePostDataNotExistException : Exception
+ {
+ public TimelinePostDataNotExistException() : this(null, null) { }
+ public TimelinePostDataNotExistException(string? message) : this(message, null) { }
+ public TimelinePostDataNotExistException(string? message, Exception? inner) : base(message, inner) { }
+ public TimelinePostDataNotExistException(long timelineId, long postId, long dataIndex, string? message = null, Exception? inner = null) : base(message, inner)
+ {
+ TimelineId = timelineId;
+ PostId = postId;
+ DataIndex = dataIndex;
+ }
+ protected TimelinePostDataNotExistException(
+ System.Runtime.Serialization.SerializationInfo info,
+ System.Runtime.Serialization.StreamingContext context) : base(info, context) { }
+
+ public long TimelineId { get; set; }
+ public long PostId { get; set; }
+ public long DataIndex { get; set; }
+ }
+}
diff --git a/BackEnd/Timeline/Services/TimelinePostService.cs b/BackEnd/Timeline/Services/TimelinePostService.cs index 66ec8090..62bc43cc 100644 --- a/BackEnd/Timeline/Services/TimelinePostService.cs +++ b/BackEnd/Timeline/Services/TimelinePostService.cs @@ -1,108 +1,49 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
-using SixLabors.ImageSharp;
using System;
using System.Collections.Generic;
using System.Linq;
+using System.Text;
using System.Threading.Tasks;
using Timeline.Entities;
using Timeline.Helpers;
+using Timeline.Helpers.Cache;
using Timeline.Models;
using Timeline.Models.Validation;
using Timeline.Services.Exceptions;
-using static Timeline.Resources.Services.TimelineService;
namespace Timeline.Services
{
- public class PostData : ICacheableData
+ public class TimelinePostCreateRequestData
{
-#pragma warning disable CA1819 // Properties should not return arrays
- public byte[] Data { get; set; } = default!;
-#pragma warning restore CA1819 // Properties should not return arrays
- public string Type { get; set; } = default!;
- public string ETag { get; set; } = default!;
- public DateTime? LastModified { get; set; } // TODO: Why nullable?
- }
-
- public abstract class TimelinePostCreateRequestContent
- {
- public abstract string TypeName { get; }
- }
-
- public class TimelinePostCreateRequestTextContent : TimelinePostCreateRequestContent
- {
- private string _text;
-
- public TimelinePostCreateRequestTextContent(string text)
- {
- if (text is null)
- throw new ArgumentNullException(nameof(text));
-
- _text = text;
- }
-
- public override string TypeName => TimelinePostContentTypes.Text;
-
- public string Text
+ public TimelinePostCreateRequestData(string contentType, byte[] data)
{
- get => _text;
- set
- {
- if (value is null)
- throw new ArgumentNullException(nameof(value));
- _text = value;
- }
+ ContentType = contentType;
+ Data = data;
}
- }
-
- public class TimelinePostCreateRequestImageContent : TimelinePostCreateRequestContent
- {
- private byte[] _data;
-
- public TimelinePostCreateRequestImageContent(byte[] data)
- {
- if (data is null)
- throw new ArgumentNullException(nameof(data));
-
- _data = data;
- }
-
- public override string TypeName => TimelinePostContentTypes.Image;
+ public string ContentType { get; set; }
#pragma warning disable CA1819 // Properties should not return arrays
- public byte[] Data
- {
- get => _data;
- set
- {
- if (value is null)
- throw new ArgumentNullException(nameof(value));
- _data = value;
- }
- }
+ public byte[] Data { get; set; }
#pragma warning restore CA1819 // Properties should not return arrays
}
public class TimelinePostCreateRequest
{
- public TimelinePostCreateRequest(TimelinePostCreateRequestContent content)
- {
- Content = content;
- }
-
public string? Color { get; set; }
/// <summary>If not set, current time is used.</summary>
public DateTime? Time { get; set; }
- public TimelinePostCreateRequestContent Content { get; set; }
+#pragma warning disable CA2227
+ public List<TimelinePostCreateRequestData> DataList { get; set; } = new List<TimelinePostCreateRequestData>();
+#pragma warning restore CA2227
}
public class TimelinePostPatchRequest
{
public string? Color { get; set; }
public DateTime? Time { get; set; }
- public TimelinePostCreateRequestContent? Content { get; set; }
}
public interface ITimelinePostService
@@ -122,34 +63,35 @@ namespace Timeline.Services /// </summary>
/// <param name="timelineId">The id of the timeline of the post.</param>
/// <param name="postId">The id of the post.</param>
- /// <param name="includeDelete">If true, return the entity even if it is deleted.</param>
+ /// <param name="includeDeleted">If true, return the entity even if it is deleted.</param>
/// <returns>The post.</returns>
/// <exception cref="TimelineNotExistException">Thrown when timeline does not exist.</exception>
/// <exception cref="TimelinePostNotExistException">Thrown when post of <paramref name="postId"/> does not exist or has been deleted.</exception>
- Task<TimelinePostEntity> GetPost(long timelineId, long postId, bool includeDelete = false);
+ Task<TimelinePostEntity> GetPost(long timelineId, long postId, bool includeDeleted = false);
/// <summary>
- /// Get the etag of data of a post.
+ /// Get the data digest of a post.
/// </summary>
- /// <param name="timelineId">The id of the timeline of the post.</param>
- /// <param name="postId">The id of the post.</param>
- /// <returns>The etag of the data.</returns>
+ /// <param name="timelineId">The timeline id.</param>
+ /// <param name="postId">The post id.</param>
+ /// <param name="dataIndex">The index of the data.</param>
+ /// <returns>The data digest.</returns>
/// <exception cref="TimelineNotExistException">Thrown when timeline does not exist.</exception>
/// <exception cref="TimelinePostNotExistException">Thrown when post of <paramref name="postId"/> does not exist or has been deleted.</exception>
- /// <exception cref="TimelinePostNoDataException">Thrown when post has no data.</exception>
- Task<string> GetPostDataETag(long timelineId, long postId);
+ /// <exception cref="TimelinePostDataNotExistException">Thrown when data of that index does not exist.</exception>
+ Task<ICacheableDataDigest> GetPostDataDigest(long timelineId, long postId, long dataIndex);
/// <summary>
/// Get the data of a post.
/// </summary>
- /// <param name="timelineId">The id of the timeline of the post.</param>
- /// <param name="postId">The id of the post.</param>
- /// <returns>The etag of the data.</returns>
+ /// <param name="timelineId">The timeline id.</param>
+ /// <param name="postId">The post id.</param>
+ /// <param name="dataIndex">The index of the data.</param>
+ /// <returns>The data.</returns>
/// <exception cref="TimelineNotExistException">Thrown when timeline does not exist.</exception>
/// <exception cref="TimelinePostNotExistException">Thrown when post of <paramref name="postId"/> does not exist or has been deleted.</exception>
- /// <exception cref="TimelinePostNoDataException">Thrown when post has no data.</exception>
- /// <seealso cref="GetPostDataETag(long, long)"/>
- Task<PostData> GetPostData(long timelineId, long postId);
+ /// <exception cref="TimelinePostDataNotExistException">Thrown when data of that index does not exist.</exception>
+ Task<ByteData> GetPostData(long timelineId, long postId, long dataIndex);
/// <summary>
/// Create a new post in timeline.
@@ -176,7 +118,6 @@ namespace Timeline.Services /// <exception cref="ArgumentException">Thrown when <paramref name="request"/> is of invalid format.</exception>
/// <exception cref="TimelineNotExistException">Thrown when timeline does not exist.</exception>
/// <exception cref="TimelinePostNotExistException">Thrown when post does not exist.</exception>
- /// <exception cref="ImageException">Thrown if data is not a image. Validated by <see cref="ImageValidator"/>.</exception>
Task<TimelinePostEntity> PatchPost(long timelineId, long postId, TimelinePostPatchRequest request);
/// <summary>
@@ -261,7 +202,7 @@ namespace Timeline.Services if (!includeDeleted)
{
- query = query.Where(p => p.Content != null);
+ query = query.Where(p => !p.Deleted);
}
if (modifiedSince.HasValue)
@@ -274,7 +215,7 @@ namespace Timeline.Services return await query.ToListAsync();
}
- public async Task<TimelinePostEntity> GetPost(long timelineId, long postId, bool includeDelete = false)
+ public async Task<TimelinePostEntity> GetPost(long timelineId, long postId, bool includeDeleted = false)
{
await CheckTimelineExistence(timelineId);
@@ -285,7 +226,7 @@ namespace Timeline.Services throw new TimelinePostNotExistException(timelineId, postId, false);
}
- if (!includeDelete && post.Content is null)
+ if (!includeDeleted && post.Deleted)
{
throw new TimelinePostNotExistException(timelineId, postId, true);
}
@@ -293,99 +234,46 @@ namespace Timeline.Services return post;
}
- public async Task<string> GetPostDataETag(long timelineId, long postId)
+ public async Task<ICacheableDataDigest> GetPostDataDigest(long timelineId, long postId, long dataIndex)
{
await CheckTimelineExistence(timelineId);
- var postEntity = await _database.TimelinePosts.Where(p => p.TimelineId == timelineId && p.LocalId == postId).SingleOrDefaultAsync();
+ var postEntity = await _database.TimelinePosts.Where(p => p.TimelineId == timelineId && p.LocalId == postId).Select(p => new { p.Id, p.Deleted }).SingleOrDefaultAsync();
- if (postEntity == null)
+ if (postEntity is null)
throw new TimelinePostNotExistException(timelineId, postId, false);
- if (postEntity.Content == null)
+ if (postEntity.Deleted)
throw new TimelinePostNotExistException(timelineId, postId, true);
- if (postEntity.ContentType != TimelinePostContentTypes.Image)
- throw new TimelinePostNoDataException(ExceptionGetDataNonImagePost);
+ var dataEntity = await _database.TimelinePostData.Where(d => d.PostId == postEntity.Id && d.Index == dataIndex).SingleOrDefaultAsync();
- var tag = postEntity.Content;
+ if (dataEntity is null)
+ throw new TimelinePostDataNotExistException(timelineId, postId, dataIndex);
- return tag;
+ return new CacheableDataDigest(dataEntity.DataTag, dataEntity.LastUpdated);
}
- public async Task<PostData> GetPostData(long timelineId, long postId)
+ public async Task<ByteData> GetPostData(long timelineId, long postId, long dataIndex)
{
await CheckTimelineExistence(timelineId);
- var postEntity = await _database.TimelinePosts.Where(p => p.TimelineId == timelineId && p.LocalId == postId).SingleOrDefaultAsync();
+ var postEntity = await _database.TimelinePosts.Where(p => p.TimelineId == timelineId && p.LocalId == postId).Select(p => new { p.Id, p.Deleted }).SingleOrDefaultAsync();
- if (postEntity == null)
+ if (postEntity is null)
throw new TimelinePostNotExistException(timelineId, postId, false);
- if (postEntity.Content == null)
+ if (postEntity.Deleted)
throw new TimelinePostNotExistException(timelineId, postId, true);
- if (postEntity.ContentType != TimelinePostContentTypes.Image)
- throw new TimelinePostNoDataException(ExceptionGetDataNonImagePost);
-
- var tag = postEntity.Content;
-
- byte[] data;
+ var dataEntity = await _database.TimelinePostData.Where(d => d.PostId == postEntity.Id && d.Index == dataIndex).SingleOrDefaultAsync();
- try
- {
- data = await _dataManager.GetEntry(tag);
- }
- catch (InvalidOperationException e)
- {
- throw new DatabaseCorruptedException(ExceptionGetDataDataEntryNotExist, e);
- }
+ if (dataEntity is null)
+ throw new TimelinePostDataNotExistException(timelineId, postId, dataIndex);
- if (postEntity.ExtraContent == null)
- {
- _logger.LogWarning(LogGetDataNoFormat);
- var format = Image.DetectFormat(data);
- postEntity.ExtraContent = format.DefaultMimeType;
- await _database.SaveChangesAsync();
- }
+ var data = await _dataManager.GetEntryAndCheck(dataEntity.DataTag, $"Timeline {timelineId}, post {postId}, data {dataIndex} requires this data.");
- return new PostData
- {
- Data = data,
- Type = postEntity.ExtraContent,
- ETag = tag,
- LastModified = postEntity.LastUpdated
- };
- }
-
- private async Task SaveContent(TimelinePostEntity entity, TimelinePostCreateRequestContent content)
- {
- switch (content)
- {
- case TimelinePostCreateRequestTextContent c:
- entity.ContentType = c.TypeName;
- entity.Content = c.Text;
- break;
- case TimelinePostCreateRequestImageContent c:
- var imageFormat = await _imageValidator.Validate(c.Data);
- var imageFormatText = imageFormat.DefaultMimeType;
-
- var tag = await _dataManager.RetainEntry(c.Data);
-
- entity.ContentType = content.TypeName;
- entity.Content = tag;
- entity.ExtraContent = imageFormatText;
- break;
- default:
- throw new ArgumentException("Unknown content type.", nameof(content));
- };
- }
-
- private async Task CleanContent(TimelinePostEntity entity)
- {
- if (entity.Content is not null && entity.ContentType == TimelinePostContentTypes.Image)
- await _dataManager.FreeEntry(entity.Content);
- entity.Content = null;
+ return new ByteData(data, dataEntity.Kind);
}
public async Task<TimelinePostEntity> CreatePost(long timelineId, long authorId, TimelinePostCreateRequest request)
@@ -393,15 +281,55 @@ namespace Timeline.Services if (request is null)
throw new ArgumentNullException(nameof(request));
-
- if (request.Content is null)
- throw new ArgumentException("Content is null.", nameof(request));
-
{
if (!_colorValidator.Validate(request.Color, out var message))
throw new ArgumentException("Color is not valid.", nameof(request));
}
+ if (request.DataList is null)
+ throw new ArgumentException("Data list can't be null.", nameof(request));
+
+ if (request.DataList.Count == 0)
+ throw new ArgumentException("Data list can't be empty.", nameof(request));
+
+ if (request.DataList.Count > 100)
+ throw new ArgumentException("Data list count can't be bigger than 100.", nameof(request));
+
+ for (int index = 0; index < request.DataList.Count; index++)
+ {
+ var data = request.DataList[index];
+
+ switch (data.ContentType)
+ {
+ case MimeTypes.ImageGif:
+ case MimeTypes.ImageJpeg:
+ case MimeTypes.ImagePng:
+ case MimeTypes.ImageWebp:
+ try
+ {
+ await _imageValidator.Validate(data.Data, data.ContentType);
+ }
+ catch (ImageException e)
+ {
+ throw new TimelinePostCreateDataException(index, "Image validation failed.", e);
+ }
+ break;
+ case MimeTypes.TextPlain:
+ case MimeTypes.TextMarkdown:
+ try
+ {
+ new UTF8Encoding(false, true).GetString(data.Data);
+ }
+ catch (DecoderFallbackException e)
+ {
+ throw new TimelinePostCreateDataException(index, "Text is not a valid utf-8 sequence.", e);
+ }
+ break;
+ default:
+ throw new TimelinePostCreateDataException(index, "Unsupported content type.");
+ }
+ }
+
request.Time = request.Time?.MyToUtc();
await CheckTimelineExistence(timelineId);
@@ -421,13 +349,29 @@ namespace Timeline.Services Color = request.Color
};
- await SaveContent(postEntity, request.Content);
-
var timelineEntity = await _database.Timelines.Where(t => t.Id == timelineId).SingleAsync();
timelineEntity.CurrentPostLocalId += 1;
postEntity.LocalId = timelineEntity.CurrentPostLocalId;
-
_database.TimelinePosts.Add(postEntity);
+ await _database.SaveChangesAsync();
+
+ List<string> dataTags = new List<string>();
+
+ for (int index = 0; index < request.DataList.Count; index++)
+ {
+ var data = request.DataList[index];
+
+ var tag = await _dataManager.RetainEntry(data.Data, false);
+
+ _database.TimelinePostData.Add(new TimelinePostDataEntity
+ {
+ DataTag = tag,
+ Kind = data.ContentType,
+ Index = index,
+ PostId = postEntity.Id,
+ LastUpdated = currentTime,
+ });
+ }
await _database.SaveChangesAsync();
@@ -452,12 +396,10 @@ namespace Timeline.Services var entity = await _database.TimelinePosts.Where(p => p.TimelineId == timelineId && p.LocalId == postId).SingleOrDefaultAsync();
- await using var transaction = await _database.Database.BeginTransactionAsync();
-
if (entity is null)
throw new TimelinePostNotExistException(timelineId, postId, false);
- if (entity.Content is null)
+ if (entity.Deleted)
throw new TimelinePostNotExistException(timelineId, postId, true);
if (request.Time.HasValue)
@@ -466,18 +408,10 @@ namespace Timeline.Services if (request.Color is not null)
entity.Color = request.Color;
- if (request.Content is not null)
- {
- await CleanContent(entity);
- await SaveContent(entity, request.Content);
- }
-
entity.LastUpdated = _clock.GetCurrentTime();
await _database.SaveChangesAsync();
- await transaction.CommitAsync();
-
return entity;
}
@@ -490,15 +424,23 @@ namespace Timeline.Services if (entity == null)
throw new TimelinePostNotExistException(timelineId, postId, false);
- if (entity.Content == null)
+ if (entity.Deleted)
throw new TimelinePostNotExistException(timelineId, postId, true);
await using var transaction = await _database.Database.BeginTransactionAsync();
- await CleanContent(entity);
-
+ entity.Deleted = true;
entity.LastUpdated = _clock.GetCurrentTime();
+ var dataEntities = await _database.TimelinePostData.Where(d => d.PostId == entity.Id).ToListAsync();
+
+ foreach (var dataEntity in dataEntities)
+ {
+ await _dataManager.FreeEntry(dataEntity.DataTag, false);
+ }
+
+ _database.TimelinePostData.RemoveRange(dataEntities);
+
await _database.SaveChangesAsync();
await transaction.CommitAsync();
@@ -506,30 +448,11 @@ namespace Timeline.Services public async Task DeleteAllPostsOfUser(long userId)
{
- var posts = await _database.TimelinePosts.Where(p => p.AuthorId == userId).ToListAsync();
-
- var now = _clock.GetCurrentTime();
-
- var dataTags = new List<string>();
-
- foreach (var post in posts)
- {
- if (post.Content != null)
- {
- if (post.ContentType == TimelinePostContentTypes.Image)
- {
- dataTags.Add(post.Content);
- }
- post.Content = null;
- }
- post.LastUpdated = now;
- }
-
- await _database.SaveChangesAsync();
+ var postEntities = await _database.TimelinePosts.Where(p => p.AuthorId == userId).Select(p => new { p.TimelineId, p.LocalId }).ToListAsync();
- foreach (var dataTag in dataTags)
+ foreach (var postEntity in postEntities)
{
- await _dataManager.FreeEntry(dataTag);
+ await this.DeletePost(postEntity.TimelineId, postEntity.LocalId);
}
}
@@ -539,9 +462,9 @@ namespace Timeline.Services var timelineEntity = await _database.Timelines.Where(t => t.Id == timelineId).Select(t => new { t.OwnerId }).SingleAsync();
- var postEntity = await _database.TimelinePosts.Where(p => p.Id == postId).Select(p => new { p.Content, p.AuthorId }).SingleOrDefaultAsync();
+ var postEntity = await _database.TimelinePosts.Where(p => p.Id == postId).Select(p => new { p.Deleted, p.AuthorId }).SingleOrDefaultAsync();
- if (postEntity == null)
+ if (postEntity is null)
{
if (throwOnPostNotExist)
throw new TimelinePostNotExistException(timelineId, postId, false);
@@ -549,7 +472,7 @@ namespace Timeline.Services return true;
}
- if (postEntity.Content == null && throwOnPostNotExist)
+ if (postEntity.Deleted && throwOnPostNotExist)
{
throw new TimelinePostNotExistException(timelineId, postId, true);
}
diff --git a/BackEnd/Timeline/Services/UserAvatarService.cs b/BackEnd/Timeline/Services/UserAvatarService.cs index b41c45fd..5a6d013e 100644 --- a/BackEnd/Timeline/Services/UserAvatarService.cs +++ b/BackEnd/Timeline/Services/UserAvatarService.cs @@ -2,34 +2,18 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
+using SixLabors.ImageSharp;
using System;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Timeline.Entities;
-using Timeline.Helpers;
+using Timeline.Helpers.Cache;
+using Timeline.Models;
using Timeline.Services.Exceptions;
namespace Timeline.Services
{
- public class Avatar
- {
- public string Type { get; set; } = default!;
- [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1819:Properties should not return arrays", Justification = "DTO Object")]
- public byte[] Data { get; set; } = default!;
- }
-
- public class AvatarInfo
- {
- public Avatar Avatar { get; set; } = default!;
- public DateTime LastModified { get; set; }
-
- public CacheableData ToCacheableData()
- {
- return new CacheableData(Avatar.Type, Avatar.Data, LastModified);
- }
- }
-
/// <summary>
/// Provider for default user avatar.
/// </summary>
@@ -39,42 +23,53 @@ namespace Timeline.Services public interface IDefaultUserAvatarProvider
{
/// <summary>
- /// Get the etag of default avatar.
+ /// Get the digest of default avatar.
/// </summary>
- /// <returns></returns>
- Task<string> GetDefaultAvatarETag();
+ /// <returns>The digest.</returns>
+ Task<ICacheableDataDigest> GetDefaultAvatarDigest();
/// <summary>
/// Get the default avatar.
/// </summary>
- Task<AvatarInfo> GetDefaultAvatar();
+ /// <returns>The avatar.</returns>
+ Task<ByteData> GetDefaultAvatar();
}
public interface IUserAvatarService
{
/// <summary>
- /// Get the etag of a user's avatar. Warning: This method does not check the user existence.
+ /// Get avatar digest of a user.
/// </summary>
- /// <param name="id">The id of the user to get avatar etag of.</param>
- /// <returns>The etag.</returns>
- Task<string> GetAvatarETag(long id);
+ /// <param name="userId">User id.</param>
+ /// <returns>The avatar digest.</returns>
+ /// <exception cref="UserNotExistException">Thrown when user does not exist.</exception>
+ Task<ICacheableDataDigest> GetAvatarDigest(long userId);
/// <summary>
- /// Get avatar of a user. If the user has no avatar set, a default one is returned. Warning: This method does not check the user existence.
+ /// Get avatar of a user. If the user has no avatar set, a default one is returned.
/// </summary>
- /// <param name="id">The id of the user to get avatar of.</param>
- /// <returns>The avatar info.</returns>
- Task<AvatarInfo> GetAvatar(long id);
+ /// <param name="userId">User id.</param>
+ /// <returns>The avatar.</returns>
+ /// <exception cref="UserNotExistException">Thrown when user does not exist.</exception>
+ Task<ByteData> GetAvatar(long userId);
/// <summary>
- /// Set avatar for a user. Warning: This method does not check the user existence.
+ /// Set avatar for a user.
/// </summary>
- /// <param name="id">The id of the user to set avatar for.</param>
- /// <param name="avatar">The avatar. Can be null to delete the saved avatar.</param>
- /// <returns>The etag of the avatar.</returns>
- /// <exception cref="ArgumentException">Thrown if any field in <paramref name="avatar"/> is null when <paramref name="avatar"/> is not null.</exception>
+ /// <param name="userId">User id.</param>
+ /// <param name="avatar">The new avatar data.</param>
+ /// <returns>The digest of the avatar.</returns>
+ /// <exception cref="ArgumentNullException">Thrown if <paramref name="avatar"/> is null.</exception>
+ /// <exception cref="UserNotExistException">Thrown when user does not exist.</exception>
/// <exception cref="ImageException">Thrown if avatar is of bad format.</exception>
- Task<string> SetAvatar(long id, Avatar? avatar);
+ Task<ICacheableDataDigest> SetAvatar(long userId, ByteData avatar);
+
+ /// <summary>
+ /// Remove avatar of a user.
+ /// </summary>
+ /// <param name="userId">User id.</param>
+ /// <exception cref="UserNotExistException">Thrown when user does not exist.</exception>
+ Task DeleteAvatar(long userId);
}
// TODO! : Make this configurable.
@@ -84,9 +79,8 @@ namespace Timeline.Services private readonly string _avatarPath;
- private byte[] _cacheData = default!;
- private DateTime _cacheLastModified;
- private string _cacheETag = default!;
+ private CacheableDataDigest? _cacheDigest;
+ private ByteData? _cacheData;
public DefaultUserAvatarProvider(IWebHostEnvironment environment, IETagGenerator eTagGenerator)
{
@@ -97,53 +91,42 @@ namespace Timeline.Services private async Task CheckAndInit()
{
var path = _avatarPath;
- if (_cacheData == null || File.GetLastWriteTime(path) > _cacheLastModified)
+ if (_cacheData == null || File.GetLastWriteTime(path) > _cacheDigest!.LastModified)
{
- _cacheData = await File.ReadAllBytesAsync(path);
- _cacheLastModified = File.GetLastWriteTime(path);
- _cacheETag = await _eTagGenerator.Generate(_cacheData);
+ var data = await File.ReadAllBytesAsync(path);
+ _cacheDigest = new CacheableDataDigest(await _eTagGenerator.Generate(data), File.GetLastWriteTime(path));
+ Image.Identify(data, out var format);
+ _cacheData = new ByteData(data, format.DefaultMimeType);
}
}
- public async Task<string> GetDefaultAvatarETag()
+ public async Task<ICacheableDataDigest> GetDefaultAvatarDigest()
{
await CheckAndInit();
- return _cacheETag;
+ return _cacheDigest!;
}
- public async Task<AvatarInfo> GetDefaultAvatar()
+ public async Task<ByteData> GetDefaultAvatar()
{
await CheckAndInit();
- return new AvatarInfo
- {
- Avatar = new Avatar
- {
- Type = "image/png",
- Data = _cacheData
- },
- LastModified = _cacheLastModified
- };
+ return _cacheData!;
}
}
public class UserAvatarService : IUserAvatarService
{
-
private readonly ILogger<UserAvatarService> _logger;
-
private readonly DatabaseContext _database;
-
+ private readonly IBasicUserService _basicUserService;
private readonly IDefaultUserAvatarProvider _defaultUserAvatarProvider;
-
private readonly IImageValidator _imageValidator;
-
private readonly IDataManager _dataManager;
-
private readonly IClock _clock;
public UserAvatarService(
ILogger<UserAvatarService> logger,
DatabaseContext database,
+ IBasicUserService basicUserService,
IDefaultUserAvatarProvider defaultUserAvatarProvider,
IImageValidator imageValidator,
IDataManager dataManager,
@@ -151,106 +134,123 @@ namespace Timeline.Services {
_logger = logger;
_database = database;
+ _basicUserService = basicUserService;
_defaultUserAvatarProvider = defaultUserAvatarProvider;
_imageValidator = imageValidator;
_dataManager = dataManager;
_clock = clock;
}
- public async Task<string> GetAvatarETag(long id)
+ public async Task<ICacheableDataDigest> GetAvatarDigest(long userId)
{
- var eTag = (await _database.UserAvatars.Where(a => a.UserId == id).Select(a => new { a.DataTag }).SingleOrDefaultAsync())?.DataTag;
- if (eTag == null)
- return await _defaultUserAvatarProvider.GetDefaultAvatarETag();
+ var usernameChangeTime = await _basicUserService.GetUsernameLastModifiedTime(userId);
+
+ var entity = await _database.UserAvatars.Where(a => a.UserId == userId).Select(a => new { a.DataTag, a.LastModified }).SingleOrDefaultAsync();
+
+ if (entity is null)
+ {
+ var defaultAvatarDigest = await _defaultUserAvatarProvider.GetDefaultAvatarDigest();
+ return new CacheableDataDigest(defaultAvatarDigest.ETag, new DateTime[] { usernameChangeTime, defaultAvatarDigest.LastModified }.Max());
+ }
+ else if (entity.DataTag is null)
+ {
+ var defaultAvatarDigest = await _defaultUserAvatarProvider.GetDefaultAvatarDigest();
+ return new CacheableDataDigest(defaultAvatarDigest.ETag, new DateTime[] { usernameChangeTime, defaultAvatarDigest.LastModified, entity.LastModified }.Max());
+ }
else
- return eTag;
+ {
+ return new CacheableDataDigest(entity.DataTag, new DateTime[] { usernameChangeTime, entity.LastModified }.Max());
+ }
}
- public async Task<AvatarInfo> GetAvatar(long id)
+ public async Task<ByteData> GetAvatar(long userId)
{
- var avatarEntity = await _database.UserAvatars.Where(a => a.UserId == id).Select(a => new { a.Type, a.DataTag, a.LastModified }).SingleOrDefaultAsync();
+ await _basicUserService.ThrowIfUserNotExist(userId);
+
+ var entity = await _database.UserAvatars.Where(a => a.UserId == userId).SingleOrDefaultAsync();
- if (avatarEntity != null)
+ if (entity is null || entity.DataTag is null)
{
- if (!LanguageHelper.AreSame(avatarEntity.DataTag == null, avatarEntity.Type == null))
- {
- var message = Resources.Services.UserAvatarService.ExceptionDatabaseCorruptedDataAndTypeNotSame;
- _logger.LogCritical(message);
- throw new DatabaseCorruptedException(message);
- }
+ return await _defaultUserAvatarProvider.GetDefaultAvatar();
+ }
+ var data = await _dataManager.GetEntryAndCheck(entity.DataTag, $"This is required by avatar of {userId}.");
- if (avatarEntity.DataTag != null)
- {
- var data = await _dataManager.GetEntry(avatarEntity.DataTag);
- return new AvatarInfo
- {
- Avatar = new Avatar
- {
- Type = avatarEntity.Type!,
- Data = data
- },
- LastModified = avatarEntity.LastModified
- };
- }
+ if (entity.Type is null)
+ {
+ Image.Identify(data, out var format);
+ entity.Type = format.DefaultMimeType;
+ await _database.SaveChangesAsync();
}
- var defaultAvatar = await _defaultUserAvatarProvider.GetDefaultAvatar();
- if (avatarEntity != null)
- defaultAvatar.LastModified = defaultAvatar.LastModified > avatarEntity.LastModified ? defaultAvatar.LastModified : avatarEntity.LastModified;
- return defaultAvatar;
+
+ return new ByteData(data, entity.Type);
}
- public async Task<string> SetAvatar(long id, Avatar? avatar)
+ public async Task<ICacheableDataDigest> SetAvatar(long userId, ByteData avatar)
{
- if (avatar != null)
- {
- if (avatar.Data == null)
- throw new ArgumentException(Resources.Services.UserAvatarService.ExceptionAvatarDataNull, nameof(avatar));
- if (string.IsNullOrEmpty(avatar.Type))
- throw new ArgumentException(Resources.Services.UserAvatarService.ExceptionAvatarTypeNullOrEmpty, nameof(avatar));
- }
+ if (avatar is null)
+ throw new ArgumentNullException(nameof(avatar));
- var avatarEntity = await _database.UserAvatars.Where(a => a.UserId == id).SingleOrDefaultAsync();
+ await _imageValidator.Validate(avatar.Data, avatar.ContentType, true);
- if (avatar == null)
+ await _basicUserService.ThrowIfUserNotExist(userId);
+
+ var entity = await _database.UserAvatars.Where(a => a.UserId == userId).SingleOrDefaultAsync();
+
+ await using var transaction = await _database.Database.BeginTransactionAsync();
+
+ var tag = await _dataManager.RetainEntry(avatar.Data);
+
+ var now = _clock.GetCurrentTime();
+
+ if (entity is null)
{
- if (avatarEntity != null && avatarEntity.DataTag != null)
+ var newEntity = new UserAvatarEntity
{
- await _dataManager.FreeEntry(avatarEntity.DataTag);
- avatarEntity.DataTag = null;
- avatarEntity.Type = null;
- avatarEntity.LastModified = _clock.GetCurrentTime();
- await _database.SaveChangesAsync();
- _logger.LogInformation(Resources.Services.UserAvatarService.LogUpdateEntity);
- }
- return await _defaultUserAvatarProvider.GetDefaultAvatarETag();
+ DataTag = tag,
+ Type = avatar.ContentType,
+ LastModified = now,
+ UserId = userId
+ };
+ _database.Add(newEntity);
}
else
{
- await _imageValidator.Validate(avatar.Data, avatar.Type, true);
- var tag = await _dataManager.RetainEntry(avatar.Data);
- var oldTag = avatarEntity?.DataTag;
- var create = avatarEntity == null;
- if (avatarEntity == null)
- {
- avatarEntity = new UserAvatarEntity();
- _database.UserAvatars.Add(avatarEntity);
- }
- avatarEntity.DataTag = tag;
- avatarEntity.Type = avatar.Type;
- avatarEntity.LastModified = _clock.GetCurrentTime();
- avatarEntity.UserId = id;
- await _database.SaveChangesAsync();
- _logger.LogInformation(create ?
- Resources.Services.UserAvatarService.LogCreateEntity
- : Resources.Services.UserAvatarService.LogUpdateEntity);
- if (oldTag != null)
- {
- await _dataManager.FreeEntry(oldTag);
- }
+ if (entity.DataTag is not null)
+ await _dataManager.FreeEntry(entity.DataTag);
- return avatarEntity.DataTag;
+ entity.DataTag = tag;
+ entity.Type = avatar.ContentType;
+ entity.LastModified = now;
}
+
+ await _database.SaveChangesAsync();
+
+ await transaction.CommitAsync();
+
+ return new CacheableDataDigest(tag, now);
+ }
+
+ public async Task DeleteAvatar(long userId)
+ {
+ await _basicUserService.ThrowIfUserNotExist(userId);
+
+ var entity = await _database.UserAvatars.Where(a => a.UserId == userId).SingleOrDefaultAsync();
+
+ if (entity is null || entity.DataTag is null)
+ return;
+
+ await using var transaction = await _database.Database.BeginTransactionAsync();
+
+ await _dataManager.FreeEntry(entity.DataTag);
+
+ entity.DataTag = null;
+ entity.Type = null;
+ entity.LastModified = _clock.GetCurrentTime();
+
+ await _database.SaveChangesAsync();
+
+ await transaction.CommitAsync();
}
}
|