feat: strengthen P0 security and operations
This commit is contained in:
@@ -230,6 +230,78 @@ public sealed class AuthController(
|
||||
return Ok(AuthenticationResultDto.FromApplication(result));
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[EnableRateLimiting(AuthRateLimitPolicies.Sms)]
|
||||
[HttpPost("password/reset/sms/send")]
|
||||
[EndpointSummary("发送密码重置短信验证码")]
|
||||
[EndpointDescription("仅适用于租户授权域;无论手机号是否存在均返回相同接受响应。")]
|
||||
[ProducesResponseType<SmsSendResult>(StatusCodes.Status202Accepted)]
|
||||
public async Task<ActionResult<SmsSendResult>> SendPasswordResetCode(
|
||||
PasswordResetSmsSendDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = await ResolveRealmTenantIdAsync(AuthRealm.Tenant, request.TenantCode, cancellationToken)
|
||||
?? throw new RequiredFieldException("tenantCode is required for password reset.");
|
||||
var result = await authService.RequestPasswordResetAsync(
|
||||
new PasswordResetCodeRequest(
|
||||
tenantId,
|
||||
request.Phone,
|
||||
GetIpAddress(),
|
||||
Request.Headers.UserAgent.ToString(),
|
||||
request.DeviceId),
|
||||
cancellationToken);
|
||||
return Accepted(result);
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[EnableRateLimiting(AuthRateLimitPolicies.Password)]
|
||||
[HttpPost("password/reset")]
|
||||
[EndpointSummary("使用短信验证码重置密码")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public async Task<IActionResult> ResetPassword(
|
||||
PasswordResetDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = await ResolveRealmTenantIdAsync(AuthRealm.Tenant, request.TenantCode, cancellationToken)
|
||||
?? throw new RequiredFieldException("tenantCode is required for password reset.");
|
||||
await authService.ResetPasswordAsync(
|
||||
new PasswordResetRequest(
|
||||
tenantId,
|
||||
request.Phone,
|
||||
request.Code,
|
||||
request.NewPassword,
|
||||
GetIpAddress(),
|
||||
Request.Headers.UserAgent.ToString()),
|
||||
cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[Authorize]
|
||||
[EnableRateLimiting(AuthRateLimitPolicies.Password)]
|
||||
[HttpPost("password/change")]
|
||||
[EndpointSummary("已登录用户修改密码")]
|
||||
[EndpointDescription("修改成功后撤销旧会话并返回新的令牌对。")]
|
||||
public async Task<ActionResult<AuthenticationResultDto>> ChangePassword(
|
||||
AuthenticatedPasswordChangeDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId || currentUser.SessionId is not { } sessionId)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
var result = await authService.ChangePasswordAsync(
|
||||
new AuthenticatedPasswordChangeRequest(
|
||||
userId,
|
||||
sessionId,
|
||||
request.CurrentPassword,
|
||||
request.NewPassword,
|
||||
GetIpAddress(),
|
||||
Request.Headers.UserAgent.ToString()),
|
||||
cancellationToken);
|
||||
return Ok(AuthenticationResultDto.FromApplication(result));
|
||||
}
|
||||
|
||||
private void ResolveRefreshTokenTenant(string refreshToken)
|
||||
{
|
||||
if (!sessionStore.TryParseRefreshToken(refreshToken, out var locator))
|
||||
|
||||
@@ -12,7 +12,8 @@ namespace Tiku.Api.Controllers;
|
||||
[Authorize(Policy = BackendPermissions.TenantJobManage)]
|
||||
public sealed class BackgroundJobsController(
|
||||
IBackgroundJobService backgroundJobService,
|
||||
ITenantContext tenantContext) : ControllerBase
|
||||
ITenantContext tenantContext,
|
||||
ICurrentUser currentUser) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
[EndpointSummary("查询租户后台任务")]
|
||||
@@ -37,8 +38,38 @@ public sealed class BackgroundJobsController(
|
||||
return Ok(await backgroundJobService.EnqueueAsync(request.ToCommand(tenantId), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("{jobId:guid}")]
|
||||
[EndpointSummary("查询租户后台任务详情")]
|
||||
public async Task<ActionResult<BackgroundJobItem>> Detail(Guid jobId, CancellationToken cancellationToken)
|
||||
{
|
||||
var item = await backgroundJobService.GetAsync(jobId, ResolveTenantId(), cancellationToken);
|
||||
return item is null ? NotFound() : Ok(item);
|
||||
}
|
||||
|
||||
[HttpPost("{jobId:guid}/cancel")]
|
||||
[EndpointSummary("取消租户后台任务")]
|
||||
public async Task<ActionResult<BackgroundJobItem>> Cancel(
|
||||
Guid jobId,
|
||||
CancelBackgroundJobDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await backgroundJobService.RequestCancellationAsync(
|
||||
jobId, ResolveTenantId(), ResolveUserId(), request.Reason, cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("{jobId:guid}/retry")]
|
||||
[EndpointSummary("重试失败或已取消的租户后台任务")]
|
||||
public async Task<ActionResult<BackgroundJobItem>> Retry(Guid jobId, CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await backgroundJobService.RetryAsync(
|
||||
jobId, ResolveTenantId(), ResolveUserId(), cancellationToken));
|
||||
}
|
||||
|
||||
private Guid ResolveTenantId()
|
||||
{
|
||||
return tenantContext.TenantId ?? throw new InvalidOperationException("Tenant context was not resolved.");
|
||||
}
|
||||
|
||||
private Guid ResolveUserId() =>
|
||||
currentUser.UserId ?? throw new InvalidOperationException("Current user was not resolved.");
|
||||
}
|
||||
|
||||
@@ -171,6 +171,79 @@ public sealed class BrowserAuthController(
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[EnableRateLimiting(AuthRateLimitPolicies.Sms)]
|
||||
[HttpPost("password/reset/sms/send")]
|
||||
[EndpointSummary("发送浏览器密码重置短信验证码")]
|
||||
public async Task<ActionResult<SmsSendResult>> SendPasswordResetCode(
|
||||
PasswordResetSmsSendDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
EnsureTrustedOrigin();
|
||||
var tenantId = await ResolveTenantIdAsync(AuthRealm.Tenant, request.TenantCode, cancellationToken)
|
||||
?? throw new RequiredFieldException("tenantCode is required for password reset.");
|
||||
var result = await authService.RequestPasswordResetAsync(
|
||||
new PasswordResetCodeRequest(
|
||||
tenantId,
|
||||
request.Phone,
|
||||
HttpContext.Connection.RemoteIpAddress?.ToString(),
|
||||
Request.Headers.UserAgent.ToString(),
|
||||
request.DeviceId),
|
||||
cancellationToken);
|
||||
return Accepted(result);
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[EnableRateLimiting(AuthRateLimitPolicies.Password)]
|
||||
[HttpPost("password/reset")]
|
||||
[EndpointSummary("使用短信验证码重置浏览器账号密码")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public async Task<IActionResult> ResetPassword(
|
||||
PasswordResetDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
EnsureTrustedOrigin();
|
||||
var tenantId = await ResolveTenantIdAsync(AuthRealm.Tenant, request.TenantCode, cancellationToken)
|
||||
?? throw new RequiredFieldException("tenantCode is required for password reset.");
|
||||
await authService.ResetPasswordAsync(
|
||||
new PasswordResetRequest(
|
||||
tenantId,
|
||||
request.Phone,
|
||||
request.Code,
|
||||
request.NewPassword,
|
||||
HttpContext.Connection.RemoteIpAddress?.ToString(),
|
||||
Request.Headers.UserAgent.ToString()),
|
||||
cancellationToken);
|
||||
ClearCookies();
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[Authorize]
|
||||
[EnableRateLimiting(AuthRateLimitPolicies.Password)]
|
||||
[HttpPost("password/change")]
|
||||
[EndpointSummary("浏览器已登录用户修改密码")]
|
||||
public async Task<ActionResult<object>> ChangePassword(
|
||||
AuthenticatedPasswordChangeDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
EnsureTrustedOrigin();
|
||||
if (currentUser.UserId is not { } userId || currentUser.SessionId is not { } sessionId)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
var result = await authService.ChangePasswordAsync(
|
||||
new AuthenticatedPasswordChangeRequest(
|
||||
userId,
|
||||
sessionId,
|
||||
request.CurrentPassword,
|
||||
request.NewPassword,
|
||||
HttpContext.Connection.RemoteIpAddress?.ToString(),
|
||||
Request.Headers.UserAgent.ToString()),
|
||||
cancellationToken);
|
||||
return Ok(WriteResult(result));
|
||||
}
|
||||
|
||||
private object WriteResult(AuthenticationResult result)
|
||||
{
|
||||
if (result.User?.Tokens is { } tokens)
|
||||
|
||||
@@ -35,13 +35,7 @@ public sealed class HealthController(
|
||||
var database = await dbContext.Database.CanConnectAsync(cancellationToken);
|
||||
var redis = !redisSecurityStore.IsConfigured || await redisSecurityStore.PingAsync(cancellationToken);
|
||||
var ready = database && redis;
|
||||
var response = new
|
||||
{
|
||||
status = ready ? "ready" : "not_ready",
|
||||
database,
|
||||
redis = new { configured = redisSecurityStore.IsConfigured, ready = redis },
|
||||
checkedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
var response = new { status = ready ? "ready" : "not_ready", checkedAt = DateTimeOffset.UtcNow };
|
||||
return ready ? Ok(response) : StatusCode(StatusCodes.Status503ServiceUnavailable, response);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Auth;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
@@ -13,6 +14,7 @@ namespace Tiku.Api.Controllers;
|
||||
[Route("api/me")]
|
||||
public sealed class MeController(
|
||||
ICurrentUser currentUser,
|
||||
IAuthSessionStore sessionStore,
|
||||
TikuDbContext dbContext) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
@@ -54,6 +56,36 @@ public sealed class MeController(
|
||||
user.Name,
|
||||
memberships));
|
||||
}
|
||||
|
||||
[HttpGet("sessions")]
|
||||
[EndpointSummary("查询当前授权域的登录设备")]
|
||||
[ProducesResponseType<IReadOnlyCollection<AuthSessionSummary>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<IReadOnlyCollection<AuthSessionSummary>>> Sessions(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId || currentUser.SessionId is not { } sessionId)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
return Ok(await sessionStore.ListActiveAsync(userId, sessionId, cancellationToken));
|
||||
}
|
||||
|
||||
[HttpDelete("sessions/{sessionFamilyId:guid}")]
|
||||
[EndpointSummary("撤销其他设备的登录会话")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public async Task<IActionResult> RevokeSession(
|
||||
Guid sessionFamilyId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId || currentUser.SessionId is not { } sessionId)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
await sessionStore.RevokeOwnedFamilyAsync(userId, sessionId, sessionFamilyId, cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Application.PlatformAdmin;
|
||||
using Tiku.Application.Auth;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Platform;
|
||||
|
||||
@@ -14,6 +15,7 @@ namespace Tiku.Api.Controllers;
|
||||
[Route("api/platform-admin")]
|
||||
public sealed class PlatformAdminController(
|
||||
IPlatformAdminService platformAdminService,
|
||||
IAuthAdministrationService authAdministrationService,
|
||||
ICurrentUser currentUser) : ControllerBase
|
||||
{
|
||||
[HttpGet("overview")]
|
||||
@@ -134,6 +136,27 @@ public sealed class PlatformAdminController(
|
||||
return Ok(await platformAdminService.UpdateStaffStatusAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("staff/{userId:guid}/password-reset")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformStaffManage)]
|
||||
[EndpointSummary("为平台员工设置一次性临时密码")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public async Task<IActionResult> ResetStaffPassword(
|
||||
Guid userId,
|
||||
AdministrativePasswordResetDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var actor = ResolveActor();
|
||||
await authAdministrationService.ResetPasswordAsync(
|
||||
new AdministrativePasswordResetRequest(
|
||||
actor.UserId,
|
||||
userId,
|
||||
null,
|
||||
request.TemporaryPassword,
|
||||
request.Reason),
|
||||
cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpGet("audit-logs")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformAuditView)]
|
||||
[EndpointSummary("查询平台审计日志")]
|
||||
|
||||
159
Tiku.Api/Controllers/PlatformOperationsController.cs
Normal file
159
Tiku.Api/Controllers/PlatformOperationsController.cs
Normal file
@@ -0,0 +1,159 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Application.Jobs;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Operations;
|
||||
using Tiku.Application.Assets;
|
||||
using Tiku.Application.Storage;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Tiku.Infrastructure.Storage;
|
||||
|
||||
namespace Tiku.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Tags("平台端-运维")]
|
||||
[Route("api/platform-admin/operations")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformOperationsView)]
|
||||
public sealed class PlatformOperationsController(
|
||||
IBackgroundJobService backgroundJobService,
|
||||
ICurrentUser currentUser,
|
||||
TikuDbContext dbContext,
|
||||
IRedisSecurityStore redisSecurityStore,
|
||||
IAssetSecurityScanner assetSecurityScanner,
|
||||
IObjectStorageService objectStorageService,
|
||||
IOptions<AliyunOssOptions> aliyunOssOptions) : ControllerBase
|
||||
{
|
||||
[HttpGet("health")]
|
||||
[EndpointSummary("查询受保护的依赖深度健康状态")]
|
||||
public async Task<ActionResult<object>> Health(CancellationToken cancellationToken)
|
||||
{
|
||||
var database = await dbContext.Database.CanConnectAsync(cancellationToken);
|
||||
var redis = !redisSecurityStore.IsConfigured || await redisSecurityStore.PingAsync(cancellationToken);
|
||||
var clamAv = await assetSecurityScanner.CheckHealthAsync(cancellationToken);
|
||||
var storageProvider = objectStorageService.ConfiguredDefaultProvider();
|
||||
var storageConfigured = storageProvider switch
|
||||
{
|
||||
ObjectStorageProviders.AliyunOss => aliyunOssOptions.Value.IsConfigured,
|
||||
ObjectStorageProviders.LocalDev => true,
|
||||
_ => false
|
||||
};
|
||||
var newestHeartbeat = await dbContext.WorkerHeartbeats.AsNoTracking()
|
||||
.MaxAsync(item => (DateTimeOffset?)item.LastHeartbeatAt, cancellationToken);
|
||||
var workerReady = newestHeartbeat >= DateTimeOffset.UtcNow.AddMinutes(-2);
|
||||
return Ok(new
|
||||
{
|
||||
status = database && redis && clamAv && workerReady && storageConfigured ? "healthy" : "degraded",
|
||||
database,
|
||||
redis = new { configured = redisSecurityStore.IsConfigured, ready = redis },
|
||||
worker = new { ready = workerReady, lastHeartbeatAt = newestHeartbeat },
|
||||
clamAv,
|
||||
storage = new { provider = storageProvider, configured = storageConfigured },
|
||||
checkedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("workers")]
|
||||
[EndpointSummary("查询 Worker 与周期循环状态")]
|
||||
public async Task<ActionResult<object>> Workers(CancellationToken cancellationToken)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var items = await dbContext.WorkerHeartbeats.AsNoTracking()
|
||||
.OrderBy(item => item.WorkerId)
|
||||
.ThenBy(item => item.Processor)
|
||||
.Select(item => new
|
||||
{
|
||||
item.WorkerId,
|
||||
item.Processor,
|
||||
item.StartedAt,
|
||||
item.LastHeartbeatAt,
|
||||
item.LastIterationStartedAt,
|
||||
item.LastIterationCompletedAt,
|
||||
item.LastSucceededAt,
|
||||
item.LastError,
|
||||
item.IsRunning
|
||||
})
|
||||
.ToArrayAsync(cancellationToken);
|
||||
return Ok(new
|
||||
{
|
||||
staleAfterSeconds = 120,
|
||||
items = items.Select(item => new
|
||||
{
|
||||
item.WorkerId,
|
||||
item.Processor,
|
||||
item.StartedAt,
|
||||
item.LastHeartbeatAt,
|
||||
item.LastIterationStartedAt,
|
||||
item.LastIterationCompletedAt,
|
||||
item.LastSucceededAt,
|
||||
item.LastError,
|
||||
item.IsRunning,
|
||||
stale = item.LastHeartbeatAt < now.AddMinutes(-2)
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("job-metrics")]
|
||||
[EndpointSummary("查询后台任务队列指标")]
|
||||
public async Task<ActionResult<object>> JobMetrics(CancellationToken cancellationToken)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var counts = await dbContext.BackgroundJobs.AsNoTracking()
|
||||
.GroupBy(item => item.Status)
|
||||
.Select(group => new { status = group.Key, count = group.Count() })
|
||||
.ToArrayAsync(cancellationToken);
|
||||
var oldestPending = await dbContext.BackgroundJobs.AsNoTracking()
|
||||
.Where(item => item.Status == BackgroundJobStatus.Pending)
|
||||
.MinAsync(item => (DateTimeOffset?)item.CreatedAt, cancellationToken);
|
||||
var expiredLeases = await dbContext.BackgroundJobs.AsNoTracking()
|
||||
.CountAsync(item => item.Status == BackgroundJobStatus.Processing && item.LockExpiresAt < now, cancellationToken);
|
||||
return Ok(new
|
||||
{
|
||||
counts,
|
||||
oldestPendingAt = oldestPending,
|
||||
queueAgeSeconds = oldestPending.HasValue ? Math.Max(0, (now - oldestPending.Value).TotalSeconds) : 0,
|
||||
expiredLeases,
|
||||
checkedAt = now
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("jobs")]
|
||||
[EndpointSummary("查询全平台后台任务")]
|
||||
public async Task<ActionResult<IReadOnlyCollection<BackgroundJobItem>>> Jobs(
|
||||
[FromQuery] Guid? tenantId,
|
||||
[FromQuery] string? jobType,
|
||||
[FromQuery] BackgroundJobStatus? status,
|
||||
[FromQuery] int? limit,
|
||||
CancellationToken cancellationToken) =>
|
||||
Ok(await backgroundJobService.ListPlatformAsync(
|
||||
tenantId, jobType, status, limit ?? 100, cancellationToken));
|
||||
|
||||
[HttpGet("jobs/{jobId:guid}")]
|
||||
[EndpointSummary("查询平台后台任务详情")]
|
||||
public async Task<ActionResult<BackgroundJobItem>> Job(Guid jobId, CancellationToken cancellationToken)
|
||||
{
|
||||
var item = await backgroundJobService.GetAsync(jobId, null, cancellationToken);
|
||||
return item is null ? NotFound() : Ok(item);
|
||||
}
|
||||
|
||||
[HttpPost("jobs/{jobId:guid}/cancel")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformOperationsManage)]
|
||||
[EndpointSummary("取消平台后台任务")]
|
||||
public async Task<ActionResult<BackgroundJobItem>> CancelJob(
|
||||
Guid jobId,
|
||||
CancelBackgroundJobDto request,
|
||||
CancellationToken cancellationToken) =>
|
||||
Ok(await backgroundJobService.RequestCancellationAsync(
|
||||
jobId, null, ResolveUserId(), request.Reason, cancellationToken));
|
||||
|
||||
[HttpPost("jobs/{jobId:guid}/retry")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformOperationsManage)]
|
||||
[EndpointSummary("重试平台后台任务")]
|
||||
public async Task<ActionResult<BackgroundJobItem>> RetryJob(Guid jobId, CancellationToken cancellationToken) =>
|
||||
Ok(await backgroundJobService.RetryAsync(jobId, null, ResolveUserId(), cancellationToken));
|
||||
|
||||
private Guid ResolveUserId() =>
|
||||
currentUser.UserId ?? throw new InvalidOperationException("Current platform user was not resolved.");
|
||||
}
|
||||
76
Tiku.Api/Controllers/PlatformTenantLifecycleController.cs
Normal file
76
Tiku.Api/Controllers/PlatformTenantLifecycleController.cs
Normal file
@@ -0,0 +1,76 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Storage;
|
||||
using Tiku.Application.Tenancy;
|
||||
|
||||
namespace Tiku.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Tags("平台端-租户生命周期")]
|
||||
[Route("api/platform-admin/tenants/{tenantId:guid}")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformTenantManage)]
|
||||
public sealed class PlatformTenantLifecycleController(
|
||||
ITenantLifecycleService lifecycleService,
|
||||
ICurrentUser currentUser) : ControllerBase
|
||||
{
|
||||
[HttpGet("archive-preview")]
|
||||
[EndpointSummary("预检租户归档条件")]
|
||||
public async Task<ActionResult<TenantArchivePreview>> Preview(Guid tenantId, CancellationToken cancellationToken) =>
|
||||
Ok(await lifecycleService.PreviewArchiveAsync(tenantId, cancellationToken));
|
||||
|
||||
[HttpPost("exports")]
|
||||
[EndpointSummary("创建租户数据导出")]
|
||||
public async Task<ActionResult<TenantLifecycleOperationItem>> CreateExport(
|
||||
Guid tenantId,
|
||||
CancellationToken cancellationToken) =>
|
||||
Accepted(await lifecycleService.CreateExportAsync(tenantId, ResolveUserId(), cancellationToken));
|
||||
|
||||
[HttpGet("exports/{operationId:guid}")]
|
||||
[EndpointSummary("查询租户数据导出状态")]
|
||||
public async Task<ActionResult<TenantLifecycleOperationItem>> ExportStatus(
|
||||
Guid tenantId,
|
||||
Guid operationId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var operation = await lifecycleService.GetOperationAsync(tenantId, operationId, cancellationToken);
|
||||
return operation is null ? NotFound() : Ok(operation);
|
||||
}
|
||||
|
||||
[HttpGet("exports/{operationId:guid}/download")]
|
||||
[EndpointSummary("获取租户导出下载地址")]
|
||||
public async Task<ActionResult<ObjectStorageSignedUrl>> DownloadExport(
|
||||
Guid tenantId,
|
||||
Guid operationId,
|
||||
CancellationToken cancellationToken) =>
|
||||
Ok(await lifecycleService.SignExportDownloadAsync(tenantId, operationId, cancellationToken));
|
||||
|
||||
[HttpPost("archive")]
|
||||
[EndpointSummary("逻辑归档租户")]
|
||||
public async Task<ActionResult<TenantLifecycleOperationItem>> Archive(
|
||||
Guid tenantId,
|
||||
TenantLifecycleReasonDto request,
|
||||
CancellationToken cancellationToken) =>
|
||||
Ok(await lifecycleService.ArchiveAsync(tenantId, ResolveUserId(), request.Reason, cancellationToken));
|
||||
|
||||
[HttpPost("restore")]
|
||||
[EndpointSummary("恢复租户到暂停状态")]
|
||||
public async Task<ActionResult<TenantLifecycleOperationItem>> Restore(
|
||||
Guid tenantId,
|
||||
TenantLifecycleReasonDto request,
|
||||
CancellationToken cancellationToken) =>
|
||||
Ok(await lifecycleService.RestoreAsync(tenantId, ResolveUserId(), request.Reason, cancellationToken));
|
||||
|
||||
[HttpPost("owner-transfer")]
|
||||
[EndpointSummary("转移租户所有者")]
|
||||
public async Task<ActionResult<TenantLifecycleOperationItem>> TransferOwner(
|
||||
Guid tenantId,
|
||||
TenantOwnerTransferDto request,
|
||||
CancellationToken cancellationToken) =>
|
||||
Ok(await lifecycleService.TransferOwnerAsync(
|
||||
tenantId, ResolveUserId(), request.TargetUserId, request.Reason, cancellationToken));
|
||||
|
||||
private Guid ResolveUserId() =>
|
||||
currentUser.UserId ?? throw new InvalidOperationException("Current platform user was not resolved.");
|
||||
}
|
||||
@@ -2,10 +2,12 @@ using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Application.Catalog;
|
||||
using Tiku.Application.Auth;
|
||||
using Tiku.Application.Content;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.TenantAdmin;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Content;
|
||||
|
||||
namespace Tiku.Api.Controllers;
|
||||
|
||||
@@ -15,6 +17,7 @@ namespace Tiku.Api.Controllers;
|
||||
[Route("api/tenant-admin")]
|
||||
public sealed class TenantAdminDirectController(
|
||||
ITenantAdminDirectService tenantAdminService,
|
||||
IAuthAdministrationService authAdministrationService,
|
||||
ICurrentUser currentUser,
|
||||
ITenantContext currentTenant) : ControllerBase
|
||||
{
|
||||
@@ -314,6 +317,27 @@ public sealed class TenantAdminDirectController(
|
||||
return Ok(await tenantAdminService.DisableMemberAsync(ResolveActor(), request.MembershipId, cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("members/{userId:guid}/password-reset")]
|
||||
[Authorize(Policy = BackendPermissions.TenantStaffManage)]
|
||||
[EndpointSummary("为租户成员设置一次性临时密码")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public async Task<IActionResult> ResetMemberPassword(
|
||||
Guid userId,
|
||||
AdministrativePasswordResetDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var actor = ResolveActor();
|
||||
await authAdministrationService.ResetPasswordAsync(
|
||||
new AdministrativePasswordResetRequest(
|
||||
actor.UserId,
|
||||
userId,
|
||||
actor.TenantId,
|
||||
request.TemporaryPassword,
|
||||
request.Reason),
|
||||
cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpGet("audit-logs")]
|
||||
[Authorize(Policy = BackendPermissions.TenantStaffManage)]
|
||||
[EndpointSummary("查询租户审计日志")]
|
||||
|
||||
Reference in New Issue
Block a user