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
|
||||
}
|
||||
@@ -71,7 +71,7 @@ func CreateStaff(ctx *gin.Context) {
|
||||
Name: request.Name, Phone: request.Phone, Avatar: request.Avatar, RoleCode: "delivery",
|
||||
GasBasicID: point.GasBasicID, DeliveryBasicID: point.ID, WorkStatus: request.WorkStatus,
|
||||
}
|
||||
if err := db().Create(&staff).Error; err != nil {
|
||||
if err := common.CreateStaffRecord(&staff); err != nil {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
@@ -92,9 +92,9 @@ func UpdateStaff(ctx *gin.Context) {
|
||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||
return
|
||||
}
|
||||
common.UpdateAllowedByIdentity(ctx, &models.StaffAccount{}, gin.H{
|
||||
common.UpdateAllowedByIdentityWithError(ctx, &models.StaffAccount{}, gin.H{
|
||||
"name": request.Name, "phone": request.Phone, "avatar": request.Avatar, "work_status": request.WorkStatus,
|
||||
}, []string{"name", "phone", "avatar", "work_status"})
|
||||
}, []string{"name", "phone", "avatar", "work_status"}, common.StaffWriteError)
|
||||
}
|
||||
|
||||
func ResetStaffPassword(ctx *gin.Context) {
|
||||
|
||||
@@ -92,7 +92,7 @@ func CreateStaff(ctx *gin.Context) {
|
||||
Name: request.Name, Phone: request.Phone, Avatar: request.Avatar, RoleCode: request.RoleCode,
|
||||
GasBasicID: station.ID, DeliveryBasicID: deliveryID, WorkStatus: request.WorkStatus,
|
||||
}
|
||||
if err := impl.DBService.Create(&staff).Error; err != nil {
|
||||
if err := common.CreateStaffRecord(&staff); err != nil {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
@@ -120,10 +120,10 @@ func UpdateStaff(ctx *gin.Context) {
|
||||
}
|
||||
deliveryID = delivery.ID
|
||||
}
|
||||
common.UpdateAllowedByIdentity(ctx, &models.StaffAccount{}, gin.H{
|
||||
common.UpdateAllowedByIdentityWithError(ctx, &models.StaffAccount{}, gin.H{
|
||||
"name": request.Name, "phone": request.Phone, "avatar": request.Avatar, "role_code": request.RoleCode,
|
||||
"delivery_basic_id": deliveryID, "work_status": request.WorkStatus,
|
||||
}, []string{"name", "phone", "avatar", "role_code", "delivery_basic_id", "work_status"})
|
||||
}, []string{"name", "phone", "avatar", "role_code", "delivery_basic_id", "work_status"}, common.StaffWriteError)
|
||||
}
|
||||
|
||||
func UpdateStaffStatus(ctx *gin.Context) {
|
||||
|
||||
@@ -104,7 +104,7 @@ func CreateStaff(ctx *gin.Context) {
|
||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||
return
|
||||
}
|
||||
if err := impl.DBService.Create(&staff).Error; err != nil {
|
||||
if err := common.CreateStaffRecord(&staff); err != nil {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
@@ -145,7 +145,7 @@ func UpdateStaff(ctx *gin.Context) {
|
||||
if request.Avatar != nil {
|
||||
values["avatar"] = *request.Avatar
|
||||
}
|
||||
common.UpdateAllowedByIdentity(ctx, &models.StaffAccount{}, values, []string{"name", "phone", "avatar", "role_code", "gas_basic_id", "delivery_basic_id", "work_status"})
|
||||
common.UpdateAllowedByIdentityWithError(ctx, &models.StaffAccount{}, values, []string{"name", "phone", "avatar", "role_code", "gas_basic_id", "delivery_basic_id", "work_status"}, common.StaffWriteError)
|
||||
}
|
||||
|
||||
func validWorkStatus(status string) bool { return status == "on_duty" || status == "off_duty" }
|
||||
|
||||
67
docs/操作日志_工作人员唯一冲突提示_20260811.md
Normal file
67
docs/操作日志_工作人员唯一冲突提示_20260811.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# 工作人员唯一冲突提示操作日志
|
||||
|
||||
操作时间:2026-08-11 13:29:44
|
||||
操作类型:修改
|
||||
影响模块:平台总后台、气站后台、配送点后台工作人员新增与编辑
|
||||
|
||||
## 操作前状态
|
||||
|
||||
- `staff_account` 的用户名和非空联系电话分别由数据库唯一索引保护,但三个后台的工作人员写入入口都会把 PostgreSQL 原始错误直接返回前端。
|
||||
- 用户遇到重复数据时会看到约束名、SQLSTATE 等数据库实现细节,无法判断需要修改用户名还是联系电话。
|
||||
- 保存失败后各前端会保留现有表单;5173平台后台若已上传头像,重新保存时仍会再次上传同一文件,可能产生重复或孤立资源。
|
||||
- 公共 `UpdateAllowedByIdentity` 没有为特定资源转换数据库错误的扩展点。
|
||||
|
||||
## 具体操作
|
||||
|
||||
- 新增工作人员写入错误转换器:
|
||||
- 精确识别 `idx_staff_account_username`,返回“该用户名已存在”。
|
||||
- 精确识别 `idx_staff_account_phone`,返回“该联系电话已被其他工作人员使用”。
|
||||
- 未识别数据库错误只写服务端日志,客户端统一收到“保存失败,请稍后重试”。
|
||||
- 新增公共工作人员创建函数,让平台、气站、配送点三个入口统一执行错误转换。
|
||||
- 以向下兼容方式扩展公共白名单更新函数:原函数签名和行为保持不变,工作人员编辑入口使用带错误转换器的新函数。
|
||||
- 三个后台的工作人员创建均接入用户名和联系电话冲突转换;三个后台的工作人员编辑均接入联系电话冲突转换。
|
||||
- 新增5173头像上传结果缓存:
|
||||
- 头像上传成功但资源保存失败时,下一次保存复用同一受控 URI。
|
||||
- 选择新文件、清除头像或重新加载资料时清空缓存。
|
||||
- 头像上传本身失败时自动清空缓存,允许再次上传。
|
||||
- 新增头像失败重试行为检查脚本,并加入平台后台脚本命令。
|
||||
|
||||
## 操作后状态
|
||||
|
||||
- 三个后台均不再向工作人员表单暴露数据库表名、索引名或 SQLSTATE。
|
||||
- 用户名重复只在新建时返回明确提示;联系电话重复在新建和编辑时均返回明确提示。
|
||||
- 联系电话继续允许留空,非空值保持全表唯一;已归档工作人员继续占用用户名和非空联系电话。
|
||||
- 保存失败后表单、下拉选择和头像预览保持不变;5173再次保存不会重复上传已经成功上传的头像。
|
||||
- 现有 HTTP 200 加 JSON 业务错误结构保持不变,没有新增保存前查重接口或数据库迁移。
|
||||
|
||||
## 代码变更
|
||||
|
||||
- `backend/api/internal/logic/common/staff_error.go`:新增 `CreateStaffRecord` 和 `StaffWriteError`,统一处理创建写入、唯一冲突与未知错误脱敏。
|
||||
- `backend/api/internal/logic/common/staff_error_test.go`:覆盖已知冲突、包装错误、未知错误脱敏、创建和编辑写入路径。
|
||||
- `backend/api/internal/logic/common/staff_wiring_test.go`:验证三个后台的创建和编辑入口均接入公共错误转换链路。
|
||||
- `backend/api/internal/logic/common/base.go`:新增 `UpdateAllowedByIdentityWithError`,原 `UpdateAllowedByIdentity` 保持兼容。
|
||||
- `backend/api/internal/logic/platform/staff/staff.go`:接入平台工作人员创建和编辑错误转换。
|
||||
- `backend/api/internal/logic/gas/staff.go`:接入气站工作人员创建和编辑错误转换。
|
||||
- `backend/api/internal/logic/delivery/staff.go`:接入配送点工作人员创建和编辑错误转换。
|
||||
- `frontend/platform_admin/src/views/resource/avatar-upload-cache.ts`:新增单次保存流程头像 URI 缓存。
|
||||
- `frontend/platform_admin/src/views/resource/use-resource-avatar.ts`:接入头像上传缓存及失效规则。
|
||||
- `frontend/platform_admin/scripts/check-avatar-upload-cache.mjs`、`package.json`:新增头像失败重试行为检查命令。
|
||||
|
||||
## 验证结果
|
||||
|
||||
- `go test ./internal/logic/common`:通过,工作人员冲突转换、未知错误脱敏、创建和编辑路径均通过。
|
||||
- `go test ./internal/logic/platform/staff ./internal/logic/gas ./internal/logic/delivery`:通过,三个工作人员处理器编译与既有测试正常。
|
||||
- `go test ./...`:通过,后端完整回归测试正常。
|
||||
- `npm.cmd run avatar-retry:check`:通过,同一头像保存重试仅上传一次,重置与上传失败后可以再次上传。
|
||||
- `npm.cmd run type:check`:通过。
|
||||
- `npm.cmd run resource-pages:check`:通过,详情46类、新建25类、编辑23类。
|
||||
- `npm.cmd run build`:通过,Vite生产构建完成。
|
||||
- `git diff --check`:通过,未发现空白符错误。
|
||||
|
||||
## 风险评估
|
||||
|
||||
- 唯一冲突识别依赖当前 PostgreSQL 约束名;模型继续沿用现有索引定义时行为稳定,相关常量和测试可防止无意改名。
|
||||
- 未识别工作人员写入错误会对客户端脱敏,但原始错误仍写入服务端日志,便于定位数据库连接或约束异常。
|
||||
- 头像 URI只在当前5173资料页实例中缓存;页面卸载、选择新头像或清除头像后立即失效,不跨账户复用。
|
||||
- 气站后台和配送点后台当前没有真实头像文件上传能力,本次不为其增加头像缓存或改变现有表单结构。
|
||||
- 本次不修改气站与配送点联动、详情唯一标识展示或编辑页冗余详情请求等独立问题。
|
||||
@@ -15,6 +15,7 @@
|
||||
"contract:check": "node scripts/check-backend-contract.mjs",
|
||||
"resource-pages:check": "node scripts/check-resource-pages.mjs",
|
||||
"account-roles:check": "node scripts/check-account-role-presentation.mjs",
|
||||
"avatar-retry:check": "node scripts/check-avatar-upload-cache.mjs",
|
||||
"audit:platform": "node scripts/check-backend-contract.mjs",
|
||||
"lint": "biome lint .",
|
||||
"lint:fix": "biome lint --write .",
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* 功能:验证头像上传成功后的保存重试会复用受控资源地址。
|
||||
* 版本:v1.0.0
|
||||
*/
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { transformWithOxc } from 'vite';
|
||||
|
||||
const sourceURL = new URL(
|
||||
'../src/views/resource/avatar-upload-cache.ts',
|
||||
import.meta.url,
|
||||
);
|
||||
const source = await readFile(sourceURL, 'utf8');
|
||||
const transformed = await transformWithOxc(source, sourceURL.pathname);
|
||||
const moduleURL = `data:text/javascript;base64,${Buffer.from(transformed.code).toString('base64')}`;
|
||||
const { createAvatarUploadCache } = await import(moduleURL);
|
||||
|
||||
let uploadCount = 0;
|
||||
const cache = createAvatarUploadCache(async () => {
|
||||
uploadCount += 1;
|
||||
return { uri: `/uploads/avatars/test-${uploadCount}.png` };
|
||||
});
|
||||
const file = { name: 'avatar.png', size: 128, lastModified: 1 };
|
||||
|
||||
const firstURI = await cache.resolve(file);
|
||||
const retryURI = await cache.resolve(file);
|
||||
assert.equal(firstURI, '/uploads/avatars/test-1.png');
|
||||
assert.equal(retryURI, firstURI);
|
||||
assert.equal(uploadCount, 1, '保存失败后重试不应重复上传同一头像');
|
||||
|
||||
cache.reset();
|
||||
const replacedURI = await cache.resolve(file);
|
||||
assert.equal(replacedURI, '/uploads/avatars/test-2.png');
|
||||
assert.equal(uploadCount, 2, '重置缓存后应重新上传头像');
|
||||
|
||||
let failedCount = 0;
|
||||
const retryableCache = createAvatarUploadCache(async () => {
|
||||
failedCount += 1;
|
||||
if (failedCount === 1) throw new Error('临时上传失败');
|
||||
return { uri: '/uploads/avatars/retry.png' };
|
||||
});
|
||||
await assert.rejects(() => retryableCache.resolve(file), /临时上传失败/);
|
||||
assert.equal(await retryableCache.resolve(file), '/uploads/avatars/retry.png');
|
||||
assert.equal(failedCount, 2, '上传失败后应允许重新上传');
|
||||
|
||||
console.log('头像失败重试缓存检查通过');
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* 功能:缓存一次资源保存流程中已经完成的头像上传结果。
|
||||
* 版本:v1.0.0
|
||||
*/
|
||||
|
||||
export type AvatarUpload = (file: File) => Promise<{ uri: string }>;
|
||||
|
||||
/** 创建头像上传缓存;选择新文件或清除头像时由调用方主动重置。 */
|
||||
export function createAvatarUploadCache(upload: AvatarUpload) {
|
||||
let cachedFile: File | undefined;
|
||||
let cachedURI: Promise<string> | undefined;
|
||||
|
||||
/** 返回当前文件的受控资源地址;同一文件重试时复用首次成功结果。 */
|
||||
async function resolve(file: File) {
|
||||
if (cachedFile !== file || !cachedURI) {
|
||||
cachedFile = file;
|
||||
cachedURI = upload(file)
|
||||
.then((result) => result.uri)
|
||||
.catch((error) => {
|
||||
// 上传本身失败时清空缓存,允许用户直接重试。
|
||||
if (cachedFile === file) cachedURI = undefined;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
return cachedURI;
|
||||
}
|
||||
|
||||
/** 清空已上传地址,确保新选择的文件不会错误复用旧头像。 */
|
||||
function reset() {
|
||||
cachedFile = undefined;
|
||||
cachedURI = undefined;
|
||||
}
|
||||
|
||||
return { resolve, reset };
|
||||
}
|
||||
@@ -6,12 +6,14 @@ import { Message } from '@arco-design/web-vue';
|
||||
import { onBeforeUnmount, ref } from 'vue';
|
||||
import { avatarApi } from '@/api/avatar';
|
||||
import { DEFAULT_USER_AVATAR } from '@/constants/avatar';
|
||||
import { createAvatarUploadCache } from './avatar-upload-cache';
|
||||
|
||||
export function useResourceAvatar() {
|
||||
const url = ref(DEFAULT_USER_AVATAR);
|
||||
const file = ref<File>();
|
||||
const cleared = ref(false);
|
||||
const canClear = ref(false);
|
||||
const uploadCache = createAvatarUploadCache(avatarApi.upload);
|
||||
let objectURL = '';
|
||||
|
||||
function revoke() {
|
||||
@@ -22,6 +24,7 @@ export function useResourceAvatar() {
|
||||
/** 加载现有受保护头像,记录没有头像时继续使用默认图。 */
|
||||
async function load(resource: string, identity: string) {
|
||||
revoke();
|
||||
uploadCache.reset();
|
||||
url.value = DEFAULT_USER_AVATAR;
|
||||
canClear.value = false;
|
||||
file.value = undefined;
|
||||
@@ -45,6 +48,7 @@ export function useResourceAvatar() {
|
||||
return;
|
||||
}
|
||||
revoke();
|
||||
uploadCache.reset();
|
||||
objectURL = URL.createObjectURL(next);
|
||||
url.value = objectURL;
|
||||
file.value = next;
|
||||
@@ -55,6 +59,7 @@ export function useResourceAvatar() {
|
||||
/** 标记清除头像,真正写入空值发生在保存资料时。 */
|
||||
function clear() {
|
||||
revoke();
|
||||
uploadCache.reset();
|
||||
url.value = DEFAULT_USER_AVATAR;
|
||||
file.value = undefined;
|
||||
cleared.value = true;
|
||||
@@ -63,7 +68,7 @@ export function useResourceAvatar() {
|
||||
|
||||
/** 将头像变化写入资源更新载荷。 */
|
||||
async function applyToPayload(payload: Record<string, unknown>) {
|
||||
if (file.value) payload.avatar = (await avatarApi.upload(file.value)).uri;
|
||||
if (file.value) payload.avatar = await uploadCache.resolve(file.value);
|
||||
else if (cleared.value) payload.avatar = '';
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user