From ab8d12527e258d066559e906b80ed6e50359a997 Mon Sep 17 00:00:00 2001 From: sam Date: Sun, 15 Feb 2026 10:34:07 +0800 Subject: [PATCH] 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 --- .dockerignore | 25 + .gitattributes | 63 + .gitignore | 398 ++++++ Directory.Build.props | 34 + Directory.Build.targets | 3 + Directory.Packages.props | 152 ++ Fengling.Activity.sln.DotSettings | 918 ++++++++++++ NuGet.config | 7 + README.md | 230 +++ docker-compose.gateway.yml | 58 + docs/GATEWAY_INTEGRATION.md | 273 ++++ docs/NAMING_CONVENTION.md | 198 +++ eng/versions.props | 6 + global.json | 7 + scripts/EXAMPLES.md | 151 ++ scripts/README.md | 56 + scripts/clean-infrastructure.ps1 | 195 +++ scripts/clean-infrastructure.sh | 177 +++ scripts/docker-compose.yml | 167 +++ scripts/gateway/Register-ActivityService.ps1 | 135 ++ scripts/gateway/Register-Service.ps1 | 118 ++ scripts/gateway/register-service.sh | 185 +++ scripts/init-infrastructure.ps1 | 258 ++++ scripts/init-infrastructure.sh | 200 +++ scripts/mysql-init/01-init.sql | 18 + scripts/postgres-init/01-init.sql | 24 + .../Aggregates/Campaigns/Campaign.cs | 139 ++ .../ParticipationRecord.cs | 90 ++ .../ParticipationRecordId.cs | 18 + .../RewardGrantRecords/RewardGrantRecord.cs | 46 + .../RewardGrantRecords/RewardGrantRecordId.cs | 18 + .../Enums/CampaignStatus.cs | 10 + .../Enums/CampaignType.cs | 12 + .../Enums/ParticipationStatus.cs | 9 + .../Enums/RewardType.cs | 11 + .../Campaigns/CampaignCancelledEvent.cs | 9 + .../Campaigns/CampaignCompletedEvent.cs | 9 + .../Events/Campaigns/CampaignCreatedEvent.cs | 13 + .../Events/Campaigns/CampaignPausedEvent.cs | 9 + .../Campaigns/CampaignPublishedEvent.cs | 9 + .../TaskCompletedEvent.cs | 12 + .../TaskParticipatedEvent.cs | 12 + .../Fengling.Activity.Domain.csproj | 21 + src/Fengling.Activity.Domain/GlobalUsings.cs | 2 + .../Repositories/ICampaignRepository.cs | 17 + .../IParticipationRecordRepository.cs | 19 + .../IRewardGrantRecordRepository.cs | 18 + .../Services/CampaignExecutionService.cs | 158 ++ .../Strategies/ActionResult.cs | 7 + .../Strategies/Actions/CouponAction.cs | 24 + .../Strategies/Actions/FixedRewardAction.cs | 24 + .../Strategies/Actions/LotteryAction.cs | 58 + .../Strategies/Actions/PointsAction.cs | 24 + .../Strategies/Actions/RandomRewardAction.cs | 28 + .../Strategies/CampaignContext.cs | 11 + .../Strategies/ConditionResult.cs | 7 + .../AccumulatedParticipationCondition.cs | 27 + .../Conditions/FirstTimeCondition.cs | 26 + .../Conditions/MemberLevelCondition.cs | 44 + .../Conditions/TimeRangeCondition.cs | 22 + .../Strategies/ConstraintResult.cs | 8 + .../Constraints/DailyLimitConstraint.cs | 27 + .../Constraints/FrequencyLimitConstraint.cs | 28 + .../Constraints/TotalLimitConstraint.cs | 26 + .../Strategies/IActionStrategy.cs | 9 + .../Strategies/IConditionStrategy.cs | 9 + .../Strategies/IConstraintStrategy.cs | 9 + .../Strategies/IStrategyFactory.cs | 8 + .../ValueObjects/ActionConfig.cs | 41 + .../ValueObjects/CampaignId.cs | 18 + .../ValueObjects/CampaignName.cs | 29 + .../ValueObjects/ConditionConfig.cs | 41 + .../ValueObjects/ConstraintConfig.cs | 41 + .../ValueObjects/TenantId.cs | 18 + .../ValueObjects/TimeRange.cs | 35 + .../ApplicationDbContext.cs | 30 + .../DesignTimeApplicationDbContextFactory.cs | 27 + .../CampaignEntityConfiguration.cs | 67 + .../ParticipationRecordEntityConfiguration.cs | 64 + .../RewardGrantRecordEntityConfiguration.cs | 62 + .../Fengling.Activity.Infrastructure.csproj | 31 + .../GlobalUsings.cs | 6 + .../20260122054728_Init.Designer.cs | 140 ++ .../Migrations/20260122054728_Init.cs | 101 ++ .../ApplicationDbContextModelSnapshot.cs | 137 ++ .../Repositories/CampaignRepository.cs | 69 + .../ParticipationRecordRepository.cs | 72 + .../RewardGrantRecordRepository.cs | 68 + .../Controllers/CampaignsController.cs | 148 ++ src/Fengling.Activity.Web/Dockerfile | 21 + .../Fengling.Activity.Web.csproj | 68 + src/Fengling.Activity.Web/GlobalUsings.cs | 10 + src/Fengling.Activity.Web/Program.cs | 105 ++ .../Properties/launchSettings.json | 25 + .../appsettings.Development.json | 31 + src/Fengling.Activity.Web/appsettings.json | 32 + vs-snippets/Install-VSSnippets.ps1 | 83 ++ vs-snippets/NetCorePalTemplates.snippet | 1271 +++++++++++++++++ 98 files changed, 8034 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 Directory.Build.props create mode 100644 Directory.Build.targets create mode 100644 Directory.Packages.props create mode 100644 Fengling.Activity.sln.DotSettings create mode 100644 NuGet.config create mode 100644 README.md create mode 100644 docker-compose.gateway.yml create mode 100644 docs/GATEWAY_INTEGRATION.md create mode 100644 docs/NAMING_CONVENTION.md create mode 100644 eng/versions.props create mode 100644 global.json create mode 100644 scripts/EXAMPLES.md create mode 100644 scripts/README.md create mode 100644 scripts/clean-infrastructure.ps1 create mode 100644 scripts/clean-infrastructure.sh create mode 100644 scripts/docker-compose.yml create mode 100644 scripts/gateway/Register-ActivityService.ps1 create mode 100644 scripts/gateway/Register-Service.ps1 create mode 100644 scripts/gateway/register-service.sh create mode 100644 scripts/init-infrastructure.ps1 create mode 100644 scripts/init-infrastructure.sh create mode 100644 scripts/mysql-init/01-init.sql create mode 100644 scripts/postgres-init/01-init.sql create mode 100644 src/Fengling.Activity.Domain/Aggregates/Campaigns/Campaign.cs create mode 100644 src/Fengling.Activity.Domain/Aggregates/ParticipationRecords/ParticipationRecord.cs create mode 100644 src/Fengling.Activity.Domain/Aggregates/ParticipationRecords/ParticipationRecordId.cs create mode 100644 src/Fengling.Activity.Domain/Aggregates/RewardGrantRecords/RewardGrantRecord.cs create mode 100644 src/Fengling.Activity.Domain/Aggregates/RewardGrantRecords/RewardGrantRecordId.cs create mode 100644 src/Fengling.Activity.Domain/Enums/CampaignStatus.cs create mode 100644 src/Fengling.Activity.Domain/Enums/CampaignType.cs create mode 100644 src/Fengling.Activity.Domain/Enums/ParticipationStatus.cs create mode 100644 src/Fengling.Activity.Domain/Enums/RewardType.cs create mode 100644 src/Fengling.Activity.Domain/Events/Campaigns/CampaignCancelledEvent.cs create mode 100644 src/Fengling.Activity.Domain/Events/Campaigns/CampaignCompletedEvent.cs create mode 100644 src/Fengling.Activity.Domain/Events/Campaigns/CampaignCreatedEvent.cs create mode 100644 src/Fengling.Activity.Domain/Events/Campaigns/CampaignPausedEvent.cs create mode 100644 src/Fengling.Activity.Domain/Events/Campaigns/CampaignPublishedEvent.cs create mode 100644 src/Fengling.Activity.Domain/Events/ParticipationRecords/TaskCompletedEvent.cs create mode 100644 src/Fengling.Activity.Domain/Events/ParticipationRecords/TaskParticipatedEvent.cs create mode 100644 src/Fengling.Activity.Domain/Fengling.Activity.Domain.csproj create mode 100644 src/Fengling.Activity.Domain/GlobalUsings.cs create mode 100644 src/Fengling.Activity.Domain/Repositories/ICampaignRepository.cs create mode 100644 src/Fengling.Activity.Domain/Repositories/IParticipationRecordRepository.cs create mode 100644 src/Fengling.Activity.Domain/Repositories/IRewardGrantRecordRepository.cs create mode 100644 src/Fengling.Activity.Domain/Services/CampaignExecutionService.cs create mode 100644 src/Fengling.Activity.Domain/Strategies/ActionResult.cs create mode 100644 src/Fengling.Activity.Domain/Strategies/Actions/CouponAction.cs create mode 100644 src/Fengling.Activity.Domain/Strategies/Actions/FixedRewardAction.cs create mode 100644 src/Fengling.Activity.Domain/Strategies/Actions/LotteryAction.cs create mode 100644 src/Fengling.Activity.Domain/Strategies/Actions/PointsAction.cs create mode 100644 src/Fengling.Activity.Domain/Strategies/Actions/RandomRewardAction.cs create mode 100644 src/Fengling.Activity.Domain/Strategies/CampaignContext.cs create mode 100644 src/Fengling.Activity.Domain/Strategies/ConditionResult.cs create mode 100644 src/Fengling.Activity.Domain/Strategies/Conditions/AccumulatedParticipationCondition.cs create mode 100644 src/Fengling.Activity.Domain/Strategies/Conditions/FirstTimeCondition.cs create mode 100644 src/Fengling.Activity.Domain/Strategies/Conditions/MemberLevelCondition.cs create mode 100644 src/Fengling.Activity.Domain/Strategies/Conditions/TimeRangeCondition.cs create mode 100644 src/Fengling.Activity.Domain/Strategies/ConstraintResult.cs create mode 100644 src/Fengling.Activity.Domain/Strategies/Constraints/DailyLimitConstraint.cs create mode 100644 src/Fengling.Activity.Domain/Strategies/Constraints/FrequencyLimitConstraint.cs create mode 100644 src/Fengling.Activity.Domain/Strategies/Constraints/TotalLimitConstraint.cs create mode 100644 src/Fengling.Activity.Domain/Strategies/IActionStrategy.cs create mode 100644 src/Fengling.Activity.Domain/Strategies/IConditionStrategy.cs create mode 100644 src/Fengling.Activity.Domain/Strategies/IConstraintStrategy.cs create mode 100644 src/Fengling.Activity.Domain/Strategies/IStrategyFactory.cs create mode 100644 src/Fengling.Activity.Domain/ValueObjects/ActionConfig.cs create mode 100644 src/Fengling.Activity.Domain/ValueObjects/CampaignId.cs create mode 100644 src/Fengling.Activity.Domain/ValueObjects/CampaignName.cs create mode 100644 src/Fengling.Activity.Domain/ValueObjects/ConditionConfig.cs create mode 100644 src/Fengling.Activity.Domain/ValueObjects/ConstraintConfig.cs create mode 100644 src/Fengling.Activity.Domain/ValueObjects/TenantId.cs create mode 100644 src/Fengling.Activity.Domain/ValueObjects/TimeRange.cs create mode 100644 src/Fengling.Activity.Infrastructure/ApplicationDbContext.cs create mode 100644 src/Fengling.Activity.Infrastructure/DesignTimeApplicationDbContextFactory.cs create mode 100644 src/Fengling.Activity.Infrastructure/EntityConfigurations/CampaignEntityConfiguration.cs create mode 100644 src/Fengling.Activity.Infrastructure/EntityConfigurations/ParticipationRecordEntityConfiguration.cs create mode 100644 src/Fengling.Activity.Infrastructure/EntityConfigurations/RewardGrantRecordEntityConfiguration.cs create mode 100644 src/Fengling.Activity.Infrastructure/Fengling.Activity.Infrastructure.csproj create mode 100644 src/Fengling.Activity.Infrastructure/GlobalUsings.cs create mode 100644 src/Fengling.Activity.Infrastructure/Migrations/20260122054728_Init.Designer.cs create mode 100644 src/Fengling.Activity.Infrastructure/Migrations/20260122054728_Init.cs create mode 100644 src/Fengling.Activity.Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs create mode 100644 src/Fengling.Activity.Infrastructure/Repositories/CampaignRepository.cs create mode 100644 src/Fengling.Activity.Infrastructure/Repositories/ParticipationRecordRepository.cs create mode 100644 src/Fengling.Activity.Infrastructure/Repositories/RewardGrantRecordRepository.cs create mode 100644 src/Fengling.Activity.Web/Controllers/CampaignsController.cs create mode 100644 src/Fengling.Activity.Web/Dockerfile create mode 100644 src/Fengling.Activity.Web/Fengling.Activity.Web.csproj create mode 100644 src/Fengling.Activity.Web/GlobalUsings.cs create mode 100644 src/Fengling.Activity.Web/Program.cs create mode 100644 src/Fengling.Activity.Web/Properties/launchSettings.json create mode 100644 src/Fengling.Activity.Web/appsettings.Development.json create mode 100644 src/Fengling.Activity.Web/appsettings.json create mode 100644 vs-snippets/Install-VSSnippets.ps1 create mode 100644 vs-snippets/NetCorePalTemplates.snippet 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$ + } +}]]> + + +
+ +