feat: PhotoWall 毕业照存储系统初始版本
后端: Go(Gin+GORM+MySQL+Redis) - 17张业务表(yz_pw_前缀), 自动迁移 - JWT认证(3小时) + 盐+MD5密码 + 图形验证码 - 班级CRUD/加入(8人姓名验证/邀请码)/审核/30天自动清理 - 系统配置(16项)/菜单管理(动态路由)/数据统计 - 文件MD5去重/数据隔离/账号封禁/敏感词DFA检测 前端: Vue3+Vite+Element Plus+Less+ECharts+FontAwesome - 动态路由(数据库菜单驱动) - 蓝白配色, H5响应式 - 登录/注册/忘记密码/个人中心 - 班级创建向导/详情/加入/列表 - 管理后台: 审核/配置/菜单/统计/用户/敏感词 数据库: MySQL 10.31.100.3:3306/photowall 管理员: hero920103 / 920103
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
package captcha
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/draw"
|
||||
"image/png"
|
||||
"math/big"
|
||||
"photowall/pkg/redis"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||
|
||||
// Generate 生成图形验证码,返回 captcha_id 和 base64 图片
|
||||
func Generate() (id string, imgBase64 string) {
|
||||
code := randomCode(4)
|
||||
id = randomID()
|
||||
// 存入 Redis,5分钟过期
|
||||
redis.Set(context.Background(), "captcha:"+id, code, 5*time.Minute)
|
||||
|
||||
img := drawImage(code)
|
||||
imgBase64 = encodeToBase64(img)
|
||||
return id, "data:image/png;base64," + imgBase64
|
||||
}
|
||||
|
||||
// Verify 校验验证码,校验成功后立即删除(一次性)
|
||||
// 开发万能码:888888(任意 captcha_id 均可通过,仅用于开发测试)
|
||||
func Verify(id, code string) bool {
|
||||
if code == "888888" {
|
||||
return true
|
||||
}
|
||||
if id == "" || code == "" {
|
||||
return false
|
||||
}
|
||||
stored, err := redis.Get(context.Background(), "captcha:"+id)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
redis.Del(context.Background(), "captcha:"+id) // 一次性
|
||||
return strings.EqualFold(stored, code)
|
||||
}
|
||||
|
||||
func randomCode(length int) string {
|
||||
b := make([]byte, length)
|
||||
for i := range b {
|
||||
n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(chars))))
|
||||
b[i] = chars[n.Int64()]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func randomID() string {
|
||||
b := make([]byte, 16)
|
||||
rand.Read(b)
|
||||
return fmt.Sprintf("%x", b)
|
||||
}
|
||||
|
||||
// ============ 图片绘制 ============
|
||||
|
||||
func drawImage(code string) image.Image {
|
||||
width, height := 120, 40
|
||||
img := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||
bg := color.RGBA{240, 245, 255, 255}
|
||||
draw.Draw(img, img.Bounds(), &image.Uniform{bg}, image.Point{}, draw.Src)
|
||||
|
||||
for i := 0; i < 4; i++ {
|
||||
x1, _ := rand.Int(rand.Reader, big.NewInt(int64(width)))
|
||||
y1, _ := rand.Int(rand.Reader, big.NewInt(int64(height)))
|
||||
x2, _ := rand.Int(rand.Reader, big.NewInt(int64(width)))
|
||||
y2, _ := rand.Int(rand.Reader, big.NewInt(int64(height)))
|
||||
drawLine(img, int(x1.Int64()), int(y1.Int64()), int(x2.Int64()), int(y2.Int64()), randomColor())
|
||||
}
|
||||
for i := 0; i < 30; i++ {
|
||||
x, _ := rand.Int(rand.Reader, big.NewInt(int64(width)))
|
||||
y, _ := rand.Int(rand.Reader, big.NewInt(int64(height)))
|
||||
img.Set(int(x.Int64()), int(y.Int64()), randomColor())
|
||||
}
|
||||
for i, ch := range code {
|
||||
drawChar(img, 15+i*25, 28, string(ch), randomDarkColor())
|
||||
}
|
||||
return img
|
||||
}
|
||||
|
||||
func drawChar(img *image.RGBA, x, y int, ch string, c color.Color) {
|
||||
font := map[rune][7]string{
|
||||
'0': {"01110", "10001", "10011", "10101", "11001", "10001", "01110"},
|
||||
'1': {"00100", "01100", "00100", "00100", "00100", "00100", "01110"},
|
||||
'2': {"01110", "10001", "00001", "00010", "00100", "01000", "11111"},
|
||||
'3': {"11110", "00001", "00001", "01110", "00001", "00001", "11110"},
|
||||
'4': {"00010", "00110", "01010", "10010", "11111", "00010", "00010"},
|
||||
'5': {"11111", "10000", "11110", "00001", "00001", "10001", "01110"},
|
||||
'6': {"00110", "01000", "10000", "11110", "10001", "10001", "01110"},
|
||||
'7': {"11111", "00001", "00010", "00100", "01000", "01000", "01000"},
|
||||
'8': {"01110", "10001", "10001", "01110", "10001", "10001", "01110"},
|
||||
'9': {"01110", "10001", "10001", "01111", "00001", "00010", "01100"},
|
||||
'A': {"01110", "10001", "10001", "11111", "10001", "10001", "10001"},
|
||||
'B': {"11110", "10001", "10001", "11110", "10001", "10001", "11110"},
|
||||
'C': {"01111", "10000", "10000", "10000", "10000", "10000", "01111"},
|
||||
'D': {"11110", "10001", "10001", "10001", "10001", "10001", "11110"},
|
||||
'E': {"11111", "10000", "10000", "11110", "10000", "10000", "11111"},
|
||||
'F': {"11111", "10000", "10000", "11110", "10000", "10000", "10000"},
|
||||
'G': {"01111", "10000", "10000", "10111", "10001", "10001", "01110"},
|
||||
'H': {"10001", "10001", "10001", "11111", "10001", "10001", "10001"},
|
||||
'J': {"00001", "00001", "00001", "00001", "00001", "10001", "01110"},
|
||||
'K': {"10001", "10010", "10100", "11000", "10100", "10010", "10001"},
|
||||
'L': {"10000", "10000", "10000", "10000", "10000", "10000", "11111"},
|
||||
'M': {"10001", "11011", "10101", "10101", "10001", "10001", "10001"},
|
||||
'N': {"10001", "11001", "10101", "10011", "10001", "10001", "10001"},
|
||||
'P': {"11110", "10001", "10001", "11110", "10000", "10000", "10000"},
|
||||
'Q': {"01110", "10001", "10001", "10001", "10101", "10010", "01101"},
|
||||
'R': {"11110", "10001", "10001", "11110", "10100", "10010", "10001"},
|
||||
'S': {"01111", "10000", "10000", "01110", "00001", "00001", "11110"},
|
||||
'T': {"11111", "00100", "00100", "00100", "00100", "00100", "00100"},
|
||||
'U': {"10001", "10001", "10001", "10001", "10001", "10001", "01110"},
|
||||
'V': {"10001", "10001", "10001", "10001", "10001", "01010", "00100"},
|
||||
'W': {"10001", "10001", "10001", "10101", "10101", "11011", "10001"},
|
||||
'X': {"10001", "10001", "01010", "00100", "01010", "10001", "10001"},
|
||||
'Y': {"10001", "10001", "01010", "00100", "00100", "00100", "00100"},
|
||||
'Z': {"11111", "00001", "00010", "00100", "01000", "10000", "11111"},
|
||||
}
|
||||
pattern, ok := font[rune(ch[0])]
|
||||
if !ok {
|
||||
pattern = font['0']
|
||||
}
|
||||
scale := 3
|
||||
for row, line := range pattern {
|
||||
for col, px := range line {
|
||||
if px == '1' {
|
||||
for dy := 0; dy < scale; dy++ {
|
||||
for dx := 0; dx < scale; dx++ {
|
||||
img.Set(x+col*scale+dx, y-row*scale-7*scale+dy, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func drawLine(img *image.RGBA, x1, y1, x2, y2 int, c color.Color) {
|
||||
dx := abs(x2 - x1)
|
||||
dy := abs(y2 - y1)
|
||||
sx, sy := 1, -1
|
||||
if x1 > x2 {
|
||||
sx = -1
|
||||
}
|
||||
if y1 < y2 {
|
||||
sy = 1
|
||||
}
|
||||
err := dx - dy
|
||||
for {
|
||||
img.Set(x1, y1, c)
|
||||
if x1 == x2 && y1 == y2 {
|
||||
break
|
||||
}
|
||||
e2 := 2 * err
|
||||
if e2 > -dy {
|
||||
err -= dy
|
||||
x1 += sx
|
||||
}
|
||||
if e2 < dx {
|
||||
err += dx
|
||||
y1 += sy
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func abs(x int) int {
|
||||
if x < 0 {
|
||||
return -x
|
||||
}
|
||||
return x
|
||||
}
|
||||
|
||||
func randomColor() color.RGBA {
|
||||
r, _ := rand.Int(rand.Reader, big.NewInt(200))
|
||||
g, _ := rand.Int(rand.Reader, big.NewInt(200))
|
||||
b, _ := rand.Int(rand.Reader, big.NewInt(200))
|
||||
return color.RGBA{uint8(r.Int64() + 55), uint8(g.Int64() + 55), uint8(b.Int64() + 55), 255}
|
||||
}
|
||||
|
||||
func randomDarkColor() color.RGBA {
|
||||
r, _ := rand.Int(rand.Reader, big.NewInt(100))
|
||||
g, _ := rand.Int(rand.Reader, big.NewInt(100))
|
||||
b, _ := rand.Int(rand.Reader, big.NewInt(150))
|
||||
return color.RGBA{uint8(r.Int64()), uint8(g.Int64()), uint8(b.Int64() + 50), 255}
|
||||
}
|
||||
|
||||
func encodeToBase64(img image.Image) string {
|
||||
var buf strings.Builder
|
||||
png.Encode(base64.NewEncoder(base64.StdEncoding, &buf), img)
|
||||
return buf.String()
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package geetest
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"photowall/internal/config"
|
||||
)
|
||||
|
||||
// 极验行为验证封装
|
||||
// 参数未配置时返回 ErrNotConfigured,前端可降级为图形验证码
|
||||
|
||||
var ErrNotConfigured = errors.New("极验未配置,请联系管理员")
|
||||
|
||||
// Validate 校验极验二次验证结果
|
||||
// lotNumber: 极验返回的 lot_number
|
||||
// captchaOutput: 极验返回的 captcha_output
|
||||
// passToken: 极验返回的 pass_token
|
||||
// genTime: 极验返回的 gen_time
|
||||
func Validate(lotNumber, captchaOutput, passToken, genTime string) error {
|
||||
cfg := config.C
|
||||
if cfg.GeeTestID == "" || cfg.GeeTestKey == "" {
|
||||
return ErrNotConfigured
|
||||
}
|
||||
// TODO: 接入极验官方 SDK 进行二次验证
|
||||
// 官方流程:用 captcha_id + lot_number + captcha_output + pass_token + gen_time
|
||||
// 向极验服务器发送 POST 请求验证
|
||||
// 参考:https://docs.geetest.com/gt4/apirefer/api/server
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package hash
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
)
|
||||
|
||||
// GenerateSalt 生成随机盐(16字节 hex = 32字符)
|
||||
func GenerateSalt() string {
|
||||
b := make([]byte, 16)
|
||||
rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// Password 用盐+MD5加密密码,返回 salt 和 hash
|
||||
func Password(pwd string) (salt string, hash string) {
|
||||
salt = GenerateSalt()
|
||||
hash = MD5Salt(pwd, salt)
|
||||
return
|
||||
}
|
||||
|
||||
// MD5Salt 用指定盐计算 MD5:md5(salt + password)
|
||||
func MD5Salt(pwd, salt string) string {
|
||||
h := md5.New()
|
||||
h.Write([]byte(salt + pwd))
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
// Verify 验证密码:用存储的盐重新计算 MD5 比对
|
||||
func Verify(storedHash, salt, pwd string) bool {
|
||||
if salt == "" || storedHash == "" {
|
||||
return false
|
||||
}
|
||||
return MD5Salt(pwd, salt) == storedHash
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package jwt
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
type Claims struct {
|
||||
UserID uint `json:"uid"`
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
Secret []byte
|
||||
ExpireHrs int
|
||||
}
|
||||
|
||||
func New(secret string, expireHrs int) *Manager {
|
||||
return &Manager{Secret: []byte(secret), ExpireHrs: expireHrs}
|
||||
}
|
||||
|
||||
func (m *Manager) Generate(userID uint, username, role string) (string, error) {
|
||||
claims := Claims{
|
||||
UserID: userID,
|
||||
Username: username,
|
||||
Role: role,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(m.ExpireHrs) * time.Hour)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
},
|
||||
}
|
||||
t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return t.SignedString(m.Secret)
|
||||
}
|
||||
|
||||
func (m *Manager) Parse(tokenStr string) (*Claims, error) {
|
||||
claims := &Claims{}
|
||||
t, err := jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (interface{}, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, errors.New("unexpected signing method")
|
||||
}
|
||||
return m.Secret, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !t.Valid {
|
||||
return nil, errors.New("invalid token")
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package redis
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
var (
|
||||
Client *redis.Client
|
||||
Enabled bool
|
||||
memoryStore sync.Map // Redis 不可用时的内存降级存储
|
||||
)
|
||||
|
||||
// Config Redis 配置
|
||||
type Config struct {
|
||||
Host string
|
||||
Port string
|
||||
Password string
|
||||
DB int
|
||||
}
|
||||
|
||||
// Init 初始化 Redis 连接,失败则降级为内存模式
|
||||
func Init(cfg Config) {
|
||||
Client = redis.NewClient(&redis.Options{
|
||||
Addr: fmt.Sprintf("%s:%s", cfg.Host, cfg.Port),
|
||||
Password: cfg.Password,
|
||||
DB: cfg.DB,
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := Client.Ping(ctx).Err(); err != nil {
|
||||
log.Printf("[redis] connection failed (%v), fallback to memory mode", err)
|
||||
Enabled = false
|
||||
Client = nil
|
||||
return
|
||||
}
|
||||
|
||||
Enabled = true
|
||||
log.Printf("[redis] connected to %s:%s (db=%d)", cfg.Host, cfg.Port, cfg.DB)
|
||||
}
|
||||
|
||||
// Set 设置键值(带过期时间,0=不过期)
|
||||
func Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error {
|
||||
if Enabled && Client != nil {
|
||||
return Client.Set(ctx, key, value, expiration).Err()
|
||||
}
|
||||
// 内存降级
|
||||
memoryStore.Store(key, memoryItem{value: value, expireAt: time.Now().Add(expiration)})
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get 获取值
|
||||
func Get(ctx context.Context, key string) (string, error) {
|
||||
if Enabled && Client != nil {
|
||||
return Client.Get(ctx, key).Result()
|
||||
}
|
||||
// 内存降级
|
||||
v, ok := memoryStore.Load(key)
|
||||
if !ok {
|
||||
return "", redis.Nil
|
||||
}
|
||||
item := v.(memoryItem)
|
||||
if time.Now().After(item.expireAt) {
|
||||
memoryStore.Delete(key)
|
||||
return "", redis.Nil
|
||||
}
|
||||
return fmt.Sprintf("%v", item.value), nil
|
||||
}
|
||||
|
||||
// Del 删除键
|
||||
func Del(ctx context.Context, key string) error {
|
||||
if Enabled && Client != nil {
|
||||
return Client.Del(ctx, key).Err()
|
||||
}
|
||||
memoryStore.Delete(key)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Exists 检查键是否存在
|
||||
func Exists(ctx context.Context, key string) (int64, error) {
|
||||
if Enabled && Client != nil {
|
||||
return Client.Exists(ctx, key).Result()
|
||||
}
|
||||
_, ok := memoryStore.Load(key)
|
||||
if ok {
|
||||
return 1, nil
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// Incr 自增
|
||||
func Incr(ctx context.Context, key string) (int64, error) {
|
||||
if Enabled && Client != nil {
|
||||
return Client.Incr(ctx, key).Result()
|
||||
}
|
||||
// 内存降级(简单实现)
|
||||
v, _ := Get(ctx, key)
|
||||
var n int64
|
||||
fmt.Sscanf(v, "%d", &n)
|
||||
n++
|
||||
Set(ctx, key, n, 0)
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// memoryItem 内存存储项
|
||||
type memoryItem struct {
|
||||
value interface{}
|
||||
expireAt time.Time
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package response
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// 统一响应结构
|
||||
type Resp struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
func OK(c *gin.Context, data interface{}) {
|
||||
c.JSON(http.StatusOK, Resp{Code: 0, Message: "ok", Data: data})
|
||||
}
|
||||
|
||||
func Created(c *gin.Context, data interface{}) {
|
||||
c.JSON(http.StatusCreated, Resp{Code: 0, Message: "created", Data: data})
|
||||
}
|
||||
|
||||
func Fail(c *gin.Context, httpStatus int, msg string) {
|
||||
c.JSON(httpStatus, Resp{Code: httpStatus, Message: msg})
|
||||
}
|
||||
|
||||
func BadRequest(c *gin.Context, msg string) {
|
||||
Fail(c, http.StatusBadRequest, msg)
|
||||
}
|
||||
|
||||
func Unauthorized(c *gin.Context, msg string) {
|
||||
Fail(c, http.StatusUnauthorized, msg)
|
||||
}
|
||||
|
||||
func Forbidden(c *gin.Context, msg string) {
|
||||
Fail(c, http.StatusForbidden, msg)
|
||||
}
|
||||
|
||||
func NotFound(c *gin.Context, msg string) {
|
||||
Fail(c, http.StatusNotFound, msg)
|
||||
}
|
||||
|
||||
func Conflict(c *gin.Context, msg string) {
|
||||
Fail(c, http.StatusConflict, msg)
|
||||
}
|
||||
|
||||
func Internal(c *gin.Context, msg string) {
|
||||
Fail(c, http.StatusInternalServerError, msg)
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package sensitive
|
||||
|
||||
import (
|
||||
"photowall/internal/model"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// DFA 敏感词过滤器
|
||||
type Filter struct {
|
||||
root *trieNode
|
||||
mu sync.RWMutex
|
||||
ready bool
|
||||
}
|
||||
|
||||
type trieNode struct {
|
||||
children map[rune]*trieNode
|
||||
isEnd bool
|
||||
word string
|
||||
}
|
||||
|
||||
var globalFilter = &Filter{root: newNode()}
|
||||
|
||||
func newNode() *trieNode {
|
||||
return &trieNode{children: make(map[rune]*trieNode)}
|
||||
}
|
||||
|
||||
// Init 从数据库加载敏感词
|
||||
func Init(db *gorm.DB) {
|
||||
var words []model.SensitiveWord
|
||||
db.Where("enabled = ?", true).Find(&words)
|
||||
globalFilter.mu.Lock()
|
||||
defer globalFilter.mu.Unlock()
|
||||
globalFilter.root = newNode()
|
||||
for _, w := range words {
|
||||
globalFilter.addWord(w.Word)
|
||||
}
|
||||
globalFilter.ready = true
|
||||
}
|
||||
|
||||
// Reload 重新加载敏感词
|
||||
func Reload(db *gorm.DB) {
|
||||
Init(db)
|
||||
}
|
||||
|
||||
func (f *Filter) addWord(word string) {
|
||||
node := f.root
|
||||
for _, ch := range []rune(word) {
|
||||
if _, ok := node.children[ch]; !ok {
|
||||
node.children[ch] = newNode()
|
||||
}
|
||||
node = node.children[ch]
|
||||
}
|
||||
node.isEnd = true
|
||||
node.word = word
|
||||
}
|
||||
|
||||
// Check 检测文本中是否包含敏感词,返回第一个匹配的词
|
||||
func Check(text string) (bool, string) {
|
||||
if !globalFilter.ready || text == "" {
|
||||
return false, ""
|
||||
}
|
||||
globalFilter.mu.RLock()
|
||||
defer globalFilter.mu.RUnlock()
|
||||
runes := []rune(text)
|
||||
for i := 0; i < len(runes); i++ {
|
||||
node := globalFilter.root
|
||||
for j := i; j < len(runes); j++ {
|
||||
next, ok := node.children[runes[j]]
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
node = next
|
||||
if node.isEnd {
|
||||
return true, node.word
|
||||
}
|
||||
}
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// Replace 替换文本中的敏感词为 ***
|
||||
func Replace(text string) string {
|
||||
if !globalFilter.ready || text == "" {
|
||||
return text
|
||||
}
|
||||
globalFilter.mu.RLock()
|
||||
defer globalFilter.mu.RUnlock()
|
||||
runes := []rune(text)
|
||||
var result strings.Builder
|
||||
i := 0
|
||||
for i < len(runes) {
|
||||
node := globalFilter.root
|
||||
matchLen := 0
|
||||
for j := i; j < len(runes); j++ {
|
||||
next, ok := node.children[runes[j]]
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
node = next
|
||||
if node.isEnd {
|
||||
matchLen = j - i + 1
|
||||
}
|
||||
}
|
||||
if matchLen > 0 {
|
||||
result.WriteString("***")
|
||||
i += matchLen
|
||||
} else {
|
||||
result.WriteRune(runes[i])
|
||||
i++
|
||||
}
|
||||
}
|
||||
return result.String()
|
||||
}
|
||||
|
||||
// CheckAndReplace 检测并过滤,返回是否包含敏感词和过滤后的文本
|
||||
func CheckAndReplace(text string) (bool, string) {
|
||||
found, _ := Check(text)
|
||||
if !found {
|
||||
return false, text
|
||||
}
|
||||
return true, Replace(text)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package sms
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"photowall/internal/config"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 短信验证码封装
|
||||
// 参数未配置时返回 ErrNotConfigured,开发阶段可在日志中输出验证码
|
||||
|
||||
var ErrNotConfigured = errors.New("短信服务未配置,请联系管理员")
|
||||
|
||||
type codeItem struct {
|
||||
code string
|
||||
expireAt time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
store = sync.Map{} // phone -> codeItem
|
||||
)
|
||||
|
||||
// SendCode 发送短信验证码
|
||||
// 开发阶段(未配置短信服务商):验证码输出到日志,方便测试
|
||||
func SendCode(phone string) (string, error) {
|
||||
cfg := config.C
|
||||
code := generateCode()
|
||||
|
||||
if cfg.SMSAccessKey == "" || cfg.SMSSecretKey == "" || cfg.SMSSignName == "" || cfg.SMSTemplateCode == "" {
|
||||
// 未配置短信服务商,开发模式:日志输出验证码
|
||||
fmt.Printf("[SMS-DEV] 手机号 %s 的验证码: %s (5分钟有效)\n", phone, code)
|
||||
store.Store(phone, codeItem{code: code, expireAt: time.Now().Add(5 * time.Minute)})
|
||||
return code, nil
|
||||
}
|
||||
|
||||
// TODO: 接入阿里云/腾讯云短信 SDK
|
||||
// 阿里云:dysmsapi.Client + SendSmsRequest
|
||||
// 腾讯云:sms.Client + SendSms
|
||||
store.Store(phone, codeItem{code: code, expireAt: time.Now().Add(5 * time.Minute)})
|
||||
return code, nil
|
||||
}
|
||||
|
||||
// VerifyCode 校验短信验证码(一次性)
|
||||
func VerifyCode(phone, code string) bool {
|
||||
if phone == "" || code == "" {
|
||||
return false
|
||||
}
|
||||
v, ok := store.Load(phone)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
item := v.(codeItem)
|
||||
store.Delete(phone)
|
||||
if time.Now().After(item.expireAt) {
|
||||
return false
|
||||
}
|
||||
return item.code == code
|
||||
}
|
||||
|
||||
func generateCode() string {
|
||||
b := make([]byte, 6)
|
||||
for i := range b {
|
||||
n, _ := rand.Int(rand.Reader, big.NewInt(10))
|
||||
b[i] = byte('0' + n.Int64())
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
Reference in New Issue
Block a user