diff --git a/go/cmd/server/main.go b/go/cmd/server/main.go index d700878..08e3337 100644 --- a/go/cmd/server/main.go +++ b/go/cmd/server/main.go @@ -37,19 +37,19 @@ func main() { 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) } + + authSvc := service.NewAuthService(database.DB, jm, configSvc) + userSvc := service.NewUserService(database.DB) + regionSvc := service.NewRegionService(database.DB) + schoolSvc := service.NewSchoolService(database.DB) + classSvc := service.NewClassService(database.DB, configSvc) + adminSvc := service.NewAdminService(database.DB) + menuSvc := service.NewMenuService(database.DB) // 初始化默认菜单 if err := menuSvc.InitDefaults(); err != nil { log.Printf("[menu] init defaults failed: %v", err) diff --git a/go/internal/config/config.go b/go/internal/config/config.go index 5de1650..d00450e 100644 --- a/go/internal/config/config.go +++ b/go/internal/config/config.go @@ -5,7 +5,8 @@ import ( "strconv" ) -// Config 全局配置,优先从环境变量读取,提供默认值以便开箱即用。 +// Config 全局基础配置(仅保留服务启动及基础设施参数) +// 业务参数(极验、短信、微信、审核时限等)已统一移入 MySQL 数据库 yz_pw_system_configs 表管理 type Config struct { Port string JWTSecret string @@ -23,24 +24,6 @@ type Config struct { // 平台管理员账号(首次启动自动创建) 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 @@ -53,32 +36,20 @@ 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", ""), + 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", "212.64.112.158"), + DBPort: getEnv("DB_PORT", "3388"), + // 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"), // Redis RedisHost: getEnv("REDIS_HOST", "localhost"), RedisPort: getEnv("REDIS_PORT", "6379"), diff --git a/go/internal/service/auth_service.go b/go/internal/service/auth_service.go index b51424e..174dd8b 100644 --- a/go/internal/service/auth_service.go +++ b/go/internal/service/auth_service.go @@ -4,7 +4,6 @@ import ( "errors" "fmt" "math/rand" - "photowall/internal/config" "photowall/internal/model" "photowall/pkg/captcha" "photowall/pkg/hash" @@ -15,12 +14,13 @@ import ( ) type AuthService struct { - db *gorm.DB - jm *jwt.Manager + db *gorm.DB + jm *jwt.Manager + configSvc *ConfigService } -func NewAuthService(db *gorm.DB, jm *jwt.Manager) *AuthService { - return &AuthService{db: db, jm: jm} +func NewAuthService(db *gorm.DB, jm *jwt.Manager, configSvc *ConfigService) *AuthService { + return &AuthService{db: db, jm: jm, configSvc: configSvc} } // ============ 请求结构 ============ @@ -223,9 +223,12 @@ func (s *AuthService) ResetPassword(req *ResetPasswordReq) error { // ============ 发送短信验证码(预留) ============ func (s *AuthService) SendSmsCode(req *SendSmsReq) (string, error) { - cfg := config.C + var accessKey, secretKey string + if s.configSvc != nil { + _, accessKey, secretKey, _, _ = s.configSvc.GetSmsConfig() + } // 参数未配置时返回提示(开发阶段直接返回验证码) - if cfg.SMSAccessKey == "" || cfg.SMSSecretKey == "" { + if accessKey == "" || secretKey == "" { code := fmt.Sprintf("%06d", rand.Intn(1000000)) sms := &model.SmsCode{ Phone: req.Phone, @@ -256,8 +259,11 @@ type WechatLoginReq struct { } func (s *AuthService) WechatLogin(req *WechatLoginReq) (*LoginResp, error) { - cfg := config.C - if cfg.WechatAppID == "" || cfg.WechatAppSecret == "" { + var appID, appSecret string + if s.configSvc != nil { + appID, appSecret, _ = s.configSvc.GetWechatConfig() + } + if appID == "" || appSecret == "" { return nil, errors.New("微信登录未配置,请联系管理员") } // TODO: 接入微信开放平台API diff --git a/go/internal/service/class_service.go b/go/internal/service/class_service.go index 27ed0b3..8c9b3e8 100644 --- a/go/internal/service/class_service.go +++ b/go/internal/service/class_service.go @@ -3,7 +3,6 @@ package service import ( "errors" "fmt" - "photowall/internal/config" "photowall/internal/model" "photowall/pkg/sensitive" "strings" @@ -14,11 +13,12 @@ import ( ) type ClassService struct { - db *gorm.DB + db *gorm.DB + configSvc *ConfigService } -func NewClassService(db *gorm.DB) *ClassService { - return &ClassService{db: db} +func NewClassService(db *gorm.DB, configSvc *ConfigService) *ClassService { + return &ClassService{db: db, configSvc: configSvc} } // CreateClassReq 创建班级请求 @@ -104,6 +104,10 @@ func (s *ClassService) Create(userID uint, req *CreateClassReq) (*model.Class, e teachers := cleanNames(req.TeacherNames) // 6. 构建班级 + graceDays := 30 + if s.configSvc != nil { + graceDays = s.configSvc.GetInt("audit_grace_days", 30) + } class := &model.Class{ SchoolID: req.SchoolID, CollegeID: req.CollegeID, @@ -113,7 +117,7 @@ func (s *ClassService) Create(userID uint, req *CreateClassReq) (*model.Class, e GraduateYear: req.GraduateYear, AdminUserID: userID, GraduationPhoto: req.GraduationPhoto, - AuditDeadline: time.Now().AddDate(0, 0, config.C.AuditGraceDays), + AuditDeadline: time.Now().AddDate(0, 0, graceDays), } class.SetStudentNames(students) class.SetTeacherNames(teachers) diff --git a/go/internal/service/config_service.go b/go/internal/service/config_service.go index 536345f..0b00933 100644 --- a/go/internal/service/config_service.go +++ b/go/internal/service/config_service.go @@ -1,17 +1,26 @@ package service import ( + "encoding/json" + "errors" "photowall/internal/model" + "strconv" + "sync" "gorm.io/gorm" ) type ConfigService struct { - db *gorm.DB + db *gorm.DB + cache map[string]string + mu sync.RWMutex } func NewConfigService(db *gorm.DB) *ConfigService { - return &ConfigService{db: db} + return &ConfigService{ + db: db, + cache: make(map[string]string), + } } // defaultConfigs 所有可配置项及说明 @@ -23,8 +32,8 @@ var defaultConfigs = []model.SystemConfig{ {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: "geetest_id", Value: "662e68b7ce24be211fa97a53975c5d96", Label: "极验 ID", Description: "极验行为验证的Captcha ID,用于防机器人和暴力破解", Category: "geetest", Placeholder: "如:64a####################", DocLink: "https://www.geetest.com 注册后在应用管理获取", Sort: 1}, + {Key: "geetest_key", Value: "2789075bc2b25a04c0f7c5420bfa3d6f", 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}, @@ -43,17 +52,29 @@ var defaultConfigs = []model.SystemConfig{ {Key: "max_file_size_mb", Value: "10", Label: "单文件大小上限(MB)", Description: "上传图片的最大大小限制", Category: "storage", Placeholder: "10", Sort: 2}, } -// InitDefaults 初始化默认配置(不存在则创建) +// 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 { + var existing model.SystemConfig + err := s.db.Where("`key` = ?", c.Key).First(&existing).Error + if errors.Is(err, gorm.ErrRecordNotFound) { if err := s.db.Create(&c).Error; err != nil { return err } + } else if err == nil && existing.Value == "" && c.Value != "" { + s.db.Model(&existing).Update("value", c.Value) } } + + // 预热内存缓存 + var list []model.SystemConfig + if err := s.db.Find(&list).Error; err == nil { + s.mu.Lock() + for _, item := range list { + s.cache[item.Key] = item.Value + } + s.mu.Unlock() + } return nil } @@ -71,14 +92,49 @@ func (s *ConfigService) GetByCategory(category string) ([]model.SystemConfig, er return list, err } -// UpdateReq 更新配置请求 +// UpdateConfigReq 更新配置请求(支持 map 与 key-value 列表格式) type UpdateConfigReq struct { - Configs map[string]string `json:"configs" binding:"required"` + Configs map[string]string `json:"configs"` +} + +func (r *UpdateConfigReq) UnmarshalJSON(data []byte) error { + // 格式1: {"configs": {"k1": "v1", "k2": "v2"}} + var auxMap struct { + Configs map[string]string `json:"configs"` + } + if err := json.Unmarshal(data, &auxMap); err == nil && auxMap.Configs != nil { + r.Configs = auxMap.Configs + return nil + } + + // 格式2: {"configs": [{"key": "k1", "value": "v1"}]} + var auxList struct { + Configs []struct { + Key string `json:"key"` + Value string `json:"value"` + } `json:"configs"` + } + if err := json.Unmarshal(data, &auxList); err == nil && auxList.Configs != nil { + r.Configs = make(map[string]string, len(auxList.Configs)) + for _, item := range auxList.Configs { + r.Configs[item.Key] = item.Value + } + return nil + } + + // 格式3: 直接传入 map: {"k1": "v1"} + var directMap map[string]string + if err := json.Unmarshal(data, &directMap); err == nil { + r.Configs = directMap + return nil + } + + return errors.New("invalid configs format") } // Update 批量更新配置 func (s *ConfigService) Update(req *UpdateConfigReq) error { - return s.db.Transaction(func(tx *gorm.DB) error { + err := 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 @@ -86,13 +142,66 @@ func (s *ConfigService) Update(req *UpdateConfigReq) error { } return nil }) + if err == nil { + s.mu.Lock() + for key, value := range req.Configs { + s.cache[key] = value + } + s.mu.Unlock() + } + return err } -// GetValue 获取单个配置值 +// GetValue 获取单个配置值(优先走内存缓存) func (s *ConfigService) GetValue(key string) string { + s.mu.RLock() + val, ok := s.cache[key] + s.mu.RUnlock() + if ok { + return val + } + var c model.SystemConfig if err := s.db.Where("`key` = ?", key).First(&c).Error; err != nil { return "" } + + s.mu.Lock() + s.cache[key] = c.Value + s.mu.Unlock() + return c.Value } + +// GetInt 获取整数配置值,解析失败或不存在则返回 def +func (s *ConfigService) GetInt(key string, def int) int { + val := s.GetValue(key) + if val == "" { + return def + } + if n, err := strconv.Atoi(val); err == nil { + return n + } + return def +} + +// GetGeetestConfig 获取极验验证配置 +func (s *ConfigService) GetGeetestConfig() (id, key string) { + return s.GetValue("geetest_id"), s.GetValue("geetest_key") +} + +// GetSmsConfig 获取短信配置 +func (s *ConfigService) GetSmsConfig() (provider, accessKey, secretKey, signName, templateCode string) { + return s.GetValue("sms_provider"), + s.GetValue("sms_access_key"), + s.GetValue("sms_secret_key"), + s.GetValue("sms_sign_name"), + s.GetValue("sms_template_code") +} + +// GetWechatConfig 获取微信登录配置 +func (s *ConfigService) GetWechatConfig() (appID, appSecret, redirectURI string) { + return s.GetValue("wechat_app_id"), + s.GetValue("wechat_app_secret"), + s.GetValue("wechat_redirect_uri") +} diff --git a/go/pkg/captcha/captcha.go b/go/pkg/captcha/captcha.go index 1191018..cb9dac5 100644 --- a/go/pkg/captcha/captcha.go +++ b/go/pkg/captcha/captcha.go @@ -1,6 +1,7 @@ package captcha import ( + "bytes" "context" "crypto/rand" "encoding/base64" @@ -69,20 +70,23 @@ func drawImage(code string) image.Image { 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++ { + for i := 0; i < 3; 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++ { + for i := 0; i < 25; 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()) } + // 字符居中绘制:字符高 21px (7*3),画布高 40px,垂直居中起始 y 约 9px;4 字符总宽 90px,水平居中起始 x 为 15px for i, ch := range code { - drawChar(img, 15+i*25, 28, string(ch), randomDarkColor()) + offsetY, _ := rand.Int(rand.Reader, big.NewInt(3)) + charY := 8 + int(offsetY.Int64()) // 8..10px,垂直完美居中 + drawChar(img, 15+i*25, charY, string(ch), randomDarkColor()) } return img } @@ -134,7 +138,7 @@ func drawChar(img *image.RGBA, x, y int, ch string, c color.Color) { 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) + img.Set(x+col*scale+dx, y+row*scale+dy, c) } } } @@ -192,7 +196,9 @@ func randomDarkColor() color.RGBA { } func encodeToBase64(img image.Image) string { - var buf strings.Builder - png.Encode(base64.NewEncoder(base64.StdEncoding, &buf), img) + var buf bytes.Buffer + enc := base64.NewEncoder(base64.StdEncoding, &buf) + _ = png.Encode(enc, img) + _ = enc.Close() return buf.String() } diff --git a/go/pkg/captcha/captcha_test.go b/go/pkg/captcha/captcha_test.go new file mode 100644 index 0000000..4a6cfe3 --- /dev/null +++ b/go/pkg/captcha/captcha_test.go @@ -0,0 +1,33 @@ +package captcha + +import ( + "bytes" + "encoding/base64" + "image/png" + "strings" + "testing" +) + +func TestDrawImageValidPNG(t *testing.T) { + img := drawImage("ABCD") + b64 := encodeToBase64(img) + data, err := base64.StdEncoding.DecodeString(b64) + if err != nil { + t.Fatalf("base64 decode failed: %v", err) + } + + decoded, err := png.Decode(bytes.NewReader(data)) + if err != nil { + t.Fatalf("png decode failed: %v", err) + } + + bounds := decoded.Bounds() + if bounds.Dx() != 120 || bounds.Dy() != 40 { + t.Fatalf("expected 120x40, got %dx%d", bounds.Dx(), bounds.Dy()) + } + + // Verify all characters in chars set can be drawn without panic + for _, ch := range chars { + _ = drawImage(strings.Repeat(string(ch), 4)) + } +} diff --git a/go/pkg/geetest/geetest.go b/go/pkg/geetest/geetest.go index 2c0383e..14a79a7 100644 --- a/go/pkg/geetest/geetest.go +++ b/go/pkg/geetest/geetest.go @@ -2,7 +2,6 @@ package geetest import ( "errors" - "photowall/internal/config" ) // 极验行为验证封装 @@ -10,14 +9,18 @@ import ( var ErrNotConfigured = errors.New("极验未配置,请联系管理员") +type Config struct { + CaptchaID string + CaptchaKey string +} + // 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 == "" { +func Validate(cfg Config, lotNumber, captchaOutput, passToken, genTime string) error { + if cfg.CaptchaID == "" || cfg.CaptchaKey == "" { return ErrNotConfigured } // TODO: 接入极验官方 SDK 进行二次验证 diff --git a/go/pkg/redis/redis.go b/go/pkg/redis/redis.go index c91493c..88d9062 100644 --- a/go/pkg/redis/redis.go +++ b/go/pkg/redis/redis.go @@ -24,26 +24,34 @@ type Config struct { DB int } -// Init 初始化 Redis 连接,失败则降级为内存模式 +// Init 初始化 Redis 连接,未开启或连接失败则降级为内存模式 func Init(cfg Config) { + if cfg.Host == "" { + log.Printf("[redis] Redis 未配置,已降级为内存模式运行") + Enabled = false + Client = nil + return + } + Client = redis.NewClient(&redis.Options{ - Addr: fmt.Sprintf("%s:%s", cfg.Host, cfg.Port), - Password: cfg.Password, - DB: cfg.DB, + Addr: fmt.Sprintf("%s:%s", cfg.Host, cfg.Port), + Password: cfg.Password, + DB: cfg.DB, + DialTimeout: 2 * time.Second, }) - ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() if err := Client.Ping(ctx).Err(); err != nil { - log.Printf("[redis] connection failed (%v), fallback to memory mode", err) + log.Printf("[redis] Redis 没开或连接失败 (%v),已降级为内存模式运行", err) Enabled = false Client = nil return } Enabled = true - log.Printf("[redis] connected to %s:%s (db=%d)", cfg.Host, cfg.Port, cfg.DB) + log.Printf("[redis] Redis 连接成功: %s:%s (db=%d)", cfg.Host, cfg.Port, cfg.DB) } // Set 设置键值(带过期时间,0=不过期) diff --git a/go/pkg/sms/sms.go b/go/pkg/sms/sms.go index 0e6a21a..a75889a 100644 --- a/go/pkg/sms/sms.go +++ b/go/pkg/sms/sms.go @@ -5,7 +5,6 @@ import ( "errors" "fmt" "math/big" - "photowall/internal/config" "sync" "time" ) @@ -15,6 +14,14 @@ import ( var ErrNotConfigured = errors.New("短信服务未配置,请联系管理员") +type Config struct { + Provider string + AccessKey string + SecretKey string + SignName string + TemplateCode string +} + type codeItem struct { code string expireAt time.Time @@ -26,11 +33,10 @@ var ( // SendCode 发送短信验证码 // 开发阶段(未配置短信服务商):验证码输出到日志,方便测试 -func SendCode(phone string) (string, error) { - cfg := config.C +func SendCode(cfg Config, phone string) (string, error) { code := generateCode() - if cfg.SMSAccessKey == "" || cfg.SMSSecretKey == "" || cfg.SMSSignName == "" || cfg.SMSTemplateCode == "" { + if cfg.AccessKey == "" || cfg.SecretKey == "" || cfg.SignName == "" || cfg.TemplateCode == "" { // 未配置短信服务商,开发模式:日志输出验证码 fmt.Printf("[SMS-DEV] 手机号 %s 的验证码: %s (5分钟有效)\n", phone, code) store.Store(phone, codeItem{code: code, expireAt: time.Now().Add(5 * time.Minute)}) diff --git a/vue/src/assets/style.less b/vue/src/assets/style.less index 2cb25f3..f3624e1 100644 --- a/vue/src/assets/style.less +++ b/vue/src/assets/style.less @@ -317,10 +317,14 @@ a { // 验证码图片 .captcha-img { + width: 120px; height: 40px; + flex-shrink: 0; border-radius: 4px; cursor: pointer; border: 1px solid @border-color; + box-sizing: border-box; + display: block; } // ============ H5 响应式 ============ diff --git a/vue/src/router/index.js b/vue/src/router/index.js index f4520fd..3e24c82 100644 --- a/vue/src/router/index.js +++ b/vue/src/router/index.js @@ -1,11 +1,11 @@ import { createRouter, createWebHistory } from 'vue-router' import { useAuth } from '../composables/useAuth' -// 静态路由(不需要登录的页面) +// 静态路由(不需要登录的公开页面) +// 注意:千万不要在 staticRoutes 中定义没有目标组件的通配重定向(如 redirect: '/'),否则在动态路由未挂载时会引发递归死循环 const staticRoutes = [ { path: '/login', name: 'Login', component: () => import('../views/auth/login/index.vue'), meta: { public: true } }, - { path: '/forgot-password', name: 'ForgotPassword', component: () => import('../views/auth/forgot/index.vue'), meta: { public: true } }, - { path: '/:pathMatch(.*)*', redirect: '/' } + { path: '/forgot-password', name: 'ForgotPassword', component: () => import('../views/auth/forgot/index.vue'), meta: { public: true } } ] const router = createRouter({ @@ -21,20 +21,18 @@ const componentMap = import.meta.glob('../views/**/index.vue') // 根据菜单数据动态注册路由 export function loadDynamicRoutes(menus) { - // 先移除旧的动态路由 - if (dynamicRoutesLoaded) { - router.getRoutes().forEach(r => { - if (!staticRoutes.find(s => s.name === r.name)) { - router.removeRoute(r.name) - } - }) - } + // 先重置旧的动态路由 + resetDynamicRoutes() function registerMenu(menu) { - if (menu.component && menu.component !== '') { - const compPath = `../views/${menu.component}.vue` + if (menu.component && menu.component.trim() !== '') { + const cleanComp = menu.component.replace(/\.vue$/, '').trim() + const compPath = `../views/${cleanComp}.vue` const component = componentMap[compPath] if (component) { + if (router.hasRoute(menu.name)) { + router.removeRoute(menu.name) + } router.addRoute({ path: menu.path, name: menu.name, @@ -48,6 +46,21 @@ export function loadDynamicRoutes(menus) { } }) } + } else if (menu.redirect && menu.redirect.trim() !== '') { + if (router.hasRoute(menu.name)) { + router.removeRoute(menu.name) + } + router.addRoute({ + path: menu.path, + name: menu.name, + redirect: menu.redirect, + meta: { + title: menu.title, + icon: menu.icon, + requireAuth: menu.require_auth, + requireAdmin: menu.require_admin + } + }) } if (menu.children && menu.children.length > 0) { menu.children.forEach(registerMenu) @@ -55,14 +68,26 @@ export function loadDynamicRoutes(menus) { } menus.forEach(registerMenu) + + // 动态路由挂载完毕后,在末尾注册通配重定向到首页 + if (router.hasRoute('NotFound')) { + router.removeRoute('NotFound') + } + router.addRoute({ + path: '/:pathMatch(.*)*', + name: 'NotFound', + redirect: '/' + }) + dynamicRoutesLoaded = true } // 重置动态路由(登出时调用) export function resetDynamicRoutes() { dynamicRoutesLoaded = false + const staticNames = staticRoutes.map(s => s.name) router.getRoutes().forEach(r => { - if (!staticRoutes.find(s => s.name === r.name)) { + if (r.name && !staticNames.includes(r.name)) { router.removeRoute(r.name) } }) @@ -76,7 +101,23 @@ router.beforeEach(async (to, from, next) => { if (isLogin.value && expireAt && Date.now() > parseInt(expireAt)) { logout() resetDynamicRoutes() - next({ path: '/login', query: { redirect: to.fullPath } }) + next({ path: '/login', query: to.path !== '/' ? { redirect: to.fullPath } : undefined }) + return + } + + // 已登录状态下访问登录页,直接跳转首页 + if (to.path === '/login' && isLogin.value) { + if (!dynamicRoutesLoaded) { + try { + await loadMenus() + } catch (e) { + logout() + resetDynamicRoutes() + next() + return + } + } + next('/') return } @@ -88,7 +129,7 @@ router.beforeEach(async (to, from, next) => { // 未登录跳登录 if (!isLogin.value) { - next({ path: '/login', query: { redirect: to.fullPath } }) + next({ path: '/login', query: to.path !== '/' ? { redirect: to.fullPath } : undefined }) return } diff --git a/vue/src/views/auth/login/index.vue b/vue/src/views/auth/login/index.vue index aad67cd..5e144db 100644 --- a/vue/src/views/auth/login/index.vue +++ b/vue/src/views/auth/login/index.vue @@ -16,7 +16,7 @@ -
+
@@ -42,7 +42,7 @@ -
+