commit 2c44b3a4b2086971a84e853bc47a524ea772beda Author: GaoXiang Date: Wed Mar 25 14:55:34 2026 +0800 ✨ diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..5605837 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*.cs] +# switch expression必须覆盖所有成员 +dotnet_diagnostic.IDE0072.severity = error +dotnet_diagnostic.IDE0010.severity = error + +# 非必要的using +dotnet_diagnostic.IDE0005.severity = warning diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5c35998 --- /dev/null +++ b/.gitignore @@ -0,0 +1,487 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from `dotnet new gitignore` + +# dotenv files +.env + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET +project.lock.json +project.fragment.lock.json +artifacts/ + +# Tye +.tye/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +# but not Directory.Build.rsp, as it configures directory-level build defaults +!Directory.Build.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.tlog +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio 6 auto-generated project file (contains which files were open etc.) +*.vbp + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files +*.ncb +*.aps + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# Visual Studio History (VSHistory) files +.vshistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for Visual Studio Code +.history/ + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp + +# JetBrains Rider +*.sln.iml +.idea/ + +## +## Visual studio for Mac +## + + +# globs +Makefile.in +*.userprefs +*.usertasks +config.make +config.status +aclocal.m4 +install-sh +autom4te.cache/ +*.tar.gz +tarballs/ +test-results/ + +# content below from: https://github.com/github/gitignore/blob/main/Global/macOS.gitignore +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +# content below from: https://github.com/github/gitignore/blob/main/Global/Windows.gitignore +# Windows thumbnail cache files +Thumbs.db +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# Vim temporary swap files +*.swp + +doc/password.txt +StopShopping.Api/wwwroot/images/ +appsettings.json +appsettings.Development.json \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..4bfcf10 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,35 @@ +{ + "version": "0.2.0", + "configurations": [ + { + // Use IntelliSense to find out which attributes exist for C# debugging + // Use hover for the description of the existing attributes + // For further information visit https://github.com/dotnet/vscode-csharp/blob/main/debugger-launchjson.md. + "name": ".NET Core Launch (web)", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build", + // If you have changed target frameworks, make sure to update the program path. + "program": "${workspaceFolder}/StopShopping.Api/bin/Debug/net10.0/StopShopping.Api.dll", + "args": [], + "cwd": "${workspaceFolder}/StopShopping.Api", + "stopAtEntry": false, + // Enable launching a web browser when ASP.NET Core starts. For more information: https://aka.ms/VSCode-CS-LaunchJson-WebBrowser + "serverReadyAction": { + "action": "openExternally", + "pattern": "\\bNow listening on:\\s+(https?://\\S+)" + }, + "env": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "sourceFileMap": { + "/Views": "${workspaceFolder}/Views" + } + }, + { + "name": ".NET Core Attach", + "type": "coreclr", + "request": "attach" + } + ] +} \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..a5e3646 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,41 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "build", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/StopShopping.Api/StopShopping.Api.csproj", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary;ForceNoAlign" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "publish", + "command": "dotnet", + "type": "process", + "args": [ + "publish", + "${workspaceFolder}/StopShopping.Api/StopShopping.Api.csproj", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary;ForceNoAlign" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "watch", + "command": "dotnet", + "type": "process", + "args": [ + "watch", + "run", + "--project", + "${workspaceFolder}/StopShopping.Api/StopShopping.Api.csproj" + ], + "problemMatcher": "$msCompile" + } + ] +} \ No newline at end of file diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..5bee49f --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,9 @@ + + + true + CS1591;CA1707;CA1304;CA1848;CA1305;CA1311;CA1822 + true + latest + Recommended + + \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..6526ffc --- /dev/null +++ b/README.md @@ -0,0 +1,60 @@ +# 简介 + +**停止购物**不同于传统由商户上架商品消费者进行下单的购物模式,提供相反的由消费者发布需求商户竞标的商品、服务交易的方式。 + +## 系统角色 +- 管理员 +后台管理员,维护系统的基础信息。 +- 用户 +用户同时是买家和卖家,用户可以在客户端设置偏好的系统功能(买家/卖家)。 + +## 业务流程 +卖家:上架商品➡️寻找需求➡️推荐商品➡️⬇️➡️➡️发货➡️⬇️ +买家:发布需求➡️➡️➡️➡️➡️➡️➡️➡️➡️️️️️下单付款➡️⬆️➡️➡️收货 + +## 系统功能(用户端) +- **通用功能** + + [x] 注册 + 账号、密码、默认角色(买家、卖家) + + [x] 登录 + + [x] 退出登录 + + 个人中心 + * [x] 昵称、头像等维护 + * [x] 收货地址维护 + * [x] 角色切换(买家版、卖家版) +- **作为卖家时:** + + 上架商品 + * [x] 商品基本信息维护 + * [x] 商品详情维护 + + 浏览需求 + * [ ] 分类检索 + * [ ] 关键词检索 + * [ ] 地区检索 + * [ ] 排序展示(发布时间、距离、竞标者数量) + + 竞标 + * [ ] 详情页展示(买家信息、需求描述、竞标者列表) + * [ ] 竞标,选择商品 + + 订单管理 + * [ ] 竞标中:查看详情 + * [ ] 已中标:查看详情、发货、在线沟通 + * [ ] 已发货:查看详情、物流 + * [ ] 已完成(买家已收货):查看详情、评价买家 + * [ ] 已关闭(未中标):查看详情、删除 +- **作为买家时:** + + 发布需求 + * [ ] 发布页:分类、简短描述、详细描述、预算 + + 订单管理 + * [ ] 已发布:查看详情、修改详情 + * [ ] 有竞标:查看详情、查看竞标详情、接受 + * [ ] 待发货:查看详情、留言、在线沟通 + * [ ] 已发货:查看详情、物流、确认收货 + * [ ] 已完成:评价卖家 + +## 系统功能(后台端) +- **首页** + + [ ] 交易数据可视化 +- **基础信息** + + [x] 商品分类维护 + + [x] 行政区划管理 +- **用户管理** + + [ ] 群发公告 diff --git a/StopShopping.Api/Extensions/AuthExtensions.cs b/StopShopping.Api/Extensions/AuthExtensions.cs new file mode 100644 index 0000000..24ba514 --- /dev/null +++ b/StopShopping.Api/Extensions/AuthExtensions.cs @@ -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.Api.Extensions; + +public static class JwtExtensions +{ + public static IServiceCollection AddAuthServices(this IServiceCollection services, IConfiguration jwtOptions) + { + services.Configure(jwtOptions); + services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(jwtBearerOptions => + { + var jwtConfiguration = jwtOptions.Get()!; + + 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(); + 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; + } +} \ No newline at end of file diff --git a/StopShopping.Api/Extensions/BearerOpenApiDocumentTransformer.cs b/StopShopping.Api/Extensions/BearerOpenApiDocumentTransformer.cs new file mode 100644 index 0000000..d52a848 --- /dev/null +++ b/StopShopping.Api/Extensions/BearerOpenApiDocumentTransformer.cs @@ -0,0 +1,37 @@ +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.OpenApi; +using Microsoft.OpenApi; + +namespace StopShopping.Api.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(); + document.Components.SecuritySchemes[JwtBearerDefaults.AuthenticationScheme] = bearerOpenApiSecurityScheme; + + var securityRequirement = new OpenApiSecurityRequirement + { + { + new OpenApiSecuritySchemeReference(JwtBearerDefaults.AuthenticationScheme), + new List() + } + }; + + document.Security ??= []; + document.Security.Add(securityRequirement); + + return Task.CompletedTask; + } +} diff --git a/StopShopping.Api/Extensions/CommonServiceCollections.cs b/StopShopping.Api/Extensions/CommonServiceCollections.cs new file mode 100644 index 0000000..cdecf48 --- /dev/null +++ b/StopShopping.Api/Extensions/CommonServiceCollections.cs @@ -0,0 +1,79 @@ +using System.Text.Json.Serialization; +using StopShopping.Api.Middlewares; +using StopShopping.Services; +using StopShopping.Services.Extensions; + +namespace StopShopping.Api.Extensions; + +public static class CommonServiceCollections +{ + public static IServiceCollection AddCommonServices( + this IServiceCollection services, + string corsPolicy, + IConfigurationSection jwtConfiguration, + IConfigurationSection appConfiguration, + bool isDevelopment) + { + var appOptions = appConfiguration.Get(); + 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(); + options.AddSchemaTransformer(); + }); + 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(); + + options.HeaderName = appOptions!.CSRFHeaderName; + options.Cookie.MaxAge = TimeSpan.FromSeconds(jwtOptions!.RefreshTokenExpiresIn); + options.Cookie.HttpOnly = true; + options.Cookie.Name = appOptions.CSRFCookieName; + if (isDevelopment) + { + options.Cookie.SameSite = SameSiteMode.Lax; + } + else + { + options.Cookie.SameSite = SameSiteMode.None; + options.Cookie.SecurePolicy = CookieSecurePolicy.Always; + options.Cookie.Domain = appOptions.CookieDomain; + } + }); + + return services; + } +} diff --git a/StopShopping.Api/Extensions/EnumOpenApiSchemaTransformer.cs b/StopShopping.Api/Extensions/EnumOpenApiSchemaTransformer.cs new file mode 100644 index 0000000..0714534 --- /dev/null +++ b/StopShopping.Api/Extensions/EnumOpenApiSchemaTransformer.cs @@ -0,0 +1,49 @@ +using System.ComponentModel; +using System.Text.Json.Nodes; +using Microsoft.AspNetCore.OpenApi; +using Microsoft.OpenApi; + +namespace StopShopping.Api.Extensions; + +/// +/// 处理enum类型openapi显示 +/// +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() + .Select(v => JsonNode.Parse(Convert.ToInt32(v).ToString())!) + .ToList(); + + schema.Enum = enumValues; + + var enumNames = Enum.GetNames(context.JsonTypeInfo.Type); + schema.Extensions ??= new Dictionary(); + 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; + } +} \ No newline at end of file diff --git a/StopShopping.Api/Extensions/HttpExtensions.cs b/StopShopping.Api/Extensions/HttpExtensions.cs new file mode 100644 index 0000000..b1e4bb8 --- /dev/null +++ b/StopShopping.Api/Extensions/HttpExtensions.cs @@ -0,0 +1,36 @@ +using StopShopping.Services.Extensions; + +namespace Microsoft.AspNetCore.Http; + +public static class HttpExtensions +{ + public const string REFRESH_TOKEN_COOKIE_KEY = "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, + }; + if (!env.IsDevelopment()) + { + options.SameSite = SameSiteMode.None; + options.Secure = true; + options.Domain = appOptions.CookieDomain; + } + + cookies.Append( + REFRESH_TOKEN_COOKIE_KEY, + token, + options); + + return cookies; + } +} diff --git a/StopShopping.Api/Extensions/MiddlewareExtensions.cs b/StopShopping.Api/Extensions/MiddlewareExtensions.cs new file mode 100644 index 0000000..858b816 --- /dev/null +++ b/StopShopping.Api/Extensions/MiddlewareExtensions.cs @@ -0,0 +1,25 @@ +using StopShopping.Api.Middlewares; + +namespace Microsoft.AspNetCore.Builder; + +public static class MiddlewareExtensions +{ + public static IApplicationBuilder UseGlobalExceptionHandler(this IApplicationBuilder applicationBuilder) + { + applicationBuilder.UseMiddleware(); + + return applicationBuilder; + } + + /// + /// 解决开发时多客户端localhost端口串cookie的问题 + /// + /// + /// + public static IApplicationBuilder UseDevelopmentCookie(this IApplicationBuilder applicationBuilder) + { + applicationBuilder.UseMiddleware(); + + return applicationBuilder; + } +} diff --git a/StopShopping.Api/Middlewares/DevelopmentCookieMiddleware.cs b/StopShopping.Api/Middlewares/DevelopmentCookieMiddleware.cs new file mode 100644 index 0000000..41a038d --- /dev/null +++ b/StopShopping.Api/Middlewares/DevelopmentCookieMiddleware.cs @@ -0,0 +1,142 @@ +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 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 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()); + } +} diff --git a/StopShopping.Api/Middlewares/GlobalExceptionHandlerMiddleware.cs b/StopShopping.Api/Middlewares/GlobalExceptionHandlerMiddleware.cs new file mode 100644 index 0000000..6043340 --- /dev/null +++ b/StopShopping.Api/Middlewares/GlobalExceptionHandlerMiddleware.cs @@ -0,0 +1,100 @@ +using Microsoft.AspNetCore.Antiforgery; +using Microsoft.AspNetCore.Mvc; +using StopShopping.Services.Models.Resp; + +namespace StopShopping.Api.Middlewares; + +public class GlobalExceptionHandlerMiddleware +{ + public GlobalExceptionHandlerMiddleware(RequestDelegate requestDelegate, + ILogger logger, + IProblemDetailsService problemDetailsService) + { + _next = requestDelegate; + _logger = logger; + _problemDetailsService = problemDetailsService; + } + + private readonly RequestDelegate _next; + private readonly ILogger _logger; + private readonly IProblemDetailsService _problemDetailsService; + + public async Task InvokeAsync(HttpContext httpContext) + { + try + { + await _next(httpContext); + } + catch (BadHttpRequestException ex) when (ex.InnerException is AntiforgeryValidationException) + { + var problemDetails = new ProblemDetails + { + Detail = ex.InnerException.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 + }); + } + 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, + }); + } + } +} diff --git a/StopShopping.Api/Middlewares/ProblemDetailsExtensions.cs b/StopShopping.Api/Middlewares/ProblemDetailsExtensions.cs new file mode 100644 index 0000000..de6d323 --- /dev/null +++ b/StopShopping.Api/Middlewares/ProblemDetailsExtensions.cs @@ -0,0 +1,23 @@ +using Microsoft.AspNetCore.Mvc; + +namespace StopShopping.Api.Middlewares; + +public static class ProblemDetailsExtensions +{ + private const string CODE_FIELD = "code"; + public static ProblemDetails AddErrorCode(this ProblemDetails problemDetails, ProblemDetailsCodes code) + { + problemDetails.Extensions ??= new Dictionary(); + + problemDetails.Extensions.Add(CODE_FIELD, (int)code); + return problemDetails; + } +} + +public enum ProblemDetailsCodes +{ + CsrfValidationFailed = 1000, + ParametersValidationFailed = 1001, + BadParameters = 1002, + ServerError = 1003, +} diff --git a/StopShopping.Api/Program.cs b/StopShopping.Api/Program.cs new file mode 100644 index 0000000..658718b --- /dev/null +++ b/StopShopping.Api/Program.cs @@ -0,0 +1,84 @@ +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.EntityFrameworkCore; +using Scalar.AspNetCore; +using Serilog; +using StopShopping.Api.Extensions; +using StopShopping.Api.Routes; +using StopShopping.Api.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(); + /**********************************************************************/ + var app = builder.Build(); + + if (app.Environment.IsDevelopment()) + { + app.UseDevelopmentCookie(); + app.MapOpenApi(); + app.MapScalarApiReference(options => + { + options.AddPreferredSecuritySchemes(JwtBearerDefaults.AuthenticationScheme); + }); + } + + app.UseGlobalExceptionHandler(); + + app.UseRouting(); + + app.UseCors(CORS_POLICY); + + app.UseAuthentication(); + + app.UseAuthorization(); + + app.MapStaticAssets().ShortCircuit(); + + Root.MapRoutes(app); + + app.UseAntiforgery(); + + app.Run(); +} +catch (Exception ex) +{ + Log.Fatal(ex, "启动异常!"); +} +finally +{ + Log.CloseAndFlush(); +} + diff --git a/StopShopping.Api/Properties/launchSettings.json b/StopShopping.Api/Properties/launchSettings.json new file mode 100644 index 0000000..cebad7b --- /dev/null +++ b/StopShopping.Api/Properties/launchSettings.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "scalar", + "applicationUrl": "http://localhost:5162", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "scalar", + "applicationUrl": "https://localhost:7121;http://localhost:5162", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/StopShopping.Api/Routes/Admin.cs b/StopShopping.Api/Routes/Admin.cs new file mode 100644 index 0000000..dbcfc36 --- /dev/null +++ b/StopShopping.Api/Routes/Admin.cs @@ -0,0 +1,44 @@ +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> SignInAsync( + SignInParams model, + IUserService userService, + HttpContext httpContext, + IWebHostEnvironment env, + IOptions options) + { + var result = await userService.SignInAdminAsync(model); + var resp = new ApiResponse + { + 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; + } +} diff --git a/StopShopping.Api/Routes/Category.cs b/StopShopping.Api/Routes/Category.cs new file mode 100644 index 0000000..c6d79bd --- /dev/null +++ b/StopShopping.Api/Routes/Category.cs @@ -0,0 +1,60 @@ +using StopShopping.Services; +using StopShopping.Services.Models.Req; +using StopShopping.Services.Models.Resp; + +namespace StopShopping.Api.Routes; + +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) + { + 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> GetTree( + ICategoryService categoryService + ) + { + return categoryService.GetCategoriesTree(); + } + + private static async Task> EditCategoryAsync( + EditCategoryParams model, + ICategoryService categoryService) + { + return await categoryService.EditCategoryAsync(model); + } + + private static async Task ResortCategoryAsync( + ResortCategoryParams model, + ICategoryService categoryService) + { + return await categoryService.ResortCategoryAsync(model); + } + + private static async Task DeleteCategoryAsync( + CategoryIdParams model, + ICategoryService categoryService + ) + { + return await categoryService.DeleteCategoryAsync(model); + } +} diff --git a/StopShopping.Api/Routes/Common.cs b/StopShopping.Api/Routes/Common.cs new file mode 100644 index 0000000..6981023 --- /dev/null +++ b/StopShopping.Api/Routes/Common.cs @@ -0,0 +1,87 @@ +using Microsoft.AspNetCore.Antiforgery; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Net.Http.Headers; +using StopShopping.Services; +using StopShopping.Services.Models.Req; +using StopShopping.Services.Models.Resp; + +namespace StopShopping.Api.Routes; + +public static class Common +{ + public static RouteGroupBuilder MapCommon(this RouteGroupBuilder routes) + { + routes.MapPost("/common/upload", UploadAsync) + .WithTags(OpenApiTags.公用.ToString()); + + routes.MapPost("/common/refreshtoken", RefreshTokenAsync) + .AllowAnonymous() + .Produces>() + .WithTags(OpenApiTags.公用.ToString()); + + routes.MapPost("/common/signout", SignOutAsync) + .AllowAnonymous().WithTags(OpenApiTags.公用.ToString()); + + routes.MapPost("/common/antiforgery-token", AntiForgeryToken) + .WithTags(OpenApiTags.公用.ToString()); + + return routes; + } + + private static async Task> UploadAsync( + [FromForm] UploadParams payload, + IFileService fileService, + HttpContext httpContext) + { + return await fileService.UploadFileAsync(payload); + } + + private static ApiResponse AntiForgeryToken( + HttpContext httpContext, + IAntiforgery antiforgery) + { + var antiforgeryToken = antiforgery.GetAndStoreTokens(httpContext); + + return new ApiResponse(new AntiForgeryToken + { + Token = antiforgeryToken.RequestToken, + HeaderName = antiforgeryToken.HeaderName + }); + } + private static async Task 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)); + } + + public static async Task 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(); + } +} diff --git a/StopShopping.Api/Routes/District.cs b/StopShopping.Api/Routes/District.cs new file mode 100644 index 0000000..fc6597c --- /dev/null +++ b/StopShopping.Api/Routes/District.cs @@ -0,0 +1,34 @@ +using StopShopping.Services; +using StopShopping.Services.Models.Req; +using StopShopping.Services.Models.Resp; + +namespace StopShopping.Api.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> GetChildrenDistricts( + [AsParameters] DistrictParentIdParams model, + IDistrictService districtService + ) + { + return districtService.GetChildren(model); + } + + private static async Task>> GetTop3LevelDistrictsAsync( + IDistrictService districtService + ) + { + return await districtService.GetTop3LevelDistrictsAsync(); + } +} diff --git a/StopShopping.Api/Routes/Product.cs b/StopShopping.Api/Routes/Product.cs new file mode 100644 index 0000000..4458e6d --- /dev/null +++ b/StopShopping.Api/Routes/Product.cs @@ -0,0 +1,61 @@ +using StopShopping.Services; +using StopShopping.Services.Models.Req; +using StopShopping.Services.Models.Resp; + +namespace StopShopping.Api.Routes; + +/// +/// 商品相关路由 +/// +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>> + SearchProductsAsync( + [AsParameters] ProductSearchParms model, + IProductService productService + ) + { + return await productService.SearchAsync(model); + } + + private static ApiResponse Detail( + [AsParameters] ProductIdParams model, + IProductService productService) + { + return productService.Detail(model); + } + + private static async Task EditAsync( + EditProductParams model, + IProductService productService + ) + { + return await productService.EditAsync(model); + } + + private static async Task DeleteAsync( + ProductIdParams model, + IProductService productService + ) + { + return await productService.DeleteAsync(model); + } +} diff --git a/StopShopping.Api/Routes/Reply.cs b/StopShopping.Api/Routes/Reply.cs new file mode 100644 index 0000000..a96eb37 --- /dev/null +++ b/StopShopping.Api/Routes/Reply.cs @@ -0,0 +1,45 @@ +using StopShopping.Services; +using StopShopping.Services.Models.Req; +using StopShopping.Services.Models.Resp; + +namespace StopShopping.Api.Routes; + +public static class Reply +{ + public static RouteGroupBuilder MapReply(this RouteGroupBuilder routes) + { + routes.MapPost("/reply/post", SubmitReplyAsync) + .WithTags(OpenApiTags.竞标.ToString()); + + routes.MapGet("/reply/list", ListAsync) + .WithTags(OpenApiTags.竞标.ToString()); + + routes.MapGet("/reply/orders", OrderSearchAsync) + .WithTags(OpenApiTags.竞标.ToString()); + + return routes; + } + + private static async Task SubmitReplyAsync( + ReplyParams model, + IReplyService replyService) + { + return await replyService.ReplyAsync(model); + } + + private static async Task>> ListAsync( + [AsParameters] RequestIdParams model, + IReplyService replyService + ) + { + return await replyService.GetRepliesAsync(model); + } + + private static async Task>> OrderSearchAsync( + [AsParameters] RequestSearchWithStatusParams model, + IRequestService requestService + ) + { + return await requestService.RequestOrderSearchAsync(model); + } +} diff --git a/StopShopping.Api/Routes/Request.cs b/StopShopping.Api/Routes/Request.cs new file mode 100644 index 0000000..57905e9 --- /dev/null +++ b/StopShopping.Api/Routes/Request.cs @@ -0,0 +1,57 @@ +using StopShopping.Services; +using StopShopping.Services.Models.Req; +using StopShopping.Services.Models.Resp; + +namespace StopShopping.Api.Routes; + +public static class Request +{ + public static RouteGroupBuilder MapRequest(this RouteGroupBuilder routes) + { + routes.MapPost("/request/publish", PublishRequestAsync) + .WithTags(OpenApiTags.需求.ToString()); + + routes.MapGet("/request/search", SearchAsync) + .WithTags(OpenApiTags.需求.ToString()) + .AllowAnonymous(); + + routes.MapGet("/request/orders", OrderSearchAsync) + .WithTags(OpenApiTags.需求.ToString()); + + routes.MapPost("/request/delete", DeleteRequestAsync) + .WithTags(OpenApiTags.需求.ToString()); + + return routes; + } + + private static async Task PublishRequestAsync( + CreateRequestParams model, + IRequestService requestService) + { + return await requestService.PublishRequestAsync(model); + } + + private static async Task>> SearchAsync( + [AsParameters] RequestSearchParams model, + IRequestService requestService + ) + { + return await requestService.SearchAsync(model); + } + + private static async Task>> OrderSearchAsync( + [AsParameters] RequestSearchWithStatusParams model, + IRequestService requestService + ) + { + return await requestService.RequestOrderSearchAsync(model); + } + + private static async Task DeleteRequestAsync( + RequestIdParams model, + IRequestService requestService + ) + { + return await requestService.DeleteRequestAsync(model); + } +} diff --git a/StopShopping.Api/Routes/Root.cs b/StopShopping.Api/Routes/Root.cs new file mode 100644 index 0000000..d23525b --- /dev/null +++ b/StopShopping.Api/Routes/Root.cs @@ -0,0 +1,45 @@ + +using Scalar.AspNetCore; +using StopShopping.Services.Models; + +namespace StopShopping.Api.Routes; + +/// +/// 其他路由从RouteGroupBuilder扩展并添加到MapGroup之后 +/// +public static class Root +{ + public static void MapRoutes(WebApplication app) + { + app.MapGroup("") + .MapUser() + .MapProduct() + .MapRequest() + .MapReply() + .MapDistrict() + .WithDescription("用户端调用") + .RequireAuthorization(policy => policy.RequireRole(SystemRoles.User.ToString())); + app.MapGroup("") + .MapCommon() + .MapCategoryCommon() + .WithDescription("公共调用") + .RequireAuthorization(); + app.MapGroup("") + .MapAdmin() + .MapCategory() + .WithDescription("管理端调用") + .RequireAuthorization(policy => policy.RequireRole(SystemRoles.Admin.ToString())); + } +} + +public enum OpenApiTags +{ + 用户, + 分类, + 商品, + 需求, + 竞标, + 地址, + 管理员, + 公用, +} diff --git a/StopShopping.Api/Routes/User.cs b/StopShopping.Api/Routes/User.cs new file mode 100644 index 0000000..34c0828 --- /dev/null +++ b/StopShopping.Api/Routes/User.cs @@ -0,0 +1,95 @@ +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 User +{ + public static RouteGroupBuilder MapUser(this RouteGroupBuilder routes) + { + routes.MapPost("/user/signup", SignUpAsync) + .AllowAnonymous().WithTags(OpenApiTags.用户.ToString()); + + routes.MapPost("/user/signin", SignInAsync) + .AllowAnonymous().WithTags(OpenApiTags.用户.ToString()); + + routes.MapPost("/user/changepassword", ChangePasswordAsync) + .WithTags(OpenApiTags.用户.ToString()); + + routes.MapGet("/user/info", GetUserAsync) + .WithTags(OpenApiTags.用户.ToString()); + + routes.MapPost("/user/edit", EditUserAsync) + .WithTags(OpenApiTags.用户.ToString()); + + return routes; + } + + private static async Task SignUpAsync( + SignUpParams model, + IUserService userService) + { + await userService.SignUpAsync(model); + + return ApiResponse.Succed(); + } + + private static async Task> SignInAsync( + SignInParams model, + IUserService userService, + HttpContext httpContext, + IWebHostEnvironment env, + IOptions options) + { + var result = await userService.SignInAsync(model); + var resp = new ApiResponse + { + 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 ChangePasswordAsync( + ChangePasswordParams model, + IUserService userService, + HttpContext httpContext, + IAccessTokenService accessTokenService + ) + { + var resp = await userService.ChangePasswordAsync(model); + + if (resp.IsSucced) + await Common.SignOutAsync(httpContext, accessTokenService); + + return resp; + } + + private static async Task> GetUserAsync( + IUserService userService + ) + { + return await userService.GetUserInfoAsync(); + } + + private static async Task EditUserAsync( + EditUserParams model, + IUserService userService + ) + { + return await userService.EditAsync(model); + } +} diff --git a/StopShopping.Api/StopShopping.Api.csproj b/StopShopping.Api/StopShopping.Api.csproj new file mode 100644 index 0000000..8c2d8f3 --- /dev/null +++ b/StopShopping.Api/StopShopping.Api.csproj @@ -0,0 +1,26 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + + + + Never + + + + diff --git a/StopShopping.Api/Workers/DbSeederBackgroundService.cs b/StopShopping.Api/Workers/DbSeederBackgroundService.cs new file mode 100644 index 0000000..7c1dc7c --- /dev/null +++ b/StopShopping.Api/Workers/DbSeederBackgroundService.cs @@ -0,0 +1,27 @@ +using StopShopping.Services; + +namespace StopShopping.Api.Workers; + +public class DbSeederBackgroundService : BackgroundService +{ + public DbSeederBackgroundService(IServiceProvider sp) + { + _sp = sp; + } + + private readonly IServiceProvider _sp; + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + using var scope = _sp.CreateScope(); + using var scope1 = _sp.CreateScope(); + + var districtService = scope.ServiceProvider.GetRequiredService(); + var userService = scope1.ServiceProvider.GetRequiredService(); + + var districtTask = districtService.InitialDatabaseAsync(stoppingToken); + var adminTask = userService.GenerateDefaultAdminAsync(); + + await Task.WhenAll(districtTask, adminTask); + } +} \ No newline at end of file diff --git a/StopShopping.Api/appsettings.Template.json b/StopShopping.Api/appsettings.Template.json new file mode 100644 index 0000000..338ffe0 --- /dev/null +++ b/StopShopping.Api/appsettings.Template.json @@ -0,0 +1,36 @@ +{ + "ConnectionStrings": { + "StopShopping": "PG_CONNECTION_STRING" + }, + "JwtOptions": { + "ValidAudience": "StopShopping.Client", + "ValidIssuer": "StopShopping.Api", + "SigningKey": "128_BIT_SIGNING_KEY", + "AccessTokenExpiresIn": "3600", + "RefreshTokenExpiresIn": "604800" + }, + "AppOptions": { + "CookieDomain": ".example.com或者localhost(开发环境)", + "DomainPath": "https://example.com或者http://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" }] + } +} diff --git a/StopShopping.EF/Models/Address.cs b/StopShopping.EF/Models/Address.cs new file mode 100644 index 0000000..e074e2e --- /dev/null +++ b/StopShopping.EF/Models/Address.cs @@ -0,0 +1,67 @@ +using System; +using System.Collections.Generic; + +namespace StopShopping.EF.Models; + +/// +/// 收货地址 +/// +public partial class Address +{ + /// + /// 主键 + /// + public int Id { get; set; } + + /// + /// 用户表id + /// + public int UserId { get; set; } + + /// + /// 收货人地址 + /// + public string Name { get; set; } = null!; + + /// + /// 联系电话 + /// + public string Telephone { get; set; } = null!; + + /// + /// 自定义标签:学校、家等 + /// + public string? Tag { get; set; } + + /// + /// 是否默认地址 + /// + public bool Default { get; set; } + + /// + /// 行政区域id,表示省/直辖市 + /// + public int DistrictLevel1Id { get; set; } + + /// + /// 行政区域id,表示市/直辖市为空 + /// + public int? DistrictLevel2Id { get; set; } + + /// + /// 行政区域id,表示区 + /// + public int DistrictLevel3Id { get; set; } + + /// + /// 详细地址 + /// + public string? Detail { get; set; } + + /// + /// 行政区域id,表示街道/镇 + /// + public int DistrictLevel4Id { get; set; } + + public virtual User User { get; set; } = null!; +} diff --git a/StopShopping.EF/Models/Administrator.cs b/StopShopping.EF/Models/Administrator.cs new file mode 100644 index 0000000..c6119a4 --- /dev/null +++ b/StopShopping.EF/Models/Administrator.cs @@ -0,0 +1,40 @@ +using System; +using System.Collections.Generic; + +namespace StopShopping.EF.Models; + +/// +/// 管理员 +/// +public partial class Administrator +{ + /// + /// 主键 + /// + public int Id { get; set; } + + /// + /// 登录账号 + /// + public string Account { get; set; } = null!; + + /// + /// 昵称 + /// + public string NickName { get; set; } = null!; + + /// + /// 登录密码(已加密) + /// + public string Password { get; set; } = null!; + + /// + /// 创建时间 + /// + public DateTime CreateTime { get; set; } + + /// + /// 最后登录时间 + /// + public DateTime LastLoginTime { get; set; } +} diff --git a/StopShopping.EF/Models/Category.cs b/StopShopping.EF/Models/Category.cs new file mode 100644 index 0000000..41c713a --- /dev/null +++ b/StopShopping.EF/Models/Category.cs @@ -0,0 +1,54 @@ +using System; +using System.Collections.Generic; + +namespace StopShopping.EF.Models; + +/// +/// 商品分类 +/// +public partial class Category +{ + /// + /// 主键 + /// + public int Id { get; set; } + + /// + /// 父级id,顶级为0 + /// + public int ParentId { get; set; } + + /// + /// id路径枚举:/1/2/3/ + /// + public string Path { get; set; } = null!; + + /// + /// 名称 + /// + public string Name { get; set; } = null!; + + /// + /// 层级,从1开始 + /// + public short Level { get; set; } + + /// + /// logo图片名,后台生成地址 + /// + public string? Logo { get; set; } + + /// + /// 层级中序号 + /// + public short Order { get; set; } + + /// + /// 软删除标识 + /// + public bool Deleted { get; set; } + + public virtual ICollection Products { get; set; } = new List(); + + public virtual ICollection Requests { get; set; } = new List(); +} diff --git a/StopShopping.EF/Models/District.cs b/StopShopping.EF/Models/District.cs new file mode 100644 index 0000000..550f989 --- /dev/null +++ b/StopShopping.EF/Models/District.cs @@ -0,0 +1,60 @@ +using System; +using System.Collections.Generic; + +namespace StopShopping.EF.Models; + +/// +/// 行政区划 +/// +public partial class District +{ + /// + /// 主键 + /// + public int Id { get; set; } + + /// + /// 父级id,顶级时为0 + /// + public int ParentId { get; set; } + + /// + /// 编码 + /// + public string Code { get; set; } = null!; + + /// + /// 简称 + /// + public string? Name { get; set; } + + /// + /// 全称 + /// + public string FullName { get; set; } = null!; + + /// + /// 名称拼音 + /// + public string? Pinyin { get; set; } + + /// + /// 层级:1-省/直辖市,2-市/直辖市无,3-区,4-街道 + /// + public short Level { get; set; } + + /// + /// 经度 + /// + public string? Latitude { get; set; } + + /// + /// 纬度 + /// + public string? Longitude { get; set; } + + /// + /// 层级中序号 + /// + public short Order { get; set; } +} diff --git a/StopShopping.EF/Models/Logistic.cs b/StopShopping.EF/Models/Logistic.cs new file mode 100644 index 0000000..8a1c246 --- /dev/null +++ b/StopShopping.EF/Models/Logistic.cs @@ -0,0 +1,55 @@ +using System; +using System.Collections.Generic; + +namespace StopShopping.EF.Models; + +/// +/// 物流 +/// +public partial class Logistic +{ + /// + /// 主键 + /// + public int Id { get; set; } + + /// + /// 快递单号 + /// + public string OrderNo { get; set; } = null!; + + /// + /// 快递公司 + /// + public string Company { get; set; } = null!; + + /// + /// 物流状态:1-揽收,0-在途,5-派件,6-退回,4-退签,3-签收,2-疑难,7-转投,8。。。-清关 + /// + public short Status { get; set; } + + /// + /// 到达时间 + /// + public DateTime ArrivalTime { get; set; } + + /// + /// 到达地点 + /// + public string Location { get; set; } = null!; + + /// + /// 详情 + /// + public string Context { get; set; } = null!; + + /// + /// 需求id + /// + public int RequestId { get; set; } + + /// + /// 入库时间 + /// + public DateTime CreateTime { get; set; } +} diff --git a/StopShopping.EF/Models/Message.cs b/StopShopping.EF/Models/Message.cs new file mode 100644 index 0000000..b4fb6f3 --- /dev/null +++ b/StopShopping.EF/Models/Message.cs @@ -0,0 +1,45 @@ +using System; +using System.Collections.Generic; + +namespace StopShopping.EF.Models; + +/// +/// 用户消息 +/// +public partial class Message +{ + /// + /// 主键 + /// + public int Id { get; set; } + + /// + /// 发出用户id + /// + public int FromUserId { get; set; } + + /// + /// 接收用户id + /// + public int ToUserId { get; set; } + + /// + /// 发出时间 + /// + public DateTime SentTime { get; set; } + + /// + /// 是否已读 + /// + public bool Read { get; set; } + + /// + /// 是否已撤回 + /// + public bool Recalled { get; set; } + + /// + /// 内容 + /// + public string Content { get; set; } = null!; +} diff --git a/StopShopping.EF/Models/Notice.cs b/StopShopping.EF/Models/Notice.cs new file mode 100644 index 0000000..701dc16 --- /dev/null +++ b/StopShopping.EF/Models/Notice.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; + +namespace StopShopping.EF.Models; + +/// +/// 系统通知 +/// +public partial class Notice +{ + /// + /// 主键 + /// + public int Id { get; set; } + + /// + /// 内容 + /// + public string Content { get; set; } = null!; + + /// + /// 是否已审核 + /// + public bool Verified { get; set; } + + /// + /// 发布时间 + /// + public DateTime PublishTime { get; set; } +} diff --git a/StopShopping.EF/Models/OperateLog.cs b/StopShopping.EF/Models/OperateLog.cs new file mode 100644 index 0000000..c682ce9 --- /dev/null +++ b/StopShopping.EF/Models/OperateLog.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; + +namespace StopShopping.EF.Models; + +/// +/// 操作日志 +/// +public partial class OperateLog +{ + /// + /// 主键 + /// + public int Id { get; set; } + + /// + /// 操作类型:c-创建,u-修改,d-删除 + /// + public char OperateType { get; set; } + + /// + /// 目标类型:0-行政区划,1-商品分类。。。 + /// + public int TargetType { get; set; } + + /// + /// 关联id,目标表主键 + /// + public int RelatedId { get; set; } + + /// + /// 管理员id + /// + public int OperaterId { get; set; } + + /// + /// 操作描述 + /// + public string? Description { get; set; } + + /// + /// 操作时间 + /// + public DateTime OperateTime { get; set; } + + /// + /// 入库时间 + /// + public DateTime CreateTime { get; set; } +} diff --git a/StopShopping.EF/Models/Product.cs b/StopShopping.EF/Models/Product.cs new file mode 100644 index 0000000..1e3d8c3 --- /dev/null +++ b/StopShopping.EF/Models/Product.cs @@ -0,0 +1,79 @@ +using System; +using System.Collections.Generic; + +namespace StopShopping.EF.Models; + +/// +/// 卖家商品表 +/// +public partial class Product +{ + /// + /// 主键 + /// + public int Id { get; set; } + + /// + /// 系统唯一编号 + /// + public string SerialNo { get; set; } = null!; + + /// + /// 名称 + /// + public string Name { get; set; } = null!; + + /// + /// 商品描述 + /// + public string? Description { get; set; } + + /// + /// 商品图片 + /// + public string Logo { get; set; } = null!; + + /// + /// 商品分类id + /// + public int CategoryId { get; set; } + + /// + /// 用户id + /// + public int UserId { get; set; } + + /// + /// 最小销售单元 + /// + public string MinimumUnit { get; set; } = null!; + + /// + /// 单价 + /// + public decimal UnitPrice { get; set; } + + /// + /// 详情 + /// + public string? Detail { get; set; } + + /// + /// 已售数量 + /// + public int SoldAmount { get; set; } + + /// + /// 软删除标识 + /// + public bool Deleted { get; set; } + + /// + /// 添加时间 + /// + public DateTime CreateTime { get; set; } + + public virtual Category Category { get; set; } = null!; + + public virtual ICollection Replies { get; set; } = new List(); +} diff --git a/StopShopping.EF/Models/RefreshToken.cs b/StopShopping.EF/Models/RefreshToken.cs new file mode 100644 index 0000000..d392bc9 --- /dev/null +++ b/StopShopping.EF/Models/RefreshToken.cs @@ -0,0 +1,40 @@ +using System; +using System.Collections.Generic; + +namespace StopShopping.EF.Models; + +/// +/// 刷新令牌 +/// +public partial class RefreshToken +{ + /// + /// 主键 + /// + public long Id { get; set; } + + /// + /// 系统角色:a-管理员,u-用户 + /// + public char SystemRole { get; set; } + + /// + /// 令牌 + /// + public string Token { get; set; } = null!; + + /// + /// 创建时间 + /// + public DateTime CreateTime { get; set; } + + /// + /// 失效时间 + /// + public DateTime ExpiresAt { get; set; } + + /// + /// 用户/管理员id + /// + public int UserId { get; set; } +} diff --git a/StopShopping.EF/Models/Reply.cs b/StopShopping.EF/Models/Reply.cs new file mode 100644 index 0000000..12971fe --- /dev/null +++ b/StopShopping.EF/Models/Reply.cs @@ -0,0 +1,61 @@ +using System; +using System.Collections.Generic; + +namespace StopShopping.EF.Models; + +/// +/// 竞标表 +/// +public partial class Reply +{ + /// + /// 主键 + /// + public int Id { get; set; } + + /// + /// 商品id + /// + public int ProductId { get; set; } + + /// + /// 数量 + /// + public int Amount { get; set; } + + /// + /// 价格,自动计算的价格(product.unit_price * amount)之后的优惠价格 + /// + public decimal Price { get; set; } + + /// + /// 竞标者id + /// + public int UserId { get; set; } + + /// + /// 回应时间 + /// + public DateTime ReplyTime { get; set; } + + /// + /// 需求id + /// + public int RequestId { get; set; } + + /// + /// 留言 + /// + public string? Memo { get; set; } + + /// + /// 是否已拒绝 + /// + public bool Rejected { get; set; } + + public virtual Product Product { get; set; } = null!; + + public virtual Request Request { get; set; } = null!; + + public virtual User User { get; set; } = null!; +} diff --git a/StopShopping.EF/Models/Request.cs b/StopShopping.EF/Models/Request.cs new file mode 100644 index 0000000..9f4b3e9 --- /dev/null +++ b/StopShopping.EF/Models/Request.cs @@ -0,0 +1,66 @@ +using System; +using System.Collections.Generic; + +namespace StopShopping.EF.Models; + +/// +/// 用户需求 +/// +public partial class Request +{ + /// + /// 主键 + /// + public int Id { get; set; } + + /// + /// 需求单号,系统唯一,后台生成 + /// + public string SerialNo { get; set; } = null!; + + /// + /// 名称 + /// + public string Name { get; set; } = null!; + + /// + /// 需求描述 + /// + public string? Description { get; set; } + + /// + /// 商品分类id + /// + public int CategoryId { get; set; } + + /// + /// 状态:0-发布,1-有竞标,2-待发货,3-待收货,4-已完成,5-已评价 + /// + public short Status { get; set; } + + /// + /// 发布者id + /// + public int PublisherId { get; set; } + + /// + /// 发布时间 + /// + public DateTime PublishTime { get; set; } + + /// + /// 截止日期 + /// + public DateOnly Deadline { get; set; } + + /// + /// 是否已删除 + /// + public bool Deleted { get; set; } + + public virtual Category Category { get; set; } = null!; + + public virtual User Publisher { get; set; } = null!; + + public virtual ICollection Replies { get; set; } = new List(); +} diff --git a/StopShopping.EF/Models/User.cs b/StopShopping.EF/Models/User.cs new file mode 100644 index 0000000..807f48d --- /dev/null +++ b/StopShopping.EF/Models/User.cs @@ -0,0 +1,61 @@ +using System; +using System.Collections.Generic; + +namespace StopShopping.EF.Models; + +/// +/// 用户 +/// +public partial class User +{ + /// + /// 主键 + /// + public int Id { get; set; } + + /// + /// 登录账号 + /// + public string Account { get; set; } = null!; + + /// + /// 昵称 + /// + public string NickName { get; set; } = null!; + + /// + /// 头像图片名,后台生成链接 + /// + public string? Avatar { get; set; } + + /// + /// 当前角色:c-买家,s-卖家 + /// + public char CurrentRole { get; set; } + + /// + /// 电话 + /// + public string? Telephone { get; set; } + + /// + /// 密码(已加密) + /// + public string Password { get; set; } = null!; + + /// + /// 创建时间 + /// + public DateTime CreateTime { get; set; } + + /// + /// 最后登录时间 + /// + public DateTime? LastLoginTime { get; set; } + + public virtual ICollection
Addresses { get; set; } = new List
(); + + public virtual ICollection Replies { get; set; } = new List(); + + public virtual ICollection Requests { get; set; } = new List(); +} diff --git a/StopShopping.EF/StopShopping.EF.csproj b/StopShopping.EF/StopShopping.EF.csproj new file mode 100644 index 0000000..74a5a82 --- /dev/null +++ b/StopShopping.EF/StopShopping.EF.csproj @@ -0,0 +1,14 @@ + + + + net10.0 + enable + enable + IDE0005;CS1591 + + + + + + + diff --git a/StopShopping.EF/StopShoppingContext.cs b/StopShopping.EF/StopShoppingContext.cs new file mode 100644 index 0000000..1d9a312 --- /dev/null +++ b/StopShopping.EF/StopShoppingContext.cs @@ -0,0 +1,597 @@ +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore; +using StopShopping.EF.Models; + +namespace StopShopping.EF; + +public partial class StopShoppingContext : DbContext +{ + public StopShoppingContext(DbContextOptions options) + : base(options) + { + } + + public virtual DbSet
Addresses { get; set; } + + public virtual DbSet Administrators { get; set; } + + public virtual DbSet Categories { get; set; } + + public virtual DbSet Districts { get; set; } + + public virtual DbSet Logistics { get; set; } + + public virtual DbSet Messages { get; set; } + + public virtual DbSet Notices { get; set; } + + public virtual DbSet OperateLogs { get; set; } + + public virtual DbSet Products { get; set; } + + public virtual DbSet RefreshTokens { get; set; } + + public virtual DbSet Replies { get; set; } + + public virtual DbSet Requests { get; set; } + + public virtual DbSet Users { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity
(entity => + { + entity.HasKey(e => e.Id).HasName("address_pkey"); + + entity.ToTable("address", tb => tb.HasComment("收货地址")); + + entity.Property(e => e.Id) + .HasComment("主键") + .UseIdentityAlwaysColumn() + .HasColumnName("id"); + entity.Property(e => e.Default) + .HasComment("是否默认地址") + .HasColumnName("default"); + entity.Property(e => e.Detail) + .HasMaxLength(200) + .HasComment("详细地址") + .HasColumnName("detail"); + entity.Property(e => e.DistrictLevel1Id) + .HasComment("行政区域id,表示省/直辖市") + .HasColumnName("district_level1_id"); + entity.Property(e => e.DistrictLevel2Id) + .HasComment("行政区域id,表示市/直辖市为空") + .HasColumnName("district_level2_id"); + entity.Property(e => e.DistrictLevel3Id) + .HasComment("行政区域id,表示区") + .HasColumnName("district_level3_id"); + entity.Property(e => e.DistrictLevel4Id) + .HasComment("行政区域id,表示街道/镇") + .HasColumnName("district_level4_id"); + entity.Property(e => e.Name) + .HasMaxLength(20) + .HasComment("收货人地址") + .HasColumnName("name"); + entity.Property(e => e.Tag) + .HasMaxLength(20) + .HasComment("自定义标签:学校、家等") + .HasColumnName("tag"); + entity.Property(e => e.Telephone) + .HasMaxLength(20) + .HasComment("联系电话") + .HasColumnName("telephone"); + entity.Property(e => e.UserId) + .HasComment("用户表id") + .HasColumnName("user_id"); + + entity.HasOne(d => d.User).WithMany(p => p.Addresses) + .HasForeignKey(d => d.UserId) + .OnDelete(DeleteBehavior.ClientSetNull) + .HasConstraintName("fk_useraddress"); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id).HasName("administrator_pkey"); + + entity.ToTable("administrator", tb => tb.HasComment("管理员")); + + entity.Property(e => e.Id) + .HasComment("主键") + .UseIdentityAlwaysColumn() + .HasColumnName("id"); + entity.Property(e => e.Account) + .HasMaxLength(20) + .HasComment("登录账号") + .HasColumnName("account"); + entity.Property(e => e.CreateTime) + .HasDefaultValueSql("CURRENT_TIMESTAMP") + .HasComment("创建时间") + .HasColumnType("timestamp without time zone") + .HasColumnName("create_time"); + entity.Property(e => e.LastLoginTime) + .HasComment("最后登录时间") + .HasColumnType("timestamp without time zone") + .HasColumnName("last_login_time"); + entity.Property(e => e.NickName) + .HasMaxLength(50) + .HasComment("昵称") + .HasColumnName("nick_name"); + entity.Property(e => e.Password) + .HasMaxLength(128) + .HasComment("登录密码(已加密)") + .HasColumnName("password"); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id).HasName("category_pkey"); + + entity.ToTable("category", tb => tb.HasComment("商品分类")); + + entity.Property(e => e.Id) + .HasComment("主键") + .UseIdentityAlwaysColumn() + .HasColumnName("id"); + entity.Property(e => e.Deleted) + .HasComment("软删除标识") + .HasColumnName("deleted"); + entity.Property(e => e.Level) + .HasComment("层级,从1开始") + .HasColumnName("level"); + entity.Property(e => e.Logo) + .HasMaxLength(50) + .HasComment("logo图片名,后台生成地址") + .HasColumnName("logo"); + entity.Property(e => e.Name) + .HasMaxLength(50) + .HasComment("名称") + .HasColumnName("name"); + entity.Property(e => e.Order) + .HasComment("层级中序号") + .HasColumnName("order"); + entity.Property(e => e.ParentId) + .HasComment("父级id,顶级为0") + .HasColumnName("parent_id"); + entity.Property(e => e.Path) + .HasMaxLength(50) + .HasComment("id路径枚举:/1/2/3/") + .HasColumnName("path"); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id).HasName("district_pkey"); + + entity.ToTable("district", tb => tb.HasComment("行政区划")); + + entity.Property(e => e.Id) + .HasComment("主键") + .UseIdentityAlwaysColumn() + .HasColumnName("id"); + entity.Property(e => e.Code) + .HasMaxLength(10) + .HasComment("编码") + .HasColumnName("code"); + entity.Property(e => e.FullName) + .HasMaxLength(100) + .HasComment("全称") + .HasColumnName("full_name"); + entity.Property(e => e.Latitude) + .HasMaxLength(20) + .HasComment("经度") + .HasColumnName("latitude"); + entity.Property(e => e.Level) + .HasComment("层级:1-省/直辖市,2-市/直辖市无,3-区,4-街道") + .HasColumnName("level"); + entity.Property(e => e.Longitude) + .HasMaxLength(20) + .HasComment("纬度") + .HasColumnName("longitude"); + entity.Property(e => e.Name) + .HasMaxLength(20) + .HasComment("简称") + .HasColumnName("name"); + entity.Property(e => e.Order) + .HasComment("层级中序号") + .HasColumnName("order"); + entity.Property(e => e.ParentId) + .HasComment("父级id,顶级时为0") + .HasColumnName("parent_id"); + entity.Property(e => e.Pinyin) + .HasMaxLength(200) + .HasComment("名称拼音") + .HasColumnName("pinyin"); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id).HasName("logistics_pkey"); + + entity.ToTable("logistics", tb => tb.HasComment("物流")); + + entity.Property(e => e.Id) + .HasComment("主键") + .UseIdentityAlwaysColumn() + .HasColumnName("id"); + entity.Property(e => e.ArrivalTime) + .HasComment("到达时间") + .HasColumnType("timestamp without time zone") + .HasColumnName("arrival_time"); + entity.Property(e => e.Company) + .HasMaxLength(30) + .HasComment("快递公司") + .HasColumnName("company"); + entity.Property(e => e.Context) + .HasMaxLength(200) + .HasComment("详情") + .HasColumnName("context"); + entity.Property(e => e.CreateTime) + .HasDefaultValueSql("CURRENT_TIMESTAMP") + .HasComment("入库时间") + .HasColumnType("timestamp without time zone") + .HasColumnName("create_time"); + entity.Property(e => e.Location) + .HasMaxLength(100) + .HasComment("到达地点") + .HasColumnName("location"); + entity.Property(e => e.OrderNo) + .HasMaxLength(20) + .HasComment("快递单号") + .HasColumnName("order_no"); + entity.Property(e => e.RequestId) + .HasComment("需求id") + .HasColumnName("request_id"); + entity.Property(e => e.Status) + .HasComment("物流状态:1-揽收,0-在途,5-派件,6-退回,4-退签,3-签收,2-疑难,7-转投,8。。。-清关") + .HasColumnName("status"); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id).HasName("message_pkey"); + + entity.ToTable("message", tb => tb.HasComment("用户消息")); + + entity.Property(e => e.Id) + .HasComment("主键") + .UseIdentityAlwaysColumn() + .HasColumnName("id"); + entity.Property(e => e.Content) + .HasComment("内容") + .HasColumnName("content"); + entity.Property(e => e.FromUserId) + .HasComment("发出用户id") + .HasColumnName("from_user_id"); + entity.Property(e => e.Read) + .HasComment("是否已读") + .HasColumnName("read"); + entity.Property(e => e.Recalled) + .HasComment("是否已撤回") + .HasColumnName("recalled"); + entity.Property(e => e.SentTime) + .HasDefaultValueSql("CURRENT_TIMESTAMP") + .HasComment("发出时间") + .HasColumnType("timestamp without time zone") + .HasColumnName("sent_time"); + entity.Property(e => e.ToUserId) + .HasComment("接收用户id") + .HasColumnName("to_user_id"); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id).HasName("notice_pkey"); + + entity.ToTable("notice", tb => tb.HasComment("系统通知")); + + entity.Property(e => e.Id) + .HasComment("主键") + .UseIdentityAlwaysColumn() + .HasColumnName("id"); + entity.Property(e => e.Content) + .HasComment("内容") + .HasColumnName("content"); + entity.Property(e => e.PublishTime) + .HasDefaultValueSql("CURRENT_TIMESTAMP") + .HasComment("发布时间") + .HasColumnType("timestamp without time zone") + .HasColumnName("publish_time"); + entity.Property(e => e.Verified) + .HasComment("是否已审核") + .HasColumnName("verified"); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id).HasName("operate_log_pkey"); + + entity.ToTable("operate_log", tb => tb.HasComment("操作日志")); + + entity.Property(e => e.Id) + .HasComment("主键") + .UseIdentityAlwaysColumn() + .HasColumnName("id"); + entity.Property(e => e.CreateTime) + .HasDefaultValueSql("CURRENT_TIMESTAMP") + .HasComment("入库时间") + .HasColumnType("timestamp without time zone") + .HasColumnName("create_time"); + entity.Property(e => e.Description) + .HasMaxLength(500) + .HasComment("操作描述") + .HasColumnName("description"); + entity.Property(e => e.OperateTime) + .HasDefaultValueSql("CURRENT_TIMESTAMP") + .HasComment("操作时间") + .HasColumnType("timestamp without time zone") + .HasColumnName("operate_time"); + entity.Property(e => e.OperateType) + .HasMaxLength(1) + .HasComment("操作类型:c-创建,u-修改,d-删除") + .HasColumnName("operate_type"); + entity.Property(e => e.OperaterId) + .HasComment("管理员id") + .HasColumnName("operater_id"); + entity.Property(e => e.RelatedId) + .HasComment("关联id,目标表主键") + .HasColumnName("related_id"); + entity.Property(e => e.TargetType) + .HasComment("目标类型:0-行政区划,1-商品分类。。。") + .HasColumnName("target_type"); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id).HasName("product_pkey"); + + entity.ToTable("product", tb => tb.HasComment("卖家商品表")); + + entity.Property(e => e.Id) + .HasComment("主键") + .UseIdentityAlwaysColumn() + .HasColumnName("id"); + entity.Property(e => e.CategoryId) + .HasComment("商品分类id") + .HasColumnName("category_id"); + entity.Property(e => e.CreateTime) + .HasDefaultValueSql("CURRENT_TIMESTAMP") + .HasComment("添加时间") + .HasColumnType("timestamp without time zone") + .HasColumnName("create_time"); + entity.Property(e => e.Deleted) + .HasComment("软删除标识") + .HasColumnName("deleted"); + entity.Property(e => e.Description) + .HasMaxLength(200) + .HasComment("商品描述") + .HasColumnName("description"); + entity.Property(e => e.Detail) + .HasComment("详情") + .HasColumnName("detail"); + entity.Property(e => e.Logo) + .HasMaxLength(50) + .HasComment("商品图片") + .HasColumnName("logo"); + entity.Property(e => e.MinimumUnit) + .HasMaxLength(20) + .HasComment("最小销售单元") + .HasColumnName("minimum_unit"); + entity.Property(e => e.Name) + .HasMaxLength(100) + .HasComment("名称") + .HasColumnName("name"); + entity.Property(e => e.SerialNo) + .HasMaxLength(20) + .HasComment("系统唯一编号") + .HasColumnName("serial_no"); + entity.Property(e => e.SoldAmount) + .HasComment("已售数量") + .HasColumnName("sold_amount"); + entity.Property(e => e.UnitPrice) + .HasPrecision(11, 2) + .HasComment("单价") + .HasColumnName("unit_price"); + entity.Property(e => e.UserId) + .HasComment("用户id") + .HasColumnName("user_id"); + + entity.HasOne(d => d.Category).WithMany(p => p.Products) + .HasForeignKey(d => d.CategoryId) + .OnDelete(DeleteBehavior.ClientSetNull) + .HasConstraintName("fk_categoryproduct"); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id).HasName("refresh_token_pkey"); + + entity.ToTable("refresh_token", tb => tb.HasComment("刷新令牌")); + + entity.Property(e => e.Id) + .HasComment("主键") + .UseIdentityAlwaysColumn() + .HasColumnName("id"); + entity.Property(e => e.CreateTime) + .HasDefaultValueSql("CURRENT_TIMESTAMP") + .HasComment("创建时间") + .HasColumnType("timestamp without time zone") + .HasColumnName("create_time"); + entity.Property(e => e.ExpiresAt) + .HasComment("失效时间") + .HasColumnType("timestamp without time zone") + .HasColumnName("expires_at"); + entity.Property(e => e.SystemRole) + .HasMaxLength(1) + .HasComment("系统角色:a-管理员,u-用户") + .HasColumnName("system_role"); + entity.Property(e => e.Token) + .HasMaxLength(50) + .HasComment("令牌") + .HasColumnName("token"); + entity.Property(e => e.UserId) + .HasComment("用户/管理员id") + .HasColumnName("user_id"); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id).HasName("reply_pkey"); + + entity.ToTable("reply", tb => tb.HasComment("竞标表")); + + entity.Property(e => e.Id) + .HasComment("主键") + .UseIdentityAlwaysColumn() + .HasColumnName("id"); + entity.Property(e => e.Amount) + .HasComment("数量") + .HasColumnName("amount"); + entity.Property(e => e.Memo) + .HasMaxLength(200) + .HasComment("留言") + .HasColumnName("memo"); + entity.Property(e => e.Price) + .HasPrecision(11, 2) + .HasComment("价格,自动计算的价格(product.unit_price * amount)之后的优惠价格") + .HasColumnName("price"); + entity.Property(e => e.ProductId) + .HasComment("商品id") + .HasColumnName("product_id"); + entity.Property(e => e.Rejected) + .HasComment("是否已拒绝") + .HasColumnName("rejected"); + entity.Property(e => e.ReplyTime) + .HasDefaultValueSql("CURRENT_TIMESTAMP") + .HasComment("回应时间") + .HasColumnType("timestamp without time zone") + .HasColumnName("reply_time"); + entity.Property(e => e.RequestId) + .HasComment("需求id") + .HasColumnName("request_id"); + entity.Property(e => e.UserId) + .HasComment("竞标者id") + .HasColumnName("user_id"); + + entity.HasOne(d => d.Product).WithMany(p => p.Replies) + .HasForeignKey(d => d.ProductId) + .OnDelete(DeleteBehavior.ClientSetNull) + .HasConstraintName("fk_replyproduct"); + + entity.HasOne(d => d.Request).WithMany(p => p.Replies) + .HasForeignKey(d => d.RequestId) + .OnDelete(DeleteBehavior.ClientSetNull) + .HasConstraintName("fk_requestreply"); + + entity.HasOne(d => d.User).WithMany(p => p.Replies) + .HasForeignKey(d => d.UserId) + .OnDelete(DeleteBehavior.ClientSetNull) + .HasConstraintName("fk_replyuser"); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id).HasName("request_pkey"); + + entity.ToTable("request", tb => tb.HasComment("用户需求")); + + entity.Property(e => e.Id) + .HasComment("主键") + .UseIdentityAlwaysColumn() + .HasColumnName("id"); + entity.Property(e => e.CategoryId) + .HasComment("商品分类id") + .HasColumnName("category_id"); + entity.Property(e => e.Deadline) + .HasComment("截止日期") + .HasColumnName("deadline"); + entity.Property(e => e.Deleted) + .HasComment("是否已删除") + .HasColumnName("deleted"); + entity.Property(e => e.Description) + .HasMaxLength(1000) + .HasComment("需求描述") + .HasColumnName("description"); + entity.Property(e => e.Name) + .HasMaxLength(100) + .HasComment("名称") + .HasColumnName("name"); + entity.Property(e => e.PublishTime) + .HasDefaultValueSql("CURRENT_TIMESTAMP") + .HasComment("发布时间") + .HasColumnType("timestamp without time zone") + .HasColumnName("publish_time"); + entity.Property(e => e.PublisherId) + .HasComment("发布者id") + .HasColumnName("publisher_id"); + entity.Property(e => e.SerialNo) + .HasMaxLength(20) + .HasComment("需求单号,系统唯一,后台生成") + .HasColumnName("serial_no"); + entity.Property(e => e.Status) + .HasComment("状态:0-发布,1-有竞标,2-待发货,3-待收货,4-已完成,5-已评价") + .HasColumnName("status"); + + entity.HasOne(d => d.Category).WithMany(p => p.Requests) + .HasForeignKey(d => d.CategoryId) + .OnDelete(DeleteBehavior.ClientSetNull) + .HasConstraintName("fk_categoryrequest"); + + entity.HasOne(d => d.Publisher).WithMany(p => p.Requests) + .HasForeignKey(d => d.PublisherId) + .OnDelete(DeleteBehavior.ClientSetNull) + .HasConstraintName("fk_userrequest"); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id).HasName("user_pkey"); + + entity.ToTable("user", tb => tb.HasComment("用户")); + + entity.Property(e => e.Id) + .HasComment("主键") + .UseIdentityAlwaysColumn() + .HasColumnName("id"); + entity.Property(e => e.Account) + .HasMaxLength(20) + .HasComment("登录账号") + .HasColumnName("account"); + entity.Property(e => e.Avatar) + .HasMaxLength(50) + .HasComment("头像图片名,后台生成链接") + .HasColumnName("avatar"); + entity.Property(e => e.CreateTime) + .HasDefaultValueSql("CURRENT_TIMESTAMP") + .HasComment("创建时间") + .HasColumnType("timestamp without time zone") + .HasColumnName("create_time"); + entity.Property(e => e.CurrentRole) + .HasMaxLength(1) + .HasDefaultValueSql("'c'::bpchar") + .HasComment("当前角色:c-买家,s-卖家") + .HasColumnName("current_role"); + entity.Property(e => e.LastLoginTime) + .HasComment("最后登录时间") + .HasColumnType("timestamp without time zone") + .HasColumnName("last_login_time"); + entity.Property(e => e.NickName) + .HasMaxLength(50) + .HasComment("昵称") + .HasColumnName("nick_name"); + entity.Property(e => e.Password) + .HasMaxLength(128) + .HasComment("密码(已加密)") + .HasColumnName("password"); + entity.Property(e => e.Telephone) + .HasMaxLength(20) + .HasComment("电话") + .HasColumnName("telephone"); + }); + + OnModelCreatingPartial(modelBuilder); + } + + partial void OnModelCreatingPartial(ModelBuilder modelBuilder); +} diff --git a/StopShopping.OpenPlatform/DistrictResponse.cs b/StopShopping.OpenPlatform/DistrictResponse.cs new file mode 100644 index 0000000..b7d8da8 --- /dev/null +++ b/StopShopping.OpenPlatform/DistrictResponse.cs @@ -0,0 +1,20 @@ +namespace StopShopping.OpenPlatform; + +public class DistrictResponse +{ + public bool IsSucced { get; set; } + public string? Message { get; set; } + public List? Districts { get; set; } + + public class District + { + public string Code { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string FullName { get; set; } = string.Empty; + public int? Level { get; set; } + public string? PinYin { get; set; } + public decimal Latitude { get; set; } + public decimal Longitude { get; set; } + public List? Districts { get; set; } + } +} diff --git a/StopShopping.OpenPlatform/Extensions/OpenPlatformExtensions.cs b/StopShopping.OpenPlatform/Extensions/OpenPlatformExtensions.cs new file mode 100644 index 0000000..ed79d42 --- /dev/null +++ b/StopShopping.OpenPlatform/Extensions/OpenPlatformExtensions.cs @@ -0,0 +1,20 @@ +using Microsoft.Extensions.Configuration; +using StopShopping.OpenPlatform; +using StopShopping.OpenPlatform.Extensions; +using StopShopping.OpenPlatform.TencentLocationApi; + +namespace Microsoft.Extensions.DependencyInjection; + +public static class OpenPlatformExtensions +{ + public static IServiceCollection AddOpenPlatformServices(this IServiceCollection services, IConfiguration openPlatformOptions) + { + services.Configure(openPlatformOptions); + + services.AddHttpClient(); + + services.AddScoped(); + + return services; + } +} diff --git a/StopShopping.OpenPlatform/Extensions/OpenPlatformOptions.cs b/StopShopping.OpenPlatform/Extensions/OpenPlatformOptions.cs new file mode 100644 index 0000000..734c7a9 --- /dev/null +++ b/StopShopping.OpenPlatform/Extensions/OpenPlatformOptions.cs @@ -0,0 +1,13 @@ +namespace StopShopping.OpenPlatform.Extensions; + +public class OpenPlatformOptions +{ + public DistrictOptions TencentDistrict { get; set; } = new(); + + public class DistrictOptions + { + public string? SecretKey { get; set; } + public string? Top3LevelUrl { get; set; } + public string? GetChildrenUrl { get; set; } + } +} diff --git a/StopShopping.OpenPlatform/IDistrictService.cs b/StopShopping.OpenPlatform/IDistrictService.cs new file mode 100644 index 0000000..819d5cc --- /dev/null +++ b/StopShopping.OpenPlatform/IDistrictService.cs @@ -0,0 +1,8 @@ +namespace StopShopping.OpenPlatform; + +public interface IDistrictService +{ + Task GetTop3LevelDistrictsAsync(CancellationToken cancellationToken = default); + + Task GetChildrenAsync(string code, CancellationToken cancellationToken = default); +} diff --git a/StopShopping.OpenPlatform/StopShopping.OpenPlatform.csproj b/StopShopping.OpenPlatform/StopShopping.OpenPlatform.csproj new file mode 100644 index 0000000..78b6540 --- /dev/null +++ b/StopShopping.OpenPlatform/StopShopping.OpenPlatform.csproj @@ -0,0 +1,18 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + diff --git a/StopShopping.OpenPlatform/TencentLocationApi/District.cs b/StopShopping.OpenPlatform/TencentLocationApi/District.cs new file mode 100644 index 0000000..b672792 --- /dev/null +++ b/StopShopping.OpenPlatform/TencentLocationApi/District.cs @@ -0,0 +1,73 @@ +using System.Text.Json.Serialization; + +namespace StopShopping.OpenPlatform.TencentLocationApi; + +/// +/// 区划 +/// +/// +public record District +{ + /// + /// 行政区划唯一标识(adcode) + /// + /// + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; + /// + /// 简称,如“内蒙古” + /// + /// + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + /// + /// 行政区划级别 + /// + /// + [JsonPropertyName("level")] + public int? Level { get; set; } + /// + /// 全称,如“内蒙古自治区” + /// + /// + [JsonPropertyName("fullname")] + public string FullName { get; set; } = string.Empty; + /// + /// 行政区划拼音,每一下标为一个字的全拼,如:[“nei”,“meng”,“gu”] + /// + /// + [JsonPropertyName("pinyin")] + public string[]? PinYin { get; set; } + /// + /// 经纬度 + /// + /// + [JsonPropertyName("location")] + public Location Location { get; set; } = new(); + /// + /// 当前区划的下级区划信息,结构与当前区划一致,如果没有下级区划则不返回此字段 + /// + /// + [JsonPropertyName("districts")] + public District[]? Districts { get; set; } +} + +/// +/// 经纬度 +/// +/// +public record Location +{ + /// + /// 纬度 + /// + /// + [JsonPropertyName("lat")] + public decimal Latitude { get; set; } + /// + /// 经度 + /// + /// + [JsonPropertyName("lng")] + public decimal Longitude { get; set; } +} diff --git a/StopShopping.OpenPlatform/TencentLocationApi/ResponseBase.cs b/StopShopping.OpenPlatform/TencentLocationApi/ResponseBase.cs new file mode 100644 index 0000000..fce1a36 --- /dev/null +++ b/StopShopping.OpenPlatform/TencentLocationApi/ResponseBase.cs @@ -0,0 +1,19 @@ +using System.Text.Json.Serialization; + +namespace StopShopping.OpenPlatform.TencentLocationApi; + +public abstract record ResponseBase +{ + /// + /// 状态码 + /// + /// + [JsonPropertyName("status")] + public int Status { get; set; } + /// + /// 状态说明 + /// + /// + [JsonPropertyName("message")] + public string? Message { get; set; } +} diff --git a/StopShopping.OpenPlatform/TencentLocationApi/ResponseDistrict.cs b/StopShopping.OpenPlatform/TencentLocationApi/ResponseDistrict.cs new file mode 100644 index 0000000..1c4cb54 --- /dev/null +++ b/StopShopping.OpenPlatform/TencentLocationApi/ResponseDistrict.cs @@ -0,0 +1,19 @@ +using System.Text.Json.Serialization; + +namespace StopShopping.OpenPlatform.TencentLocationApi; + +public record ResponseDistrict : ResponseBase +{ + /// + /// 数据版本,日期 + /// + /// + [JsonPropertyName("data_version")] + public string DataVersion { get; set; } = string.Empty; + /// + /// 结果数组 + /// + /// + [JsonPropertyName("result")] + public T[]? Result { get; set; } +} diff --git a/StopShopping.OpenPlatform/TencentLocationApi/TencentDistrictService.cs b/StopShopping.OpenPlatform/TencentLocationApi/TencentDistrictService.cs new file mode 100644 index 0000000..4aaaa11 --- /dev/null +++ b/StopShopping.OpenPlatform/TencentLocationApi/TencentDistrictService.cs @@ -0,0 +1,154 @@ +using System.Text.Json; +using System.Threading.RateLimiting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Polly; +using Polly.RateLimiting; +using Polly.Timeout; +using StopShopping.OpenPlatform.Extensions; + +namespace StopShopping.OpenPlatform.TencentLocationApi; + +public class TencentDistrictService : IDistrictService +{ + public TencentDistrictService( + IHttpClientFactory httpClientFactory, + IOptions openPlatformOptions, + ILogger logger) + { + _httpContextFactory = httpClientFactory; + _options = openPlatformOptions.Value; + _logger = logger; + + _pipeline = new ResiliencePipelineBuilder() + .AddRetry(new Polly.Retry.RetryStrategyOptions + { + Delay = TimeSpan.FromSeconds(2), + MaxRetryAttempts = 5, + ShouldHandle = (arg) => + ValueTask.FromResult(arg.Outcome.Exception is + TimeoutRejectedException or RateLimiterRejectedException) + }) + .AddRateLimiter(new SlidingWindowRateLimiter(new SlidingWindowRateLimiterOptions + { + PermitLimit = 2, + QueueLimit = int.MaxValue, + SegmentsPerWindow = 5, + Window = TimeSpan.FromSeconds(1) + })) + .AddTimeout(TimeSpan.FromSeconds(20)) + .Build(); + } + + private readonly IHttpClientFactory _httpContextFactory; + private readonly OpenPlatformOptions _options; + private readonly ILogger _logger; + + private readonly ResiliencePipeline _pipeline; + + public async Task GetChildrenAsync(string code, CancellationToken cancellationToken = default) + { + var uri = new Uri( + string.Format("{0}?key={1}&id={2}" + , _options.TencentDistrict.GetChildrenUrl! + , Uri.EscapeDataString(_options.TencentDistrict.SecretKey!) + , code)); + + var districts = await Get(uri, cancellationToken); + + if (null == districts) + return null; + + DistrictResponse response = new() + { + IsSucced = districts.Status == 0, + Message = $"{districts.Status}:{districts.Message}", + Districts = districts.Result + ?.FirstOrDefault() + ?.Select(Cast) + .ToList() + }; + + return response; + } + + public async Task GetTop3LevelDistrictsAsync(CancellationToken cancellationToken = default) + { + var uri = new Uri( + string.Format("{0}?key={1}&struct_type=1" + , _options.TencentDistrict.Top3LevelUrl! + , _options.TencentDistrict.SecretKey!) + ); + + var districts = await Get(uri, cancellationToken); + + if (null == districts) + return null; + + DistrictResponse response = new() + { + IsSucced = districts.Status == 0, + Message = $"{districts.Status}:{districts.Message}", + Districts = null != districts.Result + ? [.. RecurCast(districts.Result)] : null + }; + + return response; + } + + private async Task?> Get(Uri uri, CancellationToken cancellationToken = default) + { + using var httpClient = _httpContextFactory.CreateClient(); + + + var districts = await _pipeline.ExecuteAsync(async (token) => + { + var jsonStream = await httpClient.GetStreamAsync(uri, token); + + try + { + return await JsonSerializer.DeserializeAsync>( + jsonStream, cancellationToken: token); + } + catch (JsonException e) + { + _logger.LogError("序列化异常:{Path}", e.Path); + return null; + } + }, cancellationToken); + + if (null == districts) + { + _logger.LogError("接口请求失败:{Url}", uri.ToString()); + return null; + } + + return districts; + } + + private IEnumerable RecurCast(District[] districts) + { + foreach (var d in districts) + { + var cd = Cast(d); + if (d.Districts?.Length > 0) + cd.Districts = [.. RecurCast(d.Districts!)]; + + yield return cd; + } + } + + private DistrictResponse.District Cast(District d) + { + return new DistrictResponse.District + { + Code = d.Id, + FullName = d.FullName, + Latitude = d.Location.Latitude, + Level = d.Level, + Longitude = d.Location.Longitude, + Name = d.Name, + PinYin = d.PinYin?.Length > 0 ? string.Join(',', d.PinYin) : null, + }; + } +} \ No newline at end of file diff --git a/StopShopping.Services.Test/MSTestSettings.cs b/StopShopping.Services.Test/MSTestSettings.cs new file mode 100644 index 0000000..aaf278c --- /dev/null +++ b/StopShopping.Services.Test/MSTestSettings.cs @@ -0,0 +1 @@ +[assembly: Parallelize(Scope = ExecutionScope.MethodLevel)] diff --git a/StopShopping.Services.Test/SerialNoGeneratorTests.cs b/StopShopping.Services.Test/SerialNoGeneratorTests.cs new file mode 100644 index 0000000..fbf3c87 --- /dev/null +++ b/StopShopping.Services.Test/SerialNoGeneratorTests.cs @@ -0,0 +1,53 @@ +using Microsoft.Extensions.Logging; +using StopShopping.Services.Implementions; + +namespace StopShopping.Services.Test; + +[TestClass] +public sealed class SerialNoGeneratorTests +{ + [ClassInitialize] + public static void Init(TestContext testContext) + { + _serialNoGenerator = new SerialNoGenerator( + LoggerFactory.Create(options => options.AddConsole()).CreateLogger()); + _nanoidGenerator = new NanoidSerialNoGenerator(); + } + + private static SerialNoGenerator? _serialNoGenerator; + private static NanoidSerialNoGenerator? _nanoidGenerator; + + [TestMethod] + [DataRow(10)] + public void Gen_Right_Count(int count) + { + List serialNos = []; + for (var i = 0; i < count; i++) + { + var no = _serialNoGenerator?.GenerateRequestNo(); + if (!string.IsNullOrWhiteSpace(no)) + serialNos.Add(no); + } + + serialNos.ForEach(s => Console.WriteLine("{0} - {1}", s, Convert.ToString(long.Parse(s[1..]), toBase: 2))); + + Assert.HasCount(count, serialNos); + } + + [TestMethod] + [DataRow(10)] + public void Nanoid_Gen_Right_Count(int count) + { + List serialNos = []; + for (var i = 0; i < count; i++) + { + var no = _nanoidGenerator?.GenerateRequestNo(); + if (!string.IsNullOrWhiteSpace(no)) + serialNos.Add(no); + } + + serialNos.ForEach(s => Console.WriteLine(s)); + + Assert.HasCount(count, serialNos); + } +} diff --git a/StopShopping.Services.Test/StopShopping.Services.Test.csproj b/StopShopping.Services.Test/StopShopping.Services.Test.csproj new file mode 100644 index 0000000..fc27f50 --- /dev/null +++ b/StopShopping.Services.Test/StopShopping.Services.Test.csproj @@ -0,0 +1,22 @@ + + + + net10.0 + latest + enable + enable + + + + + + + + + + + + + + + diff --git a/StopShopping.Services/Consts.cs b/StopShopping.Services/Consts.cs new file mode 100644 index 0000000..314d8be --- /dev/null +++ b/StopShopping.Services/Consts.cs @@ -0,0 +1,29 @@ +namespace StopShopping.Services; + +/// +/// 常量 +/// +public static class Consts +{ + /// + /// 默认头像 + /// + public const string DEFAULT_AVATAR = "avatar.png"; + /// + /// 默认商品图片 + /// + public const string DEFAULT_PRODUCT = "product.png"; + + /// + /// 默认管理员账号 + /// + public const string DEFAULT_ADMIN_ACCOUNT = "stopshopping"; + + public static class CacheKeys + { + public static string AccessTokenBlacklist(string token) + { + return $"accesstoken_blacklist:{token}"; + } + } +} diff --git a/StopShopping.Services/Extensions/AppOptions.cs b/StopShopping.Services/Extensions/AppOptions.cs new file mode 100644 index 0000000..b09b28d --- /dev/null +++ b/StopShopping.Services/Extensions/AppOptions.cs @@ -0,0 +1,33 @@ +namespace StopShopping.Services.Extensions; + +/// +/// 业务配置 +/// +public record AppOptions +{ + /// + /// .bjbj.me + /// + /// + public string CookieDomain { get; set; } = string.Empty; + /// + /// 域名,http(s)://www.xxx.xx + /// + /// + public string DomainPath { get; set; } = string.Empty; + /// + /// anti-forgery 请求头 + /// + /// + public string CSRFHeaderName { get; set; } = string.Empty; + /// + /// anti-forgery cookie's name + /// + /// + public string CSRFCookieName { get; set; } = string.Empty; + /// + /// 跨域站点 + /// + /// + public string[] CorsAllowedOrigins { get; set; } = []; +} diff --git a/StopShopping.Services/Extensions/EnumExtensions.cs b/StopShopping.Services/Extensions/EnumExtensions.cs new file mode 100644 index 0000000..a12f874 --- /dev/null +++ b/StopShopping.Services/Extensions/EnumExtensions.cs @@ -0,0 +1,49 @@ +using StopShopping.Services.Models; + +namespace StopShopping.Services.Extensions; + +public static class EnumExtensions +{ + public static string GetTargetDirectory(this UploadScences uploadScences) + { + return uploadScences switch + { + UploadScences.Avatar => "avatar", + UploadScences.Product => "product", + UploadScences.Category => "category", + _ => throw new ArgumentOutOfRangeException(nameof(uploadScences)) + }; + } + + public static char GetValue(this UserRoles userRoles) + { + return userRoles switch + { + UserRoles.Seller => 's', + UserRoles.Buyer => 'c', + _ => throw new ArgumentOutOfRangeException(nameof(userRoles)) + }; + } + + public static UserRoles ToUserRoles(this char userRole) + { + return userRole switch + { + 's' => UserRoles.Seller, + 'c' => UserRoles.Buyer, + _ => throw new ArgumentOutOfRangeException(nameof(userRole), "valid: 'c','s'") + }; + } + + public static bool CanDelete(this RequestStatus requestStatus) + { + return requestStatus == RequestStatus.Publish + || requestStatus == RequestStatus.Replied; + } + + public static bool CanReply(this RequestStatus requestStatus) + { + return requestStatus == RequestStatus.Publish + || requestStatus == RequestStatus.Replied; + } +} diff --git a/StopShopping.Services/Extensions/ServiceException.cs b/StopShopping.Services/Extensions/ServiceException.cs new file mode 100644 index 0000000..3fca716 --- /dev/null +++ b/StopShopping.Services/Extensions/ServiceException.cs @@ -0,0 +1,11 @@ +namespace System; + +/// +/// 业务异常 +/// +public class ServiceException : ApplicationException +{ + public ServiceException() : base() { } + + public ServiceException(string? message) : base(message) { } +} diff --git a/StopShopping.Services/Extensions/ServicesExtensions.cs b/StopShopping.Services/Extensions/ServicesExtensions.cs new file mode 100644 index 0000000..0500323 --- /dev/null +++ b/StopShopping.Services/Extensions/ServicesExtensions.cs @@ -0,0 +1,45 @@ +using FileSignatures; +using FileSignatures.Formats; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using StopShopping.EF; +using StopShopping.Services; +using StopShopping.Services.Extensions; +using StopShopping.Services.Implementions; + +namespace Microsoft.Extensions.DependencyInjection; + +public static class ServicesExtensions +{ + public static IServiceCollection AddServices( + this IServiceCollection services, + Action dbContextOptions, + IConfiguration appOptions, + IConfiguration openPlatformOptions) + { + services.AddDbContext(dbContextOptions); + + services.Configure(appOptions); + + var imageFormats = FileFormatLocator.GetFormats().OfType(); + var imageInspector = new FileFormatInspector(imageFormats); + services.AddSingleton(imageInspector); + + services.AddSingleton(); + services.AddSingleton(); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + services.AddOpenPlatformServices(openPlatformOptions); + + return services; + } +} diff --git a/StopShopping.Services/Extensions/SystemExtensions.cs b/StopShopping.Services/Extensions/SystemExtensions.cs new file mode 100644 index 0000000..c31baac --- /dev/null +++ b/StopShopping.Services/Extensions/SystemExtensions.cs @@ -0,0 +1,14 @@ +namespace System; + +public static class SystemExtensions +{ + public static string ToFormatted(this DateTime dateTime) + { + return dateTime.ToString("yyyy-MM-dd HH:mm:ss"); + } + + public static string ToFormatted(this DateOnly date) + { + return date.ToString("yyyy-MM-dd"); + } +} diff --git a/StopShopping.Services/IAccessTokenService.cs b/StopShopping.Services/IAccessTokenService.cs new file mode 100644 index 0000000..b98fe6d --- /dev/null +++ b/StopShopping.Services/IAccessTokenService.cs @@ -0,0 +1,49 @@ +using System.Security.Claims; +using StopShopping.Services.Models; +using StopShopping.Services.Models.Resp; + +namespace StopShopping.Services; + +/// +/// 访问令牌服务 +/// +public interface IAccessTokenService +{ + /// + /// 生成访问令牌 + /// + /// + /// + AccessToken GenerateAccessToken(ClaimsIdentity claims); + /// + /// 生成访问令牌 + /// + /// + /// + Task GenerateAccessTokenAsync(string refreshToken); + /// + /// 添加访问令牌到黑名单 + /// + /// + /// + Task AddAccessTokenBlacklistAsync(string accessToken); + /// + /// 访问令牌是否在黑名单中 + /// + /// + /// + Task IsAccessTokenBlacklistAsync(string accessToken); + /// + /// 生成刷新令牌 + /// + /// 管理员/用户id + /// + /// + Task SetRefreshTokenAsync(int userId, SystemRoles systemRole); + /// + /// 回收刷新令牌 + /// + /// + /// + Task RevokeRefreshTokenAsync(string refreshToken); +} diff --git a/StopShopping.Services/ICategoryService.cs b/StopShopping.Services/ICategoryService.cs new file mode 100644 index 0000000..4073267 --- /dev/null +++ b/StopShopping.Services/ICategoryService.cs @@ -0,0 +1,34 @@ +using StopShopping.Services.Models.Req; +using StopShopping.Services.Models.Resp; + +namespace StopShopping.Services; + +/// +/// 商品分类服务 +/// +public interface ICategoryService +{ + /// + /// 获取分类树 + /// + /// + ApiResponse> GetCategoriesTree(); + /// + /// 新增/修改分类 + /// + /// + /// + Task> EditCategoryAsync(EditCategoryParams model); + /// + /// 调整层级内顺序 + /// + /// + /// + Task ResortCategoryAsync(ResortCategoryParams model); + /// + /// 删除分类 + /// + /// + /// + Task DeleteCategoryAsync(CategoryIdParams model); +} diff --git a/StopShopping.Services/ICipherService.cs b/StopShopping.Services/ICipherService.cs new file mode 100644 index 0000000..c194a5a --- /dev/null +++ b/StopShopping.Services/ICipherService.cs @@ -0,0 +1,14 @@ +namespace StopShopping.Services; + +/// +/// 加解密服务 +/// +public interface ICipherService +{ + /// + /// 用户密码加密 + /// + /// 明文 + /// + string EncryptUserPassword(string input); +} diff --git a/StopShopping.Services/IClaimsService.cs b/StopShopping.Services/IClaimsService.cs new file mode 100644 index 0000000..472b667 --- /dev/null +++ b/StopShopping.Services/IClaimsService.cs @@ -0,0 +1,28 @@ +using System.Security.Claims; +using StopShopping.EF.Models; + +namespace StopShopping.Services; + +/// +/// 身份服务 +/// +public interface IClaimsService +{ + /// + /// 创建用户身份标识 + /// + /// + /// + ClaimsIdentity BuildIdentity(User user); + /// + /// 创建管理员身份标识 + /// + /// + /// + ClaimsIdentity BuildAdminIdentity(Administrator admin); + /// + /// 获取当前登录用户id + /// + /// + int GetCurrentUserId(); +} diff --git a/StopShopping.Services/IDistrictService.cs b/StopShopping.Services/IDistrictService.cs new file mode 100644 index 0000000..cbecb9e --- /dev/null +++ b/StopShopping.Services/IDistrictService.cs @@ -0,0 +1,28 @@ +using StopShopping.Services.Models.Req; +using StopShopping.Services.Models.Resp; + +namespace StopShopping.Services; + +/// +/// 行政区划服务 +/// +public interface IDistrictService +{ + /// + /// 初始化行政区划数据库 + /// + /// + /// + Task InitialDatabaseAsync(CancellationToken cancellationToken); + /// + /// 获取到区的区域,直辖市无level=2 + /// + /// + Task>> GetTop3LevelDistrictsAsync(); + /// + /// 获取直接下级区域 + /// + /// + /// + ApiResponse> GetChildren(DistrictParentIdParams model); +} diff --git a/StopShopping.Services/IFileService.cs b/StopShopping.Services/IFileService.cs new file mode 100644 index 0000000..8f83c0e --- /dev/null +++ b/StopShopping.Services/IFileService.cs @@ -0,0 +1,25 @@ +using StopShopping.Services.Models; +using StopShopping.Services.Models.Req; +using StopShopping.Services.Models.Resp; + +namespace StopShopping.Services; + +/// +/// 文件服务 +/// +public interface IFileService +{ + /// + /// 上传文件 + /// + /// + /// + Task> UploadFileAsync(UploadParams payload); + /// + /// 获取文件链接 + /// + /// + /// + /// + string GetFileUrl(UploadScences scences, string fileName); +} diff --git a/StopShopping.Services/IProductService.cs b/StopShopping.Services/IProductService.cs new file mode 100644 index 0000000..007e565 --- /dev/null +++ b/StopShopping.Services/IProductService.cs @@ -0,0 +1,35 @@ +using StopShopping.Services.Models.Req; +using StopShopping.Services.Models.Resp; + +namespace StopShopping.Services; + +/// +/// 商品服务 +/// +public interface IProductService +{ + /// + /// 分页搜索 + /// + /// + /// + Task>> SearchAsync(ProductSearchParms model); + /// + /// 详情 + /// + /// + /// + ApiResponse Detail(ProductIdParams model); + /// + /// 新增/修改商品 + /// + /// + /// + Task EditAsync(EditProductParams model); + /// + /// 删除商品 + /// + /// + /// + Task DeleteAsync(ProductIdParams model); +} diff --git a/StopShopping.Services/IReplyService.cs b/StopShopping.Services/IReplyService.cs new file mode 100644 index 0000000..55260cc --- /dev/null +++ b/StopShopping.Services/IReplyService.cs @@ -0,0 +1,23 @@ +using StopShopping.Services.Models.Req; +using StopShopping.Services.Models.Resp; + +namespace StopShopping.Services; + +/// +/// 竞标服务 +/// +public interface IReplyService +{ + /// + /// 提交竞标 + /// + /// + /// + Task ReplyAsync(ReplyParams model); + /// + /// 查看竞标列表 + /// + /// + /// + Task>> GetRepliesAsync(RequestIdParams model); +} diff --git a/StopShopping.Services/IRequestService.cs b/StopShopping.Services/IRequestService.cs new file mode 100644 index 0000000..88c9d77 --- /dev/null +++ b/StopShopping.Services/IRequestService.cs @@ -0,0 +1,41 @@ +using StopShopping.Services.Models.Req; +using StopShopping.Services.Models.Resp; + +namespace StopShopping.Services; + +/// +/// 需求服务 +/// +public interface IRequestService +{ + /// + /// 发布需求 + /// + /// + /// + Task PublishRequestAsync(CreateRequestParams model); + /// + /// 分页搜索需求 + /// + /// + /// + Task>> SearchAsync(RequestSearchParams model); + /// + /// 需求订单检索 + /// + /// + /// + Task>> RequestOrderSearchAsync(RequestSearchWithStatusParams model); + /// + /// 竞标订单检索 + /// + /// + /// + Task>> ReplyOrderSearchAsync(RequestSearchWithStatusParams model); + /// + /// 删除需求 + /// + /// + /// + Task DeleteRequestAsync(RequestIdParams model); +} diff --git a/StopShopping.Services/ISerialNoGenerator.cs b/StopShopping.Services/ISerialNoGenerator.cs new file mode 100644 index 0000000..e4cf9b9 --- /dev/null +++ b/StopShopping.Services/ISerialNoGenerator.cs @@ -0,0 +1,20 @@ +namespace StopShopping.Services; + +public interface ISerialNoGenerator +{ + /// + /// 生成需求单号 + /// + /// + string GenerateRequestNo(); + /// + /// 生成商品编号 + /// + /// + string GenerateProductNo(); + /// + /// 生成随机密码 + /// + /// + string GenerateRandomPassword(); +} diff --git a/StopShopping.Services/IUserService.cs b/StopShopping.Services/IUserService.cs new file mode 100644 index 0000000..94049f2 --- /dev/null +++ b/StopShopping.Services/IUserService.cs @@ -0,0 +1,73 @@ +using StopShopping.Services.Models; +using StopShopping.Services.Models.Req; +using StopShopping.Services.Models.Resp; + +namespace StopShopping.Services; + +/// +/// 用户服务 +/// +public interface IUserService +{ + /// + /// 用户注册 + /// + /// + /// + Task SignUpAsync(SignUpParams model); + + /// + /// 登录 + /// + /// + /// AccessToken,RefreshToken + Task> SignInAsync(SignInParams model); + + /// + /// 管理员登录 + /// + /// + /// AccessToken,RefreshToken + Task> SignInAdminAsync(SignInParams model); + + /// + /// 生成默认管理员 + /// + /// + Task GenerateDefaultAdminAsync(); + + /// + /// 修改密码 + /// + /// + /// + Task ChangePasswordAsync(ChangePasswordParams model); + /// + /// 获取用户信息 + /// + /// + Task> GetUserInfoAsync(); + /// + /// 修改用户信息 + /// + /// + /// + Task EditAsync(EditUserParams model); + /// + /// 获取用户地址 + /// + /// + ApiResponse> GetAddresses(); + /// + /// 新增/修改地址 + /// + /// + /// + Task EditAddressAsync(EditAddressParams model); + /// + /// 删除地址 + /// + /// + /// + Task DeleteAddressAsync(int id); +} diff --git a/StopShopping.Services/Implementions/AccessTokenService.cs b/StopShopping.Services/Implementions/AccessTokenService.cs new file mode 100644 index 0000000..6ea66fe --- /dev/null +++ b/StopShopping.Services/Implementions/AccessTokenService.cs @@ -0,0 +1,174 @@ +using System.Security.Claims; +using System.Text; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; +using StopShopping.EF; +using StopShopping.EF.Models; +using StopShopping.Services.Models; +using StopShopping.Services.Models.Resp; + +namespace StopShopping.Services.Implementions; + +public class AccessTokenService : IAccessTokenService +{ + public AccessTokenService(IOptions jwtOptions, + IDistributedCache cache, + StopShoppingContext dbContext, + ILogger logger, + IClaimsService claimsService) + { + _jwtOptions = jwtOptions.Value; + _cache = cache; + _dbContext = dbContext; + _logger = logger; + _claimsService = claimsService; + } + + private readonly JwtOptions _jwtOptions; + private readonly IDistributedCache _cache; + private readonly StopShoppingContext _dbContext; + private readonly ILogger _logger; + private readonly IClaimsService _claimsService; + + public AccessToken GenerateAccessToken(ClaimsIdentity claims) + { + var tokenDescriptor = new SecurityTokenDescriptor + { + Audience = _jwtOptions.ValidAudience, + Issuer = _jwtOptions.ValidIssuer, + Subject = claims, + IssuedAt = DateTime.UtcNow, + NotBefore = DateTime.UtcNow, + Expires = DateTime.UtcNow.AddSeconds(_jwtOptions.AccessTokenExpiresIn), + SigningCredentials = new SigningCredentials( + new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtOptions.SigningKey!)), + SecurityAlgorithms.HmacSha256 + ) + }; + + var token = new JsonWebTokenHandler() + .CreateToken(tokenDescriptor); + + return new AccessToken + { + Token = token, + ExpiresIn = _jwtOptions.AccessTokenExpiresIn + }; + } + + public async Task AddAccessTokenBlacklistAsync(string accessToken) + { + JsonWebTokenHandler jwtHandler = new JsonWebTokenHandler(); + var jwtToken = jwtHandler.ReadJsonWebToken(accessToken); + var expClaim = jwtToken.Claims.FirstOrDefault(c => c.Type == JwtRegisteredClaimNames.Exp); + if (null == expClaim) + { + _logger.LogError("access_token:{Token}中无法找到exp", accessToken); + return false; + } + + var expTime = DateTimeOffset.FromUnixTimeSeconds(long.Parse(expClaim.Value)).UtcDateTime; + if (DateTime.UtcNow > expTime) + { + _logger.LogError("access_token:{Token}已过期", accessToken); + return false; + } + + await _cache.SetStringAsync(Consts.CacheKeys.AccessTokenBlacklist(accessToken), + "quited user", + new DistributedCacheEntryOptions + { + AbsoluteExpiration = expTime + }); + + return true; + } + + public async Task IsAccessTokenBlacklistAsync(string accessToken) + { + var blacklist = await _cache.GetStringAsync(Consts.CacheKeys.AccessTokenBlacklist(accessToken)); + + return !string.IsNullOrWhiteSpace(blacklist); + } + + public async Task SetRefreshTokenAsync(int userId, SystemRoles systemRole) + { + var refreshToken = Guid.NewGuid().ToString("N"); + var now = DateTime.Now; + char role = (char)systemRole; + + // var qry = await _dbContext.RefreshTokens + // .Where(r => r.UserId == userId && r.SystemRole == role) + // .ExecuteDeleteAsync(); + + var model = new RefreshToken + { + CreateTime = now, + ExpiresAt = now.AddSeconds(_jwtOptions.RefreshTokenExpiresIn), + SystemRole = role, + Token = refreshToken, + UserId = userId + }; + await _dbContext.RefreshTokens.AddAsync(model); + await _dbContext.SaveChangesAsync(); + + return new AccessToken + { + Token = refreshToken, + ExpiresIn = _jwtOptions.RefreshTokenExpiresIn + }; + } + + public async Task RevokeRefreshTokenAsync(string refreshToken) + { + await _dbContext.RefreshTokens + .Where(r => r.Token == refreshToken) + .ExecuteDeleteAsync(); + } + + public async Task GenerateAccessTokenAsync(string refreshToken) + { + var rt = await _dbContext.RefreshTokens + .AsNoTracking() + .Where(r => r.Token == refreshToken && r.ExpiresAt > DateTime.Now) + .FirstOrDefaultAsync(); + if (null == rt) + return null; + + ClaimsIdentity? claimsIdentity = null; + + switch (rt.SystemRole) + { + case (char)SystemRoles.Admin: + { + var admin = await _dbContext.Administrators + .AsNoTracking() + .Where(a => a.Id == rt.UserId) + .FirstOrDefaultAsync(); + if (null != admin) + claimsIdentity = _claimsService.BuildAdminIdentity(admin); + } + break; + case (char)SystemRoles.User: + { + var user = await _dbContext.Users + .AsNoTracking() + .Where(u => u.Id == rt.UserId) + .FirstOrDefaultAsync(); + if (null != user) + claimsIdentity = _claimsService.BuildIdentity(user); + } + break; + default: break; + } + + if (null == claimsIdentity) + return null; + + return GenerateAccessToken(claimsIdentity); + } +} diff --git a/StopShopping.Services/Implementions/CategoryService.cs b/StopShopping.Services/Implementions/CategoryService.cs new file mode 100644 index 0000000..3661541 --- /dev/null +++ b/StopShopping.Services/Implementions/CategoryService.cs @@ -0,0 +1,202 @@ +using System.Data.Common; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using StopShopping.EF; +using StopShopping.Services.Models.Req; +using StopShopping.Services.Models.Resp; + +namespace StopShopping.Services.Implementions; + +public class CategoryService : ICategoryService +{ + public CategoryService( + IFileService fileService, + StopShoppingContext dbContext, + ILogger logger + ) + { + _fileService = fileService; + _dbContext = dbContext; + _logger = logger; + } + + private readonly IFileService _fileService; + private readonly StopShoppingContext _dbContext; + private readonly ILogger _logger; + + public async Task DeleteCategoryAsync(CategoryIdParams model) + { + var category = await _dbContext.Categories + .AsNoTracking() + .FirstOrDefaultAsync(c => c.Id == model.CategoryId && !c.Deleted); + if (null == category) + return ApiResponse.Failed("此分类已不存在,请刷新重试"); + + var anyProduct = await _dbContext.Products + .AsNoTracking() + .AnyAsync(p => + _dbContext.Categories + .AsNoTracking() + .Where(c => c.Path.StartsWith(category.Path) && !c.Deleted) + .Select(c => c.Id) + .Contains(p.CategoryId)); + if (anyProduct) + return ApiResponse.Failed("分类下已有商品,不允许删除"); + + await _dbContext.Categories + .Where(c => c.Path.StartsWith(category.Path) && !c.Deleted) + .ExecuteDeleteAsync(); + + return ApiResponse.Succed(); + } + + public async Task> EditCategoryAsync(EditCategoryParams model) + { + EF.Models.Category category; + + using var trans = await _dbContext.Database.BeginTransactionAsync(); + try + { + short level = 1, order = 1; + string path = string.Empty; + + var parent = await _dbContext.Categories + .FirstOrDefaultAsync(c => c.Id == model.ParentId); + if (null != parent) + level = (short)(parent.Level + 1); + + if (model.Id > 0) + { + category = await _dbContext.Categories + .FirstAsync(c => c.Id == model.Id && !c.Deleted); + if (null == category) + return new ApiResponse().Failed("此分类已不存在"); + + path = category.Path; + order = category.Order; + } + else + { + category = new EF.Models.Category(); + await _dbContext.Categories.AddAsync(category); + + order = await _dbContext.Categories + .Where(c => c.ParentId == model.ParentId) + .Select(c => c.Order) + .DefaultIfEmpty() + .MaxAsync(); + } + + category.Level = level; + if (!string.IsNullOrWhiteSpace(model.Logo)) + category.Logo = model.Logo; + category.Name = model.Name; + category.Order = order; + category.ParentId = model.ParentId; + category.Path = path; + + await _dbContext.SaveChangesAsync(); + + if (!(model.Id > 0)) + { + category.Path = BuildPath(parent?.Path ?? "", category.Id); + await _dbContext.SaveChangesAsync(); + } + + await trans.CommitAsync(); + + var categoryResult = Cast(category); + + return new ApiResponse(categoryResult); + } + catch (DbException e) + { + await trans.RollbackAsync(); + _logger.LogError(e, "新增/修改分类失败"); + return new ApiResponse().Failed("数据库操作失败,请刷新重试"); + } + } + + public ApiResponse> GetCategoriesTree() + { + var qry = _dbContext.Categories + .AsNoTracking() + .Where(c => !c.Deleted) + .OrderBy(c => c.ParentId) + .ThenBy(c => c.Order) + .AsEnumerable(); + + var result = new ApiResponse>(ToTree(qry)); + + return result; + } + + public async Task ResortCategoryAsync(ResortCategoryParams model) + { + var curr = await _dbContext.Categories + .FirstOrDefaultAsync(c => c.Id == model.Id && !c.Deleted); + if (null == curr) + return ApiResponse.Failed("此分类已不存在,请刷新重试"); + + var target = await _dbContext.Categories + .FirstOrDefaultAsync(c => c.Order == model.TargetOrder && !c.Deleted); + if (null == target) + return ApiResponse.Failed("目标位置分类已不存在,请刷新重试"); + + target.Order = curr.Order; + curr.Order = model.TargetOrder; + + await _dbContext.SaveChangesAsync(); + + return ApiResponse.Succed(); + } + + #region private methods + + private List ToTree(IEnumerable models) + { + Dictionary idDicts = []; + List categories = []; + + foreach (var model in models) + { + Category node = Cast(model); + node.Children = []; + idDicts.Add(node.Id, node); + } + + foreach (var d in idDicts) + { + if (d.Value.ParentId > 0) + idDicts[d.Value.ParentId].Children.Add(d.Value); + else + categories.Add(d.Value); + } + + return categories; + } + + private Category Cast(EF.Models.Category model) + { + return new() + { + Id = model.Id, + LogoUrl = string.IsNullOrWhiteSpace(model.Logo) + ? "" + : _fileService.GetFileUrl(Models.UploadScences.Category, model.Logo), + Name = model.Name, + Order = model.Order, + ParentId = model.ParentId, + Children = [] + }; + } + + private string BuildPath(string prefix, params int[] ids) + { + return string.Format("{0}/{1}/", + prefix.TrimEnd('/') + , string.Join('/', ids)); + } + + #endregion +} \ No newline at end of file diff --git a/StopShopping.Services/Implementions/CipherService.cs b/StopShopping.Services/Implementions/CipherService.cs new file mode 100644 index 0000000..1a8a30d --- /dev/null +++ b/StopShopping.Services/Implementions/CipherService.cs @@ -0,0 +1,18 @@ +using System.Security.Cryptography; +using System.Text; + +namespace StopShopping.Services.Implementions; + +public class CipherService : ICipherService +{ + public string EncryptUserPassword(string input) + { + string hmacKey = "stopshopping"; + + using HMACSHA256 sha256 = new(Encoding.UTF8.GetBytes(hmacKey)); + + var hash = sha256.ComputeHash(Encoding.UTF8.GetBytes(input)); + + return Convert.ToHexStringLower(hash); + } +} diff --git a/StopShopping.Services/Implementions/ClaimsService.cs b/StopShopping.Services/Implementions/ClaimsService.cs new file mode 100644 index 0000000..21ff91c --- /dev/null +++ b/StopShopping.Services/Implementions/ClaimsService.cs @@ -0,0 +1,54 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using Microsoft.AspNetCore.Http; +using StopShopping.EF.Models; +using StopShopping.Services.Models; + +namespace StopShopping.Services.Implementions; + +public class ClaimsService : IClaimsService +{ + public ClaimsService(IHttpContextAccessor httpContextAccessor) + { + _httpContextAccessor = httpContextAccessor; + } + + private readonly IHttpContextAccessor _httpContextAccessor; + + public ClaimsIdentity BuildIdentity(User user) + { + var claimsIdentity = new ClaimsIdentity( + [ + new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString()), + new Claim(JwtRegisteredClaimNames.Name, user.Account), + new Claim(JwtRegisteredClaimNames.Nickname, user.NickName), + new Claim(ClaimTypes.Role, SystemRoles.User.ToString()), + ]); + + return claimsIdentity; + } + + public ClaimsIdentity BuildAdminIdentity(Administrator admin) + { + var claimsIdentity = new ClaimsIdentity( + [ + new Claim(JwtRegisteredClaimNames.Sub, admin.Id.ToString()), + new Claim(JwtRegisteredClaimNames.Name, admin.Account), + new Claim(JwtRegisteredClaimNames.Nickname, admin.NickName), + new Claim(ClaimTypes.Role, SystemRoles.Admin.ToString()), + ]); + + return claimsIdentity; + } + + public int GetCurrentUserId() + { + var currUserId = _httpContextAccessor.HttpContext + ?.User.FindFirstValue(JwtRegisteredClaimNames.Sub); + + if (string.IsNullOrWhiteSpace(currUserId)) + throw new InvalidOperationException("在错误的位置获取当前登录用户"); + + return Convert.ToInt32(currUserId); + } +} \ No newline at end of file diff --git a/StopShopping.Services/Implementions/DistrictService.cs b/StopShopping.Services/Implementions/DistrictService.cs new file mode 100644 index 0000000..3d62f21 --- /dev/null +++ b/StopShopping.Services/Implementions/DistrictService.cs @@ -0,0 +1,240 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using StopShopping.EF; +using StopShopping.Services.Models.Req; +using StopShopping.Services.Models.Resp; +using O = StopShopping.OpenPlatform; + +namespace StopShopping.Services.Implementions; + +public class DistrictService : IDistrictService +{ + public DistrictService( + StopShoppingContext dbContext, + O.IDistrictService districtService, + ILogger logger) + { + _dbContext = dbContext; + _oDistrictService = districtService; + _logger = logger; + } + + private readonly StopShoppingContext _dbContext; + private readonly O.IDistrictService _oDistrictService; + private readonly ILogger _logger; + + public async Task InitialDatabaseAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("开始初始化行政区划"); + + if (await _dbContext.Districts.AnyAsync(cancellationToken)) + { + _logger.LogInformation("已经存在数据,无需初始化"); + } + else + { + var districtsResp = await _oDistrictService.GetTop3LevelDistrictsAsync(cancellationToken); + if (districtsResp?.IsSucced ?? false) + { + if (null != districtsResp?.Districts) + { + using var transaction = await _dbContext.Database.BeginTransactionAsync(cancellationToken); + + short order = 1; + int parentId = 0; + var districtsProvince = districtsResp.Districts + .Select(d => Cast(d, parentId, order++)) + .ToList(); + await _dbContext.Districts.AddRangeAsync(districtsProvince, cancellationToken); + await _dbContext.SaveChangesAsync(cancellationToken); + + foreach (var province in districtsResp.Districts) + { + parentId = districtsProvince.First(p => p.Code == province.Code).Id; + order = 1; + + var citys = province.Districts; + + if (null == citys) + { + await transaction.RollbackAsync(cancellationToken); + _logger.LogError("行政区划下级数据不完整:{Name}", province.Name); + return; + } + + var districtsCity = citys + .Select(d => Cast(d, parentId, order++)) + .ToList(); + await _dbContext.Districts.AddRangeAsync(districtsCity, cancellationToken); + await _dbContext.SaveChangesAsync(cancellationToken); + + // 直辖市下属区 + // var directlyCitysDistrict = citys + // .Where(d => d.Districts?.Count == 0) + // .ToList(); + // if (0 == directlyCitysDistrict.Count) + // { + // await transaction.RollbackAsync(cancellationToken); + // _logger.LogError("行政区划数据不完整,缺少直辖市数据"); + // return; + // } + + var semaphoreSlim = new SemaphoreSlim(50); + //(parent id, district response) + List> districtTasks = []; + + foreach (var city in citys) + { + parentId = districtsCity.First(c => c.Code == city.Code).Id; + order = 1; + if (city.Districts == null || city.Districts.Count == 0)//直辖市 + { + try + { + await semaphoreSlim.WaitAsync(cancellationToken); + districtTasks.Add( + Task.FromResult(( + parentId + , await _oDistrictService.GetChildrenAsync(city.Code, cancellationToken))) + ); + } + finally + { + semaphoreSlim.Release(); + } + } + else + { + var districtsDistrict = city.Districts + .Select(d => Cast(d, parentId, order++)) + .ToList(); + await _dbContext.Districts.AddRangeAsync(districtsDistrict, cancellationToken); + await _dbContext.SaveChangesAsync(cancellationToken); + + var districtTask = city.Districts!.Select(async (d) => + { + try + { + await semaphoreSlim.WaitAsync(); + + return ( + districtsDistrict.First(dd => dd.Code == d.Code).Id + , await _oDistrictService.GetChildrenAsync(d.Code, cancellationToken) + ); + } + finally + { + semaphoreSlim.Release(); + } + }); + + districtTasks.AddRange(districtTask); + } + } + + var streets = await Task.WhenAll(districtTasks); + foreach (var s in streets) + { + order = 1; + parentId = s.Item1; + if (s.Item2?.IsSucced ?? false) + { + var districtsStreet = s.Item2.Districts + ?.Select(ds => Cast(ds, parentId, order++)) + .ToList(); + + if (null != districtsStreet) + { + await _dbContext.Districts.AddRangeAsync(districtsStreet, cancellationToken); + await _dbContext.SaveChangesAsync(cancellationToken); + } + } + else + { + await transaction.RollbackAsync(cancellationToken); + _logger.LogError("街道数据请求失败:{Message}", s.Item2?.Message); + return; + } + } + } + await transaction.CommitAsync(cancellationToken); + } + } + else + { + _logger.LogError("行政区划接口错误:{Message}", districtsResp?.Message); + } + } + + _logger.LogInformation("结束初始化行政区划"); + } + + public async Task>> GetTop3LevelDistrictsAsync() + { + List districts = []; + + var top3Districts = await _dbContext.Districts + .Where(d => d.Level <= 3) + .AsNoTracking() + .ToListAsync(); + + if (0 != top3Districts.Count) + { + Dictionary idDicts = []; + foreach (var dbDistrict in top3Districts) + { + idDicts.Add(dbDistrict.Id, Cast(dbDistrict)); + } + foreach (var kv in idDicts) + { + if (kv.Value.ParentId > 0) + idDicts[kv.Value.ParentId].Children.Add(kv.Value); + else + districts.Add(kv.Value); + } + } + + var result = new ApiResponse>(districts); + + return result; + } + + public ApiResponse> GetChildren(DistrictParentIdParams model) + { + var children = _dbContext.Districts + .Where(d => d.ParentId == model.ParentId) + .AsNoTracking() + .Select(Cast) + .ToList(); + + return new ApiResponse>(children); + } + + private District Cast(EF.Models.District district) + { + return new District + { + Children = [], + FullName = district.FullName, + Id = district.Id, + Level = district.Level, + ParentId = district.ParentId + }; + } + + private EF.Models.District Cast(O.DistrictResponse.District district, int parentId, short order) + { + return new EF.Models.District + { + Code = district.Code, + FullName = district.FullName, + Latitude = district.Latitude.ToString(), + Level = (short)(district.Level ?? 0), + Longitude = district.Longitude.ToString(), + Name = district.Name, + Order = order, + ParentId = parentId, + Pinyin = district.PinYin ?? "" + }; + } +} \ No newline at end of file diff --git a/StopShopping.Services/Implementions/FileService.cs b/StopShopping.Services/Implementions/FileService.cs new file mode 100644 index 0000000..a4f1223 --- /dev/null +++ b/StopShopping.Services/Implementions/FileService.cs @@ -0,0 +1,65 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Options; +using StopShopping.Services.Extensions; +using StopShopping.Services.Models; +using StopShopping.Services.Models.Req; +using StopShopping.Services.Models.Resp; + +namespace StopShopping.Services.Implementions; + +public class FileService : IFileService +{ + public FileService(IOptions appOptions, + IWebHostEnvironment webHostEnvironment) + { + _appOptions = appOptions.Value; + _env = webHostEnvironment; + } + + private readonly AppOptions _appOptions; + private readonly IWebHostEnvironment _env; + + public async Task> 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); + + FileUpload result = new() + { + NewName = newFullName, + Url = GetFileUrl(payload.Scences, newFullName) + }; + + return new ApiResponse(result); + } + + public string GetFileUrl(UploadScences scences, string fileName) + { + var relativeToRootPath = GetRelativeToRootPath(scences, fileName); + + return $"{_appOptions.DomainPath}/{relativeToRootPath.Replace(Path.DirectorySeparatorChar, '/')}"; + } + + private string GetRelativeToRootPath(UploadScences scences, string fileName = "") + { + return Path.Combine( + "images", + scences.GetTargetDirectory(), + fileName); + } +} diff --git a/StopShopping.Services/Implementions/NanoidSerialNoGenerator.cs b/StopShopping.Services/Implementions/NanoidSerialNoGenerator.cs new file mode 100644 index 0000000..765f152 --- /dev/null +++ b/StopShopping.Services/Implementions/NanoidSerialNoGenerator.cs @@ -0,0 +1,27 @@ +using NanoidDotNet; + +namespace StopShopping.Services.Implementions; + +public class NanoidSerialNoGenerator : ISerialNoGenerator +{ + public string GenerateProductNo() + { + string no = Nanoid.Generate(Nanoid.Alphabets.Digits, 8); + + return $"P{no}"; + } + + public string GenerateRandomPassword() + { + string pwd = Nanoid.Generate(Nanoid.Alphabets.Default, 8); + + return pwd; + } + + public string GenerateRequestNo() + { + string no = Nanoid.Generate(Nanoid.Alphabets.Digits, 8); + + return $"R{no}"; + } +} \ No newline at end of file diff --git a/StopShopping.Services/Implementions/ProductService.cs b/StopShopping.Services/Implementions/ProductService.cs new file mode 100644 index 0000000..bf9dbe1 --- /dev/null +++ b/StopShopping.Services/Implementions/ProductService.cs @@ -0,0 +1,193 @@ +using Microsoft.EntityFrameworkCore; +using StopShopping.EF; +using StopShopping.Services.Models.Req; +using StopShopping.Services.Models.Resp; + +namespace StopShopping.Services.Implementions; + +public class ProductService : IProductService +{ + public ProductService( + StopShoppingContext dbContext, + IClaimsService claimsService, + IFileService fileService, + ISerialNoGenerator serialNoGenerator) + { + _dbContext = dbContext; + _claimsService = claimsService; + _fileService = fileService; + _serilNoGenerator = serialNoGenerator; + } + + private readonly StopShoppingContext _dbContext; + private readonly IClaimsService _claimsService; + private readonly IFileService _fileService; + private readonly ISerialNoGenerator _serilNoGenerator; + + public ApiResponse Detail(ProductIdParams model) + { + var detail = _dbContext.Products + .AsNoTracking() + .Where(p => p.Id == model.ProductId && !p.Deleted) + .Include(p => p.Category) + .Select(Cast) + .FirstOrDefault(); + if (null == detail) + return new ApiResponse().Failed("商品已不存在,请刷新重试"); + + return new ApiResponse(detail); + } + + public async Task>> SearchAsync(ProductSearchParms model) + { + var userId = _claimsService.GetCurrentUserId(); + var qry = _dbContext.Products + .AsNoTracking() + .Include(p => p.Category) + .Where(p => !p.Deleted && p.UserId == userId); + if (model.CategoryId > 0) + { + var categoryPath = (await _dbContext.Categories + .FirstOrDefaultAsync(c1 => c1.Id == model.CategoryId)) + ?.Path ?? ""; + + qry = qry.Where(p => + _dbContext.Categories + .Where(c => c.Path.StartsWith(categoryPath)) + .Select(c => c.Id) + .Contains(p.CategoryId)); + } + if (!string.IsNullOrWhiteSpace(model.Keyword)) + qry = qry.Where(p => + p.Name.Contains(model.Keyword) + || (p.Description != null && p.Description.Contains(model.Keyword))); + + var orderedQry = WithOrderBys(qry, model.OrderBys); + + var result = await orderedQry! + .Select(Cast) + .ToAsyncEnumerable() + .ToPagedAsync(model.PageIndex, model.PageSize); + + return new ApiResponse>(result); + } + + public async Task EditAsync(EditProductParams model) + { + var userId = _claimsService.GetCurrentUserId(); + EF.Models.Product? product = null; + if (model.Id > 0) + { + product = await _dbContext.Products + .FirstOrDefaultAsync(p => + p.Id == model.Id.Value + && p.UserId == userId + && !p.Deleted); + if (null == product) + return ApiResponse.Failed("商品已不存在,请刷新重试"); + } + else + { + product = new() + { + SerialNo = _serilNoGenerator.GenerateProductNo(), + UserId = userId, + Logo = string.IsNullOrWhiteSpace(model.LogoName) + ? Consts.DEFAULT_PRODUCT + : model.LogoName + }; + await _dbContext.Products.AddAsync(product); + } + product.CategoryId = model.CategoryId; + product.Description = model.Description; + product.Detail = model.Detail; + product.MinimumUnit = model.MinimumUnit ?? ""; + product.Name = model.Name ?? ""; + product.UnitPrice = model.UnitPrice; + + await _dbContext.SaveChangesAsync(); + + return ApiResponse.Succed(); + } + + public async Task DeleteAsync(ProductIdParams model) + { + var userId = _claimsService.GetCurrentUserId(); + var product = await _dbContext.Products + .FirstOrDefaultAsync(p => + p.Id == model.ProductId + && p.UserId == userId + && !p.Deleted); + if (null == product) + return ApiResponse.Failed("此商品已不存在,请刷新重试"); + + product.Deleted = true; + await _dbContext.SaveChangesAsync(); + + return ApiResponse.Succed(); + } + + #region private methods + + private IOrderedQueryable? WithOrderBys(IQueryable qry, IEnumerable orderBys) + { + IOrderedQueryable? orderedQry = null; + foreach (var orderby in orderBys) + { + if (null == orderedQry) + orderedQry = orderby switch + { + ProductOrderBys.CreateTime => qry.OrderBy(p => p.CreateTime), + ProductOrderBys.CreateTimeDesc => qry.OrderByDescending(p => p.CreateTime), + ProductOrderBys.Category => qry.OrderBy(p => p.Category), + ProductOrderBys.CategoryDesc => qry.OrderByDescending(p => p.Category), + ProductOrderBys.SoldAmount => qry.OrderBy(p => p.SoldAmount), + ProductOrderBys.SoldAmountDesc => qry.OrderByDescending(p => p.SoldAmount), + _ => qry.OrderBy(p => p.CreateTime), + }; + else + orderedQry = orderby switch + { + ProductOrderBys.CreateTime => orderedQry.ThenBy(p => p.CreateTime), + ProductOrderBys.CreateTimeDesc => orderedQry.ThenByDescending(p => p.CreateTime), + ProductOrderBys.Category => orderedQry.ThenBy(p => p.Category), + ProductOrderBys.CategoryDesc => orderedQry.ThenByDescending(p => p.Category), + ProductOrderBys.SoldAmount => orderedQry.ThenBy(p => p.SoldAmount), + ProductOrderBys.SoldAmountDesc => orderedQry.ThenByDescending(p => p.SoldAmount), + _ => orderedQry.ThenBy(p => p.CreateTime), + }; + } + return orderedQry; + } + + private T Cast(EF.Models.Product product) + where T : Product, new() + { + var result = new T + { + CategoryName = product.Category.Name, + CreateTime = product.CreateTime.ToFormatted(), + Description = product.Description, + Id = product.Id, + MinimumUnit = product.MinimumUnit, + Name = product.Name, + UnitPrice = product.UnitPrice, + SoldAmount = product.SoldAmount, + LogoUrl = _fileService.GetFileUrl(Models.UploadScences.Product, product.Logo) + }; + if (result is ProductInfo) + { + (result as ProductInfo)!.CategoryId = product.CategoryId; + (result as ProductInfo)!.Detail = product.Detail; + } + + return result; + } + + private string BuildCategoryPath(int categoryId) + { + return $"/{categoryId}/"; + } + + #endregion +} \ No newline at end of file diff --git a/StopShopping.Services/Implementions/ReplyService.cs b/StopShopping.Services/Implementions/ReplyService.cs new file mode 100644 index 0000000..ad79b64 --- /dev/null +++ b/StopShopping.Services/Implementions/ReplyService.cs @@ -0,0 +1,102 @@ +using System.Data.Common; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using StopShopping.EF; +using StopShopping.Services.Extensions; +using StopShopping.Services.Models; +using StopShopping.Services.Models.Req; +using StopShopping.Services.Models.Resp; + +namespace StopShopping.Services.Implementions; + +public class ReplyService : IReplyService +{ + public ReplyService( + IClaimsService claimsService, + StopShoppingContext dbContext, + ILogger logger) + { + _claimsService = claimsService; + _dbContext = dbContext; + _logger = logger; + } + + private readonly IClaimsService _claimsService; + private readonly StopShoppingContext _dbContext; + private readonly ILogger _logger; + + public async Task>> GetRepliesAsync(RequestIdParams model) + { + var request = await _dbContext.Requests + .Include(r => r.Replies) + .ThenInclude(r => r.Product) + .Include(r => r.Replies) + .ThenInclude(r => r.User) + .AsNoTracking() + .Where(r => r.Id == model.RequestId && !r.Deleted) + .FirstOrDefaultAsync(); + if (null == request) + return new ApiResponse>().Failed("此需求已不存在,请刷新重试"); + + var replies = request.Replies + .Where(r => !r.Rejected) + .Select(r => new Reply + { + Amount = r.Amount, + Id = r.Id, + Memo = r.Memo, + ProductId = r.ProductId, + ProductName = r.Product.Name, + Replier = r.User.NickName, + ReplyTime = r.ReplyTime.ToFormatted(), + UnitPrice = r.Product.UnitPrice, + MinimumUnit = r.Product.MinimumUnit + }).ToList(); + + return new ApiResponse>(replies); + } + + public async Task ReplyAsync(ReplyParams model) + { + var userId = _claimsService.GetCurrentUserId(); + + using var trans = await _dbContext.Database.BeginTransactionAsync(); + try + { + var request = await _dbContext.Requests + .Where(r => r.Id == model.RequestId && !r.Deleted) + .FirstOrDefaultAsync(); + if (null == request) + return ApiResponse.Failed("此需求已不存在,请刷新重试"); + + var status = (RequestStatus)request.Status; + if (!status.CanReply()) + return ApiResponse.Failed("此需求已完成,请尝试其他需求"); + + request.Status = (short)RequestStatus.Replied; + + EF.Models.Reply reply = new() + { + Amount = model.Amount, + Memo = model.Memo, + Price = model.Price, + ProductId = model.ProductId, + RequestId = model.RequestId, + UserId = userId + }; + + await _dbContext.Replies.AddAsync(reply); + await _dbContext.SaveChangesAsync(); + + await trans.CommitAsync(); + } + catch (DbException ex) + { + await trans.RollbackAsync(); + _logger.LogError(ex, "提交竞标失败"); + return ApiResponse.Failed("服务器错误,请刷新重试"); + } + + return ApiResponse.Succed(); + } +} \ No newline at end of file diff --git a/StopShopping.Services/Implementions/RequestService.cs b/StopShopping.Services/Implementions/RequestService.cs new file mode 100644 index 0000000..59e4972 --- /dev/null +++ b/StopShopping.Services/Implementions/RequestService.cs @@ -0,0 +1,191 @@ +using Microsoft.EntityFrameworkCore; +using StopShopping.EF; +using StopShopping.Services.Extensions; +using StopShopping.Services.Models; +using StopShopping.Services.Models.Req; +using StopShopping.Services.Models.Resp; + +namespace StopShopping.Services.Implementions; + +public class RequestService : IRequestService +{ + public RequestService( + StopShoppingContext dbContext, + IClaimsService claimsService, + ISerialNoGenerator serialNoGenerator) + { + _dbContext = dbContext; + _claimService = claimsService; + _serialNoGenerator = serialNoGenerator; + } + + private readonly StopShoppingContext _dbContext; + private readonly IClaimsService _claimService; + private readonly ISerialNoGenerator _serialNoGenerator; + + public async Task PublishRequestAsync(CreateRequestParams model) + { + var serialNo = _serialNoGenerator.GenerateRequestNo(); + var userId = _claimService.GetCurrentUserId(); + + EF.Models.Request req = new() + { + CategoryId = model.CategoryId, + Deadline = DateOnly.Parse(model.Deadline), + Description = model.Description, + Name = model.Name, + PublisherId = userId, + SerialNo = serialNo, + Status = (short)RequestStatus.Publish, + }; + + await _dbContext.Requests.AddAsync(req); + await _dbContext.SaveChangesAsync(); + + return ApiResponse.Succed(); + } + + public async Task>> SearchAsync(RequestSearchParams model) + { + return await DoSearchAsync(model, null); + } + + public async Task>> RequestOrderSearchAsync(RequestSearchWithStatusParams model) + { + return await DoSearchAsync(model, UserRoles.Buyer); + } + + public async Task>> ReplyOrderSearchAsync(RequestSearchWithStatusParams model) + { + return await DoSearchAsync(model, UserRoles.Seller); + } + + public async Task DeleteRequestAsync(RequestIdParams model) + { + var userId = _claimService.GetCurrentUserId(); + + var request = await _dbContext.Requests + .Include(r => r.Replies) + .Where(r => r.Id == model.RequestId && !r.Deleted) + .FirstOrDefaultAsync(); + + if (null == request) + return ApiResponse.Failed("此需求已不存在,请刷新重试"); + + var status = (RequestStatus)request.Status; + if (status.CanDelete()) + return ApiResponse.Failed("此需求状态已改变,请刷新重试"); + + request.Deleted = true; + request.Status = (short)RequestStatus.Completed; + + foreach (var reply in request.Replies) + { + reply.Rejected = true; + } + + await _dbContext.SaveChangesAsync(); + + return ApiResponse.Succed(); + } + + private async Task>> DoSearchAsync(T model, UserRoles? userRoles) + where T : RequestSearchParams, new() + { + var qry = _dbContext.Requests + .AsNoTracking() + .Where(q => !q.Deleted); + + if (model is RequestSearchWithStatusParams statusParams) + { + if (statusParams.Status != RequestStatus.All) + qry = qry.Where(q => q.Status == (short)statusParams.Status); + } + else + { + qry = qry.Where(q => q.Status == (short)RequestStatus.Publish + || q.Status == (short)RequestStatus.Replied); + } + + if (model.CategoryId > 0) + { + string categoryPath = $"/{model.CategoryId.Value}/"; + qry = qry.Where(q => + _dbContext.Categories + .Where(c => c.Path.StartsWith(categoryPath) && !c.Deleted) + .Select(c => c.Id) + .Contains(q.CategoryId)); + } + if (!string.IsNullOrEmpty(model.Keyword)) + { + qry = qry.Where(q => + q.Name.Contains(model.Keyword) + || (q.Description != null && q.Description.Contains(model.Keyword)) + || q.SerialNo.Contains(model.Keyword)); + } + + if (userRoles.HasValue) + { + var userId = _claimService.GetCurrentUserId(); + qry = userRoles.Value switch + { + UserRoles.Seller => qry.Where(q => q.Replies.Any(r => r.UserId == userId)), + UserRoles.Buyer => qry.Where(q => q.PublisherId == userId), + _ => qry + }; + } + + var firstOrderBy = model.OrderBys!.First(); + var included = qry + .Include(r => r.Publisher).AsNoTracking() + .Include(r => r.Category).AsNoTracking() + .Select(r => new + { + ReplyAmount = r.Replies.Count, + r + }); + + var ordered = firstOrderBy switch + { + RequestOrderBys.PublishTime => included.OrderBy(q => q.r.PublishTime), + RequestOrderBys.PublishTimeDesc => included.OrderByDescending(q => q.r.PublishTime), + RequestOrderBys.CategoryId => included.OrderBy(q => q.r.CategoryId), + RequestOrderBys.CategoryIdDesc => included.OrderByDescending(q => q.r.CategoryId), + RequestOrderBys.ReplyAmount => included.OrderBy(q => q.ReplyAmount), + RequestOrderBys.ReplyAmountDesc => included.OrderByDescending(q => q.ReplyAmount), + _ => included.OrderBy(q => q.r.PublishTime) + }; + + foreach (var orderBy in model.OrderBys!.Skip(1)) + { + ordered = orderBy switch + { + RequestOrderBys.PublishTime => ordered!.ThenBy(q => q.r.PublishTime), + RequestOrderBys.PublishTimeDesc => ordered!.ThenByDescending(q => q.r.PublishTime), + RequestOrderBys.CategoryId => ordered!.ThenBy(q => q.r.CategoryId), + RequestOrderBys.CategoryIdDesc => ordered!.ThenByDescending(q => q.r.CategoryId), + RequestOrderBys.ReplyAmount => ordered!.ThenBy(q => q.ReplyAmount), + RequestOrderBys.ReplyAmountDesc => ordered!.ThenByDescending(q => q.ReplyAmount), + _ => ordered!.ThenBy(q => q.r.PublishTime) + }; + } + + var paged = await ordered.Select(r => new Request + { + CategoryId = r.r.Category.Id, + CategoryName = r.r.Category.Name, + Deadline = r.r.Deadline.ToFormatted(), + Description = r.r.Description, + Id = r.r.Id, + Name = r.r.Name, + Publisher = r.r.Publisher.NickName, + PublishTime = r.r.PublishTime.ToFormatted(), + ReplyAmount = r.ReplyAmount, + SerialNo = r.r.SerialNo, + Status = (RequestStatus)r.r.Status, + + }).ToAsyncEnumerable().ToPagedAsync(model.PageIndex, model.PageSize); + + return new ApiResponse>(paged); + } +} \ No newline at end of file diff --git a/StopShopping.Services/Implementions/SerialNoGenerator.cs b/StopShopping.Services/Implementions/SerialNoGenerator.cs new file mode 100644 index 0000000..3a316b3 --- /dev/null +++ b/StopShopping.Services/Implementions/SerialNoGenerator.cs @@ -0,0 +1,118 @@ +using Microsoft.Extensions.Logging; + +namespace StopShopping.Services.Implementions; + +public class SerialNoGenerator : ISerialNoGenerator +{ + public SerialNoGenerator(ILogger logger) + { + _logger = logger; + } + + private readonly ILogger _logger; + + private const long START_UTC = 1767196800000; //2026-01-01 00:00:00.000 + private const int BITS_CALLBACK = 3; + private const int BITS_SERIAL = 6; + private const long MASK_CALLBACK = ~(-1L << BITS_CALLBACK); //0b_0111 + private const long MASK_SERIAL = ~(-1L << BITS_SERIAL); + + private readonly object _lockObj = new(); + + private long _lastUtc = DateTimeOffset.Now.ToUnixTimeMilliseconds(); + private long _serial; + private long _callbackSerial; + + /// + /// + /// + /// + /// 0000000 + /// + /// + /// 00000000000 + /// + /// + /// 00000000000 + /// + /// + /// 0000000000 + /// + /// + /// + /// + /// + /// 符号位1bit + /// + /// + /// + /// 毫秒时间戳56bit + /// + /// + /// 时间回拨标识3bit + /// + /// + /// 毫秒内序号6bit + /// + /// + /// + /// + public string GenerateRequestNo() + { + lock (_lockObj) + { + var currUtc = DateTimeOffset.Now.ToUnixTimeMilliseconds(); + + if (currUtc < _lastUtc) //时间回拨 + { + currUtc = _lastUtc; + _callbackSerial = (_callbackSerial + 1) & MASK_CALLBACK; + if (_callbackSerial == 0) //时间回拨标识数据段用完 + { + _logger.LogWarning("服务器时间不准,序列号可能重复,请检查!"); + } + } + + if (currUtc > _lastUtc) + { + _serial = 1; + _callbackSerial = 0; + } + else + { + _serial = (_serial + 1) & MASK_SERIAL; + if (_serial == 0) //毫秒内数据段用完 + { + WaitNextMilliSecond(ref currUtc); + _serial = 1; + } + } + + _lastUtc = currUtc; + + long serial = ((currUtc - START_UTC) << BITS_CALLBACK + BITS_SERIAL) + | (_callbackSerial << BITS_SERIAL) + | _serial; + + return string.Format("R{0}", serial); + } + } + + public string GenerateProductNo() + { + throw new NotImplementedException(); + } + + private void WaitNextMilliSecond(ref long curr) + { + while (_lastUtc >= curr) + { + curr = DateTimeOffset.Now.ToUnixTimeMilliseconds(); + } + } + + public string GenerateRandomPassword() + { + throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/StopShopping.Services/Implementions/UserService.cs b/StopShopping.Services/Implementions/UserService.cs new file mode 100644 index 0000000..77cb42c --- /dev/null +++ b/StopShopping.Services/Implementions/UserService.cs @@ -0,0 +1,284 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using StopShopping.EF; +using StopShopping.Services.Extensions; +using StopShopping.Services.Models; +using StopShopping.Services.Models.Req; +using StopShopping.Services.Models.Resp; + +namespace StopShopping.Services.Implementions; + +public class UserService : IUserService +{ + public UserService( + ILogger logger, + StopShoppingContext dbContext, + ICipherService cipherService, + IAccessTokenService accessTokenService, + IClaimsService claimsService, + IFileService fileService, + ISerialNoGenerator serialNoGenerator) + { + _logger = logger; + _dbContext = dbContext; + _cipherService = cipherService; + _accessTokenService = accessTokenService; + _claimsService = claimsService; + _fileService = fileService; + _serialNoGenerator = serialNoGenerator; + } + + private readonly ILogger _logger; + private readonly StopShoppingContext _dbContext; + private readonly ICipherService _cipherService; + private readonly IAccessTokenService _accessTokenService; + private readonly IClaimsService _claimsService; + private readonly IFileService _fileService; + private readonly ISerialNoGenerator _serialNoGenerator; + + public async Task SignUpAsync(SignUpParams model) + { + var user = await _dbContext.Users.FirstOrDefaultAsync(u => u.Account == model.Account); + if (null != user) + return ApiResponse.Failed("帐户名已存在"); + + user = new EF.Models.User + { + Account = model.Account!, + Addresses = [], + Avatar = Consts.DEFAULT_AVATAR, + CurrentRole = model.DefaultRole.GetValue(), + NickName = model.NickName!, + Password = _cipherService.EncryptUserPassword(model.Password!) + }; + + await _dbContext.AddAsync(user); + if (await _dbContext.SaveChangesAsync() > 0) + return ApiResponse.Succed(); + + return ApiResponse.Failed("数据库操作失败"); + } + + public async Task> SignInAsync(SignInParams model) + { + SignInResult result = new(); + + var user = await _dbContext.Users + .FirstOrDefaultAsync(u => u.Account == model.Account); + if (null == user || user.Password != _cipherService.EncryptUserPassword(model.Password!)) + { + result.IsSucced = false; + result.Message = "账号或密码错误"; + + return result; + } + user.LastLoginTime = DateTime.Now; + await _dbContext.SaveChangesAsync(); + + var claimsIdentity = _claimsService.BuildIdentity(user); + + result.RefreshToken = await _accessTokenService.SetRefreshTokenAsync(user.Id, SystemRoles.User); + + result.User = new SignInUser + { + AvatarUrl = string.IsNullOrWhiteSpace(user.Avatar) + ? null + : _fileService.GetFileUrl(UploadScences.Avatar, user.Avatar), + NickName = user.NickName, + DefaultRole = user.CurrentRole.ToUserRoles(), + AccessToken = _accessTokenService.GenerateAccessToken(claimsIdentity) + }; + + return result; + } + + public async Task> SignInAdminAsync(SignInParams model) + { + SignInResult result = new(); + + var admin = await _dbContext.Administrators + .FirstOrDefaultAsync(u => u.Account == model.Account); + if (null == admin || admin.Password != _cipherService.EncryptUserPassword(model.Password!)) + { + result.IsSucced = false; + result.Message = "账号或密码错误"; + + return result; + } + admin.LastLoginTime = DateTime.Now; + await _dbContext.SaveChangesAsync(); + + var claimsIdentity = _claimsService.BuildAdminIdentity(admin); + + result.RefreshToken = await _accessTokenService.SetRefreshTokenAsync(admin.Id, SystemRoles.Admin); + + result.User = new SignInAdmin + { + NickName = admin.NickName, + AccessToken = _accessTokenService.GenerateAccessToken(claimsIdentity) + }; + + return result; + } + + public async Task GenerateDefaultAdminAsync() + { + var defaultAdmin = await _dbContext.Administrators + .Where(a => a.Account == Consts.DEFAULT_ADMIN_ACCOUNT) + .FirstOrDefaultAsync(); + if (null != defaultAdmin) + { + _logger.LogInformation("默认管理员已存在,已跳过"); + return; + } + + var pwd = _serialNoGenerator.GenerateRandomPassword(); + defaultAdmin = new() + { + Account = Consts.DEFAULT_ADMIN_ACCOUNT, + NickName = "超级管理员", + Password = _cipherService.EncryptUserPassword(pwd) + }; + await _dbContext.Administrators.AddAsync(defaultAdmin); + await _dbContext.SaveChangesAsync(); + + _logger.LogInformation( + "默认管理员({Account})已生成,请立马修改密码:{Password}", + Consts.DEFAULT_ADMIN_ACCOUNT, + pwd); + } + + public async Task ChangePasswordAsync(ChangePasswordParams model) + { + int userId = _claimsService.GetCurrentUserId(); + var user = await _dbContext.Users + .FirstAsync(u => u.Id == userId); + + if (_cipherService.EncryptUserPassword(model.OldPassword!) != user.Password) + return ApiResponse.Failed("原密码错误"); + + user.Password = _cipherService.EncryptUserPassword(model.NewPassword!); + + await _dbContext.SaveChangesAsync(); + + return ApiResponse.Succed(); + } + + public async Task> GetUserInfoAsync() + { + var userId = _claimsService.GetCurrentUserId(); + var model = await _dbContext.Users + .FirstAsync(u => u.Id == userId); + + User user = new() + { + Account = model.Account, + AvatarUrl = string.IsNullOrWhiteSpace(model.Avatar) + ? null + : _fileService.GetFileUrl(UploadScences.Avatar, model.Avatar), + DefaultRole = model.CurrentRole.ToUserRoles(), + LastLoginTime = model.LastLoginTime?.ToFormatted(), + NickName = model.NickName, + Telephone = model.Telephone + }; + + return new ApiResponse(user); + } + + public async Task EditAsync(EditUserParams model) + { + int userId = _claimsService.GetCurrentUserId(); + var user = await _dbContext.Users.FirstAsync(u => u.Id == userId); + + if (!string.IsNullOrWhiteSpace(model.AvatarFileName)) + user.Avatar = model.AvatarFileName; + user.CurrentRole = model.DefaultRole.GetValue(); + if (!string.IsNullOrWhiteSpace(model.NickName)) + user.NickName = model.NickName; + if (!string.IsNullOrWhiteSpace(model.Telephone)) + user.Telephone = model.Telephone; + + await _dbContext.SaveChangesAsync(); + + return ApiResponse.Succed(); + } + + public ApiResponse> GetAddresses() + { + var userId = _claimsService.GetCurrentUserId(); + + var addresses = _dbContext.Addresses + .Where(a => a.UserId == userId) + .OrderByDescending(a => a.Default) + .AsNoTracking() + .Select(Cast) + .ToList(); + + return new ApiResponse>(addresses); + } + + public async Task EditAddressAsync(EditAddressParams model) + { + EF.Models.Address? address = null; + + var userId = _claimsService.GetCurrentUserId(); + + if (model.Id.HasValue && model.Id > 0) + { + address = await _dbContext.Addresses + .FirstOrDefaultAsync(a => a.Id == model.Id && a.UserId == userId); + if (null == address) + return ApiResponse.Failed("地址已不存在"); + } + else + { + address = new(); + await _dbContext.Addresses.AddAsync(address); + } + + address.Default = model.Default; + address.Detail = model.Detail; + address.DistrictLevel1Id = model.DistrictLevel1Id; + address.DistrictLevel2Id = model.DistrictLevel2Id; + address.DistrictLevel3Id = model.DistrictLevel3Id; + address.DistrictLevel4Id = model.DistrictLevel4Id; + address.Name = model.Name; + address.Tag = model.Tag; + address.Telephone = model.Telephone; + + await _dbContext.SaveChangesAsync(); + + return ApiResponse.Succed(); + } + + public async Task DeleteAddressAsync(int id) + { + var userId = _claimsService.GetCurrentUserId(); + await _dbContext.Addresses + .Where(a => a.Id == id && a.UserId == userId) + .ExecuteDeleteAsync(); + + return ApiResponse.Succed(); + } + + #region private methods + + private Address Cast(EF.Models.Address a) + { + return new Address + { + Default = a.Default, + Detail = a.Detail, + DistrictLevel1Id = a.DistrictLevel1Id, + DistrictLevel2Id = a.DistrictLevel2Id, + DistrictLevel3Id = a.DistrictLevel3Id, + DistrictLevel4Id = a.DistrictLevel4Id, + Id = a.Id, + Name = a.Name, + Tag = a.Tag, + Telephone = a.Telephone + }; + } + + #endregion +} diff --git a/StopShopping.Services/JwtOptions.cs b/StopShopping.Services/JwtOptions.cs new file mode 100644 index 0000000..c1a1451 --- /dev/null +++ b/StopShopping.Services/JwtOptions.cs @@ -0,0 +1,10 @@ +namespace StopShopping.Services; + +public record JwtOptions +{ + public string? ValidAudience { get; set; } + public string? ValidIssuer { get; set; } + public string? SigningKey { get; set; } + public int AccessTokenExpiresIn { get; set; } + public int RefreshTokenExpiresIn { get; set; } +} diff --git a/StopShopping.Services/Models/Req/CategoryIdParams.cs b/StopShopping.Services/Models/Req/CategoryIdParams.cs new file mode 100644 index 0000000..2c87a6f --- /dev/null +++ b/StopShopping.Services/Models/Req/CategoryIdParams.cs @@ -0,0 +1,6 @@ +namespace StopShopping.Services.Models.Req; + +public record CategoryIdParams +{ + public int CategoryId { get; set; } +} diff --git a/StopShopping.Services/Models/Req/ChangePasswordParams.cs b/StopShopping.Services/Models/Req/ChangePasswordParams.cs new file mode 100644 index 0000000..476ccee --- /dev/null +++ b/StopShopping.Services/Models/Req/ChangePasswordParams.cs @@ -0,0 +1,24 @@ +using System.ComponentModel.DataAnnotations; + +namespace StopShopping.Services.Models.Req; + +/// +/// 修改密码 +/// +/// +public record ChangePasswordParams +{ + /// + /// 原密码 + /// + /// + [Required] + public string? OldPassword { get; set; } + /// + /// 新密码 + /// + /// + [Required] + [MinLength(6)] + public string? NewPassword { get; set; } +} diff --git a/StopShopping.Services/Models/Req/CreateRequestParams.cs b/StopShopping.Services/Models/Req/CreateRequestParams.cs new file mode 100644 index 0000000..68482d5 --- /dev/null +++ b/StopShopping.Services/Models/Req/CreateRequestParams.cs @@ -0,0 +1,36 @@ +using System.ComponentModel.DataAnnotations; + +namespace StopShopping.Services.Models.Req; + +/// +/// 创建需求请求 +/// +/// +public record CreateRequestParams +{ + /// + /// 商品名 + /// + /// + [Required] + [MaxLength(100)] + public string Name { get; set; } = string.Empty; + /// + /// 描述 + /// + /// + [MaxLength(1000)] + public string? Description { get; set; } + /// + /// 分类id + /// + /// + public int CategoryId { get; set; } + /// + /// 截止日期 + /// + /// + [Required] + [RegularExpression(@"^\d{4}\D\d{1,2}\D\d{1,2}$")] + public string Deadline { get; set; } = string.Empty; +} diff --git a/StopShopping.Services/Models/Req/DistrictParentIdParams.cs b/StopShopping.Services/Models/Req/DistrictParentIdParams.cs new file mode 100644 index 0000000..ca28918 --- /dev/null +++ b/StopShopping.Services/Models/Req/DistrictParentIdParams.cs @@ -0,0 +1,6 @@ +namespace StopShopping.Services.Models.Req; + +public record DistrictParentIdParams +{ + public int ParentId { get; set; } +} diff --git a/StopShopping.Services/Models/Req/EditAddressParams.cs b/StopShopping.Services/Models/Req/EditAddressParams.cs new file mode 100644 index 0000000..098289b --- /dev/null +++ b/StopShopping.Services/Models/Req/EditAddressParams.cs @@ -0,0 +1,67 @@ +using System.ComponentModel.DataAnnotations; + +namespace StopShopping.Services.Models.Req; + +/// +/// 新增/修改收货地址 +/// +/// +public record EditAddressParams +{ + /// + /// 大于0时为修改 + /// + /// + public int? Id { get; set; } + /// + /// 姓名 + /// + /// + [Required] + [MaxLength(20)] + public string Name { get; set; } = string.Empty; + /// + /// 联系电话 + /// + /// + [Required] + [Phone] + public string Telephone { get; set; } = string.Empty; + /// + /// 自定义标签 + /// + /// + [MaxLength(20)] + public string? Tag { get; set; } + /// + /// 是否默认地址 + /// + /// + public bool Default { get; set; } + /// + /// 区域id,表示省/直辖市 + /// + /// + public int DistrictLevel1Id { get; set; } + /// + /// 区域id,表示市/直辖市时为空 + /// + /// + public int? DistrictLevel2Id { get; set; } + /// + /// 区域id,表示区 + /// + /// + public int DistrictLevel3Id { get; set; } + /// + /// 区域id,表示街道/镇 + /// + /// + public int DistrictLevel4Id { get; set; } + /// + /// 详细地址 + /// + /// + [MaxLength(200)] + public string? Detail { get; set; } +} diff --git a/StopShopping.Services/Models/Req/EditCategoryParams.cs b/StopShopping.Services/Models/Req/EditCategoryParams.cs new file mode 100644 index 0000000..dfae9b8 --- /dev/null +++ b/StopShopping.Services/Models/Req/EditCategoryParams.cs @@ -0,0 +1,34 @@ +using System.ComponentModel.DataAnnotations; + +namespace StopShopping.Services.Models.Req; + +/// +/// 新增/修改分类 +/// +/// +public record EditCategoryParams +{ + /// + /// 大于0时修改 + /// + /// + public int? Id { get; set; } + /// + /// 顶级为0 + /// + /// + public int ParentId { get; set; } + /// + /// 名称j + /// + /// + [Required] + [MaxLength(50)] + public string Name { get; set; } = string.Empty; + /// + /// 空时保持不变 + /// + /// + [MaxLength(50)] + public string? Logo { get; set; } +} diff --git a/StopShopping.Services/Models/Req/EditProductParams.cs b/StopShopping.Services/Models/Req/EditProductParams.cs new file mode 100644 index 0000000..79f9bfd --- /dev/null +++ b/StopShopping.Services/Models/Req/EditProductParams.cs @@ -0,0 +1,56 @@ +using System.ComponentModel.DataAnnotations; + +namespace StopShopping.Services.Models.Req; + +/// +/// 新增/修改商品 +/// +public record EditProductParams +{ + /// + /// 大于0时修改 + /// + /// + public int? Id { get; set; } + /// + /// 商品名称 + /// + /// + [Required] + [MaxLength(100)] + public string? Name { get; set; } + /// + /// 简介 + /// + /// + [MaxLength(200)] + public string? Description { get; set; } + /// + /// 图片名,修改时传空保持不变 + /// + /// + [MaxLength(50)] + public string? LogoName { get; set; } + /// + /// 分类id + /// + /// + public int CategoryId { get; set; } + /// + /// 最小销售单元 + /// + /// + [Required] + [MaxLength(20)] + public string? MinimumUnit { get; set; } + /// + /// 单价 + /// + /// + public decimal UnitPrice { get; set; } + /// + /// 详情 + /// + /// + public string? Detail { get; set; } +} \ No newline at end of file diff --git a/StopShopping.Services/Models/Req/EditUserParams.cs b/StopShopping.Services/Models/Req/EditUserParams.cs new file mode 100644 index 0000000..1539cb6 --- /dev/null +++ b/StopShopping.Services/Models/Req/EditUserParams.cs @@ -0,0 +1,34 @@ +using System.ComponentModel.DataAnnotations; + +namespace StopShopping.Services.Models.Req; + +/// +/// 修改用户资料 +/// +/// +public record EditUserParams +{ + /// + /// 昵称 + /// + /// + [Length(2, 50)] + [Required] + public string NickName { get; set; } = string.Empty; + /// + /// 头像文件名 + /// + /// + public string? AvatarFileName { get; set; } + /// + /// 默认角色 + /// + /// + public UserRoles DefaultRole { get; set; } + /// + /// 联系电话 + /// + /// + [Phone] + public string? Telephone { get; set; } +} diff --git a/StopShopping.Services/Models/Req/PagedSearch.cs b/StopShopping.Services/Models/Req/PagedSearch.cs new file mode 100644 index 0000000..99fc321 --- /dev/null +++ b/StopShopping.Services/Models/Req/PagedSearch.cs @@ -0,0 +1,18 @@ +namespace StopShopping.Services.Models.Req; + +/// +/// 分页搜索 +/// +public record PagedSearch +{ + /// + /// 页码 + /// + /// + public int PageIndex { get; set; } + /// + /// 页大小 + /// + /// + public int PageSize { get; set; } +} diff --git a/StopShopping.Services/Models/Req/ProductIdParams.cs b/StopShopping.Services/Models/Req/ProductIdParams.cs new file mode 100644 index 0000000..b2f162e --- /dev/null +++ b/StopShopping.Services/Models/Req/ProductIdParams.cs @@ -0,0 +1,6 @@ +namespace StopShopping.Services.Models.Req; + +public record ProductIdParams +{ + public int ProductId { get; set; } +} diff --git a/StopShopping.Services/Models/Req/ProductOrderBys.cs b/StopShopping.Services/Models/Req/ProductOrderBys.cs new file mode 100644 index 0000000..6854d41 --- /dev/null +++ b/StopShopping.Services/Models/Req/ProductOrderBys.cs @@ -0,0 +1,32 @@ +namespace StopShopping.Services.Models.Req; + +/// +/// 商品搜索排序 +/// +public enum ProductOrderBys +{ + /// + /// 添加时间 + /// + CreateTime, + /// + /// 添加时间倒序 + /// + CreateTimeDesc, + /// + /// 分类 + /// + Category, + /// + /// 分类倒序 + /// + CategoryDesc, + /// + /// 已售数量 + /// + SoldAmount, + /// + /// 已售数量倒序 + /// + SoldAmountDesc, +} diff --git a/StopShopping.Services/Models/Req/ProductSearchParams.cs b/StopShopping.Services/Models/Req/ProductSearchParams.cs new file mode 100644 index 0000000..cd7e519 --- /dev/null +++ b/StopShopping.Services/Models/Req/ProductSearchParams.cs @@ -0,0 +1,29 @@ +using System.ComponentModel.DataAnnotations; + +namespace StopShopping.Services.Models.Req; + +/// +/// 分页搜索商品 +/// +public record ProductSearchParms : PagedSearch +{ + /// + /// 搜索此分类及下级所有商品 + /// + /// + public int? CategoryId { get; set; } + /// + /// 商品名、描述关键字 + /// + /// + public string? Keyword { get; set; } + /// + /// 排序条件 + /// + /// + [MinLength(1)] + public ProductOrderBys[] OrderBys { get; set; } = [ + ProductOrderBys.CreateTimeDesc, + ProductOrderBys.Category + ]; +} diff --git a/StopShopping.Services/Models/Req/ReplyParams.cs b/StopShopping.Services/Models/Req/ReplyParams.cs new file mode 100644 index 0000000..ba3ac78 --- /dev/null +++ b/StopShopping.Services/Models/Req/ReplyParams.cs @@ -0,0 +1,34 @@ +namespace StopShopping.Services.Models.Req; + +/// +/// 竞标参数 +/// +/// +public record ReplyParams +{ + /// + /// 需求id + /// + /// + public int RequestId { get; set; } + /// + /// 商品id + /// + /// + public int ProductId { get; set; } + /// + /// 数量 + /// + /// + public int Amount { get; set; } + /// + /// 价格 + /// + /// + public decimal Price { get; set; } + /// + /// 留言 + /// + /// + public string? Memo { get; set; } +} diff --git a/StopShopping.Services/Models/Req/RequestIdParams.cs b/StopShopping.Services/Models/Req/RequestIdParams.cs new file mode 100644 index 0000000..1adfed6 --- /dev/null +++ b/StopShopping.Services/Models/Req/RequestIdParams.cs @@ -0,0 +1,6 @@ +namespace StopShopping.Services.Models.Req; + +public record RequestIdParams +{ + public int RequestId { get; set; } +} diff --git a/StopShopping.Services/Models/Req/RequestOrderBys.cs b/StopShopping.Services/Models/Req/RequestOrderBys.cs new file mode 100644 index 0000000..c9e9518 --- /dev/null +++ b/StopShopping.Services/Models/Req/RequestOrderBys.cs @@ -0,0 +1,32 @@ +namespace StopShopping.Services.Models.Req; + +/// +/// 需求排序 +/// +public enum RequestOrderBys +{ + /// + /// 发布时间 + /// + PublishTime, + /// + /// 发布时间倒序j + /// + PublishTimeDesc, + /// + /// 分类id + /// + CategoryId, + /// + /// 分类id倒序 + /// + CategoryIdDesc, + /// + /// 竞标数量 + /// + ReplyAmount, + /// + /// 竞标数量倒序 + /// + ReplyAmountDesc, +} diff --git a/StopShopping.Services/Models/Req/RequestSearchParams.cs b/StopShopping.Services/Models/Req/RequestSearchParams.cs new file mode 100644 index 0000000..c3a6899 --- /dev/null +++ b/StopShopping.Services/Models/Req/RequestSearchParams.cs @@ -0,0 +1,43 @@ +using System.ComponentModel.DataAnnotations; + +namespace StopShopping.Services.Models.Req; + +/// +/// 需求分页检索 +/// +/// +public record RequestSearchParams : PagedSearch +{ + /// + /// 分类id + /// + /// + public int? CategoryId { get; set; } + /// + /// 关键词,序号、名称、描述 + /// + /// + public string? Keyword { get; set; } + /// + /// 排序,不要同时传同一个字段的升序和降序 + /// + /// + [MinLength(1)] + [Required] + public RequestOrderBys[] OrderBys { get; set; } = [ + RequestOrderBys.PublishTimeDesc, + ]; +} + +/// +/// 订单搜索 +/// +/// +public record RequestSearchWithStatusParams : RequestSearchParams +{ + /// + /// 订单状态 + /// + /// + public RequestStatus Status { get; set; } +} diff --git a/StopShopping.Services/Models/Req/ResortCategoryParams.cs b/StopShopping.Services/Models/Req/ResortCategoryParams.cs new file mode 100644 index 0000000..e27d1d4 --- /dev/null +++ b/StopShopping.Services/Models/Req/ResortCategoryParams.cs @@ -0,0 +1,15 @@ +namespace StopShopping.Services.Models.Req; + +/// +/// 调整层级内排序 +/// +/// +public record ResortCategoryParams +{ + public int Id { get; set; } + /// + /// 当前层级中排序,从1开始 + /// + /// + public short TargetOrder { get; set; } +} diff --git a/StopShopping.Services/Models/Req/SignInParams.cs b/StopShopping.Services/Models/Req/SignInParams.cs new file mode 100644 index 0000000..e8636c9 --- /dev/null +++ b/StopShopping.Services/Models/Req/SignInParams.cs @@ -0,0 +1,24 @@ +using System.ComponentModel.DataAnnotations; + +namespace StopShopping.Services.Models.Req; + +/// +/// 登录参数 +/// +/// +public record SignInParams +{ + /// + /// 登录账号 + /// + /// + [Required] + public string Account { get; set; } = string.Empty; + + /// + /// 登录密码 + /// + /// + [Required] + public string Password { get; set; } = string.Empty; +} diff --git a/StopShopping.Services/Models/Req/SignUpParams.cs b/StopShopping.Services/Models/Req/SignUpParams.cs new file mode 100644 index 0000000..6065974 --- /dev/null +++ b/StopShopping.Services/Models/Req/SignUpParams.cs @@ -0,0 +1,40 @@ +using System.ComponentModel.DataAnnotations; + +namespace StopShopping.Services.Models.Req; + +/// +/// 注册参数 +/// +/// +public record SignUpParams +{ + /// + /// 登录账号 + /// + /// + [Required] + [Length(2, 20)] + public string Account { get; set; } = string.Empty; + + /// + /// 昵称 + /// + /// + [Required] + [Length(2, 50)] + public string NickName { get; set; } = string.Empty; + + /// + /// 默认角色 + /// + /// + public UserRoles DefaultRole { get; set; } + + /// + /// 登录密码 + /// + /// + [Required] + [MinLength(6)] + public string Password { get; set; } = string.Empty; +} diff --git a/StopShopping.Services/Models/Req/UploadParams.cs b/StopShopping.Services/Models/Req/UploadParams.cs new file mode 100644 index 0000000..2ff42cd --- /dev/null +++ b/StopShopping.Services/Models/Req/UploadParams.cs @@ -0,0 +1,24 @@ +using System.ComponentModel.DataAnnotations; +using Microsoft.AspNetCore.Http; + +namespace StopShopping.Services.Models.Req; + +/// +/// 上传 +/// +/// +public record UploadParams +{ + /// + /// 场景 + /// + /// + public UploadScences Scences { get; set; } + /// + /// 文件 + /// + /// + [Required] + [ImageFileValidation(2 * 1024 * 1024)] + public IFormFile? File { get; set; } +} diff --git a/StopShopping.Services/Models/RequestStatus.cs b/StopShopping.Services/Models/RequestStatus.cs new file mode 100644 index 0000000..369001d --- /dev/null +++ b/StopShopping.Services/Models/RequestStatus.cs @@ -0,0 +1,42 @@ +using System.ComponentModel; + +namespace StopShopping.Services.Models; + +/// +/// 需求状态 +/// +public enum RequestStatus +{ + [Description("全部")] + All = -1, + /// + /// 发布 + /// + [Description("发布")] + Publish = 0, + /// + /// 有竞标 + /// + [Description("有竞标")] + Replied = 1, + /// + /// 待发货 + /// + [Description("待发货")] + Accepted = 2, + /// + /// 待收货 + /// + [Description("待收货")] + Sent = 3, + /// + /// 已完成 + /// + [Description("已完成")] + Completed = 4, + /// + /// 已评价 + /// + [Description("已评价")] + Commented = 5, +} diff --git a/StopShopping.Services/Models/Resp/AccessToken.cs b/StopShopping.Services/Models/Resp/AccessToken.cs new file mode 100644 index 0000000..57848bd --- /dev/null +++ b/StopShopping.Services/Models/Resp/AccessToken.cs @@ -0,0 +1,15 @@ +namespace StopShopping.Services.Models.Resp; + +public class AccessToken +{ + /// + /// token + /// + /// + public string? Token { get; set; } + /// + /// 有效秒 + /// + /// + public int ExpiresIn { get; set; } +} diff --git a/StopShopping.Services/Models/Resp/Address.cs b/StopShopping.Services/Models/Resp/Address.cs new file mode 100644 index 0000000..a2a4d0b --- /dev/null +++ b/StopShopping.Services/Models/Resp/Address.cs @@ -0,0 +1,54 @@ +namespace StopShopping.Services.Models.Resp; + +/// +/// 收货地址 +/// +public class Address +{ + public int Id { get; set; } + /// + /// 姓名 + /// + /// + public string Name { get; set; } = string.Empty; + /// + /// 联系电话 + /// + /// + public string Telephone { get; set; } = string.Empty; + /// + /// 自定义标签 + /// + /// + public string? Tag { get; set; } + /// + /// 是否默认地址 + /// + /// + public bool Default { get; set; } + /// + /// 区域id,表示省/直辖市 + /// + /// + public int DistrictLevel1Id { get; set; } + /// + /// 区域id,表示市/直辖市时为空 + /// + /// + public int? DistrictLevel2Id { get; set; } + /// + /// 区域id,表示区 + /// + /// + public int DistrictLevel3Id { get; set; } + /// + /// 区域id,表示街道/镇 + /// + /// + public int DistrictLevel4Id { get; set; } + /// + /// 详细地址 + /// + /// + public string? Detail { get; set; } +} diff --git a/StopShopping.Services/Models/Resp/AntiForgeryToken.cs b/StopShopping.Services/Models/Resp/AntiForgeryToken.cs new file mode 100644 index 0000000..17769da --- /dev/null +++ b/StopShopping.Services/Models/Resp/AntiForgeryToken.cs @@ -0,0 +1,18 @@ +namespace StopShopping.Services.Models.Resp; + +/// +/// csrf token +/// +public class AntiForgeryToken +{ + /// + /// csrf请求头 + /// + /// + public string? HeaderName { get; set; } + /// + /// csrf token + /// + /// + public string? Token { get; set; } +} diff --git a/StopShopping.Services/Models/Resp/ApiResponse.cs b/StopShopping.Services/Models/Resp/ApiResponse.cs new file mode 100644 index 0000000..6b7a744 --- /dev/null +++ b/StopShopping.Services/Models/Resp/ApiResponse.cs @@ -0,0 +1,64 @@ +namespace StopShopping.Services.Models.Resp; + +/// +/// 强类型返回值 +/// +/// +public class ApiResponse +{ + public ApiResponse() + { } + + public ApiResponse(T data) + { + Data = data; + } + + /// + /// 是否成功 + /// + /// + public bool IsSucced { get; set; } = true; + + /// + /// 错误消息 + /// + /// + public string? Message { get; set; } + + /// + /// 关联数据 + /// + /// + public T? Data { get; set; } + + public ApiResponse Failed(string message) + { + IsSucced = false; + Message = message; + + return this; + } +} + +/// +/// 强类型返回值,只返回成功与否和消息 +/// +public class ApiResponse : ApiResponse +{ + 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); + } +} diff --git a/StopShopping.Services/Models/Resp/Category.cs b/StopShopping.Services/Models/Resp/Category.cs new file mode 100644 index 0000000..872ff99 --- /dev/null +++ b/StopShopping.Services/Models/Resp/Category.cs @@ -0,0 +1,23 @@ +namespace StopShopping.Services.Models.Resp; + +public record Category +{ + public int Id { get; set; } + public int ParentId { get; set; } + public string Name { get; set; } = string.Empty; + /// + /// logo地址 + /// + /// + public string LogoUrl { get; set; } = string.Empty; + /// + /// 层级中序号 + /// + /// + public int Order { get; set; } + /// + /// 下级分类列表 + /// + /// + public List Children { get; set; } = []; +} diff --git a/StopShopping.Services/Models/Resp/District.cs b/StopShopping.Services/Models/Resp/District.cs new file mode 100644 index 0000000..6b02879 --- /dev/null +++ b/StopShopping.Services/Models/Resp/District.cs @@ -0,0 +1,29 @@ +namespace StopShopping.Services.Models.Resp; + +/// +/// 行政区划 +/// +public class District +{ + public int Id { get; set; } + /// + /// 父级id + /// + /// + public int ParentId { get; set; } + /// + /// 层级:1,[2,]3,4 + /// + /// + public int Level { get; set; } + /// + /// 名称 + /// + /// + public string FullName { get; set; } = string.Empty; + /// + /// 下级,街道无下级 + /// + /// + public List Children { get; set; } = []; +} diff --git a/StopShopping.Services/Models/Resp/FileUpload.cs b/StopShopping.Services/Models/Resp/FileUpload.cs new file mode 100644 index 0000000..d266437 --- /dev/null +++ b/StopShopping.Services/Models/Resp/FileUpload.cs @@ -0,0 +1,18 @@ +namespace StopShopping.Services.Models.Resp; + +/// +/// 文件上传结果 +/// +public class FileUpload +{ + /// + /// 新名,上传后重命名 + /// + /// + public string NewName { get; set; } = string.Empty; + /// + /// Url + /// + /// + public string Url { get; set; } = string.Empty; +} diff --git a/StopShopping.Services/Models/Resp/PagedResult.cs b/StopShopping.Services/Models/Resp/PagedResult.cs new file mode 100644 index 0000000..854e563 --- /dev/null +++ b/StopShopping.Services/Models/Resp/PagedResult.cs @@ -0,0 +1,32 @@ +namespace StopShopping.Services.Models.Resp; + +public class PagedResult +{ + public int PageCount { get; set; } + public int PageSize { get; set; } + public int PageIndex { get; set; } + public List Data { get; set; } = []; +} + +public static class PagedQueryExtensions +{ + public static async Task> ToPagedAsync(this IAsyncEnumerable query + , int pageIndex = 1 + , int pageSize = 20) + { + PagedResult result = new() + { + PageSize = pageSize, + PageIndex = pageIndex + }; + + var total = await query.CountAsync(); + result.PageCount = (int)Math.Ceiling(total / (decimal)result.PageSize); + result.Data = await query + .Skip((result.PageIndex - 1) * result.PageSize) + .Take(result.PageSize) + .ToListAsync(); + + return result; + } +} diff --git a/StopShopping.Services/Models/Resp/Product.cs b/StopShopping.Services/Models/Resp/Product.cs new file mode 100644 index 0000000..56c3838 --- /dev/null +++ b/StopShopping.Services/Models/Resp/Product.cs @@ -0,0 +1,62 @@ +namespace StopShopping.Services.Models.Resp; + +/// +/// 列表商品 +/// +public class Product +{ + public int Id { get; set; } + public string Name { get; set; } = ""; + /// + /// 简介 + /// + /// + public string? Description { get; set; } + /// + /// 图片地址 + /// + /// + public string? LogoUrl { get; set; } + /// + /// 分类名称 + /// + /// + public string CategoryName { get; set; } = ""; + /// + /// 最小销售单元 + /// + /// + public string MinimumUnit { get; set; } = ""; + /// + /// 单价 + /// + /// + public decimal UnitPrice { get; set; } + /// + /// 已售数量 + /// + /// + public int SoldAmount { get; set; } + /// + /// 添加时间 + /// + /// + public string CreateTime { get; set; } = ""; +} + +/// +/// 详情商品 +/// +public class ProductInfo : Product +{ + /// + /// 分类id + /// + /// + public int CategoryId { get; set; } + /// + /// 详情 + /// + /// + public string? Detail { get; set; } +} diff --git a/StopShopping.Services/Models/Resp/Reply.cs b/StopShopping.Services/Models/Resp/Reply.cs new file mode 100644 index 0000000..972fae2 --- /dev/null +++ b/StopShopping.Services/Models/Resp/Reply.cs @@ -0,0 +1,41 @@ +namespace StopShopping.Services.Models.Resp; + +/// +/// 竞标 +/// +public class Reply +{ + public int Id { get; set; } + public int ProductId { get; set; } + public string ProductName { get; set; } = string.Empty; + /// + /// 单价 + /// + /// + public decimal UnitPrice { get; set; } + /// + /// 最小销售单元 + /// + /// + public string MinimumUnit { get; set; } = string.Empty; + /// + /// 数量 + /// + /// + public int Amount { get; set; } + /// + /// 竞标时间 + /// + /// + public string ReplyTime { get; set; } = string.Empty; + /// + /// 竞标者 + /// + /// + public string Replier { get; set; } = string.Empty; + /// + /// 留言 + /// + /// + public string? Memo { get; set; } +} diff --git a/StopShopping.Services/Models/Resp/Request.cs b/StopShopping.Services/Models/Resp/Request.cs new file mode 100644 index 0000000..407e2f6 --- /dev/null +++ b/StopShopping.Services/Models/Resp/Request.cs @@ -0,0 +1,59 @@ +namespace StopShopping.Services.Models.Resp; + +/// +/// 列表需求 +/// +public class Request +{ + public int Id { get; set; } + /// + /// 序列号 + /// + /// + public string SerialNo { get; set; } = string.Empty; + /// + /// 状态 + /// + /// + public RequestStatus Status { get; set; } + /// + /// 名称 + /// + /// + public string Name { get; set; } = string.Empty; + /// + /// 描述 + /// + /// + public string? Description { get; set; } + /// + /// 分类id + /// + /// + public int CategoryId { get; set; } + /// + /// 分类名称 + /// + /// + public string CategoryName { get; set; } = string.Empty; + /// + /// 发布者 + /// + /// + public string Publisher { get; set; } = string.Empty; + /// + /// 发布时间 + /// + /// + public string PublishTime { get; set; } = string.Empty; + /// + /// 截止日期 + /// + /// + public string Deadline { get; set; } = string.Empty; + /// + /// 竞标者数量 + /// + /// + public int ReplyAmount { get; set; } +} diff --git a/StopShopping.Services/Models/Resp/SignIn.cs b/StopShopping.Services/Models/Resp/SignIn.cs new file mode 100644 index 0000000..dbef2b4 --- /dev/null +++ b/StopShopping.Services/Models/Resp/SignIn.cs @@ -0,0 +1,47 @@ +namespace StopShopping.Services.Models.Resp; + +/// +/// 登录返回 +/// +public abstract class SignIn +{ + /// + /// 访问令牌 + /// + /// + public AccessToken AccessToken { get; set; } = new(); +} + +/// +/// 登录用户 +/// +public class SignInUser : SignIn +{ + /// + /// 昵称 + /// + /// + public string NickName { get; set; } = string.Empty; + /// + /// 头像地址 + /// + /// + public string? AvatarUrl { get; set; } + /// + /// 默认角色 + /// + /// + public UserRoles DefaultRole { get; set; } +} + +/// +/// 登录管理员 +/// +public class SignInAdmin : SignIn +{ + /// + /// 昵称 + /// + /// + public string NickName { get; set; } = string.Empty; +} diff --git a/StopShopping.Services/Models/Resp/User.cs b/StopShopping.Services/Models/Resp/User.cs new file mode 100644 index 0000000..08a1c51 --- /dev/null +++ b/StopShopping.Services/Models/Resp/User.cs @@ -0,0 +1,38 @@ +namespace StopShopping.Services.Models.Resp; + +/// +/// +/// +public class User +{ + /// + /// 登录账号 + /// + /// + public string Account { get; set; } = string.Empty; + /// + /// 昵称 + /// + /// + public string NickName { get; set; } = string.Empty; + /// + /// 电话 + /// + /// + public string? Telephone { get; set; } + /// + /// 默认角色 + /// + /// + public UserRoles DefaultRole { get; set; } + /// + /// 头像地址 + /// + /// + public string? AvatarUrl { get; set; } + /// + /// 上次登录时间 + /// + /// + public string? LastLoginTime { get; set; } +} diff --git a/StopShopping.Services/Models/SignInResult.cs b/StopShopping.Services/Models/SignInResult.cs new file mode 100644 index 0000000..77650bb --- /dev/null +++ b/StopShopping.Services/Models/SignInResult.cs @@ -0,0 +1,12 @@ +using StopShopping.Services.Models.Resp; + +namespace StopShopping.Services.Models; + +public class SignInResult +where TUser : SignIn +{ + public bool IsSucced { get; set; } = true; + public string? Message { get; set; } + public TUser? User { get; set; } + public AccessToken? RefreshToken { get; set; } +} diff --git a/StopShopping.Services/Models/SystemRoles.cs b/StopShopping.Services/Models/SystemRoles.cs new file mode 100644 index 0000000..6cd2f02 --- /dev/null +++ b/StopShopping.Services/Models/SystemRoles.cs @@ -0,0 +1,16 @@ +namespace StopShopping.Services.Models; + +/// +/// 系统角色 +/// +public enum SystemRoles +{ + /// + /// 管理员 + /// + Admin = 'a', + /// + /// 用户 + /// + User = 'u', +} diff --git a/StopShopping.Services/Models/UploadScences.cs b/StopShopping.Services/Models/UploadScences.cs new file mode 100644 index 0000000..6b0238d --- /dev/null +++ b/StopShopping.Services/Models/UploadScences.cs @@ -0,0 +1,20 @@ +namespace StopShopping.Services.Models; + +/// +/// 文件上传场景 +/// +public enum UploadScences +{ + /// + /// 头像 + /// + Avatar, + /// + /// 商品 + /// + Product, + /// + /// 商品分类 + /// + Category, +} diff --git a/StopShopping.Services/Models/UserRoles.cs b/StopShopping.Services/Models/UserRoles.cs new file mode 100644 index 0000000..f89bd03 --- /dev/null +++ b/StopShopping.Services/Models/UserRoles.cs @@ -0,0 +1,16 @@ +namespace StopShopping.Services.Models; + +/// +/// 用户角色 +/// +public enum UserRoles +{ + /// + /// 卖家 + /// + Seller, + /// + /// 买家 + /// + Buyer +} \ No newline at end of file diff --git a/StopShopping.Services/Models/Validator/ImageFileValidationAttribute.cs b/StopShopping.Services/Models/Validator/ImageFileValidationAttribute.cs new file mode 100644 index 0000000..f3211a8 --- /dev/null +++ b/StopShopping.Services/Models/Validator/ImageFileValidationAttribute.cs @@ -0,0 +1,51 @@ +using FileSignatures; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; + +namespace System.ComponentModel.DataAnnotations; + +public class ImageFileValidationAttribute : ValidationAttribute +{ + public ImageFileValidationAttribute(long length) + { + Length = length; + } + + public long Length { get; set; } + + protected override ValidationResult? IsValid(object? value, ValidationContext validationContext) + { + var fileInspector = validationContext.GetRequiredService(); + if (null == fileInspector) + { + return new ValidationResult("未配置文件验证器"); + } + if (value is IFormFile file) + { + var result = Validate(fileInspector, file); + if (null != result) + return result; + } + else if (value is IFormFileCollection files) + { + foreach (var f in files) + { + var result = Validate(fileInspector, f); + if (null != result) + return result; + } + } + + return ValidationResult.Success; + } + + private ValidationResult? Validate(IFileFormatInspector fileInspector, IFormFile file) + { + if (file.Length > Length) + return new ValidationResult("文件太大"); + var format = fileInspector.DetermineFileFormat(file.OpenReadStream()); + if (null == format) + return new ValidationResult("文件格式不支持"); + return null; + } +} diff --git a/StopShopping.Services/StopShopping.Services.csproj b/StopShopping.Services/StopShopping.Services.csproj new file mode 100644 index 0000000..c769b01 --- /dev/null +++ b/StopShopping.Services/StopShopping.Services.csproj @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + net10.0 + enable + enable + + + diff --git a/StopShopping.slnx b/StopShopping.slnx new file mode 100644 index 0000000..877a543 --- /dev/null +++ b/StopShopping.slnx @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/doc/db.sql b/doc/db.sql new file mode 100644 index 0000000..224aa9e --- /dev/null +++ b/doc/db.sql @@ -0,0 +1,291 @@ +CREATE TABLE IF NOT EXISTS district ( + id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + parent_id integer NOT NULL DEFAULT 0, + code varchar(10) NOT NULL, + "name" varchar(20), + full_name varchar(100) NOT NULL, + pinyin varchar(200), + "level" smallint NOT NULL, + latitude varchar(20), + longitude varchar(20), + "order" smallint NOT NULL +); + +COMMENT ON TABLE district IS '行政区划'; +COMMENT ON COLUMN district.id IS '主键'; +COMMENT ON COLUMN district.parent_id IS '父级id,顶级时为0'; +COMMENT ON COLUMN district.code IS '编码'; +COMMENT ON COLUMN district.name IS '简称'; +COMMENT ON COLUMN district.full_name IS '全称'; +COMMENT ON COLUMN district.pinyin IS '名称拼音'; +COMMENT ON COLUMN district.level IS '层级:1-省/直辖市,2-市/直辖市无,3-区,4-街道'; +COMMENT ON COLUMN district.latitude IS '经度'; +COMMENT ON COLUMN district.longitude IS '纬度'; +COMMENT ON COLUMN district.order IS '层级中序号'; + +CREATE TABLE IF NOT EXISTS "user" ( + id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + account varchar(20) NOT NULL, + nick_name varchar(50) NOT NULL, + avatar varchar(50), + "current_role" char(1) NOT NULL DEFAULT 'c', + telephone varchar(20), + "password" varchar(128) NOT NULL, + create_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_login_time timestamp +); + +COMMENT ON TABLE "user" IS '用户'; +COMMENT ON COLUMN "user".id IS '主键'; +COMMENT ON COLUMN "user".account IS '登录账号'; +COMMENT ON COLUMN "user".nick_name IS '昵称'; +COMMENT ON COLUMN "user".avatar IS '头像图片名,后台生成链接'; +COMMENT ON COLUMN "user"."current_role" IS '当前角色:c-买家,s-卖家'; +COMMENT ON COLUMN "user".telephone IS '电话'; +COMMENT ON COLUMN "user"."password" IS '密码(已加密)'; +COMMENT ON COLUMN "user".create_time IS '创建时间'; +COMMENT ON COLUMN "user".last_login_time IS '最后登录时间'; + +CREATE TABLE IF NOT EXISTS address ( + id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + user_id integer NOT NULL, + "name" varchar(20) NOT NULL, + telephone varchar(20) NOT NULL, + tag varchar(20), + "default" boolean NOT NULL DEFAULT FALSE, + district_level1_id integer NOT NULL, + district_level2_id integer, + district_level3_id integer NOT NULL, + district_level4_id integer NOT NULL, + detail varchar(200), + CONSTRAINT fk_UserAddress FOREIGN KEY (user_id) REFERENCES "user"(id) +); + +COMMENT ON TABLE address IS '收货地址'; +COMMENT ON COLUMN address.id IS '主键'; +COMMENT ON COLUMN address.user_id IS '用户表id'; +COMMENT ON COLUMN address.name IS '收货人地址'; +COMMENT ON COLUMN address.telephone IS '联系电话'; +COMMENT ON COLUMN address.tag IS '自定义标签:学校、家等'; +COMMENT ON COLUMN address.default IS '是否默认地址'; +COMMENT ON COLUMN address.district_level1_id IS '行政区域id,表示省/直辖市'; +COMMENT ON COLUMN address.district_level2_id IS '行政区域id,表示市/直辖市为空'; +COMMENT ON COLUMN address.district_level3_id IS '行政区域id,表示区'; +COMMENT ON COLUMN address.district_level4_id IS '行政区域id,表示街道/镇'; +COMMENT ON COLUMN address.detail IS '详细地址'; + +CREATE TABLE IF NOT EXISTS administrator ( + id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + account varchar(20) NOT NULL, + nick_name varchar(50) NOT NULL, + "password" varchar(128) NOT NULL, + create_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_login_time timestamp NOT NULL +); + +COMMENT ON TABLE administrator IS '管理员'; +COMMENT ON COLUMN administrator.id IS '主键'; +COMMENT ON COLUMN administrator.account IS '登录账号'; +COMMENT ON COLUMN administrator.nick_name IS '昵称'; +COMMENT ON COLUMN administrator.password IS '登录密码(已加密)'; +COMMENT ON COLUMN administrator.create_time IS '创建时间'; +COMMENT ON COLUMN administrator.last_login_time IS '最后登录时间'; + +CREATE TABLE IF NOT EXISTS category ( + id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + parent_id integer NOT NULL DEFAULT 0, + "path" varchar(50) NOT NULL, + "name" varchar(50) NOT NULL, + "level" smallint NOT NULL, + logo varchar(50), + "order" smallint NOT NULL, + deleted boolean NOT NULL DEFAULT FALSE +); + +COMMENT ON TABLE category IS '商品分类'; +COMMENT ON COLUMN category.id IS '主键'; +COMMENT ON COLUMN category.parent_id IS '父级id,顶级为0'; +COMMENT ON COLUMN category.path IS 'id路径枚举:/1/2/3/'; +COMMENT ON COLUMN category.name IS '名称'; +COMMENT ON COLUMN category.level IS '层级,从1开始'; +COMMENT ON COLUMN category.logo IS 'logo图片名,后台生成地址'; +COMMENT ON COLUMN category.order IS '层级中序号'; +COMMENT ON COLUMN category.deleted IS '软删除标识'; + +CREATE TABLE IF NOT EXISTS product ( + id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + serial_no varchar(20) NOT NULL, + "name" varchar(100) NOT NULL, + description varchar(200), + logo varchar(50) NOT NULL, + category_id integer NOT NULL, + user_id integer NOT NULL, + minimum_unit varchar(20) NOT NULL, + unit_price numeric(11, 2) NOT NULL DEFAULT 0, + detail text, + sold_amount integer NOT NULL DEFAULT 0, + deleted boolean NOT NULL DEFAULT FALSE, + create_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_CategoryProduct FOREIGN KEY (category_id) REFERENCES category(id) +); + +COMMENT ON TABLE product IS '卖家商品表'; +COMMENT ON COLUMN product.id IS '主键'; +COMMENT ON COLUMN product.serial_no IS '系统唯一编号'; +COMMENT ON COLUMN product.name IS '名称'; +COMMENT ON COLUMN product.description IS '商品描述'; +COMMENT ON COLUMN product.logo IS '商品图片'; +COMMENT ON COLUMN product.category_id IS '商品分类id'; +COMMENT ON COLUMN product.user_id IS '用户id'; +COMMENT ON COLUMN product.minimum_unit IS '最小销售单元'; +COMMENT ON COLUMN product.unit_price IS '单价'; +COMMENT ON COLUMN product.detail IS '详情'; +COMMENT ON COLUMN product.sold_amount IS '已售数量'; +COMMENT ON COLUMN product.deleted IS '软删除标识'; +COMMENT ON COLUMN product.create_time IS '添加时间'; + +CREATE TABLE IF NOT EXISTS request ( + id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + serial_no varchar(20) NOT NULL, + "name" varchar(100) NOT NULL, + description varchar(1000), + category_id integer NOT NULL, + status smallint NOT NULL DEFAULT 0, + publisher_id integer NOT NULL, + publish_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deadline" date NOT NULL, + deleted boolean NOT NULL DEFAULT FALSE, + CONSTRAINT fk_CategoryRequest FOREIGN KEY (category_id) REFERENCES category(id), + CONSTRAINT fk_UserRequest FOREIGN KEY (publisher_id) REFERENCES "user"(id) +); + +COMMENT ON TABLE request IS '用户需求'; +COMMENT ON COLUMN request.id IS '主键'; +COMMENT ON COLUMN request.serial_no IS '需求单号,系统唯一,后台生成'; +COMMENT ON COLUMN request.name IS '名称'; +COMMENT ON COLUMN request.description IS '需求描述'; +COMMENT ON COLUMN request.category_id IS '商品分类id'; +COMMENT ON COLUMN request.status IS '状态:0-发布,1-有竞标,2-待发货,3-待收货,4-已完成,5-已评价'; +COMMENT ON COLUMN request.publisher_id IS '发布者id'; +COMMENT ON COLUMN request.publish_time IS '发布时间'; +COMMENT ON COLUMN request.deadline IS '截止日期'; +COMMENT ON COLUMN request.deleted IS '是否已删除'; + +CREATE TABLE IF NOT EXISTS reply ( + id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + product_id integer NOT NULL, + amount integer NOT NULL, + price numeric(11, 2) NOT NULL, + user_id integer NOT NULL, + reply_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + request_id integer NOT NULL, + memo varchar(200), + rejected boolean NOT NULL DEFAULT FALSE, + CONSTRAINT fk_RequestReply FOREIGN KEY (request_id) REFERENCES request(id), + CONSTRAINT fk_ReplyProduct FOREIGN KEY (product_id) REFERENCES product(id), + CONSTRAINT fk_ReplyUser FOREIGN KEY (user_id) REFERENCES "user"(id) +); + +COMMENT ON TABLE reply IS '竞标表'; +COMMENT ON COLUMN reply.id IS '主键'; +COMMENT ON COLUMN reply.product_id IS '商品id'; +COMMENT ON COLUMN reply.amount IS '数量'; +COMMENT ON COLUMN reply.price IS '价格,自动计算的价格(product.unit_price * amount)之后的优惠价格'; +COMMENT ON COLUMN reply.user_id IS '竞标者id'; +COMMENT ON COLUMN reply.reply_time IS '回应时间'; +COMMENT ON COLUMN reply.request_id IS '需求id'; +COMMENT ON COLUMN reply.memo IS '留言'; +COMMENT ON COLUMN reply.rejected IS '是否已拒绝'; + +CREATE TABLE IF NOT EXISTS logistics ( + id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + order_no varchar(20) NOT NULL, + company varchar(30) NOT NULL, + status smallint NOT NULL, + arrival_time timestamp NOT NULL, + "location" varchar(100) NOT NULL, + context varchar(200) NOT NULL, + request_id integer NOT NULL, + create_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +COMMENT ON TABLE logistics IS '物流'; +COMMENT ON COLUMN logistics.id IS '主键'; +COMMENT ON COLUMN logistics.order_no IS '快递单号'; +COMMENT ON COLUMN logistics.company IS '快递公司'; +COMMENT ON COLUMN logistics.status IS '物流状态:1-揽收,0-在途,5-派件,6-退回,4-退签,3-签收,2-疑难,7-转投,8。。。-清关'; +COMMENT ON COLUMN logistics.arrival_time IS '到达时间'; +COMMENT ON COLUMN logistics.location IS '到达地点'; +COMMENT ON COLUMN logistics.context IS '详情'; +COMMENT ON COLUMN logistics.request_id IS '需求id'; +COMMENT ON COLUMN logistics.create_time IS '入库时间'; + +CREATE TABLE IF NOT EXISTS message ( + id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + from_user_id integer NOT NULL, + to_user_id integer NOT NULL, + sent_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "read" boolean NOT NULL DEFAULT FALSE, + recalled boolean NOT NULL DEFAULT FALSE, + content text NOT NULL +); + +COMMENT ON TABLE message IS '用户消息'; +COMMENT ON COLUMN message.id IS '主键'; +COMMENT ON COLUMN message.from_user_id IS '发出用户id'; +COMMENT ON COLUMN message.to_user_id IS '接收用户id'; +COMMENT ON COLUMN message.sent_time IS '发出时间'; +COMMENT ON COLUMN message.read IS '是否已读'; +COMMENT ON COLUMN message.recalled IS '是否已撤回'; +COMMENT ON COLUMN message.content IS '内容'; + +CREATE TABLE IF NOT EXISTS notice ( + id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + content text NOT NULL, + verified boolean NOT NULL DEFAULT FALSE, + publish_time timestamp NOT NULL default CURRENT_TIMESTAMP +); + +COMMENT ON TABLE notice IS '系统通知'; +COMMENT ON COLUMN notice.id IS '主键'; +COMMENT ON COLUMN notice.content IS '内容'; +COMMENT ON COLUMN notice.verified IS '是否已审核'; +COMMENT ON COLUMN notice.publish_time IS '发布时间'; + +CREATE TABLE IF NOT EXISTS operate_log ( + id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + operate_type char(1) NOT NULL, + target_type integer NOT NULL, + related_id integer NOT NULL, + operater_id integer NOT NULL, + description varchar(500), + operate_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + create_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +COMMENT ON TABLE operate_log IS '操作日志'; +COMMENT ON COLUMN operate_log.id IS '主键'; +COMMENT ON COLUMN operate_log.operate_type IS '操作类型:c-创建,u-修改,d-删除'; +COMMENT ON COLUMN operate_log.target_type IS '目标类型:0-行政区划,1-商品分类。。。'; +COMMENT ON COLUMN operate_log.related_id IS '关联id,目标表主键'; +COMMENT ON COLUMN operate_log.operater_id IS '管理员id'; +COMMENT ON COLUMN operate_log.description IS '操作描述'; +COMMENT ON COLUMN operate_log.operate_time IS '操作时间'; +COMMENT ON COLUMN operate_log.create_time IS '入库时间'; + +CREATE TABLE IF NOT EXISTS refresh_token ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + user_id integer NOT NULL, + system_role char(1) NOT NULL, + token varchar(50) NOT NULL, + create_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at timestamp NOT NULL +); + +COMMENT ON TABLE refresh_token IS '刷新令牌'; +COMMENT ON COLUMN refresh_token.id IS '主键'; +COMMENT ON COLUMN refresh_token.user_id IS '用户/管理员id'; +COMMENT ON COLUMN refresh_token.system_role IS '系统角色:a-管理员,u-用户'; +COMMENT ON COLUMN refresh_token.token IS '令牌'; +COMMENT ON COLUMN refresh_token.create_time IS '创建时间'; +COMMENT ON COLUMN refresh_token.expires_at IS '失效时间'; diff --git a/ef_scaffolds/Program.cs b/ef_scaffolds/Program.cs new file mode 100644 index 0000000..3751555 --- /dev/null +++ b/ef_scaffolds/Program.cs @@ -0,0 +1,2 @@ +// See https://aka.ms/new-console-template for more information +Console.WriteLine("Hello, World!"); diff --git a/ef_scaffolds/README.md b/ef_scaffolds/README.md new file mode 100644 index 0000000..7afa48f --- /dev/null +++ b/ef_scaffolds/README.md @@ -0,0 +1,2 @@ +# ef db-first 迁移用项目,开发时使用 +``dotnet ef dbcontext scaffold "Server=localhost;Port=5432;Database=stop_shopping;User Id=stopshopping;Password=gx24601...;" Npgsql.EntityFrameworkCore.PostgreSQL -p ../StopShopping.EF -o Models --context-dir ./ --no-onconfiguring -f`` \ No newline at end of file diff --git a/ef_scaffolds/ef_scaffolds.csproj b/ef_scaffolds/ef_scaffolds.csproj new file mode 100644 index 0000000..325f227 --- /dev/null +++ b/ef_scaffolds/ef_scaffolds.csproj @@ -0,0 +1,21 @@ + + + + Exe + net10.0 + enable + enable + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + +