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:
Frudrax Cheng
2026-05-26 18:02:04 +08:00
parent 128bb7cda6
commit 6a48b0624f
4 changed files with 146 additions and 20 deletions
+3 -1
View File
@@ -317,7 +317,9 @@ swag init -g main.go
**售后工单特点**: **售后工单特点**:
- 工单号格式: `zjbf-sh-YYMMNN`(年份后 2 位 + 月份 2 位 + 当月序号至少 2 位,例:`zjbf-sh-260501` - 工单号格式: `zjbf-sh-YYMMNN`(年份后 2 位 + 月份 2 位 + 当月序号至少 2 位,例:`zjbf-sh-260501`
- 序号按月重置,软删除工单不释放编号(避免回收造成混淆) - 序号按月重置,软删除工单不释放编号(避免回收造成混淆)
- 二维码扫码后客户输入手机号后 4 位进行身份校验 - 二维码扫码后客户在网页签名(canvas)后点「已授权」确认;选择「未授权」需填写退回原因
- 签名以 PNG dataURL 形式持久化到工单(`signature` 字段),管理员详情页可查看留底
- 签名校验:必须为 `data:image/png;base64,``data:image/jpeg;base64,` 前缀,解码后 200B500KB
- 客户确认接口每分钟同一工单最多 5 次请求 - 客户确认接口每分钟同一工单最多 5 次请求
- 工单状态机: `created``pending_confirmation``closed` / `rejected`,被退回后可重新提交 - 工单状态机: `created``pending_confirmation``closed` / `rejected`,被退回后可重新提交
- 公开查询不返回手机号(脱敏) - 公开查询不返回手机号(脱敏)
+6 -2
View File
@@ -255,6 +255,7 @@ type AftersalesOrder struct {
ScannedAt *time.Time `json:"scannedAt"` ScannedAt *time.Time `json:"scannedAt"`
ConfirmedAt *time.Time `json:"confirmedAt"` ConfirmedAt *time.Time `json:"confirmedAt"`
RejectCount int `gorm:"default:0" json:"rejectCount"` RejectCount int `gorm:"default:0" json:"rejectCount"`
Signature string `gorm:"type:text" json:"signature,omitempty"`
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"` UpdatedAt time.Time `json:"updatedAt"`
@@ -293,10 +294,12 @@ type SubmitForConfirmationDTO struct {
} }
// CustomerConfirmDTO 客户确认请求 // CustomerConfirmDTO 客户确认请求
// Signature 为客户在网页上手写签名的 base64 PNG dataURL,仅 authorize 时必填
// RejectReason 为客户拒绝的原因,仅 reject 时必填
type CustomerConfirmDTO struct { type CustomerConfirmDTO struct {
PhoneLast4 string `json:"phoneLast4" validate:"required,len=4,numeric"`
Action string `json:"action" validate:"required,oneof=authorize reject"` 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 重新分配技术员请求 // ReassignAftersalesDTO 重新分配技术员请求
@@ -318,4 +321,5 @@ type AftersalesPublicView struct {
TechnicianName string `json:"technicianName"` TechnicianName string `json:"technicianName"`
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
ConfirmedAt *time.Time `json:"confirmedAt"` ConfirmedAt *time.Time `json:"confirmedAt"`
Signature string `json:"signature,omitempty"`
} }
+50 -7
View File
@@ -32,14 +32,54 @@ const (
AuthorizationStatusUnauthorized = "unauthorized" AuthorizationStatusUnauthorized = "unauthorized"
aftersalesSerialPrefix = "zjbf-sh-" 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 次尝试 // 客户确认接口频率限制:每分钟同一工单最多 5 次尝试
var confirmRateLimiter = struct { var confirmRateLimiter = struct {
sync.Mutex sync.Mutex
attempts map[string][]time.Time attempts map[string][]time.Time
}{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 { func checkConfirmRateLimit(serialNumber string) bool {
confirmRateLimiter.Lock() confirmRateLimiter.Lock()
defer confirmRateLimiter.Unlock() defer confirmRateLimiter.Unlock()
@@ -312,6 +352,7 @@ func (s *AftersalesService) PublicQuery(serialNumber string) (*models.Aftersales
AuthorizationStatus: order.AuthorizationStatus, AuthorizationStatus: order.AuthorizationStatus,
CreatedAt: order.CreatedAt, CreatedAt: order.CreatedAt,
ConfirmedAt: order.ConfirmedAt, ConfirmedAt: order.ConfirmedAt,
Signature: order.Signature,
} }
if order.Technician != nil { if order.Technician != nil {
view.TechnicianName = order.Technician.Name view.TechnicianName = order.Technician.Name
@@ -337,23 +378,25 @@ func (s *AftersalesService) CustomerConfirm(serialNumber string, dto models.Cust
return nil, errors.New("当前工单状态不可确认") return nil, errors.New("当前工单状态不可确认")
} }
if len(order.ContactPhone) < 4 || order.ContactPhone[len(order.ContactPhone)-4:] != dto.PhoneLast4 {
return nil, errors.New("手机号校验失败")
}
now := time.Now() now := time.Now()
switch dto.Action { switch dto.Action {
case "authorize": case "authorize":
if err := validateSignature(dto.Signature); err != nil {
return nil, err
}
order.WorkOrderStatus = WorkOrderStatusClosed order.WorkOrderStatus = WorkOrderStatusClosed
order.AuthorizationStatus = AuthorizationStatusAuthorized order.AuthorizationStatus = AuthorizationStatusAuthorized
order.ConfirmedAt = &now order.ConfirmedAt = &now
order.Signature = strings.TrimSpace(dto.Signature)
case "reject": case "reject":
reason := strings.TrimSpace(dto.RejectReason)
if reason == "" {
return nil, errors.New("请填写退回原因")
}
order.WorkOrderStatus = WorkOrderStatusRejected order.WorkOrderStatus = WorkOrderStatusRejected
order.AuthorizationStatus = AuthorizationStatusUnauthorized order.AuthorizationStatus = AuthorizationStatusUnauthorized
order.RejectCount++ order.RejectCount++
if dto.RejectReason != "" { order.ResolutionNote = order.ResolutionNote + "\n\n[客户退回] " + reason
order.ResolutionNote = order.ResolutionNote + "\n\n[客户退回] " + dto.RejectReason
}
default: default:
return nil, errors.New("无效的操作") return nil, errors.New("无效的操作")
} }
+87 -10
View File
@@ -1,6 +1,7 @@
package services package services
import ( import (
"encoding/base64"
"strings" "strings"
"testing" "testing"
@@ -10,6 +11,18 @@ import (
"git.beifan.cn/trace-system/backend-go/models" "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 { func seedTechnician(t *testing.T, username string) models.User {
t.Helper() t.Helper()
user := models.User{ user := models.User{
@@ -167,6 +180,7 @@ func TestAftersalesService_PublicQuery_MasksPhoneAndSetsScannedAt(t *testing.T)
} }
func TestAftersalesService_CustomerConfirm_Authorize(t *testing.T) { func TestAftersalesService_CustomerConfirm_Authorize(t *testing.T) {
confirmTest(t)
owner := seedTechnician(t, "aftersales_auth_owner") owner := seedTechnician(t, "aftersales_auth_owner")
defer database.DB.Unscoped().Delete(&owner) defer database.DB.Unscoped().Delete(&owner)
@@ -179,19 +193,26 @@ func TestAftersalesService_CustomerConfirm_Authorize(t *testing.T) {
ResolutionNote: "done", ResolutionNote: "done",
}, owner) }, owner)
sig := validSignatureFixture()
view, err := svc.CustomerConfirm(order.SerialNumber, models.CustomerConfirmDTO{ view, err := svc.CustomerConfirm(order.SerialNumber, models.CustomerConfirmDTO{
PhoneLast4: "6789", Action: "authorize",
Action: "authorize", Signature: sig,
}) })
assert.NoError(t, err) assert.NoError(t, err)
assert.NotNil(t, view) assert.NotNil(t, view)
assert.Equal(t, WorkOrderStatusClosed, view.WorkOrderStatus) assert.Equal(t, WorkOrderStatusClosed, view.WorkOrderStatus)
assert.Equal(t, AuthorizationStatusAuthorized, view.AuthorizationStatus) assert.Equal(t, AuthorizationStatusAuthorized, view.AuthorizationStatus)
assert.NotNil(t, view.ConfirmedAt) 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) { func TestAftersalesService_CustomerConfirm_AuthorizeRejectsEmptySignature(t *testing.T) {
owner := seedTechnician(t, "aftersales_phone_owner") confirmTest(t)
owner := seedTechnician(t, "aftersales_emptysig_owner")
defer database.DB.Unscoped().Delete(&owner) defer database.DB.Unscoped().Delete(&owner)
order := createOrderFor(t, owner, "13800007777") order := createOrderFor(t, owner, "13800007777")
@@ -204,14 +225,69 @@ func TestAftersalesService_CustomerConfirm_PhoneMismatch(t *testing.T) {
}, owner) }, owner)
_, err := svc.CustomerConfirm(order.SerialNumber, models.CustomerConfirmDTO{ _, err := svc.CustomerConfirm(order.SerialNumber, models.CustomerConfirmDTO{
PhoneLast4: "0000", Action: "authorize",
Action: "authorize", Signature: "",
}) })
assert.Error(t, err) 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) { func TestAftersalesService_CustomerConfirm_RejectIncrementsCount(t *testing.T) {
confirmTest(t)
owner := seedTechnician(t, "aftersales_reject_owner") owner := seedTechnician(t, "aftersales_reject_owner")
defer database.DB.Unscoped().Delete(&owner) defer database.DB.Unscoped().Delete(&owner)
@@ -225,13 +301,13 @@ func TestAftersalesService_CustomerConfirm_RejectIncrementsCount(t *testing.T) {
}, owner) }, owner)
view, err := svc.CustomerConfirm(order.SerialNumber, models.CustomerConfirmDTO{ view, err := svc.CustomerConfirm(order.SerialNumber, models.CustomerConfirmDTO{
PhoneLast4: "8888",
Action: "reject", Action: "reject",
RejectReason: "没修好", RejectReason: "没修好",
}) })
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, WorkOrderStatusRejected, view.WorkOrderStatus) assert.Equal(t, WorkOrderStatusRejected, view.WorkOrderStatus)
assert.Equal(t, AuthorizationStatusUnauthorized, view.AuthorizationStatus) assert.Equal(t, AuthorizationStatusUnauthorized, view.AuthorizationStatus)
assert.Empty(t, view.Signature, "reject 不应该写入签名")
var refreshed models.AftersalesOrder var refreshed models.AftersalesOrder
database.DB.Where("serial_number = ?", order.SerialNumber).First(&refreshed) 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) { func TestAftersalesService_CustomerConfirm_RejectsWrongStatus(t *testing.T) {
confirmTest(t)
owner := seedTechnician(t, "aftersales_wrongstatus_owner") owner := seedTechnician(t, "aftersales_wrongstatus_owner")
defer database.DB.Unscoped().Delete(&owner) defer database.DB.Unscoped().Delete(&owner)
@@ -256,8 +333,8 @@ func TestAftersalesService_CustomerConfirm_RejectsWrongStatus(t *testing.T) {
svc := AftersalesService{} svc := AftersalesService{}
// 未提交客户确认,工单仍是 created,应该拒绝 // 未提交客户确认,工单仍是 created,应该拒绝
_, err := svc.CustomerConfirm(order.SerialNumber, models.CustomerConfirmDTO{ _, err := svc.CustomerConfirm(order.SerialNumber, models.CustomerConfirmDTO{
PhoneLast4: "9999", Action: "authorize",
Action: "authorize", Signature: validSignatureFixture(),
}) })
assert.Error(t, err) assert.Error(t, err)
} }