Compare commits
93 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e93fad3b3d | |||
| 04e1a83838 | |||
| 4a7df2083e | |||
| e77b5f9658 | |||
| b71ddfaf9b | |||
| 4d6d77531d | |||
| a35e2eeb92 | |||
| 0a66b09e7b | |||
| e946c56631 | |||
| 370f735cf2 | |||
| 0e9d0cad2f | |||
| 1a0bb83c99 | |||
| 1e7c17f090 | |||
| 01073084ab | |||
| ed2b20a98e | |||
| 721661af0f | |||
| aee7b59fc1 | |||
| 61b7e4f8f2 | |||
| e5bc3a952a | |||
| 10d2ca20d1 | |||
| fdbb91d2e1 | |||
| a1024a3e18 | |||
| 1ac23667e2 | |||
| dcd11351a3 | |||
| aaaf7fc1ca | |||
| 7554d7054a | |||
| ce8e248843 | |||
| 8446e0e3b5 | |||
| 161f796cd4 | |||
| 469d97bb60 | |||
| d19c569aae | |||
| 7176fe44ee | |||
| 607f791b65 | |||
| 0002a5a6f2 | |||
| ccb938e9ba | |||
| 8e5c7c5205 | |||
| a9cd2fd67a | |||
| ae92ab8697 | |||
| fe3edd178d | |||
| 13dd0f552f | |||
| 8b9c317de6 | |||
| cbaa20f339 | |||
| e206b71186 | |||
| 0457ef6c7c | |||
| f3ac5ad7df | |||
| e72673c184 | |||
| 9288d0640b | |||
| de83c31346 | |||
| f8dc4abd04 | |||
| 2d6c95e6ee | |||
| e41747afe0 | |||
| 68da3f45a3 | |||
| 3153fd8a74 | |||
| 8cdb197ee7 | |||
| 2ca3fd29f9 | |||
| b9a72b4589 | |||
| 5c63dc6e38 | |||
| 52f6af2091 | |||
| e3fbd45739 | |||
| 0fe4db5e77 | |||
| 305b8d3fd8 | |||
| df976b7b80 | |||
| b90aa50fac | |||
| 58d419ced3 | |||
| bdd2c51726 | |||
| 2a569a024f | |||
| 04e8c49fa7 | |||
| f968a6c005 | |||
| bc710fdb7b | |||
| fd24dcebcd | |||
| 89e146bf32 | |||
| 27d995faf4 | |||
| ef1716e243 | |||
| c2ccd32f39 | |||
| c435319025 | |||
| 18a65de8c7 | |||
| e2e09ac4e8 | |||
| 61a0d68b7c | |||
| 1c4beb5f05 | |||
| 163a0a2f1a | |||
| e77611012b | |||
| a39a36e66d | |||
| 207f53ef80 | |||
| 2502cbbda2 | |||
| 5a4e4a960d | |||
| dc1e00f514 | |||
| 9d929dc7fb | |||
| 9b7d61593b | |||
| e3e3e8cec4 | |||
| 2885ff20c0 | |||
| f2a0b4d211 | |||
| 6c9f3dd671 | |||
| d1ca72300d |
@@ -0,0 +1,2 @@
|
|||||||
|
# Keep production config from current branch during merges — never overwrite
|
||||||
|
src/CMSMicroservice.WebApi/appsettings.Production.json merge=ours
|
||||||
@@ -26,7 +26,7 @@ jobs:
|
|||||||
}
|
}
|
||||||
DAEMON
|
DAEMON
|
||||||
echo "🚀 Starting Docker daemon..."
|
echo "🚀 Starting Docker daemon..."
|
||||||
dockerd &
|
dockerd --iptables=false --ip6tables=false --bridge=none --storage-driver=vfs &
|
||||||
|
|
||||||
# Wait up to 3 minutes for Docker to be ready
|
# Wait up to 3 minutes for Docker to be ready
|
||||||
for i in $(seq 1 90); do
|
for i in $(seq 1 90); do
|
||||||
@@ -50,21 +50,26 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
git clone --depth 1 --branch kub-stage http://gitea-svc:3000/admin/CMS.git .
|
git clone --depth 1 --branch kub-stage http://gitea-svc:3000/admin/CMS.git .
|
||||||
|
|
||||||
|
- name: Login to Docker registries
|
||||||
|
run: |
|
||||||
|
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login 194.5.195.53:32082 -u admin --password-stdin
|
||||||
|
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login ${{ env.REGISTRY }} -u admin --password-stdin
|
||||||
|
|
||||||
- name: Publish Protobuf packages
|
- name: Publish Protobuf packages
|
||||||
run: |
|
run: |
|
||||||
echo "📦 Publishing Protobuf packages..."
|
echo "📦 Publishing Protobuf packages..."
|
||||||
docker run --rm -v $(pwd):/src -w /src \
|
docker run --rm --network host -e CI=true -v $(pwd):/src -w /src \
|
||||||
194.5.195.53:32082/dotnet/sdk:9.0 sh -c '
|
194.5.195.53:32082/dotnet/sdk:9.0 sh -c '
|
||||||
for proj in $(find . -name "*Protobuf*.csproj" -type f); do
|
for proj in $(find . -name "*Protobuf*.csproj" -type f); do
|
||||||
echo "📦 $proj"
|
echo "📦 $proj"
|
||||||
dotnet restore "$proj"
|
dotnet restore "$proj" --configfile src/NuGet.config
|
||||||
dotnet build "$proj" -c Release --no-restore
|
dotnet build "$proj" -c Release --no-restore
|
||||||
dotnet pack "$proj" -c Release --no-build -o "$(dirname $proj)/nupkg"
|
dotnet pack "$proj" -c Release --no-build -o "$(dirname $proj)/nupkg"
|
||||||
for nupkg in $(dirname $proj)/nupkg/*.nupkg; do
|
for nupkg in $(dirname $proj)/nupkg/*.nupkg; do
|
||||||
[ -f "$nupkg" ] && dotnet nuget push "$nupkg" \
|
[ -f "$nupkg" ] && dotnet nuget push "$nupkg" \
|
||||||
--source "http://194.5.195.53:32081/repository/foursat-nuget-hosted/index.json" \
|
--source "foursat-hosted" \
|
||||||
--api-key "admin:87zH26nbqT" \
|
--api-key "admin:87zH26nbqT" \
|
||||||
--skip-duplicate --allow-insecure-connections || true
|
--skip-duplicate --configfile src/NuGet.config || true
|
||||||
done
|
done
|
||||||
done
|
done
|
||||||
'
|
'
|
||||||
@@ -72,18 +77,26 @@ jobs:
|
|||||||
|
|
||||||
- name: Build Docker Image
|
- name: Build Docker Image
|
||||||
run: |
|
run: |
|
||||||
docker build -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest .
|
DOCKER_BUILDKIT=0 docker build --network host -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest .
|
||||||
|
|
||||||
- name: Push to Registry
|
- name: Push to Registry
|
||||||
run: |
|
run: |
|
||||||
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login ${{ env.REGISTRY }} -u admin --password-stdin
|
|
||||||
docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||||
|
|
||||||
- name: Deploy to Kubernetes
|
- name: Deploy to Kubernetes
|
||||||
run: |
|
run: |
|
||||||
export SSHPASS="${{ secrets.SERVER_PASSWORD }}"
|
export SSHPASS="${{ secrets.SERVER_PASSWORD }}"
|
||||||
|
|
||||||
|
# Copy K8s manifests to server
|
||||||
|
sshpass -e scp -o StrictHostKeyChecking=no k8s/staging/cms-config.yaml root@${{ env.K8S_SERVER }}:/tmp/cms-config.yaml
|
||||||
|
sshpass -e scp -o StrictHostKeyChecking=no k8s/staging/cms-deployment.yaml root@${{ env.K8S_SERVER }}:/tmp/cms-deployment.yaml
|
||||||
|
|
||||||
|
# Apply config (Secret) first, then deployment
|
||||||
sshpass -e ssh -o StrictHostKeyChecking=no root@${{ env.K8S_SERVER }} "
|
sshpass -e ssh -o StrictHostKeyChecking=no root@${{ env.K8S_SERVER }} "
|
||||||
kubectl rollout restart deployment/cms
|
kubectl apply -f /tmp/cms-config.yaml &&
|
||||||
kubectl rollout status deployment/cms --timeout=180s
|
kubectl apply -f /tmp/cms-deployment.yaml &&
|
||||||
|
kubectl rollout restart deployment/cms &&
|
||||||
|
kubectl rollout status deployment/cms --timeout=180s &&
|
||||||
|
rm -f /tmp/cms-config.yaml /tmp/cms-deployment.yaml
|
||||||
"
|
"
|
||||||
echo "✅ Deployed!"
|
echo "✅ Deployed!"
|
||||||
|
|||||||
@@ -8,81 +8,95 @@ on:
|
|||||||
env:
|
env:
|
||||||
REGISTRY: 194.5.195.53:30080
|
REGISTRY: 194.5.195.53:30080
|
||||||
IMAGE_NAME: admin/cms
|
IMAGE_NAME: admin/cms
|
||||||
|
K8S_SERVER: 45.149.79.127
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-deploy:
|
build-and-deploy:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container:
|
container:
|
||||||
image: docker:latest
|
image: 194.5.195.53:32082/docker-sshpass:latest
|
||||||
options: --privileged
|
options: --privileged
|
||||||
env:
|
|
||||||
HTTP_PROXY: http://proxyuser:87zH26nbqT2@46.249.98.211:3128
|
|
||||||
HTTPS_PROXY: http://proxyuser:87zH26nbqT2@46.249.98.211:3128
|
|
||||||
NO_PROXY: localhost,127.0.0.1,gitea-svc,45.149.79.127,10.0.0.0/8
|
|
||||||
steps:
|
steps:
|
||||||
- name: Install dependencies
|
- name: Start Docker daemon
|
||||||
run: |
|
|
||||||
apk add --no-cache git curl
|
|
||||||
|
|
||||||
# Install kubectl with fixed version
|
|
||||||
KUBECTL_VERSION="v1.31.0"
|
|
||||||
curl -LO "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl"
|
|
||||||
chmod +x kubectl
|
|
||||||
mv kubectl /usr/local/bin/
|
|
||||||
|
|
||||||
- name: Start Docker daemon with insecure registry
|
|
||||||
run: |
|
run: |
|
||||||
mkdir -p /etc/docker
|
mkdir -p /etc/docker
|
||||||
cat > /etc/docker/daemon.json << 'DAEMON'
|
cat > /etc/docker/daemon.json << 'DAEMON'
|
||||||
{
|
{
|
||||||
"insecure-registries": ["194.5.195.53:30080", "194.5.195.53:32082", "gitea-svc:3000"]
|
"insecure-registries": ["194.5.195.53:30080", "194.5.195.53:32500", "194.5.195.53:32082"]
|
||||||
}
|
}
|
||||||
DAEMON
|
DAEMON
|
||||||
mkdir -p ~/.docker
|
echo "🚀 Starting Docker daemon..."
|
||||||
cat > ~/.docker/config.json << 'CONF'
|
dockerd --iptables=false --ip6tables=false --bridge=none --storage-driver=vfs &
|
||||||
{
|
|
||||||
"proxies": {
|
for i in $(seq 1 90); do
|
||||||
"default": {
|
if docker info >/dev/null 2>&1; then
|
||||||
"httpProxy": "http://proxyuser:87zH26nbqT2@46.249.98.211:3128",
|
echo "✅ Docker daemon is ready (attempt $i)"
|
||||||
"httpsProxy": "http://proxyuser:87zH26nbqT2@46.249.98.211:3128",
|
docker version
|
||||||
"noProxy": "localhost,127.0.0.1,gitea-svc,45.149.79.127,10.0.0.0/8"
|
break
|
||||||
}
|
else
|
||||||
}
|
echo "⏳ Waiting for Docker daemon... (attempt $i/90)"
|
||||||
}
|
sleep 2
|
||||||
CONF
|
fi
|
||||||
dockerd &
|
|
||||||
for i in $(seq 1 30); do
|
|
||||||
docker info >/dev/null 2>&1 && break || sleep 2
|
|
||||||
done
|
done
|
||||||
docker info
|
|
||||||
|
if ! docker info >/dev/null 2>&1; then
|
||||||
|
echo "❌ Docker daemon failed to start after 3 minutes"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
run: |
|
run: |
|
||||||
git clone --depth 1 --branch production http://gitea-svc:3000/admin/CMS.git .
|
git clone --depth 1 --branch production http://gitea-svc:3000/admin/CMS.git .
|
||||||
git log -1 --format="%H %s"
|
|
||||||
|
- name: Login to Docker registries
|
||||||
|
run: |
|
||||||
|
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login 194.5.195.53:32082 -u admin --password-stdin
|
||||||
|
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login ${{ env.REGISTRY }} -u admin --password-stdin
|
||||||
|
|
||||||
|
- name: Publish Protobuf packages
|
||||||
|
run: |
|
||||||
|
echo "📦 Publishing Protobuf packages..."
|
||||||
|
docker run --rm --network host -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" --configfile src/NuGet.config
|
||||||
|
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
|
- name: Build Docker Image
|
||||||
run: |
|
run: |
|
||||||
docker build -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} \
|
DOCKER_BUILDKIT=0 docker build --network host -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} \
|
||||||
-t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:prod \
|
-t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:prod .
|
||||||
--build-arg HTTP_PROXY=http://proxyuser:87zH26nbqT2@46.249.98.211:3128 \
|
|
||||||
--build-arg HTTPS_PROXY=http://proxyuser:87zH26nbqT2@46.249.98.211:3128 \
|
|
||||||
.
|
|
||||||
|
|
||||||
- name: Push to Registry
|
- name: Push to Registry
|
||||||
run: |
|
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 }}:${{ github.sha }}
|
||||||
docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:prod
|
docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:prod
|
||||||
|
|
||||||
- name: Deploy to Production
|
- name: Deploy to Production
|
||||||
run: |
|
run: |
|
||||||
# Setup kubeconfig for PRODUCTION
|
export SSHPASS="${{ secrets.SERVER_PASSWORD }}"
|
||||||
mkdir -p ~/.kube
|
|
||||||
echo "${{ secrets.KUBECONFIG_PROD }}" | base64 -d > ~/.kube/config
|
|
||||||
|
|
||||||
# Restart deployment to pull new image
|
# Copy K8s manifests to server
|
||||||
kubectl rollout restart deployment/cms || echo "Deployment doesn't exist yet"
|
sshpass -e scp -o StrictHostKeyChecking=no k8s/production/cms-config.yaml root@${{ env.K8S_SERVER }}:/tmp/cms-config.yaml
|
||||||
|
sshpass -e scp -o StrictHostKeyChecking=no k8s/production/cms-deployment.yaml root@${{ env.K8S_SERVER }}:/tmp/cms-deployment.yaml
|
||||||
|
|
||||||
# Wait for rollout to complete
|
# Apply config (Secret) first, then deployment and update image
|
||||||
kubectl rollout status deployment/cms --timeout=5m || echo "Deployment rollout pending"
|
sshpass -e ssh -o StrictHostKeyChecking=no root@${{ env.K8S_SERVER }} "
|
||||||
|
kubectl apply -f /tmp/cms-config.yaml &&
|
||||||
|
kubectl apply -f /tmp/cms-deployment.yaml &&
|
||||||
|
kubectl set image deployment/cms cms=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} &&
|
||||||
|
kubectl rollout status deployment/cms --timeout=300s &&
|
||||||
|
rm -f /tmp/cms-config.yaml /tmp/cms-deployment.yaml
|
||||||
|
"
|
||||||
|
echo "✅ Deployed to Production!"
|
||||||
|
|||||||
@@ -1,445 +1,3 @@
|
|||||||
# CMS Microservice - Network & Club Commission + Inventory Management System
|
# CMS Microservice
|
||||||
|
|
||||||
[]()
|
Documentation moved to [totalDoc/INDEX.md](../totalDoc/INDEX.md).
|
||||||
[]()
|
|
||||||
[]()
|
|
||||||
|
|
||||||
## 📊 Project Status (January 2026)
|
|
||||||
|
|
||||||
### 🏪 Inventory Management System - NEW!
|
|
||||||
**Progress**: Phase 2 Complete (50%)
|
|
||||||
**Architecture**: Clean Architecture + CQRS + Repository Pattern
|
|
||||||
|
|
||||||
#### ✅ Completed Phases
|
|
||||||
1. ✅ **Phase 1: Infrastructure & Domain Layer**
|
|
||||||
- Domain Entities: `InventoryItem`, `StockMovement`, `Warehouse`
|
|
||||||
- Domain Enums: `StockMovementType`
|
|
||||||
- EF Core Configurations with proper indexing
|
|
||||||
- Database migration applied
|
|
||||||
|
|
||||||
2. ✅ **Phase 2: Repository Pattern & CQRS**
|
|
||||||
- Repository Interfaces & Implementations
|
|
||||||
- CQRS Commands (17 commands)
|
|
||||||
- CQRS Queries (35 queries)
|
|
||||||
- MediatR Handlers (52 handlers)
|
|
||||||
|
|
||||||
#### 🔄 In Progress
|
|
||||||
3. 🔄 **Phase 3: Business Services Layer**
|
|
||||||
4. ⏳ **Phase 4: DTOs & AutoMapper**
|
|
||||||
5. ⏳ **Phase 5: API Controllers**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 💼 Commission System - Production Ready
|
|
||||||
**Progress**: 85% Complete
|
|
||||||
**MVP Status**: ✅ 100% Complete
|
|
||||||
|
|
||||||
#### ✅ Completed Features
|
|
||||||
- ✅ Binary network tree with automatic placement
|
|
||||||
- ✅ Club membership (Member/Trial) with commission rates
|
|
||||||
- ✅ Weekly commission calculation (Lesser Leg algorithm)
|
|
||||||
- ✅ Background worker with Hangfire
|
|
||||||
- ✅ Email + SMS notifications (MailKit + Kavenegar)
|
|
||||||
- ✅ Health check endpoints (Kubernetes-ready)
|
|
||||||
|
|
||||||
### 🟡 Partially Complete
|
|
||||||
- Phase 10: Withdrawal & Settlement (40%)
|
|
||||||
- ✅ Commands & Database
|
|
||||||
- ❌ Payment Gateway Integration
|
|
||||||
|
|
||||||
### ❌ Not Started
|
|
||||||
- Phase 9: Club Shop & Product Integration (0%)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 Recent Updates (January 2026)
|
|
||||||
|
|
||||||
### 🏪 Inventory Management System - NEW! ✅
|
|
||||||
**Complete CQRS-based inventory management with:**
|
|
||||||
|
|
||||||
#### Domain Layer:
|
|
||||||
- ✅ `InventoryItem` - Multi-warehouse product tracking with min/max thresholds
|
|
||||||
- ✅ `StockMovement` - Complete audit trail with 8 movement types
|
|
||||||
- ✅ `Warehouse` - Multi-location support with default warehouse
|
|
||||||
|
|
||||||
#### Repository Pattern:
|
|
||||||
- ✅ `IInventoryItemRepository` - 25+ methods for inventory operations
|
|
||||||
- ✅ `IStockMovementRepository` - Movement tracking & analytics
|
|
||||||
- ✅ `IWarehouseRepository` - Warehouse management & statistics
|
|
||||||
|
|
||||||
#### CQRS Commands (17 total):
|
|
||||||
- **Inventory:** Create, Update, Delete, Reserve, Release, Reduce, Increase
|
|
||||||
- **Movement:** Create, BulkCreate, Delete
|
|
||||||
- **Warehouse:** Create, Update, Delete, SetDefault, Activate, BulkCreate
|
|
||||||
|
|
||||||
#### CQRS Queries (35 total):
|
|
||||||
- **Inventory:** GetById, Search, LowStock, OutOfStock, CheckAvailability
|
|
||||||
- **Movement:** GetHistory, GetByOrder, Search, Analytics, DailyVolume, TopMoving
|
|
||||||
- **Warehouse:** GetById, Search, GetStats, GetLowStock, GetAllStats
|
|
||||||
|
|
||||||
#### Business Features:
|
|
||||||
- ✅ Multi-warehouse inventory management
|
|
||||||
- ✅ Stock reservation system for orders
|
|
||||||
- ✅ Automatic movement tracking
|
|
||||||
- ✅ Low stock & out-of-stock alerts
|
|
||||||
- ✅ Advanced analytics & reporting
|
|
||||||
- ✅ Bulk operations support
|
|
||||||
- ✅ Transaction-safe operations
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Email & SMS Notifications - COMPLETED ✅
|
|
||||||
- ✅ **MailKit 4.14.1** for Email (SMTP with HTML templates)
|
|
||||||
- ✅ **Kavenegar 1.2.5** for SMS (Iranian SMS gateway)
|
|
||||||
- ✅ User.Email field added with migration
|
|
||||||
- ✅ 3 notification types: Commission, Club activation, Errors
|
|
||||||
- ✅ Persian RTL templates with rich formatting
|
|
||||||
- ✅ Production configuration guide created
|
|
||||||
|
|
||||||
### Hangfire Job Scheduling - COMPLETED ✅
|
|
||||||
- ✅ Dashboard UI at `/hangfire`
|
|
||||||
- ✅ Cron schedule: Sunday 00:05 UTC
|
|
||||||
- ✅ SQL Server persistence
|
|
||||||
- ✅ Manual trigger API endpoints
|
|
||||||
- ✅ Distributed execution support
|
|
||||||
|
|
||||||
### Infrastructure Enhancements - COMPLETED ✅
|
|
||||||
- ✅ Health Check endpoints (`/health`, `/health/ready`, `/health/live`)
|
|
||||||
- ✅ AlertService (structured logging for Sentry/Slack)
|
|
||||||
- ✅ Retry logic (Polly 8.5.0 with exponential backoff)
|
|
||||||
- ✅ WorkerExecutionLog (database audit trail)
|
|
||||||
- ✅ CurrentUserService (JWT authentication context)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🏗️ Architecture
|
|
||||||
|
|
||||||
**Clean Architecture** with 4 layers:
|
|
||||||
```
|
|
||||||
CMSMicroservice.Domain/ # Entities, Enums, Interfaces
|
|
||||||
├── Entities/
|
|
||||||
│ ├── InventoryItem.cs # NEW: Inventory tracking
|
|
||||||
│ ├── StockMovement.cs # NEW: Movement audit
|
|
||||||
│ └── Warehouse.cs # NEW: Multi-warehouse
|
|
||||||
├── Enums/
|
|
||||||
│ └── StockMovementType.cs # NEW: Movement types
|
|
||||||
|
|
||||||
CMSMicroservice.Application/ # CQRS (Commands, Queries, MediatR)
|
|
||||||
├── Features/
|
|
||||||
│ ├── InventoryItems/ # NEW: Inventory CQRS
|
|
||||||
│ │ ├── Commands/
|
|
||||||
│ │ ├── Queries/
|
|
||||||
│ │ └── Handlers/
|
|
||||||
│ ├── StockMovements/ # NEW: Movement CQRS
|
|
||||||
│ │ ├── Commands/
|
|
||||||
│ │ ├── Queries/
|
|
||||||
│ │ └── Handlers/
|
|
||||||
│ └── Warehouses/ # NEW: Warehouse CQRS
|
|
||||||
│ ├── Commands/
|
|
||||||
│ ├── Queries/
|
|
||||||
│ └── Handlers/
|
|
||||||
└── Common/Interfaces/
|
|
||||||
└── Repositories/ # NEW: Repository interfaces
|
|
||||||
|
|
||||||
CMSMicroservice.Infrastructure/ # DbContext, Services, Background Jobs
|
|
||||||
├── Persistence/
|
|
||||||
│ ├── Context/
|
|
||||||
│ ├── Configurations/ # NEW: EF Core configs
|
|
||||||
│ ├── Repositories/ # NEW: Repository implementations
|
|
||||||
│ └── Migrations/
|
|
||||||
└── DependencyInjection.cs # NEW: DI setup
|
|
||||||
|
|
||||||
CMSMicroservice.WebApi/ # gRPC Services, Controllers
|
|
||||||
CMSMicroservice.Protobuf/ # Protocol Buffers definitions
|
|
||||||
```
|
|
||||||
|
|
||||||
**Technology Stack**:
|
|
||||||
- .NET 9.0
|
|
||||||
- Entity Framework Core 9.0.11
|
|
||||||
- gRPC + JSON Transcoding
|
|
||||||
- Hangfire 1.8.22 (Job Scheduling)
|
|
||||||
- MediatR 13.0.0 (CQRS)
|
|
||||||
- Polly 8.5.0 (Resilience)
|
|
||||||
- MailKit 4.14.1 (Email)
|
|
||||||
- Kavenegar 1.2.5 (SMS)
|
|
||||||
- SQL Server
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📖 Documentation
|
|
||||||
|
|
||||||
- **[Development Plan](docs/development-plan.md)** - NEW: Inventory system roadmap
|
|
||||||
- **[Implementation Progress](docs/implementation-progress.md)** - Detailed phase-by-phase progress
|
|
||||||
- **[Email/SMS Configuration Guide](docs/email-sms-configuration-guide.md)** - Production setup instructions
|
|
||||||
- **[Balance Calculation Logic](docs/balance-calculation-carryover-logic.md)** - Commission algorithm details
|
|
||||||
- **[Binary Tree Registration](docs/binary-tree-registration-guide.md)** - Network tree guide
|
|
||||||
- **[Network Club Commission System](docs/network-club-commission-system-v1.1.md)** - Full system specification
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🏪 Inventory System Usage
|
|
||||||
|
|
||||||
### Create Warehouse
|
|
||||||
```csharp
|
|
||||||
await mediator.Send(new CreateWarehouseCommand
|
|
||||||
{
|
|
||||||
Name = "Main Warehouse",
|
|
||||||
Code = "WH-001",
|
|
||||||
IsDefault = true,
|
|
||||||
IsActive = true
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Create Inventory Item
|
|
||||||
```csharp
|
|
||||||
await mediator.Send(new CreateInventoryItemCommand
|
|
||||||
{
|
|
||||||
ProductId = 1,
|
|
||||||
WarehouseId = 1,
|
|
||||||
Quantity = 100,
|
|
||||||
MinQuantity = 10,
|
|
||||||
MaxQuantity = 1000
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Reserve Stock for Order
|
|
||||||
```csharp
|
|
||||||
await mediator.Send(new ReserveInventoryCommand
|
|
||||||
{
|
|
||||||
Id = inventoryId,
|
|
||||||
Quantity = 5,
|
|
||||||
OrderId = 12345
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Check Availability
|
|
||||||
```csharp
|
|
||||||
bool available = await mediator.Send(
|
|
||||||
new CheckInventoryAvailabilityQuery(inventoryId, 10));
|
|
||||||
```
|
|
||||||
|
|
||||||
### Get Low Stock Alerts
|
|
||||||
```csharp
|
|
||||||
var lowStock = await mediator.Send(new GetLowStockItemsQuery
|
|
||||||
{
|
|
||||||
WarehouseId = 1,
|
|
||||||
Count = 50
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Get Movement Analytics
|
|
||||||
```csharp
|
|
||||||
var summary = await mediator.Send(new GetMovementSummaryQuery
|
|
||||||
{
|
|
||||||
FromDate = DateTime.Now.AddDays(-7),
|
|
||||||
ToDate = DateTime.Now
|
|
||||||
});
|
|
||||||
|
|
||||||
var topProducts = await mediator.Send(new GetTopMovingProductsQuery
|
|
||||||
{
|
|
||||||
FromDate = DateTime.Now.AddDays(-30),
|
|
||||||
ToDate = DateTime.Now,
|
|
||||||
Count = 10
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 Quick Start
|
|
||||||
|
|
||||||
### Prerequisites
|
|
||||||
- .NET 9.0 SDK
|
|
||||||
- SQL Server (local or remote)
|
|
||||||
- (Optional) Gmail account for Email
|
|
||||||
- (Optional) Kavenegar account for SMS
|
|
||||||
|
|
||||||
### 1. Clone & Build
|
|
||||||
```bash
|
|
||||||
cd /home/masoud/Apps/project/FourSat/CMS/src
|
|
||||||
dotnet build
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Configure Database
|
|
||||||
Update `appsettings.json` with your SQL Server connection:
|
|
||||||
```json
|
|
||||||
"ConnectionStrings": {
|
|
||||||
"DefaultConnection": "Server=YOUR_SERVER;Database=Foursat_CMS;..."
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Apply Migrations
|
|
||||||
```bash
|
|
||||||
cd CMSMicroservice.WebApi
|
|
||||||
dotnet ef database update
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Configure Notifications (Optional)
|
|
||||||
See [Email/SMS Configuration Guide](docs/email-sms-configuration-guide.md)
|
|
||||||
|
|
||||||
### 5. Run
|
|
||||||
```bash
|
|
||||||
dotnet run --urls="http://localhost:5133"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6. Access Endpoints
|
|
||||||
- **Health**: http://localhost:5133/health
|
|
||||||
- **Hangfire Dashboard**: http://localhost:5133/hangfire
|
|
||||||
- **gRPC**: localhost:5133 (HTTP/2)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔧 Configuration
|
|
||||||
|
|
||||||
### Email (SMTP)
|
|
||||||
```json
|
|
||||||
"Email": {
|
|
||||||
"Enabled": true,
|
|
||||||
"SmtpHost": "smtp.gmail.com",
|
|
||||||
"SmtpPort": 587,
|
|
||||||
"SmtpUsername": "your-email@gmail.com",
|
|
||||||
"SmtpPassword": "your-gmail-app-password",
|
|
||||||
"FromEmail": "noreply@foursat.com",
|
|
||||||
"FromName": "FourSat CMS",
|
|
||||||
"EnableSsl": true
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### SMS (Kavenegar)
|
|
||||||
```json
|
|
||||||
"Sms": {
|
|
||||||
"Enabled": true,
|
|
||||||
"Provider": "Kavenegar",
|
|
||||||
"KavenegarApiKey": "YOUR_API_KEY",
|
|
||||||
"Sender": "10008663"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Background Worker
|
|
||||||
```csharp
|
|
||||||
// Cron: "5 0 * * 0" = Every Sunday at 00:05 UTC
|
|
||||||
RecurringJob.AddOrUpdate<WeeklyCommissionJob>(
|
|
||||||
"weekly-commission-calculation",
|
|
||||||
job => job.ExecuteAsync(CancellationToken.None),
|
|
||||||
"5 0 * * 0");
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🧪 Testing
|
|
||||||
|
|
||||||
### Manual Trigger (via API)
|
|
||||||
```bash
|
|
||||||
# Trigger weekly calculation immediately
|
|
||||||
curl -X POST http://localhost:5133/api/admin/trigger-weekly-calculation
|
|
||||||
|
|
||||||
# Trigger recurring job now
|
|
||||||
curl -X POST http://localhost:5133/api/admin/trigger-recurring-job-now
|
|
||||||
|
|
||||||
# Get recurring jobs status
|
|
||||||
curl http://localhost:5133/api/admin/recurring-jobs-status
|
|
||||||
```
|
|
||||||
|
|
||||||
### Health Checks
|
|
||||||
```bash
|
|
||||||
curl http://localhost:5133/health # Overall health
|
|
||||||
curl http://localhost:5133/health/ready # Readiness probe (K8s)
|
|
||||||
curl http://localhost:5133/health/live # Liveness probe (K8s)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 What's Remaining?
|
|
||||||
|
|
||||||
### 🏪 Inventory System (Current Focus)
|
|
||||||
1. **Phase 3: Business Services** (In Progress)
|
|
||||||
- `IInventoryManagementService` - High-level operations
|
|
||||||
- `IStockMovementService` - Movement orchestration
|
|
||||||
- `IWarehouseService` - Warehouse business logic
|
|
||||||
- `IInventoryReportingService` - Advanced reporting
|
|
||||||
|
|
||||||
2. **Phase 4: DTOs & AutoMapper** (Next)
|
|
||||||
- Request/Response DTOs
|
|
||||||
- AutoMapper profiles
|
|
||||||
- Validation rules
|
|
||||||
|
|
||||||
3. **Phase 5: API Controllers** (Planned)
|
|
||||||
- `InventoryController` - REST API
|
|
||||||
- `WarehouseController` - Warehouse management
|
|
||||||
- `StockMovementController` - Movement tracking
|
|
||||||
- Swagger documentation
|
|
||||||
|
|
||||||
### 💼 Commission System
|
|
||||||
1. **Payment Gateway Integration** (Phase 10 - 1 week)
|
|
||||||
- Daya or Bank Mellat API integration
|
|
||||||
- IBAN transfer automation
|
|
||||||
- Admin approval UI in BackOffice
|
|
||||||
|
|
||||||
2. **Production Configuration** (30 minutes)
|
|
||||||
- Gmail App Password setup
|
|
||||||
- Kavenegar API key registration
|
|
||||||
- Update `appsettings.Production.json`
|
|
||||||
|
|
||||||
### Medium Priority
|
|
||||||
3. **Club Shop Integration** (Phase 9 - 2 weeks)
|
|
||||||
- Product catalog for club memberships
|
|
||||||
- Shopping cart integration
|
|
||||||
- Auto-activation on purchase
|
|
||||||
|
|
||||||
### Low Priority
|
|
||||||
4. **Testing** (Phase 7 - Postponed)
|
|
||||||
- Unit tests for business logic
|
|
||||||
- Integration tests for API
|
|
||||||
- Load testing for background worker
|
|
||||||
|
|
||||||
### Optional Enhancements
|
|
||||||
- Redis distributed locks (multi-server deployment)
|
|
||||||
- Sentry error tracking (API key needed)
|
|
||||||
- Slack notifications (webhook needed)
|
|
||||||
- FCM push notifications
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 MVP Features (100% Complete)
|
|
||||||
|
|
||||||
### 💼 Commission System:
|
|
||||||
✅ Binary network tree with automatic placement
|
|
||||||
✅ Club membership (Member/Trial) with different commission rates
|
|
||||||
✅ Weekly commission calculation (Lesser Leg algorithm)
|
|
||||||
✅ Background worker with Hangfire (cron scheduling)
|
|
||||||
✅ Balance carryover logic (rollover unused volumes)
|
|
||||||
✅ MaxWeeklyBalances cap enforcement
|
|
||||||
✅ Health check endpoints (Kubernetes-ready)
|
|
||||||
✅ Manual trigger API (admin control)
|
|
||||||
✅ Email + SMS notifications (MailKit + Kavenegar)
|
|
||||||
✅ Retry logic with exponential backoff (Polly)
|
|
||||||
✅ Audit trail (WorkerExecutionLog, History tables)
|
|
||||||
✅ Structured logging (AlertService for Sentry/Slack)
|
|
||||||
✅ JWT authentication context (CurrentUserService)
|
|
||||||
|
|
||||||
### 🏪 Inventory System (Phase 2 Complete):
|
|
||||||
✅ Domain entities (InventoryItem, StockMovement, Warehouse)
|
|
||||||
✅ Multi-warehouse inventory management
|
|
||||||
✅ Stock reservation system for orders
|
|
||||||
✅ 8 movement types with complete audit trail
|
|
||||||
✅ Repository pattern with 25+ methods per repository
|
|
||||||
✅ CQRS with 17 commands and 35 queries
|
|
||||||
✅ 52 MediatR handlers with business logic
|
|
||||||
✅ Low stock and out-of-stock alerts
|
|
||||||
✅ Advanced analytics (top products, daily volume)
|
|
||||||
✅ Bulk operations support
|
|
||||||
✅ Transaction-safe operations with rollback
|
|
||||||
✅ DI container configuration
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 👥 Team
|
|
||||||
|
|
||||||
**Development**: FourSat Team
|
|
||||||
**Last Updated**: January 2026
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📝 License
|
|
||||||
|
|
||||||
Proprietary - FourSat Company
|
|
||||||
# Multi-remote push enabled
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,116 @@
|
|||||||
|
---
|
||||||
|
# CMS Application Configuration Secret (Production)
|
||||||
|
# This Secret stores appsettings.Production.json externally in K8s
|
||||||
|
# so configuration persists independently of Docker image rebuilds.
|
||||||
|
#
|
||||||
|
# To edit config without redeploying:
|
||||||
|
# kubectl edit secret cms-appsettings
|
||||||
|
# kubectl rollout restart deployment/cms
|
||||||
|
#
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: cms-appsettings
|
||||||
|
namespace: default
|
||||||
|
labels:
|
||||||
|
app: cms
|
||||||
|
environment: production
|
||||||
|
type: Opaque
|
||||||
|
stringData:
|
||||||
|
appsettings.Production.json: |
|
||||||
|
{
|
||||||
|
"PaymentProvider": "zarinpal",
|
||||||
|
"ZarinPal": {
|
||||||
|
"MerchantId": "4225d555-5fa9-4df0-9b61-1ce152cbbba8",
|
||||||
|
"UseSandbox": false
|
||||||
|
},
|
||||||
|
"CmsBaseUrl": "https://cms.kbs2.ir",
|
||||||
|
"FrontOfficeBaseUrl": "https://kbs2.ir",
|
||||||
|
"JwtSecurityKey": "TvlZVx5TJaHs8e9HgUdGzhGP2CIidoI444nAj+8+g7c=",
|
||||||
|
"JwtIssuer": "https://localhost",
|
||||||
|
"JwtAudience": "https://localhost",
|
||||||
|
"JwtExpiryInDays": 5,
|
||||||
|
"ConnectionStrings": {
|
||||||
|
"DefaultConnection": "Server=mssql-svc;Database=KBS;User Id=sa;Password=YourStrong@Passw0rd;TrustServerCertificate=True;",
|
||||||
|
"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": "43676A4E786A6C50452F2F6252507939346A5145764B33566B456454374E657A614468706658656D6534593D",
|
||||||
|
"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": false,
|
||||||
|
"CronExpression": "5 0 * * 0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"SeedWorkers": {
|
||||||
|
"MagicWalletCycleSeed": {
|
||||||
|
"Enabled": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"AllowedHosts": "*",
|
||||||
|
"Kestrel": {
|
||||||
|
"EndpointDefaults": {
|
||||||
|
"Protocols": "Http2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Authentication": {
|
||||||
|
"Authority": "https://ids.domain.com/",
|
||||||
|
"Audience": "domain_api"
|
||||||
|
},
|
||||||
|
"Seq": {
|
||||||
|
"ServerUrl": "http://seq-svc:5341",
|
||||||
|
"ApiKey": "oxpvpUzU1pZxMS4s3Fqq"
|
||||||
|
},
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Warning",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
---
|
||||||
|
# CMS Uploads PersistentVolumeClaim (Production)
|
||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: cms-uploads-pvc
|
||||||
|
namespace: default
|
||||||
|
labels:
|
||||||
|
app: cms
|
||||||
|
spec:
|
||||||
|
accessModes:
|
||||||
|
- ReadWriteOnce
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 20Gi
|
||||||
|
|
||||||
|
---
|
||||||
|
# CMS Microservice Deployment (Production)
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: cms
|
||||||
|
namespace: default
|
||||||
|
labels:
|
||||||
|
app: cms
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: cms
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: cms
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: cms
|
||||||
|
image: 194.5.195.53:30080/admin/cms:prod
|
||||||
|
imagePullPolicy: Always
|
||||||
|
ports:
|
||||||
|
- containerPort: 8080
|
||||||
|
name: http
|
||||||
|
env:
|
||||||
|
- name: ASPNETCORE_ENVIRONMENT
|
||||||
|
value: "Production"
|
||||||
|
- name: ASPNETCORE_URLS
|
||||||
|
value: "http://+:8080"
|
||||||
|
volumeMounts:
|
||||||
|
- name: cms-uploads
|
||||||
|
mountPath: /app/Uploads
|
||||||
|
- name: cms-config
|
||||||
|
mountPath: /app/appsettings.Production.json
|
||||||
|
subPath: appsettings.Production.json
|
||||||
|
readOnly: true
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
memory: "512Mi"
|
||||||
|
cpu: "500m"
|
||||||
|
limits:
|
||||||
|
memory: "1Gi"
|
||||||
|
cpu: "1000m"
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /health/live
|
||||||
|
port: 8080
|
||||||
|
initialDelaySeconds: 30
|
||||||
|
periodSeconds: 10
|
||||||
|
timeoutSeconds: 5
|
||||||
|
failureThreshold: 3
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /health/ready
|
||||||
|
port: 8080
|
||||||
|
initialDelaySeconds: 10
|
||||||
|
periodSeconds: 5
|
||||||
|
timeoutSeconds: 3
|
||||||
|
failureThreshold: 3
|
||||||
|
volumes:
|
||||||
|
- name: cms-uploads
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: cms-uploads-pvc
|
||||||
|
- name: cms-config
|
||||||
|
secret:
|
||||||
|
secretName: cms-appsettings
|
||||||
|
|
||||||
|
---
|
||||||
|
# CMS Service (Production)
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: cms-svc
|
||||||
|
namespace: default
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: cms
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
port: 8080
|
||||||
|
targetPort: 8080
|
||||||
|
type: ClusterIP
|
||||||
|
|
||||||
|
---
|
||||||
|
# CMS Ingress (Production)
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: cms-ingress
|
||||||
|
namespace: default
|
||||||
|
annotations:
|
||||||
|
cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
||||||
|
nginx.ingress.kubernetes.io/ssl-redirect: "true"
|
||||||
|
spec:
|
||||||
|
ingressClassName: nginx
|
||||||
|
tls:
|
||||||
|
- hosts:
|
||||||
|
- cms.kbs1.ir
|
||||||
|
- cms.kbs2.ir
|
||||||
|
secretName: cms-tls
|
||||||
|
rules:
|
||||||
|
- host: cms.kbs2.ir
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: cms-svc
|
||||||
|
port:
|
||||||
|
number: 8080
|
||||||
|
- host: cms.kbs1.ir
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: cms-svc
|
||||||
|
port:
|
||||||
|
number: 8080
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
---
|
||||||
|
# CMS Application Configuration Secret (Staging)
|
||||||
|
# This Secret stores appsettings.Staging.json externally in K8s
|
||||||
|
# so configuration persists independently of Docker image rebuilds.
|
||||||
|
#
|
||||||
|
# To edit config without redeploying:
|
||||||
|
# kubectl edit secret cms-appsettings
|
||||||
|
# kubectl rollout restart deployment/cms
|
||||||
|
#
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: cms-appsettings
|
||||||
|
namespace: default
|
||||||
|
labels:
|
||||||
|
app: cms
|
||||||
|
environment: staging
|
||||||
|
type: Opaque
|
||||||
|
stringData:
|
||||||
|
appsettings.Staging.json: |
|
||||||
|
{
|
||||||
|
"PaymentProvider": "zarinpal",
|
||||||
|
"ZarinPal": {
|
||||||
|
"MerchantId": "4225d555-5fa9-4df0-9b61-1ce152cbbba8",
|
||||||
|
"UseSandbox": true
|
||||||
|
},
|
||||||
|
"FMS": {
|
||||||
|
"Address": "https://dl.afrino.co"
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"SeedWorkers": {
|
||||||
|
"MagicWalletCycleSeed": {
|
||||||
|
"Enabled": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"FileStorage": {
|
||||||
|
"UploadPath": "/app/Uploads"
|
||||||
|
},
|
||||||
|
"AllowedHosts": "*",
|
||||||
|
"Kestrel": {
|
||||||
|
"EndpointDefaults": {
|
||||||
|
"Protocols": "Http1AndHttp2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Authentication": {
|
||||||
|
"Authority": "https://ids.domain.com/",
|
||||||
|
"Audience": "domain_api"
|
||||||
|
},
|
||||||
|
"Seq": {
|
||||||
|
"ServerUrl": "https://seq.afrino.co",
|
||||||
|
"ApiKey": "oxpvpUzU1pZxMS4s3Fqq"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
---
|
||||||
|
# CMS Uploads PersistentVolumeClaim
|
||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: cms-uploads-pvc
|
||||||
|
namespace: default
|
||||||
|
labels:
|
||||||
|
app: cms
|
||||||
|
spec:
|
||||||
|
accessModes:
|
||||||
|
- ReadWriteOnce
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 20Gi
|
||||||
|
|
||||||
|
---
|
||||||
|
# CMS Microservice Deployment
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: cms
|
||||||
|
namespace: default
|
||||||
|
labels:
|
||||||
|
app: cms
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: cms
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: cms
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: cms
|
||||||
|
image: 194.5.195.53:30080/admin/cms:latest
|
||||||
|
imagePullPolicy: Always
|
||||||
|
ports:
|
||||||
|
- containerPort: 8080
|
||||||
|
name: http
|
||||||
|
env:
|
||||||
|
- name: ASPNETCORE_ENVIRONMENT
|
||||||
|
value: "Staging"
|
||||||
|
- name: ASPNETCORE_URLS
|
||||||
|
value: "http://+:8080"
|
||||||
|
volumeMounts:
|
||||||
|
- name: cms-uploads
|
||||||
|
mountPath: /app/Uploads
|
||||||
|
- name: cms-config
|
||||||
|
mountPath: /app/appsettings.Staging.json
|
||||||
|
subPath: appsettings.Staging.json
|
||||||
|
readOnly: true
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
memory: "512Mi"
|
||||||
|
cpu: "500m"
|
||||||
|
limits:
|
||||||
|
memory: "1Gi"
|
||||||
|
cpu: "1000m"
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /health/live
|
||||||
|
port: 8080
|
||||||
|
initialDelaySeconds: 30
|
||||||
|
periodSeconds: 10
|
||||||
|
timeoutSeconds: 5
|
||||||
|
failureThreshold: 3
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /health/ready
|
||||||
|
port: 8080
|
||||||
|
initialDelaySeconds: 10
|
||||||
|
periodSeconds: 5
|
||||||
|
timeoutSeconds: 3
|
||||||
|
failureThreshold: 3
|
||||||
|
volumes:
|
||||||
|
- name: cms-uploads
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: cms-uploads-pvc
|
||||||
|
- name: cms-config
|
||||||
|
secret:
|
||||||
|
secretName: cms-appsettings
|
||||||
|
|
||||||
|
---
|
||||||
|
# CMS Service
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: cms-svc
|
||||||
|
namespace: default
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: cms
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
port: 8080
|
||||||
|
targetPort: 8080
|
||||||
|
type: ClusterIP
|
||||||
|
|
||||||
|
---
|
||||||
|
# CMS Ingress
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: cms-ingress
|
||||||
|
namespace: default
|
||||||
|
annotations:
|
||||||
|
kubernetes.io/ingress.class: "nginx"
|
||||||
|
cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
||||||
|
nginx.ingress.kubernetes.io/ssl-redirect: "true"
|
||||||
|
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
|
||||||
|
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
|
||||||
|
spec:
|
||||||
|
tls:
|
||||||
|
- hosts:
|
||||||
|
- cms.se.kbs1.ir
|
||||||
|
secretName: cms-tls
|
||||||
|
rules:
|
||||||
|
- host: cms.se.kbs1.ir
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: cms-svc
|
||||||
|
port:
|
||||||
|
number: 8080
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace CMSMicroservice.Application.BlogCategoryCQ.Commands.CreateBlogCategory;
|
||||||
|
|
||||||
|
public class CreateBlogCategoryCommand : IRequest<long>
|
||||||
|
{
|
||||||
|
public string Title { get; set; } = default!;
|
||||||
|
public string Slug { get; set; } = default!;
|
||||||
|
public string? Description { get; set; }
|
||||||
|
public string? IconName { get; set; }
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
public bool IsActive { get; set; } = true;
|
||||||
|
}
|
||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
using CMSMicroservice.Application.Common.Interfaces;
|
||||||
|
using CMSMicroservice.Domain.Entities.Blog;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace CMSMicroservice.Application.BlogCategoryCQ.Commands.CreateBlogCategory;
|
||||||
|
|
||||||
|
public class CreateBlogCategoryCommandHandler : IRequestHandler<CreateBlogCategoryCommand, long>
|
||||||
|
{
|
||||||
|
private readonly IApplicationDbContext _context;
|
||||||
|
|
||||||
|
public CreateBlogCategoryCommandHandler(IApplicationDbContext context)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<long> Handle(CreateBlogCategoryCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var entity = new BlogCategory
|
||||||
|
{
|
||||||
|
Title = request.Title,
|
||||||
|
Slug = request.Slug.ToLower(),
|
||||||
|
Description = request.Description,
|
||||||
|
IconName = request.IconName,
|
||||||
|
SortOrder = request.SortOrder,
|
||||||
|
IsActive = request.IsActive
|
||||||
|
};
|
||||||
|
|
||||||
|
_context.BlogCategories.Add(entity);
|
||||||
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
return entity.Id;
|
||||||
|
}
|
||||||
|
}
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
namespace CMSMicroservice.Application.BlogCategoryCQ.Commands.CreateBlogCategory;
|
||||||
|
|
||||||
|
public class CreateBlogCategoryCommandValidator : AbstractValidator<CreateBlogCategoryCommand>
|
||||||
|
{
|
||||||
|
public CreateBlogCategoryCommandValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Title)
|
||||||
|
.NotEmpty().WithMessage("عنوان دستهبندی الزامی است")
|
||||||
|
.MaximumLength(200).WithMessage("عنوان دستهبندی حداکثر ۲۰۰ کاراکتر");
|
||||||
|
|
||||||
|
RuleFor(x => x.Slug)
|
||||||
|
.NotEmpty().WithMessage("اسلاگ الزامی است")
|
||||||
|
.MaximumLength(200).WithMessage("اسلاگ حداکثر ۲۰۰ کاراکتر")
|
||||||
|
.Matches(@"^[a-z0-9\-]+$").WithMessage("اسلاگ فقط شامل حروف کوچک، اعداد و خط تیره");
|
||||||
|
|
||||||
|
RuleFor(x => x.Description)
|
||||||
|
.MaximumLength(1000).WithMessage("توضیحات حداکثر ۱۰۰۰ کاراکتر");
|
||||||
|
|
||||||
|
RuleFor(x => x.IconName)
|
||||||
|
.MaximumLength(100).WithMessage("نام آیکون حداکثر ۱۰۰ کاراکتر");
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
@@ -0,0 +1,8 @@
|
|||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace CMSMicroservice.Application.BlogCategoryCQ.Commands.DeleteBlogCategory;
|
||||||
|
|
||||||
|
public class DeleteBlogCategoryCommand : IRequest<Unit>
|
||||||
|
{
|
||||||
|
public long Id { get; set; }
|
||||||
|
}
|
||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
using CMSMicroservice.Application.Common.Exceptions;
|
||||||
|
using CMSMicroservice.Application.Common.Interfaces;
|
||||||
|
using CMSMicroservice.Domain.Entities.Blog;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace CMSMicroservice.Application.BlogCategoryCQ.Commands.DeleteBlogCategory;
|
||||||
|
|
||||||
|
public class DeleteBlogCategoryCommandHandler : IRequestHandler<DeleteBlogCategoryCommand, Unit>
|
||||||
|
{
|
||||||
|
private readonly IApplicationDbContext _context;
|
||||||
|
|
||||||
|
public DeleteBlogCategoryCommandHandler(IApplicationDbContext context)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Unit> Handle(DeleteBlogCategoryCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var entity = await _context.BlogCategories.FindAsync(new object[] { request.Id }, cancellationToken);
|
||||||
|
if (entity == null || entity.IsDeleted)
|
||||||
|
throw new NotFoundException(nameof(BlogCategory), request.Id);
|
||||||
|
|
||||||
|
entity.IsDeleted = true;
|
||||||
|
|
||||||
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
return Unit.Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace CMSMicroservice.Application.BlogCategoryCQ.Commands.UpdateBlogCategory;
|
||||||
|
|
||||||
|
public class UpdateBlogCategoryCommand : IRequest<Unit>
|
||||||
|
{
|
||||||
|
public long Id { get; set; }
|
||||||
|
public string Title { get; set; } = default!;
|
||||||
|
public string Slug { get; set; } = default!;
|
||||||
|
public string? Description { get; set; }
|
||||||
|
public string? IconName { get; set; }
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
public bool IsActive { get; set; }
|
||||||
|
}
|
||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
using CMSMicroservice.Application.Common.Exceptions;
|
||||||
|
using CMSMicroservice.Application.Common.Interfaces;
|
||||||
|
using CMSMicroservice.Domain.Entities.Blog;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace CMSMicroservice.Application.BlogCategoryCQ.Commands.UpdateBlogCategory;
|
||||||
|
|
||||||
|
public class UpdateBlogCategoryCommandHandler : IRequestHandler<UpdateBlogCategoryCommand, Unit>
|
||||||
|
{
|
||||||
|
private readonly IApplicationDbContext _context;
|
||||||
|
|
||||||
|
public UpdateBlogCategoryCommandHandler(IApplicationDbContext context)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Unit> Handle(UpdateBlogCategoryCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var entity = await _context.BlogCategories.FindAsync(new object[] { request.Id }, cancellationToken);
|
||||||
|
if (entity == null || entity.IsDeleted)
|
||||||
|
throw new NotFoundException(nameof(BlogCategory), request.Id);
|
||||||
|
|
||||||
|
entity.Title = request.Title;
|
||||||
|
entity.Slug = request.Slug.ToLower();
|
||||||
|
entity.Description = request.Description;
|
||||||
|
entity.IconName = request.IconName;
|
||||||
|
entity.SortOrder = request.SortOrder;
|
||||||
|
entity.IsActive = request.IsActive;
|
||||||
|
|
||||||
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
return Unit.Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
namespace CMSMicroservice.Application.BlogCategoryCQ.Commands.UpdateBlogCategory;
|
||||||
|
|
||||||
|
public class UpdateBlogCategoryCommandValidator : AbstractValidator<UpdateBlogCategoryCommand>
|
||||||
|
{
|
||||||
|
public UpdateBlogCategoryCommandValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Id)
|
||||||
|
.GreaterThan(0).WithMessage("شناسه دستهبندی نامعتبر است");
|
||||||
|
|
||||||
|
RuleFor(x => x.Title)
|
||||||
|
.NotEmpty().WithMessage("عنوان دستهبندی الزامی است")
|
||||||
|
.MaximumLength(200).WithMessage("عنوان دستهبندی حداکثر ۲۰۰ کاراکتر");
|
||||||
|
|
||||||
|
RuleFor(x => x.Slug)
|
||||||
|
.NotEmpty().WithMessage("اسلاگ الزامی است")
|
||||||
|
.MaximumLength(200).WithMessage("اسلاگ حداکثر ۲۰۰ کاراکتر")
|
||||||
|
.Matches(@"^[a-z0-9\-]+$").WithMessage("اسلاگ فقط شامل حروف کوچک، اعداد و خط تیره");
|
||||||
|
|
||||||
|
RuleFor(x => x.Description)
|
||||||
|
.MaximumLength(1000).WithMessage("توضیحات حداکثر ۱۰۰۰ کاراکتر");
|
||||||
|
|
||||||
|
RuleFor(x => x.IconName)
|
||||||
|
.MaximumLength(100).WithMessage("نام آیکون حداکثر ۱۰۰ کاراکتر");
|
||||||
|
}
|
||||||
|
}
|
||||||
+45
@@ -0,0 +1,45 @@
|
|||||||
|
using CMSMicroservice.Application.BlogCategoryCQ.Queries.GetBlogCategory;
|
||||||
|
using CMSMicroservice.Application.Common.Interfaces;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace CMSMicroservice.Application.BlogCategoryCQ.Queries.GetActiveBlogCategories;
|
||||||
|
|
||||||
|
public class GetActiveBlogCategoriesQuery : IRequest<List<BlogCategoryDto>>
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public class GetActiveBlogCategoriesQueryHandler : IRequestHandler<GetActiveBlogCategoriesQuery, List<BlogCategoryDto>>
|
||||||
|
{
|
||||||
|
private readonly IApplicationDbContext _context;
|
||||||
|
|
||||||
|
public GetActiveBlogCategoriesQueryHandler(IApplicationDbContext context)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<BlogCategoryDto>> Handle(GetActiveBlogCategoriesQuery request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var categories = await _context.BlogCategories
|
||||||
|
.Include(x => x.BlogPostCategories)
|
||||||
|
.Where(x => !x.IsDeleted && x.IsActive)
|
||||||
|
.OrderBy(x => x.SortOrder)
|
||||||
|
.ThenBy(x => x.Title)
|
||||||
|
.Select(x => new BlogCategoryDto
|
||||||
|
{
|
||||||
|
Id = x.Id,
|
||||||
|
Title = x.Title,
|
||||||
|
Slug = x.Slug,
|
||||||
|
Description = x.Description,
|
||||||
|
IconName = x.IconName,
|
||||||
|
SortOrder = x.SortOrder,
|
||||||
|
IsActive = x.IsActive,
|
||||||
|
PostCount = x.BlogPostCategories.Count,
|
||||||
|
Created = x.Created,
|
||||||
|
LastModified = x.LastModified
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return categories;
|
||||||
|
}
|
||||||
|
}
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
using CMSMicroservice.Application.Common.Models;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace CMSMicroservice.Application.BlogCategoryCQ.Queries.GetAllBlogCategories;
|
||||||
|
|
||||||
|
public class GetAllBlogCategoriesQuery : IRequest<GetAllBlogCategoriesResponseDto>
|
||||||
|
{
|
||||||
|
public int PageNumber { get; set; } = 1;
|
||||||
|
public int PageSize { get; set; } = 20;
|
||||||
|
public string? SearchTerm { get; set; }
|
||||||
|
}
|
||||||
+64
@@ -0,0 +1,64 @@
|
|||||||
|
using CMSMicroservice.Application.BlogCategoryCQ.Queries.GetBlogCategory;
|
||||||
|
using CMSMicroservice.Application.Common.Interfaces;
|
||||||
|
using CMSMicroservice.Application.Common.Models;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace CMSMicroservice.Application.BlogCategoryCQ.Queries.GetAllBlogCategories;
|
||||||
|
|
||||||
|
public class GetAllBlogCategoriesQueryHandler : IRequestHandler<GetAllBlogCategoriesQuery, GetAllBlogCategoriesResponseDto>
|
||||||
|
{
|
||||||
|
private readonly IApplicationDbContext _context;
|
||||||
|
|
||||||
|
public GetAllBlogCategoriesQueryHandler(IApplicationDbContext context)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<GetAllBlogCategoriesResponseDto> Handle(GetAllBlogCategoriesQuery request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var query = _context.BlogCategories
|
||||||
|
.Include(x => x.BlogPostCategories)
|
||||||
|
.Where(x => !x.IsDeleted);
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(request.SearchTerm))
|
||||||
|
{
|
||||||
|
var term = request.SearchTerm.ToLower();
|
||||||
|
query = query.Where(x => x.Title.ToLower().Contains(term) || x.Slug.ToLower().Contains(term));
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalCount = await query.CountAsync(cancellationToken);
|
||||||
|
|
||||||
|
var categories = await query
|
||||||
|
.OrderBy(x => x.SortOrder)
|
||||||
|
.ThenBy(x => x.Title)
|
||||||
|
.Skip((request.PageNumber - 1) * request.PageSize)
|
||||||
|
.Take(request.PageSize)
|
||||||
|
.Select(x => new BlogCategoryDto
|
||||||
|
{
|
||||||
|
Id = x.Id,
|
||||||
|
Title = x.Title,
|
||||||
|
Slug = x.Slug,
|
||||||
|
Description = x.Description,
|
||||||
|
IconName = x.IconName,
|
||||||
|
SortOrder = x.SortOrder,
|
||||||
|
IsActive = x.IsActive,
|
||||||
|
PostCount = x.BlogPostCategories.Count,
|
||||||
|
Created = x.Created,
|
||||||
|
LastModified = x.LastModified
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var metaData = new MetaData
|
||||||
|
{
|
||||||
|
TotalCount = totalCount,
|
||||||
|
PageSize = request.PageSize,
|
||||||
|
CurrentPage = request.PageNumber,
|
||||||
|
TotalPage = (int)Math.Ceiling(totalCount / (double)request.PageSize),
|
||||||
|
HasNext = request.PageNumber < (int)Math.Ceiling(totalCount / (double)request.PageSize),
|
||||||
|
HasPrevious = request.PageNumber > 1
|
||||||
|
};
|
||||||
|
|
||||||
|
return new GetAllBlogCategoriesResponseDto { MetaData = metaData, Models = categories };
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
using CMSMicroservice.Application.BlogCategoryCQ.Queries.GetBlogCategory;
|
||||||
|
using CMSMicroservice.Application.Common.Models;
|
||||||
|
|
||||||
|
namespace CMSMicroservice.Application.BlogCategoryCQ.Queries.GetAllBlogCategories;
|
||||||
|
|
||||||
|
public class GetAllBlogCategoriesResponseDto
|
||||||
|
{
|
||||||
|
public MetaData MetaData { get; set; } = default!;
|
||||||
|
public List<BlogCategoryDto> Models { get; set; } = new();
|
||||||
|
}
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
namespace CMSMicroservice.Application.BlogCategoryCQ.Queries.GetBlogCategory;
|
||||||
|
|
||||||
|
public class BlogCategoryDto
|
||||||
|
{
|
||||||
|
public long Id { get; set; }
|
||||||
|
public string Title { get; set; } = default!;
|
||||||
|
public string Slug { get; set; } = default!;
|
||||||
|
public string? Description { get; set; }
|
||||||
|
public string? IconName { get; set; }
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
public bool IsActive { get; set; }
|
||||||
|
public int PostCount { get; set; }
|
||||||
|
public DateTime Created { get; set; }
|
||||||
|
public DateTime? LastModified { get; set; }
|
||||||
|
}
|
||||||
+8
@@ -0,0 +1,8 @@
|
|||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace CMSMicroservice.Application.BlogCategoryCQ.Queries.GetBlogCategory;
|
||||||
|
|
||||||
|
public class GetBlogCategoryQuery : IRequest<BlogCategoryDto>
|
||||||
|
{
|
||||||
|
public long Id { get; set; }
|
||||||
|
}
|
||||||
+41
@@ -0,0 +1,41 @@
|
|||||||
|
using CMSMicroservice.Application.Common.Exceptions;
|
||||||
|
using CMSMicroservice.Application.Common.Interfaces;
|
||||||
|
using CMSMicroservice.Domain.Entities.Blog;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace CMSMicroservice.Application.BlogCategoryCQ.Queries.GetBlogCategory;
|
||||||
|
|
||||||
|
public class GetBlogCategoryQueryHandler : IRequestHandler<GetBlogCategoryQuery, BlogCategoryDto>
|
||||||
|
{
|
||||||
|
private readonly IApplicationDbContext _context;
|
||||||
|
|
||||||
|
public GetBlogCategoryQueryHandler(IApplicationDbContext context)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<BlogCategoryDto> Handle(GetBlogCategoryQuery request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var entity = await _context.BlogCategories
|
||||||
|
.Include(x => x.BlogPostCategories)
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == request.Id && !x.IsDeleted, cancellationToken);
|
||||||
|
|
||||||
|
if (entity == null)
|
||||||
|
throw new NotFoundException(nameof(BlogCategory), request.Id);
|
||||||
|
|
||||||
|
return new BlogCategoryDto
|
||||||
|
{
|
||||||
|
Id = entity.Id,
|
||||||
|
Title = entity.Title,
|
||||||
|
Slug = entity.Slug,
|
||||||
|
Description = entity.Description,
|
||||||
|
IconName = entity.IconName,
|
||||||
|
SortOrder = entity.SortOrder,
|
||||||
|
IsActive = entity.IsActive,
|
||||||
|
PostCount = entity.BlogPostCategories.Count,
|
||||||
|
Created = entity.Created,
|
||||||
|
LastModified = entity.LastModified
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace CMSMicroservice.Application.BlogPostCQ.Commands.ArchiveBlogPost;
|
||||||
|
|
||||||
|
public class ArchiveBlogPostCommand : IRequest<ArchiveBlogPostResult>
|
||||||
|
{
|
||||||
|
public long Id { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ArchiveBlogPostResult
|
||||||
|
{
|
||||||
|
public bool Success { get; set; }
|
||||||
|
public string Message { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
using CMSMicroservice.Application.Common.Interfaces;
|
||||||
|
using CMSMicroservice.Domain.Enums;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace CMSMicroservice.Application.BlogPostCQ.Commands.ArchiveBlogPost;
|
||||||
|
|
||||||
|
public class ArchiveBlogPostCommandHandler : IRequestHandler<ArchiveBlogPostCommand, ArchiveBlogPostResult>
|
||||||
|
{
|
||||||
|
private readonly IApplicationDbContext _context;
|
||||||
|
private readonly ILogger<ArchiveBlogPostCommandHandler> _logger;
|
||||||
|
|
||||||
|
public ArchiveBlogPostCommandHandler(IApplicationDbContext context, ILogger<ArchiveBlogPostCommandHandler> logger)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ArchiveBlogPostResult> Handle(ArchiveBlogPostCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var post = await _context.BlogPosts.FirstOrDefaultAsync(x => x.Id == request.Id && !x.IsDeleted, cancellationToken)
|
||||||
|
?? throw new KeyNotFoundException($"مقاله با شناسه {request.Id} یافت نشد");
|
||||||
|
|
||||||
|
post.Status = BlogPostStatus.Archived;
|
||||||
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
_logger.LogInformation("Blog post archived. Id: {Id}, Title: {Title}", post.Id, post.Title);
|
||||||
|
|
||||||
|
return new ArchiveBlogPostResult { Success = true, Message = "مقاله آرشیو شد" };
|
||||||
|
}
|
||||||
|
}
|
||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace CMSMicroservice.Application.BlogPostCQ.Commands.CreateBlogPost;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// دستور ایجاد مقاله جدید
|
||||||
|
/// </summary>
|
||||||
|
public class CreateBlogPostCommand : IRequest<long>
|
||||||
|
{
|
||||||
|
public string Title { get; set; } = string.Empty;
|
||||||
|
public string Slug { get; set; } = string.Empty;
|
||||||
|
public string? Summary { get; set; }
|
||||||
|
public string HtmlContent { get; set; } = string.Empty;
|
||||||
|
public string? FeaturedImagePath { get; set; }
|
||||||
|
public string? FeaturedImageThumbnailPath { get; set; }
|
||||||
|
public List<long> CategoryIds { get; set; } = new();
|
||||||
|
public List<long> TagIds { get; set; } = new();
|
||||||
|
public bool IsFeatured { get; set; }
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
|
||||||
|
// Image upload properties
|
||||||
|
public byte[]? ImageFileBytes { get; set; }
|
||||||
|
public string? ImageFileMime { get; set; }
|
||||||
|
public string? ImageFileName { get; set; }
|
||||||
|
}
|
||||||
+98
@@ -0,0 +1,98 @@
|
|||||||
|
using CMSMicroservice.Application.Common.FileManager;
|
||||||
|
using CMSMicroservice.Application.Common.Interfaces;
|
||||||
|
using CMSMicroservice.Domain.Entities.Blog;
|
||||||
|
using CMSMicroservice.Domain.Enums;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace CMSMicroservice.Application.BlogPostCQ.Commands.CreateBlogPost;
|
||||||
|
|
||||||
|
public class CreateBlogPostCommandHandler : IRequestHandler<CreateBlogPostCommand, long>
|
||||||
|
{
|
||||||
|
private readonly IApplicationDbContext _context;
|
||||||
|
private readonly ICurrentUserService _currentUser;
|
||||||
|
private readonly IFileManager _fileManager;
|
||||||
|
private readonly ILogger<CreateBlogPostCommandHandler> _logger;
|
||||||
|
|
||||||
|
public CreateBlogPostCommandHandler(
|
||||||
|
IApplicationDbContext context,
|
||||||
|
ICurrentUserService currentUser,
|
||||||
|
IFileManager fileManager,
|
||||||
|
ILogger<CreateBlogPostCommandHandler> logger)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
_currentUser = currentUser;
|
||||||
|
_fileManager = fileManager;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<long> Handle(CreateBlogPostCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var currentUserId = _currentUser.UserId;
|
||||||
|
if (string.IsNullOrEmpty(currentUserId) || !long.TryParse(currentUserId, out var authorUserId))
|
||||||
|
throw new UnauthorizedAccessException("کاربر احراز هویت نشده است");
|
||||||
|
|
||||||
|
var post = new BlogPost
|
||||||
|
{
|
||||||
|
Title = request.Title.Trim(),
|
||||||
|
Slug = string.IsNullOrWhiteSpace(request.Slug)
|
||||||
|
? $"post-{Guid.NewGuid():N}".Substring(0, 20)
|
||||||
|
: request.Slug.Trim().ToLower(),
|
||||||
|
Summary = request.Summary?.Trim(),
|
||||||
|
HtmlContent = request.HtmlContent,
|
||||||
|
FeaturedImagePath = request.FeaturedImagePath,
|
||||||
|
FeaturedImageThumbnailPath = request.FeaturedImageThumbnailPath,
|
||||||
|
Status = BlogPostStatus.Draft,
|
||||||
|
AuthorUserId = authorUserId,
|
||||||
|
IsFeatured = request.IsFeatured,
|
||||||
|
SortOrder = request.SortOrder,
|
||||||
|
ViewCount = 0
|
||||||
|
};
|
||||||
|
|
||||||
|
// آپلود تصویر شاخص (اگر فایل ارسال شده باشد)
|
||||||
|
if (request.ImageFileBytes is { Length: > 0 })
|
||||||
|
{
|
||||||
|
var result = await _fileManager.UploadImageAsync(
|
||||||
|
"Images/BlogPosts",
|
||||||
|
request.ImageFileBytes,
|
||||||
|
request.ImageFileMime ?? "image/jpeg",
|
||||||
|
request.ImageFileName,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
post.FeaturedImagePath = result.Main.Path;
|
||||||
|
post.FeaturedImageThumbnailPath = result.Thumbnail.Path;
|
||||||
|
}
|
||||||
|
|
||||||
|
_context.BlogPosts.Add(post);
|
||||||
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
// افزودن دستهبندیها
|
||||||
|
foreach (var categoryId in request.CategoryIds)
|
||||||
|
{
|
||||||
|
_context.BlogPostCategories.Add(new BlogPostCategory
|
||||||
|
{
|
||||||
|
BlogPostId = post.Id,
|
||||||
|
BlogCategoryId = categoryId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// افزودن تگها
|
||||||
|
foreach (var tagId in request.TagIds)
|
||||||
|
{
|
||||||
|
_context.BlogPostTags.Add(new BlogPostTag
|
||||||
|
{
|
||||||
|
BlogPostId = post.Id,
|
||||||
|
TagId = tagId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.CategoryIds.Any() || request.TagIds.Any())
|
||||||
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Blog post created. Id: {Id}, Title: {Title}, Author: {Author}",
|
||||||
|
post.Id, post.Title, authorUserId);
|
||||||
|
|
||||||
|
return post.Id;
|
||||||
|
}
|
||||||
|
}
|
||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
namespace CMSMicroservice.Application.BlogPostCQ.Commands.CreateBlogPost;
|
||||||
|
|
||||||
|
public class CreateBlogPostCommandValidator : AbstractValidator<CreateBlogPostCommand>
|
||||||
|
{
|
||||||
|
public CreateBlogPostCommandValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Title)
|
||||||
|
.NotEmpty().WithMessage("عنوان مقاله الزامی است")
|
||||||
|
.MaximumLength(200).WithMessage("عنوان نمیتواند بیشتر از 200 کاراکتر باشد");
|
||||||
|
|
||||||
|
RuleFor(x => x.Slug)
|
||||||
|
.NotEmpty().WithMessage("نشانی یکتا (slug) الزامی است")
|
||||||
|
.MaximumLength(200).WithMessage("نشانی نمیتواند بیشتر از 200 کاراکتر باشد")
|
||||||
|
.Matches(@"^[a-z0-9\-]+$").WithMessage("نشانی فقط میتواند شامل حروف کوچک انگلیسی، اعداد و خط تیره باشد");
|
||||||
|
|
||||||
|
RuleFor(x => x.Summary)
|
||||||
|
.MaximumLength(500).WithMessage("خلاصه نمیتواند بیشتر از 500 کاراکتر باشد")
|
||||||
|
.When(x => !string.IsNullOrEmpty(x.Summary));
|
||||||
|
|
||||||
|
RuleFor(x => x.HtmlContent)
|
||||||
|
.NotEmpty().WithMessage("محتوای مقاله الزامی است");
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
@@ -0,0 +1,8 @@
|
|||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace CMSMicroservice.Application.BlogPostCQ.Commands.DeleteBlogPost;
|
||||||
|
|
||||||
|
public class DeleteBlogPostCommand : IRequest<Unit>
|
||||||
|
{
|
||||||
|
public long Id { get; set; }
|
||||||
|
}
|
||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
using CMSMicroservice.Application.Common.Interfaces;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace CMSMicroservice.Application.BlogPostCQ.Commands.DeleteBlogPost;
|
||||||
|
|
||||||
|
public class DeleteBlogPostCommandHandler : IRequestHandler<DeleteBlogPostCommand, Unit>
|
||||||
|
{
|
||||||
|
private readonly IApplicationDbContext _context;
|
||||||
|
private readonly ILogger<DeleteBlogPostCommandHandler> _logger;
|
||||||
|
|
||||||
|
public DeleteBlogPostCommandHandler(IApplicationDbContext context, ILogger<DeleteBlogPostCommandHandler> logger)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Unit> Handle(DeleteBlogPostCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var post = await _context.BlogPosts.FirstOrDefaultAsync(x => x.Id == request.Id && !x.IsDeleted, cancellationToken)
|
||||||
|
?? throw new KeyNotFoundException($"مقاله با شناسه {request.Id} یافت نشد");
|
||||||
|
|
||||||
|
post.IsDeleted = true;
|
||||||
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
_logger.LogInformation("Blog post soft-deleted. Id: {Id}", post.Id);
|
||||||
|
|
||||||
|
return Unit.Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
@@ -0,0 +1,8 @@
|
|||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace CMSMicroservice.Application.BlogPostCQ.Commands.IncrementViewCount;
|
||||||
|
|
||||||
|
public class IncrementViewCountCommand : IRequest<Unit>
|
||||||
|
{
|
||||||
|
public long Id { get; set; }
|
||||||
|
}
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
using CMSMicroservice.Application.Common.Interfaces;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace CMSMicroservice.Application.BlogPostCQ.Commands.IncrementViewCount;
|
||||||
|
|
||||||
|
public class IncrementViewCountCommandHandler : IRequestHandler<IncrementViewCountCommand, Unit>
|
||||||
|
{
|
||||||
|
private readonly IApplicationDbContext _context;
|
||||||
|
|
||||||
|
public IncrementViewCountCommandHandler(IApplicationDbContext context)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Unit> Handle(IncrementViewCountCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var post = await _context.BlogPosts.FirstOrDefaultAsync(x => x.Id == request.Id && !x.IsDeleted, cancellationToken);
|
||||||
|
if (post != null)
|
||||||
|
{
|
||||||
|
post.ViewCount++;
|
||||||
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Unit.Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace CMSMicroservice.Application.BlogPostCQ.Commands.PublishBlogPost;
|
||||||
|
|
||||||
|
public class PublishBlogPostCommand : IRequest<PublishBlogPostResult>
|
||||||
|
{
|
||||||
|
public long Id { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PublishBlogPostResult
|
||||||
|
{
|
||||||
|
public bool Success { get; set; }
|
||||||
|
public string Message { get; set; } = string.Empty;
|
||||||
|
public DateTime? PublishedAt { get; set; }
|
||||||
|
}
|
||||||
+41
@@ -0,0 +1,41 @@
|
|||||||
|
using CMSMicroservice.Application.Common.Interfaces;
|
||||||
|
using CMSMicroservice.Domain.Enums;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace CMSMicroservice.Application.BlogPostCQ.Commands.PublishBlogPost;
|
||||||
|
|
||||||
|
public class PublishBlogPostCommandHandler : IRequestHandler<PublishBlogPostCommand, PublishBlogPostResult>
|
||||||
|
{
|
||||||
|
private readonly IApplicationDbContext _context;
|
||||||
|
private readonly ILogger<PublishBlogPostCommandHandler> _logger;
|
||||||
|
|
||||||
|
public PublishBlogPostCommandHandler(IApplicationDbContext context, ILogger<PublishBlogPostCommandHandler> logger)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PublishBlogPostResult> Handle(PublishBlogPostCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var post = await _context.BlogPosts.FirstOrDefaultAsync(x => x.Id == request.Id && !x.IsDeleted, cancellationToken)
|
||||||
|
?? throw new KeyNotFoundException($"مقاله با شناسه {request.Id} یافت نشد");
|
||||||
|
|
||||||
|
if (post.Status == BlogPostStatus.Published)
|
||||||
|
return new PublishBlogPostResult { Success = false, Message = "مقاله قبلاً منتشر شده است" };
|
||||||
|
|
||||||
|
post.Status = BlogPostStatus.Published;
|
||||||
|
post.PublishedAt = DateTime.UtcNow;
|
||||||
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
_logger.LogInformation("Blog post published. Id: {Id}, Title: {Title}", post.Id, post.Title);
|
||||||
|
|
||||||
|
return new PublishBlogPostResult
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
Message = "مقاله با موفقیت منتشر شد",
|
||||||
|
PublishedAt = post.PublishedAt
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace CMSMicroservice.Application.BlogPostCQ.Commands.UpdateBlogPost;
|
||||||
|
|
||||||
|
public class UpdateBlogPostCommand : IRequest<Unit>
|
||||||
|
{
|
||||||
|
public long Id { get; set; }
|
||||||
|
public string Title { get; set; } = string.Empty;
|
||||||
|
public string Slug { get; set; } = string.Empty;
|
||||||
|
public string? Summary { get; set; }
|
||||||
|
public string HtmlContent { get; set; } = string.Empty;
|
||||||
|
public string? FeaturedImagePath { get; set; }
|
||||||
|
public string? FeaturedImageThumbnailPath { get; set; }
|
||||||
|
public List<long> CategoryIds { get; set; } = new();
|
||||||
|
public List<long> TagIds { get; set; } = new();
|
||||||
|
public bool IsFeatured { get; set; }
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
|
||||||
|
// Image upload properties
|
||||||
|
public byte[]? ImageFileBytes { get; set; }
|
||||||
|
public string? ImageFileMime { get; set; }
|
||||||
|
public string? ImageFileName { get; set; }
|
||||||
|
}
|
||||||
+88
@@ -0,0 +1,88 @@
|
|||||||
|
using CMSMicroservice.Application.Common.FileManager;
|
||||||
|
using CMSMicroservice.Application.Common.Interfaces;
|
||||||
|
using CMSMicroservice.Domain.Entities.Blog;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace CMSMicroservice.Application.BlogPostCQ.Commands.UpdateBlogPost;
|
||||||
|
|
||||||
|
public class UpdateBlogPostCommandHandler : IRequestHandler<UpdateBlogPostCommand, Unit>
|
||||||
|
{
|
||||||
|
private readonly IApplicationDbContext _context;
|
||||||
|
private readonly IFileManager _fileManager;
|
||||||
|
private readonly ILogger<UpdateBlogPostCommandHandler> _logger;
|
||||||
|
|
||||||
|
public UpdateBlogPostCommandHandler(
|
||||||
|
IApplicationDbContext context,
|
||||||
|
IFileManager fileManager,
|
||||||
|
ILogger<UpdateBlogPostCommandHandler> logger)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
_fileManager = fileManager;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Unit> Handle(UpdateBlogPostCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var post = await _context.BlogPosts.FirstOrDefaultAsync(x => x.Id == request.Id && !x.IsDeleted, cancellationToken)
|
||||||
|
?? throw new KeyNotFoundException($"مقاله با شناسه {request.Id} یافت نشد");
|
||||||
|
|
||||||
|
post.Title = request.Title.Trim();
|
||||||
|
// حفظ slug قبلی اگر مقدار جدید خالی باشد
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.Slug))
|
||||||
|
post.Slug = request.Slug.Trim().ToLower();
|
||||||
|
post.Summary = request.Summary?.Trim();
|
||||||
|
post.HtmlContent = request.HtmlContent;
|
||||||
|
post.FeaturedImagePath = request.FeaturedImagePath;
|
||||||
|
post.FeaturedImageThumbnailPath = request.FeaturedImageThumbnailPath;
|
||||||
|
post.IsFeatured = request.IsFeatured;
|
||||||
|
post.SortOrder = request.SortOrder;
|
||||||
|
|
||||||
|
// آپلود تصویر شاخص (اگر فایل جدید ارسال شده باشد)
|
||||||
|
if (request.ImageFileBytes is { Length: > 0 })
|
||||||
|
{
|
||||||
|
var result = await _fileManager.UploadImageAsync(
|
||||||
|
"Images/BlogPosts",
|
||||||
|
request.ImageFileBytes,
|
||||||
|
request.ImageFileMime ?? "image/jpeg",
|
||||||
|
request.ImageFileName,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
post.FeaturedImagePath = result.Main.Path;
|
||||||
|
post.FeaturedImageThumbnailPath = result.Thumbnail.Path;
|
||||||
|
}
|
||||||
|
|
||||||
|
// بروزرسانی دستهبندیها
|
||||||
|
var existingCategories = await _context.BlogPostCategories
|
||||||
|
.Where(x => x.BlogPostId == post.Id).ToListAsync(cancellationToken);
|
||||||
|
_context.BlogPostCategories.RemoveRange(existingCategories);
|
||||||
|
foreach (var categoryId in request.CategoryIds)
|
||||||
|
{
|
||||||
|
_context.BlogPostCategories.Add(new BlogPostCategory
|
||||||
|
{
|
||||||
|
BlogPostId = post.Id,
|
||||||
|
BlogCategoryId = categoryId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// بروزرسانی تگها
|
||||||
|
var existingTags = await _context.BlogPostTags
|
||||||
|
.Where(x => x.BlogPostId == post.Id).ToListAsync(cancellationToken);
|
||||||
|
_context.BlogPostTags.RemoveRange(existingTags);
|
||||||
|
foreach (var tagId in request.TagIds)
|
||||||
|
{
|
||||||
|
_context.BlogPostTags.Add(new BlogPostTag
|
||||||
|
{
|
||||||
|
BlogPostId = post.Id,
|
||||||
|
TagId = tagId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
_logger.LogInformation("Blog post updated. Id: {Id}, Title: {Title}", post.Id, post.Title);
|
||||||
|
|
||||||
|
return Unit.Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
namespace CMSMicroservice.Application.BlogPostCQ.Commands.UpdateBlogPost;
|
||||||
|
|
||||||
|
public class UpdateBlogPostCommandValidator : AbstractValidator<UpdateBlogPostCommand>
|
||||||
|
{
|
||||||
|
public UpdateBlogPostCommandValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Id).GreaterThan(0).WithMessage("شناسه مقاله نامعتبر است");
|
||||||
|
|
||||||
|
RuleFor(x => x.Title)
|
||||||
|
.NotEmpty().WithMessage("عنوان مقاله الزامی است")
|
||||||
|
.MaximumLength(200).WithMessage("عنوان نمیتواند بیشتر از 200 کاراکتر باشد");
|
||||||
|
|
||||||
|
RuleFor(x => x.Slug)
|
||||||
|
.NotEmpty().WithMessage("نشانی یکتا (slug) الزامی است")
|
||||||
|
.MaximumLength(200).WithMessage("نشانی نمیتواند بیشتر از 200 کاراکتر باشد")
|
||||||
|
.Matches(@"^[a-z0-9\-]+$").WithMessage("نشانی فقط میتواند شامل حروف کوچک انگلیسی، اعداد و خط تیره باشد");
|
||||||
|
|
||||||
|
RuleFor(x => x.HtmlContent)
|
||||||
|
.NotEmpty().WithMessage("محتوای مقاله الزامی است");
|
||||||
|
}
|
||||||
|
}
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
using CMSMicroservice.Application.Common.Models;
|
||||||
|
using CMSMicroservice.Domain.Enums;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace CMSMicroservice.Application.BlogPostCQ.Queries.GetAllBlogPosts;
|
||||||
|
|
||||||
|
public class GetAllBlogPostsQuery : IRequest<GetAllBlogPostsResponseDto>
|
||||||
|
{
|
||||||
|
public int PageNumber { get; set; } = 1;
|
||||||
|
public int PageSize { get; set; } = 10;
|
||||||
|
public string? SortBy { get; set; }
|
||||||
|
public string? SearchTerm { get; set; }
|
||||||
|
public BlogPostStatus? Status { get; set; }
|
||||||
|
public long? CategoryId { get; set; }
|
||||||
|
public bool? IsFeatured { get; set; }
|
||||||
|
}
|
||||||
+85
@@ -0,0 +1,85 @@
|
|||||||
|
using CMSMicroservice.Application.BlogPostCQ.Queries.GetBlogPost;
|
||||||
|
using CMSMicroservice.Application.Common.Interfaces;
|
||||||
|
using CMSMicroservice.Application.Common.Models;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace CMSMicroservice.Application.BlogPostCQ.Queries.GetAllBlogPosts;
|
||||||
|
|
||||||
|
public class GetAllBlogPostsQueryHandler : IRequestHandler<GetAllBlogPostsQuery, GetAllBlogPostsResponseDto>
|
||||||
|
{
|
||||||
|
private readonly IApplicationDbContext _context;
|
||||||
|
|
||||||
|
public GetAllBlogPostsQueryHandler(IApplicationDbContext context)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<GetAllBlogPostsResponseDto> Handle(GetAllBlogPostsQuery request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var query = _context.BlogPosts
|
||||||
|
.Include(x => x.BlogPostCategories).ThenInclude(x => x.BlogCategory)
|
||||||
|
.Where(x => !x.IsDeleted);
|
||||||
|
|
||||||
|
// فیلترها
|
||||||
|
if (request.Status.HasValue)
|
||||||
|
query = query.Where(x => x.Status == request.Status.Value);
|
||||||
|
if (request.CategoryId.HasValue)
|
||||||
|
query = query.Where(x => x.BlogPostCategories.Any(c => c.BlogCategoryId == request.CategoryId.Value));
|
||||||
|
if (request.IsFeatured.HasValue)
|
||||||
|
query = query.Where(x => x.IsFeatured == request.IsFeatured.Value);
|
||||||
|
if (!string.IsNullOrEmpty(request.SearchTerm))
|
||||||
|
{
|
||||||
|
var term = request.SearchTerm.ToLower();
|
||||||
|
query = query.Where(x => x.Title.ToLower().Contains(term) || (x.Summary != null && x.Summary.ToLower().Contains(term)));
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalCount = await query.CountAsync(cancellationToken);
|
||||||
|
|
||||||
|
// مرتبسازی
|
||||||
|
query = request.SortBy?.ToLower() switch
|
||||||
|
{
|
||||||
|
"title" => query.OrderBy(x => x.Title),
|
||||||
|
"viewcount" => query.OrderByDescending(x => x.ViewCount),
|
||||||
|
"publishedat" => query.OrderByDescending(x => x.PublishedAt),
|
||||||
|
_ => query.OrderByDescending(x => x.Created)
|
||||||
|
};
|
||||||
|
|
||||||
|
var posts = await query
|
||||||
|
.Skip((request.PageNumber - 1) * request.PageSize)
|
||||||
|
.Take(request.PageSize)
|
||||||
|
.Select(x => new BlogPostListItemDto
|
||||||
|
{
|
||||||
|
Id = x.Id,
|
||||||
|
Title = x.Title,
|
||||||
|
Slug = x.Slug,
|
||||||
|
Summary = x.Summary,
|
||||||
|
FeaturedImageThumbnailPath = x.FeaturedImageThumbnailPath,
|
||||||
|
Status = (int)x.Status,
|
||||||
|
StatusName = GetBlogPostQueryHandler.GetStatusName(x.Status),
|
||||||
|
PublishedAt = x.PublishedAt,
|
||||||
|
ViewCount = x.ViewCount,
|
||||||
|
IsFeatured = x.IsFeatured,
|
||||||
|
Created = x.Created,
|
||||||
|
Categories = x.BlogPostCategories.Select(c => new BlogPostCategoryDto
|
||||||
|
{
|
||||||
|
Id = c.BlogCategory.Id,
|
||||||
|
Title = c.BlogCategory.Title,
|
||||||
|
Slug = c.BlogCategory.Slug
|
||||||
|
}).ToList()
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var metaData = new MetaData
|
||||||
|
{
|
||||||
|
TotalCount = totalCount,
|
||||||
|
PageSize = request.PageSize,
|
||||||
|
CurrentPage = request.PageNumber,
|
||||||
|
TotalPage = (int)Math.Ceiling(totalCount / (double)request.PageSize),
|
||||||
|
HasNext = request.PageNumber < (int)Math.Ceiling(totalCount / (double)request.PageSize),
|
||||||
|
HasPrevious = request.PageNumber > 1
|
||||||
|
};
|
||||||
|
|
||||||
|
return new GetAllBlogPostsResponseDto { MetaData = metaData, Models = posts };
|
||||||
|
}
|
||||||
|
}
|
||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
using CMSMicroservice.Application.Common.Models;
|
||||||
|
using CMSMicroservice.Application.BlogPostCQ.Queries.GetBlogPost;
|
||||||
|
|
||||||
|
namespace CMSMicroservice.Application.BlogPostCQ.Queries.GetAllBlogPosts;
|
||||||
|
|
||||||
|
public class GetAllBlogPostsResponseDto
|
||||||
|
{
|
||||||
|
public MetaData MetaData { get; set; } = new();
|
||||||
|
public List<BlogPostListItemDto> Models { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class BlogPostListItemDto
|
||||||
|
{
|
||||||
|
public long Id { get; set; }
|
||||||
|
public string Title { get; set; } = string.Empty;
|
||||||
|
public string Slug { get; set; } = string.Empty;
|
||||||
|
public string? Summary { get; set; }
|
||||||
|
public string? FeaturedImageThumbnailPath { get; set; }
|
||||||
|
public int Status { get; set; }
|
||||||
|
public string StatusName { get; set; } = string.Empty;
|
||||||
|
public DateTime? PublishedAt { get; set; }
|
||||||
|
public int ViewCount { get; set; }
|
||||||
|
public bool IsFeatured { get; set; }
|
||||||
|
public DateTime Created { get; set; }
|
||||||
|
public List<BlogPostCategoryDto> Categories { get; set; } = new();
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
using CMSMicroservice.Domain.Enums;
|
||||||
|
|
||||||
|
namespace CMSMicroservice.Application.BlogPostCQ.Queries.GetBlogPost;
|
||||||
|
|
||||||
|
public class BlogPostDto
|
||||||
|
{
|
||||||
|
public long Id { get; set; }
|
||||||
|
public string Title { get; set; } = string.Empty;
|
||||||
|
public string Slug { get; set; } = string.Empty;
|
||||||
|
public string? Summary { get; set; }
|
||||||
|
public string HtmlContent { get; set; } = string.Empty;
|
||||||
|
public string? FeaturedImagePath { get; set; }
|
||||||
|
public string? FeaturedImageThumbnailPath { get; set; }
|
||||||
|
public BlogPostStatus Status { get; set; }
|
||||||
|
public string StatusName { get; set; } = string.Empty;
|
||||||
|
public DateTime? PublishedAt { get; set; }
|
||||||
|
public int ViewCount { get; set; }
|
||||||
|
public long AuthorUserId { get; set; }
|
||||||
|
public bool IsFeatured { get; set; }
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
public DateTime Created { get; set; }
|
||||||
|
public DateTime? LastModified { get; set; }
|
||||||
|
public List<BlogPostCategoryDto> Categories { get; set; } = new();
|
||||||
|
public List<BlogPostTagDto> Tags { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class BlogPostCategoryDto
|
||||||
|
{
|
||||||
|
public long Id { get; set; }
|
||||||
|
public string Title { get; set; } = string.Empty;
|
||||||
|
public string Slug { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class BlogPostTagDto
|
||||||
|
{
|
||||||
|
public long Id { get; set; }
|
||||||
|
public string Title { get; set; } = string.Empty;
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace CMSMicroservice.Application.BlogPostCQ.Queries.GetBlogPost;
|
||||||
|
|
||||||
|
public class GetBlogPostQuery : IRequest<BlogPostDto>
|
||||||
|
{
|
||||||
|
public long Id { get; set; }
|
||||||
|
}
|
||||||
+66
@@ -0,0 +1,66 @@
|
|||||||
|
using CMSMicroservice.Application.Common.Interfaces;
|
||||||
|
using CMSMicroservice.Domain.Enums;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace CMSMicroservice.Application.BlogPostCQ.Queries.GetBlogPost;
|
||||||
|
|
||||||
|
public class GetBlogPostQueryHandler : IRequestHandler<GetBlogPostQuery, BlogPostDto>
|
||||||
|
{
|
||||||
|
private readonly IApplicationDbContext _context;
|
||||||
|
|
||||||
|
public GetBlogPostQueryHandler(IApplicationDbContext context)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<BlogPostDto> Handle(GetBlogPostQuery request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var post = await _context.BlogPosts
|
||||||
|
.Include(x => x.BlogPostCategories).ThenInclude(x => x.BlogCategory)
|
||||||
|
.Include(x => x.BlogPostTags).ThenInclude(x => x.Tag)
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == request.Id && !x.IsDeleted, cancellationToken)
|
||||||
|
?? throw new KeyNotFoundException($"مقاله با شناسه {request.Id} یافت نشد");
|
||||||
|
|
||||||
|
return new BlogPostDto
|
||||||
|
{
|
||||||
|
Id = post.Id,
|
||||||
|
Title = post.Title,
|
||||||
|
Slug = post.Slug,
|
||||||
|
Summary = post.Summary,
|
||||||
|
HtmlContent = post.HtmlContent,
|
||||||
|
FeaturedImagePath = post.FeaturedImagePath,
|
||||||
|
FeaturedImageThumbnailPath = post.FeaturedImageThumbnailPath,
|
||||||
|
Status = post.Status,
|
||||||
|
StatusName = GetStatusName(post.Status),
|
||||||
|
PublishedAt = post.PublishedAt,
|
||||||
|
ViewCount = post.ViewCount,
|
||||||
|
AuthorUserId = post.AuthorUserId,
|
||||||
|
IsFeatured = post.IsFeatured,
|
||||||
|
SortOrder = post.SortOrder,
|
||||||
|
Created = post.Created,
|
||||||
|
LastModified = post.LastModified,
|
||||||
|
Categories = post.BlogPostCategories.Select(c => new BlogPostCategoryDto
|
||||||
|
{
|
||||||
|
Id = c.BlogCategory.Id,
|
||||||
|
Title = c.BlogCategory.Title,
|
||||||
|
Slug = c.BlogCategory.Slug
|
||||||
|
}).ToList(),
|
||||||
|
Tags = post.BlogPostTags.Select(t => new BlogPostTagDto
|
||||||
|
{
|
||||||
|
Id = t.Tag.Id,
|
||||||
|
Title = t.Tag.Title,
|
||||||
|
Name = t.Tag.Name
|
||||||
|
}).ToList()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string GetStatusName(BlogPostStatus status) => status switch
|
||||||
|
{
|
||||||
|
BlogPostStatus.Draft => "پیشنویس",
|
||||||
|
BlogPostStatus.Published => "منتشرشده",
|
||||||
|
BlogPostStatus.Scheduled => "زمانبندیشده",
|
||||||
|
BlogPostStatus.Archived => "آرشیو",
|
||||||
|
_ => "نامشخص"
|
||||||
|
};
|
||||||
|
}
|
||||||
+8
@@ -0,0 +1,8 @@
|
|||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace CMSMicroservice.Application.BlogPostCQ.Queries.GetBlogPostBySlug;
|
||||||
|
|
||||||
|
public class GetBlogPostBySlugQuery : IRequest<GetBlogPost.BlogPostDto>
|
||||||
|
{
|
||||||
|
public string Slug { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
+58
@@ -0,0 +1,58 @@
|
|||||||
|
using CMSMicroservice.Application.BlogPostCQ.Queries.GetBlogPost;
|
||||||
|
using CMSMicroservice.Application.Common.Interfaces;
|
||||||
|
using CMSMicroservice.Domain.Enums;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace CMSMicroservice.Application.BlogPostCQ.Queries.GetBlogPostBySlug;
|
||||||
|
|
||||||
|
public class GetBlogPostBySlugQueryHandler : IRequestHandler<GetBlogPostBySlugQuery, BlogPostDto>
|
||||||
|
{
|
||||||
|
private readonly IApplicationDbContext _context;
|
||||||
|
|
||||||
|
public GetBlogPostBySlugQueryHandler(IApplicationDbContext context)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<BlogPostDto> Handle(GetBlogPostBySlugQuery request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var post = await _context.BlogPosts
|
||||||
|
.Include(x => x.BlogPostCategories).ThenInclude(x => x.BlogCategory)
|
||||||
|
.Include(x => x.BlogPostTags).ThenInclude(x => x.Tag)
|
||||||
|
.FirstOrDefaultAsync(x => x.Slug == request.Slug && !x.IsDeleted, cancellationToken)
|
||||||
|
?? throw new KeyNotFoundException($"مقاله با نشانی '{request.Slug}' یافت نشد");
|
||||||
|
|
||||||
|
return new BlogPostDto
|
||||||
|
{
|
||||||
|
Id = post.Id,
|
||||||
|
Title = post.Title,
|
||||||
|
Slug = post.Slug,
|
||||||
|
Summary = post.Summary,
|
||||||
|
HtmlContent = post.HtmlContent,
|
||||||
|
FeaturedImagePath = post.FeaturedImagePath,
|
||||||
|
FeaturedImageThumbnailPath = post.FeaturedImageThumbnailPath,
|
||||||
|
Status = post.Status,
|
||||||
|
StatusName = GetBlogPostQueryHandler.GetStatusName(post.Status),
|
||||||
|
PublishedAt = post.PublishedAt,
|
||||||
|
ViewCount = post.ViewCount,
|
||||||
|
AuthorUserId = post.AuthorUserId,
|
||||||
|
IsFeatured = post.IsFeatured,
|
||||||
|
SortOrder = post.SortOrder,
|
||||||
|
Created = post.Created,
|
||||||
|
LastModified = post.LastModified,
|
||||||
|
Categories = post.BlogPostCategories.Select(c => new BlogPostCategoryDto
|
||||||
|
{
|
||||||
|
Id = c.BlogCategory.Id,
|
||||||
|
Title = c.BlogCategory.Title,
|
||||||
|
Slug = c.BlogCategory.Slug
|
||||||
|
}).ToList(),
|
||||||
|
Tags = post.BlogPostTags.Select(t => new BlogPostTagDto
|
||||||
|
{
|
||||||
|
Id = t.Tag.Id,
|
||||||
|
Title = t.Tag.Title,
|
||||||
|
Name = t.Tag.Name
|
||||||
|
}).ToList()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+55
@@ -0,0 +1,55 @@
|
|||||||
|
using CMSMicroservice.Application.BlogPostCQ.Queries.GetBlogPost;
|
||||||
|
using CMSMicroservice.Application.Common.Interfaces;
|
||||||
|
using CMSMicroservice.Domain.Enums;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace CMSMicroservice.Application.BlogPostCQ.Queries.GetFeaturedBlogPosts;
|
||||||
|
|
||||||
|
public class GetFeaturedBlogPostsQuery : IRequest<List<GetAllBlogPosts.BlogPostListItemDto>>
|
||||||
|
{
|
||||||
|
public int Count { get; set; } = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class GetFeaturedBlogPostsQueryHandler : IRequestHandler<GetFeaturedBlogPostsQuery, List<GetAllBlogPosts.BlogPostListItemDto>>
|
||||||
|
{
|
||||||
|
private readonly IApplicationDbContext _context;
|
||||||
|
|
||||||
|
public GetFeaturedBlogPostsQueryHandler(IApplicationDbContext context)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<GetAllBlogPosts.BlogPostListItemDto>> Handle(GetFeaturedBlogPostsQuery request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var posts = await _context.BlogPosts
|
||||||
|
.Include(x => x.BlogPostCategories).ThenInclude(x => x.BlogCategory)
|
||||||
|
.Where(x => !x.IsDeleted && x.Status == BlogPostStatus.Published && x.IsFeatured)
|
||||||
|
.OrderBy(x => x.SortOrder)
|
||||||
|
.ThenByDescending(x => x.PublishedAt)
|
||||||
|
.Take(request.Count)
|
||||||
|
.Select(x => new GetAllBlogPosts.BlogPostListItemDto
|
||||||
|
{
|
||||||
|
Id = x.Id,
|
||||||
|
Title = x.Title,
|
||||||
|
Slug = x.Slug,
|
||||||
|
Summary = x.Summary,
|
||||||
|
FeaturedImageThumbnailPath = x.FeaturedImageThumbnailPath,
|
||||||
|
Status = (int)x.Status,
|
||||||
|
StatusName = GetBlogPostQueryHandler.GetStatusName(x.Status),
|
||||||
|
PublishedAt = x.PublishedAt,
|
||||||
|
ViewCount = x.ViewCount,
|
||||||
|
IsFeatured = x.IsFeatured,
|
||||||
|
Created = x.Created,
|
||||||
|
Categories = x.BlogPostCategories.Select(c => new BlogPostCategoryDto
|
||||||
|
{
|
||||||
|
Id = c.BlogCategory.Id,
|
||||||
|
Title = c.BlogCategory.Title,
|
||||||
|
Slug = c.BlogCategory.Slug
|
||||||
|
}).ToList()
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return posts;
|
||||||
|
}
|
||||||
|
}
|
||||||
+81
@@ -0,0 +1,81 @@
|
|||||||
|
using CMSMicroservice.Application.BlogPostCQ.Queries.GetBlogPost;
|
||||||
|
using CMSMicroservice.Application.Common.Interfaces;
|
||||||
|
using CMSMicroservice.Application.Common.Models;
|
||||||
|
using CMSMicroservice.Domain.Enums;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace CMSMicroservice.Application.BlogPostCQ.Queries.GetPublishedBlogPosts;
|
||||||
|
|
||||||
|
public class GetPublishedBlogPostsQuery : IRequest<GetAllBlogPosts.GetAllBlogPostsResponseDto>
|
||||||
|
{
|
||||||
|
public int PageNumber { get; set; } = 1;
|
||||||
|
public int PageSize { get; set; } = 10;
|
||||||
|
public string? SearchTerm { get; set; }
|
||||||
|
public long? CategoryId { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class GetPublishedBlogPostsQueryHandler : IRequestHandler<GetPublishedBlogPostsQuery, GetAllBlogPosts.GetAllBlogPostsResponseDto>
|
||||||
|
{
|
||||||
|
private readonly IApplicationDbContext _context;
|
||||||
|
|
||||||
|
public GetPublishedBlogPostsQueryHandler(IApplicationDbContext context)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<GetAllBlogPosts.GetAllBlogPostsResponseDto> Handle(GetPublishedBlogPostsQuery request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var query = _context.BlogPosts
|
||||||
|
.Include(x => x.BlogPostCategories).ThenInclude(x => x.BlogCategory)
|
||||||
|
.Where(x => !x.IsDeleted && x.Status == BlogPostStatus.Published);
|
||||||
|
|
||||||
|
if (request.CategoryId.HasValue)
|
||||||
|
query = query.Where(x => x.BlogPostCategories.Any(c => c.BlogCategoryId == request.CategoryId.Value));
|
||||||
|
if (!string.IsNullOrEmpty(request.SearchTerm))
|
||||||
|
{
|
||||||
|
var term = request.SearchTerm.ToLower();
|
||||||
|
query = query.Where(x => x.Title.ToLower().Contains(term) || (x.Summary != null && x.Summary.ToLower().Contains(term)));
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalCount = await query.CountAsync(cancellationToken);
|
||||||
|
|
||||||
|
var posts = await query
|
||||||
|
.OrderByDescending(x => x.PublishedAt)
|
||||||
|
.Skip((request.PageNumber - 1) * request.PageSize)
|
||||||
|
.Take(request.PageSize)
|
||||||
|
.Select(x => new GetAllBlogPosts.BlogPostListItemDto
|
||||||
|
{
|
||||||
|
Id = x.Id,
|
||||||
|
Title = x.Title,
|
||||||
|
Slug = x.Slug,
|
||||||
|
Summary = x.Summary,
|
||||||
|
FeaturedImageThumbnailPath = x.FeaturedImageThumbnailPath,
|
||||||
|
Status = (int)x.Status,
|
||||||
|
StatusName = GetBlogPostQueryHandler.GetStatusName(x.Status),
|
||||||
|
PublishedAt = x.PublishedAt,
|
||||||
|
ViewCount = x.ViewCount,
|
||||||
|
IsFeatured = x.IsFeatured,
|
||||||
|
Created = x.Created,
|
||||||
|
Categories = x.BlogPostCategories.Select(c => new BlogPostCategoryDto
|
||||||
|
{
|
||||||
|
Id = c.BlogCategory.Id,
|
||||||
|
Title = c.BlogCategory.Title,
|
||||||
|
Slug = c.BlogCategory.Slug
|
||||||
|
}).ToList()
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var metaData = new MetaData
|
||||||
|
{
|
||||||
|
TotalCount = totalCount,
|
||||||
|
PageSize = request.PageSize,
|
||||||
|
CurrentPage = request.PageNumber,
|
||||||
|
TotalPage = (int)Math.Ceiling(totalCount / (double)request.PageSize),
|
||||||
|
HasNext = request.PageNumber < (int)Math.Ceiling(totalCount / (double)request.PageSize),
|
||||||
|
HasPrevious = request.PageNumber > 1
|
||||||
|
};
|
||||||
|
|
||||||
|
return new GetAllBlogPosts.GetAllBlogPostsResponseDto { MetaData = metaData, Models = posts };
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace CMSMicroservice.Application.BlogPostImageCQ.Commands.AddBlogPostImage;
|
||||||
|
|
||||||
|
public class AddBlogPostImageCommand : IRequest<long>
|
||||||
|
{
|
||||||
|
public long BlogPostId { get; set; }
|
||||||
|
public string ImagePath { get; set; } = default!;
|
||||||
|
public string ThumbnailPath { get; set; } = default!;
|
||||||
|
public string? AltText { get; set; }
|
||||||
|
public string? Caption { get; set; }
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
}
|
||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
using CMSMicroservice.Application.Common.Exceptions;
|
||||||
|
using CMSMicroservice.Application.Common.Interfaces;
|
||||||
|
using CMSMicroservice.Domain.Entities.Blog;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace CMSMicroservice.Application.BlogPostImageCQ.Commands.AddBlogPostImage;
|
||||||
|
|
||||||
|
public class AddBlogPostImageCommandHandler : IRequestHandler<AddBlogPostImageCommand, long>
|
||||||
|
{
|
||||||
|
private readonly IApplicationDbContext _context;
|
||||||
|
|
||||||
|
public AddBlogPostImageCommandHandler(IApplicationDbContext context)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<long> Handle(AddBlogPostImageCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var blogPost = await _context.BlogPosts.FirstOrDefaultAsync(x => x.Id == request.BlogPostId && !x.IsDeleted, cancellationToken);
|
||||||
|
if (blogPost == null)
|
||||||
|
throw new NotFoundException(nameof(BlogPost), request.BlogPostId);
|
||||||
|
|
||||||
|
var entity = new BlogPostImage
|
||||||
|
{
|
||||||
|
BlogPostId = request.BlogPostId,
|
||||||
|
ImagePath = request.ImagePath,
|
||||||
|
ThumbnailPath = request.ThumbnailPath,
|
||||||
|
AltText = request.AltText,
|
||||||
|
Caption = request.Caption,
|
||||||
|
SortOrder = request.SortOrder
|
||||||
|
};
|
||||||
|
|
||||||
|
_context.BlogPostImages.Add(entity);
|
||||||
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
return entity.Id;
|
||||||
|
}
|
||||||
|
}
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
namespace CMSMicroservice.Application.BlogPostImageCQ.Commands.AddBlogPostImage;
|
||||||
|
|
||||||
|
public class AddBlogPostImageCommandValidator : AbstractValidator<AddBlogPostImageCommand>
|
||||||
|
{
|
||||||
|
public AddBlogPostImageCommandValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.BlogPostId)
|
||||||
|
.GreaterThan(0).WithMessage("شناسه پست نامعتبر است");
|
||||||
|
|
||||||
|
RuleFor(x => x.ImagePath)
|
||||||
|
.NotEmpty().WithMessage("مسیر تصویر الزامی است");
|
||||||
|
|
||||||
|
RuleFor(x => x.AltText)
|
||||||
|
.MaximumLength(500).WithMessage("متن جایگزین حداکثر ۵۰۰ کاراکتر");
|
||||||
|
|
||||||
|
RuleFor(x => x.Caption)
|
||||||
|
.MaximumLength(1000).WithMessage("عنوان تصویر حداکثر ۱۰۰۰ کاراکتر");
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
@@ -0,0 +1,8 @@
|
|||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace CMSMicroservice.Application.BlogPostImageCQ.Commands.DeleteBlogPostImage;
|
||||||
|
|
||||||
|
public class DeleteBlogPostImageCommand : IRequest<Unit>
|
||||||
|
{
|
||||||
|
public long Id { get; set; }
|
||||||
|
}
|
||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
using CMSMicroservice.Application.Common.Exceptions;
|
||||||
|
using CMSMicroservice.Application.Common.Interfaces;
|
||||||
|
using CMSMicroservice.Domain.Entities.Blog;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace CMSMicroservice.Application.BlogPostImageCQ.Commands.DeleteBlogPostImage;
|
||||||
|
|
||||||
|
public class DeleteBlogPostImageCommandHandler : IRequestHandler<DeleteBlogPostImageCommand, Unit>
|
||||||
|
{
|
||||||
|
private readonly IApplicationDbContext _context;
|
||||||
|
|
||||||
|
public DeleteBlogPostImageCommandHandler(IApplicationDbContext context)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Unit> Handle(DeleteBlogPostImageCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var entity = await _context.BlogPostImages.FindAsync(new object[] { request.Id }, cancellationToken);
|
||||||
|
if (entity == null || entity.IsDeleted)
|
||||||
|
throw new NotFoundException(nameof(BlogPostImage), request.Id);
|
||||||
|
|
||||||
|
entity.IsDeleted = true;
|
||||||
|
|
||||||
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
return Unit.Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace CMSMicroservice.Application.BlogPostImageCQ.Commands.ReorderBlogPostImages;
|
||||||
|
|
||||||
|
public class ReorderBlogPostImagesCommand : IRequest<Unit>
|
||||||
|
{
|
||||||
|
public List<ImageSortItem> Items { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ImageSortItem
|
||||||
|
{
|
||||||
|
public long Id { get; set; }
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
}
|
||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
using CMSMicroservice.Application.Common.Interfaces;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace CMSMicroservice.Application.BlogPostImageCQ.Commands.ReorderBlogPostImages;
|
||||||
|
|
||||||
|
public class ReorderBlogPostImagesCommandHandler : IRequestHandler<ReorderBlogPostImagesCommand, Unit>
|
||||||
|
{
|
||||||
|
private readonly IApplicationDbContext _context;
|
||||||
|
|
||||||
|
public ReorderBlogPostImagesCommandHandler(IApplicationDbContext context)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Unit> Handle(ReorderBlogPostImagesCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var ids = request.Items.Select(x => x.Id).ToList();
|
||||||
|
var images = await _context.BlogPostImages
|
||||||
|
.Where(x => ids.Contains(x.Id) && !x.IsDeleted)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
foreach (var item in request.Items)
|
||||||
|
{
|
||||||
|
var image = images.FirstOrDefault(x => x.Id == item.Id);
|
||||||
|
if (image != null)
|
||||||
|
image.SortOrder = item.SortOrder;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
return Unit.Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace CMSMicroservice.Application.BlogPostImageCQ.Queries.GetBlogPostImages;
|
||||||
|
|
||||||
|
public class GetBlogPostImagesQuery : IRequest<List<BlogPostImageDto>>
|
||||||
|
{
|
||||||
|
public long BlogPostId { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class BlogPostImageDto
|
||||||
|
{
|
||||||
|
public long Id { get; set; }
|
||||||
|
public long BlogPostId { get; set; }
|
||||||
|
public string ImagePath { get; set; } = default!;
|
||||||
|
public string? ThumbnailPath { get; set; }
|
||||||
|
public string? AltText { get; set; }
|
||||||
|
public string? Caption { get; set; }
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
}
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
using CMSMicroservice.Application.Common.Interfaces;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace CMSMicroservice.Application.BlogPostImageCQ.Queries.GetBlogPostImages;
|
||||||
|
|
||||||
|
public class GetBlogPostImagesQueryHandler : IRequestHandler<GetBlogPostImagesQuery, List<BlogPostImageDto>>
|
||||||
|
{
|
||||||
|
private readonly IApplicationDbContext _context;
|
||||||
|
|
||||||
|
public GetBlogPostImagesQueryHandler(IApplicationDbContext context)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<BlogPostImageDto>> Handle(GetBlogPostImagesQuery request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var images = await _context.BlogPostImages
|
||||||
|
.Where(x => x.BlogPostId == request.BlogPostId && !x.IsDeleted)
|
||||||
|
.OrderBy(x => x.SortOrder)
|
||||||
|
.Select(x => new BlogPostImageDto
|
||||||
|
{
|
||||||
|
Id = x.Id,
|
||||||
|
BlogPostId = x.BlogPostId,
|
||||||
|
ImagePath = x.ImagePath,
|
||||||
|
ThumbnailPath = x.ThumbnailPath,
|
||||||
|
AltText = x.AltText,
|
||||||
|
Caption = x.Caption,
|
||||||
|
SortOrder = x.SortOrder
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return images;
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
-1
@@ -31,7 +31,18 @@ public class GetAllCategoryByFilterQueryHandler : IRequestHandler<GetAllCategory
|
|||||||
{
|
{
|
||||||
MetaData = await query.GetMetaData(request.PaginationState, cancellationToken),
|
MetaData = await query.GetMetaData(request.PaginationState, cancellationToken),
|
||||||
Models = await query.PaginatedListAsync(paginationState: request.PaginationState)
|
Models = await query.PaginatedListAsync(paginationState: request.PaginationState)
|
||||||
.ProjectToType<GetAllCategoryByFilterResponseModel>().ToListAsync(cancellationToken)
|
.Select(x => new GetAllCategoryByFilterResponseModel
|
||||||
|
{
|
||||||
|
Id = x.Id,
|
||||||
|
Name = x.Name,
|
||||||
|
Title = x.Title,
|
||||||
|
Description = x.Description,
|
||||||
|
ImagePath = x.ImagePath,
|
||||||
|
ParentId = x.ParentId,
|
||||||
|
IsActive = x.IsActive,
|
||||||
|
SortOrder = x.SortOrder,
|
||||||
|
ProductCount = x.ProductCategories.Count
|
||||||
|
}).ToListAsync(cancellationToken)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
@@ -24,4 +24,6 @@ public class GetAllCategoryByFilterResponseDto
|
|||||||
public bool IsActive { get; set; }
|
public bool IsActive { get; set; }
|
||||||
//ترتیب نمایش
|
//ترتیب نمایش
|
||||||
public int SortOrder { get; set; }
|
public int SortOrder { get; set; }
|
||||||
|
//تعداد محصولات
|
||||||
|
public int ProductCount { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
+94
-40
@@ -53,20 +53,28 @@ public class AcceptClubMembershipContractCommandHandler
|
|||||||
AcceptClubMembershipContractCommand request,
|
AcceptClubMembershipContractCommand request,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
// خواندن UserId از JWT (امنتر از دریافت از کلاینت)
|
||||||
|
if (!long.TryParse(_currentUser.UserId, out var userId))
|
||||||
|
return new AcceptClubMembershipContractResponseDto
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
Message = "کاربر احراز هویت نشده است"
|
||||||
|
};
|
||||||
|
|
||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
"Processing club membership contract for UserId: {UserId}",
|
"Processing club membership contract for UserId: {UserId}",
|
||||||
request.UserId
|
userId
|
||||||
);
|
);
|
||||||
|
|
||||||
// 1. دریافت کاربر
|
// 1. دریافت کاربر
|
||||||
var user = await _context.Users
|
var user = await _context.Users
|
||||||
.Include(u => u.ClubMembership)
|
.Include(u => u.ClubMembership)
|
||||||
.FirstOrDefaultAsync(u => u.Id == request.UserId, cancellationToken);
|
.FirstOrDefaultAsync(u => u.Id == userId, cancellationToken);
|
||||||
|
|
||||||
if (user == null)
|
if (user == null)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("User not found: {UserId}", request.UserId);
|
_logger.LogWarning("User not found: {UserId}", userId);
|
||||||
throw new NotFoundException(nameof(User), request.UserId);
|
throw new NotFoundException(nameof(User), userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. بررسی خرید پکیج
|
// 2. بررسی خرید پکیج
|
||||||
@@ -127,13 +135,25 @@ public class AcceptClubMembershipContractCommandHandler
|
|||||||
};
|
};
|
||||||
await _context.UserContracts.AddAsync(userContract, cancellationToken);
|
await _context.UserContracts.AddAsync(userContract, cancellationToken);
|
||||||
|
|
||||||
// 6. دریافت مقادیر از SystemConstants (استاتیک)
|
// 6. بارگذاری پکیج از آخرین خرید کاربر یا پکیج پایه
|
||||||
long giftValue = SystemConstants.ClubMembershipGiftValue;
|
var latestPurchase = await _context.UserPackagePurchases
|
||||||
long activationFeeValue = SystemConstants.ClubActivationFee;
|
.Where(p => p.UserId == userId)
|
||||||
|
.OrderByDescending(p => p.PurchasedAt)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
|
var package = latestPurchase != null
|
||||||
|
? await _context.Packages.FirstOrDefaultAsync(p => p.Id == latestPurchase.PackageId && !p.IsDeleted, cancellationToken)
|
||||||
|
: await _context.Packages.FirstOrDefaultAsync(p => p.IsBasePackage && !p.IsDeleted, cancellationToken);
|
||||||
|
|
||||||
|
if (package == null)
|
||||||
|
throw new NotFoundException("پکیج یافت نشد");
|
||||||
|
|
||||||
|
long giftValue = package.ActivationFee;
|
||||||
|
long activationFeeValue = package.ActivationFee;
|
||||||
|
|
||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
"Using Club.MembershipGiftValue: {GiftValue}, Club.ActivationFee: {ActivationFee}",
|
"Using Package {PackageId} ({PackageName}): GiftValue={GiftValue}, ActivationFee={ActivationFee}",
|
||||||
giftValue, activationFeeValue
|
package.Id, package.Title, giftValue, activationFeeValue
|
||||||
);
|
);
|
||||||
|
|
||||||
// 7. فعالسازی باشگاه مشتریان
|
// 7. فعالسازی باشگاه مشتریان
|
||||||
@@ -148,6 +168,10 @@ public class AcceptClubMembershipContractCommandHandler
|
|||||||
UserId = user.Id,
|
UserId = user.Id,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
ActivatedAt = activationDate,
|
ActivatedAt = activationDate,
|
||||||
|
FirstActivationDate = activationDate,
|
||||||
|
FirstPackageId = package.Id,
|
||||||
|
LastActivationDate = activationDate,
|
||||||
|
LastPackageId = package.Id,
|
||||||
InitialContribution = activationFeeValue,
|
InitialContribution = activationFeeValue,
|
||||||
GiftValue = giftValue,
|
GiftValue = giftValue,
|
||||||
TotalEarned = 0,
|
TotalEarned = 0,
|
||||||
@@ -167,6 +191,8 @@ public class AcceptClubMembershipContractCommandHandler
|
|||||||
clubMembership = user.ClubMembership!;
|
clubMembership = user.ClubMembership!;
|
||||||
clubMembership.IsActive = true;
|
clubMembership.IsActive = true;
|
||||||
clubMembership.ActivatedAt = activationDate;
|
clubMembership.ActivatedAt = activationDate;
|
||||||
|
clubMembership.LastActivationDate = activationDate;
|
||||||
|
clubMembership.LastPackageId = package.Id;
|
||||||
clubMembership.PurchaseMethod = user.PackagePurchaseMethod;
|
clubMembership.PurchaseMethod = user.PackagePurchaseMethod;
|
||||||
_context.ClubMemberships.Update(clubMembership);
|
_context.ClubMemberships.Update(clubMembership);
|
||||||
|
|
||||||
@@ -198,13 +224,14 @@ public class AcceptClubMembershipContractCommandHandler
|
|||||||
// 9. اضافه کردن مبلغ به Pool هفته جاری
|
// 9. اضافه کردن مبلغ به Pool هفته جاری
|
||||||
var currentWeekDefinitionId = GetCurrentWeekDefinitionId();
|
var currentWeekDefinitionId = GetCurrentWeekDefinitionId();
|
||||||
var weeklyPool = await _context.WeeklyCommissionPools
|
var weeklyPool = await _context.WeeklyCommissionPools
|
||||||
.FirstOrDefaultAsync(p => p.WeekDefinitionId == currentWeekDefinitionId, cancellationToken);
|
.FirstOrDefaultAsync(p => p.WeekDefinitionId == currentWeekDefinitionId && p.PackageId == package.Id, cancellationToken);
|
||||||
|
|
||||||
if (weeklyPool == null)
|
if (weeklyPool == null)
|
||||||
{
|
{
|
||||||
weeklyPool = new WeeklyCommissionPool
|
weeklyPool = new WeeklyCommissionPool
|
||||||
{
|
{
|
||||||
WeekDefinitionId = currentWeekDefinitionId,
|
WeekDefinitionId = currentWeekDefinitionId,
|
||||||
|
PackageId = package.Id,
|
||||||
TotalPoolAmount = activationFeeValue,
|
TotalPoolAmount = activationFeeValue,
|
||||||
TotalBalances = 0,
|
TotalBalances = 0,
|
||||||
ValuePerBalance = 0,
|
ValuePerBalance = 0,
|
||||||
@@ -235,36 +262,8 @@ public class AcceptClubMembershipContractCommandHandler
|
|||||||
|
|
||||||
await _context.SaveChangesAsync(cancellationToken);
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
// 10. اعطای ویژگیهای باشگاه برای کاربر (فقط برای عضویت جدید)
|
// 10. اعمال ویژگیهای پکیج — DIFF/تفاضل (Q20)
|
||||||
if (isNewMembership)
|
await ApplyFeatureDiffAsync(user.Id, clubMembership.Id, package.Id, isNewMembership, activationDate, cancellationToken);
|
||||||
{
|
|
||||||
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(
|
_logger.LogInformation(
|
||||||
"Club membership contract accepted and activated for UserId: {UserId}, ContractId: {ContractId}, MembershipId: {MembershipId}",
|
"Club membership contract accepted and activated for UserId: {UserId}, ContractId: {ContractId}, MembershipId: {MembershipId}",
|
||||||
@@ -294,6 +293,61 @@ public class AcceptClubMembershipContractCommandHandler
|
|||||||
return week.Id;
|
return week.Id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task ApplyFeatureDiffAsync(
|
||||||
|
long userId, long membershipId, long packageId,
|
||||||
|
bool isNewMembership, DateTime activationDate,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var newFeatureIds = await _context.PackageFeatures
|
||||||
|
.Where(pf => pf.PackageId == packageId && pf.IsIncluded && !pf.IsDeleted)
|
||||||
|
.Select(pf => pf.ClubFeatureId)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (!newFeatureIds.Any())
|
||||||
|
{
|
||||||
|
_logger.LogWarning("No PackageFeatures for PackageId {PackageId}", packageId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentFeatures = await _context.UserClubFeatures
|
||||||
|
.Where(ucf => ucf.UserId == userId && ucf.IsActive)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var currentFeatureIds = currentFeatures.Select(f => f.ClubFeatureId).ToHashSet();
|
||||||
|
var toAdd = newFeatureIds.Where(id => !currentFeatureIds.Contains(id)).ToList();
|
||||||
|
var toRemove = currentFeatures.Where(f => !newFeatureIds.Contains(f.ClubFeatureId)).ToList();
|
||||||
|
|
||||||
|
foreach (var feature in toRemove)
|
||||||
|
{
|
||||||
|
feature.IsActive = false;
|
||||||
|
feature.Notes = $"حذف شده بابت تغییر پکیج (PackageId: {packageId})";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toAdd.Any())
|
||||||
|
{
|
||||||
|
var newUserFeatures = toAdd.Select(featureId => new UserClubFeature
|
||||||
|
{
|
||||||
|
UserId = userId,
|
||||||
|
ClubMembershipId = membershipId,
|
||||||
|
ClubFeatureId = featureId,
|
||||||
|
GrantedAt = activationDate,
|
||||||
|
IsActive = true,
|
||||||
|
Notes = isNewMembership
|
||||||
|
? "اعطا شده هنگام امضای قرارداد"
|
||||||
|
: $"اضافه شده بابت خرید مجدد پکیج (PackageId: {packageId})"
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
_context.UserClubFeatures.AddRange(newUserFeatures);
|
||||||
|
}
|
||||||
|
|
||||||
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Feature DIFF applied for UserId={UserId}: Added={Added}, Removed={Removed}, Kept={Kept}",
|
||||||
|
userId, toAdd.Count, toRemove.Count,
|
||||||
|
currentFeatureIds.Count - toRemove.Count);
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<(bool Success, string Message)> VerifyOtpAsync(
|
private async Task<(bool Success, string Message)> VerifyOtpAsync(
|
||||||
string mobile,
|
string mobile,
|
||||||
string code,
|
string code,
|
||||||
|
|||||||
-4
@@ -4,10 +4,6 @@ public class AcceptClubMembershipContractCommandValidator : AbstractValidator<Ac
|
|||||||
{
|
{
|
||||||
public AcceptClubMembershipContractCommandValidator()
|
public AcceptClubMembershipContractCommandValidator()
|
||||||
{
|
{
|
||||||
RuleFor(x => x.UserId)
|
|
||||||
.GreaterThan(0)
|
|
||||||
.WithMessage("شناسه کاربر الزامی است");
|
|
||||||
|
|
||||||
RuleFor(x => x.OtpCode)
|
RuleFor(x => x.OtpCode)
|
||||||
.NotEmpty()
|
.NotEmpty()
|
||||||
.WithMessage("کد تایید الزامی است")
|
.WithMessage("کد تایید الزامی است")
|
||||||
|
|||||||
+5
@@ -12,4 +12,9 @@ public record ActivateClubMembershipCommand : IRequest<bool>
|
|||||||
/// شناسه کاربر
|
/// شناسه کاربر
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public long UserId { get; init; }
|
public long UserId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// فعالسازی اجباری توسط ادمین — بدون بررسی سفارش و کیفپول
|
||||||
|
/// </summary>
|
||||||
|
public bool ForceActivation { get; init; }
|
||||||
}
|
}
|
||||||
|
|||||||
+204
-78
@@ -54,15 +54,21 @@ public class ActivateClubMembershipCommandHandler : IRequestHandler<ActivateClub
|
|||||||
throw new NotFoundException(nameof(User), request.UserId);
|
throw new NotFoundException(nameof(User), request.UserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// متغیر پکیج — در هر دو مسیر (عادی و Force) مقداردهی میشود
|
||||||
|
Package package;
|
||||||
|
|
||||||
|
// 2-5: بررسیهای مالی — در حالت ForceActivation (ادمین) رد میشود
|
||||||
|
if (!request.ForceActivation)
|
||||||
|
{
|
||||||
// 2. بررسی اینکه پکیج خریده باشد
|
// 2. بررسی اینکه پکیج خریده باشد
|
||||||
if (user.PackagePurchaseMethod == PackagePurchaseMethod.None)
|
if (user.PackagePurchaseMethod == PackagePurchaseMethod.None)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(
|
_logger.LogWarning(
|
||||||
"User {UserId} has not purchased golden package yet",
|
"User {UserId} has not purchased any package yet",
|
||||||
request.UserId
|
request.UserId
|
||||||
);
|
);
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"برای فعالسازی باشگاه مشتریان ابتدا باید پکیج طلایی خریداری کنید"
|
"برای فعالسازی باشگاه مشتریان ابتدا باید یک پکیج خریداری کنید"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,55 +82,59 @@ public class ActivateClubMembershipCommandHandler : IRequestHandler<ActivateClub
|
|||||||
throw new NotFoundException("کیف پول کاربر یافت نشد");
|
throw new NotFoundException("کیف پول کاربر یافت نشد");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (wallet.Balance < SystemConstants.BasePackageAmount)
|
// NOTE: balance check uses package.Price — loaded after finding order (step 4)
|
||||||
|
// Moved to after package loading
|
||||||
|
|
||||||
|
// 3.5. بررسی وضعیت کیفپول جادویی — اگر در حالت Magic است، فعالسازی مجاز نیست
|
||||||
|
if (wallet.WalletMode == WalletMode.Magic)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(
|
_logger.LogWarning(
|
||||||
"User {UserId} has insufficient balance: {Balance}",
|
"User {UserId} cannot activate club while in Magic wallet mode",
|
||||||
request.UserId,
|
request.UserId
|
||||||
wallet.Balance
|
|
||||||
);
|
);
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
$"برای فعالسازی باشگاه مشتریان باید حداقل {SystemConstants.BasePackageAmount:N0} ریال موجودی اصلی داشته باشید"
|
"ابتدا باید چرخه کیفپول جادویی تکمیل شود"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. پیدا کردن UserOrder با PackageId
|
// 4. پیدا کردن آخرین خرید موفق پکیج
|
||||||
var packageOrder = await _context.UserOrders
|
var packagePurchase = await _context.UserPackagePurchases
|
||||||
.Include(o => o.Transaction)
|
.Include(p => p.Transaction)
|
||||||
.Where(o =>
|
.Where(p =>
|
||||||
o.UserId == user.Id &&
|
p.UserId == user.Id &&
|
||||||
o.PackageId != null &&
|
p.Transaction != null &&
|
||||||
o.PaymentStatus == PaymentStatus.Success)
|
p.Transaction.PaymentStatus == PaymentStatus.Success)
|
||||||
.OrderByDescending(o => o.Created)
|
.OrderByDescending(p => p.Created)
|
||||||
.FirstOrDefaultAsync(cancellationToken);
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
if (packageOrder == null)
|
if (packagePurchase == null)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(
|
_logger.LogWarning(
|
||||||
"No successful package order found for UserId: {UserId}",
|
"No successful package purchase found for UserId: {UserId}",
|
||||||
request.UserId
|
request.UserId
|
||||||
);
|
);
|
||||||
throw new NotFoundException("سفارش پکیج طلایی یافت نشد");
|
throw new NotFoundException("سفارش پکیج یافت نشد");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. بررسی Transaction
|
// 5. بررسی Transaction
|
||||||
if (packageOrder.Transaction == null)
|
if (packagePurchase.Transaction == null)
|
||||||
{
|
{
|
||||||
_logger.LogError(
|
_logger.LogError(
|
||||||
"Transaction not found for OrderId: {OrderId}",
|
"Transaction not found for PurchaseId: {PurchaseId}",
|
||||||
packageOrder.Id
|
packagePurchase.Id
|
||||||
);
|
);
|
||||||
throw new NotFoundException("تراکنش مربوط به سفارش یافت نشد");
|
throw new NotFoundException("تراکنش مربوط به سفارش یافت نشد");
|
||||||
}
|
}
|
||||||
|
|
||||||
var transaction = packageOrder.Transaction;
|
var transaction = packagePurchase.Transaction;
|
||||||
|
|
||||||
if (transaction.Type != TransactionType.DepositIpg &&
|
if (transaction.Type != TransactionType.Buy &&
|
||||||
|
transaction.Type != TransactionType.DepositIpg &&
|
||||||
transaction.Type != TransactionType.DepositExternal1)
|
transaction.Type != TransactionType.DepositExternal1)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(
|
_logger.LogWarning(
|
||||||
"Invalid transaction type for OrderId {OrderId}: {Type}",
|
"Invalid transaction type for PurchaseId {PurchaseId}: {Type}",
|
||||||
packageOrder.Id,
|
packagePurchase.Id,
|
||||||
transaction.Type
|
transaction.Type
|
||||||
);
|
);
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
@@ -132,17 +142,51 @@ public class ActivateClubMembershipCommandHandler : IRequestHandler<ActivateClub
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 5.5. بارگذاری پکیج از خرید
|
||||||
|
package = await _context.Packages
|
||||||
|
.FirstOrDefaultAsync(p => p.Id == packagePurchase.PackageId && !p.IsDeleted, cancellationToken)
|
||||||
|
?? throw new NotFoundException("پکیج یافت نشد");
|
||||||
|
|
||||||
|
// بررسی موجودی با مبلغ پکیج واقعی
|
||||||
|
if (wallet.Balance < package.Price)
|
||||||
|
{
|
||||||
|
throw new BadRequestException(
|
||||||
|
$"برای فعالسازی باشگاه مشتریان باید حداقل {package.Price:N0} تومان موجودی اصلی داشته باشید"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Force activation by admin for UserId: {UserId} — skipping order/wallet validation",
|
||||||
|
request.UserId
|
||||||
|
);
|
||||||
|
|
||||||
|
// در فعالسازی اجباری، اگر PackagePurchaseMethod تنظیم نشده، مقدار DirectPurchase بگذار
|
||||||
|
if (user.PackagePurchaseMethod == PackagePurchaseMethod.None)
|
||||||
|
{
|
||||||
|
user.PackagePurchaseMethod = PackagePurchaseMethod.DirectPurchase;
|
||||||
|
_context.Users.Update(user);
|
||||||
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
// بارگذاری پکیج پایه برای فعالسازی اجباری
|
||||||
|
package = await _context.Packages
|
||||||
|
.FirstOrDefaultAsync(p => p.IsBasePackage && !p.IsDeleted, cancellationToken)
|
||||||
|
?? throw new NotFoundException("پکیج پایه یافت نشد");
|
||||||
|
}
|
||||||
|
|
||||||
// 6. بررسی عضویت فعلی
|
// 6. بررسی عضویت فعلی
|
||||||
var existingMembership = await _context.ClubMemberships
|
var existingMembership = await _context.ClubMemberships
|
||||||
.FirstOrDefaultAsync(c => c.UserId == user.Id, cancellationToken);
|
.FirstOrDefaultAsync(c => c.UserId == user.Id, cancellationToken);
|
||||||
|
|
||||||
// 6.1. دریافت مبلغ هدیه و هزینه فعالسازی از SystemConstants
|
// 6.1. دریافت مبلغ هدیه و هزینه فعالسازی از پکیج
|
||||||
long giftValue = SystemConstants.ClubMembershipGiftValue;
|
long giftValue = package.ActivationFee;
|
||||||
long activationFeeValue = SystemConstants.ClubActivationFee;
|
long activationFeeValue = package.ActivationFee;
|
||||||
|
|
||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
"Using Club.MembershipGiftValue: {GiftValue}, Club.ActivationFee: {ActivationFee}",
|
"Using Package {PackageId} ({PackageName}): GiftValue={GiftValue}, ActivationFee={ActivationFee}, Price={Price}",
|
||||||
giftValue, activationFeeValue
|
package.Id, package.Title, giftValue, activationFeeValue, package.Price
|
||||||
);
|
);
|
||||||
|
|
||||||
ClubMembership entity;
|
ClubMembership entity;
|
||||||
@@ -151,14 +195,18 @@ public class ActivateClubMembershipCommandHandler : IRequestHandler<ActivateClub
|
|||||||
|
|
||||||
if (isNewMembership)
|
if (isNewMembership)
|
||||||
{
|
{
|
||||||
// ایجاد عضویت جدید
|
// ایجاد عضویت جدید — IsActive = false تا زمان امضای قرارداد باشگاه
|
||||||
entity = new ClubMembership
|
entity = new ClubMembership
|
||||||
{
|
{
|
||||||
UserId = user.Id,
|
UserId = user.Id,
|
||||||
IsActive = true,
|
IsActive = false,
|
||||||
ActivatedAt = activationDate,
|
ActivatedAt = activationDate,
|
||||||
|
FirstActivationDate = activationDate,
|
||||||
|
FirstPackageId = package.Id,
|
||||||
|
LastActivationDate = activationDate,
|
||||||
|
LastPackageId = package.Id,
|
||||||
InitialContribution = activationFeeValue,
|
InitialContribution = activationFeeValue,
|
||||||
GiftValue = giftValue, // مقدار از تنظیمات
|
GiftValue = giftValue,
|
||||||
TotalEarned = 0,
|
TotalEarned = 0,
|
||||||
PurchaseMethod = user.PackagePurchaseMethod
|
PurchaseMethod = user.PackagePurchaseMethod
|
||||||
};
|
};
|
||||||
@@ -174,31 +222,73 @@ public class ActivateClubMembershipCommandHandler : IRequestHandler<ActivateClub
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
if (existingMembership.IsActive)
|
// بررسی آیا چرخه فعال دارد — اگر بله، واقعاً فعال است و نیازی به کار نیست
|
||||||
|
var hasCurrentCycle = await _context.ClubMembershipCycles
|
||||||
|
.AnyAsync(c => c.UserId == user.Id && c.IsCurrentCycle, cancellationToken);
|
||||||
|
|
||||||
|
if (existingMembership.IsActive && hasCurrentCycle)
|
||||||
{
|
{
|
||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
"User {UserId} is already an active club member",
|
"User {UserId} is already an active club member with a running cycle",
|
||||||
user.Id
|
user.Id
|
||||||
);
|
);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// فعالسازی مجدد
|
// فعالسازی مجدد یا خرید مجدد (Q19/Q20) — FirstActivation حفظ میشه (Q21), Last بروزرسانی میشه
|
||||||
|
// مقدار IsActive قبلی حفظ میشه — اگر قبلاً فعال بوده، فعال بمونه
|
||||||
entity = existingMembership;
|
entity = existingMembership;
|
||||||
entity.IsActive = true;
|
entity.LastActivationDate = activationDate;
|
||||||
entity.ActivatedAt = activationDate;
|
entity.LastPackageId = package.Id;
|
||||||
entity.PurchaseMethod = user.PackagePurchaseMethod;
|
entity.PurchaseMethod = user.PackagePurchaseMethod;
|
||||||
|
|
||||||
_context.ClubMemberships.Update(entity);
|
_context.ClubMemberships.Update(entity);
|
||||||
|
|
||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
"Reactivated club membership for UserId {UserId}",
|
"Re-activating club membership for UserId {UserId} (re-purchase scenario, IsActive was {WasActive})",
|
||||||
user.Id
|
user.Id,
|
||||||
|
existingMembership.IsActive
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await _context.SaveChangesAsync(cancellationToken);
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
// 6.5. ایجاد ClubMembershipCycle — هر خرید پکیج یک دور جدید
|
||||||
|
var previousCycles = await _context.ClubMembershipCycles
|
||||||
|
.Where(c => c.UserId == user.Id && c.IsCurrentCycle)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
foreach (var prevCycle in previousCycles)
|
||||||
|
{
|
||||||
|
prevCycle.IsCurrentCycle = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var maxCycleNumber = await _context.ClubMembershipCycles
|
||||||
|
.Where(c => c.ClubMembershipId == entity.Id)
|
||||||
|
.MaxAsync(c => (int?)c.CycleNumber, cancellationToken) ?? 0;
|
||||||
|
|
||||||
|
var newCycle = new ClubMembershipCycle
|
||||||
|
{
|
||||||
|
UserId = user.Id,
|
||||||
|
ClubMembershipId = entity.Id,
|
||||||
|
CycleNumber = maxCycleNumber + 1,
|
||||||
|
PackagePurchasedAt = activationDate,
|
||||||
|
PurchaseMethod = user.PackagePurchaseMethod,
|
||||||
|
PackageAmount = package.Price,
|
||||||
|
PackageId = package.Id,
|
||||||
|
IsCurrentCycle = true
|
||||||
|
};
|
||||||
|
|
||||||
|
_context.ClubMembershipCycles.Add(newCycle);
|
||||||
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Created ClubMembershipCycle #{CycleNumber} for UserId {UserId}, MembershipId {MembershipId}",
|
||||||
|
newCycle.CycleNumber,
|
||||||
|
user.Id,
|
||||||
|
entity.Id
|
||||||
|
);
|
||||||
|
|
||||||
// 7. ثبت تاریخچه
|
// 7. ثبت تاریخچه
|
||||||
var history = new ClubMembershipHistory
|
var history = new ClubMembershipHistory
|
||||||
{
|
{
|
||||||
@@ -219,15 +309,16 @@ public class ActivateClubMembershipCommandHandler : IRequestHandler<ActivateClub
|
|||||||
// ⭐ 8. اضافه کردن مبلغ به Pool هفته جاری
|
// ⭐ 8. اضافه کردن مبلغ به Pool هفته جاری
|
||||||
var currentWeekDefinitionId = GetCurrentWeekDefinitionId();
|
var currentWeekDefinitionId = GetCurrentWeekDefinitionId();
|
||||||
var weeklyPool = await _context.WeeklyCommissionPools
|
var weeklyPool = await _context.WeeklyCommissionPools
|
||||||
.FirstOrDefaultAsync(p => p.WeekDefinitionId == currentWeekDefinitionId, cancellationToken);
|
.FirstOrDefaultAsync(p => p.WeekDefinitionId == currentWeekDefinitionId && p.PackageId == package.Id, cancellationToken);
|
||||||
|
|
||||||
if (weeklyPool == null)
|
if (weeklyPool == null)
|
||||||
{
|
{
|
||||||
// ایجاد Pool جدید برای این هفته
|
// ایجاد Pool جدید برای این هفته × این پکیج
|
||||||
weeklyPool = new WeeklyCommissionPool
|
weeklyPool = new WeeklyCommissionPool
|
||||||
{
|
{
|
||||||
WeekDefinitionId = currentWeekDefinitionId,
|
WeekDefinitionId = currentWeekDefinitionId,
|
||||||
TotalPoolAmount = activationFeeValue, // مبلغ هدیه به Pool اضافه میشه
|
PackageId = package.Id,
|
||||||
|
TotalPoolAmount = activationFeeValue,
|
||||||
TotalBalances = 0, // در CalculateWeeklyBalances محاسبه میشه
|
TotalBalances = 0, // در CalculateWeeklyBalances محاسبه میشه
|
||||||
ValuePerBalance = 0, // در CalculateWeeklyCommissionPool محاسبه میشه
|
ValuePerBalance = 0, // در CalculateWeeklyCommissionPool محاسبه میشه
|
||||||
IsCalculated = false,
|
IsCalculated = false,
|
||||||
@@ -258,41 +349,8 @@ public class ActivateClubMembershipCommandHandler : IRequestHandler<ActivateClub
|
|||||||
|
|
||||||
await _context.SaveChangesAsync(cancellationToken);
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
// 9. اضافه کردن ویژگیهای باشگاه برای کاربر (فقط برای عضویت جدید)
|
// 9. اعمال ویژگیهای پکیج — DIFF/تفاضل (Q20)
|
||||||
if (isNewMembership)
|
await ApplyFeatureDiffAsync(user.Id, entity.Id, package.Id, isNewMembership, activationDate, cancellationToken);
|
||||||
{
|
|
||||||
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 = entity.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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_logger.LogWarning(
|
|
||||||
"No club features found to grant to UserId {UserId}",
|
|
||||||
user.Id
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
"Club membership activated successfully. UserId: {UserId}, MembershipId: {MembershipId}",
|
"Club membership activated successfully. UserId: {UserId}, MembershipId: {MembershipId}",
|
||||||
@@ -313,6 +371,74 @@ public class ActivateClubMembershipCommandHandler : IRequestHandler<ActivateClub
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// اعمال DIFF فیچرها بر اساس پکیج (Q20)
|
||||||
|
/// عضویت جدید: همه فیچرهای پکیج اضافه میشه
|
||||||
|
/// خرید مجدد: تفاضل فیچرهای فعلی و پکیج جدید
|
||||||
|
/// </summary>
|
||||||
|
private async Task ApplyFeatureDiffAsync(
|
||||||
|
long userId, long membershipId, long packageId,
|
||||||
|
bool isNewMembership, DateTime activationDate,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// فیچرهای پکیج جدید
|
||||||
|
var newFeatureIds = await _context.PackageFeatures
|
||||||
|
.Where(pf => pf.PackageId == packageId && pf.IsIncluded && !pf.IsDeleted)
|
||||||
|
.Select(pf => pf.ClubFeatureId)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (!newFeatureIds.Any())
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"No PackageFeatures found for PackageId {PackageId} — skipping feature assignment for UserId {UserId}",
|
||||||
|
packageId, userId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// فیچرهای فعال فعلی کاربر
|
||||||
|
var currentFeatures = await _context.UserClubFeatures
|
||||||
|
.Where(ucf => ucf.UserId == userId && ucf.IsActive)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var currentFeatureIds = currentFeatures.Select(f => f.ClubFeatureId).ToHashSet();
|
||||||
|
|
||||||
|
// DIFF
|
||||||
|
var toAdd = newFeatureIds.Where(id => !currentFeatureIds.Contains(id)).ToList();
|
||||||
|
var toRemove = currentFeatures.Where(f => !newFeatureIds.Contains(f.ClubFeatureId)).ToList();
|
||||||
|
|
||||||
|
// حذف فیچرهای قدیمی که پکیج جدید ندارد
|
||||||
|
foreach (var feature in toRemove)
|
||||||
|
{
|
||||||
|
feature.IsActive = false;
|
||||||
|
feature.Notes = $"حذف شده بابت تغییر پکیج (PackageId: {packageId})";
|
||||||
|
}
|
||||||
|
|
||||||
|
// اضافه فیچرهای جدید
|
||||||
|
if (toAdd.Any())
|
||||||
|
{
|
||||||
|
var newUserFeatures = toAdd.Select(featureId => new UserClubFeature
|
||||||
|
{
|
||||||
|
UserId = userId,
|
||||||
|
ClubMembershipId = membershipId,
|
||||||
|
ClubFeatureId = featureId,
|
||||||
|
GrantedAt = activationDate,
|
||||||
|
IsActive = true,
|
||||||
|
Notes = isNewMembership
|
||||||
|
? "اعطا شده هنگام فعالسازی اولیه"
|
||||||
|
: $"اضافه شده بابت خرید مجدد پکیج (PackageId: {packageId})"
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
_context.UserClubFeatures.AddRange(newUserFeatures);
|
||||||
|
}
|
||||||
|
|
||||||
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Feature DIFF applied for UserId={UserId}: Added={Added}, Removed={Removed}, Kept={Kept}",
|
||||||
|
userId, toAdd.Count, toRemove.Count,
|
||||||
|
currentFeatureIds.Count - toRemove.Count);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// دریافت شناسه تعریف هفته جاری
|
/// دریافت شناسه تعریف هفته جاری
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
+21
-10
@@ -43,8 +43,14 @@ public class CalculateWeeklyBalancesCommandHandler : IRequestHandler<CalculateWe
|
|||||||
|
|
||||||
// ⭐ دریافت همه کاربرانی که عضو فعال باشگاه هستند
|
// ⭐ دریافت همه کاربرانی که عضو فعال باشگاه هستند
|
||||||
// بدون محدودیت زمانی - همه اعضای فعال کلاب باید کمیسیون بگیرند
|
// بدون محدودیت زمانی - همه اعضای فعال کلاب باید کمیسیون بگیرند
|
||||||
|
// ⚠️ Magic Wallet: کاربرهایی که در حالت جادویی هستند از کمیسیون خارج میشن
|
||||||
|
var magicModeUserIds = await _context.UserWallets
|
||||||
|
.Where(w => w.WalletMode == WalletMode.Magic)
|
||||||
|
.Select(w => w.UserId)
|
||||||
|
.ToHashSetAsync(cancellationToken);
|
||||||
|
|
||||||
var activeClubMemberUserIds = await _context.ClubMemberships
|
var activeClubMemberUserIds = await _context.ClubMemberships
|
||||||
.Where(c => c.IsActive)
|
.Where(c => c.IsActive && !magicModeUserIds.Contains(c.UserId))
|
||||||
.Select(c => c.UserId)
|
.Select(c => c.UserId)
|
||||||
.ToHashSetAsync(cancellationToken);
|
.ToHashSetAsync(cancellationToken);
|
||||||
|
|
||||||
@@ -76,11 +82,14 @@ public class CalculateWeeklyBalancesCommandHandler : IRequestHandler<CalculateWe
|
|||||||
var balancesList = new List<NetworkWeeklyBalance>();
|
var balancesList = new List<NetworkWeeklyBalance>();
|
||||||
var calculatedAt = DateTime.Now;
|
var calculatedAt = DateTime.Now;
|
||||||
|
|
||||||
// استفاده از SystemConstants (استاتیک - بدون کوئری به دیتابیس)
|
// بارگذاری پکیج پایه برای خواندن سقفها
|
||||||
// سقف تعادل هفتگی برای هر دست (نه کل) - 300 برای چپ + 300 برای راست = حداکثر 600 تعادل
|
var package = await _context.Packages
|
||||||
var maxBalancesPerLeg = SystemConstants.CommissionMaxWeeklyBalancesPerLeg;
|
.FirstOrDefaultAsync(p => p.IsBasePackage && !p.IsDeleted, cancellationToken)
|
||||||
// حداکثر عمق شبکه برای شمارش اعضا (15 لول)
|
?? throw new InvalidOperationException("پکیج پایه یافت نشد");
|
||||||
var maxNetworkLevel = SystemConstants.CommissionMaxNetworkLevel;
|
|
||||||
|
// خواندن سقفها از پکیج (قبلاً از SystemConstants بود)
|
||||||
|
var maxBalancesPerLeg = package.MaxBalancesPerLeg;
|
||||||
|
var maxNetworkLevel = package.MaxNetworkLevel;
|
||||||
|
|
||||||
foreach (var user in usersInNetwork.OrderBy(o=>o.Id))
|
foreach (var user in usersInNetwork.OrderBy(o=>o.Id))
|
||||||
{
|
{
|
||||||
@@ -134,6 +143,7 @@ public class CalculateWeeklyBalancesCommandHandler : IRequestHandler<CalculateWe
|
|||||||
{
|
{
|
||||||
UserId = user.Id,
|
UserId = user.Id,
|
||||||
WeekDefinitionId = request.WeekDefinitionId,
|
WeekDefinitionId = request.WeekDefinitionId,
|
||||||
|
PackageId = package.Id,
|
||||||
|
|
||||||
// اطلاعات جدید
|
// اطلاعات جدید
|
||||||
LeftLegNewMembers = leftNewMembers,
|
LeftLegNewMembers = leftNewMembers,
|
||||||
@@ -256,11 +266,12 @@ public class CalculateWeeklyBalancesCommandHandler : IRequestHandler<CalculateWe
|
|||||||
|
|
||||||
var count = 0;
|
var count = 0;
|
||||||
|
|
||||||
// اگر فرزند در این هفته فعال شده، 1 امتیاز
|
// اگر فرزند در این هفته پکیج خریده (Cycle جدید ساخته شده)، 1 امتیاز
|
||||||
var membership = await _context.ClubMemberships
|
// ⚠️ از ClubMembershipCycle.PackagePurchasedAt استفاده میکنیم (نه ActivatedAt که overwrite میشد)
|
||||||
.FirstOrDefaultAsync(x => x.UserId == child.Id && x.IsActive, cancellationToken);
|
var currentCycle = await _context.ClubMembershipCycles
|
||||||
|
.FirstOrDefaultAsync(x => x.UserId == child.Id && x.IsCurrentCycle, cancellationToken);
|
||||||
|
|
||||||
if (membership?.ActivatedAt >= startDate && membership?.ActivatedAt <= endDate)
|
if (currentCycle?.PackagePurchasedAt >= startDate && currentCycle?.PackagePurchasedAt <= endDate)
|
||||||
{
|
{
|
||||||
count = 1;
|
count = 1;
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-5
@@ -192,7 +192,7 @@ public class CalculateWeeklyCommissionPoolCommandHandler : IRequestHandler<Calcu
|
|||||||
.ToDictionaryAsync(w => w.UserId, cancellationToken);
|
.ToDictionaryAsync(w => w.UserId, cancellationToken);
|
||||||
|
|
||||||
var newWallets = new List<UserWallet>();
|
var newWallets = new List<UserWallet>();
|
||||||
var walletLogs = new List<UserWalletChangeLog>();
|
var walletLogs = new List<UserWalletHistory>();
|
||||||
|
|
||||||
foreach (var payout in payouts)
|
foreach (var payout in payouts)
|
||||||
{
|
{
|
||||||
@@ -241,7 +241,7 @@ public class CalculateWeeklyCommissionPoolCommandHandler : IRequestHandler<Calcu
|
|||||||
wallet.NetworkBalance += payout.TotalAmount;
|
wallet.NetworkBalance += payout.TotalAmount;
|
||||||
|
|
||||||
// ایجاد لاگ تغییر کیف پول
|
// ایجاد لاگ تغییر کیف پول
|
||||||
var walletLog = new UserWalletChangeLog
|
var walletLog = new UserWalletHistory
|
||||||
{
|
{
|
||||||
WalletId = wallet.Id,
|
WalletId = wallet.Id,
|
||||||
CurrentBalance = wallet.Balance,
|
CurrentBalance = wallet.Balance,
|
||||||
@@ -258,7 +258,7 @@ public class CalculateWeeklyCommissionPoolCommandHandler : IRequestHandler<Calcu
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ذخیره تغییرات کیف پول و لاگها
|
// ذخیره تغییرات کیف پول و لاگها
|
||||||
await _context.UserWalletChangeLogs.AddRangeAsync(walletLogs, cancellationToken);
|
await _context.UserWalletHistories.AddRangeAsync(walletLogs, cancellationToken);
|
||||||
await _context.SaveChangesAsync(cancellationToken);
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,7 +271,7 @@ public class CalculateWeeklyCommissionPoolCommandHandler : IRequestHandler<Calcu
|
|||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
// پیدا کردن لاگهای کیف پول مرتبط با پرداختهای قبلی
|
// پیدا کردن لاگهای کیف پول مرتبط با پرداختهای قبلی
|
||||||
var oldWalletLogs = await _context.UserWalletChangeLogs
|
var oldWalletLogs = await _context.UserWalletHistories
|
||||||
.Where(l => l.RefrenceId.HasValue && oldPayoutIds.Contains(l.RefrenceId.Value))
|
.Where(l => l.RefrenceId.HasValue && oldPayoutIds.Contains(l.RefrenceId.Value))
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
@@ -300,7 +300,7 @@ public class CalculateWeeklyCommissionPoolCommandHandler : IRequestHandler<Calcu
|
|||||||
}
|
}
|
||||||
|
|
||||||
// حذف لاگهای قبلی
|
// حذف لاگهای قبلی
|
||||||
_context.UserWalletChangeLogs.RemoveRange(oldWalletLogs);
|
_context.UserWalletHistories.RemoveRange(oldWalletLogs);
|
||||||
|
|
||||||
await _context.SaveChangesAsync(cancellationToken);
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-2
@@ -49,8 +49,11 @@ public class ProcessUserPayoutsCommandHandler : IRequestHandler<ProcessUserPayou
|
|||||||
await _context.SaveChangesAsync(cancellationToken);
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ⭐ خواندن MaxNetworkLevel از SystemConstants (استاتیک)
|
// بارگذاری پکیج پایه برای خواندن سقفها
|
||||||
var maxNetworkLevel = SystemConstants.CommissionMaxNetworkLevel;
|
var package = await _context.Packages
|
||||||
|
.FirstOrDefaultAsync(p => p.IsBasePackage && !p.IsDeleted, cancellationToken)
|
||||||
|
?? throw new InvalidOperationException("پکیج پایه یافت نشد");
|
||||||
|
var maxNetworkLevel = package.MaxNetworkLevel;
|
||||||
|
|
||||||
// دریافت همه تعادلهای هفتگی (شامل صفرها هم برای محاسبه زیرمجموعه)
|
// دریافت همه تعادلهای هفتگی (شامل صفرها هم برای محاسبه زیرمجموعه)
|
||||||
var allWeeklyBalances = await _context.NetworkWeeklyBalances
|
var allWeeklyBalances = await _context.NetworkWeeklyBalances
|
||||||
@@ -113,6 +116,7 @@ public class ProcessUserPayoutsCommandHandler : IRequestHandler<ProcessUserPayou
|
|||||||
UserId = userId,
|
UserId = userId,
|
||||||
WeekDefinitionId = request.WeekDefinitionId,
|
WeekDefinitionId = request.WeekDefinitionId,
|
||||||
WeeklyPoolId = pool.Id,
|
WeeklyPoolId = pool.Id,
|
||||||
|
PackageId = package.Id,
|
||||||
BalancesEarned = totalBalancesWithSubordinates, // ⭐ شامل زیرمجموعه
|
BalancesEarned = totalBalancesWithSubordinates, // ⭐ شامل زیرمجموعه
|
||||||
ValuePerBalance = pool.ValuePerBalance,
|
ValuePerBalance = pool.ValuePerBalance,
|
||||||
TotalAmount = totalAmount,
|
TotalAmount = totalAmount,
|
||||||
|
|||||||
+5
@@ -21,4 +21,9 @@ public record TriggerWeeklyCalculationCommand : IRequest<TriggerWeeklyCalculatio
|
|||||||
/// Skip pool calculation and payout processing (Step 2)
|
/// Skip pool calculation and payout processing (Step 2)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool SkipPayouts { get; init; }
|
public bool SkipPayouts { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// شناسه پکیج — اگر مقدار داشته باشد فقط این پکیج محاسبه میشود، در غیر این صورت همه پکیجها
|
||||||
|
/// </summary>
|
||||||
|
public long? PackageId { get; init; }
|
||||||
}
|
}
|
||||||
|
|||||||
+2
@@ -53,6 +53,7 @@ public class TriggerWeeklyCalculationCommandHandler : IRequestHandler<TriggerWee
|
|||||||
var balancesCount = await strategy.CalculateWeeklyBalancesAsync(
|
var balancesCount = await strategy.CalculateWeeklyBalancesAsync(
|
||||||
request.WeekDefinitionId,
|
request.WeekDefinitionId,
|
||||||
request.ForceRecalculate,
|
request.ForceRecalculate,
|
||||||
|
request.PackageId,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
steps.Add($"محاسبه امتیازات هفتگی ({balancesCount} کاربر)");
|
steps.Add($"محاسبه امتیازات هفتگی ({balancesCount} کاربر)");
|
||||||
@@ -64,6 +65,7 @@ public class TriggerWeeklyCalculationCommandHandler : IRequestHandler<TriggerWee
|
|||||||
var poolId = await strategy.CalculateWeeklyCommissionPoolAsync(
|
var poolId = await strategy.CalculateWeeklyCommissionPoolAsync(
|
||||||
request.WeekDefinitionId,
|
request.WeekDefinitionId,
|
||||||
request.ForceRecalculate,
|
request.ForceRecalculate,
|
||||||
|
request.PackageId,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
steps.Add($"محاسبه استخر و پرداخت کاربران (Pool: {poolId})");
|
steps.Add($"محاسبه استخر و پرداخت کاربران (Pool: {poolId})");
|
||||||
|
|||||||
+9
-4
@@ -6,20 +6,25 @@ namespace CMSMicroservice.Application.CommissionCQ.Queries.GetAllWeeklyPools;
|
|||||||
public record GetAllWeeklyPoolsQuery : IRequest<GetAllWeeklyPoolsResponseDto>
|
public record GetAllWeeklyPoolsQuery : IRequest<GetAllWeeklyPoolsResponseDto>
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// از هفته (فیلتر اختیاری)
|
/// از هفته — WeekDefinitionId (فیلتر اختیاری)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int? FromWeekOrder { get; init; }
|
public long? FromWeekDefinitionId { get; init; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// تا هفته (فیلتر اختیاری)
|
/// تا هفته — WeekDefinitionId (فیلتر اختیاری)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int? ToWeekOrder { get; init; }
|
public long? ToWeekDefinitionId { get; init; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// فقط Pool های محاسبه شده
|
/// فقط Pool های محاسبه شده
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool? OnlyCalculated { get; init; }
|
public bool? OnlyCalculated { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// فیلتر بر اساس پکیج (اختیاری)
|
||||||
|
/// </summary>
|
||||||
|
public long? PackageId { get; init; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// شماره صفحه
|
/// شماره صفحه
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
+13
-5
@@ -17,17 +17,18 @@ public class GetAllWeeklyPoolsQueryHandler : IRequestHandler<GetAllWeeklyPoolsQu
|
|||||||
{
|
{
|
||||||
var query = _context.WeeklyCommissionPools
|
var query = _context.WeeklyCommissionPools
|
||||||
.Include(i=> i.WeekDefinition)
|
.Include(i=> i.WeekDefinition)
|
||||||
|
.Include(i => i.Package)
|
||||||
.AsNoTracking();
|
.AsNoTracking();
|
||||||
|
|
||||||
// Apply filters
|
// Apply filters
|
||||||
if (request.FromWeekOrder!=null)
|
if (request.FromWeekDefinitionId != null)
|
||||||
{
|
{
|
||||||
query = query.Where(x => x.WeekDefinition.WeekOrder>=request.FromWeekOrder );
|
query = query.Where(x => x.WeekDefinitionId >= request.FromWeekDefinitionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.ToWeekOrder!=null)
|
if (request.ToWeekDefinitionId != null)
|
||||||
{
|
{
|
||||||
query = query.Where(x =>x.WeekDefinition.WeekOrder<= request.ToWeekOrder);
|
query = query.Where(x => x.WeekDefinitionId <= request.ToWeekDefinitionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.OnlyCalculated.HasValue && request.OnlyCalculated.Value)
|
if (request.OnlyCalculated.HasValue && request.OnlyCalculated.Value)
|
||||||
@@ -35,6 +36,11 @@ public class GetAllWeeklyPoolsQueryHandler : IRequestHandler<GetAllWeeklyPoolsQu
|
|||||||
query = query.Where(x => x.IsCalculated);
|
query = query.Where(x => x.IsCalculated);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (request.PackageId.HasValue)
|
||||||
|
{
|
||||||
|
query = query.Where(x => x.PackageId == request.PackageId.Value);
|
||||||
|
}
|
||||||
|
|
||||||
// Order by week number descending (newest first)
|
// Order by week number descending (newest first)
|
||||||
query = query.OrderByDescending(x => x.WeekDefinitionId);
|
query = query.OrderByDescending(x => x.WeekDefinitionId);
|
||||||
|
|
||||||
@@ -55,7 +61,9 @@ public class GetAllWeeklyPoolsQueryHandler : IRequestHandler<GetAllWeeklyPoolsQu
|
|||||||
ValuePerBalance = x.ValuePerBalance,
|
ValuePerBalance = x.ValuePerBalance,
|
||||||
IsCalculated = x.IsCalculated,
|
IsCalculated = x.IsCalculated,
|
||||||
CalculatedAt = x.CalculatedAt,
|
CalculatedAt = x.CalculatedAt,
|
||||||
Created = x.Created
|
Created = x.Created,
|
||||||
|
PackageId = x.PackageId,
|
||||||
|
PackageTitle = x.Package != null ? x.Package.Title : string.Empty
|
||||||
})
|
})
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
|||||||
+2
@@ -17,6 +17,8 @@ public record WeeklyCommissionPoolDto
|
|||||||
public bool IsCalculated { get; init; }
|
public bool IsCalculated { get; init; }
|
||||||
public DateTime? CalculatedAt { get; init; }
|
public DateTime? CalculatedAt { get; init; }
|
||||||
public DateTime Created { get; init; }
|
public DateTime Created { get; init; }
|
||||||
|
public long PackageId { get; init; }
|
||||||
|
public string PackageTitle { get; init; } = string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
public record MetaDataDto
|
public record MetaDataDto
|
||||||
|
|||||||
+5
@@ -18,6 +18,11 @@ public record GetMyCommissionPayoutsQuery : IRequest<GetMyCommissionPayoutsRespo
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public long? WeekDefinitionId { get; init; }
|
public long? WeekDefinitionId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// فیلتر بر اساس پکیج (اختیاری)
|
||||||
|
/// </summary>
|
||||||
|
public long? PackageId { get; init; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Pagination
|
/// Pagination
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
+10
-1
@@ -28,6 +28,7 @@ public class GetMyCommissionPayoutsQueryHandler : IRequestHandler<GetMyCommissio
|
|||||||
|
|
||||||
var query = _context.UserCommissionPayouts
|
var query = _context.UserCommissionPayouts
|
||||||
.Include(x => x.WeekDefinition)
|
.Include(x => x.WeekDefinition)
|
||||||
|
.Include(x => x.Package)
|
||||||
.Where(x => x.UserId == userId)
|
.Where(x => x.UserId == userId)
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.AsQueryable();
|
.AsQueryable();
|
||||||
@@ -43,6 +44,12 @@ public class GetMyCommissionPayoutsQueryHandler : IRequestHandler<GetMyCommissio
|
|||||||
query = query.Where(x => x.WeekDefinitionId == request.WeekDefinitionId.Value);
|
query = query.Where(x => x.WeekDefinitionId == request.WeekDefinitionId.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// فیلتر بر اساس پکیج
|
||||||
|
if (request.PackageId.HasValue && request.PackageId.Value > 0)
|
||||||
|
{
|
||||||
|
query = query.Where(x => x.PackageId == request.PackageId.Value);
|
||||||
|
}
|
||||||
|
|
||||||
// مرتبسازی: جدیدترین اول
|
// مرتبسازی: جدیدترین اول
|
||||||
query = query.OrderByDescending(x => x.Created);
|
query = query.OrderByDescending(x => x.Created);
|
||||||
|
|
||||||
@@ -60,7 +67,9 @@ public class GetMyCommissionPayoutsQueryHandler : IRequestHandler<GetMyCommissio
|
|||||||
AmountFormatted = x.TotalAmount.ToString("N0") + " تومان",
|
AmountFormatted = x.TotalAmount.ToString("N0") + " تومان",
|
||||||
Status = x.Status,
|
Status = x.Status,
|
||||||
CalculatedDate = x.PaidAt ?? (DateTime?)x.Created,
|
CalculatedDate = x.PaidAt ?? (DateTime?)x.Created,
|
||||||
DatePersian = ""
|
DatePersian = "",
|
||||||
|
PackageId = x.PackageId,
|
||||||
|
PackageTitle = x.Package != null ? x.Package.Title : ""
|
||||||
})
|
})
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
|||||||
+4
@@ -20,4 +20,8 @@ public class GetMyCommissionPayoutsResponseModel
|
|||||||
public CommissionPayoutStatus Status { get; set; }
|
public CommissionPayoutStatus Status { get; set; }
|
||||||
public DateTime? CalculatedDate { get; set; }
|
public DateTime? CalculatedDate { get; set; }
|
||||||
public string DatePersian { get; set; } = string.Empty;
|
public string DatePersian { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
// Package info
|
||||||
|
public long PackageId { get; set; }
|
||||||
|
public string PackageTitle { get; set; } = string.Empty;
|
||||||
}
|
}
|
||||||
|
|||||||
+5
@@ -17,6 +17,11 @@ public record GetMyWeeklyBalancesQuery : IRequest<GetUserWeeklyBalancesResponseD
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public bool OnlyActive { get; init; }
|
public bool OnlyActive { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// فیلتر بر اساس پکیج (اختیاری)
|
||||||
|
/// </summary>
|
||||||
|
public long? PackageId { get; init; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Pagination
|
/// Pagination
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
+1
@@ -38,6 +38,7 @@ public class GetMyWeeklyBalancesQueryHandler : IRequestHandler<GetMyWeeklyBalanc
|
|||||||
{
|
{
|
||||||
UserId = userId,
|
UserId = userId,
|
||||||
WeekDefinitionId = request.WeekDefinitionId,
|
WeekDefinitionId = request.WeekDefinitionId,
|
||||||
|
PackageId = request.PackageId,
|
||||||
OnlyActive = request.OnlyActive,
|
OnlyActive = request.OnlyActive,
|
||||||
PaginationState = request.PaginationState
|
PaginationState = request.PaginationState
|
||||||
};
|
};
|
||||||
|
|||||||
+5
@@ -20,6 +20,11 @@ public record GetUserCommissionPayoutsQuery : IRequest<GetUserCommissionPayoutsR
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public long? WeekDefinitionId { get; init; }
|
public long? WeekDefinitionId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// فیلتر بر اساس پکیج (اختیاری)
|
||||||
|
/// </summary>
|
||||||
|
public long? PackageId { get; init; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// مرتبسازی
|
/// مرتبسازی
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
+14
-14
@@ -4,16 +4,13 @@ public class GetUserCommissionPayoutsQueryHandler : IRequestHandler<GetUserCommi
|
|||||||
{
|
{
|
||||||
private readonly IApplicationDbContext _context;
|
private readonly IApplicationDbContext _context;
|
||||||
private readonly IWeekDefinitionRepository _weekDefinitionRepository;
|
private readonly IWeekDefinitionRepository _weekDefinitionRepository;
|
||||||
private readonly ICurrentUserService _currentUser;
|
|
||||||
|
|
||||||
public GetUserCommissionPayoutsQueryHandler(
|
public GetUserCommissionPayoutsQueryHandler(
|
||||||
IApplicationDbContext context,
|
IApplicationDbContext context,
|
||||||
IWeekDefinitionRepository weekDefinitionRepository,
|
IWeekDefinitionRepository weekDefinitionRepository)
|
||||||
ICurrentUserService currentUser)
|
|
||||||
{
|
{
|
||||||
_context = context;
|
_context = context;
|
||||||
_weekDefinitionRepository = weekDefinitionRepository;
|
_weekDefinitionRepository = weekDefinitionRepository;
|
||||||
_currentUser = currentUser;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<GetUserCommissionPayoutsResponseDto> Handle(GetUserCommissionPayoutsQuery request, CancellationToken cancellationToken)
|
public async Task<GetUserCommissionPayoutsResponseDto> Handle(GetUserCommissionPayoutsQuery request, CancellationToken cancellationToken)
|
||||||
@@ -21,20 +18,15 @@ public class GetUserCommissionPayoutsQueryHandler : IRequestHandler<GetUserCommi
|
|||||||
var query = _context.UserCommissionPayouts
|
var query = _context.UserCommissionPayouts
|
||||||
.Include(x => x.WeekDefinition)
|
.Include(x => x.WeekDefinition)
|
||||||
.Include(x => x.User)
|
.Include(x => x.User)
|
||||||
|
.Include(x => x.Package)
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.AsQueryable();
|
.AsQueryable();
|
||||||
|
|
||||||
// اگر UserId داده نشده، از CurrentUser بگیر (برای Customer API)
|
// UserId > 0 → filter by that user
|
||||||
|
// UserId == 0 or null → show ALL users (admin mode)
|
||||||
|
// Customer endpoints resolve UserId from JWT before calling this handler
|
||||||
long? userId = request.UserId;
|
long? userId = request.UserId;
|
||||||
if (!userId.HasValue || userId.Value == 0)
|
|
||||||
{
|
|
||||||
if (long.TryParse(_currentUser.UserId, out var currentUserId))
|
|
||||||
{
|
|
||||||
userId = currentUserId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// فیلترها
|
|
||||||
if (userId.HasValue && userId.Value > 0)
|
if (userId.HasValue && userId.Value > 0)
|
||||||
{
|
{
|
||||||
query = query.Where(x => x.UserId == userId.Value);
|
query = query.Where(x => x.UserId == userId.Value);
|
||||||
@@ -50,6 +42,12 @@ public class GetUserCommissionPayoutsQueryHandler : IRequestHandler<GetUserCommi
|
|||||||
query = query.Where(x => x.WeekDefinitionId == request.WeekDefinitionId);
|
query = query.Where(x => x.WeekDefinitionId == request.WeekDefinitionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// فیلتر بر اساس پکیج
|
||||||
|
if (request.PackageId.HasValue && request.PackageId.Value > 0)
|
||||||
|
{
|
||||||
|
query = query.Where(x => x.PackageId == request.PackageId.Value);
|
||||||
|
}
|
||||||
|
|
||||||
query = query.ApplyOrder(sortBy: request.SortBy ?? "Created");
|
query = query.ApplyOrder(sortBy: request.SortBy ?? "Created");
|
||||||
|
|
||||||
var meta = await query.GetMetaData(request.PaginationState, cancellationToken);
|
var meta = await query.GetMetaData(request.PaginationState, cancellationToken);
|
||||||
@@ -73,7 +71,9 @@ public class GetUserCommissionPayoutsQueryHandler : IRequestHandler<GetUserCommi
|
|||||||
WithdrawalMethod = x.WithdrawalMethod,
|
WithdrawalMethod = x.WithdrawalMethod,
|
||||||
IbanNumber = x.IbanNumber,
|
IbanNumber = x.IbanNumber,
|
||||||
WithdrawnAt = x.WithdrawnAt,
|
WithdrawnAt = x.WithdrawnAt,
|
||||||
Created = x.Created
|
Created = x.Created,
|
||||||
|
PackageId = x.PackageId,
|
||||||
|
PackageTitle = x.Package != null ? x.Package.Title : ""
|
||||||
})
|
})
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
|||||||
+4
@@ -24,4 +24,8 @@ public class GetUserCommissionPayoutsResponseModel
|
|||||||
public string? IbanNumber { get; set; }
|
public string? IbanNumber { get; set; }
|
||||||
public DateTime? WithdrawnAt { get; set; }
|
public DateTime? WithdrawnAt { get; set; }
|
||||||
public DateTimeOffset Created { get; set; }
|
public DateTimeOffset Created { get; set; }
|
||||||
|
|
||||||
|
// Package info
|
||||||
|
public long PackageId { get; set; }
|
||||||
|
public string PackageTitle { get; set; } = string.Empty;
|
||||||
}
|
}
|
||||||
|
|||||||
+4
@@ -15,6 +15,10 @@ public record GetUserWeeklyBalancesQuery : IRequest<GetUserWeeklyBalancesRespons
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public long? WeekDefinitionId { get; init; }
|
public long? WeekDefinitionId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// فیلتر بر اساس پکیج (اختیاری)
|
||||||
|
/// </summary>
|
||||||
|
public long? PackageId { get; init; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// فقط موارد Expired نشده؟
|
/// فقط موارد Expired نشده؟
|
||||||
|
|||||||
+14
-14
@@ -4,16 +4,13 @@ public class GetUserWeeklyBalancesQueryHandler : IRequestHandler<GetUserWeeklyBa
|
|||||||
{
|
{
|
||||||
private readonly IApplicationDbContext _context;
|
private readonly IApplicationDbContext _context;
|
||||||
private readonly IWeekDefinitionRepository _weekDefinitionRepository;
|
private readonly IWeekDefinitionRepository _weekDefinitionRepository;
|
||||||
private readonly ICurrentUserService _currentUser;
|
|
||||||
|
|
||||||
public GetUserWeeklyBalancesQueryHandler(
|
public GetUserWeeklyBalancesQueryHandler(
|
||||||
IApplicationDbContext context,
|
IApplicationDbContext context,
|
||||||
IWeekDefinitionRepository weekDefinitionRepository,
|
IWeekDefinitionRepository weekDefinitionRepository)
|
||||||
ICurrentUserService currentUser)
|
|
||||||
{
|
{
|
||||||
_context = context;
|
_context = context;
|
||||||
_weekDefinitionRepository = weekDefinitionRepository;
|
_weekDefinitionRepository = weekDefinitionRepository;
|
||||||
_currentUser = currentUser;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<GetUserWeeklyBalancesResponseDto> Handle(GetUserWeeklyBalancesQuery request, CancellationToken cancellationToken)
|
public async Task<GetUserWeeklyBalancesResponseDto> Handle(GetUserWeeklyBalancesQuery request, CancellationToken cancellationToken)
|
||||||
@@ -21,20 +18,15 @@ public class GetUserWeeklyBalancesQueryHandler : IRequestHandler<GetUserWeeklyBa
|
|||||||
var query = _context.NetworkWeeklyBalances
|
var query = _context.NetworkWeeklyBalances
|
||||||
.Include(x => x.WeekDefinition)
|
.Include(x => x.WeekDefinition)
|
||||||
.Include(x => x.User)
|
.Include(x => x.User)
|
||||||
|
.Include(x => x.Package)
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.AsQueryable();
|
.AsQueryable();
|
||||||
|
|
||||||
// اگر UserId داده نشده، از CurrentUser بگیر (برای Customer API)
|
// UserId > 0 → filter by that user
|
||||||
|
// UserId == 0 or null → show ALL users (admin mode)
|
||||||
|
// Customer endpoints resolve UserId from JWT before calling this handler
|
||||||
long? userId = request.UserId;
|
long? userId = request.UserId;
|
||||||
if (!userId.HasValue || userId.Value == 0)
|
|
||||||
{
|
|
||||||
if (long.TryParse(_currentUser.UserId, out var currentUserId))
|
|
||||||
{
|
|
||||||
userId = currentUserId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// فیلترها
|
|
||||||
if (userId.HasValue && userId.Value > 0)
|
if (userId.HasValue && userId.Value > 0)
|
||||||
{
|
{
|
||||||
query = query.Where(x => x.UserId == userId.Value);
|
query = query.Where(x => x.UserId == userId.Value);
|
||||||
@@ -52,6 +44,12 @@ public class GetUserWeeklyBalancesQueryHandler : IRequestHandler<GetUserWeeklyBa
|
|||||||
query = query.Where(x => !x.IsExpired);
|
query = query.Where(x => !x.IsExpired);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// فیلتر بر اساس پکیج
|
||||||
|
if (request.PackageId.HasValue && request.PackageId.Value > 0)
|
||||||
|
{
|
||||||
|
query = query.Where(x => x.PackageId == request.PackageId.Value);
|
||||||
|
}
|
||||||
|
|
||||||
// مرتبسازی بر اساس WeekDefinitionId (نزولی = جدیدترین اول)
|
// مرتبسازی بر اساس WeekDefinitionId (نزولی = جدیدترین اول)
|
||||||
query = query.ApplyOrder(sortBy: request.SortBy ?? "-WeekDefinitionId");
|
query = query.ApplyOrder(sortBy: request.SortBy ?? "-WeekDefinitionId");
|
||||||
|
|
||||||
@@ -76,7 +74,9 @@ public class GetUserWeeklyBalancesQueryHandler : IRequestHandler<GetUserWeeklyBa
|
|||||||
WeeklyPoolContribution = x.WeeklyPoolContribution,
|
WeeklyPoolContribution = x.WeeklyPoolContribution,
|
||||||
CalculatedAt = x.CalculatedAt,
|
CalculatedAt = x.CalculatedAt,
|
||||||
IsExpired = x.IsExpired,
|
IsExpired = x.IsExpired,
|
||||||
Created = x.Created
|
Created = x.Created,
|
||||||
|
PackageId = x.PackageId,
|
||||||
|
PackageTitle = x.Package != null ? x.Package.Title : ""
|
||||||
})
|
})
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
|||||||
+4
@@ -29,4 +29,8 @@ public class GetUserWeeklyBalancesResponseModel
|
|||||||
public DateTime? CalculatedAt { get; set; }
|
public DateTime? CalculatedAt { get; set; }
|
||||||
public bool IsExpired { get; set; }
|
public bool IsExpired { get; set; }
|
||||||
public DateTimeOffset Created { get; set; }
|
public DateTimeOffset Created { get; set; }
|
||||||
|
|
||||||
|
// Package info
|
||||||
|
public long PackageId { get; set; }
|
||||||
|
public string PackageTitle { get; set; } = string.Empty;
|
||||||
}
|
}
|
||||||
|
|||||||
+5
@@ -9,4 +9,9 @@ public record GetWeeklyCommissionPoolQuery : IRequest<WeeklyCommissionPoolDto?>
|
|||||||
/// شماره هفته
|
/// شماره هفته
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public long WeekDefinitionId { get; init; }
|
public long WeekDefinitionId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// شناسه پکیج — اختیاری
|
||||||
|
/// </summary>
|
||||||
|
public long? PackageId { get; init; }
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-8
@@ -15,10 +15,16 @@ public class GetWeeklyCommissionPoolQueryHandler : IRequestHandler<GetWeeklyComm
|
|||||||
|
|
||||||
public async Task<WeeklyCommissionPoolDto?> Handle(GetWeeklyCommissionPoolQuery request, CancellationToken cancellationToken)
|
public async Task<WeeklyCommissionPoolDto?> Handle(GetWeeklyCommissionPoolQuery request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var pool = await _context.WeeklyCommissionPools
|
var query = _context.WeeklyCommissionPools
|
||||||
.Include(x => x.WeekDefinition)
|
.Include(x => x.WeekDefinition)
|
||||||
|
.Include(x => x.Package)
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(x => x.WeekDefinitionId == request.WeekDefinitionId)
|
.Where(x => x.WeekDefinitionId == request.WeekDefinitionId);
|
||||||
|
|
||||||
|
if (request.PackageId.HasValue)
|
||||||
|
query = query.Where(x => x.PackageId == request.PackageId.Value);
|
||||||
|
|
||||||
|
var pool = await query
|
||||||
.Select(x => new WeeklyCommissionPoolDto
|
.Select(x => new WeeklyCommissionPoolDto
|
||||||
{
|
{
|
||||||
Id = x.Id,
|
Id = x.Id,
|
||||||
@@ -29,15 +35,12 @@ public class GetWeeklyCommissionPoolQueryHandler : IRequestHandler<GetWeeklyComm
|
|||||||
ValuePerBalance = x.ValuePerBalance,
|
ValuePerBalance = x.ValuePerBalance,
|
||||||
IsCalculated = x.IsCalculated,
|
IsCalculated = x.IsCalculated,
|
||||||
CalculatedAt = x.CalculatedAt,
|
CalculatedAt = x.CalculatedAt,
|
||||||
Created = x.Created
|
Created = x.Created,
|
||||||
|
PackageId = x.PackageId,
|
||||||
|
PackageTitle = x.Package != null ? x.Package.Title : string.Empty
|
||||||
})
|
})
|
||||||
.FirstOrDefaultAsync(cancellationToken);
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
// if (pool != null)
|
|
||||||
// {
|
|
||||||
// pool.WeekDisplayName = _weekDefinitionRepository.GetDisplayNameByGregorianWeekNumber(pool.WeekNumber);
|
|
||||||
// }
|
|
||||||
|
|
||||||
return pool ?? new WeeklyCommissionPoolDto();
|
return pool ?? new WeeklyCommissionPoolDto();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
@@ -14,4 +14,6 @@ public class WeeklyCommissionPoolDto
|
|||||||
public bool IsCalculated { get; set; }
|
public bool IsCalculated { get; set; }
|
||||||
public DateTime? CalculatedAt { get; set; }
|
public DateTime? CalculatedAt { get; set; }
|
||||||
public DateTimeOffset Created { get; set; }
|
public DateTimeOffset Created { get; set; }
|
||||||
|
public long PackageId { get; set; }
|
||||||
|
public string PackageTitle { get; set; } = string.Empty;
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-1
@@ -51,6 +51,15 @@ public class GetWithdrawalRequestsQueryHandler : IRequestHandler<GetWithdrawalRe
|
|||||||
.PaginatedListAsync(paginationState: request.PaginationState)
|
.PaginatedListAsync(paginationState: request.PaginationState)
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
// دریافت PackageName از آخرین خرید پکیج کاربران
|
||||||
|
var userIds = models.Select(x => x.UserId).Distinct().ToList();
|
||||||
|
var userPackageNames = await _context.UserPackagePurchases
|
||||||
|
.Where(p => userIds.Contains(p.UserId))
|
||||||
|
.Include(p => p.Package)
|
||||||
|
.GroupBy(p => p.UserId)
|
||||||
|
.Select(g => new { UserId = g.Key, PackageName = g.OrderByDescending(x => x.PurchasedAt).First().Package.Title })
|
||||||
|
.ToDictionaryAsync(x => x.UserId, x => x.PackageName, cancellationToken);
|
||||||
|
|
||||||
var result = models.Select(x => new WithdrawalRequestModel
|
var result = models.Select(x => new WithdrawalRequestModel
|
||||||
{
|
{
|
||||||
Id = x.Id,
|
Id = x.Id,
|
||||||
@@ -69,7 +78,8 @@ public class GetWithdrawalRequestsQueryHandler : IRequestHandler<GetWithdrawalRe
|
|||||||
BankReferenceId = x.BankReferenceId,
|
BankReferenceId = x.BankReferenceId,
|
||||||
BankTrackingCode = x.BankTrackingCode,
|
BankTrackingCode = x.BankTrackingCode,
|
||||||
PaymentFailureReason = x.PaymentFailureReason,
|
PaymentFailureReason = x.PaymentFailureReason,
|
||||||
Created = x.Created
|
Created = x.Created,
|
||||||
|
PackageName = userPackageNames.GetValueOrDefault(x.UserId)
|
||||||
}).ToList();
|
}).ToList();
|
||||||
|
|
||||||
return new GetWithdrawalRequestsResponseDto
|
return new GetWithdrawalRequestsResponseDto
|
||||||
|
|||||||
+1
@@ -25,4 +25,5 @@ public class WithdrawalRequestModel
|
|||||||
public string? BankTrackingCode { get; set; }
|
public string? BankTrackingCode { get; set; }
|
||||||
public string? PaymentFailureReason { get; set; }
|
public string? PaymentFailureReason { get; set; }
|
||||||
public DateTime Created { get; set; }
|
public DateTime Created { get; set; }
|
||||||
|
public string? PackageName { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,10 @@ public class LoggingBehaviour<TRequest> : IRequestPreProcessor<TRequest> where T
|
|||||||
{
|
{
|
||||||
var requestName = typeof(TRequest).Name;
|
var requestName = typeof(TRequest).Name;
|
||||||
var userId = _currentUserService.UserId ?? string.Empty;
|
var userId = _currentUserService.UserId ?? string.Empty;
|
||||||
_logger.LogInformation("Request: {Name} {@UserId} {@Request}",
|
var safeLog = request?.ToString() ?? "";
|
||||||
requestName, userId, request);
|
if (safeLog.Length > 2000)
|
||||||
|
safeLog = safeLog[..2000] + "... [TRUNCATED]";
|
||||||
|
_logger.LogInformation("Request: {Name} {UserId} {Request}",
|
||||||
|
requestName, userId, safeLog);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,20 @@
|
|||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace CMSMicroservice.Application.Common.Behaviours;
|
namespace CMSMicroservice.Application.Common.Behaviours;
|
||||||
|
|
||||||
public class PerformanceBehaviour<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
|
public partial class PerformanceBehaviour<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
|
||||||
where TRequest : IRequest<TResponse>
|
where TRequest : IRequest<TResponse>
|
||||||
{
|
{
|
||||||
private readonly Stopwatch _timer;
|
private readonly Stopwatch _timer;
|
||||||
private readonly ILogger<TRequest> _logger;
|
private readonly ILogger<TRequest> _logger;
|
||||||
private readonly ICurrentUserService _currentUserService;
|
private readonly ICurrentUserService _currentUserService;
|
||||||
|
|
||||||
|
[GeneratedRegex(@"ImageFile(?:Bytes|Mime|FileName)|ImageFile|File", RegexOptions.None)]
|
||||||
|
private static partial Regex BinaryPropertyPattern();
|
||||||
|
|
||||||
public PerformanceBehaviour(ILogger<TRequest> logger, ICurrentUserService currentUserService)
|
public PerformanceBehaviour(ILogger<TRequest> logger, ICurrentUserService currentUserService)
|
||||||
{
|
{
|
||||||
_timer = new Stopwatch();
|
_timer = new Stopwatch();
|
||||||
@@ -33,11 +37,20 @@ public class PerformanceBehaviour<TRequest, TResponse> : IPipelineBehavior<TRequ
|
|||||||
{
|
{
|
||||||
var requestName = typeof(TRequest).Name;
|
var requestName = typeof(TRequest).Name;
|
||||||
var userId = _currentUserService.UserId ?? string.Empty;
|
var userId = _currentUserService.UserId ?? string.Empty;
|
||||||
|
var safeLog = SanitizeForLog(request);
|
||||||
|
|
||||||
_logger.LogWarning("Long Running Request: {Name} ({ElapsedMilliseconds} milliseconds) {@UserId} {@Request}",
|
_logger.LogWarning("Long Running Request: {Name} ({ElapsedMilliseconds} milliseconds) {UserId} {Request}",
|
||||||
requestName, elapsedMilliseconds, userId, request);
|
requestName, elapsedMilliseconds, userId, safeLog);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string SanitizeForLog(TRequest request)
|
||||||
|
{
|
||||||
|
var text = request?.ToString() ?? "";
|
||||||
|
if (text.Length > 2000)
|
||||||
|
return text[..2000] + "... [TRUNCATED]";
|
||||||
|
return text;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,8 +22,11 @@ public class UnhandledExceptionBehaviour<TRequest, TResponse> : IPipelineBehavio
|
|||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
var requestName = typeof(TRequest).Name;
|
var requestName = typeof(TRequest).Name;
|
||||||
|
var safeLog = request?.ToString() ?? "";
|
||||||
|
if (safeLog.Length > 2000)
|
||||||
|
safeLog = safeLog[..2000] + "... [TRUNCATED]";
|
||||||
|
|
||||||
_logger.LogError(ex, "Request: Unhandled Exception for Request {Name} {@Request}", requestName, request);
|
_logger.LogError(ex, "Request: Unhandled Exception for Request {Name} {Request}", requestName, safeLog);
|
||||||
|
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
namespace CMSMicroservice.Application.Common.FileManager;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// سرویس مدیریت فایل — ذخیره روی دیسک، مسیر نسبی در دیتابیس
|
||||||
|
/// موقع واکشی: خواندن از دیسک و تبدیل به base64 data-URI
|
||||||
|
/// </summary>
|
||||||
|
public interface IFileManager
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// آپلود یک فایل خام به دیسک
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>نتیجه آپلود شامل مسیر نسبی فایل</returns>
|
||||||
|
/// <exception cref="FileUploadException">در صورت خطای آپلود</exception>
|
||||||
|
Task<UploadedFile> UploadAsync(
|
||||||
|
string directory,
|
||||||
|
byte[] fileBytes,
|
||||||
|
string mime,
|
||||||
|
string? fileName = null,
|
||||||
|
CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// آپلود تصویر با بهینهسازی خودکار + ساخت بندانگشتی
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>نتیجه آپلود شامل تصویر اصلی و بندانگشتی (مسیرهای نسبی)</returns>
|
||||||
|
/// <exception cref="FileUploadException">در صورت خطای آپلود</exception>
|
||||||
|
Task<UploadedImage> UploadImageAsync(
|
||||||
|
string directory,
|
||||||
|
byte[] fileBytes,
|
||||||
|
string mime,
|
||||||
|
string? fileName = null,
|
||||||
|
CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// حذف فایل از دیسک
|
||||||
|
/// </summary>
|
||||||
|
Task DeleteAsync(long fileId, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// خواندن فایل از دیسک و تبدیل به base64 data-URI
|
||||||
|
/// اگر مسیر از قبل data: باشد، همان را برمیگرداند
|
||||||
|
/// اگر فایل وجود نداشته باشد، رشته خالی برمیگرداند
|
||||||
|
/// </summary>
|
||||||
|
string ResolveImageUrl(string? path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// نتیجه آپلود فایل
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Id">شناسه فایل در FMS</param>
|
||||||
|
/// <param name="Path">مسیر فایل ذخیرهشده (مثلاً /Images/Products/abc.jpg)</param>
|
||||||
|
public sealed record UploadedFile(long Id, string Path);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// نتیجه آپلود تصویر — شامل تصویر اصلی و بندانگشتی
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Main">تصویر اصلی بهینهشده</param>
|
||||||
|
/// <param name="Thumbnail">تصویر بندانگشتی</param>
|
||||||
|
public sealed record UploadedImage(UploadedFile Main, UploadedFile Thumbnail);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// خطای آپلود فایل
|
||||||
|
/// </summary>
|
||||||
|
public class FileUploadException : Exception
|
||||||
|
{
|
||||||
|
public FileUploadException(string message) : base(message) { }
|
||||||
|
public FileUploadException(string message, Exception inner) : base(message, inner) { }
|
||||||
|
}
|
||||||
@@ -35,6 +35,7 @@ public interface IUserNotificationService
|
|||||||
long userId,
|
long userId,
|
||||||
decimal amount,
|
decimal amount,
|
||||||
int weekNumber,
|
int weekNumber,
|
||||||
|
string? packageName = null,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -42,6 +43,7 @@ public interface IUserNotificationService
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
Task SendClubActivationNotificationAsync(
|
Task SendClubActivationNotificationAsync(
|
||||||
long userId,
|
long userId,
|
||||||
|
string? packageName = null,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -50,5 +52,6 @@ public interface IUserNotificationService
|
|||||||
Task SendPayoutErrorNotificationAsync(
|
Task SendPayoutErrorNotificationAsync(
|
||||||
long userId,
|
long userId,
|
||||||
string errorMessage,
|
string errorMessage,
|
||||||
|
string? packageName = null,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using CMSMicroservice.Domain.Entities.Blog;
|
||||||
|
using CMSMicroservice.Domain.Entities.Content;
|
||||||
using CMSMicroservice.Domain.Entities.Payment;
|
using CMSMicroservice.Domain.Entities.Payment;
|
||||||
using CMSMicroservice.Domain.Entities.Order;
|
using CMSMicroservice.Domain.Entities.Order;
|
||||||
using CMSMicroservice.Domain.Entities.DiscountShop;
|
using CMSMicroservice.Domain.Entities.DiscountShop;
|
||||||
@@ -30,11 +32,16 @@ public interface IApplicationDbContext
|
|||||||
DbSet<OrderVAT> OrderVATs { get; }
|
DbSet<OrderVAT> OrderVATs { get; }
|
||||||
DbSet<UserPackagePurchase> UserPackagePurchases { get; }
|
DbSet<UserPackagePurchase> UserPackagePurchases { get; }
|
||||||
DbSet<UserWallet> UserWallets { get; }
|
DbSet<UserWallet> UserWallets { get; }
|
||||||
DbSet<UserWalletChangeLog> UserWalletChangeLogs { get; }
|
DbSet<UserWalletHistory> UserWalletHistories { get; }
|
||||||
DbSet<ManualPayment> ManualPayments { get; }
|
DbSet<ManualPayment> ManualPayments { get; }
|
||||||
|
DbSet<PaymentTransaction> PaymentTransactions { get; }
|
||||||
DbSet<PublicMessage> PublicMessages { get; }
|
DbSet<PublicMessage> PublicMessages { get; }
|
||||||
DbSet<ClubMembership> ClubMemberships { get; }
|
DbSet<ClubMembership> ClubMemberships { get; }
|
||||||
DbSet<ClubMembershipHistory> ClubMembershipHistories { get; }
|
DbSet<ClubMembershipHistory> ClubMembershipHistories { get; }
|
||||||
|
DbSet<ClubMembershipCycle> ClubMembershipCycles { get; }
|
||||||
|
DbSet<ClubMembershipCycleHistory> ClubMembershipCycleHistories { get; }
|
||||||
|
DbSet<PackageHistory> PackageHistories { get; }
|
||||||
|
DbSet<PackageFeature> PackageFeatures { get; }
|
||||||
DbSet<ClubFeature> ClubFeatures { get; }
|
DbSet<ClubFeature> ClubFeatures { get; }
|
||||||
DbSet<UserClubFeature> UserClubFeatures { get; }
|
DbSet<UserClubFeature> UserClubFeatures { get; }
|
||||||
DbSet<NetworkWeeklyBalance> NetworkWeeklyBalances { get; }
|
DbSet<NetworkWeeklyBalance> NetworkWeeklyBalances { get; }
|
||||||
@@ -66,6 +73,21 @@ public interface IApplicationDbContext
|
|||||||
DbSet<InventoryItem> InventoryItems { get; }
|
DbSet<InventoryItem> InventoryItems { get; }
|
||||||
DbSet<StockMovement> StockMovements { get; }
|
DbSet<StockMovement> StockMovements { get; }
|
||||||
|
|
||||||
|
// ============= Blog =============
|
||||||
|
DbSet<BlogPost> BlogPosts { get; }
|
||||||
|
DbSet<BlogCategory> BlogCategories { get; }
|
||||||
|
DbSet<BlogPostCategory> BlogPostCategories { get; }
|
||||||
|
DbSet<BlogPostTag> BlogPostTags { get; }
|
||||||
|
DbSet<BlogPostImage> BlogPostImages { get; }
|
||||||
|
|
||||||
|
// ============= Content Management =============
|
||||||
|
DbSet<SitePage> SitePages { get; }
|
||||||
|
DbSet<SitePageSection> SitePageSections { get; }
|
||||||
|
|
||||||
|
// ============= Site Page Settings (Simplified) =============
|
||||||
|
DbSet<SitePageSettings> SitePageSettingsEntities { get; }
|
||||||
|
DbSet<SitePageImage> SitePageImages { get; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// دسترسی به DatabaseFacade برای اجرای raw SQL و Stored Procedures
|
/// دسترسی به DatabaseFacade برای اجرای raw SQL و Stored Procedures
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -11,11 +11,13 @@ public interface ICommissionCalculationStrategy
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="weekDefinitionId">شناسه هفته</param>
|
/// <param name="weekDefinitionId">شناسه هفته</param>
|
||||||
/// <param name="forceRecalculate">محاسبه مجدد حتی اگر قبلاً محاسبه شده</param>
|
/// <param name="forceRecalculate">محاسبه مجدد حتی اگر قبلاً محاسبه شده</param>
|
||||||
|
/// <param name="packageId">اگر مقدار داشته باشد فقط این پکیج محاسبه میشود — در غیر این صورت همه پکیجها</param>
|
||||||
/// <param name="cancellationToken">توکن لغو</param>
|
/// <param name="cancellationToken">توکن لغو</param>
|
||||||
/// <returns>تعداد رکوردهای محاسبه شده</returns>
|
/// <returns>تعداد رکوردهای محاسبه شده</returns>
|
||||||
Task<int> CalculateWeeklyBalancesAsync(
|
Task<int> CalculateWeeklyBalancesAsync(
|
||||||
long weekDefinitionId,
|
long weekDefinitionId,
|
||||||
bool forceRecalculate,
|
bool forceRecalculate,
|
||||||
|
long? packageId = null,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -23,11 +25,13 @@ public interface ICommissionCalculationStrategy
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="weekDefinitionId">شناسه هفته</param>
|
/// <param name="weekDefinitionId">شناسه هفته</param>
|
||||||
/// <param name="forceRecalculate">محاسبه مجدد حتی اگر قبلاً محاسبه شده</param>
|
/// <param name="forceRecalculate">محاسبه مجدد حتی اگر قبلاً محاسبه شده</param>
|
||||||
|
/// <param name="packageId">اگر مقدار داشته باشد فقط این پکیج محاسبه میشود — در غیر این صورت همه پکیجها</param>
|
||||||
/// <param name="cancellationToken">توکن لغو</param>
|
/// <param name="cancellationToken">توکن لغو</param>
|
||||||
/// <returns>شناسه Pool ایجاد شده</returns>
|
/// <returns>شناسه Pool ایجاد شده</returns>
|
||||||
Task<long> CalculateWeeklyCommissionPoolAsync(
|
Task<long> CalculateWeeklyCommissionPoolAsync(
|
||||||
long weekDefinitionId,
|
long weekDefinitionId,
|
||||||
bool forceRecalculate,
|
bool forceRecalculate,
|
||||||
|
long? packageId = null,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
namespace CMSMicroservice.Application.Common.Interfaces;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Service for uploading files to FMS (File Management Service)
|
|
||||||
/// </summary>
|
|
||||||
public interface IFileManagementService
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Uploads a file to FMS and returns the stored file path
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="directory">Target directory path (e.g. "Images/Products")</param>
|
|
||||||
/// <param name="fileBytes">Raw file bytes</param>
|
|
||||||
/// <param name="mime">MIME type (e.g. "image/jpeg")</param>
|
|
||||||
/// <param name="fileName">Original file name</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>The stored file path returned by FMS, or null if upload failed</returns>
|
|
||||||
Task<string?> UploadFileAsync(string directory, byte[] fileBytes, string mime, string? fileName, CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Uploads an image to FMS with optimization (resize + compress)
|
|
||||||
/// Returns both main image path and thumbnail path
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="directory">Target directory path (e.g. "Images/Products")</param>
|
|
||||||
/// <param name="fileBytes">Raw image bytes</param>
|
|
||||||
/// <param name="mime">MIME type (e.g. "image/jpeg")</param>
|
|
||||||
/// <param name="fileName">Original file name</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>Tuple of (mainImagePath, thumbnailPath), either can be null if upload failed</returns>
|
|
||||||
Task<(string? MainImagePath, string? ThumbnailPath)> UploadImageWithThumbnailAsync(
|
|
||||||
string directory, byte[] fileBytes, string mime, string? fileName,
|
|
||||||
CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Deletes a file from FMS by its ID
|
|
||||||
/// </summary>
|
|
||||||
Task<bool> DeleteFileAsync(long fileId, CancellationToken cancellationToken = default);
|
|
||||||
}
|
|
||||||
@@ -13,5 +13,10 @@ public interface IKavenegarService
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// ارسال پیامک با قالب (VerifyLookup)
|
/// ارسال پیامک با قالب (VerifyLookup)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task VerifyLookupAsync(string mobile, string token, string template = "Afrino");
|
Task VerifyLookupAsync(string mobile, string token, string template = "OTP");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ارسال پیامک با قالب و توکن دوم (VerifyLookup) — مثلاً برای قراردادها
|
||||||
|
/// </summary>
|
||||||
|
Task VerifyLookupAsync(string mobile, string token, string token2, string template);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,26 @@ public interface IPaymentGatewayService
|
|||||||
string verificationToken,
|
string verificationToken,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// تأیید پرداخت با مبلغ — برای درگاههایی مثل زرینپال که مبلغ را در Verify نیاز دارند
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="refId">شماره مرجع تراکنش (Authority در زرینپال)</param>
|
||||||
|
/// <param name="verificationToken">توکن تأیید از درگاه (Status در زرینپال)</param>
|
||||||
|
/// <param name="amountInToman">مبلغ تراکنش به تومان</param>
|
||||||
|
/// <param name="cancellationToken"></param>
|
||||||
|
/// <returns>وضعیت نهایی تراکنش</returns>
|
||||||
|
Task<PaymentVerificationResult> VerifyPaymentAsync(
|
||||||
|
string refId,
|
||||||
|
string verificationToken,
|
||||||
|
decimal amountInToman,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
// ⚠️ هشدار: این پیادهسازی پیشفرض مبلغ را نادیده میگیرد.
|
||||||
|
// درگاههایی مثل زرینپال باید حتماً این متد را override کنند.
|
||||||
|
throw new NotImplementedException(
|
||||||
|
"درگاه پرداخت باید متد VerifyPaymentAsync با مبلغ را پیادهسازی کند");
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// واریز مبلغ به حساب کاربر (برداشت از کیف پول)
|
/// واریز مبلغ به حساب کاربر (برداشت از کیف پول)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -124,6 +144,21 @@ public class PaymentVerificationResult
|
|||||||
/// پیام
|
/// پیام
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string? Message { get; set; }
|
public string? Message { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// شماره کارت ماسکشده (مثلاً 6037-****-****-1234)
|
||||||
|
/// </summary>
|
||||||
|
public string? CardPan { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// هش کارت بانکی
|
||||||
|
/// </summary>
|
||||||
|
public string? CardHash { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// کد وضعیت verify از درگاه (100=موفق، 101=تکراری)
|
||||||
|
/// </summary>
|
||||||
|
public int? VerificationCode { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
using CMSMicroservice.Application.UserWalletChangeLogCQ.Queries.GetAllUserWalletChangeLogByFilter;
|
|
||||||
using CMSMicroservice.Application.UserWalletChangeLogCQ.Queries.GetUserWalletChangeLog;
|
|
||||||
|
|
||||||
namespace CMSMicroservice.Application.Common.Mappings;
|
|
||||||
|
|
||||||
public class UserWalletChangeLogProfile : IRegister
|
|
||||||
{
|
|
||||||
void IRegister.Register(TypeAdapterConfig config)
|
|
||||||
{
|
|
||||||
config.NewConfig<UserWalletChangeLog,GetAllUserWalletChangeLogByFilterResponseModel>()
|
|
||||||
.Map(dest => dest.CreatedAt, src => src.Created);
|
|
||||||
|
|
||||||
config.NewConfig<UserWalletChangeLog, GetUserWalletChangeLogResponseDto>()
|
|
||||||
.Map(dest => dest.CreatedAt, src => src.Created);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
using CMSMicroservice.Application.UserWalletHistoryCQ.Queries.GetAllUserWalletHistoryByFilter;
|
||||||
|
using CMSMicroservice.Application.UserWalletHistoryCQ.Queries.GetUserWalletHistory;
|
||||||
|
|
||||||
|
namespace CMSMicroservice.Application.Common.Mappings;
|
||||||
|
|
||||||
|
public class UserWalletHistoryProfile : IRegister
|
||||||
|
{
|
||||||
|
void IRegister.Register(TypeAdapterConfig config)
|
||||||
|
{
|
||||||
|
config.NewConfig<UserWalletHistory,GetAllUserWalletHistoryByFilterResponseModel>()
|
||||||
|
.Map(dest => dest.CreatedAt, src => src.Created);
|
||||||
|
|
||||||
|
config.NewConfig<UserWalletHistory, GetUserWalletHistoryResponseDto>()
|
||||||
|
.Map(dest => dest.CreatedAt, src => src.Created);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,14 +6,14 @@ namespace CMSMicroservice.Application.Common.Services;
|
|||||||
public static class VatCalculator
|
public static class VatCalculator
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// نرخ VAT ایران - 9 درصد
|
/// نرخ VAT ایران - 10 درصد
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public const decimal VAT_RATE = 0.09m;
|
public const decimal VAT_RATE = 0.10m;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// نرخ VAT به صورت درصد (9)
|
/// نرخ VAT به صورت درصد (10)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public const int VAT_PERCENT = 9;
|
public const int VAT_PERCENT = 10;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// محاسبه VAT از مبلغ خالص
|
/// محاسبه VAT از مبلغ خالص
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user