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; \
|
||||
fi
|
||||
|
||||
# 写入版本文件(用于许可证系统版本检查)
|
||||
ARG APP_VERSION="1.0.0"
|
||||
RUN echo "${APP_VERSION}" > VERSION.txt
|
||||
|
||||
# 为可选模块创建占位符(确保 COPY 不会失败)
|
||||
# 如果 canvas 未安装,创建一个空占位符目录
|
||||
RUN if [ ! -d "node_modules/canvas" ]; then \
|
||||
@ -244,6 +248,9 @@ COPY --from=builder --chown=nextjs:nodejs /app/logo ./public/logo
|
||||
# 3. Prisma schema 和迁移文件(运行时可能需要)
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma
|
||||
|
||||
# 4. 版本文件(许可证系统版本检查)
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/VERSION.txt ./VERSION.txt
|
||||
|
||||
# 4. lib 目录(包含 configLoader 等运行时模块)
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/lib ./lib
|
||||
|
||||
|
||||
52
VERSION.txt
52
VERSION.txt
@ -1,51 +1 @@
|
||||
FLNXI素材库管理系统
|
||||
版本历史
|
||||
|
||||
============================================
|
||||
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接口开放
|
||||
- [ ] 更多素材类型支持
|
||||
- [ ] 协作功能增强
|
||||
|
||||
|
||||
1.0.0
|
||||
|
||||
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 Header from '@/components/Header'
|
||||
import AppearanceSettingsPanel from '../_components/AppearanceSettingsPanel'
|
||||
import LicenseInfo from '@/components/LicenseInfo'
|
||||
|
||||
export default function SystemSettingsPage() {
|
||||
const { data: session, status } = useSession()
|
||||
@ -22,7 +23,7 @@ export default function SystemSettingsPage() {
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@ -35,6 +36,7 @@ export default function SystemSettingsPage() {
|
||||
<div className="min-h-screen bg-slate-50 dark:bg-slate-900">
|
||||
<Header session={session} />
|
||||
<main className="container mx-auto px-4 py-8 max-w-6xl space-y-8">
|
||||
<LicenseInfo />
|
||||
<AppearanceSettingsPanel sessionUser={session.user as any} variant="standalone" />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@ -4,6 +4,7 @@ import { authOptions } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { Prisma } from '@prisma/client'
|
||||
import { syncFieldPoolFromTemplates } from '@/lib/field-pool'
|
||||
import { canUseCustomTemplates } from '@/lib/license'
|
||||
|
||||
const baseSelect = {
|
||||
id: true,
|
||||
@ -96,6 +97,14 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ error: '无权限' }, { status: 403 })
|
||||
}
|
||||
|
||||
// 检查自定义模板功能是否可用
|
||||
if (!canUseCustomTemplates()) {
|
||||
return NextResponse.json({
|
||||
error: '自定义模板功能需要标准版或专业版许可证,请升级后使用',
|
||||
errorCode: 'LICENSE_REQUIRED',
|
||||
}, { status: 403 })
|
||||
}
|
||||
|
||||
body = await req.json()
|
||||
const {
|
||||
name,
|
||||
|
||||
@ -1,9 +1,19 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { canCreateUser } from '@/lib/license'
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
// 检查许可证用户数量限制
|
||||
const userLimit = await canCreateUser();
|
||||
if (!userLimit.allowed) {
|
||||
return NextResponse.json(
|
||||
{ error: userLimit.message || '已达到用户数量上限' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
// 检查注册功能是否开启
|
||||
const settings = await prisma.systemSettings.findFirst()
|
||||
if (settings && !settings.enableRegistration) {
|
||||
|
||||
@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { canUseDepartmentIsolation } from '@/lib/license'
|
||||
|
||||
// Get all departments
|
||||
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()
|
||||
|
||||
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 { parseAudioMetadata, isAudioFile } from '@/lib/audio-processor'
|
||||
import { validateTemplateId } from '@/lib/template-validator'
|
||||
import { canUploadMaterial } from '@/lib/license'
|
||||
import path from 'path'
|
||||
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...')
|
||||
const formData = await req.formData()
|
||||
const normalizeInput = (value: FormDataEntryValue | null) => {
|
||||
|
||||
@ -5,6 +5,7 @@ import { prisma } from '@/lib/prisma'
|
||||
import { defaultLogos, featureFlags } from '@/lib/config'
|
||||
import { cache } from '@/lib/cache'
|
||||
import { ensureSystemSettingsOptionalColumns } from '@/lib/system-settings-columns'
|
||||
import { canUseSystemSettings } from '@/lib/license'
|
||||
|
||||
const CACHE_KEY = 'system-settings'
|
||||
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 {
|
||||
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')
|
||||
|
||||
// 不应该抛出错误
|
||||
expect(() => render(<RankingPanel />)).not.toThrow()
|
||||
await act(async () => {
|
||||
expect(() => render(<RankingPanel />)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
it('应该处理空的 localStorage', async () => {
|
||||
render(<RankingPanel />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('贡献榜')).toBeInTheDocument()
|
||||
await act(async () => {
|
||||
render(<RankingPanel />)
|
||||
})
|
||||
|
||||
// 使用 getAllByText 因为有多个元素包含相同文本
|
||||
const elements = screen.getAllByText('贡献榜')
|
||||
expect(elements.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('应该将位置限制在视口内', async () => {
|
||||
@ -287,7 +291,9 @@ describe('RankingPanel 组件', () => {
|
||||
}))
|
||||
localStorageMock.setItem('rankingPanelClosed', 'true')
|
||||
|
||||
render(<RankingPanel />)
|
||||
await act(async () => {
|
||||
render(<RankingPanel />)
|
||||
})
|
||||
|
||||
const button = document.querySelector('button[title="显示排行榜(可拖动)"]')
|
||||
expect(button).toBeInTheDocument()
|
||||
@ -303,6 +309,7 @@ describe('RankingPanel 位置恢复逻辑', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
localStorageMock.clear()
|
||||
jest.useFakeTimers()
|
||||
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
@ -314,6 +321,10 @@ describe('RankingPanel 位置恢复逻辑', () => {
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers()
|
||||
})
|
||||
|
||||
it('centered: true 时按钮应该垂直居中', async () => {
|
||||
localStorageMock.setItem('rankingPanelButtonPosition', JSON.stringify({
|
||||
x: 32,
|
||||
@ -322,7 +333,9 @@ describe('RankingPanel 位置恢复逻辑', () => {
|
||||
}))
|
||||
localStorageMock.setItem('rankingPanelClosed', 'true')
|
||||
|
||||
render(<RankingPanel />)
|
||||
await act(async () => {
|
||||
render(<RankingPanel />)
|
||||
})
|
||||
|
||||
const button = document.querySelector('button[title="显示排行榜(可拖动)"]')
|
||||
expect(button).toHaveStyle({
|
||||
@ -340,7 +353,9 @@ describe('RankingPanel 位置恢复逻辑', () => {
|
||||
}))
|
||||
localStorageMock.setItem('rankingPanelClosed', 'true')
|
||||
|
||||
render(<RankingPanel />)
|
||||
await act(async () => {
|
||||
render(<RankingPanel />)
|
||||
})
|
||||
|
||||
const button = document.querySelector('button[title="显示排行榜(可拖动)"]')
|
||||
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