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 <noreply@anthropic.com>
This commit is contained in:
@@ -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("无效的操作")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user