♻️ Refactored localization service

This commit is contained in:
2026-02-04 23:59:41 +08:00
parent c1669286f4
commit 9b6a62ec66
30 changed files with 530 additions and 369 deletions

View File

@@ -5,7 +5,7 @@ using DysonNetwork.Shared.Proto;
using DysonNetwork.Shared.Queue;
using DysonNetwork.Shared.Registry;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Localization;
using DysonNetwork.Shared.Localization;
using NATS.Client.Core;
using NodaTime;
using NodaTime.Extensions;
@@ -15,7 +15,7 @@ namespace DysonNetwork.Pass.Account;
public class AccountEventService(
AppDatabase db,
ICacheService cache,
IStringLocalizer<Localization.AccountEventResource> localizer,
ILocalizationService localizer,
RingService.RingServiceClient pusher,
Pass.Leveling.ExperienceService experienceService,
RemotePaymentService payment,
@@ -359,8 +359,8 @@ public class AccountEventService(
new CheckInFortuneTip
{
IsPositive = true,
Title = localizer["FortuneTipSpecialTitle_Birthday"].Value,
Content = localizer["FortuneTipSpecialContent_Birthday", user.Nick].Value,
Title = localizer.Get("fortuneTipSpecialTitleBirthday"),
Content = localizer.Get("fortuneTipSpecialContentBirthday", args: new { user.Nick }),
}
];
}
@@ -374,8 +374,8 @@ public class AccountEventService(
tips = positiveIndices.Select(index => new CheckInFortuneTip
{
IsPositive = true,
Title = localizer[$"FortuneTipPositiveTitle_{index}"].Value,
Content = localizer[$"FortuneTipPositiveContent_{index}"].Value
Title = localizer.Get($"fortuneTipPositiveTitle{index}"),
Content = localizer.Get($"fortuneTipPositiveContent{index}")
}).ToList();
// Generate 2 negative tips
@@ -387,8 +387,8 @@ public class AccountEventService(
tips.AddRange(negativeIndices.Select(index => new CheckInFortuneTip
{
IsPositive = false,
Title = localizer[$"FortuneTipNegativeTitle_{index}"].Value,
Content = localizer[$"FortuneTipNegativeContent_{index}"].Value
Title = localizer.Get($"fortuneTipNegativeTitle{index}"),
Content = localizer.Get($"fortuneTipNegativeContent{index}")
}));
// The 5 is specialized, keep it alone.

View File

@@ -6,11 +6,11 @@ using DysonNetwork.Pass.Mailer;
using DysonNetwork.Pass.Resources.Emails;
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Localization;
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto;
using DysonNetwork.Shared.Queue;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Localization;
using NATS.Client.Core;
using NATS.Net;
using NodaTime;
@@ -28,8 +28,7 @@ public class AccountService(
AffiliationSpellService ars,
EmailService mailer,
RingService.RingServiceClient pusher,
IStringLocalizer<NotificationResource> localizer,
IStringLocalizer<EmailResource> emailLocalizer,
ILocalizationService localizer,
ICacheService cache,
ILogger<AccountService> logger,
RemoteSubscriptionService remoteSubscription,
@@ -434,8 +433,8 @@ public class AccountService(
Notification = new PushNotification
{
Topic = "auth.verification",
Title = localizer["AuthCodeTitle"],
Body = localizer["AuthCodeBody", code],
Title = localizer.Get("authCodeTitle"),
Body = localizer.Get("authCodeBody", args: new { code }),
IsSavable = false
}
}
@@ -466,7 +465,7 @@ public class AccountService(
.SendTemplatedEmailAsync<FactorCodeEmail, VerificationEmailModel>(
account.Nick,
contact.Content,
emailLocalizer["CodeEmailTitle"],
localizer.Get("codeEmailTitle"),
new VerificationEmailModel
{
Name = account.Name,

View File

@@ -5,7 +5,7 @@ using DysonNetwork.Pass.Resources.Emails;
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Localization;
using DysonNetwork.Shared.Localization;
using NodaTime;
using EmailResource = DysonNetwork.Pass.Localization.EmailResource;
@@ -15,7 +15,7 @@ public class MagicSpellService(
AppDatabase db,
IConfiguration configuration,
ILogger<MagicSpellService> logger,
IStringLocalizer<EmailResource> localizer,
ILocalizationService localizer,
EmailService email,
ICacheService cache
)
@@ -98,7 +98,7 @@ public class MagicSpellService(
await email.SendTemplatedEmailAsync<RegistrationConfirmEmail, LandingEmailModel>(
contact.Account.Nick,
contact.Content,
localizer["RegConfirmTitle"],
localizer.Get("regConfirmTitle"),
new LandingEmailModel
{
Name = contact.Account.Name,
@@ -110,7 +110,7 @@ public class MagicSpellService(
await email.SendTemplatedEmailAsync<AccountDeletionEmail, AccountDeletionEmailModel>(
contact.Account.Nick,
contact.Content,
localizer["AccountDeletionTitle"],
localizer.Get("accountDeletionTitle"),
new AccountDeletionEmailModel
{
Name = contact.Account.Name,
@@ -122,7 +122,7 @@ public class MagicSpellService(
await email.SendTemplatedEmailAsync<PasswordResetEmail, PasswordResetEmailModel>(
contact.Account.Nick,
contact.Content,
localizer["PasswordResetTitle"],
localizer.Get("passwordResetTitle"),
new PasswordResetEmailModel
{
Name = contact.Account.Name,
@@ -136,7 +136,7 @@ public class MagicSpellService(
await email.SendTemplatedEmailAsync<ContactVerificationEmail, ContactVerificationEmailModel>(
contact.Account.Nick,
contactMethod!,
localizer["ContractVerificationTitle"],
localizer.Get("contractVerificationTitle"),
new ContactVerificationEmailModel
{
Name = contact.Account.Name,

View File

@@ -3,7 +3,7 @@ using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Localization;
using DysonNetwork.Shared.Localization;
using NodaTime;
namespace DysonNetwork.Pass.Account;
@@ -12,7 +12,7 @@ public class RelationshipService(
AppDatabase db,
ICacheService cache,
RingService.RingServiceClient pusher,
IStringLocalizer<NotificationResource> localizer
ILocalizationService localizer
)
{
private const string UserFriendsCacheKeyPrefix = "accounts:friends:";
@@ -117,8 +117,8 @@ public class RelationshipService(
Notification = new PushNotification
{
Topic = "relationships.friends.request",
Title = localizer["FriendRequestTitle", sender.Nick],
Body = localizer["FriendRequestBody"],
Title = localizer.Get("friendRequestTitle", args: new { sender.Nick }),
Body = localizer.Get("friendRequestBody"),
ActionUri = "/account/relationships",
IsSavable = true
}

View File

@@ -5,7 +5,7 @@ using Microsoft.EntityFrameworkCore;
using DysonNetwork.Pass.Localization;
using DysonNetwork.Shared.Geometry;
using DysonNetwork.Shared.Proto;
using Microsoft.Extensions.Localization;
using DysonNetwork.Shared.Localization;
using AccountService = DysonNetwork.Pass.Account.AccountService;
using ActionLogService = DysonNetwork.Pass.Account.ActionLogService;
using DysonNetwork.Shared.Models;
@@ -22,7 +22,7 @@ public class AuthController(
ActionLogService als,
RingService.RingServiceClient pusher,
IConfiguration configuration,
IStringLocalizer<NotificationResource> localizer,
ILocalizationService localizer,
ILogger<AuthController> logger
) : ControllerBase
{
@@ -237,9 +237,8 @@ public class AuthController(
Notification = new PushNotification
{
Topic = "auth.login",
Title = localizer["NewLoginTitle"],
Body = localizer["NewLoginBody", challenge.DeviceName ?? "unknown",
challenge.IpAddress ?? "unknown"],
Title = localizer.Get("newLoginTitle"),
Body = localizer.Get("newLoginBody", args: new { deviceName = challenge.DeviceName ?? "unknown", ipAddress = challenge.IpAddress ?? "unknown" }),
IsSavable = true
},
UserId = challenge.AccountId.ToString()

View File

@@ -134,4 +134,8 @@
<ItemGroup>
<Folder Include="Migrations\" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Resources\Locales\*.json" />
</ItemGroup>
</Project>

View File

@@ -5,14 +5,14 @@ using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto;
using DysonNetwork.Shared.Registry;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Localization;
using DysonNetwork.Shared.Localization;
namespace DysonNetwork.Pass.Realm;
public class RealmService(
AppDatabase db,
RingService.RingServiceClient pusher,
IStringLocalizer<NotificationResource> localizer,
ILocalizationService localizer,
ICacheService cache
)
{
@@ -55,8 +55,8 @@ public class RealmService(
Notification = new PushNotification
{
Topic = "invites.realms",
Title = localizer["RealmInviteTitle"],
Body = localizer["RealmInviteBody", member.Realm.Name],
Title = localizer.Get("realmInviteTitle"),
Body = localizer.Get("realmInviteBody", args: new { realmName = member.Realm.Name }),
ActionUri = "/realms",
IsSavable = true
}

View File

@@ -5,14 +5,14 @@ using Microsoft.EntityFrameworkCore;
using DysonNetwork.Pass.Localization;
using DysonNetwork.Shared;
using DysonNetwork.Shared.Cache;
using Microsoft.Extensions.Localization;
using DysonNetwork.Shared.Localization;
namespace DysonNetwork.Pass.Realm;
public class RealmServiceGrpc(
AppDatabase db,
RingService.RingServiceClient pusher,
IStringLocalizer<NotificationResource> localizer,
ILocalizationService localizer,
ICacheService cache
)
: Shared.Proto.RealmService.RealmServiceBase
@@ -127,8 +127,8 @@ public class RealmServiceGrpc(
Notification = new PushNotification
{
Topic = "invites.realms",
Title = localizer["RealmInviteTitle"],
Body = localizer["RealmInviteBody", member.Realm?.Name ?? "Unknown Realm"],
Title = localizer.Get("realmInviteTitle"),
Body = localizer.Get("realmInviteBody", args: new { realmName = member.Realm?.Name ?? "Unknown Realm" }),
ActionUri = "/realms",
IsSavable = true
}

View File

@@ -0,0 +1,22 @@
{
"welcomeMessage": "Hello, {name}!",
"goodbyeMessage": "Goodbye, {name}!",
"itemCount": {
"one": "{count} item",
"other": "{count} items"
},
"userCount": {
"one": "There is {count} user online",
"other": "There are {count} users online"
},
"notificationMessage": "You have {count} new message(s) from {sender}",
"profileUpdated": "Your profile has been updated successfully",
"errorOccurred": "An error occurred: {error}",
"loginSuccess": "Welcome back, {username}!",
"loginFailed": "Login failed. Please try again.",
"accountCreated": "Your account has been created successfully",
"emailVerification": "Please verify your email address",
"passwordReset": "Password reset link has been sent to your email",
"permissionDenied": "You do not have permission to access this resource",
"lotteryWon": "Congratulations! You won the lottery!"
}

View File

@@ -0,0 +1,22 @@
{
"welcomeMessage": "你好,{name}",
"goodbyeMessage": "再见,{name}",
"itemCount": {
"one": "{count}个项目",
"other": "{count}个项目"
},
"userCount": {
"one": "有 {count} 位用户在线",
"other": "有 {count} 位用户在线"
},
"notificationMessage": "您有 {count} 条来自 {sender} 的新消息",
"profileUpdated": "您的个人资料已成功更新",
"errorOccurred": "发生错误:{error}",
"loginSuccess": "欢迎回来,{username}",
"loginFailed": "登录失败,请重试",
"accountCreated": "您的账户已成功创建",
"emailVerification": "请验证您的电子邮件地址",
"passwordReset": "密码重置链接已发送到您的邮箱",
"permissionDenied": "您没有权限访问此资源",
"lotteryWon": "恭喜!您赢得了抽奖!"
}

View File

@@ -0,0 +1,6 @@
namespace DysonNetwork.Shared.Localization;
public interface ILocalizationService
{
string Get(string key, string? locale = null, object? args = null);
}

View File

@@ -0,0 +1,262 @@
using System.Globalization;
using System.Reflection;
using System.Text.Json;
namespace DysonNetwork.Shared.Localization;
public class JsonLocalizationService : ILocalizationService
{
private readonly Dictionary<string, Dictionary<string, LocalizationEntry>> _localeCache = new();
private readonly Assembly _assembly;
private readonly string _resourceNamespace;
private readonly object _lock = new();
private readonly List<string> _availableLocales = new();
public JsonLocalizationService(Assembly? assembly = null, string? resourceNamespace = null)
{
_assembly = assembly ?? Assembly.GetEntryAssembly() ?? Assembly.GetCallingAssembly();
_resourceNamespace = resourceNamespace ?? "DysonNetwork.Pass.Resources.Locales";
DiscoverAvailableLocales();
}
private void DiscoverAvailableLocales()
{
var resourceNames = _assembly.GetManifestResourceNames();
var prefix = $"{_resourceNamespace}.";
var suffix = ".json";
foreach (var resourceName in resourceNames)
{
if (resourceName.StartsWith(prefix) && resourceName.EndsWith(suffix))
{
var locale = resourceName.Substring(prefix.Length, resourceName.Length - prefix.Length - suffix.Length);
_availableLocales.Add(locale);
}
}
}
public string Get(string key, string? locale = null, object? args = null)
{
locale ??= CultureInfo.CurrentUICulture.Name;
// Try the requested locale first
var entries = GetLocaleEntries(locale);
if (entries.TryGetValue(key, out var entry))
{
return FormatEntry(entry, args);
}
// Fallback: search all available locales
foreach (var availableLocale in _availableLocales)
{
if (availableLocale.Equals(locale, StringComparison.OrdinalIgnoreCase))
continue;
var fallbackEntries = GetLocaleEntries(availableLocale);
if (fallbackEntries.TryGetValue(key, out entry))
{
return FormatEntry(entry, args);
}
}
// If no translation found, return key with args joined
return FormatFallback(key, args);
}
private string FormatEntry(LocalizationEntry entry, object? args)
{
string template;
if (args != null && entry.IsPlural)
{
template = SelectPluralForm(entry, args);
}
else
{
template = !string.IsNullOrEmpty(entry.Value) ? entry.Value : (entry.Other ?? entry.One ?? string.Empty);
}
return FormatTemplate(template, args);
}
private string FormatFallback(string key, object? args)
{
if (args == null)
{
return key;
}
// Extract argument values and join them with the key
var argValues = new List<string>();
var type = args.GetType();
var properties = type.GetProperties(BindingFlags.Public | BindingFlags.Instance);
var fields = type.GetFields(BindingFlags.Public | BindingFlags.Instance);
foreach (var prop in properties)
{
var value = prop.GetValue(args);
if (value != null)
{
argValues.Add(value.ToString() ?? string.Empty);
}
}
foreach (var field in fields)
{
var value = field.GetValue(args);
if (value != null)
{
argValues.Add(value.ToString() ?? string.Empty);
}
}
if (argValues.Count == 0)
{
return key;
}
return $"{key} {string.Join(" ", argValues)}";
}
private Dictionary<string, LocalizationEntry> GetLocaleEntries(string locale)
{
lock (_lock)
{
if (_localeCache.TryGetValue(locale, out var cached))
{
return cached;
}
var entries = LoadLocale(locale);
_localeCache[locale] = entries;
return entries;
}
}
private Dictionary<string, LocalizationEntry> LoadLocale(string locale)
{
var resourceName = $"{_resourceNamespace}.{locale}.json";
using var stream = _assembly.GetManifestResourceStream(resourceName);
if (stream == null)
{
return new Dictionary<string, LocalizationEntry>(StringComparer.OrdinalIgnoreCase);
}
using var reader = new StreamReader(stream);
var json = reader.ReadToEnd();
var root = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(json);
if (root == null)
{
return new Dictionary<string, LocalizationEntry>(StringComparer.OrdinalIgnoreCase);
}
var entries = new Dictionary<string, LocalizationEntry>(StringComparer.OrdinalIgnoreCase);
foreach (var kvp in root)
{
entries[kvp.Key] = ParseLocalizationEntry(kvp.Value);
}
return entries;
}
private LocalizationEntry ParseLocalizationEntry(JsonElement element)
{
if (element.ValueKind == JsonValueKind.String)
{
return new LocalizationEntry { Value = element.GetString() ?? string.Empty };
}
if (element.ValueKind == JsonValueKind.Object)
{
var entry = new LocalizationEntry();
if (element.TryGetProperty("value", out var valueProp))
{
entry.Value = valueProp.GetString() ?? string.Empty;
}
if (element.TryGetProperty("one", out var oneProp))
{
entry.One = oneProp.GetString();
}
if (element.TryGetProperty("other", out var otherProp))
{
entry.Other = otherProp.GetString();
}
return entry;
}
return new LocalizationEntry { Value = element.ToString() };
}
private string SelectPluralForm(LocalizationEntry entry, object args)
{
var countValue = GetCountValue(args);
if (countValue.HasValue)
{
var count = countValue.Value;
if (count == 1 && !string.IsNullOrEmpty(entry.One))
{
return entry.One;
}
}
return entry.Other ?? entry.One ?? string.Empty;
}
private int? GetCountValue(object args)
{
if (args == null) return null;
var type = args.GetType();
var countProperty = type.GetProperty("Count", BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase);
if (countProperty != null)
{
var value = countProperty.GetValue(args);
if (value is int intValue) return intValue;
if (value is long longValue) return (int)longValue;
if (value is double doubleValue) return (int)doubleValue;
if (value is decimal decimalValue) return (int)decimalValue;
}
var countField = type.GetField("Count", BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase);
if (countField != null)
{
var value = countField.GetValue(args);
if (value is int intValue) return intValue;
if (value is long longValue) return (int)longValue;
if (value is double doubleValue) return (int)doubleValue;
if (value is decimal decimalValue) return (int)decimalValue;
}
return null;
}
private string FormatTemplate(string template, object? args)
{
if (args == null || string.IsNullOrEmpty(template))
{
return template;
}
var result = template;
var type = args.GetType();
var properties = type.GetProperties(BindingFlags.Public | BindingFlags.Instance);
var fields = type.GetFields(BindingFlags.Public | BindingFlags.Instance);
foreach (var prop in properties)
{
var placeholder = $"{{{prop.Name}}}";
var value = prop.GetValue(args);
result = result.Replace(placeholder, value?.ToString() ?? string.Empty);
}
foreach (var field in fields)
{
var placeholder = $"{{{field.Name}}}";
var value = field.GetValue(args);
result = result.Replace(placeholder, value?.ToString() ?? string.Empty);
}
return result;
}
}

View File

@@ -0,0 +1,12 @@
namespace DysonNetwork.Shared.Localization;
public class LocalizationEntry
{
public string Value { get; set; } = string.Empty;
public string? One { get; set; }
public string? Other { get; set; }
public bool IsPlural => One != null || Other != null;
}

View File

@@ -0,0 +1,54 @@
using System.Globalization;
namespace DysonNetwork.Shared.Localization;
public static class LocalizationExtensions
{
public static string Localize(this string key, string? locale = null, object? args = null)
{
var service = LocalizationServiceLocator.Service;
if (service == null)
{
return key;
}
return service.Get(key, locale, args);
}
public static string LocalizeCount(this string key, int count, string? locale = null, object? additionalArgs = null)
{
object args;
if (additionalArgs != null)
{
args = new CountWrapper(count, additionalArgs);
}
else
{
args = new { count };
}
var service = LocalizationServiceLocator.Service;
if (service == null)
{
return key;
}
return service.Get(key, locale, args);
}
public class CountWrapper
{
public int Count { get; }
private readonly object? _additionalArgs;
public CountWrapper(int count, object? additionalArgs)
{
Count = count;
_additionalArgs = additionalArgs;
}
}
}
public static class LocalizationServiceLocator
{
public static ILocalizationService? Service { get; set; }
}

View File

@@ -95,4 +95,8 @@
<ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Resources\Locales\*.json" />
</ItemGroup>
</Project>

View File

@@ -7,7 +7,7 @@ using DysonNetwork.Sphere.Localization;
using DysonNetwork.Sphere.Publisher;
using DysonNetwork.Sphere.ActivityPub;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Localization;
using DysonNetwork.Shared.Localization;
using NodaTime;
using Markdig;
using AngleSharp.Html.Parser;
@@ -19,7 +19,7 @@ namespace DysonNetwork.Sphere.Post;
public partial class PostService(
AppDatabase db,
IStringLocalizer<NotificationResource> localizer,
ILocalizationService localizer,
IServiceScopeFactory factory,
FlushBufferService flushBuffer,
ICacheService cache,
@@ -124,9 +124,9 @@ public partial class PostService(
? string.Concat(post.Content.AsSpan(0, 97), "...")
: post.Content;
var title = post.Title ?? (post.Content?.Length >= 10 ? post.Content[..10] + "..." : post.Content);
title ??= localizer["PostOnlyMedia"];
title ??= localizer.Get("postOnlyMedia");
if (string.IsNullOrWhiteSpace(content))
content = localizer["PostOnlyMedia"];
content = localizer.Get("postOnlyMedia");
return (title, content);
}
@@ -229,7 +229,7 @@ public partial class PostService(
Notification = new PushNotification
{
Topic = "post.replies",
Title = localizer["PostReplyTitle", sender!.Nick],
Title = localizer.Get("postReplyTitle", args: new { senderNick = sender!.Nick }),
Body = ChopPostForNotification(post).content,
IsSavable = true,
ActionUri = $"/posts/{post.Id}"
@@ -703,11 +703,10 @@ public partial class PostService(
Notification = new PushNotification
{
Topic = "posts.reactions.new",
Title = localizer["PostReactTitle", sender.Nick],
Title = localizer.Get("postReactTitle", args: new { senderNick = sender.Nick }),
Body = string.IsNullOrWhiteSpace(post.Title)
? localizer["PostReactBody", sender.Nick, reaction.Symbol]
: localizer["PostReactContentBody", sender.Nick, reaction.Symbol,
post.Title],
? localizer.Get("postReactBody", args: new { senderNick = sender.Nick, reaction.Symbol })
: localizer.Get("postReactContentBody", args: new { senderNick = sender.Nick, reaction.Symbol, post.Title }),
IsSavable = true,
ActionUri = $"/posts/{post.Id}"
}
@@ -1157,11 +1156,10 @@ public partial class PostService(
Notification = new PushNotification
{
Topic = "posts.awards.new",
Title = localizer["PostAwardedTitle", sender.Nick],
Title = localizer.Get("postAwardedTitle", args: new { senderNick = sender.Nick }),
Body = string.IsNullOrWhiteSpace(post.Title)
? localizer["PostAwardedBody", sender.Nick, amount]
: localizer["PostAwardedContentBody", sender.Nick, amount,
post.Title],
? localizer.Get("postAwardedBody", args: new { senderNick = sender.Nick, amount })
: localizer.Get("postAwardedContentBody", args: new { senderNick = sender.Nick, amount, post.Title }),
IsSavable = true,
ActionUri = $"/posts/{post.Id}"
}

View File

@@ -4,14 +4,14 @@ using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto;
using DysonNetwork.Sphere.Localization;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Localization;
using DysonNetwork.Shared.Localization;
namespace DysonNetwork.Sphere.Publisher;
public class PublisherSubscriptionService(
AppDatabase db,
Post.PostService ps,
IStringLocalizer<NotificationResource> localizer,
ILocalizationService localizer,
ICacheService cache,
RingService.RingServiceClient pusher,
AccountService.AccountServiceClient accounts
@@ -118,7 +118,7 @@ public class PublisherSubscriptionService(
var notification = new PushNotification
{
Topic = "posts.new",
Title = localizer["PostSubscriptionTitle", post.Publisher!.Nick, title],
Title = localizer.Get("postSubscriptionTitle", args: new { publisherNick = post.Publisher!.Nick, title }),
Body = message,
Meta = GrpcTypeHelper.ConvertObjectToByteString(data),
IsSavable = true,

View File

@@ -0,0 +1,13 @@
{
"postOnlyMedia": "Media only",
"postReplyTitle": "{user} replied to your post",
"postReactTitle": "{user} reacted to your post",
"postReactBody": "{user} reacted with {reaction}",
"postReactContentBody": "{user} reacted with {reaction} to \"{title}\"",
"postAwardedTitle": "{user} awarded your post",
"postAwardedBody": "{user} awarded {amount} points",
"postAwardedContentBody": "{user} awarded {amount} points to \"{title}\"",
"postSubscriptionTitle": "{publisher}: {title}",
"realmInviteTitle": "Realm Invitation",
"realmInviteBody": "You have been invited to join {realm}"
}

View File

@@ -0,0 +1,13 @@
{
"postOnlyMedia": "仅媒体",
"postReplyTitle": "{user} 回复了您的帖子",
"postReactTitle": "{user} 对您的帖子做出了反应",
"postReactBody": "{user} 用 {reaction} 做出了反应",
"postReactContentBody": "{user} 用 {reaction} 对 \"{title}\" 做出了反应",
"postAwardedTitle": "{user} 打赏了您的帖子",
"postAwardedBody": "{user} 打赏了 {amount} 积分",
"postAwardedContentBody": "{user} 打赏了 {amount} 积分给 \"{title}\"",
"postSubscriptionTitle": "{publisher}: {title}",
"realmInviteTitle": "领域邀请",
"realmInviteBody": "您被邀请加入 {realm}"
}

View File

@@ -29,4 +29,8 @@
<ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Resources\Locales\*.json" />
</ItemGroup>
</Project>

View File

@@ -6,7 +6,7 @@ using DysonNetwork.Shared.Proto;
using DysonNetwork.Shared.Queue;
using DysonNetwork.Shared.Registry;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Localization;
using DysonNetwork.Shared.Localization;
using NATS.Client.Core;
using NATS.Net;
using NodaTime;
@@ -17,7 +17,7 @@ public class PaymentService(
AppDatabase db,
WalletService wat,
RingService.RingServiceClient pusher,
IStringLocalizer<NotificationResource> localizer,
ILocalizationService localizer,
INatsConnection nats
)
{
@@ -189,14 +189,16 @@ public class PaymentService(
Notification = new PushNotification
{
Topic = "wallets.transactions",
Title = localizer["TransactionNewTitle", readableTransactionRemark],
Title = localizer.Get("transactionNewTitle", args: new { remark = readableTransactionRemark }),
Body = transaction.Amount > 0
? localizer["TransactionNewBodyMinus",
transaction.Amount.ToString(CultureInfo.InvariantCulture),
transaction.Currency]
: localizer["TransactionNewBodyPlus",
transaction.Amount.ToString(CultureInfo.InvariantCulture),
transaction.Currency],
? localizer.Get("transactionNewBodyMinus", args: new {
amount = transaction.Amount.ToString(CultureInfo.InvariantCulture),
currency = transaction.Currency
})
: localizer.Get("transactionNewBodyPlus", args: new {
amount = transaction.Amount.ToString(CultureInfo.InvariantCulture),
currency = transaction.Currency
}),
IsSavable = true
}
}
@@ -216,14 +218,16 @@ public class PaymentService(
Notification = new PushNotification
{
Topic = "wallets.transactions",
Title = localizer["TransactionNewTitle", readableTransactionRemark],
Title = localizer.Get("transactionNewTitle", args: new { remark = readableTransactionRemark }),
Body = transaction.Amount > 0
? localizer["TransactionNewBodyPlus",
transaction.Amount.ToString(CultureInfo.InvariantCulture),
transaction.Currency]
: localizer["TransactionNewBodyMinus",
transaction.Amount.ToString(CultureInfo.InvariantCulture),
transaction.Currency],
? localizer.Get("transactionNewBodyPlus", args: new {
amount = transaction.Amount.ToString(CultureInfo.InvariantCulture),
currency = transaction.Currency
})
: localizer.Get("transactionNewBodyMinus", args: new {
amount = transaction.Amount.ToString(CultureInfo.InvariantCulture),
currency = transaction.Currency
}),
IsSavable = true
}
}
@@ -320,10 +324,12 @@ public class PaymentService(
Notification = new PushNotification
{
Topic = "wallets.orders.paid",
Title = localizer["OrderPaidTitle", $"#{readableOrderId}"],
Body = localizer["OrderPaidBody", order.Amount.ToString(CultureInfo.InvariantCulture),
order.Currency,
readableOrderRemark],
Title = localizer.Get("orderPaidTitle", args: new { orderId = $"#{readableOrderId}" }),
Body = localizer.Get("orderPaidBody", args: new {
amount = order.Amount.ToString(CultureInfo.InvariantCulture),
currency = order.Currency,
remark = readableOrderRemark
}),
IsSavable = true
}
}
@@ -343,10 +349,12 @@ public class PaymentService(
Notification = new PushNotification
{
Topic = "wallets.orders.received",
Title = localizer["OrderReceivedTitle", $"#{readableOrderId}"],
Body = localizer["OrderReceivedBody", order.Amount.ToString(CultureInfo.InvariantCulture),
order.Currency,
readableOrderRemark],
Title = localizer.Get("orderReceivedTitle", args: new { orderId = $"#{readableOrderId}" }),
Body = localizer.Get("orderReceivedBody", args: new {
amount = order.Amount.ToString(CultureInfo.InvariantCulture),
currency = order.Currency,
remark = readableOrderRemark
}),
IsSavable = true
}
}

View File

@@ -8,7 +8,7 @@ using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto;
using DysonNetwork.Shared.Registry;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Localization;
using DysonNetwork.Shared.Localization;
using NodaTime;
using Duration = NodaTime.Duration;
@@ -19,7 +19,7 @@ public class SubscriptionService(
PaymentService payment,
AccountService.AccountServiceClient accounts,
RingService.RingServiceClient pusher,
IStringLocalizer<NotificationResource> localizer,
ILocalizationService localizer,
IConfiguration configuration,
ICacheService cache,
ILogger<SubscriptionService> logger
@@ -423,8 +423,8 @@ public class SubscriptionService(
var notification = new PushNotification
{
Topic = "subscriptions.begun",
Title = localizer["SubscriptionAppliedTitle", humanReadableName],
Body = localizer["SubscriptionAppliedBody", duration, humanReadableName],
Title = localizer.Get("subscriptionAppliedTitle", args: new { subscriptionName = humanReadableName }),
Body = localizer.Get("subscriptionAppliedBody", args: new { duration, subscriptionName = humanReadableName }),
Meta = GrpcTypeHelper.ConvertObjectToByteString(new Dictionary<string, object>
{
["subscription_id"] = subscription.Id.ToString()
@@ -920,8 +920,8 @@ public class SubscriptionService(
var notification = new PushNotification
{
Topic = "gifts.claimed",
Title = localizer["GiftClaimedTitle"],
Body = localizer["GiftClaimedBody", humanReadableName, redeemer.Name],
Title = localizer.Get("giftClaimedTitle"),
Body = localizer.Get("giftClaimedBody", args: new { subscriptionName = humanReadableName, redeemerName = redeemer.Name }),
Meta = GrpcTypeHelper.ConvertObjectToByteString(new Dictionary<string, object>
{
["gift_id"] = gift.Id.ToString(),

View File

@@ -0,0 +1,13 @@
{
"transactionNewTitle": "New Transaction",
"transactionNewBodyMinus": "You spent {amount} {currency}",
"transactionNewBodyPlus": "You received {amount} {currency}",
"orderPaidTitle": "Order Paid",
"orderPaidBody": "Paid {amount} {currency} for {remark}",
"orderReceivedTitle": "Order Received",
"orderReceivedBody": "Received {amount} {currency} for {remark}",
"subscriptionAppliedTitle": "Subscription Applied",
"subscriptionAppliedBody": "Your subscription for {duration} days of {subscription} has been applied",
"giftClaimedTitle": "Gift Claimed",
"giftClaimedBody": "Your gift of {subscription} has been claimed by {user}"
}

View File

@@ -0,0 +1,13 @@
{
"transactionNewTitle": "新交易",
"transactionNewBodyMinus": "您花费了 {amount} {currency}",
"transactionNewBodyPlus": "您收到了 {amount} {currency}",
"orderPaidTitle": "订单已支付",
"orderPaidBody": "支付了 {amount} {currency} 用于 {remark}",
"orderReceivedTitle": "订单已接收",
"orderReceivedBody": "收到了 {amount} {currency} 用于 {remark}",
"subscriptionAppliedTitle": "订阅已应用",
"subscriptionAppliedBody": "您的 {duration} 天 {subscription} 订阅已应用",
"giftClaimedTitle": "礼物已领取",
"giftClaimedBody": "您的 {subscription} 礼物已被 {user} 领取"
}

View File

@@ -1,16 +0,0 @@
{
"Debug": true,
"BaseUrl": "http://localhost:5071",
"SiteUrl": "https://solian.app",
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"App": "Host=host.docker.internal;Port=5432;Database=dyson_develop;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60"
},
"KnownProxies": ["127.0.0.1", "::1"]
}

View File

@@ -1,123 +0,0 @@
{
"Debug": true,
"BaseUrl": "http://localhost:5090",
"GatewayUrl": "http://localhost:5094",
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"App": "Host=host.docker.internal;Port=5432;Database=dyson_drive;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=5;Connection Idle Lifetime=30"
},
"Authentication": {
"Schemes": {
"Bearer": {
"ValidAudiences": ["http://localhost:5071", "https://localhost:7099"],
"ValidIssuer": "solar-network"
}
}
},
"AuthToken": {
"PublicKeyPath": "app/keys/PublicKey.pem",
"PrivateKeyPath": "app/keys/PrivateKey.pem"
},
"OidcProvider": {
"IssuerUri": "https://nt.solian.app",
"PublicKeyPath": "Keys/PublicKey.pem",
"PrivateKeyPath": "Keys/PrivateKey.pem",
"AccessTokenLifetime": "01:00:00",
"RefreshTokenLifetime": "30.00:00:00",
"AuthorizationCodeLifetime": "00:30:00",
"RequireHttpsMetadata": true
},
"Tus": {
"StorePath": "/app/uploads"
},
"Storage": {
"PreferredRemote": "2adceae3-981a-4564-9b8d-5d71a211c873",
"Remote": [
{
"Id": "minio",
"Label": "Minio",
"Region": "auto",
"Bucket": "solar-network-development",
"Endpoint": "minio.orb.local:9000",
"SecretId": "littlesheep",
"SecretKey": "password",
"EnabledSigned": true,
"EnableSsl": false
},
{
"Id": "cloudflare",
"Label": "Cloudflare R2",
"Region": "auto",
"Bucket": "solar-network",
"Endpoint": "0a70a6d1b7128888c823359d0008f4e1.r2.cloudflarestorage.com",
"SecretId": "8ff5d06c7b1639829d60bc6838a542e6",
"SecretKey": "fd58158c5201be16d1872c9209d9cf199421dae3c2f9972f94b2305976580d67",
"EnableSigned": true,
"EnableSsl": true
}
]
},
"Captcha": {
"Provider": "cloudflare",
"ApiKey": "0x4AAAAAABCDUdOujj4feOb_",
"ApiSecret": "0x4AAAAAABCDUWABiJQweqlB7tYq-IqIm8U"
},
"Notifications": {
"Topic": "dev.solsynth.solian",
"Endpoint": "http://localhost:8088"
},
"Email": {
"Server": "smtp4dev.orb.local",
"Port": 25,
"UseSsl": false,
"Username": "no-reply@mail.solsynth.dev",
"Password": "password",
"FromAddress": "no-reply@mail.solsynth.dev",
"FromName": "Alphabot",
"SubjectPrefix": "Solar Network"
},
"RealtimeChat": {
"Endpoint": "https://solar-network-im44o8gq.livekit.cloud",
"ApiKey": "APIs6TiL8wj3A4j",
"ApiSecret": "SffxRneIwTnlHPtEf3zicmmv3LUEl7xXael4PvWZrEhE"
},
"GeoIp": {
"DatabasePath": "./Keys/GeoLite2-City.mmdb"
},
"Oidc": {
"Google": {
"ClientId": "961776991058-963m1qin2vtp8fv693b5fdrab5hmpl89.apps.googleusercontent.com",
"ClientSecret": ""
},
"Apple": {
"ClientId": "dev.solsynth.solian",
"TeamId": "W7HPZ53V6B",
"KeyId": "B668YP4KBG",
"PrivateKeyPath": "./Keys/Solarpass.p8"
},
"Microsoft": {
"ClientId": "YOUR_MICROSOFT_CLIENT_ID",
"ClientSecret": "YOUR_MICROSOFT_CLIENT_SECRET",
"DiscoveryEndpoint": "YOUR_MICROSOFT_DISCOVERY_ENDPOINT"
}
},
"Payment": {
"Auth": {
"Afdian": "<token here>"
},
"Subscriptions": {
"Afdian": {
"7d17aae23c9611f0b5705254001e7c00": "solian.stellar.primary",
"7dfae4743c9611f0b3a55254001e7c00": "solian.stellar.nova",
"141713ee3d6211f085b352540025c377": "solian.stellar.supernova"
}
}
},
"KnownProxies": ["127.0.0.1", "::1"]
}

View File

@@ -1,75 +0,0 @@
{
"Debug": true,
"BaseUrl": "http://localhost:5001",
"SiteUrl": "http://localhost:3000",
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"App": "Host=host.docker.internal;Port=5432;Database=dyson_pass;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60"
},
"Authentication": {
"Schemes": {
"Bearer": {
"ValidAudiences": ["http://localhost:5071", "https://localhost:7099"],
"ValidIssuer": "solar-network"
}
}
},
"AuthToken": {
"CookieDomain": "localhost",
"PublicKeyPath": "/app/keys/PublicKey.pem",
"PrivateKeyPath": "/app/keys/PrivateKey.pem"
},
"OidcProvider": {
"IssuerUri": "https://nt.solian.app",
"PublicKeyPath": "Keys/PublicKey.pem",
"PrivateKeyPath": "Keys/PrivateKey.pem",
"AccessTokenLifetime": "01:00:00",
"RefreshTokenLifetime": "30.00:00:00",
"AuthorizationCodeLifetime": "00:30:00",
"RequireHttpsMetadata": true
},
"Captcha": {
"Provider": "cloudflare",
"ApiKey": "0x4AAAAAABCDUdOujj4feOb_",
"ApiSecret": "0x4AAAAAABCDUWABiJQweqlB7tYq-IqIm8U"
},
"GeoIp": {
"DatabasePath": "./Keys/GeoLite2-City.mmdb"
},
"Oidc": {
"Google": {
"ClientId": "961776991058-963m1qin2vtp8fv693b5fdrab5hmpl89.apps.googleusercontent.com",
"ClientSecret": ""
},
"Apple": {
"ClientId": "dev.solsynth.solian",
"TeamId": "W7HPZ53V6B",
"KeyId": "B668YP4KBG",
"PrivateKeyPath": "./Keys/Solarpass.p8"
},
"Microsoft": {
"ClientId": "YOUR_MICROSOFT_CLIENT_ID",
"ClientSecret": "YOUR_MICROSOFT_CLIENT_SECRET",
"DiscoveryEndpoint": "YOUR_MICROSOFT_DISCOVERY_ENDPOINT"
}
},
"Payment": {
"Auth": {
"Afdian": "<token here>"
},
"Subscriptions": {
"Afdian": {
"7d17aae23c9611f0b5705254001e7c00": "solian.stellar.primary",
"7dfae4743c9611f0b3a55254001e7c00": "solian.stellar.nova",
"141713ee3d6211f085b352540025c377": "solian.stellar.supernova"
}
}
},
"KnownProxies": ["127.0.0.1", "::1"]
}

View File

@@ -1,40 +0,0 @@
{
"Debug": true,
"BaseUrl": "http://localhost:5212",
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"App": "Host=host.docker.internal;Port=5432;Database=dyson_ring;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60"
},
"Notifications": {
"Push": {
"Production": true,
"Google": "/app/keys/Solian.json",
"Apple": {
"PrivateKey": "./Keys/Solian.p8",
"PrivateKeyId": "4US4KSX4W6",
"TeamId": "W7HPZ53V6B",
"BundleIdentifier": "dev.solsynth.solian"
}
}
},
"Email": {
"Server": "smtp4dev.orb.local",
"Port": 25,
"UseSsl": false,
"Username": "no-reply@mail.solsynth.dev",
"Password": "password",
"FromAddress": "no-reply@mail.solsynth.dev",
"FromName": "Alphabot",
"SubjectPrefix": "Solar Network"
},
"GeoIp": {
"DatabasePath": "/app/keys/GeoLite2-City.mmdb"
},
"KnownProxies": ["127.0.0.1", "::1"]
}

View File

@@ -1,31 +0,0 @@
{
"Debug": true,
"BaseUrl": "http://localhost:5071",
"SiteUrl": "https://solian.app",
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"App": "Host=host.docker.internal;Port=5432;Database=dyson_sphere;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60"
},
"GeoIp": {
"DatabasePath": "/app/keys/GeoLite2-City.mmdb"
},
"RealtimeChat": {
"Endpoint": "https://solar-network-im44o8gq.livekit.cloud",
"ApiKey": "",
"ApiSecret": ""
},
"Translation": {
"Provider": "Tencent",
"Region": "ap-hongkong",
"ProjectId": "0",
"SecretId": "",
"SecretKey": ""
},
"KnownProxies": ["127.0.0.1", "::1"]
}