✨ 打卡照片按类型最多传 9 张,照片列加宽为 text

- 节点打卡提交新增 photoItems(带显式类型),photos 原样保留供旧版小程序按下标回推
- 打卡照片是每个打卡节点必有的类型,不由过程配置决定,与各凭证类型各自最多 9 张
- 新版提交要求每个类型至少 1 张;旧版仍只要求至少 1 张,避免打断未升级的小程序
- 管理端订正只校验每类上限,不校验必填
- photos 由 varchar(2000) 改 text:36 张约 4000 字符,旧列宽约 17 张即溢出
- 基线建表脚本补 COLLATE=utf8mb4_0900_ai_ci(与本机库实际排序规则一致)
This commit is contained in:
2026-10-07 16:49:19 +08:00
parent e9156ab56b
commit 2e5eae4d32
9 changed files with 606 additions and 33 deletions
@@ -38,6 +38,7 @@ import org.springblade.transport.mapper.WaybillEnroutePunchMapper;
import org.springblade.transport.mapper.WaybillNodePunchMapper;
import org.springblade.transport.pojo.dto.EnrouteSubmitDTO;
import org.springblade.transport.pojo.dto.NodeSubmitDTO;
import org.springblade.transport.pojo.dto.PunchPhotoDTO;
import org.springblade.transport.pojo.entity.Driver;
import org.springblade.transport.pojo.entity.ExceptionDisposal;
import org.springblade.transport.pojo.entity.TransportVehicle;
@@ -323,7 +324,13 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
}
assertNodeNotBlocked(processJson, waybill.getId(), WaybillProcessSupport.nodeKey(nodeCfg));
assertPrerequisitePunched(processJson, waybill.getId(), nodeCfg);
assertPunchPhoto(dto.getPhotos());
// photoItems 是新版小程序带的类型化照片;photos 是旧版仍在提交的裸 URL 列表,
// 类型只能按下标回推过程配置的凭证类型。两者并存是为了不强制升级小程序。
boolean typedPhotoItems = dto.getPhotoItems() != null && !dto.getPhotoItems().isEmpty();
List<Map<String, String>> photoItems = typedPhotoItems
? typedNodePunchPhotos(dto.getPhotoItems(), WaybillProcessSupport.nodePhotoTypes(nodeCfg))
: legacyNodePunchPhotos(dto.getPhotos(), WaybillProcessSupport.nodeStringList(nodeCfg, "voucherTypes"));
assertNodePunchPhotos(photoItems, nodeCfg, typedPhotoItems);
Date now = new Date();
WaybillNodePunch punch = new WaybillNodePunch();
@@ -342,24 +349,8 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
}
punch.setAddress(Func.toStr(dto.getLocation().getAddress(), "").trim());
}
if (dto.getPhotos() != null && !dto.getPhotos().isEmpty()) {
List<String> urls = dto.getPhotos().stream()
.filter(Objects::nonNull)
.map(String::trim)
.filter(Func::isNotEmpty)
.collect(Collectors.toList());
if (!urls.isEmpty()) {
List<String> types = WaybillProcessSupport.nodeStringList(nodeCfg, "voucherTypes");
List<Map<String, String>> photoItems = new ArrayList<>();
for (int i = 0; i < urls.size(); i++) {
Map<String, String> item = new LinkedHashMap<>();
String type = i < types.size() ? types.get(i) : ("凭证" + (i + 1));
item.put("type", type);
item.put("url", urls.get(i));
photoItems.add(item);
}
punch.setPhotos(JsonUtil.toJson(photoItems));
}
if (!photoItems.isEmpty()) {
punch.setPhotos(JsonUtil.toJson(photoItems));
}
punch.setWeight(trimOrNull(dto.getWeight()));
punch.setVolume(trimOrNull(dto.getVolume()));
@@ -916,9 +907,9 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
}
/**
* 断言打卡照片已上传。
* 断言打卡照片已上传(在途链路)。
* <p>
* 照片是打卡的必填项(每个节点至少 1 张),此处拦的是绕过客户端直接调接口的情况
* 在途只有一张照片、不带类型,故只判有没有;此处拦的是绕过客户端直接调接口的情况
* ——前端的必填提示不构成约束。
*/
private void assertPunchPhoto(List<String> photos) {
@@ -931,6 +922,99 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
}
}
/**
* 节点打卡的显式类型照片:类型由提交方给出,超出该节点取值域的直接拒绝。
* <p>
* 取值域是固定的「打卡照片」加过程配置勾选的凭证类型;类型是照片分组的唯一依据,
* 放进来路不明的类型会让回显分组错乱。
*/
private List<Map<String, String>> typedNodePunchPhotos(List<PunchPhotoDTO> items, List<String> allowedTypes) {
List<Map<String, String>> out = new ArrayList<>();
for (PunchPhotoDTO item : items) {
if (item == null) {
continue;
}
String url = Func.toStr(item.getUrl(), "").trim();
if (Func.isEmpty(url)) {
continue;
}
String type = Func.toStr(item.getType(), "").trim();
if (Func.isEmpty(type)) {
throw new ServiceException("照片类型不能为空");
}
if (!allowedTypes.contains(type)) {
throw new ServiceException("照片类型「" + type + "」不属于该节点");
}
out.add(nodePunchPhotoItem(type, url));
}
return out;
}
/**
* 节点打卡的旧版照片:裸 URL 列表,类型按下标取该节点的凭证类型,超出补「凭证N」。
*/
private List<Map<String, String>> legacyNodePunchPhotos(List<String> photos, List<String> voucherTypes) {
List<Map<String, String>> out = new ArrayList<>();
if (photos == null || photos.isEmpty()) {
return out;
}
List<String> urls = photos.stream()
.filter(Objects::nonNull)
.map(String::trim)
.filter(Func::isNotEmpty)
.collect(Collectors.toList());
for (int i = 0; i < urls.size(); i++) {
String type = i < voucherTypes.size() ? voucherTypes.get(i) : ("凭证" + (i + 1));
out.add(nodePunchPhotoItem(type, urls.get(i)));
}
return out;
}
private Map<String, String> nodePunchPhotoItem(String type, String url) {
Map<String, String> item = new LinkedHashMap<>();
item.put("type", type);
item.put("url", url);
return item;
}
/**
* 断言节点打卡照片满足类型口径。
* <p>
* 每个类型至多 9 张,按类型各自计数,不是节点合计。
* 新版提交(photoItems)还需每个类型至少 1 张,含固定的「打卡照片」;
* 旧版只提交裸 URL 列表,类型按下标回推、表达不出「打卡照片」,
* 对它套用「每个类型至少 1 张」会让所有未升级的小程序提交失败,故旧版仍只要求至少有 1 张。
*/
private void assertNodePunchPhotos(List<Map<String, String>> photoItems, Map<String, Object> nodeCfg,
boolean typedPhotoItems) {
Map<String, Integer> countByType = countPunchPhotoByType(photoItems);
for (Map.Entry<String, Integer> entry : countByType.entrySet()) {
if (entry.getValue() > WaybillProcessSupport.MAX_PHOTOS_PER_TYPE) {
throw new ServiceException("「" + entry.getKey() + "」最多上传 "
+ WaybillProcessSupport.MAX_PHOTOS_PER_TYPE + " 张照片");
}
}
if (!typedPhotoItems) {
if (countByType.isEmpty()) {
throw new ServiceException("请上传打卡照片");
}
return;
}
for (String type : WaybillProcessSupport.nodePhotoTypes(nodeCfg)) {
if (countByType.getOrDefault(type, 0) < 1) {
throw new ServiceException("「" + type + "」至少上传 1 张照片");
}
}
}
private Map<String, Integer> countPunchPhotoByType(List<Map<String, String>> photoItems) {
Map<String, Integer> countByType = new LinkedHashMap<>();
for (Map<String, String> item : photoItems) {
countByType.merge(item.get("type"), 1, Integer::sum);
}
return countByType;
}
/**
* 断言该运单没有未了结的异常。
* <p>
@@ -582,7 +582,9 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
punch.setVolume(TransportBusinessSupport.trimToNull(request.getVolume()));
punch.setQuantity(TransportBusinessSupport.trimToNull(request.getQuantity()));
punch.setRemark(TransportBusinessSupport.trimToNull(request.getRemark()));
punch.setPhotos(encodePunchPhotos(request.getPhotos()));
List<Map<String, String>> photoItems = punchPhotoItems(request.getPhotos());
assertPunchPhotoTypeLimit(photoItems);
punch.setPhotos(photoItems.isEmpty() ? null : JsonUtil.toJson(photoItems));
// 编辑不改 exception_flag:它与异常处置表的一致性由异常上报维护
if (isNew) {
waybillNodePunchMapper.insert(punch);
@@ -724,13 +726,13 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
}
/**
* 打卡照片序列化:保留 [{type,url}] 结构,URL 为空的一律丢弃。
* 打卡照片规范成 [{type,url}]:保留原结构,URL 为空的一律丢弃,类型为空按序号补「凭证N」。
*/
private String encodePunchPhotos(List<WaybillPunchRecordSaveRequest.Photo> photos) {
if (photos == null || photos.isEmpty()) {
return null;
}
private List<Map<String, String>> punchPhotoItems(List<WaybillPunchRecordSaveRequest.Photo> photos) {
List<Map<String, String>> items = new ArrayList<>();
if (photos == null || photos.isEmpty()) {
return items;
}
for (WaybillPunchRecordSaveRequest.Photo photo : photos) {
String url = photo == null ? "" : TransportBusinessSupport.trimToEmpty(photo.getUrl());
if (Func.isEmpty(url)) {
@@ -742,7 +744,26 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
item.put("url", url);
items.add(item);
}
return items.isEmpty() ? null : JsonUtil.toJson(items);
return items;
}
/**
* 断言打卡照片每种类型都不超过单类上限。
* <p>
* 上限按类型各自计数,不是节点合计:一个节点可以同时有「打卡照片」与多类凭证,各自最多 9 张。
* 订正不校验必填——订正的是已经发生的事实,不是重新走一遍流程;必填只约束司机提交。
*/
private void assertPunchPhotoTypeLimit(List<Map<String, String>> photoItems) {
Map<String, Integer> countByType = new LinkedHashMap<>();
for (Map<String, String> item : photoItems) {
countByType.merge(item.get("type"), 1, Integer::sum);
}
for (Map.Entry<String, Integer> entry : countByType.entrySet()) {
if (entry.getValue() > WaybillProcessSupport.MAX_PHOTOS_PER_TYPE) {
throw new ServiceException("「" + entry.getKey() + "」最多上传 "
+ WaybillProcessSupport.MAX_PHOTOS_PER_TYPE + " 张照片");
}
}
}
private String firstPunchPhotoUrl(List<WaybillPunchRecordSaveRequest.Photo> photos) {
@@ -60,6 +60,10 @@ public final class WaybillProcessSupport {
private static final String DISPOSAL_REVOKED = "revoked";
/** 异常处置终态状态码集合:不在此集合内即未了结(processing 也在未了结之列) */
public static final List<String> SETTLED_DISPOSAL_STATUSES = List.of(DISPOSAL_COMPLETED, DISPOSAL_REVOKED);
/** 每个打卡节点必有的照片类型,不由过程配置的凭证类型决定 */
public static final String PUNCH_PHOTO_TYPE = "打卡照片";
/** 单个照片类型的张数上限;打卡照片与每个凭证类型各自独立计数,不是节点合计 */
public static final int MAX_PHOTOS_PER_TYPE = 9;
private static final DateTimeFormatter HM = DateTimeFormatter.ofPattern("H:mm");
private static final DateTimeFormatter HM_PADDED = DateTimeFormatter.ofPattern("HH:mm");
@@ -309,6 +313,23 @@ public final class WaybillProcessSupport {
return isTruthy(node.get("uploadVoucher"));
}
/**
* 该节点的照片类型取值域:固定的「打卡照片」加过程配置勾选的凭证类型。
* <p>
* 打卡照片是每个打卡节点必有的照片类型,不由过程配置决定,与各凭证类型并列且互不替代。
* 类型是照片分组的唯一依据,提交上来的类型必须落在这个取值域内,否则回显分组会错乱。
*/
public static List<String> nodePhotoTypes(Map<String, Object> node) {
List<String> types = new ArrayList<>();
types.add(PUNCH_PHOTO_TYPE);
for (String voucherType : nodeStringList(node, "voucherTypes")) {
if (!types.contains(voucherType)) {
types.add(voucherType);
}
}
return types;
}
@SuppressWarnings("unchecked")
public static List<String> nodeStringList(Map<String, Object> node, String field) {
Object raw = node.get(field);