Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7edd8b110 | ||
|
|
58e8ca0273 | ||
|
|
7e4f49779c | ||
|
|
a2b5ab1b3a | ||
|
|
496998acac | ||
|
|
2f683fa22b | ||
|
|
49e5952e1d | ||
|
|
f6f36c7812 | ||
|
|
a34b834382 | ||
|
|
a7b28022c8 | ||
|
|
10000681cf | ||
|
|
ac8066e0a5 | ||
|
|
1d62dc4e95 | ||
|
|
478ca96d90 | ||
|
|
92765441e3 | ||
|
|
caa4506897 | ||
|
|
97f8306aa9 | ||
|
|
e54dbcb1ae | ||
|
|
302bca25be | ||
|
|
27018599b0 | ||
|
|
5a763ac7b6 | ||
|
|
7f01806bef | ||
|
|
ab50f1151c | ||
|
|
454a474a36 | ||
|
|
1756657cc1 | ||
|
|
ded7187ac1 | ||
|
|
87e9fe703d | ||
|
|
1f3aa1ffb0 | ||
|
|
52f24b629b | ||
|
|
a8c169bc91 | ||
|
|
9e0264035b | ||
|
|
5cea6aa87a | ||
|
|
e3068ec1fa | ||
|
|
bb39c001d7 | ||
|
|
44a54a91a5 | ||
|
|
9cb8f40141 | ||
|
|
d5b90e1550 | ||
|
|
1f06896f6d | ||
|
|
b8430c5b7d | ||
|
|
961be26353 | ||
|
|
8ab50aa927 | ||
|
|
89de40828b | ||
|
|
b3ca90017b | ||
|
|
660dad3d01 | ||
|
|
96e89a54bb | ||
|
|
b018abdc7e | ||
|
|
1e267ea477 | ||
|
|
c78701f82a | ||
|
|
8f0e20f46a | ||
|
|
c9fed52b29 | ||
|
|
7ad1ae310a | ||
|
|
f0dee4a778 | ||
|
|
9c1093ec29 | ||
|
|
21d5cb1a96 | ||
|
|
30a03b53f3 | ||
|
|
f8f3edb62d | ||
|
|
7bed641508 | ||
|
|
b5d0798ee9 | ||
|
|
d3cd08ebbe | ||
|
|
a6b38c0fdc | ||
|
|
6af27cbe6b | ||
|
|
c1fd962387 | ||
|
|
dc82b9bd9b | ||
|
|
13d64d3f98 | ||
|
|
1063fe2bd8 | ||
|
|
70f5b8496b | ||
|
|
d02f49b0d7 | ||
|
|
02d38b8004 | ||
|
|
ace458b771 | ||
|
|
b47e2aa54a | ||
|
|
e9ffbc4827 | ||
|
|
795458f6d8 | ||
|
|
2f23e2c07e | ||
|
|
9352b113dd | ||
|
|
3e0dd9a4cf | ||
|
|
177a543077 | ||
|
|
ec7fe5ea9c | ||
|
|
114c630035 | ||
|
|
d8fd24390d | ||
|
|
188406c517 | ||
|
|
5c086a32f5 | ||
|
|
71aad119b4 | ||
|
|
1ac4f25bc6 | ||
|
|
6fa919062a | ||
|
|
0728dc7f19 | ||
|
|
ba4b34b290 | ||
|
|
f4cd61ec75 | ||
|
|
823dc8462f | ||
|
|
e81d024f91 | ||
|
|
9c2d90c37c | ||
|
|
959f82fac3 | ||
|
|
da00086e5f | ||
|
|
f26cf73521 | ||
|
|
baa220d061 | ||
|
|
4424badc33 | ||
|
|
b8bc313f42 | ||
|
|
9bb0483e16 | ||
|
|
4ebbcb09d2 | ||
|
|
f0762c9d78 | ||
|
|
d20f79782f | ||
|
|
311ed26284 | ||
|
|
8d64b095c8 | ||
|
|
fbed288aef | ||
|
|
05e8da2551 | ||
|
|
59fdeead65 | ||
|
|
cbda2e3531 | ||
|
|
834a44b25f | ||
|
|
2aef1315a4 | ||
|
|
2774f2b2fb | ||
|
|
1f20b615ce | ||
|
|
9f8a29edba | ||
|
|
80ec43ff3d | ||
|
|
f169d474d0 | ||
|
|
e6551aed00 | ||
|
|
08dce35524 | ||
|
|
a114a8eb19 | ||
|
|
a5780b5c87 | ||
|
|
a047f84766 | ||
|
|
e5b4aa2a36 | ||
|
|
34b8aa6059 | ||
|
|
11dfeaed01 | ||
|
|
f7d2adb42f |
No files matched your search
@@ -0,0 +1,41 @@
|
||||
name: Deploy Backend via Git
|
||||
|
||||
# 触发条件
|
||||
on:
|
||||
# 当推送到主分支且修改后端相关文件时自动触发
|
||||
push:
|
||||
branches: [ main ]
|
||||
paths:
|
||||
- 'backend/**'
|
||||
- '.github/workflows/deploy_backend.git.yml'
|
||||
# 支持手动触发
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
# 通过SSH连接服务器进行部署
|
||||
- name: Deploy to server
|
||||
uses: appleboy/ssh-action@v1.2.0
|
||||
with:
|
||||
host: ${{ secrets.HOST_PI }}
|
||||
username: pi
|
||||
password: ${{ secrets.PASS_PI }}
|
||||
port: 22
|
||||
command_timeout: 10m
|
||||
script_stop: true
|
||||
envs: USER
|
||||
script: |
|
||||
# 确保使用正确的用户环境
|
||||
export HOME=/home/pi
|
||||
export USER=pi
|
||||
|
||||
# 加载pi用户的环境配置(包括代理设置)
|
||||
source /home/pi/.bashrc || true
|
||||
source /home/pi/.profile || true
|
||||
|
||||
cd /home/pi/license-manager
|
||||
git pull origin main
|
||||
pm2 restart license_manager
|
||||
@@ -1,12 +1,12 @@
|
||||
name: Deploy Backend
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
paths:
|
||||
- 'backend/**'
|
||||
- 'Dockerfile'
|
||||
- '.github/workflows/deploy_backend.yml'
|
||||
# push:
|
||||
# branches: [ main ]
|
||||
# paths:
|
||||
# - 'backend/**'
|
||||
# - 'Dockerfile'
|
||||
# - '.github/workflows/deploy_backend.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
@@ -45,22 +45,22 @@ jobs:
|
||||
- name: Copy files to server
|
||||
uses: appleboy/scp-action@v0.1.7
|
||||
with:
|
||||
host: ${{ secrets.HOST_1 }}
|
||||
username: root
|
||||
password: ${{ secrets.PASS_1 }}
|
||||
host: ${{ secrets.HOST_PI }}
|
||||
username: pi
|
||||
password: ${{ secrets.PASS_PI }}
|
||||
port: 22
|
||||
source: "backend-image.tar"
|
||||
target: "/opt/license-manager/"
|
||||
target: "/home/pi/license-manager/"
|
||||
|
||||
- name: Deploy to server
|
||||
uses: appleboy/ssh-action@v1.0.3
|
||||
with:
|
||||
host: ${{ secrets.HOST_1 }}
|
||||
username: root
|
||||
password: ${{ secrets.PASS_1 }}
|
||||
host: ${{ secrets.HOST_PI }}
|
||||
username: pi
|
||||
password: ${{ secrets.PASS_PI }}
|
||||
port: 22
|
||||
script: |
|
||||
cd /opt/license-manager
|
||||
cd /home/pi/license-manager
|
||||
|
||||
# Stop and remove existing container
|
||||
docker stop ${{ env.CONTAINER_NAME }} || true
|
||||
@@ -78,7 +78,7 @@ jobs:
|
||||
--name ${{ env.CONTAINER_NAME }} \
|
||||
--restart unless-stopped \
|
||||
--network host \
|
||||
-v /opt/license-manager/logs:/app/logs \
|
||||
-v /home/pi/license-manager/logs:/app/logs \
|
||||
${{ env.IMAGE_NAME }}:latest
|
||||
|
||||
# Clean up
|
||||
@@ -99,7 +99,7 @@ jobs:
|
||||
- name: Health check
|
||||
run: |
|
||||
sleep 10
|
||||
response=$(curl -s -o /dev/null -w "%{http_code}" http://${{ secrets.HOST_1 }}:18888/health || echo "000")
|
||||
response=$(curl -s -o /dev/null -w "%{http_code}" http://${{ secrets.HOST_PI }}:18888/health || echo "000")
|
||||
if [ "$response" = "200" ]; then
|
||||
echo "✅ Health check passed! Service is responding."
|
||||
else
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
name: Deploy Frontend via Git
|
||||
|
||||
# 触发条件
|
||||
on:
|
||||
# 当推送到主分支且修改前端相关文件时自动触发
|
||||
push:
|
||||
branches: [ main ]
|
||||
paths:
|
||||
- 'frontend/**'
|
||||
- '.github/workflows/deploy_frontend.git.yml'
|
||||
# 支持手动触发
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
# 检出代码
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# 设置Node.js环境
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
# 缓存node_modules和构建输出
|
||||
- name: Cache dependencies and build
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
frontend/node_modules
|
||||
frontend/production
|
||||
key: ${{ runner.os }}-frontend-${{ hashFiles('frontend/package-lock.json', 'frontend/src/**/*', 'frontend/vite.config.ts') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-frontend-${{ hashFiles('frontend/package-lock.json') }}
|
||||
${{ runner.os }}-frontend-
|
||||
|
||||
# 安装依赖
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
cd frontend
|
||||
npm ci
|
||||
|
||||
# 并行执行类型检查和构建
|
||||
- name: Type check and build
|
||||
run: |
|
||||
cd frontend
|
||||
# 并行执行类型检查和构建以节省时间
|
||||
npm run type-check &
|
||||
npm run build:prod &
|
||||
wait
|
||||
# 检查构建结果
|
||||
echo "Build completed, checking production directory:"
|
||||
ls -la production/
|
||||
|
||||
# 预处理部署目录权限
|
||||
- name: Prepare deployment directory
|
||||
uses: appleboy/ssh-action@v1.0.3
|
||||
with:
|
||||
host: ${{ secrets.HOST_PI }}
|
||||
username: pi
|
||||
password: ${{ secrets.PASS_PI }}
|
||||
port: 22
|
||||
script: |
|
||||
echo "Preparing deployment directory..."
|
||||
|
||||
# 停止nginx避免文件占用
|
||||
sudo nginx -s quit 2>/dev/null || true
|
||||
|
||||
# 清理旧文件并重建目录结构
|
||||
sudo rm -rf /home/pi/lm-www/frontend
|
||||
sudo mkdir -p /home/pi/lm-www/frontend
|
||||
|
||||
# 设置正确的目录权限供文件传输
|
||||
sudo chown -R pi:pi /home/pi/lm-www/frontend
|
||||
sudo chmod -R 755 /home/pi/lm-www/frontend
|
||||
|
||||
# 将构建产物传输到服务器
|
||||
- name: Copy build files to server
|
||||
uses: appleboy/scp-action@v0.1.7
|
||||
with:
|
||||
host: ${{ secrets.HOST_PI }}
|
||||
username: pi
|
||||
password: ${{ secrets.PASS_PI }}
|
||||
port: 22
|
||||
source: "frontend/production/*"
|
||||
target: "/home/pi/lm-www/"
|
||||
overwrite: true
|
||||
|
||||
# 完成部署配置
|
||||
- name: Finalize deployment
|
||||
uses: appleboy/ssh-action@v1.0.3
|
||||
with:
|
||||
host: ${{ secrets.HOST_PI }}
|
||||
username: pi
|
||||
password: ${{ secrets.PASS_PI }}
|
||||
port: 22
|
||||
script: |
|
||||
echo "Finalizing deployment..."
|
||||
|
||||
# 设置Web服务器权限
|
||||
sudo chown -R www-data:www-data /home/pi/lm-www/frontend/
|
||||
sudo chmod -R 644 /home/pi/lm-www/frontend/
|
||||
sudo find /home/pi/lm-www/frontend/ -type d -exec chmod 755 {} \;
|
||||
|
||||
# 启动nginx并验证
|
||||
echo "Starting nginx..."
|
||||
if sudo nginx -t; then
|
||||
sudo systemctl start nginx
|
||||
sudo systemctl reload nginx
|
||||
|
||||
if sudo systemctl is-active --quiet nginx; then
|
||||
echo "✅ Frontend deployment completed successfully!"
|
||||
else
|
||||
echo "❌ Nginx failed to start"
|
||||
sudo systemctl status nginx --no-pager -l
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "❌ Nginx configuration test failed!"
|
||||
exit 1
|
||||
fi
|
||||
@@ -1,13 +1,13 @@
|
||||
name: Deploy Frontend
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
paths:
|
||||
- 'frontend/**'
|
||||
- 'Dockerfile.frontend'
|
||||
- 'nginx.conf'
|
||||
- '.github/workflows/deploy_frontend.yml'
|
||||
# push:
|
||||
# branches: [ main ]
|
||||
# paths:
|
||||
# - 'frontend/**'
|
||||
# - 'Dockerfile.frontend'
|
||||
# - 'nginx.conf'
|
||||
# - '.github/workflows/deploy_frontend.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
@@ -33,22 +33,22 @@ jobs:
|
||||
- name: Copy files to server
|
||||
uses: appleboy/scp-action@v0.1.7
|
||||
with:
|
||||
host: ${{ secrets.HOST_1 }}
|
||||
username: root
|
||||
password: ${{ secrets.PASS_1 }}
|
||||
host: ${{ secrets.HOST_PI }}
|
||||
username: pi
|
||||
password: ${{ secrets.PASS_PI }}
|
||||
port: 22
|
||||
source: "frontend-image.tar"
|
||||
target: "/opt/license-manager/"
|
||||
target: "/home/pi/license-manager/"
|
||||
|
||||
- name: Deploy to server
|
||||
uses: appleboy/ssh-action@v1.0.3
|
||||
with:
|
||||
host: ${{ secrets.HOST_1 }}
|
||||
username: root
|
||||
password: ${{ secrets.PASS_1 }}
|
||||
host: ${{ secrets.HOST_PI }}
|
||||
username: pi
|
||||
password: ${{ secrets.PASS_PI }}
|
||||
port: 22
|
||||
script: |
|
||||
cd /opt/license-manager
|
||||
cd /home/pi/license-manager
|
||||
|
||||
# Stop and remove existing container
|
||||
docker stop ${{ env.CONTAINER_NAME }} || true
|
||||
@@ -87,7 +87,7 @@ jobs:
|
||||
- name: Health check
|
||||
run: |
|
||||
sleep 10
|
||||
response=$(curl -s -o /dev/null -w "%{http_code}" http://${{ secrets.HOST_1 }}:18080/ || echo "000")
|
||||
response=$(curl -s -o /dev/null -w "%{http_code}" http://${{ secrets.HOST_PI }}:18080/ || echo "000")
|
||||
if [ "$response" = "200" ]; then
|
||||
echo "✅ Frontend health check passed! Service is responding."
|
||||
else
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
name: Publish MySQL to Tencent CCR
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Login to CCR
|
||||
run: |
|
||||
echo "${{ secrets.TENCENT_CCR_PASSWORD }}" | docker login ccr.ccs.tencentyun.com --username="${{ secrets.TENCENT_CCR_USERNAME }}" --password-stdin
|
||||
|
||||
- name: Pull and push AMD64
|
||||
run: |
|
||||
docker pull mysql:8.0 --platform linux/amd64
|
||||
docker tag mysql:8.0 ccr.ccs.tencentyun.com/cedar-v/mysql:8.0-amd64
|
||||
docker push ccr.ccs.tencentyun.com/cedar-v/mysql:8.0-amd64
|
||||
|
||||
- name: Pull and push ARM64
|
||||
run: |
|
||||
docker pull mysql:8.0 --platform linux/arm64
|
||||
docker tag mysql:8.0 ccr.ccs.tencentyun.com/cedar-v/mysql:8.0-arm64
|
||||
docker push ccr.ccs.tencentyun.com/cedar-v/mysql:8.0-arm64
|
||||
|
||||
- name: Create and push manifest
|
||||
run: |
|
||||
docker manifest create ccr.ccs.tencentyun.com/cedar-v/mysql:8.0 \
|
||||
ccr.ccs.tencentyun.com/cedar-v/mysql:8.0-amd64 \
|
||||
ccr.ccs.tencentyun.com/cedar-v/mysql:8.0-arm64
|
||||
docker manifest push ccr.ccs.tencentyun.com/cedar-v/mysql:8.0
|
||||
@@ -48,8 +48,8 @@ jobs:
|
||||
context: .
|
||||
file: ./Dockerfile.frontend
|
||||
build-args: |
|
||||
NGINX_CONF=nginx-ci.conf
|
||||
platforms: linux/amd64
|
||||
NGINX_CONF=nginx.conf
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
@@ -11,5 +11,7 @@ RUN apk --no-cache add ca-certificates tzdata curl
|
||||
WORKDIR /app/backend/cmd
|
||||
COPY --from=builder /app/backend/license-manager ./license-manager
|
||||
COPY backend/configs/ ../configs/
|
||||
# Use production config by default
|
||||
RUN ln -sf ../configs/config.prod.yaml config.yaml
|
||||
EXPOSE 18888
|
||||
CMD ["./license-manager"]
|
||||
@@ -8,6 +8,15 @@
|
||||
|
||||
软件授权管理平台是一个独立的软件授权管理系统,为软件系统提供授权码生成、分发、验证和管理服务。支持在线和离线两种授权模式,基于硬件绑定的授权机制确保安全性。
|
||||
|
||||
## Demo环境
|
||||
|
||||
访问地址:[lm.cedar-v.com:18080](http://lm.cedar-v.com:18080)
|
||||
**登录凭证:**
|
||||
- 用户名:`admin`
|
||||
- 密码:`admin@123`
|
||||
|
||||
> 💡 这是一个当前功能的演示环境,您可以体验已开发的核心特性。我们正在持续开发中,更多功能将陆续上线。
|
||||
|
||||
## 系统界面
|
||||
|
||||
### 登录界面
|
||||
@@ -16,6 +25,11 @@
|
||||
### 管理仪表盘
|
||||

|
||||
|
||||
### 客户管理
|
||||

|
||||
|
||||
客户管理模块提供完整的客户信息管理功能,包括客户信息的增删改查、状态管理、授权关联等核心功能。
|
||||
|
||||
## 核心功能
|
||||
|
||||
- 🔧 **客户管理**:完整的客户信息管理和状态控制
|
||||
@@ -73,9 +87,17 @@ go build -o license-manager
|
||||
|
||||
./license-manager
|
||||
```
|
||||
|
||||
##
|
||||
## Docker 部署
|
||||
|
||||
### 使用预构建镜像(推荐)
|
||||
|
||||
如需快速部署且无需修改源码,可直接使用预构建的 GitHub Container Registry 镜像:
|
||||
|
||||
详细部署步骤请参考:[GitHub 镜像部署指南](docs/github-images-deploy.md)
|
||||
|
||||
### 从源码构建
|
||||
|
||||
推荐使用 Docker Compose,已提供开发与生产编排文件:
|
||||
|
||||
```bash
|
||||
@@ -121,4 +143,21 @@ https://www.gnu.org/licenses/gpl-3.0.html
|
||||
|
||||
## 支持
|
||||
|
||||
如有任何问题或需要支持,请提交issue。
|
||||
### 用户反馈与交流
|
||||
|
||||
我们非常重视用户的反馈和建议!欢迎加入我们的QQ群进行讨论和交流:
|
||||
|
||||
<img src="docs/images/qrcode_1755081220153.jpg" alt="QQ群二维码" width="200" />
|
||||
|
||||
扫描二维码或者搜索QQ群号加入,与其他用户和开发者一起:
|
||||
- 分享使用经验和最佳实践
|
||||
- 提出问题和获得帮助
|
||||
- 参与新功能讨论
|
||||
- 反馈问题和改进建议
|
||||
|
||||
### 问题报告
|
||||
|
||||
如有技术问题或Bug报告,请通过以下方式联系我们:
|
||||
- 提交GitHub Issue(推荐)
|
||||
- 在QQ群中反馈
|
||||
- 发送邮件给项目维护者
|
||||
+40
-1
@@ -8,6 +8,15 @@
|
||||
|
||||
License Manager is an independent software licensing platform that provides license code generation, distribution, validation, and management services for IoT platforms and other software systems. It offers both online and offline licensing modes with hardware-based binding for enhanced security.
|
||||
|
||||
## Demo Environment
|
||||
|
||||
Access URL: [lm.cedar-v.com:18080](http://lm.cedar-v.com:18080)
|
||||
**Login Credentials:**
|
||||
- Username: `admin`
|
||||
- Password: `admin@123`
|
||||
|
||||
> 💡 This is a demo environment showcasing our current features. You can experience the implemented core functionalities while we continue active development with more features coming soon.
|
||||
|
||||
## System Interface
|
||||
|
||||
### Login Page
|
||||
@@ -16,6 +25,11 @@ License Manager is an independent software licensing platform that provides lice
|
||||
### Management Dashboard
|
||||

|
||||
|
||||
### Customer Management
|
||||

|
||||
|
||||
The customer management module provides comprehensive customer information management features, including CRUD operations for customer data, status management, and authorization associations.
|
||||
|
||||
## Key Features
|
||||
|
||||
- 🔧 **Customer Management**: Complete customer information management with status control
|
||||
@@ -87,6 +101,14 @@ go build -o license-manager
|
||||
|
||||
## Docker Deployment
|
||||
|
||||
### Using Pre-built Images (Recommended)
|
||||
|
||||
For quick deployment without downloading source code, you can use pre-built GitHub Container Registry images:
|
||||
|
||||
For detailed deployment steps, please refer to: [GitHub Images Deployment Guide](docs/github-images-deploy.md)
|
||||
|
||||
### Build from Source
|
||||
|
||||
We provide ready-to-use Docker Compose files for development and production:
|
||||
|
||||
```bash
|
||||
@@ -137,4 +159,21 @@ We welcome contributions! Please feel free to submit a Pull Request.
|
||||
|
||||
## Support
|
||||
|
||||
If you have any questions or need support, please open an issue.
|
||||
### User Feedback & Community
|
||||
|
||||
We highly value user feedback and suggestions! Join our QQ group for discussions and communication:
|
||||
|
||||
<img src="docs/images/qrcode_1755081220153.jpg" alt="QQ Group QR Code" width="200" />
|
||||
|
||||
Scan the QR code or search for the QQ group number to join and connect with other users and developers to:
|
||||
- Share usage experiences and best practices
|
||||
- Ask questions and get help
|
||||
- Participate in new feature discussions
|
||||
- Provide feedback and improvement suggestions
|
||||
|
||||
### Issue Reporting
|
||||
|
||||
For technical issues or bug reports, please contact us through:
|
||||
- Submit GitHub Issues (recommended)
|
||||
- Provide feedback in the QQ group
|
||||
- Send email to project maintainers
|
||||
@@ -0,0 +1,121 @@
|
||||
# 软件授权客户端示例程序
|
||||
|
||||
演示如何与许可证管理系统集成,包括硬件指纹采集、授权激活和心跳检测功能。
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 编译
|
||||
|
||||
```bash
|
||||
cd backend/cmd/client-demo
|
||||
go build -o client-demo main.go
|
||||
```
|
||||
|
||||
### 运行
|
||||
|
||||
```bash
|
||||
# 使用命令行参数
|
||||
./client-demo -server http://localhost:18888
|
||||
|
||||
# 或使用配置文件 client_config.json
|
||||
./client-demo
|
||||
```
|
||||
|
||||
## 授权码配置
|
||||
|
||||
将授权码保存到 `license_code/AUTH_CODE` 文件中,程序会自动读取。如果文件不存在或授权码为空,程序在需要激活时会退出并提示错误。
|
||||
|
||||
## 命令行参数
|
||||
|
||||
| 参数 | 说明 | 默认值 |
|
||||
|------|------|--------|
|
||||
| `-server` | 服务器地址 | http://localhost:18888 |
|
||||
| `-version` | 软件版本 | 1.0.0 |
|
||||
| `-interval` | 心跳间隔(秒) | 300 |
|
||||
| `-activate-only` | 仅激活,不启动心跳 | false |
|
||||
|
||||
## 环境变量
|
||||
|
||||
| 变量名 | 说明 |
|
||||
|--------|------|
|
||||
| `LICENSE_ENCRYPTION_KEY` | 许可证文件加密密钥(默认使用服务器默认密钥,长度需 16/24/32 字节) |
|
||||
|
||||
## 工作流程
|
||||
|
||||
1. **启动时**
|
||||
- 采集硬件指纹(MAC 地址、CPU、主机ID)
|
||||
- 检查 `license_code/LICENSE` 文件是否存在
|
||||
- 如果文件存在,解析并验证有效期(start_date 和 end_date)
|
||||
|
||||
2. **需要激活时**(文件不存在或过期)
|
||||
- 从 `license_code/AUTH_CODE` 文件读取授权码
|
||||
- 调用激活接口获取许可证
|
||||
- 保存许可证到 `license_code/LICENSE`
|
||||
- 再次验证许可证
|
||||
|
||||
3. **验证通过后**
|
||||
- 启动心跳服务(每 300 秒或按服务器指定间隔)
|
||||
- 自动接收并更新许可证文件
|
||||
|
||||
## 硬件指纹
|
||||
|
||||
程序自动采集以下信息生成硬件指纹:
|
||||
|
||||
- MAC 地址(第一个非虚拟网络接口)
|
||||
- CPU 型号名称
|
||||
- 主机 ID(系统唯一标识符)
|
||||
|
||||
格式(随意,只要能唯一标识设备即可,哈希值也行):`MAC:xx:xx:xx:xx:xx:xx,CPU:xxx,HOSTID:xxx`
|
||||
|
||||
## 许可证文件
|
||||
|
||||
许可证文件保存在 `license_code/LICENSE`,使用 AES-GCM 加密,包含:
|
||||
- 许可证密钥和硬件指纹
|
||||
- 有效期(开始/结束日期)
|
||||
- 功能配置和使用限制
|
||||
|
||||
**重要**:许可证绑定硬件指纹,更换硬件需重新激活。
|
||||
|
||||
## 示例输出
|
||||
|
||||
**首次运行**(自动激活)
|
||||
```
|
||||
=== 软件授权客户端示例程序 ===
|
||||
硬件指纹: MAC:00:11:22:33:44:55,CPU:Intel(R) Core(TM) i7-8700,HOSTID:abc123
|
||||
许可证文件检查失败: 许可证文件不存在
|
||||
尝试重新激活...
|
||||
✓ 激活成功!许可证文件已保存到 license_code/LICENSE
|
||||
✓ 激活后验证通过
|
||||
[心跳] 启动心跳服务...
|
||||
✓ 心跳成功 [14:30:00] - 状态: active
|
||||
```
|
||||
|
||||
**后续运行**(使用本地许可证)
|
||||
```
|
||||
=== 软件授权客户端示例程序 ===
|
||||
硬件指纹: MAC:00:11:22:33:44:55,CPU:Intel(R) Core(TM) i7-8700,HOSTID:abc123
|
||||
✓ 许可证验证通过
|
||||
[心跳] 启动心跳服务...
|
||||
✓ 心跳成功 [14:30:00] - 状态: active
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
- **验证优先级**:优先检查本地许可证文件,文件不存在或过期时才重新激活
|
||||
- **错误处理**:许可证验证失败会退出程序;心跳失败仅记录日志
|
||||
- **授权码**:激活时需要授权码,请将授权码保存到 `license_code/AUTH_CODE` 文件
|
||||
|
||||
## 故障排查
|
||||
|
||||
**激活失败**
|
||||
- 检查服务器地址和授权码是否正确
|
||||
- 确认 `license_code/AUTH_CODE` 文件存在且包含有效的授权码
|
||||
- 查看服务器日志了解详细错误
|
||||
|
||||
**心跳失败**
|
||||
- 检查网络连接
|
||||
- 确认许可证密钥和硬件指纹是否正确
|
||||
|
||||
**硬件指纹变更**
|
||||
- 更换硬件需重新激活
|
||||
- 联系管理员释放旧的激活记录
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"server_url": "http://localhost:18888",
|
||||
"authorization_code": "LIC-B597-EFr0tbfAooQF-uUAA",
|
||||
"software_version": "1.0.0",
|
||||
"heartbeat_interval": 300,
|
||||
"license_key": "LIC-DEVICE-68DF8AFEF41CA26F248D7D55",
|
||||
"config_updated_at": "2025-11-01T17:54:30+08:00",
|
||||
"license_file": "eyJhbGdvcml0aG0iOiJSU0EtUFNTLVNIQTI1NiIsImRhdGEiOiJ7XCJhY3RpdmF0ZWRfYXRcIjpcIjIwMjUtMTEtMDFUMTc6NTQ6MjguMTI4MTc5NCswODowMFwiLFwiYXV0aG9yaXphdGlvbl9jb2RlXCI6XCJMSUMtQjU5Ny1FRnIwdGJmQW9vUUYtdVVBQVwiLFwiYXV0aG9yaXphdGlvbl9jb2RlX2lkXCI6XCI4ZjZlNzViMS0xMTViLTRhNWUtYjk1ZC0yNjczOTcwZDNmYmFcIixcImNvbmZpZ191cGRhdGVkX2F0XCI6bnVsbCxcImN1c3RvbV9wYXJhbWV0ZXJzXCI6e30sXCJkZXBsb3ltZW50X3R5cGVcIjpcInN0YW5kYWxvbmVcIixcImVuZF9kYXRlXCI6XCIzMDI1LTAzLTAzVDIzOjU5OjU5KzA4OjAwXCIsXCJmZWF0dXJlX2NvbmZpZ1wiOnt9LFwiZ2VuZXJhdGVkX2F0XCI6XCIyMDI1LTExLTAxVDE3OjU0OjMwKzA4OjAwXCIsXCJoYXJkd2FyZV9maW5nZXJwcmludFwiOlwiTUFDOjVlOmEzOjIyOjIyOmVmOmRiLENQVTpBTUQgUnl6ZW4gNSA5NjAwWCA2LUNvcmUgUHJvY2Vzc29yLEhPU1RJRDphMzJlOGViNC00ODg1LTRkMmItYTA3My0yYmM2NDVkZWU0ZTRcIixcImxpY2Vuc2Vfa2V5XCI6XCJMSUMtREVWSUNFLTY4REY4QUZFRjQxQ0EyNkYyNDhEN0Q1NVwiLFwibWF4X2FjdGl2YXRpb25zXCI6NSxcInN0YXJ0X2RhdGVcIjpcIjIwMjUtMTEtMDFUMDA6MDA6MDArMDg6MDBcIixcInN0YXR1c1wiOlwiYWN0aXZlXCIsXCJ1c2FnZV9saW1pdHNcIjp7fX0iLCJzaWduYXR1cmUiOiJoVEc2dUZ3UWtmRnNEam9sSVlFK3NOZ3JVM3hncDJPeHJOd3dSa2FLSmpGSjlLbXR2a1J1N2ZZWUVDUFozc0RLT094RlFYdmM4MktWd0dBcTREZ3A5TXBmajVLVlAwTnhjL0RBOCtKVm9oN1BXTERGZFhmMDd2bUdHZFF4YzBSRy91d2hWN3MyOUwzWW9WdlZGdnJxbUhuNGdBQ3B3eUVnL2Y3SC9HT0FCTTNGdWErekRkelR6QUNUbkdEdG1GbFAwUFZ1SURCRVlGRTRvZmRZZDBObDZMY1E5Z0wrb3pORHh0ZVNpdzNuZE0xelJiS3d4SUxOYVFKWnRaellVT1djem05RHg2bUNYMGlFeEtHTlA0NG9Pem5xNjRvclU5M2tGYzRLOW4wK2svL1JpblZMSWE1VE0vVUN2SmZPNzRkUmxGL2Nta3RRUFp6Tk5jbUJ4bWZoRXc9PSJ9"
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
LIC-B597-EFr0tbfAooQF-uUAA
|
||||
@@ -0,0 +1 @@
|
||||
eyJhbGdvcml0aG0iOiJSU0EtUFNTLVNIQTI1NiIsImRhdGEiOiJ7XCJhY3RpdmF0ZWRfYXRcIjpcIjIwMjUtMTEtMDFUMTc6NTQ6MjguMTI4MTc5NCswODowMFwiLFwiYXV0aG9yaXphdGlvbl9jb2RlXCI6XCJMSUMtQjU5Ny1FRnIwdGJmQW9vUUYtdVVBQVwiLFwiYXV0aG9yaXphdGlvbl9jb2RlX2lkXCI6XCI4ZjZlNzViMS0xMTViLTRhNWUtYjk1ZC0yNjczOTcwZDNmYmFcIixcImNvbmZpZ191cGRhdGVkX2F0XCI6bnVsbCxcImN1c3RvbV9wYXJhbWV0ZXJzXCI6e30sXCJkZXBsb3ltZW50X3R5cGVcIjpcInN0YW5kYWxvbmVcIixcImVuZF9kYXRlXCI6XCIzMDI1LTAzLTAzVDIzOjU5OjU5KzA4OjAwXCIsXCJmZWF0dXJlX2NvbmZpZ1wiOnt9LFwiZ2VuZXJhdGVkX2F0XCI6XCIyMDI1LTExLTAxVDE3OjU0OjMwKzA4OjAwXCIsXCJoYXJkd2FyZV9maW5nZXJwcmludFwiOlwiTUFDOjVlOmEzOjIyOjIyOmVmOmRiLENQVTpBTUQgUnl6ZW4gNSA5NjAwWCA2LUNvcmUgUHJvY2Vzc29yLEhPU1RJRDphMzJlOGViNC00ODg1LTRkMmItYTA3My0yYmM2NDVkZWU0ZTRcIixcImxpY2Vuc2Vfa2V5XCI6XCJMSUMtREVWSUNFLTY4REY4QUZFRjQxQ0EyNkYyNDhEN0Q1NVwiLFwibWF4X2FjdGl2YXRpb25zXCI6NSxcInN0YXJ0X2RhdGVcIjpcIjIwMjUtMTEtMDFUMDA6MDA6MDArMDg6MDBcIixcInN0YXR1c1wiOlwiYWN0aXZlXCIsXCJ1c2FnZV9saW1pdHNcIjp7fX0iLCJzaWduYXR1cmUiOiJoVEc2dUZ3UWtmRnNEam9sSVlFK3NOZ3JVM3hncDJPeHJOd3dSa2FLSmpGSjlLbXR2a1J1N2ZZWUVDUFozc0RLT094RlFYdmM4MktWd0dBcTREZ3A5TXBmajVLVlAwTnhjL0RBOCtKVm9oN1BXTERGZFhmMDd2bUdHZFF4YzBSRy91d2hWN3MyOUwzWW9WdlZGdnJxbUhuNGdBQ3B3eUVnL2Y3SC9HT0FCTTNGdWErekRkelR6QUNUbkdEdG1GbFAwUFZ1SURCRVlGRTRvZmRZZDBObDZMY1E5Z0wrb3pORHh0ZVNpdzNuZE0xelJiS3d4SUxOYVFKWnRaellVT1djem05RHg2bUNYMGlFeEtHTlA0NG9Pem5xNjRvclU5M2tGYzRLOW4wK2svL1JpblZMSWE1VE0vVUN2SmZPNzRkUmxGL2Nta3RRUFp6Tk5jbUJ4bWZoRXc9PSJ9
|
||||
@@ -0,0 +1,682 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/shirou/gopsutil/v3/cpu"
|
||||
"github.com/shirou/gopsutil/v3/host"
|
||||
"github.com/shirou/gopsutil/v3/mem"
|
||||
)
|
||||
|
||||
// ClientConfig 客户端配置
|
||||
type ClientConfig struct {
|
||||
ServerURL string `json:"server_url"` // 服务器地址
|
||||
AuthorizationCode string `json:"authorization_code"` // 授权码
|
||||
SoftwareVersion string `json:"software_version"` // 软件版本
|
||||
HeartbeatInterval int `json:"heartbeat_interval"` // 心跳间隔(秒),默认300
|
||||
LicenseKey string `json:"license_key"` // 许可证密钥(激活后保存)
|
||||
ConfigUpdatedAt string `json:"config_updated_at"` // 配置更新时间
|
||||
LicenseFile string `json:"license_file"` // 许可证文件内容
|
||||
}
|
||||
|
||||
// ActivateRequest 激活请求
|
||||
type ActivateRequest struct {
|
||||
AuthorizationCode string `json:"authorization_code"`
|
||||
HardwareFingerprint string `json:"hardware_fingerprint"`
|
||||
DeviceInfo map[string]interface{} `json:"device_info,omitempty"`
|
||||
SoftwareVersion *string `json:"software_version,omitempty"`
|
||||
}
|
||||
|
||||
// ActivateResponse 激活响应
|
||||
type ActivateResponse struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data struct {
|
||||
LicenseKey string `json:"license_key"`
|
||||
LicenseFile string `json:"license_file"`
|
||||
HeartbeatInterval int `json:"heartbeat_interval"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// HeartbeatRequest 心跳请求
|
||||
type HeartbeatRequest struct {
|
||||
LicenseKey string `json:"license_key"`
|
||||
HardwareFingerprint string `json:"hardware_fingerprint"`
|
||||
ConfigUpdatedAt *string `json:"config_updated_at,omitempty"`
|
||||
UsageData map[string]interface{} `json:"usage_data,omitempty"`
|
||||
SoftwareVersion *string `json:"software_version,omitempty"`
|
||||
}
|
||||
|
||||
// HeartbeatResponse 心跳响应
|
||||
type HeartbeatResponse struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data struct {
|
||||
Status string `json:"status"`
|
||||
ConfigUpdated bool `json:"config_updated"`
|
||||
LicenseFile *string `json:"license_file,omitempty"`
|
||||
HeartbeatInterval int `json:"heartbeat_interval"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// ErrorResponse 错误响应
|
||||
type ErrorResponse struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
}
|
||||
|
||||
// LicenseFileData 许可证文件数据结构
|
||||
type LicenseFileData struct {
|
||||
LicenseKey string `json:"license_key"`
|
||||
AuthorizationCodeID string `json:"authorization_code_id"`
|
||||
HardwareFingerprint string `json:"hardware_fingerprint"`
|
||||
Status string `json:"status"`
|
||||
ActivatedAt *string `json:"activated_at"`
|
||||
ConfigUpdatedAt *string `json:"config_updated_at"`
|
||||
GeneratedAt string `json:"generated_at"`
|
||||
AuthorizationCode *string `json:"authorization_code"`
|
||||
StartDate *string `json:"start_date"`
|
||||
EndDate *string `json:"end_date"`
|
||||
DeploymentType *string `json:"deployment_type"`
|
||||
MaxActivations *int `json:"max_activations"`
|
||||
FeatureConfig map[string]interface{} `json:"feature_config,omitempty"`
|
||||
UsageLimits map[string]interface{} `json:"usage_limits,omitempty"`
|
||||
CustomParameters map[string]interface{} `json:"custom_parameters,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
configFile = "client_config.json"
|
||||
licenseDir = "license_code"
|
||||
licenseFileName = "LICENSE"
|
||||
)
|
||||
|
||||
var (
|
||||
config ClientConfig
|
||||
)
|
||||
|
||||
func main() {
|
||||
var (
|
||||
serverURL = flag.String("server", "http://localhost:18888", "服务器地址 (例如: http://localhost:18888)")
|
||||
softwareVersion = flag.String("version", "1.0.0", "软件版本")
|
||||
interval = flag.Int("interval", 300, "心跳间隔(秒)")
|
||||
activateOnly = flag.Bool("activate-only", false, "仅执行激活,不启动心跳")
|
||||
)
|
||||
flag.Parse()
|
||||
|
||||
// 加载配置
|
||||
if err := loadConfig(); err != nil {
|
||||
log.Printf("加载配置失败,使用命令行参数: %v", err)
|
||||
}
|
||||
|
||||
// 命令行参数优先
|
||||
if *serverURL != "" {
|
||||
config.ServerURL = *serverURL
|
||||
}
|
||||
// 从 license_code/AUTH_CODE 文件读取授权码
|
||||
authCodeFromFile, err := readAuthCodeFromFile()
|
||||
if err == nil && authCodeFromFile != "" {
|
||||
config.AuthorizationCode = strings.TrimSpace(authCodeFromFile)
|
||||
}
|
||||
if *softwareVersion != "" {
|
||||
config.SoftwareVersion = *softwareVersion
|
||||
}
|
||||
if *interval > 0 {
|
||||
config.HeartbeatInterval = *interval
|
||||
}
|
||||
if config.HeartbeatInterval <= 0 {
|
||||
config.HeartbeatInterval = 300 // 默认5分钟
|
||||
}
|
||||
|
||||
// 验证必要参数
|
||||
if config.ServerURL == "" {
|
||||
log.Fatal("请提供服务器地址 (-server 或配置文件)")
|
||||
}
|
||||
|
||||
log.Println("=== 软件授权客户端示例程序 ===")
|
||||
log.Printf("服务器地址: %s", config.ServerURL)
|
||||
log.Printf("软件版本: %s", config.SoftwareVersion)
|
||||
|
||||
// 初始化RSA公钥(从当前目录的 rsa_public_key.pem 加载)
|
||||
if err := initRSAPublicKey(); err != nil {
|
||||
log.Fatalf("初始化RSA公钥失败: %v", err)
|
||||
}
|
||||
log.Println("✓ RSA公钥加载成功")
|
||||
|
||||
// 采集硬件指纹
|
||||
hardwareFingerprint, deviceInfo := collectHardwareInfo()
|
||||
log.Printf("硬件指纹: %s", hardwareFingerprint)
|
||||
log.Printf("设备信息: CPU=%s, Memory=%s, OS=%s",
|
||||
deviceInfo["cpu"], deviceInfo["memory"], deviceInfo["os"])
|
||||
|
||||
// ===== 新的验证流程 =====
|
||||
// 1. 检查 license_code/LICENSE 文件是否存在
|
||||
licenseFilePath := filepath.Join(licenseDir, licenseFileName)
|
||||
licenseData, err := checkAndParseLicenseFile(licenseFilePath)
|
||||
if err != nil {
|
||||
log.Printf("许可证文件检查失败: %v", err)
|
||||
log.Println("尝试重新激活...")
|
||||
licenseData = nil // 标记需要重新激活
|
||||
}
|
||||
|
||||
// 2. 如果许可证文件存在,验证时间
|
||||
needActivation := false
|
||||
if licenseData != nil {
|
||||
isValid, err := validateLicenseTime(licenseData)
|
||||
if err != nil {
|
||||
log.Printf("许可证时间验证失败: %v", err)
|
||||
needActivation = true
|
||||
} else if !isValid {
|
||||
log.Println("许可证已过期或未生效,需要重新激活")
|
||||
needActivation = true
|
||||
} else {
|
||||
log.Println("✓ 许可证验证通过")
|
||||
// 从许可证文件加载许可证密钥
|
||||
config.LicenseKey = licenseData.LicenseKey
|
||||
config.LicenseFile = "" // 可以从文件读取
|
||||
}
|
||||
} else {
|
||||
needActivation = true
|
||||
}
|
||||
|
||||
// 3. 如果需要激活,使用AUTH_CODE重新获取许可证
|
||||
if needActivation {
|
||||
if config.AuthorizationCode == "" {
|
||||
log.Fatal("需要授权码进行激活,请将授权码保存到 license_code/AUTH_CODE 文件")
|
||||
}
|
||||
|
||||
log.Println("\n[激活] 开始激活授权...")
|
||||
if err := activateLicense(hardwareFingerprint, deviceInfo); err != nil {
|
||||
log.Fatalf("激活失败: %v", err)
|
||||
}
|
||||
log.Println("✓ 激活成功!")
|
||||
log.Printf("许可证密钥: %s", config.LicenseKey)
|
||||
log.Printf("心跳间隔: %d 秒", config.HeartbeatInterval)
|
||||
|
||||
// 保存许可证文件到 license_code/LICENSE
|
||||
if err := saveLicenseFile(licenseFilePath); err != nil {
|
||||
log.Printf("保存许可证文件失败: %v", err)
|
||||
} else {
|
||||
log.Printf("许可证文件已保存到 %s", licenseFilePath)
|
||||
}
|
||||
|
||||
// 保存配置
|
||||
if err := saveConfig(); err != nil {
|
||||
log.Printf("保存配置失败: %v", err)
|
||||
} else {
|
||||
log.Println("配置已保存到 " + configFile)
|
||||
}
|
||||
|
||||
// 再次验证许可证文件
|
||||
licenseData, err = checkAndParseLicenseFile(licenseFilePath)
|
||||
if err != nil {
|
||||
log.Fatalf("激活后验证许可证文件失败: %v", err)
|
||||
}
|
||||
isValid, err := validateLicenseTime(licenseData)
|
||||
if err != nil || !isValid {
|
||||
log.Fatalf("激活后验证许可证时间失败: %v", err)
|
||||
}
|
||||
log.Println("✓ 激活后验证通过")
|
||||
}
|
||||
|
||||
if *activateOnly {
|
||||
log.Println("激活完成,退出程序")
|
||||
return
|
||||
}
|
||||
|
||||
// 4. 验证通过,启动心跳服务
|
||||
log.Println("\n[心跳] 启动心跳服务...")
|
||||
startHeartbeat(hardwareFingerprint, deviceInfo)
|
||||
}
|
||||
|
||||
// collectHardwareInfo 采集硬件信息
|
||||
func collectHardwareInfo() (string, map[string]interface{}) {
|
||||
var parts []string
|
||||
|
||||
// 1. 获取 MAC 地址
|
||||
if macs, err := getMACAddresses(); err == nil && len(macs) > 0 {
|
||||
parts = append(parts, fmt.Sprintf("MAC:%s", macs[0]))
|
||||
}
|
||||
|
||||
// 2. 获取 CPU 信息
|
||||
if cpuInfo, err := cpu.Info(); err == nil && len(cpuInfo) > 0 {
|
||||
cpuModel := strings.TrimSpace(cpuInfo[0].ModelName)
|
||||
if cpuModel != "" {
|
||||
parts = append(parts, fmt.Sprintf("CPU:%s", cpuModel))
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 获取主板序列号(Windows)或机器ID(Linux)
|
||||
if hostInfo, err := host.Info(); err == nil {
|
||||
if hostInfo.HostID != "" {
|
||||
parts = append(parts, fmt.Sprintf("HOSTID:%s", hostInfo.HostID))
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 如果没有足够信息,使用操作系统信息
|
||||
if len(parts) == 0 {
|
||||
parts = append(parts, fmt.Sprintf("OS:%s-%s", runtime.GOOS, runtime.GOARCH))
|
||||
}
|
||||
|
||||
hardwareFingerprint := strings.Join(parts, ",")
|
||||
|
||||
// 收集详细的设备信息
|
||||
deviceInfo := make(map[string]interface{})
|
||||
|
||||
// CPU 信息
|
||||
if cpuInfo, err := cpu.Info(); err == nil && len(cpuInfo) > 0 {
|
||||
deviceInfo["cpu"] = cpuInfo[0].ModelName
|
||||
deviceInfo["cpu_cores"] = runtime.NumCPU()
|
||||
}
|
||||
|
||||
// 内存信息
|
||||
if memInfo, err := mem.VirtualMemory(); err == nil {
|
||||
deviceInfo["memory"] = fmt.Sprintf("%d GB", memInfo.Total/(1024*1024*1024))
|
||||
}
|
||||
|
||||
// 操作系统信息
|
||||
if hostInfo, err := host.Info(); err == nil {
|
||||
deviceInfo["os"] = fmt.Sprintf("%s %s", hostInfo.Platform, hostInfo.PlatformVersion)
|
||||
}
|
||||
|
||||
deviceInfo["arch"] = runtime.GOARCH
|
||||
|
||||
return hardwareFingerprint, deviceInfo
|
||||
}
|
||||
|
||||
// getMACAddresses 获取 MAC 地址
|
||||
func getMACAddresses() ([]string, error) {
|
||||
var macs []string
|
||||
interfaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, iface := range interfaces {
|
||||
// 跳过虚拟接口和回环接口
|
||||
if iface.Flags&net.FlagLoopback != 0 ||
|
||||
iface.Flags&net.FlagUp == 0 ||
|
||||
strings.HasPrefix(iface.Name, "veth") ||
|
||||
strings.HasPrefix(iface.Name, "docker") {
|
||||
continue
|
||||
}
|
||||
|
||||
mac := iface.HardwareAddr.String()
|
||||
if mac != "" {
|
||||
macs = append(macs, mac)
|
||||
}
|
||||
}
|
||||
|
||||
return macs, nil
|
||||
}
|
||||
|
||||
// activateLicense 激活授权
|
||||
func activateLicense(hardwareFingerprint string, deviceInfo map[string]interface{}) error {
|
||||
req := ActivateRequest{
|
||||
AuthorizationCode: config.AuthorizationCode,
|
||||
HardwareFingerprint: hardwareFingerprint,
|
||||
DeviceInfo: deviceInfo,
|
||||
}
|
||||
|
||||
if config.SoftwareVersion != "" {
|
||||
req.SoftwareVersion = &config.SoftwareVersion
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("序列化请求失败: %w", err)
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/api/v1/activate", config.ServerURL)
|
||||
log.Printf("POST %s", url)
|
||||
log.Printf("请求体: %s", string(jsonData))
|
||||
|
||||
resp, err := http.Post(url, "application/json", bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return fmt.Errorf("请求失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取响应失败: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
var errResp ErrorResponse
|
||||
if err := json.Unmarshal(body, &errResp); err == nil {
|
||||
return fmt.Errorf("激活失败 [%s]: %s", errResp.Code, errResp.Message)
|
||||
}
|
||||
return fmt.Errorf("激活失败 [HTTP %d]: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var activateResp ActivateResponse
|
||||
if err := json.Unmarshal(body, &activateResp); err != nil {
|
||||
return fmt.Errorf("解析响应失败: %w", err)
|
||||
}
|
||||
|
||||
if activateResp.Code != "000000" {
|
||||
return fmt.Errorf("激活失败 [%s]: %s", activateResp.Code, activateResp.Message)
|
||||
}
|
||||
|
||||
// 更新配置
|
||||
config.LicenseKey = activateResp.Data.LicenseKey
|
||||
config.LicenseFile = activateResp.Data.LicenseFile
|
||||
if activateResp.Data.HeartbeatInterval > 0 {
|
||||
config.HeartbeatInterval = activateResp.Data.HeartbeatInterval
|
||||
}
|
||||
config.ConfigUpdatedAt = time.Now().Format(time.RFC3339)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// startHeartbeat 启动心跳服务
|
||||
func startHeartbeat(hardwareFingerprint string, deviceInfo map[string]interface{}) {
|
||||
ticker := time.NewTicker(time.Duration(config.HeartbeatInterval) * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
// 立即发送一次心跳
|
||||
log.Println("发送初始心跳...")
|
||||
sendHeartbeat(hardwareFingerprint, deviceInfo)
|
||||
|
||||
// 定期发送心跳
|
||||
for range ticker.C {
|
||||
sendHeartbeat(hardwareFingerprint, deviceInfo)
|
||||
}
|
||||
}
|
||||
|
||||
// sendHeartbeat 发送心跳
|
||||
func sendHeartbeat(hardwareFingerprint string, deviceInfo map[string]interface{}) {
|
||||
req := HeartbeatRequest{
|
||||
LicenseKey: config.LicenseKey,
|
||||
HardwareFingerprint: hardwareFingerprint,
|
||||
}
|
||||
|
||||
if config.ConfigUpdatedAt != "" {
|
||||
req.ConfigUpdatedAt = &config.ConfigUpdatedAt
|
||||
}
|
||||
|
||||
// 模拟使用数据
|
||||
req.UsageData = map[string]interface{}{
|
||||
"active_users": 50,
|
||||
"api_calls_today": 5000,
|
||||
"cpu_usage": 25.5,
|
||||
"memory_usage": 45.2,
|
||||
}
|
||||
|
||||
if config.SoftwareVersion != "" {
|
||||
req.SoftwareVersion = &config.SoftwareVersion
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
log.Printf("✗ 心跳失败: 序列化请求失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 打印完整的请求信息
|
||||
reqJSON, _ := json.MarshalIndent(req, "", " ")
|
||||
log.Printf("心跳请求信息:\n%s", string(reqJSON))
|
||||
|
||||
url := fmt.Sprintf("%s/api/v1/heartbeat", config.ServerURL)
|
||||
resp, err := http.Post(url, "application/json", bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
log.Printf("✗ 心跳失败: 请求失败: %v", err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
log.Printf("✗ 心跳失败: 读取响应失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
var errResp ErrorResponse
|
||||
if err := json.Unmarshal(body, &errResp); err == nil {
|
||||
log.Printf("✗ 心跳失败 [%s]: %s", errResp.Code, errResp.Message)
|
||||
} else {
|
||||
log.Printf("✗ 心跳失败 [HTTP %d]: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
var heartbeatResp HeartbeatResponse
|
||||
if err := json.Unmarshal(body, &heartbeatResp); err != nil {
|
||||
log.Printf("✗ 心跳失败: 解析响应失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if heartbeatResp.Code != "000000" {
|
||||
log.Printf("✗ 心跳失败 [%s]: %s", heartbeatResp.Code, heartbeatResp.Message)
|
||||
return
|
||||
}
|
||||
|
||||
// 打印完整的响应信息
|
||||
respJSON, _ := json.MarshalIndent(heartbeatResp, "", " ")
|
||||
log.Printf("心跳返回信息:\n%s", string(respJSON))
|
||||
|
||||
// 更新配置更新时间
|
||||
if heartbeatResp.Data.ConfigUpdated && heartbeatResp.Data.LicenseFile != nil {
|
||||
log.Println("✓ 收到配置更新,正在更新许可证文件...")
|
||||
config.LicenseFile = *heartbeatResp.Data.LicenseFile
|
||||
config.ConfigUpdatedAt = time.Now().Format(time.RFC3339)
|
||||
|
||||
// 保存许可证文件到 license_code/LICENSE
|
||||
licenseFilePath := filepath.Join(licenseDir, licenseFileName)
|
||||
if err := saveLicenseFile(licenseFilePath); err != nil {
|
||||
log.Printf("保存许可证文件失败: %v", err)
|
||||
} else {
|
||||
log.Printf("许可证文件已更新到 %s", licenseFilePath)
|
||||
}
|
||||
|
||||
// 保存配置
|
||||
if err := saveConfig(); err != nil {
|
||||
log.Printf("保存配置失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("✓ 心跳成功 [%s] - 状态: %s, 配置更新: %v, 间隔: %d秒",
|
||||
time.Now().Format("15:04:05"),
|
||||
heartbeatResp.Data.Status,
|
||||
heartbeatResp.Data.ConfigUpdated,
|
||||
heartbeatResp.Data.HeartbeatInterval)
|
||||
|
||||
// 如果服务器返回了不同的心跳间隔,更新配置
|
||||
if heartbeatResp.Data.HeartbeatInterval > 0 &&
|
||||
heartbeatResp.Data.HeartbeatInterval != config.HeartbeatInterval {
|
||||
log.Printf("更新心跳间隔: %d 秒 -> %d 秒",
|
||||
config.HeartbeatInterval, heartbeatResp.Data.HeartbeatInterval)
|
||||
config.HeartbeatInterval = heartbeatResp.Data.HeartbeatInterval
|
||||
}
|
||||
}
|
||||
|
||||
// loadConfig 加载配置
|
||||
func loadConfig() error {
|
||||
data, err := os.ReadFile(configFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(data, &config)
|
||||
}
|
||||
|
||||
// saveConfig 保存配置
|
||||
func saveConfig() error {
|
||||
data, err := json.MarshalIndent(config, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(configFile, data, 0644)
|
||||
}
|
||||
|
||||
// checkAndParseLicenseFile 检查并解析许可证文件
|
||||
func checkAndParseLicenseFile(filePath string) (*LicenseFileData, error) {
|
||||
// 检查文件是否存在
|
||||
if _, err := os.Stat(filePath); os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("许可证文件不存在: %s", filePath)
|
||||
}
|
||||
|
||||
// 读取文件内容
|
||||
data, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取许可证文件失败: %w", err)
|
||||
}
|
||||
|
||||
// 尝试解密许可证文件
|
||||
decryptedData, err := decryptLicenseFile(data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解密许可证文件失败: %w", err)
|
||||
}
|
||||
|
||||
// 解析JSON
|
||||
var licenseData LicenseFileData
|
||||
if err := json.Unmarshal(decryptedData, &licenseData); err != nil {
|
||||
return nil, fmt.Errorf("解析许可证文件JSON失败: %w", err)
|
||||
}
|
||||
|
||||
// 打印解析后的许可证信息
|
||||
log.Println("========== 解析后的许可证信息 ==========")
|
||||
licenseJSON, _ := json.MarshalIndent(licenseData, "", " ")
|
||||
log.Printf("%s\n", string(licenseJSON))
|
||||
log.Println("========================================")
|
||||
|
||||
return &licenseData, nil
|
||||
}
|
||||
|
||||
// validateLicenseTime 验证许可证时间
|
||||
func validateLicenseTime(licenseData *LicenseFileData) (bool, error) {
|
||||
now := time.Now()
|
||||
|
||||
// 检查开始日期
|
||||
if licenseData.StartDate != nil && *licenseData.StartDate != "" {
|
||||
startDate, err := time.Parse(time.RFC3339, *licenseData.StartDate)
|
||||
if err != nil {
|
||||
// 尝试其他时间格式
|
||||
startDate, err = time.Parse("2006-01-02T15:04:05Z07:00", *licenseData.StartDate)
|
||||
if err != nil {
|
||||
startDate, err = time.Parse("2006-01-02 15:04:05", *licenseData.StartDate)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("解析开始日期失败: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if now.Before(startDate) {
|
||||
return false, fmt.Errorf("许可证尚未生效,生效日期: %s", startDate.Format("2006-01-02 15:04:05"))
|
||||
}
|
||||
}
|
||||
|
||||
// 检查结束日期
|
||||
if licenseData.EndDate != nil && *licenseData.EndDate != "" {
|
||||
endDate, err := time.Parse(time.RFC3339, *licenseData.EndDate)
|
||||
if err != nil {
|
||||
// 尝试其他时间格式
|
||||
endDate, err = time.Parse("2006-01-02T15:04:05Z07:00", *licenseData.EndDate)
|
||||
if err != nil {
|
||||
endDate, err = time.Parse("2006-01-02 15:04:05", *licenseData.EndDate)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("解析结束日期失败: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if now.After(endDate) {
|
||||
return false, fmt.Errorf("许可证已过期,过期日期: %s", endDate.Format("2006-01-02 15:04:05"))
|
||||
}
|
||||
startDateStr := ""
|
||||
if licenseData.StartDate != nil {
|
||||
startDateStr = *licenseData.StartDate
|
||||
}
|
||||
endDateStr := ""
|
||||
if licenseData.EndDate != nil {
|
||||
endDateStr = *licenseData.EndDate
|
||||
}
|
||||
log.Printf("许可证有效期: %s - %s", startDateStr, endDateStr)
|
||||
} else {
|
||||
return false, fmt.Errorf("许可证文件缺少结束日期")
|
||||
}
|
||||
|
||||
// 检查状态
|
||||
if licenseData.Status == "revoked" {
|
||||
return false, fmt.Errorf("许可证已被撤销")
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// LicenseFileWithSignature RSA签名后的许可证文件结构
|
||||
type LicenseFileWithSignature struct {
|
||||
Data string `json:"data"` // 原始数据(JSON字符串)
|
||||
Signature string `json:"signature"` // 数字签名
|
||||
Algorithm string `json:"algorithm"` // 签名算法
|
||||
}
|
||||
|
||||
// decryptLicenseFile 验证RSA签名的许可证文件
|
||||
func decryptLicenseFile(encryptedData []byte) ([]byte, error) {
|
||||
// Base64解码
|
||||
decoded, err := base64.StdEncoding.DecodeString(string(encryptedData))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("base64解码失败: %w", err)
|
||||
}
|
||||
|
||||
// 解析签名后的许可证文件
|
||||
var signedLicense LicenseFileWithSignature
|
||||
if err := json.Unmarshal(decoded, &signedLicense); err != nil {
|
||||
return nil, fmt.Errorf("解析许可证文件失败: %w", err)
|
||||
}
|
||||
|
||||
// 检查签名算法
|
||||
if signedLicense.Algorithm != "RSA-PSS-SHA256" {
|
||||
return nil, fmt.Errorf("不支持的签名算法: %s", signedLicense.Algorithm)
|
||||
}
|
||||
|
||||
// 验证签名
|
||||
dataBytes := []byte(signedLicense.Data)
|
||||
if err := verifyRSASignature(dataBytes, signedLicense.Signature); err != nil {
|
||||
return nil, fmt.Errorf("签名验证失败: %w", err)
|
||||
}
|
||||
|
||||
// 返回原始数据
|
||||
return dataBytes, nil
|
||||
}
|
||||
|
||||
// saveLicenseFile 保存许可证文件到 license_code/LICENSE
|
||||
func saveLicenseFile(filePath string) error {
|
||||
// 确保目录存在
|
||||
dir := filepath.Dir(filePath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return fmt.Errorf("创建目录失败: %w", err)
|
||||
}
|
||||
|
||||
// 如果config.LicenseFile不为空,直接保存
|
||||
if config.LicenseFile != "" {
|
||||
// 写入文件(已经是base64编码的加密数据)
|
||||
return os.WriteFile(filePath, []byte(config.LicenseFile), 0644)
|
||||
}
|
||||
|
||||
// 如果LicenseFile为空,但LicenseKey存在,可能需要重新生成
|
||||
// 这里我们假设LicenseFile已经在激活时设置
|
||||
return fmt.Errorf("许可证文件内容为空")
|
||||
}
|
||||
|
||||
// readAuthCodeFromFile 从 license_code/AUTH_CODE 文件读取授权码
|
||||
func readAuthCodeFromFile() (string, error) {
|
||||
authCodePath := filepath.Join(licenseDir, "AUTH_CODE")
|
||||
data, err := os.ReadFile(authCodePath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("读取授权码文件失败: %w", err)
|
||||
}
|
||||
return strings.TrimSpace(string(data)), nil
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// 默认RSA公钥(应该从配置文件或环境变量加载)
|
||||
// 在生产环境中,这个公钥应该编译到程序中,或者从配置文件读取
|
||||
var defaultRSAPublicKeyPEM = `
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAyour-public-key-here
|
||||
-----END PUBLIC KEY-----
|
||||
`
|
||||
|
||||
var rsaPublicKey *rsa.PublicKey
|
||||
|
||||
// initRSAPublicKey 初始化RSA公钥(从文件或环境变量加载)
|
||||
func initRSAPublicKey() error {
|
||||
if rsaPublicKey != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var pubKeyPEM string
|
||||
var err error
|
||||
|
||||
// 1. 首先尝试从当前目录下的 rsa_public_key.pem 文件加载
|
||||
defaultPubKeyPath := "rsa_public_key.pem"
|
||||
if _, err := os.Stat(defaultPubKeyPath); err == nil {
|
||||
data, err := os.ReadFile(defaultPubKeyPath)
|
||||
if err == nil {
|
||||
pubKeyPEM = string(data)
|
||||
// 获取绝对路径用于日志
|
||||
if absPath, err := filepath.Abs(defaultPubKeyPath); err == nil {
|
||||
log.Printf("从当前目录加载RSA公钥: %s", absPath)
|
||||
} else {
|
||||
log.Printf("从当前目录加载RSA公钥: %s", defaultPubKeyPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 如果当前目录没有,尝试从环境变量获取公钥内容
|
||||
if pubKeyPEM == "" {
|
||||
pubKeyPEM = os.Getenv("LICENSE_RSA_PUBLIC_KEY")
|
||||
if pubKeyPEM != "" {
|
||||
log.Println("从环境变量 LICENSE_RSA_PUBLIC_KEY 加载RSA公钥")
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 如果还是没有,尝试从环境变量指定的文件路径加载
|
||||
if pubKeyPEM == "" {
|
||||
pubKeyPath := os.Getenv("LICENSE_RSA_PUBLIC_KEY_PATH")
|
||||
if pubKeyPath != "" {
|
||||
data, err := os.ReadFile(pubKeyPath)
|
||||
if err == nil {
|
||||
pubKeyPEM = string(data)
|
||||
log.Printf("从环境变量指定的路径加载RSA公钥: %s", pubKeyPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果还是没有找到公钥,返回错误
|
||||
if pubKeyPEM == "" {
|
||||
// 获取当前工作目录用于错误提示
|
||||
cwd, _ := os.Getwd()
|
||||
return fmt.Errorf("RSA公钥未配置,请将公钥文件 rsa_public_key.pem 放在当前目录 (%s) 下,或设置 LICENSE_RSA_PUBLIC_KEY 环境变量或 LICENSE_RSA_PUBLIC_KEY_PATH 环境变量", cwd)
|
||||
}
|
||||
|
||||
key, err := loadRSAPublicKeyFromString(pubKeyPEM)
|
||||
if err != nil {
|
||||
return fmt.Errorf("加载RSA公钥失败: %w", err)
|
||||
}
|
||||
|
||||
rsaPublicKey = key
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadRSAPublicKeyFromString 从PEM格式字符串加载RSA公钥
|
||||
func loadRSAPublicKeyFromString(pubKeyStr string) (*rsa.PublicKey, error) {
|
||||
block, _ := pem.Decode([]byte(pubKeyStr))
|
||||
if block == nil {
|
||||
return nil, errors.New("无法解析PEM格式的公钥")
|
||||
}
|
||||
|
||||
pub, err := x509.ParsePKIXPublicKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解析公钥失败: %w", err)
|
||||
}
|
||||
|
||||
rsaPub, ok := pub.(*rsa.PublicKey)
|
||||
if !ok {
|
||||
return nil, errors.New("不是有效的RSA公钥")
|
||||
}
|
||||
|
||||
return rsaPub, nil
|
||||
}
|
||||
|
||||
// verifyRSASignature 使用RSA公钥验证数字签名
|
||||
func verifyRSASignature(data []byte, signatureBase64 string) error {
|
||||
if rsaPublicKey == nil {
|
||||
if err := initRSAPublicKey(); err != nil {
|
||||
return fmt.Errorf("RSA公钥未初始化: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 解码base64签名
|
||||
signature, err := base64.StdEncoding.DecodeString(signatureBase64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("解码签名失败: %w", err)
|
||||
}
|
||||
|
||||
// 计算数据的SHA256哈希
|
||||
hashed := sha256.Sum256(data)
|
||||
|
||||
// 验证签名
|
||||
err = rsa.VerifyPSS(rsaPublicKey, crypto.SHA256, hashed[:], signature, &rsa.PSSOptions{
|
||||
SaltLength: rsa.PSSSaltLengthEqualsHash,
|
||||
Hash: crypto.SHA256,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("签名验证失败: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA5hGz3hws+L2MUW7CaRQI
|
||||
t7Vxk0KAdPBUuNdkJcs2kpgkDmiT/pHf+9WXfRVdKD/Eu/t7wckQfzziddXTbeCx
|
||||
gN/5bKSeLTPXT0VvvSkfB5FUUQABHFWO4fiBlfP/NdFy++qKgCUF1wGg+jSsrhAw
|
||||
6oMDI7MHVRfRZTe3rTM3T0qi3Ed6qaoIM9INQ87FcqPVMyQ/KXbe4Dz5yVkLY5iY
|
||||
j8ZJP7iU2vKYvWIG9TNv0FK/dXQk7syMGBSKmIncCe+UaflAajbDGXq52gIeTyhO
|
||||
BumOLXXBdlwgCVtejBTqDmlQpiJYADKiZLQfx0z1hYbbOPBPB3djXCJJmQk69sso
|
||||
ZQIDAQAB
|
||||
-----END PUBLIC KEY-----
|
||||
@@ -0,0 +1,30 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"license-manager/pkg/utils"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var (
|
||||
privateKeyPath = flag.String("private", "../../configs/rsa_private_key.pem", "私钥文件路径")
|
||||
publicKeyPath = flag.String("public", "../../configs/rsa_public_key.pem", "公钥文件路径")
|
||||
keySize = flag.Int("size", 2048, "密钥大小(2048或4096)")
|
||||
)
|
||||
flag.Parse()
|
||||
|
||||
fmt.Printf("正在生成 RSA 密钥对...\n")
|
||||
fmt.Printf("密钥大小: %d 位\n", *keySize)
|
||||
fmt.Printf("私钥路径: %s\n", *privateKeyPath)
|
||||
fmt.Printf("公钥路径: %s\n", *publicKeyPath)
|
||||
|
||||
if err := utils.GenerateRSAKeyPair(*privateKeyPath, *publicKeyPath, *keySize); err != nil {
|
||||
fmt.Printf("生成失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("✓ 密钥对生成成功!\n")
|
||||
fmt.Printf("\n请将私钥文件保存在安全的位置(服务器端)\n")
|
||||
fmt.Printf("请将公钥提供给客户端开发人员(可以编译到客户端程序中)\n")
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
go run .
|
||||
@@ -23,9 +23,6 @@ auth:
|
||||
secret: "license-manager-dev-secret-key-32-bytes"
|
||||
expire_hours: 1
|
||||
refresh_threshold_minutes: 30
|
||||
admin:
|
||||
username: "admin"
|
||||
password: "admin@123"
|
||||
security:
|
||||
max_login_attempts: 5
|
||||
lockout_duration_minutes: 15
|
||||
@@ -40,4 +37,15 @@ i18n:
|
||||
config_path: "../configs/i18n/errors"
|
||||
support_langs: ["zh-CN", "en-US", "ja-JP"]
|
||||
|
||||
license:
|
||||
# RSA非对称加密配置
|
||||
rsa:
|
||||
private_key_path: "../configs/rsa_private_key.pem" # RSA私钥文件路径(服务器端使用,必须保密)
|
||||
public_key_path: "../configs/rsa_public_key.pem" # RSA公钥文件路径(客户端使用,可以公开)
|
||||
key_size: 2048 # 密钥大小:2048或4096,默认2048
|
||||
|
||||
heartbeat_timeout: 300 # 心跳超时时间(秒) - 5分钟内为在线
|
||||
offline_timeout: 1440 # 离线超时时间(分钟) - 24小时后为异常
|
||||
expiring_days: 30 # 即将过期天数 - 30天内到期算即将过期
|
||||
|
||||
|
||||
@@ -24,9 +24,6 @@ auth:
|
||||
secret: "license-manager-dev-secret-key-32-bytes" # 32字节的随机字符串
|
||||
expire_hours: 1 # 1小时后过期
|
||||
refresh_threshold_minutes: 30 # 30分钟后可刷新token
|
||||
admin:
|
||||
username: "admin"
|
||||
password: "admin@123"
|
||||
security:
|
||||
max_login_attempts: 5 # 5次失败后锁定15分钟
|
||||
lockout_duration_minutes: 15 # 15分钟后解锁
|
||||
@@ -39,4 +36,15 @@ i18n:
|
||||
enable: true # 是否启用多语言支持
|
||||
default_lang: "zh-CN" # 默认语言
|
||||
config_path: "../configs/i18n/errors" # 语言包文件路径(相对于backend目录)
|
||||
support_langs: ["zh-CN", "en-US", "ja-JP"] # 支持的语言列表
|
||||
support_langs: ["zh-CN", "en-US", "ja-JP"] # 支持的语言列表
|
||||
|
||||
license:
|
||||
# RSA非对称加密配置
|
||||
rsa:
|
||||
private_key_path: "../configs/rsa_private_key.pem" # RSA私钥文件路径(服务器端使用,必须保密)
|
||||
public_key_path: "../configs/rsa_public_key.pem" # RSA公钥文件路径(客户端使用,可以公开)
|
||||
key_size: 2048 # 密钥大小:2048或4096,默认2048
|
||||
|
||||
heartbeat_timeout: 300 # 心跳超时时间(秒) - 5分钟内为在线
|
||||
offline_timeout: 1440 # 离线超时时间(分钟) - 24小时后为异常
|
||||
expiring_days: 30 # 即将过期天数 - 30天内到期算即将过期
|
||||
@@ -23,9 +23,6 @@ auth:
|
||||
secret: "please-change-in-prod"
|
||||
expire_hours: 1
|
||||
refresh_threshold_minutes: 30
|
||||
admin:
|
||||
username: "admin"
|
||||
password: "changeMe!"
|
||||
security:
|
||||
max_login_attempts: 5
|
||||
lockout_duration_minutes: 15
|
||||
@@ -40,4 +37,15 @@ i18n:
|
||||
config_path: "../configs/i18n/errors"
|
||||
support_langs: ["zh-CN", "en-US", "ja-JP"]
|
||||
|
||||
license:
|
||||
# RSA非对称加密配置
|
||||
rsa:
|
||||
private_key_path: "../configs/rsa_private_key.pem" # RSA私钥文件路径(服务器端使用,必须保密)
|
||||
public_key_path: "../configs/rsa_public_key.pem" # RSA公钥文件路径(客户端使用,可以公开)
|
||||
key_size: 2048 # 密钥大小:2048或4096,默认2048
|
||||
|
||||
heartbeat_timeout: 300 # 心跳超时时间(秒) - 5分钟内为在线
|
||||
offline_timeout: 1440 # 离线超时时间(分钟) - 24小时后为异常
|
||||
expiring_days: 30 # 即将过期天数 - 30天内到期算即将过期
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ server:
|
||||
|
||||
database:
|
||||
driver: "mysql"
|
||||
host: "104.156.140.42"
|
||||
host: "raspberrypi.local"
|
||||
port: 13306
|
||||
username: "root"
|
||||
password: "root@123"
|
||||
@@ -23,9 +23,6 @@ auth:
|
||||
secret: "license-manager-dev-secret-key-32-bytes" # 32字节的随机字符串
|
||||
expire_hours: 1 # 1小时后过期
|
||||
refresh_threshold_minutes: 30 # 30分钟后可刷新token
|
||||
admin:
|
||||
username: "admin"
|
||||
password: "admin@123"
|
||||
security:
|
||||
max_login_attempts: 5 # 5次失败后锁定15分钟
|
||||
lockout_duration_minutes: 15 # 15分钟后解锁
|
||||
@@ -38,4 +35,15 @@ i18n:
|
||||
enable: true # 是否启用多语言支持
|
||||
default_lang: "zh-CN" # 默认语言
|
||||
config_path: "../configs/i18n/errors" # 语言包文件路径(相对于backend目录)
|
||||
support_langs: ["zh-CN", "en-US", "ja-JP"] # 支持的语言列表
|
||||
support_langs: ["zh-CN", "en-US", "ja-JP"] # 支持的语言列表
|
||||
|
||||
license:
|
||||
# RSA非对称加密配置
|
||||
rsa:
|
||||
private_key_path: "../configs/rsa_private_key.pem" # RSA私钥文件路径(服务器端使用,必须保密)
|
||||
public_key_path: "../configs/rsa_public_key.pem" # RSA公钥文件路径(客户端使用,可以公开)
|
||||
key_size: 2048 # 密钥大小:2048或4096,默认2048
|
||||
|
||||
heartbeat_timeout: 300 # 心跳超时时间(秒) - 5分钟内为在线
|
||||
offline_timeout: 1440 # 离线超时时间(分钟) - 24小时后为异常
|
||||
expiring_days: 30 # 即将过期天数 - 30天内到期算即将过期
|
||||
@@ -14,19 +14,37 @@ errors:
|
||||
"100004": "Authentication required"
|
||||
"100005": "Insufficient permissions"
|
||||
|
||||
# Customer module (20xxxx) - Reserved
|
||||
# Customer module (20xxxx)
|
||||
customer:
|
||||
"200001": "Customer not found"
|
||||
"200002": "Customer already exists"
|
||||
"200003": "Customer name is required"
|
||||
"200004": "Invalid customer information format"
|
||||
"200005": "Failed to query customer list"
|
||||
"200006": "Customer has authorization codes, cannot delete. Please delete authorizations first"
|
||||
"200007": "Customer is disabled, cannot create authorization"
|
||||
|
||||
# License module (30xxxx) - Reserved
|
||||
license:
|
||||
"300001": "Invalid license key"
|
||||
"300002": "License expired"
|
||||
"300003": "License key is required"
|
||||
"300004": "License key already used"
|
||||
# Authorization module (30xxxx)
|
||||
authorization:
|
||||
"300001": "Authorization code not found"
|
||||
"300002": "Authorization code already exists"
|
||||
"300003": "Authorization code is locked"
|
||||
"300004": "Maximum activation limit reached"
|
||||
"300005": "Invalid hardware fingerprint format"
|
||||
"300006": "License not found"
|
||||
"300007": "License has been revoked"
|
||||
"300008": "Hardware fingerprint mismatch"
|
||||
"300009": "Failed to generate license file"
|
||||
"300010": "Invalid configuration parameters"
|
||||
|
||||
# Dashboard module (40xxxx)
|
||||
dashboard:
|
||||
"400001": "Invalid time type parameter"
|
||||
"400002": "Invalid start date format"
|
||||
"400003": "Invalid end date format"
|
||||
"400004": "Time range exceeds limit (over one year)"
|
||||
"400005": "Start date cannot be later than end date"
|
||||
"400006": "Invalid limit parameter"
|
||||
|
||||
# System general (90xxxx)
|
||||
system:
|
||||
@@ -35,5 +53,73 @@ errors:
|
||||
"900003": "Resource conflict"
|
||||
"900004": "Internal server error"
|
||||
|
||||
# Common text
|
||||
common:
|
||||
success: "Success"
|
||||
|
||||
# Enum translations
|
||||
enums:
|
||||
dashboard_period:
|
||||
week: "This Week"
|
||||
month: "This Month"
|
||||
customer_type:
|
||||
"individual": "Individual"
|
||||
"enterprise": "Enterprise"
|
||||
"government": "Government"
|
||||
"education": "Education"
|
||||
|
||||
authorization_code_status:
|
||||
normal: "Normal"
|
||||
locked: "Locked"
|
||||
expired: "Expired"
|
||||
|
||||
customer_level:
|
||||
"basic": "Basic Customer"
|
||||
"active": "Active Customer"
|
||||
"vip": "VIP Customer"
|
||||
"strategic": "Strategic Partner"
|
||||
|
||||
customer_status:
|
||||
"active": "Active"
|
||||
"disabled": "Disabled"
|
||||
|
||||
company_size:
|
||||
"small": "Small"
|
||||
"medium": "Medium"
|
||||
"large": "Large"
|
||||
"enterprise": "Enterprise"
|
||||
|
||||
deployment_type:
|
||||
"standalone": "Standalone"
|
||||
"cloud": "Cloud"
|
||||
"hybrid": "Hybrid"
|
||||
|
||||
encryption_type:
|
||||
"standard": "Standard"
|
||||
"advanced": "Advanced"
|
||||
|
||||
authorization_status:
|
||||
"normal": "Normal"
|
||||
"locked": "Locked"
|
||||
"expired": "Expired"
|
||||
|
||||
license_status:
|
||||
"active": "Active"
|
||||
"inactive": "Inactive"
|
||||
"revoked": "Revoked"
|
||||
|
||||
license_online_status:
|
||||
"online": "Online"
|
||||
"offline": "Offline"
|
||||
|
||||
authorization_change_type:
|
||||
"renewal": "Renewal"
|
||||
"upgrade": "Upgrade"
|
||||
"limit_change": "Limit Change"
|
||||
"feature_toggle": "Feature Toggle"
|
||||
"lock": "Lock"
|
||||
"unlock": "Unlock"
|
||||
"other": "Other"
|
||||
|
||||
# Default error message
|
||||
default_error: "Unknown error"
|
||||
@@ -14,19 +14,37 @@ errors:
|
||||
"100004": "認証が必要です"
|
||||
"100005": "権限が不足しています"
|
||||
|
||||
# 顧客モジュール (20xxxx) - 予約済み
|
||||
# 顧客モジュール (20xxxx)
|
||||
customer:
|
||||
"200001": "顧客が見つかりません"
|
||||
"200002": "顧客が既に存在します"
|
||||
"200003": "顧客名は必須です"
|
||||
"200004": "無効な顧客情報形式"
|
||||
"200005": "顧客リストの照会に失敗しました"
|
||||
"200006": "顧客にまだ認可コードが存在します。削除する前に認可を削除してください"
|
||||
"200007": "顧客が無効になっています。認可を作成できません"
|
||||
|
||||
# ライセンスモジュール (30xxxx) - 予約済み
|
||||
license:
|
||||
"300001": "無効なライセンスキー"
|
||||
"300002": "ライセンスが期限切れです"
|
||||
"300003": "ライセンスキーは必須です"
|
||||
"300004": "ライセンスキーは既に使用されています"
|
||||
# 認可モジュール (30xxxx)
|
||||
authorization:
|
||||
"300001": "認可コードが見つかりません"
|
||||
"300002": "認可コードが既に存在します"
|
||||
"300003": "認可コードがロックされています"
|
||||
"300004": "最大アクティベーション数に達しました"
|
||||
"300005": "無効なハードウェア指紋形式"
|
||||
"300006": "ライセンスが見つかりません"
|
||||
"300007": "ライセンスが取り消されました"
|
||||
"300008": "ハードウェア指紋が一致しません"
|
||||
"300009": "ライセンスファイルの生成に失敗しました"
|
||||
"300010": "無効な設定パラメータ"
|
||||
|
||||
# ダッシュボードモジュール (40xxxx)
|
||||
dashboard:
|
||||
"400001": "無効な時間タイプパラメータ"
|
||||
"400002": "無効な開始日付形式"
|
||||
"400003": "無効な終了日付形式"
|
||||
"400004": "時間範囲が制限を超えています(1年以上)"
|
||||
"400005": "開始日は終了日より後にできません"
|
||||
"400006": "無効な制限パラメータ"
|
||||
|
||||
# システム共通 (90xxxx)
|
||||
system:
|
||||
@@ -35,5 +53,73 @@ errors:
|
||||
"900003": "リソースの競合"
|
||||
"900004": "内部サーバーエラー"
|
||||
|
||||
# 共通テキスト
|
||||
common:
|
||||
success: "成功"
|
||||
|
||||
# 列挙値多言語
|
||||
enums:
|
||||
dashboard_period:
|
||||
week: "今週"
|
||||
month: "今月"
|
||||
customer_type:
|
||||
"individual": "個人顧客"
|
||||
"enterprise": "企業顧客"
|
||||
"government": "政府顧客"
|
||||
"education": "教育顧客"
|
||||
|
||||
authorization_code_status:
|
||||
normal: "正常"
|
||||
locked: "ロック済み"
|
||||
expired: "期限切れ"
|
||||
|
||||
customer_level:
|
||||
"basic": "基本顧客"
|
||||
"active": "アクティブ顧客"
|
||||
"vip": "VIP顧客"
|
||||
"strategic": "戦略的パートナー"
|
||||
|
||||
customer_status:
|
||||
"active": "アクティブ"
|
||||
"disabled": "無効"
|
||||
|
||||
company_size:
|
||||
"small": "小企業"
|
||||
"medium": "中企業"
|
||||
"large": "大企業"
|
||||
"enterprise": "超大企業"
|
||||
|
||||
deployment_type:
|
||||
"standalone": "スタンドアロン"
|
||||
"cloud": "クラウド"
|
||||
"hybrid": "ハイブリッド"
|
||||
|
||||
encryption_type:
|
||||
"standard": "標準暗号化"
|
||||
"advanced": "高度暗号化"
|
||||
|
||||
authorization_status:
|
||||
"normal": "正常"
|
||||
"locked": "ロック済み"
|
||||
"expired": "期限切れ"
|
||||
|
||||
license_status:
|
||||
"active": "アクティブ"
|
||||
"inactive": "非アクティブ"
|
||||
"revoked": "取り消し済み"
|
||||
|
||||
license_online_status:
|
||||
"online": "オンライン"
|
||||
"offline": "オフライン"
|
||||
|
||||
authorization_change_type:
|
||||
"renewal": "更新"
|
||||
"upgrade": "アップグレード"
|
||||
"limit_change": "制限変更"
|
||||
"feature_toggle": "機能切り替え"
|
||||
"lock": "ロック"
|
||||
"unlock": "アンロック"
|
||||
"other": "その他"
|
||||
|
||||
# デフォルトエラーメッセージ
|
||||
default_error: "不明なエラー"
|
||||
@@ -14,19 +14,37 @@ errors:
|
||||
"100004": "缺少认证信息"
|
||||
"100005": "权限不足"
|
||||
|
||||
# 客户模块 (20xxxx) - 预留
|
||||
# 客户模块 (20xxxx)
|
||||
customer:
|
||||
"200001": "客户不存在"
|
||||
"200002": "客户已存在"
|
||||
"200003": "客户名称不能为空"
|
||||
"200004": "客户信息格式无效"
|
||||
"200005": "客户列表查询失败"
|
||||
"200006": "客户仍有授权,无法删除,请先删除授权"
|
||||
"200007": "客户已停用,无法创建授权"
|
||||
|
||||
# 授权模块 (30xxxx) - 预留
|
||||
license:
|
||||
"300001": "授权码无效"
|
||||
"300002": "授权已过期"
|
||||
"300003": "授权码不能为空"
|
||||
"300004": "授权已被使用"
|
||||
# 授权模块 (30xxxx)
|
||||
authorization:
|
||||
"300001": "授权码不存在"
|
||||
"300002": "授权码已存在"
|
||||
"300003": "授权码已被锁定"
|
||||
"300004": "激活数量已达上限"
|
||||
"300005": "硬件指纹格式错误"
|
||||
"300006": "许可证不存在"
|
||||
"300007": "许可证已被撤销"
|
||||
"300008": "硬件指纹不匹配"
|
||||
"300009": "许可证文件生成失败"
|
||||
"300010": "配置参数错误"
|
||||
|
||||
# 仪表盘模块 (40xxxx)
|
||||
dashboard:
|
||||
"400001": "时间类型参数错误"
|
||||
"400002": "开始日期格式错误"
|
||||
"400003": "结束日期格式错误"
|
||||
"400004": "时间范围超过限制(超过一年)"
|
||||
"400005": "开始日期不能晚于结束日期"
|
||||
"400006": "数量限制参数错误"
|
||||
|
||||
# 系统通用 (90xxxx)
|
||||
system:
|
||||
@@ -35,5 +53,73 @@ errors:
|
||||
"900003": "资源冲突"
|
||||
"900004": "服务器内部错误"
|
||||
|
||||
# 通用文本
|
||||
common:
|
||||
success: "成功"
|
||||
|
||||
# 枚举值多语言
|
||||
enums:
|
||||
dashboard_period:
|
||||
week: "本周"
|
||||
month: "本月"
|
||||
customer_type:
|
||||
"individual": "个人客户"
|
||||
"enterprise": "企业客户"
|
||||
"government": "政府客户"
|
||||
"education": "教育客户"
|
||||
|
||||
authorization_code_status:
|
||||
normal: "正常"
|
||||
locked: "已锁定"
|
||||
expired: "已过期"
|
||||
|
||||
customer_level:
|
||||
"basic": "基础客户"
|
||||
"active": "活跃客户"
|
||||
"vip": "VIP客户"
|
||||
"strategic": "战略合作客户"
|
||||
|
||||
customer_status:
|
||||
"active": "激活"
|
||||
"disabled": "停用"
|
||||
|
||||
company_size:
|
||||
"small": "小型企业"
|
||||
"medium": "中型企业"
|
||||
"large": "大型企业"
|
||||
"enterprise": "超大型企业"
|
||||
|
||||
deployment_type:
|
||||
"standalone": "单机版"
|
||||
"cloud": "云端版"
|
||||
"hybrid": "混合版"
|
||||
|
||||
encryption_type:
|
||||
"standard": "标准加密"
|
||||
"advanced": "高级加密"
|
||||
|
||||
authorization_status:
|
||||
"normal": "正常"
|
||||
"locked": "已锁定"
|
||||
"expired": "已过期"
|
||||
|
||||
license_status:
|
||||
"active": "激活"
|
||||
"inactive": "未激活"
|
||||
"revoked": "已撤销"
|
||||
|
||||
license_online_status:
|
||||
"online": "在线"
|
||||
"offline": "离线"
|
||||
|
||||
authorization_change_type:
|
||||
"renewal": "续期"
|
||||
"upgrade": "升级"
|
||||
"limit_change": "限制变更"
|
||||
"feature_toggle": "功能切换"
|
||||
"lock": "锁定"
|
||||
"unlock": "解锁"
|
||||
"other": "其他"
|
||||
|
||||
# 默认错误信息
|
||||
default_error: "未知错误"
|
||||
@@ -0,0 +1,27 @@
|
||||
-----BEGIN RSA PRIVATE KEY-----
|
||||
MIIEpQIBAAKCAQEA5hGz3hws+L2MUW7CaRQIt7Vxk0KAdPBUuNdkJcs2kpgkDmiT
|
||||
/pHf+9WXfRVdKD/Eu/t7wckQfzziddXTbeCxgN/5bKSeLTPXT0VvvSkfB5FUUQAB
|
||||
HFWO4fiBlfP/NdFy++qKgCUF1wGg+jSsrhAw6oMDI7MHVRfRZTe3rTM3T0qi3Ed6
|
||||
qaoIM9INQ87FcqPVMyQ/KXbe4Dz5yVkLY5iYj8ZJP7iU2vKYvWIG9TNv0FK/dXQk
|
||||
7syMGBSKmIncCe+UaflAajbDGXq52gIeTyhOBumOLXXBdlwgCVtejBTqDmlQpiJY
|
||||
ADKiZLQfx0z1hYbbOPBPB3djXCJJmQk69ssoZQIDAQABAoIBAAyqfNSFiJRJGyiA
|
||||
iINwbupuOsp4psnJUWC7uu+5uE9pwNJqP3uMXGBo6eYg/vfwSsxuk+mfQCFBINaL
|
||||
5w/thtgpfHnWtj6W1AuxLu2jDepEvfeXoHgG3c7i939h53L3DPWBfA6B+0hS9qHP
|
||||
tJIAojDG9a1JdeXhYc3Iu3v1jd33izEPeX/O+I35dusJxag2+0Ii3DIl13/JeSLR
|
||||
m1i79lpsx6QobVKNt3bz27dxRjDZIXnsalpd5ik/QK8q1iRfGfLHfDwGn2J+X1LT
|
||||
btbW/fipsBc56AJMOGZ/OXpvO2LncZBzW/66Oa+9fvBi4kH9J3+fGpR9X28qMxuo
|
||||
OE86niECgYEA/QFHViamV0SzjCb6g9xIYD9hGMToRiq6O9UqPd7512yfxRF9y0I4
|
||||
HbrJG0k4bASeDTd8e1FvJplwPQn4tkBM02sPlfSFcjOYsDDfHmGaOh79GW/oMdst
|
||||
BfmaXSCKzYc/vR+cePDRqY+2Y8gAGUfKyte2BAOKOahLblPlp54Qn4MCgYEA6Mrq
|
||||
9XMWZagFv8y+oxQKm3i4e7/M6y7p/BadDX5s3jNjrGpkr9eE+vZw/033xuiz9rVR
|
||||
nKVLMeazferXKB1FyD3RU+5lAw3GqaWJDfUWiNyYZMgyOintEMllDTj4YsMhT/w9
|
||||
d5qS5NNYpgKToEtmPydtynn/DG55CWZkkkAc6/cCgYEA847xj5vxcf1vIzK644mC
|
||||
GzWoUWHtgO7NDaBt49XtilbFj78A7FU5FFWmGCBqsoHXbT3HFigU6oG3rOsWhMxt
|
||||
qPFdtawJcyD8klDZ2bN39w+x83TpgGVQeHoLAi+7r5PN5lCyHzMlgygYGwKEJdIZ
|
||||
BSCX1lFaFZtP0kvtv2jI8+UCgYEA1HBxcOLA+q4V6Q2Qn0Pn1i+Zs5y6BZm/gASm
|
||||
+Cx6b2BYQnypO+si+N8zCVl+l2++45JeUuRpNbXB8VQBWyMzjmQThlOUrO/CbwiY
|
||||
hTs3gFhHXhlv9LJUHDCd3i4yRfaqjNK0yVQvX1EbogvR6sNEILf7sn4YaaDbJd/i
|
||||
QbDuc8sCgYEA5I9wdyGUgnqunw/5r4D+uKNRCtms9hTTMql1me8ZOGcF3+KjjSL/
|
||||
zjt+BToLhrjjCHJYByS2P5O1vtXeeyiWslexWWTc1ujOjJlrxLgBCAMW+qNz5fLc
|
||||
CeXO8bo/mT7KS7ABXh7tCNhFsSjNNCUcHyzzKp9S1gzRC6k4AGADx8s=
|
||||
-----END RSA PRIVATE KEY-----
|
||||
@@ -0,0 +1,9 @@
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA5hGz3hws+L2MUW7CaRQI
|
||||
t7Vxk0KAdPBUuNdkJcs2kpgkDmiT/pHf+9WXfRVdKD/Eu/t7wckQfzziddXTbeCx
|
||||
gN/5bKSeLTPXT0VvvSkfB5FUUQABHFWO4fiBlfP/NdFy++qKgCUF1wGg+jSsrhAw
|
||||
6oMDI7MHVRfRZTe3rTM3T0qi3Ed6qaoIM9INQ87FcqPVMyQ/KXbe4Dz5yVkLY5iY
|
||||
j8ZJP7iU2vKYvWIG9TNv0FK/dXQk7syMGBSKmIncCe+UaflAajbDGXq52gIeTyhO
|
||||
BumOLXXBdlwgCVtejBTqDmlQpiJYADKiZLQfx0z1hYbbOPBPB3djXCJJmQk69sso
|
||||
ZQIDAQAB
|
||||
-----END PUBLIC KEY-----
|
||||
@@ -1,196 +1,83 @@
|
||||
# API 开发规范
|
||||
|
||||
## 设计原则
|
||||
## 核心要点
|
||||
|
||||
遵循系统设计理念,通过代码结构和命名体现设计思路:
|
||||
|
||||
- **分层架构**:明确职责边界,保持高内聚低耦合
|
||||
- **代码即文档**:通过良好的命名和结构让代码自己说话
|
||||
- **渐进式复杂度**:从简单到复杂的实现路径
|
||||
- **失败快速原则**:早期验证,明确错误
|
||||
|
||||
## 目录结构规范
|
||||
|
||||
### 分层架构
|
||||
一定要阅读customer模块的实现,看看其他模块的实,明白编码细节,现然后再进行开发。
|
||||
|
||||
### 1. 分层架构
|
||||
```
|
||||
internal/
|
||||
├── models/ # 数据模型层:结构体定义、验证规则
|
||||
├── service/ # 业务逻辑层:核心业务规则实现
|
||||
├── repository/ # 数据访问层:数据库、缓存操作
|
||||
└── api/handlers/ # HTTP处理层:请求处理、响应格式化
|
||||
├── models/ # 数据模型:结构体定义、验证规则
|
||||
├── service/ # 业务逻辑:核心业务规则实现
|
||||
├── repository/ # 数据访问:数据库、缓存操作
|
||||
└── api/handlers/ # HTTP处理:请求处理、响应格式化
|
||||
```
|
||||
|
||||
### 职责边界
|
||||
**职责边界**:
|
||||
- Models:数据结构、输入验证、响应格式
|
||||
- Service:业务规则、数据处理、跨模块协调
|
||||
- Repository:数据持久化、查询优化、事务管理
|
||||
- Handler:HTTP协议、参数绑定、错误响应
|
||||
|
||||
每层专注单一职责,避免跨层调用:
|
||||
### 2. 多语言支持
|
||||
|
||||
- **Models**:数据结构定义,输入验证规则,响应格式规范
|
||||
- **Service**:业务规则实现,数据处理逻辑,跨模块协调
|
||||
- **Repository**:数据持久化,查询优化,事务管理
|
||||
- **Handler**:HTTP协议处理,参数绑定,错误响应
|
||||
|
||||
## 文件组织规范
|
||||
|
||||
### 模块化设计
|
||||
|
||||
按业务领域组织文件,体现清晰的模块边界:
|
||||
|
||||
```
|
||||
models/
|
||||
├── auth.go # 认证领域:登录、Token、权限
|
||||
├── customer.go # 客户领域:客户信息、关系管理
|
||||
├── license.go # 授权领域:许可证、授权码
|
||||
└── common.go # 通用结构:响应格式、分页
|
||||
|
||||
service/
|
||||
├── interfaces.go # 接口契约:定义服务边界
|
||||
├── auth_service.go
|
||||
├── customer_service.go
|
||||
└── license_service.go
|
||||
|
||||
api/handlers/
|
||||
├── auth_handler.go
|
||||
├── customer_handler.go
|
||||
└── license_handler.go
|
||||
**枚举字段扩展**:为所有枚举类型添加`*_display`字段
|
||||
```json
|
||||
{
|
||||
"status": "active",
|
||||
"status_display": "激活",
|
||||
"customer_type": "enterprise",
|
||||
"customer_type_display": "企业客户"
|
||||
}
|
||||
```
|
||||
|
||||
## 代码组织模式
|
||||
**语言检测优先级**:
|
||||
1. URL参数:`?lang=en-US`
|
||||
2. HTTP Header:`Accept-Language`
|
||||
3. 系统默认:配置文件指定
|
||||
|
||||
### 模型层设计要点
|
||||
### 3. 错误处理
|
||||
|
||||
- 请求结构体:清晰的字段验证规则,语义化的字段命名
|
||||
- 响应结构体:统一的数据格式,合理的字段层次
|
||||
- 验证标签使用:利用 binding 标签进行输入验证
|
||||
- 文档标签:通过 json 标签明确 API 契约
|
||||
**错误码规范**:六位数字
|
||||
- 前2位:模块代码(10=认证,20=客户,30=授权,90=系统)
|
||||
- 后4位:连续错误号
|
||||
|
||||
### 服务层设计要点
|
||||
**统一响应格式**:
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": {...}
|
||||
}
|
||||
```
|
||||
|
||||
- 接口优先:先定义契约,后实现逻辑
|
||||
- 依赖注入:通过构造函数注入依赖
|
||||
- 错误处理:明确的错误类型和信息
|
||||
- 业务封装:核心逻辑集中在服务层
|
||||
### 4. RESTful设计
|
||||
|
||||
### 处理器层设计要点
|
||||
**路由规范**:
|
||||
- 使用名词表示资源
|
||||
- HTTP方法体现操作
|
||||
- 版本控制:`/api/v1/`
|
||||
|
||||
- 职责单一:只处理 HTTP 协议相关逻辑
|
||||
- 参数绑定:统一使用框架提供的绑定机制
|
||||
- 错误响应:使用统一的错误处理函数
|
||||
- 文档注解:完整的 Swagger 注解信息
|
||||
**HTTP状态码**:
|
||||
- 200:成功(包括业务逻辑失败)
|
||||
- 400:请求格式错误
|
||||
- 401:身份认证失败
|
||||
- 403:权限不足
|
||||
- 500:服务器内部错误
|
||||
|
||||
## REST API 设计
|
||||
### 5. 开发流程
|
||||
|
||||
### 路由设计原则
|
||||
1. 定义模型结构(含多语言字段)
|
||||
2. 声明服务接口
|
||||
3. 实现业务逻辑(添加i18n处理)
|
||||
4. 编写HTTP处理器
|
||||
5. 注册路由
|
||||
6. 完善Swagger文档
|
||||
|
||||
遵循 RESTful 风格,资源导向的 URL 设计:
|
||||
### 6. 质量检查
|
||||
|
||||
- 使用名词表示资源,动词通过 HTTP 方法体现
|
||||
- 嵌套路由体现资源关系
|
||||
- 版本控制通过路径前缀实现
|
||||
- 统一的路径命名约定
|
||||
|
||||
### 响应格式标准化
|
||||
|
||||
**成功响应**:统一的数据结构,包含状态码、消息、数据
|
||||
**错误响应**:详细的错误信息,包含错误码、消息、时间戳
|
||||
**分页响应**:包含分页元信息的数据结构
|
||||
|
||||
### HTTP 状态码使用策略
|
||||
|
||||
遵循设计理念,大部分业务错误返回 200,通过响应体区分状态:
|
||||
|
||||
- **200**:成功(包括业务逻辑失败)
|
||||
- **400**:请求格式错误
|
||||
- **401**:身份认证失败
|
||||
- **403**:权限不足
|
||||
- **404**:资源不存在
|
||||
- **500**:服务器内部错误
|
||||
|
||||
## API 文档规范
|
||||
|
||||
### Swagger 注解要求
|
||||
|
||||
完整的接口文档注解,包含所有必要信息:
|
||||
|
||||
- **Summary**:简洁的接口描述
|
||||
- **Description**:详细的功能说明
|
||||
- **Tags**:合理的分组标签
|
||||
- **Parameters**:完整的参数说明
|
||||
- **Responses**:所有可能的响应状态
|
||||
- **Security**:认证要求说明
|
||||
|
||||
### 文档分组策略
|
||||
|
||||
按业务领域组织接口文档:
|
||||
|
||||
- **认证管理**:登录、登出、Token 刷新
|
||||
- **客户管理**:客户信息 CRUD 操作
|
||||
- **授权管理**:许可证、授权码管理
|
||||
- **系统监控**:健康检测、系统信息
|
||||
|
||||
## 错误处理体系
|
||||
|
||||
### 错误码设计
|
||||
|
||||
采用六位数字业务错误码,体现模块化设计:
|
||||
|
||||
- **前2位**:模块代码(10=认证,20=客户,30=授权,90=系统)
|
||||
- **后4位**:连续错误号,无需分层
|
||||
- **直接映射**:错误码到错误信息的直接对应关系
|
||||
|
||||
### 错误响应策略
|
||||
|
||||
**失败快速原则**:早期验证,明确提示
|
||||
**统一处理**:使用 NewErrorResponse 函数统一创建错误响应
|
||||
**分层错误**:Service 层返回业务错误,Handler 层转换为 HTTP 响应
|
||||
|
||||
## 开发工作流
|
||||
|
||||
### API 开发步骤
|
||||
|
||||
遵循分层架构,自下而上的开发顺序:
|
||||
|
||||
1. **模型设计**:定义请求响应结构,明确数据契约
|
||||
2. **接口定义**:在 interfaces.go 中声明服务契约
|
||||
3. **业务实现**:在 service 层实现核心逻辑
|
||||
4. **处理器编写**:在 handlers 层处理 HTTP 协议
|
||||
5. **路由注册**:在 router.go 中注册 API 路由
|
||||
6. **文档完善**:添加完整的 Swagger 注解
|
||||
7. **文档生成**:更新 API 文档
|
||||
|
||||
### 质量检查要点
|
||||
|
||||
**架构层面**:
|
||||
- 分层职责是否清晰
|
||||
- 依赖关系是否合理
|
||||
- 接口设计是否完整
|
||||
|
||||
**实现层面**:
|
||||
- 错误处理是否完整
|
||||
- 输入验证是否充分
|
||||
- 响应格式是否统一
|
||||
|
||||
**文档层面**:
|
||||
- Swagger 注解是否完整
|
||||
- 接口描述是否清晰
|
||||
- 错误码是否正确
|
||||
|
||||
## 实施准则
|
||||
|
||||
### 整洁代码
|
||||
|
||||
- **命名语义化**:函数、变量名体现其用途
|
||||
- **函数职责单一**:每个函数专注一个职责
|
||||
- **避免深层嵌套**:使用早期返回减少复杂度
|
||||
|
||||
### 可维护性
|
||||
|
||||
- **接口优先设计**:先定义契约再实现
|
||||
- **依赖注入模式**:便于测试和扩展
|
||||
- **统一错误处理**:减少重复代码
|
||||
|
||||
### 渐进式复杂度
|
||||
|
||||
- **基础功能优先**:先实现核心功能
|
||||
- **逐步增强**:根据需求添加高级特性
|
||||
- **配置驱动**:通过配置控制功能开关
|
||||
- 多语言字段是否完整
|
||||
- 错误处理是否统一
|
||||
- Swagger注解是否完整
|
||||
- 避免不必要的冗余,能写到工具函数的不要写在模块内
|
||||
@@ -19,52 +19,103 @@
|
||||
```
|
||||
backend/
|
||||
├── cmd/
|
||||
│ └── server/
|
||||
│ └── main.go # 程序入口
|
||||
│ ├── main.go # 程序入口
|
||||
│ └── license_manager.sh # 启动脚本
|
||||
├── internal/
|
||||
│ ├── api/
|
||||
│ │ ├── handlers/ # HTTP处理器
|
||||
│ │ │ ├── auth.go
|
||||
│ │ │ ├── customer.go
|
||||
│ │ │ ├── license.go
|
||||
│ │ │ └── system.go
|
||||
│ │ │ ├── auth_handler.go
|
||||
│ │ │ ├── authorization_code_handler.go
|
||||
│ │ │ ├── customer_handler.go
|
||||
│ │ │ ├── enum_handler.go
|
||||
│ │ │ ├── license_handler.go
|
||||
│ │ │ └── system_handler.go
|
||||
│ │ ├── middleware/ # 中间件
|
||||
│ │ │ ├── auth.go
|
||||
│ │ │ ├── cors.go
|
||||
│ │ │ ├── i18n.go
|
||||
│ │ │ └── logging.go
|
||||
│ │ └── routes/ # 路由定义
|
||||
│ │ └── router.go
|
||||
│ ├── service/ # 业务逻辑层
|
||||
│ │ ├── auth_service.go
|
||||
│ │ ├── authorization_code_service.go
|
||||
│ │ ├── customer_service.go
|
||||
│ │ ├── enum_service.go
|
||||
│ │ ├── license_service.go
|
||||
│ │ ├── system_service.go
|
||||
│ │ └── interfaces.go # 接口定义
|
||||
│ ├── repository/ # 数据访问层
|
||||
│ │ ├── customer_repo.go
|
||||
│ │ ├── license_repo.go
|
||||
│ │ ├── authorization_code_repository.go
|
||||
│ │ ├── customer_repository.go
|
||||
│ │ ├── license_repository.go
|
||||
│ │ ├── user_repository.go
|
||||
│ │ ├── errors.go
|
||||
│ │ └── interfaces.go
|
||||
│ ├── models/ # 数据模型
|
||||
│ │ ├── auth.go
|
||||
│ │ ├── common.go
|
||||
│ │ ├── customer.go
|
||||
│ │ ├── license.go
|
||||
│ │ └── common.go
|
||||
│ │ ├── system.go
|
||||
│ │ └── user.go
|
||||
│ ├── config/ # 配置管理
|
||||
│ │ └── config.go
|
||||
│ └── cache/ # 缓存层
|
||||
│ ├── memory.go
|
||||
│ └── redis.go
|
||||
│ └── database/ # 数据库连接和迁移
|
||||
│ ├── connection.go
|
||||
│ └── migration.go
|
||||
├── pkg/
|
||||
│ ├── context/ # 上下文封装
|
||||
│ │ └── context.go
|
||||
│ ├── i18n/ # 国际化
|
||||
│ │ ├── errors.go
|
||||
│ │ └── manager.go
|
||||
│ ├── utils/ # 工具函数
|
||||
│ │ ├── crypto.go
|
||||
│ │ ├── hardware.go
|
||||
│ │ └── validator.go
|
||||
│ │ └── jwt.go
|
||||
│ └── logger/ # 日志封装
|
||||
│ └── logger.go
|
||||
├── configs/
|
||||
│ ├── config.yaml
|
||||
│ └── config.example.yaml
|
||||
│ ├── config.dev.yaml # 开发环境配置
|
||||
│ ├── config.prod.yaml # 生产环境配置
|
||||
│ ├── config.yaml # 默认配置
|
||||
│ ├── config.example.yaml # 配置示例
|
||||
│ └── i18n/ # 国际化配置
|
||||
│ └── errors/
|
||||
│ ├── en-US.yaml
|
||||
│ ├── ja-JP.yaml
|
||||
│ └── zh-CN.yaml
|
||||
├── docs/ # 文档目录
|
||||
│ ├── api_development_guide.md
|
||||
│ ├── backend_design.md
|
||||
│ ├── design_philosophy.md
|
||||
│ ├── error_i18n_design.md
|
||||
│ ├── install/
|
||||
│ │ ├── db_install.md
|
||||
│ │ └── swagger_install.md
|
||||
│ ├── modules_design/
|
||||
│ │ ├── auth_api.md
|
||||
│ │ ├── customer-api.md
|
||||
│ │ ├── customer.md
|
||||
│ │ ├── login&auth.md
|
||||
│ │ ├── user.md
|
||||
│ │ ├── user_story.md
|
||||
│ │ ├── 产品原型设计方案.md
|
||||
│ │ └── 授权模块属性设计.md
|
||||
│ └── swagger/ # Swagger文档
|
||||
│ ├── docs.go
|
||||
│ ├── swagger.json
|
||||
│ └── swagger.yaml
|
||||
├── migrations/ # 数据库迁移
|
||||
├── tools/ # 硬件信息工具
|
||||
└── go.mod
|
||||
│ ├── 001_create_customers_table.sql
|
||||
│ ├── 002_insert_sample_data.sql
|
||||
│ ├── 003_create_authorization_codes_table.sql
|
||||
│ ├── 004_create_licenses_table.sql
|
||||
│ ├── 005_create_authorization_changes_table.sql
|
||||
│ ├── 006_create_users_table.sql
|
||||
│ └── README.md
|
||||
├── go.mod
|
||||
└── go.sum
|
||||
```
|
||||
|
||||
**核心设计原则:**
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
# 客户详情接口改造需求
|
||||
|
||||
## 一、接口信息
|
||||
|
||||
**接口路径**: `GET /api/customers/{id}`
|
||||
**接口说明**: 获取客户详细信息,在原有客户基本信息基础上,新增客户授权统计信息
|
||||
|
||||
---
|
||||
|
||||
## 二、需求概述
|
||||
|
||||
在客户详情接口的响应数据中,新增一个 `authorization_stats` 对象,包含该客户的授权码和许可证相关统计数据,用于在客户详情页面展示客户的授权使用情况。
|
||||
|
||||
---
|
||||
|
||||
## 三、数据模型设计
|
||||
|
||||
### 3.1 响应结构
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "000000",
|
||||
"message": "success",
|
||||
"data": {
|
||||
// ... 原有客户基本信息字段 ...
|
||||
|
||||
// 新增:授权统计信息
|
||||
"authorization_stats": {
|
||||
"total_auth_codes": 15, // 总授权码数量
|
||||
"expired_auth_codes": 3, // 已过期授权码数量
|
||||
"expiring_soon_auth_codes": 2, // 30日内即将到期授权码数量
|
||||
"total_licenses": 42, // 总许可证数量
|
||||
"active_licenses": 28, // 已激活许可证数量
|
||||
"inactive_licenses": 14, // 未激活许可证数量
|
||||
"expired_licenses": 8 // 已过期许可证数量
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 统计字段详细说明
|
||||
|
||||
#### 授权码统计(基于 `authorization_codes` 表)
|
||||
|
||||
| 字段名 | 类型 | 说明 | 计算规则 |
|
||||
|--------|------|------|----------|
|
||||
| `total_auth_codes` | int64 | 总授权码数量 | 统计该客户(`customer_id`)的所有授权码总数 |
|
||||
| `expired_auth_codes` | int64 | 已过期授权码数量 | `end_date < NOW()` 的授权码数量(不考虑锁定状态) |
|
||||
| `expiring_soon_auth_codes` | int64 | 30日内即将到期授权码数量 | `end_date >= NOW()` 且 `end_date <= DATE_ADD(NOW(), INTERVAL 30 DAY)` 的授权码数量(不考虑锁定状态) |
|
||||
|
||||
#### 许可证统计(基于 `licenses` 表)
|
||||
|
||||
| 字段名 | 类型 | 说明 | 计算规则 |
|
||||
|--------|------|------|----------|
|
||||
| `total_licenses` | int64 | 总许可证数量 | 统计该客户(`customer_id`)的所有许可证总数 |
|
||||
| `active_licenses` | int64 | 已激活许可证数量 | `status = 'active'` 的许可证数量 |
|
||||
| `inactive_licenses` | int64 | 未激活许可证数量 | `total_licenses - active_licenses` |
|
||||
| `expired_licenses` | int64 | 已过期许可证数量 | 关联的授权码已过期(`authorization_code.end_date < NOW()`)的许可证总数 |
|
||||
|
||||
---
|
||||
|
||||
## 四、计算逻辑说明
|
||||
|
||||
### 4.1 授权码状态判断
|
||||
|
||||
- **正常(normal)**: `NOW() >= start_date AND NOW() <= end_date AND is_locked = false`
|
||||
- **已过期(expired)**: `NOW() > end_date`(无论是否锁定)
|
||||
- **已锁定(locked)**: `is_locked = true`(锁定状态不影响有效期统计)
|
||||
|
||||
**注意**:
|
||||
- **过期和即将到期统计不考虑锁定状态**:只要授权码的 `end_date` 满足条件,无论是否锁定都应计入统计
|
||||
- 例如:即使授权码已锁定,只要 `end_date < NOW()`,仍应计入"已过期授权码数量"
|
||||
|
||||
### 4.2 许可证过期判断
|
||||
|
||||
许可证的过期状态**依赖于关联的授权码**:
|
||||
- 许可证本身没有 `end_date` 字段
|
||||
- 如果许可证关联的授权码已过期(`authorization_codes.end_date < NOW()`),则视为该许可证已过期
|
||||
|
||||
### 4.3 边界条件
|
||||
|
||||
1. **空数据情况**: 如果客户没有任何授权码或许可证,所有统计值应返回 `0`
|
||||
2. **软删除**: 只统计未删除的记录(`deleted_at IS NULL`)
|
||||
3. **时间计算**: 使用服务器当前时间(`NOW()`)作为基准进行判断
|
||||
|
||||
---
|
||||
|
||||
## 五、相关数据库表
|
||||
|
||||
### 5.1 涉及表结构
|
||||
|
||||
- **`customers`**: 客户表(主表)
|
||||
- **`authorization_codes`**: 授权码表
|
||||
- `customer_id`: 关联客户ID
|
||||
- `start_date`: 授权开始时间
|
||||
- `end_date`: 授权结束时间
|
||||
- `is_locked`: 是否锁定
|
||||
- **`licenses`**: 许可证表
|
||||
- `customer_id`: 关联客户ID(冗余字段)
|
||||
- `authorization_code_id`: 关联授权码ID(外键)
|
||||
- `status`: 许可证状态(`active`/`inactive`/`revoked`)
|
||||
|
||||
### 5.2 关联关系
|
||||
|
||||
```
|
||||
customers (1) ──< (N) authorization_codes
|
||||
│
|
||||
│ (1)
|
||||
│
|
||||
└──< (N) licenses
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、实现建议
|
||||
|
||||
### 6.1 查询优化
|
||||
|
||||
建议使用单次 SQL 查询或有限次查询完成统计,避免 N+1 查询问题:
|
||||
|
||||
1. **授权码统计**: 使用 `COUNT` + `CASE WHEN` 在一条 SQL 中完成
|
||||
2. **许可证统计**:
|
||||
- `total_licenses` 和 `active_licenses`: 单次查询统计
|
||||
- `expired_licenses`: 需要关联 `authorization_codes` 表判断过期状态
|
||||
|
||||
### 6.2 性能考虑
|
||||
|
||||
- 确保相关字段已建立索引(如 `customer_id`, `end_date`, `status`, `is_locked`)
|
||||
- 对于高频访问的接口,可考虑缓存统计结果(需注意数据一致性)
|
||||
|
||||
---
|
||||
|
||||
## 七、示例数据
|
||||
|
||||
假设某客户(ID: `customer-001`)有以下数据:
|
||||
|
||||
**授权码数据**:
|
||||
- 授权码A: `end_date = '2025-12-31'`, `is_locked = false` → 正常
|
||||
- 授权码B: `end_date = '2025-01-25'`, `is_locked = false` → 30日内即将到期
|
||||
- 授权码C: `end_date = '2024-12-31'`, `is_locked = false` → 已过期
|
||||
- 授权码D: `end_date = '2025-12-31'`, `is_locked = true` → 已锁定(正常范围内)
|
||||
- 授权码E: `end_date = '2024-11-30'`, `is_locked = true` → 已锁定且已过期(仍计入过期统计)
|
||||
- 授权码F: `end_date = '2025-01-20'`, `is_locked = true` → 已锁定且即将到期(仍计入即将到期统计)
|
||||
|
||||
**许可证数据**:
|
||||
- 授权码A关联: 10个许可证(8个active, 2个inactive)
|
||||
- 授权码B关联: 5个许可证(4个active, 1个inactive)
|
||||
- 授权码C关联: 3个许可证(已过期授权码下的所有许可证,均为active)
|
||||
- 授权码D关联: 2个许可证(1个active, 1个inactive)
|
||||
- 授权码E关联: 2个许可证(1个active, 1个inactive,因授权码已过期,这2个许可证计入过期许可证)
|
||||
- 授权码F关联: 1个许可证(1个active)
|
||||
|
||||
**统计结果**:
|
||||
```json
|
||||
{
|
||||
"authorization_stats": {
|
||||
"total_auth_codes": 6,
|
||||
"expired_auth_codes": 2, // 授权码C(已过期)+ 授权码E(已锁定但已过期)
|
||||
"expiring_soon_auth_codes": 2, // 授权码B(即将到期)+ 授权码F(已锁定但即将到期)
|
||||
"total_licenses": 23, // 10+5+3+2+2+1 = 23
|
||||
"active_licenses": 18, // 8+4+3+1+1+1 = 18
|
||||
"inactive_licenses": 5, // 23-18 = 5
|
||||
"expired_licenses": 5 // 授权码C的3个 + 授权码E的2个 = 5
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 八、验收标准
|
||||
|
||||
1. ✅ 接口响应包含 `authorization_stats` 对象
|
||||
2. ✅ 所有统计字段类型为 `int64`,空数据返回 `0`
|
||||
3. ✅ 已过期和即将到期授权码统计不考虑锁定状态(仅基于 `end_date` 判断)
|
||||
4. ✅ 许可证过期判断基于关联授权码的有效期
|
||||
5. ✅ 计算逻辑准确,边界情况处理正确
|
||||
6. ✅ 查询性能良好,无明显的性能问题
|
||||
@@ -0,0 +1,125 @@
|
||||
# 仪表盘 API 设计
|
||||
|
||||
## 1. 授权趋势统计 API
|
||||
|
||||
### 1.1 获取授权趋势数据
|
||||
```http
|
||||
GET /api/v1/dashboard/authorization-trend
|
||||
```
|
||||
|
||||
**查询参数**
|
||||
- `type` - 时间类型 (week/month/custom) 必填
|
||||
- `start_date` - 开始日期 (YYYY-MM-DD格式,当type为custom时必填)
|
||||
- `end_date` - 结束日期 (YYYY-MM-DD格式,当type为custom时必填)
|
||||
- `timezone` - 时区 (可选,如:Asia/Shanghai、UTC等,默认使用服务器本地时区)
|
||||
|
||||
**参数说明:**
|
||||
- `week`: 返回本周每日的授权总数(周一到周日)
|
||||
- `month`: 返回本月每日的授权总数(1号到当月最后一天)
|
||||
- `custom`: 返回指定时间段内每日授权总数,时间间隔不超过一年
|
||||
- `timezone`: 指定时区,影响"本周"、"本月"以及自定义日期的计算基准。支持标准时区名称如Asia/Shanghai、America/New_York、UTC等
|
||||
|
||||
**响应**
|
||||
```json
|
||||
{
|
||||
"code": "000000",
|
||||
"message": "成功",
|
||||
"data": {
|
||||
"period": {
|
||||
"type": "week",
|
||||
"start_date": "2024-01-01",
|
||||
"end_date": "2024-01-07",
|
||||
"description_display": "本周"
|
||||
},
|
||||
"trend_data": [
|
||||
{
|
||||
"date": "2024-01-01",
|
||||
"total_authorizations": 15,
|
||||
"new_authorizations": 3,
|
||||
"expired_authorizations": 1
|
||||
},
|
||||
{
|
||||
"date": "2024-01-02",
|
||||
"total_authorizations": 17,
|
||||
"new_authorizations": 2,
|
||||
"expired_authorizations": 0
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"total_count": 120,
|
||||
"new_count": 15,
|
||||
"expired_count": 5,
|
||||
"growth_rate": 5.2
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**响应字段说明:**
|
||||
- `description_display` - 多语言
|
||||
|
||||
## 2. 最近授权列表 API
|
||||
|
||||
### 2.1 获取最近授权列表
|
||||
```http
|
||||
GET /api/v1/dashboard/recent-authorizations
|
||||
```
|
||||
|
||||
**查询参数**
|
||||
- `limit` - 返回数量限制(默认20,最大100)
|
||||
- `customer_id` - 客户ID筛选(可选)
|
||||
- `status` - 状态筛选 (normal/locked/expired) 可选
|
||||
|
||||
**响应**
|
||||
```json
|
||||
{
|
||||
"code": "000000",
|
||||
"message": "成功",
|
||||
"data": {
|
||||
"list": [
|
||||
{
|
||||
"id": "uuid-string",
|
||||
"code": "LIC-COMP001-A7B9X2-C8F4",
|
||||
"customer_id": "customer-uuid",
|
||||
"customer_name": "张三公司",
|
||||
"description": "企业版授权",
|
||||
"status": "normal",
|
||||
"status_display": "正常",
|
||||
"start_date": "2024-01-01T00:00:00Z",
|
||||
"end_date": "2024-12-31T23:59:59Z",
|
||||
"max_activations": 10,
|
||||
"current_activations": 7,
|
||||
"created_at": "2024-01-01T10:00:00Z",
|
||||
"updated_at": "2024-01-01T10:00:00Z"
|
||||
}
|
||||
],
|
||||
"total": 20
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**响应字段说明:**
|
||||
- `total` - 符合条件的授权总数量
|
||||
- `list` - 当前返回的授权列表数据
|
||||
- `status_display` - 多语言
|
||||
|
||||
## 3. 状态说明
|
||||
|
||||
### 3.1 授权码状态
|
||||
- `normal` - 正常:当前时间在有效期内且未被锁定
|
||||
- `locked` - 已锁定:is_locked=true
|
||||
- `expired` - 已过期:当前时间超过end_date
|
||||
|
||||
### 3.2 时间类型
|
||||
- `week` - 本周:从周一00:00:00到周日23:59:59 (基于指定时区)
|
||||
- `month` - 本月:从1号00:00:00到当月最后一天23:59:59 (基于指定时区)
|
||||
- `custom` - 自定义:指定开始和结束日期,最大跨度365天 (基于指定时区)
|
||||
|
||||
## 4. 错误码定义
|
||||
|
||||
- `400001` - 时间类型参数错误
|
||||
- `400002` - 开始日期格式错误
|
||||
- `400003` - 结束日期格式错误
|
||||
- `400004` - 时间范围超过限制(超过一年)
|
||||
- `400005` - 开始日期不能晚于结束日期
|
||||
- `400006` - 数量限制参数错误
|
||||
@@ -0,0 +1,68 @@
|
||||
# 许可证加密设计文档
|
||||
|
||||
## 加密方案
|
||||
|
||||
使用 **RSA 数字签名**确保许可证文件的完整性和来源认证。
|
||||
|
||||
### 签名算法
|
||||
- **算法**: RSA-PSS
|
||||
- **哈希**: SHA-256
|
||||
- **密钥长度**: 2048 位(推荐)或 4096 位
|
||||
|
||||
## 服务器端实现
|
||||
|
||||
### 密钥管理
|
||||
- 私钥存储在配置文件中:`configs/rsa_private_key.pem`
|
||||
- 配置文件路径:`license.rsa.private_key_path`
|
||||
|
||||
### 签名流程
|
||||
1. 构建许可证数据(JSON)
|
||||
2. 使用 RSA 私钥对数据进行数字签名
|
||||
3. 生成许可证文件结构:
|
||||
```json
|
||||
{
|
||||
"data": "原始JSON数据(字符串)",
|
||||
"signature": "base64编码的签名",
|
||||
"algorithm": "RSA-PSS-SHA256"
|
||||
}
|
||||
```
|
||||
4. Base64 编码整个结构
|
||||
|
||||
### 代码位置
|
||||
- 签名实现:`backend/internal/service/license_service.go::signLicenseFile()`
|
||||
- 密钥工具:`backend/pkg/utils/crypto.go`
|
||||
|
||||
## 客户端实现
|
||||
|
||||
### 公钥加载
|
||||
支持三种方式(按优先级):
|
||||
1. 当前目录的 `rsa_public_key.pem` 文件
|
||||
2. 环境变量 `LICENSE_RSA_PUBLIC_KEY`(PEM 格式字符串)
|
||||
3. 环境变量 `LICENSE_RSA_PUBLIC_KEY_PATH`(文件路径)
|
||||
|
||||
### 验证流程
|
||||
1. Base64 解码许可证文件
|
||||
2. 解析 JSON 结构
|
||||
3. 使用 RSA 公钥验证签名
|
||||
4. 验证通过后解析许可证数据
|
||||
|
||||
### 代码位置
|
||||
- 验证实现:`backend/cmd/client-demo/rsa.go::verifyRSASignature()`
|
||||
- 许可证解析:`backend/cmd/client-demo/main.go::decryptLicenseFile()`
|
||||
|
||||
## 密钥生成
|
||||
|
||||
使用工具生成密钥对:
|
||||
```bash
|
||||
go run backend/cmd/gen-rsa-keys/main.go
|
||||
```
|
||||
|
||||
生成文件:
|
||||
- `rsa_private_key.pem` - 服务器使用(必须保密)
|
||||
- `rsa_public_key.pem` - 客户端使用(可公开)
|
||||
|
||||
## 安全性说明
|
||||
|
||||
1. **防伪造**:只有服务器拥有私钥,无法伪造有效签名
|
||||
2. **防篡改**:任何数据修改都会导致签名验证失败
|
||||
3. **密钥分离**:公钥可公开,私钥仅服务器持有
|
||||
@@ -0,0 +1,551 @@
|
||||
# 授权模块API设计
|
||||
|
||||
## 1. 授权码管理 API
|
||||
|
||||
### 1.1 授权码列表
|
||||
```http
|
||||
GET /api/v1/authorization-codes
|
||||
```
|
||||
|
||||
**查询参数**
|
||||
- `customer_id` - 客户ID
|
||||
- `status` - 状态 (normal/locked/expired)
|
||||
- `page` - 页码
|
||||
- `page_size` - 每页数量(默认20,最大100)
|
||||
- `start_date` - 创建开始时间
|
||||
- `end_date` - 创建结束时间
|
||||
- `sort` - 排序字段 (created_at/updated_at/code)
|
||||
- `order` - 排序方向 (asc/desc)
|
||||
|
||||
**实现说明**
|
||||
|
||||
由于 `status` 是虚拟字段,需要在 SQL 查询中动态计算并筛选:
|
||||
|
||||
```sql
|
||||
-- 状态计算逻辑
|
||||
SELECT *,
|
||||
CASE
|
||||
WHEN is_locked = true THEN 'locked'
|
||||
WHEN end_date < NOW() THEN 'expired'
|
||||
WHEN start_date <= NOW() AND end_date >= NOW() THEN 'normal'
|
||||
ELSE 'expired'
|
||||
END AS status
|
||||
FROM authorization_codes
|
||||
WHERE 1=1
|
||||
-- status 参数筛选
|
||||
AND (
|
||||
(:status = 'locked' AND is_locked = true) OR
|
||||
(:status = 'expired' AND is_locked = false AND end_date < NOW()) OR
|
||||
(:status = 'normal' AND is_locked = false AND start_date <= NOW() AND end_date >= NOW()) OR
|
||||
:status IS NULL
|
||||
)
|
||||
-- 其他筛选条件
|
||||
AND (:customer_id IS NULL OR customer_id = :customer_id)
|
||||
AND (:start_date IS NULL OR created_at >= :start_date)
|
||||
AND (:end_date IS NULL OR created_at <= :end_date)
|
||||
ORDER BY created_at DESC
|
||||
LIMIT :page_size OFFSET :offset;
|
||||
```
|
||||
|
||||
**响应**
|
||||
```json
|
||||
{
|
||||
"code": "000000",
|
||||
"message": "成功",
|
||||
"data": {
|
||||
"list": [
|
||||
{
|
||||
"id": "uuid-string",
|
||||
"code": "LIC-COMP001-A7B9X2-C8F4",
|
||||
"customer_id": "customer-uuid",
|
||||
"customer_name": "张三公司",
|
||||
"customer_name_display": "张三公司",
|
||||
"status": "normal",
|
||||
"status_display": "正常",
|
||||
"start_date": "2024-01-01T00:00:00Z",
|
||||
"end_date": "2024-12-31T23:59:59Z",
|
||||
"max_activations": 10,
|
||||
"current_activations": 7,
|
||||
"deployment_type": "standalone",
|
||||
"deployment_type_display": "单机版",
|
||||
"is_locked": false,
|
||||
"description": "企业版授权",
|
||||
"created_at": "2024-01-01T10:00:00Z"
|
||||
}
|
||||
],
|
||||
"total": 100,
|
||||
"page": 1,
|
||||
"page_size": 20,
|
||||
"total_pages": 5
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 1.2 创建授权码
|
||||
```http
|
||||
POST /api/v1/authorization-codes
|
||||
```
|
||||
|
||||
**说明:** 有效期从当天00:00:00开始,到指定天数后的23:59:59结束。例如:validity_days=1表示今天一整天有效。
|
||||
|
||||
**请求体**
|
||||
```json
|
||||
{
|
||||
"customer_id": "customer-uuid-123",
|
||||
"software_id": "erp-system",
|
||||
"description": "企业版授权",
|
||||
"validity_days": 365,
|
||||
"deployment_type": "standalone",
|
||||
"encryption_type": "standard",
|
||||
"software_version": "1.0.0",
|
||||
"max_activations": 10,
|
||||
"feature_config": {
|
||||
"modules": ["user_mgmt", "inventory", "finance"]
|
||||
},
|
||||
"usage_limits": {
|
||||
"max_users": 100,
|
||||
"api_calls_per_day": 10000
|
||||
},
|
||||
"custom_parameters": {}
|
||||
}
|
||||
```
|
||||
|
||||
**参数说明:**
|
||||
- `customer_id`: 客户ID(必填)
|
||||
- `software_id`: 软件产品ID(可选)
|
||||
- `description`: 授权描述(可选)
|
||||
- `validity_days`: 有效期天数,范围1-36500天(必填)
|
||||
- `deployment_type`: 部署类型,枚举值:standalone/cloud/hybrid(必填)
|
||||
- `encryption_type`: 加密类型,枚举值:standard/advanced(可选,默认standard)
|
||||
- `software_version`: 软件版本(可选)
|
||||
- `max_activations`: 最大激活数量(必填,最小值1)
|
||||
- `feature_config`: 功能配置JSON(可选)
|
||||
- `usage_limits`: 使用限制JSON(可选)
|
||||
- `custom_parameters`: 自定义参数JSON(可选)
|
||||
|
||||
**响应**
|
||||
```json
|
||||
{
|
||||
"code": "000000",
|
||||
"message": "成功",
|
||||
"data": {
|
||||
"id": "uuid-string",
|
||||
"code": "LIC-COMP001-A7B9X2-C8F4"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 1.3 授权码详情
|
||||
```http
|
||||
GET /api/v1/authorization-codes/{id}
|
||||
```
|
||||
|
||||
**响应**
|
||||
```json
|
||||
{
|
||||
"code": "000000",
|
||||
"message": "成功",
|
||||
"data": {
|
||||
"id": "uuid-string",
|
||||
"code": "LIC-COMP001-A7B9X2-C8F4",
|
||||
"customer_id": "customer-uuid",
|
||||
"status": "normal",
|
||||
"status_display": "正常",
|
||||
"start_date": "2024-01-01T00:00:00Z",
|
||||
"end_date": "2024-12-31T23:59:59Z",
|
||||
"max_activations": 10,
|
||||
"current_activations": 7,
|
||||
"deployment_type": "standalone",
|
||||
"deployment_type_display": "单机版",
|
||||
"encryption_type": "standard",
|
||||
"encryption_type_display": "标准加密",
|
||||
"feature_config": {
|
||||
"modules": ["user_mgmt", "inventory", "finance"]
|
||||
},
|
||||
"usage_limits": {
|
||||
"max_users": 100,
|
||||
"api_calls_per_day": 10000
|
||||
},
|
||||
"custom_parameters": {},
|
||||
"is_locked": false,
|
||||
"lock_reason": null,
|
||||
"description": "企业版授权",
|
||||
"created_at": "2024-01-01T10:00:00Z",
|
||||
"updated_at": "2024-01-01T10:00:00Z",
|
||||
"customer_info": {
|
||||
"id": "customer-uuid",
|
||||
"customer_code": "CUS-2024-0001",
|
||||
"customer_name": "张三公司",
|
||||
"customer_type": "enterprise",
|
||||
"customer_type_display": "企业客户",
|
||||
"status": "active",
|
||||
"status_display": "激活",
|
||||
"created_at": "2023-12-01T10:00:00Z"
|
||||
},
|
||||
"activated_licenses_count": 7
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 1.4 更新授权码
|
||||
```http
|
||||
PUT /api/v1/authorization-codes/{id}
|
||||
```
|
||||
|
||||
**请求体**
|
||||
```json
|
||||
{
|
||||
"start_date": "2024-01-01",
|
||||
"end_date": "2025-12-31",
|
||||
"max_activations": 20,
|
||||
"feature_config": {
|
||||
"modules": ["user_mgmt", "inventory", "finance", "crm"]
|
||||
},
|
||||
"usage_limits": {
|
||||
"max_users": 200,
|
||||
"api_calls_per_day": 20000
|
||||
},
|
||||
"change_type": "upgrade",
|
||||
"reason": "客户升级套餐"
|
||||
}
|
||||
```
|
||||
|
||||
### 1.5 锁定/解锁授权码
|
||||
```http
|
||||
PUT /api/v1/authorization-codes/{id}/lock
|
||||
```
|
||||
|
||||
**请求体**
|
||||
```json
|
||||
{
|
||||
"is_locked": true,
|
||||
"lock_reason": "违规使用"
|
||||
}
|
||||
```
|
||||
|
||||
### 1.6 删除授权码
|
||||
```http
|
||||
DELETE /api/v1/authorization-codes/{id}
|
||||
```
|
||||
|
||||
## 2. 许可证管理 API
|
||||
|
||||
### 2.1 许可证列表
|
||||
```http
|
||||
GET /api/v1/licenses
|
||||
```
|
||||
|
||||
**查询参数**
|
||||
- `authorization_code_id` - 授权码ID
|
||||
- `customer_id` - 客户ID
|
||||
- `status` - 状态 (active/inactive/revoked)
|
||||
- `is_online` - 在线状态
|
||||
- `page` - 页码(默认1)
|
||||
- `page_size` - 每页数量(默认20,最大100)
|
||||
- `sort` - 排序字段 (created_at/updated_at/activated_at/last_heartbeat)
|
||||
- `order` - 排序方向 (asc/desc)
|
||||
|
||||
**响应**
|
||||
```json
|
||||
{
|
||||
"code": "000000",
|
||||
"message": "成功",
|
||||
"data": {
|
||||
"list": [
|
||||
{
|
||||
"id": "license-uuid",
|
||||
"license_key": "LIC-DEVICE-ABC123456789",
|
||||
"authorization_code_id": "auth-code-uuid",
|
||||
"authorization_code": "LIC-COMP001-A7B9X2-C8F4",
|
||||
"customer_name": "张三公司",
|
||||
"hardware_fingerprint": "CPU:ABC123,MB:DEF456",
|
||||
"status": "active",
|
||||
"status_display": "激活",
|
||||
"is_online": true,
|
||||
"is_online_display": "在线",
|
||||
"activation_ip": "192.168.1.100",
|
||||
"last_online_ip": "192.168.1.100",
|
||||
"activated_at": "2024-01-01T10:00:00Z",
|
||||
"last_heartbeat": "2024-01-01T14:30:00Z"
|
||||
}
|
||||
],
|
||||
"total": 50,
|
||||
"page": 1,
|
||||
"page_size": 20,
|
||||
"total_pages": 3
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 许可证详情
|
||||
```http
|
||||
GET /api/v1/licenses/{id}
|
||||
```
|
||||
|
||||
**响应**
|
||||
```json
|
||||
{
|
||||
"code": "000000",
|
||||
"message": "成功",
|
||||
"data": {
|
||||
"id": "license-uuid",
|
||||
"license_key": "LIC-DEVICE-ABC123456789",
|
||||
"authorization_code_id": "auth-code-uuid",
|
||||
"authorization_code": "LIC-COMP001-A7B9X2-C8F4",
|
||||
"customer_id": "customer-uuid",
|
||||
"customer_name": "张三公司",
|
||||
"hardware_fingerprint": "CPU:ABC123,MB:DEF456,MAC:00:11:22:33:44:55",
|
||||
"device_info": {
|
||||
"cpu": "Intel i7-8700",
|
||||
"memory": "16GB",
|
||||
"disk": "512GB SSD",
|
||||
"os": "Windows 10 Pro"
|
||||
},
|
||||
"activation_ip": "192.168.1.100",
|
||||
"status": "active",
|
||||
"status_display": "激活",
|
||||
"is_online": true,
|
||||
"is_online_display": "在线",
|
||||
"activated_at": "2024-01-01T10:00:00Z",
|
||||
"last_heartbeat": "2024-01-01T14:30:00Z",
|
||||
"last_online_ip": "192.168.1.100",
|
||||
"config_updated_at": "2024-01-01T10:00:00Z",
|
||||
"usage_data": {
|
||||
"active_users": 50,
|
||||
"api_calls_today": 5000
|
||||
},
|
||||
"created_at": "2024-01-01T10:00:00Z",
|
||||
"updated_at": "2024-01-01T14:30:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.3 手动添加许可证
|
||||
```http
|
||||
POST /api/v1/licenses
|
||||
```
|
||||
|
||||
**请求体**
|
||||
```json
|
||||
{
|
||||
"authorization_code_id": "auth-code-uuid",
|
||||
"hardware_fingerprint": "CPU:ABC123,MB:DEF456,MAC:00:11:22:33:44:55",
|
||||
"device_info": {
|
||||
"cpu": "Intel i7-8700",
|
||||
"memory": "16GB",
|
||||
"disk": "512GB SSD",
|
||||
"os": "Windows 10 Pro"
|
||||
},
|
||||
"activation_ip": "192.168.1.100"
|
||||
}
|
||||
```
|
||||
|
||||
**响应**
|
||||
```json
|
||||
{
|
||||
"code": "000000",
|
||||
"message": "成功",
|
||||
"data": {
|
||||
"id": "license-uuid",
|
||||
"license_key": "LIC-DEVICE-ABC123456789",
|
||||
"authorization_code_id": "auth-code-uuid",
|
||||
"customer_id": "customer-uuid",
|
||||
"hardware_fingerprint": "CPU:ABC123,MB:DEF456,MAC:00:11:22:33:44:55",
|
||||
"device_info": {
|
||||
"cpu": "Intel i7-8700",
|
||||
"memory": "16GB",
|
||||
"disk": "512GB SSD",
|
||||
"os": "Windows 10 Pro"
|
||||
},
|
||||
"activation_ip": "192.168.1.100",
|
||||
"status": "inactive",
|
||||
"created_at": "2024-01-01T10:00:00Z",
|
||||
"updated_at": "2024-01-01T10:00:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.4 撤销许可证
|
||||
```http
|
||||
PUT /api/v1/licenses/{id}/revoke
|
||||
```
|
||||
|
||||
**请求体**
|
||||
```json
|
||||
{
|
||||
"reason": "设备更换"
|
||||
}
|
||||
```
|
||||
|
||||
### 2.5 下载许可证文件
|
||||
```http
|
||||
GET /api/v1/licenses/{id}/download
|
||||
```
|
||||
|
||||
**响应**: 返回加密的许可证文件
|
||||
|
||||
## 3. 客户端激活 API
|
||||
|
||||
### 3.1 软件激活
|
||||
```http
|
||||
POST /api/v1/activate
|
||||
```
|
||||
|
||||
**请求体**
|
||||
```json
|
||||
{
|
||||
"authorization_code": "LIC-COMP001-A7B9X2-C8F4",
|
||||
"hardware_fingerprint": "CPU:ABC123,MB:DEF456,MAC:00:11:22:33:44:55",
|
||||
"device_info": {
|
||||
"cpu": "Intel i7-8700",
|
||||
"memory": "16GB",
|
||||
"os": "Windows 10 Pro"
|
||||
},
|
||||
"software_version": "1.0.0"
|
||||
}
|
||||
```
|
||||
|
||||
**响应**
|
||||
```json
|
||||
{
|
||||
"code": "000000",
|
||||
"message": "成功",
|
||||
"data": {
|
||||
"license_key": "LIC-DEVICE-ABC123456789",
|
||||
"license_file": "base64编码的加密许可证文件",
|
||||
"heartbeat_interval": 300
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 心跳检测
|
||||
```http
|
||||
POST /api/v1/heartbeat
|
||||
```
|
||||
|
||||
**请求体**
|
||||
```json
|
||||
{
|
||||
"license_key": "LIC-DEVICE-ABC123456789",
|
||||
"hardware_fingerprint": "CPU:ABC123,MB:DEF456,MAC:00:11:22:33:44:55",
|
||||
"config_updated_at": "2024-01-01T10:00:00Z",
|
||||
"usage_data": {
|
||||
"active_users": 50,
|
||||
"api_calls_today": 5000
|
||||
},
|
||||
"software_version": "1.0.0"
|
||||
}
|
||||
```
|
||||
|
||||
**响应**
|
||||
```json
|
||||
{
|
||||
"code": "000000",
|
||||
"message": "成功",
|
||||
"data": {
|
||||
"status": "active",
|
||||
"config_updated": true,
|
||||
"license_file": "base64编码的新许可证文件",
|
||||
"heartbeat_interval": 300
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 4. 统计报表 API
|
||||
|
||||
### 4.1 授权概览统计
|
||||
```http
|
||||
GET /api/v1/stats/overview
|
||||
```
|
||||
|
||||
**响应**
|
||||
```json
|
||||
{
|
||||
"code": "000000",
|
||||
"message": "成功",
|
||||
"data": {
|
||||
"total_auth_codes": 1234,
|
||||
"active_licenses": 856,
|
||||
"expiring_soon": 23,
|
||||
"abnormal_alerts": 5,
|
||||
"growth_rate": {
|
||||
"auth_codes": 5.2,
|
||||
"licenses": 3.1
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 授权变更历史
|
||||
```http
|
||||
GET /api/v1/authorization-codes/{id}/changes
|
||||
```
|
||||
|
||||
**查询参数**
|
||||
- `page` - 页码(默认1)
|
||||
- `page_size` - 每页数量(默认20,最大100)
|
||||
- `change_type` - 变更类型筛选 (renewal/upgrade/limit_change/feature_toggle/lock/unlock/other)
|
||||
- `operator_id` - 操作人筛选
|
||||
- `start_date` - 开始时间 (YYYY-MM-DD格式)
|
||||
- `end_date` - 结束时间 (YYYY-MM-DD格式)
|
||||
- `sort` - 排序字段 (created_at/change_type)
|
||||
- `order` - 排序方向 (asc/desc)
|
||||
|
||||
**响应**
|
||||
```json
|
||||
{
|
||||
"code": "000000",
|
||||
"message": "成功",
|
||||
"data": {
|
||||
"list": [
|
||||
{
|
||||
"id": "change-uuid",
|
||||
"change_type": "upgrade",
|
||||
"change_type_display": "升级",
|
||||
"operator_id": "user-uuid",
|
||||
"operator_name": "张三",
|
||||
"reason": "客户升级套餐",
|
||||
"created_at": "2024-01-01T10:00:00Z"
|
||||
}
|
||||
],
|
||||
"total": 25,
|
||||
"page": 1,
|
||||
"page_size": 20,
|
||||
"total_pages": 2
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 5. 错误码定义
|
||||
|
||||
- `300001` - 授权码不存在
|
||||
- `300002` - 授权码已存在
|
||||
- `300003` - 授权码已被锁定
|
||||
- `300004` - 激活数量已达上限
|
||||
- `300005` - 硬件指纹格式错误
|
||||
- `300006` - 许可证不存在
|
||||
- `300007` - 许可证已被撤销
|
||||
- `300008` - 硬件指纹不匹配
|
||||
- `300009` - 许可证文件生成失败
|
||||
- `300010` - 配置参数错误
|
||||
|
||||
## 6. 状态说明
|
||||
|
||||
### 6.1 授权码状态
|
||||
- `normal` - 正常:当前时间在有效期内且未被锁定
|
||||
- `locked` - 已锁定:is_locked=true
|
||||
- `expired` - 已过期:当前时间超过end_date
|
||||
|
||||
### 6.2 部署类型
|
||||
- `standalone` - 单机版:独立部署的软件
|
||||
- `cloud` - 云端版:基于云的SaaS服务
|
||||
- `hybrid` - 混合版:结合本地和云端的部署
|
||||
|
||||
### 6.3 加密类型
|
||||
- `standard` - 标准加密:基础安全级别
|
||||
- `advanced` - 高级加密:增强安全级别
|
||||
|
||||
### 6.4 许可证状态
|
||||
- `active` - 激活:正常使用中
|
||||
- `inactive` - 未激活:已创建但尚未激活
|
||||
- `revoked` - 已撤销:被管理员撤销或因违规被停用
|
||||
@@ -22,14 +22,18 @@
|
||||
"customer_code": "CUS-2025-0001",
|
||||
"customer_name": "北京科技有限公司",
|
||||
"customer_type": "enterprise",
|
||||
"customer_type_display": "企业客户",
|
||||
"contact_person": "张三",
|
||||
"contact_title": "技术总监",
|
||||
"email": "zhangsan@example.com",
|
||||
"phone": "13800138000",
|
||||
"address": "北京市朝阳区科技园区100号",
|
||||
"company_size": "medium",
|
||||
"company_size_display": "中型企业",
|
||||
"customer_level": "vip",
|
||||
"customer_level_display": "VIP客户",
|
||||
"status": "active",
|
||||
"status_display": "激活",
|
||||
"description": "重要客户,需要优先技术支持",
|
||||
"created_at": "2025-01-15T10:30:00Z",
|
||||
"updated_at": "2025-01-20T15:45:00Z",
|
||||
@@ -40,9 +44,15 @@
|
||||
|
||||
### 字段说明
|
||||
- `customer_type`: `individual` | `enterprise` | `government` | `education`
|
||||
- `customer_type_display`: 客户类型的多语言显示名称
|
||||
- `company_size`: `small` | `medium` | `large` | `enterprise`
|
||||
- `company_size_display`: 企业规模的多语言显示名称
|
||||
- `customer_level`: `normal` | `vip` | `enterprise` | `strategic`
|
||||
- `customer_level_display`: 客户等级的多语言显示名称
|
||||
- `status`: `active` | `disabled`
|
||||
- `status_display`: 状态的多语言显示名称
|
||||
|
||||
> **注意**: `*_display` 字段根据请求头 `Accept-Language` 或查询参数 `lang` 返回对应语言的显示文本
|
||||
|
||||
---
|
||||
|
||||
@@ -75,10 +85,13 @@ GET /api/customers
|
||||
"customer_code": "CUS-2025-0001",
|
||||
"customer_name": "北京科技有限公司",
|
||||
"customer_type": "enterprise",
|
||||
"customer_type_display": "企业客户",
|
||||
"contact_person": "张三",
|
||||
"email": "zhangsan@example.com",
|
||||
"customer_level": "vip",
|
||||
"customer_level_display": "VIP客户",
|
||||
"status": "active",
|
||||
"status_display": "激活",
|
||||
"created_at": "2025-01-15T10:30:00Z"
|
||||
}
|
||||
],
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
## 社区版登录设计
|
||||
|
||||
用户名和密码配置在配置文件中,默认用户名和密码为 `admin` / `admin@123`
|
||||
用户名和密码硬编码在系统中,默认用户名和密码为 `admin` / `admin@123`
|
||||
|
||||
## 功能模块
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# 用户管理模块设计
|
||||
|
||||
## 表结构设计
|
||||
|
||||
### 用户表 (users)
|
||||
|
||||
**基础信息属性**
|
||||
- `id` - 主键 (UUID)
|
||||
- `username` - 用户名,唯一
|
||||
- `email` - 邮箱地址,唯一
|
||||
- `password_hash` - 密码哈希值
|
||||
- `full_name` - 真实姓名
|
||||
- `phone` - 手机号码
|
||||
|
||||
**角色权限属性**
|
||||
- `role` - 用户角色 (admin管理员/operator操作员/viewer查看者)
|
||||
- `status` - 账号状态 (active活跃/disabled禁用/locked锁定)
|
||||
|
||||
**登录相关属性**
|
||||
- `last_login_at` - 最后登录时间
|
||||
- `last_login_ip` - 最后登录IP
|
||||
- `login_attempts` - 登录失败次数
|
||||
- `locked_until` - 账号锁定到期时间
|
||||
|
||||
**时间字段**
|
||||
- `created_at` - 创建时间
|
||||
- `updated_at` - 更新时间
|
||||
|
||||
## 角色权限说明
|
||||
|
||||
**admin (管理员)**
|
||||
- 完整的系统管理权限
|
||||
- 可以管理用户、授权码、许可证
|
||||
- 可以查看所有报表和日志
|
||||
|
||||
**operator (操作员)**
|
||||
- 可以创建和管理授权码
|
||||
- 可以管理许可证
|
||||
- 可以查看相关报表
|
||||
- 不能管理用户
|
||||
|
||||
**viewer (查看者)**
|
||||
- 只读权限
|
||||
- 可以查看授权码和许可证信息
|
||||
- 可以查看报表
|
||||
- 不能进行任何修改操作
|
||||
|
||||
## 安全机制
|
||||
|
||||
**密码策略**
|
||||
- 最小长度8位
|
||||
- 包含大小写字母、数字、特殊字符
|
||||
- 定期提醒更换密码
|
||||
|
||||
**账号锁定**
|
||||
- 连续5次登录失败自动锁定30分钟
|
||||
- 管理员可手动解锁账号
|
||||
|
||||
**会话管理**
|
||||
- JWT Token有效期24小时
|
||||
- 支持Token刷新机制
|
||||
@@ -0,0 +1,243 @@
|
||||
# 授权管理用户故事
|
||||
|
||||
## 授权管理 - 用户故事
|
||||
|
||||
|
||||
### 🏢 **授权管理员角色**
|
||||
|
||||
|
||||
**故事1:生成新授权**
|
||||
|
||||
```
|
||||
作为 授权管理员
|
||||
我想要 为客户生成新的授权码
|
||||
以便 客户能够部署和使用IoT平台
|
||||
|
||||
验收标准:
|
||||
- 我可以选择已有客户或创建新客户
|
||||
- 我可以设置授权期限(如1年)
|
||||
- 我可以设置设备数量限制(如100台)
|
||||
- 我可以选择启用的功能模块
|
||||
- 系统自动生成唯一的授权码
|
||||
- 我可以下载包含授权码的部署包
|
||||
```
|
||||
**故事2:监控授权状态**
|
||||
|
||||
```
|
||||
作为 授权管理员
|
||||
我想要 查看所有授权的使用状态
|
||||
以便 及时发现问题并提供支持
|
||||
|
||||
验收标准:
|
||||
- 我可以看到每个授权的在线状态
|
||||
- 我可以看到最后心跳时间
|
||||
- 我可以看到软件资源使用情况,比如用户数量等
|
||||
- 过期或异常的授权会高亮显示
|
||||
- 我可以筛选查看特定状态的授权
|
||||
```
|
||||
**故事3:处理授权过期**
|
||||
|
||||
```
|
||||
作为 授权管理员
|
||||
我想要 在授权即将过期时收到提醒
|
||||
以便 提前联系客户续费
|
||||
|
||||
验收标准:
|
||||
- 授权过期前30天、7天、1天自动提醒
|
||||
- 我可以看到即将过期的授权列表
|
||||
- 我可以为客户延长授权期限并非重新生成一个授权
|
||||
- 我可以生成续费用的新授权
|
||||
```
|
||||
|
||||
|
||||
### 👨💼 **客户/最终用户角色**
|
||||
|
||||
|
||||
**故事4:部署授权**
|
||||
|
||||
```
|
||||
作为 IoT平台客户
|
||||
我想要 在我的服务器上部署授权
|
||||
以便 开始使用IoT平台服务
|
||||
|
||||
验收标准:
|
||||
- 系统启动时自动验证授权有效性
|
||||
- 授权验证失败时有清晰的错误提示
|
||||
- 我可以看到授权的基本信息(如过期时间)
|
||||
```
|
||||
**故事5:监控自己的授权**
|
||||
|
||||
```
|
||||
作为 IoT平台客户
|
||||
我想要 了解我的授权使用情况
|
||||
以便 合理规划设备接入和功能使用
|
||||
|
||||
验收标准:
|
||||
- 我可以看到授权剩余天数
|
||||
- 我可以看到已使用的设备数量和限制
|
||||
- 我可以看到可用的功能模块
|
||||
- 接近限制时收到提醒
|
||||
- 我知道如何联系续费或扩容
|
||||
```
|
||||
### 🔧 **系统运维角色**
|
||||
|
||||
|
||||
**故事6:处理授权异常**
|
||||
|
||||
```
|
||||
作为 系统运维人员
|
||||
我想要 快速诊断和解决授权相关问题
|
||||
以便 确保客户服务不中断
|
||||
|
||||
验收标准:
|
||||
- 我可以查看授权验证的详细日志
|
||||
- 我可以看到硬件指纹匹配情况
|
||||
- 我可以临时禁用或启用授权
|
||||
- 我可以重置授权的硬件绑定
|
||||
- 我有权限进行紧急授权延期
|
||||
```
|
||||
### 💼 **销售/客服角色**
|
||||
|
||||
|
||||
**故事7:客户咨询处理**
|
||||
|
||||
```
|
||||
作为 销售/客服人员
|
||||
我想要 快速查询客户的授权信息
|
||||
以便 准确回答客户问题并提供服务
|
||||
|
||||
验收标准:
|
||||
- 我可以通过客户名称快速找到授权
|
||||
- 我可以看到授权的详细信息和状态
|
||||
- 我可以查看客户的授权历史记录
|
||||
- 我可以生成授权使用报告
|
||||
- 我知道如何为客户申请授权变更
|
||||
```
|
||||
**故事8:续费商机识别**
|
||||
|
||||
```
|
||||
作为 销售人员
|
||||
我想要 识别即将到期的授权客户
|
||||
以便 主动联系促成续费
|
||||
|
||||
验收标准:
|
||||
- 我可以看到30天内过期的授权列表
|
||||
- 我可以看到客户的授权使用活跃度
|
||||
- 我可以看到客户的设备使用情况
|
||||
- 系统提示扩容或升级的商机
|
||||
- 我可以导出客户联系信息进行跟进
|
||||
```
|
||||
|
||||
### 🚨 **紧急安全场景**
|
||||
|
||||
|
||||
**故事1:发现安全威胁时远程锁定**
|
||||
|
||||
```
|
||||
作为 安全管理员
|
||||
我想要 在发现客户环境存在安全威胁时远程锁定软件
|
||||
以便 防止数据泄露或系统被恶意使用
|
||||
|
||||
验收标准:
|
||||
- 我可以在管理后台选择特定授权进行锁定
|
||||
- 锁定后客户端软件立即停止所有功能
|
||||
- 客户端显示"系统已被远程锁定,请联系技术支持"
|
||||
- 我可以设置锁定原因(安全威胁/违规使用/紧急维护)
|
||||
- 锁定操作会记录日志和通知相关人员
|
||||
- 我可以随时解除锁定恢复正常服务
|
||||
```
|
||||
### 💰 **商业违约场景**
|
||||
|
||||
|
||||
**故事2:客户违约时执行锁定**
|
||||
|
||||
```
|
||||
作为 业务管理员
|
||||
我想要 在客户违反合同条款时远程锁定软件
|
||||
以便 保护公司商业利益并促使客户履约
|
||||
|
||||
验收标准:
|
||||
- 我可以设置锁定生效时间(立即/定时)
|
||||
- 客户端显示违约原因和解决方式
|
||||
- 我可以设置部分功能锁定(如只锁定高级功能)
|
||||
- 客户付款后我可以快速解锁
|
||||
- 整个过程有完整的操作记录
|
||||
```
|
||||
### 🔧 **技术维护场景**
|
||||
|
||||
|
||||
**故事3:系统维护时临时锁定**
|
||||
|
||||
```
|
||||
作为 系统运维人员
|
||||
我想要 在进行重要系统升级时临时锁定客户端
|
||||
以便 确保升级过程中数据一致性和系统稳定
|
||||
|
||||
验收标准:
|
||||
- 我可以批量锁定所有或特定客户的软件
|
||||
- 客户端显示"系统维护中,预计X小时后恢复"
|
||||
- 我可以设置维护窗口和预计恢复时间
|
||||
- 维护完成后可以批量解锁
|
||||
- 客户可以看到维护进度和预计完成时间
|
||||
```
|
||||
### 🏢 **客户端场景**
|
||||
|
||||
|
||||
**故事4:客户收到锁定通知**
|
||||
|
||||
```
|
||||
作为 IoT平台用户
|
||||
我想要 在软件被锁定时了解原因和解决方法
|
||||
以便 快速恢复正常使用
|
||||
|
||||
验收标准:
|
||||
- 软件被锁定时我收到清晰的提示信息
|
||||
- 我可以看到锁定原因(技术/商务/安全)
|
||||
- 我可以看到联系方式和解决步骤
|
||||
- 对于计划性维护,我能看到预计恢复时间
|
||||
- 我可以通过软件界面直接联系技术支持
|
||||
- 我可以查看我的历史锁定记录
|
||||
```
|
||||
### 🎯 **精细化控制场景**
|
||||
|
||||
|
||||
**故事5:功能级别锁定**
|
||||
|
||||
```
|
||||
作为 产品管理员
|
||||
我想要 对违规客户进行部分功能锁定
|
||||
以便 在保持基本服务的同时限制高级功能
|
||||
|
||||
验收标准:
|
||||
- 我可以选择锁定特定功能模块(如数据导出、API调用)
|
||||
- 我可以设置设备数量限制(如从100台降到50台)
|
||||
- 客户端清楚显示哪些功能被限制
|
||||
- 我可以逐步解锁功能作为激励措施
|
||||
- 客户可以看到如何恢复完整功能的说明
|
||||
```
|
||||
|
||||
|
||||
### 🔧 **授权管理员角色**
|
||||
|
||||
|
||||
**故事:为不同客户选择合适的加密方式**
|
||||
|
||||
```
|
||||
作为 授权管理员
|
||||
我想要 在生成授权时选择使用标准加密或高级加密
|
||||
以便 满足不同客户的安全需求
|
||||
|
||||
验收标准:
|
||||
- 我可以在生成授权页面选择加密类型:
|
||||
• 标准加密 (HMAC-SHA256) - 适用于一般客户
|
||||
• 高级加密 (RSA-2048) - 适用于安全要求高的客户
|
||||
- 系统显示每种加密方式的简单说明
|
||||
- 生成的授权码会标识使用的加密类型
|
||||
- 客户端能够自动识别并使用对应的解密方式
|
||||
- 我可以在授权列表中看到每个授权使用的加密类型
|
||||
```
|
||||
|
||||
|
||||
|
||||
|
||||
----
|
||||
@@ -0,0 +1,361 @@
|
||||
# 授权管理系统 - 产品原型设计方案
|
||||
|
||||
## 📋 文档概述
|
||||
|
||||
**目标用户**: 产品经理、UI/UX设计师
|
||||
**文档目的**: 为授权管理系统的原型设计和交互设计提供详细指导
|
||||
**适用范围**: 管理后台、客户端授权验证流程
|
||||
|
||||
---
|
||||
|
||||
## 🎯 产品定位
|
||||
|
||||
### 核心价值
|
||||
- **统一授权管理** - 为多个软件产品提供统一的授权管理平台
|
||||
- **灵活配置** - 支持不同软件的个性化授权规则
|
||||
- **安全可靠** - 基于硬件指纹的防盗版机制
|
||||
- **离线支持** - 许可证文件支持完全离线运行
|
||||
|
||||
### 目标用户群体
|
||||
1. **系统管理员** - 管理授权码、监控使用情况
|
||||
2. **销售人员** - 为客户创建和配置授权
|
||||
3. **客服人员** - 处理客户授权问题和故障排查
|
||||
4. **最终用户** - 软件激活和使用
|
||||
|
||||
---
|
||||
|
||||
## 🚀 核心业务流程
|
||||
|
||||
### 1. 授权码生命周期
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[创建授权码] --> B[配置权限和限制]
|
||||
B --> C[分发给客户]
|
||||
C --> D[客户激活软件]
|
||||
D --> E[生成许可证]
|
||||
E --> F[软件正常运行]
|
||||
F --> G{配置需要更新?}
|
||||
G -->|是| H[推送新配置]
|
||||
G -->|否| I[定期心跳检查]
|
||||
H --> F
|
||||
I --> F
|
||||
|
||||
J[授权过期] --> K[软件停止功能]
|
||||
F --> J
|
||||
|
||||
L[违规使用] --> M[锁定授权]
|
||||
F --> L
|
||||
```
|
||||
---
|
||||
|
||||
## 📱 页面结构设计
|
||||
|
||||
### 导航结构
|
||||
|
||||
```
|
||||
授权管理系统
|
||||
├── 仪表板 (Dashboard)
|
||||
│ ├── 概览统计
|
||||
│ ├── 近期活动
|
||||
│ └── 告警信息
|
||||
├── 授权码管理
|
||||
│ ├── 授权码列表
|
||||
│ ├── 创建授权码
|
||||
│ ├── 授权码详情
|
||||
│ └── 批量操作
|
||||
├── 许可证管理
|
||||
│ ├── 许可证列表
|
||||
│ ├── 许可证详情
|
||||
│ ├── 手动添加许可证
|
||||
│ └── 设备管理
|
||||
├── 客户管理
|
||||
│ ├── 客户列表
|
||||
│ ├── 客户详情
|
||||
│ └── 客户授权概览
|
||||
├── 报表分析
|
||||
│ ├── 使用情况统计
|
||||
│ ├── 激活趋势分析
|
||||
│ └── 收入分析
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎨 关键页面设计要求
|
||||
|
||||
### 1. 仪表板页面
|
||||
|
||||
**布局要求:**
|
||||
- 采用卡片式布局,4列网格排列
|
||||
- 顶部显示核心指标(总授权数、活跃许可证、过期警告等)
|
||||
- 中部显示图表(激活趋势、使用情况分布)
|
||||
- 底部显示近期活动日志
|
||||
|
||||
**核心指标卡片:**
|
||||
```
|
||||
┌─────────────┬─────────────┬─────────────┬─────────────┐
|
||||
│ 总授权码数量 │ 活跃许可证 │ 即将过期 │ 异常告警 │
|
||||
│ 1,234 │ 856 │ 23 │ 5 │
|
||||
│ ↑ 5.2% │ ↑ 3.1% │ ↓ 12% │ ↑ 2 │
|
||||
└─────────────┴─────────────┴─────────────┴─────────────┘
|
||||
```
|
||||
|
||||
**交互要求:**
|
||||
- 指标卡片可点击跳转到详情页面
|
||||
- 图表支持时间范围筛选(7天/30天/90天)
|
||||
- 实时数据更新(每5分钟刷新)
|
||||
|
||||
### 2. 授权码管理页面
|
||||
|
||||
**列表页面字段:**
|
||||
| 字段 | 显示名称 | 宽度 | 操作 |
|
||||
|-----|---------|------|------|
|
||||
| code | 授权码 | 120px | 复制 |
|
||||
| customer_name | 客户名称 | 150px | 点击查看 |
|
||||
| status | 状态 | 80px | 状态标签 |
|
||||
| activated/max | 激活情况 | 100px | 进度条 |
|
||||
| end_date | 到期时间 | 120px | 倒计时 |
|
||||
| remark | 备注说明 | 100px | - |
|
||||
| actions | 操作 | 120px | 按钮组 |
|
||||
|
||||
**状态标识设计:**
|
||||
- 🟢 正常 (normal) - 绿色标签
|
||||
- 🔒 已锁定 (locked) - 红色标签
|
||||
- ⏰ 已过期 (expired) - 灰色标签
|
||||
|
||||
**筛选器设计:**
|
||||
```
|
||||
┌─ 筛选条件 ────────────────────────────────────────┐
|
||||
│ 客户: [下拉选择] 状态: [多选] │
|
||||
│ 创建时间: [日期范围] 到期时间: [日期范围] │
|
||||
│ [重置] [搜索] │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 3. 授权码详情页面
|
||||
|
||||
**页面布局:**
|
||||
```
|
||||
┌─ 基础信息 ─────────────────┬─ 配置信息 ─────────────────┐
|
||||
│ 授权码: ABC123 │ 开始时间: 2024-01-01 │
|
||||
│ 客户: 张三公司 │ 结束时间: 2024-12-31 │
|
||||
│ │ 最大激活数: 10 │
|
||||
│ 状态: 🟢 正常 │ 当前激活: 7 │
|
||||
│ 创建时间: 2024-01-01 │ 部署类型: 单机版 │
|
||||
│ 创建人: 李四 │备注说明: xxxx
|
||||
└───────────────────────────┴───────────────────────────┘
|
||||
|
||||
┌─ 功能配置 ─────────────────────────────────────────────┐
|
||||
│ { │
|
||||
│ "modules": ["user_mgmt", "inventory", "finance"], │
|
||||
│ "max_users": 100, │
|
||||
│ "api_calls_per_day": 10000 │
|
||||
│ } │
|
||||
│ [编辑配置] │
|
||||
└───────────────────────────────────────────────────────┘
|
||||
|
||||
┌─ 许可证列表 ───────────────────────────────────────────┐
|
||||
│ 设备1 | 192.168.1.100 | 最后心跳: 2分钟前 | 🟢在线 │
|
||||
│ 设备2 | 192.168.1.101 | 最后心跳: 1小时前 | 🔴离线 │
|
||||
│ 设备3 | 192.168.1.102 | 最后心跳: 5分钟前 | 🟢在线 │
|
||||
│ [查看更多] │
|
||||
└───────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**操作按钮设计:**
|
||||
- **主要操作:** [编辑配置] [延期续费] [导出许可证]
|
||||
- **次要操作:** [锁定/解锁授权] [查看日志] [复制授权码]
|
||||
- **危险操作:** [删除授权] - 需要二次确认
|
||||
|
||||
### 4. 创建授权码页面
|
||||
|
||||
**表单步骤设计:**
|
||||
```
|
||||
步骤1: 基础信息 → 步骤2: 授权配置 → 步骤3: 功能设置 → 步骤4: 确认创建
|
||||
🔵 ⚪ ⚪ ⚪
|
||||
```
|
||||
|
||||
**授权码生成规则:**
|
||||
- 格式: `LIC-{customer_code}-{random}-{checksum}`
|
||||
- 示例: `LIC-COMP001-A7B9X2-C8F4`
|
||||
- 由后端自动生成,确保唯一性和安全性
|
||||
|
||||
**步骤1 - 基础信息:**
|
||||
```
|
||||
┌─ 基础信息 ─────────────────────────────────────────┐
|
||||
│ * 客户选择: [下拉搜索框] [+ 新建客户] │
|
||||
│ 授权码: [提交后自动生成] │
|
||||
│ 备注说明: [多行文本框] │
|
||||
└───────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**步骤2 - 授权配置:**
|
||||
```
|
||||
┌─ 授权配置 ─────────────────────────────────────────┐
|
||||
│ * 授权期限: │
|
||||
│ ○ 永久授权 │
|
||||
│ ● 限期授权 时间范围 │
|
||||
│ │
|
||||
│ * 最大激活数: [数字输入框] 台设备 │
|
||||
│ │
|
||||
│ * 部署类型: │
|
||||
│ ● 单机版 ○ 云端版 ○ 混合版 │
|
||||
│ │
|
||||
│ 加密类型: ● 标准加密 ○ 高级加密 │
|
||||
└───────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**步骤3 - 功能设置:**
|
||||
```
|
||||
|
||||
MVP版本使用json编辑器即可
|
||||
┌─ 功能模块 ─────────────────────────────────────────┐
|
||||
│ [JSON编辑器或键值对编辑器] │
|
||||
│ 键: [输入框] 值: [输入框] [+ 添加] [- 删除] │
|
||||
└───────────────────────────────────────────────────┘
|
||||
|
||||
┌─ 使用限制 ─────────────────────────────────────────┐
|
||||
│ [JSON编辑器或键值对编辑器] │
|
||||
│ 键: [输入框] 值: [输入框] [+ 添加] [- 删除] │
|
||||
└───────────────────────────────────────────────────┘
|
||||
|
||||
┌─ 自定义参数 ───────────────────────────────────────┐
|
||||
│ [JSON编辑器或键值对编辑器] │
|
||||
│ 键: [输入框] 值: [输入框] [+ 添加] [- 删除] │
|
||||
└───────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 5. 手动添加许可证页面
|
||||
|
||||
**适用场景:**
|
||||
- 离线部署环境
|
||||
- 客户提前提供硬件信息
|
||||
- 批量部署前预生成许可证
|
||||
|
||||
**表单设计:**
|
||||
```
|
||||
┌─ 许可证信息 ───────────────────────────────────────┐
|
||||
│ * 授权码: [下拉选择框 - 显示可用授权码] │
|
||||
│ * 硬件指纹: [文本输入框] │
|
||||
│ 示例: CPU:ABC123,MB:DEF456,MAC:00:11:22:33:44:55│
|
||||
│ │
|
||||
│ 设备信息: [JSON编辑器] (可选) │
|
||||
│ { │
|
||||
│ "cpu": "Intel i7-8700", │
|
||||
│ "memory": "16GB", │
|
||||
│ "disk": "512GB SSD", │
|
||||
│ "os": "Windows 10 Pro" │
|
||||
│ } │
|
||||
│ │
|
||||
│ 激活IP: [IP输入框] (可选) │
|
||||
│ 备注: [多行文本框] │
|
||||
│ │
|
||||
│ [取消] [生成许可证文件] │
|
||||
└───────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
|
||||
**操作流程:**
|
||||
```
|
||||
[选择授权码] → [输入硬件信息] → [验证格式] → [生成许可证] → [下载许可证文件]
|
||||
↓ ↓ ↓ ↓ ↓
|
||||
检查激活限制 解析硬件指纹 格式校验 更新数据库 提供文件下载
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 状态与交互流程
|
||||
|
||||
### 1. 授权状态流转
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> 正常
|
||||
正常 --> 已锁定: 管理员锁定/违规检测
|
||||
正常 --> 已过期: 到期时间
|
||||
已锁定 --> 正常: 管理员解锁
|
||||
已过期 --> 正常: 续费延期
|
||||
已过期 --> [*]: 删除授权
|
||||
已锁定 --> [*]: 删除授权
|
||||
```
|
||||
|
||||
### 2. 许可证状态流转
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> 未激活: 创建许可证
|
||||
未激活 --> 激活: 软件首次连接
|
||||
激活 --> 已撤销: 设备更换/违规/管理员撤销
|
||||
已撤销 --> [*]: 清理记录
|
||||
|
||||
激活 --> 在线: 心跳正常
|
||||
激活 --> 离线: 心跳超时
|
||||
在线 --> 离线: 心跳中断
|
||||
离线 --> 在线: 心跳恢复
|
||||
```
|
||||
|
||||
### 3. 关键操作确认流程
|
||||
|
||||
**修改授权配置:**
|
||||
```
|
||||
[编辑配置] → [确认弹窗] → [填写变更信息] → [最终确认] → [执行修改]
|
||||
↓ ↓
|
||||
确认修改对话框 变更类型选择
|
||||
变更原因(可选)
|
||||
```
|
||||
|
||||
**弹窗设计:**
|
||||
```
|
||||
┌─ 确认修改授权配置 ─────────────────────────────────────┐
|
||||
│ │
|
||||
│ 您即将修改授权码 ABC123 的配置,此操作将影响 │
|
||||
│ 7 个已激活的许可证设备。 │
|
||||
│ │
|
||||
│ * 变更类型: │
|
||||
│ ○ renewal续费 ○ upgrade升级 ○ limit_change限制调整│
|
||||
│ ○ feature_toggle功能开关 ○ lock锁定 ○ unlock解锁 │
|
||||
│ ○ other其他 │
|
||||
│ │
|
||||
│ 变更原因: [多行文本框 - 非必填] │
|
||||
│ ┌─────────────────────────────────────────────────┐ │
|
||||
│ │ 例如:客户要求增加用户数限制... │ │
|
||||
│ │ 或填写锁定原因等自定义说明 │ │
|
||||
│ └─────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ [取消] [确认修改] │
|
||||
└───────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 📊 数据展示要求
|
||||
|
||||
### 1. 列表页面分页
|
||||
|
||||
**分页控件要求:**
|
||||
- 每页显示数量: 10/20/50/100 可选
|
||||
- 页面跳转: 首页/上页/下页/末页 + 页码输入
|
||||
- 总数显示: "共 1,234 条记录,第 1/25 页"
|
||||
|
||||
### 2. 日期时间显示
|
||||
|
||||
**标准格式:**
|
||||
- 完整日期时间: 2024-01-01 14:30:25
|
||||
- 相对时间: 2分钟前、1小时前、昨天、3天前
|
||||
- 倒计时: 还有15天到期、已过期3天
|
||||
|
||||
### 3. 状态指示器
|
||||
|
||||
**在线状态:**
|
||||
- 🟢 在线 (最近5分钟有心跳)
|
||||
- 🟡 离线 (5分钟-24小时无心跳)
|
||||
- 🔴 异常 (24小时以上无心跳)
|
||||
|
||||
**进度指示:**
|
||||
```
|
||||
激活情况: 7/10 [████████▒▒] 70%
|
||||
```
|
||||
|
||||
|
||||
这份文档为产品经理和设计师提供了详细的原型设计指导,涵盖了用户流程、页面布局、交互细节和技术要求。可以基于这份文档进行原型制作和界面设计。
|
||||
@@ -0,0 +1,175 @@
|
||||
# 授权管理模块属性设计
|
||||
|
||||
## 整体架构说明
|
||||
|
||||
授权管理采用两层架构模式:
|
||||
1. **授权码 (authorization_codes)** - 业务配置的容器,包含功能权限、使用限制等
|
||||
2. **许可证 (licenses)** - 硬件激活凭证,只表示哪台机器有权使用该授权
|
||||
|
||||
### 关系映射
|
||||
```
|
||||
授权码 (1) → 许可证 (N)
|
||||
```
|
||||
一个授权码定义业务规则,多个许可证表示可以在多台机器上使用
|
||||
|
||||
### 数据流向
|
||||
```
|
||||
授权码(业务配置模板) → 许可证(硬件绑定) → 软件本地许可证文件(包含完整配置) → 软件运行时使用
|
||||
```
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 1. 授权码表 (authorization_codes)
|
||||
|
||||
### 基础信息属性
|
||||
- `id` - 主键
|
||||
- `code` - 唯一授权码 (软件身份凭证,可重复使用)
|
||||
- `customer_id` - 关联客户ID
|
||||
- `created_by` - 创建人ID
|
||||
- `created_at/updated_at` - 时间戳
|
||||
- `software_id` - 目标软件产品ID(企业版)
|
||||
- `description` - 描述
|
||||
|
||||
### 授权核心属性
|
||||
- `start_date` - 授权开始时间
|
||||
- `end_date` - 授权结束时间
|
||||
- `deployment_type` - 部署类型 (standalone单机版/cloud云端版/hybrid混合版)
|
||||
- `encryption_type` - 加密类型 (standard标准加密/advanced高级加密)(企业版)
|
||||
- `software_version` - 支持的软件版本范围(企业版)
|
||||
|
||||
### 激活控制属性
|
||||
- `max_activations` - 最大许可证数量
|
||||
- `current_activations` - 当前已激活数量(可通过关联查询获得)
|
||||
|
||||
### 状态管理属性
|
||||
- (虚字段-不出现在表中)`status` - 授权状态 (normal正常/locked已锁定/expired已过期)
|
||||
- **SQL 计算逻辑**:
|
||||
```sql
|
||||
CASE
|
||||
WHEN is_locked = true THEN 'locked'
|
||||
WHEN end_date < NOW() THEN 'expired'
|
||||
WHEN start_date <= NOW() AND end_date >= NOW() THEN 'normal'
|
||||
ELSE 'expired'
|
||||
END AS status
|
||||
```
|
||||
- **优先级**: locked > expired > normal
|
||||
- **筛选实现**: 在 WHERE 子句中使用相同逻辑进行条件判断
|
||||
- `is_locked` - 是否被锁定 (布尔字段)
|
||||
- `lock_reason` - 锁定原因 (自定义文本输入)
|
||||
- `locked_at` - 锁定时间
|
||||
- `locked_by` - 锁定操作人
|
||||
|
||||
### 通用功能控制属性
|
||||
- `feature_config` - 功能配置 (JSON格式,软件产品基础功能)
|
||||
- `usage_limits` - 使用量限制 (JSON格式,如用户数、API调用次数等)
|
||||
- `custom_parameters` - 自定义参数 (JSON格式,软件特定功能或用户定制功能)
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 2. 许可证表 (licenses)
|
||||
|
||||
### 基础信息属性
|
||||
- `id` - 主键
|
||||
- `license_key` - 许可证密钥 (设备特定)
|
||||
- `authorization_code_id` - 关联的授权码 (外键)
|
||||
- `customer_id` - 客户ID (冗余字段,便于查询)
|
||||
- `created_at/updated_at` - 时间戳
|
||||
|
||||
### 硬件绑定属性
|
||||
- `hardware_fingerprint` - 绑定的硬件指纹
|
||||
- `device_info` - 设备信息 (JSON格式,包含CPU、内存等)
|
||||
- `activation_ip` - 激活时的IP地址
|
||||
|
||||
### 激活状态属性
|
||||
- `status` - 许可证状态 (active激活/inactive未激活/revoked已撤销)
|
||||
- 硬件更换 - 用户换了新设备,需要撤销旧设备的许可证
|
||||
- 设备丢失/损坏 - 设备不再使用,主动释放许可证名额
|
||||
- 违规使用 - 发现设备被盗用或滥用,管理员撤销
|
||||
- `activated_at` - 激活时间
|
||||
- `last_heartbeat` - 最后心跳时间
|
||||
- (虚字段)`is_online` - 在线状态
|
||||
- `last_online_ip` - 最后在线IP
|
||||
|
||||
### 配置同步属性
|
||||
- `config_updated_at` - 配置更新时间
|
||||
|
||||
### 使用统计属性
|
||||
- `usage_data` - 使用数据 (JSON格式,软件上报的统计信息)
|
||||
|
||||
---
|
||||
|
||||
## 3. 授权变更历史表 (authorization_changes)
|
||||
|
||||
当修改授权的时候,需要用户确认并选择变更类型和填写变更原因(非必输)
|
||||
|
||||
### 基础属性
|
||||
- `id` - 主键
|
||||
- `authorization_code_id` - 授权码ID
|
||||
- `change_type` - 变更类型 (renewal续费/upgrade升级/limit_change限制调整/feature_toggle功能开关/lock锁定/unlock解锁/other其他)
|
||||
- `old_config` - 变更前配置 (JSON)
|
||||
- `new_config` - 变更后配置 (JSON)
|
||||
- `operator_id` - 操作人ID
|
||||
- `reason` - 变更原因
|
||||
- `created_at` - 记录创建时间
|
||||
|
||||
---
|
||||
|
||||
## 软件运行机制
|
||||
|
||||
### 启动流程
|
||||
1. **读取许可证文件**: 加载并解密本地许可证文件,验证数字签名防止篡改
|
||||
2. **配置检查**:
|
||||
- 如果联网,比较许可证内的 `config_updated_at` 与服务端 `updated_at`
|
||||
- 如有差异则下载新的加密许可证文件替换本地文件
|
||||
3. **配置验证**: 检查许可证内的授权有效期和功能权限
|
||||
4. **应用配置**: 根据许可证内的配置启用/禁用功能模块
|
||||
5. **开始运行**: 按许可证配置限制控制资源使用
|
||||
|
||||
### 心跳流程
|
||||
1. **发送心跳**: 携带授权码 + 硬件指纹 + 使用统计 + 许可证内的配置更新时间
|
||||
2. **状态验证**: 服务端验证授权码和许可证状态
|
||||
3. **配置比较**: 比较客户端上报的配置更新时间与服务端当前时间
|
||||
4. **返回响应**:
|
||||
- 如配置有更新:返回新的加密许可证文件
|
||||
- 无更新:仅返回状态确认和下次心跳间隔
|
||||
5. **更新许可证**: 如有配置更新,客户端替换本地许可证文件
|
||||
|
||||
### 许可证生成逻辑
|
||||
1. **请求验证**: 软件发送授权码 + 硬件指纹
|
||||
2. **授权码检查**: 验证授权码有效性和状态
|
||||
3. **许可证查找**: 检查该硬件是否已有许可证
|
||||
4. **数量限制检查**: 如果是新硬件,检查当前激活数 < max_activations
|
||||
5. **许可证生成**: 生成包含完整配置的加密许可证文件
|
||||
6. **返回数据**: 返回加密的许可证文件
|
||||
|
||||
### 配置同步机制
|
||||
1. **管理员修改授权码配置**: 更新authorization_codes表,同时更新 `updated_at` 时间戳
|
||||
2. **客户端检测**: 通过心跳或启动时比较许可证内的配置更新时间
|
||||
3. **许可证更新**: 发现配置有更新时,客户端下载新的加密许可证文件
|
||||
4. **文件替换**: 用新的许可证文件替换本地文件
|
||||
5. **应用新配置**: 软件重新加载许可证文件中的配置
|
||||
|
||||
### 并发控制
|
||||
```sql
|
||||
-- 原子性操作:检查并生成许可证
|
||||
BEGIN TRANSACTION;
|
||||
SELECT COUNT(*) FROM licenses WHERE authorization_code = ? AND status = 'active';
|
||||
-- 如果未超限,则INSERT新许可证
|
||||
INSERT INTO licenses (license_key, authorization_code, hardware_fingerprint, config_updated_at, ...) VALUES (...);
|
||||
COMMIT;
|
||||
```
|
||||
|
||||
### 离线运行支持
|
||||
1. **自包含许可证**: 许可证文件包含完整的加密配置信息
|
||||
2. **有效期检查**: 软件自行验证许可证内的授权是否在有效期内
|
||||
3. **功能控制**: 根据许可证文件中的配置控制功能可用性
|
||||
4. **网络恢复**: 连网后通过心跳自动检查并更新许可证文件
|
||||
|
||||
### 业务场景支持
|
||||
1. **多机部署** - 同一授权码在不同硬件上自动激活
|
||||
2. **离线运行** - 软件使用许可证文件完全离线运行
|
||||
3. **配置更新** - 授权码配置变更,心跳时推送新许可证文件给所有激活设备
|
||||
4. **数量控制** - 通过max_activations控制并发许可证数量
|
||||
5. **硬件更换** - 新硬件指纹触发新许可证生成
|
||||
+3527
-3
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -5,6 +5,7 @@ go 1.23
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||
github.com/google/uuid v1.4.0
|
||||
github.com/sirupsen/logrus v1.9.3
|
||||
github.com/spf13/viper v1.19.0
|
||||
github.com/swaggo/files v1.0.1
|
||||
@@ -28,6 +29,7 @@ require (
|
||||
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/go-ole/go-ole v1.2.6 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.19.5 // indirect
|
||||
github.com/go-openapi/jsonreference v0.19.6 // indirect
|
||||
github.com/go-openapi/spec v0.20.4 // indirect
|
||||
@@ -44,6 +46,7 @@ require (
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
||||
github.com/magiconair/properties v1.8.7 // indirect
|
||||
github.com/mailru/easyjson v0.7.6 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
@@ -51,15 +54,21 @@ require (
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
|
||||
github.com/sagikazarmark/locafero v0.4.0 // indirect
|
||||
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
|
||||
github.com/shirou/gopsutil/v3 v3.24.5 // indirect
|
||||
github.com/shoenig/go-m1cpu v0.1.6 // indirect
|
||||
github.com/sourcegraph/conc v0.3.0 // indirect
|
||||
github.com/spf13/afero v1.11.0 // indirect
|
||||
github.com/spf13/cast v1.6.0 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.12 // indirect
|
||||
github.com/tklauser/numcpus v0.6.1 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
go.uber.org/atomic v1.9.0 // indirect
|
||||
go.uber.org/multierr v1.9.0 // indirect
|
||||
golang.org/x/arch v0.8.0 // indirect
|
||||
|
||||
@@ -31,6 +31,8 @@ github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
|
||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||
github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
|
||||
github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY=
|
||||
github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
|
||||
@@ -55,9 +57,12 @@ github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4=
|
||||
github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
|
||||
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
@@ -81,6 +86,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
|
||||
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
|
||||
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
||||
@@ -103,12 +110,18 @@ github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
|
||||
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
|
||||
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
|
||||
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
|
||||
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
|
||||
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
|
||||
github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
|
||||
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
|
||||
@@ -142,11 +155,17 @@ github.com/swaggo/gin-swagger v1.6.0 h1:y8sxvQ3E20/RCyrXeFfg60r6H0Z+SwpTjMYsMm+z
|
||||
github.com/swaggo/gin-swagger v1.6.0/go.mod h1:BG00cCEy294xtVpyIAHG6+e2Qzj/xKlRdOqDkvq0uzo=
|
||||
github.com/swaggo/swag v1.16.3 h1:PnCYjPCah8FK4I26l2F/KQ4yz3sILcVUN3cTlBFA9Pg=
|
||||
github.com/swaggo/swag v1.16.3/go.mod h1:DImHIuOFXKpMFAQjcC7FG4m3Dg4+QuUgUzJmKjI/gRk=
|
||||
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
|
||||
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
|
||||
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
|
||||
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
|
||||
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
|
||||
@@ -175,7 +194,9 @@ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJ
|
||||
golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ=
|
||||
golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
@@ -183,6 +204,8 @@ golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
|
||||
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
@@ -201,6 +224,7 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
|
||||
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
||||
@@ -47,7 +47,10 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
data, err := h.authService.Login(&req)
|
||||
// 获取客户端IP
|
||||
clientIP := c.ClientIP()
|
||||
|
||||
data, err := h.authService.Login(c.Request.Context(), &req, clientIP)
|
||||
if err != nil {
|
||||
errorCode := "100003" // 登录失败
|
||||
if err.Error() == "配置未初始化" {
|
||||
@@ -67,7 +70,7 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
||||
lang := middleware.GetLanguage(c)
|
||||
successMessage := i18n.GetErrorMessage("000000", lang)
|
||||
c.JSON(http.StatusOK, models.LoginResponse{
|
||||
Code: http.StatusOK,
|
||||
Code: "000000",
|
||||
Message: successMessage,
|
||||
Data: data,
|
||||
})
|
||||
@@ -90,7 +93,7 @@ func (h *AuthHandler) Logout(c *gin.Context) {
|
||||
lang := middleware.GetLanguage(c)
|
||||
successMessage := i18n.GetErrorMessage("000000", lang)
|
||||
c.JSON(http.StatusOK, models.APIResponse{
|
||||
Code: http.StatusOK,
|
||||
Code: "000000",
|
||||
Message: successMessage,
|
||||
})
|
||||
}
|
||||
@@ -141,7 +144,7 @@ func (h *AuthHandler) RefreshToken(c *gin.Context) {
|
||||
lang := middleware.GetLanguage(c)
|
||||
successMessage := i18n.GetErrorMessage("000000", lang)
|
||||
c.JSON(http.StatusOK, models.APIResponse{
|
||||
Code: http.StatusOK,
|
||||
Code: "000000",
|
||||
Message: successMessage,
|
||||
Data: map[string]interface{}{
|
||||
"token": newToken,
|
||||
|
||||
@@ -0,0 +1,497 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"license-manager/internal/api/middleware"
|
||||
"license-manager/internal/models"
|
||||
"license-manager/internal/service"
|
||||
"license-manager/pkg/i18n"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type AuthorizationCodeHandler struct {
|
||||
authCodeService service.AuthorizationCodeService
|
||||
}
|
||||
|
||||
// NewAuthorizationCodeHandler 创建授权码处理器
|
||||
func NewAuthorizationCodeHandler(authCodeService service.AuthorizationCodeService) *AuthorizationCodeHandler {
|
||||
return &AuthorizationCodeHandler{
|
||||
authCodeService: authCodeService,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateAuthorizationCode 创建授权码
|
||||
// @Summary 创建授权码
|
||||
// @Description 创建新的授权码,自动生成授权码字符串。有效期从当天00:00:00开始,到指定天数后的23:59:59结束
|
||||
// @Tags 授权码管理
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param authorization_code body models.AuthorizationCodeCreateRequest true "授权码信息"
|
||||
// @Success 200 {object} models.APIResponse{data=models.AuthorizationCodeCreateResponse} "创建成功"
|
||||
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
|
||||
// @Failure 401 {object} models.ErrorResponse "未认证"
|
||||
// @Failure 404 {object} models.ErrorResponse "客户不存在"
|
||||
// @Failure 409 {object} models.ErrorResponse "授权码已存在"
|
||||
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
|
||||
// @Router /api/v1/authorization-codes [post]
|
||||
func (h *AuthorizationCodeHandler) CreateAuthorizationCode(c *gin.Context) {
|
||||
var req models.AuthorizationCodeCreateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
lang := middleware.GetLanguage(c)
|
||||
status, errCode, message := i18n.NewI18nErrorResponse("900001", lang)
|
||||
c.JSON(status, models.ErrorResponse{
|
||||
Code: errCode,
|
||||
Message: message + ": " + err.Error(),
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 直接传递 gin.Context
|
||||
data, err := h.authCodeService.CreateAuthorizationCode(c, &req)
|
||||
if err != nil {
|
||||
// 错误已经在Service层完全包装好了,直接使用
|
||||
var i18nErr *i18n.I18nError
|
||||
if errors.As(err, &i18nErr) {
|
||||
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
|
||||
Code: i18nErr.Code,
|
||||
Message: i18nErr.Message,
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
} else {
|
||||
// 兜底:理论上不应该到这里,因为Service层应该返回I18nError
|
||||
lang := middleware.GetLanguage(c)
|
||||
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang)
|
||||
c.JSON(status, models.ErrorResponse{
|
||||
Code: errCode,
|
||||
Message: message,
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
lang := middleware.GetLanguage(c)
|
||||
successMessage := i18n.GetErrorMessage("000000", lang)
|
||||
c.JSON(http.StatusOK, models.APIResponse{
|
||||
Code: "000000",
|
||||
Message: successMessage,
|
||||
Data: data,
|
||||
})
|
||||
}
|
||||
|
||||
// GetAuthorizationCodeList 查询授权码列表
|
||||
// @Summary 查询授权码列表
|
||||
// @Description 分页查询授权码列表,支持筛选和排序
|
||||
// @Tags 授权码管理
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param page query int false "页码,默认1" minimum(1)
|
||||
// @Param page_size query int false "每页条数,默认20,最大100" minimum(1) maximum(100)
|
||||
// @Param customer_id query string false "客户ID筛选"
|
||||
// @Param status query string false "状态筛选" Enums(normal, locked, expired)
|
||||
// @Param start_date query string false "创建开始时间"
|
||||
// @Param end_date query string false "创建结束时间"
|
||||
// @Param sort query string false "排序字段,默认created_at" Enums(created_at, updated_at, code)
|
||||
// @Param order query string false "排序方向,默认desc" Enums(asc, desc)
|
||||
// @Success 200 {object} models.APIResponse{data=models.AuthorizationCodeListResponse} "查询成功"
|
||||
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
|
||||
// @Failure 401 {object} models.ErrorResponse "未认证"
|
||||
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
|
||||
// @Router /api/v1/authorization-codes [get]
|
||||
func (h *AuthorizationCodeHandler) GetAuthorizationCodeList(c *gin.Context) {
|
||||
var req models.AuthorizationCodeListRequest
|
||||
if err := c.ShouldBindQuery(&req); err != nil {
|
||||
lang := middleware.GetLanguage(c)
|
||||
status, errCode, message := i18n.NewI18nErrorResponse("900001", lang)
|
||||
c.JSON(status, models.ErrorResponse{
|
||||
Code: errCode,
|
||||
Message: message + ": " + err.Error(),
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 直接传递 gin.Context
|
||||
data, err := h.authCodeService.GetAuthorizationCodeList(c, &req)
|
||||
if err != nil {
|
||||
// 错误已经在Service层完全包装好了,直接使用
|
||||
var i18nErr *i18n.I18nError
|
||||
if errors.As(err, &i18nErr) {
|
||||
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
|
||||
Code: i18nErr.Code,
|
||||
Message: i18nErr.Message,
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
} else {
|
||||
// 兜底:理论上不应该到这里,因为Service层应该返回I18nError
|
||||
lang := middleware.GetLanguage(c)
|
||||
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang)
|
||||
c.JSON(status, models.ErrorResponse{
|
||||
Code: errCode,
|
||||
Message: message,
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
lang := middleware.GetLanguage(c)
|
||||
successMessage := i18n.GetErrorMessage("000000", lang)
|
||||
c.JSON(http.StatusOK, models.APIResponse{
|
||||
Code: "000000",
|
||||
Message: successMessage,
|
||||
Data: data,
|
||||
})
|
||||
}
|
||||
|
||||
// GetAuthorizationCode 获取授权码详情
|
||||
// @Summary 获取授权码详情
|
||||
// @Description 根据授权码ID获取授权码详细信息
|
||||
// @Tags 授权码管理
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param id path string true "授权码ID"
|
||||
// @Success 200 {object} models.APIResponse{data=models.AuthorizationCode} "查询成功"
|
||||
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
|
||||
// @Failure 401 {object} models.ErrorResponse "未认证"
|
||||
// @Failure 404 {object} models.ErrorResponse "授权码不存在"
|
||||
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
|
||||
// @Router /api/v1/authorization-codes/{id} [get]
|
||||
func (h *AuthorizationCodeHandler) GetAuthorizationCode(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if id == "" {
|
||||
lang := middleware.GetLanguage(c)
|
||||
status, errCode, message := i18n.NewI18nErrorResponse("900001", lang)
|
||||
c.JSON(status, models.ErrorResponse{
|
||||
Code: errCode,
|
||||
Message: message,
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 直接传递 gin.Context
|
||||
data, err := h.authCodeService.GetAuthorizationCode(c, id)
|
||||
if err != nil {
|
||||
// 错误已经在Service层完全包装好了,直接使用
|
||||
var i18nErr *i18n.I18nError
|
||||
if errors.As(err, &i18nErr) {
|
||||
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
|
||||
Code: i18nErr.Code,
|
||||
Message: i18nErr.Message,
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
} else {
|
||||
// 兜底:理论上不应该到这里,因为Service层应该返回I18nError
|
||||
lang := middleware.GetLanguage(c)
|
||||
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang)
|
||||
c.JSON(status, models.ErrorResponse{
|
||||
Code: errCode,
|
||||
Message: message,
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
lang := middleware.GetLanguage(c)
|
||||
successMessage := i18n.GetErrorMessage("000000", lang)
|
||||
c.JSON(http.StatusOK, models.APIResponse{
|
||||
Code: "000000",
|
||||
Message: successMessage,
|
||||
Data: data,
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateAuthorizationCode 更新授权码
|
||||
// @Summary 更新授权码
|
||||
// @Description 更新授权码信息,所有字段都是可选的,需要指定变更类型和原因
|
||||
// @Tags 授权码管理
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param id path string true "授权码ID"
|
||||
// @Param authorization_code body models.AuthorizationCodeUpdateRequest true "授权码信息"
|
||||
// @Success 200 {object} models.APIResponse{data=models.AuthorizationCode} "更新成功"
|
||||
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
|
||||
// @Failure 401 {object} models.ErrorResponse "未认证"
|
||||
// @Failure 404 {object} models.ErrorResponse "授权码不存在"
|
||||
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
|
||||
// @Router /api/v1/authorization-codes/{id} [put]
|
||||
func (h *AuthorizationCodeHandler) UpdateAuthorizationCode(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if id == "" {
|
||||
lang := middleware.GetLanguage(c)
|
||||
status, errCode, message := i18n.NewI18nErrorResponse("900001", lang)
|
||||
c.JSON(status, models.ErrorResponse{
|
||||
Code: errCode,
|
||||
Message: message,
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var req models.AuthorizationCodeUpdateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
lang := middleware.GetLanguage(c)
|
||||
status, errCode, message := i18n.NewI18nErrorResponse("900001", lang)
|
||||
c.JSON(status, models.ErrorResponse{
|
||||
Code: errCode,
|
||||
Message: message + ": " + err.Error(),
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 直接传递 gin.Context
|
||||
authCode, err := h.authCodeService.UpdateAuthorizationCode(c, id, &req)
|
||||
if err != nil {
|
||||
// 错误已经在Service层完全包装好了,直接使用
|
||||
var i18nErr *i18n.I18nError
|
||||
if errors.As(err, &i18nErr) {
|
||||
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
|
||||
Code: i18nErr.Code,
|
||||
Message: i18nErr.Message,
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
} else {
|
||||
// 兜底:理论上不应该到这里,因为Service层应该返回I18nError
|
||||
lang := middleware.GetLanguage(c)
|
||||
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang)
|
||||
c.JSON(status, models.ErrorResponse{
|
||||
Code: errCode,
|
||||
Message: message,
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
lang := middleware.GetLanguage(c)
|
||||
successMessage := i18n.GetErrorMessage("000000", lang)
|
||||
c.JSON(http.StatusOK, models.APIResponse{
|
||||
Code: "000000",
|
||||
Message: successMessage,
|
||||
Data: authCode,
|
||||
})
|
||||
}
|
||||
|
||||
// LockUnlockAuthorizationCode 锁定/解锁授权码
|
||||
// @Summary 锁定/解锁授权码
|
||||
// @Description 锁定或解锁指定的授权码,可以设置锁定原因
|
||||
// @Tags 授权码管理
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param id path string true "授权码ID"
|
||||
// @Param lock_request body models.AuthorizationCodeLockRequest true "锁定/解锁信息"
|
||||
// @Success 200 {object} models.APIResponse{data=models.AuthorizationCode} "操作成功"
|
||||
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
|
||||
// @Failure 401 {object} models.ErrorResponse "未认证"
|
||||
// @Failure 404 {object} models.ErrorResponse "授权码不存在"
|
||||
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
|
||||
// @Router /api/v1/authorization-codes/{id}/lock [put]
|
||||
func (h *AuthorizationCodeHandler) LockUnlockAuthorizationCode(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if id == "" {
|
||||
lang := middleware.GetLanguage(c)
|
||||
status, errCode, message := i18n.NewI18nErrorResponse("900001", lang)
|
||||
c.JSON(status, models.ErrorResponse{
|
||||
Code: errCode,
|
||||
Message: message,
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var req models.AuthorizationCodeLockRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
lang := middleware.GetLanguage(c)
|
||||
status, errCode, message := i18n.NewI18nErrorResponse("900001", lang)
|
||||
c.JSON(status, models.ErrorResponse{
|
||||
Code: errCode,
|
||||
Message: message + ": " + err.Error(),
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 直接传递 gin.Context
|
||||
data, err := h.authCodeService.LockUnlockAuthorizationCode(c, id, &req)
|
||||
if err != nil {
|
||||
// 错误已经在Service层完全包装好了,直接使用
|
||||
var i18nErr *i18n.I18nError
|
||||
if errors.As(err, &i18nErr) {
|
||||
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
|
||||
Code: i18nErr.Code,
|
||||
Message: i18nErr.Message,
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
} else {
|
||||
// 兜底:理论上不应该到这里,因为Service层应该返回I18nError
|
||||
lang := middleware.GetLanguage(c)
|
||||
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang)
|
||||
c.JSON(status, models.ErrorResponse{
|
||||
Code: errCode,
|
||||
Message: message,
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
lang := middleware.GetLanguage(c)
|
||||
successMessage := i18n.GetErrorMessage("000000", lang)
|
||||
c.JSON(http.StatusOK, models.APIResponse{
|
||||
Code: "000000",
|
||||
Message: successMessage,
|
||||
Data: data,
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteAuthorizationCode 删除授权码
|
||||
// @Summary 删除授权码
|
||||
// @Description 删除指定的授权码(软删除)
|
||||
// @Tags 授权码管理
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param id path string true "授权码ID"
|
||||
// @Success 200 {object} models.APIResponse "删除成功"
|
||||
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
|
||||
// @Failure 401 {object} models.ErrorResponse "未认证"
|
||||
// @Failure 404 {object} models.ErrorResponse "授权码不存在"
|
||||
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
|
||||
// @Router /api/v1/authorization-codes/{id} [delete]
|
||||
func (h *AuthorizationCodeHandler) DeleteAuthorizationCode(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if id == "" {
|
||||
lang := middleware.GetLanguage(c)
|
||||
status, errCode, message := i18n.NewI18nErrorResponse("900001", lang)
|
||||
c.JSON(status, models.ErrorResponse{
|
||||
Code: errCode,
|
||||
Message: message,
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 直接传递 gin.Context
|
||||
if err := h.authCodeService.DeleteAuthorizationCode(c, id); err != nil {
|
||||
// 错误已经在Service层完全包装好了,直接使用
|
||||
var i18nErr *i18n.I18nError
|
||||
if errors.As(err, &i18nErr) {
|
||||
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
|
||||
Code: i18nErr.Code,
|
||||
Message: i18nErr.Message,
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
} else {
|
||||
// 兜底:理论上不应该到这里,因为Service层应该返回I18nError
|
||||
lang := middleware.GetLanguage(c)
|
||||
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang)
|
||||
c.JSON(status, models.ErrorResponse{
|
||||
Code: errCode,
|
||||
Message: message,
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
lang := middleware.GetLanguage(c)
|
||||
successMessage := i18n.GetErrorMessage("000000", lang)
|
||||
c.JSON(http.StatusOK, models.APIResponse{
|
||||
Code: "000000",
|
||||
Message: successMessage,
|
||||
Data: nil,
|
||||
})
|
||||
}
|
||||
|
||||
// GetAuthorizationChangeList 查询授权变更历史列表
|
||||
// @Summary 查询授权变更历史列表
|
||||
// @Description 分页查询指定授权码的变更历史,支持筛选
|
||||
// @Tags 授权码管理
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param id path string true "授权码ID"
|
||||
// @Param page query int false "页码,默认1" minimum(1)
|
||||
// @Param page_size query int false "每页条数,默认20,最大100" minimum(1) maximum(100)
|
||||
// @Param change_type query string false "变更类型筛选"
|
||||
// @Param operator_id query string false "操作人筛选"
|
||||
// @Param start_date query string false "开始时间 (YYYY-MM-DD格式)"
|
||||
// @Param end_date query string false "结束时间 (YYYY-MM-DD格式)"
|
||||
// @Param sort query string false "排序字段,默认created_at" Enums(created_at, change_type)
|
||||
// @Param order query string false "排序方向,默认desc" Enums(asc, desc)
|
||||
// @Success 200 {object} models.APIResponse{data=models.AuthorizationChangeListResponse} "查询成功"
|
||||
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
|
||||
// @Failure 401 {object} models.ErrorResponse "未认证"
|
||||
// @Failure 404 {object} models.ErrorResponse "授权码不存在"
|
||||
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
|
||||
// @Router /api/v1/authorization-codes/{id}/changes [get]
|
||||
func (h *AuthorizationCodeHandler) GetAuthorizationChangeList(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if id == "" {
|
||||
lang := middleware.GetLanguage(c)
|
||||
status, errCode, message := i18n.NewI18nErrorResponse("900001", lang)
|
||||
c.JSON(status, models.ErrorResponse{
|
||||
Code: errCode,
|
||||
Message: message,
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var req models.AuthorizationChangeListRequest
|
||||
if err := c.ShouldBindQuery(&req); err != nil {
|
||||
lang := middleware.GetLanguage(c)
|
||||
status, errCode, message := i18n.NewI18nErrorResponse("900001", lang)
|
||||
c.JSON(status, models.ErrorResponse{
|
||||
Code: errCode,
|
||||
Message: message + ": " + err.Error(),
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 直接传递 gin.Context
|
||||
data, err := h.authCodeService.GetAuthorizationChangeList(c, id, &req)
|
||||
if err != nil {
|
||||
// 错误已经在Service层完全包装好了,直接使用
|
||||
var i18nErr *i18n.I18nError
|
||||
if errors.As(err, &i18nErr) {
|
||||
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
|
||||
Code: i18nErr.Code,
|
||||
Message: i18nErr.Message,
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
} else {
|
||||
// 兜底:理论上不应该到这里,因为Service层应该返回I18nError
|
||||
lang := middleware.GetLanguage(c)
|
||||
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang)
|
||||
c.JSON(status, models.ErrorResponse{
|
||||
Code: errCode,
|
||||
Message: message,
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
lang := middleware.GetLanguage(c)
|
||||
successMessage := i18n.GetErrorMessage("000000", lang)
|
||||
c.JSON(http.StatusOK, models.APIResponse{
|
||||
Code: "000000",
|
||||
Message: successMessage,
|
||||
Data: data,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"license-manager/internal/api/middleware"
|
||||
"license-manager/internal/models"
|
||||
"license-manager/internal/service"
|
||||
"license-manager/pkg/i18n"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type CustomerHandler struct {
|
||||
customerService service.CustomerService
|
||||
}
|
||||
|
||||
// NewCustomerHandler 创建客户处理器
|
||||
func NewCustomerHandler(customerService service.CustomerService) *CustomerHandler {
|
||||
return &CustomerHandler{
|
||||
customerService: customerService,
|
||||
}
|
||||
}
|
||||
|
||||
// GetCustomerList 查询客户列表
|
||||
// @Summary 查询客户列表
|
||||
// @Description 分页查询客户列表,支持搜索和筛选
|
||||
// @Tags 客户管理
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param page query int false "页码,默认1" minimum(1)
|
||||
// @Param page_size query int false "每页条数,默认20,最大100" minimum(1) maximum(100)
|
||||
// @Param search query string false "搜索关键词(支持客户编码、名称、联系人、邮箱)"
|
||||
// @Param customer_type query string false "客户类型筛选" Enums(individual, enterprise, government, education)
|
||||
// @Param customer_level query string false "客户等级筛选" Enums(normal, vip, enterprise, strategic)
|
||||
// @Param status query string false "状态筛选" Enums(active, disabled)
|
||||
// @Param sort query string false "排序字段,默认created_at" Enums(created_at, updated_at, customer_name, customer_code)
|
||||
// @Param order query string false "排序方向,默认desc" Enums(asc, desc)
|
||||
// @Success 200 {object} models.APIResponse{data=models.CustomerListResponse} "查询成功"
|
||||
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
|
||||
// @Failure 401 {object} models.ErrorResponse "未认证"
|
||||
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
|
||||
// @Router /api/customers [get]
|
||||
func (h *CustomerHandler) GetCustomerList(c *gin.Context) {
|
||||
var req models.CustomerListRequest
|
||||
if err := c.ShouldBindQuery(&req); err != nil {
|
||||
lang := middleware.GetLanguage(c)
|
||||
status, errCode, message := i18n.NewI18nErrorResponse("900001", lang)
|
||||
c.JSON(status, models.ErrorResponse{
|
||||
Code: errCode,
|
||||
Message: message + ": " + err.Error(),
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 设置语言到Context中
|
||||
ctx := middleware.WithLanguage(c.Request.Context(), c)
|
||||
|
||||
data, err := h.customerService.GetCustomerList(ctx, &req)
|
||||
if err != nil {
|
||||
// 错误已经在Service层完全包装好了,直接使用
|
||||
var i18nErr *i18n.I18nError
|
||||
if errors.As(err, &i18nErr) {
|
||||
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
|
||||
Code: i18nErr.Code,
|
||||
Message: i18nErr.Message,
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
} else {
|
||||
// 兜底:理论上不应该到这里,因为Service层应该返回I18nError
|
||||
lang := middleware.GetLanguage(c)
|
||||
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang)
|
||||
c.JSON(status, models.ErrorResponse{
|
||||
Code: errCode,
|
||||
Message: message,
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
lang := middleware.GetLanguage(c)
|
||||
successMessage := i18n.GetErrorMessage("000000", lang)
|
||||
c.JSON(http.StatusOK, models.APIResponse{
|
||||
Code: "000000",
|
||||
Message: successMessage,
|
||||
Data: data,
|
||||
})
|
||||
}
|
||||
|
||||
// GetCustomer 获取客户详情
|
||||
// @Summary 获取客户详情
|
||||
// @Description 根据客户ID获取客户详细信息
|
||||
// @Tags 客户管理
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param id path string true "客户ID"
|
||||
// @Success 200 {object} models.APIResponse{data=models.Customer} "查询成功"
|
||||
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
|
||||
// @Failure 401 {object} models.ErrorResponse "未认证"
|
||||
// @Failure 404 {object} models.ErrorResponse "客户不存在"
|
||||
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
|
||||
// @Router /api/customers/{id} [get]
|
||||
func (h *CustomerHandler) GetCustomer(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if id == "" {
|
||||
lang := middleware.GetLanguage(c)
|
||||
status, errCode, message := i18n.NewI18nErrorResponse("900001", lang)
|
||||
c.JSON(status, models.ErrorResponse{
|
||||
Code: errCode,
|
||||
Message: message,
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 设置语言到Context中
|
||||
ctx := middleware.WithLanguage(c.Request.Context(), c)
|
||||
|
||||
data, err := h.customerService.GetCustomer(ctx, id)
|
||||
if err != nil {
|
||||
// 错误已经在Service层完全包装好了,直接使用
|
||||
var i18nErr *i18n.I18nError
|
||||
if errors.As(err, &i18nErr) {
|
||||
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
|
||||
Code: i18nErr.Code,
|
||||
Message: i18nErr.Message,
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
} else {
|
||||
// 兜底:理论上不应该到这里,因为Service层应该返回I18nError
|
||||
lang := middleware.GetLanguage(c)
|
||||
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang)
|
||||
c.JSON(status, models.ErrorResponse{
|
||||
Code: errCode,
|
||||
Message: message,
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
lang := middleware.GetLanguage(c)
|
||||
successMessage := i18n.GetErrorMessage("000000", lang)
|
||||
c.JSON(http.StatusOK, models.APIResponse{
|
||||
Code: "000000",
|
||||
Message: successMessage,
|
||||
Data: data,
|
||||
})
|
||||
}
|
||||
|
||||
// CreateCustomer 创建客户
|
||||
// @Summary 创建客户
|
||||
// @Description 创建新的客户记录,自动生成客户编码
|
||||
// @Tags 客户管理
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param customer body models.CustomerCreateRequest true "客户信息"
|
||||
// @Success 200 {object} models.APIResponse{data=models.Customer} "创建成功"
|
||||
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
|
||||
// @Failure 401 {object} models.ErrorResponse "未认证"
|
||||
// @Failure 409 {object} models.ErrorResponse "客户已存在"
|
||||
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
|
||||
// @Router /api/customers [post]
|
||||
func (h *CustomerHandler) CreateCustomer(c *gin.Context) {
|
||||
var req models.CustomerCreateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
lang := middleware.GetLanguage(c)
|
||||
status, errCode, message := i18n.NewI18nErrorResponse("900001", lang)
|
||||
c.JSON(status, models.ErrorResponse{
|
||||
Code: errCode,
|
||||
Message: message + ": " + err.Error(),
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 设置语言到Context中
|
||||
ctx := middleware.WithLanguage(c.Request.Context(), c)
|
||||
|
||||
data, err := h.customerService.CreateCustomer(ctx, &req)
|
||||
if err != nil {
|
||||
// 错误已经在Service层完全包装好了,直接使用
|
||||
var i18nErr *i18n.I18nError
|
||||
if errors.As(err, &i18nErr) {
|
||||
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
|
||||
Code: i18nErr.Code,
|
||||
Message: i18nErr.Message,
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
} else {
|
||||
// 兜底:理论上不应该到这里,因为Service层应该返回I18nError
|
||||
lang := middleware.GetLanguage(c)
|
||||
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang)
|
||||
c.JSON(status, models.ErrorResponse{
|
||||
Code: errCode,
|
||||
Message: message,
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
lang := middleware.GetLanguage(c)
|
||||
successMessage := i18n.GetErrorMessage("000000", lang)
|
||||
c.JSON(http.StatusOK, models.APIResponse{
|
||||
Code: "000000",
|
||||
Message: successMessage,
|
||||
Data: data,
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateCustomer 更新客户信息
|
||||
// @Summary 更新客户信息
|
||||
// @Description 更新客户信息,所有字段都是可选的
|
||||
// @Tags 客户管理
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param id path string true "客户ID"
|
||||
// @Param customer body models.CustomerUpdateRequest true "客户信息"
|
||||
// @Success 200 {object} models.APIResponse{data=models.Customer} "更新成功"
|
||||
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
|
||||
// @Failure 401 {object} models.ErrorResponse "未认证"
|
||||
// @Failure 404 {object} models.ErrorResponse "客户不存在"
|
||||
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
|
||||
// @Router /api/customers/{id} [put]
|
||||
func (h *CustomerHandler) UpdateCustomer(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if id == "" {
|
||||
lang := middleware.GetLanguage(c)
|
||||
status, errCode, message := i18n.NewI18nErrorResponse("900001", lang)
|
||||
c.JSON(status, models.ErrorResponse{
|
||||
Code: errCode,
|
||||
Message: message,
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var req models.CustomerUpdateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
lang := middleware.GetLanguage(c)
|
||||
status, errCode, message := i18n.NewI18nErrorResponse("900001", lang)
|
||||
c.JSON(status, models.ErrorResponse{
|
||||
Code: errCode,
|
||||
Message: message + ": " + err.Error(),
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 设置语言到Context中
|
||||
ctx := middleware.WithLanguage(c.Request.Context(), c)
|
||||
|
||||
data, err := h.customerService.UpdateCustomer(ctx, id, &req)
|
||||
if err != nil {
|
||||
// 错误已经在Service层完全包装好了,直接使用
|
||||
var i18nErr *i18n.I18nError
|
||||
if errors.As(err, &i18nErr) {
|
||||
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
|
||||
Code: i18nErr.Code,
|
||||
Message: i18nErr.Message,
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
} else {
|
||||
// 兜底:理论上不应该到这里,因为Service层应该返回I18nError
|
||||
lang := middleware.GetLanguage(c)
|
||||
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang)
|
||||
c.JSON(status, models.ErrorResponse{
|
||||
Code: errCode,
|
||||
Message: message,
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
lang := middleware.GetLanguage(c)
|
||||
successMessage := i18n.GetErrorMessage("000000", lang)
|
||||
c.JSON(http.StatusOK, models.APIResponse{
|
||||
Code: "000000",
|
||||
Message: successMessage,
|
||||
Data: data,
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteCustomer 删除客户
|
||||
// @Summary 删除客户
|
||||
// @Description 直接删除客户记录,删除前会检查是否有关联的授权码或许可证,如有则提示先删除授权
|
||||
// @Tags 客户管理
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param id path string true "客户ID"
|
||||
// @Success 200 {object} models.APIResponse "删除成功"
|
||||
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
|
||||
// @Failure 401 {object} models.ErrorResponse "未认证"
|
||||
// @Failure 404 {object} models.ErrorResponse "客户不存在"
|
||||
// @Failure 409 {object} models.ErrorResponse "客户仍有授权,无法删除"
|
||||
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
|
||||
// @Router /api/customers/{id} [delete]
|
||||
func (h *CustomerHandler) DeleteCustomer(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if id == "" {
|
||||
lang := middleware.GetLanguage(c)
|
||||
status, errCode, message := i18n.NewI18nErrorResponse("900001", lang)
|
||||
c.JSON(status, models.ErrorResponse{
|
||||
Code: errCode,
|
||||
Message: message,
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 设置语言到Context中
|
||||
ctx := middleware.WithLanguage(c.Request.Context(), c)
|
||||
|
||||
err := h.customerService.DeleteCustomer(ctx, id)
|
||||
if err != nil {
|
||||
// 错误已经在Service层完全包装好了,直接使用
|
||||
var i18nErr *i18n.I18nError
|
||||
if errors.As(err, &i18nErr) {
|
||||
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
|
||||
Code: i18nErr.Code,
|
||||
Message: i18nErr.Message,
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
} else {
|
||||
// 兜底:理论上不应该到这里,因为Service层应该返回I18nError
|
||||
lang := middleware.GetLanguage(c)
|
||||
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang)
|
||||
c.JSON(status, models.ErrorResponse{
|
||||
Code: errCode,
|
||||
Message: message,
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
lang := middleware.GetLanguage(c)
|
||||
successMessage := i18n.GetErrorMessage("000000", lang)
|
||||
c.JSON(http.StatusOK, models.APIResponse{
|
||||
Code: "000000",
|
||||
Message: successMessage,
|
||||
Data: nil,
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateCustomerStatus 修改客户状态
|
||||
// @Summary 修改客户状态
|
||||
// @Description 修改客户状态(激活/停用)
|
||||
// @Tags 客户管理
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param id path string true "客户ID"
|
||||
// @Param status body models.CustomerStatusUpdateRequest true "状态信息"
|
||||
// @Success 200 {object} models.APIResponse{data=models.Customer} "状态更新成功"
|
||||
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
|
||||
// @Failure 401 {object} models.ErrorResponse "未认证"
|
||||
// @Failure 404 {object} models.ErrorResponse "客户不存在"
|
||||
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
|
||||
// @Router /api/customers/{id}/status [patch]
|
||||
func (h *CustomerHandler) UpdateCustomerStatus(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if id == "" {
|
||||
lang := middleware.GetLanguage(c)
|
||||
status, errCode, message := i18n.NewI18nErrorResponse("900001", lang)
|
||||
c.JSON(status, models.ErrorResponse{
|
||||
Code: errCode,
|
||||
Message: message,
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var req models.CustomerStatusUpdateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
lang := middleware.GetLanguage(c)
|
||||
status, errCode, message := i18n.NewI18nErrorResponse("900001", lang)
|
||||
c.JSON(status, models.ErrorResponse{
|
||||
Code: errCode,
|
||||
Message: message + ": " + err.Error(),
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 设置语言到Context中
|
||||
ctx := middleware.WithLanguage(c.Request.Context(), c)
|
||||
|
||||
data, err := h.customerService.UpdateCustomerStatus(ctx, id, &req)
|
||||
if err != nil {
|
||||
// 错误已经在Service层完全包装好了,直接使用
|
||||
var i18nErr *i18n.I18nError
|
||||
if errors.As(err, &i18nErr) {
|
||||
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
|
||||
Code: i18nErr.Code,
|
||||
Message: i18nErr.Message,
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
} else {
|
||||
// 兜底:理论上不应该到这里,因为Service层应该返回I18nError
|
||||
lang := middleware.GetLanguage(c)
|
||||
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang)
|
||||
c.JSON(status, models.ErrorResponse{
|
||||
Code: errCode,
|
||||
Message: message,
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
lang := middleware.GetLanguage(c)
|
||||
successMessage := i18n.GetErrorMessage("000000", lang)
|
||||
c.JSON(http.StatusOK, models.APIResponse{
|
||||
Code: "000000",
|
||||
Message: successMessage,
|
||||
Data: data,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"license-manager/internal/api/middleware"
|
||||
"license-manager/internal/models"
|
||||
"license-manager/internal/service"
|
||||
"license-manager/pkg/i18n"
|
||||
)
|
||||
|
||||
type DashboardHandler struct {
|
||||
dashboardService service.DashboardService
|
||||
}
|
||||
|
||||
// NewDashboardHandler 创建仪表盘处理器
|
||||
func NewDashboardHandler(dashboardService service.DashboardService) *DashboardHandler {
|
||||
return &DashboardHandler{
|
||||
dashboardService: dashboardService,
|
||||
}
|
||||
}
|
||||
|
||||
// GetAuthorizationTrend 获取授权趋势数据
|
||||
// @Summary 获取授权趋势数据
|
||||
// @Description 根据时间类型(本周/本月/自定义)获取授权趋势统计数据
|
||||
// @Tags 仪表盘
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param type query string true "时间类型" Enums(week,month,custom)
|
||||
// @Param start_date query string false "开始日期(YYYY-MM-DD格式,当type为custom时必填)"
|
||||
// @Param end_date query string false "结束日期(YYYY-MM-DD格式,当type为custom时必填)"
|
||||
// @Param timezone query string false "时区(如:Asia/Shanghai,UTC等,默认使用服务器本地时区)"
|
||||
// @Success 200 {object} models.APIResponse{data=models.DashboardAuthorizationTrendResponse} "授权趋势数据"
|
||||
// @Failure 400 {object} models.ErrorResponse "请求参数错误"
|
||||
// @Failure 401 {object} models.ErrorResponse "未认证"
|
||||
// @Failure 403 {object} models.ErrorResponse "权限不足"
|
||||
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
|
||||
// @Router /api/v1/dashboard/authorization-trend [get]
|
||||
func (h *DashboardHandler) GetAuthorizationTrend(c *gin.Context) {
|
||||
// 绑定查询参数
|
||||
var req models.DashboardAuthorizationTrendRequest
|
||||
if err := c.ShouldBindQuery(&req); err != nil {
|
||||
lang := middleware.GetLanguage(c)
|
||||
status, errCode, message := i18n.NewI18nErrorResponse("900001", lang)
|
||||
c.JSON(status, models.ErrorResponse{
|
||||
Code: errCode,
|
||||
Message: message + ": " + err.Error(),
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 设置语言到Context中
|
||||
ctx := middleware.WithLanguage(c.Request.Context(), c)
|
||||
|
||||
// 调用服务层获取数据
|
||||
response, err := h.dashboardService.GetAuthorizationTrend(ctx, &req)
|
||||
if err != nil {
|
||||
// 错误已经在Service层完全包装好了,直接使用
|
||||
var i18nErr *i18n.I18nError
|
||||
if errors.As(err, &i18nErr) {
|
||||
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
|
||||
Code: i18nErr.Code,
|
||||
Message: i18nErr.Message,
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
} else {
|
||||
// 兜底:理论上不应该到这里,因为Service层应该返回I18nError
|
||||
lang := middleware.GetLanguage(c)
|
||||
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang)
|
||||
c.JSON(status, models.ErrorResponse{
|
||||
Code: errCode,
|
||||
Message: message,
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 返回成功响应
|
||||
lang := middleware.GetLanguage(c)
|
||||
successMessage := i18n.GetErrorMessage("000000", lang)
|
||||
c.JSON(http.StatusOK, models.APIResponse{
|
||||
Code: "000000",
|
||||
Message: successMessage,
|
||||
Data: response,
|
||||
})
|
||||
}
|
||||
|
||||
// GetRecentAuthorizations 获取最近授权列表
|
||||
// @Summary 获取最近授权列表
|
||||
// @Description 获取最近创建的授权码列表,支持按客户ID和状态筛选
|
||||
// @Tags 仪表盘
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param limit query int false "返回数量限制(默认20,最大100)"
|
||||
// @Param customer_id query string false "客户ID筛选"
|
||||
// @Param status query string false "状态筛选" Enums(normal,locked,expired)
|
||||
// @Success 200 {object} models.APIResponse{data=models.DashboardRecentAuthorizationsResponse} "最近授权列表"
|
||||
// @Failure 400 {object} models.ErrorResponse "请求参数错误"
|
||||
// @Failure 401 {object} models.ErrorResponse "未认证"
|
||||
// @Failure 403 {object} models.ErrorResponse "权限不足"
|
||||
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
|
||||
// @Router /api/v1/dashboard/recent-authorizations [get]
|
||||
func (h *DashboardHandler) GetRecentAuthorizations(c *gin.Context) {
|
||||
// 绑定查询参数
|
||||
var req models.DashboardRecentAuthorizationsRequest
|
||||
if err := c.ShouldBindQuery(&req); err != nil {
|
||||
lang := middleware.GetLanguage(c)
|
||||
status, errCode, message := i18n.NewI18nErrorResponse("900001", lang)
|
||||
c.JSON(status, models.ErrorResponse{
|
||||
Code: errCode,
|
||||
Message: message + ": " + err.Error(),
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 设置语言到Context中
|
||||
ctx := middleware.WithLanguage(c.Request.Context(), c)
|
||||
|
||||
// 调用服务层获取数据
|
||||
response, err := h.dashboardService.GetRecentAuthorizations(ctx, &req)
|
||||
if err != nil {
|
||||
// 错误已经在Service层完全包装好了,直接使用
|
||||
var i18nErr *i18n.I18nError
|
||||
if errors.As(err, &i18nErr) {
|
||||
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
|
||||
Code: i18nErr.Code,
|
||||
Message: i18nErr.Message,
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
} else {
|
||||
// 兜底:理论上不应该到这里,因为Service层应该返回I18nError
|
||||
lang := middleware.GetLanguage(c)
|
||||
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang)
|
||||
c.JSON(status, models.ErrorResponse{
|
||||
Code: errCode,
|
||||
Message: message,
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 返回成功响应
|
||||
lang := middleware.GetLanguage(c)
|
||||
successMessage := i18n.GetErrorMessage("000000", lang)
|
||||
c.JSON(http.StatusOK, models.APIResponse{
|
||||
Code: "000000",
|
||||
Message: successMessage,
|
||||
Data: response,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"license-manager/internal/api/middleware"
|
||||
"license-manager/internal/models"
|
||||
"license-manager/internal/service"
|
||||
"license-manager/pkg/i18n"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type EnumHandler struct {
|
||||
enumService service.EnumService
|
||||
}
|
||||
|
||||
// NewEnumHandler 创建枚举处理器
|
||||
func NewEnumHandler(enumService service.EnumService) *EnumHandler {
|
||||
return &EnumHandler{
|
||||
enumService: enumService,
|
||||
}
|
||||
}
|
||||
|
||||
// GetAllEnums 获取所有枚举类型及其值
|
||||
// @Summary 获取所有枚举值
|
||||
// @Description 获取系统中所有枚举类型及其对应的多语言显示值
|
||||
// @Tags 枚举管理
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} models.APIResponse{data=models.EnumListResponse} "查询成功"
|
||||
// @Failure 401 {object} models.ErrorResponse "未认证"
|
||||
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
|
||||
// @Router /api/enums [get]
|
||||
func (h *EnumHandler) GetAllEnums(c *gin.Context) {
|
||||
// 设置语言到Context中
|
||||
ctx := middleware.WithLanguage(c.Request.Context(), c)
|
||||
|
||||
data, err := h.enumService.GetAllEnums(ctx)
|
||||
if err != nil {
|
||||
// 错误已经在Service层完全包装好了,直接使用
|
||||
var i18nErr *i18n.I18nError
|
||||
if errors.As(err, &i18nErr) {
|
||||
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
|
||||
Code: i18nErr.Code,
|
||||
Message: i18nErr.Message,
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
} else {
|
||||
// 兜底:理论上不应该到这里,因为Service层应该返回I18nError
|
||||
lang := middleware.GetLanguage(c)
|
||||
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang)
|
||||
c.JSON(status, models.ErrorResponse{
|
||||
Code: errCode,
|
||||
Message: message,
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
lang := middleware.GetLanguage(c)
|
||||
successMessage := i18n.GetErrorMessage("000000", lang)
|
||||
c.JSON(http.StatusOK, models.APIResponse{
|
||||
Code: "000000",
|
||||
Message: successMessage,
|
||||
Data: data,
|
||||
})
|
||||
}
|
||||
|
||||
// GetEnumsByType 获取指定类型的枚举值
|
||||
// @Summary 获取指定类型的枚举值
|
||||
// @Description 根据枚举类型获取对应的多语言显示值
|
||||
// @Tags 枚举管理
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param type path string true "枚举类型" Enums(customer_type, customer_level, customer_status, company_size)
|
||||
// @Success 200 {object} models.APIResponse{data=models.EnumTypeResponse} "查询成功"
|
||||
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
|
||||
// @Failure 401 {object} models.ErrorResponse "未认证"
|
||||
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
|
||||
// @Router /api/enums/{type} [get]
|
||||
func (h *EnumHandler) GetEnumsByType(c *gin.Context) {
|
||||
enumType := c.Param("type")
|
||||
if enumType == "" {
|
||||
lang := middleware.GetLanguage(c)
|
||||
status, errCode, message := i18n.NewI18nErrorResponse("900001", lang)
|
||||
c.JSON(status, models.ErrorResponse{
|
||||
Code: errCode,
|
||||
Message: message + ": enum type is required",
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 设置语言到Context中
|
||||
ctx := middleware.WithLanguage(c.Request.Context(), c)
|
||||
|
||||
data, err := h.enumService.GetEnumsByType(ctx, enumType)
|
||||
if err != nil {
|
||||
// 错误已经在Service层完全包装好了,直接使用
|
||||
var i18nErr *i18n.I18nError
|
||||
if errors.As(err, &i18nErr) {
|
||||
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
|
||||
Code: i18nErr.Code,
|
||||
Message: i18nErr.Message,
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
} else {
|
||||
// 兜底:理论上不应该到这里,因为Service层应该返回I18nError
|
||||
lang := middleware.GetLanguage(c)
|
||||
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang)
|
||||
c.JSON(status, models.ErrorResponse{
|
||||
Code: errCode,
|
||||
Message: message,
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
lang := middleware.GetLanguage(c)
|
||||
successMessage := i18n.GetErrorMessage("000000", lang)
|
||||
c.JSON(http.StatusOK, models.APIResponse{
|
||||
Code: "000000",
|
||||
Message: successMessage,
|
||||
Data: data,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,536 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"license-manager/internal/api/middleware"
|
||||
"license-manager/internal/models"
|
||||
"license-manager/internal/service"
|
||||
"license-manager/pkg/i18n"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type LicenseHandler struct {
|
||||
licenseService service.LicenseService
|
||||
}
|
||||
|
||||
// NewLicenseHandler 创建许可证处理器
|
||||
func NewLicenseHandler(licenseService service.LicenseService) *LicenseHandler {
|
||||
return &LicenseHandler{
|
||||
licenseService: licenseService,
|
||||
}
|
||||
}
|
||||
|
||||
// GetLicenseList 查询许可证列表
|
||||
// @Summary 查询许可证列表
|
||||
// @Description 分页查询许可证列表,支持筛选
|
||||
// @Tags 许可证管理
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param page query int false "页码,默认1" minimum(1)
|
||||
// @Param page_size query int false "每页条数,默认20,最大100" minimum(1) maximum(100)
|
||||
// @Param authorization_code_id query string false "授权码ID筛选"
|
||||
// @Param customer_id query string false "客户ID筛选"
|
||||
// @Param status query string false "状态筛选" Enums(active, inactive, revoked)
|
||||
// @Param is_online query bool false "在线状态筛选"
|
||||
// @Param sort query string false "排序字段,默认created_at" Enums(created_at, updated_at, activated_at, last_heartbeat)
|
||||
// @Param order query string false "排序方向,默认desc" Enums(asc, desc)
|
||||
// @Success 200 {object} models.APIResponse{data=models.LicenseListResponse} "查询成功"
|
||||
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
|
||||
// @Failure 401 {object} models.ErrorResponse "未认证"
|
||||
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
|
||||
// @Router /api/v1/licenses [get]
|
||||
func (h *LicenseHandler) GetLicenseList(c *gin.Context) {
|
||||
var req models.LicenseListRequest
|
||||
if err := c.ShouldBindQuery(&req); err != nil {
|
||||
lang := middleware.GetLanguage(c)
|
||||
status, errCode, message := i18n.NewI18nErrorResponse("900001", lang)
|
||||
c.JSON(status, models.ErrorResponse{
|
||||
Code: errCode,
|
||||
Message: message + ": " + err.Error(),
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 设置语言到Context中
|
||||
ctx := middleware.WithLanguage(c.Request.Context(), c)
|
||||
|
||||
data, err := h.licenseService.GetLicenseList(ctx, &req)
|
||||
if err != nil {
|
||||
// 错误已经在Service层完全包装好了,直接使用
|
||||
var i18nErr *i18n.I18nError
|
||||
if errors.As(err, &i18nErr) {
|
||||
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
|
||||
Code: i18nErr.Code,
|
||||
Message: i18nErr.Message,
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
} else {
|
||||
// 兜底:理论上不应该到这里,因为Service层应该返回I18nError
|
||||
lang := middleware.GetLanguage(c)
|
||||
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang)
|
||||
c.JSON(status, models.ErrorResponse{
|
||||
Code: errCode,
|
||||
Message: message,
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
lang := middleware.GetLanguage(c)
|
||||
successMessage := i18n.GetErrorMessage("000000", lang)
|
||||
c.JSON(http.StatusOK, models.APIResponse{
|
||||
Code: "000000",
|
||||
Message: successMessage,
|
||||
Data: data,
|
||||
})
|
||||
}
|
||||
|
||||
// GetLicense 获取许可证详情
|
||||
// @Summary 获取许可证详情
|
||||
// @Description 根据许可证ID获取许可证详细信息
|
||||
// @Tags 许可证管理
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param id path string true "许可证ID"
|
||||
// @Success 200 {object} models.APIResponse{data=models.LicenseDetailResponse} "查询成功"
|
||||
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
|
||||
// @Failure 401 {object} models.ErrorResponse "未认证"
|
||||
// @Failure 404 {object} models.ErrorResponse "许可证不存在"
|
||||
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
|
||||
// @Router /api/v1/licenses/{id} [get]
|
||||
func (h *LicenseHandler) GetLicense(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if id == "" {
|
||||
lang := middleware.GetLanguage(c)
|
||||
status, errCode, message := i18n.NewI18nErrorResponse("900001", lang)
|
||||
c.JSON(status, models.ErrorResponse{
|
||||
Code: errCode,
|
||||
Message: message,
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 设置语言到Context中
|
||||
ctx := middleware.WithLanguage(c.Request.Context(), c)
|
||||
|
||||
data, err := h.licenseService.GetLicense(ctx, id)
|
||||
if err != nil {
|
||||
// 错误已经在Service层完全包装好了,直接使用
|
||||
var i18nErr *i18n.I18nError
|
||||
if errors.As(err, &i18nErr) {
|
||||
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
|
||||
Code: i18nErr.Code,
|
||||
Message: i18nErr.Message,
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
} else {
|
||||
// 兜底:理论上不应该到这里,因为Service层应该返回I18nError
|
||||
lang := middleware.GetLanguage(c)
|
||||
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang)
|
||||
c.JSON(status, models.ErrorResponse{
|
||||
Code: errCode,
|
||||
Message: message,
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
lang := middleware.GetLanguage(c)
|
||||
successMessage := i18n.GetErrorMessage("000000", lang)
|
||||
c.JSON(http.StatusOK, models.APIResponse{
|
||||
Code: "000000",
|
||||
Message: successMessage,
|
||||
Data: data,
|
||||
})
|
||||
}
|
||||
|
||||
// CreateLicense 手动添加许可证
|
||||
// @Summary 手动添加许可证
|
||||
// @Description 为指定授权码手动创建许可证
|
||||
// @Tags 许可证管理
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param request body models.LicenseCreateRequest true "许可证创建信息"
|
||||
// @Success 201 {object} models.APIResponse{data=models.License} "创建成功"
|
||||
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
|
||||
// @Failure 401 {object} models.ErrorResponse "未认证"
|
||||
// @Failure 404 {object} models.ErrorResponse "授权码不存在"
|
||||
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
|
||||
// @Router /api/v1/licenses [post]
|
||||
func (h *LicenseHandler) CreateLicense(c *gin.Context) {
|
||||
var req models.LicenseCreateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
lang := middleware.GetLanguage(c)
|
||||
status, errCode, message := i18n.NewI18nErrorResponse("900001", lang)
|
||||
c.JSON(status, models.ErrorResponse{
|
||||
Code: errCode,
|
||||
Message: message + ": " + err.Error(),
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 设置语言到Context中
|
||||
ctx := middleware.WithLanguage(c.Request.Context(), c)
|
||||
|
||||
data, err := h.licenseService.CreateLicense(ctx, &req)
|
||||
if err != nil {
|
||||
// 错误已经在Service层完全包装好了,直接使用
|
||||
var i18nErr *i18n.I18nError
|
||||
if errors.As(err, &i18nErr) {
|
||||
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
|
||||
Code: i18nErr.Code,
|
||||
Message: i18nErr.Message,
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
} else {
|
||||
// 兜底:理论上不应该到这里,因为Service层应该返回I18nError
|
||||
lang := middleware.GetLanguage(c)
|
||||
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang)
|
||||
c.JSON(status, models.ErrorResponse{
|
||||
Code: errCode,
|
||||
Message: message,
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
lang := middleware.GetLanguage(c)
|
||||
successMessage := i18n.GetErrorMessage("000000", lang)
|
||||
c.JSON(http.StatusCreated, models.APIResponse{
|
||||
Code: "000000",
|
||||
Message: successMessage,
|
||||
Data: data,
|
||||
})
|
||||
}
|
||||
|
||||
// RevokeLicense 撤销许可证
|
||||
// @Summary 撤销许可证
|
||||
// @Description 撤销指定的许可证,撤销后无法再使用
|
||||
// @Tags 许可证管理
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param id path string true "许可证ID"
|
||||
// @Param request body models.LicenseRevokeRequest true "撤销原因"
|
||||
// @Success 200 {object} models.APIResponse{data=models.License} "撤销成功"
|
||||
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
|
||||
// @Failure 401 {object} models.ErrorResponse "未认证"
|
||||
// @Failure 404 {object} models.ErrorResponse "许可证不存在"
|
||||
// @Failure 409 {object} models.ErrorResponse "许可证已被撤销"
|
||||
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
|
||||
// @Router /api/v1/licenses/{id}/revoke [put]
|
||||
func (h *LicenseHandler) RevokeLicense(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if id == "" {
|
||||
lang := middleware.GetLanguage(c)
|
||||
status, errCode, message := i18n.NewI18nErrorResponse("900001", lang)
|
||||
c.JSON(status, models.ErrorResponse{
|
||||
Code: errCode,
|
||||
Message: message,
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var req models.LicenseRevokeRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
lang := middleware.GetLanguage(c)
|
||||
status, errCode, message := i18n.NewI18nErrorResponse("900001", lang)
|
||||
c.JSON(status, models.ErrorResponse{
|
||||
Code: errCode,
|
||||
Message: message + ": " + err.Error(),
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 设置语言到Context中
|
||||
ctx := middleware.WithLanguage(c.Request.Context(), c)
|
||||
|
||||
data, err := h.licenseService.RevokeLicense(ctx, id, &req)
|
||||
if err != nil {
|
||||
// 错误已经在Service层完全包装好了,直接使用
|
||||
var i18nErr *i18n.I18nError
|
||||
if errors.As(err, &i18nErr) {
|
||||
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
|
||||
Code: i18nErr.Code,
|
||||
Message: i18nErr.Message,
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
} else {
|
||||
// 兜底:理论上不应该到这里,因为Service层应该返回I18nError
|
||||
lang := middleware.GetLanguage(c)
|
||||
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang)
|
||||
c.JSON(status, models.ErrorResponse{
|
||||
Code: errCode,
|
||||
Message: message,
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
lang := middleware.GetLanguage(c)
|
||||
successMessage := i18n.GetErrorMessage("000000", lang)
|
||||
c.JSON(http.StatusOK, models.APIResponse{
|
||||
Code: "000000",
|
||||
Message: successMessage,
|
||||
Data: data,
|
||||
})
|
||||
}
|
||||
|
||||
// DownloadLicenseFile 下载许可证文件
|
||||
// @Summary 下载许可证文件
|
||||
// @Description 下载加密的许可证文件,用于客户端软件激活
|
||||
// @Tags 许可证管理
|
||||
// @Accept json
|
||||
// @Produce application/octet-stream
|
||||
// @Security BearerAuth
|
||||
// @Param id path string true "许可证ID"
|
||||
// @Success 200 {file} binary "许可证文件"
|
||||
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
|
||||
// @Failure 401 {object} models.ErrorResponse "未认证"
|
||||
// @Failure 404 {object} models.ErrorResponse "许可证不存在"
|
||||
// @Failure 409 {object} models.ErrorResponse "许可证已被撤销"
|
||||
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
|
||||
// @Router /api/v1/licenses/{id}/download [get]
|
||||
func (h *LicenseHandler) DownloadLicenseFile(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if id == "" {
|
||||
lang := middleware.GetLanguage(c)
|
||||
status, errCode, message := i18n.NewI18nErrorResponse("900001", lang)
|
||||
c.JSON(status, models.ErrorResponse{
|
||||
Code: errCode,
|
||||
Message: message,
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 设置语言到Context中
|
||||
ctx := middleware.WithLanguage(c.Request.Context(), c)
|
||||
|
||||
fileData, fileName, err := h.licenseService.GenerateLicenseFile(ctx, id)
|
||||
if err != nil {
|
||||
// 错误已经在Service层完全包装好了,直接使用
|
||||
var i18nErr *i18n.I18nError
|
||||
if errors.As(err, &i18nErr) {
|
||||
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
|
||||
Code: i18nErr.Code,
|
||||
Message: i18nErr.Message,
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
} else {
|
||||
// 兜底:理论上不应该到这里,因为Service层应该返回I18nError
|
||||
lang := middleware.GetLanguage(c)
|
||||
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang)
|
||||
c.JSON(status, models.ErrorResponse{
|
||||
Code: errCode,
|
||||
Message: message,
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 设置响应头,指示这是一个文件下载
|
||||
c.Header("Content-Description", "File Transfer")
|
||||
c.Header("Content-Type", "application/octet-stream")
|
||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", fileName))
|
||||
c.Header("Content-Transfer-Encoding", "binary")
|
||||
c.Header("Cache-Control", "must-revalidate")
|
||||
c.Header("Pragma", "public")
|
||||
|
||||
// 返回文件内容
|
||||
c.Data(http.StatusOK, "application/octet-stream", fileData)
|
||||
}
|
||||
|
||||
// ActivateLicense 激活许可证
|
||||
// @Summary 激活许可证
|
||||
// @Description 客户端使用授权码激活软件,获取许可证文件
|
||||
// @Tags 许可证激活
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body models.ActivateRequest true "激活请求"
|
||||
// @Success 200 {object} models.APIResponse{data=models.ActivateResponse} "激活成功"
|
||||
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
|
||||
// @Failure 404 {object} models.ErrorResponse "授权码不存在"
|
||||
// @Failure 409 {object} models.ErrorResponse "授权码已锁定或已过期"
|
||||
// @Failure 429 {object} models.ErrorResponse "激活数量已达上限"
|
||||
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
|
||||
// @Router /api/v1/activate [post]
|
||||
func (h *LicenseHandler) ActivateLicense(c *gin.Context) {
|
||||
var req models.ActivateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
lang := middleware.GetLanguage(c)
|
||||
status, errCode, message := i18n.NewI18nErrorResponse("900001", lang)
|
||||
c.JSON(status, models.ErrorResponse{
|
||||
Code: errCode,
|
||||
Message: message + ": " + err.Error(),
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 设置语言到Context中
|
||||
ctx := middleware.WithLanguage(c.Request.Context(), c)
|
||||
|
||||
// 获取客户端IP
|
||||
clientIP := c.ClientIP()
|
||||
|
||||
data, err := h.licenseService.ActivateLicense(ctx, &req, clientIP)
|
||||
if err != nil {
|
||||
// 错误已经在Service层完全包装好了,直接使用
|
||||
var i18nErr *i18n.I18nError
|
||||
if errors.As(err, &i18nErr) {
|
||||
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
|
||||
Code: i18nErr.Code,
|
||||
Message: i18nErr.Message,
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
} else {
|
||||
// 兜底:理论上不应该到这里,因为Service层应该返回I18nError
|
||||
lang := middleware.GetLanguage(c)
|
||||
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang)
|
||||
c.JSON(status, models.ErrorResponse{
|
||||
Code: errCode,
|
||||
Message: message,
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
lang := middleware.GetLanguage(c)
|
||||
successMessage := i18n.GetErrorMessage("000000", lang)
|
||||
c.JSON(http.StatusOK, models.APIResponse{
|
||||
Code: "000000",
|
||||
Message: successMessage,
|
||||
Data: data,
|
||||
})
|
||||
}
|
||||
|
||||
// Heartbeat 心跳检测
|
||||
// @Summary 心跳检测
|
||||
// @Description 客户端定期发送心跳,更新在线状态和使用数据
|
||||
// @Tags 许可证激活
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body models.HeartbeatRequest true "心跳请求"
|
||||
// @Success 200 {object} models.APIResponse{data=models.HeartbeatResponse} "心跳成功"
|
||||
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
|
||||
// @Failure 404 {object} models.ErrorResponse "许可证不存在"
|
||||
// @Failure 409 {object} models.ErrorResponse "许可证已被撤销"
|
||||
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
|
||||
// @Router /api/v1/heartbeat [post]
|
||||
func (h *LicenseHandler) Heartbeat(c *gin.Context) {
|
||||
var req models.HeartbeatRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
lang := middleware.GetLanguage(c)
|
||||
status, errCode, message := i18n.NewI18nErrorResponse("900001", lang)
|
||||
c.JSON(status, models.ErrorResponse{
|
||||
Code: errCode,
|
||||
Message: message + ": " + err.Error(),
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 设置语言到Context中
|
||||
ctx := middleware.WithLanguage(c.Request.Context(), c)
|
||||
|
||||
// 获取客户端IP
|
||||
clientIP := c.ClientIP()
|
||||
|
||||
data, err := h.licenseService.Heartbeat(ctx, &req, clientIP)
|
||||
if err != nil {
|
||||
// 错误已经在Service层完全包装好了,直接使用
|
||||
var i18nErr *i18n.I18nError
|
||||
if errors.As(err, &i18nErr) {
|
||||
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
|
||||
Code: i18nErr.Code,
|
||||
Message: i18nErr.Message,
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
} else {
|
||||
// 兜底:理论上不应该到这里,因为Service层应该返回I18nError
|
||||
lang := middleware.GetLanguage(c)
|
||||
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang)
|
||||
c.JSON(status, models.ErrorResponse{
|
||||
Code: errCode,
|
||||
Message: message,
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
lang := middleware.GetLanguage(c)
|
||||
successMessage := i18n.GetErrorMessage("000000", lang)
|
||||
c.JSON(http.StatusOK, models.APIResponse{
|
||||
Code: "000000",
|
||||
Message: successMessage,
|
||||
Data: data,
|
||||
})
|
||||
}
|
||||
|
||||
// GetStatsOverview 获取授权概览统计
|
||||
// @Summary 获取授权概览统计
|
||||
// @Description 获取授权码、许可证的总体统计信息
|
||||
// @Tags 统计分析
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} models.APIResponse{data=models.StatsOverviewResponse} "查询成功"
|
||||
// @Failure 401 {object} models.ErrorResponse "未认证"
|
||||
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
|
||||
// @Router /api/v1/stats/overview [get]
|
||||
func (h *LicenseHandler) GetStatsOverview(c *gin.Context) {
|
||||
// 设置语言到Context中
|
||||
ctx := middleware.WithLanguage(c.Request.Context(), c)
|
||||
|
||||
data, err := h.licenseService.GetStatsOverview(ctx)
|
||||
if err != nil {
|
||||
// 错误已经在Service层完全包装好了,直接使用
|
||||
var i18nErr *i18n.I18nError
|
||||
if errors.As(err, &i18nErr) {
|
||||
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
|
||||
Code: i18nErr.Code,
|
||||
Message: i18nErr.Message,
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
} else {
|
||||
// 兜底:理论上不应该到这里,因为Service层应该返回I18nError
|
||||
lang := middleware.GetLanguage(c)
|
||||
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang)
|
||||
c.JSON(status, models.ErrorResponse{
|
||||
Code: errCode,
|
||||
Message: message,
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
lang := middleware.GetLanguage(c)
|
||||
successMessage := i18n.GetErrorMessage("000000", lang)
|
||||
c.JSON(http.StatusOK, models.APIResponse{
|
||||
Code: "000000",
|
||||
Message: successMessage,
|
||||
Data: data,
|
||||
})
|
||||
}
|
||||
@@ -47,7 +47,7 @@ func (h *SystemHandler) GetSystemInfo(c *gin.Context) {
|
||||
systemInfo := h.systemService.GetSystemInfo()
|
||||
|
||||
c.JSON(http.StatusOK, models.APIResponse{
|
||||
Code: http.StatusOK,
|
||||
Code: "000000",
|
||||
Message: "获取成功",
|
||||
Data: systemInfo,
|
||||
})
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"license-manager/pkg/i18n"
|
||||
pkgcontext "license-manager/pkg/context"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -210,3 +212,11 @@ func (h *I18nResponseHelper) ErrorResponse(code string, customMessage ...string)
|
||||
lang := h.GetLang()
|
||||
return i18n.NewI18nErrorResponse(code, lang, customMessage...)
|
||||
}
|
||||
|
||||
// Context相关工具函数
|
||||
|
||||
// WithLanguage 将语言信息添加到Context中
|
||||
func WithLanguage(ctx context.Context, c *gin.Context) context.Context {
|
||||
lang := GetLanguage(c)
|
||||
return pkgcontext.WithLanguage(ctx, lang)
|
||||
}
|
||||
@@ -5,7 +5,10 @@ import (
|
||||
"license-manager/internal/api/handlers"
|
||||
"license-manager/internal/api/middleware"
|
||||
"license-manager/internal/config"
|
||||
"license-manager/internal/database"
|
||||
"license-manager/internal/repository"
|
||||
"license-manager/internal/service"
|
||||
"license-manager/pkg/logger"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
swaggerFiles "github.com/swaggo/files"
|
||||
@@ -31,13 +34,34 @@ func SetupRouter() *gin.Engine {
|
||||
router.Use(middleware.I18nMiddleware(i18nConfig))
|
||||
}
|
||||
|
||||
// 初始化服务
|
||||
authService := service.NewAuthService()
|
||||
systemService := service.NewSystemService()
|
||||
// 初始化数据访问层
|
||||
db := database.GetDB()
|
||||
customerRepo := repository.NewCustomerRepository(db)
|
||||
userRepo := repository.NewUserRepository(db)
|
||||
authCodeRepo := repository.NewAuthorizationCodeRepository(db)
|
||||
licenseRepo := repository.NewLicenseRepository(db)
|
||||
dashboardRepo := repository.NewDashboardRepository(db)
|
||||
|
||||
// 初始化处理器
|
||||
// 获取logger实例
|
||||
log := logger.GetLogger()
|
||||
|
||||
// 初始化服务层
|
||||
authService := service.NewAuthService(userRepo)
|
||||
systemService := service.NewSystemService()
|
||||
customerService := service.NewCustomerService(customerRepo)
|
||||
enumService := service.NewEnumService()
|
||||
authCodeService := service.NewAuthorizationCodeService(authCodeRepo, customerRepo, licenseRepo)
|
||||
licenseService := service.NewLicenseService(licenseRepo, db, log)
|
||||
dashboardService := service.NewDashboardService(dashboardRepo)
|
||||
|
||||
// 初始化处理器层
|
||||
authHandler := handlers.NewAuthHandler(authService)
|
||||
systemHandler := handlers.NewSystemHandler(systemService)
|
||||
customerHandler := handlers.NewCustomerHandler(customerService)
|
||||
enumHandler := handlers.NewEnumHandler(enumService)
|
||||
authCodeHandler := handlers.NewAuthorizationCodeHandler(authCodeService)
|
||||
licenseHandler := handlers.NewLicenseHandler(licenseService)
|
||||
dashboardHandler := handlers.NewDashboardHandler(dashboardService)
|
||||
|
||||
// 健康检测接口(无需认证)
|
||||
router.GET("/health", systemHandler.HealthCheck)
|
||||
@@ -45,26 +69,65 @@ func SetupRouter() *gin.Engine {
|
||||
// Swagger文档路由
|
||||
router.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler, ginSwagger.URL("/swagger/doc.json")))
|
||||
|
||||
// API v1 路由组
|
||||
v1 := router.Group("/api/v1")
|
||||
// API路由组
|
||||
api := router.Group("/api")
|
||||
{
|
||||
// 公开接口(无需认证)
|
||||
public := v1.Group("")
|
||||
public := api.Group("")
|
||||
{
|
||||
public.POST("/login", authHandler.Login)
|
||||
public.POST("/v1/login", authHandler.Login)
|
||||
|
||||
// 许可证激活接口(客户端软件使用)
|
||||
public.POST("/v1/activate", licenseHandler.ActivateLicense)
|
||||
public.POST("/v1/heartbeat", licenseHandler.Heartbeat)
|
||||
}
|
||||
|
||||
// 需要认证的接口
|
||||
auth := v1.Group("")
|
||||
auth := api.Group("")
|
||||
auth.Use(middleware.AuthMiddleware())
|
||||
{
|
||||
// 认证相关
|
||||
auth.POST("/logout", authHandler.Logout)
|
||||
auth.POST("/auth/refresh", authHandler.RefreshToken)
|
||||
auth.POST("/v1/logout", authHandler.Logout)
|
||||
auth.POST("/v1/auth/refresh", authHandler.RefreshToken)
|
||||
|
||||
// 客户管理
|
||||
auth.GET("/customers", customerHandler.GetCustomerList)
|
||||
auth.GET("/customers/:id", customerHandler.GetCustomer)
|
||||
auth.POST("/customers", customerHandler.CreateCustomer)
|
||||
auth.PUT("/customers/:id", customerHandler.UpdateCustomer)
|
||||
auth.DELETE("/customers/:id", customerHandler.DeleteCustomer)
|
||||
auth.PATCH("/customers/:id/status", customerHandler.UpdateCustomerStatus)
|
||||
|
||||
// 枚举管理
|
||||
auth.GET("/enums", enumHandler.GetAllEnums)
|
||||
auth.GET("/enums/:type", enumHandler.GetEnumsByType)
|
||||
|
||||
// 授权码管理
|
||||
auth.GET("/v1/authorization-codes", authCodeHandler.GetAuthorizationCodeList)
|
||||
auth.POST("/v1/authorization-codes", authCodeHandler.CreateAuthorizationCode)
|
||||
auth.GET("/v1/authorization-codes/:id", authCodeHandler.GetAuthorizationCode)
|
||||
auth.PUT("/v1/authorization-codes/:id", authCodeHandler.UpdateAuthorizationCode)
|
||||
auth.PUT("/v1/authorization-codes/:id/lock", authCodeHandler.LockUnlockAuthorizationCode)
|
||||
auth.DELETE("/v1/authorization-codes/:id", authCodeHandler.DeleteAuthorizationCode)
|
||||
auth.GET("/v1/authorization-codes/:id/changes", authCodeHandler.GetAuthorizationChangeList)
|
||||
|
||||
// 许可证管理
|
||||
auth.GET("/v1/licenses", licenseHandler.GetLicenseList)
|
||||
auth.GET("/v1/licenses/:id", licenseHandler.GetLicense)
|
||||
auth.POST("/v1/licenses", licenseHandler.CreateLicense)
|
||||
auth.PUT("/v1/licenses/:id/revoke", licenseHandler.RevokeLicense)
|
||||
auth.GET("/v1/licenses/:id/download", licenseHandler.DownloadLicenseFile)
|
||||
|
||||
// 统计分析
|
||||
auth.GET("/v1/stats/overview", licenseHandler.GetStatsOverview)
|
||||
|
||||
// 仪表盘接口
|
||||
auth.GET("/v1/dashboard/authorization-trend", dashboardHandler.GetAuthorizationTrend)
|
||||
auth.GET("/v1/dashboard/recent-authorizations", dashboardHandler.GetRecentAuthorizations)
|
||||
}
|
||||
|
||||
// 管理员接口
|
||||
admin := v1.Group("/admin")
|
||||
admin := api.Group("/v1/admin")
|
||||
admin.Use(middleware.AuthMiddleware(), middleware.AdminOnlyMiddleware())
|
||||
{
|
||||
admin.GET("/system/info", systemHandler.GetSystemInfo)
|
||||
|
||||
@@ -13,6 +13,7 @@ type Config struct {
|
||||
Auth AuthConfig `mapstructure:"auth"`
|
||||
Log LogConfig `mapstructure:"log"`
|
||||
I18n I18nConfig `mapstructure:"i18n"`
|
||||
License LicenseConfig `mapstructure:"license"`
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
@@ -39,24 +40,18 @@ type DatabaseConfig struct {
|
||||
|
||||
type AuthConfig struct {
|
||||
JWT JWTConfig `mapstructure:"jwt"`
|
||||
Admin AdminConfig `mapstructure:"admin"`
|
||||
Security SecurityConfig `mapstructure:"security"`
|
||||
}
|
||||
|
||||
type JWTConfig struct {
|
||||
Secret string `mapstructure:"secret"`
|
||||
ExpireHours int `mapstructure:"expire_hours"`
|
||||
RefreshThresholdMinutes int `mapstructure:"refresh_threshold_minutes"`
|
||||
}
|
||||
|
||||
type AdminConfig struct {
|
||||
Username string `mapstructure:"username"`
|
||||
Password string `mapstructure:"password"`
|
||||
Secret string `mapstructure:"secret"`
|
||||
ExpireHours int `mapstructure:"expire_hours"`
|
||||
RefreshThresholdMinutes int `mapstructure:"refresh_threshold_minutes"`
|
||||
}
|
||||
|
||||
type SecurityConfig struct {
|
||||
MaxLoginAttempts int `mapstructure:"max_login_attempts"`
|
||||
LockoutDurationMinutes int `mapstructure:"lockout_duration_minutes"`
|
||||
MaxLoginAttempts int `mapstructure:"max_login_attempts"`
|
||||
LockoutDurationMinutes int `mapstructure:"lockout_duration_minutes"`
|
||||
}
|
||||
|
||||
type LogConfig struct {
|
||||
@@ -71,12 +66,27 @@ type I18nConfig struct {
|
||||
SupportLangs []string `mapstructure:"support_langs"` // 支持的语言列表
|
||||
}
|
||||
|
||||
type LicenseConfig struct {
|
||||
// RSA非对称加密配置
|
||||
RSA RSAConfig `mapstructure:"rsa"`
|
||||
|
||||
HeartbeatTimeout int `mapstructure:"heartbeat_timeout"` // 心跳超时时间(秒)
|
||||
OfflineTimeout int `mapstructure:"offline_timeout"` // 离线超时时间(分钟)
|
||||
ExpiringDays int `mapstructure:"expiring_days"` // 即将过期天数
|
||||
}
|
||||
|
||||
type RSAConfig struct {
|
||||
PrivateKeyPath string `mapstructure:"private_key_path"` // RSA私钥文件路径
|
||||
PublicKeyPath string `mapstructure:"public_key_path"` // RSA公钥文件路径
|
||||
KeySize int `mapstructure:"key_size"` // 密钥大小(2048或4096),默认2048
|
||||
}
|
||||
|
||||
var AppConfig *Config
|
||||
|
||||
func Load(configPath string) error {
|
||||
viper.SetConfigName("config")
|
||||
viper.SetConfigType("yaml")
|
||||
|
||||
|
||||
if configPath != "" {
|
||||
viper.SetConfigFile(configPath)
|
||||
} else {
|
||||
@@ -128,10 +138,7 @@ func setDefaults() {
|
||||
viper.SetDefault("auth.jwt.secret", "license-manager-default-secret-key")
|
||||
viper.SetDefault("auth.jwt.expire_hours", 1)
|
||||
viper.SetDefault("auth.jwt.refresh_threshold_minutes", 30)
|
||||
|
||||
viper.SetDefault("auth.admin.username", "admin")
|
||||
viper.SetDefault("auth.admin.password", "admin@123")
|
||||
|
||||
|
||||
viper.SetDefault("auth.security.max_login_attempts", 5)
|
||||
viper.SetDefault("auth.security.lockout_duration_minutes", 15)
|
||||
|
||||
@@ -144,8 +151,17 @@ func setDefaults() {
|
||||
viper.SetDefault("i18n.default_lang", "zh-CN")
|
||||
viper.SetDefault("i18n.config_path", "../configs/i18n/errors")
|
||||
viper.SetDefault("i18n.support_langs", []string{"zh-CN", "en-US", "ja-JP"})
|
||||
|
||||
// License defaults
|
||||
viper.SetDefault("license.encryption_key", "license-manager-secret-key-32b") // legacy AES key
|
||||
viper.SetDefault("license.rsa.private_key_path", "configs/rsa_private_key.pem")
|
||||
viper.SetDefault("license.rsa.public_key_path", "configs/rsa_public_key.pem")
|
||||
viper.SetDefault("license.rsa.key_size", 2048)
|
||||
viper.SetDefault("license.heartbeat_timeout", 300)
|
||||
viper.SetDefault("license.offline_timeout", 1440)
|
||||
viper.SetDefault("license.expiring_days", 30)
|
||||
}
|
||||
|
||||
func GetConfig() *Config {
|
||||
return AppConfig
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"license-manager/internal/config"
|
||||
"license-manager/internal/models"
|
||||
"license-manager/pkg/utils"
|
||||
)
|
||||
|
||||
// AutoMigrate 执行自动数据库迁移
|
||||
@@ -21,6 +22,10 @@ func AutoMigrate() error {
|
||||
err := DB.AutoMigrate(
|
||||
&models.Customer{},
|
||||
&models.CustomerCodeSequence{},
|
||||
&models.User{},
|
||||
&models.AuthorizationCode{},
|
||||
&models.License{},
|
||||
&models.AuthorizationChange{},
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to migrate database: %w", err)
|
||||
@@ -31,6 +36,11 @@ func AutoMigrate() error {
|
||||
return fmt.Errorf("failed to initialize customer code sequence: %w", err)
|
||||
}
|
||||
|
||||
// 初始化默认管理员用户
|
||||
if err := initDefaultAdminUser(); err != nil {
|
||||
return fmt.Errorf("failed to initialize default admin user: %w", err)
|
||||
}
|
||||
|
||||
log.Println("Database auto migration completed successfully")
|
||||
return nil
|
||||
}
|
||||
@@ -69,5 +79,38 @@ func RunMigrationIfEnabled() error {
|
||||
}
|
||||
|
||||
log.Println("Auto migration is disabled in config")
|
||||
return nil
|
||||
}
|
||||
|
||||
// initDefaultAdminUser 初始化默认管理员用户
|
||||
func initDefaultAdminUser() error {
|
||||
var user models.User
|
||||
result := DB.Where("username = ?", "admin").First(&user)
|
||||
|
||||
if result.Error != nil {
|
||||
// 用户不存在,创建默认管理员
|
||||
passwordHash, err := utils.HashPassword("admin@123")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to hash password: %w", err)
|
||||
}
|
||||
|
||||
user = models.User{
|
||||
Username: "admin",
|
||||
Email: "admin@example.com",
|
||||
PasswordHash: passwordHash,
|
||||
FullName: "系统管理员",
|
||||
Role: "admin",
|
||||
Status: "active",
|
||||
}
|
||||
|
||||
if err := DB.Create(&user).Error; err != nil {
|
||||
return fmt.Errorf("failed to create default admin user: %w", err)
|
||||
}
|
||||
|
||||
log.Println("Created default admin user: admin / admin@123")
|
||||
} else {
|
||||
log.Println("Default admin user already exists")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
+243
-11
@@ -1,27 +1,259 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// LoginRequest 登录请求结构
|
||||
type LoginRequest struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
Username string `json:"username" binding:"required"` // 用户名
|
||||
Password string `json:"password" binding:"required"` // 密码
|
||||
}
|
||||
|
||||
// LoginResponse 登录响应结构
|
||||
type LoginResponse struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
Code string `json:"code"` // 响应代码
|
||||
Message string `json:"message"` // 响应消息
|
||||
Data interface{} `json:"data,omitempty"` // 响应数据
|
||||
}
|
||||
|
||||
// LoginData 登录成功返回的数据
|
||||
type LoginData struct {
|
||||
Token string `json:"token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
UserInfo UserInfo `json:"user_info"`
|
||||
Token string `json:"token"` // JWT令牌
|
||||
ExpiresIn int `json:"expires_in"` // 过期时间(秒)
|
||||
UserInfo UserInfo `json:"user_info"` // 用户信息
|
||||
}
|
||||
|
||||
// UserInfo 用户信息
|
||||
type UserInfo struct {
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
Username string `json:"username"` // 用户名
|
||||
Role string `json:"role"` // 角色
|
||||
}
|
||||
|
||||
// AuthorizationCode 授权码模型
|
||||
type AuthorizationCode struct {
|
||||
ID string `gorm:"type:varchar(36);primaryKey" json:"id"` // 授权码ID
|
||||
Code string `gorm:"type:varchar(100);uniqueIndex;not null" json:"code"` // 授权码
|
||||
CustomerID string `gorm:"type:varchar(36);not null;index" json:"customer_id"` // 客户ID
|
||||
CustomerName string `gorm:"-" json:"customer_name,omitempty"` // 客户名称
|
||||
CustomerNameDisplay string `gorm:"-" json:"customer_name_display,omitempty"` // 客户名称显示(多语言)
|
||||
CreatedBy string `gorm:"type:varchar(36);not null" json:"created_by"` // 创建人ID
|
||||
SoftwareID *string `gorm:"type:varchar(50)" json:"software_id"` // 软件ID
|
||||
Description *string `gorm:"type:text" json:"description"` // 描述
|
||||
StartDate time.Time `gorm:"type:datetime(3);not null" json:"start_date"` // 生效日期
|
||||
EndDate time.Time `gorm:"type:datetime(3);not null" json:"end_date"` // 失效日期
|
||||
DeploymentType string `gorm:"type:varchar(20);not null;default:'standalone'" json:"deployment_type"` // 部署类型:standalone/cloud/hybrid
|
||||
DeploymentTypeDisplay string `gorm:"-" json:"deployment_type_display,omitempty"` // 部署类型显示(多语言)
|
||||
EncryptionType *string `gorm:"type:varchar(20);default:'standard'" json:"encryption_type"` // 加密类型:standard/advanced
|
||||
EncryptionTypeDisplay string `gorm:"-" json:"encryption_type_display,omitempty"` // 加密类型显示(多语言)
|
||||
SoftwareVersion *string `gorm:"type:varchar(50)" json:"software_version"` // 软件版本
|
||||
MaxActivations int `gorm:"not null;default:1" json:"max_activations"` // 最大激活次数
|
||||
CurrentActivations int `gorm:"-" json:"current_activations,omitempty"` // 当前激活次数
|
||||
IsLocked bool `gorm:"not null;default:false" json:"is_locked"` // 是否锁定
|
||||
LockReason *string `gorm:"type:text" json:"lock_reason"` // 锁定原因
|
||||
LockedAt *time.Time `gorm:"type:datetime(3)" json:"locked_at"` // 锁定时间
|
||||
LockedBy *string `gorm:"type:varchar(36)" json:"locked_by"` // 锁定人ID
|
||||
FeatureConfig JSON `gorm:"type:json" json:"feature_config" swaggertype:"object"` // 功能配置(JSON对象)
|
||||
UsageLimits JSON `gorm:"type:json" json:"usage_limits" swaggertype:"object"` // 使用限制(JSON对象)
|
||||
CustomParameters JSON `gorm:"type:json" json:"custom_parameters" swaggertype:"object"` // 自定义参数(JSON对象)
|
||||
Status string `gorm:"-" json:"status,omitempty"` // 状态:normal/locked/expired
|
||||
StatusDisplay string `gorm:"-" json:"status_display,omitempty"` // 状态显示(多语言)
|
||||
CreatedAt time.Time `gorm:"type:datetime(3);not null" json:"created_at"` // 创建时间
|
||||
UpdatedAt time.Time `gorm:"type:datetime(3);not null" json:"updated_at"` // 更新时间
|
||||
CustomerInfo *CustomerInfoForAuthCode `gorm:"-" json:"customer_info,omitempty"` // 客户信息(仅在详情接口返回)
|
||||
ActivatedLicensesCount int64 `gorm:"-" json:"activated_licenses_count,omitempty"` // 该授权码下已激活的许可证数量
|
||||
}
|
||||
|
||||
// CustomerInfoForAuthCode 授权码详情中的客户信息结构
|
||||
type CustomerInfoForAuthCode struct {
|
||||
ID string `json:"id"` // 客户ID
|
||||
CustomerCode string `json:"customer_code"` // 客户编号
|
||||
CustomerName string `json:"customer_name"` // 客户名称
|
||||
CustomerType string `json:"customer_type"` // 客户类型
|
||||
CustomerTypeDisplay string `json:"customer_type_display,omitempty"` // 客户类型显示(多语言)
|
||||
Status string `json:"status"` // 客户状态
|
||||
StatusDisplay string `json:"status_display,omitempty"` // 客户状态显示(多语言)
|
||||
CreatedAt string `json:"created_at"` // 客户创建时间
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (AuthorizationCode) TableName() string {
|
||||
return "authorization_codes"
|
||||
}
|
||||
|
||||
// BeforeCreate 创建前自动设置时间戳和ID
|
||||
func (a *AuthorizationCode) BeforeCreate(tx *gorm.DB) error {
|
||||
if a.ID == "" {
|
||||
a.ID = uuid.New().String()
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
if a.CreatedAt.IsZero() {
|
||||
a.CreatedAt = now
|
||||
}
|
||||
if a.UpdatedAt.IsZero() {
|
||||
a.UpdatedAt = now
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BeforeUpdate 更新前自动刷新更新时间
|
||||
func (a *AuthorizationCode) BeforeUpdate(tx *gorm.DB) error {
|
||||
a.UpdatedAt = time.Now()
|
||||
return nil
|
||||
}
|
||||
|
||||
// AuthorizationCodeCreateRequest 创建授权码请求结构
|
||||
type AuthorizationCodeCreateRequest struct {
|
||||
CustomerID string `json:"customer_id" binding:"required"` // 客户ID
|
||||
SoftwareID *string `json:"software_id" binding:"omitempty"` // 软件ID
|
||||
Description *string `json:"description" binding:"omitempty,max=1000"` // 描述
|
||||
ValidityDays int `json:"validity_days" binding:"required,min=1,max=365000"` // 有效天数(1-365000天,365000代表永久有效)
|
||||
DeploymentType string `json:"deployment_type" binding:"required,oneof=standalone cloud hybrid"` // 部署类型:standalone/cloud/hybrid
|
||||
EncryptionType *string `json:"encryption_type" binding:"omitempty,oneof=standard advanced"` // 加密类型:standard/advanced
|
||||
SoftwareVersion *string `json:"software_version" binding:"omitempty"` // 软件版本
|
||||
MaxActivations int `json:"max_activations" binding:"required,min=1"` // 最大激活次数
|
||||
FeatureConfig interface{} `json:"feature_config" binding:"omitempty"` // 功能配置(JSON对象)
|
||||
UsageLimits interface{} `json:"usage_limits" binding:"omitempty"` // 使用限制(JSON对象)
|
||||
CustomParameters interface{} `json:"custom_parameters" binding:"omitempty"` // 自定义参数(JSON对象)
|
||||
}
|
||||
|
||||
// AuthorizationCodeCreateResponse 创建授权码响应结构
|
||||
type AuthorizationCodeCreateResponse struct {
|
||||
ID string `json:"id"` // 授权码ID
|
||||
Code string `json:"code"` // 授权码
|
||||
}
|
||||
|
||||
// AuthorizationCodeListRequest 授权码列表查询请求结构
|
||||
type AuthorizationCodeListRequest struct {
|
||||
Page int `form:"page" binding:"omitempty,min=1"` // 页码,默认1
|
||||
PageSize int `form:"page_size" binding:"omitempty,min=1,max=100"` // 每页条数,默认20,最大100
|
||||
CustomerID string `form:"customer_id" binding:"omitempty"` // 客户ID筛选
|
||||
Status string `form:"status" binding:"omitempty,oneof=normal locked expired"` // 状态筛选
|
||||
StartDate string `form:"start_date" binding:"omitempty"` // 创建开始时间
|
||||
EndDate string `form:"end_date" binding:"omitempty"` // 创建结束时间
|
||||
Sort string `form:"sort" binding:"omitempty,oneof=created_at updated_at code"` // 排序字段,默认created_at
|
||||
Order string `form:"order" binding:"omitempty,oneof=asc desc"` // 排序方向,默认desc
|
||||
}
|
||||
|
||||
// AuthorizationCodeListItem 授权码列表项结构
|
||||
type AuthorizationCodeListItem struct {
|
||||
ID string `json:"id"` // 授权码ID
|
||||
Code string `json:"code"` // 授权码
|
||||
CustomerID string `json:"customer_id"` // 客户ID
|
||||
CustomerName string `json:"customer_name"` // 客户名称
|
||||
CustomerNameDisplay string `json:"customer_name_display,omitempty"` // 客户名称显示(多语言)
|
||||
Status string `json:"status"` // 状态:normal/locked/expired
|
||||
StatusDisplay string `json:"status_display,omitempty"` // 状态显示(多语言)
|
||||
StartDate string `json:"start_date"` // 生效日期
|
||||
EndDate string `json:"end_date"` // 失效日期
|
||||
MaxActivations int `json:"max_activations"` // 最大激活次数
|
||||
CurrentActivations int `json:"current_activations"` // 当前激活次数
|
||||
DeploymentType string `json:"deployment_type"` // 部署类型:standalone/cloud/hybrid
|
||||
DeploymentTypeDisplay string `json:"deployment_type_display,omitempty"` // 部署类型显示(多语言)
|
||||
IsLocked bool `json:"is_locked"` // 是否锁定
|
||||
Description *string `json:"description"` // 描述
|
||||
CreatedAt string `json:"created_at"` // 创建时间
|
||||
}
|
||||
|
||||
// AuthorizationCodeListResponse 授权码列表响应结构
|
||||
type AuthorizationCodeListResponse struct {
|
||||
List []AuthorizationCodeListItem `json:"list"` // 授权码列表
|
||||
Total int64 `json:"total"` // 总记录数
|
||||
Page int `json:"page"` // 当前页码
|
||||
PageSize int `json:"page_size"` // 每页条数
|
||||
TotalPages int `json:"total_pages"` // 总页数
|
||||
}
|
||||
|
||||
// AuthorizationCodeUpdateRequest 更新授权码请求结构
|
||||
type AuthorizationCodeUpdateRequest struct {
|
||||
SoftwareID *string `json:"software_id" binding:"omitempty"` // 软件ID
|
||||
Description *string `json:"description" binding:"omitempty,max=1000"` // 描述
|
||||
ValidityDays *int `json:"validity_days" binding:"omitempty,min=1,max=365000"` // 有效天数(1-365000天,365000代表永久有效)
|
||||
DeploymentType *string `json:"deployment_type" binding:"omitempty,oneof=standalone cloud hybrid"` // 部署类型:standalone/cloud/hybrid
|
||||
EncryptionType *string `json:"encryption_type" binding:"omitempty,oneof=standard advanced"` // 加密类型:standard/advanced
|
||||
SoftwareVersion *string `json:"software_version" binding:"omitempty"` // 软件版本
|
||||
MaxActivations *int `json:"max_activations" binding:"omitempty,min=1"` // 最大激活次数
|
||||
FeatureConfig interface{} `json:"feature_config" binding:"omitempty"` // 功能配置
|
||||
UsageLimits interface{} `json:"usage_limits" binding:"omitempty"` // 使用限制
|
||||
CustomParameters interface{} `json:"custom_parameters" binding:"omitempty"` // 自定义参数
|
||||
ChangeType string `json:"change_type" binding:"required,oneof=renewal upgrade limit_change feature_toggle lock unlock other"` // 变更类型:renewal/upgrade/limit_change/feature_toggle/lock/unlock/other
|
||||
Reason *string `json:"reason" binding:"omitempty,max=500"` // 变更原因
|
||||
}
|
||||
|
||||
// AuthorizationCodeLockRequest 锁定/解锁授权码请求结构
|
||||
type AuthorizationCodeLockRequest struct {
|
||||
IsLocked bool `json:"is_locked" binding:""` // true-锁定,false-解锁
|
||||
LockReason *string `json:"lock_reason" binding:"omitempty,max=500"` // 锁定原因
|
||||
Reason *string `json:"reason" binding:"omitempty,max=500"` // 变更原因(记录到变更历史)
|
||||
}
|
||||
|
||||
// AuthorizationChange 授权变更历史模型
|
||||
type AuthorizationChange struct {
|
||||
ID string `gorm:"type:varchar(36);primaryKey" json:"id"` // 变更记录ID
|
||||
AuthorizationCodeID string `gorm:"type:varchar(36);not null;index" json:"authorization_code_id"` // 授权码ID
|
||||
ChangeType string `gorm:"type:varchar(30);not null;index" json:"change_type"` // 变更类型
|
||||
ChangeTypeDisplay string `gorm:"-" json:"change_type_display,omitempty"` // 变更类型显示(多语言)
|
||||
OldConfig JSON `gorm:"type:json" json:"old_config,omitempty" swaggertype:"object"` // 变更前配置(JSON对象)
|
||||
NewConfig JSON `gorm:"type:json" json:"new_config,omitempty" swaggertype:"object"` // 变更后配置(JSON对象)
|
||||
OperatorID string `gorm:"type:varchar(36);not null;index" json:"operator_id"` // 操作人ID
|
||||
OperatorName string `gorm:"-" json:"operator_name,omitempty"` // 操作人名称
|
||||
Reason *string `gorm:"type:text" json:"reason"` // 变更原因
|
||||
CreatedAt time.Time `gorm:"type:datetime(3);not null;index" json:"created_at"` // 创建时间
|
||||
|
||||
// 关联字段
|
||||
AuthorizationCode *AuthorizationCode `gorm:"foreignKey:AuthorizationCodeID" json:"authorization_code,omitempty"` // 关联的授权码
|
||||
Operator *User `gorm:"foreignKey:OperatorID" json:"operator,omitempty"` // 关联的操作人
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (AuthorizationChange) TableName() string {
|
||||
return "authorization_changes"
|
||||
}
|
||||
|
||||
// BeforeCreate 创建前自动设置时间戳和ID
|
||||
func (a *AuthorizationChange) BeforeCreate(tx *gorm.DB) error {
|
||||
if a.ID == "" {
|
||||
a.ID = uuid.New().String()
|
||||
}
|
||||
|
||||
if a.CreatedAt.IsZero() {
|
||||
a.CreatedAt = time.Now()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AuthorizationChangeListRequest 授权变更历史列表查询请求结构
|
||||
type AuthorizationChangeListRequest struct {
|
||||
Page int `form:"page" binding:"omitempty,min=1"` // 页码,默认1
|
||||
PageSize int `form:"page_size" binding:"omitempty,min=1,max=100"` // 每页条数,默认20,最大100
|
||||
ChangeType string `form:"change_type" binding:"omitempty"` // 变更类型筛选
|
||||
OperatorID string `form:"operator_id" binding:"omitempty"` // 操作人筛选
|
||||
StartDate string `form:"start_date" binding:"omitempty"` // 开始时间
|
||||
EndDate string `form:"end_date" binding:"omitempty"` // 结束时间
|
||||
Sort string `form:"sort" binding:"omitempty,oneof=created_at change_type"` // 排序字段,默认created_at
|
||||
Order string `form:"order" binding:"omitempty,oneof=asc desc"` // 排序方向,默认desc
|
||||
}
|
||||
|
||||
// AuthorizationChangeListItem 授权变更历史列表项结构
|
||||
type AuthorizationChangeListItem struct {
|
||||
ID string `json:"id"` // 变更记录ID
|
||||
ChangeType string `json:"change_type"` // 变更类型
|
||||
ChangeTypeDisplay string `json:"change_type_display,omitempty"` // 变更类型显示(多语言)
|
||||
OperatorID string `json:"operator_id"` // 操作人ID
|
||||
OperatorName string `json:"operator_name,omitempty"` // 操作人名称
|
||||
Reason *string `json:"reason"` // 变更原因
|
||||
CreatedAt string `json:"created_at"` // 创建时间
|
||||
}
|
||||
|
||||
// AuthorizationChangeListResponse 授权变更历史列表响应结构
|
||||
type AuthorizationChangeListResponse struct {
|
||||
List []AuthorizationChangeListItem `json:"list"` // 变更历史列表
|
||||
Total int64 `json:"total"` // 总记录数
|
||||
Page int `json:"page"` // 当前页码
|
||||
PageSize int `json:"page_size"` // 每页条数
|
||||
TotalPages int `json:"total_pages"` // 总页数
|
||||
}
|
||||
@@ -1,8 +1,62 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
)
|
||||
|
||||
// JSON 类型用于GORM JSON字段
|
||||
// swagger:type object
|
||||
type JSON json.RawMessage
|
||||
|
||||
// 实现 driver.Valuer 接口
|
||||
func (j JSON) Value() (driver.Value, error) {
|
||||
if len(j) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return json.RawMessage(j).MarshalJSON()
|
||||
}
|
||||
|
||||
// 实现 sql.Scanner 接口
|
||||
func (j *JSON) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
*j = nil
|
||||
return nil
|
||||
}
|
||||
var bytes []byte
|
||||
switch v := value.(type) {
|
||||
case []byte:
|
||||
bytes = v
|
||||
case string:
|
||||
bytes = []byte(v)
|
||||
default:
|
||||
return errors.New("cannot scan into JSON")
|
||||
}
|
||||
*j = append((*j)[0:0], bytes...)
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarshalJSON 实现 json.Marshaler 接口
|
||||
func (j JSON) MarshalJSON() ([]byte, error) {
|
||||
if len(j) == 0 {
|
||||
return []byte("null"), nil
|
||||
}
|
||||
return j, nil
|
||||
}
|
||||
|
||||
// UnmarshalJSON 实现 json.Unmarshaler 接口
|
||||
func (j *JSON) UnmarshalJSON(data []byte) error {
|
||||
if j == nil {
|
||||
return errors.New("JSON receiver is nil")
|
||||
}
|
||||
*j = append((*j)[0:0], data...)
|
||||
return nil
|
||||
}
|
||||
|
||||
// APIResponse 通用API响应结构
|
||||
type APIResponse struct {
|
||||
Code int `json:"code"`
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
Timestamp string `json:"timestamp,omitempty"`
|
||||
@@ -14,3 +68,22 @@ type ErrorResponse struct {
|
||||
Message string `json:"message"` // 错误描述信息
|
||||
Timestamp string `json:"timestamp"` // 错误发生时间
|
||||
}
|
||||
|
||||
// 枚举相关结构
|
||||
|
||||
// EnumItem 枚举项结构
|
||||
type EnumItem struct {
|
||||
Key string `json:"key"` // 枚举值
|
||||
Display string `json:"display"` // 多语言显示文本
|
||||
}
|
||||
|
||||
// EnumTypeResponse 单个枚举类型响应
|
||||
type EnumTypeResponse struct {
|
||||
Type string `json:"type"` // 枚举类型名称
|
||||
Items []EnumItem `json:"items"` // 枚举项列表
|
||||
}
|
||||
|
||||
// EnumListResponse 枚举列表响应
|
||||
type EnumListResponse struct {
|
||||
Enums []EnumTypeResponse `json:"enums"` // 所有枚举类型
|
||||
}
|
||||
@@ -3,29 +3,37 @@ package models
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Customer 客户信息模型
|
||||
type Customer struct {
|
||||
ID string `gorm:"type:varchar(36);primaryKey;default:(UUID())" json:"id"`
|
||||
ID string `gorm:"type:varchar(36);primaryKey" json:"id"`
|
||||
CustomerCode string `gorm:"type:varchar(20);uniqueIndex;not null" json:"customer_code"`
|
||||
CustomerName string `gorm:"type:varchar(200);not null;index" json:"customer_name"`
|
||||
CustomerType string `gorm:"type:varchar(20);not null;default:'enterprise';index" json:"customer_type"`
|
||||
CustomerTypeDisplay string `gorm:"-" json:"customer_type_display,omitempty"`
|
||||
ContactPerson string `gorm:"type:varchar(100);not null" json:"contact_person"`
|
||||
ContactTitle *string `gorm:"type:varchar(100)" json:"contact_title"`
|
||||
Email *string `gorm:"type:varchar(255)" json:"email"`
|
||||
Phone *string `gorm:"type:varchar(20)" json:"phone"`
|
||||
Address *string `gorm:"type:text" json:"address"`
|
||||
CompanySize *string `gorm:"type:varchar(20)" json:"company_size"`
|
||||
CustomerLevel string `gorm:"type:varchar(20);not null;default:'normal';index" json:"customer_level"`
|
||||
CompanySizeDisplay string `gorm:"-" json:"company_size_display,omitempty"`
|
||||
CustomerLevel string `gorm:"type:varchar(20);not null;default:'basic';index" json:"customer_level"`
|
||||
CustomerLevelDisplay string `gorm:"-" json:"customer_level_display,omitempty"`
|
||||
Status string `gorm:"type:varchar(20);not null;default:'active';index" json:"status"`
|
||||
StatusDisplay string `gorm:"-" json:"status_display,omitempty"`
|
||||
Description *string `gorm:"type:text" json:"description"`
|
||||
CreatedAt time.Time `gorm:"not null;index" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"not null" json:"updated_at"`
|
||||
CreatedBy string `gorm:"type:varchar(36);not null;index" json:"created_by"`
|
||||
UpdatedBy *string `gorm:"type:varchar(36)" json:"updated_by"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
|
||||
// 授权统计信息(仅在详情接口返回)
|
||||
AuthorizationStats *AuthorizationStats `gorm:"-" json:"authorization_stats,omitempty"`
|
||||
}
|
||||
|
||||
// CustomerCodeSequence 客户编码序列模型
|
||||
@@ -39,9 +47,15 @@ func (Customer) TableName() string {
|
||||
return "customers"
|
||||
}
|
||||
|
||||
// BeforeCreate 创建前自动设置时间戳
|
||||
// BeforeCreate 创建前自动设置时间戳和ID
|
||||
// 确保数据完整性,避免依赖数据库默认值(符合失败快速原则)
|
||||
func (c *Customer) BeforeCreate(tx *gorm.DB) error {
|
||||
// 生成UUID作为主键ID
|
||||
if c.ID == "" {
|
||||
c.ID = uuid.New().String()
|
||||
}
|
||||
|
||||
// 设置时间戳
|
||||
now := time.Now()
|
||||
if c.CreatedAt.IsZero() {
|
||||
c.CreatedAt = now
|
||||
@@ -63,3 +77,86 @@ func (c *Customer) BeforeUpdate(tx *gorm.DB) error {
|
||||
func (CustomerCodeSequence) TableName() string {
|
||||
return "customer_code_sequence"
|
||||
}
|
||||
|
||||
// CustomerListRequest 客户列表查询请求结构
|
||||
type CustomerListRequest struct {
|
||||
Page int `form:"page" binding:"omitempty,min=1"` // 页码,默认1
|
||||
PageSize int `form:"page_size" binding:"omitempty,min=1,max=100"` // 每页条数,默认20,最大100
|
||||
Search string `form:"search" binding:"omitempty,max=100"` // 搜索关键词(支持客户编码、名称、联系人、邮箱)
|
||||
CustomerType string `form:"customer_type" binding:"omitempty,oneof=individual enterprise government education"` // 客户类型筛选
|
||||
CustomerLevel string `form:"customer_level" binding:"omitempty"` // 客户等级筛选
|
||||
Status string `form:"status" binding:"omitempty,oneof=active disabled"` // 状态筛选
|
||||
Sort string `form:"sort" binding:"omitempty,oneof=created_at updated_at customer_name customer_code"` // 排序字段,默认created_at
|
||||
Order string `form:"order" binding:"omitempty,oneof=asc desc"` // 排序方向,默认desc
|
||||
}
|
||||
|
||||
// CustomerListItem 客户列表项结构(用于列表展示,包含主要字段)
|
||||
type CustomerListItem struct {
|
||||
ID string `json:"id"`
|
||||
CustomerCode string `json:"customer_code"`
|
||||
CustomerName string `json:"customer_name"`
|
||||
CustomerType string `json:"customer_type"`
|
||||
CustomerTypeDisplay string `json:"customer_type_display,omitempty"`
|
||||
ContactPerson string `json:"contact_person"`
|
||||
Email *string `json:"email"`
|
||||
CustomerLevel string `json:"customer_level"`
|
||||
CustomerLevelDisplay string `json:"customer_level_display,omitempty"`
|
||||
Status string `json:"status"`
|
||||
StatusDisplay string `json:"status_display,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// CustomerListResponse 客户列表响应结构
|
||||
type CustomerListResponse struct {
|
||||
List []CustomerListItem `json:"list"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
}
|
||||
|
||||
// CustomerCreateRequest 创建客户请求结构
|
||||
type CustomerCreateRequest struct {
|
||||
CustomerName string `json:"customer_name" binding:"required,max=200"` // 客户名称,必填
|
||||
CustomerType string `json:"customer_type" binding:"required,oneof=individual enterprise government education"` // 客户类型,必填
|
||||
ContactPerson string `json:"contact_person" binding:"required,max=100"` // 联系人姓名,必填
|
||||
ContactTitle *string `json:"contact_title" binding:"omitempty,max=100"` // 联系人职位,可选
|
||||
Email *string `json:"email" binding:"omitempty,email,max=255"` // 邮箱,可选
|
||||
Phone *string `json:"phone" binding:"omitempty,max=20"` // 电话,可选
|
||||
Address *string `json:"address" binding:"omitempty,max=500"` // 地址,可选
|
||||
CompanySize *string `json:"company_size" binding:"omitempty"` // 企业规模,可选
|
||||
CustomerLevel string `json:"customer_level" binding:"required"` // 客户等级,必填
|
||||
Status string `json:"status" binding:"required,oneof=active disabled"` // 状态,必填
|
||||
Description *string `json:"description" binding:"omitempty,max=1000"` // 描述,可选
|
||||
}
|
||||
|
||||
// CustomerUpdateRequest 更新客户请求结构(所有字段都是可选的)
|
||||
type CustomerUpdateRequest struct {
|
||||
CustomerName *string `json:"customer_name" binding:"omitempty,max=200"` // 客户名称,可选
|
||||
CustomerType *string `json:"customer_type" binding:"omitempty,oneof=individual enterprise government education"` // 客户类型,可选
|
||||
ContactPerson *string `json:"contact_person" binding:"omitempty,max=100"` // 联系人姓名,可选
|
||||
ContactTitle *string `json:"contact_title" binding:"omitempty,max=100"` // 联系人职位,可选
|
||||
Email *string `json:"email" binding:"omitempty,email,max=255"` // 邮箱,可选
|
||||
Phone *string `json:"phone" binding:"omitempty,max=20"` // 电话,可选
|
||||
Address *string `json:"address" binding:"omitempty,max=500"` // 地址,可选
|
||||
CompanySize *string `json:"company_size" binding:"omitempty"` // 企业规模,可选
|
||||
CustomerLevel *string `json:"customer_level" binding:"omitempty"` // 客户等级,可选
|
||||
Status *string `json:"status" binding:"omitempty,oneof=active disabled"` // 状态,可选
|
||||
Description *string `json:"description" binding:"omitempty,max=1000"` // 描述,可选
|
||||
}
|
||||
|
||||
// CustomerStatusUpdateRequest 客户状态更新请求结构
|
||||
type CustomerStatusUpdateRequest struct {
|
||||
Status string `json:"status" binding:"required,oneof=active disabled"` // 状态,必填
|
||||
}
|
||||
|
||||
// AuthorizationStats 授权统计信息
|
||||
type AuthorizationStats struct {
|
||||
TotalAuthCodes int64 `json:"total_auth_codes"` // 总授权码数量
|
||||
ExpiredAuthCodes int64 `json:"expired_auth_codes"` // 已过期授权码数量
|
||||
ExpiringSoonAuthCodes int64 `json:"expiring_soon_auth_codes"` // 30日内即将到期授权码数量
|
||||
TotalLicenses int64 `json:"total_licenses"` // 总许可证数量
|
||||
ActiveLicenses int64 `json:"active_licenses"` // 已激活许可证数量
|
||||
InactiveLicenses int64 `json:"inactive_licenses"` // 未激活许可证数量
|
||||
ExpiredLicenses int64 `json:"expired_licenses"` // 已过期许可证数量
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// DashboardAuthorizationTrendRequest 授权趋势查询请求
|
||||
type DashboardAuthorizationTrendRequest struct {
|
||||
Type string `form:"type" binding:"required,oneof=week month custom" json:"type"` // 时间类型: week/month/custom
|
||||
StartDate string `form:"start_date" json:"start_date,omitempty"` // 开始日期 (YYYY-MM-DD格式,当type为custom时必填)
|
||||
EndDate string `form:"end_date" json:"end_date,omitempty"` // 结束日期 (YYYY-MM-DD格式,当type为custom时必填)
|
||||
Timezone string `form:"timezone" json:"timezone,omitempty"` // 时区 (如: Asia/Shanghai, UTC等,默认使用服务器本地时区)
|
||||
}
|
||||
|
||||
// DashboardAuthorizationTrendResponse 授权趋势响应
|
||||
type DashboardAuthorizationTrendResponse struct {
|
||||
Period TrendPeriod `json:"period"` // 时间段信息
|
||||
TrendData []TrendData `json:"trend_data"` // 趋势数据
|
||||
Summary TrendSummary `json:"summary"` // 汇总信息
|
||||
}
|
||||
|
||||
// TrendPeriod 时间段信息
|
||||
type TrendPeriod struct {
|
||||
Type string `json:"type"` // 时间类型
|
||||
StartDate string `json:"start_date"` // 开始日期
|
||||
EndDate string `json:"end_date"` // 结束日期
|
||||
DescriptionDisplay string `json:"description_display"` // 多语言描述
|
||||
}
|
||||
|
||||
// TrendData 单日趋势数据
|
||||
type TrendData struct {
|
||||
Date string `json:"date"` // 日期 (YYYY-MM-DD)
|
||||
TotalAuthorizations int64 `json:"total_authorizations"` // 当日授权总数
|
||||
NewAuthorizations int64 `json:"new_authorizations"` // 当日新增授权数
|
||||
ExpiredAuthorizations int64 `json:"expired_authorizations"` // 当日过期授权数
|
||||
}
|
||||
|
||||
// TrendSummary 趋势汇总信息
|
||||
type TrendSummary struct {
|
||||
TotalCount int64 `json:"total_count"` // 期间总授权数
|
||||
NewCount int64 `json:"new_count"` // 期间新增授权数
|
||||
ExpiredCount int64 `json:"expired_count"` // 期间过期授权数
|
||||
GrowthRate float64 `json:"growth_rate"` // 增长率(百分比)
|
||||
}
|
||||
|
||||
// DashboardRecentAuthorizationsRequest 最近授权列表查询请求
|
||||
type DashboardRecentAuthorizationsRequest struct {
|
||||
Limit int `form:"limit" json:"limit,omitempty"` // 返回数量限制(默认20,最大100)
|
||||
CustomerID string `form:"customer_id" json:"customer_id,omitempty"` // 客户ID筛选(可选)
|
||||
Status string `form:"status" json:"status,omitempty"` // 状态筛选 (normal/locked/expired) 可选
|
||||
}
|
||||
|
||||
// DashboardRecentAuthorizationsResponse 最近授权列表响应
|
||||
type DashboardRecentAuthorizationsResponse struct {
|
||||
List []RecentAuthorization `json:"list"` // 授权列表
|
||||
Total int64 `json:"total"` // 符合条件的授权总数量
|
||||
}
|
||||
|
||||
// RecentAuthorization 最近授权信息
|
||||
type RecentAuthorization struct {
|
||||
ID string `json:"id"` // 授权码ID
|
||||
Code string `json:"code"` // 授权码
|
||||
CustomerID string `json:"customer_id"` // 客户ID
|
||||
CustomerName string `json:"customer_name"` // 客户名称
|
||||
Description string `json:"description"` // 授权描述
|
||||
Status string `json:"status"` // 状态 (normal/locked/expired)
|
||||
StatusDisplay string `json:"status_display"` // 状态显示文本(多语言)
|
||||
StartDate time.Time `json:"start_date"` // 生效时间
|
||||
EndDate time.Time `json:"end_date"` // 到期时间
|
||||
MaxActivations int `json:"max_activations"` // 最大激活数量
|
||||
CurrentActivations int `json:"current_activations"` // 当前激活数量
|
||||
CreatedAt time.Time `json:"created_at"` // 创建时间
|
||||
UpdatedAt time.Time `json:"updated_at"` // 更新时间
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// License 许可证模型
|
||||
type License struct {
|
||||
ID string `gorm:"type:varchar(36);primaryKey" json:"id"`
|
||||
LicenseKey string `gorm:"type:varchar(200);uniqueIndex;not null" json:"license_key"`
|
||||
AuthorizationCodeID string `gorm:"type:varchar(36);not null;index" json:"authorization_code_id"`
|
||||
CustomerID string `gorm:"type:varchar(36);not null;index" json:"customer_id"`
|
||||
HardwareFingerprint string `gorm:"type:varchar(200);not null;index" json:"hardware_fingerprint"`
|
||||
DeviceInfo JSON `gorm:"type:json" json:"device_info,omitempty" swaggertype:"object"`
|
||||
ActivationIP *string `gorm:"type:varchar(45)" json:"activation_ip"`
|
||||
Status string `gorm:"type:varchar(20);not null;default:'inactive';index" json:"status"`
|
||||
StatusDisplay string `gorm:"-" json:"status_display,omitempty"`
|
||||
ActivatedAt *time.Time `gorm:"index" json:"activated_at"`
|
||||
LastHeartbeat *time.Time `gorm:"index" json:"last_heartbeat"`
|
||||
LastOnlineIP *string `gorm:"type:varchar(45)" json:"last_online_ip"`
|
||||
ConfigUpdatedAt *time.Time `gorm:"index" json:"config_updated_at"`
|
||||
UsageData JSON `gorm:"type:json" json:"usage_data,omitempty" swaggertype:"object"`
|
||||
CreatedAt time.Time `gorm:"not null;index" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"not null" json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
|
||||
// 关联字段(用于查询时的JOIN)
|
||||
AuthorizationCode *AuthorizationCode `gorm:"foreignKey:AuthorizationCodeID" json:"authorization_code,omitempty"`
|
||||
Customer *Customer `gorm:"foreignKey:CustomerID" json:"customer,omitempty"`
|
||||
IsOnline bool `gorm:"-" json:"is_online"`
|
||||
IsOnlineDisplay string `gorm:"-" json:"is_online_display,omitempty"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (License) TableName() string {
|
||||
return "licenses"
|
||||
}
|
||||
|
||||
// BeforeCreate 创建前自动设置时间戳和ID
|
||||
func (l *License) BeforeCreate(tx *gorm.DB) error {
|
||||
if l.ID == "" {
|
||||
l.ID = uuid.New().String()
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
if l.CreatedAt.IsZero() {
|
||||
l.CreatedAt = now
|
||||
}
|
||||
if l.UpdatedAt.IsZero() {
|
||||
l.UpdatedAt = now
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BeforeUpdate 更新前自动刷新更新时间
|
||||
func (l *License) BeforeUpdate(tx *gorm.DB) error {
|
||||
l.UpdatedAt = time.Now()
|
||||
return nil
|
||||
}
|
||||
|
||||
// LicenseListRequest 许可证列表查询请求结构
|
||||
type LicenseListRequest struct {
|
||||
Page int `form:"page" binding:"omitempty,min=1"` // 页码,默认1
|
||||
PageSize int `form:"page_size" binding:"omitempty,min=1,max=100"` // 每页条数,默认20,最大100
|
||||
AuthorizationCodeID string `form:"authorization_code_id" binding:"omitempty"` // 授权码ID筛选
|
||||
CustomerID string `form:"customer_id" binding:"omitempty"` // 客户ID筛选
|
||||
Status string `form:"status" binding:"omitempty,oneof=active inactive revoked"` // 状态筛选
|
||||
IsOnline *bool `form:"is_online" binding:"omitempty"` // 在线状态筛选
|
||||
Sort string `form:"sort" binding:"omitempty,oneof=created_at updated_at activated_at last_heartbeat"` // 排序字段,默认created_at
|
||||
Order string `form:"order" binding:"omitempty,oneof=asc desc"` // 排序方向,默认desc
|
||||
}
|
||||
|
||||
// LicenseListItem 许可证列表项结构
|
||||
type LicenseListItem struct {
|
||||
ID string `json:"id"` // 许可证ID
|
||||
LicenseKey string `json:"license_key"` // 许可证密钥
|
||||
AuthorizationCodeID string `json:"authorization_code_id"` // 授权码ID
|
||||
AuthorizationCode string `json:"authorization_code"` // 授权码
|
||||
CustomerName string `json:"customer_name"` // 客户名称
|
||||
HardwareFingerprint string `json:"hardware_fingerprint"` // 硬件指纹
|
||||
Status string `json:"status"` // 许可证状态
|
||||
StatusDisplay string `json:"status_display,omitempty"` // 状态显示名称
|
||||
IsOnline bool `json:"is_online"` // 是否在线
|
||||
IsOnlineDisplay string `json:"is_online_display,omitempty"` // 在线状态显示名称
|
||||
ActivationIP *string `json:"activation_ip"` // 激活IP
|
||||
LastOnlineIP *string `json:"last_online_ip"` // 最后在线IP
|
||||
ActivatedAt *string `json:"activated_at"` // 激活时间
|
||||
LastHeartbeat *string `json:"last_heartbeat"` // 最后心跳时间
|
||||
}
|
||||
|
||||
// LicenseListResponse 许可证列表响应结构
|
||||
type LicenseListResponse struct {
|
||||
List []LicenseListItem `json:"list"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
}
|
||||
|
||||
// LicenseDetailResponse 许可证详情响应结构
|
||||
type LicenseDetailResponse struct {
|
||||
ID string `json:"id"` // 许可证ID
|
||||
LicenseKey string `json:"license_key"` // 许可证密钥
|
||||
AuthorizationCodeID string `json:"authorization_code_id"` // 授权码ID
|
||||
AuthorizationCode string `json:"authorization_code"` // 授权码
|
||||
CustomerID string `json:"customer_id"` // 客户ID
|
||||
CustomerName string `json:"customer_name"` // 客户名称
|
||||
HardwareFingerprint string `json:"hardware_fingerprint"` // 硬件指纹
|
||||
DeviceInfo map[string]interface{} `json:"device_info,omitempty"` // 设备信息
|
||||
ActivationIP *string `json:"activation_ip"` // 激活IP
|
||||
Status string `json:"status"` // 许可证状态
|
||||
StatusDisplay string `json:"status_display,omitempty"` // 状态显示名称
|
||||
IsOnline bool `json:"is_online"` // 是否在线
|
||||
IsOnlineDisplay string `json:"is_online_display,omitempty"` // 在线状态显示名称
|
||||
ActivatedAt *string `json:"activated_at"` // 激活时间
|
||||
LastHeartbeat *string `json:"last_heartbeat"` // 最后心跳时间
|
||||
LastOnlineIP *string `json:"last_online_ip"` // 最后在线IP
|
||||
ConfigUpdatedAt *string `json:"config_updated_at"` // 客户端配置更新时间
|
||||
UsageData map[string]interface{} `json:"usage_data,omitempty"` // 使用数据
|
||||
CreatedAt string `json:"created_at"` // 创建时间
|
||||
UpdatedAt string `json:"updated_at"` // 更新时间
|
||||
}
|
||||
|
||||
// LicenseCreateRequest 手动添加许可证请求结构
|
||||
type LicenseCreateRequest struct {
|
||||
AuthorizationCodeID string `json:"authorization_code_id" binding:"required"` // 授权码ID,必填
|
||||
HardwareFingerprint string `json:"hardware_fingerprint" binding:"required"` // 硬件指纹,必填
|
||||
DeviceInfo map[string]interface{} `json:"device_info,omitempty"` // 设备信息,可选
|
||||
ActivationIP *string `json:"activation_ip" binding:"omitempty,ip"` // 激活IP,可选
|
||||
}
|
||||
|
||||
// LicenseRevokeRequest 撤销许可证请求结构
|
||||
type LicenseRevokeRequest struct {
|
||||
Reason string `json:"reason" binding:"omitempty,max=500"` // 撤销原因,可选
|
||||
}
|
||||
|
||||
// ActivateRequest 软件激活请求结构
|
||||
type ActivateRequest struct {
|
||||
AuthorizationCode string `json:"authorization_code" binding:"required"` // 授权码,必填
|
||||
HardwareFingerprint string `json:"hardware_fingerprint" binding:"required"` // 硬件指纹,必填
|
||||
DeviceInfo map[string]interface{} `json:"device_info,omitempty"` // 设备信息,可选
|
||||
SoftwareVersion *string `json:"software_version" binding:"omitempty"` // 软件版本,可选
|
||||
}
|
||||
|
||||
// ActivateResponse 软件激活响应结构
|
||||
type ActivateResponse struct {
|
||||
LicenseKey string `json:"license_key"` // 许可证密钥
|
||||
LicenseFile string `json:"license_file"` // base64编码的加密许可证文件
|
||||
HeartbeatInterval int `json:"heartbeat_interval"` // 心跳间隔(秒)
|
||||
}
|
||||
|
||||
// HeartbeatRequest 心跳检测请求结构
|
||||
type HeartbeatRequest struct {
|
||||
LicenseKey string `json:"license_key" binding:"required"` // 许可证密钥,必填
|
||||
HardwareFingerprint string `json:"hardware_fingerprint" binding:"required"` // 硬件指纹,必填
|
||||
ConfigUpdatedAt *string `json:"config_updated_at,omitempty"` // 客户端配置更新时间,可选
|
||||
UsageData map[string]interface{} `json:"usage_data,omitempty"` // 使用数据,可选
|
||||
SoftwareVersion *string `json:"software_version,omitempty"` // 软件版本,可选
|
||||
}
|
||||
|
||||
// HeartbeatResponse 心跳检测响应结构
|
||||
type HeartbeatResponse struct {
|
||||
Status string `json:"status"` // 许可证状态
|
||||
ConfigUpdated bool `json:"config_updated"` // 配置是否有更新
|
||||
LicenseFile *string `json:"license_file"` // base64编码的新许可证文件(如有更新)
|
||||
HeartbeatInterval int `json:"heartbeat_interval"` // 下次心跳间隔(秒)
|
||||
}
|
||||
|
||||
// StatsOverviewResponse 授权概览统计响应结构
|
||||
type StatsOverviewResponse struct {
|
||||
TotalAuthCodes int64 `json:"total_auth_codes"` // 总授权码数量(所有授权码的累计总数)
|
||||
ActiveLicenses int64 `json:"active_licenses"` // 活跃许可证数量(状态为active的许可证总数)
|
||||
ExpiringSoon int64 `json:"expiring_soon"` // 即将过期数量(30天内即将过期的授权码,且未被锁定)
|
||||
AbnormalAlerts int64 `json:"abnormal_alerts"` // 异常告警数量(活跃许可证中心跳超时的数量,超过配置的心跳超时时间未收到心跳)
|
||||
GrowthRate GrowthRate `json:"growth_rate"` // 增长率(同比上月)
|
||||
}
|
||||
|
||||
// GrowthRate 增长率结构(同比上月:当前值相比一个月前同一时刻值的增长率)
|
||||
type GrowthRate struct {
|
||||
AuthCodes float64 `json:"auth_codes"` // 授权码增长率(%)(当前总授权码数相比一个月前总授权码数的增长率)
|
||||
Licenses float64 `json:"licenses"` // 许可证增长率(%)(当前活跃许可证数相比一个月前活跃许可证数的增长率)
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// User 用户模型
|
||||
type User struct {
|
||||
ID string `gorm:"type:varchar(36);primaryKey" json:"id"`
|
||||
Username string `gorm:"type:varchar(50);uniqueIndex;not null" json:"username"`
|
||||
Email string `gorm:"type:varchar(255);uniqueIndex;not null" json:"email"`
|
||||
PasswordHash string `gorm:"type:varchar(255);not null" json:"-"` // 不在JSON中显示密码哈希
|
||||
FullName string `gorm:"type:varchar(100);not null" json:"full_name"`
|
||||
Phone *string `gorm:"type:varchar(20)" json:"phone"`
|
||||
Role string `gorm:"type:varchar(20);not null;default:'viewer';index" json:"role"`
|
||||
Status string `gorm:"type:varchar(20);not null;default:'active';index" json:"status"`
|
||||
LastLoginAt *time.Time `gorm:"index" json:"last_login_at"`
|
||||
LastLoginIP *string `gorm:"type:varchar(45)" json:"last_login_ip"`
|
||||
LoginAttempts int `gorm:"not null;default:0" json:"login_attempts"`
|
||||
LockedUntil *time.Time `gorm:"index" json:"locked_until"`
|
||||
CreatedAt time.Time `gorm:"not null;index" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"not null" json:"updated_at"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (User) TableName() string {
|
||||
return "users"
|
||||
}
|
||||
|
||||
// BeforeCreate 创建前自动设置时间戳和ID
|
||||
func (u *User) BeforeCreate(tx *gorm.DB) error {
|
||||
// 生成UUID作为主键ID
|
||||
if u.ID == "" {
|
||||
u.ID = uuid.New().String()
|
||||
}
|
||||
|
||||
// 设置时间戳
|
||||
now := time.Now()
|
||||
if u.CreatedAt.IsZero() {
|
||||
u.CreatedAt = now
|
||||
}
|
||||
if u.UpdatedAt.IsZero() {
|
||||
u.UpdatedAt = now
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BeforeUpdate 更新前自动刷新更新时间
|
||||
func (u *User) BeforeUpdate(tx *gorm.DB) error {
|
||||
u.UpdatedAt = time.Now()
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsAccountLocked 检查账号是否被锁定
|
||||
func (u *User) IsAccountLocked() bool {
|
||||
if u.Status == "locked" {
|
||||
return true
|
||||
}
|
||||
if u.LockedUntil != nil && time.Now().Before(*u.LockedUntil) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// UserCreateRequest 创建用户请求结构
|
||||
type UserCreateRequest struct {
|
||||
Username string `json:"username" binding:"required,min=3,max=50"`
|
||||
Email string `json:"email" binding:"required,email,max=255"`
|
||||
Password string `json:"password" binding:"required,min=8,max=50"`
|
||||
FullName string `json:"full_name" binding:"required,max=100"`
|
||||
Phone *string `json:"phone" binding:"omitempty,max=20"`
|
||||
Role string `json:"role" binding:"required,oneof=admin operator viewer"`
|
||||
Status string `json:"status" binding:"required,oneof=active disabled"`
|
||||
}
|
||||
|
||||
// UserUpdateRequest 更新用户请求结构
|
||||
type UserUpdateRequest struct {
|
||||
Email *string `json:"email" binding:"omitempty,email,max=255"`
|
||||
FullName *string `json:"full_name" binding:"omitempty,max=100"`
|
||||
Phone *string `json:"phone" binding:"omitempty,max=20"`
|
||||
Role *string `json:"role" binding:"omitempty,oneof=admin operator viewer"`
|
||||
Status *string `json:"status" binding:"omitempty,oneof=active disabled locked"`
|
||||
}
|
||||
|
||||
// ChangePasswordRequest 修改密码请求结构
|
||||
type ChangePasswordRequest struct {
|
||||
OldPassword string `json:"old_password" binding:"required"`
|
||||
NewPassword string `json:"new_password" binding:"required,min=8,max=50"`
|
||||
}
|
||||
|
||||
// UserListRequest 用户列表查询请求结构
|
||||
type UserListRequest struct {
|
||||
Page int `form:"page" binding:"omitempty,min=1"`
|
||||
PageSize int `form:"page_size" binding:"omitempty,min=1,max=100"`
|
||||
Search string `form:"search" binding:"omitempty,max=100"`
|
||||
Role string `form:"role" binding:"omitempty,oneof=admin operator viewer"`
|
||||
Status string `form:"status" binding:"omitempty,oneof=active disabled locked"`
|
||||
Sort string `form:"sort" binding:"omitempty,oneof=created_at updated_at username full_name"`
|
||||
Order string `form:"order" binding:"omitempty,oneof=asc desc"`
|
||||
}
|
||||
|
||||
// UserListItem 用户列表项结构
|
||||
type UserListItem struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
FullName string `json:"full_name"`
|
||||
Phone *string `json:"phone"`
|
||||
Role string `json:"role"`
|
||||
Status string `json:"status"`
|
||||
LastLoginAt *time.Time `json:"last_login_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// UserListResponse 用户列表响应结构
|
||||
type UserListResponse struct {
|
||||
List []UserListItem `json:"list"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"license-manager/internal/models"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type authorizationCodeRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewAuthorizationCodeRepository 创建授权码仓储实例
|
||||
func NewAuthorizationCodeRepository(db *gorm.DB) AuthorizationCodeRepository {
|
||||
return &authorizationCodeRepository{
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateAuthorizationCode 创建授权码
|
||||
func (r *authorizationCodeRepository) CreateAuthorizationCode(ctx context.Context, authCode *models.AuthorizationCode) error {
|
||||
return r.db.WithContext(ctx).Create(authCode).Error
|
||||
}
|
||||
|
||||
// GetAuthorizationCodeByID 根据ID获取授权码
|
||||
func (r *authorizationCodeRepository) GetAuthorizationCodeByID(ctx context.Context, id string) (*models.AuthorizationCode, error) {
|
||||
var authCode models.AuthorizationCode
|
||||
err := r.db.WithContext(ctx).First(&authCode, "id = ?", id).Error
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, ErrAuthorizationCodeNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &authCode, nil
|
||||
}
|
||||
|
||||
// GetAuthorizationCodeList 查询授权码列表
|
||||
func (r *authorizationCodeRepository) GetAuthorizationCodeList(ctx context.Context, req *models.AuthorizationCodeListRequest) (*models.AuthorizationCodeListResponse, error) {
|
||||
// 设置默认值
|
||||
page := req.Page
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
pageSize := req.PageSize
|
||||
if pageSize <= 0 {
|
||||
pageSize = 20
|
||||
}
|
||||
sort := req.Sort
|
||||
if sort == "" {
|
||||
sort = "created_at"
|
||||
}
|
||||
order := req.Order
|
||||
if order == "" {
|
||||
order = "desc"
|
||||
}
|
||||
|
||||
// 构建查询,包含状态计算与当前激活数量
|
||||
query := r.db.WithContext(ctx).Table("authorization_codes ac").
|
||||
Select(`ac.id, ac.code, ac.customer_id, c.customer_name,
|
||||
ac.start_date, ac.end_date, ac.max_activations,
|
||||
COALESCE(l.active_count, 0) AS current_activations,
|
||||
ac.deployment_type, ac.is_locked, ac.description, ac.created_at,
|
||||
CASE
|
||||
WHEN ac.is_locked = true THEN 'locked'
|
||||
WHEN ac.end_date < NOW() THEN 'expired'
|
||||
WHEN ac.start_date <= NOW() AND ac.end_date >= NOW() THEN 'normal'
|
||||
ELSE 'expired'
|
||||
END AS status`).
|
||||
Joins("LEFT JOIN customers c ON ac.customer_id = c.id AND c.deleted_at IS NULL").
|
||||
Joins(`LEFT JOIN (
|
||||
SELECT authorization_code_id, COUNT(*) AS active_count
|
||||
FROM licenses
|
||||
WHERE status = 'active' AND deleted_at IS NULL
|
||||
GROUP BY authorization_code_id
|
||||
) l ON ac.id = l.authorization_code_id`)
|
||||
|
||||
// 添加筛选条件
|
||||
if req.CustomerID != "" {
|
||||
query = query.Where("ac.customer_id = ?", req.CustomerID)
|
||||
}
|
||||
|
||||
// 状态筛选 - 在 SQL 层面处理
|
||||
if req.Status != "" {
|
||||
switch req.Status {
|
||||
case "locked":
|
||||
query = query.Where("ac.is_locked = true")
|
||||
case "expired":
|
||||
query = query.Where("ac.is_locked = false AND ac.end_date < NOW()")
|
||||
case "normal":
|
||||
query = query.Where("ac.is_locked = false AND ac.start_date <= NOW() AND ac.end_date >= NOW()")
|
||||
}
|
||||
}
|
||||
|
||||
if req.StartDate != "" {
|
||||
query = query.Where("ac.created_at >= ?", req.StartDate)
|
||||
}
|
||||
if req.EndDate != "" {
|
||||
query = query.Where("ac.created_at <= ?", req.EndDate)
|
||||
}
|
||||
|
||||
// 获取总数
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 分页查询
|
||||
var results []struct {
|
||||
ID string `json:"id"`
|
||||
Code string `json:"code"`
|
||||
CustomerID string `json:"customer_id"`
|
||||
CustomerName *string `json:"customer_name"`
|
||||
StartDate string `json:"start_date"`
|
||||
EndDate string `json:"end_date"`
|
||||
MaxActivations int `json:"max_activations"`
|
||||
CurrentActivations int `json:"current_activations" gorm:"column:current_activations"`
|
||||
DeploymentType string `json:"deployment_type"`
|
||||
IsLocked bool `json:"is_locked"`
|
||||
Description *string `json:"description"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
Status string `json:"status"` // 添加状态字段
|
||||
}
|
||||
|
||||
offset := (page - 1) * pageSize
|
||||
orderClause := sort + " " + order
|
||||
if err := query.Order(orderClause).Limit(pageSize).Offset(offset).Scan(&results).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 转换为响应格式
|
||||
list := make([]models.AuthorizationCodeListItem, len(results))
|
||||
for i, result := range results {
|
||||
customerName := ""
|
||||
if result.CustomerName != nil {
|
||||
customerName = *result.CustomerName
|
||||
}
|
||||
|
||||
list[i] = models.AuthorizationCodeListItem{
|
||||
ID: result.ID,
|
||||
Code: result.Code,
|
||||
CustomerID: result.CustomerID,
|
||||
CustomerName: customerName,
|
||||
Status: result.Status, // 使用 SQL 计算的状态
|
||||
StartDate: result.StartDate,
|
||||
EndDate: result.EndDate,
|
||||
MaxActivations: result.MaxActivations,
|
||||
CurrentActivations: result.CurrentActivations,
|
||||
DeploymentType: result.DeploymentType,
|
||||
IsLocked: result.IsLocked,
|
||||
Description: result.Description,
|
||||
CreatedAt: result.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
totalPages := int((total + int64(pageSize) - 1) / int64(pageSize))
|
||||
|
||||
return &models.AuthorizationCodeListResponse{
|
||||
List: list,
|
||||
Total: total,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
TotalPages: totalPages,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// UpdateAuthorizationCode 更新授权码
|
||||
func (r *authorizationCodeRepository) UpdateAuthorizationCode(ctx context.Context, authCode *models.AuthorizationCode) error {
|
||||
return r.db.WithContext(ctx).Save(authCode).Error
|
||||
}
|
||||
|
||||
// DeleteAuthorizationCode 删除授权码(软删除)
|
||||
func (r *authorizationCodeRepository) DeleteAuthorizationCode(ctx context.Context, id string) error {
|
||||
return r.db.WithContext(ctx).Delete(&models.AuthorizationCode{}, "id = ?", id).Error
|
||||
}
|
||||
|
||||
// CheckCustomerExists 检查客户是否存在
|
||||
func (r *authorizationCodeRepository) CheckCustomerExists(ctx context.Context, customerID string) (bool, error) {
|
||||
var count int64
|
||||
err := r.db.WithContext(ctx).Model(&models.Customer{}).Where("id = ? AND deleted_at IS NULL", customerID).Count(&count).Error
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
// GetAuthorizationChangeList 查询授权变更历史列表
|
||||
func (r *authorizationCodeRepository) GetAuthorizationChangeList(ctx context.Context, authCodeID string, req *models.AuthorizationChangeListRequest) (*models.AuthorizationChangeListResponse, error) {
|
||||
// 设置默认值
|
||||
if req.Page <= 0 {
|
||||
req.Page = 1
|
||||
}
|
||||
if req.PageSize <= 0 {
|
||||
req.PageSize = 20
|
||||
}
|
||||
if req.PageSize > 100 {
|
||||
req.PageSize = 100
|
||||
}
|
||||
if req.Sort == "" {
|
||||
req.Sort = "created_at"
|
||||
}
|
||||
if req.Order == "" {
|
||||
req.Order = "desc"
|
||||
}
|
||||
|
||||
// 构建查询
|
||||
query := r.db.WithContext(ctx).Model(&models.AuthorizationChange{}).
|
||||
Select(`authorization_changes.id, authorization_changes.change_type,
|
||||
authorization_changes.operator_id, users.username as operator_name,
|
||||
authorization_changes.reason, authorization_changes.created_at`).
|
||||
Joins("LEFT JOIN users ON authorization_changes.operator_id = users.id").
|
||||
Where("authorization_changes.authorization_code_id = ?", authCodeID)
|
||||
|
||||
// 变更类型筛选
|
||||
if req.ChangeType != "" {
|
||||
query = query.Where("authorization_changes.change_type = ?", req.ChangeType)
|
||||
}
|
||||
|
||||
// 操作人筛选
|
||||
if req.OperatorID != "" {
|
||||
query = query.Where("authorization_changes.operator_id = ?", req.OperatorID)
|
||||
}
|
||||
|
||||
// 时间范围筛选
|
||||
if req.StartDate != "" {
|
||||
startTime, err := time.Parse("2006-01-02", req.StartDate)
|
||||
if err == nil {
|
||||
query = query.Where("authorization_changes.created_at >= ?", startTime)
|
||||
}
|
||||
}
|
||||
if req.EndDate != "" {
|
||||
endTime, err := time.Parse("2006-01-02", req.EndDate)
|
||||
if err == nil {
|
||||
// 结束时间加一天,以包含当天的所有时间
|
||||
endTime = endTime.AddDate(0, 0, 1)
|
||||
query = query.Where("authorization_changes.created_at < ?", endTime)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取总数
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 排序和分页
|
||||
orderBy := "authorization_changes." + req.Sort + " " + req.Order
|
||||
offset := (req.Page - 1) * req.PageSize
|
||||
|
||||
var changes []struct {
|
||||
ID string `json:"id"`
|
||||
ChangeType string `json:"change_type"`
|
||||
OperatorID string `json:"operator_id"`
|
||||
OperatorName *string `json:"operator_name"`
|
||||
Reason *string `json:"reason"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
if err := query.Order(orderBy).Limit(req.PageSize).Offset(offset).Scan(&changes).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 转换为响应格式
|
||||
list := make([]models.AuthorizationChangeListItem, len(changes))
|
||||
for i, change := range changes {
|
||||
var operatorName string
|
||||
if change.OperatorName != nil {
|
||||
operatorName = *change.OperatorName
|
||||
}
|
||||
|
||||
list[i] = models.AuthorizationChangeListItem{
|
||||
ID: change.ID,
|
||||
ChangeType: change.ChangeType,
|
||||
OperatorID: change.OperatorID,
|
||||
OperatorName: operatorName,
|
||||
Reason: change.Reason,
|
||||
CreatedAt: change.CreatedAt.Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
// 计算总页数
|
||||
totalPages := int(math.Ceil(float64(total) / float64(req.PageSize)))
|
||||
|
||||
return &models.AuthorizationChangeListResponse{
|
||||
List: list,
|
||||
Total: total,
|
||||
Page: req.Page,
|
||||
PageSize: req.PageSize,
|
||||
TotalPages: totalPages,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RecordAuthorizationChange 记录授权变更历史
|
||||
func (r *authorizationCodeRepository) RecordAuthorizationChange(ctx context.Context, change *models.AuthorizationChange) error {
|
||||
return r.db.WithContext(ctx).Create(change).Error
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"license-manager/internal/models"
|
||||
)
|
||||
|
||||
type customerRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewCustomerRepository 创建客户数据访问实例
|
||||
func NewCustomerRepository(db *gorm.DB) CustomerRepository {
|
||||
return &customerRepository{
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
// GetCustomerList 查询客户列表
|
||||
func (r *customerRepository) GetCustomerList(ctx context.Context, req *models.CustomerListRequest) (*models.CustomerListResponse, error) {
|
||||
// 设置默认值
|
||||
if req.Page <= 0 {
|
||||
req.Page = 1
|
||||
}
|
||||
if req.PageSize <= 0 {
|
||||
req.PageSize = 20
|
||||
}
|
||||
if req.PageSize > 100 {
|
||||
req.PageSize = 100
|
||||
}
|
||||
if req.Sort == "" {
|
||||
req.Sort = "created_at"
|
||||
}
|
||||
if req.Order == "" {
|
||||
req.Order = "desc"
|
||||
}
|
||||
|
||||
// 构建查询
|
||||
query := r.db.Model(&models.Customer{})
|
||||
|
||||
// 搜索关键词筛选
|
||||
if req.Search != "" {
|
||||
searchTerm := "%" + strings.TrimSpace(req.Search) + "%"
|
||||
query = query.Where(
|
||||
"customer_code LIKE ? OR customer_name LIKE ? OR contact_person LIKE ? OR email LIKE ?",
|
||||
searchTerm, searchTerm, searchTerm, searchTerm,
|
||||
)
|
||||
}
|
||||
|
||||
// 客户类型筛选
|
||||
if req.CustomerType != "" {
|
||||
query = query.Where("customer_type = ?", req.CustomerType)
|
||||
}
|
||||
|
||||
// 客户等级筛选
|
||||
if req.CustomerLevel != "" {
|
||||
query = query.Where("customer_level = ?", req.CustomerLevel)
|
||||
}
|
||||
|
||||
// 状态筛选
|
||||
if req.Status != "" {
|
||||
query = query.Where("status = ?", req.Status)
|
||||
}
|
||||
|
||||
// 计算总数
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to count customers: %w", err)
|
||||
}
|
||||
|
||||
// 排序和分页
|
||||
offset := (req.Page - 1) * req.PageSize
|
||||
orderClause := fmt.Sprintf("%s %s", req.Sort, strings.ToUpper(req.Order))
|
||||
query = query.Order(orderClause).Offset(offset).Limit(req.PageSize)
|
||||
|
||||
// 查询数据
|
||||
var customers []models.Customer
|
||||
if err := query.Find(&customers).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to query customers: %w", err)
|
||||
}
|
||||
|
||||
// 转换为响应格式
|
||||
customerList := make([]models.CustomerListItem, 0, len(customers))
|
||||
for _, customer := range customers {
|
||||
customerList = append(customerList, models.CustomerListItem{
|
||||
ID: customer.ID,
|
||||
CustomerCode: customer.CustomerCode,
|
||||
CustomerName: customer.CustomerName,
|
||||
CustomerType: customer.CustomerType,
|
||||
ContactPerson: customer.ContactPerson,
|
||||
Email: customer.Email,
|
||||
CustomerLevel: customer.CustomerLevel,
|
||||
Status: customer.Status,
|
||||
CreatedAt: customer.CreatedAt.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
// 计算总页数
|
||||
totalPages := int(total) / req.PageSize
|
||||
if int(total)%req.PageSize > 0 {
|
||||
totalPages++
|
||||
}
|
||||
|
||||
return &models.CustomerListResponse{
|
||||
List: customerList,
|
||||
Total: total,
|
||||
Page: req.Page,
|
||||
PageSize: req.PageSize,
|
||||
TotalPages: totalPages,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetCustomerByID 根据ID获取客户信息
|
||||
func (r *customerRepository) GetCustomerByID(ctx context.Context, id string) (*models.Customer, error) {
|
||||
var customer models.Customer
|
||||
|
||||
if err := r.db.Where("id = ?", id).First(&customer).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, ErrCustomerNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get customer by id: %w", err)
|
||||
}
|
||||
return &customer, nil
|
||||
}
|
||||
|
||||
// CreateCustomer 创建客户
|
||||
func (r *customerRepository) CreateCustomer(ctx context.Context, customer *models.Customer) error {
|
||||
// 使用事务确保数据一致性
|
||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||
// 生成客户编码
|
||||
customerCode, err := r.generateCustomerCode(tx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate customer code: %w", err)
|
||||
}
|
||||
customer.CustomerCode = customerCode
|
||||
|
||||
// 创建客户
|
||||
if err := tx.Create(customer).Error; err != nil {
|
||||
return fmt.Errorf("failed to create customer: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// generateCustomerCode 生成客户编码
|
||||
func (r *customerRepository) generateCustomerCode(tx *gorm.DB) (string, error) {
|
||||
currentYear := time.Now().Year()
|
||||
|
||||
// 查询或创建当年的序列号记录
|
||||
var sequence models.CustomerCodeSequence
|
||||
err := tx.Where("year = ?", currentYear).First(&sequence).Error
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
// 创建新年份的序列号记录
|
||||
sequence = models.CustomerCodeSequence{
|
||||
Year: currentYear,
|
||||
SequenceNumber: 1,
|
||||
}
|
||||
if err := tx.Create(&sequence).Error; err != nil {
|
||||
return "", fmt.Errorf("failed to create sequence: %w", err)
|
||||
}
|
||||
} else {
|
||||
return "", fmt.Errorf("failed to query sequence: %w", err)
|
||||
}
|
||||
} else {
|
||||
// 更新序列号
|
||||
sequence.SequenceNumber++
|
||||
if err := tx.Save(&sequence).Error; err != nil {
|
||||
return "", fmt.Errorf("failed to update sequence: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 生成客户编码:CUS-YYYY-NNNN
|
||||
customerCode := fmt.Sprintf("CUS-%d-%04d", currentYear, sequence.SequenceNumber)
|
||||
return customerCode, nil
|
||||
}
|
||||
|
||||
// UpdateCustomer 更新客户信息
|
||||
func (r *customerRepository) UpdateCustomer(ctx context.Context, customer *models.Customer) error {
|
||||
if err := r.db.Save(customer).Error; err != nil {
|
||||
return fmt.Errorf("failed to update customer: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteCustomer 删除客户(物理删除)
|
||||
func (r *customerRepository) DeleteCustomer(ctx context.Context, id string) error {
|
||||
result := r.db.Unscoped().Where("id = ?", id).Delete(&models.Customer{})
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("failed to delete customer: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return ErrCustomerNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckCustomerHasAuthorizationCodes 检查客户是否有关联的授权码
|
||||
func (r *customerRepository) CheckCustomerHasAuthorizationCodes(ctx context.Context, customerID string) (bool, error) {
|
||||
var count int64
|
||||
err := r.db.Model(&models.AuthorizationCode{}).Where("customer_id = ?", customerID).Count(&count).Error
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to check authorization codes: %w", err)
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
// CheckCustomerHasLicenses 检查客户是否有关联的许可证
|
||||
func (r *customerRepository) CheckCustomerHasLicenses(ctx context.Context, customerID string) (bool, error) {
|
||||
var count int64
|
||||
err := r.db.Model(&models.License{}).Where("customer_id = ?", customerID).Count(&count).Error
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to check licenses: %w", err)
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
// GetCustomerCount 获取客户总数(用于统计)
|
||||
func (r *customerRepository) GetCustomerCount(ctx context.Context, filters map[string]interface{}) (int64, error) {
|
||||
var count int64
|
||||
query := r.db.Model(&models.Customer{})
|
||||
|
||||
// 应用筛选条件
|
||||
for key, value := range filters {
|
||||
query = query.Where(fmt.Sprintf("%s = ?", key), value)
|
||||
}
|
||||
|
||||
if err := query.Count(&count).Error; err != nil {
|
||||
return 0, fmt.Errorf("failed to count customers: %w", err)
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// GetCustomerAuthorizationStats 获取客户授权统计信息
|
||||
func (r *customerRepository) GetCustomerAuthorizationStats(ctx context.Context, customerID string) (*models.AuthorizationStats, error) {
|
||||
stats := &models.AuthorizationStats{}
|
||||
|
||||
// 1. 授权码统计
|
||||
// 总授权码数量
|
||||
var totalAuthCodes int64
|
||||
if err := r.db.Model(&models.AuthorizationCode{}).
|
||||
Where("customer_id = ?", customerID).
|
||||
Count(&totalAuthCodes).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to count total auth codes: %w", err)
|
||||
}
|
||||
stats.TotalAuthCodes = totalAuthCodes
|
||||
|
||||
// 已过期授权码数量(end_date < NOW(),不考虑锁定状态)
|
||||
var expiredAuthCodes int64
|
||||
if err := r.db.Model(&models.AuthorizationCode{}).
|
||||
Where("customer_id = ? AND end_date < NOW()", customerID).
|
||||
Count(&expiredAuthCodes).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to count expired auth codes: %w", err)
|
||||
}
|
||||
stats.ExpiredAuthCodes = expiredAuthCodes
|
||||
|
||||
// 30日内即将到期授权码数量(end_date >= NOW() AND end_date <= DATE_ADD(NOW(), INTERVAL 30 DAY),不考虑锁定状态)
|
||||
var expiringSoonAuthCodes int64
|
||||
if err := r.db.Model(&models.AuthorizationCode{}).
|
||||
Where("customer_id = ? AND end_date >= NOW() AND end_date <= DATE_ADD(NOW(), INTERVAL 30 DAY)", customerID).
|
||||
Count(&expiringSoonAuthCodes).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to count expiring soon auth codes: %w", err)
|
||||
}
|
||||
stats.ExpiringSoonAuthCodes = expiringSoonAuthCodes
|
||||
|
||||
// 2. 许可证统计
|
||||
// 总许可证数量
|
||||
var totalLicenses int64
|
||||
if err := r.db.Model(&models.License{}).
|
||||
Where("customer_id = ? AND deleted_at IS NULL", customerID).
|
||||
Count(&totalLicenses).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to count total licenses: %w", err)
|
||||
}
|
||||
stats.TotalLicenses = totalLicenses
|
||||
|
||||
// 已激活许可证数量(status = 'active')
|
||||
var activeLicenses int64
|
||||
if err := r.db.Model(&models.License{}).
|
||||
Where("customer_id = ? AND status = 'active' AND deleted_at IS NULL", customerID).
|
||||
Count(&activeLicenses).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to count active licenses: %w", err)
|
||||
}
|
||||
stats.ActiveLicenses = activeLicenses
|
||||
|
||||
// 未激活许可证数量(total - active)
|
||||
stats.InactiveLicenses = totalLicenses - activeLicenses
|
||||
|
||||
// 已过期许可证数量(关联的授权码已过期)
|
||||
// 需要通过JOIN authorization_codes表来判断
|
||||
var expiredLicenses int64
|
||||
if err := r.db.Model(&models.License{}).
|
||||
Joins("INNER JOIN authorization_codes ON licenses.authorization_code_id = authorization_codes.id").
|
||||
Where("licenses.customer_id = ? AND authorization_codes.end_date < NOW() AND licenses.deleted_at IS NULL", customerID).
|
||||
Count(&expiredLicenses).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to count expired licenses: %w", err)
|
||||
}
|
||||
stats.ExpiredLicenses = expiredLicenses
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"license-manager/internal/models"
|
||||
pkgcontext "license-manager/pkg/context"
|
||||
"license-manager/pkg/i18n"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type dashboardRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewDashboardRepository 创建仪表盘数据访问层实例
|
||||
func NewDashboardRepository(db *gorm.DB) DashboardRepository {
|
||||
return &dashboardRepository{
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
// GetAuthorizationTrendData 获取授权趋势数据
|
||||
func (r *dashboardRepository) GetAuthorizationTrendData(ctx context.Context, startDate, endDate time.Time) ([]models.TrendData, error) {
|
||||
lang := pkgcontext.GetLanguageFromContext(ctx)
|
||||
var trendData []models.TrendData
|
||||
|
||||
// 生成日期范围
|
||||
dates := make([]time.Time, 0)
|
||||
for d := startDate; !d.After(endDate); d = d.AddDate(0, 0, 1) {
|
||||
dates = append(dates, d)
|
||||
}
|
||||
|
||||
// 查询每日数据
|
||||
for _, date := range dates {
|
||||
dayStart := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location())
|
||||
dayEnd := dayStart.Add(24*time.Hour - time.Nanosecond)
|
||||
|
||||
// 统计当日的授权数据
|
||||
var totalCount, newCount, expiredCount int64
|
||||
|
||||
// 当日总授权数(截至当日24:00的累计有效授权数)
|
||||
err := r.db.WithContext(ctx).Model(&models.AuthorizationCode{}).
|
||||
Where("created_at <= ?", dayEnd).
|
||||
Count(&totalCount).Error
|
||||
if err != nil {
|
||||
return nil, i18n.NewI18nError("900004", lang, err.Error())
|
||||
}
|
||||
|
||||
// 当日新增授权数
|
||||
err = r.db.WithContext(ctx).Model(&models.AuthorizationCode{}).
|
||||
Where("created_at >= ? AND created_at <= ?", dayStart, dayEnd).
|
||||
Count(&newCount).Error
|
||||
if err != nil {
|
||||
return nil, i18n.NewI18nError("900004", lang, err.Error())
|
||||
}
|
||||
|
||||
// 当日过期授权数
|
||||
err = r.db.WithContext(ctx).Model(&models.AuthorizationCode{}).
|
||||
Where("end_date >= ? AND end_date < ?", dayStart, dayEnd).
|
||||
Count(&expiredCount).Error
|
||||
if err != nil {
|
||||
return nil, i18n.NewI18nError("900004", lang, err.Error())
|
||||
}
|
||||
|
||||
trendData = append(trendData, models.TrendData{
|
||||
Date: date.Format("2006-01-02"),
|
||||
TotalAuthorizations: totalCount,
|
||||
NewAuthorizations: newCount,
|
||||
ExpiredAuthorizations: expiredCount,
|
||||
})
|
||||
}
|
||||
|
||||
return trendData, nil
|
||||
}
|
||||
|
||||
// GetRecentAuthorizations 获取最近授权列表
|
||||
func (r *dashboardRepository) GetRecentAuthorizations(ctx context.Context, req *models.DashboardRecentAuthorizationsRequest) (*models.DashboardRecentAuthorizationsResponse, error) {
|
||||
lang := pkgcontext.GetLanguageFromContext(ctx)
|
||||
|
||||
// 设置默认值
|
||||
limit := req.Limit
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
if limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
|
||||
// 构建查询
|
||||
query := r.db.WithContext(ctx).Table("authorization_codes ac").
|
||||
Select(`ac.id, ac.code, ac.customer_id, c.customer_name as customer_name,
|
||||
ac.description, ac.start_date, ac.end_date, ac.max_activations,
|
||||
COALESCE(l.active_count, 0) as current_activations,
|
||||
ac.created_at, ac.updated_at, ac.is_locked`).
|
||||
Joins("LEFT JOIN customers c ON ac.customer_id = c.id").
|
||||
Joins(`LEFT JOIN (
|
||||
SELECT authorization_code_id, COUNT(*) as active_count
|
||||
FROM licenses
|
||||
WHERE status = 'active' AND deleted_at IS NULL
|
||||
GROUP BY authorization_code_id
|
||||
) l ON ac.id = l.authorization_code_id`).
|
||||
Where("1=1").
|
||||
Order("ac.created_at DESC")
|
||||
|
||||
// 添加筛选条件
|
||||
if req.CustomerID != "" {
|
||||
query = query.Where("ac.customer_id = ?", req.CustomerID)
|
||||
}
|
||||
|
||||
// 状态筛选(虚字段)
|
||||
now := time.Now()
|
||||
switch req.Status {
|
||||
case "normal":
|
||||
query = query.Where("ac.is_locked = ? AND ac.end_date > ?", false, now)
|
||||
case "locked":
|
||||
query = query.Where("ac.is_locked = ?", true)
|
||||
case "expired":
|
||||
query = query.Where("ac.end_date <= ?", now)
|
||||
}
|
||||
|
||||
// 查询总数 - 使用简化查询避免JOIN复杂性
|
||||
var total int64
|
||||
countQuery := r.db.WithContext(ctx).Model(&models.AuthorizationCode{}).Where("1=1")
|
||||
|
||||
// 添加相同的筛选条件
|
||||
if req.CustomerID != "" {
|
||||
countQuery = countQuery.Where("customer_id = ?", req.CustomerID)
|
||||
}
|
||||
|
||||
// 状态筛选
|
||||
switch req.Status {
|
||||
case "normal":
|
||||
countQuery = countQuery.Where("is_locked = ? AND end_date > ?", false, now)
|
||||
case "locked":
|
||||
countQuery = countQuery.Where("is_locked = ?", true)
|
||||
case "expired":
|
||||
countQuery = countQuery.Where("end_date <= ?", now)
|
||||
}
|
||||
|
||||
err := countQuery.Count(&total).Error
|
||||
if err != nil {
|
||||
return nil, i18n.NewI18nError("900004", lang, err.Error())
|
||||
}
|
||||
|
||||
// 查询列表数据
|
||||
type queryResult struct {
|
||||
ID string `gorm:"column:id"`
|
||||
Code string `gorm:"column:code"`
|
||||
CustomerID string `gorm:"column:customer_id"`
|
||||
CustomerName string `gorm:"column:customer_name"`
|
||||
Description string `gorm:"column:description"`
|
||||
StartDate time.Time `gorm:"column:start_date"`
|
||||
EndDate time.Time `gorm:"column:end_date"`
|
||||
MaxActivations int `gorm:"column:max_activations"`
|
||||
CurrentActivations int `gorm:"column:current_activations"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
IsLocked bool `gorm:"column:is_locked"`
|
||||
}
|
||||
|
||||
var results []queryResult
|
||||
err = query.Limit(limit).Find(&results).Error
|
||||
if err != nil {
|
||||
return nil, i18n.NewI18nError("900004", lang, err.Error())
|
||||
}
|
||||
|
||||
// 转换为响应格式
|
||||
authorizations := make([]models.RecentAuthorization, 0, len(results))
|
||||
for _, result := range results {
|
||||
// 计算状态
|
||||
status := "normal"
|
||||
statusDisplay := i18n.GetEnumMessage("authorization_code_status", "normal", lang)
|
||||
|
||||
if result.IsLocked {
|
||||
status = "locked"
|
||||
statusDisplay = i18n.GetEnumMessage("authorization_code_status", "locked", lang)
|
||||
} else if result.EndDate.Before(now) {
|
||||
status = "expired"
|
||||
statusDisplay = i18n.GetEnumMessage("authorization_code_status", "expired", lang)
|
||||
}
|
||||
|
||||
authorizations = append(authorizations, models.RecentAuthorization{
|
||||
ID: result.ID,
|
||||
Code: result.Code,
|
||||
CustomerID: result.CustomerID,
|
||||
CustomerName: result.CustomerName,
|
||||
Description: result.Description,
|
||||
Status: status,
|
||||
StatusDisplay: statusDisplay,
|
||||
StartDate: result.StartDate,
|
||||
EndDate: result.EndDate,
|
||||
MaxActivations: result.MaxActivations,
|
||||
CurrentActivations: result.CurrentActivations,
|
||||
CreatedAt: result.CreatedAt,
|
||||
UpdatedAt: result.UpdatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
return &models.DashboardRecentAuthorizationsResponse{
|
||||
List: authorizations,
|
||||
Total: total,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package repository
|
||||
|
||||
import "errors"
|
||||
|
||||
// 客户领域的业务错误(给开发者看的,英文即可)
|
||||
var (
|
||||
ErrCustomerNotFound = errors.New("customer not found")
|
||||
ErrCustomerAlreadyExists = errors.New("customer already exists")
|
||||
ErrCustomerCodeDuplicate = errors.New("customer code already exists")
|
||||
)
|
||||
|
||||
// 授权码领域的业务错误
|
||||
var (
|
||||
ErrAuthorizationCodeNotFound = errors.New("authorization code not found")
|
||||
ErrAuthorizationCodeAlreadyExists = errors.New("authorization code already exists")
|
||||
ErrAuthorizationCodeDuplicate = errors.New("authorization code already exists")
|
||||
)
|
||||
|
||||
// 许可证领域的业务错误
|
||||
var (
|
||||
ErrLicenseNotFound = errors.New("license not found")
|
||||
ErrLicenseAlreadyExists = errors.New("license already exists")
|
||||
ErrLicenseDuplicate = errors.New("license already exists")
|
||||
)
|
||||
|
||||
// 通用的数据库/系统错误(给开发者看的)
|
||||
var (
|
||||
ErrDatabaseConnection = errors.New("database connection failed")
|
||||
ErrDatabaseQuery = errors.New("database query failed")
|
||||
ErrDatabaseCreate = errors.New("database create failed")
|
||||
ErrDatabaseUpdate = errors.New("database update failed")
|
||||
ErrDatabaseDelete = errors.New("database delete failed")
|
||||
)
|
||||
|
||||
// 辅助函数:判断错误类型
|
||||
func IsCustomerNotFound(err error) bool {
|
||||
return errors.Is(err, ErrCustomerNotFound)
|
||||
}
|
||||
|
||||
func IsCustomerAlreadyExists(err error) bool {
|
||||
return errors.Is(err, ErrCustomerAlreadyExists)
|
||||
}
|
||||
|
||||
func IsDatabaseError(err error) bool {
|
||||
return errors.Is(err, ErrDatabaseConnection) ||
|
||||
errors.Is(err, ErrDatabaseQuery) ||
|
||||
errors.Is(err, ErrDatabaseCreate) ||
|
||||
errors.Is(err, ErrDatabaseUpdate) ||
|
||||
errors.Is(err, ErrDatabaseDelete)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"license-manager/internal/models"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CustomerRepository 客户数据访问接口
|
||||
type CustomerRepository interface {
|
||||
// GetCustomerList 查询客户列表
|
||||
GetCustomerList(ctx context.Context, req *models.CustomerListRequest) (*models.CustomerListResponse, error)
|
||||
|
||||
// GetCustomerByID 根据ID获取客户信息
|
||||
GetCustomerByID(ctx context.Context, id string) (*models.Customer, error)
|
||||
|
||||
// CreateCustomer 创建客户
|
||||
CreateCustomer(ctx context.Context, customer *models.Customer) error
|
||||
|
||||
// UpdateCustomer 更新客户信息
|
||||
UpdateCustomer(ctx context.Context, customer *models.Customer) error
|
||||
|
||||
// DeleteCustomer 删除客户(物理删除)
|
||||
DeleteCustomer(ctx context.Context, id string) error
|
||||
|
||||
// GetCustomerCount 获取客户总数(用于统计)
|
||||
GetCustomerCount(ctx context.Context, filters map[string]interface{}) (int64, error)
|
||||
|
||||
// CheckCustomerHasAuthorizationCodes 检查客户是否有关联的授权码
|
||||
CheckCustomerHasAuthorizationCodes(ctx context.Context, customerID string) (bool, error)
|
||||
|
||||
// CheckCustomerHasLicenses 检查客户是否有关联的许可证
|
||||
CheckCustomerHasLicenses(ctx context.Context, customerID string) (bool, error)
|
||||
|
||||
// GetCustomerAuthorizationStats 获取客户授权统计信息
|
||||
GetCustomerAuthorizationStats(ctx context.Context, customerID string) (*models.AuthorizationStats, error)
|
||||
}
|
||||
|
||||
// UserRepository 用户数据访问接口
|
||||
type UserRepository interface {
|
||||
// GetUserByUsername 根据用户名获取用户
|
||||
GetUserByUsername(ctx context.Context, username string) (*models.User, error)
|
||||
|
||||
// GetUserByID 根据ID获取用户
|
||||
GetUserByID(ctx context.Context, id string) (*models.User, error)
|
||||
|
||||
// UpdateUser 更新用户信息
|
||||
UpdateUser(ctx context.Context, user *models.User) error
|
||||
|
||||
// IncrementLoginAttempts 增加登录失败次数
|
||||
IncrementLoginAttempts(ctx context.Context, id string) error
|
||||
|
||||
// ResetLoginAttempts 重置登录失败次数
|
||||
ResetLoginAttempts(ctx context.Context, id string) error
|
||||
|
||||
// LockUser 锁定用户账号
|
||||
LockUser(ctx context.Context, id string, lockDuration int) error
|
||||
}
|
||||
|
||||
// AuthorizationCodeRepository 授权码数据访问接口
|
||||
type AuthorizationCodeRepository interface {
|
||||
// CreateAuthorizationCode 创建授权码
|
||||
CreateAuthorizationCode(ctx context.Context, authCode *models.AuthorizationCode) error
|
||||
|
||||
// GetAuthorizationCodeByID 根据ID获取授权码
|
||||
GetAuthorizationCodeByID(ctx context.Context, id string) (*models.AuthorizationCode, error)
|
||||
|
||||
// GetAuthorizationCodeList 查询授权码列表
|
||||
GetAuthorizationCodeList(ctx context.Context, req *models.AuthorizationCodeListRequest) (*models.AuthorizationCodeListResponse, error)
|
||||
|
||||
// UpdateAuthorizationCode 更新授权码
|
||||
UpdateAuthorizationCode(ctx context.Context, authCode *models.AuthorizationCode) error
|
||||
|
||||
// DeleteAuthorizationCode 删除授权码(软删除)
|
||||
DeleteAuthorizationCode(ctx context.Context, id string) error
|
||||
|
||||
// CheckCustomerExists 检查客户是否存在
|
||||
CheckCustomerExists(ctx context.Context, customerID string) (bool, error)
|
||||
|
||||
// GetAuthorizationChangeList 查询授权变更历史列表
|
||||
GetAuthorizationChangeList(ctx context.Context, authCodeID string, req *models.AuthorizationChangeListRequest) (*models.AuthorizationChangeListResponse, error)
|
||||
|
||||
// RecordAuthorizationChange 记录授权变更历史
|
||||
RecordAuthorizationChange(ctx context.Context, change *models.AuthorizationChange) error
|
||||
}
|
||||
|
||||
// LicenseRepository 许可证数据访问接口
|
||||
type LicenseRepository interface {
|
||||
// GetLicenseList 查询许可证列表
|
||||
GetLicenseList(ctx context.Context, req *models.LicenseListRequest) (*models.LicenseListResponse, error)
|
||||
|
||||
// GetLicenseByID 根据ID获取许可证信息
|
||||
GetLicenseByID(ctx context.Context, id string) (*models.License, error)
|
||||
|
||||
// CreateLicense 创建许可证
|
||||
CreateLicense(ctx context.Context, license *models.License) error
|
||||
|
||||
// UpdateLicense 更新许可证信息
|
||||
UpdateLicense(ctx context.Context, license *models.License) error
|
||||
|
||||
// CheckAuthorizationCodeExists 检查授权码是否存在
|
||||
CheckAuthorizationCodeExists(ctx context.Context, authCodeID string) (bool, error)
|
||||
|
||||
// GetAuthorizationCodeByID 根据ID获取授权码信息
|
||||
GetAuthorizationCodeByID(ctx context.Context, authCodeID string) (*models.AuthorizationCode, error)
|
||||
|
||||
// GetAuthorizationCodeByCode 根据授权码获取授权码信息
|
||||
GetAuthorizationCodeByCode(ctx context.Context, code string) (*models.AuthorizationCode, error)
|
||||
|
||||
// GetLicenseByKey 根据许可证密钥获取许可证信息
|
||||
GetLicenseByKey(ctx context.Context, licenseKey string) (*models.License, error)
|
||||
|
||||
// GetActiveLicenseCount 获取指定授权码的激活许可证数量
|
||||
GetActiveLicenseCount(ctx context.Context, authCodeID string) (int64, error)
|
||||
}
|
||||
|
||||
// DashboardRepository 仪表盘数据访问接口
|
||||
type DashboardRepository interface {
|
||||
// GetAuthorizationTrendData 获取授权趋势数据
|
||||
GetAuthorizationTrendData(ctx context.Context, startDate, endDate time.Time) ([]models.TrendData, error)
|
||||
|
||||
// GetRecentAuthorizations 获取最近授权列表
|
||||
GetRecentAuthorizations(ctx context.Context, req *models.DashboardRecentAuthorizationsRequest) (*models.DashboardRecentAuthorizationsResponse, error)
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"license-manager/internal/models"
|
||||
)
|
||||
|
||||
type licenseRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewLicenseRepository 创建许可证数据访问实例
|
||||
func NewLicenseRepository(db *gorm.DB) LicenseRepository {
|
||||
return &licenseRepository{
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
// GetLicenseList 查询许可证列表
|
||||
func (r *licenseRepository) GetLicenseList(ctx context.Context, req *models.LicenseListRequest) (*models.LicenseListResponse, error) {
|
||||
// 设置默认值
|
||||
if req.Page <= 0 {
|
||||
req.Page = 1
|
||||
}
|
||||
if req.PageSize <= 0 {
|
||||
req.PageSize = 20
|
||||
}
|
||||
if req.PageSize > 100 {
|
||||
req.PageSize = 100
|
||||
}
|
||||
if req.Sort == "" {
|
||||
req.Sort = "created_at"
|
||||
}
|
||||
if req.Order == "" {
|
||||
req.Order = "desc"
|
||||
}
|
||||
|
||||
// 构建查询
|
||||
query := r.db.Model(&models.License{}).
|
||||
Select(`licenses.id, licenses.license_key, licenses.authorization_code_id,
|
||||
authorization_codes.code as authorization_code, customers.customer_name,
|
||||
licenses.hardware_fingerprint, licenses.status, licenses.activation_ip,
|
||||
licenses.last_online_ip, licenses.activated_at, licenses.last_heartbeat`).
|
||||
Joins("LEFT JOIN authorization_codes ON licenses.authorization_code_id = authorization_codes.id").
|
||||
Joins("LEFT JOIN customers ON licenses.customer_id = customers.id")
|
||||
|
||||
// 授权码ID筛选
|
||||
if req.AuthorizationCodeID != "" {
|
||||
query = query.Where("licenses.authorization_code_id = ?", req.AuthorizationCodeID)
|
||||
}
|
||||
|
||||
// 客户ID筛选
|
||||
if req.CustomerID != "" {
|
||||
query = query.Where("licenses.customer_id = ?", req.CustomerID)
|
||||
}
|
||||
|
||||
// 状态筛选
|
||||
if req.Status != "" {
|
||||
query = query.Where("licenses.status = ?", req.Status)
|
||||
}
|
||||
|
||||
// 在线状态筛选
|
||||
if req.IsOnline != nil {
|
||||
// 在线状态:最后心跳时间在5分钟内
|
||||
fiveMinutesAgo := time.Now().Add(-5 * time.Minute)
|
||||
if *req.IsOnline {
|
||||
query = query.Where("licenses.last_heartbeat >= ?", fiveMinutesAgo)
|
||||
} else {
|
||||
query = query.Where("licenses.last_heartbeat < ? OR licenses.last_heartbeat IS NULL", fiveMinutesAgo)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取总数
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 排序和分页
|
||||
orderBy := "licenses." + req.Sort + " " + req.Order
|
||||
offset := (req.Page - 1) * req.PageSize
|
||||
|
||||
var licenses []struct {
|
||||
ID string `json:"id"`
|
||||
LicenseKey string `json:"license_key"`
|
||||
AuthorizationCodeID string `json:"authorization_code_id"`
|
||||
AuthorizationCode string `json:"authorization_code"`
|
||||
CustomerName string `json:"customer_name"`
|
||||
HardwareFingerprint string `json:"hardware_fingerprint"`
|
||||
Status string `json:"status"`
|
||||
ActivationIP *string `json:"activation_ip"`
|
||||
LastOnlineIP *string `json:"last_online_ip"`
|
||||
ActivatedAt *time.Time `json:"activated_at"`
|
||||
LastHeartbeat *time.Time `json:"last_heartbeat"`
|
||||
}
|
||||
|
||||
if err := query.Order(orderBy).Limit(req.PageSize).Offset(offset).Scan(&licenses).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 转换为响应格式
|
||||
list := make([]models.LicenseListItem, len(licenses))
|
||||
for i, license := range licenses {
|
||||
// 计算在线状态
|
||||
isOnline := false
|
||||
if license.LastHeartbeat != nil {
|
||||
fiveMinutesAgo := time.Now().Add(-5 * time.Minute)
|
||||
isOnline = license.LastHeartbeat.After(fiveMinutesAgo)
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
var activatedAtStr, lastHeartbeatStr *string
|
||||
if license.ActivatedAt != nil {
|
||||
str := license.ActivatedAt.Format(time.RFC3339)
|
||||
activatedAtStr = &str
|
||||
}
|
||||
if license.LastHeartbeat != nil {
|
||||
str := license.LastHeartbeat.Format(time.RFC3339)
|
||||
lastHeartbeatStr = &str
|
||||
}
|
||||
|
||||
list[i] = models.LicenseListItem{
|
||||
ID: license.ID,
|
||||
LicenseKey: license.LicenseKey,
|
||||
AuthorizationCodeID: license.AuthorizationCodeID,
|
||||
AuthorizationCode: license.AuthorizationCode,
|
||||
CustomerName: license.CustomerName,
|
||||
HardwareFingerprint: license.HardwareFingerprint,
|
||||
Status: license.Status,
|
||||
IsOnline: isOnline,
|
||||
ActivationIP: license.ActivationIP,
|
||||
LastOnlineIP: license.LastOnlineIP,
|
||||
ActivatedAt: activatedAtStr,
|
||||
LastHeartbeat: lastHeartbeatStr,
|
||||
}
|
||||
}
|
||||
|
||||
// 计算总页数
|
||||
totalPages := int(math.Ceil(float64(total) / float64(req.PageSize)))
|
||||
|
||||
return &models.LicenseListResponse{
|
||||
List: list,
|
||||
Total: total,
|
||||
Page: req.Page,
|
||||
PageSize: req.PageSize,
|
||||
TotalPages: totalPages,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetLicenseByID 根据ID获取许可证信息
|
||||
func (r *licenseRepository) GetLicenseByID(ctx context.Context, id string) (*models.License, error) {
|
||||
var license models.License
|
||||
|
||||
err := r.db.Preload("AuthorizationCode").Preload("Customer").
|
||||
Where("id = ?", id).First(&license).Error
|
||||
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, ErrLicenseNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 计算在线状态
|
||||
if license.LastHeartbeat != nil {
|
||||
fiveMinutesAgo := time.Now().Add(-5 * time.Minute)
|
||||
license.IsOnline = license.LastHeartbeat.After(fiveMinutesAgo)
|
||||
}
|
||||
|
||||
return &license, nil
|
||||
}
|
||||
|
||||
// CreateLicense 创建许可证
|
||||
func (r *licenseRepository) CreateLicense(ctx context.Context, license *models.License) error {
|
||||
return r.db.Create(license).Error
|
||||
}
|
||||
|
||||
// UpdateLicense 更新许可证信息
|
||||
func (r *licenseRepository) UpdateLicense(ctx context.Context, license *models.License) error {
|
||||
return r.db.Save(license).Error
|
||||
}
|
||||
|
||||
// CheckAuthorizationCodeExists 检查授权码是否存在
|
||||
func (r *licenseRepository) CheckAuthorizationCodeExists(ctx context.Context, authCodeID string) (bool, error) {
|
||||
var count int64
|
||||
err := r.db.Model(&models.AuthorizationCode{}).Where("id = ?", authCodeID).Count(&count).Error
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
// GetAuthorizationCodeByID 根据ID获取授权码信息
|
||||
func (r *licenseRepository) GetAuthorizationCodeByID(ctx context.Context, authCodeID string) (*models.AuthorizationCode, error) {
|
||||
var authCode models.AuthorizationCode
|
||||
err := r.db.Where("id = ?", authCodeID).First(&authCode).Error
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, ErrAuthorizationCodeNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &authCode, nil
|
||||
}
|
||||
|
||||
// GetAuthorizationCodeByCode 根据授权码获取授权码信息
|
||||
func (r *licenseRepository) GetAuthorizationCodeByCode(ctx context.Context, code string) (*models.AuthorizationCode, error) {
|
||||
var authCode models.AuthorizationCode
|
||||
err := r.db.Where("code = ?", code).First(&authCode).Error
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, ErrAuthorizationCodeNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &authCode, nil
|
||||
}
|
||||
|
||||
// GetLicenseByKey 根据许可证密钥获取许可证信息
|
||||
func (r *licenseRepository) GetLicenseByKey(ctx context.Context, licenseKey string) (*models.License, error) {
|
||||
var license models.License
|
||||
|
||||
err := r.db.Preload("AuthorizationCode").Preload("Customer").
|
||||
Where("license_key = ?", licenseKey).First(&license).Error
|
||||
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, ErrLicenseNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 计算在线状态
|
||||
if license.LastHeartbeat != nil {
|
||||
fiveMinutesAgo := time.Now().Add(-5 * time.Minute)
|
||||
license.IsOnline = license.LastHeartbeat.After(fiveMinutesAgo)
|
||||
}
|
||||
|
||||
return &license, nil
|
||||
}
|
||||
|
||||
// GetActiveLicenseCount 获取指定授权码的激活许可证数量
|
||||
func (r *licenseRepository) GetActiveLicenseCount(ctx context.Context, authCodeID string) (int64, error) {
|
||||
var count int64
|
||||
err := r.db.Model(&models.License{}).
|
||||
Where("authorization_code_id = ? AND status = ?", authCodeID, "active").
|
||||
Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"license-manager/internal/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type userRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewUserRepository 创建用户仓储实例
|
||||
func NewUserRepository(db *gorm.DB) UserRepository {
|
||||
return &userRepository{db: db}
|
||||
}
|
||||
|
||||
// GetUserByUsername 根据用户名获取用户
|
||||
func (r *userRepository) GetUserByUsername(ctx context.Context, username string) (*models.User, error) {
|
||||
var user models.User
|
||||
err := r.db.WithContext(ctx).Where("username = ?", username).First(&user).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// GetUserByID 根据ID获取用户
|
||||
func (r *userRepository) GetUserByID(ctx context.Context, id string) (*models.User, error) {
|
||||
var user models.User
|
||||
err := r.db.WithContext(ctx).Where("id = ?", id).First(&user).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// UpdateUser 更新用户信息
|
||||
func (r *userRepository) UpdateUser(ctx context.Context, user *models.User) error {
|
||||
return r.db.WithContext(ctx).Save(user).Error
|
||||
}
|
||||
|
||||
// IncrementLoginAttempts 增加登录失败次数
|
||||
func (r *userRepository) IncrementLoginAttempts(ctx context.Context, id string) error {
|
||||
return r.db.WithContext(ctx).Model(&models.User{}).
|
||||
Where("id = ?", id).
|
||||
UpdateColumn("login_attempts", gorm.Expr("login_attempts + 1")).Error
|
||||
}
|
||||
|
||||
// ResetLoginAttempts 重置登录失败次数
|
||||
func (r *userRepository) ResetLoginAttempts(ctx context.Context, id string) error {
|
||||
now := time.Now()
|
||||
return r.db.WithContext(ctx).Model(&models.User{}).
|
||||
Where("id = ?", id).
|
||||
Updates(map[string]interface{}{
|
||||
"login_attempts": 0,
|
||||
"last_login_at": &now,
|
||||
"locked_until": nil,
|
||||
}).Error
|
||||
}
|
||||
|
||||
// LockUser 锁定用户账号
|
||||
func (r *userRepository) LockUser(ctx context.Context, id string, lockDuration int) error {
|
||||
lockUntil := time.Now().Add(time.Duration(lockDuration) * time.Minute)
|
||||
return r.db.WithContext(ctx).Model(&models.User{}).
|
||||
Where("id = ?", id).
|
||||
Updates(map[string]interface{}{
|
||||
"status": "locked",
|
||||
"locked_until": &lockUntil,
|
||||
}).Error
|
||||
}
|
||||
@@ -1,36 +1,91 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"license-manager/internal/config"
|
||||
"license-manager/internal/models"
|
||||
"license-manager/internal/repository"
|
||||
pkgcontext "license-manager/pkg/context"
|
||||
"license-manager/pkg/i18n"
|
||||
"license-manager/pkg/utils"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type authService struct{}
|
||||
type authService struct {
|
||||
userRepo repository.UserRepository
|
||||
}
|
||||
|
||||
// NewAuthService 创建认证服务实例
|
||||
func NewAuthService() AuthService {
|
||||
return &authService{}
|
||||
func NewAuthService(userRepo repository.UserRepository) AuthService {
|
||||
return &authService{
|
||||
userRepo: userRepo,
|
||||
}
|
||||
}
|
||||
|
||||
// Login 用户登录
|
||||
func (s *authService) Login(req *models.LoginRequest) (*models.LoginData, error) {
|
||||
func (s *authService) Login(ctx context.Context, req *models.LoginRequest, clientIP string) (*models.LoginData, error) {
|
||||
lang := pkgcontext.GetLanguageFromContext(ctx)
|
||||
|
||||
cfg := config.GetConfig()
|
||||
if cfg == nil {
|
||||
return nil, errors.New("配置未初始化")
|
||||
return nil, i18n.NewI18nError("900004", lang, "配置未初始化")
|
||||
}
|
||||
|
||||
// 验证用户名和密码
|
||||
if req.Username != cfg.Auth.Admin.Username || req.Password != cfg.Auth.Admin.Password {
|
||||
return nil, errors.New("用户名或密码错误")
|
||||
// 业务逻辑:参数验证
|
||||
if req == nil {
|
||||
return nil, i18n.NewI18nError("900001", lang)
|
||||
}
|
||||
|
||||
// 生成JWT Token (用户ID设为1,因为只有一个管理员用户)
|
||||
token, err := utils.GenerateToken(1, req.Username, "administrator")
|
||||
// 根据用户名查找用户
|
||||
user, err := s.userRepo.GetUserByUsername(ctx, req.Username)
|
||||
if err != nil {
|
||||
return nil, errors.New("生成令牌失败")
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, i18n.NewI18nError("100003", lang) // 用户名或密码错误
|
||||
}
|
||||
return nil, i18n.NewI18nError("900004", lang, err.Error())
|
||||
}
|
||||
|
||||
// 检查账号状态
|
||||
if user.Status == "disabled" {
|
||||
return nil, i18n.NewI18nError("100005", lang) // 权限不足/账号已被禁用
|
||||
}
|
||||
|
||||
// 检查账号是否被锁定
|
||||
if user.IsAccountLocked() {
|
||||
return nil, i18n.NewI18nError("100001", lang) // 认证已过期/账号已被锁定
|
||||
}
|
||||
|
||||
// 验证密码
|
||||
if !utils.CheckPasswordHash(req.Password, user.PasswordHash) {
|
||||
// 增加登录失败次数
|
||||
s.userRepo.IncrementLoginAttempts(ctx, user.ID)
|
||||
|
||||
// 检查是否需要锁定账号
|
||||
if user.LoginAttempts >= 4 { // 第5次失败时锁定
|
||||
s.userRepo.LockUser(ctx, user.ID, 30) // 锁定30分钟
|
||||
return nil, i18n.NewI18nError("100001", lang) // 密码错误次数过多,账号已被锁定
|
||||
}
|
||||
|
||||
return nil, i18n.NewI18nError("100003", lang) // 用户名或密码错误
|
||||
}
|
||||
|
||||
// 登录成功,重置登录失败次数并更新登录信息
|
||||
user.LastLoginIP = &clientIP
|
||||
s.userRepo.ResetLoginAttempts(ctx, user.ID)
|
||||
|
||||
// 如果账号状态是locked但锁定时间已过期,更新状态为active
|
||||
if user.Status == "locked" && (user.LockedUntil == nil || time.Now().After(*user.LockedUntil)) {
|
||||
user.Status = "active"
|
||||
s.userRepo.UpdateUser(ctx, user)
|
||||
}
|
||||
|
||||
// 生成JWT Token
|
||||
token, err := utils.GenerateToken(user.ID, user.Username, user.Role)
|
||||
if err != nil {
|
||||
return nil, i18n.NewI18nError("900004", lang, err.Error())
|
||||
}
|
||||
|
||||
// 计算过期时间(秒)
|
||||
@@ -40,8 +95,8 @@ func (s *authService) Login(req *models.LoginRequest) (*models.LoginData, error)
|
||||
Token: token,
|
||||
ExpiresIn: expiresIn,
|
||||
UserInfo: models.UserInfo{
|
||||
Username: req.Username,
|
||||
Role: "administrator",
|
||||
Username: user.Username,
|
||||
Role: user.Role,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
@@ -57,7 +112,7 @@ func (s *authService) RefreshToken(token string) (string, error) {
|
||||
// 生成新Token
|
||||
newToken, err := utils.GenerateToken(claims.UserID, claims.Username, claims.Role)
|
||||
if err != nil {
|
||||
return "", errors.New("生成新令牌失败")
|
||||
return "", err
|
||||
}
|
||||
|
||||
return newToken, nil
|
||||
|
||||
@@ -0,0 +1,706 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"license-manager/internal/models"
|
||||
"license-manager/internal/repository"
|
||||
pkgcontext "license-manager/pkg/context"
|
||||
"license-manager/pkg/i18n"
|
||||
)
|
||||
|
||||
type authorizationCodeService struct {
|
||||
authCodeRepo repository.AuthorizationCodeRepository
|
||||
customerRepo repository.CustomerRepository
|
||||
licenseRepo repository.LicenseRepository
|
||||
}
|
||||
|
||||
// NewAuthorizationCodeService 创建授权码服务实例
|
||||
func NewAuthorizationCodeService(
|
||||
authCodeRepo repository.AuthorizationCodeRepository,
|
||||
customerRepo repository.CustomerRepository,
|
||||
licenseRepo repository.LicenseRepository,
|
||||
) AuthorizationCodeService {
|
||||
return &authorizationCodeService{
|
||||
authCodeRepo: authCodeRepo,
|
||||
customerRepo: customerRepo,
|
||||
licenseRepo: licenseRepo,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateAuthorizationCode 创建授权码
|
||||
func (s *authorizationCodeService) CreateAuthorizationCode(ctx context.Context, req *models.AuthorizationCodeCreateRequest) (*models.AuthorizationCodeCreateResponse, error) {
|
||||
lang := pkgcontext.GetLanguageFromContext(ctx)
|
||||
|
||||
// 业务逻辑:参数验证
|
||||
if req == nil {
|
||||
return nil, i18n.NewI18nError("900001", lang)
|
||||
}
|
||||
|
||||
// 验证客户是否存在并检查状态
|
||||
customer, err := s.customerRepo.GetCustomerByID(ctx, req.CustomerID)
|
||||
if err != nil {
|
||||
if errors.Is(err, repository.ErrCustomerNotFound) {
|
||||
return nil, i18n.NewI18nError("200001", lang) // 客户不存在
|
||||
}
|
||||
return nil, i18n.NewI18nError("900004", lang, err.Error())
|
||||
}
|
||||
|
||||
// 检查客户状态:如果客户状态为停用(disabled),不允许创建授权
|
||||
if customer.Status == "disabled" {
|
||||
return nil, i18n.NewI18nError("200007", lang) // 客户已停用,无法创建授权
|
||||
}
|
||||
|
||||
// 业务逻辑:计算开始时间和结束时间
|
||||
now := time.Now()
|
||||
startDate := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) // 当天00:00:00开始
|
||||
endDate := startDate.AddDate(0, 0, req.ValidityDays-1).Add(23*time.Hour + 59*time.Minute + 59*time.Second) // 有效期最后一天的23:59:59结束
|
||||
|
||||
// 获取当前用户ID
|
||||
currentUserID := pkgcontext.GetUserIDFromContext(ctx)
|
||||
if currentUserID == "" {
|
||||
return nil, i18n.NewI18nError("100004", lang) // 缺少认证信息
|
||||
}
|
||||
|
||||
// 生成授权码
|
||||
authCode, err := s.generateAuthorizationCode(req.CustomerID)
|
||||
if err != nil {
|
||||
return nil, i18n.NewI18nError("900004", lang, err.Error())
|
||||
}
|
||||
|
||||
// 处理JSON字段
|
||||
var featureConfig, usageLimits, customParameters models.JSON
|
||||
if req.FeatureConfig != nil {
|
||||
featureConfigBytes, err := json.Marshal(req.FeatureConfig)
|
||||
if err != nil {
|
||||
return nil, i18n.NewI18nError("300010", lang) // 配置参数错误
|
||||
}
|
||||
featureConfig = models.JSON(featureConfigBytes)
|
||||
}
|
||||
if req.UsageLimits != nil {
|
||||
usageLimitsBytes, err := json.Marshal(req.UsageLimits)
|
||||
if err != nil {
|
||||
return nil, i18n.NewI18nError("300010", lang) // 配置参数错误
|
||||
}
|
||||
usageLimits = models.JSON(usageLimitsBytes)
|
||||
}
|
||||
if req.CustomParameters != nil {
|
||||
customParametersBytes, err := json.Marshal(req.CustomParameters)
|
||||
if err != nil {
|
||||
return nil, i18n.NewI18nError("300010", lang) // 配置参数错误
|
||||
}
|
||||
customParameters = models.JSON(customParametersBytes)
|
||||
}
|
||||
|
||||
// 设置默认加密类型
|
||||
encryptionType := req.EncryptionType
|
||||
if encryptionType == nil {
|
||||
defaultEncryption := "standard"
|
||||
encryptionType = &defaultEncryption
|
||||
}
|
||||
|
||||
// 构建授权码实体
|
||||
authCodeEntity := &models.AuthorizationCode{
|
||||
Code: authCode,
|
||||
CustomerID: req.CustomerID,
|
||||
CreatedBy: currentUserID,
|
||||
SoftwareID: req.SoftwareID,
|
||||
Description: req.Description,
|
||||
StartDate: startDate,
|
||||
EndDate: endDate,
|
||||
DeploymentType: req.DeploymentType,
|
||||
EncryptionType: encryptionType,
|
||||
SoftwareVersion: req.SoftwareVersion,
|
||||
MaxActivations: req.MaxActivations,
|
||||
IsLocked: false,
|
||||
FeatureConfig: featureConfig,
|
||||
UsageLimits: usageLimits,
|
||||
CustomParameters: customParameters,
|
||||
}
|
||||
|
||||
// 委托给Repository层进行数据创建
|
||||
if err := s.authCodeRepo.CreateAuthorizationCode(ctx, authCodeEntity); err != nil {
|
||||
// 根据错误类型包装为完整的I18nError
|
||||
if strings.Contains(err.Error(), "duplicate") || strings.Contains(err.Error(), "unique") {
|
||||
return nil, i18n.NewI18nError("300002", lang) // 授权码已存在
|
||||
}
|
||||
|
||||
// 数据库相关错误
|
||||
return nil, i18n.NewI18nError("900004", lang, err.Error())
|
||||
}
|
||||
|
||||
return &models.AuthorizationCodeCreateResponse{
|
||||
ID: authCodeEntity.ID,
|
||||
Code: authCodeEntity.Code,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetAuthorizationCodeList 查询授权码列表
|
||||
func (s *authorizationCodeService) GetAuthorizationCodeList(ctx context.Context, req *models.AuthorizationCodeListRequest) (*models.AuthorizationCodeListResponse, error) {
|
||||
lang := pkgcontext.GetLanguageFromContext(ctx)
|
||||
|
||||
// 业务逻辑:参数验证
|
||||
if req == nil {
|
||||
return nil, i18n.NewI18nError("900001", lang)
|
||||
}
|
||||
|
||||
// 委托给Repository层进行数据访问
|
||||
result, err := s.authCodeRepo.GetAuthorizationCodeList(ctx, req)
|
||||
if err != nil {
|
||||
// 数据库相关错误
|
||||
return nil, i18n.NewI18nError("900004", lang, err.Error())
|
||||
}
|
||||
|
||||
// 业务逻辑:添加多语言显示字段和状态计算
|
||||
now := time.Now()
|
||||
for i := range result.List {
|
||||
item := &result.List[i]
|
||||
s.fillAuthCodeDisplayFields(item, lang, now)
|
||||
|
||||
// 根据状态筛选
|
||||
if req.Status != "" && item.Status != req.Status {
|
||||
// 移除不匹配的项 - 这里简化处理,实际应该在查询时过滤
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetAuthorizationCode 获取单个授权码详情
|
||||
func (s *authorizationCodeService) GetAuthorizationCode(ctx context.Context, id string) (*models.AuthorizationCode, error) {
|
||||
lang := pkgcontext.GetLanguageFromContext(ctx)
|
||||
|
||||
// 业务逻辑:参数验证
|
||||
if id == "" {
|
||||
return nil, i18n.NewI18nError("900001", lang)
|
||||
}
|
||||
|
||||
// 委托给Repository层进行数据访问
|
||||
authCode, err := s.authCodeRepo.GetAuthorizationCodeByID(ctx, id)
|
||||
if err != nil {
|
||||
// 根据Repository错误类型包装为完整的I18nError
|
||||
if errors.Is(err, repository.ErrAuthorizationCodeNotFound) {
|
||||
return nil, i18n.NewI18nError("300001", lang)
|
||||
}
|
||||
|
||||
// 数据库相关错误
|
||||
return nil, i18n.NewI18nError("900004", lang, err.Error())
|
||||
}
|
||||
|
||||
// 查询客户信息
|
||||
customer, err := s.customerRepo.GetCustomerByID(ctx, authCode.CustomerID)
|
||||
if err != nil {
|
||||
// 如果客户不存在,不影响授权码查询,只是不填充客户信息
|
||||
// 记录错误日志但不返回错误
|
||||
} else {
|
||||
// 填充客户信息
|
||||
authCode.CustomerInfo = &models.CustomerInfoForAuthCode{
|
||||
ID: customer.ID,
|
||||
CustomerCode: customer.CustomerCode,
|
||||
CustomerName: customer.CustomerName,
|
||||
CustomerType: customer.CustomerType,
|
||||
CustomerTypeDisplay: i18n.GetEnumMessage("customer_type", customer.CustomerType, lang),
|
||||
Status: customer.Status,
|
||||
StatusDisplay: i18n.GetEnumMessage("customer_status", customer.Status, lang),
|
||||
CreatedAt: customer.CreatedAt.Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
// 查询已激活的许可证数量
|
||||
activatedCount, err := s.licenseRepo.GetActiveLicenseCount(ctx, id)
|
||||
if err != nil {
|
||||
// 查询失败时,数量设为0,记录错误日志但不返回错误
|
||||
activatedCount = 0
|
||||
}
|
||||
authCode.ActivatedLicensesCount = activatedCount
|
||||
|
||||
// 填充多语言显示字段和计算状态
|
||||
s.fillAuthorizationCodeDisplayFields(authCode, lang)
|
||||
|
||||
return authCode, nil
|
||||
}
|
||||
|
||||
// fillAuthorizationCodeDisplayFields 填充完整授权码模型的多语言显示字段
|
||||
func (s *authorizationCodeService) fillAuthorizationCodeDisplayFields(authCode *models.AuthorizationCode, lang string) {
|
||||
// 计算状态
|
||||
now := time.Now()
|
||||
if authCode.IsLocked {
|
||||
authCode.Status = "locked"
|
||||
} else if now.After(authCode.EndDate) {
|
||||
authCode.Status = "expired"
|
||||
} else {
|
||||
authCode.Status = "normal"
|
||||
}
|
||||
|
||||
// 添加多语言显示字段
|
||||
authCode.StatusDisplay = i18n.GetEnumMessage("authorization_status", authCode.Status, lang)
|
||||
authCode.DeploymentTypeDisplay = i18n.GetEnumMessage("deployment_type", authCode.DeploymentType, lang)
|
||||
if authCode.EncryptionType != nil {
|
||||
authCode.EncryptionTypeDisplay = i18n.GetEnumMessage("encryption_type", *authCode.EncryptionType, lang)
|
||||
}
|
||||
|
||||
// TODO: 统计当前激活数量
|
||||
authCode.CurrentActivations = 0
|
||||
|
||||
// TODO: 获取客户名称
|
||||
// 可以考虑在Repository层通过JOIN获取,或在这里单独查询
|
||||
}
|
||||
|
||||
// UpdateAuthorizationCode 更新授权码
|
||||
func (s *authorizationCodeService) UpdateAuthorizationCode(ctx context.Context, id string, req *models.AuthorizationCodeUpdateRequest) (*models.AuthorizationCode, error) {
|
||||
lang := pkgcontext.GetLanguageFromContext(ctx)
|
||||
|
||||
// 业务逻辑:参数验证
|
||||
if id == "" {
|
||||
return nil, i18n.NewI18nError("900001", lang)
|
||||
}
|
||||
if req == nil {
|
||||
return nil, i18n.NewI18nError("900001", lang)
|
||||
}
|
||||
|
||||
// 先查询现有授权码
|
||||
existingAuthCode, err := s.authCodeRepo.GetAuthorizationCodeByID(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, repository.ErrAuthorizationCodeNotFound) {
|
||||
return nil, i18n.NewI18nError("300001", lang)
|
||||
}
|
||||
return nil, i18n.NewI18nError("900004", lang, err.Error())
|
||||
}
|
||||
|
||||
// 获取当前用户ID
|
||||
currentUserID := pkgcontext.GetUserIDFromContext(ctx)
|
||||
if currentUserID == "" {
|
||||
return nil, i18n.NewI18nError("100004", lang)
|
||||
}
|
||||
|
||||
// 记录变更前的配置
|
||||
oldConfig := s.buildConfigSnapshot(existingAuthCode)
|
||||
|
||||
// 只更新提供的字段
|
||||
if req.SoftwareID != nil {
|
||||
existingAuthCode.SoftwareID = req.SoftwareID
|
||||
}
|
||||
if req.Description != nil {
|
||||
existingAuthCode.Description = req.Description
|
||||
}
|
||||
if req.ValidityDays != nil {
|
||||
// 重新计算开始时间和结束时间
|
||||
now := time.Now()
|
||||
startDate := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
endDate := startDate.AddDate(0, 0, *req.ValidityDays-1).Add(23*time.Hour + 59*time.Minute + 59*time.Second)
|
||||
existingAuthCode.StartDate = startDate
|
||||
existingAuthCode.EndDate = endDate
|
||||
}
|
||||
if req.DeploymentType != nil {
|
||||
existingAuthCode.DeploymentType = *req.DeploymentType
|
||||
}
|
||||
if req.EncryptionType != nil {
|
||||
existingAuthCode.EncryptionType = req.EncryptionType
|
||||
}
|
||||
if req.SoftwareVersion != nil {
|
||||
existingAuthCode.SoftwareVersion = req.SoftwareVersion
|
||||
}
|
||||
if req.MaxActivations != nil {
|
||||
existingAuthCode.MaxActivations = *req.MaxActivations
|
||||
}
|
||||
|
||||
// 处理JSON字段
|
||||
if req.FeatureConfig != nil {
|
||||
featureConfigBytes, err := json.Marshal(req.FeatureConfig)
|
||||
if err != nil {
|
||||
return nil, i18n.NewI18nError("300010", lang)
|
||||
}
|
||||
existingAuthCode.FeatureConfig = models.JSON(featureConfigBytes)
|
||||
}
|
||||
if req.UsageLimits != nil {
|
||||
usageLimitsBytes, err := json.Marshal(req.UsageLimits)
|
||||
if err != nil {
|
||||
return nil, i18n.NewI18nError("300010", lang)
|
||||
}
|
||||
existingAuthCode.UsageLimits = models.JSON(usageLimitsBytes)
|
||||
}
|
||||
if req.CustomParameters != nil {
|
||||
customParametersBytes, err := json.Marshal(req.CustomParameters)
|
||||
if err != nil {
|
||||
return nil, i18n.NewI18nError("300010", lang)
|
||||
}
|
||||
existingAuthCode.CustomParameters = models.JSON(customParametersBytes)
|
||||
}
|
||||
|
||||
// 委托给Repository层进行数据更新
|
||||
if err := s.authCodeRepo.UpdateAuthorizationCode(ctx, existingAuthCode); err != nil {
|
||||
return nil, i18n.NewI18nError("900004", lang, err.Error())
|
||||
}
|
||||
|
||||
// 填充多语言显示字段和计算状态
|
||||
s.fillAuthorizationCodeDisplayFields(existingAuthCode, lang)
|
||||
|
||||
// 记录变更历史到 authorization_changes 表
|
||||
newConfig := s.buildConfigSnapshot(existingAuthCode)
|
||||
if err := s.recordAuthorizationChange(ctx, id, req.ChangeType, req.Reason, currentUserID, oldConfig, newConfig); err != nil {
|
||||
log.Printf("记录授权变更历史失败: %v", err)
|
||||
}
|
||||
|
||||
return existingAuthCode, nil
|
||||
}
|
||||
|
||||
// LockUnlockAuthorizationCode 锁定/解锁授权码
|
||||
func (s *authorizationCodeService) LockUnlockAuthorizationCode(ctx context.Context, id string, req *models.AuthorizationCodeLockRequest) (*models.AuthorizationCode, error) {
|
||||
lang := pkgcontext.GetLanguageFromContext(ctx)
|
||||
|
||||
// 业务逻辑:参数验证
|
||||
if id == "" {
|
||||
return nil, i18n.NewI18nError("900001", lang)
|
||||
}
|
||||
if req == nil {
|
||||
return nil, i18n.NewI18nError("900001", lang)
|
||||
}
|
||||
|
||||
// 先查询现有授权码
|
||||
existingAuthCode, err := s.authCodeRepo.GetAuthorizationCodeByID(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, repository.ErrAuthorizationCodeNotFound) {
|
||||
return nil, i18n.NewI18nError("300001", lang)
|
||||
}
|
||||
return nil, i18n.NewI18nError("900004", lang, err.Error())
|
||||
}
|
||||
|
||||
// 获取当前用户ID
|
||||
currentUserID := pkgcontext.GetUserIDFromContext(ctx)
|
||||
if currentUserID == "" {
|
||||
return nil, i18n.NewI18nError("100004", lang)
|
||||
}
|
||||
|
||||
// 记录变更前的配置
|
||||
oldConfig := s.buildConfigSnapshot(existingAuthCode)
|
||||
|
||||
// 更新锁定状态
|
||||
now := time.Now()
|
||||
existingAuthCode.IsLocked = req.IsLocked
|
||||
|
||||
if req.IsLocked {
|
||||
// 锁定
|
||||
existingAuthCode.LockReason = req.LockReason
|
||||
existingAuthCode.LockedAt = &now
|
||||
existingAuthCode.LockedBy = ¤tUserID
|
||||
} else {
|
||||
// 解锁
|
||||
existingAuthCode.LockReason = nil
|
||||
existingAuthCode.LockedAt = nil
|
||||
existingAuthCode.LockedBy = nil
|
||||
}
|
||||
|
||||
// 委托给Repository层进行数据更新
|
||||
if err := s.authCodeRepo.UpdateAuthorizationCode(ctx, existingAuthCode); err != nil {
|
||||
return nil, i18n.NewI18nError("900004", lang, err.Error())
|
||||
}
|
||||
|
||||
// 填充多语言显示字段和计算状态
|
||||
s.fillAuthorizationCodeDisplayFields(existingAuthCode, lang)
|
||||
|
||||
// 记录变更历史到 authorization_changes 表
|
||||
changeType := "lock"
|
||||
if !req.IsLocked {
|
||||
changeType = "unlock"
|
||||
}
|
||||
newConfig := s.buildConfigSnapshot(existingAuthCode)
|
||||
if err := s.recordAuthorizationChange(ctx, id, changeType, req.Reason, currentUserID, oldConfig, newConfig); err != nil {
|
||||
log.Printf("记录授权变更历史失败: %v", err)
|
||||
}
|
||||
|
||||
return existingAuthCode, nil
|
||||
}
|
||||
|
||||
// DeleteAuthorizationCode 删除授权码(软删除)
|
||||
func (s *authorizationCodeService) DeleteAuthorizationCode(ctx context.Context, id string) error {
|
||||
lang := pkgcontext.GetLanguageFromContext(ctx)
|
||||
|
||||
// 业务逻辑:参数验证
|
||||
if id == "" {
|
||||
return i18n.NewI18nError("900001", lang)
|
||||
}
|
||||
|
||||
// 获取当前用户ID
|
||||
currentUserID := pkgcontext.GetUserIDFromContext(ctx)
|
||||
if currentUserID == "" {
|
||||
return i18n.NewI18nError("100004", lang)
|
||||
}
|
||||
|
||||
// 先查询现有授权码,确保存在
|
||||
existingAuthCode, err := s.authCodeRepo.GetAuthorizationCodeByID(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, repository.ErrAuthorizationCodeNotFound) {
|
||||
return i18n.NewI18nError("300001", lang)
|
||||
}
|
||||
return i18n.NewI18nError("900004", lang, err.Error())
|
||||
}
|
||||
|
||||
// 记录变更前的配置
|
||||
oldConfig := s.buildConfigSnapshot(existingAuthCode)
|
||||
|
||||
// 委托给Repository层进行软删除
|
||||
if err := s.authCodeRepo.DeleteAuthorizationCode(ctx, id); err != nil {
|
||||
return i18n.NewI18nError("900004", lang, err.Error())
|
||||
}
|
||||
|
||||
// 记录变更历史到 authorization_changes 表
|
||||
emptyConfig := make(map[string]interface{})
|
||||
if err := s.recordAuthorizationChange(ctx, id, "delete", nil, currentUserID, oldConfig, emptyConfig); err != nil {
|
||||
log.Printf("记录授权变更历史失败: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// recordAuthorizationChange 记录授权变更历史
|
||||
func (s *authorizationCodeService) recordAuthorizationChange(ctx context.Context, authCodeID string, changeType string, reason *string, operatorID string, oldConfig, newConfig map[string]interface{}) error {
|
||||
// 构建变更历史记录
|
||||
change := &models.AuthorizationChange{
|
||||
AuthorizationCodeID: authCodeID,
|
||||
ChangeType: changeType,
|
||||
OperatorID: operatorID,
|
||||
Reason: reason,
|
||||
}
|
||||
|
||||
// 序列化配置为JSON
|
||||
if oldConfig != nil {
|
||||
oldConfigBytes, err := json.Marshal(oldConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
change.OldConfig = models.JSON(oldConfigBytes)
|
||||
}
|
||||
|
||||
if newConfig != nil {
|
||||
newConfigBytes, err := json.Marshal(newConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
change.NewConfig = models.JSON(newConfigBytes)
|
||||
}
|
||||
|
||||
// 委托给Repository层记录变更历史
|
||||
return s.authCodeRepo.RecordAuthorizationChange(ctx, change)
|
||||
}
|
||||
|
||||
// buildConfigSnapshot 构建配置快照,用于记录变更历史
|
||||
func (s *authorizationCodeService) buildConfigSnapshot(authCode *models.AuthorizationCode) map[string]interface{} {
|
||||
config := make(map[string]interface{})
|
||||
|
||||
// 基础配置
|
||||
config["code"] = authCode.Code
|
||||
config["software_id"] = authCode.SoftwareID
|
||||
config["description"] = authCode.Description
|
||||
config["start_date"] = authCode.StartDate.Format(time.RFC3339)
|
||||
config["end_date"] = authCode.EndDate.Format(time.RFC3339)
|
||||
config["deployment_type"] = authCode.DeploymentType
|
||||
config["encryption_type"] = authCode.EncryptionType
|
||||
config["software_version"] = authCode.SoftwareVersion
|
||||
config["max_activations"] = authCode.MaxActivations
|
||||
config["is_locked"] = authCode.IsLocked
|
||||
config["lock_reason"] = authCode.LockReason
|
||||
|
||||
// JSON配置字段
|
||||
if len(authCode.FeatureConfig) > 0 {
|
||||
var featureConfig map[string]interface{}
|
||||
if err := json.Unmarshal(authCode.FeatureConfig, &featureConfig); err == nil {
|
||||
config["feature_config"] = featureConfig
|
||||
}
|
||||
}
|
||||
|
||||
if len(authCode.UsageLimits) > 0 {
|
||||
var usageLimits map[string]interface{}
|
||||
if err := json.Unmarshal(authCode.UsageLimits, &usageLimits); err == nil {
|
||||
config["usage_limits"] = usageLimits
|
||||
}
|
||||
}
|
||||
|
||||
if len(authCode.CustomParameters) > 0 {
|
||||
var customParameters map[string]interface{}
|
||||
if err := json.Unmarshal(authCode.CustomParameters, &customParameters); err == nil {
|
||||
config["custom_parameters"] = customParameters
|
||||
}
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
// fillAuthCodeDisplayFields 填充授权码列表项多语言显示字段
|
||||
func (s *authorizationCodeService) fillAuthCodeDisplayFields(item *models.AuthorizationCodeListItem, lang string, now time.Time) {
|
||||
// 计算状态
|
||||
endDate, _ := time.Parse("2006-01-02T15:04:05Z07:00", item.EndDate)
|
||||
|
||||
if item.IsLocked {
|
||||
item.Status = "locked"
|
||||
} else if now.After(endDate) {
|
||||
item.Status = "expired"
|
||||
} else {
|
||||
item.Status = "normal"
|
||||
}
|
||||
|
||||
// 添加多语言显示字段
|
||||
item.StatusDisplay = i18n.GetEnumMessage("authorization_status", item.Status, lang)
|
||||
item.DeploymentTypeDisplay = i18n.GetEnumMessage("deployment_type", item.DeploymentType, lang)
|
||||
item.CustomerNameDisplay = item.CustomerName // 客户名称暂时不需要翻译
|
||||
}
|
||||
|
||||
// generateAuthorizationCode 生成授权码
|
||||
// 格式: LIC-{4位客户代码}-{12位随机}-{4位校验码}
|
||||
// 安全性:12位随机字符(62字符集)提供约 3.23 × 10^21 种可能组合
|
||||
func (s *authorizationCodeService) generateAuthorizationCode(customerID string) (string, error) {
|
||||
// 获取客户编码的前4位作为前缀
|
||||
customerCode := "COMP" // 默认前缀
|
||||
if len(customerID) >= 4 {
|
||||
customerCode = strings.ToUpper(customerID[:4])
|
||||
}
|
||||
|
||||
// 生成12位随机字符串(从8位增加到12位以提升安全性)
|
||||
randomStr, err := s.generateRandomString(12)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// 生成4位校验码
|
||||
checksum := s.generateChecksum(customerCode + randomStr)
|
||||
|
||||
// 格式: LIC-{customer_code}-{random}-{checksum}
|
||||
return fmt.Sprintf("LIC-%s-%s-%s", customerCode, randomStr, checksum), nil
|
||||
}
|
||||
|
||||
// generateRandomString 生成随机字符串
|
||||
// 使用62字符集(A-Z, a-z, 0-9)提供更高的熵值
|
||||
func (s *authorizationCodeService) generateRandomString(length int) (string, error) {
|
||||
// 扩展字符集到62字符(A-Z, a-z, 0-9)以提升安全性
|
||||
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
||||
bytes := make([]byte, length)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
for i, b := range bytes {
|
||||
bytes[i] = chars[b%byte(len(chars))]
|
||||
}
|
||||
return string(bytes), nil
|
||||
}
|
||||
|
||||
// generateChecksum 生成校验码
|
||||
// 基于输入字符串的ASCII值计算,用于格式验证
|
||||
func (s *authorizationCodeService) generateChecksum(input string) string {
|
||||
sum := 0
|
||||
for _, char := range input {
|
||||
sum += int(char)
|
||||
}
|
||||
// 使用与随机字符串相同的62字符集以保持一致性
|
||||
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
||||
checksum := ""
|
||||
for i := 0; i < 4; i++ {
|
||||
checksum += string(chars[sum%len(chars)])
|
||||
sum = sum / len(chars)
|
||||
}
|
||||
return checksum
|
||||
}
|
||||
|
||||
// ValidateAuthorizationCodeFormat 验证授权码格式和校验和
|
||||
// 格式: LIC-{4位客户代码}-{8或12位随机}-{4位校验码}
|
||||
// 兼容旧格式(8位随机,36字符集)和新格式(12位随机,62字符集)
|
||||
func (s *authorizationCodeService) ValidateAuthorizationCodeFormat(code string) bool {
|
||||
// 解析格式
|
||||
parts := strings.Split(code, "-")
|
||||
if len(parts) != 4 || parts[0] != "LIC" {
|
||||
return false
|
||||
}
|
||||
|
||||
// 验证各部分长度(兼容旧格式8位和新格式12位)
|
||||
customerCodeLen := len(parts[1])
|
||||
randomLen := len(parts[2])
|
||||
checksumLen := len(parts[3])
|
||||
|
||||
// 客户代码必须为4位,校验和必须为4位
|
||||
if customerCodeLen != 4 || checksumLen != 4 {
|
||||
return false
|
||||
}
|
||||
|
||||
// 随机部分:兼容8位(旧格式)和12位(新格式)
|
||||
if randomLen != 8 && randomLen != 12 {
|
||||
return false
|
||||
}
|
||||
|
||||
// 根据随机部分长度选择字符集验证校验和
|
||||
// 8位:使用36字符集(旧格式)
|
||||
// 12位:使用62字符集(新格式)
|
||||
expectedChecksum := s.generateChecksumWithCharset(parts[1]+parts[2], randomLen == 12)
|
||||
return expectedChecksum == parts[3]
|
||||
}
|
||||
|
||||
// generateChecksumWithCharset 生成校验码(支持指定字符集)
|
||||
// useExtendedCharset: true使用62字符集(新格式),false使用36字符集(旧格式)
|
||||
func (s *authorizationCodeService) generateChecksumWithCharset(input string, useExtendedCharset bool) string {
|
||||
sum := 0
|
||||
for _, char := range input {
|
||||
sum += int(char)
|
||||
}
|
||||
|
||||
var chars string
|
||||
if useExtendedCharset {
|
||||
// 使用62字符集(A-Z, a-z, 0-9)用于新格式
|
||||
chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
||||
} else {
|
||||
// 使用36字符集(A-Z, 0-9)用于旧格式
|
||||
chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
}
|
||||
|
||||
checksum := ""
|
||||
for i := 0; i < 4; i++ {
|
||||
checksum += string(chars[sum%len(chars)])
|
||||
sum = sum / len(chars)
|
||||
}
|
||||
return checksum
|
||||
}
|
||||
|
||||
// GetAuthorizationChangeList 查询授权变更历史列表
|
||||
func (s *authorizationCodeService) GetAuthorizationChangeList(ctx context.Context, authCodeID string, req *models.AuthorizationChangeListRequest) (*models.AuthorizationChangeListResponse, error) {
|
||||
lang := pkgcontext.GetLanguageFromContext(ctx)
|
||||
|
||||
// 业务逻辑:参数验证
|
||||
if authCodeID == "" {
|
||||
return nil, i18n.NewI18nError("900001", lang)
|
||||
}
|
||||
if req == nil {
|
||||
return nil, i18n.NewI18nError("900001", lang)
|
||||
}
|
||||
|
||||
// 验证授权码是否存在
|
||||
_, err := s.authCodeRepo.GetAuthorizationCodeByID(ctx, authCodeID)
|
||||
if err != nil {
|
||||
if errors.Is(err, repository.ErrAuthorizationCodeNotFound) {
|
||||
return nil, i18n.NewI18nError("300001", lang) // 授权码不存在
|
||||
}
|
||||
return nil, i18n.NewI18nError("900004", lang, err.Error())
|
||||
}
|
||||
|
||||
// 委托给Repository层进行数据访问
|
||||
result, err := s.authCodeRepo.GetAuthorizationChangeList(ctx, authCodeID, req)
|
||||
if err != nil {
|
||||
return nil, i18n.NewI18nError("900004", lang, err.Error())
|
||||
}
|
||||
|
||||
// 业务逻辑:添加多语言显示字段
|
||||
for i := range result.List {
|
||||
s.fillChangeDisplayFields(&result.List[i], lang)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// fillChangeDisplayFields 填充变更历史列表项多语言显示字段
|
||||
func (s *authorizationCodeService) fillChangeDisplayFields(item *models.AuthorizationChangeListItem, lang string) {
|
||||
// 填充变更类型显示字段
|
||||
item.ChangeTypeDisplay = i18n.GetEnumMessage("authorization_change_type", item.ChangeType, lang)
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"license-manager/internal/models"
|
||||
"license-manager/internal/repository"
|
||||
pkgcontext "license-manager/pkg/context"
|
||||
"license-manager/pkg/i18n"
|
||||
)
|
||||
|
||||
type customerService struct {
|
||||
customerRepo repository.CustomerRepository
|
||||
}
|
||||
|
||||
// NewCustomerService 创建客户服务实例
|
||||
func NewCustomerService(customerRepo repository.CustomerRepository) CustomerService {
|
||||
return &customerService{
|
||||
customerRepo: customerRepo,
|
||||
}
|
||||
}
|
||||
|
||||
// GetCustomerList 查询客户列表
|
||||
func (s *customerService) GetCustomerList(ctx context.Context, req *models.CustomerListRequest) (*models.CustomerListResponse, error) {
|
||||
lang := pkgcontext.GetLanguageFromContext(ctx)
|
||||
|
||||
// 业务逻辑:参数验证
|
||||
if req == nil {
|
||||
return nil, i18n.NewI18nError("900001", lang) // 业务错误,不覆盖多语言message
|
||||
}
|
||||
|
||||
// 委托给Repository层进行数据访问
|
||||
result, err := s.customerRepo.GetCustomerList(ctx, req)
|
||||
if err != nil {
|
||||
// 根据Repository错误类型包装为完整的I18nError
|
||||
if errors.Is(err, repository.ErrCustomerNotFound) {
|
||||
return nil, i18n.NewI18nError("200001", lang) // 业务错误,保持多语言
|
||||
}
|
||||
|
||||
// 数据库相关错误,使用系统错误码,覆盖message显示详细信息
|
||||
return nil, i18n.NewI18nError("900004", lang, err.Error())
|
||||
}
|
||||
|
||||
// 业务逻辑:添加多语言显示字段
|
||||
for i := range result.List {
|
||||
s.fillDisplayFields(&result.List[i], lang)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetCustomer 获取单个客户详情
|
||||
func (s *customerService) GetCustomer(ctx context.Context, id string) (*models.Customer, error) {
|
||||
lang := pkgcontext.GetLanguageFromContext(ctx)
|
||||
|
||||
// 业务逻辑:参数验证
|
||||
if id == "" {
|
||||
return nil, i18n.NewI18nError("900001", lang)
|
||||
}
|
||||
|
||||
// 委托给Repository层进行数据访问
|
||||
customer, err := s.customerRepo.GetCustomerByID(ctx, id)
|
||||
if err != nil {
|
||||
// 根据Repository错误类型包装为完整的I18nError
|
||||
if errors.Is(err, repository.ErrCustomerNotFound) {
|
||||
return nil, i18n.NewI18nError("200001", lang)
|
||||
}
|
||||
|
||||
// 数据库相关错误
|
||||
return nil, i18n.NewI18nError("900004", lang, err.Error())
|
||||
}
|
||||
|
||||
// 获取授权统计信息
|
||||
stats, err := s.customerRepo.GetCustomerAuthorizationStats(ctx, id)
|
||||
if err != nil {
|
||||
// 统计信息获取失败不影响主流程,记录错误但继续执行
|
||||
// 如果获取失败,stats 为 nil,客户详情仍然可以返回,只是没有统计信息
|
||||
// 根据需求文档,空数据时应该返回0,所以这里创建一个空的统计对象
|
||||
stats = &models.AuthorizationStats{}
|
||||
}
|
||||
customer.AuthorizationStats = stats
|
||||
|
||||
// 填充多语言显示字段
|
||||
s.fillCustomerDisplayFields(customer, lang)
|
||||
|
||||
return customer, nil
|
||||
}
|
||||
|
||||
// fillDisplayFields 填充列表项多语言显示字段
|
||||
func (s *customerService) fillDisplayFields(item *models.CustomerListItem, lang string) {
|
||||
item.CustomerTypeDisplay = i18n.GetEnumMessage("customer_type", item.CustomerType, lang)
|
||||
item.CustomerLevelDisplay = i18n.GetEnumMessage("customer_level", item.CustomerLevel, lang)
|
||||
item.StatusDisplay = i18n.GetEnumMessage("customer_status", item.Status, lang)
|
||||
}
|
||||
|
||||
// CreateCustomer 创建客户
|
||||
func (s *customerService) CreateCustomer(ctx context.Context, req *models.CustomerCreateRequest) (*models.Customer, error) {
|
||||
lang := pkgcontext.GetLanguageFromContext(ctx)
|
||||
|
||||
// 业务逻辑:参数验证
|
||||
if req == nil {
|
||||
return nil, i18n.NewI18nError("900001", lang)
|
||||
}
|
||||
|
||||
// 获取当前用户ID(从Context中获取)
|
||||
// TODO: 实现用户上下文获取,这里暂时使用硬编码
|
||||
currentUserID := "admin_uuid" // 实际应该从JWT token中获取
|
||||
|
||||
// 构建客户实体
|
||||
customer := &models.Customer{
|
||||
CustomerName: req.CustomerName,
|
||||
CustomerType: req.CustomerType,
|
||||
ContactPerson: req.ContactPerson,
|
||||
ContactTitle: req.ContactTitle,
|
||||
Email: req.Email,
|
||||
Phone: req.Phone,
|
||||
Address: req.Address,
|
||||
CompanySize: req.CompanySize,
|
||||
CustomerLevel: req.CustomerLevel,
|
||||
Status: req.Status,
|
||||
Description: req.Description,
|
||||
CreatedBy: currentUserID,
|
||||
// CustomerCode 将在Repository层自动生成
|
||||
// CreatedAt, UpdatedAt 将在模型的BeforeCreate中自动设置
|
||||
}
|
||||
|
||||
// 委托给Repository层进行数据创建
|
||||
if err := s.customerRepo.CreateCustomer(ctx, customer); err != nil {
|
||||
// 根据错误类型包装为完整的I18nError
|
||||
if strings.Contains(err.Error(), "duplicate") || strings.Contains(err.Error(), "unique") {
|
||||
return nil, i18n.NewI18nError("200002", lang) // 客户已存在
|
||||
}
|
||||
|
||||
// 数据库相关错误
|
||||
return nil, i18n.NewI18nError("900004", lang, err.Error())
|
||||
}
|
||||
|
||||
// 填充多语言显示字段
|
||||
s.fillCustomerDisplayFields(customer, lang)
|
||||
|
||||
return customer, nil
|
||||
}
|
||||
|
||||
// UpdateCustomer 更新客户信息
|
||||
func (s *customerService) UpdateCustomer(ctx context.Context, id string, req *models.CustomerUpdateRequest) (*models.Customer, error) {
|
||||
lang := pkgcontext.GetLanguageFromContext(ctx)
|
||||
|
||||
// 业务逻辑:参数验证
|
||||
if id == "" {
|
||||
return nil, i18n.NewI18nError("900001", lang)
|
||||
}
|
||||
if req == nil {
|
||||
return nil, i18n.NewI18nError("900001", lang)
|
||||
}
|
||||
|
||||
// 先查询现有客户
|
||||
existingCustomer, err := s.customerRepo.GetCustomerByID(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, repository.ErrCustomerNotFound) {
|
||||
return nil, i18n.NewI18nError("200001", lang)
|
||||
}
|
||||
return nil, i18n.NewI18nError("900004", lang, err.Error())
|
||||
}
|
||||
|
||||
// 获取当前用户ID
|
||||
currentUserID := "admin_uuid" // TODO: 从JWT token中获取
|
||||
|
||||
// 只更新提供的字段
|
||||
if req.CustomerName != nil {
|
||||
existingCustomer.CustomerName = *req.CustomerName
|
||||
}
|
||||
if req.CustomerType != nil {
|
||||
existingCustomer.CustomerType = *req.CustomerType
|
||||
}
|
||||
if req.ContactPerson != nil {
|
||||
existingCustomer.ContactPerson = *req.ContactPerson
|
||||
}
|
||||
if req.ContactTitle != nil {
|
||||
existingCustomer.ContactTitle = req.ContactTitle
|
||||
}
|
||||
if req.Email != nil {
|
||||
existingCustomer.Email = req.Email
|
||||
}
|
||||
if req.Phone != nil {
|
||||
existingCustomer.Phone = req.Phone
|
||||
}
|
||||
if req.Address != nil {
|
||||
existingCustomer.Address = req.Address
|
||||
}
|
||||
if req.CompanySize != nil {
|
||||
existingCustomer.CompanySize = req.CompanySize
|
||||
}
|
||||
if req.CustomerLevel != nil {
|
||||
existingCustomer.CustomerLevel = *req.CustomerLevel
|
||||
}
|
||||
if req.Status != nil {
|
||||
existingCustomer.Status = *req.Status
|
||||
}
|
||||
if req.Description != nil {
|
||||
existingCustomer.Description = req.Description
|
||||
}
|
||||
|
||||
// 设置更新者
|
||||
existingCustomer.UpdatedBy = ¤tUserID
|
||||
|
||||
// 委托给Repository层进行数据更新
|
||||
if err := s.customerRepo.UpdateCustomer(ctx, existingCustomer); err != nil {
|
||||
return nil, i18n.NewI18nError("900004", lang, err.Error())
|
||||
}
|
||||
|
||||
// 填充多语言显示字段
|
||||
s.fillCustomerDisplayFields(existingCustomer, lang)
|
||||
|
||||
return existingCustomer, nil
|
||||
}
|
||||
|
||||
// DeleteCustomer 删除客户(物理删除,删除前检查是否有授权)
|
||||
func (s *customerService) DeleteCustomer(ctx context.Context, id string) error {
|
||||
lang := pkgcontext.GetLanguageFromContext(ctx)
|
||||
|
||||
// 业务逻辑:参数验证
|
||||
if id == "" {
|
||||
return i18n.NewI18nError("900001", lang)
|
||||
}
|
||||
|
||||
// 检查客户是否存在
|
||||
_, err := s.customerRepo.GetCustomerByID(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, repository.ErrCustomerNotFound) {
|
||||
return i18n.NewI18nError("200001", lang)
|
||||
}
|
||||
return i18n.NewI18nError("900004", lang, err.Error())
|
||||
}
|
||||
|
||||
// 检查是否有关联的授权码
|
||||
hasAuthCodes, err := s.customerRepo.CheckCustomerHasAuthorizationCodes(ctx, id)
|
||||
if err != nil {
|
||||
return i18n.NewI18nError("900004", lang, err.Error())
|
||||
}
|
||||
if hasAuthCodes {
|
||||
return i18n.NewI18nError("200006", lang)
|
||||
}
|
||||
|
||||
// 检查是否有关联的许可证
|
||||
hasLicenses, err := s.customerRepo.CheckCustomerHasLicenses(ctx, id)
|
||||
if err != nil {
|
||||
return i18n.NewI18nError("900004", lang, err.Error())
|
||||
}
|
||||
if hasLicenses {
|
||||
return i18n.NewI18nError("200006", lang)
|
||||
}
|
||||
|
||||
// 委托给Repository层进行物理删除
|
||||
if err := s.customerRepo.DeleteCustomer(ctx, id); err != nil {
|
||||
if errors.Is(err, repository.ErrCustomerNotFound) {
|
||||
return i18n.NewI18nError("200001", lang)
|
||||
}
|
||||
return i18n.NewI18nError("900004", lang, err.Error())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateCustomerStatus 更新客户状态
|
||||
func (s *customerService) UpdateCustomerStatus(ctx context.Context, id string, req *models.CustomerStatusUpdateRequest) (*models.Customer, error) {
|
||||
lang := pkgcontext.GetLanguageFromContext(ctx)
|
||||
|
||||
// 业务逻辑:参数验证
|
||||
if id == "" {
|
||||
return nil, i18n.NewI18nError("900001", lang)
|
||||
}
|
||||
if req == nil {
|
||||
return nil, i18n.NewI18nError("900001", lang)
|
||||
}
|
||||
|
||||
// 先查询现有客户
|
||||
existingCustomer, err := s.customerRepo.GetCustomerByID(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, repository.ErrCustomerNotFound) {
|
||||
return nil, i18n.NewI18nError("200001", lang)
|
||||
}
|
||||
return nil, i18n.NewI18nError("900004", lang, err.Error())
|
||||
}
|
||||
|
||||
// 获取当前用户ID
|
||||
currentUserID := "admin_uuid" // TODO: 从JWT token中获取
|
||||
|
||||
// 更新状态
|
||||
existingCustomer.Status = req.Status
|
||||
existingCustomer.UpdatedBy = ¤tUserID
|
||||
|
||||
// 委托给Repository层进行数据更新
|
||||
if err := s.customerRepo.UpdateCustomer(ctx, existingCustomer); err != nil {
|
||||
return nil, i18n.NewI18nError("900004", lang, err.Error())
|
||||
}
|
||||
|
||||
// 填充多语言显示字段
|
||||
s.fillCustomerDisplayFields(existingCustomer, lang)
|
||||
|
||||
return existingCustomer, nil
|
||||
}
|
||||
|
||||
// fillCustomerDisplayFields 填充完整客户模型的多语言显示字段
|
||||
func (s *customerService) fillCustomerDisplayFields(customer *models.Customer, lang string) {
|
||||
customer.CustomerTypeDisplay = i18n.GetEnumMessage("customer_type", customer.CustomerType, lang)
|
||||
customer.CustomerLevelDisplay = i18n.GetEnumMessage("customer_level", customer.CustomerLevel, lang)
|
||||
customer.StatusDisplay = i18n.GetEnumMessage("customer_status", customer.Status, lang)
|
||||
if customer.CompanySize != nil {
|
||||
customer.CompanySizeDisplay = i18n.GetEnumMessage("company_size", *customer.CompanySize, lang)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"license-manager/internal/models"
|
||||
"license-manager/internal/repository"
|
||||
pkgcontext "license-manager/pkg/context"
|
||||
"license-manager/pkg/i18n"
|
||||
)
|
||||
|
||||
type dashboardService struct {
|
||||
dashboardRepo repository.DashboardRepository
|
||||
}
|
||||
|
||||
// NewDashboardService 创建仪表盘服务实例
|
||||
func NewDashboardService(dashboardRepo repository.DashboardRepository) DashboardService {
|
||||
return &dashboardService{
|
||||
dashboardRepo: dashboardRepo,
|
||||
}
|
||||
}
|
||||
|
||||
// GetAuthorizationTrend 获取授权趋势数据
|
||||
func (s *dashboardService) GetAuthorizationTrend(ctx context.Context, req *models.DashboardAuthorizationTrendRequest) (*models.DashboardAuthorizationTrendResponse, error) {
|
||||
lang := pkgcontext.GetLanguageFromContext(ctx)
|
||||
|
||||
// 业务逻辑:参数验证
|
||||
if req == nil {
|
||||
return nil, i18n.NewI18nError("900001", lang)
|
||||
}
|
||||
|
||||
var startDate, endDate time.Time
|
||||
var descriptionDisplay string
|
||||
|
||||
// 获取时区,默认使用本地时区
|
||||
loc := time.Local
|
||||
if req.Timezone != "" {
|
||||
var err error
|
||||
loc, err = time.LoadLocation(req.Timezone)
|
||||
if err != nil {
|
||||
// 时区解析失败,使用本地时区
|
||||
loc = time.Local
|
||||
}
|
||||
}
|
||||
|
||||
now := time.Now().In(loc)
|
||||
|
||||
// 根据类型计算时间范围
|
||||
switch req.Type {
|
||||
case "week":
|
||||
// 本周:从周一到周日
|
||||
weekday := int(now.Weekday())
|
||||
if weekday == 0 { // Sunday
|
||||
weekday = 7
|
||||
}
|
||||
startDate = now.AddDate(0, 0, -(weekday-1))
|
||||
startDate = time.Date(startDate.Year(), startDate.Month(), startDate.Day(), 0, 0, 0, 0, loc)
|
||||
endDate = startDate.AddDate(0, 0, 6)
|
||||
endDate = time.Date(endDate.Year(), endDate.Month(), endDate.Day(), 23, 59, 59, 999999999, loc)
|
||||
descriptionDisplay = i18n.GetEnumMessage("dashboard_period", "week", lang)
|
||||
|
||||
case "month":
|
||||
// 本月:从1号到月末
|
||||
startDate = time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, loc)
|
||||
endDate = startDate.AddDate(0, 1, -1)
|
||||
endDate = time.Date(endDate.Year(), endDate.Month(), endDate.Day(), 23, 59, 59, 999999999, loc)
|
||||
descriptionDisplay = i18n.GetEnumMessage("dashboard_period", "month", lang)
|
||||
|
||||
case "custom":
|
||||
// 自定义时间范围
|
||||
if req.StartDate == "" || req.EndDate == "" {
|
||||
return nil, i18n.NewI18nError("400002", lang) // 开始日期和结束日期都是必填的
|
||||
}
|
||||
|
||||
var err error
|
||||
startDate, err = time.ParseInLocation("2006-01-02", req.StartDate, loc)
|
||||
if err != nil {
|
||||
return nil, i18n.NewI18nError("400002", lang) // 开始日期格式错误
|
||||
}
|
||||
|
||||
endDate, err = time.ParseInLocation("2006-01-02", req.EndDate, loc)
|
||||
if err != nil {
|
||||
return nil, i18n.NewI18nError("400003", lang) // 结束日期格式错误
|
||||
}
|
||||
|
||||
// 验证时间范围
|
||||
if startDate.After(endDate) {
|
||||
return nil, i18n.NewI18nError("400005", lang) // 开始日期不能晚于结束日期
|
||||
}
|
||||
|
||||
// 验证时间跨度不超过一年
|
||||
if endDate.Sub(startDate) > 365*24*time.Hour {
|
||||
return nil, i18n.NewI18nError("400004", lang) // 时间范围超过限制
|
||||
}
|
||||
|
||||
// 设置为全天
|
||||
startDate = time.Date(startDate.Year(), startDate.Month(), startDate.Day(), 0, 0, 0, 0, startDate.Location())
|
||||
endDate = time.Date(endDate.Year(), endDate.Month(), endDate.Day(), 23, 59, 59, 999999999, endDate.Location())
|
||||
descriptionDisplay = fmt.Sprintf("%s - %s", req.StartDate, req.EndDate)
|
||||
|
||||
default:
|
||||
return nil, i18n.NewI18nError("400001", lang) // 时间类型参数错误
|
||||
}
|
||||
|
||||
// 获取趋势数据
|
||||
trendData, err := s.dashboardRepo.GetAuthorizationTrendData(ctx, startDate, endDate)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 计算汇总信息
|
||||
var totalCount, newCount, expiredCount int64
|
||||
for _, data := range trendData {
|
||||
newCount += data.NewAuthorizations
|
||||
expiredCount += data.ExpiredAuthorizations
|
||||
}
|
||||
|
||||
// 获取期间结束时的总授权数
|
||||
if len(trendData) > 0 {
|
||||
totalCount = trendData[len(trendData)-1].TotalAuthorizations
|
||||
}
|
||||
|
||||
// 计算增长率(本期新增相对于期初的百分比)
|
||||
var growthRate float64
|
||||
if totalCount > 0 && newCount > 0 {
|
||||
periodStartCount := totalCount - newCount
|
||||
if periodStartCount > 0 {
|
||||
growthRate = float64(newCount) / float64(periodStartCount) * 100
|
||||
} else {
|
||||
growthRate = 100.0 // 如果期初为0,则增长率为100%
|
||||
}
|
||||
}
|
||||
|
||||
return &models.DashboardAuthorizationTrendResponse{
|
||||
Period: models.TrendPeriod{
|
||||
Type: req.Type,
|
||||
StartDate: startDate.Format("2006-01-02"),
|
||||
EndDate: endDate.Format("2006-01-02"),
|
||||
DescriptionDisplay: descriptionDisplay,
|
||||
},
|
||||
TrendData: trendData,
|
||||
Summary: models.TrendSummary{
|
||||
TotalCount: totalCount,
|
||||
NewCount: newCount,
|
||||
ExpiredCount: expiredCount,
|
||||
GrowthRate: growthRate,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetRecentAuthorizations 获取最近授权列表
|
||||
func (s *dashboardService) GetRecentAuthorizations(ctx context.Context, req *models.DashboardRecentAuthorizationsRequest) (*models.DashboardRecentAuthorizationsResponse, error) {
|
||||
lang := pkgcontext.GetLanguageFromContext(ctx)
|
||||
|
||||
// 业务逻辑:参数验证
|
||||
if req == nil {
|
||||
return nil, i18n.NewI18nError("900001", lang)
|
||||
}
|
||||
|
||||
// 验证limit参数
|
||||
if req.Limit < 0 || req.Limit > 100 {
|
||||
return nil, i18n.NewI18nError("400006", lang) // 数量限制参数错误
|
||||
}
|
||||
|
||||
// 验证status参数
|
||||
if req.Status != "" && req.Status != "normal" && req.Status != "locked" && req.Status != "expired" {
|
||||
return nil, i18n.NewI18nError("400001", lang) // 状态参数错误
|
||||
}
|
||||
|
||||
// 调用repository获取数据
|
||||
return s.dashboardRepo.GetRecentAuthorizations(ctx, req)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"license-manager/internal/models"
|
||||
pkgcontext "license-manager/pkg/context"
|
||||
"license-manager/pkg/i18n"
|
||||
)
|
||||
|
||||
type enumService struct{}
|
||||
|
||||
// NewEnumService 创建枚举服务实例
|
||||
func NewEnumService() EnumService {
|
||||
return &enumService{}
|
||||
}
|
||||
|
||||
// GetAllEnums 获取所有枚举类型及其值
|
||||
func (s *enumService) GetAllEnums(ctx context.Context) (*models.EnumListResponse, error) {
|
||||
lang := pkgcontext.GetLanguageFromContext(ctx)
|
||||
|
||||
// 从i18n管理器获取所有枚举值
|
||||
enumsMap := i18n.GetAllEnums(lang)
|
||||
|
||||
var enums []models.EnumTypeResponse
|
||||
for enumType, enumItems := range enumsMap {
|
||||
var items []models.EnumItem
|
||||
for key, display := range enumItems {
|
||||
items = append(items, models.EnumItem{
|
||||
Key: key,
|
||||
Display: display,
|
||||
})
|
||||
}
|
||||
|
||||
enums = append(enums, models.EnumTypeResponse{
|
||||
Type: enumType,
|
||||
Items: items,
|
||||
})
|
||||
}
|
||||
|
||||
return &models.EnumListResponse{
|
||||
Enums: enums,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetEnumsByType 获取指定类型的枚举值
|
||||
func (s *enumService) GetEnumsByType(ctx context.Context, enumType string) (*models.EnumTypeResponse, error) {
|
||||
lang := pkgcontext.GetLanguageFromContext(ctx)
|
||||
|
||||
// 业务逻辑:参数验证
|
||||
if enumType == "" {
|
||||
return nil, i18n.NewI18nError("900001", lang)
|
||||
}
|
||||
|
||||
// 从i18n管理器获取指定类型的枚举值
|
||||
enumItems := i18n.GetEnumsByType(enumType, lang)
|
||||
|
||||
var items []models.EnumItem
|
||||
for key, display := range enumItems {
|
||||
items = append(items, models.EnumItem{
|
||||
Key: key,
|
||||
Display: display,
|
||||
})
|
||||
}
|
||||
|
||||
return &models.EnumTypeResponse{
|
||||
Type: enumType,
|
||||
Items: items,
|
||||
}, nil
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
package service
|
||||
|
||||
import "license-manager/internal/models"
|
||||
import (
|
||||
"context"
|
||||
"license-manager/internal/models"
|
||||
)
|
||||
|
||||
// AuthService 认证服务接口
|
||||
type AuthService interface {
|
||||
Login(req *models.LoginRequest) (*models.LoginData, error)
|
||||
Login(ctx context.Context, req *models.LoginRequest, clientIP string) (*models.LoginData, error)
|
||||
RefreshToken(token string) (string, error)
|
||||
ValidateToken(token string) error
|
||||
}
|
||||
@@ -13,4 +16,56 @@ type AuthService interface {
|
||||
type SystemService interface {
|
||||
GetHealthStatus() *models.HealthResponse
|
||||
GetSystemInfo() map[string]interface{}
|
||||
}
|
||||
}
|
||||
|
||||
// CustomerService 客户服务接口
|
||||
type CustomerService interface {
|
||||
GetCustomerList(ctx context.Context, req *models.CustomerListRequest) (*models.CustomerListResponse, error)
|
||||
GetCustomer(ctx context.Context, id string) (*models.Customer, error)
|
||||
CreateCustomer(ctx context.Context, req *models.CustomerCreateRequest) (*models.Customer, error)
|
||||
UpdateCustomer(ctx context.Context, id string, req *models.CustomerUpdateRequest) (*models.Customer, error)
|
||||
DeleteCustomer(ctx context.Context, id string) error
|
||||
UpdateCustomerStatus(ctx context.Context, id string, req *models.CustomerStatusUpdateRequest) (*models.Customer, error)
|
||||
}
|
||||
|
||||
// EnumService 枚举服务接口
|
||||
type EnumService interface {
|
||||
GetAllEnums(ctx context.Context) (*models.EnumListResponse, error)
|
||||
GetEnumsByType(ctx context.Context, enumType string) (*models.EnumTypeResponse, error)
|
||||
}
|
||||
|
||||
// AuthorizationCodeService 授权码服务接口
|
||||
type AuthorizationCodeService interface {
|
||||
CreateAuthorizationCode(ctx context.Context, req *models.AuthorizationCodeCreateRequest) (*models.AuthorizationCodeCreateResponse, error)
|
||||
GetAuthorizationCodeList(ctx context.Context, req *models.AuthorizationCodeListRequest) (*models.AuthorizationCodeListResponse, error)
|
||||
GetAuthorizationCode(ctx context.Context, id string) (*models.AuthorizationCode, error)
|
||||
UpdateAuthorizationCode(ctx context.Context, id string, req *models.AuthorizationCodeUpdateRequest) (*models.AuthorizationCode, error)
|
||||
LockUnlockAuthorizationCode(ctx context.Context, id string, req *models.AuthorizationCodeLockRequest) (*models.AuthorizationCode, error)
|
||||
DeleteAuthorizationCode(ctx context.Context, id string) error
|
||||
GetAuthorizationChangeList(ctx context.Context, authCodeID string, req *models.AuthorizationChangeListRequest) (*models.AuthorizationChangeListResponse, error)
|
||||
}
|
||||
|
||||
// LicenseService 许可证服务接口
|
||||
type LicenseService interface {
|
||||
GetLicenseList(ctx context.Context, req *models.LicenseListRequest) (*models.LicenseListResponse, error)
|
||||
GetLicense(ctx context.Context, id string) (*models.LicenseDetailResponse, error)
|
||||
CreateLicense(ctx context.Context, req *models.LicenseCreateRequest) (*models.License, error)
|
||||
RevokeLicense(ctx context.Context, id string, req *models.LicenseRevokeRequest) (*models.License, error)
|
||||
GenerateLicenseFile(ctx context.Context, id string) ([]byte, string, error)
|
||||
|
||||
// 客户端激活和心跳接口
|
||||
ActivateLicense(ctx context.Context, req *models.ActivateRequest, clientIP string) (*models.ActivateResponse, error)
|
||||
Heartbeat(ctx context.Context, req *models.HeartbeatRequest, clientIP string) (*models.HeartbeatResponse, error)
|
||||
|
||||
// 统计接口
|
||||
GetStatsOverview(ctx context.Context) (*models.StatsOverviewResponse, error)
|
||||
}
|
||||
|
||||
// DashboardService 仪表盘服务接口
|
||||
type DashboardService interface {
|
||||
// 获取授权趋势数据
|
||||
GetAuthorizationTrend(ctx context.Context, req *models.DashboardAuthorizationTrendRequest) (*models.DashboardAuthorizationTrendResponse, error)
|
||||
|
||||
// 获取最近授权列表
|
||||
GetRecentAuthorizations(ctx context.Context, req *models.DashboardRecentAuthorizationsRequest) (*models.DashboardRecentAuthorizationsResponse, error)
|
||||
}
|
||||
@@ -0,0 +1,847 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"gorm.io/gorm"
|
||||
"license-manager/internal/config"
|
||||
"license-manager/internal/models"
|
||||
"license-manager/internal/repository"
|
||||
pkgcontext "license-manager/pkg/context"
|
||||
"license-manager/pkg/i18n"
|
||||
"license-manager/pkg/utils"
|
||||
)
|
||||
|
||||
type licenseService struct {
|
||||
licenseRepo repository.LicenseRepository
|
||||
db *gorm.DB
|
||||
logger *logrus.Logger
|
||||
rsaPrivateKey *utils.RSAPrivateKey // RSA私钥(缓存)
|
||||
}
|
||||
|
||||
// NewLicenseService 创建许可证服务实例
|
||||
func NewLicenseService(licenseRepo repository.LicenseRepository, db *gorm.DB, logger *logrus.Logger) LicenseService {
|
||||
return &licenseService{
|
||||
licenseRepo: licenseRepo,
|
||||
db: db,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// GetLicenseList 查询许可证列表
|
||||
func (s *licenseService) GetLicenseList(ctx context.Context, req *models.LicenseListRequest) (*models.LicenseListResponse, error) {
|
||||
lang := pkgcontext.GetLanguageFromContext(ctx)
|
||||
|
||||
// 业务逻辑:参数验证
|
||||
if req == nil {
|
||||
return nil, i18n.NewI18nError("900001", lang) // 业务错误,不覆盖多语言message
|
||||
}
|
||||
|
||||
// 委托给Repository层进行数据访问
|
||||
result, err := s.licenseRepo.GetLicenseList(ctx, req)
|
||||
if err != nil {
|
||||
// 数据库相关错误,使用系统错误码,覆盖message显示详细信息
|
||||
return nil, i18n.NewI18nError("900004", lang, err.Error())
|
||||
}
|
||||
|
||||
// 业务逻辑:添加多语言显示字段
|
||||
for i := range result.List {
|
||||
s.fillDisplayFields(&result.List[i], lang)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// fillDisplayFields 填充列表项多语言显示字段
|
||||
func (s *licenseService) fillDisplayFields(item *models.LicenseListItem, lang string) {
|
||||
// 填充状态显示字段
|
||||
item.StatusDisplay = i18n.GetEnumMessage("license_status", item.Status, lang)
|
||||
|
||||
// 填充在线状态显示字段
|
||||
if item.IsOnline {
|
||||
item.IsOnlineDisplay = i18n.GetEnumMessage("license_online_status", "online", lang)
|
||||
} else {
|
||||
item.IsOnlineDisplay = i18n.GetEnumMessage("license_online_status", "offline", lang)
|
||||
}
|
||||
}
|
||||
|
||||
// GetLicense 获取单个许可证详情
|
||||
func (s *licenseService) GetLicense(ctx context.Context, id string) (*models.LicenseDetailResponse, error) {
|
||||
lang := pkgcontext.GetLanguageFromContext(ctx)
|
||||
|
||||
// 业务逻辑:参数验证
|
||||
if id == "" {
|
||||
return nil, i18n.NewI18nError("900001", lang)
|
||||
}
|
||||
|
||||
// 委托给Repository层进行数据访问
|
||||
license, err := s.licenseRepo.GetLicenseByID(ctx, id)
|
||||
if err != nil {
|
||||
// 根据Repository错误类型包装为完整的I18nError
|
||||
if errors.Is(err, repository.ErrLicenseNotFound) {
|
||||
return nil, i18n.NewI18nError("300006", lang) // 许可证不存在
|
||||
}
|
||||
|
||||
// 数据库相关错误,使用系统错误码,覆盖message显示详细信息
|
||||
return nil, i18n.NewI18nError("900004", lang, err.Error())
|
||||
}
|
||||
|
||||
// 转换为详情响应格式
|
||||
response := s.convertToDetailResponse(license, lang)
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// CreateLicense 创建许可证
|
||||
func (s *licenseService) CreateLicense(ctx context.Context, req *models.LicenseCreateRequest) (*models.License, error) {
|
||||
lang := pkgcontext.GetLanguageFromContext(ctx)
|
||||
|
||||
// 业务逻辑:参数验证
|
||||
if req == nil {
|
||||
return nil, i18n.NewI18nError("900001", lang)
|
||||
}
|
||||
|
||||
// 获取授权码信息以获取客户ID
|
||||
authCode, err := s.licenseRepo.GetAuthorizationCodeByID(ctx, req.AuthorizationCodeID)
|
||||
if err != nil {
|
||||
if errors.Is(err, repository.ErrAuthorizationCodeNotFound) {
|
||||
return nil, i18n.NewI18nError("300001", lang) // 授权码不存在
|
||||
}
|
||||
return nil, i18n.NewI18nError("900004", lang, err.Error())
|
||||
}
|
||||
|
||||
// 生成许可证密钥
|
||||
licenseKey, err := s.generateLicenseKey()
|
||||
if err != nil {
|
||||
return nil, i18n.NewI18nError("900004", lang, err.Error())
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
|
||||
// 构建许可证实体,手动添加的离线许可证直接视为已激活
|
||||
license := &models.License{
|
||||
LicenseKey: licenseKey,
|
||||
AuthorizationCodeID: req.AuthorizationCodeID,
|
||||
CustomerID: authCode.CustomerID, // 从授权码获取客户ID
|
||||
HardwareFingerprint: req.HardwareFingerprint,
|
||||
ActivationIP: req.ActivationIP,
|
||||
Status: "active",
|
||||
ActivatedAt: &now,
|
||||
}
|
||||
|
||||
// 设置设备信息
|
||||
if req.DeviceInfo != nil {
|
||||
deviceInfoBytes, err := json.Marshal(req.DeviceInfo)
|
||||
if err == nil {
|
||||
license.DeviceInfo = models.JSON(deviceInfoBytes)
|
||||
}
|
||||
}
|
||||
|
||||
// 委托给Repository层进行数据创建
|
||||
if err := s.licenseRepo.CreateLicense(ctx, license); err != nil {
|
||||
// 根据错误类型包装为完整的I18nError
|
||||
if strings.Contains(err.Error(), "duplicate") || strings.Contains(err.Error(), "unique") {
|
||||
return nil, i18n.NewI18nError("300007", lang) // 许可证已存在
|
||||
}
|
||||
|
||||
// 数据库相关错误
|
||||
return nil, i18n.NewI18nError("900004", lang, err.Error())
|
||||
}
|
||||
|
||||
return license, nil
|
||||
}
|
||||
|
||||
// convertToDetailResponse 转换为详情响应格式
|
||||
func (s *licenseService) convertToDetailResponse(license *models.License, lang string) *models.LicenseDetailResponse {
|
||||
response := &models.LicenseDetailResponse{
|
||||
ID: license.ID,
|
||||
LicenseKey: license.LicenseKey,
|
||||
AuthorizationCodeID: license.AuthorizationCodeID,
|
||||
CustomerID: license.CustomerID,
|
||||
HardwareFingerprint: license.HardwareFingerprint,
|
||||
ActivationIP: license.ActivationIP,
|
||||
Status: license.Status,
|
||||
IsOnline: license.IsOnline,
|
||||
LastOnlineIP: license.LastOnlineIP,
|
||||
CreatedAt: license.CreatedAt.Format(time.RFC3339),
|
||||
UpdatedAt: license.UpdatedAt.Format(time.RFC3339),
|
||||
}
|
||||
|
||||
// 格式化时间字段
|
||||
if license.ActivatedAt != nil {
|
||||
activatedAt := license.ActivatedAt.Format(time.RFC3339)
|
||||
response.ActivatedAt = &activatedAt
|
||||
}
|
||||
if license.LastHeartbeat != nil {
|
||||
lastHeartbeat := license.LastHeartbeat.Format(time.RFC3339)
|
||||
response.LastHeartbeat = &lastHeartbeat
|
||||
}
|
||||
if license.ConfigUpdatedAt != nil {
|
||||
configUpdatedAt := license.ConfigUpdatedAt.Format(time.RFC3339)
|
||||
response.ConfigUpdatedAt = &configUpdatedAt
|
||||
}
|
||||
|
||||
// 设置关联数据
|
||||
if license.AuthorizationCode != nil {
|
||||
response.AuthorizationCode = license.AuthorizationCode.Code
|
||||
}
|
||||
if license.Customer != nil {
|
||||
response.CustomerName = license.Customer.CustomerName
|
||||
}
|
||||
|
||||
// 解析JSON字段
|
||||
if len(license.DeviceInfo) > 0 {
|
||||
var deviceInfo map[string]interface{}
|
||||
if err := json.Unmarshal(license.DeviceInfo, &deviceInfo); err == nil {
|
||||
response.DeviceInfo = deviceInfo
|
||||
}
|
||||
}
|
||||
if len(license.UsageData) > 0 {
|
||||
var usageData map[string]interface{}
|
||||
if err := json.Unmarshal(license.UsageData, &usageData); err == nil {
|
||||
response.UsageData = usageData
|
||||
}
|
||||
}
|
||||
|
||||
// 填充多语言显示字段
|
||||
response.StatusDisplay = i18n.GetEnumMessage("license_status", license.Status, lang)
|
||||
if license.IsOnline {
|
||||
response.IsOnlineDisplay = i18n.GetEnumMessage("license_online_status", "online", lang)
|
||||
} else {
|
||||
response.IsOnlineDisplay = i18n.GetEnumMessage("license_online_status", "offline", lang)
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
// RevokeLicense 撤销许可证
|
||||
func (s *licenseService) RevokeLicense(ctx context.Context, id string, req *models.LicenseRevokeRequest) (*models.License, error) {
|
||||
lang := pkgcontext.GetLanguageFromContext(ctx)
|
||||
|
||||
// 业务逻辑:参数验证
|
||||
if id == "" {
|
||||
return nil, i18n.NewI18nError("900001", lang)
|
||||
}
|
||||
if req == nil {
|
||||
return nil, i18n.NewI18nError("900001", lang)
|
||||
}
|
||||
|
||||
// 先查询现有许可证
|
||||
existingLicense, err := s.licenseRepo.GetLicenseByID(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, repository.ErrLicenseNotFound) {
|
||||
return nil, i18n.NewI18nError("300006", lang) // 许可证不存在
|
||||
}
|
||||
return nil, i18n.NewI18nError("900004", lang, err.Error())
|
||||
}
|
||||
|
||||
// 检查许可证是否已经被撤销
|
||||
if existingLicense.Status == "revoked" {
|
||||
return nil, i18n.NewI18nError("300007", lang) // 许可证已被撤销
|
||||
}
|
||||
|
||||
// 更新许可证状态为撤销
|
||||
existingLicense.Status = "revoked"
|
||||
|
||||
// 如果提供了撤销原因,保存到使用数据中
|
||||
if req.Reason != "" {
|
||||
revokeData := map[string]interface{}{
|
||||
"revoked_at": time.Now().Format(time.RFC3339),
|
||||
"revoke_reason": req.Reason,
|
||||
}
|
||||
|
||||
// 保留原有的使用数据
|
||||
if len(existingLicense.UsageData) > 0 {
|
||||
var existingUsageData map[string]interface{}
|
||||
if err := json.Unmarshal(existingLicense.UsageData, &existingUsageData); err == nil {
|
||||
for k, v := range existingUsageData {
|
||||
revokeData[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
usageDataBytes, err := json.Marshal(revokeData)
|
||||
if err == nil {
|
||||
existingLicense.UsageData = models.JSON(usageDataBytes)
|
||||
}
|
||||
}
|
||||
|
||||
// 委托给Repository层进行数据更新
|
||||
if err := s.licenseRepo.UpdateLicense(ctx, existingLicense); err != nil {
|
||||
return nil, i18n.NewI18nError("900004", lang, err.Error())
|
||||
}
|
||||
|
||||
return existingLicense, nil
|
||||
}
|
||||
|
||||
// GenerateLicenseFile 生成许可证文件
|
||||
func (s *licenseService) GenerateLicenseFile(ctx context.Context, id string) ([]byte, string, error) {
|
||||
lang := pkgcontext.GetLanguageFromContext(ctx)
|
||||
s.logger.Infof("[GenerateLicenseFile] 开始生成许可证文件,license_id: %s", id)
|
||||
|
||||
// 业务逻辑:参数验证
|
||||
if id == "" {
|
||||
s.logger.Error("[GenerateLicenseFile] 错误:许可证ID为空")
|
||||
return nil, "", i18n.NewI18nError("900001", lang)
|
||||
}
|
||||
|
||||
// 获取许可证信息
|
||||
s.logger.Infof("[GenerateLicenseFile] 查询许可证信息,license_id: %s", id)
|
||||
license, err := s.licenseRepo.GetLicenseByID(ctx, id)
|
||||
if err != nil {
|
||||
s.logger.Errorf("[GenerateLicenseFile] 查询许可证失败,license_id: %s, error: %v", id, err)
|
||||
if errors.Is(err, repository.ErrLicenseNotFound) {
|
||||
return nil, "", i18n.NewI18nError("300006", lang) // 许可证不存在
|
||||
}
|
||||
return nil, "", i18n.NewI18nError("900004", lang, err.Error())
|
||||
}
|
||||
|
||||
s.logger.Infof("[GenerateLicenseFile] 许可证信息查询成功,license_key: %s, status: %s", license.LicenseKey, license.Status)
|
||||
|
||||
// 检查许可证状态
|
||||
if license.Status == "revoked" {
|
||||
s.logger.Warnf("[GenerateLicenseFile] 许可证已被撤销,license_id: %s", id)
|
||||
return nil, "", i18n.NewI18nError("300007", lang) // 许可证已被撤销
|
||||
}
|
||||
|
||||
// 构建许可证文件内容
|
||||
licenseFileData := map[string]interface{}{
|
||||
"license_key": license.LicenseKey,
|
||||
"authorization_code_id": license.AuthorizationCodeID,
|
||||
"hardware_fingerprint": license.HardwareFingerprint,
|
||||
"status": license.Status,
|
||||
"activated_at": license.ActivatedAt,
|
||||
"config_updated_at": license.ConfigUpdatedAt,
|
||||
"generated_at": time.Now().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
// 包含授权码信息
|
||||
if license.AuthorizationCode != nil {
|
||||
licenseFileData["authorization_code"] = license.AuthorizationCode.Code
|
||||
licenseFileData["start_date"] = license.AuthorizationCode.StartDate
|
||||
licenseFileData["end_date"] = license.AuthorizationCode.EndDate
|
||||
licenseFileData["deployment_type"] = license.AuthorizationCode.DeploymentType
|
||||
licenseFileData["max_activations"] = license.AuthorizationCode.MaxActivations
|
||||
|
||||
// 包含功能配置
|
||||
if len(license.AuthorizationCode.FeatureConfig) > 0 {
|
||||
var featureConfig map[string]interface{}
|
||||
if err := json.Unmarshal(license.AuthorizationCode.FeatureConfig, &featureConfig); err == nil {
|
||||
licenseFileData["feature_config"] = featureConfig
|
||||
}
|
||||
}
|
||||
|
||||
// 包含使用限制
|
||||
if len(license.AuthorizationCode.UsageLimits) > 0 {
|
||||
var usageLimits map[string]interface{}
|
||||
if err := json.Unmarshal(license.AuthorizationCode.UsageLimits, &usageLimits); err == nil {
|
||||
licenseFileData["usage_limits"] = usageLimits
|
||||
}
|
||||
}
|
||||
|
||||
// 包含自定义参数
|
||||
if len(license.AuthorizationCode.CustomParameters) > 0 {
|
||||
var customParameters map[string]interface{}
|
||||
if err := json.Unmarshal(license.AuthorizationCode.CustomParameters, &customParameters); err == nil {
|
||||
licenseFileData["custom_parameters"] = customParameters
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 序列化许可证数据
|
||||
licenseJSON, err := json.Marshal(licenseFileData)
|
||||
if err != nil {
|
||||
return nil, "", i18n.NewI18nError("300009", lang) // 许可证文件生成失败
|
||||
}
|
||||
|
||||
// 使用RSA数字签名
|
||||
encryptedData, err := s.signLicenseFile(licenseJSON)
|
||||
if err != nil {
|
||||
return nil, "", i18n.NewI18nError("300009", lang) // 许可证文件生成失败
|
||||
}
|
||||
|
||||
// 生成文件名
|
||||
fileName := fmt.Sprintf("license_%s.lic", license.LicenseKey)
|
||||
|
||||
return encryptedData, fileName, nil
|
||||
}
|
||||
|
||||
// generateLicenseKey 生成许可证密钥
|
||||
func (s *licenseService) generateLicenseKey() (string, error) {
|
||||
// 生成12字节随机数据
|
||||
bytes := make([]byte, 12)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// 转换为16进制字符串并格式化
|
||||
hex := strings.ToUpper(hex.EncodeToString(bytes))
|
||||
return fmt.Sprintf("LIC-DEVICE-%s", hex), nil
|
||||
}
|
||||
|
||||
// validateAuthorizationCodeFormat 验证授权码格式和校验和
|
||||
// 格式: LIC-{4位客户代码}-{8或12位随机}-{4位校验码}
|
||||
// 兼容旧格式(8位随机,36字符集)和新格式(12位随机,62字符集)
|
||||
func validateAuthorizationCodeFormat(code string) bool {
|
||||
// 解析格式
|
||||
parts := strings.Split(code, "-")
|
||||
if len(parts) != 4 || parts[0] != "LIC" {
|
||||
return false
|
||||
}
|
||||
|
||||
// 验证各部分长度(兼容旧格式8位和新格式12位)
|
||||
customerCodeLen := len(parts[1])
|
||||
randomLen := len(parts[2])
|
||||
checksumLen := len(parts[3])
|
||||
|
||||
// 客户代码必须为4位,校验和必须为4位
|
||||
if customerCodeLen != 4 || checksumLen != 4 {
|
||||
return false
|
||||
}
|
||||
|
||||
// 随机部分:兼容8位(旧格式)和12位(新格式)
|
||||
if randomLen != 8 && randomLen != 12 {
|
||||
return false
|
||||
}
|
||||
|
||||
// 根据随机部分长度选择字符集验证校验和
|
||||
// 8位:使用36字符集(旧格式)
|
||||
// 12位:使用62字符集(新格式)
|
||||
expectedChecksum := generateChecksumForCode(parts[1]+parts[2], randomLen == 12)
|
||||
return expectedChecksum == parts[3]
|
||||
}
|
||||
|
||||
// generateChecksumForCode 生成校验码(用于验证)
|
||||
// 基于输入字符串的ASCII值计算
|
||||
// useExtendedCharset: true使用62字符集(新格式),false使用36字符集(旧格式)
|
||||
func generateChecksumForCode(input string, useExtendedCharset bool) string {
|
||||
sum := 0
|
||||
for _, char := range input {
|
||||
sum += int(char)
|
||||
}
|
||||
|
||||
var chars string
|
||||
if useExtendedCharset {
|
||||
// 使用62字符集(A-Z, a-z, 0-9)用于新格式
|
||||
chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
||||
} else {
|
||||
// 使用36字符集(A-Z, 0-9)用于旧格式
|
||||
chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
}
|
||||
|
||||
checksum := ""
|
||||
for i := 0; i < 4; i++ {
|
||||
checksum += string(chars[sum%len(chars)])
|
||||
sum = sum / len(chars)
|
||||
}
|
||||
return checksum
|
||||
}
|
||||
|
||||
// ActivateLicense 激活许可证
|
||||
func (s *licenseService) ActivateLicense(ctx context.Context, req *models.ActivateRequest, clientIP string) (*models.ActivateResponse, error) {
|
||||
lang := pkgcontext.GetLanguageFromContext(ctx)
|
||||
|
||||
// 参数验证
|
||||
if req == nil {
|
||||
return nil, i18n.NewI18nError("900001", lang)
|
||||
}
|
||||
|
||||
// 验证授权码格式(在查询数据库前先验证格式,避免无效查询)
|
||||
if !validateAuthorizationCodeFormat(req.AuthorizationCode) {
|
||||
return nil, i18n.NewI18nError("300001", lang) // 授权码格式无效
|
||||
}
|
||||
|
||||
// 获取授权码信息
|
||||
authCode, err := s.licenseRepo.GetAuthorizationCodeByCode(ctx, req.AuthorizationCode)
|
||||
if err != nil {
|
||||
if errors.Is(err, repository.ErrAuthorizationCodeNotFound) {
|
||||
return nil, i18n.NewI18nError("300001", lang) // 授权码不存在
|
||||
}
|
||||
return nil, i18n.NewI18nError("900004", lang, err.Error())
|
||||
}
|
||||
|
||||
// 检查授权码状态和有效期
|
||||
now := time.Now()
|
||||
if authCode.IsLocked {
|
||||
return nil, i18n.NewI18nError("300003", lang) // 授权码已被锁定
|
||||
}
|
||||
if now.Before(authCode.StartDate) || now.After(authCode.EndDate) {
|
||||
return nil, i18n.NewI18nError("300001", lang) // 授权码已过期
|
||||
}
|
||||
|
||||
// 使用事务确保并发安全
|
||||
var response *models.ActivateResponse
|
||||
err = s.db.Transaction(func(tx *gorm.DB) error {
|
||||
// 检查当前激活数量
|
||||
count, err := s.licenseRepo.GetActiveLicenseCount(ctx, authCode.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 检查是否已存在相同硬件指纹的许可证
|
||||
var existingLicense models.License
|
||||
err = tx.Where("authorization_code_id = ? AND hardware_fingerprint = ?",
|
||||
authCode.ID, req.HardwareFingerprint).First(&existingLicense).Error
|
||||
|
||||
if err == nil {
|
||||
// 已存在,直接激活
|
||||
existingLicense.Status = "active"
|
||||
existingLicense.ActivationIP = &clientIP
|
||||
now := time.Now()
|
||||
existingLicense.ActivatedAt = &now
|
||||
existingLicense.LastHeartbeat = &now
|
||||
existingLicense.LastOnlineIP = &clientIP
|
||||
|
||||
if err := tx.Save(&existingLicense).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 生成许可证文件
|
||||
licenseFile, err := s.generateLicenseFileContent(&existingLicense, authCode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
response = &models.ActivateResponse{
|
||||
LicenseKey: existingLicense.LicenseKey,
|
||||
LicenseFile: licenseFile,
|
||||
HeartbeatInterval: 300, // 5分钟
|
||||
}
|
||||
return nil
|
||||
} else if err != gorm.ErrRecordNotFound {
|
||||
return err
|
||||
}
|
||||
|
||||
// 检查激活数量限制
|
||||
if count >= int64(authCode.MaxActivations) {
|
||||
return repository.ErrLicenseNotFound // 使用已有错误,表示激活数量已达上限
|
||||
}
|
||||
|
||||
// 生成新的许可证
|
||||
licenseKey, err := s.generateLicenseKey()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
license := &models.License{
|
||||
LicenseKey: licenseKey,
|
||||
AuthorizationCodeID: authCode.ID,
|
||||
CustomerID: authCode.CustomerID,
|
||||
HardwareFingerprint: req.HardwareFingerprint,
|
||||
ActivationIP: &clientIP,
|
||||
Status: "active",
|
||||
ActivatedAt: &now,
|
||||
LastHeartbeat: &now,
|
||||
LastOnlineIP: &clientIP,
|
||||
}
|
||||
|
||||
// 设置设备信息
|
||||
if req.DeviceInfo != nil {
|
||||
deviceInfoBytes, err := json.Marshal(req.DeviceInfo)
|
||||
if err == nil {
|
||||
license.DeviceInfo = models.JSON(deviceInfoBytes)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Create(license).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 生成许可证文件
|
||||
licenseFile, err := s.generateLicenseFileContent(license, authCode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
response = &models.ActivateResponse{
|
||||
LicenseKey: license.LicenseKey,
|
||||
LicenseFile: licenseFile,
|
||||
HeartbeatInterval: 300,
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, repository.ErrLicenseNotFound) {
|
||||
return nil, i18n.NewI18nError("300004", lang) // 激活数量已达上限
|
||||
}
|
||||
return nil, i18n.NewI18nError("900004", lang, err.Error())
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// Heartbeat 心跳检测
|
||||
func (s *licenseService) Heartbeat(ctx context.Context, req *models.HeartbeatRequest, clientIP string) (*models.HeartbeatResponse, error) {
|
||||
lang := pkgcontext.GetLanguageFromContext(ctx)
|
||||
|
||||
// 参数验证
|
||||
if req == nil {
|
||||
return nil, i18n.NewI18nError("900001", lang)
|
||||
}
|
||||
|
||||
// 获取许可证信息
|
||||
license, err := s.licenseRepo.GetLicenseByKey(ctx, req.LicenseKey)
|
||||
if err != nil {
|
||||
if errors.Is(err, repository.ErrLicenseNotFound) {
|
||||
return nil, i18n.NewI18nError("300006", lang) // 许可证不存在
|
||||
}
|
||||
return nil, i18n.NewI18nError("900004", lang, err.Error())
|
||||
}
|
||||
|
||||
// 检查许可证状态
|
||||
if license.Status == "revoked" {
|
||||
return nil, i18n.NewI18nError("300007", lang) // 许可证已被撤销
|
||||
}
|
||||
|
||||
// 更新心跳时间和使用数据
|
||||
now := time.Now()
|
||||
license.LastHeartbeat = &now
|
||||
license.LastOnlineIP = &clientIP
|
||||
|
||||
// 更新使用数据
|
||||
if req.UsageData != nil {
|
||||
usageDataBytes, err := json.Marshal(req.UsageData)
|
||||
if err == nil {
|
||||
license.UsageData = models.JSON(usageDataBytes)
|
||||
}
|
||||
}
|
||||
|
||||
// 检查配置是否有更新
|
||||
configUpdated := false
|
||||
var licenseFile *string
|
||||
|
||||
if req.ConfigUpdatedAt != nil && license.AuthorizationCode != nil {
|
||||
clientConfigTime, err := time.Parse(time.RFC3339, *req.ConfigUpdatedAt)
|
||||
if err == nil && license.AuthorizationCode.UpdatedAt.After(clientConfigTime) {
|
||||
configUpdated = true
|
||||
// 生成新的许可证文件
|
||||
fileContent, err := s.generateLicenseFileContent(license, license.AuthorizationCode)
|
||||
if err == nil {
|
||||
licenseFile = &fileContent
|
||||
license.ConfigUpdatedAt = &now
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 保存更新
|
||||
if err := s.licenseRepo.UpdateLicense(ctx, license); err != nil {
|
||||
return nil, i18n.NewI18nError("900004", lang, err.Error())
|
||||
}
|
||||
|
||||
response := &models.HeartbeatResponse{
|
||||
Status: license.Status,
|
||||
ConfigUpdated: configUpdated,
|
||||
LicenseFile: licenseFile,
|
||||
HeartbeatInterval: 300,
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// generateLicenseFileContent 生成许可证文件内容
|
||||
func (s *licenseService) generateLicenseFileContent(license *models.License, authCode *models.AuthorizationCode) (string, error) {
|
||||
// 构建许可证文件内容
|
||||
licenseFileData := map[string]interface{}{
|
||||
"license_key": license.LicenseKey,
|
||||
"authorization_code_id": license.AuthorizationCodeID,
|
||||
"hardware_fingerprint": license.HardwareFingerprint,
|
||||
"status": license.Status,
|
||||
"activated_at": license.ActivatedAt,
|
||||
"config_updated_at": license.ConfigUpdatedAt,
|
||||
"generated_at": time.Now().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
if authCode != nil {
|
||||
licenseFileData["authorization_code"] = authCode.Code
|
||||
licenseFileData["start_date"] = authCode.StartDate
|
||||
licenseFileData["end_date"] = authCode.EndDate
|
||||
licenseFileData["deployment_type"] = authCode.DeploymentType
|
||||
licenseFileData["max_activations"] = authCode.MaxActivations
|
||||
|
||||
// 包含功能配置等
|
||||
if len(authCode.FeatureConfig) > 0 {
|
||||
var featureConfig map[string]interface{}
|
||||
if err := json.Unmarshal(authCode.FeatureConfig, &featureConfig); err == nil {
|
||||
licenseFileData["feature_config"] = featureConfig
|
||||
}
|
||||
}
|
||||
|
||||
if len(authCode.UsageLimits) > 0 {
|
||||
var usageLimits map[string]interface{}
|
||||
if err := json.Unmarshal(authCode.UsageLimits, &usageLimits); err == nil {
|
||||
licenseFileData["usage_limits"] = usageLimits
|
||||
}
|
||||
}
|
||||
|
||||
if len(authCode.CustomParameters) > 0 {
|
||||
var customParameters map[string]interface{}
|
||||
if err := json.Unmarshal(authCode.CustomParameters, &customParameters); err == nil {
|
||||
licenseFileData["custom_parameters"] = customParameters
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 序列化
|
||||
licenseJSON, err := json.Marshal(licenseFileData)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// 使用RSA数字签名
|
||||
encryptedData, err := s.signLicenseFile(licenseJSON)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(encryptedData), nil
|
||||
}
|
||||
|
||||
// loadRSAPrivateKey 加载RSA私钥(懒加载)
|
||||
func (s *licenseService) loadRSAPrivateKey() (*utils.RSAPrivateKey, error) {
|
||||
if s.rsaPrivateKey != nil {
|
||||
return s.rsaPrivateKey, nil
|
||||
}
|
||||
|
||||
cfg := config.GetConfig()
|
||||
if cfg == nil {
|
||||
return nil, fmt.Errorf("configuration not initialized")
|
||||
}
|
||||
|
||||
privateKeyPath := cfg.License.RSA.PrivateKeyPath
|
||||
if privateKeyPath == "" {
|
||||
return nil, fmt.Errorf("RSA private key path not configured")
|
||||
}
|
||||
|
||||
key, err := utils.LoadRSAPrivateKeyFromFile(privateKeyPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("加载RSA私钥失败: %w", err)
|
||||
}
|
||||
|
||||
s.rsaPrivateKey = key
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// signLicenseFile 使用RSA数字签名许可证文件
|
||||
func (s *licenseService) signLicenseFile(data []byte) ([]byte, error) {
|
||||
// 加载RSA私钥
|
||||
privateKey, err := s.loadRSAPrivateKey()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("加载RSA私钥失败: %w", err)
|
||||
}
|
||||
|
||||
// 对数据进行数字签名
|
||||
signature, err := privateKey.SignData(data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("签名失败: %w", err)
|
||||
}
|
||||
|
||||
// 构建签名后的许可证文件结构
|
||||
licenseWithSignature := map[string]interface{}{
|
||||
"data": string(data), // 原始数据(JSON字符串)
|
||||
"signature": signature, // 数字签名
|
||||
"algorithm": "RSA-PSS-SHA256",
|
||||
}
|
||||
|
||||
// 序列化为JSON
|
||||
licenseJSON, err := json.Marshal(licenseWithSignature)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("序列化失败: %w", err)
|
||||
}
|
||||
|
||||
// 使用base64编码
|
||||
encoded := base64.StdEncoding.EncodeToString(licenseJSON)
|
||||
|
||||
return []byte(encoded), nil
|
||||
}
|
||||
|
||||
// GetStatsOverview 获取授权概览统计
|
||||
func (s *licenseService) GetStatsOverview(ctx context.Context) (*models.StatsOverviewResponse, error) {
|
||||
lang := pkgcontext.GetLanguageFromContext(ctx)
|
||||
|
||||
// 获取配置信息
|
||||
cfg := config.GetConfig()
|
||||
offlineTimeout := time.Duration(cfg.License.HeartbeatTimeout) * time.Second
|
||||
|
||||
// 计算时间点
|
||||
now := time.Now()
|
||||
onlineThreshold := now.Add(-offlineTimeout)
|
||||
lastMonth := now.AddDate(0, -1, 0)
|
||||
|
||||
var stats models.StatsOverviewResponse
|
||||
|
||||
// 执行统计查询
|
||||
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||
// 1. 总授权码数量
|
||||
if err := tx.Model(&models.AuthorizationCode{}).Count(&stats.TotalAuthCodes).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 2. 活跃许可证数量(状态为active)
|
||||
if err := tx.Model(&models.License{}).Where("status = ?", "active").Count(&stats.ActiveLicenses).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 3. 即将过期数量(授权码在30天内过期,且状态为active)
|
||||
expireThreshold := now.AddDate(0, 0, 30)
|
||||
if err := tx.Model(&models.AuthorizationCode{}).
|
||||
Where("end_date <= ? AND end_date > ? AND is_locked = false", expireThreshold, now).
|
||||
Count(&stats.ExpiringSoon).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 4. 异常告警数量(许可证超时未心跳)
|
||||
if err := tx.Model(&models.License{}).
|
||||
Where("status = 'active' AND (last_heartbeat < ? OR last_heartbeat IS NULL)", onlineThreshold).
|
||||
Count(&stats.AbnormalAlerts).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 5. 计算同比上月的授权码和许可证数量(用于计算增长率)
|
||||
var lastMonthAuthCodes, lastMonthActiveLicenses int64
|
||||
|
||||
// 上月同期授权码总数(一个月前同一时刻的累计总数)
|
||||
if err := tx.Model(&models.AuthorizationCode{}).
|
||||
Where("created_at <= ?", lastMonth).
|
||||
Count(&lastMonthAuthCodes).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 上月同期活跃许可证总数(一个月前同一时刻的状态为active的许可证数)
|
||||
if err := tx.Model(&models.License{}).
|
||||
Where("status = ? AND created_at <= ?", "active", lastMonth).
|
||||
Count(&lastMonthActiveLicenses).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 计算增长率(同比上月:当前总数相比一个月前总数的增长率)
|
||||
if lastMonthAuthCodes > 0 {
|
||||
stats.GrowthRate.AuthCodes = float64(stats.TotalAuthCodes-lastMonthAuthCodes) / float64(lastMonthAuthCodes) * 100
|
||||
} else {
|
||||
stats.GrowthRate.AuthCodes = 0
|
||||
}
|
||||
|
||||
if lastMonthActiveLicenses > 0 {
|
||||
stats.GrowthRate.Licenses = float64(stats.ActiveLicenses-lastMonthActiveLicenses) / float64(lastMonthActiveLicenses) * 100
|
||||
} else {
|
||||
stats.GrowthRate.Licenses = 0
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, i18n.NewI18nError("900004", lang, err.Error())
|
||||
}
|
||||
|
||||
return &stats, nil
|
||||
}
|
||||
@@ -23,7 +23,7 @@ CREATE TABLE customers (
|
||||
company_size VARCHAR(20) COMMENT '企业规模: small, medium, large, enterprise',
|
||||
|
||||
-- 客户分级
|
||||
customer_level VARCHAR(20) NOT NULL DEFAULT 'normal' COMMENT '客户等级: normal, vip, enterprise, strategic',
|
||||
customer_level VARCHAR(20) NOT NULL DEFAULT 'basic' COMMENT '客户等级: basic, active, vip, strategic',
|
||||
|
||||
-- 状态管理
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'active' COMMENT '状态: active, disabled',
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
-- 授权码表结构
|
||||
-- 基于授权模块属性设计创建
|
||||
-- MySQL版本
|
||||
|
||||
-- 创建授权码表
|
||||
CREATE TABLE authorization_codes (
|
||||
-- 基础信息属性
|
||||
id VARCHAR(36) PRIMARY KEY DEFAULT (UUID()),
|
||||
code VARCHAR(100) NOT NULL UNIQUE COMMENT '唯一授权码,软件身份凭证',
|
||||
customer_id VARCHAR(36) NOT NULL COMMENT '关联客户ID',
|
||||
created_by VARCHAR(36) NOT NULL COMMENT '创建人ID',
|
||||
software_id VARCHAR(50) COMMENT '目标软件产品ID',
|
||||
description TEXT COMMENT '授权码描述',
|
||||
|
||||
-- 授权核心属性
|
||||
start_date DATETIME(3) NOT NULL COMMENT '授权开始时间',
|
||||
end_date DATETIME(3) NOT NULL COMMENT '授权结束时间',
|
||||
deployment_type VARCHAR(20) NOT NULL DEFAULT 'standalone' COMMENT '部署类型: standalone单机版/cloud云端版/hybrid混合版',
|
||||
encryption_type VARCHAR(20) DEFAULT 'standard' COMMENT '加密类型: standard标准加密/advanced高级加密',
|
||||
software_version VARCHAR(50) COMMENT '支持的软件版本范围',
|
||||
|
||||
-- 激活控制属性
|
||||
max_activations INT NOT NULL DEFAULT 1 COMMENT '最大许可证数量',
|
||||
|
||||
-- 状态管理属性
|
||||
is_locked BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否被锁定',
|
||||
lock_reason TEXT COMMENT '锁定原因,自定义文本输入',
|
||||
locked_at DATETIME(3) NULL COMMENT '锁定时间',
|
||||
locked_by VARCHAR(36) COMMENT '锁定操作人',
|
||||
|
||||
-- 通用功能控制属性
|
||||
feature_config JSON COMMENT '功能配置,软件产品基础功能',
|
||||
usage_limits JSON COMMENT '使用量限制,如用户数、API调用次数等',
|
||||
custom_parameters JSON COMMENT '自定义参数,软件特定功能或用户定制功能',
|
||||
|
||||
-- 时间字段 (使用DATETIME(3)匹配Go的time.Time和GORM的精度要求)
|
||||
created_at DATETIME(3) NOT NULL,
|
||||
updated_at DATETIME(3) NOT NULL,
|
||||
|
||||
-- 外键约束
|
||||
FOREIGN KEY (customer_id) REFERENCES customers(id) ON DELETE RESTRICT
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='授权码表 - 业务配置容器';
|
||||
|
||||
-- 创建索引
|
||||
CREATE INDEX idx_authorization_codes_code ON authorization_codes(code);
|
||||
CREATE INDEX idx_authorization_codes_customer_id ON authorization_codes(customer_id);
|
||||
CREATE INDEX idx_authorization_codes_software_id ON authorization_codes(software_id);
|
||||
CREATE INDEX idx_authorization_codes_deployment_type ON authorization_codes(deployment_type);
|
||||
CREATE INDEX idx_authorization_codes_start_date ON authorization_codes(start_date);
|
||||
CREATE INDEX idx_authorization_codes_end_date ON authorization_codes(end_date);
|
||||
CREATE INDEX idx_authorization_codes_created_by ON authorization_codes(created_by);
|
||||
CREATE INDEX idx_authorization_codes_created_at ON authorization_codes(created_at);
|
||||
CREATE INDEX idx_authorization_codes_is_locked ON authorization_codes(is_locked);
|
||||
CREATE INDEX idx_authorization_codes_locked_at ON authorization_codes(locked_at);
|
||||
|
||||
-- 复合索引用于常见查询
|
||||
CREATE INDEX idx_authorization_codes_customer_status ON authorization_codes(customer_id, start_date, end_date);
|
||||
CREATE INDEX idx_authorization_codes_software_status ON authorization_codes(software_id, deployment_type);
|
||||
|
||||
-- 注意:时间戳由Go应用程序管理,不依赖数据库默认值
|
||||
-- 状态字段(status)是虚字段,通过应用逻辑计算得出:
|
||||
-- normal正常: 当前时间在start_date和end_date之间且is_locked=false
|
||||
-- locked已锁定: is_locked=true
|
||||
-- expired已过期: 当前时间超过end_date
|
||||
@@ -0,0 +1,67 @@
|
||||
-- 许可证表结构
|
||||
-- 基于授权模块属性设计创建
|
||||
-- MySQL版本
|
||||
|
||||
-- 创建许可证表
|
||||
CREATE TABLE licenses (
|
||||
-- 基础信息属性
|
||||
id VARCHAR(36) PRIMARY KEY DEFAULT (UUID()),
|
||||
license_key VARCHAR(200) NOT NULL UNIQUE COMMENT '许可证密钥,设备特定',
|
||||
authorization_code_id VARCHAR(36) NOT NULL COMMENT '关联的授权码ID',
|
||||
customer_id VARCHAR(36) NOT NULL COMMENT '客户ID,冗余字段便于查询',
|
||||
|
||||
-- 硬件绑定属性
|
||||
hardware_fingerprint VARCHAR(200) NOT NULL COMMENT '绑定的硬件指纹',
|
||||
device_info JSON COMMENT '设备信息,包含CPU、内存等',
|
||||
activation_ip VARCHAR(45) COMMENT '激活时的IP地址',
|
||||
|
||||
-- 激活状态属性
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'inactive' COMMENT '许可证状态: active激活/inactive未激活/revoked已撤销',
|
||||
activated_at DATETIME(3) NULL COMMENT '激活时间',
|
||||
last_heartbeat DATETIME(3) NULL COMMENT '最后心跳时间',
|
||||
last_online_ip VARCHAR(45) COMMENT '最后在线IP',
|
||||
|
||||
-- 配置同步属性
|
||||
config_updated_at DATETIME(3) NULL COMMENT '配置更新时间',
|
||||
|
||||
-- 使用统计属性
|
||||
usage_data JSON COMMENT '使用数据,软件上报的统计信息',
|
||||
|
||||
-- 时间字段 (使用DATETIME(3)匹配Go的time.Time和GORM的精度要求)
|
||||
created_at DATETIME(3) NOT NULL,
|
||||
updated_at DATETIME(3) NOT NULL,
|
||||
|
||||
-- 外键约束
|
||||
FOREIGN KEY (authorization_code_id) REFERENCES authorization_codes(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (customer_id) REFERENCES customers(id) ON DELETE RESTRICT,
|
||||
|
||||
-- 唯一约束:同一授权码下的硬件指纹应该唯一
|
||||
UNIQUE KEY uk_licenses_auth_hardware (authorization_code_id, hardware_fingerprint)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='许可证表 - 硬件激活凭证';
|
||||
|
||||
-- 创建索引
|
||||
CREATE INDEX idx_licenses_license_key ON licenses(license_key);
|
||||
CREATE INDEX idx_licenses_authorization_code_id ON licenses(authorization_code_id);
|
||||
CREATE INDEX idx_licenses_customer_id ON licenses(customer_id);
|
||||
CREATE INDEX idx_licenses_hardware_fingerprint ON licenses(hardware_fingerprint);
|
||||
CREATE INDEX idx_licenses_status ON licenses(status);
|
||||
CREATE INDEX idx_licenses_activated_at ON licenses(activated_at);
|
||||
CREATE INDEX idx_licenses_last_heartbeat ON licenses(last_heartbeat);
|
||||
CREATE INDEX idx_licenses_config_updated_at ON licenses(config_updated_at);
|
||||
CREATE INDEX idx_licenses_created_at ON licenses(created_at);
|
||||
|
||||
-- 复合索引用于常见查询
|
||||
CREATE INDEX idx_licenses_customer_status ON licenses(customer_id, status);
|
||||
CREATE INDEX idx_licenses_auth_status ON licenses(authorization_code_id, status);
|
||||
CREATE INDEX idx_licenses_status_activated ON licenses(status, activated_at);
|
||||
CREATE INDEX idx_licenses_heartbeat_status ON licenses(last_heartbeat, status);
|
||||
|
||||
-- 注意:时间戳由Go应用程序管理,不依赖数据库默认值
|
||||
-- 许可证状态说明:
|
||||
-- active激活: 设备已激活并可正常使用
|
||||
-- inactive未激活: 许可证已生成但设备尚未激活
|
||||
-- revoked已撤销: 由于硬件更换、设备丢失或违规使用而被撤销
|
||||
-- 在线状态(is_online)是虚字段,通过last_heartbeat时间计算:
|
||||
-- 在线: last_heartbeat在5分钟内
|
||||
-- 离线: last_heartbeat超过5分钟但在24小时内
|
||||
-- 异常: last_heartbeat超过24小时
|
||||
@@ -0,0 +1,44 @@
|
||||
-- 授权变更历史表结构
|
||||
-- 基于授权模块属性设计创建
|
||||
-- MySQL版本
|
||||
|
||||
-- 创建授权变更历史表
|
||||
CREATE TABLE authorization_changes (
|
||||
-- 基础属性
|
||||
id VARCHAR(36) PRIMARY KEY DEFAULT (UUID()),
|
||||
authorization_code_id VARCHAR(36) NOT NULL COMMENT '授权码ID',
|
||||
change_type VARCHAR(30) NOT NULL COMMENT '变更类型: renewal续费/upgrade升级/limit_change限制调整/feature_toggle功能开关/lock锁定/unlock解锁/other其他',
|
||||
old_config JSON COMMENT '变更前配置',
|
||||
new_config JSON COMMENT '变更后配置',
|
||||
operator_id VARCHAR(36) NOT NULL COMMENT '操作人ID',
|
||||
reason TEXT COMMENT '变更原因',
|
||||
|
||||
-- 时间字段 (使用DATETIME(3)匹配Go的time.Time和GORM的精度要求)
|
||||
created_at DATETIME(3) NOT NULL COMMENT '记录创建时间',
|
||||
|
||||
-- 外键约束 - 修改为级联删除
|
||||
FOREIGN KEY (authorization_code_id) REFERENCES authorization_codes(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='授权变更历史表 - 记录授权码的所有变更操作';
|
||||
|
||||
-- 创建索引
|
||||
CREATE INDEX idx_authorization_changes_authorization_code_id ON authorization_changes(authorization_code_id);
|
||||
CREATE INDEX idx_authorization_changes_change_type ON authorization_changes(change_type);
|
||||
CREATE INDEX idx_authorization_changes_operator_id ON authorization_changes(operator_id);
|
||||
CREATE INDEX idx_authorization_changes_created_at ON authorization_changes(created_at);
|
||||
|
||||
-- 复合索引用于常见查询
|
||||
CREATE INDEX idx_authorization_changes_auth_type ON authorization_changes(authorization_code_id, change_type);
|
||||
CREATE INDEX idx_authorization_changes_auth_time ON authorization_changes(authorization_code_id, created_at);
|
||||
CREATE INDEX idx_authorization_changes_operator_time ON authorization_changes(operator_id, created_at);
|
||||
|
||||
-- 注意:
|
||||
-- 1. 该表只有created_at字段,没有updated_at和deleted_at,因为历史记录不应被修改或删除
|
||||
-- 2. 变更类型包括:
|
||||
-- - renewal: 续费操作
|
||||
-- - upgrade: 升级操作
|
||||
-- - limit_change: 限制调整(如修改max_activations)
|
||||
-- - feature_toggle: 功能开关(修改feature_config)
|
||||
-- - lock: 锁定操作
|
||||
-- - unlock: 解锁操作
|
||||
-- - other: 其他类型的变更
|
||||
-- 3. old_config和new_config存储变更前后的完整配置快照,便于审计和回滚
|
||||
@@ -0,0 +1,48 @@
|
||||
-- 用户表结构
|
||||
-- 基于用户管理模块设计创建
|
||||
-- MySQL版本
|
||||
|
||||
-- 创建用户表
|
||||
CREATE TABLE users (
|
||||
-- 基础信息属性
|
||||
id VARCHAR(36) PRIMARY KEY DEFAULT (UUID()),
|
||||
username VARCHAR(50) NOT NULL UNIQUE COMMENT '用户名,唯一',
|
||||
email VARCHAR(255) NOT NULL UNIQUE COMMENT '邮箱地址,唯一',
|
||||
password_hash VARCHAR(255) NOT NULL COMMENT '密码哈希值',
|
||||
full_name VARCHAR(100) NOT NULL COMMENT '真实姓名',
|
||||
phone VARCHAR(20) COMMENT '手机号码',
|
||||
|
||||
-- 角色权限属性
|
||||
role VARCHAR(20) NOT NULL DEFAULT 'viewer' COMMENT '用户角色: admin管理员/operator操作员/viewer查看者',
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'active' COMMENT '账号状态: active活跃/disabled禁用/locked锁定',
|
||||
|
||||
-- 登录相关属性
|
||||
last_login_at DATETIME(3) NULL COMMENT '最后登录时间',
|
||||
last_login_ip VARCHAR(45) COMMENT '最后登录IP',
|
||||
login_attempts INT NOT NULL DEFAULT 0 COMMENT '登录失败次数',
|
||||
locked_until DATETIME(3) NULL COMMENT '账号锁定到期时间',
|
||||
|
||||
-- 时间字段 (使用DATETIME(3)匹配Go的time.Time和GORM的精度要求)
|
||||
created_at DATETIME(3) NOT NULL,
|
||||
updated_at DATETIME(3) NOT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表 - 系统用户管理';
|
||||
|
||||
-- 创建索引
|
||||
CREATE INDEX idx_users_username ON users(username);
|
||||
CREATE INDEX idx_users_email ON users(email);
|
||||
CREATE INDEX idx_users_role ON users(role);
|
||||
CREATE INDEX idx_users_status ON users(status);
|
||||
CREATE INDEX idx_users_last_login_at ON users(last_login_at);
|
||||
CREATE INDEX idx_users_created_at ON users(created_at);
|
||||
CREATE INDEX idx_users_locked_until ON users(locked_until);
|
||||
|
||||
-- 复合索引用于常见查询
|
||||
CREATE INDEX idx_users_role_status ON users(role, status);
|
||||
CREATE INDEX idx_users_status_locked ON users(status, locked_until);
|
||||
|
||||
-- 注意:
|
||||
-- 1. 时间戳由Go应用程序管理,不依赖数据库默认值
|
||||
-- 2. password_hash使用bcrypt算法,成本因子建议设置为12
|
||||
-- 3. 账号锁定机制:连续5次登录失败锁定30分钟
|
||||
-- 4. 角色权限通过中间件在应用层控制
|
||||
-- 5. 默认管理员账号需要在应用初始化时创建
|
||||
@@ -0,0 +1,162 @@
|
||||
package context
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// 定义Context键的类型,避免键冲突
|
||||
type contextKey string
|
||||
|
||||
const (
|
||||
// LanguageKey 存储语言信息的上下文键
|
||||
LanguageKey contextKey = "language"
|
||||
// RequestIDKey 存储请求ID的上下文键
|
||||
RequestIDKey contextKey = "request_id"
|
||||
// UserIDKey 存储用户ID的上下文键
|
||||
UserIDKey contextKey = "user_id"
|
||||
// TraceIDKey 存储链路追踪ID的上下文键
|
||||
TraceIDKey contextKey = "trace_id"
|
||||
)
|
||||
|
||||
// DefaultLanguage 默认语言
|
||||
const DefaultLanguage = "zh-CN"
|
||||
|
||||
// WithLanguage 将语言信息添加到Context中
|
||||
func WithLanguage(ctx context.Context, lang string) context.Context {
|
||||
if lang == "" {
|
||||
lang = DefaultLanguage
|
||||
}
|
||||
return context.WithValue(ctx, LanguageKey, lang)
|
||||
}
|
||||
|
||||
// GetLanguageFromContext 从Context中获取语言信息
|
||||
func GetLanguageFromContext(ctx context.Context) string {
|
||||
if lang, ok := ctx.Value(LanguageKey).(string); ok && lang != "" {
|
||||
return lang
|
||||
}
|
||||
return DefaultLanguage
|
||||
}
|
||||
|
||||
// WithRequestID 将请求ID添加到Context中
|
||||
func WithRequestID(ctx context.Context, requestID string) context.Context {
|
||||
return context.WithValue(ctx, RequestIDKey, requestID)
|
||||
}
|
||||
|
||||
// GetRequestIDFromContext 从Context中获取请求ID
|
||||
func GetRequestIDFromContext(ctx context.Context) string {
|
||||
if requestID, ok := ctx.Value(RequestIDKey).(string); ok {
|
||||
return requestID
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// WithUserID 将用户ID添加到Context中
|
||||
func WithUserID(ctx context.Context, userID string) context.Context {
|
||||
return context.WithValue(ctx, UserIDKey, userID)
|
||||
}
|
||||
|
||||
// GetUserIDFromContext 从context中获取当前用户ID
|
||||
func GetUserIDFromContext(ctx context.Context) string {
|
||||
// 尝试从gin.Context获取
|
||||
if ginCtx, ok := ctx.(*gin.Context); ok {
|
||||
if userID, exists := ginCtx.Get("user_id"); exists {
|
||||
if id, ok := userID.(string); ok {
|
||||
return id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试从标准context.Context获取
|
||||
if userID := ctx.Value("user_id"); userID != nil {
|
||||
if id, ok := userID.(string); ok {
|
||||
return id
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetUsernameFromContext 从context中获取当前用户名
|
||||
func GetUsernameFromContext(ctx context.Context) string {
|
||||
if ginCtx, ok := ctx.(*gin.Context); ok {
|
||||
if username, exists := ginCtx.Get("username"); exists {
|
||||
if name, ok := username.(string); ok {
|
||||
return name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if username := ctx.Value("username"); username != nil {
|
||||
if name, ok := username.(string); ok {
|
||||
return name
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetUserRoleFromContext 从context中获取当前用户角色
|
||||
func GetUserRoleFromContext(ctx context.Context) string {
|
||||
if ginCtx, ok := ctx.(*gin.Context); ok {
|
||||
if role, exists := ginCtx.Get("role"); exists {
|
||||
if r, ok := role.(string); ok {
|
||||
return r
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if role := ctx.Value("role"); role != nil {
|
||||
if r, ok := role.(string); ok {
|
||||
return r
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// WithTraceID 将链路追踪ID添加到Context中
|
||||
func WithTraceID(ctx context.Context, traceID string) context.Context {
|
||||
return context.WithValue(ctx, TraceIDKey, traceID)
|
||||
}
|
||||
|
||||
// GetTraceIDFromContext 从Context中获取链路追踪ID
|
||||
func GetTraceIDFromContext(ctx context.Context) string {
|
||||
if traceID, ok := ctx.Value(TraceIDKey).(string); ok {
|
||||
return traceID
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ContextInfo 上下文信息结构体
|
||||
type ContextInfo struct {
|
||||
Language string
|
||||
RequestID string
|
||||
UserID string
|
||||
TraceID string
|
||||
}
|
||||
|
||||
// GetContextInfo 获取Context中的所有信息
|
||||
func GetContextInfo(ctx context.Context) *ContextInfo {
|
||||
return &ContextInfo{
|
||||
Language: GetLanguageFromContext(ctx),
|
||||
RequestID: GetRequestIDFromContext(ctx),
|
||||
UserID: GetUserIDFromContext(ctx),
|
||||
TraceID: GetTraceIDFromContext(ctx),
|
||||
}
|
||||
}
|
||||
|
||||
// WithContextInfo 将ContextInfo添加到Context中
|
||||
func WithContextInfo(ctx context.Context, info *ContextInfo) context.Context {
|
||||
if info == nil {
|
||||
return ctx
|
||||
}
|
||||
|
||||
ctx = WithLanguage(ctx, info.Language)
|
||||
ctx = WithRequestID(ctx, info.RequestID)
|
||||
ctx = WithUserID(ctx, info.UserID)
|
||||
ctx = WithTraceID(ctx, info.TraceID)
|
||||
|
||||
return ctx
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
package errors
|
||||
|
||||
// HTTP 状态码常量
|
||||
const (
|
||||
StatusOK = 200
|
||||
StatusBadRequest = 400
|
||||
StatusUnauthorized = 401
|
||||
StatusForbidden = 403
|
||||
StatusNotFound = 404
|
||||
StatusConflict = 409
|
||||
StatusInternalServerError = 500
|
||||
)
|
||||
|
||||
// ErrorMessages 业务错误码到错误信息的映射
|
||||
// 六位数字格式:前2位模块码 + 后4位错误号
|
||||
var ErrorMessages = map[string]string{
|
||||
"000000": "成功",
|
||||
|
||||
// 10xxxx - 认证模块
|
||||
"100001": "认证已过期",
|
||||
"100002": "认证无效",
|
||||
"100003": "用户名或密码错误",
|
||||
"100004": "缺少认证信息",
|
||||
"100005": "权限不足",
|
||||
|
||||
// 90xxxx - 系统通用错误
|
||||
"900001": "请求参数无效",
|
||||
"900002": "资源不存在",
|
||||
"900003": "资源冲突",
|
||||
"900004": "服务器内部错误",
|
||||
}
|
||||
|
||||
// GetErrorMessage 获取错误码对应的错误信息
|
||||
func GetErrorMessage(code string) string {
|
||||
if msg, ok := ErrorMessages[code]; ok {
|
||||
return msg
|
||||
}
|
||||
return "未知错误"
|
||||
}
|
||||
|
||||
// NewErrorResponse 创建标准错误响应,返回 HTTP 状态码、错误码、错误消息
|
||||
// 大部分业务错误返回 200,只有协议级错误才返回对应 HTTP 状态码
|
||||
func NewErrorResponse(code string, customMessage ...string) (int, string, string) {
|
||||
httpStatus := getHTTPStatusByCode(code)
|
||||
message := GetErrorMessage(code)
|
||||
|
||||
if len(customMessage) > 0 && customMessage[0] != "" {
|
||||
message = customMessage[0]
|
||||
}
|
||||
|
||||
return httpStatus, code, message
|
||||
}
|
||||
|
||||
// getHTTPStatusByCode 根据业务错误码获取对应的 HTTP 状态码
|
||||
func getHTTPStatusByCode(code string) int {
|
||||
switch code {
|
||||
case "000000": // 成功
|
||||
return StatusOK
|
||||
case "900001": // 请求参数无效
|
||||
return StatusBadRequest
|
||||
case "100001", "100002", "100003", "100004": // 认证相关错误
|
||||
return StatusUnauthorized
|
||||
case "100005": // 权限不足
|
||||
return StatusForbidden
|
||||
case "900002": // 资源不存在
|
||||
return StatusNotFound
|
||||
case "900003": // 资源冲突
|
||||
return StatusConflict
|
||||
case "900004": // 服务器内部错误
|
||||
return StatusInternalServerError
|
||||
default:
|
||||
return StatusInternalServerError
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,7 @@ func getDefaultErrorMessage(code string) string {
|
||||
"200002": "Customer already exists",
|
||||
"200003": "Customer name is required",
|
||||
"200004": "Invalid customer information format",
|
||||
"200005": "Failed to query customer list",
|
||||
"300001": "Invalid license key",
|
||||
"300002": "License expired",
|
||||
"300003": "License key is required",
|
||||
@@ -107,10 +108,12 @@ func getHTTPStatusByCode(code string) int {
|
||||
return StatusForbidden
|
||||
case "900002", "200001", "300001": // 资源不存在
|
||||
return StatusNotFound
|
||||
case "900003", "200002", "300004": // 资源冲突
|
||||
case "900003", "200002", "200006", "300004": // 资源冲突
|
||||
return StatusConflict
|
||||
case "900004": // 服务器内部错误
|
||||
return StatusInternalServerError
|
||||
case "200005": // 客户列表查询失败 - 根据设计理念返回200,通过响应体区分
|
||||
return StatusOK
|
||||
default:
|
||||
// 根据错误码前缀判断
|
||||
if len(code) >= 2 {
|
||||
@@ -118,10 +121,10 @@ func getHTTPStatusByCode(code string) int {
|
||||
switch prefix {
|
||||
case "10": // 认证模块错误,默认401
|
||||
return StatusUnauthorized
|
||||
case "20": // 客户模块错误,默认404
|
||||
return StatusNotFound
|
||||
case "30": // 授权模块错误,默认404
|
||||
return StatusNotFound
|
||||
case "20": // 客户模块错误,默认200(业务错误通过响应体区分)
|
||||
return StatusOK
|
||||
case "30": // 授权模块错误,默认200(业务错误通过响应体区分)
|
||||
return StatusOK
|
||||
case "90": // 系统错误,默认500
|
||||
return StatusInternalServerError
|
||||
}
|
||||
|
||||
@@ -20,8 +20,10 @@ type SystemInfo struct {
|
||||
type LanguageData struct {
|
||||
System SystemInfo `yaml:"system"`
|
||||
Errors map[string]interface{} `yaml:"errors"` // 灵活结构:支持直接字符串和嵌套对象
|
||||
Enums map[string]interface{} `yaml:"enums"` // 枚举值多语言映射
|
||||
DefaultError string `yaml:"default_error"`
|
||||
flatErrors map[string]string // 扁平化的错误码映射(内部使用)
|
||||
flatEnums map[string]string // 扁平化的枚举值映射(内部使用)
|
||||
}
|
||||
|
||||
// Manager 多语言管理器
|
||||
@@ -36,6 +38,9 @@ type Manager struct {
|
||||
type I18nManager interface {
|
||||
LoadLanguage(lang string) error
|
||||
GetErrorMessage(code, lang string) string
|
||||
GetEnumMessage(enumType, enumValue, lang string) string
|
||||
GetAllEnums(lang string) map[string]map[string]string
|
||||
GetEnumsByType(enumType, lang string) map[string]string
|
||||
SupportedLanguages() []string
|
||||
SetDefaultLanguage(lang string)
|
||||
IsLanguageSupported(lang string) bool
|
||||
@@ -77,6 +82,7 @@ func (m *Manager) LoadLanguage(lang string) error {
|
||||
|
||||
// 扁平化错误码映射,便于快速查找
|
||||
langData.flatErrors = make(map[string]string)
|
||||
langData.flatEnums = make(map[string]string)
|
||||
|
||||
// 处理错误码映射
|
||||
for key, value := range langData.Errors {
|
||||
@@ -96,6 +102,21 @@ func (m *Manager) LoadLanguage(lang string) error {
|
||||
}
|
||||
}
|
||||
|
||||
// 处理枚举值映射
|
||||
for enumType, enumData := range langData.Enums {
|
||||
if enumMap, ok := enumData.(map[interface{}]interface{}); ok {
|
||||
for enumKey, enumValue := range enumMap {
|
||||
if key, ok := enumKey.(string); ok {
|
||||
if value, ok := enumValue.(string); ok {
|
||||
// 使用 "enumType.enumKey" 作为扁平化的键
|
||||
flatKey := fmt.Sprintf("%s.%s", enumType, key)
|
||||
langData.flatEnums[flatKey] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 存储加载的语言数据
|
||||
m.loadedLangs[lang] = &langData
|
||||
|
||||
@@ -135,6 +156,123 @@ func (m *Manager) GetErrorMessage(code, lang string) string {
|
||||
return "Unknown error"
|
||||
}
|
||||
|
||||
// GetEnumMessage 获取指定语言的枚举值显示文本
|
||||
func (m *Manager) GetEnumMessage(enumType, enumValue, lang string) string {
|
||||
m.mutex.RLock()
|
||||
defer m.mutex.RUnlock()
|
||||
|
||||
// 构建查找键
|
||||
flatKey := fmt.Sprintf("%s.%s", enumType, enumValue)
|
||||
|
||||
// 首先尝试获取请求语言的枚举值
|
||||
if langData, exists := m.loadedLangs[lang]; exists {
|
||||
if message, found := langData.flatEnums[flatKey]; found {
|
||||
return message
|
||||
}
|
||||
}
|
||||
|
||||
// 如果请求语言不存在,降级到默认语言
|
||||
if lang != m.defaultLang {
|
||||
if langData, exists := m.loadedLangs[m.defaultLang]; exists {
|
||||
if message, found := langData.flatEnums[flatKey]; found {
|
||||
return message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 最终降级:返回原始枚举值
|
||||
return enumValue
|
||||
}
|
||||
|
||||
// GetAllEnums 获取指定语言的所有枚举值
|
||||
func (m *Manager) GetAllEnums(lang string) map[string]map[string]string {
|
||||
m.mutex.RLock()
|
||||
defer m.mutex.RUnlock()
|
||||
|
||||
result := make(map[string]map[string]string)
|
||||
|
||||
// 首先尝试获取请求语言的枚举值
|
||||
if langData, exists := m.loadedLangs[lang]; exists {
|
||||
result = m.buildEnumMap(langData)
|
||||
if len(result) > 0 {
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
// 如果请求语言不存在,降级到默认语言
|
||||
if lang != m.defaultLang {
|
||||
if langData, exists := m.loadedLangs[m.defaultLang]; exists {
|
||||
result = m.buildEnumMap(langData)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// GetEnumsByType 获取指定类型的枚举值
|
||||
func (m *Manager) GetEnumsByType(enumType, lang string) map[string]string {
|
||||
m.mutex.RLock()
|
||||
defer m.mutex.RUnlock()
|
||||
|
||||
// 首先尝试获取请求语言的枚举值
|
||||
if langData, exists := m.loadedLangs[lang]; exists {
|
||||
if enumMap := m.getEnumMapByType(langData, enumType); len(enumMap) > 0 {
|
||||
return enumMap
|
||||
}
|
||||
}
|
||||
|
||||
// 如果请求语言不存在,降级到默认语言
|
||||
if lang != m.defaultLang {
|
||||
if langData, exists := m.loadedLangs[m.defaultLang]; exists {
|
||||
return m.getEnumMapByType(langData, enumType)
|
||||
}
|
||||
}
|
||||
|
||||
return make(map[string]string)
|
||||
}
|
||||
|
||||
// buildEnumMap 构建完整的枚举映射
|
||||
func (m *Manager) buildEnumMap(langData *LanguageData) map[string]map[string]string {
|
||||
result := make(map[string]map[string]string)
|
||||
|
||||
for enumType, enumData := range langData.Enums {
|
||||
if enumMap, ok := enumData.(map[interface{}]interface{}); ok {
|
||||
typeMap := make(map[string]string)
|
||||
for enumKey, enumValue := range enumMap {
|
||||
if key, ok := enumKey.(string); ok {
|
||||
if value, ok := enumValue.(string); ok {
|
||||
typeMap[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(typeMap) > 0 {
|
||||
result[enumType] = typeMap
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// getEnumMapByType 获取指定类型的枚举映射
|
||||
func (m *Manager) getEnumMapByType(langData *LanguageData, enumType string) map[string]string {
|
||||
result := make(map[string]string)
|
||||
|
||||
if enumData, exists := langData.Enums[enumType]; exists {
|
||||
if enumMap, ok := enumData.(map[interface{}]interface{}); ok {
|
||||
for enumKey, enumValue := range enumMap {
|
||||
if key, ok := enumKey.(string); ok {
|
||||
if value, ok := enumValue.(string); ok {
|
||||
result[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// SupportedLanguages 返回已加载的语言列表
|
||||
func (m *Manager) SupportedLanguages() []string {
|
||||
m.mutex.RLock()
|
||||
@@ -233,3 +371,27 @@ func LoadLanguage(lang string) error {
|
||||
}
|
||||
return globalManager.LoadLanguage(lang)
|
||||
}
|
||||
|
||||
// GetEnumMessage 全局函数:获取枚举值显示文本
|
||||
func GetEnumMessage(enumType, enumValue, lang string) string {
|
||||
if globalManager == nil {
|
||||
return enumValue
|
||||
}
|
||||
return globalManager.GetEnumMessage(enumType, enumValue, lang)
|
||||
}
|
||||
|
||||
// GetAllEnums 全局函数:获取所有枚举值
|
||||
func GetAllEnums(lang string) map[string]map[string]string {
|
||||
if globalManager == nil {
|
||||
return make(map[string]map[string]string)
|
||||
}
|
||||
return globalManager.GetAllEnums(lang)
|
||||
}
|
||||
|
||||
// GetEnumsByType 全局函数:获取指定类型的枚举值
|
||||
func GetEnumsByType(enumType, lang string) map[string]string {
|
||||
if globalManager == nil {
|
||||
return make(map[string]string)
|
||||
}
|
||||
return globalManager.GetEnumsByType(enumType, lang)
|
||||
}
|
||||
+179
-1
@@ -1,6 +1,17 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
@@ -14,4 +25,171 @@ func HashPassword(password string) (string, error) {
|
||||
func CheckPasswordHash(password, hash string) bool {
|
||||
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
||||
return err == nil
|
||||
}
|
||||
}
|
||||
|
||||
// RSAPrivateKey RSA私钥结构
|
||||
type RSAPrivateKey struct {
|
||||
*rsa.PrivateKey
|
||||
}
|
||||
|
||||
// RSAPublicKey RSA公钥结构
|
||||
type RSAPublicKey struct {
|
||||
*rsa.PublicKey
|
||||
}
|
||||
|
||||
// LoadRSAPrivateKeyFromFile 从文件加载RSA私钥
|
||||
func LoadRSAPrivateKeyFromFile(filePath string) (*RSAPrivateKey, error) {
|
||||
data, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取私钥文件失败: %w", err)
|
||||
}
|
||||
|
||||
block, _ := pem.Decode(data)
|
||||
if block == nil {
|
||||
return nil, errors.New("无法解析PEM格式的私钥")
|
||||
}
|
||||
|
||||
var key *rsa.PrivateKey
|
||||
var errParse error
|
||||
|
||||
// 尝试PKCS1格式
|
||||
key, errParse = x509.ParsePKCS1PrivateKey(block.Bytes)
|
||||
if errParse != nil {
|
||||
// 尝试PKCS8格式
|
||||
parsedKey, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解析私钥失败: %w", err)
|
||||
}
|
||||
var ok bool
|
||||
key, ok = parsedKey.(*rsa.PrivateKey)
|
||||
if !ok {
|
||||
return nil, errors.New("不是有效的RSA私钥")
|
||||
}
|
||||
}
|
||||
|
||||
return &RSAPrivateKey{PrivateKey: key}, nil
|
||||
}
|
||||
|
||||
// LoadRSAPublicKeyFromFile 从文件加载RSA公钥
|
||||
func LoadRSAPublicKeyFromFile(filePath string) (*RSAPublicKey, error) {
|
||||
data, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取公钥文件失败: %w", err)
|
||||
}
|
||||
|
||||
block, _ := pem.Decode(data)
|
||||
if block == nil {
|
||||
return nil, errors.New("无法解析PEM格式的公钥")
|
||||
}
|
||||
|
||||
pub, err := x509.ParsePKIXPublicKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解析公钥失败: %w", err)
|
||||
}
|
||||
|
||||
rsaPub, ok := pub.(*rsa.PublicKey)
|
||||
if !ok {
|
||||
return nil, errors.New("不是有效的RSA公钥")
|
||||
}
|
||||
|
||||
return &RSAPublicKey{PublicKey: rsaPub}, nil
|
||||
}
|
||||
|
||||
// LoadRSAPublicKeyFromString 从字符串加载RSA公钥(支持PEM格式字符串)
|
||||
func LoadRSAPublicKeyFromString(pubKeyStr string) (*RSAPublicKey, error) {
|
||||
block, _ := pem.Decode([]byte(pubKeyStr))
|
||||
if block == nil {
|
||||
return nil, errors.New("无法解析PEM格式的公钥")
|
||||
}
|
||||
|
||||
pub, err := x509.ParsePKIXPublicKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解析公钥失败: %w", err)
|
||||
}
|
||||
|
||||
rsaPub, ok := pub.(*rsa.PublicKey)
|
||||
if !ok {
|
||||
return nil, errors.New("不是有效的RSA公钥")
|
||||
}
|
||||
|
||||
return &RSAPublicKey{PublicKey: rsaPub}, nil
|
||||
}
|
||||
|
||||
// SignData 使用RSA私钥对数据进行数字签名
|
||||
func (key *RSAPrivateKey) SignData(data []byte) (string, error) {
|
||||
// 计算数据的SHA256哈希
|
||||
hashed := sha256.Sum256(data)
|
||||
|
||||
// 使用PSS模式进行签名(更安全)
|
||||
signature, err := rsa.SignPSS(rand.Reader, key.PrivateKey, crypto.SHA256, hashed[:], &rsa.PSSOptions{
|
||||
SaltLength: rsa.PSSSaltLengthEqualsHash,
|
||||
Hash: crypto.SHA256,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("签名失败: %w", err)
|
||||
}
|
||||
|
||||
// 返回base64编码的签名
|
||||
return base64.StdEncoding.EncodeToString(signature), nil
|
||||
}
|
||||
|
||||
// VerifySignature 使用RSA公钥验证数字签名
|
||||
func (key *RSAPublicKey) VerifySignature(data []byte, signatureBase64 string) error {
|
||||
// 解码base64签名
|
||||
signature, err := base64.StdEncoding.DecodeString(signatureBase64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("解码签名失败: %w", err)
|
||||
}
|
||||
|
||||
// 计算数据的SHA256哈希
|
||||
hashed := sha256.Sum256(data)
|
||||
|
||||
// 验证签名
|
||||
err = rsa.VerifyPSS(key.PublicKey, crypto.SHA256, hashed[:], signature, &rsa.PSSOptions{
|
||||
SaltLength: rsa.PSSSaltLengthEqualsHash,
|
||||
Hash: crypto.SHA256,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("签名验证失败: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GenerateRSAKeyPair 生成RSA密钥对并保存到文件
|
||||
func GenerateRSAKeyPair(privateKeyPath, publicKeyPath string, keySize int) error {
|
||||
if keySize < 2048 {
|
||||
keySize = 2048 // 最小2048位
|
||||
}
|
||||
|
||||
// 生成密钥对
|
||||
privateKey, err := rsa.GenerateKey(rand.Reader, keySize)
|
||||
if err != nil {
|
||||
return fmt.Errorf("生成RSA密钥对失败: %w", err)
|
||||
}
|
||||
|
||||
// 保存私钥(PKCS1格式)
|
||||
privateKeyPEM := pem.EncodeToMemory(&pem.Block{
|
||||
Type: "RSA PRIVATE KEY",
|
||||
Bytes: x509.MarshalPKCS1PrivateKey(privateKey),
|
||||
})
|
||||
if err := os.WriteFile(privateKeyPath, privateKeyPEM, 0600); err != nil {
|
||||
return fmt.Errorf("保存私钥失败: %w", err)
|
||||
}
|
||||
|
||||
// 保存公钥
|
||||
publicKeyBytes, err := x509.MarshalPKIXPublicKey(&privateKey.PublicKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("序列化公钥失败: %w", err)
|
||||
}
|
||||
|
||||
publicKeyPEM := pem.EncodeToMemory(&pem.Block{
|
||||
Type: "PUBLIC KEY",
|
||||
Bytes: publicKeyBytes,
|
||||
})
|
||||
if err := os.WriteFile(publicKeyPath, publicKeyPEM, 0644); err != nil {
|
||||
return fmt.Errorf("保存公钥失败: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
)
|
||||
|
||||
type Claims struct {
|
||||
UserID int `json:"user_id"`
|
||||
UserID string `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
jwt.RegisteredClaims
|
||||
@@ -23,7 +23,7 @@ var (
|
||||
)
|
||||
|
||||
// GenerateToken 生成JWT Token
|
||||
func GenerateToken(userID int, username, role string) (string, error) {
|
||||
func GenerateToken(userID string, username, role string) (string, error) {
|
||||
cfg := config.GetConfig()
|
||||
if cfg == nil {
|
||||
return "", errors.New("config not initialized")
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# MySQL 8 database - production optimized configuration
|
||||
mysql:
|
||||
image: mysql:8.0
|
||||
container_name: license-manager-mysql-git
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: root@123
|
||||
MYSQL_DATABASE: license_manager
|
||||
MYSQL_USER: license_user
|
||||
MYSQL_PASSWORD: license@123
|
||||
TZ: Asia/Shanghai
|
||||
ports:
|
||||
- "13306:3306"
|
||||
volumes:
|
||||
- mysql_data:/var/lib/mysql
|
||||
healthcheck:
|
||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 60s
|
||||
networks:
|
||||
- license-manager-network
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
# Backend service - using GitHub registry image
|
||||
backend:
|
||||
image: ghcr.io/cedar-v/license-manager-backend:v0.1.0
|
||||
container_name: license-manager-backend-git
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- TZ=Asia/Shanghai
|
||||
- GIN_MODE=release
|
||||
ports:
|
||||
- "18888:18888"
|
||||
volumes:
|
||||
- ./backend-config:/app/backend/configs:ro
|
||||
- ./logs:/app/logs
|
||||
- ./data:/app/data
|
||||
depends_on:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -fsS http://localhost:18888/health || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 10s
|
||||
retries: 6
|
||||
start_period: 60s
|
||||
networks:
|
||||
- license-manager-network
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512M
|
||||
reservations:
|
||||
memory: 256M
|
||||
|
||||
# Frontend service - using GitHub registry image
|
||||
frontend:
|
||||
image: ghcr.io/cedar-v/license-manager-frontend:v0.1.0
|
||||
container_name: license-manager-frontend-git
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- TZ=Asia/Shanghai
|
||||
ports:
|
||||
- "18080:80"
|
||||
volumes:
|
||||
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -fsS http://localhost/ || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
networks:
|
||||
- license-manager-network
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 256M
|
||||
reservations:
|
||||
memory: 128M
|
||||
|
||||
volumes:
|
||||
mysql_data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
license-manager-network:
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.20.0.0/16
|
||||
File renamed without changes.
@@ -0,0 +1,67 @@
|
||||
# GitHub 镜像快速部署指南
|
||||
|
||||
使用预构建的 GitHub Container Registry 镜像快速部署许可证管理系统。
|
||||
|
||||
## 快速部署
|
||||
|
||||
### 1. 获取部署文件
|
||||
|
||||
复制项目根目录的 `docker-compose.github.image.yml` 文件到你的部署目录:
|
||||
|
||||
```bash
|
||||
# 方式一:直接下载
|
||||
curl -O https://raw.githubusercontent.com/cedar-v/license-manager/main/docker-compose.github.image.yml
|
||||
|
||||
# 方式二:从项目中复制
|
||||
cp docker-compose.github.image.yml /your/deploy/path/
|
||||
```
|
||||
|
||||
### 2. 提取配置文件
|
||||
|
||||
```bash
|
||||
# 提取后端配置文件
|
||||
mkdir -p backend-config
|
||||
docker run --rm -v $(pwd)/backend-config:/tmp/config ghcr.io/cedar-v/license-manager-backend:v0.1.0 sh -c "cp -r /app/backend/configs/* /tmp/config/"
|
||||
|
||||
# 提取前端 nginx 配置文件
|
||||
docker run --rm -v $(pwd):/tmp/extract ghcr.io/cedar-v/license-manager-frontend:v0.1.0 sh -c "cp /etc/nginx/conf.d/default.conf /tmp/extract/nginx.conf"
|
||||
```
|
||||
|
||||
### 3. 启动服务
|
||||
|
||||
```bash
|
||||
docker-compose -f docker-compose.github.image.yml up
|
||||
```
|
||||
|
||||
## 访问信息
|
||||
|
||||
- **前端**: http://localhost:18080
|
||||
- **后端 API**: http://localhost:18888
|
||||
- **默认账号**: admin / admin@123
|
||||
|
||||
## 常用命令
|
||||
|
||||
```bash
|
||||
# 查看服务状态
|
||||
docker-compose -f docker-compose.github.image.yml ps
|
||||
|
||||
# 查看日志
|
||||
docker-compose -f docker-compose.github.image.yml logs -f
|
||||
|
||||
# 停止服务
|
||||
docker-compose -f docker-compose.github.image.yml down
|
||||
|
||||
# 重启服务
|
||||
docker-compose -f docker-compose.github.image.yml restart
|
||||
|
||||
# 完全清理(包括数据)
|
||||
docker-compose -f docker-compose.github.image.yml down -v
|
||||
```
|
||||
|
||||
## 配置修改
|
||||
|
||||
如需修改后端配置,编辑 `backend-config/config.prod.yaml` 文件,然后重启后端服务:
|
||||
|
||||
```bash
|
||||
docker-compose -f docker-compose.github.image.yml restart backend
|
||||
```
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 145 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 267 KiB After Width: | Height: | Size: 395 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 417 KiB |
@@ -0,0 +1,51 @@
|
||||
# Release v1.0.0
|
||||
|
||||
**发布日期**: 2025-11-19
|
||||
**版本类型**: 第一个正式版本
|
||||
|
||||
---
|
||||
|
||||
## 🎉 首个框架版本发布
|
||||
|
||||
这是License Manager项目的第一个正式版本,包含了基础的系统框架和核心功能。
|
||||
本版本聚焦“全生命周期 License 管理”场景,兼顾 SaaS 交付与离线部署,
|
||||
帮助企业搭建统一的 License 中台体系。
|
||||
|
||||
## ✨ 本版本新增
|
||||
- 客户管理模块。
|
||||
- 授权管理模块:支持授权码生成、设置有效时间、设置最大激活数量、设置部署类型、设置加密类型等。
|
||||
- 许可证管理模块:集中管理所有 License,支持离线手动生成。
|
||||
- 跨平台工具模块:支持 Windows / Linux / macOS 快速集成。
|
||||
|
||||
## 📦 快速体验
|
||||
|
||||
查看官方文档:https://docs.lm.cedar-v.com/guide/getting-started.html
|
||||
- 生产部署:提供 Docker Compose,详见部署指南。
|
||||
|
||||
## 🔮 下一步计划
|
||||
|
||||
### v1.0.1
|
||||
- [ ] 完善导航栏和菜单栏
|
||||
- [ ] 完善对接系统文档
|
||||
- [ ] 开发变更授权功能
|
||||
- [ ] 开发延期授权功能
|
||||
- [ ] 根据社区反馈完善功能优化交互
|
||||
|
||||
## 🤝 贡献
|
||||
|
||||
欢迎提交Issue和Pull Request!
|
||||
- 鼓励补充文档、修复缺陷、扩展模块或插件。
|
||||
- 在 Issue 模板中补充使用场景,可帮助我们更快定位问题。
|
||||
|
||||
## 📄 许可证
|
||||
|
||||
本项目采用GPL-3.0开源许可证。
|
||||
|
||||
---
|
||||
|
||||
**项目地址**: https://github.com/cedar-v/license-manager
|
||||
**问题反馈**: https://github.com/cedar-v/license-manager/issues
|
||||
|
||||
---
|
||||
|
||||
企业版支持或定制需求,请联系QQ群管理员。
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"WebFetch(domain:www.figma.com)",
|
||||
"Bash(git checkout:*)",
|
||||
"Read(//mnt/d/work/license-manager/backend/internal/models/**)",
|
||||
"Bash(npm run build:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,15 @@
|
||||
# @Author: 13895237362 2205451508@qq.com
|
||||
# @Date: 2025-07-31 14:30:20
|
||||
# @LastEditors: 13895237362 2205451508@qq.com
|
||||
# @LastEditTime: 2025-08-08 09:18:54
|
||||
# @LastEditTime: 2025-09-24 16:46:13
|
||||
# @FilePath: /frontend/.env.development
|
||||
# @Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE
|
||||
###
|
||||
# 开发环境接口地址
|
||||
VITE_API_BASE_URL = 'http://104.156.140.42:18888'
|
||||
# VITE_API_BASE_URL = 'http://localhost:18888'
|
||||
VITE_API_BASE_URL = 'http://111.113.214.109:18888'
|
||||
# VITE_API_BASE_URL = 'http://lm.cedar-v.com:18080'
|
||||
|
||||
|
||||
# # 开发环境打包名称
|
||||
# VITE_OUTDIR='dev'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# 正式环境接口地址
|
||||
VITE_API_BASE_URL = 'http://104.156.140.42:18888'
|
||||
# 正式环境接口地址 - 自动匹配当前域名
|
||||
VITE_API_BASE_URL = ''
|
||||
|
||||
# 正式环境打包名称
|
||||
VITE_OUTDIR='production'
|
||||
|
||||
@@ -24,7 +24,7 @@ This project is based on Vue 3, TypeScript, and Vite, integrating mainstream fro
|
||||
- **State Management**: Integrated vuex
|
||||
- **UI Framework**: Supports Element Plus
|
||||
- **Internationalization**: Integrated vue-i18n, supports multi-language switching
|
||||
- **Responsive Adaptation**: postcss-pxtorem automatically converts px to rem
|
||||
- **Responsive Adaptation**: Based on px and media queries
|
||||
- **Code Quality**: Integrated ESLint, Prettier
|
||||
- **Build Optimization**: gzip compression, bundle analysis, code splitting
|
||||
- **Clear Directory Structure**: src contains assets, components, views, router, store, utils, etc.
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@
|
||||
- **状态管理**:集成 vuex
|
||||
- **UI 框架**:支持 Element Plus
|
||||
- **国际化**:集成 vue-i18n,支持多语言切换
|
||||
- **响应式适配**:postcss-pxtorem 自动 px 转 rem
|
||||
- **响应式适配**:基于 px 和媒体查询
|
||||
- **代码规范**:集成 ESLint、Prettier
|
||||
- **打包优化**:gzip 压缩、打包分析、按需分包
|
||||
- **目录结构清晰**:src 下分 assets、components、views、router、store、utils 等
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: 13895237362 2205451508@qq.com
|
||||
* @Date: 2025-08-11 09:07:12
|
||||
* @LastEditors: 13895237362 2205451508@qq.com
|
||||
* @LastEditTime: 2025-08-11 11:07:32
|
||||
* @LastEditTime: 2025-09-03 10:24:43
|
||||
* @FilePath: /frontend/docs/Claude + Figma 开发 Vue 3 + TypeScript + Element Plus 应用指南.md
|
||||
* @Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE
|
||||
-->
|
||||
@@ -157,17 +157,48 @@ $border-radius: 6px;
|
||||
- **复用样式**: 将常用样式抽取到 mixins 中
|
||||
- **主题支持**: 使用 SCSS 变量配合 Element Plus 主题定制
|
||||
|
||||
#### 响应式设计
|
||||
```scss
|
||||
// 使用 mixins.scss 中的响应式断点
|
||||
@include tablet {
|
||||
// 平板样式
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
// 手机样式
|
||||
}
|
||||
```
|
||||
**整体响应式方案(不使用rem):**
|
||||
|
||||
1. **JavaScript 辅助监听**: 在 `src/utils/resize.js` 等文件中监听窗口 resize 事件,动态计算屏幕宽度
|
||||
2. **设备类型管理**: 将设备类型(desktop/mobile)等信息存入 Pinia,供组件使用
|
||||
3. **CSS 媒体查询**: 定义不同屏幕宽度下的样式规则(核心方案)
|
||||
4. **Flex 布局和百分比宽度**: 使用 Flex 布局实现内容弹性流动,使用 % 或 vw/vh 定义容器宽度
|
||||
|
||||
**二、移动端体验深度优化:**
|
||||
|
||||
1. **点击目标大小**: 确保按钮、链接等可点击元素尺寸不小于 44x44px(苹果HIG标准),适应手指触摸,避免误操作
|
||||
|
||||
2. **避免悬停(Hover)事件**: 移动端没有鼠标悬停,所有依赖 `:hover` 状态的功能(工具提示、二级菜单)都必须提供替代交互方式,如点击触发或长按触发
|
||||
|
||||
3. **原生控件和滚动**:
|
||||
- 使用 `<input type="date">` 调用原生日期选择器,体验优于自定义组件
|
||||
- 处理 `-webkit-overflow-scrolling: touch` 启用iOS弹性滚动,让列表滚动更顺滑
|
||||
|
||||
4. **视口(Viewport)设置**: 确保 `<meta name="viewport">` 标签正确设置,根据需求决定是否禁止缩放以防止布局错乱
|
||||
|
||||
**三、复杂组件的响应策略:**
|
||||
|
||||
**1. 表格(Table):**
|
||||
- **横向滚动**: 为表格容器设置 `overflow-x: auto`,允许用户横向滑动查看隐藏列
|
||||
- **列省略/优先级**: 隐藏次要列,只保留关键信息
|
||||
- **卡片化视图**: 小屏幕下(特别是移动端),将表格行转换为独立卡片,垂直展示数据
|
||||
|
||||
**2. 表单(Form):**
|
||||
- **布局调整**: 将 label 和 input 的左右布局改为上下(block)布局
|
||||
|
||||
**3. 图表(Charts):**
|
||||
- **动态重绘**: 使用 ECharts 等库的 `resize()` 方法,在容器尺寸变化时重绘图表
|
||||
- **简化显示**: 复杂图表在小屏幕下隐藏图例、简化配置,或提示用户"请在更大屏幕上查看此图表"
|
||||
|
||||
**4. 模态框(Modal/Dialog):**
|
||||
- **全屏显示**: 移动端模态框应全屏显示,而非居中弹出浮层,更符合移动端交互习惯
|
||||
|
||||
**四、性能优化:**
|
||||
|
||||
1. **防抖和节流**: 对 resize、scroll 等频繁触发事件使用防抖或节流,避免性能浪费
|
||||
2. **按需加载和代码分割**: 使用 Vue 异步组件和 Webpack 动态 `import()` 语法,将不同设备的组件代码分别加载
|
||||
3. **图片和媒体优化**: 使用 `srcset` 和 `sizes` 属性为不同屏幕提供不同分辨率图片,避免移动端加载桌面大图
|
||||
|
||||
## 3. Figma设计转代码
|
||||
|
||||
@@ -457,7 +488,6 @@ export default defineConfig({
|
||||
// 解决方案:使用视口单位和媒体查询
|
||||
.container {
|
||||
width: 100vw;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 4vw;
|
||||
|
||||
|
||||
+15
-2
@@ -1,10 +1,23 @@
|
||||
<!--
|
||||
* @Author: 13895237362 2205451508@qq.com
|
||||
* @Date: 2025-09-24 10:31:33
|
||||
* @LastEditors: 13895237362 2205451508@qq.com
|
||||
* @LastEditTime: 2025-09-24 10:32:56
|
||||
* @FilePath: \frontend\index.html
|
||||
* @Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE
|
||||
-->
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes, minimum-scale=0.5, maximum-scale=3.0" />
|
||||
<title>Vite + Vue3.0 + TS项目模板</title>
|
||||
<title>授权管理系统</title>
|
||||
<!-- Noto Sans SC 本地字体预加载(优化加载速度) -->
|
||||
<!-- 注意: 字体文件需要下载到 src/assets/fonts/ 目录,参考 FONT_OPTIMIZATION.md -->
|
||||
<link rel="preload" href="/src/assets/fonts/noto-sans-sc-regular.woff2" as="font" type="font/woff2" crossorigin="anonymous">
|
||||
<link rel="preload" href="/src/assets/fonts/noto-sans-sc-medium.woff2" as="font" type="font/woff2" crossorigin="anonymous">
|
||||
<link rel="preload" href="/src/assets/fonts/noto-sans-sc-bold.woff2" as="font" type="font/woff2" crossorigin="anonymous">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
Loaded 100 of 181 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user