如何顯示正確的錯誤訊息

有的時候雖然有做驗證, 但你會看到這樣的錯誤訊息

1
2
3
4
5
6
7
8
{
"timestamp": "2020-07-06T00:55:52.132+00:00",
"status": 500,
"error": "Internal Server Error",
"exception": "java.lang.IllegalAccessError",
"message": "未知錯誤",
"path": "/api/v1/marketingActivity/prizeWheel"
}

這樣真的很不明確, 前端工程師必須一個一個去檢查到底是哪個值有問題, 久而久之你就會看到前後端大大們在門口定孤枝XD

首先先看 Controller

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@ResponseStatus(HttpStatus.CREATED)
@PostMapping(value = "/v1/marketingActivity/prizeWheel", consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
public void create(
@ApiParam(name = "CreateDto", value = "建档用物件", required = true)
@Valid @RequestBody ActivitiesPrizeWheelCreateDto activitiesPrizeWheelCreateDto, BindingResult bindingResult) throws BindException {
activitiesPrizeWheelCreateValidation.validate(activitiesPrizeWheelCreateDto, bindingResult);
if (bindingResult.hasErrors()) {
throw new BindException(bindingResult);
} else {
activitiesPrizeWheelCreateValidation.validate(activitiesPrizeWheelCreateDto, bindingResult);
if (bindingResult.hasErrors()) {
throw new BindException(bindingResult);
}
}
}

其實我每支 API 都長得很像這樣, 首先先對 ActivitiesPrizeWheelCreateDto 加上 @Valid 啟動驗證
來看看 ActivitiesPrizeWheelCreateDto 內容長這樣

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Data
@ApiModel(description = "营销活动大转盘建档")
public class ActivitiesPrizeWheelCreateDto{
@Valid
@NotNull(message = "基础设置 为必填")
@ApiModelProperty(value = "基础设置", required = true)
private MarketingActivitiesCreateBaseSetting baseSetting;
@Valid
@NotNull(message = "派奖设置 为必填")
@ApiModelProperty(value = "派奖设置", required = true)
private MarketingActivitiesCreateTargetSetting targetSetting;
@Valid
@NotNull(message = "奖品设置 为必填")
@Size(min = 6, max = 12, message = "奖品 数量 需介于 {min} 到 {max} 之间")
@ApiModelProperty(value = "奖品设置", required = true)
private List<PrizeWheelSlotCreateDto> prizeSetting;
}

如果是封裝過的物件必須再加上 @Valid ,這邊有用到 @NotNull @Size

再來我們看 MarketingActivitiesCreateBaseSetting

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class MarketingActivitiesCreateBaseSetting implements Serializable {
private static final long serialVersionUID = 1L;

@NotBlank(message = "活动主题 为必填")
@Length(max = 10, message = "活动主题 长度 不能超过 {max}")
@ApiModelProperty(value = "活动主题", required = true, example = "破天荒 超值 超强 仅此一档")
private String subject;
@NotNull(message = "访问限制 为必填")
@Range(min = 1, max = 2, message = "访问限制 值 需介于 {min} 到 {max} 之间")
@ApiModelProperty(value = "访问限制: 1 不限 2 仅微信可访问", required = true, example = "1")
private Integer accessLimit;
@NotNull(message = "活动开始时间 为必填")
@Future(message = "活动开始时间 不可小于现在")
@ApiModelProperty(value = "活动时间开始", required = true, example = "2017-11-01 00:00:00")
private Date startAt;
@NotNull(message = "活动结束时间 为必填")
@Future(message = "活动结束时间 不可小于现在")
@ApiModelProperty(value = "活动时间结束", required = true, example = "2017-12-31 23:59:59")
private Date endAt;
}

這邊有用到 @NotBlank @Length @Range @Future

再來看獎品部分 PrizeWheelSlotCreateDto

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class PrizeWheelSlotCreateDto implements Serializable {
private static final long serialVersionUID = 1L;

@NotNull(message = "奖品类型 为必填")
@Range(min = 1, max = 4, message = "奖品类型 值 需介于 {min} 到 {max} 之间")
@ApiModelProperty(value = "奖品类型: 1 实物类 2 电子券类 3 积分类 4 无奖类", required = true, example = "1")
private Integer type;
@NotNull(message = "奖品名称 为必填")
@NotBlank(message = "奖品名称 为必填")
@Length(min = 1, max = 100, message = "奖品名称 长度 需介于 {min} 到 {max} 之间")
@ApiModelProperty(value = "奖品名称", required = true, example = "程序員鼓勵師", notes = "")
private String name;
@NotNull(message = "奖品图片URL 为必填")
@NotBlank(message = "奖品图片URL 为必填")
@Length(max = 300, message = "奖品图片URL 长度 不能超过 {max}")
@Pattern(regexp = "[a-zA-z]+://[^\\s]*", message = "奖品图片URL 格式不正确")
@ApiModelProperty(value = "奖品图片URL", required = true, example = "http://dl.stickershop.line.naver.jp/products/0/0/2/2801/android/stickers/638803_key.png", notes = "")
private String image;
@NotNull(message = "數量 为必填")
@Range(min = 0, max = 5000, message = "數量 值 需介于 {min} 到 {max} 之间")
@ApiModelProperty(value = "數量", required = true, example = "1", notes = "")
private Integer quantity;
@NotNull(message = "中奖概率为必填")
@Range(min = 0, max = 100000, message = "中奖概率 值 需介于 {min} 到 {max} 之间")
@ApiModelProperty(value = "中奖概率, 分母为 100000", required = true, example = "10000", allowableValues = "range[0, 100000]", notes = "中奖概率, 分母为 100000")
private Integer probability;
}

這邊有用到 @Pattern

以上做完 你就有一個簡單欄位檢驗功能, 但有很多時候需求是 填 A 就必填 B 這類邏輯檢查, 就無法滿足了
這時候我們可透過自訂 Validator 來滿足 奇奇怪怪 複雜的需求.
ActivitiesPrizeWheelCreateValidation

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
public class ActivitiesPrizeWheelCreateValidation implements Validator {

@Override
public boolean supports(Class<?> aClass) {
return ActivitiesPrizeWheelCreateDto.class.equals(aClass);
}

@Override
public void validate(Object object, Errors errors) {
ActivitiesPrizeWheelCreateDto createDto = (ActivitiesPrizeWheelCreateDto) object;

// 基础设置 检查
{
MarketingActivitiesCreateBaseSetting baseSetting = createDto.getBaseSetting();

ZonedDateTime startAt = ZonedDateTime.ofInstant(baseSetting.getStartAt().toInstant(), ZoneId.systemDefault());
ZonedDateTime endAt = ZonedDateTime.ofInstant(baseSetting.getEndAt().toInstant(), ZoneId.systemDefault());
if (startAt.plusMonths(3).isBefore(endAt)) {
errors.rejectValue("baseSetting.endAt", PrizeWheelErrorCode.TIMEFRAME_OVER, "活动时间不能超过3个月");
}
if (startAt.isBefore(endAt) == false) {
errors.rejectValue("baseSetting.endAt", "error", "活动结束时间必须大于活动开始时间");
}
}

// 奖品设置 检查
{
List<PrizeWheelSlotCreateDto> prizeSetting = createDto.getPrizeSetting();
Long noPrizeCount = prizeSetting.stream().filter(prizeWheelSlotCreateDto -> prizeWheelSlotCreateDto.getType().intValue() == PrizeWheelSlotTypeEnum.NO_PRIZE.getValue().intValue()).count();
if (noPrizeCount > 1) {
errors.rejectValue("prizeSetting", PrizeWheelErrorCode.NO_PRIZE_TYPE_MORE_THAN_ONE, "一个大转盘只能设一个无奖类的奖品");
}
if (noPrizeCount == 0) {
errors.rejectValue("prizeSetting", PrizeWheelErrorCode.NEED_ONE_NO_PRIZE_TYPE, "一个大转盘 必須 设一个无奖类的奖品");
}
// 机率加总
Integer totalProbability = prizeSetting.stream().map(prizeWheelSlotCreateDto -> prizeWheelSlotCreateDto.getProbability())
.reduce((total, probability) -> total + probability).get();
// 不含无奖的全部机率
Integer totalHaveProbability = prizeSetting.stream()
.filter(prizeWheelSlotCreateDto -> prizeWheelSlotCreateDto.getType().intValue() != PrizeWheelSlotTypeEnum.NO_PRIZE.getValue().intValue())
.map(prizeWheelSlotCreateDto -> prizeWheelSlotCreateDto.getProbability())
.reduce((total, probability) -> total + probability).get();
if (totalProbability > PrizeWheelConstant.PROBABILITY_DENOMINATOR) {
errors.rejectValue("prizeSetting", "error", null, "中奖概率 不能超过 100%");
}

for (int i = 0; i < prizeSetting.size(); i++) {
PrizeWheelSlotCreateDto prizeWheelSlotCreateDto = prizeSetting.get(i);
// 实体商品 start
if (PrizeWheelSlotTypeEnum.PHYSICAL_PRIZE.getValue().intValue() == prizeWheelSlotCreateDto.getType().intValue()) {
if (!StringUtils.hasText(prizeWheelSlotCreateDto.getImage())) {
errors.rejectValue(String.format("prizeSetting[%d].image", i), "error", null, String.format("实物类奖品:%s 奖品图片URL为必填", prizeWheelSlotCreateDto.getName()));
}
// 检查机率
this.checkProbability(i, prizeWheelSlotCreateDto.getName(), prizeWheelSlotCreateDto.getQuantity(), prizeWheelSlotCreateDto.getProbability(), errors);
}// 实体商品 end

// 电子券 start
if (PrizeWheelSlotTypeEnum.COUPON.getValue().intValue() == prizeWheelSlotCreateDto.getType().intValue()) {
if (!StringUtils.hasText(prizeWheelSlotCreateDto.getImage())) {
errors.rejectValue(String.format("prizeSetting[%d].image", i), "error", null, String.format("电子券类奖品:%s 奖品图片URL为必填", prizeWheelSlotCreateDto.getName()));
}
// 检查机率
this.checkProbability(i, prizeWheelSlotCreateDto.getName(), prizeWheelSlotCreateDto.getQuantity(), prizeWheelSlotCreateDto.getProbability(), errors);
}
// 电子券 end
// 积分类 start
if (PrizeWheelSlotTypeEnum.POINT.getValue().intValue() == prizeWheelSlotCreateDto.getType().intValue()) {
if (!StringUtils.hasText(prizeWheelSlotCreateDto.getImage())) {
errors.rejectValue(String.format("prizeSetting[%d].image", i), "error", null, String.format("积分类奖品:%s 奖品图片URL为必填", prizeWheelSlotCreateDto.getName()));
}
// 检查机率
this.checkProbability(i, prizeWheelSlotCreateDto.getName(), prizeWheelSlotCreateDto.getQuantity(), prizeWheelSlotCreateDto.getProbability(), errors);
}
// 积分类 end
// 无奖类 start
if (PrizeWheelSlotTypeEnum.NO_PRIZE.getValue().intValue() == prizeWheelSlotCreateDto.getType().intValue()) {
if (!StringUtils.hasText(prizeWheelSlotCreateDto.getImage())) {
errors.rejectValue(String.format("prizeSetting[%d].image", i), "error", null, String.format("无奖类奖品:%s 奖品图片URL为必填", prizeWheelSlotCreateDto.getName()));
}
Integer noPrizeProbability = PrizeWheelConstant.PROBABILITY_DENOMINATOR - totalHaveProbability;
log.info("probability={}", noPrizeProbability.intValue());
if (noPrizeProbability.intValue() != prizeWheelSlotCreateDto.getProbability().intValue()) {
errors.rejectValue(String.format("prizeSetting[%d].probability", i), "error", null, String.format("无奖类奖品:%s 中奖机率应为 %s", prizeWheelSlotCreateDto.getName(), toProbabilityInPercent(noPrizeProbability)));
}
}
}
// 如果都没有错误 才检查重复电子券总量是否超过
if (errors.getErrorCount() == 0) {
List<PrizeWheelSlotCreateDto> allCouponPrizes = createDto.getPrizeSetting().stream()
.filter(prizeWheelSlotCreateDto -> prizeWheelSlotCreateDto.getType().intValue() == PrizeWheelSlotTypeEnum.COUPON.getValue().intValue())
.collect(Collectors.toList());

Map<String, List<PrizeWheelSlotCreateDto>> duplicateCouponPrizesMap = new HashMap<>();
for (String couponCode : duplicateCouponPrizesMap.keySet()) {
List<PrizeWheelSlotCreateDto> duplicateCouponPrizes = duplicateCouponPrizesMap.get(couponCode);
if (duplicateCouponPrizes.size() < 2) {
continue;
}
int totalQuantity = 0;
for (PrizeWheelSlotCreateDto duplicateCouponPrize : duplicateCouponPrizes) {
totalQuantity += duplicateCouponPrize.getQuantity();
}
}
}
}
}
}

當檢驗到邏輯錯誤時, 就往 Errors 填入訊息, 再結合外面 Controller 的動作

1
2
3
if (bindingResult.hasErrors()) {
throw new BindException(bindingResult);
}

Spring 就可以幫我們處理好顯示的部分

測試

正確的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
curl --location --request POST 'localhost:8080/api/v1/marketingActivity/prizeWheel' \
--header 'Content-Type: application/json' \
--data-raw '{
"baseSetting": {
"subject": "振興券加碼送",
"accessLimit": 1,
"startAt": "2020-11-01T00:00:00.000",
"endAt": "2020-11-30T23:59:59.999"
},
"targetSetting": {
"totalDrawLimitType": 1
},
"prizeSetting": [
{
"type": 4,
"name": "電子禮券",
"image": "https://i.imgur.com/DAdIjWa.png",
"quantity": 1000,
"probability": 95000
},
{
"type": 1,
"name": "實體商品2",
"image": "https://i.imgur.com/DAdIjWa.png",
"quantity": 100,
"probability": 1000
},
{
"type": 1,
"name": "實體商品3",
"image": "https://i.imgur.com/DAdIjWa.png",
"quantity": 100,
"probability": 1000
},
{
"type": 1,
"name": "實體商品4",
"image": "https://i.imgur.com/DAdIjWa.png",
"quantity": 100,
"probability": 1000
},
{
"type": 1,
"name": "實體商品5",
"image": "https://i.imgur.com/DAdIjWa.png",
"quantity": 100,
"probability": 1000
},
{
"type": 1,
"name": "實體商品6",
"image": "https://i.imgur.com/DAdIjWa.png",
"quantity": 100,
"probability": 1000
}
]
}'

Test Results
Response Code 201 (Created)

錯誤的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
curl --location --request POST 'localhost:8080/api/v1/marketingActivity/prizeWheel' \
--header 'Content-Type: application/json' \
--data-raw '{
"baseSetting": {
"subject": "振興券加碼送",
"accessLimit": 3,
"startAt": "2020-06-01T00:00:00.000",
"endAt": "2020-11-30T23:59:59.999"
},
"targetSetting": {
"totalDrawLimitType": 1
},
"prizeSetting": [
{
"type": 4,
"name": "電子禮券",
"image": "https://i.imgur.com/DAdIjWa.png",
"quantity": 1000,
"probability": 1000
},
{
"type": 1,
"name": "實體商品2",
"image": "https://i.imgur.com/DAdIjWa.png",
"quantity": 100,
"probability": 1000
},
{
"type": 1,
"name": "實體商品3",
"image": "https://i.imgur.com/DAdIjWa.png",
"quantity": 100,
"probability": 1000
},
{
"type": 1,
"name": "實體商品4",
"image": "https://i.imgur.com/DAdIjWa.png",
"quantity": 100,
"probability": 1000
},
{
"type": 1,
"name": "實體商品5",
"image": "https://i.imgur.com/DAdIjWa.png",
"quantity": 100,
"probability": 1000
},
{
"type": 1,
"name": "實體商品6",
"image": "https://i.imgur.com/DAdIjWa.png",
"quantity": 100,
"probability": 1000
}
]
}'

Test Results
Response Code 400 (Bad Request)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
{
"timestamp": "2020-07-06T01:16:31.728+00:00",
"status": 400,
"error": "Bad Request",
"exception": "org.springframework.validation.BindException",
"message": "Validation failed for object='activitiesPrizeWheelCreateDto'. Error count: 4",
"errors": [
{
"codes": [
"Future.activitiesPrizeWheelCreateDto.baseSetting.startAt",
"Future.baseSetting.startAt",
"Future.startAt",
"Future.java.util.Date",
"Future"
],
"arguments": [
{
"codes": [
"activitiesPrizeWheelCreateDto.baseSetting.startAt",
"baseSetting.startAt"
],
"arguments": null,
"defaultMessage": "baseSetting.startAt",
"code": "baseSetting.startAt"
}
],
"defaultMessage": "活动开始时间 不可小于现在",
"objectName": "activitiesPrizeWheelCreateDto",
"field": "baseSetting.startAt",
"rejectedValue": "2020-06-01T00:00:00.000+00:00",
"bindingFailure": false,
"code": "Future"
},
{
"codes": [
"Range.activitiesPrizeWheelCreateDto.baseSetting.accessLimit",
"Range.baseSetting.accessLimit",
"Range.accessLimit",
"Range.java.lang.Integer",
"Range"
],
"arguments": [
{
"codes": [
"activitiesPrizeWheelCreateDto.baseSetting.accessLimit",
"baseSetting.accessLimit"
],
"arguments": null,
"defaultMessage": "baseSetting.accessLimit",
"code": "baseSetting.accessLimit"
},
2,
1
],
"defaultMessage": "访问限制 值 需介于 1 到 2 之间",
"objectName": "activitiesPrizeWheelCreateDto",
"field": "baseSetting.accessLimit",
"rejectedValue": 3,
"bindingFailure": false,
"code": "Range"
},
{
"codes": [
"TimeFrame.activitiesPrizeWheelCreateDto.baseSetting.endAt",
"TimeFrame.baseSetting.endAt",
"TimeFrame.endAt",
"TimeFrame.java.util.Date",
"TimeFrame"
],
"arguments": null,
"defaultMessage": "活动时间不能超过3个月",
"objectName": "activitiesPrizeWheelCreateDto",
"field": "baseSetting.endAt",
"rejectedValue": "2020-11-30T23:59:59.999+00:00",
"bindingFailure": false,
"code": "TimeFrame"
},
{
"codes": [
"error.activitiesPrizeWheelCreateDto.prizeSetting[0].probability",
"error.activitiesPrizeWheelCreateDto.prizeSetting.probability",
"error.prizeSetting[0].probability",
"error.prizeSetting.probability",
"error.probability",
"error.java.lang.Integer",
"error"
],
"arguments": null,
"defaultMessage": "无奖类奖品:電子禮券 中奖机率应为 95",
"objectName": "activitiesPrizeWheelCreateDto",
"field": "prizeSetting[0].probability",
"rejectedValue": 1000,
"bindingFailure": false,
"code": "error"
}
],
"path": "/api/v1/marketingActivity/prizeWheel"
}

這樣一來雖然訊息量比較多(也可以再優化), 但前端工程師就可以很方便開發了 XD
https://i.imgur.com/DAdIjWa.png