forked from gongxuegit/tiku-backend.net
feat(security): add distributed authorization foundation
This commit is contained in:
@@ -28,6 +28,8 @@ public sealed class AuthController(
|
||||
[AllowAnonymous]
|
||||
[EnableRateLimiting(AuthRateLimitPolicies.Sms)]
|
||||
[HttpPost("sms/send")]
|
||||
[EndpointSummary("发送短信验证码")]
|
||||
[EndpointDescription("发送登录用途短信验证码,并应用租户级短信限流。")]
|
||||
[ProducesResponseType<SmsSendResult>(StatusCodes.Status202Accepted)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status429TooManyRequests)]
|
||||
@@ -86,6 +88,7 @@ public sealed class AuthController(
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[EnableRateLimiting(AuthRateLimitPolicies.Sms)]
|
||||
[HttpPost("login/sms")]
|
||||
[EndpointSummary("短信验证码登录")]
|
||||
[EndpointDescription("校验已发送的登录用途短信验证码,成功后签发 JWT access token 与数据库 refresh/session。")]
|
||||
@@ -195,6 +198,8 @@ public sealed class AuthController(
|
||||
|
||||
[HttpPost("logout-all")]
|
||||
[Authorize]
|
||||
[EndpointSummary("退出全部登录会话")]
|
||||
[EndpointDescription("撤销当前用户全部 refresh/session,会话校验开启时旧 access token 也会被拒绝。")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public async Task<IActionResult> LogoutAll(CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -210,6 +215,8 @@ public sealed class AuthController(
|
||||
[AllowAnonymous]
|
||||
[HttpPost("password/change-required")]
|
||||
[EnableRateLimiting(AuthRateLimitPolicies.Password)]
|
||||
[EndpointSummary("修改首次登录必改密码")]
|
||||
[EndpointDescription("校验密码变更挑战令牌并设置新密码,成功后签发新的登录会话。")]
|
||||
public async Task<ActionResult<AuthenticationResultDto>> ChangeRequiredPassword(
|
||||
[FromBody] RequiredPasswordChangeDto request,
|
||||
CancellationToken cancellationToken)
|
||||
|
||||
@@ -14,6 +14,7 @@ public sealed class BackgroundJobsController(
|
||||
ITenantContext tenantContext) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
[EndpointSummary("查询租户后台任务")]
|
||||
[ProducesResponseType<IReadOnlyCollection<BackgroundJobItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<IReadOnlyCollection<BackgroundJobItem>>> List(
|
||||
[FromQuery] string? jobType,
|
||||
@@ -25,6 +26,7 @@ public sealed class BackgroundJobsController(
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[EndpointSummary("创建租户后台任务")]
|
||||
[ProducesResponseType<BackgroundJobItem>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<BackgroundJobItem>> Create(
|
||||
CreateBackgroundJobDto request,
|
||||
|
||||
@@ -14,6 +14,8 @@ public sealed class BackofficeController(
|
||||
{
|
||||
[HttpGet("tenant/ui-bootstrap")]
|
||||
[Authorize(Policy = TikuPolicies.TenantBackofficeBootstrap)]
|
||||
[EndpointSummary("查询租户后台菜单与权限")]
|
||||
[EndpointDescription("返回当前租户管理员可见的后台菜单、权限和模块启用状态。")]
|
||||
[ProducesResponseType<BackofficeUiBootstrap>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<BackofficeUiBootstrap>> GetTenantUiBootstrap(CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -24,6 +26,7 @@ public sealed class BackofficeController(
|
||||
|
||||
[HttpGet("tenant/bootstrap")]
|
||||
[Authorize(Policy = BackendPermissions.TenantRoleManage)]
|
||||
[EndpointSummary("查询租户角色管理初始化数据")]
|
||||
[ProducesResponseType<BackofficeBootstrap>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<BackofficeBootstrap>> GetTenantBootstrap(CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -32,6 +35,8 @@ public sealed class BackofficeController(
|
||||
|
||||
[HttpGet("platform/ui-bootstrap")]
|
||||
[Authorize(Policy = TikuPolicies.PlatformBackofficeBootstrap)]
|
||||
[EndpointSummary("查询平台后台菜单与权限")]
|
||||
[EndpointDescription("返回当前平台管理员可见的后台菜单、权限和模块启用状态。")]
|
||||
[ProducesResponseType<BackofficeUiBootstrap>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<BackofficeUiBootstrap>> GetPlatformUiBootstrap(CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -42,6 +47,7 @@ public sealed class BackofficeController(
|
||||
|
||||
[HttpPost("tenant/roles")]
|
||||
[Authorize(Policy = BackendPermissions.TenantRoleManage)]
|
||||
[EndpointSummary("创建或更新租户后台角色")]
|
||||
[ProducesResponseType<BackofficeRoleItem>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<BackofficeRoleItem>> UpsertTenantRole(
|
||||
UpsertBackofficeRoleDto request,
|
||||
@@ -52,6 +58,7 @@ public sealed class BackofficeController(
|
||||
|
||||
[HttpPut("tenant/roles/{roleId:guid}/bindings")]
|
||||
[Authorize(Policy = BackendPermissions.TenantRoleManage)]
|
||||
[EndpointSummary("替换租户后台角色权限绑定")]
|
||||
[ProducesResponseType<BackofficeRoleItem>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<BackofficeRoleItem>> ReplaceTenantRoleBindings(
|
||||
Guid roleId,
|
||||
@@ -63,6 +70,7 @@ public sealed class BackofficeController(
|
||||
|
||||
[HttpPut("tenant/users/{userId:guid}/roles")]
|
||||
[Authorize(Policy = BackendPermissions.TenantRoleManage)]
|
||||
[EndpointSummary("替换租户用户后台角色")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public async Task<IActionResult> ReplaceTenantUserRoles(
|
||||
Guid userId,
|
||||
@@ -75,6 +83,7 @@ public sealed class BackofficeController(
|
||||
|
||||
[HttpGet("platform/bootstrap")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformRoleManage)]
|
||||
[EndpointSummary("查询平台角色管理初始化数据")]
|
||||
[ProducesResponseType<BackofficeBootstrap>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<BackofficeBootstrap>> GetPlatformBootstrap(CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -83,6 +92,7 @@ public sealed class BackofficeController(
|
||||
|
||||
[HttpPost("platform/roles")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformRoleManage)]
|
||||
[EndpointSummary("创建或更新平台后台角色")]
|
||||
[ProducesResponseType<BackofficeRoleItem>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<BackofficeRoleItem>> UpsertPlatformRole(
|
||||
UpsertBackofficeRoleDto request,
|
||||
@@ -93,6 +103,7 @@ public sealed class BackofficeController(
|
||||
|
||||
[HttpPut("platform/roles/{roleId:guid}/bindings")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformRoleManage)]
|
||||
[EndpointSummary("替换平台后台角色权限绑定")]
|
||||
[ProducesResponseType<BackofficeRoleItem>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<BackofficeRoleItem>> ReplacePlatformRoleBindings(
|
||||
Guid roleId,
|
||||
@@ -104,6 +115,7 @@ public sealed class BackofficeController(
|
||||
|
||||
[HttpPut("platform/users/{userId:guid}/roles")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformRoleManage)]
|
||||
[EndpointSummary("替换平台用户后台角色")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public async Task<IActionResult> ReplacePlatformUserRoles(
|
||||
Guid userId,
|
||||
|
||||
251
Tiku.Api/Controllers/BrowserAuthController.cs
Normal file
251
Tiku.Api/Controllers/BrowserAuthController.cs
Normal file
@@ -0,0 +1,251 @@
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Api.Options;
|
||||
using Tiku.Application.Auth;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Content;
|
||||
|
||||
namespace Tiku.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/browser-auth")]
|
||||
[Produces("application/json")]
|
||||
public sealed class BrowserAuthController(
|
||||
IAuthService authService,
|
||||
ISmsVerificationService smsVerificationService,
|
||||
ITenantContext tenantContext,
|
||||
ITenantContextInitializer tenantContextInitializer,
|
||||
ITenantDirectory tenantDirectory,
|
||||
ICurrentUser currentUser,
|
||||
IOptions<TenantResolutionOptions> tenantResolutionOptions) : ControllerBase
|
||||
{
|
||||
[AllowAnonymous]
|
||||
[EnableRateLimiting(AuthRateLimitPolicies.Sms)]
|
||||
[HttpPost("sms/send")]
|
||||
public async Task<ActionResult<SmsSendResult>> SendSmsCode(
|
||||
[FromBody] SendSmsCodeDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
EnsureTrustedOrigin();
|
||||
var realm = request.Realm!.Value;
|
||||
if (realm != AuthRealm.Tenant)
|
||||
{
|
||||
throw new RequiredFieldException("SMS authentication is only available in the tenant realm.");
|
||||
}
|
||||
var tenantId = await ResolveTenantIdAsync(realm, request.TenantCode, cancellationToken)
|
||||
?? throw new RequiredFieldException("tenantCode is required for SMS authentication.");
|
||||
var result = await smsVerificationService.CreateCodeAsync(new SendSmsCodeRequest(
|
||||
tenantId,
|
||||
request.Phone,
|
||||
SmsPurpose.Login,
|
||||
HttpContext.Connection.RemoteIpAddress?.ToString(),
|
||||
Request.Headers.UserAgent.ToString(),
|
||||
request.DeviceId), cancellationToken);
|
||||
return Accepted(result);
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpPost("login/password")]
|
||||
public async Task<ActionResult<object>> LoginWithPassword(
|
||||
[FromBody] PasswordLoginDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
EnsureTrustedOrigin();
|
||||
var realm = request.Realm!.Value;
|
||||
var identifier = request.Identifier ?? request.Phone;
|
||||
if (string.IsNullOrWhiteSpace(identifier)) throw new RequiredFieldException("identifier is required.");
|
||||
var result = await authService.LoginWithPasswordAsync(new PasswordLoginRequest(
|
||||
realm,
|
||||
await ResolveTenantIdAsync(realm, request.TenantCode, cancellationToken),
|
||||
identifier,
|
||||
request.Password,
|
||||
HttpContext.Connection.RemoteIpAddress?.ToString(),
|
||||
Request.Headers.UserAgent.ToString()), cancellationToken);
|
||||
return Ok(WriteResult(result));
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[EnableRateLimiting(AuthRateLimitPolicies.Sms)]
|
||||
[HttpPost("login/sms")]
|
||||
public async Task<ActionResult<object>> LoginWithSms(
|
||||
[FromBody] SmsLoginDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
EnsureTrustedOrigin();
|
||||
var realm = request.Realm!.Value;
|
||||
var result = await authService.LoginWithSmsAsync(new SmsLoginRequest(
|
||||
realm,
|
||||
await ResolveTenantIdAsync(realm, request.TenantCode, cancellationToken),
|
||||
request.Phone,
|
||||
request.Code,
|
||||
HttpContext.Connection.RemoteIpAddress?.ToString(),
|
||||
Request.Headers.UserAgent.ToString()), cancellationToken);
|
||||
return Ok(WriteResult(result));
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpPost("oauth/wechat")]
|
||||
public async Task<ActionResult<object>> LoginWithWechatWeb(
|
||||
[FromBody] OAuthCodeDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
EnsureTrustedOrigin();
|
||||
var realm = request.Realm!.Value;
|
||||
var result = await authService.LoginWithWechatWebAsync(new WechatLoginRequest(
|
||||
realm,
|
||||
await ResolveTenantIdAsync(realm, request.TenantCode, cancellationToken),
|
||||
request.Code,
|
||||
HttpContext.Connection.RemoteIpAddress?.ToString(),
|
||||
Request.Headers.UserAgent.ToString()), cancellationToken);
|
||||
return Ok(WriteResult(result));
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpPost("oauth/wechat-miniapp")]
|
||||
public async Task<ActionResult<object>> LoginWithWechatMiniApp(
|
||||
[FromBody] OAuthCodeDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
EnsureTrustedOrigin();
|
||||
var realm = request.Realm!.Value;
|
||||
var result = await authService.LoginWithWechatMiniAppAsync(new WechatLoginRequest(
|
||||
realm,
|
||||
await ResolveTenantIdAsync(realm, request.TenantCode, cancellationToken),
|
||||
request.Code,
|
||||
HttpContext.Connection.RemoteIpAddress?.ToString(),
|
||||
Request.Headers.UserAgent.ToString()), cancellationToken);
|
||||
return Ok(WriteResult(result));
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpPost("refresh")]
|
||||
public async Task<ActionResult<object>> Refresh(CancellationToken cancellationToken)
|
||||
{
|
||||
var refreshToken = Request.Cookies[BrowserAuthOptions.RefreshCookie];
|
||||
if (string.IsNullOrWhiteSpace(refreshToken)) return Unauthorized();
|
||||
var tokens = await authService.RefreshAsync(new RefreshSessionRequest(
|
||||
refreshToken,
|
||||
HttpContext.Connection.RemoteIpAddress?.ToString(),
|
||||
Request.Headers.UserAgent.ToString()), cancellationToken);
|
||||
WriteCookies(tokens);
|
||||
return Ok(new { status = "authenticated" });
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpPost("logout")]
|
||||
public async Task<IActionResult> Logout(CancellationToken cancellationToken)
|
||||
{
|
||||
var refreshToken = Request.Cookies[BrowserAuthOptions.RefreshCookie];
|
||||
if (!string.IsNullOrWhiteSpace(refreshToken))
|
||||
{
|
||||
await authService.LogoutAsync(new LogoutSessionRequest(refreshToken), cancellationToken);
|
||||
}
|
||||
ClearCookies();
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[Authorize]
|
||||
[HttpPost("logout-all")]
|
||||
public async Task<IActionResult> LogoutAll(CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId) return Unauthorized();
|
||||
await authService.LogoutAllAsync(userId, cancellationToken);
|
||||
ClearCookies();
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
private object WriteResult(AuthenticationResult result)
|
||||
{
|
||||
if (result.User?.Tokens is { } tokens)
|
||||
{
|
||||
WriteCookies(tokens);
|
||||
}
|
||||
return new
|
||||
{
|
||||
status = result.Status.ToString(),
|
||||
user = result.User is null ? null : new
|
||||
{
|
||||
result.User.UserId,
|
||||
result.User.Phone,
|
||||
result.User.Email,
|
||||
result.User.Name,
|
||||
result.User.Realm,
|
||||
result.User.Tenant
|
||||
},
|
||||
result.ChallengeToken,
|
||||
result.ChallengeExpiresAt
|
||||
};
|
||||
}
|
||||
|
||||
private void WriteCookies(AuthTokenPair tokens)
|
||||
{
|
||||
Response.Cookies.Append(BrowserAuthOptions.AccessCookie, tokens.AccessToken, new CookieOptions
|
||||
{
|
||||
Secure = true,
|
||||
HttpOnly = true,
|
||||
SameSite = SameSiteMode.Lax,
|
||||
Path = "/",
|
||||
MaxAge = TimeSpan.FromMinutes(15)
|
||||
});
|
||||
Response.Cookies.Append(BrowserAuthOptions.RefreshCookie, tokens.RefreshToken, new CookieOptions
|
||||
{
|
||||
Secure = true,
|
||||
HttpOnly = true,
|
||||
SameSite = SameSiteMode.Strict,
|
||||
Path = "/api/browser-auth",
|
||||
MaxAge = TimeSpan.FromDays(30)
|
||||
});
|
||||
Response.Cookies.Append(BrowserAuthOptions.CsrfCookie,
|
||||
Convert.ToHexString(RandomNumberGenerator.GetBytes(32)).ToLowerInvariant(), new CookieOptions
|
||||
{
|
||||
Secure = true,
|
||||
HttpOnly = false,
|
||||
SameSite = SameSiteMode.Strict,
|
||||
Path = "/"
|
||||
});
|
||||
}
|
||||
|
||||
private void ClearCookies()
|
||||
{
|
||||
Response.Cookies.Delete(BrowserAuthOptions.AccessCookie, new CookieOptions { Secure = true, Path = "/" });
|
||||
Response.Cookies.Delete(BrowserAuthOptions.RefreshCookie, new CookieOptions { Secure = true, Path = "/api/browser-auth" });
|
||||
Response.Cookies.Delete(BrowserAuthOptions.CsrfCookie, new CookieOptions { Secure = true, Path = "/" });
|
||||
}
|
||||
|
||||
private void EnsureTrustedOrigin()
|
||||
{
|
||||
var origin = Request.Headers.Origin.ToString();
|
||||
if (!Uri.TryCreate(origin, UriKind.Absolute, out var uri) ||
|
||||
!string.Equals(uri.Scheme, Request.Scheme, StringComparison.OrdinalIgnoreCase) ||
|
||||
!string.Equals(uri.Authority, Request.Host.Value, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new BrowserOriginException();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Guid?> ResolveTenantIdAsync(AuthRealm realm, string? tenantCode, CancellationToken cancellationToken)
|
||||
{
|
||||
if (realm == AuthRealm.Platform)
|
||||
{
|
||||
var requestHost = Request.Host.Host.Trim().TrimEnd('.');
|
||||
if (!tenantResolutionOptions.Value.PlatformHosts.Any(host =>
|
||||
string.Equals(host.Trim().TrimEnd('.'), requestHost, StringComparison.OrdinalIgnoreCase)))
|
||||
throw new RequiredFieldException("platform realm is only available on a configured platform host.");
|
||||
return null;
|
||||
}
|
||||
if (tenantContext.TenantId is { } resolved) return resolved;
|
||||
if (string.IsNullOrWhiteSpace(tenantCode)) throw new RequiredFieldException("tenantCode is required.");
|
||||
var tenant = await tenantDirectory.FindByCodeAsync(tenantCode.Trim(), cancellationToken)
|
||||
?? throw new TenantNotFoundException();
|
||||
tenantContextInitializer.Initialize(tenant.TenantId, tenant.TenantCode, TenantResolutionSource.TenantCode);
|
||||
return tenant.TenantId;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class BrowserOriginException() : Exception("Browser authentication requires a same-origin request.");
|
||||
@@ -16,50 +16,62 @@ public sealed class CommissionController(
|
||||
ITenantContext currentTenant) : ControllerBase
|
||||
{
|
||||
[HttpGet("settings")]
|
||||
[EndpointSummary("查询佣金配置")]
|
||||
public async Task<ActionResult<CommissionSettingsItem>> Settings(CancellationToken cancellationToken) =>
|
||||
Ok(await commissionService.GetSettingsAsync(ResolveActor(), cancellationToken));
|
||||
|
||||
[HttpPut("settings")]
|
||||
[EndpointSummary("保存佣金配置")]
|
||||
public async Task<ActionResult<CommissionSettingsItem>> UpdateSettings(UpdateCommissionSettingsDto request, CancellationToken cancellationToken) =>
|
||||
Ok(await commissionService.UpdateSettingsAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
||||
|
||||
[HttpPut("member-rate")]
|
||||
[EndpointSummary("调整成员佣金比例")]
|
||||
public async Task<ActionResult<object>> MemberRate(UpdateMemberCommissionRateDto request, CancellationToken cancellationToken) =>
|
||||
Ok(await commissionService.UpdateMemberRateAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
||||
|
||||
[HttpGet("summary")]
|
||||
[EndpointSummary("查询佣金统计摘要")]
|
||||
public async Task<ActionResult<CommissionSummaryItem>> Summary([FromQuery] CommissionPeriodQueryDto query, CancellationToken cancellationToken) =>
|
||||
Ok(await commissionService.GetSummaryAsync(ResolveActor(), query.ToQuery(), cancellationToken));
|
||||
|
||||
[HttpGet("orders")]
|
||||
[EndpointSummary("查询佣金来源订单")]
|
||||
public async Task<ActionResult<CommissionList<CommissionSourceItem>>> Orders([FromQuery] CommissionPeriodQueryDto query, CancellationToken cancellationToken) =>
|
||||
Ok(await commissionService.GetOrdersAsync(ResolveActor(), query.ToQuery(), cancellationToken));
|
||||
|
||||
[HttpGet("settlements")]
|
||||
[EndpointSummary("查询佣金结算单")]
|
||||
public async Task<ActionResult<CommissionList<CommissionSettlementItemDto>>> Settlements([FromQuery] CommissionSettlementsQueryDto query, CancellationToken cancellationToken) =>
|
||||
Ok(await commissionService.GetSettlementsAsync(ResolveActor(), query.ToQuery(), cancellationToken));
|
||||
|
||||
[HttpGet("settlements/export")]
|
||||
[EndpointSummary("导出佣金结算单")]
|
||||
public async Task<ActionResult<CommissionExportItem>> Export([FromQuery] CommissionSettlementExportQueryDto query, CancellationToken cancellationToken) =>
|
||||
Ok(await commissionService.ExportSettlementAsync(ResolveActor(), query.SettlementId, query.Format, cancellationToken));
|
||||
|
||||
[HttpPost("settlements/generate")]
|
||||
[EndpointSummary("生成佣金结算单")]
|
||||
public async Task<ActionResult<CommissionSettlementItemDto>> Generate(GenerateCommissionSettlementDto request, CancellationToken cancellationToken) =>
|
||||
Ok(await commissionService.GenerateSettlementAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
||||
|
||||
[HttpPost("settlements/status")]
|
||||
[EndpointSummary("更新佣金结算单状态")]
|
||||
public async Task<ActionResult<CommissionSettlementItemDto>> UpdateStatus(UpdateCommissionSettlementStatusDto request, CancellationToken cancellationToken) =>
|
||||
Ok(await commissionService.UpdateSettlementStatusAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
||||
|
||||
[HttpGet("settlements/proofs")]
|
||||
[EndpointSummary("查询佣金结算凭证")]
|
||||
public async Task<ActionResult<CommissionList<CommissionProofItem>>> Proofs([FromQuery] CommissionSettlementProofQueryDto query, CancellationToken cancellationToken) =>
|
||||
Ok(await commissionService.GetProofsAsync(ResolveActor(), query.SettlementId, cancellationToken));
|
||||
|
||||
[HttpPost("settlements/proofs")]
|
||||
[EndpointSummary("创建佣金结算凭证")]
|
||||
public async Task<ActionResult<CommissionProofItem>> CreateProof(CreateCommissionProofDto request, CancellationToken cancellationToken) =>
|
||||
Ok(await commissionService.CreateProofAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
||||
|
||||
[HttpPost("settlements/proofs/status")]
|
||||
[EndpointSummary("更新佣金结算凭证状态")]
|
||||
public async Task<ActionResult<CommissionProofItem>> UpdateProofStatus(UpdateCommissionProofStatusDto request, CancellationToken cancellationToken) =>
|
||||
Ok(await commissionService.UpdateProofStatusAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
||||
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MassTransit.EntityFrameworkCoreIntegration;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Tiku.Infrastructure.Messaging;
|
||||
|
||||
namespace Tiku.Api.Controllers;
|
||||
|
||||
@@ -8,7 +14,11 @@ namespace Tiku.Api.Controllers;
|
||||
[AllowAnonymous]
|
||||
[Produces("application/json")]
|
||||
[Route("api/health")]
|
||||
public sealed class HealthController : ControllerBase
|
||||
public sealed class HealthController(
|
||||
TikuDbContext dbContext,
|
||||
IRedisSecurityStore redisSecurityStore,
|
||||
MessagingOptions messagingOptions,
|
||||
HealthCheckService healthCheckService) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
[EndpointSummary("健康检查")]
|
||||
@@ -21,4 +31,30 @@ public sealed class HealthController : ControllerBase
|
||||
"tiku-api",
|
||||
DateTimeOffset.UtcNow));
|
||||
}
|
||||
|
||||
[HttpGet("ready")]
|
||||
[EndpointSummary("依赖就绪检查")]
|
||||
public async Task<ActionResult<object>> Ready(CancellationToken cancellationToken)
|
||||
{
|
||||
var database = await dbContext.Database.CanConnectAsync(cancellationToken);
|
||||
var redis = !redisSecurityStore.IsConfigured || await redisSecurityStore.PingAsync(cancellationToken);
|
||||
var rabbitHealth = await healthCheckService.CheckHealthAsync(
|
||||
registration => registration.Tags.Contains("ready"),
|
||||
cancellationToken);
|
||||
var rabbitMq = !messagingOptions.IsConfigured || rabbitHealth.Status == HealthStatus.Healthy;
|
||||
var outboxPending = database
|
||||
? await dbContext.Set<OutboxMessage>().CountAsync(cancellationToken)
|
||||
: -1;
|
||||
var ready = database && redis && rabbitMq;
|
||||
var response = new
|
||||
{
|
||||
status = ready ? "ready" : "not_ready",
|
||||
database,
|
||||
redis = new { configured = redisSecurityStore.IsConfigured, ready = redis },
|
||||
rabbitMq = new { configured = messagingOptions.IsConfigured, ready = rabbitMq },
|
||||
outbox = new { pending = outboxPending },
|
||||
checkedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
return ready ? Ok(response) : StatusCode(StatusCodes.Status503ServiceUnavailable, response);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ public sealed class MeController(
|
||||
TikuDbContext dbContext) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
[EndpointSummary("获取当前登录用户")]
|
||||
[EndpointDescription("根据 Bearer Token 返回当前用户基础信息和活跃租户成员摘要。")]
|
||||
public async Task<ActionResult<MeResponse>> Get(CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is null)
|
||||
|
||||
@@ -13,6 +13,7 @@ public sealed class SecurityDiagnosticsController(
|
||||
{
|
||||
[Authorize(Policy = TikuPolicies.AuthenticatedUser)]
|
||||
[HttpGet("authenticated")]
|
||||
[EndpointSummary("诊断已登录用户上下文")]
|
||||
public ActionResult<object> Authenticated()
|
||||
{
|
||||
return Ok(new
|
||||
@@ -24,6 +25,7 @@ public sealed class SecurityDiagnosticsController(
|
||||
|
||||
[Authorize(Policy = TikuPolicies.CurrentTenantMember)]
|
||||
[HttpGet("tenant-member")]
|
||||
[EndpointSummary("诊断当前租户成员上下文")]
|
||||
public ActionResult<object> TenantMember()
|
||||
{
|
||||
return Ok(new
|
||||
@@ -34,6 +36,7 @@ public sealed class SecurityDiagnosticsController(
|
||||
|
||||
[Authorize(Policy = TikuPolicies.TenantAdmin)]
|
||||
[HttpGet("tenant-admin")]
|
||||
[EndpointSummary("诊断当前租户管理员上下文")]
|
||||
public ActionResult<object> TenantAdmin()
|
||||
{
|
||||
return Ok(new
|
||||
|
||||
@@ -15,6 +15,7 @@ public sealed class TaxonomyController(
|
||||
ITaxonomyService taxonomyService) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
[EndpointSummary("查询租户分类节点")]
|
||||
public Task<IReadOnlyCollection<TaxonomyNodeItem>> List(CancellationToken cancellationToken)
|
||||
{
|
||||
return taxonomyService.ListAsync(RequireTenantId(), cancellationToken);
|
||||
@@ -22,6 +23,7 @@ public sealed class TaxonomyController(
|
||||
|
||||
[HttpPost]
|
||||
[Authorize(Policy = BackendPermissions.TenantContentManage)]
|
||||
[EndpointSummary("创建租户分类节点")]
|
||||
public Task<TaxonomyNodeItem> Create(
|
||||
CreateTaxonomyNodeDto request,
|
||||
CancellationToken cancellationToken)
|
||||
|
||||
@@ -15,12 +15,14 @@ public sealed class TenantFrontendConfigController(
|
||||
ITenantFrontendConfigService frontendConfigService) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
[EndpointSummary("查询租户前端配置")]
|
||||
public Task<TenantFrontendConfigItem> Get(CancellationToken cancellationToken)
|
||||
{
|
||||
return frontendConfigService.GetAsync(RequireTenantId(), cancellationToken);
|
||||
}
|
||||
|
||||
[HttpPut("draft")]
|
||||
[EndpointSummary("保存租户前端配置草稿")]
|
||||
public Task<TenantFrontendConfigItem> SaveDraft(
|
||||
SaveTenantFrontendConfigDraftDto request,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -32,6 +34,7 @@ public sealed class TenantFrontendConfigController(
|
||||
}
|
||||
|
||||
[HttpPost("publish")]
|
||||
[EndpointSummary("发布租户前端配置")]
|
||||
public Task<TenantFrontendConfigItem> Publish(
|
||||
PublishTenantFrontendConfigDto request,
|
||||
CancellationToken cancellationToken)
|
||||
|
||||
@@ -17,6 +17,8 @@ public sealed class TenantsController(
|
||||
TikuDbContext dbContext) : ControllerBase
|
||||
{
|
||||
[HttpGet("current")]
|
||||
[EndpointSummary("查询当前租户")]
|
||||
[EndpointDescription("返回当前请求租户及当前用户在该租户内的成员角色。")]
|
||||
public async Task<ActionResult<CurrentTenantResponse>> GetCurrent(CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is null || currentTenant.TenantId is null)
|
||||
|
||||
Reference in New Issue
Block a user