fix: 优化工作人员重复信息提示
This commit is contained in:
@@ -205,7 +205,15 @@ func GetByIdentity[T any](ctx *gin.Context) {
|
||||
infra.Response.Success(ctx, ProtectPreciseLocation(ctx, new(T), response))
|
||||
}
|
||||
|
||||
// UpdateAllowedByIdentity 按白名单更新资源,并保持原有数据库错误响应行为。
|
||||
func UpdateAllowedByIdentity(ctx *gin.Context, model any, values map[string]any, allowedFields []string) {
|
||||
UpdateAllowedByIdentityWithError(ctx, model, values, allowedFields, nil)
|
||||
}
|
||||
|
||||
// UpdateAllowedByIdentityWithError 按白名单更新资源,并允许调用方转换数据库写入错误。
|
||||
// 参数:transformError 为空时保持原有错误响应,非空时仅转换数据库更新错误。
|
||||
// 返回值:无,处理结果通过统一 HTTP 响应写入。
|
||||
func UpdateAllowedByIdentityWithError(ctx *gin.Context, model any, values map[string]any, allowedFields []string, transformError func(error) error) {
|
||||
values = FilterFields(values, allowedFields)
|
||||
if len(values) == 0 {
|
||||
infra.Response.Success(ctx, gin.H{"updated": false})
|
||||
@@ -214,6 +222,9 @@ func UpdateAllowedByIdentity(ctx *gin.Context, model any, values map[string]any,
|
||||
|
||||
result := ActiveRecords(impl.DBService.Model(model)).Where("identity = ?", ctx.Param("identity")).Updates(values)
|
||||
if result.Error != nil {
|
||||
if transformError != nil {
|
||||
result.Error = transformError(result.Error)
|
||||
}
|
||||
infra.Response.Error(ctx, result.Error)
|
||||
return
|
||||
}
|
||||
|
||||
52
backend/api/internal/logic/common/staff_error.go
Normal file
52
backend/api/internal/logic/common/staff_error.go
Normal file
@@ -0,0 +1,52 @@
|
||||
// 功能描述:转换工作人员写入时的数据库错误,避免向客户端泄露数据库实现细节。
|
||||
// 版本:v1.0
|
||||
package common
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
|
||||
"git.apinb.com/heqiapp/platforms/backend/api/internal/impl"
|
||||
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
const (
|
||||
staffUsernameUniqueConstraint = "idx_staff_account_username"
|
||||
staffPhoneUniqueConstraint = "idx_staff_account_phone"
|
||||
)
|
||||
|
||||
var (
|
||||
errStaffUsernameExists = errors.New("该用户名已存在")
|
||||
errStaffPhoneExists = errors.New("该联系电话已被其他工作人员使用")
|
||||
errStaffSaveFailed = errors.New("保存失败,请稍后重试")
|
||||
)
|
||||
|
||||
// CreateStaffRecord 创建工作人员记录,并统一转换数据库写入错误。
|
||||
// 参数:staff 为已经完成业务校验和密码哈希处理的工作人员模型。
|
||||
// 返回值:创建成功返回 nil,失败返回脱敏后的工作人员写入错误。
|
||||
func CreateStaffRecord(staff *models.StaffAccount) error {
|
||||
if err := impl.DBService.Create(staff).Error; err != nil {
|
||||
return StaffWriteError(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// StaffWriteError 将工作人员写入错误转换为稳定的中文业务提示。
|
||||
// 参数:err 为工作人员创建或更新操作返回的数据库错误。
|
||||
// 返回值:已知唯一约束冲突返回对应业务提示,其他错误脱敏后返回通用保存失败提示。
|
||||
func StaffWriteError(err error) error {
|
||||
var postgresError *pgconn.PgError
|
||||
if errors.As(err, &postgresError) && postgresError.Code == "23505" {
|
||||
switch postgresError.ConstraintName {
|
||||
case staffUsernameUniqueConstraint:
|
||||
return errStaffUsernameExists
|
||||
case staffPhoneUniqueConstraint:
|
||||
return errStaffPhoneExists
|
||||
}
|
||||
}
|
||||
|
||||
// 未识别错误仅写入服务端日志,客户端不能看到数据库表名、约束名或 SQLSTATE。
|
||||
log.Printf("staff write failed: %v", err)
|
||||
return errStaffSaveFailed
|
||||
}
|
||||
143
backend/api/internal/logic/common/staff_error_test.go
Normal file
143
backend/api/internal/logic/common/staff_error_test.go
Normal file
@@ -0,0 +1,143 @@
|
||||
// 功能描述:验证工作人员数据库写入错误的业务化转换与脱敏规则。
|
||||
// 版本:v1.0
|
||||
package common
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.apinb.com/heqiapp/platforms/backend/api/internal/impl"
|
||||
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// TestStaffWriteErrorKnownConflicts 验证用户名和联系电话冲突会返回明确中文提示。
|
||||
// 参数:t 为 Go 测试上下文。
|
||||
// 返回值:无。
|
||||
func TestStaffWriteErrorKnownConflicts(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
constraint string
|
||||
message string
|
||||
}{
|
||||
{name: "用户名重复", constraint: staffUsernameUniqueConstraint, message: "该用户名已存在"},
|
||||
{name: "联系电话重复", constraint: staffPhoneUniqueConstraint, message: "该联系电话已被其他工作人员使用"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
databaseError := &pgconn.PgError{Code: "23505", ConstraintName: test.constraint}
|
||||
if actual := StaffWriteError(databaseError).Error(); actual != test.message {
|
||||
t.Fatalf("期望提示 %q,实际为 %q", test.message, actual)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestStaffWriteErrorWrappedConflict 验证被包装的 PostgreSQL 冲突仍能被准确识别。
|
||||
// 参数:t 为 Go 测试上下文。
|
||||
// 返回值:无。
|
||||
func TestStaffWriteErrorWrappedConflict(t *testing.T) {
|
||||
databaseError := &pgconn.PgError{Code: "23505", ConstraintName: staffPhoneUniqueConstraint}
|
||||
wrapped := fmt.Errorf("create staff: %w", databaseError)
|
||||
|
||||
if actual := StaffWriteError(wrapped).Error(); actual != "该联系电话已被其他工作人员使用" {
|
||||
t.Fatalf("期望识别包装后的联系电话冲突,实际为 %q", actual)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStaffWriteErrorSanitizesUnknownErrors 验证未知约束和普通数据库异常不会泄露原始信息。
|
||||
// 参数:t 为 Go 测试上下文。
|
||||
// 返回值:无。
|
||||
func TestStaffWriteErrorSanitizesUnknownErrors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
}{
|
||||
{name: "未知唯一约束", err: &pgconn.PgError{Code: "23505", ConstraintName: "idx_private_constraint"}},
|
||||
{name: "普通数据库异常", err: errors.New("database unavailable at private-host")},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if actual := StaffWriteError(test.err).Error(); actual != "保存失败,请稍后重试" {
|
||||
t.Fatalf("期望通用脱敏提示,实际为 %q", actual)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateStaffRecordTransformsConflict 验证三个入口共用的创建函数会转换唯一约束错误。
|
||||
// 参数:t 为 Go 测试上下文。
|
||||
// 返回值:无。
|
||||
func TestCreateStaffRecordTransformsConflict(t *testing.T) {
|
||||
mock := setupStaffWriteDatabase(t)
|
||||
mock.ExpectQuery(`INSERT INTO "staff_account"`).
|
||||
WillReturnError(&pgconn.PgError{Code: "23505", ConstraintName: staffUsernameUniqueConstraint})
|
||||
|
||||
err := CreateStaffRecord(&models.StaffAccount{})
|
||||
if err == nil || err.Error() != "该用户名已存在" {
|
||||
t.Fatalf("期望工作人员创建返回用户名冲突,实际为 %v", err)
|
||||
}
|
||||
assertStaffWriteExpectations(t, mock)
|
||||
}
|
||||
|
||||
// TestUpdateAllowedByIdentityWithErrorTransformsConflict 验证编辑联系电话冲突会经过调用方转换器。
|
||||
// 参数:t 为 Go 测试上下文。
|
||||
// 返回值:无。
|
||||
func TestUpdateAllowedByIdentityWithErrorTransformsConflict(t *testing.T) {
|
||||
mock := setupStaffWriteDatabase(t)
|
||||
mock.ExpectExec(`UPDATE "staff_account"`).
|
||||
WillReturnError(&pgconn.PgError{Code: "23505", ConstraintName: staffPhoneUniqueConstraint})
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
response := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(response)
|
||||
ctx.Params = gin.Params{{Key: "identity", Value: "staff-identity"}}
|
||||
UpdateAllowedByIdentityWithError(ctx, &models.StaffAccount{}, map[string]any{"phone": "13800000000"}, []string{"phone"}, StaffWriteError)
|
||||
|
||||
if !strings.Contains(response.Body.String(), "该联系电话已被其他工作人员使用") {
|
||||
t.Fatalf("期望响应包含联系电话冲突提示,实际为 %s", response.Body.String())
|
||||
}
|
||||
assertStaffWriteExpectations(t, mock)
|
||||
}
|
||||
|
||||
// setupStaffWriteDatabase 创建工作人员写入测试使用的 PostgreSQL 模拟连接。
|
||||
// 参数:t 为 Go 测试上下文。
|
||||
// 返回值:SQL 模拟器,测试结束后自动恢复全局数据库连接。
|
||||
func setupStaffWriteDatabase(t *testing.T) sqlmock.Sqlmock {
|
||||
t.Helper()
|
||||
connection, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("创建数据库模拟连接失败:%v", err)
|
||||
}
|
||||
database, err := gorm.Open(postgres.New(postgres.Config{Conn: connection}), &gorm.Config{SkipDefaultTransaction: true})
|
||||
if err != nil {
|
||||
_ = connection.Close()
|
||||
t.Fatalf("创建 GORM 测试连接失败:%v", err)
|
||||
}
|
||||
previous := impl.DBService
|
||||
impl.DBService = database
|
||||
t.Cleanup(func() {
|
||||
impl.DBService = previous
|
||||
_ = connection.Close()
|
||||
})
|
||||
return mock
|
||||
}
|
||||
|
||||
// assertStaffWriteExpectations 验证工作人员写入测试声明的 SQL 均已执行。
|
||||
// 参数:t 为 Go 测试上下文,mock 为 SQL 模拟器。
|
||||
// 返回值:无。
|
||||
func assertStaffWriteExpectations(t *testing.T, mock sqlmock.Sqlmock) {
|
||||
t.Helper()
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("工作人员写入 SQL 未符合预期:%v", err)
|
||||
}
|
||||
}
|
||||
71
backend/api/internal/logic/common/staff_wiring_test.go
Normal file
71
backend/api/internal/logic/common/staff_wiring_test.go
Normal file
@@ -0,0 +1,71 @@
|
||||
// 功能描述:验证三个管理后台的工作人员写入入口均接入公共错误转换链路。
|
||||
// 版本:v1.0
|
||||
package common
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestStaffHandlersUseWriteErrorConversion 防止任一后台重新绕过工作人员错误转换器。
|
||||
// 参数:t 为 Go 测试上下文。
|
||||
// 返回值:无。
|
||||
func TestStaffHandlersUseWriteErrorConversion(t *testing.T) {
|
||||
_, currentFile, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("无法定位工作人员接线测试文件")
|
||||
}
|
||||
logicDirectory := filepath.Dir(filepath.Dir(currentFile))
|
||||
targets := []struct {
|
||||
name string
|
||||
path string
|
||||
}{
|
||||
{name: "平台总后台", path: filepath.Join(logicDirectory, "platform", "staff", "staff.go")},
|
||||
{name: "气站后台", path: filepath.Join(logicDirectory, "gas", "staff.go")},
|
||||
{name: "配送点后台", path: filepath.Join(logicDirectory, "delivery", "staff.go")},
|
||||
}
|
||||
|
||||
for _, target := range targets {
|
||||
t.Run(target.name, func(t *testing.T) {
|
||||
calls := commonCallsInFile(t, target.path)
|
||||
if calls["CreateStaffRecord"] == 0 {
|
||||
t.Fatalf("%s工作人员创建入口未接入 CreateStaffRecord", target.name)
|
||||
}
|
||||
if calls["UpdateAllowedByIdentityWithError"] == 0 {
|
||||
t.Fatalf("%s工作人员编辑入口未接入错误转换更新函数", target.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// commonCallsInFile 统计源文件中调用 common 包函数的次数。
|
||||
// 参数:t 为测试上下文,path 为待检查的 Go 源文件。
|
||||
// 返回值:以函数名为键、调用次数为值的映射。
|
||||
func commonCallsInFile(t *testing.T, path string) map[string]int {
|
||||
t.Helper()
|
||||
file, err := parser.ParseFile(token.NewFileSet(), path, nil, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("解析工作人员处理器失败:%v", err)
|
||||
}
|
||||
calls := map[string]int{}
|
||||
ast.Inspect(file, func(node ast.Node) bool {
|
||||
call, ok := node.(*ast.CallExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
selector, ok := call.Fun.(*ast.SelectorExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
packageName, ok := selector.X.(*ast.Ident)
|
||||
if ok && packageName.Name == "common" {
|
||||
calls[selector.Sel.Name]++
|
||||
}
|
||||
return true
|
||||
})
|
||||
return calls
|
||||
}
|
||||
Reference in New Issue
Block a user