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> 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> UpdatePreferences( UpdatePushPreferencesRequest request, CancellationToken ct) { var desired = new Dictionary { [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> 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 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; } }