Initial commit
This commit is contained in:
10
license-system-backend/.env.example
Normal file
10
license-system-backend/.env.example
Normal 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
|
||||
17
license-system-backend/Dockerfile
Normal file
17
license-system-backend/Dockerfile
Normal 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"]
|
||||
62
license-system-backend/README.md
Normal file
62
license-system-backend/README.md
Normal 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).
|
||||
60
license-system-backend/docker-compose.yml
Normal file
60
license-system-backend/docker-compose.yml
Normal 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"
|
||||
296
license-system-backend/docs/frontend-handoff.md
Normal file
296
license-system-backend/docs/frontend-handoff.md
Normal 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` 外为预留接口。
|
||||
212
license-system-backend/scripts/init.sql
Normal file
212
license-system-backend/scripts/init.sql
Normal 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);
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
31
license-system-backend/src/License.Api/DTOs/AdminDtos.cs
Normal file
31
license-system-backend/src/License.Api/DTOs/AdminDtos.cs
Normal 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; }
|
||||
}
|
||||
68
license-system-backend/src/License.Api/DTOs/AgentDtos.cs
Normal file
68
license-system-backend/src/License.Api/DTOs/AgentDtos.cs
Normal 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; }
|
||||
}
|
||||
24
license-system-backend/src/License.Api/DTOs/ApiResponse.cs
Normal file
24
license-system-backend/src/License.Api/DTOs/ApiResponse.cs
Normal 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 };
|
||||
}
|
||||
38
license-system-backend/src/License.Api/DTOs/AuthDtos.cs
Normal file
38
license-system-backend/src/License.Api/DTOs/AuthDtos.cs
Normal 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; }
|
||||
}
|
||||
60
license-system-backend/src/License.Api/DTOs/CardDtos.cs
Normal file
60
license-system-backend/src/License.Api/DTOs/CardDtos.cs
Normal 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; }
|
||||
}
|
||||
15
license-system-backend/src/License.Api/DTOs/PagedResult.cs
Normal file
15
license-system-backend/src/License.Api/DTOs/PagedResult.cs
Normal 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; }
|
||||
}
|
||||
78
license-system-backend/src/License.Api/DTOs/ProjectDtos.cs
Normal file
78
license-system-backend/src/License.Api/DTOs/ProjectDtos.cs
Normal 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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
19
license-system-backend/src/License.Api/DTOs/SoftwareDtos.cs
Normal file
19
license-system-backend/src/License.Api/DTOs/SoftwareDtos.cs
Normal 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; }
|
||||
}
|
||||
27
license-system-backend/src/License.Api/DTOs/StatsDtos.cs
Normal file
27
license-system-backend/src/License.Api/DTOs/StatsDtos.cs
Normal 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; }
|
||||
}
|
||||
164
license-system-backend/src/License.Api/Data/AppDbContext.cs
Normal file
164
license-system-backend/src/License.Api/Data/AppDbContext.cs
Normal 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");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
};
|
||||
}
|
||||
}
|
||||
26
license-system-backend/src/License.Api/License.Api.csproj
Normal file
26
license-system-backend/src/License.Api/License.Api.csproj
Normal 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>
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
29
license-system-backend/src/License.Api/Models/AccessLog.cs
Normal file
29
license-system-backend/src/License.Api/Models/AccessLog.cs
Normal 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; }
|
||||
}
|
||||
33
license-system-backend/src/License.Api/Models/Admin.cs
Normal file
33
license-system-backend/src/License.Api/Models/Admin.cs
Normal 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; }
|
||||
}
|
||||
46
license-system-backend/src/License.Api/Models/Agent.cs
Normal file
46
license-system-backend/src/License.Api/Models/Agent.cs
Normal 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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
59
license-system-backend/src/License.Api/Models/CardKey.cs
Normal file
59
license-system-backend/src/License.Api/Models/CardKey.cs
Normal 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>();
|
||||
}
|
||||
27
license-system-backend/src/License.Api/Models/CardKeyLog.cs
Normal file
27
license-system-backend/src/License.Api/Models/CardKeyLog.cs
Normal 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; }
|
||||
}
|
||||
35
license-system-backend/src/License.Api/Models/Device.cs
Normal file
35
license-system-backend/src/License.Api/Models/Device.cs
Normal 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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
43
license-system-backend/src/License.Api/Models/Project.cs
Normal file
43
license-system-backend/src/License.Api/Models/Project.cs
Normal 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>();
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
20
license-system-backend/src/License.Api/Models/Statistic.cs
Normal file
20
license-system-backend/src/License.Api/Models/Statistic.cs
Normal 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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
10
license-system-backend/src/License.Api/Options/JwtOptions.cs
Normal file
10
license-system-backend/src/License.Api/Options/JwtOptions.cs
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace License.Api.Options;
|
||||
|
||||
public class RedisOptions
|
||||
{
|
||||
public string ConnectionString { get; set; } = string.Empty;
|
||||
public bool Enabled { get; set; } = true;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace License.Api.Options;
|
||||
|
||||
public class SecurityOptions
|
||||
{
|
||||
public bool SignatureEnabled { get; set; } = true;
|
||||
public int TimestampToleranceSeconds { get; set; } = 300;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
165
license-system-backend/src/License.Api/Program.cs
Normal file
165
license-system-backend/src/License.Api/Program.cs
Normal 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();
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
357
license-system-backend/src/License.Api/Services/AuthService.cs
Normal file
357
license-system-backend/src/License.Api/Services/AuthService.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
168
license-system-backend/src/License.Api/Services/CardService.cs
Normal file
168
license-system-backend/src/License.Api/Services/CardService.cs
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
259
license-system-backend/src/License.Api/Services/StatsService.cs
Normal file
259
license-system-backend/src/License.Api/Services/StatsService.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
32
license-system-backend/src/License.Api/Utils/CardDefaults.cs
Normal file
32
license-system-backend/src/License.Api/Utils/CardDefaults.cs
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
170
license-system-backend/src/License.Api/Utils/CardKeyGenerator.cs
Normal file
170
license-system-backend/src/License.Api/Utils/CardKeyGenerator.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
16
license-system-backend/src/License.Api/Utils/RsaKeyLoader.cs
Normal file
16
license-system-backend/src/License.Api/Utils/RsaKeyLoader.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
54
license-system-backend/src/License.Api/appsettings.json
Normal file
54
license-system-backend/src/License.Api/appsettings.json
Normal 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" } }
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user