feat: 添加许可证系统、Agent部署模块和Harbor镜像管理

- 新增许可证服务器 (license-server/)
- 新增 Agent 部署模块 (flnxi-agent/)
- 添加许可证验证和激活页面
- 添加 Harbor 镜像同步脚本
- 更新素材系统支持许可证验证
This commit is contained in:
WuYongJie 2025-12-15 21:19:46 +08:00
parent 63ec6b7dfb
commit ba98bf22cc
113 changed files with 24669 additions and 128 deletions

File diff suppressed because it is too large Load Diff

View 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

View 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 集成测试:全部通过
- 许可证服务器部署验证:✓

View File

@ -181,6 +181,10 @@ RUN if [ ! -f ".next/standalone/server.js" ]; then \
exit 1; \ exit 1; \
fi fi
# 写入版本文件(用于许可证系统版本检查)
ARG APP_VERSION="1.0.0"
RUN echo "${APP_VERSION}" > VERSION.txt
# 为可选模块创建占位符(确保 COPY 不会失败) # 为可选模块创建占位符(确保 COPY 不会失败)
# 如果 canvas 未安装,创建一个空占位符目录 # 如果 canvas 未安装,创建一个空占位符目录
RUN if [ ! -d "node_modules/canvas" ]; then \ RUN if [ ! -d "node_modules/canvas" ]; then \
@ -244,6 +248,9 @@ COPY --from=builder --chown=nextjs:nodejs /app/logo ./public/logo
# 3. Prisma schema 和迁移文件(运行时可能需要) # 3. Prisma schema 和迁移文件(运行时可能需要)
COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma
# 4. 版本文件(许可证系统版本检查)
COPY --from=builder --chown=nextjs:nodejs /app/VERSION.txt ./VERSION.txt
# 4. lib 目录(包含 configLoader 等运行时模块) # 4. lib 目录(包含 configLoader 等运行时模块)
COPY --from=builder --chown=nextjs:nodejs /app/lib ./lib COPY --from=builder --chown=nextjs:nodejs /app/lib ./lib

View File

@ -1,51 +1 @@
FLNXI素材库管理系统 1.0.0
版本历史
============================================
Version 1.0.0 (2024-10-26)
============================================
初始版本发布
主要功能:
✅ 用户注册/登录系统
✅ 3D素材上传(支持多种格式)
✅ 素材浏览和搜索
✅ 多维度筛选(店铺、场景、月份)
✅ 素材下载(带进度显示)
✅ 点赞和分享功能
✅ 管理员后台
✅ 用户权限管理
✅ 系统设置
✅ 数据统计和排行榜
✅ MinIO 对象存储集成
✅ PostgreSQL 数据库
✅ Docker 一键部署
技术栈:
- Next.js 14
- React 18
- TypeScript
- TailwindCSS
- PostgreSQL 15
- MinIO
- Prisma ORM
- NextAuth.js
部署方式:
- Docker Compose
- 完全私有化部署
- 包含数据库和对象存储
- 一键安装脚本
============================================
未来计划:
- [ ] 批量上传功能
- [ ] 在线3D预览
- [ ] 移动端适配
- [ ] API接口开放
- [ ] 更多素材类型支持
- [ ] 协作功能增强

View 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
View 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>
);
}

View File

@ -5,6 +5,7 @@ import { useSession } from 'next-auth/react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import Header from '@/components/Header' import Header from '@/components/Header'
import AppearanceSettingsPanel from '../_components/AppearanceSettingsPanel' import AppearanceSettingsPanel from '../_components/AppearanceSettingsPanel'
import LicenseInfo from '@/components/LicenseInfo'
export default function SystemSettingsPage() { export default function SystemSettingsPage() {
const { data: session, status } = useSession() const { data: session, status } = useSession()
@ -22,7 +23,7 @@ export default function SystemSettingsPage() {
if (status === 'loading') { if (status === 'loading') {
return ( return (
<div className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-900"> <div className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-900">
<div className="text-sm text-slate-500 dark:text-slate-400">正在加载外观设置...</div> <div className="text-sm text-slate-500 dark:text-slate-400">正在加载设置...</div>
</div> </div>
) )
} }
@ -35,6 +36,7 @@ export default function SystemSettingsPage() {
<div className="min-h-screen bg-slate-50 dark:bg-slate-900"> <div className="min-h-screen bg-slate-50 dark:bg-slate-900">
<Header session={session} /> <Header session={session} />
<main className="container mx-auto px-4 py-8 max-w-6xl space-y-8"> <main className="container mx-auto px-4 py-8 max-w-6xl space-y-8">
<LicenseInfo />
<AppearanceSettingsPanel sessionUser={session.user as any} variant="standalone" /> <AppearanceSettingsPanel sessionUser={session.user as any} variant="standalone" />
</main> </main>
</div> </div>

View File

@ -4,6 +4,7 @@ import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { Prisma } from '@prisma/client' import { Prisma } from '@prisma/client'
import { syncFieldPoolFromTemplates } from '@/lib/field-pool' import { syncFieldPoolFromTemplates } from '@/lib/field-pool'
import { canUseCustomTemplates } from '@/lib/license'
const baseSelect = { const baseSelect = {
id: true, id: true,
@ -96,6 +97,14 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: '无权限' }, { status: 403 }) return NextResponse.json({ error: '无权限' }, { status: 403 })
} }
// 检查自定义模板功能是否可用
if (!canUseCustomTemplates()) {
return NextResponse.json({
error: '自定义模板功能需要标准版或专业版许可证,请升级后使用',
errorCode: 'LICENSE_REQUIRED',
}, { status: 403 })
}
body = await req.json() body = await req.json()
const { const {
name, name,

View File

@ -1,9 +1,19 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import bcrypt from 'bcryptjs' import bcrypt from 'bcryptjs'
import { canCreateUser } from '@/lib/license'
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
try { try {
// 检查许可证用户数量限制
const userLimit = await canCreateUser();
if (!userLimit.allowed) {
return NextResponse.json(
{ error: userLimit.message || '已达到用户数量上限' },
{ status: 403 }
)
}
// 检查注册功能是否开启 // 检查注册功能是否开启
const settings = await prisma.systemSettings.findFirst() const settings = await prisma.systemSettings.findFirst()
if (settings && !settings.enableRegistration) { if (settings && !settings.enableRegistration) {

View File

@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth' import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth' import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { canUseDepartmentIsolation } from '@/lib/license'
// Get all departments // Get all departments
export async function GET() { export async function GET() {
@ -34,6 +35,14 @@ export async function POST(req: NextRequest) {
) )
} }
// 检查部门隔离功能是否可用
if (!canUseDepartmentIsolation()) {
return NextResponse.json(
{ error: '部门隔离功能需要专业版许可证,请升级后使用' },
{ status: 403 }
)
}
const { name } = await req.json() const { name } = await req.json()
if (!name || name.trim() === '') { if (!name || name.trim() === '') {

View 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 }
);
}
}

View 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);
});
});
});

View 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 }
);
}
}

View 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 }
);
}
}

View File

@ -7,6 +7,7 @@ import { uploadToS3 } from '@/lib/s3'
import { processImage, getProcessedFileName, sliceImage } from '@/lib/image-processor' import { processImage, getProcessedFileName, sliceImage } from '@/lib/image-processor'
import { parseAudioMetadata, isAudioFile } from '@/lib/audio-processor' import { parseAudioMetadata, isAudioFile } from '@/lib/audio-processor'
import { validateTemplateId } from '@/lib/template-validator' import { validateTemplateId } from '@/lib/template-validator'
import { canUploadMaterial } from '@/lib/license'
import path from 'path' import path from 'path'
import { randomBytes } from 'crypto' import { randomBytes } from 'crypto'
@ -78,6 +79,16 @@ export async function POST(req: NextRequest) {
) )
} }
// 检查许可证素材数量限制
const materialLimit = await canUploadMaterial();
if (!materialLimit.allowed) {
console.log('[Upload] Material limit reached:', materialLimit)
return NextResponse.json(
{ error: materialLimit.message || '已达到素材数量上限' },
{ status: 403 }
)
}
console.log('[Upload] Parsing form data...') console.log('[Upload] Parsing form data...')
const formData = await req.formData() const formData = await req.formData()
const normalizeInput = (value: FormDataEntryValue | null) => { const normalizeInput = (value: FormDataEntryValue | null) => {

View File

@ -5,6 +5,7 @@ import { prisma } from '@/lib/prisma'
import { defaultLogos, featureFlags } from '@/lib/config' import { defaultLogos, featureFlags } from '@/lib/config'
import { cache } from '@/lib/cache' import { cache } from '@/lib/cache'
import { ensureSystemSettingsOptionalColumns } from '@/lib/system-settings-columns' import { ensureSystemSettingsOptionalColumns } from '@/lib/system-settings-columns'
import { canUseSystemSettings } from '@/lib/license'
const CACHE_KEY = 'system-settings' const CACHE_KEY = 'system-settings'
const CACHE_TTL = 60000 // 1分钟缓存 const CACHE_TTL = 60000 // 1分钟缓存
@ -188,6 +189,14 @@ export async function PUT(request: NextRequest) {
) )
} }
// 检查系统设置功能是否可用(免费版只能查看,不能修改)
if (!canUseSystemSettings()) {
return NextResponse.json(
{ error: '系统设置功能需要标准版或专业版许可证,请升级后使用' },
{ status: 403 }
)
}
const body = await request.json() const body = await request.json()
const { const {
headerBgColor, headerBgColor,

212
components/LicenseInfo.tsx Normal file
View 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>
);
}

View File

@ -267,15 +267,19 @@ describe('RankingPanel 组件', () => {
localStorageMock.setItem('rankingPanelClosed', 'true') localStorageMock.setItem('rankingPanelClosed', 'true')
// 不应该抛出错误 // 不应该抛出错误
await act(async () => {
expect(() => render(<RankingPanel />)).not.toThrow() expect(() => render(<RankingPanel />)).not.toThrow()
}) })
})
it('应该处理空的 localStorage', async () => { it('应该处理空的 localStorage', async () => {
await act(async () => {
render(<RankingPanel />) render(<RankingPanel />)
await waitFor(() => {
expect(screen.getByText('贡献榜')).toBeInTheDocument()
}) })
// 使用 getAllByText 因为有多个元素包含相同文本
const elements = screen.getAllByText('贡献榜')
expect(elements.length).toBeGreaterThan(0)
}) })
it('应该将位置限制在视口内', async () => { it('应该将位置限制在视口内', async () => {
@ -287,7 +291,9 @@ describe('RankingPanel 组件', () => {
})) }))
localStorageMock.setItem('rankingPanelClosed', 'true') localStorageMock.setItem('rankingPanelClosed', 'true')
await act(async () => {
render(<RankingPanel />) render(<RankingPanel />)
})
const button = document.querySelector('button[title="显示排行榜(可拖动)"]') const button = document.querySelector('button[title="显示排行榜(可拖动)"]')
expect(button).toBeInTheDocument() expect(button).toBeInTheDocument()
@ -303,6 +309,7 @@ describe('RankingPanel 位置恢复逻辑', () => {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks() jest.clearAllMocks()
localStorageMock.clear() localStorageMock.clear()
jest.useFakeTimers()
mockFetch.mockResolvedValue({ mockFetch.mockResolvedValue({
ok: true, ok: true,
@ -314,6 +321,10 @@ describe('RankingPanel 位置恢复逻辑', () => {
}) })
}) })
afterEach(() => {
jest.useRealTimers()
})
it('centered: true 时按钮应该垂直居中', async () => { it('centered: true 时按钮应该垂直居中', async () => {
localStorageMock.setItem('rankingPanelButtonPosition', JSON.stringify({ localStorageMock.setItem('rankingPanelButtonPosition', JSON.stringify({
x: 32, x: 32,
@ -322,7 +333,9 @@ describe('RankingPanel 位置恢复逻辑', () => {
})) }))
localStorageMock.setItem('rankingPanelClosed', 'true') localStorageMock.setItem('rankingPanelClosed', 'true')
await act(async () => {
render(<RankingPanel />) render(<RankingPanel />)
})
const button = document.querySelector('button[title="显示排行榜(可拖动)"]') const button = document.querySelector('button[title="显示排行榜(可拖动)"]')
expect(button).toHaveStyle({ expect(button).toHaveStyle({
@ -340,7 +353,9 @@ describe('RankingPanel 位置恢复逻辑', () => {
})) }))
localStorageMock.setItem('rankingPanelClosed', 'true') localStorageMock.setItem('rankingPanelClosed', 'true')
await act(async () => {
render(<RankingPanel />) render(<RankingPanel />)
})
const button = document.querySelector('button[title="显示排行榜(可拖动)"]') const button = document.querySelector('button[title="显示排行榜(可拖动)"]')
expect(button).toHaveStyle({ expect(button).toHaveStyle({

View 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
View File

@ -0,0 +1,6 @@
node_modules/
dist/
.env
*.log
frontend/node_modules/
frontend/dist/

42
flnxi-agent/Dockerfile Normal file
View 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
View 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
View File

@ -0,0 +1 @@
1.0.0

View 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

File diff suppressed because it is too large Load Diff

View 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"
}
}

View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View 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>
);
}

View 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;
}

View 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>
);

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@ -0,0 +1,8 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {},
},
plugins: [],
};

View 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" }]
}

View File

@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

View 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

File diff suppressed because it is too large Load Diff

30
flnxi-agent/package.json Normal file
View 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"
}
}

View 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
View 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}`);
});

View 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
View 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
View 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);
}
}
}

View 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);
});
});

View 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);
});
});
});

View 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');
});
});
});

View 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');
}

View 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');
}

View 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
View 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';

View 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
View 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,
};
}

View 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
View 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
View 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"]

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 }
);
}
}

View 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;
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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));
}

View 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>
);
}

View 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
View 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

View 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

View 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

View File

@ -0,0 +1 @@
// Jest setup file

122
license-server/lib/auth.ts Normal file
View 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('-');
}

View 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
View 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
View 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,
};
}

View File

@ -0,0 +1,7 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
output: 'standalone',
}
module.exports = nextConfig

View 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

File diff suppressed because it is too large Load Diff

View 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"
}
}

View File

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View 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")
}

View 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);

View 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