Initial commit

This commit is contained in:
2026-01-04 23:00:21 +08:00
commit d3178871eb
124 changed files with 19300 additions and 0 deletions

View File

@@ -0,0 +1,329 @@
using System.Text.Json;
using License.Api.Data;
using License.Api.DTOs;
using License.Api.Models;
using License.Api.Services;
using License.Api.Utils;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace License.Api.Controllers;
[ApiController]
[Authorize(Policy = "SuperAdmin")]
[Route("api/admin/agents")]
public class AdminAgentsController : ControllerBase
{
private readonly AppDbContext _db;
private readonly ConfigService _config;
public AdminAgentsController(AppDbContext db, ConfigService config)
{
_db = db;
_config = config;
}
[HttpPost]
public async Task<IActionResult> Create([FromBody] AgentCreateRequest request)
{
var agentSystemEnabled = await _config.GetBoolAsync("feature.agent_system", true);
if (!agentSystemEnabled)
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
var agent = new Agent
{
AdminId = request.AdminId,
AgentCode = request.AgentCode,
CompanyName = request.CompanyName,
ContactPerson = request.ContactPerson,
ContactPhone = request.ContactPhone,
ContactEmail = request.ContactEmail,
PasswordHash = PasswordHasher.Hash(request.Password),
Balance = request.InitialBalance,
Discount = request.Discount,
CreditLimit = request.CreditLimit,
MaxProjects = request.AllowedProjects?.Count ?? 0,
AllowedProjects = request.AllowedProjects == null ? null : JsonSerializer.Serialize(request.AllowedProjects),
Status = "active",
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow
};
_db.Agents.Add(agent);
await _db.SaveChangesAsync();
var data = new AgentDetailResponse
{
Id = agent.Id,
AgentCode = agent.AgentCode,
CompanyName = agent.CompanyName,
ContactPerson = agent.ContactPerson,
ContactPhone = agent.ContactPhone,
ContactEmail = agent.ContactEmail,
Balance = agent.Balance,
Discount = agent.Discount,
CreditLimit = agent.CreditLimit,
Status = agent.Status,
CreatedAt = agent.CreatedAt
};
return Ok(ApiResponse<AgentDetailResponse>.Ok(data));
}
[HttpGet]
public async Task<IActionResult> List([FromQuery] string? status, [FromQuery] int page = 1, [FromQuery] int pageSize = 20)
{
var agentSystemEnabled = await _config.GetBoolAsync("feature.agent_system", true);
if (!agentSystemEnabled)
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
page = Math.Max(1, page);
pageSize = Math.Clamp(pageSize, 1, 100);
var query = _db.Agents.AsQueryable();
if (!string.IsNullOrWhiteSpace(status))
query = query.Where(a => a.Status == status);
var total = await query.CountAsync();
var items = await query.OrderByDescending(a => a.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(a => new AgentListItem
{
Id = a.Id,
AgentCode = a.AgentCode,
CompanyName = a.CompanyName,
ContactPerson = a.ContactPerson,
ContactPhone = a.ContactPhone,
Balance = a.Balance,
Discount = a.Discount,
Status = a.Status,
CreatedAt = a.CreatedAt
})
.ToListAsync();
var result = new PagedResult<AgentListItem>
{
Items = items,
Pagination = new PaginationInfo
{
Page = page,
PageSize = pageSize,
Total = total,
TotalPages = (int)Math.Ceiling(total / (double)pageSize)
}
};
return Ok(ApiResponse<PagedResult<AgentListItem>>.Ok(result));
}
[HttpGet("{id:int}")]
public async Task<IActionResult> Get(int id)
{
var agentSystemEnabled = await _config.GetBoolAsync("feature.agent_system", true);
if (!agentSystemEnabled)
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
var agent = await _db.Agents.FindAsync(id);
if (agent == null)
return NotFound(ApiResponse.Fail(404, "not_found"));
var transactions = await _db.AgentTransactions
.Where(t => t.AgentId == id)
.OrderByDescending(t => t.CreatedAt)
.Take(50)
.ToListAsync();
var stats = await _db.CardKeys
.Where(c => c.AgentId == id && c.DeletedAt == null)
.GroupBy(c => c.AgentId)
.Select(g => new
{
totalCards = g.Count(),
activeCards = g.Count(x => x.Status == "active"),
totalRevenue = g.Sum(x => x.SoldPrice ?? 0)
})
.SingleOrDefaultAsync();
return Ok(ApiResponse<object>.Ok(new
{
agent = new AgentDetailResponse
{
Id = agent.Id,
AgentCode = agent.AgentCode,
CompanyName = agent.CompanyName,
ContactPerson = agent.ContactPerson,
ContactPhone = agent.ContactPhone,
ContactEmail = agent.ContactEmail,
Balance = agent.Balance,
Discount = agent.Discount,
CreditLimit = agent.CreditLimit,
Status = agent.Status,
CreatedAt = agent.CreatedAt
},
stats,
transactions
}));
}
[HttpPut("{id:int}")]
public async Task<IActionResult> Update(int id, [FromBody] AgentUpdateRequest request)
{
var agentSystemEnabled = await _config.GetBoolAsync("feature.agent_system", true);
if (!agentSystemEnabled)
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
var agent = await _db.Agents.FindAsync(id);
if (agent == null)
return NotFound(ApiResponse.Fail(404, "not_found"));
if (!string.IsNullOrWhiteSpace(request.CompanyName))
agent.CompanyName = request.CompanyName;
if (!string.IsNullOrWhiteSpace(request.ContactPerson))
agent.ContactPerson = request.ContactPerson;
if (!string.IsNullOrWhiteSpace(request.ContactPhone))
agent.ContactPhone = request.ContactPhone;
if (!string.IsNullOrWhiteSpace(request.ContactEmail))
agent.ContactEmail = request.ContactEmail;
if (request.Discount.HasValue)
agent.Discount = request.Discount.Value;
if (request.CreditLimit.HasValue)
agent.CreditLimit = request.CreditLimit.Value;
if (request.AllowedProjects != null)
{
agent.AllowedProjects = JsonSerializer.Serialize(request.AllowedProjects);
agent.MaxProjects = request.AllowedProjects.Count;
}
if (!string.IsNullOrWhiteSpace(request.Status))
agent.Status = request.Status;
agent.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync();
return Ok(ApiResponse.Ok());
}
[HttpPost("{id:int}/disable")]
public async Task<IActionResult> Disable(int id)
{
var agentSystemEnabled = await _config.GetBoolAsync("feature.agent_system", true);
if (!agentSystemEnabled)
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
var agent = await _db.Agents.FindAsync(id);
if (agent == null)
return NotFound(ApiResponse.Fail(404, "not_found"));
agent.Status = "disabled";
agent.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync();
return Ok(ApiResponse.Ok());
}
[HttpPost("{id:int}/enable")]
public async Task<IActionResult> Enable(int id)
{
var agentSystemEnabled = await _config.GetBoolAsync("feature.agent_system", true);
if (!agentSystemEnabled)
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
var agent = await _db.Agents.FindAsync(id);
if (agent == null)
return NotFound(ApiResponse.Fail(404, "not_found"));
agent.Status = "active";
agent.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync();
return Ok(ApiResponse.Ok());
}
[HttpDelete("{id:int}")]
public async Task<IActionResult> Delete(int id)
{
var agentSystemEnabled = await _config.GetBoolAsync("feature.agent_system", true);
if (!agentSystemEnabled)
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
var agent = await _db.Agents.FindAsync(id);
if (agent == null)
return NotFound(ApiResponse.Fail(404, "not_found"));
_db.Agents.Remove(agent);
await _db.SaveChangesAsync();
return Ok(ApiResponse.Ok());
}
[HttpPost("{id:int}/recharge")]
public async Task<IActionResult> Recharge(int id, [FromBody] AgentBalanceRequest request)
{
var agentSystemEnabled = await _config.GetBoolAsync("feature.agent_system", true);
if (!agentSystemEnabled)
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
return await AdjustBalance(id, request.Amount, "recharge", request.Remark);
}
[HttpPost("{id:int}/deduct")]
public async Task<IActionResult> Deduct(int id, [FromBody] AgentBalanceRequest request)
{
var agentSystemEnabled = await _config.GetBoolAsync("feature.agent_system", true);
if (!agentSystemEnabled)
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
return await AdjustBalance(id, -Math.Abs(request.Amount), "consume", request.Remark);
}
[HttpGet("{id:int}/transactions")]
public async Task<IActionResult> Transactions(int id)
{
var agentSystemEnabled = await _config.GetBoolAsync("feature.agent_system", true);
if (!agentSystemEnabled)
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
var items = await _db.AgentTransactions
.Where(t => t.AgentId == id)
.OrderByDescending(t => t.CreatedAt)
.ToListAsync();
return Ok(ApiResponse<List<AgentTransaction>>.Ok(items));
}
private async Task<IActionResult> AdjustBalance(int id, decimal amount, string type, string? remark)
{
if (!User.TryGetUserId(out var adminId))
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
await using var tx = await _db.Database.BeginTransactionAsync();
var agent = await _db.Agents
.FromSqlRaw("SELECT * FROM \"Agents\" WHERE \"Id\" = {0} FOR UPDATE", id)
.FirstOrDefaultAsync();
if (agent == null)
return NotFound(ApiResponse.Fail(404, "not_found"));
var balanceBefore = agent.Balance;
var balanceAfter = balanceBefore + amount;
if (balanceAfter < -agent.CreditLimit)
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
agent.Balance = balanceAfter;
agent.UpdatedAt = DateTime.UtcNow;
_db.AgentTransactions.Add(new AgentTransaction
{
AgentId = agent.Id,
Type = type,
Amount = amount,
BalanceBefore = balanceBefore,
BalanceAfter = balanceAfter,
Remark = remark,
CreatedBy = adminId,
CreatedAt = DateTime.UtcNow
});
await _db.SaveChangesAsync();
await tx.CommitAsync();
return Ok(ApiResponse.Ok());
}
}

View File

@@ -0,0 +1,151 @@
using License.Api.Data;
using License.Api.DTOs;
using License.Api.Security;
using License.Api.Services;
using License.Api.Utils;
using License.Api.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace License.Api.Controllers;
[ApiController]
[Route("api/admin")]
public class AdminAuthController : ControllerBase
{
private readonly AppDbContext _db;
private readonly JwtTokenService _jwt;
public AdminAuthController(AppDbContext db, JwtTokenService jwt)
{
_db = db;
_jwt = jwt;
}
[HttpPost("login")]
public async Task<IActionResult> Login([FromBody] AdminLoginRequest request)
{
var admin = await _db.Admins.FirstOrDefaultAsync(a => a.Username == request.Username);
if (admin == null || !PasswordHasher.Verify(request.Password, admin.PasswordHash))
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
if (admin.Status != "active")
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
admin.LastLoginAt = DateTime.UtcNow;
admin.LastLoginIp = HttpContext.Connection.RemoteIpAddress?.ToString();
await _db.SaveChangesAsync();
await LogAccessAsync($"admin:{admin.Username}", "admin_login");
var token = _jwt.CreateAdminToken(admin);
var permissions = ResolvePermissions(admin);
var data = new
{
token,
user = new
{
id = admin.Id,
username = admin.Username,
role = admin.Role,
permissions
}
};
return Ok(ApiResponse<object>.Ok(data));
}
[Authorize(Policy = "Admin")]
[HttpPost("logout")]
public IActionResult Logout()
{
var username = User.FindFirst("username")?.Value ?? "admin";
_ = LogAccessAsync($"admin:{username}", "admin_logout");
return Ok(ApiResponse.Ok());
}
[Authorize(Policy = "Admin")]
[HttpGet("profile")]
public async Task<IActionResult> Profile()
{
if (!User.TryGetUserId(out var adminId))
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
var admin = await _db.Admins.FindAsync(adminId);
if (admin == null)
return NotFound(ApiResponse.Fail(404, "not_found"));
return Ok(ApiResponse<object>.Ok(new
{
id = admin.Id,
username = admin.Username,
role = admin.Role,
email = admin.Email,
permissions = ResolvePermissions(admin)
}));
}
[Authorize(Policy = "Admin")]
[HttpPut("profile")]
public async Task<IActionResult> UpdateProfile([FromBody] AdminUpdateRequest request)
{
if (!User.TryGetUserId(out var adminId))
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
var admin = await _db.Admins.FindAsync(adminId);
if (admin == null)
return NotFound(ApiResponse.Fail(404, "not_found"));
if (!string.IsNullOrWhiteSpace(request.Email))
admin.Email = request.Email;
admin.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync();
return Ok(ApiResponse.Ok());
}
[Authorize(Policy = "Admin")]
[HttpPost("change-password")]
public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordRequest request)
{
if (!User.TryGetUserId(out var adminId))
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
var admin = await _db.Admins.FindAsync(adminId);
if (admin == null)
return NotFound(ApiResponse.Fail(404, "not_found"));
if (!PasswordHasher.Verify(request.OldPassword, admin.PasswordHash))
return BadRequest(ApiResponse.Fail(400, "bad_request"));
admin.PasswordHash = PasswordHasher.Hash(request.NewPassword);
admin.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync();
return Ok(ApiResponse.Ok());
}
private async Task LogAccessAsync(string? deviceId, string action)
{
_db.AccessLogs.Add(new AccessLog
{
DeviceId = deviceId,
Action = action,
IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString(),
UserAgent = HttpContext.Request.Headers.UserAgent.ToString(),
ResponseCode = 200,
CreatedAt = DateTime.UtcNow
});
await _db.SaveChangesAsync();
}
private static List<string> ResolvePermissions(Admin admin)
{
if (string.Equals(admin.Role, "super_admin", StringComparison.OrdinalIgnoreCase))
return new List<string> { "*" };
var (hasAll, allowed) = AdminAccessService.ParsePermissions(admin.Permissions);
if (hasAll)
return new List<string> { "*" };
return allowed.OrderBy(p => p).ToList();
}
}

View File

@@ -0,0 +1,594 @@
using System.IO;
using System.Text;
using System.Text.Json;
using ClosedXML.Excel;
using License.Api.Data;
using License.Api.DTOs;
using License.Api.Models;
using License.Api.Services;
using License.Api.Utils;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace License.Api.Controllers;
[ApiController]
[Authorize(Policy = "Admin")]
[Route("api/admin/cards")]
public class AdminCardsController : ControllerBase
{
private readonly AppDbContext _db;
private readonly CardService _cards;
private readonly IdempotencyService _idempotency;
private readonly ConfigService _config;
private readonly AdminAccessService _adminAccess;
public AdminCardsController(AppDbContext db, CardService cards, IdempotencyService idempotency, ConfigService config, AdminAccessService adminAccess)
{
_db = db;
_cards = cards;
_idempotency = idempotency;
_config = config;
_adminAccess = adminAccess;
}
[HttpPost("generate")]
public async Task<IActionResult> Generate([FromBody] CardGenerateRequest request)
{
var scope = await _adminAccess.GetScopeAsync(User);
if (scope == null)
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
if (request.Quantity <= 0 || request.Quantity > 10000)
return BadRequest(ApiResponse.Fail(400, "bad_request"));
var project = await _db.Projects.FirstOrDefaultAsync(p => p.ProjectId == request.ProjectId);
if (project == null)
return NotFound(ApiResponse.Fail(404, "not_found"));
if (!scope.CanAccessProject(project.ProjectId))
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
if (!project.IsEnabled)
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(1011, "project_disabled"));
var requestHash = IdempotencyService.ComputeRequestHash(JsonSerializer.Serialize(request));
var idempotencyKey = Request.Headers["X-Idempotency-Key"].ToString();
if (!string.IsNullOrWhiteSpace(idempotencyKey))
{
var existing = await _idempotency.GetAsync(idempotencyKey);
if (existing != null)
{
if (!string.Equals(existing.RequestHash, requestHash, StringComparison.OrdinalIgnoreCase))
return Conflict(ApiResponse.Fail(400, "bad_request"));
var cached = JsonSerializer.Deserialize<ApiResponse<CardGenerateResponse>>(existing.ResponseBody ?? "{}")
?? ApiResponse<CardGenerateResponse>.Fail(500, "internal_error");
return StatusCode(existing.ResponseCode ?? 200, cached);
}
}
if (!User.TryGetUserId(out var adminId))
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
var data = await _cards.GenerateAsync(request, adminId);
var response = ApiResponse<CardGenerateResponse>.Ok(data);
if (!string.IsNullOrWhiteSpace(idempotencyKey))
{
var body = JsonSerializer.Serialize(response);
await _idempotency.StoreAsync(idempotencyKey, Request.Path, requestHash, 200, body);
}
return Ok(response);
}
[HttpGet]
public async Task<IActionResult> List([FromQuery] string? projectId, [FromQuery] string? status, [FromQuery] int page = 1, [FromQuery] int pageSize = 20)
{
page = Math.Max(1, page);
pageSize = Math.Clamp(pageSize, 1, 100);
var scope = await _adminAccess.GetScopeAsync(User);
if (scope == null)
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
var query = _db.CardKeys.Where(c => c.DeletedAt == null).AsQueryable();
if (!string.IsNullOrWhiteSpace(projectId))
{
if (!scope.CanAccessProject(projectId))
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
query = query.Where(c => c.ProjectId == projectId);
}
else if (!scope.IsSuperAdmin && !scope.HasAllProjects)
{
if (scope.AllowedProjects.Count == 0)
{
var empty = new PagedResult<CardKey>
{
Items = new List<CardKey>(),
Pagination = new PaginationInfo
{
Page = page,
PageSize = pageSize,
Total = 0,
TotalPages = 0
}
};
return Ok(ApiResponse<PagedResult<CardKey>>.Ok(empty));
}
var allowed = scope.AllowedProjects.ToList();
query = query.Where(c => c.ProjectId != null && allowed.Contains(c.ProjectId));
}
if (!string.IsNullOrWhiteSpace(status))
query = query.Where(c => c.Status == status);
var total = await query.CountAsync();
var items = await query.OrderByDescending(c => c.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
var result = new PagedResult<CardKey>
{
Items = items,
Pagination = new PaginationInfo
{
Page = page,
PageSize = pageSize,
Total = total,
TotalPages = (int)Math.Ceiling(total / (double)pageSize)
}
};
return Ok(ApiResponse<PagedResult<CardKey>>.Ok(result));
}
[HttpGet("{id:int}")]
public async Task<IActionResult> Get(int id)
{
var scope = await _adminAccess.GetScopeAsync(User);
if (scope == null)
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
var card = await _db.CardKeys
.Include(c => c.Devices)
.Include(c => c.Logs)
.AsSplitQuery()
.FirstOrDefaultAsync(c => c.Id == id && c.DeletedAt == null);
if (card == null)
return NotFound(ApiResponse.Fail(404, "not_found"));
if (!scope.CanAccessProject(card.ProjectId))
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
return Ok(ApiResponse<CardKey>.Ok(card));
}
[HttpGet("{id:int}/logs")]
public async Task<IActionResult> Logs(int id)
{
var scope = await _adminAccess.GetScopeAsync(User);
if (scope == null)
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
var card = await _db.CardKeys.FirstOrDefaultAsync(c => c.Id == id && c.DeletedAt == null);
if (card == null)
return NotFound(ApiResponse.Fail(404, "not_found"));
if (!scope.CanAccessProject(card.ProjectId))
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
var logs = await _db.CardKeyLogs
.Where(l => l.CardKeyId == id)
.OrderByDescending(l => l.CreatedAt)
.ToListAsync();
return Ok(ApiResponse<List<CardKeyLog>>.Ok(logs));
}
[HttpPut("{id:int}")]
public async Task<IActionResult> UpdateNote(int id, [FromBody] CardNoteUpdateRequest request)
{
var scope = await _adminAccess.GetScopeAsync(User);
if (scope == null)
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
var card = await _db.CardKeys.FirstOrDefaultAsync(c => c.Id == id && c.DeletedAt == null);
if (card == null)
return NotFound(ApiResponse.Fail(404, "not_found"));
if (!scope.CanAccessProject(card.ProjectId))
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
card.Note = request.Note;
await _db.SaveChangesAsync();
return Ok(ApiResponse.Ok());
}
[HttpPost("{id:int}/ban")]
public async Task<IActionResult> Ban(int id, [FromBody] CardBanRequest request)
{
var scope = await _adminAccess.GetScopeAsync(User);
if (scope == null)
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
var card = await _db.CardKeys.FirstOrDefaultAsync(c => c.Id == id && c.DeletedAt == null);
if (card == null)
return NotFound(ApiResponse.Fail(404, "not_found"));
if (!scope.CanAccessProject(card.ProjectId))
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
if (!User.TryGetUserId(out var adminId))
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
await _cards.BanAsync(card, request.Reason, adminId, "admin");
return Ok(ApiResponse.Ok());
}
[HttpPost("{id:int}/unban")]
public async Task<IActionResult> Unban(int id)
{
var scope = await _adminAccess.GetScopeAsync(User);
if (scope == null)
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
var card = await _db.CardKeys.FirstOrDefaultAsync(c => c.Id == id && c.DeletedAt == null);
if (card == null)
return NotFound(ApiResponse.Fail(404, "not_found"));
if (!scope.CanAccessProject(card.ProjectId))
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
if (!User.TryGetUserId(out var adminId))
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
await _cards.UnbanAsync(card, adminId, "admin");
return Ok(ApiResponse.Ok());
}
[HttpPost("{id:int}/extend")]
public async Task<IActionResult> Extend(int id, [FromBody] CardExtendRequest request)
{
var scope = await _adminAccess.GetScopeAsync(User);
if (scope == null)
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
var renewalEnabled = await _config.GetBoolAsync("feature.card_renewal", true);
if (!renewalEnabled)
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
var card = await _db.CardKeys.FirstOrDefaultAsync(c => c.Id == id && c.DeletedAt == null);
if (card == null)
return NotFound(ApiResponse.Fail(404, "not_found"));
if (!scope.CanAccessProject(card.ProjectId))
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
if (!User.TryGetUserId(out var adminId))
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
await _cards.ExtendAsync(card, request.Days, adminId, "admin");
return Ok(ApiResponse.Ok());
}
[HttpPost("{id:int}/reset-device")]
public async Task<IActionResult> ResetDevice(int id)
{
var scope = await _adminAccess.GetScopeAsync(User);
if (scope == null)
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
var card = await _db.CardKeys.FirstOrDefaultAsync(c => c.Id == id && c.DeletedAt == null);
if (card == null)
return NotFound(ApiResponse.Fail(404, "not_found"));
if (!scope.CanAccessProject(card.ProjectId))
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
if (!User.TryGetUserId(out var adminId))
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
await _cards.ResetDeviceAsync(card, adminId, "admin");
return Ok(ApiResponse.Ok());
}
[HttpDelete("{id:int}")]
public async Task<IActionResult> Delete(int id)
{
var scope = await _adminAccess.GetScopeAsync(User);
if (scope == null)
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
var card = await _db.CardKeys.FirstOrDefaultAsync(c => c.Id == id && c.DeletedAt == null);
if (card == null)
return NotFound(ApiResponse.Fail(404, "not_found"));
if (!scope.CanAccessProject(card.ProjectId))
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
card.DeletedAt = DateTime.UtcNow;
await _db.SaveChangesAsync();
return Ok(ApiResponse.Ok());
}
[HttpPost("ban-batch")]
public async Task<IActionResult> BanBatch([FromBody] CardBatchRequest request)
{
var scope = await _adminAccess.GetScopeAsync(User);
if (scope == null)
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
var cards = await _db.CardKeys.Where(c => request.Ids.Contains(c.Id) && c.DeletedAt == null).ToListAsync();
if (!scope.IsSuperAdmin && !scope.HasAllProjects)
{
if (cards.Any(c => !scope.CanAccessProject(c.ProjectId)))
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
}
if (!User.TryGetUserId(out var adminId))
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
foreach (var card in cards)
await _cards.BanAsync(card, request.Reason, adminId, "admin");
return Ok(ApiResponse.Ok());
}
[HttpPost("unban-batch")]
public async Task<IActionResult> UnbanBatch([FromBody] CardBatchRequest request)
{
var scope = await _adminAccess.GetScopeAsync(User);
if (scope == null)
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
var cards = await _db.CardKeys.Where(c => request.Ids.Contains(c.Id) && c.DeletedAt == null).ToListAsync();
if (!scope.IsSuperAdmin && !scope.HasAllProjects)
{
if (cards.Any(c => !scope.CanAccessProject(c.ProjectId)))
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
}
if (!User.TryGetUserId(out var adminId))
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
foreach (var card in cards)
await _cards.UnbanAsync(card, adminId, "admin");
return Ok(ApiResponse.Ok());
}
[HttpDelete("batch")]
public async Task<IActionResult> DeleteBatch([FromBody] CardBatchRequest request)
{
var scope = await _adminAccess.GetScopeAsync(User);
if (scope == null)
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
var cards = await _db.CardKeys.Where(c => request.Ids.Contains(c.Id) && c.DeletedAt == null).ToListAsync();
if (!scope.IsSuperAdmin && !scope.HasAllProjects)
{
if (cards.Any(c => !scope.CanAccessProject(c.ProjectId)))
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
}
foreach (var card in cards)
card.DeletedAt = DateTime.UtcNow;
await _db.SaveChangesAsync();
return Ok(ApiResponse.Ok());
}
[HttpGet("export")]
public async Task<IActionResult> Export([FromQuery] string? projectId, [FromQuery] string? format)
{
var scope = await _adminAccess.GetScopeAsync(User);
if (scope == null)
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
var query = _db.CardKeys.Where(c => c.DeletedAt == null);
if (!string.IsNullOrWhiteSpace(projectId))
{
if (!scope.CanAccessProject(projectId))
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
query = query.Where(c => c.ProjectId == projectId);
}
else if (!scope.IsSuperAdmin && !scope.HasAllProjects)
{
if (scope.AllowedProjects.Count == 0)
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
var allowed = scope.AllowedProjects.ToList();
query = query.Where(c => c.ProjectId != null && allowed.Contains(c.ProjectId));
}
var items = await query.OrderByDescending(c => c.CreatedAt).ToListAsync();
var ext = string.IsNullOrWhiteSpace(format) ? "csv" : format.ToLowerInvariant();
if (ext is "excel" or "xlsx")
{
using var workbook = new XLWorkbook();
var sheet = workbook.Worksheets.Add("CardKeys");
sheet.Cell(1, 1).Value = "keyCode";
sheet.Cell(1, 2).Value = "cardType";
sheet.Cell(1, 3).Value = "status";
sheet.Cell(1, 4).Value = "expireTime";
sheet.Cell(1, 5).Value = "note";
var row = 2;
foreach (var card in items)
{
sheet.Cell(row, 1).Value = card.KeyCode;
sheet.Cell(row, 2).Value = card.CardType;
sheet.Cell(row, 3).Value = card.Status;
sheet.Cell(row, 4).Value = card.ExpireTime?.ToString("O") ?? string.Empty;
sheet.Cell(row, 5).Value = card.Note ?? string.Empty;
row++;
}
using var ms = new MemoryStream();
workbook.SaveAs(ms);
var bytes = ms.ToArray();
return File(bytes, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "cardkeys.xlsx");
}
else
{
var sb = new StringBuilder();
sb.AppendLine("keyCode,cardType,status,expireTime,note");
foreach (var card in items)
{
sb.AppendLine($"{card.KeyCode},{card.CardType},{card.Status},{card.ExpireTime:O},{card.Note}");
}
var bytes = Encoding.UTF8.GetBytes(sb.ToString());
var contentType = ext == "txt" ? "text/plain" : "text/csv";
var fileName = ext == "txt" ? "cardkeys.txt" : "cardkeys.csv";
return File(bytes, contentType, fileName);
}
}
[HttpPost("import")]
public async Task<IActionResult> Import([FromForm] IFormFile file, [FromForm] string projectId)
{
var scope = await _adminAccess.GetScopeAsync(User);
if (scope == null)
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
var project = await _db.Projects.FirstOrDefaultAsync(p => p.ProjectId == projectId);
if (project == null)
return NotFound(ApiResponse.Fail(404, "not_found"));
if (!scope.CanAccessProject(project.ProjectId))
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
if (!project.IsEnabled)
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(1011, "project_disabled"));
var pricing = await _db.ProjectPricing
.Where(p => p.ProjectId == projectId && p.IsEnabled)
.Select(p => new { p.CardType, p.DurationDays })
.ToListAsync();
var durationByType = pricing
.GroupBy(p => p.CardType.Trim().ToLowerInvariant())
.ToDictionary(g => g.Key, g => g.Select(x => x.DurationDays).Distinct().ToList());
(string cardType, int durationDays) ResolveCardMeta(string keyCode, string? rawCardType)
{
var normalizedType = string.IsNullOrWhiteSpace(rawCardType)
? null
: rawCardType.Trim().ToLowerInvariant();
var decodedDuration = 0;
var decodedType = (string?)null;
if (CardKeyGenerator.TryDecode(keyCode, out var keyType, out var keyDuration))
{
decodedDuration = keyDuration;
decodedType = CardDefaults.ResolveCardType(keyType);
}
if (string.IsNullOrWhiteSpace(normalizedType) && !string.IsNullOrWhiteSpace(decodedType))
normalizedType = decodedType;
if (string.IsNullOrWhiteSpace(normalizedType))
normalizedType = "unknown";
if (decodedDuration > 0)
return (normalizedType, decodedDuration);
if (durationByType.TryGetValue(normalizedType, out var durations) && durations.Count == 1)
return (normalizedType, durations[0]);
return (normalizedType, CardDefaults.ResolveDurationDays(normalizedType));
}
var successes = 0;
var failures = new List<object>();
var extension = Path.GetExtension(file.FileName);
if (string.Equals(extension, ".xlsx", StringComparison.OrdinalIgnoreCase))
{
using var workbook = new XLWorkbook(file.OpenReadStream());
var sheet = workbook.Worksheets.First();
var rowIndex = 1;
foreach (var row in sheet.RowsUsed())
{
var keyCell = row.Cell(1).GetString();
if (rowIndex == 1 && keyCell.Equals("keyCode", StringComparison.OrdinalIgnoreCase))
{
rowIndex++;
continue;
}
if (string.IsNullOrWhiteSpace(keyCell))
{
rowIndex++;
continue;
}
if (await _db.CardKeys.AnyAsync(c => c.KeyCode == keyCell))
{
failures.Add(new { row = rowIndex, keyCode = keyCell, reason = "exists" });
rowIndex++;
continue;
}
var cardType = row.Cell(2).GetString();
var status = row.Cell(3).GetString();
var note = row.Cell(5).GetString();
var meta = ResolveCardMeta(keyCell, cardType);
var card = new CardKey
{
ProjectId = projectId,
KeyCode = keyCell.Trim(),
CardType = meta.cardType,
DurationDays = meta.durationDays,
Status = string.IsNullOrWhiteSpace(status) ? "unused" : status.Trim(),
Note = string.IsNullOrWhiteSpace(note) ? null : note.Trim(),
CreatedAt = DateTime.UtcNow
};
_db.CardKeys.Add(card);
successes++;
rowIndex++;
}
}
else
{
using var reader = new StreamReader(file.OpenReadStream());
var lineNum = 0;
while (!reader.EndOfStream)
{
var line = await reader.ReadLineAsync();
lineNum++;
if (string.IsNullOrWhiteSpace(line))
continue;
if (lineNum == 1 && line.StartsWith("keyCode", StringComparison.OrdinalIgnoreCase))
continue;
var parts = line.Split(',');
if (parts.Length == 0)
continue;
var keyCode = parts[0].Trim();
if (string.IsNullOrWhiteSpace(keyCode))
continue;
if (await _db.CardKeys.AnyAsync(c => c.KeyCode == keyCode))
{
failures.Add(new { row = lineNum, keyCode, reason = "exists" });
continue;
}
var cardType = parts.Length > 1 ? parts[1].Trim() : string.Empty;
var meta = ResolveCardMeta(keyCode, cardType);
var card = new CardKey
{
ProjectId = projectId,
KeyCode = keyCode,
CardType = meta.cardType,
DurationDays = meta.durationDays,
Status = parts.Length > 2 ? parts[2].Trim() : "unused",
Note = parts.Length > 4 ? parts[4].Trim() : null,
CreatedAt = DateTime.UtcNow
};
_db.CardKeys.Add(card);
successes++;
}
}
await _db.SaveChangesAsync();
return Ok(ApiResponse<object>.Ok(new
{
total = successes + failures.Count,
success = successes,
failed = failures.Count,
errors = failures
}));
}
}

View File

@@ -0,0 +1,112 @@
using License.Api.Data;
using License.Api.DTOs;
using License.Api.Models;
using License.Api.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace License.Api.Controllers;
[ApiController]
[Authorize(Policy = "Admin")]
[Route("api/admin/devices")]
public class AdminDevicesController : ControllerBase
{
private readonly AppDbContext _db;
private readonly AdminAccessService _adminAccess;
public AdminDevicesController(AppDbContext db, AdminAccessService adminAccess)
{
_db = db;
_adminAccess = adminAccess;
}
[HttpGet]
public async Task<IActionResult> List([FromQuery] string? projectId, [FromQuery] bool? isActive)
{
var scope = await _adminAccess.GetScopeAsync(User);
if (scope == null)
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
var query = _db.Devices.Where(d => d.DeletedAt == null).AsQueryable();
if (isActive.HasValue)
query = query.Where(d => d.IsActive == isActive.Value);
if (!string.IsNullOrWhiteSpace(projectId))
{
if (!scope.CanAccessProject(projectId))
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
var cardIds = await _db.CardKeys.Where(c => c.ProjectId == projectId).Select(c => c.Id).ToListAsync();
query = query.Where(d => cardIds.Contains(d.CardKeyId));
}
else if (!scope.IsSuperAdmin && !scope.HasAllProjects)
{
if (scope.AllowedProjects.Count == 0)
return Ok(ApiResponse<List<Device>>.Ok(new List<Device>()));
var allowed = scope.AllowedProjects.ToList();
var cardIds = await _db.CardKeys
.Where(c => c.ProjectId != null && allowed.Contains(c.ProjectId))
.Select(c => c.Id)
.ToListAsync();
query = query.Where(d => cardIds.Contains(d.CardKeyId));
}
var items = await query.OrderByDescending(d => d.LastHeartbeat).Take(200).ToListAsync();
return Ok(ApiResponse<List<Device>>.Ok(items));
}
[HttpDelete("{id:int}")]
public async Task<IActionResult> Unbind(int id)
{
var scope = await _adminAccess.GetScopeAsync(User);
if (scope == null)
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
var device = await _db.Devices.FindAsync(id);
if (device == null)
return NotFound(ApiResponse.Fail(404, "not_found"));
if (!scope.IsSuperAdmin && !scope.HasAllProjects)
{
var projectId = await _db.CardKeys.Where(c => c.Id == device.CardKeyId)
.Select(c => c.ProjectId)
.FirstOrDefaultAsync();
if (!scope.CanAccessProject(projectId))
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
}
device.DeletedAt = DateTime.UtcNow;
device.IsActive = false;
await _db.SaveChangesAsync();
return Ok(ApiResponse.Ok());
}
[HttpPost("{id:int}/kick")]
public async Task<IActionResult> Kick(int id)
{
var scope = await _adminAccess.GetScopeAsync(User);
if (scope == null)
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
var device = await _db.Devices.FindAsync(id);
if (device == null)
return NotFound(ApiResponse.Fail(404, "not_found"));
if (!scope.IsSuperAdmin && !scope.HasAllProjects)
{
var projectId = await _db.CardKeys.Where(c => c.Id == device.CardKeyId)
.Select(c => c.ProjectId)
.FirstOrDefaultAsync();
if (!scope.CanAccessProject(projectId))
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
}
device.IsActive = false;
await _db.SaveChangesAsync();
return Ok(ApiResponse.Ok());
}
}

View File

@@ -0,0 +1,96 @@
using License.Api.Data;
using License.Api.DTOs;
using License.Api.Models;
using License.Api.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace License.Api.Controllers;
[ApiController]
[Authorize(Policy = "Admin")]
[Route("api/admin/logs")]
public class AdminLogsController : ControllerBase
{
private readonly AppDbContext _db;
private readonly AdminAccessService _adminAccess;
public AdminLogsController(AppDbContext db, AdminAccessService adminAccess)
{
_db = db;
_adminAccess = adminAccess;
}
[HttpGet]
public async Task<IActionResult> List([FromQuery] string? action, [FromQuery] int page = 1, [FromQuery] int pageSize = 20)
{
page = Math.Max(1, page);
pageSize = Math.Clamp(pageSize, 1, 100);
var scope = await _adminAccess.GetScopeAsync(User);
if (scope == null)
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
var query = _db.AccessLogs.AsQueryable();
if (!string.IsNullOrWhiteSpace(action))
query = query.Where(l => l.Action == action);
if (!scope.IsSuperAdmin && !scope.HasAllProjects)
{
if (scope.AllowedProjects.Count == 0)
{
var empty = new PagedResult<AccessLog>
{
Items = new List<AccessLog>(),
Pagination = new PaginationInfo
{
Page = page,
PageSize = pageSize,
Total = 0,
TotalPages = 0
}
};
return Ok(ApiResponse<PagedResult<AccessLog>>.Ok(empty));
}
var allowed = scope.AllowedProjects.ToList();
query = query.Where(l => l.ProjectId != null && allowed.Contains(l.ProjectId));
}
var total = await query.CountAsync();
var items = await query.OrderByDescending(l => l.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
var result = new PagedResult<AccessLog>
{
Items = items,
Pagination = new PaginationInfo
{
Page = page,
PageSize = pageSize,
Total = total,
TotalPages = (int)Math.Ceiling(total / (double)pageSize)
}
};
return Ok(ApiResponse<PagedResult<AccessLog>>.Ok(result));
}
[HttpGet("{id:int}")]
public async Task<IActionResult> Get(int id)
{
var scope = await _adminAccess.GetScopeAsync(User);
if (scope == null)
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
var log = await _db.AccessLogs.FindAsync(id);
if (log == null)
return NotFound(ApiResponse.Fail(404, "not_found"));
if (!scope.IsSuperAdmin && !scope.HasAllProjects && !scope.CanAccessProject(log.ProjectId))
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
return Ok(ApiResponse<AccessLog>.Ok(log));
}
}

View File

@@ -0,0 +1,469 @@
using License.Api.Data;
using License.Api.DTOs;
using License.Api.Models;
using License.Api.Services;
using License.Api.Utils;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace License.Api.Controllers;
[ApiController]
[Authorize(Policy = "Admin")]
[Route("api/admin/projects")]
public class AdminProjectsController : ControllerBase
{
private readonly AppDbContext _db;
private readonly SoftwareService _software;
private readonly AdminAccessService _adminAccess;
public AdminProjectsController(AppDbContext db, SoftwareService software, AdminAccessService adminAccess)
{
_db = db;
_software = software;
_adminAccess = adminAccess;
}
[HttpPost]
public async Task<IActionResult> Create([FromBody] ProjectCreateRequest request)
{
var scope = await _adminAccess.GetScopeAsync(User);
if (scope == null)
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
var project = new Project
{
ProjectId = RandomIdGenerator.GenerateProjectId(),
ProjectKey = RandomIdGenerator.GenerateKey(32),
ProjectSecret = RandomIdGenerator.GenerateSecret(48),
Name = request.Name,
Description = request.Description,
MaxDevices = request.MaxDevices,
AutoUpdate = request.AutoUpdate,
IconUrl = request.IconUrl,
CreatedBy = scope.Admin.Id,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow
};
_db.Projects.Add(project);
await _db.SaveChangesAsync();
if (!scope.IsSuperAdmin && !scope.HasAllProjects)
{
scope.AddProject(project.ProjectId);
scope.Admin.Permissions = scope.SerializePermissions();
scope.Admin.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync();
}
var data = new
{
id = project.Id,
projectId = project.ProjectId,
projectKey = project.ProjectKey,
projectSecret = project.ProjectSecret
};
return Ok(ApiResponse<object>.Ok(data));
}
[HttpGet]
public async Task<IActionResult> List([FromQuery] int page = 1, [FromQuery] int pageSize = 20)
{
page = Math.Max(1, page);
pageSize = Math.Clamp(pageSize, 1, 100);
var scope = await _adminAccess.GetScopeAsync(User);
if (scope == null)
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
var query = _db.Projects.AsQueryable();
if (!scope.IsSuperAdmin && !scope.HasAllProjects)
{
if (scope.AllowedProjects.Count == 0)
{
var empty = new PagedResult<ProjectListItem>
{
Items = new List<ProjectListItem>(),
Pagination = new PaginationInfo
{
Page = page,
PageSize = pageSize,
Total = 0,
TotalPages = 0
}
};
return Ok(ApiResponse<PagedResult<ProjectListItem>>.Ok(empty));
}
var allowed = scope.AllowedProjects.ToList();
query = query.Where(p => allowed.Contains(p.ProjectId));
}
var total = await query.CountAsync();
var items = await query.OrderByDescending(p => p.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(p => new ProjectListItem
{
Id = p.Id,
ProjectId = p.ProjectId,
Name = p.Name,
Description = p.Description,
IconUrl = p.IconUrl,
MaxDevices = p.MaxDevices,
AutoUpdate = p.AutoUpdate,
IsEnabled = p.IsEnabled,
CreatedAt = p.CreatedAt
})
.ToListAsync();
var result = new PagedResult<ProjectListItem>
{
Items = items,
Pagination = new PaginationInfo
{
Page = page,
PageSize = pageSize,
Total = total,
TotalPages = (int)Math.Ceiling(total / (double)pageSize)
}
};
return Ok(ApiResponse<PagedResult<ProjectListItem>>.Ok(result));
}
[HttpGet("{id:int}")]
public async Task<IActionResult> Get(int id)
{
var scope = await _adminAccess.GetScopeAsync(User);
if (scope == null)
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
var project = await _db.Projects.FindAsync(id);
if (project == null)
return NotFound(ApiResponse.Fail(404, "not_found"));
if (!scope.CanAccessProject(project.ProjectId))
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
var data = new ProjectDetailResponse
{
Id = project.Id,
ProjectId = project.ProjectId,
ProjectKey = project.ProjectKey,
Name = project.Name,
Description = project.Description,
IconUrl = project.IconUrl,
MaxDevices = project.MaxDevices,
AutoUpdate = project.AutoUpdate,
IsEnabled = project.IsEnabled,
CreatedAt = project.CreatedAt,
UpdatedAt = project.UpdatedAt
};
return Ok(ApiResponse<ProjectDetailResponse>.Ok(data));
}
[HttpPut("{id:int}")]
public async Task<IActionResult> Update(int id, [FromBody] ProjectUpdateRequest request)
{
var scope = await _adminAccess.GetScopeAsync(User);
if (scope == null)
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
var project = await _db.Projects.FindAsync(id);
if (project == null)
return NotFound(ApiResponse.Fail(404, "not_found"));
if (!scope.CanAccessProject(project.ProjectId))
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
if (!string.IsNullOrWhiteSpace(request.Name))
project.Name = request.Name;
if (!string.IsNullOrWhiteSpace(request.Description))
project.Description = request.Description;
if (request.MaxDevices.HasValue)
project.MaxDevices = request.MaxDevices.Value;
if (request.AutoUpdate.HasValue)
project.AutoUpdate = request.AutoUpdate.Value;
if (request.IsEnabled.HasValue)
project.IsEnabled = request.IsEnabled.Value;
if (!string.IsNullOrWhiteSpace(request.IconUrl))
project.IconUrl = request.IconUrl;
project.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync();
return Ok(ApiResponse.Ok());
}
[HttpDelete("{id:int}")]
public async Task<IActionResult> Delete(int id)
{
var scope = await _adminAccess.GetScopeAsync(User);
if (scope == null)
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
var project = await _db.Projects.FindAsync(id);
if (project == null)
return NotFound(ApiResponse.Fail(404, "not_found"));
if (!scope.CanAccessProject(project.ProjectId))
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
project.IsEnabled = false;
project.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync();
return Ok(ApiResponse.Ok());
}
[HttpGet("{id:int}/stats")]
public async Task<IActionResult> Stats(int id)
{
var scope = await _adminAccess.GetScopeAsync(User);
if (scope == null)
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
var project = await _db.Projects.FindAsync(id);
if (project == null)
return NotFound(ApiResponse.Fail(404, "not_found"));
if (!scope.CanAccessProject(project.ProjectId))
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
var stats = await _db.Statistics
.Where(s => s.ProjectId == project.ProjectId)
.OrderByDescending(s => s.Date)
.Take(30)
.ToListAsync();
return Ok(ApiResponse<object>.Ok(stats));
}
[HttpGet("{id:int}/docs")]
public async Task<IActionResult> GetDocs(int id)
{
var scope = await _adminAccess.GetScopeAsync(User);
if (scope == null)
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
var project = await _db.Projects.FindAsync(id);
if (project == null)
return NotFound(ApiResponse.Fail(404, "not_found"));
if (!scope.CanAccessProject(project.ProjectId))
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
return Ok(ApiResponse<object>.Ok(new { content = project.DocsContent ?? string.Empty }));
}
[HttpPut("{id:int}/docs")]
public async Task<IActionResult> UpdateDocs(int id, [FromBody] ProjectDocUpdateRequest request)
{
var scope = await _adminAccess.GetScopeAsync(User);
if (scope == null)
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
var project = await _db.Projects.FindAsync(id);
if (project == null)
return NotFound(ApiResponse.Fail(404, "not_found"));
if (!scope.CanAccessProject(project.ProjectId))
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
project.DocsContent = request.Content ?? string.Empty;
project.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync();
return Ok(ApiResponse.Ok());
}
[HttpGet("{id:int}/pricing")]
public async Task<IActionResult> GetPricing(int id)
{
var scope = await _adminAccess.GetScopeAsync(User);
if (scope == null)
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
var project = await _db.Projects.FindAsync(id);
if (project == null)
return NotFound(ApiResponse.Fail(404, "not_found"));
if (!scope.CanAccessProject(project.ProjectId))
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
var items = await _db.ProjectPricing.Where(p => p.ProjectId == project.ProjectId).ToListAsync();
return Ok(ApiResponse<List<ProjectPricing>>.Ok(items));
}
[HttpPost("{id:int}/pricing")]
public async Task<IActionResult> CreatePricing(int id, [FromBody] ProjectPricingCreateRequest request)
{
var scope = await _adminAccess.GetScopeAsync(User);
if (scope == null)
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
var project = await _db.Projects.FindAsync(id);
if (project == null)
return NotFound(ApiResponse.Fail(404, "not_found"));
if (!scope.CanAccessProject(project.ProjectId))
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
var pricing = new ProjectPricing
{
ProjectId = project.ProjectId,
CardType = request.CardType,
DurationDays = request.DurationDays,
OriginalPrice = request.OriginalPrice,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow
};
_db.ProjectPricing.Add(pricing);
await _db.SaveChangesAsync();
return Ok(ApiResponse<ProjectPricing>.Ok(pricing));
}
[HttpPut("{id:int}/pricing/{priceId:int}")]
public async Task<IActionResult> UpdatePricing(int id, int priceId, [FromBody] ProjectPricingUpdateRequest request)
{
var scope = await _adminAccess.GetScopeAsync(User);
if (scope == null)
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
var pricing = await _db.ProjectPricing.FindAsync(priceId);
if (pricing == null)
return NotFound(ApiResponse.Fail(404, "not_found"));
if (!scope.CanAccessProject(pricing.ProjectId))
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
if (request.OriginalPrice.HasValue)
pricing.OriginalPrice = request.OriginalPrice.Value;
if (request.IsEnabled.HasValue)
pricing.IsEnabled = request.IsEnabled.Value;
pricing.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync();
return Ok(ApiResponse.Ok());
}
[HttpDelete("{id:int}/pricing/{priceId:int}")]
public async Task<IActionResult> DeletePricing(int id, int priceId)
{
var scope = await _adminAccess.GetScopeAsync(User);
if (scope == null)
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
var pricing = await _db.ProjectPricing.FindAsync(priceId);
if (pricing == null)
return NotFound(ApiResponse.Fail(404, "not_found"));
if (!scope.CanAccessProject(pricing.ProjectId))
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
_db.ProjectPricing.Remove(pricing);
await _db.SaveChangesAsync();
return Ok(ApiResponse.Ok());
}
[HttpGet("{id:int}/versions")]
public async Task<IActionResult> Versions(int id)
{
var scope = await _adminAccess.GetScopeAsync(User);
if (scope == null)
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
var project = await _db.Projects.FindAsync(id);
if (project == null)
return NotFound(ApiResponse.Fail(404, "not_found"));
if (!scope.CanAccessProject(project.ProjectId))
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
var items = await _db.SoftwareVersions
.Where(v => v.ProjectId == project.ProjectId)
.OrderByDescending(v => v.PublishedAt)
.Select(v => new SoftwareVersionListItem
{
Id = v.Id,
Version = v.Version,
FileSize = v.FileSize,
FileHash = v.FileHash,
IsStable = v.IsStable,
IsForceUpdate = v.IsForceUpdate,
Changelog = v.Changelog,
PublishedAt = v.PublishedAt
})
.ToListAsync();
return Ok(ApiResponse<List<SoftwareVersionListItem>>.Ok(items));
}
[HttpPost("{id:int}/versions")]
[RequestSizeLimit(1024L * 1024L * 500L)]
public async Task<IActionResult> UploadVersion(int id, [FromForm] string version, [FromForm] IFormFile file, [FromForm] string? changelog, [FromForm] bool isForceUpdate = false, [FromForm] bool isStable = true)
{
var scope = await _adminAccess.GetScopeAsync(User);
if (scope == null)
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
var project = await _db.Projects.FindAsync(id);
if (project == null)
return NotFound(ApiResponse.Fail(404, "not_found"));
if (!scope.CanAccessProject(project.ProjectId))
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
if (!User.TryGetUserId(out var adminId))
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
var entity = await _software.CreateVersionAsync(project.ProjectId, version, file, changelog, isForceUpdate, isStable, adminId);
var data = new
{
versionId = entity.Id,
version = entity.Version,
fileUrl = entity.FileUrl,
fileHash = entity.FileHash,
encryptionKey = entity.EncryptionKey
};
return Ok(ApiResponse<object>.Ok(data));
}
[HttpPut("{id:int}/versions/{versionId:int}")]
public async Task<IActionResult> UpdateVersion(int id, int versionId, [FromBody] SoftwareVersionUpdateRequest update)
{
var scope = await _adminAccess.GetScopeAsync(User);
if (scope == null)
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
var version = await _db.SoftwareVersions.FindAsync(versionId);
if (version == null)
return NotFound(ApiResponse.Fail(404, "not_found"));
if (!scope.CanAccessProject(version.ProjectId))
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
version.IsForceUpdate = update.IsForceUpdate;
version.IsStable = update.IsStable;
version.Changelog = update.Changelog;
await _db.SaveChangesAsync();
return Ok(ApiResponse.Ok());
}
[HttpDelete("{id:int}/versions/{versionId:int}")]
public async Task<IActionResult> DeleteVersion(int id, int versionId)
{
var scope = await _adminAccess.GetScopeAsync(User);
if (scope == null)
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
var version = await _db.SoftwareVersions.FindAsync(versionId);
if (version == null)
return NotFound(ApiResponse.Fail(404, "not_found"));
if (!scope.CanAccessProject(version.ProjectId))
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
_db.SoftwareVersions.Remove(version);
await _db.SaveChangesAsync();
return Ok(ApiResponse.Ok());
}
}

View File

@@ -0,0 +1,172 @@
using License.Api.Data;
using License.Api.DTOs;
using License.Api.Models;
using License.Api.Services;
using License.Api.Utils;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace License.Api.Controllers;
[ApiController]
[Authorize(Policy = "SuperAdmin")]
[Route("api/admin")]
public class AdminSettingsController : ControllerBase
{
private readonly AppDbContext _db;
private readonly ConfigService _configService;
public AdminSettingsController(AppDbContext db, ConfigService configService)
{
_db = db;
_configService = configService;
}
[HttpGet("settings")]
public async Task<IActionResult> GetSettings()
{
var configs = await _db.SystemConfigs.OrderBy(c => c.Category).ToListAsync();
return Ok(ApiResponse<List<SystemConfig>>.Ok(configs));
}
[HttpPut("settings")]
public async Task<IActionResult> UpdateSettings([FromBody] List<SystemConfig> configs)
{
foreach (var config in configs)
{
var existing = await _db.SystemConfigs.FirstOrDefaultAsync(c => c.ConfigKey == config.ConfigKey);
if (existing == null)
{
config.UpdatedAt = DateTime.UtcNow;
_db.SystemConfigs.Add(config);
}
else
{
existing.ConfigValue = config.ConfigValue;
existing.ValueType = config.ValueType;
existing.Category = config.Category;
existing.DisplayName = config.DisplayName;
existing.Description = config.Description;
existing.Options = config.Options;
existing.IsPublic = config.IsPublic;
existing.UpdatedAt = DateTime.UtcNow;
}
_configService.Invalidate(config.ConfigKey);
}
await _db.SaveChangesAsync();
return Ok(ApiResponse.Ok());
}
[HttpGet("admins")]
public async Task<IActionResult> Admins()
{
var items = await _db.Admins
.OrderBy(a => a.Id)
.Select(a => new
{
id = a.Id,
username = a.Username,
email = a.Email,
role = a.Role,
permissions = a.Permissions,
status = a.Status,
lastLoginAt = a.LastLoginAt,
createdAt = a.CreatedAt
})
.ToListAsync();
var data = items.Select(a =>
{
var permissions = ResolvePermissions(a.role, a.permissions);
return new
{
a.id,
a.username,
a.email,
a.role,
permissions,
a.status,
a.lastLoginAt,
a.createdAt
};
}).ToList();
return Ok(ApiResponse<object>.Ok(data));
}
[HttpPost("admins")]
public async Task<IActionResult> CreateAdmin([FromBody] AdminCreateRequest request)
{
var admin = new Admin
{
Username = request.Username,
PasswordHash = PasswordHasher.Hash(request.Password),
Email = request.Email,
Role = request.Role,
Permissions = request.Permissions,
Status = "active",
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow
};
_db.Admins.Add(admin);
await _db.SaveChangesAsync();
return Ok(ApiResponse<object>.Ok(new
{
id = admin.Id,
username = admin.Username,
email = admin.Email,
role = admin.Role,
permissions = ResolvePermissions(admin.Role, admin.Permissions),
status = admin.Status
}));
}
[HttpPut("admins/{id:int}")]
public async Task<IActionResult> UpdateAdmin(int id, [FromBody] AdminUpdateRequest request)
{
var admin = await _db.Admins.FindAsync(id);
if (admin == null)
return NotFound(ApiResponse.Fail(404, "not_found"));
if (!string.IsNullOrWhiteSpace(request.Email))
admin.Email = request.Email;
if (!string.IsNullOrWhiteSpace(request.Role))
admin.Role = request.Role;
if (!string.IsNullOrWhiteSpace(request.Permissions))
admin.Permissions = request.Permissions;
if (!string.IsNullOrWhiteSpace(request.Status))
admin.Status = request.Status;
admin.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync();
return Ok(ApiResponse.Ok());
}
[HttpDelete("admins/{id:int}")]
public async Task<IActionResult> DeleteAdmin(int id)
{
var admin = await _db.Admins.FindAsync(id);
if (admin == null)
return NotFound(ApiResponse.Fail(404, "not_found"));
_db.Admins.Remove(admin);
await _db.SaveChangesAsync();
return Ok(ApiResponse.Ok());
}
private static List<string> ResolvePermissions(string role, string? raw)
{
if (string.Equals(role, "super_admin", StringComparison.OrdinalIgnoreCase))
return new List<string> { "*" };
var (hasAll, allowed) = AdminAccessService.ParsePermissions(raw);
if (hasAll)
return new List<string> { "*" };
return allowed.OrderBy(p => p).ToList();
}
}

View File

@@ -0,0 +1,84 @@
using License.Api.DTOs;
using License.Api.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace License.Api.Controllers;
[ApiController]
[Authorize(Policy = "Admin")]
[Route("api/admin/stats")]
public class AdminStatsController : ControllerBase
{
private readonly StatsService _stats;
private readonly AdminAccessService _adminAccess;
public AdminStatsController(StatsService stats, AdminAccessService adminAccess)
{
_stats = stats;
_adminAccess = adminAccess;
}
[HttpGet("dashboard")]
public async Task<IActionResult> Dashboard()
{
var scope = await _adminAccess.GetScopeAsync(User);
if (scope == null)
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
var filter = !scope.IsSuperAdmin && !scope.HasAllProjects ? scope.AllowedProjects.ToList() : null;
var data = await _stats.GetDashboardAsync(filter);
return Ok(ApiResponse<object>.Ok(data));
}
[HttpGet("projects")]
public async Task<IActionResult> Projects()
{
var scope = await _adminAccess.GetScopeAsync(User);
if (scope == null)
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
var filter = !scope.IsSuperAdmin && !scope.HasAllProjects ? scope.AllowedProjects.ToList() : null;
var items = await _stats.GetProjectStatsAsync(filter);
return Ok(ApiResponse<List<ProjectStatsItem>>.Ok(items));
}
[HttpGet("agents")]
public async Task<IActionResult> Agents()
{
var scope = await _adminAccess.GetScopeAsync(User);
if (scope == null)
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
if (!scope.IsSuperAdmin)
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
var items = await _stats.GetAgentStatsAsync();
return Ok(ApiResponse<List<AgentStatsItem>>.Ok(items));
}
[HttpGet("logs")]
public async Task<IActionResult> Logs([FromQuery] int days = 7)
{
var scope = await _adminAccess.GetScopeAsync(User);
if (scope == null)
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
var filter = !scope.IsSuperAdmin && !scope.HasAllProjects ? scope.AllowedProjects.ToList() : null;
var items = await _stats.GetLogStatsAsync(days, filter);
return Ok(ApiResponse<List<LogStatsItem>>.Ok(items));
}
[HttpGet("export")]
public async Task<IActionResult> Export([FromQuery] int days = 30)
{
var scope = await _adminAccess.GetScopeAsync(User);
if (scope == null)
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
var filter = !scope.IsSuperAdmin && !scope.HasAllProjects ? scope.AllowedProjects.ToList() : null;
var csv = await _stats.ExportStatsCsvAsync(days, filter);
var bytes = System.Text.Encoding.UTF8.GetBytes(csv);
return File(bytes, "text/csv", "stats.csv");
}
}

View File

@@ -0,0 +1,183 @@
using System.Text.Json;
using License.Api.Data;
using License.Api.DTOs;
using License.Api.Models;
using License.Api.Security;
using License.Api.Services;
using License.Api.Utils;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace License.Api.Controllers;
[ApiController]
[Route("api/agent")]
public class AgentAuthController : ControllerBase
{
private readonly AppDbContext _db;
private readonly JwtTokenService _jwt;
private readonly ConfigService _config;
public AgentAuthController(AppDbContext db, JwtTokenService jwt, ConfigService config)
{
_db = db;
_jwt = jwt;
_config = config;
}
[HttpPost("login")]
public async Task<IActionResult> Login([FromBody] AgentLoginRequest request)
{
var agentSystemEnabled = await _config.GetBoolAsync("feature.agent_system", true);
if (!agentSystemEnabled)
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
var agent = await _db.Agents.FirstOrDefaultAsync(a => a.AgentCode == request.AgentCode);
if (agent == null || !PasswordHasher.Verify(request.Password, agent.PasswordHash))
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
if (agent.Status != "active")
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
agent.LastLoginAt = DateTime.UtcNow;
await _db.SaveChangesAsync();
await LogAccessAsync($"agent:{agent.AgentCode}", "agent_login");
var allowed = string.IsNullOrWhiteSpace(agent.AllowedProjects)
? new List<string>()
: JsonSerializer.Deserialize<List<string>>(agent.AllowedProjects) ?? new List<string>();
var projects = await _db.Projects
.Where(p => allowed.Count == 0 || allowed.Contains(p.ProjectId))
.Select(p => new { projectId = p.ProjectId, projectName = p.Name })
.ToListAsync();
var token = _jwt.CreateAgentToken(agent);
var data = new
{
token,
agent = new
{
id = agent.Id,
agentCode = agent.AgentCode,
companyName = agent.CompanyName,
balance = agent.Balance,
discount = agent.Discount
},
allowedProjects = projects
};
return Ok(ApiResponse<object>.Ok(data));
}
[Authorize(Policy = "Agent")]
[HttpPost("logout")]
public IActionResult Logout()
{
var agentCode = User.FindFirst("agentCode")?.Value ?? "agent";
_ = LogAccessAsync($"agent:{agentCode}", "agent_logout");
return Ok(ApiResponse.Ok());
}
[Authorize(Policy = "Agent")]
[HttpGet("profile")]
public async Task<IActionResult> Profile()
{
if (!User.TryGetUserId(out var agentId))
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
var agent = await _db.Agents.FindAsync(agentId);
if (agent == null)
return NotFound(ApiResponse.Fail(404, "not_found"));
return Ok(ApiResponse<object>.Ok(new
{
id = agent.Id,
agentCode = agent.AgentCode,
companyName = agent.CompanyName,
contactPerson = agent.ContactPerson,
contactPhone = agent.ContactPhone,
contactEmail = agent.ContactEmail,
balance = agent.Balance,
discount = agent.Discount,
creditLimit = agent.CreditLimit,
status = agent.Status
}));
}
[Authorize(Policy = "Agent")]
[HttpPut("profile")]
public async Task<IActionResult> UpdateProfile([FromBody] AgentUpdateRequest request)
{
if (!User.TryGetUserId(out var agentId))
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
var agent = await _db.Agents.FindAsync(agentId);
if (agent == null)
return NotFound(ApiResponse.Fail(404, "not_found"));
if (!string.IsNullOrWhiteSpace(request.CompanyName))
agent.CompanyName = request.CompanyName;
if (!string.IsNullOrWhiteSpace(request.ContactPerson))
agent.ContactPerson = request.ContactPerson;
if (!string.IsNullOrWhiteSpace(request.ContactPhone))
agent.ContactPhone = request.ContactPhone;
if (!string.IsNullOrWhiteSpace(request.ContactEmail))
agent.ContactEmail = request.ContactEmail;
agent.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync();
return Ok(ApiResponse.Ok());
}
[Authorize(Policy = "Agent")]
[HttpPost("change-password")]
public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordRequest request)
{
if (!User.TryGetUserId(out var agentId))
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
var agent = await _db.Agents.FindAsync(agentId);
if (agent == null)
return NotFound(ApiResponse.Fail(404, "not_found"));
if (!PasswordHasher.Verify(request.OldPassword, agent.PasswordHash))
return BadRequest(ApiResponse.Fail(400, "bad_request"));
agent.PasswordHash = PasswordHasher.Hash(request.NewPassword);
agent.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync();
return Ok(ApiResponse.Ok());
}
[Authorize(Policy = "Agent")]
[HttpGet("transactions")]
public async Task<IActionResult> Transactions()
{
if (!User.TryGetUserId(out var agentId))
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
var items = await _db.AgentTransactions
.Where(t => t.AgentId == agentId)
.OrderByDescending(t => t.CreatedAt)
.Take(200)
.ToListAsync();
return Ok(ApiResponse<List<AgentTransaction>>.Ok(items));
}
private async Task LogAccessAsync(string? deviceId, string action)
{
_db.AccessLogs.Add(new AccessLog
{
DeviceId = deviceId,
Action = action,
IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString(),
UserAgent = HttpContext.Request.Headers.UserAgent.ToString(),
ResponseCode = 200,
CreatedAt = DateTime.UtcNow
});
await _db.SaveChangesAsync();
}
}

View File

@@ -0,0 +1,190 @@
using System.Text.Json;
using License.Api.Data;
using License.Api.DTOs;
using License.Api.Models;
using License.Api.Services;
using License.Api.Utils;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace License.Api.Controllers;
[ApiController]
[Authorize(Policy = "Agent")]
[Route("api/agent/cards")]
public class AgentCardsController : ControllerBase
{
private readonly AppDbContext _db;
private readonly CardService _cards;
private readonly IdempotencyService _idempotency;
private readonly ConfigService _config;
public AgentCardsController(AppDbContext db, CardService cards, IdempotencyService idempotency, ConfigService config)
{
_db = db;
_cards = cards;
_idempotency = idempotency;
_config = config;
}
[HttpPost("generate")]
public async Task<IActionResult> Generate([FromBody] CardGenerateRequest request)
{
var agentSystemEnabled = await _config.GetBoolAsync("feature.agent_system", true);
if (!agentSystemEnabled)
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
if (request.Quantity <= 0 || request.Quantity > 10000)
return BadRequest(ApiResponse.Fail(400, "bad_request"));
var project = await _db.Projects.FirstOrDefaultAsync(p => p.ProjectId == request.ProjectId);
if (project == null)
return NotFound(ApiResponse.Fail(404, "not_found"));
if (!project.IsEnabled)
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(1011, "project_disabled"));
if (!User.TryGetUserId(out var agentId))
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
var agent = await _db.Agents.FindAsync(agentId);
if (agent == null || agent.Status != "active")
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
var allowedProjects = string.IsNullOrWhiteSpace(agent.AllowedProjects)
? new List<string>()
: JsonSerializer.Deserialize<List<string>>(agent.AllowedProjects) ?? new List<string>();
if (allowedProjects.Count > 0 && !allowedProjects.Contains(project.ProjectId))
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
var pricing = await _db.ProjectPricing.FirstOrDefaultAsync(p =>
p.ProjectId == project.ProjectId &&
p.CardType == request.CardType &&
p.DurationDays == request.DurationDays &&
p.IsEnabled);
if (pricing == null)
return BadRequest(ApiResponse.Fail(400, "bad_request"));
var unitPrice = pricing.OriginalPrice * (agent.Discount / 100m);
var totalCost = unitPrice * request.Quantity;
var requestHash = IdempotencyService.ComputeRequestHash(JsonSerializer.Serialize(request));
var idempotencyKey = Request.Headers["X-Idempotency-Key"].ToString();
if (!string.IsNullOrWhiteSpace(idempotencyKey))
{
var existing = await _idempotency.GetAsync(idempotencyKey);
if (existing != null)
{
if (!string.Equals(existing.RequestHash, requestHash, StringComparison.OrdinalIgnoreCase))
return Conflict(ApiResponse.Fail(400, "bad_request"));
var cached = JsonSerializer.Deserialize<ApiResponse<AgentCardGenerateResponse>>(existing.ResponseBody ?? "{}")
?? ApiResponse<AgentCardGenerateResponse>.Fail(500, "internal_error");
return StatusCode(existing.ResponseCode ?? 200, cached);
}
}
await using var tx = await _db.Database.BeginTransactionAsync();
var lockedAgent = await _db.Agents
.FromSqlRaw("SELECT * FROM \"Agents\" WHERE \"Id\" = {0} FOR UPDATE", agentId)
.FirstOrDefaultAsync();
if (lockedAgent == null)
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
var balanceBefore = lockedAgent.Balance;
var balanceAfter = balanceBefore - totalCost;
if (balanceAfter < -lockedAgent.CreditLimit)
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
lockedAgent.Balance = balanceAfter;
lockedAgent.UpdatedAt = DateTime.UtcNow;
_db.AgentTransactions.Add(new AgentTransaction
{
AgentId = lockedAgent.Id,
Type = "consume",
Amount = -totalCost,
BalanceBefore = balanceBefore,
BalanceAfter = balanceAfter,
Remark = $"generate_cards:{request.CardType}:{request.DurationDays}x{request.Quantity}",
CreatedAt = DateTime.UtcNow
});
var generated = await _cards.GenerateAsync(request, agentId, lockedAgent.Id, unitPrice, "agent");
await _db.SaveChangesAsync();
await tx.CommitAsync();
var response = new AgentCardGenerateResponse
{
BatchId = generated.BatchId,
Keys = generated.Keys,
Count = generated.Count,
UnitPrice = unitPrice,
TotalPrice = totalCost,
BalanceAfter = balanceAfter
};
var apiResponse = ApiResponse<AgentCardGenerateResponse>.Ok(response);
if (!string.IsNullOrWhiteSpace(idempotencyKey))
{
var body = JsonSerializer.Serialize(apiResponse);
await _idempotency.StoreAsync(idempotencyKey, Request.Path, requestHash, 200, body);
}
return Ok(apiResponse);
}
[HttpGet]
public async Task<IActionResult> List([FromQuery] string? projectId, [FromQuery] string? status, [FromQuery] int page = 1, [FromQuery] int pageSize = 20)
{
var agentSystemEnabled = await _config.GetBoolAsync("feature.agent_system", true);
if (!agentSystemEnabled)
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
page = Math.Max(1, page);
pageSize = Math.Clamp(pageSize, 1, 100);
if (!User.TryGetUserId(out var agentId))
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
var query = _db.CardKeys.Where(c => c.AgentId == agentId && c.DeletedAt == null).AsQueryable();
if (!string.IsNullOrWhiteSpace(projectId))
query = query.Where(c => c.ProjectId == projectId);
if (!string.IsNullOrWhiteSpace(status))
query = query.Where(c => c.Status == status);
var total = await query.CountAsync();
var items = await query.OrderByDescending(c => c.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(c => new AgentCardListItem
{
Id = c.Id,
KeyCode = c.KeyCode,
CardType = c.CardType,
Status = c.Status,
ActivateTime = c.ActivateTime,
ExpireTime = c.ExpireTime,
Note = c.Note,
CreatedAt = c.CreatedAt
})
.ToListAsync();
var result = new PagedResult<AgentCardListItem>
{
Items = items,
Pagination = new PaginationInfo
{
Page = page,
PageSize = pageSize,
Total = total,
TotalPages = (int)Math.Ceiling(total / (double)pageSize)
}
};
return Ok(ApiResponse<PagedResult<AgentCardListItem>>.Ok(result));
}
}

View File

@@ -0,0 +1,31 @@
using License.Api.DTOs;
using License.Api.Services;
using Microsoft.AspNetCore.Mvc;
namespace License.Api.Controllers;
[ApiController]
[Route("api/auth")]
public class AuthController : ControllerBase
{
private readonly AuthService _auth;
public AuthController(AuthService auth)
{
_auth = auth;
}
[HttpPost("verify")]
public async Task<IActionResult> Verify([FromBody] AuthVerifyRequest request)
{
var (response, status) = await _auth.VerifyAsync(request, HttpContext);
return StatusCode(status, response);
}
[HttpPost("heartbeat")]
public async Task<IActionResult> Heartbeat([FromBody] AuthHeartbeatRequest request)
{
var (response, status) = await _auth.HeartbeatAsync(request, HttpContext);
return StatusCode(status, response);
}
}

View File

@@ -0,0 +1,60 @@
using License.Api.Data;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using StackExchange.Redis;
namespace License.Api.Controllers;
[ApiController]
[Route("health")]
public class HealthController : ControllerBase
{
private readonly AppDbContext _db;
private readonly IConnectionMultiplexer? _redis;
public HealthController(AppDbContext db, IConnectionMultiplexer? redis = null)
{
_db = db;
_redis = redis;
}
[HttpGet("live")]
public IActionResult Live()
{
return Ok(new { status = "ok" });
}
[HttpGet("ready")]
public async Task<IActionResult> Ready()
{
var checks = new Dictionary<string, string>();
try
{
await _db.Database.ExecuteSqlRawAsync("SELECT 1");
checks["database"] = "ok";
}
catch (Exception ex)
{
checks["database"] = $"error: {ex.Message}";
}
if (_redis != null)
{
try
{
var db = _redis.GetDatabase();
await db.PingAsync();
checks["redis"] = "ok";
}
catch (Exception ex)
{
checks["redis"] = $"error: {ex.Message}";
}
}
var status = checks.Values.All(v => v == "ok") ? "ok" : "error";
var code = status == "ok" ? 200 : 503;
return StatusCode(code, new { status, checks });
}
}

View File

@@ -0,0 +1,41 @@
using License.Api.Data;
using License.Api.DTOs;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace License.Api.Controllers;
[ApiController]
[Route("api/config")]
public class PublicConfigController : ControllerBase
{
private readonly AppDbContext _db;
public PublicConfigController(AppDbContext db)
{
_db = db;
}
[HttpGet("public")]
public async Task<IActionResult> GetPublicConfigs()
{
var configs = await _db.SystemConfigs
.AsNoTracking()
.Where(c => c.IsPublic)
.OrderBy(c => c.Category)
.ThenBy(c => c.ConfigKey)
.Select(c => new
{
key = c.ConfigKey,
value = c.ConfigValue,
valueType = c.ValueType,
category = c.Category,
displayName = c.DisplayName,
description = c.Description,
options = c.Options
})
.ToListAsync();
return Ok(ApiResponse<object>.Ok(configs));
}
}

View File

@@ -0,0 +1,154 @@
using License.Api.Data;
using License.Api.DTOs;
using License.Api.Models;
using License.Api.Options;
using License.Api.Security;
using License.Api.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace License.Api.Controllers;
[ApiController]
[Route("api/software")]
public class SoftwareController : ControllerBase
{
private readonly AppDbContext _db;
private readonly SoftwareService _software;
private readonly JwtTokenService _jwt;
private readonly ILogger<SoftwareController> _logger;
private readonly ConfigService _config;
private readonly StorageOptions _storage;
public SoftwareController(AppDbContext db, SoftwareService software, JwtTokenService jwt, ILogger<SoftwareController> logger, ConfigService config, IOptions<StorageOptions> storageOptions)
{
_db = db;
_software = software;
_jwt = jwt;
_logger = logger;
_config = config;
_storage = storageOptions.Value;
}
[HttpPost("check-update")]
public async Task<IActionResult> CheckUpdate([FromBody] SoftwareCheckUpdateRequest request)
{
var project = await _db.Projects.FirstOrDefaultAsync(p => p.ProjectId == request.ProjectId);
if (project == null)
return NotFound(ApiResponse.Fail(404, "not_found"));
if (!project.IsEnabled)
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(1011, "project_disabled"));
var autoUpdateEnabled = (await _config.GetBoolAsync("feature.auto_update", true)) && project.AutoUpdate;
if (!autoUpdateEnabled)
{
var disabled = new SoftwareCheckUpdateResponse { HasUpdate = false };
return Ok(ApiResponse<SoftwareCheckUpdateResponse>.Ok(disabled));
}
var data = await _software.CheckUpdateAsync(request);
var forceUpdateEnabled = await _config.GetBoolAsync("feature.force_update", false);
if (forceUpdateEnabled)
data.ForceUpdate = true;
return Ok(ApiResponse<SoftwareCheckUpdateResponse>.Ok(data));
}
[HttpGet("download")]
public async Task<IActionResult> Download([FromQuery] string? version, [FromQuery] string? token)
{
if (string.IsNullOrWhiteSpace(token))
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
var principal = _jwt.ValidateToken(token);
if (principal == null)
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
var type = principal.Claims.FirstOrDefault(c => c.Type == "type")?.Value;
if (!string.Equals(type, "card", StringComparison.OrdinalIgnoreCase))
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
var projectId = principal.Claims.FirstOrDefault(c => c.Type == "projectId")?.Value;
if (string.IsNullOrWhiteSpace(projectId))
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
var cardIdStr = principal.Claims.FirstOrDefault(c => c.Type == System.IdentityModel.Tokens.Jwt.JwtRegisteredClaimNames.Sub)?.Value;
int.TryParse(cardIdStr, out var cardId);
if (cardId > 0)
{
var card = await _db.CardKeys.FirstOrDefaultAsync(c => c.Id == cardId && c.DeletedAt == null);
if (card == null)
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
if (card.Status == "banned")
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(1003, "card_banned"));
if (card.ExpireTime.HasValue && card.ExpireTime <= DateTime.UtcNow)
{
card.Status = "expired";
await _db.SaveChangesAsync();
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(1002, "card_expired"));
}
}
var software = await _software.GetVersionAsync(projectId, version);
if (software == null)
return NotFound(ApiResponse.Fail(404, "not_found"));
try
{
var filePath = software.FileUrl;
if (!System.IO.File.Exists(filePath))
return NotFound(ApiResponse.Fail(404, "not_found"));
if (!string.IsNullOrWhiteSpace(software.FileHash))
Response.Headers["X-File-Hash"] = software.FileHash;
if (software.FileSize.HasValue)
Response.Headers["X-File-Size"] = software.FileSize.Value.ToString();
if (!string.IsNullOrWhiteSpace(software.EncryptionKey))
{
if (_storage.RequireHttpsForDownloadKey && !IsSecureRequest(Request))
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "https_required"));
Response.Headers["X-Encryption-Method"] = "AES-256-GCM";
Response.Headers["X-Encryption-Key"] = software.EncryptionKey;
using var fs = System.IO.File.OpenRead(filePath);
var nonce = new byte[12];
var read = await fs.ReadAsync(nonce, 0, nonce.Length);
if (read == nonce.Length)
Response.Headers["X-Encryption-Nonce"] = Convert.ToBase64String(nonce);
}
_db.AccessLogs.Add(new AccessLog
{
ProjectId = projectId,
CardKeyId = cardId > 0 ? cardId : null,
Action = "download",
IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString(),
UserAgent = HttpContext.Request.Headers.UserAgent.ToString(),
ResponseCode = 200,
CreatedAt = DateTime.UtcNow
});
await _db.SaveChangesAsync();
return PhysicalFile(filePath, "application/octet-stream", enableRangeProcessing: true);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to download file");
return StatusCode(500, ApiResponse.Fail(500, "internal_error"));
}
}
private static bool IsSecureRequest(HttpRequest request)
{
if (request.IsHttps)
return true;
var forwardedProto = request.Headers["X-Forwarded-Proto"].ToString();
return string.Equals(forwardedProto, "https", StringComparison.OrdinalIgnoreCase);
}
}

View File

@@ -0,0 +1,31 @@
namespace License.Api.DTOs;
public class AdminLoginRequest
{
public string Username { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
public string? Captcha { get; set; }
}
public class ChangePasswordRequest
{
public string OldPassword { get; set; } = string.Empty;
public string NewPassword { get; set; } = string.Empty;
}
public class AdminCreateRequest
{
public string Username { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
public string? Email { get; set; }
public string Role { get; set; } = "admin";
public string? Permissions { get; set; }
}
public class AdminUpdateRequest
{
public string? Email { get; set; }
public string? Role { get; set; }
public string? Permissions { get; set; }
public string? Status { get; set; }
}

View File

@@ -0,0 +1,68 @@
namespace License.Api.DTOs;
public class AgentLoginRequest
{
public string AgentCode { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
}
public class AgentCreateRequest
{
public int? AdminId { get; set; }
public string AgentCode { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
public string? CompanyName { get; set; }
public string? ContactPerson { get; set; }
public string? ContactPhone { get; set; }
public string? ContactEmail { get; set; }
public decimal InitialBalance { get; set; }
public decimal Discount { get; set; } = 100m;
public decimal CreditLimit { get; set; }
public List<string>? AllowedProjects { get; set; }
}
public class AgentUpdateRequest
{
public string? CompanyName { get; set; }
public string? ContactPerson { get; set; }
public string? ContactPhone { get; set; }
public string? ContactEmail { get; set; }
public decimal? Discount { get; set; }
public decimal? CreditLimit { get; set; }
public List<string>? AllowedProjects { get; set; }
public string? Status { get; set; }
}
public class AgentBalanceRequest
{
public decimal Amount { get; set; }
public string? Remark { get; set; }
}
public class AgentListItem
{
public int Id { get; set; }
public string AgentCode { get; set; } = string.Empty;
public string? CompanyName { get; set; }
public string? ContactPerson { get; set; }
public string? ContactPhone { get; set; }
public decimal Balance { get; set; }
public decimal Discount { get; set; }
public string Status { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; }
}
public class AgentDetailResponse
{
public int Id { get; set; }
public string AgentCode { get; set; } = string.Empty;
public string? CompanyName { get; set; }
public string? ContactPerson { get; set; }
public string? ContactPhone { get; set; }
public string? ContactEmail { get; set; }
public decimal Balance { get; set; }
public decimal Discount { get; set; }
public decimal CreditLimit { get; set; }
public string Status { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; }
}

View File

@@ -0,0 +1,24 @@
namespace License.Api.DTOs;
public class ApiResponse<T>
{
public int Code { get; set; }
public string Message { get; set; } = "success";
public T? Data { get; set; }
public long Timestamp { get; set; } = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
public static ApiResponse<T> Ok(T? data, string message = "success")
=> new() { Code = 200, Message = message, Data = data };
public static ApiResponse<T> Fail(int code, string message)
=> new() { Code = code, Message = message, Data = default };
}
public class ApiResponse : ApiResponse<object>
{
public static ApiResponse Ok(string message = "success")
=> new() { Code = 200, Message = message, Data = null };
public static ApiResponse Fail(int code, string message)
=> new() { Code = code, Message = message, Data = null };
}

View File

@@ -0,0 +1,38 @@
namespace License.Api.DTOs;
public class AuthVerifyRequest
{
public string ProjectId { get; set; } = string.Empty;
public string KeyCode { get; set; } = string.Empty;
public string DeviceId { get; set; } = string.Empty;
public string? ClientVersion { get; set; }
public long Timestamp { get; set; }
public string Signature { get; set; } = string.Empty;
}
public class AuthVerifyResponse
{
public bool Valid { get; set; }
public DateTime? ExpireTime { get; set; }
public int RemainingDays { get; set; }
public string? DownloadUrl { get; set; }
public string? FileHash { get; set; }
public string? Version { get; set; }
public int HeartbeatInterval { get; set; }
public string? AccessToken { get; set; }
}
public class AuthHeartbeatRequest
{
public string AccessToken { get; set; } = string.Empty;
public string DeviceId { get; set; } = string.Empty;
public long Timestamp { get; set; }
public string Signature { get; set; } = string.Empty;
}
public class AuthHeartbeatResponse
{
public bool Valid { get; set; }
public int RemainingDays { get; set; }
public long ServerTime { get; set; }
}

View File

@@ -0,0 +1,60 @@
namespace License.Api.DTOs;
public class CardGenerateRequest
{
public string ProjectId { get; set; } = string.Empty;
public string CardType { get; set; } = string.Empty;
public int DurationDays { get; set; }
public int Quantity { get; set; }
public string? Note { get; set; }
}
public class CardGenerateResponse
{
public string BatchId { get; set; } = string.Empty;
public List<string> Keys { get; set; } = new();
public int Count { get; set; }
}
public class CardBanRequest
{
public string? Reason { get; set; }
}
public class CardExtendRequest
{
public int Days { get; set; }
}
public class CardNoteUpdateRequest
{
public string? Note { get; set; }
}
public class CardBatchRequest
{
public List<int> Ids { get; set; } = new();
public string? Reason { get; set; }
}
public class AgentCardGenerateResponse
{
public string BatchId { get; set; } = string.Empty;
public List<string> Keys { get; set; } = new();
public int Count { get; set; }
public decimal UnitPrice { get; set; }
public decimal TotalPrice { get; set; }
public decimal BalanceAfter { get; set; }
}
public class AgentCardListItem
{
public int Id { get; set; }
public string KeyCode { get; set; } = string.Empty;
public string CardType { get; set; } = string.Empty;
public string Status { get; set; } = string.Empty;
public DateTime? ActivateTime { get; set; }
public DateTime? ExpireTime { get; set; }
public string? Note { get; set; }
public DateTime CreatedAt { get; set; }
}

View File

@@ -0,0 +1,15 @@
namespace License.Api.DTOs;
public class PagedResult<T>
{
public List<T> Items { get; set; } = new();
public PaginationInfo Pagination { get; set; } = new();
}
public class PaginationInfo
{
public int Page { get; set; }
public int PageSize { get; set; }
public int Total { get; set; }
public int TotalPages { get; set; }
}

View File

@@ -0,0 +1,78 @@
namespace License.Api.DTOs;
public class ProjectCreateRequest
{
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
public int MaxDevices { get; set; } = 1;
public bool AutoUpdate { get; set; } = true;
public string? IconUrl { get; set; }
}
public class ProjectUpdateRequest
{
public string? Name { get; set; }
public string? Description { get; set; }
public int? MaxDevices { get; set; }
public bool? AutoUpdate { get; set; }
public bool? IsEnabled { get; set; }
public string? IconUrl { get; set; }
}
public class ProjectDocUpdateRequest
{
public string? Content { get; set; }
}
public class ProjectPricingCreateRequest
{
public string CardType { get; set; } = string.Empty;
public int DurationDays { get; set; }
public decimal OriginalPrice { get; set; }
}
public class ProjectPricingUpdateRequest
{
public decimal? OriginalPrice { get; set; }
public bool? IsEnabled { get; set; }
}
public class ProjectListItem
{
public int Id { get; set; }
public string ProjectId { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
public string? IconUrl { get; set; }
public int MaxDevices { get; set; }
public bool AutoUpdate { get; set; }
public bool IsEnabled { get; set; }
public DateTime CreatedAt { get; set; }
}
public class ProjectDetailResponse
{
public int Id { get; set; }
public string ProjectId { get; set; } = string.Empty;
public string ProjectKey { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
public string? IconUrl { get; set; }
public int MaxDevices { get; set; }
public bool AutoUpdate { get; set; }
public bool IsEnabled { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
}
public class SoftwareVersionListItem
{
public int Id { get; set; }
public string Version { get; set; } = string.Empty;
public long? FileSize { get; set; }
public string? FileHash { get; set; }
public bool IsStable { get; set; }
public bool IsForceUpdate { get; set; }
public string? Changelog { get; set; }
public DateTime PublishedAt { get; set; }
}

View File

@@ -0,0 +1,8 @@
namespace License.Api.DTOs;
public class SoftwareVersionUpdateRequest
{
public bool IsForceUpdate { get; set; }
public bool IsStable { get; set; }
public string? Changelog { get; set; }
}

View File

@@ -0,0 +1,19 @@
namespace License.Api.DTOs;
public class SoftwareCheckUpdateRequest
{
public string ProjectId { get; set; } = string.Empty;
public string CurrentVersion { get; set; } = string.Empty;
public string? Platform { get; set; }
}
public class SoftwareCheckUpdateResponse
{
public bool HasUpdate { get; set; }
public string? LatestVersion { get; set; }
public bool ForceUpdate { get; set; }
public string? DownloadUrl { get; set; }
public long FileSize { get; set; }
public string? FileHash { get; set; }
public string? Changelog { get; set; }
}

View File

@@ -0,0 +1,27 @@
namespace License.Api.DTOs;
public class ProjectStatsItem
{
public string ProjectId { get; set; } = string.Empty;
public string ProjectName { get; set; } = string.Empty;
public int TotalCards { get; set; }
public int ActiveCards { get; set; }
public int ActiveDevices { get; set; }
public decimal Revenue { get; set; }
}
public class AgentStatsItem
{
public int AgentId { get; set; }
public string AgentCode { get; set; } = string.Empty;
public string? CompanyName { get; set; }
public int TotalCards { get; set; }
public int ActiveCards { get; set; }
public decimal TotalRevenue { get; set; }
}
public class LogStatsItem
{
public string Action { get; set; } = string.Empty;
public int Count { get; set; }
}

View File

@@ -0,0 +1,164 @@
using License.Api.Models;
using Microsoft.EntityFrameworkCore;
namespace License.Api.Data;
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options)
{
}
public DbSet<Project> Projects => Set<Project>();
public DbSet<ProjectPricing> ProjectPricing => Set<ProjectPricing>();
public DbSet<SoftwareVersion> SoftwareVersions => Set<SoftwareVersion>();
public DbSet<CardKey> CardKeys => Set<CardKey>();
public DbSet<Device> Devices => Set<Device>();
public DbSet<AccessLog> AccessLogs => Set<AccessLog>();
public DbSet<Statistic> Statistics => Set<Statistic>();
public DbSet<Admin> Admins => Set<Admin>();
public DbSet<Agent> Agents => Set<Agent>();
public DbSet<AgentTransaction> AgentTransactions => Set<AgentTransaction>();
public DbSet<CardKeyLog> CardKeyLogs => Set<CardKeyLog>();
public DbSet<SystemConfig> SystemConfigs => Set<SystemConfig>();
public DbSet<IdempotencyKeyRecord> IdempotencyKeys => Set<IdempotencyKeyRecord>();
public override int SaveChanges()
{
BumpCardKeyVersion();
return base.SaveChanges();
}
public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
BumpCardKeyVersion();
return base.SaveChangesAsync(cancellationToken);
}
private void BumpCardKeyVersion()
{
foreach (var entry in ChangeTracker.Entries<CardKey>())
{
if (entry.State == EntityState.Modified)
entry.Entity.Version += 1;
}
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Project>(entity =>
{
entity.ToTable("Projects");
entity.HasIndex(p => p.ProjectId).IsUnique();
entity.Property(p => p.CreatedAt).HasDefaultValueSql("CURRENT_TIMESTAMP");
entity.Property(p => p.UpdatedAt).HasDefaultValueSql("CURRENT_TIMESTAMP");
entity.HasMany(p => p.Pricing)
.WithOne(p => p.Project)
.HasForeignKey(p => p.ProjectId)
.HasPrincipalKey(p => p.ProjectId);
entity.HasMany(p => p.Versions)
.WithOne(v => v.Project)
.HasForeignKey(v => v.ProjectId)
.HasPrincipalKey(p => p.ProjectId);
});
modelBuilder.Entity<ProjectPricing>(entity =>
{
entity.ToTable("ProjectPricing");
entity.HasIndex(p => new { p.ProjectId, p.CardType, p.DurationDays }).IsUnique();
entity.Property(p => p.CreatedAt).HasDefaultValueSql("CURRENT_TIMESTAMP");
entity.Property(p => p.UpdatedAt).HasDefaultValueSql("CURRENT_TIMESTAMP");
});
modelBuilder.Entity<SoftwareVersion>(entity =>
{
entity.ToTable("SoftwareVersions");
entity.HasIndex(v => new { v.ProjectId, v.Version }).IsUnique();
entity.Property(v => v.PublishedAt).HasDefaultValueSql("CURRENT_TIMESTAMP");
entity.Property(v => v.CreatedAt).HasDefaultValueSql("CURRENT_TIMESTAMP");
});
modelBuilder.Entity<CardKey>(entity =>
{
entity.ToTable("CardKeys");
entity.HasIndex(k => k.KeyCode).IsUnique();
entity.HasIndex(k => k.ProjectId);
entity.HasIndex(k => k.Status);
entity.Property(k => k.CreatedAt).HasDefaultValueSql("CURRENT_TIMESTAMP");
entity.HasOne(k => k.Project)
.WithMany()
.HasForeignKey(k => k.ProjectId)
.HasPrincipalKey(p => p.ProjectId);
entity.HasMany(k => k.Devices).WithOne(d => d.CardKey).HasForeignKey(d => d.CardKeyId);
entity.HasMany(k => k.Logs).WithOne(l => l.CardKey).HasForeignKey(l => l.CardKeyId);
});
modelBuilder.Entity<Device>(entity =>
{
entity.ToTable("Devices");
entity.HasIndex(d => d.CardKeyId);
entity.HasIndex(d => d.DeviceId);
entity.HasIndex(d => new { d.CardKeyId, d.DeviceId }).IsUnique();
entity.Property(d => d.FirstLoginAt).HasDefaultValueSql("CURRENT_TIMESTAMP");
});
modelBuilder.Entity<AccessLog>(entity =>
{
entity.ToTable("AccessLogs");
entity.HasIndex(l => l.ProjectId);
entity.HasIndex(l => l.CreatedAt);
entity.HasIndex(l => new { l.Action, l.CreatedAt });
entity.Property(l => l.CreatedAt).HasDefaultValueSql("CURRENT_TIMESTAMP");
});
modelBuilder.Entity<Statistic>(entity =>
{
entity.ToTable("Statistics");
entity.HasIndex(s => new { s.ProjectId, s.Date }).IsUnique();
entity.Property(s => s.Date).HasColumnType("date");
});
modelBuilder.Entity<Admin>(entity =>
{
entity.ToTable("Admins");
entity.HasIndex(a => a.Username).IsUnique();
entity.Property(a => a.CreatedAt).HasDefaultValueSql("CURRENT_TIMESTAMP");
entity.Property(a => a.UpdatedAt).HasDefaultValueSql("CURRENT_TIMESTAMP");
});
modelBuilder.Entity<Agent>(entity =>
{
entity.ToTable("Agents");
entity.HasIndex(a => a.AgentCode).IsUnique();
entity.Property(a => a.CreatedAt).HasDefaultValueSql("CURRENT_TIMESTAMP");
entity.Property(a => a.UpdatedAt).HasDefaultValueSql("CURRENT_TIMESTAMP");
});
modelBuilder.Entity<AgentTransaction>(entity =>
{
entity.ToTable("AgentTransactions");
entity.Property(t => t.CreatedAt).HasDefaultValueSql("CURRENT_TIMESTAMP");
});
modelBuilder.Entity<CardKeyLog>(entity =>
{
entity.ToTable("CardKeyLogs");
entity.Property(l => l.CreatedAt).HasDefaultValueSql("CURRENT_TIMESTAMP");
});
modelBuilder.Entity<SystemConfig>(entity =>
{
entity.ToTable("SystemConfigs");
entity.HasIndex(c => c.ConfigKey).IsUnique();
entity.Property(c => c.UpdatedAt).HasDefaultValueSql("CURRENT_TIMESTAMP");
});
modelBuilder.Entity<IdempotencyKeyRecord>(entity =>
{
entity.ToTable("IdempotencyKeys");
entity.HasIndex(i => i.IdempotencyKey).IsUnique();
entity.HasIndex(i => i.ExpiresAt);
entity.Property(i => i.CreatedAt).HasDefaultValueSql("CURRENT_TIMESTAMP");
});
}
}

View File

@@ -0,0 +1,87 @@
using License.Api.Models;
using License.Api.Options;
using License.Api.Utils;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace License.Api.Data;
public class DatabaseInitializer
{
private readonly AppDbContext _db;
private readonly SeedOptions _seedOptions;
public DatabaseInitializer(AppDbContext db, IOptions<SeedOptions> seedOptions)
{
_db = db;
_seedOptions = seedOptions.Value;
}
public async Task InitializeAsync()
{
await _db.Database.EnsureCreatedAsync();
if (!await _db.Admins.AnyAsync())
{
var admin = new Admin
{
Username = _seedOptions.AdminUser,
PasswordHash = PasswordHasher.Hash(_seedOptions.AdminPassword),
Email = _seedOptions.AdminEmail,
Role = "super_admin",
Status = "active",
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow
};
_db.Admins.Add(admin);
}
if (!await _db.SystemConfigs.AnyAsync())
{
_db.SystemConfigs.AddRange(DefaultConfigs());
}
await _db.SaveChangesAsync();
}
private static IEnumerable<SystemConfig> DefaultConfigs()
{
return new List<SystemConfig>
{
new() { ConfigKey = "feature.heartbeat", ConfigValue = "true", ValueType = "bool", Category = "feature", DisplayName = "Heartbeat", Description = "Enable heartbeat", IsPublic = true },
new() { ConfigKey = "feature.device_bind", ConfigValue = "true", ValueType = "bool", Category = "feature", DisplayName = "Device Bind", Description = "Enable device binding", IsPublic = true },
new() { ConfigKey = "feature.auto_update", ConfigValue = "true", ValueType = "bool", Category = "feature", DisplayName = "Auto Update", Description = "Enable auto update", IsPublic = true },
new() { ConfigKey = "feature.force_update", ConfigValue = "false", ValueType = "bool", Category = "feature", DisplayName = "Force Update", Description = "Enable force update", IsPublic = true },
new() { ConfigKey = "feature.agent_system", ConfigValue = "true", ValueType = "bool", Category = "feature", DisplayName = "Agent System", Description = "Enable agent system", IsPublic = false },
new() { ConfigKey = "feature.card_renewal", ConfigValue = "true", ValueType = "bool", Category = "feature", DisplayName = "Card Renewal", Description = "Enable card renewal", IsPublic = false },
new() { ConfigKey = "feature.trial_mode", ConfigValue = "false", ValueType = "bool", Category = "feature", DisplayName = "Trial Mode", Description = "Enable trial mode", IsPublic = true },
new() { ConfigKey = "trial.days", ConfigValue = "3", ValueType = "number", Category = "trial", DisplayName = "Trial Days", Description = "Trial duration days", IsPublic = true },
new() { ConfigKey = "auth.max_devices", ConfigValue = "1", ValueType = "number", Category = "auth", DisplayName = "Max Devices", Description = "Max devices per card", IsPublic = true },
new() { ConfigKey = "auth.allow_multi_device", ConfigValue = "false", ValueType = "bool", Category = "auth", DisplayName = "Allow Multi Device", Description = "Allow multi device online", IsPublic = true },
new() { ConfigKey = "auth.need_activate", ConfigValue = "true", ValueType = "bool", Category = "auth", DisplayName = "Need Activate", Description = "Card activation required", IsPublic = true },
new() { ConfigKey = "auth.expire_type", ConfigValue = "activate", ValueType = "string", Category = "auth", DisplayName = "Expire Type", Description = "activate/fix", IsPublic = true },
new() { ConfigKey = "heartbeat.enabled", ConfigValue = "true", ValueType = "bool", Category = "heartbeat", DisplayName = "Heartbeat Enabled", Description = "Enable heartbeat", IsPublic = true },
new() { ConfigKey = "heartbeat.interval", ConfigValue = "60", ValueType = "number", Category = "heartbeat", DisplayName = "Heartbeat Interval", Description = "Heartbeat interval seconds", IsPublic = true },
new() { ConfigKey = "heartbeat.timeout", ConfigValue = "180", ValueType = "number", Category = "heartbeat", DisplayName = "Heartbeat Timeout", Description = "Heartbeat timeout seconds", IsPublic = true },
new() { ConfigKey = "heartbeat.offline_action", ConfigValue = "exit", ValueType = "string", Category = "heartbeat", DisplayName = "Offline Action", Description = "exit/warning/none", IsPublic = true },
new() { ConfigKey = "ratelimit.enabled", ConfigValue = "true", ValueType = "bool", Category = "ratelimit", DisplayName = "Rate Limit", Description = "Enable rate limit", IsPublic = false },
new() { ConfigKey = "ratelimit.ip_per_minute", ConfigValue = "100", ValueType = "number", Category = "ratelimit", DisplayName = "IP per minute", Description = "IP requests per minute", IsPublic = false },
new() { ConfigKey = "ratelimit.device_per_minute", ConfigValue = "50", ValueType = "number", Category = "ratelimit", DisplayName = "Device per minute", Description = "Device requests per minute", IsPublic = false },
new() { ConfigKey = "ratelimit.block_duration", ConfigValue = "5", ValueType = "number", Category = "ratelimit", DisplayName = "Block Duration", Description = "Block duration minutes", IsPublic = false },
new() { ConfigKey = "risk.enabled", ConfigValue = "true", ValueType = "bool", Category = "risk", DisplayName = "Risk Enabled", Description = "Enable risk control", IsPublic = false },
new() { ConfigKey = "risk.check_location", ConfigValue = "true", ValueType = "bool", Category = "risk", DisplayName = "Check Location", Description = "Detect location change", IsPublic = false },
new() { ConfigKey = "risk.check_device_change", ConfigValue = "true", ValueType = "bool", Category = "risk", DisplayName = "Check Device Change", Description = "Detect device change", IsPublic = false },
new() { ConfigKey = "risk.auto_ban", ConfigValue = "false", ValueType = "bool", Category = "risk", DisplayName = "Auto Ban", Description = "Auto ban anomalies", IsPublic = false },
new() { ConfigKey = "risk.proxy_prefixes", ConfigValue = "", ValueType = "string", Category = "risk", DisplayName = "Proxy Prefixes", Description = "Comma separated IP prefixes", IsPublic = false },
new() { ConfigKey = "client.notice_title", ConfigValue = "", ValueType = "string", Category = "client", DisplayName = "Notice Title", Description = "Client notice title", IsPublic = true },
new() { ConfigKey = "client.notice_content", ConfigValue = "", ValueType = "string", Category = "client", DisplayName = "Notice Content", Description = "Client notice content", IsPublic = true },
new() { ConfigKey = "client.contact_url", ConfigValue = "", ValueType = "string", Category = "client", DisplayName = "Contact URL", Description = "Contact url", IsPublic = true },
new() { ConfigKey = "client.help_url", ConfigValue = "", ValueType = "string", Category = "client", DisplayName = "Help URL", Description = "Help url", IsPublic = true },
new() { ConfigKey = "client.show_balance", ConfigValue = "false", ValueType = "bool", Category = "client", DisplayName = "Show Balance", Description = "Show balance on client", IsPublic = true },
new() { ConfigKey = "system.site_name", ConfigValue = "License System", ValueType = "string", Category = "system", DisplayName = "Site Name", Description = "Site name", IsPublic = true },
new() { ConfigKey = "system.logo_url", ConfigValue = "", ValueType = "string", Category = "system", DisplayName = "Logo URL", Description = "Logo url", IsPublic = true },
new() { ConfigKey = "system.enable_register", ConfigValue = "false", ValueType = "bool", Category = "system", DisplayName = "Enable Register", Description = "Enable register", IsPublic = false },
new() { ConfigKey = "log.retention_days", ConfigValue = "90", ValueType = "number", Category = "system", DisplayName = "Log Retention", Description = "Log retention days", IsPublic = false }
};
}
}

View File

@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="8.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.8">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.8" />
<PackageReference Include="StackExchange.Redis" Version="2.7.33" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.1" />
<PackageReference Include="Serilog.Sinks.File" Version="5.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.1" />
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageReference Include="ClosedXML" Version="0.102.2" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,33 @@
using System.Net;
using System.Text.Json;
using License.Api.DTOs;
namespace License.Api.Middlewares;
public class ExceptionHandlingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<ExceptionHandlingMiddleware> _logger;
public ExceptionHandlingMiddleware(RequestDelegate next, ILogger<ExceptionHandlingMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
}
catch (Exception ex)
{
_logger.LogError(ex, "Unhandled exception");
context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
context.Response.ContentType = "application/json";
var payload = ApiResponse.Fail(500, "internal_error");
await context.Response.WriteAsync(JsonSerializer.Serialize(payload));
}
}
}

View File

@@ -0,0 +1,95 @@
using System.Text.Json;
using License.Api.DTOs;
using License.Api.Options;
using License.Api.Services;
using Microsoft.Extensions.Options;
namespace License.Api.Middlewares;
public class RateLimitMiddleware
{
private readonly RequestDelegate _next;
private readonly RateLimitOptions _options;
public RateLimitMiddleware(RequestDelegate next, IOptions<RateLimitOptions> options)
{
_next = next;
_options = options.Value;
}
public async Task InvokeAsync(HttpContext context, IRateLimitStore store, ConfigService configService)
{
var enabled = await configService.GetBoolAsync("ratelimit.enabled", _options.Enabled);
if (!enabled)
{
await _next(context);
return;
}
if (!context.Request.Path.StartsWithSegments("/api"))
{
await _next(context);
return;
}
var ipLimit = await configService.GetIntAsync("ratelimit.ip_per_minute", _options.IpPerMinute);
var deviceLimit = await configService.GetIntAsync("ratelimit.device_per_minute", _options.DevicePerMinute);
var blockMinutes = await configService.GetIntAsync("ratelimit.block_duration", _options.BlockDurationMinutes);
var ip = context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
var ipBlockKey = $"ratelimit:block:ip:{ip}";
if (blockMinutes > 0 && await store.ExistsAsync(ipBlockKey))
{
context.Response.StatusCode = StatusCodes.Status429TooManyRequests;
context.Response.ContentType = "application/json";
var payload = ApiResponse.Fail(1009, "rate_limit_exceeded");
await context.Response.WriteAsync(JsonSerializer.Serialize(payload));
return;
}
var ipKey = $"ratelimit:ip:{ip}:{DateTime.UtcNow:yyyyMMddHHmm}";
var ipCount = await store.IncrementAsync(ipKey, TimeSpan.FromMinutes(1));
if (ipCount > ipLimit)
{
if (blockMinutes > 0)
await store.SetAsync(ipBlockKey, TimeSpan.FromMinutes(blockMinutes));
context.Response.StatusCode = StatusCodes.Status429TooManyRequests;
context.Response.ContentType = "application/json";
var payload = ApiResponse.Fail(1009, "rate_limit_exceeded");
await context.Response.WriteAsync(JsonSerializer.Serialize(payload));
return;
}
var deviceId = context.Request.Headers["X-Device-Id"].ToString();
if (!string.IsNullOrWhiteSpace(deviceId))
{
var deviceBlockKey = $"ratelimit:block:device:{deviceId}";
if (blockMinutes > 0 && await store.ExistsAsync(deviceBlockKey))
{
context.Response.StatusCode = StatusCodes.Status429TooManyRequests;
context.Response.ContentType = "application/json";
var payload = ApiResponse.Fail(1009, "rate_limit_exceeded");
await context.Response.WriteAsync(JsonSerializer.Serialize(payload));
return;
}
var deviceKey = $"ratelimit:device:{deviceId}:{DateTime.UtcNow:yyyyMMddHHmm}";
var deviceCount = await store.IncrementAsync(deviceKey, TimeSpan.FromMinutes(1));
if (deviceCount > deviceLimit)
{
if (blockMinutes > 0)
await store.SetAsync(deviceBlockKey, TimeSpan.FromMinutes(blockMinutes));
context.Response.StatusCode = StatusCodes.Status429TooManyRequests;
context.Response.ContentType = "application/json";
var payload = ApiResponse.Fail(1009, "rate_limit_exceeded");
await context.Response.WriteAsync(JsonSerializer.Serialize(payload));
return;
}
}
await _next(context);
}
}

View File

@@ -0,0 +1,29 @@
using System.ComponentModel.DataAnnotations;
namespace License.Api.Models;
public class AccessLog
{
public int Id { get; set; }
[MaxLength(32)]
public string? ProjectId { get; set; }
public int? CardKeyId { get; set; }
[MaxLength(64)]
public string? DeviceId { get; set; }
[MaxLength(50)]
public string Action { get; set; } = string.Empty;
public string? IpAddress { get; set; }
public string? UserAgent { get; set; }
public int? ResponseCode { get; set; }
public int? ResponseTime { get; set; }
public DateTime CreatedAt { get; set; }
}

View File

@@ -0,0 +1,33 @@
using System.ComponentModel.DataAnnotations;
namespace License.Api.Models;
public class Admin
{
public int Id { get; set; }
[MaxLength(50)]
public string Username { get; set; } = string.Empty;
public string PasswordHash { get; set; } = string.Empty;
[MaxLength(100)]
public string? Email { get; set; }
[MaxLength(20)]
public string Role { get; set; } = "admin";
public string? Permissions { get; set; }
[MaxLength(20)]
public string Status { get; set; } = "active";
public DateTime? LastLoginAt { get; set; }
[MaxLength(45)]
public string? LastLoginIp { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
}

View File

@@ -0,0 +1,46 @@
using System.ComponentModel.DataAnnotations;
namespace License.Api.Models;
public class Agent
{
public int Id { get; set; }
public int? AdminId { get; set; }
[MaxLength(20)]
public string AgentCode { get; set; } = string.Empty;
[MaxLength(100)]
public string? CompanyName { get; set; }
[MaxLength(50)]
public string? ContactPerson { get; set; }
[MaxLength(20)]
public string? ContactPhone { get; set; }
[MaxLength(100)]
public string? ContactEmail { get; set; }
public string PasswordHash { get; set; } = string.Empty;
public decimal Balance { get; set; }
public decimal Discount { get; set; } = 100m;
public decimal CreditLimit { get; set; }
public int MaxProjects { get; set; }
public string? AllowedProjects { get; set; }
[MaxLength(20)]
public string Status { get; set; } = "active";
public DateTime? LastLoginAt { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
}

View File

@@ -0,0 +1,30 @@
using System.ComponentModel.DataAnnotations;
namespace License.Api.Models;
public class AgentTransaction
{
public int Id { get; set; }
public int AgentId { get; set; }
[MaxLength(20)]
public string Type { get; set; } = string.Empty;
public decimal Amount { get; set; }
public decimal BalanceBefore { get; set; }
public decimal BalanceAfter { get; set; }
public int? CardKeyId { get; set; }
[MaxLength(200)]
public string? Remark { get; set; }
public int? CreatedBy { get; set; }
public DateTime CreatedAt { get; set; }
public Agent? Agent { get; set; }
}

View File

@@ -0,0 +1,59 @@
using System.ComponentModel.DataAnnotations;
namespace License.Api.Models;
public class CardKey
{
public int Id { get; set; }
[MaxLength(32)]
public string? ProjectId { get; set; }
[MaxLength(32)]
public string KeyCode { get; set; } = string.Empty;
[MaxLength(20)]
public string CardType { get; set; } = string.Empty;
public int DurationDays { get; set; }
public DateTime? ExpireTime { get; set; }
public int MaxDevices { get; set; } = 1;
[MaxLength(64)]
public string? MachineCode { get; set; }
[MaxLength(20)]
public string Status { get; set; } = "unused";
public DateTime? ActivateTime { get; set; }
public DateTime? LastUsedAt { get; set; }
public long UsedDuration { get; set; }
public int? GeneratedBy { get; set; }
public int? AgentId { get; set; }
public decimal? SoldPrice { get; set; }
[MaxLength(200)]
public string? Note { get; set; }
[MaxLength(36)]
public string? BatchId { get; set; }
public int Version { get; set; } = 1;
public DateTime CreatedAt { get; set; }
public DateTime? DeletedAt { get; set; }
public Project? Project { get; set; }
public ICollection<Device> Devices { get; set; } = new List<Device>();
public ICollection<CardKeyLog> Logs { get; set; } = new List<CardKeyLog>();
}

View File

@@ -0,0 +1,27 @@
using System.ComponentModel.DataAnnotations;
namespace License.Api.Models;
public class CardKeyLog
{
public int Id { get; set; }
public int CardKeyId { get; set; }
[MaxLength(50)]
public string Action { get; set; } = string.Empty;
public int? OperatorId { get; set; }
[MaxLength(20)]
public string? OperatorType { get; set; }
public string? Details { get; set; }
[MaxLength(45)]
public string? IpAddress { get; set; }
public DateTime CreatedAt { get; set; }
public CardKey? CardKey { get; set; }
}

View File

@@ -0,0 +1,35 @@
using System.ComponentModel.DataAnnotations;
namespace License.Api.Models;
public class Device
{
public int Id { get; set; }
public int CardKeyId { get; set; }
[MaxLength(64)]
public string DeviceId { get; set; } = string.Empty;
[MaxLength(100)]
public string? DeviceName { get; set; }
[MaxLength(100)]
public string? OsInfo { get; set; }
public DateTime? LastHeartbeat { get; set; }
[MaxLength(45)]
public string? IpAddress { get; set; }
[MaxLength(100)]
public string? Location { get; set; }
public bool IsActive { get; set; } = true;
public DateTime FirstLoginAt { get; set; }
public DateTime? DeletedAt { get; set; }
public CardKey? CardKey { get; set; }
}

View File

@@ -0,0 +1,25 @@
using System.ComponentModel.DataAnnotations;
namespace License.Api.Models;
public class IdempotencyKeyRecord
{
public int Id { get; set; }
[MaxLength(64)]
public string IdempotencyKey { get; set; } = string.Empty;
[MaxLength(200)]
public string RequestPath { get; set; } = string.Empty;
[MaxLength(64)]
public string? RequestHash { get; set; }
public int? ResponseCode { get; set; }
public string? ResponseBody { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime ExpiresAt { get; set; }
}

View File

@@ -0,0 +1,43 @@
using System.ComponentModel.DataAnnotations;
namespace License.Api.Models;
public class Project
{
public int Id { get; set; }
[MaxLength(32)]
public string ProjectId { get; set; } = string.Empty;
[MaxLength(64)]
public string ProjectKey { get; set; } = string.Empty;
[MaxLength(64)]
public string ProjectSecret { get; set; } = string.Empty;
[MaxLength(100)]
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
[MaxLength(500)]
public string? IconUrl { get; set; }
public int MaxDevices { get; set; } = 1;
public bool AutoUpdate { get; set; } = true;
public bool IsEnabled { get; set; } = true;
public int? CreatedBy { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
public string? DocsContent { get; set; }
public ICollection<ProjectPricing> Pricing { get; set; } = new List<ProjectPricing>();
public ICollection<SoftwareVersion> Versions { get; set; } = new List<SoftwareVersion>();
}

View File

@@ -0,0 +1,26 @@
using System.ComponentModel.DataAnnotations;
namespace License.Api.Models;
public class ProjectPricing
{
public int Id { get; set; }
[MaxLength(32)]
public string ProjectId { get; set; } = string.Empty;
[MaxLength(20)]
public string CardType { get; set; } = string.Empty;
public int DurationDays { get; set; }
public decimal OriginalPrice { get; set; }
public bool IsEnabled { get; set; } = true;
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
public Project? Project { get; set; }
}

View File

@@ -0,0 +1,38 @@
using System.ComponentModel.DataAnnotations;
namespace License.Api.Models;
public class SoftwareVersion
{
public int Id { get; set; }
[MaxLength(32)]
public string ProjectId { get; set; } = string.Empty;
[MaxLength(20)]
public string Version { get; set; } = string.Empty;
[MaxLength(500)]
public string FileUrl { get; set; } = string.Empty;
public long? FileSize { get; set; }
[MaxLength(64)]
public string? FileHash { get; set; }
public string? EncryptionKey { get; set; }
public string? Changelog { get; set; }
public bool IsForceUpdate { get; set; }
public bool IsStable { get; set; } = true;
public DateTime PublishedAt { get; set; }
public int? CreatedBy { get; set; }
public DateTime CreatedAt { get; set; }
public Project? Project { get; set; }
}

View File

@@ -0,0 +1,20 @@
namespace License.Api.Models;
public class Statistic
{
public int Id { get; set; }
public string? ProjectId { get; set; }
public DateOnly Date { get; set; }
public int ActiveUsers { get; set; }
public int NewUsers { get; set; }
public int TotalDownloads { get; set; }
public long TotalDuration { get; set; }
public decimal Revenue { get; set; }
}

View File

@@ -0,0 +1,31 @@
using System.ComponentModel.DataAnnotations;
namespace License.Api.Models;
public class SystemConfig
{
public int Id { get; set; }
[MaxLength(50)]
public string ConfigKey { get; set; } = string.Empty;
public string? ConfigValue { get; set; }
[MaxLength(20)]
public string ValueType { get; set; } = "string";
[MaxLength(50)]
public string Category { get; set; } = "general";
[MaxLength(100)]
public string? DisplayName { get; set; }
[MaxLength(500)]
public string? Description { get; set; }
public string? Options { get; set; }
public bool IsPublic { get; set; }
public DateTime UpdatedAt { get; set; }
}

View File

@@ -0,0 +1,8 @@
namespace License.Api.Options;
public class HeartbeatOptions
{
public bool Enabled { get; set; } = true;
public int IntervalSeconds { get; set; } = 60;
public int TimeoutSeconds { get; set; } = 180;
}

View File

@@ -0,0 +1,10 @@
namespace License.Api.Options;
public class JwtOptions
{
public string Secret { get; set; } = string.Empty;
public string Issuer { get; set; } = string.Empty;
public int ExpireMinutes { get; set; } = 1440;
public int AdminExpireMinutes { get; set; } = 720;
public int AgentExpireMinutes { get; set; } = 1440;
}

View File

@@ -0,0 +1,9 @@
namespace License.Api.Options;
public class RateLimitOptions
{
public bool Enabled { get; set; } = true;
public int IpPerMinute { get; set; } = 100;
public int DevicePerMinute { get; set; } = 50;
public int BlockDurationMinutes { get; set; } = 5;
}

View File

@@ -0,0 +1,7 @@
namespace License.Api.Options;
public class RedisOptions
{
public string ConnectionString { get; set; } = string.Empty;
public bool Enabled { get; set; } = true;
}

View File

@@ -0,0 +1,7 @@
namespace License.Api.Options;
public class SecurityOptions
{
public bool SignatureEnabled { get; set; } = true;
public int TimestampToleranceSeconds { get; set; } = 300;
}

View File

@@ -0,0 +1,10 @@
namespace License.Api.Options;
public class SeedOptions
{
public string AdminUser { get; set; } = "admin";
public string AdminPassword { get; set; } = "admin123";
public string? AdminEmail { get; set; }
}

View File

@@ -0,0 +1,9 @@
namespace License.Api.Options;
public class StorageOptions
{
public string UploadRoot { get; set; } = "uploads";
public int MaxUploadMb { get; set; } = 200;
public string? ClientRsaPublicKeyPem { get; set; }
public bool RequireHttpsForDownloadKey { get; set; } = true;
}

View File

@@ -0,0 +1,165 @@
using System.Text;
using System.Text.Json.Serialization;
using System.IdentityModel.Tokens.Jwt;
using System.IO;
using License.Api.Data;
using License.Api.Middlewares;
using License.Api.Options;
using License.Api.Security;
using License.Api.Services;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using Serilog;
using StackExchange.Redis;
var builder = WebApplication.CreateBuilder(args);
JwtSecurityTokenHandler.DefaultMapInboundClaims = false;
builder.Host.UseSerilog((context, config) =>
config.ReadFrom.Configuration(context.Configuration));
builder.Services.Configure<JwtOptions>(builder.Configuration.GetSection("Jwt"));
builder.Services.Configure<SecurityOptions>(builder.Configuration.GetSection("Security"));
builder.Services.Configure<StorageOptions>(builder.Configuration.GetSection("Storage"));
builder.Services.Configure<RedisOptions>(builder.Configuration.GetSection("Redis"));
builder.Services.Configure<RateLimitOptions>(builder.Configuration.GetSection("RateLimit"));
builder.Services.Configure<HeartbeatOptions>(builder.Configuration.GetSection("Heartbeat"));
builder.Services.Configure<SeedOptions>(builder.Configuration.GetSection("Seed"));
builder.Services.AddDbContext<AppDbContext>(options =>
{
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"));
});
builder.Services.AddMemoryCache();
var dataProtectionPath = Path.Combine(builder.Environment.ContentRootPath, "data", "protection-keys");
Directory.CreateDirectory(dataProtectionPath);
builder.Services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo(dataProtectionPath))
.SetApplicationName("license-system");
builder.Services.AddSingleton<JwtTokenService>();
builder.Services.AddSingleton<HmacSignatureService>();
builder.Services.AddScoped<ConfigService>();
builder.Services.AddScoped<RiskControlService>();
builder.Services.AddScoped<AuthService>();
builder.Services.AddScoped<CardService>();
builder.Services.AddScoped<SoftwareService>();
builder.Services.AddScoped<SoftwareEncryptionService>();
builder.Services.AddScoped<FileStorageService>();
builder.Services.AddScoped<StatsService>();
builder.Services.AddScoped<IdempotencyService>();
builder.Services.AddScoped<AdminAccessService>();
builder.Services.AddScoped<DatabaseInitializer>();
builder.Services.AddHostedService<HeartbeatMonitorService>();
builder.Services.AddHostedService<StatsAggregationService>();
builder.Services.AddHostedService<MaintenanceService>();
var redisOptions = builder.Configuration.GetSection("Redis").Get<RedisOptions>() ?? new RedisOptions();
if (redisOptions.Enabled && !string.IsNullOrWhiteSpace(redisOptions.ConnectionString))
{
builder.Services.AddSingleton<IConnectionMultiplexer>(_ =>
ConnectionMultiplexer.Connect(redisOptions.ConnectionString));
}
builder.Services.AddScoped<IRateLimitStore>(sp =>
{
var multiplexer = sp.GetService<IConnectionMultiplexer>();
if (multiplexer != null)
return new RedisRateLimitStore(multiplexer);
return new MemoryRateLimitStore(sp.GetRequiredService<Microsoft.Extensions.Caching.Memory.IMemoryCache>());
});
var jwtOptions = builder.Configuration.GetSection("Jwt").Get<JwtOptions>() ?? new JwtOptions();
var key = Encoding.UTF8.GetBytes(jwtOptions.Secret);
builder.Services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = jwtOptions.Issuer,
ValidateAudience = false,
IssuerSigningKey = new SymmetricSecurityKey(key),
ValidateLifetime = true,
ClockSkew = TimeSpan.FromSeconds(30)
};
});
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("Admin", policy =>
policy.RequireClaim("type", "admin"));
options.AddPolicy("Agent", policy =>
policy.RequireClaim("type", "agent"));
options.AddPolicy("SuperAdmin", policy =>
policy.RequireClaim("type", "admin").RequireClaim("role", "super_admin"));
});
builder.Services.AddControllers().AddJsonOptions(options =>
{
options.JsonSerializerOptions.PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase;
options.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles;
});
builder.Services.AddCors(options =>
{
options.AddPolicy("AllowAll", policy =>
{
policy.AllowAnyHeader()
.AllowAnyMethod();
var allowAnyCors = builder.Configuration.GetValue<bool>("Cors:AllowAny");
var origins = builder.Configuration.GetSection("Cors:AllowedOrigins").Get<string[]>() ?? Array.Empty<string>();
origins = origins
.Where(origin => !string.IsNullOrWhiteSpace(origin))
.Select(origin => origin.Trim())
.ToArray();
if (allowAnyCors || builder.Environment.IsDevelopment())
{
policy.AllowAnyOrigin();
}
else if (origins.Length > 0)
{
policy.WithOrigins(origins);
}
});
});
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
app.UseSerilogRequestLogging();
app.UseMiddleware<ExceptionHandlingMiddleware>();
app.UseMiddleware<RateLimitMiddleware>();
app.UseSwagger();
app.UseSwaggerUI();
app.UseCors("AllowAll");
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
using (var scope = app.Services.CreateScope())
{
var initializer = scope.ServiceProvider.GetRequiredService<DatabaseInitializer>();
await initializer.InitializeAsync();
}
app.Run();

View File

@@ -0,0 +1,40 @@
using System.Security.Cryptography;
using System.Text;
using License.Api.Options;
using Microsoft.Extensions.Options;
namespace License.Api.Security;
public class HmacSignatureService
{
private readonly SecurityOptions _options;
public HmacSignatureService(IOptions<SecurityOptions> options)
{
_options = options.Value;
}
public bool IsEnabled => _options.SignatureEnabled;
public bool ValidateTimestamp(long timestamp)
{
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
return Math.Abs(now - timestamp) <= _options.TimestampToleranceSeconds;
}
public string Sign(string payload, string secret)
{
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(payload));
return Convert.ToHexString(hash).ToLowerInvariant();
}
public bool Verify(string payload, string signature, string secret)
{
if (string.IsNullOrWhiteSpace(signature))
return false;
var expected = Sign(payload, secret);
return string.Equals(signature, expected, StringComparison.OrdinalIgnoreCase);
}
}

View File

@@ -0,0 +1,100 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using License.Api.Models;
using License.Api.Options;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
namespace License.Api.Security;
public class JwtTokenService
{
private readonly JwtOptions _options;
public JwtTokenService(IOptions<JwtOptions> options)
{
_options = options.Value;
}
public string CreateAdminToken(Admin admin)
{
var claims = new List<Claim>
{
new(JwtRegisteredClaimNames.Sub, admin.Id.ToString()),
new("role", admin.Role),
new("type", "admin"),
new("username", admin.Username)
};
if (!string.IsNullOrWhiteSpace(admin.Permissions))
claims.Add(new Claim("permissions", admin.Permissions));
return CreateToken(claims, _options.AdminExpireMinutes);
}
public string CreateAgentToken(Agent agent)
{
var claims = new List<Claim>
{
new(JwtRegisteredClaimNames.Sub, agent.Id.ToString()),
new("role", "agent"),
new("type", "agent"),
new("agentCode", agent.AgentCode)
};
return CreateToken(claims, _options.AgentExpireMinutes);
}
public string CreateCardToken(CardKey cardKey, string deviceId)
{
var claims = new List<Claim>
{
new(JwtRegisteredClaimNames.Sub, cardKey.Id.ToString()),
new("type", "card"),
new("projectId", cardKey.ProjectId ?? string.Empty),
new("deviceId", deviceId)
};
return CreateToken(claims, _options.ExpireMinutes);
}
public ClaimsPrincipal? ValidateToken(string token)
{
var tokenHandler = new JwtSecurityTokenHandler();
var key = Encoding.UTF8.GetBytes(_options.Secret);
try
{
var principal = tokenHandler.ValidateToken(token, new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = _options.Issuer,
ValidateAudience = false,
IssuerSigningKey = new SymmetricSecurityKey(key),
ValidateLifetime = true,
ClockSkew = TimeSpan.FromSeconds(30)
}, out _);
return principal;
}
catch
{
return null;
}
}
private string CreateToken(IEnumerable<Claim> claims, int expireMinutes)
{
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.Secret));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
issuer: _options.Issuer,
audience: null,
claims: claims,
expires: DateTime.UtcNow.AddMinutes(expireMinutes),
signingCredentials: creds);
return new JwtSecurityTokenHandler().WriteToken(token);
}
}

View File

@@ -0,0 +1,115 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text.Json;
using License.Api.Data;
using License.Api.Models;
using Microsoft.EntityFrameworkCore;
namespace License.Api.Services;
public class AdminScope
{
public Admin Admin { get; }
public bool IsSuperAdmin { get; }
public bool HasAllProjects { get; }
public HashSet<string> AllowedProjects { get; }
public AdminScope(Admin admin, bool isSuperAdmin, bool hasAllProjects, HashSet<string> allowedProjects)
{
Admin = admin;
IsSuperAdmin = isSuperAdmin;
HasAllProjects = hasAllProjects;
AllowedProjects = allowedProjects;
}
public bool CanAccessProject(string? projectId)
{
if (string.IsNullOrWhiteSpace(projectId))
return false;
if (IsSuperAdmin || HasAllProjects)
return true;
return AllowedProjects.Contains(projectId);
}
public void AddProject(string projectId)
{
if (IsSuperAdmin || HasAllProjects)
return;
if (!string.IsNullOrWhiteSpace(projectId))
AllowedProjects.Add(projectId);
}
public string? SerializePermissions()
{
if (IsSuperAdmin || HasAllProjects)
return Admin.Permissions;
return JsonSerializer.Serialize(AllowedProjects.OrderBy(p => p).ToList());
}
}
public class AdminAccessService
{
private readonly AppDbContext _db;
public AdminAccessService(AppDbContext db)
{
_db = db;
}
public async Task<AdminScope?> GetScopeAsync(ClaimsPrincipal user)
{
var sub = user.FindFirst(JwtRegisteredClaimNames.Sub)?.Value;
if (!int.TryParse(sub, out var adminId))
return null;
var admin = await _db.Admins.FirstOrDefaultAsync(a => a.Id == adminId);
if (admin == null)
return null;
var isSuperAdmin = string.Equals(admin.Role, "super_admin", StringComparison.OrdinalIgnoreCase);
var (hasAllProjects, allowedProjects) = ParsePermissions(admin.Permissions);
return new AdminScope(admin, isSuperAdmin, hasAllProjects, allowedProjects);
}
public static (bool hasAllProjects, HashSet<string> allowedProjects) ParsePermissions(string? raw)
{
if (string.IsNullOrWhiteSpace(raw))
return (false, new HashSet<string>(StringComparer.OrdinalIgnoreCase));
var trimmed = raw.Trim();
if (trimmed == "*")
return (true, new HashSet<string>(StringComparer.OrdinalIgnoreCase));
List<string>? items = null;
if (trimmed.StartsWith("[", StringComparison.Ordinal))
{
try
{
items = JsonSerializer.Deserialize<List<string>>(trimmed);
}
catch
{
items = null;
}
}
if (items == null)
{
items = trimmed
.Split(new[] { ',', ';', '\n', '\r', '\t' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.ToList();
}
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var item in items)
{
if (string.IsNullOrWhiteSpace(item))
continue;
if (item == "*")
return (true, new HashSet<string>(StringComparer.OrdinalIgnoreCase));
set.Add(item);
}
return (false, set);
}
}

View File

@@ -0,0 +1,357 @@
using System.IdentityModel.Tokens.Jwt;
using License.Api.Data;
using License.Api.DTOs;
using License.Api.Models;
using License.Api.Options;
using License.Api.Security;
using License.Api.Utils;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace License.Api.Services;
public class AuthService
{
private readonly AppDbContext _db;
private readonly JwtTokenService _jwt;
private readonly HmacSignatureService _hmac;
private readonly HeartbeatOptions _heartbeat;
private readonly ConfigService _config;
private readonly RiskControlService _risk;
public AuthService(
AppDbContext db,
JwtTokenService jwt,
HmacSignatureService hmac,
IOptions<HeartbeatOptions> heartbeat,
ConfigService config,
RiskControlService risk)
{
_db = db;
_jwt = jwt;
_hmac = hmac;
_heartbeat = heartbeat.Value;
_config = config;
_risk = risk;
}
public async Task<(ApiResponse<AuthVerifyResponse> response, int httpStatus)> VerifyAsync(AuthVerifyRequest request, HttpContext httpContext)
{
if (!_hmac.ValidateTimestamp(request.Timestamp))
return (ApiResponse<AuthVerifyResponse>.Fail(1008, "timestamp_expired"), StatusCodes.Status400BadRequest);
var project = await _db.Projects.FirstOrDefaultAsync(p => p.ProjectId == request.ProjectId);
if (project == null)
return (ApiResponse<AuthVerifyResponse>.Fail(1001, "card_invalid"), StatusCodes.Status400BadRequest);
if (!project.IsEnabled)
return (ApiResponse<AuthVerifyResponse>.Fail(1011, "project_disabled"), StatusCodes.Status403Forbidden);
if (_hmac.IsEnabled)
{
var payload = $"{project.ProjectId}|{request.DeviceId}|{request.Timestamp}";
var valid = _hmac.Verify(payload, request.Signature, project.ProjectSecret);
if (!valid && !string.IsNullOrWhiteSpace(project.ProjectKey))
valid = _hmac.Verify(payload, request.Signature, project.ProjectKey);
if (!valid)
return (ApiResponse<AuthVerifyResponse>.Fail(1007, "signature_invalid"), StatusCodes.Status403Forbidden);
}
var card = await _db.CardKeys
.FirstOrDefaultAsync(c => c.ProjectId == project.ProjectId && c.KeyCode == request.KeyCode && c.DeletedAt == null);
if (card == null)
return (ApiResponse<AuthVerifyResponse>.Fail(1001, "card_invalid"), StatusCodes.Status400BadRequest);
if (card.Status == "banned")
return (ApiResponse<AuthVerifyResponse>.Fail(1003, "card_banned"), StatusCodes.Status403Forbidden);
if (string.Equals(card.CardType, "test", StringComparison.OrdinalIgnoreCase)
&& card.LastUsedAt.HasValue)
return (ApiResponse<AuthVerifyResponse>.Fail(1002, "card_expired"), StatusCodes.Status403Forbidden);
if (card.DurationDays <= 0 && !string.Equals(card.CardType, "lifetime", StringComparison.OrdinalIgnoreCase))
{
var resolvedDays = 0;
if (CardKeyGenerator.TryDecode(card.KeyCode, out _, out var decodedDays) && decodedDays > 0)
resolvedDays = decodedDays;
if (resolvedDays <= 0)
resolvedDays = CardDefaults.ResolveDurationDays(card.CardType);
if (resolvedDays > 0)
card.DurationDays = resolvedDays;
}
var expireType = await _config.GetValueAsync("auth.expire_type") ?? "activate";
if (string.Equals(expireType, "fix", StringComparison.OrdinalIgnoreCase)
&& !card.ExpireTime.HasValue
&& !string.Equals(card.CardType, "lifetime", StringComparison.OrdinalIgnoreCase))
{
card.ExpireTime = card.CreatedAt.AddDays(card.DurationDays);
}
if (card.Status == "expired" || (card.ExpireTime.HasValue && card.ExpireTime <= DateTime.UtcNow))
{
card.Status = "expired";
await _db.SaveChangesAsync();
return (ApiResponse<AuthVerifyResponse>.Fail(1002, "card_expired"), StatusCodes.Status403Forbidden);
}
var trialMode = await _config.GetBoolAsync("feature.trial_mode", false);
var trialDays = await _config.GetIntAsync("trial.days", 3);
var deviceBindEnabled = await _config.GetBoolAsync("feature.device_bind", true);
var maxDevicesConfig = await _config.GetIntAsync("auth.max_devices", 1);
var allowMultiDevice = await _config.GetBoolAsync("auth.allow_multi_device", false);
var heartbeatEnabled = await _config.GetBoolAsync("feature.heartbeat", true);
var autoUpdateEnabled = (await _config.GetBoolAsync("feature.auto_update", true)) && project.AutoUpdate;
var heartbeatInterval = await _config.GetIntAsync("heartbeat.interval", _heartbeat.IntervalSeconds);
var riskDecision = await _risk.CheckVerifyAsync(project, card, request, httpContext);
if (riskDecision != null && riskDecision.Blocked)
{
await LogAccessAsync(project.ProjectId, card.Id, request.DeviceId, "verify", httpContext, riskDecision.HttpStatus);
return (ApiResponse<AuthVerifyResponse>.Fail(riskDecision.Code, riskDecision.Message), riskDecision.HttpStatus);
}
var activatedNow = false;
if (card.Status == "unused")
{
card.Status = "active";
card.ActivateTime = DateTime.UtcNow;
activatedNow = true;
if (!card.ExpireTime.HasValue && !string.Equals(card.CardType, "lifetime", StringComparison.OrdinalIgnoreCase))
{
if (trialMode)
{
var days = Math.Max(1, Math.Min(trialDays, card.DurationDays));
card.ExpireTime = DateTime.UtcNow.AddDays(days);
}
else if (string.Equals(expireType, "fix", StringComparison.OrdinalIgnoreCase))
{
card.ExpireTime = card.CreatedAt.AddDays(card.DurationDays);
}
else
{
card.ExpireTime = DateTime.UtcNow.AddDays(card.DurationDays);
}
}
}
var maxDevices = card.MaxDevices > 0 ? card.MaxDevices : project.MaxDevices;
if (maxDevices <= 0)
maxDevices = maxDevicesConfig;
if (!deviceBindEnabled)
maxDevices = int.MaxValue;
if (deviceBindEnabled)
{
if (!string.IsNullOrWhiteSpace(card.MachineCode)
&& !string.Equals(card.MachineCode, request.DeviceId, StringComparison.OrdinalIgnoreCase))
return (ApiResponse<AuthVerifyResponse>.Fail(1005, "device_limit_exceeded"), StatusCodes.Status403Forbidden);
if (string.IsNullOrWhiteSpace(card.MachineCode))
card.MachineCode = request.DeviceId;
}
var device = await _db.Devices.FirstOrDefaultAsync(d => d.CardKeyId == card.Id && d.DeviceId == request.DeviceId && d.DeletedAt == null);
if (device == null)
{
var activeDevices = await _db.Devices
.Where(d => d.CardKeyId == card.Id && d.IsActive && d.DeletedAt == null)
.OrderBy(d => d.LastHeartbeat ?? d.FirstLoginAt)
.ToListAsync();
if (!allowMultiDevice && activeDevices.Count > 0)
{
foreach (var other in activeDevices)
other.IsActive = false;
}
else if (allowMultiDevice && activeDevices.Count >= maxDevices)
{
var oldest = activeDevices.FirstOrDefault();
if (oldest != null)
oldest.IsActive = false;
}
else if (activeDevices.Count >= maxDevices)
{
await LogAccessAsync(project.ProjectId, card.Id, request.DeviceId, "verify", httpContext, 403);
return (ApiResponse<AuthVerifyResponse>.Fail(1005, "device_limit_exceeded"), StatusCodes.Status403Forbidden);
}
device = new Device
{
CardKeyId = card.Id,
DeviceId = request.DeviceId,
LastHeartbeat = DateTime.UtcNow,
IpAddress = httpContext.Connection.RemoteIpAddress?.ToString(),
FirstLoginAt = DateTime.UtcNow,
IsActive = true
};
_db.Devices.Add(device);
}
else
{
device.LastHeartbeat = DateTime.UtcNow;
device.IpAddress = httpContext.Connection.RemoteIpAddress?.ToString();
device.IsActive = true;
}
card.LastUsedAt = DateTime.UtcNow;
if (activatedNow)
{
_db.CardKeyLogs.Add(new CardKeyLog
{
CardKeyId = card.Id,
Action = "activate",
OperatorType = "system",
Details = $"deviceId={request.DeviceId};trial={trialMode}",
CreatedAt = DateTime.UtcNow
});
}
await _db.SaveChangesAsync();
if (!string.IsNullOrWhiteSpace(request.ClientVersion) && !Version.TryParse(request.ClientVersion, out _))
{
await LogAccessAsync(project.ProjectId, card.Id, request.DeviceId, "verify", httpContext, StatusCodes.Status400BadRequest);
return (ApiResponse<AuthVerifyResponse>.Fail(1010, "invalid_version"), StatusCodes.Status400BadRequest);
}
var latestVersion = await _db.SoftwareVersions
.Where(v => v.ProjectId == project.ProjectId)
.OrderByDescending(v => v.PublishedAt)
.FirstOrDefaultAsync();
if (!string.IsNullOrWhiteSpace(request.ClientVersion) && latestVersion != null && latestVersion.IsForceUpdate)
{
var compare = VersionComparer.Compare(request.ClientVersion, latestVersion.Version);
if (compare < 0)
{
await LogAccessAsync(project.ProjectId, card.Id, request.DeviceId, "verify", httpContext, StatusCodes.Status426UpgradeRequired);
return (ApiResponse<AuthVerifyResponse>.Fail(1012, "force_update_required"), StatusCodes.Status426UpgradeRequired);
}
}
var token = _jwt.CreateCardToken(card, request.DeviceId);
var response = new AuthVerifyResponse
{
Valid = true,
ExpireTime = card.ExpireTime,
RemainingDays = card.ExpireTime.HasValue ? (int)Math.Max(0, (card.ExpireTime.Value - DateTime.UtcNow).TotalDays) : 99999,
DownloadUrl = autoUpdateEnabled && latestVersion != null ? $"/api/software/download?version={latestVersion.Version}&token={token}" : null,
FileHash = autoUpdateEnabled ? latestVersion?.FileHash : null,
Version = autoUpdateEnabled ? latestVersion?.Version : null,
HeartbeatInterval = heartbeatEnabled ? heartbeatInterval : 0,
AccessToken = token
};
await LogAccessAsync(project.ProjectId, card.Id, request.DeviceId, "verify", httpContext, 200);
return (ApiResponse<AuthVerifyResponse>.Ok(response), StatusCodes.Status200OK);
}
public async Task<(ApiResponse<AuthHeartbeatResponse> response, int httpStatus)> HeartbeatAsync(AuthHeartbeatRequest request, HttpContext httpContext)
{
var heartbeatEnabled = await _config.GetBoolAsync("heartbeat.enabled", _heartbeat.Enabled);
if (!_hmac.ValidateTimestamp(request.Timestamp))
return (ApiResponse<AuthHeartbeatResponse>.Fail(1008, "timestamp_expired"), StatusCodes.Status400BadRequest);
var principal = _jwt.ValidateToken(request.AccessToken);
if (principal == null)
return (ApiResponse<AuthHeartbeatResponse>.Fail(401, "unauthorized"), StatusCodes.Status401Unauthorized);
var type = principal.Claims.FirstOrDefault(c => c.Type == "type")?.Value;
if (!string.Equals(type, "card", StringComparison.OrdinalIgnoreCase))
return (ApiResponse<AuthHeartbeatResponse>.Fail(401, "unauthorized"), StatusCodes.Status401Unauthorized);
var projectId = principal.Claims.FirstOrDefault(c => c.Type == "projectId")?.Value;
var cardIdStr = principal.Claims.FirstOrDefault(c => c.Type == System.IdentityModel.Tokens.Jwt.JwtRegisteredClaimNames.Sub)?.Value;
if (!int.TryParse(cardIdStr, out var cardId))
return (ApiResponse<AuthHeartbeatResponse>.Fail(401, "unauthorized"), StatusCodes.Status401Unauthorized);
var card = await _db.CardKeys.FirstOrDefaultAsync(c => c.Id == cardId && c.DeletedAt == null);
if (card == null || card.Status == "banned")
return (ApiResponse<AuthHeartbeatResponse>.Fail(1003, "card_banned"), StatusCodes.Status403Forbidden);
if (card.ExpireTime.HasValue && card.ExpireTime <= DateTime.UtcNow)
{
card.Status = "expired";
await _db.SaveChangesAsync();
return (ApiResponse<AuthHeartbeatResponse>.Fail(1002, "card_expired"), StatusCodes.Status403Forbidden);
}
Device? device = null;
if (heartbeatEnabled)
{
device = await _db.Devices.FirstOrDefaultAsync(d => d.CardKeyId == card.Id && d.DeviceId == request.DeviceId && d.DeletedAt == null);
if (device == null)
return (ApiResponse<AuthHeartbeatResponse>.Fail(1006, "device_not_found"), StatusCodes.Status404NotFound);
}
if (_hmac.IsEnabled)
{
var payload = $"{projectId}|{request.DeviceId}|{request.Timestamp}";
var project = await _db.Projects.FirstOrDefaultAsync(p => p.ProjectId == projectId);
if (project == null)
return (ApiResponse<AuthHeartbeatResponse>.Fail(1007, "signature_invalid"), StatusCodes.Status403Forbidden);
var valid = _hmac.Verify(payload, request.Signature, project.ProjectSecret);
if (!valid && !string.IsNullOrWhiteSpace(project.ProjectKey))
valid = _hmac.Verify(payload, request.Signature, project.ProjectKey);
if (!valid)
return (ApiResponse<AuthHeartbeatResponse>.Fail(1007, "signature_invalid"), StatusCodes.Status403Forbidden);
}
if (heartbeatEnabled && device != null)
{
if (device.LastHeartbeat.HasValue)
{
var delta = DateTime.UtcNow - device.LastHeartbeat.Value;
if (delta.TotalSeconds > 0)
card.UsedDuration += (long)delta.TotalSeconds;
}
device.LastHeartbeat = DateTime.UtcNow;
device.IpAddress = httpContext.Connection.RemoteIpAddress?.ToString();
}
card.LastUsedAt = DateTime.UtcNow;
await _db.SaveChangesAsync();
await LogAccessAsync(projectId ?? card.ProjectId ?? string.Empty, card.Id, request.DeviceId, "heartbeat", httpContext, 200);
if (heartbeatEnabled)
{
var ip = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
await _risk.CheckHeartbeatAsync(card, request.DeviceId, ip);
}
var response = new AuthHeartbeatResponse
{
Valid = true,
RemainingDays = card.ExpireTime.HasValue ? (int)Math.Max(0, (card.ExpireTime.Value - DateTime.UtcNow).TotalDays) : 99999,
ServerTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds()
};
return (ApiResponse<AuthHeartbeatResponse>.Ok(response), StatusCodes.Status200OK);
}
private async Task LogAccessAsync(string projectId, int? cardKeyId, string? deviceId, string action, HttpContext context, int responseCode)
{
var log = new AccessLog
{
ProjectId = projectId,
CardKeyId = cardKeyId,
DeviceId = deviceId,
Action = action,
IpAddress = context.Connection.RemoteIpAddress?.ToString(),
UserAgent = context.Request.Headers.UserAgent.ToString(),
ResponseCode = responseCode,
CreatedAt = DateTime.UtcNow
};
_db.AccessLogs.Add(log);
await _db.SaveChangesAsync();
}
}

View File

@@ -0,0 +1,168 @@
using License.Api.Data;
using License.Api.DTOs;
using License.Api.Models;
using License.Api.Utils;
using Microsoft.EntityFrameworkCore;
namespace License.Api.Services;
public class CardService
{
private readonly AppDbContext _db;
private readonly ConfigService _config;
public CardService(AppDbContext db, ConfigService config)
{
_db = db;
_config = config;
}
public async Task<CardGenerateResponse> GenerateAsync(CardGenerateRequest request, int? operatorId, int? agentId = null, decimal? soldPrice = null, string operatorType = "admin")
{
var needActivate = await _config.GetBoolAsync("auth.need_activate", true);
var expireType = await _config.GetValueAsync("auth.expire_type") ?? "activate";
var trialMode = await _config.GetBoolAsync("feature.trial_mode", false);
var trialDays = await _config.GetIntAsync("trial.days", 3);
var batchSuffix = Guid.NewGuid().ToString("N")[..8];
var batchId = $"batch_{DateTime.UtcNow:yyyyMMddHHmmss}_{batchSuffix}";
var keys = new List<string>();
for (var i = 0; i < request.Quantity; i++)
{
string key;
do
{
key = CardKeyGenerator.Generate(MapCardType(request.CardType), request.DurationDays);
} while (await _db.CardKeys.AnyAsync(k => k.KeyCode == key));
keys.Add(key);
}
var entities = keys.Select(key =>
{
var createdAt = DateTime.UtcNow;
var expireTime = (DateTime?)null;
if (!string.Equals(request.CardType, "lifetime", StringComparison.OrdinalIgnoreCase))
{
if (trialMode && !needActivate)
{
var days = Math.Max(1, Math.Min(trialDays, request.DurationDays));
expireTime = createdAt.AddDays(days);
}
else if (!needActivate || string.Equals(expireType, "fix", StringComparison.OrdinalIgnoreCase))
{
expireTime = createdAt.AddDays(request.DurationDays);
}
}
return new CardKey
{
ProjectId = request.ProjectId,
KeyCode = key,
CardType = request.CardType,
DurationDays = request.DurationDays,
Status = needActivate ? "unused" : "active",
ActivateTime = needActivate ? null : createdAt,
ExpireTime = expireTime,
Note = request.Note,
BatchId = batchId,
CreatedAt = createdAt,
GeneratedBy = operatorType == "admin" ? operatorId : null,
AgentId = agentId,
SoldPrice = soldPrice
};
}).ToList();
await _db.CardKeys.AddRangeAsync(entities);
await _db.SaveChangesAsync();
var logs = entities.Select(card => new CardKeyLog
{
CardKeyId = card.Id,
Action = "create",
OperatorId = operatorId,
OperatorType = operatorType,
Details = $"batchId={batchId}",
CreatedAt = DateTime.UtcNow
}).ToList();
await _db.CardKeyLogs.AddRangeAsync(logs);
await _db.SaveChangesAsync();
return new CardGenerateResponse
{
BatchId = batchId,
Keys = keys,
Count = keys.Count
};
}
public async Task BanAsync(CardKey card, string? reason, int? operatorId, string operatorType)
{
card.Status = "banned";
await _db.SaveChangesAsync();
await LogAsync(card.Id, "ban", operatorId, operatorType, reason);
}
public async Task UnbanAsync(CardKey card, int? operatorId, string operatorType)
{
card.Status = "active";
await _db.SaveChangesAsync();
await LogAsync(card.Id, "unban", operatorId, operatorType, null);
}
public async Task ExtendAsync(CardKey card, int days, int? operatorId, string operatorType)
{
if (!card.ExpireTime.HasValue)
card.ExpireTime = DateTime.UtcNow.AddDays(days);
else
card.ExpireTime = card.ExpireTime.Value.AddDays(days);
await _db.SaveChangesAsync();
await LogAsync(card.Id, "extend", operatorId, operatorType, $"days={days}");
}
public async Task ResetDeviceAsync(CardKey card, int? operatorId, string operatorType)
{
card.MachineCode = null;
var devices = await _db.Devices
.Where(d => d.CardKeyId == card.Id && d.DeletedAt == null)
.ToListAsync();
foreach (var device in devices)
{
device.IsActive = false;
device.DeletedAt = DateTime.UtcNow;
}
await _db.SaveChangesAsync();
await LogAsync(card.Id, "reset_device", operatorId, operatorType, null);
}
private async Task LogAsync(int cardId, string action, int? operatorId, string operatorType, string? details)
{
_db.CardKeyLogs.Add(new CardKeyLog
{
CardKeyId = cardId,
Action = action,
OperatorId = operatorId,
OperatorType = operatorType,
Details = details,
CreatedAt = DateTime.UtcNow
});
await _db.SaveChangesAsync();
}
private static byte MapCardType(string cardType)
{
return cardType.ToLowerInvariant() switch
{
"test" => 6,
"day" => 1,
"week" => 2,
"month" => 3,
"year" => 4,
"lifetime" => 5,
_ => 0
};
}
}

View File

@@ -0,0 +1,72 @@
using License.Api.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
namespace License.Api.Services;
public class ConfigService
{
private readonly AppDbContext _db;
private readonly IMemoryCache _cache;
private static readonly TimeSpan CacheTtl = TimeSpan.FromMinutes(1);
public ConfigService(AppDbContext db, IMemoryCache cache)
{
_db = db;
_cache = cache;
}
public async Task<string?> GetValueAsync(string key)
{
if (_cache.TryGetValue<string?>(key, out var cached))
return cached;
var config = await _db.SystemConfigs
.AsNoTracking()
.FirstOrDefaultAsync(c => c.ConfigKey == key);
var value = config?.ConfigValue;
_cache.Set(key, value, CacheTtl);
return value;
}
public async Task<bool> GetBoolAsync(string key, bool defaultValue)
{
var value = await GetValueAsync(key);
if (string.IsNullOrWhiteSpace(value))
return defaultValue;
if (bool.TryParse(value, out var parsed))
return parsed;
return defaultValue;
}
public async Task<int> GetIntAsync(string key, int defaultValue)
{
var value = await GetValueAsync(key);
if (string.IsNullOrWhiteSpace(value))
return defaultValue;
if (int.TryParse(value, out var parsed))
return parsed;
return defaultValue;
}
public async Task<decimal> GetDecimalAsync(string key, decimal defaultValue)
{
var value = await GetValueAsync(key);
if (string.IsNullOrWhiteSpace(value))
return defaultValue;
if (decimal.TryParse(value, out var parsed))
return parsed;
return defaultValue;
}
public void Invalidate(string key)
{
_cache.Remove(key);
}
}

View File

@@ -0,0 +1,51 @@
using License.Api.Options;
using Microsoft.Extensions.Options;
namespace License.Api.Services;
public class FileStorageService
{
private readonly StorageOptions _options;
private readonly IWebHostEnvironment _env;
public FileStorageService(IOptions<StorageOptions> options, IWebHostEnvironment env)
{
_options = options.Value;
_env = env;
}
public string GetUploadRoot()
{
var root = _options.UploadRoot;
if (Path.IsPathRooted(root))
return root;
return Path.Combine(_env.ContentRootPath, root);
}
public async Task<string> SaveAsync(string projectId, string version, byte[] content)
{
var root = GetUploadRoot();
var folder = Path.Combine(root, projectId);
Directory.CreateDirectory(folder);
var fileName = $"{version}_{DateTime.UtcNow:yyyyMMddHHmmss}.bin";
var path = Path.Combine(folder, fileName);
await File.WriteAllBytesAsync(path, content);
return path;
}
public async Task<string> SaveAsync(string projectId, string version, Stream stream)
{
var root = GetUploadRoot();
var folder = Path.Combine(root, projectId);
Directory.CreateDirectory(folder);
var fileName = $"{version}_{DateTime.UtcNow:yyyyMMddHHmmss}.bin";
var path = Path.Combine(folder, fileName);
await using var fs = File.Create(path);
await stream.CopyToAsync(fs);
return path;
}
}

View File

@@ -0,0 +1,58 @@
using License.Api.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace License.Api.Services;
public class HeartbeatMonitorService : BackgroundService
{
private readonly IServiceProvider _provider;
private readonly ILogger<HeartbeatMonitorService> _logger;
public HeartbeatMonitorService(IServiceProvider provider, ILogger<HeartbeatMonitorService> logger)
{
_provider = provider;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
using var scope = _provider.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var config = scope.ServiceProvider.GetRequiredService<ConfigService>();
var enabled = await config.GetBoolAsync("heartbeat.enabled", true);
if (enabled)
{
var timeoutSeconds = await config.GetIntAsync("heartbeat.timeout", 180);
if (timeoutSeconds > 0)
{
var cutoff = DateTime.UtcNow.AddSeconds(-timeoutSeconds);
var devices = await db.Devices
.Where(d => d.IsActive && d.DeletedAt == null && d.LastHeartbeat != null && d.LastHeartbeat < cutoff)
.ToListAsync(stoppingToken);
if (devices.Count > 0)
{
foreach (var device in devices)
device.IsActive = false;
await db.SaveChangesAsync(stoppingToken);
}
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Heartbeat monitor failed");
}
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
}
}
}

View File

@@ -0,0 +1,49 @@
using System.Security.Cryptography;
using System.Text;
using License.Api.Data;
using License.Api.Models;
using Microsoft.EntityFrameworkCore;
namespace License.Api.Services;
public class IdempotencyService
{
private readonly AppDbContext _db;
public IdempotencyService(AppDbContext db)
{
_db = db;
}
public async Task<IdempotencyKeyRecord?> GetAsync(string key)
{
return await _db.IdempotencyKeys
.FirstOrDefaultAsync(x => x.IdempotencyKey == key && x.ExpiresAt > DateTime.UtcNow);
}
public async Task<IdempotencyKeyRecord> StoreAsync(string key, string path, string requestBodyHash, int responseCode, string responseBody)
{
var record = new IdempotencyKeyRecord
{
IdempotencyKey = key,
RequestPath = path,
RequestHash = requestBodyHash,
ResponseCode = responseCode,
ResponseBody = responseBody,
CreatedAt = DateTime.UtcNow,
ExpiresAt = DateTime.UtcNow.AddHours(24)
};
_db.IdempotencyKeys.Add(record);
await _db.SaveChangesAsync();
return record;
}
public static string ComputeRequestHash(string? body)
{
if (string.IsNullOrWhiteSpace(body))
return string.Empty;
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(body));
return Convert.ToHexString(hash).ToLowerInvariant();
}
}

View File

@@ -0,0 +1,57 @@
using License.Api.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace License.Api.Services;
public class MaintenanceService : BackgroundService
{
private readonly IServiceProvider _provider;
private readonly ILogger<MaintenanceService> _logger;
public MaintenanceService(IServiceProvider provider, ILogger<MaintenanceService> logger)
{
_provider = provider;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
using var scope = _provider.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var config = scope.ServiceProvider.GetRequiredService<ConfigService>();
var retentionDays = await config.GetIntAsync("log.retention_days", 90);
var logCutoff = DateTime.UtcNow.AddDays(-retentionDays);
var deletedCutoff = DateTime.UtcNow.AddDays(-30);
await db.IdempotencyKeys
.Where(k => k.ExpiresAt < DateTime.UtcNow)
.ExecuteDeleteAsync(stoppingToken);
await db.CardKeys
.Where(k => k.DeletedAt != null && k.DeletedAt < deletedCutoff)
.ExecuteDeleteAsync(stoppingToken);
await db.AccessLogs
.Where(l => l.CreatedAt < logCutoff)
.ExecuteDeleteAsync(stoppingToken);
await db.CardKeyLogs
.Where(l => l.CreatedAt < logCutoff)
.ExecuteDeleteAsync(stoppingToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Maintenance cleanup failed");
}
await Task.Delay(TimeSpan.FromHours(6), stoppingToken);
}
}
}

View File

@@ -0,0 +1,72 @@
using Microsoft.Extensions.Caching.Memory;
using StackExchange.Redis;
namespace License.Api.Services;
public interface IRateLimitStore
{
Task<long> IncrementAsync(string key, TimeSpan ttl);
Task<bool> ExistsAsync(string key);
Task SetAsync(string key, TimeSpan ttl);
}
public class MemoryRateLimitStore : IRateLimitStore
{
private readonly IMemoryCache _cache;
private readonly object _lock = new();
public MemoryRateLimitStore(IMemoryCache cache)
{
_cache = cache;
}
public Task<long> IncrementAsync(string key, TimeSpan ttl)
{
lock (_lock)
{
if (!_cache.TryGetValue<long>(key, out var count))
{
count = 0;
}
count++;
_cache.Set(key, count, ttl);
return Task.FromResult(count);
}
}
public Task<bool> ExistsAsync(string key)
{
var exists = _cache.TryGetValue(key, out _);
return Task.FromResult(exists);
}
public Task SetAsync(string key, TimeSpan ttl)
{
_cache.Set(key, true, ttl);
return Task.CompletedTask;
}
}
public class RedisRateLimitStore : IRateLimitStore
{
private readonly IDatabase _db;
public RedisRateLimitStore(IConnectionMultiplexer multiplexer)
{
_db = multiplexer.GetDatabase();
}
public async Task<long> IncrementAsync(string key, TimeSpan ttl)
{
var count = await _db.StringIncrementAsync(key);
if (count == 1)
await _db.KeyExpireAsync(key, ttl);
return count;
}
public Task<bool> ExistsAsync(string key)
=> _db.KeyExistsAsync(key);
public Task SetAsync(string key, TimeSpan ttl)
=> _db.StringSetAsync(key, "1", ttl);
}

View File

@@ -0,0 +1,247 @@
using License.Api.Data;
using License.Api.DTOs;
using License.Api.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.Http;
namespace License.Api.Services;
public class RiskDecision
{
public bool Blocked { get; set; }
public int Code { get; set; }
public string Message { get; set; } = "forbidden";
public int HttpStatus { get; set; } = StatusCodes.Status403Forbidden;
}
public class RiskControlService
{
private readonly AppDbContext _db;
private readonly ConfigService _config;
private readonly IRateLimitStore _store;
public RiskControlService(AppDbContext db, ConfigService config, IRateLimitStore store)
{
_db = db;
_config = config;
_store = store;
}
public async Task<RiskDecision?> CheckVerifyAsync(Project project, CardKey card, AuthVerifyRequest request, HttpContext context)
{
var enabled = await _config.GetBoolAsync("risk.enabled", true);
if (!enabled)
return null;
var ip = context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
var bruteCount = await _store.IncrementAsync($"risk:bruteforce:{ip}:{DateTime.UtcNow:yyyyMMddHH}", TimeSpan.FromHours(1));
if (bruteCount > 100)
{
return new RiskDecision
{
Blocked = true,
Code = 1009,
Message = "rate_limit_exceeded",
HttpStatus = StatusCodes.Status429TooManyRequests
};
}
if (!string.IsNullOrWhiteSpace(request.DeviceId))
{
var deviceCount = await _store.IncrementAsync(
$"risk:device:attempts:{request.DeviceId}:{DateTime.UtcNow:yyyyMMdd}",
TimeSpan.FromDays(1));
if (deviceCount > 10)
{
return new RiskDecision
{
Blocked = true,
Code = 1009,
Message = "rate_limit_exceeded",
HttpStatus = StatusCodes.Status429TooManyRequests
};
}
}
var checkDeviceChange = await _config.GetBoolAsync("risk.check_device_change", true);
if (checkDeviceChange && !string.IsNullOrWhiteSpace(card.MachineCode)
&& !string.Equals(card.MachineCode, request.DeviceId, StringComparison.OrdinalIgnoreCase))
{
await AddRiskLogAsync(card.Id, "risk_device_change", $"old={card.MachineCode};new={request.DeviceId}", ip);
var autoBan = await _config.GetBoolAsync("risk.auto_ban", false);
if (autoBan)
{
card.Status = "banned";
await _db.SaveChangesAsync();
return new RiskDecision
{
Blocked = true,
Code = 1003,
Message = "card_banned",
HttpStatus = StatusCodes.Status403Forbidden
};
}
return new RiskDecision
{
Blocked = true,
Code = 1005,
Message = "device_limit_exceeded",
HttpStatus = StatusCodes.Status403Forbidden
};
}
var checkLocation = await _config.GetBoolAsync("risk.check_location", true);
if (checkLocation)
{
var since = DateTime.UtcNow.AddHours(-1);
var lastLog = await _db.AccessLogs
.Where(l => l.CardKeyId == card.Id && l.CreatedAt >= since)
.OrderByDescending(l => l.CreatedAt)
.FirstOrDefaultAsync();
if (lastLog != null && !string.IsNullOrWhiteSpace(lastLog.IpAddress)
&& !string.Equals(lastLog.IpAddress, ip, StringComparison.OrdinalIgnoreCase))
{
await AddRiskLogAsync(card.Id, "risk_location", $"old={lastLog.IpAddress};new={ip}", ip);
var autoBan = await _config.GetBoolAsync("risk.auto_ban", false);
if (autoBan)
{
card.Status = "banned";
await _db.SaveChangesAsync();
return new RiskDecision
{
Blocked = true,
Code = 1003,
Message = "card_banned",
HttpStatus = StatusCodes.Status403Forbidden
};
}
}
}
var proxyDecision = await CheckProxyAsync(card, ip);
if (proxyDecision != null)
return proxyDecision;
var shareDecision = await CheckIpShareAsync(card, ip);
if (shareDecision != null)
return shareDecision;
return null;
}
public async Task CheckHeartbeatAsync(CardKey card, string deviceId, string ip)
{
var enabled = await _config.GetBoolAsync("risk.enabled", true);
if (!enabled)
return;
var interval = await _config.GetIntAsync("heartbeat.interval", 60);
if (interval <= 0)
return;
var logs = await _db.AccessLogs
.Where(l => l.CardKeyId == card.Id && l.DeviceId == deviceId && l.Action == "heartbeat")
.OrderByDescending(l => l.CreatedAt)
.Take(6)
.ToListAsync();
if (logs.Count < 6)
return;
var intervals = new List<double>();
for (var i = 0; i < logs.Count - 1; i++)
{
var delta = (logs[i].CreatedAt - logs[i + 1].CreatedAt).TotalSeconds;
if (delta > 0)
intervals.Add(delta);
}
if (intervals.Count < 5)
return;
var avg = intervals.Average();
var variance = intervals.Select(x => Math.Pow(x - avg, 2)).Average();
var std = Math.Sqrt(variance);
if (Math.Abs(avg - interval) <= 2 && std <= 1)
{
await AddRiskLogAsync(card.Id, "risk_automation", $"avg={avg:F1};std={std:F1}", ip);
}
}
private async Task<RiskDecision?> CheckIpShareAsync(CardKey card, string ip)
{
var since = DateTime.UtcNow.AddHours(-24);
var ipCount = await _db.AccessLogs
.Where(l => l.CardKeyId == card.Id && l.CreatedAt >= since && l.IpAddress != null)
.Select(l => l.IpAddress!)
.Distinct()
.CountAsync();
if (ipCount > 5)
{
await AddRiskLogAsync(card.Id, "risk_share", $"uniqueIp={ipCount}", ip);
card.Status = "banned";
await _db.SaveChangesAsync();
return new RiskDecision
{
Blocked = true,
Code = 1003,
Message = "card_banned",
HttpStatus = StatusCodes.Status403Forbidden
};
}
return null;
}
private async Task<RiskDecision?> CheckProxyAsync(CardKey card, string ip)
{
var prefixes = await _config.GetValueAsync("risk.proxy_prefixes");
if (string.IsNullOrWhiteSpace(prefixes))
return null;
var list = prefixes.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (list.Length == 0)
return null;
var hit = list.Any(prefix => ip.StartsWith(prefix, StringComparison.OrdinalIgnoreCase));
if (!hit)
return null;
await AddRiskLogAsync(card.Id, "risk_proxy", $"ip={ip}", ip);
var autoBan = await _config.GetBoolAsync("risk.auto_ban", false);
if (!autoBan)
return null;
card.Status = "banned";
await _db.SaveChangesAsync();
return new RiskDecision
{
Blocked = true,
Code = 1003,
Message = "card_banned",
HttpStatus = StatusCodes.Status403Forbidden
};
}
private async Task AddRiskLogAsync(int cardId, string action, string details, string? ip)
{
_db.CardKeyLogs.Add(new CardKeyLog
{
CardKeyId = cardId,
Action = action,
OperatorType = "system",
Details = details,
IpAddress = ip,
CreatedAt = DateTime.UtcNow
});
await _db.SaveChangesAsync();
}
}

View File

@@ -0,0 +1,78 @@
using System.Security.Cryptography;
using License.Api.Options;
using License.Api.Utils;
using Microsoft.Extensions.Options;
namespace License.Api.Services;
public class SoftwareEncryptionResult
{
public byte[] EncryptedData { get; set; } = Array.Empty<byte>();
public string FileHash { get; set; } = string.Empty;
public string? EncryptionKey { get; set; }
public long FileSize { get; set; }
public byte[] Nonce { get; set; } = Array.Empty<byte>();
}
public class SoftwareEncryptionService
{
private readonly StorageOptions _options;
public SoftwareEncryptionService(IOptions<StorageOptions> options)
{
_options = options.Value;
}
public async Task<SoftwareEncryptionResult> EncryptAsync(Stream input)
{
using var ms = new MemoryStream();
await input.CopyToAsync(ms);
var fileData = ms.ToArray();
if (string.IsNullOrWhiteSpace(_options.ClientRsaPublicKeyPem))
{
var rawHash = SHA256.HashData(fileData);
return new SoftwareEncryptionResult
{
EncryptedData = fileData,
FileHash = Convert.ToHexString(rawHash).ToLowerInvariant(),
FileSize = fileData.Length,
EncryptionKey = null,
Nonce = Array.Empty<byte>()
};
}
var aesKey = RandomNumberGenerator.GetBytes(32);
var nonce = RandomNumberGenerator.GetBytes(12);
var tag = new byte[16];
var encryptedData = new byte[fileData.Length];
using (var aes = new AesGcm(aesKey))
{
aes.Encrypt(nonce, fileData, encryptedData, tag);
}
var finalData = new byte[nonce.Length + tag.Length + encryptedData.Length];
Buffer.BlockCopy(nonce, 0, finalData, 0, nonce.Length);
Buffer.BlockCopy(tag, 0, finalData, nonce.Length, tag.Length);
Buffer.BlockCopy(encryptedData, 0, finalData, nonce.Length + tag.Length, encryptedData.Length);
var hash = SHA256.HashData(finalData);
var result = new SoftwareEncryptionResult
{
EncryptedData = finalData,
FileHash = Convert.ToHexString(hash).ToLowerInvariant(),
FileSize = fileData.Length,
Nonce = nonce
};
var rsa = RsaKeyLoader.LoadPublicKey(_options.ClientRsaPublicKeyPem);
if (rsa == null)
throw new InvalidOperationException("Client RSA public key is not configured");
var encryptedKey = rsa.Encrypt(aesKey, RSAEncryptionPadding.OaepSHA256);
result.EncryptionKey = Convert.ToBase64String(encryptedKey);
return result;
}
}

View File

@@ -0,0 +1,96 @@
using License.Api.Data;
using License.Api.DTOs;
using License.Api.Models;
using License.Api.Utils;
using Microsoft.EntityFrameworkCore;
namespace License.Api.Services;
public class SoftwareService
{
private readonly AppDbContext _db;
private readonly SoftwareEncryptionService _encryption;
private readonly FileStorageService _storage;
public SoftwareService(AppDbContext db, SoftwareEncryptionService encryption, FileStorageService storage)
{
_db = db;
_encryption = encryption;
_storage = storage;
}
public async Task<SoftwareVersion> CreateVersionAsync(string projectId, string version, IFormFile file, string? changelog, bool isForceUpdate, bool isStable, int? createdBy)
{
await using var stream = file.OpenReadStream();
var encryptionResult = await _encryption.EncryptAsync(stream);
var filePath = await _storage.SaveAsync(projectId, version, encryptionResult.EncryptedData);
var entity = new SoftwareVersion
{
ProjectId = projectId,
Version = version,
FileUrl = filePath,
FileSize = encryptionResult.FileSize,
FileHash = encryptionResult.FileHash,
EncryptionKey = encryptionResult.EncryptionKey,
Changelog = changelog,
IsForceUpdate = isForceUpdate,
IsStable = isStable,
PublishedAt = DateTime.UtcNow,
CreatedAt = DateTime.UtcNow,
CreatedBy = createdBy
};
_db.SoftwareVersions.Add(entity);
await _db.SaveChangesAsync();
return entity;
}
public async Task<SoftwareCheckUpdateResponse> CheckUpdateAsync(SoftwareCheckUpdateRequest request)
{
var latest = await _db.SoftwareVersions
.Where(v => v.ProjectId == request.ProjectId)
.OrderByDescending(v => v.PublishedAt)
.FirstOrDefaultAsync();
if (latest == null)
{
return new SoftwareCheckUpdateResponse
{
HasUpdate = false,
LatestVersion = null
};
}
var compare = VersionComparer.Compare(latest.Version, request.CurrentVersion);
var hasUpdate = compare > 0;
return new SoftwareCheckUpdateResponse
{
HasUpdate = hasUpdate,
LatestVersion = latest.Version,
ForceUpdate = latest.IsForceUpdate,
DownloadUrl = $"/api/software/download?version={latest.Version}",
FileSize = latest.FileSize ?? 0,
FileHash = latest.FileHash,
Changelog = latest.Changelog
};
}
public async Task<SoftwareVersion?> GetVersionAsync(string projectId, string? version)
{
if (!string.IsNullOrWhiteSpace(version))
{
return await _db.SoftwareVersions
.FirstOrDefaultAsync(v => v.ProjectId == projectId && v.Version == version);
}
return await _db.SoftwareVersions
.Where(v => v.ProjectId == projectId)
.OrderByDescending(v => v.PublishedAt)
.FirstOrDefaultAsync();
}
public Task<byte[]> ReadFileAsync(string filePath)
=> File.ReadAllBytesAsync(filePath);
}

View File

@@ -0,0 +1,111 @@
using License.Api.Data;
using License.Api.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace License.Api.Services;
public class StatsAggregationService : BackgroundService
{
private readonly IServiceProvider _provider;
private readonly ILogger<StatsAggregationService> _logger;
public StatsAggregationService(IServiceProvider provider, ILogger<StatsAggregationService> logger)
{
_provider = provider;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
using var scope = _provider.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var config = scope.ServiceProvider.GetRequiredService<ConfigService>();
var heartbeatInterval = await config.GetIntAsync("heartbeat.interval", 60);
var start = DateTime.UtcNow.Date;
var end = start.AddDays(1);
var date = DateOnly.FromDateTime(start);
var projects = await db.Projects
.AsNoTracking()
.Select(p => p.ProjectId)
.ToListAsync(stoppingToken);
var activeUsers = await db.AccessLogs
.Where(l => l.ProjectId != null && l.CreatedAt >= start && l.CreatedAt < end
&& (l.Action == "verify" || l.Action == "heartbeat"))
.GroupBy(l => l.ProjectId!)
.Select(g => new { ProjectId = g.Key, Count = g.Select(x => x.DeviceId).Distinct().Count() })
.ToListAsync(stoppingToken);
var downloads = await db.AccessLogs
.Where(l => l.ProjectId != null && l.CreatedAt >= start && l.CreatedAt < end && l.Action == "download")
.GroupBy(l => l.ProjectId!)
.Select(g => new { ProjectId = g.Key, Count = g.Count() })
.ToListAsync(stoppingToken);
var heartbeatCounts = await db.AccessLogs
.Where(l => l.ProjectId != null && l.CreatedAt >= start && l.CreatedAt < end && l.Action == "heartbeat")
.GroupBy(l => l.ProjectId!)
.Select(g => new { ProjectId = g.Key, Count = g.Count() })
.ToListAsync(stoppingToken);
var newUsers = await db.CardKeys
.Where(c => c.ProjectId != null && c.ActivateTime != null && c.ActivateTime >= start && c.ActivateTime < end)
.GroupBy(c => c.ProjectId!)
.Select(g => new { ProjectId = g.Key, Count = g.Count() })
.ToListAsync(stoppingToken);
var revenue = await db.CardKeys
.Where(c => c.ProjectId != null && c.CreatedAt >= start && c.CreatedAt < end)
.GroupBy(c => c.ProjectId!)
.Select(g => new { ProjectId = g.Key, Amount = g.Sum(x => x.SoldPrice ?? 0) })
.ToListAsync(stoppingToken);
var activeMap = activeUsers.ToDictionary(x => x.ProjectId, x => x.Count);
var downloadMap = downloads.ToDictionary(x => x.ProjectId, x => x.Count);
var heartbeatMap = heartbeatCounts.ToDictionary(x => x.ProjectId, x => x.Count);
var newUserMap = newUsers.ToDictionary(x => x.ProjectId, x => x.Count);
var revenueMap = revenue.ToDictionary(x => x.ProjectId, x => x.Amount);
foreach (var projectId in projects)
{
var entity = await db.Statistics
.FirstOrDefaultAsync(s => s.ProjectId == projectId && s.Date == date, stoppingToken);
if (entity == null)
{
entity = new Statistic
{
ProjectId = projectId,
Date = date
};
db.Statistics.Add(entity);
}
entity.ActiveUsers = activeMap.TryGetValue(projectId, out var activeCount) ? activeCount : 0;
entity.NewUsers = newUserMap.TryGetValue(projectId, out var newCount) ? newCount : 0;
entity.TotalDownloads = downloadMap.TryGetValue(projectId, out var downloadCount) ? downloadCount : 0;
entity.TotalDuration = heartbeatMap.TryGetValue(projectId, out var hbCount)
? hbCount * heartbeatInterval
: 0;
entity.Revenue = revenueMap.TryGetValue(projectId, out var rev) ? rev : 0;
}
await db.SaveChangesAsync(stoppingToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Stats aggregation failed");
}
await Task.Delay(TimeSpan.FromMinutes(30), stoppingToken);
}
}
}

View File

@@ -0,0 +1,259 @@
using License.Api.Data;
using License.Api.DTOs;
using Microsoft.EntityFrameworkCore;
namespace License.Api.Services;
public class StatsService
{
private readonly AppDbContext _db;
public StatsService(AppDbContext db)
{
_db = db;
}
public async Task<object> GetDashboardAsync(IReadOnlyCollection<string>? projectIds = null)
{
var filter = NormalizeFilter(projectIds);
if (filter is { Count: 0 })
{
return new
{
overview = new
{
totalProjects = 0,
totalCards = 0,
activeCards = 0,
activeDevices = 0,
todayRevenue = 0,
monthRevenue = 0
},
trend = new
{
dates = new List<string>(),
activeUsers = new List<int>(),
newUsers = new List<int>(),
revenue = new List<decimal>()
},
projectDistribution = new List<object>()
};
}
var projectsQuery = _db.Projects.AsQueryable();
if (filter != null)
projectsQuery = projectsQuery.Where(p => p.ProjectId != null && filter.Contains(p.ProjectId));
var totalProjects = await projectsQuery.CountAsync();
var cardsQuery = _db.CardKeys.Where(c => c.DeletedAt == null).AsQueryable();
if (filter != null)
cardsQuery = cardsQuery.Where(c => c.ProjectId != null && filter.Contains(c.ProjectId));
var totalCards = await cardsQuery.CountAsync();
var activeCards = await cardsQuery.CountAsync(c => c.Status == "active");
var activeDevicesQuery = _db.Devices.Where(d => d.IsActive && d.DeletedAt == null).AsQueryable();
if (filter != null)
{
activeDevicesQuery = activeDevicesQuery.Join(
_db.CardKeys.Where(c => c.ProjectId != null && filter.Contains(c.ProjectId)),
d => d.CardKeyId,
c => c.Id,
(d, _) => d);
}
var activeDevices = await activeDevicesQuery.CountAsync();
var today = DateOnly.FromDateTime(DateTime.UtcNow.Date);
var since = today.AddDays(-29);
var statsQuery = _db.Statistics.Where(s => s.Date >= since).AsQueryable();
if (filter != null)
statsQuery = statsQuery.Where(s => s.ProjectId != null && filter.Contains(s.ProjectId));
var stats = await statsQuery.ToListAsync();
var grouped = stats
.GroupBy(s => s.Date)
.OrderBy(g => g.Key)
.ToList();
var trend = new
{
dates = grouped.Select(g => g.Key.ToString("yyyy-MM-dd")).ToList(),
activeUsers = grouped.Select(g => g.Sum(x => x.ActiveUsers)).ToList(),
newUsers = grouped.Select(g => g.Sum(x => x.NewUsers)).ToList(),
revenue = grouped.Select(g => g.Sum(x => x.Revenue)).ToList()
};
var todayRevenue = grouped.FirstOrDefault(g => g.Key == today)?.Sum(x => x.Revenue) ?? 0;
var monthRevenue = grouped.Sum(g => g.Sum(x => x.Revenue));
return new
{
overview = new
{
totalProjects,
totalCards,
activeCards,
activeDevices,
todayRevenue,
monthRevenue
},
trend,
projectDistribution = await cardsQuery
.Where(c => c.ProjectId != null)
.GroupBy(c => c.ProjectId)
.Select(g => new { project = g.Key, count = g.Count() })
.ToListAsync()
};
}
public async Task<List<ProjectStatsItem>> GetProjectStatsAsync(IReadOnlyCollection<string>? projectIds = null)
{
var filter = NormalizeFilter(projectIds);
if (filter is { Count: 0 })
return new List<ProjectStatsItem>();
var projectsQuery = _db.Projects.AsNoTracking().AsQueryable();
if (filter != null)
projectsQuery = projectsQuery.Where(p => p.ProjectId != null && filter.Contains(p.ProjectId));
var projects = await projectsQuery
.Select(p => new { p.ProjectId, p.Name })
.ToListAsync();
var cardStatsQuery = _db.CardKeys
.Where(c => c.DeletedAt == null && c.ProjectId != null)
.AsQueryable();
if (filter != null)
cardStatsQuery = cardStatsQuery.Where(c => filter.Contains(c.ProjectId!));
var cardStats = await cardStatsQuery
.GroupBy(c => c.ProjectId!)
.Select(g => new
{
ProjectId = g.Key,
TotalCards = g.Count(),
ActiveCards = g.Count(x => x.Status == "active"),
Revenue = g.Sum(x => x.SoldPrice ?? 0)
})
.ToListAsync();
var deviceStatsQuery = _db.Devices
.Where(d => d.IsActive && d.DeletedAt == null)
.Join(_db.CardKeys, d => d.CardKeyId, c => c.Id, (d, c) => c.ProjectId)
.Where(pid => pid != null)
.AsQueryable();
if (filter != null)
deviceStatsQuery = deviceStatsQuery.Where(pid => filter.Contains(pid!));
var deviceStats = await deviceStatsQuery
.GroupBy(pid => pid!)
.Select(g => new { ProjectId = g.Key, ActiveDevices = g.Count() })
.ToListAsync();
var cardMap = cardStats.ToDictionary(c => c.ProjectId, c => c);
var deviceMap = deviceStats.ToDictionary(d => d.ProjectId, d => d.ActiveDevices);
return projects.Select(p =>
{
cardMap.TryGetValue(p.ProjectId, out var stats);
deviceMap.TryGetValue(p.ProjectId, out var deviceCount);
return new ProjectStatsItem
{
ProjectId = p.ProjectId,
ProjectName = p.Name,
TotalCards = stats?.TotalCards ?? 0,
ActiveCards = stats?.ActiveCards ?? 0,
ActiveDevices = deviceCount,
Revenue = stats?.Revenue ?? 0
};
}).ToList();
}
public async Task<List<AgentStatsItem>> GetAgentStatsAsync()
{
var agents = await _db.Agents
.AsNoTracking()
.Select(a => new { a.Id, a.AgentCode, a.CompanyName })
.ToListAsync();
var cardStats = await _db.CardKeys
.Where(c => c.AgentId != null && c.DeletedAt == null)
.GroupBy(c => c.AgentId!.Value)
.Select(g => new
{
AgentId = g.Key,
TotalCards = g.Count(),
ActiveCards = g.Count(x => x.Status == "active"),
Revenue = g.Sum(x => x.SoldPrice ?? 0)
})
.ToListAsync();
var map = cardStats.ToDictionary(c => c.AgentId, c => c);
return agents.Select(a =>
{
map.TryGetValue(a.Id, out var stats);
return new AgentStatsItem
{
AgentId = a.Id,
AgentCode = a.AgentCode,
CompanyName = a.CompanyName,
TotalCards = stats?.TotalCards ?? 0,
ActiveCards = stats?.ActiveCards ?? 0,
TotalRevenue = stats?.Revenue ?? 0
};
}).ToList();
}
public async Task<List<LogStatsItem>> GetLogStatsAsync(int days, IReadOnlyCollection<string>? projectIds = null)
{
var since = DateTime.UtcNow.AddDays(-days);
var filter = NormalizeFilter(projectIds);
if (filter is { Count: 0 })
return new List<LogStatsItem>();
var query = _db.AccessLogs
.Where(l => l.CreatedAt >= since)
.AsQueryable();
if (filter != null)
query = query.Where(l => l.ProjectId != null && filter.Contains(l.ProjectId));
return await query
.GroupBy(l => l.Action)
.Select(g => new LogStatsItem { Action = g.Key, Count = g.Count() })
.ToListAsync();
}
public async Task<string> ExportStatsCsvAsync(int days, IReadOnlyCollection<string>? projectIds = null)
{
var since = DateOnly.FromDateTime(DateTime.UtcNow.AddDays(-days));
var filter = NormalizeFilter(projectIds);
if (filter is { Count: 0 })
return "date,projectId,activeUsers,newUsers,totalDownloads,totalDuration,revenue\n";
var query = _db.Statistics
.Where(s => s.Date >= since)
.AsQueryable();
if (filter != null)
query = query.Where(s => s.ProjectId != null && filter.Contains(s.ProjectId));
var rows = await query
.OrderBy(s => s.Date)
.ToListAsync();
var sb = new System.Text.StringBuilder();
sb.AppendLine("date,projectId,activeUsers,newUsers,totalDownloads,totalDuration,revenue");
foreach (var row in rows)
{
sb.AppendLine($"{row.Date:yyyy-MM-dd},{row.ProjectId},{row.ActiveUsers},{row.NewUsers},{row.TotalDownloads},{row.TotalDuration},{row.Revenue}");
}
return sb.ToString();
}
private static List<string>? NormalizeFilter(IReadOnlyCollection<string>? projectIds)
{
if (projectIds == null)
return null;
return projectIds
.Where(id => !string.IsNullOrWhiteSpace(id))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
}
}

View File

@@ -0,0 +1,32 @@
namespace License.Api.Utils;
public static class CardDefaults
{
public static int ResolveDurationDays(string? cardType)
{
return cardType?.Trim().ToLowerInvariant() switch
{
"test" => 1,
"day" => 1,
"week" => 7,
"month" => 30,
"year" => 365,
"lifetime" => 0,
_ => 0
};
}
public static string? ResolveCardType(byte type)
{
return type switch
{
6 => "test",
1 => "day",
2 => "week",
3 => "month",
4 => "year",
5 => "lifetime",
_ => null
};
}
}

View File

@@ -0,0 +1,170 @@
using System.Security.Cryptography;
namespace License.Api.Utils;
public static class CardKeyGenerator
{
private const string Base32Chars = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ";
public static string Generate(byte type, int durationDays)
{
var randomBytes = RandomNumberGenerator.GetBytes(5);
var payload = new byte[8];
Array.Copy(randomBytes, 0, payload, 0, randomBytes.Length);
payload[5] = type;
var duration = (ushort)Math.Clamp(durationDays, 0, ushort.MaxValue);
var durationBytes = BitConverter.GetBytes(duration);
payload[6] = durationBytes[0];
payload[7] = durationBytes[1];
var crc = Crc32.Compute(payload);
var checksum = BitConverter.GetBytes(crc);
var fullPayload = payload.Concat(checksum).ToArray();
var encoded = Base32Encode(fullPayload);
return FormatKey(encoded);
}
public static bool Validate(string keyCode)
{
if (string.IsNullOrWhiteSpace(keyCode))
return false;
if (!System.Text.RegularExpressions.Regex.IsMatch(keyCode, "^[2-9A-HJ-NP-Z]{4}-[2-9A-HJ-NP-Z]{4}-[2-9A-HJ-NP-Z]{4}-[2-9A-HJ-NP-Z]{4}$"))
return false;
var raw = keyCode.Replace("-", string.Empty);
var payload = Base32Decode(raw);
if (payload.Length < 2)
return false;
var receivedCrc = BitConverter.ToUInt16(payload.AsSpan(payload.Length - 2));
var computedCrc = Crc32.Compute(payload[..^2]);
return receivedCrc == computedCrc;
}
public static bool TryDecode(string keyCode, out byte type, out int durationDays)
{
type = 0;
durationDays = 0;
if (string.IsNullOrWhiteSpace(keyCode))
return false;
var raw = keyCode.Replace("-", string.Empty).Trim().ToUpperInvariant();
var payload = Base32Decode(raw);
if (payload.Length < 10)
return false;
var data = payload[..^2];
var receivedCrc = BitConverter.ToUInt16(payload.AsSpan(payload.Length - 2));
var computedCrc = Crc32.Compute(data);
if (receivedCrc != computedCrc || data.Length < 8)
return false;
type = data[5];
durationDays = BitConverter.ToUInt16(data.AsSpan(6, 2));
return true;
}
private static string FormatKey(string encoded)
{
var parts = new List<string>();
for (var i = 0; i < encoded.Length; i += 4)
{
parts.Add(encoded.Substring(i, Math.Min(4, encoded.Length - i)));
}
return string.Join("-", parts.Take(4));
}
private static string Base32Encode(byte[] data)
{
var output = new List<char>();
var buffer = 0;
var bitsLeft = 0;
foreach (var b in data)
{
buffer = (buffer << 8) | b;
bitsLeft += 8;
while (bitsLeft >= 5)
{
var index = (buffer >> (bitsLeft - 5)) & 0x1F;
bitsLeft -= 5;
output.Add(Base32Chars[index]);
}
}
if (bitsLeft > 0)
{
var index = (buffer << (5 - bitsLeft)) & 0x1F;
output.Add(Base32Chars[index]);
}
return new string(output.ToArray());
}
private static byte[] Base32Decode(string input)
{
var buffer = 0;
var bitsLeft = 0;
var output = new List<byte>();
foreach (var c in input)
{
var index = Base32Chars.IndexOf(c);
if (index < 0)
continue;
buffer = (buffer << 5) | index;
bitsLeft += 5;
if (bitsLeft >= 8)
{
output.Add((byte)(buffer >> (bitsLeft - 8)));
bitsLeft -= 8;
}
}
return output.ToArray();
}
}
internal static class Crc32
{
private static readonly uint[] Table = CreateTable();
public static ushort Compute(byte[] data)
{
uint crc = 0xFFFFFFFF;
foreach (var b in data)
{
crc = (crc >> 8) ^ Table[(crc ^ b) & 0xFF];
}
return (ushort)(crc ^ 0xFFFFFFFF);
}
private static uint[] CreateTable()
{
var table = new uint[256];
const uint polynomial = 0xEDB88320;
for (var i = 0; i < table.Length; i++)
{
var crc = (uint)i;
for (var j = 0; j < 8; j++)
{
if ((crc & 1) == 1)
crc = (crc >> 1) ^ polynomial;
else
crc >>= 1;
}
table[i] = crc;
}
return table;
}
}

View File

@@ -0,0 +1,14 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
namespace License.Api.Utils;
public static class ClaimsPrincipalExtensions
{
public static bool TryGetUserId(this ClaimsPrincipal user, out int userId)
{
userId = 0;
var sub = user.FindFirst(JwtRegisteredClaimNames.Sub)?.Value;
return int.TryParse(sub, out userId);
}
}

View File

@@ -0,0 +1,12 @@
using BCrypt.Net;
namespace License.Api.Utils;
public static class PasswordHasher
{
public static string Hash(string password)
=> BCrypt.Net.BCrypt.HashPassword(password);
public static bool Verify(string password, string hash)
=> BCrypt.Net.BCrypt.Verify(password, hash);
}

View File

@@ -0,0 +1,28 @@
using System.Security.Cryptography;
namespace License.Api.Utils;
public static class RandomIdGenerator
{
private const string AlphaNum = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
public static string GenerateProjectId()
=> $"PROJ_{GenerateRandomString(6)}";
public static string GenerateKey(int length = 32)
=> GenerateRandomString(length);
public static string GenerateSecret(int length = 48)
=> GenerateRandomString(length);
private static string GenerateRandomString(int length)
{
var bytes = RandomNumberGenerator.GetBytes(length);
var chars = new char[length];
for (var i = 0; i < length; i++)
{
chars[i] = AlphaNum[bytes[i] % AlphaNum.Length];
}
return new string(chars);
}
}

View File

@@ -0,0 +1,16 @@
using System.Security.Cryptography;
namespace License.Api.Utils;
public static class RsaKeyLoader
{
public static RSA? LoadPublicKey(string? pem)
{
if (string.IsNullOrWhiteSpace(pem))
return null;
var rsa = RSA.Create();
rsa.ImportFromPem(pem.ToCharArray());
return rsa;
}
}

View File

@@ -0,0 +1,19 @@
namespace License.Api.Utils;
public static class VersionComparer
{
public static int Compare(string? a, string? b)
{
if (string.IsNullOrWhiteSpace(a) && string.IsNullOrWhiteSpace(b))
return 0;
if (string.IsNullOrWhiteSpace(a))
return -1;
if (string.IsNullOrWhiteSpace(b))
return 1;
if (Version.TryParse(a, out var va) && Version.TryParse(b, out var vb))
return va.CompareTo(vb);
return string.Compare(a, b, StringComparison.OrdinalIgnoreCase);
}
}

View File

@@ -0,0 +1,54 @@
{
"AllowedHosts": "*",
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Port=5432;Database=license;Username=license;Password=license"
},
"Redis": {
"ConnectionString": "localhost:6379",
"Enabled": true
},
"Jwt": {
"Secret": "replace_with_32plus_chars_secret",
"Issuer": "license-system",
"ExpireMinutes": 1440,
"AdminExpireMinutes": 720,
"AgentExpireMinutes": 1440
},
"Security": {
"SignatureEnabled": true,
"TimestampToleranceSeconds": 300
},
"Storage": {
"UploadRoot": "uploads",
"MaxUploadMb": 200,
"ClientRsaPublicKeyPem": "",
"RequireHttpsForDownloadKey": true
},
"RateLimit": {
"Enabled": true,
"IpPerMinute": 100,
"DevicePerMinute": 50,
"BlockDurationMinutes": 5
},
"Heartbeat": {
"Enabled": true,
"IntervalSeconds": 60,
"TimeoutSeconds": 180
},
"Cors": {
"AllowAny": false,
"AllowedOrigins": []
},
"Seed": {
"AdminUser": "admin",
"AdminPassword": "admin123",
"AdminEmail": ""
},
"Serilog": {
"MinimumLevel": "Information",
"WriteTo": [
{ "Name": "Console" },
{ "Name": "File", "Args": { "path": "logs/app-.log", "rollingInterval": "Day" } }
]
}
}