This commit is contained in:
2026-03-30 11:07:30 +08:00
parent 2c44b3a4b2
commit d4a8e71733
74 changed files with 1751 additions and 421 deletions

2
.gitignore vendored
View File

@@ -482,6 +482,6 @@ $RECYCLE.BIN/
*.swp *.swp
doc/password.txt doc/password.txt
StopShopping.Api/wwwroot/images/ wwwroot/
appsettings.json appsettings.json
appsettings.Development.json appsettings.Development.json

View File

@@ -6,4 +6,11 @@
<AnalysisLevel>latest</AnalysisLevel> <AnalysisLevel>latest</AnalysisLevel>
<AnalysisMode>Recommended</AnalysisMode> <AnalysisMode>Recommended</AnalysisMode>
</PropertyGroup> </PropertyGroup>
<ItemGroup>
<Content Update="appsettings.Template.json">
<CopyToPublishDirectory>Never</CopyToPublishDirectory>
</Content>
</ItemGroup>
</Project> </Project>

View File

@@ -0,0 +1,56 @@
using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using Microsoft.Net.Http.Headers;
using StopShopping.Services;
namespace StopShopping.AdminApi.Extensions;
public static class JwtExtensions
{
public static IServiceCollection AddAuthServices(this IServiceCollection services, IConfiguration jwtOptions)
{
services.Configure<JwtOptions>(jwtOptions);
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(jwtBearerOptions =>
{
var jwtConfiguration = jwtOptions.Get<JwtOptions>()!;
var signingKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(jwtConfiguration.SigningKey!)
);
jwtBearerOptions.MapInboundClaims = false;
jwtBearerOptions.TokenValidationParameters = new TokenValidationParameters
{
ValidAudience = jwtConfiguration.ValidAudience,
ValidIssuer = jwtConfiguration.ValidIssuer,
IssuerSigningKey = signingKey,
ClockSkew = TimeSpan.FromSeconds(30) //宽容时间30秒后才失效
};
jwtBearerOptions.Events = new JwtBearerEvents
{
OnMessageReceived = async (context) =>
{
var accessTokenService = context.HttpContext.RequestServices.GetRequiredService<IAccessTokenService>();
var authorizationHeader = context.Request.Headers[HeaderNames.Authorization];
if (authorizationHeader.Count == 0)
{
context.Fail($"未找到{HeaderNames.Authorization}请求头");
}
else
{
var token = authorizationHeader.First()!.Split(" ").Last();
if (string.IsNullOrWhiteSpace(token))
context.Fail("未找到token");
if (await accessTokenService.IsAccessTokenBlacklistAsync(token))
context.Fail("token已失效");
}
}
};
});
services.AddAuthorization();
return services;
}
}

View File

@@ -0,0 +1,37 @@
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.OpenApi;
using Microsoft.OpenApi;
namespace StopShopping.AdminApi.Extensions;
// 来自网络。。。
public class BearerOpenApiDocumentTransformer : IOpenApiDocumentTransformer
{
public Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerContext context, CancellationToken cancellationToken)
{
var bearerOpenApiSecurityScheme = new OpenApiSecurityScheme
{
In = ParameterLocation.Header,
Scheme = JwtBearerDefaults.AuthenticationScheme,
Type = SecuritySchemeType.Http,
Description = "jwt"
};
document.Components ??= new OpenApiComponents();
document.Components.SecuritySchemes ??= new Dictionary<string, IOpenApiSecurityScheme>();
document.Components.SecuritySchemes[JwtBearerDefaults.AuthenticationScheme] = bearerOpenApiSecurityScheme;
var securityRequirement = new OpenApiSecurityRequirement
{
{
new OpenApiSecuritySchemeReference(JwtBearerDefaults.AuthenticationScheme),
new List<string>()
}
};
document.Security ??= [];
document.Security.Add(securityRequirement);
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,76 @@
using System.Text.Json.Serialization;
using StopShopping.AdminApi.Middlewares;
using StopShopping.Services;
using StopShopping.Services.Extensions;
namespace StopShopping.AdminApi.Extensions;
public static class CommonServiceCollections
{
public static IServiceCollection AddCommonServices(
this IServiceCollection services,
string corsPolicy,
IConfigurationSection jwtConfiguration,
IConfigurationSection appConfiguration,
bool isDevelopment)
{
var appOptions = appConfiguration.Get<AppOptions>();
services.AddCors(options =>
{
options.AddPolicy(corsPolicy, policy =>
{
policy.AllowAnyHeader();
policy.AllowAnyMethod();
policy.WithOrigins(appOptions!.CorsAllowedOrigins);
policy.AllowCredentials();
});
});
services.ConfigureHttpJsonOptions(options =>
{
options.SerializerOptions.Converters.Add(
new JsonStringEnumConverter(namingPolicy: null, allowIntegerValues: true));
});
services.AddHttpContextAccessor();
services.AddOpenApi(options =>
{
options.AddDocumentTransformer<BearerOpenApiDocumentTransformer>();
options.AddSchemaTransformer<EnumOpenApiSchemaTransformer>();
});
services.AddProblemDetails(options =>
{
options.CustomizeProblemDetails = (context) =>
{
if (context.ProblemDetails is HttpValidationProblemDetails problemDetails)
{
problemDetails.AddErrorCode(ProblemDetailsCodes.ParametersValidationFailed);
var errors = problemDetails.Errors.Select(e => string.Join(',', e.Value));
if (null != errors)
problemDetails.Detail = string.Join(',', errors);
}
};
});
services.AddValidation();
services.AddDistributedMemoryCache();
services.AddAuthServices(jwtConfiguration);
services.AddAntiforgery(options =>
{
var jwtOptions = jwtConfiguration.Get<JwtOptions>();
options.HeaderName = appOptions!.CSRFHeaderName;
options.Cookie.MaxAge = TimeSpan.FromSeconds(jwtOptions!.RefreshTokenExpiresIn);
options.Cookie.HttpOnly = true;
options.Cookie.Name = appOptions.CSRFCookieName;
options.Cookie.SameSite = SameSiteMode.Lax;
options.Cookie.Domain = appOptions.CookieDomain;
if (!isDevelopment)
{
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
}
});
return services;
}
}

View File

@@ -0,0 +1,49 @@
using System.ComponentModel;
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.OpenApi;
using Microsoft.OpenApi;
namespace StopShopping.AdminApi.Extensions;
/// <summary>
/// 处理enum类型openapi显示
/// </summary>
public class EnumOpenApiSchemaTransformer : IOpenApiSchemaTransformer
{
public Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext context, CancellationToken cancellationToken)
{
if (context.JsonTypeInfo.Type.IsEnum)
{
schema.Type = JsonSchemaType.Integer;
var enumValues = Enum.GetValues(context.JsonTypeInfo.Type)
.Cast<object>()
.Select(v => JsonNode.Parse(Convert.ToInt32(v).ToString())!)
.ToList();
schema.Enum = enumValues;
var enumNames = Enum.GetNames(context.JsonTypeInfo.Type);
schema.Extensions ??= new Dictionary<string, IOpenApiExtension>();
var namesExtension = new JsonNodeExtension(new JsonArray(
enumNames
.Select(n => (JsonNode)n)
.ToArray()));
schema.Extensions.Add("x-enumNames", namesExtension);
var descMap = new JsonObject();
foreach (var name in enumNames)
{
if (context.JsonTypeInfo.Type.GetField(name)
?.GetCustomAttributes(typeof(DescriptionAttribute), false)
.FirstOrDefault() is DescriptionAttribute attr)
{
descMap[name] = attr.Description;
}
}
schema.Extensions.Add("x-enumDescriptions", new JsonNodeExtension(descMap));
}
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,35 @@
using StopShopping.Services.Extensions;
namespace Microsoft.AspNetCore.Http;
public static class HttpExtensions
{
public const string REFRESH_TOKEN_COOKIE_KEY = "admin_refresh_token";
public static IResponseCookies AppendRefreshToken(
this IResponseCookies cookies,
IWebHostEnvironment env,
AppOptions appOptions,
TimeSpan maxAge,
string token)
{
CookieOptions options = new()
{
MaxAge = maxAge,
HttpOnly = true,
SameSite = SameSiteMode.Lax,
Domain = appOptions.CookieDomain,
};
if (!env.IsDevelopment())
options.Secure = true;
cookies.Append(
REFRESH_TOKEN_COOKIE_KEY,
token,
options);
return cookies;
}
}

View File

@@ -0,0 +1,13 @@
using StopShopping.AdminApi.Middlewares;
namespace Microsoft.AspNetCore.Builder;
public static class MiddlewareExtensions
{
public static IApplicationBuilder UseGlobalExceptionHandler(this IApplicationBuilder applicationBuilder)
{
applicationBuilder.UseMiddleware<GlobalExceptionHandlerMiddleware>();
return applicationBuilder;
}
}

View File

@@ -0,0 +1,106 @@
using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.Mvc;
using StopShopping.Services.Models.Resp;
namespace StopShopping.AdminApi.Middlewares;
public class GlobalExceptionHandlerMiddleware
{
public GlobalExceptionHandlerMiddleware(RequestDelegate requestDelegate,
ILogger<GlobalExceptionHandlerMiddleware> logger,
IProblemDetailsService problemDetailsService)
{
_next = requestDelegate;
_logger = logger;
_problemDetailsService = problemDetailsService;
}
private readonly RequestDelegate _next;
private readonly ILogger<GlobalExceptionHandlerMiddleware> _logger;
private readonly IProblemDetailsService _problemDetailsService;
public async Task InvokeAsync(HttpContext httpContext)
{
try
{
await _next(httpContext);
httpContext.Response.OnStarting(async () =>
{
var antiforgeryFeature = httpContext.Features.Get<IAntiforgeryValidationFeature>();
if (null != antiforgeryFeature && !antiforgeryFeature.IsValid)
{
var problemDetails = new ProblemDetails
{
Detail = antiforgeryFeature.Error?.Message,
Instance = httpContext.Request.Path,
Status = StatusCodes.Status400BadRequest,
Title = "CSRF 错误",
};
problemDetails.AddErrorCode(ProblemDetailsCodes.CsrfValidationFailed);
httpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
httpContext.Response.ContentType = "application/problem+json";
await _problemDetailsService.WriteAsync(new ProblemDetailsContext
{
HttpContext = httpContext,
ProblemDetails = problemDetails
});
}
await Task.CompletedTask;
});
}
catch (BadHttpRequestException ex)
{
_logger.LogError(ex, "参数错误");
var problemDetails = new ProblemDetails
{
Detail = ex.Message,
Instance = httpContext.Request.Path,
Status = StatusCodes.Status400BadRequest,
Title = "参数错误",
};
problemDetails.AddErrorCode(ProblemDetailsCodes.BadParameters);
httpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
httpContext.Response.ContentType = "application/problem+json";
await _problemDetailsService.WriteAsync(new ProblemDetailsContext
{
HttpContext = httpContext,
ProblemDetails = problemDetails
});
}
catch (ServiceException ex)
{
await httpContext.Response.WriteAsJsonAsync(ApiResponse.Failed(ex.Message));
}
catch (Exception ex)
{
_logger.LogCritical(ex, "意外的异常");
var problemDetails = new ProblemDetails
{
Detail = ex.Message,
Instance = httpContext.Request.Path,
Status = StatusCodes.Status500InternalServerError,
Title = "服务器错误",
};
problemDetails.AddErrorCode(ProblemDetailsCodes.ServerError);
httpContext.Response.StatusCode = StatusCodes.Status500InternalServerError;
httpContext.Response.ContentType = "application/problem+json";
await _problemDetailsService.WriteAsync(new ProblemDetailsContext
{
HttpContext = httpContext,
ProblemDetails = problemDetails,
});
}
}
}

View File

@@ -0,0 +1,23 @@
using Microsoft.AspNetCore.Mvc;
namespace StopShopping.AdminApi.Middlewares;
public static class ProblemDetailsExtensions
{
private const string CODE_FIELD = "code";
public static ProblemDetails AddErrorCode(this ProblemDetails problemDetails, ProblemDetailsCodes code)
{
problemDetails.Extensions ??= new Dictionary<string, object?>();
problemDetails.Extensions.Add(CODE_FIELD, (int)code);
return problemDetails;
}
}
public enum ProblemDetailsCodes
{
CsrfValidationFailed = 1000,
ParametersValidationFailed = 1001,
BadParameters = 1002,
ServerError = 1003,
}

View File

@@ -0,0 +1,98 @@
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.HostFiltering;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Scalar.AspNetCore;
using Serilog;
using StopShopping.AdminApi.Extensions;
using StopShopping.AdminApi.Routes;
using StopShopping.AdminApi.Workers;
const string CORS_POLICY = "default";
// 将启动日志写入控制台用于捕获启动时异常启动后WriteTo被后续配置替代
Log.Logger = new LoggerConfiguration()
.WriteTo.Console()
.CreateBootstrapLogger();
try
{
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSerilog((services, seriLogger) =>
{
seriLogger.ReadFrom.Configuration(builder.Configuration)
.ReadFrom.Services(services);
});
var jwtConfiguration = builder.Configuration.GetSection("JwtOptions");
var appConfiguration = builder.Configuration.GetSection("AppOptions");
builder.Services.AddCommonServices(
CORS_POLICY,
jwtConfiguration,
appConfiguration,
builder.Environment.IsDevelopment());
builder.Services.AddServices(dbContextOptions =>
{
dbContextOptions.UseNpgsql(
builder.Configuration.GetConnectionString("StopShopping"));
if (builder.Environment.IsDevelopment())
dbContextOptions.EnableSensitiveDataLogging();
},
appConfiguration,
builder.Configuration.GetSection("OpenPlatformOptions"));
builder.Services.AddHostedService<DbSeederBackgroundService>();
/**********************************************************************/
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.MapScalarApiReference(options =>
{
options.AddPreferredSecuritySchemes(JwtBearerDefaults.AuthenticationScheme);
});
}
if (!app.Environment.IsDevelopment())
{
app.UseHostFiltering();
var forwardedHeadersOptions = new ForwardedHeadersOptions
{
ForwardedHeaders = ForwardedHeaders.All,
};
var hostFilteringOptions = app.Services.GetRequiredService<IOptions<HostFilteringOptions>>();
if (null != hostFilteringOptions)
forwardedHeadersOptions.AllowedHosts = hostFilteringOptions.Value.AllowedHosts;
app.UseForwardedHeaders(forwardedHeadersOptions);
}
app.UseGlobalExceptionHandler();
app.UseCors(CORS_POLICY);
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
Root.MapRoutes(app);
app.UseAntiforgery();
app.Run();
}
catch (Exception ex)
{
Log.Fatal(ex, "启动异常!");
}
finally
{
Log.CloseAndFlush();
}

View File

@@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5002",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:7054;http://localhost:5002",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,89 @@
using Microsoft.Extensions.Options;
using Microsoft.Net.Http.Headers;
using StopShopping.Services;
using StopShopping.Services.Extensions;
using StopShopping.Services.Models.Req;
using StopShopping.Services.Models.Resp;
namespace StopShopping.AdminApi.Routes;
public static class Admin
{
public static RouteGroupBuilder MapAdmin(this RouteGroupBuilder routes)
{
routes.MapPost("/admin/signin", SignInAsync)
.WithTags(OpenApiTags..ToString());
routes.MapPost("/admin/refreshtoken", RefreshTokenAsync)
.Produces<ApiResponse<AccessToken>>()
.WithTags(OpenApiTags..ToString());
routes.MapPost("/admin/signout", SignOutAsync)
.WithTags(OpenApiTags..ToString());
return routes;
}
private static async Task<ApiResponse<SignInAdmin>> SignInAsync(
SignInParams model,
IUserService userService,
HttpContext httpContext,
IWebHostEnvironment env,
IOptions<AppOptions> options)
{
var result = await userService.SignInAdminAsync(model);
var resp = new ApiResponse<SignInAdmin>
{
IsSucced = result.IsSucced,
Data = result.User,
Message = result.Message
};
if (result.IsSucced)
{
httpContext.Response.Cookies.AppendRefreshToken(
env,
options.Value,
TimeSpan.FromSeconds(result.RefreshToken!.ExpiresIn),
result.RefreshToken.Token!
);
}
return resp;
}
private static async Task<IResult> RefreshTokenAsync(
HttpContext httpContext,
IAccessTokenService accessTokenService)
{
var refreshToken = httpContext.Request.Cookies[HttpExtensions.REFRESH_TOKEN_COOKIE_KEY];
if (string.IsNullOrWhiteSpace(refreshToken))
return Results.Unauthorized();
var accessToken = await accessTokenService.GenerateAccessTokenAsync(refreshToken);
if (null == accessToken)
return Results.Unauthorized();
return Results.Ok(new ApiResponse<AccessToken>(accessToken));
}
public static async Task<ApiResponse> SignOutAsync(
HttpContext httpContext,
IAccessTokenService accessTokenService)
{
var accessTokenHeader = httpContext.Request.Headers[HeaderNames.Authorization];
if (accessTokenHeader.Count != 0)
{
var accessToken = accessTokenHeader.First()!.Split(" ").Last();
if (!string.IsNullOrWhiteSpace(accessToken))
await accessTokenService.AddAccessTokenBlacklistAsync(accessToken);
}
var refreshToken = httpContext.Request.Cookies[HttpExtensions.REFRESH_TOKEN_COOKIE_KEY];
if (!string.IsNullOrWhiteSpace(refreshToken))
{
await accessTokenService.RevokeRefreshTokenAsync(refreshToken);
httpContext.Response.Cookies.Delete(HttpExtensions.REFRESH_TOKEN_COOKIE_KEY);
}
return ApiResponse.Succed();
}
}

View File

@@ -0,0 +1,54 @@
using StopShopping.Services;
using StopShopping.Services.Models.Req;
using StopShopping.Services.Models.Resp;
namespace StopShopping.AdminApi.Routes;
public static class Category
{
public static RouteGroupBuilder MapCategory(this RouteGroupBuilder routes)
{
routes.MapGet("/category/list", GetTree)
.WithTags(OpenApiTags..ToString());
routes.MapPost("/category/edit", EditCategoryAsync)
.WithTags(OpenApiTags..ToString());
routes.MapPost("/category/resort", ResortCategoryAsync)
.WithTags(OpenApiTags..ToString());
routes.MapPost("/category/delete", DeleteCategoryAsync)
.WithTags(OpenApiTags..ToString());
return routes;
}
private static ApiResponse<List<Services.Models.Resp.Category>> GetTree(
ICategoryService categoryService
)
{
return categoryService.GetCategoriesTree();
}
private static async Task<ApiResponse<Services.Models.Resp.Category>> EditCategoryAsync(
EditCategoryParams model,
ICategoryService categoryService)
{
return await categoryService.EditCategoryAsync(model);
}
private static async Task<ApiResponse> ResortCategoryAsync(
ResortCategoryParams model,
ICategoryService categoryService)
{
return await categoryService.ResortCategoryAsync(model);
}
private static async Task<ApiResponse> DeleteCategoryAsync(
CategoryIdParams model,
ICategoryService categoryService
)
{
return await categoryService.DeleteCategoryAsync(model);
}
}

View File

@@ -0,0 +1,43 @@
using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.Mvc;
using StopShopping.Services;
using StopShopping.Services.Models.Req;
using StopShopping.Services.Models.Resp;
namespace StopShopping.AdminApi.Routes;
public static class Common
{
public static RouteGroupBuilder MapCommon(this RouteGroupBuilder routes)
{
routes.MapPost("/common/upload", UploadAsync)
.WithTags(OpenApiTags..ToString());
routes.MapPost("/common/antiforgery-token", AntiForgeryToken)
.WithTags(OpenApiTags..ToString());
return routes;
}
private static async Task<ApiResponse<FileUploadResp>> UploadAsync(
[FromForm] UploadParams payload,
IFileService fileService,
HttpContext httpContext)
{
return await fileService.UploadFileAsync(payload);
}
private static ApiResponse<AntiForgeryToken> AntiForgeryToken(
HttpContext httpContext,
IAntiforgery antiforgery)
{
var antiforgeryToken = antiforgery.GetAndStoreTokens(httpContext);
return new ApiResponse<AntiForgeryToken>(new AntiForgeryToken
{
Token = antiforgeryToken.RequestToken,
HeaderName = antiforgeryToken.HeaderName
});
}
}

View File

@@ -0,0 +1,34 @@
using StopShopping.Services;
using StopShopping.Services.Models.Req;
using StopShopping.Services.Models.Resp;
namespace StopShopping.AdminApi.Routes;
public static class District
{
public static RouteGroupBuilder MapDistrict(this RouteGroupBuilder routes)
{
routes.MapGet("/district/top3level", GetTop3LevelDistrictsAsync)
.WithTags(OpenApiTags..ToString());
routes.MapGet("/district/children", GetChildrenDistricts)
.WithTags(OpenApiTags..ToString());
return routes;
}
private static ApiResponse<List<Services.Models.Resp.District>> GetChildrenDistricts(
[AsParameters] DistrictParentIdParams model,
IDistrictService districtService
)
{
return districtService.GetChildren(model);
}
private static async Task<ApiResponse<List<Services.Models.Resp.District>>> GetTop3LevelDistrictsAsync(
IDistrictService districtService
)
{
return await districtService.GetTop3LevelDistrictsAsync();
}
}

View File

@@ -0,0 +1,61 @@
using StopShopping.Services;
using StopShopping.Services.Models.Req;
using StopShopping.Services.Models.Resp;
namespace StopShopping.AdminApi.Routes;
/// <summary>
/// 商品相关路由
/// </summary>
public static class Product
{
public static RouteGroupBuilder MapProduct(this RouteGroupBuilder routes)
{
routes.MapGet("/product/list", SearchProductsAsync)
.WithTags(OpenApiTags..ToString());
routes.MapGet("/product/detail", Detail)
.WithTags(OpenApiTags..ToString());
routes.MapPost("/product/edit", EditAsync)
.WithTags(OpenApiTags..ToString());
routes.MapPost("/product/delete", DeleteAsync)
.WithTags(OpenApiTags..ToString());
return routes;
}
private static async
Task<ApiResponse<PagedResult<Services.Models.Resp.Product>>>
SearchProductsAsync(
[AsParameters] ProductSearchParms model,
IProductService productService
)
{
return await productService.SearchAsync(model);
}
private static ApiResponse<ProductInfo> Detail(
[AsParameters] ProductIdParams model,
IProductService productService)
{
return productService.Detail(model);
}
private static async Task<ApiResponse> EditAsync(
EditProductParams model,
IProductService productService
)
{
return await productService.EditAsync(model);
}
private static async Task<ApiResponse> DeleteAsync(
ProductIdParams model,
IProductService productService
)
{
return await productService.DeleteAsync(model);
}
}

View File

@@ -0,0 +1,35 @@
using Scalar.AspNetCore;
using StopShopping.Services.Models;
namespace StopShopping.AdminApi.Routes;
/// <summary>
/// 其他路由从RouteGroupBuilder扩展并添加到MapGroup之后
/// </summary>
public static class Root
{
public static void MapRoutes(WebApplication app)
{
app.MapGroup("")
.MapCommon()
.MapCategory()
.MapProduct()
.MapDistrict()
.WithDescription("登录用户调用")
.RequireAuthorization(policy => policy.RequireRole(SystemRoles.Admin.ToString()));
app.MapGroup("")
.MapAdmin()
.WithDescription("公共调用")
.AllowAnonymous();
}
}
public enum OpenApiTags
{
,
,
,
,
,
}

View File

@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.3" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.1" />
<PackageReference Include="Scalar.AspNetCore" Version="2.13.5" />
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StopShopping.Services\StopShopping.Services.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,6 +1,6 @@
using StopShopping.Services; using StopShopping.Services;
namespace StopShopping.Api.Workers; namespace StopShopping.AdminApi.Workers;
public class DbSeederBackgroundService : BackgroundService public class DbSeederBackgroundService : BackgroundService
{ {

View File

@@ -0,0 +1,37 @@
{
"ConnectionStrings": {
"StopShopping": "PG_CONNECTION_STRING"
},
"JwtOptions": {
"ValidAudience": "StopShopping.Client",
"ValidIssuer": "StopShopping.Api",
"SigningKey": "128_BIT_SIGNING_KEY",
"AccessTokenExpiresIn": "3600",
"RefreshTokenExpiresIn": "604800"
},
"AppOptions": {
"FileApiDomain": "FILE_API_DOMAIN",
"FileApiLocalDomain": "FILE_API_LOCAL_DOMAIN",
"CookieDomain": ".example.com或者localhost开发环境",
"CSRFHeaderName": "X-CSRF-TOKEN",
"CSRFCookieName": "csrf_token",
"CorsAllowedOrigins": ["https://web.example.com跨域"]
},
"OpenPlatformOptions": {
"TencentDistrict": {
"SecretKey": "TENCENT_API_KEY",
"Top3LevelUrl": "https://apis.map.qq.com/ws/district/v1/list",
"GetChildrenUrl": "https://apis.map.qq.com/ws/district/v1/getchildren"
}
},
"Serilog": {
"Using": ["Serilog.Sinks.Console"],
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft.AspNetCore": "Warning"
}
},
"WriteTo": [{ "Name": "Console" }]
}
}

View File

@@ -62,15 +62,12 @@ public static class CommonServiceCollections
options.Cookie.MaxAge = TimeSpan.FromSeconds(jwtOptions!.RefreshTokenExpiresIn); options.Cookie.MaxAge = TimeSpan.FromSeconds(jwtOptions!.RefreshTokenExpiresIn);
options.Cookie.HttpOnly = true; options.Cookie.HttpOnly = true;
options.Cookie.Name = appOptions.CSRFCookieName; options.Cookie.Name = appOptions.CSRFCookieName;
if (isDevelopment)
{
options.Cookie.SameSite = SameSiteMode.Lax; options.Cookie.SameSite = SameSiteMode.Lax;
}
else
{
options.Cookie.SameSite = SameSiteMode.None;
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
options.Cookie.Domain = appOptions.CookieDomain; options.Cookie.Domain = appOptions.CookieDomain;
if (!isDevelopment)
{
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
} }
}); });

View File

@@ -1,10 +1,11 @@
using StopShopping.Services.Extensions; using StopShopping.Services.Extensions;
namespace Microsoft.AspNetCore.Http; namespace Microsoft.AspNetCore.Http;
public static class HttpExtensions public static class HttpExtensions
{ {
public const string REFRESH_TOKEN_COOKIE_KEY = "refresh_token"; public const string REFRESH_TOKEN_COOKIE_KEY = "user_refresh_token";
public static IResponseCookies AppendRefreshToken( public static IResponseCookies AppendRefreshToken(
this IResponseCookies cookies, this IResponseCookies cookies,
@@ -18,13 +19,11 @@ public static class HttpExtensions
MaxAge = maxAge, MaxAge = maxAge,
HttpOnly = true, HttpOnly = true,
SameSite = SameSiteMode.Lax, SameSite = SameSiteMode.Lax,
Domain = appOptions.CookieDomain,
}; };
if (!env.IsDevelopment()) if (!env.IsDevelopment())
{
options.SameSite = SameSiteMode.None;
options.Secure = true; options.Secure = true;
options.Domain = appOptions.CookieDomain;
}
cookies.Append( cookies.Append(
REFRESH_TOKEN_COOKIE_KEY, REFRESH_TOKEN_COOKIE_KEY,

View File

@@ -10,16 +10,4 @@ public static class MiddlewareExtensions
return applicationBuilder; return applicationBuilder;
} }
/// <summary>
/// 解决开发时多客户端localhost端口串cookie的问题
/// </summary>
/// <param name="applicationBuilder"></param>
/// <returns></returns>
public static IApplicationBuilder UseDevelopmentCookie(this IApplicationBuilder applicationBuilder)
{
applicationBuilder.UseMiddleware<DevelopmentCookieMiddleware>();
return applicationBuilder;
}
} }

View File

@@ -1,142 +0,0 @@
using System.Text;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Primitives;
using Microsoft.Net.Http.Headers;
using StopShopping.Services.Extensions;
namespace StopShopping.Api.Middlewares;
public class DevelopmentCookieMiddleware
{
public DevelopmentCookieMiddleware(
RequestDelegate requestDelegate,
IOptions<AppOptions> options)
{
_next = requestDelegate;
_appOptions = options.Value;
}
private readonly RequestDelegate _next;
private readonly AppOptions _appOptions;
public async Task InvokeAsync(HttpContext httpContext)
{
int? port = null;
var origin = httpContext.Request.Headers[HeaderNames.Origin];
if (origin.Count > 0 && null != origin[0])
{
Uri uri = new(origin[0]!);
port = uri.Port;
}
else
{
var referer = httpContext.Request.Headers[HeaderNames.Referer];
if (referer.Count > 0 && null != referer[0])
{
Uri uri = new(referer[0]!);
port = uri.Port;
}
}
if (port.HasValue)
{
var modified = ModifyCookie(httpContext.Request.Headers[HeaderNames.Cookie], port.Value);
httpContext.Request.Headers[HeaderNames.Cookie] = modified;
}
httpContext.Response.OnStarting(() =>
{
if (port.HasValue)
{
var cookieHeader = httpContext.Response.Headers[HeaderNames.SetCookie];
ModifyResponseCookie(cookieHeader, httpContext, port.Value);
}
return Task.CompletedTask;
});
await _next(httpContext);
}
private void ModifyResponseCookie(StringValues cookieHeader, HttpContext httpContext, int port)
{
foreach (var cookieItem in cookieHeader)
{
if (cookieItem != null)
{
var cookies = cookieItem.Split(';');
foreach (var item in cookies)
{
if (null != item)
{
var pairs = item.Split('=');
if (2 == pairs.Length)
{
if (pairs[0].Trim() == _appOptions.CSRFCookieName)
{
var val = pairs[1];
httpContext.Response.Cookies.Delete(pairs[0]);
httpContext.Response.Cookies.Append($"{_appOptions.CSRFCookieName}_{port}", val);
}
else if (pairs[0].Trim() == HttpExtensions.REFRESH_TOKEN_COOKIE_KEY)
{
var val = pairs[1];
httpContext.Response.Cookies.Delete(pairs[0]);
httpContext.Response.Cookies.Append($"{HttpExtensions.REFRESH_TOKEN_COOKIE_KEY}_{port}", val);
}
}
}
}
}
}
}
private StringValues ModifyCookie(StringValues cookie, int port)
{
List<string> result = [];
var csrfCookieName = $"{_appOptions.CSRFCookieName}_{port}";
var refreshTokenCookieName = $"{HttpExtensions.REFRESH_TOKEN_COOKIE_KEY}_{port}";
foreach (var cookieItem in cookie)
{
if (null != cookieItem)
{
StringBuilder itemBuilder = new();
var cookies = cookieItem.Split(';');
foreach (var item in cookies)
{
if (null != item)
{
var pairs = item.Split('=');
if (pairs.Length == 2)
{
if (0 != itemBuilder.Length)
itemBuilder.Append(';');
if (pairs[0].Trim() == csrfCookieName)
{
itemBuilder.Append(_appOptions.CSRFCookieName);
itemBuilder.Append('=');
itemBuilder.Append(pairs[1].Trim());
}
else if (pairs[0].Trim() == refreshTokenCookieName)
{
itemBuilder.Append(HttpExtensions.REFRESH_TOKEN_COOKIE_KEY);
itemBuilder.Append('=');
itemBuilder.Append(pairs[1].Trim());
}
else
{
itemBuilder.Append(item);
}
}
else
{
itemBuilder.Append(item);
}
}
}
result.Add(itemBuilder.ToString());
}
}
return new StringValues(result.ToArray());
}
}

View File

@@ -24,12 +24,14 @@ public class GlobalExceptionHandlerMiddleware
try try
{ {
await _next(httpContext); await _next(httpContext);
} httpContext.Response.OnStarting(async () =>
catch (BadHttpRequestException ex) when (ex.InnerException is AntiforgeryValidationException) {
var antiforgeryFeature = httpContext.Features.Get<IAntiforgeryValidationFeature>();
if (null != antiforgeryFeature && !antiforgeryFeature.IsValid)
{ {
var problemDetails = new ProblemDetails var problemDetails = new ProblemDetails
{ {
Detail = ex.InnerException.Message, Detail = antiforgeryFeature.Error?.Message,
Instance = httpContext.Request.Path, Instance = httpContext.Request.Path,
Status = StatusCodes.Status400BadRequest, Status = StatusCodes.Status400BadRequest,
Title = "CSRF 错误", Title = "CSRF 错误",
@@ -46,6 +48,10 @@ public class GlobalExceptionHandlerMiddleware
ProblemDetails = problemDetails ProblemDetails = problemDetails
}); });
} }
await Task.CompletedTask;
});
}
catch (BadHttpRequestException ex) catch (BadHttpRequestException ex)
{ {
_logger.LogError(ex, "参数错误"); _logger.LogError(ex, "参数错误");

View File

@@ -1,10 +1,12 @@
using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.HostFiltering;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Scalar.AspNetCore; using Scalar.AspNetCore;
using Serilog; using Serilog;
using StopShopping.Api.Extensions; using StopShopping.Api.Extensions;
using StopShopping.Api.Routes; using StopShopping.Api.Routes;
using StopShopping.Api.Workers;
const string CORS_POLICY = "default"; const string CORS_POLICY = "default";
// 将启动日志写入控制台用于捕获启动时异常启动后WriteTo被后续配置替代 // 将启动日志写入控制台用于捕获启动时异常启动后WriteTo被后续配置替代
@@ -41,13 +43,11 @@ try
}, },
appConfiguration, appConfiguration,
builder.Configuration.GetSection("OpenPlatformOptions")); builder.Configuration.GetSection("OpenPlatformOptions"));
builder.Services.AddHostedService<DbSeederBackgroundService>();
/**********************************************************************/ /**********************************************************************/
var app = builder.Build(); var app = builder.Build();
if (app.Environment.IsDevelopment()) if (app.Environment.IsDevelopment())
{ {
app.UseDevelopmentCookie();
app.MapOpenApi(); app.MapOpenApi();
app.MapScalarApiReference(options => app.MapScalarApiReference(options =>
{ {
@@ -55,18 +55,30 @@ try
}); });
} }
if (!app.Environment.IsDevelopment())
{
app.UseHostFiltering();
var forwardedHeadersOptions = new ForwardedHeadersOptions
{
ForwardedHeaders = ForwardedHeaders.All,
};
var hostFilteringOptions = app.Services.GetRequiredService<IOptions<HostFilteringOptions>>();
if (null != hostFilteringOptions)
forwardedHeadersOptions.AllowedHosts = hostFilteringOptions.Value.AllowedHosts;
app.UseForwardedHeaders(forwardedHeadersOptions);
}
app.UseGlobalExceptionHandler(); app.UseGlobalExceptionHandler();
app.UseRouting();
app.UseCors(CORS_POLICY); app.UseCors(CORS_POLICY);
app.UseRouting();
app.UseAuthentication(); app.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();
app.MapStaticAssets().ShortCircuit();
Root.MapRoutes(app); Root.MapRoutes(app);
app.UseAntiforgery(); app.UseAntiforgery();

View File

@@ -1,44 +0,0 @@
using Microsoft.Extensions.Options;
using StopShopping.Services;
using StopShopping.Services.Extensions;
using StopShopping.Services.Models.Req;
using StopShopping.Services.Models.Resp;
namespace StopShopping.Api.Routes;
public static class Admin
{
public static RouteGroupBuilder MapAdmin(this RouteGroupBuilder routes)
{
routes.MapPost("/admin/signin", SignInAsync)
.AllowAnonymous().WithTags(OpenApiTags..ToString());
return routes;
}
private static async Task<ApiResponse<SignInAdmin>> SignInAsync(
SignInParams model,
IUserService userService,
HttpContext httpContext,
IWebHostEnvironment env,
IOptions<AppOptions> options)
{
var result = await userService.SignInAdminAsync(model);
var resp = new ApiResponse<SignInAdmin>
{
IsSucced = result.IsSucced,
Data = result.User,
Message = result.Message
};
if (result.IsSucced)
{
httpContext.Response.Cookies.AppendRefreshToken(
env,
options.Value,
TimeSpan.FromSeconds(result.RefreshToken!.ExpiresIn),
result.RefreshToken.Token!
);
}
return resp;
}
}

View File

@@ -1,29 +1,13 @@
using StopShopping.Services; using StopShopping.Services;
using StopShopping.Services.Models.Req;
using StopShopping.Services.Models.Resp; using StopShopping.Services.Models.Resp;
namespace StopShopping.Api.Routes; namespace StopShopping.Api.Routes;
public static class Category public static class Category
{ {
public static RouteGroupBuilder MapCategoryCommon(this RouteGroupBuilder routes)
{
routes.MapGet("/category/list", GetTree)
.WithTags(OpenApiTags..ToString())
.AllowAnonymous();
return routes;
}
public static RouteGroupBuilder MapCategory(this RouteGroupBuilder routes) public static RouteGroupBuilder MapCategory(this RouteGroupBuilder routes)
{ {
routes.MapPost("/category/edit", EditCategoryAsync) routes.MapGet("/category/list", GetTree)
.WithTags(OpenApiTags..ToString());
routes.MapPost("/category/resort", ResortCategoryAsync)
.WithTags(OpenApiTags..ToString());
routes.MapPost("/category/delete", DeleteCategoryAsync)
.WithTags(OpenApiTags..ToString()); .WithTags(OpenApiTags..ToString());
return routes; return routes;
@@ -35,26 +19,4 @@ public static class Category
{ {
return categoryService.GetCategoriesTree(); return categoryService.GetCategoriesTree();
} }
private static async Task<ApiResponse<Services.Models.Resp.Category>> EditCategoryAsync(
EditCategoryParams model,
ICategoryService categoryService)
{
return await categoryService.EditCategoryAsync(model);
}
private static async Task<ApiResponse> ResortCategoryAsync(
ResortCategoryParams model,
ICategoryService categoryService)
{
return await categoryService.ResortCategoryAsync(model);
}
private static async Task<ApiResponse> DeleteCategoryAsync(
CategoryIdParams model,
ICategoryService categoryService
)
{
return await categoryService.DeleteCategoryAsync(model);
}
} }

View File

@@ -1,6 +1,5 @@
using Microsoft.AspNetCore.Antiforgery; using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Net.Http.Headers;
using StopShopping.Services; using StopShopping.Services;
using StopShopping.Services.Models.Req; using StopShopping.Services.Models.Req;
using StopShopping.Services.Models.Resp; using StopShopping.Services.Models.Resp;
@@ -12,23 +11,15 @@ public static class Common
public static RouteGroupBuilder MapCommon(this RouteGroupBuilder routes) public static RouteGroupBuilder MapCommon(this RouteGroupBuilder routes)
{ {
routes.MapPost("/common/upload", UploadAsync) routes.MapPost("/common/upload", UploadAsync)
.WithTags(OpenApiTags..ToString()); .WithTags(OpenApiTags..ToString());
routes.MapPost("/common/refreshtoken", RefreshTokenAsync)
.AllowAnonymous()
.Produces<ApiResponse<AccessToken>>()
.WithTags(OpenApiTags..ToString());
routes.MapPost("/common/signout", SignOutAsync)
.AllowAnonymous().WithTags(OpenApiTags..ToString());
routes.MapPost("/common/antiforgery-token", AntiForgeryToken) routes.MapPost("/common/antiforgery-token", AntiForgeryToken)
.WithTags(OpenApiTags..ToString()); .WithTags(OpenApiTags..ToString());
return routes; return routes;
} }
private static async Task<ApiResponse<FileUpload>> UploadAsync( private static async Task<ApiResponse<FileUploadResp>> UploadAsync(
[FromForm] UploadParams payload, [FromForm] UploadParams payload,
IFileService fileService, IFileService fileService,
HttpContext httpContext) HttpContext httpContext)
@@ -48,40 +39,5 @@ public static class Common
HeaderName = antiforgeryToken.HeaderName HeaderName = antiforgeryToken.HeaderName
}); });
} }
private static async Task<IResult> RefreshTokenAsync(
HttpContext httpContext,
IAccessTokenService accessTokenService)
{
var refreshToken = httpContext.Request.Cookies[HttpExtensions.REFRESH_TOKEN_COOKIE_KEY];
if (string.IsNullOrWhiteSpace(refreshToken))
return Results.Unauthorized();
var accessToken = await accessTokenService.GenerateAccessTokenAsync(refreshToken);
if (null == accessToken)
return Results.Unauthorized();
return Results.Ok(new ApiResponse<AccessToken>(accessToken));
}
public static async Task<ApiResponse> SignOutAsync(
HttpContext httpContext,
IAccessTokenService accessTokenService)
{
var accessTokenHeader = httpContext.Request.Headers[HeaderNames.Authorization];
if (accessTokenHeader.Count != 0)
{
var accessToken = accessTokenHeader.First()!.Split(" ").Last();
if (!string.IsNullOrWhiteSpace(accessToken))
await accessTokenService.AddAccessTokenBlacklistAsync(accessToken);
}
var refreshToken = httpContext.Request.Cookies[HttpExtensions.REFRESH_TOKEN_COOKIE_KEY];
if (!string.IsNullOrWhiteSpace(refreshToken))
{
await accessTokenService.RevokeRefreshTokenAsync(refreshToken);
httpContext.Response.Cookies.Delete(HttpExtensions.REFRESH_TOKEN_COOKIE_KEY);
}
return ApiResponse.Succed();
}
} }

View File

@@ -26,6 +26,14 @@ public static class Product
return routes; return routes;
} }
public static RouteGroupBuilder MapProductAnonymous(this RouteGroupBuilder routes)
{
routes.MapGet("/product/search", SearchProductsAsync)
.WithTags(OpenApiTags..ToString());
return routes;
}
private static async private static async
Task<ApiResponse<PagedResult<Services.Models.Resp.Product>>> Task<ApiResponse<PagedResult<Services.Models.Resp.Product>>>
SearchProductsAsync( SearchProductsAsync(

View File

@@ -11,10 +11,6 @@ public static class Request
routes.MapPost("/request/publish", PublishRequestAsync) routes.MapPost("/request/publish", PublishRequestAsync)
.WithTags(OpenApiTags..ToString()); .WithTags(OpenApiTags..ToString());
routes.MapGet("/request/search", SearchAsync)
.WithTags(OpenApiTags..ToString())
.AllowAnonymous();
routes.MapGet("/request/orders", OrderSearchAsync) routes.MapGet("/request/orders", OrderSearchAsync)
.WithTags(OpenApiTags..ToString()); .WithTags(OpenApiTags..ToString());
@@ -24,6 +20,14 @@ public static class Request
return routes; return routes;
} }
public static RouteGroupBuilder MapRequestAnonymous(this RouteGroupBuilder routes)
{
routes.MapGet("/request/search", SearchAsync)
.WithTags(OpenApiTags..ToString());
return routes;
}
private static async Task<ApiResponse> PublishRequestAsync( private static async Task<ApiResponse> PublishRequestAsync(
CreateRequestParams model, CreateRequestParams model,
IRequestService requestService) IRequestService requestService)

View File

@@ -17,18 +17,16 @@ public static class Root
.MapRequest() .MapRequest()
.MapReply() .MapReply()
.MapDistrict() .MapDistrict()
.WithDescription("用户端调用") .MapCommon()
.WithDescription("登录用户调用")
.RequireAuthorization(policy => policy.RequireRole(SystemRoles.User.ToString())); .RequireAuthorization(policy => policy.RequireRole(SystemRoles.User.ToString()));
app.MapGroup("") app.MapGroup("")
.MapCommon() .MapRequestAnonymous()
.MapCategoryCommon() .MapProductAnonymous()
.WithDescription("公共调用")
.RequireAuthorization();
app.MapGroup("")
.MapAdmin()
.MapCategory() .MapCategory()
.WithDescription("管理端调用") .MapUserAnonymous()
.RequireAuthorization(policy => policy.RequireRole(SystemRoles.Admin.ToString())); .WithDescription("匿名用户调用")
.AllowAnonymous();
} }
} }
@@ -40,6 +38,5 @@ public enum OpenApiTags
, ,
, ,
, ,
, ,
,
} }

View File

@@ -1,4 +1,5 @@
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Microsoft.Net.Http.Headers;
using StopShopping.Services; using StopShopping.Services;
using StopShopping.Services.Extensions; using StopShopping.Services.Extensions;
using StopShopping.Services.Models.Req; using StopShopping.Services.Models.Req;
@@ -28,6 +29,18 @@ public static class User
return routes; return routes;
} }
public static RouteGroupBuilder MapUserAnonymous(this RouteGroupBuilder routes)
{
routes.MapPost("/user/refreshtoken", RefreshTokenAsync)
.Produces<ApiResponse<AccessToken>>()
.WithTags(OpenApiTags..ToString());
routes.MapPost("/user/signout", SignOutAsync)
.WithTags(OpenApiTags..ToString());
return routes;
}
private static async Task<ApiResponse> SignUpAsync( private static async Task<ApiResponse> SignUpAsync(
SignUpParams model, SignUpParams model,
IUserService userService) IUserService userService)
@@ -41,8 +54,8 @@ public static class User
SignInParams model, SignInParams model,
IUserService userService, IUserService userService,
HttpContext httpContext, HttpContext httpContext,
IWebHostEnvironment env, IOptions<AppOptions> options,
IOptions<AppOptions> options) IWebHostEnvironment env)
{ {
var result = await userService.SignInAsync(model); var result = await userService.SignInAsync(model);
var resp = new ApiResponse<SignInUser> var resp = new ApiResponse<SignInUser>
@@ -73,7 +86,7 @@ public static class User
var resp = await userService.ChangePasswordAsync(model); var resp = await userService.ChangePasswordAsync(model);
if (resp.IsSucced) if (resp.IsSucced)
await Common.SignOutAsync(httpContext, accessTokenService); await SignOutAsync(httpContext, accessTokenService);
return resp; return resp;
} }
@@ -92,4 +105,41 @@ public static class User
{ {
return await userService.EditAsync(model); return await userService.EditAsync(model);
} }
private static async Task<IResult> RefreshTokenAsync(
HttpContext httpContext,
IAccessTokenService accessTokenService)
{
var refreshToken = httpContext.Request.Cookies[HttpExtensions.REFRESH_TOKEN_COOKIE_KEY];
if (string.IsNullOrWhiteSpace(refreshToken))
return Results.Unauthorized();
var accessToken = await accessTokenService.GenerateAccessTokenAsync(refreshToken);
if (null == accessToken)
return Results.Unauthorized();
return Results.Ok(new ApiResponse<AccessToken>(accessToken));
}
public static async Task<ApiResponse> SignOutAsync(
HttpContext httpContext,
IAccessTokenService accessTokenService)
{
var accessTokenHeader = httpContext.Request.Headers[HeaderNames.Authorization];
if (accessTokenHeader.Count != 0)
{
var accessToken = accessTokenHeader.First()!.Split(" ").Last();
if (!string.IsNullOrWhiteSpace(accessToken))
await accessTokenService.AddAccessTokenBlacklistAsync(accessToken);
}
var refreshToken = httpContext.Request.Cookies[HttpExtensions.REFRESH_TOKEN_COOKIE_KEY];
if (!string.IsNullOrWhiteSpace(refreshToken))
{
await accessTokenService.RevokeRefreshTokenAsync(refreshToken);
httpContext.Response.Cookies.Delete(HttpExtensions.REFRESH_TOKEN_COOKIE_KEY);
}
return ApiResponse.Succed();
}
} }

View File

@@ -17,10 +17,4 @@
<ProjectReference Include="..\StopShopping.Services\StopShopping.Services.csproj" /> <ProjectReference Include="..\StopShopping.Services\StopShopping.Services.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<Content Update="wwwroot\images\**\*">
<CopyToPublishDirectory>Never</CopyToPublishDirectory>
</Content>
</ItemGroup>
</Project> </Project>

View File

@@ -10,8 +10,9 @@
"RefreshTokenExpiresIn": "604800" "RefreshTokenExpiresIn": "604800"
}, },
"AppOptions": { "AppOptions": {
"FileApiDomain": "FILE_API_DOMAIN",
"FileApiLocalDomain": "FILE_API_LOCAL_DOMAIN",
"CookieDomain": ".example.com或者localhost开发环境", "CookieDomain": ".example.com或者localhost开发环境",
"DomainPath": "https://example.com或者http://localhost开发环境",
"CSRFHeaderName": "X-CSRF-TOKEN", "CSRFHeaderName": "X-CSRF-TOKEN",
"CSRFCookieName": "csrf_token", "CSRFCookieName": "csrf_token",
"CorsAllowedOrigins": ["https://web.example.com跨域"] "CorsAllowedOrigins": ["https://web.example.com跨域"]

View File

@@ -0,0 +1,49 @@
using System.ComponentModel;
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.OpenApi;
using Microsoft.OpenApi;
namespace StopShopping.FileApi.Extensions;
/// <summary>
/// 处理enum类型openapi显示
/// </summary>
public class EnumOpenApiSchemaTransformer : IOpenApiSchemaTransformer
{
public Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext context, CancellationToken cancellationToken)
{
if (context.JsonTypeInfo.Type.IsEnum)
{
schema.Type = JsonSchemaType.Integer;
var enumValues = Enum.GetValues(context.JsonTypeInfo.Type)
.Cast<object>()
.Select(v => JsonNode.Parse(Convert.ToInt32(v).ToString())!)
.ToList();
schema.Enum = enumValues;
var enumNames = Enum.GetNames(context.JsonTypeInfo.Type);
schema.Extensions ??= new Dictionary<string, IOpenApiExtension>();
var namesExtension = new JsonNodeExtension(new JsonArray(
enumNames
.Select(n => (JsonNode)n)
.ToArray()));
schema.Extensions.Add("x-enumNames", namesExtension);
var descMap = new JsonObject();
foreach (var name in enumNames)
{
if (context.JsonTypeInfo.Type.GetField(name)
?.GetCustomAttributes(typeof(DescriptionAttribute), false)
.FirstOrDefault() is DescriptionAttribute attr)
{
descMap[name] = attr.Description;
}
}
schema.Extensions.Add("x-enumDescriptions", new JsonNodeExtension(descMap));
}
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,13 @@
using StopShopping.FileApi.Middlewares;
namespace StopShopping.FileApi.Extensions;
public static class MiddlewareExtensions
{
public static IApplicationBuilder UseInternalOnlyAccess(this IApplicationBuilder applicationBuilder)
{
applicationBuilder.UseMiddleware<InternalAccessOnlyMiddleware>();
return applicationBuilder;
}
}

View File

@@ -0,0 +1,13 @@
namespace StopShopping.FileApi.Extensions;
public static class RouteGroupBuilderExtensions
{
public static RouteGroupBuilder WithInternalOnly(this RouteGroupBuilder routes)
{
routes.WithMetadata(new InternalOnlyMetadata());
return routes;
}
}
public class InternalOnlyMetadata { }

View File

@@ -0,0 +1,58 @@
using System.Net;
using Microsoft.AspNetCore.Mvc;
using StopShopping.FileApi.Extensions;
namespace StopShopping.FileApi.Middlewares;
public class InternalAccessOnlyMiddleware
{
public InternalAccessOnlyMiddleware(
RequestDelegate next,
IProblemDetailsService problemDetailsService,
ILogger<InternalAccessOnlyMiddleware> logger)
{
_next = next;
_problemService = problemDetailsService;
_logger = logger;
}
private readonly RequestDelegate _next;
private readonly IProblemDetailsService _problemService;
private readonly ILogger<InternalAccessOnlyMiddleware> _logger;
public async Task InvokeAsync(HttpContext httpContext)
{
var endpoint = httpContext.GetEndpoint();
if (null != endpoint)
{
var internalOnlyMetadata = endpoint.Metadata.GetMetadata<InternalOnlyMetadata>();
if (null != internalOnlyMetadata)
{
if (null == httpContext.Connection.RemoteIpAddress
|| !IPAddress.IsLoopback(httpContext.Connection.RemoteIpAddress))
{
var problemDetails = new ProblemDetails
{
Detail = $"remote ip: {httpContext.Connection.RemoteIpAddress}",
Instance = httpContext.Request.Path,
Status = StatusCodes.Status403Forbidden,
Title = "access denied, local access only."
};
httpContext.Response.StatusCode = StatusCodes.Status403Forbidden;
httpContext.Response.ContentType = "application/problem+json";
await _problemService.WriteAsync(new ProblemDetailsContext
{
HttpContext = httpContext,
ProblemDetails = problemDetails,
});
_logger.LogInformation("denied access: {Ip}", httpContext.Connection.RemoteIpAddress);
}
}
}
await _next(httpContext);
}
}

View File

@@ -0,0 +1,69 @@
using Microsoft.AspNetCore.HostFiltering;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.Extensions.Options;
using Scalar.AspNetCore;
using Serilog;
using StopShopping.FileApi;
using StopShopping.FileApi.Extensions;
using StopShopping.FileApi.Services;
// 将启动日志写入控制台用于捕获启动时异常启动后WriteTo被后续配置替代
Log.Logger = new LoggerConfiguration()
.WriteTo.Console()
.CreateBootstrapLogger();
try
{
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSerilog((services, seriLogger) =>
{
seriLogger.ReadFrom.Configuration(builder.Configuration)
.ReadFrom.Services(services);
});
builder.Services.AddOpenApi(options =>
{
options.AddSchemaTransformer<EnumOpenApiSchemaTransformer>();
});
builder.Services.AddProblemDetails();
builder.Services.AddServices(builder.Configuration.GetSection("AppOptions"));
/********************************************************************/
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.MapScalarApiReference();
}
if (!app.Environment.IsDevelopment())
{
app.UseHostFiltering();
var forwardedHeadersOptions = new ForwardedHeadersOptions
{
ForwardedHeaders = ForwardedHeaders.All,
};
var hostFilteringOptions = app.Services.GetRequiredService<IOptions<HostFilteringOptions>>();
if (null != hostFilteringOptions)
forwardedHeadersOptions.AllowedHosts = hostFilteringOptions.Value.AllowedHosts;
app.UseForwardedHeaders(forwardedHeadersOptions);
}
app.UseStaticFiles();
Routes.MapRoutes(app);
app.UseInternalOnlyAccess();
app.Run();
}
catch (Exception ex)
{
Log.Fatal(ex, "启动异常!");
}
finally
{
Log.CloseAndFlush();
}

View File

@@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5072",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:7178;http://localhost:5072",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,31 @@
using Microsoft.AspNetCore.Mvc;
using StopShopping.FileApi.Extensions;
using StopShopping.FileApi.Services;
namespace StopShopping.FileApi;
public static class Routes
{
public static void MapRoutes(WebApplication app)
{
app.MapGroup("")
.MapRoutes()
.WithInternalOnly();
}
public static RouteGroupBuilder MapRoutes(this RouteGroupBuilder routes)
{
routes.MapPost("/upload", UploadAsync)
.DisableAntiforgery()
.WithDescription("上传文件对外接口自己实现anti-forgery重要");
return routes;
}
private static async Task<ApiResponse<FileUploadResp>> UploadAsync(
[FromForm] UploadParams payload,
IFileService fileService)
{
return await fileService.UploadFileAsync(payload);
}
}

View File

@@ -0,0 +1,64 @@
namespace StopShopping.FileApi.Services;
/// <summary>
/// 强类型返回值
/// </summary>
/// <typeparam name="T"></typeparam>
public class ApiResponse<T>
{
public ApiResponse()
{ }
public ApiResponse(T data)
{
Data = data;
}
/// <summary>
/// 是否成功
/// </summary>
/// <value></value>
public bool IsSucced { get; set; } = true;
/// <summary>
/// 错误消息
/// </summary>
/// <value></value>
public string? Message { get; set; }
/// <summary>
/// 关联数据
/// </summary>
/// <value></value>
public T? Data { get; set; }
public ApiResponse<T> Failed(string message)
{
IsSucced = false;
Message = message;
return this;
}
}
/// <summary>
/// 强类型返回值,只返回成功与否和消息
/// </summary>
public class ApiResponse : ApiResponse<object?>
{
public ApiResponse(bool isSucced = true, string message = "")
{
IsSucced = isSucced;
Message = message;
}
public static ApiResponse Succed(string message = "")
{
return new ApiResponse(message: message);
}
public static new ApiResponse Failed(string message)
{
return new ApiResponse(false, message);
}
}

View File

@@ -0,0 +1,6 @@
namespace StopShopping.FileApi.Services;
public class AppOptions
{
public string Domain { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,34 @@
using FileSignatures;
using FileSignatures.Formats;
using StopShopping.FileApi.Services.Implementions;
namespace StopShopping.FileApi.Services;
public static class Extensions
{
public static IServiceCollection AddServices(this IServiceCollection services, IConfiguration appOptions)
{
services.Configure<AppOptions>(appOptions);
services.AddValidation();
var imageFormats = FileFormatLocator.GetFormats().OfType<Image>();
var imageInspector = new FileFormatInspector(imageFormats);
services.AddSingleton<IFileFormatInspector>(imageInspector);
services.AddScoped<IFileService, FileService>();
return services;
}
public static string GetTargetDirectory(this UploadScences uploadScences)
{
return uploadScences switch
{
UploadScences.Avatar => "avatar",
UploadScences.Product => "product",
UploadScences.Category => "category",
_ => throw new ArgumentOutOfRangeException(nameof(uploadScences))
};
}
}

View File

@@ -0,0 +1,18 @@
namespace StopShopping.FileApi.Services;
/// <summary>
/// 文件上传结果
/// </summary>
public class FileUploadResp
{
/// <summary>
/// 新名,上传后重命名
/// </summary>
/// <value></value>
public string NewName { get; set; } = string.Empty;
/// <summary>
/// Url
/// </summary>
/// <value></value>
public string Url { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,14 @@
namespace StopShopping.FileApi.Services;
/// <summary>
/// 文件服务
/// </summary>
public interface IFileService
{
/// <summary>
/// 上传文件
/// </summary>
/// <param name="payload"></param>
/// <returns></returns>
Task<ApiResponse<FileUploadResp>> UploadFileAsync(UploadParams payload);
}

View File

@@ -0,0 +1,59 @@
using Microsoft.Extensions.Options;
namespace StopShopping.FileApi.Services.Implementions;
public class FileService : IFileService
{
public FileService(IOptions<AppOptions> appOptions,
IWebHostEnvironment webHostEnvironment)
{
_appOptions = appOptions.Value;
_env = webHostEnvironment;
}
private readonly IWebHostEnvironment _env;
private readonly AppOptions _appOptions;
public async Task<ApiResponse<FileUploadResp>> UploadFileAsync(UploadParams payload)
{
var newName = Guid.NewGuid().ToString("N").ToLower();
var extension = Path.GetExtension(payload.File!.FileName);
var newFullName = $"{newName}{extension}";
var relativeToRootPath = GetRelativeToRootPath(payload.Scences, newFullName);
var targetPath = Path.Combine(_env.WebRootPath, GetRelativeToRootPath(payload.Scences));
if (!Directory.Exists(targetPath))
{
Directory.CreateDirectory(targetPath);
}
using var file = new FileStream(
Path.Combine(_env.WebRootPath, relativeToRootPath),
FileMode.CreateNew,
FileAccess.Write);
await payload.File.CopyToAsync(file);
FileUploadResp result = new()
{
NewName = newFullName,
Url = GetFileUrl(payload.Scences, newFullName),
};
return new ApiResponse<FileUploadResp>(result);
}
private string GetFileUrl(UploadScences scences, string fileName)
{
return $"{_appOptions.Domain}/{GetRelativeToRootPath(scences, fileName)
.Replace(Path.DirectorySeparatorChar, '/')}";
}
private string GetRelativeToRootPath(UploadScences scences, string fileName = "")
{
return Path.Combine(
"images",
scences.GetTargetDirectory(),
fileName);
}
}

View File

@@ -0,0 +1,22 @@
using System.ComponentModel.DataAnnotations;
namespace StopShopping.FileApi.Services;
/// <summary>
/// url查询参数
/// </summary>
/// <value></value>
public record NameUrlParams
{
/// <summary>
/// 场景
/// </summary>
/// <value></value>
public UploadScences Scences { get; set; }
/// <summary>
/// 文件名
/// </summary>
/// <value></value>
[Required]
public string[] Names { get; set; } = [];
}

View File

@@ -0,0 +1,14 @@
namespace StopShopping.FileApi.Services;
/// <summary>
/// url查询响应
/// </summary>
/// <value></value>
public class NameUrlResp
{
/// <summary>
/// 文件名:文件链接
/// </summary>
/// <value></value>
public KeyValuePair<string, string>[] NameUrls { get; set; } = [];
}

View File

@@ -0,0 +1,23 @@
using System.ComponentModel.DataAnnotations;
namespace StopShopping.FileApi.Services;
/// <summary>
/// 上传
/// </summary>
/// <value></value>
public record UploadParams
{
/// <summary>
/// 场景
/// </summary>
/// <value></value>
public UploadScences Scences { get; set; }
/// <summary>
/// 文件
/// </summary>
/// <value></value>
[Required]
[ImageFileValidation(2 * 1024 * 1024)]
public IFormFile? File { get; set; }
}

View File

@@ -0,0 +1,20 @@
namespace StopShopping.FileApi.Services;
/// <summary>
/// 文件上传场景
/// </summary>
public enum UploadScences
{
/// <summary>
/// 头像
/// </summary>
Avatar,
/// <summary>
/// 商品
/// </summary>
Product,
/// <summary>
/// 商品分类
/// </summary>
Category,
}

View File

@@ -1,6 +1,4 @@
using FileSignatures; using FileSignatures;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
namespace System.ComponentModel.DataAnnotations; namespace System.ComponentModel.DataAnnotations;

View File

@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.1" />
<PackageReference Include="Scalar.AspNetCore" Version="2.13.5" />
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageReference Include="FileSignatures" Version="7.0.0" />
</ItemGroup>
<ItemGroup>
<Content Update="wwwroot\**\*">
<CopyToPublishDirectory>Never</CopyToPublishDirectory>
</Content>
<Content Update="appsettings.Template.json">
<CopyToPublishDirectory>Never</CopyToPublishDirectory>
</Content>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,26 @@
{
"AppOptions": {
"Domain": "DOMAIN"
},
"Serilog": {
"Using": ["Serilog.Sinks.File"],
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore": "Warning"
}
},
"WriteTo": [
{
"Name": "File",
"Args": {
"path": "./logs/log-.txt",
"rollingInterval": "Day",
"outputTemplate": "{Timestamp:HH:mm:ss.fff} [{Level:u3}] {SourceContext} - {Message:lj}{NewLine}{Exception}"
}
}
]
},
"AllowedHosts": "*"
}

View File

@@ -19,6 +19,11 @@ public static class Consts
/// </summary> /// </summary>
public const string DEFAULT_ADMIN_ACCOUNT = "stopshopping"; public const string DEFAULT_ADMIN_ACCOUNT = "stopshopping";
/// <summary>
/// 文件服务httpclient名
/// </summary>
public const string FILE_API_CLIENT_NAME = "file_api_client";
public static class CacheKeys public static class CacheKeys
{ {
public static string AccessTokenBlacklist(string token) public static string AccessTokenBlacklist(string token)

View File

@@ -5,17 +5,22 @@ namespace StopShopping.Services.Extensions;
/// </summary> /// </summary>
public record AppOptions public record AppOptions
{ {
/// <summary>
/// 文件服务站点
/// </summary>
/// <value></value>
public string FileApiDomain { get; set; } = string.Empty;
/// <summary>
/// 文件服务本地站点
/// </summary>
/// <value></value>
public string FileApiLocalDomain { get; set; } = string.Empty;
/// <summary> /// <summary>
/// .bjbj.me /// .bjbj.me
/// </summary> /// </summary>
/// <value></value> /// <value></value>
public string CookieDomain { get; set; } = string.Empty; public string CookieDomain { get; set; } = string.Empty;
/// <summary> /// <summary>
/// 域名http(s)://www.xxx.xx
/// </summary>
/// <value></value>
public string DomainPath { get; set; } = string.Empty;
/// <summary>
/// anti-forgery 请求头 /// anti-forgery 请求头
/// </summary> /// </summary>
/// <value></value> /// <value></value>

View File

@@ -1,7 +1,6 @@
using FileSignatures;
using FileSignatures.Formats;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Options;
using StopShopping.EF; using StopShopping.EF;
using StopShopping.Services; using StopShopping.Services;
using StopShopping.Services.Extensions; using StopShopping.Services.Extensions;
@@ -21,16 +20,18 @@ public static class ServicesExtensions
services.Configure<AppOptions>(appOptions); services.Configure<AppOptions>(appOptions);
var imageFormats = FileFormatLocator.GetFormats().OfType<Image>(); services.AddHttpClient(Consts.FILE_API_CLIENT_NAME, (sp, client) =>
var imageInspector = new FileFormatInspector(imageFormats); {
services.AddSingleton<IFileFormatInspector>(imageInspector); var options = sp.GetRequiredService<IOptions<AppOptions>>();
client.BaseAddress = new Uri(options.Value.FileApiLocalDomain);
});
services.AddSingleton<ICipherService, CipherService>(); services.AddSingleton<ICipherService, CipherService>();
services.AddSingleton<ISerialNoGenerator, NanoidSerialNoGenerator>(); services.AddSingleton<ISerialNoGenerator, NanoidSerialNoGenerator>();
services.AddScoped<IFileService, FileService>();
services.AddScoped<IDistrictService, DistrictService>(); services.AddScoped<IDistrictService, DistrictService>();
services.AddScoped<IClaimsService, ClaimsService>(); services.AddScoped<IClaimsService, ClaimsService>();
services.AddScoped<IFileService, FileService>();
services.AddScoped<IAccessTokenService, AccessTokenService>(); services.AddScoped<IAccessTokenService, AccessTokenService>();
services.AddScoped<IUserService, UserService>(); services.AddScoped<IUserService, UserService>();
services.AddScoped<ICategoryService, CategoryService>(); services.AddScoped<ICategoryService, CategoryService>();

View File

@@ -24,5 +24,5 @@ public interface IClaimsService
/// 获取当前登录用户id /// 获取当前登录用户id
/// </summary> /// </summary>
/// <returns></returns> /// <returns></returns>
int GetCurrentUserId(); int? GetCurrentUserId();
} }

View File

@@ -14,7 +14,7 @@ public interface IFileService
/// </summary> /// </summary>
/// <param name="payload"></param> /// <param name="payload"></param>
/// <returns></returns> /// <returns></returns>
Task<ApiResponse<FileUpload>> UploadFileAsync(UploadParams payload); Task<ApiResponse<FileUploadResp>> UploadFileAsync(UploadParams payload);
/// <summary> /// <summary>
/// 获取文件链接 /// 获取文件链接
/// </summary> /// </summary>

View File

@@ -2,6 +2,7 @@ using System.Data.Common;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using StopShopping.EF; using StopShopping.EF;
using StopShopping.Services.Models;
using StopShopping.Services.Models.Req; using StopShopping.Services.Models.Req;
using StopShopping.Services.Models.Resp; using StopShopping.Services.Models.Resp;
@@ -183,7 +184,7 @@ public class CategoryService : ICategoryService
Id = model.Id, Id = model.Id,
LogoUrl = string.IsNullOrWhiteSpace(model.Logo) LogoUrl = string.IsNullOrWhiteSpace(model.Logo)
? "" ? ""
: _fileService.GetFileUrl(Models.UploadScences.Category, model.Logo), : _fileService.GetFileUrl(UploadScences.Category, model.Logo),
Name = model.Name, Name = model.Name,
Order = model.Order, Order = model.Order,
ParentId = model.ParentId, ParentId = model.ParentId,

View File

@@ -41,13 +41,13 @@ public class ClaimsService : IClaimsService
return claimsIdentity; return claimsIdentity;
} }
public int GetCurrentUserId() public int? GetCurrentUserId()
{ {
var currUserId = _httpContextAccessor.HttpContext var currUserId = _httpContextAccessor.HttpContext
?.User.FindFirstValue(JwtRegisteredClaimNames.Sub); ?.User.FindFirstValue(JwtRegisteredClaimNames.Sub);
if (string.IsNullOrWhiteSpace(currUserId)) if (string.IsNullOrWhiteSpace(currUserId))
throw new InvalidOperationException("在错误的位置获取当前登录用户"); return null;
return Convert.ToInt32(currUserId); return Convert.ToInt32(currUserId);
} }

View File

@@ -1,4 +1,9 @@
using Microsoft.AspNetCore.Hosting; using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using StopShopping.Services.Extensions; using StopShopping.Services.Extensions;
using StopShopping.Services.Models; using StopShopping.Services.Models;
@@ -9,50 +14,66 @@ namespace StopShopping.Services.Implementions;
public class FileService : IFileService public class FileService : IFileService
{ {
public FileService(IOptions<AppOptions> appOptions, public FileService(
IWebHostEnvironment webHostEnvironment) IHttpClientFactory httpClientFactory,
IOptions<AppOptions> options,
ILogger<FileService> logger)
{ {
_appOptions = appOptions.Value; _fileClient = httpClientFactory.CreateClient(Consts.FILE_API_CLIENT_NAME);
_env = webHostEnvironment; _options = options.Value;
_logger = logger;
} }
private readonly AppOptions _appOptions; private readonly HttpClient _fileClient;
private readonly IWebHostEnvironment _env; private readonly AppOptions _options;
private readonly ILogger<FileService> _logger;
public async Task<ApiResponse<FileUpload>> UploadFileAsync(UploadParams payload) private const string UPLOAD_PATH = "/upload";
public async Task<ApiResponse<FileUploadResp>> UploadFileAsync(UploadParams payload)
{ {
var newName = Guid.NewGuid().ToString("N").ToLower(); try
var extension = Path.GetExtension(payload.File!.FileName);
var newFullName = $"{newName}{extension}";
var relativeToRootPath = GetRelativeToRootPath(payload.Scences, newFullName);
var targetPath = Path.Combine(_env.WebRootPath, GetRelativeToRootPath(payload.Scences));
if (!Directory.Exists(targetPath))
{ {
Directory.CreateDirectory(targetPath); var formData = new MultipartFormDataContent
}
using var file = new FileStream(
Path.Combine(_env.WebRootPath, relativeToRootPath),
FileMode.CreateNew,
FileAccess.Write);
await payload.File.CopyToAsync(file);
FileUpload result = new()
{ {
NewName = newFullName, { new StringContent(((int)payload.Scences).ToString()), nameof(payload.Scences) },
Url = GetFileUrl(payload.Scences, newFullName)
}; };
var fileContent = new StreamContent(payload.File!.OpenReadStream());
fileContent.Headers.ContentType = new MediaTypeHeaderValue(payload.File.ContentType);
formData.Add(fileContent, nameof(payload.File), payload.File.FileName);
return new ApiResponse<FileUpload>(result); var resp = await _fileClient.PostAsync(UPLOAD_PATH, formData);
if (resp.IsSuccessStatusCode)
{
var deserialized = await resp.Content.ReadFromJsonAsync<ApiResponse<FileUploadResp>>();
if (null == deserialized)
throw new JsonException("上传失败");
return deserialized;
}
else
{
var deserialized = await resp.Content.ReadFromJsonAsync<ProblemDetails>();
if (null == deserialized)
throw new BadHttpRequestException("上传失败", (int)resp.StatusCode);
throw new BadHttpRequestException(
deserialized.Title ?? deserialized.Detail ?? "上传失败",
deserialized.Status ?? StatusCodes.Status400BadRequest);
}
}
catch (Exception e)
{
_logger.LogError(e, "上传失败");
throw;
}
} }
public string GetFileUrl(UploadScences scences, string fileName) public string GetFileUrl(UploadScences scences, string fileName)
{ {
var relativeToRootPath = GetRelativeToRootPath(scences, fileName); return string.IsNullOrWhiteSpace(fileName)
? ""
return $"{_appOptions.DomainPath}/{relativeToRootPath.Replace(Path.DirectorySeparatorChar, '/')}"; : $"{_options.FileApiDomain}/{GetRelativeToRootPath(scences, fileName)
.Replace(Path.DirectorySeparatorChar, '/')}";
} }
private string GetRelativeToRootPath(UploadScences scences, string fileName = "") private string GetRelativeToRootPath(UploadScences scences, string fileName = "")

View File

@@ -44,7 +44,9 @@ public class ProductService : IProductService
var qry = _dbContext.Products var qry = _dbContext.Products
.AsNoTracking() .AsNoTracking()
.Include(p => p.Category) .Include(p => p.Category)
.Where(p => !p.Deleted && p.UserId == userId); .Where(p => !p.Deleted);
if (userId.HasValue)
qry = qry.Where(p => p.UserId == userId);
if (model.CategoryId > 0) if (model.CategoryId > 0)
{ {
var categoryPath = (await _dbContext.Categories var categoryPath = (await _dbContext.Categories
@@ -74,7 +76,8 @@ public class ProductService : IProductService
public async Task<ApiResponse> EditAsync(EditProductParams model) public async Task<ApiResponse> EditAsync(EditProductParams model)
{ {
var userId = _claimsService.GetCurrentUserId(); var userId = _claimsService.GetCurrentUserId()!.Value;
EF.Models.Product? product = null; EF.Models.Product? product = null;
if (model.Id > 0) if (model.Id > 0)
{ {
@@ -112,7 +115,7 @@ public class ProductService : IProductService
public async Task<ApiResponse> DeleteAsync(ProductIdParams model) public async Task<ApiResponse> DeleteAsync(ProductIdParams model)
{ {
var userId = _claimsService.GetCurrentUserId(); var userId = _claimsService.GetCurrentUserId()!.Value;
var product = await _dbContext.Products var product = await _dbContext.Products
.FirstOrDefaultAsync(p => .FirstOrDefaultAsync(p =>
p.Id == model.ProductId p.Id == model.ProductId
@@ -169,11 +172,11 @@ public class ProductService : IProductService
CreateTime = product.CreateTime.ToFormatted(), CreateTime = product.CreateTime.ToFormatted(),
Description = product.Description, Description = product.Description,
Id = product.Id, Id = product.Id,
LogoUrl = _fileService.GetFileUrl(Models.UploadScences.Product, product.Logo),
MinimumUnit = product.MinimumUnit, MinimumUnit = product.MinimumUnit,
Name = product.Name, Name = product.Name,
UnitPrice = product.UnitPrice, UnitPrice = product.UnitPrice,
SoldAmount = product.SoldAmount, SoldAmount = product.SoldAmount,
LogoUrl = _fileService.GetFileUrl(Models.UploadScences.Product, product.Logo)
}; };
if (result is ProductInfo) if (result is ProductInfo)
{ {
@@ -184,10 +187,5 @@ public class ProductService : IProductService
return result; return result;
} }
private string BuildCategoryPath(int categoryId)
{
return $"/{categoryId}/";
}
#endregion #endregion
} }

View File

@@ -58,7 +58,7 @@ public class ReplyService : IReplyService
public async Task<ApiResponse> ReplyAsync(ReplyParams model) public async Task<ApiResponse> ReplyAsync(ReplyParams model)
{ {
var userId = _claimsService.GetCurrentUserId(); var userId = _claimsService.GetCurrentUserId()!.Value;
using var trans = await _dbContext.Database.BeginTransactionAsync(); using var trans = await _dbContext.Database.BeginTransactionAsync();
try try

View File

@@ -26,7 +26,7 @@ public class RequestService : IRequestService
public async Task<ApiResponse> PublishRequestAsync(CreateRequestParams model) public async Task<ApiResponse> PublishRequestAsync(CreateRequestParams model)
{ {
var serialNo = _serialNoGenerator.GenerateRequestNo(); var serialNo = _serialNoGenerator.GenerateRequestNo();
var userId = _claimService.GetCurrentUserId(); var userId = _claimService.GetCurrentUserId()!.Value;
EF.Models.Request req = new() EF.Models.Request req = new()
{ {
@@ -62,7 +62,7 @@ public class RequestService : IRequestService
public async Task<ApiResponse> DeleteRequestAsync(RequestIdParams model) public async Task<ApiResponse> DeleteRequestAsync(RequestIdParams model)
{ {
var userId = _claimService.GetCurrentUserId(); var userId = _claimService.GetCurrentUserId()!.Value;
var request = await _dbContext.Requests var request = await _dbContext.Requests
.Include(r => r.Replies) .Include(r => r.Replies)
@@ -126,7 +126,7 @@ public class RequestService : IRequestService
if (userRoles.HasValue) if (userRoles.HasValue)
{ {
var userId = _claimService.GetCurrentUserId(); var userId = _claimService.GetCurrentUserId()!.Value;
qry = userRoles.Value switch qry = userRoles.Value switch
{ {
UserRoles.Seller => qry.Where(q => q.Replies.Any(r => r.UserId == userId)), UserRoles.Seller => qry.Where(q => q.Replies.Any(r => r.UserId == userId)),

View File

@@ -150,7 +150,7 @@ public class UserService : IUserService
public async Task<ApiResponse> ChangePasswordAsync(ChangePasswordParams model) public async Task<ApiResponse> ChangePasswordAsync(ChangePasswordParams model)
{ {
int userId = _claimsService.GetCurrentUserId(); int userId = _claimsService.GetCurrentUserId()!.Value;
var user = await _dbContext.Users var user = await _dbContext.Users
.FirstAsync(u => u.Id == userId); .FirstAsync(u => u.Id == userId);
@@ -166,7 +166,7 @@ public class UserService : IUserService
public async Task<ApiResponse<User>> GetUserInfoAsync() public async Task<ApiResponse<User>> GetUserInfoAsync()
{ {
var userId = _claimsService.GetCurrentUserId(); var userId = _claimsService.GetCurrentUserId()!.Value;
var model = await _dbContext.Users var model = await _dbContext.Users
.FirstAsync(u => u.Id == userId); .FirstAsync(u => u.Id == userId);
@@ -187,7 +187,7 @@ public class UserService : IUserService
public async Task<ApiResponse> EditAsync(EditUserParams model) public async Task<ApiResponse> EditAsync(EditUserParams model)
{ {
int userId = _claimsService.GetCurrentUserId(); int userId = _claimsService.GetCurrentUserId()!.Value;
var user = await _dbContext.Users.FirstAsync(u => u.Id == userId); var user = await _dbContext.Users.FirstAsync(u => u.Id == userId);
if (!string.IsNullOrWhiteSpace(model.AvatarFileName)) if (!string.IsNullOrWhiteSpace(model.AvatarFileName))
@@ -205,7 +205,7 @@ public class UserService : IUserService
public ApiResponse<List<Address>> GetAddresses() public ApiResponse<List<Address>> GetAddresses()
{ {
var userId = _claimsService.GetCurrentUserId(); var userId = _claimsService.GetCurrentUserId()!.Value;
var addresses = _dbContext.Addresses var addresses = _dbContext.Addresses
.Where(a => a.UserId == userId) .Where(a => a.UserId == userId)
@@ -221,7 +221,7 @@ public class UserService : IUserService
{ {
EF.Models.Address? address = null; EF.Models.Address? address = null;
var userId = _claimsService.GetCurrentUserId(); var userId = _claimsService.GetCurrentUserId()!.Value;
if (model.Id.HasValue && model.Id > 0) if (model.Id.HasValue && model.Id > 0)
{ {
@@ -253,7 +253,7 @@ public class UserService : IUserService
public async Task<ApiResponse> DeleteAddressAsync(int id) public async Task<ApiResponse> DeleteAddressAsync(int id)
{ {
var userId = _claimsService.GetCurrentUserId(); var userId = _claimsService.GetCurrentUserId()!.Value;
await _dbContext.Addresses await _dbContext.Addresses
.Where(a => a.Id == id && a.UserId == userId) .Where(a => a.Id == id && a.UserId == userId)
.ExecuteDeleteAsync(); .ExecuteDeleteAsync();

View File

@@ -0,0 +1,22 @@
using System.ComponentModel.DataAnnotations;
namespace StopShopping.Services.Models.Req;
/// <summary>
/// url查询参数
/// </summary>
/// <value></value>
public record NameUrlParams
{
/// <summary>
/// 场景
/// </summary>
/// <value></value>
public UploadScences Scences { get; set; }
/// <summary>
/// 文件名
/// </summary>
/// <value></value>
[Required]
public string[] Names { get; set; } = [];
}

View File

@@ -15,10 +15,9 @@ public record UploadParams
/// <value></value> /// <value></value>
public UploadScences Scences { get; set; } public UploadScences Scences { get; set; }
/// <summary> /// <summary>
/// 文件 /// 文件,2m,图片格式
/// </summary> /// </summary>
/// <value></value> /// <value></value>
[Required] [Required]
[ImageFileValidation(2 * 1024 * 1024)]
public IFormFile? File { get; set; } public IFormFile? File { get; set; }
} }

View File

@@ -3,7 +3,7 @@ namespace StopShopping.Services.Models.Resp;
/// <summary> /// <summary>
/// 文件上传结果 /// 文件上传结果
/// </summary> /// </summary>
public class FileUpload public class FileUploadResp
{ {
/// <summary> /// <summary>
/// 新名,上传后重命名 /// 新名,上传后重命名

View File

@@ -0,0 +1,14 @@
namespace StopShopping.Services.Models.Resp;
/// <summary>
/// url查询响应
/// </summary>
/// <value></value>
public class NameUrlResp
{
/// <summary>
/// 文件名:文件链接
/// </summary>
/// <value></value>
public KeyValuePair<string, string>[] NameUrls { get; set; } = [];
}

View File

@@ -10,7 +10,6 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="FileSignatures" Version="7.0.0" />
<PackageReference Include="Nanoid" Version="3.1.0" /> <PackageReference Include="Nanoid" Version="3.1.0" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.16.0" /> <PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.16.0" />
</ItemGroup> </ItemGroup>

View File

@@ -1,7 +1,9 @@
<Solution> <Solution>
<Project Path="ef_scaffolds/ef_scaffolds.csproj" /> <Project Path="ef_scaffolds/ef_scaffolds.csproj" />
<Project Path="StopShopping.AdminApi/StopShopping.AdminApi.csproj" />
<Project Path="StopShopping.Api/StopShopping.Api.csproj" /> <Project Path="StopShopping.Api/StopShopping.Api.csproj" />
<Project Path="StopShopping.EF/StopShopping.EF.csproj" /> <Project Path="StopShopping.EF/StopShopping.EF.csproj" />
<Project Path="StopShopping.FileApi/StopShopping.FileApi.csproj" />
<Project Path="StopShopping.OpenPlatform/StopShopping.OpenPlatform.csproj" /> <Project Path="StopShopping.OpenPlatform/StopShopping.OpenPlatform.csproj" />
<Project Path="StopShopping.Services.Test/StopShopping.Services.Test.csproj" /> <Project Path="StopShopping.Services.Test/StopShopping.Services.Test.csproj" />
<Project Path="StopShopping.Services/StopShopping.Services.csproj" /> <Project Path="StopShopping.Services/StopShopping.Services.csproj" />