commit 7c106b69d29d4c1c8c52c4af12972e17f7713a49 Author: 年糕崽崽 Date: Thu Sep 17 14:42:44 2026 +0800 初始化 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..1fbf887 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* linguist-language=GO \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..18646b6 --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..2a6e6e9 --- /dev/null +++ b/Makefile @@ -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 \ No newline at end of file diff --git a/README.MD b/README.MD new file mode 100644 index 0000000..d36cedd --- /dev/null +++ b/README.MD @@ -0,0 +1,4 @@ +# GoFrame Template For SingleRepo + +Quick Start: +- https://goframe.org/quick \ No newline at end of file diff --git a/api/admin/v1/admin.go b/api/admin/v1/admin.go new file mode 100644 index 0000000..5946c53 --- /dev/null +++ b/api/admin/v1/admin.go @@ -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{} diff --git a/api/user/v1/user.go b/api/user/v1/user.go new file mode 100644 index 0000000..18b8634 --- /dev/null +++ b/api/user/v1/user.go @@ -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 或预置头像 key:avatar: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{} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..b15246a --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..973517f --- /dev/null +++ b/go.sum @@ -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= diff --git a/hack/config.yaml b/hack/config.yaml new file mode 100644 index 0000000..04ba959 --- /dev/null +++ b/hack/config.yaml @@ -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 diff --git a/hack/hack-cli.mk b/hack/hack-cli.mk new file mode 100644 index 0000000..526124e --- /dev/null +++ b/hack/hack-cli.mk @@ -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; \ No newline at end of file diff --git a/hack/hack.mk b/hack/hack.mk new file mode 100644 index 0000000..2f68179 --- /dev/null +++ b/hack/hack.mk @@ -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 \ No newline at end of file diff --git a/internal/cmd/cmd.go b/internal/cmd/cmd.go new file mode 100644 index 0000000..f29d32f --- /dev/null +++ b/internal/cmd/cmd.go @@ -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 + }, + } +) diff --git a/internal/consts/consts.go b/internal/consts/consts.go new file mode 100644 index 0000000..a7954cd --- /dev/null +++ b/internal/consts/consts.go @@ -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" +) diff --git a/internal/controller/admin.go b/internal/controller/admin.go new file mode 100644 index 0000000..581b5ca --- /dev/null +++ b/internal/controller/admin.go @@ -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) +} diff --git a/internal/controller/user.go b/internal/controller/user.go new file mode 100644 index 0000000..ecb8e47 --- /dev/null +++ b/internal/controller/user.go @@ -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) +} diff --git a/internal/dao/.gitkeep b/internal/dao/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/internal/logic/admin.go b/internal/logic/admin.go new file mode 100644 index 0000000..2c34867 --- /dev/null +++ b/internal/logic/admin.go @@ -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 +} diff --git a/internal/logic/jwt.go b/internal/logic/jwt.go new file mode 100644 index 0000000..625dbb7 --- /dev/null +++ b/internal/logic/jwt.go @@ -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 签发 JWT;aud 区分 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() +} diff --git a/internal/logic/middleware.go b/internal/logic/middleware.go new file mode 100644 index 0000000..7b90fa7 --- /dev/null +++ b/internal/logic/middleware.go @@ -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}) +} diff --git a/internal/logic/seed.go b/internal/logic/seed.go new file mode 100644 index 0000000..165cad8 --- /dev/null +++ b/internal/logic/seed.go @@ -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}, + } +} diff --git a/internal/logic/tools.go b/internal/logic/tools.go new file mode 100644 index 0000000..886fd1b --- /dev/null +++ b/internal/logic/tools.go @@ -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 +} diff --git a/internal/logic/user.go b/internal/logic/user.go new file mode 100644 index 0000000..b70444e --- /dev/null +++ b/internal/logic/user.go @@ -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, + } +} diff --git a/internal/model/.gitkeep b/internal/model/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/internal/model/do/.gitkeep b/internal/model/do/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/internal/model/entity/.gitkeep b/internal/model/entity/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/internal/model/entity/entity_admin_users.go b/internal/model/entity/entity_admin_users.go new file mode 100644 index 0000000..3a301d9 --- /dev/null +++ b/internal/model/entity/entity_admin_users.go @@ -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"` +} diff --git a/internal/model/entity/entity_feedbacks.go b/internal/model/entity/entity_feedbacks.go new file mode 100644 index 0000000..68ab624 --- /dev/null +++ b/internal/model/entity/entity_feedbacks.go @@ -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"` +} diff --git a/internal/model/entity/entity_levels.go b/internal/model/entity/entity_levels.go new file mode 100644 index 0000000..3a6e808 --- /dev/null +++ b/internal/model/entity/entity_levels.go @@ -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"` +} diff --git a/internal/model/entity/entity_modules.go b/internal/model/entity/entity_modules.go new file mode 100644 index 0000000..2907307 --- /dev/null +++ b/internal/model/entity/entity_modules.go @@ -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"` +} diff --git a/internal/model/entity/entity_tool_usage_logs.go b/internal/model/entity/entity_tool_usage_logs.go new file mode 100644 index 0000000..96eed0f --- /dev/null +++ b/internal/model/entity/entity_tool_usage_logs.go @@ -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"` +} diff --git a/internal/model/entity/entity_tools.go b/internal/model/entity/entity_tools.go new file mode 100644 index 0000000..52e9bc6 --- /dev/null +++ b/internal/model/entity/entity_tools.go @@ -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"` +} diff --git a/internal/model/entity/entity_user_workbench.go b/internal/model/entity/entity_user_workbench.go new file mode 100644 index 0000000..77cb432 --- /dev/null +++ b/internal/model/entity/entity_user_workbench.go @@ -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"` +} diff --git a/internal/model/entity/entity_users.go b/internal/model/entity/entity_users.go new file mode 100644 index 0000000..be8e705 --- /dev/null +++ b/internal/model/entity/entity_users.go @@ -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"` +} diff --git a/internal/service/.gitkeep b/internal/service/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/main.go b/main.go new file mode 100644 index 0000000..36ab2d3 --- /dev/null +++ b/main.go @@ -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()) +} diff --git a/main_test.exe b/main_test.exe new file mode 100644 index 0000000..14e32e0 Binary files /dev/null and b/main_test.exe differ diff --git a/manifest/deploy/kustomize/base/deployment.yaml b/manifest/deploy/kustomize/base/deployment.yaml new file mode 100644 index 0000000..28f1d69 --- /dev/null +++ b/manifest/deploy/kustomize/base/deployment.yaml @@ -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 + diff --git a/manifest/deploy/kustomize/base/kustomization.yaml b/manifest/deploy/kustomize/base/kustomization.yaml new file mode 100644 index 0000000..302d92d --- /dev/null +++ b/manifest/deploy/kustomize/base/kustomization.yaml @@ -0,0 +1,8 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: +- deployment.yaml +- service.yaml + + + diff --git a/manifest/deploy/kustomize/base/service.yaml b/manifest/deploy/kustomize/base/service.yaml new file mode 100644 index 0000000..608771c --- /dev/null +++ b/manifest/deploy/kustomize/base/service.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: Service +metadata: + name: template-single +spec: + ports: + - port: 80 + protocol: TCP + targetPort: 8000 + selector: + app: template-single + diff --git a/manifest/deploy/kustomize/overlays/develop/configmap.yaml b/manifest/deploy/kustomize/overlays/develop/configmap.yaml new file mode 100644 index 0000000..3b1d0af --- /dev/null +++ b/manifest/deploy/kustomize/overlays/develop/configmap.yaml @@ -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 diff --git a/manifest/deploy/kustomize/overlays/develop/deployment.yaml b/manifest/deploy/kustomize/overlays/develop/deployment.yaml new file mode 100644 index 0000000..04e4851 --- /dev/null +++ b/manifest/deploy/kustomize/overlays/develop/deployment.yaml @@ -0,0 +1,10 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: template-single +spec: + template: + spec: + containers: + - name : main + image: template-single:develop \ No newline at end of file diff --git a/manifest/deploy/kustomize/overlays/develop/kustomization.yaml b/manifest/deploy/kustomize/overlays/develop/kustomization.yaml new file mode 100644 index 0000000..4731c47 --- /dev/null +++ b/manifest/deploy/kustomize/overlays/develop/kustomization.yaml @@ -0,0 +1,14 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: +- ../../base +- configmap.yaml + +patchesStrategicMerge: +- deployment.yaml + +namespace: default + + + diff --git a/manifest/docker/Dockerfile b/manifest/docker/Dockerfile new file mode 100644 index 0000000..d3abe8f --- /dev/null +++ b/manifest/docker/Dockerfile @@ -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 diff --git a/manifest/docker/docker.sh b/manifest/docker/docker.sh new file mode 100644 index 0000000..ff393f9 --- /dev/null +++ b/manifest/docker/docker.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +# This shell is executed before docker build. + + + + + diff --git a/manifest/i18n/.gitkeep b/manifest/i18n/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/manifest/protobuf/.keep-if-necessary b/manifest/protobuf/.keep-if-necessary new file mode 100644 index 0000000..e69de29 diff --git a/manifest/sql/migrate.sql b/manifest/sql/migrate.sql new file mode 100644 index 0000000..e873e8d --- /dev/null +++ b/manifest/sql/migrate.sql @@ -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 '图标 key(wot 图标名)', + `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_hash:H5 账号登录用;微信账号绑定 H5 账号后同样有值(合并账号) +CREATE TABLE IF NOT EXISTS `users` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `openid` VARCHAR(64) NOT NULL COMMENT '微信openid;H5 独立账号为 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='用户反馈'; diff --git a/resource/public/html/.gitkeep b/resource/public/html/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/resource/public/plugin/.gitkeep b/resource/public/plugin/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/resource/public/resource/css/.gitkeep b/resource/public/resource/css/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/resource/public/resource/image/.gitkeep b/resource/public/resource/image/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/resource/public/resource/js/.gitkeep b/resource/public/resource/js/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/resource/template/.gitkeep b/resource/template/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/utility/.gitkeep b/utility/.gitkeep new file mode 100644 index 0000000..e69de29