初始化

This commit is contained in:
2026-09-17 14:42:44 +08:00
commit 7c106b69d2
54 changed files with 2521 additions and 0 deletions

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
* linguist-language=GO

19
.gitignore vendored Normal file
View File

@@ -0,0 +1,19 @@
.buildpath
.hgignore.swp
.project
.orig
.swp
.idea/
.settings/
.vscode/
bin/
**/.DS_Store
gf
main
main.exe
output/
manifest/output/
temp/
temp.yaml
bin
**/config/config.yaml

7
Makefile Normal file
View File

@@ -0,0 +1,7 @@
ROOT_DIR = $(shell pwd)
NAMESPACE = "default"
DEPLOY_NAME = "template-single"
DOCKER_NAME = "template-single"
include ./hack/hack-cli.mk
include ./hack/hack.mk

4
README.MD Normal file
View File

@@ -0,0 +1,4 @@
# GoFrame Template For SingleRepo
Quick Start:
- https://goframe.org/quick

235
api/admin/v1/admin.go Normal file
View File

@@ -0,0 +1,235 @@
package v1
import "github.com/gogf/gf/v2/frame/g"
// ===== 登录 =====
type LoginReq struct {
g.Meta `path:"/login" method:"post" tags:"admin" summary:"管理员登录"`
Account string `v:"required#请输入账号" json:"account"`
Password string `v:"required#请输入密码" json:"password"`
Remember bool `json:"remember"`
}
type LoginRes struct {
Token string `json:"token"`
NickName string `json:"nick_name"`
RoleName string `json:"role_name"`
RoleValue string `json:"role_value"`
}
type MyInfoReq struct {
g.Meta `path:"/user/my-info" method:"get" tags:"admin" summary:"当前管理员信息"`
}
type MyInfoRes struct {
Id int64 `json:"id"`
Account string `json:"account"`
NickName string `json:"nick_name"`
RoleName string `json:"role_name"`
RoleValue string `json:"role_value"`
}
// ===== 用户管理 =====
type AdminUserItem struct {
Id int64 `json:"id"`
Nickname string `json:"nickname"`
AvatarUrl string `json:"avatar_url"`
Openid string `json:"openid"`
LevelKey string `json:"level_key"`
LevelName string `json:"level_name"`
Status int `json:"status"`
UsageCount int64 `json:"usage_count"`
LastUsedAt string `json:"last_used_at"`
CreatedAt string `json:"created_at"`
}
type UserListReq struct {
g.Meta `path:"/user/list" method:"get" tags:"admin" summary:"用户列表"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
Keyword string `json:"keyword"`
}
type UserListRes struct {
List []AdminUserItem `json:"list"`
Total int `json:"total"`
}
type UserSetLevelReq struct {
g.Meta `path:"/user/set-level" method:"post" tags:"admin" summary:"设置用户等级"`
UserId int64 `v:"required" json:"userId"`
LevelKey string `v:"required" json:"levelKey"`
}
type UserSetLevelRes struct{}
type UserStatusReq struct {
g.Meta `path:"/user/status" method:"post" tags:"admin" summary:"启停用户"`
UserId int64 `v:"required" json:"userId"`
Status int `json:"status"`
}
type UserStatusRes struct{}
// ===== 等级管理 =====
type LevelItem struct {
Id int64 `json:"id"`
LevelKey string `json:"level_key"`
Name string `json:"name"`
Modules []string `json:"modules"`
Sort int `json:"sort"`
IsEnabled int `json:"is_enabled"`
Remark string `json:"remark"`
}
type LevelListReq struct {
g.Meta `path:"/level/list" method:"get" tags:"admin" summary:"等级列表"`
}
type LevelListRes struct {
List []LevelItem `json:"list"`
}
type LevelSaveReq struct {
g.Meta `path:"/level/save" method:"post" tags:"admin" summary:"保存等级"`
Id int64 `json:"id"`
LevelKey string `v:"required-if:id,0#等级标识不能为空" json:"levelKey"`
Name string `v:"required#等级名称不能为空" json:"name"`
Modules []string `v:"required#至少勾选一个模块" json:"modules"`
Sort int `json:"sort"`
IsEnabled int `json:"isEnabled"`
Remark string `json:"remark"`
}
type LevelSaveRes struct{}
// ===== 模块管理 =====
type ModuleItem struct {
Id int64 `json:"id"`
ModuleKey string `json:"module_key"`
Name string `json:"name"`
Icon string `json:"icon"`
Description string `json:"description"`
Sort int `json:"sort"`
IsEnabled int `json:"is_enabled"`
ToolCount int `json:"tool_count"`
}
type ModuleListReq struct {
g.Meta `path:"/module/list" method:"get" tags:"admin" summary:"模块列表"`
}
type ModuleListRes struct {
List []ModuleItem `json:"list"`
}
type ModuleSaveReq struct {
g.Meta `path:"/module/save" method:"post" tags:"admin" summary:"保存模块"`
Id int64 `json:"id"`
ModuleKey string `v:"required-if:id,0#模块标识不能为空" json:"moduleKey"`
Name string `v:"required#模块名称不能为空" json:"name"`
Icon string `json:"icon"`
Description string `json:"description"`
Sort int `json:"sort"`
IsEnabled int `json:"isEnabled"`
}
type ModuleSaveRes struct{}
// ===== 工具管理 =====
type AdminToolItem struct {
Id int64 `json:"id"`
ToolKey string `json:"tool_key"`
ModuleKey string `json:"module_key"`
ModuleName string `json:"module_name"`
Name string `json:"name"`
Icon string `json:"icon"`
Description string `json:"description"`
Sort int `json:"sort"`
IsEnabled int `json:"is_enabled"`
IsHot int `json:"is_hot"`
UsageCount int64 `json:"usage_count"`
}
type ToolsListReq struct {
g.Meta `path:"/tools/all" method:"get" tags:"admin" summary:"工具列表(全量)"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
ModuleKey string `json:"moduleKey"`
Keyword string `json:"keyword"`
IncludeDisabled bool `json:"includeDisabled"`
}
type ToolsListRes struct {
List []AdminToolItem `json:"list"`
Total int `json:"total"`
}
type ToolsSaveReq struct {
g.Meta `path:"/tools/save" method:"post" tags:"admin" summary:"保存工具"`
Id int64 `json:"id"`
ToolKey string `v:"required-if:id,0#工具标识不能为空" json:"toolKey"`
ModuleKey string `v:"required#所属模块不能为空" json:"moduleKey"`
Name string `v:"required#工具名称不能为空" json:"name"`
Icon string `json:"icon"`
Description string `json:"description"`
Sort int `json:"sort"`
IsEnabled int `json:"isEnabled"`
IsHot int `json:"isHot"`
}
type ToolsSaveRes struct{}
type ToolsToggleReq struct {
g.Meta `path:"/tools/toggle" method:"post" tags:"admin" summary:"工具开关"`
Id int64 `v:"required" json:"id"`
Field string `v:"required|in:is_enabled,is_hot" json:"field"`
Value int `json:"value"`
}
type ToolsToggleRes struct{}
// ===== 数据看板 =====
type TrendItem struct {
Date string `json:"date"`
Count int64 `json:"count"`
}
type TopItem struct {
ToolKey string `json:"tool_key"`
Name string `json:"name"`
Count int64 `json:"count"`
}
type DashboardStatsReq struct {
g.Meta `path:"/dashboard/stats" method:"get" tags:"admin" summary:"看板统计"`
}
type DashboardStatsRes struct {
UserCount int64 `json:"userCount"`
TodayActive int64 `json:"todayActive"`
TotalUsage int64 `json:"totalUsage"`
WeekTrend []TrendItem `json:"weekTrend"`
ToolsTop []TopItem `json:"toolsTop"`
}
// ===== 反馈管理 =====
type FeedbackItem struct {
Id int64 `json:"id"`
UserId int64 `json:"user_id"`
Content string `json:"content"`
Contact string `json:"contact"`
Status int `json:"status"`
CreatedAt string `json:"created_at"`
}
type FeedbackListReq struct {
g.Meta `path:"/feedback/list" method:"get" tags:"admin" summary:"反馈列表"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
Status *int `json:"status"`
}
type FeedbackListRes struct {
List []FeedbackItem `json:"list"`
Total int `json:"total"`
}
type FeedbackHandleReq struct {
g.Meta `path:"/feedback/handle" method:"post" tags:"admin" summary:"标记已处理"`
Id int64 `v:"required" json:"id"`
}
type FeedbackHandleRes struct{}

148
api/user/v1/user.go Normal file
View File

@@ -0,0 +1,148 @@
package v1
import "github.com/gogf/gf/v2/frame/g"
// ===== 公共结构 =====
type UserInfo struct {
Id int64 `json:"id"`
Nickname string `json:"nickname"`
AvatarUrl string `json:"avatar_url"`
}
type LevelInfo struct {
LevelKey string `json:"level_key"`
Name string `json:"name"`
Modules []string `json:"modules"`
}
// ===== 登录 =====
type WxLoginReq struct {
g.Meta `path:"/user/wx-login" method:"post" tags:"user" summary:"微信登录"`
Code string `v:"required#code不能为空" json:"code"`
}
type WxLoginRes struct {
Token string `json:"token"`
User *UserInfo `json:"user"`
Level *LevelInfo `json:"level"`
}
// DevLogin 仅 debug 配置开启时可用H5/联调)
type DevLoginReq struct {
g.Meta `path:"/user/dev-login" method:"post" tags:"user" summary:"开发登录"`
}
type DevLoginRes = WxLoginRes
// ===== H5 账号注册/登录 =====
// Register H5 独立账号注册(注册即登录)
type RegisterReq struct {
g.Meta `path:"/user/register" method:"post" tags:"user" summary:"H5 账号注册"`
Username string `v:"required|length:3,32#用户名不能为空|用户名长度为 3-32 位" json:"username"`
Password string `v:"required|length:6,64#密码不能为空|密码长度为 6-64 位" json:"password"`
Nickname string `v:"length:0,32#昵称最长 32 位" json:"nickname"`
}
type RegisterRes = WxLoginRes
// AccountLogin H5 账号密码登录
type AccountLoginReq struct {
g.Meta `path:"/user/login" method:"post" tags:"user" summary:"H5 账号登录"`
Username string `v:"required#用户名不能为空" json:"username"`
Password string `v:"required#密码不能为空" json:"password"`
}
type AccountLoginRes = WxLoginRes
// BindAccount 将一个 H5 独立账号合并进当前登录账号(两端共享数据)
type BindAccountReq struct {
g.Meta `path:"/user/bind-account" method:"post" tags:"user" summary:"绑定合并H5 账号"`
Username string `v:"required#用户名不能为空" json:"username"`
Password string `v:"required#密码不能为空" json:"password"`
}
type BindAccountRes struct {
Username string `json:"username"`
}
// ===== 个人信息 =====
// ProfileUpdate 更新昵称 / 头像avatarUrl 支持远端 URL 或预置头像 keyavatar:rocket 等)
type ProfileUpdateReq struct {
g.Meta `path:"/user/profile" method:"put" tags:"user" summary:"更新个人信息"`
Nickname string `v:"required|length:1,32#昵称不能为空|昵称最长 32 位" json:"nickname"`
AvatarUrl string `v:"length:0,512" json:"avatar_url"`
}
type ProfileUpdateRes struct {
Nickname string `json:"nickname"`
AvatarUrl string `json:"avatar_url"`
}
// ===== 等级 =====
type LevelReq struct {
g.Meta `path:"/user/level" method:"get" tags:"user" summary:"我的等级与模块"`
}
type LevelRes = LevelInfo
// UserInfoGet 当前登录用户信息token 校验后恢复 user 用)
type UserInfoGetReq struct {
g.Meta `path:"/user/info" method:"get" tags:"user" summary:"我的信息"`
}
type UserInfoGetRes = UserInfo
// ===== 工具目录 =====
type ModuleOut struct {
ModuleKey string `json:"module_key"`
Name string `json:"name"`
Icon string `json:"icon"`
Locked bool `json:"locked"`
EnabledToolsCount int `json:"enabled_tools_count"`
}
type ToolOut struct {
ToolKey string `json:"tool_key"`
ModuleKey string `json:"module_key"`
Name string `json:"name"`
Icon string `json:"icon"`
Description string `json:"description"`
IsHot bool `json:"is_hot"`
Locked bool `json:"locked"`
}
type ToolsListReq struct {
g.Meta `path:"/tools/list" method:"get" tags:"user" summary:"工具目录(含锁定态)"`
}
type ToolsListRes struct {
Modules []ModuleOut `json:"modules"`
List []ToolOut `json:"list"`
}
type ToolsUsedReq struct {
g.Meta `path:"/tools/used" method:"post" tags:"user" summary:"工具使用上报"`
ToolKey string `v:"required" json:"tool_key"`
}
type ToolsUsedRes struct{}
// ===== 工作台 =====
type WorkbenchGetReq struct {
g.Meta `path:"/workbench/get" method:"get" tags:"user" summary:"我的工作台"`
}
type WorkbenchGetRes struct {
ToolKeys []string `json:"tool_keys"`
}
type WorkbenchSaveReq struct {
g.Meta `path:"/workbench/save" method:"post" tags:"user" summary:"保存工作台"`
ToolKeys []string `v:"required" json:"tool_keys"`
}
type WorkbenchSaveRes struct{}
// ===== 反馈 =====
type FeedbackSubmitReq struct {
g.Meta `path:"/feedback/submit" method:"post" tags:"user" summary:"提交反馈"`
Content string `v:"required#反馈内容不能为空" json:"content"`
Contact string `json:"contact"`
}
type FeedbackSubmitRes struct{}

41
go.mod Normal file
View File

@@ -0,0 +1,41 @@
module tool-api
go 1.25.0
require (
github.com/gogf/gf/contrib/drivers/mysql/v2 v2.10.0
github.com/gogf/gf/v2 v2.10.0
github.com/golang-jwt/jwt/v5 v5.3.1
golang.org/x/crypto v0.55.0
)
require (
github.com/BurntSushi/toml v1.5.0 // indirect
github.com/clbanning/mxj/v2 v2.7.0 // indirect
github.com/emirpasic/gods/v2 v2.0.0-alpha // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-sql-driver/mysql v1.7.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/grokify/html-strip-tags-go v0.1.0 // indirect
github.com/magiconair/properties v1.8.10 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/olekukonko/errors v1.1.0 // indirect
github.com/olekukonko/ll v0.0.9 // indirect
github.com/olekukonko/tablewriter v1.1.0 // indirect
github.com/rivo/uniseg v0.2.0 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/otel v1.38.0 // indirect
go.opentelemetry.io/otel/metric v1.38.0 // indirect
go.opentelemetry.io/otel/sdk v1.38.0 // indirect
go.opentelemetry.io/otel/trace v1.38.0 // indirect
golang.org/x/net v0.57.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.41.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

89
go.sum Normal file
View File

@@ -0,0 +1,89 @@
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME=
github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/emirpasic/gods/v2 v2.0.0-alpha h1:dwFlh8pBg1VMOXWGipNMRt8v96dKAIvBehtCt6OtunU=
github.com/emirpasic/gods/v2 v2.0.0-alpha/go.mod h1:W0y4M2dtBB9U5z3YlghmpuUhiaZT2h6yoeE+C1sCp6A=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI=
github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/gogf/gf/contrib/drivers/mysql/v2 v2.10.0 h1:UvqxwinkelKxwdwnKUfdy51/ls4RL7MCeJqAZOVAy0I=
github.com/gogf/gf/contrib/drivers/mysql/v2 v2.10.0/go.mod h1:6v7oGBF9wv59WERJIOJxXmLhkUcxwON3tPYW3AZ7wbY=
github.com/gogf/gf/v2 v2.10.0 h1:rzDROlyqGMe/eM6dCalSR8dZOuMIdLhmxKSH1DGhbFs=
github.com/gogf/gf/v2 v2.10.0/go.mod h1:Svl1N+E8G/QshU2DUbh/3J/AJauqCgUnxHurXWR4Qx0=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grokify/html-strip-tags-go v0.1.0 h1:03UrQLjAny8xci+R+qjCce/MYnpNXCtgzltlQbOBae4=
github.com/grokify/html-strip-tags-go v0.1.0/go.mod h1:ZdzgfHEzAfz9X6Xe5eBLVblWIxXfYSQ40S/VKrAOGpc=
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/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
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-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/olekukonko/errors v1.1.0 h1:RNuGIh15QdDenh+hNvKrJkmxxjV4hcS50Db478Ou5sM=
github.com/olekukonko/errors v1.1.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y=
github.com/olekukonko/ll v0.0.9 h1:Y+1YqDfVkqMWuEQMclsF9HUR5+a82+dxJuL1HHSRpxI=
github.com/olekukonko/ll v0.0.9/go.mod h1:En+sEW0JNETl26+K8eZ6/W4UQ7CYSrrgg/EdIYT2H8g=
github.com/olekukonko/tablewriter v1.1.0 h1:N0LHrshF4T39KvI96fn6GT8HEjXRXYNDrDjKFDB7RIY=
github.com/olekukonko/tablewriter v1.1.0/go.mod h1:5c+EBPeSqvXnLLgkm9isDdzR3wjfBkHR9Nhfp3NWrzo=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

27
hack/config.yaml Normal file
View File

@@ -0,0 +1,27 @@
# ============================================================
# GoFrame CLI 工具配置仅开发环境使用gf 命令读取
# 文档https://goframe.org/docs/cli
# ============================================================
gfcli:
gen:
dao:
# 生成 dao/entity/do 的数据库连接
# 用法cd tool-api && gf gen dao
# 注意 manifest/config/config.yaml 的数据库保持同一套当前root/root@nl_toolbox
- link: "mysql:root:root@tcp(127.0.0.1:3306)/nl_toolbox"
# 实体字段附带数据库字段描述注释
descriptionTag: true
# 只生成指定表逗号分隔留空 = 全部表 "users,tools"
# tables: ""
# 去除表名公共前缀如表名 tb_users 生成 Users 时配 "tb_"
# removePrefix: ""
docker:
# docker 镜像构建参数
# -a 架构(amd64/arm64) -s 系统(linux) -p 临时目录前缀 -ew 排除文件
build: "-a amd64 -s linux -p temp -ew"
# 镜像 tag 前缀推送到私有仓库时改成自己的 registry
tagPrefixes:
- my.image.pub/my-app

18
hack/hack-cli.mk Normal file
View File

@@ -0,0 +1,18 @@
# Install/Update to the latest CLI tool.
.PHONY: cli
cli:
@set -e; \
echo "go install github.com/gogf/gf/cmd/gf/v2@latest"; \
go install github.com/gogf/gf/cmd/gf/v2@latest; \
echo "GoFame CLI installed successfully!"
# Check and install CLI tool.
.PHONY: cli.install
cli.install:
@set -e; \
gf -v > /dev/null 2>&1 || if [[ "$?" -ne "0" ]]; then \
echo "GoFame CLI is not installed, start proceeding auto installation..."; \
make cli; \
fi;

75
hack/hack.mk Normal file
View File

@@ -0,0 +1,75 @@
.DEFAULT_GOAL := build
# Update GoFrame and its CLI to latest stable version.
.PHONY: up
up: cli.install
@gf up -a
# Build binary using configuration from hack/config.yaml.
.PHONY: build
build: cli.install
@gf build -ew
# Parse api and generate controller/sdk.
.PHONY: ctrl
ctrl: cli.install
@gf gen ctrl
# Generate Go files for DAO/DO/Entity.
.PHONY: dao
dao: cli.install
@gf gen dao
# Parse current project go files and generate enums go file.
.PHONY: enums
enums: cli.install
@gf gen enums
# Generate Go files for Service.
.PHONY: service
service: cli.install
@gf gen service
# Build docker image.
.PHONY: image
image: cli.install
$(eval _TAG = $(shell git rev-parse --short HEAD))
ifneq (, $(shell git status --porcelain 2>/dev/null))
$(eval _TAG = $(_TAG).dirty)
endif
$(eval _TAG = $(if ${TAG}, ${TAG}, $(_TAG)))
$(eval _PUSH = $(if ${PUSH}, ${PUSH}, ))
@gf docker ${_PUSH} -tn $(DOCKER_NAME):${_TAG};
# Build docker image and automatically push to docker repo.
.PHONY: image.push
image.push: cli.install
@make image PUSH=-p;
# Deploy image and yaml to current kubectl environment.
.PHONY: deploy
deploy: cli.install
$(eval _TAG = $(if ${TAG}, ${TAG}, develop))
@set -e; \
mkdir -p $(ROOT_DIR)/temp/kustomize;\
cd $(ROOT_DIR)/manifest/deploy/kustomize/overlays/${_ENV};\
kustomize build > $(ROOT_DIR)/temp/kustomize.yaml;\
kubectl apply -f $(ROOT_DIR)/temp/kustomize.yaml; \
if [ $(DEPLOY_NAME) != "" ]; then \
kubectl patch -n $(NAMESPACE) deployment/$(DEPLOY_NAME) -p "{\"spec\":{\"template\":{\"metadata\":{\"labels\":{\"date\":\"$(shell date +%s)\"}}}}}"; \
fi;
# Parsing protobuf files and generating go files.
.PHONY: pb
pb: cli.install
@gf gen pb
# Generate protobuf files for database tables.
.PHONY: pbentity
pbentity: cli.install
@gf gen pbentity

44
internal/cmd/cmd.go Normal file
View File

@@ -0,0 +1,44 @@
package cmd
import (
"context"
_ "github.com/gogf/gf/contrib/drivers/mysql/v2"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
"github.com/gogf/gf/v2/os/gcmd"
"tool-api/internal/controller"
"tool-api/internal/logic"
)
var (
Main = gcmd.Command{
Name: "main",
Usage: "main",
Brief: "start http server",
Func: func(ctx context.Context, parser *gcmd.Parser) (err error) {
// 启动时幂等写入种子数据(管理员/模块/等级/工具),数据库不可用时仅告警
logic.Seed(ctx)
s := g.Server()
// 公开接口:小程序登录、开发登录、管理端登录
s.Group("/", func(group *ghttp.RouterGroup) {
group.Middleware(logic.CORS, logic.Response)
group.Bind(controller.UserPub, controller.AdminPub)
})
// 小程序用户接口
s.Group("/", func(group *ghttp.RouterGroup) {
group.Middleware(logic.CORS, logic.Response, logic.UserAuth)
group.Bind(controller.UserAuth)
})
// 管理端接口
s.Group("/", func(group *ghttp.RouterGroup) {
group.Middleware(logic.CORS, logic.Response, logic.AdminAuth)
group.Bind(controller.AdminAuth)
})
s.Run()
return nil
},
}
)

31
internal/consts/consts.go Normal file
View File

@@ -0,0 +1,31 @@
package consts
// JWT audience
const (
AudUser = "user"
AudAdmin = "admin"
)
// 请求上下文变量(中间件 SetCtxVar
const (
CtxUserId = "userId"
CtxLevelKey = "levelKey"
CtxAdminId = "adminId"
)
// 业务错误码(响应 code 字段)
const (
CodeLocked = 4001 // 模块未授权(等级锁定)
)
// 表名
const (
TableUsers = "users"
TableModules = "modules"
TableLevels = "levels"
TableTools = "tools"
TableUsageLogs = "tool_usage_logs"
TableWorkbench = "user_workbench"
TableAdminUsers = "admin_users"
TableFeedbacks = "feedbacks"
)

View File

@@ -0,0 +1,79 @@
package controller
import (
"context"
v1 "tool-api/api/admin/v1"
"tool-api/internal/logic"
)
// AdminPub 管理端公开接口(登录)
type cAdminPub struct{}
// AdminAuth 管理端需登录接口
type cAdminAuth struct{}
var (
AdminPub = &cAdminPub{}
AdminAuth = &cAdminAuth{}
)
func (c *cAdminPub) Login(ctx context.Context, req *v1.LoginReq) (res *v1.LoginRes, err error) {
return logic.AdminLogin(ctx, req.Account, req.Password)
}
func (c *cAdminAuth) MyInfo(ctx context.Context, req *v1.MyInfoReq) (res *v1.MyInfoRes, err error) {
return logic.AdminMyInfo(ctx)
}
func (c *cAdminAuth) UserList(ctx context.Context, req *v1.UserListReq) (res *v1.UserListRes, err error) {
return logic.AdminUserList(ctx, req)
}
func (c *cAdminAuth) UserSetLevel(ctx context.Context, req *v1.UserSetLevelReq) (res *v1.UserSetLevelRes, err error) {
return logic.AdminUserSetLevel(ctx, req.UserId, req.LevelKey)
}
func (c *cAdminAuth) UserStatus(ctx context.Context, req *v1.UserStatusReq) (res *v1.UserStatusRes, err error) {
return logic.AdminUserStatus(ctx, req.UserId, req.Status)
}
func (c *cAdminAuth) LevelList(ctx context.Context, req *v1.LevelListReq) (res *v1.LevelListRes, err error) {
return logic.AdminLevelList(ctx)
}
func (c *cAdminAuth) LevelSave(ctx context.Context, req *v1.LevelSaveReq) (res *v1.LevelSaveRes, err error) {
return logic.AdminLevelSave(ctx, req)
}
func (c *cAdminAuth) ModuleList(ctx context.Context, req *v1.ModuleListReq) (res *v1.ModuleListRes, err error) {
return logic.AdminModuleList(ctx)
}
func (c *cAdminAuth) ModuleSave(ctx context.Context, req *v1.ModuleSaveReq) (res *v1.ModuleSaveRes, err error) {
return logic.AdminModuleSave(ctx, req)
}
func (c *cAdminAuth) ToolsList(ctx context.Context, req *v1.ToolsListReq) (res *v1.ToolsListRes, err error) {
return logic.AdminToolsList(ctx, req)
}
func (c *cAdminAuth) ToolsSave(ctx context.Context, req *v1.ToolsSaveReq) (res *v1.ToolsSaveRes, err error) {
return logic.AdminToolsSave(ctx, req)
}
func (c *cAdminAuth) ToolsToggle(ctx context.Context, req *v1.ToolsToggleReq) (res *v1.ToolsToggleRes, err error) {
return logic.AdminToolsToggle(ctx, req.Id, req.Field, req.Value)
}
func (c *cAdminAuth) DashboardStats(ctx context.Context, req *v1.DashboardStatsReq) (res *v1.DashboardStatsRes, err error) {
return logic.AdminDashboardStats(ctx)
}
func (c *cAdminAuth) FeedbackList(ctx context.Context, req *v1.FeedbackListReq) (res *v1.FeedbackListRes, err error) {
return logic.AdminFeedbackList(ctx, req)
}
func (c *cAdminAuth) FeedbackHandle(ctx context.Context, req *v1.FeedbackHandleReq) (res *v1.FeedbackHandleRes, err error) {
return logic.AdminFeedbackHandle(ctx, req.Id)
}

View File

@@ -0,0 +1,71 @@
package controller
import (
"context"
v1 "tool-api/api/user/v1"
"tool-api/internal/logic"
)
// UserPub 公开接口(免登录)
type cUserPub struct{}
// UserAuth 需要用户登录的接口
type cUserAuth struct{}
var (
UserPub = &cUserPub{}
UserAuth = &cUserAuth{}
)
func (c *cUserPub) WxLogin(ctx context.Context, req *v1.WxLoginReq) (res *v1.WxLoginRes, err error) {
return logic.WxLogin(ctx, req.Code)
}
func (c *cUserPub) DevLogin(ctx context.Context, req *v1.DevLoginReq) (res *v1.DevLoginRes, err error) {
return logic.DevLogin(ctx)
}
func (c *cUserPub) Register(ctx context.Context, req *v1.RegisterReq) (res *v1.RegisterRes, err error) {
return logic.Register(ctx, req.Username, req.Password, req.Nickname)
}
func (c *cUserPub) AccountLogin(ctx context.Context, req *v1.AccountLoginReq) (res *v1.AccountLoginRes, err error) {
return logic.AccountLogin(ctx, req.Username, req.Password)
}
func (c *cUserAuth) BindAccount(ctx context.Context, req *v1.BindAccountReq) (res *v1.BindAccountRes, err error) {
return logic.BindAccount(ctx, req.Username, req.Password)
}
func (c *cUserAuth) ProfileUpdate(ctx context.Context, req *v1.ProfileUpdateReq) (res *v1.ProfileUpdateRes, err error) {
return logic.ProfileUpdate(ctx, req.Nickname, req.AvatarUrl)
}
func (c *cUserAuth) Level(ctx context.Context, req *v1.LevelReq) (res *v1.LevelRes, err error) {
return logic.UserLevel(ctx)
}
func (c *cUserAuth) UserInfoGet(ctx context.Context, req *v1.UserInfoGetReq) (res *v1.UserInfoGetRes, err error) {
return logic.GetUserInfo(ctx)
}
func (c *cUserAuth) ToolsList(ctx context.Context, req *v1.ToolsListReq) (res *v1.ToolsListRes, err error) {
return logic.ToolsList(ctx)
}
func (c *cUserAuth) ToolsUsed(ctx context.Context, req *v1.ToolsUsedReq) (res *v1.ToolsUsedRes, err error) {
return logic.ToolsUsed(ctx, req.ToolKey)
}
func (c *cUserAuth) WorkbenchGet(ctx context.Context, req *v1.WorkbenchGetReq) (res *v1.WorkbenchGetRes, err error) {
return logic.WorkbenchGet(ctx)
}
func (c *cUserAuth) WorkbenchSave(ctx context.Context, req *v1.WorkbenchSaveReq) (res *v1.WorkbenchSaveRes, err error) {
return logic.WorkbenchSave(ctx, req.ToolKeys)
}
func (c *cUserAuth) FeedbackSubmit(ctx context.Context, req *v1.FeedbackSubmitReq) (res *v1.FeedbackSubmitRes, err error) {
return logic.FeedbackSubmit(ctx, req.Content, req.Contact)
}

0
internal/dao/.gitkeep Normal file
View File

476
internal/logic/admin.go Normal file
View File

@@ -0,0 +1,476 @@
package logic
import (
"context"
"encoding/json"
"time"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"golang.org/x/crypto/bcrypt"
v1 "tool-api/api/admin/v1"
"tool-api/internal/consts"
)
// ===== 登录 =====
func AdminLogin(ctx context.Context, account, password string) (*v1.LoginRes, error) {
record, err := g.Model(consts.TableAdminUsers).Where("account", account).One()
if err != nil {
return nil, err
}
if record.IsEmpty() {
return nil, gerror.New("账号或密码错误")
}
if err = bcrypt.CompareHashAndPassword([]byte(record["password"].String()), []byte(password)); err != nil {
return nil, gerror.New("账号或密码错误")
}
nickName := record["nick_name"].String()
roleName := record["role_name"].String()
roleValue := record["role_value"].String()
token, err := IssueToken(ctx, consts.AudAdmin, record["id"].Int64(), map[string]string{
"nickName": nickName,
"roleName": roleName,
"roleValue": roleValue,
})
if err != nil {
return nil, err
}
return &v1.LoginRes{Token: token, NickName: nickName, RoleName: roleName, RoleValue: roleValue}, nil
}
func AdminMyInfo(ctx context.Context) (*v1.MyInfoRes, error) {
record, err := g.Model(consts.TableAdminUsers).Where("id", CtxAdminId(ctx)).One()
if err != nil {
return nil, err
}
if record.IsEmpty() {
return nil, gerror.NewCode(gcodeUnauthorized(), "管理员不存在")
}
return &v1.MyInfoRes{
Id: record["id"].Int64(),
Account: record["account"].String(),
NickName: record["nick_name"].String(),
RoleName: record["role_name"].String(),
RoleValue: record["role_value"].String(),
}, nil
}
// ===== 用户管理 =====
func AdminUserList(ctx context.Context, req *v1.UserListReq) (*v1.UserListRes, error) {
if req.Page <= 0 {
req.Page = 1
}
if req.PageSize <= 0 {
req.PageSize = 20
}
m := g.Model(consts.TableUsers)
if req.Keyword != "" {
kw := "%" + req.Keyword + "%"
m = m.Where("nickname LIKE ? OR openid LIKE ?", kw, kw)
}
total, err := m.Count()
if err != nil {
return nil, err
}
records, err := m.Page(req.Page, req.PageSize).Order("id desc").All()
if err != nil {
return nil, err
}
levelNames, err := levelNameMap(ctx)
if err != nil {
return nil, err
}
list := make([]v1.AdminUserItem, 0, len(records))
for _, r := range records {
item := v1.AdminUserItem{
Id: r["id"].Int64(),
Nickname: r["nickname"].String(),
AvatarUrl: r["avatar_url"].String(),
Openid: r["openid"].String(),
LevelKey: r["level_key"].String(),
LevelName: levelNames[r["level_key"].String()],
Status: r["status"].Int(),
CreatedAt: r["created_at"].String(),
}
list = append(list, item)
}
// 附加使用统计
if len(list) > 0 {
ids := make([]int64, 0, len(list))
for _, u := range list {
ids = append(ids, u.Id)
}
stats, err := g.Model(consts.TableUsageLogs).
Fields("user_id, COUNT(*) AS c, MAX(used_at) AS last_used").
WhereIn("user_id", ids).Group("user_id").All()
if err == nil {
statMap := map[int64]gdb.Record{}
for _, s := range stats {
statMap[s["user_id"].Int64()] = s
}
for i := range list {
if s, ok := statMap[list[i].Id]; ok {
list[i].UsageCount = s["c"].Int64()
list[i].LastUsedAt = s["last_used"].String()
}
}
}
}
return &v1.UserListRes{List: list, Total: total}, nil
}
func levelNameMap(ctx context.Context) (map[string]string, error) {
records, err := g.Model(consts.TableLevels).All()
if err != nil {
return nil, err
}
m := map[string]string{}
for _, r := range records {
m[r["level_key"].String()] = r["name"].String()
}
return m, nil
}
func AdminUserSetLevel(ctx context.Context, userId int64, levelKey string) (*v1.UserSetLevelRes, error) {
level, err := g.Model(consts.TableLevels).Where("level_key", levelKey).One()
if err != nil {
return nil, err
}
if level.IsEmpty() {
return nil, gerror.New("等级不存在")
}
if _, err = g.Model(consts.TableUsers).Where("id", userId).
Data(g.Map{"level_key": levelKey}).Update(); err != nil {
return nil, err
}
return &v1.UserSetLevelRes{}, nil
}
func AdminUserStatus(ctx context.Context, userId int64, status int) (*v1.UserStatusRes, error) {
if _, err := g.Model(consts.TableUsers).Where("id", userId).
Data(g.Map{"status": status}).Update(); err != nil {
return nil, err
}
return &v1.UserStatusRes{}, nil
}
// ===== 等级管理 =====
func AdminLevelList(ctx context.Context) (*v1.LevelListRes, error) {
records, err := g.Model(consts.TableLevels).Order("sort asc").All()
if err != nil {
return nil, err
}
list := make([]v1.LevelItem, 0, len(records))
for _, r := range records {
modules := []string{}
_ = json.Unmarshal([]byte(r["modules"].String()), &modules)
list = append(list, v1.LevelItem{
Id: r["id"].Int64(),
LevelKey: r["level_key"].String(),
Name: r["name"].String(),
Modules: modules,
Sort: r["sort"].Int(),
IsEnabled: r["is_enabled"].Int(),
Remark: r["remark"].String(),
})
}
return &v1.LevelListRes{List: list}, nil
}
func AdminLevelSave(ctx context.Context, req *v1.LevelSaveReq) (*v1.LevelSaveRes, error) {
modulesJson, _ := json.Marshal(req.Modules)
data := g.Map{
"name": req.Name,
"modules": string(modulesJson),
"sort": req.Sort,
"is_enabled": req.IsEnabled,
"remark": req.Remark,
}
if req.Id > 0 {
// 编辑level_key 只读,不修改
if _, err := g.Model(consts.TableLevels).Where("id", req.Id).Data(data).Update(); err != nil {
return nil, err
}
} else {
count, err := g.Model(consts.TableLevels).Where("level_key", req.LevelKey).Count()
if err != nil {
return nil, err
}
if count > 0 {
return nil, gerror.New("等级标识已存在")
}
data["level_key"] = req.LevelKey
if _, err := g.Model(consts.TableLevels).Data(data).Insert(); err != nil {
return nil, err
}
}
return &v1.LevelSaveRes{}, nil
}
// ===== 模块管理 =====
func AdminModuleList(ctx context.Context) (*v1.ModuleListRes, error) {
records, err := g.Model(consts.TableModules).Order("sort asc").All()
if err != nil {
return nil, err
}
counts, err := g.Model(consts.TableTools).Fields("module_key, COUNT(*) AS c").Group("module_key").All()
if err != nil {
return nil, err
}
countMap := map[string]int{}
for _, c := range counts {
countMap[c["module_key"].String()] = c["c"].Int()
}
list := make([]v1.ModuleItem, 0, len(records))
for _, r := range records {
key := r["module_key"].String()
list = append(list, v1.ModuleItem{
Id: r["id"].Int64(),
ModuleKey: key,
Name: r["name"].String(),
Icon: r["icon"].String(),
Description: r["description"].String(),
Sort: r["sort"].Int(),
IsEnabled: r["is_enabled"].Int(),
ToolCount: countMap[key],
})
}
return &v1.ModuleListRes{List: list}, nil
}
func AdminModuleSave(ctx context.Context, req *v1.ModuleSaveReq) (*v1.ModuleSaveRes, error) {
data := g.Map{
"name": req.Name,
"icon": req.Icon,
"description": req.Description,
"sort": req.Sort,
"is_enabled": req.IsEnabled,
}
if req.Id > 0 {
if _, err := g.Model(consts.TableModules).Where("id", req.Id).Data(data).Update(); err != nil {
return nil, err
}
} else {
count, err := g.Model(consts.TableModules).Where("module_key", req.ModuleKey).Count()
if err != nil {
return nil, err
}
if count > 0 {
return nil, gerror.New("模块标识已存在")
}
data["module_key"] = req.ModuleKey
if _, err := g.Model(consts.TableModules).Data(data).Insert(); err != nil {
return nil, err
}
}
return &v1.ModuleSaveRes{}, nil
}
// ===== 工具管理 =====
func AdminToolsList(ctx context.Context, req *v1.ToolsListReq) (*v1.ToolsListRes, error) {
if req.Page <= 0 {
req.Page = 1
}
if req.PageSize <= 0 {
req.PageSize = 50
}
m := g.Model(consts.TableTools)
if req.ModuleKey != "" {
m = m.Where("module_key", req.ModuleKey)
}
if req.Keyword != "" {
kw := "%" + req.Keyword + "%"
m = m.Where("name LIKE ? OR tool_key LIKE ?", kw, kw)
}
if !req.IncludeDisabled {
m = m.Where("is_enabled", 1)
}
total, err := m.Count()
if err != nil {
return nil, err
}
records, err := m.Page(req.Page, req.PageSize).Order("sort asc").All()
if err != nil {
return nil, err
}
moduleNames, err := moduleNameMap(ctx)
if err != nil {
return nil, err
}
list := make([]v1.AdminToolItem, 0, len(records))
for _, r := range records {
mk := r["module_key"].String()
list = append(list, v1.AdminToolItem{
Id: r["id"].Int64(),
ToolKey: r["tool_key"].String(),
ModuleKey: mk,
ModuleName: moduleNames[mk],
Name: r["name"].String(),
Icon: r["icon"].String(),
Description: r["description"].String(),
Sort: r["sort"].Int(),
IsEnabled: r["is_enabled"].Int(),
IsHot: r["is_hot"].Int(),
UsageCount: r["usage_count"].Int64(),
})
}
return &v1.ToolsListRes{List: list, Total: total}, nil
}
func moduleNameMap(ctx context.Context) (map[string]string, error) {
records, err := g.Model(consts.TableModules).All()
if err != nil {
return nil, err
}
m := map[string]string{}
for _, r := range records {
m[r["module_key"].String()] = r["name"].String()
}
return m, nil
}
func AdminToolsSave(ctx context.Context, req *v1.ToolsSaveReq) (*v1.ToolsSaveRes, error) {
data := g.Map{
"module_key": req.ModuleKey,
"name": req.Name,
"icon": req.Icon,
"description": req.Description,
"sort": req.Sort,
"is_enabled": req.IsEnabled,
"is_hot": req.IsHot,
}
if req.Id > 0 {
// 编辑tool_key 只读,不修改
if _, err := g.Model(consts.TableTools).Where("id", req.Id).Data(data).Update(); err != nil {
return nil, err
}
} else {
count, err := g.Model(consts.TableTools).Where("tool_key", req.ToolKey).Count()
if err != nil {
return nil, err
}
if count > 0 {
return nil, gerror.New("工具标识已存在")
}
data["tool_key"] = req.ToolKey
if _, err := g.Model(consts.TableTools).Data(data).Insert(); err != nil {
return nil, err
}
}
return &v1.ToolsSaveRes{}, nil
}
func AdminToolsToggle(ctx context.Context, id int64, field string, value int) (*v1.ToolsToggleRes, error) {
if field != "is_enabled" && field != "is_hot" {
return nil, gerror.New("不支持的字段")
}
if _, err := g.Model(consts.TableTools).Where("id", id).Data(g.Map{field: value}).Update(); err != nil {
return nil, err
}
return &v1.ToolsToggleRes{}, nil
}
// ===== 数据看板 =====
func AdminDashboardStats(ctx context.Context) (*v1.DashboardStatsRes, error) {
res := &v1.DashboardStatsRes{WeekTrend: []v1.TrendItem{}, ToolsTop: []v1.TopItem{}}
if c, err := g.Model(consts.TableUsers).Count(); err == nil {
res.UserCount = int64(c)
}
if c, err := g.Model(consts.TableUsageLogs).Count(); err == nil {
res.TotalUsage = int64(c)
}
// 今日活跃:今天有使用记录或登录过的用户数
record, err := g.DB().GetOne(ctx,
"SELECT COUNT(*) AS c FROM users u WHERE DATE(u.last_login_at) = CURDATE() "+
"OR EXISTS (SELECT 1 FROM tool_usage_logs l WHERE l.user_id = u.id AND DATE(l.used_at) = CURDATE())")
if err == nil && !record.IsEmpty() {
res.TodayActive = record["c"].Int64()
}
// 7 日使用趋势(缺失日期补 0
trend, err := g.DB().GetAll(ctx,
"SELECT DATE(used_at) AS d, COUNT(*) AS c FROM tool_usage_logs "+
"WHERE used_at >= DATE_SUB(CURDATE(), INTERVAL 6 DAY) GROUP BY d")
if err == nil {
countByDate := map[string]int64{}
for _, r := range trend {
countByDate[r["d"].String()] = r["c"].Int64()
}
for i := 6; i >= 0; i-- {
day := time.Now().AddDate(0, 0, -i).Format("2006-01-02")
res.WeekTrend = append(res.WeekTrend, v1.TrendItem{Date: day, Count: countByDate[day]})
}
}
// 工具使用 TOP10
top, err := g.DB().GetAll(ctx,
"SELECT tool_key, COUNT(*) AS c FROM tool_usage_logs GROUP BY tool_key ORDER BY c DESC LIMIT 10")
if err == nil {
names, _ := toolNameMap(ctx)
for _, r := range top {
key := r["tool_key"].String()
res.ToolsTop = append(res.ToolsTop, v1.TopItem{ToolKey: key, Name: names[key], Count: r["c"].Int64()})
}
}
return res, nil
}
func toolNameMap(ctx context.Context) (map[string]string, error) {
records, err := g.Model(consts.TableTools).All()
if err != nil {
return nil, err
}
m := map[string]string{}
for _, r := range records {
m[r["tool_key"].String()] = r["name"].String()
}
return m, nil
}
// ===== 反馈管理 =====
func AdminFeedbackList(ctx context.Context, req *v1.FeedbackListReq) (*v1.FeedbackListRes, error) {
if req.Page <= 0 {
req.Page = 1
}
if req.PageSize <= 0 {
req.PageSize = 20
}
m := g.Model(consts.TableFeedbacks)
if req.Status != nil {
m = m.Where("status", *req.Status)
}
total, err := m.Count()
if err != nil {
return nil, err
}
records, err := m.Page(req.Page, req.PageSize).Order("id desc").All()
if err != nil {
return nil, err
}
list := make([]v1.FeedbackItem, 0, len(records))
for _, r := range records {
list = append(list, v1.FeedbackItem{
Id: r["id"].Int64(),
UserId: r["user_id"].Int64(),
Content: r["content"].String(),
Contact: r["contact"].String(),
Status: r["status"].Int(),
CreatedAt: r["created_at"].String(),
})
}
return &v1.FeedbackListRes{List: list, Total: total}, nil
}
func AdminFeedbackHandle(ctx context.Context, id int64) (*v1.FeedbackHandleRes, error) {
if _, err := g.Model(consts.TableFeedbacks).Where("id", id).Data(g.Map{"status": 1}).Update(); err != nil {
return nil, err
}
return &v1.FeedbackHandleRes{}, nil
}

121
internal/logic/jwt.go Normal file
View File

@@ -0,0 +1,121 @@
package logic
import (
"context"
"errors"
"strings"
"time"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
"github.com/gogf/gf/v2/os/gctx"
"github.com/golang-jwt/jwt/v5"
"tool-api/internal/consts"
)
type jwtClaims struct {
UserId int64 `json:"userId,omitempty"`
LevelKey string `json:"levelKey,omitempty"`
NickName string `json:"nickName,omitempty"`
RoleName string `json:"roleName,omitempty"`
RoleValue string `json:"roleValue,omitempty"`
jwt.RegisteredClaims
}
func jwtSecret(ctx context.Context) []byte {
return []byte(g.Cfg().MustGet(ctx, "jwt.secret", "tool-box-secret-change-me").String())
}
// IssueToken 签发 JWTaud 区分 user / admin
func IssueToken(ctx context.Context, aud string, userId int64, extra map[string]string) (string, error) {
hours := g.Cfg().MustGet(ctx, "jwt.expireHours", 168).Int()
claims := jwtClaims{
UserId: userId,
RegisteredClaims: jwt.RegisteredClaims{
Audience: jwt.ClaimStrings{aud},
Issuer: "tool-api",
IssuedAt: jwt.NewNumericDate(time.Now()),
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(hours) * time.Hour)),
},
}
for k, v := range extra {
switch k {
case "levelKey":
claims.LevelKey = v
case "nickName":
claims.NickName = v
case "roleName":
claims.RoleName = v
case "roleValue":
claims.RoleValue = v
}
}
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(jwtSecret(ctx))
}
func ParseToken(tokenStr, aud string) (*jwtClaims, error) {
claims := &jwtClaims{}
_, err := jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (interface{}, error) {
return jwtSecret(gctx.GetInitCtx()), nil
}, jwt.WithValidMethods([]string{"HS256"}), jwt.WithAudience(aud))
if err != nil {
return nil, err
}
return claims, nil
}
func parseBearer(r *ghttp.Request, aud string) (*jwtClaims, error) {
auth := r.Header.Get("Authorization")
tokenStr := strings.TrimPrefix(auth, "Bearer ")
if tokenStr == "" {
return nil, errors.New("empty token")
}
return ParseToken(tokenStr, aud)
}
func writeAuthFail(r *ghttp.Request) {
r.Response.WriteJson(g.Map{"code": 401, "message": "登录已失效,请重新登录", "result": nil})
r.Exit()
}
// UserAuth 小程序用户鉴权
func UserAuth(r *ghttp.Request) {
claims, err := parseBearer(r, consts.AudUser)
if err != nil {
writeAuthFail(r)
return
}
r.SetCtxVar(consts.CtxUserId, claims.UserId)
r.SetCtxVar(consts.CtxLevelKey, claims.LevelKey)
r.Middleware.Next()
}
// AdminAuth 管理端鉴权
func AdminAuth(r *ghttp.Request) {
claims, err := parseBearer(r, consts.AudAdmin)
if err != nil {
writeAuthFail(r)
return
}
r.SetCtxVar(consts.CtxAdminId, claims.UserId)
r.Middleware.Next()
}
// ===== 业务上下文取值 =====
func CtxUserId(ctx context.Context) int64 {
return g.RequestFromCtx(ctx).GetCtxVar(consts.CtxUserId).Int64()
}
func CtxLevelKey(ctx context.Context) string {
return g.RequestFromCtx(ctx).GetCtxVar(consts.CtxLevelKey).String()
}
func CtxAdminId(ctx context.Context) int64 {
return g.RequestFromCtx(ctx).GetCtxVar(consts.CtxAdminId).Int64()
}
func IsDebug(ctx context.Context) bool {
return g.Cfg().MustGet(ctx, "debug", false).Bool()
}

View File

@@ -0,0 +1,44 @@
package logic
import (
"github.com/gogf/gf/v2/errors/gcode"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
)
// CORS 允许跨域(管理后台与 H5 联调)
func CORS(r *ghttp.Request) {
r.Response.CORSDefault()
if r.Method == "OPTIONS" {
r.Exit()
}
r.Middleware.Next()
}
// Response 统一响应封装:{code, message, result}
// 前端约定code=0 成功401 未登录4001 模块未授权。
func Response(r *ghttp.Request) {
r.Middleware.Next()
// 鉴权中间件等已直接写响应(如 401不再二次包装
if r.Response.BufferLength() > 0 {
return
}
var (
err = r.GetError()
res = r.GetHandlerResponse()
)
if err != nil {
code := gerror.Code(err)
codeInt := code.Code()
if code == gcode.CodeNil {
codeInt = 500
} else if code == gcode.CodeValidationFailed {
codeInt = 422
}
r.Response.WriteJson(g.Map{"code": codeInt, "message": err.Error(), "result": nil})
return
}
r.Response.WriteJson(g.Map{"code": 0, "message": "ok", "result": res})
}

100
internal/logic/seed.go Normal file
View File

@@ -0,0 +1,100 @@
package logic
import (
"context"
"github.com/gogf/gf/v2/frame/g"
"golang.org/x/crypto/bcrypt"
"tool-api/internal/consts"
)
// Seed 启动时幂等写入种子数据:管理员、模块、等级、工具目录。
// 数据库未就绪时仅告警,不阻断服务启动。
func Seed(ctx context.Context) {
if err := seedAdmin(ctx); err != nil {
g.Log().Warning(ctx, "[seed] admin:", err)
}
if err := seedRows(ctx, consts.TableModules, "module_key", seedModules()); err != nil {
g.Log().Warning(ctx, "[seed] modules:", err)
}
if err := seedRows(ctx, consts.TableLevels, "level_key", seedLevels()); err != nil {
g.Log().Warning(ctx, "[seed] levels:", err)
}
if err := seedRows(ctx, consts.TableTools, "tool_key", seedTools()); err != nil {
g.Log().Warning(ctx, "[seed] tools:", err)
}
}
func seedAdmin(ctx context.Context) error {
count, err := g.Model(consts.TableAdminUsers).Count()
if err != nil {
return err
}
if count > 0 {
return nil
}
hash, err := bcrypt.GenerateFromPassword([]byte("admin123"), bcrypt.DefaultCost)
if err != nil {
return err
}
_, err = g.Model(consts.TableAdminUsers).Data(g.Map{
"account": "admin",
"password": string(hash),
"nick_name": "管理员",
"role_name": "超级管理员",
"role_value": "super",
}).Insert()
return err
}
// seedRows 按 uniqueKey 幂等插入(存在即跳过该行)
func seedRows(ctx context.Context, table, uniqueKey string, rows []g.Map) error {
for _, row := range rows {
count, err := g.Model(table).Where(uniqueKey, row[uniqueKey]).Count()
if err != nil {
return err
}
if count > 0 {
continue
}
if _, err := g.Model(table).Data(row).Insert(); err != nil {
return err
}
}
return nil
}
func seedModules() []g.Map {
return []g.Map{
{"module_key": "image", "name": "图片处理", "icon": "picture", "description": "压缩、转换、抠图等图片工具", "sort": 1},
{"module_key": "doc", "name": "文档处理", "icon": "description", "description": "文档转换与处理(规划中)", "sort": 2},
{"module_key": "calc", "name": "计算换算", "icon": "computer", "description": "单位换算、房贷计算等", "sort": 3},
{"module_key": "time", "name": "时间效率", "icon": "time", "description": "番茄钟、倒数日等", "sort": 4},
{"module_key": "create", "name": "生成制作", "icon": "edit-outline", "description": "二维码、随机决定等", "sort": 5},
{"module_key": "life", "name": "生活查询", "icon": "home", "description": "日期计算、汇率等", "sort": 6},
}
}
func seedLevels() []g.Map {
return []g.Map{
{"level_key": "v1", "name": "基础版", "modules": `["image"]`, "sort": 1, "remark": "默认等级:仅图片处理模块"},
{"level_key": "v2", "name": "进阶版", "modules": `["image","doc"]`, "sort": 2, "remark": "示例:追加文档处理模块"},
}
}
func seedTools() []g.Map {
return []g.Map{
{"tool_key": "img-compress", "module_key": "image", "name": "图片压缩", "icon": "picture", "description": "批量压缩图片体积,画质几乎无损", "sort": 1, "is_hot": 1},
{"tool_key": "img-convert", "module_key": "image", "name": "格式转换", "icon": "picture", "description": "PNG / JPG / WebP 互转", "sort": 2, "is_hot": 1},
{"tool_key": "img-resize", "module_key": "image", "name": "修改尺寸", "icon": "picture", "description": "按宽高、比例或最长边缩放", "sort": 3},
{"tool_key": "img-crop", "module_key": "image", "name": "裁剪旋转", "icon": "picture", "description": "自由裁剪、旋转与镜像", "sort": 4},
{"tool_key": "img-watermark", "module_key": "image", "name": "文字水印", "icon": "picture", "description": "给图片添加平铺或居中水印", "sort": 5},
{"tool_key": "img-matting", "module_key": "image", "name": "去除背景", "icon": "picture", "description": "一键抠除纯色背景", "sort": 6, "is_hot": 1},
{"tool_key": "img-avatarfx", "module_key": "image", "name": "头像特效", "icon": "picture", "description": "国庆红旗圈、节日边框等头像特效制作", "sort": 7, "is_hot": 1},
{"tool_key": "img-grid9", "module_key": "image", "name": "九宫格切图", "icon": "picture", "description": "照片切成 9 张发朋友圈", "sort": 8},
{"tool_key": "img-collage", "module_key": "image", "name": "图片拼图", "icon": "picture", "description": "2-4 张照片拼成一张", "sort": 9},
{"tool_key": "img-qrcode", "module_key": "image", "name": "二维码生成", "icon": "picture", "description": "文本链接生成二维码图片", "sort": 10},
{"tool_key": "img-annotate", "module_key": "image", "name": "图片标注", "icon": "picture", "description": "涂鸦、文字、马赛克标注", "sort": 11},
}
}

197
internal/logic/tools.go Normal file
View File

@@ -0,0 +1,197 @@
package logic
import (
"context"
"encoding/json"
"github.com/gogf/gf/v2/errors/gcode"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
v1 "tool-api/api/user/v1"
"tool-api/internal/consts"
)
func gcodeUnauthorized() gcode.Code {
return gcode.New(401, "", nil)
}
func gcodeLocked() gcode.Code {
return gcode.New(consts.CodeLocked, "", nil)
}
// ToolsList 工具目录(含模块与工具的锁定态)
func ToolsList(ctx context.Context) (*v1.ToolsListRes, error) {
user, err := getUserById(ctx, CtxUserId(ctx))
if err != nil {
return nil, err
}
level, err := GetLevelByKey(ctx, user.LevelKey)
if err != nil {
return nil, err
}
toolRecords, err := g.Model(consts.TableTools).Where("is_enabled", 1).Order("sort asc").All()
if err != nil {
return nil, err
}
modRecords, err := g.Model(consts.TableModules).Where("is_enabled", 1).Order("sort asc").All()
if err != nil {
return nil, err
}
res := &v1.ToolsListRes{Modules: []v1.ModuleOut{}, List: []v1.ToolOut{}}
countByModule := map[string]int{}
for _, t := range toolRecords {
moduleKey := t["module_key"].String()
countByModule[moduleKey]++
res.List = append(res.List, v1.ToolOut{
ToolKey: t["tool_key"].String(),
ModuleKey: moduleKey,
Name: t["name"].String(),
Icon: t["icon"].String(),
Description: t["description"].String(),
IsHot: t["is_hot"].Int() == 1,
Locked: !slicesContain(level.Modules, moduleKey),
})
}
for _, m := range modRecords {
moduleKey := m["module_key"].String()
res.Modules = append(res.Modules, v1.ModuleOut{
ModuleKey: moduleKey,
Name: m["name"].String(),
Icon: m["icon"].String(),
Locked: !slicesContain(level.Modules, moduleKey),
EnabledToolsCount: countByModule[moduleKey],
})
}
return res, nil
}
func slicesContain(list []string, s string) bool {
for _, v := range list {
if v == s {
return true
}
}
return false
}
// ToolsUsed 工具使用上报(锁定/下架时拒绝)
func ToolsUsed(ctx context.Context, toolKey string) (*v1.ToolsUsedRes, error) {
user, err := getUserById(ctx, CtxUserId(ctx))
if err != nil {
return nil, err
}
level, err := GetLevelByKey(ctx, user.LevelKey)
if err != nil {
return nil, err
}
tool, err := g.Model(consts.TableTools).Where("tool_key", toolKey).Where("is_enabled", 1).One()
if err != nil {
return nil, err
}
if tool.IsEmpty() {
return nil, gerror.New("工具不存在或已下架")
}
if !slicesContain(level.Modules, tool["module_key"].String()) {
return nil, gerror.NewCode(gcodeLocked(), "该功能需要升级等级后使用")
}
if _, err = g.Model(consts.TableUsageLogs).Data(g.Map{
"user_id": user.Id,
"tool_key": toolKey,
"used_at": gtime.Now(),
}).Insert(); err != nil {
return nil, err
}
if _, err = g.Model(consts.TableTools).Where("tool_key", toolKey).
Increment("usage_count", 1); err != nil {
return nil, err
}
return &v1.ToolsUsedRes{}, nil
}
// WorkbenchGet 我的工作台;未配置时返回热推工具作为默认组合
func WorkbenchGet(ctx context.Context) (*v1.WorkbenchGetRes, error) {
user, err := getUserById(ctx, CtxUserId(ctx))
if err != nil {
return nil, err
}
record, err := g.Model(consts.TableWorkbench).Where("user_id", user.Id).One()
if err != nil {
return nil, err
}
if !record.IsEmpty() {
keys := []string{}
_ = json.Unmarshal([]byte(record["tool_keys"].String()), &keys)
return &v1.WorkbenchGetRes{ToolKeys: keys}, nil
}
// 首次进入:预置已授权模块下的热推工具
level, err := GetLevelByKey(ctx, user.LevelKey)
if err != nil {
return nil, err
}
hot, err := g.Model(consts.TableTools).Where("is_enabled", 1).Where("is_hot", 1).Order("sort asc").All()
if err != nil {
return nil, err
}
keys := make([]string, 0, 4)
for _, t := range hot {
if slicesContain(level.Modules, t["module_key"].String()) {
keys = append(keys, t["tool_key"].String())
}
}
return &v1.WorkbenchGetRes{ToolKeys: keys}, nil
}
// WorkbenchSave 保存工作台(校验启用 + 已授权)
func WorkbenchSave(ctx context.Context, toolKeys []string) (*v1.WorkbenchSaveRes, error) {
user, err := getUserById(ctx, CtxUserId(ctx))
if err != nil {
return nil, err
}
level, err := GetLevelByKey(ctx, user.LevelKey)
if err != nil {
return nil, err
}
if len(toolKeys) == 0 {
return nil, gerror.New("工作台至少保留一个工具")
}
for _, key := range toolKeys {
tool, err := g.Model(consts.TableTools).Where("tool_key", key).Where("is_enabled", 1).One()
if err != nil {
return nil, err
}
if tool.IsEmpty() {
return nil, gerror.Newf("工具 %s 不存在或已下架", key)
}
if !slicesContain(level.Modules, tool["module_key"].String()) {
return nil, gerror.NewCode(gcodeLocked(), "包含未解锁的工具,请升级等级")
}
}
jsonStr, err := json.Marshal(toolKeys)
if err != nil {
return nil, err
}
if _, err = g.Model(consts.TableWorkbench).Data(g.Map{
"user_id": user.Id,
"tool_keys": string(jsonStr),
"updated_at": gtime.Now(),
}).Save(); err != nil {
return nil, err
}
return &v1.WorkbenchSaveRes{}, nil
}
// FeedbackSubmit 提交反馈
func FeedbackSubmit(ctx context.Context, content, contact string) (*v1.FeedbackSubmitRes, error) {
if _, err := g.Model(consts.TableFeedbacks).Data(g.Map{
"user_id": CtxUserId(ctx),
"content": content,
"contact": contact,
}).Insert(); err != nil {
return nil, err
}
return &v1.FeedbackSubmitRes{}, nil
}

341
internal/logic/user.go Normal file
View File

@@ -0,0 +1,341 @@
package logic
import (
"context"
"encoding/json"
"fmt"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
"github.com/gogf/gf/v2/util/guid"
"golang.org/x/crypto/bcrypt"
v1 "tool-api/api/user/v1"
"tool-api/internal/consts"
"tool-api/internal/model/entity"
)
// WxLogin 微信登录code 换 openid
func WxLogin(ctx context.Context, code string) (*v1.WxLoginRes, error) {
openid, err := resolveOpenid(ctx, code)
if err != nil {
return nil, err
}
return loginByOpenid(ctx, openid)
}
// DevLogin 开发联调登录(仅 debug 配置开放)
func DevLogin(ctx context.Context) (*v1.WxLoginRes, error) {
if !IsDebug(ctx) {
return nil, gerror.New("当前环境未开启 debug禁止开发登录")
}
return loginByOpenid(ctx, "dev-user")
}
// UserLevel 我的等级与已授权模块
func UserLevel(ctx context.Context) (*v1.LevelRes, error) {
user, err := getUserById(ctx, CtxUserId(ctx))
if err != nil {
return nil, err
}
return GetLevelByKey(ctx, user.LevelKey)
}
// GetUserInfo 当前登录用户信息
func GetUserInfo(ctx context.Context) (*v1.UserInfo, error) {
user, err := getUserById(ctx, CtxUserId(ctx))
if err != nil {
return nil, err
}
return &v1.UserInfo{Id: user.Id, Nickname: user.Nickname, AvatarUrl: user.AvatarUrl}, nil
}
func resolveOpenid(ctx context.Context, code string) (string, error) {
// 联调快捷通道debug 模式下 code 传 "dev"
if code == "dev" && IsDebug(ctx) {
return "dev-user", nil
}
appId := g.Cfg().MustGet(ctx, "wx.appId").String()
secret := g.Cfg().MustGet(ctx, "wx.appSecret").String()
if appId == "" || secret == "" {
return "", gerror.New("服务端未配置微信 appId/appSecret请填写 manifest/config/config.yaml 后重启")
}
url := fmt.Sprintf(
"https://api.weixin.qq.com/sns/jscode2session?appid=%s&secret=%s&js_code=%s&grant_type=authorization_code",
appId, secret, code,
)
resp, err := g.Client().Get(ctx, url)
if err != nil {
return "", gerror.Newf("请求微信接口失败: %v", err)
}
defer resp.Close()
var out struct {
Openid string `json:"openid"`
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
if err = json.Unmarshal(resp.ReadAll(), &out); err != nil {
return "", gerror.Newf("解析微信响应失败: %v", err)
}
if out.ErrCode != 0 || out.Openid == "" {
return "", gerror.Newf("微信登录失败: %d %s", out.ErrCode, out.ErrMsg)
}
return out.Openid, nil
}
func loginByOpenid(ctx context.Context, openid string) (*v1.WxLoginRes, error) {
// 原子 upsert原生 SQL小程序启动时 onLaunch 与页面 onShow 会并发各发一次登录,
// 冲突时仅更新登录时间。不依赖 ORM 的 OnDuplicate 拼接,行为跨版本稳定。
now := gtime.Now()
if _, err := g.DB().Exec(ctx,
"INSERT INTO `users`(`openid`, `nickname`, `level_key`, `status`, `created_at`, `last_login_at`, `updated_at`) "+
"VALUES(?, ?, ?, ?, ?, ?, ?) "+
"ON DUPLICATE KEY UPDATE `last_login_at` = VALUES(`last_login_at`)",
openid, "微信用户", "v1", 1, now, now, now,
); err != nil {
return nil, err
}
record, err := g.Model(consts.TableUsers).Where("openid", openid).One()
if err != nil {
return nil, err
}
user := &entity.Users{}
if err = record.Struct(user); err != nil {
return nil, err
}
if user.Status != 1 {
return nil, gerror.New("账号已被禁用,请联系管理员")
}
return loginResult(ctx, user)
}
// ===== H5 账号:注册 / 登录 / 绑定合并 =====
// placeholderOpenid H5 独立账号的 openid 占位openid 列 NOT NULL UNIQUE微信 openid 不会以 h5- 开头)
func placeholderOpenid() string {
return "h5-" + guid.S()
}
// Register H5 独立账号注册(注册即登录)
func Register(ctx context.Context, username, password, nickname string) (*v1.WxLoginRes, error) {
count, err := g.Model(consts.TableUsers).Where("username", username).Count()
if err != nil {
return nil, err
}
if count > 0 {
return nil, gerror.New("用户名已被占用")
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return nil, err
}
if nickname == "" {
nickname = username
}
now := gtime.Now()
if _, err = g.Model(consts.TableUsers).Data(g.Map{
"openid": placeholderOpenid(),
"username": username,
"password_hash": string(hash),
"nickname": nickname,
"level_key": "v1",
"status": 1,
"created_at": now,
"last_login_at": now,
}).Insert(); err != nil {
return nil, err
}
return loginByUsername(ctx, username, password)
}
// AccountLogin H5 账号密码登录
func AccountLogin(ctx context.Context, username, password string) (*v1.WxLoginRes, error) {
return loginByUsername(ctx, username, password)
}
func loginByUsername(ctx context.Context, username, password string) (*v1.WxLoginRes, error) {
record, err := g.Model(consts.TableUsers).Where("username", username).One()
if err != nil {
return nil, err
}
if record.IsEmpty() {
return nil, gerror.New("用户名或密码错误")
}
user := &entity.Users{}
if err = record.Struct(user); err != nil {
return nil, err
}
if user.PasswordHash == "" || bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)) != nil {
return nil, gerror.New("用户名或密码错误")
}
if user.Status != 1 {
return nil, gerror.New("账号已被禁用,请联系管理员")
}
// 更新登录时间fire-and-forget 语义,失败不影响登录)
_, _ = g.Model(consts.TableUsers).Where("id", user.Id).Data(g.Map{"last_login_at": gtime.Now()}).Update()
return loginResult(ctx, user)
}
// BindAccount 把一个 H5 独立账号合并进当前登录账号:
// 迁移其工作台配置 → 将用户名/密码挂到当前账号 → 目标账号禁用保留历史。
// 合并后两端使用同一份用户数据(工作台/等级/反馈)。
func BindAccount(ctx context.Context, username, password string) (*v1.BindAccountRes, error) {
current, err := getUserById(ctx, CtxUserId(ctx))
if err != nil {
return nil, err
}
record, err := g.Model(consts.TableUsers).Where("username", username).One()
if err != nil {
return nil, err
}
if record.IsEmpty() {
return nil, gerror.New("该账号不存在,请先在 H5 端注册")
}
target := &entity.Users{}
if err = record.Struct(target); err != nil {
return nil, err
}
// 已是当前账号:视为重复操作
if target.Id == current.Id {
return nil, gerror.New("该账号已是当前账号")
}
// 只允许合并 H5 独立账号openid 为 h5- 占位),微信账号不可被合并
if target.Openid == current.Openid || !(len(target.Openid) > 3 && target.Openid[:3] == "h5-") {
return nil, gerror.New("该账号已绑定微信,无法合并")
}
if target.PasswordHash == "" || bcrypt.CompareHashAndPassword([]byte(target.PasswordHash), []byte(password)) != nil {
return nil, gerror.New("用户名或密码错误")
}
if current.Username != "" {
return nil, gerror.New("当前账号已绑定过 H5 账号")
}
// 迁移工作台:目标 keys 追加到当前 keys 之后(去重,上限 20
targetKeys := workbenchKeysOf(ctx, target.Id)
if len(targetKeys) > 0 {
currentKeys := workbenchKeysOf(ctx, current.Id)
seen := map[string]bool{}
merged := make([]string, 0, len(currentKeys)+len(targetKeys))
for _, k := range append(append([]string{}, currentKeys...), targetKeys...) {
if !seen[k] {
seen[k] = true
merged = append(merged, k)
}
}
if len(merged) > 20 {
merged = merged[:20]
}
if err = saveWorkbenchRaw(ctx, current.Id, merged); err != nil {
return nil, err
}
}
// 先释放目标账号的用户名username 可空,唯一索引不冲突),并禁用保留历史
if _, err = g.Model(consts.TableUsers).Where("id", target.Id).Data(g.Map{
"username": nil,
"status": 0,
}).Update(); err != nil {
return nil, err
}
// 凭证挂到当前账号
if _, err = g.Model(consts.TableUsers).Where("id", current.Id).Data(g.Map{
"username": username,
"password_hash": target.PasswordHash,
}).Update(); err != nil {
return nil, err
}
return &v1.BindAccountRes{Username: username}, nil
}
// ProfileUpdate 更新当前用户昵称 / 头像
func ProfileUpdate(ctx context.Context, nickname, avatarUrl string) (*v1.ProfileUpdateRes, error) {
userId := CtxUserId(ctx)
if _, err := g.Model(consts.TableUsers).Where("id", userId).Data(g.Map{
"nickname": nickname,
"avatar_url": avatarUrl,
}).Update(); err != nil {
return nil, err
}
return &v1.ProfileUpdateRes{Nickname: nickname, AvatarUrl: avatarUrl}, nil
}
func workbenchKeysOf(ctx context.Context, userId int64) []string {
record, err := g.Model(consts.TableWorkbench).Where("user_id", userId).One()
if err != nil || record.IsEmpty() {
return nil
}
keys := []string{}
_ = json.Unmarshal([]byte(record["tool_keys"].String()), &keys)
return keys
}
// saveWorkbenchRaw 直接写入工作台配置(绑定合并用,不做逐个工具校验)
func saveWorkbenchRaw(ctx context.Context, userId int64, keys []string) error {
jsonStr, err := json.Marshal(keys)
if err != nil {
return err
}
_, err = g.Model(consts.TableWorkbench).Data(g.Map{
"user_id": userId,
"tool_keys": string(jsonStr),
"updated_at": gtime.Now(),
}).Save()
return err
}
// loginResult 统一组装登录返回token + 用户 + 等级)
func loginResult(ctx context.Context, user *entity.Users) (*v1.WxLoginRes, error) {
level, err := GetLevelByKey(ctx, user.LevelKey)
if err != nil {
return nil, err
}
token, err := IssueToken(ctx, consts.AudUser, user.Id, map[string]string{"levelKey": user.LevelKey})
if err != nil {
return nil, err
}
return &v1.WxLoginRes{
Token: token,
User: &v1.UserInfo{Id: user.Id, Nickname: user.Nickname, AvatarUrl: user.AvatarUrl},
Level: level,
}, nil
}
func getUserById(ctx context.Context, id int64) (*entity.Users, error) {
record, err := g.Model(consts.TableUsers).Where("id", id).One()
if err != nil {
return nil, err
}
if record.IsEmpty() {
return nil, gerror.NewCode(gcodeUnauthorized(), "用户不存在")
}
user := &entity.Users{}
if err = record.Struct(user); err != nil {
return nil, err
}
return user, nil
}
// GetLevelByKey 查询等级(含已授权模块 key 列表)
func GetLevelByKey(ctx context.Context, key string) (*v1.LevelInfo, error) {
record, err := g.Model(consts.TableLevels).Where("level_key", key).Where("is_enabled", 1).One()
if err != nil {
return nil, err
}
if record.IsEmpty() {
// 等级被停用或不存在:回落为空权限,避免越权
return &v1.LevelInfo{LevelKey: key, Name: "未授权", Modules: []string{}}, nil
}
return levelInfoFromRecord(record), nil
}
func levelInfoFromRecord(record gdb.Record) *v1.LevelInfo {
modules := []string{}
_ = json.Unmarshal([]byte(record["modules"].String()), &modules)
return &v1.LevelInfo{
LevelKey: record["level_key"].String(),
Name: record["name"].String(),
Modules: modules,
}
}

0
internal/model/.gitkeep Normal file
View File

View File

View File

View File

@@ -0,0 +1,14 @@
package entity
import "github.com/gogf/gf/v2/os/gtime"
// AdminUsers 后台管理员
type AdminUsers struct {
Id int64 `json:"id"`
Account string `json:"account"`
Password string `json:"-"`
NickName string `json:"nick_name"`
RoleName string `json:"role_name"`
RoleValue string `json:"role_value"`
CreatedAt *gtime.Time `json:"created_at"`
}

View File

@@ -0,0 +1,13 @@
package entity
import "github.com/gogf/gf/v2/os/gtime"
// Feedbacks 用户反馈
type Feedbacks struct {
Id int64 `json:"id"`
UserId int64 `json:"user_id"`
Content string `json:"content"`
Contact string `json:"contact"`
Status int `json:"status"`
CreatedAt *gtime.Time `json:"created_at"`
}

View File

@@ -0,0 +1,16 @@
package entity
import "github.com/gogf/gf/v2/os/gtime"
// Levels 用户等级(模块权限)
type Levels struct {
Id int64 `json:"id"`
LevelKey string `json:"level_key"`
Name string `json:"name"`
Modules string `json:"modules"` // JSON: ["image","doc"]
Sort int `json:"sort"`
IsEnabled int `json:"is_enabled"`
Remark string `json:"remark"`
CreatedAt *gtime.Time `json:"created_at"`
UpdatedAt *gtime.Time `json:"updated_at"`
}

View File

@@ -0,0 +1,16 @@
package entity
import "github.com/gogf/gf/v2/os/gtime"
// Modules 功能模块
type Modules struct {
Id int64 `json:"id"`
ModuleKey string `json:"module_key"`
Name string `json:"name"`
Icon string `json:"icon"`
Description string `json:"description"`
Sort int `json:"sort"`
IsEnabled int `json:"is_enabled"`
CreatedAt *gtime.Time `json:"created_at"`
UpdatedAt *gtime.Time `json:"updated_at"`
}

View File

@@ -0,0 +1,11 @@
package entity
import "github.com/gogf/gf/v2/os/gtime"
// ToolUsageLogs 工具使用日志
type ToolUsageLogs struct {
Id int64 `json:"id"`
UserId int64 `json:"user_id"`
ToolKey string `json:"tool_key"`
UsedAt *gtime.Time `json:"used_at"`
}

View File

@@ -0,0 +1,19 @@
package entity
import "github.com/gogf/gf/v2/os/gtime"
// Tools 工具目录
type Tools struct {
Id int64 `json:"id"`
ToolKey string `json:"tool_key"`
ModuleKey string `json:"module_key"`
Name string `json:"name"`
Icon string `json:"icon"`
Description string `json:"description"`
Sort int `json:"sort"`
IsEnabled int `json:"is_enabled"`
IsHot int `json:"is_hot"`
UsageCount int64 `json:"usage_count"`
CreatedAt *gtime.Time `json:"created_at"`
UpdatedAt *gtime.Time `json:"updated_at"`
}

View File

@@ -0,0 +1,10 @@
package entity
import "github.com/gogf/gf/v2/os/gtime"
// UserWorkbench 工作台配置
type UserWorkbench struct {
UserId int64 `json:"user_id"`
ToolKeys string `json:"tool_keys"` // JSON: ["img-compress",...]
UpdatedAt *gtime.Time `json:"updated_at"`
}

View File

@@ -0,0 +1,18 @@
package entity
import "github.com/gogf/gf/v2/os/gtime"
// Users 小程序/H5 用户
type Users struct {
Id int64 `json:"id"`
Openid string `json:"openid"`
Username string `json:"username"`
PasswordHash string `json:"password_hash"`
Nickname string `json:"nickname"`
AvatarUrl string `json:"avatar_url"`
LevelKey string `json:"level_key"`
Status int `json:"status"`
CreatedAt *gtime.Time `json:"created_at"`
LastLoginAt *gtime.Time `json:"last_login_at"`
UpdatedAt *gtime.Time `json:"updated_at"`
}

View File

11
main.go Normal file
View File

@@ -0,0 +1,11 @@
package main
import (
"github.com/gogf/gf/v2/os/gctx"
"tool-api/internal/cmd"
)
func main() {
cmd.Main.Run(gctx.GetInitCtx())
}

BIN
main_test.exe Normal file

Binary file not shown.

View File

@@ -0,0 +1,21 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: template-single
labels:
app: template-single
spec:
replicas: 1
selector:
matchLabels:
app: template-single
template:
metadata:
labels:
app: template-single
spec:
containers:
- name : main
image: template-single
imagePullPolicy: Always

View File

@@ -0,0 +1,8 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- deployment.yaml
- service.yaml

View File

@@ -0,0 +1,12 @@
apiVersion: v1
kind: Service
metadata:
name: template-single
spec:
ports:
- port: 80
protocol: TCP
targetPort: 8000
selector:
app: template-single

View File

@@ -0,0 +1,14 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: template-single-configmap
data:
config.yaml: |
server:
address: ":8000"
openapiPath: "/api.json"
swaggerPath: "/swagger"
logger:
level : "all"
stdout: true

View File

@@ -0,0 +1,10 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: template-single
spec:
template:
spec:
containers:
- name : main
image: template-single:develop

View File

@@ -0,0 +1,14 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ../../base
- configmap.yaml
patchesStrategicMerge:
- deployment.yaml
namespace: default

View File

@@ -0,0 +1,16 @@
FROM loads/alpine:3.8
###############################################################################
# INSTALLATION
###############################################################################
ENV WORKDIR /app
ADD resource $WORKDIR/
ADD ./temp/linux_amd64/main $WORKDIR/main
RUN chmod +x $WORKDIR/main
###############################################################################
# START
###############################################################################
WORKDIR $WORKDIR
CMD ./main

View File

@@ -0,0 +1,8 @@
#!/bin/bash
# This shell is executed before docker build.

0
manifest/i18n/.gitkeep Normal file
View File

View File

122
manifest/sql/migrate.sql Normal file
View File

@@ -0,0 +1,122 @@
-- 工具箱项目数据库初始化脚本
-- 种子数据管理员/模块/等级/工具由服务启动时自动写入logic.Seed无需手工插入
CREATE DATABASE IF NOT EXISTS `nl_toolbox` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
USE `nl_toolbox`;
-- 功能模块
CREATE TABLE IF NOT EXISTS `modules` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`module_key` VARCHAR(32) NOT NULL COMMENT 'image/doc/calc/time/create/life',
`name` VARCHAR(64) NOT NULL COMMENT '模块名称',
`icon` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '图标 keywot 图标名',
`description` VARCHAR(255) NOT NULL DEFAULT '',
`sort` INT NOT NULL DEFAULT 0,
`is_enabled` TINYINT NOT NULL DEFAULT 1,
`created_at` DATETIME NULL DEFAULT NULL,
`updated_at` DATETIME NULL DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_module_key` (`module_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='功能模块';
-- 用户等级模块权限
CREATE TABLE IF NOT EXISTS `levels` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`level_key` VARCHAR(32) NOT NULL COMMENT 'v1/v2/v3...',
`name` VARCHAR(64) NOT NULL COMMENT '展示名基础版/进阶版',
`modules` JSON NOT NULL COMMENT '["image","doc"] 模块 key 数组',
`sort` INT NOT NULL DEFAULT 0,
`is_enabled` TINYINT NOT NULL DEFAULT 1,
`remark` VARCHAR(255) NOT NULL DEFAULT '',
`created_at` DATETIME NULL DEFAULT NULL,
`updated_at` DATETIME NULL DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_level_key` (`level_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户等级模块权限';
-- 小程序/H5 用户
-- username/password_hashH5 账号登录用微信账号绑定 H5 账号后同样有值合并账号
CREATE TABLE IF NOT EXISTS `users` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`openid` VARCHAR(64) NOT NULL COMMENT '微信openidH5 独立账号为 h5- 前缀占位',
`username` VARCHAR(64) NULL DEFAULT NULL COMMENT 'H5 登录名唯一可空',
`password_hash` VARCHAR(255) NULL DEFAULT NULL COMMENT 'bcrypt',
`nickname` VARCHAR(64) NOT NULL DEFAULT '',
`avatar_url` VARCHAR(512) NOT NULL DEFAULT '',
`level_key` VARCHAR(32) NOT NULL DEFAULT 'v1',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '1正常 0禁用',
`created_at` DATETIME NULL DEFAULT NULL,
`last_login_at` DATETIME NULL DEFAULT NULL,
`updated_at` DATETIME NULL DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_openid` (`openid`),
UNIQUE KEY `uk_username` (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='小程序用户';
-- ===== v0.2 升级已有库手动执行MySQL 8.0 不支持 ADD COLUMN IF NOT EXISTS =====
-- ALTER TABLE `users`
-- ADD COLUMN `username` VARCHAR(64) NULL DEFAULT NULL COMMENT 'H5 登录名唯一可空' AFTER `openid`,
-- ADD COLUMN `password_hash` VARCHAR(255) NULL DEFAULT NULL COMMENT 'bcrypt' AFTER `username`,
-- ADD UNIQUE KEY `uk_username` (`username`);
-- 工具目录
CREATE TABLE IF NOT EXISTS `tools` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`tool_key` VARCHAR(64) NOT NULL COMMENT '与前端注册表 src/config/tools.ts 强约定一致',
`module_key` VARCHAR(32) NOT NULL COMMENT '所属模块',
`name` VARCHAR(64) NOT NULL,
`icon` VARCHAR(64) NOT NULL DEFAULT '',
`description` VARCHAR(255) NOT NULL DEFAULT '',
`sort` INT NOT NULL DEFAULT 0,
`is_enabled` TINYINT NOT NULL DEFAULT 1,
`is_hot` TINYINT NOT NULL DEFAULT 0 COMMENT '工作台推荐位',
`usage_count` BIGINT NOT NULL DEFAULT 0,
`created_at` DATETIME NULL DEFAULT NULL,
`updated_at` DATETIME NULL DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_tool_key` (`tool_key`),
KEY `idx_module_key` (`module_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='工具目录';
-- 工具使用日志
CREATE TABLE IF NOT EXISTS `tool_usage_logs` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`user_id` BIGINT UNSIGNED NOT NULL,
`tool_key` VARCHAR(64) NOT NULL,
`used_at` DATETIME NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_user_tool_time` (`user_id`, `tool_key`, `used_at`),
KEY `idx_used_at` (`used_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='工具使用日志';
-- 工作台配置
CREATE TABLE IF NOT EXISTS `user_workbench` (
`user_id` BIGINT UNSIGNED NOT NULL,
`tool_keys` JSON NOT NULL COMMENT '["img-compress",...] 有序',
`updated_at` DATETIME NULL DEFAULT NULL,
PRIMARY KEY (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='工作台配置';
-- 后台管理员
CREATE TABLE IF NOT EXISTS `admin_users` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`account` VARCHAR(64) NOT NULL,
`password` VARCHAR(255) NOT NULL COMMENT 'bcrypt',
`nick_name` VARCHAR(64) NOT NULL DEFAULT '',
`role_name` VARCHAR(64) NOT NULL DEFAULT '超级管理员',
`role_value` VARCHAR(32) NOT NULL DEFAULT 'super',
`created_at` DATETIME NULL DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_account` (`account`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='后台管理员';
-- 用户反馈
CREATE TABLE IF NOT EXISTS `feedbacks` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`user_id` BIGINT UNSIGNED NULL,
`content` VARCHAR(1000) NOT NULL,
`contact` VARCHAR(128) NOT NULL DEFAULT '',
`status` TINYINT NOT NULL DEFAULT 0 COMMENT '0待处理 1已处理',
`created_at` DATETIME NULL DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户反馈';

View File

View File

View File

View File

View File

View File

0
utility/.gitkeep Normal file
View File