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);
}
}