diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index ab372b4..c27ea03 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -4,7 +4,7 @@ name: Deploy # 触发后: # 1. 校验 Secrets # 2. SSH 到服务器 → 备份 .env → git reset --hard -# 3. 用 deploy/docker-compose.prod.yml 构建 + 迁移 + 重启 +# 3. 用 deploy/docker-compose.prod.yml 构建 + 迁移 + seed + 重启 # 4. 通过 docker exec 做容器内健康检查 # # 必须在仓库 Settings → Secrets 配置以下 Secrets: @@ -17,7 +17,10 @@ name: Deploy # 服务器侧首次部署需手动: # cd $DEPLOY_PATH # cp .env.example .env -# vim .env # 必须填 JWT_SECRET / POSTGRES_PASSWORD / RABBITMQ_PASSWORD 等 +# vim .env # 必须填 JWT_SECRET / POSTGRES_PASSWORD / RABBITMQ_PASSWORD +# # 以及 SEED_ADMIN_EMAIL / SEED_ADMIN_PASSWORD(≥8 位) +# # 后者用于 ./seed 创建/重置管理员密码 —— 部署流水线会自动跑 seed, +# # 所以务必与想登录用的账号保持一致;密码变更 = 改 .env + 重新部署。 # chmod 600 .env on: @@ -136,6 +139,22 @@ jobs: exit 1 fi + echo ">>> 创建/重置管理员(依赖 .env 中的 SEED_ADMIN_EMAIL / SEED_ADMIN_PASSWORD)" + # 缺失任一变量则跳过:保留"手工管理管理员"的部署模式不被强制覆盖。 + # 已存在同名账号时 ./seed 仅重置密码 + bump token_version,旧 JWT 立即失效。 + if [ -n "${SEED_ADMIN_EMAIL:-}" ] && [ -n "${SEED_ADMIN_PASSWORD:-}" ]; then + if $COMPOSE run --rm backend /app/seed; then + echo "✅ 管理员账号已同步(创建 / 密码重置 / admin 角色补绑)" + else + echo "::error::管理员种子失败,请看下方日志" + $COMPOSE logs --tail=80 backend + exit 1 + fi + else + echo "⚠️ SEED_ADMIN_EMAIL / SEED_ADMIN_PASSWORD 未在 .env 中设置,跳过 ./seed。" + echo " 若 /admin/login 返回\"用户名或密码错误\",请在 .env 中填好这两个变量后重新部署。" + fi + echo ">>> 启动 backend / frontend / nginx(强制用新镜像)" $COMPOSE up -d --no-deps --force-recreate --remove-orphans backend frontend nginx diff --git a/backend/cmd/seed/main.go b/backend/cmd/seed/main.go index c53d054..c6b2b8b 100644 --- a/backend/cmd/seed/main.go +++ b/backend/cmd/seed/main.go @@ -1,4 +1,4 @@ -// Package main 实现 seed 命令:首次部署后创建管理员账号。 +// Package main 实现 seed 命令:创建 / 重置管理员账号。 // // 使用方式: // @@ -10,12 +10,16 @@ // // SEED_ADMIN_EMAIL=... SEED_ADMIN_PASSWORD=... ./bin/seed // -// 该命令幂等: -// - 若 tb_user_auth 中已存在任意账号,则跳过创建,只打印日志 -// - 若表为空,则基于环境变量创建 UserInfo + UserAuth + UserRole(admin) +// 该命令幂等,且支持密码覆盖: +// - 若 tb_user_auth 中已存在 email 对应的账号,则仅重置该账号的密码(re-hash)、 +// 保证绑定 admin 角色(role_id=1)、并 bump token_version 让旧 JWT 失效, +// 其余字段不动。重复执行可作为"找回管理员密码"的逃生口。 +// - 若 email 不存在,则基于环境变量创建 UserInfo + UserAuth + UserRole(admin)。 +// - 若 email 不存在但 tb_user_auth 已有其他账号,也会照常创建(不依赖"空表才创建")。 package main import ( + "errors" "log" "os" "time" @@ -31,6 +35,9 @@ import ( const minPasswordLen = 8 +// adminRoleID 由 SeedRoles 保证存在的内置管理员角色 ID。 +const adminRoleID = 1 + func main() { email := os.Getenv("SEED_ADMIN_EMAIL") password := os.Getenv("SEED_ADMIN_PASSWORD") @@ -64,56 +71,97 @@ func main() { model.SeedRoles(db) model.SeedPages(db) - if err := createAdmin(db, email, password); err != nil { - log.Fatalf("创建管理员失败: %v", err) + if err := upsertAdmin(db, email, password); err != nil { + log.Fatalf("创建/重置管理员失败: %v", err) } } -// createAdmin 在 tb_user_auth 为空时创建管理员账号。 -// 若已存在任何认证账号,则跳过(避免误覆盖)。 -func createAdmin(db *gorm.DB, email, password string) error { - var count int64 - if err := db.Model(&model.UserAuth{}).Count(&count).Error; err != nil { - return err - } - if count > 0 { - log.Printf("检测到 tb_user_auth 已存在 %d 条记录,跳过管理员创建", count) - return nil - } - +// upsertAdmin 创建或重置管理员账号。 +// +// 行为: +// - email 不存在于 tb_user_auth → 创建 UserInfo + UserAuth + UserRole(admin)。 +// - email 已存在 → 仅重置密码(re-hash),保证 admin 角色已绑定,并 bump token_version +// 让旧 JWT 立刻失效。其他字段(昵称、头像、last_login_time 等)保持不变。 +// +// 该函数不承担迁移职责,调用方应先执行 model.AutoMigrate / model.Seed。 +func upsertAdmin(db *gorm.DB, email, password string) error { hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { return err } now := time.Now() - info := model.UserInfo{ - BaseModel: model.BaseModel{CreateTime: now}, - Email: email, - Nickname: "管理员", - Avatar: "https://static.talkxj.com/avatar/user.png", - } - if err := db.Create(&info).Error; err != nil { - return err - } - auth := model.UserAuth{ - BaseModel: model.BaseModel{CreateTime: now}, - UserInfoID: info.ID, - Username: email, - Password: string(hashedPassword), - LoginType: 1, - } - if err := db.Create(&auth).Error; err != nil { + // 1. 邮箱对应的账号是否存在(username 与 email 字段都查询,防止两边漂移) + var existing model.UserAuth + err = db.Where("username = ?", email).First(&existing).Error + switch { + case err == nil: + // 已存在 → 重置密码 + bump token_version 保证旧 JWT 失效 + if uerr := db.Model(&existing).Updates(map[string]interface{}{ + "password": string(hashedPassword), + "token_version": gorm.Expr("token_version + 1"), + }).Error; uerr != nil { + return uerr + } + // 保证 admin 角色绑定(幂等) + if rerr := ensureAdminRole(db, existing.ID); rerr != nil { + return rerr + } + log.Printf("管理员密码已重置:username=%s, user_auth_id=%d(其他字段未改动)", email, existing.ID) + return nil + + case errors.Is(err, gorm.ErrRecordNotFound): + // 不存在 → 走完整创建流程 + default: return err } - // 绑定 admin 角色(ID=1 由 SeedRoles 保证) - userRole := model.UserRole{UserID: auth.ID, RoleID: 1} - if err := db.Create(&userRole).Error; err != nil { + return db.Transaction(func(tx *gorm.DB) error { + info := model.UserInfo{ + BaseModel: model.BaseModel{CreateTime: now}, + Email: email, + Nickname: "管理员", + Avatar: "https://static.talkxj.com/avatar/user.png", + } + if err := tx.Create(&info).Error; err != nil { + return err + } + + auth := model.UserAuth{ + BaseModel: model.BaseModel{CreateTime: now}, + UserInfoID: info.ID, + Username: email, + Password: string(hashedPassword), + LoginType: 1, + } + if err := tx.Create(&auth).Error; err != nil { + return err + } + + role := model.UserRole{UserID: auth.ID, RoleID: adminRoleID} + if err := tx.Create(&role).Error; err != nil { + return err + } + + log.Printf("管理员账号创建成功:username=%s, user_info_id=%d, user_auth_id=%d", email, info.ID, auth.ID) + return nil + }) +} + +// ensureAdminRole 幂等地绑定 admin 角色。 +// +// 若已绑定任意 admin 角色,则跳过(不强制独占,避免误删运维手工调整的多角色配置)。 +// 若未绑定,则插入 role_id=adminRoleID 的关联。 +func ensureAdminRole(db *gorm.DB, userAuthID int) error { + var count int64 + if err := db.Model(&model.UserRole{}). + Where("user_id = ? AND role_id = ?", userAuthID, adminRoleID). + Count(&count).Error; err != nil { return err } - - log.Printf("管理员账号创建成功:username=%s, user_info_id=%d, user_auth_id=%d", email, info.ID, auth.ID) - return nil + if count > 0 { + return nil + } + return db.Create(&model.UserRole{UserID: userAuthID, RoleID: adminRoleID}).Error } diff --git a/backend/internal/config/database.go b/backend/internal/config/database.go index a240857..8de405b 100644 --- a/backend/internal/config/database.go +++ b/backend/internal/config/database.go @@ -48,10 +48,36 @@ func InitDatabase() { log.Println("AUTO_MIGRATE_ON_STARTUP=false,跳过启动期迁移;请通过 cmd/migrate 执行") } + // 检查是否已存在管理员账号;若缺失,打印一行醒目提示引导运维执行 ./seed。 + // 这是登录页 401 "用户名或密码错误" 时最常见的盲区: + // 只跑过 migrate 而忘了 ./seed(或 seed 被旧的"表非空就跳过"逻辑静默跳过)。 + warnIfNoAdmin(db) + DB = db log.Println("数据库初始化完成") } +// warnIfNoAdmin 当 tb_user_role 中没有任何 role_id=1(管理员)绑定时,打印醒目的 +// 单行警告。仅做提示,不阻断启动 —— 因为: +// - 生产环境可能在等运维手工建管理员; +// - 同一进程可能被多副本部署,其中一台做这一步检查即可。 +func warnIfNoAdmin(db *gorm.DB) { + if db == nil { + return + } + var n int64 + if err := db.Model(&model.UserRole{}).Where("role_id = ?", 1).Count(&n).Error; err != nil { + log.Printf("检查管理员角色失败(非致命): %v", err) + return + } + if n == 0 { + log.Println("⚠️ 未检测到任何管理员账号(tb_user_role.role_id=1 空)。" + + "后台 /admin/login 将一律返回\"用户名或密码错误\"。" + + "请执行:docker compose exec backend ./seed (或在本地:go run ./cmd/seed)," + + "并确保已设置 SEED_ADMIN_EMAIL / SEED_ADMIN_PASSWORD。") + } +} + // shouldAutoMigrate 解析 AUTO_MIGRATE_ON_STARTUP 环境变量,默认 true 以兼容现有部署。 func shouldAutoMigrate() bool { v := strings.ToLower(strings.TrimSpace(os.Getenv("AUTO_MIGRATE_ON_STARTUP")))