## 新增功能 ### 消息通知中心 (全新模块) - 新增 MessageController / NotificationService / NotificationRepo - SSR 消息页面 (/messages):左侧边栏 + 右侧卡片列表,noindex 元标签 - 通知能力:列表分页、单条标为已读、一键全部标为已读 - 未读数角标 (1/66/99+):侧边栏 + 导航栏铃铛图标 - 导航栏轮询 /api/messages/unread 每 60 秒刷新未读数 - 审核通过/驳回时自动 fire-and-forget 推送通知 ### 密码修改 - ChangePassword:验证当前密码 → 新密码强度校验(8位+字母+数字) → 哈希更新 - 修改后递增 token_version 强制所有设备退登 ### 账号自助注销 - DeleteAccount:验证密码 → 设置 deleted 状态 → 记录原因 → 吊销 JWT - 注销后登录二次确认:Login 检测 deleted → 返回 confirm_restore - ConfirmRestore 恢复账号,重新签发 token - 注销页文案:"账号将在 7 天后正式注销,期间可随时重新登录恢复" ### IP 审计记录 - 注册时记录 RegIP,登录时记录 LastLoginIP + LastLoginAt - clientIP() 支持 X-Forwarded-For / X-Real-IP 反向代理 ### 安全加固 - Login 防时序攻击:用户不存在时仍执行完整 bcrypt 比对 - FindByEmail 改用 Unscoped() 覆盖软删除用户 - 站长 (owner) 不允许自主注销,避免权限体系死锁 ## Bug 修复 1. 注销按钮不触发:JS IIFE 中 profile 代码 return 阻塞了 account 标签页处理器注册 → 拆分为两层 IIFE,profile 放在内层 2. label 缺少 for 属性导致控制台警告 → 全部补充 for 属性 3. 注销后未自动退登:DeleteAccount 只设状态未吊销 JWT → 末尾加 InvalidateSessions 4. 退登后重定向 500 panic:authenticateToken 中 err||versionMismatch 合并判断 在 err=nil 但版本不匹配时返回 (nil,nil) → 拆为两个独立判断 injectUserContext 增加 claims==nil / 类型断言空安全守卫 5. 注销后登录直接提示"登录成功":FindByEmail 默认 scope 排除软删除记录 → 改用 Unscoped() ## 文件变更 - 新建 17 个文件 (消息/审核/站点设置完整模块) - 修改 25 个文件 (认证/设置/中间件/前端) - 统计:+3229 / -161,42 files changed
142 lines
3.8 KiB
Go
142 lines
3.8 KiB
Go
package config
|
||
|
||
import (
|
||
"bufio"
|
||
"log"
|
||
"os"
|
||
"strings"
|
||
|
||
"github.com/spf13/viper"
|
||
)
|
||
|
||
// Config 应用总配置
|
||
type Config struct {
|
||
Server ServerConfig `mapstructure:"server"`
|
||
Database DatabaseConfig `mapstructure:"database"`
|
||
JWT JWTConfig `mapstructure:"jwt"`
|
||
Bcrypt BcryptConfig `mapstructure:"bcrypt"`
|
||
Roles RolesConfig `mapstructure:"roles"`
|
||
Audit AuditConfig `mapstructure:"audit"`
|
||
}
|
||
|
||
// AuditConfig 审核系统配置(config.yaml 提供默认值,站点设置可运行时覆盖)
|
||
type AuditConfig struct {
|
||
Enabled bool `mapstructure:"enabled"`
|
||
UsernameAudit bool `mapstructure:"username_audit"`
|
||
AvatarAudit bool `mapstructure:"avatar_audit"`
|
||
BioAudit bool `mapstructure:"bio_audit"`
|
||
}
|
||
|
||
type ServerConfig struct {
|
||
Port string `mapstructure:"port"`
|
||
Mode string `mapstructure:"mode"`
|
||
}
|
||
|
||
type DatabaseConfig struct {
|
||
Host string `mapstructure:"host"`
|
||
Port string `mapstructure:"port"`
|
||
User string `mapstructure:"user"`
|
||
Password string `mapstructure:"password"`
|
||
DBName string `mapstructure:"dbname"`
|
||
SSLMode string `mapstructure:"sslmode"`
|
||
}
|
||
|
||
type JWTConfig struct {
|
||
Secret string `mapstructure:"secret"`
|
||
AccessExpire int `mapstructure:"access_expire"` // 分钟
|
||
RefreshExpire int `mapstructure:"refresh_expire"` // 小时
|
||
RememberExpire int `mapstructure:"remember_expire"` // 小时
|
||
}
|
||
|
||
type BcryptConfig struct {
|
||
Cost int `mapstructure:"cost"`
|
||
}
|
||
|
||
// RolesConfig 角色配置(可扩展,新增角色只需改 config.yaml)
|
||
type RolesConfig struct {
|
||
Levels map[string]int `mapstructure:"levels"`
|
||
Names map[string]string `mapstructure:"names"`
|
||
Permissions RolesPermissions `mapstructure:"permissions"`
|
||
}
|
||
|
||
// RolesPermissions 角色间操作权限
|
||
type RolesPermissions struct {
|
||
OperableRoles map[string][]string `mapstructure:"operable_roles"`
|
||
}
|
||
|
||
// 全局配置实例(初始化后只读)
|
||
var App *Config
|
||
|
||
// Load 加载配置:config.yaml → .env 覆盖
|
||
func Load(configPath string) *Config {
|
||
// 1. 从 .env 加载环境变量(优先于 config.yaml)
|
||
loadEnvFile(".env")
|
||
|
||
v := viper.New()
|
||
|
||
// 2. 读取 config.yaml
|
||
v.SetConfigFile(configPath)
|
||
v.SetConfigType("yaml")
|
||
if err := v.ReadInConfig(); err != nil {
|
||
log.Fatalf("读取配置文件失败: %v", err)
|
||
}
|
||
|
||
// 3. 环境变量覆盖(DATABASE_PASSWORD → database.password)
|
||
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
|
||
v.AutomaticEnv()
|
||
bindEnvOverride(v)
|
||
|
||
c := &Config{}
|
||
if err := v.Unmarshal(c); err != nil {
|
||
log.Fatalf("解析配置失败: %v", err)
|
||
}
|
||
|
||
log.Printf("[Config] JWT.SecretLen=%d JWT.AccessExpire=%d min JWT.RefreshExpire=%d h JWT.RememberExpire=%d h | Server.Mode=%s",
|
||
len(c.JWT.Secret), c.JWT.AccessExpire, c.JWT.RefreshExpire, c.JWT.RememberExpire, c.Server.Mode)
|
||
|
||
App = c
|
||
return c
|
||
}
|
||
|
||
// loadEnvFile 读取 .env 文件并设置环境变量(仅当环境变量未设置时)
|
||
func loadEnvFile(path string) {
|
||
f, err := os.Open(path)
|
||
if err != nil {
|
||
return // .env 不存在,跳过
|
||
}
|
||
defer f.Close()
|
||
|
||
scanner := bufio.NewScanner(f)
|
||
for scanner.Scan() {
|
||
line := strings.TrimSpace(scanner.Text())
|
||
if line == "" || strings.HasPrefix(line, "#") {
|
||
continue
|
||
}
|
||
parts := strings.SplitN(line, "=", 2)
|
||
if len(parts) != 2 {
|
||
continue
|
||
}
|
||
key := strings.TrimSpace(parts[0])
|
||
val := strings.TrimSpace(parts[1])
|
||
if os.Getenv(key) == "" {
|
||
os.Setenv(key, val)
|
||
}
|
||
}
|
||
}
|
||
|
||
// bindEnvOverride 将环境变量映射到 config 的嵌套键
|
||
func bindEnvOverride(v *viper.Viper) {
|
||
_ = v.BindEnv("database.password", "DATABASE_PASSWORD")
|
||
_ = v.BindEnv("jwt.secret", "JWT_SECRET")
|
||
}
|
||
|
||
// DSN 返回 PostgreSQL 连接字符串
|
||
func (d DatabaseConfig) DSN() string {
|
||
return "host=" + d.Host +
|
||
" port=" + d.Port +
|
||
" user=" + d.User +
|
||
" password=" + d.Password +
|
||
" dbname=" + d.DBName +
|
||
" sslmode=" + d.SSLMode
|
||
}
|