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
+87 -10
View File
@@ -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)
}