Web version login

This commit is contained in:
LittleSheep 2025-06-28 22:53:07 +08:00
parent ec3961d546
commit 98b2eeb13d
21 changed files with 633 additions and 32 deletions

View File

@ -174,13 +174,4 @@ public class AccountController(
.Take(take)
.ToListAsync();
}
[HttpPost("/maintenance/ensureProfileCreated")]
[Authorize]
[RequiredPermission("maintenance", "accounts.profiles")]
public async Task<ActionResult> EnsureProfileCreated()
{
await accounts.EnsureAccountProfileCreated();
return Ok();
}
}

View File

@ -288,7 +288,7 @@ public class NotificationService(
var client = httpFactory.CreateClient();
client.BaseAddress = _notifyEndpoint;
var request = await client.PostAsync("/api/push", new StringContent(
var request = await client.PostAsync("/push", new StringContent(
JsonSerializer.Serialize(requestDict),
Encoding.UTF8,
"application/json"

View File

@ -15,6 +15,7 @@ public static class AuthConstants
{
public const string SchemeName = "DysonToken";
public const string TokenQueryParamName = "tk";
public const string CookieTokenName = "AuthToken";
}
public enum TokenType
@ -182,7 +183,7 @@ public class DysonTokenAuthHandler(
return Convert.FromBase64String(padded);
}
private static TokenInfo? _ExtractToken(HttpRequest request)
private TokenInfo? _ExtractToken(HttpRequest request)
{
// Check for token in query parameters
if (request.Query.TryGetValue(AuthConstants.TokenQueryParamName, out var queryToken))
@ -227,7 +228,7 @@ public class DysonTokenAuthHandler(
}
// Check for token in cookies
if (request.Cookies.TryGetValue(AuthConstants.TokenQueryParamName, out var cookieToken))
if (request.Cookies.TryGetValue(AuthConstants.CookieTokenName, out var cookieToken))
{
return new TokenInfo
{

View File

@ -131,7 +131,7 @@ public class AuthService(
case "google":
content = new StringContent($"secret={apiSecret}&response={token}", System.Text.Encoding.UTF8,
"application/x-www-form-urlencoded");
response = await client.PostAsync("https://www.google.com/recaptcha/api/siteverify", content);
response = await client.PostAsync("https://www.google.com/recaptcha/siteverify", content);
response.EnsureSuccessStatusCode();
json = await response.Content.ReadAsStringAsync();

View File

@ -39,7 +39,7 @@ public class AfdianOidcService(
return Task.FromResult(new OidcDiscoveryDocument
{
AuthorizationEndpoint = "https://afdian.com/oauth2/authorize",
TokenEndpoint = "https://afdian.com/api/oauth2/access_token",
TokenEndpoint = "https://afdian.com/oauth2/access_token",
UserinfoEndpoint = null,
JwksUri = null
})!;
@ -60,7 +60,7 @@ public class AfdianOidcService(
});
var client = HttpClientFactory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Post, "https://afdian.com/api/oauth2/access_token");
var request = new HttpRequestMessage(HttpMethod.Post, "https://afdian.com/oauth2/access_token");
request.Content = content;
var response = await client.SendAsync(request);

View File

@ -30,7 +30,7 @@ public class DiscordOidcService(
};
var queryString = string.Join("&", queryParams.Select(p => $"{p.Key}={Uri.EscapeDataString(p.Value)}"));
return $"https://discord.com/api/oauth2/authorize?{queryString}";
return $"https://discord.com/oauth2/authorize?{queryString}";
}
protected override Task<OidcDiscoveryDocument?> GetDiscoveryDocumentAsync()
@ -38,8 +38,8 @@ public class DiscordOidcService(
return Task.FromResult(new OidcDiscoveryDocument
{
AuthorizationEndpoint = "https://discord.com/oauth2/authorize",
TokenEndpoint = "https://discord.com/api/oauth2/token",
UserinfoEndpoint = "https://discord.com/api/users/@me",
TokenEndpoint = "https://discord.com/oauth2/token",
UserinfoEndpoint = "https://discord.com/users/@me",
JwksUri = null
})!;
}
@ -75,7 +75,7 @@ public class DiscordOidcService(
{ "redirect_uri", config.RedirectUri },
});
var response = await client.PostAsync("https://discord.com/api/oauth2/token", content);
var response = await client.PostAsync("https://discord.com/oauth2/token", content);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<OidcTokenResponse>();
@ -84,7 +84,7 @@ public class DiscordOidcService(
private async Task<OidcUserInfo> GetUserInfoAsync(string accessToken)
{
var client = HttpClientFactory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Get, "https://discord.com/api/users/@me");
var request = new HttpRequestMessage(HttpMethod.Get, "https://discord.com/users/@me");
request.Headers.Add("Authorization", $"Bearer {accessToken}");
var response = await client.SendAsync(request);

View File

@ -8,7 +8,7 @@ namespace DysonNetwork.Sphere.Connection.WebReader;
[Authorize]
[ApiController]
[Route("feeds")]
[Route("/feeds")]
public class WebFeedController(WebFeedService webFeedService, AppDatabase database) : ControllerBase
{
public class CreateWebFeedRequest

View File

@ -3,7 +3,7 @@ using Microsoft.AspNetCore.Mvc;
namespace DysonNetwork.Sphere.Discovery;
[ApiController]
[Route("discovery")]
[Route("/discovery")]
public class DiscoveryController(DiscoveryService discoveryService) : ControllerBase
{
[HttpGet("realms")]

View File

@ -0,0 +1,78 @@
@page "/web/account/profile"
@model DysonNetwork.Sphere.Pages.Account.ProfileModel
@{
ViewData["Title"] = "Profile";
}
<div class="min-h-screen flex items-center justify-center bg-gray-100 dark:bg-gray-900 py-12">
<div class="bg-white dark:bg-gray-800 p-8 rounded-lg shadow-md w-full max-w-2xl">
<h1 class="text-3xl font-bold text-center text-gray-900 dark:text-white mb-8">User Profile</h1>
@if (Model.Account != null)
{
<div class="mb-6">
<h2 class="text-2xl font-semibold text-gray-800 dark:text-gray-200 mb-4">Account Information</h2>
<p class="text-gray-700 dark:text-gray-300 mb-2"><strong>Username:</strong> @Model.Account.Name</p>
<p class="text-gray-700 dark:text-gray-300 mb-2"><strong>Nickname:</strong> @Model.Account.Nick</p>
<p class="text-gray-700 dark:text-gray-300 mb-2"><strong>Language:</strong> @Model.Account.Language</p>
<p class="text-gray-700 dark:text-gray-300 mb-2">
<strong>Activated:</strong> @Model.Account.ActivatedAt?.ToString("yyyy-MM-dd HH:mm", System.Globalization.CultureInfo.InvariantCulture)
</p>
<p class="text-gray-700 dark:text-gray-300 mb-2"><strong>Superuser:</strong> @Model.Account.IsSuperuser
</p>
</div>
<div class="mb-6">
<h2 class="text-2xl font-semibold text-gray-800 dark:text-gray-200 mb-4">Profile Details</h2>
<p class="text-gray-700 dark:text-gray-300 mb-2">
<strong>Name:</strong> @Model.Account.Profile.FirstName @Model.Account.Profile.MiddleName @Model.Account.Profile.LastName
</p>
<p class="text-gray-700 dark:text-gray-300 mb-2"><strong>Bio:</strong> @Model.Account.Profile.Bio</p>
<p class="text-gray-700 dark:text-gray-300 mb-2"><strong>Gender:</strong> @Model.Account.Profile.Gender
</p>
<p class="text-gray-700 dark:text-gray-300 mb-2">
<strong>Location:</strong> @Model.Account.Profile.Location</p>
<p class="text-gray-700 dark:text-gray-300 mb-2">
<strong>Birthday:</strong> @Model.Account.Profile.Birthday?.ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture)
</p>
<p class="text-gray-700 dark:text-gray-300 mb-2">
<strong>Experience:</strong> @Model.Account.Profile.Experience</p>
<p class="text-gray-700 dark:text-gray-300 mb-2"><strong>Level:</strong> @Model.Account.Profile.Level
</p>
</div>
<div class="mb-6">
<h2 class="text-2xl font-semibold text-gray-800 dark:text-gray-200 mb-4">Access Token</h2>
<div class="flex items-center">
<input type="text" id="accessToken" value="@Model.AccessToken" readonly
class="form-input flex-grow rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-500 focus:ring-opacity-50 dark:bg-gray-700 dark:border-gray-600 dark:text-white"/>
<button onclick="copyAccessToken()"
class="ml-4 bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50">
Copy
</button>
</div>
</div>
<form method="post" asp-page-handler="Logout" class="text-center">
<button type="submit"
class="bg-red-600 text-white py-2 px-4 rounded-md hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-opacity-50">
Logout
</button>
</form>
}
else
{
<p class="text-red-500 text-center">User profile not found. Please log in.</p>
}
</div>
</div>
<script>
function copyAccessToken() {
var copyText = document.getElementById("accessToken");
copyText.select();
copyText.setSelectionRange(0, 99999); /* For mobile devices */
document.execCommand("copy");
alert("Access Token copied to clipboard!");
}
</script>

View File

@ -0,0 +1,28 @@
using DysonNetwork.Sphere.Auth;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace DysonNetwork.Sphere.Pages.Account;
public class ProfileModel : PageModel
{
public DysonNetwork.Sphere.Account.Account? Account { get; set; }
public string? AccessToken { get; set; }
public Task<IActionResult> OnGetAsync()
{
if (HttpContext.Items["CurrentUser"] is not Sphere.Account.Account currentUser)
return Task.FromResult<IActionResult>(RedirectToPage("/Auth/Login"));
Account = currentUser;
AccessToken = Request.Cookies.TryGetValue(AuthConstants.CookieTokenName, out var value) ? value : null;
return Task.FromResult<IActionResult>(Page());
}
public IActionResult OnPostLogout()
{
HttpContext.Response.Cookies.Delete(AuthConstants.CookieTokenName);
return RedirectToPage("/Auth/Login");
}
}

View File

@ -0,0 +1,54 @@
@page "/web/auth/challenge/{id:guid}"
@model DysonNetwork.Sphere.Pages.Auth.ChallengeModel
@{
ViewData["Title"] = "Challenge";
}
<div class="min-h-screen flex items-center justify-center bg-gray-100 dark:bg-gray-900">
<div class="bg-white dark:bg-gray-800 p-8 rounded-lg shadow-md w-full max-w-md">
<h1 class="text-2xl font-bold text-center text-gray-900 dark:text-white mb-6">Authentication Challenge</h1>
@if (Model.AuthChallenge == null)
{
<p class="text-red-500 text-center">Challenge not found or expired.</p>
}
else
{
<p class="text-gray-700 dark:text-gray-300 mb-4">Remaining steps: @Model.AuthChallenge.StepRemain</p>
@if (Model.AuthChallenge.StepRemain > 0)
{
<form method="post">
<input type="hidden" asp-for="Id"/>
<div class="mb-4">
<label asp-for="SelectedFactorId"
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Select
Factor:</label>
<select asp-for="SelectedFactorId" asp-items="Model.AuthFactors"
class="form-select mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-500 focus:ring-opacity-50 dark:bg-gray-700 dark:border-gray-600 dark:text-white px-4 py-2"></select>
</div>
<div class="mb-4">
<label asp-for="Secret"
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"></label>
<input asp-for="Secret"
class="form-input mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-500 focus:ring-opacity-50 dark:bg-gray-700 dark:border-gray-600 dark:text-white px-4 py-2"
type="password"/>
<span asp-validation-for="Secret" class="text-red-500 text-sm mt-1"></span>
</div>
<button type="submit"
class="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50">
Submit
</button>
</form>
}
else
{
<p class="text-green-600 dark:text-green-400 text-center">Challenge completed. Redirecting...</p>
}
}
</div>
</div>
@section Scripts {
@{ await Html.RenderPartialAsync("_ValidationScriptsPartial"); }
}

View File

@ -0,0 +1,185 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.AspNetCore.Mvc.Rendering;
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Sphere.Auth;
using DysonNetwork.Sphere.Account;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Sphere.Pages.Auth
{
public class ChallengeModel(
AppDatabase db,
AccountService accounts,
AuthService auth,
ActionLogService als,
IConfiguration configuration
)
: PageModel
{
[BindProperty(SupportsGet = true)] public Guid Id { get; set; }
public Challenge? AuthChallenge { get; set; }
[BindProperty] public Guid SelectedFactorId { get; set; }
[BindProperty] [Required] public string Secret { get; set; } = string.Empty;
public List<SelectListItem> AuthFactors { get; set; } = new();
public async Task<IActionResult> OnGetAsync()
{
await LoadChallengeAndFactors();
if (AuthChallenge == null) return NotFound();
if (AuthChallenge.StepRemain == 0) return await ExchangeTokenAndRedirect();
return Page();
}
public async Task<IActionResult> OnPostAsync()
{
if (!ModelState.IsValid)
{
await LoadChallengeAndFactors();
return Page();
}
var challenge = await db.AuthChallenges.Include(e => e.Account).FirstOrDefaultAsync(e => e.Id == Id);
if (challenge is null) return NotFound("Auth challenge was not found.");
var factor = await db.AccountAuthFactors.FindAsync(SelectedFactorId);
if (factor is null) return NotFound("Auth factor was not found.");
if (factor.EnabledAt is null) return BadRequest("Auth factor is not enabled.");
if (factor.Trustworthy <= 0) return BadRequest("Auth factor is not trustworthy.");
if (challenge.StepRemain == 0) return Page(); // Challenge already completed
if (challenge.ExpiredAt.HasValue && challenge.ExpiredAt.Value < Instant.FromDateTimeUtc(DateTime.UtcNow))
{
ModelState.AddModelError(string.Empty, "Challenge expired.");
await LoadChallengeAndFactors();
return Page();
}
try
{
if (await accounts.VerifyFactorCode(factor, Secret))
{
challenge.StepRemain -= factor.Trustworthy;
challenge.StepRemain = Math.Max(0, challenge.StepRemain);
challenge.BlacklistFactors.Add(factor.Id);
db.Update(challenge);
als.CreateActionLogFromRequest(ActionLogType.ChallengeSuccess,
new Dictionary<string, object>
{
{ "challenge_id", challenge.Id },
{ "factor_id", factor.Id }
}, Request, challenge.Account
);
}
else
{
throw new ArgumentException("Invalid password.");
}
}
catch (Exception ex)
{
challenge.FailedAttempts++;
db.Update(challenge);
await db.SaveChangesAsync();
als.CreateActionLogFromRequest(ActionLogType.ChallengeFailure,
new Dictionary<string, object>
{
{ "challenge_id", challenge.Id },
{ "factor_id", factor.Id }
}, Request, challenge.Account
);
ModelState.AddModelError(string.Empty, ex.Message);
await LoadChallengeAndFactors();
return Page();
}
if (challenge.StepRemain == 0)
{
als.CreateActionLogFromRequest(ActionLogType.NewLogin,
new Dictionary<string, object>
{
{ "challenge_id", challenge.Id },
{ "account_id", challenge.AccountId }
}, Request, challenge.Account
);
}
await db.SaveChangesAsync();
AuthChallenge = challenge;
if (AuthChallenge.StepRemain == 0)
{
return await ExchangeTokenAndRedirect();
}
await LoadChallengeAndFactors();
return Page();
}
private async Task LoadChallengeAndFactors()
{
var challenge = await db.AuthChallenges
.Include(e => e.Account)
.ThenInclude(e => e.AuthFactors)
.FirstOrDefaultAsync(e => e.Id == Id);
AuthChallenge = challenge;
if (AuthChallenge != null)
{
var factorsResponse = AuthChallenge.Account.AuthFactors
.Where(e => e is { EnabledAt: not null, Trustworthy: >= 1 })
.ToList();
AuthFactors = factorsResponse.Select(f => new SelectListItem
{
Value = f.Id.ToString(),
Text = f.Type.ToString() // You might want a more user-friendly display for factor types
}).ToList();
}
}
private async Task<IActionResult> ExchangeTokenAndRedirect()
{
var challenge = await db.AuthChallenges
.Include(e => e.Account)
.Where(e => e.Id == Id)
.FirstOrDefaultAsync();
if (challenge is null) return BadRequest("Authorization code not found or expired.");
if (challenge.StepRemain != 0) return BadRequest("Challenge not yet completed.");
var session = await db.AuthSessions
.Where(e => e.Challenge == challenge)
.FirstOrDefaultAsync();
if (session is not null) return BadRequest("Session already exists for this challenge.");
session = new Session
{
LastGrantedAt = Instant.FromDateTimeUtc(DateTime.UtcNow),
ExpiredAt = Instant.FromDateTimeUtc(DateTime.UtcNow.AddDays(30)),
Account = challenge.Account,
Challenge = challenge,
};
db.AuthSessions.Add(session);
await db.SaveChangesAsync();
var tk = auth.CreateToken(session);
HttpContext.Response.Cookies.Append(AuthConstants.CookieTokenName, tk, new CookieOptions()
{
HttpOnly = true,
Secure = !configuration.GetValue<bool>("Debug"),
SameSite = SameSiteMode.Strict,
Path = "/"
});
return RedirectToPage("/Account/Profile"); // Redirect to profile page
}
}
}

View File

@ -0,0 +1,29 @@
@page "/web/auth/login"
@model DysonNetwork.Sphere.Pages.Auth.LoginModel
@{
ViewData["Title"] = "Login";
}
<div class="min-h-screen flex items-center justify-center bg-gray-100 dark:bg-gray-900">
<div class="bg-white dark:bg-gray-800 p-8 rounded-lg shadow-md w-full max-w-md">
<h1 class="text-2xl font-bold text-center text-gray-900 dark:text-white mb-6">Login</h1>
<form method="post">
<div class="mb-4">
<label asp-for="Username"
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"></label>
<input asp-for="Username"
class="form-input mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-500 focus:ring-opacity-50 dark:bg-gray-700 dark:border-gray-600 dark:text-white px-4 py-2"/>
<span asp-validation-for="Username" class="text-red-500 text-sm mt-1"></span>
</div>
<button type="submit"
class="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50">
Next
</button>
</form>
</div>
</div>
@section Scripts {
@{ await Html.RenderPartialAsync("_ValidationScriptsPartial"); }
}

View File

@ -0,0 +1,81 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Sphere.Auth;
using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Connection;
using NodaTime;
using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Sphere.Pages.Auth
{
public class LoginModel(
AppDatabase db,
AccountService accounts,
AuthService auth,
GeoIpService geo,
ActionLogService als
) : PageModel
{
[BindProperty] [Required] public string Username { get; set; } = string.Empty;
public void OnGet()
{
}
public async Task<IActionResult> OnPostAsync()
{
if (!ModelState.IsValid)
{
return Page();
}
var account = await accounts.LookupAccount(Username);
if (account is null)
{
ModelState.AddModelError(string.Empty, "Account was not found.");
return Page();
}
var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString();
var userAgent = HttpContext.Request.Headers.UserAgent.ToString();
var now = Instant.FromDateTimeUtc(DateTime.UtcNow);
var existingChallenge = await db.AuthChallenges
.Where(e => e.Account == account)
.Where(e => e.IpAddress == ipAddress)
.Where(e => e.UserAgent == userAgent)
.Where(e => e.StepRemain > 0)
.Where(e => e.ExpiredAt != null && now < e.ExpiredAt)
.FirstOrDefaultAsync();
if (existingChallenge is not null)
{
return RedirectToPage("Challenge", new { id = existingChallenge.Id });
}
var challenge = new Challenge
{
ExpiredAt = Instant.FromDateTimeUtc(DateTime.UtcNow.AddHours(1)),
StepTotal = await auth.DetectChallengeRisk(Request, account),
Platform = ChallengePlatform.Web,
Audiences = new List<string>(),
Scopes = new List<string>(),
IpAddress = ipAddress,
UserAgent = userAgent,
Location = geo.GetPointFromIp(ipAddress),
DeviceId = "web-browser",
AccountId = account.Id
}.Normalize();
await db.AuthChallenges.AddAsync(challenge);
await db.SaveChangesAsync();
als.CreateActionLogFromRequest(ActionLogType.ChallengeAttempt,
new Dictionary<string, object> { { "challenge_id", challenge.Id } }, Request, account
);
return RedirectToPage("Challenge", new { id = challenge.Id });
}
}
}

View File

@ -1,3 +1,4 @@
@using DysonNetwork.Sphere.Auth
<!DOCTYPE html>
<html lang="en" class="h-full">
<head>
@ -13,6 +14,19 @@
<div class="flex">
<a href="/" class="text-xl font-bold text-gray-900 dark:text-white">Solar Network</a>
</div>
<div class="flex items-center ml-auto">
@if (Context.Request.Cookies.TryGetValue(AuthConstants.CookieTokenName, out _))
{
<a href="/Account/Profile" class="text-gray-900 dark:text-white hover:text-gray-700 dark:hover:text-gray-300 px-3 py-2 rounded-md text-sm font-medium">Profile</a>
<form method="post" asp-page="/Account/Profile" asp-page-handler="Logout" class="inline">
<button type="submit" class="text-gray-900 dark:text-white hover:text-gray-700 dark:hover:text-gray-300 px-3 py-2 rounded-md text-sm font-medium">Logout</button>
</form>
}
else
{
<a href="/web/auth/login" class="text-gray-900 dark:text-white hover:text-gray-700 dark:hover:text-gray-300 px-3 py-2 rounded-md text-sm font-medium">Login</a>
}
</div>
</div>
</nav>
</header>

View File

@ -0,0 +1,3 @@
<script src="~/lib/jquery/dist/jquery.min.js"></script>
<script src="~/lib/jquery-validation/dist/jquery.validate.min.js"></script>
<script src="~/lib/jquery-validation-unobtrusive/jquery.validate.unobtrusive.min.js"></script>

View File

@ -194,7 +194,8 @@ public static class ServiceCollectionExtensions
return services;
}
public static IServiceCollection AddAppBusinessServices(this IServiceCollection services, IConfiguration configuration)
public static IServiceCollection AddAppBusinessServices(this IServiceCollection services,
IConfiguration configuration)
{
services.AddScoped<CompactTokenService>();
services.AddScoped<RazorViewRenderer>();

View File

@ -51,7 +51,7 @@ public class AfdianPaymentHandler(
var sign = CalculateSign(token, userId, paramsJson, ts);
var client = _httpClientFactory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Post, "https://afdian.com/api/open/query-order")
var request = new HttpRequestMessage(HttpMethod.Post, "https://afdian.com/open/query-order")
{
Content = new StringContent(JsonSerializer.Serialize(new
{
@ -107,7 +107,7 @@ public class AfdianPaymentHandler(
var sign = CalculateSign(token, userId, paramsJson, ts);
var client = _httpClientFactory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Post, "https://afdian.com/api/open/query-order")
var request = new HttpRequestMessage(HttpMethod.Post, "https://afdian.com/open/query-order")
{
Content = new StringContent(JsonSerializer.Serialize(new
{
@ -176,7 +176,7 @@ public class AfdianPaymentHandler(
var sign = CalculateSign(token, userId, paramsJson, ts);
var client = _httpClientFactory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Post, "https://afdian.com/api/open/query-order")
var request = new HttpRequestMessage(HttpMethod.Post, "https://afdian.com/open/query-order")
{
Content = new StringContent(JsonSerializer.Serialize(new
{

View File

@ -1,4 +1,4 @@
/*! tailwindcss v4.1.10 | MIT License | https://tailwindcss.com */
/*! tailwindcss v4.1.11 | MIT License | https://tailwindcss.com */
@layer properties;
@layer theme, base, components, utilities;
@layer theme {
@ -7,12 +7,17 @@
'Noto Color Emoji';
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
monospace;
--color-red-500: oklch(63.7% 0.237 25.331);
--color-red-600: oklch(57.7% 0.245 27.325);
--color-red-700: oklch(50.5% 0.213 27.518);
--color-yellow-100: oklch(97.3% 0.071 103.193);
--color-yellow-200: oklch(94.5% 0.129 101.54);
--color-yellow-800: oklch(47.6% 0.114 61.907);
--color-yellow-900: oklch(42.1% 0.095 57.708);
--color-green-100: oklch(96.2% 0.044 156.743);
--color-green-200: oklch(92.5% 0.084 155.995);
--color-green-400: oklch(79.2% 0.209 151.711);
--color-green-600: oklch(62.7% 0.194 149.214);
--color-green-800: oklch(44.8% 0.119 151.328);
--color-green-900: oklch(39.3% 0.095 152.535);
--color-blue-400: oklch(70.7% 0.165 254.624);
@ -20,6 +25,7 @@
--color-blue-600: oklch(54.6% 0.245 262.881);
--color-blue-700: oklch(48.8% 0.243 264.376);
--color-gray-100: oklch(96.7% 0.003 264.542);
--color-gray-200: oklch(92.8% 0.006 264.531);
--color-gray-300: oklch(87.2% 0.01 258.338);
--color-gray-400: oklch(70.7% 0.022 261.325);
--color-gray-500: oklch(55.1% 0.027 264.364);
@ -29,6 +35,7 @@
--color-gray-900: oklch(21% 0.034 264.665);
--color-white: #fff;
--spacing: 0.25rem;
--container-md: 28rem;
--container-lg: 32rem;
--container-2xl: 42rem;
--container-7xl: 80rem;
@ -50,6 +57,7 @@
--font-weight-semibold: 600;
--font-weight-bold: 700;
--tracking-tight: -0.025em;
--radius-md: 0.375rem;
--radius-lg: 0.5rem;
--default-transition-duration: 150ms;
--default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
@ -206,6 +214,9 @@
.invisible {
visibility: hidden;
}
.absolute {
position: absolute;
}
.fixed {
position: fixed;
}
@ -248,6 +259,9 @@
.mx-auto {
margin-inline: auto;
}
.mt-1 {
margin-top: calc(var(--spacing) * 1);
}
.mt-4 {
margin-top: calc(var(--spacing) * 4);
}
@ -278,6 +292,12 @@
.mb-8 {
margin-bottom: calc(var(--spacing) * 8);
}
.ml-4 {
margin-left: calc(var(--spacing) * 4);
}
.ml-auto {
margin-left: auto;
}
.block {
display: block;
}
@ -290,6 +310,9 @@
.hidden {
display: none;
}
.inline {
display: inline;
}
.table {
display: table;
}
@ -302,6 +325,9 @@
.h-full {
height: 100%;
}
.min-h-screen {
min-height: 100vh;
}
.w-full {
width: 100%;
}
@ -311,6 +337,12 @@
.max-w-lg {
max-width: var(--container-lg);
}
.max-w-md {
max-width: var(--container-md);
}
.flex-grow {
flex-grow: 1;
}
.border-collapse {
border-collapse: collapse;
}
@ -332,9 +364,17 @@
.gap-x-6 {
column-gap: calc(var(--spacing) * 6);
}
.truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rounded-lg {
border-radius: var(--radius-lg);
}
.rounded-md {
border-radius: var(--radius-md);
}
.border {
border-style: var(--tw-border-style);
border-width: 1px;
@ -349,12 +389,18 @@
.bg-blue-500 {
background-color: var(--color-blue-500);
}
.bg-blue-600 {
background-color: var(--color-blue-600);
}
.bg-gray-100 {
background-color: var(--color-gray-100);
}
.bg-green-100 {
background-color: var(--color-green-100);
}
.bg-red-600 {
background-color: var(--color-red-600);
}
.bg-white {
background-color: var(--color-white);
}
@ -367,12 +413,21 @@
.p-6 {
padding: calc(var(--spacing) * 6);
}
.p-8 {
padding: calc(var(--spacing) * 8);
}
.px-3 {
padding-inline: calc(var(--spacing) * 3);
}
.px-4 {
padding-inline: calc(var(--spacing) * 4);
}
.px-6 {
padding-inline: calc(var(--spacing) * 6);
}
.py-2 {
padding-block: calc(var(--spacing) * 2);
}
.py-3 {
padding-block: calc(var(--spacing) * 3);
}
@ -435,12 +490,21 @@
.text-gray-700 {
color: var(--color-gray-700);
}
.text-gray-800 {
color: var(--color-gray-800);
}
.text-gray-900 {
color: var(--color-gray-900);
}
.text-green-600 {
color: var(--color-green-600);
}
.text-green-800 {
color: var(--color-green-800);
}
.text-red-500 {
color: var(--color-red-500);
}
.text-white {
color: var(--color-white);
}
@ -464,6 +528,10 @@
--tw-shadow: 0 10px 15px -3px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 4px 6px -4px var(--tw-shadow-color, rgb(0 0 0 / 0.1));
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
}
.shadow-md {
--tw-shadow: 0 4px 6px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 2px 4px -2px var(--tw-shadow-color, rgb(0 0 0 / 0.1));
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
}
.shadow-sm {
--tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
@ -497,6 +565,20 @@
}
}
}
.hover\:bg-blue-700 {
&:hover {
@media (hover: hover) {
background-color: var(--color-blue-700);
}
}
}
.hover\:bg-red-700 {
&:hover {
@media (hover: hover) {
background-color: var(--color-red-700);
}
}
}
.hover\:text-blue-600 {
&:hover {
@media (hover: hover) {
@ -504,6 +586,24 @@
}
}
}
.hover\:text-gray-700 {
&:hover {
@media (hover: hover) {
color: var(--color-gray-700);
}
}
}
.focus\:border-blue-500 {
&:focus {
border-color: var(--color-blue-500);
}
}
.focus\:ring {
&:focus {
--tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
}
}
.focus\:ring-2 {
&:focus {
--tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);
@ -515,6 +615,16 @@
--tw-ring-color: var(--color-blue-400);
}
}
.focus\:ring-blue-500 {
&:focus {
--tw-ring-color: var(--color-blue-500);
}
}
.focus\:ring-red-500 {
&:focus {
--tw-ring-color: var(--color-red-500);
}
}
.focus\:outline-none {
&:focus {
--tw-outline-style: none;
@ -532,6 +642,11 @@
border-color: var(--color-gray-600);
}
}
.dark\:bg-gray-700 {
@media (prefers-color-scheme: dark) {
background-color: var(--color-gray-700);
}
}
.dark\:bg-gray-800 {
@media (prefers-color-scheme: dark) {
background-color: var(--color-gray-800);
@ -552,6 +667,11 @@
background-color: var(--color-yellow-900);
}
}
.dark\:text-gray-200 {
@media (prefers-color-scheme: dark) {
color: var(--color-gray-200);
}
}
.dark\:text-gray-300 {
@media (prefers-color-scheme: dark) {
color: var(--color-gray-300);
@ -567,6 +687,11 @@
color: var(--color-green-200);
}
}
.dark\:text-green-400 {
@media (prefers-color-scheme: dark) {
color: var(--color-green-400);
}
}
.dark\:text-white {
@media (prefers-color-scheme: dark) {
color: var(--color-white);
@ -586,6 +711,15 @@
}
}
}
.dark\:hover\:text-gray-300 {
@media (prefers-color-scheme: dark) {
&:hover {
@media (hover: hover) {
color: var(--color-gray-300);
}
}
}
}
}
@layer theme, base, components, utilities;
@layer theme;

View File

@ -10,7 +10,9 @@
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ABucketArgs_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fd515fb889657fcdcace3fed90735057b458ff9e0bb60bded7c8fe8b3a4673c_003FBucketArgs_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AChapterData_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Ffef366b36a224d469ff150d30f9a866d23c00_003Fe6_003F64a6c0f7_003FChapterData_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AClaim_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fa7fdc52b6e574ae7b9822133be91162a15800_003Ff7_003Feebffd8d_003FClaim_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AConnectionMultiplexer_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FSourcesCache_003F2ed0e2f073b1d77b98dadb822da09ee8a9dfb91bf29bf2bbaecb8750d7e74cc9_003FConnectionMultiplexer_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AControllerBase_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F0b5acdd962e549369896cece0026e556214600_003Ff6_003Fdf150bb3_003FControllerBase_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ACookieOptions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F663f33943e4c4e889dc7050c1e97e703e000_003F89_003Fb06980d7_003FCookieOptions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ACorsPolicyBuilder_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F051ad509d0504b7ca10dedd9c2cabb9914200_003F8e_003Fb28257cb_003FCorsPolicyBuilder_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ADailyTimeIntervalScheduleBuilder_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F929ef51651404d13aacd3eb8198d2961e4800_003F2b_003Ff86eadcb_003FDailyTimeIntervalScheduleBuilder_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ADailyTimeIntervalTriggerBuilderExtensions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F929ef51651404d13aacd3eb8198d2961e4800_003F5c_003F297b8312_003FDailyTimeIntervalTriggerBuilderExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>