Initial commit
This commit is contained in:
28
.gitignore
vendored
Normal file
28
.gitignore
vendored
Normal file
@@ -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
|
||||||
10
license-system-backend/.env.example
Normal file
10
license-system-backend/.env.example
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
DB_PASSWORD=your_strong_password_here
|
||||||
|
JWT_SECRET=your_jwt_secret_at_least_32_chars
|
||||||
|
ADMIN_USER=admin
|
||||||
|
ADMIN_PASSWORD=change_me
|
||||||
|
ADMIN_EMAIL=
|
||||||
|
REDIS_ENABLED=true
|
||||||
|
CORS_ALLOW_ANY=false
|
||||||
|
CORS_ALLOWED_ORIGIN0=https://your-frontend.example.com
|
||||||
|
STORAGE_CLIENT_RSA_PUBLIC_KEY_PEM=
|
||||||
|
STORAGE_REQUIRE_HTTPS_FOR_DOWNLOAD_KEY=true
|
||||||
17
license-system-backend/Dockerfile
Normal file
17
license-system-backend/Dockerfile
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
|
||||||
|
WORKDIR /app
|
||||||
|
EXPOSE 39256
|
||||||
|
ENV ASPNETCORE_URLS=http://+:39256
|
||||||
|
|
||||||
|
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
|
||||||
|
WORKDIR /src
|
||||||
|
COPY src/License.Api/License.Api.csproj src/License.Api/
|
||||||
|
RUN dotnet restore "src/License.Api/License.Api.csproj"
|
||||||
|
COPY src/License.Api/ src/License.Api/
|
||||||
|
WORKDIR /src/src/License.Api
|
||||||
|
RUN dotnet publish -c Release -o /app/publish
|
||||||
|
|
||||||
|
FROM base AS final
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=build /app/publish .
|
||||||
|
ENTRYPOINT ["dotnet", "License.Api.dll"]
|
||||||
62
license-system-backend/README.md
Normal file
62
license-system-backend/README.md
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
# License System Backend
|
||||||
|
|
||||||
|
ASP.NET Core 8 API service for the software authorization system.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- .NET 8 SDK
|
||||||
|
- PostgreSQL 16+
|
||||||
|
- Redis 7+ (optional, for rate limiting)
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
1. Create database and apply schema:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
psql -U postgres -d license -f scripts/init.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Update `src/License.Api/appsettings.json` with your connection string and JWT secret.
|
||||||
|
|
||||||
|
3. Run the API:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet restore
|
||||||
|
|
||||||
|
ASPNETCORE_URLS=http://localhost:39256 dotnet run --project src/License.Api
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Swagger:
|
||||||
|
|
||||||
|
```
|
||||||
|
http://localhost:39256/swagger
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docker Compose
|
||||||
|
|
||||||
|
1. Copy env file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Start services:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Check health:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:39256/health/ready
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- On first run, a `super_admin` user is seeded from `Seed` config in `appsettings.json`.
|
||||||
|
- Files are stored under the `Storage:UploadRoot` path (defaults to `uploads/`).
|
||||||
|
- If `Storage:ClientRsaPublicKeyPem` is empty, files are stored unencrypted and no key headers are returned.
|
||||||
|
- CORS can be restricted via `Cors:AllowedOrigins` or env `Cors__AllowedOrigins__0` (set `Cors:AllowAny` to `true` for dev).
|
||||||
|
- When `Storage:RequireHttpsForDownloadKey` is `true`, encrypted downloads require HTTPS to return the decryption key.
|
||||||
|
- Auth signature accepts `ProjectSecret` or `ProjectKey` (use ProjectKey for client integrations).
|
||||||
60
license-system-backend/docker-compose.yml
Normal file
60
license-system-backend/docker-compose.yml
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
api:
|
||||||
|
build: .
|
||||||
|
container_name: license-api
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
- ASPNETCORE_ENVIRONMENT=Production
|
||||||
|
- ConnectionStrings__DefaultConnection=Host=db;Port=5432;Database=license;Username=license;Password=${DB_PASSWORD}
|
||||||
|
- Redis__ConnectionString=redis:6379
|
||||||
|
- Redis__Enabled=${REDIS_ENABLED}
|
||||||
|
- Jwt__Secret=${JWT_SECRET}
|
||||||
|
- Jwt__Issuer=license-system
|
||||||
|
- Seed__AdminUser=${ADMIN_USER}
|
||||||
|
- Seed__AdminPassword=${ADMIN_PASSWORD}
|
||||||
|
- Seed__AdminEmail=${ADMIN_EMAIL}
|
||||||
|
- Cors__AllowAny=${CORS_ALLOW_ANY}
|
||||||
|
- Cors__AllowedOrigins__0=${CORS_ALLOWED_ORIGIN0}
|
||||||
|
- Storage__ClientRsaPublicKeyPem=${STORAGE_CLIENT_RSA_PUBLIC_KEY_PEM}
|
||||||
|
- Storage__RequireHttpsForDownloadKey=${STORAGE_REQUIRE_HTTPS_FOR_DOWNLOAD_KEY}
|
||||||
|
volumes:
|
||||||
|
- ./data/uploads:/app/uploads
|
||||||
|
- ./data/logs:/app/logs
|
||||||
|
- ./data/protection-keys:/app/data/protection-keys
|
||||||
|
ports:
|
||||||
|
- "0.0.0.0:39256:39256"
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_started
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: license-db
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: license
|
||||||
|
POSTGRES_USER: license
|
||||||
|
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- ./data/postgres:/var/lib/postgresql/data
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:5432:5432"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U license"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: license-redis
|
||||||
|
restart: always
|
||||||
|
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
|
||||||
|
volumes:
|
||||||
|
- ./data/redis:/data
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:6379:6379"
|
||||||
296
license-system-backend/docs/frontend-handoff.md
Normal file
296
license-system-backend/docs/frontend-handoff.md
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
# 前端对接文档(管理后台 + 客户端)
|
||||||
|
|
||||||
|
本文档基于已实现的后端接口,提供给前端开发使用。
|
||||||
|
|
||||||
|
## 统一响应格式
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"message": "success",
|
||||||
|
"data": {},
|
||||||
|
"timestamp": 1735000000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 统一错误码
|
||||||
|
|
||||||
|
- 200 success
|
||||||
|
- 400 bad_request
|
||||||
|
- 401 unauthorized
|
||||||
|
- 403 forbidden
|
||||||
|
- 404 not_found
|
||||||
|
- 1001 card_invalid
|
||||||
|
- 1002 card_expired
|
||||||
|
- 1003 card_banned
|
||||||
|
- 1005 device_limit_exceeded
|
||||||
|
- 1006 device_not_found
|
||||||
|
- 1007 signature_invalid
|
||||||
|
- 1008 timestamp_expired
|
||||||
|
- 1009 rate_limit_exceeded
|
||||||
|
- 1010 invalid_version
|
||||||
|
- 1011 project_disabled
|
||||||
|
- 1012 force_update_required
|
||||||
|
- 500 internal_error
|
||||||
|
|
||||||
|
## 分页参数
|
||||||
|
|
||||||
|
- page: 页码,默认 1
|
||||||
|
- pageSize: 每页数量,默认 20,最大 100
|
||||||
|
|
||||||
|
响应:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"data": {
|
||||||
|
"items": [],
|
||||||
|
"pagination": {
|
||||||
|
"page": 1,
|
||||||
|
"pageSize": 20,
|
||||||
|
"total": 100,
|
||||||
|
"totalPages": 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 鉴权方式
|
||||||
|
|
||||||
|
### 管理员后台
|
||||||
|
|
||||||
|
登录后获得 JWT,前端需在请求头加入:
|
||||||
|
|
||||||
|
```
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 代理商
|
||||||
|
|
||||||
|
代理商登录后也使用 JWT,同上。
|
||||||
|
|
||||||
|
### 客户端授权
|
||||||
|
|
||||||
|
`/api/auth/verify` 成功后返回 `accessToken`,后续心跳/下载使用。
|
||||||
|
|
||||||
|
## 角色与权限
|
||||||
|
|
||||||
|
- role:
|
||||||
|
- `super_admin`:全部权限(含代理商管理、系统设置、管理员管理)
|
||||||
|
- `admin`:仅可访问所属项目
|
||||||
|
- permissions:
|
||||||
|
- JSON 数组,内容为允许的 `projectId`,示例:`["PROJ_001","PROJ_002"]`
|
||||||
|
- 若为 `["*"]` 表示可访问全部项目
|
||||||
|
- 非超管且权限为空时,默认无项目访问权限
|
||||||
|
- 权限生效范围:
|
||||||
|
- 非超管只能查看/操作允许项目的项目、卡密、设备、日志、统计
|
||||||
|
- 代理商管理与系统设置仅超管可访问
|
||||||
|
|
||||||
|
## 管理后台接口
|
||||||
|
|
||||||
|
### 1) 管理员认证
|
||||||
|
|
||||||
|
- POST `/api/admin/login`
|
||||||
|
- POST `/api/admin/logout`
|
||||||
|
- GET `/api/admin/profile`
|
||||||
|
- PUT `/api/admin/profile`
|
||||||
|
- POST `/api/admin/change-password`
|
||||||
|
|
||||||
|
### 2) 项目管理
|
||||||
|
|
||||||
|
- POST `/api/admin/projects`
|
||||||
|
- GET `/api/admin/projects`
|
||||||
|
- GET `/api/admin/projects/{id}`
|
||||||
|
- PUT `/api/admin/projects/{id}`
|
||||||
|
- DELETE `/api/admin/projects/{id}`
|
||||||
|
- GET `/api/admin/projects/{id}/stats`
|
||||||
|
- GET `/api/admin/projects/{id}/docs`
|
||||||
|
- PUT `/api/admin/projects/{id}/docs`
|
||||||
|
|
||||||
|
#### 价格管理
|
||||||
|
|
||||||
|
- GET `/api/admin/projects/{id}/pricing`
|
||||||
|
- POST `/api/admin/projects/{id}/pricing`
|
||||||
|
- PUT `/api/admin/projects/{id}/pricing/{priceId}`
|
||||||
|
- DELETE `/api/admin/projects/{id}/pricing/{priceId}`
|
||||||
|
|
||||||
|
#### 版本管理
|
||||||
|
|
||||||
|
- GET `/api/admin/projects/{id}/versions`
|
||||||
|
- POST `/api/admin/projects/{id}/versions` (multipart/form-data)
|
||||||
|
- version (string)
|
||||||
|
- file (file)
|
||||||
|
- changelog (string)
|
||||||
|
- isForceUpdate (bool)
|
||||||
|
- isStable (bool)
|
||||||
|
- PUT `/api/admin/projects/{id}/versions/{versionId}`
|
||||||
|
- DELETE `/api/admin/projects/{id}/versions/{versionId}`
|
||||||
|
|
||||||
|
说明:项目创建时返回 `projectSecret`,后续查询不再返回该字段(仅保留 `projectKey`)。请前端在创建时提示管理员保存。
|
||||||
|
|
||||||
|
### 3) 卡密管理
|
||||||
|
|
||||||
|
- POST `/api/admin/cards/generate`
|
||||||
|
- GET `/api/admin/cards`
|
||||||
|
- GET `/api/admin/cards/{id}`
|
||||||
|
- GET `/api/admin/cards/{id}/logs`
|
||||||
|
- PUT `/api/admin/cards/{id}` (更新备注)
|
||||||
|
- POST `/api/admin/cards/{id}/ban`
|
||||||
|
- POST `/api/admin/cards/{id}/unban`
|
||||||
|
- POST `/api/admin/cards/{id}/extend`
|
||||||
|
- POST `/api/admin/cards/{id}/reset-device`
|
||||||
|
- DELETE `/api/admin/cards/{id}`
|
||||||
|
|
||||||
|
#### 批量操作
|
||||||
|
|
||||||
|
- POST `/api/admin/cards/ban-batch`
|
||||||
|
- POST `/api/admin/cards/unban-batch`
|
||||||
|
- DELETE `/api/admin/cards/batch`
|
||||||
|
|
||||||
|
#### 导入/导出
|
||||||
|
|
||||||
|
- GET `/api/admin/cards/export`
|
||||||
|
- POST `/api/admin/cards/import` (CSV)
|
||||||
|
|
||||||
|
说明:导出支持 `format=excel` 生成 `xlsx`,导入支持 CSV 或 Excel(首行可带表头)。卡密生成支持请求头 `X-Idempotency-Key` 防止重复提交。
|
||||||
|
|
||||||
|
### 4) 代理商管理
|
||||||
|
|
||||||
|
- 仅超管可访问
|
||||||
|
- POST `/api/admin/agents`
|
||||||
|
- GET `/api/admin/agents`
|
||||||
|
- GET `/api/admin/agents/{id}`
|
||||||
|
- PUT `/api/admin/agents/{id}`
|
||||||
|
- POST `/api/admin/agents/{id}/disable`
|
||||||
|
- POST `/api/admin/agents/{id}/enable`
|
||||||
|
- DELETE `/api/admin/agents/{id}`
|
||||||
|
- POST `/api/admin/agents/{id}/recharge`
|
||||||
|
- POST `/api/admin/agents/{id}/deduct`
|
||||||
|
- GET `/api/admin/agents/{id}/transactions`
|
||||||
|
|
||||||
|
### 5) 设备管理
|
||||||
|
|
||||||
|
- GET `/api/admin/devices`
|
||||||
|
- DELETE `/api/admin/devices/{id}`
|
||||||
|
- POST `/api/admin/devices/{id}/kick`
|
||||||
|
|
||||||
|
### 6) 统计报表
|
||||||
|
|
||||||
|
- GET `/api/admin/stats/dashboard`
|
||||||
|
- GET `/api/admin/stats/projects` (项目维度统计)
|
||||||
|
- GET `/api/admin/stats/agents` (代理商销售统计,仅超管)
|
||||||
|
- GET `/api/admin/stats/logs?days=7` (按 action 聚合统计)
|
||||||
|
- GET `/api/admin/stats/export?days=30` (CSV 导出)
|
||||||
|
|
||||||
|
### 7) 日志审计
|
||||||
|
|
||||||
|
- GET `/api/admin/logs`
|
||||||
|
- GET `/api/admin/logs/{id}`
|
||||||
|
|
||||||
|
### 8) 系统设置 & 管理员
|
||||||
|
|
||||||
|
- 仅超管可访问
|
||||||
|
- GET `/api/admin/settings`
|
||||||
|
- PUT `/api/admin/settings`
|
||||||
|
- GET `/api/admin/admins`
|
||||||
|
- POST `/api/admin/admins`
|
||||||
|
- PUT `/api/admin/admins/{id}`
|
||||||
|
- DELETE `/api/admin/admins/{id}`
|
||||||
|
|
||||||
|
## 代理商接口
|
||||||
|
|
||||||
|
- POST `/api/agent/login`
|
||||||
|
- POST `/api/agent/logout`
|
||||||
|
- GET `/api/agent/profile`
|
||||||
|
- PUT `/api/agent/profile`
|
||||||
|
- POST `/api/agent/change-password`
|
||||||
|
- GET `/api/agent/transactions`
|
||||||
|
|
||||||
|
### 代理商售卡
|
||||||
|
|
||||||
|
- POST `/api/agent/cards/generate`
|
||||||
|
- GET `/api/agent/cards`
|
||||||
|
|
||||||
|
说明:代理商售卡会按项目价格 * 折扣扣减余额,支持 `X-Idempotency-Key` 防重复扣款。
|
||||||
|
|
||||||
|
## 客户端/SDK 接口
|
||||||
|
|
||||||
|
### 1) 卡密验证
|
||||||
|
|
||||||
|
POST `/api/auth/verify`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"projectId": "PROJ_001",
|
||||||
|
"keyCode": "A3D7-K2P9-M8N1-Q4W6",
|
||||||
|
"deviceId": "SHA256硬件指纹",
|
||||||
|
"clientVersion": "1.0.0",
|
||||||
|
"timestamp": 1735000000,
|
||||||
|
"signature": "HMAC-SHA256签名"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
成功返回:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"data": {
|
||||||
|
"valid": true,
|
||||||
|
"expireTime": "2025-12-31T23:59:59Z",
|
||||||
|
"remainingDays": 30,
|
||||||
|
"downloadUrl": "/api/software/download?version=1.2.0&token=xxx",
|
||||||
|
"fileHash": "sha256...",
|
||||||
|
"version": "1.2.0",
|
||||||
|
"heartbeatInterval": 60,
|
||||||
|
"accessToken": "jwt_token"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
若客户端版本低于强制更新版本,将返回 `1012 force_update_required`(HTTP 426)。
|
||||||
|
|
||||||
|
### 2) 心跳验证
|
||||||
|
|
||||||
|
POST `/api/auth/heartbeat`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"accessToken": "jwt_token",
|
||||||
|
"deviceId": "xxx",
|
||||||
|
"timestamp": 1735000000,
|
||||||
|
"signature": "xxx"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3) 版本检查
|
||||||
|
|
||||||
|
POST `/api/software/check-update`
|
||||||
|
|
||||||
|
### 4) 软件下载
|
||||||
|
|
||||||
|
GET `/api/software/download?version=1.2.0&token=jwt_token`
|
||||||
|
|
||||||
|
响应头(加密开启时):
|
||||||
|
|
||||||
|
- `X-File-Hash`
|
||||||
|
- `X-File-Size`
|
||||||
|
- `X-Encryption-Method: AES-256-GCM`
|
||||||
|
- `X-Encryption-Key: <Base64>`
|
||||||
|
- `X-Encryption-Nonce: <Base64>`
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
- 上传软件版本需要 `multipart/form-data`。
|
||||||
|
- 当 `Storage.ClientRsaPublicKeyPem` 未配置时,文件存储为明文,不返回加密头。
|
||||||
|
- `/api/software/check-update` 返回的 `downloadUrl` 需要客户端自行追加 `token` 参数(来自 `/api/auth/verify`)。
|
||||||
|
- 目前导入卡密仅支持 CSV。
|
||||||
|
- 设备限流可通过请求头 `X-Device-Id` 识别设备(建议 SDK 统一加上)。
|
||||||
|
- 当系统配置 `feature.auto_update=false` 时,`/api/software/check-update` 将返回 `hasUpdate=false`。
|
||||||
|
|
||||||
|
## 公共配置接口
|
||||||
|
|
||||||
|
- GET `/api/config/public`
|
||||||
|
|
||||||
|
返回所有 `IsPublic = true` 的系统配置(用于客户端功能开关/心跳间隔等)。
|
||||||
|
- `/api/admin/stats/*` 除 `dashboard` 外为预留接口。
|
||||||
212
license-system-backend/scripts/init.sql
Normal file
212
license-system-backend/scripts/init.sql
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
-- Database schema for license system
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS Projects (
|
||||||
|
Id SERIAL PRIMARY KEY,
|
||||||
|
ProjectId VARCHAR(32) UNIQUE NOT NULL,
|
||||||
|
ProjectKey VARCHAR(64) NOT NULL,
|
||||||
|
ProjectSecret VARCHAR(64) NOT NULL,
|
||||||
|
Name VARCHAR(100) NOT NULL,
|
||||||
|
Description TEXT,
|
||||||
|
IconUrl VARCHAR(500),
|
||||||
|
MaxDevices INT DEFAULT 1,
|
||||||
|
AutoUpdate BOOLEAN DEFAULT TRUE,
|
||||||
|
IsEnabled BOOLEAN DEFAULT TRUE,
|
||||||
|
DocsContent TEXT,
|
||||||
|
CreatedBy INT,
|
||||||
|
CreatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UpdatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ProjectPricing (
|
||||||
|
Id SERIAL PRIMARY KEY,
|
||||||
|
ProjectId VARCHAR(32) REFERENCES Projects(ProjectId) ON DELETE CASCADE,
|
||||||
|
CardType VARCHAR(20) NOT NULL,
|
||||||
|
DurationDays INT NOT NULL,
|
||||||
|
OriginalPrice DECIMAL(10,2) NOT NULL,
|
||||||
|
IsEnabled BOOLEAN DEFAULT TRUE,
|
||||||
|
CreatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UpdatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(ProjectId, CardType, DurationDays)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS SoftwareVersions (
|
||||||
|
Id SERIAL PRIMARY KEY,
|
||||||
|
ProjectId VARCHAR(32) REFERENCES Projects(ProjectId) ON DELETE CASCADE,
|
||||||
|
Version VARCHAR(20) NOT NULL,
|
||||||
|
FileUrl VARCHAR(500) NOT NULL,
|
||||||
|
FileSize BIGINT,
|
||||||
|
FileHash VARCHAR(64),
|
||||||
|
EncryptionKey VARCHAR(256),
|
||||||
|
Changelog TEXT,
|
||||||
|
IsForceUpdate BOOLEAN DEFAULT FALSE,
|
||||||
|
IsStable BOOLEAN DEFAULT TRUE,
|
||||||
|
PublishedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CreatedBy INT,
|
||||||
|
CreatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(ProjectId, Version)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS Admins (
|
||||||
|
Id SERIAL PRIMARY KEY,
|
||||||
|
Username VARCHAR(50) UNIQUE NOT NULL,
|
||||||
|
PasswordHash VARCHAR(255) NOT NULL,
|
||||||
|
Email VARCHAR(100),
|
||||||
|
Role VARCHAR(20) DEFAULT 'admin',
|
||||||
|
Permissions TEXT,
|
||||||
|
Status VARCHAR(20) DEFAULT 'active',
|
||||||
|
LastLoginAt TIMESTAMP,
|
||||||
|
LastLoginIp VARCHAR(45),
|
||||||
|
CreatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UpdatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS Agents (
|
||||||
|
Id SERIAL PRIMARY KEY,
|
||||||
|
AdminId INT REFERENCES Admins(Id) ON DELETE SET NULL,
|
||||||
|
AgentCode VARCHAR(20) UNIQUE NOT NULL,
|
||||||
|
CompanyName VARCHAR(100),
|
||||||
|
ContactPerson VARCHAR(50),
|
||||||
|
ContactPhone VARCHAR(20),
|
||||||
|
ContactEmail VARCHAR(100),
|
||||||
|
PasswordHash VARCHAR(255) NOT NULL,
|
||||||
|
Balance DECIMAL(10,2) DEFAULT 0,
|
||||||
|
Discount DECIMAL(5,2) DEFAULT 100.00,
|
||||||
|
CreditLimit DECIMAL(10,2) DEFAULT 0,
|
||||||
|
MaxProjects INT DEFAULT 0,
|
||||||
|
AllowedProjects TEXT,
|
||||||
|
Status VARCHAR(20) DEFAULT 'active',
|
||||||
|
LastLoginAt TIMESTAMP,
|
||||||
|
CreatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UpdatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS CardKeys (
|
||||||
|
Id SERIAL PRIMARY KEY,
|
||||||
|
ProjectId VARCHAR(32) REFERENCES Projects(ProjectId) ON DELETE SET NULL,
|
||||||
|
KeyCode VARCHAR(32) UNIQUE NOT NULL,
|
||||||
|
CardType VARCHAR(20) NOT NULL,
|
||||||
|
DurationDays INT NOT NULL,
|
||||||
|
ExpireTime TIMESTAMP,
|
||||||
|
MaxDevices INT DEFAULT 1,
|
||||||
|
MachineCode VARCHAR(64),
|
||||||
|
Status VARCHAR(20) DEFAULT 'unused',
|
||||||
|
ActivateTime TIMESTAMP,
|
||||||
|
LastUsedAt TIMESTAMP,
|
||||||
|
UsedDuration BIGINT DEFAULT 0,
|
||||||
|
GeneratedBy INT REFERENCES Admins(Id),
|
||||||
|
AgentId INT REFERENCES Agents(Id),
|
||||||
|
SoldPrice DECIMAL(10,2),
|
||||||
|
Note VARCHAR(200),
|
||||||
|
BatchId VARCHAR(36),
|
||||||
|
Version INT DEFAULT 1,
|
||||||
|
CreatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
DeletedAt TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_card_keys_project ON CardKeys(ProjectId);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_card_keys_code ON CardKeys(KeyCode);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_card_keys_status ON CardKeys(Status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_card_keys_project_status_created ON CardKeys(ProjectId, Status, CreatedAt DESC) WHERE DeletedAt IS NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_card_keys_expire ON CardKeys(ExpireTime) WHERE Status = 'active' AND DeletedAt IS NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_card_keys_agent ON CardKeys(AgentId) WHERE DeletedAt IS NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_card_keys_batch ON CardKeys(BatchId);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS Devices (
|
||||||
|
Id SERIAL PRIMARY KEY,
|
||||||
|
CardKeyId INT REFERENCES CardKeys(Id) ON DELETE CASCADE,
|
||||||
|
DeviceId VARCHAR(64) NOT NULL,
|
||||||
|
DeviceName VARCHAR(100),
|
||||||
|
OsInfo VARCHAR(100),
|
||||||
|
LastHeartbeat TIMESTAMP,
|
||||||
|
IpAddress VARCHAR(45),
|
||||||
|
Location VARCHAR(100),
|
||||||
|
IsActive BOOLEAN DEFAULT TRUE,
|
||||||
|
FirstLoginAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
DeletedAt TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_devices_card_key ON Devices(CardKeyId);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_devices_device_id ON Devices(DeviceId);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_devices_card_key_device ON Devices(CardKeyId, DeviceId);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_devices_heartbeat ON Devices(LastHeartbeat) WHERE IsActive = true AND DeletedAt IS NULL;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS AccessLogs (
|
||||||
|
Id SERIAL PRIMARY KEY,
|
||||||
|
ProjectId VARCHAR(32),
|
||||||
|
CardKeyId INT,
|
||||||
|
DeviceId VARCHAR(64),
|
||||||
|
Action VARCHAR(50),
|
||||||
|
IpAddress INET,
|
||||||
|
UserAgent TEXT,
|
||||||
|
ResponseCode INT,
|
||||||
|
ResponseTime INT,
|
||||||
|
CreatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_logs_project ON AccessLogs(ProjectId);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_logs_created ON AccessLogs(CreatedAt);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_logs_action_created ON AccessLogs(Action, CreatedAt DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_logs_card_key ON AccessLogs(CardKeyId);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS Statistics (
|
||||||
|
Id SERIAL PRIMARY KEY,
|
||||||
|
ProjectId VARCHAR(32),
|
||||||
|
Date DATE,
|
||||||
|
ActiveUsers INT DEFAULT 0,
|
||||||
|
NewUsers INT DEFAULT 0,
|
||||||
|
TotalDownloads INT DEFAULT 0,
|
||||||
|
TotalDuration BIGINT DEFAULT 0,
|
||||||
|
Revenue DECIMAL(10,2) DEFAULT 0,
|
||||||
|
UNIQUE(ProjectId, Date)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS AgentTransactions (
|
||||||
|
Id SERIAL PRIMARY KEY,
|
||||||
|
AgentId INT REFERENCES Agents(Id) ON DELETE CASCADE,
|
||||||
|
Type VARCHAR(20) NOT NULL,
|
||||||
|
Amount DECIMAL(10,2) NOT NULL,
|
||||||
|
BalanceBefore DECIMAL(10,2) NOT NULL,
|
||||||
|
BalanceAfter DECIMAL(10,2) NOT NULL,
|
||||||
|
CardKeyId INT REFERENCES CardKeys(Id) ON DELETE SET NULL,
|
||||||
|
Remark VARCHAR(200),
|
||||||
|
CreatedBy INT REFERENCES Admins(Id) ON DELETE SET NULL,
|
||||||
|
CreatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS CardKeyLogs (
|
||||||
|
Id SERIAL PRIMARY KEY,
|
||||||
|
CardKeyId INT REFERENCES CardKeys(Id) ON DELETE CASCADE,
|
||||||
|
Action VARCHAR(50) NOT NULL,
|
||||||
|
OperatorId INT,
|
||||||
|
OperatorType VARCHAR(20),
|
||||||
|
Details TEXT,
|
||||||
|
IpAddress VARCHAR(45),
|
||||||
|
CreatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS SystemConfigs (
|
||||||
|
Id SERIAL PRIMARY KEY,
|
||||||
|
ConfigKey VARCHAR(50) UNIQUE NOT NULL,
|
||||||
|
ConfigValue TEXT,
|
||||||
|
ValueType VARCHAR(20) DEFAULT 'string',
|
||||||
|
Category VARCHAR(50) DEFAULT 'general',
|
||||||
|
DisplayName VARCHAR(100),
|
||||||
|
Description VARCHAR(500),
|
||||||
|
Options TEXT,
|
||||||
|
IsPublic BOOLEAN DEFAULT FALSE,
|
||||||
|
UpdatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS IdempotencyKeys (
|
||||||
|
Id SERIAL PRIMARY KEY,
|
||||||
|
IdempotencyKey VARCHAR(64) UNIQUE NOT NULL,
|
||||||
|
RequestPath VARCHAR(200) NOT NULL,
|
||||||
|
RequestHash VARCHAR(64),
|
||||||
|
ResponseCode INT,
|
||||||
|
ResponseBody TEXT,
|
||||||
|
CreatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
ExpiresAt TIMESTAMP NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_idempotency_key ON IdempotencyKeys(IdempotencyKey);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_idempotency_expires ON IdempotencyKeys(ExpiresAt);
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using License.Api.Data;
|
||||||
|
using License.Api.DTOs;
|
||||||
|
using License.Api.Models;
|
||||||
|
using License.Api.Services;
|
||||||
|
using License.Api.Utils;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace License.Api.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Authorize(Policy = "SuperAdmin")]
|
||||||
|
[Route("api/admin/agents")]
|
||||||
|
public class AdminAgentsController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
private readonly ConfigService _config;
|
||||||
|
|
||||||
|
public AdminAgentsController(AppDbContext db, ConfigService config)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_config = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<IActionResult> Create([FromBody] AgentCreateRequest request)
|
||||||
|
{
|
||||||
|
var agentSystemEnabled = await _config.GetBoolAsync("feature.agent_system", true);
|
||||||
|
if (!agentSystemEnabled)
|
||||||
|
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
|
||||||
|
|
||||||
|
var agent = new Agent
|
||||||
|
{
|
||||||
|
AdminId = request.AdminId,
|
||||||
|
AgentCode = request.AgentCode,
|
||||||
|
CompanyName = request.CompanyName,
|
||||||
|
ContactPerson = request.ContactPerson,
|
||||||
|
ContactPhone = request.ContactPhone,
|
||||||
|
ContactEmail = request.ContactEmail,
|
||||||
|
PasswordHash = PasswordHasher.Hash(request.Password),
|
||||||
|
Balance = request.InitialBalance,
|
||||||
|
Discount = request.Discount,
|
||||||
|
CreditLimit = request.CreditLimit,
|
||||||
|
MaxProjects = request.AllowedProjects?.Count ?? 0,
|
||||||
|
AllowedProjects = request.AllowedProjects == null ? null : JsonSerializer.Serialize(request.AllowedProjects),
|
||||||
|
Status = "active",
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
UpdatedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
_db.Agents.Add(agent);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var data = new AgentDetailResponse
|
||||||
|
{
|
||||||
|
Id = agent.Id,
|
||||||
|
AgentCode = agent.AgentCode,
|
||||||
|
CompanyName = agent.CompanyName,
|
||||||
|
ContactPerson = agent.ContactPerson,
|
||||||
|
ContactPhone = agent.ContactPhone,
|
||||||
|
ContactEmail = agent.ContactEmail,
|
||||||
|
Balance = agent.Balance,
|
||||||
|
Discount = agent.Discount,
|
||||||
|
CreditLimit = agent.CreditLimit,
|
||||||
|
Status = agent.Status,
|
||||||
|
CreatedAt = agent.CreatedAt
|
||||||
|
};
|
||||||
|
|
||||||
|
return Ok(ApiResponse<AgentDetailResponse>.Ok(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> List([FromQuery] string? status, [FromQuery] int page = 1, [FromQuery] int pageSize = 20)
|
||||||
|
{
|
||||||
|
var agentSystemEnabled = await _config.GetBoolAsync("feature.agent_system", true);
|
||||||
|
if (!agentSystemEnabled)
|
||||||
|
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
|
||||||
|
|
||||||
|
page = Math.Max(1, page);
|
||||||
|
pageSize = Math.Clamp(pageSize, 1, 100);
|
||||||
|
|
||||||
|
var query = _db.Agents.AsQueryable();
|
||||||
|
if (!string.IsNullOrWhiteSpace(status))
|
||||||
|
query = query.Where(a => a.Status == status);
|
||||||
|
|
||||||
|
var total = await query.CountAsync();
|
||||||
|
var items = await query.OrderByDescending(a => a.CreatedAt)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
|
.Select(a => new AgentListItem
|
||||||
|
{
|
||||||
|
Id = a.Id,
|
||||||
|
AgentCode = a.AgentCode,
|
||||||
|
CompanyName = a.CompanyName,
|
||||||
|
ContactPerson = a.ContactPerson,
|
||||||
|
ContactPhone = a.ContactPhone,
|
||||||
|
Balance = a.Balance,
|
||||||
|
Discount = a.Discount,
|
||||||
|
Status = a.Status,
|
||||||
|
CreatedAt = a.CreatedAt
|
||||||
|
})
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var result = new PagedResult<AgentListItem>
|
||||||
|
{
|
||||||
|
Items = items,
|
||||||
|
Pagination = new PaginationInfo
|
||||||
|
{
|
||||||
|
Page = page,
|
||||||
|
PageSize = pageSize,
|
||||||
|
Total = total,
|
||||||
|
TotalPages = (int)Math.Ceiling(total / (double)pageSize)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return Ok(ApiResponse<PagedResult<AgentListItem>>.Ok(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id:int}")]
|
||||||
|
public async Task<IActionResult> Get(int id)
|
||||||
|
{
|
||||||
|
var agentSystemEnabled = await _config.GetBoolAsync("feature.agent_system", true);
|
||||||
|
if (!agentSystemEnabled)
|
||||||
|
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
|
||||||
|
|
||||||
|
var agent = await _db.Agents.FindAsync(id);
|
||||||
|
if (agent == null)
|
||||||
|
return NotFound(ApiResponse.Fail(404, "not_found"));
|
||||||
|
|
||||||
|
var transactions = await _db.AgentTransactions
|
||||||
|
.Where(t => t.AgentId == id)
|
||||||
|
.OrderByDescending(t => t.CreatedAt)
|
||||||
|
.Take(50)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var stats = await _db.CardKeys
|
||||||
|
.Where(c => c.AgentId == id && c.DeletedAt == null)
|
||||||
|
.GroupBy(c => c.AgentId)
|
||||||
|
.Select(g => new
|
||||||
|
{
|
||||||
|
totalCards = g.Count(),
|
||||||
|
activeCards = g.Count(x => x.Status == "active"),
|
||||||
|
totalRevenue = g.Sum(x => x.SoldPrice ?? 0)
|
||||||
|
})
|
||||||
|
.SingleOrDefaultAsync();
|
||||||
|
|
||||||
|
return Ok(ApiResponse<object>.Ok(new
|
||||||
|
{
|
||||||
|
agent = new AgentDetailResponse
|
||||||
|
{
|
||||||
|
Id = agent.Id,
|
||||||
|
AgentCode = agent.AgentCode,
|
||||||
|
CompanyName = agent.CompanyName,
|
||||||
|
ContactPerson = agent.ContactPerson,
|
||||||
|
ContactPhone = agent.ContactPhone,
|
||||||
|
ContactEmail = agent.ContactEmail,
|
||||||
|
Balance = agent.Balance,
|
||||||
|
Discount = agent.Discount,
|
||||||
|
CreditLimit = agent.CreditLimit,
|
||||||
|
Status = agent.Status,
|
||||||
|
CreatedAt = agent.CreatedAt
|
||||||
|
},
|
||||||
|
stats,
|
||||||
|
transactions
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id:int}")]
|
||||||
|
public async Task<IActionResult> Update(int id, [FromBody] AgentUpdateRequest request)
|
||||||
|
{
|
||||||
|
var agentSystemEnabled = await _config.GetBoolAsync("feature.agent_system", true);
|
||||||
|
if (!agentSystemEnabled)
|
||||||
|
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
|
||||||
|
|
||||||
|
var agent = await _db.Agents.FindAsync(id);
|
||||||
|
if (agent == null)
|
||||||
|
return NotFound(ApiResponse.Fail(404, "not_found"));
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.CompanyName))
|
||||||
|
agent.CompanyName = request.CompanyName;
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.ContactPerson))
|
||||||
|
agent.ContactPerson = request.ContactPerson;
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.ContactPhone))
|
||||||
|
agent.ContactPhone = request.ContactPhone;
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.ContactEmail))
|
||||||
|
agent.ContactEmail = request.ContactEmail;
|
||||||
|
if (request.Discount.HasValue)
|
||||||
|
agent.Discount = request.Discount.Value;
|
||||||
|
if (request.CreditLimit.HasValue)
|
||||||
|
agent.CreditLimit = request.CreditLimit.Value;
|
||||||
|
if (request.AllowedProjects != null)
|
||||||
|
{
|
||||||
|
agent.AllowedProjects = JsonSerializer.Serialize(request.AllowedProjects);
|
||||||
|
agent.MaxProjects = request.AllowedProjects.Count;
|
||||||
|
}
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.Status))
|
||||||
|
agent.Status = request.Status;
|
||||||
|
|
||||||
|
agent.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return Ok(ApiResponse.Ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:int}/disable")]
|
||||||
|
public async Task<IActionResult> Disable(int id)
|
||||||
|
{
|
||||||
|
var agentSystemEnabled = await _config.GetBoolAsync("feature.agent_system", true);
|
||||||
|
if (!agentSystemEnabled)
|
||||||
|
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
|
||||||
|
|
||||||
|
var agent = await _db.Agents.FindAsync(id);
|
||||||
|
if (agent == null)
|
||||||
|
return NotFound(ApiResponse.Fail(404, "not_found"));
|
||||||
|
|
||||||
|
agent.Status = "disabled";
|
||||||
|
agent.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
return Ok(ApiResponse.Ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:int}/enable")]
|
||||||
|
public async Task<IActionResult> Enable(int id)
|
||||||
|
{
|
||||||
|
var agentSystemEnabled = await _config.GetBoolAsync("feature.agent_system", true);
|
||||||
|
if (!agentSystemEnabled)
|
||||||
|
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
|
||||||
|
|
||||||
|
var agent = await _db.Agents.FindAsync(id);
|
||||||
|
if (agent == null)
|
||||||
|
return NotFound(ApiResponse.Fail(404, "not_found"));
|
||||||
|
|
||||||
|
agent.Status = "active";
|
||||||
|
agent.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
return Ok(ApiResponse.Ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{id:int}")]
|
||||||
|
public async Task<IActionResult> Delete(int id)
|
||||||
|
{
|
||||||
|
var agentSystemEnabled = await _config.GetBoolAsync("feature.agent_system", true);
|
||||||
|
if (!agentSystemEnabled)
|
||||||
|
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
|
||||||
|
|
||||||
|
var agent = await _db.Agents.FindAsync(id);
|
||||||
|
if (agent == null)
|
||||||
|
return NotFound(ApiResponse.Fail(404, "not_found"));
|
||||||
|
|
||||||
|
_db.Agents.Remove(agent);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
return Ok(ApiResponse.Ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:int}/recharge")]
|
||||||
|
public async Task<IActionResult> Recharge(int id, [FromBody] AgentBalanceRequest request)
|
||||||
|
{
|
||||||
|
var agentSystemEnabled = await _config.GetBoolAsync("feature.agent_system", true);
|
||||||
|
if (!agentSystemEnabled)
|
||||||
|
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
|
||||||
|
|
||||||
|
return await AdjustBalance(id, request.Amount, "recharge", request.Remark);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:int}/deduct")]
|
||||||
|
public async Task<IActionResult> Deduct(int id, [FromBody] AgentBalanceRequest request)
|
||||||
|
{
|
||||||
|
var agentSystemEnabled = await _config.GetBoolAsync("feature.agent_system", true);
|
||||||
|
if (!agentSystemEnabled)
|
||||||
|
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
|
||||||
|
|
||||||
|
return await AdjustBalance(id, -Math.Abs(request.Amount), "consume", request.Remark);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id:int}/transactions")]
|
||||||
|
public async Task<IActionResult> Transactions(int id)
|
||||||
|
{
|
||||||
|
var agentSystemEnabled = await _config.GetBoolAsync("feature.agent_system", true);
|
||||||
|
if (!agentSystemEnabled)
|
||||||
|
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
|
||||||
|
|
||||||
|
var items = await _db.AgentTransactions
|
||||||
|
.Where(t => t.AgentId == id)
|
||||||
|
.OrderByDescending(t => t.CreatedAt)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
return Ok(ApiResponse<List<AgentTransaction>>.Ok(items));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IActionResult> AdjustBalance(int id, decimal amount, string type, string? remark)
|
||||||
|
{
|
||||||
|
if (!User.TryGetUserId(out var adminId))
|
||||||
|
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
|
||||||
|
|
||||||
|
await using var tx = await _db.Database.BeginTransactionAsync();
|
||||||
|
var agent = await _db.Agents
|
||||||
|
.FromSqlRaw("SELECT * FROM \"Agents\" WHERE \"Id\" = {0} FOR UPDATE", id)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
if (agent == null)
|
||||||
|
return NotFound(ApiResponse.Fail(404, "not_found"));
|
||||||
|
|
||||||
|
var balanceBefore = agent.Balance;
|
||||||
|
var balanceAfter = balanceBefore + amount;
|
||||||
|
if (balanceAfter < -agent.CreditLimit)
|
||||||
|
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
|
||||||
|
|
||||||
|
agent.Balance = balanceAfter;
|
||||||
|
agent.UpdatedAt = DateTime.UtcNow;
|
||||||
|
_db.AgentTransactions.Add(new AgentTransaction
|
||||||
|
{
|
||||||
|
AgentId = agent.Id,
|
||||||
|
Type = type,
|
||||||
|
Amount = amount,
|
||||||
|
BalanceBefore = balanceBefore,
|
||||||
|
BalanceAfter = balanceAfter,
|
||||||
|
Remark = remark,
|
||||||
|
CreatedBy = adminId,
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
});
|
||||||
|
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
await tx.CommitAsync();
|
||||||
|
|
||||||
|
return Ok(ApiResponse.Ok());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
using License.Api.Data;
|
||||||
|
using License.Api.DTOs;
|
||||||
|
using License.Api.Security;
|
||||||
|
using License.Api.Services;
|
||||||
|
using License.Api.Utils;
|
||||||
|
using License.Api.Models;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace License.Api.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/admin")]
|
||||||
|
public class AdminAuthController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
private readonly JwtTokenService _jwt;
|
||||||
|
|
||||||
|
public AdminAuthController(AppDbContext db, JwtTokenService jwt)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_jwt = jwt;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("login")]
|
||||||
|
public async Task<IActionResult> Login([FromBody] AdminLoginRequest request)
|
||||||
|
{
|
||||||
|
var admin = await _db.Admins.FirstOrDefaultAsync(a => a.Username == request.Username);
|
||||||
|
if (admin == null || !PasswordHasher.Verify(request.Password, admin.PasswordHash))
|
||||||
|
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
|
||||||
|
|
||||||
|
if (admin.Status != "active")
|
||||||
|
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
|
||||||
|
|
||||||
|
admin.LastLoginAt = DateTime.UtcNow;
|
||||||
|
admin.LastLoginIp = HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
await LogAccessAsync($"admin:{admin.Username}", "admin_login");
|
||||||
|
|
||||||
|
var token = _jwt.CreateAdminToken(admin);
|
||||||
|
var permissions = ResolvePermissions(admin);
|
||||||
|
var data = new
|
||||||
|
{
|
||||||
|
token,
|
||||||
|
user = new
|
||||||
|
{
|
||||||
|
id = admin.Id,
|
||||||
|
username = admin.Username,
|
||||||
|
role = admin.Role,
|
||||||
|
permissions
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return Ok(ApiResponse<object>.Ok(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Authorize(Policy = "Admin")]
|
||||||
|
[HttpPost("logout")]
|
||||||
|
public IActionResult Logout()
|
||||||
|
{
|
||||||
|
var username = User.FindFirst("username")?.Value ?? "admin";
|
||||||
|
_ = LogAccessAsync($"admin:{username}", "admin_logout");
|
||||||
|
return Ok(ApiResponse.Ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Authorize(Policy = "Admin")]
|
||||||
|
[HttpGet("profile")]
|
||||||
|
public async Task<IActionResult> Profile()
|
||||||
|
{
|
||||||
|
if (!User.TryGetUserId(out var adminId))
|
||||||
|
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
|
||||||
|
var admin = await _db.Admins.FindAsync(adminId);
|
||||||
|
if (admin == null)
|
||||||
|
return NotFound(ApiResponse.Fail(404, "not_found"));
|
||||||
|
|
||||||
|
return Ok(ApiResponse<object>.Ok(new
|
||||||
|
{
|
||||||
|
id = admin.Id,
|
||||||
|
username = admin.Username,
|
||||||
|
role = admin.Role,
|
||||||
|
email = admin.Email,
|
||||||
|
permissions = ResolvePermissions(admin)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Authorize(Policy = "Admin")]
|
||||||
|
[HttpPut("profile")]
|
||||||
|
public async Task<IActionResult> UpdateProfile([FromBody] AdminUpdateRequest request)
|
||||||
|
{
|
||||||
|
if (!User.TryGetUserId(out var adminId))
|
||||||
|
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
|
||||||
|
var admin = await _db.Admins.FindAsync(adminId);
|
||||||
|
if (admin == null)
|
||||||
|
return NotFound(ApiResponse.Fail(404, "not_found"));
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.Email))
|
||||||
|
admin.Email = request.Email;
|
||||||
|
|
||||||
|
admin.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return Ok(ApiResponse.Ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Authorize(Policy = "Admin")]
|
||||||
|
[HttpPost("change-password")]
|
||||||
|
public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordRequest request)
|
||||||
|
{
|
||||||
|
if (!User.TryGetUserId(out var adminId))
|
||||||
|
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
|
||||||
|
var admin = await _db.Admins.FindAsync(adminId);
|
||||||
|
if (admin == null)
|
||||||
|
return NotFound(ApiResponse.Fail(404, "not_found"));
|
||||||
|
|
||||||
|
if (!PasswordHasher.Verify(request.OldPassword, admin.PasswordHash))
|
||||||
|
return BadRequest(ApiResponse.Fail(400, "bad_request"));
|
||||||
|
|
||||||
|
admin.PasswordHash = PasswordHasher.Hash(request.NewPassword);
|
||||||
|
admin.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return Ok(ApiResponse.Ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LogAccessAsync(string? deviceId, string action)
|
||||||
|
{
|
||||||
|
_db.AccessLogs.Add(new AccessLog
|
||||||
|
{
|
||||||
|
DeviceId = deviceId,
|
||||||
|
Action = action,
|
||||||
|
IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString(),
|
||||||
|
UserAgent = HttpContext.Request.Headers.UserAgent.ToString(),
|
||||||
|
ResponseCode = 200,
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
});
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<string> ResolvePermissions(Admin admin)
|
||||||
|
{
|
||||||
|
if (string.Equals(admin.Role, "super_admin", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return new List<string> { "*" };
|
||||||
|
|
||||||
|
var (hasAll, allowed) = AdminAccessService.ParsePermissions(admin.Permissions);
|
||||||
|
if (hasAll)
|
||||||
|
return new List<string> { "*" };
|
||||||
|
return allowed.OrderBy(p => p).ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,594 @@
|
|||||||
|
using System.IO;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using ClosedXML.Excel;
|
||||||
|
using License.Api.Data;
|
||||||
|
using License.Api.DTOs;
|
||||||
|
using License.Api.Models;
|
||||||
|
using License.Api.Services;
|
||||||
|
using License.Api.Utils;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace License.Api.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Authorize(Policy = "Admin")]
|
||||||
|
[Route("api/admin/cards")]
|
||||||
|
public class AdminCardsController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
private readonly CardService _cards;
|
||||||
|
private readonly IdempotencyService _idempotency;
|
||||||
|
private readonly ConfigService _config;
|
||||||
|
private readonly AdminAccessService _adminAccess;
|
||||||
|
|
||||||
|
public AdminCardsController(AppDbContext db, CardService cards, IdempotencyService idempotency, ConfigService config, AdminAccessService adminAccess)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_cards = cards;
|
||||||
|
_idempotency = idempotency;
|
||||||
|
_config = config;
|
||||||
|
_adminAccess = adminAccess;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("generate")]
|
||||||
|
public async Task<IActionResult> Generate([FromBody] CardGenerateRequest request)
|
||||||
|
{
|
||||||
|
var scope = await _adminAccess.GetScopeAsync(User);
|
||||||
|
if (scope == null)
|
||||||
|
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
|
||||||
|
|
||||||
|
if (request.Quantity <= 0 || request.Quantity > 10000)
|
||||||
|
return BadRequest(ApiResponse.Fail(400, "bad_request"));
|
||||||
|
|
||||||
|
var project = await _db.Projects.FirstOrDefaultAsync(p => p.ProjectId == request.ProjectId);
|
||||||
|
if (project == null)
|
||||||
|
return NotFound(ApiResponse.Fail(404, "not_found"));
|
||||||
|
if (!scope.CanAccessProject(project.ProjectId))
|
||||||
|
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
|
||||||
|
if (!project.IsEnabled)
|
||||||
|
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(1011, "project_disabled"));
|
||||||
|
|
||||||
|
var requestHash = IdempotencyService.ComputeRequestHash(JsonSerializer.Serialize(request));
|
||||||
|
var idempotencyKey = Request.Headers["X-Idempotency-Key"].ToString();
|
||||||
|
if (!string.IsNullOrWhiteSpace(idempotencyKey))
|
||||||
|
{
|
||||||
|
var existing = await _idempotency.GetAsync(idempotencyKey);
|
||||||
|
if (existing != null)
|
||||||
|
{
|
||||||
|
if (!string.Equals(existing.RequestHash, requestHash, StringComparison.OrdinalIgnoreCase))
|
||||||
|
return Conflict(ApiResponse.Fail(400, "bad_request"));
|
||||||
|
|
||||||
|
var cached = JsonSerializer.Deserialize<ApiResponse<CardGenerateResponse>>(existing.ResponseBody ?? "{}")
|
||||||
|
?? ApiResponse<CardGenerateResponse>.Fail(500, "internal_error");
|
||||||
|
return StatusCode(existing.ResponseCode ?? 200, cached);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!User.TryGetUserId(out var adminId))
|
||||||
|
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
|
||||||
|
var data = await _cards.GenerateAsync(request, adminId);
|
||||||
|
var response = ApiResponse<CardGenerateResponse>.Ok(data);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(idempotencyKey))
|
||||||
|
{
|
||||||
|
var body = JsonSerializer.Serialize(response);
|
||||||
|
await _idempotency.StoreAsync(idempotencyKey, Request.Path, requestHash, 200, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> List([FromQuery] string? projectId, [FromQuery] string? status, [FromQuery] int page = 1, [FromQuery] int pageSize = 20)
|
||||||
|
{
|
||||||
|
page = Math.Max(1, page);
|
||||||
|
pageSize = Math.Clamp(pageSize, 1, 100);
|
||||||
|
|
||||||
|
var scope = await _adminAccess.GetScopeAsync(User);
|
||||||
|
if (scope == null)
|
||||||
|
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
|
||||||
|
|
||||||
|
var query = _db.CardKeys.Where(c => c.DeletedAt == null).AsQueryable();
|
||||||
|
if (!string.IsNullOrWhiteSpace(projectId))
|
||||||
|
{
|
||||||
|
if (!scope.CanAccessProject(projectId))
|
||||||
|
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
|
||||||
|
query = query.Where(c => c.ProjectId == projectId);
|
||||||
|
}
|
||||||
|
else if (!scope.IsSuperAdmin && !scope.HasAllProjects)
|
||||||
|
{
|
||||||
|
if (scope.AllowedProjects.Count == 0)
|
||||||
|
{
|
||||||
|
var empty = new PagedResult<CardKey>
|
||||||
|
{
|
||||||
|
Items = new List<CardKey>(),
|
||||||
|
Pagination = new PaginationInfo
|
||||||
|
{
|
||||||
|
Page = page,
|
||||||
|
PageSize = pageSize,
|
||||||
|
Total = 0,
|
||||||
|
TotalPages = 0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return Ok(ApiResponse<PagedResult<CardKey>>.Ok(empty));
|
||||||
|
}
|
||||||
|
var allowed = scope.AllowedProjects.ToList();
|
||||||
|
query = query.Where(c => c.ProjectId != null && allowed.Contains(c.ProjectId));
|
||||||
|
}
|
||||||
|
if (!string.IsNullOrWhiteSpace(status))
|
||||||
|
query = query.Where(c => c.Status == status);
|
||||||
|
|
||||||
|
var total = await query.CountAsync();
|
||||||
|
var items = await query.OrderByDescending(c => c.CreatedAt)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var result = new PagedResult<CardKey>
|
||||||
|
{
|
||||||
|
Items = items,
|
||||||
|
Pagination = new PaginationInfo
|
||||||
|
{
|
||||||
|
Page = page,
|
||||||
|
PageSize = pageSize,
|
||||||
|
Total = total,
|
||||||
|
TotalPages = (int)Math.Ceiling(total / (double)pageSize)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return Ok(ApiResponse<PagedResult<CardKey>>.Ok(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id:int}")]
|
||||||
|
public async Task<IActionResult> Get(int id)
|
||||||
|
{
|
||||||
|
var scope = await _adminAccess.GetScopeAsync(User);
|
||||||
|
if (scope == null)
|
||||||
|
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
|
||||||
|
|
||||||
|
var card = await _db.CardKeys
|
||||||
|
.Include(c => c.Devices)
|
||||||
|
.Include(c => c.Logs)
|
||||||
|
.AsSplitQuery()
|
||||||
|
.FirstOrDefaultAsync(c => c.Id == id && c.DeletedAt == null);
|
||||||
|
|
||||||
|
if (card == null)
|
||||||
|
return NotFound(ApiResponse.Fail(404, "not_found"));
|
||||||
|
if (!scope.CanAccessProject(card.ProjectId))
|
||||||
|
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
|
||||||
|
|
||||||
|
return Ok(ApiResponse<CardKey>.Ok(card));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id:int}/logs")]
|
||||||
|
public async Task<IActionResult> Logs(int id)
|
||||||
|
{
|
||||||
|
var scope = await _adminAccess.GetScopeAsync(User);
|
||||||
|
if (scope == null)
|
||||||
|
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
|
||||||
|
|
||||||
|
var card = await _db.CardKeys.FirstOrDefaultAsync(c => c.Id == id && c.DeletedAt == null);
|
||||||
|
if (card == null)
|
||||||
|
return NotFound(ApiResponse.Fail(404, "not_found"));
|
||||||
|
if (!scope.CanAccessProject(card.ProjectId))
|
||||||
|
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
|
||||||
|
|
||||||
|
var logs = await _db.CardKeyLogs
|
||||||
|
.Where(l => l.CardKeyId == id)
|
||||||
|
.OrderByDescending(l => l.CreatedAt)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
return Ok(ApiResponse<List<CardKeyLog>>.Ok(logs));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id:int}")]
|
||||||
|
public async Task<IActionResult> UpdateNote(int id, [FromBody] CardNoteUpdateRequest request)
|
||||||
|
{
|
||||||
|
var scope = await _adminAccess.GetScopeAsync(User);
|
||||||
|
if (scope == null)
|
||||||
|
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
|
||||||
|
|
||||||
|
var card = await _db.CardKeys.FirstOrDefaultAsync(c => c.Id == id && c.DeletedAt == null);
|
||||||
|
if (card == null)
|
||||||
|
return NotFound(ApiResponse.Fail(404, "not_found"));
|
||||||
|
if (!scope.CanAccessProject(card.ProjectId))
|
||||||
|
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
|
||||||
|
|
||||||
|
card.Note = request.Note;
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return Ok(ApiResponse.Ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:int}/ban")]
|
||||||
|
public async Task<IActionResult> Ban(int id, [FromBody] CardBanRequest request)
|
||||||
|
{
|
||||||
|
var scope = await _adminAccess.GetScopeAsync(User);
|
||||||
|
if (scope == null)
|
||||||
|
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
|
||||||
|
|
||||||
|
var card = await _db.CardKeys.FirstOrDefaultAsync(c => c.Id == id && c.DeletedAt == null);
|
||||||
|
if (card == null)
|
||||||
|
return NotFound(ApiResponse.Fail(404, "not_found"));
|
||||||
|
if (!scope.CanAccessProject(card.ProjectId))
|
||||||
|
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
|
||||||
|
|
||||||
|
if (!User.TryGetUserId(out var adminId))
|
||||||
|
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
|
||||||
|
await _cards.BanAsync(card, request.Reason, adminId, "admin");
|
||||||
|
|
||||||
|
return Ok(ApiResponse.Ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:int}/unban")]
|
||||||
|
public async Task<IActionResult> Unban(int id)
|
||||||
|
{
|
||||||
|
var scope = await _adminAccess.GetScopeAsync(User);
|
||||||
|
if (scope == null)
|
||||||
|
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
|
||||||
|
|
||||||
|
var card = await _db.CardKeys.FirstOrDefaultAsync(c => c.Id == id && c.DeletedAt == null);
|
||||||
|
if (card == null)
|
||||||
|
return NotFound(ApiResponse.Fail(404, "not_found"));
|
||||||
|
if (!scope.CanAccessProject(card.ProjectId))
|
||||||
|
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
|
||||||
|
|
||||||
|
if (!User.TryGetUserId(out var adminId))
|
||||||
|
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
|
||||||
|
await _cards.UnbanAsync(card, adminId, "admin");
|
||||||
|
|
||||||
|
return Ok(ApiResponse.Ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:int}/extend")]
|
||||||
|
public async Task<IActionResult> Extend(int id, [FromBody] CardExtendRequest request)
|
||||||
|
{
|
||||||
|
var scope = await _adminAccess.GetScopeAsync(User);
|
||||||
|
if (scope == null)
|
||||||
|
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
|
||||||
|
|
||||||
|
var renewalEnabled = await _config.GetBoolAsync("feature.card_renewal", true);
|
||||||
|
if (!renewalEnabled)
|
||||||
|
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
|
||||||
|
|
||||||
|
var card = await _db.CardKeys.FirstOrDefaultAsync(c => c.Id == id && c.DeletedAt == null);
|
||||||
|
if (card == null)
|
||||||
|
return NotFound(ApiResponse.Fail(404, "not_found"));
|
||||||
|
if (!scope.CanAccessProject(card.ProjectId))
|
||||||
|
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
|
||||||
|
|
||||||
|
if (!User.TryGetUserId(out var adminId))
|
||||||
|
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
|
||||||
|
await _cards.ExtendAsync(card, request.Days, adminId, "admin");
|
||||||
|
|
||||||
|
return Ok(ApiResponse.Ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:int}/reset-device")]
|
||||||
|
public async Task<IActionResult> ResetDevice(int id)
|
||||||
|
{
|
||||||
|
var scope = await _adminAccess.GetScopeAsync(User);
|
||||||
|
if (scope == null)
|
||||||
|
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
|
||||||
|
|
||||||
|
var card = await _db.CardKeys.FirstOrDefaultAsync(c => c.Id == id && c.DeletedAt == null);
|
||||||
|
if (card == null)
|
||||||
|
return NotFound(ApiResponse.Fail(404, "not_found"));
|
||||||
|
if (!scope.CanAccessProject(card.ProjectId))
|
||||||
|
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
|
||||||
|
|
||||||
|
if (!User.TryGetUserId(out var adminId))
|
||||||
|
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
|
||||||
|
await _cards.ResetDeviceAsync(card, adminId, "admin");
|
||||||
|
|
||||||
|
return Ok(ApiResponse.Ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{id:int}")]
|
||||||
|
public async Task<IActionResult> Delete(int id)
|
||||||
|
{
|
||||||
|
var scope = await _adminAccess.GetScopeAsync(User);
|
||||||
|
if (scope == null)
|
||||||
|
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
|
||||||
|
|
||||||
|
var card = await _db.CardKeys.FirstOrDefaultAsync(c => c.Id == id && c.DeletedAt == null);
|
||||||
|
if (card == null)
|
||||||
|
return NotFound(ApiResponse.Fail(404, "not_found"));
|
||||||
|
if (!scope.CanAccessProject(card.ProjectId))
|
||||||
|
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
|
||||||
|
|
||||||
|
card.DeletedAt = DateTime.UtcNow;
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
return Ok(ApiResponse.Ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("ban-batch")]
|
||||||
|
public async Task<IActionResult> BanBatch([FromBody] CardBatchRequest request)
|
||||||
|
{
|
||||||
|
var scope = await _adminAccess.GetScopeAsync(User);
|
||||||
|
if (scope == null)
|
||||||
|
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
|
||||||
|
|
||||||
|
var cards = await _db.CardKeys.Where(c => request.Ids.Contains(c.Id) && c.DeletedAt == null).ToListAsync();
|
||||||
|
if (!scope.IsSuperAdmin && !scope.HasAllProjects)
|
||||||
|
{
|
||||||
|
if (cards.Any(c => !scope.CanAccessProject(c.ProjectId)))
|
||||||
|
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
|
||||||
|
}
|
||||||
|
if (!User.TryGetUserId(out var adminId))
|
||||||
|
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
|
||||||
|
foreach (var card in cards)
|
||||||
|
await _cards.BanAsync(card, request.Reason, adminId, "admin");
|
||||||
|
|
||||||
|
return Ok(ApiResponse.Ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("unban-batch")]
|
||||||
|
public async Task<IActionResult> UnbanBatch([FromBody] CardBatchRequest request)
|
||||||
|
{
|
||||||
|
var scope = await _adminAccess.GetScopeAsync(User);
|
||||||
|
if (scope == null)
|
||||||
|
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
|
||||||
|
|
||||||
|
var cards = await _db.CardKeys.Where(c => request.Ids.Contains(c.Id) && c.DeletedAt == null).ToListAsync();
|
||||||
|
if (!scope.IsSuperAdmin && !scope.HasAllProjects)
|
||||||
|
{
|
||||||
|
if (cards.Any(c => !scope.CanAccessProject(c.ProjectId)))
|
||||||
|
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
|
||||||
|
}
|
||||||
|
if (!User.TryGetUserId(out var adminId))
|
||||||
|
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
|
||||||
|
foreach (var card in cards)
|
||||||
|
await _cards.UnbanAsync(card, adminId, "admin");
|
||||||
|
|
||||||
|
return Ok(ApiResponse.Ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("batch")]
|
||||||
|
public async Task<IActionResult> DeleteBatch([FromBody] CardBatchRequest request)
|
||||||
|
{
|
||||||
|
var scope = await _adminAccess.GetScopeAsync(User);
|
||||||
|
if (scope == null)
|
||||||
|
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
|
||||||
|
|
||||||
|
var cards = await _db.CardKeys.Where(c => request.Ids.Contains(c.Id) && c.DeletedAt == null).ToListAsync();
|
||||||
|
if (!scope.IsSuperAdmin && !scope.HasAllProjects)
|
||||||
|
{
|
||||||
|
if (cards.Any(c => !scope.CanAccessProject(c.ProjectId)))
|
||||||
|
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
|
||||||
|
}
|
||||||
|
foreach (var card in cards)
|
||||||
|
card.DeletedAt = DateTime.UtcNow;
|
||||||
|
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
return Ok(ApiResponse.Ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("export")]
|
||||||
|
public async Task<IActionResult> Export([FromQuery] string? projectId, [FromQuery] string? format)
|
||||||
|
{
|
||||||
|
var scope = await _adminAccess.GetScopeAsync(User);
|
||||||
|
if (scope == null)
|
||||||
|
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
|
||||||
|
|
||||||
|
var query = _db.CardKeys.Where(c => c.DeletedAt == null);
|
||||||
|
if (!string.IsNullOrWhiteSpace(projectId))
|
||||||
|
{
|
||||||
|
if (!scope.CanAccessProject(projectId))
|
||||||
|
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
|
||||||
|
query = query.Where(c => c.ProjectId == projectId);
|
||||||
|
}
|
||||||
|
else if (!scope.IsSuperAdmin && !scope.HasAllProjects)
|
||||||
|
{
|
||||||
|
if (scope.AllowedProjects.Count == 0)
|
||||||
|
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
|
||||||
|
var allowed = scope.AllowedProjects.ToList();
|
||||||
|
query = query.Where(c => c.ProjectId != null && allowed.Contains(c.ProjectId));
|
||||||
|
}
|
||||||
|
|
||||||
|
var items = await query.OrderByDescending(c => c.CreatedAt).ToListAsync();
|
||||||
|
var ext = string.IsNullOrWhiteSpace(format) ? "csv" : format.ToLowerInvariant();
|
||||||
|
if (ext is "excel" or "xlsx")
|
||||||
|
{
|
||||||
|
using var workbook = new XLWorkbook();
|
||||||
|
var sheet = workbook.Worksheets.Add("CardKeys");
|
||||||
|
sheet.Cell(1, 1).Value = "keyCode";
|
||||||
|
sheet.Cell(1, 2).Value = "cardType";
|
||||||
|
sheet.Cell(1, 3).Value = "status";
|
||||||
|
sheet.Cell(1, 4).Value = "expireTime";
|
||||||
|
sheet.Cell(1, 5).Value = "note";
|
||||||
|
var row = 2;
|
||||||
|
foreach (var card in items)
|
||||||
|
{
|
||||||
|
sheet.Cell(row, 1).Value = card.KeyCode;
|
||||||
|
sheet.Cell(row, 2).Value = card.CardType;
|
||||||
|
sheet.Cell(row, 3).Value = card.Status;
|
||||||
|
sheet.Cell(row, 4).Value = card.ExpireTime?.ToString("O") ?? string.Empty;
|
||||||
|
sheet.Cell(row, 5).Value = card.Note ?? string.Empty;
|
||||||
|
row++;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var ms = new MemoryStream();
|
||||||
|
workbook.SaveAs(ms);
|
||||||
|
var bytes = ms.ToArray();
|
||||||
|
return File(bytes, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "cardkeys.xlsx");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.AppendLine("keyCode,cardType,status,expireTime,note");
|
||||||
|
foreach (var card in items)
|
||||||
|
{
|
||||||
|
sb.AppendLine($"{card.KeyCode},{card.CardType},{card.Status},{card.ExpireTime:O},{card.Note}");
|
||||||
|
}
|
||||||
|
|
||||||
|
var bytes = Encoding.UTF8.GetBytes(sb.ToString());
|
||||||
|
var contentType = ext == "txt" ? "text/plain" : "text/csv";
|
||||||
|
var fileName = ext == "txt" ? "cardkeys.txt" : "cardkeys.csv";
|
||||||
|
return File(bytes, contentType, fileName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("import")]
|
||||||
|
public async Task<IActionResult> Import([FromForm] IFormFile file, [FromForm] string projectId)
|
||||||
|
{
|
||||||
|
var scope = await _adminAccess.GetScopeAsync(User);
|
||||||
|
if (scope == null)
|
||||||
|
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
|
||||||
|
|
||||||
|
var project = await _db.Projects.FirstOrDefaultAsync(p => p.ProjectId == projectId);
|
||||||
|
if (project == null)
|
||||||
|
return NotFound(ApiResponse.Fail(404, "not_found"));
|
||||||
|
if (!scope.CanAccessProject(project.ProjectId))
|
||||||
|
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
|
||||||
|
if (!project.IsEnabled)
|
||||||
|
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(1011, "project_disabled"));
|
||||||
|
|
||||||
|
var pricing = await _db.ProjectPricing
|
||||||
|
.Where(p => p.ProjectId == projectId && p.IsEnabled)
|
||||||
|
.Select(p => new { p.CardType, p.DurationDays })
|
||||||
|
.ToListAsync();
|
||||||
|
var durationByType = pricing
|
||||||
|
.GroupBy(p => p.CardType.Trim().ToLowerInvariant())
|
||||||
|
.ToDictionary(g => g.Key, g => g.Select(x => x.DurationDays).Distinct().ToList());
|
||||||
|
|
||||||
|
(string cardType, int durationDays) ResolveCardMeta(string keyCode, string? rawCardType)
|
||||||
|
{
|
||||||
|
var normalizedType = string.IsNullOrWhiteSpace(rawCardType)
|
||||||
|
? null
|
||||||
|
: rawCardType.Trim().ToLowerInvariant();
|
||||||
|
|
||||||
|
var decodedDuration = 0;
|
||||||
|
var decodedType = (string?)null;
|
||||||
|
if (CardKeyGenerator.TryDecode(keyCode, out var keyType, out var keyDuration))
|
||||||
|
{
|
||||||
|
decodedDuration = keyDuration;
|
||||||
|
decodedType = CardDefaults.ResolveCardType(keyType);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(normalizedType) && !string.IsNullOrWhiteSpace(decodedType))
|
||||||
|
normalizedType = decodedType;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(normalizedType))
|
||||||
|
normalizedType = "unknown";
|
||||||
|
|
||||||
|
if (decodedDuration > 0)
|
||||||
|
return (normalizedType, decodedDuration);
|
||||||
|
|
||||||
|
if (durationByType.TryGetValue(normalizedType, out var durations) && durations.Count == 1)
|
||||||
|
return (normalizedType, durations[0]);
|
||||||
|
|
||||||
|
return (normalizedType, CardDefaults.ResolveDurationDays(normalizedType));
|
||||||
|
}
|
||||||
|
|
||||||
|
var successes = 0;
|
||||||
|
var failures = new List<object>();
|
||||||
|
var extension = Path.GetExtension(file.FileName);
|
||||||
|
if (string.Equals(extension, ".xlsx", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
using var workbook = new XLWorkbook(file.OpenReadStream());
|
||||||
|
var sheet = workbook.Worksheets.First();
|
||||||
|
var rowIndex = 1;
|
||||||
|
foreach (var row in sheet.RowsUsed())
|
||||||
|
{
|
||||||
|
var keyCell = row.Cell(1).GetString();
|
||||||
|
if (rowIndex == 1 && keyCell.Equals("keyCode", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
rowIndex++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(keyCell))
|
||||||
|
{
|
||||||
|
rowIndex++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await _db.CardKeys.AnyAsync(c => c.KeyCode == keyCell))
|
||||||
|
{
|
||||||
|
failures.Add(new { row = rowIndex, keyCode = keyCell, reason = "exists" });
|
||||||
|
rowIndex++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var cardType = row.Cell(2).GetString();
|
||||||
|
var status = row.Cell(3).GetString();
|
||||||
|
var note = row.Cell(5).GetString();
|
||||||
|
var meta = ResolveCardMeta(keyCell, cardType);
|
||||||
|
|
||||||
|
var card = new CardKey
|
||||||
|
{
|
||||||
|
ProjectId = projectId,
|
||||||
|
KeyCode = keyCell.Trim(),
|
||||||
|
CardType = meta.cardType,
|
||||||
|
DurationDays = meta.durationDays,
|
||||||
|
Status = string.IsNullOrWhiteSpace(status) ? "unused" : status.Trim(),
|
||||||
|
Note = string.IsNullOrWhiteSpace(note) ? null : note.Trim(),
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
_db.CardKeys.Add(card);
|
||||||
|
successes++;
|
||||||
|
rowIndex++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
using var reader = new StreamReader(file.OpenReadStream());
|
||||||
|
var lineNum = 0;
|
||||||
|
while (!reader.EndOfStream)
|
||||||
|
{
|
||||||
|
var line = await reader.ReadLineAsync();
|
||||||
|
lineNum++;
|
||||||
|
if (string.IsNullOrWhiteSpace(line))
|
||||||
|
continue;
|
||||||
|
if (lineNum == 1 && line.StartsWith("keyCode", StringComparison.OrdinalIgnoreCase))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var parts = line.Split(',');
|
||||||
|
if (parts.Length == 0)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var keyCode = parts[0].Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(keyCode))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (await _db.CardKeys.AnyAsync(c => c.KeyCode == keyCode))
|
||||||
|
{
|
||||||
|
failures.Add(new { row = lineNum, keyCode, reason = "exists" });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var cardType = parts.Length > 1 ? parts[1].Trim() : string.Empty;
|
||||||
|
var meta = ResolveCardMeta(keyCode, cardType);
|
||||||
|
var card = new CardKey
|
||||||
|
{
|
||||||
|
ProjectId = projectId,
|
||||||
|
KeyCode = keyCode,
|
||||||
|
CardType = meta.cardType,
|
||||||
|
DurationDays = meta.durationDays,
|
||||||
|
Status = parts.Length > 2 ? parts[2].Trim() : "unused",
|
||||||
|
Note = parts.Length > 4 ? parts[4].Trim() : null,
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
_db.CardKeys.Add(card);
|
||||||
|
successes++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return Ok(ApiResponse<object>.Ok(new
|
||||||
|
{
|
||||||
|
total = successes + failures.Count,
|
||||||
|
success = successes,
|
||||||
|
failed = failures.Count,
|
||||||
|
errors = failures
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
using License.Api.Data;
|
||||||
|
using License.Api.DTOs;
|
||||||
|
using License.Api.Models;
|
||||||
|
using License.Api.Services;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace License.Api.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Authorize(Policy = "Admin")]
|
||||||
|
[Route("api/admin/devices")]
|
||||||
|
public class AdminDevicesController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
private readonly AdminAccessService _adminAccess;
|
||||||
|
|
||||||
|
public AdminDevicesController(AppDbContext db, AdminAccessService adminAccess)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_adminAccess = adminAccess;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> List([FromQuery] string? projectId, [FromQuery] bool? isActive)
|
||||||
|
{
|
||||||
|
var scope = await _adminAccess.GetScopeAsync(User);
|
||||||
|
if (scope == null)
|
||||||
|
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
|
||||||
|
|
||||||
|
var query = _db.Devices.Where(d => d.DeletedAt == null).AsQueryable();
|
||||||
|
if (isActive.HasValue)
|
||||||
|
query = query.Where(d => d.IsActive == isActive.Value);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(projectId))
|
||||||
|
{
|
||||||
|
if (!scope.CanAccessProject(projectId))
|
||||||
|
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
|
||||||
|
var cardIds = await _db.CardKeys.Where(c => c.ProjectId == projectId).Select(c => c.Id).ToListAsync();
|
||||||
|
query = query.Where(d => cardIds.Contains(d.CardKeyId));
|
||||||
|
}
|
||||||
|
else if (!scope.IsSuperAdmin && !scope.HasAllProjects)
|
||||||
|
{
|
||||||
|
if (scope.AllowedProjects.Count == 0)
|
||||||
|
return Ok(ApiResponse<List<Device>>.Ok(new List<Device>()));
|
||||||
|
var allowed = scope.AllowedProjects.ToList();
|
||||||
|
var cardIds = await _db.CardKeys
|
||||||
|
.Where(c => c.ProjectId != null && allowed.Contains(c.ProjectId))
|
||||||
|
.Select(c => c.Id)
|
||||||
|
.ToListAsync();
|
||||||
|
query = query.Where(d => cardIds.Contains(d.CardKeyId));
|
||||||
|
}
|
||||||
|
|
||||||
|
var items = await query.OrderByDescending(d => d.LastHeartbeat).Take(200).ToListAsync();
|
||||||
|
return Ok(ApiResponse<List<Device>>.Ok(items));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{id:int}")]
|
||||||
|
public async Task<IActionResult> Unbind(int id)
|
||||||
|
{
|
||||||
|
var scope = await _adminAccess.GetScopeAsync(User);
|
||||||
|
if (scope == null)
|
||||||
|
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
|
||||||
|
|
||||||
|
var device = await _db.Devices.FindAsync(id);
|
||||||
|
if (device == null)
|
||||||
|
return NotFound(ApiResponse.Fail(404, "not_found"));
|
||||||
|
|
||||||
|
if (!scope.IsSuperAdmin && !scope.HasAllProjects)
|
||||||
|
{
|
||||||
|
var projectId = await _db.CardKeys.Where(c => c.Id == device.CardKeyId)
|
||||||
|
.Select(c => c.ProjectId)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
if (!scope.CanAccessProject(projectId))
|
||||||
|
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
|
||||||
|
}
|
||||||
|
|
||||||
|
device.DeletedAt = DateTime.UtcNow;
|
||||||
|
device.IsActive = false;
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return Ok(ApiResponse.Ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:int}/kick")]
|
||||||
|
public async Task<IActionResult> Kick(int id)
|
||||||
|
{
|
||||||
|
var scope = await _adminAccess.GetScopeAsync(User);
|
||||||
|
if (scope == null)
|
||||||
|
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
|
||||||
|
|
||||||
|
var device = await _db.Devices.FindAsync(id);
|
||||||
|
if (device == null)
|
||||||
|
return NotFound(ApiResponse.Fail(404, "not_found"));
|
||||||
|
|
||||||
|
if (!scope.IsSuperAdmin && !scope.HasAllProjects)
|
||||||
|
{
|
||||||
|
var projectId = await _db.CardKeys.Where(c => c.Id == device.CardKeyId)
|
||||||
|
.Select(c => c.ProjectId)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
if (!scope.CanAccessProject(projectId))
|
||||||
|
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
|
||||||
|
}
|
||||||
|
|
||||||
|
device.IsActive = false;
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return Ok(ApiResponse.Ok());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
using License.Api.Data;
|
||||||
|
using License.Api.DTOs;
|
||||||
|
using License.Api.Models;
|
||||||
|
using License.Api.Services;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace License.Api.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Authorize(Policy = "Admin")]
|
||||||
|
[Route("api/admin/logs")]
|
||||||
|
public class AdminLogsController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
private readonly AdminAccessService _adminAccess;
|
||||||
|
|
||||||
|
public AdminLogsController(AppDbContext db, AdminAccessService adminAccess)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_adminAccess = adminAccess;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> List([FromQuery] string? action, [FromQuery] int page = 1, [FromQuery] int pageSize = 20)
|
||||||
|
{
|
||||||
|
page = Math.Max(1, page);
|
||||||
|
pageSize = Math.Clamp(pageSize, 1, 100);
|
||||||
|
|
||||||
|
var scope = await _adminAccess.GetScopeAsync(User);
|
||||||
|
if (scope == null)
|
||||||
|
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
|
||||||
|
|
||||||
|
var query = _db.AccessLogs.AsQueryable();
|
||||||
|
if (!string.IsNullOrWhiteSpace(action))
|
||||||
|
query = query.Where(l => l.Action == action);
|
||||||
|
if (!scope.IsSuperAdmin && !scope.HasAllProjects)
|
||||||
|
{
|
||||||
|
if (scope.AllowedProjects.Count == 0)
|
||||||
|
{
|
||||||
|
var empty = new PagedResult<AccessLog>
|
||||||
|
{
|
||||||
|
Items = new List<AccessLog>(),
|
||||||
|
Pagination = new PaginationInfo
|
||||||
|
{
|
||||||
|
Page = page,
|
||||||
|
PageSize = pageSize,
|
||||||
|
Total = 0,
|
||||||
|
TotalPages = 0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return Ok(ApiResponse<PagedResult<AccessLog>>.Ok(empty));
|
||||||
|
}
|
||||||
|
var allowed = scope.AllowedProjects.ToList();
|
||||||
|
query = query.Where(l => l.ProjectId != null && allowed.Contains(l.ProjectId));
|
||||||
|
}
|
||||||
|
|
||||||
|
var total = await query.CountAsync();
|
||||||
|
var items = await query.OrderByDescending(l => l.CreatedAt)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var result = new PagedResult<AccessLog>
|
||||||
|
{
|
||||||
|
Items = items,
|
||||||
|
Pagination = new PaginationInfo
|
||||||
|
{
|
||||||
|
Page = page,
|
||||||
|
PageSize = pageSize,
|
||||||
|
Total = total,
|
||||||
|
TotalPages = (int)Math.Ceiling(total / (double)pageSize)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return Ok(ApiResponse<PagedResult<AccessLog>>.Ok(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id:int}")]
|
||||||
|
public async Task<IActionResult> Get(int id)
|
||||||
|
{
|
||||||
|
var scope = await _adminAccess.GetScopeAsync(User);
|
||||||
|
if (scope == null)
|
||||||
|
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
|
||||||
|
|
||||||
|
var log = await _db.AccessLogs.FindAsync(id);
|
||||||
|
if (log == null)
|
||||||
|
return NotFound(ApiResponse.Fail(404, "not_found"));
|
||||||
|
if (!scope.IsSuperAdmin && !scope.HasAllProjects && !scope.CanAccessProject(log.ProjectId))
|
||||||
|
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
|
||||||
|
|
||||||
|
return Ok(ApiResponse<AccessLog>.Ok(log));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,469 @@
|
|||||||
|
using License.Api.Data;
|
||||||
|
using License.Api.DTOs;
|
||||||
|
using License.Api.Models;
|
||||||
|
using License.Api.Services;
|
||||||
|
using License.Api.Utils;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace License.Api.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Authorize(Policy = "Admin")]
|
||||||
|
[Route("api/admin/projects")]
|
||||||
|
public class AdminProjectsController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
private readonly SoftwareService _software;
|
||||||
|
private readonly AdminAccessService _adminAccess;
|
||||||
|
|
||||||
|
public AdminProjectsController(AppDbContext db, SoftwareService software, AdminAccessService adminAccess)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_software = software;
|
||||||
|
_adminAccess = adminAccess;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<IActionResult> Create([FromBody] ProjectCreateRequest request)
|
||||||
|
{
|
||||||
|
var scope = await _adminAccess.GetScopeAsync(User);
|
||||||
|
if (scope == null)
|
||||||
|
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
|
||||||
|
|
||||||
|
var project = new Project
|
||||||
|
{
|
||||||
|
ProjectId = RandomIdGenerator.GenerateProjectId(),
|
||||||
|
ProjectKey = RandomIdGenerator.GenerateKey(32),
|
||||||
|
ProjectSecret = RandomIdGenerator.GenerateSecret(48),
|
||||||
|
Name = request.Name,
|
||||||
|
Description = request.Description,
|
||||||
|
MaxDevices = request.MaxDevices,
|
||||||
|
AutoUpdate = request.AutoUpdate,
|
||||||
|
IconUrl = request.IconUrl,
|
||||||
|
CreatedBy = scope.Admin.Id,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
UpdatedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
_db.Projects.Add(project);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
if (!scope.IsSuperAdmin && !scope.HasAllProjects)
|
||||||
|
{
|
||||||
|
scope.AddProject(project.ProjectId);
|
||||||
|
scope.Admin.Permissions = scope.SerializePermissions();
|
||||||
|
scope.Admin.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
var data = new
|
||||||
|
{
|
||||||
|
id = project.Id,
|
||||||
|
projectId = project.ProjectId,
|
||||||
|
projectKey = project.ProjectKey,
|
||||||
|
projectSecret = project.ProjectSecret
|
||||||
|
};
|
||||||
|
|
||||||
|
return Ok(ApiResponse<object>.Ok(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> List([FromQuery] int page = 1, [FromQuery] int pageSize = 20)
|
||||||
|
{
|
||||||
|
page = Math.Max(1, page);
|
||||||
|
pageSize = Math.Clamp(pageSize, 1, 100);
|
||||||
|
|
||||||
|
var scope = await _adminAccess.GetScopeAsync(User);
|
||||||
|
if (scope == null)
|
||||||
|
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
|
||||||
|
|
||||||
|
var query = _db.Projects.AsQueryable();
|
||||||
|
if (!scope.IsSuperAdmin && !scope.HasAllProjects)
|
||||||
|
{
|
||||||
|
if (scope.AllowedProjects.Count == 0)
|
||||||
|
{
|
||||||
|
var empty = new PagedResult<ProjectListItem>
|
||||||
|
{
|
||||||
|
Items = new List<ProjectListItem>(),
|
||||||
|
Pagination = new PaginationInfo
|
||||||
|
{
|
||||||
|
Page = page,
|
||||||
|
PageSize = pageSize,
|
||||||
|
Total = 0,
|
||||||
|
TotalPages = 0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return Ok(ApiResponse<PagedResult<ProjectListItem>>.Ok(empty));
|
||||||
|
}
|
||||||
|
var allowed = scope.AllowedProjects.ToList();
|
||||||
|
query = query.Where(p => allowed.Contains(p.ProjectId));
|
||||||
|
}
|
||||||
|
|
||||||
|
var total = await query.CountAsync();
|
||||||
|
var items = await query.OrderByDescending(p => p.CreatedAt)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
|
.Select(p => new ProjectListItem
|
||||||
|
{
|
||||||
|
Id = p.Id,
|
||||||
|
ProjectId = p.ProjectId,
|
||||||
|
Name = p.Name,
|
||||||
|
Description = p.Description,
|
||||||
|
IconUrl = p.IconUrl,
|
||||||
|
MaxDevices = p.MaxDevices,
|
||||||
|
AutoUpdate = p.AutoUpdate,
|
||||||
|
IsEnabled = p.IsEnabled,
|
||||||
|
CreatedAt = p.CreatedAt
|
||||||
|
})
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var result = new PagedResult<ProjectListItem>
|
||||||
|
{
|
||||||
|
Items = items,
|
||||||
|
Pagination = new PaginationInfo
|
||||||
|
{
|
||||||
|
Page = page,
|
||||||
|
PageSize = pageSize,
|
||||||
|
Total = total,
|
||||||
|
TotalPages = (int)Math.Ceiling(total / (double)pageSize)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return Ok(ApiResponse<PagedResult<ProjectListItem>>.Ok(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id:int}")]
|
||||||
|
public async Task<IActionResult> Get(int id)
|
||||||
|
{
|
||||||
|
var scope = await _adminAccess.GetScopeAsync(User);
|
||||||
|
if (scope == null)
|
||||||
|
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
|
||||||
|
|
||||||
|
var project = await _db.Projects.FindAsync(id);
|
||||||
|
if (project == null)
|
||||||
|
return NotFound(ApiResponse.Fail(404, "not_found"));
|
||||||
|
if (!scope.CanAccessProject(project.ProjectId))
|
||||||
|
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
|
||||||
|
|
||||||
|
var data = new ProjectDetailResponse
|
||||||
|
{
|
||||||
|
Id = project.Id,
|
||||||
|
ProjectId = project.ProjectId,
|
||||||
|
ProjectKey = project.ProjectKey,
|
||||||
|
Name = project.Name,
|
||||||
|
Description = project.Description,
|
||||||
|
IconUrl = project.IconUrl,
|
||||||
|
MaxDevices = project.MaxDevices,
|
||||||
|
AutoUpdate = project.AutoUpdate,
|
||||||
|
IsEnabled = project.IsEnabled,
|
||||||
|
CreatedAt = project.CreatedAt,
|
||||||
|
UpdatedAt = project.UpdatedAt
|
||||||
|
};
|
||||||
|
|
||||||
|
return Ok(ApiResponse<ProjectDetailResponse>.Ok(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id:int}")]
|
||||||
|
public async Task<IActionResult> Update(int id, [FromBody] ProjectUpdateRequest request)
|
||||||
|
{
|
||||||
|
var scope = await _adminAccess.GetScopeAsync(User);
|
||||||
|
if (scope == null)
|
||||||
|
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
|
||||||
|
|
||||||
|
var project = await _db.Projects.FindAsync(id);
|
||||||
|
if (project == null)
|
||||||
|
return NotFound(ApiResponse.Fail(404, "not_found"));
|
||||||
|
if (!scope.CanAccessProject(project.ProjectId))
|
||||||
|
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.Name))
|
||||||
|
project.Name = request.Name;
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.Description))
|
||||||
|
project.Description = request.Description;
|
||||||
|
if (request.MaxDevices.HasValue)
|
||||||
|
project.MaxDevices = request.MaxDevices.Value;
|
||||||
|
if (request.AutoUpdate.HasValue)
|
||||||
|
project.AutoUpdate = request.AutoUpdate.Value;
|
||||||
|
if (request.IsEnabled.HasValue)
|
||||||
|
project.IsEnabled = request.IsEnabled.Value;
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.IconUrl))
|
||||||
|
project.IconUrl = request.IconUrl;
|
||||||
|
|
||||||
|
project.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return Ok(ApiResponse.Ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{id:int}")]
|
||||||
|
public async Task<IActionResult> Delete(int id)
|
||||||
|
{
|
||||||
|
var scope = await _adminAccess.GetScopeAsync(User);
|
||||||
|
if (scope == null)
|
||||||
|
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
|
||||||
|
|
||||||
|
var project = await _db.Projects.FindAsync(id);
|
||||||
|
if (project == null)
|
||||||
|
return NotFound(ApiResponse.Fail(404, "not_found"));
|
||||||
|
if (!scope.CanAccessProject(project.ProjectId))
|
||||||
|
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
|
||||||
|
|
||||||
|
project.IsEnabled = false;
|
||||||
|
project.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return Ok(ApiResponse.Ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id:int}/stats")]
|
||||||
|
public async Task<IActionResult> Stats(int id)
|
||||||
|
{
|
||||||
|
var scope = await _adminAccess.GetScopeAsync(User);
|
||||||
|
if (scope == null)
|
||||||
|
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
|
||||||
|
|
||||||
|
var project = await _db.Projects.FindAsync(id);
|
||||||
|
if (project == null)
|
||||||
|
return NotFound(ApiResponse.Fail(404, "not_found"));
|
||||||
|
if (!scope.CanAccessProject(project.ProjectId))
|
||||||
|
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
|
||||||
|
|
||||||
|
var stats = await _db.Statistics
|
||||||
|
.Where(s => s.ProjectId == project.ProjectId)
|
||||||
|
.OrderByDescending(s => s.Date)
|
||||||
|
.Take(30)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
return Ok(ApiResponse<object>.Ok(stats));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id:int}/docs")]
|
||||||
|
public async Task<IActionResult> GetDocs(int id)
|
||||||
|
{
|
||||||
|
var scope = await _adminAccess.GetScopeAsync(User);
|
||||||
|
if (scope == null)
|
||||||
|
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
|
||||||
|
|
||||||
|
var project = await _db.Projects.FindAsync(id);
|
||||||
|
if (project == null)
|
||||||
|
return NotFound(ApiResponse.Fail(404, "not_found"));
|
||||||
|
if (!scope.CanAccessProject(project.ProjectId))
|
||||||
|
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
|
||||||
|
|
||||||
|
return Ok(ApiResponse<object>.Ok(new { content = project.DocsContent ?? string.Empty }));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id:int}/docs")]
|
||||||
|
public async Task<IActionResult> UpdateDocs(int id, [FromBody] ProjectDocUpdateRequest request)
|
||||||
|
{
|
||||||
|
var scope = await _adminAccess.GetScopeAsync(User);
|
||||||
|
if (scope == null)
|
||||||
|
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
|
||||||
|
|
||||||
|
var project = await _db.Projects.FindAsync(id);
|
||||||
|
if (project == null)
|
||||||
|
return NotFound(ApiResponse.Fail(404, "not_found"));
|
||||||
|
if (!scope.CanAccessProject(project.ProjectId))
|
||||||
|
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
|
||||||
|
|
||||||
|
project.DocsContent = request.Content ?? string.Empty;
|
||||||
|
project.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return Ok(ApiResponse.Ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id:int}/pricing")]
|
||||||
|
public async Task<IActionResult> GetPricing(int id)
|
||||||
|
{
|
||||||
|
var scope = await _adminAccess.GetScopeAsync(User);
|
||||||
|
if (scope == null)
|
||||||
|
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
|
||||||
|
|
||||||
|
var project = await _db.Projects.FindAsync(id);
|
||||||
|
if (project == null)
|
||||||
|
return NotFound(ApiResponse.Fail(404, "not_found"));
|
||||||
|
if (!scope.CanAccessProject(project.ProjectId))
|
||||||
|
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
|
||||||
|
|
||||||
|
var items = await _db.ProjectPricing.Where(p => p.ProjectId == project.ProjectId).ToListAsync();
|
||||||
|
return Ok(ApiResponse<List<ProjectPricing>>.Ok(items));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:int}/pricing")]
|
||||||
|
public async Task<IActionResult> CreatePricing(int id, [FromBody] ProjectPricingCreateRequest request)
|
||||||
|
{
|
||||||
|
var scope = await _adminAccess.GetScopeAsync(User);
|
||||||
|
if (scope == null)
|
||||||
|
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
|
||||||
|
|
||||||
|
var project = await _db.Projects.FindAsync(id);
|
||||||
|
if (project == null)
|
||||||
|
return NotFound(ApiResponse.Fail(404, "not_found"));
|
||||||
|
if (!scope.CanAccessProject(project.ProjectId))
|
||||||
|
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
|
||||||
|
|
||||||
|
var pricing = new ProjectPricing
|
||||||
|
{
|
||||||
|
ProjectId = project.ProjectId,
|
||||||
|
CardType = request.CardType,
|
||||||
|
DurationDays = request.DurationDays,
|
||||||
|
OriginalPrice = request.OriginalPrice,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
UpdatedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
_db.ProjectPricing.Add(pricing);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return Ok(ApiResponse<ProjectPricing>.Ok(pricing));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id:int}/pricing/{priceId:int}")]
|
||||||
|
public async Task<IActionResult> UpdatePricing(int id, int priceId, [FromBody] ProjectPricingUpdateRequest request)
|
||||||
|
{
|
||||||
|
var scope = await _adminAccess.GetScopeAsync(User);
|
||||||
|
if (scope == null)
|
||||||
|
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
|
||||||
|
|
||||||
|
var pricing = await _db.ProjectPricing.FindAsync(priceId);
|
||||||
|
if (pricing == null)
|
||||||
|
return NotFound(ApiResponse.Fail(404, "not_found"));
|
||||||
|
if (!scope.CanAccessProject(pricing.ProjectId))
|
||||||
|
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
|
||||||
|
|
||||||
|
if (request.OriginalPrice.HasValue)
|
||||||
|
pricing.OriginalPrice = request.OriginalPrice.Value;
|
||||||
|
if (request.IsEnabled.HasValue)
|
||||||
|
pricing.IsEnabled = request.IsEnabled.Value;
|
||||||
|
|
||||||
|
pricing.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return Ok(ApiResponse.Ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{id:int}/pricing/{priceId:int}")]
|
||||||
|
public async Task<IActionResult> DeletePricing(int id, int priceId)
|
||||||
|
{
|
||||||
|
var scope = await _adminAccess.GetScopeAsync(User);
|
||||||
|
if (scope == null)
|
||||||
|
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
|
||||||
|
|
||||||
|
var pricing = await _db.ProjectPricing.FindAsync(priceId);
|
||||||
|
if (pricing == null)
|
||||||
|
return NotFound(ApiResponse.Fail(404, "not_found"));
|
||||||
|
if (!scope.CanAccessProject(pricing.ProjectId))
|
||||||
|
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
|
||||||
|
|
||||||
|
_db.ProjectPricing.Remove(pricing);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return Ok(ApiResponse.Ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id:int}/versions")]
|
||||||
|
public async Task<IActionResult> Versions(int id)
|
||||||
|
{
|
||||||
|
var scope = await _adminAccess.GetScopeAsync(User);
|
||||||
|
if (scope == null)
|
||||||
|
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
|
||||||
|
|
||||||
|
var project = await _db.Projects.FindAsync(id);
|
||||||
|
if (project == null)
|
||||||
|
return NotFound(ApiResponse.Fail(404, "not_found"));
|
||||||
|
if (!scope.CanAccessProject(project.ProjectId))
|
||||||
|
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
|
||||||
|
|
||||||
|
var items = await _db.SoftwareVersions
|
||||||
|
.Where(v => v.ProjectId == project.ProjectId)
|
||||||
|
.OrderByDescending(v => v.PublishedAt)
|
||||||
|
.Select(v => new SoftwareVersionListItem
|
||||||
|
{
|
||||||
|
Id = v.Id,
|
||||||
|
Version = v.Version,
|
||||||
|
FileSize = v.FileSize,
|
||||||
|
FileHash = v.FileHash,
|
||||||
|
IsStable = v.IsStable,
|
||||||
|
IsForceUpdate = v.IsForceUpdate,
|
||||||
|
Changelog = v.Changelog,
|
||||||
|
PublishedAt = v.PublishedAt
|
||||||
|
})
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
return Ok(ApiResponse<List<SoftwareVersionListItem>>.Ok(items));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:int}/versions")]
|
||||||
|
[RequestSizeLimit(1024L * 1024L * 500L)]
|
||||||
|
public async Task<IActionResult> UploadVersion(int id, [FromForm] string version, [FromForm] IFormFile file, [FromForm] string? changelog, [FromForm] bool isForceUpdate = false, [FromForm] bool isStable = true)
|
||||||
|
{
|
||||||
|
var scope = await _adminAccess.GetScopeAsync(User);
|
||||||
|
if (scope == null)
|
||||||
|
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
|
||||||
|
|
||||||
|
var project = await _db.Projects.FindAsync(id);
|
||||||
|
if (project == null)
|
||||||
|
return NotFound(ApiResponse.Fail(404, "not_found"));
|
||||||
|
if (!scope.CanAccessProject(project.ProjectId))
|
||||||
|
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
|
||||||
|
|
||||||
|
if (!User.TryGetUserId(out var adminId))
|
||||||
|
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
|
||||||
|
var entity = await _software.CreateVersionAsync(project.ProjectId, version, file, changelog, isForceUpdate, isStable, adminId);
|
||||||
|
|
||||||
|
var data = new
|
||||||
|
{
|
||||||
|
versionId = entity.Id,
|
||||||
|
version = entity.Version,
|
||||||
|
fileUrl = entity.FileUrl,
|
||||||
|
fileHash = entity.FileHash,
|
||||||
|
encryptionKey = entity.EncryptionKey
|
||||||
|
};
|
||||||
|
|
||||||
|
return Ok(ApiResponse<object>.Ok(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id:int}/versions/{versionId:int}")]
|
||||||
|
public async Task<IActionResult> UpdateVersion(int id, int versionId, [FromBody] SoftwareVersionUpdateRequest update)
|
||||||
|
{
|
||||||
|
var scope = await _adminAccess.GetScopeAsync(User);
|
||||||
|
if (scope == null)
|
||||||
|
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
|
||||||
|
|
||||||
|
var version = await _db.SoftwareVersions.FindAsync(versionId);
|
||||||
|
if (version == null)
|
||||||
|
return NotFound(ApiResponse.Fail(404, "not_found"));
|
||||||
|
if (!scope.CanAccessProject(version.ProjectId))
|
||||||
|
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
|
||||||
|
|
||||||
|
version.IsForceUpdate = update.IsForceUpdate;
|
||||||
|
version.IsStable = update.IsStable;
|
||||||
|
version.Changelog = update.Changelog;
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return Ok(ApiResponse.Ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{id:int}/versions/{versionId:int}")]
|
||||||
|
public async Task<IActionResult> DeleteVersion(int id, int versionId)
|
||||||
|
{
|
||||||
|
var scope = await _adminAccess.GetScopeAsync(User);
|
||||||
|
if (scope == null)
|
||||||
|
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
|
||||||
|
|
||||||
|
var version = await _db.SoftwareVersions.FindAsync(versionId);
|
||||||
|
if (version == null)
|
||||||
|
return NotFound(ApiResponse.Fail(404, "not_found"));
|
||||||
|
if (!scope.CanAccessProject(version.ProjectId))
|
||||||
|
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
|
||||||
|
|
||||||
|
_db.SoftwareVersions.Remove(version);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return Ok(ApiResponse.Ok());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
using License.Api.Data;
|
||||||
|
using License.Api.DTOs;
|
||||||
|
using License.Api.Models;
|
||||||
|
using License.Api.Services;
|
||||||
|
using License.Api.Utils;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace License.Api.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Authorize(Policy = "SuperAdmin")]
|
||||||
|
[Route("api/admin")]
|
||||||
|
public class AdminSettingsController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
private readonly ConfigService _configService;
|
||||||
|
|
||||||
|
public AdminSettingsController(AppDbContext db, ConfigService configService)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_configService = configService;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("settings")]
|
||||||
|
public async Task<IActionResult> GetSettings()
|
||||||
|
{
|
||||||
|
var configs = await _db.SystemConfigs.OrderBy(c => c.Category).ToListAsync();
|
||||||
|
return Ok(ApiResponse<List<SystemConfig>>.Ok(configs));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("settings")]
|
||||||
|
public async Task<IActionResult> UpdateSettings([FromBody] List<SystemConfig> configs)
|
||||||
|
{
|
||||||
|
foreach (var config in configs)
|
||||||
|
{
|
||||||
|
var existing = await _db.SystemConfigs.FirstOrDefaultAsync(c => c.ConfigKey == config.ConfigKey);
|
||||||
|
if (existing == null)
|
||||||
|
{
|
||||||
|
config.UpdatedAt = DateTime.UtcNow;
|
||||||
|
_db.SystemConfigs.Add(config);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
existing.ConfigValue = config.ConfigValue;
|
||||||
|
existing.ValueType = config.ValueType;
|
||||||
|
existing.Category = config.Category;
|
||||||
|
existing.DisplayName = config.DisplayName;
|
||||||
|
existing.Description = config.Description;
|
||||||
|
existing.Options = config.Options;
|
||||||
|
existing.IsPublic = config.IsPublic;
|
||||||
|
existing.UpdatedAt = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
_configService.Invalidate(config.ConfigKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
return Ok(ApiResponse.Ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("admins")]
|
||||||
|
public async Task<IActionResult> Admins()
|
||||||
|
{
|
||||||
|
var items = await _db.Admins
|
||||||
|
.OrderBy(a => a.Id)
|
||||||
|
.Select(a => new
|
||||||
|
{
|
||||||
|
id = a.Id,
|
||||||
|
username = a.Username,
|
||||||
|
email = a.Email,
|
||||||
|
role = a.Role,
|
||||||
|
permissions = a.Permissions,
|
||||||
|
status = a.Status,
|
||||||
|
lastLoginAt = a.LastLoginAt,
|
||||||
|
createdAt = a.CreatedAt
|
||||||
|
})
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var data = items.Select(a =>
|
||||||
|
{
|
||||||
|
var permissions = ResolvePermissions(a.role, a.permissions);
|
||||||
|
return new
|
||||||
|
{
|
||||||
|
a.id,
|
||||||
|
a.username,
|
||||||
|
a.email,
|
||||||
|
a.role,
|
||||||
|
permissions,
|
||||||
|
a.status,
|
||||||
|
a.lastLoginAt,
|
||||||
|
a.createdAt
|
||||||
|
};
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
return Ok(ApiResponse<object>.Ok(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("admins")]
|
||||||
|
public async Task<IActionResult> CreateAdmin([FromBody] AdminCreateRequest request)
|
||||||
|
{
|
||||||
|
var admin = new Admin
|
||||||
|
{
|
||||||
|
Username = request.Username,
|
||||||
|
PasswordHash = PasswordHasher.Hash(request.Password),
|
||||||
|
Email = request.Email,
|
||||||
|
Role = request.Role,
|
||||||
|
Permissions = request.Permissions,
|
||||||
|
Status = "active",
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
UpdatedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
_db.Admins.Add(admin);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
return Ok(ApiResponse<object>.Ok(new
|
||||||
|
{
|
||||||
|
id = admin.Id,
|
||||||
|
username = admin.Username,
|
||||||
|
email = admin.Email,
|
||||||
|
role = admin.Role,
|
||||||
|
permissions = ResolvePermissions(admin.Role, admin.Permissions),
|
||||||
|
status = admin.Status
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("admins/{id:int}")]
|
||||||
|
public async Task<IActionResult> UpdateAdmin(int id, [FromBody] AdminUpdateRequest request)
|
||||||
|
{
|
||||||
|
var admin = await _db.Admins.FindAsync(id);
|
||||||
|
if (admin == null)
|
||||||
|
return NotFound(ApiResponse.Fail(404, "not_found"));
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.Email))
|
||||||
|
admin.Email = request.Email;
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.Role))
|
||||||
|
admin.Role = request.Role;
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.Permissions))
|
||||||
|
admin.Permissions = request.Permissions;
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.Status))
|
||||||
|
admin.Status = request.Status;
|
||||||
|
|
||||||
|
admin.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return Ok(ApiResponse.Ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("admins/{id:int}")]
|
||||||
|
public async Task<IActionResult> DeleteAdmin(int id)
|
||||||
|
{
|
||||||
|
var admin = await _db.Admins.FindAsync(id);
|
||||||
|
if (admin == null)
|
||||||
|
return NotFound(ApiResponse.Fail(404, "not_found"));
|
||||||
|
|
||||||
|
_db.Admins.Remove(admin);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return Ok(ApiResponse.Ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<string> ResolvePermissions(string role, string? raw)
|
||||||
|
{
|
||||||
|
if (string.Equals(role, "super_admin", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return new List<string> { "*" };
|
||||||
|
|
||||||
|
var (hasAll, allowed) = AdminAccessService.ParsePermissions(raw);
|
||||||
|
if (hasAll)
|
||||||
|
return new List<string> { "*" };
|
||||||
|
return allowed.OrderBy(p => p).ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
using License.Api.DTOs;
|
||||||
|
using License.Api.Services;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace License.Api.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Authorize(Policy = "Admin")]
|
||||||
|
[Route("api/admin/stats")]
|
||||||
|
public class AdminStatsController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly StatsService _stats;
|
||||||
|
private readonly AdminAccessService _adminAccess;
|
||||||
|
|
||||||
|
public AdminStatsController(StatsService stats, AdminAccessService adminAccess)
|
||||||
|
{
|
||||||
|
_stats = stats;
|
||||||
|
_adminAccess = adminAccess;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("dashboard")]
|
||||||
|
public async Task<IActionResult> Dashboard()
|
||||||
|
{
|
||||||
|
var scope = await _adminAccess.GetScopeAsync(User);
|
||||||
|
if (scope == null)
|
||||||
|
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
|
||||||
|
|
||||||
|
var filter = !scope.IsSuperAdmin && !scope.HasAllProjects ? scope.AllowedProjects.ToList() : null;
|
||||||
|
var data = await _stats.GetDashboardAsync(filter);
|
||||||
|
return Ok(ApiResponse<object>.Ok(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("projects")]
|
||||||
|
public async Task<IActionResult> Projects()
|
||||||
|
{
|
||||||
|
var scope = await _adminAccess.GetScopeAsync(User);
|
||||||
|
if (scope == null)
|
||||||
|
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
|
||||||
|
|
||||||
|
var filter = !scope.IsSuperAdmin && !scope.HasAllProjects ? scope.AllowedProjects.ToList() : null;
|
||||||
|
var items = await _stats.GetProjectStatsAsync(filter);
|
||||||
|
return Ok(ApiResponse<List<ProjectStatsItem>>.Ok(items));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("agents")]
|
||||||
|
public async Task<IActionResult> Agents()
|
||||||
|
{
|
||||||
|
var scope = await _adminAccess.GetScopeAsync(User);
|
||||||
|
if (scope == null)
|
||||||
|
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
|
||||||
|
if (!scope.IsSuperAdmin)
|
||||||
|
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
|
||||||
|
|
||||||
|
var items = await _stats.GetAgentStatsAsync();
|
||||||
|
return Ok(ApiResponse<List<AgentStatsItem>>.Ok(items));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("logs")]
|
||||||
|
public async Task<IActionResult> Logs([FromQuery] int days = 7)
|
||||||
|
{
|
||||||
|
var scope = await _adminAccess.GetScopeAsync(User);
|
||||||
|
if (scope == null)
|
||||||
|
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
|
||||||
|
|
||||||
|
var filter = !scope.IsSuperAdmin && !scope.HasAllProjects ? scope.AllowedProjects.ToList() : null;
|
||||||
|
var items = await _stats.GetLogStatsAsync(days, filter);
|
||||||
|
return Ok(ApiResponse<List<LogStatsItem>>.Ok(items));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("export")]
|
||||||
|
public async Task<IActionResult> Export([FromQuery] int days = 30)
|
||||||
|
{
|
||||||
|
var scope = await _adminAccess.GetScopeAsync(User);
|
||||||
|
if (scope == null)
|
||||||
|
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
|
||||||
|
|
||||||
|
var filter = !scope.IsSuperAdmin && !scope.HasAllProjects ? scope.AllowedProjects.ToList() : null;
|
||||||
|
var csv = await _stats.ExportStatsCsvAsync(days, filter);
|
||||||
|
var bytes = System.Text.Encoding.UTF8.GetBytes(csv);
|
||||||
|
return File(bytes, "text/csv", "stats.csv");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using License.Api.Data;
|
||||||
|
using License.Api.DTOs;
|
||||||
|
using License.Api.Models;
|
||||||
|
using License.Api.Security;
|
||||||
|
using License.Api.Services;
|
||||||
|
using License.Api.Utils;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace License.Api.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/agent")]
|
||||||
|
public class AgentAuthController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
private readonly JwtTokenService _jwt;
|
||||||
|
private readonly ConfigService _config;
|
||||||
|
|
||||||
|
public AgentAuthController(AppDbContext db, JwtTokenService jwt, ConfigService config)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_jwt = jwt;
|
||||||
|
_config = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("login")]
|
||||||
|
public async Task<IActionResult> Login([FromBody] AgentLoginRequest request)
|
||||||
|
{
|
||||||
|
var agentSystemEnabled = await _config.GetBoolAsync("feature.agent_system", true);
|
||||||
|
if (!agentSystemEnabled)
|
||||||
|
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
|
||||||
|
|
||||||
|
var agent = await _db.Agents.FirstOrDefaultAsync(a => a.AgentCode == request.AgentCode);
|
||||||
|
if (agent == null || !PasswordHasher.Verify(request.Password, agent.PasswordHash))
|
||||||
|
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
|
||||||
|
|
||||||
|
if (agent.Status != "active")
|
||||||
|
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
|
||||||
|
|
||||||
|
agent.LastLoginAt = DateTime.UtcNow;
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
await LogAccessAsync($"agent:{agent.AgentCode}", "agent_login");
|
||||||
|
|
||||||
|
var allowed = string.IsNullOrWhiteSpace(agent.AllowedProjects)
|
||||||
|
? new List<string>()
|
||||||
|
: JsonSerializer.Deserialize<List<string>>(agent.AllowedProjects) ?? new List<string>();
|
||||||
|
|
||||||
|
var projects = await _db.Projects
|
||||||
|
.Where(p => allowed.Count == 0 || allowed.Contains(p.ProjectId))
|
||||||
|
.Select(p => new { projectId = p.ProjectId, projectName = p.Name })
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var token = _jwt.CreateAgentToken(agent);
|
||||||
|
var data = new
|
||||||
|
{
|
||||||
|
token,
|
||||||
|
agent = new
|
||||||
|
{
|
||||||
|
id = agent.Id,
|
||||||
|
agentCode = agent.AgentCode,
|
||||||
|
companyName = agent.CompanyName,
|
||||||
|
balance = agent.Balance,
|
||||||
|
discount = agent.Discount
|
||||||
|
},
|
||||||
|
allowedProjects = projects
|
||||||
|
};
|
||||||
|
|
||||||
|
return Ok(ApiResponse<object>.Ok(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Authorize(Policy = "Agent")]
|
||||||
|
[HttpPost("logout")]
|
||||||
|
public IActionResult Logout()
|
||||||
|
{
|
||||||
|
var agentCode = User.FindFirst("agentCode")?.Value ?? "agent";
|
||||||
|
_ = LogAccessAsync($"agent:{agentCode}", "agent_logout");
|
||||||
|
return Ok(ApiResponse.Ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Authorize(Policy = "Agent")]
|
||||||
|
[HttpGet("profile")]
|
||||||
|
public async Task<IActionResult> Profile()
|
||||||
|
{
|
||||||
|
if (!User.TryGetUserId(out var agentId))
|
||||||
|
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
|
||||||
|
var agent = await _db.Agents.FindAsync(agentId);
|
||||||
|
if (agent == null)
|
||||||
|
return NotFound(ApiResponse.Fail(404, "not_found"));
|
||||||
|
|
||||||
|
return Ok(ApiResponse<object>.Ok(new
|
||||||
|
{
|
||||||
|
id = agent.Id,
|
||||||
|
agentCode = agent.AgentCode,
|
||||||
|
companyName = agent.CompanyName,
|
||||||
|
contactPerson = agent.ContactPerson,
|
||||||
|
contactPhone = agent.ContactPhone,
|
||||||
|
contactEmail = agent.ContactEmail,
|
||||||
|
balance = agent.Balance,
|
||||||
|
discount = agent.Discount,
|
||||||
|
creditLimit = agent.CreditLimit,
|
||||||
|
status = agent.Status
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Authorize(Policy = "Agent")]
|
||||||
|
[HttpPut("profile")]
|
||||||
|
public async Task<IActionResult> UpdateProfile([FromBody] AgentUpdateRequest request)
|
||||||
|
{
|
||||||
|
if (!User.TryGetUserId(out var agentId))
|
||||||
|
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
|
||||||
|
var agent = await _db.Agents.FindAsync(agentId);
|
||||||
|
if (agent == null)
|
||||||
|
return NotFound(ApiResponse.Fail(404, "not_found"));
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.CompanyName))
|
||||||
|
agent.CompanyName = request.CompanyName;
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.ContactPerson))
|
||||||
|
agent.ContactPerson = request.ContactPerson;
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.ContactPhone))
|
||||||
|
agent.ContactPhone = request.ContactPhone;
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.ContactEmail))
|
||||||
|
agent.ContactEmail = request.ContactEmail;
|
||||||
|
|
||||||
|
agent.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return Ok(ApiResponse.Ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Authorize(Policy = "Agent")]
|
||||||
|
[HttpPost("change-password")]
|
||||||
|
public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordRequest request)
|
||||||
|
{
|
||||||
|
if (!User.TryGetUserId(out var agentId))
|
||||||
|
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
|
||||||
|
var agent = await _db.Agents.FindAsync(agentId);
|
||||||
|
if (agent == null)
|
||||||
|
return NotFound(ApiResponse.Fail(404, "not_found"));
|
||||||
|
|
||||||
|
if (!PasswordHasher.Verify(request.OldPassword, agent.PasswordHash))
|
||||||
|
return BadRequest(ApiResponse.Fail(400, "bad_request"));
|
||||||
|
|
||||||
|
agent.PasswordHash = PasswordHasher.Hash(request.NewPassword);
|
||||||
|
agent.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return Ok(ApiResponse.Ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Authorize(Policy = "Agent")]
|
||||||
|
[HttpGet("transactions")]
|
||||||
|
public async Task<IActionResult> Transactions()
|
||||||
|
{
|
||||||
|
if (!User.TryGetUserId(out var agentId))
|
||||||
|
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
|
||||||
|
var items = await _db.AgentTransactions
|
||||||
|
.Where(t => t.AgentId == agentId)
|
||||||
|
.OrderByDescending(t => t.CreatedAt)
|
||||||
|
.Take(200)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
return Ok(ApiResponse<List<AgentTransaction>>.Ok(items));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LogAccessAsync(string? deviceId, string action)
|
||||||
|
{
|
||||||
|
_db.AccessLogs.Add(new AccessLog
|
||||||
|
{
|
||||||
|
DeviceId = deviceId,
|
||||||
|
Action = action,
|
||||||
|
IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString(),
|
||||||
|
UserAgent = HttpContext.Request.Headers.UserAgent.ToString(),
|
||||||
|
ResponseCode = 200,
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
});
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using License.Api.Data;
|
||||||
|
using License.Api.DTOs;
|
||||||
|
using License.Api.Models;
|
||||||
|
using License.Api.Services;
|
||||||
|
using License.Api.Utils;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace License.Api.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Authorize(Policy = "Agent")]
|
||||||
|
[Route("api/agent/cards")]
|
||||||
|
public class AgentCardsController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
private readonly CardService _cards;
|
||||||
|
private readonly IdempotencyService _idempotency;
|
||||||
|
private readonly ConfigService _config;
|
||||||
|
|
||||||
|
public AgentCardsController(AppDbContext db, CardService cards, IdempotencyService idempotency, ConfigService config)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_cards = cards;
|
||||||
|
_idempotency = idempotency;
|
||||||
|
_config = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("generate")]
|
||||||
|
public async Task<IActionResult> Generate([FromBody] CardGenerateRequest request)
|
||||||
|
{
|
||||||
|
var agentSystemEnabled = await _config.GetBoolAsync("feature.agent_system", true);
|
||||||
|
if (!agentSystemEnabled)
|
||||||
|
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
|
||||||
|
|
||||||
|
if (request.Quantity <= 0 || request.Quantity > 10000)
|
||||||
|
return BadRequest(ApiResponse.Fail(400, "bad_request"));
|
||||||
|
|
||||||
|
var project = await _db.Projects.FirstOrDefaultAsync(p => p.ProjectId == request.ProjectId);
|
||||||
|
if (project == null)
|
||||||
|
return NotFound(ApiResponse.Fail(404, "not_found"));
|
||||||
|
if (!project.IsEnabled)
|
||||||
|
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(1011, "project_disabled"));
|
||||||
|
|
||||||
|
if (!User.TryGetUserId(out var agentId))
|
||||||
|
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
|
||||||
|
var agent = await _db.Agents.FindAsync(agentId);
|
||||||
|
if (agent == null || agent.Status != "active")
|
||||||
|
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
|
||||||
|
|
||||||
|
var allowedProjects = string.IsNullOrWhiteSpace(agent.AllowedProjects)
|
||||||
|
? new List<string>()
|
||||||
|
: JsonSerializer.Deserialize<List<string>>(agent.AllowedProjects) ?? new List<string>();
|
||||||
|
|
||||||
|
if (allowedProjects.Count > 0 && !allowedProjects.Contains(project.ProjectId))
|
||||||
|
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
|
||||||
|
|
||||||
|
var pricing = await _db.ProjectPricing.FirstOrDefaultAsync(p =>
|
||||||
|
p.ProjectId == project.ProjectId &&
|
||||||
|
p.CardType == request.CardType &&
|
||||||
|
p.DurationDays == request.DurationDays &&
|
||||||
|
p.IsEnabled);
|
||||||
|
if (pricing == null)
|
||||||
|
return BadRequest(ApiResponse.Fail(400, "bad_request"));
|
||||||
|
|
||||||
|
var unitPrice = pricing.OriginalPrice * (agent.Discount / 100m);
|
||||||
|
var totalCost = unitPrice * request.Quantity;
|
||||||
|
|
||||||
|
var requestHash = IdempotencyService.ComputeRequestHash(JsonSerializer.Serialize(request));
|
||||||
|
var idempotencyKey = Request.Headers["X-Idempotency-Key"].ToString();
|
||||||
|
if (!string.IsNullOrWhiteSpace(idempotencyKey))
|
||||||
|
{
|
||||||
|
var existing = await _idempotency.GetAsync(idempotencyKey);
|
||||||
|
if (existing != null)
|
||||||
|
{
|
||||||
|
if (!string.Equals(existing.RequestHash, requestHash, StringComparison.OrdinalIgnoreCase))
|
||||||
|
return Conflict(ApiResponse.Fail(400, "bad_request"));
|
||||||
|
|
||||||
|
var cached = JsonSerializer.Deserialize<ApiResponse<AgentCardGenerateResponse>>(existing.ResponseBody ?? "{}")
|
||||||
|
?? ApiResponse<AgentCardGenerateResponse>.Fail(500, "internal_error");
|
||||||
|
return StatusCode(existing.ResponseCode ?? 200, cached);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var tx = await _db.Database.BeginTransactionAsync();
|
||||||
|
var lockedAgent = await _db.Agents
|
||||||
|
.FromSqlRaw("SELECT * FROM \"Agents\" WHERE \"Id\" = {0} FOR UPDATE", agentId)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
if (lockedAgent == null)
|
||||||
|
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
|
||||||
|
|
||||||
|
var balanceBefore = lockedAgent.Balance;
|
||||||
|
var balanceAfter = balanceBefore - totalCost;
|
||||||
|
if (balanceAfter < -lockedAgent.CreditLimit)
|
||||||
|
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
|
||||||
|
|
||||||
|
lockedAgent.Balance = balanceAfter;
|
||||||
|
lockedAgent.UpdatedAt = DateTime.UtcNow;
|
||||||
|
|
||||||
|
_db.AgentTransactions.Add(new AgentTransaction
|
||||||
|
{
|
||||||
|
AgentId = lockedAgent.Id,
|
||||||
|
Type = "consume",
|
||||||
|
Amount = -totalCost,
|
||||||
|
BalanceBefore = balanceBefore,
|
||||||
|
BalanceAfter = balanceAfter,
|
||||||
|
Remark = $"generate_cards:{request.CardType}:{request.DurationDays}x{request.Quantity}",
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
});
|
||||||
|
|
||||||
|
var generated = await _cards.GenerateAsync(request, agentId, lockedAgent.Id, unitPrice, "agent");
|
||||||
|
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
await tx.CommitAsync();
|
||||||
|
|
||||||
|
var response = new AgentCardGenerateResponse
|
||||||
|
{
|
||||||
|
BatchId = generated.BatchId,
|
||||||
|
Keys = generated.Keys,
|
||||||
|
Count = generated.Count,
|
||||||
|
UnitPrice = unitPrice,
|
||||||
|
TotalPrice = totalCost,
|
||||||
|
BalanceAfter = balanceAfter
|
||||||
|
};
|
||||||
|
|
||||||
|
var apiResponse = ApiResponse<AgentCardGenerateResponse>.Ok(response);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(idempotencyKey))
|
||||||
|
{
|
||||||
|
var body = JsonSerializer.Serialize(apiResponse);
|
||||||
|
await _idempotency.StoreAsync(idempotencyKey, Request.Path, requestHash, 200, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(apiResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> List([FromQuery] string? projectId, [FromQuery] string? status, [FromQuery] int page = 1, [FromQuery] int pageSize = 20)
|
||||||
|
{
|
||||||
|
var agentSystemEnabled = await _config.GetBoolAsync("feature.agent_system", true);
|
||||||
|
if (!agentSystemEnabled)
|
||||||
|
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "forbidden"));
|
||||||
|
|
||||||
|
page = Math.Max(1, page);
|
||||||
|
pageSize = Math.Clamp(pageSize, 1, 100);
|
||||||
|
|
||||||
|
if (!User.TryGetUserId(out var agentId))
|
||||||
|
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
|
||||||
|
|
||||||
|
var query = _db.CardKeys.Where(c => c.AgentId == agentId && c.DeletedAt == null).AsQueryable();
|
||||||
|
if (!string.IsNullOrWhiteSpace(projectId))
|
||||||
|
query = query.Where(c => c.ProjectId == projectId);
|
||||||
|
if (!string.IsNullOrWhiteSpace(status))
|
||||||
|
query = query.Where(c => c.Status == status);
|
||||||
|
|
||||||
|
var total = await query.CountAsync();
|
||||||
|
var items = await query.OrderByDescending(c => c.CreatedAt)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
|
.Select(c => new AgentCardListItem
|
||||||
|
{
|
||||||
|
Id = c.Id,
|
||||||
|
KeyCode = c.KeyCode,
|
||||||
|
CardType = c.CardType,
|
||||||
|
Status = c.Status,
|
||||||
|
ActivateTime = c.ActivateTime,
|
||||||
|
ExpireTime = c.ExpireTime,
|
||||||
|
Note = c.Note,
|
||||||
|
CreatedAt = c.CreatedAt
|
||||||
|
})
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var result = new PagedResult<AgentCardListItem>
|
||||||
|
{
|
||||||
|
Items = items,
|
||||||
|
Pagination = new PaginationInfo
|
||||||
|
{
|
||||||
|
Page = page,
|
||||||
|
PageSize = pageSize,
|
||||||
|
Total = total,
|
||||||
|
TotalPages = (int)Math.Ceiling(total / (double)pageSize)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return Ok(ApiResponse<PagedResult<AgentCardListItem>>.Ok(result));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using License.Api.DTOs;
|
||||||
|
using License.Api.Services;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace License.Api.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/auth")]
|
||||||
|
public class AuthController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly AuthService _auth;
|
||||||
|
|
||||||
|
public AuthController(AuthService auth)
|
||||||
|
{
|
||||||
|
_auth = auth;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("verify")]
|
||||||
|
public async Task<IActionResult> Verify([FromBody] AuthVerifyRequest request)
|
||||||
|
{
|
||||||
|
var (response, status) = await _auth.VerifyAsync(request, HttpContext);
|
||||||
|
return StatusCode(status, response);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("heartbeat")]
|
||||||
|
public async Task<IActionResult> Heartbeat([FromBody] AuthHeartbeatRequest request)
|
||||||
|
{
|
||||||
|
var (response, status) = await _auth.HeartbeatAsync(request, HttpContext);
|
||||||
|
return StatusCode(status, response);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
using License.Api.Data;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using StackExchange.Redis;
|
||||||
|
|
||||||
|
namespace License.Api.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("health")]
|
||||||
|
public class HealthController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
private readonly IConnectionMultiplexer? _redis;
|
||||||
|
|
||||||
|
public HealthController(AppDbContext db, IConnectionMultiplexer? redis = null)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_redis = redis;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("live")]
|
||||||
|
public IActionResult Live()
|
||||||
|
{
|
||||||
|
return Ok(new { status = "ok" });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("ready")]
|
||||||
|
public async Task<IActionResult> Ready()
|
||||||
|
{
|
||||||
|
var checks = new Dictionary<string, string>();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _db.Database.ExecuteSqlRawAsync("SELECT 1");
|
||||||
|
checks["database"] = "ok";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
checks["database"] = $"error: {ex.Message}";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_redis != null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var db = _redis.GetDatabase();
|
||||||
|
await db.PingAsync();
|
||||||
|
checks["redis"] = "ok";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
checks["redis"] = $"error: {ex.Message}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var status = checks.Values.All(v => v == "ok") ? "ok" : "error";
|
||||||
|
var code = status == "ok" ? 200 : 503;
|
||||||
|
|
||||||
|
return StatusCode(code, new { status, checks });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
using License.Api.Data;
|
||||||
|
using License.Api.DTOs;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace License.Api.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/config")]
|
||||||
|
public class PublicConfigController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
|
||||||
|
public PublicConfigController(AppDbContext db)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("public")]
|
||||||
|
public async Task<IActionResult> GetPublicConfigs()
|
||||||
|
{
|
||||||
|
var configs = await _db.SystemConfigs
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(c => c.IsPublic)
|
||||||
|
.OrderBy(c => c.Category)
|
||||||
|
.ThenBy(c => c.ConfigKey)
|
||||||
|
.Select(c => new
|
||||||
|
{
|
||||||
|
key = c.ConfigKey,
|
||||||
|
value = c.ConfigValue,
|
||||||
|
valueType = c.ValueType,
|
||||||
|
category = c.Category,
|
||||||
|
displayName = c.DisplayName,
|
||||||
|
description = c.Description,
|
||||||
|
options = c.Options
|
||||||
|
})
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
return Ok(ApiResponse<object>.Ok(configs));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
using License.Api.Data;
|
||||||
|
using License.Api.DTOs;
|
||||||
|
using License.Api.Models;
|
||||||
|
using License.Api.Options;
|
||||||
|
using License.Api.Security;
|
||||||
|
using License.Api.Services;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace License.Api.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/software")]
|
||||||
|
public class SoftwareController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
private readonly SoftwareService _software;
|
||||||
|
private readonly JwtTokenService _jwt;
|
||||||
|
private readonly ILogger<SoftwareController> _logger;
|
||||||
|
private readonly ConfigService _config;
|
||||||
|
private readonly StorageOptions _storage;
|
||||||
|
|
||||||
|
public SoftwareController(AppDbContext db, SoftwareService software, JwtTokenService jwt, ILogger<SoftwareController> logger, ConfigService config, IOptions<StorageOptions> storageOptions)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_software = software;
|
||||||
|
_jwt = jwt;
|
||||||
|
_logger = logger;
|
||||||
|
_config = config;
|
||||||
|
_storage = storageOptions.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("check-update")]
|
||||||
|
public async Task<IActionResult> CheckUpdate([FromBody] SoftwareCheckUpdateRequest request)
|
||||||
|
{
|
||||||
|
var project = await _db.Projects.FirstOrDefaultAsync(p => p.ProjectId == request.ProjectId);
|
||||||
|
if (project == null)
|
||||||
|
return NotFound(ApiResponse.Fail(404, "not_found"));
|
||||||
|
if (!project.IsEnabled)
|
||||||
|
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(1011, "project_disabled"));
|
||||||
|
|
||||||
|
var autoUpdateEnabled = (await _config.GetBoolAsync("feature.auto_update", true)) && project.AutoUpdate;
|
||||||
|
if (!autoUpdateEnabled)
|
||||||
|
{
|
||||||
|
var disabled = new SoftwareCheckUpdateResponse { HasUpdate = false };
|
||||||
|
return Ok(ApiResponse<SoftwareCheckUpdateResponse>.Ok(disabled));
|
||||||
|
}
|
||||||
|
|
||||||
|
var data = await _software.CheckUpdateAsync(request);
|
||||||
|
var forceUpdateEnabled = await _config.GetBoolAsync("feature.force_update", false);
|
||||||
|
if (forceUpdateEnabled)
|
||||||
|
data.ForceUpdate = true;
|
||||||
|
return Ok(ApiResponse<SoftwareCheckUpdateResponse>.Ok(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("download")]
|
||||||
|
public async Task<IActionResult> Download([FromQuery] string? version, [FromQuery] string? token)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(token))
|
||||||
|
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
|
||||||
|
|
||||||
|
var principal = _jwt.ValidateToken(token);
|
||||||
|
if (principal == null)
|
||||||
|
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
|
||||||
|
|
||||||
|
var type = principal.Claims.FirstOrDefault(c => c.Type == "type")?.Value;
|
||||||
|
if (!string.Equals(type, "card", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
|
||||||
|
|
||||||
|
var projectId = principal.Claims.FirstOrDefault(c => c.Type == "projectId")?.Value;
|
||||||
|
if (string.IsNullOrWhiteSpace(projectId))
|
||||||
|
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
|
||||||
|
|
||||||
|
var cardIdStr = principal.Claims.FirstOrDefault(c => c.Type == System.IdentityModel.Tokens.Jwt.JwtRegisteredClaimNames.Sub)?.Value;
|
||||||
|
int.TryParse(cardIdStr, out var cardId);
|
||||||
|
|
||||||
|
if (cardId > 0)
|
||||||
|
{
|
||||||
|
var card = await _db.CardKeys.FirstOrDefaultAsync(c => c.Id == cardId && c.DeletedAt == null);
|
||||||
|
if (card == null)
|
||||||
|
return Unauthorized(ApiResponse.Fail(401, "unauthorized"));
|
||||||
|
|
||||||
|
if (card.Status == "banned")
|
||||||
|
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(1003, "card_banned"));
|
||||||
|
|
||||||
|
if (card.ExpireTime.HasValue && card.ExpireTime <= DateTime.UtcNow)
|
||||||
|
{
|
||||||
|
card.Status = "expired";
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(1002, "card_expired"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var software = await _software.GetVersionAsync(projectId, version);
|
||||||
|
if (software == null)
|
||||||
|
return NotFound(ApiResponse.Fail(404, "not_found"));
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var filePath = software.FileUrl;
|
||||||
|
if (!System.IO.File.Exists(filePath))
|
||||||
|
return NotFound(ApiResponse.Fail(404, "not_found"));
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(software.FileHash))
|
||||||
|
Response.Headers["X-File-Hash"] = software.FileHash;
|
||||||
|
|
||||||
|
if (software.FileSize.HasValue)
|
||||||
|
Response.Headers["X-File-Size"] = software.FileSize.Value.ToString();
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(software.EncryptionKey))
|
||||||
|
{
|
||||||
|
if (_storage.RequireHttpsForDownloadKey && !IsSecureRequest(Request))
|
||||||
|
return StatusCode(StatusCodes.Status403Forbidden, ApiResponse.Fail(403, "https_required"));
|
||||||
|
|
||||||
|
Response.Headers["X-Encryption-Method"] = "AES-256-GCM";
|
||||||
|
Response.Headers["X-Encryption-Key"] = software.EncryptionKey;
|
||||||
|
using var fs = System.IO.File.OpenRead(filePath);
|
||||||
|
var nonce = new byte[12];
|
||||||
|
var read = await fs.ReadAsync(nonce, 0, nonce.Length);
|
||||||
|
if (read == nonce.Length)
|
||||||
|
Response.Headers["X-Encryption-Nonce"] = Convert.ToBase64String(nonce);
|
||||||
|
}
|
||||||
|
|
||||||
|
_db.AccessLogs.Add(new AccessLog
|
||||||
|
{
|
||||||
|
ProjectId = projectId,
|
||||||
|
CardKeyId = cardId > 0 ? cardId : null,
|
||||||
|
Action = "download",
|
||||||
|
IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString(),
|
||||||
|
UserAgent = HttpContext.Request.Headers.UserAgent.ToString(),
|
||||||
|
ResponseCode = 200,
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
});
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return PhysicalFile(filePath, "application/octet-stream", enableRangeProcessing: true);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to download file");
|
||||||
|
return StatusCode(500, ApiResponse.Fail(500, "internal_error"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsSecureRequest(HttpRequest request)
|
||||||
|
{
|
||||||
|
if (request.IsHttps)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
var forwardedProto = request.Headers["X-Forwarded-Proto"].ToString();
|
||||||
|
return string.Equals(forwardedProto, "https", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
}
|
||||||
31
license-system-backend/src/License.Api/DTOs/AdminDtos.cs
Normal file
31
license-system-backend/src/License.Api/DTOs/AdminDtos.cs
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
namespace License.Api.DTOs;
|
||||||
|
|
||||||
|
public class AdminLoginRequest
|
||||||
|
{
|
||||||
|
public string Username { get; set; } = string.Empty;
|
||||||
|
public string Password { get; set; } = string.Empty;
|
||||||
|
public string? Captcha { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ChangePasswordRequest
|
||||||
|
{
|
||||||
|
public string OldPassword { get; set; } = string.Empty;
|
||||||
|
public string NewPassword { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AdminCreateRequest
|
||||||
|
{
|
||||||
|
public string Username { get; set; } = string.Empty;
|
||||||
|
public string Password { get; set; } = string.Empty;
|
||||||
|
public string? Email { get; set; }
|
||||||
|
public string Role { get; set; } = "admin";
|
||||||
|
public string? Permissions { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AdminUpdateRequest
|
||||||
|
{
|
||||||
|
public string? Email { get; set; }
|
||||||
|
public string? Role { get; set; }
|
||||||
|
public string? Permissions { get; set; }
|
||||||
|
public string? Status { get; set; }
|
||||||
|
}
|
||||||
68
license-system-backend/src/License.Api/DTOs/AgentDtos.cs
Normal file
68
license-system-backend/src/License.Api/DTOs/AgentDtos.cs
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
namespace License.Api.DTOs;
|
||||||
|
|
||||||
|
public class AgentLoginRequest
|
||||||
|
{
|
||||||
|
public string AgentCode { get; set; } = string.Empty;
|
||||||
|
public string Password { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AgentCreateRequest
|
||||||
|
{
|
||||||
|
public int? AdminId { get; set; }
|
||||||
|
public string AgentCode { get; set; } = string.Empty;
|
||||||
|
public string Password { get; set; } = string.Empty;
|
||||||
|
public string? CompanyName { get; set; }
|
||||||
|
public string? ContactPerson { get; set; }
|
||||||
|
public string? ContactPhone { get; set; }
|
||||||
|
public string? ContactEmail { get; set; }
|
||||||
|
public decimal InitialBalance { get; set; }
|
||||||
|
public decimal Discount { get; set; } = 100m;
|
||||||
|
public decimal CreditLimit { get; set; }
|
||||||
|
public List<string>? AllowedProjects { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AgentUpdateRequest
|
||||||
|
{
|
||||||
|
public string? CompanyName { get; set; }
|
||||||
|
public string? ContactPerson { get; set; }
|
||||||
|
public string? ContactPhone { get; set; }
|
||||||
|
public string? ContactEmail { get; set; }
|
||||||
|
public decimal? Discount { get; set; }
|
||||||
|
public decimal? CreditLimit { get; set; }
|
||||||
|
public List<string>? AllowedProjects { get; set; }
|
||||||
|
public string? Status { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AgentBalanceRequest
|
||||||
|
{
|
||||||
|
public decimal Amount { get; set; }
|
||||||
|
public string? Remark { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AgentListItem
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string AgentCode { get; set; } = string.Empty;
|
||||||
|
public string? CompanyName { get; set; }
|
||||||
|
public string? ContactPerson { get; set; }
|
||||||
|
public string? ContactPhone { get; set; }
|
||||||
|
public decimal Balance { get; set; }
|
||||||
|
public decimal Discount { get; set; }
|
||||||
|
public string Status { get; set; } = string.Empty;
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AgentDetailResponse
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string AgentCode { get; set; } = string.Empty;
|
||||||
|
public string? CompanyName { get; set; }
|
||||||
|
public string? ContactPerson { get; set; }
|
||||||
|
public string? ContactPhone { get; set; }
|
||||||
|
public string? ContactEmail { get; set; }
|
||||||
|
public decimal Balance { get; set; }
|
||||||
|
public decimal Discount { get; set; }
|
||||||
|
public decimal CreditLimit { get; set; }
|
||||||
|
public string Status { get; set; } = string.Empty;
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
}
|
||||||
24
license-system-backend/src/License.Api/DTOs/ApiResponse.cs
Normal file
24
license-system-backend/src/License.Api/DTOs/ApiResponse.cs
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
namespace License.Api.DTOs;
|
||||||
|
|
||||||
|
public class ApiResponse<T>
|
||||||
|
{
|
||||||
|
public int Code { get; set; }
|
||||||
|
public string Message { get; set; } = "success";
|
||||||
|
public T? Data { get; set; }
|
||||||
|
public long Timestamp { get; set; } = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||||
|
|
||||||
|
public static ApiResponse<T> Ok(T? data, string message = "success")
|
||||||
|
=> new() { Code = 200, Message = message, Data = data };
|
||||||
|
|
||||||
|
public static ApiResponse<T> Fail(int code, string message)
|
||||||
|
=> new() { Code = code, Message = message, Data = default };
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ApiResponse : ApiResponse<object>
|
||||||
|
{
|
||||||
|
public static ApiResponse Ok(string message = "success")
|
||||||
|
=> new() { Code = 200, Message = message, Data = null };
|
||||||
|
|
||||||
|
public static ApiResponse Fail(int code, string message)
|
||||||
|
=> new() { Code = code, Message = message, Data = null };
|
||||||
|
}
|
||||||
38
license-system-backend/src/License.Api/DTOs/AuthDtos.cs
Normal file
38
license-system-backend/src/License.Api/DTOs/AuthDtos.cs
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
namespace License.Api.DTOs;
|
||||||
|
|
||||||
|
public class AuthVerifyRequest
|
||||||
|
{
|
||||||
|
public string ProjectId { get; set; } = string.Empty;
|
||||||
|
public string KeyCode { get; set; } = string.Empty;
|
||||||
|
public string DeviceId { get; set; } = string.Empty;
|
||||||
|
public string? ClientVersion { get; set; }
|
||||||
|
public long Timestamp { get; set; }
|
||||||
|
public string Signature { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AuthVerifyResponse
|
||||||
|
{
|
||||||
|
public bool Valid { get; set; }
|
||||||
|
public DateTime? ExpireTime { get; set; }
|
||||||
|
public int RemainingDays { get; set; }
|
||||||
|
public string? DownloadUrl { get; set; }
|
||||||
|
public string? FileHash { get; set; }
|
||||||
|
public string? Version { get; set; }
|
||||||
|
public int HeartbeatInterval { get; set; }
|
||||||
|
public string? AccessToken { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AuthHeartbeatRequest
|
||||||
|
{
|
||||||
|
public string AccessToken { get; set; } = string.Empty;
|
||||||
|
public string DeviceId { get; set; } = string.Empty;
|
||||||
|
public long Timestamp { get; set; }
|
||||||
|
public string Signature { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AuthHeartbeatResponse
|
||||||
|
{
|
||||||
|
public bool Valid { get; set; }
|
||||||
|
public int RemainingDays { get; set; }
|
||||||
|
public long ServerTime { get; set; }
|
||||||
|
}
|
||||||
60
license-system-backend/src/License.Api/DTOs/CardDtos.cs
Normal file
60
license-system-backend/src/License.Api/DTOs/CardDtos.cs
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
namespace License.Api.DTOs;
|
||||||
|
|
||||||
|
public class CardGenerateRequest
|
||||||
|
{
|
||||||
|
public string ProjectId { get; set; } = string.Empty;
|
||||||
|
public string CardType { get; set; } = string.Empty;
|
||||||
|
public int DurationDays { get; set; }
|
||||||
|
public int Quantity { get; set; }
|
||||||
|
public string? Note { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CardGenerateResponse
|
||||||
|
{
|
||||||
|
public string BatchId { get; set; } = string.Empty;
|
||||||
|
public List<string> Keys { get; set; } = new();
|
||||||
|
public int Count { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CardBanRequest
|
||||||
|
{
|
||||||
|
public string? Reason { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CardExtendRequest
|
||||||
|
{
|
||||||
|
public int Days { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CardNoteUpdateRequest
|
||||||
|
{
|
||||||
|
public string? Note { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CardBatchRequest
|
||||||
|
{
|
||||||
|
public List<int> Ids { get; set; } = new();
|
||||||
|
public string? Reason { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AgentCardGenerateResponse
|
||||||
|
{
|
||||||
|
public string BatchId { get; set; } = string.Empty;
|
||||||
|
public List<string> Keys { get; set; } = new();
|
||||||
|
public int Count { get; set; }
|
||||||
|
public decimal UnitPrice { get; set; }
|
||||||
|
public decimal TotalPrice { get; set; }
|
||||||
|
public decimal BalanceAfter { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AgentCardListItem
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string KeyCode { get; set; } = string.Empty;
|
||||||
|
public string CardType { get; set; } = string.Empty;
|
||||||
|
public string Status { get; set; } = string.Empty;
|
||||||
|
public DateTime? ActivateTime { get; set; }
|
||||||
|
public DateTime? ExpireTime { get; set; }
|
||||||
|
public string? Note { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
}
|
||||||
15
license-system-backend/src/License.Api/DTOs/PagedResult.cs
Normal file
15
license-system-backend/src/License.Api/DTOs/PagedResult.cs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
namespace License.Api.DTOs;
|
||||||
|
|
||||||
|
public class PagedResult<T>
|
||||||
|
{
|
||||||
|
public List<T> Items { get; set; } = new();
|
||||||
|
public PaginationInfo Pagination { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PaginationInfo
|
||||||
|
{
|
||||||
|
public int Page { get; set; }
|
||||||
|
public int PageSize { get; set; }
|
||||||
|
public int Total { get; set; }
|
||||||
|
public int TotalPages { get; set; }
|
||||||
|
}
|
||||||
78
license-system-backend/src/License.Api/DTOs/ProjectDtos.cs
Normal file
78
license-system-backend/src/License.Api/DTOs/ProjectDtos.cs
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
namespace License.Api.DTOs;
|
||||||
|
|
||||||
|
public class ProjectCreateRequest
|
||||||
|
{
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string? Description { get; set; }
|
||||||
|
public int MaxDevices { get; set; } = 1;
|
||||||
|
public bool AutoUpdate { get; set; } = true;
|
||||||
|
public string? IconUrl { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ProjectUpdateRequest
|
||||||
|
{
|
||||||
|
public string? Name { get; set; }
|
||||||
|
public string? Description { get; set; }
|
||||||
|
public int? MaxDevices { get; set; }
|
||||||
|
public bool? AutoUpdate { get; set; }
|
||||||
|
public bool? IsEnabled { get; set; }
|
||||||
|
public string? IconUrl { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ProjectDocUpdateRequest
|
||||||
|
{
|
||||||
|
public string? Content { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ProjectPricingCreateRequest
|
||||||
|
{
|
||||||
|
public string CardType { get; set; } = string.Empty;
|
||||||
|
public int DurationDays { get; set; }
|
||||||
|
public decimal OriginalPrice { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ProjectPricingUpdateRequest
|
||||||
|
{
|
||||||
|
public decimal? OriginalPrice { get; set; }
|
||||||
|
public bool? IsEnabled { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ProjectListItem
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string ProjectId { get; set; } = string.Empty;
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string? Description { get; set; }
|
||||||
|
public string? IconUrl { get; set; }
|
||||||
|
public int MaxDevices { get; set; }
|
||||||
|
public bool AutoUpdate { get; set; }
|
||||||
|
public bool IsEnabled { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ProjectDetailResponse
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string ProjectId { get; set; } = string.Empty;
|
||||||
|
public string ProjectKey { get; set; } = string.Empty;
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string? Description { get; set; }
|
||||||
|
public string? IconUrl { get; set; }
|
||||||
|
public int MaxDevices { get; set; }
|
||||||
|
public bool AutoUpdate { get; set; }
|
||||||
|
public bool IsEnabled { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
public DateTime UpdatedAt { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class SoftwareVersionListItem
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string Version { get; set; } = string.Empty;
|
||||||
|
public long? FileSize { get; set; }
|
||||||
|
public string? FileHash { get; set; }
|
||||||
|
public bool IsStable { get; set; }
|
||||||
|
public bool IsForceUpdate { get; set; }
|
||||||
|
public string? Changelog { get; set; }
|
||||||
|
public DateTime PublishedAt { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace License.Api.DTOs;
|
||||||
|
|
||||||
|
public class SoftwareVersionUpdateRequest
|
||||||
|
{
|
||||||
|
public bool IsForceUpdate { get; set; }
|
||||||
|
public bool IsStable { get; set; }
|
||||||
|
public string? Changelog { get; set; }
|
||||||
|
}
|
||||||
19
license-system-backend/src/License.Api/DTOs/SoftwareDtos.cs
Normal file
19
license-system-backend/src/License.Api/DTOs/SoftwareDtos.cs
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
namespace License.Api.DTOs;
|
||||||
|
|
||||||
|
public class SoftwareCheckUpdateRequest
|
||||||
|
{
|
||||||
|
public string ProjectId { get; set; } = string.Empty;
|
||||||
|
public string CurrentVersion { get; set; } = string.Empty;
|
||||||
|
public string? Platform { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class SoftwareCheckUpdateResponse
|
||||||
|
{
|
||||||
|
public bool HasUpdate { get; set; }
|
||||||
|
public string? LatestVersion { get; set; }
|
||||||
|
public bool ForceUpdate { get; set; }
|
||||||
|
public string? DownloadUrl { get; set; }
|
||||||
|
public long FileSize { get; set; }
|
||||||
|
public string? FileHash { get; set; }
|
||||||
|
public string? Changelog { get; set; }
|
||||||
|
}
|
||||||
27
license-system-backend/src/License.Api/DTOs/StatsDtos.cs
Normal file
27
license-system-backend/src/License.Api/DTOs/StatsDtos.cs
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
namespace License.Api.DTOs;
|
||||||
|
|
||||||
|
public class ProjectStatsItem
|
||||||
|
{
|
||||||
|
public string ProjectId { get; set; } = string.Empty;
|
||||||
|
public string ProjectName { get; set; } = string.Empty;
|
||||||
|
public int TotalCards { get; set; }
|
||||||
|
public int ActiveCards { get; set; }
|
||||||
|
public int ActiveDevices { get; set; }
|
||||||
|
public decimal Revenue { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AgentStatsItem
|
||||||
|
{
|
||||||
|
public int AgentId { get; set; }
|
||||||
|
public string AgentCode { get; set; } = string.Empty;
|
||||||
|
public string? CompanyName { get; set; }
|
||||||
|
public int TotalCards { get; set; }
|
||||||
|
public int ActiveCards { get; set; }
|
||||||
|
public decimal TotalRevenue { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class LogStatsItem
|
||||||
|
{
|
||||||
|
public string Action { get; set; } = string.Empty;
|
||||||
|
public int Count { get; set; }
|
||||||
|
}
|
||||||
164
license-system-backend/src/License.Api/Data/AppDbContext.cs
Normal file
164
license-system-backend/src/License.Api/Data/AppDbContext.cs
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
using License.Api.Models;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace License.Api.Data;
|
||||||
|
|
||||||
|
public class AppDbContext : DbContext
|
||||||
|
{
|
||||||
|
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public DbSet<Project> Projects => Set<Project>();
|
||||||
|
public DbSet<ProjectPricing> ProjectPricing => Set<ProjectPricing>();
|
||||||
|
public DbSet<SoftwareVersion> SoftwareVersions => Set<SoftwareVersion>();
|
||||||
|
public DbSet<CardKey> CardKeys => Set<CardKey>();
|
||||||
|
public DbSet<Device> Devices => Set<Device>();
|
||||||
|
public DbSet<AccessLog> AccessLogs => Set<AccessLog>();
|
||||||
|
public DbSet<Statistic> Statistics => Set<Statistic>();
|
||||||
|
public DbSet<Admin> Admins => Set<Admin>();
|
||||||
|
public DbSet<Agent> Agents => Set<Agent>();
|
||||||
|
public DbSet<AgentTransaction> AgentTransactions => Set<AgentTransaction>();
|
||||||
|
public DbSet<CardKeyLog> CardKeyLogs => Set<CardKeyLog>();
|
||||||
|
public DbSet<SystemConfig> SystemConfigs => Set<SystemConfig>();
|
||||||
|
public DbSet<IdempotencyKeyRecord> IdempotencyKeys => Set<IdempotencyKeyRecord>();
|
||||||
|
|
||||||
|
public override int SaveChanges()
|
||||||
|
{
|
||||||
|
BumpCardKeyVersion();
|
||||||
|
return base.SaveChanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
BumpCardKeyVersion();
|
||||||
|
return base.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BumpCardKeyVersion()
|
||||||
|
{
|
||||||
|
foreach (var entry in ChangeTracker.Entries<CardKey>())
|
||||||
|
{
|
||||||
|
if (entry.State == EntityState.Modified)
|
||||||
|
entry.Entity.Version += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
modelBuilder.Entity<Project>(entity =>
|
||||||
|
{
|
||||||
|
entity.ToTable("Projects");
|
||||||
|
entity.HasIndex(p => p.ProjectId).IsUnique();
|
||||||
|
entity.Property(p => p.CreatedAt).HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||||
|
entity.Property(p => p.UpdatedAt).HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||||
|
entity.HasMany(p => p.Pricing)
|
||||||
|
.WithOne(p => p.Project)
|
||||||
|
.HasForeignKey(p => p.ProjectId)
|
||||||
|
.HasPrincipalKey(p => p.ProjectId);
|
||||||
|
entity.HasMany(p => p.Versions)
|
||||||
|
.WithOne(v => v.Project)
|
||||||
|
.HasForeignKey(v => v.ProjectId)
|
||||||
|
.HasPrincipalKey(p => p.ProjectId);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<ProjectPricing>(entity =>
|
||||||
|
{
|
||||||
|
entity.ToTable("ProjectPricing");
|
||||||
|
entity.HasIndex(p => new { p.ProjectId, p.CardType, p.DurationDays }).IsUnique();
|
||||||
|
entity.Property(p => p.CreatedAt).HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||||
|
entity.Property(p => p.UpdatedAt).HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<SoftwareVersion>(entity =>
|
||||||
|
{
|
||||||
|
entity.ToTable("SoftwareVersions");
|
||||||
|
entity.HasIndex(v => new { v.ProjectId, v.Version }).IsUnique();
|
||||||
|
entity.Property(v => v.PublishedAt).HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||||
|
entity.Property(v => v.CreatedAt).HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<CardKey>(entity =>
|
||||||
|
{
|
||||||
|
entity.ToTable("CardKeys");
|
||||||
|
entity.HasIndex(k => k.KeyCode).IsUnique();
|
||||||
|
entity.HasIndex(k => k.ProjectId);
|
||||||
|
entity.HasIndex(k => k.Status);
|
||||||
|
entity.Property(k => k.CreatedAt).HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||||
|
entity.HasOne(k => k.Project)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(k => k.ProjectId)
|
||||||
|
.HasPrincipalKey(p => p.ProjectId);
|
||||||
|
entity.HasMany(k => k.Devices).WithOne(d => d.CardKey).HasForeignKey(d => d.CardKeyId);
|
||||||
|
entity.HasMany(k => k.Logs).WithOne(l => l.CardKey).HasForeignKey(l => l.CardKeyId);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<Device>(entity =>
|
||||||
|
{
|
||||||
|
entity.ToTable("Devices");
|
||||||
|
entity.HasIndex(d => d.CardKeyId);
|
||||||
|
entity.HasIndex(d => d.DeviceId);
|
||||||
|
entity.HasIndex(d => new { d.CardKeyId, d.DeviceId }).IsUnique();
|
||||||
|
entity.Property(d => d.FirstLoginAt).HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<AccessLog>(entity =>
|
||||||
|
{
|
||||||
|
entity.ToTable("AccessLogs");
|
||||||
|
entity.HasIndex(l => l.ProjectId);
|
||||||
|
entity.HasIndex(l => l.CreatedAt);
|
||||||
|
entity.HasIndex(l => new { l.Action, l.CreatedAt });
|
||||||
|
entity.Property(l => l.CreatedAt).HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<Statistic>(entity =>
|
||||||
|
{
|
||||||
|
entity.ToTable("Statistics");
|
||||||
|
entity.HasIndex(s => new { s.ProjectId, s.Date }).IsUnique();
|
||||||
|
entity.Property(s => s.Date).HasColumnType("date");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<Admin>(entity =>
|
||||||
|
{
|
||||||
|
entity.ToTable("Admins");
|
||||||
|
entity.HasIndex(a => a.Username).IsUnique();
|
||||||
|
entity.Property(a => a.CreatedAt).HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||||
|
entity.Property(a => a.UpdatedAt).HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<Agent>(entity =>
|
||||||
|
{
|
||||||
|
entity.ToTable("Agents");
|
||||||
|
entity.HasIndex(a => a.AgentCode).IsUnique();
|
||||||
|
entity.Property(a => a.CreatedAt).HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||||
|
entity.Property(a => a.UpdatedAt).HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<AgentTransaction>(entity =>
|
||||||
|
{
|
||||||
|
entity.ToTable("AgentTransactions");
|
||||||
|
entity.Property(t => t.CreatedAt).HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<CardKeyLog>(entity =>
|
||||||
|
{
|
||||||
|
entity.ToTable("CardKeyLogs");
|
||||||
|
entity.Property(l => l.CreatedAt).HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<SystemConfig>(entity =>
|
||||||
|
{
|
||||||
|
entity.ToTable("SystemConfigs");
|
||||||
|
entity.HasIndex(c => c.ConfigKey).IsUnique();
|
||||||
|
entity.Property(c => c.UpdatedAt).HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<IdempotencyKeyRecord>(entity =>
|
||||||
|
{
|
||||||
|
entity.ToTable("IdempotencyKeys");
|
||||||
|
entity.HasIndex(i => i.IdempotencyKey).IsUnique();
|
||||||
|
entity.HasIndex(i => i.ExpiresAt);
|
||||||
|
entity.Property(i => i.CreatedAt).HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
using License.Api.Models;
|
||||||
|
using License.Api.Options;
|
||||||
|
using License.Api.Utils;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace License.Api.Data;
|
||||||
|
|
||||||
|
public class DatabaseInitializer
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
private readonly SeedOptions _seedOptions;
|
||||||
|
|
||||||
|
public DatabaseInitializer(AppDbContext db, IOptions<SeedOptions> seedOptions)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_seedOptions = seedOptions.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InitializeAsync()
|
||||||
|
{
|
||||||
|
await _db.Database.EnsureCreatedAsync();
|
||||||
|
|
||||||
|
if (!await _db.Admins.AnyAsync())
|
||||||
|
{
|
||||||
|
var admin = new Admin
|
||||||
|
{
|
||||||
|
Username = _seedOptions.AdminUser,
|
||||||
|
PasswordHash = PasswordHasher.Hash(_seedOptions.AdminPassword),
|
||||||
|
Email = _seedOptions.AdminEmail,
|
||||||
|
Role = "super_admin",
|
||||||
|
Status = "active",
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
UpdatedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
_db.Admins.Add(admin);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!await _db.SystemConfigs.AnyAsync())
|
||||||
|
{
|
||||||
|
_db.SystemConfigs.AddRange(DefaultConfigs());
|
||||||
|
}
|
||||||
|
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<SystemConfig> DefaultConfigs()
|
||||||
|
{
|
||||||
|
return new List<SystemConfig>
|
||||||
|
{
|
||||||
|
new() { ConfigKey = "feature.heartbeat", ConfigValue = "true", ValueType = "bool", Category = "feature", DisplayName = "Heartbeat", Description = "Enable heartbeat", IsPublic = true },
|
||||||
|
new() { ConfigKey = "feature.device_bind", ConfigValue = "true", ValueType = "bool", Category = "feature", DisplayName = "Device Bind", Description = "Enable device binding", IsPublic = true },
|
||||||
|
new() { ConfigKey = "feature.auto_update", ConfigValue = "true", ValueType = "bool", Category = "feature", DisplayName = "Auto Update", Description = "Enable auto update", IsPublic = true },
|
||||||
|
new() { ConfigKey = "feature.force_update", ConfigValue = "false", ValueType = "bool", Category = "feature", DisplayName = "Force Update", Description = "Enable force update", IsPublic = true },
|
||||||
|
new() { ConfigKey = "feature.agent_system", ConfigValue = "true", ValueType = "bool", Category = "feature", DisplayName = "Agent System", Description = "Enable agent system", IsPublic = false },
|
||||||
|
new() { ConfigKey = "feature.card_renewal", ConfigValue = "true", ValueType = "bool", Category = "feature", DisplayName = "Card Renewal", Description = "Enable card renewal", IsPublic = false },
|
||||||
|
new() { ConfigKey = "feature.trial_mode", ConfigValue = "false", ValueType = "bool", Category = "feature", DisplayName = "Trial Mode", Description = "Enable trial mode", IsPublic = true },
|
||||||
|
new() { ConfigKey = "trial.days", ConfigValue = "3", ValueType = "number", Category = "trial", DisplayName = "Trial Days", Description = "Trial duration days", IsPublic = true },
|
||||||
|
new() { ConfigKey = "auth.max_devices", ConfigValue = "1", ValueType = "number", Category = "auth", DisplayName = "Max Devices", Description = "Max devices per card", IsPublic = true },
|
||||||
|
new() { ConfigKey = "auth.allow_multi_device", ConfigValue = "false", ValueType = "bool", Category = "auth", DisplayName = "Allow Multi Device", Description = "Allow multi device online", IsPublic = true },
|
||||||
|
new() { ConfigKey = "auth.need_activate", ConfigValue = "true", ValueType = "bool", Category = "auth", DisplayName = "Need Activate", Description = "Card activation required", IsPublic = true },
|
||||||
|
new() { ConfigKey = "auth.expire_type", ConfigValue = "activate", ValueType = "string", Category = "auth", DisplayName = "Expire Type", Description = "activate/fix", IsPublic = true },
|
||||||
|
new() { ConfigKey = "heartbeat.enabled", ConfigValue = "true", ValueType = "bool", Category = "heartbeat", DisplayName = "Heartbeat Enabled", Description = "Enable heartbeat", IsPublic = true },
|
||||||
|
new() { ConfigKey = "heartbeat.interval", ConfigValue = "60", ValueType = "number", Category = "heartbeat", DisplayName = "Heartbeat Interval", Description = "Heartbeat interval seconds", IsPublic = true },
|
||||||
|
new() { ConfigKey = "heartbeat.timeout", ConfigValue = "180", ValueType = "number", Category = "heartbeat", DisplayName = "Heartbeat Timeout", Description = "Heartbeat timeout seconds", IsPublic = true },
|
||||||
|
new() { ConfigKey = "heartbeat.offline_action", ConfigValue = "exit", ValueType = "string", Category = "heartbeat", DisplayName = "Offline Action", Description = "exit/warning/none", IsPublic = true },
|
||||||
|
new() { ConfigKey = "ratelimit.enabled", ConfigValue = "true", ValueType = "bool", Category = "ratelimit", DisplayName = "Rate Limit", Description = "Enable rate limit", IsPublic = false },
|
||||||
|
new() { ConfigKey = "ratelimit.ip_per_minute", ConfigValue = "100", ValueType = "number", Category = "ratelimit", DisplayName = "IP per minute", Description = "IP requests per minute", IsPublic = false },
|
||||||
|
new() { ConfigKey = "ratelimit.device_per_minute", ConfigValue = "50", ValueType = "number", Category = "ratelimit", DisplayName = "Device per minute", Description = "Device requests per minute", IsPublic = false },
|
||||||
|
new() { ConfigKey = "ratelimit.block_duration", ConfigValue = "5", ValueType = "number", Category = "ratelimit", DisplayName = "Block Duration", Description = "Block duration minutes", IsPublic = false },
|
||||||
|
new() { ConfigKey = "risk.enabled", ConfigValue = "true", ValueType = "bool", Category = "risk", DisplayName = "Risk Enabled", Description = "Enable risk control", IsPublic = false },
|
||||||
|
new() { ConfigKey = "risk.check_location", ConfigValue = "true", ValueType = "bool", Category = "risk", DisplayName = "Check Location", Description = "Detect location change", IsPublic = false },
|
||||||
|
new() { ConfigKey = "risk.check_device_change", ConfigValue = "true", ValueType = "bool", Category = "risk", DisplayName = "Check Device Change", Description = "Detect device change", IsPublic = false },
|
||||||
|
new() { ConfigKey = "risk.auto_ban", ConfigValue = "false", ValueType = "bool", Category = "risk", DisplayName = "Auto Ban", Description = "Auto ban anomalies", IsPublic = false },
|
||||||
|
new() { ConfigKey = "risk.proxy_prefixes", ConfigValue = "", ValueType = "string", Category = "risk", DisplayName = "Proxy Prefixes", Description = "Comma separated IP prefixes", IsPublic = false },
|
||||||
|
new() { ConfigKey = "client.notice_title", ConfigValue = "", ValueType = "string", Category = "client", DisplayName = "Notice Title", Description = "Client notice title", IsPublic = true },
|
||||||
|
new() { ConfigKey = "client.notice_content", ConfigValue = "", ValueType = "string", Category = "client", DisplayName = "Notice Content", Description = "Client notice content", IsPublic = true },
|
||||||
|
new() { ConfigKey = "client.contact_url", ConfigValue = "", ValueType = "string", Category = "client", DisplayName = "Contact URL", Description = "Contact url", IsPublic = true },
|
||||||
|
new() { ConfigKey = "client.help_url", ConfigValue = "", ValueType = "string", Category = "client", DisplayName = "Help URL", Description = "Help url", IsPublic = true },
|
||||||
|
new() { ConfigKey = "client.show_balance", ConfigValue = "false", ValueType = "bool", Category = "client", DisplayName = "Show Balance", Description = "Show balance on client", IsPublic = true },
|
||||||
|
new() { ConfigKey = "system.site_name", ConfigValue = "License System", ValueType = "string", Category = "system", DisplayName = "Site Name", Description = "Site name", IsPublic = true },
|
||||||
|
new() { ConfigKey = "system.logo_url", ConfigValue = "", ValueType = "string", Category = "system", DisplayName = "Logo URL", Description = "Logo url", IsPublic = true },
|
||||||
|
new() { ConfigKey = "system.enable_register", ConfigValue = "false", ValueType = "bool", Category = "system", DisplayName = "Enable Register", Description = "Enable register", IsPublic = false },
|
||||||
|
new() { ConfigKey = "log.retention_days", ConfigValue = "90", ValueType = "number", Category = "system", DisplayName = "Log Retention", Description = "Log retention days", IsPublic = false }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
26
license-system-backend/src/License.Api/License.Api.csproj
Normal file
26
license-system-backend/src/License.Api/License.Api.csproj
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<InvariantGlobalization>true</InvariantGlobalization>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.8" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.8" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="8.0.8" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.8">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.8" />
|
||||||
|
<PackageReference Include="StackExchange.Redis" Version="2.7.33" />
|
||||||
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
||||||
|
<PackageReference Include="Serilog.AspNetCore" Version="8.0.1" />
|
||||||
|
<PackageReference Include="Serilog.Sinks.File" Version="5.0.0" />
|
||||||
|
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.1" />
|
||||||
|
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
|
||||||
|
<PackageReference Include="ClosedXML" Version="0.102.2" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Text.Json;
|
||||||
|
using License.Api.DTOs;
|
||||||
|
|
||||||
|
namespace License.Api.Middlewares;
|
||||||
|
|
||||||
|
public class ExceptionHandlingMiddleware
|
||||||
|
{
|
||||||
|
private readonly RequestDelegate _next;
|
||||||
|
private readonly ILogger<ExceptionHandlingMiddleware> _logger;
|
||||||
|
|
||||||
|
public ExceptionHandlingMiddleware(RequestDelegate next, ILogger<ExceptionHandlingMiddleware> logger)
|
||||||
|
{
|
||||||
|
_next = next;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InvokeAsync(HttpContext context)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _next(context);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Unhandled exception");
|
||||||
|
context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
|
||||||
|
context.Response.ContentType = "application/json";
|
||||||
|
var payload = ApiResponse.Fail(500, "internal_error");
|
||||||
|
await context.Response.WriteAsync(JsonSerializer.Serialize(payload));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using License.Api.DTOs;
|
||||||
|
using License.Api.Options;
|
||||||
|
using License.Api.Services;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace License.Api.Middlewares;
|
||||||
|
|
||||||
|
public class RateLimitMiddleware
|
||||||
|
{
|
||||||
|
private readonly RequestDelegate _next;
|
||||||
|
private readonly RateLimitOptions _options;
|
||||||
|
|
||||||
|
public RateLimitMiddleware(RequestDelegate next, IOptions<RateLimitOptions> options)
|
||||||
|
{
|
||||||
|
_next = next;
|
||||||
|
_options = options.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InvokeAsync(HttpContext context, IRateLimitStore store, ConfigService configService)
|
||||||
|
{
|
||||||
|
var enabled = await configService.GetBoolAsync("ratelimit.enabled", _options.Enabled);
|
||||||
|
if (!enabled)
|
||||||
|
{
|
||||||
|
await _next(context);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!context.Request.Path.StartsWithSegments("/api"))
|
||||||
|
{
|
||||||
|
await _next(context);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var ipLimit = await configService.GetIntAsync("ratelimit.ip_per_minute", _options.IpPerMinute);
|
||||||
|
var deviceLimit = await configService.GetIntAsync("ratelimit.device_per_minute", _options.DevicePerMinute);
|
||||||
|
var blockMinutes = await configService.GetIntAsync("ratelimit.block_duration", _options.BlockDurationMinutes);
|
||||||
|
|
||||||
|
var ip = context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
||||||
|
var ipBlockKey = $"ratelimit:block:ip:{ip}";
|
||||||
|
if (blockMinutes > 0 && await store.ExistsAsync(ipBlockKey))
|
||||||
|
{
|
||||||
|
context.Response.StatusCode = StatusCodes.Status429TooManyRequests;
|
||||||
|
context.Response.ContentType = "application/json";
|
||||||
|
var payload = ApiResponse.Fail(1009, "rate_limit_exceeded");
|
||||||
|
await context.Response.WriteAsync(JsonSerializer.Serialize(payload));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var ipKey = $"ratelimit:ip:{ip}:{DateTime.UtcNow:yyyyMMddHHmm}";
|
||||||
|
var ipCount = await store.IncrementAsync(ipKey, TimeSpan.FromMinutes(1));
|
||||||
|
|
||||||
|
if (ipCount > ipLimit)
|
||||||
|
{
|
||||||
|
if (blockMinutes > 0)
|
||||||
|
await store.SetAsync(ipBlockKey, TimeSpan.FromMinutes(blockMinutes));
|
||||||
|
|
||||||
|
context.Response.StatusCode = StatusCodes.Status429TooManyRequests;
|
||||||
|
context.Response.ContentType = "application/json";
|
||||||
|
var payload = ApiResponse.Fail(1009, "rate_limit_exceeded");
|
||||||
|
await context.Response.WriteAsync(JsonSerializer.Serialize(payload));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var deviceId = context.Request.Headers["X-Device-Id"].ToString();
|
||||||
|
if (!string.IsNullOrWhiteSpace(deviceId))
|
||||||
|
{
|
||||||
|
var deviceBlockKey = $"ratelimit:block:device:{deviceId}";
|
||||||
|
if (blockMinutes > 0 && await store.ExistsAsync(deviceBlockKey))
|
||||||
|
{
|
||||||
|
context.Response.StatusCode = StatusCodes.Status429TooManyRequests;
|
||||||
|
context.Response.ContentType = "application/json";
|
||||||
|
var payload = ApiResponse.Fail(1009, "rate_limit_exceeded");
|
||||||
|
await context.Response.WriteAsync(JsonSerializer.Serialize(payload));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var deviceKey = $"ratelimit:device:{deviceId}:{DateTime.UtcNow:yyyyMMddHHmm}";
|
||||||
|
var deviceCount = await store.IncrementAsync(deviceKey, TimeSpan.FromMinutes(1));
|
||||||
|
if (deviceCount > deviceLimit)
|
||||||
|
{
|
||||||
|
if (blockMinutes > 0)
|
||||||
|
await store.SetAsync(deviceBlockKey, TimeSpan.FromMinutes(blockMinutes));
|
||||||
|
|
||||||
|
context.Response.StatusCode = StatusCodes.Status429TooManyRequests;
|
||||||
|
context.Response.ContentType = "application/json";
|
||||||
|
var payload = ApiResponse.Fail(1009, "rate_limit_exceeded");
|
||||||
|
await context.Response.WriteAsync(JsonSerializer.Serialize(payload));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await _next(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
29
license-system-backend/src/License.Api/Models/AccessLog.cs
Normal file
29
license-system-backend/src/License.Api/Models/AccessLog.cs
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace License.Api.Models;
|
||||||
|
|
||||||
|
public class AccessLog
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(32)]
|
||||||
|
public string? ProjectId { get; set; }
|
||||||
|
|
||||||
|
public int? CardKeyId { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(64)]
|
||||||
|
public string? DeviceId { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(50)]
|
||||||
|
public string Action { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string? IpAddress { get; set; }
|
||||||
|
|
||||||
|
public string? UserAgent { get; set; }
|
||||||
|
|
||||||
|
public int? ResponseCode { get; set; }
|
||||||
|
|
||||||
|
public int? ResponseTime { get; set; }
|
||||||
|
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
}
|
||||||
33
license-system-backend/src/License.Api/Models/Admin.cs
Normal file
33
license-system-backend/src/License.Api/Models/Admin.cs
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace License.Api.Models;
|
||||||
|
|
||||||
|
public class Admin
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(50)]
|
||||||
|
public string Username { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string PasswordHash { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[MaxLength(100)]
|
||||||
|
public string? Email { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(20)]
|
||||||
|
public string Role { get; set; } = "admin";
|
||||||
|
|
||||||
|
public string? Permissions { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(20)]
|
||||||
|
public string Status { get; set; } = "active";
|
||||||
|
|
||||||
|
public DateTime? LastLoginAt { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(45)]
|
||||||
|
public string? LastLoginIp { get; set; }
|
||||||
|
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
|
||||||
|
public DateTime UpdatedAt { get; set; }
|
||||||
|
}
|
||||||
46
license-system-backend/src/License.Api/Models/Agent.cs
Normal file
46
license-system-backend/src/License.Api/Models/Agent.cs
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace License.Api.Models;
|
||||||
|
|
||||||
|
public class Agent
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
public int? AdminId { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(20)]
|
||||||
|
public string AgentCode { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[MaxLength(100)]
|
||||||
|
public string? CompanyName { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(50)]
|
||||||
|
public string? ContactPerson { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(20)]
|
||||||
|
public string? ContactPhone { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(100)]
|
||||||
|
public string? ContactEmail { get; set; }
|
||||||
|
|
||||||
|
public string PasswordHash { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public decimal Balance { get; set; }
|
||||||
|
|
||||||
|
public decimal Discount { get; set; } = 100m;
|
||||||
|
|
||||||
|
public decimal CreditLimit { get; set; }
|
||||||
|
|
||||||
|
public int MaxProjects { get; set; }
|
||||||
|
|
||||||
|
public string? AllowedProjects { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(20)]
|
||||||
|
public string Status { get; set; } = "active";
|
||||||
|
|
||||||
|
public DateTime? LastLoginAt { get; set; }
|
||||||
|
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
|
||||||
|
public DateTime UpdatedAt { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace License.Api.Models;
|
||||||
|
|
||||||
|
public class AgentTransaction
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
public int AgentId { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(20)]
|
||||||
|
public string Type { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public decimal Amount { get; set; }
|
||||||
|
|
||||||
|
public decimal BalanceBefore { get; set; }
|
||||||
|
|
||||||
|
public decimal BalanceAfter { get; set; }
|
||||||
|
|
||||||
|
public int? CardKeyId { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(200)]
|
||||||
|
public string? Remark { get; set; }
|
||||||
|
|
||||||
|
public int? CreatedBy { get; set; }
|
||||||
|
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
|
||||||
|
public Agent? Agent { get; set; }
|
||||||
|
}
|
||||||
59
license-system-backend/src/License.Api/Models/CardKey.cs
Normal file
59
license-system-backend/src/License.Api/Models/CardKey.cs
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace License.Api.Models;
|
||||||
|
|
||||||
|
public class CardKey
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(32)]
|
||||||
|
public string? ProjectId { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(32)]
|
||||||
|
public string KeyCode { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[MaxLength(20)]
|
||||||
|
public string CardType { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public int DurationDays { get; set; }
|
||||||
|
|
||||||
|
public DateTime? ExpireTime { get; set; }
|
||||||
|
|
||||||
|
public int MaxDevices { get; set; } = 1;
|
||||||
|
|
||||||
|
[MaxLength(64)]
|
||||||
|
public string? MachineCode { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(20)]
|
||||||
|
public string Status { get; set; } = "unused";
|
||||||
|
|
||||||
|
public DateTime? ActivateTime { get; set; }
|
||||||
|
|
||||||
|
public DateTime? LastUsedAt { get; set; }
|
||||||
|
|
||||||
|
public long UsedDuration { get; set; }
|
||||||
|
|
||||||
|
public int? GeneratedBy { get; set; }
|
||||||
|
|
||||||
|
public int? AgentId { get; set; }
|
||||||
|
|
||||||
|
public decimal? SoldPrice { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(200)]
|
||||||
|
public string? Note { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(36)]
|
||||||
|
public string? BatchId { get; set; }
|
||||||
|
|
||||||
|
public int Version { get; set; } = 1;
|
||||||
|
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
|
||||||
|
public DateTime? DeletedAt { get; set; }
|
||||||
|
|
||||||
|
public Project? Project { get; set; }
|
||||||
|
|
||||||
|
public ICollection<Device> Devices { get; set; } = new List<Device>();
|
||||||
|
|
||||||
|
public ICollection<CardKeyLog> Logs { get; set; } = new List<CardKeyLog>();
|
||||||
|
}
|
||||||
27
license-system-backend/src/License.Api/Models/CardKeyLog.cs
Normal file
27
license-system-backend/src/License.Api/Models/CardKeyLog.cs
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace License.Api.Models;
|
||||||
|
|
||||||
|
public class CardKeyLog
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
public int CardKeyId { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(50)]
|
||||||
|
public string Action { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public int? OperatorId { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(20)]
|
||||||
|
public string? OperatorType { get; set; }
|
||||||
|
|
||||||
|
public string? Details { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(45)]
|
||||||
|
public string? IpAddress { get; set; }
|
||||||
|
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
|
||||||
|
public CardKey? CardKey { get; set; }
|
||||||
|
}
|
||||||
35
license-system-backend/src/License.Api/Models/Device.cs
Normal file
35
license-system-backend/src/License.Api/Models/Device.cs
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace License.Api.Models;
|
||||||
|
|
||||||
|
public class Device
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
public int CardKeyId { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(64)]
|
||||||
|
public string DeviceId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[MaxLength(100)]
|
||||||
|
public string? DeviceName { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(100)]
|
||||||
|
public string? OsInfo { get; set; }
|
||||||
|
|
||||||
|
public DateTime? LastHeartbeat { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(45)]
|
||||||
|
public string? IpAddress { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(100)]
|
||||||
|
public string? Location { get; set; }
|
||||||
|
|
||||||
|
public bool IsActive { get; set; } = true;
|
||||||
|
|
||||||
|
public DateTime FirstLoginAt { get; set; }
|
||||||
|
|
||||||
|
public DateTime? DeletedAt { get; set; }
|
||||||
|
|
||||||
|
public CardKey? CardKey { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace License.Api.Models;
|
||||||
|
|
||||||
|
public class IdempotencyKeyRecord
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(64)]
|
||||||
|
public string IdempotencyKey { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[MaxLength(200)]
|
||||||
|
public string RequestPath { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[MaxLength(64)]
|
||||||
|
public string? RequestHash { get; set; }
|
||||||
|
|
||||||
|
public int? ResponseCode { get; set; }
|
||||||
|
|
||||||
|
public string? ResponseBody { get; set; }
|
||||||
|
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
|
||||||
|
public DateTime ExpiresAt { get; set; }
|
||||||
|
}
|
||||||
43
license-system-backend/src/License.Api/Models/Project.cs
Normal file
43
license-system-backend/src/License.Api/Models/Project.cs
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace License.Api.Models;
|
||||||
|
|
||||||
|
public class Project
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(32)]
|
||||||
|
public string ProjectId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[MaxLength(64)]
|
||||||
|
public string ProjectKey { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[MaxLength(64)]
|
||||||
|
public string ProjectSecret { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[MaxLength(100)]
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string? Description { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(500)]
|
||||||
|
public string? IconUrl { get; set; }
|
||||||
|
|
||||||
|
public int MaxDevices { get; set; } = 1;
|
||||||
|
|
||||||
|
public bool AutoUpdate { get; set; } = true;
|
||||||
|
|
||||||
|
public bool IsEnabled { get; set; } = true;
|
||||||
|
|
||||||
|
public int? CreatedBy { get; set; }
|
||||||
|
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
|
||||||
|
public DateTime UpdatedAt { get; set; }
|
||||||
|
|
||||||
|
public string? DocsContent { get; set; }
|
||||||
|
|
||||||
|
public ICollection<ProjectPricing> Pricing { get; set; } = new List<ProjectPricing>();
|
||||||
|
|
||||||
|
public ICollection<SoftwareVersion> Versions { get; set; } = new List<SoftwareVersion>();
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace License.Api.Models;
|
||||||
|
|
||||||
|
public class ProjectPricing
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(32)]
|
||||||
|
public string ProjectId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[MaxLength(20)]
|
||||||
|
public string CardType { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public int DurationDays { get; set; }
|
||||||
|
|
||||||
|
public decimal OriginalPrice { get; set; }
|
||||||
|
|
||||||
|
public bool IsEnabled { get; set; } = true;
|
||||||
|
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
|
||||||
|
public DateTime UpdatedAt { get; set; }
|
||||||
|
|
||||||
|
public Project? Project { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace License.Api.Models;
|
||||||
|
|
||||||
|
public class SoftwareVersion
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(32)]
|
||||||
|
public string ProjectId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[MaxLength(20)]
|
||||||
|
public string Version { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[MaxLength(500)]
|
||||||
|
public string FileUrl { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public long? FileSize { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(64)]
|
||||||
|
public string? FileHash { get; set; }
|
||||||
|
|
||||||
|
public string? EncryptionKey { get; set; }
|
||||||
|
|
||||||
|
public string? Changelog { get; set; }
|
||||||
|
|
||||||
|
public bool IsForceUpdate { get; set; }
|
||||||
|
|
||||||
|
public bool IsStable { get; set; } = true;
|
||||||
|
|
||||||
|
public DateTime PublishedAt { get; set; }
|
||||||
|
|
||||||
|
public int? CreatedBy { get; set; }
|
||||||
|
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
|
||||||
|
public Project? Project { get; set; }
|
||||||
|
}
|
||||||
20
license-system-backend/src/License.Api/Models/Statistic.cs
Normal file
20
license-system-backend/src/License.Api/Models/Statistic.cs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
namespace License.Api.Models;
|
||||||
|
|
||||||
|
public class Statistic
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
public string? ProjectId { get; set; }
|
||||||
|
|
||||||
|
public DateOnly Date { get; set; }
|
||||||
|
|
||||||
|
public int ActiveUsers { get; set; }
|
||||||
|
|
||||||
|
public int NewUsers { get; set; }
|
||||||
|
|
||||||
|
public int TotalDownloads { get; set; }
|
||||||
|
|
||||||
|
public long TotalDuration { get; set; }
|
||||||
|
|
||||||
|
public decimal Revenue { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace License.Api.Models;
|
||||||
|
|
||||||
|
public class SystemConfig
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(50)]
|
||||||
|
public string ConfigKey { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string? ConfigValue { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(20)]
|
||||||
|
public string ValueType { get; set; } = "string";
|
||||||
|
|
||||||
|
[MaxLength(50)]
|
||||||
|
public string Category { get; set; } = "general";
|
||||||
|
|
||||||
|
[MaxLength(100)]
|
||||||
|
public string? DisplayName { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(500)]
|
||||||
|
public string? Description { get; set; }
|
||||||
|
|
||||||
|
public string? Options { get; set; }
|
||||||
|
|
||||||
|
public bool IsPublic { get; set; }
|
||||||
|
|
||||||
|
public DateTime UpdatedAt { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace License.Api.Options;
|
||||||
|
|
||||||
|
public class HeartbeatOptions
|
||||||
|
{
|
||||||
|
public bool Enabled { get; set; } = true;
|
||||||
|
public int IntervalSeconds { get; set; } = 60;
|
||||||
|
public int TimeoutSeconds { get; set; } = 180;
|
||||||
|
}
|
||||||
10
license-system-backend/src/License.Api/Options/JwtOptions.cs
Normal file
10
license-system-backend/src/License.Api/Options/JwtOptions.cs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
namespace License.Api.Options;
|
||||||
|
|
||||||
|
public class JwtOptions
|
||||||
|
{
|
||||||
|
public string Secret { get; set; } = string.Empty;
|
||||||
|
public string Issuer { get; set; } = string.Empty;
|
||||||
|
public int ExpireMinutes { get; set; } = 1440;
|
||||||
|
public int AdminExpireMinutes { get; set; } = 720;
|
||||||
|
public int AgentExpireMinutes { get; set; } = 1440;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace License.Api.Options;
|
||||||
|
|
||||||
|
public class RateLimitOptions
|
||||||
|
{
|
||||||
|
public bool Enabled { get; set; } = true;
|
||||||
|
public int IpPerMinute { get; set; } = 100;
|
||||||
|
public int DevicePerMinute { get; set; } = 50;
|
||||||
|
public int BlockDurationMinutes { get; set; } = 5;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace License.Api.Options;
|
||||||
|
|
||||||
|
public class RedisOptions
|
||||||
|
{
|
||||||
|
public string ConnectionString { get; set; } = string.Empty;
|
||||||
|
public bool Enabled { get; set; } = true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace License.Api.Options;
|
||||||
|
|
||||||
|
public class SecurityOptions
|
||||||
|
{
|
||||||
|
public bool SignatureEnabled { get; set; } = true;
|
||||||
|
public int TimestampToleranceSeconds { get; set; } = 300;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace License.Api.Options;
|
||||||
|
|
||||||
|
public class SeedOptions
|
||||||
|
{
|
||||||
|
public string AdminUser { get; set; } = "admin";
|
||||||
|
|
||||||
|
public string AdminPassword { get; set; } = "admin123";
|
||||||
|
|
||||||
|
public string? AdminEmail { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace License.Api.Options;
|
||||||
|
|
||||||
|
public class StorageOptions
|
||||||
|
{
|
||||||
|
public string UploadRoot { get; set; } = "uploads";
|
||||||
|
public int MaxUploadMb { get; set; } = 200;
|
||||||
|
public string? ClientRsaPublicKeyPem { get; set; }
|
||||||
|
public bool RequireHttpsForDownloadKey { get; set; } = true;
|
||||||
|
}
|
||||||
165
license-system-backend/src/License.Api/Program.cs
Normal file
165
license-system-backend/src/License.Api/Program.cs
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
using System.Text;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
|
using System.IO;
|
||||||
|
using License.Api.Data;
|
||||||
|
using License.Api.Middlewares;
|
||||||
|
using License.Api.Options;
|
||||||
|
using License.Api.Security;
|
||||||
|
using License.Api.Services;
|
||||||
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
|
using Microsoft.AspNetCore.DataProtection;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
using Serilog;
|
||||||
|
using StackExchange.Redis;
|
||||||
|
|
||||||
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
JwtSecurityTokenHandler.DefaultMapInboundClaims = false;
|
||||||
|
|
||||||
|
builder.Host.UseSerilog((context, config) =>
|
||||||
|
config.ReadFrom.Configuration(context.Configuration));
|
||||||
|
|
||||||
|
builder.Services.Configure<JwtOptions>(builder.Configuration.GetSection("Jwt"));
|
||||||
|
builder.Services.Configure<SecurityOptions>(builder.Configuration.GetSection("Security"));
|
||||||
|
builder.Services.Configure<StorageOptions>(builder.Configuration.GetSection("Storage"));
|
||||||
|
builder.Services.Configure<RedisOptions>(builder.Configuration.GetSection("Redis"));
|
||||||
|
builder.Services.Configure<RateLimitOptions>(builder.Configuration.GetSection("RateLimit"));
|
||||||
|
builder.Services.Configure<HeartbeatOptions>(builder.Configuration.GetSection("Heartbeat"));
|
||||||
|
builder.Services.Configure<SeedOptions>(builder.Configuration.GetSection("Seed"));
|
||||||
|
|
||||||
|
builder.Services.AddDbContext<AppDbContext>(options =>
|
||||||
|
{
|
||||||
|
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"));
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.Services.AddMemoryCache();
|
||||||
|
|
||||||
|
var dataProtectionPath = Path.Combine(builder.Environment.ContentRootPath, "data", "protection-keys");
|
||||||
|
Directory.CreateDirectory(dataProtectionPath);
|
||||||
|
builder.Services.AddDataProtection()
|
||||||
|
.PersistKeysToFileSystem(new DirectoryInfo(dataProtectionPath))
|
||||||
|
.SetApplicationName("license-system");
|
||||||
|
|
||||||
|
builder.Services.AddSingleton<JwtTokenService>();
|
||||||
|
builder.Services.AddSingleton<HmacSignatureService>();
|
||||||
|
builder.Services.AddScoped<ConfigService>();
|
||||||
|
builder.Services.AddScoped<RiskControlService>();
|
||||||
|
builder.Services.AddScoped<AuthService>();
|
||||||
|
builder.Services.AddScoped<CardService>();
|
||||||
|
builder.Services.AddScoped<SoftwareService>();
|
||||||
|
builder.Services.AddScoped<SoftwareEncryptionService>();
|
||||||
|
builder.Services.AddScoped<FileStorageService>();
|
||||||
|
builder.Services.AddScoped<StatsService>();
|
||||||
|
builder.Services.AddScoped<IdempotencyService>();
|
||||||
|
builder.Services.AddScoped<AdminAccessService>();
|
||||||
|
builder.Services.AddScoped<DatabaseInitializer>();
|
||||||
|
builder.Services.AddHostedService<HeartbeatMonitorService>();
|
||||||
|
builder.Services.AddHostedService<StatsAggregationService>();
|
||||||
|
builder.Services.AddHostedService<MaintenanceService>();
|
||||||
|
|
||||||
|
var redisOptions = builder.Configuration.GetSection("Redis").Get<RedisOptions>() ?? new RedisOptions();
|
||||||
|
if (redisOptions.Enabled && !string.IsNullOrWhiteSpace(redisOptions.ConnectionString))
|
||||||
|
{
|
||||||
|
builder.Services.AddSingleton<IConnectionMultiplexer>(_ =>
|
||||||
|
ConnectionMultiplexer.Connect(redisOptions.ConnectionString));
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.Services.AddScoped<IRateLimitStore>(sp =>
|
||||||
|
{
|
||||||
|
var multiplexer = sp.GetService<IConnectionMultiplexer>();
|
||||||
|
if (multiplexer != null)
|
||||||
|
return new RedisRateLimitStore(multiplexer);
|
||||||
|
|
||||||
|
return new MemoryRateLimitStore(sp.GetRequiredService<Microsoft.Extensions.Caching.Memory.IMemoryCache>());
|
||||||
|
});
|
||||||
|
|
||||||
|
var jwtOptions = builder.Configuration.GetSection("Jwt").Get<JwtOptions>() ?? new JwtOptions();
|
||||||
|
var key = Encoding.UTF8.GetBytes(jwtOptions.Secret);
|
||||||
|
|
||||||
|
builder.Services.AddAuthentication(options =>
|
||||||
|
{
|
||||||
|
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
|
||||||
|
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
|
||||||
|
})
|
||||||
|
.AddJwtBearer(options =>
|
||||||
|
{
|
||||||
|
options.TokenValidationParameters = new TokenValidationParameters
|
||||||
|
{
|
||||||
|
ValidateIssuer = true,
|
||||||
|
ValidIssuer = jwtOptions.Issuer,
|
||||||
|
ValidateAudience = false,
|
||||||
|
IssuerSigningKey = new SymmetricSecurityKey(key),
|
||||||
|
ValidateLifetime = true,
|
||||||
|
ClockSkew = TimeSpan.FromSeconds(30)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.Services.AddAuthorization(options =>
|
||||||
|
{
|
||||||
|
options.AddPolicy("Admin", policy =>
|
||||||
|
policy.RequireClaim("type", "admin"));
|
||||||
|
options.AddPolicy("Agent", policy =>
|
||||||
|
policy.RequireClaim("type", "agent"));
|
||||||
|
options.AddPolicy("SuperAdmin", policy =>
|
||||||
|
policy.RequireClaim("type", "admin").RequireClaim("role", "super_admin"));
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.Services.AddControllers().AddJsonOptions(options =>
|
||||||
|
{
|
||||||
|
options.JsonSerializerOptions.PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase;
|
||||||
|
options.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles;
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.Services.AddCors(options =>
|
||||||
|
{
|
||||||
|
options.AddPolicy("AllowAll", policy =>
|
||||||
|
{
|
||||||
|
policy.AllowAnyHeader()
|
||||||
|
.AllowAnyMethod();
|
||||||
|
|
||||||
|
var allowAnyCors = builder.Configuration.GetValue<bool>("Cors:AllowAny");
|
||||||
|
var origins = builder.Configuration.GetSection("Cors:AllowedOrigins").Get<string[]>() ?? Array.Empty<string>();
|
||||||
|
origins = origins
|
||||||
|
.Where(origin => !string.IsNullOrWhiteSpace(origin))
|
||||||
|
.Select(origin => origin.Trim())
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
if (allowAnyCors || builder.Environment.IsDevelopment())
|
||||||
|
{
|
||||||
|
policy.AllowAnyOrigin();
|
||||||
|
}
|
||||||
|
else if (origins.Length > 0)
|
||||||
|
{
|
||||||
|
policy.WithOrigins(origins);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.Services.AddEndpointsApiExplorer();
|
||||||
|
builder.Services.AddSwaggerGen();
|
||||||
|
|
||||||
|
var app = builder.Build();
|
||||||
|
|
||||||
|
app.UseSerilogRequestLogging();
|
||||||
|
app.UseMiddleware<ExceptionHandlingMiddleware>();
|
||||||
|
app.UseMiddleware<RateLimitMiddleware>();
|
||||||
|
|
||||||
|
app.UseSwagger();
|
||||||
|
app.UseSwaggerUI();
|
||||||
|
|
||||||
|
app.UseCors("AllowAll");
|
||||||
|
|
||||||
|
app.UseAuthentication();
|
||||||
|
app.UseAuthorization();
|
||||||
|
|
||||||
|
app.MapControllers();
|
||||||
|
|
||||||
|
using (var scope = app.Services.CreateScope())
|
||||||
|
{
|
||||||
|
var initializer = scope.ServiceProvider.GetRequiredService<DatabaseInitializer>();
|
||||||
|
await initializer.InitializeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
app.Run();
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using License.Api.Options;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace License.Api.Security;
|
||||||
|
|
||||||
|
public class HmacSignatureService
|
||||||
|
{
|
||||||
|
private readonly SecurityOptions _options;
|
||||||
|
|
||||||
|
public HmacSignatureService(IOptions<SecurityOptions> options)
|
||||||
|
{
|
||||||
|
_options = options.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsEnabled => _options.SignatureEnabled;
|
||||||
|
|
||||||
|
public bool ValidateTimestamp(long timestamp)
|
||||||
|
{
|
||||||
|
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||||
|
return Math.Abs(now - timestamp) <= _options.TimestampToleranceSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Sign(string payload, string secret)
|
||||||
|
{
|
||||||
|
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
|
||||||
|
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(payload));
|
||||||
|
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Verify(string payload, string signature, string secret)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(signature))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var expected = Sign(payload, secret);
|
||||||
|
return string.Equals(signature, expected, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using System.Text;
|
||||||
|
using License.Api.Models;
|
||||||
|
using License.Api.Options;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
|
||||||
|
namespace License.Api.Security;
|
||||||
|
|
||||||
|
public class JwtTokenService
|
||||||
|
{
|
||||||
|
private readonly JwtOptions _options;
|
||||||
|
|
||||||
|
public JwtTokenService(IOptions<JwtOptions> options)
|
||||||
|
{
|
||||||
|
_options = options.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string CreateAdminToken(Admin admin)
|
||||||
|
{
|
||||||
|
var claims = new List<Claim>
|
||||||
|
{
|
||||||
|
new(JwtRegisteredClaimNames.Sub, admin.Id.ToString()),
|
||||||
|
new("role", admin.Role),
|
||||||
|
new("type", "admin"),
|
||||||
|
new("username", admin.Username)
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(admin.Permissions))
|
||||||
|
claims.Add(new Claim("permissions", admin.Permissions));
|
||||||
|
|
||||||
|
return CreateToken(claims, _options.AdminExpireMinutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string CreateAgentToken(Agent agent)
|
||||||
|
{
|
||||||
|
var claims = new List<Claim>
|
||||||
|
{
|
||||||
|
new(JwtRegisteredClaimNames.Sub, agent.Id.ToString()),
|
||||||
|
new("role", "agent"),
|
||||||
|
new("type", "agent"),
|
||||||
|
new("agentCode", agent.AgentCode)
|
||||||
|
};
|
||||||
|
|
||||||
|
return CreateToken(claims, _options.AgentExpireMinutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string CreateCardToken(CardKey cardKey, string deviceId)
|
||||||
|
{
|
||||||
|
var claims = new List<Claim>
|
||||||
|
{
|
||||||
|
new(JwtRegisteredClaimNames.Sub, cardKey.Id.ToString()),
|
||||||
|
new("type", "card"),
|
||||||
|
new("projectId", cardKey.ProjectId ?? string.Empty),
|
||||||
|
new("deviceId", deviceId)
|
||||||
|
};
|
||||||
|
|
||||||
|
return CreateToken(claims, _options.ExpireMinutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ClaimsPrincipal? ValidateToken(string token)
|
||||||
|
{
|
||||||
|
var tokenHandler = new JwtSecurityTokenHandler();
|
||||||
|
var key = Encoding.UTF8.GetBytes(_options.Secret);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var principal = tokenHandler.ValidateToken(token, new TokenValidationParameters
|
||||||
|
{
|
||||||
|
ValidateIssuer = true,
|
||||||
|
ValidIssuer = _options.Issuer,
|
||||||
|
ValidateAudience = false,
|
||||||
|
IssuerSigningKey = new SymmetricSecurityKey(key),
|
||||||
|
ValidateLifetime = true,
|
||||||
|
ClockSkew = TimeSpan.FromSeconds(30)
|
||||||
|
}, out _);
|
||||||
|
|
||||||
|
return principal;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private string CreateToken(IEnumerable<Claim> claims, int expireMinutes)
|
||||||
|
{
|
||||||
|
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.Secret));
|
||||||
|
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
|
||||||
|
|
||||||
|
var token = new JwtSecurityToken(
|
||||||
|
issuer: _options.Issuer,
|
||||||
|
audience: null,
|
||||||
|
claims: claims,
|
||||||
|
expires: DateTime.UtcNow.AddMinutes(expireMinutes),
|
||||||
|
signingCredentials: creds);
|
||||||
|
|
||||||
|
return new JwtSecurityTokenHandler().WriteToken(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using System.Text.Json;
|
||||||
|
using License.Api.Data;
|
||||||
|
using License.Api.Models;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace License.Api.Services;
|
||||||
|
|
||||||
|
public class AdminScope
|
||||||
|
{
|
||||||
|
public Admin Admin { get; }
|
||||||
|
public bool IsSuperAdmin { get; }
|
||||||
|
public bool HasAllProjects { get; }
|
||||||
|
public HashSet<string> AllowedProjects { get; }
|
||||||
|
|
||||||
|
public AdminScope(Admin admin, bool isSuperAdmin, bool hasAllProjects, HashSet<string> allowedProjects)
|
||||||
|
{
|
||||||
|
Admin = admin;
|
||||||
|
IsSuperAdmin = isSuperAdmin;
|
||||||
|
HasAllProjects = hasAllProjects;
|
||||||
|
AllowedProjects = allowedProjects;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool CanAccessProject(string? projectId)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(projectId))
|
||||||
|
return false;
|
||||||
|
if (IsSuperAdmin || HasAllProjects)
|
||||||
|
return true;
|
||||||
|
return AllowedProjects.Contains(projectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AddProject(string projectId)
|
||||||
|
{
|
||||||
|
if (IsSuperAdmin || HasAllProjects)
|
||||||
|
return;
|
||||||
|
if (!string.IsNullOrWhiteSpace(projectId))
|
||||||
|
AllowedProjects.Add(projectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string? SerializePermissions()
|
||||||
|
{
|
||||||
|
if (IsSuperAdmin || HasAllProjects)
|
||||||
|
return Admin.Permissions;
|
||||||
|
return JsonSerializer.Serialize(AllowedProjects.OrderBy(p => p).ToList());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AdminAccessService
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
|
||||||
|
public AdminAccessService(AppDbContext db)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<AdminScope?> GetScopeAsync(ClaimsPrincipal user)
|
||||||
|
{
|
||||||
|
var sub = user.FindFirst(JwtRegisteredClaimNames.Sub)?.Value;
|
||||||
|
if (!int.TryParse(sub, out var adminId))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var admin = await _db.Admins.FirstOrDefaultAsync(a => a.Id == adminId);
|
||||||
|
if (admin == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var isSuperAdmin = string.Equals(admin.Role, "super_admin", StringComparison.OrdinalIgnoreCase);
|
||||||
|
var (hasAllProjects, allowedProjects) = ParsePermissions(admin.Permissions);
|
||||||
|
return new AdminScope(admin, isSuperAdmin, hasAllProjects, allowedProjects);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static (bool hasAllProjects, HashSet<string> allowedProjects) ParsePermissions(string? raw)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(raw))
|
||||||
|
return (false, new HashSet<string>(StringComparer.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
var trimmed = raw.Trim();
|
||||||
|
if (trimmed == "*")
|
||||||
|
return (true, new HashSet<string>(StringComparer.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
List<string>? items = null;
|
||||||
|
if (trimmed.StartsWith("[", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
items = JsonSerializer.Deserialize<List<string>>(trimmed);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
items = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (items == null)
|
||||||
|
{
|
||||||
|
items = trimmed
|
||||||
|
.Split(new[] { ',', ';', '\n', '\r', '\t' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var item in items)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(item))
|
||||||
|
continue;
|
||||||
|
if (item == "*")
|
||||||
|
return (true, new HashSet<string>(StringComparer.OrdinalIgnoreCase));
|
||||||
|
set.Add(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (false, set);
|
||||||
|
}
|
||||||
|
}
|
||||||
357
license-system-backend/src/License.Api/Services/AuthService.cs
Normal file
357
license-system-backend/src/License.Api/Services/AuthService.cs
Normal file
@@ -0,0 +1,357 @@
|
|||||||
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
|
using License.Api.Data;
|
||||||
|
using License.Api.DTOs;
|
||||||
|
using License.Api.Models;
|
||||||
|
using License.Api.Options;
|
||||||
|
using License.Api.Security;
|
||||||
|
using License.Api.Utils;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace License.Api.Services;
|
||||||
|
|
||||||
|
public class AuthService
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
private readonly JwtTokenService _jwt;
|
||||||
|
private readonly HmacSignatureService _hmac;
|
||||||
|
private readonly HeartbeatOptions _heartbeat;
|
||||||
|
private readonly ConfigService _config;
|
||||||
|
private readonly RiskControlService _risk;
|
||||||
|
|
||||||
|
public AuthService(
|
||||||
|
AppDbContext db,
|
||||||
|
JwtTokenService jwt,
|
||||||
|
HmacSignatureService hmac,
|
||||||
|
IOptions<HeartbeatOptions> heartbeat,
|
||||||
|
ConfigService config,
|
||||||
|
RiskControlService risk)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_jwt = jwt;
|
||||||
|
_hmac = hmac;
|
||||||
|
_heartbeat = heartbeat.Value;
|
||||||
|
_config = config;
|
||||||
|
_risk = risk;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<(ApiResponse<AuthVerifyResponse> response, int httpStatus)> VerifyAsync(AuthVerifyRequest request, HttpContext httpContext)
|
||||||
|
{
|
||||||
|
if (!_hmac.ValidateTimestamp(request.Timestamp))
|
||||||
|
return (ApiResponse<AuthVerifyResponse>.Fail(1008, "timestamp_expired"), StatusCodes.Status400BadRequest);
|
||||||
|
|
||||||
|
var project = await _db.Projects.FirstOrDefaultAsync(p => p.ProjectId == request.ProjectId);
|
||||||
|
if (project == null)
|
||||||
|
return (ApiResponse<AuthVerifyResponse>.Fail(1001, "card_invalid"), StatusCodes.Status400BadRequest);
|
||||||
|
|
||||||
|
if (!project.IsEnabled)
|
||||||
|
return (ApiResponse<AuthVerifyResponse>.Fail(1011, "project_disabled"), StatusCodes.Status403Forbidden);
|
||||||
|
|
||||||
|
if (_hmac.IsEnabled)
|
||||||
|
{
|
||||||
|
var payload = $"{project.ProjectId}|{request.DeviceId}|{request.Timestamp}";
|
||||||
|
var valid = _hmac.Verify(payload, request.Signature, project.ProjectSecret);
|
||||||
|
if (!valid && !string.IsNullOrWhiteSpace(project.ProjectKey))
|
||||||
|
valid = _hmac.Verify(payload, request.Signature, project.ProjectKey);
|
||||||
|
if (!valid)
|
||||||
|
return (ApiResponse<AuthVerifyResponse>.Fail(1007, "signature_invalid"), StatusCodes.Status403Forbidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
var card = await _db.CardKeys
|
||||||
|
.FirstOrDefaultAsync(c => c.ProjectId == project.ProjectId && c.KeyCode == request.KeyCode && c.DeletedAt == null);
|
||||||
|
|
||||||
|
if (card == null)
|
||||||
|
return (ApiResponse<AuthVerifyResponse>.Fail(1001, "card_invalid"), StatusCodes.Status400BadRequest);
|
||||||
|
|
||||||
|
if (card.Status == "banned")
|
||||||
|
return (ApiResponse<AuthVerifyResponse>.Fail(1003, "card_banned"), StatusCodes.Status403Forbidden);
|
||||||
|
|
||||||
|
if (string.Equals(card.CardType, "test", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& card.LastUsedAt.HasValue)
|
||||||
|
return (ApiResponse<AuthVerifyResponse>.Fail(1002, "card_expired"), StatusCodes.Status403Forbidden);
|
||||||
|
|
||||||
|
if (card.DurationDays <= 0 && !string.Equals(card.CardType, "lifetime", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var resolvedDays = 0;
|
||||||
|
if (CardKeyGenerator.TryDecode(card.KeyCode, out _, out var decodedDays) && decodedDays > 0)
|
||||||
|
resolvedDays = decodedDays;
|
||||||
|
if (resolvedDays <= 0)
|
||||||
|
resolvedDays = CardDefaults.ResolveDurationDays(card.CardType);
|
||||||
|
if (resolvedDays > 0)
|
||||||
|
card.DurationDays = resolvedDays;
|
||||||
|
}
|
||||||
|
|
||||||
|
var expireType = await _config.GetValueAsync("auth.expire_type") ?? "activate";
|
||||||
|
|
||||||
|
if (string.Equals(expireType, "fix", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& !card.ExpireTime.HasValue
|
||||||
|
&& !string.Equals(card.CardType, "lifetime", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
card.ExpireTime = card.CreatedAt.AddDays(card.DurationDays);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (card.Status == "expired" || (card.ExpireTime.HasValue && card.ExpireTime <= DateTime.UtcNow))
|
||||||
|
{
|
||||||
|
card.Status = "expired";
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
return (ApiResponse<AuthVerifyResponse>.Fail(1002, "card_expired"), StatusCodes.Status403Forbidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
var trialMode = await _config.GetBoolAsync("feature.trial_mode", false);
|
||||||
|
var trialDays = await _config.GetIntAsync("trial.days", 3);
|
||||||
|
var deviceBindEnabled = await _config.GetBoolAsync("feature.device_bind", true);
|
||||||
|
var maxDevicesConfig = await _config.GetIntAsync("auth.max_devices", 1);
|
||||||
|
var allowMultiDevice = await _config.GetBoolAsync("auth.allow_multi_device", false);
|
||||||
|
var heartbeatEnabled = await _config.GetBoolAsync("feature.heartbeat", true);
|
||||||
|
var autoUpdateEnabled = (await _config.GetBoolAsync("feature.auto_update", true)) && project.AutoUpdate;
|
||||||
|
var heartbeatInterval = await _config.GetIntAsync("heartbeat.interval", _heartbeat.IntervalSeconds);
|
||||||
|
|
||||||
|
var riskDecision = await _risk.CheckVerifyAsync(project, card, request, httpContext);
|
||||||
|
if (riskDecision != null && riskDecision.Blocked)
|
||||||
|
{
|
||||||
|
await LogAccessAsync(project.ProjectId, card.Id, request.DeviceId, "verify", httpContext, riskDecision.HttpStatus);
|
||||||
|
return (ApiResponse<AuthVerifyResponse>.Fail(riskDecision.Code, riskDecision.Message), riskDecision.HttpStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
var activatedNow = false;
|
||||||
|
if (card.Status == "unused")
|
||||||
|
{
|
||||||
|
card.Status = "active";
|
||||||
|
card.ActivateTime = DateTime.UtcNow;
|
||||||
|
activatedNow = true;
|
||||||
|
|
||||||
|
if (!card.ExpireTime.HasValue && !string.Equals(card.CardType, "lifetime", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
if (trialMode)
|
||||||
|
{
|
||||||
|
var days = Math.Max(1, Math.Min(trialDays, card.DurationDays));
|
||||||
|
card.ExpireTime = DateTime.UtcNow.AddDays(days);
|
||||||
|
}
|
||||||
|
else if (string.Equals(expireType, "fix", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
card.ExpireTime = card.CreatedAt.AddDays(card.DurationDays);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
card.ExpireTime = DateTime.UtcNow.AddDays(card.DurationDays);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var maxDevices = card.MaxDevices > 0 ? card.MaxDevices : project.MaxDevices;
|
||||||
|
if (maxDevices <= 0)
|
||||||
|
maxDevices = maxDevicesConfig;
|
||||||
|
if (!deviceBindEnabled)
|
||||||
|
maxDevices = int.MaxValue;
|
||||||
|
|
||||||
|
if (deviceBindEnabled)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(card.MachineCode)
|
||||||
|
&& !string.Equals(card.MachineCode, request.DeviceId, StringComparison.OrdinalIgnoreCase))
|
||||||
|
return (ApiResponse<AuthVerifyResponse>.Fail(1005, "device_limit_exceeded"), StatusCodes.Status403Forbidden);
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(card.MachineCode))
|
||||||
|
card.MachineCode = request.DeviceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
var device = await _db.Devices.FirstOrDefaultAsync(d => d.CardKeyId == card.Id && d.DeviceId == request.DeviceId && d.DeletedAt == null);
|
||||||
|
if (device == null)
|
||||||
|
{
|
||||||
|
var activeDevices = await _db.Devices
|
||||||
|
.Where(d => d.CardKeyId == card.Id && d.IsActive && d.DeletedAt == null)
|
||||||
|
.OrderBy(d => d.LastHeartbeat ?? d.FirstLoginAt)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
if (!allowMultiDevice && activeDevices.Count > 0)
|
||||||
|
{
|
||||||
|
foreach (var other in activeDevices)
|
||||||
|
other.IsActive = false;
|
||||||
|
}
|
||||||
|
else if (allowMultiDevice && activeDevices.Count >= maxDevices)
|
||||||
|
{
|
||||||
|
var oldest = activeDevices.FirstOrDefault();
|
||||||
|
if (oldest != null)
|
||||||
|
oldest.IsActive = false;
|
||||||
|
}
|
||||||
|
else if (activeDevices.Count >= maxDevices)
|
||||||
|
{
|
||||||
|
await LogAccessAsync(project.ProjectId, card.Id, request.DeviceId, "verify", httpContext, 403);
|
||||||
|
return (ApiResponse<AuthVerifyResponse>.Fail(1005, "device_limit_exceeded"), StatusCodes.Status403Forbidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
device = new Device
|
||||||
|
{
|
||||||
|
CardKeyId = card.Id,
|
||||||
|
DeviceId = request.DeviceId,
|
||||||
|
LastHeartbeat = DateTime.UtcNow,
|
||||||
|
IpAddress = httpContext.Connection.RemoteIpAddress?.ToString(),
|
||||||
|
FirstLoginAt = DateTime.UtcNow,
|
||||||
|
IsActive = true
|
||||||
|
};
|
||||||
|
_db.Devices.Add(device);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
device.LastHeartbeat = DateTime.UtcNow;
|
||||||
|
device.IpAddress = httpContext.Connection.RemoteIpAddress?.ToString();
|
||||||
|
device.IsActive = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
card.LastUsedAt = DateTime.UtcNow;
|
||||||
|
|
||||||
|
if (activatedNow)
|
||||||
|
{
|
||||||
|
_db.CardKeyLogs.Add(new CardKeyLog
|
||||||
|
{
|
||||||
|
CardKeyId = card.Id,
|
||||||
|
Action = "activate",
|
||||||
|
OperatorType = "system",
|
||||||
|
Details = $"deviceId={request.DeviceId};trial={trialMode}",
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.ClientVersion) && !Version.TryParse(request.ClientVersion, out _))
|
||||||
|
{
|
||||||
|
await LogAccessAsync(project.ProjectId, card.Id, request.DeviceId, "verify", httpContext, StatusCodes.Status400BadRequest);
|
||||||
|
return (ApiResponse<AuthVerifyResponse>.Fail(1010, "invalid_version"), StatusCodes.Status400BadRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
var latestVersion = await _db.SoftwareVersions
|
||||||
|
.Where(v => v.ProjectId == project.ProjectId)
|
||||||
|
.OrderByDescending(v => v.PublishedAt)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.ClientVersion) && latestVersion != null && latestVersion.IsForceUpdate)
|
||||||
|
{
|
||||||
|
var compare = VersionComparer.Compare(request.ClientVersion, latestVersion.Version);
|
||||||
|
if (compare < 0)
|
||||||
|
{
|
||||||
|
await LogAccessAsync(project.ProjectId, card.Id, request.DeviceId, "verify", httpContext, StatusCodes.Status426UpgradeRequired);
|
||||||
|
return (ApiResponse<AuthVerifyResponse>.Fail(1012, "force_update_required"), StatusCodes.Status426UpgradeRequired);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var token = _jwt.CreateCardToken(card, request.DeviceId);
|
||||||
|
var response = new AuthVerifyResponse
|
||||||
|
{
|
||||||
|
Valid = true,
|
||||||
|
ExpireTime = card.ExpireTime,
|
||||||
|
RemainingDays = card.ExpireTime.HasValue ? (int)Math.Max(0, (card.ExpireTime.Value - DateTime.UtcNow).TotalDays) : 99999,
|
||||||
|
DownloadUrl = autoUpdateEnabled && latestVersion != null ? $"/api/software/download?version={latestVersion.Version}&token={token}" : null,
|
||||||
|
FileHash = autoUpdateEnabled ? latestVersion?.FileHash : null,
|
||||||
|
Version = autoUpdateEnabled ? latestVersion?.Version : null,
|
||||||
|
HeartbeatInterval = heartbeatEnabled ? heartbeatInterval : 0,
|
||||||
|
AccessToken = token
|
||||||
|
};
|
||||||
|
|
||||||
|
await LogAccessAsync(project.ProjectId, card.Id, request.DeviceId, "verify", httpContext, 200);
|
||||||
|
|
||||||
|
return (ApiResponse<AuthVerifyResponse>.Ok(response), StatusCodes.Status200OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<(ApiResponse<AuthHeartbeatResponse> response, int httpStatus)> HeartbeatAsync(AuthHeartbeatRequest request, HttpContext httpContext)
|
||||||
|
{
|
||||||
|
var heartbeatEnabled = await _config.GetBoolAsync("heartbeat.enabled", _heartbeat.Enabled);
|
||||||
|
|
||||||
|
if (!_hmac.ValidateTimestamp(request.Timestamp))
|
||||||
|
return (ApiResponse<AuthHeartbeatResponse>.Fail(1008, "timestamp_expired"), StatusCodes.Status400BadRequest);
|
||||||
|
|
||||||
|
var principal = _jwt.ValidateToken(request.AccessToken);
|
||||||
|
if (principal == null)
|
||||||
|
return (ApiResponse<AuthHeartbeatResponse>.Fail(401, "unauthorized"), StatusCodes.Status401Unauthorized);
|
||||||
|
|
||||||
|
var type = principal.Claims.FirstOrDefault(c => c.Type == "type")?.Value;
|
||||||
|
if (!string.Equals(type, "card", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return (ApiResponse<AuthHeartbeatResponse>.Fail(401, "unauthorized"), StatusCodes.Status401Unauthorized);
|
||||||
|
|
||||||
|
var projectId = principal.Claims.FirstOrDefault(c => c.Type == "projectId")?.Value;
|
||||||
|
var cardIdStr = principal.Claims.FirstOrDefault(c => c.Type == System.IdentityModel.Tokens.Jwt.JwtRegisteredClaimNames.Sub)?.Value;
|
||||||
|
if (!int.TryParse(cardIdStr, out var cardId))
|
||||||
|
return (ApiResponse<AuthHeartbeatResponse>.Fail(401, "unauthorized"), StatusCodes.Status401Unauthorized);
|
||||||
|
|
||||||
|
var card = await _db.CardKeys.FirstOrDefaultAsync(c => c.Id == cardId && c.DeletedAt == null);
|
||||||
|
if (card == null || card.Status == "banned")
|
||||||
|
return (ApiResponse<AuthHeartbeatResponse>.Fail(1003, "card_banned"), StatusCodes.Status403Forbidden);
|
||||||
|
|
||||||
|
if (card.ExpireTime.HasValue && card.ExpireTime <= DateTime.UtcNow)
|
||||||
|
{
|
||||||
|
card.Status = "expired";
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
return (ApiResponse<AuthHeartbeatResponse>.Fail(1002, "card_expired"), StatusCodes.Status403Forbidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
Device? device = null;
|
||||||
|
if (heartbeatEnabled)
|
||||||
|
{
|
||||||
|
device = await _db.Devices.FirstOrDefaultAsync(d => d.CardKeyId == card.Id && d.DeviceId == request.DeviceId && d.DeletedAt == null);
|
||||||
|
if (device == null)
|
||||||
|
return (ApiResponse<AuthHeartbeatResponse>.Fail(1006, "device_not_found"), StatusCodes.Status404NotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_hmac.IsEnabled)
|
||||||
|
{
|
||||||
|
var payload = $"{projectId}|{request.DeviceId}|{request.Timestamp}";
|
||||||
|
var project = await _db.Projects.FirstOrDefaultAsync(p => p.ProjectId == projectId);
|
||||||
|
if (project == null)
|
||||||
|
return (ApiResponse<AuthHeartbeatResponse>.Fail(1007, "signature_invalid"), StatusCodes.Status403Forbidden);
|
||||||
|
|
||||||
|
var valid = _hmac.Verify(payload, request.Signature, project.ProjectSecret);
|
||||||
|
if (!valid && !string.IsNullOrWhiteSpace(project.ProjectKey))
|
||||||
|
valid = _hmac.Verify(payload, request.Signature, project.ProjectKey);
|
||||||
|
if (!valid)
|
||||||
|
return (ApiResponse<AuthHeartbeatResponse>.Fail(1007, "signature_invalid"), StatusCodes.Status403Forbidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (heartbeatEnabled && device != null)
|
||||||
|
{
|
||||||
|
if (device.LastHeartbeat.HasValue)
|
||||||
|
{
|
||||||
|
var delta = DateTime.UtcNow - device.LastHeartbeat.Value;
|
||||||
|
if (delta.TotalSeconds > 0)
|
||||||
|
card.UsedDuration += (long)delta.TotalSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
device.LastHeartbeat = DateTime.UtcNow;
|
||||||
|
device.IpAddress = httpContext.Connection.RemoteIpAddress?.ToString();
|
||||||
|
}
|
||||||
|
card.LastUsedAt = DateTime.UtcNow;
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
await LogAccessAsync(projectId ?? card.ProjectId ?? string.Empty, card.Id, request.DeviceId, "heartbeat", httpContext, 200);
|
||||||
|
if (heartbeatEnabled)
|
||||||
|
{
|
||||||
|
var ip = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
||||||
|
await _risk.CheckHeartbeatAsync(card, request.DeviceId, ip);
|
||||||
|
}
|
||||||
|
|
||||||
|
var response = new AuthHeartbeatResponse
|
||||||
|
{
|
||||||
|
Valid = true,
|
||||||
|
RemainingDays = card.ExpireTime.HasValue ? (int)Math.Max(0, (card.ExpireTime.Value - DateTime.UtcNow).TotalDays) : 99999,
|
||||||
|
ServerTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds()
|
||||||
|
};
|
||||||
|
|
||||||
|
return (ApiResponse<AuthHeartbeatResponse>.Ok(response), StatusCodes.Status200OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LogAccessAsync(string projectId, int? cardKeyId, string? deviceId, string action, HttpContext context, int responseCode)
|
||||||
|
{
|
||||||
|
var log = new AccessLog
|
||||||
|
{
|
||||||
|
ProjectId = projectId,
|
||||||
|
CardKeyId = cardKeyId,
|
||||||
|
DeviceId = deviceId,
|
||||||
|
Action = action,
|
||||||
|
IpAddress = context.Connection.RemoteIpAddress?.ToString(),
|
||||||
|
UserAgent = context.Request.Headers.UserAgent.ToString(),
|
||||||
|
ResponseCode = responseCode,
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
_db.AccessLogs.Add(log);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
168
license-system-backend/src/License.Api/Services/CardService.cs
Normal file
168
license-system-backend/src/License.Api/Services/CardService.cs
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
using License.Api.Data;
|
||||||
|
using License.Api.DTOs;
|
||||||
|
using License.Api.Models;
|
||||||
|
using License.Api.Utils;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace License.Api.Services;
|
||||||
|
|
||||||
|
public class CardService
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
private readonly ConfigService _config;
|
||||||
|
|
||||||
|
public CardService(AppDbContext db, ConfigService config)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_config = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<CardGenerateResponse> GenerateAsync(CardGenerateRequest request, int? operatorId, int? agentId = null, decimal? soldPrice = null, string operatorType = "admin")
|
||||||
|
{
|
||||||
|
var needActivate = await _config.GetBoolAsync("auth.need_activate", true);
|
||||||
|
var expireType = await _config.GetValueAsync("auth.expire_type") ?? "activate";
|
||||||
|
var trialMode = await _config.GetBoolAsync("feature.trial_mode", false);
|
||||||
|
var trialDays = await _config.GetIntAsync("trial.days", 3);
|
||||||
|
|
||||||
|
var batchSuffix = Guid.NewGuid().ToString("N")[..8];
|
||||||
|
var batchId = $"batch_{DateTime.UtcNow:yyyyMMddHHmmss}_{batchSuffix}";
|
||||||
|
var keys = new List<string>();
|
||||||
|
|
||||||
|
for (var i = 0; i < request.Quantity; i++)
|
||||||
|
{
|
||||||
|
string key;
|
||||||
|
do
|
||||||
|
{
|
||||||
|
key = CardKeyGenerator.Generate(MapCardType(request.CardType), request.DurationDays);
|
||||||
|
} while (await _db.CardKeys.AnyAsync(k => k.KeyCode == key));
|
||||||
|
|
||||||
|
keys.Add(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
var entities = keys.Select(key =>
|
||||||
|
{
|
||||||
|
var createdAt = DateTime.UtcNow;
|
||||||
|
var expireTime = (DateTime?)null;
|
||||||
|
if (!string.Equals(request.CardType, "lifetime", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
if (trialMode && !needActivate)
|
||||||
|
{
|
||||||
|
var days = Math.Max(1, Math.Min(trialDays, request.DurationDays));
|
||||||
|
expireTime = createdAt.AddDays(days);
|
||||||
|
}
|
||||||
|
else if (!needActivate || string.Equals(expireType, "fix", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
expireTime = createdAt.AddDays(request.DurationDays);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new CardKey
|
||||||
|
{
|
||||||
|
ProjectId = request.ProjectId,
|
||||||
|
KeyCode = key,
|
||||||
|
CardType = request.CardType,
|
||||||
|
DurationDays = request.DurationDays,
|
||||||
|
Status = needActivate ? "unused" : "active",
|
||||||
|
ActivateTime = needActivate ? null : createdAt,
|
||||||
|
ExpireTime = expireTime,
|
||||||
|
Note = request.Note,
|
||||||
|
BatchId = batchId,
|
||||||
|
CreatedAt = createdAt,
|
||||||
|
GeneratedBy = operatorType == "admin" ? operatorId : null,
|
||||||
|
AgentId = agentId,
|
||||||
|
SoldPrice = soldPrice
|
||||||
|
};
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
await _db.CardKeys.AddRangeAsync(entities);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var logs = entities.Select(card => new CardKeyLog
|
||||||
|
{
|
||||||
|
CardKeyId = card.Id,
|
||||||
|
Action = "create",
|
||||||
|
OperatorId = operatorId,
|
||||||
|
OperatorType = operatorType,
|
||||||
|
Details = $"batchId={batchId}",
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
await _db.CardKeyLogs.AddRangeAsync(logs);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return new CardGenerateResponse
|
||||||
|
{
|
||||||
|
BatchId = batchId,
|
||||||
|
Keys = keys,
|
||||||
|
Count = keys.Count
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task BanAsync(CardKey card, string? reason, int? operatorId, string operatorType)
|
||||||
|
{
|
||||||
|
card.Status = "banned";
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
await LogAsync(card.Id, "ban", operatorId, operatorType, reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UnbanAsync(CardKey card, int? operatorId, string operatorType)
|
||||||
|
{
|
||||||
|
card.Status = "active";
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
await LogAsync(card.Id, "unban", operatorId, operatorType, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ExtendAsync(CardKey card, int days, int? operatorId, string operatorType)
|
||||||
|
{
|
||||||
|
if (!card.ExpireTime.HasValue)
|
||||||
|
card.ExpireTime = DateTime.UtcNow.AddDays(days);
|
||||||
|
else
|
||||||
|
card.ExpireTime = card.ExpireTime.Value.AddDays(days);
|
||||||
|
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
await LogAsync(card.Id, "extend", operatorId, operatorType, $"days={days}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ResetDeviceAsync(CardKey card, int? operatorId, string operatorType)
|
||||||
|
{
|
||||||
|
card.MachineCode = null;
|
||||||
|
var devices = await _db.Devices
|
||||||
|
.Where(d => d.CardKeyId == card.Id && d.DeletedAt == null)
|
||||||
|
.ToListAsync();
|
||||||
|
foreach (var device in devices)
|
||||||
|
{
|
||||||
|
device.IsActive = false;
|
||||||
|
device.DeletedAt = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
await LogAsync(card.Id, "reset_device", operatorId, operatorType, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LogAsync(int cardId, string action, int? operatorId, string operatorType, string? details)
|
||||||
|
{
|
||||||
|
_db.CardKeyLogs.Add(new CardKeyLog
|
||||||
|
{
|
||||||
|
CardKeyId = cardId,
|
||||||
|
Action = action,
|
||||||
|
OperatorId = operatorId,
|
||||||
|
OperatorType = operatorType,
|
||||||
|
Details = details,
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
});
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte MapCardType(string cardType)
|
||||||
|
{
|
||||||
|
return cardType.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"test" => 6,
|
||||||
|
"day" => 1,
|
||||||
|
"week" => 2,
|
||||||
|
"month" => 3,
|
||||||
|
"year" => 4,
|
||||||
|
"lifetime" => 5,
|
||||||
|
_ => 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
using License.Api.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
|
|
||||||
|
namespace License.Api.Services;
|
||||||
|
|
||||||
|
public class ConfigService
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
private readonly IMemoryCache _cache;
|
||||||
|
private static readonly TimeSpan CacheTtl = TimeSpan.FromMinutes(1);
|
||||||
|
|
||||||
|
public ConfigService(AppDbContext db, IMemoryCache cache)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_cache = cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string?> GetValueAsync(string key)
|
||||||
|
{
|
||||||
|
if (_cache.TryGetValue<string?>(key, out var cached))
|
||||||
|
return cached;
|
||||||
|
|
||||||
|
var config = await _db.SystemConfigs
|
||||||
|
.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(c => c.ConfigKey == key);
|
||||||
|
var value = config?.ConfigValue;
|
||||||
|
_cache.Set(key, value, CacheTtl);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> GetBoolAsync(string key, bool defaultValue)
|
||||||
|
{
|
||||||
|
var value = await GetValueAsync(key);
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
return defaultValue;
|
||||||
|
|
||||||
|
if (bool.TryParse(value, out var parsed))
|
||||||
|
return parsed;
|
||||||
|
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> GetIntAsync(string key, int defaultValue)
|
||||||
|
{
|
||||||
|
var value = await GetValueAsync(key);
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
return defaultValue;
|
||||||
|
|
||||||
|
if (int.TryParse(value, out var parsed))
|
||||||
|
return parsed;
|
||||||
|
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<decimal> GetDecimalAsync(string key, decimal defaultValue)
|
||||||
|
{
|
||||||
|
var value = await GetValueAsync(key);
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
return defaultValue;
|
||||||
|
|
||||||
|
if (decimal.TryParse(value, out var parsed))
|
||||||
|
return parsed;
|
||||||
|
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Invalidate(string key)
|
||||||
|
{
|
||||||
|
_cache.Remove(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
using License.Api.Options;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace License.Api.Services;
|
||||||
|
|
||||||
|
public class FileStorageService
|
||||||
|
{
|
||||||
|
private readonly StorageOptions _options;
|
||||||
|
private readonly IWebHostEnvironment _env;
|
||||||
|
|
||||||
|
public FileStorageService(IOptions<StorageOptions> options, IWebHostEnvironment env)
|
||||||
|
{
|
||||||
|
_options = options.Value;
|
||||||
|
_env = env;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string GetUploadRoot()
|
||||||
|
{
|
||||||
|
var root = _options.UploadRoot;
|
||||||
|
if (Path.IsPathRooted(root))
|
||||||
|
return root;
|
||||||
|
return Path.Combine(_env.ContentRootPath, root);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string> SaveAsync(string projectId, string version, byte[] content)
|
||||||
|
{
|
||||||
|
var root = GetUploadRoot();
|
||||||
|
var folder = Path.Combine(root, projectId);
|
||||||
|
Directory.CreateDirectory(folder);
|
||||||
|
|
||||||
|
var fileName = $"{version}_{DateTime.UtcNow:yyyyMMddHHmmss}.bin";
|
||||||
|
var path = Path.Combine(folder, fileName);
|
||||||
|
await File.WriteAllBytesAsync(path, content);
|
||||||
|
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string> SaveAsync(string projectId, string version, Stream stream)
|
||||||
|
{
|
||||||
|
var root = GetUploadRoot();
|
||||||
|
var folder = Path.Combine(root, projectId);
|
||||||
|
Directory.CreateDirectory(folder);
|
||||||
|
|
||||||
|
var fileName = $"{version}_{DateTime.UtcNow:yyyyMMddHHmmss}.bin";
|
||||||
|
var path = Path.Combine(folder, fileName);
|
||||||
|
|
||||||
|
await using var fs = File.Create(path);
|
||||||
|
await stream.CopyToAsync(fs);
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
using License.Api.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
|
||||||
|
namespace License.Api.Services;
|
||||||
|
|
||||||
|
public class HeartbeatMonitorService : BackgroundService
|
||||||
|
{
|
||||||
|
private readonly IServiceProvider _provider;
|
||||||
|
private readonly ILogger<HeartbeatMonitorService> _logger;
|
||||||
|
|
||||||
|
public HeartbeatMonitorService(IServiceProvider provider, ILogger<HeartbeatMonitorService> logger)
|
||||||
|
{
|
||||||
|
_provider = provider;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var scope = _provider.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
var config = scope.ServiceProvider.GetRequiredService<ConfigService>();
|
||||||
|
|
||||||
|
var enabled = await config.GetBoolAsync("heartbeat.enabled", true);
|
||||||
|
if (enabled)
|
||||||
|
{
|
||||||
|
var timeoutSeconds = await config.GetIntAsync("heartbeat.timeout", 180);
|
||||||
|
if (timeoutSeconds > 0)
|
||||||
|
{
|
||||||
|
var cutoff = DateTime.UtcNow.AddSeconds(-timeoutSeconds);
|
||||||
|
var devices = await db.Devices
|
||||||
|
.Where(d => d.IsActive && d.DeletedAt == null && d.LastHeartbeat != null && d.LastHeartbeat < cutoff)
|
||||||
|
.ToListAsync(stoppingToken);
|
||||||
|
|
||||||
|
if (devices.Count > 0)
|
||||||
|
{
|
||||||
|
foreach (var device in devices)
|
||||||
|
device.IsActive = false;
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(stoppingToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Heartbeat monitor failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using License.Api.Data;
|
||||||
|
using License.Api.Models;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace License.Api.Services;
|
||||||
|
|
||||||
|
public class IdempotencyService
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
|
||||||
|
public IdempotencyService(AppDbContext db)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IdempotencyKeyRecord?> GetAsync(string key)
|
||||||
|
{
|
||||||
|
return await _db.IdempotencyKeys
|
||||||
|
.FirstOrDefaultAsync(x => x.IdempotencyKey == key && x.ExpiresAt > DateTime.UtcNow);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IdempotencyKeyRecord> StoreAsync(string key, string path, string requestBodyHash, int responseCode, string responseBody)
|
||||||
|
{
|
||||||
|
var record = new IdempotencyKeyRecord
|
||||||
|
{
|
||||||
|
IdempotencyKey = key,
|
||||||
|
RequestPath = path,
|
||||||
|
RequestHash = requestBodyHash,
|
||||||
|
ResponseCode = responseCode,
|
||||||
|
ResponseBody = responseBody,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
ExpiresAt = DateTime.UtcNow.AddHours(24)
|
||||||
|
};
|
||||||
|
|
||||||
|
_db.IdempotencyKeys.Add(record);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string ComputeRequestHash(string? body)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(body))
|
||||||
|
return string.Empty;
|
||||||
|
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(body));
|
||||||
|
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
using License.Api.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
|
||||||
|
namespace License.Api.Services;
|
||||||
|
|
||||||
|
public class MaintenanceService : BackgroundService
|
||||||
|
{
|
||||||
|
private readonly IServiceProvider _provider;
|
||||||
|
private readonly ILogger<MaintenanceService> _logger;
|
||||||
|
|
||||||
|
public MaintenanceService(IServiceProvider provider, ILogger<MaintenanceService> logger)
|
||||||
|
{
|
||||||
|
_provider = provider;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var scope = _provider.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
var config = scope.ServiceProvider.GetRequiredService<ConfigService>();
|
||||||
|
|
||||||
|
var retentionDays = await config.GetIntAsync("log.retention_days", 90);
|
||||||
|
var logCutoff = DateTime.UtcNow.AddDays(-retentionDays);
|
||||||
|
var deletedCutoff = DateTime.UtcNow.AddDays(-30);
|
||||||
|
|
||||||
|
await db.IdempotencyKeys
|
||||||
|
.Where(k => k.ExpiresAt < DateTime.UtcNow)
|
||||||
|
.ExecuteDeleteAsync(stoppingToken);
|
||||||
|
|
||||||
|
await db.CardKeys
|
||||||
|
.Where(k => k.DeletedAt != null && k.DeletedAt < deletedCutoff)
|
||||||
|
.ExecuteDeleteAsync(stoppingToken);
|
||||||
|
|
||||||
|
await db.AccessLogs
|
||||||
|
.Where(l => l.CreatedAt < logCutoff)
|
||||||
|
.ExecuteDeleteAsync(stoppingToken);
|
||||||
|
|
||||||
|
await db.CardKeyLogs
|
||||||
|
.Where(l => l.CreatedAt < logCutoff)
|
||||||
|
.ExecuteDeleteAsync(stoppingToken);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Maintenance cleanup failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.Delay(TimeSpan.FromHours(6), stoppingToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
|
using StackExchange.Redis;
|
||||||
|
|
||||||
|
namespace License.Api.Services;
|
||||||
|
|
||||||
|
public interface IRateLimitStore
|
||||||
|
{
|
||||||
|
Task<long> IncrementAsync(string key, TimeSpan ttl);
|
||||||
|
Task<bool> ExistsAsync(string key);
|
||||||
|
Task SetAsync(string key, TimeSpan ttl);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class MemoryRateLimitStore : IRateLimitStore
|
||||||
|
{
|
||||||
|
private readonly IMemoryCache _cache;
|
||||||
|
private readonly object _lock = new();
|
||||||
|
|
||||||
|
public MemoryRateLimitStore(IMemoryCache cache)
|
||||||
|
{
|
||||||
|
_cache = cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<long> IncrementAsync(string key, TimeSpan ttl)
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
if (!_cache.TryGetValue<long>(key, out var count))
|
||||||
|
{
|
||||||
|
count = 0;
|
||||||
|
}
|
||||||
|
count++;
|
||||||
|
_cache.Set(key, count, ttl);
|
||||||
|
return Task.FromResult(count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<bool> ExistsAsync(string key)
|
||||||
|
{
|
||||||
|
var exists = _cache.TryGetValue(key, out _);
|
||||||
|
return Task.FromResult(exists);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task SetAsync(string key, TimeSpan ttl)
|
||||||
|
{
|
||||||
|
_cache.Set(key, true, ttl);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class RedisRateLimitStore : IRateLimitStore
|
||||||
|
{
|
||||||
|
private readonly IDatabase _db;
|
||||||
|
|
||||||
|
public RedisRateLimitStore(IConnectionMultiplexer multiplexer)
|
||||||
|
{
|
||||||
|
_db = multiplexer.GetDatabase();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<long> IncrementAsync(string key, TimeSpan ttl)
|
||||||
|
{
|
||||||
|
var count = await _db.StringIncrementAsync(key);
|
||||||
|
if (count == 1)
|
||||||
|
await _db.KeyExpireAsync(key, ttl);
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<bool> ExistsAsync(string key)
|
||||||
|
=> _db.KeyExistsAsync(key);
|
||||||
|
|
||||||
|
public Task SetAsync(string key, TimeSpan ttl)
|
||||||
|
=> _db.StringSetAsync(key, "1", ttl);
|
||||||
|
}
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
using License.Api.Data;
|
||||||
|
using License.Api.DTOs;
|
||||||
|
using License.Api.Models;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
|
||||||
|
namespace License.Api.Services;
|
||||||
|
|
||||||
|
public class RiskDecision
|
||||||
|
{
|
||||||
|
public bool Blocked { get; set; }
|
||||||
|
public int Code { get; set; }
|
||||||
|
public string Message { get; set; } = "forbidden";
|
||||||
|
public int HttpStatus { get; set; } = StatusCodes.Status403Forbidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class RiskControlService
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
private readonly ConfigService _config;
|
||||||
|
private readonly IRateLimitStore _store;
|
||||||
|
|
||||||
|
public RiskControlService(AppDbContext db, ConfigService config, IRateLimitStore store)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_config = config;
|
||||||
|
_store = store;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<RiskDecision?> CheckVerifyAsync(Project project, CardKey card, AuthVerifyRequest request, HttpContext context)
|
||||||
|
{
|
||||||
|
var enabled = await _config.GetBoolAsync("risk.enabled", true);
|
||||||
|
if (!enabled)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var ip = context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
||||||
|
var bruteCount = await _store.IncrementAsync($"risk:bruteforce:{ip}:{DateTime.UtcNow:yyyyMMddHH}", TimeSpan.FromHours(1));
|
||||||
|
if (bruteCount > 100)
|
||||||
|
{
|
||||||
|
return new RiskDecision
|
||||||
|
{
|
||||||
|
Blocked = true,
|
||||||
|
Code = 1009,
|
||||||
|
Message = "rate_limit_exceeded",
|
||||||
|
HttpStatus = StatusCodes.Status429TooManyRequests
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.DeviceId))
|
||||||
|
{
|
||||||
|
var deviceCount = await _store.IncrementAsync(
|
||||||
|
$"risk:device:attempts:{request.DeviceId}:{DateTime.UtcNow:yyyyMMdd}",
|
||||||
|
TimeSpan.FromDays(1));
|
||||||
|
|
||||||
|
if (deviceCount > 10)
|
||||||
|
{
|
||||||
|
return new RiskDecision
|
||||||
|
{
|
||||||
|
Blocked = true,
|
||||||
|
Code = 1009,
|
||||||
|
Message = "rate_limit_exceeded",
|
||||||
|
HttpStatus = StatusCodes.Status429TooManyRequests
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var checkDeviceChange = await _config.GetBoolAsync("risk.check_device_change", true);
|
||||||
|
if (checkDeviceChange && !string.IsNullOrWhiteSpace(card.MachineCode)
|
||||||
|
&& !string.Equals(card.MachineCode, request.DeviceId, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
await AddRiskLogAsync(card.Id, "risk_device_change", $"old={card.MachineCode};new={request.DeviceId}", ip);
|
||||||
|
|
||||||
|
var autoBan = await _config.GetBoolAsync("risk.auto_ban", false);
|
||||||
|
if (autoBan)
|
||||||
|
{
|
||||||
|
card.Status = "banned";
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
return new RiskDecision
|
||||||
|
{
|
||||||
|
Blocked = true,
|
||||||
|
Code = 1003,
|
||||||
|
Message = "card_banned",
|
||||||
|
HttpStatus = StatusCodes.Status403Forbidden
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return new RiskDecision
|
||||||
|
{
|
||||||
|
Blocked = true,
|
||||||
|
Code = 1005,
|
||||||
|
Message = "device_limit_exceeded",
|
||||||
|
HttpStatus = StatusCodes.Status403Forbidden
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
var checkLocation = await _config.GetBoolAsync("risk.check_location", true);
|
||||||
|
if (checkLocation)
|
||||||
|
{
|
||||||
|
var since = DateTime.UtcNow.AddHours(-1);
|
||||||
|
var lastLog = await _db.AccessLogs
|
||||||
|
.Where(l => l.CardKeyId == card.Id && l.CreatedAt >= since)
|
||||||
|
.OrderByDescending(l => l.CreatedAt)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
|
||||||
|
if (lastLog != null && !string.IsNullOrWhiteSpace(lastLog.IpAddress)
|
||||||
|
&& !string.Equals(lastLog.IpAddress, ip, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
await AddRiskLogAsync(card.Id, "risk_location", $"old={lastLog.IpAddress};new={ip}", ip);
|
||||||
|
|
||||||
|
var autoBan = await _config.GetBoolAsync("risk.auto_ban", false);
|
||||||
|
if (autoBan)
|
||||||
|
{
|
||||||
|
card.Status = "banned";
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
return new RiskDecision
|
||||||
|
{
|
||||||
|
Blocked = true,
|
||||||
|
Code = 1003,
|
||||||
|
Message = "card_banned",
|
||||||
|
HttpStatus = StatusCodes.Status403Forbidden
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var proxyDecision = await CheckProxyAsync(card, ip);
|
||||||
|
if (proxyDecision != null)
|
||||||
|
return proxyDecision;
|
||||||
|
|
||||||
|
var shareDecision = await CheckIpShareAsync(card, ip);
|
||||||
|
if (shareDecision != null)
|
||||||
|
return shareDecision;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task CheckHeartbeatAsync(CardKey card, string deviceId, string ip)
|
||||||
|
{
|
||||||
|
var enabled = await _config.GetBoolAsync("risk.enabled", true);
|
||||||
|
if (!enabled)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var interval = await _config.GetIntAsync("heartbeat.interval", 60);
|
||||||
|
if (interval <= 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var logs = await _db.AccessLogs
|
||||||
|
.Where(l => l.CardKeyId == card.Id && l.DeviceId == deviceId && l.Action == "heartbeat")
|
||||||
|
.OrderByDescending(l => l.CreatedAt)
|
||||||
|
.Take(6)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
if (logs.Count < 6)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var intervals = new List<double>();
|
||||||
|
for (var i = 0; i < logs.Count - 1; i++)
|
||||||
|
{
|
||||||
|
var delta = (logs[i].CreatedAt - logs[i + 1].CreatedAt).TotalSeconds;
|
||||||
|
if (delta > 0)
|
||||||
|
intervals.Add(delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (intervals.Count < 5)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var avg = intervals.Average();
|
||||||
|
var variance = intervals.Select(x => Math.Pow(x - avg, 2)).Average();
|
||||||
|
var std = Math.Sqrt(variance);
|
||||||
|
|
||||||
|
if (Math.Abs(avg - interval) <= 2 && std <= 1)
|
||||||
|
{
|
||||||
|
await AddRiskLogAsync(card.Id, "risk_automation", $"avg={avg:F1};std={std:F1}", ip);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<RiskDecision?> CheckIpShareAsync(CardKey card, string ip)
|
||||||
|
{
|
||||||
|
var since = DateTime.UtcNow.AddHours(-24);
|
||||||
|
var ipCount = await _db.AccessLogs
|
||||||
|
.Where(l => l.CardKeyId == card.Id && l.CreatedAt >= since && l.IpAddress != null)
|
||||||
|
.Select(l => l.IpAddress!)
|
||||||
|
.Distinct()
|
||||||
|
.CountAsync();
|
||||||
|
|
||||||
|
if (ipCount > 5)
|
||||||
|
{
|
||||||
|
await AddRiskLogAsync(card.Id, "risk_share", $"uniqueIp={ipCount}", ip);
|
||||||
|
card.Status = "banned";
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
return new RiskDecision
|
||||||
|
{
|
||||||
|
Blocked = true,
|
||||||
|
Code = 1003,
|
||||||
|
Message = "card_banned",
|
||||||
|
HttpStatus = StatusCodes.Status403Forbidden
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<RiskDecision?> CheckProxyAsync(CardKey card, string ip)
|
||||||
|
{
|
||||||
|
var prefixes = await _config.GetValueAsync("risk.proxy_prefixes");
|
||||||
|
if (string.IsNullOrWhiteSpace(prefixes))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var list = prefixes.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||||
|
if (list.Length == 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var hit = list.Any(prefix => ip.StartsWith(prefix, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (!hit)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
await AddRiskLogAsync(card.Id, "risk_proxy", $"ip={ip}", ip);
|
||||||
|
|
||||||
|
var autoBan = await _config.GetBoolAsync("risk.auto_ban", false);
|
||||||
|
if (!autoBan)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
card.Status = "banned";
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
return new RiskDecision
|
||||||
|
{
|
||||||
|
Blocked = true,
|
||||||
|
Code = 1003,
|
||||||
|
Message = "card_banned",
|
||||||
|
HttpStatus = StatusCodes.Status403Forbidden
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task AddRiskLogAsync(int cardId, string action, string details, string? ip)
|
||||||
|
{
|
||||||
|
_db.CardKeyLogs.Add(new CardKeyLog
|
||||||
|
{
|
||||||
|
CardKeyId = cardId,
|
||||||
|
Action = action,
|
||||||
|
OperatorType = "system",
|
||||||
|
Details = details,
|
||||||
|
IpAddress = ip,
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
});
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using License.Api.Options;
|
||||||
|
using License.Api.Utils;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace License.Api.Services;
|
||||||
|
|
||||||
|
public class SoftwareEncryptionResult
|
||||||
|
{
|
||||||
|
public byte[] EncryptedData { get; set; } = Array.Empty<byte>();
|
||||||
|
public string FileHash { get; set; } = string.Empty;
|
||||||
|
public string? EncryptionKey { get; set; }
|
||||||
|
public long FileSize { get; set; }
|
||||||
|
public byte[] Nonce { get; set; } = Array.Empty<byte>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class SoftwareEncryptionService
|
||||||
|
{
|
||||||
|
private readonly StorageOptions _options;
|
||||||
|
|
||||||
|
public SoftwareEncryptionService(IOptions<StorageOptions> options)
|
||||||
|
{
|
||||||
|
_options = options.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SoftwareEncryptionResult> EncryptAsync(Stream input)
|
||||||
|
{
|
||||||
|
using var ms = new MemoryStream();
|
||||||
|
await input.CopyToAsync(ms);
|
||||||
|
var fileData = ms.ToArray();
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(_options.ClientRsaPublicKeyPem))
|
||||||
|
{
|
||||||
|
var rawHash = SHA256.HashData(fileData);
|
||||||
|
return new SoftwareEncryptionResult
|
||||||
|
{
|
||||||
|
EncryptedData = fileData,
|
||||||
|
FileHash = Convert.ToHexString(rawHash).ToLowerInvariant(),
|
||||||
|
FileSize = fileData.Length,
|
||||||
|
EncryptionKey = null,
|
||||||
|
Nonce = Array.Empty<byte>()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
var aesKey = RandomNumberGenerator.GetBytes(32);
|
||||||
|
var nonce = RandomNumberGenerator.GetBytes(12);
|
||||||
|
var tag = new byte[16];
|
||||||
|
var encryptedData = new byte[fileData.Length];
|
||||||
|
|
||||||
|
using (var aes = new AesGcm(aesKey))
|
||||||
|
{
|
||||||
|
aes.Encrypt(nonce, fileData, encryptedData, tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
var finalData = new byte[nonce.Length + tag.Length + encryptedData.Length];
|
||||||
|
Buffer.BlockCopy(nonce, 0, finalData, 0, nonce.Length);
|
||||||
|
Buffer.BlockCopy(tag, 0, finalData, nonce.Length, tag.Length);
|
||||||
|
Buffer.BlockCopy(encryptedData, 0, finalData, nonce.Length + tag.Length, encryptedData.Length);
|
||||||
|
|
||||||
|
var hash = SHA256.HashData(finalData);
|
||||||
|
var result = new SoftwareEncryptionResult
|
||||||
|
{
|
||||||
|
EncryptedData = finalData,
|
||||||
|
FileHash = Convert.ToHexString(hash).ToLowerInvariant(),
|
||||||
|
FileSize = fileData.Length,
|
||||||
|
Nonce = nonce
|
||||||
|
};
|
||||||
|
|
||||||
|
var rsa = RsaKeyLoader.LoadPublicKey(_options.ClientRsaPublicKeyPem);
|
||||||
|
if (rsa == null)
|
||||||
|
throw new InvalidOperationException("Client RSA public key is not configured");
|
||||||
|
|
||||||
|
var encryptedKey = rsa.Encrypt(aesKey, RSAEncryptionPadding.OaepSHA256);
|
||||||
|
result.EncryptionKey = Convert.ToBase64String(encryptedKey);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
using License.Api.Data;
|
||||||
|
using License.Api.DTOs;
|
||||||
|
using License.Api.Models;
|
||||||
|
using License.Api.Utils;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace License.Api.Services;
|
||||||
|
|
||||||
|
public class SoftwareService
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
private readonly SoftwareEncryptionService _encryption;
|
||||||
|
private readonly FileStorageService _storage;
|
||||||
|
|
||||||
|
public SoftwareService(AppDbContext db, SoftwareEncryptionService encryption, FileStorageService storage)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_encryption = encryption;
|
||||||
|
_storage = storage;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SoftwareVersion> CreateVersionAsync(string projectId, string version, IFormFile file, string? changelog, bool isForceUpdate, bool isStable, int? createdBy)
|
||||||
|
{
|
||||||
|
await using var stream = file.OpenReadStream();
|
||||||
|
var encryptionResult = await _encryption.EncryptAsync(stream);
|
||||||
|
var filePath = await _storage.SaveAsync(projectId, version, encryptionResult.EncryptedData);
|
||||||
|
|
||||||
|
var entity = new SoftwareVersion
|
||||||
|
{
|
||||||
|
ProjectId = projectId,
|
||||||
|
Version = version,
|
||||||
|
FileUrl = filePath,
|
||||||
|
FileSize = encryptionResult.FileSize,
|
||||||
|
FileHash = encryptionResult.FileHash,
|
||||||
|
EncryptionKey = encryptionResult.EncryptionKey,
|
||||||
|
Changelog = changelog,
|
||||||
|
IsForceUpdate = isForceUpdate,
|
||||||
|
IsStable = isStable,
|
||||||
|
PublishedAt = DateTime.UtcNow,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
CreatedBy = createdBy
|
||||||
|
};
|
||||||
|
|
||||||
|
_db.SoftwareVersions.Add(entity);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
return entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SoftwareCheckUpdateResponse> CheckUpdateAsync(SoftwareCheckUpdateRequest request)
|
||||||
|
{
|
||||||
|
var latest = await _db.SoftwareVersions
|
||||||
|
.Where(v => v.ProjectId == request.ProjectId)
|
||||||
|
.OrderByDescending(v => v.PublishedAt)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
|
||||||
|
if (latest == null)
|
||||||
|
{
|
||||||
|
return new SoftwareCheckUpdateResponse
|
||||||
|
{
|
||||||
|
HasUpdate = false,
|
||||||
|
LatestVersion = null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
var compare = VersionComparer.Compare(latest.Version, request.CurrentVersion);
|
||||||
|
var hasUpdate = compare > 0;
|
||||||
|
|
||||||
|
return new SoftwareCheckUpdateResponse
|
||||||
|
{
|
||||||
|
HasUpdate = hasUpdate,
|
||||||
|
LatestVersion = latest.Version,
|
||||||
|
ForceUpdate = latest.IsForceUpdate,
|
||||||
|
DownloadUrl = $"/api/software/download?version={latest.Version}",
|
||||||
|
FileSize = latest.FileSize ?? 0,
|
||||||
|
FileHash = latest.FileHash,
|
||||||
|
Changelog = latest.Changelog
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SoftwareVersion?> GetVersionAsync(string projectId, string? version)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(version))
|
||||||
|
{
|
||||||
|
return await _db.SoftwareVersions
|
||||||
|
.FirstOrDefaultAsync(v => v.ProjectId == projectId && v.Version == version);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await _db.SoftwareVersions
|
||||||
|
.Where(v => v.ProjectId == projectId)
|
||||||
|
.OrderByDescending(v => v.PublishedAt)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<byte[]> ReadFileAsync(string filePath)
|
||||||
|
=> File.ReadAllBytesAsync(filePath);
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
using License.Api.Data;
|
||||||
|
using License.Api.Models;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
|
||||||
|
namespace License.Api.Services;
|
||||||
|
|
||||||
|
public class StatsAggregationService : BackgroundService
|
||||||
|
{
|
||||||
|
private readonly IServiceProvider _provider;
|
||||||
|
private readonly ILogger<StatsAggregationService> _logger;
|
||||||
|
|
||||||
|
public StatsAggregationService(IServiceProvider provider, ILogger<StatsAggregationService> logger)
|
||||||
|
{
|
||||||
|
_provider = provider;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var scope = _provider.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
var config = scope.ServiceProvider.GetRequiredService<ConfigService>();
|
||||||
|
|
||||||
|
var heartbeatInterval = await config.GetIntAsync("heartbeat.interval", 60);
|
||||||
|
var start = DateTime.UtcNow.Date;
|
||||||
|
var end = start.AddDays(1);
|
||||||
|
var date = DateOnly.FromDateTime(start);
|
||||||
|
|
||||||
|
var projects = await db.Projects
|
||||||
|
.AsNoTracking()
|
||||||
|
.Select(p => p.ProjectId)
|
||||||
|
.ToListAsync(stoppingToken);
|
||||||
|
|
||||||
|
var activeUsers = await db.AccessLogs
|
||||||
|
.Where(l => l.ProjectId != null && l.CreatedAt >= start && l.CreatedAt < end
|
||||||
|
&& (l.Action == "verify" || l.Action == "heartbeat"))
|
||||||
|
.GroupBy(l => l.ProjectId!)
|
||||||
|
.Select(g => new { ProjectId = g.Key, Count = g.Select(x => x.DeviceId).Distinct().Count() })
|
||||||
|
.ToListAsync(stoppingToken);
|
||||||
|
|
||||||
|
var downloads = await db.AccessLogs
|
||||||
|
.Where(l => l.ProjectId != null && l.CreatedAt >= start && l.CreatedAt < end && l.Action == "download")
|
||||||
|
.GroupBy(l => l.ProjectId!)
|
||||||
|
.Select(g => new { ProjectId = g.Key, Count = g.Count() })
|
||||||
|
.ToListAsync(stoppingToken);
|
||||||
|
|
||||||
|
var heartbeatCounts = await db.AccessLogs
|
||||||
|
.Where(l => l.ProjectId != null && l.CreatedAt >= start && l.CreatedAt < end && l.Action == "heartbeat")
|
||||||
|
.GroupBy(l => l.ProjectId!)
|
||||||
|
.Select(g => new { ProjectId = g.Key, Count = g.Count() })
|
||||||
|
.ToListAsync(stoppingToken);
|
||||||
|
|
||||||
|
var newUsers = await db.CardKeys
|
||||||
|
.Where(c => c.ProjectId != null && c.ActivateTime != null && c.ActivateTime >= start && c.ActivateTime < end)
|
||||||
|
.GroupBy(c => c.ProjectId!)
|
||||||
|
.Select(g => new { ProjectId = g.Key, Count = g.Count() })
|
||||||
|
.ToListAsync(stoppingToken);
|
||||||
|
|
||||||
|
var revenue = await db.CardKeys
|
||||||
|
.Where(c => c.ProjectId != null && c.CreatedAt >= start && c.CreatedAt < end)
|
||||||
|
.GroupBy(c => c.ProjectId!)
|
||||||
|
.Select(g => new { ProjectId = g.Key, Amount = g.Sum(x => x.SoldPrice ?? 0) })
|
||||||
|
.ToListAsync(stoppingToken);
|
||||||
|
|
||||||
|
var activeMap = activeUsers.ToDictionary(x => x.ProjectId, x => x.Count);
|
||||||
|
var downloadMap = downloads.ToDictionary(x => x.ProjectId, x => x.Count);
|
||||||
|
var heartbeatMap = heartbeatCounts.ToDictionary(x => x.ProjectId, x => x.Count);
|
||||||
|
var newUserMap = newUsers.ToDictionary(x => x.ProjectId, x => x.Count);
|
||||||
|
var revenueMap = revenue.ToDictionary(x => x.ProjectId, x => x.Amount);
|
||||||
|
|
||||||
|
foreach (var projectId in projects)
|
||||||
|
{
|
||||||
|
var entity = await db.Statistics
|
||||||
|
.FirstOrDefaultAsync(s => s.ProjectId == projectId && s.Date == date, stoppingToken);
|
||||||
|
|
||||||
|
if (entity == null)
|
||||||
|
{
|
||||||
|
entity = new Statistic
|
||||||
|
{
|
||||||
|
ProjectId = projectId,
|
||||||
|
Date = date
|
||||||
|
};
|
||||||
|
db.Statistics.Add(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
entity.ActiveUsers = activeMap.TryGetValue(projectId, out var activeCount) ? activeCount : 0;
|
||||||
|
entity.NewUsers = newUserMap.TryGetValue(projectId, out var newCount) ? newCount : 0;
|
||||||
|
entity.TotalDownloads = downloadMap.TryGetValue(projectId, out var downloadCount) ? downloadCount : 0;
|
||||||
|
entity.TotalDuration = heartbeatMap.TryGetValue(projectId, out var hbCount)
|
||||||
|
? hbCount * heartbeatInterval
|
||||||
|
: 0;
|
||||||
|
entity.Revenue = revenueMap.TryGetValue(projectId, out var rev) ? rev : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(stoppingToken);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Stats aggregation failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.Delay(TimeSpan.FromMinutes(30), stoppingToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
259
license-system-backend/src/License.Api/Services/StatsService.cs
Normal file
259
license-system-backend/src/License.Api/Services/StatsService.cs
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
using License.Api.Data;
|
||||||
|
using License.Api.DTOs;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace License.Api.Services;
|
||||||
|
|
||||||
|
public class StatsService
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
|
||||||
|
public StatsService(AppDbContext db)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<object> GetDashboardAsync(IReadOnlyCollection<string>? projectIds = null)
|
||||||
|
{
|
||||||
|
var filter = NormalizeFilter(projectIds);
|
||||||
|
if (filter is { Count: 0 })
|
||||||
|
{
|
||||||
|
return new
|
||||||
|
{
|
||||||
|
overview = new
|
||||||
|
{
|
||||||
|
totalProjects = 0,
|
||||||
|
totalCards = 0,
|
||||||
|
activeCards = 0,
|
||||||
|
activeDevices = 0,
|
||||||
|
todayRevenue = 0,
|
||||||
|
monthRevenue = 0
|
||||||
|
},
|
||||||
|
trend = new
|
||||||
|
{
|
||||||
|
dates = new List<string>(),
|
||||||
|
activeUsers = new List<int>(),
|
||||||
|
newUsers = new List<int>(),
|
||||||
|
revenue = new List<decimal>()
|
||||||
|
},
|
||||||
|
projectDistribution = new List<object>()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
var projectsQuery = _db.Projects.AsQueryable();
|
||||||
|
if (filter != null)
|
||||||
|
projectsQuery = projectsQuery.Where(p => p.ProjectId != null && filter.Contains(p.ProjectId));
|
||||||
|
var totalProjects = await projectsQuery.CountAsync();
|
||||||
|
|
||||||
|
var cardsQuery = _db.CardKeys.Where(c => c.DeletedAt == null).AsQueryable();
|
||||||
|
if (filter != null)
|
||||||
|
cardsQuery = cardsQuery.Where(c => c.ProjectId != null && filter.Contains(c.ProjectId));
|
||||||
|
var totalCards = await cardsQuery.CountAsync();
|
||||||
|
var activeCards = await cardsQuery.CountAsync(c => c.Status == "active");
|
||||||
|
|
||||||
|
var activeDevicesQuery = _db.Devices.Where(d => d.IsActive && d.DeletedAt == null).AsQueryable();
|
||||||
|
if (filter != null)
|
||||||
|
{
|
||||||
|
activeDevicesQuery = activeDevicesQuery.Join(
|
||||||
|
_db.CardKeys.Where(c => c.ProjectId != null && filter.Contains(c.ProjectId)),
|
||||||
|
d => d.CardKeyId,
|
||||||
|
c => c.Id,
|
||||||
|
(d, _) => d);
|
||||||
|
}
|
||||||
|
var activeDevices = await activeDevicesQuery.CountAsync();
|
||||||
|
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.UtcNow.Date);
|
||||||
|
var since = today.AddDays(-29);
|
||||||
|
var statsQuery = _db.Statistics.Where(s => s.Date >= since).AsQueryable();
|
||||||
|
if (filter != null)
|
||||||
|
statsQuery = statsQuery.Where(s => s.ProjectId != null && filter.Contains(s.ProjectId));
|
||||||
|
var stats = await statsQuery.ToListAsync();
|
||||||
|
|
||||||
|
var grouped = stats
|
||||||
|
.GroupBy(s => s.Date)
|
||||||
|
.OrderBy(g => g.Key)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var trend = new
|
||||||
|
{
|
||||||
|
dates = grouped.Select(g => g.Key.ToString("yyyy-MM-dd")).ToList(),
|
||||||
|
activeUsers = grouped.Select(g => g.Sum(x => x.ActiveUsers)).ToList(),
|
||||||
|
newUsers = grouped.Select(g => g.Sum(x => x.NewUsers)).ToList(),
|
||||||
|
revenue = grouped.Select(g => g.Sum(x => x.Revenue)).ToList()
|
||||||
|
};
|
||||||
|
|
||||||
|
var todayRevenue = grouped.FirstOrDefault(g => g.Key == today)?.Sum(x => x.Revenue) ?? 0;
|
||||||
|
var monthRevenue = grouped.Sum(g => g.Sum(x => x.Revenue));
|
||||||
|
|
||||||
|
return new
|
||||||
|
{
|
||||||
|
overview = new
|
||||||
|
{
|
||||||
|
totalProjects,
|
||||||
|
totalCards,
|
||||||
|
activeCards,
|
||||||
|
activeDevices,
|
||||||
|
todayRevenue,
|
||||||
|
monthRevenue
|
||||||
|
},
|
||||||
|
trend,
|
||||||
|
projectDistribution = await cardsQuery
|
||||||
|
.Where(c => c.ProjectId != null)
|
||||||
|
.GroupBy(c => c.ProjectId)
|
||||||
|
.Select(g => new { project = g.Key, count = g.Count() })
|
||||||
|
.ToListAsync()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<ProjectStatsItem>> GetProjectStatsAsync(IReadOnlyCollection<string>? projectIds = null)
|
||||||
|
{
|
||||||
|
var filter = NormalizeFilter(projectIds);
|
||||||
|
if (filter is { Count: 0 })
|
||||||
|
return new List<ProjectStatsItem>();
|
||||||
|
|
||||||
|
var projectsQuery = _db.Projects.AsNoTracking().AsQueryable();
|
||||||
|
if (filter != null)
|
||||||
|
projectsQuery = projectsQuery.Where(p => p.ProjectId != null && filter.Contains(p.ProjectId));
|
||||||
|
var projects = await projectsQuery
|
||||||
|
.Select(p => new { p.ProjectId, p.Name })
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var cardStatsQuery = _db.CardKeys
|
||||||
|
.Where(c => c.DeletedAt == null && c.ProjectId != null)
|
||||||
|
.AsQueryable();
|
||||||
|
if (filter != null)
|
||||||
|
cardStatsQuery = cardStatsQuery.Where(c => filter.Contains(c.ProjectId!));
|
||||||
|
var cardStats = await cardStatsQuery
|
||||||
|
.GroupBy(c => c.ProjectId!)
|
||||||
|
.Select(g => new
|
||||||
|
{
|
||||||
|
ProjectId = g.Key,
|
||||||
|
TotalCards = g.Count(),
|
||||||
|
ActiveCards = g.Count(x => x.Status == "active"),
|
||||||
|
Revenue = g.Sum(x => x.SoldPrice ?? 0)
|
||||||
|
})
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var deviceStatsQuery = _db.Devices
|
||||||
|
.Where(d => d.IsActive && d.DeletedAt == null)
|
||||||
|
.Join(_db.CardKeys, d => d.CardKeyId, c => c.Id, (d, c) => c.ProjectId)
|
||||||
|
.Where(pid => pid != null)
|
||||||
|
.AsQueryable();
|
||||||
|
if (filter != null)
|
||||||
|
deviceStatsQuery = deviceStatsQuery.Where(pid => filter.Contains(pid!));
|
||||||
|
var deviceStats = await deviceStatsQuery
|
||||||
|
.GroupBy(pid => pid!)
|
||||||
|
.Select(g => new { ProjectId = g.Key, ActiveDevices = g.Count() })
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var cardMap = cardStats.ToDictionary(c => c.ProjectId, c => c);
|
||||||
|
var deviceMap = deviceStats.ToDictionary(d => d.ProjectId, d => d.ActiveDevices);
|
||||||
|
|
||||||
|
return projects.Select(p =>
|
||||||
|
{
|
||||||
|
cardMap.TryGetValue(p.ProjectId, out var stats);
|
||||||
|
deviceMap.TryGetValue(p.ProjectId, out var deviceCount);
|
||||||
|
return new ProjectStatsItem
|
||||||
|
{
|
||||||
|
ProjectId = p.ProjectId,
|
||||||
|
ProjectName = p.Name,
|
||||||
|
TotalCards = stats?.TotalCards ?? 0,
|
||||||
|
ActiveCards = stats?.ActiveCards ?? 0,
|
||||||
|
ActiveDevices = deviceCount,
|
||||||
|
Revenue = stats?.Revenue ?? 0
|
||||||
|
};
|
||||||
|
}).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<AgentStatsItem>> GetAgentStatsAsync()
|
||||||
|
{
|
||||||
|
var agents = await _db.Agents
|
||||||
|
.AsNoTracking()
|
||||||
|
.Select(a => new { a.Id, a.AgentCode, a.CompanyName })
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var cardStats = await _db.CardKeys
|
||||||
|
.Where(c => c.AgentId != null && c.DeletedAt == null)
|
||||||
|
.GroupBy(c => c.AgentId!.Value)
|
||||||
|
.Select(g => new
|
||||||
|
{
|
||||||
|
AgentId = g.Key,
|
||||||
|
TotalCards = g.Count(),
|
||||||
|
ActiveCards = g.Count(x => x.Status == "active"),
|
||||||
|
Revenue = g.Sum(x => x.SoldPrice ?? 0)
|
||||||
|
})
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var map = cardStats.ToDictionary(c => c.AgentId, c => c);
|
||||||
|
|
||||||
|
return agents.Select(a =>
|
||||||
|
{
|
||||||
|
map.TryGetValue(a.Id, out var stats);
|
||||||
|
return new AgentStatsItem
|
||||||
|
{
|
||||||
|
AgentId = a.Id,
|
||||||
|
AgentCode = a.AgentCode,
|
||||||
|
CompanyName = a.CompanyName,
|
||||||
|
TotalCards = stats?.TotalCards ?? 0,
|
||||||
|
ActiveCards = stats?.ActiveCards ?? 0,
|
||||||
|
TotalRevenue = stats?.Revenue ?? 0
|
||||||
|
};
|
||||||
|
}).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<LogStatsItem>> GetLogStatsAsync(int days, IReadOnlyCollection<string>? projectIds = null)
|
||||||
|
{
|
||||||
|
var since = DateTime.UtcNow.AddDays(-days);
|
||||||
|
var filter = NormalizeFilter(projectIds);
|
||||||
|
if (filter is { Count: 0 })
|
||||||
|
return new List<LogStatsItem>();
|
||||||
|
|
||||||
|
var query = _db.AccessLogs
|
||||||
|
.Where(l => l.CreatedAt >= since)
|
||||||
|
.AsQueryable();
|
||||||
|
if (filter != null)
|
||||||
|
query = query.Where(l => l.ProjectId != null && filter.Contains(l.ProjectId));
|
||||||
|
|
||||||
|
return await query
|
||||||
|
.GroupBy(l => l.Action)
|
||||||
|
.Select(g => new LogStatsItem { Action = g.Key, Count = g.Count() })
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string> ExportStatsCsvAsync(int days, IReadOnlyCollection<string>? projectIds = null)
|
||||||
|
{
|
||||||
|
var since = DateOnly.FromDateTime(DateTime.UtcNow.AddDays(-days));
|
||||||
|
var filter = NormalizeFilter(projectIds);
|
||||||
|
if (filter is { Count: 0 })
|
||||||
|
return "date,projectId,activeUsers,newUsers,totalDownloads,totalDuration,revenue\n";
|
||||||
|
|
||||||
|
var query = _db.Statistics
|
||||||
|
.Where(s => s.Date >= since)
|
||||||
|
.AsQueryable();
|
||||||
|
if (filter != null)
|
||||||
|
query = query.Where(s => s.ProjectId != null && filter.Contains(s.ProjectId));
|
||||||
|
|
||||||
|
var rows = await query
|
||||||
|
.OrderBy(s => s.Date)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var sb = new System.Text.StringBuilder();
|
||||||
|
sb.AppendLine("date,projectId,activeUsers,newUsers,totalDownloads,totalDuration,revenue");
|
||||||
|
foreach (var row in rows)
|
||||||
|
{
|
||||||
|
sb.AppendLine($"{row.Date:yyyy-MM-dd},{row.ProjectId},{row.ActiveUsers},{row.NewUsers},{row.TotalDownloads},{row.TotalDuration},{row.Revenue}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<string>? NormalizeFilter(IReadOnlyCollection<string>? projectIds)
|
||||||
|
{
|
||||||
|
if (projectIds == null)
|
||||||
|
return null;
|
||||||
|
return projectIds
|
||||||
|
.Where(id => !string.IsNullOrWhiteSpace(id))
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
32
license-system-backend/src/License.Api/Utils/CardDefaults.cs
Normal file
32
license-system-backend/src/License.Api/Utils/CardDefaults.cs
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
namespace License.Api.Utils;
|
||||||
|
|
||||||
|
public static class CardDefaults
|
||||||
|
{
|
||||||
|
public static int ResolveDurationDays(string? cardType)
|
||||||
|
{
|
||||||
|
return cardType?.Trim().ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"test" => 1,
|
||||||
|
"day" => 1,
|
||||||
|
"week" => 7,
|
||||||
|
"month" => 30,
|
||||||
|
"year" => 365,
|
||||||
|
"lifetime" => 0,
|
||||||
|
_ => 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string? ResolveCardType(byte type)
|
||||||
|
{
|
||||||
|
return type switch
|
||||||
|
{
|
||||||
|
6 => "test",
|
||||||
|
1 => "day",
|
||||||
|
2 => "week",
|
||||||
|
3 => "month",
|
||||||
|
4 => "year",
|
||||||
|
5 => "lifetime",
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
170
license-system-backend/src/License.Api/Utils/CardKeyGenerator.cs
Normal file
170
license-system-backend/src/License.Api/Utils/CardKeyGenerator.cs
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
|
||||||
|
namespace License.Api.Utils;
|
||||||
|
|
||||||
|
public static class CardKeyGenerator
|
||||||
|
{
|
||||||
|
private const string Base32Chars = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ";
|
||||||
|
|
||||||
|
public static string Generate(byte type, int durationDays)
|
||||||
|
{
|
||||||
|
var randomBytes = RandomNumberGenerator.GetBytes(5);
|
||||||
|
var payload = new byte[8];
|
||||||
|
Array.Copy(randomBytes, 0, payload, 0, randomBytes.Length);
|
||||||
|
payload[5] = type;
|
||||||
|
var duration = (ushort)Math.Clamp(durationDays, 0, ushort.MaxValue);
|
||||||
|
var durationBytes = BitConverter.GetBytes(duration);
|
||||||
|
payload[6] = durationBytes[0];
|
||||||
|
payload[7] = durationBytes[1];
|
||||||
|
|
||||||
|
var crc = Crc32.Compute(payload);
|
||||||
|
var checksum = BitConverter.GetBytes(crc);
|
||||||
|
|
||||||
|
var fullPayload = payload.Concat(checksum).ToArray();
|
||||||
|
var encoded = Base32Encode(fullPayload);
|
||||||
|
|
||||||
|
return FormatKey(encoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool Validate(string keyCode)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(keyCode))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (!System.Text.RegularExpressions.Regex.IsMatch(keyCode, "^[2-9A-HJ-NP-Z]{4}-[2-9A-HJ-NP-Z]{4}-[2-9A-HJ-NP-Z]{4}-[2-9A-HJ-NP-Z]{4}$"))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var raw = keyCode.Replace("-", string.Empty);
|
||||||
|
var payload = Base32Decode(raw);
|
||||||
|
if (payload.Length < 2)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var receivedCrc = BitConverter.ToUInt16(payload.AsSpan(payload.Length - 2));
|
||||||
|
var computedCrc = Crc32.Compute(payload[..^2]);
|
||||||
|
|
||||||
|
return receivedCrc == computedCrc;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool TryDecode(string keyCode, out byte type, out int durationDays)
|
||||||
|
{
|
||||||
|
type = 0;
|
||||||
|
durationDays = 0;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(keyCode))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var raw = keyCode.Replace("-", string.Empty).Trim().ToUpperInvariant();
|
||||||
|
var payload = Base32Decode(raw);
|
||||||
|
if (payload.Length < 10)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var data = payload[..^2];
|
||||||
|
var receivedCrc = BitConverter.ToUInt16(payload.AsSpan(payload.Length - 2));
|
||||||
|
var computedCrc = Crc32.Compute(data);
|
||||||
|
if (receivedCrc != computedCrc || data.Length < 8)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
type = data[5];
|
||||||
|
durationDays = BitConverter.ToUInt16(data.AsSpan(6, 2));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatKey(string encoded)
|
||||||
|
{
|
||||||
|
var parts = new List<string>();
|
||||||
|
for (var i = 0; i < encoded.Length; i += 4)
|
||||||
|
{
|
||||||
|
parts.Add(encoded.Substring(i, Math.Min(4, encoded.Length - i)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Join("-", parts.Take(4));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Base32Encode(byte[] data)
|
||||||
|
{
|
||||||
|
var output = new List<char>();
|
||||||
|
var buffer = 0;
|
||||||
|
var bitsLeft = 0;
|
||||||
|
|
||||||
|
foreach (var b in data)
|
||||||
|
{
|
||||||
|
buffer = (buffer << 8) | b;
|
||||||
|
bitsLeft += 8;
|
||||||
|
|
||||||
|
while (bitsLeft >= 5)
|
||||||
|
{
|
||||||
|
var index = (buffer >> (bitsLeft - 5)) & 0x1F;
|
||||||
|
bitsLeft -= 5;
|
||||||
|
output.Add(Base32Chars[index]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bitsLeft > 0)
|
||||||
|
{
|
||||||
|
var index = (buffer << (5 - bitsLeft)) & 0x1F;
|
||||||
|
output.Add(Base32Chars[index]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new string(output.ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] Base32Decode(string input)
|
||||||
|
{
|
||||||
|
var buffer = 0;
|
||||||
|
var bitsLeft = 0;
|
||||||
|
var output = new List<byte>();
|
||||||
|
|
||||||
|
foreach (var c in input)
|
||||||
|
{
|
||||||
|
var index = Base32Chars.IndexOf(c);
|
||||||
|
if (index < 0)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
buffer = (buffer << 5) | index;
|
||||||
|
bitsLeft += 5;
|
||||||
|
|
||||||
|
if (bitsLeft >= 8)
|
||||||
|
{
|
||||||
|
output.Add((byte)(buffer >> (bitsLeft - 8)));
|
||||||
|
bitsLeft -= 8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return output.ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class Crc32
|
||||||
|
{
|
||||||
|
private static readonly uint[] Table = CreateTable();
|
||||||
|
|
||||||
|
public static ushort Compute(byte[] data)
|
||||||
|
{
|
||||||
|
uint crc = 0xFFFFFFFF;
|
||||||
|
foreach (var b in data)
|
||||||
|
{
|
||||||
|
crc = (crc >> 8) ^ Table[(crc ^ b) & 0xFF];
|
||||||
|
}
|
||||||
|
|
||||||
|
return (ushort)(crc ^ 0xFFFFFFFF);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static uint[] CreateTable()
|
||||||
|
{
|
||||||
|
var table = new uint[256];
|
||||||
|
const uint polynomial = 0xEDB88320;
|
||||||
|
for (var i = 0; i < table.Length; i++)
|
||||||
|
{
|
||||||
|
var crc = (uint)i;
|
||||||
|
for (var j = 0; j < 8; j++)
|
||||||
|
{
|
||||||
|
if ((crc & 1) == 1)
|
||||||
|
crc = (crc >> 1) ^ polynomial;
|
||||||
|
else
|
||||||
|
crc >>= 1;
|
||||||
|
}
|
||||||
|
table[i] = crc;
|
||||||
|
}
|
||||||
|
return table;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
|
using System.Security.Claims;
|
||||||
|
|
||||||
|
namespace License.Api.Utils;
|
||||||
|
|
||||||
|
public static class ClaimsPrincipalExtensions
|
||||||
|
{
|
||||||
|
public static bool TryGetUserId(this ClaimsPrincipal user, out int userId)
|
||||||
|
{
|
||||||
|
userId = 0;
|
||||||
|
var sub = user.FindFirst(JwtRegisteredClaimNames.Sub)?.Value;
|
||||||
|
return int.TryParse(sub, out userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
using BCrypt.Net;
|
||||||
|
|
||||||
|
namespace License.Api.Utils;
|
||||||
|
|
||||||
|
public static class PasswordHasher
|
||||||
|
{
|
||||||
|
public static string Hash(string password)
|
||||||
|
=> BCrypt.Net.BCrypt.HashPassword(password);
|
||||||
|
|
||||||
|
public static bool Verify(string password, string hash)
|
||||||
|
=> BCrypt.Net.BCrypt.Verify(password, hash);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
|
||||||
|
namespace License.Api.Utils;
|
||||||
|
|
||||||
|
public static class RandomIdGenerator
|
||||||
|
{
|
||||||
|
private const string AlphaNum = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
||||||
|
|
||||||
|
public static string GenerateProjectId()
|
||||||
|
=> $"PROJ_{GenerateRandomString(6)}";
|
||||||
|
|
||||||
|
public static string GenerateKey(int length = 32)
|
||||||
|
=> GenerateRandomString(length);
|
||||||
|
|
||||||
|
public static string GenerateSecret(int length = 48)
|
||||||
|
=> GenerateRandomString(length);
|
||||||
|
|
||||||
|
private static string GenerateRandomString(int length)
|
||||||
|
{
|
||||||
|
var bytes = RandomNumberGenerator.GetBytes(length);
|
||||||
|
var chars = new char[length];
|
||||||
|
for (var i = 0; i < length; i++)
|
||||||
|
{
|
||||||
|
chars[i] = AlphaNum[bytes[i] % AlphaNum.Length];
|
||||||
|
}
|
||||||
|
return new string(chars);
|
||||||
|
}
|
||||||
|
}
|
||||||
16
license-system-backend/src/License.Api/Utils/RsaKeyLoader.cs
Normal file
16
license-system-backend/src/License.Api/Utils/RsaKeyLoader.cs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
|
||||||
|
namespace License.Api.Utils;
|
||||||
|
|
||||||
|
public static class RsaKeyLoader
|
||||||
|
{
|
||||||
|
public static RSA? LoadPublicKey(string? pem)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(pem))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var rsa = RSA.Create();
|
||||||
|
rsa.ImportFromPem(pem.ToCharArray());
|
||||||
|
return rsa;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
namespace License.Api.Utils;
|
||||||
|
|
||||||
|
public static class VersionComparer
|
||||||
|
{
|
||||||
|
public static int Compare(string? a, string? b)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(a) && string.IsNullOrWhiteSpace(b))
|
||||||
|
return 0;
|
||||||
|
if (string.IsNullOrWhiteSpace(a))
|
||||||
|
return -1;
|
||||||
|
if (string.IsNullOrWhiteSpace(b))
|
||||||
|
return 1;
|
||||||
|
|
||||||
|
if (Version.TryParse(a, out var va) && Version.TryParse(b, out var vb))
|
||||||
|
return va.CompareTo(vb);
|
||||||
|
|
||||||
|
return string.Compare(a, b, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
}
|
||||||
54
license-system-backend/src/License.Api/appsettings.json
Normal file
54
license-system-backend/src/License.Api/appsettings.json
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
{
|
||||||
|
"AllowedHosts": "*",
|
||||||
|
"ConnectionStrings": {
|
||||||
|
"DefaultConnection": "Host=localhost;Port=5432;Database=license;Username=license;Password=license"
|
||||||
|
},
|
||||||
|
"Redis": {
|
||||||
|
"ConnectionString": "localhost:6379",
|
||||||
|
"Enabled": true
|
||||||
|
},
|
||||||
|
"Jwt": {
|
||||||
|
"Secret": "replace_with_32plus_chars_secret",
|
||||||
|
"Issuer": "license-system",
|
||||||
|
"ExpireMinutes": 1440,
|
||||||
|
"AdminExpireMinutes": 720,
|
||||||
|
"AgentExpireMinutes": 1440
|
||||||
|
},
|
||||||
|
"Security": {
|
||||||
|
"SignatureEnabled": true,
|
||||||
|
"TimestampToleranceSeconds": 300
|
||||||
|
},
|
||||||
|
"Storage": {
|
||||||
|
"UploadRoot": "uploads",
|
||||||
|
"MaxUploadMb": 200,
|
||||||
|
"ClientRsaPublicKeyPem": "",
|
||||||
|
"RequireHttpsForDownloadKey": true
|
||||||
|
},
|
||||||
|
"RateLimit": {
|
||||||
|
"Enabled": true,
|
||||||
|
"IpPerMinute": 100,
|
||||||
|
"DevicePerMinute": 50,
|
||||||
|
"BlockDurationMinutes": 5
|
||||||
|
},
|
||||||
|
"Heartbeat": {
|
||||||
|
"Enabled": true,
|
||||||
|
"IntervalSeconds": 60,
|
||||||
|
"TimeoutSeconds": 180
|
||||||
|
},
|
||||||
|
"Cors": {
|
||||||
|
"AllowAny": false,
|
||||||
|
"AllowedOrigins": []
|
||||||
|
},
|
||||||
|
"Seed": {
|
||||||
|
"AdminUser": "admin",
|
||||||
|
"AdminPassword": "admin123",
|
||||||
|
"AdminEmail": ""
|
||||||
|
},
|
||||||
|
"Serilog": {
|
||||||
|
"MinimumLevel": "Information",
|
||||||
|
"WriteTo": [
|
||||||
|
{ "Name": "Console" },
|
||||||
|
{ "Name": "File", "Args": { "path": "logs/app-.log", "rollingInterval": "Day" } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
2
license-system-frontend/.env.example
Normal file
2
license-system-frontend/.env.example
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
VITE_API_BASE=
|
||||||
|
VITE_API_PORT=
|
||||||
42
license-system-frontend/README.md
Normal file
42
license-system-frontend/README.md
Normal file
@@ -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".
|
||||||
12
license-system-frontend/index.html
Normal file
12
license-system-frontend/index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>License System</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
8
license-system-frontend/jsconfig.json
Normal file
8
license-system-frontend/jsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1732
license-system-frontend/package-lock.json
generated
Normal file
1732
license-system-frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
license-system-frontend/package.json
Normal file
23
license-system-frontend/package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
8
license-system-frontend/src/App.vue
Normal file
8
license-system-frontend/src/App.vue
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<template>
|
||||||
|
<div class="app-root">
|
||||||
|
<router-view />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
</script>
|
||||||
79
license-system-frontend/src/api/admin.js
Normal file
79
license-system-frontend/src/api/admin.js
Normal file
@@ -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}`);
|
||||||
13
license-system-frontend/src/api/agent.js
Normal file
13
license-system-frontend/src/api/agent.js
Normal file
@@ -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 });
|
||||||
40
license-system-frontend/src/api/http.js
Normal file
40
license-system-frontend/src/api/http.js
Normal file
@@ -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;
|
||||||
77
license-system-frontend/src/components/BarChart.vue
Normal file
77
license-system-frontend/src/components/BarChart.vue
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
<template>
|
||||||
|
<div ref="chartRef" class="chart"></div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { onMounted, onBeforeUnmount, ref, watch } from 'vue';
|
||||||
|
import * as echarts from 'echarts';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
labels: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
values: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
color: {
|
||||||
|
type: String,
|
||||||
|
default: '#ff6b35'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const chartRef = ref(null);
|
||||||
|
let chart = null;
|
||||||
|
|
||||||
|
const render = () => {
|
||||||
|
if (!chart) return;
|
||||||
|
chart.setOption({
|
||||||
|
tooltip: { trigger: 'axis' },
|
||||||
|
grid: { left: 32, right: 20, top: 20, bottom: 40 },
|
||||||
|
xAxis: {
|
||||||
|
type: 'category',
|
||||||
|
data: props.labels,
|
||||||
|
axisLabel: { rotate: 30 }
|
||||||
|
},
|
||||||
|
yAxis: { type: 'value' },
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: 'bar',
|
||||||
|
data: props.values,
|
||||||
|
itemStyle: { color: props.color, borderRadius: [8, 8, 0, 0] }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResize = () => {
|
||||||
|
chart?.resize();
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
chart = echarts.init(chartRef.value);
|
||||||
|
render();
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('resize', handleResize);
|
||||||
|
chart?.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.labels, props.values],
|
||||||
|
() => {
|
||||||
|
render();
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.chart {
|
||||||
|
width: 100%;
|
||||||
|
height: 280px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
65
license-system-frontend/src/components/TrendChart.vue
Normal file
65
license-system-frontend/src/components/TrendChart.vue
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
<template>
|
||||||
|
<div ref="chartRef" class="chart"></div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { onMounted, onBeforeUnmount, ref, watch } from 'vue';
|
||||||
|
import * as echarts from 'echarts';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
trend: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({ dates: [], activeUsers: [], newUsers: [], revenue: [] })
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const chartRef = ref(null);
|
||||||
|
let chart = null;
|
||||||
|
|
||||||
|
const render = () => {
|
||||||
|
if (!chart) return;
|
||||||
|
const { dates = [], activeUsers = [], newUsers = [], revenue = [] } = props.trend || {};
|
||||||
|
chart.setOption({
|
||||||
|
tooltip: { trigger: 'axis' },
|
||||||
|
legend: { top: 0 },
|
||||||
|
grid: { left: 30, right: 20, top: 40, bottom: 30 },
|
||||||
|
xAxis: { type: 'category', data: dates },
|
||||||
|
yAxis: { type: 'value' },
|
||||||
|
series: [
|
||||||
|
{ name: '\u6d3b\u8dc3\u7528\u6237', type: 'line', smooth: true, data: activeUsers },
|
||||||
|
{ name: '\u65b0\u589e\u7528\u6237', type: 'line', smooth: true, data: newUsers },
|
||||||
|
{ name: '\u6536\u5165', type: 'line', smooth: true, data: revenue }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResize = () => {
|
||||||
|
chart?.resize();
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
chart = echarts.init(chartRef.value);
|
||||||
|
render();
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('resize', handleResize);
|
||||||
|
chart?.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.trend,
|
||||||
|
() => {
|
||||||
|
render();
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.chart {
|
||||||
|
width: 100%;
|
||||||
|
height: 320px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
266
license-system-frontend/src/layouts/AdminLayout.vue
Normal file
266
license-system-frontend/src/layouts/AdminLayout.vue
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
<template>
|
||||||
|
<div class="layout-shell">
|
||||||
|
<el-container class="layout-container">
|
||||||
|
<el-aside class="sidebar" width="240px">
|
||||||
|
<div class="brand">
|
||||||
|
<div class="brand-mark">LS</div>
|
||||||
|
<div>
|
||||||
|
<div class="brand-title">{{ '\u6388\u6743\u7cfb\u7edf' }}</div>
|
||||||
|
<div class="brand-sub">{{ '\u7ba1\u7406\u63a7\u5236\u53f0' }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<el-menu :default-active="activeMenu" class="menu" router>
|
||||||
|
<el-menu-item index="/admin/dashboard">{{ '\u4eea\u8868\u76d8' }}</el-menu-item>
|
||||||
|
<el-menu-item index="/admin/projects">{{ '\u9879\u76ee' }}</el-menu-item>
|
||||||
|
<el-menu-item index="/admin/cards">{{ '\u5361\u5bc6' }}</el-menu-item>
|
||||||
|
<el-menu-item index="/admin/devices">{{ '\u8bbe\u5907' }}</el-menu-item>
|
||||||
|
<el-menu-item index="/admin/logs">{{ '\u65e5\u5fd7' }}</el-menu-item>
|
||||||
|
<el-menu-item index="/admin/stats">{{ '\u7edf\u8ba1' }}</el-menu-item>
|
||||||
|
<el-menu-item index="/admin/api-docs">{{ '\u63a5\u53e3\u63a5\u5165\u6587\u6863' }}</el-menu-item>
|
||||||
|
<el-menu-item v-if="superAdmin" index="/admin/agents">{{ '\u4ee3\u7406\u5546' }}</el-menu-item>
|
||||||
|
<el-menu-item v-if="superAdmin" index="/admin/settings">{{ '\u8bbe\u7f6e' }}</el-menu-item>
|
||||||
|
</el-menu>
|
||||||
|
</el-aside>
|
||||||
|
|
||||||
|
<el-container>
|
||||||
|
<el-header class="header">
|
||||||
|
<div class="header-left">
|
||||||
|
<el-button class="mobile-toggle" text @click="drawer = true">
|
||||||
|
<span class="toggle-icon"></span>
|
||||||
|
</el-button>
|
||||||
|
<div class="header-title">
|
||||||
|
<div class="section-title">{{ '\u7ba1\u7406\u5de5\u4f5c\u53f0' }}</div>
|
||||||
|
<div class="header-meta">
|
||||||
|
<span class="tag">{{ roleLabel }}</span>
|
||||||
|
<span v-if="projectHint" class="tag">{{ projectHint }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="header-right">
|
||||||
|
<div class="user-block">
|
||||||
|
<div class="user-name">{{ userName }}</div>
|
||||||
|
<div class="user-role">{{ roleLabel }}</div>
|
||||||
|
</div>
|
||||||
|
<el-button type="primary" plain @click="handleLogout">{{ '\u9000\u51fa\u767b\u5f55' }}</el-button>
|
||||||
|
</div>
|
||||||
|
</el-header>
|
||||||
|
<el-main class="page-shell">
|
||||||
|
<transition name="fade" mode="out-in">
|
||||||
|
<router-view />
|
||||||
|
</transition>
|
||||||
|
</el-main>
|
||||||
|
</el-container>
|
||||||
|
</el-container>
|
||||||
|
|
||||||
|
<el-drawer v-model="drawer" direction="ltr" size="240px" class="mobile-drawer">
|
||||||
|
<template #header>
|
||||||
|
<div class="drawer-title">{{ '\u83dc\u5355' }}</div>
|
||||||
|
</template>
|
||||||
|
<el-menu :default-active="activeMenu" router @select="drawer = false">
|
||||||
|
<el-menu-item index="/admin/dashboard">{{ '\u4eea\u8868\u76d8' }}</el-menu-item>
|
||||||
|
<el-menu-item index="/admin/projects">{{ '\u9879\u76ee' }}</el-menu-item>
|
||||||
|
<el-menu-item index="/admin/cards">{{ '\u5361\u5bc6' }}</el-menu-item>
|
||||||
|
<el-menu-item index="/admin/devices">{{ '\u8bbe\u5907' }}</el-menu-item>
|
||||||
|
<el-menu-item index="/admin/logs">{{ '\u65e5\u5fd7' }}</el-menu-item>
|
||||||
|
<el-menu-item index="/admin/stats">{{ '\u7edf\u8ba1' }}</el-menu-item>
|
||||||
|
<el-menu-item index="/admin/api-docs">{{ '\u63a5\u53e3\u63a5\u5165\u6587\u6863' }}</el-menu-item>
|
||||||
|
<el-menu-item v-if="superAdmin" index="/admin/agents">{{ '\u4ee3\u7406\u5546' }}</el-menu-item>
|
||||||
|
<el-menu-item v-if="superAdmin" index="/admin/settings">{{ '\u8bbe\u7f6e' }}</el-menu-item>
|
||||||
|
</el-menu>
|
||||||
|
</el-drawer>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
import { adminLogout } from '@/api/admin';
|
||||||
|
import { session, clearSession, isSuperAdmin } from '@/store/session';
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const drawer = ref(false);
|
||||||
|
|
||||||
|
const activeMenu = computed(() => route.path);
|
||||||
|
const superAdmin = computed(() => isSuperAdmin());
|
||||||
|
const userName = computed(() => session.user?.username || '\u7ba1\u7406\u5458');
|
||||||
|
const roleLabel = computed(() =>
|
||||||
|
session.role === 'super_admin' ? '\u8d85\u7ea7\u7ba1\u7406\u5458' : '\u7ba1\u7406\u5458'
|
||||||
|
);
|
||||||
|
const projectHint = computed(() => {
|
||||||
|
if (superAdmin.value) return '\u5168\u90e8\u9879\u76ee';
|
||||||
|
if ((session.permissions || []).includes('*')) return '\u5168\u90e8\u9879\u76ee';
|
||||||
|
if ((session.permissions || []).length === 0) return '\u65e0\u9879\u76ee\u6743\u9650';
|
||||||
|
return `${session.permissions.length}\u4e2a\u9879\u76ee`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
try {
|
||||||
|
await adminLogout();
|
||||||
|
} catch (err) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
clearSession();
|
||||||
|
router.push('/login');
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.layout-shell {
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-container {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
background: rgba(15, 23, 42, 0.92);
|
||||||
|
color: #fff;
|
||||||
|
border-right: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
padding: 18px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px 12px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-mark {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: linear-gradient(140deg, #ff6b35, #0ea5a4);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-title {
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-sub {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu :deep(.el-menu-item) {
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
border-radius: 10px;
|
||||||
|
margin: 4px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu :deep(.el-menu-item.is-active) {
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px 28px;
|
||||||
|
background: rgba(255, 255, 255, 0.7);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-title {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-block {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-name {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-role {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ink-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-toggle {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-icon {
|
||||||
|
width: 24px;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--ink-900);
|
||||||
|
display: inline-block;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-icon::before,
|
||||||
|
.toggle-icon::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
width: 24px;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--ink-900);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-icon::before {
|
||||||
|
top: -6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-icon::after {
|
||||||
|
top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-drawer :deep(.el-drawer__body) {
|
||||||
|
padding: 0 12px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-title {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.sidebar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-right {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
236
license-system-frontend/src/layouts/AgentLayout.vue
Normal file
236
license-system-frontend/src/layouts/AgentLayout.vue
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
<template>
|
||||||
|
<div class="layout-shell">
|
||||||
|
<el-container class="layout-container">
|
||||||
|
<el-aside class="sidebar" width="220px">
|
||||||
|
<div class="brand">
|
||||||
|
<div class="brand-mark">AG</div>
|
||||||
|
<div>
|
||||||
|
<div class="brand-title">{{ '\u4ee3\u7406\u5546\u540e\u53f0' }}</div>
|
||||||
|
<div class="brand-sub">{{ '\u9500\u552e\u63a7\u5236\u53f0' }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<el-menu :default-active="activeMenu" class="menu" router>
|
||||||
|
<el-menu-item index="/agent/cards">{{ '\u5361\u5bc6' }}</el-menu-item>
|
||||||
|
<el-menu-item index="/agent/transactions">{{ '\u989d\u5ea6\u6d41\u6c34' }}</el-menu-item>
|
||||||
|
<el-menu-item index="/agent/profile">{{ '\u8d44\u6599' }}</el-menu-item>
|
||||||
|
</el-menu>
|
||||||
|
</el-aside>
|
||||||
|
|
||||||
|
<el-container>
|
||||||
|
<el-header class="header">
|
||||||
|
<div class="header-left">
|
||||||
|
<el-button class="mobile-toggle" text @click="drawer = true">
|
||||||
|
<span class="toggle-icon"></span>
|
||||||
|
</el-button>
|
||||||
|
<div class="header-title">
|
||||||
|
<div class="section-title">{{ '\u4ee3\u7406\u5de5\u4f5c\u53f0' }}</div>
|
||||||
|
<div class="header-meta">
|
||||||
|
<span class="tag">{{ '\u4f59\u989d' }} {{ balanceLabel }}</span>
|
||||||
|
<span class="tag">{{ '\u6298\u6263' }} {{ discountLabel }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="header-right">
|
||||||
|
<div class="user-block">
|
||||||
|
<div class="user-name">{{ agentName }}</div>
|
||||||
|
<div class="user-role">{{ '\u4ee3\u7406\u5546' }}</div>
|
||||||
|
</div>
|
||||||
|
<el-button type="primary" plain @click="handleLogout">{{ '\u9000\u51fa\u767b\u5f55' }}</el-button>
|
||||||
|
</div>
|
||||||
|
</el-header>
|
||||||
|
<el-main class="page-shell">
|
||||||
|
<transition name="fade" mode="out-in">
|
||||||
|
<router-view />
|
||||||
|
</transition>
|
||||||
|
</el-main>
|
||||||
|
</el-container>
|
||||||
|
</el-container>
|
||||||
|
|
||||||
|
<el-drawer v-model="drawer" direction="ltr" size="220px" class="mobile-drawer">
|
||||||
|
<template #header>
|
||||||
|
<div class="drawer-title">{{ '\u83dc\u5355' }}</div>
|
||||||
|
</template>
|
||||||
|
<el-menu :default-active="activeMenu" router @select="drawer = false">
|
||||||
|
<el-menu-item index="/agent/cards">{{ '\u5361\u5bc6' }}</el-menu-item>
|
||||||
|
<el-menu-item index="/agent/transactions">{{ '\u989d\u5ea6\u6d41\u6c34' }}</el-menu-item>
|
||||||
|
<el-menu-item index="/agent/profile">{{ '\u8d44\u6599' }}</el-menu-item>
|
||||||
|
</el-menu>
|
||||||
|
</el-drawer>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
import { agentLogout } from '@/api/agent';
|
||||||
|
import { session, clearSession } from '@/store/session';
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const drawer = ref(false);
|
||||||
|
|
||||||
|
const activeMenu = computed(() => route.path);
|
||||||
|
const agentName = computed(() => session.agent?.companyName || session.agent?.agentCode || '\u4ee3\u7406\u5546');
|
||||||
|
const balanceLabel = computed(() => `${(session.agent?.balance ?? 0).toFixed(2)}`);
|
||||||
|
const discountLabel = computed(() => `${(session.agent?.discount ?? 100).toFixed(0)}%`);
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
try {
|
||||||
|
await agentLogout();
|
||||||
|
} catch (err) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
clearSession();
|
||||||
|
router.push('/login');
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.layout-shell {
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-container {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
background: rgba(15, 23, 42, 0.9);
|
||||||
|
color: #fff;
|
||||||
|
padding: 18px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px 12px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-mark {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: linear-gradient(140deg, #0ea5a4, #ff6b35);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-title {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-sub {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu :deep(.el-menu-item) {
|
||||||
|
color: rgba(255, 255, 255, 0.75);
|
||||||
|
border-radius: 10px;
|
||||||
|
margin: 4px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu :deep(.el-menu-item.is-active) {
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px 28px;
|
||||||
|
background: rgba(255, 255, 255, 0.7);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-title {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-block {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-name {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-role {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ink-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-toggle {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-icon {
|
||||||
|
width: 24px;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--ink-900);
|
||||||
|
display: inline-block;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-icon::before,
|
||||||
|
.toggle-icon::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
width: 24px;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--ink-900);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-icon::before {
|
||||||
|
top: -6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-icon::after {
|
||||||
|
top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.sidebar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-right {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
12
license-system-frontend/src/main.js
Normal file
12
license-system-frontend/src/main.js
Normal file
@@ -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');
|
||||||
157
license-system-frontend/src/pages/LoginView.vue
Normal file
157
license-system-frontend/src/pages/LoginView.vue
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
<template>
|
||||||
|
<div class="login-page">
|
||||||
|
<div class="login-card glass-card">
|
||||||
|
<div class="login-hero">
|
||||||
|
<div class="hero-mark">LS</div>
|
||||||
|
<div>
|
||||||
|
<h1 class="section-title">{{ '\u6388\u6743\u7cfb\u7edf' }}</h1>
|
||||||
|
<p class="hero-sub">{{ '\u7ba1\u7406\u5458\u4e0e\u4ee3\u7406\u5b89\u5168\u767b\u5f55' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-form :model="form" class="login-form" @submit.prevent>
|
||||||
|
<el-segmented v-model="loginType" :options="loginOptions" class="segment" />
|
||||||
|
|
||||||
|
<el-form-item :label="'\u8d26\u53f7'" v-if="loginType === 'admin'">
|
||||||
|
<el-input v-model="form.username" :placeholder="'\u8bf7\u8f93\u5165\u7ba1\u7406\u5458\u8d26\u53f7'" autocomplete="username" />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item :label="'\u4ee3\u7406\u7f16\u7801'" v-else>
|
||||||
|
<el-input v-model="form.agentCode" :placeholder="'\u8bf7\u8f93\u5165\u4ee3\u7406\u7f16\u7801'" autocomplete="username" />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item :label="'\u5bc6\u7801'">
|
||||||
|
<el-input v-model="form.password" type="password" show-password autocomplete="current-password" />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-button type="primary" size="large" class="submit" :loading="loading" @click="handleLogin">
|
||||||
|
{{ '\u767b\u5f55' }}
|
||||||
|
</el-button>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<div class="login-foot">
|
||||||
|
<span>{{ '\u9700\u8981\u5e2e\u52a9\uff1f' }}</span>
|
||||||
|
<a :href="helpUrl" target="_blank" rel="noreferrer">{{ '\u8054\u7cfb\u652f\u6301' }}</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { reactive, ref } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { ElMessage } from 'element-plus';
|
||||||
|
import { adminLogin } from '@/api/admin';
|
||||||
|
import { agentLogin } from '@/api/agent';
|
||||||
|
import { setAdminSession, setAgentSession } from '@/store/session';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const loginType = ref('admin');
|
||||||
|
const loginOptions = [
|
||||||
|
{ label: '\u7ba1\u7406\u5458', value: 'admin' },
|
||||||
|
{ label: '\u4ee3\u7406\u5546', value: 'agent' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
username: '',
|
||||||
|
agentCode: '',
|
||||||
|
password: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const helpUrl = 'https://example.com/support';
|
||||||
|
|
||||||
|
const handleLogin = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
if (loginType.value === 'admin') {
|
||||||
|
const data = await adminLogin({
|
||||||
|
username: form.username,
|
||||||
|
password: form.password,
|
||||||
|
captcha: ''
|
||||||
|
});
|
||||||
|
setAdminSession(data);
|
||||||
|
router.push('/admin/dashboard');
|
||||||
|
} else {
|
||||||
|
const data = await agentLogin({
|
||||||
|
agentCode: form.agentCode,
|
||||||
|
password: form.password
|
||||||
|
});
|
||||||
|
setAgentSession(data);
|
||||||
|
router.push('/agent/cards');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const message = err?.message || err?.data?.message || '\u767b\u5f55\u5931\u8d25';
|
||||||
|
ElMessage.error(message);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.login-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 32px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
width: min(460px, 92vw);
|
||||||
|
padding: 28px;
|
||||||
|
border-radius: 24px;
|
||||||
|
animation: fadeUp 0.6s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-hero {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-mark {
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: linear-gradient(140deg, var(--accent-500), var(--teal-500));
|
||||||
|
color: #fff;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-sub {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
color: var(--ink-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segment {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit {
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-foot {
|
||||||
|
margin-top: 16px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--ink-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-foot a {
|
||||||
|
color: var(--teal-700);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
35
license-system-frontend/src/pages/NotFoundView.vue
Normal file
35
license-system-frontend/src/pages/NotFoundView.vue
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page-shell">
|
||||||
|
<div class="glass-card panel notfound">
|
||||||
|
<h2 class="section-title">{{ '\u9875\u9762\u4e0d\u5b58\u5728' }}</h2>
|
||||||
|
<p>{{ '\u8bbf\u95ee\u8def\u5f84\u4e0d\u5b58\u5728' }}</p>
|
||||||
|
<el-button type="primary" @click="goHome">{{ '\u8fd4\u56de\u4e3b\u9875' }}</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { session } from '@/store/session';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const goHome = () => {
|
||||||
|
if (session.type === 'agent') {
|
||||||
|
router.push('/agent/cards');
|
||||||
|
} else if (session.type === 'admin') {
|
||||||
|
router.push('/admin/dashboard');
|
||||||
|
} else {
|
||||||
|
router.push('/login');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.notfound {
|
||||||
|
max-width: 480px;
|
||||||
|
margin: 0 auto;
|
||||||
|
text-align: center;
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
371
license-system-frontend/src/pages/admin/AgentsView.vue
Normal file
371
license-system-frontend/src/pages/admin/AgentsView.vue
Normal file
@@ -0,0 +1,371 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page-shell">
|
||||||
|
<div class="toolbar">
|
||||||
|
<div>
|
||||||
|
<h2 class="section-title">{{ '\u4ee3\u7406\u5546' }}</h2>
|
||||||
|
<div class="tag">{{ '\u4ee3\u7406\u8d26\u53f7\u7ba1\u7406' }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-actions">
|
||||||
|
<el-button type="primary" @click="openCreate">{{ '\u65b0\u5efa\u4ee3\u7406' }}</el-button>
|
||||||
|
<el-button @click="loadAgents">{{ '\u5237\u65b0' }}</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="glass-card panel">
|
||||||
|
<el-table :data="agents" v-loading="loading" @row-click="openDetail">
|
||||||
|
<el-table-column prop="agentCode" :label="'\u4ee3\u7406\u7f16\u7801'" width="160" />
|
||||||
|
<el-table-column prop="companyName" :label="'\u516c\u53f8'" min-width="200" />
|
||||||
|
<el-table-column prop="contactPerson" :label="'\u8054\u7cfb\u4eba'" width="160" />
|
||||||
|
<el-table-column prop="balance" :label="'\u4f59\u989d'" width="120" />
|
||||||
|
<el-table-column prop="discount" :label="'\u6298\u6263'" width="120" />
|
||||||
|
<el-table-column prop="status" :label="'\u72b6\u6001'" width="120">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-tag :type="scope.row.status === 'active' ? 'success' : 'info'">{{ statusLabel(scope.row.status) }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column :label="'\u64cd\u4f5c'" width="220">
|
||||||
|
<template #default="scope">
|
||||||
|
<div class="table-actions">
|
||||||
|
<el-button size="small" @click.stop="editAgent(scope.row)">{{ '\u7f16\u8f91' }}</el-button>
|
||||||
|
<el-button size="small" type="warning" @click.stop="toggleStatus(scope.row)">
|
||||||
|
{{ scope.row.status === 'active' ? '\u7981\u7528' : '\u542f\u7528' }}
|
||||||
|
</el-button>
|
||||||
|
<el-button size="small" type="danger" @click.stop="removeAgent(scope.row)">{{ '\u5220\u9664' }}</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<div class="table-footer">
|
||||||
|
<el-pagination
|
||||||
|
background
|
||||||
|
layout="prev, pager, next"
|
||||||
|
:total="pagination.total"
|
||||||
|
:page-size="pagination.pageSize"
|
||||||
|
:current-page="pagination.page"
|
||||||
|
@current-change="handlePage"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-drawer v-model="formVisible" :title="'\u4ee3\u7406\u5546'" size="420px">
|
||||||
|
<el-form :model="form" label-position="top">
|
||||||
|
<el-form-item :label="'\u4ee3\u7406\u7f16\u7801'">
|
||||||
|
<el-input v-model="form.agentCode" :disabled="!!form.id" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item :label="'\u5bc6\u7801'" v-if="!form.id">
|
||||||
|
<el-input v-model="form.password" type="password" show-password />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item :label="'\u516c\u53f8\u540d\u79f0'">
|
||||||
|
<el-input v-model="form.companyName" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item :label="'\u8054\u7cfb\u4eba'">
|
||||||
|
<el-input v-model="form.contactPerson" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item :label="'\u8054\u7cfb\u7535\u8bdd'">
|
||||||
|
<el-input v-model="form.contactPhone" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item :label="'\u8054\u7cfb\u90ae\u7bb1'">
|
||||||
|
<el-input v-model="form.contactEmail" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item :label="'\u521d\u59cb\u4f59\u989d'" v-if="!form.id">
|
||||||
|
<el-input-number v-model="form.initialBalance" :min="0" :step="10" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item :label="'\u6298\u6263'">
|
||||||
|
<el-input-number v-model="form.discount" :min="0" :max="100" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item :label="'\u6388\u4fe1\u989d\u5ea6'">
|
||||||
|
<el-input-number v-model="form.creditLimit" :min="0" :step="10" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item :label="'\u53ef\u7528\u9879\u76ee'">
|
||||||
|
<el-checkbox v-if="form.id" v-model="overrideProjects">{{ '\u81ea\u5b9a\u4e49\u9879\u76ee' }}</el-checkbox>
|
||||||
|
<el-select v-model="form.allowedProjects" multiple filterable :disabled="form.id && !overrideProjects">
|
||||||
|
<el-option v-for="project in projects" :key="project.projectId" :label="project.name" :value="project.projectId" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="formVisible = false">{{ '\u53d6\u6d88' }}</el-button>
|
||||||
|
<el-button type="primary" @click="saveAgent">{{ '\u4fdd\u5b58' }}</el-button>
|
||||||
|
</template>
|
||||||
|
</el-drawer>
|
||||||
|
|
||||||
|
<el-drawer v-model="detailVisible" :title="'\u4ee3\u7406\u8be6\u60c5'" size="760px">
|
||||||
|
<div v-if="selected" class="detail-head">
|
||||||
|
<div>
|
||||||
|
<h3 class="section-title">{{ selected.agent.agentCode }}</h3>
|
||||||
|
<div class="tag">{{ selected.agent.companyName }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-actions">
|
||||||
|
<el-button size="small" @click="editAgent(selected.agent)">{{ '\u7f16\u8f91' }}</el-button>
|
||||||
|
<el-button size="small" type="primary" @click="openBalance('recharge')">{{ '\u5145\u503c' }}</el-button>
|
||||||
|
<el-button size="small" type="warning" @click="openBalance('deduct')">{{ '\u6263\u51cf' }}</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="selected" class="stat-grid" style="margin-bottom: 16px;">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">{{ '\u603b\u5361\u5bc6' }}</div>
|
||||||
|
<div class="stat-value">{{ selected.stats?.totalCards ?? 0 }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">{{ '\u5df2\u6fc0\u6d3b' }}</div>
|
||||||
|
<div class="stat-value">{{ selected.stats?.activeCards ?? 0 }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">{{ '\u603b\u6536\u5165' }}</div>
|
||||||
|
<div class="stat-value">{{ selected.stats?.totalRevenue ?? 0 }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table :data="selected?.transactions || []" height="300">
|
||||||
|
<el-table-column prop="type" :label="'\u7c7b\u578b'" width="140">
|
||||||
|
<template #default="scope">
|
||||||
|
{{ transactionTypeLabel(scope.row.type) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="amount" :label="'\u91d1\u989d'" width="140" />
|
||||||
|
<el-table-column prop="balanceAfter" :label="'\u4f59\u989d'" width="140" />
|
||||||
|
<el-table-column prop="remark" :label="'\u5907\u6ce8'" min-width="220" />
|
||||||
|
<el-table-column prop="createdAt" :label="'\u65f6\u95f4'" width="180" />
|
||||||
|
</el-table>
|
||||||
|
</el-drawer>
|
||||||
|
|
||||||
|
<el-dialog v-model="balanceVisible" :title="'\u4f59\u989d\u8c03\u6574'" width="420px">
|
||||||
|
<el-form :model="balanceForm" label-position="top">
|
||||||
|
<el-form-item :label="'\u91d1\u989d'">
|
||||||
|
<el-input-number v-model="balanceForm.amount" :min="1" :step="10" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item :label="'\u5907\u6ce8'">
|
||||||
|
<el-input v-model="balanceForm.remark" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="balanceVisible = false">{{ '\u53d6\u6d88' }}</el-button>
|
||||||
|
<el-button type="primary" @click="submitBalance">{{ '\u63d0\u4ea4' }}</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { onMounted, reactive, ref } from 'vue';
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||||
|
import {
|
||||||
|
listAgents,
|
||||||
|
createAgent,
|
||||||
|
updateAgent,
|
||||||
|
deleteAgent,
|
||||||
|
enableAgent,
|
||||||
|
disableAgent,
|
||||||
|
getAgent,
|
||||||
|
rechargeAgent,
|
||||||
|
deductAgent
|
||||||
|
} from '@/api/admin';
|
||||||
|
import { fetchProjects } from '@/api/admin';
|
||||||
|
|
||||||
|
const agents = ref([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const pagination = reactive({ page: 1, pageSize: 20, total: 0 });
|
||||||
|
const projects = ref([]);
|
||||||
|
|
||||||
|
const formVisible = ref(false);
|
||||||
|
const form = reactive({
|
||||||
|
id: null,
|
||||||
|
agentCode: '',
|
||||||
|
password: '',
|
||||||
|
companyName: '',
|
||||||
|
contactPerson: '',
|
||||||
|
contactPhone: '',
|
||||||
|
contactEmail: '',
|
||||||
|
initialBalance: 0,
|
||||||
|
discount: 80,
|
||||||
|
creditLimit: 0,
|
||||||
|
allowedProjects: []
|
||||||
|
});
|
||||||
|
const overrideProjects = ref(false);
|
||||||
|
|
||||||
|
const detailVisible = ref(false);
|
||||||
|
const selected = ref(null);
|
||||||
|
|
||||||
|
const balanceVisible = ref(false);
|
||||||
|
const balanceForm = reactive({ type: 'recharge', amount: 100, remark: '' });
|
||||||
|
|
||||||
|
const statusLabel = (value) => {
|
||||||
|
if (value === 'active') return '\u542f\u7528';
|
||||||
|
if (value === 'disabled') return '\u7981\u7528';
|
||||||
|
return value || '-';
|
||||||
|
};
|
||||||
|
|
||||||
|
const transactionTypeLabel = (value) => {
|
||||||
|
if (value === 'recharge') return '\u5145\u503c';
|
||||||
|
if (value === 'consume') return '\u6263\u8d39';
|
||||||
|
return value || '-';
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadAgents = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const res = await listAgents({ page: pagination.page, pageSize: pagination.pageSize });
|
||||||
|
agents.value = res.items || [];
|
||||||
|
pagination.total = res.pagination?.total || 0;
|
||||||
|
} catch (err) {
|
||||||
|
ElMessage.error(err?.message || '\u52a0\u8f7d\u4ee3\u7406\u5546\u5931\u8d25');
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePage = (page) => {
|
||||||
|
pagination.page = page;
|
||||||
|
loadAgents();
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadProjects = async () => {
|
||||||
|
const res = await fetchProjects({ page: 1, pageSize: 100 });
|
||||||
|
projects.value = res.items || [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
Object.assign(form, {
|
||||||
|
id: null,
|
||||||
|
agentCode: '',
|
||||||
|
password: '',
|
||||||
|
companyName: '',
|
||||||
|
contactPerson: '',
|
||||||
|
contactPhone: '',
|
||||||
|
contactEmail: '',
|
||||||
|
initialBalance: 0,
|
||||||
|
discount: 80,
|
||||||
|
creditLimit: 0,
|
||||||
|
allowedProjects: []
|
||||||
|
});
|
||||||
|
overrideProjects.value = true;
|
||||||
|
formVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const editAgent = (row) => {
|
||||||
|
Object.assign(form, {
|
||||||
|
id: row.id,
|
||||||
|
agentCode: row.agentCode,
|
||||||
|
companyName: row.companyName,
|
||||||
|
contactPerson: row.contactPerson,
|
||||||
|
contactPhone: row.contactPhone,
|
||||||
|
contactEmail: row.contactEmail,
|
||||||
|
discount: row.discount,
|
||||||
|
creditLimit: row.creditLimit,
|
||||||
|
allowedProjects: row.allowedProjects || []
|
||||||
|
});
|
||||||
|
overrideProjects.value = false;
|
||||||
|
formVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveAgent = async () => {
|
||||||
|
try {
|
||||||
|
if (form.id) {
|
||||||
|
const payload = {
|
||||||
|
companyName: form.companyName,
|
||||||
|
contactPerson: form.contactPerson,
|
||||||
|
contactPhone: form.contactPhone,
|
||||||
|
contactEmail: form.contactEmail,
|
||||||
|
discount: form.discount,
|
||||||
|
creditLimit: form.creditLimit,
|
||||||
|
status: form.status
|
||||||
|
};
|
||||||
|
if (overrideProjects.value) {
|
||||||
|
payload.allowedProjects = form.allowedProjects;
|
||||||
|
}
|
||||||
|
await updateAgent(form.id, payload);
|
||||||
|
} else {
|
||||||
|
await createAgent(form);
|
||||||
|
}
|
||||||
|
ElMessage.success('\u4fdd\u5b58\u6210\u529f');
|
||||||
|
formVisible.value = false;
|
||||||
|
loadAgents();
|
||||||
|
} catch (err) {
|
||||||
|
ElMessage.error(err?.message || '\u4fdd\u5b58\u5931\u8d25');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleStatus = async (row) => {
|
||||||
|
try {
|
||||||
|
if (row.status === 'active') {
|
||||||
|
await disableAgent(row.id);
|
||||||
|
} else {
|
||||||
|
await enableAgent(row.id);
|
||||||
|
}
|
||||||
|
loadAgents();
|
||||||
|
} catch (err) {
|
||||||
|
ElMessage.error(err?.message || '\u64cd\u4f5c\u5931\u8d25');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeAgent = async (row) => {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('\u786e\u5b9a\u5220\u9664\u4ee3\u7406\u5546\uff1f', '\u786e\u8ba4', { type: 'warning' });
|
||||||
|
await deleteAgent(row.id);
|
||||||
|
ElMessage.success('\u5df2\u5220\u9664');
|
||||||
|
loadAgents();
|
||||||
|
} catch (err) {
|
||||||
|
if (err !== 'cancel') {
|
||||||
|
ElMessage.error(err?.message || '\u5220\u9664\u5931\u8d25');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openDetail = async (row) => {
|
||||||
|
try {
|
||||||
|
selected.value = await getAgent(row.id);
|
||||||
|
detailVisible.value = true;
|
||||||
|
} catch (err) {
|
||||||
|
ElMessage.error(err?.message || '\u52a0\u8f7d\u8be6\u60c5\u5931\u8d25');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openBalance = (type) => {
|
||||||
|
balanceForm.type = type;
|
||||||
|
balanceForm.amount = 100;
|
||||||
|
balanceForm.remark = '';
|
||||||
|
balanceVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitBalance = async () => {
|
||||||
|
try {
|
||||||
|
if (balanceForm.type === 'recharge') {
|
||||||
|
await rechargeAgent(selected.value.agent.id, {
|
||||||
|
amount: balanceForm.amount,
|
||||||
|
remark: balanceForm.remark
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await deductAgent(selected.value.agent.id, {
|
||||||
|
amount: balanceForm.amount,
|
||||||
|
remark: balanceForm.remark
|
||||||
|
});
|
||||||
|
}
|
||||||
|
balanceVisible.value = false;
|
||||||
|
selected.value = await getAgent(selected.value.agent.id);
|
||||||
|
} catch (err) {
|
||||||
|
ElMessage.error(err?.message || '\u64cd\u4f5c\u5931\u8d25');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadProjects();
|
||||||
|
await loadAgents();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.table-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
315
license-system-frontend/src/pages/admin/ApiDocsView.vue
Normal file
315
license-system-frontend/src/pages/admin/ApiDocsView.vue
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page-shell">
|
||||||
|
<div class="toolbar">
|
||||||
|
<div>
|
||||||
|
<h2 class="section-title">{{ '\u63a5\u53e3\u63a5\u5165\u6587\u6863' }}</h2>
|
||||||
|
<div class="tag">{{ '\u5ba2\u6237\u7aef\u4e0e\u6388\u6743\u670d\u52a1\u5bf9\u63a5' }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-actions">
|
||||||
|
<el-button type="primary" @click="openSwagger">{{ '\u6253\u5f00\u63a5\u53e3\u6587\u6863' }}</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="glass-card panel doc-card">
|
||||||
|
<section class="doc-section">
|
||||||
|
<h3 class="section-title">{{ '\u57fa\u7840\u4fe1\u606f' }}</h3>
|
||||||
|
<div class="doc-grid">
|
||||||
|
<div>
|
||||||
|
<div class="label">{{ '\u63a5\u53e3\u5730\u5740' }}</div>
|
||||||
|
<div class="value mono">{{ apiBase }}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="label">{{ '\u5bf9\u63a5\u7801\u683c\u5f0f' }}</div>
|
||||||
|
<div class="value mono">LSC1.base64(projectId|ProjectKey)</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="label">{{ '\u7b7e\u540d\u7b97\u6cd5' }}</div>
|
||||||
|
<div class="value">HMAC-SHA256</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="label">{{ '\u65f6\u95f4\u7a97\u53e3' }}</div>
|
||||||
|
<div class="value">{{ '\u00b1\u0033\u0030\u0030\u79d2' }}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="label">{{ '\u63a5\u53e3\u6587\u6863' }}</div>
|
||||||
|
<div class="value mono">{{ swaggerUrl }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="doc-section">
|
||||||
|
<h3 class="section-title">{{ '\u7b7e\u540d\u89c4\u5219' }}</h3>
|
||||||
|
<div class="doc-block">
|
||||||
|
<div class="label">{{ '\u7b7e\u540d\u5185\u5bb9' }}</div>
|
||||||
|
<div class="value mono">projectId|deviceId|timestamp</div>
|
||||||
|
</div>
|
||||||
|
<div class="doc-block">
|
||||||
|
<div class="label">{{ '\u7b7e\u540d\u5bc6\u94a5' }}</div>
|
||||||
|
<div class="value mono">{{ '\u5bf9\u63a5\u7801\u4e2d\u7684 ProjectKey \u6216 ProjectSecret' }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="doc-block">
|
||||||
|
<div class="label">{{ '\u8ba1\u7b97\u65b9\u5f0f' }}</div>
|
||||||
|
<div class="value mono">hex(hmac_sha256(secret, payload)).lower()</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="doc-section">
|
||||||
|
<h3 class="section-title">{{ '\u5bf9\u63a5\u6d41\u7a0b' }}</h3>
|
||||||
|
<ol class="flow-list">
|
||||||
|
<li>{{ '\u7b2c\u4e00\u6b21\u542f\u52a8\u5ba2\u6237\u7aef\uff0c\u8c03\u7528\u5361\u5bc6\u9a8c\u8bc1\u63a5\u53e3' }}</li>
|
||||||
|
<li>{{ '\u9a8c\u8bc1\u901a\u8fc7\u540e\u83b7\u53d6\u8bbf\u95ee\u4ee4\u724c\uff08accessToken\uff09' }}</li>
|
||||||
|
<li>{{ '\u5ba2\u6237\u7aef\u6309\u5fc3\u8df3\u95f4\u9694\u4e0a\u62a5\u5fc3\u8df3' }}</li>
|
||||||
|
<li>{{ '\u68c0\u6d4b\u66f4\u65b0\u4f7f\u7528\u7248\u672c\u68c0\u67e5\u63a5\u53e3' }}</li>
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="doc-section">
|
||||||
|
<h3 class="section-title">{{ '\u6838\u5fc3\u63a5\u53e3' }}</h3>
|
||||||
|
<div v-for="item in endpoints" :key="item.path" class="endpoint">
|
||||||
|
<div class="endpoint-head">
|
||||||
|
<el-tag size="small" :type="methodTag(item.method)">{{ item.method }}</el-tag>
|
||||||
|
<span class="path mono">{{ item.path }}</span>
|
||||||
|
<span class="endpoint-name">{{ item.name }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="endpoint-body">
|
||||||
|
<div class="label">{{ '\u8bf7\u6c42\u5b57\u6bb5' }}</div>
|
||||||
|
<ul class="field-list">
|
||||||
|
<li v-for="field in item.fields" :key="field">{{ field }}</li>
|
||||||
|
</ul>
|
||||||
|
<div v-if="item.note" class="hint">{{ item.note }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="doc-section">
|
||||||
|
<h3 class="section-title">{{ '\u793a\u4f8b\u4ee3\u7801' }}</h3>
|
||||||
|
<pre class="code-block"><code class="language-python">{{ pythonExample }}</code></pre>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="doc-section">
|
||||||
|
<h3 class="section-title">{{ '\u5e38\u89c1\u9519\u8bef\u7801' }}</h3>
|
||||||
|
<div class="error-grid">
|
||||||
|
<div v-for="item in errorCodes" :key="item.code" class="error-item">
|
||||||
|
<div class="code mono">{{ item.code }}</div>
|
||||||
|
<div class="desc">{{ item.desc }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { resolveApiBase } from '@/utils/apiBase';
|
||||||
|
|
||||||
|
const apiBase = computed(() => resolveApiBase());
|
||||||
|
|
||||||
|
const swaggerUrl = computed(() => `${apiBase.value}/swagger`);
|
||||||
|
|
||||||
|
const endpoints = [
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/auth/verify',
|
||||||
|
name: '\u5361\u5bc6\u9a8c\u8bc1',
|
||||||
|
fields: [
|
||||||
|
'projectId - \u9879\u76ee\u7f16\u53f7',
|
||||||
|
'keyCode - \u5361\u5bc6',
|
||||||
|
'deviceId - \u8bbe\u5907\u7f16\u53f7',
|
||||||
|
'clientVersion - \u5ba2\u6237\u7aef\u7248\u672c',
|
||||||
|
'timestamp - \u5f53\u524d\u65f6\u95f4\u6233',
|
||||||
|
'signature - \u7b7e\u540d'
|
||||||
|
],
|
||||||
|
note: '\u6210\u529f\u8fd4\u56de\u8bbf\u95ee\u4ee4\u724c\uff08accessToken\uff09\u4e0e\u5fc3\u8df3\u95f4\u9694'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/auth/heartbeat',
|
||||||
|
name: '\u5fc3\u8df3\u9a8c\u8bc1',
|
||||||
|
fields: [
|
||||||
|
'accessToken - \u8bbf\u95ee\u4ee4\u724c',
|
||||||
|
'deviceId - \u8bbe\u5907\u7f16\u53f7',
|
||||||
|
'timestamp - \u5f53\u524d\u65f6\u95f4\u6233',
|
||||||
|
'signature - \u7b7e\u540d'
|
||||||
|
],
|
||||||
|
note: '\u5fc3\u8df3\u8fd4\u56de\u5269\u4f59\u5929\u6570\u4e0e\u670d\u52a1\u5668\u65f6\u95f4'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/software/check-update',
|
||||||
|
name: '\u7248\u672c\u68c0\u67e5',
|
||||||
|
fields: [
|
||||||
|
'projectId - \u9879\u76ee\u7f16\u53f7',
|
||||||
|
'currentVersion - \u5f53\u524d\u7248\u672c',
|
||||||
|
'platform - \u5e73\u53f0\u6807\u8bc6'
|
||||||
|
],
|
||||||
|
note: '\u8fd4\u56de\u6700\u65b0\u7248\u672c\u4e0e\u66f4\u65b0\u5730\u5740'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/api/software/download',
|
||||||
|
name: '\u8f6f\u4ef6\u4e0b\u8f7d',
|
||||||
|
fields: [
|
||||||
|
'version - \u8981\u4e0b\u8f7d\u7684\u7248\u672c',
|
||||||
|
'token - \u8bbf\u95ee\u4ee4\u724c'
|
||||||
|
],
|
||||||
|
note: '\u6210\u529f\u540e\u8fd4\u56de\u4e8c\u8fdb\u5236\u6587\u4ef6\u6d41'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/api/config/public',
|
||||||
|
name: '\u516c\u5f00\u914d\u7f6e',
|
||||||
|
fields: [],
|
||||||
|
note: '\u8fd4\u56de\u5ba2\u6237\u7aef\u516c\u5f00\u914d\u7f6e'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const errorCodes = [
|
||||||
|
{ code: '1001', desc: '\u5361\u5bc6\u65e0\u6548' },
|
||||||
|
{ code: '1002', desc: '\u5361\u5bc6\u8fc7\u671f' },
|
||||||
|
{ code: '1003', desc: '\u5361\u5bc6\u5df2\u5c01\u7981' },
|
||||||
|
{ code: '1005', desc: '\u8bbe\u5907\u6570\u9650\u5236' },
|
||||||
|
{ code: '1007', desc: '\u7b7e\u540d\u65e0\u6548' },
|
||||||
|
{ code: '1008', desc: '\u65f6\u95f4\u6233\u8d85\u65f6' },
|
||||||
|
{ code: '1011', desc: '\u9879\u76ee\u5df2\u7981\u7528' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const pythonExample = computed(() => {
|
||||||
|
const base = apiBase.value;
|
||||||
|
return `import time, hmac, hashlib, requests\n\nbase = "${base}"\nproject_id = "PROJ_xxxx"\nsecret = "ProjectKey"\nkey_code = "XXXX-XXXX-XXXX-XXXX"\ndevice_id = "PC-001"\n\nstamp = int(time.time())\npayload = f"{project_id}|{device_id}|{stamp}"\nsignature = hmac.new(secret.encode(), payload.encode(), hashlib.sha256).hexdigest()\n\nresp = requests.post(f"{base}/api/auth/verify", json={\n "projectId": project_id,\n "keyCode": key_code,\n "deviceId": device_id,\n "clientVersion": "1.0.0",\n "timestamp": stamp,\n "signature": signature\n})\nprint(resp.json())\n`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const methodTag = (method) => {
|
||||||
|
if (method === 'POST') return 'success';
|
||||||
|
if (method === 'GET') return 'info';
|
||||||
|
return 'primary';
|
||||||
|
};
|
||||||
|
|
||||||
|
const openSwagger = () => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
window.open(swaggerUrl.value, '_blank');
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.doc-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-section {
|
||||||
|
padding: 4px 0 12px;
|
||||||
|
border-bottom: 1px dashed var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-section:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-block {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.endpoint {
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: rgba(15, 23, 42, 0.02);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.endpoint-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.endpoint-name {
|
||||||
|
color: var(--ink-600);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.endpoint-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-list {
|
||||||
|
padding-left: 18px;
|
||||||
|
color: var(--ink-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ink-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-list {
|
||||||
|
padding-left: 18px;
|
||||||
|
color: var(--ink-700);
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-block {
|
||||||
|
background: #0b1020;
|
||||||
|
color: #e2e8f0;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-item {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(239, 68, 68, 0.08);
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.code {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desc {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ink-600);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mono {
|
||||||
|
font-family: 'SFMono-Regular', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ink-500);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.endpoint-head {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user