Add domestic vendor push infrastructure

This commit is contained in:
2026-07-26 01:45:59 +08:00
parent 7cca34b331
commit 0738953e6d
77 changed files with 6470 additions and 855 deletions
@@ -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));
}