Compare commits
315 Commits
stage_new
...
c814cd9118
| Author | SHA1 | Date | |
|---|---|---|---|
| c814cd9118 | |||
| 2d23dbc798 | |||
| 8ec0910cdf | |||
| e882f09955 | |||
| 436e5d7aca | |||
| 6a04a6a270 | |||
| 427d2cce1d | |||
| fb9e3d0109 | |||
| 6c90e4caf0 | |||
| 40da1bd8df | |||
| 053c5a7c4f | |||
| 3810146651 | |||
| 82944e11d0 | |||
| ce98efdd68 | |||
| d232399f43 | |||
| bb20ad2c4e | |||
| 246d893b62 | |||
| 99aab3beff | |||
| 728f28f525 | |||
| 89af07c122 | |||
| 58aeddf004 | |||
| 6c3fbe43b1 | |||
| 7570c39e65 | |||
| 46ac1d817e | |||
| db20d95b43 | |||
| f8794bbb63 | |||
| 19be5dc9dc | |||
| 6395f63ab5 | |||
| 758640cce3 | |||
| 5c1e3cf601 | |||
| 37820ea257 | |||
| 5f8e30905d | |||
| d82b7c28a8 | |||
| 02b2966e96 | |||
| 29fa7ff435 | |||
| 3df738e496 | |||
| dba0b63a70 | |||
| 3a9c3438e6 | |||
| 541b0c2ce9 | |||
| 12ebdc9e87 | |||
| 121291eeed | |||
| 9f1bc2bcdb | |||
| d22eb1617f | |||
| f08d3ac78f | |||
| 354ba1dd1b | |||
| 4f2d313d5e | |||
| 034679d915 | |||
| 3b9958a700 | |||
| 9c562b12c7 | |||
| 6414e5663a | |||
| 3c9957463d | |||
| 90d4f65de4 | |||
| 4df71107a9 | |||
| bc52240a41 | |||
| a7fc959d3e | |||
| aac0d4271d | |||
| d96558aa4b | |||
| da1a2fcf8e | |||
| a2d13c72c3 | |||
| 4e8caa6e2c | |||
| cdd124ac25 | |||
| 4d7932f335 | |||
| 18a2299d93 | |||
| 37a017998c | |||
| 6e60ca7c25 | |||
| fd8b37a6b1 | |||
| 683ed370b5 | |||
| 16acb8f3b5 | |||
| 98c2875ae2 | |||
| 34d2354fd4 | |||
| 65db839642 | |||
| f7d6b0e3e7 | |||
| be2b22c50a | |||
| b905c308c1 | |||
| d09db638cc | |||
| 41e7f29a0d | |||
| c4409491c1 | |||
| e4279f3d05 | |||
| b260fd56fa | |||
| c5dd1a2ef5 | |||
| 1e3ba03c5c | |||
| eeb837f32e | |||
| ee7d29015f | |||
| 624ab7ea9f | |||
| d8a3b49f4d | |||
| b053d86006 | |||
| 15044c66cb | |||
| 8518c7b3ec | |||
| 0983cdf88f | |||
| 789aa0b870 | |||
| b4e7902870 | |||
| aa7f77bfec | |||
| a4d04cb7bf | |||
| 85591aec30 | |||
| e93fad3b3d | |||
| 04e1a83838 | |||
| b8120dd1b9 | |||
| 7ce5539793 | |||
| 4a7df2083e | |||
| 9a4f1e6401 | |||
| e77b5f9658 | |||
| b71ddfaf9b | |||
| 4d6d77531d | |||
| 091a04f3aa | |||
| a35e2eeb92 | |||
| 0a66b09e7b | |||
| e946c56631 | |||
| 370f735cf2 | |||
| 0e9d0cad2f | |||
| 62a0f8594d | |||
| fb81aff095 | |||
| 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 | |||
| 491b8a0d0f | |||
| cbaa20f339 | |||
| be80136ae8 | |||
| 5e9984f0e5 | |||
| 1d07ae4f71 | |||
| e206b71186 | |||
| 775a9bd75d | |||
| 2620a24597 | |||
| def3b94d89 | |||
| 0457ef6c7c | |||
| f3ac5ad7df | |||
| 3ebe0f9f48 | |||
| e72673c184 | |||
| f4e24f7449 | |||
| 9288d0640b | |||
| 540a2ce4fc | |||
| de83c31346 | |||
| e57f01965a | |||
| f8dc4abd04 | |||
| f97c4b0812 | |||
| 2d6c95e6ee | |||
| fd32e15192 | |||
| e41747afe0 | |||
| 68da3f45a3 | |||
| 4456e504d4 | |||
| 3153fd8a74 | |||
| 8cdb197ee7 | |||
| cec1e74f6c | |||
| dd9ac48474 | |||
| 2ca3fd29f9 | |||
| eb1b249869 | |||
| b9a72b4589 | |||
| 5c63dc6e38 | |||
| 52f6af2091 | |||
| e3fbd45739 | |||
| 0fe4db5e77 | |||
| 305b8d3fd8 | |||
| df976b7b80 | |||
| b90aa50fac | |||
| 58d419ced3 | |||
| e8e964f9a0 | |||
| 9050c00297 | |||
| bdd2c51726 | |||
| 2a569a024f | |||
| 04e8c49fa7 | |||
| f968a6c005 | |||
| 6048824b33 | |||
| 62a247f097 | |||
| bc710fdb7b | |||
| fd24dcebcd | |||
| 89e146bf32 | |||
| af12f996f1 | |||
| 27d995faf4 | |||
| 3b0d9eadd3 | |||
| db07838ecb | |||
| 167991890c | |||
| ef1716e243 | |||
| c2ccd32f39 | |||
| c435319025 | |||
| 18a65de8c7 | |||
| e2e09ac4e8 | |||
| 61a0d68b7c | |||
| 1c4beb5f05 | |||
| 163a0a2f1a | |||
| e77611012b | |||
| a39a36e66d | |||
| 207f53ef80 | |||
| 2502cbbda2 | |||
| 5a4e4a960d | |||
| dc1e00f514 | |||
| 9d929dc7fb | |||
| 9b7d61593b | |||
| e3e3e8cec4 | |||
| 2885ff20c0 | |||
| f2a0b4d211 | |||
| 6c9f3dd671 | |||
| d1ca72300d | |||
| b42d9e141d | |||
| f64b6be7da | |||
| 9185aa227d | |||
| b2d676b555 | |||
| b41342dcad | |||
| c3eeb16856 | |||
| 794dd01ac0 | |||
| 658d076bdf | |||
| 1a425f0d93 | |||
| 96daf899c7 | |||
| eddc82dfce | |||
| 0c95b03f0b | |||
| 45c01fc157 | |||
| 55a775f51c | |||
| 5b0558ee24 | |||
| cabf3886de | |||
| 0baed05fd4 | |||
| 7c4c978d3f | |||
| e327eb23ba | |||
| 20280cf07f | |||
| c67caca0ae | |||
| e14a653fa8 | |||
| 4968878773 | |||
| 8ffb53996b | |||
| 1a01fd9057 | |||
| 52af1bfaad | |||
| 2fd5300c1e | |||
| c76af47a4c | |||
| 77be841221 | |||
| 4743b52cae | |||
| 0b0fa3aa61 | |||
| 839250e132 | |||
| 8894e72a8e | |||
| 1c130b14e1 | |||
| 793d81f1dd | |||
| f6704eaa99 | |||
| d03fbe0a98 | |||
| f9c90bb871 | |||
| 70c4b15a23 | |||
| 5e662b5984 | |||
| 16ba2fa6fb | |||
| 389568af2b | |||
| 98f3036bd0 | |||
| 631646776e | |||
| d8031a0d17 | |||
| ca9b02a902 | |||
| dde4c68b2f | |||
| 8c3d710253 | |||
| a81fb39a32 | |||
| f8731bc7fc | |||
| d3d021c007 | |||
| bea43a0569 | |||
| 1cf501711f | |||
| f0117eb1d5 | |||
| 6f8aefc2ce | |||
| 500e169141 | |||
| ed45a1759b | |||
| 2bd5c7db78 | |||
| 7087df23ef | |||
| 6374d5c343 | |||
| 1dbb3ee502 | |||
| 0728ec2b76 | |||
| 1425fb187b | |||
| cd2549775a | |||
| 81d2b39ee1 | |||
| fce194a195 | |||
| 322242fbab | |||
| 3d42c91d21 | |||
| 9448f3fff0 | |||
| 02e2f8111f | |||
| 9d2b5ad2d4 | |||
| 8940362575 | |||
| bca3b7fe62 | |||
| c03b3705c3 | |||
| a9140bec04 | |||
| 17c2958b9c | |||
| 2e54aa7aca | |||
| 7b26073c63 | |||
| 28e94d6137 | |||
| c6ee356cbd | |||
| 2d09a69be9 | |||
| 7cebb27171 | |||
| 68489a8374 | |||
| 330f0cae5a | |||
| b16f01b6e4 | |||
| 13f24d523b | |||
| 41308e189a | |||
| 8953d86755 | |||
| ef8d5d4d97 | |||
| 288b85a59b | |||
| 30144a07c8 | |||
| 5bcf6c9840 | |||
| 9d51e70a7f | |||
| a31c88219f | |||
| ceeb29d843 | |||
| 0ba6af9049 | |||
| 5ad69cb3fa |
@@ -0,0 +1,2 @@
|
||||
# Keep production config from current branch during merges — never overwrite
|
||||
src/CMSMicroservice.WebApi/appsettings.Production.json merge=ours
|
||||
@@ -1,46 +0,0 @@
|
||||
|
||||
name: Push nuget and docker image Actions Workflow
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- stage_new
|
||||
jobs:
|
||||
Deploy:
|
||||
runs-on: windows
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: https://git.afrino.co/actions/checkout@v3
|
||||
- name: Setup dotnet
|
||||
uses: https://git.afrino.co/actions/setup-dotnet@v3
|
||||
with:
|
||||
dotnet-version: 7.0.x
|
||||
|
||||
- name: Remove Package Source
|
||||
run: dotnet nuget remove source FourSat
|
||||
continue-on-error: true
|
||||
- name: Add Package Source
|
||||
run: dotnet nuget add source --name FourSat --username systemuser --password sZSA7PTiv3pUSQZ https://git.afrino.co/api/packages/FourSat/nuget/index.json --store-password-in-clear-text
|
||||
|
||||
- name: Install dependencies
|
||||
run: dotnet restore ".\src\CMSMicroservice.WebApi\CMSMicroservice.WebApi.csproj"
|
||||
- name: Build
|
||||
run: dotnet build ".\src\CMSMicroservice.WebApi\CMSMicroservice.WebApi.csproj" --configuration Release --no-restore
|
||||
- name: Test
|
||||
run: dotnet test ".\src\CMSMicroservice.WebApi\CMSMicroservice.WebApi.csproj" --no-restore --verbosity normal
|
||||
- name: Recycle Apppool
|
||||
run: |
|
||||
& "C:\Windows\System32\inetsrv\appcmd.exe" recycle apppool /apppool.name:cms.kbs1.ir
|
||||
shell: powershell
|
||||
- name: Stop Website
|
||||
run: |
|
||||
& "C:\Windows\System32\inetsrv\appcmd.exe" stop site /site.name:cms.kbs1.ir
|
||||
shell: powershell
|
||||
- name: Publish
|
||||
run: dotnet publish ".\src\CMSMicroservice.WebApi\CMSMicroservice.WebApi.csproj" -c Release -o publish
|
||||
- name: Copy Publish To IIS Directory
|
||||
run: Get-ChildItem -Path "publish\*" | Copy-Item -Destination "E:\kbs1.ir\cms.kbs1.ir\" -Recurse -Force
|
||||
- name: Start Website
|
||||
run: |
|
||||
& "C:\Windows\System32\inetsrv\appcmd.exe" start site /site.name:cms.kbs1.ir
|
||||
shell: powershell
|
||||
@@ -6,81 +6,98 @@ on:
|
||||
- kub-stage
|
||||
|
||||
env:
|
||||
REGISTRY: git.foursat.afrino.co
|
||||
REGISTRY: 194.5.195.53:30080
|
||||
IMAGE_NAME: admin/cms
|
||||
K8S_SERVER: 194.5.195.53
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: docker:latest
|
||||
image: 194.5.195.53:32082/docker-sshpass:latest
|
||||
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,194.5.195.53,10.0.0.0/8
|
||||
steps:
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
apk add --no-cache git curl
|
||||
|
||||
# Install kubectl
|
||||
curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"
|
||||
chmod +x kubectl
|
||||
mv kubectl /usr/local/bin/
|
||||
|
||||
- name: Start Docker daemon with insecure registry
|
||||
- name: Start Docker daemon
|
||||
run: |
|
||||
mkdir -p /etc/docker
|
||||
cat > /etc/docker/daemon.json << 'DAEMON'
|
||||
{
|
||||
"insecure-registries": ["git.foursat.afrino.co", "gitea-svc:3000"]
|
||||
"insecure-registries": ["194.5.195.53:30080", "194.5.195.53:32500", "194.5.195.53:32082"]
|
||||
}
|
||||
DAEMON
|
||||
mkdir -p ~/.docker
|
||||
cat > ~/.docker/config.json << 'CONF'
|
||||
{
|
||||
"proxies": {
|
||||
"default": {
|
||||
"httpProxy": "http://proxyuser:87zH26nbqT2@46.249.98.211:3128",
|
||||
"httpsProxy": "http://proxyuser:87zH26nbqT2@46.249.98.211:3128",
|
||||
"noProxy": "localhost,127.0.0.1,gitea-svc,194.5.195.53,10.0.0.0/8"
|
||||
}
|
||||
}
|
||||
}
|
||||
CONF
|
||||
dockerd &
|
||||
for i in $(seq 1 30); do
|
||||
docker info >/dev/null 2>&1 && break || sleep 2
|
||||
echo "🚀 Starting Docker daemon..."
|
||||
dockerd --iptables=false --ip6tables=false --bridge=none --storage-driver=vfs &
|
||||
|
||||
# Wait up to 3 minutes for Docker to be ready
|
||||
for i in $(seq 1 90); do
|
||||
if docker info >/dev/null 2>&1; then
|
||||
echo "✅ Docker daemon is ready (attempt $i)"
|
||||
docker version
|
||||
break
|
||||
else
|
||||
echo "⏳ Waiting for Docker daemon... (attempt $i/90)"
|
||||
sleep 2
|
||||
fi
|
||||
done
|
||||
docker info
|
||||
|
||||
|
||||
# Final check
|
||||
if ! docker info >/dev/null 2>&1; then
|
||||
echo "❌ Docker daemon failed to start after 3 minutes"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Checkout code
|
||||
run: |
|
||||
git clone --depth 1 --branch kub-stage 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 -e CI=true -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 "foursat-hosted" \
|
||||
--api-key "admin:87zH26nbqT" \
|
||||
--skip-duplicate --configfile src/NuGet.config || true
|
||||
done
|
||||
done
|
||||
'
|
||||
echo "✅ Protobuf packages done!"
|
||||
|
||||
- name: Build Docker Image
|
||||
run: |
|
||||
docker build -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} \
|
||||
-t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest \
|
||||
--build-arg HTTP_PROXY=http://proxyuser:87zH26nbqT2@46.249.98.211:3128 \
|
||||
--build-arg HTTPS_PROXY=http://proxyuser:87zH26nbqT2@46.249.98.211:3128 \
|
||||
.
|
||||
DOCKER_BUILDKIT=0 docker build --network host -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest .
|
||||
|
||||
- name: Push to Registry
|
||||
run: |
|
||||
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login ${{ env.REGISTRY }} -u admin --password-stdin
|
||||
docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
|
||||
docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||
|
||||
|
||||
- name: Deploy to Kubernetes
|
||||
run: |
|
||||
# Setup kubeconfig
|
||||
mkdir -p ~/.kube
|
||||
echo "${{ secrets.KUBECONFIG }}" | base64 -d > ~/.kube/config
|
||||
export SSHPASS="${{ secrets.SERVER_PASSWORD }}"
|
||||
|
||||
# Restart deployment to pull new image
|
||||
kubectl rollout restart deployment/cms || echo "Deployment doesn't exist yet"
|
||||
# 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
|
||||
|
||||
# Wait for rollout to complete
|
||||
kubectl rollout status deployment/cms --timeout=5m || echo "Deployment rollout pending"
|
||||
# Apply config (Secret) first, then deployment
|
||||
sshpass -e ssh -o StrictHostKeyChecking=no root@${{ env.K8S_SERVER }} "
|
||||
kubectl apply -f /tmp/cms-config.yaml &&
|
||||
kubectl apply -f /tmp/cms-deployment.yaml &&
|
||||
crictl rmi ${REGISTRY}/${IMAGE_NAME}:latest 2>/dev/null || true &&
|
||||
kubectl rollout restart deployment/cms &&
|
||||
kubectl rollout status deployment/cms --timeout=180s &&
|
||||
rm -f /tmp/cms-config.yaml /tmp/cms-deployment.yaml
|
||||
"
|
||||
echo "✅ Deployed!"
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
name: Build and Deploy to Production
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- production
|
||||
|
||||
env:
|
||||
REGISTRY: 194.5.195.53:30080
|
||||
IMAGE_NAME: admin/cms
|
||||
K8S_SERVER: 45.149.79.127
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: 194.5.195.53:32082/docker-sshpass:latest
|
||||
options: --privileged
|
||||
steps:
|
||||
- name: Start Docker daemon
|
||||
run: |
|
||||
mkdir -p /etc/docker
|
||||
cat > /etc/docker/daemon.json << 'DAEMON'
|
||||
{
|
||||
"insecure-registries": ["194.5.195.53:30080", "194.5.195.53:32500", "194.5.195.53:32082"]
|
||||
}
|
||||
DAEMON
|
||||
echo "🚀 Starting Docker daemon..."
|
||||
dockerd --iptables=false --ip6tables=false --bridge=none --storage-driver=vfs &
|
||||
|
||||
for i in $(seq 1 90); do
|
||||
if docker info >/dev/null 2>&1; then
|
||||
echo "✅ Docker daemon is ready (attempt $i)"
|
||||
docker version
|
||||
break
|
||||
else
|
||||
echo "⏳ Waiting for Docker daemon... (attempt $i/90)"
|
||||
sleep 2
|
||||
fi
|
||||
done
|
||||
|
||||
if ! docker info >/dev/null 2>&1; then
|
||||
echo "❌ Docker daemon failed to start"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Checkout code
|
||||
run: |
|
||||
git clone --depth 1 --branch production http://gitea-svc:3000/admin/CMS.git .
|
||||
|
||||
- name: 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
|
||||
run: |
|
||||
DOCKER_BUILDKIT=0 docker build --network host \
|
||||
-t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} \
|
||||
-t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:prod \
|
||||
.
|
||||
|
||||
- name: Push to Registry
|
||||
run: |
|
||||
docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
|
||||
docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:prod
|
||||
|
||||
- name: Deploy to Production
|
||||
run: |
|
||||
export SSHPASS="${{ secrets.SERVER_PASSWORD }}"
|
||||
|
||||
# Copy K8s manifests to server
|
||||
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
|
||||
|
||||
# Apply config (Secret) first, then deployment and update image
|
||||
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!"
|
||||
@@ -492,3 +492,8 @@ fabric.properties
|
||||
.idea/caches/build_file_checksums.ser
|
||||
|
||||
/src/.idea
|
||||
|
||||
# Environment-specific configuration files with sensitive data
|
||||
**/appsettings.Staging.json
|
||||
**/appsettings.Production.json
|
||||
**/appsettings.*.local.json
|
||||
|
||||
+7
-4
@@ -1,14 +1,17 @@
|
||||
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
|
||||
FROM 194.5.195.53:32082/dotnet/sdk:9.0 AS build
|
||||
WORKDIR /src
|
||||
|
||||
# Copy NuGet config first
|
||||
COPY src/NuGet.config ./
|
||||
|
||||
# Copy solution and project files
|
||||
COPY src/ ./
|
||||
|
||||
# Restore and publish
|
||||
RUN dotnet restore "CMSMicroservice.WebApi/CMSMicroservice.WebApi.csproj"
|
||||
# Restore with Nexus config and publish
|
||||
RUN dotnet restore "CMSMicroservice.WebApi/CMSMicroservice.WebApi.csproj" --configfile NuGet.config
|
||||
RUN dotnet publish "CMSMicroservice.WebApi/CMSMicroservice.WebApi.csproj" -c Release -o /app/publish --no-restore
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS runtime
|
||||
FROM 194.5.195.53:32082/dotnet/aspnet:9.0 AS runtime
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/publish .
|
||||
ENV ASPNETCORE_URLS=http://+:8080
|
||||
|
||||
@@ -1,259 +1,4 @@
|
||||
# CMS Microservice - Network & Club Commission System
|
||||
# CMS Microservice
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[]()
|
||||
|
||||
## 📊 Project Status (2025-12-01)
|
||||
|
||||
**Overall Progress**: 85% Complete (7/10 phases)
|
||||
**Production Readiness**: 95%
|
||||
**MVP Status**: ✅ 100% Complete
|
||||
|
||||
### ✅ Completed Phases (7)
|
||||
1. ✅ Domain Layer (Entities, Enums, Value Objects)
|
||||
2. ✅ Club Membership System
|
||||
3. ✅ Binary Network Tree
|
||||
4. ✅ **Commission Calculation & Background Worker** (MVP)
|
||||
5. ✅ Protobuf gRPC Services
|
||||
6. ✅ History & Configuration Management
|
||||
7. ✅ Database Migration & Seed Data
|
||||
|
||||
### 🟡 Partially Complete (1)
|
||||
- Phase 10: Withdrawal & Settlement (40%)
|
||||
- ✅ Commands & Database
|
||||
- ❌ Payment Gateway Integration
|
||||
|
||||
### ❌ Not Started (1)
|
||||
- Phase 9: Club Shop & Product Integration (0%)
|
||||
|
||||
### ⏸️ Postponed (1)
|
||||
- Phase 7: Testing (Unit, Integration, Load tests)
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Recent Updates (2025-12-01)
|
||||
|
||||
### 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
|
||||
CMSMicroservice.Application/ # CQRS (Commands, Queries, MediatR)
|
||||
CMSMicroservice.Infrastructure/ # DbContext, Services, Background Jobs
|
||||
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
|
||||
|
||||
- **[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
|
||||
|
||||
---
|
||||
|
||||
## 🚀 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?
|
||||
|
||||
### High Priority
|
||||
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)
|
||||
|
||||
✅ 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)
|
||||
|
||||
---
|
||||
|
||||
## 👥 Team
|
||||
|
||||
**Development**: FourSat Team
|
||||
**Last Updated**: 2025-12-01
|
||||
|
||||
---
|
||||
|
||||
## 📝 License
|
||||
|
||||
Proprietary - FourSat Company
|
||||
# Multi-remote push enabled
|
||||
Documentation moved to [totalDoc/INDEX.md](../totalDoc/INDEX.md).
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
Docs moved to /totalDoc — see totalDoc/INDEX.md
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,421 @@
|
||||
{
|
||||
"info": {
|
||||
"_postman_id": "a5404a90-7d5a-4e9f-a26b-bb2bc93f19a2",
|
||||
"name": "hushyar",
|
||||
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
|
||||
"_exporter_id": "12336342",
|
||||
"_collection_link": "https://abbas-rahimzadeh-postman.postman.co/workspace/public-workspace~57c702f9-fed1-4b27-b507-0b0252812bf7/collection/12336342-a5404a90-7d5a-4e9f-a26b-bb2bc93f19a2?action=share&source=collection_link&creator=12336342"
|
||||
},
|
||||
"item": [
|
||||
{
|
||||
"name": "v1",
|
||||
"item": [
|
||||
{
|
||||
"name": "organization",
|
||||
"item": [
|
||||
{
|
||||
"name": "register-user",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{
|
||||
"key": "X-API-Key",
|
||||
"value": "{{organizationApiKey}}",
|
||||
"description": "Organization API Key",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"mobile_number\": \"09123456789\"\n}",
|
||||
"options": {
|
||||
"raw": {
|
||||
"language": "json"
|
||||
}
|
||||
}
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{baseURL}}/api/v1/organizations/register-user",
|
||||
"host": [
|
||||
"{{baseURL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"v1",
|
||||
"organizations",
|
||||
"register-user"
|
||||
]
|
||||
},
|
||||
"description": "Register a new user or get existing user via organization API key. If user is new or has no organization, they will be assigned to this organization and their wallet will be charged with the organization's user_credit."
|
||||
},
|
||||
"response": [
|
||||
{
|
||||
"name": "success - new user",
|
||||
"originalRequest": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{
|
||||
"key": "X-API-Key",
|
||||
"value": "{{organizationApiKey}}",
|
||||
"description": "Organization API Key",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"mobile_number\": \"09123456789\"\n}",
|
||||
"options": {
|
||||
"raw": {
|
||||
"language": "json"
|
||||
}
|
||||
}
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{baseURL}}/api/v1/organizations/register-user",
|
||||
"host": [
|
||||
"{{baseURL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"v1",
|
||||
"organizations",
|
||||
"register-user"
|
||||
]
|
||||
}
|
||||
},
|
||||
"status": "OK",
|
||||
"code": 200,
|
||||
"_postman_previewlanguage": "json",
|
||||
"header": [
|
||||
{
|
||||
"key": "content-type",
|
||||
"value": "application/json"
|
||||
}
|
||||
],
|
||||
"cookie": [],
|
||||
"body": "{\n \"id\": 1,\n \"mobile_number\": \"09123456789\",\n \"organization_id\": 1,\n \"organization_title\": \"Test Organization\",\n \"wallet_balance\": 100.0,\n \"is_new_user\": true,\n \"credit_charged\": 100.0\n}"
|
||||
},
|
||||
{
|
||||
"name": "success - existing user",
|
||||
"originalRequest": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{
|
||||
"key": "X-API-Key",
|
||||
"value": "{{organizationApiKey}}",
|
||||
"description": "Organization API Key",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"mobile_number\": \"09123456789\"\n}",
|
||||
"options": {
|
||||
"raw": {
|
||||
"language": "json"
|
||||
}
|
||||
}
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{baseURL}}/api/v1/organizations/register-user",
|
||||
"host": [
|
||||
"{{baseURL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"v1",
|
||||
"organizations",
|
||||
"register-user"
|
||||
]
|
||||
}
|
||||
},
|
||||
"status": "OK",
|
||||
"code": 200,
|
||||
"_postman_previewlanguage": "json",
|
||||
"header": [
|
||||
{
|
||||
"key": "content-type",
|
||||
"value": "application/json"
|
||||
}
|
||||
],
|
||||
"cookie": [],
|
||||
"body": "{\n \"id\": 1,\n \"mobile_number\": \"09123456789\",\n \"organization_id\": 1,\n \"organization_title\": \"Test Organization\",\n \"wallet_balance\": 100.0,\n \"is_new_user\": false,\n \"credit_charged\": 0.0\n}"
|
||||
},
|
||||
{
|
||||
"name": "invalid api key",
|
||||
"originalRequest": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{
|
||||
"key": "X-API-Key",
|
||||
"value": "invalid-api-key",
|
||||
"description": "Organization API Key",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"mobile_number\": \"09123456789\"\n}",
|
||||
"options": {
|
||||
"raw": {
|
||||
"language": "json"
|
||||
}
|
||||
}
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{baseURL}}/api/v1/organizations/register-user",
|
||||
"host": [
|
||||
"{{baseURL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"v1",
|
||||
"organizations",
|
||||
"register-user"
|
||||
]
|
||||
}
|
||||
},
|
||||
"status": "Unauthorized",
|
||||
"code": 401,
|
||||
"_postman_previewlanguage": "json",
|
||||
"header": [
|
||||
{
|
||||
"key": "content-type",
|
||||
"value": "application/json"
|
||||
}
|
||||
],
|
||||
"cookie": [],
|
||||
"body": "{\n \"error_code\": \"INVALID_API_KEY\",\n \"message\": \"Invalid API key\",\n \"context\": {\n \"api_key_prefix\": \"invalid-\"\n }\n}"
|
||||
},
|
||||
{
|
||||
"name": "organization disabled",
|
||||
"originalRequest": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{
|
||||
"key": "X-API-Key",
|
||||
"value": "{{organizationApiKey}}",
|
||||
"description": "Organization API Key",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"mobile_number\": \"09123456789\"\n}",
|
||||
"options": {
|
||||
"raw": {
|
||||
"language": "json"
|
||||
}
|
||||
}
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{baseURL}}/api/v1/organizations/register-user",
|
||||
"host": [
|
||||
"{{baseURL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"v1",
|
||||
"organizations",
|
||||
"register-user"
|
||||
]
|
||||
}
|
||||
},
|
||||
"status": "Forbidden",
|
||||
"code": 403,
|
||||
"_postman_previewlanguage": "json",
|
||||
"header": [
|
||||
{
|
||||
"key": "content-type",
|
||||
"value": "application/json"
|
||||
}
|
||||
],
|
||||
"cookie": [],
|
||||
"body": "{\n \"error_code\": \"ORGANIZATION_DISABLED\",\n \"message\": \"Organization is disabled\",\n \"context\": {\n \"organization_id\": 1\n }\n}"
|
||||
},
|
||||
{
|
||||
"name": "organization expired",
|
||||
"originalRequest": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{
|
||||
"key": "X-API-Key",
|
||||
"value": "{{organizationApiKey}}",
|
||||
"description": "Organization API Key",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"mobile_number\": \"09123456789\"\n}",
|
||||
"options": {
|
||||
"raw": {
|
||||
"language": "json"
|
||||
}
|
||||
}
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{baseURL}}/api/v1/organizations/register-user",
|
||||
"host": [
|
||||
"{{baseURL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"v1",
|
||||
"organizations",
|
||||
"register-user"
|
||||
]
|
||||
}
|
||||
},
|
||||
"status": "Forbidden",
|
||||
"code": 403,
|
||||
"_postman_previewlanguage": "json",
|
||||
"header": [
|
||||
{
|
||||
"key": "content-type",
|
||||
"value": "application/json"
|
||||
}
|
||||
],
|
||||
"cookie": [],
|
||||
"body": "{\n \"error_code\": \"ORGANIZATION_EXPIRED\",\n \"message\": \"Organization has expired\",\n \"context\": {\n \"organization_id\": 1,\n \"expires_at\": \"2024-01-01T00:00:00\"\n }\n}"
|
||||
},
|
||||
{
|
||||
"name": "invalid mobile format",
|
||||
"originalRequest": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{
|
||||
"key": "X-API-Key",
|
||||
"value": "{{organizationApiKey}}",
|
||||
"description": "Organization API Key",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"mobile_number\": \"01234567890\"\n}",
|
||||
"options": {
|
||||
"raw": {
|
||||
"language": "json"
|
||||
}
|
||||
}
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{baseURL}}/api/v1/organizations/register-user",
|
||||
"host": [
|
||||
"{{baseURL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"v1",
|
||||
"organizations",
|
||||
"register-user"
|
||||
]
|
||||
}
|
||||
},
|
||||
"status": "Unprocessable Entity",
|
||||
"code": 422,
|
||||
"_postman_previewlanguage": "json",
|
||||
"header": [
|
||||
{
|
||||
"key": "content-type",
|
||||
"value": "application/json"
|
||||
}
|
||||
],
|
||||
"cookie": [],
|
||||
"body": "{\n \"mobile_number\": \"String should match pattern '^09\\\\d{9}$'\"\n}"
|
||||
},
|
||||
{
|
||||
"name": "missing api key header",
|
||||
"originalRequest": {
|
||||
"method": "POST",
|
||||
"header": [],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"mobile_number\": \"09123456789\"\n}",
|
||||
"options": {
|
||||
"raw": {
|
||||
"language": "json"
|
||||
}
|
||||
}
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{baseURL}}/api/v1/organizations/register-user",
|
||||
"host": [
|
||||
"{{baseURL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"v1",
|
||||
"organizations",
|
||||
"register-user"
|
||||
]
|
||||
}
|
||||
},
|
||||
"status": "Unprocessable Entity",
|
||||
"code": 422,
|
||||
"_postman_previewlanguage": "json",
|
||||
"header": [
|
||||
{
|
||||
"key": "content-type",
|
||||
"value": "application/json"
|
||||
}
|
||||
],
|
||||
"cookie": [],
|
||||
"body": "{\n \"detail\": [\n {\n \"type\": \"missing\",\n \"loc\": [\"header\", \"x-api-key\"],\n \"msg\": \"Field required\",\n \"input\": null\n }\n ]\n}"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"event": [
|
||||
{
|
||||
"listen": "prerequest",
|
||||
"script": {
|
||||
"type": "text/javascript",
|
||||
"packages": {},
|
||||
"exec": [
|
||||
""
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"type": "text/javascript",
|
||||
"packages": {},
|
||||
"exec": [
|
||||
""
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"variable": [
|
||||
{
|
||||
"key": "baseURL",
|
||||
"value": "http://127.0.0.0:8000",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"key": "access_token",
|
||||
"value": "",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"key": "accessToken",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"key": "refreshToken",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"key": "organizationApiKey",
|
||||
"value": "",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,490 +0,0 @@
|
||||
# Club Feature Management Services - Implementation Guide
|
||||
|
||||
## Overview
|
||||
Admin services for managing user club features (enable/disable features per user).
|
||||
|
||||
## Created Files
|
||||
|
||||
### 1. CQRS Layer (Application)
|
||||
|
||||
#### Query: GetUserClubFeatures
|
||||
**Location:** `/CMS/src/CMSMicroservice.Application/ClubFeatureCQ/Queries/GetUserClubFeatures/`
|
||||
|
||||
**Files:**
|
||||
- `GetUserClubFeaturesQuery.cs` - Query definition
|
||||
- `GetUserClubFeaturesQueryHandler.cs` - Query handler
|
||||
- `UserClubFeatureDto.cs` - Response DTO
|
||||
|
||||
**Purpose:** Get list of all club features for a specific user with their active status.
|
||||
|
||||
**Input:**
|
||||
```csharp
|
||||
public record GetUserClubFeaturesQuery : IRequest<List<UserClubFeatureDto>>
|
||||
{
|
||||
public long UserId { get; init; }
|
||||
}
|
||||
```
|
||||
|
||||
**Output:**
|
||||
```csharp
|
||||
public class UserClubFeatureDto
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public long UserId { get; set; }
|
||||
public long ClubMembershipId { get; set; }
|
||||
public long ClubFeatureId { get; set; }
|
||||
public string FeatureTitle { get; set; }
|
||||
public string? FeatureDescription { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
public DateTime GrantedAt { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
**Logic:**
|
||||
- Joins `UserClubFeatures` with `ClubFeature` table
|
||||
- Filters by `UserId` and `!IsDeleted`
|
||||
- Returns list of features with their active status
|
||||
|
||||
---
|
||||
|
||||
#### Command: ToggleUserClubFeature
|
||||
**Location:** `/CMS/src/CMSMicroservice.Application/ClubFeatureCQ/Commands/ToggleUserClubFeature/`
|
||||
|
||||
**Files:**
|
||||
- `ToggleUserClubFeatureCommand.cs` - Command definition
|
||||
- `ToggleUserClubFeatureCommandHandler.cs` - Command handler
|
||||
- `ToggleUserClubFeatureResponse.cs` - Response DTO
|
||||
|
||||
**Purpose:** Enable or disable a specific club feature for a user.
|
||||
|
||||
**Input:**
|
||||
```csharp
|
||||
public record ToggleUserClubFeatureCommand : IRequest<ToggleUserClubFeatureResponse>
|
||||
{
|
||||
public long UserId { get; init; }
|
||||
public long ClubFeatureId { get; init; }
|
||||
public bool IsActive { get; init; }
|
||||
}
|
||||
```
|
||||
|
||||
**Output:**
|
||||
```csharp
|
||||
public class ToggleUserClubFeatureResponse
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public string Message { get; set; }
|
||||
public long? UserClubFeatureId { get; set; }
|
||||
public bool? IsActive { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
**Validations:**
|
||||
1. ✅ User exists and not deleted
|
||||
2. ✅ Club feature exists and not deleted
|
||||
3. ✅ User has this feature assigned (exists in UserClubFeatures)
|
||||
|
||||
**Logic:**
|
||||
- Find `UserClubFeature` record by `UserId` + `ClubFeatureId`
|
||||
- Update `IsActive` field
|
||||
- Set `LastModified` timestamp
|
||||
- Save changes
|
||||
|
||||
**Error Messages:**
|
||||
- "کاربر یافت نشد" - User not found
|
||||
- "ویژگی باشگاه یافت نشد" - Club feature not found
|
||||
- "این ویژگی برای کاربر یافت نشد" - User doesn't have this feature
|
||||
|
||||
**Success Messages:**
|
||||
- "ویژگی با موفقیت فعال شد" - Feature activated successfully
|
||||
- "ویژگی با موفقیت غیرفعال شد" - Feature deactivated successfully
|
||||
|
||||
---
|
||||
|
||||
### 2. gRPC Layer (Protobuf + WebApi)
|
||||
|
||||
#### Proto Definition
|
||||
**File:** `/CMS/src/CMSMicroservice.Protobuf/Protos/clubmembership.proto`
|
||||
|
||||
**Added RPC Methods:**
|
||||
```protobuf
|
||||
rpc GetUserClubFeatures(GetUserClubFeaturesRequest) returns (GetUserClubFeaturesResponse){
|
||||
option (google.api.http) = {
|
||||
get: "/ClubFeature/GetUserFeatures"
|
||||
};
|
||||
};
|
||||
|
||||
rpc ToggleUserClubFeature(ToggleUserClubFeatureRequest) returns (ToggleUserClubFeatureResponse){
|
||||
option (google.api.http) = {
|
||||
post: "/ClubFeature/ToggleFeature"
|
||||
body: "*"
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
**Message Definitions:**
|
||||
```protobuf
|
||||
message GetUserClubFeaturesRequest {
|
||||
int64 user_id = 1;
|
||||
}
|
||||
|
||||
message GetUserClubFeaturesResponse {
|
||||
repeated UserClubFeatureModel features = 1;
|
||||
}
|
||||
|
||||
message UserClubFeatureModel {
|
||||
int64 id = 1;
|
||||
int64 user_id = 2;
|
||||
int64 club_membership_id = 3;
|
||||
int64 club_feature_id = 4;
|
||||
string feature_title = 5;
|
||||
string feature_description = 6;
|
||||
bool is_active = 7;
|
||||
google.protobuf.Timestamp granted_at = 8;
|
||||
string notes = 9;
|
||||
}
|
||||
|
||||
message ToggleUserClubFeatureRequest {
|
||||
int64 user_id = 1;
|
||||
int64 club_feature_id = 2;
|
||||
bool is_active = 3;
|
||||
}
|
||||
|
||||
message ToggleUserClubFeatureResponse {
|
||||
bool success = 1;
|
||||
string message = 2;
|
||||
google.protobuf.Int64Value user_club_feature_id = 3;
|
||||
google.protobuf.BoolValue is_active = 4;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### gRPC Service Implementation
|
||||
**File:** `/CMS/src/CMSMicroservice.WebApi/Services/ClubMembershipService.cs`
|
||||
|
||||
**Added Methods:**
|
||||
```csharp
|
||||
public override async Task<GetUserClubFeaturesResponse> GetUserClubFeatures(
|
||||
GetUserClubFeaturesRequest request,
|
||||
ServerCallContext context)
|
||||
{
|
||||
return await _dispatchRequestToCQRS.Handle<
|
||||
GetUserClubFeaturesRequest,
|
||||
GetUserClubFeaturesQuery,
|
||||
GetUserClubFeaturesResponse>(request, context);
|
||||
}
|
||||
|
||||
public override async Task<Protobuf.Protos.ClubMembership.ToggleUserClubFeatureResponse>
|
||||
ToggleUserClubFeature(
|
||||
ToggleUserClubFeatureRequest request,
|
||||
ServerCallContext context)
|
||||
{
|
||||
return await _dispatchRequestToCQRS.Handle<
|
||||
ToggleUserClubFeatureRequest,
|
||||
ToggleUserClubFeatureCommand,
|
||||
Protobuf.Protos.ClubMembership.ToggleUserClubFeatureResponse>(request, context);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### AutoMapper Profile
|
||||
**File:** `/CMS/src/CMSMicroservice.WebApi/Common/Mappings/ClubFeatureProfile.cs`
|
||||
|
||||
**Mappings:**
|
||||
1. `GetUserClubFeaturesRequest` → `GetUserClubFeaturesQuery`
|
||||
2. `UserClubFeatureDto` → `UserClubFeatureModel` (Proto)
|
||||
3. `List<UserClubFeatureDto>` → `GetUserClubFeaturesResponse`
|
||||
4. `ToggleUserClubFeatureRequest` → `ToggleUserClubFeatureCommand`
|
||||
5. `ToggleUserClubFeatureResponse` (App) → `ToggleUserClubFeatureResponse` (Proto)
|
||||
|
||||
**Special Handling:**
|
||||
- DateTime conversion to `Timestamp` (Protobuf format)
|
||||
- Null-safe mapping for optional fields
|
||||
- Fully qualified type names to avoid ambiguity
|
||||
|
||||
---
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### 1. Get User Club Features
|
||||
**Method:** GET
|
||||
**Endpoint:** `/ClubFeature/GetUserFeatures`
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"user_id": 123
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"features": [
|
||||
{
|
||||
"id": 1,
|
||||
"user_id": 123,
|
||||
"club_membership_id": 456,
|
||||
"club_feature_id": 1,
|
||||
"feature_title": "دسترسی به فروشگاه تخفیف",
|
||||
"feature_description": "امکان خرید از فروشگاه تخفیف",
|
||||
"is_active": true,
|
||||
"granted_at": "2025-12-09T18:30:00Z",
|
||||
"notes": "اعطا شده بهطور خودکار هنگام فعالسازی"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. Toggle User Club Feature
|
||||
**Method:** POST
|
||||
**Endpoint:** `/ClubFeature/ToggleFeature`
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"user_id": 123,
|
||||
"club_feature_id": 1,
|
||||
"is_active": false
|
||||
}
|
||||
```
|
||||
|
||||
**Response (Success):**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "ویژگی با موفقیت غیرفعال شد",
|
||||
"user_club_feature_id": 1,
|
||||
"is_active": false
|
||||
}
|
||||
```
|
||||
|
||||
**Response (Error - User Not Found):**
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"message": "کاربر یافت نشد"
|
||||
}
|
||||
```
|
||||
|
||||
**Response (Error - Feature Not Found):**
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"message": "ویژگی باشگاه یافت نشد"
|
||||
}
|
||||
```
|
||||
|
||||
**Response (Error - User Doesn't Have Feature):**
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"message": "این ویژگی برای کاربر یافت نشد"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Database Schema
|
||||
|
||||
### Table: UserClubFeatures
|
||||
Existing table with newly added `IsActive` field:
|
||||
|
||||
```sql
|
||||
CREATE TABLE [CMS].[UserClubFeatures]
|
||||
(
|
||||
[Id] BIGINT IDENTITY(1,1) PRIMARY KEY,
|
||||
[UserId] BIGINT NOT NULL,
|
||||
[ClubMembershipId] BIGINT NOT NULL,
|
||||
[ClubFeatureId] BIGINT NOT NULL,
|
||||
[GrantedAt] DATETIME2 NOT NULL,
|
||||
[IsActive] BIT NOT NULL DEFAULT 1, -- ← NEW FIELD
|
||||
[Notes] NVARCHAR(MAX) NULL,
|
||||
[Created] DATETIME2 NOT NULL,
|
||||
[CreatedBy] NVARCHAR(MAX) NULL,
|
||||
[LastModified] DATETIME2 NULL,
|
||||
[LastModifiedBy] NVARCHAR(MAX) NULL,
|
||||
[IsDeleted] BIT NOT NULL DEFAULT 0,
|
||||
|
||||
CONSTRAINT FK_UserClubFeatures_Users FOREIGN KEY ([UserId])
|
||||
REFERENCES [Identity].[Users]([Id]),
|
||||
CONSTRAINT FK_UserClubFeatures_ClubMembership FOREIGN KEY ([ClubMembershipId])
|
||||
REFERENCES [CMS].[ClubMembership]([Id]),
|
||||
CONSTRAINT FK_UserClubFeatures_ClubFeatures FOREIGN KEY ([ClubFeatureId])
|
||||
REFERENCES [CMS].[ClubFeatures]([Id])
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Admin Panel Scenario
|
||||
|
||||
#### 1. View User's Club Features
|
||||
```csharp
|
||||
// Admin selects user ID: 123
|
||||
var request = new GetUserClubFeaturesRequest { UserId = 123 };
|
||||
var response = await client.GetUserClubFeaturesAsync(request);
|
||||
|
||||
// Display in grid:
|
||||
foreach (var feature in response.Features)
|
||||
{
|
||||
Console.WriteLine($"Feature: {feature.FeatureTitle}");
|
||||
Console.WriteLine($"Status: {(feature.IsActive ? "فعال" : "غیرفعال")}");
|
||||
Console.WriteLine($"Granted: {feature.GrantedAt}");
|
||||
Console.WriteLine("---");
|
||||
}
|
||||
```
|
||||
|
||||
**Output:**
|
||||
```
|
||||
Feature: دسترسی به فروشگاه تخفیف
|
||||
Status: فعال
|
||||
Granted: 2025-12-09 18:30:00
|
||||
---
|
||||
Feature: دسترسی به کمیسیون هفتگی
|
||||
Status: فعال
|
||||
Granted: 2025-12-09 18:30:00
|
||||
---
|
||||
Feature: دسترسی به شارژ شبکه
|
||||
Status: غیرفعال
|
||||
Granted: 2025-12-09 18:30:00
|
||||
---
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 2. Disable a Feature
|
||||
```csharp
|
||||
// Admin clicks "Disable" on Feature ID: 3
|
||||
var request = new ToggleUserClubFeatureRequest
|
||||
{
|
||||
UserId = 123,
|
||||
ClubFeatureId = 3,
|
||||
IsActive = false
|
||||
};
|
||||
|
||||
var response = await client.ToggleUserClubFeatureAsync(request);
|
||||
|
||||
if (response.Success)
|
||||
{
|
||||
Console.WriteLine(response.Message);
|
||||
// Output: ویژگی با موفقیت غیرفعال شد
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 3. Re-enable a Feature
|
||||
```csharp
|
||||
// Admin clicks "Enable" on Feature ID: 3
|
||||
var request = new ToggleUserClubFeatureRequest
|
||||
{
|
||||
UserId = 123,
|
||||
ClubFeatureId = 3,
|
||||
IsActive = true
|
||||
};
|
||||
|
||||
var response = await client.ToggleUserClubFeatureAsync(request);
|
||||
|
||||
if (response.Success)
|
||||
{
|
||||
Console.WriteLine(response.Message);
|
||||
// Output: ویژگی با موفقیت فعال شد
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
### Unit Tests (Recommended)
|
||||
- [ ] GetUserClubFeaturesQueryHandler returns correct DTOs
|
||||
- [ ] ToggleUserClubFeatureCommandHandler validates user exists
|
||||
- [ ] ToggleUserClubFeatureCommandHandler validates feature exists
|
||||
- [ ] ToggleUserClubFeatureCommandHandler validates user has feature
|
||||
- [ ] ToggleUserClubFeatureCommandHandler updates IsActive correctly
|
||||
- [ ] ToggleUserClubFeatureCommandHandler sets LastModified timestamp
|
||||
|
||||
### Integration Tests
|
||||
- [ ] gRPC GetUserClubFeatures endpoint returns data
|
||||
- [ ] gRPC ToggleUserClubFeature endpoint updates database
|
||||
- [ ] AutoMapper mappings work correctly
|
||||
- [ ] Proto serialization/deserialization works
|
||||
|
||||
### Manual Testing
|
||||
1. **Get Features:**
|
||||
```bash
|
||||
grpcurl -d '{"user_id": 123}' \
|
||||
-plaintext localhost:5000 \
|
||||
clubmembership.ClubMembershipContract/GetUserClubFeatures
|
||||
```
|
||||
|
||||
2. **Disable Feature:**
|
||||
```bash
|
||||
grpcurl -d '{"user_id": 123, "club_feature_id": 1, "is_active": false}' \
|
||||
-plaintext localhost:5000 \
|
||||
clubmembership.ClubMembershipContract/ToggleUserClubFeature
|
||||
```
|
||||
|
||||
3. **Verify in Database:**
|
||||
```sql
|
||||
SELECT Id, UserId, ClubFeatureId, IsActive, LastModified
|
||||
FROM CMS.UserClubFeatures
|
||||
WHERE UserId = 123;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Build Status
|
||||
✅ **All projects build successfully**
|
||||
- CMSMicroservice.Domain: ✅
|
||||
- CMSMicroservice.Application: ✅ (0 errors, 274 warnings)
|
||||
- CMSMicroservice.Protobuf: ✅
|
||||
- CMSMicroservice.WebApi: ✅ (0 errors, 17 warnings)
|
||||
|
||||
---
|
||||
|
||||
## Next Steps (Optional Enhancements)
|
||||
|
||||
1. **Authorization:**
|
||||
- Add `[Authorize(Roles = "Admin")]` attribute
|
||||
- Validate admin permissions before toggling
|
||||
|
||||
2. **Audit Logging:**
|
||||
- Log who changed the feature status
|
||||
- Track `LastModifiedBy` field
|
||||
|
||||
3. **Bulk Operations:**
|
||||
- Add endpoint to toggle multiple features at once
|
||||
- Add endpoint to enable/disable all features for a user
|
||||
|
||||
4. **History Tracking:**
|
||||
- Create `UserClubFeatureHistory` table
|
||||
- Log every status change with timestamp and reason
|
||||
|
||||
5. **Notifications:**
|
||||
- Send notification to user when feature is disabled
|
||||
- Email/SMS alert for important features
|
||||
|
||||
6. **Business Rules:**
|
||||
- Add validation: prevent disabling critical features
|
||||
- Add expiration dates for features
|
||||
- Add feature dependencies (e.g., Feature B requires Feature A)
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
✅ Created CQRS Query + Command for club feature management
|
||||
✅ Created gRPC Proto definitions and services
|
||||
✅ Created AutoMapper mappings
|
||||
✅ All builds successful
|
||||
✅ Ready for deployment and testing
|
||||
|
||||
**Total Files Created:** 8
|
||||
**Total Lines of Code:** ~350
|
||||
**Build Errors:** 0
|
||||
**Status:** ✅ Complete and ready for use
|
||||
@@ -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,113 @@
|
||||
---
|
||||
# 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
|
||||
},
|
||||
"CmsBaseUrl": "https://cms.se.kbs1.ir",
|
||||
"FrontOfficeBaseUrl": "https://frontoffice.se.kbs1.ir",
|
||||
"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": "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": 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,132 @@
|
||||
---
|
||||
# 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
|
||||
# If clients report TLS PartialChain, the tls secret (cms-tls) tls.crt must include the full chain
|
||||
# (leaf + intermediates). cert-manager ACME usually provides this; manual certs need fullchain.pem in tls.crt.
|
||||
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
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
namespace CMSMicroservice.Application.AppVersionCQ.Commands.UpdateAppVersion;
|
||||
|
||||
/// <summary>
|
||||
/// Command برای آپدیت یا ایجاد نسخه جدید اپلیکیشن
|
||||
/// </summary>
|
||||
public record UpdateAppVersionCommand : IRequest<Unit>
|
||||
{
|
||||
/// <summary>
|
||||
/// نام اپلیکیشن (FrontOffice, BackOffice, MobileApp)
|
||||
/// </summary>
|
||||
public string AppName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// شماره نسخه جدید (مثلاً 1.2.3)
|
||||
/// </summary>
|
||||
public string CurrentVersion { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// حداقل نسخه مورد نیاز
|
||||
/// </summary>
|
||||
public string? MinRequiredVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// آیا کش کامل باید پاک بشه؟
|
||||
/// </summary>
|
||||
public bool RequiresFullCacheClear { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// پیام آپدیت
|
||||
/// </summary>
|
||||
public string? UpdateMessage { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// توضیحات تغییرات این نسخه
|
||||
/// </summary>
|
||||
public string? ReleaseNotes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// دلیل آپدیت (برای لاگ)
|
||||
/// </summary>
|
||||
public string? UpdateReason { get; init; }
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
using CMSMicroservice.Domain.Entities.Configuration;
|
||||
|
||||
namespace CMSMicroservice.Application.AppVersionCQ.Commands.UpdateAppVersion;
|
||||
|
||||
/// <summary>
|
||||
/// Handler برای آپدیت یا ایجاد نسخه جدید اپلیکیشن
|
||||
/// </summary>
|
||||
public class UpdateAppVersionCommandHandler : IRequestHandler<UpdateAppVersionCommand, Unit>
|
||||
{
|
||||
private readonly IApplicationDbContext _context;
|
||||
private readonly ILogger<UpdateAppVersionCommandHandler> _logger;
|
||||
|
||||
public UpdateAppVersionCommandHandler(
|
||||
IApplicationDbContext context,
|
||||
ILogger<UpdateAppVersionCommandHandler> logger)
|
||||
{
|
||||
_context = context;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Unit> Handle(UpdateAppVersionCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// پیدا کردن نسخه موجود برای این اپ
|
||||
var existingVersion = await _context.AppVersions
|
||||
.Where(v => v.AppName == request.AppName && !v.IsDeleted)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (existingVersion != null)
|
||||
{
|
||||
// آپدیت نسخه موجود
|
||||
existingVersion.CurrentVersion = request.CurrentVersion;
|
||||
existingVersion.MinRequiredVersion = request.MinRequiredVersion ?? request.CurrentVersion;
|
||||
existingVersion.RequiresFullCacheClear = request.RequiresFullCacheClear;
|
||||
existingVersion.UpdateMessage = request.UpdateMessage;
|
||||
existingVersion.ReleaseNotes = request.ReleaseNotes;
|
||||
|
||||
_logger.LogInformation(
|
||||
"App version updated: {AppName} v{Version}, CacheClear={CacheClear}, Reason={Reason}",
|
||||
request.AppName, request.CurrentVersion, request.RequiresFullCacheClear, request.UpdateReason);
|
||||
}
|
||||
else
|
||||
{
|
||||
// ایجاد نسخه جدید
|
||||
var newVersion = new AppVersion
|
||||
{
|
||||
AppName = request.AppName,
|
||||
CurrentVersion = request.CurrentVersion,
|
||||
MinRequiredVersion = request.MinRequiredVersion ?? request.CurrentVersion,
|
||||
RequiresFullCacheClear = request.RequiresFullCacheClear,
|
||||
UpdateMessage = request.UpdateMessage,
|
||||
ReleaseNotes = request.ReleaseNotes,
|
||||
IsActive = true
|
||||
};
|
||||
|
||||
_context.AppVersions.Add(newVersion);
|
||||
|
||||
_logger.LogInformation(
|
||||
"New app version created: {AppName} v{Version}, CacheClear={CacheClear}, Reason={Reason}",
|
||||
request.AppName, request.CurrentVersion, request.RequiresFullCacheClear, request.UpdateReason);
|
||||
}
|
||||
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
return Unit.Value;
|
||||
}
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
namespace CMSMicroservice.Application.AppVersionCQ.Queries.GetAllAppVersions;
|
||||
|
||||
/// <summary>
|
||||
/// Query برای دریافت همه نسخههای اپلیکیشنها
|
||||
/// </summary>
|
||||
public record GetAllAppVersionsQuery(bool IncludeInactive = false) : IRequest<List<AppVersionItemDto>>;
|
||||
|
||||
/// <summary>
|
||||
/// DTO برای هر آیتم نسخه اپلیکیشن
|
||||
/// </summary>
|
||||
public record AppVersionItemDto
|
||||
{
|
||||
public long Id { get; init; }
|
||||
public string AppName { get; init; } = string.Empty;
|
||||
public string CurrentVersion { get; init; } = string.Empty;
|
||||
public string MinRequiredVersion { get; init; } = string.Empty;
|
||||
public bool RequiresFullCacheClear { get; init; }
|
||||
public string? UpdateMessage { get; init; }
|
||||
public string? ReleaseNotes { get; init; }
|
||||
public bool IsActive { get; init; }
|
||||
public DateTime Created { get; init; }
|
||||
public DateTime? LastModified { get; init; }
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
namespace CMSMicroservice.Application.AppVersionCQ.Queries.GetAllAppVersions;
|
||||
|
||||
/// <summary>
|
||||
/// Handler برای دریافت همه نسخههای اپلیکیشنها
|
||||
/// </summary>
|
||||
public class GetAllAppVersionsQueryHandler : IRequestHandler<GetAllAppVersionsQuery, List<AppVersionItemDto>>
|
||||
{
|
||||
private readonly IApplicationDbContext _context;
|
||||
|
||||
public GetAllAppVersionsQueryHandler(IApplicationDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public async Task<List<AppVersionItemDto>> Handle(GetAllAppVersionsQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var query = _context.AppVersions
|
||||
.Where(v => !v.IsDeleted);
|
||||
|
||||
if (!request.IncludeInactive)
|
||||
{
|
||||
query = query.Where(v => v.IsActive);
|
||||
}
|
||||
|
||||
var versions = await query
|
||||
.OrderBy(v => v.AppName)
|
||||
.Select(v => new AppVersionItemDto
|
||||
{
|
||||
Id = v.Id,
|
||||
AppName = v.AppName,
|
||||
CurrentVersion = v.CurrentVersion,
|
||||
MinRequiredVersion = v.MinRequiredVersion,
|
||||
RequiresFullCacheClear = v.RequiresFullCacheClear,
|
||||
UpdateMessage = v.UpdateMessage,
|
||||
ReleaseNotes = v.ReleaseNotes,
|
||||
IsActive = v.IsActive,
|
||||
Created = v.Created,
|
||||
LastModified = v.LastModified
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return versions;
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
namespace CMSMicroservice.Application.AppVersionCQ.Queries.GetAppVersion;
|
||||
|
||||
/// <summary>
|
||||
/// Query برای دریافت آخرین نسخه یک اپلیکیشن
|
||||
/// </summary>
|
||||
public record GetAppVersionQuery(string AppName, string? CurrentClientVersion = null) : IRequest<AppVersionDto?>;
|
||||
|
||||
/// <summary>
|
||||
/// DTO برای اطلاعات نسخه اپلیکیشن
|
||||
/// </summary>
|
||||
public record AppVersionDto
|
||||
{
|
||||
public bool Found { get; init; }
|
||||
public string AppName { get; init; } = string.Empty;
|
||||
public string CurrentVersion { get; init; } = string.Empty;
|
||||
public string MinRequiredVersion { get; init; } = string.Empty;
|
||||
public bool RequiresFullCacheClear { get; init; }
|
||||
public bool RequiresUpdate { get; init; }
|
||||
public string? UpdateMessage { get; init; }
|
||||
public string? ReleaseNotes { get; init; }
|
||||
public DateTime? LastUpdated { get; init; }
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
using CMSMicroservice.Domain.Entities.Configuration;
|
||||
|
||||
namespace CMSMicroservice.Application.AppVersionCQ.Queries.GetAppVersion;
|
||||
|
||||
/// <summary>
|
||||
/// Handler برای دریافت آخرین نسخه اپلیکیشن
|
||||
/// </summary>
|
||||
public class GetAppVersionQueryHandler : IRequestHandler<GetAppVersionQuery, AppVersionDto?>
|
||||
{
|
||||
private readonly IApplicationDbContext _context;
|
||||
|
||||
public GetAppVersionQueryHandler(IApplicationDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public async Task<AppVersionDto?> Handle(GetAppVersionQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var appVersion = await _context.AppVersions
|
||||
.Where(v => v.AppName == request.AppName && v.IsActive && !v.IsDeleted)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (appVersion == null)
|
||||
{
|
||||
return new AppVersionDto
|
||||
{
|
||||
Found = false,
|
||||
AppName = request.AppName
|
||||
};
|
||||
}
|
||||
|
||||
// مقایسه ورژن کلاینت با حداقل نسخه مورد نیاز
|
||||
bool requiresUpdate = false;
|
||||
if (!string.IsNullOrEmpty(request.CurrentClientVersion) && !string.IsNullOrEmpty(appVersion.MinRequiredVersion))
|
||||
{
|
||||
requiresUpdate = CompareVersions(request.CurrentClientVersion, appVersion.MinRequiredVersion) < 0;
|
||||
}
|
||||
|
||||
return new AppVersionDto
|
||||
{
|
||||
Found = true,
|
||||
AppName = appVersion.AppName,
|
||||
CurrentVersion = appVersion.CurrentVersion,
|
||||
MinRequiredVersion = appVersion.MinRequiredVersion,
|
||||
RequiresFullCacheClear = appVersion.RequiresFullCacheClear,
|
||||
RequiresUpdate = requiresUpdate,
|
||||
UpdateMessage = appVersion.UpdateMessage,
|
||||
ReleaseNotes = appVersion.ReleaseNotes,
|
||||
LastUpdated = appVersion.LastModified ?? appVersion.Created
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// مقایسه دو نسخه (مثلاً 1.2.3 با 1.3.0)
|
||||
/// برگشت: منفی = اولی کوچکتر، مثبت = اولی بزرگتر، صفر = برابر
|
||||
/// </summary>
|
||||
private static int CompareVersions(string version1, string version2)
|
||||
{
|
||||
var v1Parts = version1.Split('.').Select(s => int.TryParse(s, out var n) ? n : 0).ToArray();
|
||||
var v2Parts = version2.Split('.').Select(s => int.TryParse(s, out var n) ? n : 0).ToArray();
|
||||
|
||||
var maxLen = Math.Max(v1Parts.Length, v2Parts.Length);
|
||||
|
||||
for (int i = 0; i < maxLen; i++)
|
||||
{
|
||||
var v1 = i < v1Parts.Length ? v1Parts[i] : 0;
|
||||
var v2 = i < v2Parts.Length ? v2Parts[i] : 0;
|
||||
|
||||
if (v1 != v2)
|
||||
return v1.CompareTo(v2);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
+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; }
|
||||
}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
using CMSMicroservice.Application.Common;
|
||||
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 slug = BlogSlugHelper.ResolveForCreate(request.Slug, request.Title.Trim());
|
||||
slug = await BlogSlugHelper.EnsureUniquePostSlugAsync(_context, slug, cancellationToken: cancellationToken);
|
||||
|
||||
var post = new BlogPost
|
||||
{
|
||||
Title = request.Title.Trim(),
|
||||
Slug = slug,
|
||||
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)
|
||||
.MaximumLength(200).WithMessage("نشانی نمیتواند بیشتر از 200 کاراکتر باشد")
|
||||
.Matches(@"^[a-z0-9\-]+$").WithMessage("نشانی فقط میتواند شامل حروف کوچک انگلیسی، اعداد و خط تیره باشد")
|
||||
.When(x => !string.IsNullOrWhiteSpace(x.Slug));
|
||||
|
||||
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; }
|
||||
}
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
using CMSMicroservice.Application.Common;
|
||||
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();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Slug))
|
||||
{
|
||||
post.Slug = await BlogSlugHelper.EnsureUniquePostSlugAsync(
|
||||
_context,
|
||||
BlogSlugHelper.NormalizeOptional(request.Slug)!,
|
||||
post.Id,
|
||||
cancellationToken);
|
||||
}
|
||||
else if (string.IsNullOrWhiteSpace(post.Slug))
|
||||
{
|
||||
var slug = BlogSlugHelper.ResolveForCreate(null, post.Title);
|
||||
post.Slug = await BlogSlugHelper.EnsureUniquePostSlugAsync(
|
||||
_context, slug, post.Id, cancellationToken);
|
||||
}
|
||||
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)
|
||||
.MaximumLength(200).WithMessage("نشانی نمیتواند بیشتر از 200 کاراکتر باشد")
|
||||
.Matches(@"^[a-z0-9\-]+$").WithMessage("نشانی فقط میتواند شامل حروف کوچک انگلیسی، اعداد و خط تیره باشد")
|
||||
.When(x => !string.IsNullOrWhiteSpace(x.Slug));
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -7,10 +7,13 @@
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
|
||||
|
||||
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.2.2" />
|
||||
<PackageReference Include="Mapster" Version="7.4.0" />
|
||||
<PackageReference Include="MediatR.Extensions.Microsoft.DependencyInjection" Version="11.0.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.11" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.11" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.11" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.4.0" />
|
||||
<PackageReference Include="System.Linq.Dynamic.Core" Version="1.6.10" />
|
||||
|
||||
+12
-1
@@ -31,7 +31,18 @@ public class GetAllCategoryByFilterQueryHandler : IRequestHandler<GetAllCategory
|
||||
{
|
||||
MetaData = await query.GetMetaData(request.PaginationState, cancellationToken),
|
||||
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 int SortOrder { get; set; }
|
||||
//تعداد محصولات
|
||||
public int ProductCount { get; set; }
|
||||
}
|
||||
|
||||
+310
-16
@@ -1,5 +1,10 @@
|
||||
using CMSMicroservice.Application.Common.Exceptions;
|
||||
using CMSMicroservice.Application.Common.Interfaces;
|
||||
using CMSMicroservice.Domain.Common;
|
||||
using CMSMicroservice.Domain.Entities;
|
||||
using CMSMicroservice.Domain.Entities.Club;
|
||||
using CMSMicroservice.Domain.Entities.Commission;
|
||||
using CMSMicroservice.Domain.Entities.History;
|
||||
using CMSMicroservice.Domain.Enums;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -12,6 +17,8 @@ namespace CMSMicroservice.Application.ClubMembershipCQ.Commands.AcceptClubMember
|
||||
/// 1. کد OTP را تایید میکند
|
||||
/// 2. قرارداد را در جدول UserContract ثبت میکند
|
||||
/// 3. باشگاه مشتری را فعال میکند (IsActive = true)
|
||||
/// 4. مبلغ را به Pool هفته جاری اضافه میکند
|
||||
/// 5. ویژگیهای باشگاه را به کاربر اعطا میکند
|
||||
/// </summary>
|
||||
public class AcceptClubMembershipContractCommandHandler
|
||||
: IRequestHandler<AcceptClubMembershipContractCommand, AcceptClubMembershipContractResponseDto>
|
||||
@@ -19,6 +26,8 @@ public class AcceptClubMembershipContractCommandHandler
|
||||
private readonly IApplicationDbContext _context;
|
||||
private readonly IConfiguration _cfg;
|
||||
private readonly IHashService _hashService;
|
||||
private readonly ICurrentUserService _currentUser;
|
||||
private readonly IWeekDefinitionRepository _weekRepository;
|
||||
private readonly ILogger<AcceptClubMembershipContractCommandHandler> _logger;
|
||||
|
||||
private const int MaxAttempts = 5;
|
||||
@@ -28,11 +37,15 @@ public class AcceptClubMembershipContractCommandHandler
|
||||
IApplicationDbContext context,
|
||||
IConfiguration cfg,
|
||||
IHashService hashService,
|
||||
ICurrentUserService currentUser,
|
||||
IWeekDefinitionRepository weekRepository,
|
||||
ILogger<AcceptClubMembershipContractCommandHandler> logger)
|
||||
{
|
||||
_context = context;
|
||||
_cfg = cfg;
|
||||
_hashService = hashService;
|
||||
_currentUser = currentUser;
|
||||
_weekRepository = weekRepository;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -40,20 +53,28 @@ public class AcceptClubMembershipContractCommandHandler
|
||||
AcceptClubMembershipContractCommand request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// خواندن UserId از JWT (امنتر از دریافت از کلاینت)
|
||||
if (!long.TryParse(_currentUser.UserId, out var userId))
|
||||
return new AcceptClubMembershipContractResponseDto
|
||||
{
|
||||
Success = false,
|
||||
Message = "کاربر احراز هویت نشده است"
|
||||
};
|
||||
|
||||
_logger.LogInformation(
|
||||
"Processing club membership contract for UserId: {UserId}",
|
||||
request.UserId
|
||||
userId
|
||||
);
|
||||
|
||||
// 1. دریافت کاربر
|
||||
var user = await _context.Users
|
||||
.Include(u => u.ClubMembership)
|
||||
.FirstOrDefaultAsync(u => u.Id == request.UserId, cancellationToken);
|
||||
.FirstOrDefaultAsync(u => u.Id == userId, cancellationToken);
|
||||
|
||||
if (user == null)
|
||||
{
|
||||
_logger.LogWarning("User not found: {UserId}", request.UserId);
|
||||
throw new NotFoundException(nameof(User), request.UserId);
|
||||
_logger.LogWarning("User not found: {UserId}", userId);
|
||||
throw new NotFoundException(nameof(User), userId);
|
||||
}
|
||||
|
||||
// 2. بررسی خرید پکیج
|
||||
@@ -114,33 +135,238 @@ public class AcceptClubMembershipContractCommandHandler
|
||||
};
|
||||
await _context.UserContracts.AddAsync(userContract, cancellationToken);
|
||||
|
||||
// 6. فعالسازی باشگاه مشتریان
|
||||
if (user.ClubMembership == null)
|
||||
// 6. بارگذاری پکیج موثر برای امضای قرارداد
|
||||
// اولویت:
|
||||
// 1) پکیج Cycle جاری (مسیر صحیح برای پرداخت دستی)
|
||||
// 2) آخرین خرید پکیج
|
||||
// 3) پکیج پایه
|
||||
var currentCyclePackageId = await _context.ClubMembershipCycles
|
||||
.Where(c => c.UserId == userId && c.IsCurrentCycle)
|
||||
.OrderByDescending(c => c.CycleNumber)
|
||||
.Select(c => (long?)c.PackageId)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
var latestPurchase = await _context.UserPackagePurchases
|
||||
.Where(p => p.UserId == userId)
|
||||
.OrderByDescending(p => p.PurchasedAt)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
Package? package = null;
|
||||
|
||||
if (currentCyclePackageId.HasValue && currentCyclePackageId.Value > 0)
|
||||
{
|
||||
user.ClubMembership = new Domain.Entities.Club.ClubMembership
|
||||
package = await _context.Packages
|
||||
.FirstOrDefaultAsync(p => p.Id == currentCyclePackageId.Value && !p.IsDeleted, cancellationToken);
|
||||
|
||||
if (package != null)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"AcceptContract package source: CurrentCycle (UserId={UserId}, PackageId={PackageId})",
|
||||
userId,
|
||||
package.Id);
|
||||
}
|
||||
}
|
||||
|
||||
if (package == null && latestPurchase != null)
|
||||
{
|
||||
package = await _context.Packages
|
||||
.FirstOrDefaultAsync(p => p.Id == latestPurchase.PackageId && !p.IsDeleted, cancellationToken);
|
||||
|
||||
if (package != null)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"AcceptContract package source: LatestPurchase (UserId={UserId}, PackageId={PackageId})",
|
||||
userId,
|
||||
package.Id);
|
||||
}
|
||||
}
|
||||
|
||||
if (package == null)
|
||||
{
|
||||
package = await _context.Packages
|
||||
.FirstOrDefaultAsync(p => p.IsBasePackage && !p.IsDeleted, cancellationToken);
|
||||
|
||||
if (package != null)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"AcceptContract package source: BasePackage (UserId={UserId}, PackageId={PackageId})",
|
||||
userId,
|
||||
package.Id);
|
||||
}
|
||||
}
|
||||
|
||||
if (package == null)
|
||||
throw new NotFoundException("پکیج یافت نشد");
|
||||
|
||||
long giftValue = package.ActivationFee;
|
||||
long activationFeeValue = package.ActivationFee;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Using Package {PackageId} ({PackageName}): GiftValue={GiftValue}, ActivationFee={ActivationFee}",
|
||||
package.Id, package.Title, giftValue, activationFeeValue
|
||||
);
|
||||
|
||||
// 7. فعالسازی باشگاه مشتریان
|
||||
ClubMembership clubMembership;
|
||||
bool isNewMembership = user.ClubMembership == null;
|
||||
var activationDate = DateTime.Now;
|
||||
|
||||
if (isNewMembership)
|
||||
{
|
||||
clubMembership = new ClubMembership
|
||||
{
|
||||
UserId = user.Id,
|
||||
IsActive = true,
|
||||
ActivatedAt = DateTime.Now,
|
||||
InitialContribution = 56_000_000,
|
||||
GiftValue = 25_200_000,
|
||||
ActivatedAt = activationDate,
|
||||
FirstActivationDate = activationDate,
|
||||
FirstPackageId = package.Id,
|
||||
LastActivationDate = activationDate,
|
||||
LastPackageId = package.Id,
|
||||
InitialContribution = activationFeeValue,
|
||||
GiftValue = giftValue,
|
||||
TotalEarned = 0,
|
||||
PurchaseMethod = user.PackagePurchaseMethod
|
||||
};
|
||||
await _context.ClubMemberships.AddAsync(user.ClubMembership, cancellationToken);
|
||||
await _context.ClubMemberships.AddAsync(clubMembership, cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Created new club membership for UserId {UserId} via {Method}, GiftValue: {GiftValue}",
|
||||
user.Id,
|
||||
user.PackagePurchaseMethod,
|
||||
giftValue
|
||||
);
|
||||
}
|
||||
else
|
||||
{
|
||||
user.ClubMembership.IsActive = true;
|
||||
user.ClubMembership.ActivatedAt = DateTime.Now;
|
||||
_context.ClubMemberships.Update(user.ClubMembership);
|
||||
clubMembership = user.ClubMembership!;
|
||||
clubMembership.IsActive = true;
|
||||
clubMembership.ActivatedAt = activationDate;
|
||||
clubMembership.LastActivationDate = activationDate;
|
||||
clubMembership.LastPackageId = package.Id;
|
||||
clubMembership.PurchaseMethod = user.PackagePurchaseMethod;
|
||||
_context.ClubMemberships.Update(clubMembership);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Reactivated club membership for UserId {UserId}",
|
||||
user.Id
|
||||
);
|
||||
}
|
||||
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// 8. ثبت تاریخچه
|
||||
var history = new ClubMembershipHistory
|
||||
{
|
||||
ClubMembershipId = clubMembership.Id,
|
||||
UserId = clubMembership.UserId,
|
||||
OldIsActive = !isNewMembership && !user.ClubMembership!.IsActive,
|
||||
NewIsActive = true,
|
||||
Action = ClubMembershipAction.Activated,
|
||||
Reason = isNewMembership
|
||||
? $"Initial activation via contract signing - {user.PackagePurchaseMethod}"
|
||||
: $"Reactivated via contract signing - {user.PackagePurchaseMethod}",
|
||||
PerformedBy = _currentUser.GetPerformedBy()
|
||||
};
|
||||
|
||||
_context.ClubMembershipHistories.Add(history);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// 8.5. آپدیت PackagePurchasedAt سایکل جاری به زمان امضای قرارداد
|
||||
// دلیل: Pool در همین لحظه شارژ میشود — Validation باید به همین هفته اشاره کند
|
||||
// سناریوی cross-week: کاربر هفته ۱۸ پکیج خریده، هفته ۱۹ قرارداد امضا کرده
|
||||
// بدون این Fix: Cycle.PackagePurchasedAt = هفته ۱۸ ولی Pool هفته ۱۹ → اختلاف
|
||||
// با این Fix: Cycle.PackagePurchasedAt = هفته ۱۹ = Pool هفته ۱۹ → درست
|
||||
var currentCycle = await _context.ClubMembershipCycles
|
||||
.FirstOrDefaultAsync(c => c.UserId == userId && c.IsCurrentCycle, cancellationToken);
|
||||
|
||||
if (currentCycle != null)
|
||||
{
|
||||
currentCycle.PackagePurchasedAt = activationDate;
|
||||
_context.ClubMembershipCycles.Update(currentCycle);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Updated Cycle #{CycleNumber} PackagePurchasedAt to contract-signing time for UserId {UserId}",
|
||||
currentCycle.CycleNumber,
|
||||
userId
|
||||
);
|
||||
}
|
||||
else
|
||||
{
|
||||
// در صورتی که Cycle وجود ندارد (legacy یا مسیر مستقیم AcceptContract)
|
||||
// Cycle را همینجا بساز تا Validation درست کار کند
|
||||
_logger.LogWarning(
|
||||
"No IsCurrentCycle found for UserId {UserId} at AcceptContract — creating Cycle now",
|
||||
userId
|
||||
);
|
||||
|
||||
var maxCycleNumber = await _context.ClubMembershipCycles
|
||||
.Where(c => c.ClubMembershipId == clubMembership.Id)
|
||||
.MaxAsync(c => (int?)c.CycleNumber, cancellationToken) ?? 0;
|
||||
|
||||
var newCycle = new ClubMembershipCycle
|
||||
{
|
||||
UserId = userId,
|
||||
ClubMembershipId = clubMembership.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);
|
||||
}
|
||||
var currentWeekDefinitionId = GetCurrentWeekDefinitionId();
|
||||
var weeklyPool = await _context.WeeklyCommissionPools
|
||||
.FirstOrDefaultAsync(p => p.WeekDefinitionId == currentWeekDefinitionId && p.PackageId == package.Id, cancellationToken);
|
||||
|
||||
if (weeklyPool == null)
|
||||
{
|
||||
weeklyPool = new WeeklyCommissionPool
|
||||
{
|
||||
WeekDefinitionId = currentWeekDefinitionId,
|
||||
PackageId = package.Id,
|
||||
TotalPoolAmount = activationFeeValue,
|
||||
TotalBalances = 0,
|
||||
ValuePerBalance = 0,
|
||||
IsCalculated = false,
|
||||
CalculatedAt = null
|
||||
};
|
||||
|
||||
await _context.WeeklyCommissionPools.AddAsync(weeklyPool, cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Created new WeeklyCommissionPool for WeekDefinitionId={WeekDefinitionId} with initial amount: {Amount}",
|
||||
currentWeekDefinitionId,
|
||||
activationFeeValue
|
||||
);
|
||||
}
|
||||
else
|
||||
{
|
||||
weeklyPool.TotalPoolAmount += activationFeeValue;
|
||||
_context.WeeklyCommissionPools.Update(weeklyPool);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Added {Amount} to existing WeeklyCommissionPool for WeekDefinitionId={WeekDefinitionId}. New total: {NewTotal}",
|
||||
activationFeeValue,
|
||||
currentWeekDefinitionId,
|
||||
weeklyPool.TotalPoolAmount
|
||||
);
|
||||
}
|
||||
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// 10. اعمال ویژگیهای پکیج — DIFF/تفاضل (Q20)
|
||||
await ApplyFeatureDiffAsync(user.Id, clubMembership.Id, package.Id, isNewMembership, activationDate, cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Club membership contract accepted and activated for UserId: {UserId}, ContractId: {ContractId}",
|
||||
"Club membership contract accepted and activated for UserId: {UserId}, ContractId: {ContractId}, MembershipId: {MembershipId}",
|
||||
request.UserId,
|
||||
userContract.Id
|
||||
userContract.Id,
|
||||
clubMembership.Id
|
||||
);
|
||||
|
||||
return new AcceptClubMembershipContractResponseDto
|
||||
@@ -151,6 +377,74 @@ public class AcceptClubMembershipContractCommandHandler
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// دریافت شناسه تعریف هفته جاری
|
||||
/// </summary>
|
||||
private long GetCurrentWeekDefinitionId()
|
||||
{
|
||||
var week = _weekRepository.GetCurrentWeek();
|
||||
if (week == null)
|
||||
{
|
||||
throw new InvalidOperationException("هفته جاری در سیستم تعریف نشده است");
|
||||
}
|
||||
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(
|
||||
string mobile,
|
||||
string code,
|
||||
|
||||
-4
@@ -4,10 +4,6 @@ public class AcceptClubMembershipContractCommandValidator : AbstractValidator<Ac
|
||||
{
|
||||
public AcceptClubMembershipContractCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.UserId)
|
||||
.GreaterThan(0)
|
||||
.WithMessage("شناسه کاربر الزامی است");
|
||||
|
||||
RuleFor(x => x.OtpCode)
|
||||
.NotEmpty()
|
||||
.WithMessage("کد تایید الزامی است")
|
||||
|
||||
+11
@@ -12,4 +12,15 @@ public record ActivateClubMembershipCommand : IRequest<bool>
|
||||
/// شناسه کاربر
|
||||
/// </summary>
|
||||
public long UserId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// فعالسازی اجباری توسط ادمین — بدون بررسی سفارش و کیفپول
|
||||
/// </summary>
|
||||
public bool ForceActivation { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// شناسه پکیج — فقط در ForceActivation استفاده میشود.
|
||||
/// اگر 0 باشد، پکیج پایه (IsBasePackage) استفاده میشود.
|
||||
/// </summary>
|
||||
public long PackageId { get; init; }
|
||||
}
|
||||
|
||||
+308
-189
@@ -1,6 +1,8 @@
|
||||
using CMSMicroservice.Application.Common.Exceptions;
|
||||
using CMSMicroservice.Application.Common.Helpers;
|
||||
using CMSMicroservice.Application.Common.Interfaces;
|
||||
using CMSMicroservice.Application.Common.Models;
|
||||
using CMSMicroservice.Domain.Common;
|
||||
using CMSMicroservice.Domain.Entities;
|
||||
using CMSMicroservice.Domain.Entities.Club;
|
||||
using CMSMicroservice.Domain.Entities.Commission;
|
||||
@@ -53,133 +55,149 @@ public class ActivateClubMembershipCommandHandler : IRequestHandler<ActivateClub
|
||||
throw new NotFoundException(nameof(User), request.UserId);
|
||||
}
|
||||
|
||||
// 2. بررسی اینکه پکیج خریده باشد
|
||||
if (user.PackagePurchaseMethod == PackagePurchaseMethod.None)
|
||||
// متغیر پکیج — در هر دو مسیر (عادی و Force) مقداردهی میشود
|
||||
Package package;
|
||||
|
||||
// 2-5: بررسیهای مالی — در حالت ForceActivation (ادمین) رد میشود
|
||||
if (!request.ForceActivation)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"User {UserId} has not purchased golden package yet",
|
||||
// 2. بررسی اینکه پکیج خریده باشد
|
||||
if (user.PackagePurchaseMethod == PackagePurchaseMethod.None)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"User {UserId} has not purchased any package yet",
|
||||
request.UserId
|
||||
);
|
||||
throw new BadRequestException(
|
||||
"برای فعالسازی باشگاه مشتریان ابتدا باید یک پکیج خریداری کنید"
|
||||
);
|
||||
}
|
||||
|
||||
// 3. بررسی موجودی کیف پول
|
||||
var wallet = await _context.UserWallets
|
||||
.FirstOrDefaultAsync(w => w.UserId == user.Id, cancellationToken);
|
||||
|
||||
if (wallet == null)
|
||||
{
|
||||
_logger.LogError("Wallet not found for UserId: {UserId}", request.UserId);
|
||||
throw new NotFoundException("کیف پول کاربر یافت نشد");
|
||||
}
|
||||
|
||||
// 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(
|
||||
"User {UserId} cannot activate club while in Magic wallet mode",
|
||||
request.UserId
|
||||
);
|
||||
throw new BadRequestException(
|
||||
"ابتدا باید چرخه کیفپول جادویی تکمیل شود"
|
||||
);
|
||||
}
|
||||
|
||||
// 4. پیدا کردن آخرین خرید موفق پکیج
|
||||
var packagePurchase = await _context.UserPackagePurchases
|
||||
.Include(p => p.Transaction)
|
||||
.Where(p =>
|
||||
p.UserId == user.Id &&
|
||||
p.Transaction != null &&
|
||||
p.Transaction.PaymentStatus == PaymentStatus.Success)
|
||||
.OrderByDescending(p => p.Created)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (packagePurchase == null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"No successful package purchase found for UserId: {UserId}",
|
||||
request.UserId
|
||||
);
|
||||
throw new NotFoundException("سفارش پکیج یافت نشد");
|
||||
}
|
||||
|
||||
// 5. بررسی Transaction
|
||||
if (packagePurchase.Transaction == null)
|
||||
{
|
||||
_logger.LogError(
|
||||
"Transaction not found for PurchaseId: {PurchaseId}",
|
||||
packagePurchase.Id
|
||||
);
|
||||
throw new NotFoundException("تراکنش مربوط به سفارش یافت نشد");
|
||||
}
|
||||
|
||||
var transaction = packagePurchase.Transaction;
|
||||
|
||||
if (transaction.Type != TransactionType.Buy &&
|
||||
transaction.Type != TransactionType.DepositIpg &&
|
||||
transaction.Type != TransactionType.DepositExternal1)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Invalid transaction type for PurchaseId {PurchaseId}: {Type}",
|
||||
packagePurchase.Id,
|
||||
transaction.Type
|
||||
);
|
||||
throw new BadRequestException(
|
||||
"تراکنش معتبر برای فعالسازی باشگاه یافت نشد"
|
||||
);
|
||||
}
|
||||
|
||||
// 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
|
||||
);
|
||||
throw new BadRequestException(
|
||||
"برای فعالسازی باشگاه مشتریان ابتدا باید پکیج طلایی خریداری کنید"
|
||||
);
|
||||
}
|
||||
|
||||
// 3. بررسی موجودی کیف پول
|
||||
var wallet = await _context.UserWallets
|
||||
.FirstOrDefaultAsync(w => w.UserId == user.Id, cancellationToken);
|
||||
// در فعالسازی اجباری ادمین، اگر روش خرید تنظیم نشده، دستی در نظر بگیر
|
||||
if (user.PackagePurchaseMethod == PackagePurchaseMethod.None)
|
||||
{
|
||||
user.PackagePurchaseMethod = PackagePurchaseMethod.Manual;
|
||||
_context.Users.Update(user);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
if (wallet == null)
|
||||
{
|
||||
_logger.LogError("Wallet not found for UserId: {UserId}", request.UserId);
|
||||
throw new NotFoundException("کیف پول کاربر یافت نشد");
|
||||
}
|
||||
|
||||
if (wallet.Balance < 56_000_000)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"User {UserId} has insufficient balance: {Balance}",
|
||||
request.UserId,
|
||||
wallet.Balance
|
||||
);
|
||||
throw new BadRequestException(
|
||||
"برای فعالسازی باشگاه مشتریان باید حداقل 56 میلیون تومان موجودی اصلی داشته باشید"
|
||||
);
|
||||
}
|
||||
|
||||
// 4. پیدا کردن UserOrder با PackageId
|
||||
var packageOrder = await _context.UserOrders
|
||||
.Include(o => o.Transaction)
|
||||
.Where(o =>
|
||||
o.UserId == user.Id &&
|
||||
o.PackageId != null &&
|
||||
o.PaymentStatus == PaymentStatus.Success)
|
||||
.OrderByDescending(o => o.Created)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (packageOrder == null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"No successful package order found for UserId: {UserId}",
|
||||
request.UserId
|
||||
);
|
||||
throw new NotFoundException("سفارش پکیج طلایی یافت نشد");
|
||||
}
|
||||
|
||||
// 5. بررسی Transaction
|
||||
if (packageOrder.Transaction == null)
|
||||
{
|
||||
_logger.LogError(
|
||||
"Transaction not found for OrderId: {OrderId}",
|
||||
packageOrder.Id
|
||||
);
|
||||
throw new NotFoundException("تراکنش مربوط به سفارش یافت نشد");
|
||||
}
|
||||
|
||||
var transaction = packageOrder.Transaction;
|
||||
|
||||
if (transaction.Type != TransactionType.DepositIpg &&
|
||||
transaction.Type != TransactionType.DepositExternal1)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Invalid transaction type for OrderId {OrderId}: {Type}",
|
||||
packageOrder.Id,
|
||||
transaction.Type
|
||||
);
|
||||
throw new BadRequestException(
|
||||
"تراکنش معتبر برای فعالسازی باشگاه یافت نشد"
|
||||
);
|
||||
// بارگذاری پکیج — اگر PackageId مشخص شده همان را لود کن، وگرنه پکیج پایه
|
||||
if (request.PackageId > 0)
|
||||
{
|
||||
package = await _context.Packages
|
||||
.FirstOrDefaultAsync(p => p.Id == request.PackageId && !p.IsDeleted, cancellationToken)
|
||||
?? throw new NotFoundException($"پکیج {request.PackageId} یافت نشد");
|
||||
}
|
||||
else
|
||||
{
|
||||
package = await _context.Packages
|
||||
.FirstOrDefaultAsync(p => p.IsBasePackage && !p.IsDeleted, cancellationToken)
|
||||
?? throw new NotFoundException("پکیج پایه یافت نشد");
|
||||
}
|
||||
}
|
||||
|
||||
// 6. بررسی عضویت فعلی
|
||||
var existingMembership = await _context.ClubMemberships
|
||||
.FirstOrDefaultAsync(c => c.UserId == user.Id, cancellationToken);
|
||||
|
||||
// 6.1. دریافت مبلغ هدیه از تنظیمات
|
||||
var giftValueConfig = await _context.SystemConfigurations
|
||||
.FirstOrDefaultAsync(
|
||||
c => c.Key == "Club.MembershipGiftValue" && c.IsActive,
|
||||
cancellationToken
|
||||
);
|
||||
// 6.1. دریافت مبلغ هدیه و هزینه فعالسازی از پکیج
|
||||
long giftValue = package.ActivationFee;
|
||||
long activationFeeValue = package.ActivationFee;
|
||||
|
||||
var activationFeeConfig = await _context.SystemConfigurations
|
||||
.FirstOrDefaultAsync(
|
||||
c => c.Key == "Club.ActivationFee" && c.IsActive,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
long giftValue = 28_000_000; // مقدار پیشفرض
|
||||
if (giftValueConfig != null && long.TryParse(giftValueConfig.Value, out var configValue))
|
||||
{
|
||||
giftValue = configValue;
|
||||
_logger.LogInformation(
|
||||
"Using Club.MembershipGiftValue from configuration: {GiftValue}",
|
||||
giftValue
|
||||
);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Club.MembershipGiftValue not found in configuration, using default: {GiftValue}",
|
||||
giftValue
|
||||
);
|
||||
}
|
||||
long activationFeeValue = 25_200_000; // مقدار پیشفرض
|
||||
if (activationFeeConfig != null && long.TryParse(activationFeeConfig.Value, out var activationFeeConfigValue))
|
||||
{
|
||||
activationFeeValue = activationFeeConfigValue;
|
||||
_logger.LogInformation(
|
||||
"Using Club.ActivationFee from configuration: {activationFeeValue}",
|
||||
activationFeeValue
|
||||
);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Club.ActivationFee not found in configuration, using default: {activationFeeValue}",
|
||||
activationFeeValue
|
||||
);
|
||||
}
|
||||
_logger.LogInformation(
|
||||
"Using Package {PackageId} ({PackageName}): GiftValue={GiftValue}, ActivationFee={ActivationFee}, Price={Price}",
|
||||
package.Id, package.Title, giftValue, activationFeeValue, package.Price
|
||||
);
|
||||
|
||||
ClubMembership entity;
|
||||
bool isNewMembership = existingMembership == null;
|
||||
@@ -187,14 +205,18 @@ public class ActivateClubMembershipCommandHandler : IRequestHandler<ActivateClub
|
||||
|
||||
if (isNewMembership)
|
||||
{
|
||||
// ایجاد عضویت جدید
|
||||
// ایجاد عضویت جدید — IsActive = false تا زمان امضای قرارداد باشگاه
|
||||
entity = new ClubMembership
|
||||
{
|
||||
UserId = user.Id,
|
||||
IsActive = true,
|
||||
IsActive = false,
|
||||
ActivatedAt = activationDate,
|
||||
InitialContribution =activationFeeValue,
|
||||
GiftValue = giftValue, // مقدار از تنظیمات
|
||||
FirstActivationDate = activationDate,
|
||||
FirstPackageId = package.Id,
|
||||
LastActivationDate = activationDate,
|
||||
LastPackageId = package.Id,
|
||||
InitialContribution = activationFeeValue,
|
||||
GiftValue = giftValue,
|
||||
TotalEarned = 0,
|
||||
PurchaseMethod = user.PackagePurchaseMethod
|
||||
};
|
||||
@@ -210,31 +232,79 @@ public class ActivateClubMembershipCommandHandler : IRequestHandler<ActivateClub
|
||||
}
|
||||
else
|
||||
{
|
||||
if (existingMembership.IsActive)
|
||||
// بررسی آیا چرخه فعال دارد — اگر بله، واقعاً فعال است و نیازی به کار نیست
|
||||
var hasCurrentCycle = await _context.ClubMembershipCycles
|
||||
.AnyAsync(c => c.UserId == user.Id && c.IsCurrentCycle, cancellationToken);
|
||||
|
||||
// اگر چرخه فعال دارد و درخواست Force نیست — احتمال اجرای تکراری همان خرید
|
||||
// در حالت ForceActivation (مثل پرداخت دستی)، باید چرخه جدید ساخته شود تا پکیج جدید ثبت شود.
|
||||
if (hasCurrentCycle && !request.ForceActivation)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"User {UserId} is already an active club member",
|
||||
"User {UserId} already has a running cycle — skipping duplicate activation",
|
||||
user.Id
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
// فعالسازی مجدد
|
||||
// فعالسازی مجدد یا خرید مجدد (Q19/Q20) — FirstActivation حفظ میشه (Q21), Last بروزرسانی میشه
|
||||
// مقدار IsActive قبلی حفظ میشه — اگر قبلاً فعال بوده، فعال بمونه
|
||||
entity = existingMembership;
|
||||
entity.IsActive = true;
|
||||
entity.ActivatedAt = activationDate;
|
||||
entity.LastActivationDate = activationDate;
|
||||
entity.LastPackageId = package.Id;
|
||||
entity.PurchaseMethod = user.PackagePurchaseMethod;
|
||||
|
||||
_context.ClubMemberships.Update(entity);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Reactivated club membership for UserId {UserId}",
|
||||
user.Id
|
||||
"Re-activating club membership for UserId {UserId} (re-purchase scenario, IsActive was {WasActive})",
|
||||
user.Id,
|
||||
existingMembership.IsActive
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
ClubMembershipCycleHistoryWriter.WriteCycleClosed(_context, prevCycle, reason: "شروع دوره جدید");
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
ClubMembershipCycleHistoryWriter.WriteCycleStarted(_context, newCycle, reason: "فعالسازی عضویت باشگاه");
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Created ClubMembershipCycle #{CycleNumber} for UserId {UserId}, MembershipId {MembershipId}",
|
||||
newCycle.CycleNumber,
|
||||
user.Id,
|
||||
entity.Id
|
||||
);
|
||||
|
||||
// 7. ثبت تاریخچه
|
||||
var history = new ClubMembershipHistory
|
||||
{
|
||||
@@ -253,81 +323,62 @@ public class ActivateClubMembershipCommandHandler : IRequestHandler<ActivateClub
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// ⭐ 8. اضافه کردن مبلغ به Pool هفته جاری
|
||||
var currentWeekDefinitionId = GetCurrentWeekDefinitionId();
|
||||
var weeklyPool = await _context.WeeklyCommissionPools
|
||||
.FirstOrDefaultAsync(p => p.WeekDefinitionId == currentWeekDefinitionId, cancellationToken);
|
||||
|
||||
if (weeklyPool == null)
|
||||
// عضو جدید (isNewMembership=true): Pool توسط AcceptClubMembershipContract شارژ میشود
|
||||
// چون کاربر قبل از امضای قرارداد IsActive=false است و قرارداد فقط یکبار امضا میشود.
|
||||
// خرید مجدد (isNewMembership=false): قرارداد مجدد امضا نمیشود — Pool همینجا شارژ میشود.
|
||||
if (!isNewMembership)
|
||||
{
|
||||
// ایجاد Pool جدید برای این هفته
|
||||
weeklyPool = new WeeklyCommissionPool
|
||||
var currentWeekDefinitionId = GetCurrentWeekDefinitionId();
|
||||
var weeklyPool = await _context.WeeklyCommissionPools
|
||||
.FirstOrDefaultAsync(p => p.WeekDefinitionId == currentWeekDefinitionId && p.PackageId == package.Id, cancellationToken);
|
||||
|
||||
if (weeklyPool == null)
|
||||
{
|
||||
WeekDefinitionId = currentWeekDefinitionId,
|
||||
TotalPoolAmount = activationFeeValue, // مبلغ هدیه به Pool اضافه میشه
|
||||
TotalBalances = 0, // در CalculateWeeklyBalances محاسبه میشه
|
||||
ValuePerBalance = 0, // در CalculateWeeklyCommissionPool محاسبه میشه
|
||||
IsCalculated = false,
|
||||
CalculatedAt = null
|
||||
};
|
||||
// ایجاد Pool جدید برای این هفته × این پکیج
|
||||
weeklyPool = new WeeklyCommissionPool
|
||||
{
|
||||
WeekDefinitionId = currentWeekDefinitionId,
|
||||
PackageId = package.Id,
|
||||
TotalPoolAmount = activationFeeValue,
|
||||
TotalBalances = 0,
|
||||
ValuePerBalance = 0,
|
||||
IsCalculated = false,
|
||||
CalculatedAt = null
|
||||
};
|
||||
|
||||
await _context.WeeklyCommissionPools.AddAsync(weeklyPool, cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Created new WeeklyCommissionPool for WeekDefinitionId={WeekDefinitionId} with initial amount: {Amount}",
|
||||
currentWeekDefinitionId,
|
||||
giftValue
|
||||
);
|
||||
}
|
||||
else
|
||||
{
|
||||
// اضافه کردن به Pool موجود
|
||||
weeklyPool.TotalPoolAmount += activationFeeValue;
|
||||
_context.WeeklyCommissionPools.Update(weeklyPool);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Added {Amount} to existing WeeklyCommissionPool for WeekDefinitionId={WeekDefinitionId}. New total: {NewTotal}",
|
||||
activationFeeValue,
|
||||
currentWeekDefinitionId,
|
||||
weeklyPool.TotalPoolAmount
|
||||
);
|
||||
}
|
||||
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// 9. اضافه کردن ویژگیهای باشگاه برای کاربر (فقط برای عضویت جدید)
|
||||
if (isNewMembership)
|
||||
{
|
||||
var clubFeatures = await _context.ClubFeatures
|
||||
.Where(f => !f.IsDeleted && new long[] { 1, 2, 3, 4 }.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);
|
||||
await _context.WeeklyCommissionPools.AddAsync(weeklyPool, cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Granted {Count} club features to UserId {UserId}",
|
||||
clubFeatures.Count,
|
||||
user.Id
|
||||
"Created new WeeklyCommissionPool for WeekDefinitionId={WeekDefinitionId} with initial amount: {Amount}",
|
||||
currentWeekDefinitionId,
|
||||
activationFeeValue
|
||||
);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"No club features found to grant to UserId {UserId}",
|
||||
user.Id
|
||||
weeklyPool.TotalPoolAmount += activationFeeValue;
|
||||
_context.WeeklyCommissionPools.Update(weeklyPool);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Added {Amount} to existing WeeklyCommissionPool for WeekDefinitionId={WeekDefinitionId}. New total: {NewTotal}",
|
||||
activationFeeValue,
|
||||
currentWeekDefinitionId,
|
||||
weeklyPool.TotalPoolAmount
|
||||
);
|
||||
}
|
||||
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"New member — Pool charge deferred to AcceptClubMembershipContract for UserId {UserId}",
|
||||
user.Id
|
||||
);
|
||||
}
|
||||
|
||||
// 9. اعمال ویژگیهای پکیج — DIFF/تفاضل (Q20)
|
||||
await ApplyFeatureDiffAsync(user.Id, entity.Id, package.Id, isNewMembership, activationDate, cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Club membership activated successfully. UserId: {UserId}, MembershipId: {MembershipId}",
|
||||
@@ -348,6 +399,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>
|
||||
|
||||
+4
-1
@@ -13,6 +13,7 @@ public class GetAllClubMembershipsQueryHandler : IRequestHandler<GetAllClubMembe
|
||||
{
|
||||
var query = _context.ClubMemberships
|
||||
.Include(x => x.User)
|
||||
.Include(x => x.LastPackage)
|
||||
.ApplyOrder(sortBy: request.SortBy)
|
||||
.AsNoTracking()
|
||||
.AsQueryable();
|
||||
@@ -35,8 +36,10 @@ public class GetAllClubMembershipsQueryHandler : IRequestHandler<GetAllClubMembe
|
||||
Id = x.Id,
|
||||
UserId = x.UserId,
|
||||
UserName = x.User != null ? $"{x.User.FirstName} {x.User.LastName}" : string.Empty,
|
||||
PackageId = x.LastPackageId ?? 0,
|
||||
PackageName = x.LastPackage != null ? x.LastPackage.Title : string.Empty,
|
||||
IsActive = x.IsActive,
|
||||
ActivatedAt = x.ActivatedAt,
|
||||
ActivatedAt = x.LastActivationDate ?? x.ActivatedAt,
|
||||
InitialContribution = x.InitialContribution,
|
||||
TotalEarned = x.TotalEarned,
|
||||
Created = x.Created,
|
||||
|
||||
+2
@@ -11,6 +11,8 @@ public class GetAllClubMembershipsResponseModel
|
||||
public long Id { get; set; }
|
||||
public long UserId { get; set; }
|
||||
public string UserName { get; set; } = string.Empty;
|
||||
public long PackageId { get; set; }
|
||||
public string PackageName { get; set; } = string.Empty;
|
||||
public bool IsActive { get; set; }
|
||||
public DateTime? ActivatedAt { get; set; }
|
||||
public long InitialContribution { get; set; }
|
||||
|
||||
+25
-1
@@ -3,14 +3,18 @@ namespace CMSMicroservice.Application.ClubMembershipCQ.Queries.GetClubMembership
|
||||
public class GetClubMembershipQueryHandler : IRequestHandler<GetClubMembershipQuery, ClubMembershipDto?>
|
||||
{
|
||||
private readonly IApplicationDbContext _context;
|
||||
private readonly ILogger<GetClubMembershipQueryHandler> _logger;
|
||||
|
||||
public GetClubMembershipQueryHandler(IApplicationDbContext context)
|
||||
public GetClubMembershipQueryHandler(IApplicationDbContext context, ILogger<GetClubMembershipQueryHandler> logger)
|
||||
{
|
||||
_context = context;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<ClubMembershipDto?> Handle(GetClubMembershipQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("GetClubMembership called for UserId: {UserId}", request.UserId);
|
||||
|
||||
var membership = await _context.ClubMemberships
|
||||
.AsNoTracking()
|
||||
.Where(x => x.UserId == request.UserId)
|
||||
@@ -27,6 +31,26 @@ public class GetClubMembershipQueryHandler : IRequestHandler<GetClubMembershipQu
|
||||
})
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
// اگر کاربر عضویت نداره، یک DTO با وضعیت غیرفعال برگردون
|
||||
if (membership == null)
|
||||
{
|
||||
_logger.LogInformation("No membership found for UserId: {UserId}, returning inactive status", request.UserId);
|
||||
return new ClubMembershipDto
|
||||
{
|
||||
Id = 0,
|
||||
UserId = request.UserId,
|
||||
IsActive = false,
|
||||
ActivatedAt = null,
|
||||
InitialContribution = 0,
|
||||
TotalEarned = 0,
|
||||
Created = DateTimeOffset.UtcNow,
|
||||
LastModified = null
|
||||
};
|
||||
}
|
||||
|
||||
_logger.LogInformation("Membership found for UserId: {UserId}, IsActive: {IsActive}, Id: {Id}",
|
||||
request.UserId, membership.IsActive, membership.Id);
|
||||
|
||||
return membership;
|
||||
}
|
||||
}
|
||||
|
||||
+10
-20
@@ -1,27 +1,17 @@
|
||||
namespace CMSMicroservice.Application.ClubMembershipCQ.Queries.GetClubMembershipHistory;
|
||||
|
||||
/// <summary>
|
||||
/// Query برای دریافت تاریخچه تغییرات عضویت باشگاه
|
||||
/// </summary>
|
||||
public record GetClubMembershipHistoryQuery : IRequest<GetClubMembershipHistoryResponseDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// شناسه عضویت (اختیاری)
|
||||
/// </summary>
|
||||
public long? MembershipId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// شناسه کاربر (اختیاری)
|
||||
/// </summary>
|
||||
public long? UserId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// موقعیت صفحهبندی
|
||||
/// </summary>
|
||||
public PaginationState? PaginationState { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// مرتبسازی بر اساس
|
||||
/// </summary>
|
||||
public string? SortBy { get; init; }
|
||||
public GetClubMembershipHistoryFilter? Filter { get; init; }
|
||||
}
|
||||
|
||||
public class GetClubMembershipHistoryFilter
|
||||
{
|
||||
public long? UserId { get; set; }
|
||||
public long? ClubMembershipId { get; set; }
|
||||
public ClubMembershipAction? Action { get; set; }
|
||||
public DateTime? CreatedFrom { get; set; }
|
||||
public DateTime? CreatedTo { get; set; }
|
||||
}
|
||||
|
||||
+21
-8
@@ -12,20 +12,30 @@ public class GetClubMembershipHistoryQueryHandler : IRequestHandler<GetClubMembe
|
||||
public async Task<GetClubMembershipHistoryResponseDto> Handle(GetClubMembershipHistoryQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var query = _context.ClubMembershipHistories
|
||||
.Include(x => x.ClubMembership)
|
||||
.ThenInclude(m => m!.User)
|
||||
.AsNoTracking()
|
||||
.AsQueryable();
|
||||
|
||||
if (request.MembershipId.HasValue)
|
||||
if (request.Filter != null)
|
||||
{
|
||||
query = query.Where(x => x.ClubMembershipId == request.MembershipId.Value);
|
||||
if (request.Filter.ClubMembershipId.HasValue)
|
||||
query = query.Where(x => x.ClubMembershipId == request.Filter.ClubMembershipId.Value);
|
||||
|
||||
if (request.Filter.UserId.HasValue)
|
||||
query = query.Where(x => x.UserId == request.Filter.UserId.Value);
|
||||
|
||||
if (request.Filter.Action.HasValue)
|
||||
query = query.Where(x => x.Action == request.Filter.Action.Value);
|
||||
|
||||
if (request.Filter.CreatedFrom.HasValue)
|
||||
query = query.Where(x => x.Created >= request.Filter.CreatedFrom.Value);
|
||||
|
||||
if (request.Filter.CreatedTo.HasValue)
|
||||
query = query.Where(x => x.Created <= request.Filter.CreatedTo.Value);
|
||||
}
|
||||
|
||||
if (request.UserId.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.UserId == request.UserId.Value);
|
||||
}
|
||||
|
||||
query = query.ApplyOrder(sortBy: request.SortBy ?? "Created"); // پیشفرض: جدیدترین اول
|
||||
query = query.ApplyOrder(sortBy: request.SortBy ?? "-Created");
|
||||
|
||||
var meta = await query.GetMetaData(request.PaginationState, cancellationToken);
|
||||
|
||||
@@ -36,6 +46,9 @@ public class GetClubMembershipHistoryQueryHandler : IRequestHandler<GetClubMembe
|
||||
Id = x.Id,
|
||||
ClubMembershipId = x.ClubMembershipId,
|
||||
UserId = x.UserId,
|
||||
UserName = x.ClubMembership != null && x.ClubMembership.User != null
|
||||
? $"{x.ClubMembership.User.FirstName} {x.ClubMembership.User.LastName}"
|
||||
: string.Empty,
|
||||
OldIsActive = x.OldIsActive,
|
||||
NewIsActive = x.NewIsActive,
|
||||
OldInitialContribution = x.OldInitialContribution,
|
||||
|
||||
+1
-26
@@ -4,31 +4,6 @@ public class GetClubMembershipHistoryQueryValidator : AbstractValidator<GetClubM
|
||||
{
|
||||
public GetClubMembershipHistoryQueryValidator()
|
||||
{
|
||||
RuleFor(x => x.MembershipId)
|
||||
.GreaterThan(0)
|
||||
.WithMessage("شناسه عضویت معتبر نیست")
|
||||
.When(x => x.MembershipId.HasValue);
|
||||
|
||||
RuleFor(x => x.UserId)
|
||||
.GreaterThan(0)
|
||||
.WithMessage("شناسه کاربر معتبر نیست")
|
||||
.When(x => x.UserId.HasValue);
|
||||
|
||||
RuleFor(x => x)
|
||||
.Must(x => x.MembershipId.HasValue || x.UserId.HasValue)
|
||||
.WithMessage("حداقل یکی از MembershipId یا UserId باید مقداردهی شود");
|
||||
// Admin report: filters are all optional
|
||||
}
|
||||
|
||||
public Func<object, string, Task<IEnumerable<string>>> ValidateValue => async (model, propertyName) =>
|
||||
{
|
||||
var result = await ValidateAsync(
|
||||
ValidationContext<GetClubMembershipHistoryQuery>.CreateWithOptions(
|
||||
(GetClubMembershipHistoryQuery)model,
|
||||
x => x.IncludeProperties(propertyName)));
|
||||
|
||||
if (result.IsValid)
|
||||
return Array.Empty<string>();
|
||||
|
||||
return result.Errors.Select(e => e.ErrorMessage);
|
||||
};
|
||||
}
|
||||
|
||||
+1
@@ -11,6 +11,7 @@ public class GetClubMembershipHistoryResponseModel
|
||||
public long Id { get; set; }
|
||||
public long ClubMembershipId { get; set; }
|
||||
public long UserId { get; set; }
|
||||
public string UserName { get; set; } = string.Empty;
|
||||
public bool OldIsActive { get; set; }
|
||||
public bool NewIsActive { get; set; }
|
||||
public long? OldInitialContribution { get; set; }
|
||||
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
using CMSMicroservice.Domain.Entities.Club;
|
||||
|
||||
namespace CMSMicroservice.Application.ClubMembershipCycleCQ.Queries.GetAllClubMembershipCycleByFilter;
|
||||
|
||||
public static class ClubMembershipCycleFilterExtensions
|
||||
{
|
||||
public static IQueryable<ClubMembershipCycle> ApplyClubMembershipCycleFilter(
|
||||
this IQueryable<ClubMembershipCycle> query,
|
||||
GetAllClubMembershipCycleByFilterFilter? filter)
|
||||
{
|
||||
if (filter == null) return query;
|
||||
|
||||
if (filter.UserId.HasValue)
|
||||
query = query.Where(x => x.UserId == filter.UserId.Value);
|
||||
|
||||
if (filter.IsCurrentCycle.HasValue)
|
||||
query = query.Where(x => x.IsCurrentCycle == filter.IsCurrentCycle.Value);
|
||||
|
||||
if (filter.PackageId.HasValue)
|
||||
query = query.Where(x => x.PackageId == filter.PackageId.Value);
|
||||
|
||||
if (filter.CycleNumber.HasValue)
|
||||
query = query.Where(x => x.CycleNumber == filter.CycleNumber.Value);
|
||||
|
||||
if (filter.PurchaseMethod.HasValue)
|
||||
query = query.Where(x => x.PurchaseMethod == filter.PurchaseMethod.Value);
|
||||
|
||||
if (filter.MagicState.HasValue)
|
||||
{
|
||||
query = filter.MagicState.Value switch
|
||||
{
|
||||
1 => query.Where(x => x.MagicStartedAt != null && x.MagicCompletedAt == null),
|
||||
2 => query.Where(x => x.MagicCompletedAt != null),
|
||||
_ => query.Where(x => x.MagicStartedAt == null && x.MagicCompletedAt == null)
|
||||
};
|
||||
}
|
||||
|
||||
if (filter.PackagePurchasedFrom.HasValue)
|
||||
query = query.Where(x => x.PackagePurchasedAt >= filter.PackagePurchasedFrom.Value);
|
||||
|
||||
if (filter.PackagePurchasedTo.HasValue)
|
||||
query = query.Where(x => x.PackagePurchasedAt <= filter.PackagePurchasedTo.Value);
|
||||
|
||||
return query;
|
||||
}
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
using CMSMicroservice.Domain.Enums;
|
||||
|
||||
namespace CMSMicroservice.Application.ClubMembershipCycleCQ.Queries.GetAllClubMembershipCycleByFilter;
|
||||
|
||||
public record GetAllClubMembershipCycleByFilterQuery : IRequest<GetAllClubMembershipCycleByFilterResponseDto>
|
||||
{
|
||||
public PaginationState? PaginationState { get; init; }
|
||||
public string? SortBy { get; init; }
|
||||
public GetAllClubMembershipCycleByFilterFilter? Filter { get; init; }
|
||||
}
|
||||
|
||||
public class GetAllClubMembershipCycleByFilterFilter
|
||||
{
|
||||
public long? UserId { get; set; }
|
||||
public bool? IsCurrentCycle { get; set; }
|
||||
public long? PackageId { get; set; }
|
||||
public int? CycleNumber { get; set; }
|
||||
public PackagePurchaseMethod? PurchaseMethod { get; set; }
|
||||
/// <summary>0=none, 1=active magic, 2=completed magic</summary>
|
||||
public int? MagicState { get; set; }
|
||||
public DateTime? PackagePurchasedFrom { get; set; }
|
||||
public DateTime? PackagePurchasedTo { get; set; }
|
||||
}
|
||||
|
||||
public class GetAllClubMembershipCycleByFilterResponseDto
|
||||
{
|
||||
public MetaData MetaData { get; set; }
|
||||
public List<GetAllClubMembershipCycleByFilterResponseModel>? Models { get; set; }
|
||||
}
|
||||
|
||||
public class GetAllClubMembershipCycleByFilterResponseModel
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public long UserId { get; set; }
|
||||
public string UserName { get; set; } = string.Empty;
|
||||
public long ClubMembershipId { get; set; }
|
||||
public int CycleNumber { get; set; }
|
||||
public long PackageId { get; set; }
|
||||
public string PackageName { get; set; } = string.Empty;
|
||||
public long PackageAmount { get; set; }
|
||||
public PackagePurchaseMethod PurchaseMethod { get; set; }
|
||||
public DateTime PackagePurchasedAt { get; set; }
|
||||
public DateTime? MagicStartedAt { get; set; }
|
||||
public DateTime? MagicCompletedAt { get; set; }
|
||||
public bool IsCurrentCycle { get; set; }
|
||||
public DateTimeOffset Created { get; set; }
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
namespace CMSMicroservice.Application.ClubMembershipCycleCQ.Queries.GetAllClubMembershipCycleByFilter;
|
||||
|
||||
public class GetAllClubMembershipCycleByFilterQueryHandler
|
||||
: IRequestHandler<GetAllClubMembershipCycleByFilterQuery, GetAllClubMembershipCycleByFilterResponseDto>
|
||||
{
|
||||
private readonly IApplicationDbContext _context;
|
||||
|
||||
public GetAllClubMembershipCycleByFilterQueryHandler(IApplicationDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public async Task<GetAllClubMembershipCycleByFilterResponseDto> Handle(
|
||||
GetAllClubMembershipCycleByFilterQuery request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var query = _context.ClubMembershipCycles
|
||||
.Include(x => x.User)
|
||||
.Include(x => x.Package)
|
||||
.AsNoTracking()
|
||||
.ApplyClubMembershipCycleFilter(request.Filter)
|
||||
.ApplyOrder(sortBy: request.SortBy ?? "-PackagePurchasedAt");
|
||||
|
||||
return new GetAllClubMembershipCycleByFilterResponseDto
|
||||
{
|
||||
MetaData = await query.GetMetaData(request.PaginationState, cancellationToken),
|
||||
Models = await query
|
||||
.PaginatedListAsync(paginationState: request.PaginationState)
|
||||
.Select(x => new GetAllClubMembershipCycleByFilterResponseModel
|
||||
{
|
||||
Id = x.Id,
|
||||
UserId = x.UserId,
|
||||
UserName = x.User != null ? $"{x.User.FirstName} {x.User.LastName}" : string.Empty,
|
||||
ClubMembershipId = x.ClubMembershipId,
|
||||
CycleNumber = x.CycleNumber,
|
||||
PackageId = x.PackageId,
|
||||
PackageName = x.Package != null ? x.Package.Title : string.Empty,
|
||||
PackageAmount = x.PackageAmount,
|
||||
PurchaseMethod = x.PurchaseMethod,
|
||||
PackagePurchasedAt = x.PackagePurchasedAt,
|
||||
MagicStartedAt = x.MagicStartedAt,
|
||||
MagicCompletedAt = x.MagicCompletedAt,
|
||||
IsCurrentCycle = x.IsCurrentCycle,
|
||||
Created = x.Created
|
||||
})
|
||||
.ToListAsync(cancellationToken)
|
||||
};
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
namespace CMSMicroservice.Application.ClubMembershipCycleCQ.Queries.GetClubMembershipCycleSummary;
|
||||
|
||||
public record GetClubMembershipCycleSummaryQuery : IRequest<GetClubMembershipCycleSummaryResponseDto>
|
||||
{
|
||||
public GetAllClubMembershipCycleByFilter.GetAllClubMembershipCycleByFilterFilter? Filter { get; init; }
|
||||
}
|
||||
|
||||
public class GetClubMembershipCycleSummaryResponseDto
|
||||
{
|
||||
public long TotalCount { get; set; }
|
||||
public long CurrentCycleCount { get; set; }
|
||||
public long MagicActiveCount { get; set; }
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
using CMSMicroservice.Application.ClubMembershipCycleCQ.Queries.GetAllClubMembershipCycleByFilter;
|
||||
|
||||
namespace CMSMicroservice.Application.ClubMembershipCycleCQ.Queries.GetClubMembershipCycleSummary;
|
||||
|
||||
public class GetClubMembershipCycleSummaryQueryHandler
|
||||
: IRequestHandler<GetClubMembershipCycleSummaryQuery, GetClubMembershipCycleSummaryResponseDto>
|
||||
{
|
||||
private readonly IApplicationDbContext _context;
|
||||
|
||||
public GetClubMembershipCycleSummaryQueryHandler(IApplicationDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public async Task<GetClubMembershipCycleSummaryResponseDto> Handle(
|
||||
GetClubMembershipCycleSummaryQuery request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var query = _context.ClubMembershipCycles
|
||||
.AsNoTracking()
|
||||
.ApplyClubMembershipCycleFilter(request.Filter);
|
||||
|
||||
var totalCount = await query.LongCountAsync(cancellationToken);
|
||||
var currentCycleCount = await query.LongCountAsync(x => x.IsCurrentCycle, cancellationToken);
|
||||
var magicActiveCount = await query.LongCountAsync(
|
||||
x => x.MagicStartedAt != null && x.MagicCompletedAt == null,
|
||||
cancellationToken);
|
||||
|
||||
return new GetClubMembershipCycleSummaryResponseDto
|
||||
{
|
||||
TotalCount = totalCount,
|
||||
CurrentCycleCount = currentCycleCount,
|
||||
MagicActiveCount = magicActiveCount
|
||||
};
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
using CMSMicroservice.Domain.Entities.History;
|
||||
|
||||
namespace CMSMicroservice.Application.ClubMembershipCycleHistoryCQ.Queries.GetAllClubMembershipCycleHistoryByFilter;
|
||||
|
||||
public static class ClubMembershipCycleHistoryFilterExtensions
|
||||
{
|
||||
public static IQueryable<ClubMembershipCycleHistory> ApplyClubMembershipCycleHistoryFilter(
|
||||
this IQueryable<ClubMembershipCycleHistory> query,
|
||||
GetAllClubMembershipCycleHistoryByFilterFilter? filter)
|
||||
{
|
||||
if (filter == null) return query;
|
||||
|
||||
if (filter.UserId.HasValue)
|
||||
query = query.Where(x => x.UserId == filter.UserId.Value);
|
||||
|
||||
if (filter.ClubMembershipCycleId.HasValue)
|
||||
query = query.Where(x => x.ClubMembershipCycleId == filter.ClubMembershipCycleId.Value);
|
||||
|
||||
if (filter.CycleNumber.HasValue)
|
||||
query = query.Where(x => x.CycleNumber == filter.CycleNumber.Value);
|
||||
|
||||
if (filter.Action.HasValue)
|
||||
query = query.Where(x => x.Action == filter.Action.Value);
|
||||
|
||||
if (filter.CreatedFrom.HasValue)
|
||||
query = query.Where(x => x.Created >= filter.CreatedFrom.Value);
|
||||
|
||||
if (filter.CreatedTo.HasValue)
|
||||
query = query.Where(x => x.Created <= filter.CreatedTo.Value);
|
||||
|
||||
return query;
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
using CMSMicroservice.Domain.Enums;
|
||||
|
||||
namespace CMSMicroservice.Application.ClubMembershipCycleHistoryCQ.Queries.GetAllClubMembershipCycleHistoryByFilter;
|
||||
|
||||
public record GetAllClubMembershipCycleHistoryByFilterQuery : IRequest<GetAllClubMembershipCycleHistoryByFilterResponseDto>
|
||||
{
|
||||
public PaginationState? PaginationState { get; init; }
|
||||
public string? SortBy { get; init; }
|
||||
public GetAllClubMembershipCycleHistoryByFilterFilter? Filter { get; init; }
|
||||
}
|
||||
|
||||
public class GetAllClubMembershipCycleHistoryByFilterFilter
|
||||
{
|
||||
public long? UserId { get; set; }
|
||||
public long? ClubMembershipCycleId { get; set; }
|
||||
public int? CycleNumber { get; set; }
|
||||
public ClubMembershipCycleAction? Action { get; set; }
|
||||
public DateTime? CreatedFrom { get; set; }
|
||||
public DateTime? CreatedTo { get; set; }
|
||||
}
|
||||
|
||||
public class GetAllClubMembershipCycleHistoryByFilterResponseDto
|
||||
{
|
||||
public MetaData MetaData { get; set; }
|
||||
public List<GetAllClubMembershipCycleHistoryByFilterResponseModel>? Models { get; set; }
|
||||
}
|
||||
|
||||
public class GetAllClubMembershipCycleHistoryByFilterResponseModel
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public long ClubMembershipCycleId { get; set; }
|
||||
public long UserId { get; set; }
|
||||
public string UserName { get; set; } = string.Empty;
|
||||
public int CycleNumber { get; set; }
|
||||
public bool OldIsCurrentCycle { get; set; }
|
||||
public bool NewIsCurrentCycle { get; set; }
|
||||
public DateTime? OldMagicStartedAt { get; set; }
|
||||
public DateTime? NewMagicStartedAt { get; set; }
|
||||
public DateTime? OldMagicCompletedAt { get; set; }
|
||||
public DateTime? NewMagicCompletedAt { get; set; }
|
||||
public ClubMembershipCycleAction Action { get; set; }
|
||||
public string? PerformedBy { get; set; }
|
||||
public string? Reason { get; set; }
|
||||
public DateTimeOffset Created { get; set; }
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
namespace CMSMicroservice.Application.ClubMembershipCycleHistoryCQ.Queries.GetAllClubMembershipCycleHistoryByFilter;
|
||||
|
||||
public class GetAllClubMembershipCycleHistoryByFilterQueryHandler
|
||||
: IRequestHandler<GetAllClubMembershipCycleHistoryByFilterQuery, GetAllClubMembershipCycleHistoryByFilterResponseDto>
|
||||
{
|
||||
private readonly IApplicationDbContext _context;
|
||||
|
||||
public GetAllClubMembershipCycleHistoryByFilterQueryHandler(IApplicationDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public async Task<GetAllClubMembershipCycleHistoryByFilterResponseDto> Handle(
|
||||
GetAllClubMembershipCycleHistoryByFilterQuery request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var query = _context.ClubMembershipCycleHistories
|
||||
.Include(x => x.ClubMembershipCycle)
|
||||
.ThenInclude(c => c!.User)
|
||||
.AsNoTracking()
|
||||
.ApplyClubMembershipCycleHistoryFilter(request.Filter)
|
||||
.ApplyOrder(sortBy: request.SortBy ?? "-Created");
|
||||
|
||||
return new GetAllClubMembershipCycleHistoryByFilterResponseDto
|
||||
{
|
||||
MetaData = await query.GetMetaData(request.PaginationState, cancellationToken),
|
||||
Models = await query
|
||||
.PaginatedListAsync(paginationState: request.PaginationState)
|
||||
.Select(x => new GetAllClubMembershipCycleHistoryByFilterResponseModel
|
||||
{
|
||||
Id = x.Id,
|
||||
ClubMembershipCycleId = x.ClubMembershipCycleId,
|
||||
UserId = x.UserId,
|
||||
UserName = x.ClubMembershipCycle != null && x.ClubMembershipCycle.User != null
|
||||
? $"{x.ClubMembershipCycle.User.FirstName} {x.ClubMembershipCycle.User.LastName}"
|
||||
: string.Empty,
|
||||
CycleNumber = x.CycleNumber,
|
||||
OldIsCurrentCycle = x.OldIsCurrentCycle,
|
||||
NewIsCurrentCycle = x.NewIsCurrentCycle,
|
||||
OldMagicStartedAt = x.OldMagicStartedAt,
|
||||
NewMagicStartedAt = x.NewMagicStartedAt,
|
||||
OldMagicCompletedAt = x.OldMagicCompletedAt,
|
||||
NewMagicCompletedAt = x.NewMagicCompletedAt,
|
||||
Action = x.Action,
|
||||
PerformedBy = x.PerformedBy,
|
||||
Reason = x.Reason,
|
||||
Created = x.Created
|
||||
})
|
||||
.ToListAsync(cancellationToken)
|
||||
};
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
namespace CMSMicroservice.Application.CommissionCQ.Commands.BulkCreditPayouts;
|
||||
|
||||
public class BulkCreditPayoutsCommand : IRequest<BulkCreditPayoutsResult>
|
||||
{
|
||||
public long WeekDefinitionId { get; set; }
|
||||
}
|
||||
|
||||
public class BulkCreditPayoutsResult
|
||||
{
|
||||
public int CreditedCount { get; set; }
|
||||
public long TotalCredited { get; set; }
|
||||
}
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
using CMSMicroservice.Application.Common.Interfaces;
|
||||
using CMSMicroservice.Domain.Entities.Commission;
|
||||
using CMSMicroservice.Domain.Entities.History;
|
||||
using CMSMicroservice.Domain.Enums;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace CMSMicroservice.Application.CommissionCQ.Commands.BulkCreditPayouts;
|
||||
|
||||
public class BulkCreditPayoutsCommandHandler : IRequestHandler<BulkCreditPayoutsCommand, BulkCreditPayoutsResult>
|
||||
{
|
||||
private readonly IApplicationDbContext _context;
|
||||
private readonly ICurrentUserService _currentUser;
|
||||
private readonly ILogger<BulkCreditPayoutsCommandHandler> _logger;
|
||||
|
||||
public BulkCreditPayoutsCommandHandler(
|
||||
IApplicationDbContext context,
|
||||
ICurrentUserService currentUser,
|
||||
ILogger<BulkCreditPayoutsCommandHandler> logger)
|
||||
{
|
||||
_context = context;
|
||||
_currentUser = currentUser;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<BulkCreditPayoutsResult> Handle(BulkCreditPayoutsCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var pendingPayouts = await _context.UserCommissionPayouts
|
||||
.Where(p => p.WeekDefinitionId == request.WeekDefinitionId
|
||||
&& p.Status == CommissionPayoutStatus.Pending
|
||||
&& !p.IsDeleted)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (!pendingPayouts.Any())
|
||||
{
|
||||
return new BulkCreditPayoutsResult { CreditedCount = 0, TotalCredited = 0 };
|
||||
}
|
||||
|
||||
var performedBy = _currentUser.GetPerformedBy() ?? "System";
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
var payoutHistories = new List<CommissionPayoutHistory>();
|
||||
var creditedCount = 0;
|
||||
long totalCredited = 0;
|
||||
|
||||
foreach (var payout in pendingPayouts)
|
||||
{
|
||||
payout.Status = CommissionPayoutStatus.Paid;
|
||||
payout.PaidAt = now;
|
||||
payout.LastModified = now;
|
||||
|
||||
payoutHistories.Add(new CommissionPayoutHistory
|
||||
{
|
||||
UserCommissionPayoutId = payout.Id,
|
||||
UserId = payout.UserId,
|
||||
WeekDefinitionId = payout.WeekDefinitionId,
|
||||
AmountBefore = payout.TotalAmount,
|
||||
AmountAfter = payout.TotalAmount,
|
||||
OldStatus = CommissionPayoutStatus.Pending,
|
||||
NewStatus = CommissionPayoutStatus.Paid,
|
||||
Action = CommissionPayoutAction.Paid,
|
||||
PerformedBy = performedBy,
|
||||
Reason = $"تایید پرداخت دستهجمعی کمیسیون هفته {payout.WeekDefinitionId} توسط {performedBy}"
|
||||
});
|
||||
|
||||
creditedCount++;
|
||||
totalCredited += payout.TotalAmount;
|
||||
}
|
||||
|
||||
await _context.CommissionPayoutHistories.AddRangeAsync(payoutHistories, cancellationToken);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"BulkCreditPayouts completed: Week={WeekId}, Credited={Count}, TotalAmount={Total}, By={By}",
|
||||
request.WeekDefinitionId, creditedCount, totalCredited, performedBy);
|
||||
|
||||
return new BulkCreditPayoutsResult
|
||||
{
|
||||
CreditedCount = creditedCount,
|
||||
TotalCredited = totalCredited
|
||||
};
|
||||
}
|
||||
}
|
||||
+26
-19
@@ -1,3 +1,5 @@
|
||||
using CMSMicroservice.Domain.Common;
|
||||
|
||||
namespace CMSMicroservice.Application.CommissionCQ.Commands.CalculateWeeklyBalances;
|
||||
|
||||
public class CalculateWeeklyBalancesCommandHandler : IRequestHandler<CalculateWeeklyBalancesCommand, int>
|
||||
@@ -41,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
|
||||
.Where(c => c.IsActive)
|
||||
.Where(c => c.IsActive && !magicModeUserIds.Contains(c.UserId))
|
||||
.Select(c => c.UserId)
|
||||
.ToHashSetAsync(cancellationToken);
|
||||
|
||||
@@ -74,21 +82,14 @@ public class CalculateWeeklyBalancesCommandHandler : IRequestHandler<CalculateWe
|
||||
var balancesList = new List<NetworkWeeklyBalance>();
|
||||
var calculatedAt = DateTime.Now;
|
||||
|
||||
// خواندن یکباره Configuration ها (بهینهسازی - به جای N query)
|
||||
var configs = await _context.SystemConfigurations
|
||||
.Where(x => x.IsActive && (
|
||||
x.Key == "Club.ActivationFee" ||
|
||||
x.Key == "Commission.WeeklyPoolContributionPercent" ||
|
||||
x.Key == "Commission.MaxWeeklyBalancesPerLeg" ||
|
||||
x.Key == "Commission.MaxNetworkLevel"))
|
||||
.ToDictionaryAsync(x => x.Key, x => x.Value, cancellationToken);
|
||||
// بارگذاری پکیج پایه برای خواندن سقفها
|
||||
var package = await _context.Packages
|
||||
.FirstOrDefaultAsync(p => p.IsBasePackage && !p.IsDeleted, cancellationToken)
|
||||
?? throw new InvalidOperationException("پکیج پایه یافت نشد");
|
||||
|
||||
// var activationFee = long.Parse(configs.GetValueOrDefault("Club.ActivationFee", "25000000"));
|
||||
// var poolPercent = decimal.Parse(configs.GetValueOrDefault("Commission.WeeklyPoolContributionPercent", "20")) / 100m;
|
||||
// سقف تعادل هفتگی برای هر دست (نه کل) - 300 برای چپ + 300 برای راست = حداکثر 600 تعادل
|
||||
var maxBalancesPerLeg = int.Parse(configs.GetValueOrDefault("Commission.MaxWeeklyBalancesPerLeg", "300"));
|
||||
// حداکثر عمق شبکه برای شمارش اعضا (15 لول)
|
||||
var maxNetworkLevel = int.Parse(configs.GetValueOrDefault("Commission.MaxNetworkLevel", "15"));
|
||||
// خواندن سقفها از پکیج (قبلاً از SystemConstants بود)
|
||||
var maxBalancesPerLeg = package.MaxBalancesPerLeg;
|
||||
var maxNetworkLevel = package.MaxNetworkLevel;
|
||||
|
||||
foreach (var user in usersInNetwork.OrderBy(o=>o.Id))
|
||||
{
|
||||
@@ -142,6 +143,7 @@ public class CalculateWeeklyBalancesCommandHandler : IRequestHandler<CalculateWe
|
||||
{
|
||||
UserId = user.Id,
|
||||
WeekDefinitionId = request.WeekDefinitionId,
|
||||
PackageId = package.Id,
|
||||
|
||||
// اطلاعات جدید
|
||||
LeftLegNewMembers = leftNewMembers,
|
||||
@@ -264,11 +266,16 @@ public class CalculateWeeklyBalancesCommandHandler : IRequestHandler<CalculateWe
|
||||
|
||||
var count = 0;
|
||||
|
||||
// اگر فرزند در این هفته فعال شده، 1 امتیاز
|
||||
var membership = await _context.ClubMemberships
|
||||
.FirstOrDefaultAsync(x => x.UserId == child.Id && x.IsActive, cancellationToken);
|
||||
// ⭐ بر اساس بازه تاریخی هفته، نه IsCurrentCycle
|
||||
// IsCurrentCycle فقط آخرین خرید را نشان میدهد — برای تاریخچهای (هفتههای گذشته)
|
||||
// باید از PackagePurchasedAt استفاده کرد که تاریخ دقیق خرید را نگه میدارد
|
||||
var hadCycleInWeek = await _context.ClubMembershipCycles
|
||||
.AnyAsync(x => x.UserId == child.Id
|
||||
&& x.PackagePurchasedAt >= startDate
|
||||
&& x.PackagePurchasedAt <= endDate,
|
||||
cancellationToken);
|
||||
|
||||
if (membership?.ActivatedAt >= startDate && membership?.ActivatedAt <= endDate)
|
||||
if (hadCycleInWeek)
|
||||
{
|
||||
count = 1;
|
||||
}
|
||||
|
||||
+5
@@ -10,6 +10,11 @@ public record CalculateWeeklyCommissionPoolCommand : IRequest<long>
|
||||
/// </summary>
|
||||
public long WeekDefinitionId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// شناسه پکیج — اگر مقدار داشته باشد فقط همین پکیج محاسبه میشود.
|
||||
/// </summary>
|
||||
public long? PackageId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// آیا محاسبه مجدد انجام شود؟
|
||||
/// </summary>
|
||||
|
||||
+34
-13
@@ -23,8 +23,17 @@ public class CalculateWeeklyCommissionPoolCommandHandler : IRequestHandler<Calcu
|
||||
}
|
||||
|
||||
// بررسی وجود استخر
|
||||
var existingPool = await _context.WeeklyCommissionPools
|
||||
.FirstOrDefaultAsync(x => x.WeekDefinitionId == request.WeekDefinitionId, cancellationToken);
|
||||
var existingPoolQuery = _context.WeeklyCommissionPools
|
||||
.Where(x => x.WeekDefinitionId == request.WeekDefinitionId);
|
||||
|
||||
if (request.PackageId.HasValue)
|
||||
{
|
||||
existingPoolQuery = existingPoolQuery.Where(x => x.PackageId == request.PackageId.Value);
|
||||
}
|
||||
|
||||
var existingPool = await existingPoolQuery
|
||||
.OrderBy(x => x.Id)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (existingPool == null)
|
||||
{
|
||||
@@ -40,9 +49,15 @@ public class CalculateWeeklyCommissionPoolCommandHandler : IRequestHandler<Calcu
|
||||
}
|
||||
|
||||
// بررسی وجود تعادلهای هفتگی
|
||||
var weeklyBalances = await _context.NetworkWeeklyBalances
|
||||
.Where(x => x.WeekDefinitionId == request.WeekDefinitionId)
|
||||
.ToListAsync(cancellationToken);
|
||||
var weeklyBalancesQuery = _context.NetworkWeeklyBalances
|
||||
.Where(x => x.WeekDefinitionId == request.WeekDefinitionId);
|
||||
|
||||
if (request.PackageId.HasValue)
|
||||
{
|
||||
weeklyBalancesQuery = weeklyBalancesQuery.Where(x => x.PackageId == request.PackageId.Value);
|
||||
}
|
||||
|
||||
var weeklyBalances = await weeklyBalancesQuery.ToListAsync(cancellationToken);
|
||||
|
||||
if (!weeklyBalances.Any())
|
||||
{
|
||||
@@ -76,9 +91,15 @@ public class CalculateWeeklyCommissionPoolCommandHandler : IRequestHandler<Calcu
|
||||
// حذف پرداختهای قبلی در صورت ForceRecalculate
|
||||
if (request.ForceRecalculate)
|
||||
{
|
||||
var oldPayouts = await _context.UserCommissionPayouts
|
||||
.Where(p => p.WeekDefinitionId == request.WeekDefinitionId)
|
||||
.ToListAsync(cancellationToken);
|
||||
var oldPayoutsQuery = _context.UserCommissionPayouts
|
||||
.Where(p => p.WeekDefinitionId == request.WeekDefinitionId);
|
||||
|
||||
if (request.PackageId.HasValue)
|
||||
{
|
||||
oldPayoutsQuery = oldPayoutsQuery.Where(p => p.PackageId == request.PackageId.Value);
|
||||
}
|
||||
|
||||
var oldPayouts = await oldPayoutsQuery.ToListAsync(cancellationToken);
|
||||
|
||||
if (oldPayouts.Any())
|
||||
{
|
||||
@@ -192,7 +213,7 @@ public class CalculateWeeklyCommissionPoolCommandHandler : IRequestHandler<Calcu
|
||||
.ToDictionaryAsync(w => w.UserId, cancellationToken);
|
||||
|
||||
var newWallets = new List<UserWallet>();
|
||||
var walletLogs = new List<UserWalletChangeLog>();
|
||||
var walletLogs = new List<UserWalletHistory>();
|
||||
|
||||
foreach (var payout in payouts)
|
||||
{
|
||||
@@ -241,7 +262,7 @@ public class CalculateWeeklyCommissionPoolCommandHandler : IRequestHandler<Calcu
|
||||
wallet.NetworkBalance += payout.TotalAmount;
|
||||
|
||||
// ایجاد لاگ تغییر کیف پول
|
||||
var walletLog = new UserWalletChangeLog
|
||||
var walletLog = new UserWalletHistory
|
||||
{
|
||||
WalletId = wallet.Id,
|
||||
CurrentBalance = wallet.Balance,
|
||||
@@ -258,7 +279,7 @@ public class CalculateWeeklyCommissionPoolCommandHandler : IRequestHandler<Calcu
|
||||
}
|
||||
|
||||
// ذخیره تغییرات کیف پول و لاگها
|
||||
await _context.UserWalletChangeLogs.AddRangeAsync(walletLogs, cancellationToken);
|
||||
await _context.UserWalletHistories.AddRangeAsync(walletLogs, cancellationToken);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
@@ -271,7 +292,7 @@ public class CalculateWeeklyCommissionPoolCommandHandler : IRequestHandler<Calcu
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// پیدا کردن لاگهای کیف پول مرتبط با پرداختهای قبلی
|
||||
var oldWalletLogs = await _context.UserWalletChangeLogs
|
||||
var oldWalletLogs = await _context.UserWalletHistories
|
||||
.Where(l => l.RefrenceId.HasValue && oldPayoutIds.Contains(l.RefrenceId.Value))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
@@ -300,7 +321,7 @@ public class CalculateWeeklyCommissionPoolCommandHandler : IRequestHandler<Calcu
|
||||
}
|
||||
|
||||
// حذف لاگهای قبلی
|
||||
_context.UserWalletChangeLogs.RemoveRange(oldWalletLogs);
|
||||
_context.UserWalletHistories.RemoveRange(oldWalletLogs);
|
||||
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
+8
-6
@@ -1,3 +1,5 @@
|
||||
using CMSMicroservice.Domain.Common;
|
||||
|
||||
namespace CMSMicroservice.Application.CommissionCQ.Commands.ProcessUserPayouts;
|
||||
|
||||
public class ProcessUserPayoutsCommandHandler : IRequestHandler<ProcessUserPayoutsCommand, int>
|
||||
@@ -47,12 +49,11 @@ public class ProcessUserPayoutsCommandHandler : IRequestHandler<ProcessUserPayou
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
// ⭐ خواندن MaxNetworkLevel از Config
|
||||
var maxNetworkLevelConfig = await _context.SystemConfigurations
|
||||
.Where(x => x.Key == "Commission.MaxNetworkLevel" && x.IsActive)
|
||||
.Select(x => x.Value)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
var maxNetworkLevel = int.Parse(maxNetworkLevelConfig ?? "15");
|
||||
// بارگذاری پکیج پایه برای خواندن سقفها
|
||||
var package = await _context.Packages
|
||||
.FirstOrDefaultAsync(p => p.IsBasePackage && !p.IsDeleted, cancellationToken)
|
||||
?? throw new InvalidOperationException("پکیج پایه یافت نشد");
|
||||
var maxNetworkLevel = package.MaxNetworkLevel;
|
||||
|
||||
// دریافت همه تعادلهای هفتگی (شامل صفرها هم برای محاسبه زیرمجموعه)
|
||||
var allWeeklyBalances = await _context.NetworkWeeklyBalances
|
||||
@@ -115,6 +116,7 @@ public class ProcessUserPayoutsCommandHandler : IRequestHandler<ProcessUserPayou
|
||||
UserId = userId,
|
||||
WeekDefinitionId = request.WeekDefinitionId,
|
||||
WeeklyPoolId = pool.Id,
|
||||
PackageId = package.Id,
|
||||
BalancesEarned = totalBalancesWithSubordinates, // ⭐ شامل زیرمجموعه
|
||||
ValuePerBalance = pool.ValuePerBalance,
|
||||
TotalAmount = totalAmount,
|
||||
|
||||
+6
-1
@@ -1,3 +1,5 @@
|
||||
using CMSMicroservice.Application.Common;
|
||||
|
||||
namespace CMSMicroservice.Application.CommissionCQ.Commands.RequestWithdrawal;
|
||||
|
||||
public class RequestWithdrawalCommandHandler : IRequestHandler<RequestWithdrawalCommand, Unit>
|
||||
@@ -37,7 +39,10 @@ public class RequestWithdrawalCommandHandler : IRequestHandler<RequestWithdrawal
|
||||
|
||||
if (request.Method == WithdrawalMethod.Cash)
|
||||
{
|
||||
payout.IbanNumber = request.IbanNumber;
|
||||
var normalizedIban = IbanNormalizer.TryNormalize(request.IbanNumber);
|
||||
if (normalizedIban is null)
|
||||
throw new InvalidOperationException("فرمت شماره شبا معتبر نیست. باید IR و ۲۴ رقم باشد.");
|
||||
payout.IbanNumber = normalizedIban;
|
||||
}
|
||||
|
||||
_context.UserCommissionPayouts.Update(payout);
|
||||
|
||||
+5
@@ -21,4 +21,9 @@ public record TriggerWeeklyCalculationCommand : IRequest<TriggerWeeklyCalculatio
|
||||
/// Skip pool calculation and payout processing (Step 2)
|
||||
/// </summary>
|
||||
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(
|
||||
request.WeekDefinitionId,
|
||||
request.ForceRecalculate,
|
||||
request.PackageId,
|
||||
cancellationToken);
|
||||
|
||||
steps.Add($"محاسبه امتیازات هفتگی ({balancesCount} کاربر)");
|
||||
@@ -64,6 +65,7 @@ public class TriggerWeeklyCalculationCommandHandler : IRequestHandler<TriggerWee
|
||||
var poolId = await strategy.CalculateWeeklyCommissionPoolAsync(
|
||||
request.WeekDefinitionId,
|
||||
request.ForceRecalculate,
|
||||
request.PackageId,
|
||||
cancellationToken);
|
||||
|
||||
steps.Add($"محاسبه استخر و پرداخت کاربران (Pool: {poolId})");
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user