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
+50 -7
View File
@@ -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("无效的操作")
}