feat: PhotoWall 毕业照存储系统初始版本
后端: Go(Gin+GORM+MySQL+Redis) - 17张业务表(yz_pw_前缀), 自动迁移 - JWT认证(3小时) + 盐+MD5密码 + 图形验证码 - 班级CRUD/加入(8人姓名验证/邀请码)/审核/30天自动清理 - 系统配置(16项)/菜单管理(动态路由)/数据统计 - 文件MD5去重/数据隔离/账号封禁/敏感词DFA检测 前端: Vue3+Vite+Element Plus+Less+ECharts+FontAwesome - 动态路由(数据库菜单驱动) - 蓝白配色, H5响应式 - 登录/注册/忘记密码/个人中心 - 班级创建向导/详情/加入/列表 - 管理后台: 审核/配置/菜单/统计/用户/敏感词 数据库: MySQL 10.31.100.3:3306/photowall 管理员: hero920103 / 920103
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"photowall/internal/config"
|
||||
"photowall/internal/database"
|
||||
"photowall/internal/handler"
|
||||
"photowall/internal/router"
|
||||
"photowall/internal/scheduler"
|
||||
"photowall/internal/service"
|
||||
"photowall/pkg/jwt"
|
||||
"photowall/pkg/redis"
|
||||
"photowall/pkg/sensitive"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// 1. 加载配置
|
||||
cfg := config.Load()
|
||||
log.Printf("[config] port=%s db=%s@%s:%s/%s", cfg.Port, cfg.DBUser, cfg.DBHost, cfg.DBPort, cfg.DBName)
|
||||
|
||||
// 2. 初始化数据库(连接 + 自动迁移 + 管理员 + 种子数据)
|
||||
if err := database.Init(); err != nil {
|
||||
log.Fatalf("[database] init failed: %v", err)
|
||||
}
|
||||
|
||||
// 2.1 初始化 Redis(连接失败自动降级内存模式)
|
||||
redis.Init(redis.Config{
|
||||
Host: cfg.RedisHost,
|
||||
Port: cfg.RedisPort,
|
||||
Password: cfg.RedisPassword,
|
||||
DB: cfg.RedisDB,
|
||||
})
|
||||
|
||||
// 3. JWT
|
||||
jm := jwt.New(cfg.JWTSecret, cfg.JWTExpireHrs)
|
||||
|
||||
// 4. 初始化 Service
|
||||
authSvc := service.NewAuthService(database.DB, jm)
|
||||
userSvc := service.NewUserService(database.DB)
|
||||
regionSvc := service.NewRegionService(database.DB)
|
||||
schoolSvc := service.NewSchoolService(database.DB)
|
||||
classSvc := service.NewClassService(database.DB)
|
||||
adminSvc := service.NewAdminService(database.DB)
|
||||
configSvc := service.NewConfigService(database.DB)
|
||||
menuSvc := service.NewMenuService(database.DB)
|
||||
|
||||
// 初始化系统默认配置
|
||||
if err := configSvc.InitDefaults(); err != nil {
|
||||
log.Printf("[config] init defaults failed: %v", err)
|
||||
}
|
||||
// 初始化默认菜单
|
||||
if err := menuSvc.InitDefaults(); err != nil {
|
||||
log.Printf("[menu] init defaults failed: %v", err)
|
||||
}
|
||||
// 初始化敏感词过滤器
|
||||
sensitive.Init(database.DB)
|
||||
|
||||
// 5. 初始化 Handler
|
||||
deps := &router.Deps{
|
||||
Auth: handler.NewAuthHandler(authSvc),
|
||||
User: handler.NewUserHandler(userSvc),
|
||||
Region: handler.NewRegionHandler(regionSvc),
|
||||
School: handler.NewSchoolHandler(schoolSvc),
|
||||
Class: handler.NewClassHandler(classSvc),
|
||||
Admin: handler.NewAdminHandler(adminSvc),
|
||||
Upload: handler.NewUploadHandler(database.DB),
|
||||
Config: handler.NewConfigHandler(configSvc),
|
||||
Menu: handler.NewMenuHandler(menuSvc),
|
||||
}
|
||||
|
||||
// 6. 启动定时清理任务
|
||||
scheduler.StartCleanup(adminSvc)
|
||||
|
||||
// 7. 启动 HTTP 服务
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
r := gin.Default()
|
||||
router.Register(r, jm, deps)
|
||||
|
||||
log.Printf("[server] photowall API listening on :%s", cfg.Port)
|
||||
if err := r.Run(":" + cfg.Port); err != nil {
|
||||
log.Fatalf("[server] failed: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
module photowall
|
||||
|
||||
go 1.24
|
||||
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||
github.com/google/uuid v1.6.0
|
||||
golang.org/x/crypto v0.25.0
|
||||
gorm.io/driver/mysql v1.5.7
|
||||
gorm.io/gorm v1.25.11
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bytedance/sonic v1.11.6 // indirect
|
||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
||||
github.com/go-sql-driver/mysql v1.7.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||
github.com/redis/go-redis/v9 v9.22.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
golang.org/x/arch v0.8.0 // indirect
|
||||
golang.org/x/net v0.25.0 // indirect
|
||||
golang.org/x/sys v0.30.0 // indirect
|
||||
golang.org/x/text v0.16.0 // indirect
|
||||
google.golang.org/protobuf v1.34.1 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
@@ -0,0 +1,114 @@
|
||||
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
|
||||
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
||||
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
||||
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
|
||||
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
|
||||
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
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/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||
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/redis/go-redis/v9 v9.22.0 h1:laDvpYXTJtZLloinw1fA5Kqd6HAEH2XKxOkG/PDq2F0=
|
||||
github.com/redis/go-redis/v9 v9.22.0/go.mod h1:y2g0Wj8rQvuK0ELM+oxSudcLtC09JScs98I/X9gRWY4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
||||
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30=
|
||||
golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M=
|
||||
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
|
||||
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
|
||||
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
|
||||
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo=
|
||||
gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
|
||||
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||
gorm.io/gorm v1.25.11 h1:/Wfyg1B/je1hnDx3sMkX+gAlxrlZpn6X0BXRlwXlvHg=
|
||||
gorm.io/gorm v1.25.11/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
|
||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||
@@ -0,0 +1,111 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// Config 全局配置,优先从环境变量读取,提供默认值以便开箱即用。
|
||||
type Config struct {
|
||||
Port string
|
||||
JWTSecret string
|
||||
JWTExpireHrs int
|
||||
UploadDir string
|
||||
|
||||
// MySQL
|
||||
DBHost string
|
||||
DBPort string
|
||||
DBUser string
|
||||
DBPassword string
|
||||
DBName string
|
||||
DBCharset string
|
||||
|
||||
// 平台管理员账号(首次启动自动创建)
|
||||
AdminUsername string
|
||||
AdminPassword string
|
||||
// 班级审核补齐期限(天),逾期自动删除
|
||||
AuditGraceDays int
|
||||
|
||||
// 极验行为验证(参数后期补,为空则跳过极验校验)
|
||||
GeeTestID string
|
||||
GeeTestKey string
|
||||
|
||||
// 短信验证码(参数后期补,为空则短信接口返回配置缺失)
|
||||
SMSProvider string // aliyun / tencent
|
||||
SMSAccessKey string
|
||||
SMSSecretKey string
|
||||
SMSSignName string
|
||||
SMSTemplateCode string
|
||||
|
||||
// 微信扫码登录(参数后期补,为空则微信接口返回配置缺失)
|
||||
WechatAppID string
|
||||
WechatAppSecret string
|
||||
WechatRedirectURI string
|
||||
|
||||
// Redis(缓存/验证码/限流,连接失败自动降级内存)
|
||||
RedisHost string
|
||||
RedisPort string
|
||||
RedisPassword string
|
||||
RedisDB int
|
||||
}
|
||||
|
||||
var C *Config
|
||||
|
||||
func Load() *Config {
|
||||
C = &Config{
|
||||
Port: getEnv("PORT", "8010"),
|
||||
JWTSecret: getEnv("JWT_SECRET", "photowall-dev-secret-change-in-prod"),
|
||||
JWTExpireHrs: getEnvInt("JWT_EXPIRE_HOURS", 3),
|
||||
UploadDir: getEnv("UPLOAD_DIR", "uploads"),
|
||||
DBHost: getEnv("DB_HOST", "10.31.100.3"),
|
||||
DBPort: getEnv("DB_PORT", "3306"),
|
||||
DBUser: getEnv("DB_USER", "photowall"),
|
||||
DBPassword: getEnv("DB_PASSWORD", "Dfn47yeKpyJfwz8n"),
|
||||
DBName: getEnv("DB_NAME", "photowall"),
|
||||
DBCharset: getEnv("DB_CHARSET", "utf8mb4"),
|
||||
AdminUsername: getEnv("ADMIN_USERNAME", "hero920103"),
|
||||
AdminPassword: getEnv("ADMIN_PASSWORD", "920103"),
|
||||
AuditGraceDays: getEnvInt("AUDIT_GRACE_DAYS", 30),
|
||||
// 极验(后期补参数)
|
||||
GeeTestID: getEnv("GEETEST_ID", ""),
|
||||
GeeTestKey: getEnv("GEETEST_KEY", ""),
|
||||
// 短信(后期补参数)
|
||||
SMSProvider: getEnv("SMS_PROVIDER", "aliyun"),
|
||||
SMSAccessKey: getEnv("SMS_ACCESS_KEY", ""),
|
||||
SMSSecretKey: getEnv("SMS_SECRET_KEY", ""),
|
||||
SMSSignName: getEnv("SMS_SIGN_NAME", ""),
|
||||
SMSTemplateCode: getEnv("SMS_TEMPLATE_CODE", ""),
|
||||
// 微信扫码登录(后期补参数)
|
||||
WechatAppID: getEnv("WECHAT_APP_ID", ""),
|
||||
WechatAppSecret: getEnv("WECHAT_APP_SECRET", ""),
|
||||
WechatRedirectURI: getEnv("WECHAT_REDIRECT_URI", ""),
|
||||
// Redis
|
||||
RedisHost: getEnv("REDIS_HOST", "localhost"),
|
||||
RedisPort: getEnv("REDIS_PORT", "6379"),
|
||||
RedisPassword: getEnv("REDIS_PASSWORD", ""),
|
||||
RedisDB: getEnvInt("REDIS_DB", 0),
|
||||
}
|
||||
return C
|
||||
}
|
||||
|
||||
// DSN 生成 MySQL 连接串
|
||||
func (c *Config) DSN() string {
|
||||
return c.DBUser + ":" + c.DBPassword + "@tcp(" + c.DBHost + ":" + c.DBPort + ")/" +
|
||||
c.DBName + "?charset=" + c.DBCharset + "&parseTime=True&loc=Local"
|
||||
}
|
||||
|
||||
func getEnv(key, def string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func getEnvInt(key string, def int) int {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"photowall/internal/config"
|
||||
"photowall/internal/model"
|
||||
"photowall/pkg/hash"
|
||||
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
"gorm.io/gorm/schema"
|
||||
)
|
||||
|
||||
var DB *gorm.DB
|
||||
|
||||
// Init 连接 MySQL,自动建表,创建平台管理员,写入种子数据。
|
||||
func Init() error {
|
||||
cfg := config.C
|
||||
|
||||
dsn := cfg.DSN()
|
||||
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Warn),
|
||||
NamingStrategy: schema.NamingStrategy{
|
||||
TablePrefix: "yz_pw_", // 统一表前缀
|
||||
SingularTable: false,
|
||||
},
|
||||
NowFunc: func() time.Time { return time.Now().Local() },
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect mysql: %w", err)
|
||||
}
|
||||
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sqlDB.SetMaxOpenConns(50)
|
||||
sqlDB.SetMaxIdleConns(10)
|
||||
sqlDB.SetConnMaxLifetime(time.Hour)
|
||||
|
||||
DB = db
|
||||
|
||||
// 数据迁移:旧版 bcrypt 密码(60字符) 超过新版 md5(32字符) 列宽,
|
||||
// 在 AutoMigrate 缩列前先清空旧密码,后续 ensureAdmin 会重新设置。
|
||||
// 注意:salt 列此时可能还不存在,只更新 password_hash。
|
||||
db.Exec("UPDATE yz_pw_users SET password_hash = '' WHERE LENGTH(password_hash) > 32")
|
||||
|
||||
// 自动迁移
|
||||
if err := autoMigrate(db); err != nil {
|
||||
return fmt.Errorf("auto migrate: %w", err)
|
||||
}
|
||||
|
||||
// 创建平台管理员
|
||||
if err := ensureAdmin(db); err != nil {
|
||||
return fmt.Errorf("ensure admin: %w", err)
|
||||
}
|
||||
|
||||
// 写入种子地区数据
|
||||
if err := seedRegions(db); err != nil {
|
||||
return fmt.Errorf("seed regions: %w", err)
|
||||
}
|
||||
|
||||
log.Println("[database] mysql connected, tables migrated, admin & seeds ready")
|
||||
return nil
|
||||
}
|
||||
|
||||
func autoMigrate(db *gorm.DB) error {
|
||||
return db.AutoMigrate(
|
||||
&model.User{},
|
||||
&model.UserContact{},
|
||||
&model.EducationHistory{},
|
||||
&model.Region{},
|
||||
&model.School{},
|
||||
&model.College{},
|
||||
&model.Class{},
|
||||
&model.ClassMember{},
|
||||
&model.ClassPhoto{},
|
||||
&model.Invitation{},
|
||||
&model.AuditLog{},
|
||||
&model.SmsCode{},
|
||||
&model.PasswordReset{},
|
||||
&model.SystemConfig{},
|
||||
&model.Menu{},
|
||||
&model.UploadFile{},
|
||||
&model.SensitiveWord{},
|
||||
)
|
||||
}
|
||||
|
||||
func ensureAdmin(db *gorm.DB) error {
|
||||
cfg := config.C
|
||||
var admin model.User
|
||||
err := db.Where("role = ?", "admin").First(&admin).Error
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
|
||||
salt, pwdHash := hash.Password(cfg.AdminPassword)
|
||||
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
// 不存在则创建
|
||||
admin = model.User{
|
||||
Username: cfg.AdminUsername,
|
||||
PasswordHash: pwdHash,
|
||||
Salt: salt,
|
||||
Nickname: "平台管理员",
|
||||
Role: "admin",
|
||||
Bio: "系统默认平台管理员,负责班级审核与巡查",
|
||||
}
|
||||
return db.Create(&admin).Error
|
||||
}
|
||||
|
||||
// 已存在:用户名/密码方式变更时同步更新(兼容旧 bcrypt 数据)
|
||||
needUpdate := false
|
||||
if admin.Username != cfg.AdminUsername {
|
||||
admin.Username = cfg.AdminUsername
|
||||
needUpdate = true
|
||||
}
|
||||
// bcrypt 哈希长度 60,md5 长度 32;salt 为空说明是旧数据
|
||||
if len(admin.PasswordHash) == 60 || admin.Salt == "" {
|
||||
admin.PasswordHash = pwdHash
|
||||
admin.Salt = salt
|
||||
needUpdate = true
|
||||
}
|
||||
if needUpdate {
|
||||
return db.Save(&admin).Error
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"photowall/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// seedRegions 写入示例行政区划数据。
|
||||
// 全球国家/省/区县数据量巨大,此处仅写入代表性示例,平台管理员可通过
|
||||
// 管理接口批量录入完整数据(或导入民政部/GeoNames 数据源)。
|
||||
func seedRegions(db *gorm.DB) error {
|
||||
// 国家
|
||||
china := upsertRegion(db, model.Region{Name: "中国", NameEn: "China", Level: model.LevelCountry, Code: "CN", SortOrder: 1})
|
||||
usa := upsertRegion(db, model.Region{Name: "美国", NameEn: "United States", Level: model.LevelCountry, Code: "US", SortOrder: 2})
|
||||
uk := upsertRegion(db, model.Region{Name: "英国", NameEn: "United Kingdom", Level: model.LevelCountry, Code: "GB", SortOrder: 3})
|
||||
japan := upsertRegion(db, model.Region{Name: "日本", NameEn: "Japan", Level: model.LevelCountry, Code: "JP", SortOrder: 4})
|
||||
|
||||
// 中国 - 省/直辖市
|
||||
jiangsu := upsertRegion(db, model.Region{Name: "江苏省", NameEn: "Jiangsu", Level: model.LevelProvince, ParentID: china.ID, Code: "320000"})
|
||||
beijing := upsertRegion(db, model.Region{Name: "北京市", NameEn: "Beijing", Level: model.LevelProvince, ParentID: china.ID, Code: "110000"})
|
||||
guangdong := upsertRegion(db, model.Region{Name: "广东省", NameEn: "Guangdong", Level: model.LevelProvince, ParentID: china.ID, Code: "440000"})
|
||||
shanghai := upsertRegion(db, model.Region{Name: "上海市", NameEn: "Shanghai", Level: model.LevelProvince, ParentID: china.ID, Code: "310000"})
|
||||
zhejiang := upsertRegion(db, model.Region{Name: "浙江省", NameEn: "Zhejiang", Level: model.LevelProvince, ParentID: china.ID, Code: "330000"})
|
||||
|
||||
// 中国 - 区县(最低级)
|
||||
upsertRegion(db, model.Region{Name: "海州区", NameEn: "Haizhou", Level: model.LevelCounty, ParentID: jiangsu.ID, Code: "320706"})
|
||||
upsertRegion(db, model.Region{Name: "连云区", NameEn: "Lianyun", Level: model.LevelCounty, ParentID: jiangsu.ID, Code: "320703"})
|
||||
upsertRegion(db, model.Region{Name: "赣榆区", NameEn: "Ganyu", Level: model.LevelCounty, ParentID: jiangsu.ID, Code: "320707"})
|
||||
upsertRegion(db, model.Region{Name: "玄武区", NameEn: "Xuanwu", Level: model.LevelCounty, ParentID: jiangsu.ID, Code: "320102"})
|
||||
upsertRegion(db, model.Region{Name: "鼓楼区", NameEn: "Gulou", Level: model.LevelCounty, ParentID: jiangsu.ID, Code: "320106"})
|
||||
upsertRegion(db, model.Region{Name: "海淀区", NameEn: "Haidian", Level: model.LevelCounty, ParentID: beijing.ID, Code: "110108"})
|
||||
upsertRegion(db, model.Region{Name: "朝阳区", NameEn: "Chaoyang", Level: model.LevelCounty, ParentID: beijing.ID, Code: "110105"})
|
||||
upsertRegion(db, model.Region{Name: "西城区", NameEn: "Xicheng", Level: model.LevelCounty, ParentID: beijing.ID, Code: "110102"})
|
||||
upsertRegion(db, model.Region{Name: "天河区", NameEn: "Tianhe", Level: model.LevelCounty, ParentID: guangdong.ID, Code: "440106"})
|
||||
upsertRegion(db, model.Region{Name: "南山区", NameEn: "Nanshan", Level: model.LevelCounty, ParentID: guangdong.ID, Code: "440305"})
|
||||
upsertRegion(db, model.Region{Name: "福田区", NameEn: "Futian", Level: model.LevelCounty, ParentID: guangdong.ID, Code: "440304"})
|
||||
upsertRegion(db, model.Region{Name: "浦东新区", NameEn: "Pudong", Level: model.LevelCounty, ParentID: shanghai.ID, Code: "310115"})
|
||||
upsertRegion(db, model.Region{Name: "黄浦区", NameEn: "Huangpu", Level: model.LevelCounty, ParentID: shanghai.ID, Code: "310101"})
|
||||
upsertRegion(db, model.Region{Name: "西湖区", NameEn: "Xihu", Level: model.LevelCounty, ParentID: zhejiang.ID, Code: "330106"})
|
||||
upsertRegion(db, model.Region{Name: "滨江区", NameEn: "Binjiang", Level: model.LevelCounty, ParentID: zhejiang.ID, Code: "330108"})
|
||||
|
||||
// 美国 - 州
|
||||
california := upsertRegion(db, model.Region{Name: "加利福尼亚州", NameEn: "California", Level: model.LevelProvince, ParentID: usa.ID, Code: "CA"})
|
||||
newyorkState := upsertRegion(db, model.Region{Name: "纽约州", NameEn: "New York", Level: model.LevelProvince, ParentID: usa.ID, Code: "NY"})
|
||||
// 美国 - 县
|
||||
upsertRegion(db, model.Region{Name: "洛杉矶县", NameEn: "Los Angeles County", Level: model.LevelCounty, ParentID: california.ID, Code: "06037"})
|
||||
upsertRegion(db, model.Region{Name: "旧金山县", NameEn: "San Francisco County", Level: model.LevelCounty, ParentID: california.ID, Code: "06075"})
|
||||
upsertRegion(db, model.Region{Name: "圣克拉拉县", NameEn: "Santa Clara County", Level: model.LevelCounty, ParentID: california.ID, Code: "06085"})
|
||||
upsertRegion(db, model.Region{Name: "纽约县", NameEn: "New York County", Level: model.LevelCounty, ParentID: newyorkState.ID, Code: "36061"})
|
||||
|
||||
// 英国
|
||||
england := upsertRegion(db, model.Region{Name: "英格兰", NameEn: "England", Level: model.LevelProvince, ParentID: uk.ID, Code: "ENG"})
|
||||
upsertRegion(db, model.Region{Name: "大伦敦", NameEn: "Greater London", Level: model.LevelCounty, ParentID: england.ID, Code: "LND"})
|
||||
upsertRegion(db, model.Region{Name: "曼彻斯特", NameEn: "Manchester", Level: model.LevelCounty, ParentID: england.ID, Code: "MAN"})
|
||||
|
||||
// 日本
|
||||
tokyo := upsertRegion(db, model.Region{Name: "东京都", NameEn: "Tokyo", Level: model.LevelProvince, ParentID: japan.ID, Code: "13"})
|
||||
upsertRegion(db, model.Region{Name: "东京都区部", NameEn: "Tokyo Metropolis", Level: model.LevelCounty, ParentID: tokyo.ID, Code: "13100"})
|
||||
upsertRegion(db, model.Region{Name: "八王子市", NameEn: "Hachioji", Level: model.LevelCounty, ParentID: tokyo.ID, Code: "13201"})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// upsertRegion 按 name+level+parent_id 查找,不存在则创建。
|
||||
func upsertRegion(db *gorm.DB, r model.Region) model.Region {
|
||||
var existing model.Region
|
||||
err := db.Where("name = ? AND level = ? AND parent_id = ?", r.Name, r.Level, r.ParentID).First(&existing).Error
|
||||
if err == nil {
|
||||
return existing
|
||||
}
|
||||
if err := db.Create(&r).Error; err != nil {
|
||||
// 并发或重复创建时返回已存在记录
|
||||
db.Where("name = ? AND level = ? AND parent_id = ?", r.Name, r.Level, r.ParentID).First(&existing)
|
||||
return existing
|
||||
}
|
||||
return r
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"photowall/internal/database"
|
||||
"photowall/internal/middleware"
|
||||
"photowall/internal/service"
|
||||
"photowall/pkg/response"
|
||||
"photowall/pkg/sensitive"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type AdminHandler struct {
|
||||
svc *service.AdminService
|
||||
}
|
||||
|
||||
func NewAdminHandler(svc *service.AdminService) *AdminHandler {
|
||||
return &AdminHandler{svc: svc}
|
||||
}
|
||||
|
||||
// Stats GET /api/admin/stats
|
||||
func (h *AdminHandler) Stats(c *gin.Context) {
|
||||
stats, err := h.svc.Stats()
|
||||
if err != nil {
|
||||
response.Internal(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, stats)
|
||||
}
|
||||
|
||||
// DetailedStats GET /api/admin/stats/detailed 详细数据统计(仪表盘用)
|
||||
func (h *AdminHandler) DetailedStats(c *gin.Context) {
|
||||
stats, err := h.svc.DetailedStats()
|
||||
if err != nil {
|
||||
response.Internal(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, stats)
|
||||
}
|
||||
|
||||
// AuditQueue GET /api/admin/classes
|
||||
func (h *AdminHandler) AuditQueue(c *gin.Context) {
|
||||
var req service.AuditQueueReq
|
||||
if err := c.ShouldBindQuery(&req); err != nil {
|
||||
response.BadRequest(c, "参数错误")
|
||||
return
|
||||
}
|
||||
result, err := h.svc.ListAuditQueue(&req)
|
||||
if err != nil {
|
||||
response.Internal(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, result)
|
||||
}
|
||||
|
||||
// Approve POST /api/admin/classes/:id/approve
|
||||
func (h *AdminHandler) Approve(c *gin.Context) {
|
||||
auditorID := middleware.CurrentUserID(c)
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
var body struct {
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
c.ShouldBindJSON(&body)
|
||||
if err := h.svc.Approve(uint(id), auditorID, body.Reason); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"message": "已通过审核"})
|
||||
}
|
||||
|
||||
// Reject POST /api/admin/classes/:id/reject
|
||||
func (h *AdminHandler) Reject(c *gin.Context) {
|
||||
auditorID := middleware.CurrentUserID(c)
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
var body struct {
|
||||
Reason string `json:"reason" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
response.BadRequest(c, "请填写打回原因")
|
||||
return
|
||||
}
|
||||
if err := h.svc.Reject(uint(id), auditorID, body.Reason); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"message": "已打回,等待用户补齐"})
|
||||
}
|
||||
|
||||
// Delete DELETE /api/admin/classes/:id
|
||||
func (h *AdminHandler) Delete(c *gin.Context) {
|
||||
auditorID := middleware.CurrentUserID(c)
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
var body struct {
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
c.ShouldBindJSON(&body)
|
||||
if err := h.svc.ManualDelete(uint(id), auditorID, body.Reason); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"message": "已删除"})
|
||||
}
|
||||
|
||||
// AuditLogs GET /api/admin/classes/:id/logs
|
||||
func (h *AdminHandler) AuditLogs(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
logs, err := h.svc.AuditLogs(uint(id))
|
||||
if err != nil {
|
||||
response.Internal(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, logs)
|
||||
}
|
||||
|
||||
// TriggerCleanup POST /api/admin/cleanup 手动触发过期清理(也可由定时任务自动执行)
|
||||
func (h *AdminHandler) TriggerCleanup(c *gin.Context) {
|
||||
count, err := h.svc.CleanupExpired()
|
||||
if err != nil {
|
||||
response.Internal(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"deleted": count, "message": "清理完成"})
|
||||
}
|
||||
|
||||
// ============ 账号封禁管理 ============
|
||||
|
||||
// BanUser POST /api/admin/users/:id/ban 封禁用户
|
||||
func (h *AdminHandler) BanUser(c *gin.Context) {
|
||||
adminID := middleware.CurrentUserID(c)
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
var body struct {
|
||||
Reason string `json:"reason" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
response.BadRequest(c, "请填写封禁原因")
|
||||
return
|
||||
}
|
||||
req := &service.BanUserReq{UserID: uint(id), Reason: body.Reason}
|
||||
if err := h.svc.BanUser(req, adminID); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"message": "已封禁"})
|
||||
}
|
||||
|
||||
// UnbanUser POST /api/admin/users/:id/unban 解封用户
|
||||
func (h *AdminHandler) UnbanUser(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err := h.svc.UnbanUser(uint(id)); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"message": "已解封"})
|
||||
}
|
||||
|
||||
// BannedUsers GET /api/admin/users/banned 封禁用户列表
|
||||
func (h *AdminHandler) BannedUsers(c *gin.Context) {
|
||||
list, err := h.svc.ListBannedUsers()
|
||||
if err != nil {
|
||||
response.Internal(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, list)
|
||||
}
|
||||
|
||||
// ============ 敏感词管理 ============
|
||||
|
||||
// SensitiveWords GET /api/admin/sensitive-words 敏感词列表
|
||||
func (h *AdminHandler) SensitiveWords(c *gin.Context) {
|
||||
list, err := h.svc.ListSensitiveWords()
|
||||
if err != nil {
|
||||
response.Internal(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, list)
|
||||
}
|
||||
|
||||
// AddSensitiveWord POST /api/admin/sensitive-words 添加敏感词
|
||||
func (h *AdminHandler) AddSensitiveWord(c *gin.Context) {
|
||||
var body struct {
|
||||
Word string `json:"word" binding:"required"`
|
||||
Category string `json:"category"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
response.BadRequest(c, "请填写敏感词")
|
||||
return
|
||||
}
|
||||
if body.Category == "" {
|
||||
body.Category = "other"
|
||||
}
|
||||
sw, err := h.svc.AddSensitiveWord(body.Word, body.Category)
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
// 重新加载敏感词过滤器
|
||||
sensitive.Reload(database.DB)
|
||||
response.Created(c, sw)
|
||||
}
|
||||
|
||||
// DeleteSensitiveWord DELETE /api/admin/sensitive-words/:id 删除敏感词
|
||||
func (h *AdminHandler) DeleteSensitiveWord(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err := h.svc.DeleteSensitiveWord(uint(id)); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
sensitive.Reload(database.DB)
|
||||
response.OK(c, gin.H{"message": "已删除"})
|
||||
}
|
||||
|
||||
// ToggleSensitiveWord PUT /api/admin/sensitive-words/:id/toggle 启用/禁用
|
||||
func (h *AdminHandler) ToggleSensitiveWord(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
var body struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
c.ShouldBindJSON(&body)
|
||||
if err := h.svc.ToggleSensitiveWord(uint(id), body.Enabled); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
sensitive.Reload(database.DB)
|
||||
response.OK(c, gin.H{"message": "已更新"})
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"photowall/internal/middleware"
|
||||
"photowall/internal/service"
|
||||
"photowall/pkg/captcha"
|
||||
"photowall/pkg/response"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type AuthHandler struct {
|
||||
auth *service.AuthService
|
||||
}
|
||||
|
||||
func NewAuthHandler(auth *service.AuthService) *AuthHandler {
|
||||
return &AuthHandler{auth: auth}
|
||||
}
|
||||
|
||||
// Captcha GET /api/captcha 获取图形验证码
|
||||
func (h *AuthHandler) Captcha(c *gin.Context) {
|
||||
id, img := captcha.Generate()
|
||||
response.OK(c, gin.H{
|
||||
"captcha_id": id,
|
||||
"image": img,
|
||||
})
|
||||
}
|
||||
|
||||
// Register POST /api/auth/register
|
||||
func (h *AuthHandler) Register(c *gin.Context) {
|
||||
var req service.RegisterReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "参数错误:"+err.Error())
|
||||
return
|
||||
}
|
||||
user, err := h.auth.Register(&req)
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.Created(c, user)
|
||||
}
|
||||
|
||||
// Login POST /api/auth/login
|
||||
func (h *AuthHandler) Login(c *gin.Context) {
|
||||
var req service.LoginReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "参数错误:"+err.Error())
|
||||
return
|
||||
}
|
||||
resp, err := h.auth.Login(&req)
|
||||
if err != nil {
|
||||
response.Unauthorized(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, resp)
|
||||
}
|
||||
|
||||
// Me GET /api/auth/me
|
||||
func (h *AuthHandler) Me(c *gin.Context) {
|
||||
userID := middleware.CurrentUserID(c)
|
||||
username := middleware.CurrentUsername(c)
|
||||
role := middleware.CurrentRole(c)
|
||||
response.OK(c, gin.H{
|
||||
"user_id": userID,
|
||||
"username": username,
|
||||
"role": role,
|
||||
})
|
||||
}
|
||||
|
||||
// ForgotPassword POST /api/auth/forgot-password 忘记密码(生成重置码)
|
||||
func (h *AuthHandler) ForgotPassword(c *gin.Context) {
|
||||
var req service.ForgotPasswordReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "参数错误:"+err.Error())
|
||||
return
|
||||
}
|
||||
code, err := h.auth.ForgotPassword(&req)
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
// 开发阶段返回重置码(生产环境应通过邮件发送,不返回)
|
||||
response.OK(c, gin.H{
|
||||
"message": "重置码已生成,有效期30分钟",
|
||||
"reset_code": code,
|
||||
"note": "开发阶段直接返回,生产环境将通过邮箱发送",
|
||||
})
|
||||
}
|
||||
|
||||
// ResetPassword POST /api/auth/reset-password 重置密码
|
||||
func (h *AuthHandler) ResetPassword(c *gin.Context) {
|
||||
var req service.ResetPasswordReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "参数错误:"+err.Error())
|
||||
return
|
||||
}
|
||||
if err := h.auth.ResetPassword(&req); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"message": "密码重置成功,请使用新密码登录"})
|
||||
}
|
||||
|
||||
// SendSms POST /api/auth/send-sms 发送短信验证码
|
||||
func (h *AuthHandler) SendSms(c *gin.Context) {
|
||||
var req service.SendSmsReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "参数错误:"+err.Error())
|
||||
return
|
||||
}
|
||||
code, err := h.auth.SendSmsCode(&req)
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{
|
||||
"message": "验证码已发送,有效期5分钟",
|
||||
"code": code, // 开发阶段返回,生产环境不返回
|
||||
"note": "短信参数未配置时为开发模式,直接返回验证码",
|
||||
})
|
||||
}
|
||||
|
||||
// WechatLogin POST /api/auth/wechat-login 微信扫码登录
|
||||
func (h *AuthHandler) WechatLogin(c *gin.Context) {
|
||||
var req service.WechatLoginReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "参数错误:"+err.Error())
|
||||
return
|
||||
}
|
||||
resp, err := h.auth.WechatLogin(&req)
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, resp)
|
||||
}
|
||||
|
||||
// WechatQrcode GET /api/auth/wechat-qrcode 获取微信扫码登录二维码(预留)
|
||||
func (h *AuthHandler) WechatQrcode(c *gin.Context) {
|
||||
// TODO: 接入微信开放平台后生成真实扫码二维码
|
||||
response.OK(c, gin.H{
|
||||
"message": "微信扫码登录功能开发中,请配置微信开放平台参数后使用",
|
||||
"qrcode": "",
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"photowall/internal/middleware"
|
||||
"photowall/internal/service"
|
||||
"photowall/pkg/response"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type ClassHandler struct {
|
||||
svc *service.ClassService
|
||||
}
|
||||
|
||||
func NewClassHandler(svc *service.ClassService) *ClassHandler {
|
||||
return &ClassHandler{svc: svc}
|
||||
}
|
||||
|
||||
// Create POST /api/classes
|
||||
func (h *ClassHandler) Create(c *gin.Context) {
|
||||
userID := middleware.CurrentUserID(c)
|
||||
var req service.CreateClassReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "参数错误:"+err.Error())
|
||||
return
|
||||
}
|
||||
class, err := h.svc.Create(userID, &req)
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.Created(c, class)
|
||||
}
|
||||
|
||||
// Detail GET /api/classes/:id
|
||||
func (h *ClassHandler) Detail(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
userID := middleware.CurrentUserID(c)
|
||||
isSuperAdmin := middleware.CurrentRole(c) == "admin"
|
||||
detail, err := h.svc.GetDetail(uint(id), userID, isSuperAdmin)
|
||||
if err != nil {
|
||||
response.NotFound(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, detail)
|
||||
}
|
||||
|
||||
// Search GET /api/classes/search
|
||||
func (h *ClassHandler) Search(c *gin.Context) {
|
||||
var req service.SearchClassReq
|
||||
if err := c.ShouldBindQuery(&req); err != nil {
|
||||
response.BadRequest(c, "参数错误")
|
||||
return
|
||||
}
|
||||
result, err := h.svc.Search(&req)
|
||||
if err != nil {
|
||||
response.Internal(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, result)
|
||||
}
|
||||
|
||||
// JoinBySearch POST /api/classes/join/search
|
||||
func (h *ClassHandler) JoinBySearch(c *gin.Context) {
|
||||
userID := middleware.CurrentUserID(c)
|
||||
var req service.JoinBySearchReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "参数错误:"+err.Error())
|
||||
return
|
||||
}
|
||||
if err := h.svc.JoinBySearch(userID, &req); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"message": "加入成功"})
|
||||
}
|
||||
|
||||
// JoinByInvitation POST /api/classes/join/invite
|
||||
func (h *ClassHandler) JoinByInvitation(c *gin.Context) {
|
||||
userID := middleware.CurrentUserID(c)
|
||||
var req service.JoinByInvitationReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "邀请码不能为空")
|
||||
return
|
||||
}
|
||||
class, err := h.svc.JoinByInvitation(userID, &req)
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"message": "加入成功", "class_id": class.ID, "class_name": class.Name})
|
||||
}
|
||||
|
||||
// Mine GET /api/classes/mine
|
||||
func (h *ClassHandler) Mine(c *gin.Context) {
|
||||
userID := middleware.CurrentUserID(c)
|
||||
list, err := h.svc.ListMyClasses(userID)
|
||||
if err != nil {
|
||||
response.Internal(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, list)
|
||||
}
|
||||
|
||||
// CreateInvitation POST /api/classes/:id/invitation
|
||||
func (h *ClassHandler) CreateInvitation(c *gin.Context) {
|
||||
userID := middleware.CurrentUserID(c)
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
var body struct {
|
||||
MaxUses int `json:"max_uses"`
|
||||
ExpireDays int `json:"expire_days"`
|
||||
}
|
||||
c.ShouldBindJSON(&body)
|
||||
inv, err := h.svc.CreateInvitation(uint(id), userID, body.MaxUses, body.ExpireDays)
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.Created(c, inv)
|
||||
}
|
||||
|
||||
// ListInvitations GET /api/classes/:id/invitations
|
||||
func (h *ClassHandler) ListInvitations(c *gin.Context) {
|
||||
userID := middleware.CurrentUserID(c)
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
list, err := h.svc.ListInvitations(uint(id), userID)
|
||||
if err != nil {
|
||||
response.Forbidden(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, list)
|
||||
}
|
||||
|
||||
// DisableInvitation DELETE /api/classes/:id/invitations/:inv_id
|
||||
func (h *ClassHandler) DisableInvitation(c *gin.Context) {
|
||||
userID := middleware.CurrentUserID(c)
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
invID, _ := strconv.ParseUint(c.Param("inv_id"), 10, 64)
|
||||
if err := h.svc.DisableInvitation(uint(id), uint(invID), userID); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"message": "已禁用"})
|
||||
}
|
||||
|
||||
// TransferAdmin POST /api/classes/:id/transfer
|
||||
func (h *ClassHandler) TransferAdmin(c *gin.Context) {
|
||||
userID := middleware.CurrentUserID(c)
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
var body struct {
|
||||
ToUserID uint `json:"to_user_id" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
response.BadRequest(c, "请指定目标用户")
|
||||
return
|
||||
}
|
||||
if err := h.svc.TransferAdmin(uint(id), userID, body.ToUserID); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"message": "管理员已转移"})
|
||||
}
|
||||
|
||||
// RemoveMember DELETE /api/classes/:id/members/:user_id
|
||||
func (h *ClassHandler) RemoveMember(c *gin.Context) {
|
||||
userID := middleware.CurrentUserID(c)
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
targetID, _ := strconv.ParseUint(c.Param("user_id"), 10, 64)
|
||||
if err := h.svc.RemoveMember(uint(id), userID, uint(targetID)); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"message": "已移除"})
|
||||
}
|
||||
|
||||
// UploadPhoto POST /api/classes/:id/photos
|
||||
func (h *ClassHandler) UploadPhoto(c *gin.Context) {
|
||||
userID := middleware.CurrentUserID(c)
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
var body struct {
|
||||
PhotoURL string `json:"photo_url" binding:"required"`
|
||||
Caption string `json:"caption"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
response.BadRequest(c, "请先上传图片获取URL")
|
||||
return
|
||||
}
|
||||
photo, err := h.svc.UploadPhoto(uint(id), userID, body.PhotoURL, body.Caption)
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.Created(c, photo)
|
||||
}
|
||||
|
||||
// DeletePhoto DELETE /api/classes/:id/photos/:photo_id
|
||||
func (h *ClassHandler) DeletePhoto(c *gin.Context) {
|
||||
userID := middleware.CurrentUserID(c)
|
||||
isSuperAdmin := middleware.CurrentRole(c) == "admin"
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
photoID, _ := strconv.ParseUint(c.Param("photo_id"), 10, 64)
|
||||
if err := h.svc.DeletePhoto(uint(id), uint(photoID), userID, isSuperAdmin); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"message": "已删除"})
|
||||
}
|
||||
|
||||
// Resubmit POST /api/classes/:id/resubmit
|
||||
func (h *ClassHandler) Resubmit(c *gin.Context) {
|
||||
userID := middleware.CurrentUserID(c)
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
var req service.ResubmitReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "参数错误")
|
||||
return
|
||||
}
|
||||
if err := h.svc.Resubmit(uint(id), userID, &req); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"message": "已重新提交审核"})
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"photowall/internal/service"
|
||||
"photowall/pkg/response"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type ConfigHandler struct {
|
||||
cfg *service.ConfigService
|
||||
}
|
||||
|
||||
func NewConfigHandler(cfg *service.ConfigService) *ConfigHandler {
|
||||
return &ConfigHandler{cfg: cfg}
|
||||
}
|
||||
|
||||
// GetAll GET /api/admin/config 获取所有系统配置
|
||||
func (h *ConfigHandler) GetAll(c *gin.Context) {
|
||||
list, err := h.cfg.GetAll()
|
||||
if err != nil {
|
||||
response.Internal(c, "获取配置失败")
|
||||
return
|
||||
}
|
||||
// 按分类分组返回
|
||||
result := gin.H{
|
||||
"basic": []gin.H{},
|
||||
"geetest": []gin.H{},
|
||||
"sms": []gin.H{},
|
||||
"wechat": []gin.H{},
|
||||
"storage": []gin.H{},
|
||||
}
|
||||
for _, item := range list {
|
||||
result[item.Category] = append(result[item.Category].([]gin.H), gin.H{
|
||||
"key": item.Key,
|
||||
"value": item.Value,
|
||||
"label": item.Label,
|
||||
"description": item.Description,
|
||||
"placeholder": item.Placeholder,
|
||||
"doc_link": item.DocLink,
|
||||
})
|
||||
}
|
||||
response.OK(c, result)
|
||||
}
|
||||
|
||||
// Update PUT /api/admin/config 批量更新配置
|
||||
func (h *ConfigHandler) Update(c *gin.Context) {
|
||||
var req service.UpdateConfigReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "参数错误:"+err.Error())
|
||||
return
|
||||
}
|
||||
if err := h.cfg.Update(&req); err != nil {
|
||||
response.Internal(c, "更新配置失败")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"message": "配置更新成功,部分配置需重启服务生效"})
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"photowall/internal/middleware"
|
||||
"photowall/internal/model"
|
||||
"photowall/internal/service"
|
||||
"photowall/pkg/response"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type MenuHandler struct {
|
||||
menu *service.MenuService
|
||||
}
|
||||
|
||||
func NewMenuHandler(menu *service.MenuService) *MenuHandler {
|
||||
return &MenuHandler{menu: menu}
|
||||
}
|
||||
|
||||
// GetUserMenus GET /api/menus 获取当前用户的菜单树(动态路由用)
|
||||
func (h *MenuHandler) GetUserMenus(c *gin.Context) {
|
||||
isAdmin := middleware.CurrentRole(c) == "admin"
|
||||
tree, err := h.menu.GetMenuTree(isAdmin)
|
||||
if err != nil {
|
||||
response.Internal(c, "获取菜单失败")
|
||||
return
|
||||
}
|
||||
response.OK(c, tree)
|
||||
}
|
||||
|
||||
// GetAll GET /api/admin/menus 获取所有菜单(管理用)
|
||||
func (h *MenuHandler) GetAll(c *gin.Context) {
|
||||
list, err := h.menu.GetAll()
|
||||
if err != nil {
|
||||
response.Internal(c, "获取菜单失败")
|
||||
return
|
||||
}
|
||||
response.OK(c, list)
|
||||
}
|
||||
|
||||
// Create POST /api/admin/menus 创建菜单
|
||||
func (h *MenuHandler) Create(c *gin.Context) {
|
||||
var m model.Menu
|
||||
if err := c.ShouldBindJSON(&m); err != nil {
|
||||
response.BadRequest(c, "参数错误:"+err.Error())
|
||||
return
|
||||
}
|
||||
if err := h.menu.Create(&m); err != nil {
|
||||
response.Internal(c, "创建菜单失败")
|
||||
return
|
||||
}
|
||||
response.Created(c, m)
|
||||
}
|
||||
|
||||
// Update PUT /api/admin/menus/:id 更新菜单
|
||||
func (h *MenuHandler) Update(c *gin.Context) {
|
||||
id, _ := strconv.Atoi(c.Param("id"))
|
||||
var m model.Menu
|
||||
if err := c.ShouldBindJSON(&m); err != nil {
|
||||
response.BadRequest(c, "参数错误:"+err.Error())
|
||||
return
|
||||
}
|
||||
m.ID = uint(id)
|
||||
if err := h.menu.Update(&m); err != nil {
|
||||
response.Internal(c, "更新菜单失败")
|
||||
return
|
||||
}
|
||||
response.OK(c, m)
|
||||
}
|
||||
|
||||
// Delete DELETE /api/admin/menus/:id 删除菜单
|
||||
func (h *MenuHandler) Delete(c *gin.Context) {
|
||||
id, _ := strconv.Atoi(c.Param("id"))
|
||||
if err := h.menu.Delete(uint(id)); err != nil {
|
||||
response.Internal(c, "删除菜单失败")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"message": "删除成功"})
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"photowall/internal/service"
|
||||
"photowall/pkg/response"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type RegionHandler struct {
|
||||
svc *service.RegionService
|
||||
}
|
||||
|
||||
func NewRegionHandler(svc *service.RegionService) *RegionHandler {
|
||||
return &RegionHandler{svc: svc}
|
||||
}
|
||||
|
||||
// List GET /api/regions?parent_id=0
|
||||
func (h *RegionHandler) List(c *gin.Context) {
|
||||
parentID, _ := strconv.ParseUint(c.DefaultQuery("parent_id", "0"), 10, 64)
|
||||
list, err := h.svc.ListByParent(uint(parentID))
|
||||
if err != nil {
|
||||
response.Internal(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, list)
|
||||
}
|
||||
|
||||
// Countries GET /api/regions/countries
|
||||
func (h *RegionHandler) Countries(c *gin.Context) {
|
||||
list, err := h.svc.ListCountries()
|
||||
if err != nil {
|
||||
response.Internal(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, list)
|
||||
}
|
||||
|
||||
// Path GET /api/regions/:id/path
|
||||
func (h *RegionHandler) Path(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
path, err := h.svc.GetPath(uint(id))
|
||||
if err != nil {
|
||||
response.NotFound(c, "地区不存在")
|
||||
return
|
||||
}
|
||||
response.OK(c, path)
|
||||
}
|
||||
|
||||
// Search GET /api/regions/search?keyword=xxx
|
||||
func (h *RegionHandler) Search(c *gin.Context) {
|
||||
keyword := c.Query("keyword")
|
||||
if keyword == "" {
|
||||
response.BadRequest(c, "请输入搜索关键词")
|
||||
return
|
||||
}
|
||||
list, err := h.svc.Search(keyword, 20)
|
||||
if err != nil {
|
||||
response.Internal(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, list)
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"photowall/internal/model"
|
||||
"photowall/internal/service"
|
||||
"photowall/pkg/response"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type SchoolHandler struct {
|
||||
svc *service.SchoolService
|
||||
}
|
||||
|
||||
func NewSchoolHandler(svc *service.SchoolService) *SchoolHandler {
|
||||
return &SchoolHandler{svc: svc}
|
||||
}
|
||||
|
||||
// List GET /api/schools
|
||||
func (h *SchoolHandler) List(c *gin.Context) {
|
||||
var q service.SchoolQuery
|
||||
if err := c.ShouldBindQuery(&q); err != nil {
|
||||
response.BadRequest(c, "参数错误")
|
||||
return
|
||||
}
|
||||
result, err := h.svc.List(&q)
|
||||
if err != nil {
|
||||
response.Internal(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, result)
|
||||
}
|
||||
|
||||
// Create POST /api/schools
|
||||
func (h *SchoolHandler) Create(c *gin.Context) {
|
||||
var school model.School
|
||||
if err := c.ShouldBindJSON(&school); err != nil {
|
||||
response.BadRequest(c, "参数错误")
|
||||
return
|
||||
}
|
||||
if err := h.svc.Create(&school); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.Created(c, school)
|
||||
}
|
||||
|
||||
// Get GET /api/schools/:id
|
||||
func (h *SchoolHandler) Get(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
school, err := h.svc.GetByID(uint(id))
|
||||
if err != nil {
|
||||
response.NotFound(c, err.Error())
|
||||
return
|
||||
}
|
||||
// 附带学院列表
|
||||
colleges, _ := h.svc.ListColleges(uint(id))
|
||||
response.OK(c, gin.H{"school": school, "colleges": colleges})
|
||||
}
|
||||
|
||||
// Update PUT /api/schools/:id
|
||||
func (h *SchoolHandler) Update(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
var body map[string]interface{}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
response.BadRequest(c, "参数错误")
|
||||
return
|
||||
}
|
||||
// 只允许更新部分字段
|
||||
updates := map[string]interface{}{}
|
||||
if v, ok := body["name"]; ok {
|
||||
updates["name"] = v
|
||||
}
|
||||
if v, ok := body["name_en"]; ok {
|
||||
updates["name_en"] = v
|
||||
}
|
||||
if v, ok := body["school_type"]; ok {
|
||||
updates["school_type"] = v
|
||||
}
|
||||
if v, ok := body["address"]; ok {
|
||||
updates["address"] = v
|
||||
}
|
||||
if err := h.svc.Update(uint(id), updates); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"message": "更新成功"})
|
||||
}
|
||||
|
||||
// Delete DELETE /api/schools/:id
|
||||
func (h *SchoolHandler) Delete(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err := h.svc.Delete(uint(id)); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"message": "删除成功"})
|
||||
}
|
||||
|
||||
// ---- 学院 ----
|
||||
|
||||
// ListColleges GET /api/schools/:id/colleges
|
||||
func (h *SchoolHandler) ListColleges(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
list, err := h.svc.ListColleges(uint(id))
|
||||
if err != nil {
|
||||
response.Internal(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, list)
|
||||
}
|
||||
|
||||
// CreateCollege POST /api/schools/:id/colleges
|
||||
func (h *SchoolHandler) CreateCollege(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
var body struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
response.BadRequest(c, "学院名称不能为空")
|
||||
return
|
||||
}
|
||||
college, err := h.svc.CreateCollege(uint(id), body.Name)
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.Created(c, college)
|
||||
}
|
||||
|
||||
// UpdateCollege PUT /api/colleges/:id
|
||||
func (h *SchoolHandler) UpdateCollege(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
var body struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
response.BadRequest(c, "学院名称不能为空")
|
||||
return
|
||||
}
|
||||
if err := h.svc.UpdateCollege(uint(id), body.Name); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"message": "更新成功"})
|
||||
}
|
||||
|
||||
// DeleteCollege DELETE /api/colleges/:id
|
||||
func (h *SchoolHandler) DeleteCollege(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err := h.svc.DeleteCollege(uint(id)); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"message": "删除成功"})
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"photowall/internal/config"
|
||||
"photowall/internal/middleware"
|
||||
"photowall/internal/model"
|
||||
"photowall/pkg/response"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type UploadHandler struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewUploadHandler(db *gorm.DB) *UploadHandler {
|
||||
return &UploadHandler{db: db}
|
||||
}
|
||||
|
||||
// Upload POST /api/upload
|
||||
// 通用文件上传,MD5去重,返回可访问的 URL。type 参数:class(毕业照)/photo(个人照片)/avatar
|
||||
func (h *UploadHandler) Upload(c *gin.Context) {
|
||||
userID := middleware.CurrentUserID(c)
|
||||
uploadType := c.DefaultPostForm("type", "photo")
|
||||
|
||||
allowedDirs := map[string]string{
|
||||
"class": "classes",
|
||||
"photo": "photos",
|
||||
"avatar": "avatars",
|
||||
}
|
||||
subDir, ok := allowedDirs[uploadType]
|
||||
if !ok {
|
||||
subDir = "photos"
|
||||
}
|
||||
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
response.BadRequest(c, "请选择要上传的文件")
|
||||
return
|
||||
}
|
||||
|
||||
// 限制大小 10MB
|
||||
if file.Size > 10*1024*1024 {
|
||||
response.BadRequest(c, "文件大小不能超过 10MB")
|
||||
return
|
||||
}
|
||||
|
||||
// 校验图片格式
|
||||
ext := strings.ToLower(filepath.Ext(file.Filename))
|
||||
allowedExts := map[string]bool{".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true}
|
||||
if !allowedExts[ext] {
|
||||
response.BadRequest(c, "仅支持 JPG/PNG/GIF/WEBP 格式")
|
||||
return
|
||||
}
|
||||
|
||||
// 打开文件计算 MD5
|
||||
src, err := file.Open()
|
||||
if err != nil {
|
||||
response.Internal(c, "文件读取失败")
|
||||
return
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
hash := md5.New()
|
||||
if _, err := io.Copy(hash, src); err != nil {
|
||||
response.Internal(c, "文件MD5计算失败")
|
||||
return
|
||||
}
|
||||
fileMd5 := hex.EncodeToString(hash.Sum(nil))
|
||||
|
||||
// MD5 去重:已存在则返回已有路径
|
||||
var existing model.UploadFile
|
||||
if err := h.db.Where("md5 = ?", fileMd5).First(&existing).Error; err == nil {
|
||||
// 引用次数+1
|
||||
h.db.Model(&existing).Update("ref_count", gorm.Expr("ref_count + 1"))
|
||||
response.OK(c, gin.H{
|
||||
"url": existing.FileUrl,
|
||||
"filename": existing.FileName,
|
||||
"size": existing.FileSize,
|
||||
"md5": fileMd5,
|
||||
"duplicate": true,
|
||||
"message": "文件已存在,使用已有路径",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 新文件:保存到磁盘
|
||||
src.Seek(0, io.SeekStart) // 重置文件指针
|
||||
now := time.Now().Format("20060102")
|
||||
filename := fmt.Sprintf("%s_%d_%s%s", now, userID, uuid.New().String()[:8], ext)
|
||||
relPath := filepath.Join(subDir, filename)
|
||||
fullPath := filepath.Join(config.C.UploadDir, relPath)
|
||||
|
||||
if err := c.SaveUploadedFile(file, fullPath); err != nil {
|
||||
response.Internal(c, "文件保存失败:"+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 记录到文件表
|
||||
url := "/uploads/" + strings.ReplaceAll(relPath, "\\", "/")
|
||||
record := model.UploadFile{
|
||||
Md5: fileMd5,
|
||||
FilePath: relPath,
|
||||
FileUrl: url,
|
||||
FileName: filename,
|
||||
FileSize: file.Size,
|
||||
FileType: uploadType,
|
||||
UploaderID: userID,
|
||||
RefCount: 1,
|
||||
}
|
||||
h.db.Create(&record)
|
||||
|
||||
response.OK(c, gin.H{
|
||||
"url": url,
|
||||
"filename": filename,
|
||||
"size": file.Size,
|
||||
"md5": fileMd5,
|
||||
"duplicate": false,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"photowall/internal/middleware"
|
||||
"photowall/internal/model"
|
||||
"photowall/internal/service"
|
||||
"photowall/pkg/response"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type UserHandler struct {
|
||||
svc *service.UserService
|
||||
}
|
||||
|
||||
func NewUserHandler(svc *service.UserService) *UserHandler {
|
||||
return &UserHandler{svc: svc}
|
||||
}
|
||||
|
||||
// Profile GET /api/user/profile
|
||||
func (h *UserHandler) Profile(c *gin.Context) {
|
||||
userID := middleware.CurrentUserID(c)
|
||||
profile, err := h.svc.GetProfile(userID)
|
||||
if err != nil {
|
||||
response.NotFound(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, profile)
|
||||
}
|
||||
|
||||
// UpdateProfile PUT /api/user/profile
|
||||
func (h *UserHandler) UpdateProfile(c *gin.Context) {
|
||||
userID := middleware.CurrentUserID(c)
|
||||
var req service.UpdateProfileReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "参数错误")
|
||||
return
|
||||
}
|
||||
if err := h.svc.UpdateProfile(userID, &req); err != nil {
|
||||
response.Internal(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"message": "更新成功"})
|
||||
}
|
||||
|
||||
// AddContact POST /api/user/contacts
|
||||
func (h *UserHandler) AddContact(c *gin.Context) {
|
||||
userID := middleware.CurrentUserID(c)
|
||||
var body struct {
|
||||
Type model.ContactType `json:"type" binding:"required"`
|
||||
Value string `json:"value" binding:"required"`
|
||||
IsPrimary bool `json:"is_primary"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
response.BadRequest(c, "参数错误")
|
||||
return
|
||||
}
|
||||
contact, err := h.svc.AddContact(userID, body.Type, body.Value, body.IsPrimary)
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.Created(c, contact)
|
||||
}
|
||||
|
||||
// UpdateContact PUT /api/user/contacts/:id
|
||||
func (h *UserHandler) UpdateContact(c *gin.Context) {
|
||||
userID := middleware.CurrentUserID(c)
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
var body struct {
|
||||
Value string `json:"value"`
|
||||
IsPrimary *bool `json:"is_primary"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
response.BadRequest(c, "参数错误")
|
||||
return
|
||||
}
|
||||
if err := h.svc.UpdateContact(userID, uint(id), body.Value, body.IsPrimary); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"message": "更新成功"})
|
||||
}
|
||||
|
||||
// DeleteContact DELETE /api/user/contacts/:id
|
||||
func (h *UserHandler) DeleteContact(c *gin.Context) {
|
||||
userID := middleware.CurrentUserID(c)
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err := h.svc.DeleteContact(userID, uint(id)); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"message": "删除成功"})
|
||||
}
|
||||
|
||||
// AddEducation POST /api/user/education
|
||||
func (h *UserHandler) AddEducation(c *gin.Context) {
|
||||
userID := middleware.CurrentUserID(c)
|
||||
var e model.EducationHistory
|
||||
if err := c.ShouldBindJSON(&e); err != nil {
|
||||
response.BadRequest(c, "参数错误")
|
||||
return
|
||||
}
|
||||
if e.SchoolName == "" {
|
||||
response.BadRequest(c, "学校名称不能为空")
|
||||
return
|
||||
}
|
||||
if err := h.svc.AddEducation(userID, &e); err != nil {
|
||||
response.Internal(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.Created(c, e)
|
||||
}
|
||||
|
||||
// UpdateEducation PUT /api/user/education/:id
|
||||
func (h *UserHandler) UpdateEducation(c *gin.Context) {
|
||||
userID := middleware.CurrentUserID(c)
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
var e model.EducationHistory
|
||||
if err := c.ShouldBindJSON(&e); err != nil {
|
||||
response.BadRequest(c, "参数错误")
|
||||
return
|
||||
}
|
||||
if err := h.svc.UpdateEducation(userID, uint(id), &e); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"message": "更新成功"})
|
||||
}
|
||||
|
||||
// DeleteEducation DELETE /api/user/education/:id
|
||||
func (h *UserHandler) DeleteEducation(c *gin.Context) {
|
||||
userID := middleware.CurrentUserID(c)
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err := h.svc.DeleteEducation(userID, uint(id)); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"message": "删除成功"})
|
||||
}
|
||||
|
||||
// HasEducation GET /api/user/has-education (创建班级前置检查)
|
||||
func (h *UserHandler) HasEducation(c *gin.Context) {
|
||||
userID := middleware.CurrentUserID(c)
|
||||
response.OK(c, gin.H{"has_education": h.svc.HasEducation(userID)})
|
||||
}
|
||||
|
||||
// PublicProfile GET /api/user/:id 公开信息(班级成员查看)
|
||||
func (h *UserHandler) PublicProfile(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
user, err := h.svc.GetUserByID(uint(id))
|
||||
if err != nil {
|
||||
response.NotFound(c, err.Error())
|
||||
return
|
||||
}
|
||||
contacts := h.svc.GetUserContacts(uint(id))
|
||||
response.OK(c, gin.H{"user": user, "contacts": contacts})
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"photowall/pkg/jwt"
|
||||
"photowall/pkg/response"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Context keys
|
||||
const (
|
||||
CtxUserID = "user_id"
|
||||
CtxUsername = "username"
|
||||
CtxRole = "role"
|
||||
)
|
||||
|
||||
// Auth JWT 认证中间件
|
||||
func Auth(jm *jwt.Manager) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
header := c.GetHeader("Authorization")
|
||||
if header == "" {
|
||||
response.Unauthorized(c, "未登录")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
parts := strings.SplitN(header, " ", 2)
|
||||
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
|
||||
response.Unauthorized(c, "认证格式错误")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
claims, err := jm.Parse(parts[1])
|
||||
if err != nil {
|
||||
response.Unauthorized(c, "登录已过期,请重新登录")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Set(CtxUserID, claims.UserID)
|
||||
c.Set(CtxUsername, claims.Username)
|
||||
c.Set(CtxRole, claims.Role)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// AdminOnly 平台管理员权限
|
||||
func AdminOnly() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
role, _ := c.Get(CtxRole)
|
||||
if role != "admin" {
|
||||
response.Forbidden(c, "需要平台管理员权限")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// CurrentUserID 从 context 取当前用户ID
|
||||
func CurrentUserID(c *gin.Context) uint {
|
||||
v, ok := c.Get(CtxUserID)
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
id, _ := v.(uint)
|
||||
return id
|
||||
}
|
||||
|
||||
// CurrentUsername 从 context 取当前用户名
|
||||
func CurrentUsername(c *gin.Context) string {
|
||||
v, _ := c.Get(CtxUsername)
|
||||
s, _ := v.(string)
|
||||
return s
|
||||
}
|
||||
|
||||
// CurrentRole 从 context 取当前角色
|
||||
func CurrentRole(c *gin.Context) string {
|
||||
v, _ := c.Get(CtxRole)
|
||||
s, _ := v.(string)
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// SmsCode 短信验证码表
|
||||
type SmsCode struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Phone string `gorm:"size:20;index;not null" json:"phone"`
|
||||
Code string `gorm:"size:8;not null" json:"-"`
|
||||
Scene string `gorm:"size:32;not null" json:"scene"` // login / register / reset_password
|
||||
Used bool `gorm:"default:false" json:"used"`
|
||||
ExpireAt time.Time `json:"expire_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// PasswordReset 密码重置码表
|
||||
type PasswordReset struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
UserID uint `gorm:"index;not null" json:"user_id"`
|
||||
Code string `gorm:"size:8;index;not null" json:"-"`
|
||||
Used bool `gorm:"default:false" json:"used"`
|
||||
ExpireAt time.Time `json:"expire_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ClassStatus 班级审核状态
|
||||
type ClassStatus string
|
||||
|
||||
const (
|
||||
ClassPending ClassStatus = "pending" // 待审核(名字不足或等待平台审核)
|
||||
ClassActive ClassStatus = "active" // 正常
|
||||
ClassRejected ClassStatus = "rejected" // 被打回,需补齐
|
||||
ClassDeleted ClassStatus = "deleted" // 已删除(30天未补齐自动删除或手动删除)
|
||||
)
|
||||
|
||||
// Class 班级
|
||||
// 唯一性:同一学校+学院+年级+班级序号+毕业年份 唯一
|
||||
type Class struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
SchoolID uint `gorm:"index:idx_class_unique,priority:1;not null" json:"school_id"`
|
||||
CollegeID uint `gorm:"index:idx_class_unique,priority:2" json:"college_id"`
|
||||
Name string `gorm:"size:128;not null" json:"name"` // 班级展示名,如"计算机科学与技术2018级1班"
|
||||
Grade string `gorm:"size:32;index:idx_class_unique,priority:3" json:"grade"` // 年级,如"2018级"
|
||||
ClassNo string `gorm:"size:32;index:idx_class_unique,priority:4" json:"class_no"` // 班级序号,如"1班"/"3班"
|
||||
GraduateYear int `gorm:"index:idx_class_unique,priority:5" json:"graduate_year"` // 毕业年份
|
||||
AdminUserID uint `gorm:"index;not null" json:"admin_user_id"`
|
||||
GraduationPhoto string `gorm:"size:512" json:"graduation_photo"` // 毕业照URL(每个班级唯一一张)
|
||||
StudentNamesJSON string `gorm:"type:text" json:"-"` // 班级所有同学名字 JSON 数组
|
||||
TeacherNamesJSON string `gorm:"type:text" json:"-"` // 老师名字 JSON 数组
|
||||
Status ClassStatus `gorm:"size:16;default:pending;index" json:"status"`
|
||||
RejectReason string `gorm:"size:512" json:"reject_reason"`
|
||||
AuditDeadline time.Time `json:"audit_deadline"` // 补齐截止时间(创建+30天)
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// StudentNames 读写同学名单
|
||||
func (c *Class) StudentNames() []string {
|
||||
var list []string
|
||||
if c.StudentNamesJSON == "" {
|
||||
return list
|
||||
}
|
||||
_ = json.Unmarshal([]byte(c.StudentNamesJSON), &list)
|
||||
return list
|
||||
}
|
||||
|
||||
func (c *Class) SetStudentNames(names []string) {
|
||||
if names == nil {
|
||||
names = []string{}
|
||||
}
|
||||
b, _ := json.Marshal(names)
|
||||
c.StudentNamesJSON = string(b)
|
||||
}
|
||||
|
||||
// TeacherNames 读写老师名单
|
||||
func (c *Class) TeacherNames() []string {
|
||||
var list []string
|
||||
if c.TeacherNamesJSON == "" {
|
||||
return list
|
||||
}
|
||||
_ = json.Unmarshal([]byte(c.TeacherNamesJSON), &list)
|
||||
return list
|
||||
}
|
||||
|
||||
func (c *Class) SetTeacherNames(names []string) {
|
||||
if names == nil {
|
||||
names = []string{}
|
||||
}
|
||||
b, _ := json.Marshal(names)
|
||||
c.TeacherNamesJSON = string(b)
|
||||
}
|
||||
|
||||
// MinStudentCount 加入班级验证所需最少同学数,也是创建班级录入名单的下限
|
||||
const MinStudentCount = 8
|
||||
|
||||
// ClassMemberRole 班级成员角色
|
||||
type ClassMemberRole string
|
||||
|
||||
const (
|
||||
MemberRoleAdmin ClassMemberRole = "admin"
|
||||
MemberRoleMember ClassMemberRole = "member"
|
||||
)
|
||||
|
||||
// JoinMethod 加入方式
|
||||
type JoinMethod string
|
||||
|
||||
const (
|
||||
JoinByInvite JoinMethod = "invite" // 管理员邀请/邀请码
|
||||
JoinBySearch JoinMethod = "search" // 搜索+8人验证
|
||||
)
|
||||
|
||||
// ClassMember 班级成员
|
||||
type ClassMember struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
ClassID uint `gorm:"uniqueIndex:idx_class_user,priority:1;not null" json:"class_id"`
|
||||
UserID uint `gorm:"uniqueIndex:idx_class_user,priority:2;not null" json:"user_id"`
|
||||
Role ClassMemberRole `gorm:"size:16;default:member;index" json:"role"`
|
||||
JoinMethod JoinMethod `gorm:"size:16" json:"join_method"`
|
||||
JoinedAt time.Time `json:"joined_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// Invitation 班级邀请码
|
||||
type Invitation struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
ClassID uint `gorm:"index;not null" json:"class_id"`
|
||||
InviterID uint `gorm:"not null" json:"inviter_id"`
|
||||
Code string `gorm:"size:32;uniqueIndex;not null" json:"code"`
|
||||
MaxUses int `gorm:"default:50" json:"max_uses"`
|
||||
UsedCount int `gorm:"default:0" json:"used_count"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
Status string `gorm:"size:16;default:active" json:"status"` // active / disabled
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// SystemConfig 系统配置表(key-value 存储)
|
||||
type SystemConfig struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Key string `gorm:"size:64;uniqueIndex;not null" json:"key"`
|
||||
Value string `gorm:"size:1024" json:"value"`
|
||||
Label string `gorm:"size:128" json:"label"` // 显示名称
|
||||
Description string `gorm:"size:512" json:"description"` // 参数说明
|
||||
Category string `gorm:"size:32;index" json:"category"` // 分类:basic/geetest/sms/wechat/storage
|
||||
Placeholder string `gorm:"size:256" json:"placeholder"` // 输入框示例
|
||||
DocLink string `gorm:"size:256" json:"doc_link"` // 官方文档/获取地址
|
||||
Sort int `gorm:"default:0" json:"sort"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// Menu 菜单/路由表(动态路由数据源)
|
||||
type Menu struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
ParentID uint `gorm:"default:0;index" json:"parent_id"`
|
||||
Name string `gorm:"size:64;uniqueIndex;not null" json:"name"` // 路由名称(唯一)
|
||||
Path string `gorm:"size:255;not null" json:"path"` // 路由路径,如 /class/list
|
||||
Component string `gorm:"size:255" json:"component"` // 组件相对路径,如 class/list/index(空则为目录)
|
||||
Title string `gorm:"size:64;not null" json:"title"` // 菜单标题
|
||||
Icon string `gorm:"size:64" json:"icon"` // fontawesome 图标类名,如 fas fa-home
|
||||
Sort int `gorm:"default:0" json:"sort"` // 排序(从小到大)
|
||||
Visible bool `gorm:"default:true" json:"visible"` // 是否在菜单中显示
|
||||
RequireAuth bool `gorm:"default:true" json:"require_auth"` // 是否需要登录
|
||||
RequireAdmin bool `gorm:"default:false" json:"require_admin"` // 是否需要管理员权限
|
||||
KeepAlive bool `gorm:"default:false" json:"keep_alive"` // 是否缓存页面
|
||||
Redirect string `gorm:"size:255" json:"redirect"` // 重定向路径(目录菜单用)
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// MenuTree 菜单树结构(前端用)
|
||||
type MenuTree struct {
|
||||
Menu
|
||||
Children []*MenuTree `json:"children"`
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// MaxPhotosPerUserPerClass 每人在每个班级最多留存的当期照片数
|
||||
const MaxPhotosPerUserPerClass = 10
|
||||
|
||||
// ClassPhoto 用户在班级下的个人当期照片(每人每班级最多10张)
|
||||
type ClassPhoto struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
ClassID uint `gorm:"index;not null" json:"class_id"`
|
||||
UserID uint `gorm:"index;not null" json:"user_id"`
|
||||
PhotoURL string `gorm:"size:512;not null" json:"photo_url"`
|
||||
Caption string `gorm:"size:255" json:"caption"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// AuditAction 审核动作
|
||||
type AuditAction string
|
||||
|
||||
const (
|
||||
AuditSubmit AuditAction = "submit" // 提交审核
|
||||
AuditApprove AuditAction = "approve" // 平台审核通过
|
||||
AuditReject AuditAction = "reject" // 平台打回
|
||||
AuditAutoDelete AuditAction = "auto_delete" // 30天未补齐自动删除
|
||||
AuditManualDelete AuditAction = "manual_delete"
|
||||
)
|
||||
|
||||
// AuditLog 班级审核日志
|
||||
type AuditLog struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
ClassID uint `gorm:"index;not null" json:"class_id"`
|
||||
AuditorID uint `json:"auditor_id"` // 平台管理员ID,系统自动操作为0
|
||||
Action AuditAction `gorm:"size:24;index;not null" json:"action"`
|
||||
Reason string `gorm:"size:512" json:"reason"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// RegionLevel 地区层级
|
||||
type RegionLevel string
|
||||
|
||||
const (
|
||||
LevelCountry RegionLevel = "country"
|
||||
LevelProvince RegionLevel = "province" // 省/州/直辖市
|
||||
LevelCounty RegionLevel = "county" // 县/区(最低级)
|
||||
)
|
||||
|
||||
// Region 地区表,自关联三级:国家 -> 省/州 -> 县/区
|
||||
type Region struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Name string `gorm:"size:128;not null;index" json:"name"`
|
||||
NameEn string `gorm:"size:128" json:"name_en"`
|
||||
Level RegionLevel `gorm:"size:16;index;not null" json:"level"`
|
||||
ParentID uint `gorm:"index" json:"parent_id"`
|
||||
Code string `gorm:"size:32;index" json:"code"` // 行政区划代码/国家代码,可选
|
||||
SortOrder int `gorm:"default:0" json:"sort_order"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// SchoolType 学校类型
|
||||
type SchoolType string
|
||||
|
||||
const (
|
||||
SchoolUniversity SchoolType = "university" // 大学(含学院层级)
|
||||
SchoolJuniorHigh SchoolType = "junior_high" // 初中
|
||||
SchoolSeniorHigh SchoolType = "senior_high" // 高中
|
||||
SchoolPrimary SchoolType = "primary" // 小学
|
||||
SchoolKindergarten SchoolType = "kindergarten" // 幼儿园
|
||||
SchoolVocational SchoolType = "vocational" // 职业学校/中专(也可设学院)
|
||||
SchoolOther SchoolType = "other"
|
||||
)
|
||||
|
||||
// HasCollege 该类型学校是否有学院层级
|
||||
func (s SchoolType) HasCollege() bool {
|
||||
return s == SchoolUniversity || s == SchoolVocational
|
||||
}
|
||||
|
||||
// School 学校
|
||||
type School struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
RegionID uint `gorm:"index;not null" json:"region_id"` // 必须挂在 county 级地区下
|
||||
Name string `gorm:"size:128;not null;index" json:"name"`
|
||||
NameEn string `gorm:"size:128" json:"name_en"`
|
||||
SchoolType SchoolType `gorm:"size:24;index;not null" json:"school_type"`
|
||||
Address string `gorm:"size:255" json:"address"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// College 学院(仅大学/职业学校有)
|
||||
type College struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
SchoolID uint `gorm:"index;not null" json:"school_id"`
|
||||
Name string `gorm:"size:128;not null" json:"name"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// SensitiveWord 敏感词表(全局屏蔽用)
|
||||
type SensitiveWord struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Word string `gorm:"size:128;uniqueIndex;not null" json:"word"`
|
||||
Category string `gorm:"size:32;default:other" json:"category"` // politics/porn/violence/advertising/other
|
||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// UploadFile 上传文件记录表(MD5去重用)
|
||||
type UploadFile struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Md5 string `gorm:"size:32;uniqueIndex;not null" json:"md5"`
|
||||
FilePath string `gorm:"size:512;not null" json:"file_path"` // 相对路径,如 classes/20260101_1_abc.jpg
|
||||
FileUrl string `gorm:"size:512;not null" json:"file_url"` // 可访问URL,如 /uploads/classes/xxx.jpg
|
||||
FileName string `gorm:"size:255" json:"file_name"`
|
||||
FileSize int64 `gorm:"default:0" json:"file_size"`
|
||||
FileType string `gorm:"size:32" json:"file_type"` // class/photo/avatar
|
||||
UploaderID uint `gorm:"index" json:"uploader_id"`
|
||||
RefCount int `gorm:"default:1" json:"ref_count"` // 引用次数
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// User 用户表
|
||||
type User struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Username string `gorm:"size:64;uniqueIndex;not null" json:"username"`
|
||||
PasswordHash string `gorm:"size:32;not null" json:"-"` // MD5 32字符
|
||||
Salt string `gorm:"size:32;not null" json:"-"` // 密码盐
|
||||
Email string `gorm:"size:128;index" json:"email"` // 用于找回密码
|
||||
Nickname string `gorm:"size:64" json:"nickname"`
|
||||
Avatar string `gorm:"size:512" json:"avatar"`
|
||||
Bio string `gorm:"size:512" json:"bio"`
|
||||
Role string `gorm:"size:16;default:user;index" json:"role"` // user / admin
|
||||
Status string `gorm:"size:16;default:active;index" json:"status"` // active / banned
|
||||
BanReason string `gorm:"size:255" json:"ban_reason"`
|
||||
BannedAt *time.Time `json:"banned_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// ContactType 联系方式类型
|
||||
type ContactType string
|
||||
|
||||
const (
|
||||
ContactEmail ContactType = "email"
|
||||
ContactQQ ContactType = "qq"
|
||||
ContactWeChat ContactType = "wechat"
|
||||
ContactPhone ContactType = "phone"
|
||||
ContactAddress ContactType = "address"
|
||||
ContactWorkUnit ContactType = "workunit"
|
||||
)
|
||||
|
||||
// ContactMaxCount 各类型联系方式上限
|
||||
var ContactMaxCount = map[ContactType]int{
|
||||
ContactEmail: 2,
|
||||
ContactQQ: 5,
|
||||
ContactWeChat: 2,
|
||||
ContactPhone: 5,
|
||||
ContactAddress: 1,
|
||||
ContactWorkUnit: 1,
|
||||
}
|
||||
|
||||
// UserContact 用户联系方式
|
||||
type UserContact struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
UserID uint `gorm:"index;not null" json:"user_id"`
|
||||
Type ContactType `gorm:"size:16;index;not null" json:"type"`
|
||||
Value string `gorm:"size:255;not null" json:"value"`
|
||||
IsPrimary bool `gorm:"default:false" json:"is_primary"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// EducationHistory 学习履历(用户必须至少有一条才能创建班级)
|
||||
type EducationHistory struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
UserID uint `gorm:"index;not null" json:"user_id"`
|
||||
SchoolID uint `gorm:"index" json:"school_id,omitempty"` // 关联平台学校(可选)
|
||||
CollegeID uint `gorm:"index" json:"college_id,omitempty"` // 关联学院(可选)
|
||||
ClassID uint `gorm:"index" json:"class_id,omitempty"` // 关联班级(可选)
|
||||
SchoolName string `gorm:"size:128;not null" json:"school_name"` // 学校名称(冗余,便于自由填写)
|
||||
CollegeName string `gorm:"size:128" json:"college_name"`
|
||||
Major string `gorm:"size:128" json:"major"`
|
||||
Degree string `gorm:"size:64" json:"degree"` // 本科/硕士/博士/高中/初中/小学/幼儿园
|
||||
StartYear int `json:"start_year"`
|
||||
EndYear int `json:"end_year"`
|
||||
Description string `gorm:"size:512" json:"description"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"photowall/internal/handler"
|
||||
"photowall/internal/middleware"
|
||||
"photowall/pkg/jwt"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Deps 所有 handler 依赖
|
||||
type Deps struct {
|
||||
Auth *handler.AuthHandler
|
||||
User *handler.UserHandler
|
||||
Region *handler.RegionHandler
|
||||
School *handler.SchoolHandler
|
||||
Class *handler.ClassHandler
|
||||
Admin *handler.AdminHandler
|
||||
Upload *handler.UploadHandler
|
||||
Config *handler.ConfigHandler
|
||||
Menu *handler.MenuHandler
|
||||
}
|
||||
|
||||
func Register(r *gin.Engine, jm *jwt.Manager, deps *Deps) {
|
||||
// CORS 中间件
|
||||
r.Use(corsMiddleware())
|
||||
|
||||
// 静态文件:上传的图片
|
||||
r.Static("/uploads", "uploads")
|
||||
|
||||
api := r.Group("/api")
|
||||
|
||||
// ---- 公开路由 ----
|
||||
public := api.Group("")
|
||||
{
|
||||
// 图形验证码
|
||||
public.GET("/captcha", deps.Auth.Captcha)
|
||||
|
||||
// 认证
|
||||
public.POST("/auth/register", deps.Auth.Register)
|
||||
public.POST("/auth/login", deps.Auth.Login)
|
||||
public.POST("/auth/forgot-password", deps.Auth.ForgotPassword)
|
||||
public.POST("/auth/reset-password", deps.Auth.ResetPassword)
|
||||
public.POST("/auth/send-sms", deps.Auth.SendSms)
|
||||
public.POST("/auth/wechat-login", deps.Auth.WechatLogin)
|
||||
public.GET("/auth/wechat-qrcode", deps.Auth.WechatQrcode)
|
||||
|
||||
// 地区(公开只读)
|
||||
public.GET("/regions", deps.Region.List)
|
||||
public.GET("/regions/countries", deps.Region.Countries)
|
||||
public.GET("/regions/search", deps.Region.Search)
|
||||
public.GET("/regions/:id/path", deps.Region.Path)
|
||||
|
||||
// 学校(公开只读)
|
||||
public.GET("/schools", deps.School.List)
|
||||
public.GET("/schools/:id", deps.School.Get)
|
||||
public.GET("/schools/:id/colleges", deps.School.ListColleges)
|
||||
|
||||
// 班级搜索(公开,仅返回 active)
|
||||
public.GET("/classes/search", deps.Class.Search)
|
||||
}
|
||||
|
||||
// ---- 需要登录的路由 ----
|
||||
auth := api.Group("")
|
||||
auth.Use(middleware.Auth(jm))
|
||||
{
|
||||
// 当前用户
|
||||
auth.GET("/auth/me", deps.Auth.Me)
|
||||
|
||||
// 动态路由:获取当前用户菜单
|
||||
auth.GET("/menus", deps.Menu.GetUserMenus)
|
||||
|
||||
// 用户资料
|
||||
auth.GET("/user/profile", deps.User.Profile)
|
||||
auth.PUT("/user/profile", deps.User.UpdateProfile)
|
||||
auth.GET("/user/has-education", deps.User.HasEducation)
|
||||
auth.GET("/user/:id", deps.User.PublicProfile)
|
||||
|
||||
// 联系方式
|
||||
auth.POST("/user/contacts", deps.User.AddContact)
|
||||
auth.PUT("/user/contacts/:id", deps.User.UpdateContact)
|
||||
auth.DELETE("/user/contacts/:id", deps.User.DeleteContact)
|
||||
|
||||
// 学习履历
|
||||
auth.POST("/user/education", deps.User.AddEducation)
|
||||
auth.PUT("/user/education/:id", deps.User.UpdateEducation)
|
||||
auth.DELETE("/user/education/:id", deps.User.DeleteEducation)
|
||||
|
||||
// 学校/学院(登录用户可创建)
|
||||
auth.POST("/schools", deps.School.Create)
|
||||
auth.PUT("/schools/:id", deps.School.Update)
|
||||
auth.DELETE("/schools/:id", deps.School.Delete)
|
||||
auth.POST("/schools/:id/colleges", deps.School.CreateCollege)
|
||||
auth.PUT("/colleges/:id", deps.School.UpdateCollege)
|
||||
auth.DELETE("/colleges/:id", deps.School.DeleteCollege)
|
||||
|
||||
// 班级
|
||||
auth.POST("/classes", deps.Class.Create)
|
||||
auth.GET("/classes/mine", deps.Class.Mine)
|
||||
auth.GET("/classes/:id", deps.Class.Detail)
|
||||
auth.POST("/classes/:id/resubmit", deps.Class.Resubmit)
|
||||
|
||||
// 加入班级
|
||||
auth.POST("/classes/join/search", deps.Class.JoinBySearch)
|
||||
auth.POST("/classes/join/invite", deps.Class.JoinByInvitation)
|
||||
|
||||
// 邀请码(管理员)
|
||||
auth.POST("/classes/:id/invitation", deps.Class.CreateInvitation)
|
||||
auth.GET("/classes/:id/invitations", deps.Class.ListInvitations)
|
||||
auth.DELETE("/classes/:id/invitations/:inv_id", deps.Class.DisableInvitation)
|
||||
|
||||
// 成员管理
|
||||
auth.POST("/classes/:id/transfer", deps.Class.TransferAdmin)
|
||||
auth.DELETE("/classes/:id/members/:user_id", deps.Class.RemoveMember)
|
||||
|
||||
// 个人照片
|
||||
auth.POST("/classes/:id/photos", deps.Class.UploadPhoto)
|
||||
auth.DELETE("/classes/:id/photos/:photo_id", deps.Class.DeletePhoto)
|
||||
|
||||
// 文件上传
|
||||
auth.POST("/upload", deps.Upload.Upload)
|
||||
}
|
||||
|
||||
// ---- 平台管理员路由 ----
|
||||
admin := api.Group("/admin")
|
||||
admin.Use(middleware.Auth(jm), middleware.AdminOnly())
|
||||
{
|
||||
admin.GET("/stats", deps.Admin.Stats)
|
||||
admin.GET("/stats/detailed", deps.Admin.DetailedStats)
|
||||
admin.GET("/classes", deps.Admin.AuditQueue)
|
||||
admin.POST("/classes/:id/approve", deps.Admin.Approve)
|
||||
admin.POST("/classes/:id/reject", deps.Admin.Reject)
|
||||
admin.DELETE("/classes/:id", deps.Admin.Delete)
|
||||
admin.GET("/classes/:id/logs", deps.Admin.AuditLogs)
|
||||
admin.POST("/cleanup", deps.Admin.TriggerCleanup)
|
||||
// 系统配置管理
|
||||
admin.GET("/config", deps.Config.GetAll)
|
||||
admin.PUT("/config", deps.Config.Update)
|
||||
// 菜单管理
|
||||
admin.GET("/menus", deps.Menu.GetAll)
|
||||
admin.POST("/menus", deps.Menu.Create)
|
||||
admin.PUT("/menus/:id", deps.Menu.Update)
|
||||
admin.DELETE("/menus/:id", deps.Menu.Delete)
|
||||
// 账号封禁管理
|
||||
admin.GET("/users/banned", deps.Admin.BannedUsers)
|
||||
admin.POST("/users/:id/ban", deps.Admin.BanUser)
|
||||
admin.POST("/users/:id/unban", deps.Admin.UnbanUser)
|
||||
// 敏感词管理
|
||||
admin.GET("/sensitive-words", deps.Admin.SensitiveWords)
|
||||
admin.POST("/sensitive-words", deps.Admin.AddSensitiveWord)
|
||||
admin.DELETE("/sensitive-words/:id", deps.Admin.DeleteSensitiveWord)
|
||||
admin.PUT("/sensitive-words/:id/toggle", deps.Admin.ToggleSensitiveWord)
|
||||
}
|
||||
}
|
||||
|
||||
// corsMiddleware 简单 CORS 处理(开发环境允许所有来源)
|
||||
func corsMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Header("Access-Control-Allow-Origin", "*")
|
||||
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Authorization, X-Requested-With")
|
||||
c.Header("Access-Control-Max-Age", "86400")
|
||||
if c.Request.Method == "OPTIONS" {
|
||||
c.AbortWithStatus(204)
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"log"
|
||||
"photowall/internal/service"
|
||||
"time"
|
||||
)
|
||||
|
||||
// StartCleanup 启动过期班级清理定时任务。
|
||||
// 每小时检查一次,超过 audit_deadline 仍未通过审核的班级自动删除。
|
||||
func StartCleanup(adminSvc *service.AdminService) {
|
||||
go func() {
|
||||
// 启动后延迟 1 分钟先跑一次,避免服务刚启动就堆积
|
||||
time.Sleep(1 * time.Minute)
|
||||
runCleanup(adminSvc)
|
||||
|
||||
ticker := time.NewTicker(1 * time.Hour)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
runCleanup(adminSvc)
|
||||
}
|
||||
}()
|
||||
log.Println("[scheduler] expired class cleanup started (runs hourly)")
|
||||
}
|
||||
|
||||
func runCleanup(adminSvc *service.AdminService) {
|
||||
count, err := adminSvc.CleanupExpired()
|
||||
if err != nil {
|
||||
log.Printf("[scheduler] cleanup error: %v", err)
|
||||
return
|
||||
}
|
||||
if count > 0 {
|
||||
log.Printf("[scheduler] auto-deleted %d expired classes", count)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"photowall/internal/model"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type AdminService struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewAdminService(db *gorm.DB) *AdminService {
|
||||
return &AdminService{db: db}
|
||||
}
|
||||
|
||||
// AuditQueueReq 审核队列查询
|
||||
type AuditQueueReq struct {
|
||||
Status model.ClassStatus `form:"status"`
|
||||
Keyword string `form:"keyword"`
|
||||
Page int `form:"page"`
|
||||
PageSize int `form:"page_size"`
|
||||
}
|
||||
|
||||
// ListAuditQueue 平台管理员查看审核队列
|
||||
func (s *AdminService) ListAuditQueue(req *AuditQueueReq) (*SchoolListResp, error) {
|
||||
if req.Page <= 0 {
|
||||
req.Page = 1
|
||||
}
|
||||
if req.PageSize <= 0 || req.PageSize > 100 {
|
||||
req.PageSize = 20
|
||||
}
|
||||
db := s.db.Model(&model.Class{}).Where("status != ?", model.ClassDeleted)
|
||||
if req.Status != "" {
|
||||
db = db.Where("status = ?", req.Status)
|
||||
}
|
||||
if req.Keyword != "" {
|
||||
db = db.Where("name LIKE ?", "%"+req.Keyword+"%")
|
||||
}
|
||||
var total int64
|
||||
db.Count(&total)
|
||||
var list []model.Class
|
||||
err := db.Order("audit_deadline asc, id desc").Offset((req.Page - 1) * req.PageSize).Limit(req.PageSize).Find(&list).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &SchoolListResp{List: list, Total: total, Page: req.Page}, nil
|
||||
}
|
||||
|
||||
// Approve 审核通过
|
||||
func (s *AdminService) Approve(classID, auditorID uint, reason string) error {
|
||||
var class model.Class
|
||||
if err := s.db.First(&class, classID).Error; err != nil {
|
||||
return errors.New("班级不存在")
|
||||
}
|
||||
if class.Status == model.ClassDeleted {
|
||||
return errors.New("班级已删除")
|
||||
}
|
||||
class.Status = model.ClassActive
|
||||
class.RejectReason = ""
|
||||
if err := s.db.Save(&class).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
s.log(classID, auditorID, model.AuditApprove, reason)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Reject 打回补齐
|
||||
func (s *AdminService) Reject(classID, auditorID uint, reason string) error {
|
||||
if reason == "" {
|
||||
return errors.New("请填写打回原因")
|
||||
}
|
||||
var class model.Class
|
||||
if err := s.db.First(&class, classID).Error; err != nil {
|
||||
return errors.New("班级不存在")
|
||||
}
|
||||
if class.Status == model.ClassDeleted {
|
||||
return errors.New("班级已删除")
|
||||
}
|
||||
class.Status = model.ClassRejected
|
||||
class.RejectReason = reason
|
||||
if err := s.db.Save(&class).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
s.log(classID, auditorID, model.AuditReject, reason)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ManualDelete 平台管理员手动删除班级
|
||||
func (s *AdminService) ManualDelete(classID, auditorID uint, reason string) error {
|
||||
var class model.Class
|
||||
if err := s.db.First(&class, classID).Error; err != nil {
|
||||
return errors.New("班级不存在")
|
||||
}
|
||||
return s.deleteClass(class, auditorID, model.AuditManualDelete, reason)
|
||||
}
|
||||
|
||||
// CleanupExpired 清理超过审核截止时间仍未通过的班级(30天未补齐自动删除)
|
||||
// 返回被删除的班级数量
|
||||
func (s *AdminService) CleanupExpired() (int, error) {
|
||||
var expired []model.Class
|
||||
// 状态为 pending 或 rejected,且已过 audit_deadline
|
||||
err := s.db.Where("status IN ? AND audit_deadline < ?",
|
||||
[]model.ClassStatus{model.ClassPending, model.ClassRejected},
|
||||
time.Now()).Find(&expired).Error
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
count := 0
|
||||
for _, class := range expired {
|
||||
if err := s.deleteClass(class, 0, model.AuditAutoDelete,
|
||||
fmt.Sprintf("超过 %d 天补齐期限未完成审核,系统自动删除", 30)); err == nil {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// deleteClass 事务删除班级及其关联数据(成员、照片、邀请码)
|
||||
func (s *AdminService) deleteClass(class model.Class, auditorID uint, action model.AuditAction, reason string) error {
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
// 标记删除
|
||||
if err := tx.Model(&model.Class{}).Where("id = ?", class.ID).
|
||||
Updates(map[string]interface{}{
|
||||
"status": model.ClassDeleted,
|
||||
"reject_reason": reason,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// 删除成员关系
|
||||
tx.Where("class_id = ?", class.ID).Delete(&model.ClassMember{})
|
||||
// 删除照片记录(文件保留在磁盘,避免误删;如需清理可加定时任务)
|
||||
tx.Where("class_id = ?", class.ID).Delete(&model.ClassPhoto{})
|
||||
// 禁用邀请码
|
||||
tx.Model(&model.Invitation{}).Where("class_id = ?", class.ID).Update("status", "disabled")
|
||||
// 审核日志
|
||||
log := &model.AuditLog{
|
||||
ClassID: class.ID,
|
||||
AuditorID: auditorID,
|
||||
Action: action,
|
||||
Reason: reason,
|
||||
}
|
||||
return tx.Create(log).Error
|
||||
})
|
||||
}
|
||||
|
||||
func (s *AdminService) log(classID, auditorID uint, action model.AuditAction, reason string) {
|
||||
log := &model.AuditLog{
|
||||
ClassID: classID,
|
||||
AuditorID: auditorID,
|
||||
Action: action,
|
||||
Reason: reason,
|
||||
}
|
||||
s.db.Create(log)
|
||||
}
|
||||
|
||||
// AuditLogs 查看班级审核日志
|
||||
func (s *AdminService) AuditLogs(classID uint) ([]model.AuditLog, error) {
|
||||
var list []model.AuditLog
|
||||
err := s.db.Where("class_id = ?", classID).Order("id desc").Find(&list).Error
|
||||
return list, err
|
||||
}
|
||||
|
||||
// Stats 平台数据概览
|
||||
type AdminStats struct {
|
||||
TotalUsers int64 `json:"total_users"`
|
||||
TotalClasses int64 `json:"total_classes"`
|
||||
ActiveClasses int64 `json:"active_classes"`
|
||||
PendingClasses int64 `json:"pending_classes"`
|
||||
RejectedClasses int64 `json:"rejected_classes"`
|
||||
TotalSchools int64 `json:"total_schools"`
|
||||
TotalPhotos int64 `json:"total_photos"`
|
||||
}
|
||||
|
||||
func (s *AdminService) Stats() (*AdminStats, error) {
|
||||
stats := &AdminStats{}
|
||||
s.db.Model(&model.User{}).Where("role = ?", "user").Count(&stats.TotalUsers)
|
||||
s.db.Model(&model.Class{}).Where("status != ?", model.ClassDeleted).Count(&stats.TotalClasses)
|
||||
s.db.Model(&model.Class{}).Where("status = ?", model.ClassActive).Count(&stats.ActiveClasses)
|
||||
s.db.Model(&model.Class{}).Where("status = ?", model.ClassPending).Count(&stats.PendingClasses)
|
||||
s.db.Model(&model.Class{}).Where("status = ?", model.ClassRejected).Count(&stats.RejectedClasses)
|
||||
s.db.Model(&model.School{}).Count(&stats.TotalSchools)
|
||||
s.db.Model(&model.ClassPhoto{}).Count(&stats.TotalPhotos)
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// ============ 详细数据统计 ============
|
||||
|
||||
type TrendItem struct {
|
||||
Date string `json:"date"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
type NameCountItem struct {
|
||||
Name string `json:"name"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
type DetailedStats struct {
|
||||
Core *AdminStats `json:"core"`
|
||||
UserTrend7d []TrendItem `json:"user_trend_7d"`
|
||||
ClassTrend7d []TrendItem `json:"class_trend_7d"`
|
||||
ClassStatus []NameCountItem `json:"class_status"`
|
||||
SchoolTypes []NameCountItem `json:"school_types"`
|
||||
TopRegions []NameCountItem `json:"top_regions"`
|
||||
GraduateYears []NameCountItem `json:"graduate_years"`
|
||||
PhotoStats map[string]int64 `json:"photo_stats"`
|
||||
}
|
||||
|
||||
func (s *AdminService) DetailedStats() (*DetailedStats, error) {
|
||||
core, _ := s.Stats()
|
||||
ds := &DetailedStats{Core: core}
|
||||
|
||||
// 近7天用户增长
|
||||
ds.UserTrend7d = s.dailyTrend("yz_pw_users", 7)
|
||||
// 近7天班级增长
|
||||
ds.ClassTrend7d = s.dailyTrend("yz_pw_classes", 7)
|
||||
|
||||
// 班级状态分布
|
||||
ds.ClassStatus = []NameCountItem{
|
||||
{Name: "待审核", Count: core.PendingClasses},
|
||||
{Name: "已通过", Count: core.ActiveClasses},
|
||||
{Name: "被打回", Count: core.RejectedClasses},
|
||||
}
|
||||
|
||||
// 学校类型分布
|
||||
type schoolTypeRow struct {
|
||||
SchoolType string `gorm:"column:school_type"`
|
||||
Cnt int64 `gorm:"column:cnt"`
|
||||
}
|
||||
var stRows []schoolTypeRow
|
||||
s.db.Model(&model.School{}).Select("school_type, COUNT(*) as cnt").Group("school_type").Scan(&stRows)
|
||||
typeMap := map[string]string{"university": "大学", "vocational": "职业学校", "senior_high": "高中", "junior_high": "初中", "primary": "小学", "kindergarten": "幼儿园", "other": "其他"}
|
||||
for _, r := range stRows {
|
||||
name := typeMap[r.SchoolType]
|
||||
if name == "" {
|
||||
name = r.SchoolType
|
||||
}
|
||||
ds.SchoolTypes = append(ds.SchoolTypes, NameCountItem{Name: name, Count: r.Cnt})
|
||||
}
|
||||
|
||||
// 地区TOP10(按省级行政区统计班级数)
|
||||
type regionRow struct {
|
||||
Name string `gorm:"column:name"`
|
||||
Cnt int64 `gorm:"column:cnt"`
|
||||
}
|
||||
var rRows []regionRow
|
||||
s.db.Raw(`
|
||||
SELECT r.name, COUNT(c.id) as cnt
|
||||
FROM yz_pw_classes c
|
||||
JOIN yz_pw_schools s ON c.school_id = s.id
|
||||
JOIN yz_pw_regions r ON s.region_id = r.id
|
||||
WHERE c.status != 'deleted'
|
||||
GROUP BY r.name
|
||||
ORDER BY cnt DESC
|
||||
LIMIT 10
|
||||
`).Scan(&rRows)
|
||||
for _, r := range rRows {
|
||||
ds.TopRegions = append(ds.TopRegions, NameCountItem{Name: r.Name, Count: r.Cnt})
|
||||
}
|
||||
|
||||
// 毕业年份分布
|
||||
var gyRows []struct {
|
||||
GraduateYear int `gorm:"column:graduate_year"`
|
||||
Cnt int64 `gorm:"column:cnt"`
|
||||
}
|
||||
s.db.Model(&model.Class{}).Select("graduate_year, COUNT(*) as cnt").
|
||||
Where("status != ?", "deleted").Group("graduate_year").Order("graduate_year DESC").Limit(10).Scan(&gyRows)
|
||||
for _, r := range gyRows {
|
||||
ds.GraduateYears = append(ds.GraduateYears, NameCountItem{Name: fmt.Sprintf("%d届", r.GraduateYear), Count: r.Cnt})
|
||||
}
|
||||
|
||||
// 照片统计
|
||||
var gradPhotos int64
|
||||
s.db.Model(&model.Class{}).Where("graduation_photo != ? AND status != ?", "", "deleted").Count(&gradPhotos)
|
||||
ds.PhotoStats = map[string]int64{
|
||||
"graduation_photos": gradPhotos,
|
||||
"personal_photos": core.TotalPhotos,
|
||||
"total_photos": gradPhotos + core.TotalPhotos,
|
||||
}
|
||||
|
||||
return ds, nil
|
||||
}
|
||||
|
||||
// dailyTrend 查询近N天每日新增数量
|
||||
func (s *AdminService) dailyTrend(table string, days int) []TrendItem {
|
||||
var items []TrendItem
|
||||
s.db.Raw(fmt.Sprintf(`
|
||||
SELECT DATE(created_at) as date, COUNT(*) as count
|
||||
FROM %s
|
||||
WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL %d DAY)
|
||||
GROUP BY DATE(created_at)
|
||||
ORDER BY date ASC
|
||||
`, table, days)).Scan(&items)
|
||||
// 补全缺失的日期
|
||||
result := make([]TrendItem, 0, days)
|
||||
now := time.Now()
|
||||
for i := days - 1; i >= 0; i-- {
|
||||
date := now.AddDate(0, 0, -i).Format("2006-01-02")
|
||||
count := int64(0)
|
||||
for _, item := range items {
|
||||
if item.Date == date {
|
||||
count = item.Count
|
||||
break
|
||||
}
|
||||
}
|
||||
result = append(result, TrendItem{Date: date, Count: count})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ============ 账号封禁管理 ============
|
||||
|
||||
type BanUserReq struct {
|
||||
UserID uint `json:"user_id" binding:"required"`
|
||||
Reason string `json:"reason" binding:"required"`
|
||||
}
|
||||
|
||||
// BanUser 封禁用户
|
||||
func (s *AdminService) BanUser(req *BanUserReq, adminID uint) error {
|
||||
var user model.User
|
||||
if err := s.db.First(&user, req.UserID).Error; err != nil {
|
||||
return errors.New("用户不存在")
|
||||
}
|
||||
if user.Role == "admin" {
|
||||
return errors.New("不能封禁管理员账号")
|
||||
}
|
||||
now := time.Now()
|
||||
user.Status = "banned"
|
||||
user.BanReason = req.Reason
|
||||
user.BannedAt = &now
|
||||
return s.db.Save(&user).Error
|
||||
}
|
||||
|
||||
// UnbanUser 解封用户
|
||||
func (s *AdminService) UnbanUser(userID uint) error {
|
||||
var user model.User
|
||||
if err := s.db.First(&user, userID).Error; err != nil {
|
||||
return errors.New("用户不存在")
|
||||
}
|
||||
user.Status = "active"
|
||||
user.BanReason = ""
|
||||
user.BannedAt = nil
|
||||
return s.db.Save(&user).Error
|
||||
}
|
||||
|
||||
// ListBannedUsers 获取封禁用户列表
|
||||
func (s *AdminService) ListBannedUsers() ([]model.User, error) {
|
||||
var list []model.User
|
||||
err := s.db.Where("status = ?", "banned").Order("banned_at desc").Find(&list).Error
|
||||
return list, err
|
||||
}
|
||||
|
||||
// ============ 敏感词管理 ============
|
||||
|
||||
// ListSensitiveWords 获取敏感词列表
|
||||
func (s *AdminService) ListSensitiveWords() ([]model.SensitiveWord, error) {
|
||||
var list []model.SensitiveWord
|
||||
err := s.db.Order("category, id").Find(&list).Error
|
||||
return list, err
|
||||
}
|
||||
|
||||
// AddSensitiveWord 添加敏感词
|
||||
func (s *AdminService) AddSensitiveWord(word, category string) (*model.SensitiveWord, error) {
|
||||
var count int64
|
||||
s.db.Model(&model.SensitiveWord{}).Where("word = ?", word).Count(&count)
|
||||
if count > 0 {
|
||||
return nil, errors.New("该敏感词已存在")
|
||||
}
|
||||
sw := &model.SensitiveWord{Word: word, Category: category, Enabled: true}
|
||||
if err := s.db.Create(sw).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return sw, nil
|
||||
}
|
||||
|
||||
// DeleteSensitiveWord 删除敏感词
|
||||
func (s *AdminService) DeleteSensitiveWord(id uint) error {
|
||||
return s.db.Delete(&model.SensitiveWord{}, id).Error
|
||||
}
|
||||
|
||||
// ToggleSensitiveWord 启用/禁用敏感词
|
||||
func (s *AdminService) ToggleSensitiveWord(id uint, enabled bool) error {
|
||||
return s.db.Model(&model.SensitiveWord{}).Where("id = ?", id).Update("enabled", enabled).Error
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"photowall/internal/config"
|
||||
"photowall/internal/model"
|
||||
"photowall/pkg/captcha"
|
||||
"photowall/pkg/hash"
|
||||
"photowall/pkg/jwt"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type AuthService struct {
|
||||
db *gorm.DB
|
||||
jm *jwt.Manager
|
||||
}
|
||||
|
||||
func NewAuthService(db *gorm.DB, jm *jwt.Manager) *AuthService {
|
||||
return &AuthService{db: db, jm: jm}
|
||||
}
|
||||
|
||||
// ============ 请求结构 ============
|
||||
|
||||
type RegisterReq struct {
|
||||
Username string `json:"username" binding:"required,min=3,max=32"`
|
||||
Password string `json:"password" binding:"required,min=6,max=64"`
|
||||
Nickname string `json:"nickname"`
|
||||
Email string `json:"email"`
|
||||
CaptchaID string `json:"captcha_id" binding:"required"`
|
||||
CaptchaCode string `json:"captcha_code" binding:"required"`
|
||||
}
|
||||
|
||||
type LoginReq struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
LoginType string `json:"login_type"` // password / sms
|
||||
Phone string `json:"phone"`
|
||||
SmsCode string `json:"sms_code"`
|
||||
CaptchaID string `json:"captcha_id"`
|
||||
CaptchaCode string `json:"captcha_code"`
|
||||
}
|
||||
|
||||
type LoginResp struct {
|
||||
Token string `json:"token"`
|
||||
User model.User `json:"user"`
|
||||
ExpireIn int `json:"expire_in"`
|
||||
}
|
||||
|
||||
type ForgotPasswordReq struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Email string `json:"email" binding:"required"`
|
||||
}
|
||||
|
||||
type ResetPasswordReq struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
ResetCode string `json:"reset_code" binding:"required"`
|
||||
NewPassword string `json:"new_password" binding:"required,min=6,max=64"`
|
||||
}
|
||||
|
||||
type SendSmsReq struct {
|
||||
Phone string `json:"phone" binding:"required"`
|
||||
Scene string `json:"scene" binding:"required"` // login / register / reset_password
|
||||
}
|
||||
|
||||
// ============ 注册 ============
|
||||
|
||||
func (s *AuthService) Register(req *RegisterReq) (*model.User, error) {
|
||||
// 校验图形验证码
|
||||
if !captcha.Verify(req.CaptchaID, req.CaptchaCode) {
|
||||
return nil, errors.New("验证码错误或已过期")
|
||||
}
|
||||
var count int64
|
||||
if err := s.db.Model(&model.User{}).Where("username = ?", req.Username).Count(&count).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if count > 0 {
|
||||
return nil, errors.New("用户名已被注册")
|
||||
}
|
||||
salt, pwdHash := hash.Password(req.Password)
|
||||
nickname := req.Nickname
|
||||
if nickname == "" {
|
||||
nickname = req.Username
|
||||
}
|
||||
user := &model.User{
|
||||
Username: req.Username,
|
||||
PasswordHash: pwdHash,
|
||||
Salt: salt,
|
||||
Email: req.Email,
|
||||
Nickname: nickname,
|
||||
Role: "user",
|
||||
}
|
||||
if err := s.db.Create(user).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// ============ 登录 ============
|
||||
|
||||
func (s *AuthService) Login(req *LoginReq) (*LoginResp, error) {
|
||||
// 图形验证码校验(密码登录必须,短信登录可选)
|
||||
if req.LoginType != "sms" {
|
||||
if !captcha.Verify(req.CaptchaID, req.CaptchaCode) {
|
||||
return nil, errors.New("验证码错误或已过期")
|
||||
}
|
||||
}
|
||||
|
||||
var user model.User
|
||||
if req.LoginType == "sms" {
|
||||
// 短信验证码登录
|
||||
if req.Phone == "" || req.SmsCode == "" {
|
||||
return nil, errors.New("手机号和验证码不能为空")
|
||||
}
|
||||
// 校验短信验证码
|
||||
var sms model.SmsCode
|
||||
if err := s.db.Where("phone = ? AND scene = ? AND used = ?", req.Phone, "login", false).
|
||||
Order("id DESC").First(&sms).Error; err != nil {
|
||||
return nil, errors.New("验证码错误或已过期")
|
||||
}
|
||||
if sms.Code != req.SmsCode || time.Now().After(sms.ExpireAt) {
|
||||
return nil, errors.New("验证码错误或已过期")
|
||||
}
|
||||
sms.Used = true
|
||||
s.db.Save(&sms)
|
||||
// 查找或创建用户
|
||||
if err := s.db.Where("username = ?", req.Phone).First(&user).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
// 自动注册
|
||||
salt, pwdHash := hash.Password(randomCode(8))
|
||||
user = model.User{
|
||||
Username: req.Phone,
|
||||
PasswordHash: pwdHash,
|
||||
Salt: salt,
|
||||
Nickname: "手机用户" + req.Phone[len(req.Phone)-4:],
|
||||
Role: "user",
|
||||
}
|
||||
s.db.Create(&user)
|
||||
} else {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 账号密码登录
|
||||
if req.Username == "" || req.Password == "" {
|
||||
return nil, errors.New("用户名和密码不能为空")
|
||||
}
|
||||
if err := s.db.Where("username = ?", req.Username).First(&user).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.New("用户名或密码错误")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if !hash.Verify(user.PasswordHash, user.Salt, req.Password) {
|
||||
return nil, errors.New("用户名或密码错误")
|
||||
}
|
||||
}
|
||||
|
||||
// 检查账号状态
|
||||
if user.Status == "banned" {
|
||||
return nil, fmt.Errorf("账号已被封禁:%s", user.BanReason)
|
||||
}
|
||||
|
||||
token, err := s.jm.Generate(user.ID, user.Username, user.Role)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &LoginResp{Token: token, User: user, ExpireIn: s.jm.ExpireHrs}, nil
|
||||
}
|
||||
|
||||
// ============ 忘记密码 ============
|
||||
|
||||
func (s *AuthService) ForgotPassword(req *ForgotPasswordReq) (string, error) {
|
||||
var user model.User
|
||||
if err := s.db.Where("username = ? AND email = ?", req.Username, req.Email).First(&user).Error; err != nil {
|
||||
return "", errors.New("用户名与邮箱不匹配")
|
||||
}
|
||||
// 生成6位重置码
|
||||
code := fmt.Sprintf("%06d", rand.Intn(1000000))
|
||||
reset := &model.PasswordReset{
|
||||
UserID: user.ID,
|
||||
Code: code,
|
||||
ExpireAt: time.Now().Add(30 * time.Minute),
|
||||
}
|
||||
if err := s.db.Create(reset).Error; err != nil {
|
||||
return "", err
|
||||
}
|
||||
// 开发阶段:重置码直接返回(生产环境应发送邮件)
|
||||
// TODO: 接入邮件服务后,通过邮箱发送重置码
|
||||
return code, nil
|
||||
}
|
||||
|
||||
// ============ 重置密码 ============
|
||||
|
||||
func (s *AuthService) ResetPassword(req *ResetPasswordReq) error {
|
||||
var user model.User
|
||||
if err := s.db.Where("username = ?", req.Username).First(&user).Error; err != nil {
|
||||
return errors.New("用户不存在")
|
||||
}
|
||||
var reset model.PasswordReset
|
||||
if err := s.db.Where("user_id = ? AND code = ? AND used = ?", user.ID, req.ResetCode, false).
|
||||
Order("id DESC").First(&reset).Error; err != nil {
|
||||
return errors.New("重置码错误或已过期")
|
||||
}
|
||||
if time.Now().After(reset.ExpireAt) {
|
||||
return errors.New("重置码已过期")
|
||||
}
|
||||
salt, pwdHash := hash.Password(req.NewPassword)
|
||||
user.PasswordHash = pwdHash
|
||||
user.Salt = salt
|
||||
reset.Used = true
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Save(&user).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Save(&reset).Error
|
||||
})
|
||||
}
|
||||
|
||||
// ============ 发送短信验证码(预留) ============
|
||||
|
||||
func (s *AuthService) SendSmsCode(req *SendSmsReq) (string, error) {
|
||||
cfg := config.C
|
||||
// 参数未配置时返回提示(开发阶段直接返回验证码)
|
||||
if cfg.SMSAccessKey == "" || cfg.SMSSecretKey == "" {
|
||||
code := fmt.Sprintf("%06d", rand.Intn(1000000))
|
||||
sms := &model.SmsCode{
|
||||
Phone: req.Phone,
|
||||
Code: code,
|
||||
Scene: req.Scene,
|
||||
ExpireAt: time.Now().Add(5 * time.Minute),
|
||||
}
|
||||
s.db.Create(sms)
|
||||
// 开发阶段直接返回验证码(生产环境应通过短信服务商发送)
|
||||
return code, nil
|
||||
}
|
||||
// TODO: 接入阿里云/腾讯云短信SDK后发送真实短信
|
||||
code := fmt.Sprintf("%06d", rand.Intn(1000000))
|
||||
sms := &model.SmsCode{
|
||||
Phone: req.Phone,
|
||||
Code: code,
|
||||
Scene: req.Scene,
|
||||
ExpireAt: time.Now().Add(5 * time.Minute),
|
||||
}
|
||||
s.db.Create(sms)
|
||||
return code, nil
|
||||
}
|
||||
|
||||
// ============ 微信扫码登录(预留) ============
|
||||
|
||||
type WechatLoginReq struct {
|
||||
Code string `json:"code"` // 微信授权code
|
||||
}
|
||||
|
||||
func (s *AuthService) WechatLogin(req *WechatLoginReq) (*LoginResp, error) {
|
||||
cfg := config.C
|
||||
if cfg.WechatAppID == "" || cfg.WechatAppSecret == "" {
|
||||
return nil, errors.New("微信登录未配置,请联系管理员")
|
||||
}
|
||||
// TODO: 接入微信开放平台API
|
||||
// 1. 用 code 换取 access_token 和 openid
|
||||
// 2. 用 openid 查找或创建用户
|
||||
// 3. 签发 JWT
|
||||
return nil, errors.New("微信登录功能开发中")
|
||||
}
|
||||
|
||||
// ============ 工具 ============
|
||||
|
||||
func randomCode(length int) string {
|
||||
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
b := make([]byte, length)
|
||||
for i := range b {
|
||||
b[i] = chars[rand.Intn(len(chars))]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
@@ -0,0 +1,628 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"photowall/internal/config"
|
||||
"photowall/internal/model"
|
||||
"photowall/pkg/sensitive"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type ClassService struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewClassService(db *gorm.DB) *ClassService {
|
||||
return &ClassService{db: db}
|
||||
}
|
||||
|
||||
// CreateClassReq 创建班级请求
|
||||
type CreateClassReq struct {
|
||||
SchoolID uint `json:"school_id" binding:"required"`
|
||||
CollegeID uint `json:"college_id"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
Grade string `json:"grade" binding:"required"` // 如"2018级"
|
||||
ClassNo string `json:"class_no" binding:"required"`
|
||||
GraduateYear int `json:"graduate_year" binding:"required"`
|
||||
GraduationPhoto string `json:"graduation_photo"`
|
||||
StudentNames []string `json:"student_names"`
|
||||
TeacherNames []string `json:"teacher_names"`
|
||||
}
|
||||
|
||||
// cleanNames 清洗名字:去空格、去空、去重,保持顺序
|
||||
func cleanNames(names []string) []string {
|
||||
seen := map[string]bool{}
|
||||
result := []string{}
|
||||
for _, n := range names {
|
||||
n = strings.TrimSpace(n)
|
||||
if n == "" {
|
||||
continue
|
||||
}
|
||||
if seen[n] {
|
||||
continue
|
||||
}
|
||||
seen[n] = true
|
||||
result = append(result, n)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Create 创建班级
|
||||
func (s *ClassService) Create(userID uint, req *CreateClassReq) (*model.Class, error) {
|
||||
// 0. 敏感词检测
|
||||
if found, word := sensitive.Check(req.Name); found {
|
||||
return nil, fmt.Errorf("班级名称包含敏感词:%s", word)
|
||||
}
|
||||
// 1. 校验用户有学习履历
|
||||
var eduCount int64
|
||||
s.db.Model(&model.EducationHistory{}).Where("user_id = ?", userID).Count(&eduCount)
|
||||
if eduCount == 0 {
|
||||
return nil, errors.New("请先完善个人学习履历,有学习履历的用户才能创建班级")
|
||||
}
|
||||
|
||||
// 2. 校验学校
|
||||
var school model.School
|
||||
if err := s.db.First(&school, req.SchoolID).Error; err != nil {
|
||||
return nil, errors.New("学校不存在")
|
||||
}
|
||||
|
||||
// 3. 校验学院(如果传了)
|
||||
if req.CollegeID > 0 {
|
||||
if !school.SchoolType.HasCollege() {
|
||||
return nil, errors.New("该学校类型不支持学院层级")
|
||||
}
|
||||
var college model.College
|
||||
if err := s.db.Where("id = ? AND school_id = ?", req.CollegeID, req.SchoolID).First(&college).Error; err != nil {
|
||||
return nil, errors.New("学院不存在或不属于该学校")
|
||||
}
|
||||
} else if school.SchoolType.HasCollege() {
|
||||
// 大学/职业学校必须选学院
|
||||
return nil, errors.New("该学校需要选择所属学院")
|
||||
}
|
||||
|
||||
// 4. 唯一性校验
|
||||
var existCount int64
|
||||
q := s.db.Model(&model.Class{}).Where("school_id = ? AND grade = ? AND class_no = ? AND graduate_year = ? AND status != ?",
|
||||
req.SchoolID, req.Grade, req.ClassNo, req.GraduateYear, model.ClassDeleted)
|
||||
if req.CollegeID > 0 {
|
||||
q = q.Where("college_id = ?", req.CollegeID)
|
||||
} else {
|
||||
q = q.Where("college_id = 0 OR college_id IS NULL")
|
||||
}
|
||||
q.Count(&existCount)
|
||||
if existCount > 0 {
|
||||
return nil, errors.New("该班级已存在,请勿重复创建")
|
||||
}
|
||||
|
||||
// 5. 清洗名单
|
||||
students := cleanNames(req.StudentNames)
|
||||
teachers := cleanNames(req.TeacherNames)
|
||||
|
||||
// 6. 构建班级
|
||||
class := &model.Class{
|
||||
SchoolID: req.SchoolID,
|
||||
CollegeID: req.CollegeID,
|
||||
Name: req.Name,
|
||||
Grade: req.Grade,
|
||||
ClassNo: req.ClassNo,
|
||||
GraduateYear: req.GraduateYear,
|
||||
AdminUserID: userID,
|
||||
GraduationPhoto: req.GraduationPhoto,
|
||||
AuditDeadline: time.Now().AddDate(0, 0, config.C.AuditGraceDays),
|
||||
}
|
||||
class.SetStudentNames(students)
|
||||
class.SetTeacherNames(teachers)
|
||||
|
||||
// 7. 名字不足8个直接打回
|
||||
if len(students) < model.MinStudentCount {
|
||||
class.Status = model.ClassRejected
|
||||
class.RejectReason = fmt.Sprintf("班级同学名单仅 %d 人,需至少录入 %d 人(毕业照上通常有全部同学姓名,请补齐后重新提交)", len(students), model.MinStudentCount)
|
||||
} else {
|
||||
class.Status = model.ClassPending
|
||||
}
|
||||
|
||||
// 8. 事务:创建班级 + 创建者成为管理员成员 + 审核日志
|
||||
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(class).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
member := &model.ClassMember{
|
||||
ClassID: class.ID,
|
||||
UserID: userID,
|
||||
Role: model.MemberRoleAdmin,
|
||||
JoinMethod: model.JoinByInvite, // 创建者视同邀请
|
||||
JoinedAt: time.Now(),
|
||||
}
|
||||
if err := tx.Create(member).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
log := &model.AuditLog{
|
||||
ClassID: class.ID,
|
||||
AuditorID: userID,
|
||||
Action: model.AuditSubmit,
|
||||
Reason: fmt.Sprintf("创建班级,录入同学 %d 人、老师 %d 人", len(students), len(teachers)),
|
||||
}
|
||||
return tx.Create(log).Error
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return class, nil
|
||||
}
|
||||
|
||||
// ClassDetail 班级详情
|
||||
type ClassDetail struct {
|
||||
Class model.Class `json:"class"`
|
||||
StudentNames []string `json:"student_names"`
|
||||
TeacherNames []string `json:"teacher_names"`
|
||||
School *model.School `json:"school,omitempty"`
|
||||
College *model.College `json:"college,omitempty"`
|
||||
Admin *model.User `json:"admin,omitempty"`
|
||||
Members []ClassMemberInfo `json:"members"`
|
||||
Photos []model.ClassPhoto `json:"photos"`
|
||||
MyRole string `json:"my_role"` // 当前用户角色,未加入为空
|
||||
IsMember bool `json:"is_member"`
|
||||
CanViewSensitive bool `json:"can_view_sensitive"` // 是否可查看照片和联系方式(成员/管理员)
|
||||
}
|
||||
|
||||
type ClassMemberInfo struct {
|
||||
Member model.ClassMember `json:"member"`
|
||||
User model.User `json:"user"`
|
||||
Contacts []model.UserContact `json:"contacts,omitempty"`
|
||||
}
|
||||
|
||||
func (s *ClassService) GetDetail(classID, currentUserID uint, isSuperAdmin bool) (*ClassDetail, error) {
|
||||
var class model.Class
|
||||
if err := s.db.First(&class, classID).Error; err != nil {
|
||||
return nil, errors.New("班级不存在")
|
||||
}
|
||||
if class.Status == model.ClassDeleted {
|
||||
return nil, errors.New("班级已删除")
|
||||
}
|
||||
|
||||
// 数据隔离:只有班级成员或超级管理员可查看完整信息(照片+联系方式)
|
||||
canViewSensitive := isSuperAdmin || (currentUserID > 0 && s.isMember(classID, currentUserID))
|
||||
|
||||
detail := &ClassDetail{
|
||||
Class: class,
|
||||
StudentNames: class.StudentNames(),
|
||||
TeacherNames: class.TeacherNames(),
|
||||
CanViewSensitive: canViewSensitive,
|
||||
}
|
||||
|
||||
// 学校
|
||||
var school model.School
|
||||
if s.db.First(&school, class.SchoolID).Error == nil {
|
||||
detail.School = &school
|
||||
}
|
||||
// 学院
|
||||
if class.CollegeID > 0 {
|
||||
var college model.College
|
||||
if s.db.First(&college, class.CollegeID).Error == nil {
|
||||
detail.College = &college
|
||||
}
|
||||
}
|
||||
// 管理员
|
||||
var admin model.User
|
||||
if s.db.First(&admin, class.AdminUserID).Error == nil {
|
||||
detail.Admin = &admin
|
||||
}
|
||||
|
||||
// 成员列表
|
||||
var members []model.ClassMember
|
||||
s.db.Where("class_id = ?", classID).Order("role desc, joined_at").Find(&members)
|
||||
for _, m := range members {
|
||||
var u model.User
|
||||
s.db.First(&u, m.UserID)
|
||||
info := ClassMemberInfo{Member: m, User: u}
|
||||
// 数据隔离:同班成员或超级管理员可查看联系方式
|
||||
if canViewSensitive {
|
||||
info.Contacts = s.getUserContacts(m.UserID)
|
||||
}
|
||||
detail.Members = append(detail.Members, info)
|
||||
}
|
||||
|
||||
// 照片墙:数据隔离,只有成员或超级管理员可查看
|
||||
if canViewSensitive {
|
||||
var photos []model.ClassPhoto
|
||||
s.db.Where("class_id = ?", classID).Order("user_id, id desc").Find(&photos)
|
||||
detail.Photos = photos
|
||||
} else {
|
||||
detail.Photos = []model.ClassPhoto{}
|
||||
}
|
||||
|
||||
// 当前用户角色
|
||||
if currentUserID > 0 {
|
||||
var myMember model.ClassMember
|
||||
if s.db.Where("class_id = ? AND user_id = ?", classID, currentUserID).First(&myMember).Error == nil {
|
||||
detail.MyRole = string(myMember.Role)
|
||||
detail.IsMember = true
|
||||
}
|
||||
}
|
||||
return detail, nil
|
||||
}
|
||||
|
||||
func (s *ClassService) isMember(classID, userID uint) bool {
|
||||
var count int64
|
||||
s.db.Model(&model.ClassMember{}).Where("class_id = ? AND user_id = ?", classID, userID).Count(&count)
|
||||
return count > 0
|
||||
}
|
||||
|
||||
func (s *ClassService) getUserContacts(userID uint) []model.UserContact {
|
||||
var list []model.UserContact
|
||||
s.db.Where("user_id = ?", userID).Order("type, is_primary desc").Find(&list)
|
||||
return list
|
||||
}
|
||||
|
||||
// SearchClassReq 搜索班级
|
||||
type SearchClassReq struct {
|
||||
Keyword string `form:"keyword"`
|
||||
SchoolID uint `form:"school_id"`
|
||||
Grade string `form:"grade"`
|
||||
GraduateYear int `form:"graduate_year"`
|
||||
Page int `form:"page"`
|
||||
PageSize int `form:"page_size"`
|
||||
}
|
||||
|
||||
// Search 搜索可加入的班级(仅 active 状态)
|
||||
func (s *ClassService) Search(req *SearchClassReq) (*SchoolListResp, error) {
|
||||
if req.Page <= 0 {
|
||||
req.Page = 1
|
||||
}
|
||||
if req.PageSize <= 0 || req.PageSize > 100 {
|
||||
req.PageSize = 20
|
||||
}
|
||||
db := s.db.Model(&model.Class{}).Where("status = ?", model.ClassActive)
|
||||
if req.SchoolID > 0 {
|
||||
db = db.Where("school_id = ?", req.SchoolID)
|
||||
}
|
||||
if req.Grade != "" {
|
||||
db = db.Where("grade = ?", req.Grade)
|
||||
}
|
||||
if req.GraduateYear > 0 {
|
||||
db = db.Where("graduate_year = ?", req.GraduateYear)
|
||||
}
|
||||
if req.Keyword != "" {
|
||||
db = db.Where("name LIKE ?", "%"+req.Keyword+"%")
|
||||
}
|
||||
var total int64
|
||||
db.Count(&total)
|
||||
var list []model.Class
|
||||
err := db.Order("graduate_year desc, id desc").Offset((req.Page - 1) * req.PageSize).Limit(req.PageSize).Find(&list).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &SchoolListResp{List: list, Total: total, Page: req.Page}, nil
|
||||
}
|
||||
|
||||
// JoinBySearchReq 搜索加入验证
|
||||
type JoinBySearchReq struct {
|
||||
ClassID uint `json:"class_id" binding:"required"`
|
||||
StudentNames []string `json:"student_names" binding:"required,min=8"`
|
||||
}
|
||||
|
||||
// JoinBySearch 通过搜索+8人验证加入班级
|
||||
func (s *ClassService) JoinBySearch(userID uint, req *JoinBySearchReq) error {
|
||||
var class model.Class
|
||||
if err := s.db.First(&class, req.ClassID).Error; err != nil {
|
||||
return errors.New("班级不存在")
|
||||
}
|
||||
if class.Status != model.ClassActive {
|
||||
return errors.New("该班级尚未通过审核,暂不能加入")
|
||||
}
|
||||
// 已加入?
|
||||
if s.isMember(class.ID, userID) {
|
||||
return errors.New("你已经是该班级成员")
|
||||
}
|
||||
// 验证8个名字
|
||||
classStudents := class.StudentNames()
|
||||
classSet := map[string]bool{}
|
||||
for _, n := range classStudents {
|
||||
classSet[strings.TrimSpace(n)] = true
|
||||
}
|
||||
input := cleanNames(req.StudentNames)
|
||||
if len(input) < model.MinStudentCount {
|
||||
return fmt.Errorf("请输入至少 %d 位同学的名字", model.MinStudentCount)
|
||||
}
|
||||
for _, n := range input {
|
||||
if !classSet[n] {
|
||||
return fmt.Errorf("同学「%s」不在该班级名单中,验证失败,请确认名字与毕业照一致", n)
|
||||
}
|
||||
}
|
||||
// 全部匹配,加入
|
||||
member := &model.ClassMember{
|
||||
ClassID: class.ID,
|
||||
UserID: userID,
|
||||
Role: model.MemberRoleMember,
|
||||
JoinMethod: model.JoinBySearch,
|
||||
JoinedAt: time.Now(),
|
||||
}
|
||||
return s.db.Create(member).Error
|
||||
}
|
||||
|
||||
// ---- 邀请码 ----
|
||||
|
||||
// CreateInvitation 管理员生成邀请码
|
||||
func (s *ClassService) CreateInvitation(classID, userID uint, maxUses int, expireDays int) (*model.Invitation, error) {
|
||||
if !s.isAdmin(classID, userID) {
|
||||
return nil, errors.New("只有班级管理员可以生成邀请码")
|
||||
}
|
||||
var class model.Class
|
||||
if err := s.db.First(&class, classID).Error; err != nil {
|
||||
return nil, errors.New("班级不存在")
|
||||
}
|
||||
if class.Status == model.ClassDeleted {
|
||||
return nil, errors.New("班级已删除")
|
||||
}
|
||||
if maxUses <= 0 {
|
||||
maxUses = 50
|
||||
}
|
||||
if expireDays <= 0 {
|
||||
expireDays = 30
|
||||
}
|
||||
inv := &model.Invitation{
|
||||
ClassID: classID,
|
||||
InviterID: userID,
|
||||
Code: strings.ToUpper(strings.ReplaceAll(uuid.New().String()[:8], "-", "")),
|
||||
MaxUses: maxUses,
|
||||
ExpiresAt: time.Now().AddDate(0, 0, expireDays),
|
||||
Status: "active",
|
||||
}
|
||||
if err := s.db.Create(inv).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return inv, nil
|
||||
}
|
||||
|
||||
// ListInvitations 管理员查看邀请码列表
|
||||
func (s *ClassService) ListInvitations(classID, userID uint) ([]model.Invitation, error) {
|
||||
if !s.isAdmin(classID, userID) {
|
||||
return nil, errors.New("只有班级管理员可以查看邀请码")
|
||||
}
|
||||
var list []model.Invitation
|
||||
err := s.db.Where("class_id = ?", classID).Order("id desc").Find(&list).Error
|
||||
return list, err
|
||||
}
|
||||
|
||||
// JoinByInvitationReq 邀请码加入
|
||||
type JoinByInvitationReq struct {
|
||||
Code string `json:"code" binding:"required"`
|
||||
}
|
||||
|
||||
// JoinByInvitation 通过邀请码加入
|
||||
func (s *ClassService) JoinByInvitation(userID uint, req *JoinByInvitationReq) (*model.Class, error) {
|
||||
code := strings.ToUpper(strings.TrimSpace(req.Code))
|
||||
var inv model.Invitation
|
||||
if err := s.db.Where("code = ?", code).First(&inv).Error; err != nil {
|
||||
return nil, errors.New("邀请码无效")
|
||||
}
|
||||
if inv.Status != "active" {
|
||||
return nil, errors.New("邀请码已失效")
|
||||
}
|
||||
if inv.UsedCount >= inv.MaxUses {
|
||||
return nil, errors.New("邀请码使用次数已达上限")
|
||||
}
|
||||
if time.Now().After(inv.ExpiresAt) {
|
||||
return nil, errors.New("邀请码已过期")
|
||||
}
|
||||
var class model.Class
|
||||
if err := s.db.First(&class, inv.ClassID).Error; err != nil {
|
||||
return nil, errors.New("班级不存在")
|
||||
}
|
||||
if class.Status == model.ClassDeleted {
|
||||
return nil, errors.New("班级已删除")
|
||||
}
|
||||
if s.isMember(class.ID, userID) {
|
||||
return nil, errors.New("你已经是该班级成员")
|
||||
}
|
||||
// 事务:加成员 + 邀请码使用数+1
|
||||
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||
member := &model.ClassMember{
|
||||
ClassID: class.ID,
|
||||
UserID: userID,
|
||||
Role: model.MemberRoleMember,
|
||||
JoinMethod: model.JoinByInvite,
|
||||
JoinedAt: time.Now(),
|
||||
}
|
||||
if err := tx.Create(member).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Model(&model.Invitation{}).Where("id = ?", inv.ID).
|
||||
Update("used_count", gorm.Expr("used_count + 1")).Error
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &class, nil
|
||||
}
|
||||
|
||||
// DisableInvitation 管理员禁用邀请码
|
||||
func (s *ClassService) DisableInvitation(classID, invID, userID uint) error {
|
||||
if !s.isAdmin(classID, userID) {
|
||||
return errors.New("只有班级管理员可以操作")
|
||||
}
|
||||
res := s.db.Model(&model.Invitation{}).Where("id = ? AND class_id = ?", invID, classID).Update("status", "disabled")
|
||||
if res.RowsAffected == 0 {
|
||||
return errors.New("邀请码不存在")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isAdmin 检查用户是否为班级管理员
|
||||
func (s *ClassService) isAdmin(classID, userID uint) bool {
|
||||
var count int64
|
||||
s.db.Model(&model.ClassMember{}).Where("class_id = ? AND user_id = ? AND role = ?", classID, userID, model.MemberRoleAdmin).Count(&count)
|
||||
return count > 0
|
||||
}
|
||||
|
||||
// TransferAdmin 管理员转移
|
||||
func (s *ClassService) TransferAdmin(classID, fromUserID, toUserID uint) error {
|
||||
if !s.isAdmin(classID, fromUserID) {
|
||||
return errors.New("只有班级管理员可以转移管理员身份")
|
||||
}
|
||||
if fromUserID == toUserID {
|
||||
return errors.New("不能转移给自己")
|
||||
}
|
||||
// 目标必须是同班成员
|
||||
var target model.ClassMember
|
||||
if err := s.db.Where("class_id = ? AND user_id = ?", classID, toUserID).First(&target).Error; err != nil {
|
||||
return errors.New("目标用户不是该班级成员")
|
||||
}
|
||||
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||
// 原管理员降为成员
|
||||
if err := tx.Model(&model.ClassMember{}).
|
||||
Where("class_id = ? AND user_id = ?", classID, fromUserID).
|
||||
Update("role", model.MemberRoleMember).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// 目标升为管理员
|
||||
if err := tx.Model(&model.ClassMember{}).
|
||||
Where("class_id = ? AND user_id = ?", classID, toUserID).
|
||||
Update("role", model.MemberRoleAdmin).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// 更新班级 admin_user_id
|
||||
return tx.Model(&model.Class{}).Where("id = ?", classID).Update("admin_user_id", toUserID).Error
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// RemoveMember 管理员移除成员(不能移除自己)
|
||||
func (s *ClassService) RemoveMember(classID, adminID, targetUserID uint) error {
|
||||
if !s.isAdmin(classID, adminID) {
|
||||
return errors.New("只有班级管理员可以移除成员")
|
||||
}
|
||||
if adminID == targetUserID {
|
||||
return errors.New("不能移除自己,请先转移管理员")
|
||||
}
|
||||
res := s.db.Where("class_id = ? AND user_id = ?", classID, targetUserID).Delete(&model.ClassMember{})
|
||||
if res.RowsAffected == 0 {
|
||||
return errors.New("成员不存在")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- 个人照片墙 ----
|
||||
|
||||
// UploadPhoto 上传个人当期照片(每人每班级最多10张)
|
||||
func (s *ClassService) UploadPhoto(classID, userID uint, photoURL, caption string) (*model.ClassPhoto, error) {
|
||||
if !s.isMember(classID, userID) {
|
||||
return nil, errors.New("只有班级成员可以上传照片")
|
||||
}
|
||||
var class model.Class
|
||||
if err := s.db.First(&class, classID).Error; err != nil {
|
||||
return nil, errors.New("班级不存在")
|
||||
}
|
||||
if class.Status == model.ClassDeleted {
|
||||
return nil, errors.New("班级已删除")
|
||||
}
|
||||
var count int64
|
||||
s.db.Model(&model.ClassPhoto{}).Where("class_id = ? AND user_id = ?", classID, userID).Count(&count)
|
||||
if count >= int64(model.MaxPhotosPerUserPerClass) {
|
||||
return nil, fmt.Errorf("每人在每个班级最多上传 %d 张照片", model.MaxPhotosPerUserPerClass)
|
||||
}
|
||||
photo := &model.ClassPhoto{
|
||||
ClassID: classID,
|
||||
UserID: userID,
|
||||
PhotoURL: photoURL,
|
||||
Caption: caption,
|
||||
}
|
||||
if err := s.db.Create(photo).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return photo, nil
|
||||
}
|
||||
|
||||
// DeletePhoto 删除照片(本人或班级管理员或超级管理员)
|
||||
func (s *ClassService) DeletePhoto(classID, photoID, userID uint, isSuperAdmin bool) error {
|
||||
var photo model.ClassPhoto
|
||||
if err := s.db.Where("id = ? AND class_id = ?", photoID, classID).First(&photo).Error; err != nil {
|
||||
return errors.New("照片不存在")
|
||||
}
|
||||
// 权限校验:本人、班级管理员、超级管理员
|
||||
canDelete := isSuperAdmin || photo.UserID == userID || s.isAdmin(classID, userID)
|
||||
if !canDelete {
|
||||
return errors.New("无权删除此照片")
|
||||
}
|
||||
s.db.Delete(&photo)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListMyClasses 我的班级列表
|
||||
func (s *ClassService) ListMyClasses(userID uint) ([]ClassDetail, error) {
|
||||
var members []model.ClassMember
|
||||
s.db.Where("user_id = ?", userID).Order("id desc").Find(&members)
|
||||
result := []ClassDetail{}
|
||||
for _, m := range members {
|
||||
var class model.Class
|
||||
if err := s.db.First(&class, m.ClassID).Error; err != nil {
|
||||
continue
|
||||
}
|
||||
if class.Status == model.ClassDeleted {
|
||||
continue
|
||||
}
|
||||
d := ClassDetail{
|
||||
Class: class,
|
||||
MyRole: string(m.Role),
|
||||
IsMember: true,
|
||||
}
|
||||
var school model.School
|
||||
if s.db.First(&school, class.SchoolID).Error == nil {
|
||||
d.School = &school
|
||||
}
|
||||
result = append(result, d)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ResubmitReq 重新提交审核(补齐名单/照片)
|
||||
type ResubmitReq struct {
|
||||
GraduationPhoto string `json:"graduation_photo"`
|
||||
StudentNames []string `json:"student_names"`
|
||||
TeacherNames []string `json:"teacher_names"`
|
||||
}
|
||||
|
||||
// Resubmit 被打回后补齐重新提交
|
||||
func (s *ClassService) Resubmit(classID, userID uint, req *ResubmitReq) error {
|
||||
var class model.Class
|
||||
if err := s.db.First(&class, classID).Error; err != nil {
|
||||
return errors.New("班级不存在")
|
||||
}
|
||||
if class.AdminUserID != userID {
|
||||
return errors.New("只有班级管理员可以重新提交审核")
|
||||
}
|
||||
if class.Status != model.ClassRejected && class.Status != model.ClassPending {
|
||||
return errors.New("当前状态不支持重新提交")
|
||||
}
|
||||
students := cleanNames(req.StudentNames)
|
||||
teachers := cleanNames(req.TeacherNames)
|
||||
|
||||
if len(students) < model.MinStudentCount {
|
||||
return fmt.Errorf("同学名单至少需要 %d 人,当前仅 %d 人", model.MinStudentCount, len(students))
|
||||
}
|
||||
if req.GraduationPhoto != "" {
|
||||
class.GraduationPhoto = req.GraduationPhoto
|
||||
}
|
||||
class.SetStudentNames(students)
|
||||
class.SetTeacherNames(teachers)
|
||||
class.Status = model.ClassPending
|
||||
class.RejectReason = ""
|
||||
// 重新提交后审核截止时间顺延?保持原截止时间,30天从创建算起
|
||||
if err := s.db.Save(&class).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// 审核日志
|
||||
log := &model.AuditLog{
|
||||
ClassID: class.ID,
|
||||
AuditorID: userID,
|
||||
Action: model.AuditSubmit,
|
||||
Reason: fmt.Sprintf("重新提交,补齐同学 %d 人、老师 %d 人", len(students), len(teachers)),
|
||||
}
|
||||
s.db.Create(log)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"photowall/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type ConfigService struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewConfigService(db *gorm.DB) *ConfigService {
|
||||
return &ConfigService{db: db}
|
||||
}
|
||||
|
||||
// defaultConfigs 所有可配置项及说明
|
||||
var defaultConfigs = []model.SystemConfig{
|
||||
// ===== 基础配置 =====
|
||||
{Key: "site_name", Value: "PhotoWall 毕业照墙", Label: "站点名称", Description: "显示在浏览器标题和登录页的站点名称", Category: "basic", Placeholder: "PhotoWall 毕业照墙", Sort: 1},
|
||||
{Key: "jwt_secret", Value: "photowall-dev-secret-change-in-prod", Label: "JWT 密钥", Description: "用于签发和验证登录Token,生产环境务必修改为随机字符串", Category: "basic", Placeholder: "随机字符串至少32位", Sort: 2},
|
||||
{Key: "jwt_expire_hours", Value: "3", Label: "登录有效期(小时)", Description: "用户登录后Token的有效时长,超时需重新登录", Category: "basic", Placeholder: "3", Sort: 3},
|
||||
{Key: "audit_grace_days", Value: "30", Label: "审核补齐期限(天)", Description: "班级被打回后,超过该天数未补齐则自动删除", Category: "basic", Placeholder: "30", Sort: 4},
|
||||
|
||||
// ===== 极验行为验证 =====
|
||||
{Key: "geetest_id", Value: "", Label: "极验 ID", Description: "极验行为验证的Captcha ID,用于防机器人和暴力破解", Category: "geetest", Placeholder: "如:64a####################", DocLink: "https://www.geetest.com 注册后在应用管理获取", Sort: 1},
|
||||
{Key: "geetest_key", Value: "", Label: "极验 Key", Description: "极验行为验证的私钥,与ID配对使用", Category: "geetest", Placeholder: "如:64a####################", DocLink: "https://www.geetest.com 注册后在应用管理获取", Sort: 2},
|
||||
|
||||
// ===== 短信验证码 =====
|
||||
{Key: "sms_provider", Value: "aliyun", Label: "短信服务商", Description: "选择短信服务商,目前支持阿里云和腾讯云", Category: "sms", Placeholder: "aliyun 或 tencent", DocLink: "阿里云: https://dysms.console.aliyun.com | 腾讯云: https://console.cloud.tencent.com/sms", Sort: 1},
|
||||
{Key: "sms_access_key", Value: "", Label: "短信 AccessKey", Description: "短信服务商的访问密钥ID", Category: "sms", Placeholder: "如:LTAI5t################", DocLink: "阿里云: https://ram.console.aliyun.com | 腾讯云: https://console.cloud.tencent.com/cam", Sort: 2},
|
||||
{Key: "sms_secret_key", Value: "", Label: "短信 SecretKey", Description: "短信服务商的访问密钥密码", Category: "sms", Placeholder: "如:########################", DocLink: "与AccessKey在同一页面获取", Sort: 3},
|
||||
{Key: "sms_sign_name", Value: "", Label: "短信签名", Description: "短信签名,需在短信服务商审核通过,如:PhotoWall", Category: "sms", Placeholder: "如:PhotoWall", DocLink: "在短信服务商控制台的国内消息-签名管理中申请", Sort: 4},
|
||||
{Key: "sms_template_code", Value: "", Label: "短信模板CODE", Description: "短信模板编码,需在短信服务商审核通过,模板内容需包含验证码变量", Category: "sms", Placeholder: "如:SMS_123456789", DocLink: "在短信服务商控制台的国内消息-模板管理中申请", Sort: 5},
|
||||
|
||||
// ===== 微信扫码登录 =====
|
||||
{Key: "wechat_app_id", Value: "", Label: "微信 AppID", Description: "微信开放平台应用的AppID,用于扫码登录", Category: "wechat", Placeholder: "如:wx################", DocLink: "https://open.weixin.qq.com 注册开发者账号并创建网站应用", Sort: 1},
|
||||
{Key: "wechat_app_secret", Value: "", Label: "微信 AppSecret", Description: "微信开放平台应用的密钥,与AppID配对", Category: "wechat", Placeholder: "如:########################", DocLink: "在微信开放平台应用详情中查看", Sort: 2},
|
||||
{Key: "wechat_redirect_uri", Value: "", Label: "微信回调地址", Description: "微信授权后的回调URL,需与开放平台配置一致,需HTTPS", Category: "wechat", Placeholder: "如:https://yourdomain.com/api/auth/wechat/callback", DocLink: "在微信开放平台应用的网站应用-授权回调域中配置", Sort: 3},
|
||||
|
||||
// ===== 存储配置 =====
|
||||
{Key: "upload_dir", Value: "uploads", Label: "上传目录", Description: "图片文件存储的相对路径", Category: "storage", Placeholder: "uploads", Sort: 1},
|
||||
{Key: "max_file_size_mb", Value: "10", Label: "单文件大小上限(MB)", Description: "上传图片的最大大小限制", Category: "storage", Placeholder: "10", Sort: 2},
|
||||
}
|
||||
|
||||
// InitDefaults 初始化默认配置(不存在则创建)
|
||||
func (s *ConfigService) InitDefaults() error {
|
||||
for _, c := range defaultConfigs {
|
||||
var count int64
|
||||
s.db.Model(&model.SystemConfig{}).Where("`key` = ?", c.Key).Count(&count)
|
||||
if count == 0 {
|
||||
if err := s.db.Create(&c).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAll 获取所有配置(按分类和排序)
|
||||
func (s *ConfigService) GetAll() ([]model.SystemConfig, error) {
|
||||
var list []model.SystemConfig
|
||||
err := s.db.Order("category, sort").Find(&list).Error
|
||||
return list, err
|
||||
}
|
||||
|
||||
// GetByCategory 按分类获取配置
|
||||
func (s *ConfigService) GetByCategory(category string) ([]model.SystemConfig, error) {
|
||||
var list []model.SystemConfig
|
||||
err := s.db.Where("category = ?", category).Order("sort").Find(&list).Error
|
||||
return list, err
|
||||
}
|
||||
|
||||
// UpdateReq 更新配置请求
|
||||
type UpdateConfigReq struct {
|
||||
Configs map[string]string `json:"configs" binding:"required"`
|
||||
}
|
||||
|
||||
// Update 批量更新配置
|
||||
func (s *ConfigService) Update(req *UpdateConfigReq) error {
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
for key, value := range req.Configs {
|
||||
if err := tx.Model(&model.SystemConfig{}).Where("`key` = ?", key).Update("value", value).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// GetValue 获取单个配置值
|
||||
func (s *ConfigService) GetValue(key string) string {
|
||||
var c model.SystemConfig
|
||||
if err := s.db.Where("`key` = ?", key).First(&c).Error; err != nil {
|
||||
return ""
|
||||
}
|
||||
return c.Value
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"photowall/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type MenuService struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewMenuService(db *gorm.DB) *MenuService {
|
||||
return &MenuService{db: db}
|
||||
}
|
||||
|
||||
// defaultMenus 默认菜单数据
|
||||
var defaultMenus = []model.Menu{
|
||||
// 首页
|
||||
{ID: 1, ParentID: 0, Name: "Home", Path: "/", Component: "home/index", Title: "首页", Icon: "fas fa-home", Sort: 1, Visible: true, RequireAuth: false, RequireAdmin: false},
|
||||
// 班级管理
|
||||
{ID: 2, ParentID: 0, Name: "Class", Path: "/class", Component: "", Title: "班级管理", Icon: "fas fa-users", Sort: 2, Visible: true, RequireAuth: false, RequireAdmin: false, Redirect: "/class/list"},
|
||||
{ID: 3, ParentID: 2, Name: "ClassList", Path: "/class/list", Component: "class/list/index", Title: "班级列表", Icon: "fas fa-list", Sort: 1, Visible: true, RequireAuth: false, RequireAdmin: false},
|
||||
{ID: 4, ParentID: 2, Name: "ClassEdit", Path: "/class/edit", Component: "class/edit/index", Title: "创建班级", Icon: "fas fa-plus", Sort: 2, Visible: true, RequireAuth: true, RequireAdmin: false},
|
||||
{ID: 5, ParentID: 2, Name: "ClassJoin", Path: "/class/join", Component: "class/join/index", Title: "加入班级", Icon: "fas fa-sign-in-alt", Sort: 3, Visible: true, RequireAuth: true, RequireAdmin: false},
|
||||
{ID: 6, ParentID: 2, Name: "ClassDetail", Path: "/class/detail/:id", Component: "class/detail/index", Title: "班级详情", Icon: "", Sort: 4, Visible: false, RequireAuth: false, RequireAdmin: false},
|
||||
// 个人中心
|
||||
{ID: 7, ParentID: 0, Name: "Profile", Path: "/profile", Component: "normalsettings/usersettings/detail/index", Title: "个人中心", Icon: "fas fa-user", Sort: 3, Visible: true, RequireAuth: true, RequireAdmin: false},
|
||||
// 系统管理(管理员)
|
||||
{ID: 8, ParentID: 0, Name: "Admin", Path: "/admin", Component: "", Title: "系统管理", Icon: "fas fa-cog", Sort: 4, Visible: true, RequireAuth: true, RequireAdmin: true, Redirect: "/admin/dashboard"},
|
||||
{ID: 18, ParentID: 8, Name: "Dashboard", Path: "/admin/dashboard", Component: "admin/dashboard/index", Title: "数据统计", Icon: "fas fa-chart-line", Sort: 1, Visible: true, RequireAuth: true, RequireAdmin: true},
|
||||
{ID: 9, ParentID: 8, Name: "AdminAudit", Path: "/admin/audit", Component: "admin/audit/list/index", Title: "审核管理", Icon: "fas fa-clipboard-check", Sort: 2, Visible: true, RequireAuth: true, RequireAdmin: true},
|
||||
{ID: 10, ParentID: 8, Name: "AdminAuditDetail", Path: "/admin/audit/detail/:id", Component: "admin/audit/detail/index", Title: "审核详情", Icon: "", Sort: 3, Visible: false, RequireAuth: true, RequireAdmin: true},
|
||||
{ID: 11, ParentID: 8, Name: "AdminConfig", Path: "/admin/config", Component: "admin/config/index", Title: "系统配置", Icon: "fas fa-sliders-h", Sort: 4, Visible: true, RequireAuth: true, RequireAdmin: true},
|
||||
{ID: 12, ParentID: 8, Name: "AdminMenu", Path: "/admin/menu", Component: "admin/menu/index", Title: "菜单管理", Icon: "fas fa-bars", Sort: 5, Visible: true, RequireAuth: true, RequireAdmin: true},
|
||||
{ID: 19, ParentID: 8, Name: "UserManage", Path: "/admin/users", Component: "admin/users/index", Title: "用户管理", Icon: "fas fa-user-shield", Sort: 6, Visible: true, RequireAuth: true, RequireAdmin: true},
|
||||
{ID: 20, ParentID: 8, Name: "SensitiveWord", Path: "/admin/sensitive", Component: "admin/sensitive/index", Title: "敏感词管理", Icon: "fas fa-shield-alt", Sort: 7, Visible: true, RequireAuth: true, RequireAdmin: true},
|
||||
// 基本设置(管理员)
|
||||
{ID: 13, ParentID: 0, Name: "NormalSettings", Path: "/normalsettings", Component: "", Title: "基本设置", Icon: "fas fa-wrench", Sort: 5, Visible: true, RequireAuth: true, RequireAdmin: true, Redirect: "/normalsettings/usersettings"},
|
||||
{ID: 14, ParentID: 13, Name: "UserSettings", Path: "/normalsettings/usersettings", Component: "normalsettings/usersettings/list/index", Title: "用户设置", Icon: "fas fa-user-cog", Sort: 1, Visible: true, RequireAuth: true, RequireAdmin: true},
|
||||
{ID: 15, ParentID: 13, Name: "RoleSettings", Path: "/normalsettings/rolesettings", Component: "normalsettings/rolesettings/list/index", Title: "角色设置", Icon: "fas fa-user-tag", Sort: 2, Visible: true, RequireAuth: true, RequireAdmin: true},
|
||||
{ID: 16, ParentID: 13, Name: "LogManagement", Path: "/normalsettings/logmanagement", Component: "normalsettings/logmanagement/list/index", Title: "日志管理", Icon: "fas fa-file-alt", Sort: 3, Visible: true, RequireAuth: true, RequireAdmin: true},
|
||||
// 登录页(不显示在菜单)
|
||||
{ID: 17, ParentID: 0, Name: "Login", Path: "/login", Component: "auth/login/index", Title: "登录", Icon: "", Sort: 99, Visible: false, RequireAuth: false, RequireAdmin: false},
|
||||
}
|
||||
|
||||
// InitDefaults 初始化默认菜单(不存在则创建)
|
||||
func (s *MenuService) InitDefaults() error {
|
||||
for _, m := range defaultMenus {
|
||||
var count int64
|
||||
s.db.Model(&model.Menu{}).Where("id = ? OR name = ?", m.ID, m.Name).Count(&count)
|
||||
if count == 0 {
|
||||
if err := s.db.Create(&m).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetMenuTree 获取菜单树(isAdmin 过滤管理员菜单)
|
||||
func (s *MenuService) GetMenuTree(isAdmin bool) ([]*model.MenuTree, error) {
|
||||
var all []model.Menu
|
||||
query := s.db.Order("parent_id, sort")
|
||||
if !isAdmin {
|
||||
query = query.Where("require_admin = ?", false)
|
||||
}
|
||||
if err := query.Find(&all).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buildTree(all, 0), nil
|
||||
}
|
||||
|
||||
// GetAll 获取所有菜单(管理用,含隐藏)
|
||||
func (s *MenuService) GetAll() ([]model.Menu, error) {
|
||||
var list []model.Menu
|
||||
err := s.db.Order("parent_id, sort").Find(&list).Error
|
||||
return list, err
|
||||
}
|
||||
|
||||
// GetByID 获取单个菜单
|
||||
func (s *MenuService) GetByID(id uint) (*model.Menu, error) {
|
||||
var m model.Menu
|
||||
if err := s.db.First(&m, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
// Create 创建菜单
|
||||
func (s *MenuService) Create(m *model.Menu) error {
|
||||
return s.db.Create(m).Error
|
||||
}
|
||||
|
||||
// Update 更新菜单
|
||||
func (s *MenuService) Update(m *model.Menu) error {
|
||||
return s.db.Save(m).Error
|
||||
}
|
||||
|
||||
// Delete 删除菜单(同时删除子菜单)
|
||||
func (s *MenuService) Delete(id uint) error {
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
// 递归删除子菜单
|
||||
var children []model.Menu
|
||||
tx.Where("parent_id = ?", id).Find(&children)
|
||||
for _, child := range children {
|
||||
if err := tx.Delete(&child).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Delete(&model.Menu{}, id).Error
|
||||
})
|
||||
}
|
||||
|
||||
// buildTree 构建菜单树
|
||||
func buildTree(list []model.Menu, parentID uint) []*model.MenuTree {
|
||||
var tree []*model.MenuTree
|
||||
for i := range list {
|
||||
if list[i].ParentID == parentID {
|
||||
node := &model.MenuTree{
|
||||
Menu: list[i],
|
||||
Children: buildTree(list, list[i].ID),
|
||||
}
|
||||
tree = append(tree, node)
|
||||
}
|
||||
}
|
||||
return tree
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"photowall/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type RegionService struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewRegionService(db *gorm.DB) *RegionService {
|
||||
return &RegionService{db: db}
|
||||
}
|
||||
|
||||
// ListByParent 根据父级ID查子地区;parentID=0 查国家
|
||||
func (s *RegionService) ListByParent(parentID uint) ([]model.Region, error) {
|
||||
var list []model.Region
|
||||
err := s.db.Where("parent_id = ?", parentID).Order("sort_order, name").Find(&list).Error
|
||||
return list, err
|
||||
}
|
||||
|
||||
// ListCountries 查所有国家
|
||||
func (s *RegionService) ListCountries() ([]model.Region, error) {
|
||||
var list []model.Region
|
||||
err := s.db.Where("level = ?", model.LevelCountry).Order("sort_order, name").Find(&list).Error
|
||||
return list, err
|
||||
}
|
||||
|
||||
// GetPath 获取某地区的完整路径(国家->省->县)
|
||||
func (s *RegionService) GetPath(regionID uint) ([]model.Region, error) {
|
||||
var r model.Region
|
||||
if err := s.db.First(&r, regionID).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
path := []model.Region{r}
|
||||
cur := r
|
||||
for cur.ParentID != 0 {
|
||||
var parent model.Region
|
||||
if err := s.db.First(&parent, cur.ParentID).Error; err != nil {
|
||||
break
|
||||
}
|
||||
// 插到前面
|
||||
path = append([]model.Region{parent}, path...)
|
||||
cur = parent
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// Search 按名称搜索地区(用于快速定位)
|
||||
func (s *RegionService) Search(keyword string, limit int) ([]model.Region, error) {
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
var list []model.Region
|
||||
err := s.db.Where("name LIKE ? OR name_en LIKE ?", "%"+keyword+"%", "%"+keyword+"%").
|
||||
Order("level, sort_order, name").Limit(limit).Find(&list).Error
|
||||
return list, err
|
||||
}
|
||||
|
||||
// BatchCreate 平台管理员批量录入地区(导入完整行政区划数据用)
|
||||
func (s *RegionService) BatchCreate(regions []model.Region) error {
|
||||
if len(regions) == 0 {
|
||||
return nil
|
||||
}
|
||||
return s.db.CreateInBatches(regions, 100).Error
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"photowall/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type SchoolService struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewSchoolService(db *gorm.DB) *SchoolService {
|
||||
return &SchoolService{db: db}
|
||||
}
|
||||
|
||||
type SchoolQuery struct {
|
||||
RegionID uint `form:"region_id"`
|
||||
SchoolType model.SchoolType `form:"school_type"`
|
||||
Keyword string `form:"keyword"`
|
||||
Page int `form:"page"`
|
||||
PageSize int `form:"page_size"`
|
||||
}
|
||||
|
||||
// PageResp 通用分页响应
|
||||
type PageResp struct {
|
||||
List interface{} `json:"list"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
}
|
||||
|
||||
type SchoolListResp = PageResp // 别名兼容
|
||||
|
||||
func (s *SchoolService) List(q *SchoolQuery) (*SchoolListResp, error) {
|
||||
if q.Page <= 0 {
|
||||
q.Page = 1
|
||||
}
|
||||
if q.PageSize <= 0 || q.PageSize > 100 {
|
||||
q.PageSize = 20
|
||||
}
|
||||
db := s.db.Model(&model.School{})
|
||||
if q.RegionID > 0 {
|
||||
db = db.Where("region_id = ?", q.RegionID)
|
||||
}
|
||||
if q.SchoolType != "" {
|
||||
db = db.Where("school_type = ?", q.SchoolType)
|
||||
}
|
||||
if q.Keyword != "" {
|
||||
db = db.Where("name LIKE ? OR name_en LIKE ?", "%"+q.Keyword+"%", "%"+q.Keyword+"%")
|
||||
}
|
||||
var total int64
|
||||
db.Count(&total)
|
||||
var list []model.School
|
||||
err := db.Order("id desc").Offset((q.Page - 1) * q.PageSize).Limit(q.PageSize).Find(&list).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &SchoolListResp{List: list, Total: total, Page: q.Page}, nil
|
||||
}
|
||||
|
||||
// Create 创建学校(必须挂在 county 级地区下)
|
||||
func (s *SchoolService) Create(school *model.School) error {
|
||||
// 校验 region 必须是 county 级
|
||||
var region model.Region
|
||||
if err := s.db.First(®ion, school.RegionID).Error; err != nil {
|
||||
return errors.New("所属地区不存在")
|
||||
}
|
||||
if region.Level != model.LevelCounty {
|
||||
return errors.New("学校必须挂载在区/县级地区下")
|
||||
}
|
||||
if school.Name == "" {
|
||||
return errors.New("学校名称不能为空")
|
||||
}
|
||||
if school.SchoolType == "" {
|
||||
return errors.New("请选择学校类型")
|
||||
}
|
||||
return s.db.Create(school).Error
|
||||
}
|
||||
|
||||
func (s *SchoolService) GetByID(id uint) (*model.School, error) {
|
||||
var school model.School
|
||||
if err := s.db.First(&school, id).Error; err != nil {
|
||||
return nil, errors.New("学校不存在")
|
||||
}
|
||||
return &school, nil
|
||||
}
|
||||
|
||||
func (s *SchoolService) Update(id uint, updates map[string]interface{}) error {
|
||||
res := s.db.Model(&model.School{}).Where("id = ?", id).Updates(updates)
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return errors.New("学校不存在")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SchoolService) Delete(id uint) error {
|
||||
// 检查是否有班级引用
|
||||
var count int64
|
||||
s.db.Model(&model.Class{}).Where("school_id = ?", id).Count(&count)
|
||||
if count > 0 {
|
||||
return errors.New("该学校下已有班级,无法删除")
|
||||
}
|
||||
return s.db.Delete(&model.School{}, id).Error
|
||||
}
|
||||
|
||||
// ---- 学院 ----
|
||||
|
||||
func (s *SchoolService) ListColleges(schoolID uint) ([]model.College, error) {
|
||||
var list []model.College
|
||||
err := s.db.Where("school_id = ?", schoolID).Order("name").Find(&list).Error
|
||||
return list, err
|
||||
}
|
||||
|
||||
func (s *SchoolService) CreateCollege(schoolID uint, name string) (*model.College, error) {
|
||||
if name == "" {
|
||||
return nil, errors.New("学院名称不能为空")
|
||||
}
|
||||
// 校验学校是否支持学院
|
||||
var school model.School
|
||||
if err := s.db.First(&school, schoolID).Error; err != nil {
|
||||
return nil, errors.New("学校不存在")
|
||||
}
|
||||
if !school.SchoolType.HasCollege() {
|
||||
return nil, errors.New("该学校类型不支持学院层级")
|
||||
}
|
||||
c := &model.College{SchoolID: schoolID, Name: name}
|
||||
if err := s.db.Create(c).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (s *SchoolService) UpdateCollege(id uint, name string) error {
|
||||
res := s.db.Model(&model.College{}).Where("id = ?", id).Update("name", name)
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return errors.New("学院不存在")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SchoolService) DeleteCollege(id uint) error {
|
||||
var count int64
|
||||
s.db.Model(&model.Class{}).Where("college_id = ?", id).Count(&count)
|
||||
if count > 0 {
|
||||
return errors.New("该学院下已有班级,无法删除")
|
||||
}
|
||||
return s.db.Delete(&model.College{}, id).Error
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"photowall/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type UserService struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewUserService(db *gorm.DB) *UserService {
|
||||
return &UserService{db: db}
|
||||
}
|
||||
|
||||
// ProfileResp 用户完整资料
|
||||
type ProfileResp struct {
|
||||
User model.User `json:"user"`
|
||||
Contacts []model.UserContact `json:"contacts"`
|
||||
Education []model.EducationHistory `json:"education"`
|
||||
}
|
||||
|
||||
func (s *UserService) GetProfile(userID uint) (*ProfileResp, error) {
|
||||
var user model.User
|
||||
if err := s.db.First(&user, userID).Error; err != nil {
|
||||
return nil, errors.New("用户不存在")
|
||||
}
|
||||
var contacts []model.UserContact
|
||||
s.db.Where("user_id = ?", userID).Order("type, is_primary desc, id").Find(&contacts)
|
||||
var edu []model.EducationHistory
|
||||
s.db.Where("user_id = ?", userID).Order("start_year desc, id desc").Find(&edu)
|
||||
return &ProfileResp{User: user, Contacts: contacts, Education: edu}, nil
|
||||
}
|
||||
|
||||
type UpdateProfileReq struct {
|
||||
Nickname string `json:"nickname"`
|
||||
Bio string `json:"bio"`
|
||||
Avatar string `json:"avatar"`
|
||||
}
|
||||
|
||||
func (s *UserService) UpdateProfile(userID uint, req *UpdateProfileReq) error {
|
||||
updates := map[string]interface{}{}
|
||||
if req.Nickname != "" {
|
||||
updates["nickname"] = req.Nickname
|
||||
}
|
||||
if req.Bio != "" {
|
||||
updates["bio"] = req.Bio
|
||||
}
|
||||
if req.Avatar != "" {
|
||||
updates["avatar"] = req.Avatar
|
||||
}
|
||||
if len(updates) == 0 {
|
||||
return nil
|
||||
}
|
||||
return s.db.Model(&model.User{}).Where("id = ?", userID).Updates(updates).Error
|
||||
}
|
||||
|
||||
// AddContact 添加联系方式,校验各类型数量上限
|
||||
func (s *UserService) AddContact(userID uint, contactType model.ContactType, value string, isPrimary bool) (*model.UserContact, error) {
|
||||
if value == "" {
|
||||
return nil, errors.New("联系方式不能为空")
|
||||
}
|
||||
max, ok := model.ContactMaxCount[contactType]
|
||||
if !ok {
|
||||
return nil, errors.New("不支持的联系方式类型")
|
||||
}
|
||||
var count int64
|
||||
s.db.Model(&model.UserContact{}).Where("user_id = ? AND type = ?", userID, contactType).Count(&count)
|
||||
if int(count) >= max {
|
||||
return nil, fmt.Errorf("%s 最多只能添加 %d 个", contactType, max)
|
||||
}
|
||||
// 如果设为主,先取消同类型其他主
|
||||
if isPrimary {
|
||||
s.db.Model(&model.UserContact{}).Where("user_id = ? AND type = ?", userID, contactType).Update("is_primary", false)
|
||||
}
|
||||
c := &model.UserContact{
|
||||
UserID: userID,
|
||||
Type: contactType,
|
||||
Value: value,
|
||||
IsPrimary: isPrimary,
|
||||
}
|
||||
if err := s.db.Create(c).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (s *UserService) UpdateContact(userID, contactID uint, value string, isPrimary *bool) error {
|
||||
var c model.UserContact
|
||||
if err := s.db.Where("id = ? AND user_id = ?", contactID, userID).First(&c).Error; err != nil {
|
||||
return errors.New("联系方式不存在")
|
||||
}
|
||||
if value != "" {
|
||||
c.Value = value
|
||||
}
|
||||
if isPrimary != nil && *isPrimary {
|
||||
s.db.Model(&model.UserContact{}).Where("user_id = ? AND type = ?", userID, c.Type).Update("is_primary", false)
|
||||
c.IsPrimary = true
|
||||
}
|
||||
return s.db.Save(&c).Error
|
||||
}
|
||||
|
||||
func (s *UserService) DeleteContact(userID, contactID uint) error {
|
||||
res := s.db.Where("id = ? AND user_id = ?", contactID, userID).Delete(&model.UserContact{})
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return errors.New("联系方式不存在")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddEducation 添加学习履历
|
||||
func (s *UserService) AddEducation(userID uint, e *model.EducationHistory) error {
|
||||
e.UserID = userID
|
||||
e.ID = 0
|
||||
return s.db.Create(e).Error
|
||||
}
|
||||
|
||||
func (s *UserService) UpdateEducation(userID, id uint, e *model.EducationHistory) error {
|
||||
res := s.db.Model(&model.EducationHistory{}).
|
||||
Where("id = ? AND user_id = ?", id, userID).
|
||||
Updates(map[string]interface{}{
|
||||
"school_id": e.SchoolID,
|
||||
"college_id": e.CollegeID,
|
||||
"class_id": e.ClassID,
|
||||
"school_name": e.SchoolName,
|
||||
"college_name": e.CollegeName,
|
||||
"major": e.Major,
|
||||
"degree": e.Degree,
|
||||
"start_year": e.StartYear,
|
||||
"end_year": e.EndYear,
|
||||
"description": e.Description,
|
||||
})
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return errors.New("履历不存在")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *UserService) DeleteEducation(userID, id uint) error {
|
||||
res := s.db.Where("id = ? AND user_id = ?", id, userID).Delete(&model.EducationHistory{})
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return errors.New("履历不存在")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HasEducation 检查用户是否有至少一条学习履历(创建班级前置条件)
|
||||
func (s *UserService) HasEducation(userID uint) bool {
|
||||
var count int64
|
||||
s.db.Model(&model.EducationHistory{}).Where("user_id = ?", userID).Count(&count)
|
||||
return count > 0
|
||||
}
|
||||
|
||||
// GetUserByID 公开用户信息(班级成员查看)
|
||||
func (s *UserService) GetUserByID(userID uint) (*model.User, error) {
|
||||
var u model.User
|
||||
if err := s.db.First(&u, userID).Error; err != nil {
|
||||
return nil, errors.New("用户不存在")
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
// GetUserContacts 公开用户联系方式(同班同学可查看)
|
||||
func (s *UserService) GetUserContacts(userID uint) []model.UserContact {
|
||||
var list []model.UserContact
|
||||
s.db.Where("user_id = ?", userID).Order("type, is_primary desc").Find(&list)
|
||||
return list
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package captcha
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/draw"
|
||||
"image/png"
|
||||
"math/big"
|
||||
"photowall/pkg/redis"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||
|
||||
// Generate 生成图形验证码,返回 captcha_id 和 base64 图片
|
||||
func Generate() (id string, imgBase64 string) {
|
||||
code := randomCode(4)
|
||||
id = randomID()
|
||||
// 存入 Redis,5分钟过期
|
||||
redis.Set(context.Background(), "captcha:"+id, code, 5*time.Minute)
|
||||
|
||||
img := drawImage(code)
|
||||
imgBase64 = encodeToBase64(img)
|
||||
return id, "data:image/png;base64," + imgBase64
|
||||
}
|
||||
|
||||
// Verify 校验验证码,校验成功后立即删除(一次性)
|
||||
// 开发万能码:888888(任意 captcha_id 均可通过,仅用于开发测试)
|
||||
func Verify(id, code string) bool {
|
||||
if code == "888888" {
|
||||
return true
|
||||
}
|
||||
if id == "" || code == "" {
|
||||
return false
|
||||
}
|
||||
stored, err := redis.Get(context.Background(), "captcha:"+id)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
redis.Del(context.Background(), "captcha:"+id) // 一次性
|
||||
return strings.EqualFold(stored, code)
|
||||
}
|
||||
|
||||
func randomCode(length int) string {
|
||||
b := make([]byte, length)
|
||||
for i := range b {
|
||||
n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(chars))))
|
||||
b[i] = chars[n.Int64()]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func randomID() string {
|
||||
b := make([]byte, 16)
|
||||
rand.Read(b)
|
||||
return fmt.Sprintf("%x", b)
|
||||
}
|
||||
|
||||
// ============ 图片绘制 ============
|
||||
|
||||
func drawImage(code string) image.Image {
|
||||
width, height := 120, 40
|
||||
img := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||
bg := color.RGBA{240, 245, 255, 255}
|
||||
draw.Draw(img, img.Bounds(), &image.Uniform{bg}, image.Point{}, draw.Src)
|
||||
|
||||
for i := 0; i < 4; i++ {
|
||||
x1, _ := rand.Int(rand.Reader, big.NewInt(int64(width)))
|
||||
y1, _ := rand.Int(rand.Reader, big.NewInt(int64(height)))
|
||||
x2, _ := rand.Int(rand.Reader, big.NewInt(int64(width)))
|
||||
y2, _ := rand.Int(rand.Reader, big.NewInt(int64(height)))
|
||||
drawLine(img, int(x1.Int64()), int(y1.Int64()), int(x2.Int64()), int(y2.Int64()), randomColor())
|
||||
}
|
||||
for i := 0; i < 30; i++ {
|
||||
x, _ := rand.Int(rand.Reader, big.NewInt(int64(width)))
|
||||
y, _ := rand.Int(rand.Reader, big.NewInt(int64(height)))
|
||||
img.Set(int(x.Int64()), int(y.Int64()), randomColor())
|
||||
}
|
||||
for i, ch := range code {
|
||||
drawChar(img, 15+i*25, 28, string(ch), randomDarkColor())
|
||||
}
|
||||
return img
|
||||
}
|
||||
|
||||
func drawChar(img *image.RGBA, x, y int, ch string, c color.Color) {
|
||||
font := map[rune][7]string{
|
||||
'0': {"01110", "10001", "10011", "10101", "11001", "10001", "01110"},
|
||||
'1': {"00100", "01100", "00100", "00100", "00100", "00100", "01110"},
|
||||
'2': {"01110", "10001", "00001", "00010", "00100", "01000", "11111"},
|
||||
'3': {"11110", "00001", "00001", "01110", "00001", "00001", "11110"},
|
||||
'4': {"00010", "00110", "01010", "10010", "11111", "00010", "00010"},
|
||||
'5': {"11111", "10000", "11110", "00001", "00001", "10001", "01110"},
|
||||
'6': {"00110", "01000", "10000", "11110", "10001", "10001", "01110"},
|
||||
'7': {"11111", "00001", "00010", "00100", "01000", "01000", "01000"},
|
||||
'8': {"01110", "10001", "10001", "01110", "10001", "10001", "01110"},
|
||||
'9': {"01110", "10001", "10001", "01111", "00001", "00010", "01100"},
|
||||
'A': {"01110", "10001", "10001", "11111", "10001", "10001", "10001"},
|
||||
'B': {"11110", "10001", "10001", "11110", "10001", "10001", "11110"},
|
||||
'C': {"01111", "10000", "10000", "10000", "10000", "10000", "01111"},
|
||||
'D': {"11110", "10001", "10001", "10001", "10001", "10001", "11110"},
|
||||
'E': {"11111", "10000", "10000", "11110", "10000", "10000", "11111"},
|
||||
'F': {"11111", "10000", "10000", "11110", "10000", "10000", "10000"},
|
||||
'G': {"01111", "10000", "10000", "10111", "10001", "10001", "01110"},
|
||||
'H': {"10001", "10001", "10001", "11111", "10001", "10001", "10001"},
|
||||
'J': {"00001", "00001", "00001", "00001", "00001", "10001", "01110"},
|
||||
'K': {"10001", "10010", "10100", "11000", "10100", "10010", "10001"},
|
||||
'L': {"10000", "10000", "10000", "10000", "10000", "10000", "11111"},
|
||||
'M': {"10001", "11011", "10101", "10101", "10001", "10001", "10001"},
|
||||
'N': {"10001", "11001", "10101", "10011", "10001", "10001", "10001"},
|
||||
'P': {"11110", "10001", "10001", "11110", "10000", "10000", "10000"},
|
||||
'Q': {"01110", "10001", "10001", "10001", "10101", "10010", "01101"},
|
||||
'R': {"11110", "10001", "10001", "11110", "10100", "10010", "10001"},
|
||||
'S': {"01111", "10000", "10000", "01110", "00001", "00001", "11110"},
|
||||
'T': {"11111", "00100", "00100", "00100", "00100", "00100", "00100"},
|
||||
'U': {"10001", "10001", "10001", "10001", "10001", "10001", "01110"},
|
||||
'V': {"10001", "10001", "10001", "10001", "10001", "01010", "00100"},
|
||||
'W': {"10001", "10001", "10001", "10101", "10101", "11011", "10001"},
|
||||
'X': {"10001", "10001", "01010", "00100", "01010", "10001", "10001"},
|
||||
'Y': {"10001", "10001", "01010", "00100", "00100", "00100", "00100"},
|
||||
'Z': {"11111", "00001", "00010", "00100", "01000", "10000", "11111"},
|
||||
}
|
||||
pattern, ok := font[rune(ch[0])]
|
||||
if !ok {
|
||||
pattern = font['0']
|
||||
}
|
||||
scale := 3
|
||||
for row, line := range pattern {
|
||||
for col, px := range line {
|
||||
if px == '1' {
|
||||
for dy := 0; dy < scale; dy++ {
|
||||
for dx := 0; dx < scale; dx++ {
|
||||
img.Set(x+col*scale+dx, y-row*scale-7*scale+dy, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func drawLine(img *image.RGBA, x1, y1, x2, y2 int, c color.Color) {
|
||||
dx := abs(x2 - x1)
|
||||
dy := abs(y2 - y1)
|
||||
sx, sy := 1, -1
|
||||
if x1 > x2 {
|
||||
sx = -1
|
||||
}
|
||||
if y1 < y2 {
|
||||
sy = 1
|
||||
}
|
||||
err := dx - dy
|
||||
for {
|
||||
img.Set(x1, y1, c)
|
||||
if x1 == x2 && y1 == y2 {
|
||||
break
|
||||
}
|
||||
e2 := 2 * err
|
||||
if e2 > -dy {
|
||||
err -= dy
|
||||
x1 += sx
|
||||
}
|
||||
if e2 < dx {
|
||||
err += dx
|
||||
y1 += sy
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func abs(x int) int {
|
||||
if x < 0 {
|
||||
return -x
|
||||
}
|
||||
return x
|
||||
}
|
||||
|
||||
func randomColor() color.RGBA {
|
||||
r, _ := rand.Int(rand.Reader, big.NewInt(200))
|
||||
g, _ := rand.Int(rand.Reader, big.NewInt(200))
|
||||
b, _ := rand.Int(rand.Reader, big.NewInt(200))
|
||||
return color.RGBA{uint8(r.Int64() + 55), uint8(g.Int64() + 55), uint8(b.Int64() + 55), 255}
|
||||
}
|
||||
|
||||
func randomDarkColor() color.RGBA {
|
||||
r, _ := rand.Int(rand.Reader, big.NewInt(100))
|
||||
g, _ := rand.Int(rand.Reader, big.NewInt(100))
|
||||
b, _ := rand.Int(rand.Reader, big.NewInt(150))
|
||||
return color.RGBA{uint8(r.Int64()), uint8(g.Int64()), uint8(b.Int64() + 50), 255}
|
||||
}
|
||||
|
||||
func encodeToBase64(img image.Image) string {
|
||||
var buf strings.Builder
|
||||
png.Encode(base64.NewEncoder(base64.StdEncoding, &buf), img)
|
||||
return buf.String()
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package geetest
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"photowall/internal/config"
|
||||
)
|
||||
|
||||
// 极验行为验证封装
|
||||
// 参数未配置时返回 ErrNotConfigured,前端可降级为图形验证码
|
||||
|
||||
var ErrNotConfigured = errors.New("极验未配置,请联系管理员")
|
||||
|
||||
// Validate 校验极验二次验证结果
|
||||
// lotNumber: 极验返回的 lot_number
|
||||
// captchaOutput: 极验返回的 captcha_output
|
||||
// passToken: 极验返回的 pass_token
|
||||
// genTime: 极验返回的 gen_time
|
||||
func Validate(lotNumber, captchaOutput, passToken, genTime string) error {
|
||||
cfg := config.C
|
||||
if cfg.GeeTestID == "" || cfg.GeeTestKey == "" {
|
||||
return ErrNotConfigured
|
||||
}
|
||||
// TODO: 接入极验官方 SDK 进行二次验证
|
||||
// 官方流程:用 captcha_id + lot_number + captcha_output + pass_token + gen_time
|
||||
// 向极验服务器发送 POST 请求验证
|
||||
// 参考:https://docs.geetest.com/gt4/apirefer/api/server
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package hash
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
)
|
||||
|
||||
// GenerateSalt 生成随机盐(16字节 hex = 32字符)
|
||||
func GenerateSalt() string {
|
||||
b := make([]byte, 16)
|
||||
rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// Password 用盐+MD5加密密码,返回 salt 和 hash
|
||||
func Password(pwd string) (salt string, hash string) {
|
||||
salt = GenerateSalt()
|
||||
hash = MD5Salt(pwd, salt)
|
||||
return
|
||||
}
|
||||
|
||||
// MD5Salt 用指定盐计算 MD5:md5(salt + password)
|
||||
func MD5Salt(pwd, salt string) string {
|
||||
h := md5.New()
|
||||
h.Write([]byte(salt + pwd))
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
// Verify 验证密码:用存储的盐重新计算 MD5 比对
|
||||
func Verify(storedHash, salt, pwd string) bool {
|
||||
if salt == "" || storedHash == "" {
|
||||
return false
|
||||
}
|
||||
return MD5Salt(pwd, salt) == storedHash
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package jwt
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
type Claims struct {
|
||||
UserID uint `json:"uid"`
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
Secret []byte
|
||||
ExpireHrs int
|
||||
}
|
||||
|
||||
func New(secret string, expireHrs int) *Manager {
|
||||
return &Manager{Secret: []byte(secret), ExpireHrs: expireHrs}
|
||||
}
|
||||
|
||||
func (m *Manager) Generate(userID uint, username, role string) (string, error) {
|
||||
claims := Claims{
|
||||
UserID: userID,
|
||||
Username: username,
|
||||
Role: role,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(m.ExpireHrs) * time.Hour)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
},
|
||||
}
|
||||
t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return t.SignedString(m.Secret)
|
||||
}
|
||||
|
||||
func (m *Manager) Parse(tokenStr string) (*Claims, error) {
|
||||
claims := &Claims{}
|
||||
t, err := jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (interface{}, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, errors.New("unexpected signing method")
|
||||
}
|
||||
return m.Secret, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !t.Valid {
|
||||
return nil, errors.New("invalid token")
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package redis
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
var (
|
||||
Client *redis.Client
|
||||
Enabled bool
|
||||
memoryStore sync.Map // Redis 不可用时的内存降级存储
|
||||
)
|
||||
|
||||
// Config Redis 配置
|
||||
type Config struct {
|
||||
Host string
|
||||
Port string
|
||||
Password string
|
||||
DB int
|
||||
}
|
||||
|
||||
// Init 初始化 Redis 连接,失败则降级为内存模式
|
||||
func Init(cfg Config) {
|
||||
Client = redis.NewClient(&redis.Options{
|
||||
Addr: fmt.Sprintf("%s:%s", cfg.Host, cfg.Port),
|
||||
Password: cfg.Password,
|
||||
DB: cfg.DB,
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := Client.Ping(ctx).Err(); err != nil {
|
||||
log.Printf("[redis] connection failed (%v), fallback to memory mode", err)
|
||||
Enabled = false
|
||||
Client = nil
|
||||
return
|
||||
}
|
||||
|
||||
Enabled = true
|
||||
log.Printf("[redis] connected to %s:%s (db=%d)", cfg.Host, cfg.Port, cfg.DB)
|
||||
}
|
||||
|
||||
// Set 设置键值(带过期时间,0=不过期)
|
||||
func Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error {
|
||||
if Enabled && Client != nil {
|
||||
return Client.Set(ctx, key, value, expiration).Err()
|
||||
}
|
||||
// 内存降级
|
||||
memoryStore.Store(key, memoryItem{value: value, expireAt: time.Now().Add(expiration)})
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get 获取值
|
||||
func Get(ctx context.Context, key string) (string, error) {
|
||||
if Enabled && Client != nil {
|
||||
return Client.Get(ctx, key).Result()
|
||||
}
|
||||
// 内存降级
|
||||
v, ok := memoryStore.Load(key)
|
||||
if !ok {
|
||||
return "", redis.Nil
|
||||
}
|
||||
item := v.(memoryItem)
|
||||
if time.Now().After(item.expireAt) {
|
||||
memoryStore.Delete(key)
|
||||
return "", redis.Nil
|
||||
}
|
||||
return fmt.Sprintf("%v", item.value), nil
|
||||
}
|
||||
|
||||
// Del 删除键
|
||||
func Del(ctx context.Context, key string) error {
|
||||
if Enabled && Client != nil {
|
||||
return Client.Del(ctx, key).Err()
|
||||
}
|
||||
memoryStore.Delete(key)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Exists 检查键是否存在
|
||||
func Exists(ctx context.Context, key string) (int64, error) {
|
||||
if Enabled && Client != nil {
|
||||
return Client.Exists(ctx, key).Result()
|
||||
}
|
||||
_, ok := memoryStore.Load(key)
|
||||
if ok {
|
||||
return 1, nil
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// Incr 自增
|
||||
func Incr(ctx context.Context, key string) (int64, error) {
|
||||
if Enabled && Client != nil {
|
||||
return Client.Incr(ctx, key).Result()
|
||||
}
|
||||
// 内存降级(简单实现)
|
||||
v, _ := Get(ctx, key)
|
||||
var n int64
|
||||
fmt.Sscanf(v, "%d", &n)
|
||||
n++
|
||||
Set(ctx, key, n, 0)
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// memoryItem 内存存储项
|
||||
type memoryItem struct {
|
||||
value interface{}
|
||||
expireAt time.Time
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package response
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// 统一响应结构
|
||||
type Resp struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
func OK(c *gin.Context, data interface{}) {
|
||||
c.JSON(http.StatusOK, Resp{Code: 0, Message: "ok", Data: data})
|
||||
}
|
||||
|
||||
func Created(c *gin.Context, data interface{}) {
|
||||
c.JSON(http.StatusCreated, Resp{Code: 0, Message: "created", Data: data})
|
||||
}
|
||||
|
||||
func Fail(c *gin.Context, httpStatus int, msg string) {
|
||||
c.JSON(httpStatus, Resp{Code: httpStatus, Message: msg})
|
||||
}
|
||||
|
||||
func BadRequest(c *gin.Context, msg string) {
|
||||
Fail(c, http.StatusBadRequest, msg)
|
||||
}
|
||||
|
||||
func Unauthorized(c *gin.Context, msg string) {
|
||||
Fail(c, http.StatusUnauthorized, msg)
|
||||
}
|
||||
|
||||
func Forbidden(c *gin.Context, msg string) {
|
||||
Fail(c, http.StatusForbidden, msg)
|
||||
}
|
||||
|
||||
func NotFound(c *gin.Context, msg string) {
|
||||
Fail(c, http.StatusNotFound, msg)
|
||||
}
|
||||
|
||||
func Conflict(c *gin.Context, msg string) {
|
||||
Fail(c, http.StatusConflict, msg)
|
||||
}
|
||||
|
||||
func Internal(c *gin.Context, msg string) {
|
||||
Fail(c, http.StatusInternalServerError, msg)
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package sensitive
|
||||
|
||||
import (
|
||||
"photowall/internal/model"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// DFA 敏感词过滤器
|
||||
type Filter struct {
|
||||
root *trieNode
|
||||
mu sync.RWMutex
|
||||
ready bool
|
||||
}
|
||||
|
||||
type trieNode struct {
|
||||
children map[rune]*trieNode
|
||||
isEnd bool
|
||||
word string
|
||||
}
|
||||
|
||||
var globalFilter = &Filter{root: newNode()}
|
||||
|
||||
func newNode() *trieNode {
|
||||
return &trieNode{children: make(map[rune]*trieNode)}
|
||||
}
|
||||
|
||||
// Init 从数据库加载敏感词
|
||||
func Init(db *gorm.DB) {
|
||||
var words []model.SensitiveWord
|
||||
db.Where("enabled = ?", true).Find(&words)
|
||||
globalFilter.mu.Lock()
|
||||
defer globalFilter.mu.Unlock()
|
||||
globalFilter.root = newNode()
|
||||
for _, w := range words {
|
||||
globalFilter.addWord(w.Word)
|
||||
}
|
||||
globalFilter.ready = true
|
||||
}
|
||||
|
||||
// Reload 重新加载敏感词
|
||||
func Reload(db *gorm.DB) {
|
||||
Init(db)
|
||||
}
|
||||
|
||||
func (f *Filter) addWord(word string) {
|
||||
node := f.root
|
||||
for _, ch := range []rune(word) {
|
||||
if _, ok := node.children[ch]; !ok {
|
||||
node.children[ch] = newNode()
|
||||
}
|
||||
node = node.children[ch]
|
||||
}
|
||||
node.isEnd = true
|
||||
node.word = word
|
||||
}
|
||||
|
||||
// Check 检测文本中是否包含敏感词,返回第一个匹配的词
|
||||
func Check(text string) (bool, string) {
|
||||
if !globalFilter.ready || text == "" {
|
||||
return false, ""
|
||||
}
|
||||
globalFilter.mu.RLock()
|
||||
defer globalFilter.mu.RUnlock()
|
||||
runes := []rune(text)
|
||||
for i := 0; i < len(runes); i++ {
|
||||
node := globalFilter.root
|
||||
for j := i; j < len(runes); j++ {
|
||||
next, ok := node.children[runes[j]]
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
node = next
|
||||
if node.isEnd {
|
||||
return true, node.word
|
||||
}
|
||||
}
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// Replace 替换文本中的敏感词为 ***
|
||||
func Replace(text string) string {
|
||||
if !globalFilter.ready || text == "" {
|
||||
return text
|
||||
}
|
||||
globalFilter.mu.RLock()
|
||||
defer globalFilter.mu.RUnlock()
|
||||
runes := []rune(text)
|
||||
var result strings.Builder
|
||||
i := 0
|
||||
for i < len(runes) {
|
||||
node := globalFilter.root
|
||||
matchLen := 0
|
||||
for j := i; j < len(runes); j++ {
|
||||
next, ok := node.children[runes[j]]
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
node = next
|
||||
if node.isEnd {
|
||||
matchLen = j - i + 1
|
||||
}
|
||||
}
|
||||
if matchLen > 0 {
|
||||
result.WriteString("***")
|
||||
i += matchLen
|
||||
} else {
|
||||
result.WriteRune(runes[i])
|
||||
i++
|
||||
}
|
||||
}
|
||||
return result.String()
|
||||
}
|
||||
|
||||
// CheckAndReplace 检测并过滤,返回是否包含敏感词和过滤后的文本
|
||||
func CheckAndReplace(text string) (bool, string) {
|
||||
found, _ := Check(text)
|
||||
if !found {
|
||||
return false, text
|
||||
}
|
||||
return true, Replace(text)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package sms
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"photowall/internal/config"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 短信验证码封装
|
||||
// 参数未配置时返回 ErrNotConfigured,开发阶段可在日志中输出验证码
|
||||
|
||||
var ErrNotConfigured = errors.New("短信服务未配置,请联系管理员")
|
||||
|
||||
type codeItem struct {
|
||||
code string
|
||||
expireAt time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
store = sync.Map{} // phone -> codeItem
|
||||
)
|
||||
|
||||
// SendCode 发送短信验证码
|
||||
// 开发阶段(未配置短信服务商):验证码输出到日志,方便测试
|
||||
func SendCode(phone string) (string, error) {
|
||||
cfg := config.C
|
||||
code := generateCode()
|
||||
|
||||
if cfg.SMSAccessKey == "" || cfg.SMSSecretKey == "" || cfg.SMSSignName == "" || cfg.SMSTemplateCode == "" {
|
||||
// 未配置短信服务商,开发模式:日志输出验证码
|
||||
fmt.Printf("[SMS-DEV] 手机号 %s 的验证码: %s (5分钟有效)\n", phone, code)
|
||||
store.Store(phone, codeItem{code: code, expireAt: time.Now().Add(5 * time.Minute)})
|
||||
return code, nil
|
||||
}
|
||||
|
||||
// TODO: 接入阿里云/腾讯云短信 SDK
|
||||
// 阿里云:dysmsapi.Client + SendSmsRequest
|
||||
// 腾讯云:sms.Client + SendSms
|
||||
store.Store(phone, codeItem{code: code, expireAt: time.Now().Add(5 * time.Minute)})
|
||||
return code, nil
|
||||
}
|
||||
|
||||
// VerifyCode 校验短信验证码(一次性)
|
||||
func VerifyCode(phone, code string) bool {
|
||||
if phone == "" || code == "" {
|
||||
return false
|
||||
}
|
||||
v, ok := store.Load(phone)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
item := v.(codeItem)
|
||||
store.Delete(phone)
|
||||
if time.Now().After(item.expireAt) {
|
||||
return false
|
||||
}
|
||||
return item.code == code
|
||||
}
|
||||
|
||||
func generateCode() string {
|
||||
b := make([]byte, 6)
|
||||
for i := range b {
|
||||
n, _ := rand.Int(rand.Reader, big.NewInt(10))
|
||||
b[i] = byte('0' + n.Int64())
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
Reference in New Issue
Block a user