commit d3178871eb764aa4273e7e79797e829ca86abe88 Author: yuyx <237899745@qq.com> Date: Sun Jan 4 23:00:21 2026 +0800 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4ff1b3a --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +# Runtime/data +license-system-backend/data/ + +# Node/Vite +**/node_modules/ +**/dist/ +**/npm-debug.log* +**/yarn-debug.log* +**/yarn-error.log* + +# .NET (avoid ignoring Rust src/bin) +license-system-backend/**/bin/ +license-system-backend/**/obj/ + +# Rust +**/target/ + +# Env files (keep examples) +**/.env +**/.env.local +**/.env.development +**/.env.production +**/.env.test +!**/.env.example + +# OS/editor +**/.DS_Store +**/*.log diff --git a/license-system-backend/.env.example b/license-system-backend/.env.example new file mode 100644 index 0000000..1423e62 --- /dev/null +++ b/license-system-backend/.env.example @@ -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 diff --git a/license-system-backend/Dockerfile b/license-system-backend/Dockerfile new file mode 100644 index 0000000..c0e2b25 --- /dev/null +++ b/license-system-backend/Dockerfile @@ -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"] diff --git a/license-system-backend/README.md b/license-system-backend/README.md new file mode 100644 index 0000000..7880c1e --- /dev/null +++ b/license-system-backend/README.md @@ -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). diff --git a/license-system-backend/docker-compose.yml b/license-system-backend/docker-compose.yml new file mode 100644 index 0000000..97700a1 --- /dev/null +++ b/license-system-backend/docker-compose.yml @@ -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" diff --git a/license-system-backend/docs/frontend-handoff.md b/license-system-backend/docs/frontend-handoff.md new file mode 100644 index 0000000..cbad1fa --- /dev/null +++ b/license-system-backend/docs/frontend-handoff.md @@ -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 +``` + +### 代理商 + +代理商登录后也使用 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: ` +- `X-Encryption-Nonce: ` + +## 注意事项 + +- 上传软件版本需要 `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` 外为预留接口。 diff --git a/license-system-backend/scripts/init.sql b/license-system-backend/scripts/init.sql new file mode 100644 index 0000000..faaad40 --- /dev/null +++ b/license-system-backend/scripts/init.sql @@ -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); diff --git a/license-system-backend/src/License.Api/Controllers/AdminAgentsController.cs b/license-system-backend/src/License.Api/Controllers/AdminAgentsController.cs new file mode 100644 index 0000000..329f4ba --- /dev/null +++ b/license-system-backend/src/License.Api/Controllers/AdminAgentsController.cs @@ -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 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.Ok(data)); + } + + [HttpGet] + public async Task 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 + { + Items = items, + Pagination = new PaginationInfo + { + Page = page, + PageSize = pageSize, + Total = total, + TotalPages = (int)Math.Ceiling(total / (double)pageSize) + } + }; + + return Ok(ApiResponse>.Ok(result)); + } + + [HttpGet("{id:int}")] + public async Task 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.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 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 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 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 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 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 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 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>.Ok(items)); + } + + private async Task 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()); + } +} diff --git a/license-system-backend/src/License.Api/Controllers/AdminAuthController.cs b/license-system-backend/src/License.Api/Controllers/AdminAuthController.cs new file mode 100644 index 0000000..8b30699 --- /dev/null +++ b/license-system-backend/src/License.Api/Controllers/AdminAuthController.cs @@ -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 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.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 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.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 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 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 ResolvePermissions(Admin admin) + { + if (string.Equals(admin.Role, "super_admin", StringComparison.OrdinalIgnoreCase)) + return new List { "*" }; + + var (hasAll, allowed) = AdminAccessService.ParsePermissions(admin.Permissions); + if (hasAll) + return new List { "*" }; + return allowed.OrderBy(p => p).ToList(); + } +} diff --git a/license-system-backend/src/License.Api/Controllers/AdminCardsController.cs b/license-system-backend/src/License.Api/Controllers/AdminCardsController.cs new file mode 100644 index 0000000..0063d74 --- /dev/null +++ b/license-system-backend/src/License.Api/Controllers/AdminCardsController.cs @@ -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 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>(existing.ResponseBody ?? "{}") + ?? ApiResponse.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.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 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 + { + Items = new List(), + Pagination = new PaginationInfo + { + Page = page, + PageSize = pageSize, + Total = 0, + TotalPages = 0 + } + }; + return Ok(ApiResponse>.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 + { + Items = items, + Pagination = new PaginationInfo + { + Page = page, + PageSize = pageSize, + Total = total, + TotalPages = (int)Math.Ceiling(total / (double)pageSize) + } + }; + + return Ok(ApiResponse>.Ok(result)); + } + + [HttpGet("{id:int}")] + public async Task 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.Ok(card)); + } + + [HttpGet("{id:int}/logs")] + public async Task 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>.Ok(logs)); + } + + [HttpPut("{id:int}")] + public async Task 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 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 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 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 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 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 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 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 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 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 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(); + 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.Ok(new + { + total = successes + failures.Count, + success = successes, + failed = failures.Count, + errors = failures + })); + } +} diff --git a/license-system-backend/src/License.Api/Controllers/AdminDevicesController.cs b/license-system-backend/src/License.Api/Controllers/AdminDevicesController.cs new file mode 100644 index 0000000..1d9f7d4 --- /dev/null +++ b/license-system-backend/src/License.Api/Controllers/AdminDevicesController.cs @@ -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 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>.Ok(new List())); + 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>.Ok(items)); + } + + [HttpDelete("{id:int}")] + public async Task 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 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()); + } +} diff --git a/license-system-backend/src/License.Api/Controllers/AdminLogsController.cs b/license-system-backend/src/License.Api/Controllers/AdminLogsController.cs new file mode 100644 index 0000000..d00c87a --- /dev/null +++ b/license-system-backend/src/License.Api/Controllers/AdminLogsController.cs @@ -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 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 + { + Items = new List(), + Pagination = new PaginationInfo + { + Page = page, + PageSize = pageSize, + Total = 0, + TotalPages = 0 + } + }; + return Ok(ApiResponse>.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 + { + Items = items, + Pagination = new PaginationInfo + { + Page = page, + PageSize = pageSize, + Total = total, + TotalPages = (int)Math.Ceiling(total / (double)pageSize) + } + }; + + return Ok(ApiResponse>.Ok(result)); + } + + [HttpGet("{id:int}")] + public async Task 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.Ok(log)); + } +} diff --git a/license-system-backend/src/License.Api/Controllers/AdminProjectsController.cs b/license-system-backend/src/License.Api/Controllers/AdminProjectsController.cs new file mode 100644 index 0000000..cc76004 --- /dev/null +++ b/license-system-backend/src/License.Api/Controllers/AdminProjectsController.cs @@ -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 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.Ok(data)); + } + + [HttpGet] + public async Task 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 + { + Items = new List(), + Pagination = new PaginationInfo + { + Page = page, + PageSize = pageSize, + Total = 0, + TotalPages = 0 + } + }; + return Ok(ApiResponse>.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 + { + Items = items, + Pagination = new PaginationInfo + { + Page = page, + PageSize = pageSize, + Total = total, + TotalPages = (int)Math.Ceiling(total / (double)pageSize) + } + }; + + return Ok(ApiResponse>.Ok(result)); + } + + [HttpGet("{id:int}")] + public async Task 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.Ok(data)); + } + + [HttpPut("{id:int}")] + public async Task 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 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 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.Ok(stats)); + } + + [HttpGet("{id:int}/docs")] + public async Task 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.Ok(new { content = project.DocsContent ?? string.Empty })); + } + + [HttpPut("{id:int}/docs")] + public async Task 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 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>.Ok(items)); + } + + [HttpPost("{id:int}/pricing")] + public async Task 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.Ok(pricing)); + } + + [HttpPut("{id:int}/pricing/{priceId:int}")] + public async Task 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 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 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>.Ok(items)); + } + + [HttpPost("{id:int}/versions")] + [RequestSizeLimit(1024L * 1024L * 500L)] + public async Task 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.Ok(data)); + } + + [HttpPut("{id:int}/versions/{versionId:int}")] + public async Task 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 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()); + } +} diff --git a/license-system-backend/src/License.Api/Controllers/AdminSettingsController.cs b/license-system-backend/src/License.Api/Controllers/AdminSettingsController.cs new file mode 100644 index 0000000..de3d272 --- /dev/null +++ b/license-system-backend/src/License.Api/Controllers/AdminSettingsController.cs @@ -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 GetSettings() + { + var configs = await _db.SystemConfigs.OrderBy(c => c.Category).ToListAsync(); + return Ok(ApiResponse>.Ok(configs)); + } + + [HttpPut("settings")] + public async Task UpdateSettings([FromBody] List 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 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.Ok(data)); + } + + [HttpPost("admins")] + public async Task 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.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 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 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 ResolvePermissions(string role, string? raw) + { + if (string.Equals(role, "super_admin", StringComparison.OrdinalIgnoreCase)) + return new List { "*" }; + + var (hasAll, allowed) = AdminAccessService.ParsePermissions(raw); + if (hasAll) + return new List { "*" }; + return allowed.OrderBy(p => p).ToList(); + } +} diff --git a/license-system-backend/src/License.Api/Controllers/AdminStatsController.cs b/license-system-backend/src/License.Api/Controllers/AdminStatsController.cs new file mode 100644 index 0000000..a68f513 --- /dev/null +++ b/license-system-backend/src/License.Api/Controllers/AdminStatsController.cs @@ -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 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.Ok(data)); + } + + [HttpGet("projects")] + public async Task 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>.Ok(items)); + } + + [HttpGet("agents")] + public async Task 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>.Ok(items)); + } + + [HttpGet("logs")] + public async Task 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>.Ok(items)); + } + + [HttpGet("export")] + public async Task 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"); + } +} diff --git a/license-system-backend/src/License.Api/Controllers/AgentAuthController.cs b/license-system-backend/src/License.Api/Controllers/AgentAuthController.cs new file mode 100644 index 0000000..6a4c7b1 --- /dev/null +++ b/license-system-backend/src/License.Api/Controllers/AgentAuthController.cs @@ -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 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() + : JsonSerializer.Deserialize>(agent.AllowedProjects) ?? new List(); + + 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.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 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.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 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 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 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>.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(); + } +} diff --git a/license-system-backend/src/License.Api/Controllers/AgentCardsController.cs b/license-system-backend/src/License.Api/Controllers/AgentCardsController.cs new file mode 100644 index 0000000..5c2ba2e --- /dev/null +++ b/license-system-backend/src/License.Api/Controllers/AgentCardsController.cs @@ -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 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() + : JsonSerializer.Deserialize>(agent.AllowedProjects) ?? new List(); + + 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>(existing.ResponseBody ?? "{}") + ?? ApiResponse.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.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 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 + { + Items = items, + Pagination = new PaginationInfo + { + Page = page, + PageSize = pageSize, + Total = total, + TotalPages = (int)Math.Ceiling(total / (double)pageSize) + } + }; + + return Ok(ApiResponse>.Ok(result)); + } +} diff --git a/license-system-backend/src/License.Api/Controllers/AuthController.cs b/license-system-backend/src/License.Api/Controllers/AuthController.cs new file mode 100644 index 0000000..1ae3bc7 --- /dev/null +++ b/license-system-backend/src/License.Api/Controllers/AuthController.cs @@ -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 Verify([FromBody] AuthVerifyRequest request) + { + var (response, status) = await _auth.VerifyAsync(request, HttpContext); + return StatusCode(status, response); + } + + [HttpPost("heartbeat")] + public async Task Heartbeat([FromBody] AuthHeartbeatRequest request) + { + var (response, status) = await _auth.HeartbeatAsync(request, HttpContext); + return StatusCode(status, response); + } +} diff --git a/license-system-backend/src/License.Api/Controllers/HealthController.cs b/license-system-backend/src/License.Api/Controllers/HealthController.cs new file mode 100644 index 0000000..c7b981f --- /dev/null +++ b/license-system-backend/src/License.Api/Controllers/HealthController.cs @@ -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 Ready() + { + var checks = new Dictionary(); + 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 }); + } +} diff --git a/license-system-backend/src/License.Api/Controllers/PublicConfigController.cs b/license-system-backend/src/License.Api/Controllers/PublicConfigController.cs new file mode 100644 index 0000000..e6a4a5e --- /dev/null +++ b/license-system-backend/src/License.Api/Controllers/PublicConfigController.cs @@ -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 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.Ok(configs)); + } +} diff --git a/license-system-backend/src/License.Api/Controllers/SoftwareController.cs b/license-system-backend/src/License.Api/Controllers/SoftwareController.cs new file mode 100644 index 0000000..4968e3f --- /dev/null +++ b/license-system-backend/src/License.Api/Controllers/SoftwareController.cs @@ -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 _logger; + private readonly ConfigService _config; + private readonly StorageOptions _storage; + + public SoftwareController(AppDbContext db, SoftwareService software, JwtTokenService jwt, ILogger logger, ConfigService config, IOptions storageOptions) + { + _db = db; + _software = software; + _jwt = jwt; + _logger = logger; + _config = config; + _storage = storageOptions.Value; + } + + [HttpPost("check-update")] + public async Task 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.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.Ok(data)); + } + + [HttpGet("download")] + public async Task 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); + } +} diff --git a/license-system-backend/src/License.Api/DTOs/AdminDtos.cs b/license-system-backend/src/License.Api/DTOs/AdminDtos.cs new file mode 100644 index 0000000..241e425 --- /dev/null +++ b/license-system-backend/src/License.Api/DTOs/AdminDtos.cs @@ -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; } +} diff --git a/license-system-backend/src/License.Api/DTOs/AgentDtos.cs b/license-system-backend/src/License.Api/DTOs/AgentDtos.cs new file mode 100644 index 0000000..62c7376 --- /dev/null +++ b/license-system-backend/src/License.Api/DTOs/AgentDtos.cs @@ -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? 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? 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; } +} diff --git a/license-system-backend/src/License.Api/DTOs/ApiResponse.cs b/license-system-backend/src/License.Api/DTOs/ApiResponse.cs new file mode 100644 index 0000000..a29ac1c --- /dev/null +++ b/license-system-backend/src/License.Api/DTOs/ApiResponse.cs @@ -0,0 +1,24 @@ +namespace License.Api.DTOs; + +public class ApiResponse +{ + 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 Ok(T? data, string message = "success") + => new() { Code = 200, Message = message, Data = data }; + + public static ApiResponse Fail(int code, string message) + => new() { Code = code, Message = message, Data = default }; +} + +public class ApiResponse : ApiResponse +{ + 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 }; +} diff --git a/license-system-backend/src/License.Api/DTOs/AuthDtos.cs b/license-system-backend/src/License.Api/DTOs/AuthDtos.cs new file mode 100644 index 0000000..fc33357 --- /dev/null +++ b/license-system-backend/src/License.Api/DTOs/AuthDtos.cs @@ -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; } +} diff --git a/license-system-backend/src/License.Api/DTOs/CardDtos.cs b/license-system-backend/src/License.Api/DTOs/CardDtos.cs new file mode 100644 index 0000000..f2838a5 --- /dev/null +++ b/license-system-backend/src/License.Api/DTOs/CardDtos.cs @@ -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 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 Ids { get; set; } = new(); + public string? Reason { get; set; } +} + +public class AgentCardGenerateResponse +{ + public string BatchId { get; set; } = string.Empty; + public List 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; } +} diff --git a/license-system-backend/src/License.Api/DTOs/PagedResult.cs b/license-system-backend/src/License.Api/DTOs/PagedResult.cs new file mode 100644 index 0000000..e81e469 --- /dev/null +++ b/license-system-backend/src/License.Api/DTOs/PagedResult.cs @@ -0,0 +1,15 @@ +namespace License.Api.DTOs; + +public class PagedResult +{ + public List 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; } +} diff --git a/license-system-backend/src/License.Api/DTOs/ProjectDtos.cs b/license-system-backend/src/License.Api/DTOs/ProjectDtos.cs new file mode 100644 index 0000000..bf1a04b --- /dev/null +++ b/license-system-backend/src/License.Api/DTOs/ProjectDtos.cs @@ -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; } +} diff --git a/license-system-backend/src/License.Api/DTOs/SoftwareAdminDtos.cs b/license-system-backend/src/License.Api/DTOs/SoftwareAdminDtos.cs new file mode 100644 index 0000000..168b0b6 --- /dev/null +++ b/license-system-backend/src/License.Api/DTOs/SoftwareAdminDtos.cs @@ -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; } +} diff --git a/license-system-backend/src/License.Api/DTOs/SoftwareDtos.cs b/license-system-backend/src/License.Api/DTOs/SoftwareDtos.cs new file mode 100644 index 0000000..dfccd41 --- /dev/null +++ b/license-system-backend/src/License.Api/DTOs/SoftwareDtos.cs @@ -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; } +} diff --git a/license-system-backend/src/License.Api/DTOs/StatsDtos.cs b/license-system-backend/src/License.Api/DTOs/StatsDtos.cs new file mode 100644 index 0000000..ded7a1b --- /dev/null +++ b/license-system-backend/src/License.Api/DTOs/StatsDtos.cs @@ -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; } +} diff --git a/license-system-backend/src/License.Api/Data/AppDbContext.cs b/license-system-backend/src/License.Api/Data/AppDbContext.cs new file mode 100644 index 0000000..6fc27a0 --- /dev/null +++ b/license-system-backend/src/License.Api/Data/AppDbContext.cs @@ -0,0 +1,164 @@ +using License.Api.Models; +using Microsoft.EntityFrameworkCore; + +namespace License.Api.Data; + +public class AppDbContext : DbContext +{ + public AppDbContext(DbContextOptions options) : base(options) + { + } + + public DbSet Projects => Set(); + public DbSet ProjectPricing => Set(); + public DbSet SoftwareVersions => Set(); + public DbSet CardKeys => Set(); + public DbSet Devices => Set(); + public DbSet AccessLogs => Set(); + public DbSet Statistics => Set(); + public DbSet Admins => Set(); + public DbSet Agents => Set(); + public DbSet AgentTransactions => Set(); + public DbSet CardKeyLogs => Set(); + public DbSet SystemConfigs => Set(); + public DbSet IdempotencyKeys => Set(); + + public override int SaveChanges() + { + BumpCardKeyVersion(); + return base.SaveChanges(); + } + + public override Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + BumpCardKeyVersion(); + return base.SaveChangesAsync(cancellationToken); + } + + private void BumpCardKeyVersion() + { + foreach (var entry in ChangeTracker.Entries()) + { + if (entry.State == EntityState.Modified) + entry.Entity.Version += 1; + } + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(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(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(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(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(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(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(entity => + { + entity.ToTable("Statistics"); + entity.HasIndex(s => new { s.ProjectId, s.Date }).IsUnique(); + entity.Property(s => s.Date).HasColumnType("date"); + }); + + modelBuilder.Entity(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(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(entity => + { + entity.ToTable("AgentTransactions"); + entity.Property(t => t.CreatedAt).HasDefaultValueSql("CURRENT_TIMESTAMP"); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("CardKeyLogs"); + entity.Property(l => l.CreatedAt).HasDefaultValueSql("CURRENT_TIMESTAMP"); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("SystemConfigs"); + entity.HasIndex(c => c.ConfigKey).IsUnique(); + entity.Property(c => c.UpdatedAt).HasDefaultValueSql("CURRENT_TIMESTAMP"); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("IdempotencyKeys"); + entity.HasIndex(i => i.IdempotencyKey).IsUnique(); + entity.HasIndex(i => i.ExpiresAt); + entity.Property(i => i.CreatedAt).HasDefaultValueSql("CURRENT_TIMESTAMP"); + }); + } +} diff --git a/license-system-backend/src/License.Api/Data/DatabaseInitializer.cs b/license-system-backend/src/License.Api/Data/DatabaseInitializer.cs new file mode 100644 index 0000000..808391c --- /dev/null +++ b/license-system-backend/src/License.Api/Data/DatabaseInitializer.cs @@ -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) + { + _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 DefaultConfigs() + { + return new List + { + 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 } + }; + } +} diff --git a/license-system-backend/src/License.Api/License.Api.csproj b/license-system-backend/src/License.Api/License.Api.csproj new file mode 100644 index 0000000..b4b4276 --- /dev/null +++ b/license-system-backend/src/License.Api/License.Api.csproj @@ -0,0 +1,26 @@ + + + net8.0 + enable + enable + true + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + diff --git a/license-system-backend/src/License.Api/Middlewares/ExceptionHandlingMiddleware.cs b/license-system-backend/src/License.Api/Middlewares/ExceptionHandlingMiddleware.cs new file mode 100644 index 0000000..1ca112c --- /dev/null +++ b/license-system-backend/src/License.Api/Middlewares/ExceptionHandlingMiddleware.cs @@ -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 _logger; + + public ExceptionHandlingMiddleware(RequestDelegate next, ILogger 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)); + } + } +} diff --git a/license-system-backend/src/License.Api/Middlewares/RateLimitMiddleware.cs b/license-system-backend/src/License.Api/Middlewares/RateLimitMiddleware.cs new file mode 100644 index 0000000..1126b1d --- /dev/null +++ b/license-system-backend/src/License.Api/Middlewares/RateLimitMiddleware.cs @@ -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 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); + } +} diff --git a/license-system-backend/src/License.Api/Models/AccessLog.cs b/license-system-backend/src/License.Api/Models/AccessLog.cs new file mode 100644 index 0000000..3c3e96d --- /dev/null +++ b/license-system-backend/src/License.Api/Models/AccessLog.cs @@ -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; } +} diff --git a/license-system-backend/src/License.Api/Models/Admin.cs b/license-system-backend/src/License.Api/Models/Admin.cs new file mode 100644 index 0000000..1ac9229 --- /dev/null +++ b/license-system-backend/src/License.Api/Models/Admin.cs @@ -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; } +} diff --git a/license-system-backend/src/License.Api/Models/Agent.cs b/license-system-backend/src/License.Api/Models/Agent.cs new file mode 100644 index 0000000..d54925b --- /dev/null +++ b/license-system-backend/src/License.Api/Models/Agent.cs @@ -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; } +} diff --git a/license-system-backend/src/License.Api/Models/AgentTransaction.cs b/license-system-backend/src/License.Api/Models/AgentTransaction.cs new file mode 100644 index 0000000..64563e6 --- /dev/null +++ b/license-system-backend/src/License.Api/Models/AgentTransaction.cs @@ -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; } +} diff --git a/license-system-backend/src/License.Api/Models/CardKey.cs b/license-system-backend/src/License.Api/Models/CardKey.cs new file mode 100644 index 0000000..e43bbc4 --- /dev/null +++ b/license-system-backend/src/License.Api/Models/CardKey.cs @@ -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 Devices { get; set; } = new List(); + + public ICollection Logs { get; set; } = new List(); +} diff --git a/license-system-backend/src/License.Api/Models/CardKeyLog.cs b/license-system-backend/src/License.Api/Models/CardKeyLog.cs new file mode 100644 index 0000000..e6ddbab --- /dev/null +++ b/license-system-backend/src/License.Api/Models/CardKeyLog.cs @@ -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; } +} diff --git a/license-system-backend/src/License.Api/Models/Device.cs b/license-system-backend/src/License.Api/Models/Device.cs new file mode 100644 index 0000000..471e8e4 --- /dev/null +++ b/license-system-backend/src/License.Api/Models/Device.cs @@ -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; } +} diff --git a/license-system-backend/src/License.Api/Models/IdempotencyKeyRecord.cs b/license-system-backend/src/License.Api/Models/IdempotencyKeyRecord.cs new file mode 100644 index 0000000..21b9a96 --- /dev/null +++ b/license-system-backend/src/License.Api/Models/IdempotencyKeyRecord.cs @@ -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; } +} diff --git a/license-system-backend/src/License.Api/Models/Project.cs b/license-system-backend/src/License.Api/Models/Project.cs new file mode 100644 index 0000000..ecf0f87 --- /dev/null +++ b/license-system-backend/src/License.Api/Models/Project.cs @@ -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 Pricing { get; set; } = new List(); + + public ICollection Versions { get; set; } = new List(); +} diff --git a/license-system-backend/src/License.Api/Models/ProjectPricing.cs b/license-system-backend/src/License.Api/Models/ProjectPricing.cs new file mode 100644 index 0000000..2784441 --- /dev/null +++ b/license-system-backend/src/License.Api/Models/ProjectPricing.cs @@ -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; } +} diff --git a/license-system-backend/src/License.Api/Models/SoftwareVersion.cs b/license-system-backend/src/License.Api/Models/SoftwareVersion.cs new file mode 100644 index 0000000..7289b57 --- /dev/null +++ b/license-system-backend/src/License.Api/Models/SoftwareVersion.cs @@ -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; } +} diff --git a/license-system-backend/src/License.Api/Models/Statistic.cs b/license-system-backend/src/License.Api/Models/Statistic.cs new file mode 100644 index 0000000..feccda6 --- /dev/null +++ b/license-system-backend/src/License.Api/Models/Statistic.cs @@ -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; } +} diff --git a/license-system-backend/src/License.Api/Models/SystemConfig.cs b/license-system-backend/src/License.Api/Models/SystemConfig.cs new file mode 100644 index 0000000..23949e6 --- /dev/null +++ b/license-system-backend/src/License.Api/Models/SystemConfig.cs @@ -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; } +} diff --git a/license-system-backend/src/License.Api/Options/HeartbeatOptions.cs b/license-system-backend/src/License.Api/Options/HeartbeatOptions.cs new file mode 100644 index 0000000..f9c1e89 --- /dev/null +++ b/license-system-backend/src/License.Api/Options/HeartbeatOptions.cs @@ -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; +} diff --git a/license-system-backend/src/License.Api/Options/JwtOptions.cs b/license-system-backend/src/License.Api/Options/JwtOptions.cs new file mode 100644 index 0000000..765100f --- /dev/null +++ b/license-system-backend/src/License.Api/Options/JwtOptions.cs @@ -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; +} diff --git a/license-system-backend/src/License.Api/Options/RateLimitOptions.cs b/license-system-backend/src/License.Api/Options/RateLimitOptions.cs new file mode 100644 index 0000000..3eead6d --- /dev/null +++ b/license-system-backend/src/License.Api/Options/RateLimitOptions.cs @@ -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; +} diff --git a/license-system-backend/src/License.Api/Options/RedisOptions.cs b/license-system-backend/src/License.Api/Options/RedisOptions.cs new file mode 100644 index 0000000..c0b15be --- /dev/null +++ b/license-system-backend/src/License.Api/Options/RedisOptions.cs @@ -0,0 +1,7 @@ +namespace License.Api.Options; + +public class RedisOptions +{ + public string ConnectionString { get; set; } = string.Empty; + public bool Enabled { get; set; } = true; +} diff --git a/license-system-backend/src/License.Api/Options/SecurityOptions.cs b/license-system-backend/src/License.Api/Options/SecurityOptions.cs new file mode 100644 index 0000000..4da7c7a --- /dev/null +++ b/license-system-backend/src/License.Api/Options/SecurityOptions.cs @@ -0,0 +1,7 @@ +namespace License.Api.Options; + +public class SecurityOptions +{ + public bool SignatureEnabled { get; set; } = true; + public int TimestampToleranceSeconds { get; set; } = 300; +} diff --git a/license-system-backend/src/License.Api/Options/SeedOptions.cs b/license-system-backend/src/License.Api/Options/SeedOptions.cs new file mode 100644 index 0000000..8f4ecf7 --- /dev/null +++ b/license-system-backend/src/License.Api/Options/SeedOptions.cs @@ -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; } +} diff --git a/license-system-backend/src/License.Api/Options/StorageOptions.cs b/license-system-backend/src/License.Api/Options/StorageOptions.cs new file mode 100644 index 0000000..d77fc24 --- /dev/null +++ b/license-system-backend/src/License.Api/Options/StorageOptions.cs @@ -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; +} diff --git a/license-system-backend/src/License.Api/Program.cs b/license-system-backend/src/License.Api/Program.cs new file mode 100644 index 0000000..8ad910d --- /dev/null +++ b/license-system-backend/src/License.Api/Program.cs @@ -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(builder.Configuration.GetSection("Jwt")); +builder.Services.Configure(builder.Configuration.GetSection("Security")); +builder.Services.Configure(builder.Configuration.GetSection("Storage")); +builder.Services.Configure(builder.Configuration.GetSection("Redis")); +builder.Services.Configure(builder.Configuration.GetSection("RateLimit")); +builder.Services.Configure(builder.Configuration.GetSection("Heartbeat")); +builder.Services.Configure(builder.Configuration.GetSection("Seed")); + +builder.Services.AddDbContext(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(); +builder.Services.AddSingleton(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddHostedService(); +builder.Services.AddHostedService(); +builder.Services.AddHostedService(); + +var redisOptions = builder.Configuration.GetSection("Redis").Get() ?? new RedisOptions(); +if (redisOptions.Enabled && !string.IsNullOrWhiteSpace(redisOptions.ConnectionString)) +{ + builder.Services.AddSingleton(_ => + ConnectionMultiplexer.Connect(redisOptions.ConnectionString)); +} + +builder.Services.AddScoped(sp => +{ + var multiplexer = sp.GetService(); + if (multiplexer != null) + return new RedisRateLimitStore(multiplexer); + + return new MemoryRateLimitStore(sp.GetRequiredService()); +}); + +var jwtOptions = builder.Configuration.GetSection("Jwt").Get() ?? 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("Cors:AllowAny"); + var origins = builder.Configuration.GetSection("Cors:AllowedOrigins").Get() ?? Array.Empty(); + 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(); +app.UseMiddleware(); + +app.UseSwagger(); +app.UseSwaggerUI(); + +app.UseCors("AllowAll"); + +app.UseAuthentication(); +app.UseAuthorization(); + +app.MapControllers(); + +using (var scope = app.Services.CreateScope()) +{ + var initializer = scope.ServiceProvider.GetRequiredService(); + await initializer.InitializeAsync(); +} + +app.Run(); diff --git a/license-system-backend/src/License.Api/Security/HmacSignatureService.cs b/license-system-backend/src/License.Api/Security/HmacSignatureService.cs new file mode 100644 index 0000000..cc8e8c4 --- /dev/null +++ b/license-system-backend/src/License.Api/Security/HmacSignatureService.cs @@ -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 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); + } +} diff --git a/license-system-backend/src/License.Api/Security/JwtTokenService.cs b/license-system-backend/src/License.Api/Security/JwtTokenService.cs new file mode 100644 index 0000000..9c34adf --- /dev/null +++ b/license-system-backend/src/License.Api/Security/JwtTokenService.cs @@ -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 options) + { + _options = options.Value; + } + + public string CreateAdminToken(Admin admin) + { + var claims = new List + { + 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 + { + 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 + { + 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 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); + } +} diff --git a/license-system-backend/src/License.Api/Services/AdminAccessService.cs b/license-system-backend/src/License.Api/Services/AdminAccessService.cs new file mode 100644 index 0000000..e15c460 --- /dev/null +++ b/license-system-backend/src/License.Api/Services/AdminAccessService.cs @@ -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 AllowedProjects { get; } + + public AdminScope(Admin admin, bool isSuperAdmin, bool hasAllProjects, HashSet 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 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 allowedProjects) ParsePermissions(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + return (false, new HashSet(StringComparer.OrdinalIgnoreCase)); + + var trimmed = raw.Trim(); + if (trimmed == "*") + return (true, new HashSet(StringComparer.OrdinalIgnoreCase)); + + List? items = null; + if (trimmed.StartsWith("[", StringComparison.Ordinal)) + { + try + { + items = JsonSerializer.Deserialize>(trimmed); + } + catch + { + items = null; + } + } + + if (items == null) + { + items = trimmed + .Split(new[] { ',', ';', '\n', '\r', '\t' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .ToList(); + } + + var set = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var item in items) + { + if (string.IsNullOrWhiteSpace(item)) + continue; + if (item == "*") + return (true, new HashSet(StringComparer.OrdinalIgnoreCase)); + set.Add(item); + } + + return (false, set); + } +} diff --git a/license-system-backend/src/License.Api/Services/AuthService.cs b/license-system-backend/src/License.Api/Services/AuthService.cs new file mode 100644 index 0000000..0a0768c --- /dev/null +++ b/license-system-backend/src/License.Api/Services/AuthService.cs @@ -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 heartbeat, + ConfigService config, + RiskControlService risk) + { + _db = db; + _jwt = jwt; + _hmac = hmac; + _heartbeat = heartbeat.Value; + _config = config; + _risk = risk; + } + + public async Task<(ApiResponse response, int httpStatus)> VerifyAsync(AuthVerifyRequest request, HttpContext httpContext) + { + if (!_hmac.ValidateTimestamp(request.Timestamp)) + return (ApiResponse.Fail(1008, "timestamp_expired"), StatusCodes.Status400BadRequest); + + var project = await _db.Projects.FirstOrDefaultAsync(p => p.ProjectId == request.ProjectId); + if (project == null) + return (ApiResponse.Fail(1001, "card_invalid"), StatusCodes.Status400BadRequest); + + if (!project.IsEnabled) + return (ApiResponse.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.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.Fail(1001, "card_invalid"), StatusCodes.Status400BadRequest); + + if (card.Status == "banned") + return (ApiResponse.Fail(1003, "card_banned"), StatusCodes.Status403Forbidden); + + if (string.Equals(card.CardType, "test", StringComparison.OrdinalIgnoreCase) + && card.LastUsedAt.HasValue) + return (ApiResponse.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.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.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.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.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.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.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.Ok(response), StatusCodes.Status200OK); + } + + public async Task<(ApiResponse response, int httpStatus)> HeartbeatAsync(AuthHeartbeatRequest request, HttpContext httpContext) + { + var heartbeatEnabled = await _config.GetBoolAsync("heartbeat.enabled", _heartbeat.Enabled); + + if (!_hmac.ValidateTimestamp(request.Timestamp)) + return (ApiResponse.Fail(1008, "timestamp_expired"), StatusCodes.Status400BadRequest); + + var principal = _jwt.ValidateToken(request.AccessToken); + if (principal == null) + return (ApiResponse.Fail(401, "unauthorized"), StatusCodes.Status401Unauthorized); + + var type = principal.Claims.FirstOrDefault(c => c.Type == "type")?.Value; + if (!string.Equals(type, "card", StringComparison.OrdinalIgnoreCase)) + return (ApiResponse.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.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.Fail(1003, "card_banned"), StatusCodes.Status403Forbidden); + + if (card.ExpireTime.HasValue && card.ExpireTime <= DateTime.UtcNow) + { + card.Status = "expired"; + await _db.SaveChangesAsync(); + return (ApiResponse.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.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.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.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.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(); + } +} diff --git a/license-system-backend/src/License.Api/Services/CardService.cs b/license-system-backend/src/License.Api/Services/CardService.cs new file mode 100644 index 0000000..4723d2d --- /dev/null +++ b/license-system-backend/src/License.Api/Services/CardService.cs @@ -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 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(); + + 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 + }; + } +} diff --git a/license-system-backend/src/License.Api/Services/ConfigService.cs b/license-system-backend/src/License.Api/Services/ConfigService.cs new file mode 100644 index 0000000..c5cd9b2 --- /dev/null +++ b/license-system-backend/src/License.Api/Services/ConfigService.cs @@ -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 GetValueAsync(string key) + { + if (_cache.TryGetValue(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 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 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 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); + } +} diff --git a/license-system-backend/src/License.Api/Services/FileStorageService.cs b/license-system-backend/src/License.Api/Services/FileStorageService.cs new file mode 100644 index 0000000..63286c7 --- /dev/null +++ b/license-system-backend/src/License.Api/Services/FileStorageService.cs @@ -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 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 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 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; + } +} diff --git a/license-system-backend/src/License.Api/Services/HeartbeatMonitorService.cs b/license-system-backend/src/License.Api/Services/HeartbeatMonitorService.cs new file mode 100644 index 0000000..d90419b --- /dev/null +++ b/license-system-backend/src/License.Api/Services/HeartbeatMonitorService.cs @@ -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 _logger; + + public HeartbeatMonitorService(IServiceProvider provider, ILogger 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(); + var config = scope.ServiceProvider.GetRequiredService(); + + 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); + } + } +} diff --git a/license-system-backend/src/License.Api/Services/IdempotencyService.cs b/license-system-backend/src/License.Api/Services/IdempotencyService.cs new file mode 100644 index 0000000..aa3ef39 --- /dev/null +++ b/license-system-backend/src/License.Api/Services/IdempotencyService.cs @@ -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 GetAsync(string key) + { + return await _db.IdempotencyKeys + .FirstOrDefaultAsync(x => x.IdempotencyKey == key && x.ExpiresAt > DateTime.UtcNow); + } + + public async Task 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(); + } +} diff --git a/license-system-backend/src/License.Api/Services/MaintenanceService.cs b/license-system-backend/src/License.Api/Services/MaintenanceService.cs new file mode 100644 index 0000000..505d961 --- /dev/null +++ b/license-system-backend/src/License.Api/Services/MaintenanceService.cs @@ -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 _logger; + + public MaintenanceService(IServiceProvider provider, ILogger 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(); + var config = scope.ServiceProvider.GetRequiredService(); + + 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); + } + } +} diff --git a/license-system-backend/src/License.Api/Services/RateLimitStore.cs b/license-system-backend/src/License.Api/Services/RateLimitStore.cs new file mode 100644 index 0000000..5096712 --- /dev/null +++ b/license-system-backend/src/License.Api/Services/RateLimitStore.cs @@ -0,0 +1,72 @@ +using Microsoft.Extensions.Caching.Memory; +using StackExchange.Redis; + +namespace License.Api.Services; + +public interface IRateLimitStore +{ + Task IncrementAsync(string key, TimeSpan ttl); + Task 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 IncrementAsync(string key, TimeSpan ttl) + { + lock (_lock) + { + if (!_cache.TryGetValue(key, out var count)) + { + count = 0; + } + count++; + _cache.Set(key, count, ttl); + return Task.FromResult(count); + } + } + + public Task 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 IncrementAsync(string key, TimeSpan ttl) + { + var count = await _db.StringIncrementAsync(key); + if (count == 1) + await _db.KeyExpireAsync(key, ttl); + return count; + } + + public Task ExistsAsync(string key) + => _db.KeyExistsAsync(key); + + public Task SetAsync(string key, TimeSpan ttl) + => _db.StringSetAsync(key, "1", ttl); +} diff --git a/license-system-backend/src/License.Api/Services/RiskControlService.cs b/license-system-backend/src/License.Api/Services/RiskControlService.cs new file mode 100644 index 0000000..5c35a86 --- /dev/null +++ b/license-system-backend/src/License.Api/Services/RiskControlService.cs @@ -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 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(); + 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 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 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(); + } +} diff --git a/license-system-backend/src/License.Api/Services/SoftwareEncryptionService.cs b/license-system-backend/src/License.Api/Services/SoftwareEncryptionService.cs new file mode 100644 index 0000000..021aee8 --- /dev/null +++ b/license-system-backend/src/License.Api/Services/SoftwareEncryptionService.cs @@ -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(); + public string FileHash { get; set; } = string.Empty; + public string? EncryptionKey { get; set; } + public long FileSize { get; set; } + public byte[] Nonce { get; set; } = Array.Empty(); +} + +public class SoftwareEncryptionService +{ + private readonly StorageOptions _options; + + public SoftwareEncryptionService(IOptions options) + { + _options = options.Value; + } + + public async Task 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() + }; + } + + 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; + } +} diff --git a/license-system-backend/src/License.Api/Services/SoftwareService.cs b/license-system-backend/src/License.Api/Services/SoftwareService.cs new file mode 100644 index 0000000..fa913c1 --- /dev/null +++ b/license-system-backend/src/License.Api/Services/SoftwareService.cs @@ -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 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 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 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 ReadFileAsync(string filePath) + => File.ReadAllBytesAsync(filePath); +} diff --git a/license-system-backend/src/License.Api/Services/StatsAggregationService.cs b/license-system-backend/src/License.Api/Services/StatsAggregationService.cs new file mode 100644 index 0000000..81f9df5 --- /dev/null +++ b/license-system-backend/src/License.Api/Services/StatsAggregationService.cs @@ -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 _logger; + + public StatsAggregationService(IServiceProvider provider, ILogger 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(); + var config = scope.ServiceProvider.GetRequiredService(); + + 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); + } + } +} diff --git a/license-system-backend/src/License.Api/Services/StatsService.cs b/license-system-backend/src/License.Api/Services/StatsService.cs new file mode 100644 index 0000000..75fd93e --- /dev/null +++ b/license-system-backend/src/License.Api/Services/StatsService.cs @@ -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 GetDashboardAsync(IReadOnlyCollection? 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(), + activeUsers = new List(), + newUsers = new List(), + revenue = new List() + }, + projectDistribution = new List() + }; + } + + 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> GetProjectStatsAsync(IReadOnlyCollection? projectIds = null) + { + var filter = NormalizeFilter(projectIds); + if (filter is { Count: 0 }) + return new List(); + + 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> 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> GetLogStatsAsync(int days, IReadOnlyCollection? projectIds = null) + { + var since = DateTime.UtcNow.AddDays(-days); + var filter = NormalizeFilter(projectIds); + if (filter is { Count: 0 }) + return new List(); + + 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 ExportStatsCsvAsync(int days, IReadOnlyCollection? 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? NormalizeFilter(IReadOnlyCollection? projectIds) + { + if (projectIds == null) + return null; + return projectIds + .Where(id => !string.IsNullOrWhiteSpace(id)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + } +} diff --git a/license-system-backend/src/License.Api/Utils/CardDefaults.cs b/license-system-backend/src/License.Api/Utils/CardDefaults.cs new file mode 100644 index 0000000..0e1105d --- /dev/null +++ b/license-system-backend/src/License.Api/Utils/CardDefaults.cs @@ -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 + }; + } +} diff --git a/license-system-backend/src/License.Api/Utils/CardKeyGenerator.cs b/license-system-backend/src/License.Api/Utils/CardKeyGenerator.cs new file mode 100644 index 0000000..594717d --- /dev/null +++ b/license-system-backend/src/License.Api/Utils/CardKeyGenerator.cs @@ -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(); + 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(); + 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(); + + 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; + } +} diff --git a/license-system-backend/src/License.Api/Utils/ClaimsPrincipalExtensions.cs b/license-system-backend/src/License.Api/Utils/ClaimsPrincipalExtensions.cs new file mode 100644 index 0000000..e850b83 --- /dev/null +++ b/license-system-backend/src/License.Api/Utils/ClaimsPrincipalExtensions.cs @@ -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); + } +} diff --git a/license-system-backend/src/License.Api/Utils/PasswordHasher.cs b/license-system-backend/src/License.Api/Utils/PasswordHasher.cs new file mode 100644 index 0000000..f243c5c --- /dev/null +++ b/license-system-backend/src/License.Api/Utils/PasswordHasher.cs @@ -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); +} diff --git a/license-system-backend/src/License.Api/Utils/RandomIdGenerator.cs b/license-system-backend/src/License.Api/Utils/RandomIdGenerator.cs new file mode 100644 index 0000000..a46facf --- /dev/null +++ b/license-system-backend/src/License.Api/Utils/RandomIdGenerator.cs @@ -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); + } +} diff --git a/license-system-backend/src/License.Api/Utils/RsaKeyLoader.cs b/license-system-backend/src/License.Api/Utils/RsaKeyLoader.cs new file mode 100644 index 0000000..399af8c --- /dev/null +++ b/license-system-backend/src/License.Api/Utils/RsaKeyLoader.cs @@ -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; + } +} diff --git a/license-system-backend/src/License.Api/Utils/VersionComparer.cs b/license-system-backend/src/License.Api/Utils/VersionComparer.cs new file mode 100644 index 0000000..87ab174 --- /dev/null +++ b/license-system-backend/src/License.Api/Utils/VersionComparer.cs @@ -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); + } +} diff --git a/license-system-backend/src/License.Api/appsettings.json b/license-system-backend/src/License.Api/appsettings.json new file mode 100644 index 0000000..26ba752 --- /dev/null +++ b/license-system-backend/src/License.Api/appsettings.json @@ -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" } } + ] + } +} diff --git a/license-system-frontend/.env.example b/license-system-frontend/.env.example new file mode 100644 index 0000000..536bf94 --- /dev/null +++ b/license-system-frontend/.env.example @@ -0,0 +1,2 @@ +VITE_API_BASE= +VITE_API_PORT= diff --git a/license-system-frontend/README.md b/license-system-frontend/README.md new file mode 100644 index 0000000..2a32ddf --- /dev/null +++ b/license-system-frontend/README.md @@ -0,0 +1,42 @@ +# License System Frontend + +Admin + Agent web UI built with Vue 3, Vite, Element Plus. + +## Quick Start + +1) Copy env file: + +``` +cp .env.example .env +``` + +2) Update API base URL or port in `.env`: + +``` +VITE_API_BASE=http://localhost:39256 +# or +VITE_API_PORT=39256 +``` + +If both are empty, the frontend uses the current origin. + +3) Install deps and run: + +``` +npm install +npm run dev +``` + +Build: + +``` +npm run build +``` + +## Notes + +- Admin and agent share the same login page (role selector). +- Super admin is required for Agents and Settings pages. +- Backend must allow CORS for the frontend origin. +- Admin project permissions control project-level buttons (cards/projects). +- Editing agent allowed projects requires enabling "Override Allowed Projects". diff --git a/license-system-frontend/index.html b/license-system-frontend/index.html new file mode 100644 index 0000000..1fc18b2 --- /dev/null +++ b/license-system-frontend/index.html @@ -0,0 +1,12 @@ + + + + + + License System + + +
+ + + diff --git a/license-system-frontend/jsconfig.json b/license-system-frontend/jsconfig.json new file mode 100644 index 0000000..2c8ee2b --- /dev/null +++ b/license-system-frontend/jsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + } +} diff --git a/license-system-frontend/package-lock.json b/license-system-frontend/package-lock.json new file mode 100644 index 0000000..a5cc401 --- /dev/null +++ b/license-system-frontend/package-lock.json @@ -0,0 +1,1732 @@ +{ + "name": "license-system-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "license-system-frontend", + "version": "1.0.0", + "dependencies": { + "axios": "^1.6.8", + "dayjs": "^1.11.10", + "echarts": "^5.5.0", + "element-plus": "^2.6.3", + "vue": "^3.4.21", + "vue-router": "^4.3.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.4", + "vite": "^5.2.8" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@ctrl/tinycolor": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz", + "integrity": "sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/@element-plus/icons-vue": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz", + "integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==", + "license": "MIT", + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", + "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", + "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.3", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "license": "MIT" + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@popperjs/core": { + "name": "@sxzz/popperjs-es", + "version": "2.11.7", + "resolved": "https://registry.npmjs.org/@sxzz/popperjs-es/-/popperjs-es-2.11.7.tgz", + "integrity": "sha512-Ccy0NlLkzr0Ex2FKvh2X+OyERHXJ88XJ1MXtsI9y9fGexlaXaVTPzBCRBwIxFkORuOb+uBqeu+RqnpgYTEZRUQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.54.0.tgz", + "integrity": "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.54.0.tgz", + "integrity": "sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.54.0.tgz", + "integrity": "sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.54.0.tgz", + "integrity": "sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.54.0.tgz", + "integrity": "sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.54.0.tgz", + "integrity": "sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.54.0.tgz", + "integrity": "sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.54.0.tgz", + "integrity": "sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.54.0.tgz", + "integrity": "sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.54.0.tgz", + "integrity": "sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.54.0.tgz", + "integrity": "sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.54.0.tgz", + "integrity": "sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.54.0.tgz", + "integrity": "sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.54.0.tgz", + "integrity": "sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.54.0.tgz", + "integrity": "sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.54.0.tgz", + "integrity": "sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.54.0.tgz", + "integrity": "sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.54.0.tgz", + "integrity": "sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.54.0.tgz", + "integrity": "sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.54.0.tgz", + "integrity": "sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.54.0.tgz", + "integrity": "sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.54.0.tgz", + "integrity": "sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-FOvQ0YPD5NOfPgMzJihoT+Za5pdkDJWcbpuj1DjaKZIr/gxodQjY/uWEFlTNqW2ugXHUiL8lRQgw63dzKHZdeQ==", + "license": "MIT" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.20", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz", + "integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.26.tgz", + "integrity": "sha512-vXyI5GMfuoBCnv5ucIT7jhHKl55Y477yxP6fc4eUswjP8FG3FFVFd41eNDArR+Uk3QKn2Z85NavjaxLxOC19/w==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@vue/shared": "3.5.26", + "entities": "^7.0.0", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.26.tgz", + "integrity": "sha512-y1Tcd3eXs834QjswshSilCBnKGeQjQXB6PqFn/1nxcQw4pmG42G8lwz+FZPAZAby6gZeHSt/8LMPfZ4Rb+Bd/A==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.26", + "@vue/shared": "3.5.26" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.26.tgz", + "integrity": "sha512-egp69qDTSEZcf4bGOSsprUr4xI73wfrY5oRs6GSgXFTiHrWj4Y3X5Ydtip9QMqiCMCPVwLglB9GBxXtTadJ3mA==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@vue/compiler-core": "3.5.26", + "@vue/compiler-dom": "3.5.26", + "@vue/compiler-ssr": "3.5.26", + "@vue/shared": "3.5.26", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.6", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.26.tgz", + "integrity": "sha512-lZT9/Y0nSIRUPVvapFJEVDbEXruZh2IYHMk2zTtEgJSlP5gVOqeWXH54xDKAaFS4rTnDeDBQUYDtxKyoW9FwDw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.26", + "@vue/shared": "3.5.26" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/reactivity": { + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.26.tgz", + "integrity": "sha512-9EnYB1/DIiUYYnzlnUBgwU32NNvLp/nhxLXeWRhHUEeWNTn1ECxX8aGO7RTXeX6PPcxe3LLuNBFoJbV4QZ+CFQ==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.26" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.26.tgz", + "integrity": "sha512-xJWM9KH1kd201w5DvMDOwDHYhrdPTrAatn56oB/LRG4plEQeZRQLw0Bpwih9KYoqmzaxF0OKSn6swzYi84e1/Q==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.26", + "@vue/shared": "3.5.26" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.26.tgz", + "integrity": "sha512-XLLd/+4sPC2ZkN/6+V4O4gjJu6kSDbHAChvsyWgm1oGbdSO3efvGYnm25yCjtFm/K7rrSDvSfPDgN1pHgS4VNQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.26", + "@vue/runtime-core": "3.5.26", + "@vue/shared": "3.5.26", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.26.tgz", + "integrity": "sha512-TYKLXmrwWKSodyVuO1WAubucd+1XlLg4set0YoV+Hu8Lo79mp/YMwWV5mC5FgtsDxX3qo1ONrxFaTP1OQgy1uA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.26", + "@vue/shared": "3.5.26" + }, + "peerDependencies": { + "vue": "3.5.26" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.26.tgz", + "integrity": "sha512-7Z6/y3uFI5PRoKeorTOSXKcDj0MSasfNNltcslbFrPpcw6aXRUALq4IfJlaTRspiWIUOEZbrpM+iQGmCOiWe4A==", + "license": "MIT" + }, + "node_modules/@vueuse/core": { + "version": "10.11.1", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-10.11.1.tgz", + "integrity": "sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.20", + "@vueuse/metadata": "10.11.1", + "@vueuse/shared": "10.11.1", + "vue-demi": ">=0.14.8" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/core/node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/@vueuse/metadata": { + "version": "10.11.1", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-10.11.1.tgz", + "integrity": "sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "10.11.1", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-10.11.1.tgz", + "integrity": "sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==", + "license": "MIT", + "dependencies": { + "vue-demi": ">=0.14.8" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared/node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/async-validator": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz", + "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", + "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/dayjs": { + "version": "1.11.19", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", + "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/echarts": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/echarts/-/echarts-5.6.0.tgz", + "integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "2.3.0", + "zrender": "5.6.1" + } + }, + "node_modules/element-plus": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.13.0.tgz", + "integrity": "sha512-qjxS+SBChvqCl6lU6ShiliLMN6WqFHiXQENYbAY3GKNflG+FS3jqn8JmQq0CBZq4koFqsi95NT1M6SL4whZfrA==", + "license": "MIT", + "dependencies": { + "@ctrl/tinycolor": "^3.4.1", + "@element-plus/icons-vue": "^2.3.2", + "@floating-ui/dom": "^1.0.1", + "@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.7", + "@types/lodash": "^4.17.20", + "@types/lodash-es": "^4.17.12", + "@vueuse/core": "^10.11.0", + "async-validator": "^4.2.5", + "dayjs": "^1.11.19", + "lodash": "^4.17.21", + "lodash-es": "^4.17.21", + "lodash-unified": "^1.0.3", + "memoize-one": "^6.0.0", + "normalize-wheel-es": "^1.2.0" + }, + "peerDependencies": { + "vue": "^3.3.0" + } + }, + "node_modules/entities": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.0.tgz", + "integrity": "sha512-FDWG5cmEYf2Z00IkYRhbFrwIwvdFKH07uV8dvNy0omp/Qb1xcyCWp2UDtcwJF4QZZvk0sLudP6/hAu42TaqVhQ==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT", + "peer": true + }, + "node_modules/lodash-es": { + "version": "4.17.22", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.22.tgz", + "integrity": "sha512-XEawp1t0gxSi9x01glktRZ5HDy0HXqrM0x5pXQM98EaI0NxO6jVM7omDOxsuEo5UIASAnm2bRp1Jt/e0a2XU8Q==", + "license": "MIT", + "peer": true + }, + "node_modules/lodash-unified": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/lodash-unified/-/lodash-unified-1.0.3.tgz", + "integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==", + "license": "MIT", + "peerDependencies": { + "@types/lodash-es": "*", + "lodash": "*", + "lodash-es": "*" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/normalize-wheel-es": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz", + "integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==", + "license": "BSD-3-Clause" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.54.0.tgz", + "integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.54.0", + "@rollup/rollup-android-arm64": "4.54.0", + "@rollup/rollup-darwin-arm64": "4.54.0", + "@rollup/rollup-darwin-x64": "4.54.0", + "@rollup/rollup-freebsd-arm64": "4.54.0", + "@rollup/rollup-freebsd-x64": "4.54.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.54.0", + "@rollup/rollup-linux-arm-musleabihf": "4.54.0", + "@rollup/rollup-linux-arm64-gnu": "4.54.0", + "@rollup/rollup-linux-arm64-musl": "4.54.0", + "@rollup/rollup-linux-loong64-gnu": "4.54.0", + "@rollup/rollup-linux-ppc64-gnu": "4.54.0", + "@rollup/rollup-linux-riscv64-gnu": "4.54.0", + "@rollup/rollup-linux-riscv64-musl": "4.54.0", + "@rollup/rollup-linux-s390x-gnu": "4.54.0", + "@rollup/rollup-linux-x64-gnu": "4.54.0", + "@rollup/rollup-linux-x64-musl": "4.54.0", + "@rollup/rollup-openharmony-arm64": "4.54.0", + "@rollup/rollup-win32-arm64-msvc": "4.54.0", + "@rollup/rollup-win32-ia32-msvc": "4.54.0", + "@rollup/rollup-win32-x64-gnu": "4.54.0", + "@rollup/rollup-win32-x64-msvc": "4.54.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.26.tgz", + "integrity": "sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@vue/compiler-dom": "3.5.26", + "@vue/compiler-sfc": "3.5.26", + "@vue/runtime-dom": "3.5.26", + "@vue/server-renderer": "3.5.26", + "@vue/shared": "3.5.26" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/zrender": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/zrender/-/zrender-5.6.1.tgz", + "integrity": "sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "2.3.0" + } + } + } +} diff --git a/license-system-frontend/package.json b/license-system-frontend/package.json new file mode 100644 index 0000000..021aba5 --- /dev/null +++ b/license-system-frontend/package.json @@ -0,0 +1,23 @@ +{ + "name": "license-system-frontend", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "axios": "^1.6.8", + "dayjs": "^1.11.10", + "echarts": "^5.5.0", + "element-plus": "^2.6.3", + "vue": "^3.4.21", + "vue-router": "^4.3.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.4", + "vite": "^5.2.8" + } +} diff --git a/license-system-frontend/src/App.vue b/license-system-frontend/src/App.vue new file mode 100644 index 0000000..cf170a2 --- /dev/null +++ b/license-system-frontend/src/App.vue @@ -0,0 +1,8 @@ + + + diff --git a/license-system-frontend/src/api/admin.js b/license-system-frontend/src/api/admin.js new file mode 100644 index 0000000..12cc4f1 --- /dev/null +++ b/license-system-frontend/src/api/admin.js @@ -0,0 +1,79 @@ +import api from './http'; + +export const adminLogin = (payload) => api.post('/api/admin/login', payload); +export const adminLogout = () => api.post('/api/admin/logout'); +export const adminProfile = () => api.get('/api/admin/profile'); +export const adminUpdateProfile = (payload) => api.put('/api/admin/profile', payload); +export const adminChangePassword = (payload) => api.post('/api/admin/change-password', payload); + +export const fetchProjects = (params) => api.get('/api/admin/projects', { params }); +export const createProject = (payload) => api.post('/api/admin/projects', payload); +export const updateProject = (id, payload) => api.put(`/api/admin/projects/${id}`, payload); +export const deleteProject = (id) => api.delete(`/api/admin/projects/${id}`); +export const getProject = (id) => api.get(`/api/admin/projects/${id}`); +export const getProjectStats = (id) => api.get(`/api/admin/projects/${id}/stats`); +export const getProjectDocs = (id) => api.get(`/api/admin/projects/${id}/docs`); +export const updateProjectDocs = (id, payload) => api.put(`/api/admin/projects/${id}/docs`, payload); + +export const getProjectPricing = (id) => api.get(`/api/admin/projects/${id}/pricing`); +export const createProjectPricing = (id, payload) => api.post(`/api/admin/projects/${id}/pricing`, payload); +export const updateProjectPricing = (id, priceId, payload) => api.put(`/api/admin/projects/${id}/pricing/${priceId}`, payload); +export const deleteProjectPricing = (id, priceId) => api.delete(`/api/admin/projects/${id}/pricing/${priceId}`); + +export const getProjectVersions = (id) => api.get(`/api/admin/projects/${id}/versions`); +export const uploadProjectVersion = (id, formData) => api.post(`/api/admin/projects/${id}/versions`, formData, { + headers: { 'Content-Type': 'multipart/form-data' } +}); +export const updateProjectVersion = (id, versionId, payload) => api.put(`/api/admin/projects/${id}/versions/${versionId}`, payload); +export const deleteProjectVersion = (id, versionId) => api.delete(`/api/admin/projects/${id}/versions/${versionId}`); + +export const generateCards = (payload, idempotencyKey) => api.post('/api/admin/cards/generate', payload, { + headers: idempotencyKey ? { 'X-Idempotency-Key': idempotencyKey } : {} +}); +export const listCards = (params) => api.get('/api/admin/cards', { params }); +export const getCard = (id) => api.get(`/api/admin/cards/${id}`); +export const getCardLogs = (id) => api.get(`/api/admin/cards/${id}/logs`); +export const updateCard = (id, payload) => api.put(`/api/admin/cards/${id}`, payload); +export const banCard = (id, payload) => api.post(`/api/admin/cards/${id}/ban`, payload); +export const unbanCard = (id) => api.post(`/api/admin/cards/${id}/unban`); +export const extendCard = (id, payload) => api.post(`/api/admin/cards/${id}/extend`, payload); +export const resetCardDevice = (id) => api.post(`/api/admin/cards/${id}/reset-device`); +export const deleteCard = (id) => api.delete(`/api/admin/cards/${id}`); +export const banCardsBatch = (payload) => api.post('/api/admin/cards/ban-batch', payload); +export const unbanCardsBatch = (payload) => api.post('/api/admin/cards/unban-batch', payload); +export const deleteCardsBatch = (payload) => api.delete('/api/admin/cards/batch', { data: payload }); +export const exportCards = (params) => api.get('/api/admin/cards/export', { params, responseType: 'blob' }); +export const importCards = (formData) => api.post('/api/admin/cards/import', formData, { + headers: { 'Content-Type': 'multipart/form-data' } +}); + +export const listAgents = (params) => api.get('/api/admin/agents', { params }); +export const createAgent = (payload) => api.post('/api/admin/agents', payload); +export const getAgent = (id) => api.get(`/api/admin/agents/${id}`); +export const updateAgent = (id, payload) => api.put(`/api/admin/agents/${id}`, payload); +export const enableAgent = (id) => api.post(`/api/admin/agents/${id}/enable`); +export const disableAgent = (id) => api.post(`/api/admin/agents/${id}/disable`); +export const deleteAgent = (id) => api.delete(`/api/admin/agents/${id}`); +export const rechargeAgent = (id, payload) => api.post(`/api/admin/agents/${id}/recharge`, payload); +export const deductAgent = (id, payload) => api.post(`/api/admin/agents/${id}/deduct`, payload); +export const agentTransactions = (id) => api.get(`/api/admin/agents/${id}/transactions`); + +export const listDevices = (params) => api.get('/api/admin/devices', { params }); +export const kickDevice = (id) => api.post(`/api/admin/devices/${id}/kick`); +export const unbindDevice = (id) => api.delete(`/api/admin/devices/${id}`); + +export const listLogs = (params) => api.get('/api/admin/logs', { params }); +export const getLog = (id) => api.get(`/api/admin/logs/${id}`); + +export const statsDashboard = () => api.get('/api/admin/stats/dashboard'); +export const statsProjects = () => api.get('/api/admin/stats/projects'); +export const statsAgents = () => api.get('/api/admin/stats/agents'); +export const statsLogs = (params) => api.get('/api/admin/stats/logs', { params }); +export const statsExport = (params) => api.get('/api/admin/stats/export', { params, responseType: 'blob' }); + +export const listSettings = () => api.get('/api/admin/settings'); +export const updateSettings = (payload) => api.put('/api/admin/settings', payload); +export const listAdmins = () => api.get('/api/admin/admins'); +export const createAdmin = (payload) => api.post('/api/admin/admins', payload); +export const updateAdmin = (id, payload) => api.put(`/api/admin/admins/${id}`, payload); +export const deleteAdmin = (id) => api.delete(`/api/admin/admins/${id}`); diff --git a/license-system-frontend/src/api/agent.js b/license-system-frontend/src/api/agent.js new file mode 100644 index 0000000..12af388 --- /dev/null +++ b/license-system-frontend/src/api/agent.js @@ -0,0 +1,13 @@ +import api from './http'; + +export const agentLogin = (payload) => api.post('/api/agent/login', payload); +export const agentLogout = () => api.post('/api/agent/logout'); +export const agentProfile = () => api.get('/api/agent/profile'); +export const agentUpdateProfile = (payload) => api.put('/api/agent/profile', payload); +export const agentChangePassword = (payload) => api.post('/api/agent/change-password', payload); +export const agentTransactions = () => api.get('/api/agent/transactions'); + +export const agentGenerateCards = (payload, idempotencyKey) => api.post('/api/agent/cards/generate', payload, { + headers: idempotencyKey ? { 'X-Idempotency-Key': idempotencyKey } : {} +}); +export const agentCards = (params) => api.get('/api/agent/cards', { params }); diff --git a/license-system-frontend/src/api/http.js b/license-system-frontend/src/api/http.js new file mode 100644 index 0000000..18c6c7a --- /dev/null +++ b/license-system-frontend/src/api/http.js @@ -0,0 +1,40 @@ +import axios from 'axios'; +import { session, clearSession } from '@/store/session'; +import { resolveApiBase } from '@/utils/apiBase'; + +const api = axios.create({ + baseURL: resolveApiBase(), + timeout: 15000 +}); + +api.interceptors.request.use((config) => { + if (session.token) { + config.headers.Authorization = `Bearer ${session.token}`; + } + return config; +}); + +api.interceptors.response.use( + (response) => { + if (response.config.responseType === 'blob') { + return response; + } + const payload = response.data; + if (payload && typeof payload.code === 'number') { + if (payload.code !== 200) { + return Promise.reject(payload); + } + return payload.data; + } + return payload; + }, + (error) => { + const status = error?.response?.status; + if (status === 401) { + clearSession(); + } + return Promise.reject(error?.response?.data || error); + } +); + +export default api; diff --git a/license-system-frontend/src/components/BarChart.vue b/license-system-frontend/src/components/BarChart.vue new file mode 100644 index 0000000..fb3d697 --- /dev/null +++ b/license-system-frontend/src/components/BarChart.vue @@ -0,0 +1,77 @@ + + + + + diff --git a/license-system-frontend/src/components/TrendChart.vue b/license-system-frontend/src/components/TrendChart.vue new file mode 100644 index 0000000..381cf7f --- /dev/null +++ b/license-system-frontend/src/components/TrendChart.vue @@ -0,0 +1,65 @@ + + + + + diff --git a/license-system-frontend/src/layouts/AdminLayout.vue b/license-system-frontend/src/layouts/AdminLayout.vue new file mode 100644 index 0000000..7a0f454 --- /dev/null +++ b/license-system-frontend/src/layouts/AdminLayout.vue @@ -0,0 +1,266 @@ + + + + + diff --git a/license-system-frontend/src/layouts/AgentLayout.vue b/license-system-frontend/src/layouts/AgentLayout.vue new file mode 100644 index 0000000..70ab7cf --- /dev/null +++ b/license-system-frontend/src/layouts/AgentLayout.vue @@ -0,0 +1,236 @@ + + + + + diff --git a/license-system-frontend/src/main.js b/license-system-frontend/src/main.js new file mode 100644 index 0000000..184a328 --- /dev/null +++ b/license-system-frontend/src/main.js @@ -0,0 +1,12 @@ +import { createApp } from 'vue'; +import ElementPlus from 'element-plus'; +import zhCn from 'element-plus/es/locale/lang/zh-cn'; +import 'element-plus/dist/index.css'; +import '@/styles/index.css'; +import App from './App.vue'; +import router from './router'; + +const app = createApp(App); +app.use(router); +app.use(ElementPlus, { locale: zhCn }); +app.mount('#app'); diff --git a/license-system-frontend/src/pages/LoginView.vue b/license-system-frontend/src/pages/LoginView.vue new file mode 100644 index 0000000..9ae15ee --- /dev/null +++ b/license-system-frontend/src/pages/LoginView.vue @@ -0,0 +1,157 @@ + + + + + diff --git a/license-system-frontend/src/pages/NotFoundView.vue b/license-system-frontend/src/pages/NotFoundView.vue new file mode 100644 index 0000000..19f772c --- /dev/null +++ b/license-system-frontend/src/pages/NotFoundView.vue @@ -0,0 +1,35 @@ + + + + + diff --git a/license-system-frontend/src/pages/admin/AgentsView.vue b/license-system-frontend/src/pages/admin/AgentsView.vue new file mode 100644 index 0000000..25017ae --- /dev/null +++ b/license-system-frontend/src/pages/admin/AgentsView.vue @@ -0,0 +1,371 @@ + + + + + diff --git a/license-system-frontend/src/pages/admin/ApiDocsView.vue b/license-system-frontend/src/pages/admin/ApiDocsView.vue new file mode 100644 index 0000000..c129cc7 --- /dev/null +++ b/license-system-frontend/src/pages/admin/ApiDocsView.vue @@ -0,0 +1,315 @@ + + + + + diff --git a/license-system-frontend/src/pages/admin/CardsView.vue b/license-system-frontend/src/pages/admin/CardsView.vue new file mode 100644 index 0000000..fa99d8c --- /dev/null +++ b/license-system-frontend/src/pages/admin/CardsView.vue @@ -0,0 +1,600 @@ + + + + + diff --git a/license-system-frontend/src/pages/admin/DashboardView.vue b/license-system-frontend/src/pages/admin/DashboardView.vue new file mode 100644 index 0000000..7f40641 --- /dev/null +++ b/license-system-frontend/src/pages/admin/DashboardView.vue @@ -0,0 +1,94 @@ + + + + + diff --git a/license-system-frontend/src/pages/admin/DevicesView.vue b/license-system-frontend/src/pages/admin/DevicesView.vue new file mode 100644 index 0000000..0e66627 --- /dev/null +++ b/license-system-frontend/src/pages/admin/DevicesView.vue @@ -0,0 +1,116 @@ + + + diff --git a/license-system-frontend/src/pages/admin/LogsView.vue b/license-system-frontend/src/pages/admin/LogsView.vue new file mode 100644 index 0000000..192b171 --- /dev/null +++ b/license-system-frontend/src/pages/admin/LogsView.vue @@ -0,0 +1,92 @@ + + + + + diff --git a/license-system-frontend/src/pages/admin/ProjectsView.vue b/license-system-frontend/src/pages/admin/ProjectsView.vue new file mode 100644 index 0000000..5032759 --- /dev/null +++ b/license-system-frontend/src/pages/admin/ProjectsView.vue @@ -0,0 +1,671 @@ + + + + + diff --git a/license-system-frontend/src/pages/admin/SettingsView.vue b/license-system-frontend/src/pages/admin/SettingsView.vue new file mode 100644 index 0000000..69d5bfc --- /dev/null +++ b/license-system-frontend/src/pages/admin/SettingsView.vue @@ -0,0 +1,337 @@ + + + + + diff --git a/license-system-frontend/src/pages/admin/StatsView.vue b/license-system-frontend/src/pages/admin/StatsView.vue new file mode 100644 index 0000000..236f53e --- /dev/null +++ b/license-system-frontend/src/pages/admin/StatsView.vue @@ -0,0 +1,160 @@ + + + + + diff --git a/license-system-frontend/src/pages/agent/AgentCardsView.vue b/license-system-frontend/src/pages/agent/AgentCardsView.vue new file mode 100644 index 0000000..5647eee --- /dev/null +++ b/license-system-frontend/src/pages/agent/AgentCardsView.vue @@ -0,0 +1,224 @@ + + + + + diff --git a/license-system-frontend/src/pages/agent/AgentProfileView.vue b/license-system-frontend/src/pages/agent/AgentProfileView.vue new file mode 100644 index 0000000..4316d55 --- /dev/null +++ b/license-system-frontend/src/pages/agent/AgentProfileView.vue @@ -0,0 +1,109 @@ + + + + + diff --git a/license-system-frontend/src/pages/agent/AgentTransactionsView.vue b/license-system-frontend/src/pages/agent/AgentTransactionsView.vue new file mode 100644 index 0000000..9515d45 --- /dev/null +++ b/license-system-frontend/src/pages/agent/AgentTransactionsView.vue @@ -0,0 +1,65 @@ + + + diff --git a/license-system-frontend/src/router/index.js b/license-system-frontend/src/router/index.js new file mode 100644 index 0000000..2251514 --- /dev/null +++ b/license-system-frontend/src/router/index.js @@ -0,0 +1,75 @@ +import { createRouter, createWebHistory } from 'vue-router'; +import { session, isLoggedIn, isSuperAdmin } from '@/store/session'; + +import LoginView from '@/pages/LoginView.vue'; +import AdminLayout from '@/layouts/AdminLayout.vue'; +import AgentLayout from '@/layouts/AgentLayout.vue'; +import DashboardView from '@/pages/admin/DashboardView.vue'; +import ProjectsView from '@/pages/admin/ProjectsView.vue'; +import CardsView from '@/pages/admin/CardsView.vue'; +import DevicesView from '@/pages/admin/DevicesView.vue'; +import LogsView from '@/pages/admin/LogsView.vue'; +import StatsView from '@/pages/admin/StatsView.vue'; +import AgentsView from '@/pages/admin/AgentsView.vue'; +import SettingsView from '@/pages/admin/SettingsView.vue'; +import ApiDocsView from '@/pages/admin/ApiDocsView.vue'; +import AgentCardsView from '@/pages/agent/AgentCardsView.vue'; +import AgentTransactionsView from '@/pages/agent/AgentTransactionsView.vue'; +import AgentProfileView from '@/pages/agent/AgentProfileView.vue'; +import NotFoundView from '@/pages/NotFoundView.vue'; + +const routes = [ + { path: '/login', name: 'login', component: LoginView, meta: { public: true } }, + { + path: '/admin', + component: AdminLayout, + children: [ + { path: 'dashboard', name: 'admin-dashboard', component: DashboardView, meta: { type: 'admin' } }, + { path: 'projects', name: 'admin-projects', component: ProjectsView, meta: { type: 'admin' } }, + { path: 'cards', name: 'admin-cards', component: CardsView, meta: { type: 'admin' } }, + { path: 'devices', name: 'admin-devices', component: DevicesView, meta: { type: 'admin' } }, + { path: 'logs', name: 'admin-logs', component: LogsView, meta: { type: 'admin' } }, + { path: 'stats', name: 'admin-stats', component: StatsView, meta: { type: 'admin' } }, + { path: 'api-docs', name: 'admin-api-docs', component: ApiDocsView, meta: { type: 'admin' } }, + { path: 'agents', name: 'admin-agents', component: AgentsView, meta: { type: 'admin', super: true } }, + { path: 'settings', name: 'admin-settings', component: SettingsView, meta: { type: 'admin', super: true } } + ] + }, + { + path: '/agent', + component: AgentLayout, + children: [ + { path: 'cards', name: 'agent-cards', component: AgentCardsView, meta: { type: 'agent' } }, + { path: 'transactions', name: 'agent-transactions', component: AgentTransactionsView, meta: { type: 'agent' } }, + { path: 'profile', name: 'agent-profile', component: AgentProfileView, meta: { type: 'agent' } } + ] + }, + { + path: '/', + redirect: () => { + if (!isLoggedIn()) return '/login'; + if (session.type === 'agent') return '/agent/cards'; + return '/admin/dashboard'; + } + }, + { path: '/:pathMatch(.*)*', name: 'not-found', component: NotFoundView, meta: { public: true } } +]; + +const router = createRouter({ + history: createWebHistory(), + routes +}); + +router.beforeEach((to, from, next) => { + if (to.meta.public) return next(); + if (!isLoggedIn()) return next('/login'); + if (to.meta.type && session.type !== to.meta.type) { + return next(session.type === 'agent' ? '/agent/cards' : '/admin/dashboard'); + } + if (to.meta.super && !isSuperAdmin()) { + return next('/admin/dashboard'); + } + return next(); +}); + +export default router; diff --git a/license-system-frontend/src/store/session.js b/license-system-frontend/src/store/session.js new file mode 100644 index 0000000..a4a19c1 --- /dev/null +++ b/license-system-frontend/src/store/session.js @@ -0,0 +1,79 @@ +import { reactive } from 'vue'; + +const state = reactive({ + token: localStorage.getItem('token') || '', + type: localStorage.getItem('type') || '', + role: localStorage.getItem('role') || '', + permissions: JSON.parse(localStorage.getItem('permissions') || '[]'), + user: JSON.parse(localStorage.getItem('user') || 'null'), + agent: JSON.parse(localStorage.getItem('agent') || 'null'), + allowedProjects: JSON.parse(localStorage.getItem('allowedProjects') || '[]') +}); + +function persist() { + localStorage.setItem('token', state.token || ''); + localStorage.setItem('type', state.type || ''); + localStorage.setItem('role', state.role || ''); + localStorage.setItem('permissions', JSON.stringify(state.permissions || [])); + localStorage.setItem('user', JSON.stringify(state.user || null)); + localStorage.setItem('agent', JSON.stringify(state.agent || null)); + localStorage.setItem('allowedProjects', JSON.stringify(state.allowedProjects || [])); +} + +function setAdminSession(payload) { + state.token = payload.token; + state.type = 'admin'; + state.role = payload.user?.role || 'admin'; + state.permissions = payload.user?.permissions || []; + state.user = payload.user || null; + state.agent = null; + state.allowedProjects = []; + persist(); +} + +function setAgentSession(payload) { + state.token = payload.token; + state.type = 'agent'; + state.role = 'agent'; + state.permissions = []; + state.user = null; + state.agent = payload.agent || null; + state.allowedProjects = payload.allowedProjects || []; + persist(); +} + +function clearSession() { + state.token = ''; + state.type = ''; + state.role = ''; + state.permissions = []; + state.user = null; + state.agent = null; + state.allowedProjects = []; + persist(); +} + +function isLoggedIn() { + return Boolean(state.token); +} + +function isSuperAdmin() { + return state.type === 'admin' && state.role === 'super_admin'; +} + +function hasProjectAccess(projectId) { + if (!projectId) return false; + if (isSuperAdmin()) return true; + if ((state.permissions || []).includes('*')) return true; + return (state.permissions || []).includes(projectId); +} + +export { + state as session, + setAdminSession, + setAgentSession, + clearSession, + isLoggedIn, + isSuperAdmin, + hasProjectAccess +}; diff --git a/license-system-frontend/src/styles/index.css b/license-system-frontend/src/styles/index.css new file mode 100644 index 0000000..b6616c5 --- /dev/null +++ b/license-system-frontend/src/styles/index.css @@ -0,0 +1,175 @@ +@import url('https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700&family=Space+Grotesk:wght@500;600&display=swap'); + +:root { + --ink-950: #0b1220; + --ink-900: #0f172a; + --ink-700: #334155; + --ink-500: #64748b; + --sand-50: #f7f2e9; + --sand-100: #efe7d9; + --accent-500: #ff6b35; + --accent-600: #e95b2b; + --teal-500: #0ea5a4; + --teal-700: #0f766e; + --line: rgba(15, 23, 42, 0.08); + --shadow: 0 18px 40px rgba(15, 23, 42, 0.12); + --radius-lg: 18px; + --radius-md: 12px; + --radius-sm: 8px; +} + +* { + box-sizing: border-box; +} + +html, body, #app { + height: 100%; +} + +body { + margin: 0; + font-family: 'Manrope', 'Segoe UI', Tahoma, sans-serif; + color: var(--ink-900); + background: radial-gradient(circle at 10% 10%, rgba(255, 107, 53, 0.2), transparent 35%), + radial-gradient(circle at 90% 20%, rgba(14, 165, 164, 0.18), transparent 40%), + linear-gradient(160deg, #fbf7f0, #f2efe7 40%, #f7f4ed 80%); +} + +a { + color: inherit; + text-decoration: none; +} + +.app-root { + min-height: 100%; +} + +.page-shell { + padding: 24px 32px 40px; +} + +.section-title { + font-family: 'Space Grotesk', 'Manrope', sans-serif; + letter-spacing: -0.02em; +} + +.glass-card { + background: rgba(255, 255, 255, 0.82); + border: 1px solid var(--line); + border-radius: var(--radius-lg); + box-shadow: var(--shadow); + backdrop-filter: blur(14px); +} + +.panel { + padding: 20px 24px; +} + +.stat-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 16px; +} + +.stat-card { + padding: 18px 20px; + border-radius: var(--radius-md); + border: 1px solid var(--line); + background: linear-gradient(140deg, rgba(255, 255, 255, 0.9), rgba(247, 242, 233, 0.85)); + display: flex; + flex-direction: column; + gap: 8px; + animation: fadeUp 0.5s ease both; +} + +.stat-label { + font-size: 13px; + color: var(--ink-500); + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.stat-value { + font-size: 24px; + font-weight: 600; + color: var(--ink-900); +} + +.tag { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-radius: 999px; + font-size: 12px; + background: rgba(15, 23, 42, 0.06); + color: var(--ink-700); +} + +.toolbar { + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; +} + +.table-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.badge-dot { + width: 8px; + height: 8px; + border-radius: 50%; + display: inline-block; +} + +.badge-dot.active { + background: var(--teal-500); +} + +.badge-dot.inactive { + background: #d97706; +} + +.form-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 16px; +} + +.fade-enter-active, +.fade-leave-active { + transition: opacity 0.3s ease; +} + +.fade-enter-from, +.fade-leave-to { + opacity: 0; +} + +@keyframes fadeUp { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@media (max-width: 960px) { + .page-shell { + padding: 18px; + } + + .toolbar { + flex-direction: column; + align-items: stretch; + } +} diff --git a/license-system-frontend/src/utils/apiBase.js b/license-system-frontend/src/utils/apiBase.js new file mode 100644 index 0000000..240d260 --- /dev/null +++ b/license-system-frontend/src/utils/apiBase.js @@ -0,0 +1,35 @@ +const normalizePort = (value) => { + const parsed = Number(value); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; +}; + +const DEFAULT_API_PORT = normalizePort(import.meta.env.VITE_API_PORT); + +const normalizeUrl = (value) => (value ? value.replace(/\/$/, '') : ''); + +export const resolveApiBase = ({ + fallbackPort = DEFAULT_API_PORT, + fallbackHost = '127.0.0.1', + preferSameOrigin = true +} = {}) => { + const envBase = import.meta.env.VITE_API_BASE; + if (envBase) return normalizeUrl(envBase); + + if (typeof window === 'undefined') { + return fallbackPort ? `http://${fallbackHost}:${fallbackPort}` : ''; + } + + const { protocol, hostname, origin } = window.location; + const originBase = normalizeUrl(origin); + + if (preferSameOrigin) { + return originBase; + } + + if (!fallbackPort) return originBase; + + const safeHost = hostname.includes(':') ? `[${hostname}]` : hostname; + return `${protocol}//${safeHost}:${fallbackPort}`; +}; + +export { DEFAULT_API_PORT }; diff --git a/license-system-frontend/src/utils/idempotency.js b/license-system-frontend/src/utils/idempotency.js new file mode 100644 index 0000000..5a81253 --- /dev/null +++ b/license-system-frontend/src/utils/idempotency.js @@ -0,0 +1,4 @@ +export function createIdempotencyKey() { + const rand = Math.random().toString(16).slice(2); + return `${Date.now()}-${rand}`; +} diff --git a/license-system-frontend/vite.config.js b/license-system-frontend/vite.config.js new file mode 100644 index 0000000..7b63308 --- /dev/null +++ b/license-system-frontend/vite.config.js @@ -0,0 +1,15 @@ +import { defineConfig } from 'vite'; +import vue from '@vitejs/plugin-vue'; +import path from 'path'; + +export default defineConfig({ + plugins: [vue()], + resolve: { + alias: { + '@': path.resolve(process.cwd(), './src') + } + }, + server: { + port: 5173 + } +}); diff --git a/license-system-launcher/Cargo.lock b/license-system-launcher/Cargo.lock new file mode 100644 index 0000000..0626f23 --- /dev/null +++ b/license-system-launcher/Cargo.lock @@ -0,0 +1,1711 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" + +[[package]] +name = "cc" +version = "1.2.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a0aeaff4ff1a90589618835a598e545176939b97874f7abc7851caa0618f203" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.112", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "find-msvc-tools" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645cbb3a84e60b7531617d5ae4e57f7e27308f6445f5abf653209ea76dec8dff" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "js-sys" +version = "0.3.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.178" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" + +[[package]] +name = "license-system-launcher" +version = "0.1.0" +dependencies = [ + "hmac", + "local-ip-address", + "mac_address", + "rand 0.8.5", + "reqwest", + "serde", + "serde_json", + "sha2", +] + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "local-ip-address" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612ed4ea9ce5acfb5d26339302528a5e1e59dfed95e9e11af3c083236ff1d15d" +dependencies = [ + "libc", + "neli", + "thiserror 1.0.69", + "windows-sys 0.48.0", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "mac_address" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" +dependencies = [ + "nix", + "winapi", +] + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "neli" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93062a0dce6da2517ea35f301dfc88184ce18d3601ec786a727a87bf535deca9" +dependencies = [ + "byteorder", + "libc", + "log", + "neli-proc-macros", +] + +[[package]] +name = "neli-proc-macros" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c8034b7fbb6f9455b2a96c19e6edf8dc9fc34c70449938d8ee3b4df363f61fe" +dependencies = [ + "either", + "proc-macro2", + "quote", + "serde", + "syn 1.0.109", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9695f8df41bb4f3d222c95a67532365f569318332d03d5f3f67f37b20e6ebdf0" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.17", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.17", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.52.0", +] + +[[package]] +name = "quote" +version = "1.0.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.3", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.3", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.16", +] + +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.16", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustls" +version = "0.23.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21e6f2ab2928ca4291b86736a8bd920a277a399bba1589409d72154ff87c1282" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.112", +] + +[[package]] +name = "serde_json" +version = "1.0.148" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3084b546a1dd6289475996f182a22aba973866ea8e8b02c51d9f46b1336a22da" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.112" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21f182278bf2d2bcb3c88b1b08a37df029d71ce3d3ae26168e3c653b213b99d4" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.112", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +dependencies = [ + "thiserror-impl 2.0.17", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.112", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.112", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "836d9622d604feee9e5de25ac10e3ea5f2d65b41eac0d9ce72eb5deae707ce7c" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.112", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2878ef029c47c6e8cf779119f20fcf52bde7ad42a731b2a304bc221df17571e" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.112", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd74ec98b9250adb3ca554bdde269adf631549f51d8a8f8f0a10b50f1cb298c3" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8a8d209fdf45cf5138cbb5a506f6b52522a25afccc534d1475dad8e31105c6a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.112", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.112", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.112", +] + +[[package]] +name = "zmij" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac060176f7020d62c3bcc1cdbcec619d54f48b07ad1963a3f80ce7a0c17755f" diff --git a/license-system-launcher/Cargo.toml b/license-system-launcher/Cargo.toml new file mode 100644 index 0000000..c074f8b --- /dev/null +++ b/license-system-launcher/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "license-system-launcher" +version = "0.1.0" +edition = "2021" + +[profile.release] +lto = true +codegen-units = 1 +panic = "abort" +strip = "symbols" + +[dependencies] +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] } +hmac = "0.12" +sha2 = "0.10" +mac_address = "1.1" +local-ip-address = "0.5" +rand = "0.8" +base64 = "0.22" +eframe = "0.27" +rfd = "0.14" diff --git a/license-system-launcher/README.md b/license-system-launcher/README.md new file mode 100644 index 0000000..2a122c2 --- /dev/null +++ b/license-system-launcher/README.md @@ -0,0 +1,82 @@ +# License Launcher (Windows) + +Single-file launcher that verifies license online, then runs the embedded EXE. + +## Build (Windows) + +1) Install Rust (MSVC toolchain). +2) Build: + +``` +cargo build --release --bin launcher_stub +cargo build --release --bin launcher_pack +``` + +Output: +- `target/release/launcher_stub.exe` +- `target/release/launcher_pack.exe` +- `target/release/launcher_gui.exe` + +## Config + +Create `config.json` (example): + +```json +{ + "api_base": "http://118.145.218.2:39256", + "project_id": "PROJ_001", + "project_secret": "YOUR_PROJECT_SECRET", + "client_version": "1.0.0", + "license_file": "license.key", + "request_timeout_sec": 10, + "heartbeat_retries": 2, + "heartbeat_retry_delay_sec": 5, + "extract_to": "temp", + "keep_payload": false +} +``` + +Notes: +- `extract_to`: `temp` (default) or `self`. +- If `extract_to=self`, the payload is extracted next to the launcher (use this when your app needs local resource files). +- Card key is read from `license.key` next to the launcher (first non-empty line). You can also set `card_key` in config to hardcode. + +## Pack + +``` +launcher_pack --stub launcher_stub.exe --input your_app.exe --config config.json --output your_app_protected.exe +``` + +The tool writes the payload name into the config automatically. + +## GUI Pack (Recommended) + +1) Put `launcher_gui.exe` and `launcher_stub.exe` in the same folder. +2) Run `launcher_gui.exe`. +3) Fill: + - API base + - Integration code (LSC1... from Project detail) +4) Drag your EXE into the window. +5) Click **Start Pack**. + +Output file defaults to `*_packed.exe`. + +## Run + +Place `license.key` next to `your_app_protected.exe`: + +``` +YOUR-CARD-KEY +``` + +Start `your_app_protected.exe`. It will: +1) Compute deviceId = `MAC|IP` (clamped to 64 chars; hashed if too long). +2) Call `/api/auth/verify` online. +3) Start the embedded app. +4) Send `/api/auth/heartbeat` on interval; failure will exit the app. + +## Security Notes + +- Online verification is required; no offline cache. +- If the server disables heartbeat, only startup verification is enforced. +- This raises reverse-engineering cost but cannot make a client uncrackable. diff --git a/license-system-launcher/config.example.json b/license-system-launcher/config.example.json new file mode 100644 index 0000000..b878b37 --- /dev/null +++ b/license-system-launcher/config.example.json @@ -0,0 +1,12 @@ +{ + "api_base": "http://118.145.218.2:39256", + "project_id": "PROJ_001", + "project_secret": "YOUR_PROJECT_SECRET", + "client_version": "1.0.0", + "license_file": "license.key", + "request_timeout_sec": 10, + "heartbeat_retries": 2, + "heartbeat_retry_delay_sec": 5, + "extract_to": "temp", + "keep_payload": false +} diff --git a/license-system-launcher/src/bin/launcher_gui.rs b/license-system-launcher/src/bin/launcher_gui.rs new file mode 100644 index 0000000..918244f --- /dev/null +++ b/license-system-launcher/src/bin/launcher_gui.rs @@ -0,0 +1,242 @@ +use std::env; +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use base64::engine::general_purpose::STANDARD; +use base64::Engine; +use eframe::egui; + +const MAGIC: &[u8] = b"LSWRAP1"; + +fn main() -> eframe::Result<()> { + let options = eframe::NativeOptions::default(); + eframe::run_native( + "授权打包器", + options, + Box::new(|_| Box::new(LauncherGui::new())), + ) +} + +struct LauncherGui { + api_base: String, + integration_code: String, + input_path: String, + output_path: String, + extract_to_self: bool, + status: String, +} + +impl LauncherGui { + fn new() -> Self { + Self { + api_base: String::new(), + integration_code: String::new(), + input_path: String::new(), + output_path: String::new(), + extract_to_self: false, + status: String::new(), + } + } + + fn pack(&mut self) -> Result<(), String> { + let api_base = normalize_api_base(&self.api_base)?; + let (project_id, project_key) = decode_integration_code(&self.integration_code)?; + + let input = PathBuf::from(self.input_path.trim()); + if !input.exists() { + return Err("未选择可执行文件".to_string()); + } + + let output = if self.output_path.trim().is_empty() { + derive_output_path(&input)? + } else { + PathBuf::from(self.output_path.trim()) + }; + + if input == output { + return Err("输出文件不能与输入文件相同".to_string()); + } + + let exe_dir = env::current_exe() + .map_err(|e| format!("无法定位程序目录: {e}"))? + .parent() + .unwrap_or(Path::new(".")) + .to_path_buf(); + let stub_path = exe_dir.join("launcher_stub.exe"); + if !stub_path.exists() { + return Err("未找到 launcher_stub.exe,请放在同目录".to_string()); + } + + let payload_name = input + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("payload.exe") + .to_string(); + + let extract_to = if self.extract_to_self { "self" } else { "temp" }; + + let config = serde_json::json!({ + "api_base": api_base, + "project_id": project_id, + "project_secret": project_key, + "license_file": "license.key", + "request_timeout_sec": 10, + "heartbeat_retries": 2, + "heartbeat_retry_delay_sec": 5, + "extract_to": extract_to, + "keep_payload": false, + "payload_name": payload_name + }); + let config_bytes = serde_json::to_vec(&config) + .map_err(|e| format!("配置序列化失败: {e}"))?; + + pack_files(&stub_path, &input, &output, &config_bytes)?; + self.output_path = output.to_string_lossy().to_string(); + + Ok(()) + } +} + +impl eframe::App for LauncherGui { + fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + handle_drop(ctx, self); + + egui::CentralPanel::default().show(ctx, |ui| { + ui.heading("授权打包器"); + ui.add_space(8.0); + + ui.label("授权系统地址"); + ui.add(egui::TextEdit::singleline(&mut self.api_base).hint_text("http://118.145.218.2:39256")); + + ui.add_space(6.0); + ui.label("项目对接码"); + ui.add(egui::TextEdit::singleline(&mut self.integration_code).hint_text("LSC1.xxxxx")); + + ui.add_space(6.0); + ui.label("选择要打包的 EXE"); + ui.horizontal(|ui| { + ui.add(egui::TextEdit::singleline(&mut self.input_path).desired_width(360.0)); + if ui.button("浏览").clicked() { + if let Some(file) = rfd::FileDialog::new().add_filter("EXE", &["exe"]).pick_file() { + self.input_path = file.to_string_lossy().to_string(); + if self.output_path.trim().is_empty() { + if let Ok(path) = derive_output_path(&file) { + self.output_path = path.to_string_lossy().to_string(); + } + } + } + } + }); + + ui.add_space(6.0); + ui.label("输出文件"); + ui.horizontal(|ui| { + ui.add(egui::TextEdit::singleline(&mut self.output_path).desired_width(360.0)); + if ui.button("另存为").clicked() { + if let Some(file) = rfd::FileDialog::new().add_filter("EXE", &["exe"]).set_file_name("packed.exe").save_file() { + self.output_path = file.to_string_lossy().to_string(); + } + } + }); + + ui.add_space(6.0); + ui.checkbox(&mut self.extract_to_self, "解压到程序目录(需要本地资源时勾选)"); + + ui.add_space(10.0); + if ui.button("开始打包").clicked() { + match self.pack() { + Ok(_) => self.status = "打包完成".to_string(), + Err(err) => self.status = format!("打包失败: {err}"), + } + } + + if !self.status.is_empty() { + ui.add_space(8.0); + ui.label(&self.status); + } + }); + } +} + +fn handle_drop(ctx: &egui::Context, app: &mut LauncherGui) { + let dropped = ctx.input(|i| i.raw.dropped_files.clone()); + for file in dropped { + if let Some(path) = file.path { + if path.extension().and_then(|s| s.to_str()).map(|s| s.eq_ignore_ascii_case("exe")).unwrap_or(false) { + app.input_path = path.to_string_lossy().to_string(); + if app.output_path.trim().is_empty() { + if let Ok(out) = derive_output_path(&path) { + app.output_path = out.to_string_lossy().to_string(); + } + } + break; + } + } + } +} + +fn pack_files(stub_path: &Path, payload_path: &Path, output_path: &Path, config: &[u8]) -> Result<(), String> { + let stub = fs::read(stub_path).map_err(|e| format!("读取 stub 失败: {e}"))?; + let payload = fs::read(payload_path).map_err(|e| format!("读取 EXE 失败: {e}"))?; + + let mut out = fs::File::create(output_path).map_err(|e| format!("创建输出失败: {e}"))?; + out.write_all(&stub).map_err(|e| format!("写入 stub 失败: {e}"))?; + out.write_all(&payload).map_err(|e| format!("写入 EXE 失败: {e}"))?; + out.write_all(config).map_err(|e| format!("写入配置失败: {e}"))?; + out.write_all(MAGIC).map_err(|e| format!("写入标识失败: {e}"))?; + out.write_all(&(payload.len() as u64).to_le_bytes()) + .map_err(|e| format!("写入长度失败: {e}"))?; + out.write_all(&(config.len() as u64).to_le_bytes()) + .map_err(|e| format!("写入长度失败: {e}"))?; + out.flush().map_err(|e| format!("写入失败: {e}"))?; + Ok(()) +} + +fn normalize_api_base(value: &str) -> Result { + let trimmed = value.trim().trim_end_matches('/'); + if trimmed.is_empty() { + return Err("请输入授权系统地址".to_string()); + } + Ok(trimmed.to_string()) +} + +fn derive_output_path(input: &Path) -> Result { + let parent = input.parent().unwrap_or(Path::new(".")); + let stem = input + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("app"); + let ext = input + .extension() + .and_then(|s| s.to_str()) + .unwrap_or("exe"); + let name = format!("{stem}_packed.{ext}"); + Ok(parent.join(name)) +} + +fn decode_integration_code(code: &str) -> Result<(String, String), String> { + let trimmed = code.trim(); + if trimmed.is_empty() { + return Err("请输入对接码".to_string()); + } + + if trimmed.contains('|') { + return split_code(trimmed); + } + + let raw = trimmed.strip_prefix("LSC1.").unwrap_or(trimmed); + let decoded = STANDARD.decode(raw).map_err(|_| "对接码格式错误".to_string())?; + let decoded_str = String::from_utf8(decoded).map_err(|_| "对接码解析失败".to_string())?; + split_code(&decoded_str) +} + +fn split_code(value: &str) -> Result<(String, String), String> { + let mut parts = value.split('|'); + let project_id = parts.next().unwrap_or("").trim(); + let project_key = parts.next().unwrap_or("").trim(); + if project_id.is_empty() || project_key.is_empty() { + return Err("对接码内容不完整".to_string()); + } + Ok((project_id.to_string(), project_key.to_string())) +} diff --git a/license-system-launcher/src/bin/launcher_pack.rs b/license-system-launcher/src/bin/launcher_pack.rs new file mode 100644 index 0000000..f2dfebb --- /dev/null +++ b/license-system-launcher/src/bin/launcher_pack.rs @@ -0,0 +1,97 @@ +use std::env; +use std::fs; +use std::io::Write; +use std::path::Path; + +const MAGIC: &[u8] = b"LSWRAP1"; + +fn main() { + if let Err(err) = run() { + eprintln!("pack failed: {err}"); + std::process::exit(1); + } +} + +fn run() -> Result<(), String> { + let args = parse_args()?; + let stub = fs::read(&args.stub).map_err(|e| format!("read stub: {e}"))?; + let payload = fs::read(&args.input).map_err(|e| format!("read input: {e}"))?; + + let mut config_value: serde_json::Value = { + let raw = fs::read(&args.config).map_err(|e| format!("read config: {e}"))?; + serde_json::from_slice(&raw).map_err(|e| format!("invalid config json: {e}"))? + }; + + let payload_name = Path::new(&args.input) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("payload.exe") + .to_string(); + + let obj = config_value + .as_object_mut() + .ok_or("config must be a json object")?; + let existing = obj.get("payload_name").and_then(|v| v.as_str()).unwrap_or(""); + if existing.trim().is_empty() { + obj.insert("payload_name".to_string(), serde_json::Value::String(payload_name)); + } + + let config = serde_json::to_vec(&config_value).map_err(|e| format!("serialize config: {e}"))?; + + let payload_len = payload.len() as u64; + let config_len = config.len() as u64; + + let mut out = fs::File::create(&args.output).map_err(|e| format!("create output: {e}"))?; + out.write_all(&stub).map_err(|e| format!("write stub: {e}"))?; + out.write_all(&payload).map_err(|e| format!("write payload: {e}"))?; + out.write_all(&config).map_err(|e| format!("write config: {e}"))?; + out.write_all(MAGIC).map_err(|e| format!("write magic: {e}"))?; + out.write_all(&payload_len.to_le_bytes()) + .map_err(|e| format!("write payload len: {e}"))?; + out.write_all(&config_len.to_le_bytes()) + .map_err(|e| format!("write config len: {e}"))?; + out.flush().map_err(|e| format!("flush output: {e}"))?; + + println!("packed: {}", args.output); + Ok(()) +} + +struct Args { + stub: String, + input: String, + config: String, + output: String, +} + +fn parse_args() -> Result { + let mut stub = None; + let mut input = None; + let mut config = None; + let mut output = None; + + let mut iter = env::args().skip(1); + while let Some(arg) = iter.next() { + match arg.as_str() { + "--stub" | "-s" => stub = iter.next(), + "--input" | "-i" => input = iter.next(), + "--config" | "-c" => config = iter.next(), + "--output" | "-o" => output = iter.next(), + "--help" | "-h" => { + print_help(); + std::process::exit(0); + } + _ => return Err(format!("unknown arg: {arg}")), + } + } + + let stub = stub.ok_or("missing --stub")?; + let input = input.ok_or("missing --input")?; + let config = config.ok_or("missing --config")?; + let output = output.ok_or("missing --output")?; + + Ok(Args { stub, input, config, output }) +} + +fn print_help() { + println!("Usage: launcher_pack --stub --input --config --output "); +} diff --git a/license-system-launcher/src/bin/launcher_stub.rs b/license-system-launcher/src/bin/launcher_stub.rs new file mode 100644 index 0000000..fdd23f6 --- /dev/null +++ b/license-system-launcher/src/bin/launcher_stub.rs @@ -0,0 +1,443 @@ +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::thread; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use hmac::{Hmac, Mac}; +use rand::Rng; +use reqwest::blocking::Client; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +const MAGIC: &[u8] = b"LSWRAP1"; +const FOOTER_LEN: usize = 7 + 8 + 8; + +fn main() { + if let Err(err) = run() { + eprintln!("launcher error: {err}"); + std::process::exit(1); + } +} + +fn run() -> Result<(), String> { + let exe_path = env::current_exe().map_err(|e| format!("current_exe: {e}"))?; + let exe_dir = exe_path.parent().unwrap_or(Path::new(".")).to_path_buf(); + let (payload, mut config) = read_embedded(&exe_path)?; + normalize_config(&mut config)?; + + let card_key = load_card_key(&exe_dir, &config)?; + let device_id = resolve_device_id(); + + let timeout = config.request_timeout_sec.unwrap_or(10); + let client = Client::builder() + .timeout(Duration::from_secs(timeout)) + .build() + .map_err(|e| format!("http client: {e}"))?; + + let verify = verify_online(&client, &config, &card_key, &device_id)?; + let access_token = verify.access_token.ok_or("missing access_token")?; + let heartbeat_interval = if verify.heartbeat_interval > 0 { + verify.heartbeat_interval as u64 + } else { + 0 + }; + + let payload_path = extract_payload(&payload, &config, &exe_dir)?; + + let mut child = Command::new(&payload_path) + .current_dir(&exe_dir) + .args(env::args().skip(1)) + .spawn() + .map_err(|e| format!("spawn payload: {e}"))?; + + let stop = Arc::new(AtomicBool::new(false)); + let heartbeat_failed = Arc::new(AtomicBool::new(false)); + + if heartbeat_interval > 0 { + let client = client.clone(); + let config = config.clone(); + let device_id = device_id.clone(); + let access_token = access_token.clone(); + let stop_flag = stop.clone(); + let fail_flag = heartbeat_failed.clone(); + thread::spawn(move || { + heartbeat_loop(&client, &config, &device_id, &access_token, heartbeat_interval, stop_flag, fail_flag); + }); + } + + loop { + if heartbeat_failed.load(Ordering::SeqCst) { + let _ = child.kill(); + break; + } + + match child.try_wait() { + Ok(Some(_)) => break, + Ok(None) => thread::sleep(Duration::from_millis(500)), + Err(err) => { + eprintln!("wait child failed: {err}"); + break; + } + } + } + + stop.store(true, Ordering::SeqCst); + + let keep_payload = config.keep_payload.unwrap_or(false); + let extract_to = config.extract_to.clone().unwrap_or_else(|| "temp".to_string()); + if !keep_payload || extract_to == "temp" { + let _ = fs::remove_file(&payload_path); + } + + Ok(()) +} + +#[derive(Debug, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +struct LauncherConfig { + api_base: String, + project_id: String, + project_secret: String, + client_version: Option, + license_file: Option, + card_key: Option, + request_timeout_sec: Option, + heartbeat_retries: Option, + heartbeat_retry_delay_sec: Option, + extract_to: Option, + payload_name: Option, + keep_payload: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ApiResponse { + code: i32, + message: String, + data: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct VerifyData { + valid: bool, + access_token: Option, + heartbeat_interval: i32, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct VerifyRequest<'a> { + project_id: &'a str, + key_code: &'a str, + device_id: &'a str, + client_version: Option<&'a str>, + timestamp: i64, + signature: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct HeartbeatRequest<'a> { + access_token: &'a str, + device_id: &'a str, + timestamp: i64, + signature: String, +} + +struct VerifyResult { + access_token: Option, + heartbeat_interval: i64, +} + +fn read_embedded(exe_path: &Path) -> Result<(Vec, LauncherConfig), String> { + let bytes = fs::read(exe_path).map_err(|e| format!("read exe: {e}"))?; + if bytes.len() < FOOTER_LEN { + return Err("invalid launcher file".to_string()); + } + + let footer_offset = bytes.len() - FOOTER_LEN; + let magic = &bytes[footer_offset..footer_offset + MAGIC.len()]; + if magic != MAGIC { + return Err("missing embedded payload".to_string()); + } + + let payload_len = read_u64(&bytes[footer_offset + MAGIC.len()..footer_offset + MAGIC.len() + 8])? as usize; + let config_len = read_u64(&bytes[footer_offset + MAGIC.len() + 8..footer_offset + MAGIC.len() + 16])? as usize; + + let total_len = payload_len + config_len + FOOTER_LEN; + if total_len > bytes.len() { + return Err("embedded data corrupted".to_string()); + } + + let payload_start = bytes.len() - total_len; + let payload_end = payload_start + payload_len; + let config_end = payload_end + config_len; + + let payload = bytes[payload_start..payload_end].to_vec(); + let config_bytes = &bytes[payload_end..config_end]; + let config: LauncherConfig = serde_json::from_slice(config_bytes) + .map_err(|e| format!("parse config: {e}"))?; + + Ok((payload, config)) +} + +fn read_u64(slice: &[u8]) -> Result { + if slice.len() != 8 { + return Err("invalid footer".to_string()); + } + let mut buf = [0u8; 8]; + buf.copy_from_slice(slice); + Ok(u64::from_le_bytes(buf)) +} + +fn normalize_config(config: &mut LauncherConfig) -> Result<(), String> { + if config.api_base.trim().is_empty() { + return Err("api_base is empty".to_string()); + } + config.api_base = config.api_base.trim_end_matches('/').to_string(); + + if config.project_id.trim().is_empty() { + return Err("project_id is empty".to_string()); + } + if config.project_secret.trim().is_empty() { + return Err("project_secret is empty".to_string()); + } + + if config.request_timeout_sec.is_none() { + config.request_timeout_sec = Some(10); + } + if config.heartbeat_retries.is_none() { + config.heartbeat_retries = Some(2); + } + if config.heartbeat_retry_delay_sec.is_none() { + config.heartbeat_retry_delay_sec = Some(5); + } + if let Some(target) = config.extract_to.as_ref() { + config.extract_to = Some(target.trim().to_lowercase()); + } else { + config.extract_to = Some("temp".to_string()); + } + if config.payload_name.as_ref().map(|s| s.trim().is_empty()).unwrap_or(true) { + config.payload_name = Some("payload.exe".to_string()); + } + if config.keep_payload.is_none() { + config.keep_payload = Some(false); + } + + Ok(()) +} + +fn load_card_key(exe_dir: &Path, config: &LauncherConfig) -> Result { + if let Some(card) = config.card_key.as_ref().filter(|s| !s.trim().is_empty()) { + return Ok(card.trim().to_string()); + } + + let license_file = config.license_file.clone().unwrap_or_else(|| "license.key".to_string()); + let path = exe_dir.join(license_file); + let content = fs::read_to_string(&path).map_err(|_| "missing license.key".to_string())?; + let card = content + .lines() + .map(|line| line.trim()) + .find(|line| !line.is_empty()) + .ok_or("license.key is empty".to_string())?; + Ok(card.to_string()) +} + +fn resolve_device_id() -> String { + let mac = mac_address::get_mac_address() + .ok() + .flatten() + .map(|m| m.to_string()) + .unwrap_or_else(|| "unknown".to_string()); + + let ip = local_ip_address::local_ip() + .ok() + .map(|addr| addr.to_string()) + .unwrap_or_else(|| "unknown".to_string()); + + let raw = format!("{mac}|{ip}"); + if raw.len() <= 64 { + raw + } else { + sha256_hex(raw.as_bytes()) + } +} + +fn verify_online(client: &Client, config: &LauncherConfig, card_key: &str, device_id: &str) -> Result { + let timestamp = unix_ts(); + let payload = format!("{}|{}|{}", config.project_id, device_id, timestamp); + let signature = sign_hmac(&payload, &config.project_secret); + + let request = VerifyRequest { + project_id: &config.project_id, + key_code: card_key, + device_id, + client_version: config.client_version.as_deref(), + timestamp, + signature, + }; + + let url = format!("{}/api/auth/verify", config.api_base); + let resp = client + .post(&url) + .header("X-Device-Id", device_id) + .json(&request) + .send() + .map_err(|e| format!("verify request: {e}"))?; + + let text = resp.text().map_err(|e| format!("verify response: {e}"))?; + let api: ApiResponse = serde_json::from_str(&text) + .map_err(|e| format!("verify decode: {e} ({text})"))?; + + if api.code != 200 { + return Err(format!("verify failed: {}", api.message)); + } + + let data = api.data.ok_or("verify missing data".to_string())?; + if !data.valid { + return Err("license invalid".to_string()); + } + + Ok(VerifyResult { + access_token: data.access_token, + heartbeat_interval: data.heartbeat_interval as i64, + }) +} + +fn heartbeat_loop(client: &Client, config: &LauncherConfig, device_id: &str, access_token: &str, interval: u64, stop: Arc, failed: Arc) { + let retries = config.heartbeat_retries.unwrap_or(2); + let delay = config.heartbeat_retry_delay_sec.unwrap_or(5); + + loop { + for _ in 0..interval { + if stop.load(Ordering::SeqCst) { + return; + } + thread::sleep(Duration::from_secs(1)); + } + + let mut ok = false; + for attempt in 0..=retries { + if stop.load(Ordering::SeqCst) { + return; + } + match send_heartbeat(client, config, device_id, access_token) { + Ok(_) => { + ok = true; + break; + } + Err(_) if attempt < retries => { + thread::sleep(Duration::from_secs(delay)); + } + Err(_) => break, + } + } + + if !ok { + failed.store(true, Ordering::SeqCst); + return; + } + } +} + +fn send_heartbeat(client: &Client, config: &LauncherConfig, device_id: &str, access_token: &str) -> Result<(), String> { + let timestamp = unix_ts(); + let payload = format!("{}|{}|{}", config.project_id, device_id, timestamp); + let signature = sign_hmac(&payload, &config.project_secret); + + let request = HeartbeatRequest { + access_token, + device_id, + timestamp, + signature, + }; + + let url = format!("{}/api/auth/heartbeat", config.api_base); + let resp = client + .post(&url) + .header("X-Device-Id", device_id) + .json(&request) + .send() + .map_err(|e| format!("heartbeat request: {e}"))?; + + let text = resp.text().map_err(|e| format!("heartbeat response: {e}"))?; + let api: ApiResponse = serde_json::from_str(&text) + .map_err(|e| format!("heartbeat decode: {e} ({text})"))?; + + if api.code != 200 { + return Err(format!("heartbeat failed: {}", api.message)); + } + + Ok(()) +} + +fn extract_payload(payload: &[u8], config: &LauncherConfig, exe_dir: &Path) -> Result { + let payload_name = config.payload_name.clone().unwrap_or_else(|| "payload.exe".to_string()); + let safe_name = Path::new(&payload_name) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("payload.exe") + .to_string(); + + let extract_to = config.extract_to.as_deref().unwrap_or("temp").to_lowercase(); + let target_path = if extract_to == "self" { + exe_dir.join(safe_name) + } else { + let mut rng = rand::thread_rng(); + let suffix: u32 = rng.gen(); + let (stem, ext) = split_name(&safe_name); + let file_name = if ext.is_empty() { + format!("{stem}_{suffix}") + } else { + format!("{stem}_{suffix}.{ext}") + }; + env::temp_dir().join(file_name) + }; + + fs::write(&target_path, payload).map_err(|e| format!("write payload: {e}"))?; + Ok(target_path) +} + +fn split_name(name: &str) -> (String, String) { + let path = Path::new(name); + let stem = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("payload") + .to_string(); + let ext = path.extension().and_then(|s| s.to_str()).unwrap_or("").to_string(); + (stem, ext) +} + +fn unix_ts() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_else(|_| Duration::from_secs(0)) + .as_secs() as i64 +} + +fn sign_hmac(payload: &str, secret: &str) -> String { + let mut mac = Hmac::::new_from_slice(secret.as_bytes()) + .expect("hmac can take key of any size"); + mac.update(payload.as_bytes()); + let result = mac.finalize().into_bytes(); + to_hex_lower(&result) +} + +fn to_hex_lower(bytes: &[u8]) -> String { + let mut out = String::with_capacity(bytes.len() * 2); + for b in bytes { + out.push_str(&format!("{:02x}", b)); + } + out +} + +fn sha256_hex(data: &[u8]) -> String { + let hash = Sha256::digest(data); + to_hex_lower(&hash) +} diff --git a/软件授权系统-综合方案.md b/软件授权系统-综合方案.md new file mode 100644 index 0000000..a5f96e0 --- /dev/null +++ b/软件授权系统-综合方案.md @@ -0,0 +1,3280 @@ +# 软件授权系统 - 综合设计方案 + +## 一、需求分析 + +### 1.1 核心功能 + +| 模块 | 功能描述 | +|------|----------| +| **启动器客户端** | 卡密登录 → 验证 → 下载软件 → 启动软件 | +| **管理后台** | 卡密生成/管理、多项目管理、统计报表 | +| **API 服务** | 认证、授权、软件分发、心跳验证 | +| **SDK(可选)** | 供第三方软件集成,实现授权验证 | + +### 1.2 用户角色 + +| 角色 | 描述 | 权限 | +|------|------|------| +| **超级管理员** | 系统最高权限 | 全部功能 + 代理管理 | +| **管理员** | 项目管理、卡密管理 | 所属项目的完整权限 | +| **代理商** | 销售卡密、管理额度 | 限制生成数量、查看销售统计 | +| **最终用户** | 使用卡密登录启动器 | 仅使用软件 | +| **软件开发者** | 集成 SDK 开发授权软件 | 查看开发文档 | + +### 1.3 功能清单 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 管理后台功能 │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ 📊 仪表盘 │ +│ ├─ 实时统计(今日/本周/本月) │ +│ ├─ 活跃用户趋势图 │ +│ ├─ 项目分布饼图 │ +│ └─ 最近活动日志 │ +│ │ +│ 📁 项目管理 │ +│ ├─ 创建项目(自动生成 ProjectKey/Secret) │ +│ ├─ 编辑项目信息 │ +│ ├─ 上传软件包(自动加密) │ +│ ├─ 查看项目统计 │ +│ ├─ 查看开发文档(可编辑) │ +│ └─ 删除/禁用项目 │ +│ │ +│ 🔑 卡密管理 │ +│ ├─ 批量生成卡密 │ +│ │ ├─ 选择项目 │ +│ │ ├─ 选择类型(天卡/周卡/月卡/年卡/永久) │ +│ │ ├─ 设置数量 │ +│ │ ├─ 批量导出(TXT/CSV/Excel) │ +│ │ └─ 预览生成结果 │ +│ ├─ 卡密列表 │ +│ │ ├─ 搜索(卡密/备注) │ +│ │ ├─ 筛选(状态/类型/项目) │ +│ │ ├─ 批量操作 │ +│ │ └─ 导出 │ +│ ├─ 卡密详情 │ +│ │ ├─ 基本信息 │ +│ │ ├─ 使用记录 │ +│ │ ├─ 设备信息 │ +│ │ ├─ 操作日志 │ +│ │ └─ 编辑/封禁/删除 │ +│ └─ 卡密操作 │ +│ ├─ 封禁卡密 │ +│ ├─ 解封卡密 │ +│ ├─ 延长有效期 │ +│ ├─ 修改备注 │ +│ ├─ 重置设备绑定 │ +│ └─ 删除卡密 │ +│ │ +│ 👥 代理商管理 │ +│ ├─ 创建代理商 │ +│ │ ├─ 设置账号密码 │ +│ │ ├─ 设置初始额度 │ +│ │ ├─ 设置折扣比例 │ +│ │ └─ 分配可见项目 │ +│ ├─ 代理商列表 │ +│ ├─ 额度管理 │ +│ │ ├─ 充值额度 │ +│ │ ├─ 扣减额度 │ +│ │ └─ 额度流水记录 │ +│ ├─ 销售统计 │ +│ └─ 禁用/删除代理商 │ +│ │ +│ 🖥️ 设备管理 │ +│ ├─ 设备列表 │ +│ ├─ 在线设备监控 │ +│ ├─ 强制下线 │ +│ └─ 解绑设备 │ +│ │ +│ 📝 日志审计 │ +│ ├─ 操作日志 │ +│ ├─ 登录日志 │ +│ ├─ 验证日志 │ +│ └─ 异常告警 │ +│ │ +│ 📖 开发文档 │ +│ ├─ SDK 集成指南 │ +│ ├─ API 接口文档 │ +│ ├─ 代码示例 │ +│ └─ 常见问题 │ +│ │ +│ ⚙️ 系统设置 │ +│ ├─ 管理员账号管理 │ +│ ├─ 系统配置 │ +│ └─ 数据备份 │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 二、系统架构 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 管理后台前端 │ +│ (Vue3 + Element Plus) │ +│ 项目管理 | 卡密生成 | 统计报表 | 日志审计 │ +└─────────────────────────────┬───────────────────────────────────┘ + │ HTTP/HTTPS + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ 后端 API 服务 │ +│ (ASP.NET Core Web API) │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ 卡密管理 │ │ 项目管理 │ │ 统计分析 │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ 验证服务 │ │ 日志审计 │ │ 风控引擎 │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ │ +└─────────────────────────────┬───────────────────────────────────┘ + │ +┌─────────────────────────────┴───────────────────────────────────┐ +│ 数据层 │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ PostgreSQL │ │ Redis │ │ 文件存储 │ │ +│ │ (持久化) │ │ (缓存/锁) │ │ (软件包) │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ + ▲ + │ API 调用 +┌─────────────────────────────┴───────────────────────────────────┐ +│ │ +│ ┌──────────────────┐ ┌──────────────────┐ │ +│ │ 启动器客户端 │ │ 授权软件 │ │ +│ │ (C# WPF) │ │ (C# + SDK) │ │ +│ │ - 卡密验证 │ │ - 心跳验证 │ │ +│ │ - 软件下载 │ │ - 机器码绑定 │ │ +│ └──────────────────┘ └──────────────────┘ │ +│ │ +└──────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 三、数据库设计 + +### 3.1 Projects(项目表) + +```sql +CREATE TABLE Projects ( + Id SERIAL PRIMARY KEY, + ProjectId VARCHAR(32) UNIQUE NOT NULL, -- 前端展示的项目ID + ProjectKey VARCHAR(64) NOT NULL, -- 项目密钥(HMAC签名用) + 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, + CreatedBy INT, -- 创建人ID + CreatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UpdatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 项目卡密定价表 +CREATE TABLE ProjectPricing ( + Id SERIAL PRIMARY KEY, + ProjectId VARCHAR(32) REFERENCES Projects(ProjectId) ON DELETE CASCADE, + CardType VARCHAR(20) NOT NULL, -- day/week/month/year/lifetime + 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 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), -- SHA256哈希 + EncryptionKey VARCHAR(256), -- 加密密钥(RSA加密后存储) + 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) +); +``` + +### 3.2 CardKeys(卡密表) + +```sql +CREATE TABLE CardKeys ( + Id SERIAL PRIMARY KEY, + ProjectId VARCHAR(32) REFERENCES Projects(ProjectId) ON DELETE SET NULL, + KeyCode VARCHAR(32) UNIQUE NOT NULL, -- 卡密(格式:XXXX-XXXX-XXXX-XXXX) + CardType VARCHAR(20) NOT NULL, -- day/week/month/year/lifetime + DurationDays INT NOT NULL, + ExpireTime TIMESTAMP, -- 过期时间 + MaxDevices INT DEFAULT 1, -- 可单独覆盖项目配置 + MachineCode VARCHAR(64), -- 首次激活后绑定的机器码 + Status VARCHAR(20) DEFAULT 'unused', -- unused/active/expired/banned + 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), -- 批次ID(用于批量管理) + Version INT DEFAULT 1, -- 乐观锁版本号 + CreatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + DeletedAt TIMESTAMP -- 软删除时间(NULL=未删除) +); + +-- 基础索引 +CREATE INDEX idx_card_keys_project ON CardKeys(ProjectId); +CREATE INDEX idx_card_keys_code ON CardKeys(KeyCode); +CREATE INDEX idx_card_keys_status ON CardKeys(Status); +-- 复合索引优化查询 +CREATE INDEX idx_card_keys_project_status_created ON CardKeys(ProjectId, Status, CreatedAt DESC) WHERE DeletedAt IS NULL; +CREATE INDEX idx_card_keys_expire ON CardKeys(ExpireTime) WHERE Status = 'active' AND DeletedAt IS NULL; +CREATE INDEX idx_card_keys_agent ON CardKeys(AgentId) WHERE DeletedAt IS NULL; +CREATE INDEX idx_card_keys_batch ON CardKeys(BatchId); +``` + +### 3.3 Devices(设备表) + +```sql +CREATE TABLE 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), -- 最后登录IP + Location VARCHAR(100), -- IP归属地(省市) + IsActive BOOLEAN DEFAULT TRUE, + FirstLoginAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + DeletedAt TIMESTAMP -- 软删除时间 +); + +CREATE INDEX idx_devices_card_key ON Devices(CardKeyId); +CREATE INDEX idx_devices_device_id ON Devices(DeviceId); +CREATE INDEX idx_devices_heartbeat ON Devices(LastHeartbeat) WHERE IsActive = true AND DeletedAt IS NULL; +``` + +### 3.4 AccessLogs(访问日志表) + +```sql +CREATE TABLE AccessLogs ( + Id SERIAL PRIMARY KEY, + ProjectId VARCHAR(32), + CardKeyId INT, + DeviceId VARCHAR(64), + Action VARCHAR(50), -- login/verify/download/launch/heartbeat + IpAddress INET, + UserAgent TEXT, + ResponseCode INT, -- 响应码(200/403等) + ResponseTime INT, -- 响应时间(毫秒) + CreatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_logs_project ON AccessLogs(ProjectId); +CREATE INDEX idx_logs_created ON AccessLogs(CreatedAt); +CREATE INDEX idx_logs_action_created ON AccessLogs(Action, CreatedAt DESC); +CREATE INDEX idx_logs_card_key ON AccessLogs(CardKeyId); + +-- 日志表建议按月分区(数据量大时) +-- CREATE TABLE AccessLogs_202501 PARTITION OF AccessLogs FOR VALUES FROM ('2025-01-01') TO ('2025-02-01'); +``` + +### 3.5 Statistics(统计表) + +```sql +CREATE TABLE 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) +); +``` + +### 3.6 Admins(管理员表) + +```sql +CREATE TABLE Admins ( + Id SERIAL PRIMARY KEY, + Username VARCHAR(50) UNIQUE NOT NULL, + PasswordHash VARCHAR(255) NOT NULL, -- bcrypt + Email VARCHAR(100), + Role VARCHAR(20) DEFAULT 'admin', -- super_admin/admin + Permissions TEXT, -- JSON 数组,权限列表 + Status VARCHAR(20) DEFAULT 'active', -- active/disabled + LastLoginAt TIMESTAMP, + LastLoginIp VARCHAR(45), + CreatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UpdatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +``` + +### 3.7 Agents(代理商表) + +```sql +CREATE TABLE 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, -- 折扣比例(100=原价) + CreditLimit DECIMAL(10,2) DEFAULT 0, -- 信用额度(负数=允许透支) + MaxProjects INT DEFAULT 0, -- 0=全部,否则限制可见项目数 + AllowedProjects TEXT, -- JSON 数组,可见的项目ID列表 + Status VARCHAR(20) DEFAULT 'active', -- active/disabled + LastLoginAt TIMESTAMP, + CreatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UpdatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +``` + +### 3.8 AgentTransactions(代理商额度流水) + +```sql +CREATE TABLE AgentTransactions ( + Id SERIAL PRIMARY KEY, + AgentId INT REFERENCES Agents(Id) ON DELETE CASCADE, + Type VARCHAR(20) NOT NULL, -- recharge/consume/refund + 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 +); +``` + +### 3.9 CardKeyLogs(卡密操作日志) + +```sql +CREATE TABLE CardKeyLogs ( + Id SERIAL PRIMARY KEY, + CardKeyId INT REFERENCES CardKeys(Id) ON DELETE CASCADE, + Action VARCHAR(50) NOT NULL, -- create/activate/ban/unban/extend/reset_device + OperatorId INT, -- 操作人(可为空,系统操作时) + OperatorType VARCHAR(20), -- admin/agent/system + Details TEXT, -- JSON 格式详情 + IpAddress VARCHAR(45), + CreatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +``` + +### 3.10 SystemConfigs(系统配置表 - 配置驱动核心) + +```sql +CREATE TABLE SystemConfigs ( + Id SERIAL PRIMARY KEY, + ConfigKey VARCHAR(50) UNIQUE NOT NULL, + ConfigValue TEXT, + ValueType VARCHAR(20) DEFAULT 'string', -- string/bool/number/json + Category VARCHAR(50) DEFAULT 'general', -- 配置分类 + DisplayName VARCHAR(100), -- 前端显示名称 + Description VARCHAR(500), -- 说明 + Options TEXT, -- JSON,可选值(枚举类型用) + IsPublic BOOLEAN DEFAULT FALSE, -- 是否可被客户端读取 + UpdatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +``` + +**配置分类 + 前端开关控制:** + +| 分类 | 配置键 | 默认值 | 前端控制类型 | 说明 | +|------|--------|--------|-------------|------| +| **功能开关** | +| 功能 | `feature.heartbeat` | true | 开关 | 是否启用心跳验证 | +| 功能 | `feature.device_bind` | true | 开关 | 是否启用设备绑定 | +| 功能 | `feature.auto_update` | true | 开关 | 是否启用自动更新 | +| 功能 | `feature.force_update` | false | 开关 | 是否强制更新 | +| 功能 | `feature.agent_system` | true | 开关 | 是否启用代理商系统 | +| 功能 | `feature.card_renewal` | true | 开关 | 是否启用卡密续费 | +| 功能 | `feature.trial_mode` | false | 开关 | 是否启用试用模式 | +| **验证规则** | +| 验证 | `auth.max_devices` | 1 | 数字 | 单卡密最大设备数 | +| 验证 | `auth.allow_multi_device` | false | 开关 | 是否允许多设备同时在线 | +| 验证 | `auth.need_activate` | true | 开关 | 卡密是否需要激活 | +| 验证 | `auth.expire_type` | `activate` | 选择 | 过期类型:activate/fix | +| **心跳配置** | +| 心跳 | `heartbeat.enabled` | true | 开关 | 是否启用心跳 | +| 心跳 | `heartbeat.interval` | 60 | 数字 | 心跳间隔(秒) | +| 心跳 | `heartbeat.timeout` | 180 | 数字 | 心跳超时(秒) | +| 心跳 | `heartbeat.offline_action` | `exit` | 选择 | 掉线后行为:exit/warning/none | +| **限流配置** | +| 限流 | `ratelimit.enabled` | true | 开关 | 是否启用限流 | +| 限流 | `ratelimit.ip_per_minute` | 100 | 数字 | IP每分钟请求限制 | +| 限流 | `ratelimit.device_per_minute` | 50 | 数字 | 设备每分钟请求限制 | +| 限流 | `ratelimit.block_duration` | 5 | 数字 | 封禁时长(分钟) | +| **风控配置** | +| 风控 | `risk.enabled` | true | 开关 | 是否启用风控 | +| 风控 | `risk.check_location` | true | 开关 | 异地登录检测 | +| 风控 | `risk.check_device_change` | true | 开关 | 设备变更检测 | +| 风控 | `risk.auto_ban` | false | 开关 | 自动封禁异常 | +| **客户端显示** | +| 显示 | `client.notice_title` | "" | 文本 | 公告标题 | +| 显示 | `client.notice_content` | "" | 文本域 | 公告内容 | +| 显示 | `client.contact_url` | "" | 文本 | 联系方式URL | +| 显示 | `client.help_url` | "" | 文本 | 帮助文档URL | +| 显示 | `client.show_balance` | false | 开关 | 是否在客户端显示余额 | +| **其他** | +| 系统 | `system.site_name` | "软件授权系统" | 文本 | 系统名称 | +| 系统 | `system.logo_url` | "" | 文本 | Logo地址 | +| 系统 | `system.enable_register` | false | 开关 | 是否开放用户注册 | +| 系统 | `log.retention_days` | 90 | 数字 | 日志保留天数 | +``` + +### 3.11 并发安全与事务处理 + +#### 3.11.1 代理商扣款(行级锁) + +```sql +-- 代理商生成卡密时扣款(必须使用事务+行级锁) +BEGIN; + -- 1. 获取行级锁,防止并发扣款 + SELECT Balance FROM Agents WHERE Id = :agentId FOR UPDATE; + + -- 2. 检查余额是否充足 + -- (在应用层判断,不足则 ROLLBACK) + + -- 3. 扣款 + UPDATE Agents + SET Balance = Balance - :amount, UpdatedAt = NOW() + WHERE Id = :agentId AND Balance >= :amount; + + -- 4. 记录流水 + INSERT INTO AgentTransactions (AgentId, Type, Amount, BalanceBefore, BalanceAfter, Remark, CreatedAt) + VALUES (:agentId, 'consume', -:amount, :balanceBefore, :balanceAfter, :remark, NOW()); + + -- 5. 生成卡密 + INSERT INTO CardKeys (...) VALUES (...); +COMMIT; +``` + +#### 3.11.2 卡密激活(乐观锁) + +```sql +-- 使用乐观锁防止并发激活 +UPDATE CardKeys +SET Status = 'active', + ActivateTime = NOW(), + MachineCode = :machineCode, + Version = Version + 1 +WHERE KeyCode = :keyCode + AND Status = 'unused' + AND Version = :expectedVersion + AND DeletedAt IS NULL; + +-- 检查受影响行数,0表示已被其他请求激活或版本冲突 +-- 应用层需要处理这种情况 +``` + +#### 3.11.3 设备绑定(唯一约束+UPSERT) + +```sql +-- 设备绑定使用 UPSERT 避免并发插入冲突 +INSERT INTO Devices (CardKeyId, DeviceId, DeviceName, OsInfo, IpAddress, LastHeartbeat, FirstLoginAt) +VALUES (:cardKeyId, :deviceId, :deviceName, :osInfo, :ipAddress, NOW(), NOW()) +ON CONFLICT (CardKeyId, DeviceId) +DO UPDATE SET + LastHeartbeat = NOW(), + IpAddress = :ipAddress, + DeviceName = COALESCE(:deviceName, Devices.DeviceName); +``` + +#### 3.11.4 心跳更新(批量优化) + +```sql +-- 批量更新心跳时间(减少数据库压力) +UPDATE Devices +SET LastHeartbeat = NOW() +WHERE Id = ANY(:deviceIds) AND DeletedAt IS NULL; +``` + +#### 3.11.5 软删除查询模式 + +```sql +-- 所有查询默认过滤已删除数据 +SELECT * FROM CardKeys WHERE ProjectId = :projectId AND DeletedAt IS NULL; + +-- 软删除操作 +UPDATE CardKeys SET DeletedAt = NOW() WHERE Id = :id; + +-- 恢复删除 +UPDATE CardKeys SET DeletedAt = NULL WHERE Id = :id; + +-- 清理30天前的已删除数据(定时任务) +DELETE FROM CardKeys WHERE DeletedAt < NOW() - INTERVAL '30 days'; +``` + +### 3.12 幂等性支持表 + +```sql +-- 幂等性键存储(防止重复请求) +CREATE TABLE IdempotencyKeys ( + Id SERIAL PRIMARY KEY, + IdempotencyKey VARCHAR(64) UNIQUE NOT NULL, -- 幂等键(UUID) + RequestPath VARCHAR(200) NOT NULL, -- 请求路径 + RequestHash VARCHAR(64), -- 请求体哈希 + ResponseCode INT, -- 响应状态码 + ResponseBody TEXT, -- 响应内容(JSON) + CreatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + ExpiresAt TIMESTAMP NOT NULL -- 过期时间 +); + +CREATE INDEX idx_idempotency_key ON IdempotencyKeys(IdempotencyKey); +CREATE INDEX idx_idempotency_expires ON IdempotencyKeys(ExpiresAt); + +-- 定时清理过期的幂等键 +DELETE FROM IdempotencyKeys WHERE ExpiresAt < NOW(); +``` + +--- + +## 四、API 接口设计 + +### 4.0 统一响应格式与错误码 + +#### 4.0.1 统一响应格式 +```json +{ + "code": 200, + "message": "success", + "data": {}, + "timestamp": 1735000000 +} +``` + +#### 4.0.2 错误码规范 +| 错误码 | 错误消息 | HTTP状态码 | 说明 | +|--------|---------|-----------|------| +| **成功类** | +| 200 | success | 200 | 请求成功 | +| **客户端错误(4xxx)** | +| 400 | bad_request | 400 | 请求参数错误 | +| 401 | unauthorized | 401 | 未授权,需要登录 | +| 403 | forbidden | 403 | 无权限访问 | +| 404 | not_found | 404 | 资源不存在 | +| 1001 | card_invalid | 400 | 卡密格式错误或不存在 | +| 1002 | card_expired | 403 | 卡密已过期 | +| 1003 | card_banned | 403 | 卡密已被封禁 | +| 1004 | card_already_used | 400 | 卡密已被使用 | +| 1005 | device_limit_exceeded | 403 | 设备数量超过限制 | +| 1006 | device_not_found | 404 | 设备未找到 | +| 1007 | signature_invalid | 403 | 签名验证失败 | +| 1008 | timestamp_expired | 400 | 时间戳过期 | +| 1009 | rate_limit_exceeded | 429 | 请求过于频繁 | +| 1010 | invalid_version | 400 | 客户端版本过低 | +| 1011 | project_disabled | 403 | 项目已被禁用 | +| 1012 | force_update_required | 426 | 需要强制更新 | +| **服务端错误(5xxx)** | +| 500 | internal_error | 500 | 服务器内部错误 | +| 5001 | database_error | 500 | 数据库错误 | +| 5002 | cache_error | 500 | 缓存服务错误 | +| 5003 | storage_error | 500 | 文件存储错误 | +| 5004 | encryption_error | 500 | 加密服务错误 | + +#### 4.0.3 幂等性设计 + +对于可能产生副作用的接口(如生成卡密、扣款),支持幂等性请求: + +``` +请求头: +X-Idempotency-Key: # 唯一请求标识,24小时内有效 + +示例: +POST /api/admin/cards/generate +Headers: + Authorization: Bearer + X-Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000 + +重复请求同一 Idempotency-Key 将返回首次请求的响应结果。 +``` + +#### 4.0.4 分页参数规范 + +``` +请求参数: + page: 页码,从1开始(默认1) + pageSize: 每页条数(默认20,最大100) + +响应格式: +{ + "code": 200, + "data": { + "items": [...], + "pagination": { + "page": 1, + "pageSize": 20, + "total": 1000, + "totalPages": 50 + } + } +} +``` + +#### 4.0.5 健康检查接口 + +``` +# 存活检查(用于K8s/Docker健康检查) +GET /health/live +Response: { "status": "ok" } + +# 就绪检查(检查数据库、Redis连接) +GET /health/ready +Response: { + "status": "ok", + "checks": { + "database": "ok", + "redis": "ok" + } +} + +# 失败时返回 503: +Response: { + "status": "error", + "checks": { + "database": "ok", + "redis": "error: connection refused" + } +} +``` + +### 4.1 客户端/SDK 接口 + +#### 4.1.1 卡密验证 +``` +POST /api/auth/verify +Request: { + "projectId": "PROJ_001", + "keyCode": "A3D7-K2P9-M8N1", + "deviceId": "SHA256硬件指纹", + "clientVersion": "1.0.0", + "timestamp": 1735000000, + "signature": "HMAC-SHA256签名" +} +Response: { + "code": 200, + "message": "success", + "data": { + "valid": true, + "expireTime": "2025-12-31T23:59:59Z", + "remainingDays": 30, + "downloadUrl": "https://cdn.example.com/software_v1.0.bin", + "fileHash": "sha256:xxxxx", + "version": "1.0.0", + "heartbeatInterval": 60, + "accessToken": "jwt_token" + } +} +``` + +#### 4.1.2 心跳验证 +``` +POST /api/auth/heartbeat +Request: { + "accessToken": "jwt_token", + "deviceId": "xxx", + "timestamp": 1735000000, + "signature": "xxx" +} +Response: { + "code": 200, + "data": { + "valid": true, + "remainingDays": 30, + "serverTime": 1735000000 + } +} + +错误响应: +{ + "code": 403, + "message": "card_expired", + "data": { + "reason": "卡密已过期,请续费", + "kick": true + } +} +``` + +#### 4.1.3 版本检查(云更新) +``` +POST /api/software/check-update +Request: { + "projectId": "PROJ_001", + "currentVersion": "1.0.0", + "platform": "windows" +} +Response: { + "code": 200, + "data": { + "hasUpdate": true, + "latestVersion": "1.2.0", + "forceUpdate": false, + "downloadUrl": "https://cdn.example.com/software_v1.2.0.bin", + "fileSize": 52428800, + "fileHash": "sha256:xxxxx", + "changelog": "- 修复若干bug\n- 新增xxx功能" + } +} +``` + +#### 4.1.4 软件下载 +``` +GET /api/software/download?version=1.2.0&token=jwt_token +Response: 加密的二进制文件 + +Headers: +X-File-Hash: sha256校验值 +X-File-Size: 文件大小 +X-Encryption-Method: AES-256-GCM +X-Encryption-Key: RSA加密的AES密钥(Base64) +X-Encryption-Nonce: 随机nonce +``` + +### 4.2 管理后台接口 + +#### 4.2.1 认证接口 +``` +POST /api/admin/login +Request: { + "username": "admin", + "password": "password", + "captcha": "验证码" +} +Response: { + "code": 200, + "data": { + "token": "jwt_token", + "user": { + "id": 1, + "username": "admin", + "role": "super_admin", + "permissions": ["*"] + } + } +} + +POST /api/admin/logout +GET /api/admin/profile +PUT /api/admin/profile +POST /api/admin/change-password +``` + +#### 4.2.2 项目管理 +``` +POST /api/admin/projects +Request: { + "name": "我的软件", + "description": "软件描述", + "maxDevices": 1, + "autoUpdate": true +} +Response: { + "code": 200, + "data": { + "id": 1, + "projectId": "PROJ_001", -- 自动生成 + "projectKey": "abcd1234...", -- 自动生成 + "projectSecret": "secret123..." -- 自动生成(仅显示一次) + } +} + +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 +Response: { + "code": 200, + "data": [ + { + "id": 1, + "cardType": "day", + "durationDays": 1, + "originalPrice": 1.00, + "isEnabled": true + }, + { + "id": 2, + "cardType": "week", + "durationDays": 7, + "originalPrice": 5.00, + "isEnabled": true + }, + { + "id": 3, + "cardType": "month", + "durationDays": 30, + "originalPrice": 15.00, + "isEnabled": true + }, + { + "id": 4, + "cardType": "year", + "durationDays": 365, + "originalPrice": 100.00, + "isEnabled": true + }, + { + "id": 5, + "cardType": "lifetime", + "durationDays": 36500, + "originalPrice": 299.00, + "isEnabled": true + } + ] +} + +POST /api/admin/projects/{id}/pricing +Request: { + "cardType": "month", + "durationDays": 30, + "originalPrice": 15.00 +} + +PUT /api/admin/projects/{id}/pricing/{priceId} +Request: { + "originalPrice": 20.00, + "isEnabled": true +} + +DELETE /api/admin/projects/{id}/pricing/{priceId} + +# 版本管理 +GET /api/admin/projects/{id}/versions +Response: { + "code": 200, + "data": [ + { + "id": 1, + "version": "1.2.0", + "fileSize": 52428800, + "isStable": true, + "isForceUpdate": false, + "publishedAt": "2025-12-20T10:00:00Z" + }, + { + "id": 2, + "version": "1.1.0", + "fileSize": 51200000, + "isStable": true, + "isForceUpdate": false, + "publishedAt": "2025-12-10T10:00:00Z" + } + ] +} + +POST /api/admin/projects/{id}/versions +Request: { + "version": "1.3.0", + "file": "上传的文件", + "changelog": "更新内容", + "isForceUpdate": false, + "isStable": true +} +Response: { + "code": 200, + "data": { + "versionId": 3, + "version": "1.3.0", + "fileUrl": "https://cdn.example.com/software_v1.3.0.bin", + "fileHash": "sha256:xxxxx", + "encryptionKey": "RSA加密的密钥(仅首次返回)" + } +} + +PUT /api/admin/projects/{id}/versions/{versionId} +Request: { + "isForceUpdate": true, + "isStable": false, + "changelog": "更新内容" +} + +DELETE /api/admin/projects/{id}/versions/{versionId} +``` + +#### 4.2.3 卡密管理 +``` +# 批量生成卡密 +POST /api/admin/cards/generate +Request: { + "projectId": "PROJ_001", + "cardType": "month", -- day/week/month/year/lifetime + "durationDays": 30, + "quantity": 100, + "note": "批次备注" +} +Response: { + "code": 200, + "data": { + "batchId": "batch_xxx", + "keys": [ + "A3D7-K2P9-M8N1-Q4W6", + "B4E8-L3X0-N9O2-P5Y7", + ... + ], + "count": 100 + } +} + +# 卡密列表 +GET /api/admin/cards?projectId=PROJ_001&status=active&page=1&pageSize=20 +Response: { + "code": 200, + "data": { + "total": 1000, + "items": [ + { + "id": 1, + "keyCode": "A3D7-K2P9-M8N1-Q4W6", + "cardType": "month", + "status": "active", + "activateTime": "2025-12-01T10:00:00Z", + "expireTime": "2025-12-31T23:59:59Z", + "machineCode": "abc123...", + "note": "备注", + "createdAt": "2025-12-01T00:00:00Z" + }, + ... + ] + } +} + +# 卡密详情 +GET /api/admin/cards/{id} +Response: { + "code": 200, + "data": { + "id": 1, + "keyCode": "A3D7-K2P9-M8N1-Q4W6", + "project": { + "id": 1, + "name": "我的软件" + }, + "cardType": "month", + "status": "active", + "activateTime": "2025-12-01T10:00:00Z", + "expireTime": "2025-12-31T23:59:59Z", + "machineCode": "abc123...", + "devices": [ + { + "deviceId": "abc123...", + "deviceName": "DESKTOP-ABC", + "ipAddress": "1.2.3.4", + "lastHeartbeat": "2025-12-28T10:30:00Z", + "isActive": true + } + ], + "logs": [...], + "createdAt": "2025-12-01T00:00:00Z" + } +} + +# 卡密操作 +PUT /api/admin/cards/{id} -- 更新备注等 +POST /api/admin/cards/{id}/ban -- 封禁 +Request: { "reason": "违规使用" } +POST /api/admin/cards/{id}/unban -- 解封 +POST /api/admin/cards/{id}/extend -- 延长有效期 +Request: { "days": 30 } +POST /api/admin/cards/{id}/reset-device -- 重置设备绑定 +DELETE /api/admin/cards/{id} -- 删除卡密 + +# 批量操作 +POST /api/admin/cards/ban-batch -- 批量封禁 +Request: { "ids": [1, 2, 3], "reason": "批量封禁" } +POST /api/admin/cards/unban-batch -- 批量解封 +DELETE /api/admin/cards/batch -- 批量删除 + +# 导出 +GET /api/admin/cards/export?format=excel -- 导出卡密 + +# 批量导入(Excel/CSV) +POST /api/admin/cards/import +Content-Type: multipart/form-data +Request: { + "file": , + "projectId": "PROJ_001" +} +Response: { + "code": 200, + "data": { + "total": 100, + "success": 95, + "failed": 5, + "errors": [ + { "row": 3, "keyCode": "XXX", "reason": "格式错误" }, + { "row": 7, "keyCode": "YYY", "reason": "已存在" } + ] + } +} + +# 卡密日志 +GET /api/admin/cards/{id}/logs -- 卡密操作日志 +``` + +#### 4.2.4 代理商管理 +``` +# 代理商登录(独立登录入口) +POST /api/agent/login +Request: { + "agentCode": "AGENT001", + "password": "password" +} +Response: { + "code": 200, + "data": { + "token": "jwt_token", + "agent": { + "id": 1, + "agentCode": "AGENT001", + "companyName": "某某科技公司", + "balance": 5000.00, + "discount": 80.00 + }, + "allowedProjects": [ + { "projectId": "PROJ_001", "projectName": "项目A" }, + { "projectId": "PROJ_002", "projectName": "项目B" } + ] + } +} + +POST /api/agent/logout +GET /api/agent/profile +PUT /api/agent/profile +POST /api/agent/change-password + +# 创建代理商(超管操作) +POST /api/admin/agents +Request: { + "adminId": 5, -- 关联的管理员ID + "agentCode": "AGENT001", + "companyName": "某某科技公司", + "contactPerson": "张三", + "contactPhone": "13800138000", + "contactEmail": "contact@example.com", + "initialBalance": 1000.00, -- 初始额度 + "discount": 80.00, -- 折扣(80=8折) + "creditLimit": 500.00, -- 信用额度 + "allowedProjects": ["PROJ_001", "PROJ_002"] +} + +# 代理商列表 +GET /api/admin/agents?status=active&page=1 +Response: { + "code": 200, + "data": { + "total": 10, + "items": [ + { + "id": 1, + "agentCode": "AGENT001", + "companyName": "某某科技公司", + "contactPerson": "张三", + "contactPhone": "13800138000", + "balance": 500.00, + "discount": 80.00, + "status": "active", + "createdAt": "2025-12-01T00:00:00Z" + } + ] + } +} + +# 代理商详情 +GET /api/admin/agents/{id} +Response: { + "code": 200, + "data": { + "id": 1, + "agentCode": "AGENT001", + "companyName": "某某科技公司", + "balance": 500.00, + "discount": 80.00, + "stats": { + "totalCards": 1000, + "activeCards": 800, + "totalRevenue": 50000.00 + }, + "transactions": [...] + } +} + +# 额度管理 +POST /api/admin/agents/{id}/recharge -- 充值 +Request: { "amount": 1000.00, "remark": "充值" } +POST /api/admin/agents/{id}/deduct -- 扣款 +Request: { "amount": 100.00, "remark": "扣款" } +GET /api/admin/agents/{id}/transactions -- 额度流水 + +# 代理商操作 +PUT /api/admin/agents/{id} +POST /api/admin/agents/{id}/disable +POST /api/admin/agents/{id}/enable +DELETE /api/admin/agents/{id} +``` + +#### 4.2.5 设备管理 +``` +GET /api/admin/devices?projectId=PROJ_001&isActive=true +DELETE /api/admin/devices/{id} -- 解绑设备 +POST /api/admin/devices/{id}/kick -- 强制下线 +``` + +#### 4.2.6 统计报表 +``` +GET /api/admin/stats/dashboard +Response: { + "code": 200, + "data": { + "overview": { + "totalProjects": 10, + "totalCards": 10000, + "activeCards": 6000, + "activeDevices": 4500, + "todayRevenue": 5000.00, + "monthRevenue": 150000.00 + }, + "trend": { + "dates": ["2025-12-01", "2025-12-02", ...], + "activeUsers": [100, 120, ...], + "newUsers": [10, 15, ...], + "revenue": [500, 600, ...] + }, + "projectDistribution": [ + { "project": "PROJ_001", "count": 3000 }, + { "project": "PROJ_002", "count": 2000 } + ] + } +} + +GET /api/admin/stats/projects +GET /api/admin/stats/agents +GET /api/admin/stats/logs +GET /api/admin/stats/export +``` + +#### 4.2.7 日志审计 +``` +GET /api/admin/logs?action=login&page=1 +GET /api/admin/logs/{id} +``` + +#### 4.2.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} +``` + +--- + +## 五、安全方案(防破解) + +> **⚠️ 重要认知:客户端防护的局限性** +> +> 任何运行在用户机器上的代码,理论上都可以被破解。客户端防护(混淆、加壳、反调试)只能**提高破解成本**,不能阻止所有破解行为。 +> +> **核心防线是服务端验证**: +> - 关键功能必须在服务端完成验证后才能使用 +> - 心跳机制确保持续在线验证 +> - 风控引擎检测异常行为 +> +> **合理预期**: +> - ✅ 防止大规模盗版分发 +> - ✅ 让破解成本高于软件价格 +> - ✅ 通过服务端及时封禁破解版 +> - ❌ 无法阻止个别技术高手的破解 + +### 5.1 多层防护体系 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 客户端防护层 │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ 代码混淆 │ │ 加壳保护 │ │ 反调试检测 │ │ +│ │ (ConfuserEx) │ │ (Themida/ │ │ (Debugger/ │ │ +│ │ │ │ VMP可选) │ │ VM检测) │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ │ +│ ┌──────────────┐ ┌──────────────┐ │ +│ │ 完整性校验 │ │ 硬件绑定 │ │ +│ │ (文件Hash) │ │ (机器指纹) │ │ +│ └──────────────┘ └──────────────┘ │ +└─────────────────────────────────────────────────────────────┘ + │ +┌─────────────────────────────────────────────────────────────┐ +│ 通信防护层 │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ HTTPS │ │ 请求签名 │ │ 时间戳验证 │ │ +│ │ TLS 1.3 │ │ HMAC-SHA256 │ │ 防重放攻击 │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ │ +└─────────────────────────────────────────────────────────────┘ + │ +┌─────────────────────────────────────────────────────────────┐ +│ 服务端防护层 │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ 机器码绑定 │ │ 频率限制 │ │ 风控引擎 │ │ +│ │ (硬件指纹) │ │ (Rate Limit)│ │ (异常检测) │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 5.2 客户端防护代码示例 + +#### 1) 机器码生成(硬件指纹) +```csharp +public static class MachineCode +{ + public static string GetDeviceId() + { + var factors = new List + { + GetCpuId(), // CPU 处理器信息 + GetBoardSerial(), // 主板序列号 + GetDiskSerial(), // 硬盘序列号 + GetMacAddress() // MAC 地址(取第一个非虚拟网卡) + }; + + var combined = string.Join("|", factors.Where(x => !string.IsNullOrEmpty(x))); + using var sha = SHA256.Create(); + var hash = sha.ComputeHash(Encoding.UTF8.GetBytes(combined)); + return Convert.ToHexString(hash).ToLower(); + } + + private static string GetCpuId() + { + var cpuInfo = string.Empty; + try + { + using var searcher = new ManagementObjectSearcher("SELECT ProcessorId FROM Win32_Processor"); + foreach (var obj in searcher.Get()) + { + cpuInfo = obj["ProcessorId"]?.ToString(); + break; + } + } + catch { /* 忽略错误 */ } + return cpuInfo ?? "unknown_cpu"; + } + + // ... 其他硬件信息获取方法 +} +``` + +#### 2) 请求签名 +```csharp +public static class RequestSigner +{ + public static string Sign(string projectId, string deviceId, long timestamp, string secret) + { + var payload = $"{projectId}|{deviceId}|{timestamp}"; + using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret)); + var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(payload)); + return Convert.ToHexString(hash).ToLower(); + } + + public static bool Verify(string projectId, string deviceId, long timestamp, + string signature, string secret) + { + // 时间戳验证(5分钟有效期) + var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + if (Math.Abs(now - timestamp) > 300) + return false; + + var expected = Sign(projectId, deviceId, timestamp, secret); + return signature.Equals(expected, StringComparison.OrdinalIgnoreCase); + } +} +``` + +#### 3) 防篡改检测 +```csharp +public static class IntegrityChecker +{ + private static readonly string ExpectedHash = "嵌入的正确Hash值"; + + public static bool Verify() + { + // 1. 文件完整性校验 + var currentHash = ComputeFileHash(Assembly.GetExecutingAssembly().Location); + if (!currentHash.Equals(ExpectedHash, StringComparison.OrdinalIgnoreCase)) + return false; + + // 2. 调试器检测 + if (IsDebuggerAttached()) + return false; + + // 3. 虚拟机检测(可选) + if (IsRunningInVM()) + Environment.Exit(0); + + return true; + } + + private static bool IsDebuggerAttached() + { + return Debugger.IsAttached + || CheckRemoteDebuggerPresent() + || IsDebuggerPresentAPI(); + } + + private static bool IsRunningInVM() + { + // 检测常见虚拟机特征 + var vmKeys = new[] { "VBOX", "VMWARE", "QEMU", "VIRTUAL" }; + try + { + using var searcher = new ManagementObjectSearcher("SELECT * FROM Win32_ComputerSystem"); + foreach (var obj in searcher.Get()) + { + var manufacturer = obj["Manufacturer"]?.ToString()?.ToUpper() ?? ""; + var model = obj["Model"]?.ToString()?.ToUpper() ?? ""; + return vmKeys.Any(k => manufacturer.Contains(k) || model.Contains(k)); + } + } + catch { } + return false; + } +} +``` + +#### 4) 心跳机制 +```csharp +public class HeartbeatService : BackgroundService +{ + private readonly HttpClient _httpClient; + private readonly string _accessToken; + private readonly string _deviceId; + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stopping.IsCancellationRequested) + { + try + { + var response = await SendHeartbeat(stoppingToken); + if (!response.Valid) + { + // 卡密失效,退出软件 + MessageBox.Show("授权已失效,软件将退出"); + Environment.Exit(0); + } + } + catch + { + // 网络错误,允许短暂失败 + } + + await Task.Delay(60000, stoppingToken); // 60秒心跳 + } + } + + private async Task SendHeartbeat(CancellationToken ct) + { + var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + var signature = RequestSigner.Sign(_projectId, _deviceId, timestamp, _secret); + + var payload = new + { + accessToken = _accessToken, + deviceId = _deviceId, + timestamp, + signature + }; + + var response = await _httpClient.PostAsJsonAsync("/api/auth/heartbeat", payload, ct); + return await response.Content.ReadFromJsonAsync(ct); + } +} +``` + +### 5.3 服务端风控规则 + +**基础风控规则:** + +| 风控规则 | 触发条件 | 处理措施 | +|---------|---------|---------| +| 异地登录 | 同一卡密1小时内IP跨省/跨运营商 | 要求重新验证,发送告警 | +| 频繁请求 | 单IP 1分钟 > 100次请求 | 临时封禁5分钟 | +| 机器码变更 | 已绑定卡密请求新机器码 | 拒绝并记录,需管理员解绑 | +| 多机登录 | 同一卡密同时 > 最大设备数 | 踢出最早的设备 | +| 心跳超时 | 连续3分钟无心跳 | 标记离线,下次拒绝 | +| 批量注册 | 同一设备24小时内尝试 > 10个卡密 | 拉黑设备ID | + +**高级风控规则(推荐):** + +| 风控规则 | 触发条件 | 处理措施 | +|---------|---------|---------| +| 卡密爆破 | 同一IP 1小时内尝试 > 100个不同卡密 | 拉黑IP 24小时 | +| 自动化检测 | 请求间隔完全均匀(如每60.00秒) | 标记可疑,增加验证频率 | +| 设备指纹伪造 | 同一设备ID硬件参数变化 | 拒绝并告警 | +| 代理IP检测 | IP属于已知代理池/数据中心 | 增加验证难度(验证码) | +| 异常时段 | 凌晨2-6点批量操作 | 需要额外验证 | +| 卡密共享 | 同一卡密24小时内 > 5个不同IP登录 | 自动封禁,需申诉 | + +**风控规则实现示例:** + +```csharp +public class RiskControl +{ + private readonly IRedisCache _cache; + + public async Task CheckAsync(VerifyRequest request) + { + var rules = new List>> + { + () => CheckRateLimit(request.IpAddress), + () => CheckDeviceLimit(request.KeyCode, request.DeviceId), + () => CheckLocationChange(request.KeyCode, request.IpAddress), + () => CheckBruteForce(request.IpAddress), + }; + + foreach (var rule in rules) + { + var result = await rule(); + if (!result.Passed) return result; + } + + return RiskResult.Pass(); + } + + private async Task CheckRateLimit(string ip) + { + var key = $"ratelimit:ip:{ip}"; + var count = await _cache.IncrAsync(key); + if (count == 1) await _cache.ExpireAsync(key, TimeSpan.FromMinutes(1)); + + if (count > 100) + return RiskResult.Block("rate_limit_exceeded", "请求过于频繁,请稍后重试"); + + return RiskResult.Pass(); + } +} +``` + +### 5.4 已知攻击与应对 + +| 攻击手段 | 描述 | 应对措施 | +|---------|------|---------| +| 破解验证逻辑 | 修改跳过验证的 patch | 代码混淆 + 关键逻辑服务端验证 + 心跳机制 | +| 抓包重放 | 录制请求后重放 | 时间戳 + Nonce + 签名 | +| 注入 DLL | Hook 关键函数 | 完整性校验 + 反调试 | +| 虚拟机调试 | 在虚拟环境中分析 | 反虚拟机检测 | +| 中间人攻击 | 代理篡改响应 | 证书固定 + 响应签名验证 | +| 内存补丁 | 直接修改内存 | 关键数据多次读取 + 服务端校验 | +| 脱壳爆破 | 去除加壳保护 | 多层保护 + Native AOT 编译 | + +### 5.5 软件加密分发详细流程(方案2+方案3结合) + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 软件加密与分发完整流程 │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ 【服务端 - 软件上传与加密】 │ +│ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ 1. 管理员上传原始软件包 │ │ +│ │ POST /api/admin/projects/{id}/versions │ │ +│ │ file: software.exe │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ 2. 服务端加密处理 │ │ +│ │ │ │ +│ │ a) 生成随机 AES-256-GCM 密钥 │ │ +│ │ b) 用 AES 密钥加密软件文件 │ │ +│ │ c) 用客户端 RSA 公钥加密 AES 密钥 │ │ +│ │ d) 计算 SHA256 哈希 │ │ +│ │ e) 上传加密文件到 CDN/对象存储 │ │ +│ │ │ │ +│ │ 存储记录: │ │ +│ │ - SoftwareVersions 表: │ │ +│ │ FileUrl = CDN地址 │ │ +│ │ FileHash = SHA256值 │ │ +│ │ EncryptionKey = RSA加密后的AES密钥 │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +│ 【客户端 - 验证与下载】 │ +│ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ 3. 启动器卡密验证 │ │ +│ │ POST /api/auth/verify │ │ +│ │ │ │ +│ │ 返回数据: │ │ +│ │ { │ │ +│ │ "downloadUrl": "https://cdn.../v1.2.0.bin", │ │ +│ │ "fileHash": "sha256:xxxxx", │ │ +│ │ "version": "1.2.0", │ │ +│ │ "accessToken": "jwt_token" │ │ +│ │ } │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ 4. 下载加密软件包 │ │ +│ │ GET /api/software/download?token=xxx&version=1.2.0 │ │ +│ │ │ │ +│ │ 响应头: │ │ +│ │ X-File-Hash: sha256校验值 │ │ +│ │ X-File-Size: 文件大小 │ │ +│ │ X-Encryption-Key: RSA加密的AES密钥(Base64) │ │ +│ │ X-Encryption-Nonce: 随机 nonce │ │ +│ │ │ │ +│ │ Response: 二进制加密数据 │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ 5. 客户端解密 │ │ +│ │ │ │ +│ │ a) 用内置 RSA 私钥解密得到 AES 密钥 │ │ +│ │ b) 用 AES 密钥解密软件数据 │ │ +│ │ c) 验证 SHA256 哈希 │ │ +│ │ d) 保存到临时文件或内存加载 │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ 6. 启动主程序 │ │ +│ │ │ │ +│ │ Process.Start(tempFile) │ │ +│ │ 或 Assembly.Load(decryptedBytes) │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │ │ +│ │ 7. 主程序自验证(SDK,双重保险) │ │ +│ │ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │ │ +│ │ │ │ +│ │ - 向服务器发送自验证请求 │ │ +│ │ - 确认卡密仍有效 │ │ +│ │ - 启动心跳线程(每 30-60 秒) │ │ +│ │ - 心跳失败 → 自动退出 │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 5.6 服务端加密实现示例 + +```csharp +public class SoftwareEncryptionService +{ + private readonly IStorageService _storage; + private readonly RSA _clientRsa; // 客户端公钥 + + public async Task UploadAndEncryptAsync( + string projectId, IFormFile file, string version) + { + // 1. 生成随机 AES 密钥 + var aesKey = RandomNumberGenerator.GetBytes(32); + var nonce = RandomNumberGenerator.GetBytes(12); + + // 2. 读取并加密文件 + using var input = file.OpenReadStream(); + using var output = new MemoryStream(); + using var aes = new AesGcm(aesKey); + + var fileData = await.ReadAllBytesAsync(input); + var encryptedData = new byte[fileData.Length]; + var tag = new byte[16]; + + aes.Encrypt(nonce, fileData, 0, fileData.Length, + encryptedData, tag); + + // 3. 组合 nonce + tag + ciphertext + 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); + + // 4. 上传到存储 + var filename = $"{projectId}_{version}.bin"; + var url = await _storage.UploadAsync(filename, finalData); + + // 5. 用客户端 RSA 公钥加密 AES 密钥 + var encryptedKey = _clientRsa.Encrypt(aesKey, + RSAEncryptionPadding.OaepSHA256); + + // 6. 计算哈希 + var hash = SHA256.HashData(finalData); + + // 7. 保存到数据库 + return new SoftwareVersion + { + ProjectId = projectId, + Version = version, + FileUrl = url, + FileSize = fileData.Length, + FileHash = Convert.ToHexString(hash), + EncryptionKey = Convert.ToBase64String(encryptedKey), + PublishedAt = DateTime.UtcNow + }; + } +} +``` + +### 5.7 客户端解密实现示例 + +```csharp +public class SoftwareDownloader +{ + private readonly RSA _privateKey; // 内置的客户端私钥 + + public async Task DownloadAndDecryptAsync( + string downloadUrl, string encryptedKeyB64, + string expectedHash, string tempPath) + { + // 1. 下载加密文件 + using var client = new HttpClient(); + var encryptedData = await client.GetByteArrayAsync(downloadUrl); + + // 2. 解密 AES 密钥 + var encryptedKey = Convert.FromBase64String(encryptedKeyB64); + var aesKey = _privateKey.Decrypt(encryptedKey, + RSAEncryptionPadding.OaepSHA256); + + // 3. 解析数据:nonce(12) + tag(16) + ciphertext + var nonce = encryptedData[..12]; + var tag = encryptedData[12..28]; + var ciphertext = encryptedData[28..]; + + // 4. AES-GCM 解密 + using var aes = new AesGcm(aesKey); + var decryptedData = new byte[ciphertext.Length]; + + aes.Decrypt(nonce, ciphertext, tag, decryptedData); + + // 5. 验证哈希 + var actualHash = Convert.ToHexString(SHA256.HashData(encryptedData)); + if (!actualHash.Equals(expectedHash, StringComparison.OrdinalIgnoreCase)) + throw new Exception("文件完整性校验失败"); + + // 6. 写入临时文件 + var tempFile = Path.Combine(tempPath, + $"app_{Guid.NewGuid()}.exe"); + await File.WriteAllBytesAsync(tempFile, decryptedData); + + return tempFile; + } +} +``` + +### 5.8 防杀软误报处理 + +#### 报毒原因分析 + +| 类型 | 说明 | 示例 | +|------|------|------| +| **代码特征** | 特定代码序列与已知恶意软件相似 | 混淆器特征码 | +| **行为特征** | 可疑行为模式 | 注入、反调试、隐藏窗口 | +| **启发式** | AI 识别出可疑模式 | 加壳、加密、网络通信 | +| **声誉检测** | 文件签名未知/新文件 | 未签名的二进制 | + +#### 判断与处理流程 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 杀软误报处理流程 │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ Step 1: 检查报毒类型 │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ 上传到 VirusTotal (https://www.virustotal.com/) │ │ +│ │ 查看杀软检测情况和报毒类型 │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ┌────────────────┬────────────────┬──────────────┐ │ +│ ▼ ▼ ▼ │ │ +│ 【仅哈希检测】 【代码特征】 【行为特征】 │ │ +│ 重编译即可 需要改代码 需要改代码 │ │ +│ │ +│ Step 2: 尝试简单修复(仅哈希检测有效) │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ • 修改版本号 │ │ +│ │ • 重新编译 │ │ +│ │ • 更换时间戳 │ │ +│ │ • 调整编译选项 │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ Step 3: 代码调整(如简单修复无效) │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ 【去除触发特征】 │ │ +│ │ • 去掉或简化反调试代码 │ │ +│ │ • 去掉注入、Hook 等敏感操作 │ │ +│ │ • 减少混淆强度或更换混淆工具 │ │ +│ │ • 正常的窗口行为,不隐藏主窗口 │ │ +│ │ │ │ +│ │ 【加壳相关】 │ │ +│ │ • 更换加壳工具或参数 │ │ +│ │ • 尝试不加壳,仅用混淆 │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ Step 4: 申请白名单(长期方案) │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ • 购买代码签名证书($150-600/年) │ │ +│ │ - DigiCert: $400-600/年 │ │ +│ │ - Sectigo: $150-200/年 │ │ +│ │ - 国内CA: ¥500-1500/年 │ │ +│ │ • 向杀软厂商申请白名单 │ │ +│ │ • 提供软件说明和证明 │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +#### 快速判断表 + +| 现象 | 原因 | 解决方法 | +|------|------|---------| +| 仅 1-2 个杀软报毒,其他正常 | 哈希碰撞/误报 | 重编译即可 | +| 多个杀软报「Heur/Trojan」 | 启发式检测 | 去掉反调试/反虚拟机代码 | +| 多个杀软报「Packed」 | 加壳检测 | 更换加壳工具或去掉加壳 | +| 全部报毒 | 代码行为可疑 | 检查是否有注入/Hook等操作 | +| 报「Confuser」特征 | 混淆器特征 | 更换混淆工具或调整参数 | +| 报「Themida」特征 | 加壳工具特征 | 更换加壳工具或参数 | + +#### 预防措施(开发阶段) + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 开发阶段预防措施 │ +├─────────────────────────────────────────────────────────────┤ +│ ✅ 避免使用被黑的混淆器/加壳工具 │ +│ ✅ 减少反调试、反虚拟机检测(或设为可选) │ +│ ✅ 不要注入其他进程 │ +│ ✅ 正常的窗口行为,不隐藏主窗口 │ +│ ✅ 使用代码签名证书 │ +│ ✅ 定期在 VirusTotal 上测试 │ +└─────────────────────────────────────────────────────────────┘ +``` + +#### 总结 + +- 重编译可能解决问题(10-20% 情况) +- 大部分情况需要调整代码(去掉反调试等) +- 长期方案是购买代码签名证书 + +--- + +## 六、卡密生成算法 + +### 6.1 卡密格式 +``` +格式: XXXX-XXXX-XXXX-XXXX (4组,每组4字符) +示例: A3D7-K2P9-M8N1-Q4W6 + +编码方式: Base32(避免混淆字符) +排除字符: 0/O, 1/I/l +字符集: 23456789ABCDEFGHJKMNPQRSTUVWXYZ +``` + +### 6.2 生成逻辑 +```csharp +public static class CardKeyGenerator +{ + private static readonly string Base32Chars = "23456789ABCDEFGHJKMNPQRSTUVWXYZ"; + + public static string Generate(string projectId, CardType type, int durationDays) + { + // 1. 生成随机数据 + var randomBytes = new byte[12]; + RandomNumberGenerator.Fill(randomBytes); + + // 2. 添加类型和时长信息 + var payload = new byte[16]; + Array.Copy(randomBytes, 0, payload, 0, 12); + payload[12] = (byte)type; + Array.Copy(BitConverter.GetBytes(durationDays), 0, payload, 13, 2); + + // 3. 计算校验码 + var crc = Crc32.Compute(payload); + var checksum = BitConverter.GetBytes(crc).Take(2).ToArray(); + + // 4. Base32 编码 + var fullPayload = payload.Concat(checksum).ToArray(); + var encoded = Base32Encode(fullPayload); + + // 5. 格式化输出 + return FormatKey(encoded); + } + + public static bool Validate(string keyCode) + { + // 1. 格式校验 + if (!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; + + // 2. 校验码验证 + var raw = keyCode.Replace("-", ""); + var payload = Base32Decode(raw); + + var receivedCrc = BitConverter.ToUInt16(payload.AsSpan(^2)); + var computedCrc = Crc32.Compute(payload[..(^2)]); + + return receivedCrc == computedCrc; + } + + private static string FormatKey(string encoded) + { + var parts = new List(); + for (int i = 0; i < encoded.Length; i += 4) + { + parts.Add(encoded.Substring(i, Math.Min(4, encoded.Length - i))); + } + return string.Join("-", parts.Take(4)); + } +} +``` + +--- + +## 七、技术栈 + +### 7.1 后端 +| 技术 | 版本 | 用途 | +|------|------|------| +| ASP.NET Core | 8.0 | Web API 框架 | +| PostgreSQL | 16+ | 数据库 | +| Redis | 7+ | 缓存 + 分布式锁 | +| JWT | - | Token 生成 | +| Serilog | - | 日志记录 | +| Polly | - | 重试策略 | + +### 7.2 前端(管理后台) +| 技术 | 版本 | 用途 | +|------|------|------| +| Vue | 3.4+ | 前端框架 | +| Element Plus | Latest | UI 组件库 | +| Pinia | Latest | 状态管理 | +| Axios | Latest | HTTP 请求 | +| ECharts | 5+ | 数据可视化 | + +### 7.3 客户端 +| 技术 | 版本 | 用途 | +|------|------|------| +| .NET | 8.0 | 运行时 | +| WPF / WinUI 3 | - | UI 框架 | +| RestSharp | - | HTTP 客户端 | +| Microsoft.Extensions.Hosting | - | 后台服务(心跳) | + +### 7.4 安全工具 +| 工具 | 类型 | 用途 | +|------|------|------| +| ConfuserEx | 开源免费 | 代码混淆 | +| Obfuscar | 开源免费 | 另一种混淆选择 | +| Themida | 商业 $ | 强力加壳,反调试 | +| VMProtect | 商业 $ | 虚拟化保护 | +| dnSpy | 免费测试 | 反编译测试(验证防护效果) | + +--- + +## 八、部署方案(单服务器) + +> 本方案针对小型项目,采用单服务器 Docker Compose 部署,简单可靠。 + +### 8.1 单服务器架构 + +``` +┌─────────────────────────────────────────────────────────┐ +│ 单服务器 (4核8G) │ +│ │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ Nginx │ │ +│ │ (反向代理 + SSL) │ │ +│ │ :80 :443 │ │ +│ └───────────────────────┬─────────────────────────────┘ │ +│ │ │ +│ ┌───────────────────────┴─────────────────────────────┐ │ +│ │ Docker Compose │ │ +│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ +│ │ │ API │ │PostgreSQL│ │ Redis │ │ │ +│ │ │ :39256 │ │ :5432 │ │ :6379 │ │ │ +│ │ └──────────┘ └──────────┘ └──────────┘ │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ 本地文件存储 │ │ +│ │ /data/uploads (软件包) │ │ +│ │ /data/backups (数据库备份) │ │ +│ └─────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────┘ +``` + +### 8.2 服务器要求 + +| 配置 | 最低配置 | 推荐配置 | 说明 | +|------|---------|---------|------| +| CPU | 2核 | 4核 | 并发100以内2核足够 | +| 内存 | 4GB | 8GB | PostgreSQL需要较多内存 | +| 硬盘 | 40GB SSD | 100GB SSD | 预留日志和备份空间 | +| 带宽 | 5Mbps | 10Mbps | 软件下载需要带宽 | + +### 8.3 Docker Compose 完整配置 + +```yaml +# docker-compose.yml +version: '3.8' + +services: + api: + build: ./backend + 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 + - Jwt__Secret=${JWT_SECRET} + - Jwt__Issuer=license-system + - Jwt__ExpireMinutes=1440 + volumes: + - ./data/uploads:/app/uploads + - ./data/logs:/app/logs + ports: + - "127.0.0.1:39256:39256" + depends_on: + db: + condition: service_healthy + redis: + condition: service_started + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:39256/health/live"] + interval: 30s + timeout: 10s + retries: 3 + + 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 + - ./scripts/init.sql:/docker-entrypoint-initdb.d/init.sql + 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" + +volumes: + postgres_data: + redis_data: +``` + +### 8.4 环境变量配置 + +```bash +# .env 文件(不要提交到Git) +DB_PASSWORD=your_strong_password_here +JWT_SECRET=your_jwt_secret_at_least_32_chars +ADMIN_PASSWORD=initial_admin_password +``` + +### 8.5 Nginx 配置 + +```nginx +# /etc/nginx/sites-available/license.conf +server { + listen 80; + server_name api.yourdomain.com; + return 301 https://$server_name$request_uri; +} + +server { + listen 443 ssl http2; + server_name api.yourdomain.com; + + ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256; + ssl_prefer_server_ciphers off; + + # 安全头 + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + + # API代理 + location /api/ { + proxy_pass http://127.0.0.1:39256; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # 超时设置 + proxy_connect_timeout 60s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + } + + # 健康检查 + location /health/ { + proxy_pass http://127.0.0.1:39256; + access_log off; + } + + # 软件下载(大文件) + location /api/software/download { + proxy_pass http://127.0.0.1:39256; + proxy_read_timeout 300s; + proxy_buffering off; + } + + # 限流配置 + limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s; + location /api/auth/ { + limit_req zone=api burst=20 nodelay; + proxy_pass http://127.0.0.1:39256; + } +} +``` + +### 8.6 数据库备份脚本 + +```bash +#!/bin/bash +# /opt/license/scripts/backup.sh + +BACKUP_DIR="/opt/license/data/backups" +DATE=$(date +%Y%m%d_%H%M%S) +KEEP_DAYS=7 + +# 创建备份目录 +mkdir -p $BACKUP_DIR + +# 备份PostgreSQL +docker exec license-db pg_dump -U license license | gzip > $BACKUP_DIR/db_$DATE.sql.gz + +# 备份Redis +docker exec license-redis redis-cli BGSAVE +sleep 5 +cp /opt/license/data/redis/dump.rdb $BACKUP_DIR/redis_$DATE.rdb + +# 删除旧备份 +find $BACKUP_DIR -type f -mtime +$KEEP_DAYS -delete + +echo "Backup completed: $DATE" +``` + +添加定时任务: +```bash +# crontab -e +0 3 * * * /opt/license/scripts/backup.sh >> /var/log/license-backup.log 2>&1 +``` + +### 8.7 快速部署步骤 + +```bash +# 1. 安装Docker和Docker Compose +curl -fsSL https://get.docker.com | sh +apt install docker-compose-plugin + +# 2. 创建项目目录 +mkdir -p /opt/license/{data,scripts} +cd /opt/license + +# 3. 配置环境变量 +cp .env.example .env +nano .env # 修改密码 + +# 4. 启动服务 +docker compose up -d + +# 5. 检查状态 +docker compose ps +docker compose logs -f api + +# 6. 配置Nginx和SSL +apt install nginx certbot python3-certbot-nginx +certbot --nginx -d api.yourdomain.com + +# 7. 访问测试 +curl https://api.yourdomain.com/health/ready +``` + +--- + +## 九、实施步骤(小项目工期估算) + +> **团队配置**:2-3人(1后端 + 1前端 + 0.5客户端) +> **总工期**:8-10周(可根据功能优先级裁剪) + +### Phase 1: 基础架构(2周) + +**第1周:后端框架** +- [ ] 项目初始化(ASP.NET Core 8.0) +- [ ] 数据库表结构搭建 + 迁移脚本 +- [ ] 基础认证(JWT) +- [ ] 卡密生成/验证服务 + +**第2周:核心API** +- [ ] 项目管理 API +- [ ] 卡密管理 API(CRUD + 批量生成) +- [ ] 设备绑定 + 心跳验证 +- [ ] Docker 环境配置 + +### Phase 2: 管理后台(2-3周) + +**第3周:基础页面** +- [ ] Vue3 项目初始化 + 路由 +- [ ] 登录/权限模块 +- [ ] 项目管理页面 +- [ ] 卡密列表 + 生成页面 + +**第4周:功能完善** +- [ ] 卡密详情 + 操作(封禁/解绑等) +- [ ] 代理商管理(可选,如不需要可跳过) +- [ ] 系统设置页面 + +**第5周(可选):增强功能** +- [ ] 统计报表(ECharts) +- [ ] 日志审计 +- [ ] 批量导入/导出 + +### Phase 3: 客户端启动器(1-2周) + +**第6周:基础功能** +- [ ] WPF 界面(登录 + 主界面) +- [ ] 卡密验证逻辑 +- [ ] 机器码生成 +- [ ] 软件下载与启动 + +**第7周(可选):增强** +- [ ] 自动更新检测 +- [ ] 心跳服务 +- [ ] 离线缓存 + +### Phase 4: 安全与测试(2周) + +**第8周:安全加固** +- [ ] 请求签名验证 +- [ ] 风控规则(限流 + 异常检测) +- [ ] ConfuserEx 混淆配置 +- [ ] 基础反调试 + +**第9周:测试与部署** +- [ ] 功能测试(手动 + 自动化) +- [ ] 压力测试(JMeter/K6) +- [ ] 生产环境部署 +- [ ] 监控配置 + +### 工期对照表 + +| 功能模块 | MVP版本 | 完整版本 | 备注 | +|---------|---------|---------|------| +| 后端API | 2周 | 3周 | 核心必须 | +| 管理后台 | 2周 | 3周 | 可先做基础版 | +| 客户端 | 1周 | 2周 | 可后期迭代 | +| 安全加固 | 1周 | 2周 | 服务端优先 | +| 测试部署 | 1周 | 1周 | 不可省略 | +| **总计** | **7周** | **11周** | - | + +### MVP优先级建议 + +**必须有(MVP)**: +1. 卡密生成/验证/绑定 +2. 基础管理后台(项目+卡密) +3. 简单客户端(登录+启动) +4. 基础安全(签名+限流) + +**可后期迭代**: +1. 代理商系统 +2. 统计报表 +3. 自动更新 +4. 高级风控 + +--- + +## 十、风险与局限 + +### 10.1 防护的现实边界 + +**必须承认:** +> 没有绝对安全的客户端防护。任何运行在用户机器上的代码,理论上都可以被破解。 + +**合理目标:** +- 提高破解成本,让破解难度 > 软件价值 +- 防止大规模盗版(允许个别破解案例存在) +- 及时发现异常行为并封禁 +- 通过服务端校验,让破解者需要持续维护 + +### 10.2 安全建议 + +1. **持续更新** - 频繁更新客户端,让破解者跟不上 +2. **服务端校验** - 核心逻辑放服务端,客户端只做展示 +3. **在线验证** - 必须联网才能使用(牺牲体验换安全) +4. **法律手段** - 用户协议明确禁止逆向,配合法律追责 + +### 10.3 成本对比 + +| 方案 | 成本 | 安全等级 | 建议 | +|------|------|----------|------| +| 无保护 | ¥0 | ⭐ | 不推荐 | +| 开源混淆 | ¥0 | ⭐⭐ | 个人项目 | +| 混淆+反调试 | ¥0 | ⭐⭐⭐ | 中小型项目 | +| 商业加壳 | $100-500/年 | ⭐⭐⭐⭐ | 商业项目 | +| 硬件狗 | $20+/个 | ⭐⭐⭐⭐⭐ | 高价值软件 | + +--- + +## 十一、后续扩展 + +1. **多租户支持** - 允许第三方开发者注册使用 +2. **代理商系统** - 支持多级代理分销 +3. **卡密续费** - 到期后续费延长 +4. **使用时长限制** - 按小时/天数计费 +5. **移动端** - Android/iOS 授权验证 +6. **Web 版** - 支持浏览器内使用(WebAssembly) +7. **数据分析** - 用户行为分析、转化漏斗 + +--- + +## 十二、管理后台页面设计 + +### 12.1 页面结构 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 管理后台 │ +├─────────────────────────────────────────────────────────────────┤ +│ ┌─────────┐ │ +│ │ Logo │ 软件授权管理系统 管理员 ▼ │ +│ └─────────┘ │ +├──┬──────────────────────────────────────────────────────────────┤ +│ │ │ +│ │ 📊 仪表盘 📁 项目 🔑 卡密 👥 代理 🖥️ 设备 │ +│ │ │ +│ ├──────────────────────────────────────────────────────────────┤ +│ │ │ +│ │ 【页面内容区】 │ +│ │ │ +│ │ │ +│ │ │ +│ │ │ +│ └──────────────────────────────────────────────────────────────┘ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 12.2 关键页面原型 + +#### 12.2.1 仪表盘页面 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 📊 仪表盘 [刷新] │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ 总卡密数 │ │ 活跃卡密 │ │ 在线设备 │ │ +│ │ 10,234 │ │ 6,789 │ │ 4,521 │ │ +│ └─────────────┘ └─────────────┘ └─────────────┘ │ +│ │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ 今日收入 │ │ 本月收入 │ │ 今日新增 │ │ +│ │ ¥3,280 │ │ ¥89,500 │ │ 156 │ │ +│ └─────────────┘ └─────────────┘ └─────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ 活跃用户趋势 │ │ +│ │ ▁▂▃▅▆▇█▇▆▅▃▂▁ (ECharts 折线图) │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌────────────────────┐ ┌──────────────────────────────────┐ │ +│ │ 项目分布 │ │ 最近活动 │ │ +│ │ (饼图) │ │ • 代理商A生成100张卡密 10:00 │ │ +│ │ │ │ • 用户B激活卡密 09:58 │ │ +│ │ │ │ • 代理商C充值¥5000 09:30 │ │ +│ └────────────────────┘ └──────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +#### 12.2.2 卡密生成页面 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 🔑 批量生成卡密 │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ 项目: [下拉选择 ▼] (必填) │ +│ 卡密类型: ○ 天卡 ○ 周卡 ● 月卡 ○ 年卡 ○ 永久 │ +│ 有效期: [30] 天 │ +│ 生成数量: [100] (1-10000) │ +│ 备注: [_______________________________] │ +│ │ +│ 预计消耗额度:¥300.00 (当前余额:¥5,000.00) │ +│ │ +│ [取消] [生成卡密] │ +│ │ +└─────────────────────────────────────────────────────────────────┘ + │ 生成后 ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ ✓ 生成成功! │ +├─────────────────────────────────────────────────────────────────┤ +│ 已生成 100 张卡密 │ +│ │ +│ 批次ID:batch_20251228_001 │ +│ 项目:我的软件 (PROJ_001) │ +│ 类型:月卡 (30天) │ +│ │ +│ [复制全部] [导出TXT] [导出CSV] [导出Excel] [关闭] │ +│ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ A3D7-K2P9-M8N1-Q4W6 │ │ +│ │ B4E8-L3X0-N9O2-P5Y7 │ │ +│ │ C5F9-M4Y1-P0Q3-R6T8 │ │ +│ │ ... (共100条) │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +#### 12.2.3 卡密列表页面 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 🔑 卡密管理 │ +├─────────────────────────────────────────────────────────────────┤ +│ 项目: [全部 ▼] 状态: [全部 ▼] 类型: [全部 ▼] │ +│ 搜索: [__________________] [搜索] │ +│ │ +│ ☑ 批量操作: [封禁] [解封] [删除] [导出] │ +│ │ +│ ┌──┬────────────┬────┬──────┬────────┬────────┬──────┬────┐ │ +│ │☑│ 卡密 │类型│ 状态 │ 激活时间│ 过期时间│ 备注 │操作│ │ +│ ├──┼────────────┼────┼──────┼────────┼────────┼──────┼────┤ │ +│ │☑│A3D7-K2P9.. │月卡│ ●活跃│12-01 │12-31 │ │详情│ │ +│ │☑│B4E8-L3X0.. │月卡│ ○未用│ - │ - │ │编辑│ │ +│ │☑│C5F9-M4Y1.. │月卡│ ✗封禁│12-01 │12-31 │违规 │解封│ │ +│ │☑│... │... │ ... │ ... │ ... │ ... │... │ │ +│ └──┴────────────┴────┴──────┴────────┴────────┴──────┴────┘ │ +│ │ +│ 共 1000 条 [< 1 2 3 4 5 ... 20 >] 每页 [20]▼ 条 │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +#### 12.2.4 卡密详情页面 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 🔑 卡密详情 [返回列表] │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ 基本信息 │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ 卡密: A3D7-K2P9-M8N1-Q4W6 │ │ +│ │ 项目: 我的项目 (PROJ_001) │ │ +│ │ 类型: 月卡 (30天) │ │ +│ │ 状态: ● 活跃 │ │ +│ │ 激活时间: 2025-12-01 10:30:00 │ │ +│ │ 过期时间: 2025-12-31 23:59:59 (剩余 3 天) │ │ +│ │ 备注: 批次20251228 │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +│ 设备信息 │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ 设备ID: abc123def456... │ │ +│ │ 设备名: DESKTOP-ABC123 │ │ +│ │ 最后心跳: 2025-12-28 10:30:00 (● 在线) │ │ +│ │ 登录IP: 1.2.3.4 (中国-广东-深圳) │ │ +│ │ 首次登录: 2025-12-01 10:30:00 │ │ +│ │ [强制下线] [解绑设备] │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +│ 操作日志 │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ 2025-12-28 10:30 心跳验证 系统 1.2.3.4 │ │ +│ │ 2025-12-01 10:30 激活卡密 用户 1.2.3.4 │ │ +│ │ 2025-11-28 00:00 生成卡密 管理员 admin │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +│ [封禁卡密] [延长30天] [修改备注] [删除] │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +#### 12.2.5 项目管理页面 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 📁 项目管理 [+ 新建项目] │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ 我的项目 v1.0 [编辑][删除] │ │ +│ │ PROJ_001 | 活跃卡密: 1234 | 总卡密: 5000 │ │ +│ │ -------------------------------------------------------- │ │ +│ │ ProjectKey: abcd1234efgh5678 │ │ +│ │ ProjectSecret: **** [显示/复制] │ │ +│ │ 软件版本: v1.0.0 │ │ +│ │ 软件地址: https://cdn.example.com/app.zip │ │ +│ │ 最大设备数: 1 │ │ +│ │ │ │ +│ │ [查看统计] [管理卡密] [开发文档] [上传新版本] │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ 另一个项目 v2.0 [编辑][删除] │ │ +│ │ ... │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +#### 12.2.6 代理商管理页面 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 👥 代理商管理 [+ 新建代理] │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ 某某科技公司 (AGENT001) [编辑][删除] │ │ +│ │ -------------------------------------------------------- │ │ +│ │ 联系人:张三 | 电话:13800138000 | 邮箱:... │ │ +│ │ 余额:¥5,000.00 | 折扣:80% | 状态:● 活跃 │ │ +│ │ -------------------------------------------------------- │ │ +│ │ 统计:总卡密 1000 | 活跃 800 | 收入 ¥50,000 │ │ +│ │ │ │ +│ │ [充值] [查看明细] [销售统计] [额度流水] │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +#### 12.2.7 开发文档页面 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 📖 开发文档 - 我的项目 [编辑文档] │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ 目录 │ +│ ├── 1. 快速开始 │ +│ ├── 2. 项目配置 │ +│ ├── 3. SDK 集成 │ +│ ├── 4. API 接口 │ +│ ├── 5. 代码示例 │ +│ └── 6. 常见问题 │ +│ │ +│ ──────────────────────────────────────────────────────────── │ +│ │ +│ 1. 快速开始 │ +│ │ +│ 本文档将指导您如何在软件中集成授权验证功能。 │ +│ │ +│ 2. 项目配置 │ +│ │ +│ 您的项目信息如下: │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ ProjectId: PROJ_001 │ │ +│ │ ProjectKey: abcd1234efgh5678 [复制] │ │ +│ │ ProjectSecret: xyz999aaa888bbb777 [复制] │ │ +│ │ API地址: https://api.example.com │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +│ ⚠️ 重要:ProjectSecret 仅在此显示一次,请妥善保管! │ +│ │ +│ 3. SDK 集成 │ +│ │ +│ 步骤1:安装 NuGet 包 │ +│ ```bash │ +│ Install-Package YourCompany.License.SDK │ +│ ``` │ +│ │ +│ 步骤2:在 Program.cs 中初始化 │ +│ ```csharp │ +│ using YourCompany.License.SDK; │ +│ │ │ +│ var licenseClient = new LicenseClient(new LicenseConfig { │ +│ ProjectId = "PROJ_001", │ +│ ProjectKey = "abcd1234efgh5678", │ +│ ProjectSecret = "xyz999aaa888bbb777", │ +│ ApiEndpoint = "https://api.example.com" │ +│ }); │ +│ │ │ +│ var result = await licenseClient.VerifyAsync(cardKey); │ +│ if (!result.IsValid) { │ +│ MessageBox.Show("卡密验证失败:" + result.Message); │ +│ Application.Current.Shutdown(); │ +│ } │ +│ ``` │ +│ │ +│ [继续阅读下一页 →] │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 12.3 权限控制 + +| 角色 | 仪表盘 | 项目 | 卡密 | 代理 | 设备 | 日志 | 设置 | +|------|:-----:|:----:|:----:|:----:|:----:|:----:|:----:| +| 超级管理员 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| 管理员 | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ❌ | +| 代理商 | ✅ | 限 | 限 | ❌ | ✅ | 限 | ❌ | + +### 12.4 超管/管理员后台 vs 代理商后台 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 超管/管理员后台菜单 │ +├─────────────────────────────────────────────────────────────────┤ +│ 📊 仪表盘 ✅ ──► 完整统计 │ +│ 📁 项目管理 ✅ ──► 全部项目 │ +│ 🔑 卡密管理 ✅ ──► 完整CRUD + 批量操作 │ +│ 👥 代理商 ✅ ──► 创建/管理代理商(仅超管) │ +│ 🖥️ 设备管理 ✅ ──► 所有设备 │ +│ 📝 日志审计 ✅ ──► 完整日志 │ +│ ⚙️ 系统设置 ✅ ──► 系统配置 │ +└─────────────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────────────┐ +│ 代理商后台菜单 │ +├─────────────────────────────────────────────────────────────────┤ +│ 📊 仪表盘 ✅ ──► 仅显示自己的数据 │ +│ 📁 项目管理 限 ──► 仅显示授权的项目 │ +│ 🔑 卡密管理 限 ──► 仅能生成/管理自己生成的卡密 │ +│ 👥 代理商 ❌ ──► 不可见 │ +│ 🖥️ 设备管理 限 ──► 仅自己卡密的设备 │ +│ 📝 日志审计 限 ──► 仅自己的操作日志 │ +│ ⚙️ 系统设置 ❌ ──► 不可见 │ +│ 💰 额度管理 ✅ ──► 查看余额、充值记录(代理商专属) │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 12.5 代理商后台页面 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 💰 额度中心 余额: ¥5,000 │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ +│ │ 当前余额 │ │ 本月消耗 │ │ 充值记录 │ │ +│ │ ¥5,000.00 │ │ ¥3,000.00 │ │ 15 次 │ │ +│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │ +│ │ +│ ──────────────────────────────────────────────────────────── │ +│ │ +│ 💡 我的折扣:80%(8折) │ +│ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ 项目价格(折扣后) │ │ +│ │ │ │ +│ │ 项目A - 我的项目 │ │ +│ │ 天卡 ¥0.80 | 周卡 ¥4.00 | 月卡 ¥12.00 │ │ +│ │ 年卡 ¥80.00 | 永久卡 ¥239.20 │ │ +│ │ │ │ +│ │ 项目B - 另一个项目 │ │ +│ │ 天卡 ¥1.00 | 周卡 ¥5.00 | 月卡 ¥15.00 │ │ +│ │ │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +│ ──────────────────────────────────────────────────────────── │ +│ │ +│ 📊 充值/消费流水 │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ 2025-12-28 10:30 充值 +¥1000.00 余额 ¥5000.00 │ │ +│ │ 2025-12-27 15:20 消费 -¥15.00 余额 ¥4000.00 │ │ +│ │ 2025-12-25 09:00 充值 +¥5000.00 余额 ¥4015.00 │ │ +│ │ ... │ │ +│ │ │ │ +│ │ [查看更多记录 →] │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 十三、系统设置(前端配置驱动) + +### 13.1 设置页面设计 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ ⚙️ 系统设置 [保存更改] │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ 🔧 功能开关 │ │ +│ │ │ │ +│ │ ┌─────────────────────────────────────────────────────┐ │ │ +│ │ │ [✓] 启用心跳验证 说明:客户端需定期验证 │ │ │ +│ │ │ [✓] 启用设备绑定 说明:单卡密绑定单设备 │ │ │ +│ │ │ [✓] 启用自动更新 说明:客户端自动检测更新 │ │ │ +│ │ │ [ ] 启用强制更新 说明:旧版本强制升级 │ │ │ +│ │ │ [✓] 启用代理商系统 说明:允许代理商销售 │ │ │ +│ │ │ [✓] 启用卡密续费 说明:用户可续费延长 │ │ │ +│ │ │ [ ] 启用试用模式 说明:未激活可试用X天 │ │ │ +│ │ └─────────────────────────────────────────────────────┘ │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ 🔐 验证规则 │ │ +│ │ │ │ +│ │ 单卡密最大设备数: [1] (1-10) │ │ +│ │ [ ] 允许多设备同时在线 │ │ +│ │ [✓] 卡密需要激活 │ │ +│ │ 过期类型: (•)激活后计算 ( )固定时间 │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ 💓 心跳配置 │ │ +│ │ │ │ +│ │ [✓] 启用心跳验证 │ │ +│ │ 心跳间隔: [60] 秒 (10-300) │ │ +│ │ 心跳超时: [180] 秒 (30-600) │ │ +│ │ 掉线后行为: (•)退出程序 ( )警告提示 ( )忽略 │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ 🛡️ 限流配置 │ │ +│ │ │ │ +│ │ [✓] 启用请求限流 │ │ +│ │ IP每分钟限制: [100] 次 │ │ +│ │ 设备每分钟限制: [50] 次 │ │ +│ │ 封禁时长: [5] 分钟 │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ ⚠️ 风控配置 │ │ +│ │ │ │ +│ │ [✓] 启用风控检测 │ │ +│ │ [✓] 异地登录检测 │ │ +│ │ [✓] 设备变更检测 │ │ +│ │ [ ] 自动封禁异常 │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ 📢 客户端显示 │ │ +│ │ │ │ +│ │ 系统名称: [软件授权管理系统 ] │ │ +│ │ Logo地址: [https://... ] │ │ +│ │ │ │ +│ │ 公告标题: [系统维护通知 ] │ │ +│ │ 公告内容: [将于今晚23:00进行维护... ] │ │ +│ │ [✓] 显示公告 │ │ +│ │ │ │ +│ │ 联系方式URL: [https://support.example.com] │ │ +│ │ 帮助文档URL: [https://docs.example.com ] │ │ +│ │ [ ] 在客户端显示余额 │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ 📊 其他配置 │ │ +│ │ │ │ +│ │ [ ] 开放用户注册 │ │ +│ │ 日志保留天数: [90] 天 │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 13.2 配置 API + +``` +# 获取所有配置(分组) +GET /api/admin/configs +Response: { + "code": 200, + "data": { + "feature": [ + { "key": "heartbeat", "value": "true", "displayName": "启用心跳验证" }, + { "key": "device_bind", "value": "true", "displayName": "启用设备绑定" }, + ... + ], + "auth": [ + { "key": "max_devices", "value": "1", "displayName": "最大设备数" }, + ... + ], + ... + } +} + +# 批量更新配置 +PUT /api/admin/configs +Request: { + "feature.heartbeat": "false", + "auth.max_devices": "2", + "heartbeat.interval": "90" +} +Response: { "code": 200, "message": "配置已更新" } + +# 获取客户端可读的公开配置 +GET /api/client/config +Response: { + "code": 200, + "data": { + "heartbeat": true, + "autoUpdate": true, + "noticeTitle": "系统维护通知", + "noticeContent": "将于今晚23:00进行维护...", + "helpUrl": "https://docs.example.com" + } +} +``` + +### 13.3 配置读取服务(后端) + +```csharp +// 配置服务 - 从数据库读取,无硬编码 +public class ConfigService +{ + private readonly IMemoryCache _cache; + private readonly AppDbContext _db; + + public async Task GetAsync(string key, string defaultValue = "") + { + // 先从缓存读取 + var cached = await _cache.GetAsync($"config:{key}"); + if (cached != null) return cached; + + // 从数据库读取 + var config = await _db.SystemConfigs + .Where(c => c.ConfigKey == key) + .FirstOrDefaultAsync(); + + var value = config?.ConfigValue ?? defaultValue; + + // 缓存5分钟 + await _cache.SetAsync($"config:{key}", value, TimeSpan.FromMinutes(5)); + return value; + } + + public async Task IsEnabledAsync(string featureKey) + { + var value = await GetAsync($"feature.{featureKey}", "false"); + return value.Equals("true", StringComparison.OrdinalIgnoreCase); + } + + public async Task GetIntAsync(string key, int defaultValue = 0) + { + var value = await GetAsync(key, defaultValue.ToString()); + return int.TryParse(value, out var result) ? result : defaultValue; + } +} + +// 使用示例 +public class AuthService +{ + private readonly ConfigService _config; + + public async Task VerifyAsync(string cardKey) + { + // 检查功能开关 - 是否需要设备绑定 + var needBind = await _config.IsEnabledAsync("device_bind"); + if (needBind) + { + // 执行设备绑定验证... + } + + // 检查功能开关 - 是否启用心跳 + var enableHeartbeat = await _config.IsEnabledAsync("heartbeat"); + if (enableHeartbeat) + { + // 返回心跳配置给客户端... + } + + return true; + } +} +``` + +### 13.4 功能开关说明 + +| 开关 | 关闭后行为 | 使用场景 | +|------|-----------|---------| +| `feature.heartbeat` | 客户端不需要发送心跳 | 离线环境、调试 | +| `feature.device_bind` | 卡密可在任意设备使用 | 临时测试、多设备需求 | +| `feature.auto_update` | 客户端不检查更新 | 固定版本部署 | +| `feature.agent_system` | 隐藏代理商相关菜单 | 无代理商场景 | +| `feature.card_renewal` | 不显示续费功能 | 一次性买断 | +| `feature.trial_mode` | 允许未激活卡密试用X天 | 营销推广 | + +--- + +## 十四、SDK 集成开发文档(管理后台内置) + +### 14.1 快速开始 + +#### 步骤 1:获取项目信息 + +在管理后台创建项目后,会获得以下信息: + +```json +{ + "projectId": "PROJ_001", + "projectKey": "abcd1234efgh5678", + "projectSecret": "xyz999aaa888bbb777", + "apiEndpoint": "https://api.example.com" +} +``` + +#### 步骤 2:安装 SDK + +```bash +# NuGet 包 +Install-Package YourCompany.License.Sdk + +# 或 .NET CLI +dotnet add package YourCompany.License.Sdk +``` + +### 14.2 基础集成代码 + +```csharp +using YourCompany.License.Sdk; + +// 1. 初始化客户端 +var licenseClient = new LicenseClient(new LicenseConfig +{ + ProjectId = "PROJ_001", + ProjectKey = "abcd1234efgh5678", + ProjectSecret = "xyz999aaa888bbb777", + ApiEndpoint = "https://api.example.com" +}); + +// 2. 验证卡密(在软件启动时调用) +var result = await licenseClient.VerifyAsync(userInputCardKey); + +if (!result.IsValid) +{ + MessageBox.Show($"验证失败:{result.Message}"); + Application.Current.Shutdown(); + return; +} + +// 3. 启动心跳(后台服务) +await licenseClient.StartHeartbeatAsync(result.AccessToken); + +// 4. 软件正常运行... +``` + +### 14.3 WPF 应用完整示例 + +```csharp +// App.xaml.cs +public partial class App : Application +{ + private LicenseClient _licenseClient; + private ILicenseInfo _licenseInfo; + + protected override async void OnStartup(StartupEventArgs e) + { + base.OnStartup(e); + + // 显示登录窗口 + var loginWindow = new LoginWindow(); + if (loginWindow.ShowDialog() != true) + { + Shutdown(); + return; + } + + var cardKey = loginWindow.CardKey; + + // 初始化并验证 + _licenseClient = new LicenseClient(new LicenseConfig + { + ProjectId = "PROJ_001", + ProjectKey = "abcd1234efgh5678", + ProjectSecret = "xyz999aaa888bbb777", + ApiEndpoint = "https://api.example.com" + }); + + var result = await _licenseClient.VerifyAsync(cardKey); + + if (!result.IsValid) + { + MessageBox.Show($"授权验证失败:{result.Message}", "错误", + MessageBoxButton.OK, MessageBoxImage.Error); + Shutdown(); + return; + } + + _licenseInfo = result; + + // 启动心跳 + _licenseClient.LicenseExpired += (s, e) => + { + Dispatcher.Invoke(() => + { + MessageBox.Show("授权已过期,软件将退出", "提示", + MessageBoxButton.OK, MessageBoxImage.Warning); + Shutdown(); + }); + }; + + await _licenseClient.StartHeartbeatAsync(result.AccessToken); + + // 显示主窗口 + var mainWindow = new MainWindow(); + mainWindow.Closed += async (s, args) => + { + await _licenseClient.StopHeartbeatAsync(); + }; + mainWindow.Show(); + } +} +``` + +### 14.4 控制台应用示例 + +```csharp +class Program +{ + static async Task Main(string[] args) + { + Console.Write("请输入卡密:"); + var cardKey = Console.ReadLine(); + + var licenseClient = new LicenseClient(new LicenseConfig + { + ProjectId = "PROJ_001", + ProjectKey = "abcd1234efgh5678", + ProjectSecret = "xyz999aaa888bbb777", + ApiEndpoint = "https://api.example.com" + }); + + var result = await licenseClient.VerifyAsync(cardKey); + + if (!result.IsValid) + { + Console.WriteLine($"验证失败:{result.Message}"); + return; + } + + Console.WriteLine($"验证成功!到期时间:{result.ExpireTime}"); + Console.WriteLine($"剩余天数:{result.RemainingDays}"); + + // 使用 CancellationToken 处理授权失效 + var cts = new CancellationTokenSource(); + licenseClient.LicenseExpired += (s, e) => + { + Console.WriteLine("授权已失效,程序将退出..."); + cts.Cancel(); + }; + + await licenseClient.StartHeartbeatAsync(result.AccessToken); + + // 主程序逻辑 + while (!cts.Token.IsCancellationRequested) + { + // 你的业务代码 + await Task.Delay(1000); + } + } +} +``` + +### 14.5 API 接口说明 + +#### 验证接口 + +```http +POST /api/auth/verify +Content-Type: application/json + +{ + "projectId": "PROJ_001", + "keyCode": "A3D7-K2P9-M8N1-Q4W6", + "deviceId": "硬件指纹", + "timestamp": 1735000000, + "signature": "HMAC-SHA256签名" +} +``` + +#### 心跳接口 + +```http +POST /api/auth/heartbeat +Authorization: Bearer {access_token} +Content-Type: application/json + +{ + "accessToken": "jwt_token", + "deviceId": "硬件指纹" +} +``` + +### 14.6 常见问题 + +**Q:如何获取机器码(DeviceId)?** +A:SDK 会自动生成,无需手动获取。如需自定义: + +```csharp +var deviceId = MachineCode.GetDeviceId(); +``` + +**Q:心跳失败会怎样?** +A:连续 3 次心跳失败(约 3 分钟),会触发 `LicenseExpired` 事件。 + +**Q:如何处理网络断开?** +A:SDK 内置重试机制,短暂网络波动不会影响使用。 + +**Q:可以离线使用吗?** +A:不可以,必须联网验证。这是安全保障的一部分。 + +--- + +## 十五、运维指南 + +### 15.1 环境变量说明 + +| 变量名 | 必填 | 默认值 | 说明 | +|--------|------|--------|------| +| `DB_PASSWORD` | ✅ | - | PostgreSQL密码 | +| `JWT_SECRET` | ✅ | - | JWT签名密钥(≥32字符) | +| `ADMIN_PASSWORD` | ❌ | admin123 | 初始管理员密码 | +| `ASPNETCORE_ENVIRONMENT` | ❌ | Production | 运行环境 | +| `Redis__ConnectionString` | ❌ | redis:6379 | Redis连接字符串 | +| `Jwt__ExpireMinutes` | ❌ | 1440 | Token过期时间(分钟) | +| `RateLimit__IpPerMinute` | ❌ | 100 | IP每分钟请求限制 | + +### 15.2 日志管理 + +**日志位置:** +``` +/opt/license/data/logs/ +├── app-20251230.log # 应用日志(按天滚动) +├── access-20251230.log # 访问日志 +└── error-20251230.log # 错误日志 +``` + +**查看实时日志:** +```bash +# Docker日志 +docker compose logs -f api + +# 应用日志 +tail -f /opt/license/data/logs/app-$(date +%Y%m%d).log +``` + +**日志清理(自动):** +```bash +# 添加到crontab,保留30天日志 +0 4 * * * find /opt/license/data/logs -name "*.log" -mtime +30 -delete +``` + +### 15.3 备份与恢复 + +**手动备份:** +```bash +# 备份数据库 +docker exec license-db pg_dump -U license license > backup_$(date +%Y%m%d).sql + +# 备份完整数据目录 +tar -czvf license_backup_$(date +%Y%m%d).tar.gz /opt/license/data/ +``` + +**恢复数据库:** +```bash +# 停止API服务 +docker compose stop api + +# 恢复数据库 +cat backup_20251230.sql | docker exec -i license-db psql -U license license + +# 启动API服务 +docker compose start api +``` + +**恢复完整数据:** +```bash +docker compose down +tar -xzvf license_backup_20251230.tar.gz -C / +docker compose up -d +``` + +### 15.4 常见问题排查 + +#### 问题1:API无法访问 + +```bash +# 检查容器状态 +docker compose ps + +# 查看API日志 +docker compose logs api --tail 100 + +# 检查端口 +netstat -tlnp | grep 39256 + +# 检查健康状态 +curl http://127.0.0.1:39256/health/ready +``` + +#### 问题2:数据库连接失败 + +```bash +# 检查PostgreSQL状态 +docker exec license-db pg_isready -U license + +# 查看数据库日志 +docker compose logs db --tail 50 + +# 测试连接 +docker exec -it license-db psql -U license -c "SELECT 1;" +``` + +#### 问题3:Redis连接失败 + +```bash +# 检查Redis状态 +docker exec license-redis redis-cli ping + +# 查看内存使用 +docker exec license-redis redis-cli info memory +``` + +#### 问题4:卡密验证失败 + +```bash +# 查看验证日志 +docker compose logs api 2>&1 | grep -i "verify" + +# 检查卡密状态 +docker exec license-db psql -U license -c "SELECT * FROM CardKeys WHERE KeyCode = 'XXXX-XXXX-XXXX-XXXX';" + +# 检查设备绑定 +docker exec license-db psql -U license -c "SELECT * FROM Devices WHERE CardKeyId = 123;" +``` + +### 15.5 性能监控 + +**基础监控脚本:** +```bash +#!/bin/bash +# /opt/license/scripts/monitor.sh + +echo "=== System Status ===" +echo "CPU: $(top -bn1 | grep 'Cpu(s)' | awk '{print $2}')%" +echo "Memory: $(free -m | awk 'NR==2{printf "%s/%sMB (%.2f%%)", $3,$2,$3*100/$2 }')" +echo "Disk: $(df -h / | awk 'NR==2{print $5}')" + +echo "" +echo "=== Docker Status ===" +docker compose ps + +echo "" +echo "=== API Health ===" +curl -s http://127.0.0.1:39256/health/ready | jq . + +echo "" +echo "=== Recent Errors ===" +tail -5 /opt/license/data/logs/error-$(date +%Y%m%d).log 2>/dev/null || echo "No errors today" +``` + +**添加监控告警(简单版):** +```bash +#!/bin/bash +# /opt/license/scripts/alert.sh + +# 检查API健康状态 +if ! curl -sf http://127.0.0.1:39256/health/live > /dev/null; then + # 发送告警(可替换为钉钉/邮件通知) + echo "[ALERT] License API is down at $(date)" >> /var/log/license-alert.log + # 尝试重启 + docker compose restart api +fi +``` + +```bash +# 每分钟检查一次 +* * * * * /opt/license/scripts/alert.sh +``` + +### 15.6 版本升级流程 + +```bash +# 1. 备份当前数据 +/opt/license/scripts/backup.sh + +# 2. 拉取新版本代码 +cd /opt/license +git pull origin main + +# 3. 重新构建镜像 +docker compose build api + +# 4. 滚动更新(无停机) +docker compose up -d --no-deps api + +# 5. 检查新版本状态 +docker compose logs -f api + +# 6. 如有问题,回滚 +docker compose down +docker compose up -d --build api # 使用上一个镜像 +``` + +### 15.7 安全检查清单 + +**定期检查(每周):** +- [ ] 检查磁盘空间 `df -h` +- [ ] 检查备份是否正常执行 +- [ ] 检查异常登录日志 +- [ ] 更新系统安全补丁 + +**定期检查(每月):** +- [ ] 检查SSL证书有效期 `certbot certificates` +- [ ] 检查数据库表膨胀 `VACUUM ANALYZE` +- [ ] 清理过期卡密和日志 +- [ ] 审计管理员操作记录