From 4d79a4917e1eda84c29a39d6bb643cde098d52c0 Mon Sep 17 00:00:00 2001 From: czl231 <3286836406@qq.com> Date: Tue, 11 Aug 2026 13:37:44 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BC=98=E5=8C=96=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E4=BA=BA=E5=91=98=E9=87=8D=E5=A4=8D=E4=BF=A1=E6=81=AF=E6=8F=90?= =?UTF-8?q?=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/api/internal/logic/common/base.go | 11 ++ .../api/internal/logic/common/staff_error.go | 52 +++++++ .../internal/logic/common/staff_error_test.go | 143 ++++++++++++++++++ .../logic/common/staff_wiring_test.go | 71 +++++++++ backend/api/internal/logic/delivery/staff.go | 6 +- backend/api/internal/logic/gas/staff.go | 6 +- .../internal/logic/platform/staff/staff.go | 4 +- .../操作日志_工作人员唯一冲突提示_20260811.md | 67 ++++++++ frontend/platform_admin/package.json | 1 + .../scripts/check-avatar-upload-cache.mjs | 46 ++++++ .../src/views/resource/avatar-upload-cache.ts | 35 +++++ .../src/views/resource/use-resource-avatar.ts | 7 +- 12 files changed, 440 insertions(+), 9 deletions(-) create mode 100644 backend/api/internal/logic/common/staff_error.go create mode 100644 backend/api/internal/logic/common/staff_error_test.go create mode 100644 backend/api/internal/logic/common/staff_wiring_test.go create mode 100644 docs/操作日志_工作人员唯一冲突提示_20260811.md create mode 100644 frontend/platform_admin/scripts/check-avatar-upload-cache.mjs create mode 100644 frontend/platform_admin/src/views/resource/avatar-upload-cache.ts diff --git a/backend/api/internal/logic/common/base.go b/backend/api/internal/logic/common/base.go index 2cb4d52..6cc666c 100644 --- a/backend/api/internal/logic/common/base.go +++ b/backend/api/internal/logic/common/base.go @@ -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 } diff --git a/backend/api/internal/logic/common/staff_error.go b/backend/api/internal/logic/common/staff_error.go new file mode 100644 index 0000000..374ffc6 --- /dev/null +++ b/backend/api/internal/logic/common/staff_error.go @@ -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 +} diff --git a/backend/api/internal/logic/common/staff_error_test.go b/backend/api/internal/logic/common/staff_error_test.go new file mode 100644 index 0000000..02e3ed7 --- /dev/null +++ b/backend/api/internal/logic/common/staff_error_test.go @@ -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) + } +} diff --git a/backend/api/internal/logic/common/staff_wiring_test.go b/backend/api/internal/logic/common/staff_wiring_test.go new file mode 100644 index 0000000..d2922d4 --- /dev/null +++ b/backend/api/internal/logic/common/staff_wiring_test.go @@ -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 +} diff --git a/backend/api/internal/logic/delivery/staff.go b/backend/api/internal/logic/delivery/staff.go index 92e8892..97535a7 100644 --- a/backend/api/internal/logic/delivery/staff.go +++ b/backend/api/internal/logic/delivery/staff.go @@ -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) { diff --git a/backend/api/internal/logic/gas/staff.go b/backend/api/internal/logic/gas/staff.go index f209ebc..4f3965c 100644 --- a/backend/api/internal/logic/gas/staff.go +++ b/backend/api/internal/logic/gas/staff.go @@ -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) { diff --git a/backend/api/internal/logic/platform/staff/staff.go b/backend/api/internal/logic/platform/staff/staff.go index 83aa625..2792588 100644 --- a/backend/api/internal/logic/platform/staff/staff.go +++ b/backend/api/internal/logic/platform/staff/staff.go @@ -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" } diff --git a/docs/操作日志_工作人员唯一冲突提示_20260811.md b/docs/操作日志_工作人员唯一冲突提示_20260811.md new file mode 100644 index 0000000..553f4f7 --- /dev/null +++ b/docs/操作日志_工作人员唯一冲突提示_20260811.md @@ -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资料页实例中缓存;页面卸载、选择新头像或清除头像后立即失效,不跨账户复用。 +- 气站后台和配送点后台当前没有真实头像文件上传能力,本次不为其增加头像缓存或改变现有表单结构。 +- 本次不修改气站与配送点联动、详情唯一标识展示或编辑页冗余详情请求等独立问题。 diff --git a/frontend/platform_admin/package.json b/frontend/platform_admin/package.json index adea587..cc0358f 100644 --- a/frontend/platform_admin/package.json +++ b/frontend/platform_admin/package.json @@ -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 .", diff --git a/frontend/platform_admin/scripts/check-avatar-upload-cache.mjs b/frontend/platform_admin/scripts/check-avatar-upload-cache.mjs new file mode 100644 index 0000000..78256ec --- /dev/null +++ b/frontend/platform_admin/scripts/check-avatar-upload-cache.mjs @@ -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('头像失败重试缓存检查通过'); diff --git a/frontend/platform_admin/src/views/resource/avatar-upload-cache.ts b/frontend/platform_admin/src/views/resource/avatar-upload-cache.ts new file mode 100644 index 0000000..7648c8c --- /dev/null +++ b/frontend/platform_admin/src/views/resource/avatar-upload-cache.ts @@ -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 | 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 }; +} diff --git a/frontend/platform_admin/src/views/resource/use-resource-avatar.ts b/frontend/platform_admin/src/views/resource/use-resource-avatar.ts index aaba183..2acedb2 100644 --- a/frontend/platform_admin/src/views/resource/use-resource-avatar.ts +++ b/frontend/platform_admin/src/views/resource/use-resource-avatar.ts @@ -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(); 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) { - 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 = ''; }