admin page phaze2
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user