From 6a48b0624fa1a568d660bb56a90579b8e1f2b5b4 Mon Sep 17 00:00:00 2001 From: Frudrax Cheng Date: Tue, 26 May 2026 18:02:04 +0800 Subject: [PATCH] Replace phone last-4 verification with customer signature Customer now signs on the confirm page instead of inputting the last 4 digits of their phone. Signature is stored as a base64 PNG dataURL on the work order and shown back to the customer plus archived for admin review. Reject still bypasses signature but now requires a reason. Co-Authored-By: Claude Opus 4.7 --- README.md | 4 +- models/models.go | 8 ++- services/aftersales_service.go | 57 ++++++++++++++--- services/aftersales_service_test.go | 97 ++++++++++++++++++++++++++--- 4 files changed, 146 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 1ed4dd5..ea18b7a 100644 --- a/README.md +++ b/README.md @@ -317,7 +317,9 @@ swag init -g main.go **售后工单特点**: - 工单号格式: `zjbf-sh-YYMMNN`(年份后 2 位 + 月份 2 位 + 当月序号至少 2 位,例:`zjbf-sh-260501`) - 序号按月重置,软删除工单不释放编号(避免回收造成混淆) -- 二维码扫码后客户输入手机号后 4 位进行身份校验 +- 二维码扫码后客户在网页签名(canvas)后点「已授权」确认;选择「未授权」需填写退回原因 +- 签名以 PNG dataURL 形式持久化到工单(`signature` 字段),管理员详情页可查看留底 +- 签名校验:必须为 `data:image/png;base64,` 或 `data:image/jpeg;base64,` 前缀,解码后 200B–500KB - 客户确认接口每分钟同一工单最多 5 次请求 - 工单状态机: `created` → `pending_confirmation` → `closed` / `rejected`,被退回后可重新提交 - 公开查询不返回手机号(脱敏) diff --git a/models/models.go b/models/models.go index 09d3d86..46f79e1 100644 --- a/models/models.go +++ b/models/models.go @@ -255,6 +255,7 @@ type AftersalesOrder struct { ScannedAt *time.Time `json:"scannedAt"` ConfirmedAt *time.Time `json:"confirmedAt"` RejectCount int `gorm:"default:0" json:"rejectCount"` + Signature string `gorm:"type:text" json:"signature,omitempty"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` @@ -293,10 +294,12 @@ type SubmitForConfirmationDTO struct { } // CustomerConfirmDTO 客户确认请求 +// Signature 为客户在网页上手写签名的 base64 PNG dataURL,仅 authorize 时必填 +// RejectReason 为客户拒绝的原因,仅 reject 时必填 type CustomerConfirmDTO struct { - PhoneLast4 string `json:"phoneLast4" validate:"required,len=4,numeric"` Action string `json:"action" validate:"required,oneof=authorize reject"` - RejectReason string `json:"rejectReason,omitempty"` + Signature string `json:"signature,omitempty" validate:"required_if=Action authorize"` + RejectReason string `json:"rejectReason,omitempty" validate:"required_if=Action reject"` } // ReassignAftersalesDTO 重新分配技术员请求 @@ -318,4 +321,5 @@ type AftersalesPublicView struct { TechnicianName string `json:"technicianName"` CreatedAt time.Time `json:"createdAt"` ConfirmedAt *time.Time `json:"confirmedAt"` + Signature string `json:"signature,omitempty"` } diff --git a/services/aftersales_service.go b/services/aftersales_service.go index b6e465d..0481680 100644 --- a/services/aftersales_service.go +++ b/services/aftersales_service.go @@ -32,14 +32,54 @@ const ( AuthorizationStatusUnauthorized = "unauthorized" aftersalesSerialPrefix = "zjbf-sh-" + + // 签名 base64 解码后的大小限制 + signatureMinBytes = 200 + signatureMaxBytes = 500 * 1024 ) +// validateSignature 校验客户签名 dataURL 是否合法 +// 接受 data:image/png;base64,... 或 data:image/jpeg;base64,... 形式 +func validateSignature(s string) error { + s = strings.TrimSpace(s) + if s == "" { + return errors.New("签名不能为空") + } + var payload string + switch { + case strings.HasPrefix(s, "data:image/png;base64,"): + payload = strings.TrimPrefix(s, "data:image/png;base64,") + case strings.HasPrefix(s, "data:image/jpeg;base64,"): + payload = strings.TrimPrefix(s, "data:image/jpeg;base64,") + default: + return errors.New("签名格式不合法") + } + decoded, err := base64.StdEncoding.DecodeString(payload) + if err != nil { + return errors.New("签名内容解码失败") + } + if len(decoded) < signatureMinBytes { + return errors.New("签名内容过短,请重新签名") + } + if len(decoded) > signatureMaxBytes { + return errors.New("签名内容过大,请精简后重试") + } + return nil +} + // 客户确认接口频率限制:每分钟同一工单最多 5 次尝试 var confirmRateLimiter = struct { sync.Mutex attempts map[string][]time.Time }{attempts: map[string][]time.Time{}} +// ResetConfirmRateLimit 清空确认限流器(仅用于测试) +func ResetConfirmRateLimit() { + confirmRateLimiter.Lock() + defer confirmRateLimiter.Unlock() + confirmRateLimiter.attempts = map[string][]time.Time{} +} + func checkConfirmRateLimit(serialNumber string) bool { confirmRateLimiter.Lock() defer confirmRateLimiter.Unlock() @@ -312,6 +352,7 @@ func (s *AftersalesService) PublicQuery(serialNumber string) (*models.Aftersales AuthorizationStatus: order.AuthorizationStatus, CreatedAt: order.CreatedAt, ConfirmedAt: order.ConfirmedAt, + Signature: order.Signature, } if order.Technician != nil { view.TechnicianName = order.Technician.Name @@ -337,23 +378,25 @@ func (s *AftersalesService) CustomerConfirm(serialNumber string, dto models.Cust return nil, errors.New("当前工单状态不可确认") } - if len(order.ContactPhone) < 4 || order.ContactPhone[len(order.ContactPhone)-4:] != dto.PhoneLast4 { - return nil, errors.New("手机号校验失败") - } - now := time.Now() switch dto.Action { case "authorize": + if err := validateSignature(dto.Signature); err != nil { + return nil, err + } order.WorkOrderStatus = WorkOrderStatusClosed order.AuthorizationStatus = AuthorizationStatusAuthorized order.ConfirmedAt = &now + order.Signature = strings.TrimSpace(dto.Signature) case "reject": + reason := strings.TrimSpace(dto.RejectReason) + if reason == "" { + return nil, errors.New("请填写退回原因") + } order.WorkOrderStatus = WorkOrderStatusRejected order.AuthorizationStatus = AuthorizationStatusUnauthorized order.RejectCount++ - if dto.RejectReason != "" { - order.ResolutionNote = order.ResolutionNote + "\n\n[客户退回] " + dto.RejectReason - } + order.ResolutionNote = order.ResolutionNote + "\n\n[客户退回] " + reason default: return nil, errors.New("无效的操作") } diff --git a/services/aftersales_service_test.go b/services/aftersales_service_test.go index 9cdef92..b965314 100644 --- a/services/aftersales_service_test.go +++ b/services/aftersales_service_test.go @@ -1,6 +1,7 @@ package services import ( + "encoding/base64" "strings" "testing" @@ -10,6 +11,18 @@ import ( "git.beifan.cn/trace-system/backend-go/models" ) +// validSignatureFixture 构造一个合法的签名 dataURL(>200 字节) +func validSignatureFixture() string { + payload := strings.Repeat("a", 400) + return "data:image/png;base64," + base64.StdEncoding.EncodeToString([]byte(payload)) +} + +// confirmTest 为客户确认相关测试做准备:清空限流计数(多个测试可能共享同一序列号) +func confirmTest(t *testing.T) { + t.Helper() + ResetConfirmRateLimit() +} + func seedTechnician(t *testing.T, username string) models.User { t.Helper() user := models.User{ @@ -167,6 +180,7 @@ func TestAftersalesService_PublicQuery_MasksPhoneAndSetsScannedAt(t *testing.T) } func TestAftersalesService_CustomerConfirm_Authorize(t *testing.T) { + confirmTest(t) owner := seedTechnician(t, "aftersales_auth_owner") defer database.DB.Unscoped().Delete(&owner) @@ -179,19 +193,26 @@ func TestAftersalesService_CustomerConfirm_Authorize(t *testing.T) { ResolutionNote: "done", }, owner) + sig := validSignatureFixture() view, err := svc.CustomerConfirm(order.SerialNumber, models.CustomerConfirmDTO{ - PhoneLast4: "6789", - Action: "authorize", + Action: "authorize", + Signature: sig, }) assert.NoError(t, err) assert.NotNil(t, view) assert.Equal(t, WorkOrderStatusClosed, view.WorkOrderStatus) assert.Equal(t, AuthorizationStatusAuthorized, view.AuthorizationStatus) assert.NotNil(t, view.ConfirmedAt) + assert.Equal(t, sig, view.Signature) + + var refreshed models.AftersalesOrder + database.DB.Where("serial_number = ?", order.SerialNumber).First(&refreshed) + assert.Equal(t, sig, refreshed.Signature) } -func TestAftersalesService_CustomerConfirm_PhoneMismatch(t *testing.T) { - owner := seedTechnician(t, "aftersales_phone_owner") +func TestAftersalesService_CustomerConfirm_AuthorizeRejectsEmptySignature(t *testing.T) { + confirmTest(t) + owner := seedTechnician(t, "aftersales_emptysig_owner") defer database.DB.Unscoped().Delete(&owner) order := createOrderFor(t, owner, "13800007777") @@ -204,14 +225,69 @@ func TestAftersalesService_CustomerConfirm_PhoneMismatch(t *testing.T) { }, owner) _, err := svc.CustomerConfirm(order.SerialNumber, models.CustomerConfirmDTO{ - PhoneLast4: "0000", - Action: "authorize", + Action: "authorize", + Signature: "", }) assert.Error(t, err) - assert.Contains(t, err.Error(), "手机号校验失败") + assert.Contains(t, err.Error(), "签名") +} + +func TestAftersalesService_CustomerConfirm_AuthorizeRejectsInvalidSignature(t *testing.T) { + confirmTest(t) + owner := seedTechnician(t, "aftersales_badsig_owner") + defer database.DB.Unscoped().Delete(&owner) + + order := createOrderFor(t, owner, "13800007788") + defer database.DB.Unscoped().Delete(order) + defer database.DB.Unscoped().Where("company_name = ?", order.CompanyName).Delete(&models.Company{}) + + svc := AftersalesService{} + _, _ = svc.SubmitForConfirmation(order.SerialNumber, models.SubmitForConfirmationDTO{ + ResolutionNote: "done", + }, owner) + + // 非 dataURL 格式 + _, err := svc.CustomerConfirm(order.SerialNumber, models.CustomerConfirmDTO{ + Action: "authorize", + Signature: "not-a-data-url", + }) + assert.Error(t, err) + assert.Contains(t, err.Error(), "签名格式") + + // 太短 + tiny := "data:image/png;base64," + base64.StdEncoding.EncodeToString([]byte("xx")) + _, err = svc.CustomerConfirm(order.SerialNumber, models.CustomerConfirmDTO{ + Action: "authorize", + Signature: tiny, + }) + assert.Error(t, err) + assert.Contains(t, err.Error(), "过短") +} + +func TestAftersalesService_CustomerConfirm_RejectRequiresReason(t *testing.T) { + confirmTest(t) + owner := seedTechnician(t, "aftersales_rejnoreason_owner") + defer database.DB.Unscoped().Delete(&owner) + + order := createOrderFor(t, owner, "13800008877") + defer database.DB.Unscoped().Delete(order) + defer database.DB.Unscoped().Where("company_name = ?", order.CompanyName).Delete(&models.Company{}) + + svc := AftersalesService{} + _, _ = svc.SubmitForConfirmation(order.SerialNumber, models.SubmitForConfirmationDTO{ + ResolutionNote: "done", + }, owner) + + _, err := svc.CustomerConfirm(order.SerialNumber, models.CustomerConfirmDTO{ + Action: "reject", + RejectReason: "", + }) + assert.Error(t, err) + assert.Contains(t, err.Error(), "退回原因") } func TestAftersalesService_CustomerConfirm_RejectIncrementsCount(t *testing.T) { + confirmTest(t) owner := seedTechnician(t, "aftersales_reject_owner") defer database.DB.Unscoped().Delete(&owner) @@ -225,13 +301,13 @@ func TestAftersalesService_CustomerConfirm_RejectIncrementsCount(t *testing.T) { }, owner) view, err := svc.CustomerConfirm(order.SerialNumber, models.CustomerConfirmDTO{ - PhoneLast4: "8888", Action: "reject", RejectReason: "没修好", }) assert.NoError(t, err) assert.Equal(t, WorkOrderStatusRejected, view.WorkOrderStatus) assert.Equal(t, AuthorizationStatusUnauthorized, view.AuthorizationStatus) + assert.Empty(t, view.Signature, "reject 不应该写入签名") var refreshed models.AftersalesOrder database.DB.Where("serial_number = ?", order.SerialNumber).First(&refreshed) @@ -246,6 +322,7 @@ func TestAftersalesService_CustomerConfirm_RejectIncrementsCount(t *testing.T) { } func TestAftersalesService_CustomerConfirm_RejectsWrongStatus(t *testing.T) { + confirmTest(t) owner := seedTechnician(t, "aftersales_wrongstatus_owner") defer database.DB.Unscoped().Delete(&owner) @@ -256,8 +333,8 @@ func TestAftersalesService_CustomerConfirm_RejectsWrongStatus(t *testing.T) { svc := AftersalesService{} // 未提交客户确认,工单仍是 created,应该拒绝 _, err := svc.CustomerConfirm(order.SerialNumber, models.CustomerConfirmDTO{ - PhoneLast4: "9999", - Action: "authorize", + Action: "authorize", + Signature: validSignatureFixture(), }) assert.Error(t, err) }