From 05631b658982d1800d3b2d7ea6b0d53573fd363d Mon Sep 17 00:00:00 2001 From: Sam <315859133@qq.com> Date: Thu, 5 Feb 2026 14:21:36 +0800 Subject: [PATCH] feat(console): complete migration of User, Tenant, and Role management APIs --- .dockerignore | 25 + .gitattributes | 63 + .github/copilot-instructions.md | 183 +++ .../instructions/aggregate.instructions.md | 91 ++ .github/instructions/command.instructions.md | 89 ++ .../instructions/dbcontext.instructions.md | 40 + .../domain-event-handler.instructions.md | 54 + .../instructions/domain-event.instructions.md | 43 + .github/instructions/endpoint.instructions.md | 121 ++ .../entity-configuration.instructions.md | 70 + ...ntegration-event-converter.instructions.md | 49 + .../integration-event-handler.instructions.md | 60 + .../integration-event.instructions.md | 44 + .github/instructions/query.instructions.md | 206 +++ .../instructions/repository.instructions.md | 91 ++ .../instructions/unit-testing.instructions.md | 205 +++ .gitignore | 398 ++++++ Directory.Build.props | 34 + Directory.Build.targets | 3 + Directory.Packages.props | 157 ++ Fengling.Member.sln.DotSettings | 918 ++++++++++++ README.md | 230 +++ eng/versions.props | 6 + scripts/EXAMPLES.md | 151 ++ scripts/README.md | 56 + scripts/clean-infrastructure.ps1 | 195 +++ scripts/clean-infrastructure.sh | 177 +++ scripts/docker-compose.yml | 167 +++ scripts/init-infrastructure.ps1 | 258 ++++ scripts/init-infrastructure.sh | 200 +++ scripts/mysql-init/01-init.sql | 18 + scripts/postgres-init/01-init.sql | 24 + .../Commands/Member/AddMemberTagCommand.cs | 68 + .../Commands/Member/BindAlipayCommand.cs | 68 + .../Commands/Member/BindOAuthCommand.cs | 74 + .../Commands/Member/BindWechatCommand.cs | 68 + .../Commands/Member/RegisterMemberCommand.cs | 109 ++ .../Commands/Points/AddPointsCommand.cs | 76 + .../Commands/Points/DeductPointsCommand.cs | 101 ++ .../Fengling.Member.Application.csproj | 26 + .../GlobalUsings.cs | 4 + .../Aggregates/PointsModel/PointsAccount.cs | 135 ++ .../PointsModel/PointsTransaction.cs | 45 + .../Aggregates/Users/Member.cs | 145 ++ .../Aggregates/Users/MemberStatus.cs | 37 + .../Aggregates/Users/MemberTag.cs | 28 + .../Aggregates/Users/OAuthAuthorization.cs | 50 + .../Aggregates/Users/OAuthProvider.cs | 26 + .../Aggregates/Users/WechatAuthorization.cs | 38 + .../Events/Member/MemberRegisteredEvent.cs | 8 + .../Events/Points/PointsChangedEvent.cs | 9 + .../Fengling.Member.Domain.csproj | 21 + src/Fengling.Member.Domain/GlobalUsings.cs | 2 + .../IOAuthAuthorizationRepository.cs | 11 + .../ApplicationDbContext.cs | 32 + .../DesignTimeApplicationDbContextFactory.cs | 27 + .../MemberEntityTypeConfiguration.cs | 80 ++ .../MemberTagEntityTypeConfiguration.cs | 43 + .../PointsAccountEntityTypeConfiguration.cs | 57 + ...hatAuthorizationEntityTypeConfiguration.cs | 49 + .../Fengling.Member.Infrastructure.csproj | 31 + .../GlobalUsings.cs | 7 + .../20260122054728_Init.Designer.cs | 140 ++ .../Migrations/20260122054728_Init.cs | 101 ++ ...658_AddMemberAndPointsEntities.Designer.cs | 429 ++++++ ...260205051658_AddMemberAndPointsEntities.cs | 208 +++ .../ApplicationDbContextModelSnapshot.cs | 426 ++++++ .../Repositories/MemberRepository.cs | 52 + .../Repositories/PointsAccountRepository.cs | 51 + .../Application/Hubs/ChatHub.cs | 14 + .../Clients/IUserServiceClient.cs | 11 + src/Fengling.Member.Web/Dockerfile | 21 + .../Endpoints/HelloEndpoint.cs | 25 + .../Endpoints/v1/AlipayBindingEndpoints.cs | 62 + .../Endpoints/v1/MemberEndpoints.cs | 69 + .../Endpoints/v1/OAuthBindingEndpoints.cs | 67 + .../Endpoints/v1/PointsEndpoints.cs | 68 + .../Endpoints/v1/WechatBindingEndpoints.cs | 62 + ...ngeRedisDataProtectionBuilderExtensions.cs | 38 + .../SwaggerGenOptionsExtionsions.cs | 25 + .../Fengling.Member.Web.csproj | 69 + src/Fengling.Member.Web/GlobalUsings.cs | 10 + src/Fengling.Member.Web/Program.cs | 245 ++++ .../Properties/launchSettings.json | 25 + .../Utils/AppConfiguration.cs | 18 + .../appsettings.Development.json | 31 + src/Fengling.Member.Web/appsettings.json | 32 + .../Fengling.Member.Domain.Tests.csproj | 29 + .../GlobalUsings.cs | 2 + ...engling.Member.Infrastructure.Tests.csproj | 29 + .../GlobalUsings.cs | 1 + .../Fengling.Member.Web.Tests/AssemblyInfo.cs | 8 + .../Fengling.Member.Web.Tests.csproj | 41 + .../Fixtures/WebAppFixture.cs | 54 + .../Fixtures/WebAppTestCollection.cs | 7 + .../Fengling.Member.Web.Tests/GlobalUsings.cs | 9 + .../xunit.runner.json | 5 + vs-snippets/Install-VSSnippets.ps1 | 83 ++ vs-snippets/NetCorePalTemplates.snippet | 1271 +++++++++++++++++ 99 files changed, 9739 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitattributes create mode 100644 .github/copilot-instructions.md create mode 100644 .github/instructions/aggregate.instructions.md create mode 100644 .github/instructions/command.instructions.md create mode 100644 .github/instructions/dbcontext.instructions.md create mode 100644 .github/instructions/domain-event-handler.instructions.md create mode 100644 .github/instructions/domain-event.instructions.md create mode 100644 .github/instructions/endpoint.instructions.md create mode 100644 .github/instructions/entity-configuration.instructions.md create mode 100644 .github/instructions/integration-event-converter.instructions.md create mode 100644 .github/instructions/integration-event-handler.instructions.md create mode 100644 .github/instructions/integration-event.instructions.md create mode 100644 .github/instructions/query.instructions.md create mode 100644 .github/instructions/repository.instructions.md create mode 100644 .github/instructions/unit-testing.instructions.md 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.Member.sln.DotSettings create mode 100644 README.md create mode 100644 eng/versions.props 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/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.Member.Application/Commands/Member/AddMemberTagCommand.cs create mode 100644 src/Fengling.Member.Application/Commands/Member/BindAlipayCommand.cs create mode 100644 src/Fengling.Member.Application/Commands/Member/BindOAuthCommand.cs create mode 100644 src/Fengling.Member.Application/Commands/Member/BindWechatCommand.cs create mode 100644 src/Fengling.Member.Application/Commands/Member/RegisterMemberCommand.cs create mode 100644 src/Fengling.Member.Application/Commands/Points/AddPointsCommand.cs create mode 100644 src/Fengling.Member.Application/Commands/Points/DeductPointsCommand.cs create mode 100644 src/Fengling.Member.Application/Fengling.Member.Application.csproj create mode 100644 src/Fengling.Member.Application/GlobalUsings.cs create mode 100644 src/Fengling.Member.Domain/Aggregates/PointsModel/PointsAccount.cs create mode 100644 src/Fengling.Member.Domain/Aggregates/PointsModel/PointsTransaction.cs create mode 100644 src/Fengling.Member.Domain/Aggregates/Users/Member.cs create mode 100644 src/Fengling.Member.Domain/Aggregates/Users/MemberStatus.cs create mode 100644 src/Fengling.Member.Domain/Aggregates/Users/MemberTag.cs create mode 100644 src/Fengling.Member.Domain/Aggregates/Users/OAuthAuthorization.cs create mode 100644 src/Fengling.Member.Domain/Aggregates/Users/OAuthProvider.cs create mode 100644 src/Fengling.Member.Domain/Aggregates/Users/WechatAuthorization.cs create mode 100644 src/Fengling.Member.Domain/Events/Member/MemberRegisteredEvent.cs create mode 100644 src/Fengling.Member.Domain/Events/Points/PointsChangedEvent.cs create mode 100644 src/Fengling.Member.Domain/Fengling.Member.Domain.csproj create mode 100644 src/Fengling.Member.Domain/GlobalUsings.cs create mode 100644 src/Fengling.Member.Domain/Repositories/IOAuthAuthorizationRepository.cs create mode 100644 src/Fengling.Member.Infrastructure/ApplicationDbContext.cs create mode 100644 src/Fengling.Member.Infrastructure/DesignTimeApplicationDbContextFactory.cs create mode 100644 src/Fengling.Member.Infrastructure/EntityConfigurations/MemberEntityTypeConfiguration.cs create mode 100644 src/Fengling.Member.Infrastructure/EntityConfigurations/MemberTagEntityTypeConfiguration.cs create mode 100644 src/Fengling.Member.Infrastructure/EntityConfigurations/PointsAccountEntityTypeConfiguration.cs create mode 100644 src/Fengling.Member.Infrastructure/EntityConfigurations/WechatAuthorizationEntityTypeConfiguration.cs create mode 100644 src/Fengling.Member.Infrastructure/Fengling.Member.Infrastructure.csproj create mode 100644 src/Fengling.Member.Infrastructure/GlobalUsings.cs create mode 100644 src/Fengling.Member.Infrastructure/Migrations/20260122054728_Init.Designer.cs create mode 100644 src/Fengling.Member.Infrastructure/Migrations/20260122054728_Init.cs create mode 100644 src/Fengling.Member.Infrastructure/Migrations/20260205051658_AddMemberAndPointsEntities.Designer.cs create mode 100644 src/Fengling.Member.Infrastructure/Migrations/20260205051658_AddMemberAndPointsEntities.cs create mode 100644 src/Fengling.Member.Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs create mode 100644 src/Fengling.Member.Infrastructure/Repositories/MemberRepository.cs create mode 100644 src/Fengling.Member.Infrastructure/Repositories/PointsAccountRepository.cs create mode 100644 src/Fengling.Member.Web/Application/Hubs/ChatHub.cs create mode 100644 src/Fengling.Member.Web/Clients/IUserServiceClient.cs create mode 100644 src/Fengling.Member.Web/Dockerfile create mode 100644 src/Fengling.Member.Web/Endpoints/HelloEndpoint.cs create mode 100644 src/Fengling.Member.Web/Endpoints/v1/AlipayBindingEndpoints.cs create mode 100644 src/Fengling.Member.Web/Endpoints/v1/MemberEndpoints.cs create mode 100644 src/Fengling.Member.Web/Endpoints/v1/OAuthBindingEndpoints.cs create mode 100644 src/Fengling.Member.Web/Endpoints/v1/PointsEndpoints.cs create mode 100644 src/Fengling.Member.Web/Endpoints/v1/WechatBindingEndpoints.cs create mode 100644 src/Fengling.Member.Web/Extensions/StackExchangeRedisDataProtectionBuilderExtensions.cs create mode 100644 src/Fengling.Member.Web/Extensions/SwaggerGenOptionsExtionsions.cs create mode 100644 src/Fengling.Member.Web/Fengling.Member.Web.csproj create mode 100644 src/Fengling.Member.Web/GlobalUsings.cs create mode 100644 src/Fengling.Member.Web/Program.cs create mode 100644 src/Fengling.Member.Web/Properties/launchSettings.json create mode 100644 src/Fengling.Member.Web/Utils/AppConfiguration.cs create mode 100644 src/Fengling.Member.Web/appsettings.Development.json create mode 100644 src/Fengling.Member.Web/appsettings.json create mode 100644 test/Fengling.Member.Domain.Tests/Fengling.Member.Domain.Tests.csproj create mode 100644 test/Fengling.Member.Domain.Tests/GlobalUsings.cs create mode 100644 test/Fengling.Member.Infrastructure.Tests/Fengling.Member.Infrastructure.Tests.csproj create mode 100644 test/Fengling.Member.Infrastructure.Tests/GlobalUsings.cs create mode 100644 test/Fengling.Member.Web.Tests/AssemblyInfo.cs create mode 100644 test/Fengling.Member.Web.Tests/Fengling.Member.Web.Tests.csproj create mode 100644 test/Fengling.Member.Web.Tests/Fixtures/WebAppFixture.cs create mode 100644 test/Fengling.Member.Web.Tests/Fixtures/WebAppTestCollection.cs create mode 100644 test/Fengling.Member.Web.Tests/GlobalUsings.cs create mode 100644 test/Fengling.Member.Web.Tests/xunit.runner.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/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..c9639d7 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,183 @@ +你的任务是按照 *.instructions.md 描述的规范完成功能开发任务。 + +## 重要规则 + +- 优先遵循instructions文档描述的规范 + +## 最佳实践 + +- 优先使用主构造函数 +- 使用各类组件时,优先使用await关键字,使用Async方法 + +## 根据需要按照下列顺序完成工作 + +- 定义聚合、实体 +- 定义领域事件 +- 创建仓储接口与仓储实现 +- 配置实体映射 +- 定义命令与命令处理器 +- 定义查询与查询处理器 +- 定义Endpoints +- 定义领域事件处理器 +- 定义集成事件 +- 定义集成事件转换器 +- 定义集成事件处理器 + +## 项目结构 + +``` +Fengling.Member.sln +├── src/ +│ ├── Fengling.Member.Domain/ # 领域层 - 聚合根、实体、领域事件 +│ ├── Fengling.Member.Infrastructure/ # 基础设施层 - EF配置、仓储接口、仓储实现 +│ └── Fengling.Member.Web/ # 表现层 - API、应用服务 +└── test/ # 测试项目 + ├── Fengling.Member.Domain.UnitTests/ # 领域层测试项目 + ├── Fengling.Member.Infrastructure.UnitTests/ # 基础设施层测试项目 + └── Fengling.Member.Web.UnitTests/ # 表现层测试项目 +``` + +**分层依赖关系:** Web → Infrastructure → Domain (严格单向依赖) + + + +## 对于具体的开发工作,请参考以下详细指令文件: + +### 聚合与领域层 +- **聚合根开发**: 参考 `.github/instructions/aggregate.instructions.md` +- **领域事件定义**: 参考 `.github/instructions/domain-event.instructions.md` + +### 数据访问层 +- **仓储实现**: 参考 `.github/instructions/repository.instructions.md` +- **实体配置**: 参考 `.github/instructions/entity-configuration.instructions.md` +- **数据库上下文**: 参考 `.github/instructions/dbcontext.instructions.md` + +### 应用服务层 +- **命令处理**: 参考 `.github/instructions/command.instructions.md` +- **查询处理**: 参考 `.github/instructions/query.instructions.md` +- **领域事件处理**: 参考 `.github/instructions/domain-event-handler.instructions.md` + +### API表现层 +- **API端点**: 参考 `.github/instructions/endpoint.instructions.md` + +### 集成事件处理 +- **集成事件**: 参考 `.github/instructions/integration-event.instructions.md` +- **集成事件转换器**: 参考 `.github/instructions/integration-event-converter.instructions.md` +- **集成事件处理器**: 参考 `.github/instructions/integration-event-handler.instructions.md` + +### 测试 +- **单元测试**: 参考 `.github/instructions/unit-testing.instructions.md` + +### 最佳实践 +(遵循各模块对应的 *.instructions.md 文档;本节不再另行维护“通用最佳实践”文件以避免重复和漂移。) + +## 核心开发原则 + +### 文件组织 +- **聚合根** → `src/Fengling.Member.Domain/AggregatesModel/{AggregateFolder}/` +- **领域事件** → `src/Fengling.Member.Domain/DomainEvents/` +- **仓储** → `src/Fengling.Member.Infrastructure/Repositories/` +- **实体配置** → `src/Fengling.Member.Infrastructure/EntityConfigurations/` +- **命令与命令处理器** → `src/Fengling.Member.Web/Application/Commands/` +- **查询与查询处理器** → `src/Fengling.Member.Web/Application/Queries/` +- **API端点** → `src/Fengling.Member.Web/Endpoints/` +- **领域事件处理器** → `src/Fengling.Member.Web/Application/DomainEventHandlers/` +- **集成事件** → `src/Fengling.Member.Web/Application/IntegrationEvents/` +- **集成事件转换器** → `src/Fengling.Member.Web/Application/IntegrationEventConverters/` +- **集成事件处理器** → `src/Fengling.Member.Web/Application/IntegrationEventHandlers/` + +### 强制性要求 +- ✅ 所有聚合根使用强类型ID,且**不手动赋值ID**(依赖EF值生成器) +- ✅ 所有命令都要有对应的验证器 +- ✅ 领域事件在聚合发生改变时发布,仅聚合和实体可以发出领域事件 +- ✅ 遵循分层架构依赖关系 (Web → Infrastructure → Domain) +- ✅ 使用KnownException处理已知业务异常 +- ✅ 命令处理器**不调用SaveChanges**(框架自动处理) +- ✅ 仓储必须使用**异步方法**(GetAsync、AddAsync等) + +### 关键技术要求 +- **验证器**: 必须继承 `AbstractValidator` 而不是 `Validator` +- **领域事件处理器**: 实现 `Handle()` 方法而不是 `HandleAsync()` +- **FastEndpoints**: 使用构造函数注入 `IMediator`,使用 `Send.OkAsync()` 和 `.AsResponseData()`;端点采用属性特性配置(如 `[HttpPost]`、`[AllowAnonymous]`、`[Tags]`),不使用 `Configure()` +- **强类型ID**: 直接使用类型,避免 `.Value` 属性,依赖隐式转换 +- **仓储**: 通过构造函数参数访问 `ApplicationDbContext`,所有操作必须异步 +- **ID生成**: 使用EF Core值生成器,聚合根构造函数不设置ID + +## 异常处理原则 + +### KnownException使用规范 +在需要抛出业务异常的地方,必须使用 `KnownException` 而不是普通的 `Exception`: + +**正确示例:** +```csharp +// 在聚合根中 +public void OrderPaid() +{ + if (Paid) + { + throw new KnownException("Order has been paid"); + } + // 业务逻辑... +} + +// 在命令处理器中 +public async Task Handle(OrderPaidCommand request, CancellationToken cancellationToken) +{ + var order = await orderRepository.GetAsync(request.OrderId, cancellationToken) ?? + throw new KnownException($"未找到订单,OrderId = {request.OrderId}"); + order.OrderPaid(); + return order.Id; +} +``` + +**框架集成:** +- `KnownException` 会被框架自动转换为合适的HTTP状态码 +- 异常消息会直接返回给客户端 +- 支持本地化和错误码定制 + +## 常见using引用指南 + +### GlobalUsings.cs配置 +各层的常用引用已在GlobalUsings.cs中全局定义: + +**Web层** (`src/Fengling.Member.Web/GlobalUsings.cs`): +- `global using FluentValidation;` - 验证器 +- `global using MediatR;` - 命令处理器 +- `global using NetCorePal.Extensions.Primitives;` - KnownException等 +- `global using FastEndpoints;` - API端点 +- `global using NetCorePal.Extensions.Dto;` - ResponseData +- `global using NetCorePal.Extensions.Domain;` - 领域事件处理器 + +**Infrastructure层** (`src/Fengling.Member.Infrastructure/GlobalUsings.cs`): +- `global using Microsoft.EntityFrameworkCore;` - EF Core +- `global using Microsoft.EntityFrameworkCore.Metadata.Builders;` - 实体配置 +- `global using NetCorePal.Extensions.Primitives;` - 基础类型 + +**Domain层** (`src/Fengling.Member.Domain/GlobalUsings.cs`): +- `global using NetCorePal.Extensions.Domain;` - 领域基础类型 +- `global using NetCorePal.Extensions.Primitives;` - 强类型ID等 + +**Tests层** (`test/*/GlobalUsings.cs`): +- `global using Xunit;` - 测试框架 +- `global using NetCorePal.Extensions.Primitives;` - 测试中的异常处理 + +### 常见手动using引用 +当GlobalUsings未覆盖时,需要手动添加: + +**查询处理器**: +```csharp +using Fengling.Member.Domain.AggregatesModel.{AggregateFolder}; +using Fengling.Member.Infrastructure; +``` + +**实体配置**: +```csharp +using Fengling.Member.Domain.AggregatesModel.{AggregateFolder}; +``` + +**端点**: +```csharp +using Fengling.Member.Domain.AggregatesModel.{AggregateFolder}; +using Fengling.Member.Web.Application.Commands.{FeatureFolder}; +using Fengling.Member.Web.Application.Queries.{FeatureFolder}; +``` diff --git a/.github/instructions/aggregate.instructions.md b/.github/instructions/aggregate.instructions.md new file mode 100644 index 0000000..2b79cf9 --- /dev/null +++ b/.github/instructions/aggregate.instructions.md @@ -0,0 +1,91 @@ +--- +applyTo: "src/Fengling.Member.Domain/AggregatesModel/**/*.cs" +--- + +# 聚合与强类型ID开发指南 + +## 开发原则 + +### 必须 + +- **聚合根定义**: + - 聚合内必须有一个且只有一个聚合根。 + - 必须继承 `Entity` 并实现 `IAggregateRoot` 接口。 + - 必须有 `protected` 无参构造器供 EF Core 使用。 + - 所有属性使用 `private set`,并显式设置默认值。 + - 状态改变时发布领域事件,使用 `this.AddDomainEvent()`。 + - `Deleted` 属性表示软删除状态。 + - `RowVersion` 属性用于乐观并发控制。 +- **强类型ID定义**: + - 必须使用 `IInt64StronglyTypedId` 或 `IGuidStronglyTypedId`,优先使用 `IGuidStronglyTypedId`。 + - 必须使用 `partial record` 声明,让框架生成具体实现。 + - 必须是 `public` 类型。 + - 必须与聚合/实体在同一个文件中定义。 + - 命名格式必须为 `{EntityName}Id`。 +- **子实体定义**: + - 必须是 `public` 类。 + - 必须有一个无参构造器。 + - 必须有一个强类型ID,推荐使用 `IGuidStronglyTypedId`。 + - 必须继承自 `Entity`,并实现 `IEntity` 接口。 + - 聚合内允许多个子实体。 + +### 必须不要 + +- **依赖关系**: 聚合之间不相互依赖,避免直接引用其他聚合根,聚合之间也不共享子实体,使用领域事件或领域服务进行交互。 +- **命名**:聚合根类名不需要带后缀 `Aggregate`。 +- **ID设置**:无需手动设置ID的值,由 EF Core 值生成器自动生成。 + +## 文件命名规则 + +- 类文件应放置在 `src/Fengling.Member.Domain/AggregatesModel/{AggregateName}Aggregate/` 目录下。 +- 例如 `src/Fengling.Member.Domain/AggregatesModel/UserAggregate/User.cs`。 +- 每个聚合在独立文件夹中。 +- 聚合根类名与文件名一致。 +- 强类型ID与聚合根定义在同一文件中。 + +## 代码示例 + +文件: `src/Fengling.Member.Domain/AggregatesModel/UserAggregate/User.cs` + +```csharp +using Fengling.Member.Domain.DomainEvents; // 必需:引用领域事件 + +namespace Fengling.Member.Domain.AggregatesModel.UserAggregate; + +// 强类型ID定义 - 与聚合根在同一文件中 +public partial record UserId : IGuidStronglyTypedId; + +// 聚合根定义 +public class User : Entity, IAggregateRoot +{ + protected User() { } + + public User(string name, string email) + { + // 不手动设置ID,由EF Core值生成器自动生成 + Name = name; + Email = email; + this.AddDomainEvent(new UserCreatedDomainEvent(this)); + } + + #region Properties + + public string Name { get; private set; } = string.Empty; + public string Email { get; private set; } = string.Empty; + public Deleted Deleted { get; private set; } = new(); // 默认false + public RowVersion RowVersion { get; private set; } = new RowVersion(0); + + #endregion + + #region Methods + + // ...existing code... + public void ChangeEmail(string email) + { + Email = email; + this.AddDomainEvent(new UserEmailChangedDomainEvent(this)); + } + + #endregion +} +``` \ No newline at end of file diff --git a/.github/instructions/command.instructions.md b/.github/instructions/command.instructions.md new file mode 100644 index 0000000..6372850 --- /dev/null +++ b/.github/instructions/command.instructions.md @@ -0,0 +1,89 @@ +--- +applyTo: "src/Fengling.Member.Web/Application/Commands/**/*.cs" +--- + +# 命令与命令处理器开发指南 + +## 开发原则 + +### 必须 + +- **命令定义**: + - 使用 `record` 类型定义命令。 + - 无返回值命令实现 `ICommand` 接口。 + - 有返回值命令实现 `ICommand` 接口。 + - 必须为每个命令创建验证器,继承 `AbstractValidator`。 + - 命令处理器实现对应的 `ICommandHandler` 接口。 + - 命令处理器中必须通过仓储存取聚合数据。 + - 优先使用异步方法,所有仓储操作都应使用异步版本。 + - 将 `CancellationToken` 传递给所有异步操作。 + - 使用主构造函数注入所需的仓储或服务。 +- **异常处理**: + - 使用 `KnownException` 处理业务异常。 + - 业务验证失败时抛出明确的错误信息。 + +### 必须不要 + +- **事务管理**: + - 不要手动调用 `SaveChanges`,框架会自动在命令处理完成后调用。 + - 不要手动调用 `UpdateAsync`,如果实体是从仓储取出,则会自动跟踪变更。 +- **仓储使用**: + - 避免在命令处理器中进行复杂的查询操作(应使用查询端)。 + - 仓储方法名不应是通用数据访问,而应体现业务意图。 +- **重复引用**:无需重复添加 `GlobalUsings` 中已定义的 `using` 语句。 + +## 文件命名规则 + +- 类文件应放置在 `src/Fengling.Member.Web/Application/Commands/{Module}s/` 目录下(以模块复数形式命名,避免命名空间与聚合类名冲突)。 +- 命令文件名格式为 `{Action}{Entity}Command.cs`。 +- 同一个命令及其对应的验证器和处理器定义在同一文件中。 +- 不同的命令放在不同文件中。 + +## 代码示例 + +**文件**: `src/Fengling.Member.Web/Application/Commands/Users/CreateUserCommand.cs` + +```csharp +using Fengling.Member.Domain.AggregatesModel.UserAggregate; +using Fengling.Member.Infrastructure.Repositories; + +namespace Fengling.Member.Web.Application.Commands.Users; + +public record CreateUserCommand(string Name, string Email) : ICommand; + +public class CreateUserCommandValidator : AbstractValidator +{ + public CreateUserCommandValidator() + { + RuleFor(x => x.Name) + .NotEmpty() + .WithMessage("用户名不能为空") + .MaximumLength(50) + .WithMessage("用户名不能超过50个字符"); + + RuleFor(x => x.Email) + .NotEmpty() + .WithMessage("邮箱不能为空") + .EmailAddress() + .WithMessage("邮箱格式不正确") + .MaximumLength(100) + .WithMessage("邮箱不能超过100个字符"); + } +} + +public class CreateUserCommandHandler(IUserRepository userRepository) + : ICommandHandler +{ + public async Task Handle(CreateUserCommand command, CancellationToken cancellationToken) + { + if (await userRepository.EmailExistsAsync(command.Email, cancellationToken)) + { + throw new KnownException("邮箱已存在"); + } + + var user = new User(command.Name, command.Email); + await userRepository.AddAsync(user, cancellationToken); + return user.Id; + } +} +``` diff --git a/.github/instructions/dbcontext.instructions.md b/.github/instructions/dbcontext.instructions.md new file mode 100644 index 0000000..e9c3130 --- /dev/null +++ b/.github/instructions/dbcontext.instructions.md @@ -0,0 +1,40 @@ +--- +applyTo: "src/Fengling.Member.Infrastructure/ApplicationDbContext.cs" +--- + +# DbContext 添加聚合指南 + +## 开发原则 + +### 必须 + +- **命名空间**:在头部添加聚合根的命名空间。 +- **DbSet 定义**: + - 添加新聚合时在 DbSet 区域添加对应属性。 + - 使用 `=> Set()` 模式定义 DbSet。 +- **配置注册**:默认使用 `ApplyConfigurationsFromAssembly` 自动注册实体配置。 + +### 必须不要 + +- **额外配置**:无需手动注册实体配置,框架会自动扫描。 + +## 文件命名规则 + +- 文件在 `src/Fengling.Member.Infrastructure/ApplicationDbContext.cs`。 + +## 代码示例 + +**文件**: `src/Fengling.Member.Infrastructure/ApplicationDbContext.cs` + +```csharp +public partial class ApplicationDbContext(DbContextOptions options, IMediator mediator) + : AppDbContextBase(options, mediator) +{ + // 现有的 DbSet + public DbSet Orders => Set(); + public DbSet DeliverRecords => Set(); + + // 添加新聚合的 DbSet + public DbSet Customers => Set(); +} +``` \ No newline at end of file diff --git a/.github/instructions/domain-event-handler.instructions.md b/.github/instructions/domain-event-handler.instructions.md new file mode 100644 index 0000000..26b73ff --- /dev/null +++ b/.github/instructions/domain-event-handler.instructions.md @@ -0,0 +1,54 @@ +--- +applyTo: "src/Fengling.Member.Web/Application/DomainEventHandlers/*.cs" +--- + +# 领域事件处理器开发指南 + +## 开发原则 + +### 必须 + +- **处理器定义**: + - 必须实现 `IDomainEventHandler` 接口。 + - 实现方法:`public Task Handle(TEvent domainEvent, CancellationToken cancellationToken)`。 + - 每个文件仅包含一个事件处理器。 + - 使用主构造函数注入所需的仓储或服务。 + - **命名规范**:按 `{DomainEvent}DomainEventHandlerFor{Action}` 命名,语义清晰、单一目的。 +- **业务逻辑**: + - 在框架会将处理器中的命令作为事务的一部分执行。 + - 通过发送 Command(`IMediator.Send`)驱动聚合变化,而不是直接操作聚合或数据库。 + - 仅访问与该领域事件直接相关的数据或服务。 + - 尊重事务与取消:使用 `async/await`,传递并尊重 `CancellationToken`。 + +### 必须不要 + +- **直接操作**: + - 不直接通过仓储或 DbContext 修改数据(始终通过命令)。 +- **逻辑混合**: + - 不在一个文件中放多个处理器或混合不同事件的逻辑。 +- **性能与异常**: + - 不执行长时间阻塞操作,耗时操作应放置在集成事件处理器中。 + - 不吞掉异常或忽略 `CancellationToken`。 + +## 文件命名规则 + +- 类文件应放置在 `src/Fengling.Member.Web/Application/DomainEventHandlers/` 目录下。 +- 文件名格式为 `{DomainEvent}DomainEventHandlerFor{Action}.cs`,其中 `{Action}` 准确描述该 Handler 的目的。 +- 示例:`OrderCreatedDomainEventHandlerForSetPaymentInfo.cs`。 +- 类命名:`{DomainEvent}DomainEventHandlerFor{Action}`。 + +## 代码示例 +```csharp +using Fengling.Member.Web.Application.Commands; +using Fengling.Member.Web.Domain.DomainEvents; +public class OrderCreatedDomainEventHandlerForSetPaymentInfo(IMediator mediator) : + IDomainEventHandler +{ + public async Task Handle(OrderCreatedDomainEvent domainEvent, CancellationToken cancellationToken) + { + // 通过发送命令操作聚合,而不是直接操作服务 + var command = new SetPaymentInfoCommand(domainEvent.OrderId, domainEvent.PaymentInfo); + await mediator.Send(command, cancellationToken); + } +} +``` \ No newline at end of file diff --git a/.github/instructions/domain-event.instructions.md b/.github/instructions/domain-event.instructions.md new file mode 100644 index 0000000..d6e508d --- /dev/null +++ b/.github/instructions/domain-event.instructions.md @@ -0,0 +1,43 @@ +--- +applyTo: "src/Fengling.Member.Domain/DomainEvents/*.cs" +--- + +# 领域事件开发指南 + +## 开发原则 + +### 必须 + +- **事件定义**: + - 必须使用 `record` 类型。 + - 必须标记接口 `IDomainEvent`,无需额外实现。 + - 无额外信息传递需求时,将聚合作为构造函数参数。 +- **命名规范**: + - 使用过去式动词描述已发生的事情。 + - 格式:`{Entity}{Action}DomainEvent`。 + - 例如:`UserCreatedDomainEvent`、`OrderPaidDomainEvent`、`ProductUpdatedDomainEvent`。 + +### 必须不要 + +- **复杂逻辑**:领域事件本身不应包含业务逻辑,仅作为数据载体。 + +## 文件命名规则 + +- 类文件应放置在 `src/Fengling.Member.Domain/DomainEvents` 目录下。 +- 为每个聚合添加一个领域事件文件。 +- 文件名格式为 `{Aggregate}DomainEvents.cs`。 +- 一个领域事件文件中可以包含多个领域事件。 + +## 代码示例 + +**文件**: `src/Fengling.Member.Domain/DomainEvents/UserDomainEvents.cs` + +```csharp +using Fengling.Member.Domain.Aggregates.UserAggregate; + +namespace Fengling.Member.Domain.DomainEvents; + +public record UserCreatedDomainEvent(User User) : IDomainEvent; + +public record UserEmailChangedDomainEvent(User User) : IDomainEvent; +``` \ No newline at end of file diff --git a/.github/instructions/endpoint.instructions.md b/.github/instructions/endpoint.instructions.md new file mode 100644 index 0000000..004d7cd --- /dev/null +++ b/.github/instructions/endpoint.instructions.md @@ -0,0 +1,121 @@ +--- +applyTo: "src/Fengling.Member.Web/Endpoints/**/*.cs" +--- + +# Endpoint 开发指南 + +## 开发原则 + +### 必须 + +- **端点定义**: + - 继承对应的 `Endpoint` 基类。 + - 必须为每个 Endpoint 单独定义请求 DTO 和响应 DTO。 + - 请求 DTO、响应 DTO 与端点定义在同一文件中。 + - 不同的 Endpoint 放在不同文件中。 + - 使用 `ResponseData` 包装响应数据。 + - 使用主构造函数注入依赖的服务,如 `IMediator`。 +- **配置与实现**: + - 使用特性方式配置路由和权限:`[HttpPost("/api/...")]`、`[AllowAnonymous]` 等。 + - 在 `HandleAsync()` 方法中处理业务逻辑。 + - 使用构造函数注入 `IMediator` 发送命令或查询。 + - 使用 `Send.OkAsync()`、`Send.CreatedAsync()`、`Send.NoContentAsync()` 发送响应。 + - 使用 `.AsResponseData()` 扩展方法创建响应数据。 +- **强类型ID处理**: + - 在 DTO 中直接使用强类型 ID 类型,如 `UserId`、`OrderId`。 + - 依赖框架的隐式转换处理类型转换。 +- **引用**: + - 引用 `FastEndpoints` 和 `Microsoft.AspNetCore.Authorization`。 + +### 必须不要 + +- **配置方式**:使用属性特性而不是 `Configure()` 方法来配置端点。 +- **强类型ID**:避免使用 `.Value` 属性访问内部值。 + +## 文件命名规则 + +- 类文件应放置在 `src/Fengling.Member.Web/Endpoints/{Module}/` 目录下。 +- 端点文件名格式为 `{Action}{Entity}Endpoint.cs`。 +- 请求 DTO、响应 DTO 与端点定义在同一文件中。 + +## 代码示例 + +**文件**: `src/Fengling.Member.Web/Endpoints/User/CreateUserEndpoint.cs` + +```csharp +using FastEndpoints; +using Fengling.Member.Web.Application.Commands; +using Fengling.Member.Domain.AggregatesModel.UserAggregate; +using Microsoft.AspNetCore.Authorization; + +namespace Fengling.Member.Web.Endpoints.User; + +public record CreateUserRequestDto(string Name, string Email); + +public record CreateUserResponseDto(UserId UserId); + +[Tags("Users")] +[HttpPost("/api/users")] +[AllowAnonymous] +public class CreateUserEndpoint(IMediator mediator) : Endpoint> +{ + public override async Task HandleAsync(CreateUserRequestDto req, CancellationToken ct) + { + var command = new CreateUserCommand(req.Name, req.Email); + var userId = await mediator.Send(command, ct); + + await Send.OkAsync(new CreateUserResponseDto(userId).AsResponseData(), cancellation: ct); + } +} +``` + +### 更多端点响应示例 + +#### 创建资源的端点 +```csharp +public override async Task HandleAsync(CreateUserRequestDto req, CancellationToken ct) +{ + var command = new CreateUserCommand(req.Name, req.Email); + var userId = await mediator.Send(command, ct); + + // ...existing code... + var response = new CreateUserResponseDto(userId); + await Send.CreatedAsync(response.AsResponseData(), ct); +} +``` + +#### 查询资源的端点 +```csharp +public override async Task HandleAsync(GetUserRequestDto req, CancellationToken ct) +{ + var query = new GetUserQuery(req.UserId); + var user = await mediator.Send(query, ct); + + await Send.OkAsync(user.AsResponseData(), ct); +} +``` + +#### 更新资源的端点 +```csharp +public override async Task HandleAsync(UpdateUserRequestDto req, CancellationToken ct) +{ + var command = new UpdateUserCommand(req.UserId, req.Name, req.Email); + await mediator.Send(command, ct); + + await Send.NoContentAsync(ct); +} +``` + +## 配置方式 + +端点使用属性模式配置,不使用 `Configure()` 方法: + +```csharp +[Tags("ModuleName")] +[HttpPost("/api/resource")] +[AllowAnonymous] +public class CreateResourceEndpoint(IMediator mediator) : Endpoint> +{ + // 实现 +} +``` diff --git a/.github/instructions/entity-configuration.instructions.md b/.github/instructions/entity-configuration.instructions.md new file mode 100644 index 0000000..645e753 --- /dev/null +++ b/.github/instructions/entity-configuration.instructions.md @@ -0,0 +1,70 @@ +--- +applyTo: "src/Fengling.Member.Infrastructure/EntityConfigurations/*.cs" +--- + +# 实体配置开发指南 + +## 开发原则 + +### 必须 + +- **配置定义**: + - 必须实现 `IEntityTypeConfiguration` 接口。 + - 每个实体一个配置文件。 +- **字段配置**: + - 必须配置主键,使用 `HasKey(x => x.Id)`。 + - 字符串属性必须设置最大长度。 + - 必填属性使用 `IsRequired()`。 + - 所有字段都不允许为 null,使用 `IsRequired()`。 + - 所有字段都必须提供注释说明。 + - 根据查询需求添加索引。 +- **强类型ID配置**: + - 对于强类型 ID,直接使用值生成器。 + - `IInt64StronglyTypedId` 使用 `UseSnowFlakeValueGenerator()`。 + - `IGuidStronglyTypedId` 使用 `UseGuidVersion7ValueGenerator()`。 + +### 必须不要 + +- **转换器**:不要使用 `HasConversion<{Id}.EfCoreValueConverter>()`,框架会自动处理强类型 ID 的转换。 +- **RowVersion**:`RowVersion` 无需配置。 + +## 文件命名规则 + +- 类文件应放置在 `src/Fengling.Member.Infrastructure/EntityConfigurations/` 目录下。 +- 文件名格式为 `{EntityName}EntityTypeConfiguration.cs`。 + +## 代码示例 + +**文件**: `src/Fengling.Member.Infrastructure/EntityConfigurations/UserEntityTypeConfiguration.cs` + +```csharp +using Fengling.Member.Domain.AggregatesModel.UserAggregate; + +namespace Fengling.Member.Infrastructure.EntityConfigurations; + +public class UserEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("Users"); + + builder.HasKey(x => x.Id); + + builder.Property(x => x.Id) + .UseGuidVersion7ValueGenerator() + .HasComment("用户标识"); + + builder.Property(x => x.Name) + .IsRequired() + .HasMaxLength(50) + .HasComment("用户姓名"); + builder.Property(x => x.Email) + .IsRequired() + .HasMaxLength(100) + .HasComment("用户邮箱"); + + builder.HasIndex(x => x.Email) + .IsUnique(); + } +} +``` diff --git a/.github/instructions/integration-event-converter.instructions.md b/.github/instructions/integration-event-converter.instructions.md new file mode 100644 index 0000000..6daebc0 --- /dev/null +++ b/.github/instructions/integration-event-converter.instructions.md @@ -0,0 +1,49 @@ +--- +applyTo: "src/Fengling.Member.Web/Application/IntegrationEventConverters/*.cs" +--- + +# 集成事件转换器开发指南 + +## 开发原则 + +### 必须 + +- **转换器定义**: + - 必须实现 `IIntegrationEventConverter` 接口。 + - 转换器负责从领域事件创建集成事件。 + - 集成事件使用 `record` 类型定义。 +- **注册**:框架自动注册转换器。 + +### 必须不要 + +- **直接发布**:不要直接发布集成事件,应通过转换器。 + +## 文件命名规则 + +- 转换器应放置在 `src/Fengling.Member.Web/Application/IntegrationEventConverters/` 目录下。 +- 转换器文件名格式为 `{Entity}{Action}IntegrationEventConverter.cs`。 + +## 代码示例 + +**文件**: `src/Fengling.Member.Web/Application/IntegrationEventConverters/UserCreatedIntegrationEventConverter.cs` + +```csharp +using Fengling.Member.Domain.DomainEvents; +using Fengling.Member.Web.Application.IntegrationEvents; + +namespace Fengling.Member.Web.Application.IntegrationEventConverters; + +public class UserCreatedIntegrationEventConverter + : IIntegrationEventConverter +{ + public UserCreatedIntegrationEvent Convert(UserCreatedDomainEvent domainEvent) + { + var user = domainEvent.User; + return new UserCreatedIntegrationEvent( + user.Id, + user.Name, + user.Email, + DateTime.UtcNow); + } +} +``` \ No newline at end of file diff --git a/.github/instructions/integration-event-handler.instructions.md b/.github/instructions/integration-event-handler.instructions.md new file mode 100644 index 0000000..2e9f973 --- /dev/null +++ b/.github/instructions/integration-event-handler.instructions.md @@ -0,0 +1,60 @@ +--- +applyTo: "src/Fengling.Member.Web/Application/IntegrationEventHandlers/*.cs" +--- + +# 集成事件处理器开发指南 + +## 开发原则 + +### 必须 + +- **处理器定义**: + - 必须实现 `IIntegrationEventHandler` 接口。 + - 实现方法:`public Task HandleAsync(TIntegrationEvent integrationEvent, CancellationToken cancellationToken)`。 + - 每个集成事件可以对应多个处理器,每个处理器对应特定的业务目的。 + - 使用主构造函数注入所需的服务。 +- **业务逻辑**: + - 处理来自其他服务的集成事件。 + - 主要用于跨服务的数据同步和业务协调。 + - 通过发送 Command 来操作聚合,而不是直接操作。 +- **注册**:框架自动注册事件处理器。 + +### 必须不要 + +- **直接操作**:不要直接操作聚合,应通过 Command。 + +## 文件命名规则 + +- 类文件应放置在 `src/Fengling.Member.Web/Application/IntegrationEventHandlers/` 目录下。 +- 文件名格式为 `{IntegrationEvent}HandlerFor{Action}.cs`,其中 `{Action}` 需要准确描述该 Handler 的目的。 +- 一个文件仅包含一个集成事件处理器。 + +## 代码示例 + +**文件**: `src/Fengling.Member.Web/Application/IntegrationEventHandlers/UserCreatedIntegrationEventHandlerForSendNotifyEmail.cs` + +```csharp +using Fengling.Member.Web.Application.IntegrationEvents; +using Fengling.Member.Web.Application.Commands.Users; + +namespace Fengling.Member.Web.Application.IntegrationEventHandlers; + +public class UserCreatedIntegrationEventHandlerForSendNotifyEmail( + ILogger logger, + IMediator mediator) + : IIntegrationEventHandler +{ + public async Task HandleAsync(UserCreatedIntegrationEvent integrationEvent, CancellationToken cancellationToken) + { + logger.LogInformation("发送用户创建通知邮件:{UserId}", integrationEvent.UserId); + + // 通过Command发送通知邮件 + var command = new SendWelcomeEmailCommand( + integrationEvent.UserId, + integrationEvent.Email, + integrationEvent.Name); + + await mediator.Send(command, cancellationToken); + } +} +``` \ No newline at end of file diff --git a/.github/instructions/integration-event.instructions.md b/.github/instructions/integration-event.instructions.md new file mode 100644 index 0000000..c38ff86 --- /dev/null +++ b/.github/instructions/integration-event.instructions.md @@ -0,0 +1,44 @@ +--- +applyTo: "src/Fengling.Member.Web/Application/IntegrationEvents/*.cs" +--- + +# 集成事件开发指南 + +## 开发原则 + +### 必须 + +- **事件定义**: + - 必须使用 `record` 类型。 + - 包含跨服务通信所需的关键数据。 + - 使用过去式动词描述已发生的事情。 + - 事件应该是不可变的。 +- **复杂类型**:如果需要复杂类型作为属性,则在同文件中定义,同样必须使用 `record` 类型。 + +### 必须不要 + +- **引用聚合**:不允许引用聚合。 +- **敏感信息**:避免包含敏感或过于详细的内部信息。 + +## 文件命名规则 + +- 类文件应放置在 `src/Fengling.Member.Web/Application/IntegrationEvents/` 目录下。 +- 文件名格式为 `{Entity}{Action}IntegrationEvent.cs`。 +- 每个集成事件一个文件。 + +## 代码示例 + +**文件**: `src/Fengling.Member.Web/Application/IntegrationEvents/UserCreatedIntegrationEvent.cs` + +```csharp +using NetCorePal.Extensions.Domain; +using Fengling.Member.Domain.AggregatesModel.UserAggregate; + +namespace Fengling.Member.Web.Application.IntegrationEvents; + +public record UserCreatedIntegrationEvent( + UserId UserId, + string Name, + string Email, + DateTime CreatedTime); +``` diff --git a/.github/instructions/query.instructions.md b/.github/instructions/query.instructions.md new file mode 100644 index 0000000..012e761 --- /dev/null +++ b/.github/instructions/query.instructions.md @@ -0,0 +1,206 @@ +--- +applyTo: "src/Fengling.Member.Web/Application/Queries/**/*.cs" +--- + +# 查询开发指南 + +## 开发原则 + +### 必须 + +- **查询定义**: + - 查询实现 `IQuery` 接口。 + - 分页查询实现 `IPagedQuery` 接口 + - 必须为每个查询创建验证器,继承 `AbstractValidator`。 + - 查询处理器实现 `IQueryHandler` 接口。 + - 使用 `record` 类型定义查询和 DTO。 + - 框架默认会过滤掉软删除的数据(`Deleted(true)` 标记的数据)。 +- **数据访问**: + - 直接使用 `ApplicationDbContext` 进行数据访问。 + - 所有数据库操作都应使用异步版本。 + - 将 `CancellationToken` 传递给所有异步操作。 +- **性能优化**: + - 使用投影 (`Select`)、过滤 (`WhereIf`)、排序 (`OrderByIf`)、分页 (`ToPagedDataAsync`) 等优化性能。 + - 分页查询使用 `PagedData` 类型包装分页结果。 + - 确保默认排序,在动态排序时始终提供默认排序字段。 + +### 必须不要 + +- **仓储使用**:避免在查询中调用仓储方法(仓储仅用于命令处理器)。 +- **关联查询**: 不要跨聚合使用Join关联查询,应该通过多次查询再组合的方式实现。 +- **副作用**:查询不应修改任何数据状态。 +- **同步操作**:避免使用同步数据库操作。 + +## 文件命名规则 + +- 类文件应放置在 `src/Fengling.Member.Web/Application/Queries/{Module}s/` 目录下。 +- 查询文件名格式为 `{Action}{Entity}Query.cs`。 +- 查询、验证器、处理器和 DTO 定义在同一文件中。 + +## 代码示例 + +**文件**: `src/Fengling.Member.Web/Application/Queries/Users/GetUserQuery.cs` + +```csharp +using Fengling.Member.Domain.AggregatesModel.UserAggregate; +using Fengling.Member.Infrastructure; +using Microsoft.EntityFrameworkCore; // 必需:用于EF Core扩展方法 + +namespace Fengling.Member.Web.Application.Queries.Users; + +public record GetUserQuery(UserId UserId) : IQuery; + +public class GetUserQueryValidator : AbstractValidator +{ + public GetUserQueryValidator() + { + RuleFor(x => x.UserId) + .NotEmpty() + .WithMessage("用户ID不能为空"); + } +} + +public class GetUserQueryHandler(ApplicationDbContext context) : IQueryHandler +{ + public async Task Handle(GetUserQuery request, CancellationToken cancellationToken) + { + var user = await context.Users + .Where(x => x.Id == request.UserId) + .Select(x => new UserDto(x.Id, x.Name, x.Email)) + .FirstOrDefaultAsync(cancellationToken) ?? + throw new KnownException($"未找到用户,UserId = {request.UserId}"); + + return user; + } +} + +public record UserDto(UserId Id, string Name, string Email); +``` + +### 分页查询示例 + +```csharp +using Fengling.Member.Domain.AggregatesModel.ProductAggregate; +using Fengling.Member.Infrastructure; +using Microsoft.EntityFrameworkCore; // 必需:用于EF Core扩展方法 + +namespace Fengling.Member.Web.Application.Queries.Products; + +public record PagedProductQuery(string? Name = null, CategoryId? CategoryId = null, decimal? MinPrice = null, decimal? MaxPrice = null, bool? IsActive = false, string? SortBy = null, int PageIndex = 1, int PageSize = 50, bool CountTotal = true) : IPagedQuery; + +public class PagedProductQueryValidator : AbstractValidator +{ + public PagedProductQueryValidator() + { + RuleFor(query => query.PageIndex) + .GreaterThanOrEqualTo(1) + .WithMessage("页码必须大于或等于1"); + RuleFor(query => query.PageSize) + .InclusiveBetween(1, 100) + .WithMessage("每页条数必须在1到100之间"); + } +} + +public class PagedProductQueryHandler(ApplicationDbContext context) : IQueryHandler> +{ + public async Task> Handle(PagedProductQuery request, CancellationToken cancellationToken) + { + return await context.Products + // 条件过滤 + .WhereIf(!string.IsNullOrWhiteSpace(request.Name), x => x.Name.Contains(request.Name!)) + .WhereIf(request.CategoryId.HasValue, x => x.CategoryId == request.CategoryId!.Value) + .WhereIf(request.MinPrice.HasValue, x => x.Price >= request.MinPrice!.Value) + .WhereIf(request.MaxPrice.HasValue, x => x.Price <= request.MaxPrice!.Value) + .WhereIf(request.IsActive.HasValue, x => x.IsActive == request.IsActive!.Value) + // 动态排序 + .OrderByIf(request.SortBy == "name", x => x.Name, request.Desc) + .ThenByIf(request.SortBy == "price", x => x.Price, request.Desc) + .ThenByIf(request.SortBy == "createTime", x => x.CreateTime, request.Desc) + .ThenByIf(string.IsNullOrEmpty(request.SortBy), x => x.Id) // 默认排序确保结果稳定 + // 数据投影 + .Select(p => new PagedProductListItemDto( + p.Id, + p.Name, + p.Price, + p.CategoryName, + p.IsActive, + p.CreateTime)) + // 分页处理 + .ToPagedDataAsync(request, cancellationToken: cancellationToken); + } +} +``` + +## 框架扩展方法 + +### WhereIf - 条件过滤 + +使用 `WhereIf` 方法可以根据条件动态添加 Where 子句,避免编写冗长的条件判断代码: + +```csharp +// 传统写法 +var query = context.Users.AsQueryable(); +if (!string.IsNullOrWhiteSpace(searchName)) +{ + query = query.Where(x => x.Name.Contains(searchName)); +} +if (isActive.HasValue) +{ + query = query.Where(x => x.IsActive == isActive.Value); +} + +// 使用 WhereIf 的简化写法 +var query = context.Users + .WhereIf(!string.IsNullOrWhiteSpace(searchName), x => x.Name.Contains(searchName!)) + .WhereIf(isActive.HasValue, x => x.IsActive == isActive!.Value); +``` + +### OrderByIf / ThenByIf - 条件排序 + +使用 `OrderByIf` 和 `ThenByIf` 方法可以根据条件动态添加排序: + +```csharp +// 复杂的动态排序示例 +var orderedQuery = context.Users + .OrderByIf(sortBy == "name", x => x.Name, desc) + .ThenByIf(sortBy == "email", x => x.Email, desc) + .ThenByIf(sortBy == "createTime", x => x.CreateTime, desc) + .ThenByIf(string.IsNullOrEmpty(sortBy), x => x.Id); // 默认排序 + +// 参数说明: +// - condition: 条件表达式,为 true 时才应用排序 +// - predicate: 排序字段表达式 +// - desc: 可选参数,是否降序排序,默认为 false(升序) +``` + +### ToPagedDataAsync - 分页数据 + +使用 `ToPagedDataAsync` 方法可以自动处理分页逻辑,返回 `PagedData` 类型: + +```csharp +// 基本分页用法 - 默认会查询总数 +var pagedResult = await query + .Select(u => new UserListItemDto(u.Id, u.Name, u.Email)) + .ToPagedDataAsync(pageIndex, pageSize, cancellationToken: cancellationToken); + +// 性能优化版本 - 不查询总数(适用于不需要显示总页数的场景) +var pagedResult = await query + .Select(u => new UserListItemDto(u.Id, u.Name, u.Email)) + .ToPagedDataAsync(pageIndex, pageSize, countTotal: false, cancellationToken); + +// 使用 IPageRequest 接口的版本 +var pagedResult = await query + .ToPagedDataAsync(pageRequest, cancellationToken); + +// PagedData 包含以下属性: +// - Items: IEnumerable - 当前页数据 +// - Total: int - 总记录数 +// - PageIndex: int - 当前页码 +// - PageSize: int - 每页大小 +``` + +**重要的using引用**: +```csharp +using Microsoft.EntityFrameworkCore; // 用于EF Core扩展方法 +// NetCorePal.Extensions.AspNetCore 已在GlobalUsings.cs中全局引用 +``` diff --git a/.github/instructions/repository.instructions.md b/.github/instructions/repository.instructions.md new file mode 100644 index 0000000..f075cd4 --- /dev/null +++ b/.github/instructions/repository.instructions.md @@ -0,0 +1,91 @@ +--- +applyTo: "src/Fengling.Member.Infrastructure/Repositories/*.cs" +--- + +# 仓储开发指南 + +## 开发原则 + +### 必须 + +- **仓储定义**: + - 每个聚合根对应一个仓储。 + - 接口必须继承 `IRepository`。 + - 实现必须继承 `RepositoryBase`。 + - 接口和实现定义在同一个文件中。 +- **注册**:仓储类会被自动注册到依赖注入容器中,无需手动注册。 +- **实现**: + - 使用 `DbContext` 属性访问当前的 `DbContext` 实例。 + - 在自定义仓储方法中,使用 `DbContext.EntitySetName` 访问具体的 DbSet。 + +### 必须不要 + +- **冗余方法**:默认基类已经实现了一组常用方法,如无必要,尽量不要定义新的仓储方法。 +- **重复引用**:无需重复添加 `Microsoft.EntityFrameworkCore` 引用(已在 `GlobalUsings.cs` 中定义)。 + +## 文件命名规则 + +- 仓储接口和实现应放置在 `src/Fengling.Member.Infrastructure/Repositories/` 目录下。 +- 文件名格式为 `{AggregateName}Repository.cs`。 + +## 代码示例 + +**文件**: `src/Fengling.Member.Infrastructure/Repositories/AdminUserRepository.cs` + +```csharp +using Fengling.Member.Domain.AggregatesModel.AdminUserAggregate; + +namespace Fengling.Member.Infrastructure.Repositories; + +// 接口和实现定义在同一文件中 +public interface IAdminUserRepository : IRepository +{ + /// + /// 根据用户名获取管理员 + /// + Task GetByUsernameAsync(string username, CancellationToken cancellationToken = default); + + /// + /// 检查用户名是否存在 + /// + Task UsernameExistsAsync(string username, CancellationToken cancellationToken = default); +} + +public class AdminUserRepository(ApplicationDbContext context) : + RepositoryBase(context), IAdminUserRepository +{ + public async Task GetByUsernameAsync(string username, CancellationToken cancellationToken = default) + { + return await DbContext.AdminUsers.FirstOrDefaultAsync(x => x.Username == username, cancellationToken); + } + + // ...existing code... + public async Task UsernameExistsAsync(string username, CancellationToken cancellationToken = default) + { + return await DbContext.AdminUsers.AnyAsync(x => x.Username == username, cancellationToken); + } +} +``` + +## 框架默认实现的方法 + +框架的 `IRepository` 和 `IRepository` 接口已实现以下方法,无需在自定义仓储中重复实现: + +### 基础操作 +- `IUnitOfWork UnitOfWork` - 获取工作单元对象 +- `TEntity Add(TEntity entity)` - 添加实体 +- `Task AddAsync(TEntity entity, CancellationToken cancellationToken = default)` - 异步添加实体 +- `void AddRange(IEnumerable entities)` - 批量添加实体 +- `Task AddRangeAsync(IEnumerable entities, CancellationToken cancellationToken = default)` - 异步批量添加实体 +- `void Attach(TEntity entity)` - 附加实体(状态设为未更改) +- `void AttachRange(IEnumerable entities)` - 批量附加实体 +- `TEntity Update(TEntity entity)` - 更新实体 +- `Task UpdateAsync(TEntity entity, CancellationToken cancellationToken = default)` - 异步更新实体 +- `bool Delete(Entity entity)` - 删除实体 +- `Task DeleteAsync(Entity entity)` - 异步删除实体 + +### 主键操作(仅 IRepository) +- `TEntity? Get(TKey id)` - 根据主键获取实体 +- `Task GetAsync(TKey id, CancellationToken cancellationToken = default)` - 异步根据主键获取实体 +- `int DeleteById(TKey id)` - 根据主键删除实体 +- `Task DeleteByIdAsync(TKey id, CancellationToken cancellationToken = default)` - 异步根据主键删除实体 \ No newline at end of file diff --git a/.github/instructions/unit-testing.instructions.md b/.github/instructions/unit-testing.instructions.md new file mode 100644 index 0000000..05d4a5c --- /dev/null +++ b/.github/instructions/unit-testing.instructions.md @@ -0,0 +1,205 @@ +--- +applyTo: "test/**/*.cs" +--- + +# 单元测试开发指南 + +## 开发原则 + +### 必须 + +- **测试模式**:使用 AAA 模式:Arrange、Act、Assert。 +- **测试范围**: + - 一个测试方法只测试一个场景。 + - 测试正常流程和异常流程。 + - 验证领域事件的发布。 + - 确保聚合根的业务规则始终满足(不变量)。 + - 验证状态变化的正确性。 + - 测试输入的边界值。 +- **命名规范**:测试方法命名清晰表达测试意图:`{Method}_{Scenario}_{ExpectedBehavior}`。 +- **数据驱动**:使用 `Theory` 和 `InlineData` 测试多个输入值。 +- **强类型ID**: + - 使用构造函数创建测试用的强类型 ID 实例:`new UserId(123)`。 + - 测试时直接比较强类型 ID,无需转换。 +- **时间处理**: + - 避免严格的时间比较 `>`,建议使用 `>=`。 + - 使用相对时间比较而非绝对时间比较。 +- **领域事件**: + - 使用 `GetDomainEvents()` 方法获取发布的事件。 + - 验证事件的类型和数量。 + +### 必须不要 + +- **时间比较**:不要使用严格的时间比较(如 `==` 或 `>`),因为执行时间会有微小差异。 + +## 文件命名规则 + +- 领域层测试:`test/Fengling.Member.Domain.Tests/{EntityName}Tests.cs`。 +- Web 层测试:`test/Fengling.Member.Web.Tests/{Feature}Tests.cs`。 +- 基础设施层测试:`test/Fengling.Member.Infrastructure.Tests/{Component}Tests.cs`。 + +## 代码示例 + +**基本聚合根测试** + +```csharp +public class UserTests +{ + [Fact] + public void User_Constructor_ShouldCreateValidUser() + { + // Arrange + var name = "张三"; + var email = "zhangsan@example.com"; + + // Act + var user = new User(name, email); + + // Assert + Assert.Equal(name, user.Name); + Assert.Equal(email, user.Email); + Assert.False(user.IsDeleted); + Assert.True(user.CreateTime <= DateTimeOffset.UtcNow); + + // 验证领域事件 + var domainEvents = user.GetDomainEvents(); + Assert.Single(domainEvents); + Assert.IsType(domainEvents.First()); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(null)] + public void User_Constructor_WithInvalidName_ShouldThrowException(string? name) + { + // Arrange + var email = "test@example.com"; + + // Act & Assert + Assert.Throws(() => new User(name!, email)); + } +} +``` + +**时间相关测试** + +```csharp +[Fact] +public void UpdateEmail_ShouldUpdateTimestamp() +{ + // Arrange + var user = new User("张三", "old@example.com"); + var originalUpdateTime = user.UpdateTime; + + // 确保时间差异 + Thread.Sleep(1); + + // Act + user.UpdateEmail("new@example.com"); + + // Assert + Assert.Equal("new@example.com", user.Email); + Assert.True(user.UpdateTime >= originalUpdateTime); + + // 验证领域事件 + var domainEvents = user.GetDomainEvents(); + Assert.Equal(2, domainEvents.Count); // 创建事件 + 更新事件 + Assert.IsType(domainEvents.Last()); +} +``` + +**强类型ID测试** + +```csharp +[Fact] +public void UserId_ShouldWorkCorrectly() +{ + // Arrange & Act + var userId1 = new UserId(123); + var userId2 = new UserId(123); + var userId3 = new UserId(456); + + // Assert + Assert.Equal(userId1, userId2); + Assert.NotEqual(userId1, userId3); + Assert.Equal("123", userId1.ToString()); +} +``` + +**业务规则测试** + +```csharp +[Fact] +public void ChangeEmail_OnDeletedUser_ShouldThrowException() +{ + // Arrange + var user = new User("张三", "test@example.com"); + user.Delete(); + + // Act & Assert + var exception = Assert.Throws(() => + user.UpdateEmail("new@example.com")); + Assert.Equal("无法修改已删除用户的邮箱", exception.Message); +} + +[Fact] +public void Delete_AlreadyDeletedUser_ShouldThrowException() +{ + // Arrange + var user = new User("张三", "test@example.com"); + user.Delete(); + + // Act & Assert + Assert.Throws(() => user.Delete()); +} +``` + +**领域事件详细测试** + +```csharp +[Fact] +public void User_BusinessOperations_ShouldPublishCorrectEvents() +{ + // Arrange + var user = new User("张三", "old@example.com"); + user.ClearDomainEvents(); // 清除构造函数事件 + + // Act + user.UpdateEmail("new@example.com"); + user.UpdateName("李四"); + + // Assert + var events = user.GetDomainEvents(); + Assert.Equal(2, events.Count); + + var emailEvent = events.OfType().Single(); + Assert.Equal(user, emailEvent.User); + + var nameEvent = events.OfType().Single(); + Assert.Equal(user, nameEvent.User); +} +``` + +### 测试数据工厂示例 + +使用Builder模式或Factory方法创建测试数据: + +```csharp +public static class TestDataFactory +{ + public static User CreateValidUser(string name = "测试用户", string email = "test@example.com") + { + return new User(name, email); + } + + public static UserId CreateUserId(long value = 123) + { + return new UserId(value); + } +} + +// 在测试中使用 +var user = TestDataFactory.CreateValidUser(); +var userId = TestDataFactory.CreateUserId(456); +``` 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..fb7b08d --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,34 @@ + + + + Fengling.Member + Fengling.Member + Fengling.Member + Fengling.Member + + + + 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..596ad04 --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,157 @@ + + + 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.Member.sln.DotSettings b/Fengling.Member.sln.DotSettings new file mode 100644 index 0000000..e65a68a --- /dev/null +++ b/Fengling.Member.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/README.md b/README.md new file mode 100644 index 0000000..c81b806 --- /dev/null +++ b/README.md @@ -0,0 +1,230 @@ +# Fengling.Member + +## 环境准备 + +### 使用 Aspire(推荐) + +如果您的项目启用了 Aspire 支持(使用 `--UseAspire` 参数创建),只需要 Docker 环境即可,无需手动配置各种基础设施服务。 + +```bash +# 仅需确保 Docker 环境运行 +docker version + +# 直接运行 AppHost 项目,Aspire 会自动管理所有依赖服务 +cd src/Fengling.Member.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.Member.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.Member.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.Member.Infrastructure +``` + +## 代码分析可视化 + +框架提供了强大的代码流分析和可视化功能,帮助开发者直观地理解DDD架构中的组件关系和数据流向。 + +### 🎯 核心特性 + ++ **自动代码分析**:通过源生成器自动分析代码结构,识别控制器、命令、聚合根、事件等组件 ++ **多种图表类型**:支持架构流程图、命令链路图、事件流程图、类图等多种可视化图表 ++ **交互式HTML可视化**:生成完整的交互式HTML页面,内置导航和图表预览功能 ++ **一键在线编辑**:集成"View in Mermaid Live"按钮,支持一键跳转到在线编辑器 + +### 🚀 快速开始 + +安装命令行工具来生成独立的HTML文件: + +```bash +# 安装全局工具 +dotnet tool install -g NetCorePal.Extensions.CodeAnalysis.Tools + +# 进入项目目录并生成可视化文件 +cd src/Fengling.Member.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/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/scripts/EXAMPLES.md b/scripts/EXAMPLES.md new file mode 100644 index 0000000..c63fa14 --- /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.Member.Web +dotnet run + +# 3. Run tests +cd ../../ +dotnet test + +# 4. Stop infrastructure (keep data) +cd scripts +docker compose down +``` + +### Clean Development Environment +```bash +# Clean slate - remove everything including data +cd scripts +docker compose down -v + +# Start fresh +docker compose up -d +``` + +## Troubleshooting + +### Check Service Status +```bash +# List running containers +docker ps + +# Check specific service logs +docker logs netcorepal-mysql +docker logs netcorepal-redis +docker logs netcorepal-rabbitmq + +# Check service health +docker compose ps +``` + +### Common Issues + +#### Port Already in Use +```bash +# Find what's using the port +netstat -tulpn | grep :3306 # Linux +netstat -ano | findstr :3306 # Windows + +# Stop conflicting services +sudo systemctl stop mysql # Linux +net stop mysql80 # Windows +``` + +#### Container Won't Start +```bash +# Remove problematic container and restart +docker rm -f netcorepal-mysql +docker compose up -d mysql +``` + +#### Data Corruption +```bash +# Remove data volumes and start fresh +docker compose down -v +docker compose up -d +``` + +## Connection Strings for Development + +Update your `appsettings.Development.json` with these connection strings: + +```json +{ + "ConnectionStrings": { + "Redis": "localhost:6379,defaultDatabase=0", + "MySql": "Server=localhost;Port=3306;Database=abctemplate;Uid=root;Pwd=123456;", + "SqlServer": "Server=localhost,1433;Database=abctemplate;User Id=sa;Password=Test123456!;TrustServerCertificate=true;", + "PostgreSQL": "Host=localhost;Port=5432;Database=abctemplate;Username=postgres;Password=123456;" + }, + "RabbitMQ": { + "HostName": "localhost", + "Port": 5672, + "UserName": "guest", + "Password": "guest", + "VirtualHost": "/" + }, + "Kafka": { + "BootstrapServers": "localhost:9092" + } +} +``` \ No newline at end of file diff --git a/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/init-infrastructure.ps1 b/scripts/init-infrastructure.ps1 new file mode 100644 index 0000000..cb1f519 --- /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: localhost:6379" + + if ($Postgres) { + Write-Host "✅ PostgreSQL: localhost:5432 (postgres/123456)" + } + elseif ($SqlServer) { + Write-Host "✅ SQL Server: localhost:1433 (sa/Test123456!)" + } + else { + Write-Host "✅ MySQL: localhost:3306 (root/123456)" + } + + if (-not $Kafka) { + Write-Host "✅ RabbitMQ: localhost:5672 (guest/guest)" + Write-Host "📊 RabbitMQ Management UI: http://localhost:15672" + } + + Write-Host "" + Write-Host "💡 Tips:" -ForegroundColor Yellow + Write-Host "• Use 'docker ps' to see running containers" + Write-Host "• Use 'docker logs ' to check logs" + Write-Host "• Use '.\clean-infrastructure.ps1' to stop and remove all containers" + Write-Host "" + Write-Info "Ready for development! 🚀" +} + +# Main execution +Write-Host "🚀 NetCorePal Template - Infrastructure Setup" -ForegroundColor Green +Write-Host "==============================================" -ForegroundColor Green + +if ($Help) { + Show-Help + exit 0 +} + +try { + Start-Infrastructure +} +catch { + Write-Error "An error occurred during setup: $_" + exit 1 +} \ No newline at end of file diff --git a/scripts/init-infrastructure.sh b/scripts/init-infrastructure.sh new file mode 100644 index 0000000..16f8819 --- /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: localhost:6379" + echo "✅ MySQL: localhost:3306 (root/123456)" + echo "✅ RabbitMQ: localhost:5672 (guest/guest)" + echo "📊 RabbitMQ Management UI: http://localhost:15672" + echo + echo "💡 Tips:" + echo "• Use 'docker ps' to see running containers" + echo "• Use 'docker logs ' to check logs" + echo "• Use './clean-infrastructure.sh' to stop and remove all containers" + echo + print_status "Ready for development! 🚀" +} + +# Parse command line arguments +case "${1:-}" in + --help|-h) + echo "Usage: $0 [OPTIONS]" + echo + echo "Initialize infrastructure containers for NetCorePal Template development" + echo + echo "Options:" + echo " -h, --help Show this help message" + echo " --mysql Use MySQL database (default)" + echo " --sqlserver Use SQL Server database" + echo " --postgres Use PostgreSQL database" + echo " --kafka Use Kafka instead of RabbitMQ" + echo + echo "Examples:" + echo " $0 # Start with MySQL and RabbitMQ (default)" + echo " $0 --postgres # Start with PostgreSQL and RabbitMQ" + echo " $0 --kafka # Start with MySQL and Kafka" + exit 0 + ;; + --sqlserver) + print_status "SQL Server option will be implemented in Docker Compose version" + print_status "For now, use: docker-compose --profile sqlserver up -d" + exit 0 + ;; + --postgres) + print_status "PostgreSQL option will be implemented in Docker Compose version" + print_status "For now, use: docker-compose --profile postgres up -d" + exit 0 + ;; + --kafka) + print_status "Kafka option will be implemented in Docker Compose version" + print_status "For now, use: docker-compose --profile kafka up -d" + exit 0 + ;; + *) + main + ;; +esac \ No newline at end of file diff --git a/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.Member.Application/Commands/Member/AddMemberTagCommand.cs b/src/Fengling.Member.Application/Commands/Member/AddMemberTagCommand.cs new file mode 100644 index 0000000..c6ab71d --- /dev/null +++ b/src/Fengling.Member.Application/Commands/Member/AddMemberTagCommand.cs @@ -0,0 +1,68 @@ +using MediatR; +using FluentValidation; +using Fengling.Member.Domain.Aggregates.Users; +using Fengling.Member.Infrastructure.Repositories; + +namespace Fengling.Member.Application.Commands.Member; + +public class AddMemberTagCommand : IRequest +{ + public long MemberId { get; set; } + public string TagId { get; set; } = string.Empty; + public string? TagName { get; set; } +} + +public class AddMemberTagResponse +{ + public long MemberId { get; set; } + public string TagId { get; set; } = string.Empty; + public string? TagName { get; set; } + public DateTime AddedAt { get; set; } +} + +public class AddMemberTagCommandValidator : AbstractValidator +{ + public AddMemberTagCommandValidator() + { + RuleFor(x => x.MemberId).GreaterThan(0); + RuleFor(x => x.TagId).NotEmpty().MaximumLength(50); + RuleFor(x => x.TagName).MaximumLength(100); + } +} + +public class AddMemberTagCommandHandler : IRequestHandler +{ + private readonly IMemberRepository _memberRepository; + private readonly ILogger _logger; + + public AddMemberTagCommandHandler( + IMemberRepository memberRepository, + ILogger logger) + { + _memberRepository = memberRepository; + _logger = logger; + } + + public async Task Handle(AddMemberTagCommand request, CancellationToken cancellationToken) + { + _logger.LogInformation("Adding tag {TagId} to member {MemberId}", request.TagId, request.MemberId); + + var member = await _memberRepository.GetAsync(request.MemberId, cancellationToken); + if (member == null) + { + throw new KeyNotFoundException($"会员不存在: {request.MemberId}"); + } + + member.AddTag(request.TagId, request.TagName); + + _logger.LogInformation("Tag {TagId} added to member {MemberId}", request.TagId, request.MemberId); + + return new AddMemberTagResponse + { + MemberId = member.Id, + TagId = request.TagId, + TagName = request.TagName, + AddedAt = DateTime.UtcNow + }; + } +} diff --git a/src/Fengling.Member.Application/Commands/Member/BindAlipayCommand.cs b/src/Fengling.Member.Application/Commands/Member/BindAlipayCommand.cs new file mode 100644 index 0000000..024ccbd --- /dev/null +++ b/src/Fengling.Member.Application/Commands/Member/BindAlipayCommand.cs @@ -0,0 +1,68 @@ +using MediatR; +using FluentValidation; +using Fengling.Member.Domain.Aggregates.Users; +using Fengling.Member.Infrastructure.Repositories; + +namespace Fengling.Member.Application.Commands.Member; + +public class BindAlipayCommand : IRequest +{ + public long MemberId { get; set; } + public string AlipayOpenId { get; set; } = string.Empty; + public string? AlipayUserId { get; set; } +} + +public class BindAlipayResponse +{ + public long MemberId { get; set; } + public string AlipayOpenId { get; set; } = string.Empty; + public string? AlipayUserId { get; set; } + public DateTime BoundAt { get; set; } +} + +public class BindAlipayCommandValidator : AbstractValidator +{ + public BindAlipayCommandValidator() + { + RuleFor(x => x.MemberId).GreaterThan(0); + RuleFor(x => x.AlipayOpenId).NotEmpty().MaximumLength(128); + RuleFor(x => x.AlipayUserId).MaximumLength(128); + } +} + +public class BindAlipayCommandHandler : IRequestHandler +{ + private readonly IMemberRepository _memberRepository; + private readonly ILogger _logger; + + public BindAlipayCommandHandler( + IMemberRepository memberRepository, + ILogger logger) + { + _memberRepository = memberRepository; + _logger = logger; + } + + public async Task Handle(BindAlipayCommand request, CancellationToken cancellationToken) + { + _logger.LogInformation("Binding Alipay for member {MemberId}", request.MemberId); + + var member = await _memberRepository.GetAsync(request.MemberId, cancellationToken); + if (member == null) + { + throw new KeyNotFoundException($"会员不存在: {request.MemberId}"); + } + + member.BindOAuth(OAuthProvider.Alipay, request.AlipayOpenId, request.AlipayUserId); + + _logger.LogInformation("Alipay bound successfully for member {MemberId}", request.MemberId); + + return new BindAlipayResponse + { + MemberId = member.Id, + AlipayOpenId = request.AlipayOpenId, + AlipayUserId = request.AlipayUserId, + BoundAt = DateTime.UtcNow + }; + } +} diff --git a/src/Fengling.Member.Application/Commands/Member/BindOAuthCommand.cs b/src/Fengling.Member.Application/Commands/Member/BindOAuthCommand.cs new file mode 100644 index 0000000..d1020f1 --- /dev/null +++ b/src/Fengling.Member.Application/Commands/Member/BindOAuthCommand.cs @@ -0,0 +1,74 @@ +using MediatR; +using FluentValidation; +using Fengling.Member.Domain.Aggregates.Users; +using Fengling.Member.Infrastructure.Repositories; + +namespace Fengling.Member.Application.Commands.Member; + +public class BindOAuthCommand : IRequest +{ + public OAuthProvider Provider { get; set; } + public long MemberId { get; set; } + public string OpenId { get; set; } = string.Empty; + public string? UnionId { get; set; } +} + +public class BindOAuthResponse +{ + public long MemberId { get; set; } + public OAuthProvider Provider { get; set; } + public string OpenId { get; set; } = string.Empty; + public string? UnionId { get; set; } + public DateTime BoundAt { get; set; } +} + +public class BindOAuthCommandValidator : AbstractValidator +{ + public BindOAuthCommandValidator() + { + RuleFor(x => x.Provider).IsInEnum(); + RuleFor(x => x.MemberId).GreaterThan(0); + RuleFor(x => x.OpenId).NotEmpty().MaximumLength(128); + RuleFor(x => x.UnionId).MaximumLength(128); + } +} + +public class BindOAuthCommandHandler : IRequestHandler +{ + private readonly IMemberRepository _memberRepository; + private readonly ILogger _logger; + + public BindOAuthCommandHandler( + IMemberRepository memberRepository, + ILogger logger) + { + _memberRepository = memberRepository; + _logger = logger; + } + + public async Task Handle(BindOAuthCommand request, CancellationToken cancellationToken) + { + _logger.LogInformation("Binding {Provider} for member {MemberId}", + request.Provider.GetProviderName(), request.MemberId); + + var member = await _memberRepository.GetAsync(request.MemberId, cancellationToken); + if (member == null) + { + throw new KeyNotFoundException($"会员不存在: {request.MemberId}"); + } + + member.BindOAuth(request.Provider, request.OpenId, request.UnionId); + + _logger.LogInformation("{Provider} bound successfully for member {MemberId}", + request.Provider.GetProviderName(), request.MemberId); + + return new BindOAuthResponse + { + MemberId = member.Id, + Provider = request.Provider, + OpenId = request.OpenId, + UnionId = request.UnionId, + BoundAt = DateTime.UtcNow + }; + } +} diff --git a/src/Fengling.Member.Application/Commands/Member/BindWechatCommand.cs b/src/Fengling.Member.Application/Commands/Member/BindWechatCommand.cs new file mode 100644 index 0000000..705f410 --- /dev/null +++ b/src/Fengling.Member.Application/Commands/Member/BindWechatCommand.cs @@ -0,0 +1,68 @@ +using MediatR; +using FluentValidation; +using Fengling.Member.Domain.Aggregates.Users; +using Fengling.Member.Infrastructure.Repositories; + +namespace Fengling.Member.Application.Commands.Member; + +public class BindWechatCommand : IRequest +{ + public long MemberId { get; set; } + public string OpenId { get; set; } = string.Empty; + public string? UnionId { get; set; } +} + +public class BindWechatResponse +{ + public long MemberId { get; set; } + public string OpenId { get; set; } = string.Empty; + public string? UnionId { get; set; } + public DateTime BoundAt { get; set; } +} + +public class BindWechatCommandValidator : AbstractValidator +{ + public BindWechatCommandValidator() + { + RuleFor(x => x.MemberId).GreaterThan(0); + RuleFor(x => x.OpenId).NotEmpty().MaximumLength(64); + RuleFor(x => x.UnionId).MaximumLength(64); + } +} + +public class BindWechatCommandHandler : IRequestHandler +{ + private readonly IMemberRepository _memberRepository; + private readonly ILogger _logger; + + public BindWechatCommandHandler( + IMemberRepository memberRepository, + ILogger logger) + { + _memberRepository = memberRepository; + _logger = logger; + } + + public async Task Handle(BindWechatCommand request, CancellationToken cancellationToken) + { + _logger.LogInformation("Binding wechat for member {MemberId}", request.MemberId); + + var member = await _memberRepository.GetAsync(request.MemberId, cancellationToken); + if (member == null) + { + throw new KeyNotFoundException($"会员不存在: {request.MemberId}"); + } + + member.BindWechat(request.OpenId, request.UnionId); + + _logger.LogInformation("Wechat bound successfully for member {MemberId}", request.MemberId); + + return new BindWechatResponse + { + MemberId = member.Id, + OpenId = request.OpenId, + UnionId = request.UnionId, + BoundAt = DateTime.UtcNow + }; + } +} diff --git a/src/Fengling.Member.Application/Commands/Member/RegisterMemberCommand.cs b/src/Fengling.Member.Application/Commands/Member/RegisterMemberCommand.cs new file mode 100644 index 0000000..16551f5 --- /dev/null +++ b/src/Fengling.Member.Application/Commands/Member/RegisterMemberCommand.cs @@ -0,0 +1,109 @@ +using MediatR; +using FluentValidation; +using Fengling.Member.Domain.Aggregates.Users; +using Fengling.Member.Infrastructure.Repositories; + +namespace Fengling.Member.Application.Commands.Member; + +public class RegisterMemberCommand : IRequest +{ + public long TenantId { get; set; } + public string? PhoneNumber { get; set; } + public string? OpenId { get; set; } + public string? UnionId { get; set; } + public string? Source { get; set; } +} + +public class RegisterMemberResponse +{ + public long MemberId { get; set; } + public long TenantId { get; set; } + public string? PhoneNumber { get; set; } + public string? OpenId { get; set; } + public MemberStatus Status { get; set; } = MemberStatus.Active; + public DateTime RegisteredAt { get; set; } +} + +public class RegisterMemberCommandHandler : IRequestHandler +{ + private readonly IMemberRepository _memberRepository; + private readonly ILogger _logger; + + public RegisterMemberCommandHandler( + IMemberRepository memberRepository, + ILogger logger) + { + _memberRepository = memberRepository; + _logger = logger; + } + + public async Task Handle(RegisterMemberCommand request, CancellationToken cancellationToken) + { + _logger.LogInformation("Registering new member for tenant {TenantId}", request.TenantId); + + if (!string.IsNullOrEmpty(request.PhoneNumber)) + { + var existingMember = await _memberRepository.GetByPhoneNumberAsync(request.TenantId, request.PhoneNumber, cancellationToken); + if (existingMember != null) + { + _logger.LogWarning("Member with phone {PhoneNumber} already exists in tenant {TenantId}", + request.PhoneNumber, request.TenantId); + + if (!string.IsNullOrEmpty(request.OpenId) && string.IsNullOrEmpty(existingMember.OpenId)) + { + existingMember.BindWechat(request.OpenId, request.UnionId); + } + + return new RegisterMemberResponse + { + MemberId = existingMember.Id, + TenantId = existingMember.TenantId, + PhoneNumber = existingMember.PhoneNumber, + OpenId = existingMember.OpenId, + Status = existingMember.Status, + RegisteredAt = existingMember.CreatedAt + }; + } + } + + if (!string.IsNullOrEmpty(request.OpenId)) + { + var existingByOpenId = await _memberRepository.GetByOpenIdAsync(request.OpenId, cancellationToken); + if (existingByOpenId != null) + { + _logger.LogWarning("Member with OpenId {OpenId} already exists", request.OpenId); + + return new RegisterMemberResponse + { + MemberId = existingByOpenId.Id, + TenantId = existingByOpenId.TenantId, + PhoneNumber = existingByOpenId.PhoneNumber, + OpenId = existingByOpenId.OpenId, + Status = existingByOpenId.Status, + RegisteredAt = existingByOpenId.CreatedAt + }; + } + } + + var member = MemberEntity.Create(request.TenantId, request.PhoneNumber); + + if (!string.IsNullOrEmpty(request.OpenId)) + { + member.BindWechat(request.OpenId, request.UnionId); + } + + await _memberRepository.AddAsync(member, cancellationToken); + + _logger.LogInformation("Member registered successfully with ID {MemberId}", member.Id); + + return new RegisterMemberResponse + { + MemberId = member.Id, + TenantId = member.TenantId, + PhoneNumber = member.PhoneNumber, + OpenId = member.OpenId, + Status = member.Status, + RegisteredAt = member.CreatedAt + }; + } +} diff --git a/src/Fengling.Member.Application/Commands/Points/AddPointsCommand.cs b/src/Fengling.Member.Application/Commands/Points/AddPointsCommand.cs new file mode 100644 index 0000000..cbc5988 --- /dev/null +++ b/src/Fengling.Member.Application/Commands/Points/AddPointsCommand.cs @@ -0,0 +1,76 @@ +using MediatR; +using FluentValidation; +using Fengling.Member.Domain.Aggregates.PointsModel; +using Fengling.Member.Infrastructure.Repositories; + +namespace Fengling.Member.Application.Commands.Points; + +public class AddPointsCommand : IRequest +{ + public long MemberId { get; set; } + public int Points { get; set; } + public string TransactionType { get; set; } = string.Empty; + public string SourceId { get; set; } = string.Empty; + public string? Remark { get; set; } +} + +public class AddPointsResponse +{ + public long AccountId { get; set; } + public long MemberId { get; set; } + public int AddedPoints { get; set; } + public int TotalPoints { get; set; } + public DateTime TransactionAt { get; set; } +} + +public class AddPointsCommandValidator : AbstractValidator +{ + public AddPointsCommandValidator() + { + RuleFor(x => x.MemberId).GreaterThan(0); + RuleFor(x => x.Points).GreaterThan(0); + RuleFor(x => x.TransactionType).NotEmpty().MaximumLength(50); + RuleFor(x => x.SourceId).MaximumLength(100); + RuleFor(x => x.Remark).MaximumLength(500); + } +} + +public class AddPointsCommandHandler : IRequestHandler +{ + private readonly IPointsAccountRepository _pointsAccountRepository; + private readonly ILogger _logger; + + public AddPointsCommandHandler( + IPointsAccountRepository pointsAccountRepository, + ILogger logger) + { + _pointsAccountRepository = pointsAccountRepository; + _logger = logger; + } + + public async Task Handle(AddPointsCommand request, CancellationToken cancellationToken) + { + _logger.LogInformation("Adding {Points} points to member {MemberId}", request.Points, request.MemberId); + + var account = await _pointsAccountRepository.GetByMemberIdAsync(request.MemberId, cancellationToken); + if (account == null) + { + account = PointsAccount.Create(request.MemberId, 0); + await _pointsAccountRepository.AddAsync(account, cancellationToken); + } + + account.AddPoints(request.Points, request.TransactionType, request.SourceId, request.Remark); + + _logger.LogInformation("{Points} points added to member {MemberId}. New balance: {TotalPoints}", + request.Points, request.MemberId, account.TotalPoints); + + return new AddPointsResponse + { + AccountId = account.Id, + MemberId = account.MemberId, + AddedPoints = request.Points, + TotalPoints = account.TotalPoints, + TransactionAt = DateTime.UtcNow + }; + } +} diff --git a/src/Fengling.Member.Application/Commands/Points/DeductPointsCommand.cs b/src/Fengling.Member.Application/Commands/Points/DeductPointsCommand.cs new file mode 100644 index 0000000..73c614e --- /dev/null +++ b/src/Fengling.Member.Application/Commands/Points/DeductPointsCommand.cs @@ -0,0 +1,101 @@ +using MediatR; +using FluentValidation; +using Fengling.Member.Domain.Aggregates.PointsModel; +using Fengling.Member.Infrastructure.Repositories; + +namespace Fengling.Member.Application.Commands.Points; + +public class DeductPointsCommand : IRequest +{ + public long MemberId { get; set; } + public int Points { get; set; } + public string TransactionType { get; set; } = string.Empty; + public string SourceId { get; set; } = string.Empty; + public string? Remark { get; set; } +} + +public class DeductPointsResponse +{ + public long AccountId { get; set; } + public long MemberId { get; set; } + public int DeductedPoints { get; set; } + public int TotalPoints { get; set; } + public bool Success { get; set; } + public DateTime TransactionAt { get; set; } +} + +public class DeductPointsCommandValidator : AbstractValidator +{ + public DeductPointsCommandValidator() + { + RuleFor(x => x.MemberId).GreaterThan(0); + RuleFor(x => x.Points).GreaterThan(0); + RuleFor(x => x.TransactionType).NotEmpty().MaximumLength(50); + RuleFor(x => x.SourceId).MaximumLength(100); + RuleFor(x => x.Remark).MaximumLength(500); + } +} + +public class DeductPointsCommandHandler : IRequestHandler +{ + private readonly IPointsAccountRepository _pointsAccountRepository; + private readonly ILogger _logger; + + public DeductPointsCommandHandler( + IPointsAccountRepository pointsAccountRepository, + ILogger logger) + { + _pointsAccountRepository = pointsAccountRepository; + _logger = logger; + } + + public async Task Handle(DeductPointsCommand request, CancellationToken cancellationToken) + { + _logger.LogInformation("Deducting {Points} points from member {MemberId}", request.Points, request.MemberId); + + var account = await _pointsAccountRepository.GetByMemberIdAsync(request.MemberId, cancellationToken); + if (account == null) + { + _logger.LogWarning("Member {MemberId} has no points account", request.MemberId); + return new DeductPointsResponse + { + MemberId = request.MemberId, + DeductedPoints = 0, + TotalPoints = 0, + Success = false, + TransactionAt = DateTime.UtcNow + }; + } + + var success = account.DeductPoints(request.Points, request.TransactionType, request.SourceId, request.Remark); + + if (!success) + { + _logger.LogWarning("Insufficient points for member {MemberId}. Available: {Available}, Requested: {Requested}", + request.MemberId, account.AvailablePoints, request.Points); + + return new DeductPointsResponse + { + AccountId = account.Id, + MemberId = account.MemberId, + DeductedPoints = 0, + TotalPoints = account.TotalPoints, + Success = false, + TransactionAt = DateTime.UtcNow + }; + } + + _logger.LogInformation("{Points} points deducted from member {MemberId}. New balance: {TotalPoints}", + request.Points, request.MemberId, account.TotalPoints); + + return new DeductPointsResponse + { + AccountId = account.Id, + MemberId = account.MemberId, + DeductedPoints = request.Points, + TotalPoints = account.TotalPoints, + Success = true, + TransactionAt = DateTime.UtcNow + }; + } +} diff --git a/src/Fengling.Member.Application/Fengling.Member.Application.csproj b/src/Fengling.Member.Application/Fengling.Member.Application.csproj new file mode 100644 index 0000000..12dd639 --- /dev/null +++ b/src/Fengling.Member.Application/Fengling.Member.Application.csproj @@ -0,0 +1,26 @@ + + + + net10.0 + enable + enable + true + true + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/src/Fengling.Member.Application/GlobalUsings.cs b/src/Fengling.Member.Application/GlobalUsings.cs new file mode 100644 index 0000000..9435c0c --- /dev/null +++ b/src/Fengling.Member.Application/GlobalUsings.cs @@ -0,0 +1,4 @@ +global using NetCorePal.Extensions.Domain; +global using MediatR; +global using FluentValidation; +global using Microsoft.Extensions.Logging; diff --git a/src/Fengling.Member.Domain/Aggregates/PointsModel/PointsAccount.cs b/src/Fengling.Member.Domain/Aggregates/PointsModel/PointsAccount.cs new file mode 100644 index 0000000..cb6d9fd --- /dev/null +++ b/src/Fengling.Member.Domain/Aggregates/PointsModel/PointsAccount.cs @@ -0,0 +1,135 @@ +using Fengling.Member.Domain.Events.Points; + +namespace Fengling.Member.Domain.Aggregates.PointsModel; + +public class PointsAccount : Entity, IAggregateRoot +{ + public long MemberId { get; private set; } + public long TenantId { get; private set; } + public int TotalPoints { get; private set; } = 0; + public int FrozenPoints { get; private set; } = 0; + public int AvailablePoints => TotalPoints - FrozenPoints; + public int Version { get; private set; } = 1; + public DateTime CreatedAt { get; private set; } = DateTime.UtcNow; + public DateTime? UpdatedAt { get; private set; } + + private readonly List _transactions = new(); + public IReadOnlyCollection Transactions => _transactions.AsReadOnly(); + + private PointsAccount() + { + } + + public static PointsAccount Create(long memberId, long tenantId) + { + if (memberId <= 0) + throw new ArgumentException("会员ID必须大于0", nameof(memberId)); + + if (tenantId <= 0) + throw new ArgumentException("租户ID必须大于0", nameof(tenantId)); + + var account = new PointsAccount + { + MemberId = memberId, + TenantId = tenantId, + TotalPoints = 0, + FrozenPoints = 0, + CreatedAt = DateTime.UtcNow + }; + + return account; + } + + public void AddPoints(int points, string transactionType, string sourceId, string? remark = null) + { + if (points <= 0) + throw new ArgumentException("积分必须大于0", nameof(points)); + + var transaction = PointsTransaction.Create( + Id, + MemberId, + points, + transactionType, + sourceId, + PointsTransactionType.Earn, + remark + ); + + _transactions.Add(transaction); + TotalPoints += points; + UpdatedAt = DateTime.UtcNow; + Version++; + + AddDomainEvent(new PointsChangedEvent(Id, MemberId, points, TotalPoints, transactionType)); + } + + public bool DeductPoints(int points, string transactionType, string sourceId, string? remark = null) + { + if (points <= 0) + throw new ArgumentException("积分必须大于0", nameof(points)); + + if (AvailablePoints < points) + return false; + + var transaction = PointsTransaction.Create( + Id, + MemberId, + points, + transactionType, + sourceId, + PointsTransactionType.Deduct, + remark + ); + + _transactions.Add(transaction); + TotalPoints -= points; + UpdatedAt = DateTime.UtcNow; + Version++; + + AddDomainEvent(new PointsChangedEvent(Id, MemberId, -points, TotalPoints, transactionType)); + return true; + } + + public bool FreezePoints(int points) + { + if (points <= 0) + throw new ArgumentException("冻结积分必须大于0", nameof(points)); + + if (AvailablePoints < points) + return false; + + FrozenPoints += points; + UpdatedAt = DateTime.UtcNow; + Version++; + return true; + } + + public bool UnfreezePoints(int points) + { + if (points <= 0) + throw new ArgumentException("解冻积分必须大于0", nameof(points)); + + if (FrozenPoints < points) + return false; + + FrozenPoints -= points; + UpdatedAt = DateTime.UtcNow; + Version++; + return true; + } + + public bool UseFrozenPoints(int points) + { + if (points <= 0) + throw new ArgumentException("使用积分必须大于0", nameof(points)); + + if (FrozenPoints < points) + return false; + + FrozenPoints -= points; + TotalPoints -= points; + UpdatedAt = DateTime.UtcNow; + Version++; + return true; + } +} diff --git a/src/Fengling.Member.Domain/Aggregates/PointsModel/PointsTransaction.cs b/src/Fengling.Member.Domain/Aggregates/PointsModel/PointsTransaction.cs new file mode 100644 index 0000000..31bf6e2 --- /dev/null +++ b/src/Fengling.Member.Domain/Aggregates/PointsModel/PointsTransaction.cs @@ -0,0 +1,45 @@ +namespace Fengling.Member.Domain.Aggregates.PointsModel; + +public class PointsTransaction : Entity +{ + public long PointsAccountId { get; private set; } + public long MemberId { get; private set; } + public int Points { get; private set; } + public string TransactionType { get; private set; } = string.Empty; + public string SourceId { get; private set; } = string.Empty; + public PointsTransactionType TransactionTypeCategory { get; private set; } + public string? Remark { get; private set; } + public DateTime CreatedAt { get; private set; } = DateTime.UtcNow; + + private PointsTransaction() + { + } + + public static PointsTransaction Create( + long pointsAccountId, + long memberId, + int points, + string transactionType, + string sourceId, + PointsTransactionType typeCategory, + string? remark = null) + { + return new PointsTransaction + { + PointsAccountId = pointsAccountId, + MemberId = memberId, + Points = points, + TransactionType = transactionType, + SourceId = sourceId, + TransactionTypeCategory = typeCategory, + Remark = remark, + CreatedAt = DateTime.UtcNow + }; + } +} + +public enum PointsTransactionType +{ + Earn = 1, + Deduct = 2 +} diff --git a/src/Fengling.Member.Domain/Aggregates/Users/Member.cs b/src/Fengling.Member.Domain/Aggregates/Users/Member.cs new file mode 100644 index 0000000..a427fc2 --- /dev/null +++ b/src/Fengling.Member.Domain/Aggregates/Users/Member.cs @@ -0,0 +1,145 @@ +using System.Text.RegularExpressions; +using Fengling.Member.Domain.Events.Member; + +namespace Fengling.Member.Domain.Aggregates.Users; + +public class MemberEntity : Entity, IAggregateRoot +{ + public long TenantId { get; private set; } + public string? PhoneNumber { get; private set; } + public string? OpenId { get; private set; } + public string? UnionId { get; private set; } + public MemberStatus Status { get; private set; } = MemberStatus.Active; + public DateTime CreatedAt { get; private set; } = DateTime.UtcNow; + public DateTime? UpdatedAt { get; private set; } + public int Version { get; private set; } = 1; + + private readonly List _tags = new(); + public IReadOnlyCollection Tags => _tags.AsReadOnly(); + + private readonly List _oauthAuthorizations = new(); + public IReadOnlyCollection OAuthAuthorizations => _oauthAuthorizations.AsReadOnly(); + + private MemberEntity() + { + } + + public static MemberEntity Create(long tenantId, string? phoneNumber = null) + { + if (tenantId <= 0) + throw new ArgumentException("租户ID必须大于0", nameof(tenantId)); + + var member = new MemberEntity + { + TenantId = tenantId, + PhoneNumber = phoneNumber, + Status = MemberStatus.Active, + CreatedAt = DateTime.UtcNow + }; + + member.AddDomainEvent(new MemberRegisteredEvent(member.Id, member.TenantId, phoneNumber, member.CreatedAt)); + return member; + } + + public void BindPhoneNumber(string phoneNumber) + { + if (string.IsNullOrWhiteSpace(phoneNumber)) + throw new ArgumentException("手机号不能为空", nameof(phoneNumber)); + + if (!Regex.IsMatch(phoneNumber, @"^1[3-9]\d{9}$")) + throw new ArgumentException("手机号格式不正确", nameof(phoneNumber)); + + PhoneNumber = phoneNumber; + UpdatedAt = DateTime.UtcNow; + } + + public void BindOAuth(OAuthProvider provider, string openId, string? unionId = null) + { + if (string.IsNullOrWhiteSpace(openId)) + throw new ArgumentException("OpenId不能为空", nameof(openId)); + + UpdatePrimaryOAuthId(provider, openId, unionId); + UpdatedAt = DateTime.UtcNow; + + var authorization = _oauthAuthorizations.FirstOrDefault(x => x.Provider == provider && x.OpenId == openId); + if (authorization == null) + { + authorization = OAuthAuthorization.Create(Id, provider, openId, unionId); + _oauthAuthorizations.Add(authorization); + } + else + { + authorization.UpdateUnionId(unionId); + } + } + + public void BindWechat(string openId, string? unionId = null) + { + BindOAuth(OAuthProvider.Wechat, openId, unionId); + } + + private void UpdatePrimaryOAuthId(OAuthProvider provider, string openId, string? unionId) + { + if (provider == OAuthProvider.Wechat) + { + OpenId = openId; + UnionId = unionId ?? UnionId; + } + } + + public OAuthAuthorization? GetOAuthAuthorization(OAuthProvider provider) + { + return _oauthAuthorizations.FirstOrDefault(x => x.Provider == provider); + } + + public bool HasOAuthBound(OAuthProvider provider, string openId) + { + return _oauthAuthorizations.Any(x => x.Provider == provider && x.OpenId == openId); + } + + public void RecordOAuthLogin(OAuthProvider provider, string openId) + { + var authorization = _oauthAuthorizations.FirstOrDefault(x => x.Provider == provider && x.OpenId == openId); + authorization?.RecordLogin(); + } + + public void AddTag(string tagId, string? tagName = null) + { + var existingTag = _tags.FirstOrDefault(t => t.TagId == tagId); + if (existingTag != null) + return; + + _tags.Add(MemberTag.Create(Id, tagId, tagName)); + } + + public void RemoveTag(string tagId) + { + var tag = _tags.FirstOrDefault(t => t.TagId == tagId); + if (tag != null) + _tags.Remove(tag); + } + + public void Freeze() + { + if (Status == MemberStatus.Frozen) + return; + + Status = MemberStatus.Frozen; + UpdatedAt = DateTime.UtcNow; + } + + public void Unfreeze() + { + Status = MemberStatus.Active; + UpdatedAt = DateTime.UtcNow; + } + + public void Deactivate() + { + if (Status == MemberStatus.Inactive) + return; + + Status = MemberStatus.Inactive; + UpdatedAt = DateTime.UtcNow; + } +} diff --git a/src/Fengling.Member.Domain/Aggregates/Users/MemberStatus.cs b/src/Fengling.Member.Domain/Aggregates/Users/MemberStatus.cs new file mode 100644 index 0000000..76cd7b0 --- /dev/null +++ b/src/Fengling.Member.Domain/Aggregates/Users/MemberStatus.cs @@ -0,0 +1,37 @@ +namespace Fengling.Member.Domain.Aggregates.Users; + +public record MemberStatus +{ + public static MemberStatus Active { get; } = new("Active", "正常"); + public static MemberStatus Inactive { get; } = new("Inactive", "已停用"); + public static MemberStatus Frozen { get; } = new("Frozen", "已冻结"); + + public string Value { get; } + public string Description { get; } + + private MemberStatus(string value, string description) + { + Value = value; + Description = description; + } + + public static MemberStatus FromValue(string value) + { + return value switch + { + "Active" => Active, + "Inactive" => Inactive, + "Frozen" => Frozen, + _ => throw new ArgumentException($"未知的会员状态: {value}", nameof(value)) + }; + } + + public static IEnumerable GetAll() + { + yield return Active; + yield return Inactive; + yield return Frozen; + } + + public override string ToString() => Value; +} diff --git a/src/Fengling.Member.Domain/Aggregates/Users/MemberTag.cs b/src/Fengling.Member.Domain/Aggregates/Users/MemberTag.cs new file mode 100644 index 0000000..0b61979 --- /dev/null +++ b/src/Fengling.Member.Domain/Aggregates/Users/MemberTag.cs @@ -0,0 +1,28 @@ +namespace Fengling.Member.Domain.Aggregates.Users; + +public class MemberTag : Entity +{ + public long MemberId { get; private set; } + public string TagId { get; private set; } = string.Empty; + public string? TagName { get; private set; } + public DateTime CreatedAt { get; private set; } = DateTime.UtcNow; + + private MemberTag() + { + } + + public static MemberTag Create(long memberId, string tagId, string? tagName = null) + { + return new MemberTag + { + MemberId = memberId, + TagId = tagId, + TagName = tagName + }; + } + + public void UpdateTagName(string? tagName) + { + TagName = tagName; + } +} diff --git a/src/Fengling.Member.Domain/Aggregates/Users/OAuthAuthorization.cs b/src/Fengling.Member.Domain/Aggregates/Users/OAuthAuthorization.cs new file mode 100644 index 0000000..100c8d6 --- /dev/null +++ b/src/Fengling.Member.Domain/Aggregates/Users/OAuthAuthorization.cs @@ -0,0 +1,50 @@ +namespace Fengling.Member.Domain.Aggregates.Users; + +public class OAuthAuthorization : Entity +{ + public long MemberId { get; private set; } + public OAuthProvider Provider { get; private set; } + public string OpenId { get; private set; } = string.Empty; + public string? UnionId { get; private set; } + public string? AccessToken { get; private set; } + public string? RefreshToken { get; private set; } + public DateTime? TokenExpiredAt { get; private set; } + public DateTime AuthorizedAt { get; private set; } = DateTime.UtcNow; + public DateTime? LastLoginAt { get; private set; } + + private OAuthAuthorization() + { + } + + public static OAuthAuthorization Create(long memberId, OAuthProvider provider, string openId, string? unionId = null) + { + return new OAuthAuthorization + { + MemberId = memberId, + Provider = provider, + OpenId = openId, + UnionId = unionId, + AuthorizedAt = DateTime.UtcNow + }; + } + + public void UpdateUnionId(string? unionId) + { + if (unionId != null && UnionId != unionId) + { + UnionId = unionId; + } + } + + public void RecordLogin() + { + LastLoginAt = DateTime.UtcNow; + } + + public void UpdateToken(string accessToken, string? refreshToken, DateTime? expiredAt) + { + AccessToken = accessToken; + RefreshToken = refreshToken; + TokenExpiredAt = expiredAt; + } +} diff --git a/src/Fengling.Member.Domain/Aggregates/Users/OAuthProvider.cs b/src/Fengling.Member.Domain/Aggregates/Users/OAuthProvider.cs new file mode 100644 index 0000000..2f3e3d3 --- /dev/null +++ b/src/Fengling.Member.Domain/Aggregates/Users/OAuthProvider.cs @@ -0,0 +1,26 @@ +namespace Fengling.Member.Domain.Aggregates.Users; + +public enum OAuthProvider +{ + Wechat = 1, + Alipay = 2, + QQ = 3, + Weibo = 4, + Douyin = 5 +} + +public static class OAuthProviderExtensions +{ + public static string GetProviderName(this OAuthProvider provider) + { + return provider switch + { + OAuthProvider.Wechat => "wechat", + OAuthProvider.Alipay => "alipay", + OAuthProvider.QQ => "qq", + OAuthProvider.Weibo => "weibo", + OAuthProvider.Douyin => "douyin", + _ => throw new ArgumentException($"未知的OAuth提供商: {provider}") + }; + } +} diff --git a/src/Fengling.Member.Domain/Aggregates/Users/WechatAuthorization.cs b/src/Fengling.Member.Domain/Aggregates/Users/WechatAuthorization.cs new file mode 100644 index 0000000..bcc2035 --- /dev/null +++ b/src/Fengling.Member.Domain/Aggregates/Users/WechatAuthorization.cs @@ -0,0 +1,38 @@ +namespace Fengling.Member.Domain.Aggregates.Users; + +public class WechatAuthorization : Entity +{ + public long MemberId { get; private set; } + public string OpenId { get; private set; } = string.Empty; + public string? UnionId { get; private set; } + public DateTime AuthorizedAt { get; private set; } = DateTime.UtcNow; + public DateTime? LastLoginAt { get; private set; } + + private WechatAuthorization() + { + } + + public static WechatAuthorization Create(long memberId, string openId, string? unionId = null) + { + return new WechatAuthorization + { + MemberId = memberId, + OpenId = openId, + UnionId = unionId, + AuthorizedAt = DateTime.UtcNow + }; + } + + public void UpdateUnionId(string? unionId) + { + if (unionId != null && UnionId != unionId) + { + UnionId = unionId; + } + } + + public void RecordLogin() + { + LastLoginAt = DateTime.UtcNow; + } +} diff --git a/src/Fengling.Member.Domain/Events/Member/MemberRegisteredEvent.cs b/src/Fengling.Member.Domain/Events/Member/MemberRegisteredEvent.cs new file mode 100644 index 0000000..5d1c56f --- /dev/null +++ b/src/Fengling.Member.Domain/Events/Member/MemberRegisteredEvent.cs @@ -0,0 +1,8 @@ +namespace Fengling.Member.Domain.Events.Member; + +public record MemberRegisteredEvent( + long MemberId, + long TenantId, + string? PhoneNumber, + DateTime RegisteredAt +) : IDomainEvent; diff --git a/src/Fengling.Member.Domain/Events/Points/PointsChangedEvent.cs b/src/Fengling.Member.Domain/Events/Points/PointsChangedEvent.cs new file mode 100644 index 0000000..d7d7b7b --- /dev/null +++ b/src/Fengling.Member.Domain/Events/Points/PointsChangedEvent.cs @@ -0,0 +1,9 @@ +namespace Fengling.Member.Domain.Events.Points; + +public record PointsChangedEvent( + long AccountId, + long MemberId, + int ChangedPoints, + int NewBalance, + string TransactionType +) : IDomainEvent; diff --git a/src/Fengling.Member.Domain/Fengling.Member.Domain.csproj b/src/Fengling.Member.Domain/Fengling.Member.Domain.csproj new file mode 100644 index 0000000..1ab15ae --- /dev/null +++ b/src/Fengling.Member.Domain/Fengling.Member.Domain.csproj @@ -0,0 +1,21 @@ + + + + net10.0 + enable + enable + true + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/src/Fengling.Member.Domain/GlobalUsings.cs b/src/Fengling.Member.Domain/GlobalUsings.cs new file mode 100644 index 0000000..eb48228 --- /dev/null +++ b/src/Fengling.Member.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.Member.Domain/Repositories/IOAuthAuthorizationRepository.cs b/src/Fengling.Member.Domain/Repositories/IOAuthAuthorizationRepository.cs new file mode 100644 index 0000000..d2cb707 --- /dev/null +++ b/src/Fengling.Member.Domain/Repositories/IOAuthAuthorizationRepository.cs @@ -0,0 +1,11 @@ +using Fengling.Member.Domain.Aggregates.Users; + +namespace Fengling.Member.Domain.Repositories; + +public interface IOAuthAuthorizationRepository +{ + Task GetByProviderAndOpenIdAsync(OAuthProvider provider, string openId, CancellationToken cancellationToken = default); + Task GetByMemberIdAndProviderAsync(long memberId, OAuthProvider provider, CancellationToken cancellationToken = default); + Task> GetByMemberIdAsync(long memberId, CancellationToken cancellationToken = default); + Task ExistsByProviderAndOpenIdAsync(OAuthProvider provider, string openId, CancellationToken cancellationToken = default); +} diff --git a/src/Fengling.Member.Infrastructure/ApplicationDbContext.cs b/src/Fengling.Member.Infrastructure/ApplicationDbContext.cs new file mode 100644 index 0000000..2835f5d --- /dev/null +++ b/src/Fengling.Member.Infrastructure/ApplicationDbContext.cs @@ -0,0 +1,32 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; +using NetCorePal.Extensions.DistributedTransactions.CAP.Persistence; + +namespace Fengling.Member.Infrastructure; + +public partial class ApplicationDbContext(DbContextOptions options, IMediator mediator) + : AppDbContextBase(options, mediator) + , IPostgreSqlCapDataStorage +{ + public DbSet Members => Set(); + public DbSet MemberTags => Set(); + public DbSet WechatAuthorizations => Set(); + public DbSet PointsAccounts => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + if (modelBuilder is null) + { + throw new ArgumentNullException(nameof(modelBuilder)); + } + + modelBuilder.ApplyConfigurationsFromAssembly(typeof(ApplicationDbContext).Assembly); + base.OnModelCreating(modelBuilder); + } + + protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) + { + ConfigureStronglyTypedIdValueConverter(configurationBuilder); + base.ConfigureConventions(configurationBuilder); + } +} diff --git a/src/Fengling.Member.Infrastructure/DesignTimeApplicationDbContextFactory.cs b/src/Fengling.Member.Infrastructure/DesignTimeApplicationDbContextFactory.cs new file mode 100644 index 0000000..7dec12c --- /dev/null +++ b/src/Fengling.Member.Infrastructure/DesignTimeApplicationDbContextFactory.cs @@ -0,0 +1,27 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.Extensions.DependencyInjection; + +namespace Fengling.Member.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.Member.Infrastructure/EntityConfigurations/MemberEntityTypeConfiguration.cs b/src/Fengling.Member.Infrastructure/EntityConfigurations/MemberEntityTypeConfiguration.cs new file mode 100644 index 0000000..27800a8 --- /dev/null +++ b/src/Fengling.Member.Infrastructure/EntityConfigurations/MemberEntityTypeConfiguration.cs @@ -0,0 +1,80 @@ +using Fengling.Member.Domain.Aggregates.Users; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace Fengling.Member.Infrastructure.EntityConfigurations; + +public class MemberEntityTypeConfiguration : IEntityTypeConfiguration +{ + private static readonly ValueConverter _statusConverter = new( + v => v.Value, + v => MemberStatus.FromValue(v)); + + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("fls_member"); + + builder.HasKey(m => m.Id); + + builder.Property(m => m.Id) + .HasColumnName("id") + .UseIdentityColumn(); + + builder.Property(m => m.TenantId) + .HasColumnName("tenant_id") + .IsRequired(); + + builder.Property(m => m.PhoneNumber) + .HasColumnName("phone_number") + .HasMaxLength(20); + + builder.Property(m => m.OpenId) + .HasColumnName("open_id") + .HasMaxLength(64); + + builder.Property(m => m.UnionId) + .HasColumnName("union_id") + .HasMaxLength(64); + + builder.Property(m => m.Status) + .HasColumnName("status") + .HasMaxLength(20) + .HasConversion(_statusConverter); + + builder.Property(m => m.CreatedAt) + .HasColumnName("created_at") + .IsRequired(); + + builder.Property(m => m.UpdatedAt) + .HasColumnName("updated_at"); + + builder.Property(m => m.Version) + .HasColumnName("version") + .IsRequired() + .HasDefaultValue(1) + .IsConcurrencyToken(); + + builder.HasIndex(m => new { m.TenantId, m.PhoneNumber }) + .HasDatabaseName("idx_member_tenant_phone"); + + builder.HasIndex(m => m.OpenId) + .HasDatabaseName("idx_member_openid"); + + builder.HasIndex(m => m.UnionId) + .HasDatabaseName("idx_member_unionid"); + + builder.HasIndex(m => m.TenantId) + .HasDatabaseName("idx_member_tenantid"); + + builder.HasMany(m => m.Tags) + .WithOne() + .HasForeignKey(t => t.MemberId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasMany(m => m.OAuthAuthorizations) + .WithOne() + .HasForeignKey(w => w.MemberId) + .OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/src/Fengling.Member.Infrastructure/EntityConfigurations/MemberTagEntityTypeConfiguration.cs b/src/Fengling.Member.Infrastructure/EntityConfigurations/MemberTagEntityTypeConfiguration.cs new file mode 100644 index 0000000..9bef5c1 --- /dev/null +++ b/src/Fengling.Member.Infrastructure/EntityConfigurations/MemberTagEntityTypeConfiguration.cs @@ -0,0 +1,43 @@ +using Fengling.Member.Domain.Aggregates.Users; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Fengling.Member.Infrastructure.EntityConfigurations; + +public class MemberTagEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("fls_member_tag"); + + builder.HasKey(t => t.Id); + + builder.Property(t => t.Id) + .HasColumnName("id") + .UseIdentityColumn(); + + builder.Property(t => t.MemberId) + .HasColumnName("member_id") + .IsRequired(); + + builder.Property(t => t.TagId) + .HasColumnName("tag_id") + .HasMaxLength(50) + .IsRequired(); + + builder.Property(t => t.TagName) + .HasColumnName("tag_name") + .HasMaxLength(100); + + builder.Property(t => t.CreatedAt) + .HasColumnName("created_at") + .IsRequired(); + + builder.HasIndex(t => new { t.MemberId, t.TagId }) + .HasDatabaseName("idx_membertag_member_tag") + .IsUnique(); + + builder.HasIndex(t => t.TagId) + .HasDatabaseName("idx_membertag_tagid"); + } +} diff --git a/src/Fengling.Member.Infrastructure/EntityConfigurations/PointsAccountEntityTypeConfiguration.cs b/src/Fengling.Member.Infrastructure/EntityConfigurations/PointsAccountEntityTypeConfiguration.cs new file mode 100644 index 0000000..52a39c7 --- /dev/null +++ b/src/Fengling.Member.Infrastructure/EntityConfigurations/PointsAccountEntityTypeConfiguration.cs @@ -0,0 +1,57 @@ +using Fengling.Member.Domain.Aggregates.PointsModel; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Fengling.Member.Infrastructure.EntityConfigurations; + +public class PointsAccountEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("mka_integraldetails"); + + builder.HasKey(p => p.Id); + + builder.Property(p => p.Id) + .HasColumnName("id") + .UseIdentityColumn(); + + builder.Property(p => p.MemberId) + .HasColumnName("user_id") + .IsRequired(); + + builder.Property(p => p.TenantId) + .HasColumnName("tenant_id") + .IsRequired(); + + builder.Property(p => p.TotalPoints) + .HasColumnName("points") + .IsRequired() + .HasDefaultValue(0); + + builder.Property(p => p.FrozenPoints) + .HasColumnName("frozen_points") + .IsRequired() + .HasDefaultValue(0); + + builder.Property(p => p.Version) + .HasColumnName("version") + .IsRequired() + .HasDefaultValue(1) + .IsConcurrencyToken(); + + builder.Property(p => p.CreatedAt) + .HasColumnName("created_at") + .IsRequired(); + + builder.Property(p => p.UpdatedAt) + .HasColumnName("updated_at"); + + builder.HasIndex(p => p.MemberId) + .HasDatabaseName("idx_points_account_memberid") + .IsUnique(); + + builder.HasIndex(p => new { p.MemberId, p.TenantId }) + .HasDatabaseName("idx_points_account_member_tenant"); + } +} diff --git a/src/Fengling.Member.Infrastructure/EntityConfigurations/WechatAuthorizationEntityTypeConfiguration.cs b/src/Fengling.Member.Infrastructure/EntityConfigurations/WechatAuthorizationEntityTypeConfiguration.cs new file mode 100644 index 0000000..7afb696 --- /dev/null +++ b/src/Fengling.Member.Infrastructure/EntityConfigurations/WechatAuthorizationEntityTypeConfiguration.cs @@ -0,0 +1,49 @@ +using Fengling.Member.Domain.Aggregates.Users; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Fengling.Member.Infrastructure.EntityConfigurations; + +public class WechatAuthorizationEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("fls_wechat_authorization"); + + builder.HasKey(w => w.Id); + + builder.Property(w => w.Id) + .HasColumnName("id") + .UseIdentityColumn(); + + builder.Property(w => w.MemberId) + .HasColumnName("member_id") + .IsRequired(); + + builder.Property(w => w.OpenId) + .HasColumnName("open_id") + .HasMaxLength(64) + .IsRequired(); + + builder.Property(w => w.UnionId) + .HasColumnName("union_id") + .HasMaxLength(64); + + builder.Property(w => w.AuthorizedAt) + .HasColumnName("authorized_at") + .IsRequired(); + + builder.Property(w => w.LastLoginAt) + .HasColumnName("last_login_at"); + + builder.HasIndex(w => w.OpenId) + .HasDatabaseName("idx_wechat_auth_openid") + .IsUnique(); + + builder.HasIndex(w => w.UnionId) + .HasDatabaseName("idx_wechat_auth_unionid"); + + builder.HasIndex(w => w.MemberId) + .HasDatabaseName("idx_wechat_auth_memberid"); + } +} diff --git a/src/Fengling.Member.Infrastructure/Fengling.Member.Infrastructure.csproj b/src/Fengling.Member.Infrastructure/Fengling.Member.Infrastructure.csproj new file mode 100644 index 0000000..9169143 --- /dev/null +++ b/src/Fengling.Member.Infrastructure/Fengling.Member.Infrastructure.csproj @@ -0,0 +1,31 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + diff --git a/src/Fengling.Member.Infrastructure/GlobalUsings.cs b/src/Fengling.Member.Infrastructure/GlobalUsings.cs new file mode 100644 index 0000000..1aba05f --- /dev/null +++ b/src/Fengling.Member.Infrastructure/GlobalUsings.cs @@ -0,0 +1,7 @@ +global using NetCorePal.Extensions.Domain; +global using NetCorePal.Extensions.Primitives; +global using NetCorePal.Extensions.Repository; +global using NetCorePal.Extensions.Repository.EntityFrameworkCore; +global using MediatR; +global using Microsoft.EntityFrameworkCore; +global using Microsoft.EntityFrameworkCore.Metadata.Builders; diff --git a/src/Fengling.Member.Infrastructure/Migrations/20260122054728_Init.Designer.cs b/src/Fengling.Member.Infrastructure/Migrations/20260122054728_Init.Designer.cs new file mode 100644 index 0000000..2a1a3f5 --- /dev/null +++ b/src/Fengling.Member.Infrastructure/Migrations/20260122054728_Init.Designer.cs @@ -0,0 +1,140 @@ +// +using System; +using Fengling.Member.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.Member.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.Member.Infrastructure/Migrations/20260122054728_Init.cs b/src/Fengling.Member.Infrastructure/Migrations/20260122054728_Init.cs new file mode 100644 index 0000000..f4b513e --- /dev/null +++ b/src/Fengling.Member.Infrastructure/Migrations/20260122054728_Init.cs @@ -0,0 +1,101 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Fengling.Member.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.Member.Infrastructure/Migrations/20260205051658_AddMemberAndPointsEntities.Designer.cs b/src/Fengling.Member.Infrastructure/Migrations/20260205051658_AddMemberAndPointsEntities.Designer.cs new file mode 100644 index 0000000..a3cdd7f --- /dev/null +++ b/src/Fengling.Member.Infrastructure/Migrations/20260205051658_AddMemberAndPointsEntities.Designer.cs @@ -0,0 +1,429 @@ +// +using System; +using Fengling.Member.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.Member.Infrastructure.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260205051658_AddMemberAndPointsEntities")] + partial class AddMemberAndPointsEntities + { + /// + 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("Fengling.Member.Domain.Aggregates.PointsModel.PointsAccount", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("FrozenPoints") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("frozen_points"); + + b.Property("MemberId") + .HasColumnType("bigint") + .HasColumnName("user_id"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasColumnName("tenant_id"); + + b.Property("TotalPoints") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("points"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1) + .HasColumnName("version"); + + b.HasKey("Id"); + + b.HasIndex("MemberId") + .IsUnique() + .HasDatabaseName("idx_points_account_memberid"); + + b.HasIndex("MemberId", "TenantId") + .HasDatabaseName("idx_points_account_member_tenant"); + + b.ToTable("mka_integraldetails", (string)null); + }); + + modelBuilder.Entity("Fengling.Member.Domain.Aggregates.PointsModel.PointsTransaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MemberId") + .HasColumnType("bigint"); + + b.Property("Points") + .HasColumnType("integer"); + + b.Property("PointsAccountId") + .HasColumnType("bigint"); + + b.Property("Remark") + .HasColumnType("text"); + + b.Property("SourceId") + .IsRequired() + .HasColumnType("text"); + + b.Property("TransactionType") + .IsRequired() + .HasColumnType("text"); + + b.Property("TransactionTypeCategory") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("PointsAccountId"); + + b.ToTable("PointsTransaction"); + }); + + modelBuilder.Entity("Fengling.Member.Domain.Aggregates.Users.MemberEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("OpenId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("open_id"); + + b.Property("PhoneNumber") + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("phone_number"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasColumnName("tenant_id"); + + b.Property("UnionId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("union_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1) + .HasColumnName("version"); + + b.HasKey("Id"); + + b.HasIndex("OpenId") + .HasDatabaseName("idx_member_openid"); + + b.HasIndex("TenantId") + .HasDatabaseName("idx_member_tenantid"); + + b.HasIndex("UnionId") + .HasDatabaseName("idx_member_unionid"); + + b.HasIndex("TenantId", "PhoneNumber") + .HasDatabaseName("idx_member_tenant_phone"); + + b.ToTable("fls_member", (string)null); + }); + + modelBuilder.Entity("Fengling.Member.Domain.Aggregates.Users.MemberTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("MemberId") + .HasColumnType("bigint") + .HasColumnName("member_id"); + + b.Property("TagId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("tag_id"); + + b.Property("TagName") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("tag_name"); + + b.HasKey("Id"); + + b.HasIndex("TagId") + .HasDatabaseName("idx_membertag_tagid"); + + b.HasIndex("MemberId", "TagId") + .IsUnique() + .HasDatabaseName("idx_membertag_member_tag"); + + b.ToTable("fls_member_tag", (string)null); + }); + + modelBuilder.Entity("Fengling.Member.Domain.Aggregates.Users.WechatAuthorization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AuthorizedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("authorized_at"); + + b.Property("LastLoginAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_login_at"); + + b.Property("MemberId") + .HasColumnType("bigint") + .HasColumnName("member_id"); + + b.Property("OpenId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("open_id"); + + b.Property("UnionId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("union_id"); + + b.HasKey("Id"); + + b.HasIndex("MemberId") + .HasDatabaseName("idx_wechat_auth_memberid"); + + b.HasIndex("OpenId") + .IsUnique() + .HasDatabaseName("idx_wechat_auth_openid"); + + b.HasIndex("UnionId") + .HasDatabaseName("idx_wechat_auth_unionid"); + + b.ToTable("fls_wechat_authorization", (string)null); + }); + + 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); + }); + + modelBuilder.Entity("Fengling.Member.Domain.Aggregates.PointsModel.PointsTransaction", b => + { + b.HasOne("Fengling.Member.Domain.Aggregates.PointsModel.PointsAccount", null) + .WithMany("Transactions") + .HasForeignKey("PointsAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Fengling.Member.Domain.Aggregates.Users.MemberTag", b => + { + b.HasOne("Fengling.Member.Domain.Aggregates.Users.MemberEntity", null) + .WithMany("Tags") + .HasForeignKey("MemberId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Fengling.Member.Domain.Aggregates.Users.WechatAuthorization", b => + { + b.HasOne("Fengling.Member.Domain.Aggregates.Users.MemberEntity", null) + .WithMany("WechatAuthorizations") + .HasForeignKey("MemberId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Fengling.Member.Domain.Aggregates.PointsModel.PointsAccount", b => + { + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Fengling.Member.Domain.Aggregates.Users.MemberEntity", b => + { + b.Navigation("Tags"); + + b.Navigation("WechatAuthorizations"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Fengling.Member.Infrastructure/Migrations/20260205051658_AddMemberAndPointsEntities.cs b/src/Fengling.Member.Infrastructure/Migrations/20260205051658_AddMemberAndPointsEntities.cs new file mode 100644 index 0000000..7fd7107 --- /dev/null +++ b/src/Fengling.Member.Infrastructure/Migrations/20260205051658_AddMemberAndPointsEntities.cs @@ -0,0 +1,208 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Fengling.Member.Infrastructure.Migrations +{ + /// + public partial class AddMemberAndPointsEntities : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "fls_member", + columns: table => new + { + id = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + tenant_id = table.Column(type: "bigint", nullable: false), + phone_number = table.Column(type: "character varying(20)", maxLength: 20, nullable: true), + open_id = table.Column(type: "character varying(64)", maxLength: 64, nullable: true), + union_id = table.Column(type: "character varying(64)", maxLength: 64, nullable: true), + status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + updated_at = table.Column(type: "timestamp with time zone", nullable: true), + version = table.Column(type: "integer", nullable: false, defaultValue: 1) + }, + constraints: table => + { + table.PrimaryKey("PK_fls_member", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "mka_integraldetails", + columns: table => new + { + id = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + user_id = table.Column(type: "bigint", nullable: false), + tenant_id = table.Column(type: "bigint", nullable: false), + points = table.Column(type: "integer", nullable: false, defaultValue: 0), + frozen_points = table.Column(type: "integer", nullable: false, defaultValue: 0), + version = table.Column(type: "integer", nullable: false, defaultValue: 1), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + updated_at = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_mka_integraldetails", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "fls_member_tag", + columns: table => new + { + id = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + member_id = table.Column(type: "bigint", nullable: false), + tag_id = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + tag_name = table.Column(type: "character varying(100)", maxLength: 100, nullable: true), + created_at = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_fls_member_tag", x => x.id); + table.ForeignKey( + name: "FK_fls_member_tag_fls_member_member_id", + column: x => x.member_id, + principalTable: "fls_member", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "fls_wechat_authorization", + columns: table => new + { + id = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + member_id = table.Column(type: "bigint", nullable: false), + open_id = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + union_id = table.Column(type: "character varying(64)", maxLength: 64, nullable: true), + authorized_at = table.Column(type: "timestamp with time zone", nullable: false), + last_login_at = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_fls_wechat_authorization", x => x.id); + table.ForeignKey( + name: "FK_fls_wechat_authorization_fls_member_member_id", + column: x => x.member_id, + principalTable: "fls_member", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "PointsTransaction", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + PointsAccountId = table.Column(type: "bigint", nullable: false), + MemberId = table.Column(type: "bigint", nullable: false), + Points = table.Column(type: "integer", nullable: false), + TransactionType = table.Column(type: "text", nullable: false), + SourceId = table.Column(type: "text", nullable: false), + TransactionTypeCategory = table.Column(type: "integer", nullable: false), + Remark = table.Column(type: "text", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_PointsTransaction", x => x.Id); + table.ForeignKey( + name: "FK_PointsTransaction_mka_integraldetails_PointsAccountId", + column: x => x.PointsAccountId, + principalTable: "mka_integraldetails", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "idx_member_openid", + table: "fls_member", + column: "open_id"); + + migrationBuilder.CreateIndex( + name: "idx_member_tenant_phone", + table: "fls_member", + columns: new[] { "tenant_id", "phone_number" }); + + migrationBuilder.CreateIndex( + name: "idx_member_tenantid", + table: "fls_member", + column: "tenant_id"); + + migrationBuilder.CreateIndex( + name: "idx_member_unionid", + table: "fls_member", + column: "union_id"); + + migrationBuilder.CreateIndex( + name: "idx_membertag_member_tag", + table: "fls_member_tag", + columns: new[] { "member_id", "tag_id" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "idx_membertag_tagid", + table: "fls_member_tag", + column: "tag_id"); + + migrationBuilder.CreateIndex( + name: "idx_wechat_auth_memberid", + table: "fls_wechat_authorization", + column: "member_id"); + + migrationBuilder.CreateIndex( + name: "idx_wechat_auth_openid", + table: "fls_wechat_authorization", + column: "open_id", + unique: true); + + migrationBuilder.CreateIndex( + name: "idx_wechat_auth_unionid", + table: "fls_wechat_authorization", + column: "union_id"); + + migrationBuilder.CreateIndex( + name: "idx_points_account_member_tenant", + table: "mka_integraldetails", + columns: new[] { "user_id", "tenant_id" }); + + migrationBuilder.CreateIndex( + name: "idx_points_account_memberid", + table: "mka_integraldetails", + column: "user_id", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_PointsTransaction_PointsAccountId", + table: "PointsTransaction", + column: "PointsAccountId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "fls_member_tag"); + + migrationBuilder.DropTable( + name: "fls_wechat_authorization"); + + migrationBuilder.DropTable( + name: "PointsTransaction"); + + migrationBuilder.DropTable( + name: "fls_member"); + + migrationBuilder.DropTable( + name: "mka_integraldetails"); + } + } +} diff --git a/src/Fengling.Member.Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs b/src/Fengling.Member.Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs new file mode 100644 index 0000000..034b851 --- /dev/null +++ b/src/Fengling.Member.Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs @@ -0,0 +1,426 @@ +// +using System; +using Fengling.Member.Infrastructure; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Fengling.Member.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("Fengling.Member.Domain.Aggregates.PointsModel.PointsAccount", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("FrozenPoints") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("frozen_points"); + + b.Property("MemberId") + .HasColumnType("bigint") + .HasColumnName("user_id"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasColumnName("tenant_id"); + + b.Property("TotalPoints") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("points"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1) + .HasColumnName("version"); + + b.HasKey("Id"); + + b.HasIndex("MemberId") + .IsUnique() + .HasDatabaseName("idx_points_account_memberid"); + + b.HasIndex("MemberId", "TenantId") + .HasDatabaseName("idx_points_account_member_tenant"); + + b.ToTable("mka_integraldetails", (string)null); + }); + + modelBuilder.Entity("Fengling.Member.Domain.Aggregates.PointsModel.PointsTransaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MemberId") + .HasColumnType("bigint"); + + b.Property("Points") + .HasColumnType("integer"); + + b.Property("PointsAccountId") + .HasColumnType("bigint"); + + b.Property("Remark") + .HasColumnType("text"); + + b.Property("SourceId") + .IsRequired() + .HasColumnType("text"); + + b.Property("TransactionType") + .IsRequired() + .HasColumnType("text"); + + b.Property("TransactionTypeCategory") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("PointsAccountId"); + + b.ToTable("PointsTransaction"); + }); + + modelBuilder.Entity("Fengling.Member.Domain.Aggregates.Users.MemberEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("OpenId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("open_id"); + + b.Property("PhoneNumber") + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("phone_number"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasColumnName("tenant_id"); + + b.Property("UnionId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("union_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1) + .HasColumnName("version"); + + b.HasKey("Id"); + + b.HasIndex("OpenId") + .HasDatabaseName("idx_member_openid"); + + b.HasIndex("TenantId") + .HasDatabaseName("idx_member_tenantid"); + + b.HasIndex("UnionId") + .HasDatabaseName("idx_member_unionid"); + + b.HasIndex("TenantId", "PhoneNumber") + .HasDatabaseName("idx_member_tenant_phone"); + + b.ToTable("fls_member", (string)null); + }); + + modelBuilder.Entity("Fengling.Member.Domain.Aggregates.Users.MemberTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("MemberId") + .HasColumnType("bigint") + .HasColumnName("member_id"); + + b.Property("TagId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("tag_id"); + + b.Property("TagName") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("tag_name"); + + b.HasKey("Id"); + + b.HasIndex("TagId") + .HasDatabaseName("idx_membertag_tagid"); + + b.HasIndex("MemberId", "TagId") + .IsUnique() + .HasDatabaseName("idx_membertag_member_tag"); + + b.ToTable("fls_member_tag", (string)null); + }); + + modelBuilder.Entity("Fengling.Member.Domain.Aggregates.Users.WechatAuthorization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AuthorizedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("authorized_at"); + + b.Property("LastLoginAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_login_at"); + + b.Property("MemberId") + .HasColumnType("bigint") + .HasColumnName("member_id"); + + b.Property("OpenId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("open_id"); + + b.Property("UnionId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("union_id"); + + b.HasKey("Id"); + + b.HasIndex("MemberId") + .HasDatabaseName("idx_wechat_auth_memberid"); + + b.HasIndex("OpenId") + .IsUnique() + .HasDatabaseName("idx_wechat_auth_openid"); + + b.HasIndex("UnionId") + .HasDatabaseName("idx_wechat_auth_unionid"); + + b.ToTable("fls_wechat_authorization", (string)null); + }); + + 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); + }); + + modelBuilder.Entity("Fengling.Member.Domain.Aggregates.PointsModel.PointsTransaction", b => + { + b.HasOne("Fengling.Member.Domain.Aggregates.PointsModel.PointsAccount", null) + .WithMany("Transactions") + .HasForeignKey("PointsAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Fengling.Member.Domain.Aggregates.Users.MemberTag", b => + { + b.HasOne("Fengling.Member.Domain.Aggregates.Users.MemberEntity", null) + .WithMany("Tags") + .HasForeignKey("MemberId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Fengling.Member.Domain.Aggregates.Users.WechatAuthorization", b => + { + b.HasOne("Fengling.Member.Domain.Aggregates.Users.MemberEntity", null) + .WithMany("WechatAuthorizations") + .HasForeignKey("MemberId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Fengling.Member.Domain.Aggregates.PointsModel.PointsAccount", b => + { + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Fengling.Member.Domain.Aggregates.Users.MemberEntity", b => + { + b.Navigation("Tags"); + + b.Navigation("WechatAuthorizations"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Fengling.Member.Infrastructure/Repositories/MemberRepository.cs b/src/Fengling.Member.Infrastructure/Repositories/MemberRepository.cs new file mode 100644 index 0000000..0c4d027 --- /dev/null +++ b/src/Fengling.Member.Infrastructure/Repositories/MemberRepository.cs @@ -0,0 +1,52 @@ +using Fengling.Member.Domain.Aggregates.Users; + +namespace Fengling.Member.Infrastructure.Repositories; + +public interface IMemberRepository : IRepository +{ + Task GetByPhoneNumberAsync(long tenantId, string phoneNumber, CancellationToken cancellationToken = default); + Task GetByOpenIdAsync(string openId, CancellationToken cancellationToken = default); + Task GetByUnionIdAsync(string unionId, CancellationToken cancellationToken = default); + Task> GetByTenantIdAsync(long tenantId, int page = 1, int pageSize = 20, CancellationToken cancellationToken = default); + Task ExistsByPhoneNumberAsync(long tenantId, string phoneNumber, CancellationToken cancellationToken = default); + Task ExistsByOpenIdAsync(string openId, CancellationToken cancellationToken = default); +} + +public class MemberRepository(ApplicationDbContext context) : + RepositoryBase(context), IMemberRepository +{ + public async Task GetByPhoneNumberAsync(long tenantId, string phoneNumber, CancellationToken cancellationToken = default) + { + return await DbContext.Members.FirstOrDefaultAsync(m => m.TenantId == tenantId && m.PhoneNumber == phoneNumber, cancellationToken); + } + + public async Task GetByOpenIdAsync(string openId, CancellationToken cancellationToken = default) + { + return await DbContext.Members.FirstOrDefaultAsync(m => m.OpenId == openId, cancellationToken); + } + + public async Task GetByUnionIdAsync(string unionId, CancellationToken cancellationToken = default) + { + return await DbContext.Members.FirstOrDefaultAsync(m => m.UnionId == unionId, cancellationToken); + } + + public async Task> GetByTenantIdAsync(long tenantId, int page = 1, int pageSize = 20, CancellationToken cancellationToken = default) + { + return await DbContext.Members + .Where(m => m.TenantId == tenantId) + .OrderByDescending(m => m.CreatedAt) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(cancellationToken); + } + + public async Task ExistsByPhoneNumberAsync(long tenantId, string phoneNumber, CancellationToken cancellationToken = default) + { + return await DbContext.Members.AnyAsync(m => m.TenantId == tenantId && m.PhoneNumber == phoneNumber, cancellationToken); + } + + public async Task ExistsByOpenIdAsync(string openId, CancellationToken cancellationToken = default) + { + return await DbContext.Members.AnyAsync(m => m.OpenId == openId, cancellationToken); + } +} diff --git a/src/Fengling.Member.Infrastructure/Repositories/PointsAccountRepository.cs b/src/Fengling.Member.Infrastructure/Repositories/PointsAccountRepository.cs new file mode 100644 index 0000000..0eec8b4 --- /dev/null +++ b/src/Fengling.Member.Infrastructure/Repositories/PointsAccountRepository.cs @@ -0,0 +1,51 @@ +using Fengling.Member.Domain.Aggregates.PointsModel; + +namespace Fengling.Member.Infrastructure.Repositories; + +public interface IPointsAccountRepository : IRepository +{ + Task GetByMemberIdAsync(long memberId, CancellationToken cancellationToken = default); + Task GetByMemberIdAndTenantIdAsync(long memberId, long tenantId, CancellationToken cancellationToken = default); + Task GetByMemberIdWithLockAsync(long memberId, CancellationToken cancellationToken = default); + Task> GetByTenantIdAsync(long tenantId, int page = 1, int pageSize = 20, CancellationToken cancellationToken = default); + Task ExistsByMemberIdAsync(long memberId, CancellationToken cancellationToken = default); +} + +public class PointsAccountRepository(ApplicationDbContext context) : + RepositoryBase(context), IPointsAccountRepository +{ + public async Task GetByMemberIdAsync(long memberId, CancellationToken cancellationToken = default) + { + return await DbContext.PointsAccounts.FirstOrDefaultAsync(p => p.MemberId == memberId, cancellationToken); + } + + public async Task GetByMemberIdAndTenantIdAsync(long memberId, long tenantId, CancellationToken cancellationToken = default) + { + return await DbContext.PointsAccounts.FirstOrDefaultAsync(p => p.MemberId == memberId && p.TenantId == tenantId, cancellationToken); + } + + public async Task GetByMemberIdWithLockAsync(long memberId, CancellationToken cancellationToken = default) + { + var account = await DbContext.PointsAccounts.FirstOrDefaultAsync(p => p.MemberId == memberId, cancellationToken); + if (account != null) + { + await DbContext.Entry(account).Reference(p => p.Transactions).LoadAsync(cancellationToken); + } + return account; + } + + public async Task> GetByTenantIdAsync(long tenantId, int page = 1, int pageSize = 20, CancellationToken cancellationToken = default) + { + return await DbContext.PointsAccounts + .Where(p => p.TenantId == tenantId) + .OrderByDescending(p => p.CreatedAt) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(cancellationToken); + } + + public async Task ExistsByMemberIdAsync(long memberId, CancellationToken cancellationToken = default) + { + return await DbContext.PointsAccounts.AnyAsync(p => p.MemberId == memberId, cancellationToken); + } +} diff --git a/src/Fengling.Member.Web/Application/Hubs/ChatHub.cs b/src/Fengling.Member.Web/Application/Hubs/ChatHub.cs new file mode 100644 index 0000000..96249f7 --- /dev/null +++ b/src/Fengling.Member.Web/Application/Hubs/ChatHub.cs @@ -0,0 +1,14 @@ +namespace Fengling.Member.Web.Application.Hubs; + +public interface IChatClient +{ + Task ReceiveMessage(string user, string message); +} + +public class ChatHub : Microsoft.AspNetCore.SignalR.Hub +{ + public async Task SendMessage(string user, string message) + { + await Clients.All.ReceiveMessage(user, message); + } +} diff --git a/src/Fengling.Member.Web/Clients/IUserServiceClient.cs b/src/Fengling.Member.Web/Clients/IUserServiceClient.cs new file mode 100644 index 0000000..2ad952b --- /dev/null +++ b/src/Fengling.Member.Web/Clients/IUserServiceClient.cs @@ -0,0 +1,11 @@ +using Refit; + +namespace Fengling.Member.Web.Clients; + +public interface IUserServiceClient +{ + [Get("/users/{userId}")] + Task GetUserAsync(long userId); +} + +public record UserDto(string Name, string Email, string Phone); \ No newline at end of file diff --git a/src/Fengling.Member.Web/Dockerfile b/src/Fengling.Member.Web/Dockerfile new file mode 100644 index 0000000..a9ea82c --- /dev/null +++ b/src/Fengling.Member.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.Member.Web/Fengling.Member.Web.csproj" +WORKDIR "/src/src/Fengling.Member.Web" +RUN dotnet build "Fengling.Member.Web.csproj" -c Release -o /app/build + +FROM build AS publish +RUN dotnet publish "Fengling.Member.Web.csproj" -c Release -o /app/publish + +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "Fengling.Member.Web.dll"] diff --git a/src/Fengling.Member.Web/Endpoints/HelloEndpoint.cs b/src/Fengling.Member.Web/Endpoints/HelloEndpoint.cs new file mode 100644 index 0000000..26ec64a --- /dev/null +++ b/src/Fengling.Member.Web/Endpoints/HelloEndpoint.cs @@ -0,0 +1,25 @@ +using FastEndpoints; +using FastEndpoints.Swagger; +using Microsoft.AspNetCore.Authorization; +using NetCorePal.Extensions.Dto; + +namespace Fengling.Member.Web.Endpoints; + +/// +/// Hello +/// +public class HelloEndpoint : EndpointWithoutRequest> +{ + public override void Configure() + { + Tags("Hello"); + Description(b => b.AutoTagOverride("Hello")); + Get("/api/hello"); + AllowAnonymous(); + } + + public override Task HandleAsync(CancellationToken ct) + { + return Send.OkAsync("hello".AsResponseData(), cancellation: ct); + } +} diff --git a/src/Fengling.Member.Web/Endpoints/v1/AlipayBindingEndpoints.cs b/src/Fengling.Member.Web/Endpoints/v1/AlipayBindingEndpoints.cs new file mode 100644 index 0000000..d019ccd --- /dev/null +++ b/src/Fengling.Member.Web/Endpoints/v1/AlipayBindingEndpoints.cs @@ -0,0 +1,62 @@ +using FastEndpoints; +using MediatR; +using Microsoft.AspNetCore.Mvc; +using Fengling.Member.Application.Commands.Member; + +namespace Fengling.Member.Web.Endpoints.v1; + +public class BindAlipayEndpoint : Endpoint +{ + private readonly IMediator _mediator; + + public BindAlipayEndpoint(IMediator mediator) + { + _mediator = mediator; + } + + public override void Configure() + { + Post("/api/v1/members/{MemberId}/alipay"); + Summary(s => + { + s.Summary = "绑定支付宝"; + s.Description = "为会员绑定支付宝账号"; + }); + } + + public override async Task HandleAsync(BindAlipayRequest req, CancellationToken ct) + { + var command = new BindAlipayCommand + { + MemberId = req.MemberId, + AlipayOpenId = req.AlipayOpenId, + AlipayUserId = req.AlipayUserId + }; + + var result = await _mediator.Send(command, ct); + + Response = new BindAlipayResponse + { + MemberId = result.MemberId, + AlipayOpenId = result.AlipayOpenId, + AlipayUserId = result.AlipayUserId, + BoundAt = result.BoundAt + }; + } +} + +public class BindAlipayRequest +{ + [FromRoute] + public long MemberId { get; set; } + public string AlipayOpenId { get; set; } = string.Empty; + public string? AlipayUserId { get; set; } +} + +public class BindAlipayResponse +{ + public long MemberId { get; set; } + public string AlipayOpenId { get; set; } = string.Empty; + public string? AlipayUserId { get; set; } + public DateTime BoundAt { get; set; } +} diff --git a/src/Fengling.Member.Web/Endpoints/v1/MemberEndpoints.cs b/src/Fengling.Member.Web/Endpoints/v1/MemberEndpoints.cs new file mode 100644 index 0000000..42bc974 --- /dev/null +++ b/src/Fengling.Member.Web/Endpoints/v1/MemberEndpoints.cs @@ -0,0 +1,69 @@ +using FastEndpoints; +using MediatR; +using Fengling.Member.Application.Commands.Member; + +namespace Fengling.Member.Web.Endpoints.v1; + +public class RegisterMemberEndpoint : Endpoint +{ + private readonly IMediator _mediator; + + public RegisterMemberEndpoint(IMediator mediator) + { + _mediator = mediator; + } + + public override void Configure() + { + Post("/api/v1/members"); + AllowAnonymous(); + Summary(s => + { + s.Summary = "注册会员"; + s.Description = "根据手机号或微信OpenID注册新会员"; + }); + } + + public override async Task HandleAsync(RegisterMemberRequest req, CancellationToken ct) + { + var command = new RegisterMemberCommand + { + TenantId = req.TenantId, + PhoneNumber = req.PhoneNumber, + OpenId = req.OpenId, + UnionId = req.UnionId, + Source = req.Source + }; + + var result = await _mediator.Send(command, ct); + + Response = new RegisterMemberResponse + { + MemberId = result.MemberId, + TenantId = result.TenantId, + PhoneNumber = result.PhoneNumber, + OpenId = result.OpenId, + Status = result.Status.ToString(), + RegisteredAt = result.RegisteredAt + }; + } +} + +public class RegisterMemberRequest +{ + public long TenantId { get; set; } + public string? PhoneNumber { get; set; } + public string? OpenId { get; set; } + public string? UnionId { get; set; } + public string? Source { get; set; } +} + +public class RegisterMemberResponse +{ + public long MemberId { get; set; } + public long TenantId { get; set; } + public string? PhoneNumber { get; set; } + public string? OpenId { get; set; } + public string Status { get; set; } = string.Empty; + public DateTime RegisteredAt { get; set; } +} diff --git a/src/Fengling.Member.Web/Endpoints/v1/OAuthBindingEndpoints.cs b/src/Fengling.Member.Web/Endpoints/v1/OAuthBindingEndpoints.cs new file mode 100644 index 0000000..1391703 --- /dev/null +++ b/src/Fengling.Member.Web/Endpoints/v1/OAuthBindingEndpoints.cs @@ -0,0 +1,67 @@ +using FastEndpoints; +using MediatR; +using Microsoft.AspNetCore.Mvc; +using Fengling.Member.Application.Commands.Member; + +namespace Fengling.Member.Web.Endpoints.v1; + +public class BindOAuthEndpoint : Endpoint +{ + private readonly IMediator _mediator; + + public BindOAuthEndpoint(IMediator mediator) + { + _mediator = mediator; + } + + public override void Configure() + { + Post("/api/v1/members/{MemberId}/oauth"); + Summary(s => + { + s.Summary = "绑定第三方账号"; + s.Description = "为会员绑定微信、支付宝等第三方账号"; + }); + } + + public override async Task HandleAsync(BindOAuthRequest req, CancellationToken ct) + { + var command = new BindOAuthCommand + { + MemberId = req.MemberId, + Provider = req.Provider, + OpenId = req.OpenId, + UnionId = req.UnionId + }; + + var result = await _mediator.Send(command, ct); + + Response = new BindOAuthResponse + { + MemberId = result.MemberId, + Provider = result.Provider, + OpenId = result.OpenId, + UnionId = result.UnionId, + BoundAt = result.BoundAt + }; + } +} + +public class BindOAuthRequest +{ + [FromRoute] + public long MemberId { get; set; } + [FromRoute] + public Domain.Aggregates.Users.OAuthProvider Provider { get; set; } + public string OpenId { get; set; } = string.Empty; + public string? UnionId { get; set; } +} + +public class BindOAuthResponse +{ + public long MemberId { get; set; } + public Domain.Aggregates.Users.OAuthProvider Provider { get; set; } + public string OpenId { get; set; } = string.Empty; + public string? UnionId { get; set; } + public DateTime BoundAt { get; set; } +} diff --git a/src/Fengling.Member.Web/Endpoints/v1/PointsEndpoints.cs b/src/Fengling.Member.Web/Endpoints/v1/PointsEndpoints.cs new file mode 100644 index 0000000..147a6d2 --- /dev/null +++ b/src/Fengling.Member.Web/Endpoints/v1/PointsEndpoints.cs @@ -0,0 +1,68 @@ +using FastEndpoints; +using MediatR; +using Microsoft.AspNetCore.Mvc; +using Fengling.Member.Application.Commands.Points; + +namespace Fengling.Member.Web.Endpoints.v1; + +public class AddPointsEndpoint : Endpoint +{ + private readonly IMediator _mediator; + + public AddPointsEndpoint(IMediator mediator) + { + _mediator = mediator; + } + + public override void Configure() + { + Post("/api/v1/members/{MemberId}/points"); + Summary(s => + { + s.Summary = "增加积分"; + s.Description = "为会员增加积分"; + }); + } + + public override async Task HandleAsync(AddPointsRequest req, CancellationToken ct) + { + var command = new AddPointsCommand + { + MemberId = req.MemberId, + Points = req.Points, + TransactionType = req.TransactionType, + SourceId = req.SourceId, + Remark = req.Remark + }; + + var result = await _mediator.Send(command, ct); + + Response = new AddPointsResponse + { + AccountId = result.AccountId, + MemberId = result.MemberId, + AddedPoints = result.AddedPoints, + TotalPoints = result.TotalPoints, + TransactionAt = result.TransactionAt + }; + } +} + +public class AddPointsRequest +{ + [FromRoute] + public long MemberId { get; set; } + public int Points { get; set; } + public string TransactionType { get; set; } = string.Empty; + public string SourceId { get; set; } = string.Empty; + public string? Remark { get; set; } +} + +public class AddPointsResponse +{ + public long AccountId { get; set; } + public long MemberId { get; set; } + public int AddedPoints { get; set; } + public int TotalPoints { get; set; } + public DateTime TransactionAt { get; set; } +} diff --git a/src/Fengling.Member.Web/Endpoints/v1/WechatBindingEndpoints.cs b/src/Fengling.Member.Web/Endpoints/v1/WechatBindingEndpoints.cs new file mode 100644 index 0000000..d8c054c --- /dev/null +++ b/src/Fengling.Member.Web/Endpoints/v1/WechatBindingEndpoints.cs @@ -0,0 +1,62 @@ +using FastEndpoints; +using MediatR; +using Microsoft.AspNetCore.Mvc; +using Fengling.Member.Application.Commands.Member; + +namespace Fengling.Member.Web.Endpoints.v1; + +public class BindWechatEndpoint : Endpoint +{ + private readonly IMediator _mediator; + + public BindWechatEndpoint(IMediator mediator) + { + _mediator = mediator; + } + + public override void Configure() + { + Post("/api/v1/members/{MemberId}/wechat"); + Summary(s => + { + s.Summary = "绑定微信"; + s.Description = "为会员绑定微信OpenID"; + }); + } + + public override async Task HandleAsync(BindWechatRequest req, CancellationToken ct) + { + var command = new BindWechatCommand + { + MemberId = req.MemberId, + OpenId = req.OpenId, + UnionId = req.UnionId + }; + + var result = await _mediator.Send(command, ct); + + Response = new BindWechatResponse + { + MemberId = result.MemberId, + OpenId = result.OpenId, + UnionId = result.UnionId, + BoundAt = result.BoundAt + }; + } +} + +public class BindWechatRequest +{ + [FromRoute] + public long MemberId { get; set; } + public string OpenId { get; set; } = string.Empty; + public string? UnionId { get; set; } +} + +public class BindWechatResponse +{ + public long MemberId { get; set; } + public string OpenId { get; set; } = string.Empty; + public string? UnionId { get; set; } + public DateTime BoundAt { get; set; } +} diff --git a/src/Fengling.Member.Web/Extensions/StackExchangeRedisDataProtectionBuilderExtensions.cs b/src/Fengling.Member.Web/Extensions/StackExchangeRedisDataProtectionBuilderExtensions.cs new file mode 100644 index 0000000..0c60423 --- /dev/null +++ b/src/Fengling.Member.Web/Extensions/StackExchangeRedisDataProtectionBuilderExtensions.cs @@ -0,0 +1,38 @@ +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.DataProtection.KeyManagement; +using Microsoft.AspNetCore.DataProtection.StackExchangeRedis; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using StackExchange.Redis; + +namespace Fengling.Member.Web.Extensions; + +/// +/// Extension methods for configuring StackExchange.Redis-based data protection. +/// +public static class StackExchangeRedisDataProtectionBuilderExtensions +{ + /// + /// Configures data protection to persist keys to StackExchange.Redis. + /// This method resolves IConnectionMultiplexer from DI, making it work with both + /// Aspire (where AddRedisClient registers the multiplexer) and non-Aspire scenarios. + /// + /// The data protection builder. + /// The Redis key where data protection keys will be stored. + /// The data protection builder for chaining. + public static IDataProtectionBuilder PersistKeysToStackExchangeRedis( + this IDataProtectionBuilder builder, + RedisKey key) + { + builder.Services.AddSingleton>(services => + { + var connectionMultiplexer = services.GetRequiredService(); + return new ConfigureOptions(options => + { + options.XmlRepository = new RedisXmlRepository(() => connectionMultiplexer.GetDatabase(), key); + }); + }); + + return builder; + } +} diff --git a/src/Fengling.Member.Web/Extensions/SwaggerGenOptionsExtionsions.cs b/src/Fengling.Member.Web/Extensions/SwaggerGenOptionsExtionsions.cs new file mode 100644 index 0000000..0954a6b --- /dev/null +++ b/src/Fengling.Member.Web/Extensions/SwaggerGenOptionsExtionsions.cs @@ -0,0 +1,25 @@ +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace Fengling.Member.Web.Extensions; + +public static class SwaggerGenOptionsExtionsions +{ + public static SwaggerGenOptions AddEntityIdSchemaMap(this SwaggerGenOptions swaggerGenOptions) + { + foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies() + .Where(p => p.FullName != null && p.FullName.Contains("Fengling.Member"))) + { + foreach (var type in assembly.GetTypes()) + { + if (type.IsClass && Array.Exists(type.GetInterfaces(), p => p == typeof(IEntityId))) + { + swaggerGenOptions.MapType(type, + () => new OpenApiSchema { Type = typeof(string).Name.ToLower() }); + } + } + } + + return swaggerGenOptions; + } +} \ No newline at end of file diff --git a/src/Fengling.Member.Web/Fengling.Member.Web.csproj b/src/Fengling.Member.Web/Fengling.Member.Web.csproj new file mode 100644 index 0000000..65c6d4a --- /dev/null +++ b/src/Fengling.Member.Web/Fengling.Member.Web.csproj @@ -0,0 +1,69 @@ + + + + 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.Member.Web/GlobalUsings.cs b/src/Fengling.Member.Web/GlobalUsings.cs new file mode 100644 index 0000000..652d2f5 --- /dev/null +++ b/src/Fengling.Member.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.Member.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.Member.Web/Program.cs b/src/Fengling.Member.Web/Program.cs new file mode 100644 index 0000000..3974c51 --- /dev/null +++ b/src/Fengling.Member.Web/Program.cs @@ -0,0 +1,245 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using Prometheus; +using System.Reflection; +using System.Text.Json; +using Microsoft.AspNetCore.DataProtection; +using StackExchange.Redis; +using FluentValidation.AspNetCore; +using Fengling.Member.Web.Clients; +using Fengling.Member.Web.Extensions; +using Fengling.Member.Web.Utils; +using FastEndpoints; +using Serilog; +using Serilog.Formatting.Json; +using Hangfire; +using Hangfire.Redis.StackExchange; +using Microsoft.AspNetCore.Http.Json; +using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; +using Refit; +using NetCorePal.Extensions.CodeAnalysis; + +Log.Logger = new LoggerConfiguration() + .Enrich.WithClientIp() + .WriteTo.Console(new JsonFormatter()) + .CreateLogger(); +try +{ + var builder = WebApplication.CreateBuilder(args); + builder.Host.UseSerilog(); + + #region SignalR + + builder.Services.AddHealthChecks(); + builder.Services.AddMvc() + .AddNewtonsoftJson(options => { options.SerializerSettings.AddNetCorePalJsonConverters(); }); + builder.Services.AddSignalR(); + + #endregion + + #region Prometheus监控 + + builder.Services.AddHealthChecks().ForwardToPrometheus(); + builder.Services.AddHttpClient(Options.DefaultName) + .UseHttpClientMetrics(); + + #endregion + + // Add services to the container. + + #region 身份认证 + + var redis = await ConnectionMultiplexer.ConnectAsync(builder.Configuration.GetConnectionString("Redis")!); + builder.Services.AddSingleton(_ => redis); + + // DataProtection - use custom extension that resolves IConnectionMultiplexer from DI + builder.Services.AddDataProtection() + .PersistKeysToStackExchangeRedis("DataProtection-Keys"); + + // 配置JWT认证 + builder.Services.Configure(builder.Configuration.GetSection("AppConfiguration")); + var appConfig = builder.Configuration.GetSection("AppConfiguration").Get() ?? new AppConfiguration { JwtIssuer = "netcorepal", JwtAudience = "netcorepal" }; + + builder.Services.AddAuthentication().AddJwtBearer(options => + { + options.RequireHttpsMetadata = false; + options.TokenValidationParameters.ValidAudience = appConfig.JwtAudience; + options.TokenValidationParameters.ValidateAudience = true; + options.TokenValidationParameters.ValidIssuer = appConfig.JwtIssuer; + options.TokenValidationParameters.ValidateIssuer = true; + }); + builder.Services.AddNetCorePalJwt().AddRedisStore(); + + #endregion + + + #region Controller + + builder.Services.AddControllers().AddNetCorePalSystemTextJson(); + // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle + builder.Services.AddEndpointsApiExplorer(); + builder.Services.AddSwaggerGen(c => c.AddEntityIdSchemaMap()); //强类型id swagger schema 映射 + + #endregion + + #region FastEndpoints + + builder.Services.AddFastEndpoints(o => o.IncludeAbstractValidators = true); + builder.Services.Configure(o => + o.SerializerOptions.AddNetCorePalJsonConverters()); + + #endregion + + #region 模型验证器 + + builder.Services.AddFluentValidationAutoValidation(); + builder.Services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly()); + builder.Services.AddKnownExceptionErrorModelInterceptor(); + + #endregion + + + #region 基础设施 + + builder.Services.AddRepositories(typeof(ApplicationDbContext).Assembly); + + builder.Services.AddDbContext(options => + { + options.UseNpgsql(builder.Configuration.GetConnectionString("PostgreSQL")); + // 仅在开发环境启用敏感数据日志,防止生产环境泄露敏感信息 + if (builder.Environment.IsDevelopment()) + { + options.EnableSensitiveDataLogging(); + } + options.EnableDetailedErrors(); + }); + builder.Services.AddUnitOfWork(); + builder.Services.AddRedisLocks(); + builder.Services.AddContext().AddEnvContext().AddCapContextProcessor(); + builder.Services.AddNetCorePalServiceDiscoveryClient(); + builder.Services.AddIntegrationEvents(typeof(Program)) + .UseCap(b => + { + b.RegisterServicesFromAssemblies(typeof(Program)); + b.AddContextIntegrationFilters(); + }); + + + builder.Services.AddCap(x => + { + x.UseNetCorePalStorage(); + x.JsonSerializerOptions.AddNetCorePalJsonConverters(); + x.ConsumerThreadCount = Environment.ProcessorCount; + x.UseRabbitMQ(p => builder.Configuration.GetSection("RabbitMQ").Bind(p)); + x.UseDashboard(); //CAP Dashboard path: /cap + }); + + #endregion + + builder.Services.AddMediatR(cfg => + cfg.RegisterServicesFromAssemblies(Assembly.GetExecutingAssembly()) + .AddCommandLockBehavior() + .AddKnownExceptionValidationBehavior() + .AddUnitOfWorkBehaviors()); + + #region 多环境支持与服务注册发现 + + builder.Services.AddMultiEnv(envOption => envOption.ServiceName = "Abc.Template") + .UseMicrosoftServiceDiscovery(); + builder.Services.AddConfigurationServiceEndpointProvider(); + + #endregion + + #region 远程服务客户端配置 + + var jsonSerializerSettings = new JsonSerializerSettings + { + ContractResolver = new CamelCasePropertyNamesContractResolver(), + NullValueHandling = NullValueHandling.Ignore, + ReferenceLoopHandling = ReferenceLoopHandling.Ignore + }; + jsonSerializerSettings.AddNetCorePalJsonConverters(); + var ser = new NewtonsoftJsonContentSerializer(jsonSerializerSettings); + var settings = new RefitSettings(ser); + builder.Services.AddRefitClient(settings) + .ConfigureHttpClient(client => + client.BaseAddress = new Uri(builder.Configuration.GetValue("https+http://user:8080")!)) + .AddMultiEnvMicrosoftServiceDiscovery() //多环境服务发现支持 + .AddStandardResilienceHandler(); //添加标准的重试策略 + + #endregion + + #region Jobs + + builder.Services.AddHangfire(x => { x.UseRedisStorage(builder.Configuration.GetConnectionString("Redis")); }); + builder.Services.AddHangfireServer(); //hangfire dashboard path: /hangfire + + #endregion + + + var app = builder.Build(); + + // 在非生产环境中执行数据库迁移(包括开发、测试、Staging等环境) + if (!app.Environment.IsProduction()) + { + using var scope = app.Services.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + await dbContext.Database.MigrateAsync(); + } + + + app.UseKnownExceptionHandler(); + // Configure the HTTP request pipeline. + if (app.Environment.IsDevelopment()) + { + app.UseSwagger(); + app.UseSwaggerUI(); + } + + app.UseStaticFiles(); + //app.UseHttpsRedirection(); + app.UseRouting(); + app.UseAuthentication(); // Authentication 必须在 Authorization 之前 + app.UseAuthorization(); + + app.MapControllers(); + app.UseFastEndpoints(); + + #region SignalR + + app.MapHub("/chat"); + + #endregion + + app.UseHttpMetrics(); + app.MapHealthChecks("/health"); + app.MapMetrics(); // 通过 /metrics 访问指标 + + // Code analysis endpoint + app.MapGet("/code-analysis", () => + { + var assemblies = new List { typeof(Program).Assembly, typeof(ApplicationDbContext).Assembly }; + var html = VisualizationHtmlBuilder.GenerateVisualizationHtml( + CodeFlowAnalysisHelper.GetResultFromAssemblies(assemblies.ToArray()) + ); + return Results.Content(html, "text/html; charset=utf-8"); + }); + + app.UseHangfireDashboard(); + await app.RunAsync(); +} +catch (Exception ex) +{ + Log.Fatal(ex, "Application terminated unexpectedly"); +} +finally +{ + await Log.CloseAndFlushAsync(); +} + +#pragma warning disable S1118 +public partial class Program +#pragma warning restore S1118 +{ +} diff --git a/src/Fengling.Member.Web/Properties/launchSettings.json b/src/Fengling.Member.Web/Properties/launchSettings.json new file mode 100644 index 0000000..588d2a1 --- /dev/null +++ b/src/Fengling.Member.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.Member.Web/Utils/AppConfiguration.cs b/src/Fengling.Member.Web/Utils/AppConfiguration.cs new file mode 100644 index 0000000..d6ca387 --- /dev/null +++ b/src/Fengling.Member.Web/Utils/AppConfiguration.cs @@ -0,0 +1,18 @@ +namespace Fengling.Member.Web.Utils; + +public class AppConfiguration +{ + public string Secret { get; set; } = string.Empty; + public int TokenExpiryInMinutes { get; set; } + + /// + /// JWT Issuer(签发者) + /// + public string JwtIssuer { get; set; } = "netcorepal"; + + /// + /// JWT Audience(受众) + /// + public string JwtAudience { get; set; } = "netcorepal"; +} + diff --git a/src/Fengling.Member.Web/appsettings.Development.json b/src/Fengling.Member.Web/appsettings.Development.json new file mode 100644 index 0000000..e2ac1ac --- /dev/null +++ b/src/Fengling.Member.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": "localhost:6379" + }, + "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.Member.Web/appsettings.json b/src/Fengling.Member.Web/appsettings.json new file mode 100644 index 0000000..4b7ab76 --- /dev/null +++ b/src/Fengling.Member.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": "localhost:6379" + }, + "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/test/Fengling.Member.Domain.Tests/Fengling.Member.Domain.Tests.csproj b/test/Fengling.Member.Domain.Tests/Fengling.Member.Domain.Tests.csproj new file mode 100644 index 0000000..4c0cae6 --- /dev/null +++ b/test/Fengling.Member.Domain.Tests/Fengling.Member.Domain.Tests.csproj @@ -0,0 +1,29 @@ + + + + net10.0 + enable + enable + false + true + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/test/Fengling.Member.Domain.Tests/GlobalUsings.cs b/test/Fengling.Member.Domain.Tests/GlobalUsings.cs new file mode 100644 index 0000000..90b6b1e --- /dev/null +++ b/test/Fengling.Member.Domain.Tests/GlobalUsings.cs @@ -0,0 +1,2 @@ +global using Xunit; +global using NetCorePal.Extensions.Primitives; \ No newline at end of file diff --git a/test/Fengling.Member.Infrastructure.Tests/Fengling.Member.Infrastructure.Tests.csproj b/test/Fengling.Member.Infrastructure.Tests/Fengling.Member.Infrastructure.Tests.csproj new file mode 100644 index 0000000..36c96f9 --- /dev/null +++ b/test/Fengling.Member.Infrastructure.Tests/Fengling.Member.Infrastructure.Tests.csproj @@ -0,0 +1,29 @@ + + + + net10.0 + enable + enable + false + true + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/test/Fengling.Member.Infrastructure.Tests/GlobalUsings.cs b/test/Fengling.Member.Infrastructure.Tests/GlobalUsings.cs new file mode 100644 index 0000000..8c927eb --- /dev/null +++ b/test/Fengling.Member.Infrastructure.Tests/GlobalUsings.cs @@ -0,0 +1 @@ +global using Xunit; \ No newline at end of file diff --git a/test/Fengling.Member.Web.Tests/AssemblyInfo.cs b/test/Fengling.Member.Web.Tests/AssemblyInfo.cs new file mode 100644 index 0000000..5a2b23b --- /dev/null +++ b/test/Fengling.Member.Web.Tests/AssemblyInfo.cs @@ -0,0 +1,8 @@ +//Ordering Tests In Collections see:https://fast-endpoints.com/docs/integration-unit-testing#ordering-tests-in-collections +// [assembly: EnableAdvancedTesting] + +// can capture standard output and standard error +// [assembly: CaptureConsole] + +// will capture output from Debug and Trace +// [assembly: CaptureTrace] \ No newline at end of file diff --git a/test/Fengling.Member.Web.Tests/Fengling.Member.Web.Tests.csproj b/test/Fengling.Member.Web.Tests/Fengling.Member.Web.Tests.csproj new file mode 100644 index 0000000..00cdd81 --- /dev/null +++ b/test/Fengling.Member.Web.Tests/Fengling.Member.Web.Tests.csproj @@ -0,0 +1,41 @@ + + + + net10.0 + enable + enable + false + true + + + + + + + + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + \ No newline at end of file diff --git a/test/Fengling.Member.Web.Tests/Fixtures/WebAppFixture.cs b/test/Fengling.Member.Web.Tests/Fixtures/WebAppFixture.cs new file mode 100644 index 0000000..742f24e --- /dev/null +++ b/test/Fengling.Member.Web.Tests/Fixtures/WebAppFixture.cs @@ -0,0 +1,54 @@ +using Testcontainers.PostgreSql; +using Testcontainers.RabbitMq; +using Testcontainers.Redis; +using Microsoft.AspNetCore.Hosting; +using Fengling.Member.Infrastructure; +using Microsoft.EntityFrameworkCore; + +namespace Fengling.Member.Web.Tests.Fixtures; + +public class WebAppFixture : AppFixture +{ + private RedisContainer _redisContainer = null!; + private RabbitMqContainer _rabbitMqContainer = null!; + private PostgreSqlContainer _databaseContainer = null!; + + protected override async ValueTask PreSetupAsync() + { + _redisContainer = new RedisBuilder() + .WithCommand("--databases", "1024").Build(); + _rabbitMqContainer = new RabbitMqBuilder() + .WithUsername("guest").WithPassword("guest").Build(); + _databaseContainer = new PostgreSqlBuilder() + .WithUsername("postgres").WithPassword("123456") + .WithEnvironment("TZ", "Asia/Shanghai") + .WithDatabase("postgres").Build(); + + var tasks = new List { _redisContainer.StartAsync() }; + tasks.Add(_rabbitMqContainer.StartAsync()); + tasks.Add(_databaseContainer.StartAsync()); + await Task.WhenAll(tasks); + await CreateVisualHostAsync("/"); + } + + protected override void ConfigureApp(IWebHostBuilder a) + { + a.UseSetting("ConnectionStrings:Redis", + _redisContainer.GetConnectionString()); + a.UseSetting("ConnectionStrings:PostgreSQL", + _databaseContainer.GetConnectionString()); + a.UseSetting("RabbitMQ:Port", _rabbitMqContainer.GetMappedPublicPort(5672).ToString()); + a.UseSetting("RabbitMQ:UserName", "guest"); + a.UseSetting("RabbitMQ:Password", "guest"); + a.UseSetting("RabbitMQ:VirtualHost", "/"); + a.UseSetting("RabbitMQ:HostName", _rabbitMqContainer.Hostname); + a.UseEnvironment("Development"); + } + + private async Task CreateVisualHostAsync(string visualHost) + { + await _rabbitMqContainer.ExecAsync(["rabbitmqctl", "add_vhost", visualHost]); + await _rabbitMqContainer.ExecAsync(["rabbitmqctl", "set_permissions", "-p", visualHost, "guest", ".*", ".*", ".*" + ]); + } +} diff --git a/test/Fengling.Member.Web.Tests/Fixtures/WebAppTestCollection.cs b/test/Fengling.Member.Web.Tests/Fixtures/WebAppTestCollection.cs new file mode 100644 index 0000000..d1446d8 --- /dev/null +++ b/test/Fengling.Member.Web.Tests/Fixtures/WebAppTestCollection.cs @@ -0,0 +1,7 @@ +namespace Fengling.Member.Web.Tests.Fixtures; + +[CollectionDefinition(Name)] +public class WebAppTestCollection : TestCollection +{ + public const string Name = nameof(WebAppTestCollection); +} diff --git a/test/Fengling.Member.Web.Tests/GlobalUsings.cs b/test/Fengling.Member.Web.Tests/GlobalUsings.cs new file mode 100644 index 0000000..b8bfa25 --- /dev/null +++ b/test/Fengling.Member.Web.Tests/GlobalUsings.cs @@ -0,0 +1,9 @@ +global using Xunit; +global using Fengling.Member.Web.Tests.Fixtures; +global using FastEndpoints.Testing; +global using FastEndpoints; +global using Microsoft.Extensions.DependencyInjection; +global using Microsoft.Extensions.DependencyInjection.Extensions; +global using NetCorePal.Extensions.NewtonsoftJson; +global using Microsoft.Extensions.Configuration; +global using Microsoft.Extensions.Logging; diff --git a/test/Fengling.Member.Web.Tests/xunit.runner.json b/test/Fengling.Member.Web.Tests/xunit.runner.json new file mode 100644 index 0000000..c7bb228 --- /dev/null +++ b/test/Fengling.Member.Web.Tests/xunit.runner.json @@ -0,0 +1,5 @@ +{ + "parallelizeAssembly": true, + "parallelizeTestCollections": false, + "diagnosticMessages": false +} \ 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$ + } +}]]> + + +
+ +