增加气站历史账户显式修复与审计
This commit is contained in:
148
backend/api/internal/repair/gas_account.go
Normal file
148
backend/api/internal/repair/gas_account.go
Normal file
@@ -0,0 +1,148 @@
|
||||
// Package repair 提供必须显式执行且保留审计快照的一次性数据修复。
|
||||
package repair
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common"
|
||||
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
legacyGasAccountRole = "010"
|
||||
gasAdminRole = "admin"
|
||||
gasAccountRepairAction = "repair_legacy_gas_account_role"
|
||||
)
|
||||
|
||||
// GasAccountRepairResult 返回本次修复定位到的账号及审计信息。
|
||||
type GasAccountRepairResult struct {
|
||||
AccountIdentity string `json:"account_identity"`
|
||||
Username string `json:"username"`
|
||||
OldRoleCode string `json:"old_role_code"`
|
||||
NewRoleCode string `json:"new_role_code"`
|
||||
OldDisplayName string `json:"old_display_name"`
|
||||
NewDisplayName string `json:"new_display_name"`
|
||||
Operator string `json:"operator"`
|
||||
RepairedAt *time.Time `json:"repaired_at,omitempty"`
|
||||
}
|
||||
|
||||
// InspectLegacyGasAccount 只读校验修复目标和操作者,不修改任何业务或审计数据。
|
||||
func InspectLegacyGasAccount(database *gorm.DB, operatorUsername string, displayName string) (*GasAccountRepairResult, error) {
|
||||
operatorUsername = strings.TrimSpace(operatorUsername)
|
||||
displayName = strings.TrimSpace(displayName)
|
||||
if database == nil || operatorUsername == "" || displayName == "" {
|
||||
return nil, errors.New("database, operator and display name are required")
|
||||
}
|
||||
account, operator, err := legacyGasAccountRepairTarget(database, operatorUsername)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &GasAccountRepairResult{
|
||||
AccountIdentity: account.Identity,
|
||||
Username: account.Username,
|
||||
OldRoleCode: account.RoleCode,
|
||||
NewRoleCode: gasAdminRole,
|
||||
OldDisplayName: account.DisplayName,
|
||||
NewDisplayName: displayName,
|
||||
Operator: operator.Username,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func legacyGasAccountRepairTarget(database *gorm.DB, operatorUsername string) (models.GasAccount, models.PlatformAccount, error) {
|
||||
var accounts []models.GasAccount
|
||||
if err := database.Where("role_code = ?", legacyGasAccountRole).Limit(2).Find(&accounts).Error; err != nil {
|
||||
return models.GasAccount{}, models.PlatformAccount{}, fmt.Errorf("find legacy gas account: %w", err)
|
||||
}
|
||||
if len(accounts) == 0 {
|
||||
return models.GasAccount{}, models.PlatformAccount{}, errors.New("legacy gas account role 010 not found")
|
||||
}
|
||||
if len(accounts) > 1 {
|
||||
return models.GasAccount{}, models.PlatformAccount{}, fmt.Errorf("refuse ambiguous repair: found %d gas accounts with role 010", len(accounts))
|
||||
}
|
||||
|
||||
var operator models.PlatformAccount
|
||||
if err := database.Where("username = ? AND status = ?", operatorUsername, common.StatusEnable).First(&operator).Error; err != nil {
|
||||
return models.GasAccount{}, models.PlatformAccount{}, fmt.Errorf("find enabled platform operator %q: %w", operatorUsername, err)
|
||||
}
|
||||
return accounts[0], operator, nil
|
||||
}
|
||||
|
||||
type gasAccountSnapshot struct {
|
||||
RoleCode string `json:"role_code"`
|
||||
DisplayName string `json:"display_name"`
|
||||
}
|
||||
|
||||
// RepairLegacyGasAccount 将唯一一条历史 010 气站账号显式修复为管理员并写入审计日志。
|
||||
func RepairLegacyGasAccount(database *gorm.DB, operatorUsername string, displayName string) (*GasAccountRepairResult, error) {
|
||||
operatorUsername = strings.TrimSpace(operatorUsername)
|
||||
displayName = strings.TrimSpace(displayName)
|
||||
if database == nil || operatorUsername == "" || displayName == "" {
|
||||
return nil, errors.New("database, operator and display name are required")
|
||||
}
|
||||
|
||||
var result GasAccountRepairResult
|
||||
err := database.Transaction(func(tx *gorm.DB) error {
|
||||
account, operator, err := legacyGasAccountRepairTarget(tx, operatorUsername)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
before := gasAccountSnapshot{RoleCode: account.RoleCode, DisplayName: account.DisplayName}
|
||||
after := gasAccountSnapshot{RoleCode: gasAdminRole, DisplayName: displayName}
|
||||
beforeJSON, err := json.Marshal(before)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal repair before snapshot: %w", err)
|
||||
}
|
||||
afterJSON, err := json.Marshal(after)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal repair after snapshot: %w", err)
|
||||
}
|
||||
|
||||
repairedAt := time.Now()
|
||||
update := tx.Model(&models.GasAccount{}).
|
||||
Where("identity = ? AND role_code = ?", account.Identity, legacyGasAccountRole).
|
||||
Updates(map[string]any{"role_code": gasAdminRole, "display_name": displayName, "updated_at": repairedAt})
|
||||
if update.Error != nil {
|
||||
return fmt.Errorf("repair legacy gas account: %w", update.Error)
|
||||
}
|
||||
if update.RowsAffected != 1 {
|
||||
return errors.New("legacy gas account changed concurrently; repair aborted")
|
||||
}
|
||||
|
||||
audit := models.AuditOperationLog{
|
||||
Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusEnable},
|
||||
OperatorIdentity: operator.Identity,
|
||||
OperatorName: operator.Username,
|
||||
ResourceType: "gas_account",
|
||||
ResourceIdentity: account.Identity,
|
||||
Action: gasAccountRepairAction,
|
||||
BeforeValue: string(beforeJSON),
|
||||
AfterValue: string(afterJSON),
|
||||
OccurredAt: repairedAt,
|
||||
Remark: "显式修复历史非法气站角色编码 010,并补齐显示名称",
|
||||
}
|
||||
if err := tx.Create(&audit).Error; err != nil {
|
||||
return fmt.Errorf("write gas account repair audit: %w", err)
|
||||
}
|
||||
|
||||
result = GasAccountRepairResult{
|
||||
AccountIdentity: account.Identity,
|
||||
Username: account.Username,
|
||||
OldRoleCode: before.RoleCode,
|
||||
NewRoleCode: after.RoleCode,
|
||||
OldDisplayName: before.DisplayName,
|
||||
NewDisplayName: after.DisplayName,
|
||||
Operator: operator.Username,
|
||||
RepairedAt: &repairedAt,
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
84
backend/api/internal/repair/gas_account_test.go
Normal file
84
backend/api/internal/repair/gas_account_test.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package repair
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func repairTestDatabase(t *testing.T) (*gorm.DB, sqlmock.Sqlmock) {
|
||||
t.Helper()
|
||||
sqlDatabase, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("创建 SQL Mock 失败:%v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = sqlDatabase.Close() })
|
||||
database, err := gorm.Open(postgres.New(postgres.Config{Conn: sqlDatabase}), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("创建 GORM 数据库失败:%v", err)
|
||||
}
|
||||
return database, mock
|
||||
}
|
||||
|
||||
func TestRepairLegacyGasAccountWritesUpdateAndAuditInOneTransaction(t *testing.T) {
|
||||
database, mock := repairTestDatabase(t)
|
||||
now := time.Now()
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "gas_account" WHERE role_code = $1 AND "gas_account"."deleted_at" IS NULL LIMIT $2`)).
|
||||
WithArgs(legacyGasAccountRole, 2).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "username", "display_name", "role_code", "status", "created_at", "updated_at"}).
|
||||
AddRow(9, "gas-account-identity", "xuehai", "", legacyGasAccountRole, 1, now, now))
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "platform_account" WHERE (username = $1 AND status = $2) AND "platform_account"."deleted_at" IS NULL ORDER BY "platform_account"."id" LIMIT $3`)).
|
||||
WithArgs("root", 1, 1).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "username", "platform_role_code", "status", "created_at", "updated_at"}).
|
||||
AddRow(1, "root-account-identity", "root", "root", 1, now, now))
|
||||
mock.ExpectExec(regexp.QuoteMeta(`UPDATE "gas_account" SET "display_name"=$1,"role_code"=$2,"updated_at"=$3 WHERE (identity = $4 AND role_code = $5) AND "gas_account"."deleted_at" IS NULL`)).
|
||||
WithArgs("薛海", gasAdminRole, sqlmock.AnyArg(), "gas-account-identity", legacyGasAccountRole).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectQuery(`INSERT INTO "audit_operation_log"`).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(1))
|
||||
mock.ExpectCommit()
|
||||
|
||||
result, err := RepairLegacyGasAccount(database, "root", " 薛海 ")
|
||||
if err != nil {
|
||||
t.Fatalf("修复失败:%v", err)
|
||||
}
|
||||
if result.AccountIdentity != "gas-account-identity" || result.OldRoleCode != "010" || result.NewRoleCode != "admin" {
|
||||
t.Fatalf("修复结果不正确:%#v", result)
|
||||
}
|
||||
if result.OldDisplayName != "" || result.NewDisplayName != "薛海" || result.Operator != "root" || result.RepairedAt == nil {
|
||||
t.Fatalf("修复审计摘要不完整:%#v", result)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("数据库事务与审计写入不符合预期:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepairLegacyGasAccountRefusesAmbiguousTarget(t *testing.T) {
|
||||
database, mock := repairTestDatabase(t)
|
||||
now := time.Now()
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "gas_account" WHERE role_code = $1 AND "gas_account"."deleted_at" IS NULL LIMIT $2`)).
|
||||
WithArgs(legacyGasAccountRole, 2).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "role_code", "created_at", "updated_at"}).
|
||||
AddRow(1, "first", legacyGasAccountRole, now, now).
|
||||
AddRow(2, "second", legacyGasAccountRole, now, now))
|
||||
mock.ExpectRollback()
|
||||
|
||||
if _, err := RepairLegacyGasAccount(database, "root", "薛海"); err == nil {
|
||||
t.Fatal("存在多个 010 账号时必须拒绝修复")
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("歧义目标不应产生写操作:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectLegacyGasAccountRejectsMissingArguments(t *testing.T) {
|
||||
if _, err := InspectLegacyGasAccount(nil, "root", "薛海"); err == nil {
|
||||
t.Fatal("缺少数据库时必须拒绝预检")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user