admin page phaze2

This commit is contained in:
2026-09-25 16:34:36 +03:30
parent 9d4ad26dcc
commit 6abb5aae15
19 changed files with 2847 additions and 6 deletions

View File

@@ -1,8 +1,13 @@
using System.Text.Json;
using AutoMapper;
using GreenHome.Application;
using GreenHome.Application.Dtos.Admin;
using GreenHome.Domain;
using GreenHome.Infrastructure.Data;
using GreenHome.Infrastructure.Services.Usage;
using GreenHome.Sms.Ippanel;
using GreenHome.VoiceCall.Avanak;
using Microsoft.AspNetCore.Hosting;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
@@ -12,12 +17,27 @@ public sealed class AdminService : IAdminService
{
private readonly GreenHomeDbContext _dbContext;
private readonly IMapper _mapper;
private readonly ISmsService _smsService;
private readonly IVoiceCallService _voiceCallService;
private readonly UsageTracker _usageTracker;
private readonly IWebHostEnvironment _environment;
private readonly int _onlineWindowMinutes;
public AdminService(GreenHomeDbContext dbContext, IMapper mapper, IConfiguration configuration)
public AdminService(
GreenHomeDbContext dbContext,
IMapper mapper,
IConfiguration configuration,
ISmsService smsService,
IVoiceCallService voiceCallService,
UsageTracker usageTracker,
IWebHostEnvironment environment)
{
_dbContext = dbContext;
_mapper = mapper;
_smsService = smsService;
_voiceCallService = voiceCallService;
_usageTracker = usageTracker;
_environment = environment;
_onlineWindowMinutes = configuration.GetValue("Admin:OnlineWindowMinutes", 15);
}
@@ -173,6 +193,8 @@ public sealed class AdminService : IAdminService
}
user.Role = role;
AddAuditLog(currentAdminId, "User.SetRole", id.ToString(),
JsonSerializer.Serialize(new { role = role.ToString(), roleId = (int)role }));
await _dbContext.SaveChangesAsync(cancellationToken);
return UserMutationOutcome.Ok;
}
@@ -193,6 +215,7 @@ public sealed class AdminService : IAdminService
}
user.IsDisabled = !isEnabled;
AddAuditLog(currentAdminId, isEnabled ? "User.Enable" : "User.Disable", id.ToString());
await _dbContext.SaveChangesAsync(cancellationToken);
return UserMutationOutcome.Ok;
}
@@ -319,8 +342,280 @@ public sealed class AdminService : IAdminService
}).ToList();
}
// ==================== Alerts ====================
public async Task<PagedResult<AdminAlertItemDto>> GetAlertsAsync(AdminAlertFilter filter, CancellationToken cancellationToken)
{
if (filter.Page <= 0) filter.Page = 1;
if (filter.PageSize <= 0) filter.PageSize = 20;
IQueryable<Domain.AlertLog> query = _dbContext.AlertLogs.AsNoTracking()
.Include(a => a.Device).ThenInclude(d => d.User);
if (filter.DeviceId.HasValue)
query = query.Where(a => a.DeviceId == filter.DeviceId.Value);
if (filter.Status.HasValue)
query = query.Where(a => a.Status == filter.Status.Value);
if (filter.From.HasValue)
query = query.Where(a => a.SentAt >= filter.From.Value);
if (filter.To.HasValue)
query = query.Where(a => a.SentAt <= filter.To.Value);
var totalCount = await query.CountAsync(cancellationToken);
var items = await query
.OrderByDescending(a => a.SentAt)
.ThenByDescending(a => a.Id)
.Skip((filter.Page - 1) * filter.PageSize)
.Take(filter.PageSize)
.ToListAsync(cancellationToken);
return new PagedResult<AdminAlertItemDto>
{
Items = items.Select(a => new AdminAlertItemDto
{
Id = a.Id,
DeviceId = a.DeviceId,
DeviceName = a.Device.DeviceName,
OwnerName = a.Device.User.Name + " " + a.Device.User.Family,
Message = a.Message,
NotificationType = a.NotificationType,
Status = a.Status,
ErrorMessage = a.ErrorMessage,
SentAt = a.SentAt
}).ToList(),
TotalCount = totalCount,
Page = filter.Page,
PageSize = filter.PageSize
};
}
// ==================== Posts ====================
public async Task<PagedResult<AdminPostItemDto>> GetPostsAsync(AdminPostFilter filter, CancellationToken cancellationToken)
{
if (filter.Page <= 0) filter.Page = 1;
if (filter.PageSize <= 0) filter.PageSize = 20;
IQueryable<Domain.DevicePost> query = _dbContext.DevicePosts.AsNoTracking()
.Include(p => p.Device)
.Include(p => p.AuthorUser)
.Include(p => p.Images);
if (filter.DeviceId.HasValue)
query = query.Where(p => p.DeviceId == filter.DeviceId.Value);
var totalCount = await query.CountAsync(cancellationToken);
var items = await query
.OrderByDescending(p => p.CreatedAt)
.ThenByDescending(p => p.Id)
.Skip((filter.Page - 1) * filter.PageSize)
.Take(filter.PageSize)
.ToListAsync(cancellationToken);
return new PagedResult<AdminPostItemDto>
{
// ponytail: like/reply count intentionally omitted — DevicePost has no such fields yet.
Items = items.Select(p => new AdminPostItemDto
{
Id = p.Id,
DeviceId = p.DeviceId,
DeviceName = p.Device.DeviceName,
AuthorName = p.AuthorUser.Name + " " + p.AuthorUser.Family,
Content = p.Content,
PostType = p.PostType,
ImageCount = p.Images.Count,
CreatedAt = p.CreatedAt
}).ToList(),
TotalCount = totalCount,
Page = filter.Page,
PageSize = filter.PageSize
};
}
public async Task<bool> DeletePostAsync(int id, int currentAdminId, CancellationToken cancellationToken)
{
var post = await _dbContext.DevicePosts
.Include(p => p.Images)
.FirstOrDefaultAsync(p => p.Id == id, cancellationToken);
if (post == null) return false;
// remove image files from disc (best effort — a missing file must not block the delete)
var webRoot = _environment.WebRootPath ?? "wwwroot";
foreach (var image in post.Images)
{
try
{
var fullPath = Path.Combine(webRoot, image.FilePath.TrimStart('/'));
if (File.Exists(fullPath)) File.Delete(fullPath);
}
catch
{
// file cleanup is best-effort
}
}
if (!string.IsNullOrWhiteSpace(post.VoiceFilePath))
{
try
{
var voicePath = Path.Combine(webRoot, post.VoiceFilePath.TrimStart('/'));
if (File.Exists(voicePath)) File.Delete(voicePath);
}
catch
{
// file cleanup is best-effort
}
}
AddAuditLog(currentAdminId, "Post.Delete", id.ToString());
_dbContext.DevicePosts.Remove(post);
await _dbContext.SaveChangesAsync(cancellationToken);
return true;
}
// ==================== Device Debug ====================
public async Task<AdminDeviceTestResultDto> TestDeviceSmsAsync(int deviceId, int currentAdminId, CancellationToken cancellationToken)
{
var device = await _dbContext.Devices.AsNoTracking()
.Include(d => d.User)
.FirstOrDefaultAsync(d => d.Id == deviceId, cancellationToken);
if (device == null)
return new AdminDeviceTestResultDto { Success = false, Message = "دستگاه یافت نشد" };
var mobile = MobileNormalizer.Normalize(device.User.Mobile);
if (mobile.Length == 0)
return new AdminDeviceTestResultDto { Success = false, Message = "شماره موبایل مالک معتبر نیست" };
// persist the audit row up-front so it is recorded no matter how the send attempt ends
AddAuditLog(currentAdminId, "Device.TestSms", deviceId.ToString(),
JsonSerializer.Serialize(new { mobile }));
await _dbContext.SaveChangesAsync(cancellationToken);
try
{
var result = await _smsService.SendWebserviceSmsAsync(new WebserviceSmsRequest
{
Recipient = mobile,
Message = $"پیامک تست GreenHome برای دستگاه {device.DeviceName} — {DateTime.UtcNow:yyyy/MM/dd HH:mm} UTC"
}, cancellationToken);
if (result?.Meta.Status == true)
{
await _usageTracker.RecordAsync(device.User.Id, UsageKind.Sms, cancellationToken: cancellationToken);
return new AdminDeviceTestResultDto { Success = true, Message = $"پیامک تست برای {mobile} ارسال شد" };
}
var reason = result?.Meta.Message ?? "خطای نامشخص سرویس پیامک";
return new AdminDeviceTestResultDto { Success = false, Message = reason };
}
catch (Exception ex)
{
return new AdminDeviceTestResultDto { Success = false, Message = $"خطا در ارسال: {ex.Message}" };
}
}
public async Task<AdminDeviceTestResultDto> TestDeviceVoiceAsync(int deviceId, int currentAdminId, CancellationToken cancellationToken)
{
var device = await _dbContext.Devices.AsNoTracking()
.Include(d => d.User)
.FirstOrDefaultAsync(d => d.Id == deviceId, cancellationToken);
if (device == null)
return new AdminDeviceTestResultDto { Success = false, Message = "دستگاه یافت نشد" };
var mobile = MobileNormalizer.Normalize(device.User.Mobile);
if (mobile.Length == 0)
return new AdminDeviceTestResultDto { Success = false, Message = "شماره موبایل مالک معتبر نیست" };
// persist the audit row up-front so it is recorded no matter how the send attempt ends
AddAuditLog(currentAdminId, "Device.TestVoice", deviceId.ToString(),
JsonSerializer.Serialize(new { mobile }));
await _dbContext.SaveChangesAsync(cancellationToken);
try
{
var result = await _voiceCallService.QuickSendWithTTSAsync(new QuickSendWithTTSRequest
{
Text = $"پیام صوتی تست از پنل مدیریت گری‌هوم برای دستگاه {device.DeviceName}",
Number = mobile,
ServerID = 0
}, cancellationToken);
if (result?.IsSuccess == true)
{
// ponytail: ثبت مصرف فقط وقتی call واقعی ارسال می‌شود (هماهنگ با AlertNotificationSender)
await _usageTracker.RecordAsync(device.User.Id, UsageKind.VoiceCall, cancellationToken: cancellationToken);
return new AdminDeviceTestResultDto { Success = true, Message = $"تماس صوتی تست برای {mobile} انجام شد" };
}
var reason = result == null ? "پاسخی از سرویس صوتی دریافت نشد"
: $"کد خطا: {result.ReturnValue}";
return new AdminDeviceTestResultDto { Success = false, Message = reason };
}
catch (Exception ex)
{
return new AdminDeviceTestResultDto { Success = false, Message = $"خطا در تماس صوتی: {ex.Message}" };
}
}
// ==================== Audit Log ====================
public async Task<PagedResult<AdminAuditLogDto>> GetAuditLogsAsync(AdminAuditLogFilter filter, CancellationToken cancellationToken)
{
if (filter.Page <= 0) filter.Page = 1;
if (filter.PageSize <= 0) filter.PageSize = 20;
IQueryable<Domain.AdminAuditLog> query = _dbContext.AdminAuditLogs.AsNoTracking()
.Include(a => a.AdminUser);
if (filter.AdminId.HasValue)
query = query.Where(a => a.AdminUserId == filter.AdminId.Value);
var totalCount = await query.CountAsync(cancellationToken);
var items = await query
.OrderByDescending(a => a.At)
.ThenByDescending(a => a.Id)
.Skip((filter.Page - 1) * filter.PageSize)
.Take(filter.PageSize)
.ToListAsync(cancellationToken);
return new PagedResult<AdminAuditLogDto>
{
Items = items.Select(a => new AdminAuditLogDto
{
Id = a.Id,
AdminUserId = a.AdminUserId,
AdminName = a.AdminUser.Name + " " + a.AdminUser.Family,
Action = a.Action,
TargetId = a.TargetId,
Details = a.Details,
At = a.At
}).ToList(),
TotalCount = totalCount,
Page = filter.Page,
PageSize = filter.PageSize
};
}
// ==================== Helpers ====================
/// <summary>
/// یک ردیف audit log به change tracker اضافه می‌کند؛ با همان SaveChanges عملیات اصلی ذخیره می‌شود.
/// </summary>
private void AddAuditLog(int adminUserId, string action, string targetId, string details = "")
{
_dbContext.AdminAuditLogs.Add(new Domain.AdminAuditLog
{
AdminUserId = adminUserId,
Action = action,
TargetId = targetId,
Details = details,
At = DateTime.UtcNow
});
}
/// <summary>
/// آخرین تلیمتری هر دستگاه + نام مالک (برای لیست‌های admin).
/// </summary>