Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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

Expand Down
130 changes: 89 additions & 41 deletions backend/cmd/seed/main.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Package main 实现 seed 命令:首次部署后创建管理员账号。
// Package main 实现 seed 命令:创建 / 重置管理员账号。
//
// 使用方式:
//
Expand All @@ -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"
Expand All @@ -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")
Expand Down Expand Up @@ -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
}
26 changes: 26 additions & 0 deletions backend/internal/config/database.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")))
Expand Down
Loading