44 Commits

Author SHA1 Message Date
masoodafar-web 1a425f0d93 chore: Update production workflow to use Nexus registry (no proxy needed)
Build and Deploy to Production / build-and-deploy (push) Failing after 0s
- Changed Dockerfile base images to use 194.5.195.53:32082 (Nexus)
- Updated workflow to use docker-sshpass image from Nexus
- Removed HTTP_PROXY/HTTPS_PROXY dependencies
- Changed deploy method to SSH instead of kubeconfig
- Registry: 194.5.195.53:30080 (Gitea internal)
- K8S Server: 45.149.79.127 (Production)
2026-01-29 22:04:31 +03:30
masoodafar-web 6f8aefc2ce Merge branch 'kub-stage' into production
Build and Deploy to Production / build-and-deploy (push) Successful in 1m40s
2025-12-29 00:36:51 +03:30
masoodafar-web 500e169141 feat: add Id property to WeekDefinitionItemDto and map it in CommissionProfile - Enhanced week definition handling by including the Id in the DTO and mapping configuration
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 1m51s
2025-12-28 22:21:43 +03:30
masoodafar-web ed45a1759b feat: update user weekly balances model and mappings - Enhanced UserWeeklyBalanceModel with new properties and updated mappings in response DTO and profile
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 1m34s
2025-12-27 21:52:42 +03:30
masoodafar-web 2bd5c7db78 Merge branch 'kub-stage' into production
Build and Deploy to Production / build-and-deploy (push) Successful in 1m50s
2025-12-27 07:37:30 +03:30
masoodafar-web 7087df23ef feat: remove CheckDayaLoanStatusCommandHandler and ProcessDayaLoanApprovalCommandHandler - Deleted unused command handlers to streamline codebase
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 1m48s
2025-12-27 06:40:30 +03:30
masoodafar-web 6374d5c343 feat: implement CheckDayaLoanStatusCommandHandler - Added handler for checking Daya loan status and updating contracts, including error handling and logging
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 1m10s
2025-12-27 06:21:52 +03:30
masoodafar-web 1dbb3ee502 feat: implement unified command for Daya loan status check and processing - Refactored DayaLoanCheckWorker to use CheckAndProcessDayaLoansCommand, enhancing functionality and logging
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 1m50s
2025-12-27 02:30:40 +03:30
masoodafar-web 0728ec2b76 feat: add background job configuration for weekly commission calculation
Build and Deploy to Production / build-and-deploy (push) Successful in 3m18s
2025-12-27 01:51:32 +03:30
masoodafar-web 1425fb187b Merge branch 'kub-stage' into production
# Conflicts:
#	src/CMSMicroservice.WebApi/Program.cs
2025-12-27 01:49:11 +03:30
masoodafar-web cd2549775a feat: add background job configuration for weekly commission calculation - Updated appsettings and Program.cs to enable configurable scheduling for weekly commission jobs
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 2m9s
2025-12-27 01:37:43 +03:30
masoodafar-web 81d2b39ee1 fix: update Seq ServerUrl and change Hangfire job timezone to local - Adjusted Seq server URL for production and modified recurring job options to use local timezone 2025-12-27 01:14:31 +03:30
masoodafar-web fce194a195 feat: update connection strings and add production configuration - Modified database connection details and created appsettings.Production.json for production environment
Build and Deploy to Production / build-and-deploy (push) Successful in 4m2s
2025-12-27 00:16:36 +03:30
masoodafar-web 322242fbab feat: add mapping profile for AppVersion - Implemented mappings for AppVersionItemDto to proto messages and response creation
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 3m40s
2025-12-26 09:36:21 +03:30
masoodafar-web 3d42c91d21 feat: add SMS notification for Daya loan approval - Implemented SMS sending functionality and created SmsTemplates for message formatting
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 4m11s
2025-12-26 09:20:15 +03:30
masoodafar-web 9448f3fff0 refactor: remove LockedWalletAmount from DayaLoan processing logic
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 3m56s
2025-12-26 09:07:28 +03:30
masoodafar-web 02e2f8111f feat: implement Kavenegar SMS service and refactor system configurations to use static constants
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 1m38s
2025-12-26 09:04:27 +03:30
masoodafar-web 9d2b5ad2d4 fix: update database connection and remove production config
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 5m4s
2025-12-26 06:31:19 +03:30
masoodafar-web 8940362575 fix: update MaxDepth validation range to allow values between 1 and 100 - Bump Protobuf version to 0.0.159
Build and Deploy to Production / build-and-deploy (push) Successful in 3m8s
2025-12-25 19:30:06 +03:30
masoodafar-web bca3b7fe62 feat: add ReferralCode to NetworkTree nodes - Added ReferralCode to SP_GetNetworkTree stored procedure - Added ReferralCode property to NetworkTreeDto and NetworkTreeNodeDto - Added referral_code field to networkmembership.proto - Updated Handler and Profile mappings
Build and Deploy to Production / build-and-deploy (push) Successful in 1m45s
2025-12-25 19:21:36 +03:30
masoodafar-web c03b3705c3 fix(NetworkTree): Use Convert.ToBoolean for IsClubActive and IsActivatedInTargetWeek
Build and Deploy to Production / build-and-deploy (push) Successful in 2m27s
SP returns BIT which maps to Boolean in C#, not Int32
2025-12-25 19:13:06 +03:30
masoodafar-web a9140bec04 chore: bump CMSMicroservice.Protobuf to 0.0.158
Build and Deploy to Production / build-and-deploy (push) Has been cancelled
2025-12-25 19:08:51 +03:30
masoodafar-web 17c2958b9c feat(AppVersion): Add version tracking system for frontend cache invalidation
Build and Deploy to Production / build-and-deploy (push) Successful in 1m37s
- Add AppVersion entity with CurrentVersion, MinRequiredVersion, RequiresFullCacheClear
- Add GetAppVersion query to check app version and compare with client version
- Add GetAllAppVersions query for admin panel
- Add UpdateAppVersion command to update/create app versions
- Add appversion.proto for gRPC communication
- Add AppVersionService for gRPC endpoints
- Add database migration for AppVersions table
2025-12-25 18:06:12 +03:30
masoodafar-web 2e54aa7aca feat: update boolean handling for database compatibility
Build and Deploy to Production / build-and-deploy (push) Successful in 1m45s
2025-12-25 02:34:06 +03:30
masoodafar-web 7b26073c63 update
Build and Deploy to Production / build-and-deploy (push) Successful in 1m40s
2025-12-25 01:24:58 +03:30
masoodafar-web 28e94d6137 feat: update appsettings for production environment and clear default connection string 2025-12-25 01:24:58 +03:30
masoodafar-web c6ee356cbd update 2025-12-25 01:24:58 +03:30
masoodafar-web 2d09a69be9 feat: add DatabaseFacade property to IApplicationDbContext for raw SQL access 2025-12-25 01:24:58 +03:30
masoud 7cebb27171 Update src/CMSMicroservice.Infrastructure/BackgroundJobs/ChatikaAccountActivationJob.cs 2025-12-25 01:24:58 +03:30
masoodafar-web 68489a8374 feat: update database connection string in appsettings.json 2025-12-25 01:24:58 +03:30
masoodafar-web 330f0cae5a feat: enable Chatika integration in appsettings.json 2025-12-25 01:24:58 +03:30
masoodafar-web b16f01b6e4 chore: fix trailing whitespace in appsettings.json 2025-12-25 01:24:58 +03:30
masoodafar-web 13f24d523b feat: refine user filtering logic in Chatika account activation and Daya loan check jobs 2025-12-25 01:24:58 +03:30
admin 41308e189a Add production deployment workflow
Build and Deploy to Production / build-and-deploy (push) Successful in 1m46s
2025-12-23 20:16:13 +00:00
masoodafar-web 8953d86755 chore: fix trailing whitespace in appsettings.json 2025-12-23 23:31:47 +03:30
masoodafar-web ef8d5d4d97 feat: add configuration option to enable/disable Chatika integration 2025-12-23 23:09:18 +03:30
masoodafar-web 288b85a59b feat: implement Chatika account activation job for new club members 2025-12-23 22:52:49 +03:30
masoodafar-web 30144a07c8 feat: enhance club membership activation with pool and features 2025-12-23 00:02:14 +03:30
masoodafar-web 5bcf6c9840 feat: add network leg position assignment for new users 2025-12-22 22:39:15 +03:30
masoodafar-web 9d51e70a7f chore: fix trailing whitespace in appsettings.json 2025-12-20 06:29:47 +03:30
masoodafar-web a31c88219f chore: remove appsettings.Production.json file 2025-12-20 06:18:03 +03:30
masoodafar-web ceeb29d843 chore: add Seq API key in appsettings.json 2025-12-20 06:07:13 +03:30
masoodafar-web 0ba6af9049 chore: update Seq server URL in appsettings.json 2025-12-20 05:22:47 +03:30
masoodafar-web 5ad69cb3fa update 2025-12-20 04:54:59 +03:30
100 changed files with 14174 additions and 2001 deletions
+86
View File
@@ -0,0 +1,86 @@
name: Build and Deploy to Production
on:
push:
branches:
- production
env:
REGISTRY: 194.5.195.53:30080
IMAGE_NAME: admin/cms
K8S_SERVER: 45.149.79.127
jobs:
build-and-deploy:
runs-on: ubuntu-latest
container:
image: 194.5.195.53:32082/docker-sshpass:latest
options: --privileged
steps:
- name: Start Docker daemon
run: |
mkdir -p /etc/docker
cat > /etc/docker/daemon.json << 'DAEMON'
{
"insecure-registries": ["194.5.195.53:30080", "194.5.195.53:32082"]
}
DAEMON
echo "🚀 Starting Docker daemon..."
dockerd &
for i in $(seq 1 90); do
if docker info >/dev/null 2>&1; then
echo "✅ Docker daemon is ready (attempt $i)"
docker version
break
else
echo "⏳ Waiting for Docker daemon... (attempt $i/90)"
sleep 2
fi
done
if ! docker info >/dev/null 2>&1; then
echo "❌ Docker daemon failed to start"
exit 1
fi
- name: Checkout code
run: |
git clone --depth 1 --branch production http://gitea-svc:3000/admin/CMS.git .
- name: Publish Protobuf packages
run: |
echo "📦 Publishing Protobuf packages..."
docker run --rm -v $(pwd):/src -w /src \
194.5.195.53:32082/dotnet/sdk:9.0 sh -c '
for proj in $(find . -name "*Protobuf*.csproj" -type f); do
echo "📦 $proj"
dotnet restore "$proj"
dotnet build "$proj" -c Release --no-restore
dotnet pack "$proj" -c Release --no-build -o "$(dirname $proj)/nupkg"
for nupkg in $(dirname $proj)/nupkg/*.nupkg; do
[ -f "$nupkg" ] && dotnet nuget push "$nupkg" \
--source "http://194.5.195.53:32081/repository/foursat-nuget-hosted/index.json" \
--api-key "admin:87zH26nbqT" \
--skip-duplicate --allow-insecure-connections || true
done
done
'
echo "✅ Protobuf packages done!"
- name: Build Docker Image
run: |
docker build -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} \
-t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:prod \
.
- name: Push to Registry
run: |
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login ${{ env.REGISTRY }} -u admin --password-stdin
docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:prod
- name: Deploy to Production
run: |
sshpass -p "${{ secrets.K8S_SSH_PASSWORD }}" ssh -o StrictHostKeyChecking=no root@${{ env.K8S_SERVER }} \
"kubectl rollout restart deployment/cms && kubectl rollout status deployment/cms --timeout=5m" || echo "Deployment pending"
+5
View File
@@ -492,3 +492,8 @@ fabric.properties
.idea/caches/build_file_checksums.ser
/src/.idea
# Environment-specific configuration files with sensitive data
**/appsettings.Staging.json
**/appsettings.Production.json
**/appsettings.*.local.json
+6 -3
View File
@@ -1,14 +1,17 @@
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
FROM 194.5.195.53:32082/dotnet/sdk:9.0 AS build
WORKDIR /src
# Copy NuGet config first
COPY src/NuGet.config ./
# Copy solution and project files
COPY src/ ./
# Restore and publish
RUN dotnet restore "CMSMicroservice.WebApi/CMSMicroservice.WebApi.csproj"
RUN dotnet restore "CMSMicroservice.WebApi/CMSMicroservice.WebApi.csproj" --configfile NuGet.config
RUN dotnet publish "CMSMicroservice.WebApi/CMSMicroservice.WebApi.csproj" -c Release -o /app/publish --no-restore
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS runtime
FROM 194.5.195.53:32082/dotnet/aspnet:9.0 AS runtime
WORKDIR /app
COPY --from=build /app/publish .
ENV ASPNETCORE_URLS=http://+:8080
+421
View File
@@ -0,0 +1,421 @@
{
"info": {
"_postman_id": "a5404a90-7d5a-4e9f-a26b-bb2bc93f19a2",
"name": "hushyar",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
"_exporter_id": "12336342",
"_collection_link": "https://abbas-rahimzadeh-postman.postman.co/workspace/public-workspace~57c702f9-fed1-4b27-b507-0b0252812bf7/collection/12336342-a5404a90-7d5a-4e9f-a26b-bb2bc93f19a2?action=share&source=collection_link&creator=12336342"
},
"item": [
{
"name": "v1",
"item": [
{
"name": "organization",
"item": [
{
"name": "register-user",
"request": {
"method": "POST",
"header": [
{
"key": "X-API-Key",
"value": "{{organizationApiKey}}",
"description": "Organization API Key",
"type": "text"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"mobile_number\": \"09123456789\"\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "{{baseURL}}/api/v1/organizations/register-user",
"host": [
"{{baseURL}}"
],
"path": [
"api",
"v1",
"organizations",
"register-user"
]
},
"description": "Register a new user or get existing user via organization API key. If user is new or has no organization, they will be assigned to this organization and their wallet will be charged with the organization's user_credit."
},
"response": [
{
"name": "success - new user",
"originalRequest": {
"method": "POST",
"header": [
{
"key": "X-API-Key",
"value": "{{organizationApiKey}}",
"description": "Organization API Key",
"type": "text"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"mobile_number\": \"09123456789\"\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "{{baseURL}}/api/v1/organizations/register-user",
"host": [
"{{baseURL}}"
],
"path": [
"api",
"v1",
"organizations",
"register-user"
]
}
},
"status": "OK",
"code": 200,
"_postman_previewlanguage": "json",
"header": [
{
"key": "content-type",
"value": "application/json"
}
],
"cookie": [],
"body": "{\n \"id\": 1,\n \"mobile_number\": \"09123456789\",\n \"organization_id\": 1,\n \"organization_title\": \"Test Organization\",\n \"wallet_balance\": 100.0,\n \"is_new_user\": true,\n \"credit_charged\": 100.0\n}"
},
{
"name": "success - existing user",
"originalRequest": {
"method": "POST",
"header": [
{
"key": "X-API-Key",
"value": "{{organizationApiKey}}",
"description": "Organization API Key",
"type": "text"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"mobile_number\": \"09123456789\"\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "{{baseURL}}/api/v1/organizations/register-user",
"host": [
"{{baseURL}}"
],
"path": [
"api",
"v1",
"organizations",
"register-user"
]
}
},
"status": "OK",
"code": 200,
"_postman_previewlanguage": "json",
"header": [
{
"key": "content-type",
"value": "application/json"
}
],
"cookie": [],
"body": "{\n \"id\": 1,\n \"mobile_number\": \"09123456789\",\n \"organization_id\": 1,\n \"organization_title\": \"Test Organization\",\n \"wallet_balance\": 100.0,\n \"is_new_user\": false,\n \"credit_charged\": 0.0\n}"
},
{
"name": "invalid api key",
"originalRequest": {
"method": "POST",
"header": [
{
"key": "X-API-Key",
"value": "invalid-api-key",
"description": "Organization API Key",
"type": "text"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"mobile_number\": \"09123456789\"\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "{{baseURL}}/api/v1/organizations/register-user",
"host": [
"{{baseURL}}"
],
"path": [
"api",
"v1",
"organizations",
"register-user"
]
}
},
"status": "Unauthorized",
"code": 401,
"_postman_previewlanguage": "json",
"header": [
{
"key": "content-type",
"value": "application/json"
}
],
"cookie": [],
"body": "{\n \"error_code\": \"INVALID_API_KEY\",\n \"message\": \"Invalid API key\",\n \"context\": {\n \"api_key_prefix\": \"invalid-\"\n }\n}"
},
{
"name": "organization disabled",
"originalRequest": {
"method": "POST",
"header": [
{
"key": "X-API-Key",
"value": "{{organizationApiKey}}",
"description": "Organization API Key",
"type": "text"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"mobile_number\": \"09123456789\"\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "{{baseURL}}/api/v1/organizations/register-user",
"host": [
"{{baseURL}}"
],
"path": [
"api",
"v1",
"organizations",
"register-user"
]
}
},
"status": "Forbidden",
"code": 403,
"_postman_previewlanguage": "json",
"header": [
{
"key": "content-type",
"value": "application/json"
}
],
"cookie": [],
"body": "{\n \"error_code\": \"ORGANIZATION_DISABLED\",\n \"message\": \"Organization is disabled\",\n \"context\": {\n \"organization_id\": 1\n }\n}"
},
{
"name": "organization expired",
"originalRequest": {
"method": "POST",
"header": [
{
"key": "X-API-Key",
"value": "{{organizationApiKey}}",
"description": "Organization API Key",
"type": "text"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"mobile_number\": \"09123456789\"\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "{{baseURL}}/api/v1/organizations/register-user",
"host": [
"{{baseURL}}"
],
"path": [
"api",
"v1",
"organizations",
"register-user"
]
}
},
"status": "Forbidden",
"code": 403,
"_postman_previewlanguage": "json",
"header": [
{
"key": "content-type",
"value": "application/json"
}
],
"cookie": [],
"body": "{\n \"error_code\": \"ORGANIZATION_EXPIRED\",\n \"message\": \"Organization has expired\",\n \"context\": {\n \"organization_id\": 1,\n \"expires_at\": \"2024-01-01T00:00:00\"\n }\n}"
},
{
"name": "invalid mobile format",
"originalRequest": {
"method": "POST",
"header": [
{
"key": "X-API-Key",
"value": "{{organizationApiKey}}",
"description": "Organization API Key",
"type": "text"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"mobile_number\": \"01234567890\"\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "{{baseURL}}/api/v1/organizations/register-user",
"host": [
"{{baseURL}}"
],
"path": [
"api",
"v1",
"organizations",
"register-user"
]
}
},
"status": "Unprocessable Entity",
"code": 422,
"_postman_previewlanguage": "json",
"header": [
{
"key": "content-type",
"value": "application/json"
}
],
"cookie": [],
"body": "{\n \"mobile_number\": \"String should match pattern '^09\\\\d{9}$'\"\n}"
},
{
"name": "missing api key header",
"originalRequest": {
"method": "POST",
"header": [],
"body": {
"mode": "raw",
"raw": "{\n \"mobile_number\": \"09123456789\"\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "{{baseURL}}/api/v1/organizations/register-user",
"host": [
"{{baseURL}}"
],
"path": [
"api",
"v1",
"organizations",
"register-user"
]
}
},
"status": "Unprocessable Entity",
"code": 422,
"_postman_previewlanguage": "json",
"header": [
{
"key": "content-type",
"value": "application/json"
}
],
"cookie": [],
"body": "{\n \"detail\": [\n {\n \"type\": \"missing\",\n \"loc\": [\"header\", \"x-api-key\"],\n \"msg\": \"Field required\",\n \"input\": null\n }\n ]\n}"
}
]
}
]
}
]
}
],
"event": [
{
"listen": "prerequest",
"script": {
"type": "text/javascript",
"packages": {},
"exec": [
""
]
}
},
{
"listen": "test",
"script": {
"type": "text/javascript",
"packages": {},
"exec": [
""
]
}
}
],
"variable": [
{
"key": "baseURL",
"value": "http://127.0.0.0:8000",
"type": "string"
},
{
"key": "access_token",
"value": "",
"type": "string"
},
{
"key": "accessToken",
"value": ""
},
{
"key": "refreshToken",
"value": ""
},
{
"key": "organizationApiKey",
"value": "",
"type": "string"
}
]
}
@@ -0,0 +1,42 @@
namespace CMSMicroservice.Application.AppVersionCQ.Commands.UpdateAppVersion;
/// <summary>
/// Command برای آپدیت یا ایجاد نسخه جدید اپلیکیشن
/// </summary>
public record UpdateAppVersionCommand : IRequest<Unit>
{
/// <summary>
/// نام اپلیکیشن (FrontOffice, BackOffice, MobileApp)
/// </summary>
public string AppName { get; init; } = string.Empty;
/// <summary>
/// شماره نسخه جدید (مثلاً 1.2.3)
/// </summary>
public string CurrentVersion { get; init; } = string.Empty;
/// <summary>
/// حداقل نسخه مورد نیاز
/// </summary>
public string? MinRequiredVersion { get; init; }
/// <summary>
/// آیا کش کامل باید پاک بشه؟
/// </summary>
public bool RequiresFullCacheClear { get; init; }
/// <summary>
/// پیام آپدیت
/// </summary>
public string? UpdateMessage { get; init; }
/// <summary>
/// توضیحات تغییرات این نسخه
/// </summary>
public string? ReleaseNotes { get; init; }
/// <summary>
/// دلیل آپدیت (برای لاگ)
/// </summary>
public string? UpdateReason { get; init; }
}
@@ -0,0 +1,65 @@
using CMSMicroservice.Domain.Entities.Configuration;
namespace CMSMicroservice.Application.AppVersionCQ.Commands.UpdateAppVersion;
/// <summary>
/// Handler برای آپدیت یا ایجاد نسخه جدید اپلیکیشن
/// </summary>
public class UpdateAppVersionCommandHandler : IRequestHandler<UpdateAppVersionCommand, Unit>
{
private readonly IApplicationDbContext _context;
private readonly ILogger<UpdateAppVersionCommandHandler> _logger;
public UpdateAppVersionCommandHandler(
IApplicationDbContext context,
ILogger<UpdateAppVersionCommandHandler> logger)
{
_context = context;
_logger = logger;
}
public async Task<Unit> Handle(UpdateAppVersionCommand request, CancellationToken cancellationToken)
{
// پیدا کردن نسخه موجود برای این اپ
var existingVersion = await _context.AppVersions
.Where(v => v.AppName == request.AppName && !v.IsDeleted)
.FirstOrDefaultAsync(cancellationToken);
if (existingVersion != null)
{
// آپدیت نسخه موجود
existingVersion.CurrentVersion = request.CurrentVersion;
existingVersion.MinRequiredVersion = request.MinRequiredVersion ?? request.CurrentVersion;
existingVersion.RequiresFullCacheClear = request.RequiresFullCacheClear;
existingVersion.UpdateMessage = request.UpdateMessage;
existingVersion.ReleaseNotes = request.ReleaseNotes;
_logger.LogInformation(
"App version updated: {AppName} v{Version}, CacheClear={CacheClear}, Reason={Reason}",
request.AppName, request.CurrentVersion, request.RequiresFullCacheClear, request.UpdateReason);
}
else
{
// ایجاد نسخه جدید
var newVersion = new AppVersion
{
AppName = request.AppName,
CurrentVersion = request.CurrentVersion,
MinRequiredVersion = request.MinRequiredVersion ?? request.CurrentVersion,
RequiresFullCacheClear = request.RequiresFullCacheClear,
UpdateMessage = request.UpdateMessage,
ReleaseNotes = request.ReleaseNotes,
IsActive = true
};
_context.AppVersions.Add(newVersion);
_logger.LogInformation(
"New app version created: {AppName} v{Version}, CacheClear={CacheClear}, Reason={Reason}",
request.AppName, request.CurrentVersion, request.RequiresFullCacheClear, request.UpdateReason);
}
await _context.SaveChangesAsync(cancellationToken);
return Unit.Value;
}
}
@@ -0,0 +1,23 @@
namespace CMSMicroservice.Application.AppVersionCQ.Queries.GetAllAppVersions;
/// <summary>
/// Query برای دریافت همه نسخه‌های اپلیکیشن‌ها
/// </summary>
public record GetAllAppVersionsQuery(bool IncludeInactive = false) : IRequest<List<AppVersionItemDto>>;
/// <summary>
/// DTO برای هر آیتم نسخه اپلیکیشن
/// </summary>
public record AppVersionItemDto
{
public long Id { get; init; }
public string AppName { get; init; } = string.Empty;
public string CurrentVersion { get; init; } = string.Empty;
public string MinRequiredVersion { get; init; } = string.Empty;
public bool RequiresFullCacheClear { get; init; }
public string? UpdateMessage { get; init; }
public string? ReleaseNotes { get; init; }
public bool IsActive { get; init; }
public DateTime Created { get; init; }
public DateTime? LastModified { get; init; }
}
@@ -0,0 +1,44 @@
namespace CMSMicroservice.Application.AppVersionCQ.Queries.GetAllAppVersions;
/// <summary>
/// Handler برای دریافت همه نسخه‌های اپلیکیشن‌ها
/// </summary>
public class GetAllAppVersionsQueryHandler : IRequestHandler<GetAllAppVersionsQuery, List<AppVersionItemDto>>
{
private readonly IApplicationDbContext _context;
public GetAllAppVersionsQueryHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<List<AppVersionItemDto>> Handle(GetAllAppVersionsQuery request, CancellationToken cancellationToken)
{
var query = _context.AppVersions
.Where(v => !v.IsDeleted);
if (!request.IncludeInactive)
{
query = query.Where(v => v.IsActive);
}
var versions = await query
.OrderBy(v => v.AppName)
.Select(v => new AppVersionItemDto
{
Id = v.Id,
AppName = v.AppName,
CurrentVersion = v.CurrentVersion,
MinRequiredVersion = v.MinRequiredVersion,
RequiresFullCacheClear = v.RequiresFullCacheClear,
UpdateMessage = v.UpdateMessage,
ReleaseNotes = v.ReleaseNotes,
IsActive = v.IsActive,
Created = v.Created,
LastModified = v.LastModified
})
.ToListAsync(cancellationToken);
return versions;
}
}
@@ -0,0 +1,22 @@
namespace CMSMicroservice.Application.AppVersionCQ.Queries.GetAppVersion;
/// <summary>
/// Query برای دریافت آخرین نسخه یک اپلیکیشن
/// </summary>
public record GetAppVersionQuery(string AppName, string? CurrentClientVersion = null) : IRequest<AppVersionDto?>;
/// <summary>
/// DTO برای اطلاعات نسخه اپلیکیشن
/// </summary>
public record AppVersionDto
{
public bool Found { get; init; }
public string AppName { get; init; } = string.Empty;
public string CurrentVersion { get; init; } = string.Empty;
public string MinRequiredVersion { get; init; } = string.Empty;
public bool RequiresFullCacheClear { get; init; }
public bool RequiresUpdate { get; init; }
public string? UpdateMessage { get; init; }
public string? ReleaseNotes { get; init; }
public DateTime? LastUpdated { get; init; }
}
@@ -0,0 +1,75 @@
using CMSMicroservice.Domain.Entities.Configuration;
namespace CMSMicroservice.Application.AppVersionCQ.Queries.GetAppVersion;
/// <summary>
/// Handler برای دریافت آخرین نسخه اپلیکیشن
/// </summary>
public class GetAppVersionQueryHandler : IRequestHandler<GetAppVersionQuery, AppVersionDto?>
{
private readonly IApplicationDbContext _context;
public GetAppVersionQueryHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<AppVersionDto?> Handle(GetAppVersionQuery request, CancellationToken cancellationToken)
{
var appVersion = await _context.AppVersions
.Where(v => v.AppName == request.AppName && v.IsActive && !v.IsDeleted)
.FirstOrDefaultAsync(cancellationToken);
if (appVersion == null)
{
return new AppVersionDto
{
Found = false,
AppName = request.AppName
};
}
// مقایسه ورژن کلاینت با حداقل نسخه مورد نیاز
bool requiresUpdate = false;
if (!string.IsNullOrEmpty(request.CurrentClientVersion) && !string.IsNullOrEmpty(appVersion.MinRequiredVersion))
{
requiresUpdate = CompareVersions(request.CurrentClientVersion, appVersion.MinRequiredVersion) < 0;
}
return new AppVersionDto
{
Found = true,
AppName = appVersion.AppName,
CurrentVersion = appVersion.CurrentVersion,
MinRequiredVersion = appVersion.MinRequiredVersion,
RequiresFullCacheClear = appVersion.RequiresFullCacheClear,
RequiresUpdate = requiresUpdate,
UpdateMessage = appVersion.UpdateMessage,
ReleaseNotes = appVersion.ReleaseNotes,
LastUpdated = appVersion.LastModified ?? appVersion.Created
};
}
/// <summary>
/// مقایسه دو نسخه (مثلاً 1.2.3 با 1.3.0)
/// برگشت: منفی = اولی کوچکتر، مثبت = اولی بزرگتر، صفر = برابر
/// </summary>
private static int CompareVersions(string version1, string version2)
{
var v1Parts = version1.Split('.').Select(s => int.TryParse(s, out var n) ? n : 0).ToArray();
var v2Parts = version2.Split('.').Select(s => int.TryParse(s, out var n) ? n : 0).ToArray();
var maxLen = Math.Max(v1Parts.Length, v2Parts.Length);
for (int i = 0; i < maxLen; i++)
{
var v1 = i < v1Parts.Length ? v1Parts[i] : 0;
var v2 = i < v2Parts.Length ? v2Parts[i] : 0;
if (v1 != v2)
return v1.CompareTo(v2);
}
return 0;
}
}
@@ -11,6 +11,7 @@
<PackageReference Include="Mapster" Version="7.4.0" />
<PackageReference Include="MediatR.Extensions.Microsoft.DependencyInjection" Version="11.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.11" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.11" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.4.0" />
<PackageReference Include="System.Linq.Dynamic.Core" Version="1.6.10" />
@@ -1,5 +1,10 @@
using CMSMicroservice.Application.Common.Exceptions;
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Common;
using CMSMicroservice.Domain.Entities;
using CMSMicroservice.Domain.Entities.Club;
using CMSMicroservice.Domain.Entities.Commission;
using CMSMicroservice.Domain.Entities.History;
using CMSMicroservice.Domain.Enums;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
@@ -12,6 +17,8 @@ namespace CMSMicroservice.Application.ClubMembershipCQ.Commands.AcceptClubMember
/// 1. کد OTP را تایید می‌کند
/// 2. قرارداد را در جدول UserContract ثبت می‌کند
/// 3. باشگاه مشتری را فعال می‌کند (IsActive = true)
/// 4. مبلغ را به Pool هفته جاری اضافه می‌کند
/// 5. ویژگی‌های باشگاه را به کاربر اعطا می‌کند
/// </summary>
public class AcceptClubMembershipContractCommandHandler
: IRequestHandler<AcceptClubMembershipContractCommand, AcceptClubMembershipContractResponseDto>
@@ -19,6 +26,8 @@ public class AcceptClubMembershipContractCommandHandler
private readonly IApplicationDbContext _context;
private readonly IConfiguration _cfg;
private readonly IHashService _hashService;
private readonly ICurrentUserService _currentUser;
private readonly IWeekDefinitionRepository _weekRepository;
private readonly ILogger<AcceptClubMembershipContractCommandHandler> _logger;
private const int MaxAttempts = 5;
@@ -28,11 +37,15 @@ public class AcceptClubMembershipContractCommandHandler
IApplicationDbContext context,
IConfiguration cfg,
IHashService hashService,
ICurrentUserService currentUser,
IWeekDefinitionRepository weekRepository,
ILogger<AcceptClubMembershipContractCommandHandler> logger)
{
_context = context;
_cfg = cfg;
_hashService = hashService;
_currentUser = currentUser;
_weekRepository = weekRepository;
_logger = logger;
}
@@ -114,33 +127,150 @@ public class AcceptClubMembershipContractCommandHandler
};
await _context.UserContracts.AddAsync(userContract, cancellationToken);
// 6. فعال‌سازی باشگاه مشتریان
if (user.ClubMembership == null)
// 6. دریافت مقادیر از SystemConstants (استاتیک)
long giftValue = SystemConstants.ClubMembershipGiftValue;
long activationFeeValue = SystemConstants.ClubActivationFee;
_logger.LogInformation(
"Using Club.MembershipGiftValue: {GiftValue}, Club.ActivationFee: {ActivationFee}",
giftValue, activationFeeValue
);
// 7. فعال‌سازی باشگاه مشتریان
ClubMembership clubMembership;
bool isNewMembership = user.ClubMembership == null;
var activationDate = DateTime.Now;
if (isNewMembership)
{
user.ClubMembership = new Domain.Entities.Club.ClubMembership
clubMembership = new ClubMembership
{
UserId = user.Id,
IsActive = true,
ActivatedAt = DateTime.Now,
InitialContribution = 56_000_000,
GiftValue = 25_200_000,
ActivatedAt = activationDate,
InitialContribution = activationFeeValue,
GiftValue = giftValue,
TotalEarned = 0,
PurchaseMethod = user.PackagePurchaseMethod
};
await _context.ClubMemberships.AddAsync(user.ClubMembership, cancellationToken);
await _context.ClubMemberships.AddAsync(clubMembership, cancellationToken);
_logger.LogInformation(
"Created new club membership for UserId {UserId} via {Method}, GiftValue: {GiftValue}",
user.Id,
user.PackagePurchaseMethod,
giftValue
);
}
else
{
user.ClubMembership.IsActive = true;
user.ClubMembership.ActivatedAt = DateTime.Now;
_context.ClubMemberships.Update(user.ClubMembership);
clubMembership = user.ClubMembership!;
clubMembership.IsActive = true;
clubMembership.ActivatedAt = activationDate;
clubMembership.PurchaseMethod = user.PackagePurchaseMethod;
_context.ClubMemberships.Update(clubMembership);
_logger.LogInformation(
"Reactivated club membership for UserId {UserId}",
user.Id
);
}
await _context.SaveChangesAsync(cancellationToken);
// 8. ثبت تاریخچه
var history = new ClubMembershipHistory
{
ClubMembershipId = clubMembership.Id,
UserId = clubMembership.UserId,
OldIsActive = !isNewMembership && !user.ClubMembership!.IsActive,
NewIsActive = true,
Action = ClubMembershipAction.Activated,
Reason = isNewMembership
? $"Initial activation via contract signing - {user.PackagePurchaseMethod}"
: $"Reactivated via contract signing - {user.PackagePurchaseMethod}",
PerformedBy = _currentUser.GetPerformedBy()
};
_context.ClubMembershipHistories.Add(history);
await _context.SaveChangesAsync(cancellationToken);
// 9. اضافه کردن مبلغ به Pool هفته جاری
var currentWeekDefinitionId = GetCurrentWeekDefinitionId();
var weeklyPool = await _context.WeeklyCommissionPools
.FirstOrDefaultAsync(p => p.WeekDefinitionId == currentWeekDefinitionId, cancellationToken);
if (weeklyPool == null)
{
weeklyPool = new WeeklyCommissionPool
{
WeekDefinitionId = currentWeekDefinitionId,
TotalPoolAmount = activationFeeValue,
TotalBalances = 0,
ValuePerBalance = 0,
IsCalculated = false,
CalculatedAt = null
};
await _context.WeeklyCommissionPools.AddAsync(weeklyPool, cancellationToken);
_logger.LogInformation(
"Created new WeeklyCommissionPool for WeekDefinitionId={WeekDefinitionId} with initial amount: {Amount}",
currentWeekDefinitionId,
activationFeeValue
);
}
else
{
weeklyPool.TotalPoolAmount += activationFeeValue;
_context.WeeklyCommissionPools.Update(weeklyPool);
_logger.LogInformation(
"Added {Amount} to existing WeeklyCommissionPool for WeekDefinitionId={WeekDefinitionId}. New total: {NewTotal}",
activationFeeValue,
currentWeekDefinitionId,
weeklyPool.TotalPoolAmount
);
}
await _context.SaveChangesAsync(cancellationToken);
// 10. اعطای ویژگی‌های باشگاه برای کاربر (فقط برای عضویت جدید)
if (isNewMembership)
{
var featureIds = ClubFeatureTypeExtensions.GetAllFeatureIds();
var clubFeatures = await _context.ClubFeatures
.Where(f => !f.IsDeleted && featureIds.Contains(f.Id))
.ToListAsync(cancellationToken);
if (clubFeatures.Any())
{
var userClubFeatures = clubFeatures.Select(feature => new UserClubFeature
{
UserId = user.Id,
ClubMembershipId = clubMembership.Id,
ClubFeatureId = feature.Id,
GrantedAt = activationDate,
IsActive = true,
Notes = "اعطا شده هنگام امضای قرارداد"
}).ToList();
_context.UserClubFeatures.AddRange(userClubFeatures);
await _context.SaveChangesAsync(cancellationToken);
_logger.LogInformation(
"Granted {Count} club features to UserId {UserId}",
clubFeatures.Count,
user.Id
);
}
}
_logger.LogInformation(
"Club membership contract accepted and activated for UserId: {UserId}, ContractId: {ContractId}",
"Club membership contract accepted and activated for UserId: {UserId}, ContractId: {ContractId}, MembershipId: {MembershipId}",
request.UserId,
userContract.Id
userContract.Id,
clubMembership.Id
);
return new AcceptClubMembershipContractResponseDto
@@ -151,6 +281,19 @@ public class AcceptClubMembershipContractCommandHandler
};
}
/// <summary>
/// دریافت شناسه تعریف هفته جاری
/// </summary>
private long GetCurrentWeekDefinitionId()
{
var week = _weekRepository.GetCurrentWeek();
if (week == null)
{
throw new InvalidOperationException("هفته جاری در سیستم تعریف نشده است");
}
return week.Id;
}
private async Task<(bool Success, string Message)> VerifyOtpAsync(
string mobile,
string code,
@@ -1,6 +1,7 @@
using CMSMicroservice.Application.Common.Exceptions;
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Application.Common.Models;
using CMSMicroservice.Domain.Common;
using CMSMicroservice.Domain.Entities;
using CMSMicroservice.Domain.Entities.Club;
using CMSMicroservice.Domain.Entities.Commission;
@@ -75,7 +76,7 @@ public class ActivateClubMembershipCommandHandler : IRequestHandler<ActivateClub
throw new NotFoundException("کیف پول کاربر یافت نشد");
}
if (wallet.Balance < 56_000_000)
if (wallet.Balance < SystemConstants.BasePackageAmount)
{
_logger.LogWarning(
"User {UserId} has insufficient balance: {Balance}",
@@ -83,7 +84,7 @@ public class ActivateClubMembershipCommandHandler : IRequestHandler<ActivateClub
wallet.Balance
);
throw new BadRequestException(
"برای فعالسازی باشگاه مشتریان باید حداقل 56 میلیون تومان موجودی اصلی داشته باشید"
$"برای فعالسازی باشگاه مشتریان باید حداقل {SystemConstants.BasePackageAmount:N0} ریال موجودی اصلی داشته باشید"
);
}
@@ -135,51 +136,14 @@ public class ActivateClubMembershipCommandHandler : IRequestHandler<ActivateClub
var existingMembership = await _context.ClubMemberships
.FirstOrDefaultAsync(c => c.UserId == user.Id, cancellationToken);
// 6.1. دریافت مبلغ هدیه از تنظیمات
var giftValueConfig = await _context.SystemConfigurations
.FirstOrDefaultAsync(
c => c.Key == "Club.MembershipGiftValue" && c.IsActive,
cancellationToken
);
// 6.1. دریافت مبلغ هدیه و هزینه فعالسازی از SystemConstants
long giftValue = SystemConstants.ClubMembershipGiftValue;
long activationFeeValue = SystemConstants.ClubActivationFee;
var activationFeeConfig = await _context.SystemConfigurations
.FirstOrDefaultAsync(
c => c.Key == "Club.ActivationFee" && c.IsActive,
cancellationToken
);
long giftValue = 28_000_000; // مقدار پیش‌فرض
if (giftValueConfig != null && long.TryParse(giftValueConfig.Value, out var configValue))
{
giftValue = configValue;
_logger.LogInformation(
"Using Club.MembershipGiftValue from configuration: {GiftValue}",
giftValue
);
}
else
{
_logger.LogWarning(
"Club.MembershipGiftValue not found in configuration, using default: {GiftValue}",
giftValue
);
}
long activationFeeValue = 25_200_000; // مقدار پیش‌فرض
if (activationFeeConfig != null && long.TryParse(activationFeeConfig.Value, out var activationFeeConfigValue))
{
activationFeeValue = activationFeeConfigValue;
_logger.LogInformation(
"Using Club.ActivationFee from configuration: {activationFeeValue}",
activationFeeValue
);
}
else
{
_logger.LogWarning(
"Club.ActivationFee not found in configuration, using default: {activationFeeValue}",
activationFeeValue
);
}
_logger.LogInformation(
"Using Club.MembershipGiftValue: {GiftValue}, Club.ActivationFee: {ActivationFee}",
giftValue, activationFeeValue
);
ClubMembership entity;
bool isNewMembership = existingMembership == null;
@@ -297,8 +261,9 @@ public class ActivateClubMembershipCommandHandler : IRequestHandler<ActivateClub
// 9. اضافه کردن ویژگی‌های باشگاه برای کاربر (فقط برای عضویت جدید)
if (isNewMembership)
{
var featureIds = ClubFeatureTypeExtensions.GetAllFeatureIds();
var clubFeatures = await _context.ClubFeatures
.Where(f => !f.IsDeleted && new long[] { 1, 2, 3, 4 }.Contains(f.Id))
.Where(f => !f.IsDeleted && featureIds.Contains(f.Id))
.ToListAsync(cancellationToken);
if (clubFeatures.Any())
@@ -1,3 +1,5 @@
using CMSMicroservice.Domain.Common;
namespace CMSMicroservice.Application.CommissionCQ.Commands.CalculateWeeklyBalances;
public class CalculateWeeklyBalancesCommandHandler : IRequestHandler<CalculateWeeklyBalancesCommand, int>
@@ -74,21 +76,11 @@ public class CalculateWeeklyBalancesCommandHandler : IRequestHandler<CalculateWe
var balancesList = new List<NetworkWeeklyBalance>();
var calculatedAt = DateTime.Now;
// خواندن یکباره Configuration ها (بهینه‌سازی - به جای N query)
var configs = await _context.SystemConfigurations
.Where(x => x.IsActive && (
x.Key == "Club.ActivationFee" ||
x.Key == "Commission.WeeklyPoolContributionPercent" ||
x.Key == "Commission.MaxWeeklyBalancesPerLeg" ||
x.Key == "Commission.MaxNetworkLevel"))
.ToDictionaryAsync(x => x.Key, x => x.Value, cancellationToken);
// var activationFee = long.Parse(configs.GetValueOrDefault("Club.ActivationFee", "25000000"));
// var poolPercent = decimal.Parse(configs.GetValueOrDefault("Commission.WeeklyPoolContributionPercent", "20")) / 100m;
// استفاده از SystemConstants (استاتیک - بدون کوئری به دیتابیس)
// سقف تعادل هفتگی برای هر دست (نه کل) - 300 برای چپ + 300 برای راست = حداکثر 600 تعادل
var maxBalancesPerLeg = int.Parse(configs.GetValueOrDefault("Commission.MaxWeeklyBalancesPerLeg", "300"));
var maxBalancesPerLeg = SystemConstants.CommissionMaxWeeklyBalancesPerLeg;
// حداکثر عمق شبکه برای شمارش اعضا (15 لول)
var maxNetworkLevel = int.Parse(configs.GetValueOrDefault("Commission.MaxNetworkLevel", "15"));
var maxNetworkLevel = SystemConstants.CommissionMaxNetworkLevel;
foreach (var user in usersInNetwork.OrderBy(o=>o.Id))
{
@@ -1,3 +1,5 @@
using CMSMicroservice.Domain.Common;
namespace CMSMicroservice.Application.CommissionCQ.Commands.ProcessUserPayouts;
public class ProcessUserPayoutsCommandHandler : IRequestHandler<ProcessUserPayoutsCommand, int>
@@ -47,12 +49,8 @@ public class ProcessUserPayoutsCommandHandler : IRequestHandler<ProcessUserPayou
await _context.SaveChangesAsync(cancellationToken);
}
// ⭐ خواندن MaxNetworkLevel از Config
var maxNetworkLevelConfig = await _context.SystemConfigurations
.Where(x => x.Key == "Commission.MaxNetworkLevel" && x.IsActive)
.Select(x => x.Value)
.FirstOrDefaultAsync(cancellationToken);
var maxNetworkLevel = int.Parse(maxNetworkLevelConfig ?? "15");
// ⭐ خواندن MaxNetworkLevel از SystemConstants (استاتیک)
var maxNetworkLevel = SystemConstants.CommissionMaxNetworkLevel;
// دریافت همه تعادل‌های هفتگی (شامل صفرها هم برای محاسبه زیرمجموعه)
var allWeeklyBalances = await _context.NetworkWeeklyBalances
@@ -49,7 +49,7 @@ public class GetAllWeeklyPoolsQueryHandler : IRequestHandler<GetAllWeeklyPoolsQu
{
Id = x.Id,
WeekDefinitionId = x.WeekDefinitionId,
WeekDisplayName = x.WeekDefinition.PersianWeekNumber,
WeekDisplayName = x.WeekDefinition.DisplayName,
TotalPoolAmount = x.TotalPoolAmount,
TotalBalances = x.TotalBalances,
ValuePerBalance = x.ValuePerBalance,
@@ -17,6 +17,7 @@ public class GetUserWeeklyBalancesQueryHandler : IRequestHandler<GetUserWeeklyBa
{
var query = _context.NetworkWeeklyBalances
.Include(x => x.WeekDefinition)
.Include(x => x.User)
.AsNoTracking()
.AsQueryable();
@@ -49,10 +50,15 @@ public class GetUserWeeklyBalancesQueryHandler : IRequestHandler<GetUserWeeklyBa
{
Id = x.Id,
UserId = x.UserId,
UserFullName = x.User != null ? $"{x.User.FirstName} {x.User.LastName}".Trim() : "",
WeekDefinitionId = x.WeekDefinitionId,
WeekDisplayName = x.WeekDefinition != null ? x.WeekDefinition.DisplayName : "",
LeftLegBalances = x.LeftLegBalances,
RightLegBalances = x.RightLegBalances,
LeftLegNewMembers = x.LeftLegNewMembers,
LeftLegCarryover = x.LeftLegCarryover,
LeftLegTotal = x.LeftLegTotal,
RightLegNewMembers = x.RightLegNewMembers,
RightLegCarryover = x.RightLegCarryover,
RightLegTotal = x.RightLegTotal,
TotalBalances = x.TotalBalances,
WeeklyPoolContribution = x.WeeklyPoolContribution,
CalculatedAt = x.CalculatedAt,
@@ -10,11 +10,20 @@ public class GetUserWeeklyBalancesResponseModel
{
public long Id { get; set; }
public long UserId { get; set; }
public string UserFullName { get; set; } = string.Empty;
public long WeekDefinitionId { get; set; }
public string WeekDisplayName { get; set; } = string.Empty;
public int LeftLegBalances { get; set; }
public int RightLegBalances { get; set; }
// چپ
public int LeftLegNewMembers { get; set; }
public int LeftLegCarryover { get; set; }
public int LeftLegTotal { get; set; }
// راست
public int RightLegNewMembers { get; set; }
public int RightLegCarryover { get; set; }
public int RightLegTotal { get; set; }
public int TotalBalances { get; set; }
public long WeeklyPoolContribution { get; set; }
public DateTime? CalculatedAt { get; set; }
@@ -107,6 +107,7 @@ public class GetWeekDefinitionsQueryHandler : IRequestHandler<GetWeekDefinitions
// تبدیل به DTO
var weekDtos = weeksList.Select(w => new WeekDefinitionItemDto
{
Id = w.Id,
WeekOrder = w.WeekOrder,
DisplayName = w.DisplayName,
GregorianWeekNumber = w.GregorianWeekNumber,
@@ -17,6 +17,9 @@ public class GetWeekDefinitionsResponseDto
/// </summary>
public class WeekDefinitionItemDto
{
//شناسه هفته
public long Id { get; set; }
//شماره ترتیب هفته (1, 2, 3, ...)
public int WeekOrder { get; set; }
@@ -2,6 +2,7 @@ using CMSMicroservice.Domain.Entities.Payment;
using CMSMicroservice.Domain.Entities.Order;
using CMSMicroservice.Domain.Entities.DiscountShop;
using CMSMicroservice.Domain.Entities.Geography;
using Microsoft.EntityFrameworkCore.Infrastructure;
namespace CMSMicroservice.Application.Common.Interfaces;
@@ -30,8 +31,6 @@ public interface IApplicationDbContext
DbSet<UserPackagePurchase> UserPackagePurchases { get; }
DbSet<UserWallet> UserWallets { get; }
DbSet<UserWalletChangeLog> UserWalletChangeLogs { get; }
DbSet<SystemConfiguration> SystemConfigurations { get; }
DbSet<SystemConfigurationHistory> SystemConfigurationHistories { get; }
DbSet<ManualPayment> ManualPayments { get; }
DbSet<PublicMessage> PublicMessages { get; }
DbSet<ClubMembership> ClubMemberships { get; }
@@ -46,6 +45,7 @@ public interface IApplicationDbContext
DbSet<CommissionPayoutHistory> CommissionPayoutHistories { get; }
DbSet<WorkerExecutionLog> WorkerExecutionLogs { get; }
DbSet<DayaLoanContract> DayaLoanContracts { get; }
DbSet<AppVersion> AppVersions { get; }
// ============= Discount Shop =============
DbSet<DiscountProduct> DiscountProducts { get; }
@@ -60,5 +60,10 @@ public interface IApplicationDbContext
DbSet<State> States { get; }
DbSet<City> Cities { get; }
/// <summary>
/// دسترسی به DatabaseFacade برای اجرای raw SQL و Stored Procedures
/// </summary>
DatabaseFacade Database { get; }
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}
@@ -0,0 +1,64 @@
namespace CMSMicroservice.Application.Common.Interfaces;
/// <summary>
/// سرویس ارتباط با API چتیکا برای ساخت حساب کاربری
/// </summary>
public interface IChatikaApiService
{
/// <summary>
/// ایجاد حساب کاربری در چتیکا
/// </summary>
/// <param name="mobileNumber">شماره موبایل کاربر</param>
/// <param name="fullName">نام کامل کاربر</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>نتیجه ایجاد حساب</returns>
Task<ChatikaAccountResult> CreateAccountAsync(
string mobileNumber,
string fullName,
CancellationToken cancellationToken = default);
}
/// <summary>
/// نتیجه ایجاد حساب در چتیکا
/// </summary>
public class ChatikaAccountResult
{
/// <summary>
/// موفقیت عملیات
/// </summary>
public bool IsSuccess { get; set; }
/// <summary>
/// پیام خطا در صورت عدم موفقیت
/// </summary>
public string? ErrorMessage { get; set; }
/// <summary>
/// شناسه کاربر در چتیکا
/// </summary>
public string? ChatikaUserId { get; set; }
/// <summary>
/// URL دسترسی به چتیکا
/// </summary>
public string? AccessUrl { get; set; }
/// <summary>
/// ایجاد نتیجه موفق
/// </summary>
public static ChatikaAccountResult Success(string? chatikaUserId = null, string? accessUrl = null) => new()
{
IsSuccess = true,
ChatikaUserId = chatikaUserId,
AccessUrl = accessUrl
};
/// <summary>
/// ایجاد نتیجه ناموفق
/// </summary>
public static ChatikaAccountResult Failure(string errorMessage) => new()
{
IsSuccess = false,
ErrorMessage = errorMessage
};
}
@@ -0,0 +1,17 @@
namespace CMSMicroservice.Application.Common.Interfaces;
/// <summary>
/// سرویس ارسال SMS با کاوه‌نگار
/// </summary>
public interface IKavenegarService
{
/// <summary>
/// ارسال پیامک ساده
/// </summary>
Task SendAsync(string mobile, string message);
/// <summary>
/// ارسال پیامک با قالب (VerifyLookup)
/// </summary>
Task VerifyLookupAsync(string mobile, string token, string template = "Afrino");
}
@@ -1,17 +0,0 @@
namespace CMSMicroservice.Application.ConfigurationCQ.Commands.DeactivateConfiguration;
/// <summary>
/// Command برای غیرفعال کردن یک Configuration
/// </summary>
public record DeactivateConfigurationCommand : IRequest<Unit>
{
/// <summary>
/// شناسه Configuration
/// </summary>
public long ConfigurationId { get; init; }
/// <summary>
/// دلیل غیرفعال‌سازی
/// </summary>
public string? Reason { get; init; }
}
@@ -1,51 +0,0 @@
namespace CMSMicroservice.Application.ConfigurationCQ.Commands.DeactivateConfiguration;
public class DeactivateConfigurationCommandHandler : IRequestHandler<DeactivateConfigurationCommand, Unit>
{
private readonly IApplicationDbContext _context;
private readonly ICurrentUserService _currentUser;
public DeactivateConfigurationCommandHandler(
IApplicationDbContext context,
ICurrentUserService currentUser)
{
_context = context;
_currentUser = currentUser;
}
public async Task<Unit> Handle(DeactivateConfigurationCommand request, CancellationToken cancellationToken)
{
var entity = await _context.SystemConfigurations
.FirstOrDefaultAsync(x => x.Id == request.ConfigurationId, cancellationToken)
?? throw new NotFoundException(nameof(SystemConfiguration), request.ConfigurationId);
// اگر از قبل غیرفعال است، خطا ندهیم
if (!entity.IsActive)
{
return Unit.Value;
}
var oldValue = entity.Value;
entity.IsActive = false;
_context.SystemConfigurations.Update(entity);
await _context.SaveChangesAsync(cancellationToken);
// ثبت تاریخچه
var history = new SystemConfigurationHistory
{
ConfigurationId = entity.Id,
Scope = entity.Scope,
Key = entity.Key,
OldValue = oldValue,
NewValue = entity.Value,
Reason = request.Reason ?? "Configuration deactivated",
PerformedBy = _currentUser.GetPerformedBy()
};
await _context.SystemConfigurationHistories.AddAsync(history, cancellationToken);
await _context.SaveChangesAsync(cancellationToken);
return Unit.Value;
}
}
@@ -1,29 +0,0 @@
namespace CMSMicroservice.Application.ConfigurationCQ.Commands.DeactivateConfiguration;
public class DeactivateConfigurationCommandValidator : AbstractValidator<DeactivateConfigurationCommand>
{
public DeactivateConfigurationCommandValidator()
{
RuleFor(x => x.ConfigurationId)
.GreaterThan(0)
.WithMessage("شناسه Configuration معتبر نیست");
RuleFor(x => x.Reason)
.MaximumLength(500)
.WithMessage("دلیل غیرفعال‌سازی نمی‌تواند بیشتر از 500 کاراکتر باشد")
.When(x => !string.IsNullOrEmpty(x.Reason));
}
public Func<object, string, Task<IEnumerable<string>>> ValidateValue => async (model, propertyName) =>
{
var result = await ValidateAsync(
ValidationContext<DeactivateConfigurationCommand>.CreateWithOptions(
(DeactivateConfigurationCommand)model,
x => x.IncludeProperties(propertyName)));
if (result.IsValid)
return Array.Empty<string>();
return result.Errors.Select(e => e.ErrorMessage);
};
}
@@ -1,77 +0,0 @@
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Enums;
using MediatR;
using Microsoft.Extensions.Logging;
namespace CMSMicroservice.Application.ConfigurationCQ.Commands.SeedVATConfiguration;
/// <summary>
/// Seed initial VAT configuration
/// نرخ مالیات پیش‌فرض ۹٪
/// </summary>
public class SeedVATConfigurationCommand : IRequest<Unit>
{
}
public class SeedVATConfigurationCommandHandler : IRequestHandler<SeedVATConfigurationCommand, Unit>
{
private readonly IApplicationDbContext _context;
private readonly ILogger<SeedVATConfigurationCommandHandler> _logger;
public SeedVATConfigurationCommandHandler(
IApplicationDbContext context,
ILogger<SeedVATConfigurationCommandHandler> logger)
{
_context = context;
_logger = logger;
}
public async Task<Unit> Handle(SeedVATConfigurationCommand request, CancellationToken cancellationToken)
{
var configs = new[]
{
new
{
Scope = ConfigurationScope.VAT,
Key = "Rate",
Value = "0.09",
Description = "نرخ مالیات بر ارزش افزوده (۹٪)"
},
new
{
Scope = ConfigurationScope.VAT,
Key = "IsEnabled",
Value = "true",
Description = "فعال/غیرفعال بودن محاسبه مالیات"
}
};
foreach (var config in configs)
{
var exists = _context.SystemConfigurations
.Any(x => x.Scope == config.Scope && x.Key == config.Key);
if (!exists)
{
_context.SystemConfigurations.Add(new Domain.Entities.Configuration.SystemConfiguration
{
Scope = config.Scope,
Key = config.Key,
Value = config.Value,
Description = config.Description
});
_logger.LogInformation(
"VAT configuration seeded: {Scope}.{Key} = {Value}",
config.Scope,
config.Key,
config.Value
);
}
}
await _context.SaveChangesAsync(cancellationToken);
return Unit.Value;
}
}
@@ -1,32 +0,0 @@
namespace CMSMicroservice.Application.ConfigurationCQ.Commands.SetConfigurationValue;
/// <summary>
/// Command برای تنظیم یا به‌روزرسانی یک Configuration
/// </summary>
public record SetConfigurationValueCommand : IRequest<long>
{
/// <summary>
/// محدوده تنظیمات (System, Network, Club, Commission)
/// </summary>
public ConfigurationScope Scope { get; init; }
/// <summary>
/// کلید یکتا برای تنظیمات
/// </summary>
public string Key { get; init; }
/// <summary>
/// مقدار تنظیمات (JSON format)
/// </summary>
public string Value { get; init; }
/// <summary>
/// توضیحات تنظیمات
/// </summary>
public string? Description { get; init; }
/// <summary>
/// دلیل تغییر (برای History)
/// </summary>
public string? ChangeReason { get; init; }
}
@@ -1,78 +0,0 @@
namespace CMSMicroservice.Application.ConfigurationCQ.Commands.SetConfigurationValue;
public class SetConfigurationValueCommandHandler : IRequestHandler<SetConfigurationValueCommand, long>
{
private readonly IApplicationDbContext _context;
private readonly ICurrentUserService _currentUser;
public SetConfigurationValueCommandHandler(
IApplicationDbContext context,
ICurrentUserService currentUser)
{
_context = context;
_currentUser = currentUser;
}
public async Task<long> Handle(SetConfigurationValueCommand request, CancellationToken cancellationToken)
{
// بررسی وجود Configuration با همین Scope و Key
var existingConfig = await _context.SystemConfigurations
.FirstOrDefaultAsync(x =>
x.Scope == request.Scope &&
x.Key == request.Key,
cancellationToken);
SystemConfiguration entity;
bool isNewRecord = existingConfig == null;
string oldValue = null;
if (isNewRecord)
{
// ایجاد Configuration جدید
entity = new SystemConfiguration
{
Scope = request.Scope,
Key = request.Key,
Value = request.Value,
Description = request.Description,
IsActive = true
};
await _context.SystemConfigurations.AddAsync(entity, cancellationToken);
}
else
{
// به‌روزرسانی Configuration موجود
entity = existingConfig;
oldValue = entity.Value;
entity.Value = request.Value;
if (!string.IsNullOrEmpty(request.Description))
{
entity.Description = request.Description;
}
_context.SystemConfigurations.Update(entity);
}
await _context.SaveChangesAsync(cancellationToken);
// ثبت تاریخچه
var history = new SystemConfigurationHistory
{
ConfigurationId = entity.Id,
Scope = entity.Scope,
Key = entity.Key,
OldValue = oldValue,
NewValue = entity.Value,
Reason = request.ChangeReason ?? (isNewRecord ? "Initial creation" : "Value updated"),
PerformedBy = "System" // TODO: باید از Current User گرفته شود
};
await _context.SystemConfigurationHistories.AddAsync(history, cancellationToken);
await _context.SaveChangesAsync(cancellationToken);
return entity.Id;
}
}
@@ -1,48 +0,0 @@
namespace CMSMicroservice.Application.ConfigurationCQ.Commands.SetConfigurationValue;
public class SetConfigurationValueCommandValidator : AbstractValidator<SetConfigurationValueCommand>
{
public SetConfigurationValueCommandValidator()
{
RuleFor(x => x.Scope)
.IsInEnum()
.WithMessage("محدوده تنظیمات معتبر نیست");
RuleFor(x => x.Key)
.NotEmpty()
.WithMessage("کلید تنظیمات الزامی است")
.MaximumLength(100)
.WithMessage("کلید تنظیمات نمی‌تواند بیشتر از 100 کاراکتر باشد")
.Matches(@"^[a-zA-Z0-9_\.]+$")
.WithMessage("کلید تنظیمات فقط می‌تواند شامل حروف انگلیسی، اعداد، نقطه و آندرلاین باشد");
RuleFor(x => x.Value)
.NotEmpty()
.WithMessage("مقدار تنظیمات الزامی است")
.MaximumLength(2000)
.WithMessage("مقدار تنظیمات نمی‌تواند بیشتر از 2000 کاراکتر باشد");
RuleFor(x => x.Description)
.MaximumLength(500)
.WithMessage("توضیحات نمی‌تواند بیشتر از 500 کاراکتر باشد")
.When(x => !string.IsNullOrEmpty(x.Description));
RuleFor(x => x.ChangeReason)
.MaximumLength(500)
.WithMessage("دلیل تغییر نمی‌تواند بیشتر از 500 کاراکتر باشد")
.When(x => !string.IsNullOrEmpty(x.ChangeReason));
}
public Func<object, string, Task<IEnumerable<string>>> ValidateValue => async (model, propertyName) =>
{
var result = await ValidateAsync(
ValidationContext<SetConfigurationValueCommand>.CreateWithOptions(
(SetConfigurationValueCommand)model,
x => x.IncludeProperties(propertyName)));
if (result.IsValid)
return Array.Empty<string>();
return result.Errors.Select(e => e.ErrorMessage);
};
}
@@ -1,40 +0,0 @@
namespace CMSMicroservice.Application.ConfigurationCQ.Queries.GetAllConfigurations;
/// <summary>
/// Query برای دریافت لیست تمام Configuration ها با فیلتر
/// </summary>
public record GetAllConfigurationsQuery : IRequest<GetAllConfigurationsResponseDto>
{
/// <summary>
/// موقعیت صفحه‌بندی
/// </summary>
public PaginationState? PaginationState { get; init; }
/// <summary>
/// مرتب‌سازی بر اساس
/// </summary>
public string? SortBy { get; init; }
/// <summary>
/// فیلتر
/// </summary>
public GetAllConfigurationsFilter? Filter { get; init; }
}
public class GetAllConfigurationsFilter
{
/// <summary>
/// فیلتر بر اساس محدوده
/// </summary>
public ConfigurationScope? Scope { get; set; }
/// <summary>
/// جستجو در کلید
/// </summary>
public string? KeyContains { get; set; }
/// <summary>
/// فقط Configuration های فعال
/// </summary>
public bool? IsActive { get; set; }
}
@@ -1,50 +0,0 @@
namespace CMSMicroservice.Application.ConfigurationCQ.Queries.GetAllConfigurations;
public class GetAllConfigurationsQueryHandler : IRequestHandler<GetAllConfigurationsQuery, GetAllConfigurationsResponseDto>
{
private readonly IApplicationDbContext _context;
public GetAllConfigurationsQueryHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<GetAllConfigurationsResponseDto> Handle(GetAllConfigurationsQuery request, CancellationToken cancellationToken)
{
var query = _context.SystemConfigurations
.ApplyOrder(sortBy: request.SortBy)
.AsNoTracking()
.AsQueryable();
if (request.Filter is not null)
{
query = query
.Where(x => request.Filter.Scope == null || x.Scope == request.Filter.Scope)
.Where(x => request.Filter.KeyContains == null || x.Key.Contains(request.Filter.KeyContains))
.Where(x => request.Filter.IsActive == null || x.IsActive == request.Filter.IsActive);
}
var meta = await query.GetMetaData(request.PaginationState, cancellationToken);
var models = await query
.PaginatedListAsync(paginationState: request.PaginationState)
.Select(x => new GetAllConfigurationsResponseModel
{
Id = x.Id,
Scope = x.Scope,
Key = x.Key,
Value = x.Value,
Description = x.Description,
IsActive = x.IsActive,
Created = x.Created,
LastModified = x.LastModified
})
.ToListAsync(cancellationToken);
return new GetAllConfigurationsResponseDto
{
MetaData = meta,
Models = models
};
}
}
@@ -1,25 +0,0 @@
namespace CMSMicroservice.Application.ConfigurationCQ.Queries.GetAllConfigurations;
public class GetAllConfigurationsQueryValidator : AbstractValidator<GetAllConfigurationsQuery>
{
public GetAllConfigurationsQueryValidator()
{
RuleFor(x => x.Filter.Scope)
.IsInEnum()
.WithMessage("محدوده تنظیمات معتبر نیست")
.When(x => x.Filter?.Scope != null);
}
public Func<object, string, Task<IEnumerable<string>>> ValidateValue => async (model, propertyName) =>
{
var result = await ValidateAsync(
ValidationContext<GetAllConfigurationsQuery>.CreateWithOptions(
(GetAllConfigurationsQuery)model,
x => x.IncludeProperties(propertyName)));
if (result.IsValid)
return Array.Empty<string>();
return result.Errors.Select(e => e.ErrorMessage);
};
}
@@ -1,19 +0,0 @@
namespace CMSMicroservice.Application.ConfigurationCQ.Queries.GetAllConfigurations;
public class GetAllConfigurationsResponseDto
{
public MetaData MetaData { get; set; }
public List<GetAllConfigurationsResponseModel> Models { get; set; }
}
public class GetAllConfigurationsResponseModel
{
public long Id { get; set; }
public ConfigurationScope Scope { get; set; }
public string Key { get; set; }
public string Value { get; set; }
public string? Description { get; set; }
public bool IsActive { get; set; }
public DateTimeOffset Created { get; set; }
public DateTimeOffset? LastModified { get; set; }
}
@@ -1,16 +0,0 @@
namespace CMSMicroservice.Application.ConfigurationCQ.Queries.GetConfigurationByKey;
/// <summary>
/// DTO برای نمایش اطلاعات Configuration
/// </summary>
public class ConfigurationDto
{
public long Id { get; set; }
public ConfigurationScope Scope { get; set; }
public string Key { get; set; }
public string Value { get; set; }
public string? Description { get; set; }
public bool IsActive { get; set; }
public DateTimeOffset Created { get; set; }
public DateTimeOffset? LastModified { get; set; }
}
@@ -1,17 +0,0 @@
namespace CMSMicroservice.Application.ConfigurationCQ.Queries.GetConfigurationByKey;
/// <summary>
/// Query برای دریافت یک Configuration بر اساس Scope و Key
/// </summary>
public record GetConfigurationByKeyQuery : IRequest<ConfigurationDto?>
{
/// <summary>
/// محدوده تنظیمات
/// </summary>
public ConfigurationScope Scope { get; init; }
/// <summary>
/// کلید تنظیمات
/// </summary>
public string Key { get; init; }
}
@@ -1,34 +0,0 @@
namespace CMSMicroservice.Application.ConfigurationCQ.Queries.GetConfigurationByKey;
public class GetConfigurationByKeyQueryHandler : IRequestHandler<GetConfigurationByKeyQuery, ConfigurationDto?>
{
private readonly IApplicationDbContext _context;
public GetConfigurationByKeyQueryHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<ConfigurationDto?> Handle(GetConfigurationByKeyQuery request, CancellationToken cancellationToken)
{
var config = await _context.SystemConfigurations
.AsNoTracking()
.Where(x => x.Scope == request.Scope && x.Key == request.Key)
.FirstOrDefaultAsync(cancellationToken);
if (config == null)
return null;
return new ConfigurationDto
{
Id = config.Id,
Scope = config.Scope,
Key = config.Key,
Value = config.Value,
Description = config.Description,
IsActive = config.IsActive,
Created = config.Created,
LastModified = config.LastModified
};
}
}
@@ -1,28 +0,0 @@
namespace CMSMicroservice.Application.ConfigurationCQ.Queries.GetConfigurationByKey;
public class GetConfigurationByKeyQueryValidator : AbstractValidator<GetConfigurationByKeyQuery>
{
public GetConfigurationByKeyQueryValidator()
{
RuleFor(x => x.Scope)
.IsInEnum()
.WithMessage("محدوده تنظیمات معتبر نیست");
RuleFor(x => x.Key)
.NotEmpty()
.WithMessage("کلید تنظیمات الزامی است");
}
public Func<object, string, Task<IEnumerable<string>>> ValidateValue => async (model, propertyName) =>
{
var result = await ValidateAsync(
ValidationContext<GetConfigurationByKeyQuery>.CreateWithOptions(
(GetConfigurationByKeyQuery)model,
x => x.IncludeProperties(propertyName)));
if (result.IsValid)
return Array.Empty<string>();
return result.Errors.Select(e => e.ErrorMessage);
};
}
@@ -1,22 +0,0 @@
namespace CMSMicroservice.Application.ConfigurationCQ.Queries.GetConfigurationHistory;
/// <summary>
/// Query برای دریافت تاریخچه تغییرات یک Configuration
/// </summary>
public record GetConfigurationHistoryQuery : IRequest<GetConfigurationHistoryResponseDto>
{
/// <summary>
/// شناسه Configuration
/// </summary>
public long ConfigurationId { get; init; }
/// <summary>
/// موقعیت صفحه‌بندی
/// </summary>
public PaginationState? PaginationState { get; init; }
/// <summary>
/// مرتب‌سازی بر اساس
/// </summary>
public string? SortBy { get; init; }
}
@@ -1,53 +0,0 @@
namespace CMSMicroservice.Application.ConfigurationCQ.Queries.GetConfigurationHistory;
public class GetConfigurationHistoryQueryHandler : IRequestHandler<GetConfigurationHistoryQuery, GetConfigurationHistoryResponseDto>
{
private readonly IApplicationDbContext _context;
public GetConfigurationHistoryQueryHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<GetConfigurationHistoryResponseDto> Handle(GetConfigurationHistoryQuery request, CancellationToken cancellationToken)
{
// بررسی وجود Configuration
var configExists = await _context.SystemConfigurations
.AnyAsync(x => x.Id == request.ConfigurationId, cancellationToken);
if (!configExists)
{
throw new NotFoundException(nameof(SystemConfiguration), request.ConfigurationId);
}
var query = _context.SystemConfigurationHistories
.Where(x => x.ConfigurationId == request.ConfigurationId)
.ApplyOrder(sortBy: request.SortBy ?? "Created") // پیش‌فرض: جدیدترین اول
.AsNoTracking()
.AsQueryable();
var meta = await query.GetMetaData(request.PaginationState, cancellationToken);
var models = await query
.PaginatedListAsync(paginationState: request.PaginationState)
.Select(x => new GetConfigurationHistoryResponseModel
{
Id = x.Id,
ConfigurationId = x.ConfigurationId,
Scope = x.Scope,
Key = x.Key,
OldValue = x.OldValue,
NewValue = x.NewValue,
ChangeReason = x.Reason,
ChangedBy = x.PerformedBy,
Created = x.Created
})
.ToListAsync(cancellationToken);
return new GetConfigurationHistoryResponseDto
{
MetaData = meta,
Models = models
};
}
}
@@ -1,24 +0,0 @@
namespace CMSMicroservice.Application.ConfigurationCQ.Queries.GetConfigurationHistory;
public class GetConfigurationHistoryQueryValidator : AbstractValidator<GetConfigurationHistoryQuery>
{
public GetConfigurationHistoryQueryValidator()
{
RuleFor(x => x.ConfigurationId)
.GreaterThan(0)
.WithMessage("شناسه Configuration معتبر نیست");
}
public Func<object, string, Task<IEnumerable<string>>> ValidateValue => async (model, propertyName) =>
{
var result = await ValidateAsync(
ValidationContext<GetConfigurationHistoryQuery>.CreateWithOptions(
(GetConfigurationHistoryQuery)model,
x => x.IncludeProperties(propertyName)));
if (result.IsValid)
return Array.Empty<string>();
return result.Errors.Select(e => e.ErrorMessage);
};
}
@@ -1,20 +0,0 @@
namespace CMSMicroservice.Application.ConfigurationCQ.Queries.GetConfigurationHistory;
public class GetConfigurationHistoryResponseDto
{
public MetaData MetaData { get; set; }
public List<GetConfigurationHistoryResponseModel> Models { get; set; }
}
public class GetConfigurationHistoryResponseModel
{
public long Id { get; set; }
public long ConfigurationId { get; set; }
public ConfigurationScope Scope { get; set; }
public string Key { get; set; }
public string? OldValue { get; set; }
public string NewValue { get; set; }
public string ChangeReason { get; set; }
public string ChangedBy { get; set; }
public DateTimeOffset Created { get; set; }
}
@@ -0,0 +1,15 @@
using CMSMicroservice.Domain.Enums;
namespace CMSMicroservice.Application.DayaLoanCQ.Commands.CheckAndProcessDayaLoans;
/// <summary>
/// Command یکپارچه برای استعلام وضعیت وام از سرویس دایا و پردازش خودکار وام‌های تأیید شده
/// این Command هم استعلام می‌کند و هم در صورت تأیید، کیف پول را شارژ می‌کند
/// </summary>
public record CheckAndProcessDayaLoansCommand : IRequest<CheckAndProcessDayaLoansResponseDto>
{
/// <summary>
/// لیست کدهای ملی برای استعلام
/// </summary>
public required List<string> NationalCodes { get; init; }
}
@@ -0,0 +1,301 @@
using CMSMicroservice.Domain.Events;
using CMSMicroservice.Domain.Enums;
using CMSMicroservice.Domain.Common;
using CMSMicroservice.Application.DayaLoanCQ.Services;
using CMSMicroservice.Application.Common.Interfaces;
using Microsoft.Extensions.Logging;
namespace CMSMicroservice.Application.DayaLoanCQ.Commands.CheckAndProcessDayaLoans;
/// <summary>
/// Handler یکپارچه برای استعلام و پردازش وام دایا
/// 1. استعلام از API دایا
/// 2. ذخیره/به‌روزرسانی DayaLoanContract
/// 3. شارژ کیف پول برای وام‌های تأیید شده
/// 4. ثبت Order پکیج طلایی
/// 5. ارسال SMS
/// </summary>
public class CheckAndProcessDayaLoansCommandHandler : IRequestHandler<CheckAndProcessDayaLoansCommand, CheckAndProcessDayaLoansResponseDto>
{
private readonly IApplicationDbContext _context;
private readonly IDayaLoanApiService _dayaApiService;
private readonly IKavenegarService _smsService;
private readonly ILogger<CheckAndProcessDayaLoansCommandHandler> _logger;
public CheckAndProcessDayaLoansCommandHandler(
IApplicationDbContext context,
IDayaLoanApiService dayaApiService,
IKavenegarService smsService,
ILogger<CheckAndProcessDayaLoansCommandHandler> logger)
{
_context = context;
_dayaApiService = dayaApiService;
_smsService = smsService;
_logger = logger;
}
public async Task<CheckAndProcessDayaLoansResponseDto> Handle(CheckAndProcessDayaLoansCommand request, CancellationToken cancellationToken)
{
var results = new List<DayaLoanProcessResult>();
var processedCount = 0;
try
{
// 1. استعلام از سرویس دایا
_logger.LogInformation("Checking Daya loan status for {Count} national codes", request.NationalCodes.Count);
var dayaResults = await _dayaApiService.CheckLoanStatusAsync(request.NationalCodes, cancellationToken);
foreach (var dayaResult in dayaResults)
{
var result = new DayaLoanProcessResult
{
NationalCode = dayaResult.NationalCode,
Status = dayaResult.Status,
ContractNumber = dayaResult.ContractNumber,
WasProcessed = false,
Message = "استعلام موفق"
};
try
{
// 2. پیدا کردن کاربر
var user = await _context.Users
.Include(u => u.UserWallets)
.FirstOrDefaultAsync(u => u.NationalCode == dayaResult.NationalCode, cancellationToken);
if (user == null)
{
result.Message = "کاربر یافت نشد";
results.Add(result);
continue;
}
result.UserId = user.Id;
// 3. ذخیره/به‌روزرسانی DayaLoanContract
var contract = await _context.DayaLoanContracts
.FirstOrDefaultAsync(d => d.NationalCode == dayaResult.NationalCode, cancellationToken);
if (contract == null)
{
contract = new DayaLoanContract
{
UserId = user.Id,
NationalCode = dayaResult.NationalCode,
Status = dayaResult.Status,
ContractNumber = dayaResult.ContractNumber,
LastCheckDate = DateTime.Now,
IsProcessed = false
};
await _context.DayaLoanContracts.AddAsync(contract, cancellationToken);
}
else
{
contract.Status = dayaResult.Status;
contract.ContractNumber = dayaResult.ContractNumber;
contract.LastCheckDate = DateTime.Now;
}
// 4. آیا باید پردازش مالی انجام شود؟
var shouldProcess = !contract.IsProcessed &&
!user.HasReceivedDayaCredit &&
!string.IsNullOrEmpty(dayaResult.ContractNumber) &&
(dayaResult.Status == DayaLoanStatus.PendingReceive ||
dayaResult.Status == DayaLoanStatus.Received);
if (shouldProcess)
{
// 5. پردازش مالی
var processResult = await ProcessDayaLoanAsync(user, dayaResult.ContractNumber!, cancellationToken);
contract.IsProcessed = true;
result.WasProcessed = true;
result.NewWalletBalance = processResult.NewBalance;
result.Message = "وام پردازش و کیف پول شارژ شد";
processedCount++;
_logger.LogInformation(
"Daya loan processed for User {UserId}, Contract: {ContractNumber}, NewBalance: {Balance}",
user.Id, dayaResult.ContractNumber, processResult.NewBalance);
}
else if (contract.IsProcessed || user.HasReceivedDayaCredit)
{
result.Message = "قبلاً پردازش شده";
}
else if (string.IsNullOrEmpty(dayaResult.ContractNumber))
{
result.Message = "هنوز قرارداد ندارد";
}
await _context.SaveChangesAsync(cancellationToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing Daya loan for NationalCode {NationalCode}", dayaResult.NationalCode);
result.Message = $"خطا: {ex.Message}";
}
results.Add(result);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error calling Daya API service");
// در صورت خطا در API
foreach (var nationalCode in request.NationalCodes)
{
if (!results.Any(r => r.NationalCode == nationalCode))
{
results.Add(new DayaLoanProcessResult
{
NationalCode = nationalCode,
Status = DayaLoanStatus.PendingReceive,
WasProcessed = false,
Message = $"خطا در استعلام: {ex.Message}"
});
}
}
}
return new CheckAndProcessDayaLoansResponseDto
{
Results = results,
TotalChecked = request.NationalCodes.Count,
WithContractCount = results.Count(r => !string.IsNullOrEmpty(r.ContractNumber)),
ProcessedCount = processedCount
};
}
/// <summary>
/// پردازش مالی وام دایا
/// </summary>
private async Task<(long NewBalance, long TransactionId)> ProcessDayaLoanAsync(
User user,
string contractNumber,
CancellationToken cancellationToken)
{
// 1. ایجاد تراکنش
var transaction = new Transaction
{
Amount = SystemConstants.DayaLoanAmount,
Description = $"دریافت اعتبار دایا - قرارداد {contractNumber}",
PaymentStatus = PaymentStatus.Success,
PaymentDate = DateTime.Now,
RefId = contractNumber,
Type = TransactionType.DepositExternal1
};
await _context.Transactions.AddAsync(transaction, cancellationToken);
await _context.SaveChangesAsync(cancellationToken);
// 2. یافتن یا ایجاد کیف پول
var wallet = user.UserWallets.FirstOrDefault();
if (wallet == null)
{
wallet = new UserWallet
{
UserId = user.Id,
Balance = 0,
NetworkBalance = 0,
DiscountBalance = 0
};
await _context.UserWallets.AddAsync(wallet, cancellationToken);
await _context.SaveChangesAsync(cancellationToken);
}
// 3. شارژ کیف پول عادی
wallet.Balance += SystemConstants.DayaLoanAmount;
var mainLog = new UserWalletChangeLog
{
WalletId = wallet.Id,
CurrentBalance = wallet.Balance,
ChangeValue = SystemConstants.DayaLoanAmount,
CurrentNetworkBalance = wallet.NetworkBalance,
ChangeNerworkValue = 0,
IsIncrease = true,
RefrenceId = transaction.Id
};
await _context.UserWalletChangeLogs.AddAsync(mainLog, cancellationToken);
// 4. شارژ کیف پول تخفیف (دو برابر)
var discountAmount = SystemConstants.DayaLoanAmount * 2;
wallet.DiscountBalance += discountAmount;
var discountLog = new UserWalletChangeLog
{
WalletId = wallet.Id,
CurrentBalance = wallet.Balance,
ChangeValue = 0,
CurrentNetworkBalance = wallet.NetworkBalance,
ChangeNerworkValue = 0,
CurrentDiscountBalance = wallet.DiscountBalance,
ChangeDiscountValue = discountAmount,
IsIncrease = true,
RefrenceId = transaction.Id
};
await _context.UserWalletChangeLogs.AddAsync(discountLog, cancellationToken);
// 5. به‌روزرسانی کاربر
user.HasReceivedDayaCredit = true;
user.DayaCreditReceivedAt = DateTime.Now;
user.PackagePurchaseMethod = PackagePurchaseMethod.DayaLoan;
// 6. ثبت Order پکیج پایه
var goldenPackage = await _context.Packages
.FirstOrDefaultAsync(p => p.Id == 4, cancellationToken);
if (goldenPackage != null)
{
// 6. ثبت UserPackagePurchase برای پکیج پایه
var goldenPackageId =goldenPackage.Id;
var packagePurchase = new UserPackagePurchase
{
UserId = user.Id,
PackageId = goldenPackageId,
PurchaseMethod = PackagePurchaseMethod.DayaLoan,
PurchasedAt = DateTime.Now,
Amount = SystemConstants.DayaLoanAmount,
TransactionId = transaction.Id
};
await _context.UserPackagePurchases.AddAsync(packagePurchase, cancellationToken);
}
// 7. Domain Event
user.AddDomainEvent(new DayaLoanApprovedEvent(user, transaction, contractNumber));
await _context.SaveChangesAsync(cancellationToken);
// 8. ارسال SMS
await SendDayaLoanSmsAsync(user);
return (wallet.Balance, transaction.Id);
}
/// <summary>
/// ارسال SMS اطلاع‌رسانی
/// </summary>
private async Task SendDayaLoanSmsAsync(User user)
{
try
{
var userName = SmsTemplates.GetUserName(user.FirstName+" "+user.LastName);
var message = SmsTemplates.DayaLoanReceived(userName);
await _smsService.SendAsync(user.Mobile, message);
// ارسال SMS به ادمین برای اطلاع
var adminMessage = $"وام دایا دریافت شد\nکاربر: {user.FirstName} {user.LastName}\nموبایل: {user.Mobile}\nکدملی: {user.NationalCode}";
await _smsService.SendAsync("09199877503", adminMessage);
_logger.LogInformation("Daya loan SMS sent to User {UserId}", user.Id);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to send Daya loan SMS to User {UserId}", user.Id);
}
}
}
@@ -0,0 +1,70 @@
using CMSMicroservice.Domain.Enums;
namespace CMSMicroservice.Application.DayaLoanCQ.Commands.CheckAndProcessDayaLoans;
/// <summary>
/// پاسخ Command استعلام و پردازش وام دایا
/// </summary>
public class CheckAndProcessDayaLoansResponseDto
{
/// <summary>
/// نتایج استعلام و پردازش
/// </summary>
public required List<DayaLoanProcessResult> Results { get; set; }
/// <summary>
/// تعداد کل استعلام شده
/// </summary>
public int TotalChecked { get; set; }
/// <summary>
/// تعداد موفق (دارای قرارداد)
/// </summary>
public int WithContractCount { get; set; }
/// <summary>
/// تعداد پردازش شده (شارژ کیف پول)
/// </summary>
public int ProcessedCount { get; set; }
}
/// <summary>
/// نتیجه پردازش هر کاربر
/// </summary>
public class DayaLoanProcessResult
{
/// <summary>
/// کد ملی
/// </summary>
public required string NationalCode { get; set; }
/// <summary>
/// شناسه کاربر
/// </summary>
public long? UserId { get; set; }
/// <summary>
/// وضعیت وام در دایا
/// </summary>
public DayaLoanStatus Status { get; set; }
/// <summary>
/// شماره قرارداد
/// </summary>
public string? ContractNumber { get; set; }
/// <summary>
/// آیا پردازش مالی انجام شد؟
/// </summary>
public bool WasProcessed { get; set; }
/// <summary>
/// موجودی کیف پول بعد از شارژ
/// </summary>
public long? NewWalletBalance { get; set; }
/// <summary>
/// پیام
/// </summary>
public required string Message { get; set; }
}
@@ -1,14 +0,0 @@
using CMSMicroservice.Domain.Enums;
namespace CMSMicroservice.Application.DayaLoanCQ.Commands.CheckDayaLoanStatus;
/// <summary>
/// Command برای استعلام وضعیت وام از سرویس دایا
/// </summary>
public record CheckDayaLoanStatusCommand : IRequest<CheckDayaLoanStatusResponseDto>
{
/// <summary>
/// لیست کدهای ملی برای استعلام
/// </summary>
public List<string> NationalCodes { get; init; }
}
@@ -1,239 +0,0 @@
using CMSMicroservice.Domain.Events;
using CMSMicroservice.Domain.Enums;
using CMSMicroservice.Application.DayaLoanCQ.Services;
using Microsoft.Extensions.Logging;
namespace CMSMicroservice.Application.DayaLoanCQ.Commands.CheckDayaLoanStatus;
public class CheckDayaLoanStatusCommandHandler : IRequestHandler<CheckDayaLoanStatusCommand, CheckDayaLoanStatusResponseDto>
{
private readonly IApplicationDbContext _context;
private readonly IDayaLoanApiService _dayaApiService;
private readonly ILogger<CheckDayaLoanStatusCommandHandler> _logger;
/// <summary>
/// مبلغ وام دایا - 56 میلیون ریال
/// </summary>
private const long DAYA_LOAN_AMOUNT = 56_000_000;
public CheckDayaLoanStatusCommandHandler(
IApplicationDbContext context,
IDayaLoanApiService dayaApiService,
ILogger<CheckDayaLoanStatusCommandHandler> logger)
{
_context = context;
_dayaApiService = dayaApiService;
_logger = logger;
}
public async Task<CheckDayaLoanStatusResponseDto> Handle(CheckDayaLoanStatusCommand request, CancellationToken cancellationToken)
{
var results = new List<DayaLoanStatusItem>();
try
{
// فراخوانی سرویس دایا (Mock یا Real)
var dayaResults = await _dayaApiService.CheckLoanStatusAsync(request.NationalCodes, cancellationToken);
foreach (var dayaResult in dayaResults)
{
try
{
results.Add(new DayaLoanStatusItem
{
NationalCode = dayaResult.NationalCode,
Status = dayaResult.Status,
ContractNumber = dayaResult.ContractNumber,
Message = "استعلام موفق"
});
// ذخیره یا به‌روزرسانی در دیتابیس
var existingContract = await _context.DayaLoanContracts
.FirstOrDefaultAsync(d => d.NationalCode == dayaResult.NationalCode, cancellationToken);
if (existingContract != null)
{
var previousStatus = existingContract.Status;
existingContract.LastCheckDate = DateTime.Now;
existingContract.Status = dayaResult.Status;
existingContract.ContractNumber = dayaResult.ContractNumber;
// بررسی تغییر وضعیت به PendingReceive یا Received
// فقط اگر قبلاً پردازش نشده باشد
if (!existingContract.IsProcessed &&
!string.IsNullOrEmpty(dayaResult.ContractNumber) &&
(dayaResult.Status == DayaLoanStatus.PendingReceive || dayaResult.Status == DayaLoanStatus.Received))
{
await ProcessDayaLoanReceivedAsync(existingContract.UserId, dayaResult.ContractNumber, cancellationToken);
existingContract.IsProcessed = true;
_logger.LogInformation(
"Daya loan processed for User {UserId}, ContractNumber: {ContractNumber}, Amount: {Amount}",
existingContract.UserId, dayaResult.ContractNumber, DAYA_LOAN_AMOUNT);
}
}
else
{
var user = await _context.Users
.FirstOrDefaultAsync(u => u.NationalCode == dayaResult.NationalCode, cancellationToken);
if (user != null)
{
var isProcessed = false;
// اگر وضعیت PendingReceive یا Received بود و قرارداد دارد، فوری پردازش کن
if (!string.IsNullOrEmpty(dayaResult.ContractNumber) &&
(dayaResult.Status == DayaLoanStatus.PendingReceive || dayaResult.Status == DayaLoanStatus.Received))
{
await ProcessDayaLoanReceivedAsync(user.Id, dayaResult.ContractNumber, cancellationToken);
isProcessed = true;
_logger.LogInformation(
"Daya loan processed for new contract - User {UserId}, ContractNumber: {ContractNumber}, Amount: {Amount}",
user.Id, dayaResult.ContractNumber, DAYA_LOAN_AMOUNT);
}
var newContract = new DayaLoanContract
{
UserId = user.Id,
NationalCode = dayaResult.NationalCode,
Status = dayaResult.Status,
ContractNumber = dayaResult.ContractNumber,
LastCheckDate = DateTime.Now,
IsProcessed = isProcessed
};
await _context.DayaLoanContracts.AddAsync(newContract, cancellationToken);
}
}
await _context.SaveChangesAsync(cancellationToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing Daya result for {NationalCode}", dayaResult.NationalCode);
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error calling Daya API service");
// در صورت خطا، نتایج خالی برمی‌گردانیم
foreach (var nationalCode in request.NationalCodes)
{
results.Add(new DayaLoanStatusItem
{
NationalCode = nationalCode,
Status = DayaLoanStatus.PendingReceive,
ContractNumber = null,
Message = $"خطا در استعلام: {ex.Message}"
});
}
}
return new CheckDayaLoanStatusResponseDto
{
Results = results,
TotalChecked = request.NationalCodes.Count,
SuccessCount = results.Count(r => r.ContractNumber != null)
};
}
/// <summary>
/// پردازش دریافت وام دایا برای کاربر
/// 1. شارژ کیف پول (Balance + DiscountBalance)
/// 2. ثبت تراکنش
/// 3. ثبت لاگ کیف پول
/// 4. به‌روزرسانی وضعیت کاربر
/// </summary>
private async Task ProcessDayaLoanReceivedAsync(long userId, string contractNumber, CancellationToken cancellationToken)
{
// پیدا کردن کاربر
var user = await _context.Users
.FirstOrDefaultAsync(u => u.Id == userId, cancellationToken);
if (user == null)
{
_logger.LogWarning("User {UserId} not found for Daya loan processing", userId);
return;
}
// بررسی اینکه قبلاً دریافت نکرده باشد
if (user.HasReceivedDayaCredit)
{
_logger.LogWarning("User {UserId} has already received Daya credit", userId);
return;
}
// پیدا کردن یا ایجاد کیف پول کاربر
var wallet = await _context.UserWallets
.FirstOrDefaultAsync(w => w.UserId == userId, cancellationToken);
if (wallet == null)
{
wallet = new UserWallet
{
UserId = userId,
Balance = 0,
NetworkBalance = 0,
DiscountBalance = 0
};
await _context.UserWallets.AddAsync(wallet, cancellationToken);
await _context.SaveChangesAsync(cancellationToken);
}
// 1. شارژ کیف پول - هم موجودی اصلی و هم موجودی تخفیف
var previousBalance = wallet.Balance;
var previousDiscountBalance = wallet.DiscountBalance;
wallet.Balance += DAYA_LOAN_AMOUNT;
wallet.DiscountBalance += DAYA_LOAN_AMOUNT;
// 2. ثبت تراکنش - RefId = شماره قرارداد، Status = 0 (Success)، Type = 2 (DepositExternal1)
var transaction = new Transaction
{
Amount = DAYA_LOAN_AMOUNT,
Description = $"شارژ کیف پول از وام دایا - قرارداد {contractNumber}",
PaymentStatus = PaymentStatus.Success, // 0
PaymentDate = DateTime.Now,
RefId = contractNumber,
Type = TransactionType.DepositExternal1 // 2
};
await _context.Transactions.AddAsync(transaction, cancellationToken);
await _context.SaveChangesAsync(cancellationToken);
// 3. ثبت لاگ کیف پول با شناسه تراکنش
var walletLog = new UserWalletChangeLog
{
WalletId = wallet.Id,
CurrentBalance = wallet.Balance,
ChangeValue = DAYA_LOAN_AMOUNT,
CurrentNetworkBalance = wallet.NetworkBalance,
ChangeNerworkValue = 0,
CurrentDiscountBalance = wallet.DiscountBalance,
ChangeDiscountValue = DAYA_LOAN_AMOUNT,
IsIncrease = true,
RefrenceId = transaction.Id
};
await _context.UserWalletChangeLogs.AddAsync(walletLog, cancellationToken);
// 4. به‌روزرسانی وضعیت کاربر
user.HasReceivedDayaCredit = true;
user.DayaCreditReceivedAt = DateTime.Now;
user.PackagePurchaseMethod = PackagePurchaseMethod.DayaLoan; // 1
await _context.SaveChangesAsync(cancellationToken);
_logger.LogInformation(
"Daya loan fully processed for User {UserId}: " +
"Wallet Balance {PreviousBalance} -> {NewBalance}, " +
"DiscountBalance {PreviousDiscountBalance} -> {NewDiscountBalance}, " +
"Transaction Id: {TransactionId}",
userId, previousBalance, wallet.Balance,
previousDiscountBalance, wallet.DiscountBalance,
transaction.Id);
}
}
@@ -1,18 +0,0 @@
using CMSMicroservice.Domain.Enums;
namespace CMSMicroservice.Application.DayaLoanCQ.Commands.CheckDayaLoanStatus;
public class CheckDayaLoanStatusResponseDto
{
public List<DayaLoanStatusItem> Results { get; set; }
public int TotalChecked { get; set; }
public int SuccessCount { get; set; }
}
public class DayaLoanStatusItem
{
public string NationalCode { get; set; }
public DayaLoanStatus Status { get; set; }
public string? ContractNumber { get; set; }
public string Message { get; set; }
}
@@ -1,34 +0,0 @@
using CMSMicroservice.Domain.Enums;
namespace CMSMicroservice.Application.DayaLoanCQ.Commands.ProcessDayaLoanApproval;
/// <summary>
/// Command برای پردازش تایید وام دایا و شارژ کیف پول
/// </summary>
public record ProcessDayaLoanApprovalCommand : IRequest<ProcessDayaLoanApprovalResponseDto>
{
/// <summary>
/// شناسه کاربر
/// </summary>
public long UserId { get; init; }
/// <summary>
/// شماره قرارداد دایا
/// </summary>
public string ContractNumber { get; init; }
/// <summary>
/// مبلغ کیف پول عادی (56 میلیون)
/// </summary>
public long WalletAmount { get; init; } = 56_000_000;
/// <summary>
/// مبلغ کیف پول قفل شده (56 میلیون)
/// </summary>
public long LockedWalletAmount { get; init; } = 56_000_000;
/// <summary>
/// مبلغ کیف پول تخفیف (56 میلیون)
/// </summary>
public long DiscountWalletAmount { get; init; } = 56_000_000;
}
@@ -1,169 +0,0 @@
using CMSMicroservice.Domain.Events;
using CMSMicroservice.Domain.Enums;
namespace CMSMicroservice.Application.DayaLoanCQ.Commands.ProcessDayaLoanApproval;
public class ProcessDayaLoanApprovalCommandHandler : IRequestHandler<ProcessDayaLoanApprovalCommand, ProcessDayaLoanApprovalResponseDto>
{
private readonly IApplicationDbContext _context;
public ProcessDayaLoanApprovalCommandHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<ProcessDayaLoanApprovalResponseDto> Handle(ProcessDayaLoanApprovalCommand request, CancellationToken cancellationToken)
{
// پیدا کردن کاربر
var user = await _context.Users
.Include(u => u.UserWallets)
.FirstOrDefaultAsync(u => u.Id == request.UserId, cancellationToken);
if (user == null)
{
throw new NotFoundException(nameof(User), request.UserId);
}
// چک کردن که قبلاً دریافت نکرده باشد
if (user.HasReceivedDayaCredit)
{
throw new InvalidOperationException($"کاربر {request.UserId} قبلاً اعتبار دایا را دریافت کرده است");
}
// ایجاد تراکنش با RefId = شماره قرارداد دایا
var transaction = new Transaction
{
Amount = request.WalletAmount + request.LockedWalletAmount + request.DiscountWalletAmount, // 168 میلیون
Description = $"دریافت اعتبار دایا - قرارداد {request.ContractNumber}",
PaymentStatus = PaymentStatus.Success,
PaymentDate = DateTime.Now,
RefId = request.ContractNumber, // شماره قرارداد دایا
Type = TransactionType.DepositExternal1
};
await _context.Transactions.AddAsync(transaction, cancellationToken);
await _context.SaveChangesAsync(cancellationToken);
// یافتن یا ایجاد کیف پول کاربر
var wallet = user.UserWallets.FirstOrDefault();
if (wallet == null)
{
wallet = new UserWallet
{
UserId = request.UserId,
Balance = 0,
NetworkBalance = 0,
DiscountBalance = 0
};
await _context.UserWallets.AddAsync(wallet, cancellationToken);
await _context.SaveChangesAsync(cancellationToken);
}
// شارژ کیف پول عادی (56 میلیون)
var balanceBeforeMain = wallet.Balance;
wallet.Balance += request.WalletAmount;
// لاگ کیف پول عادی
var mainLog = new UserWalletChangeLog
{
WalletId = wallet.Id,
CurrentBalance = wallet.Balance,
ChangeValue = request.WalletAmount,
CurrentNetworkBalance = wallet.NetworkBalance,
ChangeNerworkValue = 0,
IsIncrease = true,
RefrenceId = transaction.Id
};
await _context.UserWalletChangeLogs.AddAsync(mainLog, cancellationToken);
// شارژ کیف پول شبکه/کارمزد (56 میلیون) - نام‌گذاری قدیم: کیف پول قفل شده
var balanceBeforeLocked = wallet.NetworkBalance;
wallet.NetworkBalance += request.LockedWalletAmount;
// لاگ کیف پول شبکه
var networkLog = new UserWalletChangeLog
{
WalletId = wallet.Id,
CurrentBalance = wallet.Balance,
ChangeValue = 0,
CurrentNetworkBalance = wallet.NetworkBalance,
ChangeNerworkValue = request.LockedWalletAmount,
IsIncrease = true,
RefrenceId = transaction.Id
};
await _context.UserWalletChangeLogs.AddAsync(networkLog, cancellationToken);
// شارژ کیف پول تخفیف (56 میلیون)
var balanceBeforeDiscount = wallet.DiscountBalance;
wallet.DiscountBalance += request.DiscountWalletAmount;
// لاگ کیف پول تخفیف
var discountLog = new UserWalletChangeLog
{
WalletId = wallet.Id,
CurrentBalance = wallet.Balance,
ChangeValue = 0,
CurrentNetworkBalance = wallet.NetworkBalance,
ChangeNerworkValue = 0,
CurrentDiscountBalance = wallet.DiscountBalance,
ChangeDiscountValue = request.DiscountWalletAmount,
IsIncrease = true,
RefrenceId = transaction.Id
};
await _context.UserWalletChangeLogs.AddAsync(discountLog, cancellationToken);
// به‌روزرسانی وضعیت کاربر
user.HasReceivedDayaCredit = true;
user.DayaCreditReceivedAt = DateTime.Now;
// تنظیم نحوه خرید پکیج به DayaLoan
user.PackagePurchaseMethod = PackagePurchaseMethod.DayaLoan;
// ثبت سفارش پکیج (فعلاً پکیج طلایی)
var goldenPackage = await _context.Packages
.FirstOrDefaultAsync(p => p.Title.Contains("طلایی") || p.Title.Contains("Golden"), cancellationToken);
if (goldenPackage != null)
{
// پیدا کردن آدرس پیش‌فرض کاربر
var defaultAddress = await _context.UserAddresses
.Where(a => a.UserId == request.UserId)
.OrderByDescending(a => a.Created)
.FirstOrDefaultAsync(cancellationToken);
if (defaultAddress != null)
{
var packageOrder = new UserOrder
{
UserId = request.UserId,
PackageId = goldenPackage.Id,
Amount = request.WalletAmount, // 56 میلیون
PaymentStatus = PaymentStatus.Success,
PaymentDate = DateTime.Now,
DeliveryStatus = DeliveryStatus.None,
UserAddressId = defaultAddress.Id,
TransactionId = transaction.Id,
PaymentMethod = PaymentMethod.IPG
};
await _context.UserOrders.AddAsync(packageOrder, cancellationToken);
}
}
// ثبت Event
user.AddDomainEvent(new DayaLoanApprovedEvent(user, transaction, request.ContractNumber));
await _context.SaveChangesAsync(cancellationToken);
return new ProcessDayaLoanApprovalResponseDto
{
UserId = user.Id,
TransactionId = transaction.Id,
ContractNumber = request.ContractNumber,
MainWalletBalance = wallet.Balance,
LockedWalletBalance = wallet.NetworkBalance,
DiscountWalletBalance = wallet.DiscountBalance,
Message = "اعتبار دایا با موفقیت دریافت شد"
};
}
}
@@ -1,21 +0,0 @@
namespace CMSMicroservice.Application.DayaLoanCQ.Commands.ProcessDayaLoanApproval;
public class ProcessDayaLoanApprovalCommandValidator : AbstractValidator<ProcessDayaLoanApprovalCommand>
{
public ProcessDayaLoanApprovalCommandValidator()
{
RuleFor(v => v.UserId)
.GreaterThan(0)
.WithMessage("شناسه کاربر باید بزرگتر از صفر باشد");
RuleFor(v => v.ContractNumber)
.NotEmpty()
.WithMessage("شماره قرارداد الزامی است")
.MaximumLength(100)
.WithMessage("شماره قرارداد نباید بیش از 100 کاراکتر باشد");
RuleFor(v => v.WalletAmount)
.GreaterThan(0)
.WithMessage("مبلغ کیف پول باید بزرگتر از صفر باشد");
}
}
@@ -1,12 +0,0 @@
namespace CMSMicroservice.Application.DayaLoanCQ.Commands.ProcessDayaLoanApproval;
public class ProcessDayaLoanApprovalResponseDto
{
public long UserId { get; set; }
public long TransactionId { get; set; }
public string ContractNumber { get; set; }
public long MainWalletBalance { get; set; }
public long LockedWalletBalance { get; set; }
public long DiscountWalletBalance { get; set; }
public string Message { get; set; }
}
@@ -1,144 +1,180 @@
using System.Data;
using System.Data.Common;
using Microsoft.EntityFrameworkCore;
namespace CMSMicroservice.Application.NetworkMembershipCQ.Queries.GetNetworkTree;
public class GetNetworkTreeQueryHandler : IRequestHandler<GetNetworkTreeQuery, NetworkTreeDto?>
{
private readonly IApplicationDbContext _context;
private readonly IWeekDefinitionRepository _weekDefinitionRepository;
private readonly ILogger<GetNetworkTreeQueryHandler> _logger;
public GetNetworkTreeQueryHandler(
IApplicationDbContext context,
IWeekDefinitionRepository weekDefinitionRepository)
ILogger<GetNetworkTreeQueryHandler> logger)
{
_context = context;
_weekDefinitionRepository = weekDefinitionRepository;
_logger = logger;
}
public async Task<NetworkTreeDto?> Handle(GetNetworkTreeQuery request, CancellationToken cancellationToken)
{
var rootUser = await _context.Users
.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == request.UserId, cancellationToken);
if (rootUser == null)
try
{
return null;
}
// دریافت نتایج flat از Stored Procedure
var flatNodes = await ExecuteStoredProcedureAsync(request, cancellationToken);
if (flatNodes == null || !flatNodes.Any())
{
return null;
}
var tree = await BuildTree(rootUser.Id, request.MaxDepth, 0, cancellationToken, request);
return tree;
// تبدیل نتایج flat به ساختار درختی
var tree = BuildTreeFromFlatNodes(flatNodes);
return tree;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error executing GetNetworkTree for UserId: {UserId}", request.UserId);
throw;
}
}
private async Task<NetworkTreeDto> BuildTree(long userId, int maxDepth, int currentDepth, CancellationToken cancellationToken, GetNetworkTreeQuery request)
/// <summary>
/// اجرای Stored Procedure و دریافت نتایج
/// </summary>
private async Task<List<NetworkTreeNodeDto>> ExecuteStoredProcedureAsync(
GetNetworkTreeQuery request,
CancellationToken cancellationToken)
{
// دریافت کاربر با اطلاعات باشگاه مشتریان
var user = await _context.Users
.Include(u => u.ClubMembership)
.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == userId, cancellationToken);
if (user == null)
var results = new List<NetworkTreeNodeDto>();
var connection = _context.Database.GetDbConnection();
try
{
throw new NotFoundException(nameof(User), userId);
if (connection.State != ConnectionState.Open)
{
await connection.OpenAsync(cancellationToken);
}
using var command = connection.CreateCommand();
command.CommandText = "[CMS].[GetNetworkTree]";
command.CommandType = CommandType.StoredProcedure;
command.CommandTimeout = 120; // 2 minutes timeout for large trees
// Parameters - use DbParameter for provider-agnostic code
var rootUserIdParam = command.CreateParameter();
rootUserIdParam.ParameterName = "@RootUserId";
rootUserIdParam.DbType = DbType.Int64;
rootUserIdParam.Value = request.UserId;
command.Parameters.Add(rootUserIdParam);
var maxDepthParam = command.CreateParameter();
maxDepthParam.ParameterName = "@MaxDepth";
maxDepthParam.DbType = DbType.Int32;
maxDepthParam.Value = request.MaxDepth > 0 ? request.MaxDepth : 100;
command.Parameters.Add(maxDepthParam);
var isClubActiveParam = command.CreateParameter();
isClubActiveParam.ParameterName = "@IsClubActive";
isClubActiveParam.DbType = DbType.Boolean;
isClubActiveParam.Value = request.IsClubActive.HasValue ? request.IsClubActive.Value : DBNull.Value;
command.Parameters.Add(isClubActiveParam);
var weekParam = command.CreateParameter();
weekParam.ParameterName = "@ActivationWeekDefinitionId";
weekParam.DbType = DbType.Int64;
weekParam.Value = request.ActivationWeekDefinitionId.HasValue ? request.ActivationWeekDefinitionId.Value : DBNull.Value;
command.Parameters.Add(weekParam);
using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
var node = new NetworkTreeNodeDto
{
UserId = reader.GetInt64(reader.GetOrdinal("UserId")),
Mobile = reader.IsDBNull(reader.GetOrdinal("Mobile")) ? null : reader.GetString(reader.GetOrdinal("Mobile")),
FirstName = reader.IsDBNull(reader.GetOrdinal("FirstName")) ? null : reader.GetString(reader.GetOrdinal("FirstName")),
LastName = reader.IsDBNull(reader.GetOrdinal("LastName")) ? null : reader.GetString(reader.GetOrdinal("LastName")),
ReferralCode = reader.IsDBNull(reader.GetOrdinal("ReferralCode")) ? null : reader.GetString(reader.GetOrdinal("ReferralCode")),
LegPosition = reader.IsDBNull(reader.GetOrdinal("LegPosition")) ? null : reader.GetInt32(reader.GetOrdinal("LegPosition")),
ParentId = reader.IsDBNull(reader.GetOrdinal("ParentId")) ? null : reader.GetInt64(reader.GetOrdinal("ParentId")),
NetworkLevel = reader.GetInt32(reader.GetOrdinal("NetworkLevel")),
ClubActivatedAt = reader.IsDBNull(reader.GetOrdinal("ClubActivatedAt")) ? null : reader.GetDateTime(reader.GetOrdinal("ClubActivatedAt")),
IsClubActive = Convert.ToBoolean(reader.GetValue(reader.GetOrdinal("IsClubActive"))),
ActivationWeekDefinitionId = reader.IsDBNull(reader.GetOrdinal("ActivationWeekDefinitionId")) ? null : reader.GetInt64(reader.GetOrdinal("ActivationWeekDefinitionId")),
ActivationWeekDisplayName = reader.IsDBNull(reader.GetOrdinal("ActivationWeekDisplayName")) ? null : reader.GetString(reader.GetOrdinal("ActivationWeekDisplayName")),
IsActivatedInTargetWeek = Convert.ToBoolean(reader.GetValue(reader.GetOrdinal("IsActivatedInTargetWeek"))),
UserCreated = new DateTimeOffset(reader.GetDateTime(reader.GetOrdinal("UserCreated")))
};
results.Add(node);
}
}
finally
{
// Connection is managed by DbContext, don't close it here
}
// محاسبه شماره هفته فعال‌سازی
long? activationWeekDefinitionId = null;
string? activationWeekDisplayName = null;
bool isActivatedInTargetWeek = false;
if (user.ClubMembership?.ActivatedAt != null)
_logger.LogInformation("GetNetworkTree SP returned {Count} nodes for UserId: {UserId}", results.Count, request.UserId);
return results;
}
/// <summary>
/// تبدیل لیست flat به ساختار درختی
/// </summary>
private NetworkTreeDto? BuildTreeFromFlatNodes(List<NetworkTreeNodeDto> flatNodes)
{
if (!flatNodes.Any()) return null;
// Dictionary برای دسترسی سریع به نودها
var nodeDict = new Dictionary<long, NetworkTreeDto>();
// ایجاد همه نودها
foreach (var flatNode in flatNodes)
{
// activationWeekDefinitionId = CalculateWeekNumber(user.ClubMembership.ActivatedAt.Value);
var week = _weekDefinitionRepository.GetWeekByDate(user.ClubMembership.ActivatedAt.Value);
activationWeekDefinitionId = week.Id;
activationWeekDisplayName = week.DisplayName;
// بررسی آیا در هفته هدف فعال شده است
if (request.ActivationWeekDefinitionId!=null)
nodeDict[flatNode.UserId] = new NetworkTreeDto
{
isActivatedInTargetWeek = activationWeekDefinitionId == request.ActivationWeekDefinitionId;
UserId = flatNode.UserId,
Mobile = flatNode.Mobile,
FirstName = flatNode.FirstName,
LastName = flatNode.LastName,
ReferralCode = flatNode.ReferralCode,
LegPosition = flatNode.LegPosition.HasValue ? (NetworkLeg)flatNode.LegPosition.Value : null,
CurrentDepth = flatNode.NetworkLevel,
ClubActivatedAt = flatNode.ClubActivatedAt,
IsClubActive = flatNode.IsClubActive,
ActivationWeekDefinitionId = flatNode.ActivationWeekDefinitionId,
ActivationWeekDisplayName = flatNode.ActivationWeekDisplayName,
IsActivatedInTargetWeek = flatNode.IsActivatedInTargetWeek,
UserCreated = flatNode.UserCreated
};
}
// برقراری ارتباط Parent-Child
foreach (var flatNode in flatNodes)
{
if (flatNode.ParentId.HasValue && nodeDict.ContainsKey(flatNode.ParentId.Value))
{
var parent = nodeDict[flatNode.ParentId.Value];
var child = nodeDict[flatNode.UserId];
// تعیین موقعیت چپ یا راست
if (flatNode.LegPosition == (int)NetworkLeg.Left)
{
parent.LeftChild = child;
}
else if (flatNode.LegPosition == (int)NetworkLeg.Right)
{
parent.RightChild = child;
}
}
}
var node = new NetworkTreeDto
{
UserId = user.Id,
Mobile = user.Mobile,
FirstName = user.FirstName,
LastName = user.LastName,
LegPosition = user.LegPosition,
CurrentDepth = currentDepth,
ClubActivatedAt = user.ClubMembership?.ActivatedAt,
IsClubActive = user.ClubMembership?.IsActive ?? false,
ActivationWeekDefinitionId = activationWeekDefinitionId,
ActivationWeekDisplayName = activationWeekDisplayName,
IsActivatedInTargetWeek = isActivatedInTargetWeek,
UserCreated = user.Created
};
// اگر به حداکثر عمق رسیدیم، دیگر فرزندان را نمی‌خوانیم
if (currentDepth >= maxDepth)
{
return node;
}
// پیدا کردن فرزندان (چپ و راست)
var children = await GetFilteredChildren(userId, request, cancellationToken);
var leftChild = children.FirstOrDefault(c => c.LegPosition == NetworkLeg.Left);
if (leftChild != null)
{
node.LeftChild = await BuildTree(leftChild.Id, maxDepth, currentDepth + 1, cancellationToken, request);
}
var rightChild = children.FirstOrDefault(c => c.LegPosition == NetworkLeg.Right);
if (rightChild != null)
{
node.RightChild = await BuildTree(rightChild.Id, maxDepth, currentDepth + 1, cancellationToken, request);
}
return node;
// پیدا کردن ریشه (اولین نود با Level=0)
var rootNode = flatNodes.FirstOrDefault(n => n.NetworkLevel == 0);
return rootNode != null ? nodeDict[rootNode.UserId] : null;
}
/// <summary>
/// دریافت فرزندان با اعمال فیلترها
/// </summary>
private async Task<List<User>> GetFilteredChildren(long parentId, GetNetworkTreeQuery request, CancellationToken cancellationToken)
{
var query = _context.Users
.Include(u => u.ClubMembership)
.AsNoTracking()
.Where(x => x.NetworkParentId == parentId);
// اعمال فیلتر IsClubActive
if (request.IsClubActive.HasValue)
{
query = query.Where(u =>
u.ClubMembership != null &&
u.ClubMembership.IsDeleted == false &&
u.ClubMembership.IsActive == request.IsClubActive.Value);
}
return await query.ToListAsync(cancellationToken);
}
/// <summary>
/// محاسبه شماره هفته از تاریخ
/// </summary>
// private long CalculateWeekNumber(DateTime date)
// {
// // First try to get from repository cache
// var weekDef = _weekDefinitionRepository.GetWeekByDate(date);
// if (weekDef != null)
// {
// return weekDef.Id;
// }
//
// // Fallback: use repository's calculation method
// // return _weekDefinitionRepository.CalculateGregorianWeekNumber(date);
// return 0;
// }
}
@@ -9,8 +9,8 @@ public class GetNetworkTreeQueryValidator : AbstractValidator<GetNetworkTreeQuer
.WithMessage("شناسه کاربر معتبر نیست");
RuleFor(x => x.MaxDepth)
.InclusiveBetween(1, 20)
.WithMessage("عمق درخت باید بین 1 تا 20 باشد");
.InclusiveBetween(1, 100)
.WithMessage("عمق درخت باید بین 1 تا 100 باشد");
}
public Func<object, string, Task<IEnumerable<string>>> ValidateValue => async (model, propertyName) =>
@@ -9,6 +9,12 @@ public class NetworkTreeDto
public string? Mobile { get; set; }
public string? FirstName { get; set; }
public string? LastName { get; set; }
/// <summary>
/// کد معرف کاربر
/// </summary>
public string? ReferralCode { get; set; }
public NetworkLeg? LegPosition { get; set; }
public int CurrentDepth { get; set; }
@@ -0,0 +1,28 @@
namespace CMSMicroservice.Application.NetworkMembershipCQ.Queries.GetNetworkTree;
/// <summary>
/// DTO برای نتیجه Flat از Stored Procedure
/// هر ردیف یک نود از درخت است
/// </summary>
public class NetworkTreeNodeDto
{
public long UserId { get; set; }
public string? Mobile { get; set; }
public string? FirstName { get; set; }
public string? LastName { get; set; }
public string? ReferralCode { get; set; }
public int? LegPosition { get; set; }
public long? ParentId { get; set; }
public int NetworkLevel { get; set; }
public DateTime? ClubActivatedAt { get; set; }
public bool IsClubActive { get; set; }
public long? ActivationWeekDefinitionId { get; set; }
public string? ActivationWeekDisplayName { get; set; }
public bool IsActivatedInTargetWeek { get; set; }
public DateTimeOffset UserCreated { get; set; }
/// <summary>
/// نام کامل کاربر
/// </summary>
public string FullName => $"{FirstName} {LastName}".Trim();
}
@@ -1,3 +1,4 @@
using CMSMicroservice.Domain.Enums;
using CMSMicroservice.Domain.Events;
using Microsoft.Extensions.Configuration;
@@ -75,6 +76,26 @@ public class VerifyOtpTokenCommandHandler : IRequestHandler<VerifyOtpTokenComman
if (await _context.Users.CountAsync(x => x.NetworkParentId == parent.Id, cancellationToken: cancellationToken) > 1)
return new VerifyOtpTokenResponseDto() { Success = false, Message = "ظرفیت معرف تکمیل است!!" };
// تعیین موقعیت در شبکه (چپ یا راست)
var existingChildren = await _context.Users
.Where(x => x.NetworkParentId == parent.Id && !x.IsDeleted)
.Select(x => x.LegPosition)
.ToListAsync(cancellationToken);
NetworkLeg newUserLegPosition;
if (!existingChildren.Any(x => x == NetworkLeg.Left))
{
newUserLegPosition = NetworkLeg.Left;
}
else if (!existingChildren.Any(x => x == NetworkLeg.Right))
{
newUserLegPosition = NetworkLeg.Right;
}
else
{
return new VerifyOtpTokenResponseDto() { Success = false, Message = "ظرفیت معرف تکمیل است!!" };
}
user = new User
{
Mobile = mobile,
@@ -83,7 +104,8 @@ public class VerifyOtpTokenCommandHandler : IRequestHandler<VerifyOtpTokenComman
MobileVerifiedAt = now,
IsRulesAccepted = true,
RulesAcceptedAt = now,
NetworkParentId = parent.Id
NetworkParentId = parent.Id,
LegPosition = newUserLegPosition
};
await _context.Users.AddAsync(user, cancellationToken);
user.AddDomainEvent(new CreateNewUserEvent(user));
@@ -2,6 +2,7 @@ using CMSMicroservice.Application.Common.Exceptions;
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Entities;
using CMSMicroservice.Domain.Enums;
using CMSMicroservice.Domain.Common;
using MediatR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
@@ -19,11 +20,6 @@ public class InitiateBasePackagePaymentCommandHandler : IRequestHandler<Initiate
private readonly IApplicationDbContext _context;
private readonly ILogger<InitiateBasePackagePaymentCommandHandler> _logger;
/// <summary>
/// مبلغ پکیج پایه (56 میلیون تومان)
/// </summary>
private const long BasePackageAmount = 56_000_000;
/// <summary>
/// شناسه پکیج پایه در دیتابیس
/// </summary>
@@ -97,7 +93,7 @@ public class InitiateBasePackagePaymentCommandHandler : IRequestHandler<Initiate
Message = "سفارش قبلی در انتظار پرداخت یافت شد.",
OrderId = pendingOrder.Id,
TransactionId = existingTransaction?.Id ?? 0,
Amount = BasePackageAmount
Amount = SystemConstants.BasePackageAmount
};
}
@@ -116,7 +112,7 @@ public class InitiateBasePackagePaymentCommandHandler : IRequestHandler<Initiate
// 5. ایجاد Transaction با وضعیت Pending
var transaction = new Transaction
{
Amount = BasePackageAmount,
Amount = SystemConstants.BasePackageAmount,
Description = $"خرید پکیج پایه ۵۶ میلیونی - کاربر #{user.Id}",
PaymentStatus = PaymentStatus.Pending,
Type = TransactionType.DepositIpg
@@ -135,7 +131,7 @@ public class InitiateBasePackagePaymentCommandHandler : IRequestHandler<Initiate
{
UserId = user.Id,
PackageId = BasePackageId,
Amount = BasePackageAmount,
Amount = SystemConstants.BasePackageAmount,
PaymentStatus = PaymentStatus.Pending,
DeliveryStatus = DeliveryStatus.None,
UserAddressId = defaultAddress.Id,
@@ -158,7 +154,7 @@ public class InitiateBasePackagePaymentCommandHandler : IRequestHandler<Initiate
Message = "تراکنش و سفارش با موفقیت ثبت شد.",
OrderId = order.Id,
TransactionId = transaction.Id,
Amount = BasePackageAmount
Amount = SystemConstants.BasePackageAmount
};
}
catch (Exception ex)
@@ -2,6 +2,7 @@ using CMSMicroservice.Application.Common.Exceptions;
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Entities;
using CMSMicroservice.Domain.Enums;
using CMSMicroservice.Domain.Common;
using MediatR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
@@ -24,11 +25,6 @@ public class VerifyBasePackagePaymentCommandHandler : IRequestHandler<VerifyBase
{
private readonly IApplicationDbContext _context;
private readonly ILogger<VerifyBasePackagePaymentCommandHandler> _logger;
/// <summary>
/// مبلغ پکیج پایه (56 میلیون تومان)
/// </summary>
private const long BasePackageAmount = 56_000_000;
public VerifyBasePackagePaymentCommandHandler(
IApplicationDbContext context,
@@ -136,8 +132,8 @@ public class VerifyBasePackagePaymentCommandHandler : IRequestHandler<VerifyBase
var oldBalance = userWallet.Balance;
var oldDiscountBalance = userWallet.DiscountBalance;
userWallet.Balance += BasePackageAmount;
userWallet.DiscountBalance += BasePackageAmount;
userWallet.Balance += SystemConstants.BasePackageAmount;
userWallet.DiscountBalance += SystemConstants.BasePackageAmount;
_logger.LogInformation(
"Charging wallet for user {UserId}. Balance: {OldBalance} -> {NewBalance}, DiscountBalance: {OldDiscount} -> {NewDiscount}",
@@ -157,11 +153,11 @@ public class VerifyBasePackagePaymentCommandHandler : IRequestHandler<VerifyBase
{
WalletId = userWallet.Id,
CurrentBalance = userWallet.Balance,
ChangeValue = BasePackageAmount,
ChangeValue = SystemConstants.BasePackageAmount,
CurrentNetworkBalance = userWallet.NetworkBalance,
ChangeNerworkValue = 0,
CurrentDiscountBalance = userWallet.DiscountBalance,
ChangeDiscountValue = BasePackageAmount,
ChangeDiscountValue = SystemConstants.BasePackageAmount,
IsIncrease = true,
RefrenceId = transaction.Id
};
@@ -1,3 +1,4 @@
using CMSMicroservice.Domain.Common;
using CMSMicroservice.Domain.Enums;
using CMSMicroservice.Domain.Events;
using CMSMicroservice.Domain.Entities.Order;
@@ -63,16 +64,11 @@ public class
}
long finalAmount = 0;
var vatIsEnable =
_context.SystemConfigurations
.FirstOrDefault(f => f.Scope == ConfigurationScope.VAT && f.Key == "IsEnabled")?.Value ??
"0";
if (vatIsEnable=="1")
// استفاده از SystemConstants برای VAT
if (SystemConstants.ShopVATEnabled)
{
_vatRate = float.Parse(
_context.SystemConfigurations
.FirstOrDefault(f => f.Scope == ConfigurationScope.VAT && f.Key == "Shop.VAT")?.Value ??
throw new InvalidOperationException());
_vatRate = (float)SystemConstants.ShopVAT;
finalAmount = AddVAT(user.UserCarts.Sum(s => s.Count * s.Product.Price));
_logger.LogInformation(
"Calculating final amount with VAT. Base Amount: {BaseAmount}, VAT Rate: {VATRate}, Final Amount: {FinalAmount}",
@@ -147,7 +143,7 @@ public class
{
ProductId = s.ProductId,
Count = s.Count,
UnitPrice =vatIsEnable=="1" ?AddVAT(s.Product.Price): s.Product.Price,
UnitPrice = SystemConstants.ShopVATEnabled ? AddVAT(s.Product.Price) : s.Product.Price,
OrderId = newOrder.Id
});
await _context.FactorDetails.AddRangeAsync(factorDetailsList, cancellationToken);
@@ -183,25 +179,15 @@ public class
{
try
{
// بررسی فعال بودن VAT
var vatEnabledConfig = await _context.SystemConfigurations
.FirstOrDefaultAsync(x => x.Scope == ConfigurationScope.VAT && x.Key == "IsEnabled", cancellationToken);
if (vatEnabledConfig == null || !bool.TryParse(vatEnabledConfig.Value, out var isEnabled) || !isEnabled)
// بررسی فعال بودن VAT از SystemConstants
if (!SystemConstants.ShopVATEnabled)
{
_logger.LogInformation("VAT is disabled. Skipping VAT calculation for order {OrderId}", orderId);
return false;
}
// دریافت نرخ VAT
var vatRateConfig = await _context.SystemConfigurations
.FirstOrDefaultAsync(x => x.Scope == ConfigurationScope.VAT && x.Key == "Shop.VAT", cancellationToken);
if (vatRateConfig == null || !decimal.TryParse(vatRateConfig.Value, out var vatRate))
{
_logger.LogWarning("VAT Rate configuration not found or invalid. Using default 0.09");
vatRate = 0.09m;
}
// دریافت نرخ VAT از SystemConstants
var vatRate = SystemConstants.ShopVAT;
// محاسبه مالیات
var vatAmount = (long)(orderAmount * vatRate);
@@ -0,0 +1,71 @@
namespace CMSMicroservice.Domain.Common;
/// <summary>
/// قالب‌های پیامک - همه پیام‌های SMS در یک جا
/// </summary>
public static class SmsTemplates
{
/// <summary>
/// پیام دریافت اعتبار دایا
/// </summary>
public static string DayaLoanReceived(string userName) =>
$"{userName} عزیز، تامین اعتبار شما انجام شد. شما می‌توانید با مراجعه به سامانه کارابازار فرایند فعال‌سازی خود را ادامه دهید. با تشکر از حسن انتخاب شما";
/// <summary>
/// پیام فعال‌سازی باشگاه مشتریان
/// </summary>
public static string ClubActivated(string userName) =>
$"{userName} گرامی، عضویت شما در باشگاه مشتریان کارابازار با موفقیت فعال شد. با تشکر از حسن انتخاب شما";
/// <summary>
/// پیام خرید پکیج
/// </summary>
public static string PackagePurchased(string userName, string packageName) =>
$"{userName} گرامی، خرید {packageName} با موفقیت انجام شد. با تشکر از حسن انتخاب شما";
/// <summary>
/// پیام واریز کمیسیون
/// </summary>
public static string CommissionDeposited(string userName, long amount) =>
$"{userName} گرامی، مبلغ {amount:N0} ریال بابت کمیسیون به کیف پول شما واریز شد.";
/// <summary>
/// پیام برداشت موفق
/// </summary>
public static string WithdrawalSuccess(string userName, long amount) =>
$"{userName} گرامی، درخواست برداشت شما به مبلغ {amount:N0} ریال با موفقیت ثبت شد.";
/// <summary>
/// پیام ورود به شبکه
/// </summary>
public static string NetworkJoined(string userName, string referrerName) =>
$"{userName} گرامی، شما با موفقیت در شبکه {referrerName} ثبت شدید. با تشکر از حسن انتخاب شما";
/// <summary>
/// پیام زیرمجموعه جدید
/// </summary>
public static string NewDownline(string userName, string downlineName) =>
$"{userName} گرامی، {downlineName} به عنوان زیرمجموعه شما ثبت شد.";
/// <summary>
/// پیام OTP
/// </summary>
public static string OtpCode(string code) =>
$"کد تایید شما: {code}\nکارابازار";
/// <summary>
/// پیام خوش‌آمدگویی
/// </summary>
public static string Welcome(string userName) =>
$"{userName} گرامی، به کارابازار خوش آمدید.";
#region Helper
/// <summary>
/// دریافت نام کاربر یا مقدار پیش‌فرض
/// </summary>
public static string GetUserName(string? fullName) =>
!string.IsNullOrWhiteSpace(fullName) ? fullName : "کاربر";
#endregion
}
@@ -0,0 +1,176 @@
namespace CMSMicroservice.Domain.Common;
/// <summary>
/// تنظیمات ثابت سیستم - Static و در مموری
/// این مقادیر هرگز تغییر نمی‌کنند و نیازی به جدول ندارند
/// </summary>
public static class SystemConstants
{
#region Network Settings
/// <summary>
/// اجازه حذف والدین که فرزند دارند
/// </summary>
public const bool NetworkAllowOrphanNodes = false;
/// <summary>
/// حداکثر تعداد فرزند مستقیم در هر پا
/// </summary>
public const int NetworkMaxChildrenPerLeg = 1;
#endregion
#region Club Settings
/// <summary>
/// مبلغ هدیه حق عضویت باشگاه (ریال) - این مبلغ از کیف پول کم نمی‌شود
/// </summary>
public const long ClubMembershipGiftValue = 25_200_000;
/// <summary>
/// هزینه فعال‌سازی عضویت باشگاه (ریال)
/// </summary>
public const long ClubActivationFee = 25_200_000;
#endregion
#region Package Settings
/// <summary>
/// مبلغ پکیج طلایی / پایه (ریال) - 56 میلیون تومان
/// شامل: هدیه باشگاه + هزینه فعال‌سازی + مزایای دیگر
/// </summary>
public const long BasePackageAmount = 56_000_000;
/// <summary>
/// مبلغ وام دایا (ریال) - همان مبلغ پکیج طلایی
/// </summary>
public const long DayaLoanAmount = 56_000_000;
#endregion
#region Commission Settings
/// <summary>
/// امکان برداشت نقدی فعال باشد
/// </summary>
public const bool CommissionCashWithdrawalEnabled = true;
/// <summary>
/// حداقل مبلغ برداشت (ریال)
/// </summary>
public const long CommissionMinWithdrawalAmount = 1_000_000;
/// <summary>
/// سقف تعادل هفتگی برای هر دست (چپ یا راست) - حداکثر کل = 600
/// </summary>
public const int CommissionMaxWeeklyBalancesPerLeg = 300;
/// <summary>
/// حداکثر عمق شبکه برای محاسبه کمیسیون (تعداد لول زیرمجموعه)
/// </summary>
public const int CommissionMaxNetworkLevel = 15;
/// <summary>
/// روش محاسبه (ORM یا SP)
/// </summary>
public const string CommissionCalculationStrategy = "SP";
#endregion
#region System Settings
/// <summary>
/// حالت تعمیر و نگهداری سیستم
/// </summary>
public const bool SystemMaintenanceMode = false;
/// <summary>
/// فعال‌سازی لاگ تغییرات
/// </summary>
public const bool SystemEnableAuditLog = true;
#endregion
#region Shop Settings
/// <summary>
/// مالیات بر ارزش افزوده (10%)
/// </summary>
public const decimal ShopVAT = 0.1m;
/// <summary>
/// مالیات فعال است؟
/// </summary>
public const bool ShopVATEnabled = true;
#endregion
#region Helper Methods
/// <summary>
/// دریافت مقدار به صورت دیکشنری برای نمایش در Admin Panel
/// </summary>
public static Dictionary<string, object> GetAllAsDict()
{
return new Dictionary<string, object>
{
// Network
["Network.AllowOrphanNodes"] = NetworkAllowOrphanNodes,
["Network.MaxChildrenPerLeg"] = NetworkMaxChildrenPerLeg,
// Club
["Club.MembershipGiftValue"] = ClubMembershipGiftValue,
["Club.ActivationFee"] = ClubActivationFee,
// Commission
["Commission.CashWithdrawalEnabled"] = CommissionCashWithdrawalEnabled,
["Commission.MinWithdrawalAmount"] = CommissionMinWithdrawalAmount,
["Commission.MaxWeeklyBalancesPerLeg"] = CommissionMaxWeeklyBalancesPerLeg,
["Commission.MaxNetworkLevel"] = CommissionMaxNetworkLevel,
["Commission.CalculationStrategy"] = CommissionCalculationStrategy,
// System
["System.MaintenanceMode"] = SystemMaintenanceMode,
["System.EnableAuditLog"] = SystemEnableAuditLog,
// Shop
["Shop.VAT"] = ShopVAT,
["Shop.VATEnabled"] = ShopVATEnabled
};
}
/// <summary>
/// دریافت لیست تنظیمات با توضیحات
/// </summary>
public static List<(string Key, object Value, string Description)> GetAllWithDescriptions()
{
return new List<(string Key, object Value, string Description)>
{
// Network
("Network.AllowOrphanNodes", NetworkAllowOrphanNodes, "اجازه حذف والدین که فرزند دارند"),
("Network.MaxChildrenPerLeg", NetworkMaxChildrenPerLeg, "حداکثر تعداد فرزند مستقیم در هر پا"),
// Club
("Club.MembershipGiftValue", ClubMembershipGiftValue, "مبلغ هدیه حق عضویت باشگاه (ریال)"),
("Club.ActivationFee", ClubActivationFee, "هزینه فعال‌سازی عضویت باشگاه (ریال)"),
// Commission
("Commission.CashWithdrawalEnabled", CommissionCashWithdrawalEnabled, "امکان برداشت نقدی فعال باشد"),
("Commission.MinWithdrawalAmount", CommissionMinWithdrawalAmount, "حداقل مبلغ برداشت (ریال)"),
("Commission.MaxWeeklyBalancesPerLeg", CommissionMaxWeeklyBalancesPerLeg, "سقف تعادل هفتگی برای هر دست"),
("Commission.MaxNetworkLevel", CommissionMaxNetworkLevel, "حداکثر عمق شبکه برای محاسبه کمیسیون"),
("Commission.CalculationStrategy", CommissionCalculationStrategy, "روش محاسبه (ORM/SP)"),
// System
("System.MaintenanceMode", SystemMaintenanceMode, "حالت تعمیر و نگهداری سیستم"),
("System.EnableAuditLog", SystemEnableAuditLog, "فعال‌سازی لاگ تغییرات"),
// Shop
("Shop.VAT", ShopVAT, "مالیات بر ارزش افزوده"),
("Shop.VATEnabled", ShopVATEnabled, "مالیات فعال است؟")
};
}
#endregion
}
@@ -0,0 +1,43 @@
namespace CMSMicroservice.Domain.Entities.Configuration;
/// <summary>
/// نسخه اپلیکیشن‌های فرانت‌اند
/// وقتی ورژن آپدیت بشه، فرانت‌ها باید کش و داده‌های محلی رو پاک کنن
/// </summary>
public class AppVersion : BaseAuditableEntity
{
/// <summary>
/// نام اپلیکیشن (FrontOffice, BackOffice, MobileApp)
/// </summary>
public string AppName { get; set; } = string.Empty;
/// <summary>
/// شماره نسخه فعلی (مثلاً 1.2.3)
/// </summary>
public string CurrentVersion { get; set; } = string.Empty;
/// <summary>
/// حداقل نسخه مورد نیاز - اگر کاربر از این پایین‌تر باشه باید آپدیت کنه
/// </summary>
public string MinRequiredVersion { get; set; } = string.Empty;
/// <summary>
/// آیا کش کامل باید پاک بشه؟
/// </summary>
public bool RequiresFullCacheClear { get; set; }
/// <summary>
/// پیام آپدیت برای نمایش به کاربر
/// </summary>
public string? UpdateMessage { get; set; }
/// <summary>
/// توضیحات تغییرات این نسخه
/// </summary>
public string? ReleaseNotes { get; set; }
/// <summary>
/// فعال یا غیرفعال
/// </summary>
public bool IsActive { get; set; } = true;
}
@@ -1,42 +0,0 @@
namespace CMSMicroservice.Domain.Entities.Configuration;
/// <summary>
/// تنظیمات پویای سیستم - قابل تغییر بدون Deployment
/// </summary>
public class SystemConfiguration : BaseAuditableEntity
{
/// <summary>
/// محدوده تنظیمات (System, Network, Club, Commission)
/// </summary>
public ConfigurationScope Scope { get; set; }
/// <summary>
/// کلید تنظیم (مثلاً "MaxWeeklyBalancesPerUser")
/// </summary>
public string Key { get; set; }
/// <summary>
/// مقدار به‌صورت رشته (تفسیر در Application Layer)
/// </summary>
public string Value { get; set; }
/// <summary>
/// نوع داده برای Validation و UI (Int/Decimal/Bool/String/Json)
/// </summary>
public string? DataType { get; set; }
/// <summary>
/// توضیحات برای ادمین
/// </summary>
public string? Description { get; set; }
/// <summary>
/// فعال یا غیرفعال
/// </summary>
public bool IsActive { get; set; }
/// <summary>
/// SystemConfigurationHistory Collection Navigation Reference
/// </summary>
public virtual ICollection<SystemConfigurationHistory>? SystemConfigurationHistories { get; set; }
}
@@ -1,47 +0,0 @@
namespace CMSMicroservice.Domain.Entities.History;
/// <summary>
/// تاریخچه تغییرات تنظیمات سیستم (برای Audit)
/// </summary>
public class SystemConfigurationHistory : BaseAuditableEntity
{
/// <summary>
/// شناسه تنظیم
/// </summary>
public long ConfigurationId { get; set; }
/// <summary>
/// SystemConfiguration Navigation Property
/// </summary>
public virtual SystemConfiguration? Configuration { get; set; }
/// <summary>
/// محدوده تنظیمات
/// </summary>
public ConfigurationScope Scope { get; set; }
/// <summary>
/// کلید تنظیم
/// </summary>
public string Key { get; set; }
/// <summary>
/// مقدار قبل از تغییر
/// </summary>
public string OldValue { get; set; }
/// <summary>
/// مقدار بعد از تغییر
/// </summary>
public string NewValue { get; set; }
/// <summary>
/// دلیل تغییر (اختیاری)
/// </summary>
public string? Reason { get; set; }
/// <summary>
/// چه کسی انجام داده (UserId یا "System")
/// </summary>
public string? PerformedBy { get; set; }
}
@@ -0,0 +1,58 @@
namespace CMSMicroservice.Domain.Enums;
/// <summary>
/// انواع ویژگی‌های باشگاه مشتریان
/// </summary>
public enum ClubFeatureType
{
/// <summary>
/// چتیکا - دستیار هوش مصنوعی
/// </summary>
Chatika = 1,
/// <summary>
/// بیمه - خدمات بیمه‌ای
/// </summary>
Bime = 2,
/// <summary>
/// تریپ - خدمات سفر و گردشگری
/// </summary>
Trip = 3,
/// <summary>
/// لرن - آموزش و یادگیری
/// </summary>
Learn = 4
}
/// <summary>
/// Extension methods برای ClubFeatureType
/// </summary>
public static class ClubFeatureTypeExtensions
{
/// <summary>
/// دریافت تمام مقادیر ClubFeatureType به صورت آرایه long
/// </summary>
public static long[] GetAllFeatureIds()
{
return Enum.GetValues<ClubFeatureType>()
.Select(f => (long)f)
.ToArray();
}
/// <summary>
/// دریافت عنوان فارسی ویژگی
/// </summary>
public static string GetPersianTitle(this ClubFeatureType featureType)
{
return featureType switch
{
ClubFeatureType.Chatika => "چتیکا",
ClubFeatureType.Bime => "بیمه",
ClubFeatureType.Trip => "تور و سفر",
ClubFeatureType.Learn => "آموزش",
_ => featureType.ToString()
};
}
}
@@ -1,32 +0,0 @@
namespace CMSMicroservice.Domain.Enums;
/// <summary>
/// محدوده تنظیمات سیستم (Scope)
/// </summary>
public enum ConfigurationScope
{
/// <summary>
/// تنظیمات کلی سیستم
/// </summary>
System = 0,
/// <summary>
/// تنظیمات شبکه باینری
/// </summary>
Network = 1,
/// <summary>
/// تنظیمات باشگاه مشتریان
/// </summary>
Club = 2,
/// <summary>
/// تنظیمات کمیسیون
/// </summary>
Commission = 3,
/// <summary>
/// تنظیمات مالیات بر ارزش افزوده
/// </summary>
VAT = 4
}
@@ -0,0 +1,194 @@
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Entities.Club;
using CMSMicroservice.Domain.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Polly;
namespace CMSMicroservice.Infrastructure.BackgroundJobs;
/// <summary>
/// Hangfire Job برای فعال‌سازی حساب چتیکا برای اعضای جدید باشگاه
/// این Job کاربرانی که باشگاهشان فعال شده ولی حساب چتیکا ندارند را پیدا کرده و حساب می‌سازد
/// </summary>
public class ChatikaAccountActivationJob
{
private readonly IApplicationDbContext _context;
private readonly IChatikaApiService _chatikaApiService;
private readonly IConfiguration _configuration;
private readonly ILogger<ChatikaAccountActivationJob> _logger;
private readonly ResiliencePipeline _retryPipeline;
/// <summary>
/// توضیحات فارسی فیچر چتیکا
/// </summary>
private const string ChatikaFeatureDescription =
"🎉 تبریک! حساب هوش مصنوعی چتیکا شما فعال شد.\n\n" +
"برای استفاده از امکانات رایگان چتیکا:\n" +
"1️⃣ به وب‌سایت chatika.ir مراجعه کنید\n" +
"2️⃣ شماره موبایل خود را وارد کنید\n" +
"3️⃣ از دستیار هوشمند چتیکا لذت ببرید!\n\n" +
"🔗 لینک ورود: https://chatika.ir";
public ChatikaAccountActivationJob(
IApplicationDbContext context,
IChatikaApiService chatikaApiService,
IConfiguration configuration,
ILogger<ChatikaAccountActivationJob> logger)
{
_context = context;
_chatikaApiService = chatikaApiService;
_configuration = configuration;
_logger = logger;
// Polly Retry: 3 تلاش با فاصله نمایی
_retryPipeline = new ResiliencePipelineBuilder()
.AddRetry(new Polly.Retry.RetryStrategyOptions
{
MaxRetryAttempts = 3,
Delay = TimeSpan.FromSeconds(30),
BackoffType = Polly.DelayBackoffType.Exponential,
UseJitter = true,
OnRetry = args =>
{
_logger.LogWarning(
"⚠️ Retry attempt {AttemptNumber} for Chatika API. Exception: {ExceptionType}",
args.AttemptNumber,
args.Outcome.Exception?.GetType().Name ?? "None");
return ValueTask.CompletedTask;
}
})
.Build();
}
/// <summary>
/// اجرای Job برای فعال‌سازی حساب چتیکا
/// </summary>
public async Task ExecuteAsync(CancellationToken cancellationToken = default)
{
// Check if Chatika integration is enabled
var enabled = _configuration.GetValue<bool>("Chatika:Enabled");
if (!enabled)
{
_logger.LogDebug("Chatika integration is disabled (Chatika:Enabled = false). Skipping job.");
return;
}
var executionId = Guid.NewGuid();
_logger.LogInformation(
"🚀 [{ExecutionId}] Starting Chatika account activation job",
executionId);
try
{
// پیدا کردن کاربرانی که:
// 1. باشگاه فعال دارند (ClubMembership.IsActive = true)
// 2. فیچر چتیکا (Id=1) رو دارند
// 3. فیچر چتیکاشون هنوز Notes نداره (یعنی حساب ساخته نشده)
var pendingUsers = await _context.UserClubFeatures
.Include(ucf => ucf.User)
.Include(ucf => ucf.ClubMembership)
.Where(ucf =>
ucf.ClubFeatureId == (long)ClubFeatureType.Chatika &&
ucf.ClubMembership.IsActive &&
!ucf.IsDeleted &&
!ucf.IsActive) // حساب هنوز ساخته نشده
.ToListAsync(cancellationToken);
if (!pendingUsers.Any())
{
_logger.LogInformation(
"✅ [{ExecutionId}] No pending users for Chatika activation",
executionId);
return;
}
_logger.LogInformation(
"📋 [{ExecutionId}] Found {Count} users pending Chatika activation",
executionId,
pendingUsers.Count);
var successCount = 0;
var failCount = 0;
foreach (var userFeature in pendingUsers)
{
try
{
// بررسی تکراری نبودن (Double-check)
if (userFeature.IsActive)
{
_logger.LogDebug(
"⏭️ Skipping user {UserId} - already processed",
userFeature.UserId);
continue;
}
var user = userFeature.User;
var fullName = $"{user.FirstName} {user.LastName}".Trim();
if (string.IsNullOrWhiteSpace(fullName)) fullName = user.Mobile;
// کال کردن API چتیکا با retry
var result = await _retryPipeline.ExecuteAsync(
async ct => await _chatikaApiService.CreateAccountAsync(
user.Mobile,
fullName,
ct),
cancellationToken);
if (result.IsSuccess)
{
// آپدیت فیچر با توضیحات و URL
var description = ChatikaFeatureDescription;
if (!string.IsNullOrEmpty(result.AccessUrl))
{
description = description.Replace(
"https://chatika.ir",
result.AccessUrl);
}
userFeature.Notes = description;
userFeature.IsActive = true;
userFeature.LastModified = DateTime.Now;
await _context.SaveChangesAsync(cancellationToken);
_logger.LogInformation(
"✅ Chatika account activated for user {UserId}",
userFeature.UserId);
successCount++;
}
else
{
_logger.LogWarning(
"⚠️ Failed to create Chatika account for user {UserId}: {Error}",
userFeature.UserId,
result.ErrorMessage);
failCount++;
}
}
catch (Exception ex)
{
_logger.LogError(ex,
"❌ Error processing Chatika activation for user {UserId}",
userFeature.UserId);
failCount++;
}
}
_logger.LogInformation(
"🏁 [{ExecutionId}] Chatika activation job completed. Success: {Success}, Failed: {Failed}",
executionId,
successCount,
failCount);
}
catch (Exception ex)
{
_logger.LogError(ex,
"❌ [{ExecutionId}] Chatika activation job failed",
executionId);
throw;
}
}
}
@@ -34,6 +34,7 @@ public static class ConfigureServices
services.AddScoped<INetworkPlacementService, NetworkPlacementService>();
services.AddScoped<IAlertService, AlertService>();
services.AddScoped<IUserNotificationService, UserNotificationService>();
services.AddScoped<IKavenegarService, KavenegarService>();
// Daya Loan API Service - قابل تغییر بین Mock و Real
var useMockDayaApi = configuration.GetValue<bool>("DayaApi:UseMock", false);
@@ -92,9 +93,19 @@ public static class ConfigureServices
// Commission Calculation Strategy Factory - برای سوییچ بین ORM و SP
services.AddScoped<ICommissionCalculationStrategyFactory, CommissionCalculationStrategyFactory>();
// Chatika API Service - سرویس ایجاد حساب چتیکا
services.AddHttpClient<IChatikaApiService, ChatikaApiService>()
.SetHandlerLifetime(TimeSpan.FromMinutes(5))
.ConfigureHttpClient((sp, client) =>
{
var config = sp.GetRequiredService<IConfiguration>();
client.Timeout = TimeSpan.FromSeconds(30);
});
// Background Workers - Deprecated: Using Hangfire instead
// services.AddHostedService<WeeklyNetworkCommissionWorker>();
services.AddScoped<WeeklyCommissionJob>(); // Hangfire Job (Scoped for DI)
services.AddScoped<ChatikaAccountActivationJob>(); // Hangfire Job for Chatika activation
if (configuration.GetValue<bool>("UseInMemoryDatabase"))
{
@@ -84,9 +84,8 @@ public class ApplicationDbContext : DbContext, IApplicationDbContext
// ============= Network Club System DbSets =============
// Configuration
public DbSet<SystemConfiguration> SystemConfigurations => Set<SystemConfiguration>();
public DbSet<SystemConfigurationHistory> SystemConfigurationHistories => Set<SystemConfigurationHistory>();
// App Version
public DbSet<AppVersion> AppVersions => Set<AppVersion>();
// Club Management
public DbSet<ClubMembership> ClubMemberships => Set<ClubMembership>();
@@ -1,12 +1,9 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using CMSMicroservice.Domain.Entities.Configuration;
using CMSMicroservice.Domain.Enums;
using System.Collections.Generic;
namespace CMSMicroservice.Infrastructure.Persistence;
public class ApplicationDbContextInitialiser
public class ApplicationDbContextInitialiser
{
private readonly ApplicationDbContext _context;
private readonly ILogger<ApplicationDbContextInitialiser> _logger;
@@ -32,6 +29,7 @@ public class ApplicationDbContextInitialiser
throw;
}
}
public async Task SeedAsync()
{
try
@@ -44,113 +42,12 @@ public class ApplicationDbContextInitialiser
throw;
}
}
public async Task TrySeedAsync()
public Task TrySeedAsync()
{
// Seed / upsert default System Configurations for Network-Club-Commission System
var desiredConfigurations = new List<SystemConfiguration>
{
// Network Configuration
new SystemConfiguration
{
Key = "Network.MaxNetworkDepth",
Value = "15",
Description = "حداکثر عمق شبکه باینری",
Scope = ConfigurationScope.Network,
IsActive = true
},
new SystemConfiguration
{
Key = "Network.MaxChildrenPerLeg",
Value = "1",
Description = "حداکثر تعداد فرزند مستقیم در هر پا",
Scope = ConfigurationScope.Network,
IsActive = true
},
// Commission Configuration
new SystemConfiguration
{
Key = "Commission.MaxWeeklyBalancesPerLeg",
Value = "300",
Description = "سقف تعادل هفتگی برای هر دست (چپ یا راست) - حداکثر کل = 600",
Scope = ConfigurationScope.Commission,
IsActive = true
},
new SystemConfiguration
{
Key = "Commission.MaxNetworkLevel",
Value = "15",
Description = "حداکثر عمق شبکه برای محاسبه کمیسیون (تعداد لول زیرمجموعه)",
Scope = ConfigurationScope.Commission,
IsActive = true
},
new SystemConfiguration
{
Key = "Commission.MinWithdrawalAmount",
Value = "1000000",
Description = "حداقل مبلغ برداشت (ریال)",
Scope = ConfigurationScope.Commission,
IsActive = true
},
new SystemConfiguration
{
Key = "Commission.DefaultInitialContribution",
Value = "25000000",
Description = "مبلغ پیش‌فرض مشارکت/هزینه فعال‌سازی",
Scope = ConfigurationScope.Commission,
IsActive = true
},
new SystemConfiguration
{
Key = "Commission.WeeklyPoolContributionPercent",
Value = "20",
Description = "درصد مشارکت در استخر هفتگی از کل فعال‌سازی‌های جدید شبکه (20%)",
Scope = ConfigurationScope.Commission,
IsActive = true
},
// Club Configuration
new SystemConfiguration
{
Key = "Club.ActivationFee",
Value = "25000000",
Description = "هزینه فعال‌سازی عضویت باشگاه (ریال)",
Scope = ConfigurationScope.Club,
IsActive = true
},
new SystemConfiguration
{
Key = "Club.MembershipGiftValue",
Value = "25200000",
Description = "مبلغ هدیه حق عضویت باشگاه (ریال) - این مبلغ از کیف پول کم نمی‌شود",
Scope = ConfigurationScope.Club,
IsActive = true
},
// System Configuration
new SystemConfiguration
{
Key = "System.EnableAuditLog",
Value = "true",
Description = "فعال‌سازی لاگ تغییرات",
Scope = ConfigurationScope.System,
IsActive = true
}
};
var existingKeys = _context.SystemConfigurations
.Select(c => c.Key)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var newConfigs = desiredConfigurations
.Where(c => !existingKeys.Contains(c.Key))
.ToList();
if (newConfigs.Any())
{
await _context.SystemConfigurations.AddRangeAsync(newConfigs);
await _context.SaveChangesAsync();
_logger.LogInformation("Seeded {Count} default system configurations", newConfigs.Count);
}
// SystemConfigurations دیگه در دیتابیس نیست
// مقادیر کانفیگ حالا در SystemConstants.cs به صورت const تعریف شدن
_logger.LogInformation("Database seeding completed. System configurations are now defined as compile-time constants in SystemConstants.cs");
return Task.CompletedTask;
}
}
@@ -1,35 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace CMSMicroservice.Infrastructure.Persistence.Configurations;
/// <summary>
/// تنظیمات پویای سیستم
/// </summary>
public class SystemConfigurationConfiguration : IEntityTypeConfiguration<SystemConfiguration>
{
public void Configure(EntityTypeBuilder<SystemConfiguration> builder)
{
builder.HasQueryFilter(p => !p.IsDeleted);
builder.Ignore(entity => entity.DomainEvents);
builder.HasKey(entity => entity.Id);
builder.Property(entity => entity.Id).UseIdentityColumn();
builder.Property(entity => entity.Scope).IsRequired();
builder.Property(entity => entity.Key).IsRequired().HasMaxLength(200);
builder.Property(entity => entity.Value).IsRequired().HasMaxLength(1000);
builder.Property(entity => entity.DataType).IsRequired(false).HasMaxLength(50);
builder.Property(entity => entity.Description).IsRequired(false).HasMaxLength(500);
builder.Property(entity => entity.IsActive).IsRequired();
// Composite Index برای جستجوی سریع
builder.HasIndex(e => new { e.Scope, e.Key })
.IsUnique()
.HasDatabaseName("IX_SystemConfiguration_Scope_Key");
// Index برای IsActive
builder.HasIndex(e => e.IsActive)
.HasDatabaseName("IX_SystemConfiguration_IsActive");
}
}
@@ -1,41 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace CMSMicroservice.Infrastructure.Persistence.Configurations;
/// <summary>
/// تاریخچه تغییرات تنظیمات سیستم
/// </summary>
public class SystemConfigurationHistoryConfiguration : IEntityTypeConfiguration<SystemConfigurationHistory>
{
public void Configure(EntityTypeBuilder<SystemConfigurationHistory> builder)
{
builder.HasQueryFilter(p => !p.IsDeleted);
builder.Ignore(entity => entity.DomainEvents);
builder.HasKey(entity => entity.Id);
builder.Property(entity => entity.Id).UseIdentityColumn();
builder.Property(entity => entity.ConfigurationId).IsRequired();
builder.Property(entity => entity.Scope).IsRequired();
builder.Property(entity => entity.Key).IsRequired().HasMaxLength(200);
builder.Property(entity => entity.OldValue).IsRequired().HasMaxLength(1000);
builder.Property(entity => entity.NewValue).IsRequired().HasMaxLength(1000);
builder.Property(entity => entity.Reason).IsRequired(false).HasMaxLength(500);
builder.Property(entity => entity.PerformedBy).IsRequired(false).HasMaxLength(100);
// رابطه با SystemConfiguration
builder.HasOne(entity => entity.Configuration)
.WithMany(sc => sc.SystemConfigurationHistories)
.HasForeignKey(entity => entity.ConfigurationId)
.OnDelete(DeleteBehavior.Restrict);
// Index برای ConfigurationId و Created
builder.HasIndex(e => new { e.ConfigurationId, e.Created })
.HasDatabaseName("IX_SystemConfigurationHistory_ConfigId_Created");
// Index برای Scope و Key
builder.HasIndex(e => new { e.Scope, e.Key })
.HasDatabaseName("IX_SystemConfigurationHistory_Scope_Key");
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,22 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace CMSMicroservice.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class u18 : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}
@@ -0,0 +1,48 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace CMSMicroservice.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddAppVersionsTable : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "AppVersions",
schema: "CMS",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
AppName = table.Column<string>(type: "nvarchar(max)", nullable: false),
CurrentVersion = table.Column<string>(type: "nvarchar(max)", nullable: false),
MinRequiredVersion = table.Column<string>(type: "nvarchar(max)", nullable: false),
RequiresFullCacheClear = table.Column<bool>(type: "bit", nullable: false),
UpdateMessage = table.Column<string>(type: "nvarchar(max)", nullable: true),
ReleaseNotes = table.Column<string>(type: "nvarchar(max)", nullable: true),
IsActive = table.Column<bool>(type: "bit", nullable: false),
Created = table.Column<DateTime>(type: "datetime2", nullable: false),
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
LastModified = table.Column<DateTime>(type: "datetime2", nullable: true),
LastModifiedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
IsDeleted = table.Column<bool>(type: "bit", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AppVersions", x => x.Id);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AppVersions",
schema: "CMS");
}
}
}
@@ -0,0 +1,108 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace CMSMicroservice.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class RemoveSystemConfigurationsTables : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "SystemConfigurationHistories",
schema: "CMS");
migrationBuilder.DropTable(
name: "SystemConfigurations",
schema: "CMS");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "SystemConfigurations",
schema: "CMS",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
Created = table.Column<DateTime>(type: "datetime2", nullable: false),
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
DataType = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: true),
Description = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
IsActive = table.Column<bool>(type: "bit", nullable: false),
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
Key = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
LastModified = table.Column<DateTime>(type: "datetime2", nullable: true),
LastModifiedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
Scope = table.Column<int>(type: "int", nullable: false),
Value = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_SystemConfigurations", x => x.Id);
});
migrationBuilder.CreateTable(
name: "SystemConfigurationHistories",
schema: "CMS",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
ConfigurationId = table.Column<long>(type: "bigint", nullable: false),
Created = table.Column<DateTime>(type: "datetime2", nullable: false),
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
Key = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
LastModified = table.Column<DateTime>(type: "datetime2", nullable: true),
LastModifiedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
NewValue = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: false),
OldValue = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: false),
PerformedBy = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true),
Reason = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
Scope = table.Column<int>(type: "int", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_SystemConfigurationHistories", x => x.Id);
table.ForeignKey(
name: "FK_SystemConfigurationHistories_SystemConfigurations_ConfigurationId",
column: x => x.ConfigurationId,
principalSchema: "CMS",
principalTable: "SystemConfigurations",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_SystemConfigurationHistory_ConfigId_Created",
schema: "CMS",
table: "SystemConfigurationHistories",
columns: new[] { "ConfigurationId", "Created" });
migrationBuilder.CreateIndex(
name: "IX_SystemConfigurationHistory_Scope_Key",
schema: "CMS",
table: "SystemConfigurationHistories",
columns: new[] { "Scope", "Key" });
migrationBuilder.CreateIndex(
name: "IX_SystemConfiguration_IsActive",
schema: "CMS",
table: "SystemConfigurations",
column: "IsActive");
migrationBuilder.CreateIndex(
name: "IX_SystemConfiguration_Scope_Key",
schema: "CMS",
table: "SystemConfigurations",
columns: new[] { "Scope", "Key" },
unique: true);
}
}
}
@@ -451,7 +451,7 @@ namespace CMSMicroservice.Infrastructure.Persistence.Migrations
b.ToTable("WorkerExecutionLogs", "CMS");
});
modelBuilder.Entity("CMSMicroservice.Domain.Entities.Configuration.SystemConfiguration", b =>
modelBuilder.Entity("CMSMicroservice.Domain.Entities.Configuration.AppVersion", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
@@ -459,19 +459,19 @@ namespace CMSMicroservice.Infrastructure.Persistence.Migrations
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<string>("AppName")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<DateTime>("Created")
.HasColumnType("datetime2");
b.Property<string>("CreatedBy")
.HasColumnType("nvarchar(max)");
b.Property<string>("DataType")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<string>("CurrentVersion")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<bool>("IsActive")
.HasColumnType("bit");
@@ -479,35 +479,28 @@ namespace CMSMicroservice.Infrastructure.Persistence.Migrations
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<DateTime?>("LastModified")
.HasColumnType("datetime2");
b.Property<string>("LastModifiedBy")
.HasColumnType("nvarchar(max)");
b.Property<int>("Scope")
.HasColumnType("int");
b.Property<string>("Value")
b.Property<string>("MinRequiredVersion")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
.HasColumnType("nvarchar(max)");
b.Property<string>("ReleaseNotes")
.HasColumnType("nvarchar(max)");
b.Property<bool>("RequiresFullCacheClear")
.HasColumnType("bit");
b.Property<string>("UpdateMessage")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.HasIndex("IsActive")
.HasDatabaseName("IX_SystemConfiguration_IsActive");
b.HasIndex("Scope", "Key")
.IsUnique()
.HasDatabaseName("IX_SystemConfiguration_Scope_Key");
b.ToTable("SystemConfigurations", "CMS");
b.ToTable("AppVersions", "CMS");
});
modelBuilder.Entity("CMSMicroservice.Domain.Entities.Contract", b =>
@@ -1452,69 +1445,6 @@ namespace CMSMicroservice.Infrastructure.Persistence.Migrations
b.ToTable("NetworkMembershipHistories", "CMS");
});
modelBuilder.Entity("CMSMicroservice.Domain.Entities.History.SystemConfigurationHistory", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<long>("ConfigurationId")
.HasColumnType("bigint");
b.Property<DateTime>("Created")
.HasColumnType("datetime2");
b.Property<string>("CreatedBy")
.HasColumnType("nvarchar(max)");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<DateTime?>("LastModified")
.HasColumnType("datetime2");
b.Property<string>("LastModifiedBy")
.HasColumnType("nvarchar(max)");
b.Property<string>("NewValue")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<string>("OldValue")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<string>("PerformedBy")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("Reason")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<int>("Scope")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("ConfigurationId", "Created")
.HasDatabaseName("IX_SystemConfigurationHistory_ConfigId_Created");
b.HasIndex("Scope", "Key")
.HasDatabaseName("IX_SystemConfigurationHistory_Scope_Key");
b.ToTable("SystemConfigurationHistories", "CMS");
});
modelBuilder.Entity("CMSMicroservice.Domain.Entities.Network.NetworkWeeklyBalance", b =>
{
b.Property<long>("Id")
@@ -3186,17 +3116,6 @@ namespace CMSMicroservice.Infrastructure.Persistence.Migrations
b.Navigation("WeekDefinition");
});
modelBuilder.Entity("CMSMicroservice.Domain.Entities.History.SystemConfigurationHistory", b =>
{
b.HasOne("CMSMicroservice.Domain.Entities.Configuration.SystemConfiguration", "Configuration")
.WithMany("SystemConfigurationHistories")
.HasForeignKey("ConfigurationId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Configuration");
});
modelBuilder.Entity("CMSMicroservice.Domain.Entities.Network.NetworkWeeklyBalance", b =>
{
b.HasOne("CMSMicroservice.Domain.Entities.User", "User")
@@ -3501,11 +3420,6 @@ namespace CMSMicroservice.Infrastructure.Persistence.Migrations
b.Navigation("UserCommissionPayouts");
});
modelBuilder.Entity("CMSMicroservice.Domain.Entities.Configuration.SystemConfiguration", b =>
{
b.Navigation("SystemConfigurationHistories");
});
modelBuilder.Entity("CMSMicroservice.Domain.Entities.Contract", b =>
{
b.Navigation("UserContracts");
@@ -65,19 +65,11 @@ BEGIN
AND IsActive = 1;
-- =============================================
-- 4. خواندن Configuration ها
-- 4. مقادیر ثابت (Hardcoded - از SystemConstants)
-- =============================================
SELECT @MaxBalancesPerLeg = CAST(Value AS INT)
FROM CMS.SystemConfigurations
WHERE [Key] = 'Commission.MaxWeeklyBalancesPerLeg' AND IsActive = 1;
SELECT @MaxNetworkLevel = CAST(Value AS INT)
FROM CMS.SystemConfigurations
WHERE [Key] = 'Commission.MaxNetworkLevel' AND IsActive = 1;
-- مقادیر پیش‌فرض
SET @MaxBalancesPerLeg = ISNULL(@MaxBalancesPerLeg, 300);
SET @MaxNetworkLevel = ISNULL(@MaxNetworkLevel, 15);
-- این مقادیر ثابت هستند و تغییر نمی‌کنند
SET @MaxBalancesPerLeg = 300; -- سقف تعادل هر پا
SET @MaxNetworkLevel = 15; -- حداکثر عمق شبکه
-- =============================================
-- 5. ایجاد جدول موقت برای نتایج
@@ -0,0 +1,156 @@
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using CMSMicroservice.Application.Common.Interfaces;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
namespace CMSMicroservice.Infrastructure.Services;
/// <summary>
/// پیاده‌سازی سرویس چتیکا با HttpClient
/// API: POST /api/v1/organizations/register-user
/// </summary>
public class ChatikaApiService : IChatikaApiService
{
private readonly HttpClient _httpClient;
private readonly ILogger<ChatikaApiService> _logger;
private readonly string _baseUrl;
private readonly string _apiKey;
public ChatikaApiService(
HttpClient httpClient,
ILogger<ChatikaApiService> logger,
IConfiguration configuration)
{
_httpClient = httpClient;
_logger = logger;
// تنظیمات از appsettings.json
_baseUrl = configuration["Chatika:BaseUrl"] ?? "https://api.chatika.ir";
_apiKey = configuration["Chatika:ApiKey"] ?? "";
// تنظیم HttpClient
_httpClient.BaseAddress = new Uri(_baseUrl);
if (!string.IsNullOrEmpty(_apiKey))
{
_httpClient.DefaultRequestHeaders.Add("X-API-Key", _apiKey);
}
}
/// <inheritdoc />
public async Task<ChatikaAccountResult> CreateAccountAsync(
string mobileNumber,
string fullName,
CancellationToken cancellationToken = default)
{
try
{
_logger.LogInformation(
"🤖 Creating Chatika account for mobile: {Mobile}",
mobileNumber.Substring(0, 4) + "***");
var request = new ChatikaRegisterRequest
{
MobileNumber = mobileNumber
};
var response = await _httpClient.PostAsJsonAsync(
"/api/v1/organizations/register-user",
request,
cancellationToken);
if (response.IsSuccessStatusCode)
{
var result = await response.Content.ReadFromJsonAsync<ChatikaRegisterResponse>(
cancellationToken: cancellationToken);
_logger.LogInformation(
"✅ Chatika account created successfully. UserId: {UserId}, IsNewUser: {IsNew}, Credit: {Credit}",
result?.Id,
result?.IsNewUser,
result?.CreditCharged);
return ChatikaAccountResult.Success(
chatikaUserId: result?.Id.ToString(),
accessUrl: "https://chatika.ir"
);
}
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken);
var errorResponse = JsonSerializer.Deserialize<ChatikaErrorResponse>(errorContent);
_logger.LogWarning(
"⚠️ Chatika API returned error. Status: {Status}, Code: {ErrorCode}, Message: {Message}",
response.StatusCode,
errorResponse?.ErrorCode,
errorResponse?.Message);
return ChatikaAccountResult.Failure($"{errorResponse?.ErrorCode}: {errorResponse?.Message}");
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "❌ Network error calling Chatika API");
return ChatikaAccountResult.Failure($"خطای شبکه: {ex.Message}");
}
catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException)
{
_logger.LogError(ex, "❌ Timeout calling Chatika API");
return ChatikaAccountResult.Failure("تایم‌اوت در ارتباط با سرویس چتیکا");
}
catch (Exception ex)
{
_logger.LogError(ex, "❌ Unexpected error calling Chatika API");
return ChatikaAccountResult.Failure($"خطای غیرمنتظره: {ex.Message}");
}
}
/// <summary>
/// Request model برای ثبت کاربر در چتیکا
/// </summary>
private class ChatikaRegisterRequest
{
[JsonPropertyName("mobile_number")]
public string MobileNumber { get; set; } = string.Empty;
}
/// <summary>
/// Response model موفق از چتیکا
/// </summary>
private class ChatikaRegisterResponse
{
[JsonPropertyName("id")]
public long Id { get; set; }
[JsonPropertyName("mobile_number")]
public string MobileNumber { get; set; } = string.Empty;
[JsonPropertyName("organization_id")]
public long OrganizationId { get; set; }
[JsonPropertyName("organization_title")]
public string OrganizationTitle { get; set; } = string.Empty;
[JsonPropertyName("wallet_balance")]
public decimal WalletBalance { get; set; }
[JsonPropertyName("is_new_user")]
public bool IsNewUser { get; set; }
[JsonPropertyName("credit_charged")]
public decimal CreditCharged { get; set; }
}
/// <summary>
/// Response model خطا از چتیکا
/// </summary>
private class ChatikaErrorResponse
{
[JsonPropertyName("error_code")]
public string ErrorCode { get; set; } = string.Empty;
[JsonPropertyName("message")]
public string Message { get; set; } = string.Empty;
}
}
@@ -1,4 +1,5 @@
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Common;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
@@ -13,13 +14,6 @@ public class CommissionCalculationStrategyFactory : ICommissionCalculationStrate
private readonly IWeekDefinitionRepository _weekRepository;
private readonly IServiceProvider _serviceProvider;
/// <summary>
/// کلید Config برای انتخاب استراتژی
/// مقدار: "ORM" یا "SP"
/// پیش‌فرض: "ORM"
/// </summary>
private const string ConfigKey = "Commission.CalculationStrategy";
public CommissionCalculationStrategyFactory(
IApplicationDbContext context,
IWeekDefinitionRepository weekRepository,
@@ -31,13 +25,10 @@ public class CommissionCalculationStrategyFactory : ICommissionCalculationStrate
}
/// <inheritdoc />
public async Task<ICommissionCalculationStrategy> CreateStrategyAsync(CancellationToken cancellationToken = default)
public Task<ICommissionCalculationStrategy> CreateStrategyAsync(CancellationToken cancellationToken = default)
{
// خواندن Config از دیتابیس
var config = await _context.SystemConfigurations
.FirstOrDefaultAsync(x => x.Key == ConfigKey && x.IsActive, cancellationToken);
var strategyValue = config?.Value?.ToUpperInvariant() ?? "ORM";
// خواندن Config از SystemConstants (استاتیک)
var strategyValue = SystemConstants.CommissionCalculationStrategy.ToUpperInvariant();
var strategyType = strategyValue switch
{
@@ -45,7 +36,7 @@ public class CommissionCalculationStrategyFactory : ICommissionCalculationStrate
_ => CommissionCalculationStrategyType.Orm
};
return CreateStrategy(strategyType);
return Task.FromResult(CreateStrategy(strategyType));
}
/// <inheritdoc />
@@ -1,5 +1,6 @@
using System.Collections.Generic;
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Common;
using CMSMicroservice.Domain.Entities.Club;
using CMSMicroservice.Domain.Entities.Commission;
using CMSMicroservice.Domain.Entities.Network;
@@ -87,15 +88,9 @@ public class OrmCommissionCalculationStrategy : ICommissionCalculationStrategy
var balancesList = new List<NetworkWeeklyBalance>();
var calculatedAt = DateTime.Now;
// خواندن یکباره Configuration ها
var configs = await _context.SystemConfigurations
.Where(x => x.IsActive && (
x.Key == "Commission.MaxWeeklyBalancesPerLeg" ||
x.Key == "Commission.MaxNetworkLevel"))
.ToDictionaryAsync(x => x.Key, x => x.Value, cancellationToken);
var maxBalancesPerLeg = int.Parse(configs.GetValueOrDefault("Commission.MaxWeeklyBalancesPerLeg", "300"));
var maxNetworkLevel = int.Parse(configs.GetValueOrDefault("Commission.MaxNetworkLevel", "15"));
// استفاده از SystemConstants به جای دیتابیس
var maxBalancesPerLeg = SystemConstants.CommissionMaxWeeklyBalancesPerLeg;
var maxNetworkLevel = SystemConstants.CommissionMaxNetworkLevel;
foreach (var user in usersInNetwork.OrderBy(o => o.Id))
{
@@ -0,0 +1,109 @@
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Infrastructure.Configuration;
using Kavenegar;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace CMSMicroservice.Infrastructure.Services;
/// <summary>
/// پیاده‌سازی سرویس ارسال SMS با کاوه‌نگار
/// </summary>
public class KavenegarService : IKavenegarService
{
private readonly KavenegarApi? _kavenegarApi;
private readonly SmsSettings _smsSettings;
private readonly ILogger<KavenegarService> _logger;
public KavenegarService(
IOptions<SmsSettings> smsSettings,
ILogger<KavenegarService> logger)
{
_smsSettings = smsSettings.Value;
_logger = logger;
// Initialize Kavenegar API
if (_smsSettings.Enabled && !string.IsNullOrEmpty(_smsSettings.KavenegarApiKey))
{
try
{
_kavenegarApi = new KavenegarApi(_smsSettings.KavenegarApiKey);
}
catch (Exception ex)
{
_logger.LogError(ex, "❌ Failed to initialize Kavenegar API");
}
}
}
/// <inheritdoc />
public async Task SendAsync(string mobile, string message)
{
if (!_smsSettings.Enabled)
{
_logger.LogInformation("SMS is disabled. Skipping send to {Mobile}", mobile);
return;
}
if (_kavenegarApi == null)
{
_logger.LogWarning("⚠️ Kavenegar API not initialized, cannot send SMS");
return;
}
try
{
// Kavenegar Send is synchronous
await Task.Run(() =>
{
var result = _kavenegarApi.Send(
sender: _smsSettings.Sender,
receptor: mobile,
message: message);
_logger.LogInformation("📱 SMS sent successfully to {Mobile}, MessageId: {MessageId}", mobile, result.Messageid);
});
}
catch (Exception ex)
{
_logger.LogError(ex, "❌ Kavenegar error sending SMS to {Mobile}: {Message}", mobile, ex.Message);
throw;
}
}
/// <inheritdoc />
public async Task VerifyLookupAsync(string mobile, string token, string template = "Afrino")
{
if (!_smsSettings.Enabled)
{
_logger.LogInformation("SMS is disabled. Skipping VerifyLookup to {Mobile}", mobile);
return;
}
if (_kavenegarApi == null)
{
_logger.LogWarning("⚠️ Kavenegar API not initialized, cannot send VerifyLookup");
return;
}
try
{
// Kavenegar VerifyLookup is synchronous
await Task.Run(() =>
{
var result = _kavenegarApi.VerifyLookup(
receptor: mobile,
token: token,
template: template);
_logger.LogInformation("📱 VerifyLookup SMS sent successfully to {Mobile} with template {Template}, MessageId: {MessageId}",
mobile, template, result.Messageid);
});
}
catch (Exception ex)
{
_logger.LogError(ex, "❌ Kavenegar error sending VerifyLookup to {Mobile}: {Message}", mobile, ex.Message);
throw;
}
}
}
@@ -3,7 +3,7 @@
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Version>0.0.156</Version>
<Version>0.0.162</Version>
<DebugType>None</DebugType>
<DebugSymbols>False</DebugSymbols>
<GeneratePackageOnBuild>False</GeneratePackageOnBuild>
@@ -57,6 +57,8 @@
<Protobuf Include="Protos\discountorder.proto" ProtoRoot="Protos\" GrpcServices="Both" />
<!-- Geography System (GMS) -->
<Protobuf Include="Protos\city.proto" ProtoRoot="Protos\" GrpcServices="Both" />
<!-- App Version Tracking System -->
<Protobuf Include="Protos\appversion.proto" ProtoRoot="Protos\" GrpcServices="Both" />
</ItemGroup>
<Target Name="PushToFoursatNuget" AfterTargets="Pack">
@@ -0,0 +1,95 @@
syntax = "proto3";
package appversion;
import "google/protobuf/empty.proto";
import "google/protobuf/wrappers.proto";
import "google/protobuf/timestamp.proto";
import "google/api/annotations.proto";
option csharp_namespace = "CMSMicroservice.Protobuf.Protos.AppVersion";
// Service for tracking app versions and cache invalidation
service AppVersionContract
{
// Get current version for an app - called by frontends to check if cache clear is needed
rpc GetAppVersion(GetAppVersionRequest) returns (GetAppVersionResponse){
option (google.api.http) = {
get: "/AppVersion/Get"
};
};
// Update/Create app version - called by admin when deploying new version
rpc UpdateAppVersion(UpdateAppVersionRequest) returns (google.protobuf.Empty){
option (google.api.http) = {
post: "/AppVersion/Update"
body: "*"
};
};
// Get all app versions
rpc GetAllAppVersions(GetAllAppVersionsRequest) returns (GetAllAppVersionsResponse){
option (google.api.http) = {
get: "/AppVersion/GetAll"
};
};
}
// Request to get current version for specific app
message GetAppVersionRequest
{
string app_name = 1; // e.g., "FrontOffice", "BackOffice", "Mobile"
google.protobuf.StringValue current_client_version = 2; // Current version on client for comparison
}
// Response with version info
message GetAppVersionResponse
{
bool found = 1; // Whether the app was found
string app_name = 2;
string current_version = 3;
string min_required_version = 4;
bool requires_full_cache_clear = 5;
bool requires_update = 6; // True if client version < min_required_version
google.protobuf.StringValue update_message = 7;
google.protobuf.StringValue release_notes = 8;
google.protobuf.Timestamp last_updated = 9;
}
// Request to update/create app version
message UpdateAppVersionRequest
{
string app_name = 1;
string current_version = 2;
google.protobuf.StringValue min_required_version = 3;
bool requires_full_cache_clear = 4;
google.protobuf.StringValue update_message = 5;
google.protobuf.StringValue release_notes = 6;
google.protobuf.StringValue update_reason = 7; // For admin logs
}
// Request to get all app versions
message GetAllAppVersionsRequest
{
bool include_inactive = 1;
}
// Response with all app versions
message GetAllAppVersionsResponse
{
repeated AppVersionItem items = 1;
}
message AppVersionItem
{
int64 id = 1;
string app_name = 2;
string current_version = 3;
string min_required_version = 4;
bool requires_full_cache_clear = 5;
string update_message = 6;
string release_notes = 7;
bool is_active = 8;
google.protobuf.Timestamp created = 9;
google.protobuf.Timestamp last_modified = 10;
}
@@ -316,15 +316,20 @@ message UserWeeklyBalanceModel
{
int64 id = 1;
int64 user_id = 2;
int64 week_definition_id = 3;
string week_display_name = 4;
int32 left_leg_balances = 5;
int32 right_leg_balances = 6;
int32 total_balances = 7;
int64 weekly_pool_contribution = 8;
google.protobuf.Timestamp calculated_at = 9;
bool is_expired = 10;
google.protobuf.Timestamp created = 11;
string user_full_name = 3; // نام کامل کاربر
int64 week_definition_id = 4;
string week_display_name = 5;
int32 left_leg_new_members = 6; // اعضای جدید چپ این هفته
int32 left_leg_carryover = 7; // باقیمانده چپ از هفته قبل
int32 left_leg_total = 8; // مجموع چپ (NewMembers + Carryover)
int32 right_leg_new_members = 9; // اعضای جدید راست این هفته
int32 right_leg_carryover = 10; // باقیمانده راست از هفته قبل
int32 right_leg_total = 11; // مجموع راست (NewMembers + Carryover)
int32 total_balances = 12; // تعداد تعادل = MIN(چپ, راست)
int64 weekly_pool_contribution = 13;
google.protobuf.Timestamp calculated_at = 14;
bool is_expired = 15;
google.protobuf.Timestamp created = 16;
}
// GetAllWeeklyPools Query
@@ -178,6 +178,7 @@ message NetworkTreeNodeModel
google.protobuf.Int64Value activation_week_definition_id = 10; // شماره هفته فعال‌سازی
bool is_activated_in_target_week = 11; // آیا در هفته هدف فعال شده
google.protobuf.Timestamp user_created = 12; // تاریخ ایجاد کاربر
string referral_code = 13; // کد معرف کاربر
}
// GetHistory Query
@@ -7,6 +7,10 @@
<DockerfileContext>..\..\..</DockerfileContext>
</PropertyGroup>
<ItemGroup>
<Content Update="appsettings.*.json" CopyToPublishDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Google.Protobuf" Version="3.23.3" />
<PackageReference Include="Grpc.AspNetCore" Version="2.54.0" />
@@ -0,0 +1,42 @@
using System.Collections.Generic;
using CMSMicroservice.Application.AppVersionCQ.Queries.GetAllAppVersions;
using CMSMicroservice.Protobuf.Protos.AppVersion;
using Google.Protobuf.WellKnownTypes;
using Mapster;
namespace CMSMicroservice.WebApi.Common.Mappings;
public class AppVersionProfile : IRegister
{
public void Register(TypeAdapterConfig config)
{
// Map List<AppVersionItemDto> to GetAllAppVersionsResponse
config.NewConfig<List<AppVersionItemDto>, GetAllAppVersionsResponse>()
.MapWith(src => CreateResponse(src));
// Map AppVersionItemDto to AppVersionItem (proto message)
config.NewConfig<AppVersionItemDto, AppVersionItem>()
.Map(dest => dest.Id, src => src.Id)
.Map(dest => dest.AppName, src => src.AppName)
.Map(dest => dest.CurrentVersion, src => src.CurrentVersion)
.Map(dest => dest.MinRequiredVersion, src => src.MinRequiredVersion ?? string.Empty)
.Map(dest => dest.RequiresFullCacheClear, src => src.RequiresFullCacheClear)
.Map(dest => dest.UpdateMessage, src => src.UpdateMessage ?? string.Empty)
.Map(dest => dest.ReleaseNotes, src => src.ReleaseNotes ?? string.Empty)
.Map(dest => dest.IsActive, src => src.IsActive)
.Map(dest => dest.Created, src => Timestamp.FromDateTime(DateTime.SpecifyKind(src.Created, DateTimeKind.Utc)))
.Map(dest => dest.LastModified, src => src.LastModified.HasValue
? Timestamp.FromDateTime(DateTime.SpecifyKind(src.LastModified.Value, DateTimeKind.Utc))
: null);
}
private static GetAllAppVersionsResponse CreateResponse(List<AppVersionItemDto> items)
{
var response = new GetAllAppVersionsResponse();
foreach (var item in items)
{
response.Items.Add(item.Adapt<AppVersionItem>());
}
return response;
}
}
@@ -92,6 +92,7 @@ public class CommissionProfile : IRegister
// WeekDefinitionItem Mapping
config.NewConfig<WeekDefinitionItemDto, WeekDefinitionItem>()
.Map(dest => dest.Id, src => src.Id)
.Map(dest => dest.WeekOrder, src => src.WeekOrder)
.Map(dest => dest.DisplayName, src => src.DisplayName)
.Map(dest => dest.GregorianWeekNumber, src => src.GregorianWeekNumber)
@@ -107,10 +108,15 @@ public class CommissionProfile : IRegister
config.NewConfig<GetUserWeeklyBalancesResponseModel, UserWeeklyBalanceModel>()
.Map(dest => dest.Id, src => src.Id)
.Map(dest => dest.UserId, src => src.UserId)
.Map(dest => dest.UserFullName, src => src.UserFullName)
.Map(dest => dest.WeekDefinitionId, src => src.WeekDefinitionId)
.Map(dest => dest.WeekDisplayName, src => src.WeekDisplayName)
.Map(dest => dest.LeftLegBalances, src => src.LeftLegBalances)
.Map(dest => dest.RightLegBalances, src => src.RightLegBalances)
.Map(dest => dest.LeftLegNewMembers, src => src.LeftLegNewMembers)
.Map(dest => dest.LeftLegCarryover, src => src.LeftLegCarryover)
.Map(dest => dest.LeftLegTotal, src => src.LeftLegTotal)
.Map(dest => dest.RightLegNewMembers, src => src.RightLegNewMembers)
.Map(dest => dest.RightLegCarryover, src => src.RightLegCarryover)
.Map(dest => dest.RightLegTotal, src => src.RightLegTotal)
.Map(dest => dest.TotalBalances, src => src.TotalBalances)
.Map(dest => dest.WeeklyPoolContribution, src => src.WeeklyPoolContribution)
.Map(dest => dest.CalculatedAt, src => src.CalculatedAt.HasValue
@@ -86,6 +86,7 @@ public class NetworkMembershipProfile : IRegister
{
UserId = node.UserId,
UserName = $"{node.FirstName} {node.LastName}".Trim(),
ReferralCode = node.ReferralCode ?? string.Empty,
NetworkLeg = (int)(node.LegPosition ?? NetworkLeg.Left),
NetworkLevel = node.CurrentDepth,
IsActive = true,
+30 -10
View File
@@ -3,6 +3,7 @@ using CMSMicroservice.Infrastructure.Data.Seeding;
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.WebApi.Hubs;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.AspNetCore.Hosting;
@@ -208,21 +209,40 @@ using (var scope = app.Services.CreateScope())
{
var recurringJobManager = scope.ServiceProvider.GetRequiredService<IRecurringJobManager>();
// Weekly Commission Calculation: Every Sunday at 00:05 (UTC)
recurringJobManager.AddOrUpdate<CMSMicroservice.Infrastructure.BackgroundJobs.WeeklyCommissionJob>(
recurringJobId: "weekly-commission-calculation",
methodCall: job => job.ExecuteAsync(null, CancellationToken.None),
cronExpression: "5 0 * * 0", // Sunday at 00:05
options: new RecurringJobOptions
{
TimeZone = TimeZoneInfo.Utc
});
// Weekly Commission Calculation: Every Sunday at 00:05 (configurable)
var weeklyCommissionEnabled = app.Configuration.GetValue<bool>("BackgroundJobs:WeeklyCommissionCalculation:Enabled", true);
var weeklyCommissionCron = app.Configuration.GetValue<string>("BackgroundJobs:WeeklyCommissionCalculation:CronExpression", "5 0 * * 0");
app.Logger.LogInformation("✅ Hangfire recurring job 'weekly-commission-calculation' registered (Cron: 5 0 * * 0 - Sunday 00:05 UTC)");
if (weeklyCommissionEnabled)
{
recurringJobManager.AddOrUpdate<CMSMicroservice.Infrastructure.BackgroundJobs.WeeklyCommissionJob>(
recurringJobId: "weekly-commission-calculation",
methodCall: job => job.ExecuteAsync(null, CancellationToken.None),
cronExpression: weeklyCommissionCron,
options: new RecurringJobOptions
{
TimeZone = TimeZoneInfo.Local
});
app.Logger.LogInformation("✅ Hangfire recurring job 'weekly-commission-calculation' registered (Cron: {Cron})", weeklyCommissionCron);
}
else
{
recurringJobManager.RemoveIfExists("weekly-commission-calculation");
app.Logger.LogInformation("⚠️ Hangfire recurring job 'weekly-commission-calculation' is DISABLED in configuration");
}
// Daya Loan Check: Every 15 minutes
CMSMicroservice.WebApi.Workers.DayaLoanCheckWorker.Schedule(recurringJobManager);
app.Logger.LogInformation("✅ Hangfire recurring job 'daya-loan-check' registered (Cron: */15 * * * * - Every 15 minutes)");
// Chatika Account Activation: Every 5 minutes
recurringJobManager.AddOrUpdate<CMSMicroservice.Infrastructure.BackgroundJobs.ChatikaAccountActivationJob>(
recurringJobId: "chatika-account-activation",
methodCall: job => job.ExecuteAsync(CancellationToken.None),
cronExpression: "*/5 * * * *",
options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Local });
app.Logger.LogInformation("✅ Hangfire recurring job 'chatika-account-activation' registered (Cron: */5 * * * * - Every 5 minutes)");
}
app.Run();
@@ -0,0 +1,32 @@
using CMSMicroservice.Protobuf.Protos.AppVersion;
using CMSMicroservice.WebApi.Common.Services;
using CMSMicroservice.Application.AppVersionCQ.Queries.GetAppVersion;
using CMSMicroservice.Application.AppVersionCQ.Queries.GetAllAppVersions;
using CMSMicroservice.Application.AppVersionCQ.Commands.UpdateAppVersion;
namespace CMSMicroservice.WebApi.Services;
public class AppVersionService : AppVersionContract.AppVersionContractBase
{
private readonly IDispatchRequestToCQRS _dispatchRequestToCQRS;
public AppVersionService(IDispatchRequestToCQRS dispatchRequestToCQRS)
{
_dispatchRequestToCQRS = dispatchRequestToCQRS;
}
public override async Task<GetAppVersionResponse> GetAppVersion(GetAppVersionRequest request, ServerCallContext context)
{
return await _dispatchRequestToCQRS.Handle<GetAppVersionRequest, GetAppVersionQuery, GetAppVersionResponse>(request, context);
}
public override async Task<Empty> UpdateAppVersion(UpdateAppVersionRequest request, ServerCallContext context)
{
return await _dispatchRequestToCQRS.Handle<UpdateAppVersionRequest, UpdateAppVersionCommand>(request, context);
}
public override async Task<GetAllAppVersionsResponse> GetAllAppVersions(GetAllAppVersionsRequest request, ServerCallContext context)
{
return await _dispatchRequestToCQRS.Handle<GetAllAppVersionsRequest, GetAllAppVersionsQuery, GetAllAppVersionsResponse>(request, context);
}
}
@@ -1,44 +1,162 @@
using CMSMicroservice.Domain.Common;
using CMSMicroservice.Protobuf.Protos.Configuration;
using CMSMicroservice.WebApi.Common.Services;
using CMSMicroservice.Application.ConfigurationCQ.Commands.SetConfigurationValue;
using CMSMicroservice.Application.ConfigurationCQ.Commands.DeactivateConfiguration;
using CMSMicroservice.Application.ConfigurationCQ.Queries.GetConfigurationByKey;
using CMSMicroservice.Application.ConfigurationCQ.Queries.GetAllConfigurations;
using CMSMicroservice.Application.ConfigurationCQ.Queries.GetConfigurationHistory;
using Google.Protobuf.WellKnownTypes;
using Grpc.Core;
using Microsoft.Extensions.Logging;
namespace CMSMicroservice.WebApi.Services;
/// <summary>
/// سرویس تنظیمات سیستم - خواندن از SystemConstants
/// </summary>
public class ConfigurationService : ConfigurationContract.ConfigurationContractBase
{
private readonly IDispatchRequestToCQRS _dispatchRequestToCQRS;
private readonly ILogger<ConfigurationService> _logger;
public ConfigurationService(IDispatchRequestToCQRS dispatchRequestToCQRS)
public ConfigurationService(ILogger<ConfigurationService> logger)
{
_dispatchRequestToCQRS = dispatchRequestToCQRS;
_logger = logger;
}
public override async Task<Empty> CreateOrUpdateConfiguration(CreateOrUpdateConfigurationRequest request, ServerCallContext context)
/// <summary>
/// دریافت تنظیمات با کلید خاص
/// </summary>
public override Task<GetConfigurationByKeyResponse> GetConfigurationByKey(GetConfigurationByKeyRequest request, ServerCallContext context)
{
return await _dispatchRequestToCQRS.Handle<CreateOrUpdateConfigurationRequest, SetConfigurationValueCommand>(request, context);
var response = new GetConfigurationByKeyResponse
{
Key = request.Key,
Scope = request.Scope,
IsActive = true,
Created = Timestamp.FromDateTime(DateTime.UtcNow),
LastModified = Timestamp.FromDateTime(DateTime.UtcNow)
};
// خواندن مقدار از SystemConstants بر اساس کلید
response.Value = GetConfigurationValue(request.Key);
response.Description = GetConfigurationDescription(request.Key);
_logger.LogDebug("Configuration requested: Key={Key}, Value={Value}", request.Key, response.Value);
return Task.FromResult(response);
}
public override async Task<Empty> DeactivateConfiguration(DeactivateConfigurationRequest request, ServerCallContext context)
/// <summary>
/// دریافت تمام تنظیمات
/// </summary>
public override Task<GetAllConfigurationsResponse> GetAllConfigurations(GetAllConfigurationsRequest request, ServerCallContext context)
{
return await _dispatchRequestToCQRS.Handle<DeactivateConfigurationRequest, DeactivateConfigurationCommand>(request, context);
var response = new GetAllConfigurationsResponse();
// Club Settings
response.Models.Add(CreateConfigModel("Club.ActivationFee", SystemConstants.ClubActivationFee.ToString(), "هزینه فعال‌سازی عضویت باشگاه", 2));
response.Models.Add(CreateConfigModel("Club.MembershipGiftValue", SystemConstants.ClubMembershipGiftValue.ToString(), "مبلغ هدیه حق عضویت باشگاه", 2));
// Commission Settings
response.Models.Add(CreateConfigModel("Commission.MinWithdrawalAmount", SystemConstants.CommissionMinWithdrawalAmount.ToString(), "حداقل مبلغ برداشت", 3));
response.Models.Add(CreateConfigModel("Commission.MaxWeeklyBalancesPerLeg", SystemConstants.CommissionMaxWeeklyBalancesPerLeg.ToString(), "سقف تعادل هفتگی برای هر دست", 3));
response.Models.Add(CreateConfigModel("Commission.MaxNetworkLevel", SystemConstants.CommissionMaxNetworkLevel.ToString(), "حداکثر عمق شبکه برای محاسبه کمیسیون", 3));
response.Models.Add(CreateConfigModel("Commission.CashWithdrawalEnabled", SystemConstants.CommissionCashWithdrawalEnabled.ToString(), "امکان برداشت نقدی", 3));
response.Models.Add(CreateConfigModel("Commission.CalculationStrategy", SystemConstants.CommissionCalculationStrategy, "روش محاسبه کمیسیون", 3));
// Network Settings
response.Models.Add(CreateConfigModel("Network.AllowOrphanNodes", SystemConstants.NetworkAllowOrphanNodes.ToString(), "اجازه حذف والدین با فرزند", 1));
response.Models.Add(CreateConfigModel("Network.MaxChildrenPerLeg", SystemConstants.NetworkMaxChildrenPerLeg.ToString(), "حداکثر تعداد فرزند مستقیم در هر پا", 1));
// Package Settings
response.Models.Add(CreateConfigModel("Package.BasePackageAmount", SystemConstants.BasePackageAmount.ToString(), "مبلغ پکیج پایه", 0));
response.Models.Add(CreateConfigModel("Package.DayaLoanAmount", SystemConstants.DayaLoanAmount.ToString(), "مبلغ وام دایا", 0));
// System Settings
response.Models.Add(CreateConfigModel("System.MaintenanceMode", SystemConstants.SystemMaintenanceMode.ToString(), "حالت تعمیر و نگهداری", 0));
response.Models.Add(CreateConfigModel("System.EnableAuditLog", SystemConstants.SystemEnableAuditLog.ToString(), "فعال‌سازی لاگ تغییرات", 0));
// Shop Settings
response.Models.Add(CreateConfigModel("Shop.VAT", SystemConstants.ShopVAT.ToString(), "مالیات بر ارزش افزوده", 0));
return Task.FromResult(response);
}
public override async Task<GetConfigurationByKeyResponse> GetConfigurationByKey(GetConfigurationByKeyRequest request, ServerCallContext context)
/// <summary>
/// سایر عملیات‌ها که فعلاً پیاده‌سازی نشده‌اند (چون از constant استفاده می‌کنیم)
/// </summary>
public override Task<Empty> CreateOrUpdateConfiguration(CreateOrUpdateConfigurationRequest request, ServerCallContext context)
{
return await _dispatchRequestToCQRS.Handle<GetConfigurationByKeyRequest, GetConfigurationByKeyQuery, GetConfigurationByKeyResponse>(request, context);
_logger.LogWarning("CreateOrUpdateConfiguration called but SystemConstants are read-only");
throw new RpcException(new Status(StatusCode.Unimplemented, "تنظیمات سیستم فقط خواندنی هستند"));
}
public override async Task<GetAllConfigurationsResponse> GetAllConfigurations(GetAllConfigurationsRequest request, ServerCallContext context)
public override Task<Empty> DeactivateConfiguration(DeactivateConfigurationRequest request, ServerCallContext context)
{
return await _dispatchRequestToCQRS.Handle<GetAllConfigurationsRequest, GetAllConfigurationsQuery, GetAllConfigurationsResponse>(request, context);
_logger.LogWarning("DeactivateConfiguration called but SystemConstants are read-only");
throw new RpcException(new Status(StatusCode.Unimplemented, "تنظیمات سیستم فقط خواندنی هستند"));
}
public override async Task<GetConfigurationHistoryResponse> GetConfigurationHistory(GetConfigurationHistoryRequest request, ServerCallContext context)
public override Task<GetConfigurationHistoryResponse> GetConfigurationHistory(GetConfigurationHistoryRequest request, ServerCallContext context)
{
return await _dispatchRequestToCQRS.Handle<GetConfigurationHistoryRequest, GetConfigurationHistoryQuery, GetConfigurationHistoryResponse>(request, context);
// چون constant هستند، تاریخچه‌ای وجود نداره
return Task.FromResult(new GetConfigurationHistoryResponse());
}
#region Private Helpers
private static string GetConfigurationValue(string key)
{
return key switch
{
"Club.ActivationFee" => SystemConstants.ClubActivationFee.ToString(),
"Club.MembershipGiftValue" => SystemConstants.ClubMembershipGiftValue.ToString(),
"Commission.MinWithdrawalAmount" => SystemConstants.CommissionMinWithdrawalAmount.ToString(),
"Commission.MaxWeeklyBalancesPerLeg" => SystemConstants.CommissionMaxWeeklyBalancesPerLeg.ToString(),
"Commission.MaxNetworkLevel" => SystemConstants.CommissionMaxNetworkLevel.ToString(),
"Commission.CashWithdrawalEnabled" => SystemConstants.CommissionCashWithdrawalEnabled.ToString(),
"Commission.CalculationStrategy" => SystemConstants.CommissionCalculationStrategy,
"Network.AllowOrphanNodes" => SystemConstants.NetworkAllowOrphanNodes.ToString(),
"Network.MaxChildrenPerLeg" => SystemConstants.NetworkMaxChildrenPerLeg.ToString(),
"Package.BasePackageAmount" => SystemConstants.BasePackageAmount.ToString(),
"Package.DayaLoanAmount" => SystemConstants.DayaLoanAmount.ToString(),
"System.MaintenanceMode" => SystemConstants.SystemMaintenanceMode.ToString(),
"System.EnableAuditLog" => SystemConstants.SystemEnableAuditLog.ToString(),
"Shop.VAT" => SystemConstants.ShopVAT.ToString(),
_ => string.Empty
};
}
private static string GetConfigurationDescription(string key)
{
return key switch
{
"Club.ActivationFee" => "هزینه فعال‌سازی عضویت باشگاه (ریال)",
"Club.MembershipGiftValue" => "مبلغ هدیه حق عضویت باشگاه (ریال)",
"Commission.MinWithdrawalAmount" => "حداقل مبلغ برداشت (ریال)",
"Commission.MaxWeeklyBalancesPerLeg" => "سقف تعادل هفتگی برای هر دست",
"Commission.MaxNetworkLevel" => "حداکثر عمق شبکه برای محاسبه کمیسیون",
"Commission.CashWithdrawalEnabled" => "امکان برداشت نقدی",
"Commission.CalculationStrategy" => "روش محاسبه کمیسیون",
"Network.AllowOrphanNodes" => "اجازه حذف والدین با فرزند",
"Network.MaxChildrenPerLeg" => "حداکثر تعداد فرزند مستقیم در هر پا",
"Package.BasePackageAmount" => "مبلغ پکیج پایه (ریال)",
"Package.DayaLoanAmount" => "مبلغ وام دایا (ریال)",
"System.MaintenanceMode" => "حالت تعمیر و نگهداری سیستم",
"System.EnableAuditLog" => "فعال‌سازی لاگ تغییرات",
"Shop.VAT" => "مالیات بر ارزش افزوده",
_ => string.Empty
};
}
private static ConfigurationModel CreateConfigModel(string key, string value, string description, int scope)
{
return new ConfigurationModel
{
Id = key.GetHashCode(),
Key = key,
Value = value,
Description = description,
Scope = scope,
IsActive = true,
Created = Timestamp.FromDateTime(DateTime.UtcNow)
};
}
#endregion
}
@@ -1,9 +1,7 @@
using Hangfire;
using MediatR;
using Microsoft.Extensions.Logging;
using CMSMicroservice.Application.DayaLoanCQ.Commands.CheckDayaLoanStatus;
using CMSMicroservice.Application.DayaLoanCQ.Commands.ProcessDayaLoanApproval;
using CMSMicroservice.Domain.Enums;
using CMSMicroservice.Application.DayaLoanCQ.Commands.CheckAndProcessDayaLoans;
using Microsoft.EntityFrameworkCore;
using CMSMicroservice.Infrastructure.Persistence;
using System.Linq;
@@ -11,7 +9,7 @@ using System.Linq;
namespace CMSMicroservice.WebApi.Workers;
/// <summary>
/// Worker برای استعلام خودکار وضعیت وام دایا (هر 15 دقیقه)
/// Worker برای استعلام خودکار وضعیت وام دایا و پردازش خودکار (هر 20 دقیقه)
/// </summary>
public class DayaLoanCheckWorker
{
@@ -39,13 +37,15 @@ public class DayaLoanCheckWorker
try
{
// پیدا کردن کاربرانی که اعتبار دایا را دریافت نکرده‌اند
// پیدا کردن کاربرانی که:
// 1. اعتبار دایا را دریافت نکرده‌اند
// 2. کد ملی دارند
var pendingUsers = await _context.Users
.Where(u =>
u.HasReceivedDayaCredit == false &&
u.NationalCode != null &&
u.NationalCode != "")
.Select(u => new { u.Id, u.NationalCode })
.Select(u => u.NationalCode!)
.ToListAsync();
if (!pendingUsers.Any())
@@ -56,47 +56,19 @@ public class DayaLoanCheckWorker
_logger.LogInformation("Found {Count} users with pending Daya loan status", pendingUsers.Count);
// استعلام از دایا
var checkCommand = new CheckDayaLoanStatusCommand
// استعلام و پردازش یکجا با Command یکپارچه
var command = new CheckAndProcessDayaLoansCommand
{
NationalCodes = pendingUsers.Select(u => u.NationalCode).ToList()
NationalCodes = pendingUsers
};
var checkResult = await _mediator.Send(checkCommand);
var result = await _mediator.Send(command);
// پردازش نتایج
foreach (var result in checkResult.Results)
{
// فقط وضعیت PendingReceive را پردازش می‌کنیم (یعنی وام درخواست شده)
if (result.Status == DayaLoanStatus.PendingReceive && !string.IsNullOrEmpty(result.ContractNumber))
{
var user = pendingUsers.FirstOrDefault(u => u.NationalCode == result.NationalCode);
if (user != null)
{
try
{
// پردازش تایید وام و شارژ کیف پول
var processCommand = new ProcessDayaLoanApprovalCommand
{
UserId = user.Id,
ContractNumber = result.ContractNumber
};
var processResult = await _mediator.Send(processCommand);
_logger.LogInformation("Daya loan processed for user {UserId}. Contract: {ContractNumber}",
user.Id, result.ContractNumber);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing Daya loan for user {UserId}", user.Id);
}
}
}
}
_logger.LogInformation("DayaLoanCheckWorker completed. Checked: {Total}, Processed: {Success}",
checkResult.TotalChecked, checkResult.SuccessCount);
_logger.LogInformation(
"DayaLoanCheckWorker completed. Checked: {Total}, WithContract: {WithContract}, Processed: {Processed}",
result.TotalChecked,
result.WithContractCount,
result.ProcessedCount);
}
catch (Exception ex)
{
@@ -106,15 +78,14 @@ public class DayaLoanCheckWorker
}
/// <summary>
/// متد برای Schedule کردن Worker (هر 15 دقیقه)
/// متد برای Schedule کردن Worker (هر 20 دقیقه)
/// </summary>
public static void Schedule(IRecurringJobManager recurringJobManager)
{
// هر 15 دقیقه: */15 * * * *
recurringJobManager.AddOrUpdate<DayaLoanCheckWorker>(
"daya-loan-check",
worker => worker.ExecuteAsync(),
"*/20 * * * *", // هر 15 دقیقه
"*/20 * * * *",
TimeZoneInfo.Local
);
}
@@ -1,10 +1,78 @@
{
"Logging": {
"LogLevel": {
"Default": "Debug",
"System": "Information",
"Grpc": "Information",
"Microsoft": "Information"
"UseRealPaymentGateway": false,
"JwtSecurityKey": "TvlZVx5TJaHs8e9HgUdGzhGP2CIidoI444nAj+8+g7c=",
"JwtIssuer": "https://localhost",
"JwtAudience": "https://localhost",
"JwtExpiryInDays": 5,
"ConnectionStrings": {
"DefaultConnection": "Data Source=194.5.195.53,31433; Initial Catalog=Foursat;User ID=sa;Password=87zH26nbqT;Connection Timeout=300000;MultipleActiveResultSets=True;Encrypt=False",
"providerName": "System.Data.SqlClient"
},
"Otp": {
"Secret": "K2w8k1h1mH2Qz1kqWk0c8kQ2Pq8q9H1eE2nqN1qQ8x7M="
},
"Monitoring": {
"SentryEnabled": false,
"SentryDsn": "",
"SlackEnabled": false,
"SlackWebhookUrl": "",
"EmailAlertsEnabled": false,
"AdminEmails": [
"admin@example.com"
],
"SmsNotificationsEnabled": false,
"SmsApiKey": "",
"SmsGatewayUrl": ""
},
"Email": {
"Enabled": true,
"SmtpHost": "smtp.gmail.com",
"SmtpPort": 587,
"SmtpUsername": "your-email@gmail.com",
"SmtpPassword": "your-app-password",
"FromEmail": "noreply@foursat.com",
"FromName": "FourSat CMS",
"EnableSsl": true
},
"Sms": {
"Enabled": true,
"Provider": "Kavenegar",
"KavenegarApiKey": "497263626F32626A48685A6137524C4F78575A766E4C74694A556B79317648424964655030682B554545413D",
"Sender": "1000001110100"
},
"DayaPayment": {
"BaseUrl": "https://api.daya.ir",
"ApiKey": "YOUR_DAYA_API_KEY"
},
"DayaApi": {
"UseMock": false,
"BaseAddress": "https://Dayadiamond.ir",
"MerchantPermissionKey": "56146364$04sXjethI5WxhItR1Q9xnmFdJzl2BB8Bclsq8dAy7YVSZp3vtt-wP7ivrcCvmKLq",
"CacheDurationMinutes": 20
},
"Chatika": {
"Enabled": true,
"BaseUrl": "https://api.chatika.ir",
"ApiKey": "tIukvL8dnV4cB3yVWcCD9Xyfbj8rBxm5wPt2mLyJCgTsBBoMTWjt6mFEqQwpw-er"
},
"BackgroundJobs": {
"WeeklyCommissionCalculation": {
"Enabled": true,
"CronExpression": "5 0 * * 0"
}
},
"AllowedHosts": "*",
"Kestrel": {
"EndpointDefaults": {
"Protocols": "Http2"
}
},
"Authentication": {
"Authority": "https://ids.domain.com/",
"Audience": "domain_api"
},
"Seq": {
"ServerUrl": "https://seq.afrino.co",
"ApiKey": "oxpvpUzU1pZxMS4s3Fqq"
}
}
@@ -1,20 +1,35 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore": "Warning"
}
},
"UseRealPaymentGateway": false,
"JwtSecurityKey": "TvlZVx5TJaHs8e9HgUdGzhGP2CIidoI444nAj+8+g7c=",
"JwtIssuer": "https://localhost",
"JwtAudience": "https://localhost",
"JwtExpiryInDays": 5,
"ConnectionStrings": {
"DefaultConnection": "Server=YOUR_PRODUCTION_SERVER;Database=FourSat_CMS;User Id=YOUR_USER;Password=YOUR_PASSWORD;TrustServerCertificate=True;MultipleActiveResultSets=true"
"DefaultConnection": "Data Source=45.149.79.127,31433; Initial Catalog=KBS;User ID=sa;Password=YourStrong@Passw0rd;Connection Timeout=300000;MultipleActiveResultSets=True;Encrypt=False",
"providerName": "System.Data.SqlClient"
},
"Otp": {
"Secret": "K2w8k1h1mH2Qz1kqWk0c8kQ2Pq8q9H1eE2nqN1qQ8x7M="
},
"Monitoring": {
"SentryEnabled": false,
"SentryDsn": "",
"SlackEnabled": false,
"SlackWebhookUrl": "",
"EmailAlertsEnabled": false,
"AdminEmails": [
"admin@example.com"
],
"SmsNotificationsEnabled": false,
"SmsApiKey": "",
"SmsGatewayUrl": ""
},
"Email": {
"Enabled": true,
"SmtpHost": "smtp.gmail.com",
"SmtpPort": 587,
"SmtpUsername": "your-production-email@gmail.com",
"SmtpPassword": "your-gmail-app-password",
"SmtpUsername": "your-email@gmail.com",
"SmtpPassword": "your-app-password",
"FromEmail": "noreply@foursat.com",
"FromName": "FourSat CMS",
"EnableSsl": true
@@ -22,13 +37,42 @@
"Sms": {
"Enabled": true,
"Provider": "Kavenegar",
"KavenegarApiKey": "YOUR_PRODUCTION_KAVENEGAR_API_KEY",
"KavenegarApiKey": "YOUR_KAVENEGAR_API_KEY",
"Sender": "10008663"
},
"Jwt": {
"Issuer": "https://api.foursat.com",
"Audience": "https://foursat.com",
"SecretKey": "YOUR_PRODUCTION_SECRET_KEY_MINIMUM_32_CHARACTERS_LONG"
"DayaPayment": {
"BaseUrl": "https://api.daya.ir",
"ApiKey": "YOUR_DAYA_API_KEY"
},
"AllowedHosts": "*"
"DayaApi": {
"UseMock": false,
"BaseAddress": "https://Dayadiamond.ir",
"MerchantPermissionKey": "56146364$04sXjethI5WxhItR1Q9xnmFdJzl2BB8Bclsq8dAy7YVSZp3vtt-wP7ivrcCvmKLq",
"CacheDurationMinutes": 20
},
"Chatika": {
"Enabled": true,
"BaseUrl": "https://api.chatika.ir",
"ApiKey": "tIukvL8dnV4cB3yVWcCD9Xyfbj8rBxm5wPt2mLyJCgTsBBoMTWjt6mFEqQwpw-er"
},
"BackgroundJobs": {
"WeeklyCommissionCalculation": {
"Enabled": false,
"CronExpression": "5 0 * * 0"
}
},
"AllowedHosts": "*",
"Kestrel": {
"EndpointDefaults": {
"Protocols": "Http2"
}
},
"Authentication": {
"Authority": "https://ids.domain.com/",
"Audience": "domain_api"
},
"Seq": {
"ServerUrl": "http://seq-svc:5341",
"ApiKey": "oxpvpUzU1pZxMS4s3Fqq"
}
}
+15 -4
View File
@@ -5,11 +5,11 @@
"JwtAudience": "https://localhost",
"JwtExpiryInDays": 5,
"ConnectionStrings": {
"DefaultConnection": "Data Source=185.252.31.42,2019; Initial Catalog=KBS;User ID=afrino;Password=87zH26nbqT%;Connection Timeout=300000;MultipleActiveResultSets=True;Encrypt=False",
"DefaultConnection": "Data Source=45.149.79.127,31433; Initial Catalog=KBS;User ID=sa;Password=YourStrong@Passw0rd;Connection Timeout=300000;MultipleActiveResultSets=True;Encrypt=False",
"providerName": "System.Data.SqlClient"
},
"Otp": {
"Secret": "K2w8k1h1mH2Qz1kqWk0c8kQ2Pq8q9H1eE2nqN1qQ8x7M="
"Secret": "K2w8k1h1mH2Qz1kqWk0c8kQ2Pq8q9H1eE2nqN1qQ8x7M="
},
"Monitoring": {
"SentryEnabled": false,
@@ -50,6 +50,17 @@
"MerchantPermissionKey": "56146364$04sXjethI5WxhItR1Q9xnmFdJzl2BB8Bclsq8dAy7YVSZp3vtt-wP7ivrcCvmKLq",
"CacheDurationMinutes": 20
},
"Chatika": {
"Enabled": true,
"BaseUrl": "https://api.chatika.ir",
"ApiKey": "tIukvL8dnV4cB3yVWcCD9Xyfbj8rBxm5wPt2mLyJCgTsBBoMTWjt6mFEqQwpw-er"
},
"BackgroundJobs": {
"WeeklyCommissionCalculation": {
"Enabled": true,
"CronExpression": "5 0 * * 0"
}
},
"AllowedHosts": "*",
"Kestrel": {
"EndpointDefaults": {
@@ -61,7 +72,7 @@
"Audience": "domain_api"
},
"Seq": {
"ServerUrl": "http://seq-svc:5341",
"ApiKey": ""
"ServerUrl": "https://seq.afrino.co",
"ApiKey": "oxpvpUzU1pZxMS4s3Fqq"
}
}