commit ab8d12527e258d066559e906b80ed6e50359a997 Author: sam Date: Sun Feb 15 10:34:07 2026 +0800 refactor: major project restructuring and cleanup Changes: - Remove deprecated Fengling.Activity and YarpGateway.Admin projects - Add points processing services with distributed lock support - Update Vben frontend with gateway management pages - Add gateway config controller and database listener - Update routing to use header-mixed-nav layout - Add comprehensive test suites for Member services - Add YarpGateway integration tests - Update package versions in Directory.Packages.props Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3729ff0 --- /dev/null +++ b/.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/.gitattributes b/.gitattributes new file mode 100644 index 0000000..a1e1e97 --- /dev/null +++ b/.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/.gitignore b/.gitignore new file mode 100644 index 0000000..d2ed0a3 --- /dev/null +++ b/.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/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..ca141d0 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,34 @@ + + + + Fengling.Activity + Fengling.Activity + Fengling.Activity + Fengling.Activity + + + + 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/Directory.Build.targets b/Directory.Build.targets new file mode 100644 index 0000000..f52a621 --- /dev/null +++ b/Directory.Build.targets @@ -0,0 +1,3 @@ + + + diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..0d3de24 --- /dev/null +++ b/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/Fengling.Activity.sln.DotSettings b/Fengling.Activity.sln.DotSettings new file mode 100644 index 0000000..e65a68a --- /dev/null +++ b/Fengling.Activity.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/NuGet.config b/NuGet.config new file mode 100644 index 0000000..4a09aa7 --- /dev/null +++ b/NuGet.config @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..91ee5d9 --- /dev/null +++ b/README.md @@ -0,0 +1,230 @@ +# Fengling.Activity + +## 环境准备 + +### 使用 Aspire(推荐) + +如果您的项目启用了 Aspire 支持(使用 `--UseAspire` 参数创建),只需要 Docker 环境即可,无需手动配置各种基础设施服务。 + +```bash +# 仅需确保 Docker 环境运行 +docker version + +# 直接运行 AppHost 项目,Aspire 会自动管理所有依赖服务 +cd src/Fengling.Activity.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.Activity.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.Activity.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.Activity.Infrastructure +``` + +## 代码分析可视化 + +框架提供了强大的代码流分析和可视化功能,帮助开发者直观地理解DDD架构中的组件关系和数据流向。 + +### 🎯 核心特性 + ++ **自动代码分析**:通过源生成器自动分析代码结构,识别控制器、命令、聚合根、事件等组件 ++ **多种图表类型**:支持架构流程图、命令链路图、事件流程图、类图等多种可视化图表 ++ **交互式HTML可视化**:生成完整的交互式HTML页面,内置导航和图表预览功能 ++ **一键在线编辑**:集成"View in Mermaid Live"按钮,支持一键跳转到在线编辑器 + +### 🚀 快速开始 + +安装命令行工具来生成独立的HTML文件: + +```bash +# 安装全局工具 +dotnet tool install -g NetCorePal.Extensions.CodeAnalysis.Tools + +# 进入项目目录并生成可视化文件 +cd src/Fengling.Activity.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/docker-compose.gateway.yml b/docker-compose.gateway.yml new file mode 100644 index 0000000..0a1f3cc --- /dev/null +++ b/docker-compose.gateway.yml @@ -0,0 +1,58 @@ +version: '3.8' + +services: + # Activity Service + activity-service: + build: + context: . + dockerfile: Dockerfile + ports: + - "5001:8080" + environment: + - ASPNETCORE_ENVIRONMENT=Development + - ConnectionStrings__PostgreSQL=Host=postgres;Port=15432;Database=fengling_activity;Username=postgres;Password=postgres + - ConnectionStrings__Redis=Host=redis;Port=6379 + depends_on: + - postgres + - redis + networks: + - fengling-network + + # YARP Gateway + gateway: + image: mcr.microsoft.com/dotnet/aspnet:10.0 + ports: + - "5000:8080" + volumes: + - ./gateway-config.json:/app/gateway-config.json:ro + environment: + - ASPNETCORE_ENVIRONMENT=Development + networks: + - fengling-network + + # PostgreSQL (shared instance) + postgres: + image: postgres:14 + environment: + - POSTGRES_PASSWORD=postgres + ports: + - "5432:5432" + volumes: + - postgres-data:/var/lib/postgresql/data + networks: + - fengling-network + + # Redis (shared instance) + redis: + image: redis:7-alpine + ports: + - "6379:6379" + networks: + - fengling-network + +networks: + fengling-network: + driver: bridge + +volumes: + postgres-data: diff --git a/docs/GATEWAY_INTEGRATION.md b/docs/GATEWAY_INTEGRATION.md new file mode 100644 index 0000000..3c5b55e --- /dev/null +++ b/docs/GATEWAY_INTEGRATION.md @@ -0,0 +1,273 @@ +# Activity Service Gateway Integration Guide + +## Overview + +This document describes how to integrate the Activity Engine microservice with the YARP reverse proxy gateway. + +## Architecture + +``` +Client Request + │ + ▼ +┌─────────────────────────────────┐ +│ YARP Gateway │ +│ │ +│ Route: /api/activity/** │ +│ Cluster: activity-service │ +└─────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────┐ +│ Activity Service │ +│ http://localhost:5001 │ +│ │ +│ GET /api/activity/campaigns │ +│ POST /api/activity/campaigns │ +│ GET /api/activity/campaigns/.. │ +└─────────────────────────────────┘ +``` + +## Configuration Steps + +### Option 1: Using Command Line Script + +#### Bash (Linux/macOS) +```bash +# Make script executable +chmod +x scripts/gateway/register-activity-service.sh + +# Register with global route (all tenants) +./scripts/gateway/register-activity-service.sh \ + --gateway-url "http://localhost:5000" \ + --service-name "activity" \ + --cluster-id "activity-service" \ + --address "http://localhost:5001" + +# Register with tenant-specific route +./scripts/gateway/register-activity-service.sh \ + --gateway-url "http://localhost:5000" \ + --tenant-code "tenant001" \ + --service-name "activity" \ + --address "http://localhost:5001" +``` + +#### PowerShell (Windows) +```powershell +# Register with global route +pwsh scripts/gateway/Register-ActivityService.ps1 ` + -GatewayUrl "http://localhost:5000" ` + -ServiceName "activity" ` + -ClusterId "activity-service" ` + -InstanceAddress "http://localhost:5001" ` + -IsGlobal + +# Register with tenant-specific route +pwsh scripts/gateway/Register-ActivityService.ps1 ` + -GatewayUrl "http://localhost:5000" ` + -TenantCode "tenant001" ` + -ServiceName "activity" ` + -InstanceAddress "http://localhost:5001" +``` + +### Option 2: Using Gateway Management API + +#### 1. Add Service Instance +```bash +curl -X POST "http://localhost:5000/api/gateway/clusters/activity-service/instances" \ + -H "Content-Type: application/json" \ + -d '{ + "destinationId": "activity-1", + "address": "http://localhost:5001", + "weight": 1 + }' +``` + +#### 2. Add Global Route +```bash +curl -X POST "http://localhost:5000/api/gateway/routes/global" \ + -H "Content-Type: application/json" \ + -d '{ + "serviceName": "activity", + "clusterId": "activity-service", + "pathPattern": "/api/activity/{**path}" + }' +``` + +#### 3. Reload Configuration +```bash +curl -X POST "http://localhost:5000/api/gateway/reload" +``` + +### Option 3: Database Direct Insert + +#### Insert Service Instance +```sql +INSERT INTO "ServiceInstances" ( + "Id", + "ClusterId", + "DestinationId", + "Address", + "Health", + "Weight", + "Status", + "CreatedTime" +) +VALUES ( + EXTRACT(EPOCH FROM NOW())::bigint * 1000, + 'activity-service', + 'activity-1', + 'http://localhost:5001', + 1, + 1, + 1, + NOW() +); +``` + +#### Insert Global Route +```sql +INSERT INTO "TenantRoutes" ( + "Id", + "TenantCode", + "ServiceName", + "ClusterId", + "PathPattern", + "Priority", + "Status", + "IsGlobal", + "CreatedTime" +) +VALUES ( + EXTRACT(EPOCH FROM NOW())::bigint * 1000 + 1, + '', + 'activity', + 'activity-service', + '/api/activity/{**path}', + 0, + 1, + true, + NOW() +); +``` + +## API Endpoints + +After configuration, the following endpoints will be available through the gateway: + +| Method | Path | Description | +|--------|------|-------------| +| POST | `/api/activity/api/campaigns` | Create a new campaign | +| GET | `/api/activity/api/campaigns` | List all campaigns | +| GET | `/api/activity/api/campaigns/{id}` | Get campaign by ID | +| POST | `/api/activity/api/campaigns/{id}/publish` | Publish a campaign | + +## Health Check + +The Activity service exposes a health check endpoint at `/health`: + +```bash +# Direct access +curl http://localhost:5001/health + +# Through gateway +curl http://localhost:5000/api/activity/health +``` + +## Multiple Instances + +For high availability, add multiple service instances: + +```bash +# Add second instance +curl -X POST "http://localhost:5000/api/gateway/clusters/activity-service/instances" \ + -H "Content-Type: application/json" \ + -d '{ + "destinationId": "activity-2", + "address": "http://localhost:5002", + "weight": 1 + }' + +# Reload configuration +curl -X POST "http://localhost:5000/api/gateway/reload" +``` + +## Load Balancing + +The gateway uses **Distributed Weighted Round Robin** load balancing by default. Adjust weights for traffic distribution: + +```bash +# Increase weight for a high-performance instance +curl -X PUT "http://localhost:5000/api/gateway/instances/12345" \ + -H "Content-Type: application/json" \ + -d '{"weight": 2}' +``` + +## Monitoring + +### View Routes +```bash +# Global routes +curl http://localhost:5000/api/gateway/routes/global + +# Tenant routes +curl http://localhost:5000/api/gateway/tenants/{tenantCode}/routes +``` + +### View Instances +```bash +curl http://localhost:5000/api/gateway/clusters/activity-service/instances +``` + +## Troubleshooting + +### Route Not Working +1. Check if route is configured: `GET /api/gateway/routes/global` +2. Verify instance health: `GET /api/gateway/clusters/{clusterId}/instances` +3. Reload configuration: `POST /api/gateway/reload` + +### 404 Not Found +- Ensure the path pattern matches the request URL +- Check if the route is active (Status = 1) + +### 502 Bad Gateway +- Verify the service instance is running +- Check the service address is accessible from the gateway +- Verify health check endpoint: `GET /health` + +## Configuration Reference + +### GwServiceInstance Fields + +| Field | Type | Description | +|-------|------|-------------| +| ClusterId | string | Unique cluster identifier | +| DestinationId | string | Unique destination identifier | +| Address | string | Service address (http://host:port) | +| Weight | int | Load balancing weight (default: 1) | +| Health | int | Health status (1 = healthy) | +| Status | int | Active status (1 = active) | + +### GwTenantRoute Fields + +| Field | Type | Description | +|-------|------|-------------| +| TenantCode | string | Tenant identifier (empty for global) | +| ServiceName | string | Logical service name | +| ClusterId | string | Target cluster identifier | +| PathPattern | string | URL path pattern with placeholders | +| Priority | int | Route priority (lower = higher priority) | +| IsGlobal | bool | Whether route applies to all tenants | + +## Path Pattern Syntax + +YARP uses ASP.NET Core routing syntax for path patterns: + +| Pattern | Description | +|---------|-------------| +| `/api/activity` | Exact match | +| `/api/activity/{**path}` | Catch-all segment | +| `/api/activity/{id}` | Parameter segment | +| `/api/activity/{id}/rewards` | Multiple parameters | + +For more details, see [ASP.NET Core Routing](https://docs.microsoft.com/en-us/aspnet/core/fundamentals/routing) diff --git a/docs/NAMING_CONVENTION.md b/docs/NAMING_CONVENTION.md new file mode 100644 index 0000000..a598974 --- /dev/null +++ b/docs/NAMING_CONVENTION.md @@ -0,0 +1,198 @@ +# Service Naming Convention + +## Overview + +This document defines the naming convention for all microservices in the Fengling platform. + +## URL Structure + +``` +{ServicePrefix}/{Version}/{Resource}/{Action} +``` + +### Components + +| Component | Description | Examples | +|-----------|-------------|----------| +| ServicePrefix | Unique service identifier | `activity`, `member`, `order` | +| Version | API version | `v1`, `v2` | +| Resource | Domain entity or resource | `campaigns`, `users`, `orders` | +| Action | Optional action endpoint | `publish`, `cancel` | + +## Service Registry + +| Service | Prefix | Port | Health Endpoint | +|---------|--------|------|-----------------| +| Activity | `activity` | 5001 | `/health` | +| Member | `member` | 5002 | `/health` | +| Order | `order` | 5003 | `/health` | +| Payment | `payment` | 5004 | `/health` | +| RiskControl | `risk` | 5005 | `/health` | + +## Cluster Naming Convention + +``` +{ServicePrefix}-service +``` + +Examples: +- `activity-service` +- `member-service` +- `order-service` +- `payment-service` +- `risk-service` + +## Gateway Route Configuration + +### Global Route Pattern +``` +/{ServicePrefix}/{**path} -> {ServicePrefix}-service +``` + +### Examples + +**Activity Service:** +```yaml +Route: + PathPattern: /activity/v1/{**path} + ClusterId: activity-service + +Endpoints: + GET /activity/v1/campaigns # List campaigns + POST /activity/v1/campaigns # Create campaign + GET /activity/v1/campaigns/{id} # Get campaign + POST /activity/v1/campaigns/{id}/publish # Publish campaign +``` + +**Member Service:** +```yaml +Route: + PathPattern: /member/v1/{**path} + ClusterId: member-service + +Endpoints: + GET /member/v1/users # List users + POST /member/v1/users # Create user + GET /member/v1/users/{id} # Get user + PUT /member/v1/users/{id} # Update user +``` + +**Order Service:** +```yaml +Route: + PathPattern: /order/v1/{**path} + ClusterId: order-service + +Endpoints: + GET /order/v1/orders # List orders + POST /order/v1/orders # Create order + GET /order/v1/orders/{id} # Get order + PUT /order/v1/orders/{id}/cancel # Cancel order +``` + +## Gateway Registration + +### Activity Service Registration Script + +```bash +# Register Activity Service +./scripts/gateway/register-service.sh \ + --service-prefix "activity" \ + --gateway-url "http://localhost:5000" \ + --address "http://localhost:5001" + +# After registration, access endpoints at: +# GET http://localhost:5000/activity/v1/campaigns +# POST http://localhost:5000/activity/v1/campaigns +``` + +## Versioning Strategy + +### Version Lifecycle + +| Version | Status | Description | +|---------|--------|-------------| +| v1 | Active | Current stable API | +| v2 | Planning | Next major version | +| beta | Testing | Beta releases | + +### Versioning Rules + +1. **Major Version** (`v1`, `v2`): Breaking changes require version bump +2. **Minor Updates**: Backward-compatible additions don't require version change +3. **Deprecation**: Old versions should be supported for at least 6 months + +## Multi-Tenancy Support + +### Tenant-Specific Routes + +For dedicated tenant instances: +``` +/tenant/{tenantCode}/{ServicePrefix}/{Version}/{**path} +``` + +Example: +``` +/tenant/acme/activity/v1/campaigns +``` + +## Health Check Endpoints + +| Service | Health Path | +|---------|-------------| +| Activity | `/health` | +| Member | `/health` | +| Order | `/health` | +| Payment | `/health` | +| Risk | `/health` | + +## Monitoring & Metrics + +### Service Labels + +| Label | Value | +|-------|-------| +| service | `activity` | +| version | `v1` | +| tenant | `{tenantCode}` | + +## Best Practices + +### 1. Consistent Naming +- Use lowercase for all components +- Use hyphens for multi-word names: `risk-control` NOT `riskControl` +- Avoid abbreviations: `campaigns` NOT `cmps` + +### 2. Resource Naming +- Use plural nouns for collections: `campaigns` NOT `campaignList` +- Use singular for single resources: `campaign/{id}` NOT `campaigns/{id}` + +### 3. Action Endpoints +- Use HTTP verbs for CRUD: `GET`, `POST`, `PUT`, `DELETE` +- Use specific verbs for actions: `publish`, `cancel`, `activate` + +### 4. Path Parameters +- Use descriptive names: `/campaigns/{campaignId}` NOT `/campaigns/{id}` +- Consistent parameter naming across services + +## Migration Guide + +### Updating Existing Routes + +**Before:** +``` +/api/campaigns +/api/orders +``` + +**After:** +``` +/activity/v1/campaigns +/order/v1/orders +``` + +**Migration Strategy:** +1. Register new routes with v1 +2. Keep old routes active (alias) +3. Update clients to use new format +4. Remove old routes after transition period diff --git a/eng/versions.props b/eng/versions.props new file mode 100644 index 0000000..0dabf26 --- /dev/null +++ b/eng/versions.props @@ -0,0 +1,6 @@ + + + 1.0.0 + + + diff --git a/global.json b/global.json new file mode 100644 index 0000000..5ed20a7 --- /dev/null +++ b/global.json @@ -0,0 +1,7 @@ +{ + "sdk": { + "version": "10.0.100", + "allowPrerelease": true, + "rollForward": "latestMinor" + } +} \ No newline at end of file diff --git a/scripts/EXAMPLES.md b/scripts/EXAMPLES.md new file mode 100644 index 0000000..b243c2c --- /dev/null +++ b/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.Activity.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=15432;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/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..eb9d22c --- /dev/null +++ b/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/scripts/clean-infrastructure.ps1 b/scripts/clean-infrastructure.ps1 new file mode 100644 index 0000000..a031c17 --- /dev/null +++ b/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/scripts/clean-infrastructure.sh b/scripts/clean-infrastructure.sh new file mode 100644 index 0000000..6673955 --- /dev/null +++ b/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/scripts/docker-compose.yml b/scripts/docker-compose.yml new file mode 100644 index 0000000..9c8af89 --- /dev/null +++ b/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/scripts/gateway/Register-ActivityService.ps1 b/scripts/gateway/Register-ActivityService.ps1 new file mode 100644 index 0000000..e356d0a --- /dev/null +++ b/scripts/gateway/Register-ActivityService.ps1 @@ -0,0 +1,135 @@ +# Activity服务接入YARP网关配置脚本 +# PowerShell: pwsh Register-ActivityService.ps1 -GatewayUrl "http://localhost:5000" + +param( + [string]$GatewayUrl = "http://localhost:5000", + [string]$ServiceName = "activity", + [string]$ClusterId = "activity-service", + [string]$PathPattern = "/api/activity/{**path}", + [string]$InstanceAddress = "http://localhost:5001", + [string]$DestinationId = "activity-1", + [int]$Weight = 1, + [switch]$IsGlobal = $true, + [string]$TenantCode = "" +) + +$ErrorActionPreference = "Stop" + +function Register-ActivityService { + param( + [string]$GatewayUrl, + [string]$ServiceName, + [string]$ClusterId, + [string]$PathPattern, + [string]$InstanceAddress, + [string]$DestinationId, + [int]$Weight, + [bool]$IsGlobal, + [string]$TenantCode + ) + + Write-Host "========================================" -ForegroundColor Cyan + Write-Host "Activity Service Gateway Configuration" -ForegroundColor Cyan + Write-Host "========================================" -ForegroundColor Cyan + Write-Host "" + + # Step 1: Add Service Instance (Cluster) + Write-Host "[Step 1] Adding service instance to cluster..." -ForegroundColor Yellow + $instanceBody = @{ + destinationId = $DestinationId + address = $InstanceAddress + weight = $Weight + } | ConvertTo-Json + + try { + $instanceResponse = Invoke-RestMethod -Uri "$GatewayUrl/api/gateway/clusters/$ClusterId/instances" ` + -Method Post ` + -ContentType "application/json" ` + -Body $instanceBody + + Write-Host " ✓ Instance added: $DestinationId -> $InstanceAddress" -ForegroundColor Green + } + catch { + if ($_.Exception.Response.StatusCode -eq "BadRequest") { + Write-Host " ℹ Instance already exists, skipping..." -ForegroundColor Gray + } + else { + throw $_ + } + } + + # Step 2: Add Route + Write-Host "" + Write-Host "[Step 2] Adding gateway route..." -ForegroundColor Yellow + + if ($IsGlobal) { + $routeBody = @{ + serviceName = $ServiceName + clusterId = $ClusterId + pathPattern = $PathPattern + } | ConvertTo-Json + + $routeUrl = "$GatewayUrl/api/gateway/routes/global" + $routeDescription = "global route" + } + else { + $routeBody = @{ + serviceName = $ServiceName + pathPattern = $PathPattern + } | ConvertTo-Json + + $routeUrl = "$GatewayUrl/api/gateway/tenants/$TenantCode/routes" + $routeDescription = "tenant route for $TenantCode" + } + + try { + $routeResponse = Invoke-RestMethod -Uri $routeUrl ` + -Method Post ` + -ContentType "application/json" ` + -Body $routeBody + + Write-Host " ✓ Route added: $PathPattern -> $ClusterId" -ForegroundColor Green + } + catch { + if ($_.Exception.Response.StatusCode -eq "BadRequest") { + Write-Host " ℹ Route already exists, skipping..." -ForegroundColor Gray + } + else { + throw $_ + } + } + + # Step 3: Reload Config + Write-Host "" + Write-Host "[Step 3] Reloading gateway configuration..." -ForegroundColor Yellow + + $reloadResponse = Invoke-RestMethod -Uri "$GatewayUrl/api/gateway/reload" -Method Post + Write-Host " ✓ Configuration reloaded" -ForegroundColor Green + + # Summary + Write-Host "" + Write-Host "========================================" -ForegroundColor Cyan + Write-Host "Configuration Complete!" -ForegroundColor Cyan + Write-Host "========================================" -ForegroundColor Cyan + Write-Host "" + Write-Host "Service: $ServiceName" -ForegroundColor White + Write-Host "Cluster: $ClusterId" -ForegroundColor White + Write-Host "Route: $routeDescription" -ForegroundColor White + Write-Host "Pattern: $PathPattern" -ForegroundColor White + Write-Host "Target: $InstanceAddress" -ForegroundColor White + Write-Host "" + Write-Host "Test the service at:" -ForegroundColor Yellow + Write-Host " $GatewayUrl$($PathPattern.Replace('{**path}', 'campaigns'))" -ForegroundColor Cyan + Write-Host "" +} + +# Execute +Register-ActivityService -GatewayUrl $GatewayUrl ` + -ServiceName $ServiceName ` + -ClusterId $ClusterId ` + -PathPattern $PathPattern ` + -InstanceAddress $InstanceAddress ` + -DestinationId $DestinationId ` + -Weight $Weight ` + -IsGlobal $IsGlobal.IsPresent ` + -TenantCode $TenantCode diff --git a/scripts/gateway/Register-Service.ps1 b/scripts/gateway/Register-Service.ps1 new file mode 100644 index 0000000..3b97949 --- /dev/null +++ b/scripts/gateway/Register-Service.ps1 @@ -0,0 +1,118 @@ +# Service Gateway Registration Script +param( + [string]$Prefix = "activity", + [string]$Version = "v1", + [string]$GatewayUrl = "http://localhost:5000", + [string]$Address = "http://localhost:5001", + [string]$DestinationId = $null, + [int]$Weight = 1, + [switch]$Global = $true, + [string]$TenantCode = "" +) + +$ErrorActionPreference = "Stop" + +# Derived values +$ClusterId = "$Prefix-service" +if ([string]::IsNullOrEmpty($DestinationId)) { + $DestinationId = "$Prefix-1" +} +$PathPattern = "/$Prefix/$Version/{**path}" + +Write-Host "╔════════════════════════════════════════════════════════════╗" -ForegroundColor Cyan +Write-Host "║ Service Gateway Registration ║" -ForegroundColor Cyan +Write-Host "╚════════════════════════════════════════════════════════════╝" -ForegroundColor Cyan +Write-Host "" + +# Step 1: Add Service Instance +Write-Host "[1/3] Adding service instance..." -ForegroundColor Yellow + +$instanceBody = @{ + destinationId = $DestinationId + address = $Address + weight = $Weight +} | ConvertTo-Json + +try { + $null = Invoke-RestMethod -Uri "$GatewayUrl/api/gateway/clusters/$ClusterId/instances" ` + -Method Post ` + -ContentType "application/json" ` + -Body $instanceBody + Write-Host " ✓ Instance: $DestinationId -> $Address" -ForegroundColor Green +} +catch { + if ($_.Exception.Response.StatusCode -eq "BadRequest") { + Write-Host " ℹ Instance may already exist" -ForegroundColor Gray + } + else { + throw $_ + } +} + +# Step 2: Add Route +Write-Host "" +Write-Host "[2/3] Configuring gateway route..." -ForegroundColor Yellow + +if ($Global) { + $routeBody = @{ + serviceName = $Prefix + clusterId = $ClusterId + pathPattern = $PathPattern + } | ConvertTo-Json + $routeType = "Global" +} +else { + $routeBody = @{ + serviceName = $Prefix + pathPattern = $PathPattern + } | ConvertTo-Json + $routeType = "Tenant [$TenantCode]" +} + +try { + $routeUrl = if ($Global) { + "$GatewayUrl/api/gateway/routes/global" + } + else { + "$GatewayUrl/api/gateway/tenants/$TenantCode/routes" + } + $null = Invoke-RestMethod -Uri $routeUrl ` + -Method Post ` + -ContentType "application/json" ` + -Body $routeBody + Write-Host " ✓ Route: $PathPattern -> $ClusterId" -ForegroundColor Green +} +catch { + if ($_.Exception.Response.StatusCode -eq "BadRequest") { + Write-Host " ℹ Route may already exist" -ForegroundColor Gray + } + else { + throw $_ + } +} + +# Step 3: Reload Config +Write-Host "" +Write-Host "[3/3] Reloading gateway configuration..." -ForegroundColor Yellow +$null = Invoke-RestMethod -Uri "$GatewayUrl/api/gateway/reload" -Method Post +Write-Host " ✓ Configuration reloaded" -ForegroundColor Green + +# Summary +Write-Host "" +Write-Host "════════════════════════════════════════════════════════════" -ForegroundColor Cyan +Write-Host " Service Registered Successfully!" -ForegroundColor Cyan +Write-Host "════════════════════════════════════════════════════════════" -ForegroundColor Cyan +Write-Host "" +Write-Host " Service: $Prefix" -ForegroundColor White +Write-Host " Version: $Version" -ForegroundColor White +Write-Host " Cluster: $ClusterId" -ForegroundColor White +Write-Host " Type: $routeType" -ForegroundColor White +Write-Host " Pattern: $PathPattern" -ForegroundColor White +Write-Host " Address: $Address" -ForegroundColor White +Write-Host "" +Write-Host " Available Endpoints:" -ForegroundColor Yellow +Write-Host " GET $GatewayUrl/$Prefix/$Version/campaigns" -ForegroundColor Cyan +Write-Host " POST $GatewayUrl/$Prefix/$Version/campaigns" -ForegroundColor Cyan +Write-Host " GET $GatewayUrl/$Prefix/$Version/campaigns/{id}" -ForegroundColor Cyan +Write-Host " POST $GatewayUrl/$Prefix/$Version/campaigns/{id}/publish" -ForegroundColor Cyan +Write-Host "" diff --git a/scripts/gateway/register-service.sh b/scripts/gateway/register-service.sh new file mode 100644 index 0000000..7252602 --- /dev/null +++ b/scripts/gateway/register-service.sh @@ -0,0 +1,185 @@ +#!/bin/bash +# Service Gateway Registration Script +# Usage: ./register-service.sh [OPTIONS] +# +# Options: +# -p, --prefix Service prefix (default: activity) +# -v, --version API version (default: v1) +# -g, --gateway-url Gateway URL (default: http://localhost:5000) +# -a, --address Service address (default: http://localhost:5001) +# -d, --destination Destination ID (default: {prefix}-1) +# -w, --weight Instance weight (default: 1) +# -G, --global Create global route (default: true) +# -t, --tenant-code Tenant code for tenant-specific route +# -h, --help Show this help + +set -e + +# Default values +SERVICE_PREFIX="activity" +API_VERSION="v1" +GATEWAY_URL="http://localhost:5000" +SERVICE_ADDRESS="http://localhost:5001" +WEIGHT=1 +IS_GLOBAL=true +TENANT_CODE="" + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +NC='\033[0m' + +# Parse arguments +while [[ $# -gt 0 ]]; do + case $1 in + -p|--prefix) + SERVICE_PREFIX="$2" + shift 2 + ;; + -v|--version) + API_VERSION="$2" + shift 2 + ;; + -g|--gateway-url) + GATEWAY_URL="$2" + shift 2 + ;; + -a|--address) + SERVICE_ADDRESS="$2" + shift 2 + ;; + -d|--destination) + DESTINATION_ID="$2" + shift 2 + ;; + -w|--weight) + WEIGHT="$2" + shift 2 + ;; + -G|--global) + IS_GLOBAL=true + shift + ;; + -t|--tenant-code) + IS_GLOBAL=false + TENANT_CODE="$2" + shift 2 + ;; + -h|--help) + grep -A 50 '# Usage:' "$0" | tail -n +2 | head -n 20 + exit 0 + ;; + *) + echo "Unknown option: $1" + exit 1 + ;; + esac +done + +# Set derived values +CLUSTER_ID="${SERVICE_PREFIX}-service" +DESTINATION_ID="${DESTINATION_ID:-${SERVICE_PREFIX}-1}" +PATH_PATTERN="/${SERVICE_PREFIX}/${API_VERSION}/{**path}" + +echo -e "${CYAN}╔════════════════════════════════════════════════════════════╗${NC}" +echo -e "${CYAN}║ Service Gateway Registration ║${NC}" +echo -e "${CYAN}╚════════════════════════════════════════════════════════════╝${NC}" +echo "" + +# Step 1: Add Service Instance +echo -e "${YELLOW}[1/3] Adding service instance...${NC}" + +INSTANCE_BODY=$(cat < $SERVICE_ADDRESS${NC}" +else + echo -e " ${YELLOW}ℹ Instance may already exist${NC}" +fi + +# Step 2: Add Route +echo "" +echo -e "${YELLOW}[2/3] Configuring gateway route...${NC}" + +if [ "$IS_GLOBAL" = true ]; then + ROUTE_URL="$GATEWAY_URL/api/gateway/routes/global" + + ROUTE_BODY=$(cat < $CLUSTER_ID${NC}" +else + echo -e " ${YELLOW}ℹ Route may already exist${NC}" +fi + +# Step 3: Reload Config +echo "" +echo -e "${YELLOW}[3/3] Reloading gateway configuration...${NC}" + +curl -s -X POST "$GATEWAY_URL/api/gateway/reload" > /dev/null +echo -e " ${GREEN}✓ Configuration reloaded${NC}" + +# Summary +echo "" +echo -e "${CYAN}════════════════════════════════════════════════════════════${NC}" +echo -e "${CYAN} Service Registered Successfully!${NC}" +echo -e "${CYAN}════════════════════════════════════════════════════════════${NC}" +echo "" +echo -e " ${WHITE}Service:${NC} $SERVICE_PREFIX" +echo -e " ${WHITE}Version:${NC} $API_VERSION" +echo -e " ${WHITE}Cluster:${NC} $CLUSTER_ID" +echo -e " ${WHITE}Type:${NC} $ROUTE_TYPE" +echo -e " ${WHITE}Pattern:${NC} $PATH_PATTERN" +echo -e " ${WHITE}Address:${NC} $SERVICE_ADDRESS" +echo "" +echo -e " ${CYAN}Available Endpoints:${NC}" +echo -e " GET $GATEWAY_URL/${SERVICE_PREFIX}/${API_VERSION}/campaigns" +echo -e " POST $GATEWAY_URL/${SERVICE_PREFIX}/${API_VERSION}/campaigns" +echo -e " GET $GATEWAY_URL/${SERVICE_PREFIX}/${API_VERSION}/campaigns/\{id\}" +echo -e " POST $GATEWAY_URL/${SERVICE_PREFIX}/${API_VERSION}/campaigns/\{id\}/publish" +echo "" diff --git a/scripts/init-infrastructure.ps1 b/scripts/init-infrastructure.ps1 new file mode 100644 index 0000000..42777ec --- /dev/null +++ b/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: 81.68.223.70:16379,password=sl52788542" + + 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/scripts/init-infrastructure.sh b/scripts/init-infrastructure.sh new file mode 100644 index 0000000..111046f --- /dev/null +++ b/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: 81.68.223.70:16379,password=sl52788542" + 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/scripts/mysql-init/01-init.sql b/scripts/mysql-init/01-init.sql new file mode 100644 index 0000000..e60eb93 --- /dev/null +++ b/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/scripts/postgres-init/01-init.sql b/scripts/postgres-init/01-init.sql new file mode 100644 index 0000000..8297d34 --- /dev/null +++ b/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/src/Fengling.Activity.Domain/Aggregates/Campaigns/Campaign.cs b/src/Fengling.Activity.Domain/Aggregates/Campaigns/Campaign.cs new file mode 100644 index 0000000..49f63fe --- /dev/null +++ b/src/Fengling.Activity.Domain/Aggregates/Campaigns/Campaign.cs @@ -0,0 +1,139 @@ +namespace Fengling.Activity.Domain.Aggregates.Campaigns; + +using Fengling.Activity.Domain.Enums; +using Fengling.Activity.Domain.Events.Campaigns; +using Fengling.Activity.Domain.ValueObjects; + +public class Campaign : Entity, IAggregateRoot +{ + public TenantId TenantId { get; private set; } = null!; + public CampaignName Name { get; private set; } = null!; + public CampaignType Type { get; private set; } + public CampaignStatus Status { get; private set; } = CampaignStatus.Draft; + public string? Description { get; private set; } + public TimeRange TimeRange { get; private set; } = null!; + public int? MaxParticipants { get; private set; } + public int? MaxRewardsPerMember { get; private set; } + + private readonly List _conditions = new(); + public IReadOnlyCollection Conditions => _conditions.AsReadOnly(); + + private readonly List _constraints = new(); + public IReadOnlyCollection Constraints => _constraints.AsReadOnly(); + + private readonly List _actions = new(); + public IReadOnlyCollection Actions => _actions.AsReadOnly(); + + public DateTime CreatedAt { get; private set; } = DateTime.UtcNow; + public DateTime? UpdatedAt { get; private set; } + public int Version { get; private set; } = 1; + + private Campaign() + { + } + + public static Campaign Create( + TenantId tenantId, + CampaignName name, + CampaignType type, + TimeRange timeRange, + string? description = null) + { + var campaign = new Campaign + { + TenantId = tenantId, + Name = name, + Type = type, + TimeRange = timeRange, + Description = description, + Status = CampaignStatus.Draft, + CreatedAt = DateTime.UtcNow + }; + + campaign.AddDomainEvent(new CampaignCreatedEvent(campaign.Id, tenantId, name, type, timeRange, campaign.CreatedAt)); + return campaign; + } + + public void Publish() + { + if (Status != CampaignStatus.Draft && Status != CampaignStatus.Paused) + throw new InvalidOperationException("Only draft or paused campaigns can be published"); + + if (!TimeRange.IsActive()) + throw new InvalidOperationException("Campaign time range must be active to publish"); + + Status = CampaignStatus.Published; + UpdatedAt = DateTime.UtcNow; + Version++; + AddDomainEvent(new CampaignPublishedEvent(Id, TenantId, DateTime.UtcNow)); + } + + public void Pause() + { + if (Status != CampaignStatus.Published) + throw new InvalidOperationException("Only published campaigns can be paused"); + + Status = CampaignStatus.Paused; + UpdatedAt = DateTime.UtcNow; + Version++; + AddDomainEvent(new CampaignPausedEvent(Id, TenantId, DateTime.UtcNow)); + } + + public void Complete() + { + if (Status != CampaignStatus.Published) + throw new InvalidOperationException("Only published campaigns can be completed"); + + Status = CampaignStatus.Completed; + UpdatedAt = DateTime.UtcNow; + Version++; + AddDomainEvent(new CampaignCompletedEvent(Id, TenantId, DateTime.UtcNow)); + } + + public void Cancel() + { + if (Status == CampaignStatus.Completed) + throw new InvalidOperationException("Completed campaigns cannot be cancelled"); + + Status = CampaignStatus.Cancelled; + UpdatedAt = DateTime.UtcNow; + Version++; + AddDomainEvent(new CampaignCancelledEvent(Id, TenantId, DateTime.UtcNow)); + } + + public void AddCondition(ConditionConfig condition) + { + _conditions.Add(condition); + UpdatedAt = DateTime.UtcNow; + } + + public void AddConstraint(ConstraintConfig constraint) + { + _constraints.Add(constraint); + UpdatedAt = DateTime.UtcNow; + } + + public void AddAction(ActionConfig action) + { + _actions.Add(action); + UpdatedAt = DateTime.UtcNow; + } + + public void UpdateTimeRange(TimeRange timeRange) + { + TimeRange = timeRange; + UpdatedAt = DateTime.UtcNow; + } + + public void UpdateMaxParticipants(int? maxParticipants) + { + MaxParticipants = maxParticipants; + UpdatedAt = DateTime.UtcNow; + } + + public void UpdateMaxRewardsPerMember(int? maxRewards) + { + MaxRewardsPerMember = maxRewards; + UpdatedAt = DateTime.UtcNow; + } +} diff --git a/src/Fengling.Activity.Domain/Aggregates/ParticipationRecords/ParticipationRecord.cs b/src/Fengling.Activity.Domain/Aggregates/ParticipationRecords/ParticipationRecord.cs new file mode 100644 index 0000000..33bf07c --- /dev/null +++ b/src/Fengling.Activity.Domain/Aggregates/ParticipationRecords/ParticipationRecord.cs @@ -0,0 +1,90 @@ +namespace Fengling.Activity.Domain.Aggregates.ParticipationRecords; + +using Fengling.Activity.Domain.Enums; +using Fengling.Activity.Domain.Events.ParticipationRecords; +using Fengling.Activity.Domain.ValueObjects; + +public class ParticipationRecord : Entity, IAggregateRoot +{ + public CampaignId CampaignId { get; private set; } = null!; + public TenantId TenantId { get; private set; } = null!; + public Guid MemberId { get; private set; } + public ParticipationStatus Status { get; private set; } = ParticipationStatus.Participating; + public int CurrentProgress { get; private set; } + public int? TargetProgress { get; private set; } + public int RewardsGranted { get; private set; } + public DateTime ParticipatedAt { get; private set; } = DateTime.UtcNow; + public DateTime? CompletedAt { get; private set; } + public DateTime? ExpiredAt { get; private set; } + public DateTime? UpdatedAt { get; private set; } + + private ParticipationRecord() + { + } + + public static ParticipationRecord Create( + CampaignId campaignId, + TenantId tenantId, + Guid memberId, + int? targetProgress = null) + { + var record = new ParticipationRecord + { + CampaignId = campaignId, + TenantId = tenantId, + MemberId = memberId, + TargetProgress = targetProgress, + Status = ParticipationStatus.Participating, + ParticipatedAt = DateTime.UtcNow + }; + + record.AddDomainEvent(new TaskParticipatedEvent(record.Id, campaignId, tenantId, memberId, record.ParticipatedAt)); + return record; + } + + public void UpdateProgress(int progress) + { + CurrentProgress = progress; + if (TargetProgress.HasValue && CurrentProgress >= TargetProgress.Value) + { + Complete(); + } + UpdatedAt = DateTime.UtcNow; + } + + public void Complete() + { + if (Status == ParticipationStatus.Completed) + return; + + Status = ParticipationStatus.Completed; + CompletedAt = DateTime.UtcNow; + AddDomainEvent(new TaskCompletedEvent(Id, CampaignId, TenantId, MemberId, CompletedAt.Value)); + UpdatedAt = DateTime.UtcNow; + } + + public void Cancel() + { + if (Status == ParticipationStatus.Cancelled) + return; + + Status = ParticipationStatus.Cancelled; + UpdatedAt = DateTime.UtcNow; + } + + public void Expire() + { + if (Status != ParticipationStatus.Participating) + return; + + Status = ParticipationStatus.Expired; + ExpiredAt = DateTime.UtcNow; + UpdatedAt = DateTime.UtcNow; + } + + public void GrantReward() + { + RewardsGranted++; + UpdatedAt = DateTime.UtcNow; + } +} diff --git a/src/Fengling.Activity.Domain/Aggregates/ParticipationRecords/ParticipationRecordId.cs b/src/Fengling.Activity.Domain/Aggregates/ParticipationRecords/ParticipationRecordId.cs new file mode 100644 index 0000000..20c8efb --- /dev/null +++ b/src/Fengling.Activity.Domain/Aggregates/ParticipationRecords/ParticipationRecordId.cs @@ -0,0 +1,18 @@ +namespace Fengling.Activity.Domain.Aggregates.ParticipationRecords; + +public class ParticipationRecordId : IEquatable +{ + public Guid Value { get; } + + private ParticipationRecordId(Guid value) => Value = value; + + public static ParticipationRecordId New() => new(Guid.NewGuid()); + public static ParticipationRecordId FromGuid(Guid value) => new(value); + public static ParticipationRecordId Parse(string value) => new(Guid.Parse(value)); + + public bool Equals(ParticipationRecordId? other) => other?.Value == Value; + public override bool Equals(object? obj) => obj is ParticipationRecordId other && Equals(other); + public override int GetHashCode() => Value.GetHashCode(); + public static implicit operator Guid(ParticipationRecordId id) => id.Value; + public override string ToString() => Value.ToString(); +} diff --git a/src/Fengling.Activity.Domain/Aggregates/RewardGrantRecords/RewardGrantRecord.cs b/src/Fengling.Activity.Domain/Aggregates/RewardGrantRecords/RewardGrantRecord.cs new file mode 100644 index 0000000..ff89053 --- /dev/null +++ b/src/Fengling.Activity.Domain/Aggregates/RewardGrantRecords/RewardGrantRecord.cs @@ -0,0 +1,46 @@ +namespace Fengling.Activity.Domain.Aggregates.RewardGrantRecords; + +using Fengling.Activity.Domain.Aggregates.ParticipationRecords; +using Fengling.Activity.Domain.Enums; +using Fengling.Activity.Domain.ValueObjects; + +public class RewardGrantRecord : Entity, IAggregateRoot +{ + public CampaignId CampaignId { get; private set; } = null!; + public TenantId TenantId { get; private set; } = null!; + public Guid MemberId { get; private set; } + public ParticipationRecordId? ParticipationRecordId { get; private set; } + public RewardType RewardType { get; private set; } + public int RewardAmount { get; private set; } + public string? RewardCode { get; private set; } + public string? Remark { get; private set; } + public DateTime GrantedAt { get; private set; } = DateTime.UtcNow; + + private RewardGrantRecord() + { + } + + public static RewardGrantRecord Create( + CampaignId campaignId, + TenantId tenantId, + Guid memberId, + ParticipationRecordId? participationRecordId, + RewardType rewardType, + int rewardAmount, + string? rewardCode = null, + string? remark = null) + { + return new RewardGrantRecord + { + CampaignId = campaignId, + TenantId = tenantId, + MemberId = memberId, + ParticipationRecordId = participationRecordId, + RewardType = rewardType, + RewardAmount = rewardAmount, + RewardCode = rewardCode, + Remark = remark, + GrantedAt = DateTime.UtcNow + }; + } +} diff --git a/src/Fengling.Activity.Domain/Aggregates/RewardGrantRecords/RewardGrantRecordId.cs b/src/Fengling.Activity.Domain/Aggregates/RewardGrantRecords/RewardGrantRecordId.cs new file mode 100644 index 0000000..173edd9 --- /dev/null +++ b/src/Fengling.Activity.Domain/Aggregates/RewardGrantRecords/RewardGrantRecordId.cs @@ -0,0 +1,18 @@ +namespace Fengling.Activity.Domain.Aggregates.RewardGrantRecords; + +public class RewardGrantRecordId : IEquatable +{ + public Guid Value { get; } + + private RewardGrantRecordId(Guid value) => Value = value; + + public static RewardGrantRecordId New() => new(Guid.NewGuid()); + public static RewardGrantRecordId FromGuid(Guid value) => new(value); + public static RewardGrantRecordId Parse(string value) => new(Guid.Parse(value)); + + public bool Equals(RewardGrantRecordId? other) => other?.Value == Value; + public override bool Equals(object? obj) => obj is RewardGrantRecordId other && Equals(other); + public override int GetHashCode() => Value.GetHashCode(); + public static implicit operator Guid(RewardGrantRecordId id) => id.Value; + public override string ToString() => Value.ToString(); +} diff --git a/src/Fengling.Activity.Domain/Enums/CampaignStatus.cs b/src/Fengling.Activity.Domain/Enums/CampaignStatus.cs new file mode 100644 index 0000000..bcc70a1 --- /dev/null +++ b/src/Fengling.Activity.Domain/Enums/CampaignStatus.cs @@ -0,0 +1,10 @@ +namespace Fengling.Activity.Domain.Enums; + +public enum CampaignStatus +{ + Draft = 0, + Published = 1, + Paused = 2, + Completed = 3, + Cancelled = 4 +} diff --git a/src/Fengling.Activity.Domain/Enums/CampaignType.cs b/src/Fengling.Activity.Domain/Enums/CampaignType.cs new file mode 100644 index 0000000..16a8598 --- /dev/null +++ b/src/Fengling.Activity.Domain/Enums/CampaignType.cs @@ -0,0 +1,12 @@ +namespace Fengling.Activity.Domain.Enums; + +public enum CampaignType +{ + Task = 1, + Recurring = 2, + Consumption = 3, + Social = 4, + Gamification = 5, + MemberExclusive = 6, + Festival = 7 +} diff --git a/src/Fengling.Activity.Domain/Enums/ParticipationStatus.cs b/src/Fengling.Activity.Domain/Enums/ParticipationStatus.cs new file mode 100644 index 0000000..8a28250 --- /dev/null +++ b/src/Fengling.Activity.Domain/Enums/ParticipationStatus.cs @@ -0,0 +1,9 @@ +namespace Fengling.Activity.Domain.Enums; + +public enum ParticipationStatus +{ + Participating = 1, + Completed = 2, + Expired = 3, + Cancelled = 4 +} diff --git a/src/Fengling.Activity.Domain/Enums/RewardType.cs b/src/Fengling.Activity.Domain/Enums/RewardType.cs new file mode 100644 index 0000000..28b4404 --- /dev/null +++ b/src/Fengling.Activity.Domain/Enums/RewardType.cs @@ -0,0 +1,11 @@ +namespace Fengling.Activity.Domain.Enums; + +public enum RewardType +{ + Points = 1, + Coupon = 2, + Physical = 3, + Virtual = 4, + Currency = 5, + Experience = 6 +} diff --git a/src/Fengling.Activity.Domain/Events/Campaigns/CampaignCancelledEvent.cs b/src/Fengling.Activity.Domain/Events/Campaigns/CampaignCancelledEvent.cs new file mode 100644 index 0000000..f05562d --- /dev/null +++ b/src/Fengling.Activity.Domain/Events/Campaigns/CampaignCancelledEvent.cs @@ -0,0 +1,9 @@ +namespace Fengling.Activity.Domain.Events.Campaigns; + +using Fengling.Activity.Domain.ValueObjects; +using NetCorePal.Extensions.Domain; + +public record CampaignCancelledEvent( + CampaignId CampaignId, + TenantId TenantId, + DateTime CancelledAt) : IDomainEvent; diff --git a/src/Fengling.Activity.Domain/Events/Campaigns/CampaignCompletedEvent.cs b/src/Fengling.Activity.Domain/Events/Campaigns/CampaignCompletedEvent.cs new file mode 100644 index 0000000..f06157d --- /dev/null +++ b/src/Fengling.Activity.Domain/Events/Campaigns/CampaignCompletedEvent.cs @@ -0,0 +1,9 @@ +namespace Fengling.Activity.Domain.Events.Campaigns; + +using Fengling.Activity.Domain.ValueObjects; +using NetCorePal.Extensions.Domain; + +public record CampaignCompletedEvent( + CampaignId CampaignId, + TenantId TenantId, + DateTime CompletedAt) : IDomainEvent; diff --git a/src/Fengling.Activity.Domain/Events/Campaigns/CampaignCreatedEvent.cs b/src/Fengling.Activity.Domain/Events/Campaigns/CampaignCreatedEvent.cs new file mode 100644 index 0000000..648919d --- /dev/null +++ b/src/Fengling.Activity.Domain/Events/Campaigns/CampaignCreatedEvent.cs @@ -0,0 +1,13 @@ +namespace Fengling.Activity.Domain.Events.Campaigns; + +using Fengling.Activity.Domain.Enums; +using Fengling.Activity.Domain.ValueObjects; +using NetCorePal.Extensions.Domain; + +public record CampaignCreatedEvent( + CampaignId CampaignId, + TenantId TenantId, + CampaignName Name, + CampaignType Type, + TimeRange TimeRange, + DateTime CreatedAt) : IDomainEvent; diff --git a/src/Fengling.Activity.Domain/Events/Campaigns/CampaignPausedEvent.cs b/src/Fengling.Activity.Domain/Events/Campaigns/CampaignPausedEvent.cs new file mode 100644 index 0000000..c60f9bb --- /dev/null +++ b/src/Fengling.Activity.Domain/Events/Campaigns/CampaignPausedEvent.cs @@ -0,0 +1,9 @@ +namespace Fengling.Activity.Domain.Events.Campaigns; + +using Fengling.Activity.Domain.ValueObjects; +using NetCorePal.Extensions.Domain; + +public record CampaignPausedEvent( + CampaignId CampaignId, + TenantId TenantId, + DateTime PausedAt) : IDomainEvent; diff --git a/src/Fengling.Activity.Domain/Events/Campaigns/CampaignPublishedEvent.cs b/src/Fengling.Activity.Domain/Events/Campaigns/CampaignPublishedEvent.cs new file mode 100644 index 0000000..4a44e0b --- /dev/null +++ b/src/Fengling.Activity.Domain/Events/Campaigns/CampaignPublishedEvent.cs @@ -0,0 +1,9 @@ +namespace Fengling.Activity.Domain.Events.Campaigns; + +using Fengling.Activity.Domain.ValueObjects; +using NetCorePal.Extensions.Domain; + +public record CampaignPublishedEvent( + CampaignId CampaignId, + TenantId TenantId, + DateTime PublishedAt) : IDomainEvent; diff --git a/src/Fengling.Activity.Domain/Events/ParticipationRecords/TaskCompletedEvent.cs b/src/Fengling.Activity.Domain/Events/ParticipationRecords/TaskCompletedEvent.cs new file mode 100644 index 0000000..249943b --- /dev/null +++ b/src/Fengling.Activity.Domain/Events/ParticipationRecords/TaskCompletedEvent.cs @@ -0,0 +1,12 @@ +namespace Fengling.Activity.Domain.Events.ParticipationRecords; + +using Fengling.Activity.Domain.Aggregates.ParticipationRecords; +using Fengling.Activity.Domain.ValueObjects; +using NetCorePal.Extensions.Domain; + +public record TaskCompletedEvent( + ParticipationRecordId RecordId, + CampaignId CampaignId, + TenantId TenantId, + Guid MemberId, + DateTime CompletedAt) : IDomainEvent; diff --git a/src/Fengling.Activity.Domain/Events/ParticipationRecords/TaskParticipatedEvent.cs b/src/Fengling.Activity.Domain/Events/ParticipationRecords/TaskParticipatedEvent.cs new file mode 100644 index 0000000..a7e2507 --- /dev/null +++ b/src/Fengling.Activity.Domain/Events/ParticipationRecords/TaskParticipatedEvent.cs @@ -0,0 +1,12 @@ +namespace Fengling.Activity.Domain.Events.ParticipationRecords; + +using Fengling.Activity.Domain.Aggregates.ParticipationRecords; +using Fengling.Activity.Domain.ValueObjects; +using NetCorePal.Extensions.Domain; + +public record TaskParticipatedEvent( + ParticipationRecordId RecordId, + CampaignId CampaignId, + TenantId TenantId, + Guid MemberId, + DateTime ParticipatedAt) : IDomainEvent; diff --git a/src/Fengling.Activity.Domain/Fengling.Activity.Domain.csproj b/src/Fengling.Activity.Domain/Fengling.Activity.Domain.csproj new file mode 100644 index 0000000..1ab15ae --- /dev/null +++ b/src/Fengling.Activity.Domain/Fengling.Activity.Domain.csproj @@ -0,0 +1,21 @@ + + + + net10.0 + enable + enable + true + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/src/Fengling.Activity.Domain/GlobalUsings.cs b/src/Fengling.Activity.Domain/GlobalUsings.cs new file mode 100644 index 0000000..eb48228 --- /dev/null +++ b/src/Fengling.Activity.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/src/Fengling.Activity.Domain/Repositories/ICampaignRepository.cs b/src/Fengling.Activity.Domain/Repositories/ICampaignRepository.cs new file mode 100644 index 0000000..22ab8dc --- /dev/null +++ b/src/Fengling.Activity.Domain/Repositories/ICampaignRepository.cs @@ -0,0 +1,17 @@ +namespace Fengling.Activity.Domain.Repositories; + +using Fengling.Activity.Domain.Aggregates.Campaigns; +using Fengling.Activity.Domain.Enums; +using Fengling.Activity.Domain.ValueObjects; + +public interface ICampaignRepository +{ + Task GetByIdAsync(CampaignId id, CancellationToken cancellationToken = default); + Task> GetByTenantIdAsync(TenantId tenantId, CancellationToken cancellationToken = default); + Task> GetByStatusAsync(CampaignStatus status, CancellationToken cancellationToken = default); + Task> GetActiveCampaignsAsync(TenantId tenantId, CancellationToken cancellationToken = default); + Task AddAsync(Campaign campaign, CancellationToken cancellationToken = default); + Task UpdateAsync(Campaign campaign, CancellationToken cancellationToken = default); + Task DeleteAsync(Campaign campaign, CancellationToken cancellationToken = default); + Task ExistsAsync(CampaignId id, CancellationToken cancellationToken = default); +} diff --git a/src/Fengling.Activity.Domain/Repositories/IParticipationRecordRepository.cs b/src/Fengling.Activity.Domain/Repositories/IParticipationRecordRepository.cs new file mode 100644 index 0000000..a2a1cc2 --- /dev/null +++ b/src/Fengling.Activity.Domain/Repositories/IParticipationRecordRepository.cs @@ -0,0 +1,19 @@ +namespace Fengling.Activity.Domain.Repositories; + +using Fengling.Activity.Domain.Aggregates.Campaigns; +using Fengling.Activity.Domain.Aggregates.ParticipationRecords; +using Fengling.Activity.Domain.Enums; +using Fengling.Activity.Domain.ValueObjects; + +public interface IParticipationRecordRepository +{ + Task GetByIdAsync(ParticipationRecordId id, CancellationToken cancellationToken = default); + Task> GetByCampaignIdAsync(CampaignId campaignId, CancellationToken cancellationToken = default); + Task> GetByMemberIdAsync(TenantId tenantId, Guid memberId, CancellationToken cancellationToken = default); + Task GetByCampaignAndMemberAsync(CampaignId campaignId, Guid memberId, CancellationToken cancellationToken = default); + Task> GetByStatusAsync(CampaignId campaignId, ParticipationStatus status, CancellationToken cancellationToken = default); + Task GetParticipationCountAsync(CampaignId campaignId, Guid memberId, CancellationToken cancellationToken = default); + Task AddAsync(ParticipationRecord record, CancellationToken cancellationToken = default); + Task UpdateAsync(ParticipationRecord record, CancellationToken cancellationToken = default); + Task DeleteAsync(ParticipationRecord record, CancellationToken cancellationToken = default); +} diff --git a/src/Fengling.Activity.Domain/Repositories/IRewardGrantRecordRepository.cs b/src/Fengling.Activity.Domain/Repositories/IRewardGrantRecordRepository.cs new file mode 100644 index 0000000..9cd0acb --- /dev/null +++ b/src/Fengling.Activity.Domain/Repositories/IRewardGrantRecordRepository.cs @@ -0,0 +1,18 @@ +namespace Fengling.Activity.Domain.Repositories; + +using Fengling.Activity.Domain.Aggregates.Campaigns; +using Fengling.Activity.Domain.Aggregates.RewardGrantRecords; +using Fengling.Activity.Domain.Enums; +using Fengling.Activity.Domain.ValueObjects; + +public interface IRewardGrantRecordRepository +{ + Task GetByIdAsync(RewardGrantRecordId id, CancellationToken cancellationToken = default); + Task> GetByCampaignIdAsync(CampaignId campaignId, CancellationToken cancellationToken = default); + Task> GetByMemberIdAsync(TenantId tenantId, Guid memberId, CancellationToken cancellationToken = default); + Task GetRewardCountAsync(CampaignId campaignId, Guid memberId, CancellationToken cancellationToken = default); + Task AddAsync(RewardGrantRecord record, CancellationToken cancellationToken = default); + Task UpdateAsync(RewardGrantRecord record, CancellationToken cancellationToken = default); + Task DeleteAsync(RewardGrantRecord record, CancellationToken cancellationToken = default); + Task> GetRewardsByDateRangeAsync(TenantId tenantId, DateTime startDate, DateTime endDate, CancellationToken cancellationToken = default); +} diff --git a/src/Fengling.Activity.Domain/Services/CampaignExecutionService.cs b/src/Fengling.Activity.Domain/Services/CampaignExecutionService.cs new file mode 100644 index 0000000..bde4fe2 --- /dev/null +++ b/src/Fengling.Activity.Domain/Services/CampaignExecutionService.cs @@ -0,0 +1,158 @@ +namespace Fengling.Activity.Domain.Services; + +using Fengling.Activity.Domain.Aggregates.Campaigns; +using Fengling.Activity.Domain.Aggregates.ParticipationRecords; +using Fengling.Activity.Domain.Aggregates.RewardGrantRecords; +using Fengling.Activity.Domain.Enums; +using Fengling.Activity.Domain.Repositories; +using Fengling.Activity.Domain.Strategies; +using Fengling.Activity.Domain.ValueObjects; + +public class CampaignExecutionService +{ + private readonly ICampaignRepository _campaignRepository; + private readonly IParticipationRecordRepository _participationRepository; + private readonly IRewardGrantRecordRepository _rewardRepository; + private readonly IStrategyFactory _strategyFactory; + + public CampaignExecutionService( + ICampaignRepository campaignRepository, + IParticipationRecordRepository participationRepository, + IRewardGrantRecordRepository rewardRepository, + IStrategyFactory strategyFactory) + { + _campaignRepository = campaignRepository; + _participationRepository = participationRepository; + _rewardRepository = rewardRepository; + _strategyFactory = strategyFactory; + } + + public async Task ExecuteCampaignAsync( + CampaignId campaignId, + TenantId tenantId, + Guid memberId, + string? memberLevel = null, + CancellationToken cancellationToken = default) + { + var campaign = await _campaignRepository.GetByIdAsync(campaignId, cancellationToken); + if (campaign == null) + { + return CampaignExecutionResult.Fail("Campaign not found"); + } + + if (campaign.TenantId != tenantId) + { + return CampaignExecutionResult.Fail("Campaign does not belong to this tenant"); + } + + if (campaign.Status != CampaignStatus.Published) + { + return CampaignExecutionResult.Fail("Campaign is not active"); + } + + var now = DateTime.UtcNow; + if (!campaign.TimeRange.Contains(now)) + { + return CampaignExecutionResult.Fail("Campaign is not within valid time range"); + } + + var existingRecord = await _participationRepository.GetByCampaignAndMemberAsync(campaignId, memberId, cancellationToken); + if (existingRecord != null && existingRecord.Status != ParticipationStatus.Cancelled) + { + return CampaignExecutionResult.Fail("Member has already participated in this campaign"); + } + + var context = new CampaignContext + { + CampaignId = campaignId.Value, + TenantId = tenantId.Value, + MemberId = memberId, + MemberLevel = memberLevel, + CurrentTime = now + }; + + foreach (var condition in campaign.Conditions) + { + var strategy = _strategyFactory.GetConditionStrategy(condition.StrategyType); + if (strategy == null) + { + return CampaignExecutionResult.Fail($"Condition strategy not found: {condition.StrategyType}"); + } + + var result = await strategy.ExecuteAsync(context, condition, cancellationToken); + if (!result.IsSatisfied) + { + return CampaignExecutionResult.Fail(result.Message ?? "Condition not met"); + } + } + + foreach (var constraint in campaign.Constraints) + { + var strategy = _strategyFactory.GetConstraintStrategy(constraint.StrategyType); + if (strategy == null) + { + return CampaignExecutionResult.Fail($"Constraint strategy not found: {constraint.StrategyType}"); + } + + var result = await strategy.ExecuteAsync(context, constraint, cancellationToken); + if (!result.IsValid) + { + return CampaignExecutionResult.Fail(result.Message ?? "Constraint not satisfied"); + } + } + + var participation = ParticipationRecord.Create(campaignId, tenantId, memberId); + + var rewards = new List(); + foreach (var action in campaign.Actions) + { + var strategy = _strategyFactory.GetActionStrategy(action.StrategyType); + if (strategy == null) + { + continue; + } + + var result = await strategy.ExecuteAsync(context, action, cancellationToken); + if (result.IsSuccess && result.Rewards != null) + { + var rewardType = Enum.Parse(result.Rewards.GetValueOrDefault("RewardType", "Points")?.ToString() ?? "Points"); + var amount = Convert.ToInt32(result.Rewards.GetValueOrDefault("Amount", 0)); + + if (amount > 0) + { + var reward = RewardGrantRecord.Create( + campaignId, + tenantId, + memberId, + participation.Id, + rewardType, + amount, + result.Rewards.GetValueOrDefault("RewardCode")?.ToString(), + result.Message); + + rewards.Add(reward); + participation.GrantReward(); + } + } + } + + return CampaignExecutionResult.Success(participation, rewards); + } +} + +public record CampaignExecutionResult( + bool IsSuccess, + string? ErrorMessage, + ParticipationRecord? Participation, + List? Rewards) +{ + public static CampaignExecutionResult Success(ParticipationRecord participation, List rewards) + { + return new CampaignExecutionResult(true, null, participation, rewards); + } + + public static CampaignExecutionResult Fail(string errorMessage) + { + return new CampaignExecutionResult(false, errorMessage, null, null); + } +} diff --git a/src/Fengling.Activity.Domain/Strategies/ActionResult.cs b/src/Fengling.Activity.Domain/Strategies/ActionResult.cs new file mode 100644 index 0000000..fa0e42b --- /dev/null +++ b/src/Fengling.Activity.Domain/Strategies/ActionResult.cs @@ -0,0 +1,7 @@ +namespace Fengling.Activity.Domain.Strategies; + +public record ActionResult(bool IsSuccess, string? Message = null, Dictionary? Rewards = null) +{ + public static ActionResult Success(Dictionary? rewards = null) => new(true, null, rewards); + public static ActionResult Fail(string message) => new(false, message); +} diff --git a/src/Fengling.Activity.Domain/Strategies/Actions/CouponAction.cs b/src/Fengling.Activity.Domain/Strategies/Actions/CouponAction.cs new file mode 100644 index 0000000..58c1af9 --- /dev/null +++ b/src/Fengling.Activity.Domain/Strategies/Actions/CouponAction.cs @@ -0,0 +1,24 @@ +namespace Fengling.Activity.Domain.Strategies.Actions; + +using Fengling.Activity.Domain.ValueObjects; + +public class CouponAction : IActionStrategy +{ + public string StrategyType => "Coupon"; + + public Task ExecuteAsync(CampaignContext context, ActionConfig config, CancellationToken cancellationToken = default) + { + var couponTemplateId = config.GetParameter("CouponTemplateId"); + var quantity = config.GetParameter("Quantity") ?? 1; + + var rewards = new Dictionary + { + { "CouponTemplateId", couponTemplateId }, + { "Quantity", quantity }, + { "CampaignId", context.CampaignId.ToString() }, + { "GrantedAt", DateTime.UtcNow } + }; + + return Task.FromResult(ActionResult.Success(rewards)); + } +} diff --git a/src/Fengling.Activity.Domain/Strategies/Actions/FixedRewardAction.cs b/src/Fengling.Activity.Domain/Strategies/Actions/FixedRewardAction.cs new file mode 100644 index 0000000..719d862 --- /dev/null +++ b/src/Fengling.Activity.Domain/Strategies/Actions/FixedRewardAction.cs @@ -0,0 +1,24 @@ +namespace Fengling.Activity.Domain.Strategies.Actions; + +using Fengling.Activity.Domain.ValueObjects; + +public class FixedRewardAction : IActionStrategy +{ + public string StrategyType => "FixedReward"; + + public Task ExecuteAsync(CampaignContext context, ActionConfig config, CancellationToken cancellationToken = default) + { + var rewardType = config.GetParameter("RewardType") ?? "Points"; + var amount = config.GetParameter("Amount"); + + var rewards = new Dictionary + { + { "RewardType", rewardType }, + { "Amount", amount }, + { "CampaignId", context.CampaignId.ToString() }, + { "GrantedAt", DateTime.UtcNow } + }; + + return Task.FromResult(ActionResult.Success(rewards)); + } +} diff --git a/src/Fengling.Activity.Domain/Strategies/Actions/LotteryAction.cs b/src/Fengling.Activity.Domain/Strategies/Actions/LotteryAction.cs new file mode 100644 index 0000000..8d1bd39 --- /dev/null +++ b/src/Fengling.Activity.Domain/Strategies/Actions/LotteryAction.cs @@ -0,0 +1,58 @@ +namespace Fengling.Activity.Domain.Strategies.Actions; + +using Fengling.Activity.Domain.ValueObjects; + +public class LotteryAction : IActionStrategy +{ + public string StrategyType => "Lottery"; + + public Task ExecuteAsync(CampaignContext context, ActionConfig config, CancellationToken cancellationToken = default) + { + var lotteryPoolId = config.GetParameter("LotteryPoolId"); + + var random = new Random(); + var prizes = context.AdditionalData.TryGetValue("LotteryPrizes", out var prizesObj) + ? prizesObj is List> list ? list : null + : null; + + Dictionary? selectedPrize = null; + + if (prizes != null && prizes.Count > 0) + { + var totalWeight = prizes.Sum(p => p.TryGetValue("Weight", out var w) && w is int weight ? weight : 1); + var randomValue = random.Next(totalWeight); + var currentWeight = 0; + + foreach (var prize in prizes) + { + var weight = prize.TryGetValue("Weight", out var w) && w is int i ? i : 1; + currentWeight += weight; + if (randomValue < currentWeight) + { + selectedPrize = prize; + break; + } + } + } + + var rewards = new Dictionary + { + { "LotteryPoolId", lotteryPoolId ?? "" }, + { "CampaignId", context.CampaignId.ToString() }, + { "GrantedAt", DateTime.UtcNow } + }; + + if (selectedPrize != null) + { + rewards["Prize"] = selectedPrize; + var name = selectedPrize.TryGetValue("Name", out var n) ? n?.ToString() : null; + rewards["Message"] = $"Congratulations! You won: {name ?? "a prize"}"; + } + else + { + rewards["Message"] = "Better luck next time!"; + } + + return Task.FromResult(ActionResult.Success(rewards)); + } +} diff --git a/src/Fengling.Activity.Domain/Strategies/Actions/PointsAction.cs b/src/Fengling.Activity.Domain/Strategies/Actions/PointsAction.cs new file mode 100644 index 0000000..816fc8f --- /dev/null +++ b/src/Fengling.Activity.Domain/Strategies/Actions/PointsAction.cs @@ -0,0 +1,24 @@ +namespace Fengling.Activity.Domain.Strategies.Actions; + +using Fengling.Activity.Domain.ValueObjects; + +public class PointsAction : IActionStrategy +{ + public string StrategyType => "Points"; + + public Task ExecuteAsync(CampaignContext context, ActionConfig config, CancellationToken cancellationToken = default) + { + var points = config.GetParameter("Points"); + var source = config.GetParameter("Source") ?? "Campaign"; + + var rewards = new Dictionary + { + { "Points", points }, + { "Source", source }, + { "CampaignId", context.CampaignId.ToString() }, + { "GrantedAt", DateTime.UtcNow } + }; + + return Task.FromResult(ActionResult.Success(rewards)); + } +} diff --git a/src/Fengling.Activity.Domain/Strategies/Actions/RandomRewardAction.cs b/src/Fengling.Activity.Domain/Strategies/Actions/RandomRewardAction.cs new file mode 100644 index 0000000..ba46be6 --- /dev/null +++ b/src/Fengling.Activity.Domain/Strategies/Actions/RandomRewardAction.cs @@ -0,0 +1,28 @@ +namespace Fengling.Activity.Domain.Strategies.Actions; + +using Fengling.Activity.Domain.ValueObjects; + +public class RandomRewardAction : IActionStrategy +{ + public string StrategyType => "RandomReward"; + + public Task ExecuteAsync(CampaignContext context, ActionConfig config, CancellationToken cancellationToken = default) + { + var minAmount = config.GetParameter("MinAmount"); + var maxAmount = config.GetParameter("MaxAmount"); + var rewardType = config.GetParameter("RewardType") ?? "Points"; + + var random = new Random(); + var amount = random.Next(minAmount, maxAmount + 1); + + var rewards = new Dictionary + { + { "RewardType", rewardType }, + { "Amount", amount }, + { "CampaignId", context.CampaignId.ToString() }, + { "GrantedAt", DateTime.UtcNow } + }; + + return Task.FromResult(ActionResult.Success(rewards)); + } +} diff --git a/src/Fengling.Activity.Domain/Strategies/CampaignContext.cs b/src/Fengling.Activity.Domain/Strategies/CampaignContext.cs new file mode 100644 index 0000000..e08a1b0 --- /dev/null +++ b/src/Fengling.Activity.Domain/Strategies/CampaignContext.cs @@ -0,0 +1,11 @@ +namespace Fengling.Activity.Domain.Strategies; + +public class CampaignContext +{ + public Guid CampaignId { get; set; } + public Guid TenantId { get; set; } + public Guid MemberId { get; set; } + public string? MemberLevel { get; set; } + public DateTime CurrentTime { get; set; } = DateTime.UtcNow; + public Dictionary AdditionalData { get; } = new(); +} diff --git a/src/Fengling.Activity.Domain/Strategies/ConditionResult.cs b/src/Fengling.Activity.Domain/Strategies/ConditionResult.cs new file mode 100644 index 0000000..91442ad --- /dev/null +++ b/src/Fengling.Activity.Domain/Strategies/ConditionResult.cs @@ -0,0 +1,7 @@ +namespace Fengling.Activity.Domain.Strategies; + +public record ConditionResult(bool IsSatisfied, string? Message = null) +{ + public static ConditionResult Success() => new(true); + public static ConditionResult Fail(string message) => new(false, message); +} diff --git a/src/Fengling.Activity.Domain/Strategies/Conditions/AccumulatedParticipationCondition.cs b/src/Fengling.Activity.Domain/Strategies/Conditions/AccumulatedParticipationCondition.cs new file mode 100644 index 0000000..01a5988 --- /dev/null +++ b/src/Fengling.Activity.Domain/Strategies/Conditions/AccumulatedParticipationCondition.cs @@ -0,0 +1,27 @@ +namespace Fengling.Activity.Domain.Strategies.Conditions; + +using Fengling.Activity.Domain.ValueObjects; + +public class AccumulatedParticipationCondition : IConditionStrategy +{ + public string StrategyType => "AccumulatedParticipation"; + + public Task ExecuteAsync(CampaignContext context, ConditionConfig config, CancellationToken cancellationToken = default) + { + var requiredCount = config.GetParameter("RequiredCount"); + var campaignId = config.GetParameter("CampaignId"); + + if (context.AdditionalData.TryGetValue("AccumulatedParticipationCount", out var countObj) && + countObj is int currentCount) + { + if (currentCount >= requiredCount) + { + return Task.FromResult(ConditionResult.Success()); + } + + return Task.FromResult(ConditionResult.Fail($"Accumulated participation {currentCount} does not meet requirement {requiredCount}")); + } + + return Task.FromResult(ConditionResult.Success()); + } +} diff --git a/src/Fengling.Activity.Domain/Strategies/Conditions/FirstTimeCondition.cs b/src/Fengling.Activity.Domain/Strategies/Conditions/FirstTimeCondition.cs new file mode 100644 index 0000000..fd4efe9 --- /dev/null +++ b/src/Fengling.Activity.Domain/Strategies/Conditions/FirstTimeCondition.cs @@ -0,0 +1,26 @@ +namespace Fengling.Activity.Domain.Strategies.Conditions; + +using Fengling.Activity.Domain.ValueObjects; + +public class FirstTimeCondition : IConditionStrategy +{ + public string StrategyType => "FirstTime"; + + public Task ExecuteAsync(CampaignContext context, ConditionConfig config, CancellationToken cancellationToken = default) + { + var checkFirstTimeOnly = config.GetParameter("CheckFirstTimeOnly"); + + if (!checkFirstTimeOnly) + { + return Task.FromResult(ConditionResult.Success()); + } + + if (context.AdditionalData.TryGetValue("HasParticipatedBefore", out var hasParticipatedObj) && + hasParticipatedObj is bool hasParticipated && hasParticipated) + { + return Task.FromResult(ConditionResult.Fail("Member has already participated before")); + } + + return Task.FromResult(ConditionResult.Success()); + } +} diff --git a/src/Fengling.Activity.Domain/Strategies/Conditions/MemberLevelCondition.cs b/src/Fengling.Activity.Domain/Strategies/Conditions/MemberLevelCondition.cs new file mode 100644 index 0000000..77d5d60 --- /dev/null +++ b/src/Fengling.Activity.Domain/Strategies/Conditions/MemberLevelCondition.cs @@ -0,0 +1,44 @@ +namespace Fengling.Activity.Domain.Strategies.Conditions; + +using Fengling.Activity.Domain.ValueObjects; + +public class MemberLevelCondition : IConditionStrategy +{ + public string StrategyType => "MemberLevel"; + + public Task ExecuteAsync(CampaignContext context, ConditionConfig config, CancellationToken cancellationToken = default) + { + var requiredLevel = config.GetParameter("RequiredLevel"); + var currentLevel = context.MemberLevel; + + if (string.IsNullOrEmpty(requiredLevel)) + { + return Task.FromResult(ConditionResult.Fail("Required level not configured")); + } + + if (string.IsNullOrEmpty(currentLevel)) + { + return Task.FromResult(ConditionResult.Fail("Member level not available")); + } + + var isLevelMet = CompareLevels(currentLevel, requiredLevel); + + return Task.FromResult(isLevelMet + ? ConditionResult.Success() + : ConditionResult.Fail($"Member level {currentLevel} does not meet requirement {requiredLevel}")); + } + + private static bool CompareLevels(string current, string required) + { + var levelOrder = new[] { "Bronze", "Silver", "Gold", "Platinum", "Diamond", "VIP" }; + var currentIndex = Array.IndexOf(levelOrder, current); + var requiredIndex = Array.IndexOf(levelOrder, required); + + if (currentIndex == -1 || requiredIndex == -1) + { + return current.Equals(required, StringComparison.OrdinalIgnoreCase); + } + + return currentIndex >= requiredIndex; + } +} diff --git a/src/Fengling.Activity.Domain/Strategies/Conditions/TimeRangeCondition.cs b/src/Fengling.Activity.Domain/Strategies/Conditions/TimeRangeCondition.cs new file mode 100644 index 0000000..abb0908 --- /dev/null +++ b/src/Fengling.Activity.Domain/Strategies/Conditions/TimeRangeCondition.cs @@ -0,0 +1,22 @@ +namespace Fengling.Activity.Domain.Strategies.Conditions; + +using Fengling.Activity.Domain.ValueObjects; + +public class TimeRangeCondition : IConditionStrategy +{ + public string StrategyType => "TimeRange"; + + public Task ExecuteAsync(CampaignContext context, ConditionConfig config, CancellationToken cancellationToken = default) + { + var startHour = config.GetParameter("StartHour"); + var endHour = config.GetParameter("EndHour"); + var currentHour = context.CurrentTime.Hour; + + if (currentHour >= startHour && currentHour < endHour) + { + return Task.FromResult(ConditionResult.Success()); + } + + return Task.FromResult(ConditionResult.Fail($"Current time {currentHour}:00 is not within allowed range {startHour}:00-{endHour}:00")); + } +} diff --git a/src/Fengling.Activity.Domain/Strategies/ConstraintResult.cs b/src/Fengling.Activity.Domain/Strategies/ConstraintResult.cs new file mode 100644 index 0000000..2ac8933 --- /dev/null +++ b/src/Fengling.Activity.Domain/Strategies/ConstraintResult.cs @@ -0,0 +1,8 @@ +namespace Fengling.Activity.Domain.Strategies; + +public record ConstraintResult(bool IsValid, string? Message = null, int? RemainingCount = null) +{ + public static ConstraintResult Pass() => new(true); + public static ConstraintResult Fail(string message) => new(false, message); + public static ConstraintResult LimitReached(string message, int remaining) => new(false, message, remaining); +} diff --git a/src/Fengling.Activity.Domain/Strategies/Constraints/DailyLimitConstraint.cs b/src/Fengling.Activity.Domain/Strategies/Constraints/DailyLimitConstraint.cs new file mode 100644 index 0000000..34d7f2d --- /dev/null +++ b/src/Fengling.Activity.Domain/Strategies/Constraints/DailyLimitConstraint.cs @@ -0,0 +1,27 @@ +namespace Fengling.Activity.Domain.Strategies.Constraints; + +using Fengling.Activity.Domain.ValueObjects; + +public class DailyLimitConstraint : IConstraintStrategy +{ + public string StrategyType => "DailyLimit"; + + public Task ExecuteAsync(CampaignContext context, ConstraintConfig config, CancellationToken cancellationToken = default) + { + var maxCount = config.GetParameter("MaxCount"); + var currentDate = context.CurrentTime.Date.ToString("yyyy-MM-dd"); + + if (context.AdditionalData.TryGetValue($"DailyCount_{currentDate}", out var countObj) && + countObj is int currentCount) + { + if (currentCount >= maxCount) + { + return Task.FromResult(ConstraintResult.LimitReached($"Daily limit of {maxCount} reached", 0)); + } + + return Task.FromResult(ConstraintResult.Pass()); + } + + return Task.FromResult(ConstraintResult.Pass()); + } +} diff --git a/src/Fengling.Activity.Domain/Strategies/Constraints/FrequencyLimitConstraint.cs b/src/Fengling.Activity.Domain/Strategies/Constraints/FrequencyLimitConstraint.cs new file mode 100644 index 0000000..22c41b9 --- /dev/null +++ b/src/Fengling.Activity.Domain/Strategies/Constraints/FrequencyLimitConstraint.cs @@ -0,0 +1,28 @@ +namespace Fengling.Activity.Domain.Strategies.Constraints; + +using Fengling.Activity.Domain.ValueObjects; + +public class FrequencyLimitConstraint : IConstraintStrategy +{ + public string StrategyType => "FrequencyLimit"; + + public Task ExecuteAsync(CampaignContext context, ConstraintConfig config, CancellationToken cancellationToken = default) + { + var intervalMinutes = config.GetParameter("IntervalMinutes"); + var lastParticipation = context.AdditionalData.TryGetValue("LastParticipationTime", out var timeObj) + ? timeObj is DateTime dt ? dt : (DateTime?)null + : null; + + if (lastParticipation.HasValue) + { + var timeSinceLast = (context.CurrentTime - lastParticipation.Value).TotalMinutes; + if (timeSinceLast < intervalMinutes) + { + var waitMinutes = intervalMinutes - (int)timeSinceLast; + return Task.FromResult(ConstraintResult.LimitReached($"Please wait {waitMinutes} minutes", waitMinutes)); + } + } + + return Task.FromResult(ConstraintResult.Pass()); + } +} diff --git a/src/Fengling.Activity.Domain/Strategies/Constraints/TotalLimitConstraint.cs b/src/Fengling.Activity.Domain/Strategies/Constraints/TotalLimitConstraint.cs new file mode 100644 index 0000000..2426e9e --- /dev/null +++ b/src/Fengling.Activity.Domain/Strategies/Constraints/TotalLimitConstraint.cs @@ -0,0 +1,26 @@ +namespace Fengling.Activity.Domain.Strategies.Constraints; + +using Fengling.Activity.Domain.ValueObjects; + +public class TotalLimitConstraint : IConstraintStrategy +{ + public string StrategyType => "TotalLimit"; + + public Task ExecuteAsync(CampaignContext context, ConstraintConfig config, CancellationToken cancellationToken = default) + { + var maxCount = config.GetParameter("MaxCount"); + + if (context.AdditionalData.TryGetValue("TotalCount", out var countObj) && + countObj is int currentCount) + { + if (currentCount >= maxCount) + { + return Task.FromResult(ConstraintResult.LimitReached($"Total limit of {maxCount} reached", 0)); + } + + return Task.FromResult(ConstraintResult.Pass()); + } + + return Task.FromResult(ConstraintResult.Pass()); + } +} diff --git a/src/Fengling.Activity.Domain/Strategies/IActionStrategy.cs b/src/Fengling.Activity.Domain/Strategies/IActionStrategy.cs new file mode 100644 index 0000000..2fb5a27 --- /dev/null +++ b/src/Fengling.Activity.Domain/Strategies/IActionStrategy.cs @@ -0,0 +1,9 @@ +namespace Fengling.Activity.Domain.Strategies; + +using Fengling.Activity.Domain.ValueObjects; + +public interface IActionStrategy +{ + string StrategyType { get; } + Task ExecuteAsync(CampaignContext context, ActionConfig config, CancellationToken cancellationToken = default); +} diff --git a/src/Fengling.Activity.Domain/Strategies/IConditionStrategy.cs b/src/Fengling.Activity.Domain/Strategies/IConditionStrategy.cs new file mode 100644 index 0000000..4c1c143 --- /dev/null +++ b/src/Fengling.Activity.Domain/Strategies/IConditionStrategy.cs @@ -0,0 +1,9 @@ +namespace Fengling.Activity.Domain.Strategies; + +using Fengling.Activity.Domain.ValueObjects; + +public interface IConditionStrategy +{ + string StrategyType { get; } + Task ExecuteAsync(CampaignContext context, ConditionConfig config, CancellationToken cancellationToken = default); +} diff --git a/src/Fengling.Activity.Domain/Strategies/IConstraintStrategy.cs b/src/Fengling.Activity.Domain/Strategies/IConstraintStrategy.cs new file mode 100644 index 0000000..93b5841 --- /dev/null +++ b/src/Fengling.Activity.Domain/Strategies/IConstraintStrategy.cs @@ -0,0 +1,9 @@ +namespace Fengling.Activity.Domain.Strategies; + +using Fengling.Activity.Domain.ValueObjects; + +public interface IConstraintStrategy +{ + string StrategyType { get; } + Task ExecuteAsync(CampaignContext context, ConstraintConfig config, CancellationToken cancellationToken = default); +} diff --git a/src/Fengling.Activity.Domain/Strategies/IStrategyFactory.cs b/src/Fengling.Activity.Domain/Strategies/IStrategyFactory.cs new file mode 100644 index 0000000..df82ae7 --- /dev/null +++ b/src/Fengling.Activity.Domain/Strategies/IStrategyFactory.cs @@ -0,0 +1,8 @@ +namespace Fengling.Activity.Domain.Strategies; + +public interface IStrategyFactory +{ + IConditionStrategy? GetConditionStrategy(string strategyType); + IConstraintStrategy? GetConstraintStrategy(string strategyType); + IActionStrategy? GetActionStrategy(string strategyType); +} diff --git a/src/Fengling.Activity.Domain/ValueObjects/ActionConfig.cs b/src/Fengling.Activity.Domain/ValueObjects/ActionConfig.cs new file mode 100644 index 0000000..1804493 --- /dev/null +++ b/src/Fengling.Activity.Domain/ValueObjects/ActionConfig.cs @@ -0,0 +1,41 @@ +namespace Fengling.Activity.Domain.ValueObjects; + +public class ActionConfig : IEquatable +{ + public string StrategyType { get; } + public IReadOnlyDictionary Parameters { get; } + + private ActionConfig(string strategyType, Dictionary parameters) + { + StrategyType = strategyType; + Parameters = parameters; + } + + public static ActionConfig Create(string strategyType, Dictionary parameters) + { + if (string.IsNullOrWhiteSpace(strategyType)) + throw new ArgumentException("Strategy type cannot be empty"); + return new ActionConfig(strategyType, parameters); + } + + public T? GetParameter(string key) + { + if (Parameters.TryGetValue(key, out var value) && value is T typedValue) + return typedValue; + return default; + } + + public bool Equals(ActionConfig? other) + { + if (other is null) return false; + if (StrategyType != other.StrategyType) return false; + return Parameters.Count == other.Parameters.Count && + Parameters.All(p => other.Parameters.TryGetValue(p.Key, out var ov) && + (p.Value?.Equals(ov) ?? ov is null)); + } + + public override bool Equals(object? obj) => Equals(obj as ActionConfig); + public override int GetHashCode() => HashCode.Combine(StrategyType, Parameters); + public static bool operator ==(ActionConfig a, ActionConfig b) => a.Equals(b); + public static bool operator !=(ActionConfig a, ActionConfig b) => !a.Equals(b); +} diff --git a/src/Fengling.Activity.Domain/ValueObjects/CampaignId.cs b/src/Fengling.Activity.Domain/ValueObjects/CampaignId.cs new file mode 100644 index 0000000..8672838 --- /dev/null +++ b/src/Fengling.Activity.Domain/ValueObjects/CampaignId.cs @@ -0,0 +1,18 @@ +namespace Fengling.Activity.Domain.ValueObjects; + +public class CampaignId : IEquatable +{ + public Guid Value { get; } + + private CampaignId(Guid value) => Value = value; + + public static CampaignId New() => new(Guid.NewGuid()); + public static CampaignId FromGuid(Guid value) => new(value); + public static CampaignId Parse(string value) => new(Guid.Parse(value)); + + public bool Equals(CampaignId? other) => other?.Value == Value; + public override bool Equals(object? obj) => obj is CampaignId other && Equals(other); + public override int GetHashCode() => Value.GetHashCode(); + public static implicit operator Guid(CampaignId id) => id.Value; + public override string ToString() => Value.ToString(); +} diff --git a/src/Fengling.Activity.Domain/ValueObjects/CampaignName.cs b/src/Fengling.Activity.Domain/ValueObjects/CampaignName.cs new file mode 100644 index 0000000..8139a43 --- /dev/null +++ b/src/Fengling.Activity.Domain/ValueObjects/CampaignName.cs @@ -0,0 +1,29 @@ +namespace Fengling.Activity.Domain.ValueObjects; + +public class CampaignName : IEquatable +{ + public string Value { get; } + + private CampaignName(string value) => Value = value; + + public static CampaignName Create(string name) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Campaign name cannot be empty"); + if (name.Length > 200) + throw new ArgumentException("Campaign name cannot exceed 200 characters"); + return new CampaignName(name.Trim()); + } + + public bool Equals(CampaignName? other) + { + if (other is null) return false; + return Value == other.Value; + } + + public override bool Equals(object? obj) => Equals(obj as CampaignName); + public override int GetHashCode() => Value.GetHashCode(); + public static bool operator ==(CampaignName a, CampaignName b) => a.Equals(b); + public static bool operator !=(CampaignName a, CampaignName b) => !a.Equals(b); + public override string ToString() => Value; +} diff --git a/src/Fengling.Activity.Domain/ValueObjects/ConditionConfig.cs b/src/Fengling.Activity.Domain/ValueObjects/ConditionConfig.cs new file mode 100644 index 0000000..3074fa7 --- /dev/null +++ b/src/Fengling.Activity.Domain/ValueObjects/ConditionConfig.cs @@ -0,0 +1,41 @@ +namespace Fengling.Activity.Domain.ValueObjects; + +public class ConditionConfig : IEquatable +{ + public string StrategyType { get; } + public IReadOnlyDictionary Parameters { get; } + + private ConditionConfig(string strategyType, Dictionary parameters) + { + StrategyType = strategyType; + Parameters = parameters; + } + + public static ConditionConfig Create(string strategyType, Dictionary parameters) + { + if (string.IsNullOrWhiteSpace(strategyType)) + throw new ArgumentException("Strategy type cannot be empty"); + return new ConditionConfig(strategyType, parameters); + } + + public T? GetParameter(string key) + { + if (Parameters.TryGetValue(key, out var value) && value is T typedValue) + return typedValue; + return default; + } + + public bool Equals(ConditionConfig? other) + { + if (other is null) return false; + if (StrategyType != other.StrategyType) return false; + return Parameters.Count == other.Parameters.Count && + Parameters.All(p => other.Parameters.TryGetValue(p.Key, out var ov) && + (p.Value?.Equals(ov) ?? ov is null)); + } + + public override bool Equals(object? obj) => Equals(obj as ConditionConfig); + public override int GetHashCode() => HashCode.Combine(StrategyType, Parameters); + public static bool operator ==(ConditionConfig a, ConditionConfig b) => a.Equals(b); + public static bool operator !=(ConditionConfig a, ConditionConfig b) => !a.Equals(b); +} diff --git a/src/Fengling.Activity.Domain/ValueObjects/ConstraintConfig.cs b/src/Fengling.Activity.Domain/ValueObjects/ConstraintConfig.cs new file mode 100644 index 0000000..d1780fd --- /dev/null +++ b/src/Fengling.Activity.Domain/ValueObjects/ConstraintConfig.cs @@ -0,0 +1,41 @@ +namespace Fengling.Activity.Domain.ValueObjects; + +public class ConstraintConfig : IEquatable +{ + public string StrategyType { get; } + public IReadOnlyDictionary Parameters { get; } + + private ConstraintConfig(string strategyType, Dictionary parameters) + { + StrategyType = strategyType; + Parameters = parameters; + } + + public static ConstraintConfig Create(string strategyType, Dictionary parameters) + { + if (string.IsNullOrWhiteSpace(strategyType)) + throw new ArgumentException("Strategy type cannot be empty"); + return new ConstraintConfig(strategyType, parameters); + } + + public T? GetParameter(string key) + { + if (Parameters.TryGetValue(key, out var value) && value is T typedValue) + return typedValue; + return default; + } + + public bool Equals(ConstraintConfig? other) + { + if (other is null) return false; + if (StrategyType != other.StrategyType) return false; + return Parameters.Count == other.Parameters.Count && + Parameters.All(p => other.Parameters.TryGetValue(p.Key, out var ov) && + (p.Value?.Equals(ov) ?? ov is null)); + } + + public override bool Equals(object? obj) => Equals(obj as ConstraintConfig); + public override int GetHashCode() => HashCode.Combine(StrategyType, Parameters); + public static bool operator ==(ConstraintConfig a, ConstraintConfig b) => a.Equals(b); + public static bool operator !=(ConstraintConfig a, ConstraintConfig b) => !a.Equals(b); +} diff --git a/src/Fengling.Activity.Domain/ValueObjects/TenantId.cs b/src/Fengling.Activity.Domain/ValueObjects/TenantId.cs new file mode 100644 index 0000000..704f498 --- /dev/null +++ b/src/Fengling.Activity.Domain/ValueObjects/TenantId.cs @@ -0,0 +1,18 @@ +namespace Fengling.Activity.Domain.ValueObjects; + +public class TenantId : IEquatable +{ + public Guid Value { get; } + + private TenantId(Guid value) => Value = value; + + public static TenantId New() => new(Guid.NewGuid()); + public static TenantId FromGuid(Guid value) => new(value); + public static TenantId Parse(string value) => new(Guid.Parse(value)); + + public bool Equals(TenantId? other) => other?.Value == Value; + public override bool Equals(object? obj) => obj is TenantId other && Equals(other); + public override int GetHashCode() => Value.GetHashCode(); + public static implicit operator Guid(TenantId id) => id.Value; + public override string ToString() => Value.ToString(); +} diff --git a/src/Fengling.Activity.Domain/ValueObjects/TimeRange.cs b/src/Fengling.Activity.Domain/ValueObjects/TimeRange.cs new file mode 100644 index 0000000..4f61a52 --- /dev/null +++ b/src/Fengling.Activity.Domain/ValueObjects/TimeRange.cs @@ -0,0 +1,35 @@ +namespace Fengling.Activity.Domain.ValueObjects; + +public class TimeRange : IEquatable +{ + public DateTime StartTime { get; } + public DateTime EndTime { get; } + + private TimeRange(DateTime startTime, DateTime endTime) + { + StartTime = startTime; + EndTime = endTime; + } + + public static TimeRange Create(DateTime startTime, DateTime endTime) + { + if (startTime >= endTime) + throw new ArgumentException("Start time must be before end time"); + return new TimeRange(startTime, endTime); + } + + public bool Contains(DateTime time) => time >= StartTime && time <= EndTime; + public bool IsActive() => Contains(DateTime.UtcNow); + public TimeSpan Duration() => EndTime - StartTime; + + public bool Equals(TimeRange? other) + { + if (other is null) return false; + return StartTime == other.StartTime && EndTime == other.EndTime; + } + + public override bool Equals(object? obj) => Equals(obj as TimeRange); + public override int GetHashCode() => HashCode.Combine(StartTime, EndTime); + public static bool operator ==(TimeRange a, TimeRange b) => a.Equals(b); + public static bool operator !=(TimeRange a, TimeRange b) => !a.Equals(b); +} diff --git a/src/Fengling.Activity.Infrastructure/ApplicationDbContext.cs b/src/Fengling.Activity.Infrastructure/ApplicationDbContext.cs new file mode 100644 index 0000000..846a381 --- /dev/null +++ b/src/Fengling.Activity.Infrastructure/ApplicationDbContext.cs @@ -0,0 +1,30 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; +using NetCorePal.Extensions.DistributedTransactions.CAP.Persistence; + +namespace Fengling.Activity.Infrastructure; + +public partial class ApplicationDbContext(DbContextOptions options, IMediator mediator) + : AppDbContextBase(options, mediator) + , IPostgreSqlCapDataStorage +{ + 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/src/Fengling.Activity.Infrastructure/DesignTimeApplicationDbContextFactory.cs b/src/Fengling.Activity.Infrastructure/DesignTimeApplicationDbContextFactory.cs new file mode 100644 index 0000000..8e0fc01 --- /dev/null +++ b/src/Fengling.Activity.Infrastructure/DesignTimeApplicationDbContextFactory.cs @@ -0,0 +1,27 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.Extensions.DependencyInjection; + +namespace Fengling.Activity.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.UseNpgsql("Host=any;Database=any;Username=any;Password=any", + 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/src/Fengling.Activity.Infrastructure/EntityConfigurations/CampaignEntityConfiguration.cs b/src/Fengling.Activity.Infrastructure/EntityConfigurations/CampaignEntityConfiguration.cs new file mode 100644 index 0000000..59b91ee --- /dev/null +++ b/src/Fengling.Activity.Infrastructure/EntityConfigurations/CampaignEntityConfiguration.cs @@ -0,0 +1,67 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Fengling.Activity.Domain.Aggregates.Campaigns; +using Fengling.Activity.Domain.Aggregates.ParticipationRecords; +using Fengling.Activity.Domain.Aggregates.RewardGrantRecords; +using Fengling.Activity.Domain.Enums; +using Fengling.Activity.Domain.ValueObjects; + +namespace Fengling.Activity.Infrastructure.EntityConfigurations; + +public class CampaignEntityConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("act_campaigns"); + + builder.HasKey(x => x.Id); + + builder.Property(x => x.Id) + .HasColumnName("id") + .ValueGeneratedNever(); + + builder.Property(x => x.TenantId) + .HasColumnName("tenant_id") + .IsRequired(); + + builder.Property(x => x.Name) + .HasColumnName("name") + .HasMaxLength(200) + .IsRequired(); + + builder.Property(x => x.Type) + .HasColumnName("type") + .HasConversion() + .IsRequired(); + + builder.Property(x => x.Status) + .HasColumnName("status") + .HasConversion() + .IsRequired(); + + builder.Property(x => x.Description) + .HasColumnName("description") + .HasMaxLength(1000); + + builder.Property(x => x.TimeRange) + .HasColumnName("time_range") + .HasColumnType("jsonb"); + + builder.Property(x => x.MaxParticipants) + .HasColumnName("max_participants"); + + builder.Property(x => x.MaxRewardsPerMember) + .HasColumnName("max_rewards_per_member"); + + builder.Property(x => x.CreatedAt) + .HasColumnName("created_at") + .IsRequired(); + + builder.Property(x => x.UpdatedAt) + .HasColumnName("updated_at"); + + builder.Property(x => x.Version) + .HasColumnName("version") + .IsRequired(); + } +} diff --git a/src/Fengling.Activity.Infrastructure/EntityConfigurations/ParticipationRecordEntityConfiguration.cs b/src/Fengling.Activity.Infrastructure/EntityConfigurations/ParticipationRecordEntityConfiguration.cs new file mode 100644 index 0000000..974136c --- /dev/null +++ b/src/Fengling.Activity.Infrastructure/EntityConfigurations/ParticipationRecordEntityConfiguration.cs @@ -0,0 +1,64 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Fengling.Activity.Domain.Aggregates.ParticipationRecords; +using Fengling.Activity.Domain.Enums; + +namespace Fengling.Activity.Infrastructure.EntityConfigurations; + +public class ParticipationRecordEntityConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("act_participation_records"); + + builder.HasKey(x => x.Id); + + builder.Property(x => x.Id) + .HasColumnName("id") + .ValueGeneratedNever(); + + builder.Property(x => x.CampaignId) + .HasColumnName("campaign_id") + .IsRequired(); + + builder.Property(x => x.TenantId) + .HasColumnName("tenant_id") + .IsRequired(); + + builder.Property(x => x.MemberId) + .HasColumnName("member_id") + .IsRequired(); + + builder.Property(x => x.Status) + .HasColumnName("status") + .HasConversion() + .IsRequired(); + + builder.Property(x => x.CurrentProgress) + .HasColumnName("current_progress") + .IsRequired(); + + builder.Property(x => x.TargetProgress) + .HasColumnName("target_progress"); + + builder.Property(x => x.RewardsGranted) + .HasColumnName("rewards_granted") + .IsRequired(); + + builder.Property(x => x.ParticipatedAt) + .HasColumnName("participated_at") + .IsRequired(); + + builder.Property(x => x.CompletedAt) + .HasColumnName("completed_at"); + + builder.Property(x => x.ExpiredAt) + .HasColumnName("expired_at"); + + builder.Property(x => x.UpdatedAt) + .HasColumnName("updated_at"); + + builder.HasIndex(x => new { x.CampaignId, x.MemberId }) + .HasDatabaseName("ix_participation_campaign_member"); + } +} diff --git a/src/Fengling.Activity.Infrastructure/EntityConfigurations/RewardGrantRecordEntityConfiguration.cs b/src/Fengling.Activity.Infrastructure/EntityConfigurations/RewardGrantRecordEntityConfiguration.cs new file mode 100644 index 0000000..b115641 --- /dev/null +++ b/src/Fengling.Activity.Infrastructure/EntityConfigurations/RewardGrantRecordEntityConfiguration.cs @@ -0,0 +1,62 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Fengling.Activity.Domain.Aggregates.RewardGrantRecords; +using Fengling.Activity.Domain.Enums; + +namespace Fengling.Activity.Infrastructure.EntityConfigurations; + +public class RewardGrantRecordEntityConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("act_reward_grant_records"); + + builder.HasKey(x => x.Id); + + builder.Property(x => x.Id) + .HasColumnName("id") + .ValueGeneratedNever(); + + builder.Property(x => x.CampaignId) + .HasColumnName("campaign_id") + .IsRequired(); + + builder.Property(x => x.TenantId) + .HasColumnName("tenant_id") + .IsRequired(); + + builder.Property(x => x.MemberId) + .HasColumnName("member_id") + .IsRequired(); + + builder.Property(x => x.ParticipationRecordId) + .HasColumnName("participation_record_id"); + + builder.Property(x => x.RewardType) + .HasColumnName("reward_type") + .HasConversion() + .IsRequired(); + + builder.Property(x => x.RewardAmount) + .HasColumnName("reward_amount") + .IsRequired(); + + builder.Property(x => x.RewardCode) + .HasColumnName("reward_code") + .HasMaxLength(100); + + builder.Property(x => x.Remark) + .HasColumnName("remark") + .HasMaxLength(500); + + builder.Property(x => x.GrantedAt) + .HasColumnName("granted_at") + .IsRequired(); + + builder.HasIndex(x => new { x.CampaignId, x.MemberId }) + .HasDatabaseName("ix_reward_campaign_member"); + + builder.HasIndex(x => x.GrantedAt) + .HasDatabaseName("ix_reward_granted_at"); + } +} diff --git a/src/Fengling.Activity.Infrastructure/Fengling.Activity.Infrastructure.csproj b/src/Fengling.Activity.Infrastructure/Fengling.Activity.Infrastructure.csproj new file mode 100644 index 0000000..e90357e --- /dev/null +++ b/src/Fengling.Activity.Infrastructure/Fengling.Activity.Infrastructure.csproj @@ -0,0 +1,31 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + diff --git a/src/Fengling.Activity.Infrastructure/GlobalUsings.cs b/src/Fengling.Activity.Infrastructure/GlobalUsings.cs new file mode 100644 index 0000000..9f026ce --- /dev/null +++ b/src/Fengling.Activity.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/src/Fengling.Activity.Infrastructure/Migrations/20260122054728_Init.Designer.cs b/src/Fengling.Activity.Infrastructure/Migrations/20260122054728_Init.Designer.cs new file mode 100644 index 0000000..25bab61 --- /dev/null +++ b/src/Fengling.Activity.Infrastructure/Migrations/20260122054728_Init.Designer.cs @@ -0,0 +1,140 @@ +// +using System; +using Fengling.Activity.Infrastructure; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Fengling.Activity.Infrastructure.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260122054728_Init")] + partial class Init + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("NetCorePal.Extensions.DistributedTransactions.CAP.Persistence.CapLock", b => + { + b.Property("Key") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Instance") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LastLockTime") + .HasColumnType("TIMESTAMP"); + + b.HasKey("Key"); + + b.ToTable("CAPLock", (string)null); + }); + + modelBuilder.Entity("NetCorePal.Extensions.DistributedTransactions.CAP.Persistence.PublishedMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Added") + .HasColumnType("TIMESTAMP"); + + b.Property("Content") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TIMESTAMP"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Retries") + .HasColumnType("integer"); + + b.Property("StatusName") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Version") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("Id"); + + b.HasIndex(new[] { "ExpiresAt", "StatusName" }, "IX_ExpiresAt_StatusName"); + + b.HasIndex(new[] { "Version", "ExpiresAt", "StatusName" }, "IX_Version_ExpiresAt_StatusName"); + + b.ToTable("CAPPublishedMessage", (string)null); + }); + + modelBuilder.Entity("NetCorePal.Extensions.DistributedTransactions.CAP.Persistence.ReceivedMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Added") + .HasColumnType("TIMESTAMP"); + + b.Property("Content") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TIMESTAMP"); + + b.Property("Group") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("Retries") + .HasColumnType("integer"); + + b.Property("StatusName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Version") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("Id"); + + b.HasIndex(new[] { "ExpiresAt", "StatusName" }, "IX_ExpiresAt_StatusName") + .HasDatabaseName("IX_ExpiresAt_StatusName1"); + + b.HasIndex(new[] { "Version", "ExpiresAt", "StatusName" }, "IX_Version_ExpiresAt_StatusName") + .HasDatabaseName("IX_Version_ExpiresAt_StatusName1"); + + b.ToTable("CAPReceivedMessage", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Fengling.Activity.Infrastructure/Migrations/20260122054728_Init.cs b/src/Fengling.Activity.Infrastructure/Migrations/20260122054728_Init.cs new file mode 100644 index 0000000..2f9d915 --- /dev/null +++ b/src/Fengling.Activity.Infrastructure/Migrations/20260122054728_Init.cs @@ -0,0 +1,101 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Fengling.Activity.Infrastructure.Migrations +{ + /// + public partial class Init : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "CAPLock", + columns: table => new + { + Key = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + Instance = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + LastLockTime = table.Column(type: "TIMESTAMP", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_CAPLock", x => x.Key); + }); + + migrationBuilder.CreateTable( + name: "CAPPublishedMessage", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Version = table.Column(type: "character varying(20)", maxLength: 20, nullable: true), + Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + Content = table.Column(type: "TEXT", nullable: true), + Retries = table.Column(type: "integer", nullable: true), + Added = table.Column(type: "TIMESTAMP", nullable: false), + ExpiresAt = table.Column(type: "TIMESTAMP", nullable: true), + StatusName = table.Column(type: "character varying(40)", maxLength: 40, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_CAPPublishedMessage", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "CAPReceivedMessage", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Version = table.Column(type: "character varying(20)", maxLength: 20, nullable: true), + Name = table.Column(type: "character varying(400)", maxLength: 400, nullable: false), + Group = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + Content = table.Column(type: "TEXT", nullable: true), + Retries = table.Column(type: "integer", nullable: true), + Added = table.Column(type: "TIMESTAMP", nullable: false), + ExpiresAt = table.Column(type: "TIMESTAMP", nullable: true), + StatusName = table.Column(type: "character varying(50)", maxLength: 50, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_CAPReceivedMessage", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_ExpiresAt_StatusName", + table: "CAPPublishedMessage", + columns: new[] { "ExpiresAt", "StatusName" }); + + migrationBuilder.CreateIndex( + name: "IX_Version_ExpiresAt_StatusName", + table: "CAPPublishedMessage", + columns: new[] { "Version", "ExpiresAt", "StatusName" }); + + migrationBuilder.CreateIndex( + name: "IX_ExpiresAt_StatusName1", + table: "CAPReceivedMessage", + columns: new[] { "ExpiresAt", "StatusName" }); + + migrationBuilder.CreateIndex( + name: "IX_Version_ExpiresAt_StatusName1", + table: "CAPReceivedMessage", + columns: new[] { "Version", "ExpiresAt", "StatusName" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "CAPLock"); + + migrationBuilder.DropTable( + name: "CAPPublishedMessage"); + + migrationBuilder.DropTable( + name: "CAPReceivedMessage"); + } + } +} diff --git a/src/Fengling.Activity.Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs b/src/Fengling.Activity.Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs new file mode 100644 index 0000000..f69a62d --- /dev/null +++ b/src/Fengling.Activity.Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs @@ -0,0 +1,137 @@ +// +using System; +using Fengling.Activity.Infrastructure; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Fengling.Activity.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") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("NetCorePal.Extensions.DistributedTransactions.CAP.Persistence.CapLock", b => + { + b.Property("Key") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Instance") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LastLockTime") + .HasColumnType("TIMESTAMP"); + + b.HasKey("Key"); + + b.ToTable("CAPLock", (string)null); + }); + + modelBuilder.Entity("NetCorePal.Extensions.DistributedTransactions.CAP.Persistence.PublishedMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Added") + .HasColumnType("TIMESTAMP"); + + b.Property("Content") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TIMESTAMP"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Retries") + .HasColumnType("integer"); + + b.Property("StatusName") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Version") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("Id"); + + b.HasIndex(new[] { "ExpiresAt", "StatusName" }, "IX_ExpiresAt_StatusName"); + + b.HasIndex(new[] { "Version", "ExpiresAt", "StatusName" }, "IX_Version_ExpiresAt_StatusName"); + + b.ToTable("CAPPublishedMessage", (string)null); + }); + + modelBuilder.Entity("NetCorePal.Extensions.DistributedTransactions.CAP.Persistence.ReceivedMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Added") + .HasColumnType("TIMESTAMP"); + + b.Property("Content") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TIMESTAMP"); + + b.Property("Group") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("Retries") + .HasColumnType("integer"); + + b.Property("StatusName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Version") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("Id"); + + b.HasIndex(new[] { "ExpiresAt", "StatusName" }, "IX_ExpiresAt_StatusName") + .HasDatabaseName("IX_ExpiresAt_StatusName1"); + + b.HasIndex(new[] { "Version", "ExpiresAt", "StatusName" }, "IX_Version_ExpiresAt_StatusName") + .HasDatabaseName("IX_Version_ExpiresAt_StatusName1"); + + b.ToTable("CAPReceivedMessage", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Fengling.Activity.Infrastructure/Repositories/CampaignRepository.cs b/src/Fengling.Activity.Infrastructure/Repositories/CampaignRepository.cs new file mode 100644 index 0000000..cbb3b54 --- /dev/null +++ b/src/Fengling.Activity.Infrastructure/Repositories/CampaignRepository.cs @@ -0,0 +1,69 @@ +using Microsoft.EntityFrameworkCore; +using Fengling.Activity.Domain.Aggregates.Campaigns; +using Fengling.Activity.Domain.Enums; +using Fengling.Activity.Domain.Repositories; +using Fengling.Activity.Domain.ValueObjects; +using Fengling.Activity.Infrastructure; + +namespace Fengling.Activity.Infrastructure.Repositories; + +public class CampaignRepository : ICampaignRepository +{ + private readonly ApplicationDbContext _context; + + public CampaignRepository(ApplicationDbContext context) => _context = context; + + public async Task GetByIdAsync(CampaignId id, CancellationToken cancellationToken = default) + { + return await _context.Set() + .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); + } + + public async Task> GetByTenantIdAsync(TenantId tenantId, CancellationToken cancellationToken = default) + { + return await _context.Set() + .Where(x => x.TenantId == tenantId) + .ToListAsync(cancellationToken); + } + + public async Task> GetByStatusAsync(CampaignStatus status, CancellationToken cancellationToken = default) + { + return await _context.Set() + .Where(x => x.Status == status) + .ToListAsync(cancellationToken); + } + + public async Task> GetActiveCampaignsAsync(TenantId tenantId, CancellationToken cancellationToken = default) + { + var now = DateTime.UtcNow; + return await _context.Set() + .Where(x => x.TenantId == tenantId && + x.Status == CampaignStatus.Published && + x.TimeRange.StartTime <= now && + x.TimeRange.EndTime >= now) + .ToListAsync(cancellationToken); + } + + public async Task AddAsync(Campaign campaign, CancellationToken cancellationToken = default) + { + await _context.Set().AddAsync(campaign, cancellationToken); + } + + public async Task UpdateAsync(Campaign campaign, CancellationToken cancellationToken = default) + { + _context.Set().Update(campaign); + await Task.CompletedTask; + } + + public async Task DeleteAsync(Campaign campaign, CancellationToken cancellationToken = default) + { + _context.Set().Remove(campaign); + await Task.CompletedTask; + } + + public async Task ExistsAsync(CampaignId id, CancellationToken cancellationToken = default) + { + return await _context.Set() + .AnyAsync(x => x.Id == id, cancellationToken); + } +} diff --git a/src/Fengling.Activity.Infrastructure/Repositories/ParticipationRecordRepository.cs b/src/Fengling.Activity.Infrastructure/Repositories/ParticipationRecordRepository.cs new file mode 100644 index 0000000..ccbe0a1 --- /dev/null +++ b/src/Fengling.Activity.Infrastructure/Repositories/ParticipationRecordRepository.cs @@ -0,0 +1,72 @@ +using Microsoft.EntityFrameworkCore; +using Fengling.Activity.Domain.Aggregates.Campaigns; +using Fengling.Activity.Domain.Aggregates.ParticipationRecords; +using Fengling.Activity.Domain.Enums; +using Fengling.Activity.Domain.Repositories; +using Fengling.Activity.Domain.ValueObjects; +using Fengling.Activity.Infrastructure; + +namespace Fengling.Activity.Infrastructure.Repositories; + +public class ParticipationRecordRepository : IParticipationRecordRepository +{ + private readonly ApplicationDbContext _context; + + public ParticipationRecordRepository(ApplicationDbContext context) => _context = context; + + public async Task GetByIdAsync(ParticipationRecordId id, CancellationToken cancellationToken = default) + { + return await _context.Set() + .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); + } + + public async Task> GetByCampaignIdAsync(CampaignId campaignId, CancellationToken cancellationToken = default) + { + return await _context.Set() + .Where(x => x.CampaignId == campaignId) + .ToListAsync(cancellationToken); + } + + public async Task> GetByMemberIdAsync(TenantId tenantId, Guid memberId, CancellationToken cancellationToken = default) + { + return await _context.Set() + .Where(x => x.TenantId == tenantId && x.MemberId == memberId) + .ToListAsync(cancellationToken); + } + + public async Task GetByCampaignAndMemberAsync(CampaignId campaignId, Guid memberId, CancellationToken cancellationToken = default) + { + return await _context.Set() + .FirstOrDefaultAsync(x => x.CampaignId == campaignId && x.MemberId == memberId, cancellationToken); + } + + public async Task> GetByStatusAsync(CampaignId campaignId, ParticipationStatus status, CancellationToken cancellationToken = default) + { + return await _context.Set() + .Where(x => x.CampaignId == campaignId && x.Status == status) + .ToListAsync(cancellationToken); + } + + public async Task GetParticipationCountAsync(CampaignId campaignId, Guid memberId, CancellationToken cancellationToken = default) + { + return await _context.Set() + .CountAsync(x => x.CampaignId == campaignId && x.MemberId == memberId, cancellationToken); + } + + public async Task AddAsync(ParticipationRecord record, CancellationToken cancellationToken = default) + { + await _context.Set().AddAsync(record, cancellationToken); + } + + public async Task UpdateAsync(ParticipationRecord record, CancellationToken cancellationToken = default) + { + _context.Set().Update(record); + await Task.CompletedTask; + } + + public async Task DeleteAsync(ParticipationRecord record, CancellationToken cancellationToken = default) + { + _context.Set().Remove(record); + await Task.CompletedTask; + } +} diff --git a/src/Fengling.Activity.Infrastructure/Repositories/RewardGrantRecordRepository.cs b/src/Fengling.Activity.Infrastructure/Repositories/RewardGrantRecordRepository.cs new file mode 100644 index 0000000..55bfe8f --- /dev/null +++ b/src/Fengling.Activity.Infrastructure/Repositories/RewardGrantRecordRepository.cs @@ -0,0 +1,68 @@ +using Microsoft.EntityFrameworkCore; +using Fengling.Activity.Domain.Aggregates.Campaigns; +using Fengling.Activity.Domain.Aggregates.RewardGrantRecords; +using Fengling.Activity.Domain.Enums; +using Fengling.Activity.Domain.Repositories; +using Fengling.Activity.Domain.ValueObjects; +using Fengling.Activity.Infrastructure; + +namespace Fengling.Activity.Infrastructure.Repositories; + +public class RewardGrantRecordRepository : IRewardGrantRecordRepository +{ + private readonly ApplicationDbContext _context; + + public RewardGrantRecordRepository(ApplicationDbContext context) => _context = context; + + public async Task GetByIdAsync(RewardGrantRecordId id, CancellationToken cancellationToken = default) + { + return await _context.Set() + .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); + } + + public async Task> GetByCampaignIdAsync(CampaignId campaignId, CancellationToken cancellationToken = default) + { + return await _context.Set() + .Where(x => x.CampaignId == campaignId) + .ToListAsync(cancellationToken); + } + + public async Task> GetByMemberIdAsync(TenantId tenantId, Guid memberId, CancellationToken cancellationToken = default) + { + return await _context.Set() + .Where(x => x.TenantId == tenantId && x.MemberId == memberId) + .ToListAsync(cancellationToken); + } + + public async Task GetRewardCountAsync(CampaignId campaignId, Guid memberId, CancellationToken cancellationToken = default) + { + return await _context.Set() + .CountAsync(x => x.CampaignId == campaignId && x.MemberId == memberId, cancellationToken); + } + + public async Task AddAsync(RewardGrantRecord record, CancellationToken cancellationToken = default) + { + await _context.Set().AddAsync(record, cancellationToken); + } + + public async Task UpdateAsync(RewardGrantRecord record, CancellationToken cancellationToken = default) + { + _context.Set().Update(record); + await Task.CompletedTask; + } + + public async Task DeleteAsync(RewardGrantRecord record, CancellationToken cancellationToken = default) + { + _context.Set().Remove(record); + await Task.CompletedTask; + } + + public async Task> GetRewardsByDateRangeAsync(TenantId tenantId, DateTime startDate, DateTime endDate, CancellationToken cancellationToken = default) + { + return await _context.Set() + .Where(x => x.TenantId == tenantId && + x.GrantedAt >= startDate && + x.GrantedAt <= endDate) + .ToListAsync(cancellationToken); + } +} diff --git a/src/Fengling.Activity.Web/Controllers/CampaignsController.cs b/src/Fengling.Activity.Web/Controllers/CampaignsController.cs new file mode 100644 index 0000000..8396673 --- /dev/null +++ b/src/Fengling.Activity.Web/Controllers/CampaignsController.cs @@ -0,0 +1,148 @@ +using Microsoft.AspNetCore.Mvc; +using Fengling.Activity.Domain.Aggregates.Campaigns; +using Fengling.Activity.Domain.Enums; +using Fengling.Activity.Domain.Repositories; +using Fengling.Activity.Domain.ValueObjects; + +namespace Fengling.Activity.Web.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class CampaignsController : ControllerBase +{ + private readonly ICampaignRepository _campaignRepository; + + public CampaignsController(ICampaignRepository campaignRepository) + { + _campaignRepository = campaignRepository; + } + + [HttpPost] + public async Task> CreateCampaign( + [FromBody] CreateCampaignRequest request, + CancellationToken cancellationToken) + { + var campaign = Campaign.Create( + TenantId.FromGuid(request.TenantId), + CampaignName.Create(request.Name), + request.Type, + TimeRange.Create(request.StartTime, request.EndTime), + request.Description); + + if (request.MaxParticipants.HasValue) + { + campaign.UpdateMaxParticipants(request.MaxParticipants); + } + + if (request.MaxRewardsPerMember.HasValue) + { + campaign.UpdateMaxRewardsPerMember(request.MaxRewardsPerMember); + } + + await _campaignRepository.AddAsync(campaign, cancellationToken); + + return CreatedAtAction(nameof(GetCampaign), new { campaignId = campaign.Id.Value }, new CreateCampaignResponse + { + CampaignId = campaign.Id.Value, + Name = campaign.Name.Value, + Status = campaign.Status.ToString(), + CreatedAt = campaign.CreatedAt + }); + } + + [HttpGet("{campaignId:guid}")] + public async Task> GetCampaign(Guid campaignId, CancellationToken cancellationToken) + { + var campaign = await _campaignRepository.GetByIdAsync(CampaignId.FromGuid(campaignId), cancellationToken); + if (campaign == null) + { + return NotFound(new { error = "Campaign not found" }); + } + + return Ok(new GetCampaignResponse + { + CampaignId = campaign.Id.Value, + TenantId = campaign.TenantId.Value, + Name = campaign.Name.Value, + Type = campaign.Type.ToString(), + Status = campaign.Status.ToString(), + Description = campaign.Description, + StartTime = campaign.TimeRange.StartTime, + EndTime = campaign.TimeRange.EndTime, + MaxParticipants = campaign.MaxParticipants, + MaxRewardsPerMember = campaign.MaxRewardsPerMember, + CreatedAt = campaign.CreatedAt, + UpdatedAt = campaign.UpdatedAt + }); + } + + [HttpPost("{campaignId:guid}/publish")] + public async Task> PublishCampaign(Guid campaignId, CancellationToken cancellationToken) + { + var campaign = await _campaignRepository.GetByIdAsync(CampaignId.FromGuid(campaignId), cancellationToken); + if (campaign == null) + { + return NotFound(new { error = "Campaign not found" }); + } + + try + { + campaign.Publish(); + await _campaignRepository.UpdateAsync(campaign, cancellationToken); + + return Ok(new PublishCampaignResponse + { + CampaignId = campaign.Id.Value, + Status = campaign.Status.ToString(), + UpdatedAt = campaign.UpdatedAt + }); + } + catch (InvalidOperationException ex) + { + return BadRequest(new { error = ex.Message }); + } + } +} + +public class CreateCampaignRequest +{ + public Guid TenantId { get; set; } + public string Name { get; set; } = ""; + public CampaignType Type { get; set; } + public DateTime StartTime { get; set; } + public DateTime EndTime { get; set; } + public string? Description { get; set; } + public int? MaxParticipants { get; set; } + public int? MaxRewardsPerMember { get; set; } +} + +public class CreateCampaignResponse +{ + public Guid CampaignId { get; set; } + public string Name { get; set; } = ""; + public string Status { get; set; } = ""; + public DateTime CreatedAt { get; set; } +} + +public class GetCampaignResponse +{ + public Guid CampaignId { get; set; } + public Guid TenantId { get; set; } + public string Name { get; set; } = ""; + public string Type { get; set; } = ""; + public string Status { get; set; } = ""; + public string? Description { get; set; } + public DateTime StartTime { get; set; } + public DateTime EndTime { get; set; } + public int? MaxParticipants { get; set; } + public int? MaxRewardsPerMember { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime? UpdatedAt { get; set; } +} + +public class PublishCampaignResponse +{ + public Guid CampaignId { get; set; } + public string Status { get; set; } = ""; + public DateTime? UpdatedAt { get; set; } +} diff --git a/src/Fengling.Activity.Web/Dockerfile b/src/Fengling.Activity.Web/Dockerfile new file mode 100644 index 0000000..f0b2aad --- /dev/null +++ b/src/Fengling.Activity.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.Activity.Web/Fengling.Activity.Web.csproj" +WORKDIR "/src/src/Fengling.Activity.Web" +RUN dotnet build "Fengling.Activity.Web.csproj" -c Release -o /app/build + +FROM build AS publish +RUN dotnet publish "Fengling.Activity.Web.csproj" -c Release -o /app/publish + +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "Fengling.Activity.Web.dll"] diff --git a/src/Fengling.Activity.Web/Fengling.Activity.Web.csproj b/src/Fengling.Activity.Web/Fengling.Activity.Web.csproj new file mode 100644 index 0000000..48ba5b1 --- /dev/null +++ b/src/Fengling.Activity.Web/Fengling.Activity.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/src/Fengling.Activity.Web/GlobalUsings.cs b/src/Fengling.Activity.Web/GlobalUsings.cs new file mode 100644 index 0000000..60b7c2b --- /dev/null +++ b/src/Fengling.Activity.Web/GlobalUsings.cs @@ -0,0 +1,10 @@ +global using NetCorePal.Extensions.AspNetCore; +global using NetCorePal.Extensions.DependencyInjection; +global using Microsoft.Extensions.DependencyInjection; +global using Fengling.Activity.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; \ No newline at end of file diff --git a/src/Fengling.Activity.Web/Program.cs b/src/Fengling.Activity.Web/Program.cs new file mode 100644 index 0000000..f496bcb --- /dev/null +++ b/src/Fengling.Activity.Web/Program.cs @@ -0,0 +1,105 @@ +using Microsoft.EntityFrameworkCore; +using Prometheus; +using System.Reflection; +using FastEndpoints; +using Serilog; +using Serilog.Formatting.Json; +using FluentValidation.AspNetCore; +using NetCorePal.Extensions.CodeAnalysis; + +Log.Logger = new LoggerConfiguration() + .Enrich.WithClientIp() + .WriteTo.Console(new JsonFormatter()) + .CreateLogger(); + +try +{ + var builder = WebApplication.CreateBuilder(args); + builder.Host.UseSerilog(); + + builder.Services.AddHealthChecks(); + builder.Services.AddControllers(); + + builder.Services.AddEndpointsApiExplorer(); + builder.Services.AddSwaggerGen(); + + builder.Services.AddFastEndpoints(o => o.IncludeAbstractValidators = true); + + builder.Services.AddFluentValidationAutoValidation(); + builder.Services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly()); + builder.Services.AddKnownExceptionErrorModelInterceptor(); + + builder.Services.AddRepositories(typeof(ApplicationDbContext).Assembly); + + builder.Services.AddDbContext(options => + { + options.UseNpgsql(builder.Configuration.GetConnectionString("PostgreSQL")); + if (builder.Environment.IsDevelopment()) + { + options.EnableSensitiveDataLogging(); + } + options.EnableDetailedErrors(); + }); + builder.Services.AddUnitOfWork(); + builder.Services.AddContext().AddEnvContext().AddCapContextProcessor(); + builder.Services.AddIntegrationEvents(typeof(Program)) + .UseCap(b => + { + b.RegisterServicesFromAssemblies(typeof(Program)); + b.AddContextIntegrationFilters(); + }); + + builder.Services.AddCap(x => + { + x.UseNetCorePalStorage(); + x.ConsumerThreadCount = Environment.ProcessorCount; + x.UseRabbitMQ(p => builder.Configuration.GetSection("RabbitMQ").Bind(p)); + x.UseDashboard(); + }); + + builder.Services.AddMediatR(cfg => + cfg.RegisterServicesFromAssemblies(Assembly.GetExecutingAssembly()) + .AddCommandLockBehavior() + .AddKnownExceptionValidationBehavior() + .AddUnitOfWorkBehaviors()); + + var app = builder.Build(); + + if (!app.Environment.IsProduction()) + { + using var scope = app.Services.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + await dbContext.Database.MigrateAsync(); + } + + app.UseKnownExceptionHandler(); + if (app.Environment.IsDevelopment()) + { + app.UseSwagger(); + app.UseSwaggerUI(); + } + + app.UseRouting(); + app.UseAuthorization(); + app.MapControllers(); + app.UseFastEndpoints(); + app.UseHttpMetrics(); + app.MapHealthChecks("/health"); + app.MapMetrics(); + + 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/src/Fengling.Activity.Web/Properties/launchSettings.json b/src/Fengling.Activity.Web/Properties/launchSettings.json new file mode 100644 index 0000000..588d2a1 --- /dev/null +++ b/src/Fengling.Activity.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/src/Fengling.Activity.Web/appsettings.Development.json b/src/Fengling.Activity.Web/appsettings.Development.json new file mode 100644 index 0000000..9c262f3 --- /dev/null +++ b/src/Fengling.Activity.Web/appsettings.Development.json @@ -0,0 +1,31 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "ConnectionStrings": { + "PostgreSQL": "Host=localhost;Database=dev;Username=postgres;Password=123456", + "Redis": "81.68.223.70:16379,password=sl52788542" + }, + "RabbitMQ": { + "HostName": "localhost", + "UserName": "guest", + "Password": "guest", + "VirtualHost": "/", + "Port": 5672 + }, + "Services": { + "user": { + "https": [ + "https://user:8443" + ] + }, + "user-v2": { + "https": [ + "https://user-v2:8443" + ] + } + } +} diff --git a/src/Fengling.Activity.Web/appsettings.json b/src/Fengling.Activity.Web/appsettings.json new file mode 100644 index 0000000..08d08a0 --- /dev/null +++ b/src/Fengling.Activity.Web/appsettings.json @@ -0,0 +1,32 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "ConnectionStrings": { + "PostgreSQL": "Host=localhost;Database=dev;Username=postgres;Password=123456", + "Redis": "81.68.223.70:16379,password=sl52788542" + }, + "RabbitMQ": { + "HostName": "localhost", + "UserName": "guest", + "Password": "guest", + "VirtualHost": "/", + "Port": 5672 + }, + "Services": { + "user": { + "https": [ + "https://user:8443" + ] + }, + "user-v2": { + "https": [ + "https://user-v2:8443" + ] + } + } +} \ No newline at end of file diff --git a/vs-snippets/Install-VSSnippets.ps1 b/vs-snippets/Install-VSSnippets.ps1 new file mode 100644 index 0000000..f8a68e6 --- /dev/null +++ b/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/vs-snippets/NetCorePalTemplates.snippet b/vs-snippets/NetCorePalTemplates.snippet new file mode 100644 index 0000000..1745824 --- /dev/null +++ b/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$ + } +}]]> + + +
+ +