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 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 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 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())); } [HttpPut("campaigns/{id:long}")] public async Task 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())); } [HttpPost("campaigns/{id:long}/send")] public async Task 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())); } [HttpPost("campaigns/{id:long}/cancel")] public async Task 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())); } [HttpGet("devices")] public async Task 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 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("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 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 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), }, }; }