Add domestic vendor push infrastructure
This commit is contained in:
@@ -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),
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user