first commit

main
JiXieShi 2024-11-14 22:55:43 +08:00
commit 421cfb8cfa
98 changed files with 12617 additions and 0 deletions

4
.gitattributes vendored Normal file
View File

@ -0,0 +1,4 @@
* text=auto eol=lf
*.{cmd,[cC][mM][dD]} text eol=crlf
*.{bat,[bB][aA][tT]} text eol=crlf
*.ps1 text eol=crlf

8
.idea/.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
# 默认忽略的文件
/shelf/
/workspace.xml
# 基于编辑器的 HTTP 客户端请求
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

6
.idea/encodings.xml Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding">
<file url="PROJECT" charset="UTF-8" />
</component>
</project>

9
.idea/licserver.iml Normal file
View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="Go" enabled="true" />
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

17
.idea/misc.xml Normal file
View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectInspectionProfilesVisibleTreeState">
<entry key="Project Default">
<profile-state>
<expanded-state>
<State />
</expanded-state>
<selected-state>
<State>
<id>用户定义</id>
</State>
</selected-state>
</profile-state>
</entry>
</component>
</project>

8
.idea/modules.xml Normal file
View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/licserver.iml" filepath="$PROJECT_DIR$/.idea/licserver.iml" />
</modules>
</component>
</project>

519
api-docs.yaml Normal file
View File

@ -0,0 +1,519 @@
swagger: '2.0'
info:
title: 授权验证管理平台 API
version: '1.0.0'
description: 授权验证管理平台的所有 API 接口文档
host: localhost:8080
basePath: /api
schemes:
- http
- https
securityDefinitions:
BearerAuth:
type: apiKey
name: Authorization
in: header
description: 'Bearer {token}'
tags:
- name: 用户认证
description: 用户登录和认证相关接口
- name: 设备管理
description: 设备型号和设备管理相关接口
- name: 授权管理
description: 授权码管理相关接口
paths:
/login:
post:
tags:
- 用户认证
summary: 用户登录
parameters:
- in: body
name: body
required: true
schema:
type: object
properties:
username:
type: string
description: 用户名
password:
type: string
description: 密码
captcha:
type: string
description: 验证码
captchaId:
type: string
description: 验证码ID
responses:
200:
description: 登录成功
schema:
type: object
properties:
code:
type: integer
message:
type: string
token:
type: string
/captcha:
get:
tags:
- 用户认证
summary: 获取验证码
responses:
200:
description: 获取成功
schema:
type: object
properties:
captchaId:
type: string
imageBase64:
type: string
/devices/models:
get:
tags:
- 设备管理
summary: 获取设备型号列表
security:
- BearerAuth: []
parameters:
- name: page
in: query
type: integer
- name: limit
in: query
type: integer
- name: model_name
in: query
type: string
- name: device_type
in: query
type: string
responses:
200:
description: 获取成功
schema:
type: object
properties:
code:
type: integer
count:
type: integer
data:
type: array
items:
$ref: '#/definitions/DeviceModel'
post:
tags:
- 设备管理
summary: 创建设备型号
security:
- BearerAuth: []
parameters:
- in: body
name: body
required: true
schema:
$ref: '#/definitions/DeviceModel'
responses:
200:
description: 创建成功
/devices/registered:
get:
tags:
- 设备管理
summary: 获取已注册设备列表
security:
- BearerAuth: []
parameters:
- name: page
in: query
type: integer
- name: limit
in: query
type: integer
- name: uid
in: query
type: string
- name: device_model
in: query
type: string
- name: status
in: query
type: string
responses:
200:
description: 获取成功
schema:
type: object
properties:
code:
type: integer
count:
type: integer
data:
type: array
items:
$ref: '#/definitions/Device'
/devices/{uid}/license:
post:
tags:
- 设备管理
summary: 绑定授权码
security:
- BearerAuth: []
parameters:
- name: uid
in: path
required: true
type: string
- in: body
name: body
required: true
schema:
type: object
properties:
license_code:
type: string
responses:
200:
description: 绑定成功
delete:
tags:
- 设备管理
summary: 解绑授权码
security:
- BearerAuth: []
parameters:
- name: uid
in: path
required: true
type: string
responses:
200:
description: 解绑成功
/licenses:
get:
tags:
- 授权管理
summary: 获取授权码列表
security:
- BearerAuth: []
parameters:
- name: page
in: query
type: integer
- name: limit
in: query
type: integer
- name: status
in: query
type: string
description: 授权码状态(unused/used/expired/revoked)
- name: license_type
in: query
type: string
description: 授权类型(time/count/permanent)
- name: batch_no
in: query
type: string
description: 批次号
responses:
200:
description: 获取成功
schema:
type: object
properties:
code:
type: integer
count:
type: integer
data:
type: array
items:
$ref: '#/definitions/LicenseCode'
post:
tags:
- 授权管理
summary: 生成授权码
security:
- BearerAuth: []
parameters:
- in: body
name: body
required: true
schema:
type: object
properties:
license_type:
type: string
enum: [time, count, permanent]
description: 授权类型
duration:
type: integer
description: 有效期(分钟)
max_uses:
type: integer
description: 最大使用次数
count:
type: integer
description: 生成数量
remark:
type: string
description: 备注说明
responses:
200:
description: 生成成功
/licenses/{id}/logs:
get:
tags:
- 授权管理
summary: 获取授权码操作日志
security:
- BearerAuth: []
parameters:
- name: id
in: path
required: true
type: string
- name: page
in: query
type: integer
- name: limit
in: query
type: integer
- name: action
in: query
type: string
description: 操作类型(create/use/verify/revoke)
- name: status
in: query
type: string
description: 状态(success/failed)
responses:
200:
description: 获取成功
schema:
type: object
properties:
code:
type: integer
count:
type: integer
data:
type: array
items:
$ref: '#/definitions/LicenseLog'
/licenses/{code}/revoke:
post:
tags:
- 授权管理
summary: 撤销授权码
security:
- BearerAuth: []
parameters:
- name: code
in: path
required: true
type: string
responses:
200:
description: 撤销成功
/licenses/batch/revoke:
post:
tags:
- 授权管理
summary: 批量撤销授权码
security:
- BearerAuth: []
parameters:
- in: body
name: body
required: true
schema:
type: object
properties:
codes:
type: array
items:
type: string
description: 授权码列表
responses:
200:
description: 批量撤销成功
/devices/register:
post:
tags:
- 设备管理
summary: 设备注册
parameters:
- in: body
name: body
required: true
schema:
type: object
required:
- uid
- device_model
- license_code
properties:
uid:
type: string
description: 设备UID
device_model:
type: string
description: 设备型号
license_code:
type: string
description: 授权码
responses:
200:
description: 注册成功
schema:
type: object
properties:
code:
type: integer
message:
type: string
400:
description: 注册失败
schema:
type: object
properties:
error:
type: string
definitions:
DeviceModel:
type: object
properties:
id:
type: integer
model_name:
type: string
device_type:
type: string
company:
type: string
remark:
type: string
device_count:
type: integer
status:
type: string
created_at:
type: string
format: date-time
Device:
type: object
properties:
uid:
type: string
device_model:
type: string
device_type:
type: string
company:
type: string
register_time:
type: string
format: date-time
expire_time:
type: string
format: date-time
license_type:
type: string
license_code:
type: string
status:
type: string
start_count:
type: integer
last_active_at:
type: string
format: date-time
LicenseCode:
type: object
properties:
id:
type: integer
code:
type: string
license_type:
type: string
description: 授权类型(time/count/permanent)
duration:
type: integer
description: 有效期(分钟)
max_uses:
type: integer
description: 最大使用次数
used_count:
type: integer
description: 已使用次数
status:
type: string
description: 状态(unused/used/expired/revoked)
used_by:
type: string
description: 使用设备UID
used_at:
type: string
format: date-time
created_by:
type: integer
batch_no:
type: string
remark:
type: string
created_at:
type: string
format: date-time
updated_at:
type: string
format: date-time
LicenseLog:
type: object
properties:
id:
type: integer
license_id:
type: integer
device_uid:
type: string
action:
type: string
description: 操作类型(create/use/verify/revoke)
ip:
type: string
status:
type: string
description: 状态(success/failed)
message:
type: string
created_at:
type: string
format: date-time

112
cmd/main.go Normal file
View File

@ -0,0 +1,112 @@
package main
import (
"fmt"
"licserver/internal/api"
"licserver/internal/model"
"licserver/internal/service"
"licserver/internal/utils"
"log"
"os"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
// 初始化管理员账号
func initAdminUser(db *gorm.DB) error {
var count int64
db.Model(&model.User{}).Where("role = ?", "admin").Count(&count)
// 如果没有管理员账号,创建默认管理员
if count == 0 {
hashedPassword, err := bcrypt.GenerateFromPassword([]byte("admin"), bcrypt.DefaultCost)
if err != nil {
return err
}
admin := model.User{
Username: "admin",
Password: string(hashedPassword),
Email: "admin@example.com",
Role: "admin",
}
if err := db.Create(&admin).Error; err != nil {
return err
}
log.Println("已创建默认管理员账号admin/admin")
}
return nil
}
func main() {
// 加载配置
config, err := utils.LoadConfig()
if err != nil {
log.Fatalf("无法加载配置: %v", err)
}
// 初始化数据库
db, err := utils.InitDB(&config.Database)
if err != nil {
log.Fatalf("无法初始化数据库: %v", err)
}
// 初始化管理员账号
if err := initAdminUser(db); err != nil {
log.Fatalf("初始化管理员账号失败: %v", err)
}
// 初始化服务
licenseService := service.NewLicenseService(db)
userService := service.NewUserService(db, config)
deviceService := service.NewDeviceService(db, licenseService)
monitorService := service.NewMonitorService(db)
uploadService := service.NewUploadService(db, config)
siteService := service.NewSiteService(config)
tokenService := service.NewTokenService(db)
// 初始化处理器
userHandler := api.NewUserHandler(userService)
deviceHandler := api.NewDeviceHandler(deviceService)
monitorHandler := api.NewMonitorHandler(monitorService)
uploadHandler := api.NewUploadHandler(uploadService)
siteHandler := api.NewSiteHandler(siteService)
tokenHandler := api.NewTokenHandler(tokenService)
licenseHandler := api.NewLicenseHandler(licenseService)
// 设置路由
router := api.SetupRouter(
userHandler,
deviceHandler,
monitorHandler,
config,
uploadHandler,
siteHandler,
tokenHandler,
licenseHandler,
)
// 创建必要的目录
dirs := []string{
"data",
"uploads",
"uploads/site",
"static/uploads",
"static/uploads/site",
"web/static/lib",
}
for _, dir := range dirs {
if err := os.MkdirAll(dir, 0755); err != nil {
log.Fatalf("创建目录失败: %v", err)
}
}
// 启动服务器
addr := fmt.Sprintf(":%s", config.Server.Port)
log.Printf("服务器启动在 %s", addr)
if err := router.Run(addr); err != nil {
log.Fatalf("服务器启动失败: %v", err)
}
}

29
config/config.yaml Normal file
View File

@ -0,0 +1,29 @@
server:
port: 8081
mode: debug # release/debug
database:
type: sqlite3
path: ./data/license.db
jwt:
secret: your-secret-key
expire: 24h # token过期时间
email:
host: smtp.example.com
port: 587
username: your-email@example.com
password: your-password
upload:
path: ./uploads
site:
title: "授权验证管理平台"
description: "专业的软件授权和设备管理平台"
base_url: "http://localhost:8080"
icp: "京ICP备XXXXXXXX号-1"
copyright: "© 2024 Your Company Name. All rights reserved."
logo: "/static/images/logo.png"
favicon: "/static/images/favicon.ico"

7
create-shortcut.ps1 Normal file
View File

@ -0,0 +1,7 @@
$WshShell = New-Object -comObject WScript.Shell
$Shortcut = $WshShell.CreateShortcut("$env:USERPROFILE\Desktop\启动授权验证平台.lnk")
$Shortcut.TargetPath = "powershell.exe"
$Shortcut.Arguments = "-ExecutionPolicy Bypass -NoProfile -File `"$PWD\start.ps1`""
$Shortcut.WorkingDirectory = $PWD
$Shortcut.IconLocation = "$PWD\licserver.exe,0"
$Shortcut.Save()

BIN
data/license.db Normal file

Binary file not shown.

74
examples/client.go Normal file
View File

@ -0,0 +1,74 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
)
const (
ServerURL = "http://localhost:8080"
DeviceUID = "test-device-001"
)
func main() {
// 1. 使用授权码注册设备
if err := registerDevice(); err != nil {
fmt.Printf("设备注册失败: %v\n", err)
return
}
// 2. 验证设备状态
if err := validateDevice(); err != nil {
fmt.Printf("设备验证失败: %v\n", err)
return
}
fmt.Println("设备注册和验证成功")
}
func registerDevice() error {
data := map[string]interface{}{
"uid": DeviceUID,
"device_type": "software",
"device_model": "test-model",
"company": "test-company",
"license_code": "your-license-code", // 替换为实际的授权码
}
jsonData, err := json.Marshal(data)
if err != nil {
return err
}
resp, err := http.Post(ServerURL+"/api/devices/register",
"application/json", bytes.NewBuffer(jsonData))
if err != nil {
return err
}
defer resp.Body.Close()
body, _ := ioutil.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("注册失败: %s", string(body))
}
return nil
}
func validateDevice() error {
resp, err := http.Get(ServerURL + "/api/devices/validate/" + DeviceUID)
if err != nil {
return err
}
defer resp.Body.Close()
body, _ := ioutil.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("验证失败: %s", string(body))
}
return nil
}

74
go.mod Normal file
View File

@ -0,0 +1,74 @@
module licserver
go 1.22.0
toolchain go1.23.0
require (
github.com/gin-gonic/gin v1.9.1
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/google/uuid v1.4.0
github.com/mojocn/base64Captcha v1.3.5
github.com/shirou/gopsutil/v3 v3.24.1
github.com/spf13/viper v1.19.0
github.com/stretchr/testify v1.9.0
golang.org/x/crypto v0.29.0
gorm.io/driver/sqlite v1.5.6
gorm.io/gorm v1.25.12
)
require (
github.com/bytedance/sonic v1.12.4 // indirect
github.com/bytedance/sonic/loader v0.2.1 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/fsnotify/fsnotify v1.8.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.6 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-ole/go-ole v1.2.6 // 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.22.1 // indirect
github.com/goccy/go-json v0.10.3 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/hashicorp/hcl v1.0.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.2.9 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.24 // indirect
github.com/mitchellh/mapstructure v1.5.0 // 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.3 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
github.com/sagikazarmark/locafero v0.6.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/shoenig/go-m1cpu v0.1.6 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.11.0 // indirect
github.com/spf13/cast v1.7.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/tklauser/go-sysconf v0.3.12 // indirect
github.com/tklauser/numcpus v0.6.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
github.com/yusufpapurcu/wmi v1.2.3 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/arch v0.12.0 // indirect
golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f // indirect
golang.org/x/image v0.0.0-20190501045829-6d32002ffd75 // indirect
golang.org/x/net v0.31.0 // indirect
golang.org/x/sys v0.27.0 // indirect
golang.org/x/text v0.20.0 // indirect
google.golang.org/protobuf v1.35.1 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

172
go.sum Normal file
View File

@ -0,0 +1,172 @@
github.com/bytedance/sonic v1.12.4 h1:9Csb3c9ZJhfUWeMtpCDCq6BUoH5ogfDFLUgQ/jG+R0k=
github.com/bytedance/sonic v1.12.4/go.mod h1:B8Gt/XvtZ3Fqj+iSKMypzymZxw/FVwgIGKzMzT9r/rk=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/bytedance/sonic/loader v0.2.1 h1:1GgorWTqf12TA8mma4DDSbaQigE2wOgQo7iCjjJv3+E=
github.com/bytedance/sonic/loader v0.2.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gabriel-vasile/mimetype v1.4.6 h1:3+PzJTKLkvgjeTbts6msPJt4DixhT4YtFNf1gtGe3zc=
github.com/gabriel-vasile/mimetype v1.4.6/go.mod h1:JX1qVKqZd40hUPpAfiNTe0Sne7hdfKSbOqqmkq8GCXc=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-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.22.1 h1:40JcKH+bBNGFczGuoBYgX4I6m/i27HYW8P9FDk5PbgA=
github.com/go-playground/validator/v10 v10.22.1/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4=
github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
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/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.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY=
github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/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.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
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/mojocn/base64Captcha v1.3.5 h1:Qeilr7Ta6eDtG4S+tQuZ5+hO+QHbiGAJdi4PfoagaA0=
github.com/mojocn/base64Captcha v1.3.5/go.mod h1:/tTTXn4WTpX9CfrmipqRytCpJ27Uw3G6I7NcP2WwcmY=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/sagikazarmark/locafero v0.6.0 h1:ON7AQg37yzcRPU69mt7gwhFEBwxI6P9T4Qu3N51bwOk=
github.com/sagikazarmark/locafero v0.6.0/go.mod h1:77OmuIc6VTraTXKXIs/uvUxKGUXjE1GbemJYHqdNjX0=
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
github.com/shirou/gopsutil/v3 v3.24.1 h1:R3t6ondCEvmARp3wxODhXMTLC/klMa87h2PHUw5m7QI=
github.com/shirou/gopsutil/v3 v3.24.1/go.mod h1:UU7a2MSBQa+kW1uuDq8DeEBS8kmrnQwsv2b5O513rwU=
github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU=
github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w=
github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI=
github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg=
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.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
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.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFiw=
github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
golang.org/x/arch v0.12.0 h1:UsYJhbzPYGsT0HbEdmYcqtCv8UNGvnaL561NnIUvaKg=
golang.org/x/arch v0.12.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ=
golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg=
golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f h1:XdNn9LlyWAhLVp6P/i8QYBW+hlyhrhei9uErw2B5GJo=
golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f/go.mod h1:D5SMRVC3C2/4+F/DB1wZsLRnSNimn2Sp/NPsCrsv8ak=
golang.org/x/image v0.0.0-20190501045829-6d32002ffd75 h1:TbGuee8sSq15Iguxu4deQ7+Bqq/d2rsQejGcEtADAMQ=
golang.org/x/image v0.0.0-20190501045829-6d32002ffd75/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/net v0.31.0 h1:68CPQngjLL0r2AlUKiSxtQFKvzRVbnzLwMUn5SzcLHo=
golang.org/x/net v0.31.0/go.mod h1:P4fl1q7dY2hnZFxEk4pPSkDHF+QqjitcnDjUQyMM+pM=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s=
golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA=
google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
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.6 h1:fO/X46qn5NUEEOZtnjJRWRzZMe8nqJiQ9E+0hi+hKQE=
gorm.io/driver/sqlite v1.5.6/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4=
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=

45
internal/api/dashboard.go Normal file
View File

@ -0,0 +1,45 @@
package api
import (
"licserver/internal/model"
"net/http"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type DashboardHandler struct {
db *gorm.DB
}
func NewDashboardHandler(db *gorm.DB) *DashboardHandler {
return &DashboardHandler{db: db}
}
func (h *DashboardHandler) GetStats(c *gin.Context) {
var stats struct {
TotalDevices int64 `json:"total_devices"`
TotalLicenses int64 `json:"total_licenses"`
TodayNew int64 `json:"today_new"`
OnlineDevices int64 `json:"online_devices"`
}
// 获取设备总数
h.db.Model(&model.Device{}).Count(&stats.TotalDevices)
// 获取授权码总数
h.db.Model(&model.LicenseCode{}).Count(&stats.TotalLicenses)
// 获取今日新增设备数
today := time.Now().Format("2006-01-02")
h.db.Model(&model.Device{}).Where("DATE(created_at) = ?", today).Count(&stats.TodayNew)
// 获取在线设备数最近30分钟内有活动的设备
thirtyMinutesAgo := time.Now().Add(-30 * time.Minute)
h.db.Model(&model.Device{}).
Where("last_active_at > ?", thirtyMinutesAgo).
Count(&stats.OnlineDevices)
c.JSON(http.StatusOK, stats)
}

434
internal/api/device.go Normal file
View File

@ -0,0 +1,434 @@
package api
import (
"fmt"
"net/http"
"strconv"
"licserver/internal/model"
"licserver/internal/service"
"github.com/gin-gonic/gin"
)
type DeviceHandler struct {
deviceService *service.DeviceService
}
func NewDeviceHandler(deviceService *service.DeviceService) *DeviceHandler {
return &DeviceHandler{deviceService: deviceService}
}
func (h *DeviceHandler) CreateDevice(c *gin.Context) {
var input service.DeviceCreateInput
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.deviceService.CreateDevice(&input); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "设备创建成功"})
}
func (h *DeviceHandler) GetDevices(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "10"))
params := &service.DeviceQueryParams{
UID: c.Query("uid"),
DeviceType: c.Query("deviceType"),
Company: c.Query("company"),
LicenseType: c.Query("licenseType"),
Status: c.Query("status"),
Page: page,
PageSize: pageSize,
}
devices, total, err := h.deviceService.GetDevices(params)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"code": 0,
"message": "获取设备列表成功",
"count": total,
"data": devices,
})
}
func (h *DeviceHandler) UpdateStartCount(c *gin.Context) {
uid := c.Param("uid")
if err := h.deviceService.UpdateStartCount(uid); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "启动次数更新成功"})
}
func (h *DeviceHandler) UpdateDevice(c *gin.Context) {
uid := c.Param("uid")
var updates map[string]interface{}
if err := c.ShouldBindJSON(&updates); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.deviceService.UpdateDevice(uid, updates); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "设备更新成功"})
}
func (h *DeviceHandler) DeleteDevice(c *gin.Context) {
uid := c.Param("uid")
if err := h.deviceService.DeleteDevice(uid); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "设备删除成功"})
}
func (h *DeviceHandler) RegisterDevice(c *gin.Context) {
var input service.DeviceRegisterInput
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.deviceService.RegisterDevice(&input, c.ClientIP()); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
status := "未激活"
if input.LicenseCode != "" {
status = "已激活"
}
c.JSON(http.StatusOK, gin.H{
"code": 0,
"message": fmt.Sprintf("设备注册成功,当前状态:%s", status),
})
}
func (h *DeviceHandler) ValidateDevice(c *gin.Context) {
uid := c.Param("uid")
if err := h.deviceService.ValidateDevice(uid); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "设备验证通过"})
}
func (h *DeviceHandler) BindLicense(c *gin.Context) {
uid := c.Param("uid")
var input struct {
LicenseCode string `json:"license_code" binding:"required"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.deviceService.BindLicense(uid, input.LicenseCode); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"code": 0,
"message": "授权码绑定成功",
})
}
func (h *DeviceHandler) UnbindLicense(c *gin.Context) {
uid := c.Param("uid")
if err := h.deviceService.UnbindLicense(uid); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"code": 0,
"message": "授权码解绑成功",
})
}
func (h *DeviceHandler) GetLicenseInfo(c *gin.Context) {
deviceUID := c.Param("uid")
device, err := h.deviceService.GetLicenseInfo(deviceUID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, device)
}
func (h *DeviceHandler) CheckLicenseStatus(c *gin.Context) {
deviceUID := c.Param("uid")
status, err := h.deviceService.CheckLicenseStatus(deviceUID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"status": status,
})
}
func (h *DeviceHandler) CheckUpdate(c *gin.Context) {
deviceUID := c.Param("uid")
currentVersion := c.Query("version")
update, err := h.deviceService.CheckUpdate(deviceUID, currentVersion)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, update)
}
func (h *DeviceHandler) CreateDeviceModel(c *gin.Context) {
var model model.DeviceModel
if err := c.ShouldBindJSON(&model); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
model.CreatedBy = c.GetUint("userID")
model.Status = "active"
if err := h.deviceService.CreateDeviceModel(&model); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"code": 0,
"message": "设备型号创建成功",
"data": model,
})
}
func (h *DeviceHandler) GetDeviceModels(c *gin.Context) {
modelName := c.Query("model_name")
deviceType := c.Query("device_type")
company := c.Query("company")
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
models, total, err := h.deviceService.GetDeviceModels(modelName, deviceType, company, page, pageSize)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"code": 0,
"msg": "",
"count": total,
"data": models,
})
}
func (h *DeviceHandler) UpdateDeviceModel(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的ID"})
return
}
var model model.DeviceModel
if err := c.ShouldBindJSON(&model); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.deviceService.UpdateDeviceModel(uint(id), &model); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"code": 0,
"message": "设备型号更新成功",
})
}
func (h *DeviceHandler) DeleteDeviceModel(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的ID"})
return
}
if err := h.deviceService.DeleteDeviceModel(uint(id)); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"code": 0,
"message": "设备型号删除成功",
})
}
func (h *DeviceHandler) BatchDeleteDeviceModels(c *gin.Context) {
var input struct {
IDs []uint `json:"ids" binding:"required"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.deviceService.BatchDeleteDeviceModels(input.IDs); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"code": 0,
"message": "设备型号批量删除成功",
})
}
func (h *DeviceHandler) GetRegisteredDevices(c *gin.Context) {
uid := c.Query("uid")
deviceModel := c.Query("device_model")
status := c.Query("status")
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
devices, total, err := h.deviceService.GetRegisteredDevices(uid, deviceModel, status, page, pageSize)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"code": 0,
"msg": "",
"count": total,
"data": devices,
})
}
func (h *DeviceHandler) GetDeviceLogs(c *gin.Context) {
uid := c.Param("uid")
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
logs, total, err := h.deviceService.GetDeviceLogs(uid, page, pageSize)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"code": 0,
"msg": "",
"count": total,
"data": logs,
})
}
// GetDashboardStats 获取仪表盘统计数据
func (h *DeviceHandler) GetDashboardStats(c *gin.Context) {
stats, err := h.deviceService.GetDashboardStats()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"code": 0,
"data": stats,
})
}

169
internal/api/license.go Normal file
View File

@ -0,0 +1,169 @@
package api
import (
"fmt"
"licserver/internal/service"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
)
type LicenseHandler struct {
licenseService *service.LicenseService
}
func NewLicenseHandler(licenseService *service.LicenseService) *LicenseHandler {
return &LicenseHandler{licenseService: licenseService}
}
// 创建授权码
func (h *LicenseHandler) CreateLicenses(c *gin.Context) {
var input service.LicenseCreateInput
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := c.GetUint("userID")
licenses, err := h.licenseService.CreateLicenses(&input, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"code": 0,
"message": "授权码创建成功",
"data": licenses,
})
}
// 使用授权码
func (h *LicenseHandler) UseLicense(c *gin.Context) {
var input struct {
Code string `json:"code" binding:"required"`
DeviceUID string `json:"device_uid" binding:"required"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
license, err := h.licenseService.UseLicense(input.Code, input.DeviceUID, c.ClientIP())
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"code": 0,
"message": "授权码使用成功",
"data": license,
})
}
// 获取授权码列表
func (h *LicenseHandler) GetLicenses(c *gin.Context) {
status := c.Query("status")
licenseType := c.Query("license_type")
batchNo := c.Query("batch_no")
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
licenses, total, err := h.licenseService.GetLicenses(status, licenseType, batchNo, page, pageSize)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// 确保返回格式符合 layui table 的要求
c.JSON(http.StatusOK, gin.H{
"code": 0,
"msg": "",
"count": total,
"data": licenses,
})
}
// 获取授权码使用日志
func (h *LicenseHandler) GetLicenseLogs(c *gin.Context) {
licenseID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的授权码ID"})
return
}
// 检查是否为导出请求
if c.Query("export") == "1" {
data, err := h.licenseService.ExportLogs(uint(licenseID))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// 设置响应头
filename := fmt.Sprintf("license_logs_%d_%s.csv", licenseID, time.Now().Format("20060102150405"))
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
c.Data(http.StatusOK, "text/csv", data)
return
}
// 常规日志查询
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "10"))
logs, total, err := h.licenseService.GetLicenseLogs(uint(licenseID), page, pageSize)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"code": 0,
"message": "获取授权码使用日志成功",
"count": total,
"data": logs,
})
}
// 添加撤销授权码的处理方法
func (h *LicenseHandler) RevokeLicense(c *gin.Context) {
code := c.Param("code")
userID := c.GetUint("userID")
if err := h.licenseService.RevokeLicense(code, userID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"code": 0,
"message": "授权码撤销成功",
})
}
// 添加批量撤销处理方法
func (h *LicenseHandler) RevokeLicenses(c *gin.Context) {
var input struct {
Codes []string `json:"codes" binding:"required"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := c.GetUint("userID")
if err := h.licenseService.RevokeLicenses(input.Codes, userID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"code": 0,
"message": "授权码批量撤销成功",
})
}

26
internal/api/monitor.go Normal file
View File

@ -0,0 +1,26 @@
package api
import (
"licserver/internal/service"
"net/http"
"github.com/gin-gonic/gin"
)
type MonitorHandler struct {
monitorService *service.MonitorService
}
func NewMonitorHandler(monitorService *service.MonitorService) *MonitorHandler {
return &MonitorHandler{monitorService: monitorService}
}
func (h *MonitorHandler) GetSystemStatus(c *gin.Context) {
status, err := h.monitorService.GetSystemStatus()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, status)
}

121
internal/api/router.go Normal file
View File

@ -0,0 +1,121 @@
package api
import (
"licserver/internal/middleware"
"licserver/internal/utils"
"github.com/gin-gonic/gin"
)
func SetupRouter(
userHandler *UserHandler,
deviceHandler *DeviceHandler,
monitorHandler *MonitorHandler,
config *utils.Config,
uploadHandler *UploadHandler,
siteHandler *SiteHandler,
tokenHandler *TokenHandler,
licenseHandler *LicenseHandler,
) *gin.Engine {
r := gin.Default()
// 添加错误处理中间件
r.Use(middleware.ErrorHandler())
// 静态文件服务
r.Static("/static", "./web/static")
// 首页和登录页面
r.StaticFile("/", "./web/templates/index.html")
r.StaticFile("/login", "./web/templates/login.html")
// Admin页面路由组
admin := r.Group("/admin")
admin.Use(middleware.JWTAuth(&config.JWT))
{
// 使用StaticFile处理包含Layui模板的页面
admin.StaticFile("/dashboard", "./web/templates/admin/dashboard.html")
admin.StaticFile("/devices", "./web/templates/admin/devices.html")
admin.StaticFile("/device-files", "./web/templates/admin/device-files.html")
admin.StaticFile("/device-license", "./web/templates/admin/device-license.html")
admin.StaticFile("/licenses", "./web/templates/admin/licenses.html")
admin.StaticFile("/license-logs", "./web/templates/admin/license-logs.html")
admin.StaticFile("/tokens", "./web/templates/admin/tokens.html")
admin.StaticFile("/token-logs", "./web/templates/admin/token-logs.html")
admin.StaticFile("/monitor", "./web/templates/admin/monitor.html")
admin.StaticFile("/site-settings", "./web/templates/admin/site-settings.html")
admin.StaticFile("/users", "./web/templates/admin/users.html")
admin.StaticFile("/user-edit", "./web/templates/admin/user-edit.html")
admin.StaticFile("/change-password", "./web/templates/admin/change-password.html")
}
// API路由
api := r.Group("/api")
{
// 公开API
api.GET("/captcha", userHandler.GetCaptcha)
api.POST("/captcha/verify", userHandler.VerifyCaptcha)
api.POST("/login", userHandler.Login)
api.POST("/register", userHandler.Register)
api.POST("/reset-password", userHandler.ResetPassword)
api.POST("/reset-password/confirm", userHandler.ResetPasswordWithToken)
api.POST("/captcha/register", userHandler.SendRegisterCaptcha)
api.POST("/captcha/reset-password", userHandler.SendResetPasswordCaptcha)
api.POST("/validate-token", tokenHandler.ValidateToken)
// 需要认证的API
authorized := api.Group("")
authorized.Use(middleware.JWTAuth(&config.JWT))
{
// 设备型号管理
authorized.POST("/devices/models", middleware.AdminRequired(), deviceHandler.CreateDeviceModel)
authorized.GET("/devices/models", deviceHandler.GetDeviceModels)
authorized.PUT("/devices/models/:id", middleware.AdminRequired(), deviceHandler.UpdateDeviceModel)
authorized.DELETE("/devices/models/:id", middleware.AdminRequired(), deviceHandler.DeleteDeviceModel)
authorized.POST("/devices/models/batch", middleware.AdminRequired(), deviceHandler.BatchDeleteDeviceModels)
// 设备管理
authorized.POST("/devices/register", deviceHandler.RegisterDevice)
authorized.GET("/devices/registered", deviceHandler.GetRegisteredDevices)
authorized.POST("/devices/:uid/license", middleware.AdminRequired(), deviceHandler.BindLicense)
authorized.DELETE("/devices/:uid/license", middleware.AdminRequired(), deviceHandler.UnbindLicense)
authorized.GET("/devices/:uid/logs", deviceHandler.GetDeviceLogs)
// 其他API路由...
// 用户管理
authorized.GET("/users", middleware.AdminRequired(), userHandler.GetUsers)
authorized.POST("/users", middleware.AdminRequired(), userHandler.CreateUser)
authorized.GET("/users/:id", middleware.AdminRequired(), userHandler.GetUserInfo)
authorized.PUT("/users/:id", middleware.AdminRequired(), userHandler.UpdateUser)
authorized.DELETE("/users/:id", middleware.AdminRequired(), userHandler.DeleteUser)
authorized.GET("/users/profile", userHandler.GetProfile)
authorized.PUT("/users/profile", userHandler.UpdateProfile)
authorized.POST("/users/change-password", userHandler.ChangePassword)
// 系统监控
authorized.GET("/monitor/status", middleware.AdminRequired(), monitorHandler.GetSystemStatus)
// 站点设置
authorized.GET("/site/settings", middleware.AdminRequired(), siteHandler.GetSettings)
authorized.PUT("/site/settings", middleware.AdminRequired(), siteHandler.UpdateSettings)
// Token管理
authorized.POST("/tokens", middleware.AdminRequired(), tokenHandler.CreateToken)
authorized.GET("/tokens", tokenHandler.GetTokens)
authorized.GET("/tokens/:id/logs", tokenHandler.GetTokenLogs)
authorized.DELETE("/tokens/:token", middleware.AdminRequired(), tokenHandler.RevokeToken)
// 授权码管理
authorized.POST("/licenses", middleware.AdminRequired(), licenseHandler.CreateLicenses)
authorized.GET("/licenses", licenseHandler.GetLicenses)
authorized.GET("/licenses/:id/logs", licenseHandler.GetLicenseLogs)
authorized.POST("/licenses/use", licenseHandler.UseLicense)
// 仪表盘统计
authorized.GET("/dashboard/stats", deviceHandler.GetDashboardStats)
}
}
return r
}

86
internal/api/site.go Normal file
View File

@ -0,0 +1,86 @@
package api
import (
"licserver/internal/service"
"licserver/internal/utils"
"net/http"
"github.com/gin-gonic/gin"
)
type SiteHandler struct {
siteService *service.SiteService
}
func NewSiteHandler(siteService *service.SiteService) *SiteHandler {
return &SiteHandler{siteService: siteService}
}
func (h *SiteHandler) GetSettings(c *gin.Context) {
settings := h.siteService.GetSettings()
c.JSON(http.StatusOK, gin.H{
"code": 0,
"data": settings,
"title": settings.Title,
"description": settings.Description,
"base_url": settings.BaseURL,
"icp": settings.ICP,
"copyright": settings.Copyright,
"logo": settings.Logo,
"favicon": settings.Favicon,
})
}
func (h *SiteHandler) UpdateSettings(c *gin.Context) {
var settings utils.SiteConfig
if err := c.ShouldBindJSON(&settings); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.siteService.ValidateSettings(settings); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.siteService.UpdateSettings(settings); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"code": 0,
"message": "站点设置更新成功",
"data": settings,
})
}
func (h *SiteHandler) BackupSettings(c *gin.Context) {
settings := h.siteService.GetSettings()
c.Header("Content-Disposition", "attachment; filename=site_settings.json")
c.JSON(http.StatusOK, settings)
}
func (h *SiteHandler) RestoreSettings(c *gin.Context) {
var settings utils.SiteConfig
if err := c.ShouldBindJSON(&settings); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.siteService.ValidateSettings(settings); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.siteService.UpdateSettings(settings); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"code": 0,
"message": "站点设置恢复成功",
"data": settings,
})
}

121
internal/api/token.go Normal file
View File

@ -0,0 +1,121 @@
package api
import (
"licserver/internal/service"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
)
type TokenHandler struct {
tokenService *service.TokenService
}
func NewTokenHandler(tokenService *service.TokenService) *TokenHandler {
return &TokenHandler{tokenService: tokenService}
}
func (h *TokenHandler) CreateToken(c *gin.Context) {
var input struct {
DeviceUID string `json:"device_uid" binding:"required"`
TokenType string `json:"token_type" binding:"required,oneof=api device"`
ExpireDays int `json:"expire_days" binding:"required,min=1"`
IPList []string `json:"ip_list"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
expireTime := time.Now().AddDate(0, 0, input.ExpireDays)
userID := c.GetUint("userID")
token, err := h.tokenService.CreateToken(
input.DeviceUID,
input.TokenType,
expireTime,
input.IPList,
userID,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, token)
}
func (h *TokenHandler) ValidateToken(c *gin.Context) {
token := c.GetHeader("X-Access-Token")
if token == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "未提供访问令牌"})
return
}
clientIP := c.ClientIP()
accessToken, err := h.tokenService.ValidateToken(token, clientIP)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, accessToken)
}
func (h *TokenHandler) RevokeToken(c *gin.Context) {
token := c.Param("token")
userID := c.GetUint("userID")
if err := h.tokenService.RevokeToken(token, userID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "令牌已撤销"})
}
func (h *TokenHandler) GetTokens(c *gin.Context) {
deviceUID := c.Query("device_uid")
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
tokens, total, err := h.tokenService.GetTokens(deviceUID, page, pageSize)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"code": 0,
"message": "获取令牌列表成功",
"count": total,
"data": tokens,
})
}
func (h *TokenHandler) GetTokenLogs(c *gin.Context) {
tokenID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的令牌ID"})
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
logs, total, err := h.tokenService.GetTokenLogs(uint(tokenID), page, pageSize)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"code": 0,
"message": "获取令牌使用日志成功",
"count": total,
"data": logs,
})
}

213
internal/api/upload.go Normal file
View File

@ -0,0 +1,213 @@
package api
import (
"fmt"
"licserver/internal/service"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
)
type UploadHandler struct {
uploadService *service.UploadService
}
func NewUploadHandler(uploadService *service.UploadService) *UploadHandler {
return &UploadHandler{uploadService: uploadService}
}
func (h *UploadHandler) UploadFile(c *gin.Context) {
file, err := c.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "未找到上传文件"})
return
}
deviceUID := c.PostForm("device_uid")
description := c.PostForm("description")
userID := c.GetUint("userID")
upload, err := h.uploadService.UploadFile(file, userID, deviceUID, description)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, upload)
}
func (h *UploadHandler) DownloadFile(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的文件ID"})
return
}
file, err := h.uploadService.DownloadFile(uint(id))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "文件不存在"})
return
}
c.FileAttachment(file.FilePath, file.FileName)
}
func (h *UploadHandler) DeleteFile(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的文件ID"})
return
}
userID := c.GetUint("userID")
if err := h.uploadService.DeleteFile(uint(id), userID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "文件删除成功"})
}
func (h *UploadHandler) GetDeviceFiles(c *gin.Context) {
deviceUID := c.Param("uid")
files, err := h.uploadService.GetDeviceFiles(deviceUID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, files)
}
func (h *UploadHandler) UploadChunk(c *gin.Context) {
file, err := c.FormFile("chunk")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "未找到上传文件"})
return
}
fileHash := c.PostForm("fileHash")
if fileHash == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "未提供文件哈希"})
return
}
chunkNumber, err := strconv.Atoi(c.PostForm("chunkNumber"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的分片序号"})
return
}
totalChunks, err := strconv.Atoi(c.PostForm("totalChunks"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的总分片数"})
return
}
totalSize, err := strconv.ParseInt(c.PostForm("totalSize"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的文件大小"})
return
}
filename := c.PostForm("filename")
deviceUID := c.PostForm("deviceUID")
userID := c.GetUint("userID")
err = h.uploadService.UploadChunk(
file,
fileHash,
chunkNumber,
totalChunks,
totalSize,
filename,
userID,
deviceUID,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// 检查是否所有分片都已上传
completed, err := h.uploadService.CheckUploadStatus(fileHash)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "分片上传成功",
"completed": completed,
})
}
func (h *UploadHandler) MergeChunks(c *gin.Context) {
fileHash := c.PostForm("fileHash")
if fileHash == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "未提供文件哈希"})
return
}
upload, err := h.uploadService.MergeChunks(fileHash)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "文件合并成功",
"file": upload,
})
}
func (h *UploadHandler) UploadSiteFile(c *gin.Context) {
file, err := c.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "未找到上传文件"})
return
}
// 检查文件类型
ext := strings.ToLower(filepath.Ext(file.Filename))
allowedExts := map[string]bool{
".jpg": true, ".jpeg": true, ".png": true, ".gif": true,
".ico": true, ".svg": true,
}
if !allowedExts[ext] {
c.JSON(http.StatusBadRequest, gin.H{"error": "不支持的文件类型"})
return
}
// 生成文件名
filename := fmt.Sprintf("site_%s%s", time.Now().Format("20060102150405"), ext)
// 构建目标目录路径
uploadDir := filepath.Join("web", "static", "images")
if err := os.MkdirAll(uploadDir, 0755); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建目录失败"})
return
}
// 构建完整的文件路径
filePath := filepath.Join(uploadDir, filename)
// 保存文件
if err := c.SaveUploadedFile(file, filePath); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存文件失败"})
return
}
// 返回文件URL使用正斜杠作为URL路径分隔符
fileURL := "/" + strings.Join([]string{"static", "images", filename}, "/")
c.JSON(http.StatusOK, gin.H{
"url": fileURL,
"message": "文件上传成功",
})
}

424
internal/api/user.go Normal file
View File

@ -0,0 +1,424 @@
package api
import (
"licserver/internal/service"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
)
type UserHandler struct {
userService *service.UserService
}
func NewUserHandler(userService *service.UserService) *UserHandler {
return &UserHandler{userService: userService}
}
func (h *UserHandler) Login(c *gin.Context) {
var input struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
Captcha string `json:"captcha" binding:"required"`
CaptchaId string `json:"captchaId" binding:"required"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 验证验证码
if !h.userService.GetCaptchaService().VerifyImageCaptcha(input.CaptchaId, input.Captcha) {
c.JSON(http.StatusBadRequest, gin.H{"error": "验证码错误"})
return
}
token, err := h.userService.Login(input.Username, input.Password)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
return
}
// 设置 cookie
// c.SetCookie("token", token, 86400, "/", "", false, true) // 24小时过期httpOnly=true
c.JSON(http.StatusOK, gin.H{"token": token})
}
func (h *UserHandler) Register(c *gin.Context) {
var input struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required,min=6"`
Email string `json:"email" binding:"required,email"`
Captcha string `json:"captcha" binding:"required,len=6"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.userService.Register(input.Username, input.Password, input.Email, input.Captcha); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "注册成功"})
}
func (h *UserHandler) ResetPasswordWithToken(c *gin.Context) {
var input struct {
Token string `json:"token" binding:"required"`
NewPassword string `json:"new_password" binding:"required,min=6"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.userService.ResetPasswordWithToken(input.Token, input.NewPassword); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "密码重置成功"})
}
func (h *UserHandler) ResetPassword(c *gin.Context) {
var input struct {
Email string `json:"email" binding:"required,email"`
Captcha string `json:"captcha" binding:"required,len=6"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.userService.ResetPassword(input.Email, input.Captcha); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "重置密码邮件已发送"})
}
func (h *UserHandler) SendRegisterCaptcha(c *gin.Context) {
var input struct {
Email string `json:"email" binding:"required,email"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.userService.SendRegisterCaptcha(input.Email); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "验证码已发送"})
}
func (h *UserHandler) SendResetPasswordCaptcha(c *gin.Context) {
var input struct {
Email string `json:"email" binding:"required,email"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.userService.SendResetPasswordCaptcha(input.Email); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "验证码已发送"})
}
// 在 UserHandler 中添加以下方法
// 获取图片验证码
func (h *UserHandler) GetCaptcha(c *gin.Context) {
id, b64s, err := h.userService.GetCaptchaService().GenerateImageCaptcha()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "生成验证码失败: " + err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"captchaId": id,
"imageBase64": b64s,
})
}
// 验证图片验证码
func (h *UserHandler) VerifyCaptcha(c *gin.Context) {
var input struct {
CaptchaId string `json:"captcha_id" binding:"required"`
Code string `json:"code" binding:"required"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if !h.userService.GetCaptchaService().VerifyImageCaptcha(input.CaptchaId, input.Code) {
c.JSON(http.StatusBadRequest, gin.H{"error": "验证码错误"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "验证成功"})
}
// 获取用户列表
func (h *UserHandler) GetUsers(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
username := c.Query("username")
role := c.Query("role")
users, total, err := h.userService.GetUsers(username, role, page, pageSize)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"code": 0,
"msg": "获取用户列表成功",
"count": total,
"data": users,
})
}
// 创建用户
func (h *UserHandler) CreateUser(c *gin.Context) {
var input struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
Email string `json:"email" binding:"required,email"`
Role string `json:"role" binding:"required,oneof=admin user"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 检查权限
if c.GetString("role") != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "需要管理员权限"})
return
}
err := h.userService.CreateUser(input.Username, input.Password, input.Email, input.Role)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "用户创建成功"})
}
// 更新用户
func (h *UserHandler) UpdateUser(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的用户ID"})
return
}
var input struct {
Email string `json:"email" binding:"required,email"`
Role string `json:"role" binding:"required,oneof=admin user"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 检查权限
if c.GetString("role") != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "需要管理员权限"})
return
}
err = h.userService.UpdateUser(uint(id), input.Email, input.Role)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "用户更新成功"})
}
// 删除用户
func (h *UserHandler) DeleteUser(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的用户ID"})
return
}
// 检查权限
if c.GetString("role") != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "需要管理员权限"})
return
}
// 不能删除自己
if uint(id) == c.GetUint("userID") {
c.JSON(http.StatusBadRequest, gin.H{"error": "不能删除自己"})
return
}
err = h.userService.DeleteUser(uint(id))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "用户删除成功"})
}
// 获取用户信息
func (h *UserHandler) GetUserInfo(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的用户ID"})
return
}
user, err := h.userService.GetUserByID(uint(id))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
return
}
c.JSON(http.StatusOK, user)
}
// 获取当前用户信息
func (h *UserHandler) GetProfile(c *gin.Context) {
userID := c.GetUint("userID")
user, err := h.userService.GetUserByID(userID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
return
}
c.JSON(http.StatusOK, user)
}
// 修改密码
func (h *UserHandler) ChangePassword(c *gin.Context) {
var input struct {
OldPassword string `json:"old_password" binding:"required"`
NewPassword string `json:"new_password" binding:"required,min=6"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := c.GetUint("userID")
err := h.userService.ChangePassword(userID, input.OldPassword, input.NewPassword)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "密码修改成功"})
}
// 在 UserHandler 结构体中添加 UpdateProfile 方法
func (h *UserHandler) UpdateProfile(c *gin.Context) {
var input struct {
Email string `json:"email" binding:"required,email"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := c.GetUint("userID")
if err := h.userService.UpdateProfile(userID, input.Email); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "个人信息更新成功"})
}

View File

@ -0,0 +1,80 @@
package middleware
import (
"licserver/internal/utils"
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
func JWTAuth(config *utils.JWTConfig) gin.HandlerFunc {
return func(c *gin.Context) {
var token string
// 1. 首先从 cookie 中获取 token
tokenCookie, err := c.Cookie("token")
if err == nil {
token = tokenCookie
}
// 2. 如果 cookie 中没有,则从 header 中获取
if token == "" {
auth := c.GetHeader("Authorization")
if auth != "" {
parts := strings.SplitN(auth, " ", 2)
if len(parts) == 2 && parts[0] == "Bearer" {
token = parts[1]
}
}
}
// 3. 如果 query 参数中有 token也可以使用
if token == "" {
token = c.Query("token")
}
// 如果都没有找到 token
if token == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "未提供认证信息"})
c.Abort()
return
}
// 验证 token
claims, err := utils.ParseToken(token, config)
if err != nil {
// 如果 token 无效,清除 cookie
// c.SetCookie("token", "", -1, "/", "", true, true)
c.JSON(http.StatusUnauthorized, gin.H{"error": "无效的token"})
c.Abort()
return
}
// 将用户信息存储到上下文
c.Set("userID", claims.UserID)
c.Set("username", claims.Username)
c.Set("role", claims.Role)
// 如果是从 header 或 query 参数获取的 token设置到 cookie 中
if tokenCookie == "" {
// 设置 cookie过期时间与 token 一致
// c.SetCookie("token", token, int(claims.ExpiresAt.Unix()-claims.IssuedAt.Unix()), "/", "", false, true)
}
c.Next()
}
}
// AdminRequired 检查用户是否为管理员
func AdminRequired() gin.HandlerFunc {
return func(c *gin.Context) {
role, exists := c.Get("role")
if !exists || role != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "需要管理员权限"})
c.Abort()
return
}
c.Next()
}
}

View File

@ -0,0 +1,62 @@
package middleware
import (
"licserver/internal/utils"
"net/http"
"github.com/gin-gonic/gin"
)
func ErrorHandler() gin.HandlerFunc {
return func(c *gin.Context) {
c.Next()
// 只处理第一个错误
if len(c.Errors) > 0 {
err := c.Errors[0].Err
var statusCode int
var response utils.ErrorResponse
switch err {
case utils.ErrUnauthorized:
statusCode = http.StatusUnauthorized
response = utils.ErrorResponse{
Code: 401,
Message: "未授权的访问",
Detail: err.Error(),
}
case utils.ErrForbidden:
statusCode = http.StatusForbidden
response = utils.ErrorResponse{
Code: 403,
Message: "禁止访问",
Detail: err.Error(),
}
case utils.ErrNotFound:
statusCode = http.StatusNotFound
response = utils.ErrorResponse{
Code: 404,
Message: "资源不存在",
Detail: err.Error(),
}
case utils.ErrInvalidInput:
statusCode = http.StatusBadRequest
response = utils.ErrorResponse{
Code: 400,
Message: "无效的输入",
Detail: err.Error(),
}
default:
statusCode = http.StatusInternalServerError
response = utils.ErrorResponse{
Code: 500,
Message: "服务器内部错误",
Detail: err.Error(),
}
}
c.JSON(statusCode, response)
c.Abort()
}
}
}

16
internal/model/captcha.go Normal file
View File

@ -0,0 +1,16 @@
package model
import (
"time"
"gorm.io/gorm"
)
type Captcha struct {
gorm.Model
Code string `gorm:"size:6"` // 验证码
Type string `gorm:"size:20"` // 验证码类型register/login/reset
Target string `gorm:"size:255"` // 目标(邮箱或手机号)
ExpiresAt time.Time `gorm:"index"` // 过期时间
Used bool `gorm:"default:false"` // 是否已使用
}

20
internal/model/chunk.go Normal file
View File

@ -0,0 +1,20 @@
package model
import (
"gorm.io/gorm"
)
type UploadChunk struct {
gorm.Model
FileHash string `gorm:"size:64;index"` // 完整文件的哈希值
ChunkNumber int `gorm:"index"` // 分片序号
ChunkSize int64 `gorm:""` // 分片大小
ChunkPath string `gorm:"size:255"` // 分片存储路径
TotalChunks int `gorm:""` // 总分片数
TotalSize int64 `gorm:""` // 文件总大小
Filename string `gorm:"size:255"` // 原始文件名
FileType string `gorm:"size:50"` // 文件类型
UploadedBy uint `gorm:"index"` // 上传者ID
DeviceUID string `gorm:"size:255;index"` // 关联的设备UID
Completed bool `gorm:"default:false"` // 是否已完成合并
}

View File

@ -0,0 +1,13 @@
package model
import (
"gorm.io/gorm"
)
type DeviceLog struct {
gorm.Model
DeviceUID string `gorm:"index" json:"device_uid"` // 设备UID
Action string `json:"action"` // 操作类型
Message string `json:"message"` // 详细信息
Status string `json:"status"` // 状态success/failed
}

View File

@ -0,0 +1,19 @@
package model
import (
"gorm.io/gorm"
)
type DeviceModel struct {
gorm.Model
ModelName string `gorm:"uniqueIndex" json:"model_name" form:"model_name"` // 设备型号名称
DeviceType string `gorm:"size:50" json:"device_type" form:"device_type"` // 设备类型
Company string `gorm:"size:255" json:"company" form:"company"` // 所属公司
Remark string `gorm:"size:500" json:"remark" form:"remark"` // 备注说明
DeviceCount int `gorm:"-" json:"device_count"` // 设备数量(非数据库字段)
CreatedBy uint `gorm:"index" json:"created_by"` // 创建者ID
CurrentVersion string `gorm:"size:50" json:"current_version"` // 当前版本
UpdateURL string `gorm:"size:255" json:"update_url"` // 更新地址
UpdateDesc string `gorm:"size:500" json:"update_desc"` // 更新说明
Status string `gorm:"size:20;default:active" json:"status"` // 状态active/disabled
}

33
internal/model/license.go Normal file
View File

@ -0,0 +1,33 @@
package model
import (
"time"
"gorm.io/gorm"
)
type LicenseCode struct {
gorm.Model
Code string `gorm:"uniqueIndex" json:"code"` // 授权码
LicenseType string `gorm:"size:20" json:"license_type"` // 授权类型time/count/permanent
Duration int `json:"duration"` // 授权时长(分钟)仅当类型为time时有效
MaxUses int `json:"max_uses"` // 最大使用次数仅当类型为count时有效
UsedCount int `gorm:"default:0" json:"used_count"` // 已使用次数
Status string `gorm:"size:20" json:"status"` // 状态unused/used/expired/revoked
UsedBy string `gorm:"index" json:"used_by"` // 使用此授权码的设备UID
UsedAt time.Time `json:"used_at"` // 使用时间
CreatedBy uint `gorm:"index" json:"created_by"` // 创建者ID
BatchNo string `gorm:"size:50;index" json:"batch_no"` // 批次号
Remark string `gorm:"size:500" json:"remark"` // 备注
BindCount int `gorm:"default:-1" json:"bind_count"` // 可绑定次数,-1表示无限制0表示不能绑定
}
type LicenseLog struct {
gorm.Model
LicenseID uint `gorm:"index" json:"license_id"` // 关联的授权码ID
DeviceUID string `gorm:"index" json:"device_uid"` // 设备UID
Action string `gorm:"size:20" json:"action"` // 操作类型create/use/verify
IP string `gorm:"size:50" json:"ip"` // 操作IP
Status string `gorm:"size:20" json:"status"` // 状态success/failed
Message string `gorm:"size:500" json:"message"` // 详细信息
}

35
internal/model/models.go Normal file
View File

@ -0,0 +1,35 @@
package model
import (
"time"
"gorm.io/gorm"
)
type Device struct {
gorm.Model
UID string `gorm:"uniqueIndex" json:"uid"`
IPPort string `json:"ip_port"`
ChipID string `json:"chip_id"`
DeviceType string `json:"device_type"`
DeviceModel string `json:"device_model"`
Company string `json:"company"`
RegisterTime time.Time `json:"register_time"`
ExpireTime time.Time `json:"expire_time"`
LicenseType string `json:"license_type"`
StartCount int `json:"start_count"`
Status string `json:"status"`
LicenseCode string `json:"license_code"`
MaxUses int `json:"max_uses"`
Duration int `json:"duration"`
LastActiveAt time.Time `json:"last_active_at"`
}
type User struct {
gorm.Model
Username string `gorm:"uniqueIndex" json:"username"`
Password string `json:"-"`
Email string `gorm:"uniqueIndex" json:"email"`
Role string `json:"role"`
LastLogin time.Time `json:"last_login"`
}

127
internal/model/monitor.go Normal file
View File

@ -0,0 +1,127 @@
package model
import (
"time"
)
type SystemStatus struct {
CPU struct {
Usage float64 `json:"usage"` // CPU使用率
LoadAvg []float64 `json:"load_avg"` // 系统负载
CoreCount int `json:"core_count"` // CPU核心数
ModelName string `json:"model_name"` // CPU型号
MHz float64 `json:"mhz"` // CPU频率
} `json:"cpu"`
Memory struct {
Total uint64 `json:"total"` // 总内存
Used uint64 `json:"used"` // 已用内存
Free uint64 `json:"free"` // 空闲内存
UsageRate float64 `json:"usage_rate"` // 使用率
SwapTotal uint64 `json:"swap_total"` // 交换分区总大小
SwapUsed uint64 `json:"swap_used"` // 交换分区已用
SwapFree uint64 `json:"swap_free"` // 交换分区空闲
SwapUsageRate float64 `json:"swap_usage_rate"` // 交换分区使用率
} `json:"memory"`
Disk struct {
Partitions []DiskPartition `json:"partitions"` // 磁盘分区信息
} `json:"disk"`
Network struct {
Interfaces []NetworkInterface `json:"interfaces"` // 网络接口信息
} `json:"network"`
Process struct {
Total int `json:"total"` // 进程总数
List []ProcessInfo `json:"list"` // 进程列表Top N
} `json:"process"`
Host struct {
Hostname string `json:"hostname"` // 主机名
OS string `json:"os"` // 操作系统
Platform string `json:"platform"` // 平台
PlatformVersion string `json:"platform_version"` // 平台版本
KernelVersion string `json:"kernel_version"` // 内核版本
BootTime time.Time `json:"boot_time"` // 启动时间
} `json:"host"`
System struct {
Uptime time.Duration `json:"uptime"` // 系统运行时间
CurrentTime time.Time `json:"current_time"` // 当前时间
ActiveUsers int `json:"active_users"` // 活跃用户数
TotalDevices int `json:"total_devices"` // 设备总数
} `json:"system"`
}
type DiskPartition struct {
Device string `json:"device"` // 设备名
Mountpoint string `json:"mountpoint"` // 挂载点
Fstype string `json:"fstype"` // 文件系统类型
Total uint64 `json:"total"` // 总空间
Used uint64 `json:"used"` // 已用空间
Free uint64 `json:"free"` // 空闲空间
UsageRate float64 `json:"usage_rate"` // 使用率
}
type NetworkInterface struct {
Name string `json:"name"` // 接口名称
BytesSent uint64 `json:"bytes_sent"` // 发送字节数
BytesRecv uint64 `json:"bytes_recv"` // 接收字节数
PacketsSent uint64 `json:"packets_sent"` // 发送包数
PacketsRecv uint64 `json:"packets_recv"` // 接收包数
Addrs []string `json:"addrs"` // IP地址列表改为字符串数组
}
type ProcessInfo struct {
PID int `json:"pid"` // 进程ID
Name string `json:"name"` // 进程名称
CPU float64 `json:"cpu"` // CPU使用率
Memory float64 `json:"memory"` // 内存使用率
Created int64 `json:"created"` // 创建时间
}

View File

@ -0,0 +1,15 @@
package model
import (
"time"
"gorm.io/gorm"
)
type PasswordResetToken struct {
gorm.Model
UserID uint `gorm:"index"`
Token string `gorm:"uniqueIndex"`
ExpiresAt time.Time
Used bool
}

40
internal/model/token.go Normal file
View File

@ -0,0 +1,40 @@
package model
import (
"time"
"gorm.io/gorm"
)
type AccessToken struct {
gorm.Model
Token string `gorm:"uniqueIndex" json:"token"` // 访问令牌
DeviceUID string `gorm:"index" json:"device_uid"` // 关联的设备UID
Type string `gorm:"size:20" json:"type"` // 令牌类型api/device
Status string `gorm:"size:20" json:"status"` // 状态active/revoked
ExpireTime time.Time `json:"expire_time"` // 过期时间
LastUsed time.Time `json:"last_used"` // 最后使用时间
UsageCount int `gorm:"default:0" json:"usage_count"` // 使用次数
IPList string `gorm:"type:text" json:"ip_list"` // 允许的IP列表逗号分隔
CreatedBy uint `gorm:"index" json:"created_by"` // 创建者ID
}
// TableName 指定表名
func (AccessToken) TableName() string {
return "access_tokens"
}
type TokenLog struct {
gorm.Model
TokenID uint `gorm:"index" json:"token_id"` // 关联的令牌ID
Action string `gorm:"size:20" json:"action"` // 操作类型create/use/revoke
IP string `gorm:"size:50" json:"ip"` // 操作IP
UserAgent string `gorm:"size:255" json:"user_agent"` // User-Agent
Status string `gorm:"size:20" json:"status"` // 状态success/failed
Message string `gorm:"size:500" json:"message"` // 详细信息
}
// TableName 指定表名
func (TokenLog) TableName() string {
return "token_logs"
}

25
internal/model/upload.go Normal file
View File

@ -0,0 +1,25 @@
package model
import (
"time"
"gorm.io/gorm"
)
type FileUpload struct {
gorm.Model
FileName string `gorm:"size:255" json:"file_name"` // 文件名
FilePath string `gorm:"size:255" json:"file_path"` // 文件路径
FileSize int64 `json:"file_size"` // 文件大小
FileType string `gorm:"size:50" json:"file_type"` // 文件类型
UploadedBy uint `gorm:"index" json:"uploaded_by"` // 上传者ID
DeviceModel string `gorm:"size:255;index" json:"device_model"` // 设备型号
Version string `gorm:"size:50" json:"version"` // 文件版本
Description string `gorm:"size:500" json:"description"` // 文件描述
IsUpdate bool `gorm:"default:false" json:"is_update"` // 是否为更新文件
Downloads int `gorm:"default:0" json:"downloads"` // 下载次数
LastDownload time.Time `json:"last_download"` // 最后下载时间
MD5 string `gorm:"size:32" json:"md5"` // 文件MD5值
ForceUpdate bool `gorm:"default:false" json:"force_update"` // 是否强制更新
DeviceUID string `gorm:"size:255;index" json:"device_uid"` // 关联的设备UID
}

100
internal/service/captcha.go Normal file
View File

@ -0,0 +1,100 @@
package service
import (
"errors"
"licserver/internal/model"
"licserver/internal/utils"
"strings"
"time"
"github.com/mojocn/base64Captcha"
"gorm.io/gorm"
)
type CaptchaService struct {
db *gorm.DB
emailConfig *utils.EmailConfig
store base64Captcha.Store
}
func NewCaptchaService(db *gorm.DB, emailConfig *utils.EmailConfig) *CaptchaService {
return &CaptchaService{
db: db,
emailConfig: emailConfig,
store: base64Captcha.DefaultMemStore,
}
}
func (s *CaptchaService) SendEmailCaptcha(email, captchaType string) error {
// 检查是否存在未过期的验证码
var count int64
s.db.Model(&model.Captcha{}).
Where("target = ? AND type = ? AND expires_at > ? AND used = ?",
email, captchaType, time.Now(), false).
Count(&count)
if count > 0 {
return errors.New("请勿频繁发送验证码")
}
// 生成验证码
code, err := utils.GenerateCaptcha()
if err != nil {
return err
}
// 保存验证码
captcha := model.Captcha{
Code: code,
Type: captchaType,
Target: email,
ExpiresAt: time.Now().Add(5 * time.Minute),
Used: false,
}
if err := s.db.Create(&captcha).Error; err != nil {
return err
}
// 发送验证码邮件
emailService := utils.NewEmailService(s.emailConfig)
content := utils.GenerateEmailCaptchaContent(code, email, captchaType)
return emailService.SendEmail(email, "验证码", content)
}
func (s *CaptchaService) VerifyCaptcha(target, captchaType, code string) error {
var captcha model.Captcha
err := s.db.Where("target = ? AND type = ? AND code = ? AND used = ? AND expires_at > ?",
target, captchaType, code, false, time.Now()).
First(&captcha).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New("验证码无效或已过期")
}
return err
}
// 标记验证码为已使用
return s.db.Model(&captcha).Update("used", true).Error
}
// 生成图片验证码
func (s *CaptchaService) GenerateImageCaptcha() (string, string, error) {
driver := base64Captcha.NewDriverDigit(80, 240, 6, 0.7, 80)
c := base64Captcha.NewCaptcha(driver, s.store)
id, b64s, err := c.Generate()
if err != nil {
return "", "", err
}
// 确保返回的base64字符串不包含前缀
b64s = strings.TrimPrefix(b64s, "data:image/png;base64,")
return id, b64s, nil
}
// 验证图片验证码
func (s *CaptchaService) VerifyImageCaptcha(id, code string) bool {
return s.store.Verify(id, code, true)
}

721
internal/service/device.go Normal file
View File

@ -0,0 +1,721 @@
package service
import (
"errors"
"fmt"
"licserver/internal/model"
"time"
"gorm.io/gorm"
)
type DeviceService struct {
db *gorm.DB
licenseService *LicenseService
}
func NewDeviceService(db *gorm.DB, licenseService *LicenseService) *DeviceService {
return &DeviceService{
db: db,
licenseService: licenseService,
}
}
type DeviceRegisterInput struct {
UID string `json:"uid" binding:"required"`
DeviceModel string `json:"device_model" binding:"required"`
LicenseCode string `json:"license_code"`
}
func (s *DeviceService) RegisterDevice(input *DeviceRegisterInput, ip string) error {
// 检查设备型号是否存在且处于启用状态
var deviceModel model.DeviceModel
if err := s.db.Where("model_name = ? AND status = ?", input.DeviceModel, "active").First(&deviceModel).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New("设备型号不存在或已禁用")
}
return err
}
// 检查设备是否已注册
var count int64
s.db.Model(&model.Device{}).Where("uid = ?", input.UID).Count(&count)
if count > 0 {
return errors.New("设备已注册")
}
// 创建设备记录
device := &model.Device{
UID: input.UID,
DeviceType: deviceModel.DeviceType,
DeviceModel: input.DeviceModel,
Company: deviceModel.Company,
RegisterTime: time.Now(),
Status: "inactive",
LastActiveAt: time.Now(),
StartCount: 0,
}
// 如果提供了授权码,进行授权绑定
if input.LicenseCode != "" {
license, err := s.licenseService.GetLicenseByCode(input.LicenseCode)
if err != nil {
return err
}
if license.Status != "unused" {
return errors.New("授权码已被使用")
}
device.Status = "active"
device.LicenseCode = license.Code
device.LicenseType = license.LicenseType
device.MaxUses = license.MaxUses
device.Duration = license.Duration
if license.LicenseType == "time" {
device.ExpireTime = time.Now().Add(time.Duration(license.Duration) * time.Minute)
}
}
return s.db.Transaction(func(tx *gorm.DB) error {
// 创建设备记录
if err := tx.Create(device).Error; err != nil {
return err
}
// 如果有授权码,更新授权码状态
if device.LicenseCode != "" {
if err := tx.Model(&model.LicenseCode{}).Where("code = ?", device.LicenseCode).
Updates(map[string]interface{}{
"status": "used",
"used_by": input.UID,
"used_at": time.Now(),
}).Error; err != nil {
return err
}
}
// 记录设备日志
logMsg := "设备注册成功"
if device.LicenseCode != "" {
logMsg += fmt.Sprintf(",使用授权码: %s", device.LicenseCode)
}
log := model.DeviceLog{
DeviceUID: input.UID,
Action: "register",
Message: logMsg,
Status: "success",
}
if err := tx.Create(&log).Error; err != nil {
return err
}
return nil
})
}
func (s *DeviceService) ValidateDevice(uid string) error {
var device model.Device
if err := s.db.Where("uid = ?", uid).First(&device).Error; err != nil {
return errors.New("设备未注册")
}
// 更新最后活跃时间
device.LastActiveAt = time.Now()
// 如果设备已激活,检查授权状态
if device.Status == "active" {
if device.LicenseCode != "" {
if err := s.licenseService.CheckLicenseValidity(device.LicenseCode); err != nil {
device.Status = "expired"
s.db.Save(&device)
return errors.New("设备授权已过期")
}
}
}
return s.db.Save(&device).Error
}
func (s *DeviceService) GetDevices(params *DeviceQueryParams) ([]model.Device, int64, error) {
var devices []model.Device
var total int64
query := s.db.Model(&model.Device{})
if params.UID != "" {
query = query.Where("uid LIKE ?", "%"+params.UID+"%")
}
if params.DeviceType != "" {
query = query.Where("device_type = ?", params.DeviceType)
}
if params.Company != "" {
query = query.Where("company LIKE ?", "%"+params.Company+"%")
}
if params.LicenseType != "" {
query = query.Where("license_type = ?", params.LicenseType)
}
if params.Status != "" {
query = query.Where("status = ?", params.Status)
}
query.Count(&total)
if params.Page > 0 && params.PageSize > 0 {
offset := (params.Page - 1) * params.PageSize
query = query.Offset(offset).Limit(params.PageSize)
}
err := query.Find(&devices).Error
return devices, total, err
}
func (s *DeviceService) UpdateStartCount(uid string) error {
var device model.Device
if err := s.db.Where("uid = ?", uid).First(&device).Error; err != nil {
return err
}
// 更新启动次数和最后活跃时间
device.StartCount++
device.LastActiveAt = time.Now()
// 如果设备已激活,检查授权状态
if device.Status == "active" {
// 检查授权码有效性
if device.LicenseCode != "" {
if err := s.licenseService.CheckLicenseValidity(device.LicenseCode); err != nil {
device.Status = "expired"
}
}
// 检查次数限制
if device.LicenseType == "count" && device.StartCount >= device.MaxUses {
device.Status = "expired"
}
}
// 记录设备日志
log := model.DeviceLog{
DeviceUID: uid,
Action: "start",
Message: fmt.Sprintf("设备启动,当前次数:%d", device.StartCount),
Status: "success",
}
return s.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Save(&device).Error; err != nil {
return err
}
return tx.Create(&log).Error
})
}
func (s *DeviceService) UpdateDevice(uid string, updates map[string]interface{}) error {
return s.db.Model(&model.Device{}).Where("uid = ?", uid).Updates(updates).Error
}
func (s *DeviceService) DeleteDevice(uid string) error {
return s.db.Where("uid = ?", uid).Delete(&model.Device{}).Error
}
func (s *DeviceService) GetLicenseInfo(deviceUID string) (*model.Device, error) {
var device model.Device
if err := s.db.Where("uid = ?", deviceUID).First(&device).Error; err != nil {
return nil, errors.New("设备不存在")
}
return &device, nil
}
func (s *DeviceService) CheckLicenseStatus(deviceUID string) (string, error) {
var device model.Device
if err := s.db.Where("uid = ?", deviceUID).First(&device).Error; err != nil {
return "", errors.New("设备不存在")
}
if device.LicenseCode == "" {
return "未授权", nil
}
if device.Status != "active" {
return device.Status, nil
}
switch device.LicenseType {
case "时间段":
if time.Now().After(device.ExpireTime) {
device.Status = "expired"
s.db.Save(&device)
return "已过期", nil
}
case "启动次数":
if device.StartCount >= device.MaxUses {
device.Status = "expired"
s.db.Save(&device)
return "已达到使用上限", nil
}
}
return "正常", nil
}
type DeviceQueryParams struct {
UID string
DeviceType string
Company string
LicenseType string
Status string
Page int
PageSize int
}
type DeviceCreateInput struct {
UID string `json:"uid" binding:"required"`
DeviceType string `json:"device_type" binding:"required"`
DeviceModel string `json:"device_model" binding:"required"`
Company string `json:"company"`
}
func (s *DeviceService) CreateDevice(input *DeviceCreateInput) error {
// 检查设备UID是否已存在
var count int64
if err := s.db.Model(&model.Device{}).Where("uid = ?", input.UID).Count(&count).Error; err != nil {
return err
}
if count > 0 {
return errors.New("设备UID已存在")
}
// 创建设备记录
device := &model.Device{
UID: input.UID,
DeviceType: input.DeviceType,
DeviceModel: input.DeviceModel,
Company: input.Company,
RegisterTime: time.Now(),
Status: "inactive", // 初始状态为未激活
}
return s.db.Create(device).Error
}
// 添加更新检查方法
func (s *DeviceService) CheckUpdate(deviceUID, currentVersion string) (*model.FileUpload, error) {
// 获取设备信息
var device model.Device
if err := s.db.Where("uid = ?", deviceUID).First(&device).Error; err != nil {
return nil, errors.New("设备不存在")
}
// 检查设备状态
if device.Status != "active" {
return nil, errors.New("设备未激活或已过期")
}
// 查找最新的更新文件
var update model.FileUpload
err := s.db.Where("device_model = ? AND is_update = ?", device.DeviceModel, true).
Order("created_at DESC").
First(&update).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil // 没有可用更新
}
return nil, err
}
// 比较版本
if update.Version <= currentVersion && !update.ForceUpdate {
return nil, nil // 当前版本已是最新
}
return &update, nil
}
// 添加设备型号相关的方法
// CreateDeviceModel 创建设备型号
func (s *DeviceService) CreateDeviceModel(model_ *model.DeviceModel) error {
// 检查型号名称是否已存在
var count int64
s.db.Model(&model.DeviceModel{}).Where("model_name = ?", model_.ModelName).Count(&count)
if count > 0 {
return errors.New("设备型号已存在")
}
return s.db.Create(model_).Error
}
// UpdateDeviceModel 更新设备型号
func (s *DeviceService) UpdateDeviceModel(id uint, model_ *model.DeviceModel) error {
// 检查型号名称是否被其他型号使用
var count int64
s.db.Model(&model.DeviceModel{}).Where("model_name = ? AND id != ?", model_.ModelName, id).Count(&count)
if count > 0 {
return errors.New("设备型号已存在")
}
return s.db.Model(&model.DeviceModel{}).Where("id = ?", id).Updates(model_).Error
}
// DeleteDeviceModel 删除设备型号
func (s *DeviceService) DeleteDeviceModel(id uint) error {
// 检查是否有设备使用此型号
var count int64
s.db.Model(&model.DeviceModel{}).Where("device_model = ?", id).Count(&count)
if count > 0 {
return errors.New("该型号下存在设备,无法删除")
}
return s.db.Delete(&model.DeviceModel{}, id).Error
}
// GetDeviceModels 获取设备型号列表
func (s *DeviceService) GetDeviceModels(modelName, deviceType, company string, page, pageSize int) ([]model.DeviceModel, int64, error) {
var models []model.DeviceModel
var total int64
query := s.db.Model(&model.DeviceModel{})
if modelName != "" {
query = query.Where("model_name LIKE ?", "%"+modelName+"%")
}
if deviceType != "" {
query = query.Where("device_type = ?", deviceType)
}
if company != "" {
query = query.Where("company LIKE ?", "%"+company+"%")
}
// 获取总数
query.Count(&total)
// 分页查询
if page > 0 && pageSize > 0 {
offset := (page - 1) * pageSize
query = query.Offset(offset).Limit(pageSize)
}
// 查询设备型号列表
if err := query.Find(&models).Error; err != nil {
return nil, 0, err
}
// 查询每个型号下的设备数量
for i := range models {
var count int64
s.db.Model(&model.Device{}).Where("device_model = ?", models[i].ModelName).Count(&count)
models[i].DeviceCount = int(count)
}
return models, total, nil
}
// BatchDeleteDeviceModels 批量删除设备型号
func (s *DeviceService) BatchDeleteDeviceModels(ids []uint) error {
// 检查是否有设备使用这些型号
var count int64
s.db.Model(&model.Device{}).Where("device_model IN (?)", ids).Count(&count)
if count > 0 {
return errors.New("选中的型号中存在正在使用的型号,无法删除")
}
return s.db.Delete(&model.DeviceModel{}, ids).Error
}
// GetRegisteredDevices 获取已注册设备列表
func (s *DeviceService) GetRegisteredDevices(uid, deviceModel, status string, page, pageSize int) ([]model.Device, int64, error) {
var devices []model.Device
var total int64
query := s.db.Model(&model.Device{})
if uid != "" {
query = query.Where("uid LIKE ?", "%"+uid+"%")
}
if deviceModel != "" {
query = query.Where("device_model = ?", deviceModel)
}
if status != "" {
query = query.Where("status = ?", status)
}
// 获取总数
query.Count(&total)
// 分页查询
if page > 0 && pageSize > 0 {
offset := (page - 1) * pageSize
query = query.Offset(offset).Limit(pageSize)
}
err := query.Order("created_at DESC").Find(&devices).Error
return devices, total, err
}
// BindLicense 绑定授权码
func (s *DeviceService) BindLicense(uid string, licenseCode string) error {
var device model.Device
if err := s.db.Where("uid = ?", uid).First(&device).Error; err != nil {
return errors.New("设备不存在")
}
// 检查设备当前状态
if device.LicenseCode != "" {
return errors.New("设备已绑定授权码,请先解绑")
}
// 验证授权码
license, err := s.licenseService.GetLicenseByCode(licenseCode)
if err != nil {
return err
}
if license.Status != "unused" {
return errors.New("授权码已被使用")
}
// 根据授权类型处理
switch license.LicenseType {
case "time":
if license.Duration <= 0 {
return errors.New("无效的授权时长")
}
device.ExpireTime = time.Now().Add(time.Duration(license.Duration) * time.Minute)
device.Duration = license.Duration
device.MaxUses = 0
case "count":
if license.MaxUses <= 0 {
return errors.New("无效的使用次数")
}
device.ExpireTime = time.Time{} // 清空过期时间
device.Duration = 0
device.MaxUses = license.MaxUses
device.StartCount = 0 // 重置启动次数
case "permanent":
device.ExpireTime = time.Time{} // 清空过期时间
device.Duration = 0
device.MaxUses = 0
default:
return errors.New("无效的授权类型")
}
// 更新设备基本信息
device.LicenseCode = licenseCode
device.LicenseType = license.LicenseType
device.Status = "active"
device.LastActiveAt = time.Now()
return s.db.Transaction(func(tx *gorm.DB) error {
// 更新设备信息
if err := tx.Save(&device).Error; err != nil {
return err
}
// 更新授权码状态
if err := tx.Model(&model.LicenseCode{}).Where("code = ?", licenseCode).
Updates(map[string]interface{}{
"status": "used",
"used_by": uid,
"used_at": time.Now(),
}).Error; err != nil {
return err
}
// 记录设备日志
log := model.DeviceLog{
DeviceUID: uid,
Action: "bind_license",
Message: fmt.Sprintf("绑定%s授权码: %s", getLicenseTypeText(license.LicenseType), licenseCode),
Status: "success",
}
if err := tx.Create(&log).Error; err != nil {
return err
}
return nil
})
}
// 获取授权类型的中文描述
func getLicenseTypeText(licenseType string) string {
switch licenseType {
case "time":
return "时间"
case "count":
return "次数"
case "permanent":
return "永久"
default:
return "未知"
}
}
// UnbindLicense 解绑授权码
func (s *DeviceService) UnbindLicense(uid string) error {
var device model.Device
if err := s.db.Where("uid = ?", uid).First(&device).Error; err != nil {
return errors.New("设备不存在")
}
if device.LicenseCode == "" {
return errors.New("设备未绑定授权码")
}
oldLicenseCode := device.LicenseCode
return s.db.Transaction(func(tx *gorm.DB) error {
// 更新设备信息
if err := tx.Model(&device).Updates(map[string]interface{}{
"license_code": "",
"license_type": "",
"status": "inactive",
"expire_time": nil,
"max_uses": 0,
"duration": 0,
}).Error; err != nil {
return err
}
// 更新授权码状态
if err := tx.Model(&model.LicenseCode{}).Where("code = ?", oldLicenseCode).
Updates(map[string]interface{}{
"status": "unused",
"used_by": "",
"used_at": nil,
}).Error; err != nil {
return err
}
// 记录设备日志
log := model.DeviceLog{
DeviceUID: uid,
Action: "unbind_license",
Message: fmt.Sprintf("解绑授权码: %s", oldLicenseCode),
Status: "success",
}
if err := tx.Create(&log).Error; err != nil {
return err
}
return nil
})
}
// GetDeviceLogs 获取设备日志
func (s *DeviceService) GetDeviceLogs(uid string, page, pageSize int) ([]model.DeviceLog, int64, error) {
var logs []model.DeviceLog
var total int64
query := s.db.Model(&model.DeviceLog{}).Where("device_uid = ?", uid)
// 获取总数
query.Count(&total)
// 分页查询
if page > 0 && pageSize > 0 {
offset := (page - 1) * pageSize
query = query.Offset(offset).Limit(pageSize)
}
err := query.Order("created_at DESC").Find(&logs).Error
return logs, total, err
}
// DashboardStats 仪表盘统计数据
type DashboardStats struct {
TotalDevices int64 `json:"total_devices"` // 设备总数
TotalLicenses int64 `json:"total_licenses"` // 授权码总数
TodayNew int64 `json:"today_new"` // 今日新增
OnlineDevices int64 `json:"online_devices"` // 在线设备
ActiveDevices int64 `json:"active_devices"` // 激活设备
ExpiredDevices int64 `json:"expired_devices"` // 过期设备
}
// GetDashboardStats 获取仪表盘统计数据
func (s *DeviceService) GetDashboardStats() (*DashboardStats, error) {
var stats DashboardStats
// 获取设备总数
s.db.Model(&model.Device{}).Count(&stats.TotalDevices)
// 获取授权码总数
s.db.Model(&model.LicenseCode{}).Count(&stats.TotalLicenses)
// 获取今日新增设备数
today := time.Now().Format("2006-01-02")
s.db.Model(&model.Device{}).Where("DATE(register_time) = ?", today).Count(&stats.TodayNew)
// 获取在线设备数最近30分钟内有活动的设备
thirtyMinutesAgo := time.Now().Add(-30 * time.Minute)
s.db.Model(&model.Device{}).Where("last_active_at > ?", thirtyMinutesAgo).Count(&stats.OnlineDevices)
// 获取激活设备数
s.db.Model(&model.Device{}).Where("status = ?", "active").Count(&stats.ActiveDevices)
// 获取过期设备数
s.db.Model(&model.Device{}).Where("status = ?", "expired").Count(&stats.ExpiredDevices)
return &stats, nil
}

View File

@ -0,0 +1,298 @@
package service
import (
"licserver/internal/model"
"licserver/internal/utils"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestDeviceService_RegisterDevice(t *testing.T) {
db := utils.TestDB(t)
licenseService := NewLicenseService(db)
deviceService := NewDeviceService(db, licenseService)
// 创建测试设备型号
deviceModel := &model.DeviceModel{
ModelName: "test-model",
DeviceType: "software",
Company: "test-company",
Status: "active",
CreatedBy: 1,
}
err := db.Create(deviceModel).Error
assert.NoError(t, err)
// 创建测试授权码
licenses, err := licenseService.CreateLicenses(&LicenseCreateInput{
LicenseType: "time",
Duration: 30,
Count: 1,
Remark: "test",
}, 1)
assert.NoError(t, err)
assert.Len(t, licenses, 1)
tests := []struct {
name string
input *DeviceRegisterInput
wantErr bool
}{
{
name: "正常注册设备",
input: &DeviceRegisterInput{
UID: "test-device-001",
DeviceModel: "test-model",
LicenseCode: licenses[0].Code,
},
wantErr: false,
},
{
name: "重复注册设备",
input: &DeviceRegisterInput{
UID: "test-device-001",
DeviceModel: "test-model",
LicenseCode: licenses[0].Code,
},
wantErr: true,
},
{
name: "使用无效授权码",
input: &DeviceRegisterInput{
UID: "test-device-002",
DeviceModel: "test-model",
LicenseCode: "invalid-code",
},
wantErr: true,
},
{
name: "使用不存在的设备型号",
input: &DeviceRegisterInput{
UID: "test-device-003",
DeviceModel: "non-existent-model",
LicenseCode: licenses[0].Code,
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := deviceService.RegisterDevice(tt.input, "127.0.0.1")
if tt.wantErr {
assert.Error(t, err)
return
}
assert.NoError(t, err)
// 验证设备是否正确注册
var device model.Device
err = db.Where("uid = ?", tt.input.UID).First(&device).Error
assert.NoError(t, err)
assert.Equal(t, deviceModel.DeviceType, device.DeviceType)
assert.Equal(t, "active", device.Status)
assert.Equal(t, tt.input.LicenseCode, device.LicenseCode)
})
}
}
func TestDeviceService_ValidateDevice(t *testing.T) {
db := utils.TestDB(t)
licenseService := NewLicenseService(db)
deviceService := NewDeviceService(db, licenseService)
// 创建测试设备型号
deviceModel := &model.DeviceModel{
ModelName: "test-model",
DeviceType: "software",
Company: "test-company",
CreatedBy: 1,
}
err := db.Create(deviceModel).Error
assert.NoError(t, err)
// 创建测试授权码
licenses, err := licenseService.CreateLicenses(&LicenseCreateInput{
LicenseType: "time",
Duration: 30,
Count: 1,
Remark: "test",
}, 1)
assert.NoError(t, err)
// 注册测试设备
device := &model.Device{
UID: "test-device-001",
DeviceType: deviceModel.DeviceType,
DeviceModel: deviceModel.ModelName,
Company: deviceModel.Company,
RegisterTime: time.Now(),
Status: "active",
LicenseCode: licenses[0].Code,
LicenseType: "time",
Duration: 30,
ExpireTime: time.Now().Add(30 * 24 * time.Hour),
}
err = db.Create(device).Error
assert.NoError(t, err)
tests := []struct {
name string
uid string
wantErr bool
}{
{
name: "验证正常设备",
uid: "test-device-001",
wantErr: false,
},
{
name: "验证不存在的设备",
uid: "non-existent-device",
wantErr: true,
},
{
name: "验证过期设备",
uid: "expired-device",
wantErr: true,
},
}
// 创建过期设备
expiredDevice := &model.Device{
UID: "expired-device",
DeviceType: deviceModel.DeviceType,
DeviceModel: deviceModel.ModelName,
Company: deviceModel.Company,
RegisterTime: time.Now(),
Status: "expired",
LicenseCode: "expired-license",
LicenseType: "time",
Duration: 30,
ExpireTime: time.Now().Add(-24 * time.Hour),
}
err = db.Create(expiredDevice).Error
assert.NoError(t, err)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := deviceService.ValidateDevice(tt.uid)
if tt.wantErr {
assert.Error(t, err)
return
}
assert.NoError(t, err)
})
}
}
func TestDeviceService_UpdateStartCount(t *testing.T) {
db := utils.TestDB(t)
licenseService := NewLicenseService(db)
deviceService := NewDeviceService(db, licenseService)
// 创建测试设备型号
deviceModel := &model.DeviceModel{
ModelName: "test-model",
DeviceType: "software",
Company: "test-company",
CreatedBy: 1,
}
err := db.Create(deviceModel).Error
assert.NoError(t, err)
// 创建测试设备
device := &model.Device{
UID: "test-device-001",
DeviceType: deviceModel.DeviceType,
DeviceModel: deviceModel.ModelName,
Company: deviceModel.Company,
RegisterTime: time.Now(),
Status: "active",
LicenseType: "count",
MaxUses: 5,
StartCount: 0,
}
err = db.Create(device).Error
assert.NoError(t, err)
// 测试更新启动次数
for i := 1; i <= 5; i++ {
err = deviceService.UpdateStartCount(device.UID)
assert.NoError(t, err)
// 验证启动次数
var updatedDevice model.Device
err = db.First(&updatedDevice, "uid = ?", device.UID).Error
assert.NoError(t, err)
assert.Equal(t, i, updatedDevice.StartCount)
// 检查最后一次是否将状态更新为过期
if i == 5 {
assert.Equal(t, "expired", updatedDevice.Status)
} else {
assert.Equal(t, "active", updatedDevice.Status)
}
}
// 测试超出使用次数
err = deviceService.UpdateStartCount(device.UID)
assert.Error(t, err)
}
func TestDeviceService_CreateDeviceModel(t *testing.T) {
db := utils.TestDB(t)
licenseService := NewLicenseService(db)
deviceService := NewDeviceService(db, licenseService)
tests := []struct {
name string
model *model.DeviceModel
wantErr bool
}{
{
name: "创建有效设备型号",
model: &model.DeviceModel{
ModelName: "test-model",
DeviceType: "software",
Company: "test-company",
Status: "active",
CreatedBy: 1,
},
wantErr: false,
},
{
name: "重复的设备型号",
model: &model.DeviceModel{
ModelName: "test-model",
DeviceType: "software",
Company: "test-company",
Status: "active",
CreatedBy: 1,
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := deviceService.CreateDeviceModel(tt.model)
if tt.wantErr {
assert.Error(t, err)
return
}
assert.NoError(t, err)
// 验证创建的设备型号
var savedModel model.DeviceModel
err = db.Where("model_name = ?", tt.model.ModelName).First(&savedModel).Error
assert.NoError(t, err)
assert.Equal(t, tt.model.DeviceType, savedModel.DeviceType)
assert.Equal(t, tt.model.Company, savedModel.Company)
assert.Equal(t, tt.model.Status, savedModel.Status)
})
}
}

709
internal/service/license.go Normal file
View File

@ -0,0 +1,709 @@
package service
import (
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"strings"
"time"
"licserver/internal/model"
"gorm.io/gorm"
)
type LicenseService struct {
db *gorm.DB
}
func NewLicenseService(db *gorm.DB) *LicenseService {
return &LicenseService{db: db}
}
// 生成授权码
func generateLicenseCode() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
// 创建授权码
type LicenseCreateInput struct {
LicenseType string `json:"license_type" binding:"required"`
Duration int `json:"duration"` // 时间授权的有效期(分钟)
MaxUses int `json:"max_uses"` // 次数授权的使用次数
Count int `json:"count" binding:"required,min=1"` // 生成数量
Remark string `json:"remark"` // 备注
BindCount int `json:"bind_count"` // 可绑定次数,默认为-1无限制
}
func (s *LicenseService) CreateLicenses(input *LicenseCreateInput, createdBy uint) ([]model.LicenseCode, error) {
// 验证参数
input.LicenseType = strings.ToLower(input.LicenseType) // 转为小写
switch input.LicenseType {
case "time":
if input.Duration <= 0 {
return nil, errors.New("时间授权必须指定有效期")
}
case "count":
if input.MaxUses <= 0 {
return nil, errors.New("次数授权必须指定使用次数")
}
case "permanent":
// 永久授权不需要额外参数
default:
return nil, errors.New("无效的授权类型")
}
// 如果未指定绑定次数,设置为默认值-1
if input.BindCount == 0 {
input.BindCount = -1
}
// 生成批次号
batchNo := time.Now().Format("20060102150405")
licenses := make([]model.LicenseCode, 0, input.Count)
for i := 0; i < input.Count; i++ {
code, err := generateLicenseCode()
if err != nil {
return nil, err
}
license := model.LicenseCode{
Code: code,
LicenseType: input.LicenseType,
Duration: input.Duration,
MaxUses: input.MaxUses,
Status: "unused",
CreatedBy: createdBy,
BatchNo: batchNo,
Remark: input.Remark,
BindCount: input.BindCount,
}
licenses = append(licenses, license)
}
// 批量创建授权码
if err := s.db.Create(&licenses).Error; err != nil {
return nil, err
}
return licenses, nil
}
// 验证并使用授权码
func (s *LicenseService) UseLicense(code, deviceUID, ip string) (*model.LicenseCode, error) {
var license model.LicenseCode
if err := s.db.Where("code = ?", code).First(&license).Error; err != nil {
return nil, errors.New("授权码不存在")
}
// 检查授权码状态
if license.Status != "unused" {
return nil, errors.New("授权码已被使用")
}
// 检查绑定次数
if license.BindCount == 0 {
return nil, errors.New("授权码已达到最大绑定次数限制")
}
// 更新授权码状态
updates := map[string]interface{}{
"status": "used",
"used_by": deviceUID,
"used_at": time.Now(),
}
// 如果不是无限制,减少绑定次数
if license.BindCount > 0 {
updates["bind_count"] = license.BindCount - 1
}
if err := s.db.Model(&license).Updates(updates).Error; err != nil {
return nil, err
}
// 记录使用日志
log := model.LicenseLog{
LicenseID: license.ID,
DeviceUID: deviceUID,
Action: "use",
IP: ip,
Status: "success",
Message: fmt.Sprintf("设备 %s 使用授权码", deviceUID),
}
if err := s.db.Create(&log).Error; err != nil {
return nil, err
}
return &license, nil
}
// 获取授权码列表
func (s *LicenseService) GetLicenses(status, licenseType, batchNo string, page, pageSize int) ([]model.LicenseCode, int64, error) {
var licenses []model.LicenseCode
var total int64
query := s.db.Model(&model.LicenseCode{})
if status != "" {
query = query.Where("status = ?", strings.ToLower(status))
}
if licenseType != "" {
query = query.Where("license_type = ?", strings.ToLower(licenseType))
}
if batchNo != "" {
query = query.Where("batch_no = ?", batchNo)
}
// 获取所有符合条件的授权码
var allLicenses []model.LicenseCode
if err := query.Find(&allLicenses).Error; err != nil {
return nil, 0, err
}
// 检查每个授权码的有效性
for i := range allLicenses {
if allLicenses[i].Status == "used" {
if err := s.CheckLicenseValidity(allLicenses[i].Code); err != nil {
// 如果检查失败,更新状态
s.db.Model(&allLicenses[i]).Update("status", "expired")
allLicenses[i].Status = "expired"
}
}
}
total = int64(len(allLicenses))
// 分页
if page > 0 && pageSize > 0 {
start := (page - 1) * pageSize
end := start + pageSize
if start < len(allLicenses) {
if end > len(allLicenses) {
end = len(allLicenses)
}
licenses = allLicenses[start:end]
}
} else {
licenses = allLicenses
}
return licenses, total, nil
}
// 获取授权码使用日志
func (s *LicenseService) GetLicenseLogs(licenseID uint, page, pageSize int) ([]model.LicenseLog, int64, error) {
var logs []model.LicenseLog
var total int64
query := s.db.Model(&model.LicenseLog{}).Where("license_id = ?", licenseID)
query.Count(&total)
if page > 0 && pageSize > 0 {
offset := (page - 1) * pageSize
query = query.Offset(offset).Limit(pageSize)
}
err := query.Order("created_at DESC").Find(&logs).Error
return logs, total, err
}
// ExportLogs 导出授权码日志
func (s *LicenseService) ExportLogs(licenseID uint) ([]byte, error) {
logs, _, err := s.GetLicenseLogs(licenseID, 0, 0) // 获取所有日志
if err != nil {
return nil, err
}
// 创建CSV内容
var content strings.Builder
content.WriteString("操作类,设备UID,IP地址,状态,详细信息,时间\n")
for _, log := range logs {
// 转换操作类型
action := map[string]string{
"create": "创建",
"use": "使用",
"verify": "验证",
}[log.Action]
// 转换状态
status := map[string]string{
"success": "成功",
"failed": "失败",
}[log.Status]
// 写入一行记录
content.WriteString(fmt.Sprintf("%s,%s,%s,%s,%s,%s\n",
action,
log.DeviceUID,
log.IP,
status,
log.Message,
log.CreatedAt.Format("2006-01-02 15:04:05"),
))
}
return []byte(content.String()), nil
}
// 撤销授权码
func (s *LicenseService) RevokeLicense(code string, userID uint) error {
var license model.LicenseCode
if err := s.db.Where("code = ?", code).First(&license).Error; err != nil {
return errors.New("授权码不存在")
}
// 检查权限
if license.CreatedBy != userID {
return errors.New("无权操作此授权码")
}
// 更新状态
if err := s.db.Model(&license).Update("status", "revoked").Error; err != nil {
return err
}
// 记录日志
log := model.LicenseLog{
LicenseID: license.ID,
Action: "revoke",
Status: "success",
Message: "授权码已撤销",
}
s.db.Create(&log)
return nil
}
// 批量撤销授权码
func (s *LicenseService) RevokeLicenses(codes []string, userID uint) error {
return s.db.Transaction(func(tx *gorm.DB) error {
for _, code := range codes {
var license model.LicenseCode
if err := tx.Where("code = ?", code).First(&license).Error; err != nil {
continue
}
// 检查权限
if license.CreatedBy != userID {
continue
}
// 更新状态
if err := tx.Model(&license).Update("status", "revoked").Error; err != nil {
return err
}
// 记录日志
log := model.LicenseLog{
LicenseID: license.ID,
Action: "revoke",
Status: "success",
Message: "授权码已撤销",
}
tx.Create(&log)
}
return nil
})
}
// 验证授权码
func (s *LicenseService) ValidateLicense(code string) (*model.LicenseCode, error) {
var license model.LicenseCode
if err := s.db.Where("code = ?", code).First(&license).Error; err != nil {
return nil, errors.New("无效的授权码")
}
// 检查状态
if license.Status != "unused" {
return nil, errors.New("授权码已被使用或已撤销")
}
return &license, nil
}
// 导出授权码
func (s *LicenseService) ExportLicenses(codes []string) ([]byte, error) {
var licenses []model.LicenseCode
if err := s.db.Where("code IN ?", codes).Find(&licenses).Error; err != nil {
return nil, err
}
// 创建CSV内容
var content strings.Builder
content.WriteString("授权码,授权类型,有效期(天),使用次数,状态,使用设备,使用时间,批次号,备注\n")
for _, license := range licenses {
// 转换授权类型
licenseType := map[string]string{
"time": "时间授权",
"count": "次数授权",
"permanent": "永久授权",
}[license.LicenseType]
// 转换状态
status := map[string]string{
"unused": "未使用",
"used": "已使用",
"revoked": "已撤销",
}[license.Status]
// 写入一行记录
content.WriteString(fmt.Sprintf("%s,%s,%d,%d,%s,%s,%s,%s,%s\n",
license.Code,
licenseType,
license.Duration,
license.MaxUses,
status,
license.UsedBy,
license.UsedAt.Format("2006-01-02 15:04:05"),
license.BatchNo,
license.Remark,
))
}
return []byte(content.String()), nil
}
// 获取授权码统计信息
func (s *LicenseService) GetLicenseStats() (map[string]interface{}, error) {
var stats struct {
Total int64
Unused int64
Used int64
Revoked int64
Today int64
ThisWeek int64
ThisMonth int64
}
// 获取总数
s.db.Model(&model.LicenseCode{}).Count(&stats.Total)
// 获取各状态数量
s.db.Model(&model.LicenseCode{}).Where("status = ?", "unused").Count(&stats.Unused)
s.db.Model(&model.LicenseCode{}).Where("status = ?", "used").Count(&stats.Used)
s.db.Model(&model.LicenseCode{}).Where("status = ?", "revoked").Count(&stats.Revoked)
// 获取今日创建数量
today := time.Now().Format("2006-01-02")
s.db.Model(&model.LicenseCode{}).Where("DATE(created_at) = ?", today).Count(&stats.Today)
// 获取本周创建数量
weekStart := time.Now().AddDate(0, 0, -int(time.Now().Weekday()))
s.db.Model(&model.LicenseCode{}).Where("created_at >= ?", weekStart).Count(&stats.ThisWeek)
// 获取本月创建数量
monthStart := time.Now().Format("2006-01") + "-01"
s.db.Model(&model.LicenseCode{}).Where("created_at >= ?", monthStart).Count(&stats.ThisMonth)
return map[string]interface{}{
"total": stats.Total,
"unused": stats.Unused,
"used": stats.Used,
"revoked": stats.Revoked,
"today": stats.Today,
"this_week": stats.ThisWeek,
"this_month": stats.ThisMonth,
}, nil
}
// 添加检查授权码有效性的方法
func (s *LicenseService) CheckLicenseValidity(code string) error {
var license model.LicenseCode
if err := s.db.Where("code = ?", code).First(&license).Error; err != nil {
return errors.New("授权码不存在")
}
if license.Status != "unused" && license.Status != "used" {
return errors.New("授权码已被撤销或过期")
}
// 检查授权类型特定的限制
switch license.LicenseType {
case "time":
// 计算过期时间
expireTime := license.UsedAt.Add(time.Duration(license.Duration) * time.Minute)
if time.Now().After(expireTime) {
// 更新状态为过期
s.db.Model(&license).Update("status", "expired")
return errors.New("授权码已过期")
}
case "count":
if license.UsedCount >= license.MaxUses {
// 更新状态为过期
s.db.Model(&license).Update("status", "expired")
return errors.New("授权码使用次数已达上限")
}
}
return nil
}
// GetLicenseByCode 通过授权码获取授权信息
func (s *LicenseService) GetLicenseByCode(code string) (*model.LicenseCode, error) {
var license model.LicenseCode
if err := s.db.Where("code = ?", code).First(&license).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("授权码不存在")
}
return nil, err
}
return &license, nil
}

View File

@ -0,0 +1,153 @@
package service
import (
"licserver/internal/utils"
"testing"
"github.com/stretchr/testify/assert"
)
func TestLicenseService_CreateLicenses(t *testing.T) {
db := utils.TestDB(t)
service := NewLicenseService(db)
tests := []struct {
name string
input *LicenseCreateInput
wantErr bool
}{
{
name: "创建时间授权码",
input: &LicenseCreateInput{
LicenseType: "time",
Duration: 30,
Count: 5,
Remark: "test time license",
},
wantErr: false,
},
{
name: "创建次数授权码",
input: &LicenseCreateInput{
LicenseType: "count",
MaxUses: 100,
Count: 3,
Remark: "test count license",
},
wantErr: false,
},
{
name: "创建永久授权码",
input: &LicenseCreateInput{
LicenseType: "permanent",
Count: 1,
Remark: "test permanent license",
},
wantErr: false,
},
{
name: "无效的授权类型",
input: &LicenseCreateInput{
LicenseType: "invalid",
Count: 1,
},
wantErr: true,
},
{
name: "时间授权无有效期",
input: &LicenseCreateInput{
LicenseType: "time",
Duration: 0,
Count: 1,
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
licenses, err := service.CreateLicenses(tt.input, 1)
if tt.wantErr {
assert.Error(t, err)
return
}
assert.NoError(t, err)
assert.Len(t, licenses, tt.input.Count)
for _, license := range licenses {
assert.Equal(t, tt.input.LicenseType, license.LicenseType)
assert.Equal(t, "unused", license.Status)
assert.Equal(t, tt.input.Remark, license.Remark)
if tt.input.LicenseType == "time" {
assert.Equal(t, tt.input.Duration, license.Duration)
} else if tt.input.LicenseType == "count" {
assert.Equal(t, tt.input.MaxUses, license.MaxUses)
}
}
})
}
}
func TestLicenseService_UseLicense(t *testing.T) {
db := utils.TestDB(t)
service := NewLicenseService(db)
// 创建测试授权码
input := &LicenseCreateInput{
LicenseType: "time",
Duration: 30,
Count: 1,
Remark: "test",
}
licenses, err := service.CreateLicenses(input, 1)
assert.NoError(t, err)
assert.Len(t, licenses, 1)
tests := []struct {
name string
code string
deviceUID string
ip string
wantErr bool
}{
{
name: "正常使用授权码",
code: licenses[0].Code,
deviceUID: "test-device-001",
ip: "127.0.0.1",
wantErr: false,
},
{
name: "使用不存在的授权码",
code: "invalid-code",
deviceUID: "test-device-002",
ip: "127.0.0.1",
wantErr: true,
},
{
name: "重复使用授权码",
code: licenses[0].Code,
deviceUID: "test-device-003",
ip: "127.0.0.1",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
license, err := service.UseLicense(tt.code, tt.deviceUID, tt.ip)
if tt.wantErr {
assert.Error(t, err)
return
}
assert.NoError(t, err)
assert.Equal(t, "used", license.Status)
assert.Equal(t, tt.deviceUID, license.UsedBy)
assert.NotZero(t, license.UsedAt)
})
}
}

377
internal/service/monitor.go Normal file
View File

@ -0,0 +1,377 @@
package service
import (
"licserver/internal/model"
"runtime"
"time"
"github.com/shirou/gopsutil/v3/cpu"
"github.com/shirou/gopsutil/v3/disk"
"github.com/shirou/gopsutil/v3/host"
"github.com/shirou/gopsutil/v3/mem"
"github.com/shirou/gopsutil/v3/net"
"github.com/shirou/gopsutil/v3/process"
"gorm.io/gorm"
)
type MonitorService struct {
db *gorm.DB
startTime time.Time
}
func NewMonitorService(db *gorm.DB) *MonitorService {
return &MonitorService{
db: db,
startTime: time.Now(),
}
}
func (s *MonitorService) GetSystemStatus() (*model.SystemStatus, error) {
status := &model.SystemStatus{}
// CPU信息
if err := s.getCPUInfo(status); err != nil {
return nil, err
}
// 内存信息
if err := s.getMemoryInfo(status); err != nil {
return nil, err
}
// 磁盘信息
if err := s.getDiskInfo(status); err != nil {
return nil, err
}
// 网络信息
if err := s.getNetworkInfo(status); err != nil {
return nil, err
}
// 进程信息
if err := s.getProcessInfo(status); err != nil {
return nil, err
}
// 主机信息
if err := s.getHostInfo(status); err != nil {
return nil, err
}
// 系统信息
s.getSystemInfo(status)
return status, nil
}
func (s *MonitorService) getCPUInfo(status *model.SystemStatus) error {
cpuPercent, err := cpu.Percent(time.Second, false)
if err != nil {
return err
}
// Windows 系统不支持 LoadAvg设置为默认值
status.CPU.LoadAvg = []float64{0, 0, 0}
cpuInfo, err := cpu.Info()
if err != nil {
return err
}
status.CPU.Usage = cpuPercent[0]
status.CPU.CoreCount = runtime.NumCPU()
if len(cpuInfo) > 0 {
status.CPU.ModelName = cpuInfo[0].ModelName
status.CPU.MHz = cpuInfo[0].Mhz
}
return nil
}
func (s *MonitorService) getMemoryInfo(status *model.SystemStatus) error {
memInfo, err := mem.VirtualMemory()
if err != nil {
return err
}
swapInfo, err := mem.SwapMemory()
if err != nil {
return err
}
status.Memory.Total = memInfo.Total
status.Memory.Used = memInfo.Used
status.Memory.Free = memInfo.Free
status.Memory.UsageRate = memInfo.UsedPercent
status.Memory.SwapTotal = swapInfo.Total
status.Memory.SwapUsed = swapInfo.Used
status.Memory.SwapFree = swapInfo.Free
status.Memory.SwapUsageRate = swapInfo.UsedPercent
return nil
}
func (s *MonitorService) getDiskInfo(status *model.SystemStatus) error {
partitions, err := disk.Partitions(true)
if err != nil {
return err
}
status.Disk.Partitions = make([]model.DiskPartition, 0)
for _, partition := range partitions {
usage, err := disk.Usage(partition.Mountpoint)
if err != nil {
continue
}
status.Disk.Partitions = append(status.Disk.Partitions, model.DiskPartition{
Device: partition.Device,
Mountpoint: partition.Mountpoint,
Fstype: partition.Fstype,
Total: usage.Total,
Used: usage.Used,
Free: usage.Free,
UsageRate: usage.UsedPercent,
})
}
return nil
}
func (s *MonitorService) getNetworkInfo(status *model.SystemStatus) error {
interfaces, err := net.Interfaces()
if err != nil {
return err
}
ioCounters, err := net.IOCounters(true)
if err != nil {
return err
}
status.Network.Interfaces = make([]model.NetworkInterface, 0)
for _, iface := range interfaces {
var counter net.IOCountersStat
for _, io := range ioCounters {
if io.Name == iface.Name {
counter = io
break
}
}
// 获取接口的地址列表
addrs := make([]string, 0)
for _, addr := range iface.Addrs {
addrs = append(addrs, addr.String())
}
status.Network.Interfaces = append(status.Network.Interfaces, model.NetworkInterface{
Name: iface.Name,
BytesSent: counter.BytesSent,
BytesRecv: counter.BytesRecv,
PacketsSent: counter.PacketsSent,
PacketsRecv: counter.PacketsRecv,
Addrs: addrs,
})
}
return nil
}
func (s *MonitorService) getProcessInfo(status *model.SystemStatus) error {
processes, err := process.Processes()
if err != nil {
return err
}
status.Process.Total = len(processes)
status.Process.List = make([]model.ProcessInfo, 0)
for i := 0; i < 10 && i < len(processes); i++ {
p := processes[i]
name, _ := p.Name()
cpu, _ := p.CPUPercent()
mem, _ := p.MemoryPercent()
created, _ := p.CreateTime()
status.Process.List = append(status.Process.List, model.ProcessInfo{
PID: int(p.Pid),
Name: name,
CPU: cpu,
Memory: float64(mem),
Created: created,
})
}
return nil
}
func (s *MonitorService) getHostInfo(status *model.SystemStatus) error {
info, err := host.Info()
if err != nil {
return err
}
status.Host.Hostname = info.Hostname
status.Host.OS = info.OS
status.Host.Platform = info.Platform
status.Host.PlatformVersion = info.PlatformVersion
status.Host.KernelVersion = info.KernelVersion
status.Host.BootTime = time.Unix(int64(info.BootTime), 0)
return nil
}
func (s *MonitorService) getSystemInfo(status *model.SystemStatus) {
status.System.Uptime = time.Since(s.startTime)
status.System.CurrentTime = time.Now()
var activeUsers int64
s.db.Model(&model.User{}).Count(&activeUsers)
status.System.ActiveUsers = int(activeUsers)
var totalDevices int64
s.db.Model(&model.Device{}).Count(&totalDevices)
status.System.TotalDevices = int(totalDevices)
}

47
internal/service/site.go Normal file
View File

@ -0,0 +1,47 @@
package service
import (
"errors"
"licserver/internal/utils"
"sync"
)
type SiteService struct {
config *utils.Config
mu sync.RWMutex
}
func NewSiteService(config *utils.Config) *SiteService {
return &SiteService{
config: config,
}
}
func (s *SiteService) GetSettings() utils.SiteConfig {
s.mu.RLock()
defer s.mu.RUnlock()
return s.config.Site
}
func (s *SiteService) UpdateSettings(settings utils.SiteConfig) error {
s.mu.Lock()
defer s.mu.Unlock()
// 更新内存中的配置
s.config.Site = settings
// 持久化配置到文件
return utils.SaveConfig(s.config, "system", "更新站点设置")
}
// ValidateSettings 验证站点设置
func (s *SiteService) ValidateSettings(settings utils.SiteConfig) error {
// 这里可以添加更多的验证逻辑
if settings.Title == "" {
return errors.New("站点标题不能为空")
}
if settings.BaseURL == "" {
return errors.New("基础URL不能为空")
}
return nil
}

188
internal/service/token.go Normal file
View File

@ -0,0 +1,188 @@
package service
import (
"crypto/rand"
"encoding/hex"
"errors"
"strings"
"time"
"licserver/internal/model"
"gorm.io/gorm"
)
type TokenService struct {
db *gorm.DB
}
func NewTokenService(db *gorm.DB) *TokenService {
return &TokenService{db: db}
}
// 生成访问令牌
func generateToken() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
// 创建访问令牌
func (s *TokenService) CreateToken(deviceUID, tokenType string, expireTime time.Time, ipList []string, createdBy uint) (*model.AccessToken, error) {
// 验证设备是否存在
var device model.Device
if err := s.db.Where("uid = ?", deviceUID).First(&device).Error; err != nil {
return nil, errors.New("设备不存在")
}
// 生成令牌
token, err := generateToken()
if err != nil {
return nil, err
}
// 创建令牌记录
accessToken := &model.AccessToken{
Token: token,
DeviceUID: deviceUID,
Type: tokenType,
Status: "active",
ExpireTime: expireTime,
IPList: strings.Join(ipList, ","),
CreatedBy: createdBy,
}
if err := s.db.Create(accessToken).Error; err != nil {
return nil, err
}
// 记录日志
tokenLog := &model.TokenLog{
TokenID: accessToken.ID,
Action: "create",
Status: "success",
Message: "创建访问令牌",
}
s.db.Create(tokenLog)
return accessToken, nil
}
// 验证令牌
func (s *TokenService) ValidateToken(token, ip string) (*model.AccessToken, error) {
var accessToken model.AccessToken
if err := s.db.Where("token = ?", token).First(&accessToken).Error; err != nil {
return nil, errors.New("无效的令牌")
}
// 检查令牌状态
if accessToken.Status != "active" {
return nil, errors.New("令牌已被撤销")
}
// 检查过期时间
if time.Now().After(accessToken.ExpireTime) {
return nil, errors.New("令牌已过期")
}
// 检查IP限制
if accessToken.IPList != "" {
allowedIPs := strings.Split(accessToken.IPList, ",")
allowed := false
for _, allowedIP := range allowedIPs {
if allowedIP == ip {
allowed = true
break
}
}
if !allowed {
return nil, errors.New("IP地址不允许访问")
}
}
// 更新使用记录
s.db.Model(&accessToken).Updates(map[string]interface{}{
"last_used": time.Now(),
"usage_count": gorm.Expr("usage_count + 1"),
})
// 记录日志
tokenLog := &model.TokenLog{
TokenID: accessToken.ID,
Action: "use",
IP: ip,
Status: "success",
}
s.db.Create(tokenLog)
return &accessToken, nil
}
// 撤销令牌
func (s *TokenService) RevokeToken(token string, userID uint) error {
var accessToken model.AccessToken
if err := s.db.Where("token = ?", token).First(&accessToken).Error; err != nil {
return errors.New("令牌不存在")
}
// 检查权限
if accessToken.CreatedBy != userID {
return errors.New("无权操作此令牌")
}
// 更新状态
if err := s.db.Model(&accessToken).Update("status", "revoked").Error; err != nil {
return err
}
// 记录日志
tokenLog := &model.TokenLog{
TokenID: accessToken.ID,
Action: "revoke",
Status: "success",
Message: "撤销访问令牌",
}
s.db.Create(tokenLog)
return nil
}
// 获取令牌列表
func (s *TokenService) GetTokens(deviceUID string, page, pageSize int) ([]model.AccessToken, int64, error) {
var tokens []model.AccessToken
var total int64
query := s.db.Model(&model.AccessToken{})
if deviceUID != "" {
query = query.Where("device_uid = ?", deviceUID)
}
query.Count(&total)
if page > 0 && pageSize > 0 {
offset := (page - 1) * pageSize
query = query.Offset(offset).Limit(pageSize)
}
err := query.Order("created_at DESC").Find(&tokens).Error
return tokens, total, err
}
// 获取令牌日志
func (s *TokenService) GetTokenLogs(tokenID uint, page, pageSize int) ([]model.TokenLog, int64, error) {
var logs []model.TokenLog
var total int64
query := s.db.Model(&model.TokenLog{}).Where("token_id = ?", tokenID)
query.Count(&total)
if page > 0 && pageSize > 0 {
offset := (page - 1) * pageSize
query = query.Offset(offset).Limit(pageSize)
}
err := query.Order("created_at DESC").Find(&logs).Error
return logs, total, err
}

463
internal/service/upload.go Normal file
View File

@ -0,0 +1,463 @@
package service
import (
"errors"
"fmt"
"io"
"mime/multipart"
"os"
"path/filepath"
"sort"
"strings"
"time"
"licserver/internal/model"
"licserver/internal/utils"
"github.com/google/uuid"
"gorm.io/gorm"
"crypto/sha256"
"encoding/hex"
)
type UploadService struct {
db *gorm.DB
config *utils.Config
}
func NewUploadService(db *gorm.DB, config *utils.Config) *UploadService {
return &UploadService{
db: db,
config: config,
}
}
func (s *UploadService) UploadFile(file *multipart.FileHeader, userID uint, deviceUID, description string) (*model.FileUpload, error) {
// 生成唯一文件名
ext := filepath.Ext(file.Filename)
uniqueID := uuid.New().String()
fileName := fmt.Sprintf("%s%s", uniqueID, ext)
filePath := filepath.Join(s.config.Upload.Path, fileName)
// 确保上传目录存在
if err := os.MkdirAll(s.config.Upload.Path, 0755); err != nil {
return nil, err
}
// 保存文件
src, err := file.Open()
if err != nil {
return nil, err
}
defer src.Close()
dst, err := os.Create(filePath)
if err != nil {
return nil, err
}
defer dst.Close()
if _, err = io.Copy(dst, src); err != nil {
return nil, err
}
// 如果提供了设备UID获取设备型号
var deviceModel string
if deviceUID != "" {
var device model.Device
if err := s.db.Where("uid = ?", deviceUID).First(&device).Error; err == nil {
deviceModel = device.DeviceModel
}
}
// 创建数据库记录
upload := &model.FileUpload{
FileName: file.Filename,
FilePath: filePath,
FileSize: file.Size,
FileType: strings.ToLower(ext),
UploadedBy: userID,
DeviceUID: deviceUID,
DeviceModel: deviceModel,
Description: description,
}
if err := s.db.Create(upload).Error; err != nil {
os.Remove(filePath)
return nil, err
}
return upload, nil
}
func (s *UploadService) DownloadFile(id uint) (*model.FileUpload, error) {
var file model.FileUpload
if err := s.db.First(&file, id).Error; err != nil {
return nil, err
}
return &file, nil
}
func (s *UploadService) DeleteFile(id uint, userID uint) error {
var file model.FileUpload
if err := s.db.First(&file, id).Error; err != nil {
return err
}
if file.UploadedBy != userID {
return errors.New("无权删除此文件")
}
if err := os.Remove(file.FilePath); err != nil && !os.IsNotExist(err) {
return err
}
return s.db.Delete(&file).Error
}
func (s *UploadService) GetDeviceFiles(deviceUID string) ([]model.FileUpload, error) {
var files []model.FileUpload
err := s.db.Where("device_uid = ?", deviceUID).Find(&files).Error
return files, err
}
func (s *UploadService) UploadChunk(
file *multipart.FileHeader,
fileHash string,
chunkNumber int,
totalChunks int,
totalSize int64,
filename string,
userID uint,
deviceUID string,
) error {
// 创建分片存储目录
chunkDir := filepath.Join(s.config.Upload.Path, "chunks", fileHash)
if err := os.MkdirAll(chunkDir, 0755); err != nil {
return err
}
// 保存分片文件
chunkPath := filepath.Join(chunkDir, fmt.Sprintf("%d", chunkNumber))
src, err := file.Open()
if err != nil {
return err
}
defer src.Close()
dst, err := os.Create(chunkPath)
if err != nil {
return err
}
defer dst.Close()
if _, err = io.Copy(dst, src); err != nil {
return err
}
// 记录分片信息
chunk := model.UploadChunk{
FileHash: fileHash,
ChunkNumber: chunkNumber,
ChunkSize: file.Size,
ChunkPath: chunkPath,
TotalChunks: totalChunks,
TotalSize: totalSize,
Filename: filename,
FileType: strings.ToLower(filepath.Ext(filename)),
UploadedBy: userID,
DeviceUID: deviceUID,
}
return s.db.Create(&chunk).Error
}
func (s *UploadService) CheckUploadStatus(fileHash string) (bool, error) {
var chunks []model.UploadChunk
if err := s.db.Where("file_hash = ?", fileHash).Find(&chunks).Error; err != nil {
return false, err
}
if len(chunks) == 0 {
return false, nil
}
totalChunks := chunks[0].TotalChunks
return len(chunks) == totalChunks, nil
}
func (s *UploadService) MergeChunks(fileHash string) (*model.FileUpload, error) {
var chunks []model.UploadChunk
if err := s.db.Where("file_hash = ?", fileHash).Find(&chunks).Error; err != nil {
return nil, err
}
if len(chunks) == 0 {
return nil, errors.New("未找到文件分片")
}
if len(chunks) != chunks[0].TotalChunks {
return nil, errors.New("文件分片不完整")
}
// 按分片序号排序
sort.Slice(chunks, func(i, j int) bool {
return chunks[i].ChunkNumber < chunks[j].ChunkNumber
})
// 创建最终文件
finalPath := filepath.Join(s.config.Upload.Path, fmt.Sprintf("%s%s", uuid.New().String(), chunks[0].FileType))
finalFile, err := os.Create(finalPath)
if err != nil {
return nil, err
}
defer finalFile.Close()
// 合并分片
hash := sha256.New()
for _, chunk := range chunks {
chunkFile, err := os.Open(chunk.ChunkPath)
if err != nil {
return nil, err
}
if _, err = io.Copy(finalFile, chunkFile); err != nil {
chunkFile.Close()
return nil, err
}
if _, err = io.Copy(hash, chunkFile); err != nil {
chunkFile.Close()
return nil, err
}
chunkFile.Close()
os.Remove(chunk.ChunkPath) // 删除已合并的分片
}
// 验证文件哈希
if hex.EncodeToString(hash.Sum(nil)) != fileHash {
os.Remove(finalPath)
return nil, errors.New("文件哈希验证失败")
}
// 创建文件记录
upload := &model.FileUpload{
FileName: chunks[0].Filename,
FilePath: finalPath,
FileSize: chunks[0].TotalSize,
FileType: chunks[0].FileType,
UploadedBy: chunks[0].UploadedBy,
DeviceUID: chunks[0].DeviceUID,
}
if err := s.db.Create(upload).Error; err != nil {
os.Remove(finalPath)
return nil, err
}
// 清理分片记录
s.db.Where("file_hash = ?", fileHash).Delete(&model.UploadChunk{})
os.RemoveAll(filepath.Dir(chunks[0].ChunkPath))
return upload, nil
}
func (s *UploadService) CleanupExpiredChunks() error {
expireTime := time.Now().Add(-24 * time.Hour)
var expiredChunks []model.UploadChunk
if err := s.db.Where("completed = ? AND created_at < ?", false, expireTime).Find(&expiredChunks).Error; err != nil {
return err
}
for _, chunk := range expiredChunks {
os.Remove(chunk.ChunkPath)
if len(chunk.ChunkPath) > 0 {
chunkDir := filepath.Dir(chunk.ChunkPath)
os.RemoveAll(chunkDir)
}
}
return s.db.Unscoped().Where("completed = ? AND created_at < ?", false, expireTime).Delete(&model.UploadChunk{}).Error
}

361
internal/service/user.go Normal file
View File

@ -0,0 +1,361 @@
package service
import (
"errors"
"fmt"
"time"
"licserver/internal/model"
"licserver/internal/utils"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
type UserService struct {
db *gorm.DB
config *utils.Config
captchaService *CaptchaService
}
type UserProfile struct {
ID uint `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
Role string `json:"role"`
}
func NewUserService(db *gorm.DB, config *utils.Config) *UserService {
return &UserService{
db: db,
config: config,
captchaService: NewCaptchaService(db, &config.Email),
}
}
func (s *UserService) Register(username, password, email, captcha string) error {
// 验证验证码
if err := s.captchaService.VerifyCaptcha(email, "register", captcha); err != nil {
return err
}
// 检查用户名是否已存在
var count int64
s.db.Model(&model.User{}).Where("username = ?", username).Count(&count)
if count > 0 {
return errors.New("用户名已存在")
}
// 检查邮箱是否已存在
s.db.Model(&model.User{}).Where("email = ?", email).Count(&count)
if count > 0 {
return errors.New("邮箱已被注册")
}
// 原有的注册逻辑
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return err
}
user := model.User{
Username: username,
Password: string(hashedPassword),
Email: email,
Role: "user",
}
return s.db.Create(&user).Error
}
func (s *UserService) Login(username, password string) (string, error) {
var user model.User
if err := s.db.Where("username = ?", username).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return "", errors.New("用户不存在")
}
return "", err
}
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); err != nil {
return "", errors.New("密码错误")
}
// 生成 JWT token
token, err := utils.GenerateToken(user.ID, user.Username, user.Role, &s.config.JWT)
if err != nil {
return "", err
}
// 更新最后登录时间
s.db.Model(&user).Update("last_login", gorm.Expr("CURRENT_TIMESTAMP"))
return token, nil
}
func (s *UserService) GetUserByID(id uint) (*UserProfile, error) {
var user model.User
if err := s.db.First(&user, id).Error; err != nil {
return nil, err
}
return &UserProfile{
ID: user.ID,
Username: user.Username,
Email: user.Email,
Role: user.Role,
}, nil
}
func (s *UserService) UpdateProfile(userID uint, email string) error {
// 检查邮箱是否被其他用户使用
var count int64
if err := s.db.Model(&model.User{}).Where("email = ? AND id != ?", email, userID).Count(&count).Error; err != nil {
return err
}
if count > 0 {
return errors.New("邮箱已被其他用户使用")
}
// 更新用户信息
return s.db.Model(&model.User{}).Where("id = ?", userID).Update("email", email).Error
}
func (s *UserService) ChangePassword(userID uint, oldPassword, newPassword string) error {
var user model.User
if err := s.db.First(&user, userID).Error; err != nil {
return err
}
// 验证旧密码
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(oldPassword)); err != nil {
return errors.New("旧密码错误")
}
// 加密新密码
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {
return err
}
return s.db.Model(&user).Update("password", string(hashedPassword)).Error
}
func (s *UserService) ResetPassword(email, captcha string) error {
// 验证验证码
if err := s.captchaService.VerifyCaptcha(email, "reset", captcha); err != nil {
return err
}
// 原有的重置密码逻辑
var user model.User
if err := s.db.Where("email = ?", email).First(&user).Error; err != nil {
return errors.New("邮箱不存在")
}
// 生成重置令牌
token, err := utils.GenerateResetToken()
if err != nil {
return err
}
// 保存重置令牌
resetToken := model.PasswordResetToken{
UserID: user.ID,
Token: token,
ExpiresAt: time.Now().Add(24 * time.Hour),
Used: false,
}
if err := s.db.Create(&resetToken).Error; err != nil {
return err
}
// 发送重置邮件
emailService := utils.NewEmailService(&s.config.Email)
resetLink := fmt.Sprintf("http://localhost:%s/reset-password?token=%s", s.config.Server.Port, token)
emailBody := fmt.Sprintf(`
<h3></h3>
<p>%s</p>
<p></p>
<p><a href="%s"></a></p>
<p>24</p>
<p></p>
`, user.Username, resetLink)
return emailService.SendEmail(user.Email, "密码重置", emailBody)
}
func (s *UserService) ValidateResetToken(token string) (*model.User, error) {
var resetToken model.PasswordResetToken
if err := s.db.Where("token = ? AND used = ? AND expires_at > ?",
token, false, time.Now()).First(&resetToken).Error; err != nil {
return nil, errors.New("无效或已过期的重置令牌")
}
var user model.User
if err := s.db.First(&user, resetToken.UserID).Error; err != nil {
return nil, err
}
return &user, nil
}
func (s *UserService) ResetPasswordWithToken(token, newPassword string) error {
user, err := s.ValidateResetToken(token)
if err != nil {
return err
}
// 更新密码
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {
return err
}
// 使用事务确保原子性
return s.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&user).Update("password", string(hashedPassword)).Error; err != nil {
return err
}
// 标记令牌为已使用
if err := tx.Model(&model.PasswordResetToken{}).
Where("token = ?", token).
Update("used", true).Error; err != nil {
return err
}
return nil
})
}
func (s *UserService) SendRegisterCaptcha(email string) error {
// 检查邮箱是否已被注册
var count int64
s.db.Model(&model.User{}).Where("email = ?", email).Count(&count)
if count > 0 {
return errors.New("邮箱已被注册")
}
return s.captchaService.SendEmailCaptcha(email, "register")
}
func (s *UserService) SendResetPasswordCaptcha(email string) error {
var user model.User
if err := s.db.Where("email = ?", email).First(&user).Error; err != nil {
return errors.New("邮箱不存在")
}
return s.captchaService.SendEmailCaptcha(email, "reset")
}
func (s *UserService) GetCaptchaService() *CaptchaService {
return s.captchaService
}
// GetUsers 获取用户列表
func (s *UserService) GetUsers(username, role string, page, pageSize int) ([]UserProfile, int64, error) {
var users []model.User
var total int64
var profiles []UserProfile
query := s.db.Model(&model.User{})
if username != "" {
query = query.Where("username LIKE ?", "%"+username+"%")
}
if role != "" {
query = query.Where("role = ?", role)
}
// 获取总数
query.Count(&total)
// 分页查询
if page > 0 && pageSize > 0 {
offset := (page - 1) * pageSize
query = query.Offset(offset).Limit(pageSize)
}
if err := query.Find(&users).Error; err != nil {
return nil, 0, err
}
// 转换为 UserProfile
for _, user := range users {
profiles = append(profiles, UserProfile{
ID: user.ID,
Username: user.Username,
Email: user.Email,
Role: user.Role,
})
}
return profiles, total, nil
}
// CreateUser 创建新用户
func (s *UserService) CreateUser(username, password, email, role string) error {
// 检查用户名是否已存在
var count int64
s.db.Model(&model.User{}).Where("username = ?", username).Count(&count)
if count > 0 {
return errors.New("用户名已存在")
}
// 检查邮箱是否已存在
s.db.Model(&model.User{}).Where("email = ?", email).Count(&count)
if count > 0 {
return errors.New("邮箱已被注册")
}
// 加密密码
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return err
}
user := model.User{
Username: username,
Password: string(hashedPassword),
Email: email,
Role: role,
}
return s.db.Create(&user).Error
}
// UpdateUser 更新用户信息
func (s *UserService) UpdateUser(id uint, email, role string) error {
// 检查邮箱是否被其他用户使用
var count int64
s.db.Model(&model.User{}).Where("email = ? AND id != ?", email, id).Count(&count)
if count > 0 {
return errors.New("邮箱已被其他用户使用")
}
return s.db.Model(&model.User{}).Where("id = ?", id).Updates(map[string]interface{}{
"email": email,
"role": role,
}).Error
}
// DeleteUser 删除用户
func (s *UserService) DeleteUser(id uint) error {
// 检查是否为最后一个管理员
var adminCount int64
s.db.Model(&model.User{}).Where("role = ?", "admin").Count(&adminCount)
var user model.User
if err := s.db.First(&user, id).Error; err != nil {
return err
}
if user.Role == "admin" && adminCount <= 1 {
return errors.New("不能删除最后一个管理员")
}
return s.db.Delete(&model.User{}, id).Error
}

View File

@ -0,0 +1,61 @@
package service
import (
"licserver/internal/model"
"licserver/internal/utils"
"testing"
"github.com/stretchr/testify/assert"
)
func TestUserService_Register(t *testing.T) {
db := utils.TestDB(t)
config := utils.TestConfig()
userService := NewUserService(db, config)
// 测试正常注册
err := userService.Register("testuser", "password123", "test@example.com", "123456")
assert.NoError(t, err)
var user model.User
err = db.Where("username = ?", "testuser").First(&user).Error
assert.NoError(t, err)
assert.Equal(t, "testuser", user.Username)
assert.Equal(t, "test@example.com", user.Email)
assert.Equal(t, "user", user.Role)
// 测试重复用户名
err = userService.Register("testuser", "password123", "test2@example.com", "123456")
assert.Error(t, err)
assert.Contains(t, err.Error(), "用户名已存在")
// 测试重复邮箱
err = userService.Register("testuser2", "password123", "test@example.com", "123456")
assert.Error(t, err)
assert.Contains(t, err.Error(), "邮箱已被注册")
}
func TestUserService_Login(t *testing.T) {
db := utils.TestDB(t)
config := utils.TestConfig()
userService := NewUserService(db, config)
// 创建测试用户
err := userService.Register("testuser", "password123", "test@example.com", "123456")
assert.NoError(t, err)
// 测试正确登录
token, err := userService.Login("testuser", "password123")
assert.NoError(t, err)
assert.NotEmpty(t, token)
// 测试错误密码
_, err = userService.Login("testuser", "wrongpassword")
assert.Error(t, err)
assert.Contains(t, err.Error(), "密码错误")
// 测试不存在的用户
_, err = userService.Login("nonexistent", "password123")
assert.Error(t, err)
assert.Contains(t, err.Error(), "用户不存在")
}

31
internal/utils/captcha.go Normal file
View File

@ -0,0 +1,31 @@
package utils
import (
"crypto/rand"
"fmt"
)
// GenerateCaptcha 生成6位数字验证码
func GenerateCaptcha() (string, error) {
// 生成6位随机数字
b := make([]byte, 3)
if _, err := rand.Read(b); err != nil {
return "", err
}
// 将随机字节转换为6位数字
num := int(b[0])<<16 | int(b[1])<<8 | int(b[2])
return fmt.Sprintf("%06d", num%1000000), nil
}
// GenerateEmailCaptchaContent 生成验证码邮件内容
func GenerateEmailCaptchaContent(code, username, action string) string {
return fmt.Sprintf(`
<h3></h3>
<p>%s</p>
<p>%s</p>
<h2 style="color: #1890ff;">%s</h2>
<p>5</p>
<p></p>
`, username, action, code)
}

77
internal/utils/config.go Normal file
View File

@ -0,0 +1,77 @@
package utils
import (
"os"
"github.com/spf13/viper"
)
type Config struct {
Server ServerConfig
Database DatabaseConfig
JWT JWTConfig
Email EmailConfig
Upload UploadConfig
Site SiteConfig
}
type ServerConfig struct {
Port string
Mode string
}
type DatabaseConfig struct {
Type string
Path string
}
type JWTConfig struct {
Secret string
Expire string
}
type EmailConfig struct {
Host string
Port int
Username string
Password string
}
type UploadConfig struct {
Path string
}
type SiteConfig struct {
Title string `mapstructure:"title"`
Description string `mapstructure:"description"`
BaseURL string `mapstructure:"base_url"`
ICP string `mapstructure:"icp"`
Copyright string `mapstructure:"copyright"`
Logo string `mapstructure:"logo"`
Favicon string `mapstructure:"favicon"`
}
func LoadConfig() (*Config, error) {
viper.SetConfigName("config")
viper.SetConfigType("yaml")
viper.AddConfigPath("./config")
// 读取环境变量
viper.AutomaticEnv()
if err := viper.ReadInConfig(); err != nil {
return nil, err
}
config := &Config{}
if err := viper.Unmarshal(config); err != nil {
return nil, err
}
// 环境变量优先
if port := os.Getenv("SERVER_PORT"); port != "" {
config.Server.Port = port
}
return config, nil
}

View File

@ -0,0 +1,232 @@
package utils
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"time"
)
type ConfigVersion struct {
Version int `json:"version"`
UpdatedAt time.Time `json:"updated_at"`
UpdatedBy string `json:"updated_by"`
Comment string `json:"comment"`
}
type ConfigWithVersion struct {
Config *Config `json:"config"`
Version ConfigVersion `json:"version"`
}
var (
configMutex sync.RWMutex
configFile = "config/config.json" // JSON格式更适合动态更新
)
// SaveConfig 保存配置到文件
func SaveConfig(config *Config, updatedBy, comment string) error {
configMutex.Lock()
defer configMutex.Unlock()
// 读取当前版本
currentVersion := 0
if existing, err := LoadPersistedConfig(); err == nil {
currentVersion = existing.Version.Version
}
// 创建新的配置版本
configWithVersion := ConfigWithVersion{
Config: config,
Version: ConfigVersion{
Version: currentVersion + 1,
UpdatedAt: time.Now(),
UpdatedBy: updatedBy,
Comment: comment,
},
}
// 确保配置目录存在
configDir := filepath.Dir(configFile)
if err := os.MkdirAll(configDir, 0755); err != nil {
return err
}
// 备份旧配置
if err := backupConfig(); err != nil {
return err
}
// 将配置转换为JSON
data, err := json.MarshalIndent(configWithVersion, "", " ")
if err != nil {
return err
}
// 写入文件
return os.WriteFile(configFile, data, 0644)
}
// LoadPersistedConfig 加载持久化的配置
func LoadPersistedConfig() (*ConfigWithVersion, error) {
configMutex.RLock()
defer configMutex.RUnlock()
// 检查配置文件是否存在
if _, err := os.Stat(configFile); os.IsNotExist(err) {
// 如果不存在,创建默认配置
config, err := LoadConfig()
if err != nil {
return nil, err
}
return &ConfigWithVersion{
Config: config,
Version: ConfigVersion{
Version: 1,
UpdatedAt: time.Now(),
UpdatedBy: "system",
Comment: "初始配置",
},
}, nil
}
// 读取配置文件
data, err := os.ReadFile(configFile)
if err != nil {
return nil, err
}
// 解析JSON配置
var configWithVersion ConfigWithVersion
if err := json.Unmarshal(data, &configWithVersion); err != nil {
return nil, err
}
return &configWithVersion, nil
}
// MergeConfig 合并配置(环境变量优先)
func MergeConfig(persisted, env *Config) *Config {
if env.Server.Port != "" {
persisted.Server.Port = env.Server.Port
}
if env.Server.Mode != "" {
persisted.Server.Mode = env.Server.Mode
}
// ... 其他配置项的合并 ...
return persisted
}
// backupConfig 备份配置文件
func backupConfig() error {
if _, err := os.Stat(configFile); os.IsNotExist(err) {
return nil
}
// 确保备份目录存在
backupDir := "config/backups"
if err := os.MkdirAll(backupDir, 0755); err != nil {
return err
}
// 读取当前配置
data, err := os.ReadFile(configFile)
if err != nil {
return err
}
// 创建备份文件名
backupFile := filepath.Join(backupDir,
fmt.Sprintf("config_%s.json", time.Now().Format("20060102150405")))
// 写入备份文件
return os.WriteFile(backupFile, data, 0644)
}

View File

@ -0,0 +1,56 @@
package utils
import (
"licserver/internal/model"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func InitDB(config *DatabaseConfig) (*gorm.DB, error) {
db, err := gorm.Open(sqlite.Open(config.Path), &gorm.Config{})
if err != nil {
return nil, err
}
// 自动迁移数据库结构
err = db.AutoMigrate(
&model.User{},
&model.Device{},
&model.DeviceModel{},
&model.PasswordResetToken{},
&model.Captcha{},
&model.FileUpload{},
&model.UploadChunk{},
&model.LicenseCode{},
&model.LicenseLog{},
&model.AccessToken{},
&model.TokenLog{},
)
if err != nil {
return nil, err
}
return db, nil
}

40
internal/utils/email.go Normal file
View File

@ -0,0 +1,40 @@
package utils
import (
"crypto/rand"
"encoding/hex"
"fmt"
"net/smtp"
)
type EmailService struct {
config *EmailConfig
auth smtp.Auth
}
func NewEmailService(config *EmailConfig) *EmailService {
auth := smtp.PlainAuth("", config.Username, config.Password, config.Host)
return &EmailService{
config: config,
auth: auth,
}
}
func (s *EmailService) SendEmail(to, subject, body string) error {
addr := fmt.Sprintf("%s:%d", s.config.Host, s.config.Port)
msg := []byte(fmt.Sprintf("To: %s\r\n"+
"Subject: %s\r\n"+
"Content-Type: text/html; charset=UTF-8\r\n"+
"\r\n"+
"%s\r\n", to, subject, body))
return smtp.SendMail(addr, s.auth, s.config.Username, []string{to}, msg)
}
func GenerateResetToken() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}

29
internal/utils/errors.go Normal file
View File

@ -0,0 +1,29 @@
package utils
import "errors"
var (
// 通用错误
ErrInvalidInput = errors.New("无效的输入")
ErrNotFound = errors.New("资源不存在")
ErrUnauthorized = errors.New("未授权的访问")
ErrForbidden = errors.New("禁止访问")
// 授权相关错误
ErrInvalidToken = errors.New("无效的令牌")
ErrTokenExpired = errors.New("令牌已过期")
ErrInvalidCaptcha = errors.New("无效的验证码")
ErrCaptchaExpired = errors.New("验证码已过期")
ErrInvalidLicense = errors.New("无效的授权码")
ErrLicenseExpired = errors.New("授权码已过期")
ErrLicenseUsed = errors.New("授权码已被使用")
ErrDeviceNotFound = errors.New("设备不存在")
ErrDeviceRegistered = errors.New("设备已注册")
)
// ErrorResponse 统一错误响应结构
type ErrorResponse struct {
Code int `json:"code"`
Message string `json:"message"`
Detail string `json:"detail,omitempty"`
}

51
internal/utils/jwt.go Normal file
View File

@ -0,0 +1,51 @@
package utils
import (
"errors"
"time"
"github.com/golang-jwt/jwt/v5"
)
type Claims struct {
UserID uint
Username string
Role string
jwt.RegisteredClaims
}
func GenerateToken(userID uint, username, role string, config *JWTConfig) (string, error) {
expDuration, err := time.ParseDuration(config.Expire)
if err != nil {
return "", err
}
claims := Claims{
UserID: userID,
Username: username,
Role: role,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(expDuration)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(config.Secret))
}
func ParseToken(tokenString string, config *JWTConfig) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
return []byte(config.Secret), nil
})
if err != nil {
return nil, err
}
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
return claims, nil
}
return nil, errors.New("invalid token")
}

50
internal/utils/logger.go Normal file
View File

@ -0,0 +1,50 @@
package utils
import (
"fmt"
"os"
"path/filepath"
"time"
)
type Logger struct {
logFile *os.File
}
func NewLogger(logPath string) (*Logger, error) {
// 确保日志目录存在
if err := os.MkdirAll(filepath.Dir(logPath), 0755); err != nil {
return nil, err
}
// 打开日志文件
file, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
return nil, err
}
return &Logger{logFile: file}, nil
}
func (l *Logger) Info(format string, args ...interface{}) {
l.log("INFO", format, args...)
}
func (l *Logger) Error(format string, args ...interface{}) {
l.log("ERROR", format, args...)
}
func (l *Logger) Debug(format string, args ...interface{}) {
l.log("DEBUG", format, args...)
}
func (l *Logger) log(level, format string, args ...interface{}) {
timestamp := time.Now().Format("2006-01-02 15:04:05")
message := fmt.Sprintf(format, args...)
logLine := fmt.Sprintf("[%s] [%s] %s\n", timestamp, level, message)
l.logFile.WriteString(logLine)
}
func (l *Logger) Close() error {
return l.logFile.Close()
}

View File

@ -0,0 +1,68 @@
package utils
import (
"licserver/internal/model"
"testing"
"github.com/stretchr/testify/assert"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
// TestDB 创建测试数据库连接
func TestDB(t *testing.T) *gorm.DB {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
assert.NoError(t, err)
// 迁移测试表
err = db.AutoMigrate(
&model.User{},
&model.Device{},
&model.DeviceModel{},
&model.LicenseCode{},
&model.LicenseLog{},
&model.AccessToken{},
&model.TokenLog{},
&model.Captcha{},
&model.PasswordResetToken{},
&model.FileUpload{},
&model.UploadChunk{},
)
assert.NoError(t, err)
return db
}
// TestConfig 创建测试配置
func TestConfig() *Config {
return &Config{
Server: ServerConfig{
Port: "8080",
Mode: "test",
},
Database: DatabaseConfig{
Type: "sqlite3",
Path: ":memory:",
},
JWT: JWTConfig{
Secret: "test-secret",
Expire: "24h",
},
Email: EmailConfig{
Host: "smtp.example.com",
Port: 587,
Username: "test@example.com",
Password: "test-password",
},
Upload: UploadConfig{
Path: "./test-uploads",
},
Site: SiteConfig{
Title: "Test Site",
Description: "Test Description",
BaseURL: "http://localhost:8080",
ICP: "Test ICP",
Copyright: "Test Copyright",
},
}
}

BIN
licserver.exe Normal file

Binary file not shown.

14
scripts/test.sh Normal file
View File

@ -0,0 +1,14 @@
#!/bin/bash
# 运行所有测试
go test -v ./...
# 运行指定包的测试
# go test -v ./internal/service/...
# 运行指定测试
# go test -v ./internal/service -run TestLicenseService
# 生成测试覆盖率报告
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out -o coverage.html

121
start.ps1 Normal file
View File

@ -0,0 +1,121 @@
# Set console encoding to UTF-8
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
$OutputEncoding = [System.Text.Encoding]::UTF8
[System.Console]::OutputEncoding = [System.Text.Encoding]::UTF8
# Set error action preference
$ErrorActionPreference = "Stop"
# Define color output function
function Write-ColorOutput($ForegroundColor) {
$fc = $host.UI.RawUI.ForegroundColor
$host.UI.RawUI.ForegroundColor = $ForegroundColor
if ($args) {
Write-Output $args
}
$host.UI.RawUI.ForegroundColor = $fc
}
# Create necessary directories
Write-ColorOutput Green "Creating necessary directories..."
$directories = @("config", "data", "uploads")
foreach ($dir in $directories) {
if (-not (Test-Path $dir)) {
New-Item -ItemType Directory -Path $dir | Out-Null
Write-ColorOutput Yellow "Created directory: $dir"
}
}
# Check if config file exists
$configPath = "config/config.yaml"
if (-not (Test-Path $configPath)) {
Write-ColorOutput Green "Creating default configuration file..."
$configContent = @"
server:
port: 8081
mode: debug
database:
type: sqlite3
path: ./data/license.db
jwt:
secret: your-secret-key-change-this
expire: 24h
email:
host: smtp.example.com
port: 587
username: your-email@example.com
password: your-password
upload:
path: ./uploads
site:
title: "授权验证管理平台"
description: "专业的软件授权和设备管理平台"
base_url: "http://localhost:8080"
icp: "京ICP备XXXXXXXX号-1"
copyright: "© 2024 Your Company Name. All rights reserved."
logo: "/static/images/logo.png"
favicon: "/static/images/favicon.ico"
"@
$configContent | Out-File -FilePath $configPath -Encoding UTF8
Write-ColorOutput Yellow "Created default config file: $configPath"
}
# Compile program
Write-ColorOutput Green "Compiling program..."
try {
go build -o licserver.exe cmd/main.go
if ($LASTEXITCODE -ne 0) {
throw "Compilation failed"
}
Write-ColorOutput Yellow "Compilation successful"
}
catch {
Write-ColorOutput Red "Compilation failed: $_"
exit 1
}
# Start server
Write-ColorOutput Green "Starting server..."
try {
# Check if port is in use
$port = 8081
$portInUse = Get-NetTCPConnection -LocalPort $port -ErrorAction SilentlyContinue
if ($portInUse) {
throw "Port $port is already in use"
}
# Start server
.\licserver.exe
# Capture Ctrl+C
$handler = {
Write-ColorOutput Yellow "`nShutting down server..."
Stop-Process -Name "licserver" -Force -ErrorAction SilentlyContinue
}
$null = Register-ObjectEvent -InputObject ([Console]) -EventName CancelKeyPress -Action $handler
}
catch {
Write-ColorOutput Red "Startup failed: $_"
exit 1
}
# 检查并下载 echarts
$echartsPath = "web/static/lib/echarts.min.js"
if (-not (Test-Path $echartsPath)) {
Write-ColorOutput Green "Downloading echarts..."
$echartsUrl = "https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"
try {
Invoke-WebRequest -Uri $echartsUrl -OutFile $echartsPath
Write-ColorOutput Yellow "Downloaded echarts successfully"
}
catch {
Write-ColorOutput Red "Failed to download echarts: $_"
exit 1
}
}

49
start.sh Normal file
View File

@ -0,0 +1,49 @@
#!/bin/bash
# 确保配置目录存在
mkdir -p config
mkdir -p data
mkdir -p uploads
# 检查配置文件是否存在
if [ ! -f config/config.yaml ]; then
echo "创建默认配置文件..."
cat > config/config.yaml << EOF
server:
port: 8080
mode: debug
database:
type: sqlite3
path: ./data/license.db
jwt:
secret: your-secret-key-change-this
expire: 24h
email:
host: smtp.example.com
port: 587
username: your-email@example.com
password: your-password
upload:
path: ./uploads
site:
title: "授权验证管理平台"
description: "专业的软件授权和设备管理平台"
base_url: "http://localhost:8080"
icp: "京ICP备XXXXXXXX号-1"
copyright: "© 2024 Your Company Name. All rights reserved."
logo: "/static/images/logo.png"
favicon: "/static/images/favicon.ico"
EOF
fi
# 编译并运行
echo "编译程序..."
go build -o licserver cmd/main.go
echo "启动服务器..."
./licserver

298
web/static/css/style.css Normal file
View File

@ -0,0 +1,298 @@
/* 全局样式 */
.login-body {
background-color: #f2f2f2;
padding-top: 100px;
}
.login-box {
background-color: #fff;
padding: 30px;
border-radius: 4px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
.login-box h2 {
text-align: center;
margin-bottom: 30px;
}
.forget-pwd {
float: left;
}
.register {
float: right;
}
.captcha-img {
width: 100%;
height: 38px;
cursor: pointer;
}
/* 主界面样式 */
.layui-layout-admin .layui-body {
padding: 15px;
background-color: #f2f2f2;
}
.layui-card {
margin-bottom: 15px;
}
.layui-card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
/* 文件上传相关样式 */
.upload-progress {
margin: 10px 0;
}
.file-list {
margin-top: 20px;
}
.file-item {
display: flex;
align-items: center;
padding: 10px;
border-bottom: 1px solid #eee;
}
.file-info {
flex: 1;
}
.file-name {
font-weight: bold;
}
.file-meta {
color: #999;
font-size: 12px;
}
.file-actions {
margin-left: 20px;
}
.upload-form {
padding: 20px;
}
.selected-file {
margin-left: 10px;
color: #666;
}
.layui-progress {
margin: 10px 0;
}
/* 上传按钮组样式 */
.layui-card-header .layui-btn-group {
float: right;
}
/* 文件列表表格样式 */
.layui-table {
margin-top: 15px;
}
.layui-table th {
font-weight: bold;
background-color: #f2f2f2;
}
/* 上传进度条样式 */
.upload-progress-container {
margin: 10px 0;
padding: 10px;
background-color: #f8f8f8;
border-radius: 4px;
}
.upload-progress-info {
margin-top: 5px;
font-size: 12px;
color: #666;
}
/* 主界面布局样式 */
.layui-layout-admin .layui-logo {
display: flex;
align-items: center;
padding: 0 20px;
}
.layui-layout-admin .layui-logo img {
height: 30px;
margin-right: 10px;
}
.layui-layout-admin .layui-logo span {
color: #fff;
font-size: 18px;
font-weight: 600;
}
.layui-nav .layui-icon {
margin-right: 10px;
}
/* 底部样式 */
.layui-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 20px;
color: #666;
font-size: 12px;
}
.footer-left {
font-size: 12px;
}
.footer-right a {
color: #666;
text-decoration: none;
}
.footer-right a:hover {
color: #009688;
}
.captcha-img {
height: 38px;
cursor: pointer;
}
.layui-form-item .captcha-wrapper {
display: flex;
align-items: center;
}
.layui-form-item .captcha-input {
margin-right: 10px;
}
/* 主页布局样式 */
.layui-layout-admin .layui-logo {
display: flex;
align-items: center;
padding: 0 20px;
color: #fff;
font-size: 18px;
}
.layui-layout-admin .layui-logo img {
height: 30px;
margin-right: 10px;
}
.content-container {
padding: 15px;
height: calc(100% - 50px);
}
.layadmin-iframe {
width: 100%;
height: 100%;
background-color: #fff;
}
.layui-breadcrumb {
padding: 10px 15px;
background-color: #fff;
box-shadow: 0 1px 2px 0 rgba(0,0,0,.05);
}
/* 导航菜单样式 */
.layui-nav .layui-icon {
margin-right: 10px;
font-size: 16px;
}
.layui-nav-item a {
font-size: 14px;
}
.layui-nav-child dd {
padding-left: 20px;
}
/* 底部样式 */
.layui-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 15px;
color: #666;
font-size: 12px;
}
/* 响应式布局 */
@media screen and (max-width: 768px) {
.layui-side {
display: none;
}
.layui-body {
left: 0;
}
.layui-footer {
left: 0;
}
}
/* 统计卡片样式 */
.big-font {
font-size: 24px;
font-weight: bold;
color: #009688;
}

BIN
web/static/images/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

View File

@ -0,0 +1,41 @@
layui.use(['layer'], function(){
var layer = layui.layer;
var $ = layui.$;
// 加载统计数据
function loadStats() {
fetch('/api/dashboard/stats', {
credentials: 'include'
})
.then(response => {
if (response.status === 401) {
window.location.href = '/login';
throw new Error('认证失败');
}
return response.json();
})
.then(result => {
if (result.error) {
layer.msg(result.error);
return;
}
// 更新统计数据
$('#total-devices').text(result.data.total_devices);
$('#total-licenses').text(result.data.total_licenses);
$('#today-new').text(result.data.today_new);
$('#online-devices').text(result.data.online_devices);
$('#active-devices').text(result.data.active_devices);
$('#expired-devices').text(result.data.expired_devices);
})
.catch(error => {
layer.msg('加载统计数据失败:' + error.message);
});
}
// 初始加载
loadStats();
// 定时刷新每30秒
setInterval(loadStats, 30000);
});

View File

@ -0,0 +1,198 @@
layui.use(['upload', 'table', 'layer', 'form'], function(){
var upload = layui.upload;
var table = layui.table;
var layer = layui.layer;
var form = layui.form;
var $ = layui.$;
// 获取设备型号
var deviceModel = decodeURIComponent(location.search.match(/model=([^&]+)/)[1]);
// 初始化表格
table.render({
elem: '#file-table',
url: '/api/uploads/device/' + deviceModel,
headers: {
'Authorization': 'Bearer ' + localStorage.getItem('token')
},
toolbar: '#tableToolbar',
defaultToolbar: ['filter', 'exports', 'print'],
cols: [[
{type: 'checkbox'},
{field: 'file_name', title: '文件名', width: 200},
{field: 'version', title: '版本', width: 100},
{field: 'file_type', title: '类型', width: 100, templet: function(d){
if(d.is_update) return '<span class="layui-badge layui-bg-blue">更新</span>';
return '<span class="layui-badge layui-bg-gray">普通</span>';
}},
{field: 'file_size', title: '大小', width: 120, templet: function(d){
return formatFileSize(d.file_size);
}},
{field: 'downloads', title: '下载次数', width: 100},
{field: 'description', title: '描述'},
{field: 'created_at', title: '上传时间', width: 160, templet: function(d){
return new Date(d.created_at).toLocaleString();
}},
{fixed: 'right', title: '操作', toolbar: '#tableRowBar', width: 180}
]],
page: true,
parseData: function(res) {
if (res.code === 401) {
window.location.href = '/login';
return;
}
return res;
}
});
// 上传普通文件
upload.render({
elem: '#uploadFile',
url: '/api/uploads',
headers: {
'Authorization': 'Bearer ' + localStorage.getItem('token')
},
data: {
device_model: deviceModel,
is_update: false
},
accept: 'file',
before: function(obj){
layer.load();
},
done: function(res){
layer.closeAll('loading');
if(res.error){
layer.msg(res.error);
return;
}
layer.msg('上传成功');
table.reload('file-table');
},
error: function(){
layer.closeAll('loading');
layer.msg('上传失败');
}
});
// 上传更新文件
upload.render({
elem: '#uploadUpdate',
url: '/api/uploads',
headers: {
'Authorization': 'Bearer ' + localStorage.getItem('token')
},
data: {
device_model: deviceModel,
is_update: true
},
accept: 'file',
before: function(obj){
// 弹出版本信息输入框
layer.prompt({
formType: 0,
title: '请输入版本号',
area: ['300px', '150px']
}, function(value, index, elem){
this.field.version = value;
layer.close(index);
layer.load();
});
},
done: function(res){
layer.closeAll('loading');
if(res.error){
layer.msg(res.error);
return;
}
layer.msg('上传成功');
table.reload('file-table');
},
error: function(){
layer.closeAll('loading');
layer.msg('上传失败');
}
});
// 表格工具栏事件
table.on('toolbar(file-table)', function(obj){
var checkStatus = table.checkStatus(obj.config.id);
switch(obj.event){
case 'refresh':
table.reload('file-table');
break;
case 'batchDel':
var data = checkStatus.data;
if(data.length === 0){
layer.msg('请选择要删除的文件');
return;
}
layer.confirm('确定删除选中的文件吗?', function(index){
var ids = data.map(item => item.id);
Promise.all(ids.map(id =>
fetch('/api/uploads/' + id, {
method: 'DELETE',
headers: {
'Authorization': 'Bearer ' + localStorage.getItem('token')
}
}).then(response => response.json())
))
.then(() => {
layer.msg('批量删除成功');
table.reload('file-table');
})
.catch(error => {
layer.msg('批量删除失败:' + error.message);
});
layer.close(index);
});
break;
}
});
// 行工具栏事件
table.on('tool(file-table)', function(obj){
var data = obj.data;
switch(obj.event){
case 'download':
window.location.href = '/api/uploads/' + data.id;
break;
case 'del':
layer.confirm('确定删除该文件吗?', function(index){
fetch('/api/uploads/' + data.id, {
method: 'DELETE',
headers: {
'Authorization': 'Bearer ' + localStorage.getItem('token')
}
})
.then(response => response.json())
.then(result => {
if(result.error){
layer.msg(result.error);
return;
}
layer.msg('删除成功');
obj.del();
})
.catch(error => {
layer.msg('删除失败:' + error.message);
});
layer.close(index);
});
break;
}
});
// 格式化文件大小
function formatFileSize(size) {
var units = ['B', 'KB', 'MB', 'GB', 'TB'];
var index = 0;
while(size >= 1024 && index < units.length - 1) {
size /= 1024;
index++;
}
return size.toFixed(2) + ' ' + units[index];
}
});

View File

@ -0,0 +1,202 @@
layui.use(['table', 'form', 'layer'], function(){
var table = layui.table;
var form = layui.form;
var layer = layui.layer;
var $ = layui.$;
// 加载设备型号列表
fetch('/api/devices/models', {
credentials: 'include'
})
.then(response => response.json())
.then(result => {
if(result.data) {
var options = '<option value="">全部</option>';
result.data.forEach(function(model) {
options += '<option value="' + model.model_name + '">' + model.model_name + '</option>';
});
$('select[name=device_model]').html(options);
form.render('select');
}
});
// 初始化表格
table.render({
elem: '#device-table',
url: '/api/devices/registered', // 已注册设备列表接口
headers: undefined,
toolbar: '#tableToolbar',
defaultToolbar: ['filter', 'exports', 'print'],
cols: [[
{type: 'checkbox'},
{field: 'uid', title: '设备UID', width: 180},
{field: 'device_model', title: '设备型号', width: 120},
{field: 'license_code', title: '授权码', width: 180},
{field: 'license_type', title: '授权类型', width: 100, templet: function(d){
var types = {
'time': '时间授权',
'count': '次数授权',
'permanent': '永久授权'
};
return types[d.license_type] || '-';
}},
{field: 'expire_time', title: '过期时间', width: 160, templet: function(d){
return d.expire_time ? new Date(d.expire_time).toLocaleString() : '-';
}},
{field: 'start_count', title: '启动次数', width: 100},
{field: 'status', title: '状态', width: 100, templet: function(d){
if(d.status === 'active') return '<span class="layui-badge layui-bg-green">正常</span>';
if(d.status === 'expired') return '<span class="layui-badge layui-bg-orange">已过期</span>';
return '<span class="layui-badge layui-bg-gray">未激活</span>';
}},
{field: 'last_active_at', title: '最后活跃', width: 160, templet: function(d){
return d.last_active_at ? new Date(d.last_active_at).toLocaleString() : '-';
}},
{fixed: 'right', title: '操作', toolbar: '#tableRowBar', width: 250}
]],
page: true,
parseData: function(res) {
if (res.code === 401) {
window.location.href = '/login';
return;
}
return res;
}
});
// 表格工具栏事件
table.on('toolbar(device-table)', function(obj){
switch(obj.event){
case 'refresh':
table.reload('device-table');
break;
}
});
// 行工具栏事件
table.on('tool(device-table)', function(obj){
var data = obj.data;
switch(obj.event){
case 'view':
layer.open({
type: 1,
title: '设备详情',
area: ['600px', '500px'],
content: laytpl($('#deviceDetailTpl').html()).render(data)
});
break;
case 'bind':
layer.prompt({
formType: 0,
value: '',
title: '请输入授权码',
area: ['300px', '150px']
}, function(value, index, elem){
// 绑定授权码
fetch('/api/devices/' + data.uid + '/license', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include',
body: JSON.stringify({
license_code: value
})
})
.then(response => response.json())
.then(result => {
if(result.error) {
layer.msg(result.error);
return;
}
layer.msg('绑定成功');
table.reload('device-table');
layer.close(index);
})
.catch(error => {
layer.msg('绑定失败:' + error.message);
});
});
break;
case 'logs':
layer.open({
type: 2,
title: '设备日志',
area: ['800px', '600px'],
content: '/admin/device-logs?uid=' + data.uid
});
break;
case 'revoke':
layer.confirm('确定要撤销该设备的授权吗?', function(index){
fetch('/api/devices/' + data.uid + '/license', {
method: 'DELETE',
credentials: 'include'
})
.then(response => response.json())
.then(result => {
if(result.error) {
layer.msg(result.error);
return;
}
layer.msg('撤销成功');
table.reload('device-table');
})
.catch(error => {
layer.msg('撤销失败:' + error.message);
});
layer.close(index);
});
break;
}
});
// 搜索表单提交
form.on('submit(search)', function(data){
table.reload('device-table', {
where: data.field,
page: {
curr: 1
}
});
return false;
});
// 导出设备列表
$('#export-devices').on('click', function(){
var checkStatus = table.checkStatus('device-table');
var data = checkStatus.data;
if(data.length === 0){
layer.msg('请选择要导出的设备');
return;
}
// 创建CSV内容
var csv = '设备UID,设备型号,授权码,授权类型,过期时间,启动次数,状态,最后活跃\n';
data.forEach(function(item){
csv += [
item.uid,
item.device_model,
item.license_code || '',
item.license_type || '',
item.expire_time ? new Date(item.expire_time).toLocaleString() : '',
item.start_count,
item.status,
item.last_active_at ? new Date(item.last_active_at).toLocaleString() : ''
].join(',') + '\n';
});
// 下载文件
var blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
var link = document.createElement("a");
if (link.download !== undefined) {
var url = URL.createObjectURL(blob);
link.setAttribute("href", url);
link.setAttribute("download", "devices.csv");
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
});
});

223
web/static/js/devices.js Normal file
View File

@ -0,0 +1,223 @@
layui.use(['table', 'form', 'layer'], function(){
var table = layui.table;
var form = layui.form;
var layer = layui.layer;
var $ = layui.$;
// 初始化表格
table.render({
elem: '#device-table',
url: '/api/devices/models',
headers: undefined,
toolbar: '#tableToolbar',
defaultToolbar: ['filter', 'exports', 'print'],
cols: [[
{type: 'checkbox'},
{field: 'model_name', title: '设备型号', width: 180},
{field: 'device_type', title: '设备类型', width: 120, templet: function(d){
var types = {
'software': '软件',
'website': '网站',
'embedded': '嵌入式设备',
'mcu': '单片机设备'
};
return types[d.device_type] || d.device_type;
}},
{field: 'company', title: '所属公司', width: 150},
{field: 'remark', title: '备注说明'},
{field: 'device_count', title: '设备数量', width: 100},
{field: 'status', title: '状态', width: 100, templet: function(d){
if(d.status === 'active') return '<span class="layui-badge layui-bg-green">已激活</span>';
if(d.status === 'expired') return '<span class="layui-badge layui-bg-orange">已过期</span>';
return '<span class="layui-badge layui-bg-gray">未激活</span>';
}},
{field: 'CreatedAt', title: '创建时间', width: 180, templet: function(d){
return d.CreatedAt ? new Date(d.CreatedAt).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
}) : '';
}},
{fixed: 'right', title: '操作', toolbar: '#tableRowBar', width: 180}
]],
page: true,
parseData: function(res) {
if (res.code === 401) {
window.location.href = '/login';
return;
}
return res;
}
});
// 表格工具栏事件
table.on('toolbar(device-table)', function(obj){
var checkStatus = table.checkStatus(obj.config.id);
switch(obj.event){
case 'refresh':
table.reload('device-table');
break;
case 'batchDel':
var data = checkStatus.data;
if(data.length === 0){
layer.msg('请选择要删除的设备型号');
return;
}
layer.confirm('确定删除选中的设备型号吗?', function(index){
var ids = data.map(item => item.id);
fetch('/api/devices/models/batch', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + localStorage.getItem('token')
},
body: JSON.stringify({ids: ids})
})
.then(response => response.json())
.then(result => {
if (result.error) {
layer.msg(result.error);
return;
}
layer.msg('批量删除成功');
table.reload('device-table');
})
.catch(error => {
layer.msg('批量删除失败:' + error.message);
});
layer.close(index);
});
break;
}
});
// 行工具栏事件
table.on('tool(device-table)', function(obj){
var data = obj.data;
switch(obj.event){
case 'edit':
layer.open({
type: 1,
title: '编辑设备型号',
area: ['500px', '400px'],
content: $('#deviceFormTpl').html(),
success: function(){
form.val('deviceForm', data);
form.render();
}
});
break;
case 'files':
layer.open({
type: 2,
title: '设备文件管理',
area: ['900px', '600px'],
content: '/admin/device-files?model=' + encodeURIComponent(data.deviceModel)
});
break;
case 'del':
layer.confirm('确定删除该设备型号吗?', function(index){
fetch('/api/devices/models/' + data.id, {
method: 'DELETE',
headers: {
'Authorization': 'Bearer ' + localStorage.getItem('token')
}
})
.then(response => response.json())
.then(result => {
if (result.error) {
layer.msg(result.error);
return;
}
layer.msg('删除成功');
obj.del();
})
.catch(error => {
layer.msg('删除失败:' + error.message);
});
layer.close(index);
});
break;
}
});
// 搜索表单提交
form.on('submit(search)', function(data){
table.reload('device-table', {
where: data.field
});
return false;
});
// 添加设备型号按钮点击事件
$('#add-device').on('click', function(){
layer.open({
type: 1,
title: '添加设备型号',
area: ['500px', '400px'],
content: $('#deviceFormTpl').html(),
success: function(){
// 初始化设备类型选择
form.val('deviceForm', {
'device_type': 'software' // 设置默认值
});
form.render();
}
});
});
// 设备型号表单提交
form.on('submit(deviceSubmit)', function(data){
// 如果是编辑模式,确保 id 是数字类型
if(data.field.id) {
data.field.id = parseInt(data.field.id);
}
// 构造提交数据,使用下划线命名
const submitData = {
model_name: data.field.model_name,
device_type: data.field.device_type,
company: data.field.company,
remark: data.field.remark,
status: 'active'
};
// 如果是编辑模式,添加 ID
if(data.field.id) {
submitData.id = data.field.id;
}
var url = data.field.id ? '/api/devices/models/' + data.field.id : '/api/devices/models';
var method = data.field.id ? 'PUT' : 'POST';
fetch(url, {
method: method,
headers: {
'Content-Type': 'application/json'
},
credentials: 'include',
body: JSON.stringify(submitData)
})
.then(response => response.json())
.then(result => {
if (result.error) {
layer.msg(result.error);
return;
}
layer.closeAll('page');
layer.msg(data.field.id ? '更新成功' : '添加成功');
table.reload('device-table');
})
.catch(error => {
layer.msg((data.field.id ? '更新' : '添加') + '失败:' + error.message);
});
return false;
});
});

340
web/static/js/licenses.js Normal file
View File

@ -0,0 +1,340 @@
layui.use(['table', 'form', 'layer'], function(){
var table = layui.table;
var form = layui.form;
var layer = layui.layer;
var $ = layui.$;
// 自定义验证规则
form.verify({
min1: function(value) {
if (value < 1) {
return '必须大于0';
}
}
});
// 初始化表格
table.render({
elem: '#license-table',
url: '/api/licenses',
headers: undefined,
toolbar: '#tableToolbar',
defaultToolbar: ['filter', 'exports', 'print'],
cols: [[
{type: 'checkbox'},
{field: 'code', title: '授权码', width: 320, templet: function(d){
return '<div class="layui-table-cell laytable-cell-1-code">' +
d.code +
'<a class="layui-btn layui-btn-xs layui-btn-normal" lay-event="copy" style="margin-left:5px;">复制</a>' +
'</div>';
}},
{field: 'license_type', title: '授权类型', width: 100, templet: function(d){
var types = {
'time': '时间授权',
'count': '次数授权',
'permanent': '永久授权'
};
return types[d.license_type.toLowerCase()] || d.license_type;
}},
{field: 'duration', title: '有效期', width: 150, templet: function(d){
if(d.license_type.toLowerCase() === 'time') {
let minutes = d.duration;
if (minutes >= 525600) {
return Math.floor(minutes / 525600) + '年';
} else if (minutes >= 43200) {
return Math.floor(minutes / 43200) + '月';
} else if (minutes >= 1440) {
return Math.floor(minutes / 1440) + '天';
} else if (minutes >= 60) {
return Math.floor(minutes / 60) + '小时';
} else {
return minutes + '分钟';
}
}
return '-';
}},
{field: 'max_uses', title: '使用次数', width: 100, templet: function(d){
if(d.license_type.toLowerCase() === 'count') {
return d.max_uses || 0;
}
return '-';
}},
{field: 'status', title: '状态', width: 100, templet: function(d){
var status = d.status.toLowerCase();
if(status === 'unused') return '<span class="layui-badge layui-bg-green">未使用</span>';
if(status === 'used') return '<span class="layui-badge layui-bg-gray">已使用</span>';
if(status === 'expired') return '<span class="layui-badge layui-bg-orange">已过期</span>';
if(status === 'revoked') return '<span class="layui-badge layui-bg-red">已撤销</span>';
return '<span class="layui-badge layui-bg-black">未知</span>';
}},
{field: 'used_by', title: '使用设备', width: 180},
{field: 'used_at', title: '使用时间', width: 160, templet: function(d){
return d.used_at ? new Date(d.used_at).toLocaleString() : '-';
}},
{field: 'batch_no', title: '批次号', width: 160},
{field: 'remark', title: '备注'},
{field: 'bind_count', title: '可绑定次数', width: 100, templet: function(d){
if(d.bind_count === -1) return '<span class="layui-badge layui-bg-blue">无限制</span>';
if(d.bind_count === 0) return '<span class="layui-badge layui-bg-gray">已用完</span>';
return d.bind_count;
}},
{fixed: 'right', title: '操作', toolbar: '#tableRowBar', width: 180, templet: function(d){
var btns = [
'<a class="layui-btn layui-btn-xs" lay-event="view">查看</a>',
'<a class="layui-btn layui-btn-xs layui-btn-warm" lay-event="logs">日志</a>'
];
// 只有未过期且未撤销的授权码才能撤销
if(d.status.toLowerCase() !== 'expired' && d.status.toLowerCase() !== 'revoked') {
btns.push('<a class="layui-btn layui-btn-xs layui-btn-danger" lay-event="revoke">撤销</a>');
}
return btns.join('');
}}
]],
page: true,
parseData: function(res) {
if (res.code === 401) {
window.location.href = '/login';
return;
}
return {
"code": res.code,
"msg": res.msg,
"count": res.count,
"data": res.data
};
}
});
// 表格工具栏事件
table.on('toolbar(license-table)', function(obj){
var checkStatus = table.checkStatus(obj.config.id);
switch(obj.event){
case 'refresh':
table.reload('license-table');
break;
case 'copySelected':
var data = checkStatus.data;
if(data.length === 0){
layer.msg('请选择要复制的授权码');
return;
}
// 提取授权码并按格式组织
var codes = data.map(item => item.code);
var copyText = '';
// 弹出选择框
layer.confirm('请选择复制格式', {
btn: ['换行分隔', '逗号分隔']
}, function(index){
// 换行分隔
copyText = codes.join('\n');
copyToClipboard(copyText);
layer.close(index);
}, function(index){
// 逗号分隔
copyText = codes.join(',');
copyToClipboard(copyText);
layer.close(index);
});
break;
case 'batchDel':
var data = checkStatus.data;
if(data.length === 0){
layer.msg('请选择要删除的授权码');
return;
}
layer.confirm('确定撤销选中的授权码吗?', function(index){
var codes = data.map(item => item.code);
fetch('/api/licenses/batch/revoke', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include',
body: JSON.stringify({codes: codes})
})
.then(response => response.json())
.then(result => {
if(result.error) {
layer.msg(result.error);
return;
}
layer.msg('批量撤销成功');
table.reload('license-table');
})
.catch(error => {
layer.msg('批量撤销失败:' + error.message);
});
layer.close(index);
});
break;
}
});
// 添加复制到剪贴板的函数
function copyToClipboard(text) {
// 创建临时文本区域
var textarea = document.createElement('textarea');
textarea.value = text;
document.body.appendChild(textarea);
// 选择文本
textarea.select();
textarea.setSelectionRange(0, 99999); // 兼容移动设备
try {
// 执行复制
var successful = document.execCommand('copy');
if (successful) {
layer.msg('复制成功');
} else {
layer.msg('复制失败,请手动复制');
}
} catch (err) {
layer.msg('复制失败:' + err.message);
}
// 移除临时文本区域
document.body.removeChild(textarea);
}
// 行工具栏事件
table.on('tool(license-table)', function(obj){
var data = obj.data;
switch(obj.event){
case 'view':
layer.alert(JSON.stringify(data, null, 2), {
title: '授权码详情'
});
break;
case 'logs':
layer.open({
type: 2,
title: '使用日志',
area: ['800px', '600px'],
content: '/admin/license-logs?id=' + data.id
});
break;
case 'revoke':
layer.confirm('确定撤销该授权码吗?', function(index){
fetch('/api/licenses/' + data.code + '/revoke', {
method: 'POST',
credentials: 'include'
})
.then(response => response.json())
.then(result => {
if (result.error) {
layer.msg(result.error);
return;
}
layer.msg('撤销成功');
table.reload('license-table');
})
.catch(error => {
layer.msg('撤销失败:' + error.message);
});
layer.close(index);
});
break;
case 'copy':
copyToClipboard(data.code);
break;
}
});
// 搜索表单提交
form.on('submit(search)', function(data){
// 转换所有字段为小写
Object.keys(data.field).forEach(key => {
if(data.field[key]) {
data.field[key] = data.field[key].toLowerCase();
}
});
table.reload('license-table', {
where: data.field,
page: {
curr: 1
}
});
return false;
});
// 创建授权码按钮点击事件
$('#create-license').on('click', function(){
layer.open({
type: 1,
title: '生成授权码',
area: ['500px', '400px'],
content: $('#createLicenseTpl').html(),
success: function(){
form.render();
}
});
});
// 监听授权类型切换
form.on('select(licenseType)', function(data){
if(data.value === 'time'){
$('#durationItem').show();
$('#maxUsesItem').hide();
} else if(data.value === 'count'){
$('#durationItem').hide();
$('#maxUsesItem').show();
} else {
$('#durationItem').hide();
$('#maxUsesItem').hide();
}
});
// 创建授权码表单提交
form.on('submit(licenseSubmit)', function(data){
var field = data.field;
// 构造请求数据,确保数值类型正确
const submitData = {
license_type: field.license_type.toLowerCase(),
count: parseInt(field.count),
remark: field.remark
};
// 根据授权类型处理参数
if(field.license_type.toLowerCase() === 'time'){
submitData.duration = parseInt(field.duration) * parseInt(field.duration_unit);
delete field.max_uses;
} else if(field.license_type.toLowerCase() === 'count'){
submitData.max_uses = parseInt(field.max_uses);
delete field.duration;
} else {
delete field.duration;
delete field.max_uses;
}
fetch('/api/licenses', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include',
body: JSON.stringify(submitData)
})
.then(response => response.json())
.then(result => {
if (result.error) {
layer.msg(result.error);
return;
}
layer.closeAll('page');
layer.msg('授权码生成成功');
table.reload('license-table');
})
.catch(error => {
layer.msg('生成失败:' + error.message);
});
return false;
});
});

127
web/static/js/login.js Normal file
View File

@ -0,0 +1,127 @@
layui.use(['form', 'layer'], function(){
var form = layui.form;
var layer = layui.layer;
var $ = layui.$;
// 检查用户是否已登录
function checkIfLoggedIn() {
const token = localStorage.getItem('token');
if (token) {
window.location.href = '/';
}
}
// 页面加载时检查登录状态
checkIfLoggedIn();
// 加载验证码
function loadCaptcha() {
fetch('/api/captcha')
.then(response => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
})
.then(data => {
if (data.error) {
layer.msg(data.error);
return;
}
// 确保图片数据正确加载
if (data.imageBase64) {
$('#captchaImg').attr('src', 'data:image/png;base64,' + data.imageBase64);
$('input[name=captchaId]').val(data.captchaId);
} else {
throw new Error('验证码图片数据无效');
}
})
.catch(error => {
layer.msg('获取验证码失败:' + error.message);
// 设置一个默认的错误图片
$('#captchaImg').attr('src', '/static/images/captcha-error.png');
});
}
// 页面加载时获取验证码
loadCaptcha();
// 点击验证码图片刷新
$('#captchaImg').on('click', function() {
loadCaptcha();
});
// 登录表单提交
form.on('submit(login)', function(data){
var field = data.field;
// 添加验证码验证
if (!field.captcha) {
layer.msg('请输入验证码');
return false;
}
if (!field.captchaId) {
layer.msg('验证码已失效,请刷新');
loadCaptcha();
return false;
}
fetch('/api/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
username: field.username,
password: field.password,
captcha: field.captcha,
captchaId: field.captchaId
})
})
.then(response => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
})
.then(result => {
if (result.error) {
layer.msg(result.error);
loadCaptcha(); // 刷新验证码
return;
}
// 确保 token 被正确设置
document.cookie = `token=${result.token}; path=/; secure; samesite=strict`;
localStorage.setItem('token', result.token);
window.location.href = '/';
})
.catch(error => {
layer.msg('登录失败:' + error.message);
loadCaptcha(); // 刷新验证码
});
return false;
});
// 注册账号点击事件
$('.register').on('click', function(){
layer.open({
type: 2,
title: '注册账号',
area: ['500px', '400px'],
content: '/register.html'
});
});
// 忘记密码点击事件
$('.forget-pwd').on('click', function(){
layer.open({
type: 2,
title: '重置密码',
area: ['500px', '300px'],
content: '/reset-password.html'
});
});
});

190
web/static/js/main.js Normal file
View File

@ -0,0 +1,190 @@
layui.use(["element", "layer"], function () {
var element = layui.element;
var layer = layui.layer;
var $ = layui.$;
// 检查认证状态
function checkAuth() {
try {
// 从 cookie 中获取 token
const token = localStorage.getItem('token');
// console.log(token);
if (!token) {
window.location.href = "/login";
return false;
}
return true;
} catch (error) {
console.error("认证检查失败:", error);
window.location.href = "/login";
return false;
}
}
// 添加通用的 fetch 封装,自动处理认证
window.authFetch = function (url, options = {}) {
return fetch(url, {
...options,
credentials: 'include', // 自动携带 cookie
})
.then((response) => {
if (!response.ok) {
if (response.status === 401) {
window.location.href = '/login';
throw new Error('认证失败');
}
throw new Error(`请求失败,状态码:${response.status}`);
}
return response.json();
})
.catch((error) => {
console.error("请求处理失败:", error);
throw error;
});
};
// 在页面加载时检查认证
$(document).ready(function () {
if (!checkAuth()) return;
// 加载用户信息
authFetch("/api/users/profile")
.then((user) => {
$("#current-user").text(user.username);
// 根据用户角色显示/隐藏菜单
if (user.role !== "admin") {
$(".admin-only").hide();
}
})
.catch((error) => {
layer.msg("加载用户信息失败:" + error.message);
});
// 加载站点配置
loadSiteConfig();
// 默认加载 dashboard
loadPage("/admin/dashboard", "控制台");
});
// 左侧菜单点击事件
$(".layui-nav-item a").on("click", function () {
var url = $(this).data("url");
if (url) {
var title = $(this).text().trim();
loadPage(url, title);
}
});
// 加载页面内容
function loadPage(url, title) {
// 更新面包屑
updateBreadcrumb(title);
// 加载页面内容
$("#content-frame").attr("src", url);
// 更新选中状态
$(".layui-nav-item").removeClass("layui-this");
$(`a[data-url="${url}"]`).parent().addClass("layui-this");
}
// 更新面包屑导航
function updateBreadcrumb(title) {
var html =
'<a href="javascript:;">首页</a> <span lay-separator="">/</span> ' +
title;
$(".layui-breadcrumb").html(html);
element.render("breadcrumb");
}
// 修改密码
$(".change-password").on("click", function () {
layer.open({
type: 2,
title: "修改密码",
area: ["500px", "300px"],
content: "/admin/change-password",
});
});
// 退出登录
$(".logout").on("click", function () {
layer.confirm("确定要退出登录吗?", function (index) {
// 清除 cookie
document.cookie = "token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT";
window.location.href = '/login';
layer.close(index);
});
});
// 加载站点配置
function loadSiteConfig() {
authFetch("/api/site/settings")
.then((data) => {
if (data.error) {
layer.msg(data.error);
return;
}
// 更新页面元素
document.title = data.title;
$("#site-title").text(data.title);
$("#site-description").attr("content", data.description);
$("#site-favicon").attr("href", data.favicon);
$("#site-logo").attr("src", data.logo);
$("#site-name").text(data.title);
$("#site-copyright").text(data.copyright);
$("#site-icp").text(data.icp);
})
.catch((error) => {
layer.msg("加载配置失败:" + error.message);
});
}
// 监听子页面消息
window.addEventListener('message', function(event) {
if (event.data.type === 'updateBreadcrumb') {
updateBreadcrumb(event.data.title);
}
});
});

209
web/static/js/monitor.js Normal file
View File

@ -0,0 +1,209 @@
layui.use(['element', 'layer'], function(){
var element = layui.element;
var layer = layui.layer;
var $ = layui.$;
// 初始化所有图表
var cpuChart = echarts.init(document.getElementById('cpu-chart'));
var memoryChart = echarts.init(document.getElementById('memory-chart'));
var diskChart = echarts.init(document.getElementById('disk-chart'));
var networkChart = echarts.init(document.getElementById('network-chart'));
// 更新系统状态
function updateSystemStatus() {
fetch('/api/monitor/status', {
credentials: 'include'
})
.then(response => {
if (response.status === 401) {
window.location.href = '/login';
throw new Error('认证失败');
}
return response.json();
})
.then(data => {
if (data.error) {
layer.msg(data.error);
return;
}
// 更新基础信息
$('#uptime').text(formatDuration(Math.floor(data.system.uptime / 1e9))); // 转换纳秒为秒
$('#active-users').text(data.system.active_users);
$('#total-devices').text(data.system.total_devices);
$('#load-avg').text(data.cpu.load_avg.map(v => v.toFixed(2)).join(' '));
// 更新系统信息
$('#hostname').text(data.host.hostname);
$('#os').text(data.host.os);
$('#platform').text(data.host.platform);
$('#kernel').text(data.host.kernel_version);
$('#cpu-model').text(data.cpu.model_name);
$('#cpu-cores').text(data.cpu.core_count);
$('#boot-time').text(new Date(data.host.boot_time).toLocaleString());
// 更新进程列表
var processHtml = '';
data.process.list.forEach(function(proc) {
processHtml += `
<tr>
<td>${proc.pid}</td>
<td>${proc.name}</td>
<td>${proc.cpu.toFixed(1)}%</td>
<td>${proc.memory.toFixed(1)}%</td>
<td>${formatDuration(Math.floor((Date.now() - proc.created) / 1000))}</td>
</tr>
`;
});
$('#process-list').html(processHtml);
$('#total-processes').text(data.process.total);
// 更新图表
updateCPUChart(data.cpu);
updateMemoryChart(data.memory);
updateDiskChart(data.disk);
updateNetworkChart(data.network);
})
.catch(error => {
layer.msg('获取系统状态失败:' + error.message);
});
}
// 更新CPU图表
function updateCPUChart(cpu) {
var option = {
title: { text: 'CPU使用率' },
tooltip: { formatter: '{b}: {c}%' },
series: [{
type: 'gauge',
min: 0,
max: 100,
detail: { formatter: '{value}%' },
data: [{ value: cpu.usage.toFixed(1), name: 'CPU' }]
}]
};
cpuChart.setOption(option);
}
// 更新内存图表
function updateMemoryChart(memory) {
var used = (memory.used / 1024 / 1024 / 1024).toFixed(1);
var free = (memory.free / 1024 / 1024 / 1024).toFixed(1);
var option = {
title: { text: '内存使用情况' },
tooltip: { formatter: '{b}: {c}GB ({d}%)' },
series: [{
type: 'pie',
radius: ['50%', '70%'],
data: [
{ value: used, name: '已用内存' },
{ value: free, name: '空闲内存' }
]
}]
};
memoryChart.setOption(option);
}
// 更新磁盘图表
function updateDiskChart(disk) {
var option = {
title: { text: '磁盘使用情况' },
tooltip: {
formatter: function(params) {
var data = params.data;
return `${params.name}<br/>
总空间: ${formatSize(data.total)}<br/>
已用空间: ${formatSize(data.used)}<br/>
剩余空间: ${formatSize(data.free)}<br/>
使用率: ${data.usage_rate.toFixed(1)}%`;
}
},
series: [{
type: 'pie',
radius: '60%',
data: disk.partitions.map(p => ({
name: p.mountpoint,
value: p.usage_rate,
total: p.total,
used: p.used,
free: p.free,
usage_rate: p.usage_rate
}))
}]
};
diskChart.setOption(option);
}
// 更新网络图表
function updateNetworkChart(network) {
var option = {
title: { text: '网络流量' },
tooltip: {
trigger: 'axis',
formatter: function(params) {
return params.map(p =>
`${p.seriesName}: ${formatSize(p.value)}/s`
).join('<br/>');
}
},
legend: { data: ['发送', '接收'] },
xAxis: {
type: 'category',
data: network.interfaces.map(i => i.name)
},
yAxis: {
type: 'value',
axisLabel: {
formatter: function(value) {
return formatSize(value) + '/s';
}
}
},
series: [{
name: '发送',
type: 'bar',
data: network.interfaces.map(i => i.bytes_sent)
}, {
name: '接收',
type: 'bar',
data: network.interfaces.map(i => i.bytes_recv)
}]
};
networkChart.setOption(option);
}
// 格式化文件大小
function formatSize(bytes) {
if (bytes === 0) return '0 B';
var k = 1024;
var sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
var i = Math.floor(Math.log(bytes) / Math.log(k));
return (bytes / Math.pow(k, i)).toFixed(2) + ' ' + sizes[i];
}
// 格式化时间
function formatDuration(seconds) {
var days = Math.floor(seconds / 86400);
var hours = Math.floor((seconds % 86400) / 3600);
var minutes = Math.floor((seconds % 3600) / 60);
var parts = [];
if (days > 0) parts.push(days + '天');
if (hours > 0) parts.push(hours + '小时');
if (minutes > 0) parts.push(minutes + '分钟');
return parts.join(' ') || '0分钟';
}
// 初始加载
updateSystemStatus();
// 定时更新
setInterval(updateSystemStatus, 5000);
// 窗口大小改变时重绘图表
window.onresize = function(){
cpuChart.resize();
memoryChart.resize();
diskChart.resize();
networkChart.resize();
};
});

View File

@ -0,0 +1,142 @@
layui.use(['form', 'upload', 'layer'], function(){
var form = layui.form;
var upload = layui.upload;
var layer = layui.layer;
var $ = layui.$;
// 加载当前配置
fetch('/api/site/settings', {
credentials: 'include'
})
.then(response => {
if (response.status === 401) {
window.location.href = '/login';
throw new Error('认证失败');
}
return response.json();
})
.then(data => {
if (data.error) {
layer.msg(data.error);
return;
}
console.log(data);
// 填充表单数据
form.val('siteSettingsForm', {
'title': data.title,
'description': data.description,
'baseUrl': data.base_url,
'icp': data.icp,
'copyright': data.copyright,
'logo': data.logo,
'favicon': data.favicon
});
// 显示当前图片
if (data.logo) {
$('#currentLogo').attr('src', data.logo).show();
}
if (data.favicon) {
$('#currentFavicon').attr('src', data.favicon).show();
}
})
.catch(error => {
layer.msg('加载配置失败:' + error.message);
});
// 上传Logo
upload.render({
elem: '#uploadLogo',
url: '/api/uploads/site',
accept: 'images',
acceptMime: 'image/*',
field: 'file',
before: function() {
layer.load();
},
done: function(res) {
layer.closeAll('loading');
if (res.error) {
layer.msg(res.error);
return;
}
$('input[name=logo]').val(res.url);
$('#currentLogo').attr('src', res.url).show();
layer.msg('Logo上传成功');
},
error: function() {
layer.closeAll('loading');
layer.msg('上传失败');
}
});
// 上传Favicon
upload.render({
elem: '#uploadFavicon',
url: '/api/uploads/site',
accept: 'images',
acceptMime: 'image/*',
field: 'file',
before: function() {
layer.load();
},
done: function(res) {
layer.closeAll('loading');
if (res.error) {
layer.msg(res.error);
return;
}
$('input[name=favicon]').val(res.url);
$('#currentFavicon').attr('src', res.url).show();
layer.msg('Favicon上传成功');
},
error: function() {
layer.closeAll('loading');
layer.msg('上传失败');
}
});
// 表单提交
form.on('submit(siteSubmit)', function(data){
// 转换字段名以匹配后端
const submitData = {
title: data.field.title,
description: data.field.description,
base_url: data.field.baseUrl,
icp: data.field.icp,
copyright: data.field.copyright,
logo: data.field.logo,
favicon: data.field.favicon
};
fetch('/api/site/settings', {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include',
body: JSON.stringify(submitData)
})
.then(response => {
if (response.status === 401) {
window.location.href = '/login';
throw new Error('认证失败');
}
return response.json();
})
.then(result => {
if (result.error) {
layer.msg(result.error);
return;
}
layer.msg('保存成功');
// 刷新父页面
parent.window.location.reload();
})
.catch(error => {
layer.msg('保存失败:' + error.message);
});
return false;
});
});

214
web/static/js/tokens.js Normal file
View File

@ -0,0 +1,214 @@
layui.use(["table", "form", "layer"], function () {
var table = layui.table;
var form = layui.form;
var layer = layui.layer;
var $ = layui.$;
// 初始化表格
table.render({
elem: "#token-table",
url: "/api/tokens",
headers: [
{
Authorization: "Bearer " + localStorage.getItem("token"),
},
],
toolbar: "#tableToolbar",
defaultToolbar: ["filter", "exports", "print"],
cols: [
[
{ type: "checkbox" },
{ field: "token", title: "访问令牌", width: 320 },
{ field: "deviceUID", title: "设备UID", width: 180 },
{ field: "type", title: "令牌类型", width: 100 },
{
field: "status",
title: "状态",
width: 100,
templet: function (d) {
if (d.status === "active")
return '<span class="layui-badge layui-bg-green">有效</span>';
if (d.status === "revoked")
return '<span class="layui-badge layui-bg-gray">已撤销</span>';
return '<span class="layui-badge layui-bg-orange">已过期</span>';
},
},
{ field: "expireTime", title: "过期时间", width: 160 },
{ field: "lastUsed", title: "最后使用", width: 160 },
{ field: "usageCount", title: "使用次数", width: 100 },
{ field: "ipList", title: "IP限制", width: 200 },
{ fixed: "right", title: "操作", toolbar: "#tableRowBar", width: 180 },
],
],
page: true,
parseData: function(res) {
if (res.code === 401) {
window.location.href = '/login';
return;
}
return res;
},
});
// 表格工具栏事件
table.on("toolbar(token-table)", function (obj) {
var checkStatus = table.checkStatus(obj.config.id);
switch (obj.event) {
case "refresh":
table.reload("token-table");
break;
case "batchRevoke":
var data = checkStatus.data;
if (data.length === 0) {
layer.msg("请选择要撤销的令牌");
return;
}
layer.confirm("确定撤销选中的令牌吗?", function (index) {
var tokens = data.map((item) => item.token);
// 执行批量撤销
Promise.all(
tokens.map((token) =>
fetch("/api/tokens/" + token, {
method: "DELETE",
headers: {
Authorization: "Bearer " + localStorage.getItem("token"),
},
}).then((response) => response.json())
)
)
.then(() => {
layer.msg("批量撤销成功");
table.reload("token-table");
})
.catch((error) => {
layer.msg("批量撤销失败:" + error.message);
});
layer.close(index);
});
break;
}
});
// 行工具栏事件
table.on("tool(token-table)", function (obj) {
var data = obj.data;
switch (obj.event) {
case "view":
layer.alert(JSON.stringify(data, null, 2), {
title: "令牌详情",
});
break;
case "logs":
layer.open({
type: 2,
title: "令牌使用日志",
area: ["800px", "600px"],
content: "/admin/token-logs.html?id=" + data.id,
});
break;
case "revoke":
layer.confirm("确定撤销该令牌吗?", function (index) {
fetch("/api/tokens/" + data.token, {
method: "DELETE",
headers: {
Authorization: "Bearer " + localStorage.getItem("token"),
},
})
.then((response) => response.json())
.then((result) => {
if (result.error) {
layer.msg(result.error);
return;
}
layer.msg("撤销成功");
obj.update({
status: "revoked",
});
})
.catch((error) => {
layer.msg("撤销失败:" + error.message);
});
layer.close(index);
});
break;
}
});
// 搜索表单提交
form.on("submit(search)", function (data) {
table.reload("token-table", {
where: data.field,
});
return false;
});
// 创建令牌按钮点击事件
$("#create-token").on("click", function () {
// 加载设备列表
fetch("/api/devices", {
headers: {
Authorization: "Bearer " + localStorage.getItem("token"),
},
})
.then((response) => response.json())
.then((result) => {
var devices = result.data;
var options = devices
.map(
(device) =>
`<option value="${device.uid}">${device.uid} (${device.deviceModel})</option>`
)
.join("");
layer.open({
type: 1,
title: "创建访问令牌",
area: ["500px", "400px"],
content: $("#createTokenTpl").html(),
success: function () {
$("select[name=device_uid]").append(options);
form.render("select");
},
});
})
.catch((error) => {
layer.msg("加载设备列表失败:" + error.message);
});
});
// 创建令牌表单提交
form.on("submit(tokenSubmit)", function (data) {
var ipList = data.field.ip_list.split(/[,\s]+/).filter((ip) => ip);
fetch("/api/tokens", {
method: "POST",
headers: {
Authorization: "Bearer " + localStorage.getItem("token"),
"Content-Type": "application/json",
},
body: JSON.stringify({
device_uid: data.field.device_uid,
token_type: data.field.token_type,
expire_days: parseInt(data.field.expire_days),
ip_list: ipList,
}),
})
.then((response) => response.json())
.then((result) => {
if (result.error) {
layer.msg(result.error);
return;
}
layer.closeAll("page");
layer.msg("创建成功");
table.reload("token-table");
})
.catch((error) => {
layer.msg("创建失败:" + error.message);
});
return false;
});
});

200
web/static/js/upload.js Normal file
View File

@ -0,0 +1,200 @@
layui.use(['upload', 'element', 'layer'], function(){
var upload = layui.upload;
var element = layui.element;
var layer = layui.layer;
var $ = layui.$;
// 普通文件上传
upload.render({
elem: '#fileUpload',
url: '/api/uploads',
headers: {
'Authorization': 'Bearer ' + localStorage.getItem('token')
},
data: {
device_uid: function() {
return $('#deviceUID').val();
},
description: function() {
return $('#description').val();
}
},
accept: 'file',
before: function(obj) {
layer.load();
},
done: function(res) {
layer.closeAll('loading');
if (res.error) {
layer.msg(res.error);
return;
}
layer.msg('上传成功');
// 刷新文件列表
loadFileList();
},
error: function() {
layer.closeAll('loading');
layer.msg('上传失败');
}
});
// 分片上传
upload.render({
elem: '#chunkUpload',
url: '/api/uploads/chunk',
headers: {
'Authorization': 'Bearer ' + localStorage.getItem('token')
},
data: {
deviceUID: function() {
return $('#deviceUID').val();
}
},
accept: 'file',
size: 1024 * 1024 * 2, // 每片2MB
chunked: true,
chunkSize: 1024 * 1024 * 2,
before: function(obj) {
layer.load();
},
progress: function(n) {
var percent = n + '%';
element.progress('uploadProgress', percent);
},
done: function(res) {
layer.closeAll('loading');
if (res.error) {
layer.msg(res.error);
return;
}
// 如果所有分片都上传完成,开始合并
if (res.completed) {
mergeChunks(res.fileHash);
} else {
layer.msg('分片上传成功');
}
},
error: function() {
layer.closeAll('loading');
layer.msg('上传失败');
}
});
// 合并分片
function mergeChunks(fileHash) {
fetch('/api/uploads/merge', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + localStorage.getItem('token'),
'Content-Type': 'application/x-www-form-urlencoded'
},
body: 'fileHash=' + fileHash
})
.then(response => response.json())
.then(result => {
if (result.error) {
layer.msg(result.error);
return;
}
layer.msg('文件合并成功');
// 刷新文件列表
loadFileList();
})
.catch(error => {
layer.msg('文件合并失败:' + error.message);
});
}
// 加载文件列表
function loadFileList() {
var deviceUID = $('#deviceUID').val();
if (!deviceUID) return;
fetch('/api/uploads/device/' + deviceUID, {
headers: {
'Authorization': 'Bearer ' + localStorage.getItem('token')
}
})
.then(response => response.json())
.then(result => {
if (result.error) {
layer.msg(result.error);
return;
}
renderFileList(result);
})
.catch(error => {
layer.msg('加载文件列表失败:' + error.message);
});
}
// 渲染文件列表
function renderFileList(files) {
var html = '';
files.forEach(file => {
html += `
<tr>
<td>${file.fileName}</td>
<td>${formatFileSize(file.fileSize)}</td>
<td>${file.fileType}</td>
<td>${formatTime(file.createdAt)}</td>
<td>
<a href="javascript:;" class="layui-btn layui-btn-xs" onclick="downloadFile(${file.id})">下载</a>
<a href="javascript:;" class="layui-btn layui-btn-danger layui-btn-xs" onclick="deleteFile(${file.id})">删除</a>
</td>
</tr>
`;
});
$('#fileList').html(html);
}
// 格式化文件大小
function formatFileSize(size) {
var units = ['B', 'KB', 'MB', 'GB', 'TB'];
var index = 0;
while (size >= 1024 && index < units.length - 1) {
size /= 1024;
index++;
}
return size.toFixed(2) + ' ' + units[index];
}
// 格式化时间
function formatTime(time) {
return new Date(time).toLocaleString();
}
// 下载文件
window.downloadFile = function(id) {
window.location.href = '/api/uploads/' + id;
};
// 删除文件
window.deleteFile = function(id) {
layer.confirm('确定删除该文件吗?', function(index) {
fetch('/api/uploads/' + id, {
method: 'DELETE',
headers: {
'Authorization': 'Bearer ' + localStorage.getItem('token')
}
})
.then(response => response.json())
.then(result => {
if (result.error) {
layer.msg(result.error);
return;
}
layer.msg('删除成功');
loadFileList();
})
.catch(error => {
layer.msg('删除失败:' + error.message);
});
layer.close(index);
});
};
// 初始加载文件列表
loadFileList();
});

146
web/static/js/users.js Normal file
View File

@ -0,0 +1,146 @@
layui.use(['table', 'form', 'layer'], function(){
var table = layui.table;
var form = layui.form;
var layer = layui.layer;
var $ = layui.$;
// 初始化表格
table.render({
elem: '#user-table',
url: '/api/users',
headers: undefined,
toolbar: '#tableToolbar',
defaultToolbar: ['filter', 'exports', 'print'],
cols: [[
{type: 'checkbox'},
{field: 'username', title: '用户名', width: 150},
{field: 'email', title: '邮箱', width: 200},
{field: 'role', title: '角色', width: 100, templet: '#roleTpl'},
{field: 'lastLogin', title: '最后登录', width: 160},
{field: 'createdAt', title: '创建时间', width: 160},
{fixed: 'right', title: '操作', toolbar: '#tableRowBar', width: 150}
]],
page: true,
parseData: function(res) {
if (res.code === 401) {
window.location.href = '/login';
return;
}
return res;
}
});
// 表格工具栏事件
table.on('toolbar(user-table)', function(obj){
switch(obj.event){
case 'refresh':
table.reload('user-table');
break;
}
});
// 行工具栏事件
table.on('tool(user-table)', function(obj){
var data = obj.data;
switch(obj.event){
case 'edit':
layer.open({
type: 1,
title: '编辑用户',
area: ['500px', '400px'],
content: $('#userFormTpl').html(),
success: function(){
// 填充表单数据
form.val('userForm', {
'id': data.id,
'username': data.username,
'email': data.email,
'role': data.role
});
// 编辑时密码可选
$('input[name=password]').removeAttr('required').removeAttr('lay-verify');
form.render();
}
});
break;
case 'del':
layer.confirm('确定删除该用户吗?', function(index){
fetch('/api/users/' + data.id, {
method: 'DELETE',
credentials: 'include'
})
.then(response => response.json())
.then(result => {
if (result.error) {
layer.msg(result.error);
return;
}
layer.msg('删除成功');
obj.del();
})
.catch(error => {
layer.msg('删除失败:' + error.message);
});
layer.close(index);
});
break;
}
});
// 搜索表单提交
form.on('submit(search)', function(data){
table.reload('user-table', {
where: data.field
});
return false;
});
// 添加用户按钮点击事件
$('#add-user').on('click', function(){
layer.open({
type: 1,
title: '添加用户',
area: ['500px', '400px'],
content: $('#userFormTpl').html(),
success: function(){
form.render();
}
});
});
// 用户表单提交
form.on('submit(userSubmit)', function(data){
var url = data.field.id ? '/api/users/' + data.field.id : '/api/users';
var method = data.field.id ? 'PUT' : 'POST';
// 如果是编辑且没有输入密码,则删除密码字段
if (data.field.id && !data.field.password) {
delete data.field.password;
}
fetch(url, {
method: method,
headers: {
'Content-Type': 'application/json'
},
credentials: 'include',
body: JSON.stringify(data.field)
})
.then(response => response.json())
.then(result => {
if (result.error) {
layer.msg(result.error);
return;
}
layer.closeAll('page');
layer.msg(data.field.id ? '更新成功' : '添加成功');
table.reload('user-table');
})
.catch(error => {
layer.msg((data.field.id ? '更新' : '添加') + '失败:' + error.message);
});
return false;
});
});

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 322 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

45
web/static/lib/echarts.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,103 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>修改密码</title>
<link rel="stylesheet" href="/static/layui/css/layui.css">
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<div class="layui-fluid">
<div class="layui-card">
<div class="layui-card-body">
<form class="layui-form" lay-filter="changePasswordForm">
<div class="layui-form-item">
<label class="layui-form-label">原密码</label>
<div class="layui-input-block">
<input type="password" name="old_password" required lay-verify="required"
placeholder="请输入原密码" autocomplete="off" class="layui-input">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">新密码</label>
<div class="layui-input-block">
<input type="password" name="newPassword" required lay-verify="required|password"
placeholder="请输入新密码" autocomplete="off" class="layui-input">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">确认密码</label>
<div class="layui-input-block">
<input type="password" name="confirmPassword" required lay-verify="required|confirmPassword"
placeholder="请再次输入新密码" autocomplete="off" class="layui-input">
</div>
</div>
<div class="layui-form-item">
<div class="layui-input-block">
<button class="layui-btn" lay-submit lay-filter="passwordSubmit">修改</button>
<button type="reset" class="layui-btn layui-btn-primary">重置</button>
</div>
</div>
</form>
</div>
</div>
</div>
<script src="/static/layui/layui.js"></script>
<script>
layui.use(['form', 'layer'], function(){
var form = layui.form;
var layer = layui.layer;
// 自定义验证规则
form.verify({
password: [
/^[\S]{6,12}$/,
'密码必须6到12位且不能出现空格'
],
confirmPassword: function(value) {
var password = $('input[name=newPassword]').val();
if (value !== password) {
return '两次输入的密码不一致';
}
}
});
// 表单提交
form.on('submit(passwordSubmit)', function(data){
fetch('/api/users/change-password', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + localStorage.getItem('token'),
'Content-Type': 'application/json'
},
body: JSON.stringify({
old_password: data.field.oldPassword,
new_password: data.field.newPassword
})
})
.then(response => response.json())
.then(result => {
if (result.error) {
layer.msg(result.error);
return;
}
layer.msg('密码修改成功,请重新登录');
setTimeout(function(){
localStorage.removeItem('token');
top.location.href = '/login.html';
}, 1500);
})
.catch(error => {
layer.msg('修改失败:' + error.message);
});
return false;
});
});
</script>
</body>
</html>

View File

@ -0,0 +1,54 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>控制台</title>
<link rel="stylesheet" href="/static/layui/css/layui.css">
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<div class="layui-fluid">
<div class="layui-row layui-col-space15">
<div class="layui-col-md3">
<div class="layui-card">
<div class="layui-card-header">设备总数</div>
<div class="layui-card-body big-font" id="total-devices">0</div>
</div>
</div>
<div class="layui-col-md3">
<div class="layui-card">
<div class="layui-card-header">授权码总数</div>
<div class="layui-card-body big-font" id="total-licenses">0</div>
</div>
</div>
<div class="layui-col-md3">
<div class="layui-card">
<div class="layui-card-header">今日新增</div>
<div class="layui-card-body big-font" id="today-new">0</div>
</div>
</div>
<div class="layui-col-md3">
<div class="layui-card">
<div class="layui-card-header">在线设备</div>
<div class="layui-card-body big-font" id="online-devices">0</div>
</div>
</div>
<div class="layui-col-md3">
<div class="layui-card">
<div class="layui-card-header">激活设备</div>
<div class="layui-card-body big-font" id="active-devices">0</div>
</div>
</div>
<div class="layui-col-md3">
<div class="layui-card">
<div class="layui-card-header">过期设备</div>
<div class="layui-card-body big-font" id="expired-devices">0</div>
</div>
</div>
</div>
</div>
<script src="/static/layui/layui.js"></script>
<script src="/static/js/dashboard.js"></script>
</body>
</html>

View File

@ -0,0 +1,119 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>设备文件管理</title>
<link rel="stylesheet" href="/static/layui/css/layui.css">
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<div class="layui-fluid">
<div class="layui-card">
<div class="layui-card-header">
<span>设备文件管理</span>
<div class="layui-btn-group">
<button class="layui-btn layui-btn-sm" id="uploadFile">
<i class="layui-icon">&#xe67c;</i>上传文件
</button>
<button class="layui-btn layui-btn-sm layui-btn-normal" id="uploadUpdate">
<i class="layui-icon">&#xe67c;</i>上传更新
</button>
</div>
</div>
<div class="layui-card-body">
<!-- 搜索表单 -->
<form class="layui-form layui-form-pane" action="">
<div class="layui-form-item">
<div class="layui-inline">
<label class="layui-form-label">设备型号</label>
<div class="layui-input-inline">
<select name="deviceModel" lay-search>
<option value="">全部</option>
</select>
</div>
</div>
<div class="layui-inline">
<label class="layui-form-label">文件类型</label>
<div class="layui-input-inline">
<select name="fileType">
<option value="">全部</option>
<option value="update">更新文件</option>
<option value="normal">普通文件</option>
</select>
</div>
</div>
<div class="layui-inline">
<button class="layui-btn" lay-submit lay-filter="search">
<i class="layui-icon">&#xe615;</i> 搜索
</button>
</div>
</div>
</form>
<!-- 文件列表 -->
<table class="layui-table">
<thead>
<tr>
<th>文件名</th>
<th>设备型号</th>
<th>版本</th>
<th>类型</th>
<th>大小</th>
<th>下载次数</th>
<th>上传时间</th>
<th>操作</th>
</tr>
</thead>
<tbody id="fileList"></tbody>
</table>
</div>
</div>
</div>
<!-- 上传表单模板 -->
<script type="text/html" id="uploadFormTpl">
<form class="layui-form" style="padding: 20px;">
<div class="layui-form-item">
<label class="layui-form-label">设备型号</label>
<div class="layui-input-block">
<select name="deviceModel" lay-verify="required" lay-search>
<option value="">请选择设备型号</option>
</select>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">文件版本</label>
<div class="layui-input-block">
<input type="text" name="version" required lay-verify="required"
placeholder="请输入文件版本" class="layui-input">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">文件描述</label>
<div class="layui-input-block">
<textarea name="description" placeholder="请输入文件描述"
class="layui-textarea"></textarea>
</div>
</div>
<div class="layui-form-item" id="updateOptions" style="display:none;">
<label class="layui-form-label">更新选项</label>
<div class="layui-input-block">
<input type="checkbox" name="forceUpdate" title="强制更新">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">选择文件</label>
<div class="layui-input-block">
<button type="button" class="layui-btn" id="selectFile">
<i class="layui-icon">&#xe67c;</i>选择文件
</button>
<div class="layui-inline layui-word-aux" id="selectedFile"></div>
</div>
</div>
</form>
</script>
<script src="/static/layui/layui.js"></script>
<script src="/static/js/upload.js"></script>
</body>
</html>

View File

@ -0,0 +1,129 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>设备授权管理</title>
<link rel="stylesheet" href="/static/layui/css/layui.css">
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<div class="layui-fluid">
<div class="layui-card">
<div class="layui-card-header">
<span>设备授权管理</span>
</div>
<div class="layui-card-body">
<!-- 搜索表单 -->
<form class="layui-form layui-form-pane" action="">
<div class="layui-form-item">
<div class="layui-inline">
<label class="layui-form-label">设备UID</label>
<div class="layui-input-inline">
<input type="text" name="uid" autocomplete="off" class="layui-input">
</div>
</div>
<div class="layui-inline">
<label class="layui-form-label">设备型号</label>
<div class="layui-input-inline">
<select name="device_model" lay-search>
<option value="">全部</option>
</select>
</div>
</div>
<div class="layui-inline">
<label class="layui-form-label">授权状态</label>
<div class="layui-input-inline">
<select name="status">
<option value="">全部</option>
<option value="active">正常</option>
<option value="expired">已过期</option>
<option value="inactive">未激活</option>
</select>
</div>
</div>
<div class="layui-inline">
<button class="layui-btn" lay-submit lay-filter="search">
<i class="layui-icon">&#xe615;</i> 搜索
</button>
<button type="button" class="layui-btn layui-btn-primary" id="export-devices">
<i class="layui-icon">&#xe67d;</i> 导出
</button>
</div>
</div>
</form>
<!-- 数据表格 -->
<table id="device-table" lay-filter="device-table"></table>
</div>
</div>
</div>
<!-- 表格工具栏模板 -->
<script type="text/html" id="tableToolbar">
<div class="layui-btn-container">
<button class="layui-btn layui-btn-sm" lay-event="refresh">
<i class="layui-icon">&#xe669;</i> 刷新
</button>
</div>
</script>
<!-- 行工具栏模板 -->
<script type="text/html" id="tableRowBar">
<a class="layui-btn layui-btn-xs" lay-event="view">查看</a>
<a class="layui-btn layui-btn-xs layui-btn-normal" lay-event="bind">绑定授权</a>
<a class="layui-btn layui-btn-xs layui-btn-warm" lay-event="logs">日志</a>
{{# if(d.status !== 'expired'){ }}
<a class="layui-btn layui-btn-xs layui-btn-danger" lay-event="revoke">撤销</a>
{{# } }}
</script>
<!-- 设备详情模板 -->
<script type="text/html" id="deviceDetailTpl">
<div style="padding: 20px;">
<table class="layui-table" lay-skin="line">
<colgroup>
<col width="150">
<col>
</colgroup>
<tbody>
<tr>
<td>设备UID</td>
<td>{{ d.uid }}</td>
</tr>
<tr>
<td>设备型号</td>
<td>{{ d.device_model }}</td>
</tr>
<tr>
<td>授权码</td>
<td>{{ d.license_code || '-' }}</td>
</tr>
<tr>
<td>授权类型</td>
<td>{{ d.license_type || '-' }}</td>
</tr>
<tr>
<td>过期时间</td>
<td>{{ d.expire_time ? new Date(d.expire_time).toLocaleString() : '-' }}</td>
</tr>
<tr>
<td>启动次数</td>
<td>{{ d.start_count }}</td>
</tr>
<tr>
<td>最后活跃</td>
<td>{{ d.last_active_at ? new Date(d.last_active_at).toLocaleString() : '-' }}</td>
</tr>
<tr>
<td>注册时间</td>
<td>{{ new Date(d.register_time).toLocaleString() }}</td>
</tr>
</tbody>
</table>
</div>
</script>
<script src="/static/layui/layui.js"></script>
<script src="/static/js/device-license.js"></script>
</body>
</html>

View File

@ -0,0 +1,213 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>设备管理</title>
<link rel="stylesheet" href="/static/layui/css/layui.css" />
<link rel="stylesheet" href="/static/css/style.css" />
</head>
<body>
<div class="layui-fluid">
<div class="layui-card">
<div class="layui-card-header">
<span>设备型号管理</span>
<button
class="layui-btn layui-btn-sm layui-btn-normal"
id="add-device"
>
<i class="layui-icon">&#xe654;</i> 添加设备型号
</button>
</div>
<div class="layui-card-body">
<!-- 搜索表单 -->
<form class="layui-form layui-form-pane" action="">
<div class="layui-form-item">
<div class="layui-inline">
<label class="layui-form-label">设备型号</label>
<div class="layui-input-inline">
<input
type="text"
name="deviceModel"
autocomplete="off"
class="layui-input"
/>
</div>
</div>
<div class="layui-inline">
<label class="layui-form-label">设备类型</label>
<div class="layui-input-inline">
<select name="deviceType">
<option value="">全部</option>
<option value="软件">软件</option>
<option value="网站">网站</option>
<option value="嵌入式设备">嵌入式设备</option>
<option value="单片机设备">单片机设备</option>
</select>
</div>
</div>
<div class="layui-inline">
<label class="layui-form-label">所属公司</label>
<div class="layui-input-inline">
<input
type="text"
name="company"
autocomplete="off"
class="layui-input"
/>
</div>
</div>
<div class="layui-inline">
<button class="layui-btn" lay-submit lay-filter="search">
<i class="layui-icon">&#xe615;</i> 搜索
</button>
<button
type="button"
class="layui-btn layui-btn-primary"
id="export-devices"
>
<i class="layui-icon">&#xe67d;</i> 导出
</button>
</div>
</div>
</form>
<!-- 数据表格 -->
<table id="device-table" lay-filter="device-table"></table>
</div>
</div>
</div>
<!-- 表格工具栏模板 -->
<script type="text/html" id="tableToolbar">
<div class="layui-btn-container">
<button class="layui-btn layui-btn-sm" lay-event="refresh">
<i class="layui-icon">&#xe669;</i> 刷新
</button>
<button
class="layui-btn layui-btn-sm layui-btn-danger"
lay-event="batchDel"
>
<i class="layui-icon">&#xe640;</i> 批量删除
</button>
</div>
</script>
<!-- 行工具栏模板 -->
<script type="text/html" id="tableRowBar">
<a class="layui-btn layui-btn-xs" lay-event="edit">编辑</a>
<a class="layui-btn layui-btn-xs layui-btn-warm" lay-event="files"
>文件</a
>
<a class="layui-btn layui-btn-xs layui-btn-danger" lay-event="del"
>删除</a
>
</script>
<!-- 设备表单模板 -->
<script type="text/html" id="deviceFormTpl">
<form class="layui-form" style="padding: 20px;" lay-filter="deviceForm">
<input type="hidden" name="id" />
<div class="layui-form-item">
<label class="layui-form-label">设备型号</label>
<div class="layui-input-block">
<input
type="text"
name="model_name"
required
lay-verify="required"
placeholder="请输入设备型号"
autocomplete="off"
class="layui-input"
/>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">设备类型</label>
<div class="layui-input-block">
<select name="device_type" required lay-verify="required">
<option value="software">软件</option>
<option value="website">网站</option>
<option value="embedded">嵌入式设备</option>
<option value="mcu">单片机设备</option>
</select>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">所属公司</label>
<div class="layui-input-block">
<input
type="text"
name="company"
placeholder="请输入所属公司"
autocomplete="off"
class="layui-input"
/>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">备注说明</label>
<div class="layui-input-block">
<textarea
name="remark"
placeholder="请输入备注说明"
class="layui-textarea"
></textarea>
</div>
</div>
<div class="layui-form-item">
<div class="layui-input-block">
<button class="layui-btn" lay-submit lay-filter="deviceSubmit">
提交
</button>
<button type="reset" class="layui-btn layui-btn-primary">
重置
</button>
</div>
</div>
</form>
</script>
<script src="/static/layui/layui.js"></script>
<script src="/static/js/devices.js"></script>
</body>
</html>

View File

@ -0,0 +1,172 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>授权码操作日志</title>
<link rel="stylesheet" href="/static/layui/css/layui.css">
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<div class="layui-fluid">
<div class="layui-card">
<div class="layui-card-header">
<span>授权码操作日志</span>
<div class="layui-btn-group">
<button class="layui-btn layui-btn-sm" id="refresh-logs">
<i class="layui-icon">&#xe669;</i> 刷新
</button>
<button class="layui-btn layui-btn-sm layui-btn-normal" id="export-logs">
<i class="layui-icon">&#xe67d;</i> 导出
</button>
</div>
</div>
<div class="layui-card-body">
<!-- 搜索表单 -->
<form class="layui-form layui-form-pane" action="">
<div class="layui-form-item">
<div class="layui-inline">
<label class="layui-form-label">操作类型</label>
<div class="layui-input-inline">
<select name="action">
<option value="">全部</option>
<option value="create">创建</option>
<option value="use">使用</option>
<option value="verify">验证</option>
<option value="revoke">撤销</option>
</select>
</div>
</div>
<div class="layui-inline">
<label class="layui-form-label">状态</label>
<div class="layui-input-inline">
<select name="status">
<option value="">全部</option>
<option value="success">成功</option>
<option value="failed">失败</option>
</select>
</div>
</div>
<div class="layui-inline">
<label class="layui-form-label">设备UID</label>
<div class="layui-input-inline">
<input type="text" name="device_uid" autocomplete="off" class="layui-input">
</div>
</div>
<div class="layui-inline">
<button class="layui-btn" lay-submit lay-filter="search">
<i class="layui-icon">&#xe615;</i> 搜索
</button>
</div>
</div>
</form>
<!-- 数据表格 -->
<table id="log-table" lay-filter="log-table"></table>
</div>
</div>
</div>
<!-- 操作类型模板 -->
<script type="text/html" id="actionTpl">
{{# var types = {
'create': '<span class="layui-badge layui-bg-blue">创建</span>',
'use': '<span class="layui-badge layui-bg-green">使用</span>',
'verify': '<span class="layui-badge layui-bg-orange">验证</span>',
'revoke': '<span class="layui-badge layui-bg-red">撤销</span>'
}; }}
{{# return types[d.action] || d.action; }}
</script>
<!-- 状态模板 -->
<script type="text/html" id="statusTpl">
{{# if(d.status === 'success'){ }}
<span class="layui-badge layui-bg-green">成功</span>
{{# } else { }}
<span class="layui-badge layui-bg-red">失败</span>
{{# } }}
</script>
<script src="/static/layui/layui.js"></script>
<script>
layui.use(['table', 'layer', 'form'], function(){
var table = layui.table;
var layer = layui.layer;
var form = layui.form;
var $ = layui.$;
// 获取授权码ID
var licenseId = location.search.match(/id=(\d+)/)[1];
// 初始化表格
var tableIns = table.render({
elem: '#log-table',
url: '/api/licenses/' + licenseId + '/logs',
headers: {
'Authorization': 'Bearer ' + localStorage.getItem('token')
},
cols: [[
{field: 'action', title: '操作类型', width: 100, templet: '#actionTpl', sort: true},
{field: 'device_uid', title: '设备UID', width: 180},
{field: 'ip', title: 'IP地址', width: 150},
{field: 'status', title: '状态', width: 100, templet: '#statusTpl'},
{field: 'message', title: '详细信息'},
{field: 'created_at', title: '操作时间', width: 180, sort: true, templet: function(d){
return new Date(d.created_at).toLocaleString();
}}
]],
page: true,
parseData: function(res) {
if (res.code === 401) {
window.location.href = '/login';
return;
}
return res;
}
});
// 搜索表单提交
form.on('submit(search)', function(data){
tableIns.reload({
where: data.field,
page: {
curr: 1
}
});
return false;
});
// 刷新按钮点击事件
$('#refresh-logs').on('click', function(){
tableIns.reload();
});
// 导出按钮点击事件
$('#export-logs').on('click', function(){
var loadIndex = layer.load(2);
fetch('/api/licenses/' + licenseId + '/logs?export=1', {
headers: {
'Authorization': 'Bearer ' + localStorage.getItem('token')
}
})
.then(response => response.blob())
.then(blob => {
layer.close(loadIndex);
var url = window.URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = 'license_logs_' + licenseId + '_' +
new Date().toISOString().slice(0,19).replace(/[-:]/g, '') + '.csv';
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
})
.catch(error => {
layer.close(loadIndex);
layer.msg('导出失败:' + error.message);
});
});
});
</script>
</body>
</html>

View File

@ -0,0 +1,263 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>授权码管理</title>
<link rel="stylesheet" href="/static/layui/css/layui.css" />
<link rel="stylesheet" href="/static/css/style.css" />
</head>
<body>
<div class="layui-fluid">
<div class="layui-card">
<div class="layui-card-header">
<span>授权码管理</span>
<button
class="layui-btn layui-btn-sm layui-btn-normal"
id="create-license"
>
<i class="layui-icon">&#xe654;</i> 生成授权码
</button>
</div>
<div class="layui-card-body">
<!-- 搜索表单 -->
<form class="layui-form layui-form-pane" action="">
<div class="layui-form-item">
<div class="layui-inline">
<label class="layui-form-label">授权码</label>
<div class="layui-input-inline">
<input
type="text"
name="code"
autocomplete="off"
class="layui-input"
/>
</div>
</div>
<div class="layui-inline">
<label class="layui-form-label">授权类型</label>
<div class="layui-input-inline">
<select name="license_type">
<option value="">全部</option>
<option value="time">时间授权</option>
<option value="count">次数授权</option>
<option value="permanent">永久授权</option>
</select>
</div>
</div>
<div class="layui-inline">
<label class="layui-form-label">状态</label>
<div class="layui-input-inline">
<select name="status">
<option value="">全部</option>
<option value="unused">未使用</option>
<option value="used">已使用</option>
<option value="expired">已过期</option>
<option value="revoked">已撤销</option>
</select>
</div>
</div>
<div class="layui-inline">
<label class="layui-form-label">批次号</label>
<div class="layui-input-inline">
<input
type="text"
name="batch_no"
autocomplete="off"
class="layui-input"
/>
</div>
</div>
<div class="layui-inline">
<button class="layui-btn" lay-submit lay-filter="search">
<i class="layui-icon">&#xe615;</i> 搜索
</button>
<button
type="button"
class="layui-btn layui-btn-primary"
id="export-licenses"
>
<i class="layui-icon">&#xe67d;</i> 导出
</button>
</div>
</div>
</form>
<!-- 数据表格 -->
<table id="license-table" lay-filter="license-table"></table>
</div>
</div>
</div>
<!-- Layui 表格工具栏模板 -->
<script type="text/html" id="tableToolbar">
<div class="layui-btn-container">
<button class="layui-btn layui-btn-sm" lay-event="refresh">
<i class="layui-icon">&#xe669;</i> 刷新
</button>
<button
class="layui-btn layui-btn-sm layui-btn-normal"
lay-event="copySelected"
>
<i class="layui-icon">&#xe64c;</i> 复制选中
</button>
<button
class="layui-btn layui-btn-sm layui-btn-danger"
lay-event="batchDel"
>
<i class="layui-icon">&#xe640;</i> 批量删除
</button>
</div>
</script>
<!-- 行工具栏模板 -->
<script id="tableRowBar" type="text/html">
<a class="layui-btn layui-btn-xs" lay-event="view">查看</a>
<a class="layui-btn layui-btn-xs layui-btn-warm" lay-event="logs">日志</a>
{{# if(d.status === 'unused'){ }}
<a class="layui-btn layui-btn-xs layui-btn-danger" lay-event="revoke"
>撤销</a
>
{{# } }}
</script>
<script src="/static/layui/layui.js"></script>
<script src="/static/js/licenses.js"></script>
<!-- 在页面底部添加创建授权码表单模板 -->
<script type="text/html" id="createLicenseTpl">
<form class="layui-form" style="padding: 20px;" lay-filter="licenseForm">
<div class="layui-form-item">
<label class="layui-form-label">授权类型</label>
<div class="layui-input-block">
<select
name="license_type"
lay-verify="required"
lay-filter="licenseType"
>
<option value="time">时间授权</option>
<option value="count">次数授权</option>
<option value="permanent">永久授权</option>
</select>
</div>
</div>
<div class="layui-form-item" id="durationItem">
<label class="layui-form-label">有效期</label>
<div class="layui-input-inline" style="width: 150px;">
<input
type="number"
name="duration"
class="layui-input"
placeholder="请输入数值"
/>
</div>
<div class="layui-input-inline" style="width: 100px;">
<select name="duration_unit">
<option value="1">分钟</option>
<option value="60">小时</option>
<option value="1440"></option>
<option value="43200"></option>
<option value="525600"></option>
</select>
</div>
</div>
<div class="layui-form-item" id="maxUsesItem" style="display:none;">
<label class="layui-form-label">使用次数</label>
<div class="layui-input-block">
<input
type="number"
name="max_uses"
class="layui-input"
placeholder="请输入最大使用次数"
/>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">生成数量</label>
<div class="layui-input-block">
<input
type="number"
name="count"
required
lay-verify="required|number|min1"
value="1"
class="layui-input"
/>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">备注</label>
<div class="layui-input-block">
<textarea
name="remark"
placeholder="请输入备注信息"
class="layui-textarea"
></textarea>
</div>
</div>
<div class="layui-form-item">
<div class="layui-input-block">
<button class="layui-btn" lay-submit lay-filter="licenseSubmit">
生成
</button>
<button type="reset" class="layui-btn layui-btn-primary">
重置
</button>
</div>
</div>
</form>
</script>
</body>
</html>

View File

@ -0,0 +1,325 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>系统监控</title>
<link rel="stylesheet" href="/static/layui/css/layui.css">
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<div class="layui-fluid">
<!-- 系统概览 -->
<div class="layui-row layui-col-space15">
<div class="layui-col-md3">
<div class="layui-card">
<div class="layui-card-header">运行时间</div>
<div class="layui-card-body big-font" id="uptime">
0天0小时0分钟
</div>
</div>
</div>
<div class="layui-col-md3">
<div class="layui-card">
<div class="layui-card-header">活跃用户</div>
<div class="layui-card-body big-font" id="active-users">
0
</div>
</div>
</div>
<div class="layui-col-md3">
<div class="layui-card">
<div class="layui-card-header">设备总数</div>
<div class="layui-card-body big-font" id="total-devices">
0
</div>
</div>
</div>
<div class="layui-col-md3">
<div class="layui-card">
<div class="layui-card-header">系统负载</div>
<div class="layui-card-body big-font" id="load-avg">
0.00
</div>
</div>
</div>
</div>
<!-- 资源监控 -->
<div class="layui-row layui-col-space15">
<div class="layui-col-md6">
<div class="layui-card">
<div class="layui-card-header">CPU使用率</div>
<div class="layui-card-body">
<div id="cpu-chart" style="height: 300px;"></div>
</div>
</div>
</div>
<div class="layui-col-md6">
<div class="layui-card">
<div class="layui-card-header">内存使用情况</div>
<div class="layui-card-body">
<div id="memory-chart" style="height: 300px;"></div>
</div>
</div>
</div>
</div>
<!-- 磁盘和网络 -->
<div class="layui-row layui-col-space15">
<div class="layui-col-md6">
<div class="layui-card">
<div class="layui-card-header">磁盘使用情况</div>
<div class="layui-card-body">
<div id="disk-chart" style="height: 300px;"></div>
</div>
</div>
</div>
<div class="layui-col-md6">
<div class="layui-card">
<div class="layui-card-header">网络流量</div>
<div class="layui-card-body">
<div id="network-chart" style="height: 300px;"></div>
</div>
</div>
</div>
</div>
<!-- 进程和系统信息 -->
<div class="layui-row layui-col-space15">
<div class="layui-col-md6">
<div class="layui-card">
<div class="layui-card-header">
<span>进程列表Top 10</span>
<span class="layui-badge layui-bg-blue" id="total-processes">0</span>
</div>
<div class="layui-card-body">
<table class="layui-table" lay-skin="line">
<thead>
<tr>
<th>PID</th>
<th>名称</th>
<th>CPU使用率</th>
<th>内存使用率</th>
<th>运行时间</th>
</tr>
</thead>
<tbody id="process-list"></tbody>
</table>
</div>
</div>
</div>
<div class="layui-col-md6">
<div class="layui-card">
<div class="layui-card-header">系统信息</div>
<div class="layui-card-body">
<table class="layui-table" lay-skin="nob">
<tbody>
<tr>
<td>主机名</td>
<td id="hostname"></td>
</tr>
<tr>
<td>操作系统</td>
<td id="os"></td>
</tr>
<tr>
<td>平台</td>
<td id="platform"></td>
</tr>
<tr>
<td>内核版本</td>
<td id="kernel"></td>
</tr>
<tr>
<td>CPU型号</td>
<td id="cpu-model"></td>
</tr>
<tr>
<td>CPU核心数</td>
<td id="cpu-cores"></td>
</tr>
<tr>
<td>启动时间</td>
<td id="boot-time"></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<script src="/static/layui/layui.js"></script>
<script src="/static/lib/echarts.min.js"></script>
<script src="/static/js/monitor.js"></script>
<script type="text/html" id="processListTpl">
{{# layui.each(d, function(index, item){ }}
<tr>
<td>{{item.pid}}</td>
<td>{{item.name}}</td>
<td>{{item.cpu.toFixed(1)}}%</td>
<td>{{item.memory.toFixed(1)}}%</td>
<td>{{formatDuration(Math.floor((Date.now() - item.created) / 1000))}}</td>
</tr>
{{# }); }}
</script>
</body>
</html>

View File

@ -0,0 +1,185 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>站点设置</title>
<link rel="stylesheet" href="/static/layui/css/layui.css">
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<div class="layui-fluid">
<div class="layui-card">
<div class="layui-card-header">站点设置</div>
<div class="layui-card-body">
<form class="layui-form" lay-filter="siteSettingsForm">
<div class="layui-form-item">
<label class="layui-form-label">站点标题</label>
<div class="layui-input-block">
<input type="text" name="title" required lay-verify="required"
placeholder="请输入站点标题" autocomplete="off" class="layui-input">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">站点描述</label>
<div class="layui-input-block">
<textarea name="description" placeholder="请输入站点描述"
class="layui-textarea"></textarea>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">基础URL</label>
<div class="layui-input-block">
<input type="text" name="baseUrl" required lay-verify="required"
placeholder="请输入基础URL" autocomplete="off" class="layui-input">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">ICP备案号</label>
<div class="layui-input-block">
<input type="text" name="icp" placeholder="请输入ICP备案号"
autocomplete="off" class="layui-input">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">版权信息</label>
<div class="layui-input-block">
<input type="text" name="copyright" required lay-verify="required"
placeholder="请输入版权信息" autocomplete="off" class="layui-input">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">Logo</label>
<div class="layui-input-inline">
<input type="text" name="logo" placeholder="Logo路径"
autocomplete="off" class="layui-input">
</div>
<div class="layui-input-inline" style="width: auto;">
<button type="button" class="layui-btn" id="uploadLogo">
<i class="layui-icon">&#xe67c;</i>上传Logo
</button>
</div>
<div class="layui-input-inline" style="width: auto;">
<img id="currentLogo" src="" style="display:none;max-height:38px;margin-left:10px;">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">Favicon</label>
<div class="layui-input-inline">
<input type="text" name="favicon" placeholder="Favicon路径"
autocomplete="off" class="layui-input">
</div>
<div class="layui-input-inline" style="width: auto;">
<button type="button" class="layui-btn" id="uploadFavicon">
<i class="layui-icon">&#xe67c;</i>上传Favicon
</button>
</div>
<div class="layui-input-inline" style="width: auto;">
<img id="currentFavicon" src="" style="display:none;max-height:38px;margin-left:10px;">
</div>
</div>
<div class="layui-form-item">
<div class="layui-input-block">
<button class="layui-btn" lay-submit lay-filter="siteSubmit">保存设置</button>
<button type="reset" class="layui-btn layui-btn-primary">重置</button>
</div>
</div>
</form>
</div>
</div>
</div>
<script src="/static/layui/layui.js"></script>
<script>
layui.use(['form', 'upload', 'layer'], function(){
var form = layui.form;
var upload = layui.upload;
var layer = layui.layer;
var $ = layui.$;
// 加载当前配置
fetch('/api/site/settings', {
headers: {
'Authorization': 'Bearer ' + localStorage.getItem('token')
}
})
.then(response => response.json())
.then(data => {
if (data.error) {
layer.msg(data.error);
return;
}
form.val('siteForm', data);
})
.catch(error => {
layer.msg('加载配置失败:' + error.message);
});
// 上传Logo
upload.render({
elem: '#uploadLogo',
url: '/api/uploads/site',
headers: {
'Authorization': 'Bearer ' + localStorage.getItem('token')
},
accept: 'images',
done: function(res){
if (res.error) {
layer.msg(res.error);
return;
}
$('input[name=logo]').val(res.url);
}
});
// 上传Favicon
upload.render({
elem: '#uploadFavicon',
url: '/api/uploads/site',
headers: {
'Authorization': 'Bearer ' + localStorage.getItem('token')
},
accept: 'images',
done: function(res){
if (res.error) {
layer.msg(res.error);
return;
}
$('input[name=favicon]').val(res.url);
}
});
// 表单提交
form.on('submit(siteSubmit)', function(data){
fetch('/api/site/settings', {
method: 'PUT',
headers: {
'Authorization': 'Bearer ' + localStorage.getItem('token'),
'Content-Type': 'application/json'
},
body: JSON.stringify(data.field)
})
.then(response => response.json())
.then(result => {
if (result.error) {
layer.msg(result.error);
return;
}
layer.msg('保存成功');
})
.catch(error => {
layer.msg('保存失败:' + error.message);
});
return false;
});
});
</script>
</body>
</html>

View File

@ -0,0 +1,69 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>令牌使用日志</title>
<link rel="stylesheet" href="/static/layui/css/layui.css">
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<div class="layui-fluid">
<div class="layui-card">
<div class="layui-card-header">令牌使用日志</div>
<div class="layui-card-body">
<table id="log-table" lay-filter="log-table"></table>
</div>
</div>
</div>
<!-- 状态模板 -->
<script type="text/html" id="statusTpl">
{{# if(d.status === 'success'){ }}
<span class="layui-badge layui-bg-green">成功</span>
{{# } else { }}
<span class="layui-badge layui-bg-red">失败</span>
{{# } }}
</script>
<!-- 操作类型模板 -->
<script type="text/html" id="actionTpl">
{{# var types = {
'create': '创建',
'use': '使用',
'revoke': '撤销'
}; }}
{{# var type = types[d.action] || d.action; }}
<span>{{type}}</span>
</script>
<script src="/static/layui/layui.js"></script>
<script>
layui.use(['table', 'layer'], function(){
var table = layui.table;
var layer = layui.layer;
var $ = layui.$;
// 获取令牌ID
var tokenId = location.search.match(/id=(\d+)/)[1];
// 初始化表格
table.render({
elem: '#log-table',
url: '/api/tokens/' + tokenId + '/logs',
headers: {
'Authorization': 'Bearer ' + localStorage.getItem('token')
},
cols: [[
{field: 'action', title: '操作类型', width: 100, templet: '#actionTpl'},
{field: 'ip', title: 'IP地址', width: 150},
{field: 'userAgent', title: 'User-Agent', width: 300},
{field: 'status', title: '状态', width: 100, templet: '#statusTpl'},
{field: 'message', title: '详细信息'},
{field: 'createdAt', title: '时间', width: 160}
]],
page: true
});
});
</script>
</body>
</html>

View File

@ -0,0 +1,130 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>访问令牌管理</title>
<link rel="stylesheet" href="/static/layui/css/layui.css">
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<div class="layui-fluid">
<div class="layui-card">
<div class="layui-card-header">
<span>访问令牌管理</span>
<button class="layui-btn layui-btn-sm layui-btn-normal" id="create-token">
<i class="layui-icon">&#xe654;</i> 创建令牌
</button>
</div>
<div class="layui-card-body">
<!-- 搜索表单 -->
<form class="layui-form layui-form-pane" action="">
<div class="layui-form-item">
<div class="layui-inline">
<label class="layui-form-label">设备UID</label>
<div class="layui-input-inline">
<input type="text" name="device_uid" autocomplete="off" class="layui-input">
</div>
</div>
<div class="layui-inline">
<label class="layui-form-label">令牌类型</label>
<div class="layui-input-inline">
<select name="token_type">
<option value="">全部</option>
<option value="api">API</option>
<option value="device">设备</option>
</select>
</div>
</div>
<div class="layui-inline">
<label class="layui-form-label">状态</label>
<div class="layui-input-inline">
<select name="status">
<option value="">全部</option>
<option value="active">有效</option>
<option value="revoked">已撤销</option>
<option value="expired">已过期</option>
</select>
</div>
</div>
<div class="layui-inline">
<button class="layui-btn" lay-submit lay-filter="search">
<i class="layui-icon">&#xe615;</i> 搜索
</button>
</div>
</div>
</form>
<!-- 数据表格 -->
<table id="token-table" lay-filter="token-table"></table>
</div>
</div>
</div>
<!-- 表格工具栏模板 -->
<script type="text/html" id="tableToolbar">
<div class="layui-btn-container">
<button class="layui-btn layui-btn-sm" lay-event="refresh">
<i class="layui-icon">&#xe669;</i> 刷新
</button>
<button class="layui-btn layui-btn-sm layui-btn-danger" lay-event="batchRevoke">
<i class="layui-icon">&#xe640;</i> 批量撤销
</button>
</div>
</script>
<!-- 行工具栏模板 -->
<script type="text/html" id="tableRowBar">
<a class="layui-btn layui-btn-xs" lay-event="view">查看</a>
<a class="layui-btn layui-btn-xs layui-btn-warm" lay-event="logs">日志</a>
[[# if(d.status === 'active'){ ]]
<a class="layui-btn layui-btn-xs layui-btn-danger" lay-event="revoke">撤销</a>
[[# } ]]
</script>
<!-- 创建令牌表单模板 -->
<script type="text/html" id="createTokenTpl">
<form class="layui-form" style="padding: 20px;" lay-filter="tokenForm">
<div class="layui-form-item">
<label class="layui-form-label">设备UID</label>
<div class="layui-input-block">
<select name="device_uid" lay-verify="required" lay-search>
<option value="">请选择设备</option>
</select>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">令牌类型</label>
<div class="layui-input-block">
<select name="token_type" lay-verify="required">
<option value="api">API</option>
<option value="device">设备</option>
</select>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">有效期</label>
<div class="layui-input-block">
<input type="number" name="expire_days" required lay-verify="required|number"
placeholder="请输入有效期天数" class="layui-input">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">IP限制</label>
<div class="layui-input-block">
<textarea name="ip_list" placeholder="请输入允许访问的IP地址多个IP用逗号分隔"
class="layui-textarea"></textarea>
</div>
</div>
<div class="layui-form-item">
<div class="layui-input-block">
<button class="layui-btn" lay-submit lay-filter="tokenSubmit">创建</button>
<button type="reset" class="layui-btn layui-btn-primary">重置</button>
</div>
</div>
</form>
</script>
<script src="/static/layui/layui.js"></script>
<script src="/static/js/tokens.js"></script>
</body>
</html>

View File

@ -0,0 +1,126 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>编辑用户</title>
<link rel="stylesheet" href="/static/layui/css/layui.css">
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<div class="layui-fluid">
<div class="layui-card">
<div class="layui-card-body">
<form class="layui-form" lay-filter="userForm">
<input type="hidden" name="id">
<div class="layui-form-item">
<label class="layui-form-label">用户名</label>
<div class="layui-input-block">
<input type="text" name="username" required lay-verify="required"
placeholder="请输入用户名" autocomplete="off" class="layui-input">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">邮箱</label>
<div class="layui-input-block">
<input type="text" name="email" required lay-verify="required|email"
placeholder="请输入邮箱" autocomplete="off" class="layui-input">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">角色</label>
<div class="layui-input-block">
<select name="role" required lay-verify="required">
<option value="user">普通用户</option>
<option value="admin">管理员</option>
</select>
</div>
</div>
<div class="layui-form-item">
<div class="layui-input-block">
<button class="layui-btn" lay-submit lay-filter="userSubmit">保存</button>
<button type="reset" class="layui-btn layui-btn-primary">重置</button>
</div>
</div>
</form>
</div>
</div>
</div>
<script src="/static/layui/layui.js"></script>
<script>
layui.use(['form', 'layer'], function(){
var form = layui.form;
var layer = layui.layer;
var $ = layui.$;
// 获取用户ID
var userId = location.search.match(/id=(\d+)/);
if (userId) {
userId = userId[1];
// 加载用户数据
fetch('/api/users/' + userId, {
headers: {
'Authorization': 'Bearer ' + localStorage.getItem('token')
}
})
.then(response => response.json())
.then(data => {
if (data.error) {
layer.msg(data.error);
return;
}
// 填充表单
form.val('userForm', {
'id': data.id,
'username': data.username,
'email': data.email,
'role': data.role
});
})
.catch(error => {
layer.msg('加载用户数据失败:' + error.message);
});
}
// 表单提交
form.on('submit(userSubmit)', function(data){
var field = data.field;
var url = userId ? '/api/users/' + userId : '/api/users';
var method = userId ? 'PUT' : 'POST';
fetch(url, {
method: method,
headers: {
'Authorization': 'Bearer ' + localStorage.getItem('token'),
'Content-Type': 'application/json'
},
body: JSON.stringify(field)
})
.then(response => response.json())
.then(result => {
if (result.error) {
layer.msg(result.error);
return;
}
layer.msg('保存成功');
// 如果是在弹窗中,则关闭弹窗并刷新父页面的表格
var index = parent.layer.getFrameIndex(window.name);
if (index) {
parent.layui.table.reload('user-table');
parent.layer.close(index);
}
})
.catch(error => {
layer.msg('保存失败:' + error.message);
});
return false;
});
});
</script>
</body>
</html>

View File

@ -0,0 +1,124 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>用户管理</title>
<link rel="stylesheet" href="/static/layui/css/layui.css">
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<div class="layui-fluid">
<div class="layui-card">
<div class="layui-card-header">
<span>用户管理</span>
<button class="layui-btn layui-btn-sm layui-btn-normal" id="add-user">
<i class="layui-icon">&#xe654;</i> 添加用户
</button>
</div>
<div class="layui-card-body">
<!-- 搜索表单 -->
<form class="layui-form layui-form-pane" action="">
<div class="layui-form-item">
<div class="layui-inline">
<label class="layui-form-label">用户名</label>
<div class="layui-input-inline">
<input type="text" name="username" autocomplete="off" class="layui-input">
</div>
</div>
<div class="layui-inline">
<label class="layui-form-label">角色</label>
<div class="layui-input-inline">
<select name="role">
<option value="">全部</option>
<option value="admin">管理员</option>
<option value="user">普通用户</option>
</select>
</div>
</div>
<div class="layui-inline">
<button class="layui-btn" lay-submit lay-filter="search">
<i class="layui-icon">&#xe615;</i> 搜索
</button>
</div>
</div>
</form>
<!-- 数据表格 -->
<table id="user-table" lay-filter="user-table"></table>
</div>
</div>
</div>
<!-- 表格工具栏模板 -->
<script type="text/html" id="tableToolbar">
<div class="layui-btn-container">
<button class="layui-btn layui-btn-sm" lay-event="refresh">
<i class="layui-icon">&#xe669;</i> 刷新
</button>
</div>
</script>
<!-- 角色模板 -->
<script type="text/html" id="roleTpl">
{{# if(d.role === 'admin'){ }}
<span class="layui-badge layui-bg-blue">管理员</span>
{{# } else { }}
<span class="layui-badge layui-bg-gray">普通用户</span>
{{# } }}
</script>
<!-- 行工具栏模板 -->
<script type="text/html" id="tableRowBar">
<a class="layui-btn layui-btn-xs" lay-event="edit">编辑</a>
{{# if(d.role !== 'admin'){ }}
<a class="layui-btn layui-btn-xs layui-btn-danger" lay-event="del">删除</a>
{{# } }}
</script>
<!-- 用户表单模板 -->
<script type="text/html" id="userFormTpl">
<form class="layui-form" style="padding: 20px;" lay-filter="userForm">
<input type="hidden" name="id">
<div class="layui-form-item">
<label class="layui-form-label">用户名</label>
<div class="layui-input-block">
<input type="text" name="username" required lay-verify="required"
placeholder="请输入用户名" autocomplete="off" class="layui-input">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">密码</label>
<div class="layui-input-block">
<input type="password" name="password" required lay-verify="required"
placeholder="请输入密码" autocomplete="off" class="layui-input">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">邮箱</label>
<div class="layui-input-block">
<input type="text" name="email" required lay-verify="required|email"
placeholder="请输入邮箱" autocomplete="off" class="layui-input">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">角色</label>
<div class="layui-input-block">
<select name="role" required lay-verify="required">
<option value="user">普通用户</option>
<option value="admin">管理员</option>
</select>
</div>
</div>
<div class="layui-form-item">
<div class="layui-input-block">
<button class="layui-btn" lay-submit lay-filter="userSubmit">提交</button>
<button type="reset" class="layui-btn layui-btn-primary">重置</button>
</div>
</div>
</form>
</script>
<script src="/static/layui/layui.js"></script>
<script src="/static/js/users.js"></script>
</body>
</html>

111
web/templates/index.html Normal file
View File

@ -0,0 +1,111 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>授权验证管理平台</title>
<link rel="stylesheet" href="/static/layui/css/layui.css">
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body class="layui-layout-body">
<div class="layui-layout layui-layout-admin">
<!-- 头部 -->
<div class="layui-header">
<div class="layui-logo">
<img src="/static/images/logo.png" alt="logo" id="site-logo">
<span id="site-name">授权验证管理平台</span>
</div>
<ul class="layui-nav layui-layout-right">
<li class="layui-nav-item">
<a href="javascript:;">
<span id="current-user"></span>
</a>
<dl class="layui-nav-child">
<dd><a href="javascript:;" class="change-password">修改密码</a></dd>
<dd><a href="javascript:;" class="logout">退出登录</a></dd>
</dl>
</li>
</ul>
</div>
<!-- 左侧导航 -->
<div class="layui-side layui-bg-black">
<div class="layui-side-scroll">
<ul class="layui-nav layui-nav-tree">
<li class="layui-nav-item layui-this">
<a href="javascript:;" data-url="/admin/dashboard">
<i class="layui-icon">&#xe68e;</i> 控制台
</a>
</li>
<li class="layui-nav-item">
<a href="javascript:;">
<i class="layui-icon">&#xe665;</i> 设备管理
</a>
<dl class="layui-nav-child">
<dd><a href="javascript:;" data-url="/admin/devices">设备型号管理</a></dd>
<dd><a href="javascript:;" data-url="/admin/device-files">设备文件管理</a></dd>
<dd><a href="javascript:;" data-url="/admin/device-license">设备授权管理</a></dd>
</dl>
</li>
<li class="layui-nav-item">
<a href="javascript:;">
<i class="layui-icon">&#xe672;</i> 授权管理
</a>
<dl class="layui-nav-child">
<dd><a href="javascript:;" data-url="/admin/licenses">授权码管理</a></dd>
<dd><a href="javascript:;" data-url="/admin/license-logs">授权日志</a></dd>
</dl>
</li>
<li class="layui-nav-item">
<a href="javascript:;">
<i class="layui-icon">&#xe674;</i> 令牌管理
</a>
<dl class="layui-nav-child">
<dd><a href="javascript:;" data-url="/admin/tokens">访问令牌</a></dd>
<dd><a href="javascript:;" data-url="/admin/token-logs">令牌日志</a></dd>
</dl>
</li>
<li class="layui-nav-item admin-only">
<a href="javascript:;" data-url="/admin/users">
<i class="layui-icon">&#xe770;</i> 用户管理
</a>
</li>
<li class="layui-nav-item">
<a href="javascript:;" data-url="/admin/monitor">
<i class="layui-icon">&#xe665;</i> 系统监控
</a>
</li>
<li class="layui-nav-item admin-only">
<a href="javascript:;" data-url="/admin/site-settings">
<i class="layui-icon">&#xe716;</i> 站点设置
</a>
</li>
</ul>
</div>
</div>
<!-- 内容主体 -->
<div class="layui-body">
<div class="layui-breadcrumb" lay-separator="/">
<a href="javascript:;">首页</a>
<a><cite>控制台</cite></a>
</div>
<div class="content-container">
<iframe id="content-frame" frameborder="0" class="layadmin-iframe"></iframe>
</div>
</div>
<!-- 底部 -->
<div class="layui-footer">
<div class="footer-left">
<span id="site-copyright"></span>
</div>
<div class="footer-right">
<span id="site-icp"></span>
</div>
</div>
</div>
<script src="/static/layui/layui.js"></script>
<script src="/static/js/main.js"></script>
</body>
</html>

49
web/templates/login.html Normal file
View File

@ -0,0 +1,49 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>登录 - 授权验证管理平台</title>
<link rel="stylesheet" href="/static/layui/css/layui.css">
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body class="login-body">
<div class="layui-container">
<div class="layui-row">
<div class="layui-col-md4 layui-col-md-offset4">
<div class="login-box">
<h2>授权验证管理平台</h2>
<form class="layui-form" action="">
<div class="layui-form-item">
<input type="text" name="username" required lay-verify="required"
placeholder="用户名" autocomplete="off" class="layui-input">
</div>
<div class="layui-form-item">
<input type="password" name="password" required lay-verify="required"
placeholder="密码" autocomplete="off" class="layui-input">
</div>
<div class="layui-form-item">
<div class="layui-input-inline" style="width: 200px;">
<input type="text" name="captcha" required lay-verify="required"
placeholder="请输入验证码" autocomplete="off" class="layui-input">
</div>
<div class="layui-input-inline" style="width: 120px;">
<img src="" id="captchaImg" style="height:38px;cursor:pointer;" alt="点击刷新">
<input type="hidden" name="captchaId">
</div>
</div>
<div class="layui-form-item">
<button class="layui-btn layui-btn-fluid" lay-submit lay-filter="login">登录</button>
</div>
<div class="layui-form-item">
<a href="javascript:;" class="forget-pwd">忘记密码?</a>
<a href="javascript:;" class="register">注册账号</a>
</div>
</form>
</div>
</div>
</div>
</div>
<script src="/static/layui/layui.js"></script>
<script src="/static/js/login.js"></script>
</body>
</html>