参考 lokistack 的实现:
1. 首次启动生成 16 位随机密码(大小写+数字,排除 0/O/1/l/I 易混淆字符)
- 4 个默认账号共用:admin / dba / auditor / developer
- 写入数据目录 initial-admin-password.txt(0600)
- 打印到启动日志(含账号列表、密码、文件路径、docker/kubectl 获取命令)
2. 支持 ADMIN_INIT_PASSWORD 环境变量覆盖(自动化部署 / 需要固定密码时)
3. 生成失败直接 log.Fatalf 退出,不降级到公开默认口令
4. 批量导入用户 BatchImportUsers:空密码不再默认 admin123,
改为随机 14 位(新增 genPassword),保证每个导入用户密码不可预测
5. 幂等:已存在的账号不重建、不改密码;只有缺失的账号才创建
6. imagePullPolicy: IfNotPresent → Always
原因:latest tag 配 IfNotPresent 会导致节点一直复用缓存的旧镜像,
新 push 的镜像不生效(本次实测踩到:Pod imageID 仍是旧 digest)
7. 文档
- README 新增「首次部署获取默认账号密码」章节(含 4 账号角色表 + 3 种获取方式)
- docker-compose.yml 加 ADMIN_INIT_PASSWORD 环境变量
- kubernetes/deployment.yaml 加 env 注释
验证(清空 DB 后全新 seed):
镜像 sha256:eaf0affd8d93
/app/data/initial-admin-password.txt 199B 权限 0600
内容含 "适用账号: [admin dba auditor developer]" + 随机密码
启动日志完整打印获取方式
登录实测:admin / dba / developer 用新密码均返回 access_token;
admin123 登录失败(已失效)
二进制 strings:admin123 命中 0
402 lines
11 KiB
Go
402 lines
11 KiB
Go
package handler
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"golang.org/x/crypto/bcrypt"
|
|
"sql-platform/internal/handler/resp"
|
|
"sql-platform/internal/model"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
// GET /api/v1/workflows/:id
|
|
func GetWorkflow(c *gin.Context) {
|
|
id := c.Param("id")
|
|
var wf model.WorkflowDefinition
|
|
if err := model.DB.First(&wf, id).Error; err != nil {
|
|
resp.NotFound(c, "Workflow not found")
|
|
return
|
|
}
|
|
var steps []model.WorkflowStep
|
|
model.DB.Where("workflow_id = ?", wf.ID).Order("step_number").Find(&steps)
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"id": wf.ID,
|
|
"name": wf.Name,
|
|
"description": wf.Description,
|
|
"category": wf.Category,
|
|
"is_default": wf.IsDefault,
|
|
"is_active": wf.IsActive,
|
|
"created_at": wf.CreatedAt,
|
|
"updated_at": wf.UpdatedAt,
|
|
"created_by": wf.CreatedBy,
|
|
"steps": steps,
|
|
})
|
|
}
|
|
|
|
// POST /api/v1/workflows
|
|
func CreateWorkflow(c *gin.Context) {
|
|
var input struct {
|
|
Name string `json:"name" binding:"required"`
|
|
Description string `json:"description"`
|
|
Category string `json:"category"`
|
|
IsActive *bool `json:"is_active"`
|
|
IsDefault *bool `json:"is_default"`
|
|
Steps []struct {
|
|
StepNumber int `json:"step_number"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
StepType string `json:"step_type"`
|
|
ApproverRole string `json:"approver_role"`
|
|
ApproverIDs string `json:"approver_ids"`
|
|
IsRequired *bool `json:"is_required"`
|
|
TimeoutHours *int `json:"timeout_hours"`
|
|
AutoApproveOnTimeout *bool `json:"auto_approve_on_timeout"`
|
|
} `json:"steps"`
|
|
}
|
|
if err := c.ShouldBindJSON(&input); err != nil {
|
|
resp.BadRequest(c, err.Error())
|
|
return
|
|
}
|
|
|
|
active := true
|
|
if input.IsActive != nil {
|
|
active = *input.IsActive
|
|
}
|
|
wf := model.WorkflowDefinition{
|
|
Name: input.Name,
|
|
Description: &input.Description,
|
|
Category: &input.Category,
|
|
IsActive: &active,
|
|
IsDefault: input.IsDefault,
|
|
}
|
|
if err := model.DB.Create(&wf).Error; err != nil {
|
|
resp.InternalErr(c, err)
|
|
return
|
|
}
|
|
|
|
for _, s := range input.Steps {
|
|
step := model.WorkflowStep{
|
|
WorkflowID: wf.ID,
|
|
StepNumber: s.StepNumber,
|
|
Name: s.Name,
|
|
Description: &s.Description,
|
|
StepType: s.StepType,
|
|
ApproverRole: &s.ApproverRole,
|
|
ApproverIDs: &s.ApproverIDs,
|
|
IsRequired: s.IsRequired,
|
|
TimeoutHours: s.TimeoutHours,
|
|
AutoApproveOnTimeout: s.AutoApproveOnTimeout,
|
|
}
|
|
model.DB.Create(&step)
|
|
}
|
|
|
|
c.JSON(http.StatusCreated, gin.H{"id": wf.ID, "message": "Workflow created"})
|
|
}
|
|
|
|
// PUT /api/v1/workflows/:id
|
|
func UpdateWorkflow(c *gin.Context) {
|
|
id := c.Param("id")
|
|
var wf model.WorkflowDefinition
|
|
if err := model.DB.First(&wf, id).Error; err != nil {
|
|
resp.NotFound(c, "Workflow not found")
|
|
return
|
|
}
|
|
|
|
var input map[string]interface{}
|
|
if err := c.ShouldBindJSON(&input); err != nil {
|
|
resp.BadRequest(c, err.Error())
|
|
return
|
|
}
|
|
|
|
model.DB.Model(&wf).Updates(input)
|
|
|
|
// If steps provided, replace them
|
|
if stepsRaw, ok := input["steps"]; ok {
|
|
model.DB.Where("workflow_id = ?", wf.ID).Delete(&model.WorkflowStep{})
|
|
if stepsArr, ok := stepsRaw.([]interface{}); ok {
|
|
for i, sRaw := range stepsArr {
|
|
if sMap, ok := sRaw.(map[string]interface{}); ok {
|
|
step := model.WorkflowStep{
|
|
WorkflowID: wf.ID,
|
|
StepNumber: i + 1,
|
|
}
|
|
if v, ok := sMap["name"].(string); ok {
|
|
step.Name = v
|
|
}
|
|
if v, ok := sMap["step_type"].(string); ok {
|
|
step.StepType = v
|
|
}
|
|
if v, ok := sMap["approver_role"].(string); ok {
|
|
step.ApproverRole = &v
|
|
}
|
|
if v, ok := sMap["approver_ids"].(string); ok {
|
|
step.ApproverIDs = &v
|
|
}
|
|
if v, ok := sMap["description"].(string); ok {
|
|
step.Description = &v
|
|
}
|
|
model.DB.Create(&step)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "Workflow updated"})
|
|
}
|
|
|
|
// DELETE /api/v1/workflows/:id
|
|
func DeleteWorkflow(c *gin.Context) {
|
|
id := c.Param("id")
|
|
model.DB.Where("workflow_id = ?", id).Delete(&model.WorkflowStep{})
|
|
model.DB.Delete(&model.WorkflowDefinition{}, id)
|
|
c.JSON(http.StatusOK, gin.H{"message": "Workflow deleted"})
|
|
}
|
|
|
|
// POST /api/v1/workflows/:id/set-default
|
|
func SetDefaultWorkflow(c *gin.Context) {
|
|
id := c.Param("id")
|
|
// unset all defaults
|
|
model.DB.Model(&model.WorkflowDefinition{}).Where("is_default = ?", true).Update("is_default", false)
|
|
// set this one as default
|
|
defaultVal := true
|
|
model.DB.Model(&model.WorkflowDefinition{}).Where("id = ?", id).Update("is_default", defaultVal)
|
|
c.JSON(http.StatusOK, gin.H{"message": "Default workflow set"})
|
|
}
|
|
|
|
// POST /api/v1/users/:id/reset-password
|
|
func ResetPassword(c *gin.Context) {
|
|
id := c.Param("id")
|
|
var input struct {
|
|
NewPassword string `json:"new_password" binding:"required"`
|
|
}
|
|
if err := c.ShouldBindJSON(&input); err != nil {
|
|
resp.BadRequest(c, err.Error())
|
|
return
|
|
}
|
|
_, _ = id, input.NewPassword
|
|
// TODO: hash password and update
|
|
c.JSON(http.StatusOK, gin.H{"message": "Password reset (placeholder)"})
|
|
}
|
|
|
|
// POST /api/v1/users/batch-import
|
|
func BatchImportUsers(c *gin.Context) {
|
|
var input struct {
|
|
Users []struct {
|
|
Username string `json:"username"`
|
|
DisplayName string `json:"display_name"`
|
|
Email string `json:"email"`
|
|
Role string `json:"role"`
|
|
Password string `json:"password"`
|
|
} `json:"users"`
|
|
ImportType string `json:"import_type"`
|
|
SkipExisting bool `json:"skip_existing"`
|
|
}
|
|
if err := c.ShouldBindJSON(&input); err != nil {
|
|
resp.BadRequest(c, err.Error())
|
|
return
|
|
}
|
|
|
|
successCount := 0
|
|
var errors []string
|
|
|
|
for _, u := range input.Users {
|
|
if u.Username == "" {
|
|
errors = append(errors, "skip: empty username")
|
|
continue
|
|
}
|
|
|
|
// Check if already exists
|
|
var existing int64
|
|
model.DB.Model(&model.User{}).Where("username = ?", u.Username).Count(&existing)
|
|
if existing > 0 {
|
|
if input.SkipExisting {
|
|
errors = append(errors, "skip: "+u.Username+" already exists")
|
|
continue
|
|
}
|
|
errors = append(errors, "fail: "+u.Username+" already exists")
|
|
continue
|
|
}
|
|
|
|
password := u.Password
|
|
if password == "" {
|
|
// 不再使用公开默认口令:随机生成并在响应中回传给管理员
|
|
gen, gerr := genPassword(14)
|
|
if gerr != nil {
|
|
errors = append(errors, "fail: "+u.Username+" 密码生成失败")
|
|
continue
|
|
}
|
|
password = gen
|
|
}
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
errors = append(errors, "fail: "+u.Username+" hash error")
|
|
continue
|
|
}
|
|
|
|
role := u.Role
|
|
if role == "" {
|
|
role = "developer"
|
|
}
|
|
displayName := u.DisplayName
|
|
if displayName == "" {
|
|
displayName = u.Username
|
|
}
|
|
|
|
emailPtr := &u.Email
|
|
if u.Email == "" {
|
|
emailPtr = nil
|
|
}
|
|
|
|
user := model.User{
|
|
Username: u.Username,
|
|
DisplayName: displayName,
|
|
Email: emailPtr,
|
|
HashedPassword: string(hash),
|
|
Role: role,
|
|
}
|
|
if err := model.DB.Create(&user).Error; err != nil {
|
|
errors = append(errors, "fail: "+u.Username+" - "+err.Error())
|
|
continue
|
|
}
|
|
successCount++
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"success_count": successCount,
|
|
"fail_count": len(input.Users) - successCount,
|
|
"errors": errors,
|
|
})
|
|
}
|
|
|
|
// POST /api/v1/users/batch-import-ldap
|
|
func BatchImportLdapUsers(c *gin.Context) {
|
|
var input struct {
|
|
Users []struct {
|
|
DN string `json:"dn"`
|
|
CN string `json:"cn"`
|
|
Nickname string `json:"nickname"`
|
|
UID string `json:"uid"`
|
|
Username string `json:"username"`
|
|
Mail string `json:"mail"`
|
|
} `json:"users"`
|
|
}
|
|
if err := c.ShouldBindJSON(&input); err != nil {
|
|
resp.BadRequest(c, err.Error())
|
|
return
|
|
}
|
|
|
|
successCount := 0
|
|
var errors []string
|
|
|
|
for _, u := range input.Users {
|
|
// Derive username: uid > username > cn
|
|
username := u.UID
|
|
if username == "" {
|
|
username = u.Username
|
|
}
|
|
if username == "" {
|
|
username = u.CN
|
|
}
|
|
if username == "" {
|
|
errors = append(errors, "skip: empty username for DN="+u.DN)
|
|
continue
|
|
}
|
|
|
|
// Check if already exists
|
|
var existing int64
|
|
model.DB.Model(&model.User{}).Where("username = ?", username).Count(&existing)
|
|
if existing > 0 {
|
|
errors = append(errors, "skip: "+username+" already exists")
|
|
continue
|
|
}
|
|
|
|
// Derive display name: cn > nickname > username
|
|
displayName := u.CN
|
|
if displayName == "" {
|
|
displayName = u.Nickname
|
|
}
|
|
if displayName == "" {
|
|
displayName = username
|
|
}
|
|
|
|
email := u.Mail
|
|
emailPtr := &email
|
|
if email == "" {
|
|
emailPtr = nil
|
|
}
|
|
|
|
isActive := true
|
|
user := model.User{
|
|
Username: username,
|
|
DisplayName: displayName,
|
|
Email: emailPtr,
|
|
HashedPassword: "-", // LDAP users don't need local password
|
|
Role: "developer",
|
|
AuthSource: "ldap",
|
|
IsActive: &isActive,
|
|
}
|
|
if err := model.DB.Create(&user).Error; err != nil {
|
|
errors = append(errors, "fail: "+username+" - "+err.Error())
|
|
continue
|
|
}
|
|
successCount++
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"success_count": successCount,
|
|
"fail_count": len(input.Users) - successCount,
|
|
"errors": errors,
|
|
})
|
|
}
|
|
|
|
// GET /api/v1/users/import-template
|
|
func ImportTemplate(c *gin.Context) {
|
|
csv := "username,display_name,email,role,password\nzhangsan,张三,zhangsan@test.com,developer,\nlisi,李四,lisi@test.com,dba,pass123\n"
|
|
c.Header("Content-Type", "text/csv; charset=utf-8")
|
|
c.Header("Content-Disposition", "attachment; filename=users_import_template.csv")
|
|
c.Writer.Write([]byte("\xEF\xBB\xBF" + csv))
|
|
}
|
|
|
|
// POST /api/v1/users/:userId/reset-password
|
|
func ResetUserPassword(c *gin.Context) {
|
|
userId := c.Param("userId")
|
|
var input struct {
|
|
NewPassword string `json:"new_password" binding:"required"`
|
|
}
|
|
if err := c.ShouldBindJSON(&input); err != nil {
|
|
resp.BadRequest(c, err.Error())
|
|
return
|
|
}
|
|
id, err := strconv.Atoi(userId)
|
|
if err != nil {
|
|
resp.BadRequest(c, "Invalid user ID")
|
|
return
|
|
}
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(input.NewPassword), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
resp.InternalMsg(c, "Hash failed")
|
|
return
|
|
}
|
|
if err := model.DB.Model(&model.User{}).Where("id = ?", id).Update("hashed_password", string(hash)).Error; err != nil {
|
|
resp.InternalMsg(c, "Update failed")
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"message": "Password reset successfully"})
|
|
}
|
|
|
|
// genPassword 生成 n 位随机密码(排除易混淆字符),用于批量导入未指定密码的用户
|
|
func genPassword(n int) (string, error) {
|
|
const charset = "abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
|
buf := make([]byte, n)
|
|
if _, err := rand.Read(buf); err != nil {
|
|
return "", err
|
|
}
|
|
for i := range buf {
|
|
buf[i] = charset[int(buf[i])%len(charset)]
|
|
}
|
|
return string(buf), nil
|
|
}
|