feat: 完善平台总后台认证与管理入口

This commit is contained in:
2026-07-26 18:47:52 +08:00
parent c3c5df6cbb
commit 073eb89762
12 changed files with 371 additions and 155 deletions

View File

@@ -1,12 +1,19 @@
# Platform API
严格沿用 `sample/server` 的 BSM-SDK Core 分层和运行方式。运行前设置:
平台总后台 API 严格沿用 `sample/server` 的 BSM-SDK Core 分层和运行方式。
启动前请设置运行配置JWT 密钥必须为 16、24 或 32 个字符。
```powershell
$env:BSM_RuntimeMode="dev"
$env:BSM_Prefix="$(Get-Location)/etc"
$env:BSM_JwtSecretKey="仅本地使用的随机密钥"
$env:BSM_JwtSecretKey="local-dev-key-16"
$env:HEQI_PLATFORM_ROOT_PASSWORD="请设置不少于12位的root初始密码"
go run ./cmd/main/main.go
```
`cmd/cli` 提供 `version``migrate`;受保护接口使用 `middleware.JwtAuth(true)`。UUID V7 主键及完整中文注释以 `../migrations` 为准
应用启动和 `go run ./cmd/cli/main.go migrate` 都会在事务内幂等创建平台 `root` 账号。账号名固定为 `root`;优先使用 `HEQI_PLATFORM_ROOT_PASSWORD`未设置时仅使用开发环境默认值。root 首次登录后必须通过 `PUT /heqi/v1/auth/password` 修改密码
匿名接口为 `POST /heqi/v1/auth/login`;其余平台接口经 `middleware.JwtAuth(true)` 保护。请求头 `Authorization` 直接传递 JWT 原始值,不使用 `Bearer` 前缀。
UUID V7 主键、模型中文注释和 PostgreSQL 变更记录以 `../migrations` 为准。

View File

@@ -7,6 +7,7 @@ import (
"git.apinb.com/heqiapp/platforms/backend/api/internal/config"
"git.apinb.com/heqiapp/platforms/backend/api/internal/impl"
"git.apinb.com/heqiapp/platforms/backend/api/internal/initdb"
_ "git.apinb.com/heqiapp/platforms/backend/api/internal/models"
)
@@ -23,6 +24,9 @@ func main() {
case "migrate":
config.New(serviceKey)
impl.NewImpl()
if err := initdb.New(impl.DBService); err != nil {
panic(err)
}
fmt.Println("platform database auto migrate completed")
default:
fmt.Fprintf(os.Stderr, "unknown command: %s\n", os.Args[1])

View File

@@ -15,6 +15,7 @@ import (
"git.apinb.com/bsm-sdk/core/printer"
"git.apinb.com/heqiapp/platforms/backend/api/internal/config"
"git.apinb.com/heqiapp/platforms/backend/api/internal/impl"
"git.apinb.com/heqiapp/platforms/backend/api/internal/initdb"
"git.apinb.com/heqiapp/platforms/backend/api/internal/routers"
"github.com/gin-gonic/gin"
)
@@ -24,6 +25,9 @@ const serviceKey = "heqi"
func main() {
config.New(serviceKey)
impl.NewImpl()
if err := initdb.New(impl.DBService); err != nil {
panic(err)
}
app := gin.Default()
middleware.Mode(app)

View File

@@ -0,0 +1,11 @@
// Package initdb 提供应用启动后的基础数据初始化。
package initdb
import "gorm.io/gorm"
// New 在同一事务中初始化平台基础数据。
func New(database *gorm.DB) error {
return database.Transaction(func(tx *gorm.DB) error {
return InitPlatformRoot(tx)
})
}

View File

@@ -0,0 +1,57 @@
package initdb
import (
"errors"
"os"
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
const (
// PlatformRootUsername 是平台总后台的内置根账号名称。
PlatformRootUsername = "root"
// PlatformRootPassword 是仅用于首次启动的初始密码,首次登录后必须修改。
PlatformRootPassword = "Heqi@Root2026"
// PlatformRootRoleCode 表示根账号的平台角色。
PlatformRootRoleCode = "platform_root"
)
// InitPlatformRoot 幂等创建平台总后台 root 账号。
func InitPlatformRoot(database *gorm.DB) error {
var account models.IdnAccount
err := database.Where("username = ?", PlatformRootUsername).First(&account).Error
if err == nil {
return nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
passwordHash, err := bcrypt.GenerateFromPassword([]byte(platformRootPassword()), bcrypt.DefaultCost)
if err != nil {
return err
}
account = models.IdnAccount{
Entity: models.Entity{Identity: models.NewIdentity(), Status: "enabled"},
Username: PlatformRootUsername,
DisplayName: "平台根管理员",
PasswordHash: string(passwordHash),
RoleCode: PlatformRootRoleCode,
MustChangePassword: true,
Phone: "",
AccountType: "operator",
ServiceArea: "全国",
}
return database.Create(&account).Error
}
// platformRootPassword 优先读取部署环境传入的 root 初始密码。
func platformRootPassword() string {
if password := os.Getenv("HEQI_PLATFORM_ROOT_PASSWORD"); len(password) >= 12 {
return password
}
return PlatformRootPassword
}

View File

@@ -0,0 +1,146 @@
package platform
import (
"strings"
"git.apinb.com/bsm-sdk/core/crypto/token"
"git.apinb.com/bsm-sdk/core/env"
"git.apinb.com/bsm-sdk/core/errcode"
"git.apinb.com/bsm-sdk/core/infra"
"git.apinb.com/bsm-sdk/core/middleware"
"git.apinb.com/heqiapp/platforms/backend/api/internal/impl"
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
// LoginRequest 是平台总后台的账号密码登录请求。
type LoginRequest struct {
Username string `json:"username" binding:"required,max=64"`
Password string `json:"password" binding:"required,min=8,max=128"`
}
// LoginReply 是后台登录成功后的访问凭证与账号状态。
type LoginReply struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
Identity string `json:"identity"`
DisplayName string `json:"display_name"`
RoleCode string `json:"role_code"`
MustChangePassword bool `json:"must_change_password"`
}
// Login 校验平台账号密码并签发 BSM JWT。
func Login(ctx *gin.Context) {
var request LoginRequest
if err := ctx.ShouldBindJSON(&request); err != nil {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
var account models.IdnAccount
err := impl.DBService.Where("username = ?", strings.TrimSpace(request.Username)).First(&account).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
infra.Response.Error(ctx, errcode.ErrPassword)
return
}
infra.Response.Error(ctx, err)
return
}
if account.Status != "enabled" {
infra.Response.Error(ctx, errcode.ErrAccountDisabled)
return
}
if bcrypt.CompareHashAndPassword([]byte(account.PasswordHash), []byte(request.Password)) != nil {
infra.Response.Error(ctx, errcode.ErrPassword)
return
}
accessToken, err := token.New(env.Runtime.JwtSecretKey).GenerateJwt(
0,
account.Identity.String(),
"platform_admin",
account.RoleCode,
map[string]string{"username": account.Username, "display_name": account.DisplayName},
map[string]string{"must_change_password": boolText(account.MustChangePassword)},
)
if err != nil {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, LoginReply{
AccessToken: accessToken,
TokenType: "JWT",
Identity: account.Identity.String(),
DisplayName: account.DisplayName,
RoleCode: account.RoleCode,
MustChangePassword: account.MustChangePassword,
})
}
// CurrentProfile 返回当前已认证的平台管理员资料。
func CurrentProfile(ctx *gin.Context) {
claims, err := middleware.ParseAuth(ctx)
if err != nil {
infra.Response.Error(ctx, err)
return
}
var account models.IdnAccount
if err := impl.DBService.Where("identity = ?", claims.Identity).First(&account).Error; err != nil {
infra.Response.Error(ctx, errcode.ErrRecordNotFound)
return
}
infra.Response.Success(ctx, gin.H{
"identity": account.Identity.String(), "username": account.Username, "display_name": account.DisplayName,
"role_code": account.RoleCode, "must_change_password": account.MustChangePassword, "mfa_enabled": account.MFAEnabled,
})
}
// ChangePasswordRequest 是已登录账号的改密请求。
type ChangePasswordRequest struct {
CurrentPassword string `json:"current_password" binding:"required,min=8,max=128"`
NewPassword string `json:"new_password" binding:"required,min=12,max=128"`
}
// ChangePassword 修改当前账号密码并解除首次登录改密限制。
func ChangePassword(ctx *gin.Context) {
claims, err := middleware.ParseAuth(ctx)
if err != nil {
infra.Response.Error(ctx, err)
return
}
var request ChangePasswordRequest
if err := ctx.ShouldBindJSON(&request); err != nil {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
var account models.IdnAccount
if err := impl.DBService.Where("identity = ?", claims.Identity).First(&account).Error; err != nil {
infra.Response.Error(ctx, errcode.ErrRecordNotFound)
return
}
if bcrypt.CompareHashAndPassword([]byte(account.PasswordHash), []byte(request.CurrentPassword)) != nil {
infra.Response.Error(ctx, errcode.ErrPassword)
return
}
passwordHash, err := bcrypt.GenerateFromPassword([]byte(request.NewPassword), bcrypt.DefaultCost)
if err != nil {
infra.Response.Error(ctx, err)
return
}
if err := impl.DBService.Model(&account).Updates(map[string]any{"password_hash": string(passwordHash), "must_change_password": false}).Error; err != nil {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, gin.H{"changed": true})
}
// boolText 将布尔值转换为 JWT 扩展字段约定的字符串。
func boolText(value bool) string {
if value {
return "true"
}
return "false"
}

View File

@@ -5,9 +5,15 @@ import "git.apinb.com/bsm-sdk/core/database"
// IdnAccount 对应 idn_account表示用户或服务人员身份账户。
type IdnAccount struct {
Entity
Phone string `gorm:"column:phone;type:varchar(32);uniqueIndex;not null" json:"phone"` // 手机号,响应时需脱敏
AccountType string `gorm:"column:account_type;type:varchar(32);not null" json:"account_type"` // 账户类型
ServiceArea string `gorm:"column:service_area;type:varchar(128);not null" json:"service_area"` // 服务区域
Username string `gorm:"column:username;type:varchar(64);uniqueIndex" json:"username"` // 登录用户名。
DisplayName string `gorm:"column:display_name;type:varchar(64);not null;default:''" json:"display_name"` // 用户展示名称。
PasswordHash string `gorm:"column:password_hash;type:varchar(255);not null;default:''" json:"-"` // 密码哈希值,禁止在接口中返回。
RoleCode string `gorm:"column:role_code;type:varchar(64);not null;default:'user'" json:"role_code"` // 平台角色编码。
MustChangePassword bool `gorm:"column:must_change_password;not null;default:false" json:"must_change_password"` // 是否必须修改初始密码。
MFAEnabled bool `gorm:"column:mfa_enabled;not null;default:false" json:"mfa_enabled"` // 是否启用多因素认证。
Phone string `gorm:"column:phone;type:varchar(32);uniqueIndex;not null" json:"phone"` // 手机号,用于登录和通知。
AccountType string `gorm:"column:account_type;type:varchar(32);not null" json:"account_type"` // 账号类型,例如 user、operator。
ServiceArea string `gorm:"column:service_area;type:varchar(128);not null" json:"service_area"` // 服务区域描述。
}
func init() { database.AppendMigrate(&IdnAccount{}) }

View File

@@ -14,10 +14,13 @@ func Register(srvKey string, engine *gin.Engine) {
v1Key := fmt.Sprintf("/%s/%s", srvKey, "v1")
anonymous := engine.Group(v1Key)
anonymous.GET("/ping/hello", platform.PingHello)
anonymous.POST("/auth/login", platform.Login)
protected := engine.Group(v1Key)
protected.Use(middleware.JwtAuth(true))
{
protected.GET("/auth/profile", platform.CurrentProfile)
protected.PUT("/auth/password", platform.ChangePassword)
protected.GET("/dashboard/overview", platform.DashboardOverview)
gasStationGroup := protected.Group("/organization/org_gas_station")
gasStationGroup.POST("", platform.CreateOrgGasStation)