feat: 完成 opsx-licence 许可证管理平台全栈实现
基于 opsx-web-lite 框架(Go+Gin+Vue3+Element Plus)构建许可证管理服务器,
业务域参考 Licence 服务器 Web 版。
【后端 - 4层架构】
- 认证:单 Admin 表 + JWT(HS256)
- 核心授权域:Product / Customer / License / Activation
· License 生成/验证/激活/机器绑定/心跳/续期/吊销
· 客户端契约接口:/api/v1/license/{verify,activate,deactivate,heartbeat}
- 定价订单:Plan / Order / 支付网关配置
- 防重复购买:CustomerEntitlement + 幂等支付 webhook + 手动发放
- 分析审计:Dashboard 统计 / Analytics / AuditLog / Setting 管理
【前端】
- Dashboard(ECharts 图表)、产品、客户、License、计划、订单、权益、日志、设置
- 9 个完整 CRUD 业务页面 + 登录/布局
【部署】
- SQLite + 单二进制(//go:embed 嵌入前端)
- 开发:./run.sh start(前端5181/后端9001)
- 生产:./run.sh build && ./run.sh binary start
- 默认账号:admin/admin
Author: Logan.Li
This commit is contained in:
commit
4ae318335c
88 files changed
+10562
No files matched your search
@@ -0,0 +1,47 @@
|
|||||||
|
# opsx-licence - 环境变量配置模板
|
||||||
|
# 复制此文件为 .env 并根据需要修改
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# 后端配置 (backend)
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
# 服务器端口
|
||||||
|
PORT=9001
|
||||||
|
|
||||||
|
# 运行模式: debug / release
|
||||||
|
GIN_MODE=debug
|
||||||
|
|
||||||
|
# JWT 密钥(生产环境必须修改!)
|
||||||
|
JWT_SECRET=your-super-secret-key-change-this-in-production
|
||||||
|
|
||||||
|
# 数据库配置
|
||||||
|
# 可选: sqlite / mysql / postgres
|
||||||
|
DB_DRIVER=sqlite
|
||||||
|
DB_NAME=db.sqlite3
|
||||||
|
|
||||||
|
# MySQL 配置(使用 mysql 时填写)
|
||||||
|
# DB_HOST=127.0.0.1
|
||||||
|
# DB_PORT=3306
|
||||||
|
# DB_USER=root
|
||||||
|
# DB_PASSWORD=your-password
|
||||||
|
# DB_NAME=opsx_licence
|
||||||
|
|
||||||
|
# PostgreSQL 配置(使用 postgres 时填写)
|
||||||
|
# DB_HOST=127.0.0.1
|
||||||
|
# DB_PORT=5432
|
||||||
|
# DB_USER=postgres
|
||||||
|
# DB_PASSWORD=your-password
|
||||||
|
# DB_NAME=opsx_licence
|
||||||
|
|
||||||
|
# CORS 配置(多个用逗号分隔)
|
||||||
|
# CORS_ORIGINS=http://localhost:5181,http://127.0.0.1:5181
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# 前端配置 (frontend)
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
# 开发环境 API 地址
|
||||||
|
VITE_API_BASE_URL=http://localhost:9001/api
|
||||||
|
|
||||||
|
# 生产环境 API 地址(构建时注入)
|
||||||
|
# VITE_APP_BASE_API=/api
|
||||||
+78
@@ -0,0 +1,78 @@
|
|||||||
|
# ============================================
|
||||||
|
# 全局忽略配置
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# ZCode / Claude 工作目录
|
||||||
|
.zcode/
|
||||||
|
.claude/
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# 后端 (backend)
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
# Binaries
|
||||||
|
*.exe
|
||||||
|
*.exe~
|
||||||
|
*.dll
|
||||||
|
*.so
|
||||||
|
*.dylib
|
||||||
|
backend/backend
|
||||||
|
backend/server
|
||||||
|
backend/main
|
||||||
|
|
||||||
|
# Test binary
|
||||||
|
*.test
|
||||||
|
|
||||||
|
# Output of the go coverage tool
|
||||||
|
*.out
|
||||||
|
|
||||||
|
# Dependency directories
|
||||||
|
vendor/
|
||||||
|
|
||||||
|
# Go workspace file
|
||||||
|
go.work
|
||||||
|
|
||||||
|
# Database
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
|
||||||
|
# 日志文件
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# PID 文件
|
||||||
|
*.pid
|
||||||
|
*_monitor.pid
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# 前端 (frontend)
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# 构建输出
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# 单二进制构建时暂存的前端资源
|
||||||
|
backend/static/*
|
||||||
|
!backend/static/.gitkeep
|
||||||
|
|
||||||
|
# 单二进制构建产物
|
||||||
|
opsx-licence
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# AGENTS.md — opsx-licence 开发约定
|
||||||
|
|
||||||
|
本文件供 AI 协作助手(Claude / ZCode 等)参考,确保产出代码与项目既有风格一致。
|
||||||
|
|
||||||
|
## 语言与签名
|
||||||
|
|
||||||
|
- **所有交互、注释、文档一律使用中文。**
|
||||||
|
- 每次新增或修改代码注释时,在注释末尾追加 `// Logan.Li` 签名。
|
||||||
|
- 函数必须有文档注释(以函数名开头)。
|
||||||
|
|
||||||
|
## 后端约定
|
||||||
|
|
||||||
|
### 分层架构(4 层)
|
||||||
|
|
||||||
|
```
|
||||||
|
internal/controller/<domain>/ HTTP 处理层(gin.Context)
|
||||||
|
↓
|
||||||
|
internal/service/<domain>/ 业务逻辑层(持有 *gorm.DB + *config.Config)
|
||||||
|
↓
|
||||||
|
internal/dto/ 请求/响应结构体(扁平包,按域分文件)
|
||||||
|
internal/model/ GORM 模型(扁平包,按域分文件)
|
||||||
|
↓
|
||||||
|
GORM
|
||||||
|
```
|
||||||
|
|
||||||
|
### 硬性约束
|
||||||
|
|
||||||
|
- **Controller 方法 ≤ 80 行**:只做参数校验 + 响应格式化;复杂逻辑下沉到 Service。
|
||||||
|
- **Service 承载所有业务逻辑**:DB 操作、业务规则、跨表事务。
|
||||||
|
- **单文件 ≤ 300 行**:超出则按子域拆分。
|
||||||
|
- **标准 CRUD 方法名**:`List / Get / Create / Update / Delete`。
|
||||||
|
- Controller 持有 `service` 与 `db`;Service 持有 `db` 与 `cfg`。
|
||||||
|
|
||||||
|
### 响应格式
|
||||||
|
|
||||||
|
统一使用 `dto.Response{Code, Message, Data}`,`code:0` 表示成功。
|
||||||
|
|
||||||
|
分页响应结构:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"count": 100,
|
||||||
|
"total_pages": 10,
|
||||||
|
"current_page": 1,
|
||||||
|
"page_size": 10,
|
||||||
|
"next": "...",
|
||||||
|
"previous": "...",
|
||||||
|
"results": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 新增业务域步骤(示例:product)
|
||||||
|
|
||||||
|
1. `internal/model/product.go` —— GORM 模型
|
||||||
|
2. `internal/dto/product.go` —— 请求/响应 DTO
|
||||||
|
3. `internal/service/product/product_service.go` —— 业务逻辑
|
||||||
|
4. `internal/controller/product/product_controller.go` —— HTTP handler
|
||||||
|
5. 在 `router/router.go` 的 protected 组注册路由
|
||||||
|
|
||||||
|
### 路由规范
|
||||||
|
|
||||||
|
- 统一 `/api` 前缀,集中注册于 `router/router.go`。
|
||||||
|
- 公开接口(登录、客户端验证等)放公开组;管理接口放 JWT 保护组。
|
||||||
|
|
||||||
|
## 前端约定
|
||||||
|
|
||||||
|
- **Vue 3 Composition API + `<script setup>`**。
|
||||||
|
- **Element Plus**:`el-checkbox` / `el-radio` 的选项用 `value` 而非 `label`。
|
||||||
|
- **减少打扰**:常规操作不发成功 toast;仅错误用 `ElMessage.error`;危险操作用确认弹窗。
|
||||||
|
- **命名**:组件 PascalCase(`UserList.vue`),工具 camelCase(`request.js`),目录 kebab-case。
|
||||||
|
- 接口封装在 `src/api/`,每个业务域一个文件,统一基于 `@/utils/request` 或 `@/api/request` 的 axios 实例。
|
||||||
|
|
||||||
|
## 认证体系
|
||||||
|
|
||||||
|
- 单 Admin 表(无 RBAC 多角色)。
|
||||||
|
- JWT 单 token,HS256,24h 过期。
|
||||||
|
- 中间件 `AuthMiddleware` 校验 `Authorization: Bearer <token>`,写入 `user_id` 到上下文。
|
||||||
|
|
||||||
|
## 安全
|
||||||
|
|
||||||
|
- 生产环境(`release` 模式)必须设置 `JWT_SECRET`,否则启动失败。
|
||||||
|
- CORS 使用显式白名单,禁止 `*`。
|
||||||
|
- 默认账号 `admin/admin` 必须在部署后修改。
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# opsx-licence - Makefile
|
||||||
|
# 提供常用开发命令的快捷方式
|
||||||
|
|
||||||
|
.PHONY: help install dev build build-ui build-api build-binary clean status stop restart lint test
|
||||||
|
|
||||||
|
# 默认显示帮助
|
||||||
|
help:
|
||||||
|
@echo "opsx-licence - 可用命令:"
|
||||||
|
@echo ""
|
||||||
|
@echo " make install - 安装前后端依赖"
|
||||||
|
@echo " make dev - 启动开发环境(前后端)"
|
||||||
|
@echo " make build - 构建前后端(分开)"
|
||||||
|
@echo " make build-ui - 仅构建前端"
|
||||||
|
@echo " make build-api - 仅构建后端"
|
||||||
|
@echo " make build-binary - 构建前端嵌入式单二进制 opsx-licence"
|
||||||
|
@echo " make test - 运行后端测试"
|
||||||
|
@echo " make lint - 运行代码检查"
|
||||||
|
@echo " make clean - 清理构建产物和日志"
|
||||||
|
@echo " make status - 查看服务状态"
|
||||||
|
@echo " make stop - 停止所有服务"
|
||||||
|
@echo " make restart - 重启所有服务"
|
||||||
|
@echo " make logs - 查看实时日志"
|
||||||
|
@echo ""
|
||||||
|
|
||||||
|
# 安装依赖
|
||||||
|
install:
|
||||||
|
@echo "📦 安装后端依赖..."
|
||||||
|
cd backend && go mod download
|
||||||
|
@echo "📦 安装前端依赖..."
|
||||||
|
cd frontend && npm install
|
||||||
|
|
||||||
|
# 开发环境启动
|
||||||
|
dev:
|
||||||
|
./run.sh start
|
||||||
|
|
||||||
|
# 构建前端
|
||||||
|
build-ui:
|
||||||
|
@echo "🔨 构建前端..."
|
||||||
|
cd frontend && npm run build
|
||||||
|
|
||||||
|
# 构建后端
|
||||||
|
build-api:
|
||||||
|
@echo "🔨 构建后端..."
|
||||||
|
cd backend && go build -o server -ldflags="-s -w" .
|
||||||
|
|
||||||
|
# 构建所有(分开)
|
||||||
|
build: build-ui build-api
|
||||||
|
|
||||||
|
# 构建前后端合一的部署二进制 // Logan.Li
|
||||||
|
build-binary:
|
||||||
|
./run.sh build
|
||||||
|
|
||||||
|
# 后端测试
|
||||||
|
test:
|
||||||
|
@echo "🧪 运行后端测试..."
|
||||||
|
cd backend && go test -v -race -coverprofile=coverage.out ./...
|
||||||
|
@echo "📊 后端覆盖率:"
|
||||||
|
@cd backend && go tool cover -func=coverage.out | grep total
|
||||||
|
|
||||||
|
# 代码检查
|
||||||
|
lint:
|
||||||
|
@echo "🔍 检查后端代码..."
|
||||||
|
cd backend && go vet ./...
|
||||||
|
|
||||||
|
# 清理
|
||||||
|
clean:
|
||||||
|
@echo "🧹 清理构建产物..."
|
||||||
|
rm -f backend/server backend/backend
|
||||||
|
rm -rf frontend/dist
|
||||||
|
rm -f backend/logs/*.log frontend/logs/*.log logs/*.log
|
||||||
|
rm -f opsx-licence
|
||||||
|
@echo "🧹 清理完成"
|
||||||
|
|
||||||
|
# 查看状态
|
||||||
|
status:
|
||||||
|
./run.sh status
|
||||||
|
|
||||||
|
# 停止服务
|
||||||
|
stop:
|
||||||
|
./run.sh stop
|
||||||
|
|
||||||
|
# 重启服务
|
||||||
|
restart:
|
||||||
|
./run.sh restart
|
||||||
|
|
||||||
|
# 查看日志
|
||||||
|
logs:
|
||||||
|
@tail -f backend/logs/server.log frontend/logs/dev.log 2>/dev/null || echo "日志文件不存在"
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
# opsx-licence
|
||||||
|
|
||||||
|
许可证(License)管理服务器 Web 版 —— 基于 Go (Gin) + Vue 3 的单体全栈应用。
|
||||||
|
|
||||||
|
## 项目简介
|
||||||
|
|
||||||
|
opsx-licence 是一个统一的授权验证与管理服务,提供许可证的生成、验证、激活、机器绑定、心跳等核心能力,并支持产品定价、订单购买、防重复购买与支付回调。
|
||||||
|
|
||||||
|
技术栈与工程结构参考 [opsx-web-lite](../opsx-web-lite),业务领域参考 Licence 服务器 Web版。
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
### 后端
|
||||||
|
- **语言/框架**:Go 1.24 + Gin
|
||||||
|
- **ORM**:GORM(默认 SQLite,支持 MySQL / PostgreSQL)
|
||||||
|
- **认证**:JWT(HS256)+ bcrypt
|
||||||
|
- **架构**:4 层分层(controller → service → dto → model),按业务域分包
|
||||||
|
|
||||||
|
### 前端
|
||||||
|
- **框架**:Vue 3.4 + Vite
|
||||||
|
- **UI**:Element Plus
|
||||||
|
- **状态/路由**:Pinia + Vue Router 4
|
||||||
|
- **图表**:ECharts
|
||||||
|
|
||||||
|
### 部署
|
||||||
|
- 单二进制:前端通过 `//go:embed` 打包进 Go 二进制,一个文件即可部署
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 环境要求
|
||||||
|
- Go >= 1.24
|
||||||
|
- Node.js >= 20
|
||||||
|
- CGO(SQLite 驱动需要,macOS 自带 gcc)
|
||||||
|
|
||||||
|
### 一键启动(开发模式)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
./run.sh start
|
||||||
|
```
|
||||||
|
|
||||||
|
- 前端:http://localhost:5181
|
||||||
|
- 后端:http://localhost:9001
|
||||||
|
- 默认账号:`admin` / `admin`
|
||||||
|
|
||||||
|
### 构建单二进制
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./run.sh build
|
||||||
|
./run.sh binary start
|
||||||
|
```
|
||||||
|
|
||||||
|
## 常用命令
|
||||||
|
|
||||||
|
| 命令 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `make dev` / `./run.sh start` | 启动前后端开发服务 |
|
||||||
|
| `make build` / `./run.sh build` | 构建嵌入前端的单二进制 |
|
||||||
|
| `./run.sh stop` | 停止服务 |
|
||||||
|
| `./run.sh status` | 查看服务状态 |
|
||||||
|
| `make clean` | 清理构建产物 |
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
opsx-licence/
|
||||||
|
├── backend/ # Go 后端
|
||||||
|
│ ├── main.go # 入口
|
||||||
|
│ ├── embed.go # 前端嵌入
|
||||||
|
│ ├── config/ # 配置
|
||||||
|
│ ├── database/ # 数据库初始化 + 种子数据
|
||||||
|
│ ├── middleware/ # 中间件(auth / cors)
|
||||||
|
│ ├── router/ # 路由
|
||||||
|
│ ├── utils/ # 工具(jwt / password)
|
||||||
|
│ └── internal/ # 业务分层
|
||||||
|
│ ├── controller/ # HTTP 处理层
|
||||||
|
│ ├── service/ # 业务逻辑层
|
||||||
|
│ ├── dto/ # 请求/响应结构
|
||||||
|
│ └── model/ # 数据模型
|
||||||
|
├── frontend/ # Vue 前端
|
||||||
|
│ └── src/
|
||||||
|
│ ├── api/ # 接口封装
|
||||||
|
│ ├── stores/ # Pinia 状态
|
||||||
|
│ ├── router/ # 路由
|
||||||
|
│ ├── layouts/ # 布局
|
||||||
|
│ └── views/ # 页面
|
||||||
|
├── Makefile
|
||||||
|
├── run.sh # 启动脚本
|
||||||
|
└── .env.example
|
||||||
|
```
|
||||||
|
|
||||||
|
## 业务域(规划)
|
||||||
|
|
||||||
|
| 模块 | 说明 | 状态 |
|
||||||
|
|---|---|---|
|
||||||
|
| 认证 (Auth) | 管理员登录、JWT | ✅ |
|
||||||
|
| 核心授权 | 产品 / 客户 / License / 激活 | 🚧 规划中 |
|
||||||
|
| 定价订单 | Plan / Order / 支付网关 | 🚧 规划中 |
|
||||||
|
| 防重复购买 | 客户权益 / 幂等回调 | 🚧 规划中 |
|
||||||
|
| 分析审计 | Dashboard / Analytics / AuditLog | 🚧 规划中 |
|
||||||
|
|
||||||
|
## 作者
|
||||||
|
|
||||||
|
Logan.Li
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config 应用配置 // Logan.Li
|
||||||
|
type Config struct {
|
||||||
|
// 服务器配置
|
||||||
|
Port string
|
||||||
|
Mode string // debug, release
|
||||||
|
|
||||||
|
// 数据库配置
|
||||||
|
DBDriver string
|
||||||
|
DBHost string
|
||||||
|
DBPort string
|
||||||
|
DBUser string
|
||||||
|
DBPassword string
|
||||||
|
DBName string
|
||||||
|
|
||||||
|
// JWT 配置
|
||||||
|
JWTSecret string
|
||||||
|
|
||||||
|
// CORS 配置
|
||||||
|
CORSOrigins []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load 从环境变量加载配置 // Logan.Li
|
||||||
|
func Load() *Config {
|
||||||
|
// 加载 .env 文件(如果存在) // Logan.Li
|
||||||
|
_ = godotenv.Load()
|
||||||
|
|
||||||
|
cfg := &Config{
|
||||||
|
Port: getEnv("PORT", "9001"),
|
||||||
|
Mode: getEnv("GIN_MODE", "debug"),
|
||||||
|
DBDriver: getEnv("DB_DRIVER", "sqlite"),
|
||||||
|
DBHost: getEnv("DB_HOST", "127.0.0.1"),
|
||||||
|
DBPort: getEnv("DB_PORT", "3306"),
|
||||||
|
DBUser: getEnv("DB_USER", "root"),
|
||||||
|
DBPassword: getEnv("DB_PASSWORD", ""),
|
||||||
|
DBName: getEnv("DB_NAME", "db.sqlite3"),
|
||||||
|
JWTSecret: getEnv("JWT_SECRET", ""),
|
||||||
|
CORSOrigins: []string{
|
||||||
|
"http://localhost:5181",
|
||||||
|
"http://127.0.0.1:5181",
|
||||||
|
"http://localhost:3000",
|
||||||
|
"http://127.0.0.1:3000",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// 安全检查:生产环境拒绝空密钥,开发环境使用进程级随机密钥避免固定后门。 // Logan.Li
|
||||||
|
if cfg.JWTSecret == "" {
|
||||||
|
if cfg.Mode == "release" {
|
||||||
|
log.Fatal("[安全] 生产环境必须设置 JWT_SECRET 环境变量")
|
||||||
|
}
|
||||||
|
cfg.JWTSecret = generateEphemeralJWTSecret()
|
||||||
|
log.Println("[安全] JWT_SECRET 未设置,已生成仅当前开发进程有效的随机密钥")
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsOriginAllowed 判断浏览器 Origin 是否在显式 CORS 白名单中。 // Logan.Li
|
||||||
|
func (c *Config) IsOriginAllowed(origin string) bool {
|
||||||
|
for _, allowed := range c.CORSOrigins {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(origin), strings.TrimSpace(allowed)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateEphemeralJWTSecret 生成开发环境临时 JWT 密钥。 // Logan.Li
|
||||||
|
func generateEphemeralJWTSecret() string {
|
||||||
|
bytes := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(bytes); err != nil {
|
||||||
|
log.Fatal("[安全] 生成开发 JWT 密钥失败")
|
||||||
|
}
|
||||||
|
return base64.RawURLEncoding.EncodeToString(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getEnv 读取环境变量,不存在则返回默认值 // Logan.Li
|
||||||
|
func getEnv(key, defaultValue string) string {
|
||||||
|
if value := os.Getenv(key); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"backend/config"
|
||||||
|
"backend/internal/model"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gorm.io/driver/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Init 初始化数据库连接并执行迁移 // Logan.Li
|
||||||
|
func Init(cfg *config.Config) (*gorm.DB, error) {
|
||||||
|
logLevel := getLogLevel(cfg.Mode)
|
||||||
|
|
||||||
|
fmt.Printf("[数据库] 使用 SQLite: %s\n", cfg.DBName)
|
||||||
|
db, err := gorm.Open(sqlite.Open(cfg.DBName), &gorm.Config{
|
||||||
|
Logger: logger.Default.LogMode(logLevel),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("SQLite 连接失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return migrateDatabase(db)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getLogLevel 根据运行模式返回日志级别 // Logan.Li
|
||||||
|
func getLogLevel(mode string) logger.LogLevel {
|
||||||
|
if mode == "release" {
|
||||||
|
return logger.Warn
|
||||||
|
}
|
||||||
|
return logger.Info
|
||||||
|
}
|
||||||
|
|
||||||
|
// migrateDatabase 执行数据库迁移 // Logan.Li
|
||||||
|
func migrateDatabase(db *gorm.DB) (*gorm.DB, error) {
|
||||||
|
err := db.AutoMigrate(
|
||||||
|
// 认证与系统
|
||||||
|
&model.User{}, // 管理员用户 // Logan.Li
|
||||||
|
&model.Setting{}, // 系统设置 // Logan.Li
|
||||||
|
&model.AuditLog{}, // 操作日志 // Logan.Li
|
||||||
|
&model.LicenseTemplate{}, // License 模板 // Logan.Li
|
||||||
|
&model.PaymentGatewayConfig{}, // 支付网关配置 // Logan.Li
|
||||||
|
// 核心授权域
|
||||||
|
&model.Product{}, // 产品 // Logan.Li
|
||||||
|
&model.Customer{}, // 客户 // Logan.Li
|
||||||
|
&model.License{}, // License // Logan.Li
|
||||||
|
&model.Activation{}, // 激活记录 // Logan.Li
|
||||||
|
// 定价与订单
|
||||||
|
&model.Plan{}, // 定价计划 // Logan.Li
|
||||||
|
&model.Order{}, // 订单 // Logan.Li
|
||||||
|
// 防重复购买
|
||||||
|
&model.CustomerEntitlement{}, // 客户权益 // Logan.Li
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("数据库迁移失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化种子数据(默认管理员、默认设置、示例数据) // Logan.Li
|
||||||
|
if err := SeedDefaults(db); err != nil {
|
||||||
|
return nil, fmt.Errorf("种子数据初始化失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"backend/internal/model"
|
||||||
|
"backend/utils"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultAdminUsername = "admin"
|
||||||
|
defaultAdminPassword = "admin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SeedDefaults 初始化默认数据:管理员账号 + 系统设置 + 示例业务数据 // Logan.Li
|
||||||
|
func SeedDefaults(db *gorm.DB) error {
|
||||||
|
if err := seedAdmin(db); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := seedSetting(db); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := seedSampleData(db); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedAdmin 初始化默认管理员账号(仅在不存在时创建) // Logan.Li
|
||||||
|
func seedAdmin(db *gorm.DB) error {
|
||||||
|
var count int64
|
||||||
|
db.Model(&model.User{}).Where("username = ?", defaultAdminUsername).Count(&count)
|
||||||
|
if count > 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
admin := model.User{
|
||||||
|
Username: defaultAdminUsername,
|
||||||
|
Password: utils.HashPassword(defaultAdminPassword),
|
||||||
|
Email: "admin@opsx-licence.local",
|
||||||
|
Role: "admin",
|
||||||
|
IsActive: true,
|
||||||
|
}
|
||||||
|
if err := db.Create(&admin).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
log.Printf("[初始化] 已创建默认管理员账号: %s / %s(请尽快修改密码)", defaultAdminUsername, defaultAdminPassword)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedSetting 初始化默认系统设置(仅创建一次) // Logan.Li
|
||||||
|
func seedSetting(db *gorm.DB) error {
|
||||||
|
var count int64
|
||||||
|
db.Model(&model.Setting{}).Count(&count)
|
||||||
|
if count > 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
setting := model.Setting{
|
||||||
|
SystemName: "opsx-licence",
|
||||||
|
DefaultDuration: 365,
|
||||||
|
DefaultMaxActivations: 1,
|
||||||
|
ExpireReminderDays: 7,
|
||||||
|
APIRateLimit: 1000,
|
||||||
|
LogRetentionDays: 365,
|
||||||
|
}
|
||||||
|
if err := db.Create(&setting).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
log.Println("[初始化] 已创建默认系统设置")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedSampleData 初始化示例业务数据(产品/客户/计划/支付网关),便于开发联调 // Logan.Li
|
||||||
|
func seedSampleData(db *gorm.DB) error {
|
||||||
|
var productCount int64
|
||||||
|
db.Model(&model.Product{}).Count(&productCount)
|
||||||
|
if productCount > 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 示例产品 // Logan.Li
|
||||||
|
product := model.Product{
|
||||||
|
Name: "OpsX Tools Suite",
|
||||||
|
Code: "opsx-tools",
|
||||||
|
Version: "1.0.0",
|
||||||
|
Description: "OpsX 工具集 - 一站式运维工具箱",
|
||||||
|
Features: `["CLI","GUI","Web Console","API"]`,
|
||||||
|
Price: 99.00,
|
||||||
|
PriceModel: "subscription",
|
||||||
|
Status: 1,
|
||||||
|
}
|
||||||
|
if err := db.Create(&product).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 示例客户 // Logan.Li
|
||||||
|
customer := model.Customer{
|
||||||
|
Name: "示例客户",
|
||||||
|
Company: "Acme Inc.",
|
||||||
|
Email: "demo@acme.com",
|
||||||
|
Phone: "13800000000",
|
||||||
|
Status: 1,
|
||||||
|
}
|
||||||
|
if err := db.Create(&customer).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 示例定价计划(买断 + 月订阅 + 年订阅) // Logan.Li
|
||||||
|
plans := []model.Plan{
|
||||||
|
{ProductID: product.ID, Name: "个人永久版", PlanType: "perpetual", Price: 199, Currency: "USD", Duration: 0, MaxDevices: 1, Features: `["全部功能","永久使用","1 设备"]`, SortOrder: 1, Status: 1},
|
||||||
|
{ProductID: product.ID, Name: "专业版(月)", PlanType: "monthly", Price: 19, Currency: "USD", Duration: 30, MaxDevices: 3, Features: `["全部功能","按月订阅","3 设备"]`, SortOrder: 2, Status: 1},
|
||||||
|
{ProductID: product.ID, Name: "企业版(年)", PlanType: "yearly", Price: 199, Currency: "USD", Duration: 365, MaxDevices: 10, Features: `["全部功能","按年订阅优惠","10 设备","优先支持"]`, SortOrder: 3, Status: 1},
|
||||||
|
}
|
||||||
|
for i := range plans {
|
||||||
|
if err := db.Create(&plans[i]).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 示例 License // Logan.Li
|
||||||
|
now := time.Now()
|
||||||
|
license := model.License{
|
||||||
|
LicenseKey: utils.GenerateLicenseKey(),
|
||||||
|
CustomerID: customer.ID,
|
||||||
|
ProductID: product.ID,
|
||||||
|
Status: model.LicenseStatusActive,
|
||||||
|
StartDate: now,
|
||||||
|
EndDate: now.AddDate(0, 0, 365),
|
||||||
|
Duration: 365,
|
||||||
|
MaxActivations: 3,
|
||||||
|
Notes: "示例授权,初始化生成",
|
||||||
|
}
|
||||||
|
if err := db.Create(&license).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 示例支付网关配置(沙箱) // Logan.Li
|
||||||
|
gateway := model.PaymentGatewayConfig{
|
||||||
|
Gateway: "creem",
|
||||||
|
Enabled: true,
|
||||||
|
SandboxMode: true,
|
||||||
|
Currency: "USD",
|
||||||
|
Priority: 1,
|
||||||
|
}
|
||||||
|
if err := db.Create(&gateway).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("[初始化] 已创建示例产品、客户、计划、License、支付网关")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// frontendFS 是构建时嵌入的前端生产资源。 // Logan.Li
|
||||||
|
//
|
||||||
|
//go:embed all:static
|
||||||
|
var frontendFS embed.FS
|
||||||
|
|
||||||
|
// setupEmbeddedFrontend 为单二进制模式提供前端静态资源和 SPA 回退。 // Logan.Li
|
||||||
|
func setupEmbeddedFrontend(r *gin.Engine) {
|
||||||
|
staticFS, err := fs.Sub(frontendFS, "static")
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := fs.Stat(staticFS, "index.html"); err != nil {
|
||||||
|
// 开发模式不复制 dist,后端仅提供 API。 // Logan.Li
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
httpFS := http.FS(staticFS)
|
||||||
|
r.NoRoute(func(c *gin.Context) {
|
||||||
|
path := c.Request.URL.Path
|
||||||
|
if strings.HasPrefix(path, "/api/") || strings.HasPrefix(path, "/ws/") {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "接口不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
filePath := strings.TrimPrefix(path, "/")
|
||||||
|
if filePath == "" {
|
||||||
|
filePath = "index.html"
|
||||||
|
}
|
||||||
|
if _, err := staticFS.Open(filePath); err == nil {
|
||||||
|
if strings.HasSuffix(filePath, ".html") {
|
||||||
|
c.Header("Cache-Control", "no-store, no-cache, must-revalidate")
|
||||||
|
}
|
||||||
|
c.FileFromFS(path, httpFS)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(filePath, "assets/") {
|
||||||
|
c.String(http.StatusNotFound, "static asset not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
index, err := fs.ReadFile(staticFS, "index.html")
|
||||||
|
if err != nil {
|
||||||
|
c.String(http.StatusInternalServerError, "前端资源不可用")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Header("Cache-Control", "no-store, no-cache, must-revalidate")
|
||||||
|
c.Data(http.StatusOK, "text/html; charset=utf-8", index)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
module backend
|
||||||
|
|
||||||
|
go 1.24.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/gin-contrib/cors v1.5.0
|
||||||
|
github.com/gin-contrib/gzip v1.2.5
|
||||||
|
github.com/gin-gonic/gin v1.11.0
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.0
|
||||||
|
github.com/joho/godotenv v1.5.1
|
||||||
|
golang.org/x/crypto v0.43.0
|
||||||
|
gorm.io/driver/sqlite v1.5.4
|
||||||
|
gorm.io/gorm v1.25.5
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||||
|
github.com/bytedance/sonic v1.14.1 // indirect
|
||||||
|
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
||||||
|
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.10 // indirect
|
||||||
|
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||||
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
|
github.com/go-playground/validator/v10 v10.28.0 // indirect
|
||||||
|
github.com/goccy/go-json v0.10.5 // indirect
|
||||||
|
github.com/goccy/go-yaml v1.18.0 // indirect
|
||||||
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
|
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.17 // indirect
|
||||||
|
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.4 // indirect
|
||||||
|
github.com/quic-go/qpack v0.5.1 // indirect
|
||||||
|
github.com/quic-go/quic-go v0.55.0 // indirect
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
|
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||||
|
golang.org/x/arch v0.22.0 // indirect
|
||||||
|
golang.org/x/mod v0.29.0 // indirect
|
||||||
|
golang.org/x/net v0.46.0 // indirect
|
||||||
|
golang.org/x/sync v0.17.0 // indirect
|
||||||
|
golang.org/x/sys v0.37.0 // indirect
|
||||||
|
golang.org/x/text v0.30.0 // indirect
|
||||||
|
golang.org/x/tools v0.38.0 // indirect
|
||||||
|
google.golang.org/protobuf v1.36.10 // indirect
|
||||||
|
)
|
||||||
+109
@@ -0,0 +1,109 @@
|
|||||||
|
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||||
|
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||||
|
github.com/bytedance/sonic v1.14.1 h1:FBMC0zVz5XUmE4z9wF4Jey0An5FueFvOsTKKKtwIl7w=
|
||||||
|
github.com/bytedance/sonic v1.14.1/go.mod h1:gi6uhQLMbTdeP0muCnrjHLeCUPyb70ujhnNlhOylAFc=
|
||||||
|
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
|
||||||
|
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||||
|
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||||
|
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||||
|
github.com/gin-contrib/cors v1.5.0 h1:DgGKV7DDoOn36DFkNtbHrjoRiT5ExCe+PC9/xp7aKvk=
|
||||||
|
github.com/gin-contrib/cors v1.5.0/go.mod h1:TvU7MAZ3EwrPLI2ztzTt3tqgvBCq+wn8WpZmfADjupI=
|
||||||
|
github.com/gin-contrib/gzip v1.2.5 h1:fIZs0S+l17pIu1P5XRJOo/YNqfIuPCrZZ3TWB7pjckI=
|
||||||
|
github.com/gin-contrib/gzip v1.2.5/go.mod h1:aomRgR7ftdZV3uWY0gW/m8rChfxau0n8YVvwlOHONzw=
|
||||||
|
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||||
|
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||||
|
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
||||||
|
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
|
||||||
|
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
|
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||||
|
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||||
|
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||||
|
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||||
|
github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=
|
||||||
|
github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
|
||||||
|
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||||
|
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
|
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||||
|
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||||
|
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||||
|
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||||
|
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||||
|
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||||
|
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/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
||||||
|
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
||||||
|
github.com/quic-go/quic-go v0.55.0 h1:zccPQIqYCXDt5NmcEabyYvOnomjs8Tlwl7tISjJh9Mk=
|
||||||
|
github.com/quic-go/quic-go v0.55.0/go.mod h1:DR51ilwU1uE164KuWXhinFcKWGlEjzys2l8zUl5Ss1U=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
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.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
||||||
|
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||||
|
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||||
|
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||||
|
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
||||||
|
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||||
|
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
|
||||||
|
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
|
||||||
|
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
|
||||||
|
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
|
||||||
|
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
|
||||||
|
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
|
||||||
|
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||||
|
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||||
|
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||||
|
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||||
|
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
|
||||||
|
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
|
||||||
|
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||||
|
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gorm.io/driver/sqlite v1.5.4 h1:IqXwXi8M/ZlPzH/947tn5uik3aYQslP9BVveoax0nV0=
|
||||||
|
gorm.io/driver/sqlite v1.5.4/go.mod h1:qxAuCol+2r6PannQDpOP1FP6ag3mKi4esLnB/jHed+4=
|
||||||
|
gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls=
|
||||||
|
gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package analytics
|
||||||
|
|
||||||
|
import (
|
||||||
|
analyticsservice "backend/internal/service/analytics"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AnalyticsController 数据分析控制器 // Logan.Li
|
||||||
|
type AnalyticsController struct {
|
||||||
|
svc *analyticsservice.AnalyticsService
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAnalyticsController 创建控制器实例 // Logan.Li
|
||||||
|
func NewAnalyticsController(db *gorm.DB) *AnalyticsController {
|
||||||
|
return &AnalyticsController{svc: analyticsservice.NewAnalyticsService(db)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDashboardStats 仪表板统计 // Logan.Li
|
||||||
|
func (ctrl *AnalyticsController) GetDashboardStats(c *gin.Context) {
|
||||||
|
stats, err := ctrl.svc.GetDashboardStats()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 0, "data": stats})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetExpiringLicenses 即将到期 License // Logan.Li
|
||||||
|
func (ctrl *AnalyticsController) GetExpiringLicenses(c *gin.Context) {
|
||||||
|
days, _ := strconv.Atoi(c.DefaultQuery("days", "30"))
|
||||||
|
licenses, err := ctrl.svc.GetExpiringLicenses(days)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 0, "data": licenses})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRecentActivities 最近活动 // Logan.Li
|
||||||
|
func (ctrl *AnalyticsController) GetRecentActivities(c *gin.Context) {
|
||||||
|
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
|
||||||
|
activities, err := ctrl.svc.GetRecentActivities(limit)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 0, "data": activities})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOnlineStats 在线统计 // Logan.Li
|
||||||
|
func (ctrl *AnalyticsController) GetOnlineStats(c *gin.Context) {
|
||||||
|
stats, err := ctrl.svc.GetOnlineStats()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 0, "data": stats})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRevenueStats 收入统计 // Logan.Li
|
||||||
|
func (ctrl *AnalyticsController) GetRevenueStats(c *gin.Context) {
|
||||||
|
stats, err := ctrl.svc.GetRevenueStats()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 0, "data": stats})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLicenseGrowth License 增长趋势 // Logan.Li
|
||||||
|
func (ctrl *AnalyticsController) GetLicenseGrowth(c *gin.Context) {
|
||||||
|
stats, err := ctrl.svc.GetLicenseGrowth()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 0, "data": stats})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetProductDistribution 产品分布 // Logan.Li
|
||||||
|
func (ctrl *AnalyticsController) GetProductDistribution(c *gin.Context) {
|
||||||
|
stats, err := ctrl.svc.GetProductDistribution()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 0, "data": stats})
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"backend/config"
|
||||||
|
authservice "backend/internal/service/auth"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuthController 认证控制器 // Logan.Li
|
||||||
|
type AuthController struct {
|
||||||
|
authService *authservice.AuthService
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAuthController 创建控制器实例 // Logan.Li
|
||||||
|
func NewAuthController(db *gorm.DB, cfg *config.Config) *AuthController {
|
||||||
|
return &AuthController{
|
||||||
|
authService: authservice.NewAuthService(db, cfg),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login 管理员登录 // Logan.Li
|
||||||
|
func (ctrl *AuthController) Login(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
Username string `json:"username" binding:"required"`
|
||||||
|
Password string `json:"password" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "请求参数错误: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := ctrl.authService.Login(req)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 1, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "登录成功", "data": result})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Me 获取当前登录用户信息 // Logan.Li
|
||||||
|
func (ctrl *AuthController) Me(c *gin.Context) {
|
||||||
|
userID, _ := c.Get("user_id")
|
||||||
|
|
||||||
|
userInfo, err := ctrl.authService.GetCurrentUser(userID.(uint))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 1, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 0, "data": userInfo})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChangePassword 修改当前用户密码 // Logan.Li
|
||||||
|
func (ctrl *AuthController) ChangePassword(c *gin.Context) {
|
||||||
|
userID, _ := c.Get("user_id")
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
OldPassword string `json:"old_password" binding:"required"`
|
||||||
|
NewPassword string `json:"new_password" binding:"required,min=6"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "请求参数错误: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ctrl.authService.ChangePassword(userID.(uint), req); err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 1, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "密码修改成功"})
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
package customer
|
||||||
|
|
||||||
|
import (
|
||||||
|
customerservice "backend/internal/service/customer"
|
||||||
|
"backend/utils"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CustomerController 客户控制器 // Logan.Li
|
||||||
|
type CustomerController struct {
|
||||||
|
svc *customerservice.CustomerService
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCustomerController 创建控制器实例 // Logan.Li
|
||||||
|
func NewCustomerController(db *gorm.DB) *CustomerController {
|
||||||
|
return &CustomerController{svc: customerservice.NewCustomerService(db), db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// List 客户列表 // Logan.Li
|
||||||
|
func (ctrl *CustomerController) List(c *gin.Context) {
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "10"))
|
||||||
|
keyword := c.Query("keyword")
|
||||||
|
|
||||||
|
customers, total, err := ctrl.svc.List(page, pageSize, keyword)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 0, "data": gin.H{
|
||||||
|
"list": customers, "total": total, "page": page, "page_size": pageSize,
|
||||||
|
"total_pages": (total + int64(pageSize) - 1) / int64(pageSize),
|
||||||
|
}})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAll 全部客户 // Logan.Li
|
||||||
|
func (ctrl *CustomerController) GetAll(c *gin.Context) {
|
||||||
|
customers, err := ctrl.svc.GetAll()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 0, "data": customers})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get 客户详情 // Logan.Li
|
||||||
|
func (ctrl *CustomerController) Get(c *gin.Context) {
|
||||||
|
id, _ := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
customer, err := ctrl.svc.GetByID(uint(id))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 404, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 0, "data": customer})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create 创建客户 // Logan.Li
|
||||||
|
func (ctrl *CustomerController) Create(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
Name string `json:"name" binding:"required"`
|
||||||
|
Company string `json:"company"`
|
||||||
|
Email string `json:"email" binding:"omitempty,email"`
|
||||||
|
Phone string `json:"phone"`
|
||||||
|
Address string `json:"address"`
|
||||||
|
Notes string `json:"notes"`
|
||||||
|
Status *int `json:"status"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
customer, err := ctrl.svc.Create(req)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 1, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.LogAction(ctrl.db, c, "create", "customer", customer.ID, customer.Name, "", "", "创建客户")
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "创建成功", "data": customer})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update 更新客户 // Logan.Li
|
||||||
|
func (ctrl *CustomerController) Update(c *gin.Context) {
|
||||||
|
id, _ := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
var req struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Company string `json:"company"`
|
||||||
|
Email string `json:"email" binding:"omitempty,email"`
|
||||||
|
Phone string `json:"phone"`
|
||||||
|
Address string `json:"address"`
|
||||||
|
Notes string `json:"notes"`
|
||||||
|
Status *int `json:"status"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
customer, err := ctrl.svc.Update(uint(id), req)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 1, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.LogAction(ctrl.db, c, "update", "customer", customer.ID, customer.Name, "", "", "更新客户")
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "更新成功", "data": customer})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete 删除客户 // Logan.Li
|
||||||
|
func (ctrl *CustomerController) Delete(c *gin.Context) {
|
||||||
|
id, _ := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
if err := ctrl.svc.Delete(uint(id)); err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 1, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.LogAction(ctrl.db, c, "delete", "customer", uint(id), "", "", "", "删除客户")
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "删除成功"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateStatus 切换状态 // Logan.Li
|
||||||
|
func (ctrl *CustomerController) UpdateStatus(c *gin.Context) {
|
||||||
|
id, _ := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
var req struct {
|
||||||
|
Status int `json:"status"`
|
||||||
|
}
|
||||||
|
c.ShouldBindJSON(&req)
|
||||||
|
if err := ctrl.svc.UpdateStatus(uint(id), req.Status); err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 1, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.LogAction(ctrl.db, c, "update_status", "customer", uint(id), "", "", "", "切换客户状态")
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "状态更新成功"})
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package entitlement
|
||||||
|
|
||||||
|
import (
|
||||||
|
entitlementservice "backend/internal/service/entitlement"
|
||||||
|
"backend/utils"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EntitlementController 客户权益控制器 // Logan.Li
|
||||||
|
type EntitlementController struct {
|
||||||
|
svc *entitlementservice.EntitlementService
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewEntitlementController 创建控制器实例 // Logan.Li
|
||||||
|
func NewEntitlementController(db *gorm.DB) *EntitlementController {
|
||||||
|
return &EntitlementController{svc: entitlementservice.NewEntitlementService(db), db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// List 权益列表 // Logan.Li
|
||||||
|
func (ctrl *EntitlementController) List(c *gin.Context) {
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "10"))
|
||||||
|
customerID, _ := strconv.ParseUint(c.Query("customer_id"), 10, 32)
|
||||||
|
productID, _ := strconv.ParseUint(c.Query("product_id"), 10, 32)
|
||||||
|
|
||||||
|
entitlements, total, err := ctrl.svc.List(page, pageSize, uint(customerID), uint(productID))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 0, "data": gin.H{
|
||||||
|
"list": entitlements, "total": total, "page": page, "page_size": pageSize,
|
||||||
|
"total_pages": (total + int64(pageSize) - 1) / int64(pageSize),
|
||||||
|
}})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get 权益详情 // Logan.Li
|
||||||
|
func (ctrl *EntitlementController) Get(c *gin.Context) {
|
||||||
|
id, _ := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
ent, err := ctrl.svc.GetByID(uint(id))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 404, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 0, "data": ent})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revoke 吊销权益 // Logan.Li
|
||||||
|
func (ctrl *EntitlementController) Revoke(c *gin.Context) {
|
||||||
|
id, _ := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
if err := ctrl.svc.Revoke(uint(id)); err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 1, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.LogAction(ctrl.db, c, "revoke", "entitlement", uint(id), "", "", "", "吊销客户权益")
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "权益已吊销"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Renew 续费权益 // Logan.Li
|
||||||
|
func (ctrl *EntitlementController) Renew(c *gin.Context) {
|
||||||
|
id, _ := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
var req struct {
|
||||||
|
PlanID uint `json:"plan_id" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ent, err := ctrl.svc.Renew(uint(id), req.PlanID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 1, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.LogAction(ctrl.db, c, "renew", "entitlement", ent.ID, "", "", "", "续费客户权益")
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "续费成功", "data": ent})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByEmail 按邮箱查询客户权益(客户端接口) // Logan.Li
|
||||||
|
func (ctrl *EntitlementController) GetByEmail(c *gin.Context) {
|
||||||
|
email := c.Query("email")
|
||||||
|
if email == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "缺少 email 参数"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
entitlements, err := ctrl.svc.GetByEmail(email)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 1, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 0, "data": entitlements})
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package license
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Verify 客户端 License 验证 // Logan.Li
|
||||||
|
func (ctrl *LicenseController) Verify(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
LicenseKey string `json:"license_key" binding:"required"`
|
||||||
|
DeviceID string `json:"device_id"`
|
||||||
|
DeviceName string `json:"device_name"`
|
||||||
|
DeviceInfo string `json:"device_info"`
|
||||||
|
ProductID uint `json:"product_id"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp, _ := ctrl.svc.Verify(req)
|
||||||
|
c.JSON(http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActivateDevice 客户端激活(机器绑定) // Logan.Li
|
||||||
|
func (ctrl *LicenseController) ActivateDevice(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
LicenseKey string `json:"license_key" binding:"required"`
|
||||||
|
DeviceID string `json:"device_id" binding:"required"`
|
||||||
|
DeviceName string `json:"device_name"`
|
||||||
|
DeviceInfo string `json:"device_info"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, err := ctrl.svc.Activate(req, c.ClientIP())
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 1, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "激活成功", "data": data})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deactivate 客户端停用 // Logan.Li
|
||||||
|
func (ctrl *LicenseController) Deactivate(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
LicenseKey string `json:"license_key" binding:"required"`
|
||||||
|
DeviceID string `json:"device_id" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := ctrl.svc.Deactivate(req); err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 1, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "停用成功"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Heartbeat 客户端心跳 // Logan.Li
|
||||||
|
func (ctrl *LicenseController) Heartbeat(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
LicenseKey string `json:"license_key" binding:"required"`
|
||||||
|
DeviceID string `json:"device_id" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, err := ctrl.svc.Heartbeat(req)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 1, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "心跳成功", "data": data})
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
package license
|
||||||
|
|
||||||
|
import (
|
||||||
|
licenseservice "backend/internal/service/license"
|
||||||
|
"backend/utils"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LicenseController License 控制器 // Logan.Li
|
||||||
|
type LicenseController struct {
|
||||||
|
svc *licenseservice.LicenseService
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLicenseController 创建控制器实例 // Logan.Li
|
||||||
|
func NewLicenseController(db *gorm.DB) *LicenseController {
|
||||||
|
return &LicenseController{svc: licenseservice.NewLicenseService(db), db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseListQuery 从请求解析筛选参数 // Logan.Li
|
||||||
|
func parseListQuery(c *gin.Context) licenseservice.ListQuery {
|
||||||
|
q := licenseservice.ListQuery{
|
||||||
|
Keyword: c.Query("keyword"),
|
||||||
|
Status: c.Query("status"),
|
||||||
|
}
|
||||||
|
if v, err := strconv.ParseUint(c.Query("customer_id"), 10, 32); err == nil {
|
||||||
|
q.CustomerID = uint(v)
|
||||||
|
}
|
||||||
|
if v, err := strconv.ParseUint(c.Query("product_id"), 10, 32); err == nil {
|
||||||
|
q.ProductID = uint(v)
|
||||||
|
}
|
||||||
|
return q
|
||||||
|
}
|
||||||
|
|
||||||
|
// List License 列表 // Logan.Li
|
||||||
|
func (ctrl *LicenseController) List(c *gin.Context) {
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "10"))
|
||||||
|
|
||||||
|
licenses, total, err := ctrl.svc.List(page, pageSize, parseListQuery(c))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 0, "data": gin.H{
|
||||||
|
"list": licenses, "total": total, "page": page, "page_size": pageSize,
|
||||||
|
"total_pages": (total + int64(pageSize) - 1) / int64(pageSize),
|
||||||
|
}})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get License 详情 // Logan.Li
|
||||||
|
func (ctrl *LicenseController) Get(c *gin.Context) {
|
||||||
|
id, _ := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
lic, activations, err := ctrl.svc.GetByID(uint(id))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 404, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 0, "data": gin.H{"license": lic, "activations": activations}})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create 创建 License // Logan.Li
|
||||||
|
func (ctrl *LicenseController) Create(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
CustomerID uint `json:"customer_id" binding:"required"`
|
||||||
|
ProductID uint `json:"product_id" binding:"required"`
|
||||||
|
Duration int `json:"duration" binding:"required"`
|
||||||
|
MaxActivations int `json:"max_activations"`
|
||||||
|
IsTrial bool `json:"is_trial"`
|
||||||
|
TrialDays int `json:"trial_days"`
|
||||||
|
Notes string `json:"notes"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
licenses, err := ctrl.svc.Create(req)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 1, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.LogAction(ctrl.db, c, "create", "license", licenses[0].ID, licenses[0].LicenseKey, "", "",
|
||||||
|
"创建 "+strconv.Itoa(len(licenses))+" 个 License")
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "创建成功", "data": licenses})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update 更新 License // Logan.Li
|
||||||
|
func (ctrl *LicenseController) Update(c *gin.Context) {
|
||||||
|
id, _ := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
var updates map[string]interface{}
|
||||||
|
if err := c.ShouldBindJSON(&updates); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lic, err := ctrl.svc.Update(uint(id), updates)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 1, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.LogAction(ctrl.db, c, "update", "license", lic.ID, lic.LicenseKey, "", "", "更新 License")
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "更新成功", "data": lic})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete 删除 License // Logan.Li
|
||||||
|
func (ctrl *LicenseController) Delete(c *gin.Context) {
|
||||||
|
id, _ := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
lic, err := ctrl.svc.Delete(uint(id))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 1, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.LogAction(ctrl.db, c, "delete", "license", lic.ID, lic.LicenseKey, "", "", "删除 License")
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "删除成功"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Renew 续期 // Logan.Li
|
||||||
|
func (ctrl *LicenseController) Renew(c *gin.Context) {
|
||||||
|
id, _ := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
var req struct {
|
||||||
|
Duration int `json:"duration" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lic, err := ctrl.svc.Renew(uint(id), req.Duration)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 1, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.LogAction(ctrl.db, c, "renew", "license", lic.ID, lic.LicenseKey, "", "", "续期 License")
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "续期成功", "data": lic})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revoke 吊销 // Logan.Li
|
||||||
|
func (ctrl *LicenseController) Revoke(c *gin.Context) {
|
||||||
|
id, _ := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
var req struct {
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
c.ShouldBindJSON(&req)
|
||||||
|
lic, err := ctrl.svc.Revoke(uint(id), req.Reason)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 1, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.LogAction(ctrl.db, c, "revoke", "license", lic.ID, lic.LicenseKey, "", "", "吊销 License: "+req.Reason)
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "已吊销", "data": lic})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Suspend 暂停 // Logan.Li
|
||||||
|
func (ctrl *LicenseController) Suspend(c *gin.Context) {
|
||||||
|
ctrl.setStatus(c, "suspended")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Activate 激活(管理后台) // Logan.Li
|
||||||
|
func (ctrl *LicenseController) Activate(c *gin.Context) {
|
||||||
|
ctrl.setStatus(c, "active")
|
||||||
|
}
|
||||||
|
|
||||||
|
// setStatus 通用状态切换 // Logan.Li
|
||||||
|
func (ctrl *LicenseController) setStatus(c *gin.Context, status string) {
|
||||||
|
id, _ := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
lic, err := ctrl.svc.SetStatus(uint(id), status)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 1, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.LogAction(ctrl.db, c, status, "license", lic.ID, lic.LicenseKey, "", "", "License 状态变更为 "+status)
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "操作成功", "data": lic})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetActivations 获取激活记录 // Logan.Li
|
||||||
|
func (ctrl *LicenseController) GetActivations(c *gin.Context) {
|
||||||
|
id, _ := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
activations, err := ctrl.svc.GetActivations(uint(id))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 1, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 0, "data": activations})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ForceOffline 强制下线 // Logan.Li
|
||||||
|
func (ctrl *LicenseController) ForceOffline(c *gin.Context) {
|
||||||
|
licenseID, _ := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
activationID, _ := strconv.ParseUint(c.Param("activationId"), 10, 32)
|
||||||
|
if err := ctrl.svc.ForceOffline(uint(licenseID), uint(activationID)); err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 1, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.LogAction(ctrl.db, c, "force_offline", "activation", uint(activationID), "", "", "", "强制下线设备")
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "强制下线成功"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export 导出 CSV // Logan.Li
|
||||||
|
func (ctrl *LicenseController) Export(c *gin.Context) {
|
||||||
|
csv, err := ctrl.svc.ExportCSV(parseListQuery(c))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 1, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Header("Content-Type", "text/csv; charset=utf-8")
|
||||||
|
c.Header("Content-Disposition", "attachment; filename=licenses.csv")
|
||||||
|
c.String(http.StatusOK, csv)
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
package order
|
||||||
|
|
||||||
|
import (
|
||||||
|
"backend/internal/dto"
|
||||||
|
orderservice "backend/internal/service/order"
|
||||||
|
"backend/utils"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OrderController 订单控制器 // Logan.Li
|
||||||
|
type OrderController struct {
|
||||||
|
svc *orderservice.OrderService
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewOrderController 创建控制器实例 // Logan.Li
|
||||||
|
func NewOrderController(db *gorm.DB) *OrderController {
|
||||||
|
return &OrderController{svc: orderservice.NewOrderService(db), db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// List 订单列表(管理后台) // Logan.Li
|
||||||
|
func (ctrl *OrderController) List(c *gin.Context) {
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "10"))
|
||||||
|
status := c.Query("status")
|
||||||
|
productID, _ := strconv.ParseUint(c.Query("product_id"), 10, 32)
|
||||||
|
|
||||||
|
orders, total, err := ctrl.svc.List(page, pageSize, status, uint(productID))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 0, "data": gin.H{
|
||||||
|
"list": orders, "total": total, "page": page, "page_size": pageSize,
|
||||||
|
"total_pages": (total + int64(pageSize) - 1) / int64(pageSize),
|
||||||
|
}})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDetail 订单详情 // Logan.Li
|
||||||
|
func (ctrl *OrderController) GetDetail(c *gin.Context) {
|
||||||
|
id, _ := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
order, err := ctrl.svc.GetByID(uint(id))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 404, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 0, "data": order})
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateOrder 客户端下单(公开接口) // Logan.Li
|
||||||
|
func (ctrl *OrderController) CreateOrder(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
CustomerEmail string `json:"customer_email" binding:"required,email"`
|
||||||
|
CustomerName string `json:"customer_name"`
|
||||||
|
ProductID uint `json:"product_id" binding:"required"`
|
||||||
|
PlanID uint `json:"plan_id" binding:"required"`
|
||||||
|
DeviceID string `json:"device_id"`
|
||||||
|
ReturnURL string `json:"return_url"`
|
||||||
|
CancelURL string `json:"cancel_url"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp, err := ctrl.svc.CreateOrder(req, utils.GetClientIP(c), c.GetHeader("User-Agent"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 2001, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "订单创建成功", "data": resp})
|
||||||
|
}
|
||||||
|
|
||||||
|
// PaymentWebhook 支付回调(公开接口,供支付渠道调用) // Logan.Li
|
||||||
|
func (ctrl *OrderController) PaymentWebhook(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
EventType string `json:"event_type" binding:"required"`
|
||||||
|
OrderNo string `json:"order_no" binding:"required"`
|
||||||
|
GatewayOrderNo string `json:"gateway_order_no"`
|
||||||
|
Status string `json:"status" binding:"required"`
|
||||||
|
Amount float64 `json:"amount"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
CustomerEmail string `json:"customer_email"`
|
||||||
|
Gateway string `json:"gateway" binding:"required"`
|
||||||
|
Signature string `json:"signature"`
|
||||||
|
Payload string `json:"payload"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "Invalid payload"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := ctrl.svc.PaymentWebhook(req, utils.GetClientIP(c)); err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 1, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "Webhook processed"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ManualGrant 手动发放 License(管理后台) // Logan.Li
|
||||||
|
func (ctrl *OrderController) ManualGrant(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
CustomerEmail string `json:"customer_email" binding:"required,email"`
|
||||||
|
CustomerName string `json:"customer_name"`
|
||||||
|
ProductID uint `json:"product_id" binding:"required"`
|
||||||
|
PlanID uint `json:"plan_id" binding:"required"`
|
||||||
|
DeviceID string `json:"device_id"`
|
||||||
|
Notes string `json:"notes"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID, _ := c.Get("user_id")
|
||||||
|
username, _ := c.Get("username")
|
||||||
|
data, err := ctrl.svc.ManualGrant(req, userID.(uint), username.(string), utils.GetClientIP(c))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 1, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "License 发放成功", "data": data})
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckDuplicatePurchase 防重复购买检测(公开接口) // Logan.Li
|
||||||
|
func (ctrl *OrderController) CheckDuplicatePurchase(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
CustomerEmail string `json:"customer_email" binding:"required,email"`
|
||||||
|
ProductID uint `json:"product_id" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 通过 CheckPurchaseStatus 间接判断:有记录且有效则不允许重复购买 // Logan.Li
|
||||||
|
status, _ := ctrl.svc.CheckPurchaseStatus(req.CustomerEmail, req.ProductID)
|
||||||
|
result := dto.DuplicatePurchaseCheckResponse{AllowPurchase: true}
|
||||||
|
if status.HasActiveLicense {
|
||||||
|
result.AllowPurchase = false
|
||||||
|
result.Reason = status.Message
|
||||||
|
if result.Reason == "" {
|
||||||
|
result.Reason = "您已拥有该产品的有效授权"
|
||||||
|
}
|
||||||
|
result.ExistingLicenseKey = status.LicenseKey
|
||||||
|
result.ExistingPlanType = status.PlanType
|
||||||
|
result.CanUpgrade = true
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 0, "data": result})
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckPurchaseStatus 查询购买状态(公开接口) // Logan.Li
|
||||||
|
func (ctrl *OrderController) CheckPurchaseStatus(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
CustomerEmail string `json:"customer_email" binding:"required,email"`
|
||||||
|
ProductID uint `json:"product_id" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp, _ := ctrl.svc.CheckPurchaseStatus(req.CustomerEmail, req.ProductID)
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 0, "data": resp})
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
package plan
|
||||||
|
|
||||||
|
import (
|
||||||
|
planservice "backend/internal/service/plan"
|
||||||
|
"backend/utils"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PlanController 定价计划控制器 // Logan.Li
|
||||||
|
type PlanController struct {
|
||||||
|
svc *planservice.PlanService
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPlanController 创建控制器实例 // Logan.Li
|
||||||
|
func NewPlanController(db *gorm.DB) *PlanController {
|
||||||
|
return &PlanController{svc: planservice.NewPlanService(db), db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// List 计划列表 // Logan.Li
|
||||||
|
func (ctrl *PlanController) List(c *gin.Context) {
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "10"))
|
||||||
|
productID, _ := strconv.ParseUint(c.Query("product_id"), 10, 32)
|
||||||
|
|
||||||
|
plans, total, err := ctrl.svc.List(page, pageSize, uint(productID))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 0, "data": gin.H{
|
||||||
|
"list": plans, "total": total, "page": page, "page_size": pageSize,
|
||||||
|
"total_pages": (total + int64(pageSize) - 1) / int64(pageSize),
|
||||||
|
}})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByProduct 查询某产品的上架计划(公开接口) // Logan.Li
|
||||||
|
func (ctrl *PlanController) GetByProduct(c *gin.Context) {
|
||||||
|
productID, _ := strconv.ParseUint(c.Param("product_id"), 10, 32)
|
||||||
|
plans, err := ctrl.svc.GetByProduct(uint(productID))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 1, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 0, "data": plans})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create 创建计划 // Logan.Li
|
||||||
|
func (ctrl *PlanController) Create(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
ProductID uint `json:"product_id" binding:"required"`
|
||||||
|
Name string `json:"name" binding:"required"`
|
||||||
|
PlanType string `json:"plan_type" binding:"required"`
|
||||||
|
Price float64 `json:"price" binding:"required"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
Duration int `json:"duration"`
|
||||||
|
MaxDevices int `json:"max_devices"`
|
||||||
|
Features []string `json:"features"`
|
||||||
|
SortOrder int `json:"sort_order"`
|
||||||
|
Status *int `json:"status"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
plan, err := ctrl.svc.Create(req)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 1, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.LogAction(ctrl.db, c, "create", "plan", plan.ID, plan.Name, "", "", "创建定价计划")
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "创建成功", "data": plan})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update 更新计划 // Logan.Li
|
||||||
|
func (ctrl *PlanController) Update(c *gin.Context) {
|
||||||
|
id, _ := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
var req struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Price *float64 `json:"price"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
Duration *int `json:"duration"`
|
||||||
|
MaxDevices *int `json:"max_devices"`
|
||||||
|
Features []string `json:"features"`
|
||||||
|
SortOrder *int `json:"sort_order"`
|
||||||
|
Status *int `json:"status"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
plan, err := ctrl.svc.Update(uint(id), req)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 1, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.LogAction(ctrl.db, c, "update", "plan", plan.ID, plan.Name, "", "", "更新定价计划")
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "更新成功", "data": plan})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete 删除计划 // Logan.Li
|
||||||
|
func (ctrl *PlanController) Delete(c *gin.Context) {
|
||||||
|
id, _ := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
if err := ctrl.svc.Delete(uint(id)); err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 1, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.LogAction(ctrl.db, c, "delete", "plan", uint(id), "", "", "", "删除定价计划")
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "删除成功"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToggleStatus 切换上下架 // Logan.Li
|
||||||
|
func (ctrl *PlanController) ToggleStatus(c *gin.Context) {
|
||||||
|
id, _ := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
var req struct {
|
||||||
|
Status int `json:"status"`
|
||||||
|
}
|
||||||
|
c.ShouldBindJSON(&req)
|
||||||
|
if err := ctrl.svc.ToggleStatus(uint(id), req.Status); err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 1, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.LogAction(ctrl.db, c, "toggle_status", "plan", uint(id), "", "", "", "切换计划状态")
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "状态更新成功"})
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
package product
|
||||||
|
|
||||||
|
import (
|
||||||
|
productservice "backend/internal/service/product"
|
||||||
|
"backend/utils"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ProductController 产品控制器 // Logan.Li
|
||||||
|
type ProductController struct {
|
||||||
|
svc *productservice.ProductService
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewProductController 创建控制器实例 // Logan.Li
|
||||||
|
func NewProductController(db *gorm.DB) *ProductController {
|
||||||
|
return &ProductController{svc: productservice.NewProductService(db), db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// List 产品列表 // Logan.Li
|
||||||
|
func (ctrl *ProductController) List(c *gin.Context) {
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "10"))
|
||||||
|
keyword := c.Query("keyword")
|
||||||
|
|
||||||
|
products, total, err := ctrl.svc.List(page, pageSize, keyword)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 0, "data": gin.H{
|
||||||
|
"list": products, "total": total, "page": page, "page_size": pageSize,
|
||||||
|
"total_pages": (total + int64(pageSize) - 1) / int64(pageSize),
|
||||||
|
}})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAll 全部产品(下拉用) // Logan.Li
|
||||||
|
func (ctrl *ProductController) GetAll(c *gin.Context) {
|
||||||
|
products, err := ctrl.svc.GetAll()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 0, "data": products})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get 产品详情 // Logan.Li
|
||||||
|
func (ctrl *ProductController) Get(c *gin.Context) {
|
||||||
|
id, _ := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
product, err := ctrl.svc.GetByID(uint(id))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 404, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 0, "data": product})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create 创建产品 // Logan.Li
|
||||||
|
func (ctrl *ProductController) Create(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
Name string `json:"name" binding:"required"`
|
||||||
|
Code string `json:"code" binding:"required"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Features []string `json:"features"`
|
||||||
|
Price float64 `json:"price"`
|
||||||
|
PriceModel string `json:"price_model"`
|
||||||
|
Status *int `json:"status"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
product, err := ctrl.svc.Create(req)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 1, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.LogAction(ctrl.db, c, "create", "product", product.ID, product.Name, "", "", "创建产品")
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "创建成功", "data": product})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update 更新产品 // Logan.Li
|
||||||
|
func (ctrl *ProductController) Update(c *gin.Context) {
|
||||||
|
id, _ := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
var req struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Features []string `json:"features"`
|
||||||
|
Price *float64 `json:"price"`
|
||||||
|
PriceModel string `json:"price_model"`
|
||||||
|
Status *int `json:"status"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
product, err := ctrl.svc.Update(uint(id), req)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 1, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.LogAction(ctrl.db, c, "update", "product", product.ID, product.Name, "", "", "更新产品")
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "更新成功", "data": product})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete 删除产品 // Logan.Li
|
||||||
|
func (ctrl *ProductController) Delete(c *gin.Context) {
|
||||||
|
id, _ := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
if err := ctrl.svc.Delete(uint(id)); err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 1, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.LogAction(ctrl.db, c, "delete", "product", uint(id), "", "", "", "删除产品")
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "删除成功"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateStatus 切换上下架 // Logan.Li
|
||||||
|
func (ctrl *ProductController) UpdateStatus(c *gin.Context) {
|
||||||
|
id, _ := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
var req struct {
|
||||||
|
Status int `json:"status"`
|
||||||
|
}
|
||||||
|
c.ShouldBindJSON(&req)
|
||||||
|
if err := ctrl.svc.UpdateStatus(uint(id), req.Status); err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 1, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.LogAction(ctrl.db, c, "update_status", "product", uint(id), "", "", "", fmt.Sprintf("切换状态为 %d", req.Status))
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "状态更新成功"})
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package setting
|
||||||
|
|
||||||
|
import (
|
||||||
|
"backend/internal/model"
|
||||||
|
settingservice "backend/internal/service/setting"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SettingController 系统设置控制器 // Logan.Li
|
||||||
|
type SettingController struct {
|
||||||
|
svc *settingservice.SettingService
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSettingController 创建控制器实例 // Logan.Li
|
||||||
|
func NewSettingController(db *gorm.DB) *SettingController {
|
||||||
|
return &SettingController{svc: settingservice.NewSettingService(db)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get 获取系统设置 // Logan.Li
|
||||||
|
func (ctrl *SettingController) Get(c *gin.Context) {
|
||||||
|
setting, err := ctrl.svc.Get()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 404, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 0, "data": setting})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update 更新系统设置 // Logan.Li
|
||||||
|
func (ctrl *SettingController) Update(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
SystemName *string `json:"system_name"`
|
||||||
|
DefaultDuration *int `json:"default_duration"`
|
||||||
|
DefaultMaxActivations *int `json:"default_max_activations"`
|
||||||
|
ExpireReminderDays *int `json:"expire_reminder_days"`
|
||||||
|
APIRateLimit *int `json:"api_rate_limit"`
|
||||||
|
LogRetentionDays *int `json:"log_retention_days"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setting, err := ctrl.svc.Update(req)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 1, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "更新成功", "data": setting})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAuditLogs 审计日志列表 // Logan.Li
|
||||||
|
func (ctrl *SettingController) ListAuditLogs(c *gin.Context) {
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "10"))
|
||||||
|
action := c.Query("action")
|
||||||
|
targetType := c.Query("target_type")
|
||||||
|
|
||||||
|
logs, total, err := ctrl.svc.ListAuditLogs(page, pageSize, action, targetType)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 0, "data": gin.H{
|
||||||
|
"list": logs, "total": total, "page": page, "page_size": pageSize,
|
||||||
|
"total_pages": (total + int64(pageSize) - 1) / int64(pageSize),
|
||||||
|
}})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearAuditLogs 清空审计日志 // Logan.Li
|
||||||
|
func (ctrl *SettingController) ClearAuditLogs(c *gin.Context) {
|
||||||
|
if err := ctrl.svc.ClearAuditLogs(); err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 1, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "日志已清空"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListLicenseTemplates 查询 License 模板 // Logan.Li
|
||||||
|
func (ctrl *SettingController) ListLicenseTemplates(c *gin.Context) {
|
||||||
|
templates, err := ctrl.svc.ListLicenseTemplates()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 0, "data": templates})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveLicenseTemplate 创建/更新 License 模板 // Logan.Li
|
||||||
|
func (ctrl *SettingController) SaveLicenseTemplate(c *gin.Context) {
|
||||||
|
var tmpl model.LicenseTemplate
|
||||||
|
if err := c.ShouldBindJSON(&tmpl); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := ctrl.svc.SaveLicenseTemplate(tmpl)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 1, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "保存成功", "data": result})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteLicenseTemplate 删除 License 模板 // Logan.Li
|
||||||
|
func (ctrl *SettingController) DeleteLicenseTemplate(c *gin.Context) {
|
||||||
|
id, _ := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
if err := ctrl.svc.DeleteLicenseTemplate(uint(id)); err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 1, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "删除成功"})
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
// DashboardStats 仪表板统计响应 // Logan.Li
|
||||||
|
type DashboardStats struct {
|
||||||
|
TotalLicenses int64 `json:"total_licenses"`
|
||||||
|
ActiveLicenses int64 `json:"active_licenses"`
|
||||||
|
NewThisMonth int64 `json:"new_this_month"`
|
||||||
|
ExpiringSoon int64 `json:"expiring_soon"`
|
||||||
|
TotalCustomers int64 `json:"total_customers"`
|
||||||
|
TotalProducts int64 `json:"total_products"`
|
||||||
|
OnlineDevices int64 `json:"online_devices"`
|
||||||
|
MonthlyRevenue float64 `json:"monthly_revenue"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExpiringLicenseItem 即将到期 License // Logan.Li
|
||||||
|
type ExpiringLicenseItem struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
LicenseKey string `json:"license_key"`
|
||||||
|
CustomerName string `json:"customer_name"`
|
||||||
|
ProductName string `json:"product_name"`
|
||||||
|
EndDate string `json:"end_date"`
|
||||||
|
DaysLeft int `json:"days_left"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecentActivityItem 最近活动 // Logan.Li
|
||||||
|
type RecentActivityItem struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Action string `json:"action"`
|
||||||
|
TargetType string `json:"target_type"`
|
||||||
|
TargetName string `json:"target_name"`
|
||||||
|
Detail string `json:"detail"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LicenseGrowthPoint License 增长趋势点 // Logan.Li
|
||||||
|
type LicenseGrowthPoint struct {
|
||||||
|
Date string `json:"date"`
|
||||||
|
Count int64 `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevenuePoint 收入趋势点 // Logan.Li
|
||||||
|
type RevenuePoint struct {
|
||||||
|
Period string `json:"period"`
|
||||||
|
Amount float64 `json:"amount"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProductDistribution 产品分布 // Logan.Li
|
||||||
|
type ProductDistribution struct {
|
||||||
|
ProductName string `json:"product_name"`
|
||||||
|
Count int64 `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SettingUpdate 更新系统设置请求 // Logan.Li
|
||||||
|
type SettingUpdate struct {
|
||||||
|
SystemName *string `json:"system_name"`
|
||||||
|
DefaultDuration *int `json:"default_duration"`
|
||||||
|
DefaultMaxActivations *int `json:"default_max_activations"`
|
||||||
|
ExpireReminderDays *int `json:"expire_reminder_days"`
|
||||||
|
APIRateLimit *int `json:"api_rate_limit"`
|
||||||
|
LogRetentionDays *int `json:"log_retention_days"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuditLogResponse 操作日志响应 // Logan.Li
|
||||||
|
type AuditLogResponse struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Action string `json:"action"`
|
||||||
|
TargetType string `json:"target_type"`
|
||||||
|
TargetID uint `json:"target_id"`
|
||||||
|
TargetName string `json:"target_name"`
|
||||||
|
Detail string `json:"detail"`
|
||||||
|
Result string `json:"result"`
|
||||||
|
IP string `json:"ip"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
// LoginRequest 登录请求 // Logan.Li
|
||||||
|
type LoginRequest struct {
|
||||||
|
Username string `json:"username" binding:"required"`
|
||||||
|
Password string `json:"password" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoginResponse 登录响应 // Logan.Li
|
||||||
|
type LoginResponse struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
UserInfo UserInfoResponse `json:"user_info"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserInfoResponse 用户信息响应 // Logan.Li
|
||||||
|
type UserInfoResponse struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Avatar string `json:"avatar"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChangePasswordRequest 修改密码请求 // Logan.Li
|
||||||
|
type ChangePasswordRequest struct {
|
||||||
|
OldPassword string `json:"old_password" binding:"required"`
|
||||||
|
NewPassword string `json:"new_password" binding:"required,min=6"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
// PlanCreate 创建定价计划请求 // Logan.Li
|
||||||
|
type PlanCreate struct {
|
||||||
|
ProductID uint `json:"product_id" binding:"required"`
|
||||||
|
Name string `json:"name" binding:"required"`
|
||||||
|
PlanType string `json:"plan_type" binding:"required"` // perpetual / monthly / yearly / lifetime
|
||||||
|
Price float64 `json:"price" binding:"required"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
Duration int `json:"duration"`
|
||||||
|
MaxDevices int `json:"max_devices"`
|
||||||
|
Features []string `json:"features"`
|
||||||
|
SortOrder int `json:"sort_order"`
|
||||||
|
Status *int `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlanUpdate 更新定价计划请求 // Logan.Li
|
||||||
|
type PlanUpdate struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Price *float64 `json:"price"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
Duration *int `json:"duration"`
|
||||||
|
MaxDevices *int `json:"max_devices"`
|
||||||
|
Features []string `json:"features"`
|
||||||
|
SortOrder *int `json:"sort_order"`
|
||||||
|
Status *int `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlanResponse 定价计划响应 // Logan.Li
|
||||||
|
type PlanResponse struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
ProductID uint `json:"product_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
PlanType string `json:"plan_type"`
|
||||||
|
Price float64 `json:"price"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
Duration int `json:"duration"`
|
||||||
|
MaxDevices int `json:"max_devices"`
|
||||||
|
Features []string `json:"features"`
|
||||||
|
SortOrder int `json:"sort_order"`
|
||||||
|
Status int `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OrderResponse 订单响应 // Logan.Li
|
||||||
|
type OrderResponse struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
OrderNo string `json:"order_no"`
|
||||||
|
CustomerID uint `json:"customer_id"`
|
||||||
|
CustomerName string `json:"customer_name"`
|
||||||
|
ProductID uint `json:"product_id"`
|
||||||
|
ProductName string `json:"product_name"`
|
||||||
|
PlanID uint `json:"plan_id"`
|
||||||
|
PlanName string `json:"plan_name"`
|
||||||
|
LicenseID uint `json:"license_id"`
|
||||||
|
Amount float64 `json:"amount"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
PaymentGateway string `json:"payment_gateway"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
PaidAt string `json:"paid_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateOrderRequest 创建订单请求(客户端购买入口) // Logan.Li
|
||||||
|
type CreateOrderRequest struct {
|
||||||
|
CustomerEmail string `json:"customer_email" binding:"required,email"`
|
||||||
|
CustomerName string `json:"customer_name"`
|
||||||
|
ProductID uint `json:"product_id" binding:"required"`
|
||||||
|
PlanID uint `json:"plan_id" binding:"required"`
|
||||||
|
DeviceID string `json:"device_id"`
|
||||||
|
ReturnURL string `json:"return_url"`
|
||||||
|
CancelURL string `json:"cancel_url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateOrderResponse 创建订单响应 // Logan.Li
|
||||||
|
type CreateOrderResponse struct {
|
||||||
|
OrderNo string `json:"order_no"`
|
||||||
|
CheckoutURL string `json:"checkout_url"`
|
||||||
|
Amount float64 `json:"amount"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Gateway string `json:"gateway"`
|
||||||
|
ExpiresAt string `json:"expires_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PaymentWebhookRequest 支付回调请求 // Logan.Li
|
||||||
|
type PaymentWebhookRequest struct {
|
||||||
|
EventType string `json:"event_type" binding:"required"`
|
||||||
|
OrderNo string `json:"order_no" binding:"required"`
|
||||||
|
GatewayOrderNo string `json:"gateway_order_no"`
|
||||||
|
Status string `json:"status" binding:"required"` // paid / failed / refunded
|
||||||
|
Amount float64 `json:"amount"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
CustomerEmail string `json:"customer_email"`
|
||||||
|
Gateway string `json:"gateway" binding:"required"`
|
||||||
|
Signature string `json:"signature"`
|
||||||
|
Payload string `json:"payload"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ManualGrantRequest 手动发放 License 请求(管理后台) // Logan.Li
|
||||||
|
type ManualGrantRequest struct {
|
||||||
|
CustomerEmail string `json:"customer_email" binding:"required,email"`
|
||||||
|
CustomerName string `json:"customer_name"`
|
||||||
|
ProductID uint `json:"product_id" binding:"required"`
|
||||||
|
PlanID uint `json:"plan_id" binding:"required"`
|
||||||
|
DeviceID string `json:"device_id"`
|
||||||
|
Notes string `json:"notes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// EntitlementResponse 客户权益响应 // Logan.Li
|
||||||
|
type EntitlementResponse struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
CustomerID uint `json:"customer_id"`
|
||||||
|
CustomerName string `json:"customer_name"`
|
||||||
|
ProductID uint `json:"product_id"`
|
||||||
|
ProductName string `json:"product_name"`
|
||||||
|
PlanID uint `json:"plan_id"`
|
||||||
|
PlanName string `json:"plan_name"`
|
||||||
|
PlanType string `json:"plan_type"`
|
||||||
|
LicenseKey string `json:"license_key"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
ValidFrom string `json:"valid_from"`
|
||||||
|
ValidUntil *string `json:"valid_until,omitempty"`
|
||||||
|
DaysLeft int `json:"days_left"`
|
||||||
|
Renewable bool `json:"renewable"`
|
||||||
|
MaxDevices int `json:"max_devices"`
|
||||||
|
CurrentDevices int `json:"current_devices"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DuplicatePurchaseCheckRequest 防重复购买检测请求 // Logan.Li
|
||||||
|
type DuplicatePurchaseCheckRequest struct {
|
||||||
|
CustomerEmail string `json:"customer_email" binding:"required,email"`
|
||||||
|
ProductID uint `json:"product_id" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DuplicatePurchaseCheckResponse 防重复购买检测响应 // Logan.Li
|
||||||
|
type DuplicatePurchaseCheckResponse struct {
|
||||||
|
AllowPurchase bool `json:"allow_purchase"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
ExistingEntitlementID uint `json:"existing_entitlement_id,omitempty"`
|
||||||
|
ExistingLicenseKey string `json:"existing_license_key,omitempty"`
|
||||||
|
ExistingPlanType string `json:"existing_plan_type,omitempty"`
|
||||||
|
CanUpgrade bool `json:"can_upgrade"`
|
||||||
|
UpgradeOptions []PlanResponse `json:"upgrade_options,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckPurchaseStatusRequest 购买状态查询请求 // Logan.Li
|
||||||
|
type CheckPurchaseStatusRequest struct {
|
||||||
|
CustomerEmail string `json:"customer_email" binding:"required,email"`
|
||||||
|
ProductID uint `json:"product_id" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckPurchaseStatusResponse 购买状态查询响应 // Logan.Li
|
||||||
|
type CheckPurchaseStatusResponse struct {
|
||||||
|
Purchased bool `json:"purchased"`
|
||||||
|
HasActiveLicense bool `json:"has_active_license"`
|
||||||
|
LicenseKey string `json:"license_key,omitempty"`
|
||||||
|
LicenseStatus string `json:"license_status,omitempty"`
|
||||||
|
PlanType string `json:"plan_type,omitempty"`
|
||||||
|
ValidUntil *string `json:"valid_until,omitempty"`
|
||||||
|
DaysLeft int `json:"days_left,omitempty"`
|
||||||
|
Renewable bool `json:"renewable"`
|
||||||
|
MaxDevices int `json:"max_devices,omitempty"`
|
||||||
|
CurrentDevices int `json:"current_devices,omitempty"`
|
||||||
|
EntitlementID uint `json:"entitlement_id,omitempty"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
// Response 统一响应结构 // Logan.Li
|
||||||
|
type Response struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Data interface{} `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PaginatedResponse 分页响应结构 // Logan.Li
|
||||||
|
type PaginatedResponse struct {
|
||||||
|
Count int `json:"count"`
|
||||||
|
TotalPages int `json:"total_pages"`
|
||||||
|
CurrentPage int `json:"current_page"`
|
||||||
|
PageSize int `json:"page_size"`
|
||||||
|
Next *string `json:"next"`
|
||||||
|
Previous *string `json:"previous"`
|
||||||
|
Results interface{} `json:"results"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
// LicenseCreate 创建 License 请求 // Logan.Li
|
||||||
|
type LicenseCreate struct {
|
||||||
|
CustomerID uint `json:"customer_id" binding:"required"`
|
||||||
|
ProductID uint `json:"product_id" binding:"required"`
|
||||||
|
Duration int `json:"duration" binding:"required"` // 30/90/180/365/730/1095/1825/0(永久)
|
||||||
|
MaxActivations int `json:"max_activations"`
|
||||||
|
IsTrial bool `json:"is_trial"`
|
||||||
|
TrialDays int `json:"trial_days"`
|
||||||
|
Notes string `json:"notes"`
|
||||||
|
Count int `json:"count"` // 批量生成数量
|
||||||
|
}
|
||||||
|
|
||||||
|
// LicenseRenew 续期请求 // Logan.Li
|
||||||
|
type LicenseRenew struct {
|
||||||
|
Duration int `json:"duration" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LicenseRevoke 吊销请求 // Logan.Li
|
||||||
|
type LicenseRevoke struct {
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LicenseResponse License 响应 // Logan.Li
|
||||||
|
type LicenseResponse struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
LicenseKey string `json:"license_key"`
|
||||||
|
CustomerID uint `json:"customer_id"`
|
||||||
|
CustomerName string `json:"customer_name"`
|
||||||
|
ProductID uint `json:"product_id"`
|
||||||
|
ProductName string `json:"product_name"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
StartDate string `json:"start_date"`
|
||||||
|
EndDate string `json:"end_date"`
|
||||||
|
Duration int `json:"duration"`
|
||||||
|
MaxActivations int `json:"max_activations"`
|
||||||
|
CurrentActivations int `json:"current_activations"`
|
||||||
|
IsTrial bool `json:"is_trial"`
|
||||||
|
DaysLeft int `json:"days_left"`
|
||||||
|
Notes string `json:"notes"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 客户端授权契约(无需认证) ============
|
||||||
|
|
||||||
|
// LicenseVerifyRequest 验证请求(客户端调用) // Logan.Li
|
||||||
|
type LicenseVerifyRequest struct {
|
||||||
|
LicenseKey string `json:"license_key" binding:"required"`
|
||||||
|
DeviceID string `json:"device_id"`
|
||||||
|
DeviceName string `json:"device_name"`
|
||||||
|
DeviceInfo string `json:"device_info"`
|
||||||
|
ProductID uint `json:"product_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LicenseVerifyResponse 验证响应 // Logan.Li
|
||||||
|
type LicenseVerifyResponse struct {
|
||||||
|
Valid bool `json:"valid"`
|
||||||
|
LicenseKey string `json:"license_key,omitempty"`
|
||||||
|
ProductID uint `json:"product_id,omitempty"`
|
||||||
|
ProductName string `json:"product_name,omitempty"`
|
||||||
|
ProductCode string `json:"product_code,omitempty"`
|
||||||
|
Features []string `json:"features,omitempty"`
|
||||||
|
Status string `json:"status,omitempty"`
|
||||||
|
ExpiresAt string `json:"expires_at,omitempty"`
|
||||||
|
DaysLeft int `json:"days_left,omitempty"`
|
||||||
|
MaxDevices int `json:"max_devices,omitempty"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LicenseActivateRequest 激活请求(客户端调用) // Logan.Li
|
||||||
|
type LicenseActivateRequest struct {
|
||||||
|
LicenseKey string `json:"license_key" binding:"required"`
|
||||||
|
DeviceID string `json:"device_id" binding:"required"`
|
||||||
|
DeviceName string `json:"device_name"`
|
||||||
|
DeviceInfo string `json:"device_info"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LicenseDeactivateRequest 停用请求(客户端调用) // Logan.Li
|
||||||
|
type LicenseDeactivateRequest struct {
|
||||||
|
LicenseKey string `json:"license_key" binding:"required"`
|
||||||
|
DeviceID string `json:"device_id" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LicenseHeartbeatRequest 心跳请求(客户端调用) // Logan.Li
|
||||||
|
type LicenseHeartbeatRequest struct {
|
||||||
|
LicenseKey string `json:"license_key" binding:"required"`
|
||||||
|
DeviceID string `json:"device_id" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActivationResponse 激活记录响应 // Logan.Li
|
||||||
|
type ActivationResponse struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
LicenseID uint `json:"license_id"`
|
||||||
|
DeviceID string `json:"device_id"`
|
||||||
|
DeviceName string `json:"device_name"`
|
||||||
|
DeviceInfo string `json:"device_info"`
|
||||||
|
IP string `json:"ip"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
IsOnline bool `json:"is_online"`
|
||||||
|
ActivatedAt string `json:"activated_at"`
|
||||||
|
LastSeenAt string `json:"last_seen_at"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
// ProductCreate 创建产品请求 // Logan.Li
|
||||||
|
type ProductCreate struct {
|
||||||
|
Name string `json:"name" binding:"required"`
|
||||||
|
Code string `json:"code" binding:"required"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Features []string `json:"features"`
|
||||||
|
Price float64 `json:"price"`
|
||||||
|
PriceModel string `json:"price_model"`
|
||||||
|
Status *int `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProductUpdate 更新产品请求 // Logan.Li
|
||||||
|
type ProductUpdate struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Features []string `json:"features"`
|
||||||
|
Price *float64 `json:"price"`
|
||||||
|
PriceModel string `json:"price_model"`
|
||||||
|
Status *int `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProductResponse 产品响应 // Logan.Li
|
||||||
|
type ProductResponse struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Features []string `json:"features"`
|
||||||
|
Price float64 `json:"price"`
|
||||||
|
PriceModel string `json:"price_model"`
|
||||||
|
Status int `json:"status"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CustomerCreate 创建客户请求 // Logan.Li
|
||||||
|
type CustomerCreate struct {
|
||||||
|
Name string `json:"name" binding:"required"`
|
||||||
|
Company string `json:"company"`
|
||||||
|
Email string `json:"email" binding:"omitempty,email"`
|
||||||
|
Phone string `json:"phone"`
|
||||||
|
Address string `json:"address"`
|
||||||
|
Notes string `json:"notes"`
|
||||||
|
Status *int `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CustomerUpdate 更新客户请求 // Logan.Li
|
||||||
|
type CustomerUpdate struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Company string `json:"company"`
|
||||||
|
Email string `json:"email" binding:"omitempty,email"`
|
||||||
|
Phone string `json:"phone"`
|
||||||
|
Address string `json:"address"`
|
||||||
|
Notes string `json:"notes"`
|
||||||
|
Status *int `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CustomerResponse 客户响应 // Logan.Li
|
||||||
|
type CustomerResponse struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Company string `json:"company"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Phone string `json:"phone"`
|
||||||
|
Address string `json:"address"`
|
||||||
|
Notes string `json:"notes"`
|
||||||
|
Status int `json:"status"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// AuditLog 操作日志模型 // Logan.Li
|
||||||
|
type AuditLog struct {
|
||||||
|
ID uint `json:"id" gorm:"primaryKey"`
|
||||||
|
UserID uint `json:"user_id"`
|
||||||
|
Username string `json:"username" gorm:"size:100"`
|
||||||
|
Action string `json:"action" gorm:"size:50;not null"` // create / update / delete / activate / renew / revoke ...
|
||||||
|
TargetType string `json:"target_type" gorm:"size:50"` // license / customer / product / order ...
|
||||||
|
TargetID uint `json:"target_id"`
|
||||||
|
TargetName string `json:"target_name" gorm:"size:200"`
|
||||||
|
OldData string `json:"old_data" gorm:"type:text"` // JSON
|
||||||
|
NewData string `json:"new_data" gorm:"type:text"` // JSON
|
||||||
|
Detail string `json:"detail" gorm:"type:text"`
|
||||||
|
Result string `json:"result" gorm:"size:20"` // success / fail
|
||||||
|
ErrorMessage string `json:"error_message" gorm:"type:text"`
|
||||||
|
IP string `json:"ip" gorm:"size:50"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName 指定表名 // Logan.Li
|
||||||
|
func (AuditLog) TableName() string {
|
||||||
|
return "audit_logs"
|
||||||
|
}
|
||||||
|
|
||||||
|
// LicenseTemplate License 模板(用于快速创建) // Logan.Li
|
||||||
|
type LicenseTemplate struct {
|
||||||
|
ID uint `json:"id" gorm:"primaryKey"`
|
||||||
|
Name string `json:"name" gorm:"size:100;not null"`
|
||||||
|
Duration int `json:"duration" gorm:"default:365"` // 天数,0 表示永久
|
||||||
|
MaxActivations int `json:"max_activations" gorm:"default:1"`
|
||||||
|
Description string `json:"description" gorm:"type:text"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName 指定表名 // Logan.Li
|
||||||
|
func (LicenseTemplate) TableName() string {
|
||||||
|
return "license_templates"
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Customer 客户模型 // Logan.Li
|
||||||
|
type Customer struct {
|
||||||
|
ID uint `json:"id" gorm:"primaryKey"`
|
||||||
|
Name string `json:"name" gorm:"size:100;not null"`
|
||||||
|
Company string `json:"company" gorm:"size:200"`
|
||||||
|
Email string `json:"email" gorm:"size:100;index"`
|
||||||
|
Phone string `json:"phone" gorm:"size:20"`
|
||||||
|
Address string `json:"address" gorm:"size:500"`
|
||||||
|
Notes string `json:"notes" gorm:"type:text"`
|
||||||
|
Status int `json:"status" gorm:"default:1"` // 1: 启用, 0: 禁用
|
||||||
|
Licenses []License `json:"licenses,omitempty" gorm:"foreignKey:CustomerID"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
DeletedAt gorm.DeletedAt `json:"deleted_at,omitempty" gorm:"index"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName 指定表名 // Logan.Li
|
||||||
|
func (Customer) TableName() string {
|
||||||
|
return "customers"
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// CustomerEntitlement 客户权益记录(防重复购买的核心表) // Logan.Li
|
||||||
|
type CustomerEntitlement struct {
|
||||||
|
ID uint `json:"id" gorm:"primaryKey"`
|
||||||
|
CustomerID uint `json:"customer_id"`
|
||||||
|
Customer Customer `json:"customer,omitempty" gorm:"foreignKey:CustomerID"`
|
||||||
|
ProductID uint `json:"product_id"`
|
||||||
|
Product Product `json:"product,omitempty" gorm:"foreignKey:ProductID"`
|
||||||
|
PlanID uint `json:"plan_id"`
|
||||||
|
Plan Plan `json:"plan,omitempty" gorm:"foreignKey:PlanID"`
|
||||||
|
LicenseID uint `json:"license_id"`
|
||||||
|
LicenseKey string `json:"license_key" gorm:"size:100"`
|
||||||
|
PlanType string `json:"plan_type" gorm:"size:50"` // perpetual / monthly / yearly / lifetime
|
||||||
|
Status string `json:"status" gorm:"size:20;default:'active'"` // active / expired / revoked
|
||||||
|
ValidFrom time.Time `json:"valid_from"`
|
||||||
|
ValidUntil *time.Time `json:"valid_until,omitempty"` // 买断/永久为 null
|
||||||
|
Renewable bool `json:"renewable" gorm:"default:true"` // 订阅到期后是否可续费
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName 指定表名 // Logan.Li
|
||||||
|
func (CustomerEntitlement) TableName() string {
|
||||||
|
return "customer_entitlements"
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// License 期限常量(天数,0 表示永久) // Logan.Li
|
||||||
|
const (
|
||||||
|
Duration1Month = 30
|
||||||
|
Duration3Months = 90
|
||||||
|
Duration6Months = 180
|
||||||
|
Duration1Year = 365
|
||||||
|
Duration2Years = 730
|
||||||
|
Duration3Years = 1095
|
||||||
|
Duration5Years = 1825
|
||||||
|
DurationForever = 0
|
||||||
|
)
|
||||||
|
|
||||||
|
// License 状态常量 // Logan.Li
|
||||||
|
const (
|
||||||
|
LicenseStatusInactive = "inactive"
|
||||||
|
LicenseStatusActive = "active"
|
||||||
|
LicenseStatusExpired = "expired"
|
||||||
|
LicenseStatusRevoked = "revoked"
|
||||||
|
LicenseStatusSuspended = "suspended"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PermanentEndDate 永久授权的到期时间(2099-12-31) // Logan.Li
|
||||||
|
var PermanentEndDate = time.Date(2099, 12, 31, 23, 59, 59, 0, time.UTC)
|
||||||
|
|
||||||
|
// License 许可证模型 // Logan.Li
|
||||||
|
type License struct {
|
||||||
|
ID uint `json:"id" gorm:"primaryKey"`
|
||||||
|
LicenseKey string `json:"license_key" gorm:"size:100;uniqueIndex;not null"`
|
||||||
|
CustomerID uint `json:"customer_id"`
|
||||||
|
Customer Customer `json:"customer,omitempty" gorm:"foreignKey:CustomerID"`
|
||||||
|
ProductID uint `json:"product_id"`
|
||||||
|
Product Product `json:"product,omitempty" gorm:"foreignKey:ProductID"`
|
||||||
|
Status string `json:"status" gorm:"size:20;default:'inactive'"`
|
||||||
|
StartDate time.Time `json:"start_date"`
|
||||||
|
EndDate time.Time `json:"end_date"`
|
||||||
|
Duration int `json:"duration" gorm:"default:365"` // 天数,0 表示永久
|
||||||
|
MaxActivations int `json:"max_activations" gorm:"default:1"`
|
||||||
|
CurrentActivations int `json:"current_activations" gorm:"default:0"`
|
||||||
|
IsTrial bool `json:"is_trial" gorm:"default:false"`
|
||||||
|
TrialDays int `json:"trial_days" gorm:"default:0"`
|
||||||
|
RevokeReason string `json:"revoke_reason" gorm:"type:text"`
|
||||||
|
Notes string `json:"notes" gorm:"type:text"`
|
||||||
|
Activations []Activation `json:"activations,omitempty" gorm:"foreignKey:LicenseID"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
DeletedAt gorm.DeletedAt `json:"deleted_at,omitempty" gorm:"index"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName 指定表名 // Logan.Li
|
||||||
|
func (License) TableName() string {
|
||||||
|
return "licenses"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Activation 激活记录模型(机器绑定) // Logan.Li
|
||||||
|
type Activation struct {
|
||||||
|
ID uint `json:"id" gorm:"primaryKey"`
|
||||||
|
LicenseID uint `json:"license_id"`
|
||||||
|
License License `json:"license,omitempty" gorm:"foreignKey:LicenseID"`
|
||||||
|
DeviceID string `json:"device_id" gorm:"size:100"`
|
||||||
|
DeviceName string `json:"device_name" gorm:"size:200"`
|
||||||
|
DeviceInfo string `json:"device_info" gorm:"type:text"` // JSON 格式存储设备信息
|
||||||
|
IP string `json:"ip" gorm:"size:50"`
|
||||||
|
Status string `json:"status" gorm:"size:20;default:'active'"` // active / inactive
|
||||||
|
IsOnline bool `json:"is_online" gorm:"default:false"`
|
||||||
|
ActivatedAt time.Time `json:"activated_at"`
|
||||||
|
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||||
|
LastSeenAt time.Time `json:"last_seen_at"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName 指定表名 // Logan.Li
|
||||||
|
func (Activation) TableName() string {
|
||||||
|
return "activations"
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// Order 订单模型 // Logan.Li
|
||||||
|
type Order struct {
|
||||||
|
ID uint `json:"id" gorm:"primaryKey"`
|
||||||
|
OrderNo string `json:"order_no" gorm:"size:100;uniqueIndex;not null"` // 系统唯一订单号(幂等键)
|
||||||
|
CustomerID uint `json:"customer_id"`
|
||||||
|
Customer Customer `json:"customer,omitempty" gorm:"foreignKey:CustomerID"`
|
||||||
|
ProductID uint `json:"product_id"`
|
||||||
|
Product Product `json:"product,omitempty" gorm:"foreignKey:ProductID"`
|
||||||
|
PlanID uint `json:"plan_id"`
|
||||||
|
Plan Plan `json:"plan,omitempty" gorm:"foreignKey:PlanID"`
|
||||||
|
LicenseID uint `json:"license_id"` // 关联的 License ID
|
||||||
|
Amount float64 `json:"amount" gorm:"type:decimal(10,2);not null"`
|
||||||
|
Currency string `json:"currency" gorm:"size:3;default:'USD'"`
|
||||||
|
Status string `json:"status" gorm:"size:20;default:'pending'"` // pending / paid / failed / refunded / cancelled
|
||||||
|
PaymentMethod string `json:"payment_method" gorm:"size:50"` // paddle / stripe / creem / waffo / alipay / wechat
|
||||||
|
PaymentGateway string `json:"payment_gateway" gorm:"size:50"`
|
||||||
|
GatewayOrderNo string `json:"gateway_order_no" gorm:"size:200"`
|
||||||
|
PaidAt *time.Time `json:"paid_at,omitempty"`
|
||||||
|
RefundAt *time.Time `json:"refund_at,omitempty"`
|
||||||
|
ClientIP string `json:"client_ip" gorm:"size:50"`
|
||||||
|
UserAgent string `json:"user_agent" gorm:"size:500"`
|
||||||
|
Notes string `json:"notes" gorm:"type:text"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName 指定表名 // Logan.Li
|
||||||
|
func (Order) TableName() string {
|
||||||
|
return "orders"
|
||||||
|
}
|
||||||
|
|
||||||
|
// PaymentGatewayConfig 支付网关配置 // Logan.Li
|
||||||
|
type PaymentGatewayConfig struct {
|
||||||
|
Gateway string `json:"gateway" gorm:"size:50;primaryKey"` // paddle / stripe / creem / waffo
|
||||||
|
Enabled bool `json:"enabled" gorm:"default:false"`
|
||||||
|
PublicKey string `json:"public_key" gorm:"type:text"`
|
||||||
|
SecretKey string `json:"secret_key" gorm:"type:text"` // 加密存储
|
||||||
|
WebhookSecret string `json:"webhook_secret" gorm:"type:text"` // Webhook 验签密钥
|
||||||
|
SandboxMode bool `json:"sandbox_mode" gorm:"default:true"`
|
||||||
|
Currency string `json:"currency" gorm:"size:3;default:'USD'"`
|
||||||
|
Region string `json:"region" gorm:"size:50"`
|
||||||
|
Priority int `json:"priority" gorm:"default:0"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName 指定表名 // Logan.Li
|
||||||
|
func (PaymentGatewayConfig) TableName() string {
|
||||||
|
return "payment_gateway_configs"
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// Plan 定价计划模型(支持买断/订阅多档位) // Logan.Li
|
||||||
|
type Plan struct {
|
||||||
|
ID uint `json:"id" gorm:"primaryKey"`
|
||||||
|
ProductID uint `json:"product_id"`
|
||||||
|
Product Product `json:"product,omitempty" gorm:"foreignKey:ProductID"`
|
||||||
|
Name string `json:"name" gorm:"size:100;not null"` // 如:个人版、专业版、企业版
|
||||||
|
PlanType string `json:"plan_type" gorm:"size:50;not null"` // perpetual / monthly / yearly / lifetime
|
||||||
|
Price float64 `json:"price" gorm:"type:decimal(10,2);not null"`
|
||||||
|
Currency string `json:"currency" gorm:"size:3;default:'USD'"` // USD / CNY
|
||||||
|
Duration int `json:"duration" gorm:"default:0"` // 天数:0=永久/买断,30=月,365=年
|
||||||
|
MaxDevices int `json:"max_devices" gorm:"default:1"` // 允许激活设备数
|
||||||
|
Features string `json:"features" gorm:"type:text"` // JSON 功能清单
|
||||||
|
SortOrder int `json:"sort_order" gorm:"default:0"`
|
||||||
|
Status int `json:"status" gorm:"default:1"` // 1: 上架, 0: 下架
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName 指定表名 // Logan.Li
|
||||||
|
func (Plan) TableName() string {
|
||||||
|
return "plans"
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Product 产品模型 // Logan.Li
|
||||||
|
type Product struct {
|
||||||
|
ID uint `json:"id" gorm:"primaryKey"`
|
||||||
|
Name string `json:"name" gorm:"size:100;not null"`
|
||||||
|
Code string `json:"code" gorm:"size:50;uniqueIndex"`
|
||||||
|
Version string `json:"version" gorm:"size:50"`
|
||||||
|
Description string `json:"description" gorm:"type:text"`
|
||||||
|
Features string `json:"features" gorm:"type:text"` // JSON 格式存储功能列表
|
||||||
|
Price float64 `json:"price" gorm:"type:decimal(10,2)"`
|
||||||
|
PriceModel string `json:"price_model" gorm:"size:50;default:'subscription'"` // subscription / perpetual / trial
|
||||||
|
Status int `json:"status" gorm:"default:1"` // 1: 上架, 0: 下架
|
||||||
|
Licenses []License `json:"licenses,omitempty" gorm:"foreignKey:ProductID"`
|
||||||
|
Plans []Plan `json:"plans,omitempty" gorm:"foreignKey:ProductID"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
DeletedAt gorm.DeletedAt `json:"deleted_at,omitempty" gorm:"index"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName 指定表名 // Logan.Li
|
||||||
|
func (Product) TableName() string {
|
||||||
|
return "products"
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// Setting 系统设置模型(单行配置,后续阶段启用) // Logan.Li
|
||||||
|
type Setting struct {
|
||||||
|
ID uint `json:"id" gorm:"primaryKey"`
|
||||||
|
SystemName string `json:"system_name" gorm:"size:100;default:'opsx-licence'"`
|
||||||
|
SystemLogo string `json:"system_logo" gorm:"size:500"`
|
||||||
|
DefaultDuration int `json:"default_duration" gorm:"default:365"` // 默认 License 有效天数
|
||||||
|
DefaultMaxActivations int `json:"default_max_activations" gorm:"default:1"` // 默认最大激活数
|
||||||
|
ExpireReminderDays int `json:"expire_reminder_days" gorm:"default:7"` // 到期提醒天数
|
||||||
|
SMTPServer string `json:"smtp_server" gorm:"size:200"`
|
||||||
|
SMTPPort int `json:"smtp_port"`
|
||||||
|
SMTPUsername string `json:"smtp_username" gorm:"size:100"`
|
||||||
|
SMTPPassword string `json:"smtp_password" gorm:"size:100"`
|
||||||
|
SenderEmail string `json:"sender_email" gorm:"size:100"`
|
||||||
|
SenderName string `json:"sender_name" gorm:"size:100"`
|
||||||
|
APIRateLimit int `json:"api_rate_limit" gorm:"default:1000"`
|
||||||
|
LogRetentionDays int `json:"log_retention_days" gorm:"default:365"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName 指定表名 // Logan.Li
|
||||||
|
func (Setting) TableName() string {
|
||||||
|
return "settings"
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// User 管理员用户模型(单 Admin 表,无 RBAC 多角色) // Logan.Li
|
||||||
|
type User struct {
|
||||||
|
ID uint `json:"id" gorm:"primaryKey"`
|
||||||
|
Username string `json:"username" gorm:"size:100;uniqueIndex;not null"`
|
||||||
|
Password string `json:"-" gorm:"size:255;not null"` // bcrypt 哈希,JSON 响应时隐藏
|
||||||
|
Email string `json:"email" gorm:"size:100"`
|
||||||
|
Avatar string `json:"avatar" gorm:"size:500"`
|
||||||
|
Role string `json:"role" gorm:"size:20;default:'admin'"` // 固定 admin,预留扩展
|
||||||
|
IsActive bool `json:"is_active" gorm:"default:true"`
|
||||||
|
LastLogin *time.Time `json:"last_login,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
DeletedAt gorm.DeletedAt `json:"deleted_at,omitempty" gorm:"index"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName 指定表名 // Logan.Li
|
||||||
|
func (User) TableName() string {
|
||||||
|
return "users"
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
package analytics
|
||||||
|
|
||||||
|
import (
|
||||||
|
"backend/internal/dto"
|
||||||
|
"backend/internal/model"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AnalyticsService 数据分析业务逻辑层 // Logan.Li
|
||||||
|
type AnalyticsService struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAnalyticsService 创建 service 实例 // Logan.Li
|
||||||
|
func NewAnalyticsService(db *gorm.DB) *AnalyticsService {
|
||||||
|
return &AnalyticsService{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDashboardStats 仪表板统计 // Logan.Li
|
||||||
|
func (s *AnalyticsService) GetDashboardStats() (*dto.DashboardStats, error) {
|
||||||
|
var stats dto.DashboardStats
|
||||||
|
|
||||||
|
s.db.Model(&model.License{}).Count(&stats.TotalLicenses)
|
||||||
|
s.db.Model(&model.License{}).Where("status = ?", model.LicenseStatusActive).Count(&stats.ActiveLicenses)
|
||||||
|
|
||||||
|
// 本月新增 // Logan.Li
|
||||||
|
now := time.Now()
|
||||||
|
monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
|
||||||
|
s.db.Model(&model.License{}).Where("created_at >= ?", monthStart).Count(&stats.NewThisMonth)
|
||||||
|
|
||||||
|
// 即将到期(30 天内) // Logan.Li
|
||||||
|
thirtyDaysLater := now.AddDate(0, 0, 30)
|
||||||
|
s.db.Model(&model.License{}).
|
||||||
|
Where("status = ? AND end_date > ? AND end_date < ?", model.LicenseStatusActive, now, thirtyDaysLater).
|
||||||
|
Count(&stats.ExpiringSoon)
|
||||||
|
|
||||||
|
s.db.Model(&model.Customer{}).Count(&stats.TotalCustomers)
|
||||||
|
s.db.Model(&model.Product{}).Count(&stats.TotalProducts)
|
||||||
|
|
||||||
|
// 在线设备(5 分钟内有心跳) // Logan.Li
|
||||||
|
fiveMinAgo := now.Add(-5 * time.Minute)
|
||||||
|
s.db.Model(&model.Activation{}).Where("is_online = ? AND last_seen_at >= ?", true, fiveMinAgo).Count(&stats.OnlineDevices)
|
||||||
|
|
||||||
|
// 本月收入 // Logan.Li
|
||||||
|
s.db.Model(&model.Order{}).
|
||||||
|
Where("status = ? AND paid_at >= ?", "paid", monthStart).
|
||||||
|
Select("COALESCE(SUM(amount), 0)").
|
||||||
|
Scan(&stats.MonthlyRevenue)
|
||||||
|
|
||||||
|
return &stats, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetExpiringLicenses 即将到期 License 列表 // Logan.Li
|
||||||
|
func (s *AnalyticsService) GetExpiringLicenses(days int) ([]dto.ExpiringLicenseItem, error) {
|
||||||
|
if days <= 0 {
|
||||||
|
days = 30
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
deadline := now.AddDate(0, 0, days)
|
||||||
|
|
||||||
|
var licenses []model.License
|
||||||
|
s.db.Preload("Customer").Preload("Product").
|
||||||
|
Where("status = ? AND end_date > ? AND end_date < ?", model.LicenseStatusActive, now, deadline).
|
||||||
|
Order("end_date ASC").Limit(20).Find(&licenses)
|
||||||
|
|
||||||
|
results := make([]dto.ExpiringLicenseItem, 0, len(licenses))
|
||||||
|
for _, l := range licenses {
|
||||||
|
results = append(results, dto.ExpiringLicenseItem{
|
||||||
|
ID: l.ID,
|
||||||
|
LicenseKey: l.LicenseKey,
|
||||||
|
CustomerName: l.Customer.Name,
|
||||||
|
ProductName: l.Product.Name,
|
||||||
|
EndDate: l.EndDate.Format("2006-01-02"),
|
||||||
|
DaysLeft: int(l.EndDate.Sub(now).Hours() / 24),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRecentActivities 最近操作活动 // Logan.Li
|
||||||
|
func (s *AnalyticsService) GetRecentActivities(limit int) ([]dto.RecentActivityItem, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 10
|
||||||
|
}
|
||||||
|
var logs []model.AuditLog
|
||||||
|
s.db.Order("created_at DESC").Limit(limit).Find(&logs)
|
||||||
|
|
||||||
|
results := make([]dto.RecentActivityItem, 0, len(logs))
|
||||||
|
for _, l := range logs {
|
||||||
|
results = append(results, dto.RecentActivityItem{
|
||||||
|
ID: l.ID,
|
||||||
|
Username: l.Username,
|
||||||
|
Action: l.Action,
|
||||||
|
TargetType: l.TargetType,
|
||||||
|
TargetName: l.TargetName,
|
||||||
|
Detail: l.Detail,
|
||||||
|
CreatedAt: l.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOnlineStats 在线统计 // Logan.Li
|
||||||
|
func (s *AnalyticsService) GetOnlineStats() (map[string]interface{}, error) {
|
||||||
|
now := time.Now()
|
||||||
|
fiveMinAgo := now.Add(-5 * time.Minute)
|
||||||
|
|
||||||
|
var onlineDevices int64
|
||||||
|
var totalActivations int64
|
||||||
|
s.db.Model(&model.Activation{}).Where("is_online = ? AND last_seen_at >= ?", true, fiveMinAgo).Count(&onlineDevices)
|
||||||
|
s.db.Model(&model.Activation{}).Where("status = ?", "active").Count(&totalActivations)
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"online_devices": onlineDevices,
|
||||||
|
"total_activations": totalActivations,
|
||||||
|
"offline_devices": totalActivations - onlineDevices,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRevenueStats 收入统计(近 6 个月) // Logan.Li
|
||||||
|
func (s *AnalyticsService) GetRevenueStats() ([]dto.RevenuePoint, error) {
|
||||||
|
now := time.Now()
|
||||||
|
results := make([]dto.RevenuePoint, 0, 6)
|
||||||
|
for i := 5; i >= 0; i-- {
|
||||||
|
monthStart := time.Date(now.Year(), now.Month()-time.Month(i), 1, 0, 0, 0, 0, now.Location())
|
||||||
|
nextMonth := monthStart.AddDate(0, 1, 0)
|
||||||
|
var amount float64
|
||||||
|
s.db.Model(&model.Order{}).
|
||||||
|
Where("status = ? AND created_at >= ? AND created_at < ?", "paid", monthStart, nextMonth).
|
||||||
|
Select("COALESCE(SUM(amount), 0)").
|
||||||
|
Scan(&amount)
|
||||||
|
results = append(results, dto.RevenuePoint{
|
||||||
|
Period: monthStart.Format("2006-01"),
|
||||||
|
Amount: amount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLicenseGrowth License 增长趋势(近 6 个月) // Logan.Li
|
||||||
|
func (s *AnalyticsService) GetLicenseGrowth() ([]dto.LicenseGrowthPoint, error) {
|
||||||
|
now := time.Now()
|
||||||
|
results := make([]dto.LicenseGrowthPoint, 0, 6)
|
||||||
|
for i := 5; i >= 0; i-- {
|
||||||
|
monthEnd := time.Date(now.Year(), now.Month()-time.Month(i)+1, 0, 23, 59, 59, 0, now.Location())
|
||||||
|
var count int64
|
||||||
|
s.db.Model(&model.License{}).Where("created_at <= ?", monthEnd).Count(&count)
|
||||||
|
results = append(results, dto.LicenseGrowthPoint{
|
||||||
|
Date: monthEnd.Format("2006-01"),
|
||||||
|
Count: count,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetProductDistribution 产品分布 // Logan.Li
|
||||||
|
func (s *AnalyticsService) GetProductDistribution() ([]dto.ProductDistribution, error) {
|
||||||
|
var results []dto.ProductDistribution
|
||||||
|
s.db.Model(&model.License{}).
|
||||||
|
Select("products.name as product_name, count(licenses.id) as count").
|
||||||
|
Joins("LEFT JOIN products ON products.id = licenses.product_id").
|
||||||
|
Group("licenses.product_id").
|
||||||
|
Scan(&results)
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"backend/config"
|
||||||
|
"backend/internal/dto"
|
||||||
|
"backend/internal/model"
|
||||||
|
"backend/utils"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuthService 认证业务逻辑层 // Logan.Li
|
||||||
|
type AuthService struct {
|
||||||
|
db *gorm.DB
|
||||||
|
cfg *config.Config
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAuthService 创建 service 实例 // Logan.Li
|
||||||
|
func NewAuthService(db *gorm.DB, cfg *config.Config) *AuthService {
|
||||||
|
return &AuthService{db: db, cfg: cfg}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login 校验账号密码并签发 JWT // Logan.Li
|
||||||
|
func (s *AuthService) Login(req dto.LoginRequest) (*dto.LoginResponse, error) {
|
||||||
|
var user model.User
|
||||||
|
if err := s.db.Where("username = ?", req.Username).First(&user).Error; err != nil {
|
||||||
|
return nil, errors.New("用户名或密码错误")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !user.IsActive {
|
||||||
|
return nil, errors.New("账号已被禁用")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !utils.CheckPassword(req.Password, user.Password) {
|
||||||
|
return nil, errors.New("用户名或密码错误")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新最后登录时间 // Logan.Li
|
||||||
|
now := time.Now()
|
||||||
|
s.db.Model(&user).Update("last_login", now)
|
||||||
|
|
||||||
|
// 签发 JWT // Logan.Li
|
||||||
|
token, err := utils.GenerateToken(user.ID, user.Username, user.Role, s.cfg.JWTSecret)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("令牌生成失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &dto.LoginResponse{
|
||||||
|
Token: token,
|
||||||
|
UserInfo: dto.UserInfoResponse{
|
||||||
|
ID: user.ID,
|
||||||
|
Username: user.Username,
|
||||||
|
Email: user.Email,
|
||||||
|
Avatar: user.Avatar,
|
||||||
|
Role: user.Role,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCurrentUser 根据 ID 获取当前用户信息 // Logan.Li
|
||||||
|
func (s *AuthService) GetCurrentUser(userID uint) (*dto.UserInfoResponse, error) {
|
||||||
|
var user model.User
|
||||||
|
if err := s.db.First(&user, userID).Error; err != nil {
|
||||||
|
return nil, errors.New("用户不存在")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &dto.UserInfoResponse{
|
||||||
|
ID: user.ID,
|
||||||
|
Username: user.Username,
|
||||||
|
Email: user.Email,
|
||||||
|
Avatar: user.Avatar,
|
||||||
|
Role: user.Role,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChangePassword 修改当前用户密码 // Logan.Li
|
||||||
|
func (s *AuthService) ChangePassword(userID uint, req dto.ChangePasswordRequest) error {
|
||||||
|
var user model.User
|
||||||
|
if err := s.db.First(&user, userID).Error; err != nil {
|
||||||
|
return errors.New("用户不存在")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !utils.CheckPassword(req.OldPassword, user.Password) {
|
||||||
|
return errors.New("原密码错误")
|
||||||
|
}
|
||||||
|
|
||||||
|
user.Password = utils.HashPassword(req.NewPassword)
|
||||||
|
return s.db.Save(&user).Error
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
package customer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"backend/internal/dto"
|
||||||
|
"backend/internal/model"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CustomerService 客户业务逻辑层 // Logan.Li
|
||||||
|
type CustomerService struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCustomerService 创建 service 实例 // Logan.Li
|
||||||
|
func NewCustomerService(db *gorm.DB) *CustomerService {
|
||||||
|
return &CustomerService{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// List 分页查询客户 // Logan.Li
|
||||||
|
func (s *CustomerService) List(page, pageSize int, keyword string) ([]dto.CustomerResponse, int64, error) {
|
||||||
|
var customers []model.Customer
|
||||||
|
var total int64
|
||||||
|
|
||||||
|
query := s.db.Model(&model.Customer{})
|
||||||
|
if keyword != "" {
|
||||||
|
query = query.Where("name LIKE ? OR email LIKE ? OR company LIKE ?", "%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%")
|
||||||
|
}
|
||||||
|
|
||||||
|
query.Count(&total)
|
||||||
|
if err := query.Order("created_at DESC").
|
||||||
|
Offset((page - 1) * pageSize).Limit(pageSize).
|
||||||
|
Find(&customers).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make([]dto.CustomerResponse, 0, len(customers))
|
||||||
|
for _, c := range customers {
|
||||||
|
results = append(results, toCustomerResponse(c))
|
||||||
|
}
|
||||||
|
return results, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAll 获取全部客户(下拉选项用) // Logan.Li
|
||||||
|
func (s *CustomerService) GetAll() ([]dto.CustomerResponse, error) {
|
||||||
|
var customers []model.Customer
|
||||||
|
if err := s.db.Where("status = ?", 1).Order("created_at DESC").Find(&customers).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
results := make([]dto.CustomerResponse, 0, len(customers))
|
||||||
|
for _, c := range customers {
|
||||||
|
results = append(results, toCustomerResponse(c))
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByID 根据 ID 获取客户 // Logan.Li
|
||||||
|
func (s *CustomerService) GetByID(id uint) (*dto.CustomerResponse, error) {
|
||||||
|
var customer model.Customer
|
||||||
|
if err := s.db.First(&customer, id).Error; err != nil {
|
||||||
|
return nil, errors.New("客户不存在")
|
||||||
|
}
|
||||||
|
resp := toCustomerResponse(customer)
|
||||||
|
return &resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create 创建客户 // Logan.Li
|
||||||
|
func (s *CustomerService) Create(req dto.CustomerCreate) (*dto.CustomerResponse, error) {
|
||||||
|
status := 1
|
||||||
|
if req.Status != nil {
|
||||||
|
status = *req.Status
|
||||||
|
}
|
||||||
|
customer := model.Customer{
|
||||||
|
Name: req.Name,
|
||||||
|
Company: req.Company,
|
||||||
|
Email: req.Email,
|
||||||
|
Phone: req.Phone,
|
||||||
|
Address: req.Address,
|
||||||
|
Notes: req.Notes,
|
||||||
|
Status: status,
|
||||||
|
}
|
||||||
|
if err := s.db.Create(&customer).Error; err != nil {
|
||||||
|
return nil, errors.New("创建客户失败")
|
||||||
|
}
|
||||||
|
resp := toCustomerResponse(customer)
|
||||||
|
return &resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update 更新客户 // Logan.Li
|
||||||
|
func (s *CustomerService) Update(id uint, req dto.CustomerUpdate) (*dto.CustomerResponse, error) {
|
||||||
|
var customer model.Customer
|
||||||
|
if err := s.db.First(&customer, id).Error; err != nil {
|
||||||
|
return nil, errors.New("客户不存在")
|
||||||
|
}
|
||||||
|
|
||||||
|
updates := map[string]interface{}{
|
||||||
|
"name": req.Name,
|
||||||
|
"company": req.Company,
|
||||||
|
"email": req.Email,
|
||||||
|
"phone": req.Phone,
|
||||||
|
"address": req.Address,
|
||||||
|
"notes": req.Notes,
|
||||||
|
}
|
||||||
|
if req.Status != nil {
|
||||||
|
updates["status"] = *req.Status
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.db.Model(&customer).Updates(updates).Error; err != nil {
|
||||||
|
return nil, errors.New("更新客户失败")
|
||||||
|
}
|
||||||
|
s.db.First(&customer, id)
|
||||||
|
resp := toCustomerResponse(customer)
|
||||||
|
return &resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete 删除客户(软删除) // Logan.Li
|
||||||
|
func (s *CustomerService) Delete(id uint) error {
|
||||||
|
var count int64
|
||||||
|
s.db.Model(&model.License{}).Where("customer_id = ?", id).Count(&count)
|
||||||
|
if count > 0 {
|
||||||
|
return errors.New("该客户下存在 License,无法删除")
|
||||||
|
}
|
||||||
|
return s.db.Delete(&model.Customer{}, id).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateStatus 切换客户状态 // Logan.Li
|
||||||
|
func (s *CustomerService) UpdateStatus(id uint, status int) error {
|
||||||
|
return s.db.Model(&model.Customer{}).Where("id = ?", id).Update("status", status).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// toCustomerResponse 模型转响应 DTO // Logan.Li
|
||||||
|
func toCustomerResponse(c model.Customer) dto.CustomerResponse {
|
||||||
|
return dto.CustomerResponse{
|
||||||
|
ID: c.ID,
|
||||||
|
Name: c.Name,
|
||||||
|
Company: c.Company,
|
||||||
|
Email: c.Email,
|
||||||
|
Phone: c.Phone,
|
||||||
|
Address: c.Address,
|
||||||
|
Notes: c.Notes,
|
||||||
|
Status: c.Status,
|
||||||
|
CreatedAt: c.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||||
|
UpdatedAt: c.UpdatedAt.Format("2006-01-02 15:04:05"),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
package entitlement
|
||||||
|
|
||||||
|
import (
|
||||||
|
"backend/internal/dto"
|
||||||
|
"backend/internal/model"
|
||||||
|
"backend/utils"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EntitlementService 客户权益业务逻辑层 // Logan.Li
|
||||||
|
type EntitlementService struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewEntitlementService 创建 service 实例 // Logan.Li
|
||||||
|
func NewEntitlementService(db *gorm.DB) *EntitlementService {
|
||||||
|
return &EntitlementService{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// List 分页/筛选查询权益 // Logan.Li
|
||||||
|
func (s *EntitlementService) List(page, pageSize int, customerID, productID uint) ([]dto.EntitlementResponse, int64, error) {
|
||||||
|
var entitlements []model.CustomerEntitlement
|
||||||
|
var total int64
|
||||||
|
|
||||||
|
query := s.db.Model(&model.CustomerEntitlement{}).Preload("Customer").Preload("Product").Preload("Plan")
|
||||||
|
if customerID > 0 {
|
||||||
|
query = query.Where("customer_id = ?", customerID)
|
||||||
|
}
|
||||||
|
if productID > 0 {
|
||||||
|
query = query.Where("product_id = ?", productID)
|
||||||
|
}
|
||||||
|
|
||||||
|
query.Count(&total)
|
||||||
|
if err := query.Order("created_at DESC").
|
||||||
|
Offset((page - 1) * pageSize).Limit(pageSize).
|
||||||
|
Find(&entitlements).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make([]dto.EntitlementResponse, 0, len(entitlements))
|
||||||
|
for _, e := range entitlements {
|
||||||
|
results = append(results, s.toResponse(e))
|
||||||
|
}
|
||||||
|
return results, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByID 获取权益详情 // Logan.Li
|
||||||
|
func (s *EntitlementService) GetByID(id uint) (*dto.EntitlementResponse, error) {
|
||||||
|
var ent model.CustomerEntitlement
|
||||||
|
if err := s.db.Preload("Customer").Preload("Product").Preload("Plan").First(&ent, id).Error; err != nil {
|
||||||
|
return nil, errors.New("权益记录不存在")
|
||||||
|
}
|
||||||
|
resp := s.toResponse(ent)
|
||||||
|
return &resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revoke 吊销权益 // Logan.Li
|
||||||
|
func (s *EntitlementService) Revoke(id uint) error {
|
||||||
|
var ent model.CustomerEntitlement
|
||||||
|
if err := s.db.First(&ent, id).Error; err != nil {
|
||||||
|
return errors.New("权益记录不存在")
|
||||||
|
}
|
||||||
|
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
ent.Status = "revoked"
|
||||||
|
if err := tx.Save(&ent).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// 同步吊销关联 License // Logan.Li
|
||||||
|
if ent.LicenseID > 0 {
|
||||||
|
tx.Model(&model.License{}).Where("id = ?", ent.LicenseID).
|
||||||
|
Updates(map[string]interface{}{"status": model.LicenseStatusRevoked, "revoke_reason": "权益被吊销"})
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Renew 续费订阅(按计划延长有效期) // Logan.Li
|
||||||
|
func (s *EntitlementService) Renew(id, planID uint) (*dto.EntitlementResponse, error) {
|
||||||
|
var ent model.CustomerEntitlement
|
||||||
|
if err := s.db.First(&ent, id).Error; err != nil {
|
||||||
|
return nil, errors.New("权益记录不存在")
|
||||||
|
}
|
||||||
|
var plan model.Plan
|
||||||
|
if err := s.db.First(&plan, planID).Error; err != nil {
|
||||||
|
return nil, errors.New("定价计划不存在")
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
var validUntil *time.Time
|
||||||
|
if plan.Duration == 0 {
|
||||||
|
validUntil = nil // 永久
|
||||||
|
} else {
|
||||||
|
var base time.Time
|
||||||
|
if ent.ValidUntil != nil && ent.ValidUntil.After(now) {
|
||||||
|
base = *ent.ValidUntil
|
||||||
|
} else {
|
||||||
|
base = now
|
||||||
|
}
|
||||||
|
end := base.AddDate(0, 0, plan.Duration)
|
||||||
|
validUntil = &end
|
||||||
|
}
|
||||||
|
|
||||||
|
ent.Status = "active"
|
||||||
|
ent.ValidFrom = now
|
||||||
|
ent.ValidUntil = validUntil
|
||||||
|
ent.PlanID = planID
|
||||||
|
ent.PlanType = plan.PlanType
|
||||||
|
if err := s.db.Save(&ent).Error; err != nil {
|
||||||
|
return nil, errors.New("续费失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同步延长关联 License 有效期 // Logan.Li
|
||||||
|
if ent.LicenseID > 0 {
|
||||||
|
var lic model.License
|
||||||
|
s.db.First(&lic, ent.LicenseID)
|
||||||
|
if plan.Duration == 0 {
|
||||||
|
lic.EndDate = model.PermanentEndDate
|
||||||
|
} else {
|
||||||
|
base := lic.EndDate
|
||||||
|
if utils.IsLicenseExpired(lic.EndDate) {
|
||||||
|
base = now
|
||||||
|
}
|
||||||
|
lic.EndDate = base.AddDate(0, 0, plan.Duration)
|
||||||
|
}
|
||||||
|
lic.Status = model.LicenseStatusActive
|
||||||
|
s.db.Save(&lic)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.db.Preload("Customer").Preload("Product").Preload("Plan").First(&ent, id)
|
||||||
|
resp := s.toResponse(ent)
|
||||||
|
return &resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByEmail 按邮箱查询客户权益(客户端接口) // Logan.Li
|
||||||
|
func (s *EntitlementService) GetByEmail(email string) ([]dto.EntitlementResponse, error) {
|
||||||
|
var customer model.Customer
|
||||||
|
if err := s.db.Where("email = ?", email).First(&customer).Error; err != nil {
|
||||||
|
return []dto.EntitlementResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var entitlements []model.CustomerEntitlement
|
||||||
|
s.db.Where("customer_id = ?", customer.ID).
|
||||||
|
Preload("Product").Preload("Plan").
|
||||||
|
Find(&entitlements)
|
||||||
|
|
||||||
|
results := make([]dto.EntitlementResponse, 0, len(entitlements))
|
||||||
|
for _, e := range entitlements {
|
||||||
|
results = append(results, s.toResponse(e))
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// toResponse 模型转响应 DTO // Logan.Li
|
||||||
|
func (s *EntitlementService) toResponse(e model.CustomerEntitlement) dto.EntitlementResponse {
|
||||||
|
resp := dto.EntitlementResponse{
|
||||||
|
ID: e.ID,
|
||||||
|
CustomerID: e.CustomerID,
|
||||||
|
ProductID: e.ProductID,
|
||||||
|
PlanID: e.PlanID,
|
||||||
|
PlanType: e.PlanType,
|
||||||
|
LicenseKey: e.LicenseKey,
|
||||||
|
Status: e.Status,
|
||||||
|
ValidFrom: e.ValidFrom.Format("2006-01-02 15:04:05"),
|
||||||
|
Renewable: e.Renewable,
|
||||||
|
}
|
||||||
|
if e.Customer.ID > 0 {
|
||||||
|
resp.CustomerName = e.Customer.Name
|
||||||
|
}
|
||||||
|
if e.Product.ID > 0 {
|
||||||
|
resp.ProductName = e.Product.Name
|
||||||
|
}
|
||||||
|
if e.Plan.ID > 0 {
|
||||||
|
resp.PlanName = e.Plan.Name
|
||||||
|
}
|
||||||
|
if e.ValidUntil != nil {
|
||||||
|
s := e.ValidUntil.Format("2006-01-02 15:04:05")
|
||||||
|
resp.ValidUntil = &s
|
||||||
|
resp.DaysLeft = utils.CalculateDaysLeft(*e.ValidUntil)
|
||||||
|
} else {
|
||||||
|
resp.DaysLeft = 9999 // 永久
|
||||||
|
}
|
||||||
|
// 查设备数 // Logan.Li
|
||||||
|
if e.LicenseID > 0 {
|
||||||
|
var lic model.License
|
||||||
|
s.db.First(&lic, e.LicenseID)
|
||||||
|
resp.MaxDevices = lic.MaxActivations
|
||||||
|
var count int64
|
||||||
|
s.db.Model(&model.Activation{}).Where("license_id = ?", e.LicenseID).Count(&count)
|
||||||
|
resp.CurrentDevices = int(count)
|
||||||
|
}
|
||||||
|
return resp
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
package license
|
||||||
|
|
||||||
|
import (
|
||||||
|
"backend/internal/dto"
|
||||||
|
"backend/internal/model"
|
||||||
|
"backend/utils"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Verify 客户端 License 验证 // Logan.Li
|
||||||
|
func (s *LicenseService) Verify(req dto.LicenseVerifyRequest) (*dto.LicenseVerifyResponse, error) {
|
||||||
|
var lic model.License
|
||||||
|
if err := s.db.Preload("Product").Where("license_key = ?", req.LicenseKey).First(&lic).Error; err != nil {
|
||||||
|
return &dto.LicenseVerifyResponse{Valid: false, Message: "无效的 License"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 校验产品归属 // Logan.Li
|
||||||
|
if req.ProductID > 0 && lic.ProductID != req.ProductID {
|
||||||
|
return &dto.LicenseVerifyResponse{Valid: false, Message: "License 与产品不匹配"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 状态检查 // Logan.Li
|
||||||
|
switch lic.Status {
|
||||||
|
case model.LicenseStatusRevoked:
|
||||||
|
return &dto.LicenseVerifyResponse{Valid: false, Message: "License 已被吊销"}, nil
|
||||||
|
case model.LicenseStatusSuspended:
|
||||||
|
return &dto.LicenseVerifyResponse{Valid: false, Message: "License 已被暂停"}, nil
|
||||||
|
case model.LicenseStatusExpired:
|
||||||
|
return &dto.LicenseVerifyResponse{Valid: false, Message: "License 已过期"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 过期检查(惰性更新状态) // Logan.Li
|
||||||
|
if utils.IsLicenseExpired(lic.EndDate) {
|
||||||
|
lic.Status = model.LicenseStatusExpired
|
||||||
|
s.db.Save(&lic)
|
||||||
|
return &dto.LicenseVerifyResponse{Valid: false, Message: "License 已过期"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &dto.LicenseVerifyResponse{
|
||||||
|
Valid: true,
|
||||||
|
LicenseKey: lic.LicenseKey,
|
||||||
|
ProductID: lic.ProductID,
|
||||||
|
ProductName: lic.Product.Name,
|
||||||
|
ProductCode: lic.Product.Code,
|
||||||
|
Features: utils.ParseFeatures(lic.Product.Features),
|
||||||
|
Status: lic.Status,
|
||||||
|
ExpiresAt: lic.EndDate.Format("2006-01-02 15:04:05"),
|
||||||
|
DaysLeft: utils.CalculateDaysLeft(lic.EndDate),
|
||||||
|
MaxDevices: lic.MaxActivations,
|
||||||
|
Message: "验证成功",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Activate 客户端激活 License(机器绑定) // Logan.Li
|
||||||
|
func (s *LicenseService) Activate(req dto.LicenseActivateRequest, clientIP string) (map[string]interface{}, error) {
|
||||||
|
var lic model.License
|
||||||
|
if err := s.db.Where("license_key = ?", req.LicenseKey).First(&lic).Error; err != nil {
|
||||||
|
return nil, errors.New("无效的 License")
|
||||||
|
}
|
||||||
|
|
||||||
|
if lic.Status == model.LicenseStatusRevoked {
|
||||||
|
return nil, errors.New("License 已被吊销")
|
||||||
|
}
|
||||||
|
if lic.Status == model.LicenseStatusExpired || utils.IsLicenseExpired(lic.EndDate) {
|
||||||
|
return nil, errors.New("License 已过期")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同设备重复激活视为重新激活,不消耗额度 // Logan.Li
|
||||||
|
var existing model.Activation
|
||||||
|
if err := s.db.Where("license_id = ? AND device_id = ?", lic.ID, req.DeviceID).First(&existing).Error; err == nil {
|
||||||
|
existing.LastSeenAt = time.Now()
|
||||||
|
existing.IsOnline = true
|
||||||
|
existing.DeviceName = req.DeviceName
|
||||||
|
existing.DeviceInfo = req.DeviceInfo
|
||||||
|
existing.IP = clientIP
|
||||||
|
s.db.Save(&existing)
|
||||||
|
return map[string]interface{}{
|
||||||
|
"expires_at": lic.EndDate.Format("2006-01-02 15:04:05"),
|
||||||
|
"days_left": utils.CalculateDaysLeft(lic.EndDate),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查激活额度 // Logan.Li
|
||||||
|
if lic.CurrentActivations >= lic.MaxActivations {
|
||||||
|
return nil, errors.New("已达到最大激活数量限制")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建激活记录 // Logan.Li
|
||||||
|
activation := model.Activation{
|
||||||
|
LicenseID: lic.ID,
|
||||||
|
DeviceID: req.DeviceID,
|
||||||
|
DeviceName: req.DeviceName,
|
||||||
|
DeviceInfo: req.DeviceInfo,
|
||||||
|
IP: clientIP,
|
||||||
|
Status: "active",
|
||||||
|
IsOnline: true,
|
||||||
|
ActivatedAt: time.Now(),
|
||||||
|
LastSeenAt: time.Now(),
|
||||||
|
}
|
||||||
|
if err := s.db.Create(&activation).Error; err != nil {
|
||||||
|
return nil, errors.New("激活失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
lic.CurrentActivations++
|
||||||
|
if lic.Status == model.LicenseStatusInactive {
|
||||||
|
lic.Status = model.LicenseStatusActive
|
||||||
|
}
|
||||||
|
s.db.Save(&lic)
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"expires_at": lic.EndDate.Format("2006-01-02 15:04:05"),
|
||||||
|
"days_left": utils.CalculateDaysLeft(lic.EndDate),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deactivate 客户端停用 // Logan.Li
|
||||||
|
func (s *LicenseService) Deactivate(req dto.LicenseDeactivateRequest) error {
|
||||||
|
var lic model.License
|
||||||
|
if err := s.db.Where("license_key = ?", req.LicenseKey).First(&lic).Error; err != nil {
|
||||||
|
return errors.New("无效的 License")
|
||||||
|
}
|
||||||
|
|
||||||
|
result := s.db.Model(&model.Activation{}).
|
||||||
|
Where("license_id = ? AND device_id = ?", lic.ID, req.DeviceID).
|
||||||
|
Update("status", "inactive")
|
||||||
|
|
||||||
|
if result.RowsAffected > 0 && lic.CurrentActivations > 0 {
|
||||||
|
lic.CurrentActivations--
|
||||||
|
s.db.Save(&lic)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Heartbeat 客户端心跳 // Logan.Li
|
||||||
|
func (s *LicenseService) Heartbeat(req dto.LicenseHeartbeatRequest) (map[string]interface{}, error) {
|
||||||
|
var lic model.License
|
||||||
|
if err := s.db.Where("license_key = ?", req.LicenseKey).First(&lic).Error; err != nil {
|
||||||
|
return nil, errors.New("无效的 License")
|
||||||
|
}
|
||||||
|
|
||||||
|
if lic.Status == model.LicenseStatusRevoked {
|
||||||
|
return nil, errors.New("License 已被吊销")
|
||||||
|
}
|
||||||
|
if lic.Status == model.LicenseStatusExpired || utils.IsLicenseExpired(lic.EndDate) {
|
||||||
|
return nil, errors.New("License 已过期")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新心跳时间与在线状态 // Logan.Li
|
||||||
|
s.db.Model(&model.Activation{}).
|
||||||
|
Where("license_id = ? AND device_id = ?", lic.ID, req.DeviceID).
|
||||||
|
Updates(map[string]interface{}{"last_seen_at": time.Now(), "is_online": true})
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"expires_at": lic.EndDate.Format("2006-01-02 15:04:05"),
|
||||||
|
"days_left": utils.CalculateDaysLeft(lic.EndDate),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
package license
|
||||||
|
|
||||||
|
import (
|
||||||
|
"backend/internal/dto"
|
||||||
|
"backend/internal/model"
|
||||||
|
"backend/utils"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LicenseService License 业务逻辑层 // Logan.Li
|
||||||
|
type LicenseService struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLicenseService 创建 service 实例 // Logan.Li
|
||||||
|
func NewLicenseService(db *gorm.DB) *LicenseService {
|
||||||
|
return &LicenseService{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListQuery 列表查询参数 // Logan.Li
|
||||||
|
type ListQuery struct {
|
||||||
|
Keyword string
|
||||||
|
Status string
|
||||||
|
CustomerID uint
|
||||||
|
ProductID uint
|
||||||
|
}
|
||||||
|
|
||||||
|
// List 分页查询 License // Logan.Li
|
||||||
|
func (s *LicenseService) List(page, pageSize int, q ListQuery) ([]dto.LicenseResponse, int64, error) {
|
||||||
|
var licenses []model.License
|
||||||
|
var total int64
|
||||||
|
|
||||||
|
query := s.db.Model(&model.License{}).Preload("Customer").Preload("Product")
|
||||||
|
if q.Keyword != "" {
|
||||||
|
query = query.Where("license_key LIKE ?", "%"+q.Keyword+"%")
|
||||||
|
}
|
||||||
|
if q.Status != "" {
|
||||||
|
query = query.Where("status = ?", q.Status)
|
||||||
|
}
|
||||||
|
if q.CustomerID > 0 {
|
||||||
|
query = query.Where("customer_id = ?", q.CustomerID)
|
||||||
|
}
|
||||||
|
if q.ProductID > 0 {
|
||||||
|
query = query.Where("product_id = ?", q.ProductID)
|
||||||
|
}
|
||||||
|
|
||||||
|
query.Count(&total)
|
||||||
|
if err := query.Order("created_at DESC").
|
||||||
|
Offset((page - 1) * pageSize).Limit(pageSize).
|
||||||
|
Find(&licenses).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make([]dto.LicenseResponse, 0, len(licenses))
|
||||||
|
for _, l := range licenses {
|
||||||
|
results = append(results, toLicenseResponse(l))
|
||||||
|
}
|
||||||
|
return results, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByID 获取 License 详情(含激活记录) // Logan.Li
|
||||||
|
func (s *LicenseService) GetByID(id uint) (*dto.LicenseResponse, []dto.ActivationResponse, error) {
|
||||||
|
var lic model.License
|
||||||
|
if err := s.db.Preload("Customer").Preload("Product").Preload("Activations").
|
||||||
|
First(&lic, id).Error; err != nil {
|
||||||
|
return nil, nil, errors.New("License 不存在")
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := toLicenseResponse(lic)
|
||||||
|
activations := make([]dto.ActivationResponse, 0, len(lic.Activations))
|
||||||
|
for _, a := range lic.Activations {
|
||||||
|
activations = append(activations, toActivationResponse(a))
|
||||||
|
}
|
||||||
|
return &resp, activations, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create 创建 License(支持批量) // Logan.Li
|
||||||
|
func (s *LicenseService) Create(req dto.LicenseCreate) ([]dto.LicenseResponse, error) {
|
||||||
|
// 校验客户与产品 // Logan.Li
|
||||||
|
var customer model.Customer
|
||||||
|
if err := s.db.First(&customer, req.CustomerID).Error; err != nil {
|
||||||
|
return nil, errors.New("客户不存在")
|
||||||
|
}
|
||||||
|
var product model.Product
|
||||||
|
if err := s.db.First(&product, req.ProductID).Error; err != nil {
|
||||||
|
return nil, errors.New("产品不存在")
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.MaxActivations <= 0 {
|
||||||
|
req.MaxActivations = 1
|
||||||
|
}
|
||||||
|
if req.Count <= 0 {
|
||||||
|
req.Count = 1
|
||||||
|
}
|
||||||
|
if req.Count > 100 {
|
||||||
|
req.Count = 100
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算到期日期 // Logan.Li
|
||||||
|
var endDate time.Time
|
||||||
|
if req.Duration == model.DurationForever {
|
||||||
|
endDate = model.PermanentEndDate
|
||||||
|
} else {
|
||||||
|
endDate = time.Now().AddDate(0, 0, req.Duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
licenses := make([]model.License, 0, req.Count)
|
||||||
|
for i := 0; i < req.Count; i++ {
|
||||||
|
licenses = append(licenses, model.License{
|
||||||
|
LicenseKey: utils.GenerateLicenseKey(),
|
||||||
|
CustomerID: req.CustomerID,
|
||||||
|
ProductID: req.ProductID,
|
||||||
|
Status: model.LicenseStatusInactive,
|
||||||
|
StartDate: time.Now(),
|
||||||
|
EndDate: endDate,
|
||||||
|
Duration: req.Duration,
|
||||||
|
MaxActivations: req.MaxActivations,
|
||||||
|
IsTrial: req.IsTrial,
|
||||||
|
TrialDays: req.TrialDays,
|
||||||
|
Notes: req.Notes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.db.Create(&licenses).Error; err != nil {
|
||||||
|
return nil, errors.New("创建 License 失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make([]dto.LicenseResponse, 0, len(licenses))
|
||||||
|
for _, l := range licenses {
|
||||||
|
l.Customer = customer
|
||||||
|
l.Product = product
|
||||||
|
results = append(results, toLicenseResponse(l))
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update 更新 License // Logan.Li
|
||||||
|
func (s *LicenseService) Update(id uint, updates map[string]interface{}) (*dto.LicenseResponse, error) {
|
||||||
|
var lic model.License
|
||||||
|
if err := s.db.First(&lic, id).Error; err != nil {
|
||||||
|
return nil, errors.New("License 不存在")
|
||||||
|
}
|
||||||
|
if err := s.db.Model(&lic).Updates(updates).Error; err != nil {
|
||||||
|
return nil, errors.New("更新 License 失败")
|
||||||
|
}
|
||||||
|
s.db.Preload("Customer").Preload("Product").First(&lic, id)
|
||||||
|
resp := toLicenseResponse(lic)
|
||||||
|
return &resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete 删除 License(软删除) // Logan.Li
|
||||||
|
func (s *LicenseService) Delete(id uint) (*model.License, error) {
|
||||||
|
var lic model.License
|
||||||
|
if err := s.db.First(&lic, id).Error; err != nil {
|
||||||
|
return nil, errors.New("License 不存在")
|
||||||
|
}
|
||||||
|
if err := s.db.Delete(&model.License{}, id).Error; err != nil {
|
||||||
|
return nil, errors.New("删除 License 失败")
|
||||||
|
}
|
||||||
|
return &lic, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Renew 续期 // Logan.Li
|
||||||
|
func (s *LicenseService) Renew(id uint, duration int) (*dto.LicenseResponse, error) {
|
||||||
|
var lic model.License
|
||||||
|
if err := s.db.First(&lic, id).Error; err != nil {
|
||||||
|
return nil, errors.New("License 不存在")
|
||||||
|
}
|
||||||
|
|
||||||
|
var newEndDate time.Time
|
||||||
|
if duration == model.DurationForever {
|
||||||
|
newEndDate = model.PermanentEndDate
|
||||||
|
} else {
|
||||||
|
// 续期在原到期日基础上叠加(若已过期则从当前起算) // Logan.Li
|
||||||
|
base := lic.EndDate
|
||||||
|
if utils.IsLicenseExpired(lic.EndDate) {
|
||||||
|
base = time.Now()
|
||||||
|
}
|
||||||
|
newEndDate = base.AddDate(0, 0, duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
lic.EndDate = newEndDate
|
||||||
|
lic.Duration = duration
|
||||||
|
lic.Status = model.LicenseStatusActive
|
||||||
|
if err := s.db.Save(&lic).Error; err != nil {
|
||||||
|
return nil, errors.New("续期失败")
|
||||||
|
}
|
||||||
|
s.db.Preload("Customer").Preload("Product").First(&lic, id)
|
||||||
|
resp := toLicenseResponse(lic)
|
||||||
|
return &resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revoke 吊销(同时停用所有激活) // Logan.Li
|
||||||
|
func (s *LicenseService) Revoke(id uint, reason string) (*dto.LicenseResponse, error) {
|
||||||
|
var lic model.License
|
||||||
|
if err := s.db.First(&lic, id).Error; err != nil {
|
||||||
|
return nil, errors.New("License 不存在")
|
||||||
|
}
|
||||||
|
lic.Status = model.LicenseStatusRevoked
|
||||||
|
lic.RevokeReason = reason
|
||||||
|
if err := s.db.Save(&lic).Error; err != nil {
|
||||||
|
return nil, errors.New("吊销失败")
|
||||||
|
}
|
||||||
|
// 级联停用所有激活记录 // Logan.Li
|
||||||
|
s.db.Model(&model.Activation{}).Where("license_id = ?", id).Update("status", "inactive")
|
||||||
|
s.db.Preload("Customer").Preload("Product").First(&lic, id)
|
||||||
|
resp := toLicenseResponse(lic)
|
||||||
|
return &resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetStatus 设置状态(暂停/激活) // Logan.Li
|
||||||
|
func (s *LicenseService) SetStatus(id uint, status string) (*dto.LicenseResponse, error) {
|
||||||
|
var lic model.License
|
||||||
|
if err := s.db.First(&lic, id).Error; err != nil {
|
||||||
|
return nil, errors.New("License 不存在")
|
||||||
|
}
|
||||||
|
lic.Status = status
|
||||||
|
if err := s.db.Save(&lic).Error; err != nil {
|
||||||
|
return nil, errors.New("状态更新失败")
|
||||||
|
}
|
||||||
|
s.db.Preload("Customer").Preload("Product").First(&lic, id)
|
||||||
|
resp := toLicenseResponse(lic)
|
||||||
|
return &resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetActivations 获取激活记录 // Logan.Li
|
||||||
|
func (s *LicenseService) GetActivations(licenseID uint) ([]dto.ActivationResponse, error) {
|
||||||
|
var activations []model.Activation
|
||||||
|
if err := s.db.Where("license_id = ?", licenseID).Order("created_at DESC").Find(&activations).Error; err != nil {
|
||||||
|
return nil, errors.New("获取激活记录失败")
|
||||||
|
}
|
||||||
|
results := make([]dto.ActivationResponse, 0, len(activations))
|
||||||
|
for _, a := range activations {
|
||||||
|
results = append(results, toActivationResponse(a))
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ForceOffline 强制下线设备 // Logan.Li
|
||||||
|
func (s *LicenseService) ForceOffline(licenseID, activationID uint) error {
|
||||||
|
result := s.db.Model(&model.Activation{}).
|
||||||
|
Where("id = ? AND license_id = ?", activationID, licenseID).
|
||||||
|
Updates(map[string]interface{}{"status": "inactive", "is_online": false})
|
||||||
|
if result.Error != nil {
|
||||||
|
return errors.New("强制下线失败")
|
||||||
|
}
|
||||||
|
if result.RowsAffected > 0 {
|
||||||
|
var lic model.License
|
||||||
|
s.db.First(&lic, licenseID)
|
||||||
|
if lic.CurrentActivations > 0 {
|
||||||
|
lic.CurrentActivations--
|
||||||
|
s.db.Save(&lic)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExportCSV 导出 License 列表为 CSV // Logan.Li
|
||||||
|
func (s *LicenseService) ExportCSV(q ListQuery) (string, error) {
|
||||||
|
var licenses []model.License
|
||||||
|
query := s.db.Model(&model.License{}).Preload("Customer").Preload("Product")
|
||||||
|
if q.Status != "" {
|
||||||
|
query = query.Where("status = ?", q.Status)
|
||||||
|
}
|
||||||
|
if q.CustomerID > 0 {
|
||||||
|
query = query.Where("customer_id = ?", q.CustomerID)
|
||||||
|
}
|
||||||
|
if q.ProductID > 0 {
|
||||||
|
query = query.Where("product_id = ?", q.ProductID)
|
||||||
|
}
|
||||||
|
if err := query.Find(&licenses).Error; err != nil {
|
||||||
|
return "", errors.New("导出失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
csv := "License Key,产品,客户,状态,到期时间,激活数/最大激活数,创建时间\n"
|
||||||
|
for _, l := range licenses {
|
||||||
|
csv += l.LicenseKey + ","
|
||||||
|
csv += l.Product.Name + ","
|
||||||
|
csv += l.Customer.Name + ","
|
||||||
|
csv += l.Status + ","
|
||||||
|
csv += l.EndDate.Format("2006-01-02") + ","
|
||||||
|
csv += strconv.Itoa(l.CurrentActivations) + "/" + strconv.Itoa(l.MaxActivations) + ","
|
||||||
|
csv += l.CreatedAt.Format("2006-01-02 15:04:05") + "\n"
|
||||||
|
}
|
||||||
|
return csv, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// toLicenseResponse 模型转响应 DTO // Logan.Li
|
||||||
|
func toLicenseResponse(l model.License) dto.LicenseResponse {
|
||||||
|
customerName := ""
|
||||||
|
if l.Customer.ID > 0 {
|
||||||
|
customerName = l.Customer.Name
|
||||||
|
}
|
||||||
|
productName := ""
|
||||||
|
if l.Product.ID > 0 {
|
||||||
|
productName = l.Product.Name
|
||||||
|
}
|
||||||
|
return dto.LicenseResponse{
|
||||||
|
ID: l.ID,
|
||||||
|
LicenseKey: l.LicenseKey,
|
||||||
|
CustomerID: l.CustomerID,
|
||||||
|
CustomerName: customerName,
|
||||||
|
ProductID: l.ProductID,
|
||||||
|
ProductName: productName,
|
||||||
|
Status: l.Status,
|
||||||
|
StartDate: l.StartDate.Format("2006-01-02 15:04:05"),
|
||||||
|
EndDate: l.EndDate.Format("2006-01-02 15:04:05"),
|
||||||
|
Duration: l.Duration,
|
||||||
|
MaxActivations: l.MaxActivations,
|
||||||
|
CurrentActivations: l.CurrentActivations,
|
||||||
|
IsTrial: l.IsTrial,
|
||||||
|
DaysLeft: utils.CalculateDaysLeft(l.EndDate),
|
||||||
|
Notes: l.Notes,
|
||||||
|
CreatedAt: l.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// toActivationResponse 模型转响应 DTO // Logan.Li
|
||||||
|
func toActivationResponse(a model.Activation) dto.ActivationResponse {
|
||||||
|
return dto.ActivationResponse{
|
||||||
|
ID: a.ID,
|
||||||
|
LicenseID: a.LicenseID,
|
||||||
|
DeviceID: a.DeviceID,
|
||||||
|
DeviceName: a.DeviceName,
|
||||||
|
DeviceInfo: a.DeviceInfo,
|
||||||
|
IP: a.IP,
|
||||||
|
Status: a.Status,
|
||||||
|
IsOnline: a.IsOnline,
|
||||||
|
ActivatedAt: a.ActivatedAt.Format("2006-01-02 15:04:05"),
|
||||||
|
LastSeenAt: a.LastSeenAt.Format("2006-01-02 15:04:05"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarshalForAudit 序列化为 JSON 用于审计日志 // Logan.Li
|
||||||
|
func MarshalForAudit(v interface{}) string {
|
||||||
|
b, _ := json.Marshal(v)
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
@@ -0,0 +1,459 @@
|
|||||||
|
package order
|
||||||
|
|
||||||
|
import (
|
||||||
|
"backend/internal/dto"
|
||||||
|
"backend/internal/model"
|
||||||
|
"backend/utils"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OrderService 订单业务逻辑层 // Logan.Li
|
||||||
|
type OrderService struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewOrderService 创建 service 实例 // Logan.Li
|
||||||
|
func NewOrderService(db *gorm.DB) *OrderService {
|
||||||
|
return &OrderService{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// List 分页查询订单 // Logan.Li
|
||||||
|
func (s *OrderService) List(page, pageSize int, status string, productID uint) ([]dto.OrderResponse, int64, error) {
|
||||||
|
var orders []model.Order
|
||||||
|
var total int64
|
||||||
|
|
||||||
|
query := s.db.Model(&model.Order{}).Preload("Customer").Preload("Product").Preload("Plan")
|
||||||
|
if status != "" {
|
||||||
|
query = query.Where("status = ?", status)
|
||||||
|
}
|
||||||
|
if productID > 0 {
|
||||||
|
query = query.Where("product_id = ?", productID)
|
||||||
|
}
|
||||||
|
|
||||||
|
query.Count(&total)
|
||||||
|
if err := query.Order("created_at DESC").
|
||||||
|
Offset((page - 1) * pageSize).Limit(pageSize).
|
||||||
|
Find(&orders).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make([]dto.OrderResponse, 0, len(orders))
|
||||||
|
for _, o := range orders {
|
||||||
|
results = append(results, toOrderResponse(o))
|
||||||
|
}
|
||||||
|
return results, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByID 获取订单详情 // Logan.Li
|
||||||
|
func (s *OrderService) GetByID(id uint) (*dto.OrderResponse, error) {
|
||||||
|
var order model.Order
|
||||||
|
if err := s.db.Preload("Customer").Preload("Product").Preload("Plan").First(&order, id).Error; err != nil {
|
||||||
|
return nil, errors.New("订单不存在")
|
||||||
|
}
|
||||||
|
resp := toOrderResponse(order)
|
||||||
|
return &resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateOrder 客户端下单(含防重复购买检查) // Logan.Li
|
||||||
|
func (s *OrderService) CreateOrder(req dto.CreateOrderRequest, clientIP, userAgent string) (*dto.CreateOrderResponse, error) {
|
||||||
|
// 1. 查找或创建客户 // Logan.Li
|
||||||
|
var customer model.Customer
|
||||||
|
if err := s.db.Where("email = ?", req.CustomerEmail).First(&customer).Error; err != nil {
|
||||||
|
name := req.CustomerName
|
||||||
|
if name == "" {
|
||||||
|
name = req.CustomerEmail
|
||||||
|
}
|
||||||
|
customer = model.Customer{Name: name, Email: req.CustomerEmail, Status: 1}
|
||||||
|
if err := s.db.Create(&customer).Error; err != nil {
|
||||||
|
return nil, errors.New("创建客户失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 校验产品与计划 // Logan.Li
|
||||||
|
var product model.Product
|
||||||
|
if err := s.db.First(&product, req.ProductID).Error; err != nil {
|
||||||
|
return nil, errors.New("产品不存在")
|
||||||
|
}
|
||||||
|
var plan model.Plan
|
||||||
|
if err := s.db.First(&plan, req.PlanID).Error; err != nil {
|
||||||
|
return nil, errors.New("定价计划不存在")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 防重复购买检查 // Logan.Li
|
||||||
|
checkResult := s.CheckDuplicatePurchase(customer.ID, req.ProductID, plan.PlanType)
|
||||||
|
if !checkResult.AllowPurchase {
|
||||||
|
return nil, fmt.Errorf("重复购买拦截: %s", checkResult.Reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 创建订单 // Logan.Li
|
||||||
|
order := model.Order{
|
||||||
|
OrderNo: utils.GenerateOrderNo(),
|
||||||
|
CustomerID: customer.ID,
|
||||||
|
ProductID: req.ProductID,
|
||||||
|
PlanID: req.PlanID,
|
||||||
|
Amount: plan.Price,
|
||||||
|
Currency: plan.Currency,
|
||||||
|
Status: "pending",
|
||||||
|
ClientIP: clientIP,
|
||||||
|
UserAgent: userAgent,
|
||||||
|
}
|
||||||
|
if err := s.db.Create(&order).Error; err != nil {
|
||||||
|
return nil, errors.New("创建订单失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 选取可用支付渠道 // Logan.Li
|
||||||
|
var gateway model.PaymentGatewayConfig
|
||||||
|
if err := s.db.Where("enabled = ?", true).Order("priority ASC").First(&gateway).Error; err != nil {
|
||||||
|
return nil, errors.New("暂无可用的支付渠道")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. 生成收银台地址(占位) // Logan.Li
|
||||||
|
checkoutURL := fmt.Sprintf("https://checkout.%s.com/?order_no=%s", gateway.Gateway, order.OrderNo)
|
||||||
|
|
||||||
|
return &dto.CreateOrderResponse{
|
||||||
|
OrderNo: order.OrderNo,
|
||||||
|
CheckoutURL: checkoutURL,
|
||||||
|
Amount: plan.Price,
|
||||||
|
Currency: plan.Currency,
|
||||||
|
Status: "pending",
|
||||||
|
Gateway: gateway.Gateway,
|
||||||
|
ExpiresAt: time.Now().Add(30 * time.Minute).Format("2006-01-02 15:04:05"),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PaymentWebhook 支付回调(幂等 + 事务) // Logan.Li
|
||||||
|
func (s *OrderService) PaymentWebhook(req dto.PaymentWebhookRequest, clientIP string) error {
|
||||||
|
// 幂等检查 // Logan.Li
|
||||||
|
var order model.Order
|
||||||
|
if err := s.db.Where("order_no = ?", req.OrderNo).First(&order).Error; err != nil {
|
||||||
|
return errors.New("订单不存在")
|
||||||
|
}
|
||||||
|
if order.Status == "paid" || order.Status == "refunded" {
|
||||||
|
return nil // 已处理,幂等返回
|
||||||
|
}
|
||||||
|
|
||||||
|
// 事务处理 // Logan.Li
|
||||||
|
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
now := time.Now()
|
||||||
|
switch req.Status {
|
||||||
|
case "paid", "completed":
|
||||||
|
// 更新订单 // Logan.Li
|
||||||
|
order.Status = "paid"
|
||||||
|
order.GatewayOrderNo = req.GatewayOrderNo
|
||||||
|
order.PaymentGateway = req.Gateway
|
||||||
|
order.PaymentMethod = req.Gateway
|
||||||
|
order.PaidAt = &now
|
||||||
|
if err := tx.Save(&order).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发放 License // Logan.Li
|
||||||
|
var plan model.Plan
|
||||||
|
tx.First(&plan, order.PlanID)
|
||||||
|
|
||||||
|
var validUntil *time.Time
|
||||||
|
var endDate time.Time
|
||||||
|
if plan.Duration == 0 {
|
||||||
|
endDate = model.PermanentEndDate
|
||||||
|
} else {
|
||||||
|
endDate = now.AddDate(0, 0, plan.Duration)
|
||||||
|
validUntil = &endDate
|
||||||
|
}
|
||||||
|
|
||||||
|
lic := model.License{
|
||||||
|
LicenseKey: utils.GenerateLicenseKey(),
|
||||||
|
CustomerID: order.CustomerID,
|
||||||
|
ProductID: order.ProductID,
|
||||||
|
Status: model.LicenseStatusActive,
|
||||||
|
StartDate: now,
|
||||||
|
EndDate: endDate,
|
||||||
|
Duration: plan.Duration,
|
||||||
|
MaxActivations: plan.MaxDevices,
|
||||||
|
}
|
||||||
|
if err := tx.Create(&lic).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
order.LicenseID = lic.ID
|
||||||
|
tx.Save(&order)
|
||||||
|
|
||||||
|
// 更新权益记录(防重复购买核心) // Logan.Li
|
||||||
|
var ent model.CustomerEntitlement
|
||||||
|
entResult := tx.Where("customer_id = ? AND product_id = ?", order.CustomerID, order.ProductID).First(&ent)
|
||||||
|
renewable := plan.PlanType != "perpetual" && plan.PlanType != "lifetime"
|
||||||
|
if entResult.Error != nil {
|
||||||
|
ent = model.CustomerEntitlement{
|
||||||
|
CustomerID: order.CustomerID,
|
||||||
|
ProductID: order.ProductID,
|
||||||
|
PlanID: order.PlanID,
|
||||||
|
LicenseID: lic.ID,
|
||||||
|
LicenseKey: lic.LicenseKey,
|
||||||
|
PlanType: plan.PlanType,
|
||||||
|
Status: "active",
|
||||||
|
ValidFrom: now,
|
||||||
|
ValidUntil: validUntil,
|
||||||
|
Renewable: renewable,
|
||||||
|
}
|
||||||
|
tx.Create(&ent)
|
||||||
|
} else {
|
||||||
|
ent.PlanID = order.PlanID
|
||||||
|
ent.LicenseID = lic.ID
|
||||||
|
ent.LicenseKey = lic.LicenseKey
|
||||||
|
ent.PlanType = plan.PlanType
|
||||||
|
ent.Status = "active"
|
||||||
|
ent.ValidFrom = now
|
||||||
|
ent.ValidUntil = validUntil
|
||||||
|
ent.Renewable = renewable
|
||||||
|
tx.Save(&ent)
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.LogSystemAction(tx, "payment_completed", "order", order.ID, order.OrderNo,
|
||||||
|
fmt.Sprintf("订单支付成功,已发放 License: %s", lic.LicenseKey), clientIP)
|
||||||
|
|
||||||
|
case "failed", "cancelled":
|
||||||
|
order.Status = req.Status
|
||||||
|
if err := tx.Save(&order).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
case "refunded":
|
||||||
|
order.Status = "refunded"
|
||||||
|
order.RefundAt = &now
|
||||||
|
if err := tx.Save(&order).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// 吊销关联 License // Logan.Li
|
||||||
|
if order.LicenseID > 0 {
|
||||||
|
tx.Model(&model.License{}).Where("id = ?", order.LicenseID).
|
||||||
|
Updates(map[string]interface{}{"status": model.LicenseStatusRevoked, "revoke_reason": "订单退款"})
|
||||||
|
tx.Model(&model.CustomerEntitlement{}).Where("license_id = ?", order.LicenseID).
|
||||||
|
Update("status", "revoked")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ManualGrant 手动发放 License(管理后台) // Logan.Li
|
||||||
|
func (s *OrderService) ManualGrant(req dto.ManualGrantRequest, operatorID uint, operatorName, clientIP string) (map[string]interface{}, error) {
|
||||||
|
// 查找或创建客户 // Logan.Li
|
||||||
|
var customer model.Customer
|
||||||
|
if err := s.db.Where("email = ?", req.CustomerEmail).First(&customer).Error; err != nil {
|
||||||
|
name := req.CustomerName
|
||||||
|
if name == "" {
|
||||||
|
name = req.CustomerEmail
|
||||||
|
}
|
||||||
|
customer = model.Customer{Name: name, Email: req.CustomerEmail, Status: 1}
|
||||||
|
if err := s.db.Create(&customer).Error; err != nil {
|
||||||
|
return nil, errors.New("创建客户失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var plan model.Plan
|
||||||
|
if err := s.db.First(&plan, req.PlanID).Error; err != nil {
|
||||||
|
return nil, errors.New("定价计划不存在")
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
var endDate time.Time
|
||||||
|
var validUntil *time.Time
|
||||||
|
if plan.Duration == 0 {
|
||||||
|
endDate = model.PermanentEndDate
|
||||||
|
} else {
|
||||||
|
endDate = now.AddDate(0, 0, plan.Duration)
|
||||||
|
validUntil = &endDate
|
||||||
|
}
|
||||||
|
|
||||||
|
lic := model.License{
|
||||||
|
LicenseKey: utils.GenerateLicenseKey(),
|
||||||
|
CustomerID: customer.ID,
|
||||||
|
ProductID: req.ProductID,
|
||||||
|
Status: model.LicenseStatusActive,
|
||||||
|
StartDate: now,
|
||||||
|
EndDate: endDate,
|
||||||
|
Duration: plan.Duration,
|
||||||
|
MaxActivations: plan.MaxDevices,
|
||||||
|
Notes: req.Notes,
|
||||||
|
}
|
||||||
|
if err := s.db.Create(&lic).Error; err != nil {
|
||||||
|
return nil, errors.New("创建 License 失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建/更新权益记录 // Logan.Li
|
||||||
|
renewable := plan.PlanType != "perpetual" && plan.PlanType != "lifetime"
|
||||||
|
var ent model.CustomerEntitlement
|
||||||
|
if err := s.db.Where("customer_id = ? AND product_id = ?", customer.ID, req.ProductID).First(&ent).Error; err != nil {
|
||||||
|
ent = model.CustomerEntitlement{
|
||||||
|
CustomerID: customer.ID, ProductID: req.ProductID, PlanID: req.PlanID,
|
||||||
|
LicenseID: lic.ID, LicenseKey: lic.LicenseKey, PlanType: plan.PlanType,
|
||||||
|
Status: "active", ValidFrom: now, ValidUntil: validUntil, Renewable: renewable,
|
||||||
|
}
|
||||||
|
s.db.Create(&ent)
|
||||||
|
} else {
|
||||||
|
ent.PlanID = req.PlanID
|
||||||
|
ent.LicenseID = lic.ID
|
||||||
|
ent.LicenseKey = lic.LicenseKey
|
||||||
|
ent.PlanType = plan.PlanType
|
||||||
|
ent.Status = "active"
|
||||||
|
ent.ValidFrom = now
|
||||||
|
ent.ValidUntil = validUntil
|
||||||
|
ent.Renewable = renewable
|
||||||
|
s.db.Save(&ent)
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.LogSystemAction(s.db, "manual_grant", "license", lic.ID, lic.LicenseKey,
|
||||||
|
fmt.Sprintf("手动发放 License: %s 给客户 %s", lic.LicenseKey, req.CustomerEmail), clientIP)
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"license_key": lic.LicenseKey,
|
||||||
|
"license_id": lic.ID,
|
||||||
|
"customer_id": customer.ID,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckDuplicatePurchase 防重复购买检测 // Logan.Li
|
||||||
|
func (s *OrderService) CheckDuplicatePurchase(customerID, productID uint, newPlanType string) dto.DuplicatePurchaseCheckResponse {
|
||||||
|
var entitlements []model.CustomerEntitlement
|
||||||
|
s.db.Where("customer_id = ? AND product_id = ?", customerID, productID).Find(&entitlements)
|
||||||
|
|
||||||
|
if len(entitlements) == 0 {
|
||||||
|
return dto.DuplicatePurchaseCheckResponse{AllowPurchase: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 已有有效的永久/买断授权:禁止重复购买,但可升级 // Logan.Li
|
||||||
|
for _, ent := range entitlements {
|
||||||
|
if ent.Status != "active" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch ent.PlanType {
|
||||||
|
case "perpetual", "lifetime":
|
||||||
|
return dto.DuplicatePurchaseCheckResponse{
|
||||||
|
AllowPurchase: false,
|
||||||
|
Reason: "您已拥有该产品的永久授权,无需重复购买",
|
||||||
|
ExistingEntitlementID: ent.ID,
|
||||||
|
ExistingLicenseKey: ent.LicenseKey,
|
||||||
|
ExistingPlanType: ent.PlanType,
|
||||||
|
CanUpgrade: true,
|
||||||
|
}
|
||||||
|
case "monthly", "yearly":
|
||||||
|
// 订阅可续费 // Logan.Li
|
||||||
|
if ent.Renewable && (newPlanType == "monthly" || newPlanType == "yearly") {
|
||||||
|
return dto.DuplicatePurchaseCheckResponse{
|
||||||
|
AllowPurchase: true,
|
||||||
|
Reason: "您有有效的订阅,将为您续费",
|
||||||
|
ExistingEntitlementID: ent.ID,
|
||||||
|
ExistingLicenseKey: ent.LicenseKey,
|
||||||
|
ExistingPlanType: ent.PlanType,
|
||||||
|
CanUpgrade: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 已过期但可续费 // Logan.Li
|
||||||
|
for _, ent := range entitlements {
|
||||||
|
if ent.Status == "expired" && ent.Renewable {
|
||||||
|
return dto.DuplicatePurchaseCheckResponse{
|
||||||
|
AllowPurchase: true,
|
||||||
|
Reason: "您的订阅已过期,可以续费恢复",
|
||||||
|
ExistingEntitlementID: ent.ID,
|
||||||
|
ExistingLicenseKey: ent.LicenseKey,
|
||||||
|
ExistingPlanType: ent.PlanType,
|
||||||
|
CanUpgrade: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return dto.DuplicatePurchaseCheckResponse{AllowPurchase: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckPurchaseStatus 查询购买状态 // Logan.Li
|
||||||
|
func (s *OrderService) CheckPurchaseStatus(customerEmail string, productID uint) (*dto.CheckPurchaseStatusResponse, error) {
|
||||||
|
var customer model.Customer
|
||||||
|
if err := s.db.Where("email = ?", customerEmail).First(&customer).Error; err != nil {
|
||||||
|
return &dto.CheckPurchaseStatusResponse{
|
||||||
|
Purchased: false, HasActiveLicense: false, Message: "未找到购买记录",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var ent model.CustomerEntitlement
|
||||||
|
if err := s.db.Where("customer_id = ? AND product_id = ?", customer.ID, productID).
|
||||||
|
Preload("Plan").First(&ent).Error; err != nil {
|
||||||
|
return &dto.CheckPurchaseStatusResponse{
|
||||||
|
Purchased: false, HasActiveLicense: false, Message: "未购买该产品",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
daysLeft := 0
|
||||||
|
if ent.ValidUntil != nil {
|
||||||
|
daysLeft = utils.CalculateDaysLeft(*ent.ValidUntil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询 License 设备数 // Logan.Li
|
||||||
|
var lic model.License
|
||||||
|
var currentDevices int64
|
||||||
|
licenseStatus := ""
|
||||||
|
maxDevices := 0
|
||||||
|
if ent.LicenseID > 0 {
|
||||||
|
s.db.First(&lic, ent.LicenseID)
|
||||||
|
licenseStatus = lic.Status
|
||||||
|
maxDevices = lic.MaxActivations
|
||||||
|
s.db.Model(&model.Activation{}).Where("license_id = ?", lic.ID).Count(¤tDevices)
|
||||||
|
}
|
||||||
|
|
||||||
|
isActive := ent.Status == "active" && (ent.ValidUntil == nil || daysLeft > 0)
|
||||||
|
|
||||||
|
var validUntilStr *string
|
||||||
|
if ent.ValidUntil != nil {
|
||||||
|
s := ent.ValidUntil.Format("2006-01-02")
|
||||||
|
validUntilStr = &s
|
||||||
|
}
|
||||||
|
|
||||||
|
return &dto.CheckPurchaseStatusResponse{
|
||||||
|
Purchased: true,
|
||||||
|
HasActiveLicense: isActive,
|
||||||
|
LicenseKey: ent.LicenseKey,
|
||||||
|
LicenseStatus: licenseStatus,
|
||||||
|
PlanType: ent.PlanType,
|
||||||
|
ValidUntil: validUntilStr,
|
||||||
|
DaysLeft: daysLeft,
|
||||||
|
Renewable: ent.Renewable,
|
||||||
|
MaxDevices: maxDevices,
|
||||||
|
CurrentDevices: int(currentDevices),
|
||||||
|
EntitlementID: ent.ID,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// toOrderResponse 模型转响应 DTO // Logan.Li
|
||||||
|
func toOrderResponse(o model.Order) dto.OrderResponse {
|
||||||
|
resp := dto.OrderResponse{
|
||||||
|
ID: o.ID,
|
||||||
|
OrderNo: o.OrderNo,
|
||||||
|
CustomerID: o.CustomerID,
|
||||||
|
ProductID: o.ProductID,
|
||||||
|
PlanID: o.PlanID,
|
||||||
|
LicenseID: o.LicenseID,
|
||||||
|
Amount: o.Amount,
|
||||||
|
Currency: o.Currency,
|
||||||
|
Status: o.Status,
|
||||||
|
PaymentGateway: o.PaymentGateway,
|
||||||
|
CreatedAt: o.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||||
|
}
|
||||||
|
if o.Customer.ID > 0 {
|
||||||
|
resp.CustomerName = o.Customer.Name
|
||||||
|
}
|
||||||
|
if o.Product.ID > 0 {
|
||||||
|
resp.ProductName = o.Product.Name
|
||||||
|
}
|
||||||
|
if o.Plan.ID > 0 {
|
||||||
|
resp.PlanName = o.Plan.Name
|
||||||
|
}
|
||||||
|
if o.PaidAt != nil {
|
||||||
|
resp.PaidAt = o.PaidAt.Format("2006-01-02 15:04:05")
|
||||||
|
}
|
||||||
|
return resp
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
package plan
|
||||||
|
|
||||||
|
import (
|
||||||
|
"backend/internal/dto"
|
||||||
|
"backend/internal/model"
|
||||||
|
"backend/utils"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PlanService 定价计划业务逻辑层 // Logan.Li
|
||||||
|
type PlanService struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPlanService 创建 service 实例 // Logan.Li
|
||||||
|
func NewPlanService(db *gorm.DB) *PlanService {
|
||||||
|
return &PlanService{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// List 分页查询定价计划 // Logan.Li
|
||||||
|
func (s *PlanService) List(page, pageSize int, productID uint) ([]dto.PlanResponse, int64, error) {
|
||||||
|
var plans []model.Plan
|
||||||
|
var total int64
|
||||||
|
|
||||||
|
query := s.db.Model(&model.Plan{}).Preload("Product")
|
||||||
|
if productID > 0 {
|
||||||
|
query = query.Where("product_id = ?", productID)
|
||||||
|
}
|
||||||
|
|
||||||
|
query.Count(&total)
|
||||||
|
if err := query.Order("sort_order ASC, created_at DESC").
|
||||||
|
Offset((page - 1) * pageSize).Limit(pageSize).
|
||||||
|
Find(&plans).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make([]dto.PlanResponse, 0, len(plans))
|
||||||
|
for _, p := range plans {
|
||||||
|
results = append(results, toPlanResponse(p))
|
||||||
|
}
|
||||||
|
return results, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByProduct 查询某产品的上架计划(公开接口) // Logan.Li
|
||||||
|
func (s *PlanService) GetByProduct(productID uint) ([]dto.PlanResponse, error) {
|
||||||
|
var plans []model.Plan
|
||||||
|
if err := s.db.Where("product_id = ? AND status = ?", productID, 1).
|
||||||
|
Order("sort_order ASC").Find(&plans).Error; err != nil {
|
||||||
|
return nil, errors.New("获取定价计划失败")
|
||||||
|
}
|
||||||
|
results := make([]dto.PlanResponse, 0, len(plans))
|
||||||
|
for _, p := range plans {
|
||||||
|
results = append(results, toPlanResponse(p))
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create 创建定价计划 // Logan.Li
|
||||||
|
func (s *PlanService) Create(req dto.PlanCreate) (*dto.PlanResponse, error) {
|
||||||
|
var product model.Product
|
||||||
|
if err := s.db.First(&product, req.ProductID).Error; err != nil {
|
||||||
|
return nil, errors.New("产品不存在")
|
||||||
|
}
|
||||||
|
|
||||||
|
status := 1
|
||||||
|
if req.Status != nil {
|
||||||
|
status = *req.Status
|
||||||
|
}
|
||||||
|
currency := req.Currency
|
||||||
|
if currency == "" {
|
||||||
|
currency = "USD"
|
||||||
|
}
|
||||||
|
|
||||||
|
plan := model.Plan{
|
||||||
|
ProductID: req.ProductID,
|
||||||
|
Name: req.Name,
|
||||||
|
PlanType: req.PlanType,
|
||||||
|
Price: req.Price,
|
||||||
|
Currency: currency,
|
||||||
|
Duration: req.Duration,
|
||||||
|
MaxDevices: req.MaxDevices,
|
||||||
|
Features: utils.FormatFeatures(req.Features),
|
||||||
|
SortOrder: req.SortOrder,
|
||||||
|
Status: status,
|
||||||
|
}
|
||||||
|
if err := s.db.Create(&plan).Error; err != nil {
|
||||||
|
return nil, errors.New("创建定价计划失败")
|
||||||
|
}
|
||||||
|
resp := toPlanResponse(plan)
|
||||||
|
return &resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update 更新定价计划 // Logan.Li
|
||||||
|
func (s *PlanService) Update(id uint, req dto.PlanUpdate) (*dto.PlanResponse, error) {
|
||||||
|
var plan model.Plan
|
||||||
|
if err := s.db.First(&plan, id).Error; err != nil {
|
||||||
|
return nil, errors.New("定价计划不存在")
|
||||||
|
}
|
||||||
|
|
||||||
|
updates := map[string]interface{}{}
|
||||||
|
if req.Name != "" {
|
||||||
|
updates["name"] = req.Name
|
||||||
|
}
|
||||||
|
if req.Price != nil {
|
||||||
|
updates["price"] = *req.Price
|
||||||
|
}
|
||||||
|
if req.Currency != "" {
|
||||||
|
updates["currency"] = req.Currency
|
||||||
|
}
|
||||||
|
if req.Duration != nil {
|
||||||
|
updates["duration"] = *req.Duration
|
||||||
|
}
|
||||||
|
if req.MaxDevices != nil {
|
||||||
|
updates["max_devices"] = *req.MaxDevices
|
||||||
|
}
|
||||||
|
if req.Features != nil {
|
||||||
|
updates["features"] = utils.FormatFeatures(req.Features)
|
||||||
|
}
|
||||||
|
if req.SortOrder != nil {
|
||||||
|
updates["sort_order"] = *req.SortOrder
|
||||||
|
}
|
||||||
|
if req.Status != nil {
|
||||||
|
updates["status"] = *req.Status
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.db.Model(&plan).Updates(updates).Error; err != nil {
|
||||||
|
return nil, errors.New("更新定价计划失败")
|
||||||
|
}
|
||||||
|
s.db.First(&plan, id)
|
||||||
|
resp := toPlanResponse(plan)
|
||||||
|
return &resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete 删除定价计划 // Logan.Li
|
||||||
|
func (s *PlanService) Delete(id uint) error {
|
||||||
|
return s.db.Delete(&model.Plan{}, id).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToggleStatus 切换上下架状态 // Logan.Li
|
||||||
|
func (s *PlanService) ToggleStatus(id uint, status int) error {
|
||||||
|
return s.db.Model(&model.Plan{}).Where("id = ?", id).Update("status", status).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// toPlanResponse 模型转响应 DTO // Logan.Li
|
||||||
|
func toPlanResponse(p model.Plan) dto.PlanResponse {
|
||||||
|
return dto.PlanResponse{
|
||||||
|
ID: p.ID,
|
||||||
|
ProductID: p.ProductID,
|
||||||
|
Name: p.Name,
|
||||||
|
PlanType: p.PlanType,
|
||||||
|
Price: p.Price,
|
||||||
|
Currency: p.Currency,
|
||||||
|
Duration: p.Duration,
|
||||||
|
MaxDevices: p.MaxDevices,
|
||||||
|
Features: utils.ParseFeatures(p.Features),
|
||||||
|
SortOrder: p.SortOrder,
|
||||||
|
Status: p.Status,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
package product
|
||||||
|
|
||||||
|
import (
|
||||||
|
"backend/internal/dto"
|
||||||
|
"backend/internal/model"
|
||||||
|
"backend/utils"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ProductService 产品业务逻辑层 // Logan.Li
|
||||||
|
type ProductService struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewProductService 创建 service 实例 // Logan.Li
|
||||||
|
func NewProductService(db *gorm.DB) *ProductService {
|
||||||
|
return &ProductService{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// List 分页查询产品 // Logan.Li
|
||||||
|
func (s *ProductService) List(page, pageSize int, keyword string) ([]dto.ProductResponse, int64, error) {
|
||||||
|
var products []model.Product
|
||||||
|
var total int64
|
||||||
|
|
||||||
|
query := s.db.Model(&model.Product{})
|
||||||
|
if keyword != "" {
|
||||||
|
query = query.Where("name LIKE ? OR code LIKE ?", "%"+keyword+"%", "%"+keyword+"%")
|
||||||
|
}
|
||||||
|
|
||||||
|
query.Count(&total)
|
||||||
|
if err := query.Order("created_at DESC").
|
||||||
|
Offset((page - 1) * pageSize).Limit(pageSize).
|
||||||
|
Find(&products).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make([]dto.ProductResponse, 0, len(products))
|
||||||
|
for _, p := range products {
|
||||||
|
results = append(results, toProductResponse(p))
|
||||||
|
}
|
||||||
|
return results, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAll 获取全部产品(下拉选项用) // Logan.Li
|
||||||
|
func (s *ProductService) GetAll() ([]dto.ProductResponse, error) {
|
||||||
|
var products []model.Product
|
||||||
|
if err := s.db.Where("status = ?", 1).Order("created_at DESC").Find(&products).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
results := make([]dto.ProductResponse, 0, len(products))
|
||||||
|
for _, p := range products {
|
||||||
|
results = append(results, toProductResponse(p))
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByID 根据 ID 获取产品 // Logan.Li
|
||||||
|
func (s *ProductService) GetByID(id uint) (*dto.ProductResponse, error) {
|
||||||
|
var product model.Product
|
||||||
|
if err := s.db.First(&product, id).Error; err != nil {
|
||||||
|
return nil, errors.New("产品不存在")
|
||||||
|
}
|
||||||
|
resp := toProductResponse(product)
|
||||||
|
return &resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create 创建产品 // Logan.Li
|
||||||
|
func (s *ProductService) Create(req dto.ProductCreate) (*dto.ProductResponse, error) {
|
||||||
|
// 检查 code 唯一性 // Logan.Li
|
||||||
|
var count int64
|
||||||
|
s.db.Model(&model.Product{}).Where("code = ?", req.Code).Count(&count)
|
||||||
|
if count > 0 {
|
||||||
|
return nil, errors.New("产品编码已存在")
|
||||||
|
}
|
||||||
|
|
||||||
|
status := 1
|
||||||
|
if req.Status != nil {
|
||||||
|
status = *req.Status
|
||||||
|
}
|
||||||
|
priceModel := req.PriceModel
|
||||||
|
if priceModel == "" {
|
||||||
|
priceModel = "subscription"
|
||||||
|
}
|
||||||
|
|
||||||
|
product := model.Product{
|
||||||
|
Name: req.Name,
|
||||||
|
Code: req.Code,
|
||||||
|
Version: req.Version,
|
||||||
|
Description: req.Description,
|
||||||
|
Features: utils.FormatFeatures(req.Features),
|
||||||
|
Price: req.Price,
|
||||||
|
PriceModel: priceModel,
|
||||||
|
Status: status,
|
||||||
|
}
|
||||||
|
if err := s.db.Create(&product).Error; err != nil {
|
||||||
|
return nil, errors.New("创建产品失败")
|
||||||
|
}
|
||||||
|
resp := toProductResponse(product)
|
||||||
|
return &resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update 更新产品 // Logan.Li
|
||||||
|
func (s *ProductService) Update(id uint, req dto.ProductUpdate) (*dto.ProductResponse, error) {
|
||||||
|
var product model.Product
|
||||||
|
if err := s.db.First(&product, id).Error; err != nil {
|
||||||
|
return nil, errors.New("产品不存在")
|
||||||
|
}
|
||||||
|
|
||||||
|
updates := map[string]interface{}{}
|
||||||
|
if req.Name != "" {
|
||||||
|
updates["name"] = req.Name
|
||||||
|
}
|
||||||
|
if req.Version != "" {
|
||||||
|
updates["version"] = req.Version
|
||||||
|
}
|
||||||
|
updates["description"] = req.Description
|
||||||
|
if req.Features != nil {
|
||||||
|
updates["features"] = utils.FormatFeatures(req.Features)
|
||||||
|
}
|
||||||
|
if req.Price != nil {
|
||||||
|
updates["price"] = *req.Price
|
||||||
|
}
|
||||||
|
if req.PriceModel != "" {
|
||||||
|
updates["price_model"] = req.PriceModel
|
||||||
|
}
|
||||||
|
if req.Status != nil {
|
||||||
|
updates["status"] = *req.Status
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.db.Model(&product).Updates(updates).Error; err != nil {
|
||||||
|
return nil, errors.New("更新产品失败")
|
||||||
|
}
|
||||||
|
// 重新查询获取最新数据 // Logan.Li
|
||||||
|
s.db.First(&product, id)
|
||||||
|
resp := toProductResponse(product)
|
||||||
|
return &resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete 删除产品(软删除) // Logan.Li
|
||||||
|
func (s *ProductService) Delete(id uint) error {
|
||||||
|
var count int64
|
||||||
|
s.db.Model(&model.License{}).Where("product_id = ?", id).Count(&count)
|
||||||
|
if count > 0 {
|
||||||
|
return errors.New("该产品下存在 License,无法删除")
|
||||||
|
}
|
||||||
|
return s.db.Delete(&model.Product{}, id).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateStatus 切换产品上下架状态 // Logan.Li
|
||||||
|
func (s *ProductService) UpdateStatus(id uint, status int) error {
|
||||||
|
return s.db.Model(&model.Product{}).Where("id = ?", id).Update("status", status).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// toProductResponse 模型转响应 DTO // Logan.Li
|
||||||
|
func toProductResponse(p model.Product) dto.ProductResponse {
|
||||||
|
return dto.ProductResponse{
|
||||||
|
ID: p.ID,
|
||||||
|
Name: p.Name,
|
||||||
|
Code: p.Code,
|
||||||
|
Version: p.Version,
|
||||||
|
Description: p.Description,
|
||||||
|
Features: utils.ParseFeatures(p.Features),
|
||||||
|
Price: p.Price,
|
||||||
|
PriceModel: p.PriceModel,
|
||||||
|
Status: p.Status,
|
||||||
|
CreatedAt: p.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||||
|
UpdatedAt: p.UpdatedAt.Format("2006-01-02 15:04:05"),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
package setting
|
||||||
|
|
||||||
|
import (
|
||||||
|
"backend/internal/dto"
|
||||||
|
"backend/internal/model"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SettingService 系统设置业务逻辑层 // Logan.Li
|
||||||
|
type SettingService struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSettingService 创建 service 实例 // Logan.Li
|
||||||
|
func NewSettingService(db *gorm.DB) *SettingService {
|
||||||
|
return &SettingService{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get 获取系统设置(单行) // Logan.Li
|
||||||
|
func (s *SettingService) Get() (*model.Setting, error) {
|
||||||
|
var setting model.Setting
|
||||||
|
if err := s.db.First(&setting).Error; err != nil {
|
||||||
|
return nil, errors.New("系统设置不存在")
|
||||||
|
}
|
||||||
|
return &setting, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update 更新系统设置 // Logan.Li
|
||||||
|
func (s *SettingService) Update(req dto.SettingUpdate) (*model.Setting, error) {
|
||||||
|
var setting model.Setting
|
||||||
|
if err := s.db.First(&setting).Error; err != nil {
|
||||||
|
return nil, errors.New("系统设置不存在")
|
||||||
|
}
|
||||||
|
|
||||||
|
updates := map[string]interface{}{}
|
||||||
|
if req.SystemName != nil {
|
||||||
|
updates["system_name"] = *req.SystemName
|
||||||
|
}
|
||||||
|
if req.DefaultDuration != nil {
|
||||||
|
updates["default_duration"] = *req.DefaultDuration
|
||||||
|
}
|
||||||
|
if req.DefaultMaxActivations != nil {
|
||||||
|
updates["default_max_activations"] = *req.DefaultMaxActivations
|
||||||
|
}
|
||||||
|
if req.ExpireReminderDays != nil {
|
||||||
|
updates["expire_reminder_days"] = *req.ExpireReminderDays
|
||||||
|
}
|
||||||
|
if req.APIRateLimit != nil {
|
||||||
|
updates["api_rate_limit"] = *req.APIRateLimit
|
||||||
|
}
|
||||||
|
if req.LogRetentionDays != nil {
|
||||||
|
updates["log_retention_days"] = *req.LogRetentionDays
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(updates) > 0 {
|
||||||
|
if err := s.db.Model(&setting).Updates(updates).Error; err != nil {
|
||||||
|
return nil, errors.New("更新系统设置失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.db.First(&setting)
|
||||||
|
return &setting, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 审计日志 ============
|
||||||
|
|
||||||
|
// ListAuditLogs 分页查询审计日志 // Logan.Li
|
||||||
|
func (s *SettingService) ListAuditLogs(page, pageSize int, action, targetType string) ([]dto.AuditLogResponse, int64, error) {
|
||||||
|
var logs []model.AuditLog
|
||||||
|
var total int64
|
||||||
|
|
||||||
|
query := s.db.Model(&model.AuditLog{})
|
||||||
|
if action != "" {
|
||||||
|
query = query.Where("action = ?", action)
|
||||||
|
}
|
||||||
|
if targetType != "" {
|
||||||
|
query = query.Where("target_type = ?", targetType)
|
||||||
|
}
|
||||||
|
|
||||||
|
query.Count(&total)
|
||||||
|
if err := query.Order("created_at DESC").
|
||||||
|
Offset((page - 1) * pageSize).Limit(pageSize).
|
||||||
|
Find(&logs).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make([]dto.AuditLogResponse, 0, len(logs))
|
||||||
|
for _, l := range logs {
|
||||||
|
results = append(results, dto.AuditLogResponse{
|
||||||
|
ID: l.ID,
|
||||||
|
Username: l.Username,
|
||||||
|
Action: l.Action,
|
||||||
|
TargetType: l.TargetType,
|
||||||
|
TargetID: l.TargetID,
|
||||||
|
TargetName: l.TargetName,
|
||||||
|
Detail: l.Detail,
|
||||||
|
Result: l.Result,
|
||||||
|
IP: l.IP,
|
||||||
|
CreatedAt: l.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return results, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearAuditLogs 清空审计日志 // Logan.Li
|
||||||
|
func (s *SettingService) ClearAuditLogs() error {
|
||||||
|
return s.db.Exec("DELETE FROM audit_logs").Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ License 模板 ============
|
||||||
|
|
||||||
|
// ListLicenseTemplates 查询 License 模板 // Logan.Li
|
||||||
|
func (s *SettingService) ListLicenseTemplates() ([]model.LicenseTemplate, error) {
|
||||||
|
var templates []model.LicenseTemplate
|
||||||
|
s.db.Order("created_at DESC").Find(&templates)
|
||||||
|
return templates, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveLicenseTemplate 创建/更新 License 模板 // Logan.Li
|
||||||
|
func (s *SettingService) SaveLicenseTemplate(tmpl model.LicenseTemplate) (*model.LicenseTemplate, error) {
|
||||||
|
if tmpl.Name == "" {
|
||||||
|
return nil, errors.New("模板名称不能为空")
|
||||||
|
}
|
||||||
|
if tmpl.ID > 0 {
|
||||||
|
if err := s.db.Save(&tmpl).Error; err != nil {
|
||||||
|
return nil, errors.New("更新模板失败")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err := s.db.Create(&tmpl).Error; err != nil {
|
||||||
|
return nil, errors.New("创建模板失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &tmpl, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteLicenseTemplate 删除 License 模板 // Logan.Li
|
||||||
|
func (s *SettingService) DeleteLicenseTemplate(id uint) error {
|
||||||
|
return s.db.Delete(&model.LicenseTemplate{}, id).Error
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"backend/config"
|
||||||
|
"backend/database"
|
||||||
|
"backend/router"
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// 加载配置 // Logan.Li
|
||||||
|
cfg := config.Load()
|
||||||
|
|
||||||
|
// 初始化数据库 // Logan.Li
|
||||||
|
db, err := database.Init(cfg)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("数据库初始化失败:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化路由 // Logan.Li
|
||||||
|
r := router.SetupRouter(db, cfg)
|
||||||
|
|
||||||
|
// 单二进制构建时提供嵌入的 Vue 前端;开发模式会自动跳过。 // Logan.Li
|
||||||
|
setupEmbeddedFrontend(r)
|
||||||
|
|
||||||
|
// 启动服务器 // Logan.Li
|
||||||
|
addr := ":" + cfg.Port
|
||||||
|
srv := &http.Server{
|
||||||
|
Addr: addr,
|
||||||
|
Handler: r,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 优雅关机: 在独立 goroutine 中启动服务器 // Logan.Li
|
||||||
|
go func() {
|
||||||
|
log.Printf("服务器启动在 %s", addr)
|
||||||
|
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
|
log.Fatal("服务器启动失败:", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// 等待中断信号 // Logan.Li
|
||||||
|
quit := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
<-quit
|
||||||
|
|
||||||
|
log.Println("正在关闭服务器...")
|
||||||
|
|
||||||
|
// 设置 5 秒超时来关闭服务器 // Logan.Li
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := srv.Shutdown(ctx); err != nil {
|
||||||
|
log.Fatal("服务器强制关闭:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("服务器已优雅关闭")
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"backend/config"
|
||||||
|
"backend/utils"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuthMiddleware 校验访问令牌并将用户身份写入请求上下文。 // Logan.Li
|
||||||
|
func AuthMiddleware(cfg *config.Config) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
authHeader := c.GetHeader("Authorization")
|
||||||
|
if authHeader == "" {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{
|
||||||
|
"code": 401,
|
||||||
|
"message": "未提供认证令牌",
|
||||||
|
"data": nil,
|
||||||
|
})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查 Bearer 前缀 // Logan.Li
|
||||||
|
parts := strings.SplitN(authHeader, " ", 2)
|
||||||
|
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{
|
||||||
|
"code": 401,
|
||||||
|
"message": "认证令牌格式错误",
|
||||||
|
"data": nil,
|
||||||
|
})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token := parts[1]
|
||||||
|
claims, err := utils.ParseToken(token, cfg.JWTSecret)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{
|
||||||
|
"code": 401,
|
||||||
|
"message": "无效的认证令牌",
|
||||||
|
"data": nil,
|
||||||
|
})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将用户身份写入上下文,供后续 handler 使用 // Logan.Li
|
||||||
|
c.Set("user_id", claims.UserID)
|
||||||
|
c.Set("username", claims.Username)
|
||||||
|
c.Set("role", claims.Role)
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"backend/config"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-contrib/cors"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CORSMiddleware CORS 中间件,使用显式白名单避免携带凭据时放行任意来源。 // Logan.Li
|
||||||
|
func CORSMiddleware(cfg *config.Config) gin.HandlerFunc {
|
||||||
|
origins := cfg.CORSOrigins
|
||||||
|
if len(origins) == 0 {
|
||||||
|
origins = []string{
|
||||||
|
"http://localhost:5181",
|
||||||
|
"http://127.0.0.1:5181",
|
||||||
|
"http://localhost:3000",
|
||||||
|
"http://127.0.0.1:3000",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cors.New(cors.Config{
|
||||||
|
AllowOrigins: origins,
|
||||||
|
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"},
|
||||||
|
AllowHeaders: []string{"Origin", "Content-Type", "Authorization", "X-Requested-With"},
|
||||||
|
ExposeHeaders: []string{"Content-Length", "Content-Type"},
|
||||||
|
AllowCredentials: true,
|
||||||
|
MaxAge: 12 * time.Hour,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
package router
|
||||||
|
|
||||||
|
import (
|
||||||
|
"backend/config"
|
||||||
|
"backend/internal/controller/analytics"
|
||||||
|
"backend/internal/controller/auth"
|
||||||
|
"backend/internal/controller/customer"
|
||||||
|
"backend/internal/controller/entitlement"
|
||||||
|
"backend/internal/controller/license"
|
||||||
|
"backend/internal/controller/order"
|
||||||
|
"backend/internal/controller/plan"
|
||||||
|
"backend/internal/controller/product"
|
||||||
|
"backend/internal/controller/setting"
|
||||||
|
"backend/middleware"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-contrib/gzip"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// register 同时注册带和不带 trailing slash 的路由 // Logan.Li
|
||||||
|
func register(r gin.IRoutes, method, relativePath string, handlers ...gin.HandlerFunc) {
|
||||||
|
isParamRoute := strings.Contains(relativePath, ":")
|
||||||
|
|
||||||
|
switch method {
|
||||||
|
case "GET":
|
||||||
|
r.GET(relativePath, handlers...)
|
||||||
|
if !isParamRoute {
|
||||||
|
r.GET(relativePath+"/", handlers...)
|
||||||
|
}
|
||||||
|
case "POST":
|
||||||
|
r.POST(relativePath, handlers...)
|
||||||
|
if !isParamRoute {
|
||||||
|
r.POST(relativePath+"/", handlers...)
|
||||||
|
}
|
||||||
|
case "PUT":
|
||||||
|
r.PUT(relativePath, handlers...)
|
||||||
|
if !isParamRoute {
|
||||||
|
r.PUT(relativePath+"/", handlers...)
|
||||||
|
}
|
||||||
|
case "DELETE":
|
||||||
|
r.DELETE(relativePath, handlers...)
|
||||||
|
if !isParamRoute {
|
||||||
|
r.DELETE(relativePath+"/", handlers...)
|
||||||
|
}
|
||||||
|
case "PATCH":
|
||||||
|
r.PATCH(relativePath, handlers...)
|
||||||
|
if !isParamRoute {
|
||||||
|
r.PATCH(relativePath+"/", handlers...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetupRouter 初始化路由 // Logan.Li
|
||||||
|
func SetupRouter(db *gorm.DB, cfg *config.Config) *gin.Engine {
|
||||||
|
gin.SetMode(cfg.Mode)
|
||||||
|
|
||||||
|
r := gin.Default()
|
||||||
|
r.RedirectTrailingSlash = false
|
||||||
|
r.RedirectFixedPath = false
|
||||||
|
|
||||||
|
// Gzip 压缩中间件 // Logan.Li
|
||||||
|
r.Use(gzip.Gzip(gzip.DefaultCompression))
|
||||||
|
|
||||||
|
// CORS 中间件 // Logan.Li
|
||||||
|
r.Use(middleware.CORSMiddleware(cfg))
|
||||||
|
|
||||||
|
// 健康检查 // Logan.Li
|
||||||
|
r.GET("/health", func(c *gin.Context) {
|
||||||
|
c.JSON(200, gin.H{"code": 0, "message": "ok", "data": nil})
|
||||||
|
})
|
||||||
|
|
||||||
|
// 初始化控制器 // Logan.Li
|
||||||
|
authController := auth.NewAuthController(db, cfg)
|
||||||
|
productController := product.NewProductController(db)
|
||||||
|
customerController := customer.NewCustomerController(db)
|
||||||
|
licenseController := license.NewLicenseController(db)
|
||||||
|
planController := plan.NewPlanController(db)
|
||||||
|
orderController := order.NewOrderController(db)
|
||||||
|
entitlementController := entitlement.NewEntitlementController(db)
|
||||||
|
analyticsController := analytics.NewAnalyticsController(db)
|
||||||
|
settingController := setting.NewSettingController(db)
|
||||||
|
|
||||||
|
api := r.Group("/api")
|
||||||
|
{
|
||||||
|
// ============ 认证路由(无需 JWT) ============
|
||||||
|
authGroup := api.Group("/auth")
|
||||||
|
{
|
||||||
|
register(authGroup, "POST", "/login", authController.Login)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 客户端授权契约(无需认证,供外部工具调用) ============
|
||||||
|
clientAPI := api.Group("/v1")
|
||||||
|
{
|
||||||
|
// License 验证 / 激活 / 停用 / 心跳
|
||||||
|
register(clientAPI, "POST", "/license/verify", licenseController.Verify)
|
||||||
|
register(clientAPI, "POST", "/license/activate", licenseController.ActivateDevice)
|
||||||
|
register(clientAPI, "POST", "/license/deactivate", licenseController.Deactivate)
|
||||||
|
register(clientAPI, "POST", "/license/heartbeat", licenseController.Heartbeat)
|
||||||
|
|
||||||
|
// 产品定价查询(展示用)
|
||||||
|
clientAPI.GET("/products/:product_id/plans", planController.GetByProduct)
|
||||||
|
|
||||||
|
// 订单与支付(客户端购买入口 + 支付回调)
|
||||||
|
register(clientAPI, "POST", "/orders", orderController.CreateOrder)
|
||||||
|
register(clientAPI, "POST", "/webhooks/payment", orderController.PaymentWebhook)
|
||||||
|
register(clientAPI, "POST", "/purchase/check", orderController.CheckDuplicatePurchase)
|
||||||
|
register(clientAPI, "POST", "/purchase/status", orderController.CheckPurchaseStatus)
|
||||||
|
|
||||||
|
// 客户权益查询(通过邮箱)
|
||||||
|
register(clientAPI, "GET", "/entitlements/my", entitlementController.GetByEmail)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 需要认证的管理路由 ============
|
||||||
|
protected := api.Group("", middleware.AuthMiddleware(cfg))
|
||||||
|
{
|
||||||
|
// 认证相关
|
||||||
|
register(protected, "GET", "/auth/me", authController.Me)
|
||||||
|
register(protected, "PUT", "/auth/password", authController.ChangePassword)
|
||||||
|
|
||||||
|
// 产品管理
|
||||||
|
products := protected.Group("/products")
|
||||||
|
{
|
||||||
|
register(products, "GET", "", productController.List)
|
||||||
|
register(products, "GET", "/all", productController.GetAll)
|
||||||
|
register(products, "POST", "", productController.Create)
|
||||||
|
register(products, "GET", "/:id", productController.Get)
|
||||||
|
register(products, "PUT", "/:id", productController.Update)
|
||||||
|
register(products, "DELETE", "/:id", productController.Delete)
|
||||||
|
register(products, "PATCH", "/:id/status", productController.UpdateStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 客户管理
|
||||||
|
customers := protected.Group("/customers")
|
||||||
|
{
|
||||||
|
register(customers, "GET", "", customerController.List)
|
||||||
|
register(customers, "GET", "/all", customerController.GetAll)
|
||||||
|
register(customers, "POST", "", customerController.Create)
|
||||||
|
register(customers, "GET", "/:id", customerController.Get)
|
||||||
|
register(customers, "PUT", "/:id", customerController.Update)
|
||||||
|
register(customers, "DELETE", "/:id", customerController.Delete)
|
||||||
|
register(customers, "PATCH", "/:id/status", customerController.UpdateStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
// License 管理
|
||||||
|
licenses := protected.Group("/licenses")
|
||||||
|
{
|
||||||
|
register(licenses, "GET", "", licenseController.List)
|
||||||
|
register(licenses, "POST", "", licenseController.Create)
|
||||||
|
register(licenses, "GET", "/export", licenseController.Export)
|
||||||
|
register(licenses, "GET", "/:id", licenseController.Get)
|
||||||
|
register(licenses, "PUT", "/:id", licenseController.Update)
|
||||||
|
register(licenses, "DELETE", "/:id", licenseController.Delete)
|
||||||
|
register(licenses, "POST", "/:id/renew", licenseController.Renew)
|
||||||
|
register(licenses, "POST", "/:id/revoke", licenseController.Revoke)
|
||||||
|
register(licenses, "POST", "/:id/suspend", licenseController.Suspend)
|
||||||
|
register(licenses, "POST", "/:id/activate", licenseController.Activate)
|
||||||
|
register(licenses, "GET", "/:id/activations", licenseController.GetActivations)
|
||||||
|
register(licenses, "POST", "/:id/activations/:activationId/offline", licenseController.ForceOffline)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 定价计划管理
|
||||||
|
plans := protected.Group("/plans")
|
||||||
|
{
|
||||||
|
register(plans, "GET", "", planController.List)
|
||||||
|
register(plans, "POST", "", planController.Create)
|
||||||
|
register(plans, "PUT", "/:id", planController.Update)
|
||||||
|
register(plans, "DELETE", "/:id", planController.Delete)
|
||||||
|
register(plans, "PATCH", "/:id/status", planController.ToggleStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 订单管理
|
||||||
|
orders := protected.Group("/orders")
|
||||||
|
{
|
||||||
|
register(orders, "GET", "", orderController.List)
|
||||||
|
register(orders, "GET", "/:id", orderController.GetDetail)
|
||||||
|
register(orders, "POST", "/manual-grant", orderController.ManualGrant)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 客户权益管理
|
||||||
|
entitlements := protected.Group("/entitlements")
|
||||||
|
{
|
||||||
|
register(entitlements, "GET", "", entitlementController.List)
|
||||||
|
register(entitlements, "GET", "/:id", entitlementController.Get)
|
||||||
|
register(entitlements, "POST", "/:id/revoke", entitlementController.Revoke)
|
||||||
|
register(entitlements, "POST", "/:id/renew", entitlementController.Renew)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 仪表板与数据分析
|
||||||
|
dashboard := protected.Group("/dashboard")
|
||||||
|
{
|
||||||
|
register(dashboard, "GET", "/stats", analyticsController.GetDashboardStats)
|
||||||
|
register(dashboard, "GET", "/expiring", analyticsController.GetExpiringLicenses)
|
||||||
|
register(dashboard, "GET", "/activities", analyticsController.GetRecentActivities)
|
||||||
|
register(dashboard, "GET", "/online-stats", analyticsController.GetOnlineStats)
|
||||||
|
register(dashboard, "GET", "/revenue", analyticsController.GetRevenueStats)
|
||||||
|
}
|
||||||
|
|
||||||
|
analyticsGroup := protected.Group("/analytics")
|
||||||
|
{
|
||||||
|
register(analyticsGroup, "GET", "/license-growth", analyticsController.GetLicenseGrowth)
|
||||||
|
register(analyticsGroup, "GET", "/product-distribution", analyticsController.GetProductDistribution)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 日志审计与系统设置
|
||||||
|
logs := protected.Group("/logs")
|
||||||
|
{
|
||||||
|
register(logs, "GET", "", settingController.ListAuditLogs)
|
||||||
|
register(logs, "DELETE", "/clear", settingController.ClearAuditLogs)
|
||||||
|
}
|
||||||
|
|
||||||
|
system := protected.Group("/settings")
|
||||||
|
{
|
||||||
|
register(system, "GET", "", settingController.Get)
|
||||||
|
register(system, "PUT", "", settingController.Update)
|
||||||
|
register(system, "GET", "/license-templates", settingController.ListLicenseTemplates)
|
||||||
|
register(system, "POST", "/license-templates", settingController.SaveLicenseTemplate)
|
||||||
|
register(system, "DELETE", "/license-templates/:id", settingController.DeleteLicenseTemplate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return r
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# 此目录用于存放构建时嵌入的前端资源。
|
||||||
|
# 开发模式下为空,后端仅提供 API。
|
||||||
|
# 执行 `./run.sh build` 时,frontend/dist 的内容会被复制到此处。
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"backend/internal/model"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LogAction 记录操作日志(成功) // Logan.Li
|
||||||
|
func LogAction(db *gorm.DB, c *gin.Context, action, targetType string, targetID uint, targetName, oldData, newData, detail string) {
|
||||||
|
logEntry := model.AuditLog{
|
||||||
|
Action: action,
|
||||||
|
TargetType: targetType,
|
||||||
|
TargetID: targetID,
|
||||||
|
TargetName: targetName,
|
||||||
|
OldData: oldData,
|
||||||
|
NewData: newData,
|
||||||
|
Detail: detail,
|
||||||
|
Result: "success",
|
||||||
|
IP: c.ClientIP(),
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从上下文中获取操作人信息(管理后台调用时存在) // Logan.Li
|
||||||
|
if c != nil {
|
||||||
|
if userID, exists := c.Get("user_id"); exists {
|
||||||
|
if id, ok := userID.(uint); ok {
|
||||||
|
logEntry.UserID = id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if username, exists := c.Get("username"); exists {
|
||||||
|
if name, ok := username.(string); ok {
|
||||||
|
logEntry.Username = name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
db.Create(&logEntry)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogSystemAction 记录系统操作日志(无 HTTP 上下文,如支付回调) // Logan.Li
|
||||||
|
func LogSystemAction(db *gorm.DB, action, targetType string, targetID uint, targetName, detail, ip string) {
|
||||||
|
db.Create(&model.AuditLog{
|
||||||
|
UserID: 0,
|
||||||
|
Username: "system",
|
||||||
|
Action: action,
|
||||||
|
TargetType: targetType,
|
||||||
|
TargetID: targetID,
|
||||||
|
TargetName: targetName,
|
||||||
|
Detail: detail,
|
||||||
|
Result: "success",
|
||||||
|
IP: ip,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetClientIP 获取客户端真实 IP(兼容代理) // Logan.Li
|
||||||
|
func GetClientIP(c *gin.Context) string {
|
||||||
|
return c.ClientIP()
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Claims JWT 声明 // Logan.Li
|
||||||
|
type Claims struct {
|
||||||
|
UserID uint `json:"user_id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateToken 生成 JWT 令牌(HS256,24h 过期) // Logan.Li
|
||||||
|
func GenerateToken(userID uint, username, role, secret string) (string, error) {
|
||||||
|
claims := Claims{
|
||||||
|
UserID: userID,
|
||||||
|
Username: username,
|
||||||
|
Role: role,
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
|
||||||
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||||
|
Issuer: "opsx-licence",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
return token.SignedString([]byte(secret))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseToken 解析并校验 JWT 令牌 // Logan.Li
|
||||||
|
func ParseToken(tokenString, secret string) (*Claims, error) {
|
||||||
|
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
// 确保使用 HMAC 签名方法,防止算法混淆攻击 // Logan.Li
|
||||||
|
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||||
|
return nil, errors.New("非预期的签名算法")
|
||||||
|
}
|
||||||
|
return []byte(secret), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, errors.New("无效的令牌")
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GenerateLicenseKey 生成 License 密钥(格式: LIC-YYYY-XXXX-XXXX-XXXX-XXXX) // Logan.Li
|
||||||
|
func GenerateLicenseKey() string {
|
||||||
|
bytes := make([]byte, 16)
|
||||||
|
_, _ = rand.Read(bytes)
|
||||||
|
|
||||||
|
year := time.Now().Year()
|
||||||
|
part1 := hex.EncodeToString(bytes[0:4])
|
||||||
|
part2 := hex.EncodeToString(bytes[4:8])
|
||||||
|
part3 := hex.EncodeToString(bytes[8:12])
|
||||||
|
part4 := hex.EncodeToString(bytes[12:16])
|
||||||
|
|
||||||
|
return fmt.Sprintf("LIC-%d-%s-%s-%s-%s",
|
||||||
|
year,
|
||||||
|
strings.ToUpper(part1[:4]),
|
||||||
|
strings.ToUpper(part2[:4]),
|
||||||
|
strings.ToUpper(part3[:4]),
|
||||||
|
strings.ToUpper(part4[:4]))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateOrderNo 生成唯一订单号(格式: ORD-YYYYMMDD-XXXXXXXX) // Logan.Li
|
||||||
|
func GenerateOrderNo() string {
|
||||||
|
bytes := make([]byte, 4)
|
||||||
|
_, _ = rand.Read(bytes)
|
||||||
|
return fmt.Sprintf("ORD-%s-%s", time.Now().Format("20060102"), strings.ToUpper(hex.EncodeToString(bytes)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// CalculateDaysLeft 计算剩余天数(向下取整,最小为 0) // Logan.Li
|
||||||
|
func CalculateDaysLeft(endDate time.Time) int {
|
||||||
|
diff := endDate.Sub(time.Now())
|
||||||
|
days := int(diff.Hours() / 24)
|
||||||
|
if days < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return days
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsLicenseExpired 检查 License 是否过期 // Logan.Li
|
||||||
|
func IsLicenseExpired(endDate time.Time) bool {
|
||||||
|
return time.Now().After(endDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsExpiringSoon 检查 License 是否即将到期 // Logan.Li
|
||||||
|
func IsExpiringSoon(endDate time.Time, warningDays int) bool {
|
||||||
|
daysLeft := CalculateDaysLeft(endDate)
|
||||||
|
return daysLeft > 0 && daysLeft <= warningDays
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseFeatures 解析产品功能列表 JSON 为字符串切片 // Logan.Li
|
||||||
|
func ParseFeatures(featuresJSON string) []string {
|
||||||
|
if featuresJSON == "" {
|
||||||
|
return []string{}
|
||||||
|
}
|
||||||
|
trimmed := strings.Trim(featuresJSON, "[]")
|
||||||
|
if trimmed == "" {
|
||||||
|
return []string{}
|
||||||
|
}
|
||||||
|
// 简单解析:去引号后按逗号分割 // Logan.Li
|
||||||
|
trimmed = strings.ReplaceAll(trimmed, `"`, "")
|
||||||
|
parts := strings.Split(trimmed, ",")
|
||||||
|
result := make([]string, 0, len(parts))
|
||||||
|
for _, p := range parts {
|
||||||
|
if s := strings.TrimSpace(p); s != "" {
|
||||||
|
result = append(result, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatFeatures 将功能列表格式化为 JSON 字符串 // Logan.Li
|
||||||
|
func FormatFeatures(features []string) string {
|
||||||
|
if len(features) == 0 {
|
||||||
|
return "[]"
|
||||||
|
}
|
||||||
|
quoted := make([]string, len(features))
|
||||||
|
for i, f := range features {
|
||||||
|
quoted[i] = fmt.Sprintf(`"%s"`, f)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("[%s]", strings.Join(quoted, ","))
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import "golang.org/x/crypto/bcrypt"
|
||||||
|
|
||||||
|
// HashPassword 对明文密码进行 bcrypt 哈希 // Logan.Li
|
||||||
|
func HashPassword(password string) string {
|
||||||
|
hashed, _ := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
return string(hashed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckPassword 校验明文密码与哈希是否匹配 // Logan.Li
|
||||||
|
func CheckPassword(password, hash string) bool {
|
||||||
|
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# 开发环境
|
||||||
|
VITE_API_BASE_URL=/api
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# 生产环境(嵌入单二进制时,前端与后端同源,走相对路径)
|
||||||
|
VITE_API_BASE_URL=/api
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" href="/favicon.ico" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>opsx-licence 授权管理平台</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+2310
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "opsx-licence-frontend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"vue": "^3.4.0",
|
||||||
|
"vue-router": "^4.2.5",
|
||||||
|
"pinia": "^2.1.7",
|
||||||
|
"element-plus": "^2.5.0",
|
||||||
|
"@element-plus/icons-vue": "^2.3.1",
|
||||||
|
"axios": "^1.6.5",
|
||||||
|
"echarts": "^5.4.3",
|
||||||
|
"vue-echarts": "^6.6.8",
|
||||||
|
"dayjs": "^1.11.10"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.0.3",
|
||||||
|
"vite": "^5.0.11",
|
||||||
|
"sass": "^1.69.7"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<template>
|
||||||
|
<router-view />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
// 根组件,仅承载 router-view // Logan.Li
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#app {
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue',
|
||||||
|
Arial, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import request from './request'
|
||||||
|
|
||||||
|
// 数据分析与仪表板 API // Logan.Li
|
||||||
|
|
||||||
|
export function getDashboardStats() {
|
||||||
|
return request({ url: '/dashboard/stats', method: 'get' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getExpiringLicenses(days = 30) {
|
||||||
|
return request({ url: '/dashboard/expiring', method: 'get', params: { days } })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRecentActivities(limit = 10) {
|
||||||
|
return request({ url: '/dashboard/activities', method: 'get', params: { limit } })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOnlineStats() {
|
||||||
|
return request({ url: '/dashboard/online-stats', method: 'get' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRevenueStats() {
|
||||||
|
return request({ url: '/dashboard/revenue', method: 'get' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLicenseGrowth() {
|
||||||
|
return request({ url: '/analytics/license-growth', method: 'get' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProductDistribution() {
|
||||||
|
return request({ url: '/analytics/product-distribution', method: 'get' })
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import request from './request'
|
||||||
|
|
||||||
|
// 登录 // Logan.Li
|
||||||
|
export function login(data) {
|
||||||
|
return request({
|
||||||
|
url: '/auth/login',
|
||||||
|
method: 'post',
|
||||||
|
data,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取当前用户信息 // Logan.Li
|
||||||
|
export function getMe() {
|
||||||
|
return request({
|
||||||
|
url: '/auth/me',
|
||||||
|
method: 'get',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 修改密码 // Logan.Li
|
||||||
|
export function changePassword(data) {
|
||||||
|
return request({
|
||||||
|
url: '/auth/password',
|
||||||
|
method: 'put',
|
||||||
|
data,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import request from './request'
|
||||||
|
|
||||||
|
// 客户管理 API // Logan.Li
|
||||||
|
|
||||||
|
export function getCustomers(params) {
|
||||||
|
return request({ url: '/customers', method: 'get', params })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAllCustomers() {
|
||||||
|
return request({ url: '/customers/all', method: 'get' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCustomer(id) {
|
||||||
|
return request({ url: `/customers/${id}`, method: 'get' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCustomer(data) {
|
||||||
|
return request({ url: '/customers', method: 'post', data })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateCustomer(id, data) {
|
||||||
|
return request({ url: `/customers/${id}`, method: 'put', data })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteCustomer(id) {
|
||||||
|
return request({ url: `/customers/${id}`, method: 'delete' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateCustomerStatus(id, status) {
|
||||||
|
return request({ url: `/customers/${id}/status`, method: 'patch', data: { status } })
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import request from './request'
|
||||||
|
|
||||||
|
// 客户权益 API // Logan.Li
|
||||||
|
|
||||||
|
export function getEntitlements(params) {
|
||||||
|
return request({ url: '/entitlements', method: 'get', params })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEntitlement(id) {
|
||||||
|
return request({ url: `/entitlements/${id}`, method: 'get' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function revokeEntitlement(id) {
|
||||||
|
return request({ url: `/entitlements/${id}/revoke`, method: 'post' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renewEntitlement(id, planId) {
|
||||||
|
return request({ url: `/entitlements/${id}/renew`, method: 'post', data: { plan_id: planId } })
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import request from './request'
|
||||||
|
|
||||||
|
// License 管理 API // Logan.Li
|
||||||
|
|
||||||
|
export function getLicenses(params) {
|
||||||
|
return request({ url: '/licenses', method: 'get', params })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLicense(id) {
|
||||||
|
return request({ url: `/licenses/${id}`, method: 'get' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createLicense(data) {
|
||||||
|
return request({ url: '/licenses', method: 'post', data })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateLicense(id, data) {
|
||||||
|
return request({ url: `/licenses/${id}`, method: 'put', data })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteLicense(id) {
|
||||||
|
return request({ url: `/licenses/${id}`, method: 'delete' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renewLicense(id, duration) {
|
||||||
|
return request({ url: `/licenses/${id}/renew`, method: 'post', data: { duration } })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function revokeLicense(id, reason) {
|
||||||
|
return request({ url: `/licenses/${id}/revoke`, method: 'post', data: { reason } })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function suspendLicense(id) {
|
||||||
|
return request({ url: `/licenses/${id}/suspend`, method: 'post' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function activateLicense(id) {
|
||||||
|
return request({ url: `/licenses/${id}/activate`, method: 'post' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getActivations(id) {
|
||||||
|
return request({ url: `/licenses/${id}/activations`, method: 'get' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function forceOffline(licenseId, activationId) {
|
||||||
|
return request({ url: `/licenses/${licenseId}/activations/${activationId}/offline`, method: 'post' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exportLicenses(params) {
|
||||||
|
return request({ url: '/licenses/export', method: 'get', params, responseType: 'blob' })
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import request from './request'
|
||||||
|
|
||||||
|
// 订单管理 API // Logan.Li
|
||||||
|
|
||||||
|
export function getOrders(params) {
|
||||||
|
return request({ url: '/orders', method: 'get', params })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOrder(id) {
|
||||||
|
return request({ url: `/orders/${id}`, method: 'get' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function manualGrant(data) {
|
||||||
|
return request({ url: '/orders/manual-grant', method: 'post', data })
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import request from './request'
|
||||||
|
|
||||||
|
// 定价计划 API // Logan.Li
|
||||||
|
|
||||||
|
export function getPlans(params) {
|
||||||
|
return request({ url: '/plans', method: 'get', params })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPlan(data) {
|
||||||
|
return request({ url: '/plans', method: 'post', data })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updatePlan(id, data) {
|
||||||
|
return request({ url: `/plans/${id}`, method: 'put', data })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deletePlan(id) {
|
||||||
|
return request({ url: `/plans/${id}`, method: 'delete' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function togglePlanStatus(id, status) {
|
||||||
|
return request({ url: `/plans/${id}/status`, method: 'patch', data: { status } })
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import request from './request'
|
||||||
|
|
||||||
|
// 产品管理 API // Logan.Li
|
||||||
|
|
||||||
|
export function getProducts(params) {
|
||||||
|
return request({ url: '/products', method: 'get', params })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAllProducts() {
|
||||||
|
return request({ url: '/products/all', method: 'get' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProduct(id) {
|
||||||
|
return request({ url: `/products/${id}`, method: 'get' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createProduct(data) {
|
||||||
|
return request({ url: '/products', method: 'post', data })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateProduct(id, data) {
|
||||||
|
return request({ url: `/products/${id}`, method: 'put', data })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteProduct(id) {
|
||||||
|
return request({ url: `/products/${id}`, method: 'delete' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateProductStatus(id, status) {
|
||||||
|
return request({ url: `/products/${id}/status`, method: 'patch', data: { status } })
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { useUserStore } from '@/stores/user'
|
||||||
|
|
||||||
|
// 创建 axios 实例 // Logan.Li
|
||||||
|
const request = axios.create({
|
||||||
|
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
|
||||||
|
timeout: 30000,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// 请求拦截器:注入 Bearer token // Logan.Li
|
||||||
|
request.interceptors.request.use(
|
||||||
|
(config) => {
|
||||||
|
const userStore = useUserStore()
|
||||||
|
if (userStore.token) {
|
||||||
|
config.headers.Authorization = `Bearer ${userStore.token}`
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
},
|
||||||
|
(error) => Promise.reject(error)
|
||||||
|
)
|
||||||
|
|
||||||
|
// 响应拦截器:统一处理 code 与 HTTP 错误 // Logan.Li
|
||||||
|
request.interceptors.response.use(
|
||||||
|
(response) => {
|
||||||
|
const res = response.data
|
||||||
|
|
||||||
|
// code 为 0 或 200 视为成功 // Logan.Li
|
||||||
|
if (res.code === 0 || res.code === 200) {
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
// 401:登录态失效,引导重新登录 // Logan.Li
|
||||||
|
if (res.code === 401) {
|
||||||
|
ElMessageBox.confirm('登录状态已过期,请重新登录', '系统提示', {
|
||||||
|
confirmButtonText: '重新登录',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning',
|
||||||
|
}).then(() => {
|
||||||
|
const userStore = useUserStore()
|
||||||
|
userStore.logout()
|
||||||
|
window.location.href = '/login'
|
||||||
|
})
|
||||||
|
return Promise.reject(new Error(res.message || '未授权'))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 其他业务错误:仅错误用 ElMessage 提示(常规操作不打扰) // Logan.Li
|
||||||
|
ElMessage.error(res.message || '请求失败')
|
||||||
|
return Promise.reject(new Error(res.message || 'Error'))
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
let message = '网络错误'
|
||||||
|
|
||||||
|
if (error.response) {
|
||||||
|
switch (error.response.status) {
|
||||||
|
case 400:
|
||||||
|
message = error.response.data?.message || '请求参数错误'
|
||||||
|
break
|
||||||
|
case 401:
|
||||||
|
message = '未授权,请重新登录'
|
||||||
|
{
|
||||||
|
const userStore = useUserStore()
|
||||||
|
userStore.logout()
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = '/login'
|
||||||
|
}, 1500)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 403:
|
||||||
|
message = '拒绝访问'
|
||||||
|
break
|
||||||
|
case 404:
|
||||||
|
message = '请求的资源不存在'
|
||||||
|
break
|
||||||
|
case 500:
|
||||||
|
message = '服务器内部错误'
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
message = `连接错误 ${error.response.status}`
|
||||||
|
}
|
||||||
|
} else if (error.request) {
|
||||||
|
message = '网络连接失败,请检查网络'
|
||||||
|
} else {
|
||||||
|
message = error.message
|
||||||
|
}
|
||||||
|
|
||||||
|
ElMessage.error(message)
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export default request
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import request from './request'
|
||||||
|
|
||||||
|
// 系统设置与日志 API // Logan.Li
|
||||||
|
|
||||||
|
export function getSetting() {
|
||||||
|
return request({ url: '/settings', method: 'get' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateSetting(data) {
|
||||||
|
return request({ url: '/settings', method: 'put', data })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLicenseTemplates() {
|
||||||
|
return request({ url: '/settings/license-templates', method: 'get' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveLicenseTemplate(data) {
|
||||||
|
return request({ url: '/settings/license-templates', method: 'post', data })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteLicenseTemplate(id) {
|
||||||
|
return request({ url: `/settings/license-templates/${id}`, method: 'delete' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAuditLogs(params) {
|
||||||
|
return request({ url: '/logs', method: 'get', params })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearAuditLogs() {
|
||||||
|
return request({ url: '/logs/clear', method: 'delete' })
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
<template>
|
||||||
|
<el-container class="layout-container">
|
||||||
|
<!-- 侧边栏 -->
|
||||||
|
<el-aside :width="isCollapse ? '64px' : '220px'" class="layout-aside">
|
||||||
|
<div class="logo">
|
||||||
|
<el-icon size="24"><Key /></el-icon>
|
||||||
|
<span v-show="!isCollapse" class="logo-text">opsx-licence</span>
|
||||||
|
</div>
|
||||||
|
<el-menu
|
||||||
|
:default-active="activeMenu"
|
||||||
|
:collapse="isCollapse"
|
||||||
|
:router="true"
|
||||||
|
background-color="#001529"
|
||||||
|
text-color="rgba(255,255,255,0.65)"
|
||||||
|
active-text-color="#fff"
|
||||||
|
>
|
||||||
|
<el-menu-item index="/dashboard">
|
||||||
|
<el-icon><Odometer /></el-icon>
|
||||||
|
<template #title>仪表板</template>
|
||||||
|
</el-menu-item>
|
||||||
|
|
||||||
|
<el-sub-menu index="licence">
|
||||||
|
<template #title>
|
||||||
|
<el-icon><Key /></el-icon>
|
||||||
|
<span>授权管理</span>
|
||||||
|
</template>
|
||||||
|
<el-menu-item index="/products">
|
||||||
|
<el-icon><Box /></el-icon>产品管理
|
||||||
|
</el-menu-item>
|
||||||
|
<el-menu-item index="/customers">
|
||||||
|
<el-icon><User /></el-icon>客户管理
|
||||||
|
</el-menu-item>
|
||||||
|
<el-menu-item index="/licenses">
|
||||||
|
<el-icon><Key /></el-icon>License 管理
|
||||||
|
</el-menu-item>
|
||||||
|
</el-sub-menu>
|
||||||
|
|
||||||
|
<el-sub-menu index="commerce">
|
||||||
|
<template #title>
|
||||||
|
<el-icon><ShoppingCart /></el-icon>
|
||||||
|
<span>定价与订单</span>
|
||||||
|
</template>
|
||||||
|
<el-menu-item index="/plans">
|
||||||
|
<el-icon><PriceTag /></el-icon>定价计划
|
||||||
|
</el-menu-item>
|
||||||
|
<el-menu-item index="/orders">
|
||||||
|
<el-icon><ShoppingCart /></el-icon>订单管理
|
||||||
|
</el-menu-item>
|
||||||
|
<el-menu-item index="/entitlements">
|
||||||
|
<el-icon><Medal /></el-icon>客户权益
|
||||||
|
</el-menu-item>
|
||||||
|
</el-sub-menu>
|
||||||
|
|
||||||
|
<el-sub-menu index="system">
|
||||||
|
<template #title>
|
||||||
|
<el-icon><TrendCharts /></el-icon>
|
||||||
|
<span>审计与设置</span>
|
||||||
|
</template>
|
||||||
|
<el-menu-item index="/logs">
|
||||||
|
<el-icon><Document /></el-icon>操作日志
|
||||||
|
</el-menu-item>
|
||||||
|
<el-menu-item index="/settings">
|
||||||
|
<el-icon><Setting /></el-icon>系统设置
|
||||||
|
</el-menu-item>
|
||||||
|
</el-sub-menu>
|
||||||
|
</el-menu>
|
||||||
|
</el-aside>
|
||||||
|
|
||||||
|
<el-container>
|
||||||
|
<!-- 顶栏 -->
|
||||||
|
<el-header class="layout-header">
|
||||||
|
<div class="header-left">
|
||||||
|
<el-icon class="collapse-btn" @click="isCollapse = !isCollapse">
|
||||||
|
<Fold v-if="!isCollapse" />
|
||||||
|
<Expand v-else />
|
||||||
|
</el-icon>
|
||||||
|
</div>
|
||||||
|
<div class="header-right">
|
||||||
|
<el-dropdown @command="handleCommand">
|
||||||
|
<span class="user-info">
|
||||||
|
<el-avatar :size="32" :icon="UserFilled" />
|
||||||
|
<span class="username">{{ userInfo?.username || 'admin' }}</span>
|
||||||
|
<el-icon><ArrowDown /></el-icon>
|
||||||
|
</span>
|
||||||
|
<template #dropdown>
|
||||||
|
<el-dropdown-menu>
|
||||||
|
<el-dropdown-item command="changePassword">修改密码</el-dropdown-item>
|
||||||
|
<el-dropdown-item command="logout" divided>退出登录</el-dropdown-item>
|
||||||
|
</el-dropdown-menu>
|
||||||
|
</template>
|
||||||
|
</el-dropdown>
|
||||||
|
</div>
|
||||||
|
</el-header>
|
||||||
|
|
||||||
|
<!-- 主内容区 -->
|
||||||
|
<el-main class="layout-main">
|
||||||
|
<router-view />
|
||||||
|
</el-main>
|
||||||
|
</el-container>
|
||||||
|
|
||||||
|
<!-- 修改密码对话框 -->
|
||||||
|
<el-dialog v-model="pwdDialogVisible" title="修改密码" width="420px">
|
||||||
|
<el-form ref="pwdFormRef" :model="pwdForm" :rules="pwdRules" label-width="80px">
|
||||||
|
<el-form-item label="原密码" prop="old_password">
|
||||||
|
<el-input v-model="pwdForm.old_password" type="password" show-password />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="新密码" prop="new_password">
|
||||||
|
<el-input v-model="pwdForm.new_password" type="password" show-password />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="确认密码" prop="confirm_password">
|
||||||
|
<el-input v-model="pwdForm.confirm_password" type="password" show-password />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="pwdDialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="submitChangePassword">确认</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</el-container>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, computed } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { UserFilled } from '@element-plus/icons-vue'
|
||||||
|
import { useUserStore } from '@/stores/user'
|
||||||
|
import { changePassword } from '@/api/auth'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
|
||||||
|
const isCollapse = ref(false)
|
||||||
|
const userInfo = ref(userStore.userInfo)
|
||||||
|
|
||||||
|
// 首次进入拉取用户信息 // Logan.Li
|
||||||
|
if (userStore.token && !userInfo.value) {
|
||||||
|
userStore.fetchUserInfo().then((info) => {
|
||||||
|
userInfo.value = info
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeMenu = computed(() => route.path)
|
||||||
|
|
||||||
|
// 退出登录 / 修改密码 // Logan.Li
|
||||||
|
async function handleCommand(command) {
|
||||||
|
if (command === 'logout') {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('确定要退出登录吗?', '提示', { type: 'warning' })
|
||||||
|
userStore.logout()
|
||||||
|
router.push('/login')
|
||||||
|
} catch { /* 用户取消 */ }
|
||||||
|
} else if (command === 'changePassword') {
|
||||||
|
pwdDialogVisible.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 修改密码 // Logan.Li
|
||||||
|
const pwdDialogVisible = ref(false)
|
||||||
|
const pwdFormRef = ref(null)
|
||||||
|
const pwdForm = reactive({ old_password: '', new_password: '', confirm_password: '' })
|
||||||
|
const pwdRules = {
|
||||||
|
old_password: [{ required: true, message: '请输入原密码', trigger: 'blur' }],
|
||||||
|
new_password: [
|
||||||
|
{ required: true, message: '请输入新密码', trigger: 'blur' },
|
||||||
|
{ min: 6, message: '密码长度至少 6 位', trigger: 'blur' },
|
||||||
|
],
|
||||||
|
confirm_password: [
|
||||||
|
{ required: true, message: '请确认新密码', trigger: 'blur' },
|
||||||
|
{
|
||||||
|
validator: (_rule, value, callback) => {
|
||||||
|
if (value !== pwdForm.new_password) callback(new Error('两次输入的密码不一致'))
|
||||||
|
else callback()
|
||||||
|
},
|
||||||
|
trigger: 'blur',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitChangePassword() {
|
||||||
|
if (!pwdFormRef.value) return
|
||||||
|
await pwdFormRef.value.validate(async (valid) => {
|
||||||
|
if (!valid) return
|
||||||
|
try {
|
||||||
|
await changePassword({ old_password: pwdForm.old_password, new_password: pwdForm.new_password })
|
||||||
|
ElMessage.success('密码修改成功,请重新登录')
|
||||||
|
pwdDialogVisible.value = false
|
||||||
|
userStore.logout()
|
||||||
|
router.push('/login')
|
||||||
|
} catch { /* 拦截器已处理 */ }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.layout-container { height: 100vh; }
|
||||||
|
.layout-aside { background-color: #001529; transition: width 0.3s; overflow-x: hidden; }
|
||||||
|
.logo { height: 60px; display: flex; align-items: center; justify-content: center; gap: 8px; color: #fff; font-size: 18px; font-weight: 600; }
|
||||||
|
.logo-text { white-space: nowrap; }
|
||||||
|
.layout-aside :deep(.el-menu) { border-right: none; }
|
||||||
|
.layout-header { display: flex; align-items: center; justify-content: space-between; background-color: #fff; border-bottom: 1px solid #f0f0f0; }
|
||||||
|
.collapse-btn { font-size: 20px; cursor: pointer; }
|
||||||
|
.user-info { display: flex; align-items: center; gap: 8px; cursor: pointer; }
|
||||||
|
.username { font-size: 14px; }
|
||||||
|
.layout-main { background-color: #f5f7fa; padding: 20px; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
import ElementPlus from 'element-plus'
|
||||||
|
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||||
|
import 'element-plus/dist/index.css'
|
||||||
|
|
||||||
|
import App from './App.vue'
|
||||||
|
import router from './router'
|
||||||
|
import { useUserStore } from './stores/user'
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
|
||||||
|
// 注册 Element Plus 图标 // Logan.Li
|
||||||
|
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||||
|
app.component(key, component)
|
||||||
|
}
|
||||||
|
|
||||||
|
app.use(createPinia())
|
||||||
|
app.use(router)
|
||||||
|
app.use(ElementPlus, { locale: undefined }) // 默认中文
|
||||||
|
|
||||||
|
// 从 localStorage 恢复登录状态 // Logan.Li
|
||||||
|
const userStore = useUserStore()
|
||||||
|
userStore.restore()
|
||||||
|
|
||||||
|
app.mount('#app')
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
import { useUserStore } from '@/stores/user'
|
||||||
|
|
||||||
|
const routes = [
|
||||||
|
{
|
||||||
|
path: '/login',
|
||||||
|
name: 'Login',
|
||||||
|
component: () => import('@/views/Login.vue'),
|
||||||
|
meta: { public: true, title: '登录' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
component: () => import('@/layouts/MainLayout.vue'),
|
||||||
|
redirect: '/dashboard',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: 'dashboard',
|
||||||
|
name: 'Dashboard',
|
||||||
|
component: () => import('@/views/Dashboard.vue'),
|
||||||
|
meta: { title: '仪表板', icon: 'Odometer' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'products',
|
||||||
|
name: 'Products',
|
||||||
|
component: () => import('@/views/Products.vue'),
|
||||||
|
meta: { title: '产品管理', icon: 'Box' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'customers',
|
||||||
|
name: 'Customers',
|
||||||
|
component: () => import('@/views/Customers.vue'),
|
||||||
|
meta: { title: '客户管理', icon: 'User' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'licenses',
|
||||||
|
name: 'Licenses',
|
||||||
|
component: () => import('@/views/Licenses.vue'),
|
||||||
|
meta: { title: 'License 管理', icon: 'Key' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'plans',
|
||||||
|
name: 'Plans',
|
||||||
|
component: () => import('@/views/Plans.vue'),
|
||||||
|
meta: { title: '定价计划', icon: 'PriceTag' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'orders',
|
||||||
|
name: 'Orders',
|
||||||
|
component: () => import('@/views/Orders.vue'),
|
||||||
|
meta: { title: '订单管理', icon: 'ShoppingCart' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'entitlements',
|
||||||
|
name: 'Entitlements',
|
||||||
|
component: () => import('@/views/Entitlements.vue'),
|
||||||
|
meta: { title: '客户权益', icon: 'Medal' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'logs',
|
||||||
|
name: 'Logs',
|
||||||
|
component: () => import('@/views/Logs.vue'),
|
||||||
|
meta: { title: '操作日志', icon: 'Document' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'settings',
|
||||||
|
name: 'Settings',
|
||||||
|
component: () => import('@/views/Settings.vue'),
|
||||||
|
meta: { title: '系统设置', icon: 'Setting' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/:pathMatch(.*)*',
|
||||||
|
name: 'NotFound',
|
||||||
|
component: () => import('@/views/NotFound.vue'),
|
||||||
|
meta: { public: true, title: '页面不存在' },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 全局前置守卫:未登录访问受保护页面时重定向到登录页 // Logan.Li
|
||||||
|
router.beforeEach((to, from, next) => {
|
||||||
|
const userStore = useUserStore()
|
||||||
|
|
||||||
|
if (to.meta.title) {
|
||||||
|
document.title = `${to.meta.title} - opsx-licence`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (to.meta.public) {
|
||||||
|
if (to.path === '/login' && userStore.token) {
|
||||||
|
next('/')
|
||||||
|
} else {
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!userStore.token) {
|
||||||
|
next({ path: '/login', query: { redirect: to.fullPath } })
|
||||||
|
} else {
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { login as loginApi, getMe } from '@/api/auth'
|
||||||
|
|
||||||
|
const TOKEN_KEY = 'opsx_licence_token'
|
||||||
|
|
||||||
|
// 用户状态 store(setup 语法) // Logan.Li
|
||||||
|
export const useUserStore = defineStore('user', () => {
|
||||||
|
const token = ref('')
|
||||||
|
const userInfo = ref(null)
|
||||||
|
|
||||||
|
// 从 localStorage 恢复登录状态 // Logan.Li
|
||||||
|
function restore() {
|
||||||
|
const saved = localStorage.getItem(TOKEN_KEY)
|
||||||
|
if (saved) {
|
||||||
|
token.value = saved
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 登录:保存 token 并拉取用户信息 // Logan.Li
|
||||||
|
async function login(loginForm) {
|
||||||
|
const res = await loginApi(loginForm)
|
||||||
|
token.value = res.data.token
|
||||||
|
userInfo.value = res.data.user_info
|
||||||
|
localStorage.setItem(TOKEN_KEY, token.value)
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
// 拉取当前用户信息 // Logan.Li
|
||||||
|
async function fetchUserInfo() {
|
||||||
|
const res = await getMe()
|
||||||
|
userInfo.value = res.data
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
// 登出:清空状态 // Logan.Li
|
||||||
|
function logout() {
|
||||||
|
token.value = ''
|
||||||
|
userInfo.value = null
|
||||||
|
localStorage.removeItem(TOKEN_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
token,
|
||||||
|
userInfo,
|
||||||
|
restore,
|
||||||
|
login,
|
||||||
|
fetchUserInfo,
|
||||||
|
logout,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<el-card shadow="never">
|
||||||
|
<div class="toolbar">
|
||||||
|
<el-input v-model="keyword" placeholder="搜索客户/邮箱/公司" clearable style="width: 260px" @keyup.enter="loadData" @clear="loadData" />
|
||||||
|
<el-button type="primary" @click="openCreate">新增客户</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table :data="list" v-loading="loading" stripe>
|
||||||
|
<el-table-column prop="name" label="客户名称" min-width="120" />
|
||||||
|
<el-table-column prop="company" label="公司" min-width="140" />
|
||||||
|
<el-table-column prop="email" label="邮箱" min-width="180" />
|
||||||
|
<el-table-column prop="phone" label="电话" width="130" />
|
||||||
|
<el-table-column label="状态" width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-switch :model-value="row.status === 1" @change="(v) => toggleStatus(row, v)" />
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="created_at" label="创建时间" width="170" />
|
||||||
|
<el-table-column label="操作" width="140" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button size="small" @click="edit(row)">编辑</el-button>
|
||||||
|
<el-button size="small" type="danger" @click="remove(row)">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="page" v-model:page-size="pageSize"
|
||||||
|
:total="total" layout="total, prev, pager, next" style="margin-top: 16px"
|
||||||
|
@current-change="loadData"
|
||||||
|
/>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-dialog v-model="dialogVisible" :title="form.id ? '编辑客户' : '新增客户'" width="520px">
|
||||||
|
<el-form ref="formRef" :model="form" :rules="rules" label-width="80px">
|
||||||
|
<el-form-item label="名称" prop="name"><el-input v-model="form.name" /></el-form-item>
|
||||||
|
<el-form-item label="公司"><el-input v-model="form.company" /></el-form-item>
|
||||||
|
<el-form-item label="邮箱" prop="email"><el-input v-model="form.email" /></el-form-item>
|
||||||
|
<el-form-item label="电话"><el-input v-model="form.phone" /></el-form-item>
|
||||||
|
<el-form-item label="地址"><el-input v-model="form.address" /></el-form-item>
|
||||||
|
<el-form-item label="备注"><el-input v-model="form.notes" type="textarea" :rows="2" /></el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="dialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="submit">确认</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive } from 'vue'
|
||||||
|
import { ElMessageBox } from 'element-plus'
|
||||||
|
import { getCustomers, createCustomer, updateCustomer, deleteCustomer, updateCustomerStatus } from '@/api/customer'
|
||||||
|
|
||||||
|
const list = ref([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const keyword = ref('')
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(10)
|
||||||
|
const total = ref(0)
|
||||||
|
|
||||||
|
const dialogVisible = ref(false)
|
||||||
|
const formRef = ref(null)
|
||||||
|
const form = reactive({ id: null, name: '', company: '', email: '', phone: '', address: '', notes: '' })
|
||||||
|
const rules = {
|
||||||
|
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
|
||||||
|
email: [{ type: 'email', message: '邮箱格式不正确', trigger: 'blur' }],
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getCustomers({ page: page.value, page_size: pageSize.value, keyword: keyword.value })
|
||||||
|
list.value = res.data.list || []
|
||||||
|
total.value = res.data.total
|
||||||
|
} finally { loading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
Object.assign(form, { id: null, name: '', company: '', email: '', phone: '', address: '', notes: '' })
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function edit(row) {
|
||||||
|
Object.assign(form, row)
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
await formRef.value.validate(async (valid) => {
|
||||||
|
if (!valid) return
|
||||||
|
try {
|
||||||
|
if (form.id) await updateCustomer(form.id, form)
|
||||||
|
else await createCustomer(form)
|
||||||
|
dialogVisible.value = false
|
||||||
|
loadData()
|
||||||
|
} catch { /* */ }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(row) {
|
||||||
|
await ElMessageBox.confirm(`确定删除客户「${row.name}」?`, '提示', { type: 'warning' })
|
||||||
|
await deleteCustomer(row.id)
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleStatus(row, val) {
|
||||||
|
await updateCustomerStatus(row.id, val ? 1 : 0)
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
loadData()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.toolbar { display: flex; justify-content: space-between; margin-bottom: 16px; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
<template>
|
||||||
|
<div class="dashboard">
|
||||||
|
<!-- 统计卡片 -->
|
||||||
|
<el-row :gutter="16">
|
||||||
|
<el-col :span="6" v-for="card in statCards" :key="card.title">
|
||||||
|
<el-card shadow="hover" class="stat-card">
|
||||||
|
<div class="stat-content">
|
||||||
|
<el-icon :size="36" :color="card.color">
|
||||||
|
<component :is="card.icon" />
|
||||||
|
</el-icon>
|
||||||
|
<div class="stat-info">
|
||||||
|
<div class="stat-title">{{ card.title }}</div>
|
||||||
|
<div class="stat-value">{{ card.value }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<el-row :gutter="16" style="margin-top: 16px">
|
||||||
|
<!-- 收入趋势 -->
|
||||||
|
<el-col :span="14">
|
||||||
|
<el-card shadow="never">
|
||||||
|
<template #header>近 6 个月收入趋势</template>
|
||||||
|
<v-chart class="chart" :option="revenueOption" autoresize />
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
<!-- 产品分布 -->
|
||||||
|
<el-col :span="10">
|
||||||
|
<el-card shadow="never">
|
||||||
|
<template #header>License 产品分布</template>
|
||||||
|
<v-chart class="chart" :option="productOption" autoresize />
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<el-row :gutter="16" style="margin-top: 16px">
|
||||||
|
<!-- 即将到期 -->
|
||||||
|
<el-col :span="14">
|
||||||
|
<el-card shadow="never">
|
||||||
|
<template #header>即将到期 License(30 天内)</template>
|
||||||
|
<el-table :data="expiring" size="small" stripe>
|
||||||
|
<el-table-column prop="license_key" label="License Key" min-width="180" />
|
||||||
|
<el-table-column prop="customer_name" label="客户" min-width="100" />
|
||||||
|
<el-table-column prop="product_name" label="产品" min-width="100" />
|
||||||
|
<el-table-column prop="end_date" label="到期时间" width="120" />
|
||||||
|
<el-table-column prop="days_left" label="剩余天数" width="90">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.days_left <= 7 ? 'danger' : 'warning'" size="small">
|
||||||
|
{{ row.days_left }} 天
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
<!-- 最近活动 -->
|
||||||
|
<el-col :span="10">
|
||||||
|
<el-card shadow="never">
|
||||||
|
<template #header>最近操作</template>
|
||||||
|
<el-timeline>
|
||||||
|
<el-timeline-item
|
||||||
|
v-for="act in activities"
|
||||||
|
:key="act.id"
|
||||||
|
:timestamp="act.created_at"
|
||||||
|
placement="top"
|
||||||
|
>
|
||||||
|
<strong>{{ act.username }}</strong>
|
||||||
|
<span style="margin-left: 8px; color: #909399">{{ act.action }}</span>
|
||||||
|
<div style="color: #606266; font-size: 13px">{{ act.detail }}</div>
|
||||||
|
</el-timeline-item>
|
||||||
|
</el-timeline>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, onMounted, computed } from 'vue'
|
||||||
|
import VChart from 'vue-echarts'
|
||||||
|
import { use } from 'echarts/core'
|
||||||
|
import { CanvasRenderer } from 'echarts/renderers'
|
||||||
|
import { BarChart, PieChart, LineChart } from 'echarts/charts'
|
||||||
|
import { GridComponent, TooltipComponent, LegendComponent, TitleComponent } from 'echarts/components'
|
||||||
|
import {
|
||||||
|
getDashboardStats, getExpiringLicenses, getRecentActivities,
|
||||||
|
getRevenueStats, getProductDistribution,
|
||||||
|
} from '@/api/analytics'
|
||||||
|
|
||||||
|
use([CanvasRenderer, BarChart, PieChart, LineChart, GridComponent, TooltipComponent, LegendComponent, TitleComponent])
|
||||||
|
|
||||||
|
const stats = ref({})
|
||||||
|
const expiring = ref([])
|
||||||
|
const activities = ref([])
|
||||||
|
const revenueData = ref([])
|
||||||
|
const productData = ref([])
|
||||||
|
|
||||||
|
const statCards = computed(() => [
|
||||||
|
{ title: 'License 总数', value: stats.value.total_licenses ?? '—', icon: 'Key', color: '#409eff' },
|
||||||
|
{ title: '活跃授权', value: stats.value.active_licenses ?? '—', icon: 'CircleCheck', color: '#67c23a' },
|
||||||
|
{ title: '客户数', value: stats.value.total_customers ?? '—', icon: 'User', color: '#e6a23c' },
|
||||||
|
{ title: '在线设备', value: stats.value.online_devices ?? '—', icon: 'Monitor', color: '#f56c6c' },
|
||||||
|
])
|
||||||
|
|
||||||
|
const revenueOption = computed(() => ({
|
||||||
|
tooltip: { trigger: 'axis' },
|
||||||
|
xAxis: { type: 'category', data: revenueData.value.map((d) => d.period) },
|
||||||
|
yAxis: { type: 'value' },
|
||||||
|
series: [{ data: revenueData.value.map((d) => d.amount), type: 'line', smooth: true, areaStyle: {}, color: '#409eff' }],
|
||||||
|
}))
|
||||||
|
|
||||||
|
const productOption = computed(() => ({
|
||||||
|
tooltip: { trigger: 'item' },
|
||||||
|
legend: { bottom: 0 },
|
||||||
|
series: [{
|
||||||
|
type: 'pie', radius: ['40%', '70%'],
|
||||||
|
data: productData.value.map((d) => ({ name: d.product_name || '未知', value: d.count })),
|
||||||
|
}],
|
||||||
|
}))
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const [s, e, a, r, p] = await Promise.all([
|
||||||
|
getDashboardStats(), getExpiringLicenses(30), getRecentActivities(8),
|
||||||
|
getRevenueStats(), getProductDistribution(),
|
||||||
|
])
|
||||||
|
stats.value = s.data
|
||||||
|
expiring.value = e.data
|
||||||
|
activities.value = a.data
|
||||||
|
revenueData.value = r.data
|
||||||
|
productData.value = p.data
|
||||||
|
} catch {
|
||||||
|
// 拦截器已处理
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.stat-card { margin-bottom: 0; }
|
||||||
|
.stat-content { display: flex; align-items: center; gap: 16px; }
|
||||||
|
.stat-info { display: flex; flex-direction: column; }
|
||||||
|
.stat-title { font-size: 13px; color: #909399; }
|
||||||
|
.stat-value { font-size: 26px; font-weight: 600; color: #303133; }
|
||||||
|
.chart { height: 280px; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<el-card shadow="never">
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="filters">
|
||||||
|
<el-select v-model="customerFilter" placeholder="客户" clearable filterable style="width: 180px" @change="loadData">
|
||||||
|
<el-option v-for="c in customers" :key="c.id" :label="c.name" :value="c.id" />
|
||||||
|
</el-select>
|
||||||
|
<el-select v-model="productFilter" placeholder="产品" clearable filterable style="width: 180px" @change="loadData">
|
||||||
|
<el-option v-for="p in products" :key="p.id" :label="p.name" :value="p.id" />
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table :data="list" v-loading="loading" stripe>
|
||||||
|
<el-table-column prop="customer_name" label="客户" min-width="110" />
|
||||||
|
<el-table-column prop="product_name" label="产品" min-width="110" />
|
||||||
|
<el-table-column prop="plan_name" label="计划" min-width="110" />
|
||||||
|
<el-table-column label="类型" width="90">
|
||||||
|
<template #default="{ row }">{{ planTypeLabel(row.plan_type) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="license_key" label="License Key" min-width="200" />
|
||||||
|
<el-table-column label="状态" width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="entStatusTag(row.status)" size="small">{{ entStatusLabel(row.status) }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="有效期" min-width="160">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div>{{ row.valid_from }}</div>
|
||||||
|
<div style="color: #909399">至 {{ row.valid_until || '永久' }}</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="剩余" width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span :style="{ color: row.days_left <= 7 ? '#f56c6c' : '' }">{{ row.days_left >= 9999 ? '永久' : row.days_left + '天' }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="设备" width="80">
|
||||||
|
<template #default="{ row }">{{ row.current_devices }}/{{ row.max_devices }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="150" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button size="small" :disabled="!row.renewable" @click="renew(row)">续费</el-button>
|
||||||
|
<el-button size="small" type="danger" @click="revoke(row)">吊销</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="page" v-model:page-size="pageSize"
|
||||||
|
:total="total" layout="total, prev, pager, next" style="margin-top: 16px"
|
||||||
|
@current-change="loadData"
|
||||||
|
/>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { getEntitlements, revokeEntitlement, renewEntitlement } from '@/api/entitlement'
|
||||||
|
import { getAllProducts } from '@/api/product'
|
||||||
|
import { getAllCustomers } from '@/api/customer'
|
||||||
|
|
||||||
|
const list = ref([])
|
||||||
|
const products = ref([])
|
||||||
|
const customers = ref([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const customerFilter = ref('')
|
||||||
|
const productFilter = ref('')
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(10)
|
||||||
|
const total = ref(0)
|
||||||
|
|
||||||
|
function planTypeLabel(t) { return { perpetual: '买断', lifetime: '终身', monthly: '月订阅', yearly: '年订阅' }[t] || t }
|
||||||
|
function entStatusLabel(s) { return { active: '有效', expired: '过期', revoked: '已吊销' }[s] || s }
|
||||||
|
function entStatusTag(s) { return { active: 'success', expired: 'warning', revoked: 'danger' }[s] || '' }
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getEntitlements({
|
||||||
|
page: page.value, page_size: pageSize.value,
|
||||||
|
customer_id: customerFilter.value, product_id: productFilter.value,
|
||||||
|
})
|
||||||
|
list.value = res.data.list || []
|
||||||
|
total.value = res.data.total
|
||||||
|
} finally { loading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function revoke(row) {
|
||||||
|
await ElMessageBox.confirm(`确定吊销「${row.customer_name}」对「${row.product_name}」的权益?`, '提示', { type: 'warning' })
|
||||||
|
await revokeEntitlement(row.id)
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renew(row) {
|
||||||
|
await ElMessageBox.confirm('将按原计划续费,是否继续?', '续费', { type: 'info' })
|
||||||
|
try {
|
||||||
|
await renewEntitlement(row.id, row.plan_id)
|
||||||
|
ElMessage.success('续费成功')
|
||||||
|
loadData()
|
||||||
|
} catch { /* */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const [p, c] = await Promise.all([getAllProducts(), getAllCustomers()])
|
||||||
|
products.value = p.data
|
||||||
|
customers.value = c.data
|
||||||
|
loadData()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.toolbar { margin-bottom: 16px; }
|
||||||
|
.filters { display: flex; gap: 10px; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<el-card shadow="never">
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="filters">
|
||||||
|
<el-input v-model="keyword" placeholder="搜索 License Key" clearable style="width: 220px" @keyup.enter="loadData" @clear="loadData" />
|
||||||
|
<el-select v-model="statusFilter" placeholder="状态" clearable style="width: 120px" @change="loadData">
|
||||||
|
<el-option v-for="s in statusOptions" :key="s.value" :label="s.label" :value="s.value" />
|
||||||
|
</el-select>
|
||||||
|
<el-select v-model="productFilter" placeholder="产品" clearable filterable style="width: 160px" @change="loadData">
|
||||||
|
<el-option v-for="p in products" :key="p.id" :label="p.name" :value="p.id" />
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<el-button @click="exportData">导出 CSV</el-button>
|
||||||
|
<el-button type="primary" @click="openCreate">生成 License</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table :data="list" v-loading="loading" stripe>
|
||||||
|
<el-table-column prop="license_key" label="License Key" min-width="220" />
|
||||||
|
<el-table-column prop="customer_name" label="客户" min-width="100" />
|
||||||
|
<el-table-column prop="product_name" label="产品" min-width="100" />
|
||||||
|
<el-table-column label="状态" width="90">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="statusTag(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="end_date" label="到期时间" width="110" />
|
||||||
|
<el-table-column label="剩余" width="70">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span :style="{ color: row.days_left <= 7 ? '#f56c6c' : '' }">{{ row.days_left }}天</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="激活" width="80">
|
||||||
|
<template #default="{ row }">{{ row.current_activations }}/{{ row.max_activations }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="280" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button size="small" @click="viewDetail(row)">详情</el-button>
|
||||||
|
<el-button size="small" @click="renew(row)">续期</el-button>
|
||||||
|
<el-button size="small" type="warning" @click="toggle(row, row.status === 'suspended' ? 'active' : 'suspended')">
|
||||||
|
{{ row.status === 'suspended' ? '恢复' : '暂停' }}
|
||||||
|
</el-button>
|
||||||
|
<el-button size="small" type="danger" @click="revoke(row)">吊销</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="page" v-model:page-size="pageSize"
|
||||||
|
:total="total" layout="total, prev, pager, next" style="margin-top: 16px"
|
||||||
|
@current-change="loadData"
|
||||||
|
/>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<!-- 生成 License -->
|
||||||
|
<el-dialog v-model="createVisible" title="生成 License" width="520px">
|
||||||
|
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
|
||||||
|
<el-form-item label="客户" prop="customer_id">
|
||||||
|
<el-select v-model="form.customer_id" filterable placeholder="选择客户" style="width: 100%">
|
||||||
|
<el-option v-for="c in customers" :key="c.id" :label="c.name" :value="c.id" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="产品" prop="product_id">
|
||||||
|
<el-select v-model="form.product_id" filterable placeholder="选择产品" style="width: 100%">
|
||||||
|
<el-option v-for="p in products" :key="p.id" :label="p.name" :value="p.id" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="有效期" prop="duration">
|
||||||
|
<el-select v-model="form.duration" style="width: 100%">
|
||||||
|
<el-option v-for="d in durations" :key="d.value" :label="d.label" :value="d.value" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="最大激活数"><el-input-number v-model="form.max_activations" :min="1" /></el-form-item>
|
||||||
|
<el-form-item label="生成数量"><el-input-number v-model="form.count" :min="1" :max="100" /></el-form-item>
|
||||||
|
<el-form-item label="备注"><el-input v-model="form.notes" type="textarea" :rows="2" /></el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="createVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="submitCreate">生成</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 详情/激活记录 -->
|
||||||
|
<el-dialog v-model="detailVisible" title="License 详情" width="680px">
|
||||||
|
<el-descriptions :column="2" border size="small" v-if="detail.license">
|
||||||
|
<el-descriptions-item label="License Key">{{ detail.license.license_key }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="状态">{{ detail.license.status }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="客户">{{ detail.license.customer_name }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="产品">{{ detail.license.product_name }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="到期">{{ detail.license.end_date }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="剩余天数">{{ detail.license.days_left }} 天</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
<h4 style="margin: 16px 0 8px">激活设备</h4>
|
||||||
|
<el-table :data="detail.activations" size="small" stripe>
|
||||||
|
<el-table-column prop="device_id" label="设备 ID" min-width="120" />
|
||||||
|
<el-table-column prop="device_name" label="设备名" min-width="120" />
|
||||||
|
<el-table-column prop="ip" label="IP" width="120" />
|
||||||
|
<el-table-column label="在线" width="70">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.is_online ? 'success' : 'info'" size="small">{{ row.is_online ? '在线' : '离线' }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="last_seen_at" label="最后心跳" width="160" />
|
||||||
|
<el-table-column label="操作" width="90">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button size="small" type="danger" link @click="handleForceOffline(row)">下线</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import {
|
||||||
|
getLicenses, createLicense, renewLicense, revokeLicense, suspendLicense,
|
||||||
|
activateLicense, getLicense, forceOffline, exportLicenses,
|
||||||
|
} from '@/api/license'
|
||||||
|
import { getAllProducts } from '@/api/product'
|
||||||
|
import { getAllCustomers } from '@/api/customer'
|
||||||
|
|
||||||
|
const list = ref([])
|
||||||
|
const products = ref([])
|
||||||
|
const customers = ref([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const keyword = ref('')
|
||||||
|
const statusFilter = ref('')
|
||||||
|
const productFilter = ref('')
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(10)
|
||||||
|
const total = ref(0)
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
{ label: '未激活', value: 'inactive' }, { label: '已激活', value: 'active' },
|
||||||
|
{ label: '已过期', value: 'expired' }, { label: '已吊销', value: 'revoked' }, { label: '已暂停', value: 'suspended' },
|
||||||
|
]
|
||||||
|
const durations = [
|
||||||
|
{ label: '1 个月', value: 30 }, { label: '3 个月', value: 90 }, { label: '6 个月', value: 180 },
|
||||||
|
{ label: '1 年', value: 365 }, { label: '2 年', value: 730 }, { label: '3 年', value: 1095 },
|
||||||
|
{ label: '5 年', value: 1825 }, { label: '永久', value: 0 },
|
||||||
|
]
|
||||||
|
|
||||||
|
function statusLabel(s) { return statusOptions.find((o) => o.value === s)?.label || s }
|
||||||
|
function statusTag(s) {
|
||||||
|
return { active: 'success', inactive: 'info', expired: 'warning', revoked: 'danger', suspended: 'warning' }[s] || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const createVisible = ref(false)
|
||||||
|
const formRef = ref(null)
|
||||||
|
const form = reactive({ customer_id: null, product_id: null, duration: 365, max_activations: 1, count: 1, notes: '' })
|
||||||
|
const rules = {
|
||||||
|
customer_id: [{ required: true, message: '请选择客户' }],
|
||||||
|
product_id: [{ required: true, message: '请选择产品' }],
|
||||||
|
duration: [{ required: true, message: '请选择有效期' }],
|
||||||
|
}
|
||||||
|
|
||||||
|
const detailVisible = ref(false)
|
||||||
|
const detail = reactive({ license: null, activations: [] })
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getLicenses({
|
||||||
|
page: page.value, page_size: pageSize.value,
|
||||||
|
keyword: keyword.value, status: statusFilter.value, product_id: productFilter.value,
|
||||||
|
})
|
||||||
|
list.value = res.data.list || []
|
||||||
|
total.value = res.data.total
|
||||||
|
} finally { loading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
Object.assign(form, { customer_id: null, product_id: null, duration: 365, max_activations: 1, count: 1, notes: '' })
|
||||||
|
createVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitCreate() {
|
||||||
|
await formRef.value.validate(async (valid) => {
|
||||||
|
if (!valid) return
|
||||||
|
try {
|
||||||
|
const res = await createLicense(form)
|
||||||
|
ElMessage.success(`成功生成 ${res.data.length} 个 License`)
|
||||||
|
createVisible.value = false
|
||||||
|
loadData()
|
||||||
|
} catch { /* */ }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function viewDetail(row) {
|
||||||
|
const res = await getLicense(row.id)
|
||||||
|
detail.license = res.data.license
|
||||||
|
detail.activations = res.data.activations || []
|
||||||
|
detailVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renew(row) {
|
||||||
|
const { value } = await ElMessageBox.prompt('续期天数(0=永久)', '续期', {
|
||||||
|
inputType: 'number', inputValue: '365', inputValidator: (v) => v !== '' && v >= 0,
|
||||||
|
})
|
||||||
|
await renewLicense(row.id, parseInt(value))
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function revoke(row) {
|
||||||
|
const { value } = await ElMessageBox.prompt('吊销原因', '吊销 License', { inputType: 'textarea' })
|
||||||
|
await revokeLicense(row.id, value || '')
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggle(row, status) {
|
||||||
|
if (status === 'active') await activateLicense(row.id)
|
||||||
|
else await suspendLicense(row.id)
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleForceOffline(act) {
|
||||||
|
await ElMessageBox.confirm(`确定强制下线设备「${act.device_name || act.device_id}」?`, '提示', { type: 'warning' })
|
||||||
|
await forceOffline(detail.license.id, act.id)
|
||||||
|
// 刷新详情 // Logan.Li
|
||||||
|
const res = await getLicense(detail.license.id)
|
||||||
|
detail.activations = res.data.activations || []
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportData() {
|
||||||
|
try {
|
||||||
|
const res = await exportLicenses({ status: statusFilter.value, product_id: productFilter.value })
|
||||||
|
const url = window.URL.createObjectURL(new Blob([res]))
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = 'licenses.csv'
|
||||||
|
a.click()
|
||||||
|
window.URL.revokeObjectURL(url)
|
||||||
|
} catch { /* */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const [p, c] = await Promise.all([getAllProducts(), getAllCustomers()])
|
||||||
|
products.value = p.data
|
||||||
|
customers.value = c.data
|
||||||
|
loadData()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.toolbar { display: flex; justify-content: space-between; margin-bottom: 16px; }
|
||||||
|
.filters { display: flex; gap: 10px; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
<template>
|
||||||
|
<div class="login-container">
|
||||||
|
<div class="login-card">
|
||||||
|
<div class="login-header">
|
||||||
|
<el-icon size="40" color="#409eff"><Key /></el-icon>
|
||||||
|
<h2 class="login-title">opsx-licence</h2>
|
||||||
|
<p class="login-subtitle">授权管理平台</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-form
|
||||||
|
ref="loginFormRef"
|
||||||
|
:model="loginForm"
|
||||||
|
:rules="loginRules"
|
||||||
|
size="large"
|
||||||
|
@keyup.enter="handleLogin"
|
||||||
|
>
|
||||||
|
<el-form-item prop="username">
|
||||||
|
<el-input
|
||||||
|
v-model="loginForm.username"
|
||||||
|
placeholder="用户名"
|
||||||
|
:prefix-icon="User"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item prop="password">
|
||||||
|
<el-input
|
||||||
|
v-model="loginForm.password"
|
||||||
|
type="password"
|
||||||
|
placeholder="密码"
|
||||||
|
show-password
|
||||||
|
:prefix-icon="Lock"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
:loading="loading"
|
||||||
|
style="width: 100%"
|
||||||
|
@click="handleLogin"
|
||||||
|
>
|
||||||
|
登 录
|
||||||
|
</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<div class="login-tip">默认账号:admin / admin</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive } from 'vue'
|
||||||
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
|
import { User, Lock } from '@element-plus/icons-vue'
|
||||||
|
import { useUserStore } from '@/stores/user'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
|
||||||
|
const loginFormRef = ref(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
const loginForm = reactive({
|
||||||
|
username: 'admin',
|
||||||
|
password: 'admin',
|
||||||
|
})
|
||||||
|
const loginRules = {
|
||||||
|
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
||||||
|
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理登录 // Logan.Li
|
||||||
|
async function handleLogin() {
|
||||||
|
if (!loginFormRef.value) return
|
||||||
|
await loginFormRef.value.validate(async (valid) => {
|
||||||
|
if (!valid) return
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await userStore.login(loginForm)
|
||||||
|
// 登录成功后跳转到 redirect 或首页 // Logan.Li
|
||||||
|
const redirect = route.query.redirect || '/'
|
||||||
|
router.push(redirect)
|
||||||
|
} catch {
|
||||||
|
// 拦截器已处理错误提示
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.login-container {
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
}
|
||||||
|
.login-card {
|
||||||
|
width: 400px;
|
||||||
|
padding: 40px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
.login-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
.login-title {
|
||||||
|
margin: 12px 0 4px;
|
||||||
|
font-size: 24px;
|
||||||
|
color: #303133;
|
||||||
|
}
|
||||||
|
.login-subtitle {
|
||||||
|
margin: 0;
|
||||||
|
color: #909399;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.login-tip {
|
||||||
|
text-align: center;
|
||||||
|
color: #c0c4cc;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<el-card shadow="never">
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="filters">
|
||||||
|
<el-select v-model="actionFilter" placeholder="操作类型" clearable style="width: 150px" @change="loadData">
|
||||||
|
<el-option v-for="a in actions" :key="a" :label="a" :value="a" />
|
||||||
|
</el-select>
|
||||||
|
<el-select v-model="targetFilter" placeholder="目标类型" clearable style="width: 130px" @change="loadData">
|
||||||
|
<el-option v-for="t in targets" :key="t" :label="t" :value="t" />
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
<el-popconfirm title="确定清空全部日志?" @confirm="clearLogs">
|
||||||
|
<template #reference><el-button type="danger">清空日志</el-button></template>
|
||||||
|
</el-popconfirm>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table :data="list" v-loading="loading" stripe>
|
||||||
|
<el-table-column prop="username" label="操作人" width="100" />
|
||||||
|
<el-table-column prop="action" label="操作" width="110" />
|
||||||
|
<el-table-column prop="target_type" label="目标类型" width="100" />
|
||||||
|
<el-table-column prop="target_name" label="目标名称" min-width="160" />
|
||||||
|
<el-table-column prop="detail" label="详情" min-width="200" show-overflow-tooltip />
|
||||||
|
<el-table-column label="结果" width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.result === 'success' ? 'success' : 'danger'" size="small">{{ row.result }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="ip" label="IP" width="130" />
|
||||||
|
<el-table-column prop="created_at" label="时间" width="170" />
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="page" v-model:page-size="pageSize"
|
||||||
|
:total="total" layout="total, prev, pager, next" style="margin-top: 16px"
|
||||||
|
@current-change="loadData"
|
||||||
|
/>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { getAuditLogs, clearAuditLogs } from '@/api/setting'
|
||||||
|
|
||||||
|
const list = ref([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const actionFilter = ref('')
|
||||||
|
const targetFilter = ref('')
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(15)
|
||||||
|
const total = ref(0)
|
||||||
|
|
||||||
|
const actions = ['create', 'update', 'delete', 'renew', 'revoke', 'activate', 'suspend', 'force_offline', 'manual_grant', 'payment_completed', 'update_status', 'toggle_status']
|
||||||
|
const targets = ['license', 'customer', 'product', 'plan', 'order', 'entitlement', 'activation']
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getAuditLogs({ page: page.value, page_size: pageSize.value, action: actionFilter.value, target_type: targetFilter.value })
|
||||||
|
list.value = res.data.list || []
|
||||||
|
total.value = res.data.total
|
||||||
|
} finally { loading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearLogs() {
|
||||||
|
await clearAuditLogs()
|
||||||
|
ElMessage.success('日志已清空')
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadData)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.toolbar { display: flex; justify-content: space-between; margin-bottom: 16px; }
|
||||||
|
.filters { display: flex; gap: 10px; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<template>
|
||||||
|
<div class="not-found">
|
||||||
|
<el-result icon="warning" title="404" sub-title="抱歉,您访问的页面不存在">
|
||||||
|
<template #extra>
|
||||||
|
<el-button type="primary" @click="$router.push('/')">返回首页</el-button>
|
||||||
|
</template>
|
||||||
|
</el-result>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
// 404 页面 // Logan.Li
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.not-found {
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<el-card shadow="never">
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="filters">
|
||||||
|
<el-select v-model="statusFilter" placeholder="订单状态" clearable style="width: 130px" @change="loadData">
|
||||||
|
<el-option v-for="s in statusOptions" :key="s.value" :label="s.label" :value="s.value" />
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
<el-button type="primary" @click="grantVisible = true">手动发放</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table :data="list" v-loading="loading" stripe>
|
||||||
|
<el-table-column prop="order_no" label="订单号" min-width="200" />
|
||||||
|
<el-table-column prop="customer_name" label="客户" min-width="100" />
|
||||||
|
<el-table-column prop="product_name" label="产品" min-width="100" />
|
||||||
|
<el-table-column prop="plan_name" label="计划" min-width="100" />
|
||||||
|
<el-table-column label="金额" width="100">
|
||||||
|
<template #default="{ row }">{{ row.amount }} {{ row.currency }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" width="90">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="statusTag(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="payment_gateway" label="渠道" width="90" />
|
||||||
|
<el-table-column prop="created_at" label="创建时间" width="160" />
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="page" v-model:page-size="pageSize"
|
||||||
|
:total="total" layout="total, prev, pager, next" style="margin-top: 16px"
|
||||||
|
@current-change="loadData"
|
||||||
|
/>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<!-- 手动发放 License -->
|
||||||
|
<el-dialog v-model="grantVisible" title="手动发放 License" width="500px">
|
||||||
|
<el-form ref="grantFormRef" :model="grantForm" :rules="grantRules" label-width="90px">
|
||||||
|
<el-form-item label="客户邮箱" prop="customer_email"><el-input v-model="grantForm.customer_email" /></el-form-item>
|
||||||
|
<el-form-item label="客户姓名"><el-input v-model="grantForm.customer_name" /></el-form-item>
|
||||||
|
<el-form-item label="产品" prop="product_id">
|
||||||
|
<el-select v-model="grantForm.product_id" filterable style="width: 100%" @change="loadPlans">
|
||||||
|
<el-option v-for="p in products" :key="p.id" :label="p.name" :value="p.id" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="计划" prop="plan_id">
|
||||||
|
<el-select v-model="grantForm.plan_id" filterable style="width: 100%">
|
||||||
|
<el-option v-for="pl in plans" :key="pl.id" :label="pl.name" :value="pl.id" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="备注"><el-input v-model="grantForm.notes" type="textarea" :rows="2" /></el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="grantVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="submitGrant">发放</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { getOrders, manualGrant } from '@/api/order'
|
||||||
|
import { getAllProducts } from '@/api/product'
|
||||||
|
import request from '@/api/request'
|
||||||
|
|
||||||
|
const list = ref([])
|
||||||
|
const products = ref([])
|
||||||
|
const plans = ref([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const statusFilter = ref('')
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(10)
|
||||||
|
const total = ref(0)
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
{ label: '待支付', value: 'pending' }, { label: '已支付', value: 'paid' },
|
||||||
|
{ label: '失败', value: 'failed' }, { label: '已退款', value: 'refunded' }, { label: '已取消', value: 'cancelled' },
|
||||||
|
]
|
||||||
|
function statusLabel(s) { return statusOptions.find((o) => o.value === s)?.label || s }
|
||||||
|
function statusTag(s) { return { paid: 'success', pending: 'warning', failed: 'danger', refunded: 'info', cancelled: 'info' }[s] || '' }
|
||||||
|
|
||||||
|
const grantVisible = ref(false)
|
||||||
|
const grantFormRef = ref(null)
|
||||||
|
const grantForm = reactive({ customer_email: '', customer_name: '', product_id: null, plan_id: null, notes: '' })
|
||||||
|
const grantRules = {
|
||||||
|
customer_email: [{ required: true, type: 'email', message: '请输入有效邮箱', trigger: 'blur' }],
|
||||||
|
product_id: [{ required: true, message: '请选择产品' }],
|
||||||
|
plan_id: [{ required: true, message: '请选择计划' }],
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getOrders({ page: page.value, page_size: pageSize.value, status: statusFilter.value })
|
||||||
|
list.value = res.data.list || []
|
||||||
|
total.value = res.data.total
|
||||||
|
} finally { loading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPlans(productID) {
|
||||||
|
grantForm.plan_id = null
|
||||||
|
const res = await request({ url: `/v1/products/${productID}/plans`, method: 'get' })
|
||||||
|
plans.value = res.data || []
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitGrant() {
|
||||||
|
await grantFormRef.value.validate(async (valid) => {
|
||||||
|
if (!valid) return
|
||||||
|
try {
|
||||||
|
const res = await manualGrant(grantForm)
|
||||||
|
ElMessage.success('License 发放成功:' + res.data.license_key)
|
||||||
|
grantVisible.value = false
|
||||||
|
loadData()
|
||||||
|
} catch { /* */ }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
products.value = (await getAllProducts()).data
|
||||||
|
loadData()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.toolbar { display: flex; justify-content: space-between; margin-bottom: 16px; }
|
||||||
|
.filters { display: flex; gap: 10px; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<el-card shadow="never">
|
||||||
|
<div class="toolbar">
|
||||||
|
<el-select v-model="productFilter" placeholder="筛选产品" clearable filterable style="width: 200px" @change="loadData" />
|
||||||
|
<el-button type="primary" @click="openCreate">新增计划</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table :data="list" v-loading="loading" stripe>
|
||||||
|
<el-table-column prop="name" label="计划名称" min-width="130" />
|
||||||
|
<el-table-column label="类型" width="90">
|
||||||
|
<template #default="{ row }">{{ planTypeLabel(row.plan_type) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="价格" width="100">
|
||||||
|
<template #default="{ row }">{{ row.price }} {{ row.currency }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="时长" width="90">
|
||||||
|
<template #default="{ row }">{{ row.duration === 0 ? '永久' : row.duration + ' 天' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="max_devices" label="设备数" width="80" />
|
||||||
|
<el-table-column label="功能" min-width="160">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag v-for="f in row.features" :key="f" size="small" style="margin: 2px">{{ f }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-switch :model-value="row.status === 1" @change="(v) => toggle(row, v)" />
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="140" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button size="small" @click="edit(row)">编辑</el-button>
|
||||||
|
<el-button size="small" type="danger" @click="remove(row)">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="page" v-model:page-size="pageSize"
|
||||||
|
:total="total" layout="total, prev, pager, next" style="margin-top: 16px"
|
||||||
|
@current-change="loadData"
|
||||||
|
/>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-dialog v-model="dialogVisible" :title="form.id ? '编辑计划' : '新增计划'" width="540px">
|
||||||
|
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
|
||||||
|
<el-form-item label="产品" prop="product_id">
|
||||||
|
<el-select v-model="form.product_id" filterable :disabled="!!form.id" style="width: 100%">
|
||||||
|
<el-option v-for="p in products" :key="p.id" :label="p.name" :value="p.id" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="名称" prop="name"><el-input v-model="form.name" /></el-form-item>
|
||||||
|
<el-form-item label="类型" prop="plan_type">
|
||||||
|
<el-select v-model="form.plan_type" style="width: 100%">
|
||||||
|
<el-option value="perpetual" label="买断" /><el-option value="lifetime" label="终身" />
|
||||||
|
<el-option value="monthly" label="月订阅" /><el-option value="yearly" label="年订阅" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="价格" prop="price"><el-input-number v-model="form.price" :min="0" :precision="2" /></el-form-item>
|
||||||
|
<el-form-item label="货币"><el-input v-model="form.currency" style="width: 100px" /></el-form-item>
|
||||||
|
<el-form-item label="时长(天)"><el-input-number v-model="form.duration" :min="0" /><span style="margin-left: 8px; color: #909399">0=永久</span></el-form-item>
|
||||||
|
<el-form-item label="设备数"><el-input-number v-model="form.max_devices" :min="1" /></el-form-item>
|
||||||
|
<el-form-item label="功能">
|
||||||
|
<el-select v-model="form.features" multiple filterable allow-create default-first-option style="width: 100%" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="dialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="submit">确认</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
|
import { ElMessageBox } from 'element-plus'
|
||||||
|
import { getPlans, createPlan, updatePlan, deletePlan, togglePlanStatus } from '@/api/plan'
|
||||||
|
import { getAllProducts } from '@/api/product'
|
||||||
|
|
||||||
|
const list = ref([])
|
||||||
|
const products = ref([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const productFilter = ref('')
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(10)
|
||||||
|
const total = ref(0)
|
||||||
|
|
||||||
|
const dialogVisible = ref(false)
|
||||||
|
const formRef = ref(null)
|
||||||
|
const form = reactive({ id: null, product_id: null, name: '', plan_type: 'monthly', price: 0, currency: 'USD', duration: 30, max_devices: 1, features: [] })
|
||||||
|
const rules = {
|
||||||
|
product_id: [{ required: true, message: '请选择产品' }],
|
||||||
|
name: [{ required: true, message: '请输入名称' }],
|
||||||
|
plan_type: [{ required: true, message: '请选择类型' }],
|
||||||
|
price: [{ required: true, message: '请输入价格' }],
|
||||||
|
}
|
||||||
|
|
||||||
|
function planTypeLabel(t) { return { perpetual: '买断', lifetime: '终身', monthly: '月订阅', yearly: '年订阅' }[t] || t }
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getPlans({ page: page.value, page_size: pageSize.value, product_id: productFilter.value })
|
||||||
|
list.value = res.data.list || []
|
||||||
|
total.value = res.data.total
|
||||||
|
} finally { loading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
Object.assign(form, { id: null, product_id: null, name: '', plan_type: 'monthly', price: 0, currency: 'USD', duration: 30, max_devices: 1, features: [] })
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function edit(row) {
|
||||||
|
Object.assign(form, row, { features: row.features || [], price: row.price })
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
await formRef.value.validate(async (valid) => {
|
||||||
|
if (!valid) return
|
||||||
|
try {
|
||||||
|
if (form.id) await updatePlan(form.id, form)
|
||||||
|
else await createPlan(form)
|
||||||
|
dialogVisible.value = false
|
||||||
|
loadData()
|
||||||
|
} catch { /* */ }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(row) {
|
||||||
|
await ElMessageBox.confirm(`确定删除计划「${row.name}」?`, '提示', { type: 'warning' })
|
||||||
|
await deletePlan(row.id)
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggle(row, val) {
|
||||||
|
await togglePlanStatus(row.id, val ? 1 : 0)
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
products.value = (await getAllProducts()).data
|
||||||
|
loadData()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.toolbar { display: flex; justify-content: space-between; margin-bottom: 16px; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<el-card shadow="never">
|
||||||
|
<div class="toolbar">
|
||||||
|
<el-input v-model="keyword" placeholder="搜索产品名称/编码" clearable style="width: 240px" @keyup.enter="loadData" @clear="loadData" />
|
||||||
|
<el-button type="primary" @click="dialogVisible = true">新增产品</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table :data="list" v-loading="loading" stripe>
|
||||||
|
<el-table-column prop="name" label="产品名称" min-width="140" />
|
||||||
|
<el-table-column prop="code" label="编码" width="120" />
|
||||||
|
<el-table-column prop="version" label="版本" width="90" />
|
||||||
|
<el-table-column prop="price" label="价格" width="90">
|
||||||
|
<template #default="{ row }">{{ row.price }} {{ row.currency || '' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="price_model" label="计费模式" width="110" />
|
||||||
|
<el-table-column label="功能" min-width="180">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag v-for="f in row.features" :key="f" size="small" style="margin: 2px">{{ f }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-switch :model-value="row.status === 1" @change="(v) => toggleStatus(row, v)" />
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="140" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button size="small" @click="edit(row)">编辑</el-button>
|
||||||
|
<el-button size="small" type="danger" @click="remove(row)">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="page" v-model:page-size="pageSize"
|
||||||
|
:total="total" layout="total, prev, pager, next" style="margin-top: 16px"
|
||||||
|
@current-change="loadData"
|
||||||
|
/>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<!-- 新增/编辑对话框 -->
|
||||||
|
<el-dialog v-model="dialogVisible" :title="form.id ? '编辑产品' : '新增产品'" width="560px">
|
||||||
|
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
|
||||||
|
<el-form-item label="名称" prop="name"><el-input v-model="form.name" /></el-form-item>
|
||||||
|
<el-form-item label="编码" prop="code"><el-input v-model="form.code" :disabled="!!form.id" /></el-form-item>
|
||||||
|
<el-form-item label="版本"><el-input v-model="form.version" /></el-form-item>
|
||||||
|
<el-form-item label="价格"><el-input-number v-model="form.price" :min="0" :precision="2" /></el-form-item>
|
||||||
|
<el-form-item label="计费模式">
|
||||||
|
<el-select v-model="form.price_model" style="width: 100%">
|
||||||
|
<el-option value="subscription" label="订阅" /><el-option value="perpetual" label="买断" /><el-option value="trial" label="试用" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="功能特性">
|
||||||
|
<el-select v-model="form.features" multiple filterable allow-create default-first-option style="width: 100%" placeholder="输入后回车添加" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="描述"><el-input v-model="form.description" type="textarea" :rows="2" /></el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="dialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="submit">确认</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { getProducts, createProduct, updateProduct, deleteProduct, updateProductStatus } from '@/api/product'
|
||||||
|
|
||||||
|
const list = ref([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const keyword = ref('')
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(10)
|
||||||
|
const total = ref(0)
|
||||||
|
|
||||||
|
const dialogVisible = ref(false)
|
||||||
|
const formRef = ref(null)
|
||||||
|
const form = reactive({ id: null, name: '', code: '', version: '', price: 0, price_model: 'subscription', features: [], description: '' })
|
||||||
|
const rules = {
|
||||||
|
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
|
||||||
|
code: [{ required: true, message: '请输入编码', trigger: 'blur' }],
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getProducts({ page: page.value, page_size: pageSize.value, keyword: keyword.value })
|
||||||
|
list.value = res.data.list || []
|
||||||
|
total.value = res.data.total
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function edit(row) {
|
||||||
|
Object.assign(form, row, { features: row.features || [] })
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
await formRef.value.validate(async (valid) => {
|
||||||
|
if (!valid) return
|
||||||
|
try {
|
||||||
|
if (form.id) {
|
||||||
|
await updateProduct(form.id, form)
|
||||||
|
} else {
|
||||||
|
await createProduct(form)
|
||||||
|
}
|
||||||
|
dialogVisible.value = false
|
||||||
|
loadData()
|
||||||
|
} catch { /* 拦截器处理 */ }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(row) {
|
||||||
|
await ElMessageBox.confirm(`确定删除产品「${row.name}」?`, '提示', { type: 'warning' })
|
||||||
|
await deleteProduct(row.id)
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleStatus(row, val) {
|
||||||
|
await updateProductStatus(row.id, val ? 1 : 0)
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
loadData()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.toolbar { display: flex; justify-content: space-between; margin-bottom: 16px; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<el-row :gutter="16">
|
||||||
|
<!-- 系统设置 -->
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-card shadow="never">
|
||||||
|
<template #header>系统设置</template>
|
||||||
|
<el-form :model="setting" label-width="130px" v-loading="loadingSetting">
|
||||||
|
<el-form-item label="系统名称"><el-input v-model="setting.system_name" /></el-form-item>
|
||||||
|
<el-form-item label="默认有效期(天)"><el-input-number v-model="setting.default_duration" :min="0" /></el-form-item>
|
||||||
|
<el-form-item label="默认最大激活数"><el-input-number v-model="setting.default_max_activations" :min="1" /></el-form-item>
|
||||||
|
<el-form-item label="到期提醒(天)"><el-input-number v-model="setting.expire_reminder_days" :min="1" /></el-form-item>
|
||||||
|
<el-form-item label="API 限频"><el-input-number v-model="setting.api_rate_limit" :min="0" /></el-form-item>
|
||||||
|
<el-form-item label="日志保留(天)"><el-input-number v-model="setting.log_retention_days" :min="0" /></el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" @click="saveSetting">保存设置</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
|
||||||
|
<!-- License 模板 -->
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-card shadow="never">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<span>License 模板</span>
|
||||||
|
<el-button size="small" type="primary" @click="openTemplate">新增模板</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<el-table :data="templates" size="small" stripe>
|
||||||
|
<el-table-column prop="name" label="名称" min-width="120" />
|
||||||
|
<el-table-column label="时长" width="90">
|
||||||
|
<template #default="{ row }">{{ row.duration === 0 ? '永久' : row.duration + '天' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="max_activations" label="激活数" width="80" />
|
||||||
|
<el-table-column prop="description" label="说明" min-width="120" show-overflow-tooltip />
|
||||||
|
<el-table-column label="操作" width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button size="small" link @click="editTemplate(row)">编辑</el-button>
|
||||||
|
<el-button size="small" link type="danger" @click="removeTemplate(row)">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<!-- 模板对话框 -->
|
||||||
|
<el-dialog v-model="tmplVisible" :title="tmplForm.id ? '编辑模板' : '新增模板'" width="440px">
|
||||||
|
<el-form :model="tmplForm" label-width="90px">
|
||||||
|
<el-form-item label="名称"><el-input v-model="tmplForm.name" /></el-form-item>
|
||||||
|
<el-form-item label="时长(天)"><el-input-number v-model="tmplForm.duration" :min="0" /><span style="margin-left:8px;color:#909399">0=永久</span></el-form-item>
|
||||||
|
<el-form-item label="激活数"><el-input-number v-model="tmplForm.max_activations" :min="1" /></el-form-item>
|
||||||
|
<el-form-item label="说明"><el-input v-model="tmplForm.description" type="textarea" :rows="2" /></el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="tmplVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="saveTemplate">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { getSetting, updateSetting, getLicenseTemplates, saveLicenseTemplate, deleteLicenseTemplate } from '@/api/setting'
|
||||||
|
|
||||||
|
const setting = reactive({})
|
||||||
|
const loadingSetting = ref(false)
|
||||||
|
const templates = ref([])
|
||||||
|
const tmplVisible = ref(false)
|
||||||
|
const tmplForm = reactive({ id: null, name: '', duration: 365, max_activations: 1, description: '' })
|
||||||
|
|
||||||
|
async function loadSetting() {
|
||||||
|
loadingSetting.value = true
|
||||||
|
try {
|
||||||
|
const res = await getSetting()
|
||||||
|
Object.assign(setting, res.data)
|
||||||
|
} finally { loadingSetting.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSetting() {
|
||||||
|
await updateSetting(setting)
|
||||||
|
ElMessage.success('设置已保存')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTemplates() {
|
||||||
|
const res = await getLicenseTemplates()
|
||||||
|
templates.value = res.data || []
|
||||||
|
}
|
||||||
|
|
||||||
|
function openTemplate() {
|
||||||
|
Object.assign(tmplForm, { id: null, name: '', duration: 365, max_activations: 1, description: '' })
|
||||||
|
tmplVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function editTemplate(row) {
|
||||||
|
Object.assign(tmplForm, row)
|
||||||
|
tmplVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveTemplate() {
|
||||||
|
await saveLicenseTemplate(tmplForm)
|
||||||
|
tmplVisible.value = false
|
||||||
|
loadTemplates()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeTemplate(row) {
|
||||||
|
await ElMessageBox.confirm(`确定删除模板「${row.name}」?`, '提示', { type: 'warning' })
|
||||||
|
await deleteLicenseTemplate(row.id)
|
||||||
|
loadTemplates()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadSetting()
|
||||||
|
loadTemplates()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.card-header { display: flex; justify-content: space-between; align-items: center; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import { resolve } from 'path'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': resolve(__dirname, 'src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
host: '0.0.0.0',
|
||||||
|
port: 5181,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:9001',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
target: 'esnext',
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,406 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# opsx-licence 启动脚本
|
||||||
|
# 一键启动前后端服务,支持日志轮转与单二进制部署
|
||||||
|
# 用法: ./run.sh [start|stop|restart|status|frontend|backend|build|binary|help]
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 配置区
|
||||||
|
# ============================================================
|
||||||
|
PROJECT_NAME="opsx-licence"
|
||||||
|
BACKEND_PORT=9001
|
||||||
|
FRONTEND_PORT=5181
|
||||||
|
BACKEND_DIR="backend"
|
||||||
|
FRONTEND_DIR="frontend"
|
||||||
|
BACKEND_BIN="backend"
|
||||||
|
STANDALONE_BIN="opsx-licence"
|
||||||
|
STANDALONE_PORT="${PORT:-9001}"
|
||||||
|
BACKEND_LAUNCH_LABEL="com.opsx.licence.backend"
|
||||||
|
FRONTEND_LAUNCH_LABEL="com.opsx.licence.frontend"
|
||||||
|
|
||||||
|
# 日志配置
|
||||||
|
MAX_LOG_FILES=3
|
||||||
|
MAX_LOG_SIZE=$((50 * 1024 * 1024)) # 50MB
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 通用函数
|
||||||
|
# ============================================================
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
info() { echo -e "${BLUE}[INFO]${NC} $1"; }
|
||||||
|
success() { echo -e "${GREEN}[SUCCESS]${NC} $1"; }
|
||||||
|
warning() { echo -e "${YELLOW}[WARNING]${NC} $1"; }
|
||||||
|
error() { echo -e "${RED}[ERROR]${NC} $1"; }
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
|
# PID 文件
|
||||||
|
BACKEND_PID_FILE="$SCRIPT_DIR/$BACKEND_DIR/.backend.pid"
|
||||||
|
FRONTEND_PID_FILE="$SCRIPT_DIR/$FRONTEND_DIR/.frontend.pid"
|
||||||
|
STANDALONE_PID_FILE="$SCRIPT_DIR/.opsx-licence.pid"
|
||||||
|
STANDALONE_LOG_FILE="$SCRIPT_DIR/logs/opsx-licence.log"
|
||||||
|
|
||||||
|
# 检查端口是否被占用
|
||||||
|
check_port() {
|
||||||
|
lsof -ti:$1 2>/dev/null | head -1
|
||||||
|
}
|
||||||
|
|
||||||
|
# 释放端口
|
||||||
|
kill_port() {
|
||||||
|
local pids=$(lsof -ti:$1 2>/dev/null || echo "")
|
||||||
|
if [ -n "$pids" ]; then
|
||||||
|
info "释放端口 $1..."
|
||||||
|
echo "$pids" | xargs kill -9 2>/dev/null || true
|
||||||
|
sleep 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# 日志轮转(仅启动时调用)
|
||||||
|
rotate_log() {
|
||||||
|
local log_file=$1
|
||||||
|
[ ! -f "$log_file" ] && return 0
|
||||||
|
|
||||||
|
local file_size=$(stat -f%z "$log_file" 2>/dev/null || stat -c%s "$log_file" 2>/dev/null || echo "0")
|
||||||
|
if [ "$file_size" -gt "$MAX_LOG_SIZE" ]; then
|
||||||
|
[ -f "${log_file}.${MAX_LOG_FILES}" ] && rm -f "${log_file}.${MAX_LOG_FILES}"
|
||||||
|
for i in $(seq $((MAX_LOG_FILES - 1)) -1 1); do
|
||||||
|
[ -f "${log_file}.${i}" ] && mv "${log_file}.${i}" "${log_file}.$((i + 1))"
|
||||||
|
done
|
||||||
|
mv "$log_file" "${log_file}.1"
|
||||||
|
info "日志已轮转: $log_file"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# macOS 使用 launchctl 脱离当前终端,避免开发服务在脚本退出后被系统回收。 // Logan.Li
|
||||||
|
start_detached() {
|
||||||
|
local label="$1"
|
||||||
|
local log_file="$2"
|
||||||
|
local command="$3"
|
||||||
|
|
||||||
|
if [ "$(uname -s)" = "Darwin" ] && command -v launchctl >/dev/null 2>&1; then
|
||||||
|
launchctl remove "$label" 2>/dev/null || true
|
||||||
|
launchctl submit -l "$label" -o "$log_file" -e "$log_file" -- /bin/sh -c "$command"
|
||||||
|
else
|
||||||
|
nohup /bin/sh -c "$command" >> "$log_file" 2>&1 < /dev/null &
|
||||||
|
echo $!
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# macOS 下卸载 launchctl 任务,其他系统沿用 PID 与端口清理。 // Logan.Li
|
||||||
|
stop_detached() {
|
||||||
|
local label="$1"
|
||||||
|
if [ "$(uname -s)" = "Darwin" ] && command -v launchctl >/dev/null 2>&1; then
|
||||||
|
launchctl remove "$label" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 后端服务
|
||||||
|
# ============================================================
|
||||||
|
start_backend() {
|
||||||
|
info "正在启动后端服务..."
|
||||||
|
cd "$SCRIPT_DIR/$BACKEND_DIR"
|
||||||
|
mkdir -p logs
|
||||||
|
rotate_log "logs/server.log"
|
||||||
|
|
||||||
|
# 释放被占用的端口
|
||||||
|
kill_port $BACKEND_PORT
|
||||||
|
|
||||||
|
export PORT=$BACKEND_PORT
|
||||||
|
export GIN_MODE=debug
|
||||||
|
|
||||||
|
# 编译并运行
|
||||||
|
info "编译 Go 程序..."
|
||||||
|
go build -o "$BACKEND_BIN" .
|
||||||
|
|
||||||
|
start_detached "$BACKEND_LAUNCH_LABEL" "$SCRIPT_DIR/$BACKEND_DIR/logs/server.log" \
|
||||||
|
"cd \"$SCRIPT_DIR/$BACKEND_DIR\" && exec env PORT=\"$BACKEND_PORT\" GIN_MODE=\"debug\" ./\"$BACKEND_BIN\""
|
||||||
|
|
||||||
|
# 等待服务启动
|
||||||
|
local retries=0
|
||||||
|
while ! curl -s "http://localhost:$BACKEND_PORT/health" > /dev/null 2>&1; do
|
||||||
|
retries=$((retries + 1))
|
||||||
|
if [ $retries -ge 30 ]; then
|
||||||
|
error "后端服务启动超时,请检查日志: $BACKEND_DIR/logs/server.log"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "$(check_port $BACKEND_PORT)" > "$BACKEND_PID_FILE"
|
||||||
|
|
||||||
|
success "后端服务已启动 (PID: $(cat "$BACKEND_PID_FILE"), 端口: $BACKEND_PORT)"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
stop_backend() {
|
||||||
|
stop_detached "$BACKEND_LAUNCH_LABEL"
|
||||||
|
if [ -f "$BACKEND_PID_FILE" ]; then
|
||||||
|
local pid=$(cat "$BACKEND_PID_FILE" 2>/dev/null || echo "")
|
||||||
|
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
|
||||||
|
kill "$pid" 2>/dev/null || true
|
||||||
|
success "后端服务已停止"
|
||||||
|
fi
|
||||||
|
rm -f "$BACKEND_PID_FILE"
|
||||||
|
fi
|
||||||
|
kill_port $BACKEND_PORT
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 前端服务
|
||||||
|
# ============================================================
|
||||||
|
start_frontend() {
|
||||||
|
info "正在启动前端服务..."
|
||||||
|
cd "$SCRIPT_DIR/$FRONTEND_DIR"
|
||||||
|
mkdir -p logs
|
||||||
|
rotate_log "logs/dev.log"
|
||||||
|
|
||||||
|
kill_port $FRONTEND_PORT
|
||||||
|
|
||||||
|
if [ ! -d "node_modules" ]; then
|
||||||
|
warning "node_modules 不存在,正在安装依赖..."
|
||||||
|
npm install
|
||||||
|
fi
|
||||||
|
|
||||||
|
local npm_bin
|
||||||
|
npm_bin=$(command -v npm)
|
||||||
|
start_detached "$FRONTEND_LAUNCH_LABEL" "$SCRIPT_DIR/$FRONTEND_DIR/logs/dev.log" \
|
||||||
|
"cd \"$SCRIPT_DIR/$FRONTEND_DIR\" && exec env PATH=\"$PATH\" \"$npm_bin\" run dev"
|
||||||
|
|
||||||
|
# 等待服务启动
|
||||||
|
local retries=0
|
||||||
|
while ! curl -s "http://localhost:$FRONTEND_PORT" > /dev/null 2>&1; do
|
||||||
|
retries=$((retries + 1))
|
||||||
|
if [ $retries -ge 30 ]; then
|
||||||
|
error "前端服务启动超时,请检查日志: $FRONTEND_DIR/logs/dev.log"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "$(check_port $FRONTEND_PORT)" > "$FRONTEND_PID_FILE"
|
||||||
|
|
||||||
|
success "前端服务已启动 (PID: $(cat "$FRONTEND_PID_FILE"), 端口: $FRONTEND_PORT)"
|
||||||
|
info "前端地址: http://localhost:$FRONTEND_PORT"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
stop_frontend() {
|
||||||
|
stop_detached "$FRONTEND_LAUNCH_LABEL"
|
||||||
|
if [ -f "$FRONTEND_PID_FILE" ]; then
|
||||||
|
local pid=$(cat "$FRONTEND_PID_FILE" 2>/dev/null || echo "")
|
||||||
|
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
|
||||||
|
kill "$pid" 2>/dev/null || true
|
||||||
|
success "前端服务已停止"
|
||||||
|
fi
|
||||||
|
rm -f "$FRONTEND_PID_FILE"
|
||||||
|
fi
|
||||||
|
kill_port $FRONTEND_PORT
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 单二进制部署模式
|
||||||
|
# ============================================================
|
||||||
|
build_binary() {
|
||||||
|
info "构建前端生产资源..."
|
||||||
|
cd "$SCRIPT_DIR/$FRONTEND_DIR"
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
info "嵌入前端资源并编译 $STANDALONE_BIN..."
|
||||||
|
rm -rf "$SCRIPT_DIR/$BACKEND_DIR/static"
|
||||||
|
mkdir -p "$SCRIPT_DIR/$BACKEND_DIR/static"
|
||||||
|
cp -R dist/. "$SCRIPT_DIR/$BACKEND_DIR/static/"
|
||||||
|
|
||||||
|
cd "$SCRIPT_DIR/$BACKEND_DIR"
|
||||||
|
CGO_ENABLED=1 go build -trimpath -ldflags="-s -w" -o "$SCRIPT_DIR/$STANDALONE_BIN" .
|
||||||
|
success "单二进制已生成: $SCRIPT_DIR/$STANDALONE_BIN"
|
||||||
|
}
|
||||||
|
|
||||||
|
start_binary() {
|
||||||
|
if [ ! -x "$SCRIPT_DIR/$STANDALONE_BIN" ]; then
|
||||||
|
error "未找到 $STANDALONE_BIN,请先执行 ./run.sh build"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$SCRIPT_DIR/logs"
|
||||||
|
rotate_log "$STANDALONE_LOG_FILE"
|
||||||
|
kill_port "$STANDALONE_PORT"
|
||||||
|
|
||||||
|
info "启动单二进制服务..."
|
||||||
|
PORT="$STANDALONE_PORT" GIN_MODE="${GIN_MODE:-debug}" nohup "$SCRIPT_DIR/$STANDALONE_BIN" >> "$STANDALONE_LOG_FILE" 2>&1 &
|
||||||
|
echo $! > "$STANDALONE_PID_FILE"
|
||||||
|
|
||||||
|
local retries=0
|
||||||
|
while ! curl -s "http://localhost:$STANDALONE_PORT/health" > /dev/null 2>&1; do
|
||||||
|
retries=$((retries + 1))
|
||||||
|
if [ $retries -ge 30 ]; then
|
||||||
|
error "单二进制启动超时,请检查日志: $STANDALONE_LOG_FILE"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
success "单二进制服务已启动: http://localhost:$STANDALONE_PORT (PID: $(cat "$STANDALONE_PID_FILE"))"
|
||||||
|
}
|
||||||
|
|
||||||
|
stop_binary() {
|
||||||
|
if [ -f "$STANDALONE_PID_FILE" ]; then
|
||||||
|
local pid=$(cat "$STANDALONE_PID_FILE" 2>/dev/null || echo "")
|
||||||
|
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
|
||||||
|
kill "$pid" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
rm -f "$STANDALONE_PID_FILE"
|
||||||
|
fi
|
||||||
|
kill_port "$STANDALONE_PORT"
|
||||||
|
}
|
||||||
|
|
||||||
|
binary_status() {
|
||||||
|
if [ -n "$(check_port $STANDALONE_PORT)" ]; then
|
||||||
|
success "单二进制服务: 运行中 (端口 $STANDALONE_PORT)"
|
||||||
|
else
|
||||||
|
warning "单二进制服务: 未运行"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
binary_command() {
|
||||||
|
case "${1:-start}" in
|
||||||
|
start) start_binary ;;
|
||||||
|
stop) stop_binary ;;
|
||||||
|
restart) stop_binary; sleep 1; start_binary ;;
|
||||||
|
status) binary_status ;;
|
||||||
|
upgrade) stop_binary; build_binary; start_binary ;;
|
||||||
|
*)
|
||||||
|
error "未知 binary 命令: $1"
|
||||||
|
echo "用法: ./run.sh binary [start|stop|restart|status|upgrade]"
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 命令
|
||||||
|
# ============================================================
|
||||||
|
stop() {
|
||||||
|
info "正在停止服务..."
|
||||||
|
stop_backend
|
||||||
|
stop_frontend
|
||||||
|
info "所有服务已停止"
|
||||||
|
}
|
||||||
|
|
||||||
|
show_status() {
|
||||||
|
info "服务状态:"
|
||||||
|
if [ -n "$(check_port $BACKEND_PORT)" ]; then
|
||||||
|
success "后端服务: 运行中 (PID: $(check_port $BACKEND_PORT), 端口: $BACKEND_PORT)"
|
||||||
|
else
|
||||||
|
warning "后端服务: 未运行"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$(check_port $FRONTEND_PORT)" ]; then
|
||||||
|
success "前端服务: 运行中 (端口: $FRONTEND_PORT)"
|
||||||
|
else
|
||||||
|
warning "前端服务: 未运行"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
show_help() {
|
||||||
|
echo "$PROJECT_NAME 启动脚本"
|
||||||
|
echo ""
|
||||||
|
echo "用法: ./run.sh [命令]"
|
||||||
|
echo ""
|
||||||
|
echo "命令:"
|
||||||
|
echo " start 启动前后端服务 (默认)"
|
||||||
|
echo " stop 停止前后端服务"
|
||||||
|
echo " restart 重启前后端服务"
|
||||||
|
echo " backend 仅启动后端服务"
|
||||||
|
echo " frontend 仅启动前端服务"
|
||||||
|
echo " build 构建嵌入前端的单二进制 $STANDALONE_BIN"
|
||||||
|
echo " binary 管理单二进制服务(start|stop|restart|status|upgrade)"
|
||||||
|
echo " status 查看服务状态"
|
||||||
|
echo " help 显示帮助信息"
|
||||||
|
echo ""
|
||||||
|
echo "示例:"
|
||||||
|
echo " ./run.sh # 启动所有服务"
|
||||||
|
echo " ./run.sh stop # 停止服务"
|
||||||
|
echo " ./run.sh restart # 重启服务"
|
||||||
|
echo " ./run.sh backend # 仅启动后端"
|
||||||
|
echo " ./run.sh build # 生成 ./opsx-licence"
|
||||||
|
echo " ./run.sh binary start # 启动单二进制服务"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 主入口
|
||||||
|
# ============================================================
|
||||||
|
main() {
|
||||||
|
local command=${1:-start}
|
||||||
|
|
||||||
|
case "$command" in
|
||||||
|
start)
|
||||||
|
info "========== $PROJECT_NAME 启动脚本 =========="
|
||||||
|
|
||||||
|
# 检查依赖
|
||||||
|
if ! command -v go &> /dev/null; then
|
||||||
|
error "Go 未安装,请先安装 Go"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! command -v npm &> /dev/null; then
|
||||||
|
error "npm 未安装,请先安装 Node.js"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if start_backend && start_frontend; then
|
||||||
|
echo ""
|
||||||
|
success "========== 所有服务已启动 =========="
|
||||||
|
info "前端地址: http://localhost:$FRONTEND_PORT"
|
||||||
|
info "后端地址: http://localhost:$BACKEND_PORT"
|
||||||
|
info "默认账号: admin / admin"
|
||||||
|
echo ""
|
||||||
|
info "使用 './run.sh stop' 停止服务"
|
||||||
|
else
|
||||||
|
error "服务启动失败"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
stop)
|
||||||
|
stop
|
||||||
|
;;
|
||||||
|
restart)
|
||||||
|
stop
|
||||||
|
sleep 2
|
||||||
|
main start
|
||||||
|
;;
|
||||||
|
backend)
|
||||||
|
info "========== $PROJECT_NAME (仅后端) =========="
|
||||||
|
start_backend
|
||||||
|
show_status
|
||||||
|
;;
|
||||||
|
frontend)
|
||||||
|
info "========== $PROJECT_NAME (仅前端) =========="
|
||||||
|
start_frontend
|
||||||
|
show_status
|
||||||
|
;;
|
||||||
|
build)
|
||||||
|
build_binary
|
||||||
|
;;
|
||||||
|
binary)
|
||||||
|
binary_command "${2:-start}"
|
||||||
|
;;
|
||||||
|
status)
|
||||||
|
show_status
|
||||||
|
;;
|
||||||
|
help|--help|-h)
|
||||||
|
show_help
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
error "未知命令: $command"
|
||||||
|
show_help
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
Reference in new issue
Block a user