feat: 添加许可证系统、Agent部署模块和Harbor镜像管理
- 新增许可证服务器 (license-server/) - 新增 Agent 部署模块 (flnxi-agent/) - 添加许可证验证和激活页面 - 添加 Harbor 镜像同步脚本 - 更新素材系统支持许可证验证
This commit is contained in:
parent
63ec6b7dfb
commit
ba98bf22cc
1246
.kiro/specs/license-system/design.md
Normal file
1246
.kiro/specs/license-system/design.md
Normal file
File diff suppressed because it is too large
Load Diff
236
.kiro/specs/license-system/requirements.md
Normal file
236
.kiro/specs/license-system/requirements.md
Normal file
@ -0,0 +1,236 @@
|
|||||||
|
# Requirements Document
|
||||||
|
|
||||||
|
## Introduction
|
||||||
|
|
||||||
|
本文档定义了 FLNXI 素材系统的许可证管理系统需求。该系统旨在实现软件的商业化分发,支持免费版、标准版和Pro版三个版本,通过联网验证和硬件指纹绑定实现防破解保护。系统包含两个主要部分:部署在客户端的许可证验证模块,以及部署在中央服务器的许可证验证服务和客户管理后台。
|
||||||
|
|
||||||
|
## Glossary
|
||||||
|
|
||||||
|
- **License(许可证)**: 授权客户使用软件特定版本和功能的凭证
|
||||||
|
- **License Key(许可证密钥)**: 用于激活许可证的唯一字符串,格式为 XXXX-XXXX-XXXX-XXXX
|
||||||
|
- **Hardware Fingerprint(硬件指纹)**: 基于客户服务器硬件特征生成的唯一标识符
|
||||||
|
- **License Server(许可证服务器)**: 部署在 45.249.208.118 的中央验证服务
|
||||||
|
- **Client Instance(客户端实例)**: 部署在客户 NAS/服务器上的 FLNXI 素材系统实例
|
||||||
|
- **Heartbeat(心跳)**: 客户端定期向服务器发送的存活和验证请求
|
||||||
|
- **Grace Period(宽限期)**: 网络断开后允许继续使用的时间窗口
|
||||||
|
- **Admin Portal(管理后台)**: 用于管理客户和许可证的 Web 界面
|
||||||
|
- **Code Obfuscation(代码混淆)**: 将源代码转换为难以阅读和理解的形式,增加逆向工程难度
|
||||||
|
- **Agent(安装代理)**: 运行在客户 NAS 上的轻量级 Docker 容器,提供 Web 安装界面,帮助客户自助完成系统部署
|
||||||
|
- **Semantic Versioning(语义化版本)**: 版本号格式为 v主版本.次版本.修订号(如 v1.2.3)
|
||||||
|
- **Rollback(回滚)**: 将系统恢复到之前版本的操作
|
||||||
|
- **Forced Update(强制更新)**: 必须完成才能继续使用系统的更新
|
||||||
|
- **Optional Update(可选更新)**: 用户可自行决定是否执行的更新
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement 1: 许可证版本定义
|
||||||
|
|
||||||
|
**User Story:** As a 系统管理员, I want to 定义不同版本的功能限制, so that 可以实现差异化的产品定价策略。
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. THE License_Server SHALL support three license types: FREE, STANDARD, and PRO
|
||||||
|
2. WHEN a license type is FREE, THE System SHALL enforce a maximum of 10 users
|
||||||
|
3. WHEN a license type is FREE, THE System SHALL enforce a maximum of 100 materials
|
||||||
|
4. WHEN a license type is FREE, THE System SHALL disable custom template functionality
|
||||||
|
5. WHEN a license type is FREE, THE System SHALL disable advanced system settings
|
||||||
|
6. WHEN a license type is FREE, THE System SHALL disable department isolation functionality
|
||||||
|
7. WHEN a license type is STANDARD or PRO, THE System SHALL allow unlimited users and materials
|
||||||
|
8. WHEN a license type is PRO, THE System SHALL enable intranet penetration functionality
|
||||||
|
|
||||||
|
### Requirement 2: 许可证激活
|
||||||
|
|
||||||
|
**User Story:** As a 客户, I want to 使用许可证密钥激活系统, so that 可以解锁对应版本的功能。
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. WHEN a user first accesses the system without activation, THE System SHALL display an activation page
|
||||||
|
2. WHEN a user enters a valid license key, THE System SHALL send an activation request to the License_Server
|
||||||
|
3. WHEN the License_Server receives an activation request, THE System SHALL validate the license key format and existence
|
||||||
|
4. WHEN the license key is valid and not already activated, THE License_Server SHALL bind the license to the hardware fingerprint
|
||||||
|
5. WHEN activation succeeds, THE Client_Instance SHALL store the license information locally
|
||||||
|
6. WHEN a user chooses "free trial" without a license key, THE System SHALL activate as FREE version automatically
|
||||||
|
7. IF the license key is invalid or already activated on another device, THEN THE System SHALL display an appropriate error message
|
||||||
|
8. WHEN activation completes, THE System SHALL redirect to the main application
|
||||||
|
|
||||||
|
### Requirement 3: 硬件指纹生成
|
||||||
|
|
||||||
|
**User Story:** As a 系统, I want to 生成稳定的硬件指纹, so that 许可证可以绑定到特定设备而不受常见硬件升级影响。
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. THE System SHALL generate a hardware fingerprint using machine ID, hostname, and primary MAC address
|
||||||
|
2. THE System SHALL NOT include RAM information in the hardware fingerprint
|
||||||
|
3. THE System SHALL NOT include non-system disk information in the hardware fingerprint
|
||||||
|
4. THE System SHALL generate a unique instance ID on first activation and store it persistently
|
||||||
|
5. WHEN hardware components change, THE System SHALL use a weighted matching algorithm with a 60% threshold
|
||||||
|
6. WHEN the fingerprint match score falls below 60%, THE System SHALL require re-activation
|
||||||
|
|
||||||
|
### Requirement 4: 许可证验证
|
||||||
|
|
||||||
|
**User Story:** As a 系统, I want to 定期验证许可证状态, so that 可以确保只有合法用户使用付费功能。
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. WHEN the system starts, THE Client_Instance SHALL verify the license with the License_Server
|
||||||
|
2. WHILE the system is running, THE Client_Instance SHALL send heartbeat requests every hour
|
||||||
|
3. WHEN the License_Server receives a verification request, THE System SHALL check license validity, expiration, and hardware fingerprint
|
||||||
|
4. WHEN verification succeeds, THE License_Server SHALL return the current license configuration
|
||||||
|
5. WHEN verification fails due to network issues, THE Client_Instance SHALL use cached license data
|
||||||
|
6. WHEN the system operates offline, THE Client_Instance SHALL allow a grace period of 7 days
|
||||||
|
7. IF the grace period expires without successful verification, THEN THE System SHALL enter read-only mode
|
||||||
|
|
||||||
|
### Requirement 5: 功能限制执行
|
||||||
|
|
||||||
|
**User Story:** As a 系统, I want to 根据许可证类型限制功能, so that 免费用户无法使用付费功能。
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. WHEN a FREE user attempts to create the 11th user, THE System SHALL block the operation and display an upgrade prompt
|
||||||
|
2. WHEN a FREE user attempts to upload the 101st material, THE System SHALL block the operation and display an upgrade prompt
|
||||||
|
3. WHEN a FREE user accesses custom template settings, THE System SHALL hide or disable the functionality
|
||||||
|
4. WHEN a FREE user accesses department isolation settings, THE System SHALL hide or disable the functionality
|
||||||
|
5. WHEN checking feature availability, THE System SHALL query the cached license configuration
|
||||||
|
6. THE System SHALL display current usage statistics (users/materials count) in the settings page
|
||||||
|
|
||||||
|
### Requirement 6: 客户管理后台
|
||||||
|
|
||||||
|
**User Story:** As a 平台管理员, I want to 管理所有客户的许可证, so that 可以进行销售和客户服务。
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. THE Admin_Portal SHALL require authentication to access
|
||||||
|
2. WHEN an admin views the dashboard, THE System SHALL display total customers count by license type
|
||||||
|
3. WHEN an admin views the customer list, THE System SHALL display company name, license type, status, and expiration date
|
||||||
|
4. WHEN an admin creates a new license, THE System SHALL generate a unique license key
|
||||||
|
5. WHEN an admin upgrades a customer's license, THE System SHALL update the license type immediately
|
||||||
|
6. WHEN an admin disables a license, THE System SHALL mark it as inactive and reject future verifications
|
||||||
|
7. WHEN an admin resets hardware fingerprint, THE System SHALL allow the license to be activated on a new device
|
||||||
|
8. THE Admin_Portal SHALL display usage statistics for each customer (user count, material count, last heartbeat)
|
||||||
|
|
||||||
|
### Requirement 7: 异常检测
|
||||||
|
|
||||||
|
**User Story:** As a 平台管理员, I want to 检测许可证滥用行为, so that 可以防止盗版和许可证共享。
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. WHEN the same license key sends heartbeats from multiple different hardware fingerprints within 24 hours, THE System SHALL flag it as suspicious
|
||||||
|
2. WHEN suspicious activity is detected, THE Admin_Portal SHALL display an alert
|
||||||
|
3. WHEN an admin reviews suspicious activity, THE System SHALL show the list of hardware fingerprints and their locations
|
||||||
|
4. THE Admin_Portal SHALL allow admins to whitelist legitimate multi-device scenarios
|
||||||
|
|
||||||
|
### Requirement 8: 许可证信息展示
|
||||||
|
|
||||||
|
**User Story:** As a 客户, I want to 查看当前许可证状态, so that 可以了解使用情况和到期时间。
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. THE System SHALL display license information in the system settings page
|
||||||
|
2. THE System SHALL show license type, activation status, expiration date, and feature availability
|
||||||
|
3. THE System SHALL show current usage (user count, material count) compared to limits
|
||||||
|
4. WHEN the license is about to expire within 30 days, THE System SHALL display a renewal reminder
|
||||||
|
5. THE System SHALL provide a link to upgrade or renew the license
|
||||||
|
|
||||||
|
### Requirement 9: 安全通信
|
||||||
|
|
||||||
|
**User Story:** As a 系统, I want to 确保许可证通信安全, so that 验证过程无法被篡改或伪造。
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. THE System SHALL use HTTP with application-layer encryption for communication between Client_Instance and License_Server
|
||||||
|
2. THE System SHALL encrypt request payloads using AES-256-GCM before transmission
|
||||||
|
3. THE System SHALL sign all license data using RSA asymmetric encryption
|
||||||
|
4. THE Client_Instance SHALL verify the signature of license data using the embedded public key
|
||||||
|
5. THE System SHALL include timestamps in requests to prevent replay attacks
|
||||||
|
6. IF signature verification fails, THEN THE System SHALL reject the license data and log the incident
|
||||||
|
7. THE System SHALL support upgrading to HTTPS when SSL certificates are configured
|
||||||
|
|
||||||
|
### Requirement 10: 数据持久化
|
||||||
|
|
||||||
|
**User Story:** As a 系统, I want to 安全存储许可证数据, so that 系统重启后无需重新激活。
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. THE Client_Instance SHALL store license data in the database with encryption
|
||||||
|
2. THE Client_Instance SHALL store the instance ID persistently across restarts
|
||||||
|
3. THE License_Server SHALL store all customer and license data in a PostgreSQL database
|
||||||
|
4. THE System SHALL backup license data regularly to prevent data loss
|
||||||
|
5. WHEN license data is corrupted, THE System SHALL attempt to recover from the License_Server
|
||||||
|
|
||||||
|
### Requirement 11: 代码混淆保护
|
||||||
|
|
||||||
|
**User Story:** As a 系统, I want to 对关键代码进行混淆处理, so that 增加逆向工程和破解的难度。
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. THE Build_Process SHALL obfuscate license verification related source files during production build
|
||||||
|
2. THE Build_Process SHALL obfuscate hardware fingerprint generation source files during production build
|
||||||
|
3. THE Build_Process SHALL obfuscate cryptographic signature verification source files during production build
|
||||||
|
4. THE Build_Process SHALL NOT obfuscate business logic components to maintain debuggability
|
||||||
|
5. WHEN obfuscation is applied, THE System SHALL maintain identical functionality to non-obfuscated code
|
||||||
|
6. THE Build_Process SHALL use control flow flattening and string encoding techniques
|
||||||
|
7. THE Build_Process SHALL generate source maps separately for internal debugging purposes only
|
||||||
|
8. WHEN the production build completes, THE System SHALL verify that obfuscated code executes correctly
|
||||||
|
|
||||||
|
### Requirement 12: 自助安装(Agent 方案)
|
||||||
|
|
||||||
|
**User Story:** As a 客户, I want to 通过简单的方式自助部署和更新系统, so that 无需手动配置 Docker 命令,也不需要联系技术支持。
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. THE System SHALL provide a lightweight Agent Docker image (flnxi/flnxi-agent) on Docker Hub for customers to download
|
||||||
|
2. THE Agent image SHALL be compatible with mainstream NAS systems including Synology, QNAP, and fnOS
|
||||||
|
3. WHEN the Agent container starts, THE Agent SHALL provide a Web-based management interface on port 9000
|
||||||
|
4. THE Agent Web interface SHALL allow customer to configure system port (default 4320)
|
||||||
|
5. THE Agent SHALL NOT perform license validation (license activation is handled by the material system on first startup)
|
||||||
|
6. WHEN customer clicks install, THE Agent SHALL pull the FLNXI material system image from harbor.flnxi.com/flnxi/material/flnxi-material
|
||||||
|
7. THE Agent SHALL create and start the FLNXI material system container with configured settings
|
||||||
|
8. THE Agent Web interface SHALL display installation progress with clear status messages
|
||||||
|
9. WHEN installation completes, THE Agent SHALL display the access URL for the system
|
||||||
|
10. IF installation fails, THEN THE Agent SHALL display error details and provide retry option
|
||||||
|
11. THE Agent SHALL provide system update functionality to pull and deploy latest version
|
||||||
|
12. THE Agent Web interface SHALL display current system version and available updates
|
||||||
|
13. THE Agent SHALL NOT collect or transmit any customer usage data
|
||||||
|
14. THE Agent container SHOULD be kept running for future system updates
|
||||||
|
|
||||||
|
### Requirement 13: 版本管理
|
||||||
|
|
||||||
|
**User Story:** As a 平台管理员, I want to 管理系统版本发布, so that 可以有序地向客户推送更新。
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. THE System SHALL use semantic versioning format (vMAJOR.MINOR.PATCH) for all releases
|
||||||
|
2. THE License_Server SHALL maintain version information including latest version and minimum required version
|
||||||
|
3. WHEN a new version is released, THE Admin_Portal SHALL allow setting the minimum required version
|
||||||
|
4. THE License_Server SHALL provide a version check API that returns latest version, minimum required version, and release notes
|
||||||
|
5. THE System SHALL store version number in /app/VERSION.txt within the Docker image
|
||||||
|
6. THE Harbor registry SHALL retain at least the 3 most recent versions for rollback purposes
|
||||||
|
|
||||||
|
### Requirement 14: 系统更新
|
||||||
|
|
||||||
|
**User Story:** As a 客户, I want to 方便地更新系统到最新版本, so that 可以获得新功能和安全修复。
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. WHEN the Agent checks for updates, THE Agent SHALL compare current version with latest version from License_Server
|
||||||
|
2. WHEN an optional update is available, THE Agent SHALL display update notification with version number and release notes
|
||||||
|
3. WHEN a forced update is required, THE System SHALL block access to main application until update is completed
|
||||||
|
4. WHEN customer initiates update, THE Agent SHALL pull new image using Docker incremental layer download
|
||||||
|
5. THE Agent SHALL display download progress and estimated time during update
|
||||||
|
6. WHEN update completes, THE Agent SHALL restart the system container automatically
|
||||||
|
7. IF update fails, THEN THE Agent SHALL automatically rollback to previous version
|
||||||
|
8. THE Agent Web interface SHALL provide manual rollback option to any of the 3 most recent versions
|
||||||
|
|
||||||
|
### Requirement 15: 更新回滚机制
|
||||||
|
|
||||||
|
**User Story:** As a 客户, I want to 在更新失败时自动恢复, so that 系统不会因为更新问题而无法使用。
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. WHEN starting an update, THE Agent SHALL backup current container configuration
|
||||||
|
2. WHEN starting an update, THE Agent SHALL rename current container instead of deleting it
|
||||||
|
3. WHEN new container starts, THE Agent SHALL perform health check by calling /api/health endpoint
|
||||||
|
4. WHEN health check fails within 60 seconds, THE Agent SHALL trigger automatic rollback
|
||||||
|
5. WHEN rollback is triggered, THE Agent SHALL stop new container, restore backup container, and restart it
|
||||||
|
6. WHEN rollback completes, THE Agent SHALL display message indicating rollback reason and restored version
|
||||||
|
7. THE Agent Web interface SHALL allow manual rollback with version selection
|
||||||
385
.kiro/specs/license-system/tasks.md
Normal file
385
.kiro/specs/license-system/tasks.md
Normal file
@ -0,0 +1,385 @@
|
|||||||
|
# Implementation Plan
|
||||||
|
|
||||||
|
## Phase 1: 核心基础设施
|
||||||
|
|
||||||
|
- [x] 1. 设置许可证服务器项目结构
|
||||||
|
- [x] 1.1 创建独立的 Next.js 项目 `license-server`
|
||||||
|
- 初始化项目,配置 TypeScript、Prisma、PostgreSQL
|
||||||
|
- 创建基础目录结构:`app/api/`, `lib/`, `prisma/`
|
||||||
|
- _Requirements: 6.1, 10.3_
|
||||||
|
- [x] 1.2 配置数据库 Schema
|
||||||
|
- 创建 customers、licenses、activations、heartbeat_logs、alerts 表
|
||||||
|
- 配置 Prisma 模型和关系
|
||||||
|
- _Requirements: 10.3_
|
||||||
|
- [x] 1.3 生成 RSA 密钥对
|
||||||
|
- 创建密钥生成脚本
|
||||||
|
- 配置环境变量存储私钥
|
||||||
|
- 导出公钥供客户端使用
|
||||||
|
- _Requirements: 9.3, 9.4_
|
||||||
|
|
||||||
|
- [x] 2. 实现加密服务模块
|
||||||
|
- [x] 2.1 实现服务器端加密服务 `license-server/lib/crypto.ts`
|
||||||
|
- RSA 签名生成函数
|
||||||
|
- AES-256-GCM 加密/解密函数
|
||||||
|
- 请求签名验证函数
|
||||||
|
- _Requirements: 9.2, 9.3, 9.5_
|
||||||
|
- [x] 2.3 实现客户端加密服务 `lib/license/crypto-service.ts`
|
||||||
|
- RSA 签名验证函数(使用嵌入的公钥)
|
||||||
|
- AES-256-GCM 加密/解密函数
|
||||||
|
- 本地数据加密存储函数
|
||||||
|
- _Requirements: 9.4, 10.1_
|
||||||
|
|
||||||
|
- [x] 3. Checkpoint - 确保所有测试通过
|
||||||
|
- 测试通过:crypto-service.test.ts, license-types.test.ts, activate/route.test.ts
|
||||||
|
|
||||||
|
## Phase 2: 许可证类型和配置
|
||||||
|
|
||||||
|
- [x] 4. 实现许可证类型定义
|
||||||
|
- [x] 4.1 创建许可证类型和配置接口 `lib/license/types.ts`
|
||||||
|
- 定义 LicenseType 枚举(FREE, STANDARD, PRO)
|
||||||
|
- 定义 LicenseConfig、SignedLicense、HardwareFingerprint 接口
|
||||||
|
- 定义各版本的功能限制配置
|
||||||
|
- _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 1.8_
|
||||||
|
- [x] 4.2 实现许可证配置生成函数
|
||||||
|
- 根据许可证类型生成对应的功能配置
|
||||||
|
- 实现限制值计算逻辑
|
||||||
|
- _Requirements: 1.1-1.8_
|
||||||
|
|
||||||
|
## Phase 3: 硬件指纹服务
|
||||||
|
|
||||||
|
- [x] 5. 实现硬件指纹服务
|
||||||
|
- [x] 5.1 实现硬件指纹生成 `lib/license/fingerprint-service.ts`
|
||||||
|
- 获取 machine-id
|
||||||
|
- 获取 hostname
|
||||||
|
- 获取主网卡 MAC 地址
|
||||||
|
- 生成/获取持久化的 instance ID
|
||||||
|
- _Requirements: 3.1, 3.2, 3.3, 3.4_
|
||||||
|
- [x] 5.3 实现指纹匹配算法
|
||||||
|
- 实现加权匹配分数计算
|
||||||
|
- 实现 60% 阈值判断逻辑
|
||||||
|
- _Requirements: 3.5, 3.6_
|
||||||
|
|
||||||
|
- [x] 6. Checkpoint - 确保所有测试通过
|
||||||
|
- 测试通过 ✓
|
||||||
|
|
||||||
|
## Phase 4: 许可证服务器 API
|
||||||
|
|
||||||
|
- [x] 7. 实现许可证激活 API
|
||||||
|
- [x] 7.1 实现激活接口 `license-server/app/api/license/activate/route.ts`
|
||||||
|
- 验证许可证密钥格式和存在性
|
||||||
|
- 检查是否已激活
|
||||||
|
- 绑定硬件指纹
|
||||||
|
- 生成签名后的许可证数据
|
||||||
|
- _Requirements: 2.2, 2.3, 2.4_
|
||||||
|
- [x] 7.2 实现许可证密钥生成函数
|
||||||
|
- 生成 XXXX-XXXX-XXXX-XXXX 格式的唯一密钥
|
||||||
|
- _Requirements: 6.4_
|
||||||
|
|
||||||
|
- [x] 8. 实现许可证验证 API
|
||||||
|
- [x] 8.1 实现验证接口 `license-server/app/api/license/verify/route.ts`
|
||||||
|
- 验证 instance ID 和硬件指纹
|
||||||
|
- 检查许可证有效性和过期时间
|
||||||
|
- 返回签名后的许可证配置
|
||||||
|
- _Requirements: 4.3, 4.4_
|
||||||
|
|
||||||
|
- [x] 9. 实现心跳 API
|
||||||
|
- [x] 9.1 实现心跳接口 `license-server/app/api/license/heartbeat/route.ts`
|
||||||
|
- 记录心跳日志
|
||||||
|
- 更新使用统计
|
||||||
|
- 检测异常(多设备使用)
|
||||||
|
- _Requirements: 4.2, 7.1_
|
||||||
|
|
||||||
|
- [x] 10. 实现版本检查 API
|
||||||
|
- [x] 10.1 实现版本检查接口 `license-server/app/api/version/check/route.ts`
|
||||||
|
- 返回最新版本和最低要求版本
|
||||||
|
- _Requirements: 13.4, 14.1_
|
||||||
|
|
||||||
|
- [x] 11. Checkpoint - 确保所有测试通过
|
||||||
|
- API 测试通过(activate, verify, heartbeat, version/check)
|
||||||
|
|
||||||
|
## Phase 5: 客户端许可证服务
|
||||||
|
|
||||||
|
- [x] 12. 实现客户端许可证服务
|
||||||
|
- [x] 12.1 实现许可证服务 `lib/license/license-service.ts`
|
||||||
|
- activate() - 激活许可证
|
||||||
|
- verify() - 验证许可证
|
||||||
|
- heartbeat() - 发送心跳
|
||||||
|
- getLicense() - 获取当前配置
|
||||||
|
- isFeatureEnabled() - 检查功能可用性
|
||||||
|
- checkLimit() - 检查限制
|
||||||
|
- _Requirements: 2.2, 2.5, 4.1, 4.2, 4.5, 4.6, 5.5_
|
||||||
|
- [x] 12.2 实现本地许可证存储
|
||||||
|
- 添加 Prisma 模型存储许可证数据
|
||||||
|
- 实现加密存储和读取
|
||||||
|
- _Requirements: 10.1, 10.2_
|
||||||
|
- [x] 12.3 实现宽限期逻辑
|
||||||
|
- 离线时使用缓存数据
|
||||||
|
- 7 天宽限期计算
|
||||||
|
- 宽限期过期进入只读模式
|
||||||
|
- _Requirements: 4.5, 4.6, 4.7_
|
||||||
|
|
||||||
|
- [x] 13. 实现功能限制执行
|
||||||
|
- [x] 13.1 实现用户数量限制检查
|
||||||
|
- 在用户创建前检查限制 `app/api/auth/register/route.ts`
|
||||||
|
- 显示升级提示
|
||||||
|
- _Requirements: 5.1_
|
||||||
|
- [x] 13.2 实现素材数量限制检查
|
||||||
|
- 在素材上传前检查限制 `app/api/materials/upload/route.ts`
|
||||||
|
- 显示升级提示
|
||||||
|
- _Requirements: 5.2_
|
||||||
|
- [x] 13.3 实现功能开关检查
|
||||||
|
- 自定义模板功能检查 `app/api/admin/upload-templates/route.ts`
|
||||||
|
- 部门隔离功能检查 `app/api/departments/route.ts`
|
||||||
|
- 系统设置功能检查 `app/api/system-settings/route.ts`
|
||||||
|
- _Requirements: 5.3, 5.4_
|
||||||
|
|
||||||
|
- [x] 14. Checkpoint - 确保所有测试通过
|
||||||
|
- 测试通过 ✓
|
||||||
|
|
||||||
|
## Phase 6: 客户端 UI 集成
|
||||||
|
|
||||||
|
- [x] 15. 实现激活页面
|
||||||
|
- [x] 15.1 创建激活页面 `app/activate/page.tsx`
|
||||||
|
- 许可证密钥输入表单
|
||||||
|
- 免费试用选项
|
||||||
|
- 激活状态显示
|
||||||
|
- 错误提示
|
||||||
|
- _Requirements: 2.1, 2.6, 2.7, 2.8_
|
||||||
|
- [x] 15.2 实现许可证中间件 `middleware.ts`
|
||||||
|
- 检查激活状态(通过 cookie)
|
||||||
|
- 未激活时重定向到激活页面
|
||||||
|
- 激活成功后设置 cookie
|
||||||
|
- _Requirements: 2.1_
|
||||||
|
|
||||||
|
- [x] 16. 实现许可证信息展示
|
||||||
|
- [x] 16.1 创建许可证信息组件 `components/LicenseInfo.tsx`
|
||||||
|
- 显示许可证类型和状态
|
||||||
|
- 显示到期时间
|
||||||
|
- 显示使用统计
|
||||||
|
- 显示续期/升级链接
|
||||||
|
- _Requirements: 8.1, 8.2, 8.3, 8.4, 8.5_
|
||||||
|
- [x] 16.2 集成到系统设置页面
|
||||||
|
- 在设置页面添加许可证信息区域
|
||||||
|
- 显示功能可用性状态
|
||||||
|
- 支持暗色模式
|
||||||
|
- _Requirements: 5.6, 8.1_
|
||||||
|
|
||||||
|
## Phase 7: 客户管理后台
|
||||||
|
|
||||||
|
- [x] 17. 实现管理后台认证
|
||||||
|
- [x] 17.1 创建管理员登录页面 `license-server/app/admin/login/page.tsx`
|
||||||
|
- 登录表单
|
||||||
|
- JWT Session 管理
|
||||||
|
- _Requirements: 6.1_
|
||||||
|
- [x] 17.2 实现认证 API
|
||||||
|
- POST /api/admin/auth/login - 登录
|
||||||
|
- POST /api/admin/auth/logout - 登出
|
||||||
|
- lib/auth.ts - 认证服务
|
||||||
|
- _Requirements: 6.1_
|
||||||
|
- [x] 17.3 创建初始化管理员脚本
|
||||||
|
- scripts/init-admin.ts
|
||||||
|
- _Requirements: 6.1_
|
||||||
|
|
||||||
|
- [x] 18. 实现客户管理功能
|
||||||
|
- [x] 18.1 创建客户列表页面 `license-server/app/admin/customers/page.tsx`
|
||||||
|
- 客户列表展示
|
||||||
|
- 搜索和筛选
|
||||||
|
- 添加客户弹窗
|
||||||
|
- _Requirements: 6.3_
|
||||||
|
- [x] 18.2 实现客户 CRUD API
|
||||||
|
- GET/POST /api/admin/customers
|
||||||
|
- _Requirements: 6.3_
|
||||||
|
|
||||||
|
- [x] 19. 实现许可证管理功能
|
||||||
|
- [x] 19.1 实现许可证管理 API
|
||||||
|
- GET/POST /api/admin/licenses - 列表和生成
|
||||||
|
- POST /api/admin/licenses/:id/disable - 禁用许可证
|
||||||
|
- POST /api/admin/licenses/:id/reset - 重置硬件绑定
|
||||||
|
- _Requirements: 6.4, 6.5, 6.6, 6.7_
|
||||||
|
- [x] 19.2 创建许可证管理界面 `license-server/app/admin/licenses/page.tsx`
|
||||||
|
- 许可证列表
|
||||||
|
- 生成新许可证表单
|
||||||
|
- 许可证操作按钮
|
||||||
|
- _Requirements: 6.4, 6.5, 6.6, 6.7_
|
||||||
|
|
||||||
|
- [x] 20. 实现仪表盘和监控
|
||||||
|
- [x] 20.1 创建仪表盘页面 `license-server/app/admin/page.tsx`
|
||||||
|
- 客户统计(按许可证类型)
|
||||||
|
- 最近激活列表
|
||||||
|
- 系统状态
|
||||||
|
- _Requirements: 6.2, 6.8_
|
||||||
|
- [x] 20.2 实现异常告警功能 `license-server/app/admin/alerts/page.tsx`
|
||||||
|
- 告警列表页面
|
||||||
|
- 告警详情
|
||||||
|
- 处理/忽略操作
|
||||||
|
- _Requirements: 7.2, 7.3, 7.4_
|
||||||
|
|
||||||
|
- [x] 21. Checkpoint - 确保所有测试通过
|
||||||
|
- 管理后台功能测试通过 ✓
|
||||||
|
|
||||||
|
## Phase 8: 代码混淆
|
||||||
|
|
||||||
|
- [x] 22. 配置代码混淆
|
||||||
|
- [x] 22.1 创建混淆配置 `scripts/obfuscate-config.js`
|
||||||
|
- 配置混淆选项(控制流扁平化、字符串编码)
|
||||||
|
- 指定需要混淆的文件列表
|
||||||
|
- 配置保留名称和字符串
|
||||||
|
- _Requirements: 11.1, 11.2, 11.3, 11.6_
|
||||||
|
- [x] 22.2 创建混淆构建脚本 `scripts/build-obfuscated.js`
|
||||||
|
- 构建后自动混淆敏感代码
|
||||||
|
- 添加 `npm run build:prod` 命令
|
||||||
|
- _Requirements: 11.4, 11.7_
|
||||||
|
- [x] 22.3 安装依赖并验证
|
||||||
|
- 安装 javascript-obfuscator ✓
|
||||||
|
- 运行 `npm run build:prod` 混淆 74 个文件 ✓
|
||||||
|
- 测试验证功能正确性 ✓
|
||||||
|
- _Requirements: 11.5, 11.8_
|
||||||
|
|
||||||
|
## Phase 9: 自助安装 Agent
|
||||||
|
|
||||||
|
- [x] 23. 设置 Agent 项目
|
||||||
|
- [x] 23.1 初始化 Agent 项目 `flnxi-agent/`
|
||||||
|
- 创建 Node.js + Express + TypeScript 项目
|
||||||
|
- 配置 dockerode、socket.io 依赖
|
||||||
|
- 创建 Dockerfile(多架构支持)
|
||||||
|
- _Requirements: 12.1, 12.2_
|
||||||
|
- [x] 23.2 创建前端安装向导和管理面板
|
||||||
|
- Welcome.tsx - 欢迎页面
|
||||||
|
- Config.tsx - 端口配置
|
||||||
|
- Installing.tsx - 安装进度
|
||||||
|
- Complete.tsx - 完成页面
|
||||||
|
- Dashboard.tsx - 管理面板(系统状态、更新、重启)
|
||||||
|
- _Requirements: 12.3, 12.4, 12.8, 12.11, 12.12_
|
||||||
|
|
||||||
|
- [x] 24. 实现 Agent 后端功能
|
||||||
|
- [x] 24.1 实现 Web 服务 `flnxi-agent/src/server.ts`
|
||||||
|
- Express 静态文件服务
|
||||||
|
- Socket.io 实时进度推送
|
||||||
|
- _Requirements: 12.3, 12.8_
|
||||||
|
- [x] 24.2 实现 Docker 操作 `flnxi-agent/src/installer.ts`
|
||||||
|
- 拉取 FLNXI 素材系统镜像(带进度)
|
||||||
|
- 拉取依赖镜像(postgres、minio 等)
|
||||||
|
- 创建并启动容器
|
||||||
|
- 许可证验证由素材系统首次启动时处理
|
||||||
|
- _Requirements: 12.5, 12.6, 12.7_
|
||||||
|
|
||||||
|
- [x] 25. 实现安装和更新流程
|
||||||
|
- [x] 25.1 实现安装 API `flnxi-agent/src/server.ts`
|
||||||
|
- POST /api/install - 开始安装
|
||||||
|
- WebSocket 推送安装进度
|
||||||
|
- 支持从 Harbor 拉取镜像
|
||||||
|
- _Requirements: 12.4, 12.8_
|
||||||
|
- [x] 25.2 实现系统更新 API `flnxi-agent/src/system-manager.ts`
|
||||||
|
- GET /api/status - 获取系统状态和版本
|
||||||
|
- GET /api/check-update - 检查最新版本
|
||||||
|
- POST /api/update - 更新系统(带回滚)
|
||||||
|
- _Requirements: 12.12, 12.13, 14.1, 14.2, 14.4_
|
||||||
|
- [x] 25.3 实现强制更新检测
|
||||||
|
- 比较当前版本与 minRequiredVersion
|
||||||
|
- Dashboard 显示更新提示
|
||||||
|
- _Requirements: 14.3_
|
||||||
|
- [x] 25.4 实现更新回滚机制
|
||||||
|
- 更新前备份容器配置
|
||||||
|
- 重命名旧容器而非删除
|
||||||
|
- 健康检查失败时自动回滚
|
||||||
|
- _Requirements: 15.1, 15.2, 15.3, 15.4_
|
||||||
|
- [x] 25.5 实现错误处理
|
||||||
|
- 安装/更新失败时显示错误详情
|
||||||
|
- 提供重试选项
|
||||||
|
- _Requirements: 12.11_
|
||||||
|
|
||||||
|
- [x] 26. 构建和发布
|
||||||
|
- [x] 26.1 Agent 自更新功能
|
||||||
|
- `flnxi-agent/src/system-manager.ts` - 添加 checkAgentUpdate, updateAgent 方法
|
||||||
|
- `flnxi-agent/src/server.ts` - 添加 Agent 更新 API
|
||||||
|
- `flnxi-agent/frontend/src/pages/Dashboard.tsx` - 添加 Agent 更新 UI
|
||||||
|
- `license-server/app/api/version/agent/route.ts` - Agent 版本检查 API
|
||||||
|
- `license-server/prisma/schema.prisma` - 添加 AgentVersion 模型
|
||||||
|
- _Requirements: 12.12, 12.13_
|
||||||
|
- [x] 26.2 发布管理工具
|
||||||
|
- `scripts/发布管理.ps1` - 统一发布管理脚本
|
||||||
|
- 支持测试版部署、正式版发布、Agent 发布
|
||||||
|
- 正式版操作需要二次确认
|
||||||
|
- _Requirements: 12.1, 12.2_
|
||||||
|
- [x] 26.3 测试安装流程
|
||||||
|
- 本地测试 Agent 构建和启动 ✓
|
||||||
|
- API 测试:/api/status 正常 ✓
|
||||||
|
- 前端页面加载正常 ✓
|
||||||
|
- 待实际 NAS 设备测试(飞牛 fnOS、群晖)
|
||||||
|
- _Requirements: 12.1-12.13_
|
||||||
|
|
||||||
|
## Phase 10: 部署和集成
|
||||||
|
|
||||||
|
- [x] 27. 实现版本管理 API
|
||||||
|
- [x] 27.1 版本信息数据模型(已在 Phase 1 完成)
|
||||||
|
- versions 表已在 prisma/schema.prisma 中定义
|
||||||
|
- _Requirements: 13.1, 13.2_
|
||||||
|
- [x] 27.2 版本检查 API(已在 Phase 4 完成)
|
||||||
|
- GET /api/version/check
|
||||||
|
- _Requirements: 13.4, 14.1_
|
||||||
|
- [x] 27.3 实现版本管理后台 `license-server/app/admin/versions/page.tsx`
|
||||||
|
- 版本列表页面
|
||||||
|
- 发布新版本表单
|
||||||
|
- _Requirements: 13.3_
|
||||||
|
|
||||||
|
- [x] 28. 部署许可证服务器配置
|
||||||
|
- [x] 28.1 创建 Docker Compose 配置 `license-server/docker-compose.yml`
|
||||||
|
- license-server 服务
|
||||||
|
- PostgreSQL 数据库
|
||||||
|
- _Requirements: 10.3_
|
||||||
|
- [x] 28.2 创建 Dockerfile `license-server/Dockerfile`
|
||||||
|
- 多阶段构建
|
||||||
|
- _Requirements: 10.3_
|
||||||
|
- [x] 28.3 部署到 45.249.208.118
|
||||||
|
- 配置环境变量(RSA 私钥、数据库连接)
|
||||||
|
- 启动服务(端口 9527,域名 lic.flnxi.com)
|
||||||
|
- 验证 API 可访问 ✓
|
||||||
|
- 创建管理员账号(admin/admin123)
|
||||||
|
- _Requirements: 10.3_
|
||||||
|
|
||||||
|
- [x] 29. 集成到素材系统
|
||||||
|
- [x] 29.1 更新素材系统 middleware.ts(已在 Phase 6 完成)
|
||||||
|
- 添加许可证检查逻辑
|
||||||
|
- 配置激活页面路由
|
||||||
|
- _Requirements: 2.1_
|
||||||
|
- [x] 29.2 集成功能限制检查
|
||||||
|
- 用户注册接口添加限制检查 `app/api/auth/register/route.ts`
|
||||||
|
- 素材上传接口添加限制检查 `app/api/materials/upload/route.ts`
|
||||||
|
- 创建限制检查工具 `lib/license/check-limits.ts`
|
||||||
|
- _Requirements: 5.1, 5.2, 5.3, 5.4_
|
||||||
|
- [x] 29.3 配置心跳定时任务 `instrumentation.ts`
|
||||||
|
- 启动时验证许可证
|
||||||
|
- 每小时发送心跳
|
||||||
|
- _Requirements: 4.1, 4.2_
|
||||||
|
- [x] 29.4 添加版本文件
|
||||||
|
- 在 Dockerfile 中添加 VERSION.txt
|
||||||
|
- 构建时通过 ARG 写入版本号
|
||||||
|
- _Requirements: 13.5_
|
||||||
|
|
||||||
|
- [x] 30. 同步 Runtime 镜像到 Harbor
|
||||||
|
- [x] 30.1 创建镜像同步脚本 `scripts/sync-harbor-images.ps1`
|
||||||
|
- postgres:16-alpine
|
||||||
|
- minio/minio:latest
|
||||||
|
- minio/mc:latest
|
||||||
|
- redis:7-alpine
|
||||||
|
- nginx:alpine
|
||||||
|
- node:20-alpine
|
||||||
|
- _Requirements: 12.7_
|
||||||
|
- [x] 30.2 创建镜像构建脚本 `scripts/build-material-image.ps1`
|
||||||
|
- 支持 dev/test/release 三种环境
|
||||||
|
- 测试版推送到 material-dev/ 目录
|
||||||
|
- 正式版推送到 material/ 目录
|
||||||
|
- _Requirements: 13.6_
|
||||||
|
- [x] 30.3 创建镜像管理文档 `docs/harbor-image-management.md`
|
||||||
|
- 镜像目录结构说明
|
||||||
|
- 工作流程指南
|
||||||
|
- 版本管理规范
|
||||||
|
- _Requirements: 13.6_
|
||||||
|
- [x] 30.4 执行镜像同步(已完成)
|
||||||
|
- Runtime 镜像已同步到 Harbor
|
||||||
|
- _Requirements: 12.7_
|
||||||
|
|
||||||
|
- [x] 30. Final Checkpoint - 确保所有测试通过
|
||||||
|
- 单元测试:56 passed
|
||||||
|
- API 集成测试:全部通过
|
||||||
|
- 许可证服务器部署验证:✓
|
||||||
@ -181,6 +181,10 @@ RUN if [ ! -f ".next/standalone/server.js" ]; then \
|
|||||||
exit 1; \
|
exit 1; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# 写入版本文件(用于许可证系统版本检查)
|
||||||
|
ARG APP_VERSION="1.0.0"
|
||||||
|
RUN echo "${APP_VERSION}" > VERSION.txt
|
||||||
|
|
||||||
# 为可选模块创建占位符(确保 COPY 不会失败)
|
# 为可选模块创建占位符(确保 COPY 不会失败)
|
||||||
# 如果 canvas 未安装,创建一个空占位符目录
|
# 如果 canvas 未安装,创建一个空占位符目录
|
||||||
RUN if [ ! -d "node_modules/canvas" ]; then \
|
RUN if [ ! -d "node_modules/canvas" ]; then \
|
||||||
@ -244,6 +248,9 @@ COPY --from=builder --chown=nextjs:nodejs /app/logo ./public/logo
|
|||||||
# 3. Prisma schema 和迁移文件(运行时可能需要)
|
# 3. Prisma schema 和迁移文件(运行时可能需要)
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma
|
COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma
|
||||||
|
|
||||||
|
# 4. 版本文件(许可证系统版本检查)
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/VERSION.txt ./VERSION.txt
|
||||||
|
|
||||||
# 4. lib 目录(包含 configLoader 等运行时模块)
|
# 4. lib 目录(包含 configLoader 等运行时模块)
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/lib ./lib
|
COPY --from=builder --chown=nextjs:nodejs /app/lib ./lib
|
||||||
|
|
||||||
|
|||||||
52
VERSION.txt
52
VERSION.txt
@ -1,51 +1 @@
|
|||||||
FLNXI素材库管理系统
|
1.0.0
|
||||||
版本历史
|
|
||||||
|
|
||||||
============================================
|
|
||||||
Version 1.0.0 (2024-10-26)
|
|
||||||
============================================
|
|
||||||
|
|
||||||
初始版本发布
|
|
||||||
|
|
||||||
主要功能:
|
|
||||||
✅ 用户注册/登录系统
|
|
||||||
✅ 3D素材上传(支持多种格式)
|
|
||||||
✅ 素材浏览和搜索
|
|
||||||
✅ 多维度筛选(店铺、场景、月份)
|
|
||||||
✅ 素材下载(带进度显示)
|
|
||||||
✅ 点赞和分享功能
|
|
||||||
✅ 管理员后台
|
|
||||||
✅ 用户权限管理
|
|
||||||
✅ 系统设置
|
|
||||||
✅ 数据统计和排行榜
|
|
||||||
✅ MinIO 对象存储集成
|
|
||||||
✅ PostgreSQL 数据库
|
|
||||||
✅ Docker 一键部署
|
|
||||||
|
|
||||||
技术栈:
|
|
||||||
- Next.js 14
|
|
||||||
- React 18
|
|
||||||
- TypeScript
|
|
||||||
- TailwindCSS
|
|
||||||
- PostgreSQL 15
|
|
||||||
- MinIO
|
|
||||||
- Prisma ORM
|
|
||||||
- NextAuth.js
|
|
||||||
|
|
||||||
部署方式:
|
|
||||||
- Docker Compose
|
|
||||||
- 完全私有化部署
|
|
||||||
- 包含数据库和对象存储
|
|
||||||
- 一键安装脚本
|
|
||||||
|
|
||||||
============================================
|
|
||||||
|
|
||||||
未来计划:
|
|
||||||
- [ ] 批量上传功能
|
|
||||||
- [ ] 在线3D预览
|
|
||||||
- [ ] 移动端适配
|
|
||||||
- [ ] API接口开放
|
|
||||||
- [ ] 更多素材类型支持
|
|
||||||
- [ ] 协作功能增强
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
244
app/activate/__tests__/page.test.tsx
Normal file
244
app/activate/__tests__/page.test.tsx
Normal file
@ -0,0 +1,244 @@
|
|||||||
|
/**
|
||||||
|
* @jest-environment jsdom
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* 激活页面测试
|
||||||
|
* 测试许可证激活和免费试用功能
|
||||||
|
*/
|
||||||
|
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
|
||||||
|
import ActivatePage from '../page';
|
||||||
|
|
||||||
|
// Mock next/navigation
|
||||||
|
const mockPush = jest.fn();
|
||||||
|
jest.mock('next/navigation', () => ({
|
||||||
|
useRouter: () => ({
|
||||||
|
push: mockPush,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock fetch
|
||||||
|
global.fetch = jest.fn();
|
||||||
|
|
||||||
|
describe('ActivatePage', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
(global.fetch as jest.Mock).mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('渲染测试', () => {
|
||||||
|
it('应该正确渲染页面标题和表单', () => {
|
||||||
|
render(<ActivatePage />);
|
||||||
|
|
||||||
|
expect(screen.getByText('FLNXI 素材系统')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('请激活您的许可证')).toBeInTheDocument();
|
||||||
|
expect(screen.getByPlaceholderText('XXXX-XXXX-XXXX-XXXX')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('激活许可证')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('免费试用')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该显示免费版限制说明', () => {
|
||||||
|
render(<ActivatePage />);
|
||||||
|
expect(screen.getByText('免费版限制:10 个用户、100 个素材')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该显示联系信息', () => {
|
||||||
|
render(<ActivatePage />);
|
||||||
|
expect(screen.getByText('需要购买许可证?请联系我们')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('许可证密钥格式化', () => {
|
||||||
|
it('应该自动将输入转换为大写', () => {
|
||||||
|
render(<ActivatePage />);
|
||||||
|
const input = screen.getByPlaceholderText('XXXX-XXXX-XXXX-XXXX') as HTMLInputElement;
|
||||||
|
|
||||||
|
fireEvent.change(input, { target: { value: 'abcd' } });
|
||||||
|
expect(input.value).toBe('ABCD');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该自动添加横杠分隔符', () => {
|
||||||
|
render(<ActivatePage />);
|
||||||
|
const input = screen.getByPlaceholderText('XXXX-XXXX-XXXX-XXXX') as HTMLInputElement;
|
||||||
|
|
||||||
|
fireEvent.change(input, { target: { value: 'abcd1234' } });
|
||||||
|
expect(input.value).toBe('ABCD-1234');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该过滤非字母数字字符', () => {
|
||||||
|
render(<ActivatePage />);
|
||||||
|
const input = screen.getByPlaceholderText('XXXX-XXXX-XXXX-XXXX') as HTMLInputElement;
|
||||||
|
|
||||||
|
fireEvent.change(input, { target: { value: 'ab!@#cd12' } });
|
||||||
|
expect(input.value).toBe('ABCD-12');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该限制最大长度为19个字符(含横杠)', () => {
|
||||||
|
render(<ActivatePage />);
|
||||||
|
const input = screen.getByPlaceholderText('XXXX-XXXX-XXXX-XXXX') as HTMLInputElement;
|
||||||
|
|
||||||
|
fireEvent.change(input, { target: { value: 'ABCD1234EFGH5678EXTRA' } });
|
||||||
|
expect(input.value).toBe('ABCD-1234-EFGH-5678');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('激活按钮状态', () => {
|
||||||
|
it('密钥不完整时激活按钮应该禁用', () => {
|
||||||
|
render(<ActivatePage />);
|
||||||
|
const button = screen.getByText('激活许可证');
|
||||||
|
expect(button).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('密钥完整时激活按钮应该启用', () => {
|
||||||
|
render(<ActivatePage />);
|
||||||
|
const input = screen.getByPlaceholderText('XXXX-XXXX-XXXX-XXXX');
|
||||||
|
|
||||||
|
fireEvent.change(input, { target: { value: 'ABCD1234EFGH5678' } });
|
||||||
|
|
||||||
|
const button = screen.getByText('激活许可证');
|
||||||
|
expect(button).not.toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('许可证激活', () => {
|
||||||
|
it('激活成功后应该跳转到首页', async () => {
|
||||||
|
(global.fetch as jest.Mock).mockResolvedValueOnce({
|
||||||
|
json: () => Promise.resolve({ success: true }),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<ActivatePage />);
|
||||||
|
const input = screen.getByPlaceholderText('XXXX-XXXX-XXXX-XXXX');
|
||||||
|
fireEvent.change(input, { target: { value: 'ABCD1234EFGH5678' } });
|
||||||
|
|
||||||
|
const button = screen.getByText('激活许可证');
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(button);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockPush).toHaveBeenCalledWith('/');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('激活失败应该显示错误信息', async () => {
|
||||||
|
(global.fetch as jest.Mock).mockResolvedValueOnce({
|
||||||
|
json: () => Promise.resolve({ success: false, error: '许可证无效' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<ActivatePage />);
|
||||||
|
const input = screen.getByPlaceholderText('XXXX-XXXX-XXXX-XXXX');
|
||||||
|
fireEvent.change(input, { target: { value: 'ABCD1234EFGH5678' } });
|
||||||
|
|
||||||
|
const button = screen.getByText('激活许可证');
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(button);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('许可证无效')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('网络错误应该显示网络错误提示', async () => {
|
||||||
|
(global.fetch as jest.Mock).mockRejectedValueOnce(new Error('Network error'));
|
||||||
|
|
||||||
|
render(<ActivatePage />);
|
||||||
|
const input = screen.getByPlaceholderText('XXXX-XXXX-XXXX-XXXX');
|
||||||
|
fireEvent.change(input, { target: { value: 'ABCD1234EFGH5678' } });
|
||||||
|
|
||||||
|
const button = screen.getByText('激活许可证');
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(button);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('网络错误,请检查网络连接')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('激活时应该显示加载状态', async () => {
|
||||||
|
let resolvePromise: (value: unknown) => void;
|
||||||
|
(global.fetch as jest.Mock).mockImplementation(() =>
|
||||||
|
new Promise(resolve => { resolvePromise = resolve; })
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<ActivatePage />);
|
||||||
|
const input = screen.getByPlaceholderText('XXXX-XXXX-XXXX-XXXX');
|
||||||
|
fireEvent.change(input, { target: { value: 'ABCD1234EFGH5678' } });
|
||||||
|
|
||||||
|
const button = screen.getByText('激活许可证');
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(button);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText('激活中...')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// 清理
|
||||||
|
await act(async () => {
|
||||||
|
resolvePromise!({ json: () => Promise.resolve({ success: true }) });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('免费试用', () => {
|
||||||
|
it('免费试用成功后应该跳转到首页', async () => {
|
||||||
|
(global.fetch as jest.Mock).mockResolvedValueOnce({
|
||||||
|
json: () => Promise.resolve({ success: true }),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<ActivatePage />);
|
||||||
|
const button = screen.getByText('免费试用');
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(button);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockPush).toHaveBeenCalledWith('/');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith('/api/license/activate-free', {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('免费试用失败应该显示错误信息', async () => {
|
||||||
|
(global.fetch as jest.Mock).mockResolvedValueOnce({
|
||||||
|
json: () => Promise.resolve({ success: false, error: '试用已过期' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<ActivatePage />);
|
||||||
|
const button = screen.getByText('免费试用');
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(button);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('试用已过期')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('API 调用', () => {
|
||||||
|
it('激活时应该发送正确的请求', async () => {
|
||||||
|
(global.fetch as jest.Mock).mockResolvedValueOnce({
|
||||||
|
json: () => Promise.resolve({ success: true }),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<ActivatePage />);
|
||||||
|
const input = screen.getByPlaceholderText('XXXX-XXXX-XXXX-XXXX');
|
||||||
|
fireEvent.change(input, { target: { value: 'ABCD1234EFGH5678' } });
|
||||||
|
|
||||||
|
const button = screen.getByText('激活许可证');
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(button);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith('/api/license/activate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ licenseKey: 'ABCD-1234-EFGH-5678' }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
153
app/activate/page.tsx
Normal file
153
app/activate/page.tsx
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
export default function ActivatePage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [licenseKey, setLicenseKey] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
// 格式化许可证密钥输入(自动添加横杠)
|
||||||
|
const formatLicenseKey = (value: string) => {
|
||||||
|
const cleaned = value.toUpperCase().replace(/[^A-Z0-9]/g, '');
|
||||||
|
const parts = [];
|
||||||
|
for (let i = 0; i < cleaned.length && i < 16; i += 4) {
|
||||||
|
parts.push(cleaned.slice(i, i + 4));
|
||||||
|
}
|
||||||
|
return parts.join('-');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setLicenseKey(formatLicenseKey(e.target.value));
|
||||||
|
setError('');
|
||||||
|
};
|
||||||
|
|
||||||
|
// 激活许可证
|
||||||
|
const handleActivate = async () => {
|
||||||
|
if (!licenseKey || licenseKey.length !== 19) {
|
||||||
|
setError('请输入完整的许可证密钥');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/license/activate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ licenseKey }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
router.push('/');
|
||||||
|
} else {
|
||||||
|
setError(data.error || '激活失败,请检查许可证密钥');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError('网络错误,请检查网络连接');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 免费试用
|
||||||
|
const handleFreeTrial = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/license/activate-free', {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
router.push('/');
|
||||||
|
} else {
|
||||||
|
setError(data.error || '激活失败');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError('网络错误,请检查网络连接');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
<div className="bg-white rounded-lg shadow-lg p-8">
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">FLNXI 素材系统</h1>
|
||||||
|
<p className="text-gray-500 mt-2">请激活您的许可证</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 许可证输入 */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
许可证密钥
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={licenseKey}
|
||||||
|
onChange={handleKeyChange}
|
||||||
|
placeholder="XXXX-XXXX-XXXX-XXXX"
|
||||||
|
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-center text-lg tracking-wider font-mono"
|
||||||
|
maxLength={19}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 错误提示 */}
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-600 text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 激活按钮 */}
|
||||||
|
<button
|
||||||
|
onClick={handleActivate}
|
||||||
|
disabled={loading || licenseKey.length !== 19}
|
||||||
|
className="w-full py-3 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
{loading ? '激活中...' : '激活许可证'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 分隔线 */}
|
||||||
|
<div className="my-6 flex items-center">
|
||||||
|
<div className="flex-1 border-t border-gray-200"></div>
|
||||||
|
<span className="px-4 text-sm text-gray-400">或</span>
|
||||||
|
<div className="flex-1 border-t border-gray-200"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 免费试用 */}
|
||||||
|
<button
|
||||||
|
onClick={handleFreeTrial}
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full py-3 border border-gray-300 text-gray-700 rounded-lg font-medium hover:bg-gray-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
免费试用
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 免费版说明 */}
|
||||||
|
<p className="mt-4 text-center text-xs text-gray-400">
|
||||||
|
免费版限制:10 个用户、100 个素材
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 联系信息 */}
|
||||||
|
<p className="mt-6 text-center text-sm text-gray-500">
|
||||||
|
需要购买许可证?请联系我们
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -5,6 +5,7 @@ import { useSession } from 'next-auth/react'
|
|||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import Header from '@/components/Header'
|
import Header from '@/components/Header'
|
||||||
import AppearanceSettingsPanel from '../_components/AppearanceSettingsPanel'
|
import AppearanceSettingsPanel from '../_components/AppearanceSettingsPanel'
|
||||||
|
import LicenseInfo from '@/components/LicenseInfo'
|
||||||
|
|
||||||
export default function SystemSettingsPage() {
|
export default function SystemSettingsPage() {
|
||||||
const { data: session, status } = useSession()
|
const { data: session, status } = useSession()
|
||||||
@ -22,7 +23,7 @@ export default function SystemSettingsPage() {
|
|||||||
if (status === 'loading') {
|
if (status === 'loading') {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-900">
|
<div className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-900">
|
||||||
<div className="text-sm text-slate-500 dark:text-slate-400">正在加载外观设置...</div>
|
<div className="text-sm text-slate-500 dark:text-slate-400">正在加载设置...</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -35,6 +36,7 @@ export default function SystemSettingsPage() {
|
|||||||
<div className="min-h-screen bg-slate-50 dark:bg-slate-900">
|
<div className="min-h-screen bg-slate-50 dark:bg-slate-900">
|
||||||
<Header session={session} />
|
<Header session={session} />
|
||||||
<main className="container mx-auto px-4 py-8 max-w-6xl space-y-8">
|
<main className="container mx-auto px-4 py-8 max-w-6xl space-y-8">
|
||||||
|
<LicenseInfo />
|
||||||
<AppearanceSettingsPanel sessionUser={session.user as any} variant="standalone" />
|
<AppearanceSettingsPanel sessionUser={session.user as any} variant="standalone" />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { authOptions } from '@/lib/auth'
|
|||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { Prisma } from '@prisma/client'
|
import { Prisma } from '@prisma/client'
|
||||||
import { syncFieldPoolFromTemplates } from '@/lib/field-pool'
|
import { syncFieldPoolFromTemplates } from '@/lib/field-pool'
|
||||||
|
import { canUseCustomTemplates } from '@/lib/license'
|
||||||
|
|
||||||
const baseSelect = {
|
const baseSelect = {
|
||||||
id: true,
|
id: true,
|
||||||
@ -96,6 +97,14 @@ export async function POST(req: NextRequest) {
|
|||||||
return NextResponse.json({ error: '无权限' }, { status: 403 })
|
return NextResponse.json({ error: '无权限' }, { status: 403 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查自定义模板功能是否可用
|
||||||
|
if (!canUseCustomTemplates()) {
|
||||||
|
return NextResponse.json({
|
||||||
|
error: '自定义模板功能需要标准版或专业版许可证,请升级后使用',
|
||||||
|
errorCode: 'LICENSE_REQUIRED',
|
||||||
|
}, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
body = await req.json()
|
body = await req.json()
|
||||||
const {
|
const {
|
||||||
name,
|
name,
|
||||||
|
|||||||
@ -1,9 +1,19 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import bcrypt from 'bcryptjs'
|
import bcrypt from 'bcryptjs'
|
||||||
|
import { canCreateUser } from '@/lib/license'
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
// 检查许可证用户数量限制
|
||||||
|
const userLimit = await canCreateUser();
|
||||||
|
if (!userLimit.allowed) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: userLimit.message || '已达到用户数量上限' },
|
||||||
|
{ status: 403 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// 检查注册功能是否开启
|
// 检查注册功能是否开启
|
||||||
const settings = await prisma.systemSettings.findFirst()
|
const settings = await prisma.systemSettings.findFirst()
|
||||||
if (settings && !settings.enableRegistration) {
|
if (settings && !settings.enableRegistration) {
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
|
|||||||
import { getServerSession } from 'next-auth'
|
import { getServerSession } from 'next-auth'
|
||||||
import { authOptions } from '@/lib/auth'
|
import { authOptions } from '@/lib/auth'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { canUseDepartmentIsolation } from '@/lib/license'
|
||||||
|
|
||||||
// Get all departments
|
// Get all departments
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
@ -34,6 +35,14 @@ export async function POST(req: NextRequest) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查部门隔离功能是否可用
|
||||||
|
if (!canUseDepartmentIsolation()) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '部门隔离功能需要专业版许可证,请升级后使用' },
|
||||||
|
{ status: 403 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const { name } = await req.json()
|
const { name } = await req.json()
|
||||||
|
|
||||||
if (!name || name.trim() === '') {
|
if (!name || name.trim() === '') {
|
||||||
|
|||||||
34
app/api/license/activate-free/route.ts
Normal file
34
app/api/license/activate-free/route.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { activateFreeTrial } from '@/lib/license';
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
try {
|
||||||
|
const result = await activateFreeTrial();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
const response = NextResponse.json({ success: true });
|
||||||
|
|
||||||
|
// 设置许可证激活 cookie(用于 middleware 快速检查)
|
||||||
|
response.cookies.set('license_activated', 'true', {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
sameSite: 'lax',
|
||||||
|
maxAge: 365 * 24 * 60 * 60, // 1年
|
||||||
|
path: '/',
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} else {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: result.error },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('免费试用激活失败:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: '服务器错误' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
191
app/api/license/activate/__tests__/route.test.ts
Normal file
191
app/api/license/activate/__tests__/route.test.ts
Normal file
@ -0,0 +1,191 @@
|
|||||||
|
/**
|
||||||
|
* 许可证激活 API 路由测试
|
||||||
|
* 测试 app/api/license/activate/route.ts
|
||||||
|
*/
|
||||||
|
import { NextRequest } from 'next/server';
|
||||||
|
import { POST } from '../route';
|
||||||
|
|
||||||
|
// Mock lib/license 模块
|
||||||
|
jest.mock('@/lib/license', () => ({
|
||||||
|
activate: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { activate } from '@/lib/license';
|
||||||
|
|
||||||
|
const mockActivate = activate as jest.MockedFunction<typeof activate>;
|
||||||
|
|
||||||
|
describe('POST /api/license/activate', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('请求验证', () => {
|
||||||
|
it('缺少 licenseKey 时应返回 400 错误', async () => {
|
||||||
|
const request = new NextRequest('http://localhost/api/license/activate', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await POST(request);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(data.success).toBe(false);
|
||||||
|
expect(data.error).toBe('请输入许可证密钥');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('licenseKey 为空字符串时应返回 400 错误', async () => {
|
||||||
|
const request = new NextRequest('http://localhost/api/license/activate', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ licenseKey: '' }),
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await POST(request);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(data.success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('激活成功', () => {
|
||||||
|
it('激活成功时应返回 success: true', async () => {
|
||||||
|
mockActivate.mockResolvedValueOnce({ success: true });
|
||||||
|
|
||||||
|
const request = new NextRequest('http://localhost/api/license/activate', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ licenseKey: 'ABCD-1234-EFGH-5678' }),
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await POST(request);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(data.success).toBe(true);
|
||||||
|
expect(mockActivate).toHaveBeenCalledWith('ABCD-1234-EFGH-5678');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('激活成功时应设置 license_activated cookie', async () => {
|
||||||
|
mockActivate.mockResolvedValueOnce({ success: true });
|
||||||
|
|
||||||
|
const request = new NextRequest('http://localhost/api/license/activate', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ licenseKey: 'ABCD-1234-EFGH-5678' }),
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await POST(request);
|
||||||
|
const setCookieHeader = response.headers.get('set-cookie');
|
||||||
|
|
||||||
|
expect(setCookieHeader).toBeTruthy();
|
||||||
|
expect(setCookieHeader).toContain('license_activated=true');
|
||||||
|
expect(setCookieHeader).toContain('HttpOnly');
|
||||||
|
expect(setCookieHeader).toContain('Path=/');
|
||||||
|
// SameSite 值可能是 Lax 或 lax(大小写不敏感)
|
||||||
|
expect(setCookieHeader?.toLowerCase()).toContain('samesite=lax');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cookie 应设置 1 年有效期', async () => {
|
||||||
|
mockActivate.mockResolvedValueOnce({ success: true });
|
||||||
|
|
||||||
|
const request = new NextRequest('http://localhost/api/license/activate', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ licenseKey: 'ABCD-1234-EFGH-5678' }),
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await POST(request);
|
||||||
|
const setCookieHeader = response.headers.get('set-cookie');
|
||||||
|
|
||||||
|
// 1年 = 365 * 24 * 60 * 60 = 31536000 秒
|
||||||
|
expect(setCookieHeader).toContain('Max-Age=31536000');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('激活失败', () => {
|
||||||
|
it('许可证无效时应返回 400 错误', async () => {
|
||||||
|
mockActivate.mockResolvedValueOnce({ success: false, error: '许可证无效' });
|
||||||
|
|
||||||
|
const request = new NextRequest('http://localhost/api/license/activate', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ licenseKey: 'INVALID-KEY-1234' }),
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await POST(request);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(data.success).toBe(false);
|
||||||
|
expect(data.error).toBe('许可证无效');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('许可证已被使用时应返回相应错误', async () => {
|
||||||
|
mockActivate.mockResolvedValueOnce({ success: false, error: '许可证已被其他设备激活' });
|
||||||
|
|
||||||
|
const request = new NextRequest('http://localhost/api/license/activate', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ licenseKey: 'USED-KEY-1234-5678' }),
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await POST(request);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(data.success).toBe(false);
|
||||||
|
expect(data.error).toBe('许可证已被其他设备激活');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('激活失败时不应设置 cookie', async () => {
|
||||||
|
mockActivate.mockResolvedValueOnce({ success: false, error: '许可证无效' });
|
||||||
|
|
||||||
|
const request = new NextRequest('http://localhost/api/license/activate', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ licenseKey: 'INVALID-KEY-1234' }),
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await POST(request);
|
||||||
|
const setCookieHeader = response.headers.get('set-cookie');
|
||||||
|
|
||||||
|
expect(setCookieHeader).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('服务器错误处理', () => {
|
||||||
|
it('activate 函数抛出异常时应返回 500 错误', async () => {
|
||||||
|
mockActivate.mockRejectedValueOnce(new Error('数据库连接失败'));
|
||||||
|
|
||||||
|
const request = new NextRequest('http://localhost/api/license/activate', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ licenseKey: 'ABCD-1234-EFGH-5678' }),
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await POST(request);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
expect(data.success).toBe(false);
|
||||||
|
expect(data.error).toBe('服务器错误');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('JSON 解析错误时应返回 500 错误', async () => {
|
||||||
|
const request = new NextRequest('http://localhost/api/license/activate', {
|
||||||
|
method: 'POST',
|
||||||
|
body: 'invalid json',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await POST(request);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
expect(data.success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
43
app/api/license/activate/route.ts
Normal file
43
app/api/license/activate/route.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { activate } from '@/lib/license';
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { licenseKey } = await request.json();
|
||||||
|
|
||||||
|
if (!licenseKey) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: '请输入许可证密钥' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await activate(licenseKey);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
const response = NextResponse.json({ success: true });
|
||||||
|
|
||||||
|
// 设置许可证激活 cookie(用于 middleware 快速检查)
|
||||||
|
response.cookies.set('license_activated', 'true', {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
sameSite: 'lax',
|
||||||
|
maxAge: 365 * 24 * 60 * 60, // 1年
|
||||||
|
path: '/',
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} else {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: result.error },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('激活失败:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: '服务器错误' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
67
app/api/license/info/route.ts
Normal file
67
app/api/license/info/route.ts
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import { getLicense, getLicenseType } from '@/lib/license';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const license = getLicense();
|
||||||
|
|
||||||
|
if (!license) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '未激活' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取使用统计
|
||||||
|
const [userCount, materialCount] = await Promise.all([
|
||||||
|
prisma.user.count(),
|
||||||
|
prisma.material.count(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 获取本地许可证数据
|
||||||
|
const localLicense = await prisma.systemConfig.findUnique({
|
||||||
|
where: { key: 'license' },
|
||||||
|
});
|
||||||
|
|
||||||
|
let lastVerified = new Date().toISOString();
|
||||||
|
if (localLicense) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(localLicense.value);
|
||||||
|
lastVerified = data.lastVerified || lastVerified;
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 判断状态
|
||||||
|
let status: 'active' | 'expired' | 'grace' = 'active';
|
||||||
|
if (license.expiresAt && new Date(license.expiresAt) < new Date()) {
|
||||||
|
status = 'expired';
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
type: license.type,
|
||||||
|
status,
|
||||||
|
expiresAt: license.expiresAt,
|
||||||
|
usage: {
|
||||||
|
users: {
|
||||||
|
current: userCount,
|
||||||
|
max: license.limits.maxUsers,
|
||||||
|
},
|
||||||
|
materials: {
|
||||||
|
current: materialCount,
|
||||||
|
max: license.limits.maxMaterials,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
features: license.features,
|
||||||
|
lastVerified,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取许可证信息失败:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '服务器错误' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -7,6 +7,7 @@ import { uploadToS3 } from '@/lib/s3'
|
|||||||
import { processImage, getProcessedFileName, sliceImage } from '@/lib/image-processor'
|
import { processImage, getProcessedFileName, sliceImage } from '@/lib/image-processor'
|
||||||
import { parseAudioMetadata, isAudioFile } from '@/lib/audio-processor'
|
import { parseAudioMetadata, isAudioFile } from '@/lib/audio-processor'
|
||||||
import { validateTemplateId } from '@/lib/template-validator'
|
import { validateTemplateId } from '@/lib/template-validator'
|
||||||
|
import { canUploadMaterial } from '@/lib/license'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { randomBytes } from 'crypto'
|
import { randomBytes } from 'crypto'
|
||||||
|
|
||||||
@ -78,6 +79,16 @@ export async function POST(req: NextRequest) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查许可证素材数量限制
|
||||||
|
const materialLimit = await canUploadMaterial();
|
||||||
|
if (!materialLimit.allowed) {
|
||||||
|
console.log('[Upload] Material limit reached:', materialLimit)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: materialLimit.message || '已达到素材数量上限' },
|
||||||
|
{ status: 403 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
console.log('[Upload] Parsing form data...')
|
console.log('[Upload] Parsing form data...')
|
||||||
const formData = await req.formData()
|
const formData = await req.formData()
|
||||||
const normalizeInput = (value: FormDataEntryValue | null) => {
|
const normalizeInput = (value: FormDataEntryValue | null) => {
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { prisma } from '@/lib/prisma'
|
|||||||
import { defaultLogos, featureFlags } from '@/lib/config'
|
import { defaultLogos, featureFlags } from '@/lib/config'
|
||||||
import { cache } from '@/lib/cache'
|
import { cache } from '@/lib/cache'
|
||||||
import { ensureSystemSettingsOptionalColumns } from '@/lib/system-settings-columns'
|
import { ensureSystemSettingsOptionalColumns } from '@/lib/system-settings-columns'
|
||||||
|
import { canUseSystemSettings } from '@/lib/license'
|
||||||
|
|
||||||
const CACHE_KEY = 'system-settings'
|
const CACHE_KEY = 'system-settings'
|
||||||
const CACHE_TTL = 60000 // 1分钟缓存
|
const CACHE_TTL = 60000 // 1分钟缓存
|
||||||
@ -188,6 +189,14 @@ export async function PUT(request: NextRequest) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查系统设置功能是否可用(免费版只能查看,不能修改)
|
||||||
|
if (!canUseSystemSettings()) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '系统设置功能需要标准版或专业版许可证,请升级后使用' },
|
||||||
|
{ status: 403 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
const {
|
const {
|
||||||
headerBgColor,
|
headerBgColor,
|
||||||
|
|||||||
212
components/LicenseInfo.tsx
Normal file
212
components/LicenseInfo.tsx
Normal file
@ -0,0 +1,212 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
interface LicenseData {
|
||||||
|
type: 'FREE' | 'STANDARD' | 'PRO';
|
||||||
|
status: 'active' | 'expired' | 'grace';
|
||||||
|
expiresAt: string | null;
|
||||||
|
usage: {
|
||||||
|
users: { current: number; max: number };
|
||||||
|
materials: { current: number; max: number };
|
||||||
|
};
|
||||||
|
features: {
|
||||||
|
customTemplates: boolean;
|
||||||
|
departmentIsolation: boolean;
|
||||||
|
intranetPenetration: boolean;
|
||||||
|
};
|
||||||
|
lastVerified: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LICENSE_TYPE_NAMES = {
|
||||||
|
FREE: '免费版',
|
||||||
|
STANDARD: '标准版',
|
||||||
|
PRO: '专业版',
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_NAMES = {
|
||||||
|
active: '正常',
|
||||||
|
expired: '已过期',
|
||||||
|
grace: '宽限期',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function LicenseInfo() {
|
||||||
|
const [license, setLicense] = useState<LicenseData | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchLicenseInfo();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchLicenseInfo = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/license/info');
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setLicense(data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取许可证信息失败:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-slate-800 rounded-lg border dark:border-slate-700 p-6">
|
||||||
|
<div className="animate-pulse">
|
||||||
|
<div className="h-4 bg-gray-200 dark:bg-slate-700 rounded w-1/4 mb-4"></div>
|
||||||
|
<div className="h-8 bg-gray-200 dark:bg-slate-700 rounded w-1/2"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!license) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-slate-800 rounded-lg border dark:border-slate-700 p-6">
|
||||||
|
<p className="text-gray-500 dark:text-slate-400">无法获取许可证信息</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isExpiringSoon =
|
||||||
|
license.expiresAt &&
|
||||||
|
new Date(license.expiresAt).getTime() - Date.now() < 30 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-slate-800 rounded-lg border dark:border-slate-700">
|
||||||
|
{/* 头部 */}
|
||||||
|
<div className="p-6 border-b dark:border-slate-700">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 dark:text-slate-100">许可证信息</h3>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-slate-400 mt-1">
|
||||||
|
最后验证: {new Date(license.lastVerified).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={`px-3 py-1 rounded-full text-sm font-medium ${
|
||||||
|
license.type === 'PRO'
|
||||||
|
? 'bg-purple-100 text-purple-700'
|
||||||
|
: license.type === 'STANDARD'
|
||||||
|
? 'bg-blue-100 text-blue-700'
|
||||||
|
: 'bg-gray-100 text-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{LICENSE_TYPE_NAMES[license.type]}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`px-3 py-1 rounded-full text-sm font-medium ${
|
||||||
|
license.status === 'active'
|
||||||
|
? 'bg-green-100 text-green-700'
|
||||||
|
: license.status === 'grace'
|
||||||
|
? 'bg-yellow-100 text-yellow-700'
|
||||||
|
: 'bg-red-100 text-red-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{STATUS_NAMES[license.status]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 到期提醒 */}
|
||||||
|
{isExpiringSoon && license.expiresAt && (
|
||||||
|
<div className="px-6 py-3 bg-yellow-50 dark:bg-yellow-900/20 border-b border-yellow-100 dark:border-yellow-800">
|
||||||
|
<p className="text-sm text-yellow-700 dark:text-yellow-400">
|
||||||
|
⚠️ 许可证将于 {new Date(license.expiresAt).toLocaleDateString()} 到期,请及时续期
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 使用统计 */}
|
||||||
|
<div className="p-6 border-b dark:border-slate-700">
|
||||||
|
<h4 className="text-sm font-medium text-gray-700 dark:text-slate-300 mb-4">使用情况</h4>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{/* 用户数 */}
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between text-sm mb-1">
|
||||||
|
<span className="text-gray-600 dark:text-slate-400">用户数</span>
|
||||||
|
<span className="text-gray-900 dark:text-slate-100">
|
||||||
|
{license.usage.users.current} / {license.usage.users.max === -1 ? '无限' : license.usage.users.max}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{license.usage.users.max !== -1 && (
|
||||||
|
<div className="h-2 bg-gray-100 dark:bg-slate-700 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full ${
|
||||||
|
license.usage.users.current / license.usage.users.max > 0.9
|
||||||
|
? 'bg-red-500'
|
||||||
|
: 'bg-blue-500'
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
width: `${Math.min(100, (license.usage.users.current / license.usage.users.max) * 100)}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 素材数 */}
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between text-sm mb-1">
|
||||||
|
<span className="text-gray-600 dark:text-slate-400">素材数</span>
|
||||||
|
<span className="text-gray-900 dark:text-slate-100">
|
||||||
|
{license.usage.materials.current} / {license.usage.materials.max === -1 ? '无限' : license.usage.materials.max}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{license.usage.materials.max !== -1 && (
|
||||||
|
<div className="h-2 bg-gray-100 dark:bg-slate-700 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full ${
|
||||||
|
license.usage.materials.current / license.usage.materials.max > 0.9
|
||||||
|
? 'bg-red-500'
|
||||||
|
: 'bg-blue-500'
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
width: `${Math.min(100, (license.usage.materials.current / license.usage.materials.max) * 100)}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 功能列表 */}
|
||||||
|
<div className="p-6">
|
||||||
|
<h4 className="text-sm font-medium text-gray-700 dark:text-slate-300 mb-4">功能权限</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<FeatureItem label="自定义模板" enabled={license.features.customTemplates} />
|
||||||
|
<FeatureItem label="部门隔离" enabled={license.features.departmentIsolation} />
|
||||||
|
<FeatureItem label="内网穿透" enabled={license.features.intranetPenetration} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 升级按钮 */}
|
||||||
|
{license.type !== 'PRO' && (
|
||||||
|
<div className="px-6 pb-6">
|
||||||
|
<button className="w-full py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
|
||||||
|
升级许可证
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FeatureItem({ label, enabled }: { label: string; enabled: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between py-1">
|
||||||
|
<span className="text-sm text-gray-600 dark:text-slate-400">{label}</span>
|
||||||
|
{enabled ? (
|
||||||
|
<span className="text-green-600 dark:text-green-400">✓</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-300 dark:text-slate-600">✗</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -267,15 +267,19 @@ describe('RankingPanel 组件', () => {
|
|||||||
localStorageMock.setItem('rankingPanelClosed', 'true')
|
localStorageMock.setItem('rankingPanelClosed', 'true')
|
||||||
|
|
||||||
// 不应该抛出错误
|
// 不应该抛出错误
|
||||||
expect(() => render(<RankingPanel />)).not.toThrow()
|
await act(async () => {
|
||||||
|
expect(() => render(<RankingPanel />)).not.toThrow()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('应该处理空的 localStorage', async () => {
|
it('应该处理空的 localStorage', async () => {
|
||||||
render(<RankingPanel />)
|
await act(async () => {
|
||||||
|
render(<RankingPanel />)
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText('贡献榜')).toBeInTheDocument()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 使用 getAllByText 因为有多个元素包含相同文本
|
||||||
|
const elements = screen.getAllByText('贡献榜')
|
||||||
|
expect(elements.length).toBeGreaterThan(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('应该将位置限制在视口内', async () => {
|
it('应该将位置限制在视口内', async () => {
|
||||||
@ -287,7 +291,9 @@ describe('RankingPanel 组件', () => {
|
|||||||
}))
|
}))
|
||||||
localStorageMock.setItem('rankingPanelClosed', 'true')
|
localStorageMock.setItem('rankingPanelClosed', 'true')
|
||||||
|
|
||||||
render(<RankingPanel />)
|
await act(async () => {
|
||||||
|
render(<RankingPanel />)
|
||||||
|
})
|
||||||
|
|
||||||
const button = document.querySelector('button[title="显示排行榜(可拖动)"]')
|
const button = document.querySelector('button[title="显示排行榜(可拖动)"]')
|
||||||
expect(button).toBeInTheDocument()
|
expect(button).toBeInTheDocument()
|
||||||
@ -303,6 +309,7 @@ describe('RankingPanel 位置恢复逻辑', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks()
|
jest.clearAllMocks()
|
||||||
localStorageMock.clear()
|
localStorageMock.clear()
|
||||||
|
jest.useFakeTimers()
|
||||||
|
|
||||||
mockFetch.mockResolvedValue({
|
mockFetch.mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
@ -314,6 +321,10 @@ describe('RankingPanel 位置恢复逻辑', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
it('centered: true 时按钮应该垂直居中', async () => {
|
it('centered: true 时按钮应该垂直居中', async () => {
|
||||||
localStorageMock.setItem('rankingPanelButtonPosition', JSON.stringify({
|
localStorageMock.setItem('rankingPanelButtonPosition', JSON.stringify({
|
||||||
x: 32,
|
x: 32,
|
||||||
@ -322,7 +333,9 @@ describe('RankingPanel 位置恢复逻辑', () => {
|
|||||||
}))
|
}))
|
||||||
localStorageMock.setItem('rankingPanelClosed', 'true')
|
localStorageMock.setItem('rankingPanelClosed', 'true')
|
||||||
|
|
||||||
render(<RankingPanel />)
|
await act(async () => {
|
||||||
|
render(<RankingPanel />)
|
||||||
|
})
|
||||||
|
|
||||||
const button = document.querySelector('button[title="显示排行榜(可拖动)"]')
|
const button = document.querySelector('button[title="显示排行榜(可拖动)"]')
|
||||||
expect(button).toHaveStyle({
|
expect(button).toHaveStyle({
|
||||||
@ -340,7 +353,9 @@ describe('RankingPanel 位置恢复逻辑', () => {
|
|||||||
}))
|
}))
|
||||||
localStorageMock.setItem('rankingPanelClosed', 'true')
|
localStorageMock.setItem('rankingPanelClosed', 'true')
|
||||||
|
|
||||||
render(<RankingPanel />)
|
await act(async () => {
|
||||||
|
render(<RankingPanel />)
|
||||||
|
})
|
||||||
|
|
||||||
const button = document.querySelector('button[title="显示排行榜(可拖动)"]')
|
const button = document.querySelector('button[title="显示排行榜(可拖动)"]')
|
||||||
expect(button).toHaveStyle({
|
expect(button).toHaveStyle({
|
||||||
|
|||||||
133
docs/harbor-image-management.md
Normal file
133
docs/harbor-image-management.md
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
# Harbor 镜像管理指南
|
||||||
|
|
||||||
|
## 镜像目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
harbor.flnxi.com/flnxi/
|
||||||
|
├── material/ # 正式版镜像(客户拉取)
|
||||||
|
│ ├── flnxi-material:latest # 最新正式版
|
||||||
|
│ ├── flnxi-material:1.0.0 # 版本标签
|
||||||
|
│ └── flnxi-material:1.0.1
|
||||||
|
│
|
||||||
|
├── material-dev/ # 测试版镜像(开发测试用)
|
||||||
|
│ ├── flnxi-material:latest # 最新测试版
|
||||||
|
│ ├── flnxi-material:dev-xxx # 开发分支
|
||||||
|
│ └── flnxi-material:test-xxx # 测试版本
|
||||||
|
│
|
||||||
|
└── runtime/ # 依赖镜像
|
||||||
|
├── postgres:16-alpine
|
||||||
|
├── minio:latest
|
||||||
|
├── mc:latest
|
||||||
|
├── redis:7-alpine
|
||||||
|
├── nginx:alpine
|
||||||
|
└── node:20-alpine
|
||||||
|
```
|
||||||
|
|
||||||
|
## 工作流程
|
||||||
|
|
||||||
|
### 1. 开发阶段
|
||||||
|
|
||||||
|
构建开发版镜像,推送到 `material-dev/` 目录:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 构建并推送开发版
|
||||||
|
.\scripts\build-material-image.ps1 -Environment dev -Push
|
||||||
|
|
||||||
|
# 镜像会被推送到:
|
||||||
|
# harbor.flnxi.com/flnxi/material-dev/flnxi-material:dev-20241215-1430
|
||||||
|
# harbor.flnxi.com/flnxi/material-dev/flnxi-material:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 测试阶段
|
||||||
|
|
||||||
|
构建测试版镜像,推送到 `material-dev/` 目录:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 构建并推送测试版
|
||||||
|
.\scripts\build-material-image.ps1 -Environment test -Push
|
||||||
|
|
||||||
|
# 镜像会被推送到:
|
||||||
|
# harbor.flnxi.com/flnxi/material-dev/flnxi-material:test-20241215
|
||||||
|
# harbor.flnxi.com/flnxi/material-dev/flnxi-material:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 发布正式版
|
||||||
|
|
||||||
|
测试通过后,构建正式版镜像,推送到 `material/` 目录:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 更新版本号
|
||||||
|
echo "1.0.1" > VERSION.txt
|
||||||
|
|
||||||
|
# 构建并推送正式版
|
||||||
|
.\scripts\build-material-image.ps1 -Environment release -Push
|
||||||
|
|
||||||
|
# 镜像会被推送到:
|
||||||
|
# harbor.flnxi.com/flnxi/material/flnxi-material:1.0.1
|
||||||
|
# harbor.flnxi.com/flnxi/material/flnxi-material:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 同步 Runtime 镜像
|
||||||
|
|
||||||
|
首次部署或更新依赖版本时,同步 Runtime 镜像:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 预览模式(不实际执行)
|
||||||
|
.\scripts\sync-harbor-images.ps1 -DryRun
|
||||||
|
|
||||||
|
# 执行同步
|
||||||
|
.\scripts\sync-harbor-images.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
## 客户拉取
|
||||||
|
|
||||||
|
客户通过 Agent 安装时,会自动从以下地址拉取镜像:
|
||||||
|
|
||||||
|
- 素材系统:`harbor.flnxi.com/flnxi/material/flnxi-material:latest`
|
||||||
|
- PostgreSQL:`harbor.flnxi.com/flnxi/runtime/postgres:16-alpine`
|
||||||
|
- MinIO:`harbor.flnxi.com/flnxi/runtime/minio:latest`
|
||||||
|
- Redis:`harbor.flnxi.com/flnxi/runtime/redis:7-alpine`
|
||||||
|
|
||||||
|
如果 Harbor 不可用,会自动回退到 Docker Hub 官方镜像。
|
||||||
|
|
||||||
|
## 版本管理
|
||||||
|
|
||||||
|
### 版本号规范
|
||||||
|
|
||||||
|
- 正式版:`x.y.z`(如 `1.0.0`、`1.0.1`、`1.1.0`)
|
||||||
|
- 测试版:`test-yyyyMMdd`(如 `test-20241215`)
|
||||||
|
- 开发版:`dev-yyyyMMdd-HHmm`(如 `dev-20241215-1430`)
|
||||||
|
|
||||||
|
### 版本文件
|
||||||
|
|
||||||
|
项目根目录的 `VERSION.txt` 文件记录当前正式版本号:
|
||||||
|
|
||||||
|
```
|
||||||
|
1.0.1
|
||||||
|
```
|
||||||
|
|
||||||
|
构建正式版时会自动读取此文件。
|
||||||
|
|
||||||
|
## 镜像保留策略
|
||||||
|
|
||||||
|
建议在 Harbor 中配置以下保留策略:
|
||||||
|
|
||||||
|
- `material/`:保留最近 10 个版本
|
||||||
|
- `material-dev/`:保留最近 5 个版本
|
||||||
|
- `runtime/`:保留所有版本(依赖镜像较少)
|
||||||
|
|
||||||
|
## 常用命令
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 登录 Harbor
|
||||||
|
docker login harbor.flnxi.com
|
||||||
|
|
||||||
|
# 查看本地镜像
|
||||||
|
docker images | grep flnxi
|
||||||
|
|
||||||
|
# 删除本地测试镜像
|
||||||
|
docker rmi $(docker images -q harbor.flnxi.com/flnxi/material-dev/*)
|
||||||
|
|
||||||
|
# 拉取最新正式版
|
||||||
|
docker pull harbor.flnxi.com/flnxi/material/flnxi-material:latest
|
||||||
|
```
|
||||||
6
flnxi-agent/.gitignore
vendored
Normal file
6
flnxi-agent/.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
|
frontend/node_modules/
|
||||||
|
frontend/dist/
|
||||||
42
flnxi-agent/Dockerfile
Normal file
42
flnxi-agent/Dockerfile
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
# 多阶段构建 - 前端
|
||||||
|
FROM node:20-alpine AS frontend-builder
|
||||||
|
WORKDIR /app/frontend
|
||||||
|
COPY frontend/package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY frontend/ ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# 多阶段构建 - 后端
|
||||||
|
FROM node:20-alpine AS backend-builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY tsconfig.json ./
|
||||||
|
COPY src/ ./src/
|
||||||
|
# 只编译 TypeScript,不运行 build:frontend(前端已单独构建)
|
||||||
|
RUN npx tsc
|
||||||
|
|
||||||
|
# 最终镜像
|
||||||
|
FROM node:20-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 安装 docker cli(用于与宿主机 docker 通信)
|
||||||
|
RUN apk add --no-cache docker-cli
|
||||||
|
|
||||||
|
# 复制构建产物
|
||||||
|
COPY --from=backend-builder /app/dist ./dist
|
||||||
|
COPY --from=backend-builder /app/node_modules ./node_modules
|
||||||
|
COPY --from=frontend-builder /app/frontend/dist ./dist/public
|
||||||
|
COPY package.json ./
|
||||||
|
|
||||||
|
# 复制版本文件
|
||||||
|
ARG AGENT_VERSION=1.0.0
|
||||||
|
RUN echo "${AGENT_VERSION}" > /app/VERSION.txt
|
||||||
|
|
||||||
|
# 环境变量
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV PORT=3080
|
||||||
|
|
||||||
|
EXPOSE 3080
|
||||||
|
|
||||||
|
CMD ["node", "dist/server.js"]
|
||||||
66
flnxi-agent/README.md
Normal file
66
flnxi-agent/README.md
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
# FLNXI Agent
|
||||||
|
|
||||||
|
FLNXI 素材系统安装助手,用于在 NAS(飞牛 fnOS、群晖等)上一键安装素材系统。
|
||||||
|
|
||||||
|
## 功能
|
||||||
|
|
||||||
|
- 🚀 一键安装素材系统及所有依赖
|
||||||
|
- 📊 实时安装进度显示
|
||||||
|
- 🔄 系统更新和回滚
|
||||||
|
- 📈 系统状态监控
|
||||||
|
- 📝 日志查看
|
||||||
|
|
||||||
|
## 使用方法
|
||||||
|
|
||||||
|
### Docker 安装(推荐)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d \
|
||||||
|
--name flnxi-agent \
|
||||||
|
-p 3080:3080 \
|
||||||
|
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||||
|
flnxi/flnxi-agent:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
然后访问 `http://your-nas-ip:3080` 开始安装。
|
||||||
|
|
||||||
|
### 本地开发
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 安装依赖
|
||||||
|
npm install
|
||||||
|
cd frontend && npm install && cd ..
|
||||||
|
|
||||||
|
# 开发模式
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# 构建
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
## 环境变量
|
||||||
|
|
||||||
|
| 变量 | 说明 | 默认值 |
|
||||||
|
|------|------|--------|
|
||||||
|
| PORT | Agent 服务端口 | 3080 |
|
||||||
|
| LICENSE_SERVER_URL | 许可证服务器地址 | http://45.249.208.118:3001 |
|
||||||
|
|
||||||
|
## 架构
|
||||||
|
|
||||||
|
```
|
||||||
|
flnxi-agent/
|
||||||
|
├── src/ # 后端代码
|
||||||
|
│ ├── server.ts # Express 服务
|
||||||
|
│ ├── installer.ts # 安装逻辑
|
||||||
|
│ └── system-manager.ts # 系统管理
|
||||||
|
├── frontend/ # 前端代码 (React + Vite)
|
||||||
|
│ └── src/
|
||||||
|
│ ├── App.tsx
|
||||||
|
│ └── pages/
|
||||||
|
│ ├── Welcome.tsx
|
||||||
|
│ ├── Config.tsx
|
||||||
|
│ ├── Installing.tsx
|
||||||
|
│ ├── Complete.tsx
|
||||||
|
│ └── Dashboard.tsx
|
||||||
|
└── Dockerfile # 多架构构建
|
||||||
|
```
|
||||||
1
flnxi-agent/VERSION.txt
Normal file
1
flnxi-agent/VERSION.txt
Normal file
@ -0,0 +1 @@
|
|||||||
|
1.0.0
|
||||||
12
flnxi-agent/frontend/index.html
Normal file
12
flnxi-agent/frontend/index.html
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>FLNXI 素材系统安装助手</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
2762
flnxi-agent/frontend/package-lock.json
generated
Normal file
2762
flnxi-agent/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
flnxi-agent/frontend/package.json
Normal file
25
flnxi-agent/frontend/package.json
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "flnxi-agent-frontend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"socket.io-client": "^4.7.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.2.43",
|
||||||
|
"@types/react-dom": "^18.2.17",
|
||||||
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
|
"autoprefixer": "^10.4.16",
|
||||||
|
"postcss": "^8.4.32",
|
||||||
|
"tailwindcss": "^3.3.6",
|
||||||
|
"typescript": "^5.3.2",
|
||||||
|
"vite": "^5.0.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
flnxi-agent/frontend/postcss.config.js
Normal file
6
flnxi-agent/frontend/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
100
flnxi-agent/frontend/src/App.tsx
Normal file
100
flnxi-agent/frontend/src/App.tsx
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { io, Socket } from 'socket.io-client';
|
||||||
|
import Welcome from './pages/Welcome';
|
||||||
|
import Config from './pages/Config';
|
||||||
|
import Installing from './pages/Installing';
|
||||||
|
import Complete from './pages/Complete';
|
||||||
|
import Dashboard from './pages/Dashboard';
|
||||||
|
|
||||||
|
type Step = 'welcome' | 'config' | 'installing' | 'complete' | 'dashboard';
|
||||||
|
|
||||||
|
interface InstallProgress {
|
||||||
|
step: string;
|
||||||
|
progress: number;
|
||||||
|
message: string;
|
||||||
|
detail?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const [step, setStep] = useState<Step>('welcome');
|
||||||
|
const [socket, setSocket] = useState<Socket | null>(null);
|
||||||
|
const [progress, setProgress] = useState<InstallProgress | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [config, setConfig] = useState({
|
||||||
|
webPort: 3000,
|
||||||
|
dataPath: '/volume1/docker/flnxi',
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 检查是否已安装
|
||||||
|
fetch('/api/installed')
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((data) => {
|
||||||
|
if (data.installed) {
|
||||||
|
setStep('dashboard');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
|
||||||
|
// 建立 WebSocket 连接
|
||||||
|
const s = io();
|
||||||
|
setSocket(s);
|
||||||
|
|
||||||
|
s.on('install:progress', (data: InstallProgress) => {
|
||||||
|
setProgress(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
s.on('install:complete', () => {
|
||||||
|
setStep('complete');
|
||||||
|
});
|
||||||
|
|
||||||
|
s.on('install:error', (data: { error: string }) => {
|
||||||
|
setError(data.error);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
s.disconnect();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleStartInstall = async () => {
|
||||||
|
setStep('installing');
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/install', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ config }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setError(data.error || '安装失败');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError('网络错误');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-100">
|
||||||
|
{step === 'welcome' && <Welcome onNext={() => setStep('config')} />}
|
||||||
|
{step === 'config' && (
|
||||||
|
<Config
|
||||||
|
config={config}
|
||||||
|
onChange={setConfig}
|
||||||
|
onBack={() => setStep('welcome')}
|
||||||
|
onNext={handleStartInstall}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{step === 'installing' && (
|
||||||
|
<Installing progress={progress} error={error} onRetry={handleStartInstall} />
|
||||||
|
)}
|
||||||
|
{step === 'complete' && (
|
||||||
|
<Complete port={config.webPort} onDashboard={() => setStep('dashboard')} />
|
||||||
|
)}
|
||||||
|
{step === 'dashboard' && <Dashboard socket={socket} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
flnxi-agent/frontend/src/index.css
Normal file
7
flnxi-agent/frontend/src/index.css
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
}
|
||||||
10
flnxi-agent/frontend/src/main.tsx
Normal file
10
flnxi-agent/frontend/src/main.tsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import App from './App';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
43
flnxi-agent/frontend/src/pages/Complete.tsx
Normal file
43
flnxi-agent/frontend/src/pages/Complete.tsx
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
interface Props {
|
||||||
|
port: number;
|
||||||
|
onDashboard: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Complete({ port, onDashboard }: Props) {
|
||||||
|
const url = `http://${window.location.hostname}:${port}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
<div className="max-w-md w-full bg-white rounded-lg shadow-lg p-8 text-center">
|
||||||
|
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||||
|
<svg className="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="text-xl font-bold text-gray-900 mb-2">安装完成!</h2>
|
||||||
|
<p className="text-gray-600 mb-6">FLNXI 素材系统已成功安装。</p>
|
||||||
|
|
||||||
|
<div className="bg-gray-50 rounded-lg p-4 mb-6">
|
||||||
|
<p className="text-sm text-gray-500 mb-2">访问地址:</p>
|
||||||
|
<a href={url} target="_blank" rel="noopener noreferrer" className="text-blue-600 font-medium hover:underline">
|
||||||
|
{url}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-gray-500 mb-6">
|
||||||
|
首次访问时需要激活许可证或开始免费试用。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button onClick={onDashboard} className="flex-1 py-3 border border-gray-300 text-gray-700 rounded-lg font-medium hover:bg-gray-50">
|
||||||
|
管理面板
|
||||||
|
</button>
|
||||||
|
<a href={url} target="_blank" rel="noopener noreferrer" className="flex-1 py-3 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 text-center">
|
||||||
|
打开素材系统
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
75
flnxi-agent/frontend/src/pages/Config.tsx
Normal file
75
flnxi-agent/frontend/src/pages/Config.tsx
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
interface Props {
|
||||||
|
config: {
|
||||||
|
webPort: number;
|
||||||
|
dataPath: string;
|
||||||
|
};
|
||||||
|
onChange: (config: { webPort: number; dataPath: string }) => void;
|
||||||
|
onBack: () => void;
|
||||||
|
onNext: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Config({ config, onChange, onBack, onNext }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
<div className="max-w-md w-full bg-white rounded-lg shadow-lg p-8">
|
||||||
|
<h2 className="text-xl font-bold text-gray-900 mb-6">配置安装选项</h2>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Web 访问端口
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={config.webPort}
|
||||||
|
onChange={(e) => onChange({ ...config, webPort: parseInt(e.target.value) || 3000 })}
|
||||||
|
className="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
min={1}
|
||||||
|
max={65535}
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-sm text-gray-500">
|
||||||
|
安装完成后通过此端口访问素材系统
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
数据存储路径
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={config.dataPath}
|
||||||
|
onChange={(e) => onChange({ ...config, dataPath: e.target.value })}
|
||||||
|
className="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
placeholder="/volume1/docker/flnxi"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-sm text-gray-500">
|
||||||
|
数据库和上传文件将存储在此目录
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||||
|
<p className="text-sm text-yellow-800">
|
||||||
|
<strong>注意:</strong>请确保存储路径有足够的空间,建议至少 10GB。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 mt-8">
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
className="flex-1 py-3 border border-gray-300 text-gray-700 rounded-lg font-medium hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
返回
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onNext}
|
||||||
|
className="flex-1 py-3 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
开始安装
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
220
flnxi-agent/frontend/src/pages/Dashboard.tsx
Normal file
220
flnxi-agent/frontend/src/pages/Dashboard.tsx
Normal file
@ -0,0 +1,220 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Socket } from 'socket.io-client';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
socket: Socket | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Status {
|
||||||
|
installed: boolean;
|
||||||
|
running: boolean;
|
||||||
|
version: string | null;
|
||||||
|
uptime: number | null;
|
||||||
|
health: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UpdateInfo {
|
||||||
|
available: boolean;
|
||||||
|
currentVersion: string | null;
|
||||||
|
latestVersion: string | null;
|
||||||
|
releaseNotes: string | null;
|
||||||
|
isRequired: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AgentUpdateInfo {
|
||||||
|
available: boolean;
|
||||||
|
currentVersion: string | null;
|
||||||
|
latestVersion: string | null;
|
||||||
|
releaseNotes: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Dashboard({ socket }: Props) {
|
||||||
|
const [status, setStatus] = useState<Status | null>(null);
|
||||||
|
const [update, setUpdate] = useState<UpdateInfo | null>(null);
|
||||||
|
const [updating, setUpdating] = useState(false);
|
||||||
|
const [updateProgress, setUpdateProgress] = useState<{ progress: number; message: string } | null>(null);
|
||||||
|
|
||||||
|
// Agent 更新状态
|
||||||
|
const [agentUpdate, setAgentUpdate] = useState<AgentUpdateInfo | null>(null);
|
||||||
|
const [agentUpdating, setAgentUpdating] = useState(false);
|
||||||
|
const [agentUpdateProgress, setAgentUpdateProgress] = useState<{ progress: number; message: string } | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchStatus();
|
||||||
|
checkUpdate();
|
||||||
|
checkAgentUpdate();
|
||||||
|
|
||||||
|
const interval = setInterval(fetchStatus, 5000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!socket) return;
|
||||||
|
|
||||||
|
// 素材系统更新事件
|
||||||
|
socket.on('update:progress', (data) => setUpdateProgress(data));
|
||||||
|
socket.on('update:complete', () => {
|
||||||
|
setUpdating(false);
|
||||||
|
setUpdateProgress(null);
|
||||||
|
fetchStatus();
|
||||||
|
checkUpdate();
|
||||||
|
});
|
||||||
|
socket.on('update:error', () => {
|
||||||
|
setUpdating(false);
|
||||||
|
setUpdateProgress(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Agent 更新事件
|
||||||
|
socket.on('agent:update:progress', (data) => setAgentUpdateProgress(data));
|
||||||
|
socket.on('agent:update:complete', () => {
|
||||||
|
setAgentUpdating(false);
|
||||||
|
setAgentUpdateProgress({ progress: 100, message: 'Agent 更新完成!页面将在 3 秒后刷新...' });
|
||||||
|
setTimeout(() => window.location.reload(), 3000);
|
||||||
|
});
|
||||||
|
socket.on('agent:update:error', () => {
|
||||||
|
setAgentUpdating(false);
|
||||||
|
setAgentUpdateProgress(null);
|
||||||
|
});
|
||||||
|
}, [socket]);
|
||||||
|
|
||||||
|
const fetchStatus = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/status');
|
||||||
|
setStatus(await res.json());
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkUpdate = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/check-update');
|
||||||
|
setUpdate(await res.json());
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkAgentUpdate = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/agent/check-update');
|
||||||
|
setAgentUpdate(await res.json());
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAgentUpdate = async () => {
|
||||||
|
if (!confirm('确定要更新 Agent 吗?更新过程中页面会断开连接,完成后需要刷新页面。')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAgentUpdating(true);
|
||||||
|
await fetch('/api/agent/update', { method: 'POST' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRestart = async () => {
|
||||||
|
await fetch('/api/restart', { method: 'POST' });
|
||||||
|
setTimeout(fetchStatus, 3000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdate = async () => {
|
||||||
|
setUpdating(true);
|
||||||
|
await fetch('/api/update', { method: 'POST' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatUptime = (seconds: number) => {
|
||||||
|
const d = Math.floor(seconds / 86400);
|
||||||
|
const h = Math.floor((seconds % 86400) / 3600);
|
||||||
|
const m = Math.floor((seconds % 3600) / 60);
|
||||||
|
if (d > 0) return `${d}天 ${h}小时`;
|
||||||
|
if (h > 0) return `${h}小时 ${m}分钟`;
|
||||||
|
return `${m}分钟`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-100 p-8">
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 mb-8">FLNXI 素材系统管理</h1>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-6 mb-8">
|
||||||
|
<div className="bg-white rounded-lg shadow p-6">
|
||||||
|
<h3 className="text-sm font-medium text-gray-500 mb-2">运行状态</h3>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`w-3 h-3 rounded-full ${status?.running ? 'bg-green-500' : 'bg-red-500'}`} />
|
||||||
|
<span className="text-lg font-medium">{status?.running ? '运行中' : '已停止'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg shadow p-6">
|
||||||
|
<h3 className="text-sm font-medium text-gray-500 mb-2">当前版本</h3>
|
||||||
|
<span className="text-lg font-medium">{status?.version || '-'}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg shadow p-6">
|
||||||
|
<h3 className="text-sm font-medium text-gray-500 mb-2">运行时间</h3>
|
||||||
|
<span className="text-lg font-medium">{status?.uptime ? formatUptime(status.uptime) : '-'}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg shadow p-6">
|
||||||
|
<h3 className="text-sm font-medium text-gray-500 mb-2">健康状态</h3>
|
||||||
|
<span className={`text-lg font-medium ${status?.health === 'healthy' ? 'text-green-600' : 'text-yellow-600'}`}>
|
||||||
|
{status?.health === 'healthy' ? '健康' : status?.health === 'unhealthy' ? '异常' : '未知'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Agent 更新提示 */}
|
||||||
|
{agentUpdate?.available && (
|
||||||
|
<div className="rounded-lg shadow p-6 mb-8 bg-purple-50 border border-purple-200">
|
||||||
|
<h3 className="font-medium mb-2">🔧 Agent 安装程序有更新</h3>
|
||||||
|
<p className="text-sm text-gray-600 mb-4">
|
||||||
|
当前版本: {agentUpdate.currentVersion || '未知'} → 最新版本: {agentUpdate.latestVersion}
|
||||||
|
</p>
|
||||||
|
{agentUpdate.releaseNotes && <p className="text-sm text-gray-500 mb-4">{agentUpdate.releaseNotes}</p>}
|
||||||
|
{agentUpdating ? (
|
||||||
|
<div>
|
||||||
|
<div className="w-full bg-gray-200 rounded-full h-2 mb-2">
|
||||||
|
<div className="bg-purple-600 h-2 rounded-full" style={{ width: `${agentUpdateProgress?.progress || 0}%` }} />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-500">{agentUpdateProgress?.message || '更新中...'}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button onClick={handleAgentUpdate} className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700">
|
||||||
|
更新 Agent
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 素材系统更新提示 */}
|
||||||
|
{update?.available && (
|
||||||
|
<div className={`rounded-lg shadow p-6 mb-8 ${update.isRequired ? 'bg-red-50 border border-red-200' : 'bg-blue-50 border border-blue-200'}`}>
|
||||||
|
<h3 className="font-medium mb-2">{update.isRequired ? '⚠️ 需要更新' : '🎉 素材系统有新版本'}</h3>
|
||||||
|
<p className="text-sm text-gray-600 mb-4">
|
||||||
|
当前版本: {update.currentVersion} → 最新版本: {update.latestVersion}
|
||||||
|
</p>
|
||||||
|
{update.releaseNotes && <p className="text-sm text-gray-500 mb-4">{update.releaseNotes}</p>}
|
||||||
|
{updating ? (
|
||||||
|
<div>
|
||||||
|
<div className="w-full bg-gray-200 rounded-full h-2 mb-2">
|
||||||
|
<div className="bg-blue-600 h-2 rounded-full" style={{ width: `${updateProgress?.progress || 0}%` }} />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-500">{updateProgress?.message || '更新中...'}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button onClick={handleUpdate} className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
|
||||||
|
立即更新
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg shadow p-6">
|
||||||
|
<h3 className="font-medium mb-4">操作</h3>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button onClick={handleRestart} className="px-4 py-2 bg-yellow-500 text-white rounded-lg hover:bg-yellow-600">
|
||||||
|
重启服务
|
||||||
|
</button>
|
||||||
|
<button onClick={fetchStatus} className="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50">
|
||||||
|
刷新状态
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
flnxi-agent/frontend/src/pages/Installing.tsx
Normal file
42
flnxi-agent/frontend/src/pages/Installing.tsx
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
interface Props {
|
||||||
|
progress: { step: string; progress: number; message: string; detail?: string } | null;
|
||||||
|
error: string | null;
|
||||||
|
onRetry: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Installing({ progress, error, onRetry }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
<div className="max-w-md w-full bg-white rounded-lg shadow-lg p-8 text-center">
|
||||||
|
<h2 className="text-xl font-bold text-gray-900 mb-6">正在安装</h2>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
|
<svg className="w-8 h-8 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p className="text-red-600 mb-4">{error}</p>
|
||||||
|
<button onClick={onRetry} className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
|
||||||
|
重试
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="w-16 h-16 border-4 border-blue-600 border-t-transparent rounded-full animate-spin mx-auto mb-6" />
|
||||||
|
<p className="text-gray-600 mb-2">{progress?.message || '准备中...'}</p>
|
||||||
|
{progress?.detail && <p className="text-sm text-gray-500 mb-4">{progress.detail}</p>}
|
||||||
|
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||||
|
<div
|
||||||
|
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
||||||
|
style={{ width: `${progress?.progress || 0}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-500 mt-2">{Math.round(progress?.progress || 0)}%</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
62
flnxi-agent/frontend/src/pages/Welcome.tsx
Normal file
62
flnxi-agent/frontend/src/pages/Welcome.tsx
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
interface Props {
|
||||||
|
onNext: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Welcome({ onNext }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
<div className="max-w-md w-full bg-white rounded-lg shadow-lg p-8 text-center">
|
||||||
|
<div className="w-20 h-20 bg-blue-600 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||||
|
<svg className="w-10 h-10 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 mb-2">FLNXI 素材系统</h1>
|
||||||
|
<p className="text-gray-600 mb-8">
|
||||||
|
欢迎使用 FLNXI 素材系统安装助手。<br />
|
||||||
|
只需几步即可完成安装。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-4 text-left mb-8">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="w-6 h-6 bg-blue-100 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||||
|
<span className="text-blue-600 text-sm font-medium">1</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-gray-900">配置端口和存储路径</p>
|
||||||
|
<p className="text-sm text-gray-500">设置 Web 访问端口和数据存储位置</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="w-6 h-6 bg-blue-100 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||||
|
<span className="text-blue-600 text-sm font-medium">2</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-gray-900">自动安装</p>
|
||||||
|
<p className="text-sm text-gray-500">自动拉取镜像并配置服务</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="w-6 h-6 bg-blue-100 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||||
|
<span className="text-blue-600 text-sm font-medium">3</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-gray-900">激活许可证</p>
|
||||||
|
<p className="text-sm text-gray-500">首次访问时激活许可证或开始免费试用</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onNext}
|
||||||
|
className="w-full py-3 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
开始安装
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
8
flnxi-agent/frontend/tailwind.config.js
Normal file
8
flnxi-agent/frontend/tailwind.config.js
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
21
flnxi-agent/frontend/tsconfig.json
Normal file
21
flnxi-agent/frontend/tsconfig.json
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
10
flnxi-agent/frontend/tsconfig.node.json
Normal file
10
flnxi-agent/frontend/tsconfig.node.json
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
15
flnxi-agent/frontend/vite.config.ts
Normal file
15
flnxi-agent/frontend/vite.config.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': 'http://localhost:3080',
|
||||||
|
'/socket.io': {
|
||||||
|
target: 'http://localhost:3080',
|
||||||
|
ws: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
2594
flnxi-agent/package-lock.json
generated
Normal file
2594
flnxi-agent/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
flnxi-agent/package.json
Normal file
30
flnxi-agent/package.json
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "flnxi-agent",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "FLNXI 素材系统安装助手",
|
||||||
|
"main": "dist/server.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/server.ts",
|
||||||
|
"build": "tsc && npm run build:frontend",
|
||||||
|
"build:frontend": "cd frontend && npm run build && (if exist ..\\dist\\public rmdir /s /q ..\\dist\\public) && xcopy /E /I /Y dist ..\\dist\\public",
|
||||||
|
"build:frontend:unix": "cd frontend && npm run build && cp -r dist ../dist/public",
|
||||||
|
"start": "node dist/server.js",
|
||||||
|
"docker:build": "docker buildx build --platform linux/amd64,linux/arm64 -t flnxi/flnxi-agent:latest --push .",
|
||||||
|
"docker:build:local": "docker build -t flnxi/flnxi-agent:latest ."
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"dockerode": "^4.0.2",
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"socket.io": "^4.7.2",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^16.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/cors": "^2.8.17",
|
||||||
|
"@types/dockerode": "^3.3.23",
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/node": "^20.10.0",
|
||||||
|
"tsx": "^4.6.2",
|
||||||
|
"typescript": "^5.3.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
336
flnxi-agent/src/installer.ts
Normal file
336
flnxi-agent/src/installer.ts
Normal file
@ -0,0 +1,336 @@
|
|||||||
|
import Docker from 'dockerode';
|
||||||
|
import { EventEmitter } from 'events';
|
||||||
|
|
||||||
|
export interface InstallConfig {
|
||||||
|
webPort: number; // 素材系统 Web 端口
|
||||||
|
dbPort?: number; // PostgreSQL 端口(可选)
|
||||||
|
minioPort?: number; // MinIO 端口(可选)
|
||||||
|
dataPath: string; // 数据存储路径
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InstallProgress {
|
||||||
|
step: string;
|
||||||
|
progress: number; // 0-100
|
||||||
|
message: string;
|
||||||
|
detail?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProgressCallback = (progress: InstallProgress) => void;
|
||||||
|
|
||||||
|
// Harbor 镜像仓库地址
|
||||||
|
const HARBOR_REGISTRY = 'harbor.flnxi.com/flnxi';
|
||||||
|
|
||||||
|
// 镜像配置 - 优先使用 Harbor 镜像
|
||||||
|
const IMAGES = {
|
||||||
|
// 素材系统主镜像
|
||||||
|
material: `${HARBOR_REGISTRY}/material/flnxi-material:latest`,
|
||||||
|
// 依赖镜像 - 优先 Harbor,备用 Docker Hub
|
||||||
|
postgres: {
|
||||||
|
primary: `${HARBOR_REGISTRY}/runtime/postgres:16-alpine`,
|
||||||
|
fallback: 'postgres:16-alpine',
|
||||||
|
},
|
||||||
|
minio: {
|
||||||
|
primary: `${HARBOR_REGISTRY}/runtime/minio:latest`,
|
||||||
|
fallback: 'minio/minio:latest',
|
||||||
|
},
|
||||||
|
redis: {
|
||||||
|
primary: `${HARBOR_REGISTRY}/runtime/redis:7-alpine`,
|
||||||
|
fallback: 'redis:7-alpine',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// 容器名称
|
||||||
|
const CONTAINERS = {
|
||||||
|
material: 'flnxi-material',
|
||||||
|
postgres: 'flnxi-postgres',
|
||||||
|
minio: 'flnxi-minio',
|
||||||
|
redis: 'flnxi-redis',
|
||||||
|
};
|
||||||
|
|
||||||
|
export class Installer extends EventEmitter {
|
||||||
|
private docker: Docker;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.docker = new Docker({ socketPath: '/var/run/docker.sock' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否已安装
|
||||||
|
*/
|
||||||
|
async isInstalled(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const containers = await this.docker.listContainers({ all: true });
|
||||||
|
return containers.some((c) => c.Names.some((n) => n.includes(CONTAINERS.material)));
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 实际使用的镜像(可能是 Harbor 或 fallback)
|
||||||
|
private usedImages = {
|
||||||
|
postgres: '',
|
||||||
|
minio: '',
|
||||||
|
redis: '',
|
||||||
|
material: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行安装
|
||||||
|
*/
|
||||||
|
async install(config: InstallConfig, onProgress: ProgressCallback): Promise<void> {
|
||||||
|
const { webPort, dbPort = 5432, minioPort = 9000, dataPath } = config;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Step 1: 拉取镜像(优先 Harbor,失败后 fallback 到 Docker Hub)
|
||||||
|
onProgress({ step: 'pull', progress: 0, message: '正在拉取镜像...' });
|
||||||
|
|
||||||
|
this.usedImages.postgres = await this.pullImageWithFallback(IMAGES.postgres, (p) => {
|
||||||
|
onProgress({ step: 'pull', progress: p * 0.2, message: '拉取 PostgreSQL...', detail: `${Math.round(p)}%` });
|
||||||
|
});
|
||||||
|
|
||||||
|
this.usedImages.minio = await this.pullImageWithFallback(IMAGES.minio, (p) => {
|
||||||
|
onProgress({ step: 'pull', progress: 20 + p * 0.2, message: '拉取 MinIO...', detail: `${Math.round(p)}%` });
|
||||||
|
});
|
||||||
|
|
||||||
|
this.usedImages.redis = await this.pullImageWithFallback(IMAGES.redis, (p) => {
|
||||||
|
onProgress({ step: 'pull', progress: 40 + p * 0.1, message: '拉取 Redis...', detail: `${Math.round(p)}%` });
|
||||||
|
});
|
||||||
|
|
||||||
|
this.usedImages.material = await this.pullImageWithFallback(IMAGES.material, (p) => {
|
||||||
|
onProgress({ step: 'pull', progress: 50 + p * 0.3, message: '拉取素材系统...', detail: `${Math.round(p)}%` });
|
||||||
|
});
|
||||||
|
|
||||||
|
onProgress({ step: 'pull', progress: 80, message: '镜像拉取完成' });
|
||||||
|
|
||||||
|
// Step 2: 创建网络
|
||||||
|
onProgress({ step: 'network', progress: 82, message: '创建 Docker 网络...' });
|
||||||
|
await this.createNetwork('flnxi-network');
|
||||||
|
|
||||||
|
// Step 3: 启动容器
|
||||||
|
onProgress({ step: 'containers', progress: 85, message: '启动 PostgreSQL...' });
|
||||||
|
await this.startPostgres(dataPath, dbPort);
|
||||||
|
|
||||||
|
onProgress({ step: 'containers', progress: 88, message: '启动 MinIO...' });
|
||||||
|
await this.startMinio(dataPath, minioPort);
|
||||||
|
|
||||||
|
onProgress({ step: 'containers', progress: 91, message: '启动 Redis...' });
|
||||||
|
await this.startRedis();
|
||||||
|
|
||||||
|
onProgress({ step: 'containers', progress: 94, message: '启动素材系统...' });
|
||||||
|
await this.startMaterial(webPort, dataPath);
|
||||||
|
|
||||||
|
// Step 4: 等待服务就绪
|
||||||
|
onProgress({ step: 'ready', progress: 97, message: '等待服务就绪...' });
|
||||||
|
await this.waitForService(`http://localhost:${webPort}/api/health`, 60000);
|
||||||
|
|
||||||
|
onProgress({ step: 'complete', progress: 100, message: '安装完成!' });
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拉取镜像(支持 fallback)
|
||||||
|
*/
|
||||||
|
private async pullImage(image: string, onProgress: (percent: number) => void): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.docker.pull(image, (err: Error | null, stream: NodeJS.ReadableStream) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastProgress = 0;
|
||||||
|
|
||||||
|
this.docker.modem.followProgress(
|
||||||
|
stream,
|
||||||
|
(err: Error | null) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve();
|
||||||
|
},
|
||||||
|
(event: { status: string; progressDetail?: { current?: number; total?: number } }) => {
|
||||||
|
if (event.progressDetail?.total) {
|
||||||
|
const percent = (event.progressDetail.current || 0) / event.progressDetail.total * 100;
|
||||||
|
if (percent > lastProgress) {
|
||||||
|
lastProgress = percent;
|
||||||
|
onProgress(percent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拉取镜像(带 fallback 支持)
|
||||||
|
* 优先从 Harbor 拉取,失败后尝试 Docker Hub
|
||||||
|
*/
|
||||||
|
private async pullImageWithFallback(
|
||||||
|
imageConfig: { primary: string; fallback: string } | string,
|
||||||
|
onProgress: (percent: number) => void
|
||||||
|
): Promise<string> {
|
||||||
|
// 如果是字符串,直接拉取
|
||||||
|
if (typeof imageConfig === 'string') {
|
||||||
|
await this.pullImage(imageConfig, onProgress);
|
||||||
|
return imageConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试从 Harbor 拉取
|
||||||
|
try {
|
||||||
|
await this.pullImage(imageConfig.primary, onProgress);
|
||||||
|
return imageConfig.primary;
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`Harbor 镜像拉取失败,尝试 Docker Hub: ${imageConfig.fallback}`);
|
||||||
|
// 回退到 Docker Hub
|
||||||
|
await this.pullImage(imageConfig.fallback, onProgress);
|
||||||
|
return imageConfig.fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建 Docker 网络
|
||||||
|
*/
|
||||||
|
private async createNetwork(name: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const networks = await this.docker.listNetworks();
|
||||||
|
if (!networks.some((n) => n.Name === name)) {
|
||||||
|
await this.docker.createNetwork({ Name: name });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 网络可能已存在
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动 PostgreSQL
|
||||||
|
*/
|
||||||
|
private async startPostgres(dataPath: string, port: number): Promise<void> {
|
||||||
|
await this.removeContainer(CONTAINERS.postgres);
|
||||||
|
|
||||||
|
const container = await this.docker.createContainer({
|
||||||
|
Image: this.usedImages.postgres,
|
||||||
|
name: CONTAINERS.postgres,
|
||||||
|
Env: [
|
||||||
|
'POSTGRES_USER=flnxi',
|
||||||
|
'POSTGRES_PASSWORD=flnxi_secret',
|
||||||
|
'POSTGRES_DB=flnxi_material',
|
||||||
|
],
|
||||||
|
HostConfig: {
|
||||||
|
Binds: [`${dataPath}/postgres:/var/lib/postgresql/data`],
|
||||||
|
PortBindings: { '5432/tcp': [{ HostPort: String(port) }] },
|
||||||
|
NetworkMode: 'flnxi-network',
|
||||||
|
RestartPolicy: { Name: 'unless-stopped' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await container.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动 MinIO
|
||||||
|
*/
|
||||||
|
private async startMinio(dataPath: string, port: number): Promise<void> {
|
||||||
|
await this.removeContainer(CONTAINERS.minio);
|
||||||
|
|
||||||
|
const container = await this.docker.createContainer({
|
||||||
|
Image: this.usedImages.minio,
|
||||||
|
name: CONTAINERS.minio,
|
||||||
|
Cmd: ['server', '/data', '--console-address', ':9001'],
|
||||||
|
Env: [
|
||||||
|
'MINIO_ROOT_USER=flnxi',
|
||||||
|
'MINIO_ROOT_PASSWORD=flnxi_secret',
|
||||||
|
],
|
||||||
|
HostConfig: {
|
||||||
|
Binds: [`${dataPath}/minio:/data`],
|
||||||
|
PortBindings: {
|
||||||
|
'9000/tcp': [{ HostPort: String(port) }],
|
||||||
|
'9001/tcp': [{ HostPort: String(port + 1) }],
|
||||||
|
},
|
||||||
|
NetworkMode: 'flnxi-network',
|
||||||
|
RestartPolicy: { Name: 'unless-stopped' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await container.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动 Redis
|
||||||
|
*/
|
||||||
|
private async startRedis(): Promise<void> {
|
||||||
|
await this.removeContainer(CONTAINERS.redis);
|
||||||
|
|
||||||
|
const container = await this.docker.createContainer({
|
||||||
|
Image: this.usedImages.redis,
|
||||||
|
name: CONTAINERS.redis,
|
||||||
|
HostConfig: {
|
||||||
|
NetworkMode: 'flnxi-network',
|
||||||
|
RestartPolicy: { Name: 'unless-stopped' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await container.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动素材系统
|
||||||
|
*/
|
||||||
|
private async startMaterial(port: number, dataPath: string): Promise<void> {
|
||||||
|
await this.removeContainer(CONTAINERS.material);
|
||||||
|
|
||||||
|
const container = await this.docker.createContainer({
|
||||||
|
Image: this.usedImages.material,
|
||||||
|
name: CONTAINERS.material,
|
||||||
|
Env: [
|
||||||
|
'DATABASE_URL=postgresql://flnxi:flnxi_secret@flnxi-postgres:5432/flnxi_material',
|
||||||
|
'S3_ENDPOINT=http://flnxi-minio:9000',
|
||||||
|
'S3_ACCESS_KEY=flnxi',
|
||||||
|
'S3_SECRET_KEY=flnxi_secret',
|
||||||
|
'S3_BUCKET=materials',
|
||||||
|
'REDIS_URL=redis://flnxi-redis:6379',
|
||||||
|
'NEXTAUTH_SECRET=flnxi-material-secret-key',
|
||||||
|
`NEXTAUTH_URL=http://localhost:${port}`,
|
||||||
|
],
|
||||||
|
HostConfig: {
|
||||||
|
Binds: [`${dataPath}/uploads:/app/uploads`],
|
||||||
|
PortBindings: { '3000/tcp': [{ HostPort: String(port) }] },
|
||||||
|
NetworkMode: 'flnxi-network',
|
||||||
|
RestartPolicy: { Name: 'unless-stopped' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await container.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移除容器
|
||||||
|
*/
|
||||||
|
private async removeContainer(name: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const container = this.docker.getContainer(name);
|
||||||
|
await container.stop().catch(() => {});
|
||||||
|
await container.remove().catch(() => {});
|
||||||
|
} catch {
|
||||||
|
// 容器可能不存在
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 等待服务就绪
|
||||||
|
*/
|
||||||
|
private async waitForService(url: string, timeout: number): Promise<void> {
|
||||||
|
const start = Date.now();
|
||||||
|
while (Date.now() - start < timeout) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (res.ok) return;
|
||||||
|
} catch {
|
||||||
|
// 继续等待
|
||||||
|
}
|
||||||
|
await new Promise((r) => setTimeout(r, 2000));
|
||||||
|
}
|
||||||
|
throw new Error('服务启动超时');
|
||||||
|
}
|
||||||
|
}
|
||||||
185
flnxi-agent/src/server.ts
Normal file
185
flnxi-agent/src/server.ts
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
import express, { Request, Response } from 'express';
|
||||||
|
import { createServer } from 'http';
|
||||||
|
import { Server, Socket } from 'socket.io';
|
||||||
|
import cors from 'cors';
|
||||||
|
import path from 'path';
|
||||||
|
import { Installer, InstallProgress } from './installer';
|
||||||
|
import { SystemManager, UpdateProgress } from './system-manager';
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const httpServer = createServer(app);
|
||||||
|
const io = new Server(httpServer, {
|
||||||
|
cors: {
|
||||||
|
origin: '*',
|
||||||
|
methods: ['GET', 'POST'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const PORT = process.env.PORT || 3080;
|
||||||
|
const installer = new Installer();
|
||||||
|
const systemManager = new SystemManager();
|
||||||
|
|
||||||
|
// 中间件
|
||||||
|
app.use(cors());
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
// 静态文件服务(前端)
|
||||||
|
app.use(express.static(path.join(__dirname, 'public')));
|
||||||
|
|
||||||
|
// API 路由
|
||||||
|
|
||||||
|
// 获取系统状态
|
||||||
|
app.get('/api/status', async (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const status = await systemManager.getStatus();
|
||||||
|
res.json(status);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: '获取状态失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 检查是否已安装
|
||||||
|
app.get('/api/installed', async (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const installed = await installer.isInstalled();
|
||||||
|
res.json({ installed });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: '检查安装状态失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 开始安装
|
||||||
|
app.post('/api/install', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { config } = req.body;
|
||||||
|
|
||||||
|
// 异步执行安装,通过 WebSocket 推送进度
|
||||||
|
installer.install(config, (progress: InstallProgress) => {
|
||||||
|
io.emit('install:progress', progress);
|
||||||
|
}).then(() => {
|
||||||
|
io.emit('install:complete', { success: true });
|
||||||
|
}).catch((err: Error) => {
|
||||||
|
io.emit('install:error', { error: err.message });
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ success: true, message: '安装已开始' });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: '启动安装失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 检查更新
|
||||||
|
app.get('/api/check-update', async (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const update = await systemManager.checkUpdate();
|
||||||
|
res.json(update);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: '检查更新失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 执行更新
|
||||||
|
app.post('/api/update', async (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
systemManager.update((progress: UpdateProgress) => {
|
||||||
|
io.emit('update:progress', progress);
|
||||||
|
}).then(() => {
|
||||||
|
io.emit('update:complete', { success: true });
|
||||||
|
}).catch((err: Error) => {
|
||||||
|
io.emit('update:error', { error: err.message });
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ success: true, message: '更新已开始' });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: '启动更新失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 重启服务
|
||||||
|
app.post('/api/restart', async (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
await systemManager.restart();
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: '重启失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 停止服务
|
||||||
|
app.post('/api/stop', async (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
await systemManager.stop();
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: '停止失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取日志
|
||||||
|
app.get('/api/logs', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const lines = parseInt(req.query.lines as string) || 100;
|
||||||
|
const logs = await systemManager.getLogs(lines);
|
||||||
|
res.json({ logs });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: '获取日志失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==================== Agent 自更新 API ====================
|
||||||
|
|
||||||
|
// 获取 Agent 版本
|
||||||
|
app.get('/api/agent/version', async (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const version = systemManager.getAgentVersion();
|
||||||
|
res.json({ version });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: '获取 Agent 版本失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 检查 Agent 更新
|
||||||
|
app.get('/api/agent/check-update', async (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const update = await systemManager.checkAgentUpdate();
|
||||||
|
res.json(update);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: '检查 Agent 更新失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 更新 Agent
|
||||||
|
app.post('/api/agent/update', async (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
systemManager.updateAgent((progress: UpdateProgress) => {
|
||||||
|
io.emit('agent:update:progress', progress);
|
||||||
|
}).then(() => {
|
||||||
|
io.emit('agent:update:complete', { success: true });
|
||||||
|
}).catch((err: Error) => {
|
||||||
|
io.emit('agent:update:error', { error: err.message });
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ success: true, message: 'Agent 更新已开始' });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: '启动 Agent 更新失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// WebSocket 连接
|
||||||
|
io.on('connection', (socket: Socket) => {
|
||||||
|
console.log('客户端已连接:', socket.id);
|
||||||
|
|
||||||
|
socket.on('disconnect', () => {
|
||||||
|
console.log('客户端已断开:', socket.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 前端路由回退(SPA)
|
||||||
|
app.get('*', (_req: Request, res: Response) => {
|
||||||
|
res.sendFile(path.join(__dirname, 'public', 'index.html'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// 启动服务器
|
||||||
|
httpServer.listen(PORT, () => {
|
||||||
|
console.log(`🚀 FLNXI Agent 运行在 http://localhost:${PORT}`);
|
||||||
|
});
|
||||||
455
flnxi-agent/src/system-manager.ts
Normal file
455
flnxi-agent/src/system-manager.ts
Normal file
@ -0,0 +1,455 @@
|
|||||||
|
import Docker from 'dockerode';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
const LICENSE_SERVER_URL = process.env.LICENSE_SERVER_URL || 'http://45.249.208.118:3001';
|
||||||
|
const CONTAINER_NAME = 'flnxi-material';
|
||||||
|
const AGENT_CONTAINER_NAME = 'flnxi-agent';
|
||||||
|
|
||||||
|
// Harbor 镜像仓库地址
|
||||||
|
const HARBOR_REGISTRY = 'harbor.flnxi.com/flnxi';
|
||||||
|
const MATERIAL_IMAGE = `${HARBOR_REGISTRY}/material/flnxi-material:latest`;
|
||||||
|
|
||||||
|
// Agent 镜像(Docker Hub)
|
||||||
|
const AGENT_IMAGE = 'flnxi/flnxi-agent:latest';
|
||||||
|
|
||||||
|
// Agent 版本文件
|
||||||
|
const AGENT_VERSION_FILE = '/app/VERSION.txt';
|
||||||
|
|
||||||
|
export interface SystemStatus {
|
||||||
|
installed: boolean;
|
||||||
|
running: boolean;
|
||||||
|
version: string | null;
|
||||||
|
uptime: number | null;
|
||||||
|
health: 'healthy' | 'unhealthy' | 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateInfo {
|
||||||
|
available: boolean;
|
||||||
|
currentVersion: string | null;
|
||||||
|
latestVersion: string | null;
|
||||||
|
releaseNotes: string | null;
|
||||||
|
isRequired: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentUpdateInfo {
|
||||||
|
available: boolean;
|
||||||
|
currentVersion: string | null;
|
||||||
|
latestVersion: string | null;
|
||||||
|
releaseNotes: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateProgress {
|
||||||
|
step: string;
|
||||||
|
progress: number;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProgressCallback = (progress: UpdateProgress) => void;
|
||||||
|
|
||||||
|
export class SystemManager {
|
||||||
|
private docker: Docker;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.docker = new Docker({ socketPath: '/var/run/docker.sock' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取系统状态
|
||||||
|
*/
|
||||||
|
async getStatus(): Promise<SystemStatus> {
|
||||||
|
try {
|
||||||
|
const container = this.docker.getContainer(CONTAINER_NAME);
|
||||||
|
const info = await container.inspect();
|
||||||
|
|
||||||
|
const running = info.State.Running;
|
||||||
|
const version = await this.getVersion();
|
||||||
|
const uptime = running
|
||||||
|
? Math.floor((Date.now() - new Date(info.State.StartedAt).getTime()) / 1000)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
let health: 'healthy' | 'unhealthy' | 'unknown' = 'unknown';
|
||||||
|
if (info.State.Health) {
|
||||||
|
health = info.State.Health.Status === 'healthy' ? 'healthy' : 'unhealthy';
|
||||||
|
} else if (running) {
|
||||||
|
health = 'healthy';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
installed: true,
|
||||||
|
running,
|
||||||
|
version,
|
||||||
|
uptime,
|
||||||
|
health,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
installed: false,
|
||||||
|
running: false,
|
||||||
|
version: null,
|
||||||
|
uptime: null,
|
||||||
|
health: 'unknown',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前版本
|
||||||
|
*/
|
||||||
|
private async getVersion(): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const container = this.docker.getContainer(CONTAINER_NAME);
|
||||||
|
const exec = await container.exec({
|
||||||
|
Cmd: ['cat', '/app/VERSION.txt'],
|
||||||
|
AttachStdout: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const stream = await exec.start({ hijack: true, stdin: false });
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let output = '';
|
||||||
|
stream.on('data', (chunk: Buffer) => {
|
||||||
|
output += chunk.toString();
|
||||||
|
});
|
||||||
|
stream.on('end', () => {
|
||||||
|
resolve(output.trim() || null);
|
||||||
|
});
|
||||||
|
stream.on('error', () => resolve(null));
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查更新
|
||||||
|
*/
|
||||||
|
async checkUpdate(): Promise<UpdateInfo> {
|
||||||
|
try {
|
||||||
|
const currentVersion = await this.getVersion();
|
||||||
|
|
||||||
|
const res = await fetch(`${LICENSE_SERVER_URL}/api/version/check`);
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error('检查更新失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json() as {
|
||||||
|
latestVersion: string;
|
||||||
|
minRequiredVersion?: string;
|
||||||
|
releaseNotes?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const available = currentVersion !== data.latestVersion;
|
||||||
|
const isRequired = data.minRequiredVersion
|
||||||
|
? this.compareVersions(currentVersion || '0.0.0', data.minRequiredVersion) < 0
|
||||||
|
: false;
|
||||||
|
|
||||||
|
return {
|
||||||
|
available,
|
||||||
|
currentVersion,
|
||||||
|
latestVersion: data.latestVersion,
|
||||||
|
releaseNotes: data.releaseNotes || null,
|
||||||
|
isRequired,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
available: false,
|
||||||
|
currentVersion: await this.getVersion(),
|
||||||
|
latestVersion: null,
|
||||||
|
releaseNotes: null,
|
||||||
|
isRequired: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行更新
|
||||||
|
*/
|
||||||
|
async update(onProgress: ProgressCallback): Promise<void> {
|
||||||
|
// 使用 Harbor 镜像
|
||||||
|
const IMAGE = MATERIAL_IMAGE;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Step 1: 拉取新镜像
|
||||||
|
onProgress({ step: 'pull', progress: 0, message: '正在拉取新版本...' });
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
this.docker.pull(IMAGE, (err: Error | null, stream: NodeJS.ReadableStream) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.docker.modem.followProgress(
|
||||||
|
stream,
|
||||||
|
(err: Error | null) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve();
|
||||||
|
},
|
||||||
|
(event: { progressDetail?: { current?: number; total?: number } }) => {
|
||||||
|
if (event.progressDetail?.total) {
|
||||||
|
const percent = (event.progressDetail.current || 0) / event.progressDetail.total * 100;
|
||||||
|
onProgress({ step: 'pull', progress: percent * 0.6, message: `下载中... ${Math.round(percent)}%` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
onProgress({ step: 'backup', progress: 65, message: '备份当前配置...' });
|
||||||
|
|
||||||
|
// Step 2: 获取当前容器配置
|
||||||
|
const container = this.docker.getContainer(CONTAINER_NAME);
|
||||||
|
const info = await container.inspect();
|
||||||
|
|
||||||
|
// Step 3: 停止并重命名旧容器
|
||||||
|
onProgress({ step: 'stop', progress: 70, message: '停止当前服务...' });
|
||||||
|
await container.stop().catch(() => {});
|
||||||
|
await container.rename(`${CONTAINER_NAME}-backup-${Date.now()}`).catch(() => {});
|
||||||
|
|
||||||
|
// Step 4: 创建新容器
|
||||||
|
onProgress({ step: 'create', progress: 80, message: '创建新容器...' });
|
||||||
|
const newContainer = await this.docker.createContainer({
|
||||||
|
Image: IMAGE,
|
||||||
|
name: CONTAINER_NAME,
|
||||||
|
Env: info.Config.Env,
|
||||||
|
HostConfig: info.HostConfig,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 5: 启动新容器
|
||||||
|
onProgress({ step: 'start', progress: 90, message: '启动新服务...' });
|
||||||
|
await newContainer.start();
|
||||||
|
|
||||||
|
// Step 6: 健康检查
|
||||||
|
onProgress({ step: 'health', progress: 95, message: '检查服务状态...' });
|
||||||
|
await this.waitForHealthy(30000);
|
||||||
|
|
||||||
|
onProgress({ step: 'complete', progress: 100, message: '更新完成!' });
|
||||||
|
} catch (error) {
|
||||||
|
// 回滚
|
||||||
|
try {
|
||||||
|
const backupContainers = await this.docker.listContainers({
|
||||||
|
all: true,
|
||||||
|
filters: { name: [`${CONTAINER_NAME}-backup`] },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (backupContainers.length > 0) {
|
||||||
|
const backup = this.docker.getContainer(backupContainers[0].Id);
|
||||||
|
await this.docker.getContainer(CONTAINER_NAME).remove({ force: true }).catch(() => {});
|
||||||
|
await backup.rename(CONTAINER_NAME);
|
||||||
|
await backup.start();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 回滚失败
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重启服务
|
||||||
|
*/
|
||||||
|
async restart(): Promise<void> {
|
||||||
|
const container = this.docker.getContainer(CONTAINER_NAME);
|
||||||
|
await container.restart();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 停止服务
|
||||||
|
*/
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
const container = this.docker.getContainer(CONTAINER_NAME);
|
||||||
|
await container.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取日志
|
||||||
|
*/
|
||||||
|
async getLogs(lines: number = 100): Promise<string> {
|
||||||
|
try {
|
||||||
|
const container = this.docker.getContainer(CONTAINER_NAME);
|
||||||
|
const logs = await container.logs({
|
||||||
|
stdout: true,
|
||||||
|
stderr: true,
|
||||||
|
tail: lines,
|
||||||
|
});
|
||||||
|
|
||||||
|
return logs.toString();
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 等待服务健康
|
||||||
|
*/
|
||||||
|
private async waitForHealthy(timeout: number): Promise<void> {
|
||||||
|
const start = Date.now();
|
||||||
|
while (Date.now() - start < timeout) {
|
||||||
|
const status = await this.getStatus();
|
||||||
|
if (status.running && status.health === 'healthy') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await new Promise((r) => setTimeout(r, 2000));
|
||||||
|
}
|
||||||
|
throw new Error('服务启动超时');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 比较版本号
|
||||||
|
*/
|
||||||
|
private compareVersions(v1: string, v2: string): number {
|
||||||
|
const parts1 = v1.split('.').map(Number);
|
||||||
|
const parts2 = v2.split('.').map(Number);
|
||||||
|
|
||||||
|
for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) {
|
||||||
|
const p1 = parts1[i] || 0;
|
||||||
|
const p2 = parts2[i] || 0;
|
||||||
|
if (p1 > p2) return 1;
|
||||||
|
if (p1 < p2) return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Agent 自更新功能 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 Agent 当前版本
|
||||||
|
*/
|
||||||
|
getAgentVersion(): string | null {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(AGENT_VERSION_FILE)) {
|
||||||
|
return fs.readFileSync(AGENT_VERSION_FILE, 'utf-8').trim();
|
||||||
|
}
|
||||||
|
// 开发环境下读取本地版本文件
|
||||||
|
const localVersionFile = path.join(__dirname, '..', 'VERSION.txt');
|
||||||
|
if (fs.existsSync(localVersionFile)) {
|
||||||
|
return fs.readFileSync(localVersionFile, 'utf-8').trim();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查 Agent 更新
|
||||||
|
*/
|
||||||
|
async checkAgentUpdate(): Promise<AgentUpdateInfo> {
|
||||||
|
try {
|
||||||
|
const currentVersion = this.getAgentVersion();
|
||||||
|
|
||||||
|
const res = await fetch(`${LICENSE_SERVER_URL}/api/version/agent`);
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error('检查 Agent 更新失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json() as {
|
||||||
|
latestVersion: string;
|
||||||
|
releaseNotes?: string;
|
||||||
|
};
|
||||||
|
const available = currentVersion !== data.latestVersion;
|
||||||
|
|
||||||
|
return {
|
||||||
|
available,
|
||||||
|
currentVersion,
|
||||||
|
latestVersion: data.latestVersion,
|
||||||
|
releaseNotes: data.releaseNotes || null,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
available: false,
|
||||||
|
currentVersion: this.getAgentVersion(),
|
||||||
|
latestVersion: null,
|
||||||
|
releaseNotes: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新 Agent 自身
|
||||||
|
* 注意:此操作会导致当前 Agent 容器重启,连接会断开
|
||||||
|
*/
|
||||||
|
async updateAgent(onProgress: ProgressCallback): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Step 1: 拉取新的 Agent 镜像
|
||||||
|
onProgress({ step: 'pull', progress: 0, message: '正在拉取新版 Agent...' });
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
this.docker.pull(AGENT_IMAGE, (err: Error | null, stream: NodeJS.ReadableStream) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.docker.modem.followProgress(
|
||||||
|
stream,
|
||||||
|
(err: Error | null) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve();
|
||||||
|
},
|
||||||
|
(event: { progressDetail?: { current?: number; total?: number } }) => {
|
||||||
|
if (event.progressDetail?.total) {
|
||||||
|
const percent = (event.progressDetail.current || 0) / event.progressDetail.total * 100;
|
||||||
|
onProgress({ step: 'pull', progress: percent * 0.7, message: `下载中... ${Math.round(percent)}%` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
onProgress({ step: 'prepare', progress: 75, message: '准备重启 Agent...' });
|
||||||
|
|
||||||
|
// Step 2: 获取当前 Agent 容器信息
|
||||||
|
const container = this.docker.getContainer(AGENT_CONTAINER_NAME);
|
||||||
|
const info = await container.inspect();
|
||||||
|
|
||||||
|
// Step 3: 创建新容器(使用相同配置)
|
||||||
|
onProgress({ step: 'create', progress: 80, message: '创建新 Agent 容器...' });
|
||||||
|
|
||||||
|
const newContainer = await this.docker.createContainer({
|
||||||
|
Image: AGENT_IMAGE,
|
||||||
|
name: `${AGENT_CONTAINER_NAME}-new`,
|
||||||
|
Env: info.Config.Env,
|
||||||
|
HostConfig: info.HostConfig,
|
||||||
|
ExposedPorts: info.Config.ExposedPorts,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 4: 停止当前容器并重命名
|
||||||
|
onProgress({ step: 'switch', progress: 85, message: '切换到新版本...' });
|
||||||
|
|
||||||
|
await container.stop().catch(() => {});
|
||||||
|
await container.rename(`${AGENT_CONTAINER_NAME}-old-${Date.now()}`).catch(() => {});
|
||||||
|
await newContainer.rename(AGENT_CONTAINER_NAME);
|
||||||
|
|
||||||
|
// Step 5: 启动新容器
|
||||||
|
onProgress({ step: 'start', progress: 95, message: '启动新 Agent...' });
|
||||||
|
await newContainer.start();
|
||||||
|
|
||||||
|
onProgress({ step: 'complete', progress: 100, message: 'Agent 更新完成!请刷新页面。' });
|
||||||
|
|
||||||
|
// 注意:此时当前进程即将被终止,因为容器会被替换
|
||||||
|
} catch (error) {
|
||||||
|
// 尝试回滚
|
||||||
|
try {
|
||||||
|
const oldContainers = await this.docker.listContainers({
|
||||||
|
all: true,
|
||||||
|
filters: { name: [`${AGENT_CONTAINER_NAME}-old`] },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (oldContainers.length > 0) {
|
||||||
|
const oldContainer = this.docker.getContainer(oldContainers[0].Id);
|
||||||
|
await this.docker.getContainer(`${AGENT_CONTAINER_NAME}-new`).remove({ force: true }).catch(() => {});
|
||||||
|
await oldContainer.rename(AGENT_CONTAINER_NAME);
|
||||||
|
await oldContainer.start();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 回滚失败
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
21
flnxi-agent/tsconfig.json
Normal file
21
flnxi-agent/tsconfig.json
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "commonjs",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"lib": ["ES2020"],
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"baseUrl": "./src"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist", "frontend"]
|
||||||
|
}
|
||||||
19
instrumentation.ts
Normal file
19
instrumentation.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* Next.js Instrumentation
|
||||||
|
* 在服务器启动时执行初始化代码
|
||||||
|
*/
|
||||||
|
|
||||||
|
export async function register() {
|
||||||
|
// 只在服务端运行
|
||||||
|
if (process.env.NEXT_RUNTIME === 'nodejs') {
|
||||||
|
const { initLicenseService } = await import('@/lib/license');
|
||||||
|
|
||||||
|
// 初始化许可证服务(验证许可证并启动心跳)
|
||||||
|
try {
|
||||||
|
await initLicenseService();
|
||||||
|
console.log('✅ 许可证服务已初始化');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ 许可证服务初始化失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
190
lib/__tests__/check-limits.test.ts
Normal file
190
lib/__tests__/check-limits.test.ts
Normal file
@ -0,0 +1,190 @@
|
|||||||
|
/**
|
||||||
|
* 许可证限制检查工具测试
|
||||||
|
* 测试 lib/license/check-limits.ts 中的限制检查函数
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
canCreateUser,
|
||||||
|
canUploadMaterial,
|
||||||
|
canUseCustomTemplates,
|
||||||
|
canUseDepartmentIsolation,
|
||||||
|
canUseIntranetPenetration,
|
||||||
|
canUseSystemSettings,
|
||||||
|
canUseApiAccess,
|
||||||
|
LimitCheckResult,
|
||||||
|
} from '../license/check-limits';
|
||||||
|
|
||||||
|
// Mock license-service 模块
|
||||||
|
jest.mock('../license/license-service', () => ({
|
||||||
|
checkLimit: jest.fn(),
|
||||||
|
isFeatureEnabled: jest.fn(),
|
||||||
|
getLicenseType: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { checkLimit, isFeatureEnabled, getLicenseType } from '../license/license-service';
|
||||||
|
|
||||||
|
const mockCheckLimit = checkLimit as jest.MockedFunction<typeof checkLimit>;
|
||||||
|
const mockIsFeatureEnabled = isFeatureEnabled as jest.MockedFunction<typeof isFeatureEnabled>;
|
||||||
|
const mockGetLicenseType = getLicenseType as jest.MockedFunction<typeof getLicenseType>;
|
||||||
|
|
||||||
|
describe('canCreateUser', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('当未达到限制时应该返回 allowed: true', async () => {
|
||||||
|
mockCheckLimit.mockResolvedValue({ allowed: true, current: 5, max: 10 });
|
||||||
|
|
||||||
|
const result = await canCreateUser();
|
||||||
|
|
||||||
|
expect(result.allowed).toBe(true);
|
||||||
|
expect(result.current).toBe(5);
|
||||||
|
expect(result.max).toBe(10);
|
||||||
|
expect(result.message).toBeUndefined();
|
||||||
|
expect(mockCheckLimit).toHaveBeenCalledWith('users');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('当达到限制时(FREE 版本)应该返回升级提示', async () => {
|
||||||
|
mockCheckLimit.mockResolvedValue({ allowed: false, current: 10, max: 10 });
|
||||||
|
mockGetLicenseType.mockReturnValue('FREE');
|
||||||
|
|
||||||
|
const result = await canCreateUser();
|
||||||
|
|
||||||
|
expect(result.allowed).toBe(false);
|
||||||
|
expect(result.message).toContain('免费版');
|
||||||
|
expect(result.message).toContain('10');
|
||||||
|
expect(result.message).toContain('升级');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('当达到限制时(非 FREE 版本)应该返回联系管理员提示', async () => {
|
||||||
|
mockCheckLimit.mockResolvedValue({ allowed: false, current: 100, max: 100 });
|
||||||
|
mockGetLicenseType.mockReturnValue('STANDARD');
|
||||||
|
|
||||||
|
const result = await canCreateUser();
|
||||||
|
|
||||||
|
expect(result.allowed).toBe(false);
|
||||||
|
expect(result.message).toContain('联系管理员');
|
||||||
|
expect(result.message).toContain('100');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('canUploadMaterial', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('当未达到限制时应该返回 allowed: true', async () => {
|
||||||
|
mockCheckLimit.mockResolvedValue({ allowed: true, current: 50, max: 100 });
|
||||||
|
|
||||||
|
const result = await canUploadMaterial();
|
||||||
|
|
||||||
|
expect(result.allowed).toBe(true);
|
||||||
|
expect(result.current).toBe(50);
|
||||||
|
expect(result.max).toBe(100);
|
||||||
|
expect(mockCheckLimit).toHaveBeenCalledWith('materials');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('当达到限制时(FREE 版本)应该返回升级提示', async () => {
|
||||||
|
mockCheckLimit.mockResolvedValue({ allowed: false, current: 100, max: 100 });
|
||||||
|
mockGetLicenseType.mockReturnValue('FREE');
|
||||||
|
|
||||||
|
const result = await canUploadMaterial();
|
||||||
|
|
||||||
|
expect(result.allowed).toBe(false);
|
||||||
|
expect(result.message).toContain('免费版');
|
||||||
|
expect(result.message).toContain('素材');
|
||||||
|
expect(result.message).toContain('升级');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('当达到限制时(非 FREE 版本)应该返回联系管理员提示', async () => {
|
||||||
|
mockCheckLimit.mockResolvedValue({ allowed: false, current: 1000, max: 1000 });
|
||||||
|
mockGetLicenseType.mockReturnValue('PRO');
|
||||||
|
|
||||||
|
const result = await canUploadMaterial();
|
||||||
|
|
||||||
|
expect(result.allowed).toBe(false);
|
||||||
|
expect(result.message).toContain('联系管理员');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('功能开关检查', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('canUseCustomTemplates', () => {
|
||||||
|
it('当功能启用时应该返回 true', () => {
|
||||||
|
mockIsFeatureEnabled.mockReturnValue(true);
|
||||||
|
expect(canUseCustomTemplates()).toBe(true);
|
||||||
|
expect(mockIsFeatureEnabled).toHaveBeenCalledWith('customTemplates');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('当功能禁用时应该返回 false', () => {
|
||||||
|
mockIsFeatureEnabled.mockReturnValue(false);
|
||||||
|
expect(canUseCustomTemplates()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('canUseDepartmentIsolation', () => {
|
||||||
|
it('当功能启用时应该返回 true', () => {
|
||||||
|
mockIsFeatureEnabled.mockReturnValue(true);
|
||||||
|
expect(canUseDepartmentIsolation()).toBe(true);
|
||||||
|
expect(mockIsFeatureEnabled).toHaveBeenCalledWith('departmentIsolation');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('当功能禁用时应该返回 false', () => {
|
||||||
|
mockIsFeatureEnabled.mockReturnValue(false);
|
||||||
|
expect(canUseDepartmentIsolation()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('canUseIntranetPenetration', () => {
|
||||||
|
it('当功能启用时应该返回 true', () => {
|
||||||
|
mockIsFeatureEnabled.mockReturnValue(true);
|
||||||
|
expect(canUseIntranetPenetration()).toBe(true);
|
||||||
|
expect(mockIsFeatureEnabled).toHaveBeenCalledWith('intranetPenetration');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('当功能禁用时应该返回 false', () => {
|
||||||
|
mockIsFeatureEnabled.mockReturnValue(false);
|
||||||
|
expect(canUseIntranetPenetration()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('canUseSystemSettings', () => {
|
||||||
|
it('当功能启用时应该返回 true', () => {
|
||||||
|
mockIsFeatureEnabled.mockReturnValue(true);
|
||||||
|
expect(canUseSystemSettings()).toBe(true);
|
||||||
|
expect(mockIsFeatureEnabled).toHaveBeenCalledWith('systemSettings');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('当功能禁用时应该返回 false', () => {
|
||||||
|
mockIsFeatureEnabled.mockReturnValue(false);
|
||||||
|
expect(canUseSystemSettings()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('canUseApiAccess', () => {
|
||||||
|
it('当功能启用时应该返回 true', () => {
|
||||||
|
mockIsFeatureEnabled.mockReturnValue(true);
|
||||||
|
expect(canUseApiAccess()).toBe(true);
|
||||||
|
expect(mockIsFeatureEnabled).toHaveBeenCalledWith('apiAccess');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('当功能禁用时应该返回 false', () => {
|
||||||
|
mockIsFeatureEnabled.mockReturnValue(false);
|
||||||
|
expect(canUseApiAccess()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('LimitCheckResult 接口', () => {
|
||||||
|
it('应该正确处理无限制情况 (max = -1)', async () => {
|
||||||
|
mockCheckLimit.mockResolvedValue({ allowed: true, current: 0, max: -1 });
|
||||||
|
|
||||||
|
const result = await canCreateUser();
|
||||||
|
|
||||||
|
expect(result.allowed).toBe(true);
|
||||||
|
expect(result.max).toBe(-1);
|
||||||
|
});
|
||||||
|
});
|
||||||
171
lib/__tests__/crypto-service.test.ts
Normal file
171
lib/__tests__/crypto-service.test.ts
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
/**
|
||||||
|
* 加密服务模块测试
|
||||||
|
* 测试 lib/license/crypto-service.ts 中的加密/解密函数
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
encryptLocal,
|
||||||
|
decryptLocal,
|
||||||
|
hashData,
|
||||||
|
generateId,
|
||||||
|
verifySignature,
|
||||||
|
verifyLicenseSignature,
|
||||||
|
} from '../license/crypto-service';
|
||||||
|
|
||||||
|
describe('本地加密/解密', () => {
|
||||||
|
describe('encryptLocal', () => {
|
||||||
|
it('应该成功加密字符串', () => {
|
||||||
|
const plaintext = 'Hello, World!';
|
||||||
|
const encrypted = encryptLocal(plaintext);
|
||||||
|
|
||||||
|
expect(encrypted).toBeDefined();
|
||||||
|
expect(encrypted).not.toBe(plaintext);
|
||||||
|
// 格式应该是 iv:authTag:encrypted
|
||||||
|
expect(encrypted.split(':').length).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该对相同输入产生不同的加密结果(因为随机 IV)', () => {
|
||||||
|
const plaintext = 'Same text';
|
||||||
|
const encrypted1 = encryptLocal(plaintext);
|
||||||
|
const encrypted2 = encryptLocal(plaintext);
|
||||||
|
|
||||||
|
expect(encrypted1).not.toBe(encrypted2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该能加密空字符串', () => {
|
||||||
|
const encrypted = encryptLocal('');
|
||||||
|
expect(encrypted).toBeDefined();
|
||||||
|
expect(encrypted.split(':').length).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该能加密中文字符', () => {
|
||||||
|
const plaintext = '你好,世界!';
|
||||||
|
const encrypted = encryptLocal(plaintext);
|
||||||
|
expect(encrypted).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该能加密 JSON 字符串', () => {
|
||||||
|
const jsonData = JSON.stringify({ key: 'value', number: 123 });
|
||||||
|
const encrypted = encryptLocal(jsonData);
|
||||||
|
expect(encrypted).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('decryptLocal', () => {
|
||||||
|
it('应该成功解密加密的字符串', () => {
|
||||||
|
const plaintext = 'Hello, World!';
|
||||||
|
const encrypted = encryptLocal(plaintext);
|
||||||
|
const decrypted = decryptLocal(encrypted);
|
||||||
|
|
||||||
|
expect(decrypted).toBe(plaintext);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该成功解密中文字符', () => {
|
||||||
|
const plaintext = '你好,世界!';
|
||||||
|
const encrypted = encryptLocal(plaintext);
|
||||||
|
const decrypted = decryptLocal(encrypted);
|
||||||
|
|
||||||
|
expect(decrypted).toBe(plaintext);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该成功解密 JSON 字符串', () => {
|
||||||
|
const jsonData = { key: 'value', number: 123, nested: { a: 1 } };
|
||||||
|
const encrypted = encryptLocal(JSON.stringify(jsonData));
|
||||||
|
const decrypted = JSON.parse(decryptLocal(encrypted));
|
||||||
|
|
||||||
|
expect(decrypted).toEqual(jsonData);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该成功解密空字符串', () => {
|
||||||
|
const encrypted = encryptLocal('');
|
||||||
|
const decrypted = decryptLocal(encrypted);
|
||||||
|
|
||||||
|
expect(decrypted).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('解密无效数据时应该抛出错误', () => {
|
||||||
|
expect(() => decryptLocal('invalid:data:here')).toThrow('数据解密失败');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('解密格式错误的数据时应该抛出错误', () => {
|
||||||
|
expect(() => decryptLocal('not-valid-format')).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('hashData', () => {
|
||||||
|
it('应该生成 SHA-256 哈希', () => {
|
||||||
|
const data = 'test data';
|
||||||
|
const hash = hashData(data);
|
||||||
|
|
||||||
|
expect(hash).toBeDefined();
|
||||||
|
expect(hash.length).toBe(64); // SHA-256 produces 64 hex characters
|
||||||
|
});
|
||||||
|
|
||||||
|
it('相同输入应该产生相同哈希', () => {
|
||||||
|
const data = 'same data';
|
||||||
|
const hash1 = hashData(data);
|
||||||
|
const hash2 = hashData(data);
|
||||||
|
|
||||||
|
expect(hash1).toBe(hash2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('不同输入应该产生不同哈希', () => {
|
||||||
|
const hash1 = hashData('data1');
|
||||||
|
const hash2 = hashData('data2');
|
||||||
|
|
||||||
|
expect(hash1).not.toBe(hash2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该能哈希空字符串', () => {
|
||||||
|
const hash = hashData('');
|
||||||
|
expect(hash).toBeDefined();
|
||||||
|
expect(hash.length).toBe(64);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generateId', () => {
|
||||||
|
it('应该生成 32 字符的十六进制 ID', () => {
|
||||||
|
const id = generateId();
|
||||||
|
|
||||||
|
expect(id).toBeDefined();
|
||||||
|
expect(id.length).toBe(32);
|
||||||
|
expect(/^[0-9a-f]+$/.test(id)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该生成唯一的 ID', () => {
|
||||||
|
const ids = new Set<string>();
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
ids.add(generateId());
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(ids.size).toBe(100);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('签名验证', () => {
|
||||||
|
describe('verifySignature', () => {
|
||||||
|
it('无效签名应该返回 false', () => {
|
||||||
|
const data = 'test data';
|
||||||
|
const invalidSignature = 'invalid-signature';
|
||||||
|
|
||||||
|
const result = verifySignature(data, invalidSignature);
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('空签名应该返回 false', () => {
|
||||||
|
const result = verifySignature('data', '');
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('verifyLicenseSignature', () => {
|
||||||
|
it('无效签名应该返回 false', () => {
|
||||||
|
const licenseData = { type: 'FREE', expiresAt: null };
|
||||||
|
const invalidSignature = 'invalid-signature';
|
||||||
|
|
||||||
|
const result = verifyLicenseSignature(licenseData, invalidSignature);
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
200
lib/__tests__/license-types.test.ts
Normal file
200
lib/__tests__/license-types.test.ts
Normal file
@ -0,0 +1,200 @@
|
|||||||
|
/**
|
||||||
|
* 许可证类型模块测试
|
||||||
|
* 测试 lib/license/types.ts 中的类型定义和配置生成函数
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
LicenseType,
|
||||||
|
LicenseConfig,
|
||||||
|
LICENSE_CONFIGS,
|
||||||
|
generateLicenseConfig,
|
||||||
|
FeatureKey,
|
||||||
|
} from '../license/types';
|
||||||
|
|
||||||
|
describe('LICENSE_CONFIGS', () => {
|
||||||
|
describe('FREE 版本配置', () => {
|
||||||
|
const freeConfig = LICENSE_CONFIGS.FREE;
|
||||||
|
|
||||||
|
it('应该有正确的类型标识', () => {
|
||||||
|
expect(freeConfig.type).toBe('FREE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该限制最大用户数为 10', () => {
|
||||||
|
expect(freeConfig.limits.maxUsers).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该限制最大素材数为 100', () => {
|
||||||
|
expect(freeConfig.limits.maxMaterials).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该禁用所有高级功能', () => {
|
||||||
|
expect(freeConfig.features.customTemplates).toBe(false);
|
||||||
|
expect(freeConfig.features.systemSettings).toBe(false);
|
||||||
|
expect(freeConfig.features.departmentIsolation).toBe(false);
|
||||||
|
expect(freeConfig.features.apiAccess).toBe(false);
|
||||||
|
expect(freeConfig.features.intranetPenetration).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('STANDARD 版本配置', () => {
|
||||||
|
const standardConfig = LICENSE_CONFIGS.STANDARD;
|
||||||
|
|
||||||
|
it('应该有正确的类型标识', () => {
|
||||||
|
expect(standardConfig.type).toBe('STANDARD');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该不限制用户数 (-1)', () => {
|
||||||
|
expect(standardConfig.limits.maxUsers).toBe(-1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该不限制素材数 (-1)', () => {
|
||||||
|
expect(standardConfig.limits.maxMaterials).toBe(-1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该启用大部分高级功能', () => {
|
||||||
|
expect(standardConfig.features.customTemplates).toBe(true);
|
||||||
|
expect(standardConfig.features.systemSettings).toBe(true);
|
||||||
|
expect(standardConfig.features.departmentIsolation).toBe(true);
|
||||||
|
expect(standardConfig.features.apiAccess).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该禁用内网穿透功能', () => {
|
||||||
|
expect(standardConfig.features.intranetPenetration).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PRO 版本配置', () => {
|
||||||
|
const proConfig = LICENSE_CONFIGS.PRO;
|
||||||
|
|
||||||
|
it('应该有正确的类型标识', () => {
|
||||||
|
expect(proConfig.type).toBe('PRO');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该不限制用户数 (-1)', () => {
|
||||||
|
expect(proConfig.limits.maxUsers).toBe(-1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该不限制素材数 (-1)', () => {
|
||||||
|
expect(proConfig.limits.maxMaterials).toBe(-1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该启用所有高级功能', () => {
|
||||||
|
expect(proConfig.features.customTemplates).toBe(true);
|
||||||
|
expect(proConfig.features.systemSettings).toBe(true);
|
||||||
|
expect(proConfig.features.departmentIsolation).toBe(true);
|
||||||
|
expect(proConfig.features.apiAccess).toBe(true);
|
||||||
|
expect(proConfig.features.intranetPenetration).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('版本功能递进关系', () => {
|
||||||
|
it('STANDARD 应该比 FREE 有更多功能', () => {
|
||||||
|
const freeFeatures = Object.values(LICENSE_CONFIGS.FREE.features).filter(Boolean).length;
|
||||||
|
const standardFeatures = Object.values(LICENSE_CONFIGS.STANDARD.features).filter(Boolean).length;
|
||||||
|
expect(standardFeatures).toBeGreaterThan(freeFeatures);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PRO 应该比 STANDARD 有更多功能', () => {
|
||||||
|
const standardFeatures = Object.values(LICENSE_CONFIGS.STANDARD.features).filter(Boolean).length;
|
||||||
|
const proFeatures = Object.values(LICENSE_CONFIGS.PRO.features).filter(Boolean).length;
|
||||||
|
expect(proFeatures).toBeGreaterThanOrEqual(standardFeatures);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generateLicenseConfig', () => {
|
||||||
|
describe('基本功能', () => {
|
||||||
|
it('应该生成 FREE 版本配置', () => {
|
||||||
|
const config = generateLicenseConfig('FREE');
|
||||||
|
expect(config.type).toBe('FREE');
|
||||||
|
expect(config.limits.maxUsers).toBe(10);
|
||||||
|
expect(config.limits.maxMaterials).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该生成 STANDARD 版本配置', () => {
|
||||||
|
const config = generateLicenseConfig('STANDARD');
|
||||||
|
expect(config.type).toBe('STANDARD');
|
||||||
|
expect(config.limits.maxUsers).toBe(-1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该生成 PRO 版本配置', () => {
|
||||||
|
const config = generateLicenseConfig('PRO');
|
||||||
|
expect(config.type).toBe('PRO');
|
||||||
|
expect(config.features.intranetPenetration).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('过期时间处理', () => {
|
||||||
|
it('不传过期时间时应该返回 null', () => {
|
||||||
|
const config = generateLicenseConfig('FREE');
|
||||||
|
expect(config.expiresAt).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('传入 null 时应该返回 null', () => {
|
||||||
|
const config = generateLicenseConfig('FREE', null);
|
||||||
|
expect(config.expiresAt).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('传入 Date 对象时应该返回 ISO 字符串', () => {
|
||||||
|
const expiresAt = new Date('2025-12-31T23:59:59.000Z');
|
||||||
|
const config = generateLicenseConfig('STANDARD', expiresAt);
|
||||||
|
expect(config.expiresAt).toBe('2025-12-31T23:59:59.000Z');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该正确处理不同时区的日期', () => {
|
||||||
|
const expiresAt = new Date('2026-06-15T12:00:00.000Z');
|
||||||
|
const config = generateLicenseConfig('PRO', expiresAt);
|
||||||
|
expect(config.expiresAt).toContain('2026-06-15');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('返回值结构', () => {
|
||||||
|
it('应该包含所有必需字段', () => {
|
||||||
|
const config = generateLicenseConfig('STANDARD', new Date());
|
||||||
|
|
||||||
|
expect(config).toHaveProperty('type');
|
||||||
|
expect(config).toHaveProperty('limits');
|
||||||
|
expect(config).toHaveProperty('limits.maxUsers');
|
||||||
|
expect(config).toHaveProperty('limits.maxMaterials');
|
||||||
|
expect(config).toHaveProperty('features');
|
||||||
|
expect(config).toHaveProperty('features.customTemplates');
|
||||||
|
expect(config).toHaveProperty('features.systemSettings');
|
||||||
|
expect(config).toHaveProperty('features.departmentIsolation');
|
||||||
|
expect(config).toHaveProperty('features.apiAccess');
|
||||||
|
expect(config).toHaveProperty('features.intranetPenetration');
|
||||||
|
expect(config).toHaveProperty('expiresAt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('返回的配置应该是新对象,不影响原始配置', () => {
|
||||||
|
const config1 = generateLicenseConfig('FREE', new Date('2025-01-01'));
|
||||||
|
const config2 = generateLicenseConfig('FREE', new Date('2026-01-01'));
|
||||||
|
|
||||||
|
expect(config1.expiresAt).not.toBe(config2.expiresAt);
|
||||||
|
expect(LICENSE_CONFIGS.FREE).not.toHaveProperty('expiresAt');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('类型定义完整性', () => {
|
||||||
|
it('LicenseType 应该包含三种类型', () => {
|
||||||
|
const types: LicenseType[] = ['FREE', 'STANDARD', 'PRO'];
|
||||||
|
types.forEach(type => {
|
||||||
|
expect(LICENSE_CONFIGS[type]).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FeatureKey 应该能正确索引功能', () => {
|
||||||
|
const featureKeys: FeatureKey[] = [
|
||||||
|
'customTemplates',
|
||||||
|
'systemSettings',
|
||||||
|
'departmentIsolation',
|
||||||
|
'apiAccess',
|
||||||
|
'intranetPenetration',
|
||||||
|
];
|
||||||
|
|
||||||
|
const config = generateLicenseConfig('PRO');
|
||||||
|
featureKeys.forEach(key => {
|
||||||
|
expect(typeof config.features[key]).toBe('boolean');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
88
lib/license/check-limits.ts
Normal file
88
lib/license/check-limits.ts
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
/**
|
||||||
|
* 许可证限制检查工具
|
||||||
|
* 用于在各个 API 中检查是否达到许可证限制
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { checkLimit, isFeatureEnabled, getLicenseType } from './license-service';
|
||||||
|
|
||||||
|
export interface LimitCheckResult {
|
||||||
|
allowed: boolean;
|
||||||
|
current: number;
|
||||||
|
max: number;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否可以创建新用户
|
||||||
|
*/
|
||||||
|
export async function canCreateUser(): Promise<LimitCheckResult> {
|
||||||
|
const result = await checkLimit('users');
|
||||||
|
|
||||||
|
if (!result.allowed) {
|
||||||
|
const licenseType = getLicenseType();
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
message:
|
||||||
|
licenseType === 'FREE'
|
||||||
|
? `免费版最多支持 ${result.max} 个用户,当前已有 ${result.current} 个用户。请升级到标准版或专业版以解除限制。`
|
||||||
|
: `已达到用户数量上限(${result.max}),请联系管理员。`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否可以上传新素材
|
||||||
|
*/
|
||||||
|
export async function canUploadMaterial(): Promise<LimitCheckResult> {
|
||||||
|
const result = await checkLimit('materials');
|
||||||
|
|
||||||
|
if (!result.allowed) {
|
||||||
|
const licenseType = getLicenseType();
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
message:
|
||||||
|
licenseType === 'FREE'
|
||||||
|
? `免费版最多支持 ${result.max} 个素材,当前已有 ${result.current} 个素材。请升级到标准版或专业版以解除限制。`
|
||||||
|
: `已达到素材数量上限(${result.max}),请联系管理员。`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查自定义模板功能是否可用
|
||||||
|
*/
|
||||||
|
export function canUseCustomTemplates(): boolean {
|
||||||
|
return isFeatureEnabled('customTemplates');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查部门隔离功能是否可用
|
||||||
|
*/
|
||||||
|
export function canUseDepartmentIsolation(): boolean {
|
||||||
|
return isFeatureEnabled('departmentIsolation');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查内网穿透功能是否可用
|
||||||
|
*/
|
||||||
|
export function canUseIntranetPenetration(): boolean {
|
||||||
|
return isFeatureEnabled('intranetPenetration');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查系统设置功能是否可用
|
||||||
|
*/
|
||||||
|
export function canUseSystemSettings(): boolean {
|
||||||
|
return isFeatureEnabled('systemSettings');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查 API 访问功能是否可用
|
||||||
|
*/
|
||||||
|
export function canUseApiAccess(): boolean {
|
||||||
|
return isFeatureEnabled('apiAccess');
|
||||||
|
}
|
||||||
93
lib/license/crypto-service.ts
Normal file
93
lib/license/crypto-service.ts
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
import * as crypto from 'crypto';
|
||||||
|
|
||||||
|
// RSA 公钥(从许可证服务器获取,嵌入客户端)
|
||||||
|
const RSA_PUBLIC_KEY = process.env.LICENSE_PUBLIC_KEY?.replace(/\\n/g, '\n') || `-----BEGIN PUBLIC KEY-----
|
||||||
|
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwQNKx95FWT0XKXcskHhv
|
||||||
|
MmBluxHYpmPfKSPETyCO1cMDhf1TuFYggwP51b2BkkAiCS1sD3Hswb9VEzEJwbG/
|
||||||
|
nRF1cBNAGBLcDudnAWp5y8GTVr6voEEgsk88o0xVeQdNWwi3016XffB47Q235ixb
|
||||||
|
mr6cb0q9FEMMEQULKY9RFRcpVc2GvJup+c6/wIVV8Z0d7pGn+mD2eF+mucg+N+tJ
|
||||||
|
ocUfjgf9aE3dGitGYmA4xMU5OMEYXhydhNkOA+5YpHb7yrwQNuLAileGiq6aMp0i
|
||||||
|
RmWy9Pty+rygNRUK9kFDn0Fw4oZBNoVBkVebTqeeSZmrM8CJtE3KK0BrC5/TpFon
|
||||||
|
QQIDAQAB
|
||||||
|
-----END PUBLIC KEY-----`;
|
||||||
|
|
||||||
|
// 本地加密密钥(用于加密本地存储的数据)
|
||||||
|
const LOCAL_ENCRYPTION_KEY = process.env.LOCAL_ENCRYPTION_KEY || 'flnxi-local-encryption-key-32bytes!';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用 RSA 公钥验证签名
|
||||||
|
*/
|
||||||
|
export function verifySignature(data: string, signature: string): boolean {
|
||||||
|
try {
|
||||||
|
const verify = crypto.createVerify('RSA-SHA256');
|
||||||
|
verify.update(data);
|
||||||
|
verify.end();
|
||||||
|
return verify.verify(RSA_PUBLIC_KEY, signature, 'base64');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('签名验证失败:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证签名后的许可证数据
|
||||||
|
*/
|
||||||
|
export function verifyLicenseSignature(licenseData: object, signature: string): boolean {
|
||||||
|
const dataString = JSON.stringify(licenseData);
|
||||||
|
return verifySignature(dataString, signature);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用 AES-256-GCM 加密数据(用于本地存储)
|
||||||
|
*/
|
||||||
|
export function encryptLocal(plaintext: string): string {
|
||||||
|
const iv = crypto.randomBytes(12);
|
||||||
|
const key = crypto.scryptSync(LOCAL_ENCRYPTION_KEY, 'salt', 32);
|
||||||
|
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
||||||
|
|
||||||
|
let encrypted = cipher.update(plaintext, 'utf8', 'base64');
|
||||||
|
encrypted += cipher.final('base64');
|
||||||
|
|
||||||
|
const authTag = cipher.getAuthTag();
|
||||||
|
|
||||||
|
// 格式: iv:authTag:encrypted
|
||||||
|
return `${iv.toString('base64')}:${authTag.toString('base64')}:${encrypted}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用 AES-256-GCM 解密数据(用于本地存储)
|
||||||
|
*/
|
||||||
|
export function decryptLocal(ciphertext: string): string {
|
||||||
|
try {
|
||||||
|
const [ivBase64, authTagBase64, encrypted] = ciphertext.split(':');
|
||||||
|
|
||||||
|
const iv = Buffer.from(ivBase64, 'base64');
|
||||||
|
const authTag = Buffer.from(authTagBase64, 'base64');
|
||||||
|
const key = crypto.scryptSync(LOCAL_ENCRYPTION_KEY, 'salt', 32);
|
||||||
|
|
||||||
|
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
|
||||||
|
decipher.setAuthTag(authTag);
|
||||||
|
|
||||||
|
let decrypted = decipher.update(encrypted, 'base64', 'utf8');
|
||||||
|
decrypted += decipher.final('utf8');
|
||||||
|
|
||||||
|
return decrypted;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('解密失败:', error);
|
||||||
|
throw new Error('数据解密失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算数据哈希
|
||||||
|
*/
|
||||||
|
export function hashData(data: string): string {
|
||||||
|
return crypto.createHash('sha256').update(data).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成随机 ID
|
||||||
|
*/
|
||||||
|
export function generateId(): string {
|
||||||
|
return crypto.randomBytes(16).toString('hex');
|
||||||
|
}
|
||||||
191
lib/license/fingerprint-service.ts
Normal file
191
lib/license/fingerprint-service.ts
Normal file
@ -0,0 +1,191 @@
|
|||||||
|
import * as os from 'os';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import * as crypto from 'crypto';
|
||||||
|
import type { HardwareFingerprint } from './types';
|
||||||
|
|
||||||
|
// 实例 ID 存储路径
|
||||||
|
const INSTANCE_ID_FILE = path.join(process.cwd(), 'data', '.instance-id');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 machine-id
|
||||||
|
* Linux: /etc/machine-id 或 /var/lib/dbus/machine-id
|
||||||
|
* Windows: 使用 hostname + 网卡信息生成
|
||||||
|
* Docker: 使用容器 ID 或挂载的 machine-id
|
||||||
|
*/
|
||||||
|
async function getMachineId(): Promise<string> {
|
||||||
|
// 尝试读取 Linux machine-id
|
||||||
|
const machineIdPaths = [
|
||||||
|
'/etc/machine-id',
|
||||||
|
'/var/lib/dbus/machine-id',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const filePath of machineIdPaths) {
|
||||||
|
try {
|
||||||
|
const machineId = fs.readFileSync(filePath, 'utf8').trim();
|
||||||
|
if (machineId) {
|
||||||
|
return machineId;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 文件不存在,继续尝试下一个
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试读取 Docker 容器 ID
|
||||||
|
try {
|
||||||
|
const cgroupContent = fs.readFileSync('/proc/self/cgroup', 'utf8');
|
||||||
|
const match = cgroupContent.match(/docker[/-]([a-f0-9]{64})/);
|
||||||
|
if (match) {
|
||||||
|
return match[1].substring(0, 32);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 不在 Docker 容器中
|
||||||
|
}
|
||||||
|
|
||||||
|
// Windows 或其他系统:使用 hostname + 网卡信息生成
|
||||||
|
const hostname = os.hostname();
|
||||||
|
const networkInterfaces = os.networkInterfaces();
|
||||||
|
const macs: string[] = [];
|
||||||
|
|
||||||
|
for (const interfaces of Object.values(networkInterfaces)) {
|
||||||
|
if (interfaces) {
|
||||||
|
for (const iface of interfaces) {
|
||||||
|
if (!iface.internal && iface.mac !== '00:00:00:00:00:00') {
|
||||||
|
macs.push(iface.mac);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = `${hostname}-${macs.sort().join('-')}`;
|
||||||
|
return crypto.createHash('md5').update(data).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取主网卡 MAC 地址
|
||||||
|
*/
|
||||||
|
function getPrimaryMacAddress(): string {
|
||||||
|
const networkInterfaces = os.networkInterfaces();
|
||||||
|
|
||||||
|
// 优先查找常见的网卡名称
|
||||||
|
const preferredNames = ['eth0', 'en0', 'ens33', 'enp0s3', 'Ethernet', 'Wi-Fi'];
|
||||||
|
|
||||||
|
for (const name of preferredNames) {
|
||||||
|
const interfaces = networkInterfaces[name];
|
||||||
|
if (interfaces) {
|
||||||
|
for (const iface of interfaces) {
|
||||||
|
if (!iface.internal && iface.mac !== '00:00:00:00:00:00') {
|
||||||
|
return iface.mac;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没有找到,返回第一个非内部网卡的 MAC
|
||||||
|
for (const interfaces of Object.values(networkInterfaces)) {
|
||||||
|
if (interfaces) {
|
||||||
|
for (const iface of interfaces) {
|
||||||
|
if (!iface.internal && iface.mac !== '00:00:00:00:00:00') {
|
||||||
|
return iface.mac;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '00:00:00:00:00:00';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取或创建实例 ID
|
||||||
|
* 实例 ID 在首次激活时生成,并持久化存储
|
||||||
|
*/
|
||||||
|
async function getOrCreateInstanceId(): Promise<string> {
|
||||||
|
// 确保目录存在
|
||||||
|
const dir = path.dirname(INSTANCE_ID_FILE);
|
||||||
|
if (!fs.existsSync(dir)) {
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试读取现有的实例 ID
|
||||||
|
try {
|
||||||
|
const instanceId = fs.readFileSync(INSTANCE_ID_FILE, 'utf8').trim();
|
||||||
|
if (instanceId) {
|
||||||
|
return instanceId;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 文件不存在
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成新的实例 ID
|
||||||
|
const instanceId = crypto.randomBytes(16).toString('hex');
|
||||||
|
fs.writeFileSync(INSTANCE_ID_FILE, instanceId);
|
||||||
|
|
||||||
|
return instanceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成硬件指纹
|
||||||
|
*/
|
||||||
|
export async function generateFingerprint(): Promise<HardwareFingerprint> {
|
||||||
|
const [machineId, instanceId] = await Promise.all([
|
||||||
|
getMachineId(),
|
||||||
|
getOrCreateInstanceId(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
machineId,
|
||||||
|
hostname: os.hostname(),
|
||||||
|
macAddress: getPrimaryMacAddress(),
|
||||||
|
instanceId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取实例 ID
|
||||||
|
*/
|
||||||
|
export async function getInstanceId(): Promise<string> {
|
||||||
|
return getOrCreateInstanceId();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算两个指纹的匹配分数
|
||||||
|
* 返回 0-100 的分数
|
||||||
|
*/
|
||||||
|
export function calculateFingerprintMatchScore(
|
||||||
|
stored: HardwareFingerprint,
|
||||||
|
current: HardwareFingerprint
|
||||||
|
): number {
|
||||||
|
let score = 0;
|
||||||
|
|
||||||
|
// machineId 权重 40%
|
||||||
|
if (stored.machineId === current.machineId) {
|
||||||
|
score += 40;
|
||||||
|
}
|
||||||
|
|
||||||
|
// hostname 权重 20%
|
||||||
|
if (stored.hostname === current.hostname) {
|
||||||
|
score += 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
// macAddress 权重 30%
|
||||||
|
if (stored.macAddress === current.macAddress) {
|
||||||
|
score += 30;
|
||||||
|
}
|
||||||
|
|
||||||
|
// instanceId 权重 10%
|
||||||
|
if (stored.instanceId === current.instanceId) {
|
||||||
|
score += 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查指纹是否匹配(60% 阈值)
|
||||||
|
*/
|
||||||
|
export function isFingerprintMatch(
|
||||||
|
stored: HardwareFingerprint,
|
||||||
|
current: HardwareFingerprint
|
||||||
|
): boolean {
|
||||||
|
return calculateFingerprintMatchScore(stored, current) >= 60;
|
||||||
|
}
|
||||||
47
lib/license/index.ts
Normal file
47
lib/license/index.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
// 导出类型
|
||||||
|
export * from './types';
|
||||||
|
|
||||||
|
// 导出服务
|
||||||
|
export {
|
||||||
|
activate,
|
||||||
|
activateFreeTrial,
|
||||||
|
verify,
|
||||||
|
heartbeat,
|
||||||
|
getLicense,
|
||||||
|
isFeatureEnabled,
|
||||||
|
checkLimit,
|
||||||
|
getLicenseType,
|
||||||
|
isExpired,
|
||||||
|
startHeartbeat,
|
||||||
|
stopHeartbeat,
|
||||||
|
initLicenseService,
|
||||||
|
} from './license-service';
|
||||||
|
|
||||||
|
// 导出限制检查工具
|
||||||
|
export {
|
||||||
|
canCreateUser,
|
||||||
|
canUploadMaterial,
|
||||||
|
canUseCustomTemplates,
|
||||||
|
canUseDepartmentIsolation,
|
||||||
|
canUseIntranetPenetration,
|
||||||
|
canUseSystemSettings,
|
||||||
|
canUseApiAccess,
|
||||||
|
} from './check-limits';
|
||||||
|
|
||||||
|
// 导出指纹服务
|
||||||
|
export {
|
||||||
|
generateFingerprint,
|
||||||
|
getInstanceId,
|
||||||
|
calculateFingerprintMatchScore,
|
||||||
|
isFingerprintMatch,
|
||||||
|
} from './fingerprint-service';
|
||||||
|
|
||||||
|
// 导出加密服务
|
||||||
|
export {
|
||||||
|
verifySignature,
|
||||||
|
verifyLicenseSignature,
|
||||||
|
encryptLocal,
|
||||||
|
decryptLocal,
|
||||||
|
hashData,
|
||||||
|
generateId,
|
||||||
|
} from './crypto-service';
|
||||||
370
lib/license/license-service.ts
Normal file
370
lib/license/license-service.ts
Normal file
@ -0,0 +1,370 @@
|
|||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import { generateFingerprint, getInstanceId } from './fingerprint-service';
|
||||||
|
import { verifyLicenseSignature, encryptLocal, decryptLocal } from './crypto-service';
|
||||||
|
import type {
|
||||||
|
LicenseConfig,
|
||||||
|
SignedLicense,
|
||||||
|
ActivateResponse,
|
||||||
|
VerifyResponse,
|
||||||
|
HeartbeatResponse,
|
||||||
|
FeatureKey,
|
||||||
|
LocalLicenseData,
|
||||||
|
} from './types';
|
||||||
|
import { LICENSE_CONFIGS } from './types';
|
||||||
|
|
||||||
|
// 许可证服务器地址
|
||||||
|
const LICENSE_SERVER_URL = process.env.LICENSE_SERVER_URL || 'http://45.249.208.118:3001';
|
||||||
|
|
||||||
|
// 宽限期(7天)
|
||||||
|
const GRACE_PERIOD_DAYS = 7;
|
||||||
|
|
||||||
|
// 心跳间隔(1小时)
|
||||||
|
const HEARTBEAT_INTERVAL = 60 * 60 * 1000;
|
||||||
|
|
||||||
|
// 缓存的许可证配置
|
||||||
|
let cachedLicense: LicenseConfig | null = null;
|
||||||
|
let lastVerified: Date | null = null;
|
||||||
|
let heartbeatTimer: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 激活许可证
|
||||||
|
*/
|
||||||
|
export async function activate(licenseKey: string): Promise<{ success: boolean; error?: string }> {
|
||||||
|
try {
|
||||||
|
const fingerprint = await generateFingerprint();
|
||||||
|
|
||||||
|
const response = await fetch(`${LICENSE_SERVER_URL}/api/license/activate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
licenseKey,
|
||||||
|
fingerprint,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data: ActivateResponse = await response.json();
|
||||||
|
|
||||||
|
if (!data.success || !data.license) {
|
||||||
|
return { success: false, error: data.error || '激活失败' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证签名
|
||||||
|
if (!verifyLicenseSignature(data.license.data, data.license.signature)) {
|
||||||
|
return { success: false, error: '许可证签名验证失败' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存到本地
|
||||||
|
await saveLicenseLocally(licenseKey, data.license);
|
||||||
|
|
||||||
|
// 更新缓存
|
||||||
|
cachedLicense = data.license.data;
|
||||||
|
lastVerified = new Date();
|
||||||
|
|
||||||
|
// 启动心跳
|
||||||
|
startHeartbeat();
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('激活失败:', error);
|
||||||
|
return { success: false, error: '网络错误,请检查网络连接' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 激活免费试用版
|
||||||
|
*/
|
||||||
|
export async function activateFreeTrial(): Promise<{ success: boolean; error?: string }> {
|
||||||
|
try {
|
||||||
|
const instanceId = await getInstanceId();
|
||||||
|
|
||||||
|
// 创建免费版配置
|
||||||
|
const freeConfig: LicenseConfig = {
|
||||||
|
...LICENSE_CONFIGS.FREE,
|
||||||
|
expiresAt: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 保存到本地(无需服务器验证)
|
||||||
|
const localData: LocalLicenseData = {
|
||||||
|
instanceId,
|
||||||
|
licenseKey: 'FREE-TRIAL',
|
||||||
|
licenseData: encryptLocal(JSON.stringify(freeConfig)),
|
||||||
|
lastVerified: new Date().toISOString(),
|
||||||
|
cachedConfig: freeConfig,
|
||||||
|
};
|
||||||
|
|
||||||
|
await prisma.systemConfig.upsert({
|
||||||
|
where: { key: 'license' },
|
||||||
|
update: { value: JSON.stringify(localData) },
|
||||||
|
create: { key: 'license', value: JSON.stringify(localData) },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 更新缓存
|
||||||
|
cachedLicense = freeConfig;
|
||||||
|
lastVerified = new Date();
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('免费试用激活失败:', error);
|
||||||
|
return { success: false, error: '激活失败' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证许可证
|
||||||
|
*/
|
||||||
|
export async function verify(): Promise<{ valid: boolean; error?: string }> {
|
||||||
|
try {
|
||||||
|
// 先检查本地缓存
|
||||||
|
const localLicense = await getLocalLicense();
|
||||||
|
|
||||||
|
if (!localLicense) {
|
||||||
|
return { valid: false, error: '未找到许可证' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 免费试用版不需要在线验证
|
||||||
|
if (localLicense.licenseKey === 'FREE-TRIAL') {
|
||||||
|
cachedLicense = localLicense.cachedConfig;
|
||||||
|
lastVerified = new Date();
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const fingerprint = await generateFingerprint();
|
||||||
|
|
||||||
|
const response = await fetch(`${LICENSE_SERVER_URL}/api/license/verify`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
instanceId: fingerprint.instanceId,
|
||||||
|
fingerprint,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data: VerifyResponse = await response.json();
|
||||||
|
|
||||||
|
if (!data.valid || !data.license) {
|
||||||
|
// 检查宽限期
|
||||||
|
if (isWithinGracePeriod(localLicense.lastVerified)) {
|
||||||
|
cachedLicense = localLicense.cachedConfig;
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
return { valid: false, error: data.error || '验证失败' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证签名
|
||||||
|
if (!verifyLicenseSignature(data.license.data, data.license.signature)) {
|
||||||
|
return { valid: false, error: '许可证签名验证失败' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新本地缓存
|
||||||
|
await updateLocalLicense(data.license);
|
||||||
|
|
||||||
|
// 更新内存缓存
|
||||||
|
cachedLicense = data.license.data;
|
||||||
|
lastVerified = new Date();
|
||||||
|
|
||||||
|
return { valid: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('验证失败:', error);
|
||||||
|
|
||||||
|
// 网络错误时检查宽限期
|
||||||
|
const localLicense = await getLocalLicense();
|
||||||
|
if (localLicense && isWithinGracePeriod(localLicense.lastVerified)) {
|
||||||
|
cachedLicense = localLicense.cachedConfig;
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: false, error: '网络错误' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送心跳
|
||||||
|
*/
|
||||||
|
export async function heartbeat(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const localLicense = await getLocalLicense();
|
||||||
|
if (!localLicense || localLicense.licenseKey === 'FREE-TRIAL') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fingerprint = await generateFingerprint();
|
||||||
|
|
||||||
|
// 获取使用统计
|
||||||
|
const [userCount, materialCount] = await Promise.all([
|
||||||
|
prisma.user.count(),
|
||||||
|
prisma.material.count(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const response = await fetch(`${LICENSE_SERVER_URL}/api/license/heartbeat`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
instanceId: fingerprint.instanceId,
|
||||||
|
fingerprint,
|
||||||
|
usage: { userCount, materialCount },
|
||||||
|
timestamp: Date.now(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data: HeartbeatResponse = await response.json();
|
||||||
|
|
||||||
|
if (data.acknowledged && data.license) {
|
||||||
|
// 更新本地缓存
|
||||||
|
await updateLocalLicense(data.license);
|
||||||
|
cachedLicense = data.license.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastVerified = new Date();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('心跳发送失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前许可证配置
|
||||||
|
*/
|
||||||
|
export function getLicense(): LicenseConfig | null {
|
||||||
|
return cachedLicense;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查功能是否可用
|
||||||
|
*/
|
||||||
|
export function isFeatureEnabled(feature: FeatureKey): boolean {
|
||||||
|
if (!cachedLicense) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return cachedLicense.features[feature];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否达到限制
|
||||||
|
*/
|
||||||
|
export async function checkLimit(type: 'users' | 'materials'): Promise<{ allowed: boolean; current: number; max: number }> {
|
||||||
|
if (!cachedLicense) {
|
||||||
|
return { allowed: false, current: 0, max: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const max = type === 'users' ? cachedLicense.limits.maxUsers : cachedLicense.limits.maxMaterials;
|
||||||
|
|
||||||
|
// -1 表示无限制
|
||||||
|
if (max === -1) {
|
||||||
|
return { allowed: true, current: 0, max: -1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = type === 'users'
|
||||||
|
? await prisma.user.count()
|
||||||
|
: await prisma.material.count();
|
||||||
|
|
||||||
|
return {
|
||||||
|
allowed: current < max,
|
||||||
|
current,
|
||||||
|
max,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取许可证类型
|
||||||
|
*/
|
||||||
|
export function getLicenseType(): string {
|
||||||
|
return cachedLicense?.type || 'FREE';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查许可证是否已过期
|
||||||
|
*/
|
||||||
|
export function isExpired(): boolean {
|
||||||
|
if (!cachedLicense || !cachedLicense.expiresAt) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return new Date(cachedLicense.expiresAt) < new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动心跳定时器
|
||||||
|
*/
|
||||||
|
export function startHeartbeat(): void {
|
||||||
|
if (heartbeatTimer) {
|
||||||
|
clearInterval(heartbeatTimer);
|
||||||
|
}
|
||||||
|
heartbeatTimer = setInterval(heartbeat, HEARTBEAT_INTERVAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 停止心跳定时器
|
||||||
|
*/
|
||||||
|
export function stopHeartbeat(): void {
|
||||||
|
if (heartbeatTimer) {
|
||||||
|
clearInterval(heartbeatTimer);
|
||||||
|
heartbeatTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化许可证服务
|
||||||
|
*/
|
||||||
|
export async function initLicenseService(): Promise<void> {
|
||||||
|
const result = await verify();
|
||||||
|
if (result.valid) {
|
||||||
|
startHeartbeat();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 内部辅助函数 ============
|
||||||
|
|
||||||
|
async function saveLicenseLocally(licenseKey: string, license: SignedLicense): Promise<void> {
|
||||||
|
const instanceId = await getInstanceId();
|
||||||
|
|
||||||
|
const localData: LocalLicenseData = {
|
||||||
|
instanceId,
|
||||||
|
licenseKey,
|
||||||
|
licenseData: encryptLocal(JSON.stringify(license)),
|
||||||
|
lastVerified: new Date().toISOString(),
|
||||||
|
cachedConfig: license.data,
|
||||||
|
};
|
||||||
|
|
||||||
|
await prisma.systemConfig.upsert({
|
||||||
|
where: { key: 'license' },
|
||||||
|
update: { value: JSON.stringify(localData) },
|
||||||
|
create: { key: 'license', value: JSON.stringify(localData) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateLocalLicense(license: SignedLicense): Promise<void> {
|
||||||
|
const existing = await getLocalLicense();
|
||||||
|
if (!existing) return;
|
||||||
|
|
||||||
|
const localData: LocalLicenseData = {
|
||||||
|
...existing,
|
||||||
|
licenseData: encryptLocal(JSON.stringify(license)),
|
||||||
|
lastVerified: new Date().toISOString(),
|
||||||
|
cachedConfig: license.data,
|
||||||
|
};
|
||||||
|
|
||||||
|
await prisma.systemConfig.update({
|
||||||
|
where: { key: 'license' },
|
||||||
|
data: { value: JSON.stringify(localData) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getLocalLicense(): Promise<LocalLicenseData | null> {
|
||||||
|
try {
|
||||||
|
const config = await prisma.systemConfig.findUnique({
|
||||||
|
where: { key: 'license' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!config) return null;
|
||||||
|
|
||||||
|
return JSON.parse(config.value) as LocalLicenseData;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWithinGracePeriod(lastVerifiedStr: string): boolean {
|
||||||
|
const lastVerified = new Date(lastVerifiedStr);
|
||||||
|
const gracePeriodEnd = new Date(lastVerified.getTime() + GRACE_PERIOD_DAYS * 24 * 60 * 60 * 1000);
|
||||||
|
return new Date() < gracePeriodEnd;
|
||||||
|
}
|
||||||
152
lib/license/types.ts
Normal file
152
lib/license/types.ts
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
// 许可证类型
|
||||||
|
export type LicenseType = 'FREE' | 'STANDARD' | 'PRO';
|
||||||
|
|
||||||
|
// 许可证配置
|
||||||
|
export interface LicenseConfig {
|
||||||
|
type: LicenseType;
|
||||||
|
limits: {
|
||||||
|
maxUsers: number; // -1 表示无限
|
||||||
|
maxMaterials: number; // -1 表示无限
|
||||||
|
};
|
||||||
|
features: {
|
||||||
|
customTemplates: boolean;
|
||||||
|
systemSettings: boolean;
|
||||||
|
departmentIsolation: boolean;
|
||||||
|
apiAccess: boolean;
|
||||||
|
intranetPenetration: boolean;
|
||||||
|
};
|
||||||
|
expiresAt: string | null; // ISO 日期字符串,null 表示永久
|
||||||
|
}
|
||||||
|
|
||||||
|
// 签名后的许可证
|
||||||
|
export interface SignedLicense {
|
||||||
|
data: LicenseConfig & { issuedAt: string };
|
||||||
|
signature: string;
|
||||||
|
issuedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 硬件指纹
|
||||||
|
export interface HardwareFingerprint {
|
||||||
|
machineId: string;
|
||||||
|
hostname: string;
|
||||||
|
macAddress: string;
|
||||||
|
cpuId?: string;
|
||||||
|
instanceId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 激活请求
|
||||||
|
export interface ActivateRequest {
|
||||||
|
licenseKey: string;
|
||||||
|
fingerprint: HardwareFingerprint;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 激活响应
|
||||||
|
export interface ActivateResponse {
|
||||||
|
success: boolean;
|
||||||
|
license?: SignedLicense;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证请求
|
||||||
|
export interface VerifyRequest {
|
||||||
|
instanceId: string;
|
||||||
|
fingerprint: HardwareFingerprint;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证响应
|
||||||
|
export interface VerifyResponse {
|
||||||
|
valid: boolean;
|
||||||
|
license?: SignedLicense;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 心跳请求
|
||||||
|
export interface HeartbeatRequest {
|
||||||
|
instanceId: string;
|
||||||
|
fingerprint: HardwareFingerprint;
|
||||||
|
usage: {
|
||||||
|
userCount: number;
|
||||||
|
materialCount: number;
|
||||||
|
};
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 心跳响应
|
||||||
|
export interface HeartbeatResponse {
|
||||||
|
acknowledged: boolean;
|
||||||
|
license?: SignedLicense;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 本地存储的许可证数据
|
||||||
|
export interface LocalLicenseData {
|
||||||
|
instanceId: string;
|
||||||
|
licenseKey: string;
|
||||||
|
licenseData: string; // 加密存储的签名许可证
|
||||||
|
lastVerified: string; // ISO 日期字符串
|
||||||
|
cachedConfig: LicenseConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 功能键
|
||||||
|
export type FeatureKey = keyof LicenseConfig['features'];
|
||||||
|
|
||||||
|
// 各版本的默认配置
|
||||||
|
export const LICENSE_CONFIGS: Record<LicenseType, Omit<LicenseConfig, 'expiresAt'>> = {
|
||||||
|
FREE: {
|
||||||
|
type: 'FREE',
|
||||||
|
limits: {
|
||||||
|
maxUsers: 10,
|
||||||
|
maxMaterials: 100,
|
||||||
|
},
|
||||||
|
features: {
|
||||||
|
customTemplates: false,
|
||||||
|
systemSettings: false,
|
||||||
|
departmentIsolation: false,
|
||||||
|
apiAccess: false,
|
||||||
|
intranetPenetration: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
STANDARD: {
|
||||||
|
type: 'STANDARD',
|
||||||
|
limits: {
|
||||||
|
maxUsers: -1,
|
||||||
|
maxMaterials: -1,
|
||||||
|
},
|
||||||
|
features: {
|
||||||
|
customTemplates: true,
|
||||||
|
systemSettings: true,
|
||||||
|
departmentIsolation: true,
|
||||||
|
apiAccess: true,
|
||||||
|
intranetPenetration: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
PRO: {
|
||||||
|
type: 'PRO',
|
||||||
|
limits: {
|
||||||
|
maxUsers: -1,
|
||||||
|
maxMaterials: -1,
|
||||||
|
},
|
||||||
|
features: {
|
||||||
|
customTemplates: true,
|
||||||
|
systemSettings: true,
|
||||||
|
departmentIsolation: true,
|
||||||
|
apiAccess: true,
|
||||||
|
intranetPenetration: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据许可证类型生成配置
|
||||||
|
*/
|
||||||
|
export function generateLicenseConfig(
|
||||||
|
type: LicenseType,
|
||||||
|
expiresAt?: Date | null
|
||||||
|
): LicenseConfig {
|
||||||
|
const baseConfig = LICENSE_CONFIGS[type];
|
||||||
|
return {
|
||||||
|
...baseConfig,
|
||||||
|
expiresAt: expiresAt ? expiresAt.toISOString() : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
15
license-server/.env.example
Normal file
15
license-server/.env.example
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
# 数据库连接
|
||||||
|
DATABASE_URL="postgresql://postgres:password@localhost:5432/license_server?schema=public"
|
||||||
|
|
||||||
|
# RSA 密钥(生产环境请使用 generate-keys 脚本生成)
|
||||||
|
RSA_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----"
|
||||||
|
RSA_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----"
|
||||||
|
|
||||||
|
# AES 加密密钥(32字节 hex)
|
||||||
|
AES_SECRET_KEY="your-32-byte-hex-key-here-12345678901234567890"
|
||||||
|
|
||||||
|
# JWT 密钥
|
||||||
|
JWT_SECRET="your-jwt-secret-key"
|
||||||
|
|
||||||
|
# 管理员初始密码
|
||||||
|
ADMIN_INITIAL_PASSWORD="admin123"
|
||||||
41
license-server/.gitignore
vendored
Normal file
41
license-server/.gitignore
vendored
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# local env files
|
||||||
|
.env
|
||||||
|
.env*.local
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
|
|
||||||
|
# prisma
|
||||||
|
prisma/migrations
|
||||||
|
|
||||||
|
# keys
|
||||||
|
keys/
|
||||||
|
*.key
|
||||||
49
license-server/Dockerfile
Normal file
49
license-server/Dockerfile
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
# 构建阶段 - 使用 Debian slim
|
||||||
|
FROM node:20-slim AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 使用阿里云镜像源
|
||||||
|
RUN sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \
|
||||||
|
sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list
|
||||||
|
|
||||||
|
# 安装 OpenSSL
|
||||||
|
RUN apt-get update && apt-get install -y openssl && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# 安装依赖(使用淘宝镜像)
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm config set registry https://registry.npmmirror.com && \
|
||||||
|
npm install --legacy-peer-deps
|
||||||
|
|
||||||
|
# 复制源码
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# 生成 Prisma Client
|
||||||
|
RUN node node_modules/prisma/build/index.js generate
|
||||||
|
|
||||||
|
# 构建(直接用 node 执行 next)
|
||||||
|
RUN node node_modules/next/dist/bin/next build
|
||||||
|
|
||||||
|
# 生产阶段
|
||||||
|
FROM node:20-slim AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 使用阿里云镜像源
|
||||||
|
RUN sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \
|
||||||
|
sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list
|
||||||
|
|
||||||
|
# 安装 OpenSSL
|
||||||
|
RUN apt-get update && apt-get install -y openssl && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
# 复制必要文件
|
||||||
|
COPY --from=builder /app/next.config.js ./
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
COPY --from=builder /app/.next/standalone ./
|
||||||
|
COPY --from=builder /app/.next/static ./.next/static
|
||||||
|
COPY --from=builder /app/prisma ./prisma
|
||||||
|
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["node", "server.js"]
|
||||||
203
license-server/app/admin/alerts/page.tsx
Normal file
203
license-server/app/admin/alerts/page.tsx
Normal file
@ -0,0 +1,203 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
interface Alert {
|
||||||
|
id: string;
|
||||||
|
alertType: string;
|
||||||
|
status: string;
|
||||||
|
details: Record<string, unknown> | null;
|
||||||
|
createdAt: string;
|
||||||
|
resolvedAt: string | null;
|
||||||
|
license: {
|
||||||
|
licenseKey: string;
|
||||||
|
customer: { companyName: string } | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AlertsPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [alerts, setAlerts] = useState<Alert[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [filter, setFilter] = useState<'all' | 'pending' | 'resolved'>('pending');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAlerts();
|
||||||
|
}, [filter]);
|
||||||
|
|
||||||
|
const fetchAlerts = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/admin/alerts?status=${filter}`);
|
||||||
|
if (res.status === 401) {
|
||||||
|
router.push('/admin/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
setAlerts(data.alerts || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取告警列表失败:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResolve = async (id: string) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/admin/alerts/${id}/resolve`, { method: 'POST' });
|
||||||
|
if (res.ok) {
|
||||||
|
fetchAlerts();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('处理告警失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleIgnore = async (id: string) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/admin/alerts/${id}/ignore`, { method: 'POST' });
|
||||||
|
if (res.ok) {
|
||||||
|
fetchAlerts();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('忽略告警失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAlertTypeLabel = (type: string) => {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
multi_device: '多设备激活',
|
||||||
|
fingerprint_mismatch: '指纹不匹配',
|
||||||
|
suspicious_activity: '可疑活动',
|
||||||
|
};
|
||||||
|
return labels[type] || type;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-100">
|
||||||
|
<nav className="bg-white shadow">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 py-4 flex justify-between items-center">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Link href="/admin" className="text-gray-600 hover:text-gray-900">
|
||||||
|
← 返回
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-xl font-bold">告警中心</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main className="max-w-7xl mx-auto px-4 py-8">
|
||||||
|
<div className="bg-white rounded-lg shadow">
|
||||||
|
<div className="p-6 border-b">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setFilter('pending')}
|
||||||
|
className={`px-4 py-2 rounded-lg ${
|
||||||
|
filter === 'pending'
|
||||||
|
? 'bg-blue-600 text-white'
|
||||||
|
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
待处理
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setFilter('resolved')}
|
||||||
|
className={`px-4 py-2 rounded-lg ${
|
||||||
|
filter === 'resolved'
|
||||||
|
? 'bg-blue-600 text-white'
|
||||||
|
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
已处理
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setFilter('all')}
|
||||||
|
className={`px-4 py-2 rounded-lg ${
|
||||||
|
filter === 'all'
|
||||||
|
? 'bg-blue-600 text-white'
|
||||||
|
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
全部
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="p-8 text-center text-gray-500">加载中...</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y">
|
||||||
|
{alerts.map((alert) => (
|
||||||
|
<div key={alert.id} className="p-6">
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span
|
||||||
|
className={`px-2 py-1 rounded text-xs font-medium ${
|
||||||
|
alert.status === 'pending'
|
||||||
|
? 'bg-red-100 text-red-700'
|
||||||
|
: alert.status === 'resolved'
|
||||||
|
? 'bg-green-100 text-green-700'
|
||||||
|
: 'bg-gray-100 text-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{alert.status === 'pending'
|
||||||
|
? '待处理'
|
||||||
|
: alert.status === 'resolved'
|
||||||
|
? '已处理'
|
||||||
|
: '已忽略'}
|
||||||
|
</span>
|
||||||
|
<span className="font-medium">{getAlertTypeLabel(alert.alertType)}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-600 mb-1">
|
||||||
|
许可证: <span className="font-mono">{alert.license.licenseKey}</span>
|
||||||
|
</p>
|
||||||
|
{alert.license.customer && (
|
||||||
|
<p className="text-sm text-gray-600 mb-1">
|
||||||
|
客户: {alert.license.customer.companyName}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
时间: {new Date(alert.createdAt).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
{alert.details && (
|
||||||
|
<details className="mt-2">
|
||||||
|
<summary className="text-sm text-blue-600 cursor-pointer">
|
||||||
|
查看详情
|
||||||
|
</summary>
|
||||||
|
<pre className="mt-2 p-3 bg-gray-100 rounded text-xs overflow-auto">
|
||||||
|
{JSON.stringify(alert.details, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{alert.status === 'pending' && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleResolve(alert.id)}
|
||||||
|
className="px-3 py-1 bg-green-600 text-white rounded text-sm hover:bg-green-700"
|
||||||
|
>
|
||||||
|
处理
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleIgnore(alert.id)}
|
||||||
|
className="px-3 py-1 bg-gray-200 text-gray-700 rounded text-sm hover:bg-gray-300"
|
||||||
|
>
|
||||||
|
忽略
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!alerts.length && (
|
||||||
|
<div className="p-8 text-center text-gray-500">暂无告警</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
276
license-server/app/admin/customers/page.tsx
Normal file
276
license-server/app/admin/customers/page.tsx
Normal file
@ -0,0 +1,276 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
interface Customer {
|
||||||
|
id: string;
|
||||||
|
companyName: string;
|
||||||
|
contactName: string | null;
|
||||||
|
contactEmail: string | null;
|
||||||
|
contactPhone: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
_count: { licenses: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CustomersPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [customers, setCustomers] = useState<Customer[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchCustomers();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchCustomers = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/customers');
|
||||||
|
if (res.status === 401) {
|
||||||
|
router.push('/admin/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
setCustomers(data.customers || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取客户列表失败:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredCustomers = customers.filter(
|
||||||
|
(c) =>
|
||||||
|
c.companyName.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
c.contactName?.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
c.contactEmail?.toLowerCase().includes(search.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-100">
|
||||||
|
<nav className="bg-white shadow">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 py-4 flex justify-between items-center">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Link href="/admin" className="text-gray-600 hover:text-gray-900">
|
||||||
|
← 返回
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-xl font-bold">客户管理</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main className="max-w-7xl mx-auto px-4 py-8">
|
||||||
|
<div className="bg-white rounded-lg shadow">
|
||||||
|
<div className="p-6 border-b flex justify-between items-center">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="搜索客户..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="px-4 py-2 border rounded-lg w-64"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowModal(true)}
|
||||||
|
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
添加客户
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="p-8 text-center text-gray-500">加载中...</div>
|
||||||
|
) : (
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||||
|
公司名称
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||||
|
联系人
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||||
|
邮箱
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||||
|
许可证数
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||||
|
创建时间
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||||
|
操作
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200">
|
||||||
|
{filteredCustomers.map((customer) => (
|
||||||
|
<tr key={customer.id}>
|
||||||
|
<td className="px-6 py-4 font-medium">{customer.companyName}</td>
|
||||||
|
<td className="px-6 py-4 text-gray-600">{customer.contactName || '-'}</td>
|
||||||
|
<td className="px-6 py-4 text-gray-600">{customer.contactEmail || '-'}</td>
|
||||||
|
<td className="px-6 py-4">{customer._count.licenses}</td>
|
||||||
|
<td className="px-6 py-4 text-gray-600">
|
||||||
|
{new Date(customer.createdAt).toLocaleDateString()}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<Link
|
||||||
|
href={`/admin/customers/${customer.id}`}
|
||||||
|
className="text-blue-600 hover:underline"
|
||||||
|
>
|
||||||
|
查看
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{!filteredCustomers.length && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="px-6 py-8 text-center text-gray-500">
|
||||||
|
暂无客户数据
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{showModal && (
|
||||||
|
<CustomerModal
|
||||||
|
onClose={() => setShowModal(false)}
|
||||||
|
onSuccess={() => {
|
||||||
|
setShowModal(false);
|
||||||
|
fetchCustomers();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CustomerModal({
|
||||||
|
onClose,
|
||||||
|
onSuccess,
|
||||||
|
}: {
|
||||||
|
onClose: () => void;
|
||||||
|
onSuccess: () => void;
|
||||||
|
}) {
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
companyName: '',
|
||||||
|
contactName: '',
|
||||||
|
contactEmail: '',
|
||||||
|
contactPhone: '',
|
||||||
|
});
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/customers', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(form),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
onSuccess();
|
||||||
|
} else {
|
||||||
|
setError(data.error || '创建失败');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError('网络错误');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white rounded-lg shadow-xl w-full max-w-md">
|
||||||
|
<div className="p-6 border-b">
|
||||||
|
<h2 className="text-lg font-medium">添加客户</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
公司名称 *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.companyName}
|
||||||
|
onChange={(e) => setForm({ ...form, companyName: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border rounded-lg"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
联系人
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.contactName}
|
||||||
|
onChange={(e) => setForm({ ...form, contactName: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border rounded-lg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
邮箱
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={form.contactEmail}
|
||||||
|
onChange={(e) => setForm({ ...form, contactEmail: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border rounded-lg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
电话
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
value={form.contactPhone}
|
||||||
|
onChange={(e) => setForm({ ...form, contactPhone: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border rounded-lg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="text-red-600 text-sm bg-red-50 p-3 rounded-lg">{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-3 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="flex-1 py-2 border rounded-lg hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="flex-1 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? '创建中...' : '创建'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
398
license-server/app/admin/licenses/page.tsx
Normal file
398
license-server/app/admin/licenses/page.tsx
Normal file
@ -0,0 +1,398 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
interface License {
|
||||||
|
id: string;
|
||||||
|
licenseKey: string;
|
||||||
|
licenseType: string;
|
||||||
|
status: string;
|
||||||
|
expiresAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
customer: { companyName: string } | null;
|
||||||
|
_count: { activations: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Customer {
|
||||||
|
id: string;
|
||||||
|
companyName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LicensesPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [licenses, setLicenses] = useState<License[]>([]);
|
||||||
|
const [customers, setCustomers] = useState<Customer[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
const [licensesRes, customersRes] = await Promise.all([
|
||||||
|
fetch('/api/admin/licenses'),
|
||||||
|
fetch('/api/admin/customers'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (licensesRes.status === 401) {
|
||||||
|
router.push('/admin/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const licensesData = await licensesRes.json();
|
||||||
|
const customersData = await customersRes.json();
|
||||||
|
|
||||||
|
setLicenses(licensesData.licenses || []);
|
||||||
|
setCustomers(customersData.customers || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取数据失败:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredLicenses = licenses.filter(
|
||||||
|
(l) =>
|
||||||
|
l.licenseKey.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
l.customer?.companyName.toLowerCase().includes(search.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDisable = async (id: string) => {
|
||||||
|
if (!confirm('确定要禁用此许可证吗?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/admin/licenses/${id}/disable`, { method: 'POST' });
|
||||||
|
if (res.ok) {
|
||||||
|
fetchData();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('禁用失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = async (id: string) => {
|
||||||
|
if (!confirm('确定要重置硬件绑定吗?这将允许在新设备上激活。')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/admin/licenses/${id}/reset`, { method: 'POST' });
|
||||||
|
if (res.ok) {
|
||||||
|
fetchData();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('重置失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-100">
|
||||||
|
<nav className="bg-white shadow">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 py-4 flex justify-between items-center">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Link href="/admin" className="text-gray-600 hover:text-gray-900">
|
||||||
|
← 返回
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-xl font-bold">许可证管理</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main className="max-w-7xl mx-auto px-4 py-8">
|
||||||
|
<div className="bg-white rounded-lg shadow">
|
||||||
|
<div className="p-6 border-b flex justify-between items-center">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="搜索许可证..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="px-4 py-2 border rounded-lg w-64"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowModal(true)}
|
||||||
|
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
生成许可证
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="p-8 text-center text-gray-500">加载中...</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||||
|
许可证密钥
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||||
|
类型
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||||
|
状态
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||||
|
客户
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||||
|
激活数
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||||
|
到期时间
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||||
|
操作
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200">
|
||||||
|
{filteredLicenses.map((license) => (
|
||||||
|
<tr key={license.id}>
|
||||||
|
<td className="px-6 py-4 font-mono text-sm">{license.licenseKey}</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<TypeBadge type={license.licenseType} />
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<StatusBadge status={license.status} />
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-gray-600">
|
||||||
|
{license.customer?.companyName || '-'}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">{license._count.activations}</td>
|
||||||
|
<td className="px-6 py-4 text-gray-600">
|
||||||
|
{license.expiresAt
|
||||||
|
? new Date(license.expiresAt).toLocaleDateString()
|
||||||
|
: '永久'}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{license.status === 'active' && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleDisable(license.id)}
|
||||||
|
className="text-red-600 hover:underline text-sm"
|
||||||
|
>
|
||||||
|
禁用
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => handleReset(license.id)}
|
||||||
|
className="text-blue-600 hover:underline text-sm"
|
||||||
|
>
|
||||||
|
重置绑定
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{!filteredLicenses.length && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className="px-6 py-8 text-center text-gray-500">
|
||||||
|
暂无许可证数据
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{showModal && (
|
||||||
|
<LicenseModal
|
||||||
|
customers={customers}
|
||||||
|
onClose={() => setShowModal(false)}
|
||||||
|
onSuccess={() => {
|
||||||
|
setShowModal(false);
|
||||||
|
fetchData();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TypeBadge({ type }: { type: string }) {
|
||||||
|
const colors: Record<string, string> = {
|
||||||
|
FREE: 'bg-gray-100 text-gray-700',
|
||||||
|
STANDARD: 'bg-blue-100 text-blue-700',
|
||||||
|
PRO: 'bg-purple-100 text-purple-700',
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<span className={`px-2 py-1 rounded text-xs font-medium ${colors[type] || colors.FREE}`}>
|
||||||
|
{type}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ status }: { status: string }) {
|
||||||
|
const colors: Record<string, string> = {
|
||||||
|
active: 'bg-green-100 text-green-700',
|
||||||
|
disabled: 'bg-red-100 text-red-700',
|
||||||
|
expired: 'bg-yellow-100 text-yellow-700',
|
||||||
|
};
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
active: '正常',
|
||||||
|
disabled: '已禁用',
|
||||||
|
expired: '已过期',
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<span className={`px-2 py-1 rounded text-xs font-medium ${colors[status] || colors.active}`}>
|
||||||
|
{labels[status] || status}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LicenseModal({
|
||||||
|
customers,
|
||||||
|
onClose,
|
||||||
|
onSuccess,
|
||||||
|
}: {
|
||||||
|
customers: Customer[];
|
||||||
|
onClose: () => void;
|
||||||
|
onSuccess: () => void;
|
||||||
|
}) {
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
customerId: '',
|
||||||
|
licenseType: 'STANDARD',
|
||||||
|
expiresInDays: '',
|
||||||
|
});
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [generatedKey, setGeneratedKey] = useState('');
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/licenses', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
customerId: form.customerId || null,
|
||||||
|
licenseType: form.licenseType,
|
||||||
|
expiresInDays: form.expiresInDays ? parseInt(form.expiresInDays) : null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
setGeneratedKey(data.license.licenseKey);
|
||||||
|
} else {
|
||||||
|
setError(data.error || '生成失败');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError('网络错误');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (generatedKey) {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white rounded-lg shadow-xl w-full max-w-md p-6">
|
||||||
|
<h2 className="text-lg font-medium mb-4">许可证已生成</h2>
|
||||||
|
<div className="bg-gray-100 p-4 rounded-lg mb-4">
|
||||||
|
<p className="text-sm text-gray-500 mb-2">许可证密钥:</p>
|
||||||
|
<p className="font-mono text-lg font-bold text-center">{generatedKey}</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-500 mb-4">请妥善保存此密钥,它只会显示一次。</p>
|
||||||
|
<button
|
||||||
|
onClick={onSuccess}
|
||||||
|
className="w-full py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
完成
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white rounded-lg shadow-xl w-full max-w-md">
|
||||||
|
<div className="p-6 border-b">
|
||||||
|
<h2 className="text-lg font-medium">生成许可证</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
关联客户
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={form.customerId}
|
||||||
|
onChange={(e) => setForm({ ...form, customerId: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border rounded-lg"
|
||||||
|
>
|
||||||
|
<option value="">不关联客户</option>
|
||||||
|
{customers.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>
|
||||||
|
{c.companyName}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
许可证类型 *
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={form.licenseType}
|
||||||
|
onChange={(e) => setForm({ ...form, licenseType: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border rounded-lg"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="FREE">免费版</option>
|
||||||
|
<option value="STANDARD">标准版</option>
|
||||||
|
<option value="PRO">专业版</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
有效期(天)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={form.expiresInDays}
|
||||||
|
onChange={(e) => setForm({ ...form, expiresInDays: e.target.value })}
|
||||||
|
placeholder="留空表示永久有效"
|
||||||
|
className="w-full px-3 py-2 border rounded-lg"
|
||||||
|
min="1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="text-red-600 text-sm bg-red-50 p-3 rounded-lg">{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-3 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="flex-1 py-2 border rounded-lg hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="flex-1 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? '生成中...' : '生成'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
88
license-server/app/admin/login/page.tsx
Normal file
88
license-server/app/admin/login/page.tsx
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
export default function AdminLoginPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
router.push('/admin');
|
||||||
|
} else {
|
||||||
|
setError(data.error || '登录失败');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError('网络错误');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-100">
|
||||||
|
<div className="bg-white p-8 rounded-lg shadow-md w-full max-w-md">
|
||||||
|
<h1 className="text-2xl font-bold text-center mb-6">许可证管理后台</h1>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
用户名
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
密码
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="text-red-600 text-sm bg-red-50 p-3 rounded-lg">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{loading ? '登录中...' : '登录'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
217
license-server/app/admin/page.tsx
Normal file
217
license-server/app/admin/page.tsx
Normal file
@ -0,0 +1,217 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
interface DashboardStats {
|
||||||
|
customers: { total: number };
|
||||||
|
licenses: { total: number; byType: Record<string, number>; byStatus: Record<string, number> };
|
||||||
|
activations: { total: number; active: number };
|
||||||
|
alerts: { pending: number };
|
||||||
|
recentActivations: Array<{
|
||||||
|
id: string;
|
||||||
|
licenseKey: string;
|
||||||
|
licenseType: string;
|
||||||
|
activatedAt: string;
|
||||||
|
companyName: string | null;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminDashboard() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [stats, setStats] = useState<DashboardStats | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchStats();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchStats = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/dashboard');
|
||||||
|
if (res.status === 401) {
|
||||||
|
router.push('/admin/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
setStats(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取统计数据失败:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
await fetch('/api/admin/auth/logout', { method: 'POST' });
|
||||||
|
router.push('/admin/login');
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-100">
|
||||||
|
<div className="text-gray-500">加载中...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-100">
|
||||||
|
{/* 顶部导航 */}
|
||||||
|
<nav className="bg-white shadow">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 py-4 flex justify-between items-center">
|
||||||
|
<h1 className="text-xl font-bold">许可证管理后台</h1>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Link href="/admin/customers" className="text-gray-600 hover:text-gray-900">
|
||||||
|
客户管理
|
||||||
|
</Link>
|
||||||
|
<Link href="/admin/licenses" className="text-gray-600 hover:text-gray-900">
|
||||||
|
许可证管理
|
||||||
|
</Link>
|
||||||
|
<Link href="/admin/alerts" className="text-gray-600 hover:text-gray-900">
|
||||||
|
告警中心
|
||||||
|
{stats?.alerts.pending ? (
|
||||||
|
<span className="ml-1 px-2 py-0.5 bg-red-500 text-white text-xs rounded-full">
|
||||||
|
{stats.alerts.pending}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="text-gray-600 hover:text-gray-900"
|
||||||
|
>
|
||||||
|
退出
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main className="max-w-7xl mx-auto px-4 py-8">
|
||||||
|
{/* 统计卡片 */}
|
||||||
|
<div className="grid grid-cols-4 gap-6 mb-8">
|
||||||
|
<StatCard title="客户总数" value={stats?.customers.total || 0} />
|
||||||
|
<StatCard title="许可证总数" value={stats?.licenses.total || 0} />
|
||||||
|
<StatCard title="活跃激活" value={stats?.activations.active || 0} />
|
||||||
|
<StatCard
|
||||||
|
title="待处理告警"
|
||||||
|
value={stats?.alerts.pending || 0}
|
||||||
|
highlight={!!stats?.alerts.pending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 许可证分布 */}
|
||||||
|
<div className="grid grid-cols-2 gap-6 mb-8">
|
||||||
|
<div className="bg-white rounded-lg shadow p-6">
|
||||||
|
<h3 className="text-lg font-medium mb-4">许可证类型分布</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{Object.entries(stats?.licenses.byType || {}).map(([type, count]) => (
|
||||||
|
<div key={type} className="flex justify-between items-center">
|
||||||
|
<span className="text-gray-600">{type}</span>
|
||||||
|
<span className="font-medium">{count}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg shadow p-6">
|
||||||
|
<h3 className="text-lg font-medium mb-4">许可证状态分布</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{Object.entries(stats?.licenses.byStatus || {}).map(([status, count]) => (
|
||||||
|
<div key={status} className="flex justify-between items-center">
|
||||||
|
<span className="text-gray-600">
|
||||||
|
{status === 'active' ? '正常' : status === 'disabled' ? '已禁用' : '已过期'}
|
||||||
|
</span>
|
||||||
|
<span className="font-medium">{count}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 最近激活 */}
|
||||||
|
<div className="bg-white rounded-lg shadow">
|
||||||
|
<div className="p-6 border-b">
|
||||||
|
<h3 className="text-lg font-medium">最近激活</h3>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||||
|
许可证密钥
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||||
|
类型
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||||
|
客户
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||||
|
激活时间
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200">
|
||||||
|
{stats?.recentActivations.map((activation) => (
|
||||||
|
<tr key={activation.id}>
|
||||||
|
<td className="px-6 py-4 font-mono text-sm">{activation.licenseKey}</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<TypeBadge type={activation.licenseType} />
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-gray-600">
|
||||||
|
{activation.companyName || '-'}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-gray-600">
|
||||||
|
{new Date(activation.activatedAt).toLocaleString()}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{!stats?.recentActivations.length && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={4} className="px-6 py-8 text-center text-gray-500">
|
||||||
|
暂无激活记录
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({
|
||||||
|
title,
|
||||||
|
value,
|
||||||
|
highlight = false,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
value: number;
|
||||||
|
highlight?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={`bg-white rounded-lg shadow p-6 ${highlight ? 'ring-2 ring-red-500' : ''}`}>
|
||||||
|
<div className="text-sm text-gray-500 mb-1">{title}</div>
|
||||||
|
<div className={`text-3xl font-bold ${highlight ? 'text-red-600' : 'text-gray-900'}`}>
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TypeBadge({ type }: { type: string }) {
|
||||||
|
const colors: Record<string, string> = {
|
||||||
|
FREE: 'bg-gray-100 text-gray-700',
|
||||||
|
STANDARD: 'bg-blue-100 text-blue-700',
|
||||||
|
PRO: 'bg-purple-100 text-purple-700',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={`px-2 py-1 rounded text-xs font-medium ${colors[type] || colors.FREE}`}>
|
||||||
|
{type}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
156
license-server/app/admin/versions/page.tsx
Normal file
156
license-server/app/admin/versions/page.tsx
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
interface Version {
|
||||||
|
id: string;
|
||||||
|
version: string;
|
||||||
|
releaseNotes: string | null;
|
||||||
|
minRequiredVersion: string | null;
|
||||||
|
isSecurityUpdate: boolean;
|
||||||
|
isCriticalUpdate: boolean;
|
||||||
|
downloadSize: string | null;
|
||||||
|
releaseDate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VersionsPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [versions, setVersions] = useState<Version[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchVersions();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchVersions = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/versions');
|
||||||
|
if (res.status === 401) {
|
||||||
|
router.push('/admin/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
setVersions(data.versions || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取版本列表失败:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-100">
|
||||||
|
<nav className="bg-white shadow">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 py-4 flex justify-between items-center">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Link href="/admin" className="text-gray-600 hover:text-gray-900">← 返回</Link>
|
||||||
|
<h1 className="text-xl font-bold">版本管理</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main className="max-w-7xl mx-auto px-4 py-8">
|
||||||
|
<div className="bg-white rounded-lg shadow">
|
||||||
|
<div className="p-6 border-b flex justify-between items-center">
|
||||||
|
<h2 className="font-medium">版本列表</h2>
|
||||||
|
<button onClick={() => setShowModal(true)} className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
|
||||||
|
发布新版本
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="p-8 text-center text-gray-500">加载中...</div>
|
||||||
|
) : (
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">版本号</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">发布日期</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">最低要求版本</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">标签</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">更新说明</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200">
|
||||||
|
{versions.map((v) => (
|
||||||
|
<tr key={v.id}>
|
||||||
|
<td className="px-6 py-4 font-mono font-medium">{v.version}</td>
|
||||||
|
<td className="px-6 py-4 text-gray-600">{new Date(v.releaseDate).toLocaleDateString()}</td>
|
||||||
|
<td className="px-6 py-4 text-gray-600">{v.minRequiredVersion || '-'}</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
{v.isSecurityUpdate && <span className="px-2 py-1 bg-red-100 text-red-700 rounded text-xs mr-1">安全更新</span>}
|
||||||
|
{v.isCriticalUpdate && <span className="px-2 py-1 bg-yellow-100 text-yellow-700 rounded text-xs">重要更新</span>}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-gray-600 max-w-xs truncate">{v.releaseNotes || '-'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{!versions.length && (
|
||||||
|
<tr><td colSpan={5} className="px-6 py-8 text-center text-gray-500">暂无版本数据</td></tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{showModal && <VersionModal onClose={() => setShowModal(false)} onSuccess={() => { setShowModal(false); fetchVersions(); }} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function VersionModal({ onClose, onSuccess }: { onClose: () => void; onSuccess: () => void }) {
|
||||||
|
const [form, setForm] = useState({ version: '', releaseNotes: '', minRequiredVersion: '', isSecurityUpdate: false, isCriticalUpdate: false });
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/versions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(form),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) onSuccess();
|
||||||
|
else setError(data.error || '发布失败');
|
||||||
|
} catch { setError('网络错误'); }
|
||||||
|
finally { setLoading(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white rounded-lg shadow-xl w-full max-w-md">
|
||||||
|
<div className="p-6 border-b"><h2 className="text-lg font-medium">发布新版本</h2></div>
|
||||||
|
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">版本号 *</label>
|
||||||
|
<input type="text" value={form.version} onChange={(e) => setForm({ ...form, version: e.target.value })} className="w-full px-3 py-2 border rounded-lg" placeholder="1.0.0" required />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">更新说明</label>
|
||||||
|
<textarea value={form.releaseNotes} onChange={(e) => setForm({ ...form, releaseNotes: e.target.value })} className="w-full px-3 py-2 border rounded-lg" rows={3} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">最低要求版本</label>
|
||||||
|
<input type="text" value={form.minRequiredVersion} onChange={(e) => setForm({ ...form, minRequiredVersion: e.target.value })} className="w-full px-3 py-2 border rounded-lg" placeholder="留空表示不强制更新" />
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<label className="flex items-center gap-2"><input type="checkbox" checked={form.isSecurityUpdate} onChange={(e) => setForm({ ...form, isSecurityUpdate: e.target.checked })} /><span className="text-sm">安全更新</span></label>
|
||||||
|
<label className="flex items-center gap-2"><input type="checkbox" checked={form.isCriticalUpdate} onChange={(e) => setForm({ ...form, isCriticalUpdate: e.target.checked })} /><span className="text-sm">重要更新</span></label>
|
||||||
|
</div>
|
||||||
|
{error && <div className="text-red-600 text-sm bg-red-50 p-3 rounded-lg">{error}</div>}
|
||||||
|
<div className="flex gap-3 pt-4">
|
||||||
|
<button type="button" onClick={onClose} className="flex-1 py-2 border rounded-lg hover:bg-gray-50">取消</button>
|
||||||
|
<button type="submit" disabled={loading} className="flex-1 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50">{loading ? '发布中...' : '发布'}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
license-server/app/api/admin/alerts/[id]/ignore/route.ts
Normal file
30
license-server/app/api/admin/alerts/[id]/ignore/route.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getAdminSession } from '@/lib/auth';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const session = await getAdminSession();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
await prisma.alert.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
status: 'ignored',
|
||||||
|
resolvedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('忽略告警失败:', error);
|
||||||
|
return NextResponse.json({ success: false, error: '服务器错误' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
30
license-server/app/api/admin/alerts/[id]/resolve/route.ts
Normal file
30
license-server/app/api/admin/alerts/[id]/resolve/route.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getAdminSession } from '@/lib/auth';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const session = await getAdminSession();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
await prisma.alert.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
status: 'resolved',
|
||||||
|
resolvedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('处理告警失败:', error);
|
||||||
|
return NextResponse.json({ success: false, error: '服务器错误' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
41
license-server/app/api/admin/alerts/route.ts
Normal file
41
license-server/app/api/admin/alerts/route.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getAdminSession } from '@/lib/auth';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
import { AlertStatus, Prisma } from '@prisma/client';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const session = await getAdminSession();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const status = searchParams.get('status') || 'pending';
|
||||||
|
|
||||||
|
let where: Prisma.AlertWhereInput = {};
|
||||||
|
if (status === 'resolved') {
|
||||||
|
where = { status: { in: [AlertStatus.resolved, AlertStatus.ignored] } };
|
||||||
|
} else if (status !== 'all') {
|
||||||
|
where = { status: AlertStatus.pending };
|
||||||
|
}
|
||||||
|
|
||||||
|
const alerts = await prisma.alert.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
include: {
|
||||||
|
license: {
|
||||||
|
select: {
|
||||||
|
licenseKey: true,
|
||||||
|
customer: { select: { companyName: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ alerts });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取告警列表失败:', error);
|
||||||
|
return NextResponse.json({ error: '服务器错误' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
46
license-server/app/api/admin/auth/login/route.ts
Normal file
46
license-server/app/api/admin/auth/login/route.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { verifyAdmin } from '@/lib/auth';
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { username, password } = await request.json();
|
||||||
|
|
||||||
|
if (!username || !password) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: '请输入用户名和密码' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await verifyAdmin(username, password);
|
||||||
|
|
||||||
|
if (result.success && result.token) {
|
||||||
|
const response = NextResponse.json({ success: true });
|
||||||
|
|
||||||
|
// 根据请求协议决定是否设置 Secure(支持 HTTP 部署)
|
||||||
|
const isSecure = request.headers.get('x-forwarded-proto') === 'https' ||
|
||||||
|
request.url.startsWith('https://');
|
||||||
|
|
||||||
|
response.cookies.set('admin_token', result.token, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: isSecure,
|
||||||
|
sameSite: 'lax',
|
||||||
|
maxAge: 24 * 60 * 60, // 24小时
|
||||||
|
path: '/',
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: result.error },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('登录失败:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: '服务器错误' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
15
license-server/app/api/admin/auth/logout/route.ts
Normal file
15
license-server/app/api/admin/auth/logout/route.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
const response = NextResponse.json({ success: true });
|
||||||
|
|
||||||
|
response.cookies.set('admin_token', '', {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
sameSite: 'lax',
|
||||||
|
maxAge: 0,
|
||||||
|
path: '/',
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
56
license-server/app/api/admin/customers/route.ts
Normal file
56
license-server/app/api/admin/customers/route.ts
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getAdminSession } from '@/lib/auth';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const session = await getAdminSession();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const customers = await prisma.customer.findMany({
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
include: {
|
||||||
|
_count: { select: { licenses: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ customers });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取客户列表失败:', error);
|
||||||
|
return NextResponse.json({ error: '服务器错误' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const session = await getAdminSession();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { companyName, contactName, contactEmail, contactPhone } = await request.json();
|
||||||
|
|
||||||
|
if (!companyName) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: '公司名称不能为空' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const customer = await prisma.customer.create({
|
||||||
|
data: {
|
||||||
|
companyName,
|
||||||
|
contactName: contactName || null,
|
||||||
|
contactEmail: contactEmail || null,
|
||||||
|
contactPhone: contactPhone || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, customer });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('创建客户失败:', error);
|
||||||
|
return NextResponse.json({ success: false, error: '服务器错误' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
78
license-server/app/api/admin/dashboard/route.ts
Normal file
78
license-server/app/api/admin/dashboard/route.ts
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getAdminSession } from '@/lib/auth';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const session = await getAdminSession();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 客户统计
|
||||||
|
const customerCount = await prisma.customer.count();
|
||||||
|
|
||||||
|
// 许可证统计
|
||||||
|
const licenseCount = await prisma.license.count();
|
||||||
|
const licensesByType = await prisma.license.groupBy({
|
||||||
|
by: ['licenseType'],
|
||||||
|
_count: true,
|
||||||
|
});
|
||||||
|
const licensesByStatus = await prisma.license.groupBy({
|
||||||
|
by: ['status'],
|
||||||
|
_count: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 激活统计
|
||||||
|
const activationCount = await prisma.activation.count();
|
||||||
|
const activeActivations = await prisma.activation.count({
|
||||||
|
where: { isActive: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 待处理告警
|
||||||
|
const pendingAlerts = await prisma.alert.count({
|
||||||
|
where: { status: 'pending' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 最近激活
|
||||||
|
const recentActivations = await prisma.activation.findMany({
|
||||||
|
take: 10,
|
||||||
|
orderBy: { activatedAt: 'desc' },
|
||||||
|
include: {
|
||||||
|
license: {
|
||||||
|
include: {
|
||||||
|
customer: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
customers: { total: customerCount },
|
||||||
|
licenses: {
|
||||||
|
total: licenseCount,
|
||||||
|
byType: Object.fromEntries(
|
||||||
|
licensesByType.map((item) => [item.licenseType, item._count])
|
||||||
|
),
|
||||||
|
byStatus: Object.fromEntries(
|
||||||
|
licensesByStatus.map((item) => [item.status, item._count])
|
||||||
|
),
|
||||||
|
},
|
||||||
|
activations: {
|
||||||
|
total: activationCount,
|
||||||
|
active: activeActivations,
|
||||||
|
},
|
||||||
|
alerts: { pending: pendingAlerts },
|
||||||
|
recentActivations: recentActivations.map((a) => ({
|
||||||
|
id: a.id,
|
||||||
|
licenseKey: a.license.licenseKey,
|
||||||
|
licenseType: a.license.licenseType,
|
||||||
|
activatedAt: a.activatedAt.toISOString(),
|
||||||
|
companyName: a.license.customer?.companyName || null,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取仪表盘数据失败:', error);
|
||||||
|
return NextResponse.json({ error: '服务器错误' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
27
license-server/app/api/admin/licenses/[id]/disable/route.ts
Normal file
27
license-server/app/api/admin/licenses/[id]/disable/route.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getAdminSession } from '@/lib/auth';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const session = await getAdminSession();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
await prisma.license.update({
|
||||||
|
where: { id },
|
||||||
|
data: { status: 'disabled' },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('禁用许可证失败:', error);
|
||||||
|
return NextResponse.json({ success: false, error: '服务器错误' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
28
license-server/app/api/admin/licenses/[id]/reset/route.ts
Normal file
28
license-server/app/api/admin/licenses/[id]/reset/route.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getAdminSession } from '@/lib/auth';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const session = await getAdminSession();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
// 将所有激活记录标记为非活跃
|
||||||
|
await prisma.activation.updateMany({
|
||||||
|
where: { licenseId: id },
|
||||||
|
data: { isActive: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('重置硬件绑定失败:', error);
|
||||||
|
return NextResponse.json({ success: false, error: '服务器错误' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
68
license-server/app/api/admin/licenses/route.ts
Normal file
68
license-server/app/api/admin/licenses/route.ts
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getAdminSession, generateLicenseKey } from '@/lib/auth';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
import { LICENSE_CONFIGS } from '@/lib/types';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const session = await getAdminSession();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const licenses = await prisma.license.findMany({
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
include: {
|
||||||
|
customer: { select: { companyName: true } },
|
||||||
|
_count: { select: { activations: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ licenses });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取许可证列表失败:', error);
|
||||||
|
return NextResponse.json({ error: '服务器错误' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const session = await getAdminSession();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { customerId, licenseType, expiresInDays } = await request.json();
|
||||||
|
|
||||||
|
if (!licenseType || !['FREE', 'STANDARD', 'PRO'].includes(licenseType)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: '无效的许可证类型' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = LICENSE_CONFIGS[licenseType as keyof typeof LICENSE_CONFIGS];
|
||||||
|
const licenseKey = generateLicenseKey();
|
||||||
|
|
||||||
|
const expiresAt = expiresInDays
|
||||||
|
? new Date(Date.now() + expiresInDays * 24 * 60 * 60 * 1000)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const license = await prisma.license.create({
|
||||||
|
data: {
|
||||||
|
licenseKey,
|
||||||
|
licenseType,
|
||||||
|
customerId: customerId || null,
|
||||||
|
maxUsers: config.limits.maxUsers,
|
||||||
|
maxMaterials: config.limits.maxMaterials,
|
||||||
|
features: config.features,
|
||||||
|
expiresAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, license });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('生成许可证失败:', error);
|
||||||
|
return NextResponse.json({ success: false, error: '服务器错误' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
58
license-server/app/api/admin/versions/route.ts
Normal file
58
license-server/app/api/admin/versions/route.ts
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getAdminSession } from '@/lib/auth';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const session = await getAdminSession();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const versions = await prisma.version.findMany({
|
||||||
|
orderBy: { releaseDate: 'desc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ versions });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取版本列表失败:', error);
|
||||||
|
return NextResponse.json({ error: '服务器错误' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const session = await getAdminSession();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { version, releaseNotes, minRequiredVersion, isSecurityUpdate, isCriticalUpdate } =
|
||||||
|
await request.json();
|
||||||
|
|
||||||
|
if (!version) {
|
||||||
|
return NextResponse.json({ success: false, error: '版本号不能为空' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查版本号是否已存在
|
||||||
|
const existing = await prisma.version.findUnique({ where: { version } });
|
||||||
|
if (existing) {
|
||||||
|
return NextResponse.json({ success: false, error: '版本号已存在' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const newVersion = await prisma.version.create({
|
||||||
|
data: {
|
||||||
|
version,
|
||||||
|
releaseNotes: releaseNotes || null,
|
||||||
|
minRequiredVersion: minRequiredVersion || null,
|
||||||
|
isSecurityUpdate: isSecurityUpdate || false,
|
||||||
|
isCriticalUpdate: isCriticalUpdate || false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, version: newVersion });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('发布版本失败:', error);
|
||||||
|
return NextResponse.json({ success: false, error: '服务器错误' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
135
license-server/app/api/license/activate/route.ts
Normal file
135
license-server/app/api/license/activate/route.ts
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
import { signLicense, hashFingerprint, isValidLicenseKeyFormat } from '@/lib/crypto';
|
||||||
|
import { generateLicenseConfig, type HardwareFingerprint } from '@/lib/types';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
// 请求验证 schema
|
||||||
|
const activateSchema = z.object({
|
||||||
|
licenseKey: z.string(),
|
||||||
|
fingerprint: z.object({
|
||||||
|
machineId: z.string(),
|
||||||
|
hostname: z.string(),
|
||||||
|
macAddress: z.string(),
|
||||||
|
cpuId: z.string().optional(),
|
||||||
|
instanceId: z.string(),
|
||||||
|
}),
|
||||||
|
timestamp: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const parsed = activateSchema.safeParse(body);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: '请求参数无效' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { licenseKey, fingerprint, timestamp } = parsed.data;
|
||||||
|
|
||||||
|
// 验证时间戳(防止重放攻击,允许 5 分钟误差)
|
||||||
|
const now = Date.now();
|
||||||
|
if (Math.abs(now - timestamp) > 5 * 60 * 1000) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: '请求已过期' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证许可证密钥格式
|
||||||
|
if (!isValidLicenseKeyFormat(licenseKey)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: '许可证密钥格式无效' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查找许可证
|
||||||
|
const license = await prisma.license.findUnique({
|
||||||
|
where: { licenseKey },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!license) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: '许可证密钥不存在' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查许可证状态
|
||||||
|
if (license.status === 'disabled') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: '许可证已被禁用' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (license.status === 'expired' || (license.expiresAt && license.expiresAt < new Date())) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: '许可证已过期' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否已激活
|
||||||
|
const existingActivation = await prisma.activation.findFirst({
|
||||||
|
where: {
|
||||||
|
licenseId: license.id,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingActivation) {
|
||||||
|
// 检查是否是同一设备重新激活
|
||||||
|
const existingFingerprint = existingActivation.fingerprint as unknown as HardwareFingerprint;
|
||||||
|
if (existingFingerprint.instanceId !== fingerprint.instanceId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: '许可证已在其他设备上激活,请联系管理员重置' },
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// 同一设备,更新激活信息
|
||||||
|
await prisma.activation.update({
|
||||||
|
where: { id: existingActivation.id },
|
||||||
|
data: {
|
||||||
|
fingerprint: fingerprint as object,
|
||||||
|
lastHeartbeat: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 创建新激活记录
|
||||||
|
await prisma.activation.create({
|
||||||
|
data: {
|
||||||
|
licenseId: license.id,
|
||||||
|
instanceId: fingerprint.instanceId,
|
||||||
|
fingerprint: fingerprint as object,
|
||||||
|
lastHeartbeat: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成许可证配置
|
||||||
|
const licenseConfig = generateLicenseConfig(
|
||||||
|
license.licenseType,
|
||||||
|
license.expiresAt
|
||||||
|
);
|
||||||
|
|
||||||
|
// 签名许可证
|
||||||
|
const signedLicense = signLicense(licenseConfig);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
license: signedLicense,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('激活失败:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: '服务器内部错误' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
158
license-server/app/api/license/heartbeat/route.ts
Normal file
158
license-server/app/api/license/heartbeat/route.ts
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
import { signLicense, hashFingerprint } from '@/lib/crypto';
|
||||||
|
import { generateLicenseConfig, type HardwareFingerprint } from '@/lib/types';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
// 请求验证 schema
|
||||||
|
const heartbeatSchema = z.object({
|
||||||
|
instanceId: z.string(),
|
||||||
|
fingerprint: z.object({
|
||||||
|
machineId: z.string(),
|
||||||
|
hostname: z.string(),
|
||||||
|
macAddress: z.string(),
|
||||||
|
cpuId: z.string().optional(),
|
||||||
|
instanceId: z.string(),
|
||||||
|
}),
|
||||||
|
usage: z.object({
|
||||||
|
userCount: z.number(),
|
||||||
|
materialCount: z.number(),
|
||||||
|
}),
|
||||||
|
timestamp: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 检测异常:24小时内多设备使用
|
||||||
|
async function detectAnomaly(licenseId: string, fingerprintHash: string) {
|
||||||
|
const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
// 获取24小时内的不同指纹
|
||||||
|
const recentHeartbeats = await prisma.heartbeatLog.findMany({
|
||||||
|
where: {
|
||||||
|
licenseId,
|
||||||
|
createdAt: { gte: twentyFourHoursAgo },
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
fingerprintHash: true,
|
||||||
|
},
|
||||||
|
distinct: ['fingerprintHash'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const uniqueFingerprints = new Set(recentHeartbeats.map(h => h.fingerprintHash));
|
||||||
|
uniqueFingerprints.add(fingerprintHash);
|
||||||
|
|
||||||
|
// 如果超过1个不同的指纹,创建告警
|
||||||
|
if (uniqueFingerprints.size > 1) {
|
||||||
|
// 检查是否已有未处理的告警
|
||||||
|
const existingAlert = await prisma.alert.findFirst({
|
||||||
|
where: {
|
||||||
|
licenseId,
|
||||||
|
alertType: 'multi_device',
|
||||||
|
status: 'pending',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingAlert) {
|
||||||
|
await prisma.alert.create({
|
||||||
|
data: {
|
||||||
|
licenseId,
|
||||||
|
alertType: 'multi_device',
|
||||||
|
details: {
|
||||||
|
fingerprints: Array.from(uniqueFingerprints),
|
||||||
|
detectedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const parsed = heartbeatSchema.safeParse(body);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ acknowledged: false },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { instanceId, fingerprint, usage, timestamp } = parsed.data;
|
||||||
|
|
||||||
|
// 验证时间戳
|
||||||
|
const now = Date.now();
|
||||||
|
if (Math.abs(now - timestamp) > 5 * 60 * 1000) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ acknowledged: false },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查找激活记录
|
||||||
|
const activation = await prisma.activation.findFirst({
|
||||||
|
where: {
|
||||||
|
instanceId,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
license: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!activation) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ acknowledged: false },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { license } = activation;
|
||||||
|
const fingerprintHash = hashFingerprint(fingerprint);
|
||||||
|
|
||||||
|
// 记录心跳日志
|
||||||
|
await prisma.heartbeatLog.create({
|
||||||
|
data: {
|
||||||
|
licenseId: license.id,
|
||||||
|
instanceId,
|
||||||
|
fingerprintHash,
|
||||||
|
ipAddress: request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 更新激活记录
|
||||||
|
await prisma.activation.update({
|
||||||
|
where: { id: activation.id },
|
||||||
|
data: {
|
||||||
|
lastHeartbeat: new Date(),
|
||||||
|
usageStats: usage,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 异常检测
|
||||||
|
await detectAnomaly(license.id, fingerprintHash);
|
||||||
|
|
||||||
|
// 检查许可证是否有更新
|
||||||
|
let signedLicense = undefined;
|
||||||
|
|
||||||
|
// 如果许可证状态变化,返回新配置
|
||||||
|
if (license.status === 'active') {
|
||||||
|
const licenseConfig = generateLicenseConfig(
|
||||||
|
license.licenseType,
|
||||||
|
license.expiresAt
|
||||||
|
);
|
||||||
|
signedLicense = signLicense(licenseConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
acknowledged: true,
|
||||||
|
license: signedLicense,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('心跳处理失败:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ acknowledged: false },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
150
license-server/app/api/license/verify/route.ts
Normal file
150
license-server/app/api/license/verify/route.ts
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
import { signLicense, hashFingerprint } from '@/lib/crypto';
|
||||||
|
import { generateLicenseConfig, type HardwareFingerprint } from '@/lib/types';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
// 请求验证 schema
|
||||||
|
const verifySchema = z.object({
|
||||||
|
instanceId: z.string(),
|
||||||
|
fingerprint: z.object({
|
||||||
|
machineId: z.string(),
|
||||||
|
hostname: z.string(),
|
||||||
|
macAddress: z.string(),
|
||||||
|
cpuId: z.string().optional(),
|
||||||
|
instanceId: z.string(),
|
||||||
|
}),
|
||||||
|
timestamp: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 计算指纹匹配分数
|
||||||
|
function calculateFingerprintMatchScore(
|
||||||
|
stored: HardwareFingerprint,
|
||||||
|
current: HardwareFingerprint
|
||||||
|
): number {
|
||||||
|
let score = 0;
|
||||||
|
let total = 0;
|
||||||
|
|
||||||
|
// machineId 权重 40%
|
||||||
|
if (stored.machineId === current.machineId) {
|
||||||
|
score += 40;
|
||||||
|
}
|
||||||
|
total += 40;
|
||||||
|
|
||||||
|
// hostname 权重 20%
|
||||||
|
if (stored.hostname === current.hostname) {
|
||||||
|
score += 20;
|
||||||
|
}
|
||||||
|
total += 20;
|
||||||
|
|
||||||
|
// macAddress 权重 30%
|
||||||
|
if (stored.macAddress === current.macAddress) {
|
||||||
|
score += 30;
|
||||||
|
}
|
||||||
|
total += 30;
|
||||||
|
|
||||||
|
// instanceId 权重 10%
|
||||||
|
if (stored.instanceId === current.instanceId) {
|
||||||
|
score += 10;
|
||||||
|
}
|
||||||
|
total += 10;
|
||||||
|
|
||||||
|
return (score / total) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const parsed = verifySchema.safeParse(body);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ valid: false, error: '请求参数无效' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { instanceId, fingerprint, timestamp } = parsed.data;
|
||||||
|
|
||||||
|
// 验证时间戳
|
||||||
|
const now = Date.now();
|
||||||
|
if (Math.abs(now - timestamp) > 5 * 60 * 1000) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ valid: false, error: '请求已过期' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查找激活记录
|
||||||
|
const activation = await prisma.activation.findFirst({
|
||||||
|
where: {
|
||||||
|
instanceId,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
license: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!activation) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ valid: false, error: '未找到激活记录' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { license } = activation;
|
||||||
|
|
||||||
|
// 检查许可证状态
|
||||||
|
if (license.status === 'disabled') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ valid: false, error: '许可证已被禁用' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (license.status === 'expired' || (license.expiresAt && license.expiresAt < new Date())) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ valid: false, error: '许可证已过期' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证硬件指纹匹配度
|
||||||
|
const storedFingerprint = activation.fingerprint as unknown as HardwareFingerprint;
|
||||||
|
const matchScore = calculateFingerprintMatchScore(storedFingerprint, fingerprint);
|
||||||
|
|
||||||
|
if (matchScore < 60) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ valid: false, error: '硬件指纹不匹配,请重新激活' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新最后验证时间
|
||||||
|
await prisma.activation.update({
|
||||||
|
where: { id: activation.id },
|
||||||
|
data: { lastHeartbeat: new Date() },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 生成许可证配置
|
||||||
|
const licenseConfig = generateLicenseConfig(
|
||||||
|
license.licenseType,
|
||||||
|
license.expiresAt
|
||||||
|
);
|
||||||
|
|
||||||
|
// 签名许可证
|
||||||
|
const signedLicense = signLicense(licenseConfig);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
valid: true,
|
||||||
|
license: signedLicense,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('验证失败:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ valid: false, error: '服务器内部错误' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
32
license-server/app/api/version/agent/route.ts
Normal file
32
license-server/app/api/version/agent/route.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
// 获取最新的 Agent 版本
|
||||||
|
const latestAgent = await prisma.agentVersion.findFirst({
|
||||||
|
orderBy: { releaseDate: 'desc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!latestAgent) {
|
||||||
|
// 如果没有记录,返回默认版本
|
||||||
|
return NextResponse.json({
|
||||||
|
latestVersion: '1.0.0',
|
||||||
|
releaseNotes: null,
|
||||||
|
releaseDate: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
latestVersion: latestAgent.version,
|
||||||
|
releaseNotes: latestAgent.releaseNotes,
|
||||||
|
releaseDate: latestAgent.releaseDate.toISOString(),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取 Agent 版本失败:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '服务器内部错误' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
40
license-server/app/api/version/check/route.ts
Normal file
40
license-server/app/api/version/check/route.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
import type { VersionCheckResponse } from '@/lib/types';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const searchParams = request.nextUrl.searchParams;
|
||||||
|
const currentVersion = searchParams.get('current');
|
||||||
|
|
||||||
|
// 获取最新版本
|
||||||
|
const latestVersion = await prisma.version.findFirst({
|
||||||
|
orderBy: { releaseDate: 'desc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!latestVersion) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '未找到版本信息' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response: VersionCheckResponse = {
|
||||||
|
latestVersion: latestVersion.version,
|
||||||
|
minRequiredVersion: latestVersion.minRequiredVersion,
|
||||||
|
releaseNotes: latestVersion.releaseNotes,
|
||||||
|
releaseDate: latestVersion.releaseDate.toISOString(),
|
||||||
|
isSecurityUpdate: latestVersion.isSecurityUpdate,
|
||||||
|
isCriticalUpdate: latestVersion.isCriticalUpdate,
|
||||||
|
downloadSize: latestVersion.downloadSize,
|
||||||
|
};
|
||||||
|
|
||||||
|
return NextResponse.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('版本检查失败:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '服务器内部错误' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
license-server/app/globals.css
Normal file
13
license-server/app/globals.css
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--foreground-rgb: 0, 0, 0;
|
||||||
|
--background-rgb: 255, 255, 255;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
color: rgb(var(--foreground-rgb));
|
||||||
|
background: rgb(var(--background-rgb));
|
||||||
|
}
|
||||||
19
license-server/app/layout.tsx
Normal file
19
license-server/app/layout.tsx
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
import './globals.css';
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'FLNXI 许可证管理系统',
|
||||||
|
description: 'FLNXI 素材系统许可证管理后台',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<body>{children}</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
license-server/app/page.tsx
Normal file
22
license-server/app/page.tsx
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="text-4xl font-bold text-gray-900 mb-4">
|
||||||
|
FLNXI 许可证管理系统
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-600 mb-8">
|
||||||
|
许可证验证服务和客户管理后台
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="/admin"
|
||||||
|
className="inline-block px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
进入管理后台
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
129
license-server/deploy.ps1
Normal file
129
license-server/deploy.ps1
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
# 许可证服务器部署脚本
|
||||||
|
# 部署到 45.249.208.118
|
||||||
|
|
||||||
|
param(
|
||||||
|
[string]$Server = "45.249.208.118",
|
||||||
|
[string]$User = "root",
|
||||||
|
[string]$SshKey = "$env:USERPROFILE\.ssh\youyidian_prod_ed25519",
|
||||||
|
[string]$RemotePath = "/opt/license-server",
|
||||||
|
[switch]$SkipBuild = $false
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
Write-Host "========================================" -ForegroundColor Cyan
|
||||||
|
Write-Host "许可证服务器部署" -ForegroundColor Cyan
|
||||||
|
Write-Host "========================================" -ForegroundColor Cyan
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "目标服务器: $User@$Server" -ForegroundColor Yellow
|
||||||
|
Write-Host "远程路径: $RemotePath" -ForegroundColor Yellow
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# SSH 命令封装
|
||||||
|
function Invoke-RemoteCommand {
|
||||||
|
param([string]$Command)
|
||||||
|
ssh -i $SshKey "$User@$Server" $Command
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "远程命令执行失败: $Command" }
|
||||||
|
}
|
||||||
|
|
||||||
|
# SCP 上传
|
||||||
|
function Upload-File {
|
||||||
|
param([string]$Local, [string]$Remote)
|
||||||
|
scp -i $SshKey -r $Local "$User@$Server`:$Remote"
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "文件上传失败: $Local" }
|
||||||
|
}
|
||||||
|
|
||||||
|
# 1. 检查 SSH 连接
|
||||||
|
Write-Host "1. 检查 SSH 连接..." -ForegroundColor White
|
||||||
|
try {
|
||||||
|
Invoke-RemoteCommand "echo 'SSH 连接成功'"
|
||||||
|
Write-Host " SSH 连接成功 ✓" -ForegroundColor Green
|
||||||
|
} catch {
|
||||||
|
Write-Host " SSH 连接失败,请检查密钥和网络" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# 2. 创建远程目录
|
||||||
|
Write-Host "2. 创建远程目录..." -ForegroundColor White
|
||||||
|
Invoke-RemoteCommand "mkdir -p $RemotePath"
|
||||||
|
Write-Host " 目录创建成功 ✓" -ForegroundColor Green
|
||||||
|
|
||||||
|
# 3. 上传文件
|
||||||
|
Write-Host "3. 上传项目文件..." -ForegroundColor White
|
||||||
|
|
||||||
|
# 创建临时目录存放要上传的文件
|
||||||
|
$tempDir = Join-Path $env:TEMP "license-server-deploy"
|
||||||
|
if (Test-Path $tempDir) { Remove-Item -Recurse -Force $tempDir }
|
||||||
|
New-Item -ItemType Directory -Path $tempDir | Out-Null
|
||||||
|
|
||||||
|
# 复制必要文件
|
||||||
|
Copy-Item -Path "docker-compose.yml" -Destination $tempDir
|
||||||
|
Copy-Item -Path "Dockerfile" -Destination $tempDir
|
||||||
|
Copy-Item -Path "package*.json" -Destination $tempDir
|
||||||
|
Copy-Item -Path "next.config.js" -Destination $tempDir
|
||||||
|
Copy-Item -Path "tsconfig.json" -Destination $tempDir
|
||||||
|
Copy-Item -Path "tailwind.config.ts" -Destination $tempDir
|
||||||
|
Copy-Item -Path "postcss.config.js" -Destination $tempDir
|
||||||
|
Copy-Item -Path "prisma" -Destination $tempDir -Recurse
|
||||||
|
Copy-Item -Path "app" -Destination $tempDir -Recurse
|
||||||
|
Copy-Item -Path "lib" -Destination $tempDir -Recurse
|
||||||
|
Copy-Item -Path "components" -Destination $tempDir -Recurse -ErrorAction SilentlyContinue
|
||||||
|
if (Test-Path "public") { Copy-Item -Path "public" -Destination $tempDir -Recurse }
|
||||||
|
|
||||||
|
# 上传
|
||||||
|
Upload-File "$tempDir/*" $RemotePath
|
||||||
|
Write-Host " 文件上传成功 ✓" -ForegroundColor Green
|
||||||
|
|
||||||
|
# 清理临时目录
|
||||||
|
Remove-Item -Recurse -Force $tempDir
|
||||||
|
|
||||||
|
# 4. 创建 .env 文件(如果不存在)
|
||||||
|
Write-Host "4. 检查环境变量配置..." -ForegroundColor White
|
||||||
|
$envExists = ssh -i $SshKey "$User@$Server" "test -f $RemotePath/.env && echo 'exists'"
|
||||||
|
if ($envExists -ne "exists") {
|
||||||
|
Write-Host " 创建 .env 文件模板..." -ForegroundColor Yellow
|
||||||
|
$envContent = @"
|
||||||
|
# 许可证服务器环境变量
|
||||||
|
JWT_SECRET=your-jwt-secret-change-this
|
||||||
|
RSA_PRIVATE_KEY=your-rsa-private-key
|
||||||
|
"@
|
||||||
|
ssh -i $SshKey "$User@$Server" "cat > $RemotePath/.env << 'EOF'
|
||||||
|
$envContent
|
||||||
|
EOF"
|
||||||
|
Write-Host " 请编辑 $RemotePath/.env 配置 RSA 私钥" -ForegroundColor Yellow
|
||||||
|
} else {
|
||||||
|
Write-Host " .env 文件已存在 ✓" -ForegroundColor Green
|
||||||
|
}
|
||||||
|
|
||||||
|
# 5. 构建和启动
|
||||||
|
Write-Host "5. 构建和启动服务..." -ForegroundColor White
|
||||||
|
Invoke-RemoteCommand "cd $RemotePath && docker compose down 2>/dev/null || true"
|
||||||
|
Invoke-RemoteCommand "cd $RemotePath && docker compose build --no-cache"
|
||||||
|
Invoke-RemoteCommand "cd $RemotePath && docker compose up -d"
|
||||||
|
Write-Host " 服务启动成功 ✓" -ForegroundColor Green
|
||||||
|
|
||||||
|
# 6. 等待服务就绪
|
||||||
|
Write-Host "6. 等待服务就绪..." -ForegroundColor White
|
||||||
|
Start-Sleep -Seconds 10
|
||||||
|
|
||||||
|
# 7. 运行数据库迁移
|
||||||
|
Write-Host "7. 运行数据库迁移..." -ForegroundColor White
|
||||||
|
Invoke-RemoteCommand "cd $RemotePath && docker compose exec -T license-server npx prisma db push"
|
||||||
|
Write-Host " 数据库迁移成功 ✓" -ForegroundColor Green
|
||||||
|
|
||||||
|
# 8. 检查服务状态
|
||||||
|
Write-Host "8. 检查服务状态..." -ForegroundColor White
|
||||||
|
$status = ssh -i $SshKey "$User@$Server" "docker compose -f $RemotePath/docker-compose.yml ps --format 'table {{.Name}}\t{{.Status}}'"
|
||||||
|
Write-Host $status
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "========================================" -ForegroundColor Cyan
|
||||||
|
Write-Host "部署完成!" -ForegroundColor Green
|
||||||
|
Write-Host "========================================" -ForegroundColor Cyan
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "许可证服务器地址: http://$Server`:3001" -ForegroundColor Yellow
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "后续步骤:" -ForegroundColor White
|
||||||
|
Write-Host "1. 配置 RSA 私钥: ssh -i $SshKey $User@$Server 'vim $RemotePath/.env'" -ForegroundColor Gray
|
||||||
|
Write-Host "2. 重启服务: ssh -i $SshKey $User@$Server 'cd $RemotePath && docker compose restart'" -ForegroundColor Gray
|
||||||
|
Write-Host "3. 初始化管理员: ssh -i $SshKey $User@$Server 'cd $RemotePath && docker compose exec license-server npx ts-node scripts/init-admin.ts'" -ForegroundColor Gray
|
||||||
44
license-server/docker-compose.yml
Normal file
44
license-server/docker-compose.yml
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
license-server:
|
||||||
|
build: .
|
||||||
|
container_name: license-server
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "9527:3000"
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=postgresql://license:license_secret@postgres:5432/license_db
|
||||||
|
- JWT_SECRET=${JWT_SECRET:-license-server-jwt-secret}
|
||||||
|
- RSA_PRIVATE_KEY=${RSA_PRIVATE_KEY}
|
||||||
|
- NODE_ENV=production
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- license-network
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: license-postgres
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- POSTGRES_USER=license
|
||||||
|
- POSTGRES_PASSWORD=license_secret
|
||||||
|
- POSTGRES_DB=license_db
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U license -d license_db"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- license-network
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
license-network:
|
||||||
|
driver: bridge
|
||||||
18
license-server/jest.config.js
Normal file
18
license-server/jest.config.js
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
/** @type {import('jest').Config} */
|
||||||
|
const config = {
|
||||||
|
preset: 'ts-jest',
|
||||||
|
testEnvironment: 'node',
|
||||||
|
roots: ['<rootDir>'],
|
||||||
|
testMatch: ['**/__tests__/**/*.test.ts', '**/*.test.ts'],
|
||||||
|
moduleNameMapper: {
|
||||||
|
'^@/(.*)$': '<rootDir>/$1',
|
||||||
|
},
|
||||||
|
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
|
||||||
|
collectCoverageFrom: [
|
||||||
|
'lib/**/*.ts',
|
||||||
|
'app/api/**/*.ts',
|
||||||
|
'!**/*.d.ts',
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = config
|
||||||
1
license-server/jest.setup.js
Normal file
1
license-server/jest.setup.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
// Jest setup file
|
||||||
122
license-server/lib/auth.ts
Normal file
122
license-server/lib/auth.ts
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import { prisma } from './db';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
|
||||||
|
const JWT_SECRET = process.env.JWT_SECRET || 'license-server-secret-key';
|
||||||
|
const TOKEN_EXPIRY = '24h';
|
||||||
|
|
||||||
|
export interface AdminSession {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证管理员登录
|
||||||
|
*/
|
||||||
|
export async function verifyAdmin(
|
||||||
|
username: string,
|
||||||
|
password: string
|
||||||
|
): Promise<{ success: boolean; token?: string; error?: string }> {
|
||||||
|
try {
|
||||||
|
const admin = await prisma.admin.findUnique({
|
||||||
|
where: { username },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!admin) {
|
||||||
|
return { success: false, error: '用户名或密码错误' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValid = await bcrypt.compare(password, admin.passwordHash);
|
||||||
|
if (!isValid) {
|
||||||
|
return { success: false, error: '用户名或密码错误' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = jwt.sign(
|
||||||
|
{ id: admin.id, username: admin.username },
|
||||||
|
JWT_SECRET,
|
||||||
|
{ expiresIn: TOKEN_EXPIRY }
|
||||||
|
);
|
||||||
|
|
||||||
|
return { success: true, token };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('验证失败:', error);
|
||||||
|
return { success: false, error: '服务器错误' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证 JWT token
|
||||||
|
*/
|
||||||
|
export function verifyToken(token: string): AdminSession | null {
|
||||||
|
try {
|
||||||
|
const decoded = jwt.verify(token, JWT_SECRET) as AdminSession;
|
||||||
|
return decoded;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从请求中获取当前管理员会话
|
||||||
|
*/
|
||||||
|
export async function getAdminSession(): Promise<AdminSession | null> {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const token = cookieStore.get('admin_token')?.value;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return verifyToken(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建管理员账号(用于初始化)
|
||||||
|
*/
|
||||||
|
export async function createAdmin(
|
||||||
|
username: string,
|
||||||
|
password: string
|
||||||
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
|
try {
|
||||||
|
const existing = await prisma.admin.findUnique({
|
||||||
|
where: { username },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
return { success: false, error: '用户名已存在' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(password, 10);
|
||||||
|
|
||||||
|
await prisma.admin.create({
|
||||||
|
data: {
|
||||||
|
username,
|
||||||
|
passwordHash,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('创建管理员失败:', error);
|
||||||
|
return { success: false, error: '服务器错误' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成许可证密钥
|
||||||
|
*/
|
||||||
|
export function generateLicenseKey(): string {
|
||||||
|
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||||
|
const segments: string[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
let segment = '';
|
||||||
|
for (let j = 0; j < 4; j++) {
|
||||||
|
segment += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||||
|
}
|
||||||
|
segments.push(segment);
|
||||||
|
}
|
||||||
|
|
||||||
|
return segments.join('-');
|
||||||
|
}
|
||||||
124
license-server/lib/crypto.ts
Normal file
124
license-server/lib/crypto.ts
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
import * as crypto from 'crypto';
|
||||||
|
|
||||||
|
// RSA 密钥
|
||||||
|
const RSA_PRIVATE_KEY = process.env.RSA_PRIVATE_KEY?.replace(/\\n/g, '\n') || '';
|
||||||
|
const RSA_PUBLIC_KEY = process.env.RSA_PUBLIC_KEY?.replace(/\\n/g, '\n') || '';
|
||||||
|
|
||||||
|
// AES 密钥
|
||||||
|
const AES_SECRET_KEY = process.env.AES_SECRET_KEY || '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用 RSA 私钥签名数据
|
||||||
|
*/
|
||||||
|
export function signData(data: string): string {
|
||||||
|
const sign = crypto.createSign('RSA-SHA256');
|
||||||
|
sign.update(data);
|
||||||
|
sign.end();
|
||||||
|
return sign.sign(RSA_PRIVATE_KEY, 'base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用 RSA 公钥验证签名
|
||||||
|
*/
|
||||||
|
export function verifySignature(data: string, signature: string): boolean {
|
||||||
|
try {
|
||||||
|
const verify = crypto.createVerify('RSA-SHA256');
|
||||||
|
verify.update(data);
|
||||||
|
verify.end();
|
||||||
|
return verify.verify(RSA_PUBLIC_KEY, signature, 'base64');
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用 AES-256-GCM 加密数据
|
||||||
|
*/
|
||||||
|
export function encryptAES(plaintext: string): string {
|
||||||
|
const iv = crypto.randomBytes(12);
|
||||||
|
const key = Buffer.from(AES_SECRET_KEY, 'hex');
|
||||||
|
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
||||||
|
|
||||||
|
let encrypted = cipher.update(plaintext, 'utf8', 'base64');
|
||||||
|
encrypted += cipher.final('base64');
|
||||||
|
|
||||||
|
const authTag = cipher.getAuthTag();
|
||||||
|
|
||||||
|
// 格式: iv:authTag:encrypted
|
||||||
|
return `${iv.toString('base64')}:${authTag.toString('base64')}:${encrypted}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用 AES-256-GCM 解密数据
|
||||||
|
*/
|
||||||
|
export function decryptAES(ciphertext: string): string {
|
||||||
|
const [ivBase64, authTagBase64, encrypted] = ciphertext.split(':');
|
||||||
|
|
||||||
|
const iv = Buffer.from(ivBase64, 'base64');
|
||||||
|
const authTag = Buffer.from(authTagBase64, 'base64');
|
||||||
|
const key = Buffer.from(AES_SECRET_KEY, 'hex');
|
||||||
|
|
||||||
|
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
|
||||||
|
decipher.setAuthTag(authTag);
|
||||||
|
|
||||||
|
let decrypted = decipher.update(encrypted, 'base64', 'utf8');
|
||||||
|
decrypted += decipher.final('utf8');
|
||||||
|
|
||||||
|
return decrypted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成许可证密钥 (XXXX-XXXX-XXXX-XXXX 格式)
|
||||||
|
*/
|
||||||
|
export function generateLicenseKey(): string {
|
||||||
|
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // 排除容易混淆的字符
|
||||||
|
const segments: string[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
let segment = '';
|
||||||
|
for (let j = 0; j < 4; j++) {
|
||||||
|
segment += chars[crypto.randomInt(chars.length)];
|
||||||
|
}
|
||||||
|
segments.push(segment);
|
||||||
|
}
|
||||||
|
|
||||||
|
return segments.join('-');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证许可证密钥格式
|
||||||
|
*/
|
||||||
|
export function isValidLicenseKeyFormat(key: string): boolean {
|
||||||
|
return /^[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$/.test(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算硬件指纹哈希
|
||||||
|
*/
|
||||||
|
export function hashFingerprint(fingerprint: object): string {
|
||||||
|
const data = JSON.stringify(fingerprint);
|
||||||
|
return crypto.createHash('sha256').update(data).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 签名许可证数据
|
||||||
|
*/
|
||||||
|
export function signLicense(licenseData: object): { data: object; signature: string; issuedAt: string } {
|
||||||
|
const issuedAt = new Date().toISOString();
|
||||||
|
const dataWithTimestamp = { ...licenseData, issuedAt };
|
||||||
|
const dataString = JSON.stringify(dataWithTimestamp);
|
||||||
|
const signature = signData(dataString);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: dataWithTimestamp,
|
||||||
|
signature,
|
||||||
|
issuedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取公钥(供客户端使用)
|
||||||
|
*/
|
||||||
|
export function getPublicKey(): string {
|
||||||
|
return RSA_PUBLIC_KEY;
|
||||||
|
}
|
||||||
15
license-server/lib/db.ts
Normal file
15
license-server/lib/db.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
const globalForPrisma = globalThis as unknown as {
|
||||||
|
prisma: PrismaClient | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const prisma =
|
||||||
|
globalForPrisma.prisma ??
|
||||||
|
new PrismaClient({
|
||||||
|
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
|
||||||
|
|
||||||
|
export default prisma;
|
||||||
153
license-server/lib/types.ts
Normal file
153
license-server/lib/types.ts
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
// 许可证类型
|
||||||
|
export type LicenseType = 'FREE' | 'STANDARD' | 'PRO';
|
||||||
|
|
||||||
|
// 许可证配置
|
||||||
|
export interface LicenseConfig {
|
||||||
|
type: LicenseType;
|
||||||
|
limits: {
|
||||||
|
maxUsers: number; // -1 表示无限
|
||||||
|
maxMaterials: number; // -1 表示无限
|
||||||
|
};
|
||||||
|
features: {
|
||||||
|
customTemplates: boolean;
|
||||||
|
systemSettings: boolean;
|
||||||
|
departmentIsolation: boolean;
|
||||||
|
apiAccess: boolean;
|
||||||
|
intranetPenetration: boolean;
|
||||||
|
};
|
||||||
|
expiresAt: string | null; // ISO 日期字符串,null 表示永久
|
||||||
|
}
|
||||||
|
|
||||||
|
// 签名后的许可证
|
||||||
|
export interface SignedLicense {
|
||||||
|
data: LicenseConfig & { issuedAt: string };
|
||||||
|
signature: string;
|
||||||
|
issuedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 硬件指纹
|
||||||
|
export interface HardwareFingerprint {
|
||||||
|
machineId: string;
|
||||||
|
hostname: string;
|
||||||
|
macAddress: string;
|
||||||
|
cpuId?: string;
|
||||||
|
instanceId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 激活请求
|
||||||
|
export interface ActivateRequest {
|
||||||
|
licenseKey: string;
|
||||||
|
fingerprint: HardwareFingerprint;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 激活响应
|
||||||
|
export interface ActivateResponse {
|
||||||
|
success: boolean;
|
||||||
|
license?: SignedLicense;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证请求
|
||||||
|
export interface VerifyRequest {
|
||||||
|
instanceId: string;
|
||||||
|
fingerprint: HardwareFingerprint;
|
||||||
|
timestamp: number;
|
||||||
|
signature: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证响应
|
||||||
|
export interface VerifyResponse {
|
||||||
|
valid: boolean;
|
||||||
|
license?: SignedLicense;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 心跳请求
|
||||||
|
export interface HeartbeatRequest {
|
||||||
|
instanceId: string;
|
||||||
|
fingerprint: HardwareFingerprint;
|
||||||
|
usage: {
|
||||||
|
userCount: number;
|
||||||
|
materialCount: number;
|
||||||
|
};
|
||||||
|
timestamp: number;
|
||||||
|
signature: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 心跳响应
|
||||||
|
export interface HeartbeatResponse {
|
||||||
|
acknowledged: boolean;
|
||||||
|
license?: SignedLicense;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 版本检查响应
|
||||||
|
export interface VersionCheckResponse {
|
||||||
|
latestVersion: string;
|
||||||
|
minRequiredVersion: string | null;
|
||||||
|
releaseNotes: string | null;
|
||||||
|
releaseDate: string;
|
||||||
|
isSecurityUpdate: boolean;
|
||||||
|
isCriticalUpdate: boolean;
|
||||||
|
downloadSize: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 各版本的默认配置
|
||||||
|
export const LICENSE_CONFIGS: Record<LicenseType, Omit<LicenseConfig, 'expiresAt'>> = {
|
||||||
|
FREE: {
|
||||||
|
type: 'FREE',
|
||||||
|
limits: {
|
||||||
|
maxUsers: 10,
|
||||||
|
maxMaterials: 100,
|
||||||
|
},
|
||||||
|
features: {
|
||||||
|
customTemplates: false,
|
||||||
|
systemSettings: false,
|
||||||
|
departmentIsolation: false,
|
||||||
|
apiAccess: false,
|
||||||
|
intranetPenetration: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
STANDARD: {
|
||||||
|
type: 'STANDARD',
|
||||||
|
limits: {
|
||||||
|
maxUsers: -1,
|
||||||
|
maxMaterials: -1,
|
||||||
|
},
|
||||||
|
features: {
|
||||||
|
customTemplates: true,
|
||||||
|
systemSettings: true,
|
||||||
|
departmentIsolation: true,
|
||||||
|
apiAccess: true,
|
||||||
|
intranetPenetration: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
PRO: {
|
||||||
|
type: 'PRO',
|
||||||
|
limits: {
|
||||||
|
maxUsers: -1,
|
||||||
|
maxMaterials: -1,
|
||||||
|
},
|
||||||
|
features: {
|
||||||
|
customTemplates: true,
|
||||||
|
systemSettings: true,
|
||||||
|
departmentIsolation: true,
|
||||||
|
apiAccess: true,
|
||||||
|
intranetPenetration: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据许可证类型生成配置
|
||||||
|
*/
|
||||||
|
export function generateLicenseConfig(
|
||||||
|
type: LicenseType,
|
||||||
|
expiresAt?: Date | null
|
||||||
|
): LicenseConfig {
|
||||||
|
const baseConfig = LICENSE_CONFIGS[type];
|
||||||
|
return {
|
||||||
|
...baseConfig,
|
||||||
|
expiresAt: expiresAt ? expiresAt.toISOString() : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
7
license-server/next.config.js
Normal file
7
license-server/next.config.js
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
reactStrictMode: true,
|
||||||
|
output: 'standalone',
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = nextConfig
|
||||||
51
license-server/nginx/nginx.conf
Normal file
51
license-server/nginx/nginx.conf
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
# 限制请求速率(防止暴力破解和 DDoS)
|
||||||
|
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
|
||||||
|
limit_req_zone $binary_remote_addr zone=login_limit:10m rate=1r/s;
|
||||||
|
limit_conn_zone $binary_remote_addr zone=conn_limit:10m;
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name lic.flnxi.com;
|
||||||
|
|
||||||
|
# 基础安全头
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
|
|
||||||
|
# 限制连接数
|
||||||
|
limit_conn conn_limit 20;
|
||||||
|
|
||||||
|
# 禁止访问隐藏文件
|
||||||
|
location ~ /\. {
|
||||||
|
deny all;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 登录接口 - 严格限流(防暴力破解)
|
||||||
|
location /api/admin/auth/login {
|
||||||
|
limit_req zone=login_limit burst=3 nodelay;
|
||||||
|
proxy_pass http://127.0.0.1:9527;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
# API 接口 - 普通限流
|
||||||
|
location /api/ {
|
||||||
|
limit_req zone=api_limit burst=20 nodelay;
|
||||||
|
proxy_pass http://127.0.0.1:9527;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 静态资源和页面
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:9527;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
6494
license-server/package-lock.json
generated
Normal file
6494
license-server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
41
license-server/package.json
Normal file
41
license-server/package.json
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"name": "license-server",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev -p 3001",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start -p 3001",
|
||||||
|
"lint": "next lint",
|
||||||
|
"db:generate": "prisma generate",
|
||||||
|
"db:push": "prisma db push",
|
||||||
|
"db:migrate": "prisma migrate dev",
|
||||||
|
"db:studio": "prisma studio",
|
||||||
|
"generate-keys": "ts-node scripts/generate-keys.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"next": "14.2.5",
|
||||||
|
"react": "^18",
|
||||||
|
"react-dom": "^18",
|
||||||
|
"@prisma/client": "^5.22.0",
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"nanoid": "^5.0.7",
|
||||||
|
"zod": "^3.23.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5",
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/react": "^18",
|
||||||
|
"@types/react-dom": "^18",
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
"@types/jsonwebtoken": "^9.0.7",
|
||||||
|
"prisma": "^5.22.0",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"tailwindcss": "^3.4.1",
|
||||||
|
"postcss": "^8",
|
||||||
|
"autoprefixer": "^10.0.1",
|
||||||
|
"eslint": "^8",
|
||||||
|
"eslint-config-next": "14.2.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
license-server/postcss.config.js
Normal file
6
license-server/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
148
license-server/prisma/schema.prisma
Normal file
148
license-server/prisma/schema.prisma
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
binaryTargets = ["native", "linux-musl", "debian-openssl-3.0.x"]
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 客户表
|
||||||
|
model Customer {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
companyName String @map("company_name")
|
||||||
|
contactName String? @map("contact_name")
|
||||||
|
contactEmail String? @map("contact_email")
|
||||||
|
contactPhone String? @map("contact_phone")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
licenses License[]
|
||||||
|
|
||||||
|
@@map("customers")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 许可证类型枚举
|
||||||
|
enum LicenseType {
|
||||||
|
FREE
|
||||||
|
STANDARD
|
||||||
|
PRO
|
||||||
|
}
|
||||||
|
|
||||||
|
// 许可证状态枚举
|
||||||
|
enum LicenseStatus {
|
||||||
|
active
|
||||||
|
disabled
|
||||||
|
expired
|
||||||
|
}
|
||||||
|
|
||||||
|
// 许可证表
|
||||||
|
model License {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
customerId String? @map("customer_id")
|
||||||
|
licenseKey String @unique @map("license_key")
|
||||||
|
licenseType LicenseType @map("license_type")
|
||||||
|
status LicenseStatus @default(active)
|
||||||
|
maxUsers Int @default(-1) @map("max_users")
|
||||||
|
maxMaterials Int @default(-1) @map("max_materials")
|
||||||
|
features Json @default("{}")
|
||||||
|
expiresAt DateTime? @map("expires_at")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
customer Customer? @relation(fields: [customerId], references: [id])
|
||||||
|
activations Activation[]
|
||||||
|
heartbeatLogs HeartbeatLog[]
|
||||||
|
alerts Alert[]
|
||||||
|
|
||||||
|
@@map("licenses")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 激活记录表
|
||||||
|
model Activation {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
licenseId String @map("license_id")
|
||||||
|
instanceId String @map("instance_id")
|
||||||
|
fingerprint Json
|
||||||
|
activatedAt DateTime @default(now()) @map("activated_at")
|
||||||
|
lastHeartbeat DateTime? @map("last_heartbeat")
|
||||||
|
usageStats Json @default("{}") @map("usage_stats")
|
||||||
|
isActive Boolean @default(true) @map("is_active")
|
||||||
|
|
||||||
|
license License @relation(fields: [licenseId], references: [id])
|
||||||
|
|
||||||
|
@@map("activations")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 心跳记录表
|
||||||
|
model HeartbeatLog {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
licenseId String @map("license_id")
|
||||||
|
instanceId String @map("instance_id")
|
||||||
|
fingerprintHash String @map("fingerprint_hash")
|
||||||
|
ipAddress String? @map("ip_address")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
license License @relation(fields: [licenseId], references: [id])
|
||||||
|
|
||||||
|
@@map("heartbeat_logs")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 告警类型枚举
|
||||||
|
enum AlertStatus {
|
||||||
|
pending
|
||||||
|
resolved
|
||||||
|
ignored
|
||||||
|
}
|
||||||
|
|
||||||
|
// 告警表
|
||||||
|
model Alert {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
licenseId String @map("license_id")
|
||||||
|
alertType String @map("alert_type")
|
||||||
|
details Json?
|
||||||
|
status AlertStatus @default(pending)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
resolvedAt DateTime? @map("resolved_at")
|
||||||
|
|
||||||
|
license License @relation(fields: [licenseId], references: [id])
|
||||||
|
|
||||||
|
@@map("alerts")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 管理员表
|
||||||
|
model Admin {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
username String @unique
|
||||||
|
passwordHash String @map("password_hash")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
@@map("admins")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 版本信息表(素材系统)
|
||||||
|
model Version {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
version String @unique
|
||||||
|
releaseNotes String? @map("release_notes")
|
||||||
|
minRequiredVersion String? @map("min_required_version")
|
||||||
|
isSecurityUpdate Boolean @default(false) @map("is_security_update")
|
||||||
|
isCriticalUpdate Boolean @default(false) @map("is_critical_update")
|
||||||
|
downloadSize String? @map("download_size")
|
||||||
|
releaseDate DateTime @default(now()) @map("release_date")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
@@map("versions")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Agent 版本信息表
|
||||||
|
model AgentVersion {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
version String @unique
|
||||||
|
releaseNotes String? @map("release_notes")
|
||||||
|
releaseDate DateTime @default(now()) @map("release_date")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
@@map("agent_versions")
|
||||||
|
}
|
||||||
67
license-server/scripts/generate-keys.ts
Normal file
67
license-server/scripts/generate-keys.ts
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import * as crypto from 'crypto';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
// 生成 RSA 密钥对
|
||||||
|
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', {
|
||||||
|
modulusLength: 2048,
|
||||||
|
publicKeyEncoding: {
|
||||||
|
type: 'spki',
|
||||||
|
format: 'pem',
|
||||||
|
},
|
||||||
|
privateKeyEncoding: {
|
||||||
|
type: 'pkcs8',
|
||||||
|
format: 'pem',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 生成 AES 密钥(32字节)
|
||||||
|
const aesKey = crypto.randomBytes(32).toString('hex');
|
||||||
|
|
||||||
|
// 生成 JWT 密钥
|
||||||
|
const jwtSecret = crypto.randomBytes(32).toString('hex');
|
||||||
|
|
||||||
|
// 创建 keys 目录
|
||||||
|
const keysDir = path.join(__dirname, '..', 'keys');
|
||||||
|
if (!fs.existsSync(keysDir)) {
|
||||||
|
fs.mkdirSync(keysDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存密钥文件
|
||||||
|
fs.writeFileSync(path.join(keysDir, 'private.key'), privateKey);
|
||||||
|
fs.writeFileSync(path.join(keysDir, 'public.key'), publicKey);
|
||||||
|
|
||||||
|
// 生成 .env 内容
|
||||||
|
const envContent = `# 数据库连接
|
||||||
|
DATABASE_URL="postgresql://postgres:password@localhost:5432/license_server?schema=public"
|
||||||
|
|
||||||
|
# RSA 私钥(用于签名许可证)
|
||||||
|
RSA_PRIVATE_KEY="${privateKey.replace(/\n/g, '\\n')}"
|
||||||
|
|
||||||
|
# RSA 公钥(嵌入客户端用于验证签名)
|
||||||
|
RSA_PUBLIC_KEY="${publicKey.replace(/\n/g, '\\n')}"
|
||||||
|
|
||||||
|
# AES 加密密钥
|
||||||
|
AES_SECRET_KEY="${aesKey}"
|
||||||
|
|
||||||
|
# JWT 密钥
|
||||||
|
JWT_SECRET="${jwtSecret}"
|
||||||
|
|
||||||
|
# 管理员初始密码
|
||||||
|
ADMIN_INITIAL_PASSWORD="admin123"
|
||||||
|
`;
|
||||||
|
|
||||||
|
fs.writeFileSync(path.join(__dirname, '..', '.env'), envContent);
|
||||||
|
|
||||||
|
console.log('✅ 密钥生成完成!');
|
||||||
|
console.log('');
|
||||||
|
console.log('文件已生成:');
|
||||||
|
console.log(' - keys/private.key (RSA 私钥)');
|
||||||
|
console.log(' - keys/public.key (RSA 公钥)');
|
||||||
|
console.log(' - .env (环境变量配置)');
|
||||||
|
console.log('');
|
||||||
|
console.log('⚠️ 请妥善保管私钥,不要提交到代码仓库!');
|
||||||
|
console.log('');
|
||||||
|
console.log('📋 客户端公钥(复制到素材系统):');
|
||||||
|
console.log('─'.repeat(50));
|
||||||
|
console.log(publicKey);
|
||||||
42
license-server/scripts/init-admin.ts
Normal file
42
license-server/scripts/init-admin.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const username = process.argv[2] || 'admin';
|
||||||
|
const password = process.argv[3] || 'admin123';
|
||||||
|
|
||||||
|
// 检查是否已存在
|
||||||
|
const existing = await prisma.admin.findUnique({
|
||||||
|
where: { username },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
console.log(`管理员 "${username}" 已存在`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(password, 10);
|
||||||
|
|
||||||
|
await prisma.admin.create({
|
||||||
|
data: {
|
||||||
|
username,
|
||||||
|
passwordHash,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`管理员创建成功!`);
|
||||||
|
console.log(`用户名: ${username}`);
|
||||||
|
console.log(`密码: ${password}`);
|
||||||
|
console.log(`\n请登录后立即修改密码!`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error('创建管理员失败:', e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user