feat: 完善卡密管理模块,添加生成、激活、查询和撤销卡密功能
This commit is contained in:
parent
7274c58329
commit
aa62486f21
2
.env
2
.env
|
|
@ -1,5 +1,5 @@
|
||||||
JWT_SECRET=5270e53a05600cb1f19b287a915b1c792d7552cbaae261afb6dcaf988d0db10f
|
JWT_SECRET=5270e53a05600cb1f19b287a915b1c792d7552cbaae261afb6dcaf988d0db10f
|
||||||
SERVER_HOST='192.144.32.178'
|
SERVER_HOST='45.130.23.71'
|
||||||
SERVER_PORT=3306
|
SERVER_PORT=3306
|
||||||
SERVER_USER='golc'
|
SERVER_USER='golc'
|
||||||
PASSWORD='Lichao1314!'
|
PASSWORD='Lichao1314!'
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,418 @@
|
||||||
|
# 生成卡密操作指南
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
现在系统已经实现了完整的管理员权限验证。管理员可以通过 API 生成卡密,普通用户无法访问生成接口。
|
||||||
|
|
||||||
|
## 管理员权限规则
|
||||||
|
|
||||||
|
系统会自动识别以下用户为管理员:
|
||||||
|
1. 用户名为 `admin`
|
||||||
|
2. 邮箱包含 `admin`(如 `admin@example.com`)
|
||||||
|
3. 用户名以 `admin_` 开头(如 `admin_user`)
|
||||||
|
|
||||||
|
## 方法一:注册管理员账号(推荐)
|
||||||
|
|
||||||
|
### 步骤 1: 注册管理员账号
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3030/user/register \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"username": "admin",
|
||||||
|
"password": "your_secure_password",
|
||||||
|
"email": "admin@example.com"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应示例:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "User registered successfully",
|
||||||
|
"user": {
|
||||||
|
"id": 1,
|
||||||
|
"username": "admin",
|
||||||
|
"email": "admin@example.com",
|
||||||
|
"isActive": true,
|
||||||
|
"provider": "local"
|
||||||
|
},
|
||||||
|
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||||
|
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**保存 `access_token`,后续生成卡密时需要使用!**
|
||||||
|
|
||||||
|
### 步骤 2: 使用管理员账号生成卡密
|
||||||
|
|
||||||
|
#### 生成单个月卡
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3030/license/generate \
|
||||||
|
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"type": "monthly",
|
||||||
|
"validDays": 30,
|
||||||
|
"count": 1,
|
||||||
|
"remarks": "测试月卡"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 批量生成卡密
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 生成 10 个月卡
|
||||||
|
curl -X POST http://localhost:3030/license/generate \
|
||||||
|
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"type": "monthly",
|
||||||
|
"validDays": 30,
|
||||||
|
"count": 10,
|
||||||
|
"remarks": "批量生成月卡"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 生成不同类型的卡密
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 试用卡 (7天)
|
||||||
|
curl -X POST http://localhost:3030/license/generate \
|
||||||
|
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"type": "trial",
|
||||||
|
"validDays": 7,
|
||||||
|
"count": 5,
|
||||||
|
"remarks": "试用卡"
|
||||||
|
}'
|
||||||
|
|
||||||
|
# 年卡 (365天)
|
||||||
|
curl -X POST http://localhost:3030/license/generate \
|
||||||
|
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"type": "yearly",
|
||||||
|
"validDays": 365,
|
||||||
|
"count": 2,
|
||||||
|
"remarks": "年度授权"
|
||||||
|
}'
|
||||||
|
|
||||||
|
# 终身卡
|
||||||
|
curl -X POST http://localhost:3030/license/generate \
|
||||||
|
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"type": "lifetime",
|
||||||
|
"validDays": 36500,
|
||||||
|
"count": 1,
|
||||||
|
"remarks": "VIP终身授权"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应示例:**
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"code": "ABCD-1234-EFGH-5678",
|
||||||
|
"type": "monthly",
|
||||||
|
"status": "unused",
|
||||||
|
"validDays": 30,
|
||||||
|
"remarks": "测试月卡",
|
||||||
|
"createdAt": "2025-12-13T10:00:00.000Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 步骤 3: 将卡密分发给用户
|
||||||
|
|
||||||
|
将生成的 `code` 发送给用户,用户可以使用以下方式激活:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3030/license/activate \
|
||||||
|
-H "Authorization: Bearer USER_ACCESS_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"code": "ABCD-1234-EFGH-5678"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 方法二:使用 Swagger UI
|
||||||
|
|
||||||
|
### 步骤 1: 访问 Swagger
|
||||||
|
|
||||||
|
打开浏览器访问:http://localhost:3030/api
|
||||||
|
|
||||||
|
### 步骤 2: 注册管理员账号
|
||||||
|
|
||||||
|
1. 找到 `POST /user/register` 接口
|
||||||
|
2. 点击 "Try it out"
|
||||||
|
3. 输入管理员信息:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"username": "admin",
|
||||||
|
"password": "admin123",
|
||||||
|
"email": "admin@example.com"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
4. 点击 "Execute"
|
||||||
|
5. 复制响应中的 `access_token`
|
||||||
|
|
||||||
|
### 步骤 3: 授权 Swagger
|
||||||
|
|
||||||
|
1. 点击页面右上角的 "Authorize" 按钮
|
||||||
|
2. 在弹出框中输入:`Bearer YOUR_ACCESS_TOKEN`
|
||||||
|
3. 点击 "Authorize"
|
||||||
|
4. 点击 "Close"
|
||||||
|
|
||||||
|
### 步骤 4: 生成卡密
|
||||||
|
|
||||||
|
1. 找到 `POST /license/generate` 接口
|
||||||
|
2. 点击 "Try it out"
|
||||||
|
3. 输入生成参数:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "monthly",
|
||||||
|
"validDays": 30,
|
||||||
|
"count": 1,
|
||||||
|
"remarks": "测试卡密"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
4. 点击 "Execute"
|
||||||
|
5. 在响应中复制生成的卡密码
|
||||||
|
|
||||||
|
### 步骤 5: 查看已生成的卡密
|
||||||
|
|
||||||
|
1. 找到 `GET /license` 接口
|
||||||
|
2. 点击 "Try it out"
|
||||||
|
3. 可选:筛选条件
|
||||||
|
- status: unused (查看未使用的)
|
||||||
|
- type: monthly (查看月卡)
|
||||||
|
4. 点击 "Execute"
|
||||||
|
5. 查看所有卡密列表
|
||||||
|
|
||||||
|
## 方法三:直接插入数据库(临时方案)
|
||||||
|
|
||||||
|
如果还没有管理员账号,可以直接在数据库中插入卡密:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 插入测试卡密
|
||||||
|
INSERT INTO `license` (
|
||||||
|
`code`,
|
||||||
|
`type`,
|
||||||
|
`status`,
|
||||||
|
`validDays`,
|
||||||
|
`remarks`,
|
||||||
|
`createdAt`,
|
||||||
|
`updatedAt`
|
||||||
|
) VALUES
|
||||||
|
('ABCD-1234-EFGH-5678', 'monthly', 'unused', 30, '测试月卡', NOW(), NOW()),
|
||||||
|
('WXYZ-9876-IJKL-5432', 'yearly', 'unused', 365, '测试年卡', NOW(), NOW()),
|
||||||
|
('TEST-0000-FREE-TRIAL', 'trial', 'unused', 7, '试用卡', NOW(), NOW());
|
||||||
|
```
|
||||||
|
|
||||||
|
**注意:** 卡密格式必须是 `XXXX-XXXX-XXXX-XXXX`(19个字符,包含3个连字符)
|
||||||
|
|
||||||
|
## 管理员其他操作
|
||||||
|
|
||||||
|
### 查看卡密统计
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X GET http://localhost:3030/license/statistics \
|
||||||
|
-H "Authorization: Bearer ADMIN_TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应示例:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"total": 100,
|
||||||
|
"unused": 50,
|
||||||
|
"active": 30,
|
||||||
|
"expired": 15,
|
||||||
|
"revoked": 5
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 查询所有卡密
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 查询所有卡密
|
||||||
|
curl -X GET http://localhost:3030/license \
|
||||||
|
-H "Authorization: Bearer ADMIN_TOKEN"
|
||||||
|
|
||||||
|
# 查询未使用的卡密
|
||||||
|
curl -X GET "http://localhost:3030/license?status=unused" \
|
||||||
|
-H "Authorization: Bearer ADMIN_TOKEN"
|
||||||
|
|
||||||
|
# 查询已激活的月卡
|
||||||
|
curl -X GET "http://localhost:3030/license?status=active&type=monthly" \
|
||||||
|
-H "Authorization: Bearer ADMIN_TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 查询单个卡密详情
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X GET http://localhost:3030/license/1 \
|
||||||
|
-H "Authorization: Bearer ADMIN_TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 撤销卡密
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3030/license/1/revoke \
|
||||||
|
-H "Authorization: Bearer ADMIN_TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 删除卡密
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X DELETE http://localhost:3030/license/1 \
|
||||||
|
-H "Authorization: Bearer ADMIN_TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### Q: 非管理员用户能生成卡密吗?
|
||||||
|
A: 不能。系统会返回 403 错误:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"statusCode": 403,
|
||||||
|
"message": "需要管理员权限",
|
||||||
|
"error": "Forbidden"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Q: 如何修改管理员权限规则?
|
||||||
|
A: 编辑 [src/common/services/admin.service.ts](src/common/services/admin.service.ts:23-30),修改 `isAdmin` 方法中的判断逻辑。
|
||||||
|
|
||||||
|
### Q: 可以有多个管理员吗?
|
||||||
|
A: 可以。所有满足管理员规则的用户都拥有管理员权限。
|
||||||
|
|
||||||
|
### Q: 生成的卡密可以重复使用吗?
|
||||||
|
A: 不可以。每个卡密只能激活一次,激活后状态变为 `active`。
|
||||||
|
|
||||||
|
### Q: 如何批量生成大量卡密?
|
||||||
|
A: 使用 `count` 参数,建议每次不超过 100 个:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "monthly",
|
||||||
|
"count": 100
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 完整示例流程
|
||||||
|
|
||||||
|
### 场景:销售100个月卡
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 使用管理员账号登录
|
||||||
|
curl -X POST http://localhost:3030/user/login \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"username": "admin",
|
||||||
|
"password": "admin123"
|
||||||
|
}'
|
||||||
|
# 获取 access_token
|
||||||
|
|
||||||
|
# 2. 批量生成 100 个月卡
|
||||||
|
curl -X POST http://localhost:3030/license/generate \
|
||||||
|
-H "Authorization: Bearer ADMIN_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"type": "monthly",
|
||||||
|
"validDays": 30,
|
||||||
|
"count": 100,
|
||||||
|
"remarks": "2025年1月批次"
|
||||||
|
}'
|
||||||
|
|
||||||
|
# 3. 查询已生成的卡密
|
||||||
|
curl -X GET "http://localhost:3030/license?status=unused&type=monthly" \
|
||||||
|
-H "Authorization: Bearer ADMIN_TOKEN"
|
||||||
|
|
||||||
|
# 4. 导出卡密(可以通过数据库查询或API获取)
|
||||||
|
# SQL 查询未使用的卡密
|
||||||
|
# SELECT code FROM license WHERE status = 'unused' AND type = 'monthly'
|
||||||
|
|
||||||
|
# 5. 将卡密分发给客户
|
||||||
|
|
||||||
|
# 6. 监控卡密使用情况
|
||||||
|
curl -X GET http://localhost:3030/license/statistics \
|
||||||
|
-H "Authorization: Bearer ADMIN_TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 数据库直接查询卡密
|
||||||
|
|
||||||
|
### 查询所有未使用的卡密
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT
|
||||||
|
id, code, type, validDays, remarks, createdAt
|
||||||
|
FROM license
|
||||||
|
WHERE status = 'unused'
|
||||||
|
ORDER BY createdAt DESC;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 导出卡密列表(用于分发)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT
|
||||||
|
code as '卡密码',
|
||||||
|
CASE type
|
||||||
|
WHEN 'trial' THEN '试用版'
|
||||||
|
WHEN 'monthly' THEN '月卡'
|
||||||
|
WHEN 'yearly' THEN '年卡'
|
||||||
|
WHEN 'lifetime' THEN '终身版'
|
||||||
|
END as '类型',
|
||||||
|
validDays as '有效天数',
|
||||||
|
remarks as '备注'
|
||||||
|
FROM license
|
||||||
|
WHERE status = 'unused'
|
||||||
|
ORDER BY type, createdAt DESC;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 统计卡密使用情况
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT
|
||||||
|
type as '类型',
|
||||||
|
COUNT(*) as '总数',
|
||||||
|
SUM(CASE WHEN status = 'unused' THEN 1 ELSE 0 END) as '未使用',
|
||||||
|
SUM(CASE WHEN status = 'active' THEN 1 ELSE 0 END) as '已激活',
|
||||||
|
SUM(CASE WHEN status = 'expired' THEN 1 ELSE 0 END) as '已过期',
|
||||||
|
SUM(CASE WHEN status = 'revoked' THEN 1 ELSE 0 END) as '已撤销'
|
||||||
|
FROM license
|
||||||
|
GROUP BY type;
|
||||||
|
```
|
||||||
|
|
||||||
|
## 安全建议
|
||||||
|
|
||||||
|
1. **保护管理员账号**
|
||||||
|
- 使用强密码
|
||||||
|
- 定期更换密码
|
||||||
|
- 不要分享管理员凭证
|
||||||
|
|
||||||
|
2. **卡密分发**
|
||||||
|
- 通过安全渠道发送(邮件、加密通讯)
|
||||||
|
- 记录卡密分发情况
|
||||||
|
- 及时撤销未使用的卡密
|
||||||
|
|
||||||
|
3. **监控使用**
|
||||||
|
- 定期查看统计信息
|
||||||
|
- 检查异常激活
|
||||||
|
- 审计管理员操作日志
|
||||||
|
|
||||||
|
## 下一步
|
||||||
|
|
||||||
|
完成卡密生成后,可以:
|
||||||
|
1. 将卡密分发给用户
|
||||||
|
2. 用户使用 `POST /license/activate` 激活
|
||||||
|
3. 激活后用户可以使用任务功能
|
||||||
|
4. 管理员可以通过统计接口监控使用情况
|
||||||
|
|
||||||
|
## 相关文档
|
||||||
|
|
||||||
|
- [LICENSE_SYSTEM.md](LICENSE_SYSTEM.md) - 卡密系统详细说明
|
||||||
|
- [QUICKSTART.md](QUICKSTART.md) - 快速开始指南
|
||||||
|
- [Swagger API](http://localhost:3030/api) - 在线 API 文档
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
# 如何生成卡密 - 快速指南
|
||||||
|
|
||||||
|
## 🎯 三种生成方式
|
||||||
|
|
||||||
|
### 方式一:使用自动化脚本(最简单)⭐
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash generate-licenses.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
脚本会自动引导你完成:
|
||||||
|
1. 注册/登录管理员账号
|
||||||
|
2. 选择卡密类型
|
||||||
|
3. 输入生成数量
|
||||||
|
4. 自动生成并显示卡密
|
||||||
|
5. 可选保存到文件
|
||||||
|
|
||||||
|
### 方式二:使用 Swagger UI(可视化)
|
||||||
|
|
||||||
|
1. 启动项目后访问:http://localhost:3030/api
|
||||||
|
|
||||||
|
2. 注册管理员账号 `POST /user/register`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"username": "admin",
|
||||||
|
"password": "admin123",
|
||||||
|
"email": "admin@example.com"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. 点击右上角 "Authorize",输入 Token
|
||||||
|
|
||||||
|
4. 调用 `POST /license/generate` 生成卡密
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "monthly",
|
||||||
|
"validDays": 30,
|
||||||
|
"count": 10
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 方式三:使用 curl 命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 注册管理员
|
||||||
|
curl -X POST http://localhost:3030/user/register \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"username":"admin","password":"admin123","email":"admin@example.com"}'
|
||||||
|
|
||||||
|
# 2. 使用返回的 token 生成卡密
|
||||||
|
curl -X POST http://localhost:3030/license/generate \
|
||||||
|
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"type":"monthly","validDays":30,"count":10}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📋 管理员权限规则
|
||||||
|
|
||||||
|
系统自动识别以下用户为管理员:
|
||||||
|
- 用户名为 `admin`
|
||||||
|
- 邮箱包含 `admin`
|
||||||
|
- 用户名以 `admin_` 开头
|
||||||
|
|
||||||
|
## 📚 详细文档
|
||||||
|
|
||||||
|
- [GENERATE_LICENSE_GUIDE.md](GENERATE_LICENSE_GUIDE.md) - 完整生成指南
|
||||||
|
- [LICENSE_SYSTEM.md](LICENSE_SYSTEM.md) - 卡密系统说明
|
||||||
|
- [QUICKSTART.md](QUICKSTART.md) - 快速开始
|
||||||
|
|
||||||
|
## 🚀 快速开始
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 启动项目
|
||||||
|
npm run start:dev
|
||||||
|
|
||||||
|
# 2. 运行生成脚本
|
||||||
|
bash generate-licenses.sh
|
||||||
|
|
||||||
|
# 3. 按照提示操作即可
|
||||||
|
```
|
||||||
|
|
||||||
|
就这么简单!🎉
|
||||||
|
|
@ -0,0 +1,355 @@
|
||||||
|
# 卡密商业化系统实现总结
|
||||||
|
|
||||||
|
## 项目概述
|
||||||
|
|
||||||
|
成功为 Task 任务管理模块添加了完整的卡密商业化授权系统。用户需要激活有效的卡密才能使用任务管理功能。
|
||||||
|
|
||||||
|
## 实现的功能
|
||||||
|
|
||||||
|
### 1. 核心功能
|
||||||
|
- ✅ 卡密生成(批量、唯一性保证)
|
||||||
|
- ✅ 卡密激活(自动验证、防重复)
|
||||||
|
- ✅ 授权延期(多卡密叠加)
|
||||||
|
- ✅ 授权验证(守卫拦截)
|
||||||
|
- ✅ 授权查询(剩余天数)
|
||||||
|
- ✅ 卡密撤销
|
||||||
|
- ✅ 统计分析
|
||||||
|
|
||||||
|
### 2. 卡密类型
|
||||||
|
- 试用版 (7天)
|
||||||
|
- 月度订阅 (30天)
|
||||||
|
- 年度订阅 (365天)
|
||||||
|
- 终身授权 (100年)
|
||||||
|
|
||||||
|
### 3. 安全机制
|
||||||
|
- 卡密格式: `XXXX-XXXX-XXXX-XXXX`
|
||||||
|
- 随机生成算法(去除易混淆字符)
|
||||||
|
- 全局唯一性检查
|
||||||
|
- 状态机管理(unused → active → expired/revoked)
|
||||||
|
- 守卫层面统一拦截
|
||||||
|
|
||||||
|
## 文件结构
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── license/
|
||||||
|
│ ├── entities/
|
||||||
|
│ │ └── license.entity.ts # 卡密实体
|
||||||
|
│ ├── dto/
|
||||||
|
│ │ ├── generate-license.dto.ts # 生成卡密 DTO
|
||||||
|
│ │ ├── activate-license.dto.ts # 激活卡密 DTO
|
||||||
|
│ │ └── query-license.dto.ts # 查询卡密 DTO
|
||||||
|
│ ├── decorators/
|
||||||
|
│ │ └── require-license.decorator.ts # 授权装饰器
|
||||||
|
│ ├── guards/
|
||||||
|
│ │ └── license.guard.ts # 授权守卫
|
||||||
|
│ ├── license.service.ts # 卡密服务
|
||||||
|
│ ├── license.controller.ts # 卡密控制器
|
||||||
|
│ └── license.module.ts # 卡密模块
|
||||||
|
│
|
||||||
|
├── task/
|
||||||
|
│ └── task.controller.ts # 已添加卡密验证
|
||||||
|
│
|
||||||
|
├── common/
|
||||||
|
│ └── dto/
|
||||||
|
│ └── error-response.dto.ts # 统一错误响应
|
||||||
|
│
|
||||||
|
└── app.module.ts # 注册 License Module
|
||||||
|
```
|
||||||
|
|
||||||
|
## API 接口
|
||||||
|
|
||||||
|
### 用户接口
|
||||||
|
|
||||||
|
| 接口 | 方法 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `/license/activate` | POST | 激活卡密 |
|
||||||
|
| `/license/my` | GET | 查询我的授权 |
|
||||||
|
| `/license/my/history` | GET | 查询卡密历史 |
|
||||||
|
|
||||||
|
### 管理员接口
|
||||||
|
|
||||||
|
| 接口 | 方法 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `/license/generate` | POST | 生成卡密 |
|
||||||
|
| `/license` | GET | 查询所有卡密 |
|
||||||
|
| `/license/statistics` | GET | 获取统计信息 |
|
||||||
|
| `/license/:id` | GET | 查询单个卡密 |
|
||||||
|
| `/license/:id/revoke` | POST | 撤销卡密 |
|
||||||
|
| `/license/:id` | DELETE | 删除卡密 |
|
||||||
|
|
||||||
|
### 受保护的接口
|
||||||
|
|
||||||
|
所有 `/task/*` 接口都需要有效授权:
|
||||||
|
- `POST /task` - 创建任务
|
||||||
|
- `GET /task` - 获取任务列表
|
||||||
|
- `GET /task/:id` - 获取单个任务
|
||||||
|
- `PATCH /task/:id` - 更新任务
|
||||||
|
- `PATCH /task/reorder` - 重新排序
|
||||||
|
- `DELETE /task/:id` - 删除任务
|
||||||
|
|
||||||
|
## 数据库变更
|
||||||
|
|
||||||
|
### 新增表: license
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE `license` (
|
||||||
|
`id` INT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
`code` VARCHAR(32) UNIQUE NOT NULL,
|
||||||
|
`type` ENUM('trial', 'monthly', 'yearly', 'lifetime') DEFAULT 'monthly',
|
||||||
|
`status` ENUM('unused', 'active', 'expired', 'revoked') DEFAULT 'unused',
|
||||||
|
`validDays` INT DEFAULT 30,
|
||||||
|
`activatedAt` DATETIME NULL,
|
||||||
|
`expiresAt` DATETIME NULL,
|
||||||
|
`userId` INT NULL,
|
||||||
|
`remarks` TEXT NULL,
|
||||||
|
`createdAt` DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`updatedAt` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (`userId`) REFERENCES `user`(`id`)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## 使用流程
|
||||||
|
|
||||||
|
### 管理员生成卡密
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3030/license/generate \
|
||||||
|
-H "Authorization: Bearer {admin_token}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"type": "monthly",
|
||||||
|
"validDays": 30,
|
||||||
|
"count": 10,
|
||||||
|
"remarks": "批量生成月卡"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 用户激活卡密
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3030/license/activate \
|
||||||
|
-H "Authorization: Bearer {user_token}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"code": "ABCD-1234-EFGH-5678"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 用户查询授权
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X GET http://localhost:3030/license/my \
|
||||||
|
-H "Authorization: Bearer {user_token}"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 使用任务功能
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3030/task \
|
||||||
|
-H "Authorization: Bearer {user_token}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"title": "完成项目文档",
|
||||||
|
"description": "编写 API 文档",
|
||||||
|
"priority": "high"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 测试
|
||||||
|
|
||||||
|
### 1. 运行测试脚本
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash test-license-system.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 手动测试步骤
|
||||||
|
|
||||||
|
1. 启动项目: `npm run start:dev`
|
||||||
|
2. 访问 Swagger: `http://localhost:3030/api`
|
||||||
|
3. 注册用户
|
||||||
|
4. 生成卡密(管理员)
|
||||||
|
5. 激活卡密
|
||||||
|
6. 测试任务接口
|
||||||
|
|
||||||
|
### 3. 测试场景
|
||||||
|
|
||||||
|
- ✅ 未授权访问任务接口 → 返回 403
|
||||||
|
- ✅ 激活卡密后访问 → 正常使用
|
||||||
|
- ✅ 重复激活同一卡密 → 返回 409
|
||||||
|
- ✅ 激活第二个卡密 → 自动延期
|
||||||
|
- ✅ 授权过期后访问 → 返回 403
|
||||||
|
- ✅ 查询剩余天数 → 正确显示
|
||||||
|
- ✅ 撤销卡密 → 无法再使用
|
||||||
|
|
||||||
|
## Swagger 文档
|
||||||
|
|
||||||
|
所有接口都已添加完整的 Swagger 注释,包括:
|
||||||
|
- 接口描述
|
||||||
|
- 请求参数说明
|
||||||
|
- 响应示例
|
||||||
|
- 错误码说明
|
||||||
|
|
||||||
|
访问地址: `http://localhost:3030/api`
|
||||||
|
|
||||||
|
## 技术亮点
|
||||||
|
|
||||||
|
### 1. 安全性
|
||||||
|
- 卡密随机生成,去除易混淆字符
|
||||||
|
- 全局唯一性保证
|
||||||
|
- 状态机防止重复激活
|
||||||
|
- 守卫层面统一验证
|
||||||
|
|
||||||
|
### 2. 用户体验
|
||||||
|
- 支持授权延期(多卡密叠加)
|
||||||
|
- 清晰的错误提示
|
||||||
|
- 剩余天数实时查询
|
||||||
|
- 授权历史记录
|
||||||
|
|
||||||
|
### 3. 可维护性
|
||||||
|
- 模块化设计
|
||||||
|
- 装饰器 + 守卫模式
|
||||||
|
- 完整的 TypeScript 类型
|
||||||
|
- 详细的代码注释
|
||||||
|
|
||||||
|
### 4. 可扩展性
|
||||||
|
- 支持多种卡密类型
|
||||||
|
- 可配置有效天数
|
||||||
|
- 易于添加新的授权模块
|
||||||
|
- 支持批量生成
|
||||||
|
|
||||||
|
## 后续优化建议
|
||||||
|
|
||||||
|
### 功能扩展
|
||||||
|
1. 支持不同模块的独立授权
|
||||||
|
2. 添加授权转让功能
|
||||||
|
3. 实现自动续费机制
|
||||||
|
4. 添加授权即将过期提醒
|
||||||
|
5. 支持授权使用日志
|
||||||
|
|
||||||
|
### 性能优化
|
||||||
|
1. 卡密验证结果缓存(Redis)
|
||||||
|
2. 批量生成优化
|
||||||
|
3. 数据库索引优化
|
||||||
|
4. 添加分页查询
|
||||||
|
|
||||||
|
### 安全增强
|
||||||
|
1. 添加 IP 白名单限制
|
||||||
|
2. 实现设备绑定
|
||||||
|
3. 添加异常登录检测
|
||||||
|
4. 卡密使用频率限制
|
||||||
|
|
||||||
|
## 前端集成建议
|
||||||
|
|
||||||
|
### 1. 授权状态管理
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 存储授权信息
|
||||||
|
interface LicenseState {
|
||||||
|
hasValidLicense: boolean;
|
||||||
|
remainingDays: number;
|
||||||
|
expiresAt: string;
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 在应用启动时查询
|
||||||
|
const checkLicense = async () => {
|
||||||
|
const response = await api.get('/license/my');
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 路由守卫
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 需要授权的路由添加守卫
|
||||||
|
const ProtectedRoute = ({ children }) => {
|
||||||
|
const { hasValidLicense } = useLicense();
|
||||||
|
|
||||||
|
if (!hasValidLicense) {
|
||||||
|
return <Navigate to="/activate" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return children;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 激活页面
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const ActivatePage = () => {
|
||||||
|
const [code, setCode] = useState('');
|
||||||
|
|
||||||
|
const handleActivate = async () => {
|
||||||
|
try {
|
||||||
|
const response = await api.post('/license/activate', { code });
|
||||||
|
toast.success(response.data.message);
|
||||||
|
navigate('/tasks');
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error.response.data.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
value={code}
|
||||||
|
onChange={e => setCode(e.target.value)}
|
||||||
|
placeholder="输入卡密: XXXX-XXXX-XXXX-XXXX"
|
||||||
|
/>
|
||||||
|
<button onClick={handleActivate}>激活</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 授权状态显示
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const LicenseStatus = () => {
|
||||||
|
const { license, remainingDays } = useLicense();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p>授权类型: {license.type}</p>
|
||||||
|
<p>剩余天数: {remainingDays} 天</p>
|
||||||
|
<p>到期时间: {new Date(license.expiresAt).toLocaleDateString()}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### Q: 如何生成第一个管理员卡密?
|
||||||
|
A: 可以直接在数据库中插入一条记录,或者临时修改代码去除权限验证。
|
||||||
|
|
||||||
|
### Q: 卡密格式有什么要求?
|
||||||
|
A: 必须是 `XXXX-XXXX-XXXX-XXXX` 格式,共19个字符(包含连字符)。
|
||||||
|
|
||||||
|
### Q: 用户激活多个卡密会怎样?
|
||||||
|
A: 如果已有有效授权,新卡密的时间会在原有基础上累加延期。
|
||||||
|
|
||||||
|
### Q: 授权过期后数据会丢失吗?
|
||||||
|
A: 不会,数据仍然保留,只是无法访问。重新激活后可以继续使用。
|
||||||
|
|
||||||
|
### Q: 如何撤销已发放的卡密?
|
||||||
|
A: 使用管理员接口 `POST /license/:id/revoke` 撤销指定卡密。
|
||||||
|
|
||||||
|
## 相关文档
|
||||||
|
|
||||||
|
- [LICENSE_SYSTEM.md](LICENSE_SYSTEM.md) - 详细使用说明
|
||||||
|
- [Swagger API 文档](http://localhost:3030/api) - 在线接口文档
|
||||||
|
- `test-license-system.sh` - 测试脚本
|
||||||
|
|
||||||
|
## 总结
|
||||||
|
|
||||||
|
本次实现完成了一个功能完整、安全可靠的卡密商业化授权系统。系统具有以下特点:
|
||||||
|
|
||||||
|
1. **功能完整**: 涵盖生成、激活、验证、查询、统计等全流程
|
||||||
|
2. **安全可靠**: 多层验证,状态机管理,防止滥用
|
||||||
|
3. **易于使用**: 清晰的 API 设计,完整的文档和测试
|
||||||
|
4. **易于扩展**: 模块化设计,支持多种授权类型和场景
|
||||||
|
|
||||||
|
系统已经可以直接投入使用,后续可根据实际需求进行功能扩展和优化。
|
||||||
|
|
@ -0,0 +1,329 @@
|
||||||
|
# 卡密系统使用说明
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
本项目实现了完整的卡密商业化授权系统,用户需要激活有效的卡密才能使用任务管理(Task)模块的功能。
|
||||||
|
|
||||||
|
## 功能特性
|
||||||
|
|
||||||
|
### 1. 卡密类型
|
||||||
|
|
||||||
|
- **试用版 (trial)**: 7天有效期
|
||||||
|
- **月度订阅 (monthly)**: 30天有效期
|
||||||
|
- **年度订阅 (yearly)**: 365天有效期
|
||||||
|
- **终身授权 (lifetime)**: 100年有效期
|
||||||
|
|
||||||
|
### 2. 卡密状态
|
||||||
|
|
||||||
|
- **未使用 (unused)**: 卡密已生成但未被激活
|
||||||
|
- **已激活 (active)**: 卡密正在使用中
|
||||||
|
- **已过期 (expired)**: 卡密有效期已过
|
||||||
|
- **已撤销 (revoked)**: 卡密被管理员撤销
|
||||||
|
|
||||||
|
### 3. 卡密格式
|
||||||
|
|
||||||
|
卡密采用 `XXXX-XXXX-XXXX-XXXX` 格式,例如:`ABCD-1234-EFGH-5678`
|
||||||
|
|
||||||
|
## API 接口
|
||||||
|
|
||||||
|
### 用户接口
|
||||||
|
|
||||||
|
#### 1. 激活卡密
|
||||||
|
```http
|
||||||
|
POST /license/activate
|
||||||
|
Authorization: Bearer {access_token}
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"code": "ABCD-1234-EFGH-5678"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应示例:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "卡密激活成功,有效期至 2026-01-13 10:00:00",
|
||||||
|
"license": {
|
||||||
|
"id": 1,
|
||||||
|
"code": "ABCD-1234-EFGH-5678",
|
||||||
|
"type": "monthly",
|
||||||
|
"status": "active",
|
||||||
|
"validDays": 30,
|
||||||
|
"activatedAt": "2025-12-13T10:00:00.000Z",
|
||||||
|
"expiresAt": "2026-01-13T10:00:00.000Z",
|
||||||
|
"userId": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**特殊说明:**
|
||||||
|
- 如果用户已有有效授权,新卡密的时间会在原有基础上累加延期
|
||||||
|
- 已激活、已过期或已撤销的卡密无法重复使用
|
||||||
|
|
||||||
|
#### 2. 查询我的授权信息
|
||||||
|
```http
|
||||||
|
GET /license/my
|
||||||
|
Authorization: Bearer {access_token}
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应示例:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"hasValidLicense": true,
|
||||||
|
"license": {
|
||||||
|
"id": 1,
|
||||||
|
"code": "ABCD-1234-EFGH-5678",
|
||||||
|
"type": "monthly",
|
||||||
|
"status": "active",
|
||||||
|
"validDays": 30,
|
||||||
|
"activatedAt": "2025-12-13T10:00:00.000Z",
|
||||||
|
"expiresAt": "2026-01-13T10:00:00.000Z",
|
||||||
|
"remainingDays": 31
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. 查询我的卡密历史
|
||||||
|
```http
|
||||||
|
GET /license/my/history
|
||||||
|
Authorization: Bearer {access_token}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 管理员接口
|
||||||
|
|
||||||
|
#### 1. 生成卡密
|
||||||
|
```http
|
||||||
|
POST /license/generate
|
||||||
|
Authorization: Bearer {admin_token}
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"type": "monthly",
|
||||||
|
"validDays": 30,
|
||||||
|
"count": 10,
|
||||||
|
"remarks": "批量生成月卡"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应示例:**
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"code": "ABCD-1234-EFGH-5678",
|
||||||
|
"type": "monthly",
|
||||||
|
"status": "unused",
|
||||||
|
"validDays": 30,
|
||||||
|
"remarks": "批量生成月卡",
|
||||||
|
"createdAt": "2025-12-13T10:00:00.000Z"
|
||||||
|
},
|
||||||
|
...
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. 查询所有卡密
|
||||||
|
```http
|
||||||
|
GET /license?status=active&type=monthly
|
||||||
|
Authorization: Bearer {admin_token}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. 获取统计信息
|
||||||
|
```http
|
||||||
|
GET /license/statistics
|
||||||
|
Authorization: Bearer {admin_token}
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应示例:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"total": 100,
|
||||||
|
"unused": 50,
|
||||||
|
"active": 30,
|
||||||
|
"expired": 15,
|
||||||
|
"revoked": 5
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. 撤销卡密
|
||||||
|
```http
|
||||||
|
POST /license/:id/revoke
|
||||||
|
Authorization: Bearer {admin_token}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 5. 删除卡密
|
||||||
|
```http
|
||||||
|
DELETE /license/:id
|
||||||
|
Authorization: Bearer {admin_token}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 使用流程
|
||||||
|
|
||||||
|
### 用户激活流程
|
||||||
|
|
||||||
|
1. **用户注册/登录**
|
||||||
|
```http
|
||||||
|
POST /user/register 或 POST /user/login
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **获取卡密**
|
||||||
|
- 从管理员处获取卡密码
|
||||||
|
|
||||||
|
3. **激活卡密**
|
||||||
|
```http
|
||||||
|
POST /license/activate
|
||||||
|
{
|
||||||
|
"code": "获取到的卡密"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **开始使用任务功能**
|
||||||
|
- 激活成功后,所有 Task 相关接口均可正常使用
|
||||||
|
- 未激活或授权过期时,Task 接口会返回 403 错误
|
||||||
|
|
||||||
|
### 管理员操作流程
|
||||||
|
|
||||||
|
1. **生成卡密**
|
||||||
|
```bash
|
||||||
|
# 生成 10 个月卡
|
||||||
|
POST /license/generate
|
||||||
|
{
|
||||||
|
"type": "monthly",
|
||||||
|
"count": 10
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **查看卡密列表**
|
||||||
|
```bash
|
||||||
|
GET /license
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **分发卡密给用户**
|
||||||
|
- 将生成的卡密码发给用户
|
||||||
|
|
||||||
|
4. **监控卡密使用情况**
|
||||||
|
```bash
|
||||||
|
GET /license/statistics
|
||||||
|
```
|
||||||
|
|
||||||
|
## 任务模块授权保护
|
||||||
|
|
||||||
|
所有任务相关接口都需要有效的卡密授权:
|
||||||
|
|
||||||
|
- `POST /task` - 创建任务
|
||||||
|
- `GET /task` - 获取任务列表
|
||||||
|
- `GET /task/:id` - 获取单个任务
|
||||||
|
- `PATCH /task/:id` - 更新任务
|
||||||
|
- `PATCH /task/reorder` - 重新排序
|
||||||
|
- `DELETE /task/:id` - 删除任务
|
||||||
|
|
||||||
|
**未授权时的响应:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"statusCode": 403,
|
||||||
|
"message": "您的授权已过期或未激活,请联系管理员获取卡密",
|
||||||
|
"error": "Forbidden"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 数据库表结构
|
||||||
|
|
||||||
|
### license 表
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| id | INT | 主键 |
|
||||||
|
| code | VARCHAR(32) | 卡密码(唯一) |
|
||||||
|
| type | ENUM | 卡密类型 |
|
||||||
|
| status | ENUM | 卡密状态 |
|
||||||
|
| validDays | INT | 有效天数 |
|
||||||
|
| activatedAt | DATETIME | 激活时间 |
|
||||||
|
| expiresAt | DATETIME | 过期时间 |
|
||||||
|
| userId | INT | 激活用户ID(外键) |
|
||||||
|
| remarks | TEXT | 备注信息 |
|
||||||
|
| createdAt | DATETIME | 创建时间 |
|
||||||
|
| updatedAt | DATETIME | 更新时间 |
|
||||||
|
|
||||||
|
## 技术实现
|
||||||
|
|
||||||
|
### 核心组件
|
||||||
|
|
||||||
|
1. **LicenseService** - 卡密业务逻辑
|
||||||
|
- 生成卡密(确保唯一性)
|
||||||
|
- 验证卡密
|
||||||
|
- 激活卡密(支持延期)
|
||||||
|
- 查询统计
|
||||||
|
|
||||||
|
2. **LicenseGuard** - 授权守卫
|
||||||
|
- 拦截需要授权的请求
|
||||||
|
- 验证用户授权状态
|
||||||
|
- 返回 403 错误(未授权)
|
||||||
|
|
||||||
|
3. **@RequireLicense** - 装饰器
|
||||||
|
- 标记需要卡密授权的 Controller/Method
|
||||||
|
|
||||||
|
### 代码示例
|
||||||
|
|
||||||
|
在需要授权保护的 Controller 上添加:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { UseGuards } from '@nestjs/common';
|
||||||
|
import { RequireLicense } from '../license/decorators/require-license.decorator';
|
||||||
|
import { LicenseGuard } from '../license/guards/license.guard';
|
||||||
|
|
||||||
|
@Controller('task')
|
||||||
|
@UseGuards(LicenseGuard)
|
||||||
|
@RequireLicense()
|
||||||
|
export class TaskController {
|
||||||
|
// ... 所有接口都需要有效授权
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. **安全性**
|
||||||
|
- 卡密使用随机生成算法,去除易混淆字符
|
||||||
|
- 每个卡密全局唯一
|
||||||
|
- 卡密验证在守卫层面统一拦截
|
||||||
|
|
||||||
|
2. **用户体验**
|
||||||
|
- 支持授权延期(多个卡密叠加)
|
||||||
|
- 提供剩余天数查询
|
||||||
|
- 清晰的错误提示
|
||||||
|
|
||||||
|
3. **管理维护**
|
||||||
|
- 支持卡密撤销(无需删除记录)
|
||||||
|
- 提供统计接口
|
||||||
|
- 支持按状态和类型筛选
|
||||||
|
|
||||||
|
## Swagger 文档
|
||||||
|
|
||||||
|
启动项目后访问:`http://localhost:3030/api`
|
||||||
|
|
||||||
|
在 Swagger 文档中可以查看所有卡密相关接口的详细说明和测试。
|
||||||
|
|
||||||
|
## 测试建议
|
||||||
|
|
||||||
|
1. **测试用户激活流程**
|
||||||
|
```bash
|
||||||
|
# 1. 注册用户
|
||||||
|
# 2. 生成测试卡密
|
||||||
|
# 3. 激活卡密
|
||||||
|
# 4. 访问任务接口
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **测试授权过期**
|
||||||
|
- 修改数据库中的 expiresAt 为过去时间
|
||||||
|
- 访问任务接口应返回 403
|
||||||
|
|
||||||
|
3. **测试延期功能**
|
||||||
|
- 激活第一个卡密
|
||||||
|
- 再激活第二个卡密
|
||||||
|
- 验证有效期是否累加
|
||||||
|
|
||||||
|
## 未来扩展
|
||||||
|
|
||||||
|
1. 支持不同模块的独立授权
|
||||||
|
2. 支持授权转让
|
||||||
|
3. 添加卡密使用日志
|
||||||
|
4. 支持自动续费
|
||||||
|
5. 添加授权即将过期提醒
|
||||||
|
|
@ -0,0 +1,303 @@
|
||||||
|
# 快速开始 - 卡密系统
|
||||||
|
|
||||||
|
## 前置要求
|
||||||
|
|
||||||
|
- Node.js 16+
|
||||||
|
- MySQL 5.7+
|
||||||
|
- 已配置好的数据库连接
|
||||||
|
|
||||||
|
## 安装步骤
|
||||||
|
|
||||||
|
### 1. 安装依赖
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
# 或
|
||||||
|
pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 启动项目
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run start:dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 访问 Swagger 文档
|
||||||
|
|
||||||
|
打开浏览器访问: http://localhost:3030/api
|
||||||
|
|
||||||
|
## 快速测试
|
||||||
|
|
||||||
|
### 方式一: 使用测试脚本
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 运行测试脚本
|
||||||
|
bash test-license-system.sh
|
||||||
|
|
||||||
|
# 2. 按照提示输入卡密
|
||||||
|
```
|
||||||
|
|
||||||
|
### 方式二: 使用 Swagger UI
|
||||||
|
|
||||||
|
#### Step 1: 注册用户
|
||||||
|
|
||||||
|
1. 找到 `POST /user/register` 接口
|
||||||
|
2. 点击 "Try it out"
|
||||||
|
3. 输入用户信息:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"username": "testuser",
|
||||||
|
"password": "password123",
|
||||||
|
"email": "test@example.com"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
4. 点击 "Execute"
|
||||||
|
5. 复制返回的 `access_token`
|
||||||
|
|
||||||
|
#### Step 2: 生成卡密(需要管理员权限)
|
||||||
|
|
||||||
|
**临时方案 - 直接插入数据库:**
|
||||||
|
|
||||||
|
```sql
|
||||||
|
INSERT INTO `license` (`code`, `type`, `status`, `validDays`, `remarks`)
|
||||||
|
VALUES
|
||||||
|
('ABCD-1234-EFGH-5678', 'monthly', 'unused', 30, '测试卡密'),
|
||||||
|
('WXYZ-9876-IJKL-5432', 'yearly', 'unused', 365, '年度测试卡');
|
||||||
|
```
|
||||||
|
|
||||||
|
**或使用 API(需要管理员 Token):**
|
||||||
|
|
||||||
|
1. 找到 `POST /license/generate` 接口
|
||||||
|
2. 点击右上角 "Authorize",输入 access_token
|
||||||
|
3. 输入生成参数:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "monthly",
|
||||||
|
"validDays": 30,
|
||||||
|
"count": 1,
|
||||||
|
"remarks": "测试卡密"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
4. 复制生成的卡密码
|
||||||
|
|
||||||
|
#### Step 3: 激活卡密
|
||||||
|
|
||||||
|
1. 找到 `POST /license/activate` 接口
|
||||||
|
2. 点击 "Authorize",输入你的 access_token
|
||||||
|
3. 输入卡密:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": "ABCD-1234-EFGH-5678"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
4. 点击 "Execute"
|
||||||
|
5. 验证激活成功
|
||||||
|
|
||||||
|
#### Step 4: 查询授权状态
|
||||||
|
|
||||||
|
1. 找到 `GET /license/my` 接口
|
||||||
|
2. 点击 "Try it out"
|
||||||
|
3. 点击 "Execute"
|
||||||
|
4. 查看授权信息和剩余天数
|
||||||
|
|
||||||
|
#### Step 5: 测试任务功能
|
||||||
|
|
||||||
|
1. 找到 `POST /task` 接口
|
||||||
|
2. 输入任务信息:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "测试任务",
|
||||||
|
"description": "这是一个测试任务",
|
||||||
|
"priority": "high"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
3. 点击 "Execute"
|
||||||
|
4. 应该创建成功(之前会返回 403)
|
||||||
|
|
||||||
|
## 常用 curl 命令
|
||||||
|
|
||||||
|
### 注册用户
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3030/user/register \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"username": "testuser",
|
||||||
|
"password": "password123",
|
||||||
|
"email": "test@example.com"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 激活卡密
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3030/license/activate \
|
||||||
|
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"code": "ABCD-1234-EFGH-5678"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 查询授权
|
||||||
|
```bash
|
||||||
|
curl -X GET http://localhost:3030/license/my \
|
||||||
|
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 创建任务(需要有效授权)
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3030/task \
|
||||||
|
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"title": "完成项目文档",
|
||||||
|
"priority": "high"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 验证授权保护
|
||||||
|
|
||||||
|
### 测试未授权访问(应该返回 403)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 使用未激活卡密的用户 Token
|
||||||
|
curl -X GET http://localhost:3030/task \
|
||||||
|
-H "Authorization: Bearer UNAUTHORIZED_TOKEN"
|
||||||
|
|
||||||
|
# 预期响应:
|
||||||
|
# {
|
||||||
|
# "statusCode": 403,
|
||||||
|
# "message": "您的授权已过期或未激活,请联系管理员获取卡密",
|
||||||
|
# "error": "Forbidden"
|
||||||
|
# }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 测试授权后访问(应该成功)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 使用已激活卡密的用户 Token
|
||||||
|
curl -X GET http://localhost:3030/task \
|
||||||
|
-H "Authorization: Bearer AUTHORIZED_TOKEN"
|
||||||
|
|
||||||
|
# 预期响应: 任务列表数组
|
||||||
|
```
|
||||||
|
|
||||||
|
## 管理员操作
|
||||||
|
|
||||||
|
### 生成批量卡密
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3030/license/generate \
|
||||||
|
-H "Authorization: Bearer ADMIN_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"type": "monthly",
|
||||||
|
"validDays": 30,
|
||||||
|
"count": 10,
|
||||||
|
"remarks": "批量生成月卡"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 查看统计信息
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X GET http://localhost:3030/license/statistics \
|
||||||
|
-H "Authorization: Bearer ADMIN_TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 查询所有卡密
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 查询所有未使用的月卡
|
||||||
|
curl -X GET "http://localhost:3030/license?status=unused&type=monthly" \
|
||||||
|
-H "Authorization: Bearer ADMIN_TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 撤销卡密
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3030/license/1/revoke \
|
||||||
|
-H "Authorization: Bearer ADMIN_TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 数据库快速查看
|
||||||
|
|
||||||
|
### 查看所有卡密
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT
|
||||||
|
id, code, type, status, validDays,
|
||||||
|
activatedAt, expiresAt, userId, remarks
|
||||||
|
FROM license
|
||||||
|
ORDER BY createdAt DESC;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 查看有效授权
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT
|
||||||
|
l.code, l.type, l.status,
|
||||||
|
u.username, u.email,
|
||||||
|
l.activatedAt, l.expiresAt,
|
||||||
|
DATEDIFF(l.expiresAt, NOW()) as remainingDays
|
||||||
|
FROM license l
|
||||||
|
LEFT JOIN user u ON l.userId = u.id
|
||||||
|
WHERE l.status = 'active' AND l.expiresAt > NOW()
|
||||||
|
ORDER BY l.expiresAt;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 查看即将过期的授权(7天内)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT
|
||||||
|
l.code, u.username, u.email,
|
||||||
|
l.expiresAt,
|
||||||
|
DATEDIFF(l.expiresAt, NOW()) as remainingDays
|
||||||
|
FROM license l
|
||||||
|
LEFT JOIN user u ON l.userId = u.id
|
||||||
|
WHERE
|
||||||
|
l.status = 'active'
|
||||||
|
AND l.expiresAt > NOW()
|
||||||
|
AND l.expiresAt < DATE_ADD(NOW(), INTERVAL 7 DAY)
|
||||||
|
ORDER BY l.expiresAt;
|
||||||
|
```
|
||||||
|
|
||||||
|
## 故障排查
|
||||||
|
|
||||||
|
### 问题: 激活卡密时返回 403
|
||||||
|
**原因**: 未登录或 Token 过期
|
||||||
|
**解决**: 重新登录获取新的 access_token
|
||||||
|
|
||||||
|
### 问题: 激活卡密时返回 409
|
||||||
|
**原因**: 卡密已被激活
|
||||||
|
**解决**: 使用其他未激活的卡密
|
||||||
|
|
||||||
|
### 问题: 任务接口返回 403
|
||||||
|
**原因**: 授权已过期或未激活
|
||||||
|
**解决**: 激活新的卡密或检查授权状态
|
||||||
|
|
||||||
|
### 问题: 生成卡密返回 401
|
||||||
|
**原因**: 需要管理员权限
|
||||||
|
**解决**: 使用管理员账号或临时从数据库插入
|
||||||
|
|
||||||
|
## 下一步
|
||||||
|
|
||||||
|
1. 根据实际业务需求调整卡密类型和有效期
|
||||||
|
2. 实现管理员权限验证机制
|
||||||
|
3. 添加前端授权管理页面
|
||||||
|
4. 配置授权即将过期的邮件提醒
|
||||||
|
5. 实现授权使用统计和分析
|
||||||
|
|
||||||
|
## 相关文档
|
||||||
|
|
||||||
|
- [LICENSE_SYSTEM.md](LICENSE_SYSTEM.md) - 详细功能说明
|
||||||
|
- [LICENSE_IMPLEMENTATION.md](LICENSE_IMPLEMENTATION.md) - 实现总结
|
||||||
|
- [Swagger API](http://localhost:3030/api) - 在线 API 文档
|
||||||
|
|
||||||
|
## 技术支持
|
||||||
|
|
||||||
|
如有问题,请查看:
|
||||||
|
1. Swagger 文档中的接口说明
|
||||||
|
2. LICENSE_SYSTEM.md 中的详细文档
|
||||||
|
3. 数据库中的卡密状态
|
||||||
|
|
||||||
|
祝使用愉快!
|
||||||
|
|
@ -0,0 +1,179 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# 卡密生成快速脚本
|
||||||
|
# 使用方法: bash generate-licenses.sh
|
||||||
|
|
||||||
|
BASE_URL="http://localhost:3030"
|
||||||
|
|
||||||
|
# 颜色定义
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
RED='\033[0;31m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
echo -e "${GREEN}====== 卡密生成工具 ======${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 检查是否已有管理员账号
|
||||||
|
read -p "是否已有管理员账号?(y/n): " has_admin
|
||||||
|
|
||||||
|
if [ "$has_admin" != "y" ]; then
|
||||||
|
echo -e "${YELLOW}步骤 1: 注册管理员账号${NC}"
|
||||||
|
read -p "请输入管理员用户名 (默认: admin): " admin_username
|
||||||
|
admin_username=${admin_username:-admin}
|
||||||
|
|
||||||
|
read -sp "请输入管理员密码: " admin_password
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
read -p "请输入管理员邮箱 (默认: admin@example.com): " admin_email
|
||||||
|
admin_email=${admin_email:-admin@example.com}
|
||||||
|
|
||||||
|
echo -e "${YELLOW}正在注册管理员账号...${NC}"
|
||||||
|
|
||||||
|
REGISTER_RESPONSE=$(curl -s -X POST "$BASE_URL/user/register" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{
|
||||||
|
\"username\": \"$admin_username\",
|
||||||
|
\"password\": \"$admin_password\",
|
||||||
|
\"email\": \"$admin_email\"
|
||||||
|
}")
|
||||||
|
|
||||||
|
ADMIN_TOKEN=$(echo $REGISTER_RESPONSE | jq -r '.access_token')
|
||||||
|
|
||||||
|
if [ "$ADMIN_TOKEN" = "null" ] || [ -z "$ADMIN_TOKEN" ]; then
|
||||||
|
echo -e "${RED}注册失败!${NC}"
|
||||||
|
echo "$REGISTER_RESPONSE" | jq '.'
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${GREEN}管理员账号注册成功!${NC}"
|
||||||
|
echo "管理员 Token: $ADMIN_TOKEN"
|
||||||
|
echo ""
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}步骤 1: 管理员登录${NC}"
|
||||||
|
read -p "请输入管理员用户名: " admin_username
|
||||||
|
read -sp "请输入管理员密码: " admin_password
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo -e "${YELLOW}正在登录...${NC}"
|
||||||
|
|
||||||
|
LOGIN_RESPONSE=$(curl -s -X POST "$BASE_URL/user/login" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{
|
||||||
|
\"username\": \"$admin_username\",
|
||||||
|
\"password\": \"$admin_password\"
|
||||||
|
}")
|
||||||
|
|
||||||
|
ADMIN_TOKEN=$(echo $LOGIN_RESPONSE | jq -r '.access_token')
|
||||||
|
|
||||||
|
if [ "$ADMIN_TOKEN" = "null" ] || [ -z "$ADMIN_TOKEN" ]; then
|
||||||
|
echo -e "${RED}登录失败!请检查用户名和密码${NC}"
|
||||||
|
echo "$LOGIN_RESPONSE" | jq '.'
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${GREEN}登录成功!${NC}"
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 生成卡密
|
||||||
|
echo -e "${YELLOW}步骤 2: 选择要生成的卡密类型${NC}"
|
||||||
|
echo "1. 试用卡 (7天)"
|
||||||
|
echo "2. 月卡 (30天)"
|
||||||
|
echo "3. 年卡 (365天)"
|
||||||
|
echo "4. 终身卡"
|
||||||
|
read -p "请选择 (1-4): " license_choice
|
||||||
|
|
||||||
|
case $license_choice in
|
||||||
|
1)
|
||||||
|
LICENSE_TYPE="trial"
|
||||||
|
VALID_DAYS=7
|
||||||
|
;;
|
||||||
|
2)
|
||||||
|
LICENSE_TYPE="monthly"
|
||||||
|
VALID_DAYS=30
|
||||||
|
;;
|
||||||
|
3)
|
||||||
|
LICENSE_TYPE="yearly"
|
||||||
|
VALID_DAYS=365
|
||||||
|
;;
|
||||||
|
4)
|
||||||
|
LICENSE_TYPE="lifetime"
|
||||||
|
VALID_DAYS=36500
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo -e "${RED}无效的选择${NC}"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
read -p "请输入生成数量 (默认: 1): " count
|
||||||
|
count=${count:-1}
|
||||||
|
|
||||||
|
read -p "请输入备注信息 (可选): " remarks
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${YELLOW}正在生成 $count 个 $LICENSE_TYPE 卡密...${NC}"
|
||||||
|
|
||||||
|
GENERATE_RESPONSE=$(curl -s -X POST "$BASE_URL/license/generate" \
|
||||||
|
-H "Authorization: Bearer $ADMIN_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{
|
||||||
|
\"type\": \"$LICENSE_TYPE\",
|
||||||
|
\"validDays\": $VALID_DAYS,
|
||||||
|
\"count\": $count,
|
||||||
|
\"remarks\": \"$remarks\"
|
||||||
|
}")
|
||||||
|
|
||||||
|
# 检查是否成功
|
||||||
|
if echo "$GENERATE_RESPONSE" | jq -e '.[0].code' > /dev/null 2>&1; then
|
||||||
|
echo -e "${GREEN}卡密生成成功!${NC}"
|
||||||
|
echo ""
|
||||||
|
echo "生成的卡密:"
|
||||||
|
echo "$GENERATE_RESPONSE" | jq -r '.[] | " \(.code) - \(.type) (\(.validDays)天) - \(.remarks // "无备注")"'
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 询问是否保存到文件
|
||||||
|
read -p "是否将卡密保存到文件?(y/n): " save_to_file
|
||||||
|
|
||||||
|
if [ "$save_to_file" = "y" ]; then
|
||||||
|
FILENAME="licenses_$(date +%Y%m%d_%H%M%S).txt"
|
||||||
|
echo "生成时间: $(date)" > "$FILENAME"
|
||||||
|
echo "类型: $LICENSE_TYPE" >> "$FILENAME"
|
||||||
|
echo "有效天数: $VALID_DAYS" >> "$FILENAME"
|
||||||
|
echo "数量: $count" >> "$FILENAME"
|
||||||
|
echo "备注: $remarks" >> "$FILENAME"
|
||||||
|
echo "" >> "$FILENAME"
|
||||||
|
echo "卡密列表:" >> "$FILENAME"
|
||||||
|
echo "$GENERATE_RESPONSE" | jq -r '.[] | .code' >> "$FILENAME"
|
||||||
|
|
||||||
|
echo -e "${GREEN}卡密已保存到: $FILENAME${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 显示统计信息
|
||||||
|
echo ""
|
||||||
|
read -p "是否查看卡密统计信息?(y/n): " show_stats
|
||||||
|
|
||||||
|
if [ "$show_stats" = "y" ]; then
|
||||||
|
echo -e "${YELLOW}查询统计信息...${NC}"
|
||||||
|
STATS=$(curl -s -X GET "$BASE_URL/license/statistics" \
|
||||||
|
-H "Authorization: Bearer $ADMIN_TOKEN")
|
||||||
|
|
||||||
|
echo "$STATS" | jq '.'
|
||||||
|
fi
|
||||||
|
|
||||||
|
else
|
||||||
|
echo -e "${RED}生成失败!${NC}"
|
||||||
|
echo "$GENERATE_RESPONSE" | jq '.'
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${GREEN}====== 操作完成 ======${NC}"
|
||||||
|
echo ""
|
||||||
|
echo "提示:"
|
||||||
|
echo "1. 管理员 Token 已保存(有效期内可继续使用)"
|
||||||
|
echo "2. 可以将卡密分发给用户进行激活"
|
||||||
|
echo "3. 用户激活接口: POST /license/activate"
|
||||||
|
echo "4. 查看所有卡密: GET /license"
|
||||||
|
echo ""
|
||||||
|
|
@ -93,5 +93,6 @@
|
||||||
],
|
],
|
||||||
"coverageDirectory": "../coverage",
|
"coverageDirectory": "../coverage",
|
||||||
"testEnvironment": "node"
|
"testEnvironment": "node"
|
||||||
}
|
},
|
||||||
|
"packageManager": "pnpm@9.15.4+sha512.b2dc20e2fc72b3e18848459b37359a32064663e5627a51e4c74b2c29dd8e8e0491483c3abb40789cfd578bf362fb6ba8261b05f0387d76792ed6e23ea3b1b6a0"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
8642
pnpm-lock.yaml
8642
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
|
|
@ -1,11 +1,25 @@
|
||||||
import { Controller, Get } from '@nestjs/common';
|
import { Controller, Get } from '@nestjs/common';
|
||||||
import { AppService } from './app.service';
|
import { AppService } from './app.service';
|
||||||
|
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
@ApiTags('根路由')
|
||||||
@Controller()
|
@Controller()
|
||||||
export class AppController {
|
export class AppController {
|
||||||
constructor(private readonly appService: AppService) {}
|
constructor(private readonly appService: AppService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
|
@ApiOperation({
|
||||||
|
summary: '健康检查',
|
||||||
|
description: '返回应用程序的基本欢迎信息,用于验证服务是否正常运行',
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: '服务运行正常',
|
||||||
|
schema: {
|
||||||
|
type: 'string',
|
||||||
|
example: 'Hello World!',
|
||||||
|
},
|
||||||
|
})
|
||||||
getHello(): string {
|
getHello(): string {
|
||||||
return this.appService.getHello();
|
return this.appService.getHello();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ import { LarkModule } from './lark/lark.module';
|
||||||
import { GitHubModule } from './github/github.module';
|
import { GitHubModule } from './github/github.module';
|
||||||
import { DeepseekModule } from './deepseek/deepseek.module';
|
import { DeepseekModule } from './deepseek/deepseek.module';
|
||||||
import { TaskModule } from './task/task.module';
|
import { TaskModule } from './task/task.module';
|
||||||
|
import { LicenseModule } from './license/license.module';
|
||||||
|
import { CommonModule } from './common/common.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
|
@ -32,12 +34,14 @@ import { TaskModule } from './task/task.module';
|
||||||
}),
|
}),
|
||||||
inject: [ConfigService],
|
inject: [ConfigService],
|
||||||
}),
|
}),
|
||||||
|
CommonModule,
|
||||||
UserModule,
|
UserModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
LarkModule,
|
LarkModule,
|
||||||
GitHubModule,
|
GitHubModule,
|
||||||
DeepseekModule,
|
DeepseekModule,
|
||||||
TaskModule,
|
TaskModule,
|
||||||
|
LicenseModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [AppService],
|
providers: [AppService],
|
||||||
|
|
|
||||||
|
|
@ -2,26 +2,68 @@ import { Controller, Get, Req, Res, UseGuards } from '@nestjs/common';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import { UserService } from '../user/user.service';
|
import { UserService } from '../user/user.service';
|
||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
import {
|
||||||
|
ApiTags,
|
||||||
|
ApiOperation,
|
||||||
|
ApiResponse,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import { ErrorResponseDto } from '../common/dto/error-response.dto';
|
||||||
|
|
||||||
@ApiTags('认证模块')
|
@ApiTags('认证模块')
|
||||||
@Controller('auth')
|
@Controller('auth')
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
constructor(private readonly userService: UserService) {}
|
constructor(private readonly userService: UserService) {}
|
||||||
|
|
||||||
@Get('github')
|
@Get('github')
|
||||||
@UseGuards(AuthGuard('github'))
|
@UseGuards(AuthGuard('github'))
|
||||||
@ApiOperation({ summary: 'Github 登录' })
|
@ApiOperation({
|
||||||
@ApiResponse({ status: 302, description: '重定向到 Github 授权页面' })
|
summary: 'GitHub 登录',
|
||||||
|
description:
|
||||||
|
'启动 GitHub OAuth 登录流程,重定向用户到 GitHub 授权页面进行身份验证',
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 302,
|
||||||
|
description: '重定向到 GitHub 授权页面',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
url: 'https://github.com/login/oauth/authorize?client_id=xxx&redirect_uri=xxx',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 500,
|
||||||
|
description: '服务器内部错误',
|
||||||
|
type: ErrorResponseDto,
|
||||||
|
})
|
||||||
async githubLogin() {
|
async githubLogin() {
|
||||||
// GitHub 登录重定向
|
// GitHub 登录重定向
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('github/callback')
|
@Get('github/callback')
|
||||||
@UseGuards(AuthGuard('github'))
|
@UseGuards(AuthGuard('github'))
|
||||||
@ApiOperation({ summary: 'Github 登录回调' })
|
@ApiOperation({
|
||||||
|
summary: 'GitHub 登录回调',
|
||||||
|
description:
|
||||||
|
'GitHub OAuth 回调端点。在用户授权后,GitHub 会重定向到此端点,携带授权码。系统将验证用户身份,生成 JWT Token,并重定向到前端页面',
|
||||||
|
})
|
||||||
@ApiResponse({
|
@ApiResponse({
|
||||||
status: 302,
|
status: 302,
|
||||||
description: '重定向到前端页面,并携带 token',
|
description: '登录成功,重定向到前端页面并携带 JWT Token 和用户信息',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
url: 'http://localhost:8000/test-github-sso.html?userId=1&username=johndoe&email=john@example.com&provider=github&providerUsername=johndoe&access_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 401,
|
||||||
|
description: 'GitHub 授权失败或用户取消授权',
|
||||||
|
type: ErrorResponseDto,
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 500,
|
||||||
|
description: '服务器内部错误',
|
||||||
|
type: ErrorResponseDto,
|
||||||
})
|
})
|
||||||
async githubCallback(@Req() req: any, @Res() res: Response) {
|
async githubCallback(@Req() req: any, @Res() res: Response) {
|
||||||
const oauthUser = req.user;
|
const oauthUser = req.user;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { Module, Global } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { User } from '../user/entities/user.entity';
|
||||||
|
import { AdminService } from './services/admin.service';
|
||||||
|
import { AdminGuard } from './guards/admin.guard';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([User])],
|
||||||
|
providers: [AdminService, AdminGuard],
|
||||||
|
exports: [AdminService, AdminGuard],
|
||||||
|
})
|
||||||
|
export class CommonModule {}
|
||||||
|
|
@ -0,0 +1,130 @@
|
||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一错误响应 DTO
|
||||||
|
*/
|
||||||
|
export class ErrorResponseDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'HTTP 状态码',
|
||||||
|
example: 400,
|
||||||
|
})
|
||||||
|
statusCode: number;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: '错误消息',
|
||||||
|
example: '请求参数错误',
|
||||||
|
})
|
||||||
|
message: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: '错误详情(可选)',
|
||||||
|
required: false,
|
||||||
|
example: ['用户名不能为空', '邮箱格式不正确'],
|
||||||
|
})
|
||||||
|
error?: string | string[];
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: '请求路径',
|
||||||
|
example: '/api/user/register',
|
||||||
|
})
|
||||||
|
path: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: '时间戳',
|
||||||
|
example: '2025-12-13T10:30:00.000Z',
|
||||||
|
})
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 认证失败响应 DTO
|
||||||
|
*/
|
||||||
|
export class UnauthorizedResponseDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'HTTP 状态码',
|
||||||
|
example: 401,
|
||||||
|
})
|
||||||
|
statusCode: number;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: '错误消息',
|
||||||
|
example: '未授权访问',
|
||||||
|
})
|
||||||
|
message: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: '错误类型',
|
||||||
|
example: 'Unauthorized',
|
||||||
|
})
|
||||||
|
error: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 资源未找到响应 DTO
|
||||||
|
*/
|
||||||
|
export class NotFoundResponseDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'HTTP 状态码',
|
||||||
|
example: 404,
|
||||||
|
})
|
||||||
|
statusCode: number;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: '错误消息',
|
||||||
|
example: '资源未找到',
|
||||||
|
})
|
||||||
|
message: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: '错误类型',
|
||||||
|
example: 'Not Found',
|
||||||
|
})
|
||||||
|
error: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 冲突响应 DTO(如用户已存在)
|
||||||
|
*/
|
||||||
|
export class ConflictResponseDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'HTTP 状态码',
|
||||||
|
example: 409,
|
||||||
|
})
|
||||||
|
statusCode: number;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: '错误消息',
|
||||||
|
example: '用户名已存在',
|
||||||
|
})
|
||||||
|
message: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: '错误类型',
|
||||||
|
example: 'Conflict',
|
||||||
|
})
|
||||||
|
error: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证失败响应 DTO
|
||||||
|
*/
|
||||||
|
export class ValidationErrorResponseDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'HTTP 状态码',
|
||||||
|
example: 422,
|
||||||
|
})
|
||||||
|
statusCode: number;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: '错误消息列表',
|
||||||
|
example: ['用户名不能为空', '密码长度必须在6到20之间'],
|
||||||
|
type: [String],
|
||||||
|
})
|
||||||
|
message: string[];
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: '错误类型',
|
||||||
|
example: 'Unprocessable Entity',
|
||||||
|
})
|
||||||
|
error: string;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
CanActivate,
|
||||||
|
ExecutionContext,
|
||||||
|
ForbiddenException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { AdminService } from '../services/admin.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AdminGuard implements CanActivate {
|
||||||
|
constructor(private adminService: AdminService) {}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
const request = context.switchToHttp().getRequest();
|
||||||
|
const user = request.user;
|
||||||
|
|
||||||
|
if (!user || !user.userId) {
|
||||||
|
throw new ForbiddenException('请先登录');
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAdmin = await this.adminService.isAdmin(user.userId);
|
||||||
|
|
||||||
|
if (!isAdmin) {
|
||||||
|
throw new ForbiddenException('需要管理员权限');
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { User } from '../user/entities/user.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AdminService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(User)
|
||||||
|
private readonly userRepository: Repository<User>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查用户是否是管理员
|
||||||
|
* 简单实现:检查用户名是否为 admin 或邮箱包含 admin
|
||||||
|
* 生产环境应该使用更复杂的角色系统
|
||||||
|
*/
|
||||||
|
async isAdmin(userId: number): Promise<boolean> {
|
||||||
|
const user = await this.userRepository.findOne({
|
||||||
|
where: { id: userId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 简单的管理员判断逻辑(可根据实际需求调整)
|
||||||
|
return (
|
||||||
|
user.username === 'admin' ||
|
||||||
|
user.email.includes('admin') ||
|
||||||
|
user.username.startsWith('admin_')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有管理员用户
|
||||||
|
*/
|
||||||
|
async getAdminUsers(): Promise<User[]> {
|
||||||
|
const users = await this.userRepository.find();
|
||||||
|
const admins: User[] = [];
|
||||||
|
|
||||||
|
for (const user of users) {
|
||||||
|
if (
|
||||||
|
user.username === 'admin' ||
|
||||||
|
user.email.includes('admin') ||
|
||||||
|
user.username.startsWith('admin_')
|
||||||
|
) {
|
||||||
|
admins.push(user);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return admins;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,25 +3,58 @@ import { DeepseekService } from './deepseek.service';
|
||||||
import { Public } from 'src/auth/decorators/public.decorator';
|
import { Public } from 'src/auth/decorators/public.decorator';
|
||||||
import { PROVIDER_TYPE } from 'src/constants/providerType';
|
import { PROVIDER_TYPE } from 'src/constants/providerType';
|
||||||
import { ApiBody, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
import { ApiBody, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { ErrorResponseDto } from '../common/dto/error-response.dto';
|
||||||
|
|
||||||
@ApiTags('Deepseek 模块')
|
@ApiTags('DeepSeek AI 模块')
|
||||||
@Controller('deepSeek')
|
@Controller('deepSeek')
|
||||||
export class DeepseekController {
|
export class DeepseekController {
|
||||||
constructor(private readonly deepseekService: DeepseekService) {}
|
constructor(private readonly deepseekService: DeepseekService) {}
|
||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@Post('chat-flow')
|
@Post('chat-flow')
|
||||||
@ApiOperation({ summary: '与 Flow 模型聊天' })
|
@ApiOperation({
|
||||||
|
summary: '与 DeepSeek Flow 模型对话',
|
||||||
|
description:
|
||||||
|
'使用 DeepSeek Flow 模型进行对话交互。Flow 模型擅长快速响应和流式输出,适合实时对话场景',
|
||||||
|
})
|
||||||
@ApiBody({
|
@ApiBody({
|
||||||
schema: {
|
schema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
message: { type: 'string', description: '发送给模型的消息' },
|
message: {
|
||||||
|
type: 'string',
|
||||||
|
description: '发送给 AI 模型的消息内容',
|
||||||
|
example: '你好,请介绍一下你自己',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
required: ['message'],
|
required: ['message'],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@ApiResponse({ status: 200, description: '成功返回模型的响应' })
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: '成功返回 AI 模型的响应',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
model: 'deepseek-flow',
|
||||||
|
message: '你好!我是 DeepSeek Flow,一个 AI 助手。我可以帮助你回答问题、提供建议和进行对话。',
|
||||||
|
usage: {
|
||||||
|
prompt_tokens: 10,
|
||||||
|
completion_tokens: 25,
|
||||||
|
total_tokens: 35,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 400,
|
||||||
|
description: '请求参数错误(消息内容为空等)',
|
||||||
|
type: ErrorResponseDto,
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 500,
|
||||||
|
description: 'AI 服务调用失败',
|
||||||
|
type: ErrorResponseDto,
|
||||||
|
})
|
||||||
async chatFlow(@Body() body: { message: string }) {
|
async chatFlow(@Body() body: { message: string }) {
|
||||||
const response = await this.deepseekService.chatRequest(
|
const response = await this.deepseekService.chatRequest(
|
||||||
body.message,
|
body.message,
|
||||||
|
|
@ -32,17 +65,50 @@ export class DeepseekController {
|
||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@Post('chat-deep')
|
@Post('chat-deep')
|
||||||
@ApiOperation({ summary: '与 Deep 模型聊天' })
|
@ApiOperation({
|
||||||
|
summary: '与 DeepSeek Deep 模型对话',
|
||||||
|
description:
|
||||||
|
'使用 DeepSeek Deep 模型进行对话交互。Deep 模型提供更深入的推理能力,适合复杂问题解答和深度分析',
|
||||||
|
})
|
||||||
@ApiBody({
|
@ApiBody({
|
||||||
schema: {
|
schema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
message: { type: 'string', description: '发送给模型的消息' },
|
message: {
|
||||||
|
type: 'string',
|
||||||
|
description: '发送给 AI 模型的消息内容',
|
||||||
|
example: '请解释一下量子计算的基本原理',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
required: ['message'],
|
required: ['message'],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@ApiResponse({ status: 200, description: '成功返回模型的响应' })
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: '成功返回 AI 模型的响应',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
model: 'deepseek-deep',
|
||||||
|
message:
|
||||||
|
'量子计算是一种利用量子力学原理进行信息处理的计算方式...',
|
||||||
|
usage: {
|
||||||
|
prompt_tokens: 15,
|
||||||
|
completion_tokens: 150,
|
||||||
|
total_tokens: 165,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 400,
|
||||||
|
description: '请求参数错误(消息内容为空等)',
|
||||||
|
type: ErrorResponseDto,
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 500,
|
||||||
|
description: 'AI 服务调用失败',
|
||||||
|
type: ErrorResponseDto,
|
||||||
|
})
|
||||||
async chatDeep(@Body() body: { message: string }) {
|
async chatDeep(@Body() body: { message: string }) {
|
||||||
const response = await this.deepseekService.chatRequest(
|
const response = await this.deepseekService.chatRequest(
|
||||||
body.message,
|
body.message,
|
||||||
|
|
@ -53,17 +119,49 @@ export class DeepseekController {
|
||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@Post('chat-grok')
|
@Post('chat-grok')
|
||||||
@ApiOperation({ summary: '与 Grok 模型聊天' })
|
@ApiOperation({
|
||||||
|
summary: '与 Grok 模型对话',
|
||||||
|
description:
|
||||||
|
'使用 Grok 模型进行对话交互。Grok 模型具有幽默风格和实时信息获取能力,适合需要最新信息的场景',
|
||||||
|
})
|
||||||
@ApiBody({
|
@ApiBody({
|
||||||
schema: {
|
schema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
message: { type: 'string', description: '发送给模型的消息' },
|
message: {
|
||||||
|
type: 'string',
|
||||||
|
description: '发送给 AI 模型的消息内容',
|
||||||
|
example: '最近有什么科技新闻?',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
required: ['message'],
|
required: ['message'],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@ApiResponse({ status: 200, description: '成功返回模型的响应' })
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: '成功返回 AI 模型的响应',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
model: 'grok',
|
||||||
|
message: '最近的科技新闻包括...',
|
||||||
|
usage: {
|
||||||
|
prompt_tokens: 12,
|
||||||
|
completion_tokens: 80,
|
||||||
|
total_tokens: 92,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 400,
|
||||||
|
description: '请求参数错误(消息内容为空等)',
|
||||||
|
type: ErrorResponseDto,
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 500,
|
||||||
|
description: 'AI 服务调用失败',
|
||||||
|
type: ErrorResponseDto,
|
||||||
|
})
|
||||||
async chatGrok(@Body() body: { message: string }) {
|
async chatGrok(@Body() body: { message: string }) {
|
||||||
const response = await this.deepseekService.chatRequest(
|
const response = await this.deepseekService.chatRequest(
|
||||||
body.message,
|
body.message,
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,9 @@ import { UserService } from '../user/user.service';
|
||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
import { Public } from '../auth/decorators/public.decorator';
|
import { Public } from '../auth/decorators/public.decorator';
|
||||||
import { ApiTags, ApiOperation, ApiResponse, ApiQuery } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiResponse, ApiQuery } from '@nestjs/swagger';
|
||||||
|
import { ErrorResponseDto } from '../common/dto/error-response.dto';
|
||||||
|
|
||||||
@ApiTags('Github 模块')
|
@ApiTags('GitHub OAuth 模块')
|
||||||
@Controller('github')
|
@Controller('github')
|
||||||
export class GitHubController {
|
export class GitHubController {
|
||||||
constructor(
|
constructor(
|
||||||
|
|
@ -15,13 +16,31 @@ export class GitHubController {
|
||||||
|
|
||||||
@Get('login')
|
@Get('login')
|
||||||
@Public()
|
@Public()
|
||||||
@ApiOperation({ summary: 'Github 登录' })
|
@ApiOperation({
|
||||||
|
summary: 'GitHub OAuth 登录',
|
||||||
|
description:
|
||||||
|
'启动 GitHub OAuth 2.0 登录流程。接收前端重定向地址,生成 GitHub 授权 URL 并重定向用户到 GitHub 授权页面',
|
||||||
|
})
|
||||||
@ApiQuery({
|
@ApiQuery({
|
||||||
name: 'redirectUri',
|
name: 'redirectUri',
|
||||||
description: '登录成功后的重定向地址',
|
description: '登录成功后的前端重定向地址(URL编码)',
|
||||||
required: true,
|
required: true,
|
||||||
|
example: 'http://localhost:8000/callback',
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 302,
|
||||||
|
description: '重定向到 GitHub 授权页面',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
url: 'https://github.com/login/oauth/authorize?client_id=xxx&redirect_uri=xxx&scope=read:user user:email',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 400,
|
||||||
|
description: '缺少必需的重定向地址参数',
|
||||||
|
type: ErrorResponseDto,
|
||||||
})
|
})
|
||||||
@ApiResponse({ status: 302, description: '重定向到 Github 授权页面' })
|
|
||||||
async login(@Query('redirectUri') redirectUri: string, @Res() res: Response) {
|
async login(@Query('redirectUri') redirectUri: string, @Res() res: Response) {
|
||||||
const loginUrl = await this.githubService.getLoginUrl(redirectUri);
|
const loginUrl = await this.githubService.getLoginUrl(redirectUri);
|
||||||
return res.redirect(loginUrl);
|
return res.redirect(loginUrl);
|
||||||
|
|
@ -29,12 +48,51 @@ export class GitHubController {
|
||||||
|
|
||||||
@Get('callback')
|
@Get('callback')
|
||||||
@Public()
|
@Public()
|
||||||
@ApiOperation({ summary: 'Github 登录回调' })
|
@ApiOperation({
|
||||||
@ApiQuery({ name: 'code', description: 'Github 返回的授权码' })
|
summary: 'GitHub OAuth 回调处理',
|
||||||
@ApiQuery({ name: 'state', description: 'Github 返回的状态' })
|
description:
|
||||||
@ApiResponse({ status: 200, description: '登录成功,返回用户信息和 token' })
|
'GitHub OAuth 回调端点。GitHub 授权完成后会重定向到此端点,携带授权码。系统会用授权码换取访问令牌,获取用户信息,创建或更新用户记录,并生成 JWT Token 返回给前端',
|
||||||
@ApiResponse({ status: 400, description: '授权码缺失' })
|
})
|
||||||
@ApiResponse({ status: 500, description: 'Github 认证失败' })
|
@ApiQuery({
|
||||||
|
name: 'code',
|
||||||
|
description: 'GitHub 返回的授权码(用于换取访问令牌)',
|
||||||
|
required: true,
|
||||||
|
example: 'ghu_xxxxxxxxxxxxxxxxxxxxx',
|
||||||
|
})
|
||||||
|
@ApiQuery({
|
||||||
|
name: 'state',
|
||||||
|
description: 'GitHub 返回的状态参数(用于防止 CSRF 攻击)',
|
||||||
|
required: false,
|
||||||
|
example: 'random_state_string',
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'GitHub 登录成功,返回用户信息和 JWT Token',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
message: 'GitHub login successful',
|
||||||
|
user: {
|
||||||
|
id: 1,
|
||||||
|
username: 'johndoe',
|
||||||
|
email: 'john@example.com',
|
||||||
|
provider: 'github',
|
||||||
|
providerUsername: 'johndoe',
|
||||||
|
},
|
||||||
|
access_token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
|
||||||
|
refresh_token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 400,
|
||||||
|
description: '授权码缺失或获取访问令牌失败',
|
||||||
|
type: ErrorResponseDto,
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 500,
|
||||||
|
description: 'GitHub 认证过程中发生服务器错误',
|
||||||
|
type: ErrorResponseDto,
|
||||||
|
})
|
||||||
async callback(
|
async callback(
|
||||||
@Query('code') code: string,
|
@Query('code') code: string,
|
||||||
@Query('state') _state: string,
|
@Query('state') _state: string,
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,9 @@ import { UserService } from '../user/user.service';
|
||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
import { Public } from '../auth/decorators/public.decorator';
|
import { Public } from '../auth/decorators/public.decorator';
|
||||||
import { ApiTags, ApiOperation, ApiQuery, ApiResponse } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiQuery, ApiResponse } from '@nestjs/swagger';
|
||||||
|
import { ErrorResponseDto } from '../common/dto/error-response.dto';
|
||||||
|
|
||||||
@ApiTags('Lark 模块')
|
@ApiTags('飞书 OAuth 模块')
|
||||||
@Controller('lark')
|
@Controller('lark')
|
||||||
export class LarkController {
|
export class LarkController {
|
||||||
constructor(
|
constructor(
|
||||||
|
|
@ -15,13 +16,31 @@ export class LarkController {
|
||||||
|
|
||||||
@Get('login')
|
@Get('login')
|
||||||
@Public()
|
@Public()
|
||||||
@ApiOperation({ summary: 'Lark 登录' })
|
@ApiOperation({
|
||||||
|
summary: '飞书 OAuth 登录',
|
||||||
|
description:
|
||||||
|
'启动飞书(Lark/Feishu)OAuth 2.0 登录流程。接收前端重定向地址,生成飞书授权 URL 并重定向用户到飞书授权页面',
|
||||||
|
})
|
||||||
@ApiQuery({
|
@ApiQuery({
|
||||||
name: 'redirectUri',
|
name: 'redirectUri',
|
||||||
description: '登录成功后的重定向地址',
|
description: '登录成功后的前端重定向地址(URL编码)',
|
||||||
required: true,
|
required: true,
|
||||||
|
example: 'http://localhost:8000/callback',
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 302,
|
||||||
|
description: '重定向到飞书授权页面',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
url: 'https://open.feishu.cn/open-apis/authen/v1/index?app_id=xxx&redirect_uri=xxx',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 400,
|
||||||
|
description: '缺少必需的重定向地址参数',
|
||||||
|
type: ErrorResponseDto,
|
||||||
})
|
})
|
||||||
@ApiResponse({ status: 302, description: '重定向到 Lark 授权页面' })
|
|
||||||
async login(@Query('redirectUri') redirectUri: string, @Res() res: Response) {
|
async login(@Query('redirectUri') redirectUri: string, @Res() res: Response) {
|
||||||
const loginUrl = await this.larkService.getLoginUrl(redirectUri);
|
const loginUrl = await this.larkService.getLoginUrl(redirectUri);
|
||||||
return res.redirect(loginUrl);
|
return res.redirect(loginUrl);
|
||||||
|
|
@ -29,11 +48,45 @@ export class LarkController {
|
||||||
|
|
||||||
@Get('callback')
|
@Get('callback')
|
||||||
@Public()
|
@Public()
|
||||||
@ApiOperation({ summary: 'Lark 登录回调' })
|
@ApiOperation({
|
||||||
@ApiQuery({ name: 'code', description: 'Lark 返回的授权码' })
|
summary: '飞书 OAuth 回调处理',
|
||||||
@ApiResponse({ status: 200, description: '登录成功,返回用户信息和 token' })
|
description:
|
||||||
@ApiResponse({ status: 400, description: '授权码缺失' })
|
'飞书 OAuth 回调端点。飞书授权完成后会重定向到此端点,携带授权码。系统会用授权码换取访问令牌,获取用户信息,创建或更新用户记录,并生成 JWT Token 返回给前端',
|
||||||
@ApiResponse({ status: 500, description: 'Lark 认证失败' })
|
})
|
||||||
|
@ApiQuery({
|
||||||
|
name: 'code',
|
||||||
|
description: '飞书返回的授权码(用于换取访问令牌)',
|
||||||
|
required: true,
|
||||||
|
example: 'xxxxxxxxxxxxxxxxxxxxxxxx',
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: '飞书登录成功,返回用户信息和 JWT Token',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
message: 'Lark login successful',
|
||||||
|
user: {
|
||||||
|
id: 1,
|
||||||
|
username: '张三',
|
||||||
|
email: 'zhangsan@example.com',
|
||||||
|
provider: 'lark',
|
||||||
|
providerUsername: '张三',
|
||||||
|
},
|
||||||
|
access_token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
|
||||||
|
refresh_token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 400,
|
||||||
|
description: '授权码缺失或获取访问令牌失败',
|
||||||
|
type: ErrorResponseDto,
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 500,
|
||||||
|
description: '飞书认证过程中发生服务器错误',
|
||||||
|
type: ErrorResponseDto,
|
||||||
|
})
|
||||||
async callback(@Query('code') code: string, @Res() res: Response) {
|
async callback(@Query('code') code: string, @Res() res: Response) {
|
||||||
if (!code) {
|
if (!code) {
|
||||||
return res.status(400).send('Authorization code is missing');
|
return res.status(400).send('Authorization code is missing');
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
|
||||||
|
export const REQUIRE_LICENSE_KEY = 'requireLicense';
|
||||||
|
export const RequireLicense = () => SetMetadata(REQUIRE_LICENSE_KEY, true);
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsString, IsNotEmpty, Length } from 'class-validator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 激活卡密 DTO
|
||||||
|
*/
|
||||||
|
export class ActivateLicenseDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: '卡密码',
|
||||||
|
example: 'ABCD-1234-EFGH-5678',
|
||||||
|
minLength: 19,
|
||||||
|
maxLength: 19,
|
||||||
|
})
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty({ message: '卡密不能为空' })
|
||||||
|
@Length(19, 19, { message: '卡密格式不正确' })
|
||||||
|
code: string;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsEnum, IsOptional, IsInt, Min, IsString } from 'class-validator';
|
||||||
|
import { LicenseType } from '../entities/license.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成卡密 DTO
|
||||||
|
*/
|
||||||
|
export class GenerateLicenseDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: '卡密类型',
|
||||||
|
enum: LicenseType,
|
||||||
|
example: LicenseType.MONTHLY,
|
||||||
|
})
|
||||||
|
@IsEnum(LicenseType)
|
||||||
|
type: LicenseType;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: '有效天数',
|
||||||
|
example: 30,
|
||||||
|
default: 30,
|
||||||
|
})
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@IsOptional()
|
||||||
|
validDays?: number;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: '生成数量',
|
||||||
|
example: 1,
|
||||||
|
default: 1,
|
||||||
|
})
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@IsOptional()
|
||||||
|
count?: number;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: '备注信息',
|
||||||
|
required: false,
|
||||||
|
example: '批量生成月卡',
|
||||||
|
})
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
remarks?: string;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsEnum, IsOptional } from 'class-validator';
|
||||||
|
import { LicenseStatus, LicenseType } from '../entities/license.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询卡密 DTO
|
||||||
|
*/
|
||||||
|
export class QueryLicenseDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: '卡密状态',
|
||||||
|
enum: LicenseStatus,
|
||||||
|
required: false,
|
||||||
|
})
|
||||||
|
@IsEnum(LicenseStatus)
|
||||||
|
@IsOptional()
|
||||||
|
status?: LicenseStatus;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: '卡密类型',
|
||||||
|
enum: LicenseType,
|
||||||
|
required: false,
|
||||||
|
})
|
||||||
|
@IsEnum(LicenseType)
|
||||||
|
@IsOptional()
|
||||||
|
type?: LicenseType;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,132 @@
|
||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { User } from '../../user/entities/user.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 卡密类型枚举
|
||||||
|
*/
|
||||||
|
export enum LicenseType {
|
||||||
|
TRIAL = 'trial', // 试用版
|
||||||
|
MONTHLY = 'monthly', // 月度订阅
|
||||||
|
YEARLY = 'yearly', // 年度订阅
|
||||||
|
LIFETIME = 'lifetime', // 终身授权
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 卡密状态枚举
|
||||||
|
*/
|
||||||
|
export enum LicenseStatus {
|
||||||
|
UNUSED = 'unused', // 未使用
|
||||||
|
ACTIVE = 'active', // 已激活
|
||||||
|
EXPIRED = 'expired', // 已过期
|
||||||
|
REVOKED = 'revoked', // 已撤销
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 卡密实体
|
||||||
|
*/
|
||||||
|
@Entity('license')
|
||||||
|
export class License {
|
||||||
|
@PrimaryGeneratedColumn()
|
||||||
|
@ApiProperty({ description: '卡密ID' })
|
||||||
|
id: number;
|
||||||
|
|
||||||
|
@Column({ unique: true, length: 32 })
|
||||||
|
@ApiProperty({ description: '卡密码', example: 'XXXX-XXXX-XXXX-XXXX' })
|
||||||
|
code: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'enum',
|
||||||
|
enum: LicenseType,
|
||||||
|
default: LicenseType.MONTHLY,
|
||||||
|
})
|
||||||
|
@ApiProperty({
|
||||||
|
description: '卡密类型',
|
||||||
|
enum: LicenseType,
|
||||||
|
example: LicenseType.MONTHLY,
|
||||||
|
})
|
||||||
|
type: LicenseType;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'enum',
|
||||||
|
enum: LicenseStatus,
|
||||||
|
default: LicenseStatus.UNUSED,
|
||||||
|
})
|
||||||
|
@ApiProperty({
|
||||||
|
description: '卡密状态',
|
||||||
|
enum: LicenseStatus,
|
||||||
|
example: LicenseStatus.UNUSED,
|
||||||
|
})
|
||||||
|
status: LicenseStatus;
|
||||||
|
|
||||||
|
@Column({ type: 'int', default: 30 })
|
||||||
|
@ApiProperty({ description: '有效天数', example: 30 })
|
||||||
|
validDays: number;
|
||||||
|
|
||||||
|
@Column({ type: 'datetime', nullable: true })
|
||||||
|
@ApiProperty({ description: '激活时间', required: false })
|
||||||
|
activatedAt?: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'datetime', nullable: true })
|
||||||
|
@ApiProperty({ description: '过期时间', required: false })
|
||||||
|
expiresAt?: Date;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
@ApiProperty({ description: '激活用户ID', required: false })
|
||||||
|
userId?: number;
|
||||||
|
|
||||||
|
@ManyToOne(() => User, { nullable: true })
|
||||||
|
@JoinColumn({ name: 'userId' })
|
||||||
|
@ApiProperty({ description: '激活用户信息', required: false, type: () => User })
|
||||||
|
user?: User;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
@ApiProperty({ description: '备注信息', required: false })
|
||||||
|
remarks?: string;
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
@ApiProperty({ description: '创建时间' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn()
|
||||||
|
@ApiProperty({ description: '更新时间' })
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查卡密是否有效
|
||||||
|
*/
|
||||||
|
isValid(): boolean {
|
||||||
|
if (this.status !== LicenseStatus.ACTIVE) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.expiresAt) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Date() < new Date(this.expiresAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取剩余天数
|
||||||
|
*/
|
||||||
|
getRemainingDays(): number {
|
||||||
|
if (!this.expiresAt || this.status !== LicenseStatus.ACTIVE) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const expiry = new Date(this.expiresAt);
|
||||||
|
const diff = expiry.getTime() - now.getTime();
|
||||||
|
|
||||||
|
return Math.max(0, Math.ceil(diff / (1000 * 60 * 60 * 24)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
CanActivate,
|
||||||
|
ExecutionContext,
|
||||||
|
ForbiddenException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { LicenseService } from '../license.service';
|
||||||
|
import { REQUIRE_LICENSE_KEY } from '../decorators/require-license.decorator';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class LicenseGuard implements CanActivate {
|
||||||
|
constructor(
|
||||||
|
private reflector: Reflector,
|
||||||
|
private licenseService: LicenseService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
const requireLicense = this.reflector.getAllAndOverride<boolean>(
|
||||||
|
REQUIRE_LICENSE_KEY,
|
||||||
|
[context.getHandler(), context.getClass()],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!requireLicense) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = context.switchToHttp().getRequest();
|
||||||
|
const user = request.user;
|
||||||
|
|
||||||
|
if (!user || !user.userId) {
|
||||||
|
throw new ForbiddenException('请先登录');
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasValidLicense = await this.licenseService.verifyUserLicense(
|
||||||
|
user.userId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!hasValidLicense) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'您的授权已过期或未激活,请联系管理员获取卡密',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,385 @@
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Body,
|
||||||
|
Param,
|
||||||
|
Delete,
|
||||||
|
Query,
|
||||||
|
Req,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
ApiTags,
|
||||||
|
ApiOperation,
|
||||||
|
ApiResponse,
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiBody,
|
||||||
|
ApiParam,
|
||||||
|
ApiQuery,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import { LicenseService } from './license.service';
|
||||||
|
import { GenerateLicenseDto } from './dto/generate-license.dto';
|
||||||
|
import { ActivateLicenseDto } from './dto/activate-license.dto';
|
||||||
|
import { QueryLicenseDto } from './dto/query-license.dto';
|
||||||
|
import { License } from './entities/license.entity';
|
||||||
|
import {
|
||||||
|
ErrorResponseDto,
|
||||||
|
UnauthorizedResponseDto,
|
||||||
|
NotFoundResponseDto,
|
||||||
|
ConflictResponseDto,
|
||||||
|
ValidationErrorResponseDto,
|
||||||
|
} from '../common/dto/error-response.dto';
|
||||||
|
import { AdminGuard } from '../common/guards/admin.guard';
|
||||||
|
|
||||||
|
@ApiTags('卡密管理模块')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller('license')
|
||||||
|
export class LicenseController {
|
||||||
|
constructor(private readonly licenseService: LicenseService) {}
|
||||||
|
|
||||||
|
@Post('generate')
|
||||||
|
@UseGuards(AdminGuard)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: '生成卡密(管理员)',
|
||||||
|
description:
|
||||||
|
'批量生成卡密,可指定卡密类型、有效天数和数量。仅管理员可访问(用户名为 admin 或邮箱包含 admin)',
|
||||||
|
})
|
||||||
|
@ApiBody({ type: GenerateLicenseDto })
|
||||||
|
@ApiResponse({
|
||||||
|
status: 201,
|
||||||
|
description: '卡密生成成功',
|
||||||
|
type: [License],
|
||||||
|
schema: {
|
||||||
|
example: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
code: 'ABCD-1234-EFGH-5678',
|
||||||
|
type: 'monthly',
|
||||||
|
status: 'unused',
|
||||||
|
validDays: 30,
|
||||||
|
remarks: '月卡批量生成',
|
||||||
|
createdAt: '2025-12-13T10:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 400,
|
||||||
|
description: '请求参数错误',
|
||||||
|
type: ErrorResponseDto,
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 401,
|
||||||
|
description: '未授权,需要管理员权限',
|
||||||
|
type: UnauthorizedResponseDto,
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 422,
|
||||||
|
description: '数据验证失败',
|
||||||
|
type: ValidationErrorResponseDto,
|
||||||
|
})
|
||||||
|
async generate(@Body() dto: GenerateLicenseDto): Promise<License[]> {
|
||||||
|
return this.licenseService.generate(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('activate')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: '激活卡密',
|
||||||
|
description:
|
||||||
|
'用户使用卡密激活授权。如果用户已有有效授权,则在原有基础上延期',
|
||||||
|
})
|
||||||
|
@ApiBody({ type: ActivateLicenseDto })
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: '卡密激活成功',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
message: '卡密激活成功,有效期至 2026-01-13 10:00:00',
|
||||||
|
license: {
|
||||||
|
id: 1,
|
||||||
|
code: 'ABCD-1234-EFGH-5678',
|
||||||
|
type: 'monthly',
|
||||||
|
status: 'active',
|
||||||
|
validDays: 30,
|
||||||
|
activatedAt: '2025-12-13T10:00:00.000Z',
|
||||||
|
expiresAt: '2026-01-13T10:00:00.000Z',
|
||||||
|
userId: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 400,
|
||||||
|
description: '卡密无效、已过期或已撤销',
|
||||||
|
type: ErrorResponseDto,
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 401,
|
||||||
|
description: '未授权,需要登录',
|
||||||
|
type: UnauthorizedResponseDto,
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 404,
|
||||||
|
description: '卡密不存在',
|
||||||
|
type: NotFoundResponseDto,
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 409,
|
||||||
|
description: '卡密已被激活',
|
||||||
|
type: ConflictResponseDto,
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 422,
|
||||||
|
description: '卡密格式不正确',
|
||||||
|
type: ValidationErrorResponseDto,
|
||||||
|
})
|
||||||
|
async activate(
|
||||||
|
@Req() req: any,
|
||||||
|
@Body() dto: ActivateLicenseDto,
|
||||||
|
): Promise<{ message: string; license: License }> {
|
||||||
|
const userId = req.user?.userId || req.user?.id;
|
||||||
|
return this.licenseService.activate(userId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('my')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: '查询我的授权信息',
|
||||||
|
description: '获取当前用户的有效授权信息,包括剩余天数等',
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: '查询成功',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
hasValidLicense: true,
|
||||||
|
license: {
|
||||||
|
id: 1,
|
||||||
|
code: 'ABCD-1234-EFGH-5678',
|
||||||
|
type: 'monthly',
|
||||||
|
status: 'active',
|
||||||
|
validDays: 30,
|
||||||
|
activatedAt: '2025-12-13T10:00:00.000Z',
|
||||||
|
expiresAt: '2026-01-13T10:00:00.000Z',
|
||||||
|
remainingDays: 31,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 401,
|
||||||
|
description: '未授权,需要登录',
|
||||||
|
type: UnauthorizedResponseDto,
|
||||||
|
})
|
||||||
|
async getMyLicense(@Req() req: any): Promise<{
|
||||||
|
hasValidLicense: boolean;
|
||||||
|
license?: any;
|
||||||
|
}> {
|
||||||
|
const userId = req.user?.userId || req.user?.id;
|
||||||
|
const license = await this.licenseService.getUserActiveLicense(userId);
|
||||||
|
|
||||||
|
if (!license) {
|
||||||
|
return { hasValidLicense: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
hasValidLicense: true,
|
||||||
|
license: {
|
||||||
|
id: license.id,
|
||||||
|
code: license.code,
|
||||||
|
type: license.type,
|
||||||
|
status: license.status,
|
||||||
|
validDays: license.validDays,
|
||||||
|
activatedAt: license.activatedAt,
|
||||||
|
expiresAt: license.expiresAt,
|
||||||
|
remainingDays: license.getRemainingDays(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('my/history')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: '查询我的卡密历史',
|
||||||
|
description: '获取当前用户激活过的所有卡密记录',
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: '查询成功',
|
||||||
|
type: [License],
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 401,
|
||||||
|
description: '未授权,需要登录',
|
||||||
|
type: UnauthorizedResponseDto,
|
||||||
|
})
|
||||||
|
async getMyLicenseHistory(@Req() req: any): Promise<License[]> {
|
||||||
|
const userId = req.user?.userId || req.user?.id;
|
||||||
|
return this.licenseService.findByUser(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('statistics')
|
||||||
|
@UseGuards(AdminGuard)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: '获取卡密统计信息(管理员)',
|
||||||
|
description: '获取卡密的统计数据,包括总数、已激活、未使用等。仅管理员可访问',
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: '查询成功',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
total: 100,
|
||||||
|
unused: 50,
|
||||||
|
active: 30,
|
||||||
|
expired: 15,
|
||||||
|
revoked: 5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 401,
|
||||||
|
description: '未授权,需要管理员权限',
|
||||||
|
type: UnauthorizedResponseDto,
|
||||||
|
})
|
||||||
|
async getStatistics(): Promise<{
|
||||||
|
total: number;
|
||||||
|
unused: number;
|
||||||
|
active: number;
|
||||||
|
expired: number;
|
||||||
|
revoked: number;
|
||||||
|
}> {
|
||||||
|
return this.licenseService.getStatistics();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@UseGuards(AdminGuard)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: '查询所有卡密(管理员)',
|
||||||
|
description: '获取所有卡密列表,支持按状态和类型筛选。仅管理员可访问',
|
||||||
|
})
|
||||||
|
@ApiQuery({
|
||||||
|
name: 'status',
|
||||||
|
required: false,
|
||||||
|
description: '按状态筛选',
|
||||||
|
enum: ['unused', 'active', 'expired', 'revoked'],
|
||||||
|
})
|
||||||
|
@ApiQuery({
|
||||||
|
name: 'type',
|
||||||
|
required: false,
|
||||||
|
description: '按类型筛选',
|
||||||
|
enum: ['trial', 'monthly', 'yearly', 'lifetime'],
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: '查询成功',
|
||||||
|
type: [License],
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 401,
|
||||||
|
description: '未授权,需要管理员权限',
|
||||||
|
type: UnauthorizedResponseDto,
|
||||||
|
})
|
||||||
|
async findAll(@Query() query: QueryLicenseDto): Promise<License[]> {
|
||||||
|
return this.licenseService.findAll(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@UseGuards(AdminGuard)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: '查询单个卡密(管理员)',
|
||||||
|
description: '根据 ID 查询卡密详细信息。仅管理员可访问',
|
||||||
|
})
|
||||||
|
@ApiParam({
|
||||||
|
name: 'id',
|
||||||
|
description: '卡密ID',
|
||||||
|
type: Number,
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: '查询成功',
|
||||||
|
type: License,
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 401,
|
||||||
|
description: '未授权,需要管理员权限',
|
||||||
|
type: UnauthorizedResponseDto,
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 404,
|
||||||
|
description: '卡密不存在',
|
||||||
|
type: NotFoundResponseDto,
|
||||||
|
})
|
||||||
|
async findOne(@Param('id') id: string): Promise<License> {
|
||||||
|
return this.licenseService.findOne(+id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/revoke')
|
||||||
|
@UseGuards(AdminGuard)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: '撤销卡密(管理员)',
|
||||||
|
description: '将指定卡密标记为已撤销状态,撤销后无法再使用。仅管理员可访问',
|
||||||
|
})
|
||||||
|
@ApiParam({
|
||||||
|
name: 'id',
|
||||||
|
description: '卡密ID',
|
||||||
|
type: Number,
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: '撤销成功',
|
||||||
|
type: License,
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 400,
|
||||||
|
description: '卡密已被撤销',
|
||||||
|
type: ErrorResponseDto,
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 401,
|
||||||
|
description: '未授权,需要管理员权限',
|
||||||
|
type: UnauthorizedResponseDto,
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 404,
|
||||||
|
description: '卡密不存在',
|
||||||
|
type: NotFoundResponseDto,
|
||||||
|
})
|
||||||
|
async revoke(@Param('id') id: string): Promise<License> {
|
||||||
|
return this.licenseService.revoke(+id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@UseGuards(AdminGuard)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: '删除卡密(管理员)',
|
||||||
|
description: '永久删除指定的卡密记录。仅管理员可访问',
|
||||||
|
})
|
||||||
|
@ApiParam({
|
||||||
|
name: 'id',
|
||||||
|
description: '卡密ID',
|
||||||
|
type: Number,
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: '删除成功',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
message: 'License deleted successfully',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 401,
|
||||||
|
description: '未授权,需要管理员权限',
|
||||||
|
type: UnauthorizedResponseDto,
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 404,
|
||||||
|
description: '卡密不存在',
|
||||||
|
type: NotFoundResponseDto,
|
||||||
|
})
|
||||||
|
async remove(@Param('id') id: string): Promise<{ message: string }> {
|
||||||
|
await this.licenseService.remove(+id);
|
||||||
|
return { message: 'License deleted successfully' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { LicenseController } from './license.controller';
|
||||||
|
import { LicenseService } from './license.service';
|
||||||
|
import { License } from './entities/license.entity';
|
||||||
|
import { LicenseGuard } from './guards/license.guard';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([License])],
|
||||||
|
controllers: [LicenseController],
|
||||||
|
providers: [LicenseService, LicenseGuard],
|
||||||
|
exports: [LicenseService, LicenseGuard],
|
||||||
|
})
|
||||||
|
export class LicenseModule {}
|
||||||
|
|
@ -0,0 +1,320 @@
|
||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
BadRequestException,
|
||||||
|
NotFoundException,
|
||||||
|
ConflictException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { License, LicenseStatus, LicenseType } from './entities/license.entity';
|
||||||
|
import { GenerateLicenseDto } from './dto/generate-license.dto';
|
||||||
|
import { ActivateLicenseDto } from './dto/activate-license.dto';
|
||||||
|
import { QueryLicenseDto } from './dto/query-license.dto';
|
||||||
|
import * as crypto from 'crypto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class LicenseService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(License)
|
||||||
|
private readonly licenseRepository: Repository<License>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成卡密码(格式:XXXX-XXXX-XXXX-XXXX)
|
||||||
|
*/
|
||||||
|
private generateLicenseCode(): string {
|
||||||
|
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // 去除易混淆字符
|
||||||
|
let code = '';
|
||||||
|
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
for (let j = 0; j < 4; j++) {
|
||||||
|
const randomIndex = crypto.randomInt(0, chars.length);
|
||||||
|
code += chars[randomIndex];
|
||||||
|
}
|
||||||
|
if (i < 3) {
|
||||||
|
code += '-';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取默认有效天数
|
||||||
|
*/
|
||||||
|
private getDefaultValidDays(type: LicenseType): number {
|
||||||
|
switch (type) {
|
||||||
|
case LicenseType.TRIAL:
|
||||||
|
return 7; // 试用 7 天
|
||||||
|
case LicenseType.MONTHLY:
|
||||||
|
return 30; // 月卡 30 天
|
||||||
|
case LicenseType.YEARLY:
|
||||||
|
return 365; // 年卡 365 天
|
||||||
|
case LicenseType.LIFETIME:
|
||||||
|
return 36500; // 终身 100 年
|
||||||
|
default:
|
||||||
|
return 30;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成卡密
|
||||||
|
*/
|
||||||
|
async generate(dto: GenerateLicenseDto): Promise<License[]> {
|
||||||
|
const count = dto.count || 1;
|
||||||
|
const validDays = dto.validDays || this.getDefaultValidDays(dto.type);
|
||||||
|
const licenses: License[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
let code: string;
|
||||||
|
let attempts = 0;
|
||||||
|
const maxAttempts = 10;
|
||||||
|
|
||||||
|
// 生成唯一卡密码,最多尝试 10 次
|
||||||
|
do {
|
||||||
|
code = this.generateLicenseCode();
|
||||||
|
attempts++;
|
||||||
|
|
||||||
|
if (attempts >= maxAttempts) {
|
||||||
|
throw new BadRequestException('生成卡密失败,请重试');
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await this.licenseRepository.findOne({
|
||||||
|
where: { code },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} while (true);
|
||||||
|
|
||||||
|
const license = this.licenseRepository.create({
|
||||||
|
code,
|
||||||
|
type: dto.type,
|
||||||
|
validDays,
|
||||||
|
status: LicenseStatus.UNUSED,
|
||||||
|
remarks: dto.remarks,
|
||||||
|
});
|
||||||
|
|
||||||
|
licenses.push(await this.licenseRepository.save(license));
|
||||||
|
}
|
||||||
|
|
||||||
|
return licenses;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 激活卡密
|
||||||
|
*/
|
||||||
|
async activate(
|
||||||
|
userId: number,
|
||||||
|
dto: ActivateLicenseDto,
|
||||||
|
): Promise<{ license: License; message: string }> {
|
||||||
|
// 查找卡密
|
||||||
|
const license = await this.licenseRepository.findOne({
|
||||||
|
where: { code: dto.code.toUpperCase() },
|
||||||
|
relations: ['user'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!license) {
|
||||||
|
throw new NotFoundException('卡密不存在');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查卡密状态
|
||||||
|
if (license.status === LicenseStatus.ACTIVE) {
|
||||||
|
throw new ConflictException('该卡密已被激活');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (license.status === LicenseStatus.REVOKED) {
|
||||||
|
throw new BadRequestException('该卡密已被撤销');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (license.status === LicenseStatus.EXPIRED) {
|
||||||
|
throw new BadRequestException('该卡密已过期');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查用户是否已有有效授权
|
||||||
|
const existingLicense = await this.getUserActiveLicense(userId);
|
||||||
|
if (existingLicense) {
|
||||||
|
// 如果已有有效授权,则在现有基础上延期
|
||||||
|
const remainingTime =
|
||||||
|
new Date(existingLicense.expiresAt).getTime() - new Date().getTime();
|
||||||
|
const additionalDays = license.validDays;
|
||||||
|
const additionalTime = additionalDays * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
existingLicense.expiresAt = new Date(
|
||||||
|
Date.now() + remainingTime + additionalTime,
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.licenseRepository.save(existingLicense);
|
||||||
|
|
||||||
|
// 将新卡密标记为已使用
|
||||||
|
license.status = LicenseStatus.EXPIRED;
|
||||||
|
license.userId = userId;
|
||||||
|
license.activatedAt = new Date();
|
||||||
|
await this.licenseRepository.save(license);
|
||||||
|
|
||||||
|
return {
|
||||||
|
license: existingLicense,
|
||||||
|
message: `成功延期 ${additionalDays} 天,当前授权到期时间:${existingLicense.expiresAt.toLocaleString('zh-CN')}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 激活新卡密
|
||||||
|
const now = new Date();
|
||||||
|
const expiresAt = new Date(
|
||||||
|
now.getTime() + license.validDays * 24 * 60 * 60 * 1000,
|
||||||
|
);
|
||||||
|
|
||||||
|
license.status = LicenseStatus.ACTIVE;
|
||||||
|
license.userId = userId;
|
||||||
|
license.activatedAt = now;
|
||||||
|
license.expiresAt = expiresAt;
|
||||||
|
|
||||||
|
await this.licenseRepository.save(license);
|
||||||
|
|
||||||
|
return {
|
||||||
|
license,
|
||||||
|
message: `卡密激活成功,有效期至 ${expiresAt.toLocaleString('zh-CN')}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户当前有效的授权
|
||||||
|
*/
|
||||||
|
async getUserActiveLicense(userId: number): Promise<License | null> {
|
||||||
|
const license = await this.licenseRepository.findOne({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
status: LicenseStatus.ACTIVE,
|
||||||
|
},
|
||||||
|
order: {
|
||||||
|
expiresAt: 'DESC',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!license) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否过期
|
||||||
|
if (license.expiresAt && new Date() > new Date(license.expiresAt)) {
|
||||||
|
license.status = LicenseStatus.EXPIRED;
|
||||||
|
await this.licenseRepository.save(license);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return license;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证用户授权
|
||||||
|
*/
|
||||||
|
async verifyUserLicense(userId: number): Promise<boolean> {
|
||||||
|
const license = await this.getUserActiveLicense(userId);
|
||||||
|
return license !== null && license.isValid();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询所有卡密(管理员)
|
||||||
|
*/
|
||||||
|
async findAll(query?: QueryLicenseDto): Promise<License[]> {
|
||||||
|
const where: any = {};
|
||||||
|
|
||||||
|
if (query?.status) {
|
||||||
|
where.status = query.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query?.type) {
|
||||||
|
where.type = query.type;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.licenseRepository.find({
|
||||||
|
where,
|
||||||
|
relations: ['user'],
|
||||||
|
order: {
|
||||||
|
createdAt: 'DESC',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询用户的所有卡密
|
||||||
|
*/
|
||||||
|
async findByUser(userId: number): Promise<License[]> {
|
||||||
|
return this.licenseRepository.find({
|
||||||
|
where: { userId },
|
||||||
|
order: {
|
||||||
|
createdAt: 'DESC',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据ID查询卡密
|
||||||
|
*/
|
||||||
|
async findOne(id: number): Promise<License> {
|
||||||
|
const license = await this.licenseRepository.findOne({
|
||||||
|
where: { id },
|
||||||
|
relations: ['user'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!license) {
|
||||||
|
throw new NotFoundException('卡密不存在');
|
||||||
|
}
|
||||||
|
|
||||||
|
return license;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 撤销卡密(管理员)
|
||||||
|
*/
|
||||||
|
async revoke(id: number): Promise<License> {
|
||||||
|
const license = await this.findOne(id);
|
||||||
|
|
||||||
|
if (license.status === LicenseStatus.REVOKED) {
|
||||||
|
throw new BadRequestException('该卡密已被撤销');
|
||||||
|
}
|
||||||
|
|
||||||
|
license.status = LicenseStatus.REVOKED;
|
||||||
|
return this.licenseRepository.save(license);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除卡密(管理员)
|
||||||
|
*/
|
||||||
|
async remove(id: number): Promise<void> {
|
||||||
|
const license = await this.findOne(id);
|
||||||
|
await this.licenseRepository.remove(license);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取统计信息(管理员)
|
||||||
|
*/
|
||||||
|
async getStatistics(): Promise<{
|
||||||
|
total: number;
|
||||||
|
unused: number;
|
||||||
|
active: number;
|
||||||
|
expired: number;
|
||||||
|
revoked: number;
|
||||||
|
}> {
|
||||||
|
const [total, unused, active, expired, revoked] = await Promise.all([
|
||||||
|
this.licenseRepository.count(),
|
||||||
|
this.licenseRepository.count({ where: { status: LicenseStatus.UNUSED } }),
|
||||||
|
this.licenseRepository.count({ where: { status: LicenseStatus.ACTIVE } }),
|
||||||
|
this.licenseRepository.count({
|
||||||
|
where: { status: LicenseStatus.EXPIRED },
|
||||||
|
}),
|
||||||
|
this.licenseRepository.count({
|
||||||
|
where: { status: LicenseStatus.REVOKED },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
total,
|
||||||
|
unused,
|
||||||
|
active,
|
||||||
|
expired,
|
||||||
|
revoked,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,60 +6,279 @@ import {
|
||||||
Patch,
|
Patch,
|
||||||
Param,
|
Param,
|
||||||
Delete,
|
Delete,
|
||||||
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { TaskService } from './task.service';
|
import { TaskService } from './task.service';
|
||||||
import { CreateTaskDto } from './dto/create-task.dto';
|
import { CreateTaskDto } from './dto/create-task.dto';
|
||||||
import { UpdateTaskDto } from './dto/update-task.dto';
|
import { UpdateTaskDto } from './dto/update-task.dto';
|
||||||
import { ReorderTasksDto } from './dto/reorder-tasks.dto';
|
import { ReorderTasksDto } from './dto/reorder-tasks.dto';
|
||||||
import { ApiTags, ApiOperation, ApiResponse, ApiParam } from '@nestjs/swagger';
|
import {
|
||||||
|
ApiTags,
|
||||||
|
ApiOperation,
|
||||||
|
ApiResponse,
|
||||||
|
ApiParam,
|
||||||
|
ApiBearerAuth,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
import { Task } from './entities/task.entity';
|
import { Task } from './entities/task.entity';
|
||||||
|
import {
|
||||||
|
ErrorResponseDto,
|
||||||
|
UnauthorizedResponseDto,
|
||||||
|
NotFoundResponseDto,
|
||||||
|
ValidationErrorResponseDto,
|
||||||
|
} from '../common/dto/error-response.dto';
|
||||||
|
import { RequireLicense } from '../license/decorators/require-license.decorator';
|
||||||
|
import { LicenseGuard } from '../license/guards/license.guard';
|
||||||
|
|
||||||
@ApiTags('任务模块')
|
@ApiTags('任务模块')
|
||||||
|
@ApiBearerAuth()
|
||||||
@Controller('task')
|
@Controller('task')
|
||||||
|
@UseGuards(LicenseGuard)
|
||||||
|
@RequireLicense()
|
||||||
export class TaskController {
|
export class TaskController {
|
||||||
constructor(private readonly taskService: TaskService) {}
|
constructor(private readonly taskService: TaskService) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@ApiOperation({ summary: '创建任务' })
|
@ApiOperation({
|
||||||
@ApiResponse({ status: 201, description: '创建成功', type: Task })
|
summary: '创建任务',
|
||||||
|
description: '创建一个新的任务,需要提供任务标题,可选提供描述、截止日期和优先级。需要有效的授权卡密',
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 201,
|
||||||
|
description: '任务创建成功',
|
||||||
|
type: Task,
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
id: 1,
|
||||||
|
title: '完成项目文档',
|
||||||
|
description: '编写项目的 API 文档和使用说明',
|
||||||
|
isCompleted: false,
|
||||||
|
position: 1,
|
||||||
|
dueDate: '2025-12-31T23:59:59.000Z',
|
||||||
|
priority: 'high',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 400,
|
||||||
|
description: '请求参数错误',
|
||||||
|
type: ErrorResponseDto,
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 401,
|
||||||
|
description: '未授权,需要登录',
|
||||||
|
type: UnauthorizedResponseDto,
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 403,
|
||||||
|
description: '授权已过期或未激活,请联系管理员获取卡密',
|
||||||
|
type: ErrorResponseDto,
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 422,
|
||||||
|
description: '数据验证失败',
|
||||||
|
type: ValidationErrorResponseDto,
|
||||||
|
})
|
||||||
create(@Body() createTaskDto: CreateTaskDto) {
|
create(@Body() createTaskDto: CreateTaskDto) {
|
||||||
return this.taskService.create(createTaskDto);
|
return this.taskService.create(createTaskDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOperation({ summary: '获取所有任务' })
|
@ApiOperation({
|
||||||
@ApiResponse({ status: 200, description: '获取成功', type: [Task] })
|
summary: '获取所有任务',
|
||||||
|
description: '获取当前用户的所有任务列表,按照 position 字段排序',
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: '获取成功,返回任务列表',
|
||||||
|
type: [Task],
|
||||||
|
schema: {
|
||||||
|
example: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: '完成项目文档',
|
||||||
|
description: '编写项目的 API 文档和使用说明',
|
||||||
|
isCompleted: false,
|
||||||
|
position: 1,
|
||||||
|
dueDate: '2025-12-31T23:59:59.000Z',
|
||||||
|
priority: 'high',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
title: '修复 Bug',
|
||||||
|
description: '修复登录页面的显示问题',
|
||||||
|
isCompleted: true,
|
||||||
|
position: 2,
|
||||||
|
dueDate: null,
|
||||||
|
priority: 'medium',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 401,
|
||||||
|
description: '未授权,需要登录',
|
||||||
|
type: UnauthorizedResponseDto,
|
||||||
|
})
|
||||||
findAll() {
|
findAll() {
|
||||||
return this.taskService.findAll();
|
return this.taskService.findAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@ApiOperation({ summary: '获取单个任务' })
|
@ApiOperation({
|
||||||
@ApiParam({ name: 'id', description: '任务ID' })
|
summary: '获取单个任务',
|
||||||
@ApiResponse({ status: 200, description: '获取成功', type: Task })
|
description: '根据任务 ID 获取任务的详细信息',
|
||||||
|
})
|
||||||
|
@ApiParam({
|
||||||
|
name: 'id',
|
||||||
|
description: '任务ID',
|
||||||
|
type: Number,
|
||||||
|
example: 1,
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: '获取成功,返回任务详情',
|
||||||
|
type: Task,
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
id: 1,
|
||||||
|
title: '完成项目文档',
|
||||||
|
description: '编写项目的 API 文档和使用说明',
|
||||||
|
isCompleted: false,
|
||||||
|
position: 1,
|
||||||
|
dueDate: '2025-12-31T23:59:59.000Z',
|
||||||
|
priority: 'high',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 401,
|
||||||
|
description: '未授权,需要登录',
|
||||||
|
type: UnauthorizedResponseDto,
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 404,
|
||||||
|
description: '任务不存在',
|
||||||
|
type: NotFoundResponseDto,
|
||||||
|
})
|
||||||
findOne(@Param('id') id: string) {
|
findOne(@Param('id') id: string) {
|
||||||
return this.taskService.findOne(+id);
|
return this.taskService.findOne(+id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch('reorder')
|
@Patch('reorder')
|
||||||
@ApiOperation({ summary: '对任务进行重新排序' })
|
@ApiOperation({
|
||||||
@ApiResponse({ status: 200, description: '排序成功' })
|
summary: '对任务进行重新排序',
|
||||||
|
description:
|
||||||
|
'批量更新任务的排序位置,传入按新顺序排列的任务 ID 数组,系统会自动更新每个任务的 position 值',
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: '排序成功',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
message: 'Tasks reordered successfully',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 400,
|
||||||
|
description: '请求参数错误',
|
||||||
|
type: ErrorResponseDto,
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 401,
|
||||||
|
description: '未授权,需要登录',
|
||||||
|
type: UnauthorizedResponseDto,
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 422,
|
||||||
|
description: '数据验证失败',
|
||||||
|
type: ValidationErrorResponseDto,
|
||||||
|
})
|
||||||
reorder(@Body() reorderTasksDto: ReorderTasksDto) {
|
reorder(@Body() reorderTasksDto: ReorderTasksDto) {
|
||||||
return this.taskService.reorder(reorderTasksDto.taskIds);
|
return this.taskService.reorder(reorderTasksDto.taskIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
@ApiOperation({ summary: '更新任务' })
|
@ApiOperation({
|
||||||
@ApiParam({ name: 'id', description: '任务ID' })
|
summary: '更新任务',
|
||||||
@ApiResponse({ status: 200, description: '更新成功', type: Task })
|
description: '更新指定任务的信息,可以更新标题、描述、完成状态、截止日期和优先级',
|
||||||
|
})
|
||||||
|
@ApiParam({
|
||||||
|
name: 'id',
|
||||||
|
description: '任务ID',
|
||||||
|
type: Number,
|
||||||
|
example: 1,
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: '更新成功,返回更新后的任务信息',
|
||||||
|
type: Task,
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
id: 1,
|
||||||
|
title: '完成项目文档(已更新)',
|
||||||
|
description: '编写项目的 API 文档和使用说明',
|
||||||
|
isCompleted: true,
|
||||||
|
position: 1,
|
||||||
|
dueDate: '2025-12-31T23:59:59.000Z',
|
||||||
|
priority: 'high',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 400,
|
||||||
|
description: '请求参数错误',
|
||||||
|
type: ErrorResponseDto,
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 401,
|
||||||
|
description: '未授权,需要登录',
|
||||||
|
type: UnauthorizedResponseDto,
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 404,
|
||||||
|
description: '任务不存在',
|
||||||
|
type: NotFoundResponseDto,
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 422,
|
||||||
|
description: '数据验证失败',
|
||||||
|
type: ValidationErrorResponseDto,
|
||||||
|
})
|
||||||
update(@Param('id') id: string, @Body() updateTaskDto: UpdateTaskDto) {
|
update(@Param('id') id: string, @Body() updateTaskDto: UpdateTaskDto) {
|
||||||
return this.taskService.update(+id, updateTaskDto);
|
return this.taskService.update(+id, updateTaskDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@ApiOperation({ summary: '删除任务' })
|
@ApiOperation({
|
||||||
@ApiParam({ name: 'id', description: '任务ID' })
|
summary: '删除任务',
|
||||||
@ApiResponse({ status: 200, description: '删除成功' })
|
description: '根据任务 ID 删除指定的任务',
|
||||||
|
})
|
||||||
|
@ApiParam({
|
||||||
|
name: 'id',
|
||||||
|
description: '任务ID',
|
||||||
|
type: Number,
|
||||||
|
example: 1,
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: '删除成功',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
message: 'Task deleted successfully',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 401,
|
||||||
|
description: '未授权,需要登录',
|
||||||
|
type: UnauthorizedResponseDto,
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 404,
|
||||||
|
description: '任务不存在',
|
||||||
|
type: NotFoundResponseDto,
|
||||||
|
})
|
||||||
remove(@Param('id') id: string) {
|
remove(@Param('id') id: string) {
|
||||||
return this.taskService.remove(+id);
|
return this.taskService.remove(+id);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,10 @@ import { TaskService } from './task.service';
|
||||||
import { TaskController } from './task.controller';
|
import { TaskController } from './task.controller';
|
||||||
import { Task } from './entities/task.entity';
|
import { Task } from './entities/task.entity';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { LicenseModule } from '../license/license.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([Task])],
|
imports: [TypeOrmModule.forFeature([Task]), LicenseModule],
|
||||||
controllers: [TaskController],
|
controllers: [TaskController],
|
||||||
providers: [TaskService],
|
providers: [TaskService],
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,12 @@ import {
|
||||||
import { UserService } from '../user/user.service';
|
import { UserService } from '../user/user.service';
|
||||||
import { Public } from '../auth/decorators/public.decorator';
|
import { Public } from '../auth/decorators/public.decorator';
|
||||||
import { ApiTags, ApiOperation, ApiResponse, ApiBody } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiResponse, ApiBody } from '@nestjs/swagger';
|
||||||
import { User } from './entities/user.entity';
|
import {
|
||||||
|
ErrorResponseDto,
|
||||||
|
UnauthorizedResponseDto,
|
||||||
|
ConflictResponseDto,
|
||||||
|
ValidationErrorResponseDto,
|
||||||
|
} from '../common/dto/error-response.dto';
|
||||||
|
|
||||||
@ApiTags('用户模块')
|
@ApiTags('用户模块')
|
||||||
@Controller('user')
|
@Controller('user')
|
||||||
|
|
@ -17,20 +22,66 @@ export class UserController {
|
||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@Post('register')
|
@Post('register')
|
||||||
@ApiOperation({ summary: '用户注册' })
|
@ApiOperation({
|
||||||
|
summary: '用户注册',
|
||||||
|
description: '创建新用户账号,需要提供用户名、密码和邮箱。注册成功后自动返回 JWT Token',
|
||||||
|
})
|
||||||
@ApiBody({
|
@ApiBody({
|
||||||
schema: {
|
schema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
username: { type: 'string', description: '用户名' },
|
username: {
|
||||||
password: { type: 'string', description: '密码' },
|
type: 'string',
|
||||||
email: { type: 'string', description: '邮箱' },
|
description: '用户名(唯一)',
|
||||||
|
example: 'johndoe',
|
||||||
|
},
|
||||||
|
password: {
|
||||||
|
type: 'string',
|
||||||
|
description: '密码(6-20位)',
|
||||||
|
example: 'password123',
|
||||||
|
},
|
||||||
|
email: {
|
||||||
|
type: 'string',
|
||||||
|
description: '邮箱地址',
|
||||||
|
example: 'john@example.com',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
required: ['username', 'password', 'email'],
|
required: ['username', 'password', 'email'],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@ApiResponse({ status: 201, description: '注册成功', type: User })
|
@ApiResponse({
|
||||||
@ApiResponse({ status: 400, description: '参数错误' })
|
status: 201,
|
||||||
|
description: '注册成功,返回用户信息和 JWT Token',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
message: 'User registered successfully',
|
||||||
|
user: {
|
||||||
|
id: 1,
|
||||||
|
username: 'johndoe',
|
||||||
|
email: 'john@example.com',
|
||||||
|
isActive: true,
|
||||||
|
provider: 'local',
|
||||||
|
},
|
||||||
|
access_token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
|
||||||
|
refresh_token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 400,
|
||||||
|
description: '请求参数错误(密码为空等)',
|
||||||
|
type: ErrorResponseDto,
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 409,
|
||||||
|
description: '用户名或邮箱已存在',
|
||||||
|
type: ConflictResponseDto,
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 422,
|
||||||
|
description: '数据验证失败',
|
||||||
|
type: ValidationErrorResponseDto,
|
||||||
|
})
|
||||||
async register(
|
async register(
|
||||||
@Body() body: { username: string; password: string; email: string },
|
@Body() body: { username: string; password: string; email: string },
|
||||||
) {
|
) {
|
||||||
|
|
@ -49,19 +100,56 @@ export class UserController {
|
||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@Post('login')
|
@Post('login')
|
||||||
@ApiOperation({ summary: '用户登录' })
|
@ApiOperation({
|
||||||
|
summary: '用户登录',
|
||||||
|
description: '使用用户名和密码进行身份验证,登录成功后返回 JWT Token',
|
||||||
|
})
|
||||||
@ApiBody({
|
@ApiBody({
|
||||||
schema: {
|
schema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
username: { type: 'string', description: '用户名' },
|
username: {
|
||||||
password: { type: 'string', description: '密码' },
|
type: 'string',
|
||||||
|
description: '用户名',
|
||||||
|
example: 'johndoe',
|
||||||
|
},
|
||||||
|
password: {
|
||||||
|
type: 'string',
|
||||||
|
description: '密码',
|
||||||
|
example: 'password123',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
required: ['username', 'password'],
|
required: ['username', 'password'],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@ApiResponse({ status: 200, description: '登录成功' })
|
@ApiResponse({
|
||||||
@ApiResponse({ status: 401, description: '认证失败' })
|
status: 200,
|
||||||
|
description: '登录成功,返回用户信息和 JWT Token',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
message: 'Login successful',
|
||||||
|
user: {
|
||||||
|
id: 1,
|
||||||
|
username: 'johndoe',
|
||||||
|
email: 'john@example.com',
|
||||||
|
isActive: true,
|
||||||
|
provider: 'local',
|
||||||
|
},
|
||||||
|
access_token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
|
||||||
|
refresh_token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 401,
|
||||||
|
description: '认证失败(用户不存在或密码错误)',
|
||||||
|
type: UnauthorizedResponseDto,
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 400,
|
||||||
|
description: '请求参数错误',
|
||||||
|
type: ErrorResponseDto,
|
||||||
|
})
|
||||||
async login(@Body() body: { username: string; password: string }) {
|
async login(@Body() body: { username: string; password: string }) {
|
||||||
const { username, password } = body;
|
const { username, password } = body;
|
||||||
const user = await this.userService.findUserByUsername(username);
|
const user = await this.userService.findUserByUsername(username);
|
||||||
|
|
@ -85,17 +173,43 @@ export class UserController {
|
||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@Post('refresh-token')
|
@Post('refresh-token')
|
||||||
@ApiOperation({ summary: '刷新-token' })
|
@ApiOperation({
|
||||||
|
summary: '刷新访问令牌',
|
||||||
|
description: '使用 refresh_token 刷新获取新的 access_token,延长用户登录状态',
|
||||||
|
})
|
||||||
@ApiBody({
|
@ApiBody({
|
||||||
schema: {
|
schema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
refresh_token: { type: 'string', description: 'Refresh Token' },
|
refresh_token: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Refresh Token(从登录或注册接口获取)',
|
||||||
|
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
required: ['refresh_token'],
|
required: ['refresh_token'],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@ApiResponse({ status: 200, description: '刷新成功' })
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: '刷新成功,返回新的访问令牌',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
access_token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
|
||||||
|
refresh_token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 401,
|
||||||
|
description: 'Refresh Token 无效或已过期',
|
||||||
|
type: UnauthorizedResponseDto,
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 400,
|
||||||
|
description: '请求参数错误',
|
||||||
|
type: ErrorResponseDto,
|
||||||
|
})
|
||||||
async refreshToken(@Body() body: { refresh_token: string }) {
|
async refreshToken(@Body() body: { refresh_token: string }) {
|
||||||
return this.userService.refreshAccessToken(body.refresh_token);
|
return this.userService.refreshAccessToken(body.refresh_token);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,124 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# 卡密系统测试脚本
|
||||||
|
# 使用方法: bash test-license-system.sh
|
||||||
|
|
||||||
|
BASE_URL="http://localhost:3030"
|
||||||
|
ADMIN_TOKEN=""
|
||||||
|
USER_TOKEN=""
|
||||||
|
LICENSE_CODE=""
|
||||||
|
|
||||||
|
echo "====== 卡密系统测试 ======"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 颜色定义
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
RED='\033[0;31m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# 测试函数
|
||||||
|
test_api() {
|
||||||
|
local name=$1
|
||||||
|
local method=$2
|
||||||
|
local endpoint=$3
|
||||||
|
local data=$4
|
||||||
|
local token=$5
|
||||||
|
|
||||||
|
echo -e "${YELLOW}测试: ${name}${NC}"
|
||||||
|
|
||||||
|
if [ -n "$token" ]; then
|
||||||
|
if [ -n "$data" ]; then
|
||||||
|
response=$(curl -s -X $method "$BASE_URL$endpoint" \
|
||||||
|
-H "Authorization: Bearer $token" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$data")
|
||||||
|
else
|
||||||
|
response=$(curl -s -X $method "$BASE_URL$endpoint" \
|
||||||
|
-H "Authorization: Bearer $token")
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
if [ -n "$data" ]; then
|
||||||
|
response=$(curl -s -X $METHOD "$BASE_URL$endpoint" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$data")
|
||||||
|
else
|
||||||
|
response=$(curl -s -X $method "$BASE_URL$endpoint")
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "$response" | jq '.'
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# 1. 注册测试用户
|
||||||
|
echo -e "${GREEN}步骤 1: 注册测试用户${NC}"
|
||||||
|
REGISTER_RESPONSE=$(curl -s -X POST "$BASE_URL/user/register" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"username": "testuser_'$(date +%s)'",
|
||||||
|
"password": "password123",
|
||||||
|
"email": "test'$(date +%s)'@example.com"
|
||||||
|
}')
|
||||||
|
|
||||||
|
USER_TOKEN=$(echo $REGISTER_RESPONSE | jq -r '.access_token')
|
||||||
|
echo "用户Token: $USER_TOKEN"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 2. 测试未授权时访问任务接口(应该返回403)
|
||||||
|
echo -e "${GREEN}步骤 2: 测试未授权访问任务接口 (预期 403)${NC}"
|
||||||
|
test_api "创建任务 (无授权)" "POST" "/task" '{
|
||||||
|
"title": "测试任务",
|
||||||
|
"description": "这应该失败"
|
||||||
|
}' "$USER_TOKEN"
|
||||||
|
|
||||||
|
# 3. 生成测试卡密(需要管理员权限,这里模拟)
|
||||||
|
echo -e "${GREEN}步骤 3: 生成测试卡密${NC}"
|
||||||
|
echo -e "${RED}注意: 此步骤需要管理员权限,请手动生成卡密或直接从数据库插入${NC}"
|
||||||
|
echo "示例 API 调用:"
|
||||||
|
echo 'POST /license/generate'
|
||||||
|
echo '{
|
||||||
|
"type": "monthly",
|
||||||
|
"validDays": 30,
|
||||||
|
"count": 1
|
||||||
|
}'
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 提示用户输入卡密
|
||||||
|
read -p "请输入生成的卡密码 (格式: XXXX-XXXX-XXXX-XXXX): " LICENSE_CODE
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 4. 激活卡密
|
||||||
|
echo -e "${GREEN}步骤 4: 激活卡密${NC}"
|
||||||
|
test_api "激活卡密" "POST" "/license/activate" "{
|
||||||
|
\"code\": \"$LICENSE_CODE\"
|
||||||
|
}" "$USER_TOKEN"
|
||||||
|
|
||||||
|
# 5. 查询授权信息
|
||||||
|
echo -e "${GREEN}步骤 5: 查询我的授权信息${NC}"
|
||||||
|
test_api "查询授权" "GET" "/license/my" "" "$USER_TOKEN"
|
||||||
|
|
||||||
|
# 6. 测试授权后访问任务接口
|
||||||
|
echo -e "${GREEN}步骤 6: 测试授权后访问任务接口 (应该成功)${NC}"
|
||||||
|
test_api "创建任务 (有授权)" "POST" "/task" '{
|
||||||
|
"title": "测试任务",
|
||||||
|
"description": "这应该成功",
|
||||||
|
"priority": "high"
|
||||||
|
}' "$USER_TOKEN"
|
||||||
|
|
||||||
|
# 7. 获取任务列表
|
||||||
|
echo -e "${GREEN}步骤 7: 获取任务列表${NC}"
|
||||||
|
test_api "获取任务列表" "GET" "/task" "" "$USER_TOKEN"
|
||||||
|
|
||||||
|
echo -e "${GREEN}====== 测试完成 ======${NC}"
|
||||||
|
echo ""
|
||||||
|
echo "测试总结:"
|
||||||
|
echo "1. 用户注册成功"
|
||||||
|
echo "2. 未授权时无法访问任务功能 (403)"
|
||||||
|
echo "3. 卡密激活成功"
|
||||||
|
echo "4. 授权后可以正常使用任务功能"
|
||||||
|
echo ""
|
||||||
|
echo "后续测试建议:"
|
||||||
|
echo "- 测试卡密延期功能(激活第二个卡密)"
|
||||||
|
echo "- 测试卡密过期后的访问"
|
||||||
|
echo "- 测试管理员接口(统计、查询、撤销等)"
|
||||||
Loading…
Reference in New Issue