增加气站历史账户显式修复与审计

This commit is contained in:
czl231
2026-08-17 22:34:02 +08:00
parent d6bb3bd43e
commit eef791427a
9 changed files with 354 additions and 4 deletions

View File

@@ -3,6 +3,8 @@ package main
import (
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"os"
@@ -20,6 +22,7 @@ import (
gaslogic "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/gas"
"git.apinb.com/heqiapp/platforms/backend/api/internal/logic/platform"
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
"git.apinb.com/heqiapp/platforms/backend/api/internal/repair"
"git.apinb.com/heqiapp/platforms/backend/api/internal/routers"
"git.apinb.com/heqiapp/platforms/backend/api/internal/seed"
"github.com/gin-gonic/gin"
@@ -85,6 +88,11 @@ func main() {
os.Exit(1)
}
fmt.Printf("mock refund reviewers repaired: %d\n", count)
case "repair-legacy-gas-account":
if err := repairLegacyGasAccount(os.Args[2:], os.Stdout); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
case "migrate":
if err := migrateDatabase(); err != nil {
fmt.Fprintln(os.Stderr, err)
@@ -99,7 +107,7 @@ func main() {
}
func printUsage() {
fmt.Fprintln(os.Stderr, "usage: platform-cli <version|resource-contract|gas-resource-contract|delivery-resource-contract|migrate|mock-data|repair-mock-gasorder-status|repair-mock-ec-order|repair-mock-track-point|repair-mock-refund-reviewer>")
fmt.Fprintln(os.Stderr, "usage: platform-cli <version|resource-contract|gas-resource-contract|delivery-resource-contract|migrate|mock-data|repair-mock-gasorder-status|repair-mock-ec-order|repair-mock-track-point|repair-mock-refund-reviewer|repair-legacy-gas-account>")
}
type route struct {
@@ -293,6 +301,49 @@ func repairMockRefundReviewer() (int64, error) {
return count, nil
}
// repairLegacyGasAccount 显式修复唯一历史 010 气站账号,并将前后值写入操作审计表。
func repairLegacyGasAccount(arguments []string, output io.Writer) error {
flags := flag.NewFlagSet("repair-legacy-gas-account", flag.ContinueOnError)
flags.SetOutput(io.Discard)
operator := flags.String("operator", "", "执行修复的平台账户用户名")
displayName := flags.String("display-name", "", "修复后的显示名称")
dryRun := flags.Bool("dry-run", false, "只读核对目标,不执行修复")
if err := flags.Parse(arguments); err != nil {
return fmt.Errorf("parse repair arguments: %w", err)
}
if strings.TrimSpace(*operator) == "" || strings.TrimSpace(*displayName) == "" {
return errors.New("repair-legacy-gas-account requires --operator and --display-name")
}
config.New(serviceKey)
if config.Spec.Databases == nil {
return fmt.Errorf("database configuration is required")
}
databaseService, err := database.NewDatabase(
config.Spec.Databases.Driver,
config.Spec.Databases.Source,
dbsql.SetOptions(nil),
)
if err != nil {
return fmt.Errorf("connect database: %w", err)
}
if *dryRun {
result, err := repair.InspectLegacyGasAccount(databaseService, *operator, *displayName)
if err != nil {
return fmt.Errorf("inspect legacy gas account: %w", err)
}
return json.NewEncoder(output).Encode(result)
}
if err := databaseService.AutoMigrate(&models.AuditOperationLog{}); err != nil {
return fmt.Errorf("migrate operation audit table: %w", err)
}
result, err := repair.RepairLegacyGasAccount(databaseService, *operator, *displayName)
if err != nil {
return fmt.Errorf("repair legacy gas account: %w", err)
}
return json.NewEncoder(output).Encode(result)
}
func migrateDatabase() error {
config.New(serviceKey)
options := &types.SqlOptions{

View File

@@ -0,0 +1,24 @@
package models
import (
"time"
"git.apinb.com/bsm-sdk/core/database"
)
// AuditOperationLog 对应 audit_operation_log保存平台敏感维护操作的不可变审计快照。
type AuditOperationLog struct {
Entity // 公共实体字段
OperatorIdentity string `gorm:"column:operator_identity;type:varchar(36);not null;index" json:"operator_identity"` // 操作平台账户唯一标识
OperatorName string `gorm:"column:operator_name;type:varchar(64);not null" json:"operator_name"` // 操作平台账户登录名快照
ResourceType string `gorm:"column:resource_type;type:varchar(64);not null;index" json:"resource_type"` // 被维护资源类型
ResourceIdentity string `gorm:"column:resource_identity;type:varchar(36);not null;index" json:"resource_identity"` // 被维护资源唯一标识
Action string `gorm:"column:action;type:varchar(64);not null;index" json:"action"` // 审计动作编码
BeforeValue string `gorm:"column:before_value;type:text;not null" json:"before_value"` // 修复前字段 JSON 快照
AfterValue string `gorm:"column:after_value;type:text;not null" json:"after_value"` // 修复后字段 JSON 快照
OccurredAt time.Time `gorm:"column:occurred_at;type:timestamptz;not null;index" json:"occurred_at"` // 操作实际发生时间
Remark string `gorm:"column:remark;type:varchar(255);not null;default:''" json:"remark"` // 操作原因或补充说明
}
func init() { database.AppendMigrate(&AuditOperationLog{}) }
func (table *AuditOperationLog) TableName() string { return "audit_operation_log" }

View 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
}

View 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("缺少数据库时必须拒绝预检")
}
}