20 Commits

Author SHA1 Message Date
masoodafar-web 7b26073c63 update
Build and Deploy to Production / build-and-deploy (push) Successful in 1m40s
2025-12-25 01:24:58 +03:30
masoodafar-web 28e94d6137 feat: update appsettings for production environment and clear default connection string 2025-12-25 01:24:58 +03:30
masoodafar-web c6ee356cbd update 2025-12-25 01:24:58 +03:30
masoodafar-web 2d09a69be9 feat: add DatabaseFacade property to IApplicationDbContext for raw SQL access 2025-12-25 01:24:58 +03:30
masoud 7cebb27171 Update src/CMSMicroservice.Infrastructure/BackgroundJobs/ChatikaAccountActivationJob.cs 2025-12-25 01:24:58 +03:30
masoodafar-web 68489a8374 feat: update database connection string in appsettings.json 2025-12-25 01:24:58 +03:30
masoodafar-web 330f0cae5a feat: enable Chatika integration in appsettings.json 2025-12-25 01:24:58 +03:30
masoodafar-web b16f01b6e4 chore: fix trailing whitespace in appsettings.json 2025-12-25 01:24:58 +03:30
masoodafar-web 13f24d523b feat: refine user filtering logic in Chatika account activation and Daya loan check jobs 2025-12-25 01:24:58 +03:30
admin 41308e189a Add production deployment workflow
Build and Deploy to Production / build-and-deploy (push) Successful in 1m46s
2025-12-23 20:16:13 +00:00
masoodafar-web 8953d86755 chore: fix trailing whitespace in appsettings.json 2025-12-23 23:31:47 +03:30
masoodafar-web ef8d5d4d97 feat: add configuration option to enable/disable Chatika integration 2025-12-23 23:09:18 +03:30
masoodafar-web 288b85a59b feat: implement Chatika account activation job for new club members 2025-12-23 22:52:49 +03:30
masoodafar-web 30144a07c8 feat: enhance club membership activation with pool and features 2025-12-23 00:02:14 +03:30
masoodafar-web 5bcf6c9840 feat: add network leg position assignment for new users 2025-12-22 22:39:15 +03:30
masoodafar-web 9d51e70a7f chore: fix trailing whitespace in appsettings.json 2025-12-20 06:29:47 +03:30
masoodafar-web a31c88219f chore: remove appsettings.Production.json file 2025-12-20 06:18:03 +03:30
masoodafar-web ceeb29d843 chore: add Seq API key in appsettings.json 2025-12-20 06:07:13 +03:30
masoodafar-web 0ba6af9049 chore: update Seq server URL in appsettings.json 2025-12-20 05:22:47 +03:30
masoodafar-web 5ad69cb3fa update 2025-12-20 04:54:59 +03:30
20 changed files with 5125 additions and 153 deletions
+87
View File
@@ -0,0 +1,87 @@
name: Build and Deploy to Production
on:
push:
branches:
- production
env:
REGISTRY: git.foursat.afrino.co
IMAGE_NAME: admin/cms
jobs:
build-and-deploy:
runs-on: ubuntu-latest
container:
image: docker: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,45.149.79.127,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
run: |
mkdir -p /etc/docker
cat > /etc/docker/daemon.json << 'DAEMON'
{
"insecure-registries": ["git.foursat.afrino.co", "gitea-svc:3000"]
}
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,45.149.79.127,10.0.0.0/8"
}
}
}
CONF
dockerd &
for i in $(seq 1 30); do
docker info >/dev/null 2>&1 && break || sleep 2
done
docker info
- name: Checkout code
run: |
git clone --depth 1 --branch production http://gitea-svc:3000/admin/CMS.git .
git log -1 --format="%H %s"
- name: Build Docker Image
run: |
docker build -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} \
-t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:prod \
--build-arg HTTP_PROXY=http://proxyuser:87zH26nbqT2@46.249.98.211:3128 \
--build-arg HTTPS_PROXY=http://proxyuser:87zH26nbqT2@46.249.98.211:3128 \
.
- name: Push to Registry
run: |
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login ${{ env.REGISTRY }} -u admin --password-stdin
docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:prod
- name: Deploy to Production
run: |
# Setup kubeconfig for PRODUCTION
mkdir -p ~/.kube
echo "${{ secrets.KUBECONFIG_PROD }}" | base64 -d > ~/.kube/config
# Restart deployment to pull new image
kubectl rollout restart deployment/cms || echo "Deployment doesn't exist yet"
# Wait for rollout to complete
kubectl rollout status deployment/cms --timeout=5m || echo "Deployment rollout pending"
+421
View File
@@ -0,0 +1,421 @@
{
"info": {
"_postman_id": "a5404a90-7d5a-4e9f-a26b-bb2bc93f19a2",
"name": "hushyar",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
"_exporter_id": "12336342",
"_collection_link": "https://abbas-rahimzadeh-postman.postman.co/workspace/public-workspace~57c702f9-fed1-4b27-b507-0b0252812bf7/collection/12336342-a5404a90-7d5a-4e9f-a26b-bb2bc93f19a2?action=share&source=collection_link&creator=12336342"
},
"item": [
{
"name": "v1",
"item": [
{
"name": "organization",
"item": [
{
"name": "register-user",
"request": {
"method": "POST",
"header": [
{
"key": "X-API-Key",
"value": "{{organizationApiKey}}",
"description": "Organization API Key",
"type": "text"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"mobile_number\": \"09123456789\"\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "{{baseURL}}/api/v1/organizations/register-user",
"host": [
"{{baseURL}}"
],
"path": [
"api",
"v1",
"organizations",
"register-user"
]
},
"description": "Register a new user or get existing user via organization API key. If user is new or has no organization, they will be assigned to this organization and their wallet will be charged with the organization's user_credit."
},
"response": [
{
"name": "success - new user",
"originalRequest": {
"method": "POST",
"header": [
{
"key": "X-API-Key",
"value": "{{organizationApiKey}}",
"description": "Organization API Key",
"type": "text"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"mobile_number\": \"09123456789\"\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "{{baseURL}}/api/v1/organizations/register-user",
"host": [
"{{baseURL}}"
],
"path": [
"api",
"v1",
"organizations",
"register-user"
]
}
},
"status": "OK",
"code": 200,
"_postman_previewlanguage": "json",
"header": [
{
"key": "content-type",
"value": "application/json"
}
],
"cookie": [],
"body": "{\n \"id\": 1,\n \"mobile_number\": \"09123456789\",\n \"organization_id\": 1,\n \"organization_title\": \"Test Organization\",\n \"wallet_balance\": 100.0,\n \"is_new_user\": true,\n \"credit_charged\": 100.0\n}"
},
{
"name": "success - existing user",
"originalRequest": {
"method": "POST",
"header": [
{
"key": "X-API-Key",
"value": "{{organizationApiKey}}",
"description": "Organization API Key",
"type": "text"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"mobile_number\": \"09123456789\"\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "{{baseURL}}/api/v1/organizations/register-user",
"host": [
"{{baseURL}}"
],
"path": [
"api",
"v1",
"organizations",
"register-user"
]
}
},
"status": "OK",
"code": 200,
"_postman_previewlanguage": "json",
"header": [
{
"key": "content-type",
"value": "application/json"
}
],
"cookie": [],
"body": "{\n \"id\": 1,\n \"mobile_number\": \"09123456789\",\n \"organization_id\": 1,\n \"organization_title\": \"Test Organization\",\n \"wallet_balance\": 100.0,\n \"is_new_user\": false,\n \"credit_charged\": 0.0\n}"
},
{
"name": "invalid api key",
"originalRequest": {
"method": "POST",
"header": [
{
"key": "X-API-Key",
"value": "invalid-api-key",
"description": "Organization API Key",
"type": "text"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"mobile_number\": \"09123456789\"\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "{{baseURL}}/api/v1/organizations/register-user",
"host": [
"{{baseURL}}"
],
"path": [
"api",
"v1",
"organizations",
"register-user"
]
}
},
"status": "Unauthorized",
"code": 401,
"_postman_previewlanguage": "json",
"header": [
{
"key": "content-type",
"value": "application/json"
}
],
"cookie": [],
"body": "{\n \"error_code\": \"INVALID_API_KEY\",\n \"message\": \"Invalid API key\",\n \"context\": {\n \"api_key_prefix\": \"invalid-\"\n }\n}"
},
{
"name": "organization disabled",
"originalRequest": {
"method": "POST",
"header": [
{
"key": "X-API-Key",
"value": "{{organizationApiKey}}",
"description": "Organization API Key",
"type": "text"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"mobile_number\": \"09123456789\"\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "{{baseURL}}/api/v1/organizations/register-user",
"host": [
"{{baseURL}}"
],
"path": [
"api",
"v1",
"organizations",
"register-user"
]
}
},
"status": "Forbidden",
"code": 403,
"_postman_previewlanguage": "json",
"header": [
{
"key": "content-type",
"value": "application/json"
}
],
"cookie": [],
"body": "{\n \"error_code\": \"ORGANIZATION_DISABLED\",\n \"message\": \"Organization is disabled\",\n \"context\": {\n \"organization_id\": 1\n }\n}"
},
{
"name": "organization expired",
"originalRequest": {
"method": "POST",
"header": [
{
"key": "X-API-Key",
"value": "{{organizationApiKey}}",
"description": "Organization API Key",
"type": "text"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"mobile_number\": \"09123456789\"\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "{{baseURL}}/api/v1/organizations/register-user",
"host": [
"{{baseURL}}"
],
"path": [
"api",
"v1",
"organizations",
"register-user"
]
}
},
"status": "Forbidden",
"code": 403,
"_postman_previewlanguage": "json",
"header": [
{
"key": "content-type",
"value": "application/json"
}
],
"cookie": [],
"body": "{\n \"error_code\": \"ORGANIZATION_EXPIRED\",\n \"message\": \"Organization has expired\",\n \"context\": {\n \"organization_id\": 1,\n \"expires_at\": \"2024-01-01T00:00:00\"\n }\n}"
},
{
"name": "invalid mobile format",
"originalRequest": {
"method": "POST",
"header": [
{
"key": "X-API-Key",
"value": "{{organizationApiKey}}",
"description": "Organization API Key",
"type": "text"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"mobile_number\": \"01234567890\"\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "{{baseURL}}/api/v1/organizations/register-user",
"host": [
"{{baseURL}}"
],
"path": [
"api",
"v1",
"organizations",
"register-user"
]
}
},
"status": "Unprocessable Entity",
"code": 422,
"_postman_previewlanguage": "json",
"header": [
{
"key": "content-type",
"value": "application/json"
}
],
"cookie": [],
"body": "{\n \"mobile_number\": \"String should match pattern '^09\\\\d{9}$'\"\n}"
},
{
"name": "missing api key header",
"originalRequest": {
"method": "POST",
"header": [],
"body": {
"mode": "raw",
"raw": "{\n \"mobile_number\": \"09123456789\"\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "{{baseURL}}/api/v1/organizations/register-user",
"host": [
"{{baseURL}}"
],
"path": [
"api",
"v1",
"organizations",
"register-user"
]
}
},
"status": "Unprocessable Entity",
"code": 422,
"_postman_previewlanguage": "json",
"header": [
{
"key": "content-type",
"value": "application/json"
}
],
"cookie": [],
"body": "{\n \"detail\": [\n {\n \"type\": \"missing\",\n \"loc\": [\"header\", \"x-api-key\"],\n \"msg\": \"Field required\",\n \"input\": null\n }\n ]\n}"
}
]
}
]
}
]
}
],
"event": [
{
"listen": "prerequest",
"script": {
"type": "text/javascript",
"packages": {},
"exec": [
""
]
}
},
{
"listen": "test",
"script": {
"type": "text/javascript",
"packages": {},
"exec": [
""
]
}
}
],
"variable": [
{
"key": "baseURL",
"value": "http://127.0.0.0:8000",
"type": "string"
},
{
"key": "access_token",
"value": "",
"type": "string"
},
{
"key": "accessToken",
"value": ""
},
{
"key": "refreshToken",
"value": ""
},
{
"key": "organizationApiKey",
"value": "",
"type": "string"
}
]
}
@@ -11,6 +11,7 @@
<PackageReference Include="Mapster" Version="7.4.0" />
<PackageReference Include="MediatR.Extensions.Microsoft.DependencyInjection" Version="11.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.11" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.11" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.4.0" />
<PackageReference Include="System.Linq.Dynamic.Core" Version="1.6.10" />
@@ -1,5 +1,9 @@
using CMSMicroservice.Application.Common.Exceptions;
using CMSMicroservice.Application.Common.Interfaces;
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 +16,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 +25,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 +36,15 @@ public class AcceptClubMembershipContractCommandHandler
IApplicationDbContext context,
IConfiguration cfg,
IHashService hashService,
ICurrentUserService currentUser,
IWeekDefinitionRepository weekRepository,
ILogger<AcceptClubMembershipContractCommandHandler> logger)
{
_context = context;
_cfg = cfg;
_hashService = hashService;
_currentUser = currentUser;
_weekRepository = weekRepository;
_logger = logger;
}
@@ -114,33 +126,174 @@ public class AcceptClubMembershipContractCommandHandler
};
await _context.UserContracts.AddAsync(userContract, cancellationToken);
// 6. فعال‌سازی باشگاه مشتریان
if (user.ClubMembership == null)
// 6. دریافت مقادیر از تنظیمات سیستم
var giftValueConfig = await _context.SystemConfigurations
.FirstOrDefaultAsync(
c => c.Key == "Club.MembershipGiftValue" && c.IsActive,
cancellationToken
);
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))
{
user.ClubMembership = new Domain.Entities.Club.ClubMembership
giftValue = configValue;
_logger.LogInformation(
"Using Club.MembershipGiftValue from configuration: {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
);
}
// 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,
InitialContribution = activationFeeValue,
GiftValue = giftValue,
TotalEarned = 0,
PurchaseMethod = user.PackagePurchaseMethod
};
await _context.ClubMemberships.AddAsync(user.ClubMembership, cancellationToken);
await _context.ClubMemberships.AddAsync(clubMembership, cancellationToken);
_logger.LogInformation(
"Created new club membership for UserId {UserId} via {Method}, GiftValue: {GiftValue}",
user.Id,
user.PackagePurchaseMethod,
giftValue
);
}
else
{
user.ClubMembership.IsActive = true;
user.ClubMembership.ActivatedAt = DateTime.Now;
_context.ClubMemberships.Update(user.ClubMembership);
clubMembership = user.ClubMembership!;
clubMembership.IsActive = true;
clubMembership.ActivatedAt = activationDate;
clubMembership.PurchaseMethod = user.PackagePurchaseMethod;
_context.ClubMemberships.Update(clubMembership);
_logger.LogInformation(
"Reactivated club membership for UserId {UserId}",
user.Id
);
}
await _context.SaveChangesAsync(cancellationToken);
// 8. ثبت تاریخچه
var history = new ClubMembershipHistory
{
ClubMembershipId = clubMembership.Id,
UserId = clubMembership.UserId,
OldIsActive = !isNewMembership && !user.ClubMembership!.IsActive,
NewIsActive = true,
Action = ClubMembershipAction.Activated,
Reason = isNewMembership
? $"Initial activation via contract signing - {user.PackagePurchaseMethod}"
: $"Reactivated via contract signing - {user.PackagePurchaseMethod}",
PerformedBy = _currentUser.GetPerformedBy()
};
_context.ClubMembershipHistories.Add(history);
await _context.SaveChangesAsync(cancellationToken);
// 9. اضافه کردن مبلغ به Pool هفته جاری
var currentWeekDefinitionId = GetCurrentWeekDefinitionId();
var weeklyPool = await _context.WeeklyCommissionPools
.FirstOrDefaultAsync(p => p.WeekDefinitionId == currentWeekDefinitionId, cancellationToken);
if (weeklyPool == null)
{
weeklyPool = new WeeklyCommissionPool
{
WeekDefinitionId = currentWeekDefinitionId,
TotalPoolAmount = activationFeeValue,
TotalBalances = 0,
ValuePerBalance = 0,
IsCalculated = false,
CalculatedAt = null
};
await _context.WeeklyCommissionPools.AddAsync(weeklyPool, cancellationToken);
_logger.LogInformation(
"Created new WeeklyCommissionPool for WeekDefinitionId={WeekDefinitionId} with initial amount: {Amount}",
currentWeekDefinitionId,
activationFeeValue
);
}
else
{
weeklyPool.TotalPoolAmount += activationFeeValue;
_context.WeeklyCommissionPools.Update(weeklyPool);
_logger.LogInformation(
"Added {Amount} to existing WeeklyCommissionPool for WeekDefinitionId={WeekDefinitionId}. New total: {NewTotal}",
activationFeeValue,
currentWeekDefinitionId,
weeklyPool.TotalPoolAmount
);
}
await _context.SaveChangesAsync(cancellationToken);
// 10. اعطای ویژگی‌های باشگاه برای کاربر (فقط برای عضویت جدید)
if (isNewMembership)
{
var featureIds = ClubFeatureTypeExtensions.GetAllFeatureIds();
var clubFeatures = await _context.ClubFeatures
.Where(f => !f.IsDeleted && featureIds.Contains(f.Id))
.ToListAsync(cancellationToken);
if (clubFeatures.Any())
{
var userClubFeatures = clubFeatures.Select(feature => new UserClubFeature
{
UserId = user.Id,
ClubMembershipId = clubMembership.Id,
ClubFeatureId = feature.Id,
GrantedAt = activationDate,
IsActive = true,
Notes = "اعطا شده هنگام امضای قرارداد"
}).ToList();
_context.UserClubFeatures.AddRange(userClubFeatures);
await _context.SaveChangesAsync(cancellationToken);
_logger.LogInformation(
"Granted {Count} club features to UserId {UserId}",
clubFeatures.Count,
user.Id
);
}
}
_logger.LogInformation(
"Club membership contract accepted and activated for UserId: {UserId}, ContractId: {ContractId}",
"Club membership contract accepted and activated for UserId: {UserId}, ContractId: {ContractId}, MembershipId: {MembershipId}",
request.UserId,
userContract.Id
userContract.Id,
clubMembership.Id
);
return new AcceptClubMembershipContractResponseDto
@@ -151,6 +304,19 @@ public class AcceptClubMembershipContractCommandHandler
};
}
/// <summary>
/// دریافت شناسه تعریف هفته جاری
/// </summary>
private long GetCurrentWeekDefinitionId()
{
var week = _weekRepository.GetCurrentWeek();
if (week == null)
{
throw new InvalidOperationException("هفته جاری در سیستم تعریف نشده است");
}
return week.Id;
}
private async Task<(bool Success, string Message)> VerifyOtpAsync(
string mobile,
string code,
@@ -297,8 +297,9 @@ public class ActivateClubMembershipCommandHandler : IRequestHandler<ActivateClub
// 9. اضافه کردن ویژگی‌های باشگاه برای کاربر (فقط برای عضویت جدید)
if (isNewMembership)
{
var featureIds = ClubFeatureTypeExtensions.GetAllFeatureIds();
var clubFeatures = await _context.ClubFeatures
.Where(f => !f.IsDeleted && new long[] { 1, 2, 3, 4 }.Contains(f.Id))
.Where(f => !f.IsDeleted && featureIds.Contains(f.Id))
.ToListAsync(cancellationToken);
if (clubFeatures.Any())
@@ -2,6 +2,7 @@ using CMSMicroservice.Domain.Entities.Payment;
using CMSMicroservice.Domain.Entities.Order;
using CMSMicroservice.Domain.Entities.DiscountShop;
using CMSMicroservice.Domain.Entities.Geography;
using Microsoft.EntityFrameworkCore.Infrastructure;
namespace CMSMicroservice.Application.Common.Interfaces;
@@ -60,5 +61,10 @@ public interface IApplicationDbContext
DbSet<State> States { get; }
DbSet<City> Cities { get; }
/// <summary>
/// دسترسی به DatabaseFacade برای اجرای raw SQL و Stored Procedures
/// </summary>
DatabaseFacade Database { get; }
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}
@@ -0,0 +1,64 @@
namespace CMSMicroservice.Application.Common.Interfaces;
/// <summary>
/// سرویس ارتباط با API چتیکا برای ساخت حساب کاربری
/// </summary>
public interface IChatikaApiService
{
/// <summary>
/// ایجاد حساب کاربری در چتیکا
/// </summary>
/// <param name="mobileNumber">شماره موبایل کاربر</param>
/// <param name="fullName">نام کامل کاربر</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>نتیجه ایجاد حساب</returns>
Task<ChatikaAccountResult> CreateAccountAsync(
string mobileNumber,
string fullName,
CancellationToken cancellationToken = default);
}
/// <summary>
/// نتیجه ایجاد حساب در چتیکا
/// </summary>
public class ChatikaAccountResult
{
/// <summary>
/// موفقیت عملیات
/// </summary>
public bool IsSuccess { get; set; }
/// <summary>
/// پیام خطا در صورت عدم موفقیت
/// </summary>
public string? ErrorMessage { get; set; }
/// <summary>
/// شناسه کاربر در چتیکا
/// </summary>
public string? ChatikaUserId { get; set; }
/// <summary>
/// URL دسترسی به چتیکا
/// </summary>
public string? AccessUrl { get; set; }
/// <summary>
/// ایجاد نتیجه موفق
/// </summary>
public static ChatikaAccountResult Success(string? chatikaUserId = null, string? accessUrl = null) => new()
{
IsSuccess = true,
ChatikaUserId = chatikaUserId,
AccessUrl = accessUrl
};
/// <summary>
/// ایجاد نتیجه ناموفق
/// </summary>
public static ChatikaAccountResult Failure(string errorMessage) => new()
{
IsSuccess = false,
ErrorMessage = errorMessage
};
}
@@ -1,144 +1,178 @@
using System.Data;
using System.Data.Common;
using Microsoft.EntityFrameworkCore;
namespace CMSMicroservice.Application.NetworkMembershipCQ.Queries.GetNetworkTree;
public class GetNetworkTreeQueryHandler : IRequestHandler<GetNetworkTreeQuery, NetworkTreeDto?>
{
private readonly IApplicationDbContext _context;
private readonly IWeekDefinitionRepository _weekDefinitionRepository;
private readonly ILogger<GetNetworkTreeQueryHandler> _logger;
public GetNetworkTreeQueryHandler(
IApplicationDbContext context,
IWeekDefinitionRepository weekDefinitionRepository)
ILogger<GetNetworkTreeQueryHandler> logger)
{
_context = context;
_weekDefinitionRepository = weekDefinitionRepository;
_logger = logger;
}
public async Task<NetworkTreeDto?> Handle(GetNetworkTreeQuery request, CancellationToken cancellationToken)
{
var rootUser = await _context.Users
.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == request.UserId, cancellationToken);
if (rootUser == null)
try
{
return null;
}
// دریافت نتایج flat از Stored Procedure
var flatNodes = await ExecuteStoredProcedureAsync(request, cancellationToken);
if (flatNodes == null || !flatNodes.Any())
{
return null;
}
var tree = await BuildTree(rootUser.Id, request.MaxDepth, 0, cancellationToken, request);
return tree;
// تبدیل نتایج flat به ساختار درختی
var tree = BuildTreeFromFlatNodes(flatNodes);
return tree;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error executing GetNetworkTree for UserId: {UserId}", request.UserId);
throw;
}
}
private async Task<NetworkTreeDto> BuildTree(long userId, int maxDepth, int currentDepth, CancellationToken cancellationToken, GetNetworkTreeQuery request)
/// <summary>
/// اجرای Stored Procedure و دریافت نتایج
/// </summary>
private async Task<List<NetworkTreeNodeDto>> ExecuteStoredProcedureAsync(
GetNetworkTreeQuery request,
CancellationToken cancellationToken)
{
// دریافت کاربر با اطلاعات باشگاه مشتریان
var user = await _context.Users
.Include(u => u.ClubMembership)
.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == userId, cancellationToken);
if (user == null)
var results = new List<NetworkTreeNodeDto>();
var connection = _context.Database.GetDbConnection();
try
{
throw new NotFoundException(nameof(User), userId);
if (connection.State != ConnectionState.Open)
{
await connection.OpenAsync(cancellationToken);
}
using var command = connection.CreateCommand();
command.CommandText = "[CMS].[GetNetworkTree]";
command.CommandType = CommandType.StoredProcedure;
command.CommandTimeout = 120; // 2 minutes timeout for large trees
// Parameters - use DbParameter for provider-agnostic code
var rootUserIdParam = command.CreateParameter();
rootUserIdParam.ParameterName = "@RootUserId";
rootUserIdParam.DbType = DbType.Int64;
rootUserIdParam.Value = request.UserId;
command.Parameters.Add(rootUserIdParam);
var maxDepthParam = command.CreateParameter();
maxDepthParam.ParameterName = "@MaxDepth";
maxDepthParam.DbType = DbType.Int32;
maxDepthParam.Value = request.MaxDepth > 0 ? request.MaxDepth : 100;
command.Parameters.Add(maxDepthParam);
var isClubActiveParam = command.CreateParameter();
isClubActiveParam.ParameterName = "@IsClubActive";
isClubActiveParam.DbType = DbType.Boolean;
isClubActiveParam.Value = request.IsClubActive.HasValue ? request.IsClubActive.Value : DBNull.Value;
command.Parameters.Add(isClubActiveParam);
var weekParam = command.CreateParameter();
weekParam.ParameterName = "@ActivationWeekDefinitionId";
weekParam.DbType = DbType.Int64;
weekParam.Value = request.ActivationWeekDefinitionId.HasValue ? request.ActivationWeekDefinitionId.Value : DBNull.Value;
command.Parameters.Add(weekParam);
using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
var node = new NetworkTreeNodeDto
{
UserId = reader.GetInt64(reader.GetOrdinal("UserId")),
Mobile = reader.IsDBNull(reader.GetOrdinal("Mobile")) ? null : reader.GetString(reader.GetOrdinal("Mobile")),
FirstName = reader.IsDBNull(reader.GetOrdinal("FirstName")) ? null : reader.GetString(reader.GetOrdinal("FirstName")),
LastName = reader.IsDBNull(reader.GetOrdinal("LastName")) ? null : reader.GetString(reader.GetOrdinal("LastName")),
LegPosition = reader.IsDBNull(reader.GetOrdinal("LegPosition")) ? null : reader.GetInt32(reader.GetOrdinal("LegPosition")),
ParentId = reader.IsDBNull(reader.GetOrdinal("ParentId")) ? null : reader.GetInt64(reader.GetOrdinal("ParentId")),
NetworkLevel = reader.GetInt32(reader.GetOrdinal("NetworkLevel")),
ClubActivatedAt = reader.IsDBNull(reader.GetOrdinal("ClubActivatedAt")) ? null : reader.GetDateTime(reader.GetOrdinal("ClubActivatedAt")),
IsClubActive = reader.GetBoolean(reader.GetOrdinal("IsClubActive")),
ActivationWeekDefinitionId = reader.IsDBNull(reader.GetOrdinal("ActivationWeekDefinitionId")) ? null : reader.GetInt64(reader.GetOrdinal("ActivationWeekDefinitionId")),
ActivationWeekDisplayName = reader.IsDBNull(reader.GetOrdinal("ActivationWeekDisplayName")) ? null : reader.GetString(reader.GetOrdinal("ActivationWeekDisplayName")),
IsActivatedInTargetWeek = reader.GetBoolean(reader.GetOrdinal("IsActivatedInTargetWeek")),
UserCreated = new DateTimeOffset(reader.GetDateTime(reader.GetOrdinal("UserCreated")))
};
results.Add(node);
}
}
finally
{
// Connection is managed by DbContext, don't close it here
}
// محاسبه شماره هفته فعال‌سازی
long? activationWeekDefinitionId = null;
string? activationWeekDisplayName = null;
bool isActivatedInTargetWeek = false;
if (user.ClubMembership?.ActivatedAt != null)
_logger.LogInformation("GetNetworkTree SP returned {Count} nodes for UserId: {UserId}", results.Count, request.UserId);
return results;
}
/// <summary>
/// تبدیل لیست flat به ساختار درختی
/// </summary>
private NetworkTreeDto? BuildTreeFromFlatNodes(List<NetworkTreeNodeDto> flatNodes)
{
if (!flatNodes.Any()) return null;
// Dictionary برای دسترسی سریع به نودها
var nodeDict = new Dictionary<long, NetworkTreeDto>();
// ایجاد همه نودها
foreach (var flatNode in flatNodes)
{
// activationWeekDefinitionId = CalculateWeekNumber(user.ClubMembership.ActivatedAt.Value);
var week = _weekDefinitionRepository.GetWeekByDate(user.ClubMembership.ActivatedAt.Value);
activationWeekDefinitionId = week.Id;
activationWeekDisplayName = week.DisplayName;
// بررسی آیا در هفته هدف فعال شده است
if (request.ActivationWeekDefinitionId!=null)
nodeDict[flatNode.UserId] = new NetworkTreeDto
{
isActivatedInTargetWeek = activationWeekDefinitionId == request.ActivationWeekDefinitionId;
UserId = flatNode.UserId,
Mobile = flatNode.Mobile,
FirstName = flatNode.FirstName,
LastName = flatNode.LastName,
LegPosition = flatNode.LegPosition.HasValue ? (NetworkLeg)flatNode.LegPosition.Value : null,
CurrentDepth = flatNode.NetworkLevel,
ClubActivatedAt = flatNode.ClubActivatedAt,
IsClubActive = flatNode.IsClubActive,
ActivationWeekDefinitionId = flatNode.ActivationWeekDefinitionId,
ActivationWeekDisplayName = flatNode.ActivationWeekDisplayName,
IsActivatedInTargetWeek = flatNode.IsActivatedInTargetWeek,
UserCreated = flatNode.UserCreated
};
}
// برقراری ارتباط Parent-Child
foreach (var flatNode in flatNodes)
{
if (flatNode.ParentId.HasValue && nodeDict.ContainsKey(flatNode.ParentId.Value))
{
var parent = nodeDict[flatNode.ParentId.Value];
var child = nodeDict[flatNode.UserId];
// تعیین موقعیت چپ یا راست
if (flatNode.LegPosition == (int)NetworkLeg.Left)
{
parent.LeftChild = child;
}
else if (flatNode.LegPosition == (int)NetworkLeg.Right)
{
parent.RightChild = child;
}
}
}
var node = new NetworkTreeDto
{
UserId = user.Id,
Mobile = user.Mobile,
FirstName = user.FirstName,
LastName = user.LastName,
LegPosition = user.LegPosition,
CurrentDepth = currentDepth,
ClubActivatedAt = user.ClubMembership?.ActivatedAt,
IsClubActive = user.ClubMembership?.IsActive ?? false,
ActivationWeekDefinitionId = activationWeekDefinitionId,
ActivationWeekDisplayName = activationWeekDisplayName,
IsActivatedInTargetWeek = isActivatedInTargetWeek,
UserCreated = user.Created
};
// اگر به حداکثر عمق رسیدیم، دیگر فرزندان را نمی‌خوانیم
if (currentDepth >= maxDepth)
{
return node;
}
// پیدا کردن فرزندان (چپ و راست)
var children = await GetFilteredChildren(userId, request, cancellationToken);
var leftChild = children.FirstOrDefault(c => c.LegPosition == NetworkLeg.Left);
if (leftChild != null)
{
node.LeftChild = await BuildTree(leftChild.Id, maxDepth, currentDepth + 1, cancellationToken, request);
}
var rightChild = children.FirstOrDefault(c => c.LegPosition == NetworkLeg.Right);
if (rightChild != null)
{
node.RightChild = await BuildTree(rightChild.Id, maxDepth, currentDepth + 1, cancellationToken, request);
}
return node;
// پیدا کردن ریشه (اولین نود با Level=0)
var rootNode = flatNodes.FirstOrDefault(n => n.NetworkLevel == 0);
return rootNode != null ? nodeDict[rootNode.UserId] : null;
}
/// <summary>
/// دریافت فرزندان با اعمال فیلترها
/// </summary>
private async Task<List<User>> GetFilteredChildren(long parentId, GetNetworkTreeQuery request, CancellationToken cancellationToken)
{
var query = _context.Users
.Include(u => u.ClubMembership)
.AsNoTracking()
.Where(x => x.NetworkParentId == parentId);
// اعمال فیلتر IsClubActive
if (request.IsClubActive.HasValue)
{
query = query.Where(u =>
u.ClubMembership != null &&
u.ClubMembership.IsDeleted == false &&
u.ClubMembership.IsActive == request.IsClubActive.Value);
}
return await query.ToListAsync(cancellationToken);
}
/// <summary>
/// محاسبه شماره هفته از تاریخ
/// </summary>
// private long CalculateWeekNumber(DateTime date)
// {
// // First try to get from repository cache
// var weekDef = _weekDefinitionRepository.GetWeekByDate(date);
// if (weekDef != null)
// {
// return weekDef.Id;
// }
//
// // Fallback: use repository's calculation method
// // return _weekDefinitionRepository.CalculateGregorianWeekNumber(date);
// return 0;
// }
}
@@ -0,0 +1,27 @@
namespace CMSMicroservice.Application.NetworkMembershipCQ.Queries.GetNetworkTree;
/// <summary>
/// DTO برای نتیجه Flat از Stored Procedure
/// هر ردیف یک نود از درخت است
/// </summary>
public class NetworkTreeNodeDto
{
public long UserId { get; set; }
public string? Mobile { get; set; }
public string? FirstName { get; set; }
public string? LastName { get; set; }
public int? LegPosition { get; set; }
public long? ParentId { get; set; }
public int NetworkLevel { get; set; }
public DateTime? ClubActivatedAt { get; set; }
public bool IsClubActive { get; set; }
public long? ActivationWeekDefinitionId { get; set; }
public string? ActivationWeekDisplayName { get; set; }
public bool IsActivatedInTargetWeek { get; set; }
public DateTimeOffset UserCreated { get; set; }
/// <summary>
/// نام کامل کاربر
/// </summary>
public string FullName => $"{FirstName} {LastName}".Trim();
}
@@ -1,3 +1,4 @@
using CMSMicroservice.Domain.Enums;
using CMSMicroservice.Domain.Events;
using Microsoft.Extensions.Configuration;
@@ -75,6 +76,26 @@ public class VerifyOtpTokenCommandHandler : IRequestHandler<VerifyOtpTokenComman
if (await _context.Users.CountAsync(x => x.NetworkParentId == parent.Id, cancellationToken: cancellationToken) > 1)
return new VerifyOtpTokenResponseDto() { Success = false, Message = "ظرفیت معرف تکمیل است!!" };
// تعیین موقعیت در شبکه (چپ یا راست)
var existingChildren = await _context.Users
.Where(x => x.NetworkParentId == parent.Id && !x.IsDeleted)
.Select(x => x.LegPosition)
.ToListAsync(cancellationToken);
NetworkLeg newUserLegPosition;
if (!existingChildren.Any(x => x == NetworkLeg.Left))
{
newUserLegPosition = NetworkLeg.Left;
}
else if (!existingChildren.Any(x => x == NetworkLeg.Right))
{
newUserLegPosition = NetworkLeg.Right;
}
else
{
return new VerifyOtpTokenResponseDto() { Success = false, Message = "ظرفیت معرف تکمیل است!!" };
}
user = new User
{
Mobile = mobile,
@@ -83,7 +104,8 @@ public class VerifyOtpTokenCommandHandler : IRequestHandler<VerifyOtpTokenComman
MobileVerifiedAt = now,
IsRulesAccepted = true,
RulesAcceptedAt = now,
NetworkParentId = parent.Id
NetworkParentId = parent.Id,
LegPosition = newUserLegPosition
};
await _context.Users.AddAsync(user, cancellationToken);
user.AddDomainEvent(new CreateNewUserEvent(user));
@@ -0,0 +1,58 @@
namespace CMSMicroservice.Domain.Enums;
/// <summary>
/// انواع ویژگی‌های باشگاه مشتریان
/// </summary>
public enum ClubFeatureType
{
/// <summary>
/// چتیکا - دستیار هوش مصنوعی
/// </summary>
Chatika = 1,
/// <summary>
/// بیمه - خدمات بیمه‌ای
/// </summary>
Bime = 2,
/// <summary>
/// تریپ - خدمات سفر و گردشگری
/// </summary>
Trip = 3,
/// <summary>
/// لرن - آموزش و یادگیری
/// </summary>
Learn = 4
}
/// <summary>
/// Extension methods برای ClubFeatureType
/// </summary>
public static class ClubFeatureTypeExtensions
{
/// <summary>
/// دریافت تمام مقادیر ClubFeatureType به صورت آرایه long
/// </summary>
public static long[] GetAllFeatureIds()
{
return Enum.GetValues<ClubFeatureType>()
.Select(f => (long)f)
.ToArray();
}
/// <summary>
/// دریافت عنوان فارسی ویژگی
/// </summary>
public static string GetPersianTitle(this ClubFeatureType featureType)
{
return featureType switch
{
ClubFeatureType.Chatika => "چتیکا",
ClubFeatureType.Bime => "بیمه",
ClubFeatureType.Trip => "تور و سفر",
ClubFeatureType.Learn => "آموزش",
_ => featureType.ToString()
};
}
}
@@ -0,0 +1,194 @@
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Entities.Club;
using CMSMicroservice.Domain.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Polly;
namespace CMSMicroservice.Infrastructure.BackgroundJobs;
/// <summary>
/// Hangfire Job برای فعال‌سازی حساب چتیکا برای اعضای جدید باشگاه
/// این Job کاربرانی که باشگاهشان فعال شده ولی حساب چتیکا ندارند را پیدا کرده و حساب می‌سازد
/// </summary>
public class ChatikaAccountActivationJob
{
private readonly IApplicationDbContext _context;
private readonly IChatikaApiService _chatikaApiService;
private readonly IConfiguration _configuration;
private readonly ILogger<ChatikaAccountActivationJob> _logger;
private readonly ResiliencePipeline _retryPipeline;
/// <summary>
/// توضیحات فارسی فیچر چتیکا
/// </summary>
private const string ChatikaFeatureDescription =
"🎉 تبریک! حساب هوش مصنوعی چتیکا شما فعال شد.\n\n" +
"برای استفاده از امکانات رایگان چتیکا:\n" +
"1️⃣ به وب‌سایت chatika.ir مراجعه کنید\n" +
"2️⃣ شماره موبایل خود را وارد کنید\n" +
"3️⃣ از دستیار هوشمند چتیکا لذت ببرید!\n\n" +
"🔗 لینک ورود: https://chatika.ir";
public ChatikaAccountActivationJob(
IApplicationDbContext context,
IChatikaApiService chatikaApiService,
IConfiguration configuration,
ILogger<ChatikaAccountActivationJob> logger)
{
_context = context;
_chatikaApiService = chatikaApiService;
_configuration = configuration;
_logger = logger;
// Polly Retry: 3 تلاش با فاصله نمایی
_retryPipeline = new ResiliencePipelineBuilder()
.AddRetry(new Polly.Retry.RetryStrategyOptions
{
MaxRetryAttempts = 3,
Delay = TimeSpan.FromSeconds(30),
BackoffType = Polly.DelayBackoffType.Exponential,
UseJitter = true,
OnRetry = args =>
{
_logger.LogWarning(
"⚠️ Retry attempt {AttemptNumber} for Chatika API. Exception: {ExceptionType}",
args.AttemptNumber,
args.Outcome.Exception?.GetType().Name ?? "None");
return ValueTask.CompletedTask;
}
})
.Build();
}
/// <summary>
/// اجرای Job برای فعال‌سازی حساب چتیکا
/// </summary>
public async Task ExecuteAsync(CancellationToken cancellationToken = default)
{
// Check if Chatika integration is enabled
var enabled = _configuration.GetValue<bool>("Chatika:Enabled");
if (!enabled)
{
_logger.LogDebug("Chatika integration is disabled (Chatika:Enabled = false). Skipping job.");
return;
}
var executionId = Guid.NewGuid();
_logger.LogInformation(
"🚀 [{ExecutionId}] Starting Chatika account activation job",
executionId);
try
{
// پیدا کردن کاربرانی که:
// 1. باشگاه فعال دارند (ClubMembership.IsActive = true)
// 2. فیچر چتیکا (Id=1) رو دارند
// 3. فیچر چتیکاشون هنوز Notes نداره (یعنی حساب ساخته نشده)
var pendingUsers = await _context.UserClubFeatures
.Include(ucf => ucf.User)
.Include(ucf => ucf.ClubMembership)
.Where(ucf =>
ucf.ClubFeatureId == (long)ClubFeatureType.Chatika &&
ucf.ClubMembership.IsActive &&
!ucf.IsDeleted &&
!ucf.IsActive) // حساب هنوز ساخته نشده
.ToListAsync(cancellationToken);
if (!pendingUsers.Any())
{
_logger.LogInformation(
"✅ [{ExecutionId}] No pending users for Chatika activation",
executionId);
return;
}
_logger.LogInformation(
"📋 [{ExecutionId}] Found {Count} users pending Chatika activation",
executionId,
pendingUsers.Count);
var successCount = 0;
var failCount = 0;
foreach (var userFeature in pendingUsers)
{
try
{
// بررسی تکراری نبودن (Double-check)
if (userFeature.IsActive)
{
_logger.LogDebug(
"⏭️ Skipping user {UserId} - already processed",
userFeature.UserId);
continue;
}
var user = userFeature.User;
var fullName = $"{user.FirstName} {user.LastName}".Trim();
if (string.IsNullOrWhiteSpace(fullName)) fullName = user.Mobile;
// کال کردن API چتیکا با retry
var result = await _retryPipeline.ExecuteAsync(
async ct => await _chatikaApiService.CreateAccountAsync(
user.Mobile,
fullName,
ct),
cancellationToken);
if (result.IsSuccess)
{
// آپدیت فیچر با توضیحات و URL
var description = ChatikaFeatureDescription;
if (!string.IsNullOrEmpty(result.AccessUrl))
{
description = description.Replace(
"https://chatika.ir",
result.AccessUrl);
}
userFeature.Notes = description;
userFeature.IsActive = true;
userFeature.LastModified = DateTime.Now;
await _context.SaveChangesAsync(cancellationToken);
_logger.LogInformation(
"✅ Chatika account activated for user {UserId}",
userFeature.UserId);
successCount++;
}
else
{
_logger.LogWarning(
"⚠️ Failed to create Chatika account for user {UserId}: {Error}",
userFeature.UserId,
result.ErrorMessage);
failCount++;
}
}
catch (Exception ex)
{
_logger.LogError(ex,
"❌ Error processing Chatika activation for user {UserId}",
userFeature.UserId);
failCount++;
}
}
_logger.LogInformation(
"🏁 [{ExecutionId}] Chatika activation job completed. Success: {Success}, Failed: {Failed}",
executionId,
successCount,
failCount);
}
catch (Exception ex)
{
_logger.LogError(ex,
"❌ [{ExecutionId}] Chatika activation job failed",
executionId);
throw;
}
}
}
@@ -92,9 +92,19 @@ public static class ConfigureServices
// Commission Calculation Strategy Factory - برای سوییچ بین ORM و SP
services.AddScoped<ICommissionCalculationStrategyFactory, CommissionCalculationStrategyFactory>();
// Chatika API Service - سرویس ایجاد حساب چتیکا
services.AddHttpClient<IChatikaApiService, ChatikaApiService>()
.SetHandlerLifetime(TimeSpan.FromMinutes(5))
.ConfigureHttpClient((sp, client) =>
{
var config = sp.GetRequiredService<IConfiguration>();
client.Timeout = TimeSpan.FromSeconds(30);
});
// Background Workers - Deprecated: Using Hangfire instead
// services.AddHostedService<WeeklyNetworkCommissionWorker>();
services.AddScoped<WeeklyCommissionJob>(); // Hangfire Job (Scoped for DI)
services.AddScoped<ChatikaAccountActivationJob>(); // Hangfire Job for Chatika activation
if (configuration.GetValue<bool>("UseInMemoryDatabase"))
{
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,22 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace CMSMicroservice.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class u18 : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}
@@ -0,0 +1,156 @@
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using CMSMicroservice.Application.Common.Interfaces;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
namespace CMSMicroservice.Infrastructure.Services;
/// <summary>
/// پیاده‌سازی سرویس چتیکا با HttpClient
/// API: POST /api/v1/organizations/register-user
/// </summary>
public class ChatikaApiService : IChatikaApiService
{
private readonly HttpClient _httpClient;
private readonly ILogger<ChatikaApiService> _logger;
private readonly string _baseUrl;
private readonly string _apiKey;
public ChatikaApiService(
HttpClient httpClient,
ILogger<ChatikaApiService> logger,
IConfiguration configuration)
{
_httpClient = httpClient;
_logger = logger;
// تنظیمات از appsettings.json
_baseUrl = configuration["Chatika:BaseUrl"] ?? "https://api.chatika.ir";
_apiKey = configuration["Chatika:ApiKey"] ?? "";
// تنظیم HttpClient
_httpClient.BaseAddress = new Uri(_baseUrl);
if (!string.IsNullOrEmpty(_apiKey))
{
_httpClient.DefaultRequestHeaders.Add("X-API-Key", _apiKey);
}
}
/// <inheritdoc />
public async Task<ChatikaAccountResult> CreateAccountAsync(
string mobileNumber,
string fullName,
CancellationToken cancellationToken = default)
{
try
{
_logger.LogInformation(
"🤖 Creating Chatika account for mobile: {Mobile}",
mobileNumber.Substring(0, 4) + "***");
var request = new ChatikaRegisterRequest
{
MobileNumber = mobileNumber
};
var response = await _httpClient.PostAsJsonAsync(
"/api/v1/organizations/register-user",
request,
cancellationToken);
if (response.IsSuccessStatusCode)
{
var result = await response.Content.ReadFromJsonAsync<ChatikaRegisterResponse>(
cancellationToken: cancellationToken);
_logger.LogInformation(
"✅ Chatika account created successfully. UserId: {UserId}, IsNewUser: {IsNew}, Credit: {Credit}",
result?.Id,
result?.IsNewUser,
result?.CreditCharged);
return ChatikaAccountResult.Success(
chatikaUserId: result?.Id.ToString(),
accessUrl: "https://chatika.ir"
);
}
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken);
var errorResponse = JsonSerializer.Deserialize<ChatikaErrorResponse>(errorContent);
_logger.LogWarning(
"⚠️ Chatika API returned error. Status: {Status}, Code: {ErrorCode}, Message: {Message}",
response.StatusCode,
errorResponse?.ErrorCode,
errorResponse?.Message);
return ChatikaAccountResult.Failure($"{errorResponse?.ErrorCode}: {errorResponse?.Message}");
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "❌ Network error calling Chatika API");
return ChatikaAccountResult.Failure($"خطای شبکه: {ex.Message}");
}
catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException)
{
_logger.LogError(ex, "❌ Timeout calling Chatika API");
return ChatikaAccountResult.Failure("تایم‌اوت در ارتباط با سرویس چتیکا");
}
catch (Exception ex)
{
_logger.LogError(ex, "❌ Unexpected error calling Chatika API");
return ChatikaAccountResult.Failure($"خطای غیرمنتظره: {ex.Message}");
}
}
/// <summary>
/// Request model برای ثبت کاربر در چتیکا
/// </summary>
private class ChatikaRegisterRequest
{
[JsonPropertyName("mobile_number")]
public string MobileNumber { get; set; } = string.Empty;
}
/// <summary>
/// Response model موفق از چتیکا
/// </summary>
private class ChatikaRegisterResponse
{
[JsonPropertyName("id")]
public long Id { get; set; }
[JsonPropertyName("mobile_number")]
public string MobileNumber { get; set; } = string.Empty;
[JsonPropertyName("organization_id")]
public long OrganizationId { get; set; }
[JsonPropertyName("organization_title")]
public string OrganizationTitle { get; set; } = string.Empty;
[JsonPropertyName("wallet_balance")]
public decimal WalletBalance { get; set; }
[JsonPropertyName("is_new_user")]
public bool IsNewUser { get; set; }
[JsonPropertyName("credit_charged")]
public decimal CreditCharged { get; set; }
}
/// <summary>
/// Response model خطا از چتیکا
/// </summary>
private class ChatikaErrorResponse
{
[JsonPropertyName("error_code")]
public string ErrorCode { get; set; } = string.Empty;
[JsonPropertyName("message")]
public string Message { get; set; } = string.Empty;
}
}
+8
View File
@@ -223,6 +223,14 @@ using (var scope = app.Services.CreateScope())
// Daya Loan Check: Every 15 minutes
CMSMicroservice.WebApi.Workers.DayaLoanCheckWorker.Schedule(recurringJobManager);
app.Logger.LogInformation("✅ Hangfire recurring job 'daya-loan-check' registered (Cron: */15 * * * * - Every 15 minutes)");
// Chatika Account Activation: Every 5 minutes
recurringJobManager.AddOrUpdate<CMSMicroservice.Infrastructure.BackgroundJobs.ChatikaAccountActivationJob>(
recurringJobId: "chatika-account-activation",
methodCall: job => job.ExecuteAsync(CancellationToken.None),
cronExpression: "*/5 * * * *",
options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc });
app.Logger.LogInformation("✅ Hangfire recurring job 'chatika-account-activation' registered (Cron: */5 * * * * - Every 5 minutes)");
}
app.Run();
@@ -39,12 +39,17 @@ public class DayaLoanCheckWorker
try
{
// پیدا کردن کاربرانی که اعتبار دایا را دریافت نکرده‌اند
// پیدا کردن کاربرانی که:
// 1. اعتبار دایا را دریافت نکرده‌اند
// 2. کد ملی دارند
// 3. قبلاً شماره قرارداد نگرفته‌اند
var pendingUsers = await _context.Users
.Where(u =>
u.HasReceivedDayaCredit == false &&
u.NationalCode != null &&
u.NationalCode != "")
u.NationalCode != ""
)
.Select(u => new { u.Id, u.NationalCode })
.ToListAsync();
@@ -1,20 +1,35 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore": "Warning"
}
},
"UseRealPaymentGateway": false,
"JwtSecurityKey": "TvlZVx5TJaHs8e9HgUdGzhGP2CIidoI444nAj+8+g7c=",
"JwtIssuer": "https://localhost",
"JwtAudience": "https://localhost",
"JwtExpiryInDays": 5,
"ConnectionStrings": {
"DefaultConnection": "Server=YOUR_PRODUCTION_SERVER;Database=FourSat_CMS;User Id=YOUR_USER;Password=YOUR_PASSWORD;TrustServerCertificate=True;MultipleActiveResultSets=true"
"DefaultConnection": "Data Source=45.149.79.127,31433; Initial Catalog=KBS;User ID=sa;Password=YourStrong@Passw0rd;Connection Timeout=300000;MultipleActiveResultSets=True;Encrypt=False",
"providerName": "System.Data.SqlClient"
},
"Otp": {
"Secret": "K2w8k1h1mH2Qz1kqWk0c8kQ2Pq8q9H1eE2nqN1qQ8x7M="
},
"Monitoring": {
"SentryEnabled": false,
"SentryDsn": "",
"SlackEnabled": false,
"SlackWebhookUrl": "",
"EmailAlertsEnabled": false,
"AdminEmails": [
"admin@example.com"
],
"SmsNotificationsEnabled": false,
"SmsApiKey": "",
"SmsGatewayUrl": ""
},
"Email": {
"Enabled": true,
"SmtpHost": "smtp.gmail.com",
"SmtpPort": 587,
"SmtpUsername": "your-production-email@gmail.com",
"SmtpPassword": "your-gmail-app-password",
"SmtpUsername": "your-email@gmail.com",
"SmtpPassword": "your-app-password",
"FromEmail": "noreply@foursat.com",
"FromName": "FourSat CMS",
"EnableSsl": true
@@ -22,13 +37,36 @@
"Sms": {
"Enabled": true,
"Provider": "Kavenegar",
"KavenegarApiKey": "YOUR_PRODUCTION_KAVENEGAR_API_KEY",
"KavenegarApiKey": "YOUR_KAVENEGAR_API_KEY",
"Sender": "10008663"
},
"Jwt": {
"Issuer": "https://api.foursat.com",
"Audience": "https://foursat.com",
"SecretKey": "YOUR_PRODUCTION_SECRET_KEY_MINIMUM_32_CHARACTERS_LONG"
"DayaPayment": {
"BaseUrl": "https://api.daya.ir",
"ApiKey": "YOUR_DAYA_API_KEY"
},
"AllowedHosts": "*"
"DayaApi": {
"UseMock": false,
"BaseAddress": "https://Dayadiamond.ir",
"MerchantPermissionKey": "56146364$04sXjethI5WxhItR1Q9xnmFdJzl2BB8Bclsq8dAy7YVSZp3vtt-wP7ivrcCvmKLq",
"CacheDurationMinutes": 20
},
"Chatika": {
"Enabled": true,
"BaseUrl": "https://api.chatika.ir",
"ApiKey": "tIukvL8dnV4cB3yVWcCD9Xyfbj8rBxm5wPt2mLyJCgTsBBoMTWjt6mFEqQwpw-er"
},
"AllowedHosts": "*",
"Kestrel": {
"EndpointDefaults": {
"Protocols": "Http2"
}
},
"Authentication": {
"Authority": "https://ids.domain.com/",
"Audience": "domain_api"
},
"Seq": {
"ServerUrl": "https://seq.afrino.co",
"ApiKey": "oxpvpUzU1pZxMS4s3Fqq"
}
}
+11 -6
View File
@@ -5,20 +5,20 @@
"JwtAudience": "https://localhost",
"JwtExpiryInDays": 5,
"ConnectionStrings": {
"DefaultConnection": "Data Source=185.252.31.42,2019; Initial Catalog=KBS;User ID=afrino;Password=87zH26nbqT%;Connection Timeout=300000;MultipleActiveResultSets=True;Encrypt=False",
"DefaultConnection": "",
"providerName": "System.Data.SqlClient"
},
"Otp": {
"Secret": "K2w8k1h1mH2Qz1kqWk0c8kQ2Pq8q9H1eE2nqN1qQ8x7M="
"Secret": "K2w8k1h1mH2Qz1kqWk0c8kQ2Pq8q9H1eE2nqN1qQ8x7M="
},
"Monitoring": {
"SentryEnabled": false,
"SentryDsn": "",
"SentryDsn": "",
"SlackEnabled": false,
"SlackWebhookUrl": "",
"EmailAlertsEnabled": false,
"AdminEmails": [
"admin@example.com"
"admin@example.com"
],
"SmsNotificationsEnabled": false,
"SmsApiKey": "",
@@ -50,6 +50,11 @@
"MerchantPermissionKey": "56146364$04sXjethI5WxhItR1Q9xnmFdJzl2BB8Bclsq8dAy7YVSZp3vtt-wP7ivrcCvmKLq",
"CacheDurationMinutes": 20
},
"Chatika": {
"Enabled": true,
"BaseUrl": "https://api.chatika.ir",
"ApiKey": "tIukvL8dnV4cB3yVWcCD9Xyfbj8rBxm5wPt2mLyJCgTsBBoMTWjt6mFEqQwpw-er"
},
"AllowedHosts": "*",
"Kestrel": {
"EndpointDefaults": {
@@ -61,7 +66,7 @@
"Audience": "domain_api"
},
"Seq": {
"ServerUrl": "http://seq-svc:5341",
"ApiKey": ""
"ServerUrl": "https://seq.afrino.co",
"ApiKey": "oxpvpUzU1pZxMS4s3Fqq"
}
}