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
+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)
}