commit e24925e1edba1ffee7f275c25d8bc1ca52c81562 Author: sam Date: Wed Feb 11 12:58:54 2026 +0800 chore(build): 添加基础构建配置和版本管理 - 新增 .dockerignore 文件,忽略多种临时及中间文件 - 新增 .gitattributes 文件,配置文本文件换行及合并行为 - 新增详细的 .gitignore 文件,排除多种开发及生成文件 - 新增 VS Code C# 代码片段,提升开发效率 - 添加 Directory.Build.props,统一 MSBuild 配置和代码分析规则 - 添加空的 Directory.Build.targets,预留构建任务扩展位置 - 添加 Directory.Packages.props,实现依赖包版本集中管理和声明 diff --git a/Backend/.dockerignore b/Backend/.dockerignore new file mode 100644 index 0000000..3729ff0 --- /dev/null +++ b/Backend/.dockerignore @@ -0,0 +1,25 @@ +**/.classpath +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/azds.yaml +**/bin +**/charts +**/docker-compose* +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +LICENSE +README.md \ No newline at end of file diff --git a/Backend/.gitattributes b/Backend/.gitattributes new file mode 100644 index 0000000..a1e1e97 --- /dev/null +++ b/Backend/.gitattributes @@ -0,0 +1,63 @@ +############################################################################### +# Set default behavior to automatically normalize line endings. +############################################################################### +* text=auto + +############################################################################### +# Set default behavior for command prompt diff. +# +# This is need for earlier builds of msysgit that does not have it on by +# default for csharp files. +# Note: This is only used by command line +############################################################################### +#*.cs diff=csharp + +############################################################################### +# Set the merge driver for project and solution files +# +# Merging from the command prompt will add diff markers to the files if there +# are conflicts (Merging from VS is not affected by the settings below, in VS +# the diff markers are never inserted). Diff markers may cause the following +# file extensions to fail to load in VS. An alternative would be to treat +# these files as binary and thus will always conflict and require user +# intervention with every merge. To do so, just uncomment the entries below +############################################################################### +#*.sln merge=binary +#*.csproj merge=binary +#*.vbproj merge=binary +#*.vcxproj merge=binary +#*.vcproj merge=binary +#*.dbproj merge=binary +#*.fsproj merge=binary +#*.lsproj merge=binary +#*.wixproj merge=binary +#*.modelproj merge=binary +#*.sqlproj merge=binary +#*.wwaproj merge=binary + +############################################################################### +# behavior for image files +# +# image files are treated as binary by default. +############################################################################### +#*.jpg binary +#*.png binary +#*.gif binary + +############################################################################### +# diff behavior for common document formats +# +# Convert binary document formats to text before diffing them. This feature +# is only available from the command line. Turn it on by uncommenting the +# entries below. +############################################################################### +#*.doc diff=astextplain +#*.DOC diff=astextplain +#*.docx diff=astextplain +#*.DOCX diff=astextplain +#*.dot diff=astextplain +#*.DOT diff=astextplain +#*.pdf diff=astextplain +#*.PDF diff=astextplain +#*.rtf diff=astextplain +#*.RTF diff=astextplain \ No newline at end of file diff --git a/Backend/.gitignore b/Backend/.gitignore new file mode 100644 index 0000000..d2ed0a3 --- /dev/null +++ b/Backend/.gitignore @@ -0,0 +1,398 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore + +# 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/ +# Exception: allow frontend scripts bin directory +!src/frontend/scripts/**/bin/ +[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 Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# 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 +*.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 +# Only ignore NuGet packages folders at project root level, not frontend workspace packages +/[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 + +# Nuget personal access tokens and Credentials +# nuget.config + +# 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 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/ + +# 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 +!.vscode/csharp.code-snippets +*.code-workspace + +# Local History for Visual Studio Code +.history/ + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp + +# JetBrains Rider +.idea/ +*.sln.iml +/.vs + +# Internal packages build outputs (generated by unbuild --stub, contains absolute paths) +src/frontend/internal/**/dist/ +src/frontend/packages/**/dist/ +src/frontend/scripts/**/dist/ diff --git a/Backend/.vscode/csharp.code-snippets b/Backend/.vscode/csharp.code-snippets new file mode 100644 index 0000000..93c24a6 --- /dev/null +++ b/Backend/.vscode/csharp.code-snippets @@ -0,0 +1,625 @@ +{ + "PostProcessor Class": { + "prefix": "postproc", + "body": [ + "sealed class ${1:MyProcessor} : IPostProcessor<${2:My}Request, ${2:My}Response>", + "{", + " public Task PostProcessAsync(${2:My}Request req, ${2:My}Response res, HttpContext ctx, IReadOnlyCollection fails, CancellationToken c)", + " {", + " $0", + " }", + "}" + ], + "description": "post-processor" + }, + + "Test Class": { + "prefix": "tstclass", + "body": [ + "namespace Tests;", + "", + "public class ${1:My}Tests : TestClass<${2:App}Fixture>", + "{", + " public ${1:My}Tests(${2:App}Fixture f, ITestOutputHelper o) : base(f, o) { }", + "", + " [Fact]", + " public async Task ${3:Name_Of_The_Test}()", + " {", + " $0", + " }", + "}" + ], + "description": "test class" + }, + + "Endpoint with Request Only": { + "prefix": "epreq", + "body": [ + "sealed class ${1:My}Request", + "{", + "", + "}", + "", + "sealed class ${1:My}Endpoint : Endpoint<${1:My}Request>", + "{", + " public override void Configure()", + " {", + " ${2:Post}(\"${3:route-pattern}\");", + " AllowAnonymous();", + " }", + "", + " public override async Task HandleAsync(${1:My}Request r, CancellationToken c)", + " {", + " $0", + " }", + "}" + ], + "description": "endpoint with request only" + }, + + "NetCorePal Command": { + "prefix": "ncpcmd", + "body": [ + "public record ${1:My}Command() : ICommand;", + "", + "public class ${1:My}CommandValidator : AbstractValidator<${1:My}Command>", + "{", + " public ${1:My}CommandValidator()", + " {", + " // 添加验证规则示例:", + " // RuleFor(x => x.Property).NotEmpty();", + " }", + "}", + "", + "public class ${1:My}CommandHandler : ICommandHandler<${1:My}Command>", + "{", + " public async Task Handle(", + " ${1:My}Command request, ", + " CancellationToken cancellationToken)", + " {", + " // 实现业务逻辑", + " throw new NotImplementedException();", + " }", + "}" + ], + "description": "创建命令" + }, + + "NetCorePal Command with Response": { + "prefix": "ncpcmdres", + "body": [ + "public record ${1:My}Command() : ICommand<${1:My}CommandResponse>;", + "", + "public record ${1:My}CommandResponse();", + "", + "public class ${1:My}CommandValidator : AbstractValidator<${1:My}Command>", + "{", + " public ${1:My}CommandValidator()", + " {", + " // 添加验证规则示例:", + " // RuleFor(x => x.Property).NotEmpty();", + " }", + "}", + "", + "public class ${1:My}CommandHandler : ICommandHandler<${1:My}Command, ${1:My}CommandResponse>", + "{", + " public async Task<${1:My}CommandResponse> Handle(", + " ${1:My}Command request,", + " CancellationToken cancellationToken)", + " {", + " // 实现业务逻辑", + " throw new NotImplementedException();", + " }", + "}" + ], + "description": "创建命令(含返回值)" + }, + + "Endpoint Request & Response DTOs": { + "prefix": "epdto", + "body": [ + "sealed class ${1:My}Request", + "{", + " $0", + "}", + "", + "sealed class ${1:My}Response", + "{", + "", + "}" + ], + "description": "endpoint request & response dtos" + }, + + "NetCorePal Aggregate Root": { + "prefix": "ncpar", + "body": [ + "public partial record ${1:My}Id : IInt64StronglyTypedId;", + "", + "public class ${1:My} : Entity<${1:My}Id>, IAggregateRoot", + "{", + " protected ${1:My}() { }", + "}" + ], + "description": "创建聚合根" + }, + + "Test Fixture": { + "prefix": "tstfixture", + "body": [ + "namespace Tests;", + "", + "public class ${1:App}Fixture : TestFixture", + "{", + " public ${1:App}Fixture(IMessageSink s) : base(s) { }", + "", + " protected override Task SetupAsync()", + " {", + " $0", + " }", + "", + " protected override void ConfigureServices(IServiceCollection s)", + " {", + "", + " }", + "", + " protected override Task TearDownAsync()", + " {", + "", + " }", + "}" + ], + "description": "test fixture" + }, + + "Event Handler": { + "prefix": "evnt", + "body": [ + "sealed class ${1:MyEvent} : IEvent", + "{", + "", + "}", + "", + "sealed class ${1:MyEvent}Handler : IEventHandler<${1:MyEvent}>", + "{", + " public Task HandleAsync(${1:MyEvent} e, CancellationToken c)", + " {", + " $0", + " }", + "}" + ], + "description": "event handler" + }, + + "NetCorePal Repository": { + "prefix": "ncprepo", + "body": [ + "public interface I${1:My}Repository : IRepository<${1:My}, ${1:My}Id>;", + "", + "public class ${1:My}Repository(ApplicationDbContext context) ", + " : RepositoryBase<${1:My}, ${1:My}Id, ApplicationDbContext>(context), ", + " I${1:My}Repository", + "{", + "}" + ], + "description": "创建仓储" + }, + + "Endpoint Data": { + "prefix": "epdat", + "body": [ + "static class ${1:My}Data", + "{", + " $0", + "}" + ], + "description": "endpoint data" + }, + + "Command Handler with Result": { + "prefix": "cmdres", + "body": [ + "sealed class ${1:MyCommand} : ICommand<${1:MyCommand}Result>", + "{", + "", + "}", + "", + "sealed class ${1:MyCommand}Result", + "{", + "", + "}", + "", + "sealed class ${1:MyCommand}Handler : ICommandHandler<${1:MyCommand}, ${1:MyCommand}Result>", + "{", + " public Task<${1:MyCommand}Result> ExecuteAsync(${1:MyCommand} cmd, CancellationToken c)", + " {", + " $0", + " }", + "}" + ], + "description": "command handler with result" + }, + + "Command Handler": { + "prefix": "cmd", + "body": [ + "sealed class ${1:MyCommand} : ICommand", + "{", + "", + "}", + "", + "sealed class ${1:MyCommand}Handler : ICommandHandler<${1:MyCommand}>", + "{", + " public Task ExecuteAsync(${1:MyCommand} cmd, CancellationToken c)", + " {", + " $0", + " }", + "}" + ], + "description": "command handler" + }, + + "Endpoint Validator": { + "prefix": "epval", + "body": [ + "sealed class ${1:My}Validator : Validator<${1:My}Request>", + "{", + " public ${1:My}Validator()", + " {", + " $0", + " }", + "}" + ], + "description": "endpoint validator" + }, + + "Global Pre-processor": { + "prefix": "preproc_g", + "body": [ + "sealed class ${1:MyProcessor} : IGlobalPreProcessor", + "{", + " public Task PreProcessAsync(object r, HttpContext ctx, List fails, CancellationToken c)", + " {", + " $0", + " }", + "}" + ], + "description": "global pre-processor" + }, + + "Endpoint with Response Only": { + "prefix": "epres", + "body": [ + "sealed class ${1:My}Response", + "{", + "", + "}", + "", + "sealed class ${1:My}Endpoint : EndpointWithoutRequest<${1:My}Response>", + "{", + " public override void Configure()", + " {", + " ${2:Get}(\"${3:route-pattern}\");", + " AllowAnonymous();", + " }", + "", + " public override async Task HandleAsync(CancellationToken c)", + " {", + " $0", + " }", + "}" + ], + "description": "endpoint with response only" + }, + + "NetCorePal Integration Event": { + "prefix": "ncpie", + "body": [ + "public record ${1:MyCreated}IntegrationEvent();", + "", + "public class ${1:MyCreated}IntegrationEventHandler(IMediator mediator) : IIntegrationEventHandler<${1:MyCreated}IntegrationEvent>", + "{", + " public Task HandleAsync(${1:MyCreated}IntegrationEvent eventData, CancellationToken cancellationToken = default)", + " {", + " // var cmd = new ${1:MyCreated}Command(eventData.Id);", + " // return mediator.Send(cmd, cancellationToken);", + " throw new NotImplementedException();", + " }", + "}" + ], + "description": "创建集成事件与事件处理器" + }, + + "NetCorePal Domain Event Handler": { + "prefix": "ncpdeh", + "body": [ + "public class ${1:MyCreated}DomainEventHandler(IMediator mediator) ", + " : IDomainEventHandler<${1:MyCreated}DomainEvent>", + "{", + " public async Task Handle(${1:MyCreated}DomainEvent notification, ", + " CancellationToken cancellationToken)", + " {", + " // 实现业务逻辑", + " throw new NotImplementedException();", + " }", + "}" + ], + "description": "创建领域事件处理器" + }, + + "FastEndpoint - NCP Style": { + "prefix": "epp", + "body": [ + "sealed class ${1:My}Endpoint(IMediator mediator) : Endpoint<${1:My}Request, ResponseData<${1:My}Response>>", + "{", + " public override void Configure()", + " {", + " ${2:Post}(\"${3:route-pattern}\");", + " AllowAnonymous();", + " }", + "", + " public override async Task HandleAsync(${1:My}Request r, CancellationToken c)", + " {", + " var cmd = new ${1:My}Command(r.Property1, r.Property2);", + " var result = await mediator.Send(cmd, c);", + " var res = new ${1:My}Response();", + " await SendOkAsync(res.AsResponseData(), c);", + " $0", + " }", + "}", + "", + "sealed record ${1:My}Request();", + "", + "sealed record ${1:My}Response();", + "", + "sealed class ${1:My}Validator : Validator<${1:My}Request>", + "{", + " public ${1:My}Validator()", + " {", + " // RuleFor(x => x.Property).NotEmpty();", + " }", + "}", + "", + "sealed class ${1:My}Summary : Summary<${1:My}Endpoint, ${1:My}Request>", + "{", + " public ${1:My}Summary()", + " {", + " Summary = \"${4:Summary text goes here...}\";", + " Description = \"${5:Description text goes here...}\";", + " }", + "}" + ], + "description": "endpoint vertical slice - NCP" + }, + + "Pre-processor": { + "prefix": "preproc", + "body": [ + "sealed class ${1:MyProcessor} : IPreProcessor<${2:My}Request>", + "{", + " public Task PreProcessAsync(${2:My}Request r, HttpContext ctx, List fails, CancellationToken c)", + " {", + " $0", + " }", + "}" + ], + "description": "pre-processor" + }, + + "NetCorePal Integration Event Converter": { + "prefix": "ncpiec", + "body": [ + "public class ${1:MyCreated}IntegrationEventConverter", + " : IIntegrationEventConverter<${1:MyCreated}DomainEvent, ${1:MyCreated}IntegrationEvent>", + "{", + " public ${1:MyCreated}IntegrationEvent Convert(${1:MyCreated}DomainEvent domainEvent)", + " {", + " // return new ${1:MyCreated}IntegrationEvent(domainEvent.Id);", + " throw new NotImplementedException();", + " }", + "}" + ], + "description": "创建集成事件转换器" + }, + + "Endpoint Mapper": { + "prefix": "epmap", + "body": [ + "sealed class ${1:My}Mapper : Mapper<${1:My}Request, ${1:My}Response, ${2:YourEntity}>", + "{", + " public override ${2:YourEntity} ToEntity(${1:My}Request r) => new()", + " {", + " $0", + " };", + "", + " public override ${1:My}Response FromEntity(${2:YourEntity} e) => new()", + " {", + " ", + " };", + "}" + ], + "description": "endpoint mapper" + }, + + "FastEndpoint Full Vertical Slice": { + "prefix": "epfull", + "body": [ + "sealed class ${1:My}Endpoint : Endpoint<${1:My}Request, ${1:My}Response, ${1:My}Mapper>", + "{", + " public override void Configure()", + " {", + " ${2:Post}(\"${3:route-pattern}\");", + " AllowAnonymous();", + " }", + "", + " public override async Task HandleAsync(${1:My}Request r, CancellationToken c)", + " {", + " $0", + " }", + "}", + "", + "sealed class ${1:My}Request", + "{", + "", + "}", + "", + "sealed class ${1:My}Response", + "{", + "", + "}", + "", + "sealed class ${1:My}Validator : Validator<${1:My}Request>", + "{", + " public ${1:My}Validator()", + " {", + "", + " }", + "}", + "", + "sealed class ${1:My}Mapper: Mapper<${1:My}Request, ${1:My}Response, ${4:YourEntity}>", + "{", + " public override ${4:YourEntity} ToEntity(${1:My}Request r) => new()", + " {", + "", + " };", + "", + " public override ${1:My}Response FromEntity(${4:YourEntity} e) => new()", + " {", + "", + " };", + "}", + "", + "sealed class ${1:My}Summary : Summary<${1:My}Endpoint, ${1:My}Request>", + "{", + " public ${1:My}Summary()", + " {", + " Summary = \"${5:Summary text goes here...}\";", + " Description = \"${6:Description text goes here...}\";", + " }", + "}" + ], + "description": "endpoint vertical slice" + }, + + "Global Post-processor": { + "prefix": "postproc_g", + "body": [ + "sealed class ${1:MyProcessor} : IGlobalPostProcessor", + "{", + " public Task PostProcessAsync(object req, object? res, HttpContext ctx, IReadOnlyCollection fails, CancellationToken c)", + " {", + " $0", + " }", + "}" + ], + "description": "global post-processor" + }, + + "Test Method": { + "prefix": "tstmethod", + "body": [ + " [Fact]", + " public async Task ${1:Name_Of_The_Test}()", + " {", + " $0", + " }" + ], + "description": "test method" + }, + + "NetCorePal Domain Event": { + "prefix": "ncpde", + "body": [ + "public record ${1:MyCreated}DomainEvent() : IDomainEvent;" + ], + "description": "创建领域事件" + }, + + "Endpoint Summary": { + "prefix": "epsum", + "body": [ + "sealed class ${1:My}Summary : Summary<${1:My}Endpoint, ${1:My}Request>", + "{", + " public ${1:My}Summary()", + " {", + " Summary = \"${2:Summary text goes here...}\";", + " Description = \"${3:Description text goes here...}\";", + " $0", + " }", + "}" + ], + "description": "endpoint summary" + }, + + "Endpoint Without Request": { + "prefix": "epnoreq", + "body": [ + "sealed class ${1:My}Endpoint : EndpointWithoutRequest", + "{", + " public override void Configure()", + " {", + " ${2:Get}(\"${3:route}\");", + " AllowAnonymous();", + " }", + "", + " public override async Task HandleAsync(CancellationToken c)", + " {", + " $0", + " }", + "}" + ], + "description": "endpoint without request" + }, + + "Endpoint with Request & Response": { + "prefix": "epreqres", + "body": [ + "sealed class ${1:My}Request", + "{", + "", + "}", + "", + "sealed class ${1:My}Response", + "{", + "", + "}", + "", + "sealed class ${1:My}Endpoint : Endpoint<${1:My}Request, ${1:My}Response>", + "{", + " public override void Configure()", + " {", + " ${2:Post}(\"${3:route-pattern}\");", + " AllowAnonymous();", + " }", + "", + " public override async Task HandleAsync(${1:My}Request r, CancellationToken c)", + " {", + " $0", + " }", + "}" + ], + "description": "endpoint with request & response" + }, + + "NetCorePal Entity Configuration": { + "prefix": "ncpconfig", + "body": [ + "public class ${1:Entity}Configuration : IEntityTypeConfiguration<${1:Entity}>", + "{", + " public void Configure(EntityTypeBuilder<${1:Entity}> builder)", + " {", + " builder.ToTable(\"${2:table}\");", + " builder.HasKey(t => t.Id);", + " builder.Property(t => t.Id)", + " /*.UseSnowFlakeValueGenerator()*/ // 如果使用 SnowFlake ID 生成器,请取消注释", + " /*.UseGuidVersion7ValueGenerator()*/ // 如果使用 Guid Version 7 ID 生成器,请取消注释", + " ;", + "", + " // Configure other properties if needed", + " $0", + " }", + "}" + ], + "description": "创建实体配置类" + } +} diff --git a/Backend/Directory.Build.props b/Backend/Directory.Build.props new file mode 100644 index 0000000..978c1b3 --- /dev/null +++ b/Backend/Directory.Build.props @@ -0,0 +1,34 @@ + + + + Fengling.Backend + Fengling.Backend + Fengling.Backend + Fengling.Backend + + + + git + + True + True + True + False + true + $(NoWarn);CS1591;NU1507;S125;CS9107; + + + + + preview + enable + $(WarningsAsErrors);CS8625;CS8604;CS8602;CS8600;CS8618;CS8601;CS8603 + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + diff --git a/Backend/Directory.Build.targets b/Backend/Directory.Build.targets new file mode 100644 index 0000000..f52a621 --- /dev/null +++ b/Backend/Directory.Build.targets @@ -0,0 +1,3 @@ + + + diff --git a/Backend/Directory.Packages.props b/Backend/Directory.Packages.props new file mode 100644 index 0000000..0d3de24 --- /dev/null +++ b/Backend/Directory.Packages.props @@ -0,0 +1,152 @@ + + + true + + + + + 3.2.1 + 7.1.1 + 4.9.0 + 13.1.0 + 1.14.0 + 1.0.5 + 1.1.2 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Backend/Fengling.Backend.sln.DotSettings b/Backend/Fengling.Backend.sln.DotSettings new file mode 100644 index 0000000..e65a68a --- /dev/null +++ b/Backend/Fengling.Backend.sln.DotSettings @@ -0,0 +1,918 @@ + + + + + False + + False + False + + + False + + False + True + True + post-processor + True + postproc + True + sealed class $name$ : IPostProcessor<$dto$Request, $dto$Response> +{ + public Task PostProcessAsync($dto$Request req, $dto$Response res, HttpContext ctx, IReadOnlyCollection<ValidationFailure> fails, CancellationToken c) + { + $END$ + } +} + True + constant("My") + 1 + True + constant("MyProcessor") + 0 + True + 2.0 + InCSharpFile + True + True + test class + True + tstclass + True + namespace Tests; + +public class $name$Tests : TestClass<$fixture$Fixture> +{ + public $name$Tests($fixture$Fixture f, ITestOutputHelper o) : base(f, o) { } + + [Fact] + public async Task $test_name$() + { + $END$ + } +} + True + constant("App") + 1 + True + constant("My") + 0 + True + constant("Name_Of_The_Test") + 2 + True + 2.0 + InCSharpFile + True + True + endpoint with request only + True + epreq + True + sealed class $epName$Request +{ + +} + +sealed class $epName$Endpoint : Endpoint<$epName$Request> +{ + public override void Configure() + { + $verb$("$route$"); + AllowAnonymous(); + } + + public override async Task HandleAsync($epName$Request r, CancellationToken c) + { + $END$ + } +} + True + constant("My") + 0 + True + constant("route-pattern") + 2 + True + constant("Post") + 1 + True + 2.0 + InCSharpFile + True + True + 创建命令 + True + constant("My") + 0 + True + True + 2.0 + InCSharpFile + ncpcmd + True + public record $name$Command() : ICommand; + +public class $name$CommandValidator : AbstractValidator<$name$Command> +{ + public $name$CommandValidator() + { + // 添加验证规则示例: + // RuleFor(x => x.Property).NotEmpty(); + } +} + +public class $name$CommandHandler : ICommandHandler<$name$Command> +{ + public async Task Handle( + $name$Command request, + CancellationToken cancellationToken) + { + // 实现业务逻辑 + throw new NotImplementedException(); + } +} + True + True + 创建命令(含返回值) + True + constant("My") + 0 + True + True + 2.0 + InCSharpFile + ncpcmdres + True + public record $name$Command() : ICommand<$name$CommandResponse>; + +public record $name$CommandResponse(); + +public class $name$CommandValidator : AbstractValidator<$name$Command> +{ + public $name$CommandValidator() + { + // 添加验证规则示例: + // RuleFor(x => x.Property).NotEmpty(); + } +} + +public class $name$CommandHandler : ICommandHandler<$name$Command, $name$CommandResponse> +{ + public async Task<$name$CommandResponse> Handle( + $name$Command request, + CancellationToken cancellationToken) + { + // 实现业务逻辑 + throw new NotImplementedException(); + } +} + True + True + endpoint request & response dtos + True + epdto + True + sealed class $name$Request +{ + $END$ +} + +sealed class $name$Response +{ + +} + True + constant("My") + 0 + True + 2.0 + InCSharpFile + True + True + 创建聚合根 + True + constant("My") + 0 + True + True + 2.0 + InCSharpFile + ncpar + True + public partial record $name$Id : IInt64StronglyTypedId; + +public class $name$ : Entity<$name$Id>, IAggregateRoot +{ + protected $name$() { } +} + + True + True + test fixture + True + tstfixture + True + namespace Tests; + +public class $name$Fixture : TestFixture<Program> +{ + public $name$Fixture(IMessageSink s) : base(s) { } + + protected override Task SetupAsync() + { + $END$ + } + + protected override void ConfigureServices(IServiceCollection s) + { + + } + + protected override Task TearDownAsync() + { + + } +} + True + constant("App") + 0 + True + 2.0 + InCSharpFile + True + True + cs + Endpoint + False + FastEndpoints Feature File Set + True + True + namespace $name_space$; + +sealed class Request +{ + +} + +sealed class Validator : Validator<Request> +{ + public Validator() + { + + } +} + +sealed class Response +{ + public string Message => "This endpoint hasn't been implemented yet!"; +} + +sealed class Endpoint : Endpoint<Request, Response, Mapper> +{ + public override void Configure() + { + Post("$route$"); + } + + public override async Task HandleAsync(Request r, CancellationToken c) + { + await SendAsync(new Response());$END$ + } +} + +sealed class Mapper : Mapper<Request, Response, object> +{ + +} + +static class Data +{ + +} + Class/Interface + True + fileDefaultNamespace() + 1 + True + constant("route-pattern") + 0 + True + InCSharpProjectFile + True + True + event handler + True + evnt + True + sealed class $name$ : IEvent +{ + +} + +sealed class $name$Handler : IEventHandler<$name$> +{ + public Task HandleAsync($name$ e, CancellationToken c) + { + $END$ + } +} + True + constant("MyEvent") + 0 + True + 2.0 + InCSharpFile + True + True + 创建仓储 + True + constant("My") + 0 + True + True + 2.0 + InCSharpFile + ncprepo + True + public interface I$name$Repository : IRepository<$name$, $name$Id>; + +public class $name$Repository(ApplicationDbContext context) + : RepositoryBase<$name$, $name$Id, ApplicationDbContext>(context), + I$name$Repository +{ +} + True + True + endpoint data + True + epdat + True + static class $name$Data +{ + $END$ +} + True + constant("My") + 0 + True + 2.0 + InCSharpFile + True + True + command handler with result + True + cmdres + True + sealed class $name$ : ICommand<$name$Result> +{ + +} + +sealed class $name$Result +{ + +} + +sealed class $name$Handler : ICommandHandler<$name$, $name$Result> +{ + public Task<$name$Result> ExecuteAsync($name$ cmd, CancellationToken c) + { + $END$ + } +} + True + constant("MyCommand") + 0 + True + 2.0 + InCSharpFile + True + True + command handler + True + cmd + True + sealed class $name$ : ICommand +{ + +} + +sealed class $name$Handler : ICommandHandler<$name$> +{ + public Task ExecuteAsync($name$ cmd, CancellationToken c) + { + $END$ + } +} + True + constant("MyCommand") + 0 + True + 2.0 + InCSharpFile + True + True + endpoint validator + True + epval + True + sealed class $name$Validator : Validator<$name$Request> +{ + public $name$Validator() + { + $END$ + } +} + True + constant("My") + 0 + True + 2.0 + InCSharpFile + True + True + global pre-processor + True + preproc_g + True + sealed class $name$ : IGlobalPreProcessor +{ + public Task PreProcessAsync(object r, HttpContext ctx, List<ValidationFailure> fails, CancellationToken c) + { + $END$ + } +} + True + constant("MyProcessor") + 0 + True + 2.0 + InCSharpFile + True + True + endpoint with response only + True + epres + True + sealed class $epName$Response +{ + +} + +sealed class $epName$Endpoint : EndpointWithoutRequest<$epName$Response> +{ + public override void Configure() + { + $verb$("$route$"); + AllowAnonymous(); + } + + public override async Task HandleAsync(CancellationToken c) + { + $END$ + } +} + True + constant("My") + 0 + True + constant("route-pattern") + 2 + True + constant("Get") + 1 + True + 2.0 + InCSharpFile + True + True + 创建集成事件与事件处理器 + True + constant("MyCreated") + 0 + True + True + 2.0 + InCSharpFile + ncpie + True + public record $name$IntegrationEvent(); + +public class $name$IntegrationEventHandler(IMediator mediator) : IIntegrationEventHandler<$name$IntegrationEvent> +{ + public Task HandleAsync($name$IntegrationEvent eventData, CancellationToken cancellationToken = default) + { + // var cmd = new $name$Command(eventData.Id); + // return mediator.Send(cmd, cancellationToken); + throw new NotImplementedException(); + } +} + True + True + 创建领域事件处理器 + True + constant("MyCreated") + 0 + True + True + 2.0 + InCSharpFile + ncpdeh + True + public class $name$DomainEventHandler(IMediator mediator) + : IDomainEventHandler<$name$DomainEvent> +{ + public async Task Handle($name$DomainEvent notification, + CancellationToken cancellationToken) + { + // 实现业务逻辑 + throw new NotImplementedException(); + } +} + True + True + endpoint vertical slice - NCP + True + constant("Description text goes here...") + 4 + True + constant("My") + 0 + True + constant("route-pattern") + 2 + True + constant("Summary text goes here...") + 3 + True + constant("Post") + 1 + True + True + 2.0 + InCSharpFile + epp + True + sealed class $epName$Endpoint(IMediator mediator) : Endpoint<$epName$Request, ResponseData<$epName$Response>> +{ + public override void Configure() + { + $verb$("$route$"); + AllowAnonymous(); + } + + public override async Task HandleAsync($epName$Request r, CancellationToken c) + { + var cmd = new $epName$Command(r.Property1, r.Property2); + var result = await mediator.Send(cmd, c); + var res = new $epName$Response(); + await SendOkAsync(res.AsResponseData(), c); + $END$ + } +} + +sealed record $epName$Request(); + +sealed record $epName$Response(); + +sealed class $epName$Validator : Validator<$epName$Request> +{ + public $epName$Validator() + { + // RuleFor(x => x.Property).NotEmpty(); + } +} + +sealed class $epName$Summary : Summary<$epName$Endpoint, $epName$Request> +{ + public $epName$Summary() + { + Summary = "$summaryText$"; + Description = "$descriptionText$"; + } +} + True + True + pre-processor + True + preproc + True + sealed class $name$ : IPreProcessor<$req$Request> +{ + public Task PreProcessAsync($req$Request r, HttpContext ctx, List<ValidationFailure> fails, CancellationToken c) + { + $END$ + } +} + True + constant("MyProcessor") + 0 + True + constant("My") + 1 + True + 2.0 + InCSharpFile + True + True + 创建集成事件转换器 + True + constant("MyCreated") + 0 + True + True + 2.0 + InCSharpFile + ncpiec + True + public class $name$IntegrationEventConverter + : IIntegrationEventConverter<$name$DomainEvent, $name$IntegrationEvent> +{ + public $name$IntegrationEvent Convert($name$DomainEvent domainEvent) + { + // return new $name$IntegrationEvent(domainEvent.Id); + throw new NotImplementedException(); + } +} + True + True + endpoint mapper + epmap + True + sealed class $epName$Mapper : Mapper<$epName$Request, $epName$Response, $entity$> +{ + public override $entity$ ToEntity($epName$Request r) => new() + { + $END$ + }; + + public override $epName$Response FromEntity($entity$ e) => new() + { + + }; +} + True + constant("YourEntity") + 1 + True + constant("My") + 0 + True + 2.0 + InCSharpFile + True + True + endpoint vertical slice + True + constant("Description text goes here...") + 5 + True + constant("Summary text goes here...") + 4 + True + epfull + True + sealed class $epName$Endpoint : Endpoint<$epName$Request, $epName$Response, $epName$Mapper> +{ + public override void Configure() + { + $verb$("$route$"); + AllowAnonymous(); + } + + public override async Task HandleAsync($epName$Request r, CancellationToken c) + { + $END$ + } +} + +sealed class $epName$Request +{ + +} + +sealed class $epName$Response +{ + +} + +sealed class $epName$Validator : Validator<$epName$Request> +{ + public $epName$Validator() + { + + } +} + +sealed class $epName$Mapper: Mapper<$epName$Request, $epName$Response, $entity$> +{ + public override $entity$ ToEntity($epName$Request r) => new() + { + + }; + + public override $epName$Response FromEntity($entity$ e) => new() + { + + }; +} + +sealed class $epName$Summary : Summary<$epName$Endpoint, $epName$Request> +{ + public $epName$Summary() + { + Summary = "$summaryText$"; + Description = "$descriptionText$"; + } +} + True + constant("YourEntity") + 3 + True + constant("My") + 0 + True + constant("route-pattern") + 2 + True + constant("Post") + 1 + True + 2.0 + InCSharpFile + True + True + global post-processor + True + postproc_g + True + sealed class $name$ : IGlobalPostProcessor +{ + public Task PostProcessAsync(object req, object? res, HttpContext ctx, IReadOnlyCollection<ValidationFailure> fails, CancellationToken c) + { + $END$ + } +} + True + constant("MyProcessor") + 0 + True + 2.0 + InCSharpFile + True + True + test method + True + tstmethod + True + [Fact] + public async Task $test_name$() + { + $END$ + } + True + constant("Name_Of_The_Test") + 0 + True + 2.0 + InCSharpFile + True + True + 创建领域事件 + True + constant("MyCreated") + 0 + True + True + 2.0 + InCSharpFile + ncpde + True + public record $name$DomainEvent() : IDomainEvent; + True + True + endpoint summary + True + epsum + True + sealed class $name$Summary : Summary<$name$Endpoint, $name$Request> +{ + public $name$Summary() + { + Summary = "$summaryText$"; + Description = "$descriptionText$"; + $END$ + } +} + True + constant("Description text goes here...") + 2 + True + constant("My") + 0 + True + constant("Summary text goes here...") + 1 + True + 2.0 + InCSharpFile + True + True + endpoint without request + True + epnoreq + True + sealed class $My$Endpoint : EndpointWithoutRequest +{ + public override void Configure() + { + $Get$("$route$"); + AllowAnonymous(); + } + + public override async Task HandleAsync(CancellationToken c) + { + $END$ + } +} + True + 1 + True + 0 + True + 2 + True + 2.0 + InCSharpFile + True + True + endpoint with request & response + True + epreqres + True + sealed class $epName$Request +{ + +} + +sealed class $epName$Response +{ + +} + +sealed class $epName$Endpoint : Endpoint<$epName$Request, $epName$Response> +{ + public override void Configure() + { + $verb$("$route$"); + AllowAnonymous(); + } + + public override async Task HandleAsync($epName$Request r, CancellationToken c) + { + $END$ + } +} + True + constant("My") + 0 + True + constant("route-pattern") + 2 + True + constant("Post") + 1 + True + 2.0 + InCSharpFile + True + True + 创建实体配置类 + True + ncpconfig + True + public class $Entity$Configuration : IEntityTypeConfiguration<$Entity$> +{ + public void Configure(EntityTypeBuilder<$Entity$> builder) + { + builder.ToTable("$table$"); + builder.HasKey(t => t.Id); + builder.Property(t => t.Id) + /*.UseSnowFlakeValueGenerator()*/ // 如果使用 SnowFlake ID 生成器,请取消注释 + /*.UseGuidVersion7ValueGenerator()*/ // 如果使用 Guid Version 7 ID 生成器,请取消注释 + ; + + // Configure other properties if needed + $END$ + } +} + True + constant("Entity") + 0 + True + constant("table") + 1 + True + 2.0 + InCSharpFile \ No newline at end of file diff --git a/Backend/Fengling.Backend.slnx b/Backend/Fengling.Backend.slnx new file mode 100644 index 0000000..60609d2 --- /dev/null +++ b/Backend/Fengling.Backend.slnx @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/Backend/NuGet.config b/Backend/NuGet.config new file mode 100644 index 0000000..4a09aa7 --- /dev/null +++ b/Backend/NuGet.config @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/Backend/README.md b/Backend/README.md new file mode 100644 index 0000000..fc3aeb3 --- /dev/null +++ b/Backend/README.md @@ -0,0 +1,230 @@ +# Fengling.Backend + +## 环境准备 + +### 使用 Aspire(推荐) + +如果您的项目启用了 Aspire 支持(使用 `--UseAspire` 参数创建),只需要 Docker 环境即可,无需手动配置各种基础设施服务。 + +```bash +# 仅需确保 Docker 环境运行 +docker version + +# 直接运行 AppHost 项目,Aspire 会自动管理所有依赖服务 +cd src/Fengling.Backend.AppHost +dotnet run +``` + +Aspire 会自动为您: +- 启动和管理数据库容器(MySQL、SQL Server、PostgreSQL、MongoDB 等) +- 启动和管理消息队列容器(RabbitMQ、Kafka、NATS 等) +- 启动和管理 Redis 容器 +- 提供统一的 Aspire Dashboard 界面查看所有服务状态 +- 自动配置服务间的连接字符串和依赖关系 + +访问 Aspire Dashboard(通常在 http://localhost:15888)可以查看和管理所有服务。 + +### 推荐方式:使用初始化脚本(不使用 Aspire 时) + +如果您没有启用 Aspire,项目提供了完整的基础设施初始化脚本,支持快速搭建开发环境: + +#### 使用 Docker Compose(推荐) +```bash +# 进入脚本目录 +cd scripts + +# 启动默认基础设施 (MySQL + Redis + RabbitMQ) +docker-compose up -d + +# 使用 SQL Server 替代 MySQL +docker-compose --profile sqlserver up -d + +# 使用 PostgreSQL 替代 MySQL +docker-compose --profile postgres up -d + +# 使用 Kafka 替代 RabbitMQ +docker-compose --profile kafka up -d + +# 停止所有服务 +docker-compose down + +# 停止并删除数据卷(完全清理) +docker-compose down -v +``` + +#### 使用初始化脚本 +```bash +# Linux/macOS +cd scripts +./init-infrastructure.sh + +# Windows PowerShell +cd scripts +.\init-infrastructure.ps1 + +# 清理环境 +./clean-infrastructure.sh # Linux/macOS +.\clean-infrastructure.ps1 # Windows +``` + +### 手动方式:单独运行 Docker 容器 + +如果需要手动控制每个容器,可以使用以下命令: + +```bash +# Redis +docker run --restart unless-stopped --name netcorepal-redis -p 6379:6379 -v netcorepal_redis_data:/data -d redis:7.2-alpine redis-server --appendonly yes --databases 1024 + +# MySQL +docker run --restart unless-stopped --name netcorepal-mysql -p 3306:3306 -e MYSQL_ROOT_PASSWORD=123456 -e MYSQL_CHARACTER_SET_SERVER=utf8mb4 -e MYSQL_COLLATION_SERVER=utf8mb4_unicode_ci -e TZ=Asia/Shanghai -v netcorepal_mysql_data:/var/lib/mysql -d mysql:8.0 + +# RabbitMQ +docker run --restart unless-stopped --name netcorepal-rabbitmq -p 5672:5672 -p 15672:15672 -e RABBITMQ_DEFAULT_USER=guest -e RABBITMQ_DEFAULT_PASS=guest -v netcorepal_rabbitmq_data:/var/lib/rabbitmq -d rabbitmq:3.12-management-alpine +``` + +### 服务访问信息 + +启动后,可以通过以下地址访问各个服务: + +- **Redis**: `localhost:6379` +- **MySQL**: `localhost:3306` (root/123456) +- **RabbitMQ AMQP**: `localhost:5672` (guest/guest) +- **RabbitMQ 管理界面**: http://localhost:15672 (guest/guest) +- **SQL Server**: `localhost:1433` (sa/Test123456!) +- **PostgreSQL**: `localhost:5432` (postgres/123456) +- **Kafka**: `localhost:9092` +- **Kafka UI**: http://localhost:8080 + +## IDE 代码片段配置 + +本模板提供了丰富的代码片段,帮助您快速生成常用的代码结构。 + +### Visual Studio 配置 + +运行以下 PowerShell 命令自动安装代码片段: + +```powershell +cd vs-snippets +.\Install-VSSnippets.ps1 +``` + +或者手动安装: + +1. 打开 Visual Studio +2. 转到 `工具` > `代码片段管理器` +3. 导入 `vs-snippets/NetCorePalTemplates.snippet` 文件 + +### VS Code 配置 + +VS Code 的代码片段已预配置在 `.vscode/csharp.code-snippets` 文件中,打开项目时自动生效。 + +### JetBrains Rider 配置 + +Rider 用户可以直接使用 `Fengling.Backend.sln.DotSettings` 文件中的 Live Templates 配置。 + +### 可用的代码片段 + +#### NetCorePal (ncp) 快捷键 +| 快捷键 | 描述 | 生成内容 | +|--------|------|----------| +| `ncpcmd` | NetCorePal 命令 | ICommand 实现(含验证器和处理器) | +| `ncpcmdres` | 命令(含返回值) | ICommand<Response> 实现 | +| `ncpar` | 聚合根 | Entity<Id> 和 IAggregateRoot | +| `ncprepo` | NetCorePal 仓储 | IRepository 接口和实现 | +| `ncpie` | 集成事件 | IntegrationEvent 和处理器 | +| `ncpdeh` | 域事件处理器 | IDomainEventHandler 实现 | +| `ncpiec` | 集成事件转换器 | IIntegrationEventConverter | +| `ncpde` | 域事件 | IDomainEvent 记录 | + +#### Endpoint (ep) 快捷键 +| 快捷键 | 描述 | 生成内容 | +|--------|------|----------| +| `epp` | FastEndpoint(NCP风格) | 完整的垂直切片实现 | +| `epreq` | 仅请求端点 | Endpoint<Request> | +| `epres` | 仅响应端点 | EndpointWithoutRequest<Response> | +| `epdto` | 端点 DTOs | Request 和 Response 类 | +| `epval` | 端点验证器 | Validator<Request> | +| `epmap` | 端点映射器 | Mapper<Request, Response, Entity> | +| `epfull` | 完整端点切片 | 带映射器的完整实现 | +| `epsum` | 端点摘要 | Summary<Endpoint, Request> | +| `epnoreq` | 无请求端点 | EndpointWithoutRequest | +| `epreqres` | 请求响应端点 | Endpoint<Request, Response> | +| `epdat` | 端点数据 | 静态数据类 | + +更多详细配置请参考:[vs-snippets/README.md](vs-snippets/README.md) + +## 依赖对框架与组件 + ++ [NetCorePal Cloud Framework](https://github.com/netcorepal/netcorepal-cloud-framework) ++ [ASP.NET Core](https://github.com/dotnet/aspnetcore) ++ [EFCore](https://github.com/dotnet/efcore) ++ [CAP](https://github.com/dotnetcore/CAP) ++ [MediatR](https://github.com/jbogard/MediatR) ++ [FluentValidation](https://docs.fluentvalidation.net/en/latest) ++ [Swashbuckle.AspNetCore.Swagger](https://github.com/domaindrivendev/Swashbuckle.AspNetCore) + +## 数据库迁移 + +```shell +# 安装工具 SEE: https://learn.microsoft.com/zh-cn/ef/core/cli/dotnet#installing-the-tools +dotnet tool install --global dotnet-ef --version 9.0.0 + +# 强制更新数据库 +dotnet ef database update -p src/Fengling.Backend.Infrastructure + +# 创建迁移 SEE:https://learn.microsoft.com/zh-cn/ef/core/managing-schemas/migrations/?tabs=dotnet-core-cli +dotnet ef migrations add InitialCreate -p src/Fengling.Backend.Infrastructure +``` + +## 代码分析可视化 + +框架提供了强大的代码流分析和可视化功能,帮助开发者直观地理解DDD架构中的组件关系和数据流向。 + +### 🎯 核心特性 + ++ **自动代码分析**:通过源生成器自动分析代码结构,识别控制器、命令、聚合根、事件等组件 ++ **多种图表类型**:支持架构流程图、命令链路图、事件流程图、类图等多种可视化图表 ++ **交互式HTML可视化**:生成完整的交互式HTML页面,内置导航和图表预览功能 ++ **一键在线编辑**:集成"View in Mermaid Live"按钮,支持一键跳转到在线编辑器 + +### 🚀 快速开始 + +安装命令行工具来生成独立的HTML文件: + +```bash +# 安装全局工具 +dotnet tool install -g NetCorePal.Extensions.CodeAnalysis.Tools + +# 进入项目目录并生成可视化文件 +cd src/Fengling.Backend.Web +netcorepal-codeanalysis generate --output architecture.html +``` + +### ✨ 主要功能 + ++ **交互式HTML页面**: + + 左侧树形导航,支持不同图表类型切换 + + 内置Mermaid.js实时渲染 + + 响应式设计,适配不同设备 + + 专业的现代化界面 + ++ **一键在线编辑**: + + 每个图表右上角的"View in Mermaid Live"按钮 + + 智能压缩算法优化URL长度 + + 自动跳转到[Mermaid Live Editor](https://mermaid.live/) + + 支持在线编辑、导出图片、生成分享链接 + +### 📖 详细文档 + +完整的使用说明和示例请参考: + ++ [代码流分析文档](https://netcorepal.github.io/netcorepal-cloud-framework/zh/code-analysis/code-flow-analysis/) ++ [代码分析工具文档](https://netcorepal.github.io/netcorepal-cloud-framework/zh/code-analysis/code-analysis-tools/) + +## 关于监控 + +这里使用了`prometheus-net`作为与基础设施prometheus集成的监控方案,默认通过地址 `/metrics` 输出监控指标。 + +更多信息请参见:[https://github.com/prometheus-net/prometheus-net](https://github.com/prometheus-net/prometheus-net) + + diff --git a/Backend/eng/versions.props b/Backend/eng/versions.props new file mode 100644 index 0000000..0dabf26 --- /dev/null +++ b/Backend/eng/versions.props @@ -0,0 +1,6 @@ + + + 1.0.0 + + + diff --git a/Backend/global.json b/Backend/global.json new file mode 100644 index 0000000..5ed20a7 --- /dev/null +++ b/Backend/global.json @@ -0,0 +1,7 @@ +{ + "sdk": { + "version": "10.0.100", + "allowPrerelease": true, + "rollForward": "latestMinor" + } +} \ No newline at end of file diff --git a/Backend/scripts/EXAMPLES.md b/Backend/scripts/EXAMPLES.md new file mode 100644 index 0000000..455fc54 --- /dev/null +++ b/Backend/scripts/EXAMPLES.md @@ -0,0 +1,151 @@ +# Usage Examples + +This document provides practical examples for using the infrastructure initialization scripts. + +## Quick Start Examples + +### Default Setup (MySQL + Redis + RabbitMQ) +```bash +# Using Docker Compose (Recommended) +docker compose up -d + +# Using shell script (Linux/macOS) +./init-infrastructure.sh + +# Using PowerShell (Windows) +.\init-infrastructure.ps1 +``` + +### Different Database Options +```bash +# Use PostgreSQL instead of MySQL +docker compose --profile postgres up -d + +# Use SQL Server instead of MySQL +docker compose --profile sqlserver up -d + +# With PowerShell +.\init-infrastructure.ps1 -Postgres +.\init-infrastructure.ps1 -SqlServer +``` + +### Different Message Queue Options +```bash +# Use Kafka instead of RabbitMQ +docker compose --profile kafka up -d + +# With PowerShell +.\init-infrastructure.ps1 -Kafka +``` + +### Cleanup Examples +```bash +# Stop services, keep data +docker compose down +./clean-infrastructure.sh +.\clean-infrastructure.ps1 + +# Stop services and remove all data +docker compose down -v +./clean-infrastructure.sh --volumes +.\clean-infrastructure.ps1 -Volumes +``` + +## Development Workflow + +### Typical Development Session +```bash +# 1. Start infrastructure +cd scripts +docker compose up -d + +# 2. Develop your application +cd ../src/Fengling.Backend.Web +dotnet run + +# 3. Run tests +cd ../../ +dotnet test + +# 4. Stop infrastructure (keep data) +cd scripts +docker compose down +``` + +### Clean Development Environment +```bash +# Clean slate - remove everything including data +cd scripts +docker compose down -v + +# Start fresh +docker compose up -d +``` + +## Troubleshooting + +### Check Service Status +```bash +# List running containers +docker ps + +# Check specific service logs +docker logs netcorepal-mysql +docker logs netcorepal-redis +docker logs netcorepal-rabbitmq + +# Check service health +docker compose ps +``` + +### Common Issues + +#### Port Already in Use +```bash +# Find what's using the port +netstat -tulpn | grep :3306 # Linux +netstat -ano | findstr :3306 # Windows + +# Stop conflicting services +sudo systemctl stop mysql # Linux +net stop mysql80 # Windows +``` + +#### Container Won't Start +```bash +# Remove problematic container and restart +docker rm -f netcorepal-mysql +docker compose up -d mysql +``` + +#### Data Corruption +```bash +# Remove data volumes and start fresh +docker compose down -v +docker compose up -d +``` + +## Connection Strings for Development + +Update your `appsettings.Development.json` with these connection strings: + +```json +{ + "ConnectionStrings": { + "Redis": "localhost:6379,defaultDatabase=0", + "MySql": "Server=localhost;Port=3306;Database=abctemplate;Uid=root;Pwd=123456;", + "SqlServer": "Server=localhost,1433;Database=abctemplate;User Id=sa;Password=Test123456!;TrustServerCertificate=true;", + "PostgreSQL": "Host=localhost;Port=5432;Database=abctemplate;Username=postgres;Password=123456;" + }, + "RabbitMQ": { + "HostName": "localhost", + "Port": 5672, + "UserName": "guest", + "Password": "guest", + "VirtualHost": "/" + }, + "Kafka": { + "BootstrapServers": "localhost:9092" + } +} +``` \ No newline at end of file diff --git a/Backend/scripts/README.md b/Backend/scripts/README.md new file mode 100644 index 0000000..eb9d22c --- /dev/null +++ b/Backend/scripts/README.md @@ -0,0 +1,56 @@ +# Infrastructure Initialization Scripts + +This directory contains scripts to help developers quickly set up the infrastructure needed for development and debugging. + +## Available Scripts + +- `docker-compose.yml` - Complete infrastructure setup using Docker Compose +- `init-infrastructure.sh` - Shell script for Linux/macOS +- `init-infrastructure.ps1` - PowerShell script for Windows +- `clean-infrastructure.sh` - Cleanup script for Linux/macOS +- `clean-infrastructure.ps1` - Cleanup script for Windows + +## Quick Start + +### Using Docker Compose (Recommended) +```bash +# Start all infrastructure services +docker-compose up -d + +# Stop all services +docker-compose down + +# Stop and remove volumes (clean start) +docker-compose down -v +``` + +### Using Individual Scripts +```bash +# Linux/macOS +./init-infrastructure.sh + +# Windows PowerShell +.\init-infrastructure.ps1 +``` + +## Infrastructure Components + +The scripts will set up the following services: + +### Database Options +- **MySQL** (default): Port 3306, root password: 123456 +- **SQL Server**: Port 1433, SA password: Test123456! +- **PostgreSQL**: Port 5432, postgres password: 123456 + +### Cache & Message Queue +- **Redis**: Port 6379, no password +- **RabbitMQ**: Ports 5672 (AMQP), 15672 (Management UI), guest/guest +- **Kafka**: Port 9092 (when using Kafka option) + +### Management Interfaces +- RabbitMQ Management: http://localhost:15672 (guest/guest) +- Kafka UI (if included): http://localhost:8080 + +## Configuration + +The default configuration matches the test containers setup used in the project's integration tests. \ No newline at end of file diff --git a/Backend/scripts/clean-infrastructure.ps1 b/Backend/scripts/clean-infrastructure.ps1 new file mode 100644 index 0000000..a031c17 --- /dev/null +++ b/Backend/scripts/clean-infrastructure.ps1 @@ -0,0 +1,195 @@ +# NetCorePal Template - Infrastructure Cleanup Script (PowerShell) +# This script stops and removes all infrastructure containers + +param( + [switch]$Volumes, + [switch]$Help +) + +$ErrorActionPreference = "Stop" + +# Color functions for output +function Write-Info { + param([string]$Message) + Write-Host "[INFO] $Message" -ForegroundColor Blue +} + +function Write-Success { + param([string]$Message) + Write-Host "[SUCCESS] $Message" -ForegroundColor Green +} + +function Write-Warning { + param([string]$Message) + Write-Host "[WARNING] $Message" -ForegroundColor Yellow +} + +function Write-Error { + param([string]$Message) + Write-Host "[ERROR] $Message" -ForegroundColor Red +} + +function Show-Help { + Write-Host "NetCorePal Template - Infrastructure Cleanup" -ForegroundColor Green + Write-Host "===========================================" -ForegroundColor Green + Write-Host "" + Write-Host "Usage: .\clean-infrastructure.ps1 [OPTIONS]" + Write-Host "" + Write-Host "Clean up NetCorePal Template infrastructure containers" + Write-Host "" + Write-Host "Options:" + Write-Host " -Help Show this help message" + Write-Host " -Volumes Also remove data volumes (WARNING: This will delete all data!)" + Write-Host "" + Write-Host "Examples:" + Write-Host " .\clean-infrastructure.ps1 # Stop and remove containers, keep data" + Write-Host " .\clean-infrastructure.ps1 -Volumes # Stop and remove containers and all data volumes" + Write-Host "" +} + +function Remove-Container { + param([string]$ContainerName) + + try { + $exists = docker ps -a --format "table {{.Names}}" | Select-String "^$ContainerName$" + if ($exists) { + Write-Info "Stopping and removing $ContainerName..." + + # Stop the container + try { + docker stop $ContainerName 2>$null | Out-Null + Write-Info "$ContainerName stopped" + } + catch { + Write-Warning "Could not stop $ContainerName (may already be stopped)" + } + + # Remove the container + try { + docker rm $ContainerName 2>$null | Out-Null + Write-Success "$ContainerName removed" + } + catch { + Write-Warning "Could not remove $ContainerName" + } + } + else { + Write-Info "$ContainerName not found, skipping..." + } + } + catch { + Write-Warning "Error processing $ContainerName : $_" + } +} + +function Remove-Volumes { + param([bool]$RemoveVolumes) + + if ($RemoveVolumes) { + Write-Info "Removing data volumes..." + + $volumes = @( + "netcorepal_redis_data", + "netcorepal_mysql_data", + "netcorepal_sqlserver_data", + "netcorepal_postgres_data", + "netcorepal_rabbitmq_data", + "netcorepal_zookeeper_data", + "netcorepal_zookeeper_logs", + "netcorepal_kafka_data" + ) + + foreach ($volume in $volumes) { + try { + $exists = docker volume ls --format "table {{.Name}}" | Select-String "^$volume$" + if ($exists) { + docker volume rm $volume 2>$null | Out-Null + Write-Success "Volume $volume removed" + } + } + catch { + Write-Warning "Could not remove volume $volume" + } + } + } + else { + Write-Info "Preserving data volumes (use -Volumes to remove them)" + } +} + +function Remove-Network { + try { + $exists = docker network ls --format "table {{.Name}}" | Select-String "^netcorepal-network$" + if ($exists) { + Write-Info "Removing network netcorepal-network..." + try { + docker network rm netcorepal-network 2>$null | Out-Null + Write-Success "Network removed" + } + catch { + Write-Warning "Could not remove network (may still be in use)" + } + } + } + catch { + Write-Warning "Error checking network: $_" + } +} + +function Start-Cleanup { + Write-Host "" + Write-Info "Starting infrastructure cleanup..." + Write-Host "" + + # List of containers to clean up + $containers = @( + "netcorepal-redis", + "netcorepal-mysql", + "netcorepal-sqlserver", + "netcorepal-postgres", + "netcorepal-rabbitmq", + "netcorepal-kafka", + "netcorepal-kafka-ui", + "netcorepal-zookeeper" + ) + + # Clean up containers + foreach ($container in $containers) { + Remove-Container -ContainerName $container + } + + # Clean up volumes if requested + Remove-Volumes -RemoveVolumes $Volumes + + # Clean up network + Remove-Network + + Write-Host "" + Write-Success "🎉 Infrastructure cleanup completed!" + Write-Host "" + if ($Volumes) { + Write-Warning "⚠️ All data has been removed. You'll need to reinitialize your databases." + } + else { + Write-Info "💾 Data volumes preserved. Data will be available when you restart the infrastructure." + } + Write-Host "" + Write-Info "Use '.\init-infrastructure.ps1' to restart the infrastructure" +} + +# Main execution +Write-Host "🧹 NetCorePal Template - Infrastructure Cleanup" -ForegroundColor Green +Write-Host "===============================================" -ForegroundColor Green + +if ($Help) { + Show-Help + exit 0 +} + +try { + Start-Cleanup +} +catch { + Write-Error "An error occurred during cleanup: $_" + exit 1 +} \ No newline at end of file diff --git a/Backend/scripts/clean-infrastructure.sh b/Backend/scripts/clean-infrastructure.sh new file mode 100644 index 0000000..6673955 --- /dev/null +++ b/Backend/scripts/clean-infrastructure.sh @@ -0,0 +1,177 @@ +#!/bin/bash + +# NetCorePal Template - Infrastructure Cleanup Script +# This script stops and removes all infrastructure containers + +set -e + +echo "🧹 NetCorePal Template - Infrastructure Cleanup" +echo "===============================================" + +# Color codes for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Function to print colored output +print_status() { + echo -e "${BLUE}[INFO]${NC} $1" +} + +print_success() { + echo -e "${GREEN}[SUCCESS]${NC} $1" +} + +print_warning() { + echo -e "${YELLOW}[WARNING]${NC} $1" +} + +print_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +# Function to stop and remove container +cleanup_container() { + local container_name=$1 + + if docker ps -a --format 'table {{.Names}}' | grep -q "^$container_name$"; then + print_status "Stopping and removing $container_name..." + + # Stop the container + if docker stop $container_name > /dev/null 2>&1; then + print_status "$container_name stopped" + else + print_warning "Could not stop $container_name (may already be stopped)" + fi + + # Remove the container + if docker rm $container_name > /dev/null 2>&1; then + print_success "$container_name removed" + else + print_warning "Could not remove $container_name" + fi + else + print_status "$container_name not found, skipping..." + fi +} + +# Function to remove volumes +cleanup_volumes() { + local remove_volumes=$1 + + if [ "$remove_volumes" = "true" ]; then + print_status "Removing data volumes..." + + local volumes=( + "netcorepal_redis_data" + "netcorepal_mysql_data" + "netcorepal_sqlserver_data" + "netcorepal_postgres_data" + "netcorepal_rabbitmq_data" + "netcorepal_zookeeper_data" + "netcorepal_zookeeper_logs" + "netcorepal_kafka_data" + ) + + for volume in "${volumes[@]}"; do + if docker volume ls --format 'table {{.Name}}' | grep -q "^$volume$"; then + if docker volume rm "$volume" > /dev/null 2>&1; then + print_success "Volume $volume removed" + else + print_warning "Could not remove volume $volume" + fi + fi + done + else + print_status "Preserving data volumes (use --volumes to remove them)" + fi +} + +# Function to remove network +cleanup_network() { + if docker network ls --format 'table {{.Name}}' | grep -q "^netcorepal-network$"; then + print_status "Removing network netcorepal-network..." + if docker network rm netcorepal-network > /dev/null 2>&1; then + print_success "Network removed" + else + print_warning "Could not remove network (may still be in use)" + fi + fi +} + +# Main cleanup function +main() { + local remove_volumes=false + + # Parse arguments + while [[ $# -gt 0 ]]; do + case $1 in + --volumes|-v) + remove_volumes=true + shift + ;; + --help|-h) + echo "Usage: $0 [OPTIONS]" + echo + echo "Clean up NetCorePal Template infrastructure containers" + echo + echo "Options:" + echo " -h, --help Show this help message" + echo " -v, --volumes Also remove data volumes (WARNING: This will delete all data!)" + echo + echo "Examples:" + echo " $0 # Stop and remove containers, keep data" + echo " $0 --volumes # Stop and remove containers and all data volumes" + exit 0 + ;; + *) + print_error "Unknown option: $1" + echo "Use --help for usage information" + exit 1 + ;; + esac + done + + echo + print_status "Starting infrastructure cleanup..." + echo + + # List of containers to clean up + local containers=( + "netcorepal-redis" + "netcorepal-mysql" + "netcorepal-sqlserver" + "netcorepal-postgres" + "netcorepal-rabbitmq" + "netcorepal-kafka" + "netcorepal-kafka-ui" + "netcorepal-zookeeper" + ) + + # Clean up containers + for container in "${containers[@]}"; do + cleanup_container "$container" + done + + # Clean up volumes if requested + cleanup_volumes "$remove_volumes" + + # Clean up network + cleanup_network + + echo + print_success "🎉 Infrastructure cleanup completed!" + echo + if [ "$remove_volumes" = "true" ]; then + print_warning "⚠️ All data has been removed. You'll need to reinitialize your databases." + else + print_status "💾 Data volumes preserved. Data will be available when you restart the infrastructure." + fi + echo + print_status "Use './init-infrastructure.sh' to restart the infrastructure" +} + +# Execute main function with all arguments +main "$@" \ No newline at end of file diff --git a/Backend/scripts/docker-compose.yml b/Backend/scripts/docker-compose.yml new file mode 100644 index 0000000..9c8af89 --- /dev/null +++ b/Backend/scripts/docker-compose.yml @@ -0,0 +1,167 @@ +services: + # Redis - Always included for caching and sessions + redis: + image: redis:7.2-alpine + container_name: netcorepal-redis + ports: + - "6379:6379" + volumes: + - redis_data:/data + command: redis-server --appendonly yes --databases 1024 + restart: unless-stopped + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 3 + + # MySQL Database (default option) + mysql: + image: mysql:8.0 + container_name: netcorepal-mysql + ports: + - "3306:3306" + environment: + MYSQL_ROOT_PASSWORD: 123456 + MYSQL_CHARACTER_SET_SERVER: utf8mb4 + MYSQL_COLLATION_SERVER: utf8mb4_unicode_ci + TZ: Asia/Shanghai + volumes: + - mysql_data:/var/lib/mysql + - ./mysql-init:/docker-entrypoint-initdb.d:ro + restart: unless-stopped + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p123456"] + interval: 10s + timeout: 5s + retries: 5 + + # SQL Server (alternative database option) + sqlserver: + image: mcr.microsoft.com/mssql/server:2022-latest + container_name: netcorepal-sqlserver + ports: + - "1433:1433" + environment: + ACCEPT_EULA: Y + MSSQL_SA_PASSWORD: Test123456! + TZ: Asia/Shanghai + volumes: + - sqlserver_data:/var/opt/mssql + restart: unless-stopped + profiles: + - sqlserver + healthcheck: + test: ["CMD-SHELL", "/opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P Test123456! -Q 'SELECT 1'"] + interval: 10s + timeout: 5s + retries: 5 + + # PostgreSQL (alternative database option) + postgres: + image: postgres:15-alpine + container_name: netcorepal-postgres + ports: + - "5432:5432" + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: 123456 + POSTGRES_DB: postgres + TZ: Asia/Shanghai + volumes: + - postgres_data:/var/lib/postgresql/data + - ./postgres-init:/docker-entrypoint-initdb.d:ro + restart: unless-stopped + profiles: + - postgres + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 10s + timeout: 5s + retries: 3 + + # RabbitMQ (default message queue option) + rabbitmq: + image: rabbitmq:3.12-management-alpine + container_name: netcorepal-rabbitmq + ports: + - "5672:5672" + - "15672:15672" + environment: + RABBITMQ_DEFAULT_USER: guest + RABBITMQ_DEFAULT_PASS: guest + volumes: + - rabbitmq_data:/var/lib/rabbitmq + restart: unless-stopped + healthcheck: + test: ["CMD", "rabbitmq-diagnostics", "ping"] + interval: 10s + timeout: 5s + retries: 3 + + # Kafka (alternative message queue option) + zookeeper: + image: confluentinc/cp-zookeeper:7.4.0 + container_name: netcorepal-zookeeper + environment: + ZOOKEEPER_CLIENT_PORT: 2181 + ZOOKEEPER_TICK_TIME: 2000 + volumes: + - zookeeper_data:/var/lib/zookeeper/data + - zookeeper_logs:/var/lib/zookeeper/log + restart: unless-stopped + profiles: + - kafka + + kafka: + image: confluentinc/cp-kafka:7.4.0 + container_name: netcorepal-kafka + depends_on: + - zookeeper + ports: + - "9092:9092" + environment: + KAFKA_BROKER_ID: 1 + KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181 + KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092 + KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 + KAFKA_AUTO_CREATE_TOPICS_ENABLE: 'true' + volumes: + - kafka_data:/var/lib/kafka/data + restart: unless-stopped + profiles: + - kafka + healthcheck: + test: ["CMD-SHELL", "kafka-broker-api-versions --bootstrap-server localhost:9092"] + interval: 10s + timeout: 5s + retries: 5 + + # Kafka UI (optional management interface) + kafka-ui: + image: provectuslabs/kafka-ui:latest + container_name: netcorepal-kafka-ui + depends_on: + - kafka + ports: + - "8080:8080" + environment: + KAFKA_CLUSTERS_0_NAME: local + KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS: kafka:9092 + restart: unless-stopped + profiles: + - kafka + +volumes: + redis_data: + mysql_data: + sqlserver_data: + postgres_data: + rabbitmq_data: + zookeeper_data: + zookeeper_logs: + kafka_data: + +networks: + default: + name: netcorepal-network \ No newline at end of file diff --git a/Backend/scripts/init-infrastructure.ps1 b/Backend/scripts/init-infrastructure.ps1 new file mode 100644 index 0000000..cb1f519 --- /dev/null +++ b/Backend/scripts/init-infrastructure.ps1 @@ -0,0 +1,258 @@ +# NetCorePal Template - Infrastructure Initialization Script (PowerShell) +# This script initializes the required infrastructure for development + +param( + [switch]$SqlServer, + [switch]$Postgres, + [switch]$Kafka, + [switch]$Help +) + +$ErrorActionPreference = "Stop" + +# Color functions for output +function Write-Info { + param([string]$Message) + Write-Host "[INFO] $Message" -ForegroundColor Blue +} + +function Write-Success { + param([string]$Message) + Write-Host "[SUCCESS] $Message" -ForegroundColor Green +} + +function Write-Warning { + param([string]$Message) + Write-Host "[WARNING] $Message" -ForegroundColor Yellow +} + +function Write-Error { + param([string]$Message) + Write-Host "[ERROR] $Message" -ForegroundColor Red +} + +function Show-Help { + Write-Host "NetCorePal Template - Infrastructure Initialization" -ForegroundColor Green + Write-Host "=================================================" -ForegroundColor Green + Write-Host "" + Write-Host "Usage: .\init-infrastructure.ps1 [OPTIONS]" + Write-Host "" + Write-Host "Initialize infrastructure containers for NetCorePal Template development" + Write-Host "" + Write-Host "Options:" + Write-Host " -Help Show this help message" + Write-Host " -SqlServer Use SQL Server database instead of MySQL" + Write-Host " -Postgres Use PostgreSQL database instead of MySQL" + Write-Host " -Kafka Use Kafka instead of RabbitMQ" + Write-Host "" + Write-Host "Examples:" + Write-Host " .\init-infrastructure.ps1 # Start with MySQL and RabbitMQ (default)" + Write-Host " .\init-infrastructure.ps1 -Postgres # Start with PostgreSQL and RabbitMQ" + Write-Host " .\init-infrastructure.ps1 -Kafka # Start with MySQL and Kafka" + Write-Host "" +} + +function Test-Docker { + Write-Info "Checking Docker installation..." + + try { + $null = Get-Command docker -ErrorAction Stop + } + catch { + Write-Error "Docker is not installed. Please install Docker Desktop first." + Write-Host "Download from: https://www.docker.com/products/docker-desktop/" -ForegroundColor Cyan + exit 1 + } + + try { + $null = docker info 2>$null + } + catch { + Write-Error "Docker is not running. Please start Docker Desktop first." + exit 1 + } + + Write-Success "Docker is installed and running" +} + +function Start-Container { + param( + [string]$Name, + [string]$Image, + [string]$Ports, + [string]$Environment, + [string]$Volumes, + [string]$AdditionalArgs + ) + + Write-Info "Starting $Name container..." + + # Stop and remove existing container if it exists + $existingContainer = docker ps -a --format "table {{.Names}}" | Select-String "^$Name$" + if ($existingContainer) { + Write-Warning "Stopping existing $Name container..." + docker stop $Name 2>$null | Out-Null + docker rm $Name 2>$null | Out-Null + } + + # Build the docker run command + $cmd = "docker run --restart unless-stopped --name $Name" + if ($Ports) { $cmd += " $Ports" } + if ($Environment) { $cmd += " $Environment" } + if ($Volumes) { $cmd += " $Volumes" } + if ($AdditionalArgs) { $cmd += " $AdditionalArgs" } + $cmd += " -d $Image" + + try { + Invoke-Expression $cmd | Out-Null + Write-Success "$Name container started successfully" + return $true + } + catch { + Write-Error "Failed to start $Name container: $_" + return $false + } +} + +function Wait-ForContainer { + param( + [string]$ContainerName, + [int]$MaxAttempts = 30 + ) + + Write-Info "Waiting for $ContainerName to be healthy..." + + for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) { + $running = docker ps --filter "name=$ContainerName" --filter "status=running" | Select-String $ContainerName + if ($running) { + Write-Success "$ContainerName is running" + return $true + } + + Write-Host "." -NoNewline + Start-Sleep -Seconds 2 + } + + Write-Host "" # New line after dots + Write-Error "$ContainerName failed to start properly" + return $false +} + +function Start-Infrastructure { + Write-Host "" + Write-Info "Starting infrastructure setup..." + Write-Host "" + + # Check prerequisites + Test-Docker + + # Start Redis + $success = Start-Container -Name "netcorepal-redis" -Image "redis:7.2-alpine" ` + -Ports "-p 6379:6379" ` + -Volumes "-v netcorepal_redis_data:/data" ` + -AdditionalArgs "redis-server --appendonly yes --databases 1024" + + if ($success) { + Wait-ForContainer -ContainerName "netcorepal-redis" -MaxAttempts 15 + } + + # Start Database + if ($Postgres) { + Write-Info "Setting up PostgreSQL database..." + $success = Start-Container -Name "netcorepal-postgres" -Image "postgres:15-alpine" ` + -Ports "-p 5432:5432" ` + -Environment "-e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=123456 -e POSTGRES_DB=postgres -e TZ=Asia/Shanghai" ` + -Volumes "-v netcorepal_postgres_data:/var/lib/postgresql/data" + + if ($success) { + Wait-ForContainer -ContainerName "netcorepal-postgres" -MaxAttempts 30 + } + } + elseif ($SqlServer) { + Write-Info "Setting up SQL Server database..." + $success = Start-Container -Name "netcorepal-sqlserver" -Image "mcr.microsoft.com/mssql/server:2022-latest" ` + -Ports "-p 1433:1433" ` + -Environment "-e ACCEPT_EULA=Y -e MSSQL_SA_PASSWORD=Test123456! -e TZ=Asia/Shanghai" ` + -Volumes "-v netcorepal_sqlserver_data:/var/opt/mssql" + + if ($success) { + Wait-ForContainer -ContainerName "netcorepal-sqlserver" -MaxAttempts 30 + } + } + else { + Write-Info "Setting up MySQL database..." + $success = Start-Container -Name "netcorepal-mysql" -Image "mysql:8.0" ` + -Ports "-p 3306:3306" ` + -Environment "-e MYSQL_ROOT_PASSWORD=123456 -e MYSQL_CHARACTER_SET_SERVER=utf8mb4 -e MYSQL_COLLATION_SERVER=utf8mb4_unicode_ci -e TZ=Asia/Shanghai" ` + -Volumes "-v netcorepal_mysql_data:/var/lib/mysql" + + if ($success) { + Wait-ForContainer -ContainerName "netcorepal-mysql" -MaxAttempts 30 + } + } + + # Start Message Queue + if ($Kafka) { + Write-Info "Setting up Kafka message queue..." + Write-Warning "Kafka setup requires Zookeeper. For full Kafka setup, please use Docker Compose:" + Write-Host "docker-compose --profile kafka up -d" -ForegroundColor Cyan + } + else { + Write-Info "Setting up RabbitMQ message queue..." + $success = Start-Container -Name "netcorepal-rabbitmq" -Image "rabbitmq:3.12-management-alpine" ` + -Ports "-p 5672:5672 -p 15672:15672" ` + -Environment "-e RABBITMQ_DEFAULT_USER=guest -e RABBITMQ_DEFAULT_PASS=guest" ` + -Volumes "-v netcorepal_rabbitmq_data:/var/lib/rabbitmq" + + if ($success) { + Wait-ForContainer -ContainerName "netcorepal-rabbitmq" -MaxAttempts 20 + } + } + + Write-Host "" + Write-Success "🎉 Infrastructure setup completed successfully!" + Write-Host "" + Write-Host "📋 Service Summary:" -ForegroundColor Cyan + Write-Host "===================" + Write-Host "✅ Redis: localhost:6379" + + if ($Postgres) { + Write-Host "✅ PostgreSQL: localhost:5432 (postgres/123456)" + } + elseif ($SqlServer) { + Write-Host "✅ SQL Server: localhost:1433 (sa/Test123456!)" + } + else { + Write-Host "✅ MySQL: localhost:3306 (root/123456)" + } + + if (-not $Kafka) { + Write-Host "✅ RabbitMQ: localhost:5672 (guest/guest)" + Write-Host "📊 RabbitMQ Management UI: http://localhost:15672" + } + + Write-Host "" + Write-Host "💡 Tips:" -ForegroundColor Yellow + Write-Host "• Use 'docker ps' to see running containers" + Write-Host "• Use 'docker logs ' to check logs" + Write-Host "• Use '.\clean-infrastructure.ps1' to stop and remove all containers" + Write-Host "" + Write-Info "Ready for development! 🚀" +} + +# Main execution +Write-Host "🚀 NetCorePal Template - Infrastructure Setup" -ForegroundColor Green +Write-Host "==============================================" -ForegroundColor Green + +if ($Help) { + Show-Help + exit 0 +} + +try { + Start-Infrastructure +} +catch { + Write-Error "An error occurred during setup: $_" + exit 1 +} \ No newline at end of file diff --git a/Backend/scripts/init-infrastructure.sh b/Backend/scripts/init-infrastructure.sh new file mode 100644 index 0000000..16f8819 --- /dev/null +++ b/Backend/scripts/init-infrastructure.sh @@ -0,0 +1,200 @@ +#!/bin/bash + +# NetCorePal Template - Infrastructure Initialization Script +# This script initializes the required infrastructure for development + +set -e + +echo "🚀 NetCorePal Template - Infrastructure Setup" +echo "==============================================" + +# Color codes for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Function to print colored output +print_status() { + echo -e "${BLUE}[INFO]${NC} $1" +} + +print_success() { + echo -e "${GREEN}[SUCCESS]${NC} $1" +} + +print_warning() { + echo -e "${YELLOW}[WARNING]${NC} $1" +} + +print_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +# Check if Docker is installed and running +check_docker() { + print_status "Checking Docker installation..." + + if ! command -v docker &> /dev/null; then + print_error "Docker is not installed. Please install Docker first." + echo "Download from: https://www.docker.com/products/docker-desktop/" + exit 1 + fi + + if ! docker info &> /dev/null; then + print_error "Docker is not running. Please start Docker first." + exit 1 + fi + + print_success "Docker is installed and running" +} + +# Function to run a Docker container with retry logic +run_container() { + local name=$1 + local image=$2 + local ports=$3 + local environment=$4 + local volumes=$5 + local additional_args=$6 + + print_status "Starting $name container..." + + # Stop and remove existing container if it exists + if docker ps -a --format 'table {{.Names}}' | grep -q "^$name$"; then + print_warning "Stopping existing $name container..." + docker stop $name > /dev/null 2>&1 || true + docker rm $name > /dev/null 2>&1 || true + fi + + # Run the container + local cmd="docker run --restart unless-stopped --name $name $ports $environment $volumes $additional_args -d $image" + + if eval $cmd > /dev/null; then + print_success "$name container started successfully" + return 0 + else + print_error "Failed to start $name container" + return 1 + fi +} + +# Function to wait for container to be healthy +wait_for_container() { + local container_name=$1 + local max_attempts=${2:-30} + local attempt=1 + + print_status "Waiting for $container_name to be healthy..." + + while [ $attempt -le $max_attempts ]; do + if docker ps --filter "name=$container_name" --filter "status=running" | grep -q $container_name; then + print_success "$container_name is running" + return 0 + fi + + echo -n "." + sleep 2 + ((attempt++)) + done + + print_error "$container_name failed to start properly" + return 1 +} + +# Main execution +main() { + echo + print_status "Starting infrastructure setup..." + echo + + # Check prerequisites + check_docker + + # Start Redis + run_container "netcorepal-redis" "redis:7.2-alpine" \ + "-p 6379:6379" \ + "" \ + "-v netcorepal_redis_data:/data" \ + "redis-server --appendonly yes --databases 1024" + + wait_for_container "netcorepal-redis" 15 + + # Start MySQL (default database) + print_status "Setting up MySQL database..." + run_container "netcorepal-mysql" "mysql:8.0" \ + "-p 3306:3306" \ + "-e MYSQL_ROOT_PASSWORD=123456 -e MYSQL_CHARACTER_SET_SERVER=utf8mb4 -e MYSQL_COLLATION_SERVER=utf8mb4_unicode_ci -e TZ=Asia/Shanghai" \ + "-v netcorepal_mysql_data:/var/lib/mysql" \ + "" + + wait_for_container "netcorepal-mysql" 30 + + # Start RabbitMQ (default message queue) + print_status "Setting up RabbitMQ message queue..." + run_container "netcorepal-rabbitmq" "rabbitmq:3.12-management-alpine" \ + "-p 5672:5672 -p 15672:15672" \ + "-e RABBITMQ_DEFAULT_USER=guest -e RABBITMQ_DEFAULT_PASS=guest" \ + "-v netcorepal_rabbitmq_data:/var/lib/rabbitmq" \ + "" + + wait_for_container "netcorepal-rabbitmq" 20 + + echo + print_success "🎉 Infrastructure setup completed successfully!" + echo + echo "📋 Service Summary:" + echo "===================" + echo "✅ Redis: localhost:6379" + echo "✅ MySQL: localhost:3306 (root/123456)" + echo "✅ RabbitMQ: localhost:5672 (guest/guest)" + echo "📊 RabbitMQ Management UI: http://localhost:15672" + echo + echo "💡 Tips:" + echo "• Use 'docker ps' to see running containers" + echo "• Use 'docker logs ' to check logs" + echo "• Use './clean-infrastructure.sh' to stop and remove all containers" + echo + print_status "Ready for development! 🚀" +} + +# Parse command line arguments +case "${1:-}" in + --help|-h) + echo "Usage: $0 [OPTIONS]" + echo + echo "Initialize infrastructure containers for NetCorePal Template development" + echo + echo "Options:" + echo " -h, --help Show this help message" + echo " --mysql Use MySQL database (default)" + echo " --sqlserver Use SQL Server database" + echo " --postgres Use PostgreSQL database" + echo " --kafka Use Kafka instead of RabbitMQ" + echo + echo "Examples:" + echo " $0 # Start with MySQL and RabbitMQ (default)" + echo " $0 --postgres # Start with PostgreSQL and RabbitMQ" + echo " $0 --kafka # Start with MySQL and Kafka" + exit 0 + ;; + --sqlserver) + print_status "SQL Server option will be implemented in Docker Compose version" + print_status "For now, use: docker-compose --profile sqlserver up -d" + exit 0 + ;; + --postgres) + print_status "PostgreSQL option will be implemented in Docker Compose version" + print_status "For now, use: docker-compose --profile postgres up -d" + exit 0 + ;; + --kafka) + print_status "Kafka option will be implemented in Docker Compose version" + print_status "For now, use: docker-compose --profile kafka up -d" + exit 0 + ;; + *) + main + ;; +esac \ No newline at end of file diff --git a/Backend/scripts/mysql-init/01-init.sql b/Backend/scripts/mysql-init/01-init.sql new file mode 100644 index 0000000..e60eb93 --- /dev/null +++ b/Backend/scripts/mysql-init/01-init.sql @@ -0,0 +1,18 @@ +-- MySQL Initialization Script for NetCorePal Template +-- This script creates the necessary database and user for development + +-- Create development database if it doesn't exist +CREATE DATABASE IF NOT EXISTS `abctemplate` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- Create a development user (optional - you can use root for development) +-- CREATE USER IF NOT EXISTS 'devuser'@'%' IDENTIFIED BY 'devpass123'; +-- GRANT ALL PRIVILEGES ON `abctemplate`.* TO 'devuser'@'%'; + +-- Ensure root can connect from any host (for development only) +-- ALTER USER 'root'@'%' IDENTIFIED BY '123456'; +-- GRANT ALL PRIVILEGES ON *.* TO 'root'@'%' WITH GRANT OPTION; + +FLUSH PRIVILEGES; + +-- Display completion message +SELECT 'MySQL initialization completed successfully' AS message; \ No newline at end of file diff --git a/Backend/scripts/postgres-init/01-init.sql b/Backend/scripts/postgres-init/01-init.sql new file mode 100644 index 0000000..8297d34 --- /dev/null +++ b/Backend/scripts/postgres-init/01-init.sql @@ -0,0 +1,24 @@ +-- PostgreSQL Initialization Script for NetCorePal Template +-- This script creates the necessary database and user for development + +-- Create development database if it doesn't exist +SELECT 'CREATE DATABASE abctemplate' +WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'abctemplate')\gexec + +-- Create a development user (optional - you can use postgres for development) +-- DO +-- $do$ +-- BEGIN +-- IF NOT EXISTS ( +-- SELECT FROM pg_catalog.pg_roles +-- WHERE rolname = 'devuser') THEN +-- CREATE ROLE devuser LOGIN PASSWORD 'devpass123'; +-- END IF; +-- END +-- $do$; + +-- Grant privileges to development user +-- GRANT ALL PRIVILEGES ON DATABASE abctemplate TO devuser; + +-- Display completion message +SELECT 'PostgreSQL initialization completed successfully' AS message; \ No newline at end of file diff --git a/Backend/src/Fengling.Backend.Domain/AggregatesModel/GiftAggregate/Gift.cs b/Backend/src/Fengling.Backend.Domain/AggregatesModel/GiftAggregate/Gift.cs new file mode 100644 index 0000000..18d637a --- /dev/null +++ b/Backend/src/Fengling.Backend.Domain/AggregatesModel/GiftAggregate/Gift.cs @@ -0,0 +1,239 @@ +using Fengling.Backend.Domain.DomainEvents; + +namespace Fengling.Backend.Domain.AggregatesModel.GiftAggregate; + +/// +/// 礼品ID +/// +public partial record GiftId : IGuidStronglyTypedId; + +/// +/// 礼品聚合根 +/// +public class Gift : Entity, IAggregateRoot +{ + protected Gift() { } + + public Gift( + string name, + GiftType type, + string description, + string imageUrl, + int requiredPoints, + int totalStock, + int? limitPerMember = null) + { + Name = name; + Type = type; + Description = description; + ImageUrl = imageUrl; + RequiredPoints = requiredPoints; + TotalStock = totalStock; + AvailableStock = totalStock; + LimitPerMember = limitPerMember; + IsOnShelf = false; + SortOrder = 0; + CreatedAt = DateTime.UtcNow; + + this.AddDomainEvent(new GiftCreatedDomainEvent(this)); + } + + /// + /// 礼品名称 + /// + public string Name { get; private set; } = string.Empty; + + /// + /// 礼品类型 + /// + public GiftType Type { get; private set; } + + /// + /// 描述 + /// + public string Description { get; private set; } = string.Empty; + + /// + /// 图片URL + /// + public string ImageUrl { get; private set; } = string.Empty; + + /// + /// 所需积分 + /// + public int RequiredPoints { get; private set; } + + /// + /// 总库存 + /// + public int TotalStock { get; private set; } + + /// + /// 可用库存 + /// + public int AvailableStock { get; private set; } + + /// + /// 每人限兑数量 + /// + public int? LimitPerMember { get; private set; } + + /// + /// 是否上架 + /// + public bool IsOnShelf { get; private set; } + + /// + /// 排序 + /// + public int SortOrder { get; private set; } + + /// + /// 创建时间 + /// + public DateTime CreatedAt { get; private set; } + + /// + /// 更新时间 + /// + public DateTime UpdatedAt { get; private set; } + + public Deleted Deleted { get; private set; } = new(); + public RowVersion RowVersion { get; private set; } = new(0); + + /// + /// 更新礼品信息 + /// + public void Update( + string? name = null, + string? description = null, + string? imageUrl = null, + int? requiredPoints = null, + int? limitPerMember = null) + { + if (!string.IsNullOrWhiteSpace(name)) + Name = name; + + if (!string.IsNullOrWhiteSpace(description)) + Description = description; + + if (!string.IsNullOrWhiteSpace(imageUrl)) + ImageUrl = imageUrl; + + if (requiredPoints.HasValue && requiredPoints.Value > 0) + RequiredPoints = requiredPoints.Value; + + if (limitPerMember.HasValue) + LimitPerMember = limitPerMember.Value; + + UpdatedAt = DateTime.UtcNow; + this.AddDomainEvent(new GiftUpdatedDomainEvent(this)); + } + + /// + /// 增加库存 + /// + public void AddStock(int quantity) + { + if (quantity <= 0) + throw new KnownException("库存数量必须大于0"); + + TotalStock += quantity; + AvailableStock += quantity; + UpdatedAt = DateTime.UtcNow; + } + + /// + /// 预留库存(下单时) + /// + public void ReserveStock(int quantity) + { + if (quantity <= 0) + throw new KnownException("数量必须大于0"); + + if (AvailableStock < quantity) + throw new KnownException($"库存不足,当前可用:{AvailableStock},需要:{quantity}"); + + AvailableStock -= quantity; + + if (AvailableStock <= 10) + { + this.AddDomainEvent(new GiftStockLowDomainEvent(this, AvailableStock)); + } + + this.AddDomainEvent(new GiftStockReservedDomainEvent(this, quantity)); + } + + /// + /// 扣减库存(确认发货后) + /// + public void DeductStock(int quantity) + { + if (quantity <= 0) + throw new KnownException("数量必须大于0"); + + this.AddDomainEvent(new GiftStockDeductedDomainEvent(this, quantity)); + } + + /// + /// 释放库存(订单取消时) + /// + public void ReleaseStock(int quantity) + { + if (quantity <= 0) + throw new KnownException("数量必须大于0"); + + AvailableStock += quantity; + this.AddDomainEvent(new GiftStockReleasedDomainEvent(this, quantity)); + } + + /// + /// 上架 + /// + public void PutOnShelf() + { + if (AvailableStock <= 0) + throw new KnownException("库存为0,无法上架"); + + if (!IsOnShelf) + { + IsOnShelf = true; + UpdatedAt = DateTime.UtcNow; + this.AddDomainEvent(new GiftPutOnShelfDomainEvent(this)); + } + } + + /// + /// 下架 + /// + public void PutOffShelf() + { + if (IsOnShelf) + { + IsOnShelf = false; + UpdatedAt = DateTime.UtcNow; + this.AddDomainEvent(new GiftPutOffShelfDomainEvent(this)); + } + } +} + +/// +/// 礼品类型 +/// +public enum GiftType +{ + /// + /// 实物 + /// + Physical = 1, + + /// + /// 虚拟(卡券等) + /// + Virtual = 2, + + /// + /// 自有产品 + /// + Product = 3 +} diff --git a/Backend/src/Fengling.Backend.Domain/AggregatesModel/MarketingCodeAggregate/MarketingCode.cs b/Backend/src/Fengling.Backend.Domain/AggregatesModel/MarketingCodeAggregate/MarketingCode.cs new file mode 100644 index 0000000..ee2db99 --- /dev/null +++ b/Backend/src/Fengling.Backend.Domain/AggregatesModel/MarketingCodeAggregate/MarketingCode.cs @@ -0,0 +1,102 @@ +using Fengling.Backend.Domain.DomainEvents; + +namespace Fengling.Backend.Domain.AggregatesModel.MarketingCodeAggregate; + +/// +/// 营销码ID +/// +public partial record MarketingCodeId : IGuidStronglyTypedId; + +/// +/// 营销码聚合根 +/// +public class MarketingCode : Entity, IAggregateRoot +{ + protected MarketingCode() { } + + public MarketingCode( + string code, + Guid productId, + string productName, + string batchNo, + DateTime? expiryDate = null) + { + Code = code; + ProductInfo = new ProductInfo(productId, productName); + BatchNo = batchNo; + IsUsed = false; + ExpiryDate = expiryDate; + CreatedAt = DateTime.UtcNow; + + this.AddDomainEvent(new MarketingCodeGeneratedDomainEvent(this)); + } + + /// + /// 营销码(唯一) + /// + public string Code { get; private set; } = string.Empty; + + /// + /// 产品信息 + /// + public ProductInfo ProductInfo { get; private set; } = ProductInfo.Empty(); + + /// + /// 批次号 + /// + public string BatchNo { get; private set; } = string.Empty; + + /// + /// 是否已使用 + /// + public bool IsUsed { get; private set; } + + /// + /// 使用者会员ID + /// + public Guid? UsedByMemberId { get; private set; } + + /// + /// 使用时间 + /// + public DateTime? UsedAt { get; private set; } + + /// + /// 过期时间 + /// + public DateTime? ExpiryDate { get; private set; } + + /// + /// 创建时间 + /// + public DateTime CreatedAt { get; private set; } + + public Deleted Deleted { get; private set; } = new(); + public RowVersion RowVersion { get; private set; } = new(0); + + /// + /// 标记为已使用 + /// + public void MarkAsUsed(Guid memberId) + { + if (IsUsed) + throw new KnownException("该营销码已被使用"); + + if (ExpiryDate.HasValue && ExpiryDate.Value < DateTime.UtcNow) + throw new KnownException("该营销码已过期"); + + IsUsed = true; + UsedByMemberId = memberId; + UsedAt = DateTime.UtcNow; + + this.AddDomainEvent(new MarketingCodeUsedDomainEvent(this, memberId)); + } + + /// + /// 检查是否有效 + /// + public bool IsValid() + { + return !IsUsed && (!ExpiryDate.HasValue || ExpiryDate.Value >= DateTime.UtcNow); + } +} diff --git a/Backend/src/Fengling.Backend.Domain/AggregatesModel/MarketingCodeAggregate/ProductInfo.cs b/Backend/src/Fengling.Backend.Domain/AggregatesModel/MarketingCodeAggregate/ProductInfo.cs new file mode 100644 index 0000000..19828f9 --- /dev/null +++ b/Backend/src/Fengling.Backend.Domain/AggregatesModel/MarketingCodeAggregate/ProductInfo.cs @@ -0,0 +1,39 @@ +namespace Fengling.Backend.Domain.AggregatesModel.MarketingCodeAggregate; + +/// +/// 产品信息值对象 +/// +public record ProductInfo +{ + /// + /// 产品ID + /// + public Guid ProductId { get; init; } + + /// + /// 产品名称 + /// + public string ProductName { get; init; } = string.Empty; + + /// + /// 品类ID(可选) + /// + public Guid? CategoryId { get; init; } + + /// + /// 品类名称(可选) + /// + public string? CategoryName { get; init; } + + private ProductInfo() { } + + public ProductInfo(Guid productId, string productName, Guid? categoryId = null, string? categoryName = null) + { + ProductId = productId; + ProductName = productName; + CategoryId = categoryId; + CategoryName = categoryName; + } + + public static ProductInfo Empty() => new(Guid.Empty, string.Empty); +} diff --git a/Backend/src/Fengling.Backend.Domain/AggregatesModel/MemberAggregate/Member.cs b/Backend/src/Fengling.Backend.Domain/AggregatesModel/MemberAggregate/Member.cs new file mode 100644 index 0000000..0b547af --- /dev/null +++ b/Backend/src/Fengling.Backend.Domain/AggregatesModel/MemberAggregate/Member.cs @@ -0,0 +1,204 @@ +using Fengling.Backend.Domain.DomainEvents; + +namespace Fengling.Backend.Domain.AggregatesModel.MemberAggregate; + +/// +/// 会员ID强类型标识 +/// +public partial record MemberId : IGuidStronglyTypedId; + +/// +/// 会员聚合根 +/// +public class Member : Entity, IAggregateRoot +{ + protected Member() { } + + public Member(string phone, string password, string? nickname = null) + { + Phone = phone; + Password = password; + Nickname = nickname ?? phone; + Level = MemberLevel.Default(); + TotalPoints = 0; + AvailablePoints = 0; + Status = MemberStatus.Active; + RegisteredAt = DateTime.UtcNow; + + this.AddDomainEvent(new MemberRegisteredDomainEvent(this)); + } + + /// + /// 手机号(唯一) + /// + public string Phone { get; private set; } = string.Empty; + + /// + /// 密码(已加密) + /// + public string Password { get; private set; } = string.Empty; + + /// + /// 昵称 + /// + public string Nickname { get; private set; } = string.Empty; + + /// + /// 会员等级 + /// + public MemberLevel Level { get; private set; } = MemberLevel.Default(); + + /// + /// 累计总积分(用于等级判定) + /// + public int TotalPoints { get; private set; } + + /// + /// 可用积分余额 + /// + public int AvailablePoints { get; private set; } + + /// + /// 会员状态 + /// + public MemberStatus Status { get; private set; } + + /// + /// 注册时间 + /// + public DateTime RegisteredAt { get; private set; } + + public Deleted Deleted { get; private set; } = new(); + public RowVersion RowVersion { get; private set; } = new(0); + + /// + /// 修改密码 + /// + public void ChangePassword(string newPassword) + { + Password = newPassword; + this.AddDomainEvent(new MemberPasswordChangedDomainEvent(this)); + } + + /// + /// 更新资料 + /// + public void UpdateProfile(string? nickname) + { + if (!string.IsNullOrWhiteSpace(nickname)) + { + Nickname = nickname; + this.AddDomainEvent(new MemberProfileUpdatedDomainEvent(this)); + } + } + + /// + /// 升级会员等级 + /// + public void UpgradeLevel(MemberLevel newLevel) + { + if (newLevel.RequiredPoints > Level.RequiredPoints) + { + Level = newLevel; + this.AddDomainEvent(new MemberLevelUpgradedDomainEvent(this, newLevel)); + } + } + + /// + /// 禁用会员 + /// + public void Disable(string reason) + { + if (Status == MemberStatus.Active) + { + Status = MemberStatus.Disabled; + this.AddDomainEvent(new MemberDisabledDomainEvent(this, reason)); + } + } + + /// + /// 启用会员 + /// + public void Enable() + { + if (Status == MemberStatus.Disabled) + { + Status = MemberStatus.Active; + this.AddDomainEvent(new MemberEnabledDomainEvent(this)); + } + } + + /// + /// 增加积分 + /// + public void AddPoints(int amount, string source, Guid relatedId, DateTime expiryDate) + { + if (amount <= 0) + throw new KnownException("积分数量必须大于0"); + + TotalPoints += amount; + AvailablePoints += amount; + + this.AddDomainEvent(new PointsAddedDomainEvent(Id, amount, source, relatedId, expiryDate)); + } + + /// + /// 消费积分 + /// + public void ConsumePoints(int amount, string reason, Guid orderId) + { + if (amount <= 0) + throw new KnownException("积分数量必须大于0"); + + if (AvailablePoints < amount) + throw new KnownException($"积分余额不足,当前可用:{AvailablePoints},需要:{amount}"); + + AvailablePoints -= amount; + + this.AddDomainEvent(new PointsConsumedDomainEvent(Id, amount, reason, orderId)); + } + + /// + /// 退还积分(订单取消) + /// + public void RefundPoints(int amount, string reason, Guid orderId) + { + if (amount <= 0) + throw new KnownException("积分数量必须大于0"); + + AvailablePoints += amount; + + this.AddDomainEvent(new PointsRefundedDomainEvent(Id, amount, reason, orderId)); + } + + /// + /// 过期积分 + /// + public void ExpirePoints(int amount) + { + if (amount <= 0) return; + + if (AvailablePoints < amount) + amount = AvailablePoints; + + AvailablePoints -= amount; + + this.AddDomainEvent(new PointsExpiredDomainEvent(Id, amount)); + } +} + +/// +/// 会员状态枚举 +/// +public enum MemberStatus +{ + /// + /// 正常 + /// + Active = 1, + + /// + /// 禁用 + /// + Disabled = 2 +} diff --git a/Backend/src/Fengling.Backend.Domain/AggregatesModel/MemberAggregate/MemberLevel.cs b/Backend/src/Fengling.Backend.Domain/AggregatesModel/MemberAggregate/MemberLevel.cs new file mode 100644 index 0000000..5208a7e --- /dev/null +++ b/Backend/src/Fengling.Backend.Domain/AggregatesModel/MemberAggregate/MemberLevel.cs @@ -0,0 +1,42 @@ +namespace Fengling.Backend.Domain.AggregatesModel.MemberAggregate; + +/// +/// 会员等级值对象 +/// +public record MemberLevel +{ + /// + /// 等级编码 + /// + public string LevelCode { get; init; } = string.Empty; + + /// + /// 等级名称 + /// + public string LevelName { get; init; } = string.Empty; + + /// + /// 所需积分 + /// + public int RequiredPoints { get; init; } + + /// + /// 积分奖励倍率 + /// + public decimal BonusRate { get; init; } + + private MemberLevel() { } + + public MemberLevel(string levelCode, string levelName, int requiredPoints, decimal bonusRate = 1.0m) + { + LevelCode = levelCode; + LevelName = levelName; + RequiredPoints = requiredPoints; + BonusRate = bonusRate; + } + + /// + /// 默认等级 + /// + public static MemberLevel Default() => new("NORMAL", "普通会员", 0, 1.0m); +} diff --git a/Backend/src/Fengling.Backend.Domain/AggregatesModel/MemberLevelRuleAggregate/MemberLevelRule.cs b/Backend/src/Fengling.Backend.Domain/AggregatesModel/MemberLevelRuleAggregate/MemberLevelRule.cs new file mode 100644 index 0000000..7ddd160 --- /dev/null +++ b/Backend/src/Fengling.Backend.Domain/AggregatesModel/MemberLevelRuleAggregate/MemberLevelRule.cs @@ -0,0 +1,118 @@ +using Fengling.Backend.Domain.DomainEvents; + +namespace Fengling.Backend.Domain.AggregatesModel.MemberLevelRuleAggregate; + +/// +/// 会员等级规则ID +/// +public partial record MemberLevelRuleId : IGuidStronglyTypedId; + +/// +/// 会员等级规则聚合根 +/// +public class MemberLevelRule : Entity, IAggregateRoot +{ + protected MemberLevelRule() { } + + public MemberLevelRule( + string levelCode, + string levelName, + int requiredPoints, + decimal bonusPointsRate = 1.0m, + int sortOrder = 0) + { + LevelCode = levelCode; + LevelName = levelName; + RequiredPoints = requiredPoints; + BonusPointsRate = bonusPointsRate; + SortOrder = sortOrder; + IsActive = true; + CreatedAt = DateTime.UtcNow; + + this.AddDomainEvent(new MemberLevelRuleCreatedDomainEvent(this)); + } + + /// + /// 等级编码(唯一) + /// + public string LevelCode { get; private set; } = string.Empty; + + /// + /// 等级名称 + /// + public string LevelName { get; private set; } = string.Empty; + + /// + /// 所需积分 + /// + public int RequiredPoints { get; private set; } + + /// + /// 积分奖励倍率 + /// + public decimal BonusPointsRate { get; private set; } + + /// + /// 排序(越小越高) + /// + public int SortOrder { get; private set; } + + /// + /// 是否激活 + /// + public bool IsActive { get; private set; } + + /// + /// 创建时间 + /// + public DateTime CreatedAt { get; private set; } + + public Deleted Deleted { get; private set; } = new(); + public RowVersion RowVersion { get; private set; } = new(0); + + /// + /// 更新规则 + /// + public void Update( + string? levelName = null, + int? requiredPoints = null, + decimal? bonusPointsRate = null, + int? sortOrder = null) + { + if (!string.IsNullOrWhiteSpace(levelName)) + LevelName = levelName; + + if (requiredPoints.HasValue && requiredPoints.Value >= 0) + RequiredPoints = requiredPoints.Value; + + if (bonusPointsRate.HasValue && bonusPointsRate.Value > 0) + BonusPointsRate = bonusPointsRate.Value; + + if (sortOrder.HasValue) + SortOrder = sortOrder.Value; + + this.AddDomainEvent(new MemberLevelRuleUpdatedDomainEvent(this)); + } + + /// + /// 激活 + /// + public void Activate() + { + if (!IsActive) + { + IsActive = true; + } + } + + /// + /// 停用 + /// + public void Deactivate() + { + if (IsActive) + { + IsActive = false; + } + } +} diff --git a/Backend/src/Fengling.Backend.Domain/AggregatesModel/PointsRuleAggregate/PointsRule.cs b/Backend/src/Fengling.Backend.Domain/AggregatesModel/PointsRuleAggregate/PointsRule.cs new file mode 100644 index 0000000..a73afe6 --- /dev/null +++ b/Backend/src/Fengling.Backend.Domain/AggregatesModel/PointsRuleAggregate/PointsRule.cs @@ -0,0 +1,201 @@ +using Fengling.Backend.Domain.DomainEvents; + +namespace Fengling.Backend.Domain.AggregatesModel.PointsRuleAggregate; + +/// +/// 积分规则ID +/// +public partial record PointsRuleId : IGuidStronglyTypedId; + +/// +/// 积分规则聚合根 +/// +public class PointsRule : Entity, IAggregateRoot +{ + protected PointsRule() { } + + public PointsRule( + string ruleName, + PointsRuleType ruleType, + int pointsValue, + DateTime startDate, + DateTime? endDate = null, + Guid? productId = null, + Guid? categoryId = null, + string? memberLevelCode = null, + decimal bonusMultiplier = 1.0m) + { + RuleName = ruleName; + RuleType = ruleType; + PointsValue = pointsValue; + BonusMultiplier = bonusMultiplier; + StartDate = startDate; + EndDate = endDate; + ProductId = productId; + CategoryId = categoryId; + MemberLevelCode = memberLevelCode; + IsActive = true; + CreatedAt = DateTime.UtcNow; + + this.AddDomainEvent(new PointsRuleCreatedDomainEvent(this)); + } + + /// + /// 规则名称 + /// + public string RuleName { get; private set; } = string.Empty; + + /// + /// 规则类型 + /// + public PointsRuleType RuleType { get; private set; } + + /// + /// 积分值 + /// + public int PointsValue { get; private set; } + + /// + /// 奖励倍数 + /// + public decimal BonusMultiplier { get; private set; } + + /// + /// 生效开始时间 + /// + public DateTime StartDate { get; private set; } + + /// + /// 生效结束时间(可选) + /// + public DateTime? EndDate { get; private set; } + + /// + /// 产品ID(产品维度规则) + /// + public Guid? ProductId { get; private set; } + + /// + /// 品类ID(品类维度规则) + /// + public Guid? CategoryId { get; private set; } + + /// + /// 会员等级编码(会员维度规则) + /// + public string? MemberLevelCode { get; private set; } + + /// + /// 是否激活 + /// + public bool IsActive { get; private set; } + + /// + /// 创建时间 + /// + public DateTime CreatedAt { get; private set; } + + public Deleted Deleted { get; private set; } = new(); + public RowVersion RowVersion { get; private set; } = new(0); + + /// + /// 更新规则 + /// + public void Update( + string? ruleName = null, + int? pointsValue = null, + decimal? bonusMultiplier = null, + DateTime? startDate = null, + DateTime? endDate = null) + { + if (!string.IsNullOrWhiteSpace(ruleName)) + RuleName = ruleName; + + if (pointsValue.HasValue && pointsValue.Value > 0) + PointsValue = pointsValue.Value; + + if (bonusMultiplier.HasValue && bonusMultiplier.Value > 0) + BonusMultiplier = bonusMultiplier.Value; + + if (startDate.HasValue) + StartDate = startDate.Value; + + if (endDate.HasValue) + EndDate = endDate.Value; + + this.AddDomainEvent(new PointsRuleUpdatedDomainEvent(this)); + } + + /// + /// 激活规则 + /// + public void Activate() + { + if (!IsActive) + { + IsActive = true; + this.AddDomainEvent(new PointsRuleActivatedDomainEvent(this)); + } + } + + /// + /// 停用规则 + /// + public void Deactivate() + { + if (IsActive) + { + IsActive = false; + this.AddDomainEvent(new PointsRuleDeactivatedDomainEvent(this)); + } + } + + /// + /// 检查规则是否匹配 + /// + public bool IsMatch(Guid productId, Guid? categoryId, string memberLevelCode, DateTime scanDate) + { + if (!IsActive) return false; + if (scanDate < StartDate || (EndDate.HasValue && scanDate > EndDate.Value)) return false; + + // 产品维度 + if (ProductId.HasValue && ProductId.Value != productId) return false; + + // 品类维度 + if (CategoryId.HasValue && categoryId.HasValue && CategoryId.Value != categoryId.Value) return false; + + // 会员等级维度 + if (!string.IsNullOrWhiteSpace(MemberLevelCode) && MemberLevelCode != memberLevelCode) return false; + + return true; + } + + /// + /// 计算最终积分 + /// + public int CalculatePoints() + { + return (int)(PointsValue * BonusMultiplier); + } +} + +/// +/// 积分规则类型 +/// +public enum PointsRuleType +{ + /// + /// 产品维度 + /// + Product = 1, + + /// + /// 时间维度 + /// + Time = 2, + + /// + /// 会员等级维度 + /// + MemberLevel = 3 +} diff --git a/Backend/src/Fengling.Backend.Domain/AggregatesModel/PointsTransactionAggregate/PointsTransaction.cs b/Backend/src/Fengling.Backend.Domain/AggregatesModel/PointsTransactionAggregate/PointsTransaction.cs new file mode 100644 index 0000000..cffb453 --- /dev/null +++ b/Backend/src/Fengling.Backend.Domain/AggregatesModel/PointsTransactionAggregate/PointsTransaction.cs @@ -0,0 +1,183 @@ +using Fengling.Backend.Domain.AggregatesModel.MemberAggregate; + +namespace Fengling.Backend.Domain.AggregatesModel.PointsTransactionAggregate; + +/// +/// 积分交易ID +/// +public partial record PointsTransactionId : IGuidStronglyTypedId; + +/// +/// 积分交易聚合根 +/// +public class PointsTransaction : Entity, IAggregateRoot +{ + protected PointsTransaction() { } + + /// + /// 创建积分交易记录 + /// + public static PointsTransaction CreateEarnTransaction( + MemberId memberId, + int amount, + string source, + string reason, + Guid relatedId, + DateTime expiryDate) + { + if (amount <= 0) + throw new KnownException("积分数量必须大于0"); + + return new PointsTransaction + { + MemberId = memberId, + Type = PointsTransactionType.Earn, + Amount = amount, + Source = source, + Reason = reason, + RelatedId = relatedId, + ExpiryDate = expiryDate, + CreatedAt = DateTime.UtcNow + }; + } + + /// + /// 创建积分消费记录 + /// + public static PointsTransaction CreateConsumeTransaction( + MemberId memberId, + int amount, + string reason, + Guid orderId) + { + if (amount <= 0) + throw new KnownException("积分数量必须大于0"); + + return new PointsTransaction + { + MemberId = memberId, + Type = PointsTransactionType.Consume, + Amount = amount, + Source = "兑换消费", + Reason = reason, + RelatedId = orderId, + CreatedAt = DateTime.UtcNow + }; + } + + /// + /// 创建积分退还记录 + /// + public static PointsTransaction CreateRefundTransaction( + MemberId memberId, + int amount, + string reason, + Guid orderId) + { + if (amount <= 0) + throw new KnownException("积分数量必须大于0"); + + return new PointsTransaction + { + MemberId = memberId, + Type = PointsTransactionType.Refund, + Amount = amount, + Source = "订单退还", + Reason = reason, + RelatedId = orderId, + CreatedAt = DateTime.UtcNow + }; + } + + /// + /// 创建积分过期记录 + /// + public static PointsTransaction CreateExpireTransaction( + MemberId memberId, + int amount, + Guid batchId) + { + if (amount <= 0) + throw new KnownException("积分数量必须大于0"); + + return new PointsTransaction + { + MemberId = memberId, + Type = PointsTransactionType.Expire, + Amount = amount, + Source = "积分过期", + Reason = "积分已过期自动失效", + RelatedId = batchId, + CreatedAt = DateTime.UtcNow + }; + } + + /// + /// 会员ID + /// + public MemberId MemberId { get; private set; } = default!; + + /// + /// 交易类型 + /// + public PointsTransactionType Type { get; private set; } + + /// + /// 积分数量 + /// + public int Amount { get; private set; } + + /// + /// 来源 + /// + public string Source { get; private set; } = string.Empty; + + /// + /// 原因描述 + /// + public string Reason { get; private set; } = string.Empty; + + /// + /// 关联ID(营销码ID/订单ID等) + /// + public Guid RelatedId { get; private set; } + + /// + /// 过期时间(仅获取类型有效) + /// + public DateTime? ExpiryDate { get; private set; } + + /// + /// 创建时间 + /// + public DateTime CreatedAt { get; private set; } + + public Deleted Deleted { get; private set; } = new(); + public RowVersion RowVersion { get; private set; } = new(0); +} + +/// +/// 积分交易类型 +/// +public enum PointsTransactionType +{ + /// + /// 获得 + /// + Earn = 1, + + /// + /// 消费 + /// + Consume = 2, + + /// + /// 过期 + /// + Expire = 3, + + /// + /// 退还 + /// + Refund = 4 +} diff --git a/Backend/src/Fengling.Backend.Domain/AggregatesModel/RedemptionOrderAggregate/Address.cs b/Backend/src/Fengling.Backend.Domain/AggregatesModel/RedemptionOrderAggregate/Address.cs new file mode 100644 index 0000000..ed13f7f --- /dev/null +++ b/Backend/src/Fengling.Backend.Domain/AggregatesModel/RedemptionOrderAggregate/Address.cs @@ -0,0 +1,60 @@ +namespace Fengling.Backend.Domain.AggregatesModel.RedemptionOrderAggregate; + +/// +/// 收货地址值对象 +/// +public record Address +{ + /// + /// 收货人姓名 + /// + public string ReceiverName { get; init; } = string.Empty; + + /// + /// 联系电话 + /// + public string Phone { get; init; } = string.Empty; + + /// + /// 省 + /// + public string Province { get; init; } = string.Empty; + + /// + /// 市 + /// + public string City { get; init; } = string.Empty; + + /// + /// 区/县 + /// + public string District { get; init; } = string.Empty; + + /// + /// 详细地址 + /// + public string DetailAddress { get; init; } = string.Empty; + + private Address() { } + + public Address( + string receiverName, + string phone, + string province, + string city, + string district, + string detailAddress) + { + ReceiverName = receiverName; + Phone = phone; + Province = province; + City = city; + District = district; + DetailAddress = detailAddress; + } + + /// + /// 获取完整地址 + /// + public string GetFullAddress() => $"{Province}{City}{District}{DetailAddress}"; +} diff --git a/Backend/src/Fengling.Backend.Domain/AggregatesModel/RedemptionOrderAggregate/RedemptionOrder.cs b/Backend/src/Fengling.Backend.Domain/AggregatesModel/RedemptionOrderAggregate/RedemptionOrder.cs new file mode 100644 index 0000000..2505245 --- /dev/null +++ b/Backend/src/Fengling.Backend.Domain/AggregatesModel/RedemptionOrderAggregate/RedemptionOrder.cs @@ -0,0 +1,200 @@ +using Fengling.Backend.Domain.DomainEvents; + +namespace Fengling.Backend.Domain.AggregatesModel.RedemptionOrderAggregate; + +/// +/// 兑换订单ID +/// +public partial record RedemptionOrderId : IGuidStronglyTypedId; + +/// +/// 兑换订单聚合根 +/// +public class RedemptionOrder : Entity, IAggregateRoot +{ + protected RedemptionOrder() { } + + public RedemptionOrder( + string orderNo, + Guid memberId, + Guid giftId, + string giftName, + int giftType, + int quantity, + int consumedPoints, + Address? shippingAddress = null) + { + OrderNo = orderNo; + MemberId = memberId; + GiftId = giftId; + GiftName = giftName; + GiftType = giftType; + Quantity = quantity; + ConsumedPoints = consumedPoints; + ShippingAddress = shippingAddress; + Status = RedemptionOrderStatus.Pending; + CreatedAt = DateTime.UtcNow; + + this.AddDomainEvent(new RedemptionOrderCreatedDomainEvent(this)); + } + + /// + /// 订单号(唯一) + /// + public string OrderNo { get; private set; } = string.Empty; + + /// + /// 会员ID + /// + public Guid MemberId { get; private set; } + + /// + /// 礼品ID + /// + public Guid GiftId { get; private set; } + + /// + /// 礼品名称 + /// + public string GiftName { get; private set; } = string.Empty; + + /// + /// 礼品类型 + /// + public int GiftType { get; private set; } + + /// + /// 数量 + /// + public int Quantity { get; private set; } + + /// + /// 消耗积分 + /// + public int ConsumedPoints { get; private set; } + + /// + /// 收货地址(实物礼品) + /// + public Address? ShippingAddress { get; private set; } + + /// + /// 物流单号 + /// + public string? TrackingNo { get; private set; } + + /// + /// 订单状态 + /// + public RedemptionOrderStatus Status { get; private set; } + + /// + /// 取消原因 + /// + public string? CancelReason { get; private set; } + + /// + /// 创建时间 + /// + public DateTime CreatedAt { get; private set; } + + /// + /// 更新时间 + /// + public DateTime UpdatedAt { get; private set; } + + public Deleted Deleted { get; private set; } = new(); + public RowVersion RowVersion { get; private set; } = new(0); + + /// + /// 标记为已发货 + /// + public void MarkAsDispatched(string? trackingNo = null) + { + if (Status != RedemptionOrderStatus.Pending) + throw new KnownException($"订单状态错误,当前状态:{Status}"); + + Status = RedemptionOrderStatus.Dispatched; + TrackingNo = trackingNo; + UpdatedAt = DateTime.UtcNow; + + this.AddDomainEvent(new RedemptionOrderDispatchedDomainEvent(this)); + } + + /// + /// 标记为已送达 + /// + public void MarkAsDelivered() + { + if (Status != RedemptionOrderStatus.Dispatched) + throw new KnownException($"订单状态错误,当前状态:{Status}"); + + Status = RedemptionOrderStatus.Delivered; + UpdatedAt = DateTime.UtcNow; + + this.AddDomainEvent(new RedemptionOrderDeliveredDomainEvent(this)); + } + + /// + /// 完成订单 + /// + public void Complete() + { + if (Status == RedemptionOrderStatus.Completed || Status == RedemptionOrderStatus.Cancelled) + throw new KnownException($"订单已{Status},无法完成"); + + Status = RedemptionOrderStatus.Completed; + UpdatedAt = DateTime.UtcNow; + + this.AddDomainEvent(new RedemptionOrderCompletedDomainEvent(this)); + } + + /// + /// 取消订单 + /// + public void Cancel(string reason) + { + if (Status == RedemptionOrderStatus.Completed) + throw new KnownException("订单已完成,无法取消"); + + if (Status == RedemptionOrderStatus.Cancelled) + throw new KnownException("订单已取消"); + + Status = RedemptionOrderStatus.Cancelled; + CancelReason = reason; + UpdatedAt = DateTime.UtcNow; + + this.AddDomainEvent(new RedemptionOrderCancelledDomainEvent(this)); + } +} + +/// +/// 订单状态 +/// +public enum RedemptionOrderStatus +{ + /// + /// 待处理 + /// + Pending = 1, + + /// + /// 已发货 + /// + Dispatched = 2, + + /// + /// 已送达 + /// + Delivered = 3, + + /// + /// 已完成 + /// + Completed = 4, + + /// + /// 已取消 + /// + Cancelled = 5 +} diff --git a/Backend/src/Fengling.Backend.Domain/DomainEvents/GiftDomainEvents.cs b/Backend/src/Fengling.Backend.Domain/DomainEvents/GiftDomainEvents.cs new file mode 100644 index 0000000..bfd28e8 --- /dev/null +++ b/Backend/src/Fengling.Backend.Domain/DomainEvents/GiftDomainEvents.cs @@ -0,0 +1,43 @@ +using Fengling.Backend.Domain.AggregatesModel.GiftAggregate; + +namespace Fengling.Backend.Domain.DomainEvents; + +/// +/// 礼品创建领域事件 +/// +public record GiftCreatedDomainEvent(Gift Gift) : IDomainEvent; + +/// +/// 礼品更新领域事件 +/// +public record GiftUpdatedDomainEvent(Gift Gift) : IDomainEvent; + +/// +/// 礼品库存预留领域事件 +/// +public record GiftStockReservedDomainEvent(Gift Gift, int Quantity) : IDomainEvent; + +/// +/// 礼品库存扣减领域事件 +/// +public record GiftStockDeductedDomainEvent(Gift Gift, int Quantity) : IDomainEvent; + +/// +/// 礼品库存释放领域事件 +/// +public record GiftStockReleasedDomainEvent(Gift Gift, int Quantity) : IDomainEvent; + +/// +/// 礼品库存预警领域事件 +/// +public record GiftStockLowDomainEvent(Gift Gift, int CurrentStock) : IDomainEvent; + +/// +/// 礼品上架领域事件 +/// +public record GiftPutOnShelfDomainEvent(Gift Gift) : IDomainEvent; + +/// +/// 礼品下架领域事件 +/// +public record GiftPutOffShelfDomainEvent(Gift Gift) : IDomainEvent; diff --git a/Backend/src/Fengling.Backend.Domain/DomainEvents/MarketingCodeDomainEvents.cs b/Backend/src/Fengling.Backend.Domain/DomainEvents/MarketingCodeDomainEvents.cs new file mode 100644 index 0000000..4593dc5 --- /dev/null +++ b/Backend/src/Fengling.Backend.Domain/DomainEvents/MarketingCodeDomainEvents.cs @@ -0,0 +1,13 @@ +using Fengling.Backend.Domain.AggregatesModel.MarketingCodeAggregate; + +namespace Fengling.Backend.Domain.DomainEvents; + +/// +/// 营销码生成领域事件 +/// +public record MarketingCodeGeneratedDomainEvent(MarketingCode MarketingCode) : IDomainEvent; + +/// +/// 营销码使用领域事件(核心事件:触发积分发放) +/// +public record MarketingCodeUsedDomainEvent(MarketingCode MarketingCode, Guid MemberId) : IDomainEvent; diff --git a/Backend/src/Fengling.Backend.Domain/DomainEvents/MemberDomainEvents.cs b/Backend/src/Fengling.Backend.Domain/DomainEvents/MemberDomainEvents.cs new file mode 100644 index 0000000..f0258ac --- /dev/null +++ b/Backend/src/Fengling.Backend.Domain/DomainEvents/MemberDomainEvents.cs @@ -0,0 +1,68 @@ +using Fengling.Backend.Domain.AggregatesModel.MemberAggregate; + +namespace Fengling.Backend.Domain.DomainEvents; + +/// +/// 会员注册领域事件 +/// +public record MemberRegisteredDomainEvent(Member Member) : IDomainEvent; + +/// +/// 会员密码修改领域事件 +/// +public record MemberPasswordChangedDomainEvent(Member Member) : IDomainEvent; + +/// +/// 会员资料更新领域事件 +/// +public record MemberProfileUpdatedDomainEvent(Member Member) : IDomainEvent; + +/// +/// 会员等级升级领域事件 +/// +public record MemberLevelUpgradedDomainEvent(Member Member, MemberLevel NewLevel) : IDomainEvent; + +/// +/// 会员禁用领域事件 +/// +public record MemberDisabledDomainEvent(Member Member, string Reason) : IDomainEvent; + +/// +/// 会员启用领域事件 +/// +public record MemberEnabledDomainEvent(Member Member) : IDomainEvent; + +/// +/// 积分增加领域事件 +/// +public record PointsAddedDomainEvent( + MemberId MemberId, + int Amount, + string Source, + Guid RelatedId, + DateTime ExpiryDate) : IDomainEvent; + +/// +/// 积分消费领域事件 +/// +public record PointsConsumedDomainEvent( + MemberId MemberId, + int Amount, + string Reason, + Guid OrderId) : IDomainEvent; + +/// +/// 积分退还领域事件 +/// +public record PointsRefundedDomainEvent( + MemberId MemberId, + int Amount, + string Reason, + Guid OrderId) : IDomainEvent; + +/// +/// 积分过期领域事件 +/// +public record PointsExpiredDomainEvent( + MemberId MemberId, + int Amount) : IDomainEvent; diff --git a/Backend/src/Fengling.Backend.Domain/DomainEvents/MemberLevelRuleDomainEvents.cs b/Backend/src/Fengling.Backend.Domain/DomainEvents/MemberLevelRuleDomainEvents.cs new file mode 100644 index 0000000..add5bd5 --- /dev/null +++ b/Backend/src/Fengling.Backend.Domain/DomainEvents/MemberLevelRuleDomainEvents.cs @@ -0,0 +1,13 @@ +using Fengling.Backend.Domain.AggregatesModel.MemberLevelRuleAggregate; + +namespace Fengling.Backend.Domain.DomainEvents; + +/// +/// 会员等级规则创建领域事件 +/// +public record MemberLevelRuleCreatedDomainEvent(MemberLevelRule Rule) : IDomainEvent; + +/// +/// 会员等级规则更新领域事件 +/// +public record MemberLevelRuleUpdatedDomainEvent(MemberLevelRule Rule) : IDomainEvent; diff --git a/Backend/src/Fengling.Backend.Domain/DomainEvents/PointsRuleDomainEvents.cs b/Backend/src/Fengling.Backend.Domain/DomainEvents/PointsRuleDomainEvents.cs new file mode 100644 index 0000000..63bbae1 --- /dev/null +++ b/Backend/src/Fengling.Backend.Domain/DomainEvents/PointsRuleDomainEvents.cs @@ -0,0 +1,23 @@ +using Fengling.Backend.Domain.AggregatesModel.PointsRuleAggregate; + +namespace Fengling.Backend.Domain.DomainEvents; + +/// +/// 积分规则创建领域事件 +/// +public record PointsRuleCreatedDomainEvent(PointsRule PointsRule) : IDomainEvent; + +/// +/// 积分规则更新领域事件 +/// +public record PointsRuleUpdatedDomainEvent(PointsRule PointsRule) : IDomainEvent; + +/// +/// 积分规则激活领域事件 +/// +public record PointsRuleActivatedDomainEvent(PointsRule PointsRule) : IDomainEvent; + +/// +/// 积分规则停用领域事件 +/// +public record PointsRuleDeactivatedDomainEvent(PointsRule PointsRule) : IDomainEvent; diff --git a/Backend/src/Fengling.Backend.Domain/DomainEvents/RedemptionOrderDomainEvents.cs b/Backend/src/Fengling.Backend.Domain/DomainEvents/RedemptionOrderDomainEvents.cs new file mode 100644 index 0000000..392dbf7 --- /dev/null +++ b/Backend/src/Fengling.Backend.Domain/DomainEvents/RedemptionOrderDomainEvents.cs @@ -0,0 +1,28 @@ +using Fengling.Backend.Domain.AggregatesModel.RedemptionOrderAggregate; + +namespace Fengling.Backend.Domain.DomainEvents; + +/// +/// 兑换订单创建领域事件(核心事件:触发库存预留和积分扣减) +/// +public record RedemptionOrderCreatedDomainEvent(RedemptionOrder Order) : IDomainEvent; + +/// +/// 兑换订单发货领域事件 +/// +public record RedemptionOrderDispatchedDomainEvent(RedemptionOrder Order) : IDomainEvent; + +/// +/// 兑换订单送达领域事件 +/// +public record RedemptionOrderDeliveredDomainEvent(RedemptionOrder Order) : IDomainEvent; + +/// +/// 兑换订单完成领域事件 +/// +public record RedemptionOrderCompletedDomainEvent(RedemptionOrder Order) : IDomainEvent; + +/// +/// 兑换订单取消领域事件(触发库存释放和积分退还) +/// +public record RedemptionOrderCancelledDomainEvent(RedemptionOrder Order) : IDomainEvent; diff --git a/Backend/src/Fengling.Backend.Domain/Fengling.Backend.Domain.csproj b/Backend/src/Fengling.Backend.Domain/Fengling.Backend.Domain.csproj new file mode 100644 index 0000000..1ab15ae --- /dev/null +++ b/Backend/src/Fengling.Backend.Domain/Fengling.Backend.Domain.csproj @@ -0,0 +1,21 @@ + + + + net10.0 + enable + enable + true + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/Backend/src/Fengling.Backend.Domain/GlobalUsings.cs b/Backend/src/Fengling.Backend.Domain/GlobalUsings.cs new file mode 100644 index 0000000..eb48228 --- /dev/null +++ b/Backend/src/Fengling.Backend.Domain/GlobalUsings.cs @@ -0,0 +1,2 @@ +global using NetCorePal.Extensions.Domain; +global using NetCorePal.Extensions.Primitives; \ No newline at end of file diff --git a/Backend/src/Fengling.Backend.Domain/IntegrationEvents/PointsIntegrationEvents.cs b/Backend/src/Fengling.Backend.Domain/IntegrationEvents/PointsIntegrationEvents.cs new file mode 100644 index 0000000..3b99913 --- /dev/null +++ b/Backend/src/Fengling.Backend.Domain/IntegrationEvents/PointsIntegrationEvents.cs @@ -0,0 +1,38 @@ +namespace Fengling.Backend.Domain.IntegrationEvents; + +/// +/// 积分获得集成事件 +/// +public record PointsEarnedIntegrationEvent( + Guid MemberId, + int Amount, + string Source, + string Reason, + Guid RelatedId, + DateTime ExpiryDate) : IIntegrationEvent; + +/// +/// 积分消费集成事件 +/// +public record PointsConsumedIntegrationEvent( + Guid MemberId, + int Amount, + string Reason, + Guid OrderId) : IIntegrationEvent; + +/// +/// 积分退还集成事件 +/// +public record PointsRefundedIntegrationEvent( + Guid MemberId, + int Amount, + string Reason, + Guid OrderId) : IIntegrationEvent; + +/// +/// 积分过期集成事件 +/// +public record PointsExpiredIntegrationEvent( + Guid MemberId, + int Amount, + Guid BatchId) : IIntegrationEvent; diff --git a/Backend/src/Fengling.Backend.Infrastructure/ApplicationDbContext.cs b/Backend/src/Fengling.Backend.Infrastructure/ApplicationDbContext.cs new file mode 100644 index 0000000..ebc34a4 --- /dev/null +++ b/Backend/src/Fengling.Backend.Infrastructure/ApplicationDbContext.cs @@ -0,0 +1,52 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; +using NetCorePal.Extensions.DistributedTransactions.CAP.Persistence; +using Fengling.Backend.Domain.AggregatesModel.MemberAggregate; +using Fengling.Backend.Domain.AggregatesModel.MarketingCodeAggregate; +using Fengling.Backend.Domain.AggregatesModel.PointsRuleAggregate; +using Fengling.Backend.Domain.AggregatesModel.PointsTransactionAggregate; +using Fengling.Backend.Domain.AggregatesModel.GiftAggregate; +using Fengling.Backend.Domain.AggregatesModel.RedemptionOrderAggregate; + +namespace Fengling.Backend.Infrastructure; + +public partial class ApplicationDbContext(DbContextOptions options, IMediator mediator) + : AppDbContextBase(options, mediator) + , ISqliteCapDataStorage +{ + // 会员聚合 + public DbSet Members => Set(); + public DbSet PointsTransactions => Set(); + + // 营销码聚合 + public DbSet MarketingCodes => Set(); + + // 积分规则聚合 + public DbSet PointsRules => Set(); + + // 礼品聚合 + public DbSet Gifts => Set(); + + // 兑换订单聚合 + public DbSet RedemptionOrders => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + if (modelBuilder is null) + { + throw new ArgumentNullException(nameof(modelBuilder)); + } + + modelBuilder.ApplyConfigurationsFromAssembly(typeof(ApplicationDbContext).Assembly); + base.OnModelCreating(modelBuilder); + } + + + + protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) + { + ConfigureStronglyTypedIdValueConverter(configurationBuilder); + base.ConfigureConventions(configurationBuilder); + } + +} diff --git a/Backend/src/Fengling.Backend.Infrastructure/DesignTimeApplicationDbContextFactory.cs b/Backend/src/Fengling.Backend.Infrastructure/DesignTimeApplicationDbContextFactory.cs new file mode 100644 index 0000000..f3e8188 --- /dev/null +++ b/Backend/src/Fengling.Backend.Infrastructure/DesignTimeApplicationDbContextFactory.cs @@ -0,0 +1,27 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.Extensions.DependencyInjection; + +namespace Fengling.Backend.Infrastructure; + +public class DesignTimeApplicationDbContextFactory: IDesignTimeDbContextFactory +{ + public ApplicationDbContext CreateDbContext(string[] args) + { + IServiceCollection services = new ServiceCollection(); + services.AddMediatR(c => + c.RegisterServicesFromAssemblies(typeof(DesignTimeApplicationDbContextFactory).Assembly)); + services.AddDbContext(options => + { + // change connectionstring if you want to run command "dotnet ef database update" + options.UseSqlite("Data Source=fengling.db", + b => + { + b.MigrationsAssembly(typeof(DesignTimeApplicationDbContextFactory).Assembly.FullName); + }); + }); + var provider = services.BuildServiceProvider(); + var dbContext = provider.CreateScope().ServiceProvider.GetRequiredService(); + return dbContext; + } +} \ No newline at end of file diff --git a/Backend/src/Fengling.Backend.Infrastructure/EntityConfigurations/GiftEntityTypeConfiguration.cs b/Backend/src/Fengling.Backend.Infrastructure/EntityConfigurations/GiftEntityTypeConfiguration.cs new file mode 100644 index 0000000..8b5ab73 --- /dev/null +++ b/Backend/src/Fengling.Backend.Infrastructure/EntityConfigurations/GiftEntityTypeConfiguration.cs @@ -0,0 +1,71 @@ +using Fengling.Backend.Domain.AggregatesModel.GiftAggregate; + +namespace Fengling.Backend.Infrastructure.EntityConfigurations; + +public class GiftEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("Gifts"); + builder.HasKey(x => x.Id); + + builder.Property(x => x.Id) + .UseGuidVersion7ValueGenerator() + .HasComment("礼品ID"); + + builder.Property(x => x.Name) + .IsRequired() + .HasMaxLength(100) + .HasComment("礼品名称"); + + builder.Property(x => x.Type) + .IsRequired() + .HasComment("礼品类型(1:实物,2:虚拟,3:自有产品)"); + + builder.Property(x => x.Description) + .IsRequired() + .HasMaxLength(500) + .HasComment("描述"); + + builder.Property(x => x.ImageUrl) + .IsRequired() + .HasMaxLength(500) + .HasComment("图片URL"); + + builder.Property(x => x.RequiredPoints) + .IsRequired() + .HasComment("所需积分"); + + builder.Property(x => x.TotalStock) + .IsRequired() + .HasComment("总库存"); + + builder.Property(x => x.AvailableStock) + .IsRequired() + .HasComment("可用库存"); + + builder.Property(x => x.LimitPerMember) + .HasComment("每人限兑数量"); + + builder.Property(x => x.IsOnShelf) + .IsRequired() + .HasComment("是否上架"); + + builder.Property(x => x.SortOrder) + .IsRequired() + .HasComment("排序"); + + builder.Property(x => x.CreatedAt) + .IsRequired() + .HasComment("创建时间"); + + builder.Property(x => x.UpdatedAt) + .IsRequired() + .HasComment("更新时间"); + + // 索引 + builder.HasIndex(x => x.IsOnShelf).HasDatabaseName("IX_Gifts_IsOnShelf"); + builder.HasIndex(x => x.Type).HasDatabaseName("IX_Gifts_Type"); + builder.HasIndex(x => x.SortOrder).HasDatabaseName("IX_Gifts_SortOrder"); + } +} diff --git a/Backend/src/Fengling.Backend.Infrastructure/EntityConfigurations/MarketingCodeEntityTypeConfiguration.cs b/Backend/src/Fengling.Backend.Infrastructure/EntityConfigurations/MarketingCodeEntityTypeConfiguration.cs new file mode 100644 index 0000000..858db0e --- /dev/null +++ b/Backend/src/Fengling.Backend.Infrastructure/EntityConfigurations/MarketingCodeEntityTypeConfiguration.cs @@ -0,0 +1,72 @@ +using Fengling.Backend.Domain.AggregatesModel.MarketingCodeAggregate; + +namespace Fengling.Backend.Infrastructure.EntityConfigurations; + +public class MarketingCodeEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("MarketingCodes"); + builder.HasKey(x => x.Id); + + builder.Property(x => x.Id) + .UseGuidVersion7ValueGenerator() + .HasComment("营销码ID"); + + builder.Property(x => x.Code) + .IsRequired() + .HasMaxLength(50) + .HasComment("营销码"); + + builder.Property(x => x.BatchNo) + .IsRequired() + .HasMaxLength(50) + .HasComment("批次号"); + + builder.Property(x => x.IsUsed) + .IsRequired() + .HasComment("是否已使用"); + + builder.Property(x => x.UsedByMemberId) + .HasComment("使用者会员ID"); + + builder.Property(x => x.UsedAt) + .HasComment("使用时间"); + + builder.Property(x => x.ExpiryDate) + .HasComment("过期时间"); + + builder.Property(x => x.CreatedAt) + .IsRequired() + .HasComment("创建时间"); + + // 产品信息值对象 + builder.OwnsOne(x => x.ProductInfo, product => + { + product.Property(p => p.ProductId) + .IsRequired() + .HasColumnName("ProductId") + .HasComment("产品ID"); + + product.Property(p => p.ProductName) + .IsRequired() + .HasMaxLength(100) + .HasColumnName("ProductName") + .HasComment("产品名称"); + + product.Property(p => p.CategoryId) + .HasColumnName("CategoryId") + .HasComment("品类ID"); + + product.Property(p => p.CategoryName) + .HasMaxLength(100) + .HasColumnName("CategoryName") + .HasComment("品类名称"); + }); + + // 索引 + builder.HasIndex(x => x.Code).IsUnique().HasDatabaseName("IX_MarketingCodes_Code"); + builder.HasIndex(x => x.BatchNo).HasDatabaseName("IX_MarketingCodes_BatchNo"); + builder.HasIndex(x => x.IsUsed).HasDatabaseName("IX_MarketingCodes_IsUsed"); + } +} diff --git a/Backend/src/Fengling.Backend.Infrastructure/EntityConfigurations/MemberEntityTypeConfiguration.cs b/Backend/src/Fengling.Backend.Infrastructure/EntityConfigurations/MemberEntityTypeConfiguration.cs new file mode 100644 index 0000000..81be682 --- /dev/null +++ b/Backend/src/Fengling.Backend.Infrastructure/EntityConfigurations/MemberEntityTypeConfiguration.cs @@ -0,0 +1,77 @@ +using Fengling.Backend.Domain.AggregatesModel.MemberAggregate; + +namespace Fengling.Backend.Infrastructure.EntityConfigurations; + +public class MemberEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("Members"); + builder.HasKey(x => x.Id); + + builder.Property(x => x.Id) + .UseGuidVersion7ValueGenerator() + .HasComment("会员ID"); + + builder.Property(x => x.Phone) + .IsRequired() + .HasMaxLength(20) + .HasComment("手机号"); + + builder.Property(x => x.Password) + .IsRequired() + .HasMaxLength(100) + .HasComment("密码(已加密)"); + + builder.Property(x => x.Nickname) + .IsRequired() + .HasMaxLength(50) + .HasComment("昵称"); + + builder.Property(x => x.TotalPoints) + .IsRequired() + .HasComment("累计总积分"); + + builder.Property(x => x.AvailablePoints) + .IsRequired() + .HasComment("可用积分"); + + builder.Property(x => x.Status) + .IsRequired() + .HasComment("状态(1:正常,2:禁用)"); + + builder.Property(x => x.RegisteredAt) + .IsRequired() + .HasComment("注册时间"); + + // 会员等级值对象 + builder.OwnsOne(x => x.Level, level => + { + level.Property(l => l.LevelCode) + .IsRequired() + .HasMaxLength(20) + .HasColumnName("LevelCode") + .HasComment("等级编码"); + + level.Property(l => l.LevelName) + .IsRequired() + .HasMaxLength(50) + .HasColumnName("LevelName") + .HasComment("等级名称"); + + level.Property(l => l.RequiredPoints) + .IsRequired() + .HasColumnName("RequiredPoints") + .HasComment("所需积分"); + + level.Property(l => l.BonusRate) + .IsRequired() + .HasColumnName("BonusRate") + .HasComment("积分奖励倍率"); + }); + + // 索引 + builder.HasIndex(x => x.Phone).IsUnique().HasDatabaseName("IX_Members_Phone"); + builder.HasIndex(x => x.Status).HasDatabaseName("IX_Members_Status"); + } +} diff --git a/Backend/src/Fengling.Backend.Infrastructure/EntityConfigurations/PointsRuleEntityTypeConfiguration.cs b/Backend/src/Fengling.Backend.Infrastructure/EntityConfigurations/PointsRuleEntityTypeConfiguration.cs new file mode 100644 index 0000000..ed572fb --- /dev/null +++ b/Backend/src/Fengling.Backend.Infrastructure/EntityConfigurations/PointsRuleEntityTypeConfiguration.cs @@ -0,0 +1,64 @@ +using Fengling.Backend.Domain.AggregatesModel.PointsRuleAggregate; + +namespace Fengling.Backend.Infrastructure.EntityConfigurations; + +public class PointsRuleEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("PointsRules"); + builder.HasKey(x => x.Id); + + builder.Property(x => x.Id) + .UseGuidVersion7ValueGenerator() + .HasComment("积分规则ID"); + + builder.Property(x => x.RuleName) + .IsRequired() + .HasMaxLength(100) + .HasComment("规则名称"); + + builder.Property(x => x.RuleType) + .IsRequired() + .HasComment("规则类型(1:产品,2:时间,3:会员等级)"); + + builder.Property(x => x.PointsValue) + .IsRequired() + .HasComment("积分值"); + + builder.Property(x => x.BonusMultiplier) + .IsRequired() + .HasComment("奖励倍数"); + + builder.Property(x => x.StartDate) + .IsRequired() + .HasComment("生效开始时间"); + + builder.Property(x => x.EndDate) + .HasComment("生效结束时间"); + + builder.Property(x => x.ProductId) + .HasComment("产品ID"); + + builder.Property(x => x.CategoryId) + .HasComment("品类ID"); + + builder.Property(x => x.MemberLevelCode) + .HasMaxLength(20) + .HasComment("会员等级编码"); + + builder.Property(x => x.IsActive) + .IsRequired() + .HasComment("是否激活"); + + builder.Property(x => x.CreatedAt) + .IsRequired() + .HasComment("创建时间"); + + // 索引 + builder.HasIndex(x => x.IsActive).HasDatabaseName("IX_PointsRules_IsActive"); + builder.HasIndex(x => x.ProductId).HasDatabaseName("IX_PointsRules_ProductId"); + builder.HasIndex(x => x.MemberLevelCode).HasDatabaseName("IX_PointsRules_MemberLevelCode"); + builder.HasIndex(x => new { x.StartDate, x.EndDate }).HasDatabaseName("IX_PointsRules_DateRange"); + } +} diff --git a/Backend/src/Fengling.Backend.Infrastructure/EntityConfigurations/PointsTransactionEntityTypeConfiguration.cs b/Backend/src/Fengling.Backend.Infrastructure/EntityConfigurations/PointsTransactionEntityTypeConfiguration.cs new file mode 100644 index 0000000..2a94a8b --- /dev/null +++ b/Backend/src/Fengling.Backend.Infrastructure/EntityConfigurations/PointsTransactionEntityTypeConfiguration.cs @@ -0,0 +1,55 @@ +using Fengling.Backend.Domain.AggregatesModel.PointsTransactionAggregate; + +namespace Fengling.Backend.Infrastructure.EntityConfigurations; + +public class PointsTransactionEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("PointsTransactions"); + builder.HasKey(x => x.Id); + + builder.Property(x => x.Id) + .UseGuidVersion7ValueGenerator() + .HasComment("积分流水ID"); + + builder.Property(x => x.MemberId) + .IsRequired() + .HasComment("会员ID"); + + builder.Property(x => x.Type) + .IsRequired() + .HasComment("交易类型(1:获得,2:消费,3:过期,4:退还)"); + + builder.Property(x => x.Amount) + .IsRequired() + .HasComment("积分数量"); + + builder.Property(x => x.Source) + .IsRequired() + .HasMaxLength(100) + .HasComment("来源"); + + builder.Property(x => x.Reason) + .IsRequired() + .HasMaxLength(200) + .HasComment("原因描述"); + + builder.Property(x => x.RelatedId) + .IsRequired() + .HasComment("关联ID"); + + builder.Property(x => x.ExpiryDate) + .HasComment("过期时间"); + + builder.Property(x => x.CreatedAt) + .IsRequired() + .HasComment("创建时间"); + + // 索引 + builder.HasIndex(x => x.MemberId).HasDatabaseName("IX_PointsTransactions_MemberId"); + builder.HasIndex(x => x.RelatedId).IsUnique().HasDatabaseName("IX_PointsTransactions_RelatedId"); + builder.HasIndex(x => x.Type).HasDatabaseName("IX_PointsTransactions_Type"); + builder.HasIndex(x => x.CreatedAt).HasDatabaseName("IX_PointsTransactions_CreatedAt"); + } +} diff --git a/Backend/src/Fengling.Backend.Infrastructure/EntityConfigurations/RedemptionOrderEntityTypeConfiguration.cs b/Backend/src/Fengling.Backend.Infrastructure/EntityConfigurations/RedemptionOrderEntityTypeConfiguration.cs new file mode 100644 index 0000000..673b38d --- /dev/null +++ b/Backend/src/Fengling.Backend.Infrastructure/EntityConfigurations/RedemptionOrderEntityTypeConfiguration.cs @@ -0,0 +1,106 @@ +using Fengling.Backend.Domain.AggregatesModel.RedemptionOrderAggregate; + +namespace Fengling.Backend.Infrastructure.EntityConfigurations; + +public class RedemptionOrderEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("RedemptionOrders"); + builder.HasKey(x => x.Id); + + builder.Property(x => x.Id) + .UseGuidVersion7ValueGenerator() + .HasComment("兑换订单ID"); + + builder.Property(x => x.OrderNo) + .IsRequired() + .HasMaxLength(50) + .HasComment("订单号"); + + builder.Property(x => x.MemberId) + .IsRequired() + .HasComment("会员ID"); + + builder.Property(x => x.GiftId) + .IsRequired() + .HasComment("礼品ID"); + + builder.Property(x => x.GiftName) + .IsRequired() + .HasMaxLength(100) + .HasComment("礼品名称"); + + builder.Property(x => x.GiftType) + .IsRequired() + .HasComment("礼品类型"); + + builder.Property(x => x.Quantity) + .IsRequired() + .HasComment("数量"); + + builder.Property(x => x.ConsumedPoints) + .IsRequired() + .HasComment("消耗积分"); + + builder.Property(x => x.TrackingNo) + .HasMaxLength(100) + .HasComment("物流单号"); + + builder.Property(x => x.Status) + .IsRequired() + .HasComment("订单状态(1:待处理,2:已发货,3:已送达,4:已完成,5:已取消)"); + + builder.Property(x => x.CancelReason) + .HasMaxLength(500) + .HasComment("取消原因"); + + builder.Property(x => x.CreatedAt) + .IsRequired() + .HasComment("创建时间"); + + builder.Property(x => x.UpdatedAt) + .IsRequired() + .HasComment("更新时间"); + + // 收货地址值对象 + builder.OwnsOne(x => x.ShippingAddress, address => + { + address.Property(a => a.ReceiverName) + .HasMaxLength(50) + .HasColumnName("ReceiverName") + .HasComment("收货人姓名"); + + address.Property(a => a.Phone) + .HasMaxLength(20) + .HasColumnName("ReceiverPhone") + .HasComment("联系电话"); + + address.Property(a => a.Province) + .HasMaxLength(50) + .HasColumnName("Province") + .HasComment("省"); + + address.Property(a => a.City) + .HasMaxLength(50) + .HasColumnName("City") + .HasComment("市"); + + address.Property(a => a.District) + .HasMaxLength(50) + .HasColumnName("District") + .HasComment("区/县"); + + address.Property(a => a.DetailAddress) + .HasMaxLength(200) + .HasColumnName("DetailAddress") + .HasComment("详细地址"); + }); + + // 索引 + builder.HasIndex(x => x.OrderNo).IsUnique().HasDatabaseName("IX_RedemptionOrders_OrderNo"); + builder.HasIndex(x => x.MemberId).HasDatabaseName("IX_RedemptionOrders_MemberId"); + builder.HasIndex(x => x.Status).HasDatabaseName("IX_RedemptionOrders_Status"); + builder.HasIndex(x => x.CreatedAt).HasDatabaseName("IX_RedemptionOrders_CreatedAt"); + } +} diff --git a/Backend/src/Fengling.Backend.Infrastructure/Fengling.Backend.Infrastructure.csproj b/Backend/src/Fengling.Backend.Infrastructure/Fengling.Backend.Infrastructure.csproj new file mode 100644 index 0000000..b469823 --- /dev/null +++ b/Backend/src/Fengling.Backend.Infrastructure/Fengling.Backend.Infrastructure.csproj @@ -0,0 +1,31 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + diff --git a/Backend/src/Fengling.Backend.Infrastructure/GlobalUsings.cs b/Backend/src/Fengling.Backend.Infrastructure/GlobalUsings.cs new file mode 100644 index 0000000..9f026ce --- /dev/null +++ b/Backend/src/Fengling.Backend.Infrastructure/GlobalUsings.cs @@ -0,0 +1,6 @@ +global using NetCorePal.Extensions.Domain; +global using NetCorePal.Extensions.Primitives; +global using NetCorePal.Extensions.Repository; +global using NetCorePal.Extensions.Repository.EntityFrameworkCore; +global using Microsoft.EntityFrameworkCore; +global using Microsoft.EntityFrameworkCore.Metadata.Builders; diff --git a/Backend/src/Fengling.Backend.Infrastructure/Migrations/20260211044819_Init.Designer.cs b/Backend/src/Fengling.Backend.Infrastructure/Migrations/20260211044819_Init.Designer.cs new file mode 100644 index 0000000..3754614 --- /dev/null +++ b/Backend/src/Fengling.Backend.Infrastructure/Migrations/20260211044819_Init.Designer.cs @@ -0,0 +1,599 @@ +// +using System; +using Fengling.Backend.Infrastructure; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Fengling.Backend.Infrastructure.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260211044819_Init")] + partial class Init + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.0"); + + modelBuilder.Entity("Fengling.Backend.Domain.AggregatesModel.GiftAggregate.Gift", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasComment("礼品ID"); + + b.Property("AvailableStock") + .HasColumnType("INTEGER") + .HasComment("可用库存"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasComment("创建时间"); + + b.Property("Deleted") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasComment("描述"); + + b.Property("ImageUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasComment("图片URL"); + + b.Property("IsOnShelf") + .HasColumnType("INTEGER") + .HasComment("是否上架"); + + b.Property("LimitPerMember") + .HasColumnType("INTEGER") + .HasComment("每人限兑数量"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasComment("礼品名称"); + + b.Property("RequiredPoints") + .HasColumnType("INTEGER") + .HasComment("所需积分"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SortOrder") + .HasColumnType("INTEGER") + .HasComment("排序"); + + b.Property("TotalStock") + .HasColumnType("INTEGER") + .HasComment("总库存"); + + b.Property("Type") + .HasColumnType("INTEGER") + .HasComment("礼品类型(1:实物,2:虚拟,3:自有产品)"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT") + .HasComment("更新时间"); + + b.HasKey("Id"); + + b.HasIndex("IsOnShelf") + .HasDatabaseName("IX_Gifts_IsOnShelf"); + + b.HasIndex("SortOrder") + .HasDatabaseName("IX_Gifts_SortOrder"); + + b.HasIndex("Type") + .HasDatabaseName("IX_Gifts_Type"); + + b.ToTable("Gifts", (string)null); + }); + + modelBuilder.Entity("Fengling.Backend.Domain.AggregatesModel.MarketingCodeAggregate.MarketingCode", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasComment("营销码ID"); + + b.Property("BatchNo") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasComment("批次号"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasComment("营销码"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasComment("创建时间"); + + b.Property("Deleted") + .HasColumnType("INTEGER"); + + b.Property("ExpiryDate") + .HasColumnType("TEXT") + .HasComment("过期时间"); + + b.Property("IsUsed") + .HasColumnType("INTEGER") + .HasComment("是否已使用"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("UsedAt") + .HasColumnType("TEXT") + .HasComment("使用时间"); + + b.Property("UsedByMemberId") + .HasColumnType("TEXT") + .HasComment("使用者会员ID"); + + b.HasKey("Id"); + + b.HasIndex("BatchNo") + .HasDatabaseName("IX_MarketingCodes_BatchNo"); + + b.HasIndex("Code") + .IsUnique() + .HasDatabaseName("IX_MarketingCodes_Code"); + + b.HasIndex("IsUsed") + .HasDatabaseName("IX_MarketingCodes_IsUsed"); + + b.ToTable("MarketingCodes", (string)null); + }); + + modelBuilder.Entity("Fengling.Backend.Domain.AggregatesModel.MemberAggregate.Member", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasComment("会员ID"); + + b.Property("AvailablePoints") + .HasColumnType("INTEGER") + .HasComment("可用积分"); + + b.Property("Deleted") + .HasColumnType("INTEGER"); + + b.Property("Nickname") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasComment("昵称"); + + b.Property("Password") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasComment("密码(已加密)"); + + b.Property("Phone") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT") + .HasComment("手机号"); + + b.Property("RegisteredAt") + .HasColumnType("TEXT") + .HasComment("注册时间"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER") + .HasComment("状态(1:正常,2:禁用)"); + + b.Property("TotalPoints") + .HasColumnType("INTEGER") + .HasComment("累计总积分"); + + b.HasKey("Id"); + + b.HasIndex("Phone") + .IsUnique() + .HasDatabaseName("IX_Members_Phone"); + + b.HasIndex("Status") + .HasDatabaseName("IX_Members_Status"); + + b.ToTable("Members", (string)null); + }); + + modelBuilder.Entity("Fengling.Backend.Domain.AggregatesModel.PointsRuleAggregate.PointsRule", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasComment("积分规则ID"); + + b.Property("BonusMultiplier") + .HasColumnType("TEXT") + .HasComment("奖励倍数"); + + b.Property("CategoryId") + .HasColumnType("TEXT") + .HasComment("品类ID"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasComment("创建时间"); + + b.Property("Deleted") + .HasColumnType("INTEGER"); + + b.Property("EndDate") + .HasColumnType("TEXT") + .HasComment("生效结束时间"); + + b.Property("IsActive") + .HasColumnType("INTEGER") + .HasComment("是否激活"); + + b.Property("MemberLevelCode") + .HasMaxLength(20) + .HasColumnType("TEXT") + .HasComment("会员等级编码"); + + b.Property("PointsValue") + .HasColumnType("INTEGER") + .HasComment("积分值"); + + b.Property("ProductId") + .HasColumnType("TEXT") + .HasComment("产品ID"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("RuleName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasComment("规则名称"); + + b.Property("RuleType") + .HasColumnType("INTEGER") + .HasComment("规则类型(1:产品,2:时间,3:会员等级)"); + + b.Property("StartDate") + .HasColumnType("TEXT") + .HasComment("生效开始时间"); + + b.HasKey("Id"); + + b.HasIndex("IsActive") + .HasDatabaseName("IX_PointsRules_IsActive"); + + b.HasIndex("MemberLevelCode") + .HasDatabaseName("IX_PointsRules_MemberLevelCode"); + + b.HasIndex("ProductId") + .HasDatabaseName("IX_PointsRules_ProductId"); + + b.HasIndex("StartDate", "EndDate") + .HasDatabaseName("IX_PointsRules_DateRange"); + + b.ToTable("PointsRules", (string)null); + }); + + modelBuilder.Entity("Fengling.Backend.Domain.AggregatesModel.PointsTransactionAggregate.PointsTransaction", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasComment("积分流水ID"); + + b.Property("Amount") + .HasColumnType("INTEGER") + .HasComment("积分数量"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasComment("创建时间"); + + b.Property("Deleted") + .HasColumnType("INTEGER"); + + b.Property("ExpiryDate") + .HasColumnType("TEXT") + .HasComment("过期时间"); + + b.Property("MemberId") + .HasColumnType("TEXT") + .HasComment("会员ID"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT") + .HasComment("原因描述"); + + b.Property("RelatedId") + .HasColumnType("TEXT") + .HasComment("关联ID"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasComment("来源"); + + b.Property("Type") + .HasColumnType("INTEGER") + .HasComment("交易类型(1:获得,2:消费,3:过期,4:退还)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt") + .HasDatabaseName("IX_PointsTransactions_CreatedAt"); + + b.HasIndex("MemberId") + .HasDatabaseName("IX_PointsTransactions_MemberId"); + + b.HasIndex("RelatedId") + .IsUnique() + .HasDatabaseName("IX_PointsTransactions_RelatedId"); + + b.HasIndex("Type") + .HasDatabaseName("IX_PointsTransactions_Type"); + + b.ToTable("PointsTransactions", (string)null); + }); + + modelBuilder.Entity("Fengling.Backend.Domain.AggregatesModel.RedemptionOrderAggregate.RedemptionOrder", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasComment("兑换订单ID"); + + b.Property("CancelReason") + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasComment("取消原因"); + + b.Property("ConsumedPoints") + .HasColumnType("INTEGER") + .HasComment("消耗积分"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasComment("创建时间"); + + b.Property("Deleted") + .HasColumnType("INTEGER"); + + b.Property("GiftId") + .HasColumnType("TEXT") + .HasComment("礼品ID"); + + b.Property("GiftName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasComment("礼品名称"); + + b.Property("GiftType") + .HasColumnType("INTEGER") + .HasComment("礼品类型"); + + b.Property("MemberId") + .HasColumnType("TEXT") + .HasComment("会员ID"); + + b.Property("OrderNo") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasComment("订单号"); + + b.Property("Quantity") + .HasColumnType("INTEGER") + .HasComment("数量"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER") + .HasComment("订单状态(1:待处理,2:已发货,3:已送达,4:已完成,5:已取消)"); + + b.Property("TrackingNo") + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasComment("物流单号"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT") + .HasComment("更新时间"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt") + .HasDatabaseName("IX_RedemptionOrders_CreatedAt"); + + b.HasIndex("MemberId") + .HasDatabaseName("IX_RedemptionOrders_MemberId"); + + b.HasIndex("OrderNo") + .IsUnique() + .HasDatabaseName("IX_RedemptionOrders_OrderNo"); + + b.HasIndex("Status") + .HasDatabaseName("IX_RedemptionOrders_Status"); + + b.ToTable("RedemptionOrders", (string)null); + }); + + modelBuilder.Entity("Fengling.Backend.Domain.AggregatesModel.MarketingCodeAggregate.MarketingCode", b => + { + b.OwnsOne("Fengling.Backend.Domain.AggregatesModel.MarketingCodeAggregate.ProductInfo", "ProductInfo", b1 => + { + b1.Property("MarketingCodeId") + .HasColumnType("TEXT"); + + b1.Property("CategoryId") + .HasColumnType("TEXT") + .HasColumnName("CategoryId") + .HasComment("品类ID"); + + b1.Property("CategoryName") + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasColumnName("CategoryName") + .HasComment("品类名称"); + + b1.Property("ProductId") + .HasColumnType("TEXT") + .HasColumnName("ProductId") + .HasComment("产品ID"); + + b1.Property("ProductName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasColumnName("ProductName") + .HasComment("产品名称"); + + b1.HasKey("MarketingCodeId"); + + b1.ToTable("MarketingCodes"); + + b1.WithOwner() + .HasForeignKey("MarketingCodeId"); + }); + + b.Navigation("ProductInfo") + .IsRequired(); + }); + + modelBuilder.Entity("Fengling.Backend.Domain.AggregatesModel.MemberAggregate.Member", b => + { + b.OwnsOne("Fengling.Backend.Domain.AggregatesModel.MemberAggregate.MemberLevel", "Level", b1 => + { + b1.Property("MemberId") + .HasColumnType("TEXT"); + + b1.Property("BonusRate") + .HasColumnType("TEXT") + .HasColumnName("BonusRate") + .HasComment("积分奖励倍率"); + + b1.Property("LevelCode") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT") + .HasColumnName("LevelCode") + .HasComment("等级编码"); + + b1.Property("LevelName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("LevelName") + .HasComment("等级名称"); + + b1.Property("RequiredPoints") + .HasColumnType("INTEGER") + .HasColumnName("RequiredPoints") + .HasComment("所需积分"); + + b1.HasKey("MemberId"); + + b1.ToTable("Members"); + + b1.WithOwner() + .HasForeignKey("MemberId"); + }); + + b.Navigation("Level") + .IsRequired(); + }); + + modelBuilder.Entity("Fengling.Backend.Domain.AggregatesModel.RedemptionOrderAggregate.RedemptionOrder", b => + { + b.OwnsOne("Fengling.Backend.Domain.AggregatesModel.RedemptionOrderAggregate.Address", "ShippingAddress", b1 => + { + b1.Property("RedemptionOrderId") + .HasColumnType("TEXT"); + + b1.Property("City") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("City") + .HasComment("市"); + + b1.Property("DetailAddress") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT") + .HasColumnName("DetailAddress") + .HasComment("详细地址"); + + b1.Property("District") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("District") + .HasComment("区/县"); + + b1.Property("Phone") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT") + .HasColumnName("ReceiverPhone") + .HasComment("联系电话"); + + b1.Property("Province") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("Province") + .HasComment("省"); + + b1.Property("ReceiverName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("ReceiverName") + .HasComment("收货人姓名"); + + b1.HasKey("RedemptionOrderId"); + + b1.ToTable("RedemptionOrders"); + + b1.WithOwner() + .HasForeignKey("RedemptionOrderId"); + }); + + b.Navigation("ShippingAddress"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Backend/src/Fengling.Backend.Infrastructure/Migrations/20260211044819_Init.cs b/Backend/src/Fengling.Backend.Infrastructure/Migrations/20260211044819_Init.cs new file mode 100644 index 0000000..f2ddc9d --- /dev/null +++ b/Backend/src/Fengling.Backend.Infrastructure/Migrations/20260211044819_Init.cs @@ -0,0 +1,290 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Fengling.Backend.Infrastructure.Migrations +{ + /// + public partial class Init : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Gifts", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false, comment: "礼品ID"), + Name = table.Column(type: "TEXT", maxLength: 100, nullable: false, comment: "礼品名称"), + Type = table.Column(type: "INTEGER", nullable: false, comment: "礼品类型(1:实物,2:虚拟,3:自有产品)"), + Description = table.Column(type: "TEXT", maxLength: 500, nullable: false, comment: "描述"), + ImageUrl = table.Column(type: "TEXT", maxLength: 500, nullable: false, comment: "图片URL"), + RequiredPoints = table.Column(type: "INTEGER", nullable: false, comment: "所需积分"), + TotalStock = table.Column(type: "INTEGER", nullable: false, comment: "总库存"), + AvailableStock = table.Column(type: "INTEGER", nullable: false, comment: "可用库存"), + LimitPerMember = table.Column(type: "INTEGER", nullable: true, comment: "每人限兑数量"), + IsOnShelf = table.Column(type: "INTEGER", nullable: false, comment: "是否上架"), + SortOrder = table.Column(type: "INTEGER", nullable: false, comment: "排序"), + CreatedAt = table.Column(type: "TEXT", nullable: false, comment: "创建时间"), + UpdatedAt = table.Column(type: "TEXT", nullable: false, comment: "更新时间"), + Deleted = table.Column(type: "INTEGER", nullable: false), + RowVersion = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Gifts", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "MarketingCodes", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false, comment: "营销码ID"), + Code = table.Column(type: "TEXT", maxLength: 50, nullable: false, comment: "营销码"), + ProductId = table.Column(type: "TEXT", nullable: false, comment: "产品ID"), + ProductName = table.Column(type: "TEXT", maxLength: 100, nullable: false, comment: "产品名称"), + CategoryId = table.Column(type: "TEXT", nullable: true, comment: "品类ID"), + CategoryName = table.Column(type: "TEXT", maxLength: 100, nullable: true, comment: "品类名称"), + BatchNo = table.Column(type: "TEXT", maxLength: 50, nullable: false, comment: "批次号"), + IsUsed = table.Column(type: "INTEGER", nullable: false, comment: "是否已使用"), + UsedByMemberId = table.Column(type: "TEXT", nullable: true, comment: "使用者会员ID"), + UsedAt = table.Column(type: "TEXT", nullable: true, comment: "使用时间"), + ExpiryDate = table.Column(type: "TEXT", nullable: true, comment: "过期时间"), + CreatedAt = table.Column(type: "TEXT", nullable: false, comment: "创建时间"), + Deleted = table.Column(type: "INTEGER", nullable: false), + RowVersion = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_MarketingCodes", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Members", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false, comment: "会员ID"), + Phone = table.Column(type: "TEXT", maxLength: 20, nullable: false, comment: "手机号"), + Password = table.Column(type: "TEXT", maxLength: 100, nullable: false, comment: "密码(已加密)"), + Nickname = table.Column(type: "TEXT", maxLength: 50, nullable: false, comment: "昵称"), + LevelCode = table.Column(type: "TEXT", maxLength: 20, nullable: false, comment: "等级编码"), + LevelName = table.Column(type: "TEXT", maxLength: 50, nullable: false, comment: "等级名称"), + RequiredPoints = table.Column(type: "INTEGER", nullable: false, comment: "所需积分"), + BonusRate = table.Column(type: "TEXT", nullable: false, comment: "积分奖励倍率"), + TotalPoints = table.Column(type: "INTEGER", nullable: false, comment: "累计总积分"), + AvailablePoints = table.Column(type: "INTEGER", nullable: false, comment: "可用积分"), + Status = table.Column(type: "INTEGER", nullable: false, comment: "状态(1:正常,2:禁用)"), + RegisteredAt = table.Column(type: "TEXT", nullable: false, comment: "注册时间"), + Deleted = table.Column(type: "INTEGER", nullable: false), + RowVersion = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Members", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "PointsRules", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false, comment: "积分规则ID"), + RuleName = table.Column(type: "TEXT", maxLength: 100, nullable: false, comment: "规则名称"), + RuleType = table.Column(type: "INTEGER", nullable: false, comment: "规则类型(1:产品,2:时间,3:会员等级)"), + PointsValue = table.Column(type: "INTEGER", nullable: false, comment: "积分值"), + BonusMultiplier = table.Column(type: "TEXT", nullable: false, comment: "奖励倍数"), + StartDate = table.Column(type: "TEXT", nullable: false, comment: "生效开始时间"), + EndDate = table.Column(type: "TEXT", nullable: true, comment: "生效结束时间"), + ProductId = table.Column(type: "TEXT", nullable: true, comment: "产品ID"), + CategoryId = table.Column(type: "TEXT", nullable: true, comment: "品类ID"), + MemberLevelCode = table.Column(type: "TEXT", maxLength: 20, nullable: true, comment: "会员等级编码"), + IsActive = table.Column(type: "INTEGER", nullable: false, comment: "是否激活"), + CreatedAt = table.Column(type: "TEXT", nullable: false, comment: "创建时间"), + Deleted = table.Column(type: "INTEGER", nullable: false), + RowVersion = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_PointsRules", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "PointsTransactions", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false, comment: "积分流水ID"), + MemberId = table.Column(type: "TEXT", nullable: false, comment: "会员ID"), + Type = table.Column(type: "INTEGER", nullable: false, comment: "交易类型(1:获得,2:消费,3:过期,4:退还)"), + Amount = table.Column(type: "INTEGER", nullable: false, comment: "积分数量"), + Source = table.Column(type: "TEXT", maxLength: 100, nullable: false, comment: "来源"), + Reason = table.Column(type: "TEXT", maxLength: 200, nullable: false, comment: "原因描述"), + RelatedId = table.Column(type: "TEXT", nullable: false, comment: "关联ID"), + ExpiryDate = table.Column(type: "TEXT", nullable: true, comment: "过期时间"), + CreatedAt = table.Column(type: "TEXT", nullable: false, comment: "创建时间"), + Deleted = table.Column(type: "INTEGER", nullable: false), + RowVersion = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_PointsTransactions", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "RedemptionOrders", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false, comment: "兑换订单ID"), + OrderNo = table.Column(type: "TEXT", maxLength: 50, nullable: false, comment: "订单号"), + MemberId = table.Column(type: "TEXT", nullable: false, comment: "会员ID"), + GiftId = table.Column(type: "TEXT", nullable: false, comment: "礼品ID"), + GiftName = table.Column(type: "TEXT", maxLength: 100, nullable: false, comment: "礼品名称"), + GiftType = table.Column(type: "INTEGER", nullable: false, comment: "礼品类型"), + Quantity = table.Column(type: "INTEGER", nullable: false, comment: "数量"), + ConsumedPoints = table.Column(type: "INTEGER", nullable: false, comment: "消耗积分"), + ReceiverName = table.Column(type: "TEXT", maxLength: 50, nullable: true, comment: "收货人姓名"), + ReceiverPhone = table.Column(type: "TEXT", maxLength: 20, nullable: true, comment: "联系电话"), + Province = table.Column(type: "TEXT", maxLength: 50, nullable: true, comment: "省"), + City = table.Column(type: "TEXT", maxLength: 50, nullable: true, comment: "市"), + District = table.Column(type: "TEXT", maxLength: 50, nullable: true, comment: "区/县"), + DetailAddress = table.Column(type: "TEXT", maxLength: 200, nullable: true, comment: "详细地址"), + TrackingNo = table.Column(type: "TEXT", maxLength: 100, nullable: true, comment: "物流单号"), + Status = table.Column(type: "INTEGER", nullable: false, comment: "订单状态(1:待处理,2:已发货,3:已送达,4:已完成,5:已取消)"), + CancelReason = table.Column(type: "TEXT", maxLength: 500, nullable: true, comment: "取消原因"), + CreatedAt = table.Column(type: "TEXT", nullable: false, comment: "创建时间"), + UpdatedAt = table.Column(type: "TEXT", nullable: false, comment: "更新时间"), + Deleted = table.Column(type: "INTEGER", nullable: false), + RowVersion = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_RedemptionOrders", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_Gifts_IsOnShelf", + table: "Gifts", + column: "IsOnShelf"); + + migrationBuilder.CreateIndex( + name: "IX_Gifts_SortOrder", + table: "Gifts", + column: "SortOrder"); + + migrationBuilder.CreateIndex( + name: "IX_Gifts_Type", + table: "Gifts", + column: "Type"); + + migrationBuilder.CreateIndex( + name: "IX_MarketingCodes_BatchNo", + table: "MarketingCodes", + column: "BatchNo"); + + migrationBuilder.CreateIndex( + name: "IX_MarketingCodes_Code", + table: "MarketingCodes", + column: "Code", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_MarketingCodes_IsUsed", + table: "MarketingCodes", + column: "IsUsed"); + + migrationBuilder.CreateIndex( + name: "IX_Members_Phone", + table: "Members", + column: "Phone", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Members_Status", + table: "Members", + column: "Status"); + + migrationBuilder.CreateIndex( + name: "IX_PointsRules_DateRange", + table: "PointsRules", + columns: new[] { "StartDate", "EndDate" }); + + migrationBuilder.CreateIndex( + name: "IX_PointsRules_IsActive", + table: "PointsRules", + column: "IsActive"); + + migrationBuilder.CreateIndex( + name: "IX_PointsRules_MemberLevelCode", + table: "PointsRules", + column: "MemberLevelCode"); + + migrationBuilder.CreateIndex( + name: "IX_PointsRules_ProductId", + table: "PointsRules", + column: "ProductId"); + + migrationBuilder.CreateIndex( + name: "IX_PointsTransactions_CreatedAt", + table: "PointsTransactions", + column: "CreatedAt"); + + migrationBuilder.CreateIndex( + name: "IX_PointsTransactions_MemberId", + table: "PointsTransactions", + column: "MemberId"); + + migrationBuilder.CreateIndex( + name: "IX_PointsTransactions_RelatedId", + table: "PointsTransactions", + column: "RelatedId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_PointsTransactions_Type", + table: "PointsTransactions", + column: "Type"); + + migrationBuilder.CreateIndex( + name: "IX_RedemptionOrders_CreatedAt", + table: "RedemptionOrders", + column: "CreatedAt"); + + migrationBuilder.CreateIndex( + name: "IX_RedemptionOrders_MemberId", + table: "RedemptionOrders", + column: "MemberId"); + + migrationBuilder.CreateIndex( + name: "IX_RedemptionOrders_OrderNo", + table: "RedemptionOrders", + column: "OrderNo", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_RedemptionOrders_Status", + table: "RedemptionOrders", + column: "Status"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Gifts"); + + migrationBuilder.DropTable( + name: "MarketingCodes"); + + migrationBuilder.DropTable( + name: "Members"); + + migrationBuilder.DropTable( + name: "PointsRules"); + + migrationBuilder.DropTable( + name: "PointsTransactions"); + + migrationBuilder.DropTable( + name: "RedemptionOrders"); + } + } +} diff --git a/Backend/src/Fengling.Backend.Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs b/Backend/src/Fengling.Backend.Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs new file mode 100644 index 0000000..c88d28f --- /dev/null +++ b/Backend/src/Fengling.Backend.Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs @@ -0,0 +1,596 @@ +// +using System; +using Fengling.Backend.Infrastructure; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Fengling.Backend.Infrastructure.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + partial class ApplicationDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.0"); + + modelBuilder.Entity("Fengling.Backend.Domain.AggregatesModel.GiftAggregate.Gift", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasComment("礼品ID"); + + b.Property("AvailableStock") + .HasColumnType("INTEGER") + .HasComment("可用库存"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasComment("创建时间"); + + b.Property("Deleted") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasComment("描述"); + + b.Property("ImageUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasComment("图片URL"); + + b.Property("IsOnShelf") + .HasColumnType("INTEGER") + .HasComment("是否上架"); + + b.Property("LimitPerMember") + .HasColumnType("INTEGER") + .HasComment("每人限兑数量"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasComment("礼品名称"); + + b.Property("RequiredPoints") + .HasColumnType("INTEGER") + .HasComment("所需积分"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SortOrder") + .HasColumnType("INTEGER") + .HasComment("排序"); + + b.Property("TotalStock") + .HasColumnType("INTEGER") + .HasComment("总库存"); + + b.Property("Type") + .HasColumnType("INTEGER") + .HasComment("礼品类型(1:实物,2:虚拟,3:自有产品)"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT") + .HasComment("更新时间"); + + b.HasKey("Id"); + + b.HasIndex("IsOnShelf") + .HasDatabaseName("IX_Gifts_IsOnShelf"); + + b.HasIndex("SortOrder") + .HasDatabaseName("IX_Gifts_SortOrder"); + + b.HasIndex("Type") + .HasDatabaseName("IX_Gifts_Type"); + + b.ToTable("Gifts", (string)null); + }); + + modelBuilder.Entity("Fengling.Backend.Domain.AggregatesModel.MarketingCodeAggregate.MarketingCode", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasComment("营销码ID"); + + b.Property("BatchNo") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasComment("批次号"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasComment("营销码"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasComment("创建时间"); + + b.Property("Deleted") + .HasColumnType("INTEGER"); + + b.Property("ExpiryDate") + .HasColumnType("TEXT") + .HasComment("过期时间"); + + b.Property("IsUsed") + .HasColumnType("INTEGER") + .HasComment("是否已使用"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("UsedAt") + .HasColumnType("TEXT") + .HasComment("使用时间"); + + b.Property("UsedByMemberId") + .HasColumnType("TEXT") + .HasComment("使用者会员ID"); + + b.HasKey("Id"); + + b.HasIndex("BatchNo") + .HasDatabaseName("IX_MarketingCodes_BatchNo"); + + b.HasIndex("Code") + .IsUnique() + .HasDatabaseName("IX_MarketingCodes_Code"); + + b.HasIndex("IsUsed") + .HasDatabaseName("IX_MarketingCodes_IsUsed"); + + b.ToTable("MarketingCodes", (string)null); + }); + + modelBuilder.Entity("Fengling.Backend.Domain.AggregatesModel.MemberAggregate.Member", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasComment("会员ID"); + + b.Property("AvailablePoints") + .HasColumnType("INTEGER") + .HasComment("可用积分"); + + b.Property("Deleted") + .HasColumnType("INTEGER"); + + b.Property("Nickname") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasComment("昵称"); + + b.Property("Password") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasComment("密码(已加密)"); + + b.Property("Phone") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT") + .HasComment("手机号"); + + b.Property("RegisteredAt") + .HasColumnType("TEXT") + .HasComment("注册时间"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER") + .HasComment("状态(1:正常,2:禁用)"); + + b.Property("TotalPoints") + .HasColumnType("INTEGER") + .HasComment("累计总积分"); + + b.HasKey("Id"); + + b.HasIndex("Phone") + .IsUnique() + .HasDatabaseName("IX_Members_Phone"); + + b.HasIndex("Status") + .HasDatabaseName("IX_Members_Status"); + + b.ToTable("Members", (string)null); + }); + + modelBuilder.Entity("Fengling.Backend.Domain.AggregatesModel.PointsRuleAggregate.PointsRule", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasComment("积分规则ID"); + + b.Property("BonusMultiplier") + .HasColumnType("TEXT") + .HasComment("奖励倍数"); + + b.Property("CategoryId") + .HasColumnType("TEXT") + .HasComment("品类ID"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasComment("创建时间"); + + b.Property("Deleted") + .HasColumnType("INTEGER"); + + b.Property("EndDate") + .HasColumnType("TEXT") + .HasComment("生效结束时间"); + + b.Property("IsActive") + .HasColumnType("INTEGER") + .HasComment("是否激活"); + + b.Property("MemberLevelCode") + .HasMaxLength(20) + .HasColumnType("TEXT") + .HasComment("会员等级编码"); + + b.Property("PointsValue") + .HasColumnType("INTEGER") + .HasComment("积分值"); + + b.Property("ProductId") + .HasColumnType("TEXT") + .HasComment("产品ID"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("RuleName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasComment("规则名称"); + + b.Property("RuleType") + .HasColumnType("INTEGER") + .HasComment("规则类型(1:产品,2:时间,3:会员等级)"); + + b.Property("StartDate") + .HasColumnType("TEXT") + .HasComment("生效开始时间"); + + b.HasKey("Id"); + + b.HasIndex("IsActive") + .HasDatabaseName("IX_PointsRules_IsActive"); + + b.HasIndex("MemberLevelCode") + .HasDatabaseName("IX_PointsRules_MemberLevelCode"); + + b.HasIndex("ProductId") + .HasDatabaseName("IX_PointsRules_ProductId"); + + b.HasIndex("StartDate", "EndDate") + .HasDatabaseName("IX_PointsRules_DateRange"); + + b.ToTable("PointsRules", (string)null); + }); + + modelBuilder.Entity("Fengling.Backend.Domain.AggregatesModel.PointsTransactionAggregate.PointsTransaction", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasComment("积分流水ID"); + + b.Property("Amount") + .HasColumnType("INTEGER") + .HasComment("积分数量"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasComment("创建时间"); + + b.Property("Deleted") + .HasColumnType("INTEGER"); + + b.Property("ExpiryDate") + .HasColumnType("TEXT") + .HasComment("过期时间"); + + b.Property("MemberId") + .HasColumnType("TEXT") + .HasComment("会员ID"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT") + .HasComment("原因描述"); + + b.Property("RelatedId") + .HasColumnType("TEXT") + .HasComment("关联ID"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasComment("来源"); + + b.Property("Type") + .HasColumnType("INTEGER") + .HasComment("交易类型(1:获得,2:消费,3:过期,4:退还)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt") + .HasDatabaseName("IX_PointsTransactions_CreatedAt"); + + b.HasIndex("MemberId") + .HasDatabaseName("IX_PointsTransactions_MemberId"); + + b.HasIndex("RelatedId") + .IsUnique() + .HasDatabaseName("IX_PointsTransactions_RelatedId"); + + b.HasIndex("Type") + .HasDatabaseName("IX_PointsTransactions_Type"); + + b.ToTable("PointsTransactions", (string)null); + }); + + modelBuilder.Entity("Fengling.Backend.Domain.AggregatesModel.RedemptionOrderAggregate.RedemptionOrder", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasComment("兑换订单ID"); + + b.Property("CancelReason") + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasComment("取消原因"); + + b.Property("ConsumedPoints") + .HasColumnType("INTEGER") + .HasComment("消耗积分"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasComment("创建时间"); + + b.Property("Deleted") + .HasColumnType("INTEGER"); + + b.Property("GiftId") + .HasColumnType("TEXT") + .HasComment("礼品ID"); + + b.Property("GiftName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasComment("礼品名称"); + + b.Property("GiftType") + .HasColumnType("INTEGER") + .HasComment("礼品类型"); + + b.Property("MemberId") + .HasColumnType("TEXT") + .HasComment("会员ID"); + + b.Property("OrderNo") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasComment("订单号"); + + b.Property("Quantity") + .HasColumnType("INTEGER") + .HasComment("数量"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER") + .HasComment("订单状态(1:待处理,2:已发货,3:已送达,4:已完成,5:已取消)"); + + b.Property("TrackingNo") + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasComment("物流单号"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT") + .HasComment("更新时间"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt") + .HasDatabaseName("IX_RedemptionOrders_CreatedAt"); + + b.HasIndex("MemberId") + .HasDatabaseName("IX_RedemptionOrders_MemberId"); + + b.HasIndex("OrderNo") + .IsUnique() + .HasDatabaseName("IX_RedemptionOrders_OrderNo"); + + b.HasIndex("Status") + .HasDatabaseName("IX_RedemptionOrders_Status"); + + b.ToTable("RedemptionOrders", (string)null); + }); + + modelBuilder.Entity("Fengling.Backend.Domain.AggregatesModel.MarketingCodeAggregate.MarketingCode", b => + { + b.OwnsOne("Fengling.Backend.Domain.AggregatesModel.MarketingCodeAggregate.ProductInfo", "ProductInfo", b1 => + { + b1.Property("MarketingCodeId") + .HasColumnType("TEXT"); + + b1.Property("CategoryId") + .HasColumnType("TEXT") + .HasColumnName("CategoryId") + .HasComment("品类ID"); + + b1.Property("CategoryName") + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasColumnName("CategoryName") + .HasComment("品类名称"); + + b1.Property("ProductId") + .HasColumnType("TEXT") + .HasColumnName("ProductId") + .HasComment("产品ID"); + + b1.Property("ProductName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasColumnName("ProductName") + .HasComment("产品名称"); + + b1.HasKey("MarketingCodeId"); + + b1.ToTable("MarketingCodes"); + + b1.WithOwner() + .HasForeignKey("MarketingCodeId"); + }); + + b.Navigation("ProductInfo") + .IsRequired(); + }); + + modelBuilder.Entity("Fengling.Backend.Domain.AggregatesModel.MemberAggregate.Member", b => + { + b.OwnsOne("Fengling.Backend.Domain.AggregatesModel.MemberAggregate.MemberLevel", "Level", b1 => + { + b1.Property("MemberId") + .HasColumnType("TEXT"); + + b1.Property("BonusRate") + .HasColumnType("TEXT") + .HasColumnName("BonusRate") + .HasComment("积分奖励倍率"); + + b1.Property("LevelCode") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT") + .HasColumnName("LevelCode") + .HasComment("等级编码"); + + b1.Property("LevelName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("LevelName") + .HasComment("等级名称"); + + b1.Property("RequiredPoints") + .HasColumnType("INTEGER") + .HasColumnName("RequiredPoints") + .HasComment("所需积分"); + + b1.HasKey("MemberId"); + + b1.ToTable("Members"); + + b1.WithOwner() + .HasForeignKey("MemberId"); + }); + + b.Navigation("Level") + .IsRequired(); + }); + + modelBuilder.Entity("Fengling.Backend.Domain.AggregatesModel.RedemptionOrderAggregate.RedemptionOrder", b => + { + b.OwnsOne("Fengling.Backend.Domain.AggregatesModel.RedemptionOrderAggregate.Address", "ShippingAddress", b1 => + { + b1.Property("RedemptionOrderId") + .HasColumnType("TEXT"); + + b1.Property("City") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("City") + .HasComment("市"); + + b1.Property("DetailAddress") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT") + .HasColumnName("DetailAddress") + .HasComment("详细地址"); + + b1.Property("District") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("District") + .HasComment("区/县"); + + b1.Property("Phone") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT") + .HasColumnName("ReceiverPhone") + .HasComment("联系电话"); + + b1.Property("Province") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("Province") + .HasComment("省"); + + b1.Property("ReceiverName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("ReceiverName") + .HasComment("收货人姓名"); + + b1.HasKey("RedemptionOrderId"); + + b1.ToTable("RedemptionOrders"); + + b1.WithOwner() + .HasForeignKey("RedemptionOrderId"); + }); + + b.Navigation("ShippingAddress"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Backend/src/Fengling.Backend.Infrastructure/Repositories/GiftRepository.cs b/Backend/src/Fengling.Backend.Infrastructure/Repositories/GiftRepository.cs new file mode 100644 index 0000000..e1208cb --- /dev/null +++ b/Backend/src/Fengling.Backend.Infrastructure/Repositories/GiftRepository.cs @@ -0,0 +1,38 @@ +using Fengling.Backend.Domain.AggregatesModel.GiftAggregate; + +namespace Fengling.Backend.Infrastructure.Repositories; + +public interface IGiftRepository : IRepository +{ + Task GetByIdAsync(GiftId giftId, CancellationToken cancellationToken = default); + Task> GetOnShelfGiftsAsync(CancellationToken cancellationToken = default); + Task> GetByTypeAsync(GiftType type, CancellationToken cancellationToken = default); +} + +public class GiftRepository(ApplicationDbContext context) + : RepositoryBase(context), IGiftRepository +{ + public async Task GetByIdAsync(GiftId giftId, CancellationToken cancellationToken = default) + { + return await DbContext.Gifts + .FirstOrDefaultAsync(x => x.Id == giftId, cancellationToken); + } + + public async Task> GetOnShelfGiftsAsync(CancellationToken cancellationToken = default) + { + return await DbContext.Gifts + .Where(x => x.IsOnShelf) + .OrderBy(x => x.SortOrder) + .ThenByDescending(x => x.CreatedAt) + .ToListAsync(cancellationToken); + } + + public async Task> GetByTypeAsync(GiftType type, CancellationToken cancellationToken = default) + { + return await DbContext.Gifts + .Where(x => x.Type == type && x.IsOnShelf) + .OrderBy(x => x.SortOrder) + .ThenByDescending(x => x.CreatedAt) + .ToListAsync(cancellationToken); + } +} diff --git a/Backend/src/Fengling.Backend.Infrastructure/Repositories/MarketingCodeRepository.cs b/Backend/src/Fengling.Backend.Infrastructure/Repositories/MarketingCodeRepository.cs new file mode 100644 index 0000000..e163e16 --- /dev/null +++ b/Backend/src/Fengling.Backend.Infrastructure/Repositories/MarketingCodeRepository.cs @@ -0,0 +1,38 @@ +using Fengling.Backend.Domain.AggregatesModel.MarketingCodeAggregate; + +namespace Fengling.Backend.Infrastructure.Repositories; + +/// +/// 营销码仓储接口 +/// +public interface IMarketingCodeRepository : IRepository +{ + /// + /// 根据营销码查询 + /// + Task GetByCodeAsync(string code, CancellationToken cancellationToken = default); + + /// + /// 检查营销码是否存在 + /// + Task CodeExistsAsync(string code, CancellationToken cancellationToken = default); +} + +/// +/// 营销码仓储实现 +/// +public class MarketingCodeRepository(ApplicationDbContext context) + : RepositoryBase(context), IMarketingCodeRepository +{ + public async Task GetByCodeAsync(string code, CancellationToken cancellationToken = default) + { + return await DbContext.MarketingCodes + .FirstOrDefaultAsync(x => x.Code == code, cancellationToken); + } + + public async Task CodeExistsAsync(string code, CancellationToken cancellationToken = default) + { + return await DbContext.MarketingCodes + .AnyAsync(x => x.Code == code, cancellationToken); + } +} diff --git a/Backend/src/Fengling.Backend.Infrastructure/Repositories/MemberRepository.cs b/Backend/src/Fengling.Backend.Infrastructure/Repositories/MemberRepository.cs new file mode 100644 index 0000000..e579187 --- /dev/null +++ b/Backend/src/Fengling.Backend.Infrastructure/Repositories/MemberRepository.cs @@ -0,0 +1,38 @@ +using Fengling.Backend.Domain.AggregatesModel.MemberAggregate; + +namespace Fengling.Backend.Infrastructure.Repositories; + +/// +/// 会员仓储接口 +/// +public interface IMemberRepository : IRepository +{ + /// + /// 根据手机号查询会员 + /// + Task GetByPhoneAsync(string phone, CancellationToken cancellationToken = default); + + /// + /// 检查手机号是否存在 + /// + Task PhoneExistsAsync(string phone, CancellationToken cancellationToken = default); +} + +/// +/// 会员仓储实现 +/// +public class MemberRepository(ApplicationDbContext context) + : RepositoryBase(context), IMemberRepository +{ + public async Task GetByPhoneAsync(string phone, CancellationToken cancellationToken = default) + { + return await DbContext.Members + .FirstOrDefaultAsync(x => x.Phone == phone, cancellationToken); + } + + public async Task PhoneExistsAsync(string phone, CancellationToken cancellationToken = default) + { + return await DbContext.Members + .AnyAsync(x => x.Phone == phone, cancellationToken); + } +} diff --git a/Backend/src/Fengling.Backend.Infrastructure/Repositories/PointsRuleRepository.cs b/Backend/src/Fengling.Backend.Infrastructure/Repositories/PointsRuleRepository.cs new file mode 100644 index 0000000..b7eff08 --- /dev/null +++ b/Backend/src/Fengling.Backend.Infrastructure/Repositories/PointsRuleRepository.cs @@ -0,0 +1,87 @@ +using Fengling.Backend.Domain.AggregatesModel.PointsRuleAggregate; + +namespace Fengling.Backend.Infrastructure.Repositories; + +/// +/// 积分规则仓储接口 +/// +public interface IPointsRuleRepository : IRepository +{ + /// + /// 获取有效的积分规则(匹配产品、会员等级、时间) + /// + Task> GetEffectiveRulesAsync( + Guid productId, + Guid? categoryId, + string memberLevelCode, + DateTime scanDate, + CancellationToken cancellationToken = default); + + /// + /// 检查是否存在冲突的规则(同维度) + /// + Task HasConflictingRuleAsync( + Guid? productId, + Guid? categoryId, + string? memberLevelCode, + DateTime startDate, + DateTime? endDate, + CancellationToken cancellationToken = default); +} + +/// +/// 积分规则仓储实现 +/// +public class PointsRuleRepository(ApplicationDbContext context) + : RepositoryBase(context), IPointsRuleRepository +{ + public async Task> GetEffectiveRulesAsync( + Guid productId, + Guid? categoryId, + string memberLevelCode, + DateTime scanDate, + CancellationToken cancellationToken = default) + { + return await DbContext.PointsRules + .Where(x => x.IsActive) + .Where(x => x.StartDate <= scanDate && (x.EndDate == null || x.EndDate >= scanDate)) + .Where(x => x.ProductId == null || x.ProductId == productId) + .Where(x => x.CategoryId == null || x.CategoryId == categoryId) + .Where(x => x.MemberLevelCode == null || x.MemberLevelCode == memberLevelCode) + .ToListAsync(cancellationToken); + } + + public async Task HasConflictingRuleAsync( + Guid? productId, + Guid? categoryId, + string? memberLevelCode, + DateTime startDate, + DateTime? endDate, + CancellationToken cancellationToken = default) + { + var query = DbContext.PointsRules.AsQueryable(); + + // 检查维度是否完全一致 + if (productId.HasValue) + query = query.Where(x => x.ProductId == productId); + else + query = query.Where(x => x.ProductId == null); + + if (categoryId.HasValue) + query = query.Where(x => x.CategoryId == categoryId); + else + query = query.Where(x => x.CategoryId == null); + + if (!string.IsNullOrWhiteSpace(memberLevelCode)) + query = query.Where(x => x.MemberLevelCode == memberLevelCode); + else + query = query.Where(x => x.MemberLevelCode == null); + + // 检查时间重叠 + query = query.Where(x => + x.StartDate <= (endDate ?? DateTime.MaxValue) && + (x.EndDate == null || x.EndDate >= startDate)); + + return await query.AnyAsync(cancellationToken); + } +} diff --git a/Backend/src/Fengling.Backend.Infrastructure/Repositories/PointsTransactionRepository.cs b/Backend/src/Fengling.Backend.Infrastructure/Repositories/PointsTransactionRepository.cs new file mode 100644 index 0000000..0b81ee5 --- /dev/null +++ b/Backend/src/Fengling.Backend.Infrastructure/Repositories/PointsTransactionRepository.cs @@ -0,0 +1,41 @@ +using Fengling.Backend.Domain.AggregatesModel.MemberAggregate; +using Fengling.Backend.Domain.AggregatesModel.PointsTransactionAggregate; + +namespace Fengling.Backend.Infrastructure.Repositories; + +/// +/// 积分交易仓储接口 +/// +public interface IPointsTransactionRepository : IRepository +{ + /// + /// 根据会员ID查询积分交易记录 + /// + Task> GetByMemberIdAsync(MemberId memberId, CancellationToken cancellationToken = default); + + /// + /// 检查关联ID是否已存在交易记录(用于幂等性) + /// + Task ExistsByRelatedIdAsync(Guid relatedId, CancellationToken cancellationToken = default); +} + +/// +/// 积分交易仓储实现 +/// +public class PointsTransactionRepository(ApplicationDbContext context) + : RepositoryBase(context), IPointsTransactionRepository +{ + public async Task> GetByMemberIdAsync(MemberId memberId, CancellationToken cancellationToken = default) + { + return await DbContext.PointsTransactions + .Where(x => x.MemberId == memberId) + .OrderByDescending(x => x.CreatedAt) + .ToListAsync(cancellationToken); + } + + public async Task ExistsByRelatedIdAsync(Guid relatedId, CancellationToken cancellationToken = default) + { + return await DbContext.PointsTransactions + .AnyAsync(x => x.RelatedId == relatedId, cancellationToken); + } +} diff --git a/Backend/src/Fengling.Backend.Infrastructure/Repositories/RedemptionOrderRepository.cs b/Backend/src/Fengling.Backend.Infrastructure/Repositories/RedemptionOrderRepository.cs new file mode 100644 index 0000000..85b7c23 --- /dev/null +++ b/Backend/src/Fengling.Backend.Infrastructure/Repositories/RedemptionOrderRepository.cs @@ -0,0 +1,44 @@ +using Fengling.Backend.Domain.AggregatesModel.RedemptionOrderAggregate; + +namespace Fengling.Backend.Infrastructure.Repositories; + +public interface IRedemptionOrderRepository : IRepository +{ + Task GetByIdAsync(RedemptionOrderId orderId, CancellationToken cancellationToken = default); + Task GetByOrderNoAsync(string orderNo, CancellationToken cancellationToken = default); + Task> GetByMemberIdAsync(Guid memberId, CancellationToken cancellationToken = default); + Task GetMemberRedemptionCountAsync(Guid memberId, Guid giftId, CancellationToken cancellationToken = default); +} + +public class RedemptionOrderRepository(ApplicationDbContext context) + : RepositoryBase(context), IRedemptionOrderRepository +{ + public async Task GetByIdAsync(RedemptionOrderId orderId, CancellationToken cancellationToken = default) + { + return await DbContext.RedemptionOrders + .FirstOrDefaultAsync(x => x.Id == orderId, cancellationToken); + } + + public async Task GetByOrderNoAsync(string orderNo, CancellationToken cancellationToken = default) + { + return await DbContext.RedemptionOrders + .FirstOrDefaultAsync(x => x.OrderNo == orderNo, cancellationToken); + } + + public async Task> GetByMemberIdAsync(Guid memberId, CancellationToken cancellationToken = default) + { + return await DbContext.RedemptionOrders + .Where(x => x.MemberId == memberId) + .OrderByDescending(x => x.CreatedAt) + .ToListAsync(cancellationToken); + } + + public async Task GetMemberRedemptionCountAsync(Guid memberId, Guid giftId, CancellationToken cancellationToken = default) + { + return await DbContext.RedemptionOrders + .Where(x => x.MemberId == memberId + && x.GiftId == giftId + && x.Status != RedemptionOrderStatus.Cancelled) + .SumAsync(x => x.Quantity, cancellationToken); + } +} diff --git a/Backend/src/Fengling.Backend.Web/Application/Commands/Gifts/GiftCommands.cs b/Backend/src/Fengling.Backend.Web/Application/Commands/Gifts/GiftCommands.cs new file mode 100644 index 0000000..4ce7008 --- /dev/null +++ b/Backend/src/Fengling.Backend.Web/Application/Commands/Gifts/GiftCommands.cs @@ -0,0 +1,176 @@ +using Fengling.Backend.Domain.AggregatesModel.GiftAggregate; +using Fengling.Backend.Infrastructure.Repositories; + +namespace Fengling.Backend.Web.Application.Commands.Gifts; + +/// +/// 创建礼品命令 +/// +public record CreateGiftCommand( + string Name, + int Type, + string Description, + string ImageUrl, + int RequiredPoints, + int TotalStock, + int? LimitPerMember = null) : ICommand; + +public class CreateGiftCommandValidator : AbstractValidator +{ + public CreateGiftCommandValidator() + { + RuleFor(x => x.Name).NotEmpty().MaximumLength(100); + RuleFor(x => x.Type).IsInEnum(); + RuleFor(x => x.Description).NotEmpty().MaximumLength(500); + RuleFor(x => x.ImageUrl).NotEmpty().MaximumLength(500); + RuleFor(x => x.RequiredPoints).GreaterThan(0); + RuleFor(x => x.TotalStock).GreaterThanOrEqualTo(0); + } +} + +public class CreateGiftCommandHandler(IGiftRepository giftRepository) + : ICommandHandler +{ + public async Task Handle(CreateGiftCommand request, CancellationToken cancellationToken) + { + var giftType = (GiftType)request.Type; + + var gift = new Gift( + request.Name, + giftType, + request.Description, + request.ImageUrl, + request.RequiredPoints, + request.TotalStock, + request.LimitPerMember); + + await giftRepository.AddAsync(gift, cancellationToken); + + return gift.Id; + } +} + +/// +/// 更新礼品命令 +/// +public record UpdateGiftCommand( + Guid GiftId, + string? Name = null, + string? Description = null, + string? ImageUrl = null, + int? RequiredPoints = null, + int? LimitPerMember = null) : ICommand; + +public class UpdateGiftCommandValidator : AbstractValidator +{ + public UpdateGiftCommandValidator() + { + RuleFor(x => x.GiftId).NotEmpty(); + RuleFor(x => x.RequiredPoints).GreaterThan(0).When(x => x.RequiredPoints.HasValue); + RuleFor(x => x.Name).MaximumLength(100); + RuleFor(x => x.Description).MaximumLength(500); + RuleFor(x => x.ImageUrl).MaximumLength(500); + } +} + +public class UpdateGiftCommandHandler(IGiftRepository giftRepository) + : ICommandHandler +{ + public async Task Handle(UpdateGiftCommand request, CancellationToken cancellationToken) + { + var giftId = new GiftId(request.GiftId); + var gift = await giftRepository.GetByIdAsync(giftId, cancellationToken); + + if (gift == null) + throw new KnownException("礼品不存在"); + + gift.Update( + request.Name, + request.Description, + request.ImageUrl, + request.RequiredPoints, + request.LimitPerMember); + + await giftRepository.UpdateAsync(gift, cancellationToken); + + return new ResponseData(); + } +} + +/// +/// 上架礼品命令 +/// +public record PutOnShelfCommand(Guid GiftId) : ICommand; + +public class PutOnShelfCommandHandler(IGiftRepository giftRepository) + : ICommandHandler +{ + public async Task Handle(PutOnShelfCommand request, CancellationToken cancellationToken) + { + var giftId = new GiftId(request.GiftId); + var gift = await giftRepository.GetByIdAsync(giftId, cancellationToken); + + if (gift == null) + throw new KnownException("礼品不存在"); + + gift.PutOnShelf(); + await giftRepository.UpdateAsync(gift, cancellationToken); + + return new ResponseData(); + } +} + +/// +/// 下架礼品命令 +/// +public record PutOffShelfCommand(Guid GiftId) : ICommand; + +public class PutOffShelfCommandHandler(IGiftRepository giftRepository) + : ICommandHandler +{ + public async Task Handle(PutOffShelfCommand request, CancellationToken cancellationToken) + { + var giftId = new GiftId(request.GiftId); + var gift = await giftRepository.GetByIdAsync(giftId, cancellationToken); + + if (gift == null) + throw new KnownException("礼品不存在"); + + gift.PutOffShelf(); + await giftRepository.UpdateAsync(gift, cancellationToken); + + return new ResponseData(); + } +} + +/// +/// 增加库存命令 +/// +public record AddGiftStockCommand(Guid GiftId, int Quantity) : ICommand; + +public class AddGiftStockCommandValidator : AbstractValidator +{ + public AddGiftStockCommandValidator() + { + RuleFor(x => x.GiftId).NotEmpty(); + RuleFor(x => x.Quantity).GreaterThan(0); + } +} + +public class AddGiftStockCommandHandler(IGiftRepository giftRepository) + : ICommandHandler +{ + public async Task Handle(AddGiftStockCommand request, CancellationToken cancellationToken) + { + var giftId = new GiftId(request.GiftId); + var gift = await giftRepository.GetByIdAsync(giftId, cancellationToken); + + if (gift == null) + throw new KnownException("礼品不存在"); + + gift.AddStock(request.Quantity); + await giftRepository.UpdateAsync(gift, cancellationToken); + + return new ResponseData(); + } +} diff --git a/Backend/src/Fengling.Backend.Web/Application/Commands/MarketingCodes/GenerateMarketingCodesCommand.cs b/Backend/src/Fengling.Backend.Web/Application/Commands/MarketingCodes/GenerateMarketingCodesCommand.cs new file mode 100644 index 0000000..c91ae35 --- /dev/null +++ b/Backend/src/Fengling.Backend.Web/Application/Commands/MarketingCodes/GenerateMarketingCodesCommand.cs @@ -0,0 +1,107 @@ +using Fengling.Backend.Domain.AggregatesModel.MarketingCodeAggregate; +using Fengling.Backend.Infrastructure.Repositories; + +namespace Fengling.Backend.Web.Application.Commands.MarketingCodes; + +/// +/// 生成营销码命令 +/// +public record GenerateMarketingCodesCommand( + string BatchNo, + Guid ProductId, + string ProductName, + int Quantity, + DateTime? ExpiryDate = null) : ICommand; + +/// +/// 生成营销码响应 +/// +public record GenerateMarketingCodesResponse(string BatchNo, int Count, List Codes); + +/// +/// 生成营销码命令验证器 +/// +public class GenerateMarketingCodesCommandValidator : AbstractValidator +{ + public GenerateMarketingCodesCommandValidator() + { + RuleFor(x => x.BatchNo) + .NotEmpty().WithMessage("批次号不能为空") + .MaximumLength(50).WithMessage("批次号最多50个字符"); + + RuleFor(x => x.ProductId) + .NotEmpty().WithMessage("产品ID不能为空"); + + RuleFor(x => x.ProductName) + .NotEmpty().WithMessage("产品名称不能为空") + .MaximumLength(100).WithMessage("产品名称最多100个字符"); + + RuleFor(x => x.Quantity) + .GreaterThan(0).WithMessage("数量必须大于0") + .LessThanOrEqualTo(10000).WithMessage("单次生成数量不能超过10000"); + } +} + +/// +/// 生成营销码命令处理器 +/// +public class GenerateMarketingCodesCommandHandler( + IMarketingCodeRepository marketingCodeRepository, + ILogger logger) + : ICommandHandler +{ + public async Task Handle( + GenerateMarketingCodesCommand command, + CancellationToken cancellationToken) + { + logger.LogInformation("开始生成营销码,批次:{BatchNo},数量:{Quantity}", + command.BatchNo, command.Quantity); + + var codes = new List(); + var marketingCodes = new List(); + + // 生成营销码 + for (int i = 0; i < command.Quantity; i++) + { + var code = GenerateUniqueCode(command.BatchNo, i); + + // 检查码是否存在(虽然理论上不会重复) + if (await marketingCodeRepository.CodeExistsAsync(code, cancellationToken)) + { + logger.LogWarning("营销码已存在,跳过:{Code}", code); + continue; + } + + var marketingCode = new MarketingCode( + code, + command.ProductId, + command.ProductName, + command.BatchNo, + command.ExpiryDate); + + marketingCodes.Add(marketingCode); + codes.Add(code); + } + + // 批量保存 + foreach (var marketingCode in marketingCodes) + { + await marketingCodeRepository.AddAsync(marketingCode, cancellationToken); + } + + logger.LogInformation("营销码生成成功,批次:{BatchNo},实际生成数量:{Count}", + command.BatchNo, codes.Count); + + return new GenerateMarketingCodesResponse(command.BatchNo, codes.Count, codes); + } + + /// + /// 生成唯一码(批次号+序号+时间戳) + /// + private static string GenerateUniqueCode(string batchNo, int sequence) + { + var timestamp = DateTime.UtcNow.ToString("yyyyMMddHHmmss"); + var random = Random.Shared.Next(1000, 9999); + return $"{batchNo}-{sequence + 1:D6}-{timestamp}-{random}"; + } +} diff --git a/Backend/src/Fengling.Backend.Web/Application/Commands/MarketingCodes/UseMarketingCodeCommand.cs b/Backend/src/Fengling.Backend.Web/Application/Commands/MarketingCodes/UseMarketingCodeCommand.cs new file mode 100644 index 0000000..9a38cde --- /dev/null +++ b/Backend/src/Fengling.Backend.Web/Application/Commands/MarketingCodes/UseMarketingCodeCommand.cs @@ -0,0 +1,74 @@ +using Fengling.Backend.Domain.AggregatesModel.MarketingCodeAggregate; +using Fengling.Backend.Domain.AggregatesModel.MemberAggregate; +using Fengling.Backend.Infrastructure.Repositories; + +namespace Fengling.Backend.Web.Application.Commands.MarketingCodes; + +/// +/// 使用营销码命令(扫码) +/// +public record UseMarketingCodeCommand(string Code, MemberId MemberId) : ICommand; + +/// +/// 使用营销码响应 +/// +public record UseMarketingCodeResponse( + MarketingCodeId MarketingCodeId, + string ProductName, + int EarnedPoints, + string Message); + +/// +/// 使用营销码命令验证器 +/// +public class UseMarketingCodeCommandValidator : AbstractValidator +{ + public UseMarketingCodeCommandValidator() + { + RuleFor(x => x.Code) + .NotEmpty().WithMessage("营销码不能为空") + .MaximumLength(50).WithMessage("营销码格式不正确"); + + RuleFor(x => x.MemberId) + .NotEmpty().WithMessage("会员ID不能为空"); + } +} + +/// +/// 使用营销码命令处理器 +/// +public class UseMarketingCodeCommandHandler( + IMarketingCodeRepository marketingCodeRepository, + IMemberRepository memberRepository) + : ICommandHandler +{ + public async Task Handle( + UseMarketingCodeCommand command, + CancellationToken cancellationToken) + { + // 1. 查询营销码 + var marketingCode = await marketingCodeRepository.GetByCodeAsync(command.Code, cancellationToken) + ?? throw new KnownException("营销码不存在"); + + // 2. 查询会员 + var member = await memberRepository.GetAsync(command.MemberId, cancellationToken) + ?? throw new KnownException("会员不存在"); + + // 3. 检查会员状态 + if (member.Status == MemberStatus.Disabled) + throw new KnownException("该账号已被禁用,无法获取积分"); + + // 4. 标记营销码为已使用(会触发MarketingCodeUsedDomainEvent) + marketingCode.MarkAsUsed(command.MemberId.Id); + + // 5. 更新营销码 + await marketingCodeRepository.UpdateAsync(marketingCode, cancellationToken); + + // 注意:积分发放由领域事件处理器异步处理(MarketingCodeUsedDomainEventHandler) + return new UseMarketingCodeResponse( + marketingCode.Id, + marketingCode.ProductInfo.ProductName, + 0, // 实际积分由事件处理器计算 + "扫码成功,积分正在发放中..."); + } +} diff --git a/Backend/src/Fengling.Backend.Web/Application/Commands/Members/LoginMemberCommand.cs b/Backend/src/Fengling.Backend.Web/Application/Commands/Members/LoginMemberCommand.cs new file mode 100644 index 0000000..f926c07 --- /dev/null +++ b/Backend/src/Fengling.Backend.Web/Application/Commands/Members/LoginMemberCommand.cs @@ -0,0 +1,69 @@ +using Fengling.Backend.Domain.AggregatesModel.MemberAggregate; +using Fengling.Backend.Infrastructure.Repositories; + +namespace Fengling.Backend.Web.Application.Commands.Members; + +/// +/// 会员登录命令 +/// +public record LoginMemberCommand(string Phone, string Password) : ICommand; + +/// +/// 登录响应 +/// +public record LoginMemberResponse(MemberId MemberId, string Token); + +/// +/// 会员登录命令验证器 +/// +public class LoginMemberCommandValidator : AbstractValidator +{ + public LoginMemberCommandValidator() + { + RuleFor(x => x.Phone) + .NotEmpty().WithMessage("手机号不能为空"); + + RuleFor(x => x.Password) + .NotEmpty().WithMessage("密码不能为空"); + } +} + +/// +/// 会员登录命令处理器 +/// +public class LoginMemberCommandHandler( + IMemberRepository memberRepository) + : ICommandHandler +{ + public async Task Handle(LoginMemberCommand command, CancellationToken cancellationToken) + { + // 查询会员 + var member = await memberRepository.GetByPhoneAsync(command.Phone, cancellationToken) + ?? throw new KnownException("手机号或密码错误"); + + // 验证密码 + var hashedPassword = HashPassword(command.Password); + if (member.Password != hashedPassword) + throw new KnownException("手机号或密码错误"); + + // 检查状态 + if (member.Status == MemberStatus.Disabled) + throw new KnownException("该账号已被禁用"); + + // 生成Token(这里简化处理) + var token = GenerateToken(member.Id); + + return new LoginMemberResponse(member.Id, token); + } + + private static string HashPassword(string password) + { + return Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(password)); + } + + private static string GenerateToken(MemberId memberId) + { + // TODO: 实际项目中应使用JWT + return Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes($"Member:{memberId}:{DateTime.UtcNow:O}")); + } +} diff --git a/Backend/src/Fengling.Backend.Web/Application/Commands/Members/RegisterMemberCommand.cs b/Backend/src/Fengling.Backend.Web/Application/Commands/Members/RegisterMemberCommand.cs new file mode 100644 index 0000000..d171a28 --- /dev/null +++ b/Backend/src/Fengling.Backend.Web/Application/Commands/Members/RegisterMemberCommand.cs @@ -0,0 +1,62 @@ +using Fengling.Backend.Domain.AggregatesModel.MemberAggregate; +using Fengling.Backend.Infrastructure.Repositories; + +namespace Fengling.Backend.Web.Application.Commands.Members; + +/// +/// 注册会员命令 +/// +public record RegisterMemberCommand(string Phone, string Password, string? Nickname = null) : ICommand; + +/// +/// 注册会员命令验证器 +/// +public class RegisterMemberCommandValidator : AbstractValidator +{ + public RegisterMemberCommandValidator() + { + RuleFor(x => x.Phone) + .NotEmpty().WithMessage("手机号不能为空") + .Matches(@"^1[3-9]\d{9}$").WithMessage("手机号格式不正确"); + + RuleFor(x => x.Password) + .NotEmpty().WithMessage("密码不能为空") + .MinimumLength(6).WithMessage("密码至少6位") + .MaximumLength(20).WithMessage("密码最多20位"); + + RuleFor(x => x.Nickname) + .MaximumLength(50).WithMessage("昵称最多50个字符"); + } +} + +/// +/// 注册会员命令处理器 +/// +public class RegisterMemberCommandHandler( + IMemberRepository memberRepository) + : ICommandHandler +{ + public async Task Handle(RegisterMemberCommand command, CancellationToken cancellationToken) + { + // 检查手机号是否已存在 + if (await memberRepository.PhoneExistsAsync(command.Phone, cancellationToken)) + throw new KnownException("该手机号已注册"); + + // 密码加密(这里简化处理,实际应使用BCrypt等) + var hashedPassword = HashPassword(command.Password); + + // 创建会员 + var member = new Member(command.Phone, hashedPassword, command.Nickname); + + // 保存 + await memberRepository.AddAsync(member, cancellationToken); + + return member.Id; + } + + private static string HashPassword(string password) + { + // TODO: 实际项目中应使用BCrypt.Net或AspNetCore.Identity的PasswordHasher + return Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(password)); + } +} diff --git a/Backend/src/Fengling.Backend.Web/Application/Commands/PointsRules/CreatePointsRuleCommand.cs b/Backend/src/Fengling.Backend.Web/Application/Commands/PointsRules/CreatePointsRuleCommand.cs new file mode 100644 index 0000000..e1db12e --- /dev/null +++ b/Backend/src/Fengling.Backend.Web/Application/Commands/PointsRules/CreatePointsRuleCommand.cs @@ -0,0 +1,79 @@ +using Fengling.Backend.Domain.AggregatesModel.PointsRuleAggregate; +using Fengling.Backend.Infrastructure.Repositories; + +namespace Fengling.Backend.Web.Application.Commands.PointsRules; + +/// +/// 创建积分规则命令 +/// +public record CreatePointsRuleCommand( + string RuleName, + PointsRuleType RuleType, + int PointsValue, + DateTime StartDate, + DateTime? EndDate = null, + Guid? ProductId = null, + Guid? CategoryId = null, + string? MemberLevelCode = null, + decimal BonusMultiplier = 1.0m) : ICommand; + +/// +/// 创建积分规则命令验证器 +/// +public class CreatePointsRuleCommandValidator : AbstractValidator +{ + public CreatePointsRuleCommandValidator() + { + RuleFor(x => x.RuleName) + .NotEmpty().WithMessage("规则名称不能为空") + .MaximumLength(100).WithMessage("规则名称最多100个字符"); + + RuleFor(x => x.PointsValue) + .GreaterThan(0).WithMessage("积分值必须大于0"); + + RuleFor(x => x.BonusMultiplier) + .GreaterThan(0).WithMessage("奖励倍数必须大于0"); + + RuleFor(x => x.StartDate) + .NotEmpty().WithMessage("生效开始时间不能为空"); + } +} + +/// +/// 创建积分规则命令处理器 +/// +public class CreatePointsRuleCommandHandler( + IPointsRuleRepository pointsRuleRepository) + : ICommandHandler +{ + public async Task Handle(CreatePointsRuleCommand command, CancellationToken cancellationToken) + { + // 检查是否存在冲突的规则(同维度) + if (await pointsRuleRepository.HasConflictingRuleAsync( + command.ProductId, + command.CategoryId, + command.MemberLevelCode, + command.StartDate, + command.EndDate, + cancellationToken)) + { + throw new KnownException("存在冲突的积分规则,同一维度和时间范围内不允许重复规则"); + } + + // 创建规则 + var rule = new PointsRule( + command.RuleName, + command.RuleType, + command.PointsValue, + command.StartDate, + command.EndDate, + command.ProductId, + command.CategoryId, + command.MemberLevelCode, + command.BonusMultiplier); + + await pointsRuleRepository.AddAsync(rule, cancellationToken); + + return rule.Id; + } +} diff --git a/Backend/src/Fengling.Backend.Web/Application/Commands/RedemptionOrders/RedemptionOrderCommands.cs b/Backend/src/Fengling.Backend.Web/Application/Commands/RedemptionOrders/RedemptionOrderCommands.cs new file mode 100644 index 0000000..46cb9dc --- /dev/null +++ b/Backend/src/Fengling.Backend.Web/Application/Commands/RedemptionOrders/RedemptionOrderCommands.cs @@ -0,0 +1,173 @@ +using Fengling.Backend.Domain.AggregatesModel.RedemptionOrderAggregate; +using Fengling.Backend.Domain.AggregatesModel.GiftAggregate; +using Fengling.Backend.Domain.AggregatesModel.MemberAggregate; +using Fengling.Backend.Infrastructure.Repositories; + +namespace Fengling.Backend.Web.Application.Commands.RedemptionOrders; + +/// +/// 创建兑换订单命令 +/// +public record CreateRedemptionOrderCommand( + Guid MemberId, + Guid GiftId, + int Quantity, + AddressDto? ShippingAddress = null) : ICommand; + +public record AddressDto( + string ReceiverName, + string Phone, + string Province, + string City, + string District, + string DetailAddress); + +public class CreateRedemptionOrderCommandHandler( + IRedemptionOrderRepository redemptionOrderRepository, + IGiftRepository giftRepository, + IMemberRepository memberRepository) : ICommandHandler +{ + public async Task Handle(CreateRedemptionOrderCommand request, CancellationToken cancellationToken) + { + // 1. 获取礼品信息 + var giftId = new GiftId(request.GiftId); + var gift = await giftRepository.GetByIdAsync(giftId, cancellationToken); + if (gift == null) + throw new KnownException("礼品不存在"); + + if (!gift.IsOnShelf) + throw new KnownException("礼品已下架"); + + // 2. 检查库存 + if (gift.AvailableStock < request.Quantity) + throw new KnownException($"库存不足,当前可用:{gift.AvailableStock}"); + + // 3. 检查限兑数量 + if (gift.LimitPerMember.HasValue) + { + var redeemedCount = await redemptionOrderRepository.GetMemberRedemptionCountAsync( + request.MemberId, request.GiftId, cancellationToken); + + if (redeemedCount + request.Quantity > gift.LimitPerMember.Value) + throw new KnownException($"超出限兑数量,每人限兑{gift.LimitPerMember.Value}个,已兑换{redeemedCount}个"); + } + + // 4. 获取会员信息并检查积分 + var memberId = new MemberId(request.MemberId); + var member = await memberRepository.GetAsync(memberId, cancellationToken); + if (member == null) + throw new KnownException("会员不存在"); + + var consumedPoints = gift.RequiredPoints * request.Quantity; + if (member.AvailablePoints < consumedPoints) + throw new KnownException($"积分不足,需要{consumedPoints}分,当前{member.AvailablePoints}分"); + + // 5. 扣减积分 + member.ConsumePoints(consumedPoints, "兑换礼品", Guid.NewGuid()); + await memberRepository.UpdateAsync(member, cancellationToken); + + // 6. 构造收货地址 + Address? shippingAddress = null; + if (request.ShippingAddress != null) + { + shippingAddress = new Address( + request.ShippingAddress.ReceiverName, + request.ShippingAddress.Phone, + request.ShippingAddress.Province, + request.ShippingAddress.City, + request.ShippingAddress.District, + request.ShippingAddress.DetailAddress); + } + else if (gift.Type == GiftType.Physical) + { + throw new KnownException("实物礼品必须提供收货地址"); + } + + // 7. 生成订单号 + var orderNo = $"RO{DateTime.UtcNow:yyyyMMddHHmmss}{Random.Shared.Next(1000, 9999)}"; + + // 8. 创建订单 + var order = new RedemptionOrder( + orderNo, + request.MemberId, + request.GiftId, + gift.Name, + (int)gift.Type, + request.Quantity, + consumedPoints, + shippingAddress); + + await redemptionOrderRepository.AddAsync(order, cancellationToken); + + return order.Id; + } +} + +/// +/// 标记订单为已发货命令 +/// +public record MarkOrderAsDispatchedCommand(Guid OrderId, string? TrackingNo = null) : ICommand; + +public class MarkOrderAsDispatchedCommandHandler( + IRedemptionOrderRepository redemptionOrderRepository) : ICommandHandler +{ + public async Task Handle(MarkOrderAsDispatchedCommand request, CancellationToken cancellationToken) + { + var orderId = new RedemptionOrderId(request.OrderId); + var order = await redemptionOrderRepository.GetByIdAsync(orderId, cancellationToken); + + if (order == null) + throw new KnownException("订单不存在"); + + order.MarkAsDispatched(request.TrackingNo); + await redemptionOrderRepository.UpdateAsync(order, cancellationToken); + + return new ResponseData(); + } +} + +/// +/// 完成订单命令 +/// +public record CompleteOrderCommand(Guid OrderId) : ICommand; + +public class CompleteOrderCommandHandler( + IRedemptionOrderRepository redemptionOrderRepository) : ICommandHandler +{ + public async Task Handle(CompleteOrderCommand request, CancellationToken cancellationToken) + { + var orderId = new RedemptionOrderId(request.OrderId); + var order = await redemptionOrderRepository.GetByIdAsync(orderId, cancellationToken); + + if (order == null) + throw new KnownException("订单不存在"); + + order.Complete(); + await redemptionOrderRepository.UpdateAsync(order, cancellationToken); + + return new ResponseData(); + } +} + +/// +/// 取消订单命令 +/// +public record CancelOrderCommand(Guid OrderId, string Reason) : ICommand; + +public class CancelOrderCommandHandler( + IRedemptionOrderRepository redemptionOrderRepository) : ICommandHandler +{ + public async Task Handle(CancelOrderCommand request, CancellationToken cancellationToken) + { + var orderId = new RedemptionOrderId(request.OrderId); + var order = await redemptionOrderRepository.GetByIdAsync(orderId, cancellationToken); + + if (order == null) + throw new KnownException("订单不存在"); + + order.Cancel(request.Reason); + await redemptionOrderRepository.UpdateAsync(order, cancellationToken); + + return new ResponseData(); + } +} diff --git a/Backend/src/Fengling.Backend.Web/Application/DomainEventHandlers/MarketingCodeUsedDomainEventHandlerForEarnPoints.cs b/Backend/src/Fengling.Backend.Web/Application/DomainEventHandlers/MarketingCodeUsedDomainEventHandlerForEarnPoints.cs new file mode 100644 index 0000000..dd86cbe --- /dev/null +++ b/Backend/src/Fengling.Backend.Web/Application/DomainEventHandlers/MarketingCodeUsedDomainEventHandlerForEarnPoints.cs @@ -0,0 +1,77 @@ +using Fengling.Backend.Domain.DomainEvents; +using Fengling.Backend.Domain.AggregatesModel.MemberAggregate; +using Fengling.Backend.Infrastructure.Repositories; + +namespace Fengling.Backend.Web.Application.DomainEventHandlers; + +/// +/// 营销码使用领域事件处理器-发放积分 +/// 核心业务流程:扫码 → 匹配规则 → 计算积分 → 发放积分 +/// 注意:积分交易记录由集成事件处理器异步创建,保证解耦和幂等性 +/// +public class MarketingCodeUsedDomainEventHandlerForEarnPoints( + IMemberRepository memberRepository, + IPointsRuleRepository pointsRuleRepository, + ILogger logger) + : IDomainEventHandler +{ + public async Task Handle(MarketingCodeUsedDomainEvent domainEvent, CancellationToken cancellationToken) + { + var marketingCode = domainEvent.MarketingCode; + var memberId = new MemberId(domainEvent.MemberId); + + logger.LogInformation("开始处理营销码使用事件,营销码:{Code},会员:{MemberId}", + marketingCode.Code, memberId); + + try + { + // 1. 查询会员 + var member = await memberRepository.GetAsync(memberId, cancellationToken); + if (member == null) + { + logger.LogError("会员不存在,无法发放积分.会员ID:{MemberId}", memberId); + return; + } + + // 2. 获取有效的积分规则 + var rules = await pointsRuleRepository.GetEffectiveRulesAsync( + marketingCode.ProductInfo.ProductId, + marketingCode.ProductInfo.CategoryId, + member.Level.LevelCode, + DateTime.UtcNow, + cancellationToken); + + if (rules.Count == 0) + { + logger.LogWarning("未找到匹配的积分规则,无法发放积分.产品:{ProductId},会员等级:{LevelCode}", + marketingCode.ProductInfo.ProductId, member.Level.LevelCode); + return; + } + + // 3. 计算应得积分(取最高值或累加,这里简化为取最高值) + var totalPoints = rules.Max(r => r.CalculatePoints()); + + // 4. 计算积分过期时间(默认1年) + var expiryDate = DateTime.UtcNow.AddYears(1); + + // 5. 发放积分(会触发PointsAddedDomainEvent → 转换为PointsEarnedIntegrationEvent → 创建积分交易记录) + member.AddPoints( + totalPoints, + $"扫码获得-{marketingCode.ProductInfo.ProductName}", + marketingCode.Id.Id, + expiryDate); + + // 6. 更新会员 + await memberRepository.UpdateAsync(member, cancellationToken); + + logger.LogInformation("积分发放成功.会员:{MemberId},积分:{Points},营销码:{Code}", + memberId, totalPoints, marketingCode.Code); + } + catch (Exception ex) + { + logger.LogError(ex, "处理营销码使用事件失败.营销码:{Code},会员:{MemberId}", + marketingCode.Code, memberId); + throw; + } + } +} diff --git a/Backend/src/Fengling.Backend.Web/Application/DomainEventHandlers/RedemptionOrderDomainEventHandlers.cs b/Backend/src/Fengling.Backend.Web/Application/DomainEventHandlers/RedemptionOrderDomainEventHandlers.cs new file mode 100644 index 0000000..e202770 --- /dev/null +++ b/Backend/src/Fengling.Backend.Web/Application/DomainEventHandlers/RedemptionOrderDomainEventHandlers.cs @@ -0,0 +1,95 @@ +using Fengling.Backend.Domain.DomainEvents; +using Fengling.Backend.Domain.AggregatesModel.GiftAggregate; +using Fengling.Backend.Infrastructure.Repositories; + +namespace Fengling.Backend.Web.Application.DomainEventHandlers; + +/// +/// 兑换订单创建领域事件处理器:预留库存 +/// +public class RedemptionOrderCreatedDomainEventHandler( + IGiftRepository giftRepository, + ILogger logger) + : IDomainEventHandler +{ + public async Task Handle(RedemptionOrderCreatedDomainEvent notification, CancellationToken cancellationToken) + { + logger.LogInformation("处理兑换订单创建事件:预留库存. 订单ID:{OrderId}, 礼品ID:{GiftId}, 数量:{Quantity}", + notification.Order.Id, notification.Order.GiftId, notification.Order.Quantity); + + var giftId = new GiftId(notification.Order.GiftId); + var gift = await giftRepository.GetByIdAsync(giftId, cancellationToken); + + if (gift == null) + { + logger.LogError("礼品不存在,无法预留库存. 礼品ID:{GiftId}", notification.Order.GiftId); + throw new KnownException("礼品不存在"); + } + + gift.ReserveStock(notification.Order.Quantity); + await giftRepository.UpdateAsync(gift, cancellationToken); + + logger.LogInformation("库存预留成功. 礼品ID:{GiftId}, 预留数量:{Quantity}, 剩余可用:{Available}", + notification.Order.GiftId, notification.Order.Quantity, gift.AvailableStock); + } +} + +/// +/// 兑换订单取消领域事件处理器:释放库存和退还积分 +/// +public class RedemptionOrderCancelledDomainEventHandler( + IGiftRepository giftRepository, + ILogger logger) + : IDomainEventHandler +{ + public async Task Handle(RedemptionOrderCancelledDomainEvent notification, CancellationToken cancellationToken) + { + logger.LogInformation("处理兑换订单取消事件:释放库存和退还积分. 订单ID:{OrderId}, 礼品ID:{GiftId}", + notification.Order.Id, notification.Order.GiftId); + + var giftId = new GiftId(notification.Order.GiftId); + var gift = await giftRepository.GetByIdAsync(giftId, cancellationToken); + + if (gift == null) + { + logger.LogError("礼品不存在,无法释放库存. 礼品ID:{GiftId}", notification.Order.GiftId); + return; + } + + gift.ReleaseStock(notification.Order.Quantity); + await giftRepository.UpdateAsync(gift, cancellationToken); + + logger.LogInformation("库存释放成功. 礼品ID:{GiftId}, 释放数量:{Quantity}, 当前可用:{Available}", + notification.Order.GiftId, notification.Order.Quantity, gift.AvailableStock); + } +} + +/// +/// 兑换订单发货领域事件处理器:扣减总库存 +/// +public class RedemptionOrderDispatchedDomainEventHandler( + IGiftRepository giftRepository, + ILogger logger) + : IDomainEventHandler +{ + public async Task Handle(RedemptionOrderDispatchedDomainEvent notification, CancellationToken cancellationToken) + { + logger.LogInformation("处理兑换订单发货事件:扣减总库存. 订单ID:{OrderId}, 礼品ID:{GiftId}", + notification.Order.Id, notification.Order.GiftId); + + var giftId = new GiftId(notification.Order.GiftId); + var gift = await giftRepository.GetByIdAsync(giftId, cancellationToken); + + if (gift == null) + { + logger.LogError("礼品不存在,无法扣减库存. 礼品ID:{GiftId}", notification.Order.GiftId); + return; + } + + gift.DeductStock(notification.Order.Quantity); + await giftRepository.UpdateAsync(gift, cancellationToken); + + logger.LogInformation("库存扣减成功. 礼品ID:{GiftId}, 扣减数量:{Quantity}", + notification.Order.GiftId, notification.Order.Quantity); + } +} diff --git a/Backend/src/Fengling.Backend.Web/Application/Hubs/ChatHub.cs b/Backend/src/Fengling.Backend.Web/Application/Hubs/ChatHub.cs new file mode 100644 index 0000000..3aa0275 --- /dev/null +++ b/Backend/src/Fengling.Backend.Web/Application/Hubs/ChatHub.cs @@ -0,0 +1,14 @@ +namespace Fengling.Backend.Web.Application.Hubs; + +public interface IChatClient +{ + Task ReceiveMessage(string user, string message); +} + +public class ChatHub : Microsoft.AspNetCore.SignalR.Hub +{ + public async Task SendMessage(string user, string message) + { + await Clients.All.ReceiveMessage(user, message); + } +} diff --git a/Backend/src/Fengling.Backend.Web/Application/IntegrationEventConverters/PointsIntegrationEventConverters.cs b/Backend/src/Fengling.Backend.Web/Application/IntegrationEventConverters/PointsIntegrationEventConverters.cs new file mode 100644 index 0000000..1413969 --- /dev/null +++ b/Backend/src/Fengling.Backend.Web/Application/IntegrationEventConverters/PointsIntegrationEventConverters.cs @@ -0,0 +1,69 @@ +using Fengling.Backend.Domain.DomainEvents; +using Fengling.Backend.Domain.IntegrationEvents; + +namespace Fengling.Backend.Web.Application.IntegrationEventConverters; + +/// +/// 积分增加领域事件转集成事件转换器 +/// +public class PointsAddedToPointsEarnedConverter + : IIntegrationEventConverter +{ + public PointsEarnedIntegrationEvent Convert(PointsAddedDomainEvent domainEvent) + { + return new PointsEarnedIntegrationEvent( + domainEvent.MemberId.Id, + domainEvent.Amount, + domainEvent.Source, + domainEvent.Source, // Reason使用Source + domainEvent.RelatedId, + domainEvent.ExpiryDate); + } +} + +/// +/// 积分消费领域事件转集成事件转换器 +/// +public class PointsConsumedToIntegrationEventConverter + : IIntegrationEventConverter +{ + public PointsConsumedIntegrationEvent Convert(PointsConsumedDomainEvent domainEvent) + { + return new PointsConsumedIntegrationEvent( + domainEvent.MemberId.Id, + domainEvent.Amount, + domainEvent.Reason, + domainEvent.OrderId); + } +} + +/// +/// 积分退还领域事件转集成事件转换器 +/// +public class PointsRefundedToIntegrationEventConverter + : IIntegrationEventConverter +{ + public PointsRefundedIntegrationEvent Convert(PointsRefundedDomainEvent domainEvent) + { + return new PointsRefundedIntegrationEvent( + domainEvent.MemberId.Id, + domainEvent.Amount, + domainEvent.Reason, + domainEvent.OrderId); + } +} + +/// +/// 积分过期领域事件转集成事件转换器 +/// +public class PointsExpiredToIntegrationEventConverter + : IIntegrationEventConverter +{ + public PointsExpiredIntegrationEvent Convert(PointsExpiredDomainEvent domainEvent) + { + return new PointsExpiredIntegrationEvent( + domainEvent.MemberId.Id, + domainEvent.Amount, + Guid.NewGuid()); // BatchId生成新的 + } +} diff --git a/Backend/src/Fengling.Backend.Web/Application/IntegrationEventHandlers/PointsIntegrationEventHandlers.cs b/Backend/src/Fengling.Backend.Web/Application/IntegrationEventHandlers/PointsIntegrationEventHandlers.cs new file mode 100644 index 0000000..f81a94b --- /dev/null +++ b/Backend/src/Fengling.Backend.Web/Application/IntegrationEventHandlers/PointsIntegrationEventHandlers.cs @@ -0,0 +1,202 @@ +using Fengling.Backend.Domain.AggregatesModel.MemberAggregate; +using Fengling.Backend.Domain.AggregatesModel.PointsTransactionAggregate; +using Fengling.Backend.Domain.IntegrationEvents; +using Fengling.Backend.Infrastructure; + +namespace Fengling.Backend.Web.Application.IntegrationEventHandlers; + +/// +/// 积分获得集成事件处理器 +/// +public class PointsEarnedIntegrationEventHandler( + ApplicationDbContext dbContext, + ILogger logger) + : IIntegrationEventHandler +{ + public async Task HandleAsync(PointsEarnedIntegrationEvent integrationEvent, CancellationToken cancellationToken) + { + logger.LogInformation("接收到积分获得集成事件. 会员:{MemberId}, 积分:{Amount}, 关联ID:{RelatedId}", + integrationEvent.MemberId, integrationEvent.Amount, integrationEvent.RelatedId); + + try + { + // 幂等性检查:基于RelatedId判断是否已创建过交易记录 + var exists = await dbContext.PointsTransactions + .AnyAsync(x => x.RelatedId == integrationEvent.RelatedId, cancellationToken); + + if (exists) + { + logger.LogWarning("积分交易记录已存在,跳过处理. 关联ID:{RelatedId}", integrationEvent.RelatedId); + return; + } + + // 创建积分交易记录 + var memberId = new MemberId(integrationEvent.MemberId); + var transaction = PointsTransaction.CreateEarnTransaction( + memberId, + integrationEvent.Amount, + integrationEvent.Source, + integrationEvent.Reason, + integrationEvent.RelatedId, + integrationEvent.ExpiryDate); + + await dbContext.PointsTransactions.AddAsync(transaction, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + + logger.LogInformation("积分交易记录创建成功. 会员:{MemberId}, 积分:{Amount}, 交易ID:{TransactionId}", + integrationEvent.MemberId, integrationEvent.Amount, transaction.Id); + } + catch (Exception ex) + { + logger.LogError(ex, "处理积分获得集成事件失败. 会员:{MemberId}, 积分:{Amount}", + integrationEvent.MemberId, integrationEvent.Amount); + throw; + } + } +} + +/// +/// 积分消费集成事件处理器 +/// +public class PointsConsumedIntegrationEventHandler( + ApplicationDbContext dbContext, + ILogger logger) + : IIntegrationEventHandler +{ + public async Task HandleAsync(PointsConsumedIntegrationEvent integrationEvent, CancellationToken cancellationToken) + { + logger.LogInformation("接收到积分消费集成事件. 会员:{MemberId}, 积分:{Amount}, 订单ID:{OrderId}", + integrationEvent.MemberId, integrationEvent.Amount, integrationEvent.OrderId); + + try + { + // 幂等性检查:基于OrderId + Consume类型判断是否已创建过交易记录 + var exists = await dbContext.PointsTransactions + .AnyAsync(x => x.RelatedId == integrationEvent.OrderId + && x.Type == PointsTransactionType.Consume, cancellationToken); + + if (exists) + { + logger.LogWarning("积分消费记录已存在,跳过处理. 订单ID:{OrderId}", integrationEvent.OrderId); + return; + } + + // 创建积分交易记录 + var memberId = new MemberId(integrationEvent.MemberId); + var transaction = PointsTransaction.CreateConsumeTransaction( + memberId, + integrationEvent.Amount, + integrationEvent.Reason, + integrationEvent.OrderId); + + await dbContext.PointsTransactions.AddAsync(transaction, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + + logger.LogInformation("积分消费记录创建成功. 会员:{MemberId}, 积分:{Amount}, 交易ID:{TransactionId}", + integrationEvent.MemberId, integrationEvent.Amount, transaction.Id); + } + catch (Exception ex) + { + logger.LogError(ex, "处理积分消费集成事件失败. 会员:{MemberId}, 积分:{Amount}", + integrationEvent.MemberId, integrationEvent.Amount); + throw; + } + } +} + +/// +/// 积分退还集成事件处理器 +/// +public class PointsRefundedIntegrationEventHandler( + ApplicationDbContext dbContext, + ILogger logger) + : IIntegrationEventHandler +{ + public async Task HandleAsync(PointsRefundedIntegrationEvent integrationEvent, CancellationToken cancellationToken) + { + logger.LogInformation("接收到积分退还集成事件. 会员:{MemberId}, 积分:{Amount}, 订单ID:{OrderId}", + integrationEvent.MemberId, integrationEvent.Amount, integrationEvent.OrderId); + + try + { + // 幂等性检查:基于OrderId + Refund类型判断是否已创建过交易记录 + var exists = await dbContext.PointsTransactions + .AnyAsync(x => x.RelatedId == integrationEvent.OrderId + && x.Type == PointsTransactionType.Refund, cancellationToken); + + if (exists) + { + logger.LogWarning("积分退还记录已存在,跳过处理. 订单ID:{OrderId}", integrationEvent.OrderId); + return; + } + + // 创建积分交易记录 + var memberId = new MemberId(integrationEvent.MemberId); + var transaction = PointsTransaction.CreateRefundTransaction( + memberId, + integrationEvent.Amount, + integrationEvent.Reason, + integrationEvent.OrderId); + + await dbContext.PointsTransactions.AddAsync(transaction, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + + logger.LogInformation("积分退还记录创建成功. 会员:{MemberId}, 积分:{Amount}, 交易ID:{TransactionId}", + integrationEvent.MemberId, integrationEvent.Amount, transaction.Id); + } + catch (Exception ex) + { + logger.LogError(ex, "处理积分退还集成事件失败. 会员:{MemberId}, 积分:{Amount}", + integrationEvent.MemberId, integrationEvent.Amount); + throw; + } + } +} + +/// +/// 积分过期集成事件处理器 +/// +public class PointsExpiredIntegrationEventHandler( + ApplicationDbContext dbContext, + ILogger logger) + : IIntegrationEventHandler +{ + public async Task HandleAsync(PointsExpiredIntegrationEvent integrationEvent, CancellationToken cancellationToken) + { + logger.LogInformation("接收到积分过期集成事件. 会员:{MemberId}, 积分:{Amount}, 批次ID:{BatchId}", + integrationEvent.MemberId, integrationEvent.Amount, integrationEvent.BatchId); + + try + { + // 幂等性检查:基于BatchId判断是否已创建过交易记录 + var exists = await dbContext.PointsTransactions + .AnyAsync(x => x.RelatedId == integrationEvent.BatchId + && x.Type == PointsTransactionType.Expire, cancellationToken); + + if (exists) + { + logger.LogWarning("积分过期记录已存在,跳过处理. 批次ID:{BatchId}", integrationEvent.BatchId); + return; + } + + // 创建积分交易记录 + var memberId = new MemberId(integrationEvent.MemberId); + var transaction = PointsTransaction.CreateExpireTransaction( + memberId, + integrationEvent.Amount, + integrationEvent.BatchId); + + await dbContext.PointsTransactions.AddAsync(transaction, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + + logger.LogInformation("积分过期记录创建成功. 会员:{MemberId}, 积分:{Amount}, 交易ID:{TransactionId}", + integrationEvent.MemberId, integrationEvent.Amount, transaction.Id); + } + catch (Exception ex) + { + logger.LogError(ex, "处理积分过期集成事件失败. 会员:{MemberId}, 积分:{Amount}", + integrationEvent.MemberId, integrationEvent.Amount); + throw; + } + } +} diff --git a/Backend/src/Fengling.Backend.Web/Application/Queries/Gifts/GiftQueries.cs b/Backend/src/Fengling.Backend.Web/Application/Queries/Gifts/GiftQueries.cs new file mode 100644 index 0000000..6a79c22 --- /dev/null +++ b/Backend/src/Fengling.Backend.Web/Application/Queries/Gifts/GiftQueries.cs @@ -0,0 +1,96 @@ +using Fengling.Backend.Infrastructure; + +namespace Fengling.Backend.Web.Application.Queries.Gifts; + +/// +/// 礼品列表查询 +/// +public record GetGiftsQuery(int? Type = null, bool? IsOnShelf = null) : IQuery>; + +public record GiftDto +{ + public Guid Id { get; init; } + public string Name { get; init; } = string.Empty; + public int Type { get; init; } + public string Description { get; init; } = string.Empty; + public string ImageUrl { get; init; } = string.Empty; + public int RequiredPoints { get; init; } + public int TotalStock { get; init; } + public int AvailableStock { get; init; } + public int? LimitPerMember { get; init; } + public bool IsOnShelf { get; init; } + public int SortOrder { get; init; } + public DateTime CreatedAt { get; init; } +} + +public class GetGiftsQueryHandler(ApplicationDbContext dbContext) : IQueryHandler> +{ + public async Task> Handle(GetGiftsQuery request, CancellationToken cancellationToken) + { + var query = dbContext.Gifts.AsQueryable(); + + if (request.Type.HasValue) + { + query = query.Where(x => (int)x.Type == request.Type.Value); + } + + if (request.IsOnShelf.HasValue) + { + query = query.Where(x => x.IsOnShelf == request.IsOnShelf.Value); + } + + var gifts = await query + .OrderBy(x => x.SortOrder) + .ThenByDescending(x => x.CreatedAt) + .Select(x => new GiftDto + { + Id = x.Id.Id, + Name = x.Name, + Type = (int)x.Type, + Description = x.Description, + ImageUrl = x.ImageUrl, + RequiredPoints = x.RequiredPoints, + TotalStock = x.TotalStock, + AvailableStock = x.AvailableStock, + LimitPerMember = x.LimitPerMember, + IsOnShelf = x.IsOnShelf, + SortOrder = x.SortOrder, + CreatedAt = x.CreatedAt + }) + .ToListAsync(cancellationToken); + + return gifts; + } +} + +/// +/// 礼品详情查询 +/// +public record GetGiftByIdQuery(Guid GiftId) : IQuery; + +public class GetGiftByIdQueryHandler(ApplicationDbContext dbContext) : IQueryHandler +{ + public async Task Handle(GetGiftByIdQuery request, CancellationToken cancellationToken) + { + var gift = await dbContext.Gifts + .Where(x => x.Id.Id == request.GiftId) + .Select(x => new GiftDto + { + Id = x.Id.Id, + Name = x.Name, + Type = (int)x.Type, + Description = x.Description, + ImageUrl = x.ImageUrl, + RequiredPoints = x.RequiredPoints, + TotalStock = x.TotalStock, + AvailableStock = x.AvailableStock, + LimitPerMember = x.LimitPerMember, + IsOnShelf = x.IsOnShelf, + SortOrder = x.SortOrder, + CreatedAt = x.CreatedAt + }) + .FirstOrDefaultAsync(cancellationToken); + + return gift; + } +} diff --git a/Backend/src/Fengling.Backend.Web/Application/Queries/Members/GetMemberQuery.cs b/Backend/src/Fengling.Backend.Web/Application/Queries/Members/GetMemberQuery.cs new file mode 100644 index 0000000..6b10e3b --- /dev/null +++ b/Backend/src/Fengling.Backend.Web/Application/Queries/Members/GetMemberQuery.cs @@ -0,0 +1,59 @@ +using Fengling.Backend.Domain.AggregatesModel.MemberAggregate; +using Fengling.Backend.Infrastructure; + +namespace Fengling.Backend.Web.Application.Queries.Members; + +/// +/// 查询会员信息 +/// +public record GetMemberQuery(MemberId MemberId) : IQuery; + +/// +/// 会员DTO +/// +public record MemberDto( + MemberId Id, + string Phone, + string Nickname, + string LevelCode, + string LevelName, + int TotalPoints, + int AvailablePoints, + string Status, + DateTime RegisteredAt); + +/// +/// 查询会员验证器 +/// +public class GetMemberQueryValidator : AbstractValidator +{ + public GetMemberQueryValidator() + { + RuleFor(x => x.MemberId).NotEmpty().WithMessage("会员ID不能为空"); + } +} + +/// +/// 查询会员处理器 +/// +public class GetMemberQueryHandler(ApplicationDbContext context) + : IQueryHandler +{ + public async Task Handle(GetMemberQuery request, CancellationToken cancellationToken) + { + return await context.Members + .Where(x => x.Id == request.MemberId) + .Select(x => new MemberDto( + x.Id, + x.Phone, + x.Nickname, + x.Level.LevelCode, + x.Level.LevelName, + x.TotalPoints, + x.AvailablePoints, + x.Status.ToString(), + x.RegisteredAt)) + .FirstOrDefaultAsync(cancellationToken) + ?? throw new KnownException($"未找到会员,ID:{request.MemberId}"); + } +} diff --git a/Backend/src/Fengling.Backend.Web/Application/Queries/RedemptionOrders/RedemptionOrderQueries.cs b/Backend/src/Fengling.Backend.Web/Application/Queries/RedemptionOrders/RedemptionOrderQueries.cs new file mode 100644 index 0000000..0c336a1 --- /dev/null +++ b/Backend/src/Fengling.Backend.Web/Application/Queries/RedemptionOrders/RedemptionOrderQueries.cs @@ -0,0 +1,129 @@ +using Fengling.Backend.Infrastructure; + +namespace Fengling.Backend.Web.Application.Queries.RedemptionOrders; + +/// +/// 兑换订单列表查询 +/// +public record GetRedemptionOrdersQuery(Guid? MemberId = null, int? Status = null) : IQuery>; + +public record RedemptionOrderDto +{ + public Guid Id { get; init; } + public string OrderNo { get; init; } = string.Empty; + public Guid MemberId { get; init; } + public Guid GiftId { get; init; } + public string GiftName { get; init; } = string.Empty; + public int GiftType { get; init; } + public int Quantity { get; init; } + public int ConsumedPoints { get; init; } + public AddressDto? ShippingAddress { get; init; } + public string? TrackingNo { get; init; } + public int Status { get; init; } + public string? CancelReason { get; init; } + public DateTime CreatedAt { get; init; } + public DateTime UpdatedAt { get; init; } +} + +public record AddressDto +{ + public string ReceiverName { get; init; } = string.Empty; + public string Phone { get; init; } = string.Empty; + public string Province { get; init; } = string.Empty; + public string City { get; init; } = string.Empty; + public string District { get; init; } = string.Empty; + public string DetailAddress { get; init; } = string.Empty; +} + +public class GetRedemptionOrdersQueryHandler(ApplicationDbContext dbContext) + : IQueryHandler> +{ + public async Task> Handle(GetRedemptionOrdersQuery request, CancellationToken cancellationToken) + { + var query = dbContext.RedemptionOrders.AsQueryable(); + + if (request.MemberId.HasValue) + { + query = query.Where(x => x.MemberId == request.MemberId.Value); + } + + if (request.Status.HasValue) + { + query = query.Where(x => (int)x.Status == request.Status.Value); + } + + var orders = await query + .OrderByDescending(x => x.CreatedAt) + .Select(x => new RedemptionOrderDto + { + Id = x.Id.Id, + OrderNo = x.OrderNo, + MemberId = x.MemberId, + GiftId = x.GiftId, + GiftName = x.GiftName, + GiftType = x.GiftType, + Quantity = x.Quantity, + ConsumedPoints = x.ConsumedPoints, + ShippingAddress = x.ShippingAddress == null ? null : new AddressDto + { + ReceiverName = x.ShippingAddress.ReceiverName, + Phone = x.ShippingAddress.Phone, + Province = x.ShippingAddress.Province, + City = x.ShippingAddress.City, + District = x.ShippingAddress.District, + DetailAddress = x.ShippingAddress.DetailAddress + }, + TrackingNo = x.TrackingNo, + Status = (int)x.Status, + CancelReason = x.CancelReason, + CreatedAt = x.CreatedAt, + UpdatedAt = x.UpdatedAt + }) + .ToListAsync(cancellationToken); + + return orders; + } +} + +/// +/// 订单详情查询 +/// +public record GetRedemptionOrderByIdQuery(Guid OrderId) : IQuery; + +public class GetRedemptionOrderByIdQueryHandler(ApplicationDbContext dbContext) + : IQueryHandler +{ + public async Task Handle(GetRedemptionOrderByIdQuery request, CancellationToken cancellationToken) + { + var order = await dbContext.RedemptionOrders + .Where(x => x.Id.Id == request.OrderId) + .Select(x => new RedemptionOrderDto + { + Id = x.Id.Id, + OrderNo = x.OrderNo, + MemberId = x.MemberId, + GiftId = x.GiftId, + GiftName = x.GiftName, + GiftType = x.GiftType, + Quantity = x.Quantity, + ConsumedPoints = x.ConsumedPoints, + ShippingAddress = x.ShippingAddress == null ? null : new AddressDto + { + ReceiverName = x.ShippingAddress.ReceiverName, + Phone = x.ShippingAddress.Phone, + Province = x.ShippingAddress.Province, + City = x.ShippingAddress.City, + District = x.ShippingAddress.District, + DetailAddress = x.ShippingAddress.DetailAddress + }, + TrackingNo = x.TrackingNo, + Status = (int)x.Status, + CancelReason = x.CancelReason, + CreatedAt = x.CreatedAt, + UpdatedAt = x.UpdatedAt + }) + .FirstOrDefaultAsync(cancellationToken); + + return order; + } +} diff --git a/Backend/src/Fengling.Backend.Web/Clients/IUserServiceClient.cs b/Backend/src/Fengling.Backend.Web/Clients/IUserServiceClient.cs new file mode 100644 index 0000000..0b185f1 --- /dev/null +++ b/Backend/src/Fengling.Backend.Web/Clients/IUserServiceClient.cs @@ -0,0 +1,11 @@ +using Refit; + +namespace Fengling.Backend.Web.Clients; + +public interface IUserServiceClient +{ + [Get("/users/{userId}")] + Task GetUserAsync(long userId); +} + +public record UserDto(string Name, string Email, string Phone); \ No newline at end of file diff --git a/Backend/src/Fengling.Backend.Web/Dockerfile b/Backend/src/Fengling.Backend.Web/Dockerfile new file mode 100644 index 0000000..eb8d42c --- /dev/null +++ b/Backend/src/Fengling.Backend.Web/Dockerfile @@ -0,0 +1,21 @@ +#See https://aka.ms/containerfastmode to understand how Visual Studio uses this Dockerfile to build your images for faster debugging. + +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base +WORKDIR /app +EXPOSE 80 +EXPOSE 443 + +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src +COPY . . +RUN dotnet restore "src/Fengling.Backend.Web/Fengling.Backend.Web.csproj" +WORKDIR "/src/src/Fengling.Backend.Web" +RUN dotnet build "Fengling.Backend.Web.csproj" -c Release -o /app/build + +FROM build AS publish +RUN dotnet publish "Fengling.Backend.Web.csproj" -c Release -o /app/publish + +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "Fengling.Backend.Web.dll"] diff --git a/Backend/src/Fengling.Backend.Web/Endpoints/Admin/CreatePointsRuleEndpoint.cs b/Backend/src/Fengling.Backend.Web/Endpoints/Admin/CreatePointsRuleEndpoint.cs new file mode 100644 index 0000000..8b033d9 --- /dev/null +++ b/Backend/src/Fengling.Backend.Web/Endpoints/Admin/CreatePointsRuleEndpoint.cs @@ -0,0 +1,53 @@ +using FastEndpoints; +using Fengling.Backend.Domain.AggregatesModel.PointsRuleAggregate; +using Fengling.Backend.Web.Application.Commands.PointsRules; + +namespace Fengling.Backend.Web.Endpoints.Admin; + +/// +/// 创建积分规则请求 +/// +public record CreatePointsRuleRequest( + string RuleName, + int RuleType, + int PointsValue, + DateTime StartDate, + DateTime? EndDate = null, + Guid? ProductId = null, + Guid? CategoryId = null, + string? MemberLevelCode = null, + decimal BonusMultiplier = 1.0m); + +/// +/// 创建积分规则响应 +/// +public record CreatePointsRuleResponse(PointsRuleId RuleId); + +/// +/// 创建积分规则端点 +/// +[Tags("Admin-PointsRules")] +[HttpPost("/api/admin/points-rules")] +[AllowAnonymous] +public class CreatePointsRuleEndpoint(IMediator mediator) + : Endpoint> +{ + public override async Task HandleAsync(CreatePointsRuleRequest req, CancellationToken ct) + { + var command = new CreatePointsRuleCommand( + req.RuleName, + (PointsRuleType)req.RuleType, + req.PointsValue, + req.StartDate, + req.EndDate, + req.ProductId, + req.CategoryId, + req.MemberLevelCode, + req.BonusMultiplier); + + var ruleId = await mediator.Send(command, ct); + + var response = new CreatePointsRuleResponse(ruleId); + await Send.OkAsync(response.AsResponseData(), ct); + } +} diff --git a/Backend/src/Fengling.Backend.Web/Endpoints/Admin/GenerateMarketingCodesEndpoint.cs b/Backend/src/Fengling.Backend.Web/Endpoints/Admin/GenerateMarketingCodesEndpoint.cs new file mode 100644 index 0000000..d962715 --- /dev/null +++ b/Backend/src/Fengling.Backend.Web/Endpoints/Admin/GenerateMarketingCodesEndpoint.cs @@ -0,0 +1,38 @@ +using FastEndpoints; +using Fengling.Backend.Web.Application.Commands.MarketingCodes; + +namespace Fengling.Backend.Web.Endpoints.Admin; + +/// +/// 生成营销码请求 +/// +public record GenerateMarketingCodesRequest( + string BatchNo, + Guid ProductId, + string ProductName, + int Quantity, + DateTime? ExpiryDate = null); + +/// +/// 生成营销码端点 +/// +[Tags("Admin-MarketingCodes")] +[HttpPost("/api/admin/marketing-codes/generate")] +[AllowAnonymous] +public class GenerateMarketingCodesEndpoint(IMediator mediator) + : Endpoint> +{ + public override async Task HandleAsync(GenerateMarketingCodesRequest req, CancellationToken ct) + { + var command = new GenerateMarketingCodesCommand( + req.BatchNo, + req.ProductId, + req.ProductName, + req.Quantity, + req.ExpiryDate); + + var result = await mediator.Send(command, ct); + + await Send.OkAsync(result.AsResponseData(), ct); + } +} diff --git a/Backend/src/Fengling.Backend.Web/Endpoints/Admin/Gifts/GiftEndpoints.cs b/Backend/src/Fengling.Backend.Web/Endpoints/Admin/Gifts/GiftEndpoints.cs new file mode 100644 index 0000000..140aeac --- /dev/null +++ b/Backend/src/Fengling.Backend.Web/Endpoints/Admin/Gifts/GiftEndpoints.cs @@ -0,0 +1,110 @@ +using FastEndpoints; +using Fengling.Backend.Web.Application.Commands.Gifts; +using Fengling.Backend.Web.Application.Queries.Gifts; + +namespace Fengling.Backend.Web.Endpoints.Admin.Gifts; + +/// +/// 创建礼品端点 +/// +[Tags("Admin/Gifts")] +[HttpPost("/api/admin/gifts")] +public class CreateGiftEndpoint(IMediator mediator) + : Endpoint> +{ + public override async Task HandleAsync(CreateGiftCommand req, CancellationToken ct) + { + var giftId = await mediator.Send(req, ct); + await Send.OkAsync(giftId.Id.AsResponseData(), ct); + } +} + +/// +/// 更新礼品端点 +/// +[Tags("Admin/Gifts")] +[HttpPut("/api/admin/gifts/{GiftId}")] +public class UpdateGiftEndpoint(IMediator mediator) + : Endpoint +{ + public override async Task HandleAsync(UpdateGiftCommand req, CancellationToken ct) + { + var result = await mediator.Send(req, ct); + await Send.OkAsync(result, ct); + } +} + +/// +/// 上架礼品端点 +/// +[Tags("Admin/Gifts")] +[HttpPost("/api/admin/gifts/{GiftId}/putonshelf")] +public class PutOnShelfEndpoint(IMediator mediator) + : Endpoint +{ + public override async Task HandleAsync(PutOnShelfCommand req, CancellationToken ct) + { + var result = await mediator.Send(req, ct); + await Send.OkAsync(result, ct); + } +} + +/// +/// 下架礼品端点 +/// +[Tags("Admin/Gifts")] +[HttpPost("/api/admin/gifts/{GiftId}/putoffshelf")] +public class PutOffShelfEndpoint(IMediator mediator) + : Endpoint +{ + public override async Task HandleAsync(PutOffShelfCommand req, CancellationToken ct) + { + var result = await mediator.Send(req, ct); + await Send.OkAsync(result, ct); + } +} + +/// +/// 增加库存端点 +/// +[Tags("Admin/Gifts")] +[HttpPost("/api/admin/gifts/{GiftId}/addstock")] +public class AddGiftStockEndpoint(IMediator mediator) + : Endpoint +{ + public override async Task HandleAsync(AddGiftStockCommand req, CancellationToken ct) + { + var result = await mediator.Send(req, ct); + await Send.OkAsync(result, ct); + } +} + +/// +/// 获取礼品列表端点 +/// +[Tags("Admin/Gifts")] +[HttpGet("/api/admin/gifts")] +public class GetGiftsEndpoint(IMediator mediator) + : Endpoint>> +{ + public override async Task HandleAsync(GetGiftsQuery req, CancellationToken ct) + { + var gifts = await mediator.Send(req, ct); + await Send.OkAsync(gifts.AsResponseData(), ct); + } +} + +/// +/// 获取礼品详情端点 +/// +[Tags("Admin/Gifts")] +[HttpGet("/api/admin/gifts/{GiftId}")] +public class GetGiftByIdEndpoint(IMediator mediator) + : Endpoint> +{ + public override async Task HandleAsync(GetGiftByIdQuery req, CancellationToken ct) + { + var gift = await mediator.Send(req, ct); + await Send.OkAsync(gift.AsResponseData(), ct); + } +} diff --git a/Backend/src/Fengling.Backend.Web/Endpoints/Admin/RedemptionOrders/RedemptionOrderEndpoints.cs b/Backend/src/Fengling.Backend.Web/Endpoints/Admin/RedemptionOrders/RedemptionOrderEndpoints.cs new file mode 100644 index 0000000..a8aa10a --- /dev/null +++ b/Backend/src/Fengling.Backend.Web/Endpoints/Admin/RedemptionOrders/RedemptionOrderEndpoints.cs @@ -0,0 +1,80 @@ +using FastEndpoints; +using Fengling.Backend.Web.Application.Commands.RedemptionOrders; +using Fengling.Backend.Web.Application.Queries.RedemptionOrders; + +namespace Fengling.Backend.Web.Endpoints.Admin.RedemptionOrders; + +/// +/// 获取兑换订单列表端点 +/// +[Tags("Admin/RedemptionOrders")] +[HttpGet("/api/admin/redemption-orders")] +public class GetRedemptionOrdersEndpoint(IMediator mediator) + : Endpoint>> +{ + public override async Task HandleAsync(GetRedemptionOrdersQuery req, CancellationToken ct) + { + var orders = await mediator.Send(req, ct); + await Send.OkAsync(orders.AsResponseData(), ct); + } +} + +/// +/// 获取兑换订单详情端点 +/// +[Tags("Admin/RedemptionOrders")] +[HttpGet("/api/admin/redemption-orders/{OrderId}")] +public class GetRedemptionOrderByIdEndpoint(IMediator mediator) + : Endpoint> +{ + public override async Task HandleAsync(GetRedemptionOrderByIdQuery req, CancellationToken ct) + { + var order = await mediator.Send(req, ct); + await Send.OkAsync(order.AsResponseData(), ct); + } +} + +/// +/// 标记订单为已发货端点 +/// +[Tags("Admin/RedemptionOrders")] +[HttpPost("/api/admin/redemption-orders/{OrderId}/dispatch")] +public class MarkOrderAsDispatchedEndpoint(IMediator mediator) + : Endpoint +{ + public override async Task HandleAsync(MarkOrderAsDispatchedCommand req, CancellationToken ct) + { + var result = await mediator.Send(req, ct); + await Send.OkAsync(result, ct); + } +} + +/// +/// 完成订单端点 +/// +[Tags("Admin/RedemptionOrders")] +[HttpPost("/api/admin/redemption-orders/{OrderId}/complete")] +public class CompleteOrderEndpoint(IMediator mediator) + : Endpoint +{ + public override async Task HandleAsync(CompleteOrderCommand req, CancellationToken ct) + { + var result = await mediator.Send(req, ct); + await Send.OkAsync(result, ct); + } +} + +/// +/// 取消订单端点 +/// +[Tags("Admin/RedemptionOrders")] +[HttpPost("/api/admin/redemption-orders/{OrderId}/cancel")] +public class CancelOrderEndpoint(IMediator mediator) + : Endpoint +{ + public override async Task HandleAsync(CancelOrderCommand req, CancellationToken ct) + { + var result = await mediator.Send(req, ct); + await Send.OkAsync(result, ct); + } +} diff --git a/Backend/src/Fengling.Backend.Web/Endpoints/Gifts/GiftEndpoints.cs b/Backend/src/Fengling.Backend.Web/Endpoints/Gifts/GiftEndpoints.cs new file mode 100644 index 0000000..5d7f674 --- /dev/null +++ b/Backend/src/Fengling.Backend.Web/Endpoints/Gifts/GiftEndpoints.cs @@ -0,0 +1,53 @@ +using FastEndpoints; +using Fengling.Backend.Web.Application.Commands.RedemptionOrders; +using Fengling.Backend.Web.Application.Queries.Gifts; + +namespace Fengling.Backend.Web.Endpoints.Gifts; + +/// +/// 获取上架礼品列表端点(会员端) +/// +[Tags("Gifts")] +[HttpGet("/api/gifts")] +[AllowAnonymous] +public class GetOnShelfGiftsEndpoint(IMediator mediator) + : Endpoint>> +{ + public override async Task HandleAsync(EmptyRequest req, CancellationToken ct) + { + var query = new GetGiftsQuery(IsOnShelf: true); + var gifts = await mediator.Send(query, ct); + await Send.OkAsync(gifts.AsResponseData(), ct); + } +} + +/// +/// 获取礼品详情端点(会员端) +/// +[Tags("Gifts")] +[HttpGet("/api/gifts/{GiftId}")] +[AllowAnonymous] +public class GetGiftDetailEndpoint(IMediator mediator) + : Endpoint> +{ + public override async Task HandleAsync(GetGiftByIdQuery req, CancellationToken ct) + { + var gift = await mediator.Send(req, ct); + await Send.OkAsync(gift.AsResponseData(), ct); + } +} + +/// +/// 兑换礼品端点(会员端) +/// +[Tags("Gifts")] +[HttpPost("/api/gifts/redeem")] +public class RedeemGiftEndpoint(IMediator mediator) + : Endpoint> +{ + public override async Task HandleAsync(CreateRedemptionOrderCommand req, CancellationToken ct) + { + var orderId = await mediator.Send(req, ct); + await Send.OkAsync(orderId.Id.AsResponseData(), ct); + } +} diff --git a/Backend/src/Fengling.Backend.Web/Endpoints/HelloEndpoint.cs b/Backend/src/Fengling.Backend.Web/Endpoints/HelloEndpoint.cs new file mode 100644 index 0000000..e430476 --- /dev/null +++ b/Backend/src/Fengling.Backend.Web/Endpoints/HelloEndpoint.cs @@ -0,0 +1,25 @@ +using FastEndpoints; +using FastEndpoints.Swagger; +using Microsoft.AspNetCore.Authorization; +using NetCorePal.Extensions.Dto; + +namespace Fengling.Backend.Web.Endpoints; + +/// +/// Hello +/// +public class HelloEndpoint : EndpointWithoutRequest> +{ + public override void Configure() + { + Tags("Hello"); + Description(b => b.AutoTagOverride("Hello")); + Get("/api/hello"); + AllowAnonymous(); + } + + public override Task HandleAsync(CancellationToken ct) + { + return Send.OkAsync("hello".AsResponseData(), cancellation: ct); + } +} diff --git a/Backend/src/Fengling.Backend.Web/Endpoints/MarketingCodes/UseMarketingCodeEndpoint.cs b/Backend/src/Fengling.Backend.Web/Endpoints/MarketingCodes/UseMarketingCodeEndpoint.cs new file mode 100644 index 0000000..d285da8 --- /dev/null +++ b/Backend/src/Fengling.Backend.Web/Endpoints/MarketingCodes/UseMarketingCodeEndpoint.cs @@ -0,0 +1,44 @@ +using FastEndpoints; +using Fengling.Backend.Domain.AggregatesModel.MemberAggregate; +using Fengling.Backend.Domain.AggregatesModel.MarketingCodeAggregate; +using Fengling.Backend.Web.Application.Commands.MarketingCodes; + +namespace Fengling.Backend.Web.Endpoints.MarketingCodes; + +/// +/// 扫码请求 +/// +public record UseMarketingCodeRequest(string Code, MemberId MemberId); + +/// +/// 扫码响应 +/// +public record UseMarketingCodeEndpointResponse( + MarketingCodeId MarketingCodeId, + string ProductName, + int EarnedPoints, + string Message); + +/// +/// 使用营销码端点(扫码) +/// +[Tags("MarketingCodes")] +[HttpPost("/api/marketing-codes/scan")] +[AllowAnonymous] +public class UseMarketingCodeEndpoint(IMediator mediator) + : Endpoint> +{ + public override async Task HandleAsync(UseMarketingCodeRequest req, CancellationToken ct) + { + var command = new UseMarketingCodeCommand(req.Code, req.MemberId); + var result = await mediator.Send(command, ct); + + var response = new UseMarketingCodeEndpointResponse( + result.MarketingCodeId, + result.ProductName, + result.EarnedPoints, + result.Message); + + await Send.OkAsync(response.AsResponseData(), ct); + } +} diff --git a/Backend/src/Fengling.Backend.Web/Endpoints/Members/GetMemberEndpoint.cs b/Backend/src/Fengling.Backend.Web/Endpoints/Members/GetMemberEndpoint.cs new file mode 100644 index 0000000..0937f50 --- /dev/null +++ b/Backend/src/Fengling.Backend.Web/Endpoints/Members/GetMemberEndpoint.cs @@ -0,0 +1,31 @@ +using FastEndpoints; +using Fengling.Backend.Domain.AggregatesModel.MemberAggregate; +using Fengling.Backend.Web.Application.Queries.Members; + +namespace Fengling.Backend.Web.Endpoints.Members; + +/// +/// 获取会员信息端点 +/// +[Tags("Members")] +[HttpGet("/api/members/{memberId}")] +[AllowAnonymous] +public class GetMemberEndpoint(IMediator mediator) + : Endpoint> +{ + public override async Task HandleAsync(GetMemberEndpointRequest req, CancellationToken ct) + { + var query = new GetMemberQuery(req.MemberId); + var result = await mediator.Send(query, ct); + + await Send.OkAsync(result.AsResponseData(), ct); + } +} + +/// +/// 获取会员请求 +/// +public record GetMemberEndpointRequest +{ + public MemberId MemberId { get; init; } = default!; +} diff --git a/Backend/src/Fengling.Backend.Web/Endpoints/Members/LoginMemberEndpoint.cs b/Backend/src/Fengling.Backend.Web/Endpoints/Members/LoginMemberEndpoint.cs new file mode 100644 index 0000000..7dea4cc --- /dev/null +++ b/Backend/src/Fengling.Backend.Web/Endpoints/Members/LoginMemberEndpoint.cs @@ -0,0 +1,34 @@ +using FastEndpoints; +using Fengling.Backend.Domain.AggregatesModel.MemberAggregate; +using Fengling.Backend.Web.Application.Commands.Members; + +namespace Fengling.Backend.Web.Endpoints.Members; + +/// +/// 登录请求 +/// +public record LoginMemberRequest(string Phone, string Password); + +/// +/// 登录响应 +/// +public record LoginMemberEndpointResponse(MemberId MemberId, string Token); + +/// +/// 会员登录端点 +/// +[Tags("Members")] +[HttpPost("/api/members/login")] +[AllowAnonymous] +public class LoginMemberEndpoint(IMediator mediator) + : Endpoint> +{ + public override async Task HandleAsync(LoginMemberRequest req, CancellationToken ct) + { + var command = new LoginMemberCommand(req.Phone, req.Password); + var result = await mediator.Send(command, ct); + + var response = new LoginMemberEndpointResponse(result.MemberId, result.Token); + await Send.OkAsync(response.AsResponseData(), ct); + } +} diff --git a/Backend/src/Fengling.Backend.Web/Endpoints/Members/RegisterMemberEndpoint.cs b/Backend/src/Fengling.Backend.Web/Endpoints/Members/RegisterMemberEndpoint.cs new file mode 100644 index 0000000..29d1294 --- /dev/null +++ b/Backend/src/Fengling.Backend.Web/Endpoints/Members/RegisterMemberEndpoint.cs @@ -0,0 +1,34 @@ +using FastEndpoints; +using Fengling.Backend.Domain.AggregatesModel.MemberAggregate; +using Fengling.Backend.Web.Application.Commands.Members; + +namespace Fengling.Backend.Web.Endpoints.Members; + +/// +/// 注册会员请求 +/// +public record RegisterMemberRequest(string Phone, string Password, string? Nickname = null); + +/// +/// 注册会员响应 +/// +public record RegisterMemberResponse(MemberId MemberId, string Phone); + +/// +/// 注册会员端点 +/// +[Tags("Members")] +[HttpPost("/api/members/register")] +[AllowAnonymous] +public class RegisterMemberEndpoint(IMediator mediator) + : Endpoint> +{ + public override async Task HandleAsync(RegisterMemberRequest req, CancellationToken ct) + { + var command = new RegisterMemberCommand(req.Phone, req.Password, req.Nickname); + var memberId = await mediator.Send(command, ct); + + var response = new RegisterMemberResponse(memberId, req.Phone); + await Send.OkAsync(response.AsResponseData(), ct); + } +} diff --git a/Backend/src/Fengling.Backend.Web/Endpoints/RedemptionOrders/RedemptionOrderEndpoints.cs b/Backend/src/Fengling.Backend.Web/Endpoints/RedemptionOrders/RedemptionOrderEndpoints.cs new file mode 100644 index 0000000..b45c4ef --- /dev/null +++ b/Backend/src/Fengling.Backend.Web/Endpoints/RedemptionOrders/RedemptionOrderEndpoints.cs @@ -0,0 +1,39 @@ +using FastEndpoints; +using Fengling.Backend.Web.Application.Queries.RedemptionOrders; + +namespace Fengling.Backend.Web.Endpoints.RedemptionOrders; + +/// +/// 获取我的兑换订单列表端点(会员端) +/// +[Tags("RedemptionOrders")] +[HttpGet("/api/redemption-orders/my")] +public class GetMyRedemptionOrdersEndpoint(IMediator mediator) + : Endpoint>> +{ + public override async Task HandleAsync(GetMyRedemptionOrdersRequest req, CancellationToken ct) + { + // TODO: 从JWT Token中获取当前登录会员ID + // 暂时使用请求中的MemberId + var query = new GetRedemptionOrdersQuery(MemberId: req.MemberId, Status: req.Status); + var orders = await mediator.Send(query, ct); + await Send.OkAsync(orders.AsResponseData(), ct); + } +} + +public record GetMyRedemptionOrdersRequest(Guid MemberId, int? Status = null); + +/// +/// 获取订单详情端点(会员端) +/// +[Tags("RedemptionOrders")] +[HttpGet("/api/redemption-orders/{OrderId}")] +public class GetMyRedemptionOrderDetailEndpoint(IMediator mediator) + : Endpoint> +{ + public override async Task HandleAsync(GetRedemptionOrderByIdQuery req, CancellationToken ct) + { + var order = await mediator.Send(req, ct); + await Send.OkAsync(order.AsResponseData(), ct); + } +} diff --git a/Backend/src/Fengling.Backend.Web/Extensions/StackExchangeRedisDataProtectionBuilderExtensions.cs b/Backend/src/Fengling.Backend.Web/Extensions/StackExchangeRedisDataProtectionBuilderExtensions.cs new file mode 100644 index 0000000..82ffa65 --- /dev/null +++ b/Backend/src/Fengling.Backend.Web/Extensions/StackExchangeRedisDataProtectionBuilderExtensions.cs @@ -0,0 +1,38 @@ +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.DataProtection.KeyManagement; +using Microsoft.AspNetCore.DataProtection.StackExchangeRedis; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using StackExchange.Redis; + +namespace Fengling.Backend.Web.Extensions; + +/// +/// Extension methods for configuring StackExchange.Redis-based data protection. +/// +public static class StackExchangeRedisDataProtectionBuilderExtensions +{ + /// + /// Configures data protection to persist keys to StackExchange.Redis. + /// This method resolves IConnectionMultiplexer from DI, making it work with both + /// Aspire (where AddRedisClient registers the multiplexer) and non-Aspire scenarios. + /// + /// The data protection builder. + /// The Redis key where data protection keys will be stored. + /// The data protection builder for chaining. + public static IDataProtectionBuilder PersistKeysToStackExchangeRedis( + this IDataProtectionBuilder builder, + RedisKey key) + { + builder.Services.AddSingleton>(services => + { + var connectionMultiplexer = services.GetRequiredService(); + return new ConfigureOptions(options => + { + options.XmlRepository = new RedisXmlRepository(() => connectionMultiplexer.GetDatabase(), key); + }); + }); + + return builder; + } +} diff --git a/Backend/src/Fengling.Backend.Web/Extensions/SwaggerGenOptionsExtionsions.cs b/Backend/src/Fengling.Backend.Web/Extensions/SwaggerGenOptionsExtionsions.cs new file mode 100644 index 0000000..c307f79 --- /dev/null +++ b/Backend/src/Fengling.Backend.Web/Extensions/SwaggerGenOptionsExtionsions.cs @@ -0,0 +1,25 @@ +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace Fengling.Backend.Web.Extensions; + +public static class SwaggerGenOptionsExtionsions +{ + public static SwaggerGenOptions AddEntityIdSchemaMap(this SwaggerGenOptions swaggerGenOptions) + { + foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies() + .Where(p => p.FullName != null && p.FullName.Contains("Fengling.Backend"))) + { + foreach (var type in assembly.GetTypes()) + { + if (type.IsClass && Array.Exists(type.GetInterfaces(), p => p == typeof(IEntityId))) + { + swaggerGenOptions.MapType(type, + () => new OpenApiSchema { Type = typeof(string).Name.ToLower() }); + } + } + } + + return swaggerGenOptions; + } +} \ No newline at end of file diff --git a/Backend/src/Fengling.Backend.Web/Fengling.Backend.Web.csproj b/Backend/src/Fengling.Backend.Web/Fengling.Backend.Web.csproj new file mode 100644 index 0000000..663f461 --- /dev/null +++ b/Backend/src/Fengling.Backend.Web/Fengling.Backend.Web.csproj @@ -0,0 +1,68 @@ + + + + net10.0 + enable + enable + Linux + ..\.. + true + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + diff --git a/Backend/src/Fengling.Backend.Web/GlobalUsings.cs b/Backend/src/Fengling.Backend.Web/GlobalUsings.cs new file mode 100644 index 0000000..1098abc --- /dev/null +++ b/Backend/src/Fengling.Backend.Web/GlobalUsings.cs @@ -0,0 +1,12 @@ +global using NetCorePal.Extensions.AspNetCore; +global using NetCorePal.Extensions.DependencyInjection; +global using Microsoft.Extensions.DependencyInjection; +global using Fengling.Backend.Infrastructure; +global using FluentValidation; +global using NetCorePal.Extensions.Primitives; +global using MediatR; +global using NetCorePal.Extensions.Domain; +global using NetCorePal.Extensions.Dto; +global using NetCorePal.Extensions.DistributedTransactions; +global using Microsoft.AspNetCore.Authorization; +global using Microsoft.EntityFrameworkCore; \ No newline at end of file diff --git a/Backend/src/Fengling.Backend.Web/Program.cs b/Backend/src/Fengling.Backend.Web/Program.cs new file mode 100644 index 0000000..f471700 --- /dev/null +++ b/Backend/src/Fengling.Backend.Web/Program.cs @@ -0,0 +1,245 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using Prometheus; +using System.Reflection; +using System.Text.Json; +using Microsoft.AspNetCore.DataProtection; +using StackExchange.Redis; +using FluentValidation.AspNetCore; +using Fengling.Backend.Web.Clients; +using Fengling.Backend.Web.Extensions; +using Fengling.Backend.Web.Utils; +using FastEndpoints; +using Serilog; +using Serilog.Formatting.Json; +using Hangfire; +using Hangfire.Redis.StackExchange; +using Microsoft.AspNetCore.Http.Json; +using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; +using Refit; +using NetCorePal.Extensions.CodeAnalysis; + +Log.Logger = new LoggerConfiguration() + .Enrich.WithClientIp() + .WriteTo.Console(new JsonFormatter()) + .CreateLogger(); +try +{ + var builder = WebApplication.CreateBuilder(args); + builder.Host.UseSerilog(); + + #region SignalR + + builder.Services.AddHealthChecks(); + builder.Services.AddMvc() + .AddNewtonsoftJson(options => { options.SerializerSettings.AddNetCorePalJsonConverters(); }); + builder.Services.AddSignalR(); + + #endregion + + #region Prometheus监控 + + builder.Services.AddHealthChecks().ForwardToPrometheus(); + builder.Services.AddHttpClient(Options.DefaultName) + .UseHttpClientMetrics(); + + #endregion + + // Add services to the container. + + #region 身份认证 + + var redis = await ConnectionMultiplexer.ConnectAsync(builder.Configuration.GetConnectionString("Redis")!); + builder.Services.AddSingleton(_ => redis); + + // DataProtection - use custom extension that resolves IConnectionMultiplexer from DI + builder.Services.AddDataProtection() + .PersistKeysToStackExchangeRedis("DataProtection-Keys"); + + // 配置JWT认证 + builder.Services.Configure(builder.Configuration.GetSection("AppConfiguration")); + var appConfig = builder.Configuration.GetSection("AppConfiguration").Get() ?? new AppConfiguration { JwtIssuer = "netcorepal", JwtAudience = "netcorepal" }; + + builder.Services.AddAuthentication().AddJwtBearer(options => + { + options.RequireHttpsMetadata = false; + options.TokenValidationParameters.ValidAudience = appConfig.JwtAudience; + options.TokenValidationParameters.ValidateAudience = true; + options.TokenValidationParameters.ValidIssuer = appConfig.JwtIssuer; + options.TokenValidationParameters.ValidateIssuer = true; + }); + builder.Services.AddNetCorePalJwt().AddRedisStore(); + + #endregion + + + #region Controller + + builder.Services.AddControllers().AddNetCorePalSystemTextJson(); + // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle + builder.Services.AddEndpointsApiExplorer(); + builder.Services.AddSwaggerGen(c => c.AddEntityIdSchemaMap()); //强类型id swagger schema 映射 + + #endregion + + #region FastEndpoints + + builder.Services.AddFastEndpoints(o => o.IncludeAbstractValidators = true); + builder.Services.Configure(o => + o.SerializerOptions.AddNetCorePalJsonConverters()); + + #endregion + + #region 模型验证器 + + builder.Services.AddFluentValidationAutoValidation(); + builder.Services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly()); + builder.Services.AddKnownExceptionErrorModelInterceptor(); + + #endregion + + + #region 基础设施 + + builder.Services.AddRepositories(typeof(ApplicationDbContext).Assembly); + + builder.Services.AddDbContext(options => + { + options.UseSqlite(builder.Configuration.GetConnectionString("SQLite")); + // 仅在开发环境启用敏感数据日志,防止生产环境泄露敏感信息 + if (builder.Environment.IsDevelopment()) + { + options.EnableSensitiveDataLogging(); + } + options.EnableDetailedErrors(); + }); + builder.Services.AddUnitOfWork(); + builder.Services.AddRedisLocks(); + builder.Services.AddContext().AddEnvContext().AddCapContextProcessor(); + builder.Services.AddNetCorePalServiceDiscoveryClient(); + builder.Services.AddIntegrationEvents(typeof(Program)) + .UseCap(b => + { + b.RegisterServicesFromAssemblies(typeof(Program)); + b.AddContextIntegrationFilters(); + }); + + + builder.Services.AddCap(x => + { + x.UseNetCorePalStorage(); + x.JsonSerializerOptions.AddNetCorePalJsonConverters(); + x.ConsumerThreadCount = Environment.ProcessorCount; + x.UseRedis(builder.Configuration.GetConnectionString("Redis")!); + x.UseDashboard(); //CAP Dashboard path: /cap + }); + + #endregion + + builder.Services.AddMediatR(cfg => + cfg.RegisterServicesFromAssemblies(Assembly.GetExecutingAssembly()) + .AddCommandLockBehavior() + .AddKnownExceptionValidationBehavior() + .AddUnitOfWorkBehaviors()); + + #region 多环境支持与服务注册发现 + + builder.Services.AddMultiEnv(envOption => envOption.ServiceName = "Abc.Template") + .UseMicrosoftServiceDiscovery(); + builder.Services.AddConfigurationServiceEndpointProvider(); + + #endregion + + #region 远程服务客户端配置 + + var jsonSerializerSettings = new JsonSerializerSettings + { + ContractResolver = new CamelCasePropertyNamesContractResolver(), + NullValueHandling = NullValueHandling.Ignore, + ReferenceLoopHandling = ReferenceLoopHandling.Ignore + }; + jsonSerializerSettings.AddNetCorePalJsonConverters(); + var ser = new NewtonsoftJsonContentSerializer(jsonSerializerSettings); + var settings = new RefitSettings(ser); + builder.Services.AddRefitClient(settings) + .ConfigureHttpClient(client => + client.BaseAddress = new Uri(builder.Configuration.GetValue("https+http://user:8080")!)) + .AddMultiEnvMicrosoftServiceDiscovery() //多环境服务发现支持 + .AddStandardResilienceHandler(); //添加标准的重试策略 + + #endregion + + #region Jobs + + builder.Services.AddHangfire(x => { x.UseRedisStorage(builder.Configuration.GetConnectionString("Redis")); }); + builder.Services.AddHangfireServer(); //hangfire dashboard path: /hangfire + + #endregion + + + var app = builder.Build(); + + // 在非生产环境中执行数据库迁移(包括开发、测试、Staging等环境) + if (!app.Environment.IsProduction()) + { + using var scope = app.Services.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + await dbContext.Database.MigrateAsync(); + } + + + app.UseKnownExceptionHandler(); + // Configure the HTTP request pipeline. + if (app.Environment.IsDevelopment()) + { + app.UseSwagger(); + app.UseSwaggerUI(); + } + + app.UseStaticFiles(); + //app.UseHttpsRedirection(); + app.UseRouting(); + app.UseAuthentication(); // Authentication 必须在 Authorization 之前 + app.UseAuthorization(); + + app.MapControllers(); + app.UseFastEndpoints(); + + #region SignalR + + app.MapHub("/chat"); + + #endregion + + app.UseHttpMetrics(); + app.MapHealthChecks("/health"); + app.MapMetrics(); // 通过 /metrics 访问指标 + + // Code analysis endpoint + app.MapGet("/code-analysis", () => + { + var assemblies = new List { typeof(Program).Assembly, typeof(ApplicationDbContext).Assembly }; + var html = VisualizationHtmlBuilder.GenerateVisualizationHtml( + CodeFlowAnalysisHelper.GetResultFromAssemblies(assemblies.ToArray()) + ); + return Results.Content(html, "text/html; charset=utf-8"); + }); + + app.UseHangfireDashboard(); + await app.RunAsync(); +} +catch (Exception ex) +{ + Log.Fatal(ex, "Application terminated unexpectedly"); +} +finally +{ + await Log.CloseAndFlushAsync(); +} + +#pragma warning disable S1118 +public partial class Program +#pragma warning restore S1118 +{ +} diff --git a/Backend/src/Fengling.Backend.Web/Properties/launchSettings.json b/Backend/src/Fengling.Backend.Web/Properties/launchSettings.json new file mode 100644 index 0000000..588d2a1 --- /dev/null +++ b/Backend/src/Fengling.Backend.Web/Properties/launchSettings.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5511", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7435;http://localhost:5511", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} \ No newline at end of file diff --git a/Backend/src/Fengling.Backend.Web/Utils/AppConfiguration.cs b/Backend/src/Fengling.Backend.Web/Utils/AppConfiguration.cs new file mode 100644 index 0000000..2b0e3d3 --- /dev/null +++ b/Backend/src/Fengling.Backend.Web/Utils/AppConfiguration.cs @@ -0,0 +1,18 @@ +namespace Fengling.Backend.Web.Utils; + +public class AppConfiguration +{ + public string Secret { get; set; } = string.Empty; + public int TokenExpiryInMinutes { get; set; } + + /// + /// JWT Issuer(签发者) + /// + public string JwtIssuer { get; set; } = "netcorepal"; + + /// + /// JWT Audience(受众) + /// + public string JwtAudience { get; set; } = "netcorepal"; +} + diff --git a/Backend/src/Fengling.Backend.Web/appsettings.Development.json b/Backend/src/Fengling.Backend.Web/appsettings.Development.json new file mode 100644 index 0000000..b995007 --- /dev/null +++ b/Backend/src/Fengling.Backend.Web/appsettings.Development.json @@ -0,0 +1,24 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "ConnectionStrings": { + "SQLite": "Data Source=fengling.db", + "Redis": "81.68.223.70:6379" + }, + "Services": { + "user": { + "https": [ + "https://user:8443" + ] + }, + "user-v2": { + "https": [ + "https://user-v2:8443" + ] + } + } +} diff --git a/Backend/src/Fengling.Backend.Web/appsettings.json b/Backend/src/Fengling.Backend.Web/appsettings.json new file mode 100644 index 0000000..7592754 --- /dev/null +++ b/Backend/src/Fengling.Backend.Web/appsettings.json @@ -0,0 +1,28 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "ConnectionStrings": { + "SQLite": "Data Source=fengling.db", + "Redis": "81.68.223.70:6379" + }, + "RedisStreams": { + "ConnectionString": "81.68.223.70:6379" + }, + "Services": { + "user": { + "https": [ + "https://user:8443" + ] + }, + "user-v2": { + "https": [ + "https://user-v2:8443" + ] + } + } +} \ No newline at end of file diff --git a/Backend/test/Fengling.Backend.Domain.Tests/Fengling.Backend.Domain.Tests.csproj b/Backend/test/Fengling.Backend.Domain.Tests/Fengling.Backend.Domain.Tests.csproj new file mode 100644 index 0000000..fab64f7 --- /dev/null +++ b/Backend/test/Fengling.Backend.Domain.Tests/Fengling.Backend.Domain.Tests.csproj @@ -0,0 +1,29 @@ + + + + net10.0 + enable + enable + false + true + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/Backend/test/Fengling.Backend.Domain.Tests/GlobalUsings.cs b/Backend/test/Fengling.Backend.Domain.Tests/GlobalUsings.cs new file mode 100644 index 0000000..90b6b1e --- /dev/null +++ b/Backend/test/Fengling.Backend.Domain.Tests/GlobalUsings.cs @@ -0,0 +1,2 @@ +global using Xunit; +global using NetCorePal.Extensions.Primitives; \ No newline at end of file diff --git a/Backend/test/Fengling.Backend.Infrastructure.Tests/Fengling.Backend.Infrastructure.Tests.csproj b/Backend/test/Fengling.Backend.Infrastructure.Tests/Fengling.Backend.Infrastructure.Tests.csproj new file mode 100644 index 0000000..1882cc1 --- /dev/null +++ b/Backend/test/Fengling.Backend.Infrastructure.Tests/Fengling.Backend.Infrastructure.Tests.csproj @@ -0,0 +1,29 @@ + + + + net10.0 + enable + enable + false + true + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/Backend/test/Fengling.Backend.Infrastructure.Tests/GlobalUsings.cs b/Backend/test/Fengling.Backend.Infrastructure.Tests/GlobalUsings.cs new file mode 100644 index 0000000..8c927eb --- /dev/null +++ b/Backend/test/Fengling.Backend.Infrastructure.Tests/GlobalUsings.cs @@ -0,0 +1 @@ +global using Xunit; \ No newline at end of file diff --git a/Backend/test/Fengling.Backend.Web.Tests/AssemblyInfo.cs b/Backend/test/Fengling.Backend.Web.Tests/AssemblyInfo.cs new file mode 100644 index 0000000..5a2b23b --- /dev/null +++ b/Backend/test/Fengling.Backend.Web.Tests/AssemblyInfo.cs @@ -0,0 +1,8 @@ +//Ordering Tests In Collections see:https://fast-endpoints.com/docs/integration-unit-testing#ordering-tests-in-collections +// [assembly: EnableAdvancedTesting] + +// can capture standard output and standard error +// [assembly: CaptureConsole] + +// will capture output from Debug and Trace +// [assembly: CaptureTrace] \ No newline at end of file diff --git a/Backend/test/Fengling.Backend.Web.Tests/Fengling.Backend.Web.Tests.csproj b/Backend/test/Fengling.Backend.Web.Tests/Fengling.Backend.Web.Tests.csproj new file mode 100644 index 0000000..66ff183 --- /dev/null +++ b/Backend/test/Fengling.Backend.Web.Tests/Fengling.Backend.Web.Tests.csproj @@ -0,0 +1,40 @@ + + + + net10.0 + enable + enable + false + true + + + + + + + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + \ No newline at end of file diff --git a/Backend/test/Fengling.Backend.Web.Tests/Fixtures/WebAppFixture.cs b/Backend/test/Fengling.Backend.Web.Tests/Fixtures/WebAppFixture.cs new file mode 100644 index 0000000..97cb6b6 --- /dev/null +++ b/Backend/test/Fengling.Backend.Web.Tests/Fixtures/WebAppFixture.cs @@ -0,0 +1,37 @@ +using Testcontainers.PostgreSql; +using Testcontainers.Redis; +using Microsoft.AspNetCore.Hosting; +using Fengling.Backend.Infrastructure; +using Microsoft.EntityFrameworkCore; + +namespace Fengling.Backend.Web.Tests.Fixtures; + +public class WebAppFixture : AppFixture +{ + private RedisContainer _redisContainer = null!; + private PostgreSqlContainer _databaseContainer = null!; + + protected override async ValueTask PreSetupAsync() + { + _redisContainer = new RedisBuilder() + .WithCommand("--databases", "1024").Build(); + _databaseContainer = new PostgreSqlBuilder() + .WithUsername("postgres").WithPassword("123456") + .WithEnvironment("TZ", "Asia/Shanghai") + .WithDatabase("postgres").Build(); + + var tasks = new List { _redisContainer.StartAsync() }; + tasks.Add(_databaseContainer.StartAsync()); + await Task.WhenAll(tasks); + } + + protected override void ConfigureApp(IWebHostBuilder a) + { + a.UseSetting("ConnectionStrings:Redis", + _redisContainer.GetConnectionString()); + a.UseSetting("ConnectionStrings:PostgreSQL", + _databaseContainer.GetConnectionString()); + a.UseEnvironment("Development"); + } + +} diff --git a/Backend/test/Fengling.Backend.Web.Tests/Fixtures/WebAppTestCollection.cs b/Backend/test/Fengling.Backend.Web.Tests/Fixtures/WebAppTestCollection.cs new file mode 100644 index 0000000..d5b0e00 --- /dev/null +++ b/Backend/test/Fengling.Backend.Web.Tests/Fixtures/WebAppTestCollection.cs @@ -0,0 +1,7 @@ +namespace Fengling.Backend.Web.Tests.Fixtures; + +[CollectionDefinition(Name)] +public class WebAppTestCollection : TestCollection +{ + public const string Name = nameof(WebAppTestCollection); +} diff --git a/Backend/test/Fengling.Backend.Web.Tests/GlobalUsings.cs b/Backend/test/Fengling.Backend.Web.Tests/GlobalUsings.cs new file mode 100644 index 0000000..3b6bf5e --- /dev/null +++ b/Backend/test/Fengling.Backend.Web.Tests/GlobalUsings.cs @@ -0,0 +1,9 @@ +global using Xunit; +global using Fengling.Backend.Web.Tests.Fixtures; +global using FastEndpoints.Testing; +global using FastEndpoints; +global using Microsoft.Extensions.DependencyInjection; +global using Microsoft.Extensions.DependencyInjection.Extensions; +global using NetCorePal.Extensions.NewtonsoftJson; +global using Microsoft.Extensions.Configuration; +global using Microsoft.Extensions.Logging; diff --git a/Backend/test/Fengling.Backend.Web.Tests/xunit.runner.json b/Backend/test/Fengling.Backend.Web.Tests/xunit.runner.json new file mode 100644 index 0000000..c7bb228 --- /dev/null +++ b/Backend/test/Fengling.Backend.Web.Tests/xunit.runner.json @@ -0,0 +1,5 @@ +{ + "parallelizeAssembly": true, + "parallelizeTestCollections": false, + "diagnosticMessages": false +} \ No newline at end of file diff --git a/Backend/vs-snippets/Install-VSSnippets.ps1 b/Backend/vs-snippets/Install-VSSnippets.ps1 new file mode 100644 index 0000000..f8a68e6 --- /dev/null +++ b/Backend/vs-snippets/Install-VSSnippets.ps1 @@ -0,0 +1,83 @@ +# NetCorePal Template - Visual Studio Code Snippets Installer +# Auto install Visual Studio code snippets + +param( + [string]$VisualStudioVersion = "2022", + [switch]$ShowPathOnly +) + +$ErrorActionPreference = "Stop" + +Write-Host "NetCorePal Template - Visual Studio Code Snippets Installer" -ForegroundColor Green +Write-Host "=================================================" -ForegroundColor Green + +# Get current script directory +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$SnippetFile = Join-Path $ScriptDir "NetCorePalTemplates.snippet" + +# Check if snippet file exists +if (-not (Test-Path $SnippetFile)) { + Write-Error "Snippet file not found: $SnippetFile" + exit 1 +} + +# Build Visual Studio snippets directory path +$VSSnippetsPath = "$env:USERPROFILE\Documents\Visual Studio $VisualStudioVersion\Code Snippets\Visual C#\My Code Snippets" + +Write-Host "Target directory: $VSSnippetsPath" -ForegroundColor Yellow + +# If only showing path, don't execute installation +if ($ShowPathOnly) { + Write-Host "" + Write-Host "Manual installation steps:" -ForegroundColor Cyan + Write-Host "1. Ensure target directory exists: $VSSnippetsPath" -ForegroundColor White + Write-Host "2. Copy file: $SnippetFile" -ForegroundColor White + Write-Host "3. To target directory: $VSSnippetsPath" -ForegroundColor White + Write-Host "4. Restart Visual Studio" -ForegroundColor White + Write-Host "" + Write-Host "Or use Tools > Code Snippets Manager > Import in Visual Studio" -ForegroundColor Yellow + return +} + +# Create directory if it doesn't exist +if (-not (Test-Path $VSSnippetsPath)) { + Write-Host "Creating snippets directory..." -ForegroundColor Yellow + New-Item -ItemType Directory -Path $VSSnippetsPath -Force | Out-Null +} + +# Copy snippet file +$DestinationFile = Join-Path $VSSnippetsPath "NetCorePalTemplates.snippet" + +try { + Copy-Item -Path $SnippetFile -Destination $DestinationFile -Force + Write-Host "Code snippets installed successfully!" -ForegroundColor Green + Write-Host " Source file: $SnippetFile" -ForegroundColor Gray + Write-Host " Target file: $DestinationFile" -ForegroundColor Gray + + Write-Host "" + Write-Host "Available snippet shortcuts:" -ForegroundColor Cyan + Write-Host " postproc - PostProcessor class" -ForegroundColor White + Write-Host " tstclass - Test class" -ForegroundColor White + Write-Host " ncpcmd - NetCorePal command" -ForegroundColor White + Write-Host " ncpcmdres - Command response" -ForegroundColor White + Write-Host " evnt - Domain event" -ForegroundColor White + Write-Host " ncprepo - Repository interface" -ForegroundColor White + Write-Host " epp - FastEndpoint" -ForegroundColor White + + Write-Host "" + Write-Host "Usage:" -ForegroundColor Cyan + Write-Host "1. Open C# file in Visual Studio" -ForegroundColor White + Write-Host "2. Type shortcut (like 'postproc')" -ForegroundColor White + Write-Host "3. Press Tab key twice" -ForegroundColor White + Write-Host "4. Fill parameters and press Tab to switch to next parameter" -ForegroundColor White + + Write-Host "" + Write-Host "Note: If Visual Studio is running, restart it to load new snippets." -ForegroundColor Yellow +} +catch { + Write-Error "Installation failed: $($_.Exception.Message)" + exit 1 +} + +Write-Host "" +Write-Host "Installation completed!" -ForegroundColor Green diff --git a/Backend/vs-snippets/NetCorePalTemplates.snippet b/Backend/vs-snippets/NetCorePalTemplates.snippet new file mode 100644 index 0000000..1745824 --- /dev/null +++ b/Backend/vs-snippets/NetCorePalTemplates.snippet @@ -0,0 +1,1271 @@ + + + + + +
+ PostProcessor Class + postproc + post-processor + NetCorePal Template + + Expansion + +
+ + + + name + Name of the processor class + MyProcessor + + + dto + Name of the DTO prefix + My + + + + +{ + public Task PostProcessAsync($dto$Request req, $dto$Response res, HttpContext ctx, IReadOnlyCollection fails, CancellationToken c) + { + $end$ + } +}]]> + + +
+ + + +
+ Test Class + tstclass + test class + NetCorePal Template + + Expansion + +
+ + + + name + Name of the test class + My + + + fixture + Name of the fixture + App + + + test_name + Name of the test method + Name_Of_The_Test + + + + +{ + public $name$Tests($fixture$Fixture f, ITestOutputHelper o) : base(f, o) { } + + [Fact] + public async Task $test_name$() + { + $end$ + } +}]]> + + +
+ + + +
+ Endpoint with Request Only + epreq + endpoint with request only + NetCorePal Template + + Expansion + +
+ + + + epName + Name of the endpoint + My + + + verb + HTTP verb + Post + + + route + Route pattern + route-pattern + + + + +{ + public override void Configure() + { + $verb$("$route$"); + AllowAnonymous(); + } + + public override async Task HandleAsync($epName$Request r, CancellationToken c) + { + $end$ + } +}]]> + + +
+ + + +
+ NetCorePal Command + ncpcmd + create command + NetCorePal Template + + Expansion + +
+ + + + name + Name of the command + My + + + + +{ + public $name$CommandValidator() + { + // Add validation rules example: + // RuleFor(x => x.Property).NotEmpty(); + } +} + +public class $name$CommandHandler : ICommandHandler<$name$Command> +{ + public async Task Handle( + $name$Command request, + CancellationToken cancellationToken) + { + // Implement business logic + throw new NotImplementedException(); + } +}]]> + + +
+ + + +
+ NetCorePal Command with Response + ncpcmdres + create command with response + NetCorePal Template + + Expansion + +
+ + + + name + Name of the command + My + + + + ; + +public record $name$CommandResponse(); + +public class $name$CommandValidator : AbstractValidator<$name$Command> +{ + public $name$CommandValidator() + { + // Add validation rules example: + // RuleFor(x => x.Property).NotEmpty(); + } +} + +public class $name$CommandHandler : ICommandHandler<$name$Command, $name$CommandResponse> +{ + public async Task<$name$CommandResponse> Handle( + $name$Command request, + CancellationToken cancellationToken) + { + // Implement business logic + throw new NotImplementedException(); + } +}]]> + + +
+ + + +
+ Endpoint Request and Response DTOs + epdto + endpoint request and response dtos + NetCorePal Template + + Expansion + +
+ + + + name + Name prefix + My + + + + + + +
+ + + +
+ NetCorePal Aggregate Root + ncpar + create aggregate root + NetCorePal Template + + Expansion + +
+ + + + name + Name of the aggregate + My + + + + , IAggregateRoot +{ + protected $name$() { } +}]]> + + +
+ + + +
+ Test Fixture + tstfixture + test fixture + NetCorePal Template + + Expansion + +
+ + + + name + Name of the fixture + App + + + + +{ + public $name$Fixture(IMessageSink s) : base(s) { } + + protected override Task SetupAsync() + { + $end$ + } + + protected override void ConfigureServices(IServiceCollection s) + { + + } + + protected override Task TearDownAsync() + { + + } +}]]> + + +
+ + + +
+ Event Handler + evnt + event handler + NetCorePal Template + + Expansion + +
+ + + + name + Name of the event + MyEvent + + + + +{ + public Task HandleAsync($name$ e, CancellationToken c) + { + $end$ + } +}]]> + + +
+ + + +
+ NetCorePal Repository + ncprepo + create repository + NetCorePal Template + + Expansion + +
+ + + + name + Name of the entity + My + + + + ; + +public class $name$Repository(ApplicationDbContext context) + : RepositoryBase<$name$, $name$Id, ApplicationDbContext>(context), + I$name$Repository +{ +}]]> + + +
+ + + +
+ FastEndpoint NCP Style + epp + endpoint vertical slice - NCP + NetCorePal Template + + Expansion + +
+ + + + epName + Name of the endpoint + My + + + verb + HTTP verb + Post + + + route + Route pattern + route-pattern + + + summaryText + Summary text + Summary text goes here... + + + descriptionText + Description text + Description text goes here... + + + + > +{ + public override void Configure() + { + $verb$("$route$"); + AllowAnonymous(); + } + + public override async Task HandleAsync($epName$Request r, CancellationToken c) + { + var cmd = new $epName$Command(r.Property1, r.Property2); + var result = await mediator.Send(cmd, c); + var res = new $epName$Response(); + await SendOkAsync(res.AsResponseData(), c); + $end$ + } +} + +sealed record $epName$Request(); + +sealed record $epName$Response(); + +sealed class $epName$Validator : Validator<$epName$Request> +{ + public $epName$Validator() + { + // RuleFor(x => x.Property).NotEmpty(); + } +} + +sealed class $epName$Summary : Summary<$epName$Endpoint, $epName$Request> +{ + public $epName$Summary() + { + Summary = "$summaryText$"; + Description = "$descriptionText$"; + } +}]]> + + +
+ + + +
+ NetCorePal Integration Event + ncpie + integration event + NetCorePal Template + + Expansion + +
+ + + + name + Name of the integration event + My + + + + +{ + public async Task HandleAsync($name$IntegrationEvent integrationEvent, CancellationToken cancellationToken) + { + // Implement integration event handling logic + throw new NotImplementedException(); + $end$ + } +}]]> + + +
+ + + +
+ NetCorePal Domain Event Handler + ncpdeh + domain event handler + NetCorePal Template + + Expansion + +
+ + + + name + Name of the domain event + My + + + + +{ + public async Task HandleAsync($name$DomainEvent domainEvent, CancellationToken cancellationToken) + { + // Implement domain event handling logic + throw new NotImplementedException(); + $end$ + } +}]]> + + +
+ + + +
+ NetCorePal Integration Event Converter + ncpiec + integration event converter + NetCorePal Template + + Expansion + +
+ + + + domainEventName + Name of the domain event + My + + + integrationEventName + Name of the integration event + My + + + + +{ + public $integrationEventName$IntegrationEvent Convert($domainEventName$DomainEvent domainEvent) + { + return new $integrationEventName$IntegrationEvent( + // Map domain event properties to integration event + ); + $end$ + } +}]]> + + +
+ + + +
+ NetCorePal Domain Event + ncpde + domain event + NetCorePal Template + + Expansion + +
+ + + + name + Name of the domain event + My + + + + + + +
+ + + +
+ Endpoint with Response Only + epres + endpoint with response only + NetCorePal Template + + Expansion + +
+ + + + epName + Name of the endpoint + My + + + verb + HTTP verb + Get + + + route + Route pattern + route-pattern + + + + +{ + public override void Configure() + { + $verb$("$route$"); + AllowAnonymous(); + } + + public override async Task HandleAsync(CancellationToken c) + { + $end$ + } +}]]> + + +
+ + + +
+ Endpoint Validator + epval + endpoint validator + NetCorePal Template + + Expansion + +
+ + + + epName + Name of the endpoint + My + + + + +{ + public $epName$Validator() + { + $end$ + } +}]]> + + +
+ + + +
+ Endpoint Mapper + epmap + endpoint mapper + NetCorePal Template + + Expansion + +
+ + + + epName + Name of the endpoint + My + + + entity + Entity name + YourEntity + + + + +{ + public override $entity$ ToEntity($epName$Request r) => new() + { + $end$ + }; + + public override $epName$Response FromEntity($entity$ e) => new() + { + + }; +}]]> + + +
+ + + +
+ FastEndpoint Full Vertical Slice + epfull + endpoint vertical slice + NetCorePal Template + + Expansion + +
+ + + + epName + Name of the endpoint + My + + + verb + HTTP verb + Post + + + route + Route pattern + route-pattern + + + entity + Entity name + YourEntity + + + summaryText + Summary text + Summary text goes here... + + + descriptionText + Description text + Description text goes here... + + + + +{ + public override void Configure() + { + $verb$("$route$"); + AllowAnonymous(); + } + + public override async Task HandleAsync($epName$Request r, CancellationToken c) + { + $end$ + } +} + +sealed class $epName$Request +{ + +} + +sealed class $epName$Response +{ + +} + +sealed class $epName$Validator : Validator<$epName$Request> +{ + public $epName$Validator() + { + + } +} + +sealed class $epName$Mapper: Mapper<$epName$Request, $epName$Response, $entity$> +{ + public override $entity$ ToEntity($epName$Request r) => new() + { + + }; + + public override $epName$Response FromEntity($entity$ e) => new() + { + + }; +} + +sealed class $epName$Summary : Summary<$epName$Endpoint, $epName$Request> +{ + public $epName$Summary() + { + Summary = "$summaryText$"; + Description = "$descriptionText$"; + } +}]]> + + +
+ + + +
+ Endpoint Summary + epsum + endpoint summary + NetCorePal Template + + Expansion + +
+ + + + epName + Name of the endpoint + My + + + summaryText + Summary text + Summary text goes here... + + + descriptionText + Description text + Description text goes here... + + + + +{ + public $epName$Summary() + { + Summary = "$summaryText$"; + Description = "$descriptionText$"; + $end$ + } +}]]> + + +
+ + + +
+ Endpoint Without Request + epnoreq + endpoint without request + NetCorePal Template + + Expansion + +
+ + + + epName + Name of the endpoint + My + + + verb + HTTP verb + Get + + + route + Route pattern + route + + + + + + +
+ + + +
+ Endpoint with Request and Response + epreqres + endpoint with request and response + NetCorePal Template + + Expansion + +
+ + + + epName + Name of the endpoint + My + + + verb + HTTP verb + Post + + + route + Route pattern + route-pattern + + + + +{ + public override void Configure() + { + $verb$("$route$"); + AllowAnonymous(); + } + + public override async Task HandleAsync($epName$Request r, CancellationToken c) + { + $end$ + } +}]]> + + +
+ + + +
+ Endpoint Data + epdat + endpoint data + NetCorePal Template + + Expansion + +
+ + + + epName + Name of the data class + My + + + + + + +
+ + + +
+ Command Handler with Result + cmdres + command handler with result + NetCorePal Template + + Expansion + +
+ + + + name + Name of the command + MyCommand + + + + +{ + +} + +sealed class $name$Result +{ + +} + +sealed class $name$Handler : ICommandHandler<$name$, $name$Result> +{ + public Task<$name$Result> ExecuteAsync($name$ cmd, CancellationToken c) + { + $end$ + } +}]]> + + +
+ + + +
+ Command Handler + cmd + command handler + NetCorePal Template + + Expansion + +
+ + + + name + Name of the command + MyCommand + + + + +{ + public Task ExecuteAsync($name$ cmd, CancellationToken c) + { + $end$ + } +}]]> + + +
+ + + +
+ Global Pre-processor + preproc_g + global pre-processor + NetCorePal Template + + Expansion + +
+ + + + name + Name of the processor + MyProcessor + + + + fails, CancellationToken c) + { + $end$ + } +}]]> + + +
+ + + +
+ Pre-processor + preproc + pre-processor + NetCorePal Template + + Expansion + +
+ + + + name + Name of the processor + MyProcessor + + + dto + Name of the DTO + My + + + + +{ + public Task PreProcessAsync($dto$Request r, HttpContext ctx, List fails, CancellationToken c) + { + $end$ + } +}]]> + + +
+ + + +
+ Global Post-processor + postproc_g + global post-processor + NetCorePal Template + + Expansion + +
+ + + + name + Name of the processor + MyProcessor + + + + fails, CancellationToken c) + { + $end$ + } +}]]> + + +
+ + + +
+ Test Method + tstmethod + test method + NetCorePal Template + + Expansion + +
+ + + + testName + Name of the test method + Name_Of_The_Test + + + + + + +
+ + + +
+ NetCorePal Entity Configuration + ncpconfig + 创建实体配置类 + NetCorePal Template + + Expansion + +
+ + + + Entity + Entity name + Entity + + + table + Table name + table + + + + +{ + public void Configure(EntityTypeBuilder<$Entity$> builder) + { + builder.ToTable("$table$"); + builder.HasKey(t => t.Id); + builder.Property(t => t.Id) + /*.UseSnowFlakeValueGenerator()*/ // 如果使用 SnowFlake ID 生成器,请取消注释 + /*.UseGuidVersion7ValueGenerator()*/ // 如果使用 Guid Version 7 ID 生成器,请取消注释 + ; + + // Configure other properties if needed + $end$ + } +}]]> + + +
+ +
diff --git a/Demo.sln b/Demo.sln new file mode 100644 index 0000000..3491f4b --- /dev/null +++ b/Demo.sln @@ -0,0 +1,70 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.2.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Backend", "Backend", "{1AE8ACA6-933B-BF2A-3671-3E2EAC007D16}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{0F9113EE-888A-26D2-68B0-4A7D0A2A8745}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Fengling.Backend.Domain", "Backend\src\Fengling.Backend.Domain\Fengling.Backend.Domain.csproj", "{CE2A444C-B50B-C4C1-5E7B-0BF19A6A09D5}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Fengling.Backend.Infrastructure", "Backend\src\Fengling.Backend.Infrastructure\Fengling.Backend.Infrastructure.csproj", "{31C2D771-9C2A-D2B2-4F39-F0B7A0DAF5AA}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Fengling.Backend.Web", "Backend\src\Fengling.Backend.Web\Fengling.Backend.Web.csproj", "{0552385F-FB3D-8095-AD6A-F37DA8A4CF6F}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{C9F2D317-4826-407B-F8B2-698283998959}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Fengling.Backend.Domain.Tests", "Backend\test\Fengling.Backend.Domain.Tests\Fengling.Backend.Domain.Tests.csproj", "{437F144C-2866-F616-344E-0272933866A4}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Fengling.Backend.Infrastructure.Tests", "Backend\test\Fengling.Backend.Infrastructure.Tests\Fengling.Backend.Infrastructure.Tests.csproj", "{6CEBBDA6-E9DD-8DBD-539D-3E5CEE14B94D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Fengling.Backend.Web.Tests", "Backend\test\Fengling.Backend.Web.Tests\Fengling.Backend.Web.Tests.csproj", "{047DBC5E-0639-7E72-4F04-D54BEC040958}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {CE2A444C-B50B-C4C1-5E7B-0BF19A6A09D5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CE2A444C-B50B-C4C1-5E7B-0BF19A6A09D5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CE2A444C-B50B-C4C1-5E7B-0BF19A6A09D5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CE2A444C-B50B-C4C1-5E7B-0BF19A6A09D5}.Release|Any CPU.Build.0 = Release|Any CPU + {31C2D771-9C2A-D2B2-4F39-F0B7A0DAF5AA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {31C2D771-9C2A-D2B2-4F39-F0B7A0DAF5AA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {31C2D771-9C2A-D2B2-4F39-F0B7A0DAF5AA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {31C2D771-9C2A-D2B2-4F39-F0B7A0DAF5AA}.Release|Any CPU.Build.0 = Release|Any CPU + {0552385F-FB3D-8095-AD6A-F37DA8A4CF6F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0552385F-FB3D-8095-AD6A-F37DA8A4CF6F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0552385F-FB3D-8095-AD6A-F37DA8A4CF6F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0552385F-FB3D-8095-AD6A-F37DA8A4CF6F}.Release|Any CPU.Build.0 = Release|Any CPU + {437F144C-2866-F616-344E-0272933866A4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {437F144C-2866-F616-344E-0272933866A4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {437F144C-2866-F616-344E-0272933866A4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {437F144C-2866-F616-344E-0272933866A4}.Release|Any CPU.Build.0 = Release|Any CPU + {6CEBBDA6-E9DD-8DBD-539D-3E5CEE14B94D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6CEBBDA6-E9DD-8DBD-539D-3E5CEE14B94D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6CEBBDA6-E9DD-8DBD-539D-3E5CEE14B94D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6CEBBDA6-E9DD-8DBD-539D-3E5CEE14B94D}.Release|Any CPU.Build.0 = Release|Any CPU + {047DBC5E-0639-7E72-4F04-D54BEC040958}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {047DBC5E-0639-7E72-4F04-D54BEC040958}.Debug|Any CPU.Build.0 = Debug|Any CPU + {047DBC5E-0639-7E72-4F04-D54BEC040958}.Release|Any CPU.ActiveCfg = Release|Any CPU + {047DBC5E-0639-7E72-4F04-D54BEC040958}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {0F9113EE-888A-26D2-68B0-4A7D0A2A8745} = {1AE8ACA6-933B-BF2A-3671-3E2EAC007D16} + {CE2A444C-B50B-C4C1-5E7B-0BF19A6A09D5} = {0F9113EE-888A-26D2-68B0-4A7D0A2A8745} + {31C2D771-9C2A-D2B2-4F39-F0B7A0DAF5AA} = {0F9113EE-888A-26D2-68B0-4A7D0A2A8745} + {0552385F-FB3D-8095-AD6A-F37DA8A4CF6F} = {0F9113EE-888A-26D2-68B0-4A7D0A2A8745} + {C9F2D317-4826-407B-F8B2-698283998959} = {1AE8ACA6-933B-BF2A-3671-3E2EAC007D16} + {437F144C-2866-F616-344E-0272933866A4} = {C9F2D317-4826-407B-F8B2-698283998959} + {6CEBBDA6-E9DD-8DBD-539D-3E5CEE14B94D} = {C9F2D317-4826-407B-F8B2-698283998959} + {047DBC5E-0639-7E72-4F04-D54BEC040958} = {C9F2D317-4826-407B-F8B2-698283998959} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {7D2BA74C-6EAC-4D68-BA52-2E04A35B6593} + EndGlobalSection +EndGlobal diff --git a/docs/backend/后端功能概览.md b/docs/backend/后端功能概览.md new file mode 100644 index 0000000..bb0f773 --- /dev/null +++ b/docs/backend/后端功能概览.md @@ -0,0 +1,194 @@ +## 后端功能概览 + +> 本文档简要说明当前后端(Backend)已完成的主要功能与技术实现,便于后续开发和前后端联调。 + +--- + +## 一、整体技术栈与架构 + +- **技术栈** + - .NET 10 + - EF Core + SQLite + - CAP + NetCorePal 集成事件 + - FastEndpoints 用于 API 端点 + +- **架构风格** + - CleanDDD 分层结构:Domain / Infrastructure / Web + - 聚合根统一继承 `Entity, IAggregateRoot` + - 使用强类型 ID(`xxxId : IGuidStronglyTypedId`) + - 领域事件 + 集成事件 解耦跨聚合、跨事务操作 + - 命令(Command)与查询(Query)各自独立,命令处理器只操作仓储,不显式保存事务 + +--- + +## 二、已完成的核心聚合与领域模型 + +- **会员聚合(MemberAggregate)** + - 会员注册、登录 + - 基本信息:手机号、密码(加密后存储)、昵称 + - 会员等级值对象(Level):等级编码、名称、所需积分、积分倍率 + - 积分账户:累计积分、可用积分 + +- **营销码聚合(MarketingCodeAggregate)** + - 一物一码:唯一营销码与产品信息绑定 + - 状态管理:未使用/已使用 + - 使用营销码会发布 `MarketingCodeUsedDomainEvent` + +- **积分规则聚合(PointsRuleAggregate)** + - 支持按产品 / 类目 / 会员等级 / 时间区间配置规则 + - 可以计算应得积分(当前实现为“取匹配规则中的最高值”) + +- **积分交易聚合(PointsTransactionAggregate)** + - 已从会员聚合中拆分为独立聚合 + - 支持四类交易:获取(Earn)、消费(Consume)、退还(Refund)、过期(Expire) + - 通过集成事件异步创建记录(详见第四节) + +- **礼品聚合(GiftAggregate)** + - 礼品基本信息:名称、类型(实物/虚拟/自有产品)、描述、图片 + - 业务字段:所需积分、总库存、可用库存、每人限兑数量、是否上架、排序 + - 领域行为: + - `Update(...)` 更新礼品信息 + - `AddStock(...)` 增加库存 + - `ReserveStock(...)` 下单时预留库存 + - `DeductStock(...)` 发货时扣减总库存 + - `ReleaseStock(...)` 订单取消时释放库存 + - `PutOnShelf/PutOffShelf` 上/下架 + +- **兑换订单聚合(RedemptionOrderAggregate)** + - 基本字段:订单号、会员ID、礼品ID/名称/类型、数量、消耗积分 + - 收货地址值对象(Address) 用于实物礼品 + - 状态机:Pending → Dispatched → Delivered → Completed / Cancelled + - 领域行为:`MarkAsDispatched`、`MarkAsDelivered`、`Complete`、`Cancel` + - 领域事件: + - `RedemptionOrderCreatedDomainEvent`(创建订单) + - `RedemptionOrderDispatchedDomainEvent`(发货) + - `RedemptionOrderCancelledDomainEvent`(取消) + - 其他状态事件(已送达、完成) + +--- + +## 三、会员与积分业务流程 + +- **会员注册 / 登录** + - 命令: + - `RegisterMemberCommand`:校验手机号唯一性,保存会员 + - `LoginMemberCommand`:验证手机号+密码 + - 端点(示例): + - `POST /api/members/register` + - `POST /api/members/login` + +- **扫码使用营销码 → 发放积分** + - 命令:`UseMarketingCodeCommand` + - 校验营销码是否存在、是否可用 + - 关联会员后标记营销码为已使用 + - 更新由 `IMarketingCodeRepository` 完成 + - 领域事件:`MarketingCodeUsedDomainEvent` + - 领域事件处理器:`MarketingCodeUsedDomainEventHandlerForEarnPoints` + - 加载会员 + - 通过 `IPointsRuleRepository.GetEffectiveRulesAsync` 匹配积分规则 + - 计算应得积分 + 过期时间 + - 调用 `Member.AddPoints(...)` + - 更新会员(不会在此处创建积分交易记录) + +--- + +## 四、积分交易与集成事件流程 + +- **领域事件 → 集成事件** + - 会员增加积分时发布 `PointsAddedDomainEvent` + - Web 层有对应的集成事件转换器(IIntegrationEventConverter): + - `PointsAddedToPointsEarnedConverter` 等 + - 集成事件定义(实现 `IIntegrationEvent`): + - `PointsEarnedIntegrationEvent` + - `PointsConsumedIntegrationEvent` + - `PointsRefundedIntegrationEvent` + - `PointsExpiredIntegrationEvent` + - 集成事件只携带 **ID 和必要基础字段**(MemberId, Amount, RelatedId, ExpiryDate 等), 不包含聚合对象。 + +- **集成事件处理器(IIntegrationEventHandler)** + - 在 Web 层实现, 通过 `ApplicationDbContext` 读写积分交易表: + - 收到 Earn/Consume/Refund/Expire 事件 + - 基于 `RelatedId` 或批次ID 做幂等性检查 + - 调用 `PointsTransaction.CreateXxxTransaction(...)` 创建交易聚合 + - 保存到 `PointsTransactions` 表 + +- **事务与一致性** + - 积分变更(会员聚合) 与 积分交易记录(PointsTransaction 聚合) 处于不同事务 + - 通过领域事件 → 集成事件 → 处理器链路实现最终一致性 + +--- + +## 五、礼品与积分商城功能 + +- **礼品管理(管理端)** + - 命令(Commands): + - `CreateGiftCommand`:创建礼品 + - `UpdateGiftCommand`:更新礼品信息 + - `PutOnShelfCommand` / `PutOffShelfCommand`:上/下架 + - `AddGiftStockCommand`:增加库存 + - 查询(Queries): + - `GetGiftsQuery`:按类型/是否上架筛选列表 + - `GetGiftByIdQuery`:礼品详情 + - 仓储:`IGiftRepository` + `GiftRepository` + - 端点(Endpoints): + - `POST /api/admin/gifts` + - `PUT /api/admin/gifts/{GiftId}` + - `POST /api/admin/gifts/{GiftId}/putonshelf` + - `POST /api/admin/gifts/{GiftId}/putoffshelf` + - `POST /api/admin/gifts/{GiftId}/addstock` + - `GET /api/admin/gifts` + - `GET /api/admin/gifts/{GiftId}` + +- **会员端积分商城** + - 礼品浏览与详情: + - `GET /api/gifts`:仅返回已上架礼品 + - `GET /api/gifts/{GiftId}`:礼品详情 + - 兑换礼品(创建兑换订单): + - 命令:`CreateRedemptionOrderCommand` + - 校验礼品存在且已上架 + - 校验库存及每人限兑数量(通过 `IRedemptionOrderRepository.GetMemberRedemptionCountAsync`) + - 校验会员可用积分是否足够 + - 调用会员聚合 `ConsumePoints` 扣减积分并更新会员 + - 构造收货地址(实物礼品必须提供地址) + - 创建兑换订单聚合 `RedemptionOrder` + - 使用仓储 `IRedemptionOrderRepository.AddAsync` 持久化 + - 端点:`POST /api/gifts/redeem` + - 会员查询自己的兑换订单: + - 查询:`GetRedemptionOrdersQuery` / `GetRedemptionOrderByIdQuery` + - 端点: + - `GET /api/redemption-orders/my`(按会员+状态过滤) + - `GET /api/redemption-orders/{OrderId}` + +- **后台兑换订单管理(管理端)** + - 列表/详情:同上查询, 管理端端点前缀 `/api/admin/redemption-orders` + - 状态流转命令: + - `MarkOrderAsDispatchedCommand`:发货 + - `CompleteOrderCommand`:完成 + - `CancelOrderCommand`:取消 + - 对应端点: + - `POST /api/admin/redemption-orders/{OrderId}/dispatch` + - `POST /api/admin/redemption-orders/{OrderId}/complete` + - `POST /api/admin/redemption-orders/{OrderId}/cancel` + +- **兑换订单相关领域事件处理器** + - `RedemptionOrderCreatedDomainEventHandler`: + - 加载礼品聚合 + - 调用 `Gift.ReserveStock(...)` 预留库存 + - 调用仓储 `UpdateAsync` 持久化 + - `RedemptionOrderCancelledDomainEventHandler`: + - 释放库存 `Gift.ReleaseStock(...)` + - `RedemptionOrderDispatchedDomainEventHandler`: + - 发货后扣减总库存 `Gift.DeductStock(...)` + +--- + +## 六、当前状态与后续工作建议 + +- **当前状态** + - 后端项目可以成功编译(`dotnet build`);存在若干 Sonar 质量规则警告(主要是 TODO/异常处理提示), 不影响运行 + - 会员、营销码、积分规则、积分交易、礼品、兑换订单等核心领域模型和主要业务流程已经打通 + +- **后续可选工作** + - 增加更多查询(如礼品统计、积分流水查询分页等) + - 完善认证鉴权(从 JWT 中获取当前登录会员ID, 替换临时 MemberId 参数) + - 根据前端实际需要, 补充 DTO 字段或调整 API 结构 diff --git a/docs/需求分析确认单.md b/docs/需求分析确认单.md new file mode 100644 index 0000000..91d034b --- /dev/null +++ b/docs/需求分析确认单.md @@ -0,0 +1,29 @@ +## 待确认清单1 + +身份验证方式 目前阶段不用验证码 只需要手机号和密码登录 +营销码生成 营销码可以写一个简单的生码系统来生成 +积分规则优先级 不允许维度相同的规则存在 所以也不需要优先级 也就是创建时需要校验 +积分有效期 积分有有效期 +会员等级规则 要做一个会员等级的规则来配置会员的升级规则 +礼品库存同步 不需要同步能力 只需要CRUD来创建 +虚拟礼品发放 可以只留接口 不实现 +物流跟踪 暂时不对接 +分布式事务 不用分布式事务 但是可以用cap保证最终一致性 +并发控制 命令锁/悲观锁来处理即可 +审计权限 暂时不做 +数据导出 暂时支持csv即可 + + +## 技术栈选型 + +1. 后端.net clean-ddd全套 + + +## 系统边界 + +1. 只需要支持营销码生成模块 兑换核销暂时不做 + + +## 第一期MVP反胃 + +实现核心流程 diff --git a/docs/需求说明书.md b/docs/需求说明书.md new file mode 100644 index 0000000..2d9583e --- /dev/null +++ b/docs/需求说明书.md @@ -0,0 +1,108 @@ +好的,根据您提供的思路,我为您完善了“一物一码会员营销系统”的业务说明部分。这份说明侧重于梳理核心业务逻辑和流程,希望能帮助您更清晰地表达项目规划。 + +一物一码会员营销系统 - 业务需求说明 + +1. 会员体系构建 + +1.1 核心业务逻辑:一物一码关联 + +本系统的基石是“一物一码”技术,即每个产品上的营销码都是唯一的。消费者购买产品后,通过扫描该码,即可触发一系列会员相关的业务操作。这不仅是防伪验真的手段,更是将线下消费行为转化为线上会员关系的核心入口。 + +1.2 前端主要业务功能 + +面向消费者(C端用户)的前端业务流如下: + +• 会员注册/登录:提供基于手机号的快速注册与登录流程。业务上,重点在于扫码即注册或扫码后引导注册的无感化体验,极大降低会员转化门槛。 + +• 积分获取与反馈: + + ◦ 主要途径:消费者扫描产品上的营销码,经系统校验通过后,自动获得相应积分。这是积分获取的核心业务场景。 + + ◦ 即时反馈:前端需清晰展示本次扫码获得的积分数、积分来源(如:购买XX产品)以及当前总积分。 + + ◦ 规则透明:提供积分记录查询页面,让会员清晰了解每一笔积分的来源(扫码、活动等)和消耗(兑换、过期等)详情。 + +• 会员中心:展示会员基本信息、等级权益、积分余额与明细。 + +1.3 后端主要业务功能 + +面向系统管理员(Admin端)的后端业务功能,旨在支撑前端业务流并进行有效管理: + +• 会员基础管理:支持会员账号的创建、查询、禁用及信息维护。核心在于将扫码行为与会员ID精准绑定,构建统一的会员档案。 + +• 积分配置与规则引擎(核心业务): + + ◦ 规则创建:Admin端能够灵活配置积分规则。这包括: + + ▪ 基于产品/品类:为不同产品、不同品类设置不同的积分值。例如,高利润产品可配置更高积分以刺激购买。 + + ▪ 基于时间/活动:支持设置限时积分翻倍、节假日专属积分等活动规则。 + + ▪ 唯一性校验(去重规则):这是业务规则的关键。系统必须确保同一个营销码只能被积分一次。当同一码被多次扫描时,系统应能识别并拒绝重复积分,同时可向后续扫描者展示“该码已被兑换”等提示,保障营销费用的公平性与有效性。 + + ◦ 规则发布与生效:配置的规则需有明确的生效时间和状态控制,并支持测试验证。 + +• 积分记录审计:系统自动记录所有积分流水,包括会员ID、营销码、积分值、获取时间、规则来源等。Admin端需提供强大的查询与导出功能,用于业务分析、对账和争议处理。 + +• 会员画像与分层:基于会员的扫码频率、产品偏好、积分积累等数据,系统可自动为会员打标签(如:高频用户、母婴爱好者),或进行等级划分(如:普通、白银、黄金会员),并配置不同权益,为精准营销提供数据基础。 + +表:积分获取规则配置示例 + +规则维度 配置示例 业务目的 + +产品维度 A产品扫码得10分,B产品扫码得20分 引导消费高附加值产品 + +时间维度 国庆期间(10.1-10.7)扫码积分翻倍 刺激节假日销量 + +人群维度 黄金等级会员额外获得10%积分 提升高价值用户忠诚度 + +防作弊维度 同一手机号对同一营销码仅积分一次 防止刷单,保障营销成本 + +2. 会员积分消耗体系 + +积分消耗是提升会员粘性和促进复购的关键环节,其主要载体是积分商城。 + +2.1 积分商城的礼品库构建 + +礼品库是积分兑换业务的基础物资中心,其业务管理要点包括: + +• 礼品类型管理:支持添加和管理多种类型的礼品,主要包括: + + ◦ 实物礼品:如电子产品、品牌周边、定制商品等,需关联库存管理系统。 + + ◦ 虚拟礼品:如电商平台优惠券、视频会员卡、话费充值卡等,可实现自动发放和核销。 + + ◦ 自有产品:可将部分产品也纳入礼品库,允许会员使用积分直接兑换或部分抵扣货款。 + +• 礼品信息与规则:为每个礼品设置名称、图片、详细说明、所需积分值、兑换总量和单人限兑次数等。 + +• 上下架与库存管理:支持礼品的上架(开放兑换)和下架(暂停兑换)操作,并对实物礼品进行库存预警和同步管理。 + +2.2 积分商城运营业务流 + +• 前端展示与兑换:前端应打造清晰的积分商城页面,对礼品进行分类展示(如:热门兑换、虚拟卡券、实物大奖)。会员选择礼品并确认兑换后,系统核心业务逻辑是:校验积分余额是否充足 → 校验礼品库存及限兑规则 → 扣减对应积分 → 生成一条积分兑换订单。 + +• 后端管理与配置:Admin端负责商城的日常运营,包括礼品的CRUD(增删改查)、设置热门推荐、监控兑换数据、并根据兑换情况调整礼品策略以控制成本和提升吸引力。 + +3. 会员订单体系构建 + +此处的订单特指由积分消费行为产生的“积分兑换订单”,与传统电商的销售订单隔离,便于专门管理。 + +3.1 积分兑换订单生成 + +当会员在积分商城成功兑换礼品后,系统自动生成一条积分兑换订单。该订单应包含以下核心业务信息: +• 订单基本信息:订单号、生成时间、会员ID。 + +• 兑换内容:礼品ID、礼品名称、兑换数量、消耗积分总额。 + +• 订单状态:待处理、已发货(实物)、已发放(虚拟)、已完成、已取消。 + +3.2 订单履约与跟踪 + +• 虚拟礼品订单:系统应尽可能实现自动化。订单生成后,状态自动变为“已发放”,并通过站内信、短信或邮件等方式将卡密或链接发送给会员,随后订单可自动关闭或设置有效期为“已完成”。 + +• 实物礼品订单:订单状态为“待处理”。后台运营人员需手动处理:审核订单 -> 联系物流发货 -> 在后台录入发货单号 -> 将订单状态更新为“已发货”。系统可将发货信息同步给会员,并提供物流跟踪接口。 + +• 订单查询:会员可在前端查看自己所有的积分兑换订单及其状态。Admin端则具备全面的订单查询、筛选和导出功能,用于履约管理和数据分析。 + +希望这份基于您思路完善的业务说明能对您有所帮助。如果您对某个业务环节有更具体的想法或需要进一步展开,我们可以继续讨论。 \ No newline at end of file