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,10 @@
DB_PASSWORD=your_strong_password_here
JWT_SECRET=your_jwt_secret_at_least_32_chars
ADMIN_USER=admin
ADMIN_PASSWORD=change_me
ADMIN_EMAIL=
REDIS_ENABLED=true
CORS_ALLOW_ANY=false
CORS_ALLOWED_ORIGIN0=https://your-frontend.example.com
STORAGE_CLIENT_RSA_PUBLIC_KEY_PEM=
STORAGE_REQUIRE_HTTPS_FOR_DOWNLOAD_KEY=true

View File

@@ -0,0 +1,17 @@
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
WORKDIR /app
EXPOSE 39256
ENV ASPNETCORE_URLS=http://+:39256
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY src/License.Api/License.Api.csproj src/License.Api/
RUN dotnet restore "src/License.Api/License.Api.csproj"
COPY src/License.Api/ src/License.Api/
WORKDIR /src/src/License.Api
RUN dotnet publish -c Release -o /app/publish
FROM base AS final
WORKDIR /app
COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "License.Api.dll"]

View File

@@ -0,0 +1,62 @@
# License System Backend
ASP.NET Core 8 API service for the software authorization system.
## Requirements
- .NET 8 SDK
- PostgreSQL 16+
- Redis 7+ (optional, for rate limiting)
## Quick Start
1. Create database and apply schema:
```bash
psql -U postgres -d license -f scripts/init.sql
```
2. Update `src/License.Api/appsettings.json` with your connection string and JWT secret.
3. Run the API:
```bash
dotnet restore
ASPNETCORE_URLS=http://localhost:39256 dotnet run --project src/License.Api
```
4. Swagger:
```
http://localhost:39256/swagger
```
## Docker Compose
1. Copy env file:
```bash
cp .env.example .env
```
2. Start services:
```bash
docker compose up -d
```
3. Check health:
```bash
curl http://localhost:39256/health/ready
```
## Notes
- On first run, a `super_admin` user is seeded from `Seed` config in `appsettings.json`.
- Files are stored under the `Storage:UploadRoot` path (defaults to `uploads/`).
- If `Storage:ClientRsaPublicKeyPem` is empty, files are stored unencrypted and no key headers are returned.
- CORS can be restricted via `Cors:AllowedOrigins` or env `Cors__AllowedOrigins__0` (set `Cors:AllowAny` to `true` for dev).
- When `Storage:RequireHttpsForDownloadKey` is `true`, encrypted downloads require HTTPS to return the decryption key.
- Auth signature accepts `ProjectSecret` or `ProjectKey` (use ProjectKey for client integrations).

View File

@@ -0,0 +1,60 @@
version: '3.8'
services:
api:
build: .
container_name: license-api
restart: always
environment:
- ASPNETCORE_ENVIRONMENT=Production
- ConnectionStrings__DefaultConnection=Host=db;Port=5432;Database=license;Username=license;Password=${DB_PASSWORD}
- Redis__ConnectionString=redis:6379
- Redis__Enabled=${REDIS_ENABLED}
- Jwt__Secret=${JWT_SECRET}
- Jwt__Issuer=license-system
- Seed__AdminUser=${ADMIN_USER}
- Seed__AdminPassword=${ADMIN_PASSWORD}
- Seed__AdminEmail=${ADMIN_EMAIL}
- Cors__AllowAny=${CORS_ALLOW_ANY}
- Cors__AllowedOrigins__0=${CORS_ALLOWED_ORIGIN0}
- Storage__ClientRsaPublicKeyPem=${STORAGE_CLIENT_RSA_PUBLIC_KEY_PEM}
- Storage__RequireHttpsForDownloadKey=${STORAGE_REQUIRE_HTTPS_FOR_DOWNLOAD_KEY}
volumes:
- ./data/uploads:/app/uploads
- ./data/logs:/app/logs
- ./data/protection-keys:/app/data/protection-keys
ports:
- "0.0.0.0:39256:39256"
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
db:
image: postgres:16-alpine
container_name: license-db
restart: always
environment:
POSTGRES_DB: license
POSTGRES_USER: license
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- ./data/postgres:/var/lib/postgresql/data
ports:
- "127.0.0.1:5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U license"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
container_name: license-redis
restart: always
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
volumes:
- ./data/redis:/data
ports:
- "127.0.0.1:6379:6379"

View File

@@ -0,0 +1,296 @@
# 前端对接文档(管理后台 + 客户端)
本文档基于已实现的后端接口,提供给前端开发使用。
## 统一响应格式
```json
{
"code": 200,
"message": "success",
"data": {},
"timestamp": 1735000000
}
```
## 统一错误码
- 200 success
- 400 bad_request
- 401 unauthorized
- 403 forbidden
- 404 not_found
- 1001 card_invalid
- 1002 card_expired
- 1003 card_banned
- 1005 device_limit_exceeded
- 1006 device_not_found
- 1007 signature_invalid
- 1008 timestamp_expired
- 1009 rate_limit_exceeded
- 1010 invalid_version
- 1011 project_disabled
- 1012 force_update_required
- 500 internal_error
## 分页参数
- page: 页码,默认 1
- pageSize: 每页数量,默认 20最大 100
响应:
```json
{
"code": 200,
"data": {
"items": [],
"pagination": {
"page": 1,
"pageSize": 20,
"total": 100,
"totalPages": 5
}
}
}
```
## 鉴权方式
### 管理员后台
登录后获得 JWT前端需在请求头加入
```
Authorization: Bearer <token>
```
### 代理商
代理商登录后也使用 JWT同上。
### 客户端授权
`/api/auth/verify` 成功后返回 `accessToken`,后续心跳/下载使用。
## 角色与权限
- role:
- `super_admin`:全部权限(含代理商管理、系统设置、管理员管理)
- `admin`:仅可访问所属项目
- permissions:
- JSON 数组,内容为允许的 `projectId`,示例:`["PROJ_001","PROJ_002"]`
- 若为 `["*"]` 表示可访问全部项目
- 非超管且权限为空时,默认无项目访问权限
- 权限生效范围:
- 非超管只能查看/操作允许项目的项目、卡密、设备、日志、统计
- 代理商管理与系统设置仅超管可访问
## 管理后台接口
### 1) 管理员认证
- POST `/api/admin/login`
- POST `/api/admin/logout`
- GET `/api/admin/profile`
- PUT `/api/admin/profile`
- POST `/api/admin/change-password`
### 2) 项目管理
- POST `/api/admin/projects`
- GET `/api/admin/projects`
- GET `/api/admin/projects/{id}`
- PUT `/api/admin/projects/{id}`
- DELETE `/api/admin/projects/{id}`
- GET `/api/admin/projects/{id}/stats`
- GET `/api/admin/projects/{id}/docs`
- PUT `/api/admin/projects/{id}/docs`
#### 价格管理
- GET `/api/admin/projects/{id}/pricing`
- POST `/api/admin/projects/{id}/pricing`
- PUT `/api/admin/projects/{id}/pricing/{priceId}`
- DELETE `/api/admin/projects/{id}/pricing/{priceId}`
#### 版本管理
- GET `/api/admin/projects/{id}/versions`
- POST `/api/admin/projects/{id}/versions` (multipart/form-data)
- version (string)
- file (file)
- changelog (string)
- isForceUpdate (bool)
- isStable (bool)
- PUT `/api/admin/projects/{id}/versions/{versionId}`
- DELETE `/api/admin/projects/{id}/versions/{versionId}`
说明:项目创建时返回 `projectSecret`,后续查询不再返回该字段(仅保留 `projectKey`)。请前端在创建时提示管理员保存。
### 3) 卡密管理
- POST `/api/admin/cards/generate`
- GET `/api/admin/cards`
- GET `/api/admin/cards/{id}`
- GET `/api/admin/cards/{id}/logs`
- PUT `/api/admin/cards/{id}` (更新备注)
- POST `/api/admin/cards/{id}/ban`
- POST `/api/admin/cards/{id}/unban`
- POST `/api/admin/cards/{id}/extend`
- POST `/api/admin/cards/{id}/reset-device`
- DELETE `/api/admin/cards/{id}`
#### 批量操作
- POST `/api/admin/cards/ban-batch`
- POST `/api/admin/cards/unban-batch`
- DELETE `/api/admin/cards/batch`
#### 导入/导出
- GET `/api/admin/cards/export`
- POST `/api/admin/cards/import` (CSV)
说明:导出支持 `format=excel` 生成 `xlsx`,导入支持 CSV 或 Excel首行可带表头。卡密生成支持请求头 `X-Idempotency-Key` 防止重复提交。
### 4) 代理商管理
- 仅超管可访问
- POST `/api/admin/agents`
- GET `/api/admin/agents`
- GET `/api/admin/agents/{id}`
- PUT `/api/admin/agents/{id}`
- POST `/api/admin/agents/{id}/disable`
- POST `/api/admin/agents/{id}/enable`
- DELETE `/api/admin/agents/{id}`
- POST `/api/admin/agents/{id}/recharge`
- POST `/api/admin/agents/{id}/deduct`
- GET `/api/admin/agents/{id}/transactions`
### 5) 设备管理
- GET `/api/admin/devices`
- DELETE `/api/admin/devices/{id}`
- POST `/api/admin/devices/{id}/kick`
### 6) 统计报表
- GET `/api/admin/stats/dashboard`
- GET `/api/admin/stats/projects` (项目维度统计)
- GET `/api/admin/stats/agents` (代理商销售统计,仅超管)
- GET `/api/admin/stats/logs?days=7` (按 action 聚合统计)
- GET `/api/admin/stats/export?days=30` (CSV 导出)
### 7) 日志审计
- GET `/api/admin/logs`
- GET `/api/admin/logs/{id}`
### 8) 系统设置 & 管理员
- 仅超管可访问
- GET `/api/admin/settings`
- PUT `/api/admin/settings`
- GET `/api/admin/admins`
- POST `/api/admin/admins`
- PUT `/api/admin/admins/{id}`
- DELETE `/api/admin/admins/{id}`
## 代理商接口
- POST `/api/agent/login`
- POST `/api/agent/logout`
- GET `/api/agent/profile`
- PUT `/api/agent/profile`
- POST `/api/agent/change-password`
- GET `/api/agent/transactions`
### 代理商售卡
- POST `/api/agent/cards/generate`
- GET `/api/agent/cards`
说明:代理商售卡会按项目价格 * 折扣扣减余额,支持 `X-Idempotency-Key` 防重复扣款。
## 客户端/SDK 接口
### 1) 卡密验证
POST `/api/auth/verify`
```json
{
"projectId": "PROJ_001",
"keyCode": "A3D7-K2P9-M8N1-Q4W6",
"deviceId": "SHA256硬件指纹",
"clientVersion": "1.0.0",
"timestamp": 1735000000,
"signature": "HMAC-SHA256签名"
}
```
成功返回:
```json
{
"code": 200,
"data": {
"valid": true,
"expireTime": "2025-12-31T23:59:59Z",
"remainingDays": 30,
"downloadUrl": "/api/software/download?version=1.2.0&token=xxx",
"fileHash": "sha256...",
"version": "1.2.0",
"heartbeatInterval": 60,
"accessToken": "jwt_token"
}
}
```
若客户端版本低于强制更新版本,将返回 `1012 force_update_required`HTTP 426
### 2) 心跳验证
POST `/api/auth/heartbeat`
```json
{
"accessToken": "jwt_token",
"deviceId": "xxx",
"timestamp": 1735000000,
"signature": "xxx"
}
```
### 3) 版本检查
POST `/api/software/check-update`
### 4) 软件下载
GET `/api/software/download?version=1.2.0&token=jwt_token`
响应头(加密开启时):
- `X-File-Hash`
- `X-File-Size`
- `X-Encryption-Method: AES-256-GCM`
- `X-Encryption-Key: <Base64>`
- `X-Encryption-Nonce: <Base64>`
## 注意事项
- 上传软件版本需要 `multipart/form-data`
-`Storage.ClientRsaPublicKeyPem` 未配置时,文件存储为明文,不返回加密头。
- `/api/software/check-update` 返回的 `downloadUrl` 需要客户端自行追加 `token` 参数(来自 `/api/auth/verify`)。
- 目前导入卡密仅支持 CSV。
- 设备限流可通过请求头 `X-Device-Id` 识别设备(建议 SDK 统一加上)。
- 当系统配置 `feature.auto_update=false` 时,`/api/software/check-update` 将返回 `hasUpdate=false`
## 公共配置接口
- GET `/api/config/public`
返回所有 `IsPublic = true` 的系统配置(用于客户端功能开关/心跳间隔等)。
- `/api/admin/stats/*``dashboard` 外为预留接口。

View File

@@ -0,0 +1,212 @@
-- Database schema for license system
CREATE TABLE IF NOT EXISTS Projects (
Id SERIAL PRIMARY KEY,
ProjectId VARCHAR(32) UNIQUE NOT NULL,
ProjectKey VARCHAR(64) NOT NULL,
ProjectSecret VARCHAR(64) NOT NULL,
Name VARCHAR(100) NOT NULL,
Description TEXT,
IconUrl VARCHAR(500),
MaxDevices INT DEFAULT 1,
AutoUpdate BOOLEAN DEFAULT TRUE,
IsEnabled BOOLEAN DEFAULT TRUE,
DocsContent TEXT,
CreatedBy INT,
CreatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UpdatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS ProjectPricing (
Id SERIAL PRIMARY KEY,
ProjectId VARCHAR(32) REFERENCES Projects(ProjectId) ON DELETE CASCADE,
CardType VARCHAR(20) NOT NULL,
DurationDays INT NOT NULL,
OriginalPrice DECIMAL(10,2) NOT NULL,
IsEnabled BOOLEAN DEFAULT TRUE,
CreatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UpdatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(ProjectId, CardType, DurationDays)
);
CREATE TABLE IF NOT EXISTS SoftwareVersions (
Id SERIAL PRIMARY KEY,
ProjectId VARCHAR(32) REFERENCES Projects(ProjectId) ON DELETE CASCADE,
Version VARCHAR(20) NOT NULL,
FileUrl VARCHAR(500) NOT NULL,
FileSize BIGINT,
FileHash VARCHAR(64),
EncryptionKey VARCHAR(256),
Changelog TEXT,
IsForceUpdate BOOLEAN DEFAULT FALSE,
IsStable BOOLEAN DEFAULT TRUE,
PublishedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CreatedBy INT,
CreatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(ProjectId, Version)
);
CREATE TABLE IF NOT EXISTS Admins (
Id SERIAL PRIMARY KEY,
Username VARCHAR(50) UNIQUE NOT NULL,
PasswordHash VARCHAR(255) NOT NULL,
Email VARCHAR(100),
Role VARCHAR(20) DEFAULT 'admin',
Permissions TEXT,
Status VARCHAR(20) DEFAULT 'active',
LastLoginAt TIMESTAMP,
LastLoginIp VARCHAR(45),
CreatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UpdatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS Agents (
Id SERIAL PRIMARY KEY,
AdminId INT REFERENCES Admins(Id) ON DELETE SET NULL,
AgentCode VARCHAR(20) UNIQUE NOT NULL,
CompanyName VARCHAR(100),
ContactPerson VARCHAR(50),
ContactPhone VARCHAR(20),
ContactEmail VARCHAR(100),
PasswordHash VARCHAR(255) NOT NULL,
Balance DECIMAL(10,2) DEFAULT 0,
Discount DECIMAL(5,2) DEFAULT 100.00,
CreditLimit DECIMAL(10,2) DEFAULT 0,
MaxProjects INT DEFAULT 0,
AllowedProjects TEXT,
Status VARCHAR(20) DEFAULT 'active',
LastLoginAt TIMESTAMP,
CreatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UpdatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS CardKeys (
Id SERIAL PRIMARY KEY,
ProjectId VARCHAR(32) REFERENCES Projects(ProjectId) ON DELETE SET NULL,
KeyCode VARCHAR(32) UNIQUE NOT NULL,
CardType VARCHAR(20) NOT NULL,
DurationDays INT NOT NULL,
ExpireTime TIMESTAMP,
MaxDevices INT DEFAULT 1,
MachineCode VARCHAR(64),
Status VARCHAR(20) DEFAULT 'unused',
ActivateTime TIMESTAMP,
LastUsedAt TIMESTAMP,
UsedDuration BIGINT DEFAULT 0,
GeneratedBy INT REFERENCES Admins(Id),
AgentId INT REFERENCES Agents(Id),
SoldPrice DECIMAL(10,2),
Note VARCHAR(200),
BatchId VARCHAR(36),
Version INT DEFAULT 1,
CreatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
DeletedAt TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_card_keys_project ON CardKeys(ProjectId);
CREATE INDEX IF NOT EXISTS idx_card_keys_code ON CardKeys(KeyCode);
CREATE INDEX IF NOT EXISTS idx_card_keys_status ON CardKeys(Status);
CREATE INDEX IF NOT EXISTS idx_card_keys_project_status_created ON CardKeys(ProjectId, Status, CreatedAt DESC) WHERE DeletedAt IS NULL;
CREATE INDEX IF NOT EXISTS idx_card_keys_expire ON CardKeys(ExpireTime) WHERE Status = 'active' AND DeletedAt IS NULL;
CREATE INDEX IF NOT EXISTS idx_card_keys_agent ON CardKeys(AgentId) WHERE DeletedAt IS NULL;
CREATE INDEX IF NOT EXISTS idx_card_keys_batch ON CardKeys(BatchId);
CREATE TABLE IF NOT EXISTS Devices (
Id SERIAL PRIMARY KEY,
CardKeyId INT REFERENCES CardKeys(Id) ON DELETE CASCADE,
DeviceId VARCHAR(64) NOT NULL,
DeviceName VARCHAR(100),
OsInfo VARCHAR(100),
LastHeartbeat TIMESTAMP,
IpAddress VARCHAR(45),
Location VARCHAR(100),
IsActive BOOLEAN DEFAULT TRUE,
FirstLoginAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
DeletedAt TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_devices_card_key ON Devices(CardKeyId);
CREATE INDEX IF NOT EXISTS idx_devices_device_id ON Devices(DeviceId);
CREATE UNIQUE INDEX IF NOT EXISTS idx_devices_card_key_device ON Devices(CardKeyId, DeviceId);
CREATE INDEX IF NOT EXISTS idx_devices_heartbeat ON Devices(LastHeartbeat) WHERE IsActive = true AND DeletedAt IS NULL;
CREATE TABLE IF NOT EXISTS AccessLogs (
Id SERIAL PRIMARY KEY,
ProjectId VARCHAR(32),
CardKeyId INT,
DeviceId VARCHAR(64),
Action VARCHAR(50),
IpAddress INET,
UserAgent TEXT,
ResponseCode INT,
ResponseTime INT,
CreatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_logs_project ON AccessLogs(ProjectId);
CREATE INDEX IF NOT EXISTS idx_logs_created ON AccessLogs(CreatedAt);
CREATE INDEX IF NOT EXISTS idx_logs_action_created ON AccessLogs(Action, CreatedAt DESC);
CREATE INDEX IF NOT EXISTS idx_logs_card_key ON AccessLogs(CardKeyId);
CREATE TABLE IF NOT EXISTS Statistics (
Id SERIAL PRIMARY KEY,
ProjectId VARCHAR(32),
Date DATE,
ActiveUsers INT DEFAULT 0,
NewUsers INT DEFAULT 0,
TotalDownloads INT DEFAULT 0,
TotalDuration BIGINT DEFAULT 0,
Revenue DECIMAL(10,2) DEFAULT 0,
UNIQUE(ProjectId, Date)
);
CREATE TABLE IF NOT EXISTS AgentTransactions (
Id SERIAL PRIMARY KEY,
AgentId INT REFERENCES Agents(Id) ON DELETE CASCADE,
Type VARCHAR(20) NOT NULL,
Amount DECIMAL(10,2) NOT NULL,
BalanceBefore DECIMAL(10,2) NOT NULL,
BalanceAfter DECIMAL(10,2) NOT NULL,
CardKeyId INT REFERENCES CardKeys(Id) ON DELETE SET NULL,
Remark VARCHAR(200),
CreatedBy INT REFERENCES Admins(Id) ON DELETE SET NULL,
CreatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS CardKeyLogs (
Id SERIAL PRIMARY KEY,
CardKeyId INT REFERENCES CardKeys(Id) ON DELETE CASCADE,
Action VARCHAR(50) NOT NULL,
OperatorId INT,
OperatorType VARCHAR(20),
Details TEXT,
IpAddress VARCHAR(45),
CreatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS SystemConfigs (
Id SERIAL PRIMARY KEY,
ConfigKey VARCHAR(50) UNIQUE NOT NULL,
ConfigValue TEXT,
ValueType VARCHAR(20) DEFAULT 'string',
Category VARCHAR(50) DEFAULT 'general',
DisplayName VARCHAR(100),
Description VARCHAR(500),
Options TEXT,
IsPublic BOOLEAN DEFAULT FALSE,
UpdatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS IdempotencyKeys (
Id SERIAL PRIMARY KEY,
IdempotencyKey VARCHAR(64) UNIQUE NOT NULL,
RequestPath VARCHAR(200) NOT NULL,
RequestHash VARCHAR(64),
ResponseCode INT,
ResponseBody TEXT,
CreatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
ExpiresAt TIMESTAMP NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_idempotency_key ON IdempotencyKeys(IdempotencyKey);
CREATE INDEX IF NOT EXISTS idx_idempotency_expires ON IdempotencyKeys(ExpiresAt);

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" } }
]
}
}