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