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:
hero920103
2026-09-03 05:55:23 +08:00
commit 57d9866dab
82 changed files with 12668 additions and 0 deletions
+85
View File
@@ -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)
}
}
+46
View File
@@ -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
)
+114
View File
@@ -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=
+111
View File
@@ -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
}
+133
View File
@@ -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
}
+78
View File
@@ -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
}
+226
View File
@@ -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": "已更新"})
}
+146
View File
@@ -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": "",
})
}
+224
View File
@@ -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": "已重新提交审核"})
}
+58
View File
@@ -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": "配置更新成功,部分配置需重启服务生效"})
}
+80
View File
@@ -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": "删除成功"})
}
+64
View File
@@ -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)
}
+157
View File
@@ -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": "删除成功"})
}
+129
View File
@@ -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,
})
}
+159
View File
@@ -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})
}
+82
View File
@@ -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
}
+24
View File
@@ -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"`
}
+116
View File
@@ -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"`
}
+18
View File
@@ -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"`
}
+28
View File
@@ -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"`
}
+37
View File
@@ -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"`
}
+62
View File
@@ -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"`
}
+13
View File
@@ -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"`
}
+18
View File
@@ -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"`
}
+73
View File
@@ -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"`
}
+169
View File
@@ -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()
}
}
+35
View File
@@ -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)
}
}
+388
View File
@@ -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
}
+279
View File
@@ -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)
}
+628
View File
@@ -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
}
+98
View File
@@ -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
}
+128
View File
@@ -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
}
+68
View File
@@ -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
}
+155
View File
@@ -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(&region, 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
}
+180
View File
@@ -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
}
+198
View File
@@ -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()
}
+28
View File
@@ -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
}
+36
View File
@@ -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
}
+55
View File
@@ -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
}
+116
View File
@@ -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
}
+50
View File
@@ -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)
}
+125
View File
@@ -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)
}
+71
View File
@@ -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)
}
View File
View File
View File