fix: 优化工作人员重复信息提示

This commit is contained in:
czl231
2026-08-11 13:37:44 +08:00
parent 5738a43f3e
commit 4d79a4917e
12 changed files with 440 additions and 9 deletions

View File

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

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

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

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