Add domestic vendor push infrastructure
This commit is contained in:
@@ -41,9 +41,13 @@ public sealed class ApiFixture : IAsyncLifetime
|
||||
Environment.SetEnvironmentVariable(
|
||||
"Jwt__Secret",
|
||||
"test-only-jwt-secret-at-least-thirty-two-characters");
|
||||
Environment.SetEnvironmentVariable(
|
||||
"Admin__Key",
|
||||
"test-only-admin-key-at-least-24-characters");
|
||||
Environment.SetEnvironmentVariable(
|
||||
"Admin__Key",
|
||||
"test-only-admin-key-at-least-24-characters");
|
||||
Environment.SetEnvironmentVariable(
|
||||
"Push__TokenEncryptionKey",
|
||||
Convert.ToBase64String(Enumerable.Range(1, 32).Select(value => (byte)value).ToArray()));
|
||||
Environment.SetEnvironmentVariable("RateLimiting__AuthPermitLimit", "1000");
|
||||
|
||||
Factory = new WebApplicationFactory<Program>().WithWebHostBuilder(
|
||||
builder =>
|
||||
@@ -65,8 +69,10 @@ public sealed class ApiFixture : IAsyncLifetime
|
||||
Factory?.Dispose();
|
||||
await _database.DisposeAsync();
|
||||
Environment.SetEnvironmentVariable("ConnectionStrings__Default", null);
|
||||
Environment.SetEnvironmentVariable("Jwt__Secret", null);
|
||||
Environment.SetEnvironmentVariable("Admin__Key", null);
|
||||
Environment.SetEnvironmentVariable("Jwt__Secret", null);
|
||||
Environment.SetEnvironmentVariable("Admin__Key", null);
|
||||
Environment.SetEnvironmentVariable("Push__TokenEncryptionKey", null);
|
||||
Environment.SetEnvironmentVariable("RateLimiting__AuthPermitLimit", null);
|
||||
}
|
||||
|
||||
public async Task<HttpClient> RegisterAsync(string username)
|
||||
@@ -395,7 +401,7 @@ public sealed class ApiIntegrationTests(ApiFixture fixture)
|
||||
[Fact]
|
||||
public async Task RecognitionBatch_PreservesThreeConsecutiveTransfers_AndIsIdempotent()
|
||||
{
|
||||
using var client = await fixture.RegisterAsync("recognition_batch_three_transfers");
|
||||
using var client = await fixture.RegisterAsync("recognition_batch_transfers");
|
||||
var ledgers = await client.GetFromJsonAsync<JsonElement>("/api/ledgers");
|
||||
var ledgerId = ledgers[0].GetProperty("id").GetInt64();
|
||||
var categories = await client.GetFromJsonAsync<JsonElement>(
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using MiaoJiZhang.Domain.Entities;
|
||||
using MiaoJiZhang.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace MiaoJiZhang.Api.Tests;
|
||||
|
||||
[Collection(ApiCollection.Name)]
|
||||
public sealed class PushIntegrationTests(ApiFixture fixture)
|
||||
{
|
||||
private const string AdminKey = "test-only-admin-key-at-least-24-characters";
|
||||
|
||||
[Fact]
|
||||
public async Task Preferences_DefaultOff_AndPersistAllCategories()
|
||||
{
|
||||
using var client = await fixture.RegisterAsync("push_preferences");
|
||||
|
||||
var defaults = await client.GetFromJsonAsync<JsonElement>("/api/push/preferences");
|
||||
Assert.False(defaults.GetProperty("system").GetBoolean());
|
||||
Assert.False(defaults.GetProperty("budget").GetBoolean());
|
||||
Assert.False(defaults.GetProperty("operations").GetBoolean());
|
||||
|
||||
var update = await client.PutAsJsonAsync(
|
||||
"/api/push/preferences",
|
||||
new { system = true, budget = false, operations = true });
|
||||
update.EnsureSuccessStatusCode();
|
||||
|
||||
var persisted = await client.GetFromJsonAsync<JsonElement>("/api/push/preferences");
|
||||
Assert.True(persisted.GetProperty("system").GetBoolean());
|
||||
Assert.False(persisted.GetProperty("budget").GetBoolean());
|
||||
Assert.True(persisted.GetProperty("operations").GetBoolean());
|
||||
|
||||
await using var scope = fixture.Factory.Services.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var userId = await db.Users.Where(user => user.Username == "push_preferences")
|
||||
.Select(user => user.Id).SingleAsync();
|
||||
var preferences = await db.UserPushPreferences.Where(item => item.UserId == userId)
|
||||
.ToDictionaryAsync(item => item.Category, item => item.IsEnabled);
|
||||
Assert.Equal(3, preferences.Count);
|
||||
Assert.True(preferences[PushCategories.System]);
|
||||
Assert.False(preferences[PushCategories.Budget]);
|
||||
Assert.True(preferences[PushCategories.Operations]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeviceRegistration_RebindsInstallation_AndRotatesAnonymousUnbindToken()
|
||||
{
|
||||
using var firstUser = await fixture.RegisterAsync("push_device_first");
|
||||
using var secondUser = await fixture.RegisterAsync("push_device_second");
|
||||
await EnableSystemAsync(firstUser);
|
||||
await EnableSystemAsync(secondUser);
|
||||
var installationId = Guid.NewGuid().ToString();
|
||||
|
||||
var firstRegistration = await RegisterDeviceAsync(
|
||||
firstUser,
|
||||
installationId,
|
||||
"first-device-token");
|
||||
var firstPayload = await firstRegistration.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var deviceId = firstPayload.GetProperty("deviceId").GetInt64();
|
||||
var oldUnbindToken = firstPayload.GetProperty("unbindToken").GetString()!;
|
||||
|
||||
var secondRegistration = await RegisterDeviceAsync(
|
||||
secondUser,
|
||||
installationId,
|
||||
"second-device-token");
|
||||
var secondPayload = await secondRegistration.Content.ReadFromJsonAsync<JsonElement>();
|
||||
Assert.Equal(deviceId, secondPayload.GetProperty("deviceId").GetInt64());
|
||||
var newUnbindToken = secondPayload.GetProperty("unbindToken").GetString()!;
|
||||
Assert.NotEqual(oldUnbindToken, newUnbindToken);
|
||||
|
||||
using var anonymous = fixture.Factory.CreateClient();
|
||||
await DeleteWithUnbindToken(anonymous, installationId, oldUnbindToken);
|
||||
Assert.True(await DeviceExists(deviceId));
|
||||
|
||||
var unauthenticated = await anonymous.DeleteAsync($"/api/push/devices/{installationId}");
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, unauthenticated.StatusCode);
|
||||
Assert.True(await DeviceExists(deviceId));
|
||||
|
||||
var removed = await DeleteWithUnbindToken(anonymous, installationId, newUnbindToken);
|
||||
Assert.Equal(HttpStatusCode.NoContent, removed.StatusCode);
|
||||
Assert.False(await DeviceExists(deviceId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BudgetPush_CreatesMessagesOnlyWhenCrossingEnabledThresholds()
|
||||
{
|
||||
using var client = await fixture.RegisterAsync("push_budget_crossings");
|
||||
var preference = await client.PutAsJsonAsync(
|
||||
"/api/push/preferences",
|
||||
new { system = false, budget = true, operations = false });
|
||||
preference.EnsureSuccessStatusCode();
|
||||
var (userId, ledgerId, categoryId) = await FinanceContext(client);
|
||||
await PutBudget(client, ledgerId, categoryId, 100);
|
||||
|
||||
await CreateExpense(client, ledgerId, categoryId, 79, "budget-crossing-79");
|
||||
Assert.Equal((0, 0), await BudgetCounts(userId));
|
||||
|
||||
await CreateExpense(client, ledgerId, categoryId, 1, "budget-crossing-80");
|
||||
Assert.Equal((1, 1), await BudgetCounts(userId));
|
||||
|
||||
await CreateExpense(client, ledgerId, categoryId, 20, "budget-crossing-100");
|
||||
Assert.Equal((2, 2), await BudgetCounts(userId));
|
||||
|
||||
await using var scope = fixture.Factory.Services.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var messages = await db.PushMessages.Where(item => item.TargetUserId == userId && item.Source == "budget")
|
||||
.OrderBy(item => item.Id).ToListAsync();
|
||||
Assert.Equal("预算接近上限", messages[0].Title);
|
||||
Assert.Equal("预算已达到上限", messages[1].Title);
|
||||
Assert.All(messages, item => Assert.Equal(PushActions.Budget, item.Action));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BudgetPush_OneStepCrossingAggregatesMessage_AndDisabledPreferenceDoesNotBackfill()
|
||||
{
|
||||
using var enabled = await fixture.RegisterAsync("push_budget_one_step");
|
||||
var enabledPreference = await enabled.PutAsJsonAsync(
|
||||
"/api/push/preferences",
|
||||
new { system = false, budget = true, operations = false });
|
||||
enabledPreference.EnsureSuccessStatusCode();
|
||||
var (enabledUserId, enabledLedgerId, enabledCategoryId) = await FinanceContext(enabled);
|
||||
await PutBudget(enabled, enabledLedgerId, enabledCategoryId, 100);
|
||||
await CreateExpense(enabled, enabledLedgerId, enabledCategoryId, 100, "budget-one-step");
|
||||
Assert.Equal((2, 1), await BudgetCounts(enabledUserId));
|
||||
|
||||
using var disabled = await fixture.RegisterAsync("push_budget_disabled");
|
||||
var (disabledUserId, disabledLedgerId, disabledCategoryId) = await FinanceContext(disabled);
|
||||
await PutBudget(disabled, disabledLedgerId, disabledCategoryId, 100);
|
||||
await CreateExpense(disabled, disabledLedgerId, disabledCategoryId, 100, "budget-disabled");
|
||||
Assert.Equal((2, 0), await BudgetCounts(disabledUserId));
|
||||
|
||||
var disabledPreference = await disabled.PutAsJsonAsync(
|
||||
"/api/push/preferences",
|
||||
new { system = false, budget = true, operations = false });
|
||||
disabledPreference.EnsureSuccessStatusCode();
|
||||
await CreateExpense(disabled, disabledLedgerId, disabledCategoryId, 1, "budget-no-backfill");
|
||||
Assert.Equal((2, 0), await BudgetCounts(disabledUserId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AdminCampaign_ValidatesEstimatesSchedulesCancelsAndQueues()
|
||||
{
|
||||
using var user = await fixture.RegisterAsync("push_campaign_target");
|
||||
await EnableSystemAsync(user);
|
||||
var profile = await user.GetFromJsonAsync<JsonElement>("/api/users/me");
|
||||
var userId = profile.GetProperty("userId").GetInt64();
|
||||
await RegisterDeviceAsync(user, Guid.NewGuid().ToString(), "campaign-device-token");
|
||||
|
||||
using var admin = fixture.Factory.CreateClient();
|
||||
admin.DefaultRequestHeaders.Add("X-Admin-Key", AdminKey);
|
||||
var request = new
|
||||
{
|
||||
title = "系统维护通知",
|
||||
body = "今晚 23:00 将进行短时维护",
|
||||
category = "system",
|
||||
action = "home",
|
||||
flavor = "production",
|
||||
provider = "xiaomi",
|
||||
targetUserId = userId,
|
||||
minVersionCode = 1,
|
||||
maxVersionCode = 99999999,
|
||||
ttlSeconds = 3600,
|
||||
};
|
||||
|
||||
var estimate = await admin.PostAsJsonAsync("/api/admin/push/campaigns/estimate", request);
|
||||
estimate.EnsureSuccessStatusCode();
|
||||
Assert.Equal(
|
||||
1,
|
||||
(await estimate.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("devices").GetInt32());
|
||||
|
||||
var invalid = await admin.PostAsJsonAsync(
|
||||
"/api/admin/push/campaigns",
|
||||
new { request.title, request.body, category = "unknown", request.action, request.flavor });
|
||||
Assert.Equal(HttpStatusCode.BadRequest, invalid.StatusCode);
|
||||
|
||||
var created = await admin.PostAsJsonAsync("/api/admin/push/campaigns", request);
|
||||
created.EnsureSuccessStatusCode();
|
||||
var campaign = await created.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var campaignId = campaign.GetProperty("id").GetInt64();
|
||||
Assert.Equal(PushMessageStates.Draft, campaign.GetProperty("state").GetString());
|
||||
|
||||
var scheduled = await admin.PostAsJsonAsync(
|
||||
$"/api/admin/push/campaigns/{campaignId}/send",
|
||||
new { scheduledAt = DateTime.UtcNow.AddHours(1) });
|
||||
scheduled.EnsureSuccessStatusCode();
|
||||
Assert.Equal(
|
||||
PushMessageStates.Scheduled,
|
||||
(await scheduled.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("state").GetString());
|
||||
|
||||
var cancelled = await admin.PostAsync($"/api/admin/push/campaigns/{campaignId}/cancel", null);
|
||||
cancelled.EnsureSuccessStatusCode();
|
||||
var sendCancelled = await admin.PostAsJsonAsync(
|
||||
$"/api/admin/push/campaigns/{campaignId}/send",
|
||||
new { scheduledAt = (DateTime?)null });
|
||||
Assert.Equal(HttpStatusCode.Conflict, sendCancelled.StatusCode);
|
||||
|
||||
var immediateDraft = await admin.PostAsJsonAsync(
|
||||
"/api/admin/push/campaigns",
|
||||
new { request.title, body = "立即发送", request.category, request.action, request.flavor, request.targetUserId });
|
||||
immediateDraft.EnsureSuccessStatusCode();
|
||||
var immediateId = (await immediateDraft.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetInt64();
|
||||
var queued = await admin.PostAsJsonAsync(
|
||||
$"/api/admin/push/campaigns/{immediateId}/send",
|
||||
new { scheduledAt = (DateTime?)null });
|
||||
queued.EnsureSuccessStatusCode();
|
||||
Assert.Equal(
|
||||
PushMessageStates.Queued,
|
||||
(await queued.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("state").GetString());
|
||||
}
|
||||
|
||||
private static async Task EnableSystemAsync(HttpClient client)
|
||||
{
|
||||
var response = await client.PutAsJsonAsync(
|
||||
"/api/push/preferences",
|
||||
new { system = true, budget = false, operations = false });
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
private static async Task<HttpResponseMessage> RegisterDeviceAsync(
|
||||
HttpClient client,
|
||||
string installationId,
|
||||
string token)
|
||||
{
|
||||
var response = await client.PutAsJsonAsync(
|
||||
$"/api/push/devices/{installationId}",
|
||||
new
|
||||
{
|
||||
provider = "xiaomi",
|
||||
token,
|
||||
packageName = "com.nx.miaoji",
|
||||
flavor = "production",
|
||||
appVersion = "20260725-test",
|
||||
versionCode = 20260725,
|
||||
notificationsAllowed = true,
|
||||
});
|
||||
response.EnsureSuccessStatusCode();
|
||||
return response;
|
||||
}
|
||||
|
||||
private static async Task<HttpResponseMessage> DeleteWithUnbindToken(
|
||||
HttpClient client,
|
||||
string installationId,
|
||||
string token)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Delete, $"/api/push/devices/{installationId}");
|
||||
request.Headers.Add("X-Push-Unbind-Token", token);
|
||||
return await client.SendAsync(request);
|
||||
}
|
||||
|
||||
private async Task<bool> DeviceExists(long id)
|
||||
{
|
||||
await using var scope = fixture.Factory.Services.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
return await db.PushDevices.AnyAsync(item => item.Id == id);
|
||||
}
|
||||
|
||||
private static async Task<(long UserId, long LedgerId, long CategoryId)> FinanceContext(HttpClient client)
|
||||
{
|
||||
var profile = await client.GetFromJsonAsync<JsonElement>("/api/users/me");
|
||||
var ledgers = await client.GetFromJsonAsync<JsonElement>("/api/ledgers");
|
||||
var categories = await client.GetFromJsonAsync<JsonElement>("/api/categories?type=expense");
|
||||
return (
|
||||
profile.GetProperty("userId").GetInt64(),
|
||||
ledgers[0].GetProperty("id").GetInt64(),
|
||||
categories[0].GetProperty("id").GetInt64());
|
||||
}
|
||||
|
||||
private static async Task PutBudget(HttpClient client, long ledgerId, long categoryId, decimal amount)
|
||||
{
|
||||
var response = await client.PutAsJsonAsync(
|
||||
$"/api/budgets?year=2026&month=7&ledgerId={ledgerId}",
|
||||
new { categoryId, amount, recurring = false });
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
private static async Task CreateExpense(
|
||||
HttpClient client,
|
||||
long ledgerId,
|
||||
long categoryId,
|
||||
decimal amount,
|
||||
string clientRequestId)
|
||||
{
|
||||
var response = await client.PostAsJsonAsync(
|
||||
"/api/transactions",
|
||||
new
|
||||
{
|
||||
ledgerId,
|
||||
categoryId,
|
||||
type = "expense",
|
||||
amount,
|
||||
occurredAt = "2026-07-25T12:00:00+08:00",
|
||||
source = "manual",
|
||||
clientRequestId,
|
||||
});
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
private async Task<(int Receipts, int Messages)> BudgetCounts(long userId)
|
||||
{
|
||||
await using var scope = fixture.Factory.Services.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
return (
|
||||
await db.BudgetNotificationReceipts.CountAsync(item => item.UserId == userId),
|
||||
await db.PushMessages.CountAsync(item => item.TargetUserId == userId && item.Source == "budget"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using MiaoJiZhang.Api.Services;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace MiaoJiZhang.Api.Tests;
|
||||
|
||||
public sealed class PushProviderTests
|
||||
{
|
||||
private static readonly PushEnvelope Message = new(
|
||||
"message-1",
|
||||
"测试标题",
|
||||
"测试正文",
|
||||
"system",
|
||||
"home",
|
||||
null,
|
||||
3600);
|
||||
|
||||
[Fact]
|
||||
public async Task Xiaomi_Http200BusinessFailure_IsNotAccepted()
|
||||
{
|
||||
var factory = new StubHttpClientFactory(_ => Json(
|
||||
"""{"result":"error","code":70000003,"description":"invalid registration_id"}"""));
|
||||
var provider = Provider("xiaomi", factory, new Dictionary<string, string?>
|
||||
{
|
||||
["Push:Providers:xiaomi:production:Enabled"] = "true",
|
||||
["Push:Providers:xiaomi:production:AppSecret"] = "server-secret",
|
||||
});
|
||||
|
||||
var result = await provider.SendAsync(
|
||||
"production",
|
||||
"com.nx.miaoji",
|
||||
"invalid-token",
|
||||
Message,
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.False(result.Accepted);
|
||||
Assert.True(result.InvalidToken);
|
||||
Assert.Equal("provider_error", result.ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Xiaomi_Http200Success_IsAccepted()
|
||||
{
|
||||
var factory = new StubHttpClientFactory(_ => Json(
|
||||
"""{"result":"ok","code":0,"data":{"id":"xiaomi-message"}}"""));
|
||||
var provider = Provider("xiaomi", factory, new Dictionary<string, string?>
|
||||
{
|
||||
["Push:Providers:xiaomi:production:Enabled"] = "true",
|
||||
["Push:Providers:xiaomi:production:AppSecret"] = "server-secret",
|
||||
});
|
||||
|
||||
var result = await provider.SendAsync(
|
||||
"production",
|
||||
"com.nx.miaoji",
|
||||
"valid-token",
|
||||
Message,
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.True(result.Accepted);
|
||||
Assert.False(result.Retryable);
|
||||
Assert.False(result.InvalidToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Huawei_AccessTokens_AreCachedPerFlavor()
|
||||
{
|
||||
var authCalls = new Dictionary<string, int>();
|
||||
var sendTokens = new Dictionary<string, List<string>>();
|
||||
var factory = new StubHttpClientFactory(request =>
|
||||
{
|
||||
var path = request.RequestUri!.AbsolutePath;
|
||||
var flavor = path.Contains("production", StringComparison.Ordinal)
|
||||
? "production"
|
||||
: "internal";
|
||||
if (path.EndsWith("/auth", StringComparison.Ordinal))
|
||||
{
|
||||
authCalls[flavor] = authCalls.GetValueOrDefault(flavor) + 1;
|
||||
return Json($$"""{"access_token":"{{flavor}}-token","expires_in":3600}""");
|
||||
}
|
||||
|
||||
sendTokens.TryAdd(flavor, []);
|
||||
sendTokens[flavor].Add(request.Headers.Authorization?.Parameter ?? "");
|
||||
return Json("""{"code":"80000000","requestId":"huawei-message"}""");
|
||||
});
|
||||
var provider = Provider("huawei", factory, new Dictionary<string, string?>
|
||||
{
|
||||
["Push:Providers:huawei:production:Enabled"] = "true",
|
||||
["Push:Providers:huawei:production:AppId"] = "production-app",
|
||||
["Push:Providers:huawei:production:AppSecret"] = "production-secret",
|
||||
["Push:Providers:huawei:production:AuthUrl"] = "https://push.test/production/auth",
|
||||
["Push:Providers:huawei:production:SendUrl"] = "https://push.test/production/send",
|
||||
["Push:Providers:huawei:internal:Enabled"] = "true",
|
||||
["Push:Providers:huawei:internal:AppId"] = "internal-app",
|
||||
["Push:Providers:huawei:internal:AppSecret"] = "internal-secret",
|
||||
["Push:Providers:huawei:internal:AuthUrl"] = "https://push.test/internal/auth",
|
||||
["Push:Providers:huawei:internal:SendUrl"] = "https://push.test/internal/send",
|
||||
});
|
||||
|
||||
Assert.True((await provider.SendAsync(
|
||||
"production", "com.nx.miaoji", "token-1", Message, CancellationToken.None)).Accepted);
|
||||
Assert.True((await provider.SendAsync(
|
||||
"internal", "com.nx.miaoji.internal", "token-2", Message, CancellationToken.None)).Accepted);
|
||||
Assert.True((await provider.SendAsync(
|
||||
"production", "com.nx.miaoji", "token-3", Message, CancellationToken.None)).Accepted);
|
||||
|
||||
Assert.Equal(1, authCalls["production"]);
|
||||
Assert.Equal(1, authCalls["internal"]);
|
||||
Assert.Equal(["production-token", "production-token"], sendTokens["production"]);
|
||||
Assert.Equal(["internal-token"], sendTokens["internal"]);
|
||||
}
|
||||
|
||||
private static OfficialPushProvider Provider(
|
||||
string name,
|
||||
IHttpClientFactory factory,
|
||||
Dictionary<string, string?> values)
|
||||
{
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(values)
|
||||
.Build();
|
||||
return new OfficialPushProvider(
|
||||
name,
|
||||
configuration,
|
||||
factory,
|
||||
NullLogger<OfficialPushProvider>.Instance);
|
||||
}
|
||||
|
||||
private static HttpResponseMessage Json(string body) => new(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(body),
|
||||
};
|
||||
|
||||
private sealed class StubHttpClientFactory : IHttpClientFactory
|
||||
{
|
||||
private readonly HttpClient client;
|
||||
|
||||
public StubHttpClientFactory(Func<HttpRequestMessage, HttpResponseMessage> response)
|
||||
{
|
||||
client = new HttpClient(new StubHandler(response));
|
||||
}
|
||||
|
||||
public HttpClient CreateClient(string name) => client;
|
||||
}
|
||||
|
||||
private sealed class StubHandler(Func<HttpRequestMessage, HttpResponseMessage> response)
|
||||
: HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken) =>
|
||||
Task.FromResult(response(request));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
namespace MiaoJiZhang.Api.Contracts;
|
||||
|
||||
public record PushPreferencesResponse(bool System, bool Budget, bool Operations);
|
||||
public record UpdatePushPreferencesRequest(bool System, bool Budget, bool Operations);
|
||||
|
||||
public record RegisterPushDeviceRequest(
|
||||
string Provider,
|
||||
string Token,
|
||||
string PackageName,
|
||||
string Flavor,
|
||||
string AppVersion,
|
||||
int VersionCode,
|
||||
bool NotificationsAllowed);
|
||||
|
||||
public record PushDeviceRegistrationResponse(
|
||||
long DeviceId,
|
||||
string InstallationId,
|
||||
string Provider,
|
||||
bool Active,
|
||||
string UnbindToken);
|
||||
|
||||
public record CreatePushCampaignRequest(
|
||||
string Title,
|
||||
string Body,
|
||||
string Category,
|
||||
string Action = "none",
|
||||
string? EntityId = null,
|
||||
string Flavor = "production",
|
||||
string? Provider = null,
|
||||
int? MinVersionCode = null,
|
||||
int? MaxVersionCode = null,
|
||||
long? TargetUserId = null,
|
||||
int? TtlSeconds = null);
|
||||
|
||||
public record SchedulePushCampaignRequest(DateTime? ScheduledAt = null);
|
||||
|
||||
public record TestPushRequest(
|
||||
long DeviceId,
|
||||
string Title,
|
||||
string Body,
|
||||
string Category = "system",
|
||||
string Action = "none",
|
||||
string? EntityId = null);
|
||||
@@ -0,0 +1,326 @@
|
||||
using MiaoJiZhang.Api.Contracts;
|
||||
using MiaoJiZhang.Api.Services;
|
||||
using MiaoJiZhang.Domain.Entities;
|
||||
using MiaoJiZhang.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MiaoJiZhang.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[AdminAuth]
|
||||
[Route("api/admin/push")]
|
||||
public class AdminPushController(
|
||||
AppDbContext db,
|
||||
PushProviderRegistry providers,
|
||||
IConfiguration configuration) : ControllerBase
|
||||
{
|
||||
[HttpGet("campaigns")]
|
||||
public async Task<IActionResult> Campaigns(
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int limit = 20,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
page = Math.Max(1, page);
|
||||
limit = Math.Clamp(limit, 1, 100);
|
||||
var query = db.PushMessages.AsNoTracking().Where(message => message.Source == "admin");
|
||||
var total = await query.CountAsync(ct);
|
||||
var messages = await query.OrderByDescending(message => message.CreatedAt)
|
||||
.Skip((page - 1) * limit).Take(limit).ToListAsync(ct);
|
||||
var ids = messages.Select(message => message.Id).ToList();
|
||||
var counts = await db.PushDeliveries.Where(delivery => ids.Contains(delivery.PushMessageId))
|
||||
.GroupBy(delivery => new { delivery.PushMessageId, delivery.State })
|
||||
.Select(group => new { group.Key.PushMessageId, group.Key.State, Count = group.Count() })
|
||||
.ToListAsync(ct);
|
||||
return Ok(new
|
||||
{
|
||||
total,
|
||||
page,
|
||||
list = messages.Select(message => ToDto(message, counts
|
||||
.Where(item => item.PushMessageId == message.Id)
|
||||
.ToDictionary(item => item.State, item => item.Count))),
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("campaigns/estimate")]
|
||||
public async Task<IActionResult> Estimate(CreatePushCampaignRequest request, CancellationToken ct)
|
||||
{
|
||||
var error = Validate(request);
|
||||
if (error is not null) return BadRequest(error);
|
||||
var count = await EligibleDevices(request).CountAsync(ct);
|
||||
return Ok(new { devices = count });
|
||||
}
|
||||
|
||||
[HttpPost("campaigns")]
|
||||
public async Task<IActionResult> Create(CreatePushCampaignRequest request, CancellationToken ct)
|
||||
{
|
||||
var error = Validate(request);
|
||||
if (error is not null) return BadRequest(error);
|
||||
if (request.TargetUserId.HasValue &&
|
||||
!await db.Users.AnyAsync(user => user.Id == request.TargetUserId.Value, ct))
|
||||
return BadRequest(new ApiError("PUSH_TARGET_INVALID", "目标用户不存在"));
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var message = Map(request, new PushMessage
|
||||
{
|
||||
PublicId = Guid.NewGuid().ToString(),
|
||||
Source = "admin",
|
||||
State = PushMessageStates.Draft,
|
||||
CreatedAt = now,
|
||||
}, now);
|
||||
db.PushMessages.Add(message);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return Ok(ToDto(message, new Dictionary<string, int>()));
|
||||
}
|
||||
|
||||
[HttpPut("campaigns/{id:long}")]
|
||||
public async Task<IActionResult> Update(long id, CreatePushCampaignRequest request, CancellationToken ct)
|
||||
{
|
||||
var error = Validate(request);
|
||||
if (error is not null) return BadRequest(error);
|
||||
if (request.TargetUserId.HasValue &&
|
||||
!await db.Users.AnyAsync(user => user.Id == request.TargetUserId.Value, ct))
|
||||
return BadRequest(new ApiError("PUSH_TARGET_INVALID", "目标用户不存在"));
|
||||
var message = await db.PushMessages.FirstOrDefaultAsync(item => item.Id == id && item.Source == "admin", ct);
|
||||
if (message is null) return NotFound();
|
||||
if (message.State is not (PushMessageStates.Draft or PushMessageStates.Scheduled))
|
||||
return Conflict(new ApiError("PUSH_CAMPAIGN_LOCKED", "推送已开始发送,不能再编辑"));
|
||||
Map(request, message, DateTime.UtcNow);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return Ok(ToDto(message, new Dictionary<string, int>()));
|
||||
}
|
||||
|
||||
[HttpPost("campaigns/{id:long}/send")]
|
||||
public async Task<IActionResult> Send(
|
||||
long id,
|
||||
SchedulePushCampaignRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var message = await db.PushMessages.FirstOrDefaultAsync(item => item.Id == id && item.Source == "admin", ct);
|
||||
if (message is null) return NotFound();
|
||||
if (message.State is not (PushMessageStates.Draft or PushMessageStates.Scheduled))
|
||||
return Conflict(new ApiError("PUSH_CAMPAIGN_LOCKED", "推送已开始发送或已经结束"));
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var scheduledAt = request.ScheduledAt?.ToUniversalTime();
|
||||
message.ScheduledAt = scheduledAt;
|
||||
message.State = scheduledAt.HasValue && scheduledAt.Value > now.AddSeconds(5)
|
||||
? PushMessageStates.Scheduled
|
||||
: PushMessageStates.Queued;
|
||||
message.UpdatedAt = now;
|
||||
await db.SaveChangesAsync(ct);
|
||||
return Ok(ToDto(message, new Dictionary<string, int>()));
|
||||
}
|
||||
|
||||
[HttpPost("campaigns/{id:long}/cancel")]
|
||||
public async Task<IActionResult> Cancel(long id, CancellationToken ct)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var cancelled = await db.PushMessages
|
||||
.Where(message => message.Id == id && message.Source == "admin" &&
|
||||
(message.State == PushMessageStates.Draft ||
|
||||
message.State == PushMessageStates.Scheduled ||
|
||||
message.State == PushMessageStates.Queued) &&
|
||||
message.StartedAt == null)
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(message => message.State, PushMessageStates.Cancelled)
|
||||
.SetProperty(message => message.CancelledAt, now)
|
||||
.SetProperty(message => message.UpdatedAt, now), ct);
|
||||
if (cancelled != 1)
|
||||
{
|
||||
var exists = await db.PushMessages.AnyAsync(
|
||||
message => message.Id == id && message.Source == "admin", ct);
|
||||
return exists
|
||||
? Conflict(new ApiError("PUSH_CAMPAIGN_LOCKED", "推送已经开始,不能取消"))
|
||||
: NotFound();
|
||||
}
|
||||
var message = await db.PushMessages.AsNoTracking().FirstAsync(item => item.Id == id, ct);
|
||||
return Ok(ToDto(message, new Dictionary<string, int>()));
|
||||
}
|
||||
|
||||
[HttpGet("devices")]
|
||||
public async Task<IActionResult> Devices(
|
||||
[FromQuery] string? search = null,
|
||||
[FromQuery] int limit = 50,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
limit = Math.Clamp(limit, 1, 100);
|
||||
var query = db.PushDevices.AsNoTracking().Include(device => device.User).AsQueryable();
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
var term = search.Trim();
|
||||
query = query.Where(device => device.User.Username.Contains(term) ||
|
||||
device.InstallationId.Contains(term));
|
||||
}
|
||||
var devices = await query.OrderByDescending(device => device.LastSeenAt).Take(limit).ToListAsync(ct);
|
||||
return Ok(devices.Select(device => new
|
||||
{
|
||||
device.Id,
|
||||
device.UserId,
|
||||
device.User.Username,
|
||||
device.Provider,
|
||||
device.PackageName,
|
||||
device.Flavor,
|
||||
device.AppVersion,
|
||||
device.VersionCode,
|
||||
device.NotificationsAllowed,
|
||||
device.IsActive,
|
||||
device.DisabledReason,
|
||||
tokenSuffix = device.TokenHash[^Math.Min(8, device.TokenHash.Length)..],
|
||||
device.LastSeenAt,
|
||||
}));
|
||||
}
|
||||
|
||||
[HttpPost("test")]
|
||||
public async Task<IActionResult> Test(TestPushRequest request, CancellationToken ct)
|
||||
{
|
||||
if (request.Title.Trim().Length is < 1 or > 80 || request.Body.Trim().Length is < 1 or > 240 ||
|
||||
!PushCategories.All.Contains(request.Category) || !PushActions.All.Contains(request.Action))
|
||||
return BadRequest(new ApiError("PUSH_MESSAGE_INVALID", "测试推送内容或分类无效"));
|
||||
var device = await db.PushDevices.FirstOrDefaultAsync(item => item.Id == request.DeviceId, ct);
|
||||
if (device is null || !device.IsActive || !device.NotificationsAllowed)
|
||||
return BadRequest(new ApiError("PUSH_DEVICE_INACTIVE", "测试设备不存在或当前不可投递"));
|
||||
var now = DateTime.UtcNow;
|
||||
var message = new PushMessage
|
||||
{
|
||||
PublicId = Guid.NewGuid().ToString(),
|
||||
Source = "admin",
|
||||
State = PushMessageStates.Queued,
|
||||
Category = request.Category.ToLowerInvariant(),
|
||||
Title = request.Title.Trim(),
|
||||
Body = request.Body.Trim(),
|
||||
Action = request.Action.ToLowerInvariant(),
|
||||
EntityId = request.EntityId?.Trim(),
|
||||
TargetUserId = device.UserId,
|
||||
Flavor = device.Flavor,
|
||||
ProviderFilter = device.Provider,
|
||||
TtlSeconds = DefaultTtl(request.Category),
|
||||
IsTest = true,
|
||||
TestDeviceId = device.Id,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now,
|
||||
};
|
||||
db.PushMessages.Add(message);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return Ok(new { message.Id, message.PublicId, message.State });
|
||||
}
|
||||
|
||||
[HttpGet("health")]
|
||||
public IActionResult Health()
|
||||
{
|
||||
var flavors = new[] { "production", "internal" };
|
||||
return Ok(new
|
||||
{
|
||||
enabled = configuration.GetValue<bool>("Push:Enabled"),
|
||||
tokenEncryptionConfigured = !string.IsNullOrWhiteSpace(configuration["Push:TokenEncryptionKey"]),
|
||||
providers = providers.All.Select(provider => new
|
||||
{
|
||||
provider = provider.Provider,
|
||||
environments = flavors.Select(flavor => new
|
||||
{
|
||||
flavor,
|
||||
enabled = provider.IsEnabled(flavor),
|
||||
errors = provider.ConfigurationErrors(flavor),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
private IQueryable<PushDevice> EligibleDevices(CreatePushCampaignRequest request)
|
||||
{
|
||||
var category = request.Category.Trim().ToLowerInvariant();
|
||||
var query = db.PushDevices.Where(device =>
|
||||
device.IsActive && device.NotificationsAllowed &&
|
||||
!device.User.IsBanned && device.User.AccountClosureScheduledAt == null &&
|
||||
db.UserPushPreferences.Any(preference => preference.UserId == device.UserId &&
|
||||
preference.Category == category && preference.IsEnabled) &&
|
||||
device.Flavor == request.Flavor.ToLowerInvariant());
|
||||
if (request.TargetUserId.HasValue)
|
||||
query = query.Where(device => device.UserId == request.TargetUserId.Value);
|
||||
if (!string.IsNullOrWhiteSpace(request.Provider))
|
||||
query = query.Where(device => device.Provider == request.Provider.ToLowerInvariant());
|
||||
if (request.MinVersionCode.HasValue)
|
||||
query = query.Where(device => device.VersionCode >= request.MinVersionCode.Value);
|
||||
if (request.MaxVersionCode.HasValue)
|
||||
query = query.Where(device => device.VersionCode <= request.MaxVersionCode.Value);
|
||||
return query;
|
||||
}
|
||||
|
||||
private static PushMessage Map(CreatePushCampaignRequest request, PushMessage message, DateTime now)
|
||||
{
|
||||
message.Title = request.Title.Trim();
|
||||
message.Body = request.Body.Trim();
|
||||
message.Category = request.Category.Trim().ToLowerInvariant();
|
||||
message.Action = request.Action.Trim().ToLowerInvariant();
|
||||
message.EntityId = string.IsNullOrWhiteSpace(request.EntityId) ? null : request.EntityId.Trim();
|
||||
message.Flavor = request.Flavor.Trim().ToLowerInvariant();
|
||||
message.ProviderFilter = string.IsNullOrWhiteSpace(request.Provider)
|
||||
? null
|
||||
: request.Provider.Trim().ToLowerInvariant();
|
||||
message.MinVersionCode = request.MinVersionCode;
|
||||
message.MaxVersionCode = request.MaxVersionCode;
|
||||
message.TargetUserId = request.TargetUserId;
|
||||
message.TtlSeconds = request.TtlSeconds ?? DefaultTtl(message.Category);
|
||||
message.UpdatedAt = now;
|
||||
return message;
|
||||
}
|
||||
|
||||
private static ApiError? Validate(CreatePushCampaignRequest request)
|
||||
{
|
||||
if (request.Title.Trim().Length is < 1 or > 80)
|
||||
return new ApiError("PUSH_TITLE_INVALID", "标题长度必须在 1 到 80 个字符之间");
|
||||
if (request.Body.Trim().Length is < 1 or > 240)
|
||||
return new ApiError("PUSH_BODY_INVALID", "正文长度必须在 1 到 240 个字符之间");
|
||||
if (!PushCategories.All.Contains(request.Category))
|
||||
return new ApiError("PUSH_CATEGORY_INVALID", "推送分类无效");
|
||||
if (!PushActions.All.Contains(request.Action))
|
||||
return new ApiError("PUSH_ACTION_INVALID", "点击动作无效");
|
||||
if (request.Flavor is not ("production" or "internal"))
|
||||
return new ApiError("PUSH_FLAVOR_INVALID", "推送环境无效");
|
||||
if (!string.IsNullOrWhiteSpace(request.Provider) && !PushProviders.All.Contains(request.Provider))
|
||||
return new ApiError("PUSH_PROVIDER_INVALID", "推送厂商无效");
|
||||
if (request.MinVersionCode is < 1 || request.MaxVersionCode is < 1 ||
|
||||
request.MinVersionCode > request.MaxVersionCode)
|
||||
return new ApiError("PUSH_VERSION_RANGE_INVALID", "版本号范围无效");
|
||||
if (request.TtlSeconds.HasValue && request.TtlSeconds is < 60 or > 604800)
|
||||
return new ApiError("PUSH_TTL_INVALID", "消息有效期必须在 60 秒到 7 天之间");
|
||||
return null;
|
||||
}
|
||||
|
||||
private static int DefaultTtl(string category) => category.ToLowerInvariant() switch
|
||||
{
|
||||
PushCategories.System => 72 * 3600,
|
||||
_ => 24 * 3600,
|
||||
};
|
||||
|
||||
private static object ToDto(PushMessage message, IReadOnlyDictionary<string, int> counts) => new
|
||||
{
|
||||
message.Id,
|
||||
message.PublicId,
|
||||
message.State,
|
||||
message.Title,
|
||||
message.Body,
|
||||
message.Category,
|
||||
message.Action,
|
||||
message.EntityId,
|
||||
message.Flavor,
|
||||
provider = message.ProviderFilter,
|
||||
message.MinVersionCode,
|
||||
message.MaxVersionCode,
|
||||
message.TargetUserId,
|
||||
message.TtlSeconds,
|
||||
message.ScheduledAt,
|
||||
message.CreatedAt,
|
||||
message.StartedAt,
|
||||
message.CompletedAt,
|
||||
message.CancelledAt,
|
||||
deliveries = new
|
||||
{
|
||||
queued = counts.GetValueOrDefault(PushDeliveryStates.Queued),
|
||||
sending = counts.GetValueOrDefault(PushDeliveryStates.Sending),
|
||||
accepted = counts.GetValueOrDefault(PushDeliveryStates.Accepted),
|
||||
failed = counts.GetValueOrDefault(PushDeliveryStates.Failed),
|
||||
skipped = counts.GetValueOrDefault(PushDeliveryStates.Skipped),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
using System.Security.Claims;
|
||||
using MiaoJiZhang.Api.Contracts;
|
||||
using MiaoJiZhang.Api.Services;
|
||||
using MiaoJiZhang.Domain.Entities;
|
||||
using MiaoJiZhang.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MiaoJiZhang.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/push")]
|
||||
public class PushController(AppDbContext db, PushTokenProtector tokenProtector) : ControllerBase
|
||||
{
|
||||
private long Uid => long.Parse(
|
||||
User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub")!);
|
||||
|
||||
[HttpGet("preferences")]
|
||||
public async Task<ActionResult<PushPreferencesResponse>> Preferences(CancellationToken ct)
|
||||
{
|
||||
var enabled = await db.UserPushPreferences
|
||||
.Where(item => item.UserId == Uid && item.IsEnabled)
|
||||
.Select(item => item.Category)
|
||||
.ToListAsync(ct);
|
||||
return Ok(new PushPreferencesResponse(
|
||||
enabled.Contains(PushCategories.System),
|
||||
enabled.Contains(PushCategories.Budget),
|
||||
enabled.Contains(PushCategories.Operations)));
|
||||
}
|
||||
|
||||
[HttpPut("preferences")]
|
||||
public async Task<ActionResult<PushPreferencesResponse>> UpdatePreferences(
|
||||
UpdatePushPreferencesRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var desired = new Dictionary<string, bool>
|
||||
{
|
||||
[PushCategories.System] = request.System,
|
||||
[PushCategories.Budget] = request.Budget,
|
||||
[PushCategories.Operations] = request.Operations,
|
||||
};
|
||||
var existing = await db.UserPushPreferences
|
||||
.Where(item => item.UserId == Uid)
|
||||
.ToDictionaryAsync(item => item.Category, ct);
|
||||
var now = DateTime.UtcNow;
|
||||
foreach (var (category, enabled) in desired)
|
||||
{
|
||||
if (!existing.TryGetValue(category, out var preference))
|
||||
{
|
||||
preference = new UserPushPreference
|
||||
{
|
||||
UserId = Uid,
|
||||
Category = category,
|
||||
};
|
||||
db.UserPushPreferences.Add(preference);
|
||||
}
|
||||
preference.IsEnabled = enabled;
|
||||
preference.UpdatedAt = now;
|
||||
}
|
||||
if (!desired.Values.Any(value => value))
|
||||
{
|
||||
var devices = await db.PushDevices
|
||||
.Where(device => device.UserId == Uid && device.IsActive)
|
||||
.ToListAsync(ct);
|
||||
foreach (var device in devices)
|
||||
{
|
||||
device.IsActive = false;
|
||||
device.DisabledReason = "all_categories_disabled";
|
||||
device.UpdatedAt = now;
|
||||
}
|
||||
}
|
||||
await db.SaveChangesAsync(ct);
|
||||
return Ok(new PushPreferencesResponse(request.System, request.Budget, request.Operations));
|
||||
}
|
||||
|
||||
[HttpPut("devices/{installationId}")]
|
||||
public async Task<ActionResult<PushDeviceRegistrationResponse>> RegisterDevice(
|
||||
string installationId,
|
||||
RegisterPushDeviceRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var validation = ValidateDevice(installationId, request);
|
||||
if (validation is not null) return validation;
|
||||
if (!tokenProtector.IsConfigured)
|
||||
return StatusCode(StatusCodes.Status503ServiceUnavailable,
|
||||
new ApiError("PUSH_NOT_CONFIGURED", "推送服务尚未完成安全配置"));
|
||||
|
||||
var provider = request.Provider.Trim().ToLowerInvariant();
|
||||
var token = request.Token.Trim();
|
||||
var tokenHash = PushTokenProtector.Hash(token);
|
||||
var duplicate = await db.PushDevices.FirstOrDefaultAsync(device =>
|
||||
device.Provider == provider &&
|
||||
device.PackageName == request.PackageName &&
|
||||
device.TokenHash == tokenHash &&
|
||||
device.InstallationId != installationId, ct);
|
||||
if (duplicate is not null) db.PushDevices.Remove(duplicate);
|
||||
|
||||
var device = await db.PushDevices.FirstOrDefaultAsync(item =>
|
||||
item.PackageName == request.PackageName &&
|
||||
item.InstallationId == installationId, ct);
|
||||
var now = DateTime.UtcNow;
|
||||
if (device is null)
|
||||
{
|
||||
device = new PushDevice
|
||||
{
|
||||
UserId = Uid,
|
||||
InstallationId = installationId,
|
||||
PackageName = request.PackageName,
|
||||
CreatedAt = now,
|
||||
};
|
||||
db.PushDevices.Add(device);
|
||||
}
|
||||
|
||||
var unbindToken = PushTokenProtector.CreateUnbindToken();
|
||||
device.UserId = Uid;
|
||||
device.Provider = provider;
|
||||
device.TokenCiphertext = tokenProtector.Protect(token);
|
||||
device.TokenHash = tokenHash;
|
||||
device.UnbindTokenHash = PushTokenProtector.Hash(unbindToken);
|
||||
device.Flavor = request.Flavor.Trim().ToLowerInvariant();
|
||||
device.AppVersion = request.AppVersion.Trim();
|
||||
device.VersionCode = request.VersionCode;
|
||||
device.NotificationsAllowed = request.NotificationsAllowed;
|
||||
device.IsActive = request.NotificationsAllowed;
|
||||
device.DisabledReason = request.NotificationsAllowed ? null : "notification_permission_denied";
|
||||
device.UpdatedAt = now;
|
||||
device.LastSeenAt = now;
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
return Ok(new PushDeviceRegistrationResponse(
|
||||
device.Id,
|
||||
device.InstallationId,
|
||||
device.Provider,
|
||||
device.IsActive,
|
||||
unbindToken));
|
||||
}
|
||||
|
||||
[HttpDelete("devices/{installationId}")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> UnregisterDevice(
|
||||
string installationId,
|
||||
[FromHeader(Name = "X-Push-Unbind-Token")] string? unbindToken,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var userIdValue = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub");
|
||||
var hasUser = long.TryParse(userIdValue, out var userId);
|
||||
var unbindHash = string.IsNullOrWhiteSpace(unbindToken)
|
||||
? null
|
||||
: PushTokenProtector.Hash(unbindToken);
|
||||
var device = await db.PushDevices.FirstOrDefaultAsync(item =>
|
||||
item.InstallationId == installationId &&
|
||||
((hasUser && item.UserId == userId) ||
|
||||
(unbindHash != null && item.UnbindTokenHash == unbindHash)), ct);
|
||||
if (device is null)
|
||||
return hasUser || unbindHash is not null ? NoContent() : Unauthorized();
|
||||
|
||||
db.PushDevices.Remove(device);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
private ActionResult? ValidateDevice(string installationId, RegisterPushDeviceRequest request)
|
||||
{
|
||||
if (!Guid.TryParse(installationId, out _))
|
||||
return BadRequest(new ApiError("INSTALLATION_ID_INVALID", "设备安装标识无效"));
|
||||
if (!PushProviders.All.Contains(request.Provider))
|
||||
return BadRequest(new ApiError("PUSH_PROVIDER_INVALID", "不支持该设备推送厂商"));
|
||||
if (string.IsNullOrWhiteSpace(request.Token) || request.Token.Length > 4096)
|
||||
return BadRequest(new ApiError("PUSH_TOKEN_INVALID", "推送令牌无效"));
|
||||
var expectedFlavor = request.PackageName switch
|
||||
{
|
||||
"com.nx.miaoji" => "production",
|
||||
"com.nx.miaoji.internal" => "internal",
|
||||
_ => null,
|
||||
};
|
||||
if (expectedFlavor is null || !string.Equals(expectedFlavor, request.Flavor, StringComparison.OrdinalIgnoreCase))
|
||||
return BadRequest(new ApiError("PUSH_PACKAGE_INVALID", "推送包名或环境无效"));
|
||||
if (request.AppVersion.Length is < 1 or > 32 || request.VersionCode < 1)
|
||||
return BadRequest(new ApiError("APP_VERSION_INVALID", "应用版本无效"));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,10 @@ namespace MiaoJiZhang.Api.Controllers;
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/transactions")]
|
||||
public class TransactionsController(AppDbContext db, LedgerResolver ledgers) : ControllerBase
|
||||
public class TransactionsController(
|
||||
AppDbContext db,
|
||||
LedgerResolver ledgers,
|
||||
BudgetPushService budgetPush) : ControllerBase
|
||||
{
|
||||
private long Uid => long.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub")!);
|
||||
|
||||
@@ -68,13 +71,22 @@ public class TransactionsController(AppDbContext db, LedgerResolver ledgers) : C
|
||||
UpdatedAt = DateTime.UtcNow,
|
||||
};
|
||||
db.Transactions.Add(tx);
|
||||
await using var writeScope = await db.Database.BeginTransactionAsync();
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync();
|
||||
if (tx.Type == TransactionType.Expense)
|
||||
{
|
||||
await budgetPush.EvaluateAsync(Uid,
|
||||
[new BudgetExpenseChange(tx.LedgerId, tx.CategoryId, tx.OccurredAt, tx.Amount)]);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
await writeScope.CommitAsync();
|
||||
return Ok(ToDto(tx, cat));
|
||||
}
|
||||
catch (DbUpdateException) when (clientRequestId is not null)
|
||||
{
|
||||
await writeScope.RollbackAsync();
|
||||
// Another channel may have committed the same recognition candidate
|
||||
// after the initial lookup. Resolve the unique-key race as idempotent success.
|
||||
db.Entry(tx).State = EntityState.Detached;
|
||||
@@ -161,6 +173,17 @@ public class TransactionsController(AppDbContext db, LedgerResolver ledgers) : C
|
||||
}
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync(ct);
|
||||
var expenseChanges = mapped
|
||||
.Where(item => !existing.ContainsKey(item.Transaction.ClientRequestId ?? "") &&
|
||||
item.Transaction.Type == TransactionType.Expense)
|
||||
.Select(item => new BudgetExpenseChange(
|
||||
item.Transaction.LedgerId,
|
||||
item.Transaction.CategoryId,
|
||||
item.Transaction.OccurredAt,
|
||||
item.Transaction.Amount))
|
||||
.ToList();
|
||||
await budgetPush.EvaluateAsync(Uid, expenseChanges, ct);
|
||||
await db.SaveChangesAsync(ct);
|
||||
await transactionScope.CommitAsync(ct);
|
||||
return Ok(mapped.Select(item => new RecognitionBatchTransactionDto(
|
||||
@@ -223,6 +246,10 @@ public class TransactionsController(AppDbContext db, LedgerResolver ledgers) : C
|
||||
message = "账单已在其他设备修改,请选择保留本地或云端版本",
|
||||
server = ToDto(tx, await db.Categories.FindAsync(tx.CategoryId) ?? category),
|
||||
});
|
||||
var expenseChanges = new List<BudgetExpenseChange>();
|
||||
if (tx.Type == TransactionType.Expense)
|
||||
expenseChanges.Add(new BudgetExpenseChange(
|
||||
tx.LedgerId, tx.CategoryId, tx.OccurredAt, -tx.Amount));
|
||||
tx.LedgerId = ledgerId.Value;
|
||||
tx.CategoryId = category.Id;
|
||||
tx.Category = category;
|
||||
@@ -232,7 +259,14 @@ public class TransactionsController(AppDbContext db, LedgerResolver ledgers) : C
|
||||
tx.PaymentMethod = req.PaymentMethod?.Trim();
|
||||
tx.OccurredAt = NormalizeOccurredAt(req.OccurredAt);
|
||||
tx.UpdatedAt = DateTime.UtcNow;
|
||||
if (tx.Type == TransactionType.Expense)
|
||||
expenseChanges.Add(new BudgetExpenseChange(
|
||||
tx.LedgerId, tx.CategoryId, tx.OccurredAt, tx.Amount));
|
||||
await using var writeScope = await db.Database.BeginTransactionAsync();
|
||||
await db.SaveChangesAsync();
|
||||
await budgetPush.EvaluateAsync(Uid, expenseChanges);
|
||||
await db.SaveChangesAsync();
|
||||
await writeScope.CommitAsync();
|
||||
return Ok(ToDto(tx, category));
|
||||
}
|
||||
|
||||
@@ -291,7 +325,15 @@ public class TransactionsController(AppDbContext db, LedgerResolver ledgers) : C
|
||||
}); tx.IsDeleted = false;
|
||||
tx.DeletedAt = null;
|
||||
tx.UpdatedAt = DateTime.UtcNow;
|
||||
await using var writeScope = await db.Database.BeginTransactionAsync();
|
||||
await db.SaveChangesAsync();
|
||||
if (tx.Type == TransactionType.Expense)
|
||||
{
|
||||
await budgetPush.EvaluateAsync(Uid,
|
||||
[new BudgetExpenseChange(tx.LedgerId, tx.CategoryId, tx.OccurredAt, tx.Amount)]);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
await writeScope.CommitAsync();
|
||||
return Ok(ToDto(tx, tx.Category));
|
||||
}
|
||||
|
||||
|
||||
@@ -9,9 +9,10 @@ using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Services.AddControllers();
|
||||
builder.Services.AddOpenApi();
|
||||
builder.Services.AddRateLimiter(options =>
|
||||
builder.Services.AddControllers();
|
||||
builder.Services.AddOpenApi();
|
||||
var authPermitLimit = Math.Max(1, builder.Configuration.GetValue("RateLimiting:AuthPermitLimit", 10));
|
||||
builder.Services.AddRateLimiter(options =>
|
||||
{
|
||||
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||
options.AddPolicy("auth", context =>
|
||||
@@ -19,7 +20,7 @@ builder.Services.AddRateLimiter(options =>
|
||||
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
|
||||
_ => new FixedWindowRateLimiterOptions
|
||||
{
|
||||
PermitLimit = 10,
|
||||
PermitLimit = authPermitLimit,
|
||||
Window = TimeSpan.FromMinutes(1),
|
||||
QueueLimit = 0,
|
||||
}));
|
||||
@@ -50,13 +51,32 @@ builder.Services.AddScoped<ReplyService>();
|
||||
builder.Services.AddScoped<LedgerResolver>();
|
||||
builder.Services.AddScoped<AiPermissionService>();
|
||||
builder.Services.AddScoped<AiChatQuotaService>();
|
||||
builder.Services.AddScoped<AiPermissionFilter>();
|
||||
builder.Services.AddHttpClient("LlmClient");
|
||||
builder.Services.AddScoped<BudgetPushService>();
|
||||
builder.Services.AddSingleton<PushTokenProtector>();
|
||||
builder.Services.AddScoped<AiPermissionFilter>();
|
||||
builder.Services.AddHttpClient("LlmClient");
|
||||
builder.Services.AddHttpClient("PushProviders", client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromSeconds(20);
|
||||
});
|
||||
foreach (var provider in new[]
|
||||
{
|
||||
"huawei", "honor", "xiaomi", "oppo", "vivo", "meizu",
|
||||
})
|
||||
{
|
||||
builder.Services.AddSingleton<IPushProvider>(services => new OfficialPushProvider(
|
||||
provider,
|
||||
services.GetRequiredService<IConfiguration>(),
|
||||
services.GetRequiredService<IHttpClientFactory>(),
|
||||
services.GetRequiredService<ILogger<OfficialPushProvider>>()));
|
||||
}
|
||||
builder.Services.AddSingleton<PushProviderRegistry>();
|
||||
builder.Services.AddSingleton<OpenAiVisionClient>();
|
||||
builder.Services.AddSingleton<ILlmClient>(sp => sp.GetRequiredService<OpenAiVisionClient>());
|
||||
builder.Services.AddHostedService<RecycleBinCleanupService>();
|
||||
builder.Services.AddScoped<AccountDataEraser>();
|
||||
builder.Services.AddHostedService<AccountClosureCleanupService>();
|
||||
builder.Services.AddHostedService<AccountClosureCleanupService>();
|
||||
builder.Services.AddHostedService<PushDispatchService>();
|
||||
|
||||
var conn = builder.Configuration.GetConnectionString("Default");
|
||||
if (string.IsNullOrWhiteSpace(conn))
|
||||
@@ -64,9 +84,19 @@ if (string.IsNullOrWhiteSpace(conn))
|
||||
var jwtSecret = builder.Configuration["Jwt:Secret"];
|
||||
if (string.IsNullOrWhiteSpace(jwtSecret) || jwtSecret.Length < 32)
|
||||
throw new InvalidOperationException("必须通过 Jwt__Secret 配置至少 32 位的 JWT 密钥");
|
||||
var adminKey = builder.Configuration["Admin:Key"];
|
||||
var adminKey = builder.Configuration["Admin:Key"];
|
||||
if (string.IsNullOrWhiteSpace(adminKey) || adminKey.Length < 24)
|
||||
throw new InvalidOperationException("必须通过 Admin__Key 配置至少 24 位的管理密钥");
|
||||
throw new InvalidOperationException("必须通过 Admin__Key 配置至少 24 位的管理密钥");
|
||||
if (builder.Configuration.GetValue<bool>("Push:Enabled"))
|
||||
{
|
||||
var pushKey = builder.Configuration["Push:TokenEncryptionKey"];
|
||||
byte[]? key = null;
|
||||
try { key = string.IsNullOrWhiteSpace(pushKey) ? null : Convert.FromBase64String(pushKey); }
|
||||
catch (FormatException) { }
|
||||
if (key?.Length != 32)
|
||||
throw new InvalidOperationException(
|
||||
"启用推送时必须通过 Push__TokenEncryptionKey 配置 base64 编码的 32 字节密钥");
|
||||
}
|
||||
builder.Services.AddDbContext<AppDbContext>(o =>
|
||||
o.UseMySql(conn, ServerVersion.AutoDetect(conn)));
|
||||
|
||||
|
||||
@@ -9,6 +9,18 @@ public class AccountDataEraser(AppDbContext db)
|
||||
{
|
||||
await using var transaction =
|
||||
await db.Database.BeginTransactionAsync(ct);
|
||||
await db.PushMessages
|
||||
.Where(message => message.TargetUserId == userId)
|
||||
.ExecuteDeleteAsync(ct);
|
||||
await db.PushDevices
|
||||
.Where(device => device.UserId == userId)
|
||||
.ExecuteDeleteAsync(ct);
|
||||
await db.UserPushPreferences
|
||||
.Where(preference => preference.UserId == userId)
|
||||
.ExecuteDeleteAsync(ct);
|
||||
await db.BudgetNotificationReceipts
|
||||
.Where(receipt => receipt.UserId == userId)
|
||||
.ExecuteDeleteAsync(ct);
|
||||
await db.ChatMessages
|
||||
.Where(message => message.UserId == userId)
|
||||
.ExecuteDeleteAsync(ct);
|
||||
|
||||
@@ -14,6 +14,7 @@ public record AgentTurnResult(
|
||||
public class AgentService(
|
||||
AppDbContext db,
|
||||
ILlmClient llm,
|
||||
BudgetPushService budgetPush,
|
||||
ILogger<AgentService> logger)
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions =
|
||||
@@ -330,12 +331,23 @@ public class AgentService(
|
||||
}
|
||||
|
||||
db.Transactions.AddRange(pending);
|
||||
await using var writeScope = await db.Database.BeginTransactionAsync(ct);
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync(ct);
|
||||
await budgetPush.EvaluateAsync(userId, pending
|
||||
.Where(transaction => transaction.Type == TransactionType.Expense)
|
||||
.Select(transaction => new BudgetExpenseChange(
|
||||
transaction.LedgerId,
|
||||
transaction.CategoryId,
|
||||
transaction.OccurredAt,
|
||||
transaction.Amount)), ct);
|
||||
await db.SaveChangesAsync(ct);
|
||||
await writeScope.CommitAsync(ct);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await writeScope.RollbackAsync(ct);
|
||||
foreach (var transaction in pending)
|
||||
db.Entry(transaction).State = EntityState.Detached;
|
||||
throw;
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
using MiaoJiZhang.Domain.Entities;
|
||||
using MiaoJiZhang.Domain.Enums;
|
||||
using MiaoJiZhang.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MiaoJiZhang.Api.Services;
|
||||
|
||||
public sealed record BudgetExpenseChange(
|
||||
long LedgerId,
|
||||
long CategoryId,
|
||||
DateTime OccurredAt,
|
||||
decimal Delta);
|
||||
|
||||
public sealed class BudgetPushService(AppDbContext db)
|
||||
{
|
||||
private static readonly int[] Thresholds = [80, 100];
|
||||
|
||||
public async Task EvaluateAsync(
|
||||
long userId,
|
||||
IEnumerable<BudgetExpenseChange> rawChanges,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var changes = rawChanges
|
||||
.Where(change => change.Delta != 0)
|
||||
.Select(change => new
|
||||
{
|
||||
Change = change,
|
||||
Local = ChinaClock.ToLocal(change.OccurredAt),
|
||||
})
|
||||
.Select(item => new ChangeWithPeriod(
|
||||
item.Change.LedgerId,
|
||||
item.Change.CategoryId,
|
||||
item.Local.Year * 100 + item.Local.Month,
|
||||
item.Change.Delta))
|
||||
.ToList();
|
||||
if (changes.Count == 0) return;
|
||||
|
||||
var crossed = new List<CrossedBudget>();
|
||||
foreach (var group in changes.GroupBy(change => new { change.LedgerId, change.Period }))
|
||||
{
|
||||
var period = group.Key.Period;
|
||||
var year = period / 100;
|
||||
var month = period % 100;
|
||||
if (month is < 1 or > 12) continue;
|
||||
var rows = await db.Budgets
|
||||
.Where(budget => budget.UserId == userId && budget.LedgerId == group.Key.LedgerId &&
|
||||
(budget.Period == period || budget.Period == 0) && budget.Amount > 0)
|
||||
.ToListAsync(ct);
|
||||
var budgets = rows.GroupBy(budget => budget.CategoryId)
|
||||
.Select(items => items.FirstOrDefault(item => item.Period == period) ??
|
||||
items.First(item => item.Period == 0))
|
||||
.ToList();
|
||||
if (budgets.Count == 0) continue;
|
||||
|
||||
var (start, end) = ChinaClock.MonthRangeUtc(year, month);
|
||||
var spent = await db.Transactions
|
||||
.Where(transaction => transaction.UserId == userId &&
|
||||
transaction.LedgerId == group.Key.LedgerId &&
|
||||
transaction.Type == TransactionType.Expense &&
|
||||
transaction.OccurredAt >= start && transaction.OccurredAt < end)
|
||||
.GroupBy(transaction => transaction.CategoryId)
|
||||
.Select(items => new { CategoryId = items.Key, Amount = items.Sum(item => item.Amount) })
|
||||
.ToDictionaryAsync(item => item.CategoryId, item => item.Amount, ct);
|
||||
var existing = await db.BudgetNotificationReceipts
|
||||
.Where(receipt => receipt.UserId == userId && receipt.Period == period &&
|
||||
budgets.Select(budget => budget.Id).Contains(receipt.BudgetId))
|
||||
.Select(receipt => new { receipt.BudgetId, receipt.Threshold })
|
||||
.ToListAsync(ct);
|
||||
var existingKeys = existing.Select(item => (item.BudgetId, item.Threshold)).ToHashSet();
|
||||
|
||||
foreach (var budget in budgets)
|
||||
{
|
||||
var currentSpent = budget.CategoryId.HasValue
|
||||
? spent.GetValueOrDefault(budget.CategoryId.Value)
|
||||
: spent.Values.Sum();
|
||||
var delta = budget.CategoryId.HasValue
|
||||
? group.Where(change => change.CategoryId == budget.CategoryId.Value).Sum(change => change.Delta)
|
||||
: group.Sum(change => change.Delta);
|
||||
var previousSpent = currentSpent - delta;
|
||||
var highestCrossed = 0;
|
||||
foreach (var threshold in Thresholds)
|
||||
{
|
||||
if (currentSpent * 100 < budget.Amount * threshold ||
|
||||
existingKeys.Contains((budget.Id, threshold))) continue;
|
||||
db.BudgetNotificationReceipts.Add(new BudgetNotificationReceipt
|
||||
{
|
||||
UserId = userId,
|
||||
BudgetId = budget.Id,
|
||||
Period = period,
|
||||
Threshold = threshold,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
});
|
||||
existingKeys.Add((budget.Id, threshold));
|
||||
if (delta > 0 && previousSpent * 100 < budget.Amount * threshold)
|
||||
highestCrossed = threshold;
|
||||
}
|
||||
if (highestCrossed > 0)
|
||||
crossed.Add(new CrossedBudget(budget.CategoryId, highestCrossed));
|
||||
}
|
||||
}
|
||||
|
||||
if (crossed.Count == 0) return;
|
||||
var notificationsEnabled = await db.UserPushPreferences.AnyAsync(preference =>
|
||||
preference.UserId == userId && preference.Category == PushCategories.Budget &&
|
||||
preference.IsEnabled, ct);
|
||||
if (!notificationsEnabled) return;
|
||||
|
||||
var categoryIds = crossed.Where(item => item.CategoryId.HasValue)
|
||||
.Select(item => item.CategoryId!.Value).Distinct().ToList();
|
||||
var names = await db.Categories.Where(category => categoryIds.Contains(category.Id))
|
||||
.ToDictionaryAsync(category => category.Id, category => category.Name, ct);
|
||||
var details = crossed
|
||||
.OrderByDescending(item => item.Threshold)
|
||||
.ThenBy(item => item.CategoryId)
|
||||
.Select(item =>
|
||||
$"{(item.CategoryId.HasValue ? names.GetValueOrDefault(item.CategoryId.Value, "分类预算") : "总预算")}" +
|
||||
(item.Threshold >= 100 ? "已用完" : "已使用 80%"))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
var body = string.Join(";", details);
|
||||
if (body.Length > 240) body = body[..237] + "...";
|
||||
var now = DateTime.UtcNow;
|
||||
db.PushMessages.Add(new PushMessage
|
||||
{
|
||||
PublicId = Guid.NewGuid().ToString(),
|
||||
Source = "budget",
|
||||
State = PushMessageStates.Queued,
|
||||
Category = PushCategories.Budget,
|
||||
Title = crossed.Any(item => item.Threshold >= 100) ? "预算已达到上限" : "预算接近上限",
|
||||
Body = body,
|
||||
Action = PushActions.Budget,
|
||||
TargetUserId = userId,
|
||||
Flavor = "",
|
||||
TtlSeconds = 24 * 3600,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now,
|
||||
});
|
||||
}
|
||||
|
||||
private sealed record ChangeWithPeriod(long LedgerId, long CategoryId, int Period, decimal Delta);
|
||||
private sealed record CrossedBudget(long? CategoryId, int Threshold);
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
using MiaoJiZhang.Domain.Entities;
|
||||
using MiaoJiZhang.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MiaoJiZhang.Api.Services;
|
||||
|
||||
public sealed class PushDispatchService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IConfiguration configuration,
|
||||
ILogger<PushDispatchService> logger) : BackgroundService
|
||||
{
|
||||
private static readonly TimeSpan[] RetrySchedule =
|
||||
[
|
||||
TimeSpan.FromMinutes(1),
|
||||
TimeSpan.FromMinutes(5),
|
||||
TimeSpan.FromMinutes(30),
|
||||
TimeSpan.FromHours(2),
|
||||
TimeSpan.FromHours(6),
|
||||
];
|
||||
|
||||
private DateTime nextCleanupAt = DateTime.MinValue;
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(5));
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
if (configuration.GetValue<bool>("Push:Enabled"))
|
||||
{
|
||||
try
|
||||
{
|
||||
await RecoverAndFinalizeMessages(stoppingToken);
|
||||
await ExpandMessages(stoppingToken);
|
||||
await DispatchDeliveries(stoppingToken);
|
||||
if (nextCleanupAt <= DateTime.UtcNow)
|
||||
{
|
||||
await Cleanup(stoppingToken);
|
||||
nextCleanupAt = DateTime.UtcNow.AddHours(6);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(exception, "Push dispatch loop failed");
|
||||
}
|
||||
}
|
||||
await timer.WaitForNextTickAsync(stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RecoverAndFinalizeMessages(CancellationToken ct)
|
||||
{
|
||||
using var scope = scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var now = DateTime.UtcNow;
|
||||
var staleBefore = now.AddMinutes(-3);
|
||||
await db.PushMessages
|
||||
.Where(message => message.State == PushMessageStates.Sending &&
|
||||
message.StartedAt <= staleBefore &&
|
||||
!db.PushDeliveries.Any(delivery => delivery.PushMessageId == message.Id))
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(message => message.State, PushMessageStates.Queued)
|
||||
.SetProperty(message => message.StartedAt, (DateTime?)null)
|
||||
.SetProperty(message => message.UpdatedAt, now), ct);
|
||||
|
||||
var ready = await db.PushMessages
|
||||
.Where(message => message.State == PushMessageStates.Sending &&
|
||||
db.PushDeliveries.Any(delivery => delivery.PushMessageId == message.Id) &&
|
||||
!db.PushDeliveries.Any(delivery => delivery.PushMessageId == message.Id &&
|
||||
(delivery.State == PushDeliveryStates.Queued ||
|
||||
delivery.State == PushDeliveryStates.Sending)))
|
||||
.Select(message => message.Id)
|
||||
.Take(100)
|
||||
.ToListAsync(ct);
|
||||
foreach (var messageId in ready)
|
||||
await FinalizeMessage(db, messageId, ct);
|
||||
}
|
||||
|
||||
private async Task ExpandMessages(CancellationToken ct)
|
||||
{
|
||||
using var scope = scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var now = DateTime.UtcNow;
|
||||
var candidates = await db.PushMessages
|
||||
.Where(message =>
|
||||
message.State == PushMessageStates.Queued ||
|
||||
(message.State == PushMessageStates.Scheduled && message.ScheduledAt <= now))
|
||||
.OrderBy(message => message.ScheduledAt ?? message.CreatedAt)
|
||||
.Select(message => message.Id)
|
||||
.Take(20)
|
||||
.ToListAsync(ct);
|
||||
|
||||
foreach (var id in candidates)
|
||||
{
|
||||
var claimed = await db.PushMessages
|
||||
.Where(message => message.Id == id &&
|
||||
(message.State == PushMessageStates.Queued ||
|
||||
(message.State == PushMessageStates.Scheduled && message.ScheduledAt <= now)))
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(message => message.State, PushMessageStates.Sending)
|
||||
.SetProperty(message => message.StartedAt, now)
|
||||
.SetProperty(message => message.UpdatedAt, now), ct);
|
||||
if (claimed != 1) continue;
|
||||
|
||||
var message = await db.PushMessages.FirstAsync(item => item.Id == id, ct);
|
||||
var deviceQuery = db.PushDevices
|
||||
.Where(device => device.IsActive && device.NotificationsAllowed &&
|
||||
!device.User.IsBanned && device.User.AccountClosureScheduledAt == null);
|
||||
if (message.IsTest)
|
||||
{
|
||||
deviceQuery = deviceQuery.Where(device => device.Id == message.TestDeviceId);
|
||||
}
|
||||
else
|
||||
{
|
||||
deviceQuery = deviceQuery.Where(device =>
|
||||
db.UserPushPreferences.Any(preference =>
|
||||
preference.UserId == device.UserId &&
|
||||
preference.Category == message.Category &&
|
||||
preference.IsEnabled));
|
||||
if (message.TargetUserId.HasValue)
|
||||
deviceQuery = deviceQuery.Where(device => device.UserId == message.TargetUserId.Value);
|
||||
if (!string.IsNullOrWhiteSpace(message.Flavor))
|
||||
deviceQuery = deviceQuery.Where(device => device.Flavor == message.Flavor);
|
||||
if (!string.IsNullOrWhiteSpace(message.ProviderFilter))
|
||||
deviceQuery = deviceQuery.Where(device => device.Provider == message.ProviderFilter);
|
||||
if (message.MinVersionCode.HasValue)
|
||||
deviceQuery = deviceQuery.Where(device => device.VersionCode >= message.MinVersionCode.Value);
|
||||
if (message.MaxVersionCode.HasValue)
|
||||
deviceQuery = deviceQuery.Where(device => device.VersionCode <= message.MaxVersionCode.Value);
|
||||
}
|
||||
|
||||
var devices = await deviceQuery.Select(device => new
|
||||
{
|
||||
device.Id,
|
||||
device.UserId,
|
||||
device.Provider,
|
||||
}).ToListAsync(ct);
|
||||
var existing = await db.PushDeliveries
|
||||
.Where(delivery => delivery.PushMessageId == id)
|
||||
.Select(delivery => delivery.PushDeviceId)
|
||||
.ToListAsync(ct);
|
||||
var existingIds = existing.ToHashSet();
|
||||
foreach (var device in devices.Where(device => !existingIds.Contains(device.Id)))
|
||||
{
|
||||
db.PushDeliveries.Add(new PushDelivery
|
||||
{
|
||||
PushMessageId = id,
|
||||
PushDeviceId = device.Id,
|
||||
UserId = device.UserId,
|
||||
Provider = device.Provider,
|
||||
State = PushDeliveryStates.Queued,
|
||||
NextAttemptAt = now,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now,
|
||||
});
|
||||
}
|
||||
if (devices.Count == 0)
|
||||
{
|
||||
message.State = PushMessageStates.Completed;
|
||||
message.CompletedAt = now;
|
||||
message.UpdatedAt = now;
|
||||
}
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DispatchDeliveries(CancellationToken ct)
|
||||
{
|
||||
using var scope = scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var registry = scope.ServiceProvider.GetRequiredService<PushProviderRegistry>();
|
||||
var tokenProtector = scope.ServiceProvider.GetRequiredService<PushTokenProtector>();
|
||||
var now = DateTime.UtcNow;
|
||||
var candidates = await db.PushDeliveries
|
||||
.Where(delivery =>
|
||||
(delivery.State == PushDeliveryStates.Queued && delivery.NextAttemptAt <= now) ||
|
||||
(delivery.State == PushDeliveryStates.Sending && delivery.LeaseExpiresAt <= now))
|
||||
.OrderBy(delivery => delivery.NextAttemptAt)
|
||||
.Select(delivery => delivery.Id)
|
||||
.Take(50)
|
||||
.ToListAsync(ct);
|
||||
var affectedMessages = new HashSet<long>();
|
||||
|
||||
foreach (var id in candidates)
|
||||
{
|
||||
var claimNow = DateTime.UtcNow;
|
||||
var leaseId = Guid.NewGuid().ToString();
|
||||
var leaseUntil = claimNow.AddMinutes(2);
|
||||
var claimed = await db.PushDeliveries
|
||||
.Where(delivery => delivery.Id == id &&
|
||||
((delivery.State == PushDeliveryStates.Queued && delivery.NextAttemptAt <= claimNow) ||
|
||||
(delivery.State == PushDeliveryStates.Sending && delivery.LeaseExpiresAt <= claimNow)))
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(delivery => delivery.State, PushDeliveryStates.Sending)
|
||||
.SetProperty(delivery => delivery.LeaseId, leaseId)
|
||||
.SetProperty(delivery => delivery.LeaseExpiresAt, leaseUntil)
|
||||
.SetProperty(delivery => delivery.AttemptCount, delivery => delivery.AttemptCount + 1)
|
||||
.SetProperty(delivery => delivery.UpdatedAt, claimNow), ct);
|
||||
if (claimed != 1) continue;
|
||||
|
||||
var delivery = await db.PushDeliveries
|
||||
.Include(item => item.PushMessage)
|
||||
.Include(item => item.PushDevice).ThenInclude(device => device.User)
|
||||
.FirstAsync(item => item.Id == id, ct);
|
||||
affectedMessages.Add(delivery.PushMessageId);
|
||||
await SendOne(db, registry, tokenProtector, delivery, ct);
|
||||
}
|
||||
|
||||
foreach (var messageId in affectedMessages)
|
||||
await FinalizeMessage(db, messageId, ct);
|
||||
}
|
||||
|
||||
private static async Task SendOne(
|
||||
AppDbContext db,
|
||||
PushProviderRegistry registry,
|
||||
PushTokenProtector tokenProtector,
|
||||
PushDelivery delivery,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var message = delivery.PushMessage;
|
||||
var device = delivery.PushDevice;
|
||||
var expiresAt = (message.ScheduledAt ?? message.CreatedAt).AddSeconds(message.TtlSeconds);
|
||||
if (expiresAt <= now)
|
||||
{
|
||||
Skip(delivery, "message_expired", now);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return;
|
||||
}
|
||||
if (!device.IsActive || !device.NotificationsAllowed ||
|
||||
device.User.IsBanned || device.User.AccountClosureScheduledAt.HasValue)
|
||||
{
|
||||
Skip(delivery, "device_or_account_inactive", now);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return;
|
||||
}
|
||||
if (!message.IsTest && !await db.UserPushPreferences.AnyAsync(preference =>
|
||||
preference.UserId == device.UserId && preference.Category == message.Category &&
|
||||
preference.IsEnabled, ct))
|
||||
{
|
||||
Skip(delivery, "category_disabled", now);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
var provider = registry.Find(device.Provider);
|
||||
if (provider is null)
|
||||
{
|
||||
Fail(delivery, "provider_unknown", "Unknown push provider", now);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return;
|
||||
}
|
||||
var envelope = new PushEnvelope(
|
||||
message.PublicId,
|
||||
message.Title,
|
||||
message.Body,
|
||||
message.Category,
|
||||
message.Action,
|
||||
message.EntityId,
|
||||
Math.Max(60, (int)(expiresAt - now).TotalSeconds));
|
||||
var result = await provider.SendAsync(
|
||||
device.Flavor,
|
||||
device.PackageName,
|
||||
tokenProtector.Unprotect(device.TokenCiphertext),
|
||||
envelope,
|
||||
ct);
|
||||
if (result.Accepted)
|
||||
{
|
||||
delivery.State = PushDeliveryStates.Accepted;
|
||||
delivery.AcceptedAt = now;
|
||||
delivery.ProviderMessageId = result.ProviderMessageId;
|
||||
delivery.ErrorCode = null;
|
||||
delivery.ErrorMessage = null;
|
||||
ClearLease(delivery, now);
|
||||
}
|
||||
else if (result.Retryable && delivery.AttemptCount <= RetrySchedule.Length && expiresAt > now)
|
||||
{
|
||||
var retry = result.RetryAfter ?? RetrySchedule[Math.Clamp(delivery.AttemptCount - 1, 0, RetrySchedule.Length - 1)];
|
||||
delivery.State = PushDeliveryStates.Queued;
|
||||
delivery.NextAttemptAt = now.Add(retry) < expiresAt ? now.Add(retry) : expiresAt;
|
||||
delivery.ErrorCode = result.ErrorCode;
|
||||
delivery.ErrorMessage = result.ErrorMessage;
|
||||
ClearLease(delivery, now);
|
||||
}
|
||||
else
|
||||
{
|
||||
Fail(delivery, result.ErrorCode ?? "provider_rejected", result.ErrorMessage, now);
|
||||
}
|
||||
if (result.InvalidToken)
|
||||
{
|
||||
device.IsActive = false;
|
||||
device.DisabledReason = "provider_invalid_token";
|
||||
device.UpdatedAt = now;
|
||||
}
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
private static async Task FinalizeMessage(AppDbContext db, long messageId, CancellationToken ct)
|
||||
{
|
||||
var pending = await db.PushDeliveries.AnyAsync(delivery =>
|
||||
delivery.PushMessageId == messageId &&
|
||||
(delivery.State == PushDeliveryStates.Queued || delivery.State == PushDeliveryStates.Sending), ct);
|
||||
if (pending) return;
|
||||
var failed = await db.PushDeliveries.AnyAsync(delivery =>
|
||||
delivery.PushMessageId == messageId && delivery.State == PushDeliveryStates.Failed, ct);
|
||||
var now = DateTime.UtcNow;
|
||||
await db.PushMessages.Where(message => message.Id == messageId)
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(message => message.State,
|
||||
failed ? PushMessageStates.PartiallyFailed : PushMessageStates.Completed)
|
||||
.SetProperty(message => message.CompletedAt, now)
|
||||
.SetProperty(message => message.UpdatedAt, now), ct);
|
||||
}
|
||||
|
||||
private async Task Cleanup(CancellationToken ct)
|
||||
{
|
||||
using var scope = scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var staleBefore = DateTime.UtcNow.AddDays(-90);
|
||||
await db.PushDevices
|
||||
.Where(device => device.IsActive && device.LastSeenAt < staleBefore)
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(device => device.IsActive, false)
|
||||
.SetProperty(device => device.DisabledReason, "stale_device")
|
||||
.SetProperty(device => device.UpdatedAt, DateTime.UtcNow), ct);
|
||||
await db.PushDeliveries
|
||||
.Where(delivery => delivery.UpdatedAt < staleBefore &&
|
||||
delivery.State != PushDeliveryStates.Queued &&
|
||||
delivery.State != PushDeliveryStates.Sending)
|
||||
.ExecuteDeleteAsync(ct);
|
||||
}
|
||||
|
||||
private static void Skip(PushDelivery delivery, string code, DateTime now)
|
||||
{
|
||||
delivery.State = PushDeliveryStates.Skipped;
|
||||
delivery.ErrorCode = code;
|
||||
delivery.ErrorMessage = null;
|
||||
ClearLease(delivery, now);
|
||||
}
|
||||
|
||||
private static void Fail(PushDelivery delivery, string code, string? message, DateTime now)
|
||||
{
|
||||
delivery.State = PushDeliveryStates.Failed;
|
||||
delivery.ErrorCode = code;
|
||||
delivery.ErrorMessage = message is { Length: > 400 } ? message[..400] : message;
|
||||
ClearLease(delivery, now);
|
||||
}
|
||||
|
||||
private static void ClearLease(PushDelivery delivery, DateTime now)
|
||||
{
|
||||
delivery.LeaseId = null;
|
||||
delivery.LeaseExpiresAt = null;
|
||||
delivery.UpdatedAt = now;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,571 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Collections.Concurrent;
|
||||
using MiaoJiZhang.Domain.Entities;
|
||||
|
||||
namespace MiaoJiZhang.Api.Services;
|
||||
|
||||
public sealed record PushEnvelope(
|
||||
string MessageId,
|
||||
string Title,
|
||||
string Body,
|
||||
string Category,
|
||||
string Action,
|
||||
string? EntityId,
|
||||
int TtlSeconds);
|
||||
|
||||
public sealed record PushSendResult(
|
||||
bool Accepted,
|
||||
bool Retryable,
|
||||
bool InvalidToken,
|
||||
string? ProviderMessageId = null,
|
||||
string? ErrorCode = null,
|
||||
string? ErrorMessage = null,
|
||||
TimeSpan? RetryAfter = null);
|
||||
|
||||
public interface IPushProvider
|
||||
{
|
||||
string Provider { get; }
|
||||
bool IsEnabled(string flavor);
|
||||
IReadOnlyList<string> ConfigurationErrors(string flavor);
|
||||
Task<PushSendResult> SendAsync(
|
||||
string flavor,
|
||||
string packageName,
|
||||
string token,
|
||||
PushEnvelope message,
|
||||
CancellationToken ct);
|
||||
}
|
||||
|
||||
public sealed class PushProviderRegistry(IEnumerable<IPushProvider> providers)
|
||||
{
|
||||
private readonly IReadOnlyDictionary<string, IPushProvider> items = providers
|
||||
.ToDictionary(provider => provider.Provider, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public IReadOnlyCollection<IPushProvider> All => items.Values.ToArray();
|
||||
public IPushProvider? Find(string provider) => items.GetValueOrDefault(provider);
|
||||
}
|
||||
|
||||
public sealed class OfficialPushProvider(
|
||||
string provider,
|
||||
IConfiguration configuration,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
ILogger<OfficialPushProvider> logger) : IPushProvider
|
||||
{
|
||||
private readonly SemaphoreSlim tokenLock = new(1, 1);
|
||||
private readonly ConcurrentDictionary<string, CachedAccessToken> accessTokens =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public string Provider { get; } = provider;
|
||||
|
||||
public bool IsEnabled(string flavor) =>
|
||||
configuration.GetValue<bool>($"Push:Providers:{Provider}:{flavor}:Enabled");
|
||||
|
||||
public IReadOnlyList<string> ConfigurationErrors(string flavor)
|
||||
{
|
||||
if (!IsEnabled(flavor)) return ["disabled"];
|
||||
var required = Provider switch
|
||||
{
|
||||
PushProviders.Huawei or PushProviders.Honor => new[] { "AppId", "AppSecret" },
|
||||
PushProviders.Xiaomi => new[] { "AppSecret" },
|
||||
PushProviders.Oppo => new[] { "AppKey", "MasterSecret" },
|
||||
PushProviders.Vivo => new[] { "AppId", "AppKey", "AppSecret" },
|
||||
PushProviders.Meizu => new[] { "AppId", "AppSecret" },
|
||||
_ => [],
|
||||
};
|
||||
return required
|
||||
.Where(key => string.IsNullOrWhiteSpace(Value(flavor, key)))
|
||||
.Select(key => $"missing_{key.ToLowerInvariant()}")
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task<PushSendResult> SendAsync(
|
||||
string flavor,
|
||||
string packageName,
|
||||
string token,
|
||||
PushEnvelope message,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var errors = ConfigurationErrors(flavor);
|
||||
if (errors.Count > 0)
|
||||
return new(false, false, false, ErrorCode: "provider_not_configured",
|
||||
ErrorMessage: string.Join(',', errors));
|
||||
try
|
||||
{
|
||||
return Provider switch
|
||||
{
|
||||
PushProviders.Huawei => await SendHuaweiLike(flavor, packageName, token, message, false, ct),
|
||||
PushProviders.Honor => await SendHuaweiLike(flavor, packageName, token, message, true, ct),
|
||||
PushProviders.Xiaomi => await SendXiaomi(flavor, packageName, token, message, ct),
|
||||
PushProviders.Oppo => await SendOppo(flavor, packageName, token, message, ct),
|
||||
PushProviders.Vivo => await SendVivo(flavor, packageName, token, message, ct),
|
||||
PushProviders.Meizu => await SendMeizu(flavor, packageName, token, message, ct),
|
||||
_ => new(false, false, false, ErrorCode: "provider_unknown"),
|
||||
};
|
||||
}
|
||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
return new(false, true, false, ErrorCode: "provider_timeout", ErrorMessage: "Provider request timed out");
|
||||
}
|
||||
catch (HttpRequestException exception)
|
||||
{
|
||||
logger.LogWarning(exception, "Push provider {Provider} request failed", Provider);
|
||||
return new(false, true, false, ErrorCode: "provider_network_error", ErrorMessage: exception.Message);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(exception, "Push provider {Provider} failed unexpectedly", Provider);
|
||||
return new(false, false, false, ErrorCode: "provider_internal_error", ErrorMessage: exception.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<PushSendResult> SendHuaweiLike(
|
||||
string flavor,
|
||||
string packageName,
|
||||
string token,
|
||||
PushEnvelope message,
|
||||
bool honor,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var accessToken = await GetOAuthToken(flavor, honor, ct);
|
||||
if (accessToken.Result is not null) return accessToken.Result;
|
||||
var appId = Value(flavor, "AppId")!;
|
||||
var defaultUrl = honor
|
||||
? $"https://push-api.cloud.hihonor.com/api/v1/{appId}/sendMessage"
|
||||
: $"https://push-api.cloud.huawei.com/v1/{appId}/messages:send";
|
||||
var url = Value(flavor, "SendUrl") ?? defaultUrl;
|
||||
var intent = IntentUri(packageName, message);
|
||||
object payload = honor
|
||||
? new
|
||||
{
|
||||
message = new
|
||||
{
|
||||
notification = new { title = message.Title, body = message.Body },
|
||||
android = new
|
||||
{
|
||||
ttl = $"{message.TtlSeconds}s",
|
||||
data = PayloadJson(message),
|
||||
notification = new
|
||||
{
|
||||
channel_id = Channel(flavor, message.Category),
|
||||
click_action = new { type = 1, intent },
|
||||
},
|
||||
},
|
||||
token = new[] { token },
|
||||
},
|
||||
}
|
||||
: new
|
||||
{
|
||||
validate_only = false,
|
||||
message = new
|
||||
{
|
||||
notification = new { title = message.Title, body = message.Body },
|
||||
android = new
|
||||
{
|
||||
ttl = $"{message.TtlSeconds}s",
|
||||
data = PayloadJson(message),
|
||||
notification = new
|
||||
{
|
||||
channel_id = Channel(flavor, message.Category),
|
||||
notify_id = StableNotificationId(message.MessageId),
|
||||
click_action = new { type = 1, intent },
|
||||
},
|
||||
},
|
||||
token = new[] { token },
|
||||
},
|
||||
};
|
||||
using var request = JsonRequest(HttpMethod.Post, url, payload);
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken.Token);
|
||||
return await Send(request, ct);
|
||||
}
|
||||
|
||||
private async Task<(string? Token, PushSendResult? Result)> GetOAuthToken(
|
||||
string flavor,
|
||||
bool honor,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (FreshAccessToken(flavor) is { } cached) return (cached, null);
|
||||
await tokenLock.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
if (FreshAccessToken(flavor) is { } lockedCached) return (lockedCached, null);
|
||||
var defaultUrl = honor
|
||||
? "https://iam.developer.hihonor.com/auth/token"
|
||||
: "https://oauth-login.cloud.huawei.com/oauth2/v3/token";
|
||||
var url = Value(flavor, "AuthUrl") ?? defaultUrl;
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, url)
|
||||
{
|
||||
Content = new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
{
|
||||
["grant_type"] = "client_credentials",
|
||||
["client_id"] = Value(flavor, "AppId")!,
|
||||
["client_secret"] = Value(flavor, "AppSecret")!,
|
||||
}),
|
||||
};
|
||||
using var response = await Client().SendAsync(request, ct);
|
||||
var body = await response.Content.ReadAsStringAsync(ct);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return (null, FromFailure(response, body));
|
||||
using var json = JsonDocument.Parse(body);
|
||||
if (!TryString(json.RootElement, out var value, "access_token", "accessToken", "token"))
|
||||
return (null, new(false, false, false, ErrorCode: "provider_auth_invalid", ErrorMessage: Trim(body)));
|
||||
var expires = TryInt(json.RootElement, "expires_in", "expiresIn") ?? 3600;
|
||||
accessTokens[flavor] = new CachedAccessToken(
|
||||
value!,
|
||||
DateTime.UtcNow.AddSeconds(Math.Max(expires, 300)));
|
||||
return (value, null);
|
||||
}
|
||||
finally
|
||||
{
|
||||
tokenLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<PushSendResult> SendXiaomi(
|
||||
string flavor,
|
||||
string packageName,
|
||||
string token,
|
||||
PushEnvelope message,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var url = Value(flavor, "SendUrl") ?? "https://api.xmpush.xiaomi.com/v3/message/regid";
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, url)
|
||||
{
|
||||
Content = new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
{
|
||||
["registration_id"] = token,
|
||||
["restricted_package_name"] = packageName,
|
||||
["title"] = message.Title,
|
||||
["description"] = message.Body,
|
||||
["notify_id"] = StableNotificationId(message.MessageId).ToString(),
|
||||
["time_to_live"] = (message.TtlSeconds * 1000L).ToString(),
|
||||
["extra.notify_effect"] = "2",
|
||||
["extra.intent_uri"] = IntentUri(packageName, message),
|
||||
["extra.jz_payload"] = PayloadJson(message),
|
||||
["extra.channel_id"] = Channel(flavor, message.Category),
|
||||
}),
|
||||
};
|
||||
request.Headers.TryAddWithoutValidation("Authorization", $"key={Value(flavor, "AppSecret")}");
|
||||
return await Send(request, ct);
|
||||
}
|
||||
|
||||
private async Task<PushSendResult> SendOppo(
|
||||
string flavor,
|
||||
string packageName,
|
||||
string token,
|
||||
PushEnvelope message,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString();
|
||||
var appKey = Value(flavor, "AppKey")!;
|
||||
var sign = Sha256Hex(appKey + timestamp + Value(flavor, "MasterSecret"));
|
||||
var authUrl = Value(flavor, "AuthUrl") ?? "https://api.push.oppomobile.com/server/v1/auth";
|
||||
using var authRequest = new HttpRequestMessage(HttpMethod.Post, authUrl)
|
||||
{
|
||||
Content = new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
{
|
||||
["app_key"] = appKey,
|
||||
["timestamp"] = timestamp,
|
||||
["sign"] = sign,
|
||||
}),
|
||||
};
|
||||
using var authResponse = await Client().SendAsync(authRequest, ct);
|
||||
var authBody = await authResponse.Content.ReadAsStringAsync(ct);
|
||||
if (!authResponse.IsSuccessStatusCode) return FromFailure(authResponse, authBody);
|
||||
using var authJson = JsonDocument.Parse(authBody);
|
||||
if (!TryNestedString(authJson.RootElement, out var authToken, "data", "auth_token") &&
|
||||
!TryString(authJson.RootElement, out authToken, "auth_token", "authToken"))
|
||||
return new(false, false, false, ErrorCode: "provider_auth_invalid", ErrorMessage: Trim(authBody));
|
||||
|
||||
var notification = JsonSerializer.Serialize(new
|
||||
{
|
||||
app_message_id = message.MessageId,
|
||||
title = message.Title,
|
||||
content = message.Body,
|
||||
click_action_type = 1,
|
||||
click_action_activity = $"{packageName}/com.nx.miaoji.MainActivity",
|
||||
action_parameters = PayloadJson(message),
|
||||
off_line = true,
|
||||
off_line_ttl = message.TtlSeconds,
|
||||
channel_id = Channel(flavor, message.Category),
|
||||
});
|
||||
var sendUrl = Value(flavor, "SendUrl") ??
|
||||
"https://api.push.oppomobile.com/server/v1/message/notification/unicast";
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, sendUrl)
|
||||
{
|
||||
Content = new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
{
|
||||
["auth_token"] = authToken!,
|
||||
["registration_id"] = token,
|
||||
["message"] = notification,
|
||||
}),
|
||||
};
|
||||
return await Send(request, ct);
|
||||
}
|
||||
|
||||
private async Task<PushSendResult> SendVivo(
|
||||
string flavor,
|
||||
string packageName,
|
||||
string token,
|
||||
PushEnvelope message,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var appId = Value(flavor, "AppId")!;
|
||||
var appKey = Value(flavor, "AppKey")!;
|
||||
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString();
|
||||
var sign = Md5Hex(appId + appKey + timestamp + Value(flavor, "AppSecret"));
|
||||
var authUrl = Value(flavor, "AuthUrl") ?? "https://api-push.vivo.com.cn/message/auth";
|
||||
using var authRequest = JsonRequest(HttpMethod.Post, authUrl, new
|
||||
{
|
||||
appId = int.TryParse(appId, out var id) ? id : 0,
|
||||
appKey,
|
||||
timestamp = long.Parse(timestamp),
|
||||
sign,
|
||||
});
|
||||
using var authResponse = await Client().SendAsync(authRequest, ct);
|
||||
var authBody = await authResponse.Content.ReadAsStringAsync(ct);
|
||||
if (!authResponse.IsSuccessStatusCode) return FromFailure(authResponse, authBody);
|
||||
using var authJson = JsonDocument.Parse(authBody);
|
||||
if (!TryString(authJson.RootElement, out var authToken, "authToken", "auth_token"))
|
||||
return new(false, false, false, ErrorCode: "provider_auth_invalid", ErrorMessage: Trim(authBody));
|
||||
|
||||
var sendUrl = Value(flavor, "SendUrl") ?? "https://api-push.vivo.com.cn/message/send";
|
||||
using var request = JsonRequest(HttpMethod.Post, sendUrl, new
|
||||
{
|
||||
regId = token,
|
||||
notifyType = 4,
|
||||
title = message.Title,
|
||||
content = message.Body,
|
||||
timeToLive = message.TtlSeconds,
|
||||
skipType = 3,
|
||||
skipContent = IntentUri(packageName, message),
|
||||
requestId = message.MessageId,
|
||||
classification = message.Category == PushCategories.Operations ? 1 : 0,
|
||||
clientCustomMap = new Dictionary<string, string> { ["jz_payload"] = PayloadJson(message) },
|
||||
});
|
||||
request.Headers.TryAddWithoutValidation("authToken", authToken);
|
||||
return await Send(request, ct);
|
||||
}
|
||||
|
||||
private async Task<PushSendResult> SendMeizu(
|
||||
string flavor,
|
||||
string packageName,
|
||||
string token,
|
||||
PushEnvelope message,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var appId = Value(flavor, "AppId")!;
|
||||
var messageJson = JsonSerializer.Serialize(new
|
||||
{
|
||||
noticeBarInfo = new
|
||||
{
|
||||
title = message.Title,
|
||||
content = message.Body,
|
||||
noticeBarType = 0,
|
||||
},
|
||||
clickTypeInfo = new
|
||||
{
|
||||
clickType = 3,
|
||||
parameters = new Dictionary<string, string> { ["jz_payload"] = PayloadJson(message) },
|
||||
uri = IntentUri(packageName, message),
|
||||
},
|
||||
pushTimeInfo = new { offLine = true, validTime = message.TtlSeconds / 3600 },
|
||||
advanceInfo = new { notifyId = StableNotificationId(message.MessageId) },
|
||||
});
|
||||
var values = new SortedDictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["appId"] = appId,
|
||||
["pushIds"] = JsonSerializer.Serialize(new[] { token }),
|
||||
["messageJson"] = messageJson,
|
||||
};
|
||||
var signSource = string.Concat(values.Select(item => item.Key + item.Value)) + Value(flavor, "AppSecret");
|
||||
values["sign"] = Md5Hex(signSource);
|
||||
var url = Value(flavor, "SendUrl") ??
|
||||
"https://server-api-push.meizu.com/garcia/api/server/push/varnished/pushByPushId";
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, url)
|
||||
{
|
||||
Content = new FormUrlEncodedContent(values),
|
||||
};
|
||||
return await Send(request, ct);
|
||||
}
|
||||
|
||||
private async Task<PushSendResult> Send(HttpRequestMessage request, CancellationToken ct)
|
||||
{
|
||||
using var response = await Client().SendAsync(request, ct);
|
||||
var body = await response.Content.ReadAsStringAsync(ct);
|
||||
if (!response.IsSuccessStatusCode) return FromFailure(response, body);
|
||||
string? providerId = null;
|
||||
try
|
||||
{
|
||||
using var json = JsonDocument.Parse(body);
|
||||
var businessFailure = FromBusinessFailure(json.RootElement, body);
|
||||
if (businessFailure is not null) return businessFailure;
|
||||
TryString(json.RootElement, out providerId,
|
||||
"requestId", "request_id", "taskId", "msgId", "messageId", "code");
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Some providers return an empty or non-JSON success body.
|
||||
}
|
||||
return new(true, false, false, providerId);
|
||||
}
|
||||
|
||||
private PushSendResult? FromBusinessFailure(JsonElement root, string body)
|
||||
{
|
||||
string? code = null;
|
||||
var failed = Provider switch
|
||||
{
|
||||
PushProviders.Huawei or PushProviders.Honor =>
|
||||
HasUnexpectedValue(root, ["0", "200", "80000000"], out code, "code"),
|
||||
PushProviders.Xiaomi =>
|
||||
HasUnexpectedValue(root, ["ok", "success"], out code, "result") ||
|
||||
HasUnexpectedValue(root, ["0"], out code, "code"),
|
||||
PushProviders.Oppo =>
|
||||
HasUnexpectedValue(root, ["0"], out code, "code"),
|
||||
PushProviders.Vivo =>
|
||||
HasUnexpectedValue(root, ["0"], out code, "result", "code"),
|
||||
PushProviders.Meizu =>
|
||||
HasUnexpectedValue(root, ["200"], out code, "code"),
|
||||
_ => false,
|
||||
};
|
||||
if (!failed && TryString(root, out var error, "error", "error_description") &&
|
||||
!string.IsNullOrWhiteSpace(error))
|
||||
{
|
||||
code = error;
|
||||
failed = true;
|
||||
}
|
||||
if (!failed) return null;
|
||||
|
||||
var normalized = body.ToLowerInvariant();
|
||||
var retryable = normalized.Contains("rate limit") || normalized.Contains("too many") ||
|
||||
normalized.Contains("frequency") || normalized.Contains("system busy") ||
|
||||
normalized.Contains("try again");
|
||||
return new PushSendResult(
|
||||
false,
|
||||
retryable,
|
||||
LooksLikeInvalidToken(normalized),
|
||||
ErrorCode: $"provider_{NormalizeCode(code)}",
|
||||
ErrorMessage: Trim(body));
|
||||
}
|
||||
|
||||
private static PushSendResult FromFailure(HttpResponseMessage response, string body)
|
||||
{
|
||||
var normalized = body.ToLowerInvariant();
|
||||
var retryable = response.StatusCode == HttpStatusCode.TooManyRequests ||
|
||||
(int)response.StatusCode >= 500;
|
||||
TimeSpan? retryAfter = response.Headers.RetryAfter?.Delta;
|
||||
return new(false, retryable, LooksLikeInvalidToken(normalized),
|
||||
ErrorCode: $"http_{(int)response.StatusCode}",
|
||||
ErrorMessage: Trim(body), RetryAfter: retryAfter);
|
||||
}
|
||||
|
||||
private string? FreshAccessToken(string flavor) =>
|
||||
accessTokens.TryGetValue(flavor, out var cached) &&
|
||||
cached.ExpiresAt > DateTime.UtcNow.AddMinutes(2)
|
||||
? cached.Token
|
||||
: null;
|
||||
|
||||
private HttpClient Client() => httpClientFactory.CreateClient("PushProviders");
|
||||
private string? Value(string flavor, string key) =>
|
||||
configuration[$"Push:Providers:{Provider}:{flavor}:{key}"];
|
||||
private string Channel(string flavor, string category) =>
|
||||
Value(flavor, $"Channels:{category}") ?? $"jizhi_{category}";
|
||||
|
||||
private static HttpRequestMessage JsonRequest(HttpMethod method, string url, object body) => new(method, url)
|
||||
{
|
||||
Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json"),
|
||||
};
|
||||
|
||||
private static string PayloadJson(PushEnvelope message) => JsonSerializer.Serialize(new
|
||||
{
|
||||
v = 1,
|
||||
messageId = message.MessageId,
|
||||
category = message.Category,
|
||||
action = message.Action,
|
||||
entityId = message.EntityId,
|
||||
});
|
||||
|
||||
private static string IntentUri(string packageName, PushEnvelope message)
|
||||
{
|
||||
var entity = message.EntityId is null ? "" : $"&entityId={Uri.EscapeDataString(message.EntityId)}";
|
||||
return $"miaoji://push/open?messageId={Uri.EscapeDataString(message.MessageId)}" +
|
||||
$"&category={Uri.EscapeDataString(message.Category)}&action={Uri.EscapeDataString(message.Action)}{entity}";
|
||||
}
|
||||
|
||||
private static int StableNotificationId(string messageId)
|
||||
{
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(messageId));
|
||||
return BitConverter.ToInt32(bytes, 0) & int.MaxValue;
|
||||
}
|
||||
|
||||
private static string Sha256Hex(string value) =>
|
||||
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant();
|
||||
private static string Md5Hex(string value) =>
|
||||
Convert.ToHexString(MD5.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant();
|
||||
private static string Trim(string value) => value.Length <= 400 ? value : value[..400];
|
||||
|
||||
private static bool HasUnexpectedValue(
|
||||
JsonElement root,
|
||||
string[] accepted,
|
||||
out string? value,
|
||||
params string[] names)
|
||||
{
|
||||
foreach (var name in names)
|
||||
{
|
||||
if (!TryString(root, out value, name)) continue;
|
||||
return !accepted.Contains(value!);
|
||||
}
|
||||
value = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool LooksLikeInvalidToken(string normalized) =>
|
||||
normalized.Contains("invalid token") || normalized.Contains("invalid reg") ||
|
||||
normalized.Contains("registration_id_invalid") || normalized.Contains("target invalid") ||
|
||||
normalized.Contains("pushid") && normalized.Contains("invalid");
|
||||
|
||||
private static string NormalizeCode(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return "rejected";
|
||||
var normalized = new string(value.Where(character => char.IsLetterOrDigit(character) || character == '_')
|
||||
.Take(48).ToArray());
|
||||
return string.IsNullOrEmpty(normalized) ? "rejected" : normalized.ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static bool TryString(JsonElement root, out string? value, params string[] names)
|
||||
{
|
||||
foreach (var name in names)
|
||||
{
|
||||
if (root.ValueKind == JsonValueKind.Object && root.TryGetProperty(name, out var element))
|
||||
{
|
||||
value = element.ValueKind == JsonValueKind.String ? element.GetString() : element.ToString();
|
||||
if (!string.IsNullOrWhiteSpace(value)) return true;
|
||||
}
|
||||
}
|
||||
value = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryNestedString(JsonElement root, out string? value, string parent, string child)
|
||||
{
|
||||
if (root.ValueKind == JsonValueKind.Object && root.TryGetProperty(parent, out var nested))
|
||||
return TryString(nested, out value, child);
|
||||
value = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static int? TryInt(JsonElement root, params string[] names)
|
||||
{
|
||||
foreach (var name in names)
|
||||
{
|
||||
if (!root.TryGetProperty(name, out var value)) continue;
|
||||
if (value.TryGetInt32(out var parsed)) return parsed;
|
||||
if (int.TryParse(value.ToString(), out parsed)) return parsed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private sealed record CachedAccessToken(string Token, DateTime ExpiresAt);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace MiaoJiZhang.Api.Services;
|
||||
|
||||
public sealed class PushTokenProtector(IConfiguration configuration)
|
||||
{
|
||||
private readonly byte[]? key = ReadKey(configuration["Push:TokenEncryptionKey"]);
|
||||
|
||||
public bool IsConfigured => key is { Length: 32 };
|
||||
|
||||
public string Protect(string value)
|
||||
{
|
||||
if (key is null)
|
||||
throw new InvalidOperationException("Push__TokenEncryptionKey must be a base64-encoded 32-byte key");
|
||||
|
||||
var plaintext = Encoding.UTF8.GetBytes(value);
|
||||
var nonce = RandomNumberGenerator.GetBytes(12);
|
||||
var tag = new byte[16];
|
||||
var ciphertext = new byte[plaintext.Length];
|
||||
using var aes = new AesGcm(key, tag.Length);
|
||||
aes.Encrypt(nonce, plaintext, ciphertext, tag);
|
||||
|
||||
var envelope = new byte[nonce.Length + tag.Length + ciphertext.Length];
|
||||
Buffer.BlockCopy(nonce, 0, envelope, 0, nonce.Length);
|
||||
Buffer.BlockCopy(tag, 0, envelope, nonce.Length, tag.Length);
|
||||
Buffer.BlockCopy(ciphertext, 0, envelope, nonce.Length + tag.Length, ciphertext.Length);
|
||||
return Convert.ToBase64String(envelope);
|
||||
}
|
||||
|
||||
public string Unprotect(string value)
|
||||
{
|
||||
if (key is null)
|
||||
throw new InvalidOperationException("Push__TokenEncryptionKey must be a base64-encoded 32-byte key");
|
||||
|
||||
var envelope = Convert.FromBase64String(value);
|
||||
if (envelope.Length < 29) throw new CryptographicException("Invalid push token envelope");
|
||||
var nonce = envelope.AsSpan(0, 12);
|
||||
var tag = envelope.AsSpan(12, 16);
|
||||
var ciphertext = envelope.AsSpan(28);
|
||||
var plaintext = new byte[ciphertext.Length];
|
||||
using var aes = new AesGcm(key, tag.Length);
|
||||
aes.Decrypt(nonce, ciphertext, tag, plaintext);
|
||||
return Encoding.UTF8.GetString(plaintext);
|
||||
}
|
||||
|
||||
public static string Hash(string value) =>
|
||||
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value)));
|
||||
|
||||
public static string CreateUnbindToken() =>
|
||||
Convert.ToBase64String(RandomNumberGenerator.GetBytes(32))
|
||||
.TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
||||
|
||||
private static byte[]? ReadKey(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
try
|
||||
{
|
||||
var parsed = Convert.FromBase64String(value);
|
||||
return parsed.Length == 32 ? parsed : null;
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,5 +18,17 @@
|
||||
"AllowedHosts": "*",
|
||||
"Admin": {
|
||||
"Key": ""
|
||||
}
|
||||
},
|
||||
"Push": {
|
||||
"Enabled": false,
|
||||
"TokenEncryptionKey": "",
|
||||
"Providers": {
|
||||
"huawei": { "production": { "Enabled": false }, "internal": { "Enabled": false } },
|
||||
"honor": { "production": { "Enabled": false }, "internal": { "Enabled": false } },
|
||||
"xiaomi": { "production": { "Enabled": false }, "internal": { "Enabled": false } },
|
||||
"oppo": { "production": { "Enabled": false }, "internal": { "Enabled": false } },
|
||||
"vivo": { "production": { "Enabled": false }, "internal": { "Enabled": false } },
|
||||
"meizu": { "production": { "Enabled": false }, "internal": { "Enabled": false } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
import{Bn as e,Cn as t,Kn as n,Ln as r,Pn as i,Qn as a,Sn as o,_n as s,a as c,ar as l,bn as u,fn as d,or as f,vn as p,xn as m,yn as h}from"./config-provider-q7ATIdCu.js";import{n as g,r as _,t as v}from"./EditOutlined-CeylGsUo.js";import{t as y}from"./api-BV_Zb8mM.js";var b={style:{display:`flex`,"justify-content":`space-between`,"align-items":`center`,"margin-bottom":`16px`}},x=t({__name:`Avatars`,setup(t){let x=a([]),S=a(!0),C=a(!1),w=a(null),T=a({key:``,name:``,speechTic:``,imageUrl:``,isEnabled:!0}),E=[{title:`Key`,dataIndex:`key`,key:`key`,width:90},{title:`默认名`,dataIndex:`defaultName`,key:`name`,width:100},{title:`口癖`,dataIndex:`speechTic`,key:`tic`,width:80},{title:`状态`,dataIndex:`isEnabled`,key:`on`,width:70},{title:``,key:`act`,width:150}];i(D);async function D(){S.value=!0;try{x.value=await y.avatars()}finally{S.value=!1}}function O(){w.value=null,T.value={key:``,name:``,speechTic:``,imageUrl:``,isEnabled:!0},C.value=!0}function k(e){w.value=e,T.value={key:e.key,name:e.defaultName,speechTic:e.speechTic,imageUrl:e.imageUrl||``,isEnabled:e.isEnabled},C.value=!0}async function A(){let e={...T.value,imageUrl:T.value.imageUrl||null};w.value?await y.updateAvatar(w.value.id,e):await y.createAvatar(e),c.success(w.value?`已更新`:`已创建`),C.value=!1,D()}async function j(e){await y.deleteAvatar(e),c.success(`已删除`),D()}return(t,i)=>{let a=e(`a-button`),c=e(`a-tag`),y=e(`a-popconfirm`),D=e(`a-table`),M=e(`a-input`),N=e(`a-form-item`),P=e(`a-col`),F=e(`a-row`),I=e(`a-switch`),L=e(`a-form`),R=e(`a-modal`);return r(),u(d,null,[s(`div`,b,[i[7]||=s(`h2`,null,`AI 形象管理`,-1),o(a,{type:`primary`,onClick:O},{default:n(()=>[o(l(_)),i[6]||=m(` 新建形象`,-1)]),_:1})]),o(D,{columns:E,dataSource:x.value,loading:S.value,rowKey:`id`,size:`small`,pagination:{pageSize:10}},{bodyCell:n(({column:e,record:t})=>[e.key===`on`?(r(),p(c,{key:0,color:t.isEnabled?`green`:`default`},{default:n(()=>[m(f(t.isEnabled?`启用`:`禁用`),1)]),_:2},1032,[`color`])):h(``,!0),e.key===`act`?(r(),u(d,{key:1},[o(a,{size:`small`,style:{"margin-right":`6px`},onClick:e=>k(t)},{default:n(()=>[o(l(v))]),_:1},8,[`onClick`]),o(y,{title:`确定删除?`,onConfirm:e=>j(t.id)},{default:n(()=>[o(a,{size:`small`,danger:``},{default:n(()=>[o(l(g))]),_:1})]),_:1},8,[`onConfirm`])],64)):h(``,!0)]),_:1},8,[`dataSource`,`loading`]),o(R,{open:C.value,"onUpdate:open":i[5]||=e=>C.value=e,title:w.value?`编辑形象`:`新建形象`,onOk:A,width:500},{default:n(()=>[o(L,{layout:`vertical`,style:{"margin-top":`8px`}},{default:n(()=>[o(F,{gutter:12},{default:n(()=>[o(P,{span:12},{default:n(()=>[o(N,{label:`Key`},{default:n(()=>[o(M,{value:T.value.key,"onUpdate:value":i[0]||=e=>T.value.key=e,placeholder:`cat`},null,8,[`value`])]),_:1})]),_:1}),o(P,{span:12},{default:n(()=>[o(N,{label:`默认名`},{default:n(()=>[o(M,{value:T.value.name,"onUpdate:value":i[1]||=e=>T.value.name=e,placeholder:`小账喵`},null,8,[`value`])]),_:1})]),_:1})]),_:1}),o(N,{label:`口癖后缀`},{default:n(()=>[o(M,{value:T.value.speechTic,"onUpdate:value":i[2]||=e=>T.value.speechTic=e,placeholder:`喵 / 汪 / 留空=无口癖`},null,8,[`value`]),i[8]||=s(`div`,{style:{color:`#999`,"font-size":`11px`,"margin-top":`4px`}},`口癖跟随形象——决策 20`,-1)]),_:1}),o(N,{label:`头像图片 URL`},{default:n(()=>[o(M,{value:T.value.imageUrl,"onUpdate:value":i[3]||=e=>T.value.imageUrl=e,placeholder:`可选,CDN 地址`},null,8,[`value`])]),_:1}),o(N,{label:`是否启用`},{default:n(()=>[o(I,{checked:T.value.isEnabled,"onUpdate:checked":i[4]||=e=>T.value.isEnabled=e},null,8,[`checked`])]),_:1})]),_:1})]),_:1},8,[`open`,`title`])],64)}}});export{x as default};
|
||||
@@ -1 +0,0 @@
|
||||
import{Bn as e,Cn as t,Kn as n,Ln as r,Pn as i,Qn as a,Sn as o,_n as s,a as c,ar as l,bn as u,fn as d,or as f,vn as p,xn as m,yn as h}from"./config-provider-q7ATIdCu.js";import{n as g,r as _,t as v}from"./EditOutlined-CeylGsUo.js";import{t as y}from"./api-C4vz6nB3.js";var b={style:{display:`flex`,"justify-content":`space-between`,"align-items":`center`,"margin-bottom":`16px`}},x=t({__name:`Avatars`,setup(t){let x=a([]),S=a(!0),C=a(!1),w=a(null),T=a({key:``,name:``,speechTic:``,imageUrl:``,isEnabled:!0}),E=[{title:`Key`,dataIndex:`key`,key:`key`,width:90},{title:`默认名`,dataIndex:`defaultName`,key:`name`,width:100},{title:`口癖`,dataIndex:`speechTic`,key:`tic`,width:80},{title:`状态`,dataIndex:`isEnabled`,key:`on`,width:70},{title:``,key:`act`,width:150}];i(D);async function D(){S.value=!0;try{x.value=await y.avatars()}finally{S.value=!1}}function O(){w.value=null,T.value={key:``,name:``,speechTic:``,imageUrl:``,isEnabled:!0},C.value=!0}function k(e){w.value=e,T.value={key:e.key,name:e.defaultName,speechTic:e.speechTic,imageUrl:e.imageUrl||``,isEnabled:e.isEnabled},C.value=!0}async function A(){let e={...T.value,imageUrl:T.value.imageUrl||null};w.value?await y.updateAvatar(w.value.id,e):await y.createAvatar(e),c.success(w.value?`已更新`:`已创建`),C.value=!1,D()}async function j(e){await y.deleteAvatar(e),c.success(`已删除`),D()}return(t,i)=>{let a=e(`a-button`),c=e(`a-tag`),y=e(`a-popconfirm`),D=e(`a-table`),M=e(`a-input`),N=e(`a-form-item`),P=e(`a-col`),F=e(`a-row`),I=e(`a-switch`),L=e(`a-form`),R=e(`a-modal`);return r(),u(d,null,[s(`div`,b,[i[7]||=s(`h2`,null,`AI 形象管理`,-1),o(a,{type:`primary`,onClick:O},{default:n(()=>[o(l(_)),i[6]||=m(` 新建形象`,-1)]),_:1})]),o(D,{columns:E,dataSource:x.value,loading:S.value,rowKey:`id`,size:`small`,pagination:{pageSize:10}},{bodyCell:n(({column:e,record:t})=>[e.key===`on`?(r(),p(c,{key:0,color:t.isEnabled?`green`:`default`},{default:n(()=>[m(f(t.isEnabled?`启用`:`禁用`),1)]),_:2},1032,[`color`])):h(``,!0),e.key===`act`?(r(),u(d,{key:1},[o(a,{size:`small`,style:{"margin-right":`6px`},onClick:e=>k(t)},{default:n(()=>[o(l(v))]),_:1},8,[`onClick`]),o(y,{title:`确定删除?`,onConfirm:e=>j(t.id)},{default:n(()=>[o(a,{size:`small`,danger:``},{default:n(()=>[o(l(g))]),_:1})]),_:1},8,[`onConfirm`])],64)):h(``,!0)]),_:1},8,[`dataSource`,`loading`]),o(R,{open:C.value,"onUpdate:open":i[5]||=e=>C.value=e,title:w.value?`编辑形象`:`新建形象`,onOk:A,width:500},{default:n(()=>[o(L,{layout:`vertical`,style:{"margin-top":`8px`}},{default:n(()=>[o(F,{gutter:12},{default:n(()=>[o(P,{span:12},{default:n(()=>[o(N,{label:`Key`},{default:n(()=>[o(M,{value:T.value.key,"onUpdate:value":i[0]||=e=>T.value.key=e,placeholder:`cat`},null,8,[`value`])]),_:1})]),_:1}),o(P,{span:12},{default:n(()=>[o(N,{label:`默认名`},{default:n(()=>[o(M,{value:T.value.name,"onUpdate:value":i[1]||=e=>T.value.name=e,placeholder:`小账喵`},null,8,[`value`])]),_:1})]),_:1})]),_:1}),o(N,{label:`口癖后缀`},{default:n(()=>[o(M,{value:T.value.speechTic,"onUpdate:value":i[2]||=e=>T.value.speechTic=e,placeholder:`喵 / 汪 / 留空=无口癖`},null,8,[`value`]),i[8]||=s(`div`,{style:{color:`#999`,"font-size":`11px`,"margin-top":`4px`}},`口癖跟随形象——决策 20`,-1)]),_:1}),o(N,{label:`头像图片 URL`},{default:n(()=>[o(M,{value:T.value.imageUrl,"onUpdate:value":i[3]||=e=>T.value.imageUrl=e,placeholder:`可选,CDN 地址`},null,8,[`value`])]),_:1}),o(N,{label:`是否启用`},{default:n(()=>[o(I,{checked:T.value.isEnabled,"onUpdate:checked":i[4]||=e=>T.value.isEnabled=e},null,8,[`checked`])]),_:1})]),_:1})]),_:1},8,[`open`,`title`])],64)}}});export{x as default};
|
||||
@@ -0,0 +1 @@
|
||||
import{Bn as e,Cn as t,Kn as n,Ln as r,Pn as i,Qn as a,Sn as o,_n as s,a as c,ar as l,bn as u,fn as d,or as f,vn as p,xn as m,yn as h}from"./config-provider-q7ATIdCu.js";import{n as g,t as _}from"./EditOutlined-h6ScL3Qz.js";import{t as v}from"./DeleteOutlined-yVoeJ3Fd.js";import{t as y}from"./api-wmB-hCXT.js";var b={style:{display:`flex`,"justify-content":`space-between`,"align-items":`center`,"margin-bottom":`16px`}},x=t({__name:`Avatars`,setup(t){let x=a([]),S=a(!0),C=a(!1),w=a(null),T=a({key:``,name:``,speechTic:``,imageUrl:``,isEnabled:!0}),E=[{title:`Key`,dataIndex:`key`,key:`key`,width:90},{title:`默认名`,dataIndex:`defaultName`,key:`name`,width:100},{title:`口癖`,dataIndex:`speechTic`,key:`tic`,width:80},{title:`状态`,dataIndex:`isEnabled`,key:`on`,width:70},{title:``,key:`act`,width:150}];i(D);async function D(){S.value=!0;try{x.value=await y.avatars()}finally{S.value=!1}}function O(){w.value=null,T.value={key:``,name:``,speechTic:``,imageUrl:``,isEnabled:!0},C.value=!0}function k(e){w.value=e,T.value={key:e.key,name:e.defaultName,speechTic:e.speechTic,imageUrl:e.imageUrl||``,isEnabled:e.isEnabled},C.value=!0}async function A(){let e={...T.value,imageUrl:T.value.imageUrl||null};w.value?await y.updateAvatar(w.value.id,e):await y.createAvatar(e),c.success(w.value?`已更新`:`已创建`),C.value=!1,D()}async function j(e){await y.deleteAvatar(e),c.success(`已删除`),D()}return(t,i)=>{let a=e(`a-button`),c=e(`a-tag`),y=e(`a-popconfirm`),D=e(`a-table`),M=e(`a-input`),N=e(`a-form-item`),P=e(`a-col`),F=e(`a-row`),I=e(`a-switch`),L=e(`a-form`),R=e(`a-modal`);return r(),u(d,null,[s(`div`,b,[i[7]||=s(`h2`,null,`AI 形象管理`,-1),o(a,{type:`primary`,onClick:O},{default:n(()=>[o(l(g)),i[6]||=m(` 新建形象`,-1)]),_:1})]),o(D,{columns:E,dataSource:x.value,loading:S.value,rowKey:`id`,size:`small`,pagination:{pageSize:10}},{bodyCell:n(({column:e,record:t})=>[e.key===`on`?(r(),p(c,{key:0,color:t.isEnabled?`green`:`default`},{default:n(()=>[m(f(t.isEnabled?`启用`:`禁用`),1)]),_:2},1032,[`color`])):h(``,!0),e.key===`act`?(r(),u(d,{key:1},[o(a,{size:`small`,style:{"margin-right":`6px`},onClick:e=>k(t)},{default:n(()=>[o(l(_))]),_:1},8,[`onClick`]),o(y,{title:`确定删除?`,onConfirm:e=>j(t.id)},{default:n(()=>[o(a,{size:`small`,danger:``},{default:n(()=>[o(l(v))]),_:1})]),_:1},8,[`onConfirm`])],64)):h(``,!0)]),_:1},8,[`dataSource`,`loading`]),o(R,{open:C.value,"onUpdate:open":i[5]||=e=>C.value=e,title:w.value?`编辑形象`:`新建形象`,onOk:A,width:500},{default:n(()=>[o(L,{layout:`vertical`,style:{"margin-top":`8px`}},{default:n(()=>[o(F,{gutter:12},{default:n(()=>[o(P,{span:12},{default:n(()=>[o(N,{label:`Key`},{default:n(()=>[o(M,{value:T.value.key,"onUpdate:value":i[0]||=e=>T.value.key=e,placeholder:`cat`},null,8,[`value`])]),_:1})]),_:1}),o(P,{span:12},{default:n(()=>[o(N,{label:`默认名`},{default:n(()=>[o(M,{value:T.value.name,"onUpdate:value":i[1]||=e=>T.value.name=e,placeholder:`小账喵`},null,8,[`value`])]),_:1})]),_:1})]),_:1}),o(N,{label:`口癖后缀`},{default:n(()=>[o(M,{value:T.value.speechTic,"onUpdate:value":i[2]||=e=>T.value.speechTic=e,placeholder:`喵 / 汪 / 留空=无口癖`},null,8,[`value`]),i[8]||=s(`div`,{style:{color:`#999`,"font-size":`11px`,"margin-top":`4px`}},`口癖跟随形象——决策 20`,-1)]),_:1}),o(N,{label:`头像图片 URL`},{default:n(()=>[o(M,{value:T.value.imageUrl,"onUpdate:value":i[3]||=e=>T.value.imageUrl=e,placeholder:`可选,CDN 地址`},null,8,[`value`])]),_:1}),o(N,{label:`是否启用`},{default:n(()=>[o(I,{checked:T.value.isEnabled,"onUpdate:checked":i[4]||=e=>T.value.isEnabled=e},null,8,[`checked`])]),_:1})]),_:1})]),_:1},8,[`open`,`title`])],64)}}});export{x as default};
|
||||
@@ -1 +0,0 @@
|
||||
import{Bn as e,Cn as t,Kn as n,Ln as r,Pn as i,Qn as a,Sn as o,_n as s,a as c,bn as l,fn as u,gn as d,or as f,vn as p,xn as m,zn as h}from"./config-provider-q7ATIdCu.js";import{t as g}from"./api-BV_Zb8mM.js";var _={style:{display:`flex`,"justify-content":`space-between`,"align-items":`center`,"margin-bottom":`20px`}},v={style:{display:`flex`,"justify-content":`space-between`,"align-items":`flex-start`}},y={style:{"font-size":`13px`,"font-weight":`600`,"margin-bottom":`2px`}},b={style:{color:`#999`,"font-size":`11px`,"margin-bottom":`6px`}},x={key:0,style:{"margin-top":`6px`,"font-size":`18px`}},S={key:0,style:{color:`#00B386`}},C={key:1,style:{color:`#ccc`}},w={key:1,style:{"margin-top":`6px`,"font-size":`16px`,"font-weight":`700`,"word-break":`break-all`}},T={style:{color:`#999`,"font-size":`10px`,"margin-top":`4px`}},E={style:{"margin-bottom":`10px`,color:`#999`,"font-size":`12px`}},D=t({__name:`Configs`,setup(t){let D=a([]),O=a(!0),k={"brand.app_name":{label:`App 名称`,desc:`App 内展示名称`,type:`text`},"brand.slogan":{label:`App 标语`,desc:`启动页/关于页口号`,type:`text`},"brand.logo_url":{label:`Logo URL`,desc:`品牌 Logo 远程地址`,type:`url`},"limit.daily_ai_messages":{label:`全局日限额`,desc:`全站每日 AI 消息上限`,type:`number`},"limit.daily_ai_messages_per_user":{label:`每人日限额`,desc:`单用户每日 AI 消息上限`,type:`number`},"limit.max_monthly_budget":{label:`最大月预算`,desc:`用户可设置的最高月预算金额`,type:`number`},"feature.ocr_enabled":{label:`OCR 拍照识别`,desc:`是否开放 OCR 小票识别功能`,type:`switch`},"feature.voice_enabled":{label:`语音输入`,desc:`是否开放语音记账功能`,type:`switch`},"feature.ai_auto_book":{label:`AI 自动入账`,desc:`AI 识别记账意图后是否直接写库`,type:`switch`},"feature.sticker_enabled":{label:`表情包功能`,desc:`是否开放表情包面板和 AI 表情回复`,type:`switch`},"system.default_ledger_name":{label:`默认账本名`,desc:`新用户注册时自动创建`,type:`text`},"system.max_ledgers_per_user":{label:`每人最多账本`,desc:`单用户可创建账本上限`,type:`number`}},A=[{key:`brand`,label:`品牌`,prefix:`brand.`},{key:`limit`,label:`限额`,prefix:`limit.`},{key:`feature`,label:`功能开关`,prefix:`feature.`},{key:`system`,label:`系统`,prefix:`system.`}],j=d(()=>{let e={};for(let t of D.value){let n=A.find(e=>t.key.startsWith(e.prefix))?.key||`other`;e[n]||(e[n]=[]),e[n].push(t)}return e});i(M);async function M(){O.value=!0;try{D.value=await g.configs()}finally{O.value=!1}}let N=a(!1),P=a(null),F=a(``),I=d(()=>P.value?k[P.value.key]:null);function L(e){P.value=e,F.value=e.value,N.value=!0}async function R(){P.value&&(await g.updateConfig(P.value.id,F.value),c.success(`已更新 ${P.value.key}`),N.value=!1,M())}return(t,i)=>{let a=e(`a-button`),c=e(`a-tag`),d=e(`a-card`),g=e(`a-col`),D=e(`a-row`),O=e(`a-tab-pane`),z=e(`a-tabs`),B=e(`a-switch`),V=e(`a-input-number`),H=e(`a-select`),U=e(`a-input`),W=e(`a-modal`);return r(),l(`div`,null,[s(`div`,_,[i[6]||=s(`h2`,{style:{margin:`0`}},`品牌配置`,-1),o(a,{onClick:M},{default:n(()=>[...i[5]||=[m(`刷新`,-1)]]),_:1})]),o(z,null,{default:n(()=>[(r(),l(u,null,h(A,e=>o(O,{key:e.key,tab:e.label},{default:n(()=>[o(D,{gutter:[16,12]},{default:n(()=>[(r(!0),l(u,null,h(j.value[e.key],e=>(r(),p(g,{key:e.id,span:8},{default:n(()=>[o(d,{size:`small`,hoverable:``,onClick:t=>L(e)},{default:n(()=>[s(`div`,v,[s(`div`,null,[s(`div`,y,f(k[e.key]?.label||e.key),1),s(`div`,b,f(k[e.key]?.desc||``),1)]),o(c,{color:`blue`,style:{"margin-left":`8px`}},{default:n(()=>[m(`v`+f(e.version),1)]),_:2},1024)]),k[e.key]?.type===`switch`?(r(),l(`div`,x,[e.value===`true`?(r(),l(`span`,S,`✅ 已开启`)):(r(),l(`span`,C,`❌ 已关闭`))])):(r(),l(`div`,w,f(e.key.includes(`key`)?`••••••••`:e.value||`(空)`),1)),s(`div`,T,f(e.updatedAt?.split(`T`)[0]),1)]),_:2},1032,[`onClick`])]),_:2},1024))),128))]),_:2},1024)]),_:2},1032,[`tab`])),64))]),_:1}),o(W,{open:N.value,"onUpdate:open":i[4]||=e=>N.value=e,title:`编辑配置: ${P.value?.key}`,onOk:R,width:440},{default:n(()=>[s(`div`,E,f(I.value?.desc),1),I.value?.type===`switch`?(r(),p(B,{key:0,checked:F.value===`true`,onChange:i[0]||=e=>F.value=String(e),"checked-children":`开启`,"un-checked-children":`关闭`},null,8,[`checked`])):I.value?.type===`number`?(r(),p(V,{key:1,value:F.value,"onUpdate:value":i[1]||=e=>F.value=e,style:{width:`100%`}},null,8,[`value`])):I.value?.type===`select`&&I.value.options?(r(),p(H,{key:2,value:F.value,"onUpdate:value":i[2]||=e=>F.value=e,style:{width:`100%`},options:I.value.options.map(e=>({value:e,label:e}))},null,8,[`value`,`options`])):(r(),p(U,{key:3,value:F.value,"onUpdate:value":i[3]||=e=>F.value=e},null,8,[`value`]))]),_:1},8,[`open`,`title`])])}}});export{D as default};
|
||||
@@ -1 +0,0 @@
|
||||
import{Bn as e,Cn as t,Kn as n,Ln as r,Pn as i,Qn as a,Sn as o,_n as s,a as c,bn as l,fn as u,gn as d,or as f,vn as p,xn as m,zn as h}from"./config-provider-q7ATIdCu.js";import{t as g}from"./api-C4vz6nB3.js";var _={style:{display:`flex`,"justify-content":`space-between`,"align-items":`center`,"margin-bottom":`20px`}},v={style:{display:`flex`,"justify-content":`space-between`,"align-items":`flex-start`}},y={style:{"font-size":`13px`,"font-weight":`600`,"margin-bottom":`2px`}},b={style:{color:`#999`,"font-size":`11px`,"margin-bottom":`6px`}},x={key:0,style:{"margin-top":`6px`,"font-size":`18px`}},S={key:0,style:{color:`#00B386`}},C={key:1,style:{color:`#ccc`}},w={key:1,style:{"margin-top":`6px`,"font-size":`16px`,"font-weight":`700`,"word-break":`break-all`}},T={style:{color:`#999`,"font-size":`10px`,"margin-top":`4px`}},E={style:{"margin-bottom":`10px`,color:`#999`,"font-size":`12px`}},D=t({__name:`Configs`,setup(t){let D=a([]),O=a(!0),k={"brand.app_name":{label:`App 名称`,desc:`App 内展示名称`,type:`text`},"brand.slogan":{label:`App 标语`,desc:`启动页/关于页口号`,type:`text`},"brand.logo_url":{label:`Logo URL`,desc:`品牌 Logo 远程地址`,type:`url`},"limit.daily_ai_messages":{label:`全局日限额`,desc:`全站每日 AI 消息上限`,type:`number`},"limit.daily_ai_messages_per_user":{label:`每人日限额`,desc:`单用户每日 AI 消息上限`,type:`number`},"limit.max_monthly_budget":{label:`最大月预算`,desc:`用户可设置的最高月预算金额`,type:`number`},"feature.ocr_enabled":{label:`OCR 拍照识别`,desc:`是否开放 OCR 小票识别功能`,type:`switch`},"feature.voice_enabled":{label:`语音输入`,desc:`是否开放语音记账功能`,type:`switch`},"feature.ai_auto_book":{label:`AI 自动入账`,desc:`AI 识别记账意图后是否直接写库`,type:`switch`},"feature.sticker_enabled":{label:`表情包功能`,desc:`是否开放表情包面板和 AI 表情回复`,type:`switch`},"system.default_ledger_name":{label:`默认账本名`,desc:`新用户注册时自动创建`,type:`text`},"system.max_ledgers_per_user":{label:`每人最多账本`,desc:`单用户可创建账本上限`,type:`number`}},A=[{key:`brand`,label:`品牌`,prefix:`brand.`},{key:`limit`,label:`限额`,prefix:`limit.`},{key:`feature`,label:`功能开关`,prefix:`feature.`},{key:`system`,label:`系统`,prefix:`system.`}],j=d(()=>{let e={};for(let t of D.value){let n=A.find(e=>t.key.startsWith(e.prefix))?.key||`other`;e[n]||(e[n]=[]),e[n].push(t)}return e});i(M);async function M(){O.value=!0;try{D.value=await g.configs()}finally{O.value=!1}}let N=a(!1),P=a(null),F=a(``),I=d(()=>P.value?k[P.value.key]:null);function L(e){P.value=e,F.value=e.value,N.value=!0}async function R(){P.value&&(await g.updateConfig(P.value.id,F.value),c.success(`已更新 ${P.value.key}`),N.value=!1,M())}return(t,i)=>{let a=e(`a-button`),c=e(`a-tag`),d=e(`a-card`),g=e(`a-col`),D=e(`a-row`),O=e(`a-tab-pane`),z=e(`a-tabs`),B=e(`a-switch`),V=e(`a-input-number`),H=e(`a-select`),U=e(`a-input`),W=e(`a-modal`);return r(),l(`div`,null,[s(`div`,_,[i[6]||=s(`h2`,{style:{margin:`0`}},`品牌配置`,-1),o(a,{onClick:M},{default:n(()=>[...i[5]||=[m(`刷新`,-1)]]),_:1})]),o(z,null,{default:n(()=>[(r(),l(u,null,h(A,e=>o(O,{key:e.key,tab:e.label},{default:n(()=>[o(D,{gutter:[16,12]},{default:n(()=>[(r(!0),l(u,null,h(j.value[e.key],e=>(r(),p(g,{key:e.id,span:8},{default:n(()=>[o(d,{size:`small`,hoverable:``,onClick:t=>L(e)},{default:n(()=>[s(`div`,v,[s(`div`,null,[s(`div`,y,f(k[e.key]?.label||e.key),1),s(`div`,b,f(k[e.key]?.desc||``),1)]),o(c,{color:`blue`,style:{"margin-left":`8px`}},{default:n(()=>[m(`v`+f(e.version),1)]),_:2},1024)]),k[e.key]?.type===`switch`?(r(),l(`div`,x,[e.value===`true`?(r(),l(`span`,S,`✅ 已开启`)):(r(),l(`span`,C,`❌ 已关闭`))])):(r(),l(`div`,w,f(e.key.includes(`key`)?`••••••••`:e.value||`(空)`),1)),s(`div`,T,f(e.updatedAt?.split(`T`)[0]),1)]),_:2},1032,[`onClick`])]),_:2},1024))),128))]),_:2},1024)]),_:2},1032,[`tab`])),64))]),_:1}),o(W,{open:N.value,"onUpdate:open":i[4]||=e=>N.value=e,title:`编辑配置: ${P.value?.key}`,onOk:R,width:440},{default:n(()=>[s(`div`,E,f(I.value?.desc),1),I.value?.type===`switch`?(r(),p(B,{key:0,checked:F.value===`true`,onChange:i[0]||=e=>F.value=String(e),"checked-children":`开启`,"un-checked-children":`关闭`},null,8,[`checked`])):I.value?.type===`number`?(r(),p(V,{key:1,value:F.value,"onUpdate:value":i[1]||=e=>F.value=e,style:{width:`100%`}},null,8,[`value`])):I.value?.type===`select`&&I.value.options?(r(),p(H,{key:2,value:F.value,"onUpdate:value":i[2]||=e=>F.value=e,style:{width:`100%`},options:I.value.options.map(e=>({value:e,label:e}))},null,8,[`value`,`options`])):(r(),p(U,{key:3,value:F.value,"onUpdate:value":i[3]||=e=>F.value=e},null,8,[`value`]))]),_:1},8,[`open`,`title`])])}}});export{D as default};
|
||||
@@ -0,0 +1 @@
|
||||
import{Bn as e,Cn as t,Kn as n,Ln as r,Pn as i,Qn as a,Sn as o,_n as s,a as c,ar as l,bn as u,fn as d,gn as f,or as p,vn as m,xn as h,zn as g}from"./config-provider-q7ATIdCu.js";import{t as _}from"./api-wmB-hCXT.js";import{t as v}from"./time-pIfF89ap.js";var y={style:{display:`flex`,"justify-content":`space-between`,"align-items":`center`,"margin-bottom":`20px`}},b={style:{display:`flex`,"justify-content":`space-between`,"align-items":`flex-start`}},x={style:{"font-size":`13px`,"font-weight":`600`,"margin-bottom":`2px`}},S={style:{color:`#999`,"font-size":`11px`,"margin-bottom":`6px`}},C={key:0,style:{"margin-top":`6px`,"font-size":`18px`}},w={key:0,style:{color:`#00B386`}},T={key:1,style:{color:`#ccc`}},E={key:1,style:{"margin-top":`6px`,"font-size":`16px`,"font-weight":`700`,"word-break":`break-all`}},D={style:{color:`#999`,"font-size":`10px`,"margin-top":`4px`}},O={style:{"margin-bottom":`10px`,color:`#999`,"font-size":`12px`}},k=t({__name:`Configs`,setup(t){let k=a([]),A=a(!0),j={"brand.app_name":{label:`App 名称`,desc:`App 内展示名称`,type:`text`},"brand.slogan":{label:`App 标语`,desc:`启动页/关于页口号`,type:`text`},"brand.logo_url":{label:`Logo URL`,desc:`品牌 Logo 远程地址`,type:`url`},"limit.daily_ai_messages":{label:`全局日限额`,desc:`全站每日 AI 消息上限`,type:`number`},"limit.daily_ai_messages_per_user":{label:`每人日限额`,desc:`单用户每日 AI 消息上限`,type:`number`},"limit.max_monthly_budget":{label:`最大月预算`,desc:`用户可设置的最高月预算金额`,type:`number`},"feature.ocr_enabled":{label:`OCR 拍照识别`,desc:`是否开放 OCR 小票识别功能`,type:`switch`},"feature.voice_enabled":{label:`语音输入`,desc:`是否开放语音记账功能`,type:`switch`},"feature.ai_auto_book":{label:`AI 自动入账`,desc:`AI 识别记账意图后是否直接写库`,type:`switch`},"feature.sticker_enabled":{label:`表情包功能`,desc:`是否开放表情包面板和 AI 表情回复`,type:`switch`},"system.default_ledger_name":{label:`默认账本名`,desc:`新用户注册时自动创建`,type:`text`},"system.max_ledgers_per_user":{label:`每人最多账本`,desc:`单用户可创建账本上限`,type:`number`}},M=[{key:`brand`,label:`品牌`,prefix:`brand.`},{key:`limit`,label:`限额`,prefix:`limit.`},{key:`feature`,label:`功能开关`,prefix:`feature.`},{key:`system`,label:`系统`,prefix:`system.`}],N=f(()=>{let e={};for(let t of k.value){let n=M.find(e=>t.key.startsWith(e.prefix))?.key||`other`;e[n]||(e[n]=[]),e[n].push(t)}return e});i(P);async function P(){A.value=!0;try{k.value=await _.configs()}finally{A.value=!1}}let F=a(!1),I=a(null),L=a(``),R=f(()=>I.value?j[I.value.key]:null);function z(e){I.value=e,L.value=e.value,F.value=!0}async function B(){I.value&&(await _.updateConfig(I.value.id,L.value),c.success(`已更新 ${I.value.key}`),F.value=!1,P())}return(t,i)=>{let a=e(`a-button`),c=e(`a-tag`),f=e(`a-card`),_=e(`a-col`),k=e(`a-row`),A=e(`a-tab-pane`),V=e(`a-tabs`),H=e(`a-switch`),U=e(`a-input-number`),W=e(`a-select`),G=e(`a-input`),K=e(`a-modal`);return r(),u(`div`,null,[s(`div`,y,[i[6]||=s(`h2`,{style:{margin:`0`}},`品牌配置`,-1),o(a,{onClick:P},{default:n(()=>[...i[5]||=[h(`刷新`,-1)]]),_:1})]),o(V,null,{default:n(()=>[(r(),u(d,null,g(M,e=>o(A,{key:e.key,tab:e.label},{default:n(()=>[o(k,{gutter:[16,12]},{default:n(()=>[(r(!0),u(d,null,g(N.value[e.key],e=>(r(),m(_,{key:e.id,span:8},{default:n(()=>[o(f,{size:`small`,hoverable:``,onClick:t=>z(e)},{default:n(()=>[s(`div`,b,[s(`div`,null,[s(`div`,x,p(j[e.key]?.label||e.key),1),s(`div`,S,p(j[e.key]?.desc||``),1)]),o(c,{color:`blue`,style:{"margin-left":`8px`}},{default:n(()=>[h(`v`+p(e.version),1)]),_:2},1024)]),j[e.key]?.type===`switch`?(r(),u(`div`,C,[e.value===`true`?(r(),u(`span`,w,`✅ 已开启`)):(r(),u(`span`,T,`❌ 已关闭`))])):(r(),u(`div`,E,p(e.key.includes(`key`)?`••••••••`:e.value||`(空)`),1)),s(`div`,D,p(l(v)(e.updatedAt)),1)]),_:2},1032,[`onClick`])]),_:2},1024))),128))]),_:2},1024)]),_:2},1032,[`tab`])),64))]),_:1}),o(K,{open:F.value,"onUpdate:open":i[4]||=e=>F.value=e,title:`编辑配置: ${I.value?.key}`,onOk:B,width:440},{default:n(()=>[s(`div`,O,p(R.value?.desc),1),R.value?.type===`switch`?(r(),m(H,{key:0,checked:L.value===`true`,onChange:i[0]||=e=>L.value=String(e),"checked-children":`开启`,"un-checked-children":`关闭`},null,8,[`checked`])):R.value?.type===`number`?(r(),m(U,{key:1,value:L.value,"onUpdate:value":i[1]||=e=>L.value=e,style:{width:`100%`}},null,8,[`value`])):R.value?.type===`select`&&R.value.options?(r(),m(W,{key:2,value:L.value,"onUpdate:value":i[2]||=e=>L.value=e,style:{width:`100%`},options:R.value.options.map(e=>({value:e,label:e}))},null,8,[`value`,`options`])):(r(),m(G,{key:3,value:L.value,"onUpdate:value":i[3]||=e=>L.value=e},null,8,[`value`]))]),_:1},8,[`open`,`title`])])}}});export{k as default};
|
||||
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
import{Sn as e,y as t}from"./config-provider-q7ATIdCu.js";var n={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M360 184h-8c4.4 0 8-3.6 8-8v8h304v-8c0 4.4 3.6 8 8 8h-8v72h72v-80c0-35.3-28.7-64-64-64H352c-35.3 0-64 28.7-64 64v80h72v-72zm504 72H160c-17.7 0-32 14.3-32 32v32c0 4.4 3.6 8 8 8h60.4l24.7 523c1.6 34.1 29.8 61 63.9 61h454c34.2 0 62.3-26.8 63.9-61l24.7-523H888c4.4 0 8-3.6 8-8v-32c0-17.7-14.3-32-32-32zM731.3 840H292.7l-24.2-512h487l-24.2 512z`}}]},name:`delete`,theme:`outlined`};function r(e){for(var t=1;t<arguments.length;t++){var n=arguments[t]==null?{}:Object(arguments[t]),r=Object.keys(n);typeof Object.getOwnPropertySymbols==`function`&&(r=r.concat(Object.getOwnPropertySymbols(n).filter(function(e){return Object.getOwnPropertyDescriptor(n,e).enumerable}))),r.forEach(function(t){i(e,t,n[t])})}return e}function i(e,t,n){return t in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}var a=function(i,a){return e(t,r({},r({},i,a.attrs),{icon:n}),null)};a.displayName=`DeleteOutlined`,a.inheritAttrs=!1;export{a as t};
|
||||
@@ -1 +0,0 @@
|
||||
import{Sn as e,y as t}from"./config-provider-q7ATIdCu.js";var n={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M482 152h60q8 0 8 8v704q0 8-8 8h-60q-8 0-8-8V160q0-8 8-8z`}},{tag:`path`,attrs:{d:`M192 474h672q8 0 8 8v60q0 8-8 8H160q-8 0-8-8v-60q0-8 8-8z`}}]},name:`plus`,theme:`outlined`};function r(e){for(var t=1;t<arguments.length;t++){var n=arguments[t]==null?{}:Object(arguments[t]),r=Object.keys(n);typeof Object.getOwnPropertySymbols==`function`&&(r=r.concat(Object.getOwnPropertySymbols(n).filter(function(e){return Object.getOwnPropertyDescriptor(n,e).enumerable}))),r.forEach(function(t){i(e,t,n[t])})}return e}function i(e,t,n){return t in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}var a=function(i,a){return e(t,r({},r({},i,a.attrs),{icon:n}),null)};a.displayName=`PlusOutlined`,a.inheritAttrs=!1;var o={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M360 184h-8c4.4 0 8-3.6 8-8v8h304v-8c0 4.4 3.6 8 8 8h-8v72h72v-80c0-35.3-28.7-64-64-64H352c-35.3 0-64 28.7-64 64v80h72v-72zm504 72H160c-17.7 0-32 14.3-32 32v32c0 4.4 3.6 8 8 8h60.4l24.7 523c1.6 34.1 29.8 61 63.9 61h454c34.2 0 62.3-26.8 63.9-61l24.7-523H888c4.4 0 8-3.6 8-8v-32c0-17.7-14.3-32-32-32zM731.3 840H292.7l-24.2-512h487l-24.2 512z`}}]},name:`delete`,theme:`outlined`};function s(e){for(var t=1;t<arguments.length;t++){var n=arguments[t]==null?{}:Object(arguments[t]),r=Object.keys(n);typeof Object.getOwnPropertySymbols==`function`&&(r=r.concat(Object.getOwnPropertySymbols(n).filter(function(e){return Object.getOwnPropertyDescriptor(n,e).enumerable}))),r.forEach(function(t){c(e,t,n[t])})}return e}function c(e,t,n){return t in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}var l=function(n,r){return e(t,s({},s({},n,r.attrs),{icon:o}),null)};l.displayName=`DeleteOutlined`,l.inheritAttrs=!1;var u={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M257.7 752c2 0 4-.2 6-.5L431.9 722c2-.4 3.9-1.3 5.3-2.8l423.9-423.9a9.96 9.96 0 000-14.1L694.9 114.9c-1.9-1.9-4.4-2.9-7.1-2.9s-5.2 1-7.1 2.9L256.8 538.8c-1.5 1.5-2.4 3.3-2.8 5.3l-29.5 168.2a33.5 33.5 0 009.4 29.8c6.6 6.4 14.9 9.9 23.8 9.9zm67.4-174.4L687.8 215l73.3 73.3-362.7 362.6-88.9 15.7 15.6-89zM880 836H144c-17.7 0-32 14.3-32 32v36c0 4.4 3.6 8 8 8h784c4.4 0 8-3.6 8-8v-36c0-17.7-14.3-32-32-32z`}}]},name:`edit`,theme:`outlined`};function d(e){for(var t=1;t<arguments.length;t++){var n=arguments[t]==null?{}:Object(arguments[t]),r=Object.keys(n);typeof Object.getOwnPropertySymbols==`function`&&(r=r.concat(Object.getOwnPropertySymbols(n).filter(function(e){return Object.getOwnPropertyDescriptor(n,e).enumerable}))),r.forEach(function(t){f(e,t,n[t])})}return e}function f(e,t,n){return t in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}var p=function(n,r){return e(t,d({},d({},n,r.attrs),{icon:u}),null)};p.displayName=`EditOutlined`,p.inheritAttrs=!1;export{l as n,a as r,p as t};
|
||||
@@ -0,0 +1 @@
|
||||
import{Sn as e,y as t}from"./config-provider-q7ATIdCu.js";var n={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M482 152h60q8 0 8 8v704q0 8-8 8h-60q-8 0-8-8V160q0-8 8-8z`}},{tag:`path`,attrs:{d:`M192 474h672q8 0 8 8v60q0 8-8 8H160q-8 0-8-8v-60q0-8 8-8z`}}]},name:`plus`,theme:`outlined`};function r(e){for(var t=1;t<arguments.length;t++){var n=arguments[t]==null?{}:Object(arguments[t]),r=Object.keys(n);typeof Object.getOwnPropertySymbols==`function`&&(r=r.concat(Object.getOwnPropertySymbols(n).filter(function(e){return Object.getOwnPropertyDescriptor(n,e).enumerable}))),r.forEach(function(t){i(e,t,n[t])})}return e}function i(e,t,n){return t in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}var a=function(i,a){return e(t,r({},r({},i,a.attrs),{icon:n}),null)};a.displayName=`PlusOutlined`,a.inheritAttrs=!1;var o={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M257.7 752c2 0 4-.2 6-.5L431.9 722c2-.4 3.9-1.3 5.3-2.8l423.9-423.9a9.96 9.96 0 000-14.1L694.9 114.9c-1.9-1.9-4.4-2.9-7.1-2.9s-5.2 1-7.1 2.9L256.8 538.8c-1.5 1.5-2.4 3.3-2.8 5.3l-29.5 168.2a33.5 33.5 0 009.4 29.8c6.6 6.4 14.9 9.9 23.8 9.9zm67.4-174.4L687.8 215l73.3 73.3-362.7 362.6-88.9 15.7 15.6-89zM880 836H144c-17.7 0-32 14.3-32 32v36c0 4.4 3.6 8 8 8h784c4.4 0 8-3.6 8-8v-36c0-17.7-14.3-32-32-32z`}}]},name:`edit`,theme:`outlined`};function s(e){for(var t=1;t<arguments.length;t++){var n=arguments[t]==null?{}:Object(arguments[t]),r=Object.keys(n);typeof Object.getOwnPropertySymbols==`function`&&(r=r.concat(Object.getOwnPropertySymbols(n).filter(function(e){return Object.getOwnPropertyDescriptor(n,e).enumerable}))),r.forEach(function(t){c(e,t,n[t])})}return e}function c(e,t,n){return t in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}var l=function(n,r){return e(t,s({},s({},n,r.attrs),{icon:o}),null)};l.displayName=`EditOutlined`,l.inheritAttrs=!1;export{a as n,l as t};
|
||||
@@ -0,0 +1 @@
|
||||
import{Bn as e,Cn as t,Kn as n,Ln as r,Pn as i,Qn as a,Sn as o,_n as s,a as c,ar as l,bn as u,fn as d,or as f,vn as p,xn as m,yn as h}from"./config-provider-q7ATIdCu.js";import{n as g,t as _}from"./EditOutlined-h6ScL3Qz.js";import{t as v}from"./DeleteOutlined-yVoeJ3Fd.js";import{t as y}from"./api-wmB-hCXT.js";var b={style:{display:`flex`,"justify-content":`space-between`,"align-items":`center`,"margin-bottom":`16px`}},x=t({__name:`Personas`,setup(t){let x=a([]),S=a(!0),C=a(!1),w=a(null),T=a({key:``,name:``,description:``,sampleLine:``,promptTemplate:``,isEnabled:!0}),E=[{title:`Key`,dataIndex:`key`,key:`key`,width:110},{title:`名称`,dataIndex:`name`,key:`name`,width:100},{title:`描述`,dataIndex:`description`,key:`desc`,ellipsis:!0},{title:`版本`,dataIndex:`version`,key:`ver`,width:60},{title:`状态`,dataIndex:`isEnabled`,key:`on`,width:70},{title:``,key:`act`,width:150}];i(D);async function D(){S.value=!0;try{x.value=await y.personas()}finally{S.value=!1}}function O(){w.value=null,T.value={key:``,name:``,description:``,sampleLine:``,promptTemplate:``,isEnabled:!0},C.value=!0}function k(e){w.value=e,T.value={key:e.key,name:e.name,description:e.description,sampleLine:e.sampleLine,promptTemplate:e.promptTemplate,isEnabled:e.isEnabled},C.value=!0}async function A(){let e={...T.value,isEnabled:T.value.isEnabled};w.value?await y.updatePersona(w.value.id,e):await y.createPersona(e),c.success(w.value?`已更新(版本号+1)`:`已创建`),C.value=!1,D()}async function j(e){await y.deletePersona(e),c.success(`已删除`),D()}return(t,i)=>{let a=e(`a-button`),c=e(`a-tag`),y=e(`a-popconfirm`),D=e(`a-table`),M=e(`a-input`),N=e(`a-form-item`),P=e(`a-col`),F=e(`a-row`),I=e(`a-textarea`),L=e(`a-switch`),R=e(`a-form`),z=e(`a-modal`);return r(),u(d,null,[s(`div`,b,[i[8]||=s(`h2`,null,`AI 性格管理`,-1),o(a,{type:`primary`,onClick:O},{default:n(()=>[o(l(g)),i[7]||=m(` 新建性格`,-1)]),_:1})]),o(D,{columns:E,dataSource:x.value,loading:S.value,rowKey:`id`,size:`small`,pagination:{pageSize:10}},{bodyCell:n(({column:e,record:t})=>[e.key===`on`?(r(),p(c,{key:0,color:t.isEnabled?`green`:`default`},{default:n(()=>[m(f(t.isEnabled?`启用`:`禁用`),1)]),_:2},1032,[`color`])):h(``,!0),e.key===`act`?(r(),u(d,{key:1},[o(a,{size:`small`,style:{"margin-right":`6px`},onClick:e=>k(t)},{default:n(()=>[o(l(_))]),_:1},8,[`onClick`]),o(y,{title:`确定删除?`,onConfirm:e=>j(t.id)},{default:n(()=>[o(a,{size:`small`,danger:``},{default:n(()=>[o(l(v))]),_:1})]),_:1},8,[`onConfirm`])],64)):h(``,!0)]),_:1},8,[`dataSource`,`loading`]),o(z,{open:C.value,"onUpdate:open":i[6]||=e=>C.value=e,title:w.value?`编辑性格`:`新建性格`,onOk:A,width:560},{default:n(()=>[o(R,{layout:`vertical`,style:{"margin-top":`8px`}},{default:n(()=>[o(F,{gutter:12},{default:n(()=>[o(P,{span:12},{default:n(()=>[o(N,{label:`Key`},{default:n(()=>[o(M,{value:T.value.key,"onUpdate:value":i[0]||=e=>T.value.key=e,placeholder:`sassy_cat`},null,8,[`value`])]),_:1})]),_:1}),o(P,{span:12},{default:n(()=>[o(N,{label:`名称`},{default:n(()=>[o(M,{value:T.value.name,"onUpdate:value":i[1]||=e=>T.value.name=e,placeholder:`毒舌猫娘`},null,8,[`value`])]),_:1})]),_:1})]),_:1}),o(N,{label:`描述`},{default:n(()=>[o(M,{value:T.value.description,"onUpdate:value":i[2]||=e=>T.value.description=e,placeholder:`一句话描述`},null,8,[`value`])]),_:1}),o(N,{label:`示例台词`},{default:n(()=>[o(M,{value:T.value.sampleLine,"onUpdate:value":i[3]||=e=>T.value.sampleLine=e,placeholder:`这句会展示给用户选性格时看`},null,8,[`value`])]),_:1}),o(N,{label:`Prompt 模板`},{default:n(()=>[o(I,{value:T.value.promptTemplate,"onUpdate:value":i[4]||=e=>T.value.promptTemplate=e,rows:5,placeholder:`系统提示词模板,支持 {tic} 占位符,此字段可调不发版`},null,8,[`value`]),i[9]||=s(`div`,{style:{color:`#999`,"font-size":`11px`,"margin-top":`4px`}},[m(` ⚡ 调这个不需要发版,前端实时生效。可用占位符:`),s(`code`,null,`{'{tic}'}`),m(` = 口癖(喵/汪/无) `)],-1)]),_:1}),o(N,{label:`是否启用`},{default:n(()=>[o(L,{checked:T.value.isEnabled,"onUpdate:checked":i[5]||=e=>T.value.isEnabled=e},null,8,[`checked`])]),_:1})]),_:1})]),_:1},8,[`open`,`title`])],64)}}});export{x as default};
|
||||
@@ -1 +0,0 @@
|
||||
import{Bn as e,Cn as t,Kn as n,Ln as r,Pn as i,Qn as a,Sn as o,_n as s,a as c,ar as l,bn as u,fn as d,or as f,vn as p,xn as m,yn as h}from"./config-provider-q7ATIdCu.js";import{n as g,r as _,t as v}from"./EditOutlined-CeylGsUo.js";import{t as y}from"./api-C4vz6nB3.js";var b={style:{display:`flex`,"justify-content":`space-between`,"align-items":`center`,"margin-bottom":`16px`}},x=t({__name:`Personas`,setup(t){let x=a([]),S=a(!0),C=a(!1),w=a(null),T=a({key:``,name:``,description:``,sampleLine:``,promptTemplate:``,isEnabled:!0}),E=[{title:`Key`,dataIndex:`key`,key:`key`,width:110},{title:`名称`,dataIndex:`name`,key:`name`,width:100},{title:`描述`,dataIndex:`description`,key:`desc`,ellipsis:!0},{title:`版本`,dataIndex:`version`,key:`ver`,width:60},{title:`状态`,dataIndex:`isEnabled`,key:`on`,width:70},{title:``,key:`act`,width:150}];i(D);async function D(){S.value=!0;try{x.value=await y.personas()}finally{S.value=!1}}function O(){w.value=null,T.value={key:``,name:``,description:``,sampleLine:``,promptTemplate:``,isEnabled:!0},C.value=!0}function k(e){w.value=e,T.value={key:e.key,name:e.name,description:e.description,sampleLine:e.sampleLine,promptTemplate:e.promptTemplate,isEnabled:e.isEnabled},C.value=!0}async function A(){let e={...T.value,isEnabled:T.value.isEnabled};w.value?await y.updatePersona(w.value.id,e):await y.createPersona(e),c.success(w.value?`已更新(版本号+1)`:`已创建`),C.value=!1,D()}async function j(e){await y.deletePersona(e),c.success(`已删除`),D()}return(t,i)=>{let a=e(`a-button`),c=e(`a-tag`),y=e(`a-popconfirm`),D=e(`a-table`),M=e(`a-input`),N=e(`a-form-item`),P=e(`a-col`),F=e(`a-row`),I=e(`a-textarea`),L=e(`a-switch`),R=e(`a-form`),z=e(`a-modal`);return r(),u(d,null,[s(`div`,b,[i[8]||=s(`h2`,null,`AI 性格管理`,-1),o(a,{type:`primary`,onClick:O},{default:n(()=>[o(l(_)),i[7]||=m(` 新建性格`,-1)]),_:1})]),o(D,{columns:E,dataSource:x.value,loading:S.value,rowKey:`id`,size:`small`,pagination:{pageSize:10}},{bodyCell:n(({column:e,record:t})=>[e.key===`on`?(r(),p(c,{key:0,color:t.isEnabled?`green`:`default`},{default:n(()=>[m(f(t.isEnabled?`启用`:`禁用`),1)]),_:2},1032,[`color`])):h(``,!0),e.key===`act`?(r(),u(d,{key:1},[o(a,{size:`small`,style:{"margin-right":`6px`},onClick:e=>k(t)},{default:n(()=>[o(l(v))]),_:1},8,[`onClick`]),o(y,{title:`确定删除?`,onConfirm:e=>j(t.id)},{default:n(()=>[o(a,{size:`small`,danger:``},{default:n(()=>[o(l(g))]),_:1})]),_:1},8,[`onConfirm`])],64)):h(``,!0)]),_:1},8,[`dataSource`,`loading`]),o(z,{open:C.value,"onUpdate:open":i[6]||=e=>C.value=e,title:w.value?`编辑性格`:`新建性格`,onOk:A,width:560},{default:n(()=>[o(R,{layout:`vertical`,style:{"margin-top":`8px`}},{default:n(()=>[o(F,{gutter:12},{default:n(()=>[o(P,{span:12},{default:n(()=>[o(N,{label:`Key`},{default:n(()=>[o(M,{value:T.value.key,"onUpdate:value":i[0]||=e=>T.value.key=e,placeholder:`sassy_cat`},null,8,[`value`])]),_:1})]),_:1}),o(P,{span:12},{default:n(()=>[o(N,{label:`名称`},{default:n(()=>[o(M,{value:T.value.name,"onUpdate:value":i[1]||=e=>T.value.name=e,placeholder:`毒舌猫娘`},null,8,[`value`])]),_:1})]),_:1})]),_:1}),o(N,{label:`描述`},{default:n(()=>[o(M,{value:T.value.description,"onUpdate:value":i[2]||=e=>T.value.description=e,placeholder:`一句话描述`},null,8,[`value`])]),_:1}),o(N,{label:`示例台词`},{default:n(()=>[o(M,{value:T.value.sampleLine,"onUpdate:value":i[3]||=e=>T.value.sampleLine=e,placeholder:`这句会展示给用户选性格时看`},null,8,[`value`])]),_:1}),o(N,{label:`Prompt 模板`},{default:n(()=>[o(I,{value:T.value.promptTemplate,"onUpdate:value":i[4]||=e=>T.value.promptTemplate=e,rows:5,placeholder:`系统提示词模板,支持 {tic} 占位符,此字段可调不发版`},null,8,[`value`]),i[9]||=s(`div`,{style:{color:`#999`,"font-size":`11px`,"margin-top":`4px`}},[m(` ⚡ 调这个不需要发版,前端实时生效。可用占位符:`),s(`code`,null,`{'{tic}'}`),m(` = 口癖(喵/汪/无) `)],-1)]),_:1}),o(N,{label:`是否启用`},{default:n(()=>[o(L,{checked:T.value.isEnabled,"onUpdate:checked":i[5]||=e=>T.value.isEnabled=e},null,8,[`checked`])]),_:1})]),_:1})]),_:1},8,[`open`,`title`])],64)}}});export{x as default};
|
||||
@@ -1 +0,0 @@
|
||||
import{Bn as e,Cn as t,Kn as n,Ln as r,Pn as i,Qn as a,Sn as o,_n as s,a as c,ar as l,bn as u,fn as d,or as f,vn as p,xn as m,yn as h}from"./config-provider-q7ATIdCu.js";import{n as g,r as _,t as v}from"./EditOutlined-CeylGsUo.js";import{t as y}from"./api-BV_Zb8mM.js";var b={style:{display:`flex`,"justify-content":`space-between`,"align-items":`center`,"margin-bottom":`16px`}},x=t({__name:`Personas`,setup(t){let x=a([]),S=a(!0),C=a(!1),w=a(null),T=a({key:``,name:``,description:``,sampleLine:``,promptTemplate:``,isEnabled:!0}),E=[{title:`Key`,dataIndex:`key`,key:`key`,width:110},{title:`名称`,dataIndex:`name`,key:`name`,width:100},{title:`描述`,dataIndex:`description`,key:`desc`,ellipsis:!0},{title:`版本`,dataIndex:`version`,key:`ver`,width:60},{title:`状态`,dataIndex:`isEnabled`,key:`on`,width:70},{title:``,key:`act`,width:150}];i(D);async function D(){S.value=!0;try{x.value=await y.personas()}finally{S.value=!1}}function O(){w.value=null,T.value={key:``,name:``,description:``,sampleLine:``,promptTemplate:``,isEnabled:!0},C.value=!0}function k(e){w.value=e,T.value={key:e.key,name:e.name,description:e.description,sampleLine:e.sampleLine,promptTemplate:e.promptTemplate,isEnabled:e.isEnabled},C.value=!0}async function A(){let e={...T.value,isEnabled:T.value.isEnabled};w.value?await y.updatePersona(w.value.id,e):await y.createPersona(e),c.success(w.value?`已更新(版本号+1)`:`已创建`),C.value=!1,D()}async function j(e){await y.deletePersona(e),c.success(`已删除`),D()}return(t,i)=>{let a=e(`a-button`),c=e(`a-tag`),y=e(`a-popconfirm`),D=e(`a-table`),M=e(`a-input`),N=e(`a-form-item`),P=e(`a-col`),F=e(`a-row`),I=e(`a-textarea`),L=e(`a-switch`),R=e(`a-form`),z=e(`a-modal`);return r(),u(d,null,[s(`div`,b,[i[8]||=s(`h2`,null,`AI 性格管理`,-1),o(a,{type:`primary`,onClick:O},{default:n(()=>[o(l(_)),i[7]||=m(` 新建性格`,-1)]),_:1})]),o(D,{columns:E,dataSource:x.value,loading:S.value,rowKey:`id`,size:`small`,pagination:{pageSize:10}},{bodyCell:n(({column:e,record:t})=>[e.key===`on`?(r(),p(c,{key:0,color:t.isEnabled?`green`:`default`},{default:n(()=>[m(f(t.isEnabled?`启用`:`禁用`),1)]),_:2},1032,[`color`])):h(``,!0),e.key===`act`?(r(),u(d,{key:1},[o(a,{size:`small`,style:{"margin-right":`6px`},onClick:e=>k(t)},{default:n(()=>[o(l(v))]),_:1},8,[`onClick`]),o(y,{title:`确定删除?`,onConfirm:e=>j(t.id)},{default:n(()=>[o(a,{size:`small`,danger:``},{default:n(()=>[o(l(g))]),_:1})]),_:1},8,[`onConfirm`])],64)):h(``,!0)]),_:1},8,[`dataSource`,`loading`]),o(z,{open:C.value,"onUpdate:open":i[6]||=e=>C.value=e,title:w.value?`编辑性格`:`新建性格`,onOk:A,width:560},{default:n(()=>[o(R,{layout:`vertical`,style:{"margin-top":`8px`}},{default:n(()=>[o(F,{gutter:12},{default:n(()=>[o(P,{span:12},{default:n(()=>[o(N,{label:`Key`},{default:n(()=>[o(M,{value:T.value.key,"onUpdate:value":i[0]||=e=>T.value.key=e,placeholder:`sassy_cat`},null,8,[`value`])]),_:1})]),_:1}),o(P,{span:12},{default:n(()=>[o(N,{label:`名称`},{default:n(()=>[o(M,{value:T.value.name,"onUpdate:value":i[1]||=e=>T.value.name=e,placeholder:`毒舌猫娘`},null,8,[`value`])]),_:1})]),_:1})]),_:1}),o(N,{label:`描述`},{default:n(()=>[o(M,{value:T.value.description,"onUpdate:value":i[2]||=e=>T.value.description=e,placeholder:`一句话描述`},null,8,[`value`])]),_:1}),o(N,{label:`示例台词`},{default:n(()=>[o(M,{value:T.value.sampleLine,"onUpdate:value":i[3]||=e=>T.value.sampleLine=e,placeholder:`这句会展示给用户选性格时看`},null,8,[`value`])]),_:1}),o(N,{label:`Prompt 模板`},{default:n(()=>[o(I,{value:T.value.promptTemplate,"onUpdate:value":i[4]||=e=>T.value.promptTemplate=e,rows:5,placeholder:`系统提示词模板,支持 {tic} 占位符,此字段可调不发版`},null,8,[`value`]),i[9]||=s(`div`,{style:{color:`#999`,"font-size":`11px`,"margin-top":`4px`}},[m(` ⚡ 调这个不需要发版,前端实时生效。可用占位符:`),s(`code`,null,`{'{tic}'}`),m(` = 口癖(喵/汪/无) `)],-1)]),_:1}),o(N,{label:`是否启用`},{default:n(()=>[o(L,{checked:T.value.isEnabled,"onUpdate:checked":i[5]||=e=>T.value.isEnabled=e},null,8,[`checked`])]),_:1})]),_:1})]),_:1},8,[`open`,`title`])],64)}}});export{x as default};
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
.page-header[data-v-3ceae462],.toolbar[data-v-3ceae462],.modal-actions[data-v-3ceae462],.estimate-row[data-v-3ceae462]{justify-content:space-between;align-items:center;gap:12px;display:flex}.page-header[data-v-3ceae462]{margin-bottom:16px}.page-header h2[data-v-3ceae462]{margin:0}.toolbar[data-v-3ceae462]{margin-bottom:12px}.subtle[data-v-3ceae462]{color:#8c8c8c;font-size:12px}.campaign-title[data-v-3ceae462]{margin-bottom:3px;font-weight:600}.campaign-body[data-v-3ceae462]{color:#595959;white-space:pre-wrap;margin-bottom:6px;font-size:12px}.action-label[data-v-3ceae462]{color:#8c8c8c;font-size:12px}.mono[data-v-3ceae462]{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}.estimate-row[data-v-3ceae462]{justify-content:flex-start;min-height:32px;margin-bottom:16px}.modal-actions[data-v-3ceae462]{justify-content:flex-end;padding-top:4px}@media (width<=760px){.page-header[data-v-3ceae462]{flex-direction:column;align-items:flex-start}}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
import{Bn as e,Cn as t,Kn as n,Ln as r,Pn as i,Qn as a,Sn as o,_n as s,a as c,ar as l,bn as u,fn as d,or as f,vn as p,xn as m,yn as h,zn as g}from"./config-provider-q7ATIdCu.js";import{n as _,r as v,t as y}from"./EditOutlined-CeylGsUo.js";import{t as b}from"./api-C4vz6nB3.js";var x={style:{display:`flex`,"justify-content":`space-between`,"align-items":`center`,"margin-bottom":`16px`}},S={key:1,style:{color:`#ccc`}},C=t({__name:`Stickers`,setup(t){let C=a([]),w=a(!0),T=a(!1),E=a(null),D=a({key:``,label:``,groupKey:`classic`,triggerTags:``,imageUrl:``,isEnabled:!0}),O=[{title:`Key`,dataIndex:`key`,key:`key`,width:110},{title:`名称`,dataIndex:`label`,key:`label`,width:100},{title:`分组`,dataIndex:`groupKey`,key:`group`,width:90},{title:`触发标签`,dataIndex:`triggerTags`,key:`tags`,width:200},{title:`状态`,dataIndex:`isEnabled`,key:`on`,width:70},{title:``,key:`act`,width:150}];i(k);async function k(){w.value=!0;try{C.value=await b.stickers()}finally{w.value=!1}}function A(){E.value=null,D.value={key:``,label:``,groupKey:`classic`,triggerTags:``,imageUrl:``,isEnabled:!0},T.value=!0}function j(e){E.value=e,D.value={key:e.key,label:e.label,groupKey:e.groupKey,triggerTags:e.triggerTags||``,imageUrl:e.imageUrl||``,isEnabled:e.isEnabled},T.value=!0}async function M(){let e={...D.value,triggerTags:D.value.triggerTags||null,imageUrl:D.value.imageUrl||null};E.value?await b.updateSticker(E.value.id,e):await b.createSticker(e),c.success(E.value?`已更新`:`已创建`),T.value=!1,k()}async function N(e){await b.deleteSticker(e),c.success(`已删除`),k()}return(t,i)=>{let a=e(`a-button`),c=e(`a-tag`),b=e(`a-popconfirm`),k=e(`a-table`),P=e(`a-input`),F=e(`a-form-item`),I=e(`a-col`),L=e(`a-row`),R=e(`a-select-option`),z=e(`a-select`),B=e(`a-switch`),V=e(`a-form`),H=e(`a-modal`);return r(),u(d,null,[s(`div`,x,[i[8]||=s(`h2`,null,`表情包库`,-1),o(a,{type:`primary`,onClick:A},{default:n(()=>[o(l(v)),i[7]||=m(` 新建表情包`,-1)]),_:1})]),o(k,{columns:O,dataSource:C.value,loading:w.value,rowKey:`id`,size:`small`,pagination:{pageSize:10}},{bodyCell:n(({column:e,record:t})=>[e.key===`tags`?(r(),u(d,{key:0},[t.triggerTags?(r(!0),u(d,{key:0},g((t.triggerTags||``).split(`,`).filter(Boolean),e=>(r(),p(c,{key:e,color:`blue`,style:{margin:`1px 2px`}},{default:n(()=>[m(f(e),1)]),_:2},1024))),128)):(r(),u(`span`,S,`-`))],64)):h(``,!0),e.key===`on`?(r(),p(c,{key:1,color:t.isEnabled?`green`:`default`},{default:n(()=>[m(f(t.isEnabled?`启用`:`禁用`),1)]),_:2},1032,[`color`])):h(``,!0),e.key===`act`?(r(),u(d,{key:2},[o(a,{size:`small`,style:{"margin-right":`6px`},onClick:e=>j(t)},{default:n(()=>[o(l(y))]),_:1},8,[`onClick`]),o(b,{title:`确定删除?`,onConfirm:e=>N(t.id)},{default:n(()=>[o(a,{size:`small`,danger:``},{default:n(()=>[o(l(_))]),_:1})]),_:1},8,[`onConfirm`])],64)):h(``,!0)]),_:1},8,[`dataSource`,`loading`]),o(H,{open:T.value,"onUpdate:open":i[6]||=e=>T.value=e,title:E.value?`编辑表情包`:`新建表情包`,onOk:M,width:520},{default:n(()=>[o(V,{layout:`vertical`,style:{"margin-top":`8px`}},{default:n(()=>[o(L,{gutter:12},{default:n(()=>[o(I,{span:12},{default:n(()=>[o(F,{label:`Key`},{default:n(()=>[o(P,{value:D.value.key,"onUpdate:value":i[0]||=e=>D.value.key=e,placeholder:`salary`},null,8,[`value`])]),_:1})]),_:1}),o(I,{span:12},{default:n(()=>[o(F,{label:`名称`},{default:n(()=>[o(P,{value:D.value.label,"onUpdate:value":i[1]||=e=>D.value.label=e,placeholder:`发工资啦`},null,8,[`value`])]),_:1})]),_:1})]),_:1}),o(L,{gutter:12},{default:n(()=>[o(I,{span:12},{default:n(()=>[o(F,{label:`分组`},{default:n(()=>[o(z,{value:D.value.groupKey,"onUpdate:value":i[2]||=e=>D.value.groupKey=e},{default:n(()=>[o(R,{value:`ai_exclusive`},{default:n(()=>[...i[9]||=[m(`🤖 AI 专属`,-1)]]),_:1}),o(R,{value:`classic`},{default:n(()=>[...i[10]||=[m(`📦 经典`,-1)]]),_:1})]),_:1},8,[`value`])]),_:1})]),_:1}),o(I,{span:12},{default:n(()=>[o(F,{label:`图片 URL`},{default:n(()=>[o(P,{value:D.value.imageUrl,"onUpdate:value":i[3]||=e=>D.value.imageUrl=e,placeholder:`可选 CDN 地址`},null,8,[`value`])]),_:1})]),_:1})]),_:1}),o(F,{label:`触发标签`},{default:n(()=>[o(P,{value:D.value.triggerTags,"onUpdate:value":i[4]||=e=>D.value.triggerTags=e,placeholder:`over_budget,salary,forgive,逗号分隔`},null,8,[`value`]),i[11]||=s(`div`,{style:{color:`#999`,"font-size":`11px`,"margin-top":`4px`}},`标签匹配用户场景,AI 自动选择对应表情包`,-1)]),_:1}),o(F,{label:`是否启用`},{default:n(()=>[o(B,{checked:D.value.isEnabled,"onUpdate:checked":i[5]||=e=>D.value.isEnabled=e},null,8,[`checked`])]),_:1})]),_:1})]),_:1},8,[`open`,`title`])],64)}}});export{C as default};
|
||||
@@ -1 +0,0 @@
|
||||
import{Bn as e,Cn as t,Kn as n,Ln as r,Pn as i,Qn as a,Sn as o,_n as s,a as c,ar as l,bn as u,fn as d,or as f,vn as p,xn as m,yn as h,zn as g}from"./config-provider-q7ATIdCu.js";import{n as _,r as v,t as y}from"./EditOutlined-CeylGsUo.js";import{t as b}from"./api-BV_Zb8mM.js";var x={style:{display:`flex`,"justify-content":`space-between`,"align-items":`center`,"margin-bottom":`16px`}},S={key:1,style:{color:`#ccc`}},C=t({__name:`Stickers`,setup(t){let C=a([]),w=a(!0),T=a(!1),E=a(null),D=a({key:``,label:``,groupKey:`classic`,triggerTags:``,imageUrl:``,isEnabled:!0}),O=[{title:`Key`,dataIndex:`key`,key:`key`,width:110},{title:`名称`,dataIndex:`label`,key:`label`,width:100},{title:`分组`,dataIndex:`groupKey`,key:`group`,width:90},{title:`触发标签`,dataIndex:`triggerTags`,key:`tags`,width:200},{title:`状态`,dataIndex:`isEnabled`,key:`on`,width:70},{title:``,key:`act`,width:150}];i(k);async function k(){w.value=!0;try{C.value=await b.stickers()}finally{w.value=!1}}function A(){E.value=null,D.value={key:``,label:``,groupKey:`classic`,triggerTags:``,imageUrl:``,isEnabled:!0},T.value=!0}function j(e){E.value=e,D.value={key:e.key,label:e.label,groupKey:e.groupKey,triggerTags:e.triggerTags||``,imageUrl:e.imageUrl||``,isEnabled:e.isEnabled},T.value=!0}async function M(){let e={...D.value,triggerTags:D.value.triggerTags||null,imageUrl:D.value.imageUrl||null};E.value?await b.updateSticker(E.value.id,e):await b.createSticker(e),c.success(E.value?`已更新`:`已创建`),T.value=!1,k()}async function N(e){await b.deleteSticker(e),c.success(`已删除`),k()}return(t,i)=>{let a=e(`a-button`),c=e(`a-tag`),b=e(`a-popconfirm`),k=e(`a-table`),P=e(`a-input`),F=e(`a-form-item`),I=e(`a-col`),L=e(`a-row`),R=e(`a-select-option`),z=e(`a-select`),B=e(`a-switch`),V=e(`a-form`),H=e(`a-modal`);return r(),u(d,null,[s(`div`,x,[i[8]||=s(`h2`,null,`表情包库`,-1),o(a,{type:`primary`,onClick:A},{default:n(()=>[o(l(v)),i[7]||=m(` 新建表情包`,-1)]),_:1})]),o(k,{columns:O,dataSource:C.value,loading:w.value,rowKey:`id`,size:`small`,pagination:{pageSize:10}},{bodyCell:n(({column:e,record:t})=>[e.key===`tags`?(r(),u(d,{key:0},[t.triggerTags?(r(!0),u(d,{key:0},g((t.triggerTags||``).split(`,`).filter(Boolean),e=>(r(),p(c,{key:e,color:`blue`,style:{margin:`1px 2px`}},{default:n(()=>[m(f(e),1)]),_:2},1024))),128)):(r(),u(`span`,S,`-`))],64)):h(``,!0),e.key===`on`?(r(),p(c,{key:1,color:t.isEnabled?`green`:`default`},{default:n(()=>[m(f(t.isEnabled?`启用`:`禁用`),1)]),_:2},1032,[`color`])):h(``,!0),e.key===`act`?(r(),u(d,{key:2},[o(a,{size:`small`,style:{"margin-right":`6px`},onClick:e=>j(t)},{default:n(()=>[o(l(y))]),_:1},8,[`onClick`]),o(b,{title:`确定删除?`,onConfirm:e=>N(t.id)},{default:n(()=>[o(a,{size:`small`,danger:``},{default:n(()=>[o(l(_))]),_:1})]),_:1},8,[`onConfirm`])],64)):h(``,!0)]),_:1},8,[`dataSource`,`loading`]),o(H,{open:T.value,"onUpdate:open":i[6]||=e=>T.value=e,title:E.value?`编辑表情包`:`新建表情包`,onOk:M,width:520},{default:n(()=>[o(V,{layout:`vertical`,style:{"margin-top":`8px`}},{default:n(()=>[o(L,{gutter:12},{default:n(()=>[o(I,{span:12},{default:n(()=>[o(F,{label:`Key`},{default:n(()=>[o(P,{value:D.value.key,"onUpdate:value":i[0]||=e=>D.value.key=e,placeholder:`salary`},null,8,[`value`])]),_:1})]),_:1}),o(I,{span:12},{default:n(()=>[o(F,{label:`名称`},{default:n(()=>[o(P,{value:D.value.label,"onUpdate:value":i[1]||=e=>D.value.label=e,placeholder:`发工资啦`},null,8,[`value`])]),_:1})]),_:1})]),_:1}),o(L,{gutter:12},{default:n(()=>[o(I,{span:12},{default:n(()=>[o(F,{label:`分组`},{default:n(()=>[o(z,{value:D.value.groupKey,"onUpdate:value":i[2]||=e=>D.value.groupKey=e},{default:n(()=>[o(R,{value:`ai_exclusive`},{default:n(()=>[...i[9]||=[m(`🤖 AI 专属`,-1)]]),_:1}),o(R,{value:`classic`},{default:n(()=>[...i[10]||=[m(`📦 经典`,-1)]]),_:1})]),_:1},8,[`value`])]),_:1})]),_:1}),o(I,{span:12},{default:n(()=>[o(F,{label:`图片 URL`},{default:n(()=>[o(P,{value:D.value.imageUrl,"onUpdate:value":i[3]||=e=>D.value.imageUrl=e,placeholder:`可选 CDN 地址`},null,8,[`value`])]),_:1})]),_:1})]),_:1}),o(F,{label:`触发标签`},{default:n(()=>[o(P,{value:D.value.triggerTags,"onUpdate:value":i[4]||=e=>D.value.triggerTags=e,placeholder:`over_budget,salary,forgive,逗号分隔`},null,8,[`value`]),i[11]||=s(`div`,{style:{color:`#999`,"font-size":`11px`,"margin-top":`4px`}},`标签匹配用户场景,AI 自动选择对应表情包`,-1)]),_:1}),o(F,{label:`是否启用`},{default:n(()=>[o(B,{checked:D.value.isEnabled,"onUpdate:checked":i[5]||=e=>D.value.isEnabled=e},null,8,[`checked`])]),_:1})]),_:1})]),_:1},8,[`open`,`title`])],64)}}});export{C as default};
|
||||
@@ -0,0 +1 @@
|
||||
import{Bn as e,Cn as t,Kn as n,Ln as r,Pn as i,Qn as a,Sn as o,_n as s,a as c,ar as l,bn as u,fn as d,or as f,vn as p,xn as m,yn as h,zn as g}from"./config-provider-q7ATIdCu.js";import{n as _,t as v}from"./EditOutlined-h6ScL3Qz.js";import{t as y}from"./DeleteOutlined-yVoeJ3Fd.js";import{t as b}from"./api-wmB-hCXT.js";var x={style:{display:`flex`,"justify-content":`space-between`,"align-items":`center`,"margin-bottom":`16px`}},S={key:1,style:{color:`#ccc`}},C=t({__name:`Stickers`,setup(t){let C=a([]),w=a(!0),T=a(!1),E=a(null),D=a({key:``,label:``,groupKey:`classic`,triggerTags:``,imageUrl:``,isEnabled:!0}),O=[{title:`Key`,dataIndex:`key`,key:`key`,width:110},{title:`名称`,dataIndex:`label`,key:`label`,width:100},{title:`分组`,dataIndex:`groupKey`,key:`group`,width:90},{title:`触发标签`,dataIndex:`triggerTags`,key:`tags`,width:200},{title:`状态`,dataIndex:`isEnabled`,key:`on`,width:70},{title:``,key:`act`,width:150}];i(k);async function k(){w.value=!0;try{C.value=await b.stickers()}finally{w.value=!1}}function A(){E.value=null,D.value={key:``,label:``,groupKey:`classic`,triggerTags:``,imageUrl:``,isEnabled:!0},T.value=!0}function j(e){E.value=e,D.value={key:e.key,label:e.label,groupKey:e.groupKey,triggerTags:e.triggerTags||``,imageUrl:e.imageUrl||``,isEnabled:e.isEnabled},T.value=!0}async function M(){let e={...D.value,triggerTags:D.value.triggerTags||null,imageUrl:D.value.imageUrl||null};E.value?await b.updateSticker(E.value.id,e):await b.createSticker(e),c.success(E.value?`已更新`:`已创建`),T.value=!1,k()}async function N(e){await b.deleteSticker(e),c.success(`已删除`),k()}return(t,i)=>{let a=e(`a-button`),c=e(`a-tag`),b=e(`a-popconfirm`),k=e(`a-table`),P=e(`a-input`),F=e(`a-form-item`),I=e(`a-col`),L=e(`a-row`),R=e(`a-select-option`),z=e(`a-select`),B=e(`a-switch`),V=e(`a-form`),H=e(`a-modal`);return r(),u(d,null,[s(`div`,x,[i[8]||=s(`h2`,null,`表情包库`,-1),o(a,{type:`primary`,onClick:A},{default:n(()=>[o(l(_)),i[7]||=m(` 新建表情包`,-1)]),_:1})]),o(k,{columns:O,dataSource:C.value,loading:w.value,rowKey:`id`,size:`small`,pagination:{pageSize:10}},{bodyCell:n(({column:e,record:t})=>[e.key===`tags`?(r(),u(d,{key:0},[t.triggerTags?(r(!0),u(d,{key:0},g((t.triggerTags||``).split(`,`).filter(Boolean),e=>(r(),p(c,{key:e,color:`blue`,style:{margin:`1px 2px`}},{default:n(()=>[m(f(e),1)]),_:2},1024))),128)):(r(),u(`span`,S,`-`))],64)):h(``,!0),e.key===`on`?(r(),p(c,{key:1,color:t.isEnabled?`green`:`default`},{default:n(()=>[m(f(t.isEnabled?`启用`:`禁用`),1)]),_:2},1032,[`color`])):h(``,!0),e.key===`act`?(r(),u(d,{key:2},[o(a,{size:`small`,style:{"margin-right":`6px`},onClick:e=>j(t)},{default:n(()=>[o(l(v))]),_:1},8,[`onClick`]),o(b,{title:`确定删除?`,onConfirm:e=>N(t.id)},{default:n(()=>[o(a,{size:`small`,danger:``},{default:n(()=>[o(l(y))]),_:1})]),_:1},8,[`onConfirm`])],64)):h(``,!0)]),_:1},8,[`dataSource`,`loading`]),o(H,{open:T.value,"onUpdate:open":i[6]||=e=>T.value=e,title:E.value?`编辑表情包`:`新建表情包`,onOk:M,width:520},{default:n(()=>[o(V,{layout:`vertical`,style:{"margin-top":`8px`}},{default:n(()=>[o(L,{gutter:12},{default:n(()=>[o(I,{span:12},{default:n(()=>[o(F,{label:`Key`},{default:n(()=>[o(P,{value:D.value.key,"onUpdate:value":i[0]||=e=>D.value.key=e,placeholder:`salary`},null,8,[`value`])]),_:1})]),_:1}),o(I,{span:12},{default:n(()=>[o(F,{label:`名称`},{default:n(()=>[o(P,{value:D.value.label,"onUpdate:value":i[1]||=e=>D.value.label=e,placeholder:`发工资啦`},null,8,[`value`])]),_:1})]),_:1})]),_:1}),o(L,{gutter:12},{default:n(()=>[o(I,{span:12},{default:n(()=>[o(F,{label:`分组`},{default:n(()=>[o(z,{value:D.value.groupKey,"onUpdate:value":i[2]||=e=>D.value.groupKey=e},{default:n(()=>[o(R,{value:`ai_exclusive`},{default:n(()=>[...i[9]||=[m(`🤖 AI 专属`,-1)]]),_:1}),o(R,{value:`classic`},{default:n(()=>[...i[10]||=[m(`📦 经典`,-1)]]),_:1})]),_:1},8,[`value`])]),_:1})]),_:1}),o(I,{span:12},{default:n(()=>[o(F,{label:`图片 URL`},{default:n(()=>[o(P,{value:D.value.imageUrl,"onUpdate:value":i[3]||=e=>D.value.imageUrl=e,placeholder:`可选 CDN 地址`},null,8,[`value`])]),_:1})]),_:1})]),_:1}),o(F,{label:`触发标签`},{default:n(()=>[o(P,{value:D.value.triggerTags,"onUpdate:value":i[4]||=e=>D.value.triggerTags=e,placeholder:`over_budget,salary,forgive,逗号分隔`},null,8,[`value`]),i[11]||=s(`div`,{style:{color:`#999`,"font-size":`11px`,"margin-top":`4px`}},`标签匹配用户场景,AI 自动选择对应表情包`,-1)]),_:1}),o(F,{label:`是否启用`},{default:n(()=>[o(B,{checked:D.value.isEnabled,"onUpdate:checked":i[5]||=e=>D.value.isEnabled=e},null,8,[`checked`])]),_:1})]),_:1})]),_:1},8,[`open`,`title`])],64)}}});export{C as default};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
import{Bn as e,Cn as t,Kn as n,Ln as r,Pn as i,Qn as a,Sn as o,_n as s,a as c,ar as l,bn as u,fn as d,or as f,vn as p,xn as m,yn as h}from"./config-provider-q7ATIdCu.js";import{t as g}from"./ReloadOutlined-CVrW_3-b.js";import{t as _}from"./api-C4vz6nB3.js";var v={style:{display:`flex`,"justify-content":`space-between`,"align-items":`center`,"margin-bottom":`16px`}},y={style:{"margin-bottom":`14px`,display:`flex`,gap:`8px`}},b={key:0},x={key:1,style:{color:`#ccc`}},S={style:{color:`#999`}},C=15,w=t({__name:`Users`,setup(t){let w=a([]),T=a(0),E=a(!0),D=a(``),O=a(1),k=[{title:`ID`,dataIndex:`id`,key:`id`,width:60},{title:`用户名`,dataIndex:`username`,key:`un`,width:120},{title:`模式`,dataIndex:`appMode`,key:`mode`,width:80},{title:`AI 伙伴`,key:`comp`,width:150},{title:`账单(总/AI)`,key:`tx`},{title:`封禁`,dataIndex:`isBanned`,key:`ban`,width:70},{title:`注册时间`,dataIndex:`createdAt`,key:`reg`,width:110},{title:`最后登录`,dataIndex:`lastLoginAt`,key:`login`,width:110},{title:``,key:`act`,width:200}],A=a(!1),j=a(``),M=a(null);i(N);async function N(){E.value=!0;try{let e=await _.users({search:D.value||void 0,page:O.value,limit:C});w.value=e.list,T.value=e.total}finally{E.value=!1}}async function P(){O.value=1,N()}async function F(e){let t=await _.toggleBan(e.id);c.success(t.isBanned?`已封禁 ${e.username}`:`已解封 ${e.username}`),N()}async function I(e){j.value=e.nickname||e.username,A.value=!0,M.value=await _.userStats(e.id)}return(t,i)=>{let a=e(`a-button`),c=e(`a-input-search`),_=e(`a-tag`),L=e(`a-popconfirm`),R=e(`a-table`),z=e(`a-statistic`),B=e(`a-card`),V=e(`a-col`),H=e(`a-row`),U=e(`a-modal`);return r(),u(d,null,[s(`div`,v,[i[3]||=s(`h2`,null,`用户管理`,-1),o(a,{onClick:N},{default:n(()=>[o(l(g)),i[2]||=m(` 刷新`,-1)]),_:1})]),s(`div`,y,[o(c,{value:D.value,"onUpdate:value":i[0]||=e=>D.value=e,placeholder:`搜索用户名...`,style:{"max-width":`280px`},onSearch:P},null,8,[`value`])]),o(R,{columns:k,dataSource:w.value,loading:E.value,rowKey:`id`,size:`small`,pagination:{current:O.value,total:T.value,pageSize:C,showTotal:e=>`共 ${e} 人`,onChange:e=>{O.value=e,N()}}},{bodyCell:n(({column:e,record:t})=>[e.key===`mode`?(r(),p(_,{key:0,color:t.appMode===`ai`?`blue`:`green`},{default:n(()=>[m(f(t.appMode===`ai`?`全AI`:`普通`),1)]),_:2},1032,[`color`])):h(``,!0),e.key===`comp`?(r(),u(d,{key:1},[t.companion?(r(),u(`span`,b,`形象:`+f(t.companion.avatarKey)+` · 性格:`+f(t.companion.personaKey),1)):(r(),u(`span`,x,`未设置`))],64)):h(``,!0),e.key===`tx`?(r(),u(d,{key:2},[m(f(t.txCount)+` `,1),s(`span`,S,`(AI:`+f(t.aiTxCount)+`)`,1)],64)):h(``,!0),e.key===`ban`?(r(),p(_,{key:3,color:t.isBanned?`red`:`default`},{default:n(()=>[m(f(t.isBanned?`已封`:`正常`),1)]),_:2},1032,[`color`])):h(``,!0),e.key===`reg`?(r(),u(d,{key:4},[m(f(t.createdAt?.split(`T`)[0]),1)],64)):h(``,!0),e.key===`login`?(r(),u(d,{key:5},[m(f(t.lastLoginAt?t.lastLoginAt.split(`T`)[0]:`从未`),1)],64)):h(``,!0),e.key===`act`?(r(),u(d,{key:6},[o(a,{size:`small`,style:{"margin-right":`6px`},onClick:e=>I(t)},{default:n(()=>[...i[4]||=[m(`📊 统计`,-1)]]),_:1},8,[`onClick`]),o(L,{title:t.isBanned?`确定解封?`:`确定封禁?`,onConfirm:e=>F(t)},{default:n(()=>[o(a,{size:`small`,danger:!t.isBanned},{default:n(()=>[m(f(t.isBanned?`解封`:`封禁`),1)]),_:2},1032,[`danger`])]),_:2},1032,[`title`,`onConfirm`])],64)):h(``,!0)]),_:1},8,[`dataSource`,`loading`,`pagination`]),o(U,{open:A.value,"onUpdate:open":i[1]||=e=>A.value=e,title:`${j.value} 使用统计`,footer:null,width:420},{default:n(()=>[M.value?(r(),p(H,{key:0,gutter:12},{default:n(()=>[o(V,{span:8},{default:n(()=>[o(B,{size:`small`},{default:n(()=>[o(z,{title:`总账单`,value:M.value.totalTransactions},null,8,[`value`])]),_:1})]),_:1}),o(V,{span:8},{default:n(()=>[o(B,{size:`small`},{default:n(()=>[o(z,{title:`AI 记账`,value:M.value.aiBooked},null,8,[`value`])]),_:1})]),_:1}),o(V,{span:8},{default:n(()=>[o(B,{size:`small`},{default:n(()=>[o(z,{title:`AI 准确率`,value:M.value.aiAccuracy,suffix:`%`,"value-style":{color:M.value.aiAccuracy>=70?`#00B386`:`#F0642D`}},null,8,[`value`,`value-style`])]),_:1})]),_:1})]),_:1})):h(``,!0)]),_:1},8,[`open`,`title`])],64)}}});export{w as default};
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
import{Bn as e,Cn as t,Kn as n,Ln as r,Pn as i,Qn as a,Sn as o,_n as s,a as c,ar as l,bn as u,fn as d,or as f,vn as p,xn as m,yn as h}from"./config-provider-q7ATIdCu.js";import{t as g}from"./ReloadOutlined-CVrW_3-b.js";import{t as _}from"./api-BV_Zb8mM.js";var v={style:{display:`flex`,"justify-content":`space-between`,"align-items":`center`,"margin-bottom":`16px`}},y={style:{"margin-bottom":`14px`,display:`flex`,gap:`8px`}},b={key:0},x={key:1,style:{color:`#ccc`}},S={style:{color:`#999`}},C={key:2,style:{"font-size":`10px`,color:`#999`,"margin-top":`3px`}},w=15,T=t({__name:`Users`,setup(t){let T=a([]),E=a(0),D=a(!0),O=a(``),k=a(1),A=[{title:`ID`,dataIndex:`id`,key:`id`,width:60},{title:`用户名`,dataIndex:`username`,key:`un`,width:120},{title:`模式`,dataIndex:`appMode`,key:`mode`,width:80},{title:`AI 伙伴`,key:`comp`,width:150},{title:`账单(总/AI)`,key:`tx`},{title:`状态`,key:`status`,width:100},{title:`注册时间`,dataIndex:`createdAt`,key:`reg`,width:110},{title:`最后登录`,dataIndex:`lastLoginAt`,key:`login`,width:110},{title:``,key:`act`,width:200}],j=a(!1),M=a(``),N=a(null);i(P);async function P(){D.value=!0;try{let e=await _.users({search:O.value||void 0,page:k.value,limit:w});T.value=e.list,E.value=e.total}finally{D.value=!1}}async function F(){k.value=1,P()}async function I(e){let t=await _.toggleBan(e.id);c.success(t.isBanned?`已封禁 ${e.username}`:`已解封 ${e.username}`),P()}async function L(e){await _.cancelAccountClosure(e.id),c.success(`已取消 `+e.username+` 的注销流程`),P()}async function R(e){M.value=e.nickname||e.username,j.value=!0,N.value=await _.userStats(e.id)}return(t,i)=>{let a=e(`a-button`),c=e(`a-input-search`),_=e(`a-tag`),z=e(`a-popconfirm`),B=e(`a-table`),V=e(`a-statistic`),H=e(`a-card`),U=e(`a-col`),W=e(`a-row`),G=e(`a-modal`);return r(),u(d,null,[s(`div`,v,[i[3]||=s(`h2`,null,`用户管理`,-1),o(a,{onClick:P},{default:n(()=>[o(l(g)),i[2]||=m(` 刷新`,-1)]),_:1})]),s(`div`,y,[o(c,{value:O.value,"onUpdate:value":i[0]||=e=>O.value=e,placeholder:`搜索用户名...`,style:{"max-width":`280px`},onSearch:F},null,8,[`value`])]),o(B,{columns:A,dataSource:T.value,loading:D.value,rowKey:`id`,size:`small`,pagination:{current:k.value,total:E.value,pageSize:w,showTotal:e=>`共 ${e} 人`,onChange:e=>{k.value=e,P()}}},{bodyCell:n(({column:e,record:t})=>[e.key===`mode`?(r(),p(_,{key:0,color:t.appMode===`ai`?`blue`:`green`},{default:n(()=>[m(f(t.appMode===`ai`?`全AI`:`普通`),1)]),_:2},1032,[`color`])):h(``,!0),e.key===`comp`?(r(),u(d,{key:1},[t.companion?(r(),u(`span`,b,`形象:`+f(t.companion.avatarKey)+` · 性格:`+f(t.companion.personaKey),1)):(r(),u(`span`,x,`未设置`))],64)):h(``,!0),e.key===`tx`?(r(),u(d,{key:2},[m(f(t.txCount)+` `,1),s(`span`,S,`(AI:`+f(t.aiTxCount)+`)`,1)],64)):h(``,!0),e.key===`status`?(r(),u(d,{key:3},[t.accountClosureScheduledAt?(r(),p(_,{key:0,color:`orange`},{default:n(()=>[...i[4]||=[m(`注销中`,-1)]]),_:1})):(r(),p(_,{key:1,color:t.isBanned?`red`:`default`},{default:n(()=>[m(f(t.isBanned?`已封`:`正常`),1)]),_:2},1032,[`color`])),t.accountClosureScheduledAt?(r(),u(`div`,C,f(t.accountClosureScheduledAt.split(`T`)[0])+` 删除 `,1)):h(``,!0)],64)):h(``,!0),e.key===`reg`?(r(),u(d,{key:4},[m(f(t.createdAt?.split(`T`)[0]),1)],64)):h(``,!0),e.key===`login`?(r(),u(d,{key:5},[m(f(t.lastLoginAt?t.lastLoginAt.split(`T`)[0]:`从未`),1)],64)):h(``,!0),e.key===`act`?(r(),u(d,{key:6},[o(a,{size:`small`,style:{"margin-right":`6px`},onClick:e=>R(t)},{default:n(()=>[...i[5]||=[m(`📊 统计`,-1)]]),_:1},8,[`onClick`]),t.accountClosureScheduledAt?(r(),p(z,{key:0,title:`确定取消该用户的注销流程?`,onConfirm:e=>L(t)},{default:n(()=>[o(a,{size:`small`,type:`primary`},{default:n(()=>[...i[6]||=[m(`取消注销`,-1)]]),_:1})]),_:1},8,[`onConfirm`])):(r(),p(z,{key:1,title:t.isBanned?`确定解封?`:`确定封禁?`,onConfirm:e=>I(t)},{default:n(()=>[o(a,{size:`small`,danger:!t.isBanned},{default:n(()=>[m(f(t.isBanned?`解封`:`封禁`),1)]),_:2},1032,[`danger`])]),_:2},1032,[`title`,`onConfirm`]))],64)):h(``,!0)]),_:1},8,[`dataSource`,`loading`,`pagination`]),o(G,{open:j.value,"onUpdate:open":i[1]||=e=>j.value=e,title:`${M.value} 使用统计`,footer:null,width:420},{default:n(()=>[N.value?(r(),p(W,{key:0,gutter:12},{default:n(()=>[o(U,{span:8},{default:n(()=>[o(H,{size:`small`},{default:n(()=>[o(V,{title:`总账单`,value:N.value.totalTransactions},null,8,[`value`])]),_:1})]),_:1}),o(U,{span:8},{default:n(()=>[o(H,{size:`small`},{default:n(()=>[o(V,{title:`AI 记账`,value:N.value.aiBooked},null,8,[`value`])]),_:1})]),_:1}),o(U,{span:8},{default:n(()=>[o(H,{size:`small`},{default:n(()=>[o(V,{title:`AI 准确率`,value:N.value.aiAccuracy,suffix:`%`,"value-style":{color:N.value.aiAccuracy>=70?`#00B386`:`#F0642D`}},null,8,[`value`,`value-style`])]),_:1})]),_:1})]),_:1})):h(``,!0)]),_:1},8,[`open`,`title`])],64)}}});export{T as default};
|
||||
File diff suppressed because one or more lines are too long
+3
-3
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
var e=new Intl.DateTimeFormat(`zh-CN`,{timeZone:`Asia/Shanghai`,year:`numeric`,month:`2-digit`,day:`2-digit`});function t(t){if(!t)return``;let n=/(?:Z|[+-]\d{2}:?\d{2})$/i.test(t)?t:t+`Z`,r=new Date(n);return Number.isNaN(r.getTime())?``:e.format(r).replaceAll(`/`,`-`)}export{t};
|
||||
@@ -5,9 +5,11 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>admin-web</title>
|
||||
<script type="module" crossorigin src="/assets/index-BSN4-dIH.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-BK5aVReu.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/dayjs.min-CeCVojfG.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/config-provider-q7ATIdCu.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/EditOutlined-CeylGsUo.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/EditOutlined-h6ScL3Qz.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/DeleteOutlined-yVoeJ3Fd.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/ReloadOutlined-CVrW_3-b.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/TeamOutlined-0klbs6LP.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-B6VCboLO.css">
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
namespace MiaoJiZhang.Domain.Entities;
|
||||
|
||||
public static class PushProviders
|
||||
{
|
||||
public const string Huawei = "huawei";
|
||||
public const string Honor = "honor";
|
||||
public const string Xiaomi = "xiaomi";
|
||||
public const string Oppo = "oppo";
|
||||
public const string Vivo = "vivo";
|
||||
public const string Meizu = "meizu";
|
||||
|
||||
public static readonly IReadOnlySet<string> All = new HashSet<string>(
|
||||
[Huawei, Honor, Xiaomi, Oppo, Vivo, Meizu],
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public static class PushCategories
|
||||
{
|
||||
public const string System = "system";
|
||||
public const string Budget = "budget";
|
||||
public const string Operations = "operations";
|
||||
|
||||
public static readonly IReadOnlySet<string> All = new HashSet<string>(
|
||||
[System, Budget, Operations],
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public static class PushActions
|
||||
{
|
||||
public const string None = "none";
|
||||
public const string Home = "home";
|
||||
public const string Budget = "budget";
|
||||
public const string Update = "update";
|
||||
|
||||
public static readonly IReadOnlySet<string> All = new HashSet<string>(
|
||||
[None, Home, Budget, Update],
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public static class PushMessageStates
|
||||
{
|
||||
public const string Draft = "draft";
|
||||
public const string Scheduled = "scheduled";
|
||||
public const string Queued = "queued";
|
||||
public const string Sending = "sending";
|
||||
public const string Completed = "completed";
|
||||
public const string PartiallyFailed = "partially_failed";
|
||||
public const string Cancelled = "cancelled";
|
||||
}
|
||||
|
||||
public static class PushDeliveryStates
|
||||
{
|
||||
public const string Queued = "queued";
|
||||
public const string Sending = "sending";
|
||||
public const string Accepted = "accepted";
|
||||
public const string Failed = "failed";
|
||||
public const string Skipped = "skipped";
|
||||
}
|
||||
|
||||
public class PushDevice
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public long UserId { get; set; }
|
||||
public User User { get; set; } = null!;
|
||||
public string InstallationId { get; set; } = null!;
|
||||
public string Provider { get; set; } = null!;
|
||||
public string TokenCiphertext { get; set; } = null!;
|
||||
public string TokenHash { get; set; } = null!;
|
||||
public string UnbindTokenHash { get; set; } = null!;
|
||||
public string PackageName { get; set; } = null!;
|
||||
public string Flavor { get; set; } = null!;
|
||||
public string AppVersion { get; set; } = null!;
|
||||
public int VersionCode { get; set; }
|
||||
public bool NotificationsAllowed { get; set; }
|
||||
public bool IsActive { get; set; } = true;
|
||||
public string? DisabledReason { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
public DateTime LastSeenAt { get; set; }
|
||||
|
||||
public List<PushDelivery> Deliveries { get; set; } = [];
|
||||
}
|
||||
|
||||
public class UserPushPreference
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public long UserId { get; set; }
|
||||
public User User { get; set; } = null!;
|
||||
public string Category { get; set; } = null!;
|
||||
public bool IsEnabled { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
}
|
||||
|
||||
public class PushMessage
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public string PublicId { get; set; } = null!;
|
||||
public string Source { get; set; } = null!;
|
||||
public string State { get; set; } = PushMessageStates.Draft;
|
||||
public string Category { get; set; } = null!;
|
||||
public string Title { get; set; } = null!;
|
||||
public string Body { get; set; } = null!;
|
||||
public string Action { get; set; } = PushActions.None;
|
||||
public string? EntityId { get; set; }
|
||||
public long? TargetUserId { get; set; }
|
||||
public string Flavor { get; set; } = "production";
|
||||
public string? ProviderFilter { get; set; }
|
||||
public int? MinVersionCode { get; set; }
|
||||
public int? MaxVersionCode { get; set; }
|
||||
public int TtlSeconds { get; set; }
|
||||
public bool IsTest { get; set; }
|
||||
public long? TestDeviceId { get; set; }
|
||||
public DateTime? ScheduledAt { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
public DateTime? StartedAt { get; set; }
|
||||
public DateTime? CompletedAt { get; set; }
|
||||
public DateTime? CancelledAt { get; set; }
|
||||
|
||||
public List<PushDelivery> Deliveries { get; set; } = [];
|
||||
}
|
||||
|
||||
public class PushDelivery
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public long PushMessageId { get; set; }
|
||||
public PushMessage PushMessage { get; set; } = null!;
|
||||
public long PushDeviceId { get; set; }
|
||||
public PushDevice PushDevice { get; set; } = null!;
|
||||
public long UserId { get; set; }
|
||||
public string Provider { get; set; } = null!;
|
||||
public string State { get; set; } = PushDeliveryStates.Queued;
|
||||
public int AttemptCount { get; set; }
|
||||
public DateTime NextAttemptAt { get; set; }
|
||||
public string? LeaseId { get; set; }
|
||||
public DateTime? LeaseExpiresAt { get; set; }
|
||||
public string? ProviderMessageId { get; set; }
|
||||
public string? ErrorCode { get; set; }
|
||||
public string? ErrorMessage { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
public DateTime? AcceptedAt { get; set; }
|
||||
}
|
||||
|
||||
public class BudgetNotificationReceipt
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public long UserId { get; set; }
|
||||
public User User { get; set; } = null!;
|
||||
public long BudgetId { get; set; }
|
||||
public Budget Budget { get; set; } = null!;
|
||||
public int Period { get; set; }
|
||||
public int Threshold { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
@@ -38,9 +38,11 @@ public class User
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? LastLoginAt { get; set; }
|
||||
|
||||
public List<Ledger> Ledgers { get; set; } = [];
|
||||
public List<UserFeaturePermission> FeaturePermissions { get; set; } = [];
|
||||
}
|
||||
public List<Ledger> Ledgers { get; set; } = [];
|
||||
public List<UserFeaturePermission> FeaturePermissions { get; set; } = [];
|
||||
public List<PushDevice> PushDevices { get; set; } = [];
|
||||
public List<UserPushPreference> PushPreferences { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>AI 伙伴设置(形象/昵称/性格/滑杆),1:1 User</summary>
|
||||
public class UserFeaturePermission
|
||||
|
||||
@@ -17,7 +17,12 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
|
||||
public DbSet<AppConfig> AppConfigs => Set<AppConfig>();
|
||||
public DbSet<AiPersona> AiPersonas => Set<AiPersona>();
|
||||
public DbSet<AiAvatar> AiAvatars => Set<AiAvatar>();
|
||||
public DbSet<Sticker> Stickers => Set<Sticker>();
|
||||
public DbSet<Sticker> Stickers => Set<Sticker>();
|
||||
public DbSet<PushDevice> PushDevices => Set<PushDevice>();
|
||||
public DbSet<UserPushPreference> UserPushPreferences => Set<UserPushPreference>();
|
||||
public DbSet<PushMessage> PushMessages => Set<PushMessage>();
|
||||
public DbSet<PushDelivery> PushDeliveries => Set<PushDelivery>();
|
||||
public DbSet<BudgetNotificationReceipt> BudgetNotificationReceipts => Set<BudgetNotificationReceipt>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder b)
|
||||
{
|
||||
@@ -67,6 +72,76 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
|
||||
b.Entity<AiAvatar>(e => e.HasIndex(x => x.Key).IsUnique());
|
||||
b.Entity<Sticker>(e => e.HasIndex(x => x.Key).IsUnique());
|
||||
|
||||
b.Entity<PushDevice>(e =>
|
||||
{
|
||||
e.Property(x => x.InstallationId).HasMaxLength(64);
|
||||
e.Property(x => x.Provider).HasMaxLength(16);
|
||||
e.Property(x => x.TokenCiphertext).HasMaxLength(6144);
|
||||
e.Property(x => x.TokenHash).HasMaxLength(64);
|
||||
e.Property(x => x.UnbindTokenHash).HasMaxLength(64);
|
||||
e.Property(x => x.PackageName).HasMaxLength(128);
|
||||
e.Property(x => x.Flavor).HasMaxLength(24);
|
||||
e.Property(x => x.AppVersion).HasMaxLength(32);
|
||||
e.Property(x => x.DisabledReason).HasMaxLength(64);
|
||||
e.HasIndex(x => new { x.PackageName, x.InstallationId }).IsUnique();
|
||||
e.HasIndex(x => new { x.Provider, x.PackageName, x.TokenHash }).IsUnique();
|
||||
e.HasIndex(x => new { x.UserId, x.IsActive });
|
||||
e.HasIndex(x => x.LastSeenAt);
|
||||
e.HasOne(x => x.User).WithMany(x => x.PushDevices)
|
||||
.HasForeignKey(x => x.UserId).OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
b.Entity<UserPushPreference>(e =>
|
||||
{
|
||||
e.Property(x => x.Category).HasMaxLength(24);
|
||||
e.HasIndex(x => new { x.UserId, x.Category }).IsUnique();
|
||||
e.HasOne(x => x.User).WithMany(x => x.PushPreferences)
|
||||
.HasForeignKey(x => x.UserId).OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
b.Entity<PushMessage>(e =>
|
||||
{
|
||||
e.Property(x => x.PublicId).HasMaxLength(36);
|
||||
e.Property(x => x.Source).HasMaxLength(24);
|
||||
e.Property(x => x.State).HasMaxLength(24);
|
||||
e.Property(x => x.Category).HasMaxLength(24);
|
||||
e.Property(x => x.Title).HasMaxLength(80);
|
||||
e.Property(x => x.Body).HasMaxLength(240);
|
||||
e.Property(x => x.Action).HasMaxLength(24);
|
||||
e.Property(x => x.EntityId).HasMaxLength(64);
|
||||
e.Property(x => x.Flavor).HasMaxLength(24);
|
||||
e.Property(x => x.ProviderFilter).HasMaxLength(16);
|
||||
e.HasIndex(x => x.PublicId).IsUnique();
|
||||
e.HasIndex(x => new { x.State, x.ScheduledAt });
|
||||
e.HasIndex(x => new { x.TargetUserId, x.CreatedAt });
|
||||
});
|
||||
|
||||
b.Entity<PushDelivery>(e =>
|
||||
{
|
||||
e.Property(x => x.Provider).HasMaxLength(16);
|
||||
e.Property(x => x.State).HasMaxLength(24);
|
||||
e.Property(x => x.LeaseId).HasMaxLength(36);
|
||||
e.Property(x => x.ProviderMessageId).HasMaxLength(128);
|
||||
e.Property(x => x.ErrorCode).HasMaxLength(64);
|
||||
e.Property(x => x.ErrorMessage).HasMaxLength(400);
|
||||
e.HasIndex(x => new { x.PushMessageId, x.PushDeviceId }).IsUnique();
|
||||
e.HasIndex(x => new { x.State, x.NextAttemptAt, x.LeaseExpiresAt });
|
||||
e.HasOne(x => x.PushMessage).WithMany(x => x.Deliveries)
|
||||
.HasForeignKey(x => x.PushMessageId).OnDelete(DeleteBehavior.Cascade);
|
||||
e.HasOne(x => x.PushDevice).WithMany(x => x.Deliveries)
|
||||
.HasForeignKey(x => x.PushDeviceId).OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
b.Entity<BudgetNotificationReceipt>(e =>
|
||||
{
|
||||
e.HasIndex(x => new { x.BudgetId, x.Period, x.Threshold }).IsUnique();
|
||||
e.HasIndex(x => new { x.UserId, x.Period });
|
||||
e.HasOne(x => x.User).WithMany()
|
||||
.HasForeignKey(x => x.UserId).OnDelete(DeleteBehavior.Cascade);
|
||||
e.HasOne(x => x.Budget).WithMany()
|
||||
.HasForeignKey(x => x.BudgetId).OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
// MySQL DATETIME has no timezone metadata. Preserve stored UTC wall-clock
|
||||
// values and restore DateTimeKind.Utc whenever EF materializes them.
|
||||
var utcDateTimeConverter = new ValueConverter<DateTime, DateTime>(
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
|
||||
namespace MiaoJiZhang.Infrastructure.Persistence;
|
||||
|
||||
public sealed class AppDbContextFactory : IDesignTimeDbContextFactory<AppDbContext>
|
||||
{
|
||||
public AppDbContext CreateDbContext(string[] args)
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseMySql(
|
||||
"Server=127.0.0.1;Database=miaoji_design;User=design;Password=design;",
|
||||
new MySqlServerVersion(new Version(8, 0, 36)))
|
||||
.Options;
|
||||
return new AppDbContext(options);
|
||||
}
|
||||
}
|
||||
+1031
File diff suppressed because it is too large
Load Diff
+295
@@ -0,0 +1,295 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace MiaoJiZhang.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class VendorPushInfrastructure : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "BudgetNotificationReceipts",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
|
||||
UserId = table.Column<long>(type: "bigint", nullable: false),
|
||||
BudgetId = table.Column<long>(type: "bigint", nullable: false),
|
||||
Period = table.Column<int>(type: "int", nullable: false),
|
||||
Threshold = table.Column<int>(type: "int", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_BudgetNotificationReceipts", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_BudgetNotificationReceipts_Budgets_BudgetId",
|
||||
column: x => x.BudgetId,
|
||||
principalTable: "Budgets",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_BudgetNotificationReceipts_Users_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
})
|
||||
.Annotation("MySql:CharSet", "utf8mb4");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "PushDevices",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
|
||||
UserId = table.Column<long>(type: "bigint", nullable: false),
|
||||
InstallationId = table.Column<string>(type: "varchar(64)", maxLength: 64, nullable: false)
|
||||
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||
Provider = table.Column<string>(type: "varchar(16)", maxLength: 16, nullable: false)
|
||||
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||
TokenCiphertext = table.Column<string>(type: "varchar(6144)", maxLength: 6144, nullable: false)
|
||||
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||
TokenHash = table.Column<string>(type: "varchar(64)", maxLength: 64, nullable: false)
|
||||
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||
UnbindTokenHash = table.Column<string>(type: "varchar(64)", maxLength: 64, nullable: false)
|
||||
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||
PackageName = table.Column<string>(type: "varchar(128)", maxLength: 128, nullable: false)
|
||||
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||
Flavor = table.Column<string>(type: "varchar(24)", maxLength: 24, nullable: false)
|
||||
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||
AppVersion = table.Column<string>(type: "varchar(32)", maxLength: 32, nullable: false)
|
||||
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||
VersionCode = table.Column<int>(type: "int", nullable: false),
|
||||
NotificationsAllowed = table.Column<bool>(type: "tinyint(1)", nullable: false),
|
||||
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false),
|
||||
DisabledReason = table.Column<string>(type: "varchar(64)", maxLength: 64, nullable: true)
|
||||
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||
LastSeenAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_PushDevices", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_PushDevices_Users_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
})
|
||||
.Annotation("MySql:CharSet", "utf8mb4");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "PushMessages",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
|
||||
PublicId = table.Column<string>(type: "varchar(36)", maxLength: 36, nullable: false)
|
||||
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||
Source = table.Column<string>(type: "varchar(24)", maxLength: 24, nullable: false)
|
||||
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||
State = table.Column<string>(type: "varchar(24)", maxLength: 24, nullable: false)
|
||||
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||
Category = table.Column<string>(type: "varchar(24)", maxLength: 24, nullable: false)
|
||||
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||
Title = table.Column<string>(type: "varchar(80)", maxLength: 80, nullable: false)
|
||||
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||
Body = table.Column<string>(type: "varchar(240)", maxLength: 240, nullable: false)
|
||||
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||
Action = table.Column<string>(type: "varchar(24)", maxLength: 24, nullable: false)
|
||||
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||
EntityId = table.Column<string>(type: "varchar(64)", maxLength: 64, nullable: true)
|
||||
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||
TargetUserId = table.Column<long>(type: "bigint", nullable: true),
|
||||
Flavor = table.Column<string>(type: "varchar(24)", maxLength: 24, nullable: false)
|
||||
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||
ProviderFilter = table.Column<string>(type: "varchar(16)", maxLength: 16, nullable: true)
|
||||
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||
MinVersionCode = table.Column<int>(type: "int", nullable: true),
|
||||
MaxVersionCode = table.Column<int>(type: "int", nullable: true),
|
||||
TtlSeconds = table.Column<int>(type: "int", nullable: false),
|
||||
IsTest = table.Column<bool>(type: "tinyint(1)", nullable: false),
|
||||
TestDeviceId = table.Column<long>(type: "bigint", nullable: true),
|
||||
ScheduledAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||
StartedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||
CompletedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||
CancelledAt = table.Column<DateTime>(type: "datetime(6)", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_PushMessages", x => x.Id);
|
||||
})
|
||||
.Annotation("MySql:CharSet", "utf8mb4");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "UserPushPreferences",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
|
||||
UserId = table.Column<long>(type: "bigint", nullable: false),
|
||||
Category = table.Column<string>(type: "varchar(24)", maxLength: 24, nullable: false)
|
||||
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||
IsEnabled = table.Column<bool>(type: "tinyint(1)", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_UserPushPreferences", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_UserPushPreferences_Users_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
})
|
||||
.Annotation("MySql:CharSet", "utf8mb4");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "PushDeliveries",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
|
||||
PushMessageId = table.Column<long>(type: "bigint", nullable: false),
|
||||
PushDeviceId = table.Column<long>(type: "bigint", nullable: false),
|
||||
UserId = table.Column<long>(type: "bigint", nullable: false),
|
||||
Provider = table.Column<string>(type: "varchar(16)", maxLength: 16, nullable: false)
|
||||
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||
State = table.Column<string>(type: "varchar(24)", maxLength: 24, nullable: false)
|
||||
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||
AttemptCount = table.Column<int>(type: "int", nullable: false),
|
||||
NextAttemptAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||
LeaseId = table.Column<string>(type: "varchar(36)", maxLength: 36, nullable: true)
|
||||
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||
LeaseExpiresAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||
ProviderMessageId = table.Column<string>(type: "varchar(128)", maxLength: 128, nullable: true)
|
||||
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||
ErrorCode = table.Column<string>(type: "varchar(64)", maxLength: 64, nullable: true)
|
||||
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||
ErrorMessage = table.Column<string>(type: "varchar(400)", maxLength: 400, nullable: true)
|
||||
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||
AcceptedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_PushDeliveries", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_PushDeliveries_PushDevices_PushDeviceId",
|
||||
column: x => x.PushDeviceId,
|
||||
principalTable: "PushDevices",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_PushDeliveries_PushMessages_PushMessageId",
|
||||
column: x => x.PushMessageId,
|
||||
principalTable: "PushMessages",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
})
|
||||
.Annotation("MySql:CharSet", "utf8mb4");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_BudgetNotificationReceipts_BudgetId_Period_Threshold",
|
||||
table: "BudgetNotificationReceipts",
|
||||
columns: new[] { "BudgetId", "Period", "Threshold" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_BudgetNotificationReceipts_UserId_Period",
|
||||
table: "BudgetNotificationReceipts",
|
||||
columns: new[] { "UserId", "Period" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PushDeliveries_PushDeviceId",
|
||||
table: "PushDeliveries",
|
||||
column: "PushDeviceId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PushDeliveries_PushMessageId_PushDeviceId",
|
||||
table: "PushDeliveries",
|
||||
columns: new[] { "PushMessageId", "PushDeviceId" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PushDeliveries_State_NextAttemptAt_LeaseExpiresAt",
|
||||
table: "PushDeliveries",
|
||||
columns: new[] { "State", "NextAttemptAt", "LeaseExpiresAt" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PushDevices_LastSeenAt",
|
||||
table: "PushDevices",
|
||||
column: "LastSeenAt");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PushDevices_PackageName_InstallationId",
|
||||
table: "PushDevices",
|
||||
columns: new[] { "PackageName", "InstallationId" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PushDevices_Provider_PackageName_TokenHash",
|
||||
table: "PushDevices",
|
||||
columns: new[] { "Provider", "PackageName", "TokenHash" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PushDevices_UserId_IsActive",
|
||||
table: "PushDevices",
|
||||
columns: new[] { "UserId", "IsActive" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PushMessages_PublicId",
|
||||
table: "PushMessages",
|
||||
column: "PublicId",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PushMessages_State_ScheduledAt",
|
||||
table: "PushMessages",
|
||||
columns: new[] { "State", "ScheduledAt" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PushMessages_TargetUserId_CreatedAt",
|
||||
table: "PushMessages",
|
||||
columns: new[] { "TargetUserId", "CreatedAt" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_UserPushPreferences_UserId_Category",
|
||||
table: "UserPushPreferences",
|
||||
columns: new[] { "UserId", "Category" },
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "BudgetNotificationReceipts");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "PushDeliveries");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "UserPushPreferences");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "PushDevices");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "PushMessages");
|
||||
}
|
||||
}
|
||||
}
|
||||
+448
-47
@@ -1,4 +1,4 @@
|
||||
// <auto-generated />
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using MiaoJiZhang.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -199,6 +199,39 @@ namespace MiaoJiZhang.Infrastructure.Persistence.Migrations
|
||||
b.ToTable("Budgets");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MiaoJiZhang.Domain.Entities.BudgetNotificationReceipt", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<long>("BudgetId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<int>("Period")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int>("Threshold")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<long>("UserId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId", "Period");
|
||||
|
||||
b.HasIndex("BudgetId", "Period", "Threshold")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("BudgetNotificationReceipts");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MiaoJiZhang.Domain.Entities.Category", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
@@ -303,6 +336,271 @@ namespace MiaoJiZhang.Infrastructure.Persistence.Migrations
|
||||
b.ToTable("Ledgers");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MiaoJiZhang.Domain.Entities.PushDelivery", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime?>("AcceptedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<int>("AttemptCount")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<string>("ErrorCode")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("varchar(64)");
|
||||
|
||||
b.Property<string>("ErrorMessage")
|
||||
.HasMaxLength(400)
|
||||
.HasColumnType("varchar(400)");
|
||||
|
||||
b.Property<DateTime?>("LeaseExpiresAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<string>("LeaseId")
|
||||
.HasMaxLength(36)
|
||||
.HasColumnType("varchar(36)");
|
||||
|
||||
b.Property<DateTime>("NextAttemptAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<string>("Provider")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("varchar(16)");
|
||||
|
||||
b.Property<string>("ProviderMessageId")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("varchar(128)");
|
||||
|
||||
b.Property<long>("PushDeviceId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<long>("PushMessageId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("State")
|
||||
.IsRequired()
|
||||
.HasMaxLength(24)
|
||||
.HasColumnType("varchar(24)");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<long>("UserId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PushDeviceId");
|
||||
|
||||
b.HasIndex("PushMessageId", "PushDeviceId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("State", "NextAttemptAt", "LeaseExpiresAt");
|
||||
|
||||
b.ToTable("PushDeliveries");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MiaoJiZhang.Domain.Entities.PushDevice", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("AppVersion")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("varchar(32)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<string>("DisabledReason")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("varchar(64)");
|
||||
|
||||
b.Property<string>("Flavor")
|
||||
.IsRequired()
|
||||
.HasMaxLength(24)
|
||||
.HasColumnType("varchar(24)");
|
||||
|
||||
b.Property<string>("InstallationId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("varchar(64)");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("tinyint(1)");
|
||||
|
||||
b.Property<DateTime>("LastSeenAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<bool>("NotificationsAllowed")
|
||||
.HasColumnType("tinyint(1)");
|
||||
|
||||
b.Property<string>("PackageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("varchar(128)");
|
||||
|
||||
b.Property<string>("Provider")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("varchar(16)");
|
||||
|
||||
b.Property<string>("TokenCiphertext")
|
||||
.IsRequired()
|
||||
.HasMaxLength(6144)
|
||||
.HasColumnType("varchar(6144)");
|
||||
|
||||
b.Property<string>("TokenHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("varchar(64)");
|
||||
|
||||
b.Property<string>("UnbindTokenHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("varchar(64)");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<long>("UserId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<int>("VersionCode")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("LastSeenAt");
|
||||
|
||||
b.HasIndex("PackageName", "InstallationId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("UserId", "IsActive");
|
||||
|
||||
b.HasIndex("Provider", "PackageName", "TokenHash")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("PushDevices");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MiaoJiZhang.Domain.Entities.PushMessage", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("Action")
|
||||
.IsRequired()
|
||||
.HasMaxLength(24)
|
||||
.HasColumnType("varchar(24)");
|
||||
|
||||
b.Property<string>("Body")
|
||||
.IsRequired()
|
||||
.HasMaxLength(240)
|
||||
.HasColumnType("varchar(240)");
|
||||
|
||||
b.Property<DateTime?>("CancelledAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<string>("Category")
|
||||
.IsRequired()
|
||||
.HasMaxLength(24)
|
||||
.HasColumnType("varchar(24)");
|
||||
|
||||
b.Property<DateTime?>("CompletedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<string>("EntityId")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("varchar(64)");
|
||||
|
||||
b.Property<string>("Flavor")
|
||||
.IsRequired()
|
||||
.HasMaxLength(24)
|
||||
.HasColumnType("varchar(24)");
|
||||
|
||||
b.Property<bool>("IsTest")
|
||||
.HasColumnType("tinyint(1)");
|
||||
|
||||
b.Property<int?>("MaxVersionCode")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int?>("MinVersionCode")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("ProviderFilter")
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("varchar(16)");
|
||||
|
||||
b.Property<string>("PublicId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(36)
|
||||
.HasColumnType("varchar(36)");
|
||||
|
||||
b.Property<DateTime?>("ScheduledAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<string>("Source")
|
||||
.IsRequired()
|
||||
.HasMaxLength(24)
|
||||
.HasColumnType("varchar(24)");
|
||||
|
||||
b.Property<DateTime?>("StartedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<string>("State")
|
||||
.IsRequired()
|
||||
.HasMaxLength(24)
|
||||
.HasColumnType("varchar(24)");
|
||||
|
||||
b.Property<long?>("TargetUserId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<long?>("TestDeviceId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(80)
|
||||
.HasColumnType("varchar(80)");
|
||||
|
||||
b.Property<int>("TtlSeconds")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PublicId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("State", "ScheduledAt");
|
||||
|
||||
b.HasIndex("TargetUserId", "CreatedAt");
|
||||
|
||||
b.ToTable("PushMessages");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MiaoJiZhang.Domain.Entities.Sticker", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
@@ -404,47 +702,14 @@ namespace MiaoJiZhang.Infrastructure.Persistence.Migrations
|
||||
|
||||
b.HasIndex("LedgerId", "OccurredAt");
|
||||
|
||||
b.HasIndex("UserId", "IsDeleted");
|
||||
|
||||
b.HasIndex("UserId", "ClientRequestId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("UserId", "IsDeleted");
|
||||
|
||||
b.ToTable("Transactions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MiaoJiZhang.Domain.Entities.UserFeaturePermission", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<bool>("IsEnabled")
|
||||
.HasColumnType("tinyint(1)");
|
||||
|
||||
b.Property<string>("PermissionKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("varchar(32)");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<long>("UserId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId", "PermissionKey")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("UserFeaturePermissions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MiaoJiZhang.Domain.Entities.User", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
@@ -453,8 +718,11 @@ namespace MiaoJiZhang.Infrastructure.Persistence.Migrations
|
||||
|
||||
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<int>("AppMode")
|
||||
.HasColumnType("int");
|
||||
b.Property<DateTime?>("AccountClosureRequestedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<DateTime?>("AccountClosureScheduledAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<int>("AiChatLimit")
|
||||
.HasColumnType("int");
|
||||
@@ -468,15 +736,15 @@ namespace MiaoJiZhang.Infrastructure.Persistence.Migrations
|
||||
b.Property<DateTime>("AiChatWindowStartedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<DateTime?>("AccountClosureRequestedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<DateTime?>("AccountClosureScheduledAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
b.Property<int>("AppMode")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("AppleUserId")
|
||||
.HasColumnType("longtext");
|
||||
|
||||
b.Property<int>("AuthVersion")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("AvatarUrl")
|
||||
.HasColumnType("longtext");
|
||||
|
||||
@@ -489,9 +757,6 @@ namespace MiaoJiZhang.Infrastructure.Persistence.Migrations
|
||||
b.Property<bool>("EmailVerified")
|
||||
.HasColumnType("tinyint(1)");
|
||||
|
||||
b.Property<int>("AuthVersion")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<bool>("IsBanned")
|
||||
.HasColumnType("tinyint(1)");
|
||||
|
||||
@@ -542,6 +807,69 @@ namespace MiaoJiZhang.Infrastructure.Persistence.Migrations
|
||||
b.ToTable("Users");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MiaoJiZhang.Domain.Entities.UserFeaturePermission", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<bool>("IsEnabled")
|
||||
.HasColumnType("tinyint(1)");
|
||||
|
||||
b.Property<string>("PermissionKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("varchar(32)");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<long>("UserId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId", "PermissionKey")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("UserFeaturePermissions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MiaoJiZhang.Domain.Entities.UserPushPreference", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("Category")
|
||||
.IsRequired()
|
||||
.HasMaxLength(24)
|
||||
.HasColumnType("varchar(24)");
|
||||
|
||||
b.Property<bool>("IsEnabled")
|
||||
.HasColumnType("tinyint(1)");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<long>("UserId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId", "Category")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("UserPushPreferences");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MiaoJiZhang.Domain.Entities.AiCompanionSetting", b =>
|
||||
{
|
||||
b.HasOne("MiaoJiZhang.Domain.Entities.User", "User")
|
||||
@@ -564,6 +892,25 @@ namespace MiaoJiZhang.Infrastructure.Persistence.Migrations
|
||||
b.Navigation("Ledger");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MiaoJiZhang.Domain.Entities.BudgetNotificationReceipt", b =>
|
||||
{
|
||||
b.HasOne("MiaoJiZhang.Domain.Entities.Budget", "Budget")
|
||||
.WithMany()
|
||||
.HasForeignKey("BudgetId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("MiaoJiZhang.Domain.Entities.User", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Budget");
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MiaoJiZhang.Domain.Entities.Ledger", b =>
|
||||
{
|
||||
b.HasOne("MiaoJiZhang.Domain.Entities.User", "Owner")
|
||||
@@ -575,6 +922,36 @@ namespace MiaoJiZhang.Infrastructure.Persistence.Migrations
|
||||
b.Navigation("Owner");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MiaoJiZhang.Domain.Entities.PushDelivery", b =>
|
||||
{
|
||||
b.HasOne("MiaoJiZhang.Domain.Entities.PushDevice", "PushDevice")
|
||||
.WithMany("Deliveries")
|
||||
.HasForeignKey("PushDeviceId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("MiaoJiZhang.Domain.Entities.PushMessage", "PushMessage")
|
||||
.WithMany("Deliveries")
|
||||
.HasForeignKey("PushMessageId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("PushDevice");
|
||||
|
||||
b.Navigation("PushMessage");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MiaoJiZhang.Domain.Entities.PushDevice", b =>
|
||||
{
|
||||
b.HasOne("MiaoJiZhang.Domain.Entities.User", "User")
|
||||
.WithMany("PushDevices")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MiaoJiZhang.Domain.Entities.Transaction", b =>
|
||||
{
|
||||
b.HasOne("MiaoJiZhang.Domain.Entities.Category", "Category")
|
||||
@@ -605,6 +982,17 @@ namespace MiaoJiZhang.Infrastructure.Persistence.Migrations
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MiaoJiZhang.Domain.Entities.UserPushPreference", b =>
|
||||
{
|
||||
b.HasOne("MiaoJiZhang.Domain.Entities.User", "User")
|
||||
.WithMany("PushPreferences")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MiaoJiZhang.Domain.Entities.Ledger", b =>
|
||||
{
|
||||
b.Navigation("Budgets");
|
||||
@@ -612,6 +1000,16 @@ namespace MiaoJiZhang.Infrastructure.Persistence.Migrations
|
||||
b.Navigation("Transactions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MiaoJiZhang.Domain.Entities.PushDevice", b =>
|
||||
{
|
||||
b.Navigation("Deliveries");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MiaoJiZhang.Domain.Entities.PushMessage", b =>
|
||||
{
|
||||
b.Navigation("Deliveries");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MiaoJiZhang.Domain.Entities.User", b =>
|
||||
{
|
||||
b.Navigation("AiCompanion");
|
||||
@@ -619,9 +1017,12 @@ namespace MiaoJiZhang.Infrastructure.Persistence.Migrations
|
||||
b.Navigation("FeaturePermissions");
|
||||
|
||||
b.Navigation("Ledgers");
|
||||
|
||||
b.Navigation("PushDevices");
|
||||
|
||||
b.Navigation("PushPreferences");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user