This commit is contained in:
2026-09-09 13:54:47 +08:00
parent 1a092bc9d7
commit f31a049444
11 changed files with 336 additions and 4 deletions
@@ -36,6 +36,33 @@ import java.util.List;
@Tag(name = "目的港码头拖车")
@RequestMapping("/api/platform/v1/terminal-drayages")
public class PlatformTerminalDrayageController {
@jakarta.annotation.Resource private com.baiyue.firstleg.terminaldrayage.service.TerminalDrayageMailService mails;
@PostMapping("/{containerBookingId}/pickup-email")
@PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:fleet:edit')")
public ApiResponse<View> sendPickupEmail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long containerBookingId, @RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.EmailRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.authorize(service.sendPickupEmail(containerBookingId, user.userId(), user.username(), key, request), user.permissions()));
}
@GetMapping("/{containerBookingId}/emails")
@PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:list')")
public ApiResponse<com.baiyue.firstleg.terminaldrayage.service.TerminalDrayageMailService.Mailbox> emails(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long containerBookingId) {
FirstlegScopeGuard.platform(user); service.detail(containerBookingId);
return ApiResponse.success(mails.list(containerBookingId));
}
@PostMapping("/{containerBookingId}/emails/{mailId}/retry")
@PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:fleet:edit')")
public ApiResponse<Void> retryEmail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long containerBookingId, @PathVariable @Positive long mailId) {
FirstlegScopeGuard.platform(user); service.detail(containerBookingId);
mails.retry(containerBookingId, mailId, user.userId());
return ApiResponse.success(null);
}
private final TerminalDrayageService service;
@GetMapping
@@ -11,6 +11,7 @@ import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerAc
public final class TerminalDrayageDtos {
private TerminalDrayageDtos() {}
public record EmailRequest(@NotNull @Min(0) Integer expectedVersion) {}
public record FleetSelectionRequest(@NotNull @Min(0) Integer expectedVersion,
@NotNull @Positive Long fleetSupplierId,
@@ -110,4 +110,65 @@ public interface TerminalDrayageMapper extends BaseMapper<TerminalDrayageEntity>
record Context(Long containerBookingId, String containerNo, Long oceanBookingId, String recordStatus,
LocalDateTime unloadingCompletedAt) {}
record Operation(String operation, String requestHash, Integer resultVersion) {}
record MailTask(Long id, Long containerBookingId, Long fleetSupplierId, String fleetName,
String recipient, String kind, String subject, String body, String status,
Integer attempts, String failureReason, LocalDateTime createdAt, LocalDateTime sentAt) {}
@Insert("""
INSERT INTO fl_terminal_drayage_mail(id,container_booking_id,fleet_supplier_id,fleet_name,
recipient,kind,subject,body,status,created_by,updated_by)
VALUES(#{id},#{containerId},#{supplierId},#{name},#{email},#{kind},#{subject},#{body},'PENDING',#{operatorId},#{operatorId})
""")
int insertMail(@Param("id") long id, @Param("containerId") long containerId,
@Param("supplierId") long supplierId, @Param("name") String name, @Param("email") String email,
@Param("kind") String kind, @Param("subject") String subject, @Param("body") String body,
@Param("operatorId") long operatorId);
@Select("SELECT * FROM fl_terminal_drayage_mail WHERE container_booking_id=#{containerId} ORDER BY id DESC LIMIT 100")
List<MailTask> mails(@Param("containerId") long containerId);
@Select("""
SELECT * FROM fl_terminal_drayage_mail WHERE container_booking_id=#{containerId}
AND fleet_supplier_id=#{supplierId} AND kind='REQUEST_PICKUP' ORDER BY id DESC LIMIT 1
""")
MailTask lastPickupMail(@Param("containerId") long containerId, @Param("supplierId") long supplierId);
@Select("""
SELECT m.* FROM fl_terminal_drayage_mail m WHERE m.status='PENDING'
AND NOT EXISTS(SELECT 1 FROM fl_terminal_drayage_mail earlier
WHERE earlier.container_booking_id=m.container_booking_id AND earlier.id<m.id AND earlier.status<>'SENT')
ORDER BY m.id LIMIT 20
""")
List<MailTask> pendingMails();
@Update("""
UPDATE fl_terminal_drayage_mail SET status='SENDING',attempts=attempts+1,updated_at=CURRENT_TIMESTAMP(3)
WHERE id=#{id} AND status='PENDING'
""")
int claimMail(@Param("id") long id);
@Update("""
UPDATE fl_terminal_drayage_mail SET status='SENT',sent_at=CURRENT_TIMESTAMP(3),failure_reason=NULL,
updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{id} AND status='SENDING'
""")
int completeMail(@Param("id") long id);
@Update("""
UPDATE fl_terminal_drayage_mail SET status='FAILED',failure_reason=#{reason},updated_at=CURRENT_TIMESTAMP(3)
WHERE id=#{id} AND status='SENDING'
""")
int failMail(@Param("id") long id, @Param("reason") String reason);
@Update("""
UPDATE fl_terminal_drayage_mail SET status='FAILED',failure_reason='发送中断,结果待核对;确认未收到后再重试',
updated_at=CURRENT_TIMESTAMP(3) WHERE status='SENDING' AND updated_at<DATE_SUB(CURRENT_TIMESTAMP(3), INTERVAL 5 MINUTE)
""")
int expireMails();
@Update("""
UPDATE fl_terminal_drayage_mail SET status='PENDING',failure_reason=NULL,updated_by=#{operatorId},updated_at=CURRENT_TIMESTAMP(3)
WHERE id=#{id} AND container_booking_id=#{containerId} AND status='FAILED'
""")
int retryMail(@Param("containerId") long containerId, @Param("id") long id, @Param("operatorId") long operatorId);
}
@@ -0,0 +1,62 @@
package com.baiyue.firstleg.terminaldrayage.service;
import com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSenderImpl;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@Slf4j
@Component
@RequiredArgsConstructor
@ConditionalOnProperty(name="baiyue.terminal-drayage.mail.enabled", havingValue="true")
public class TerminalDrayageMailDispatcher {
private final TerminalDrayageMapper mapper;
@Value("${baiyue.terminal-drayage.mail.host}") private String host;
@Value("${baiyue.terminal-drayage.mail.port:587}") private int port;
@Value("${baiyue.terminal-drayage.mail.username}") private String username;
@Value("${baiyue.terminal-drayage.mail.password}") private String password;
@Value("${baiyue.terminal-drayage.mail.from}") private String from;
@Value("${baiyue.terminal-drayage.mail.ssl:false}") private boolean ssl;
JavaMailSenderImpl sender() {
TerminalDrayageMailService.validateAddress(from);
var sender = new JavaMailSenderImpl();
sender.setHost(host); sender.setPort(port); sender.setUsername(username); sender.setPassword(password);
sender.setDefaultEncoding("UTF-8");
var properties = sender.getJavaMailProperties();
properties.setProperty("mail.smtp.auth", "true");
properties.setProperty("mail.smtp.ssl.enable", String.valueOf(ssl));
properties.setProperty("mail.smtp.starttls.enable", String.valueOf(!ssl));
properties.setProperty("mail.smtp.starttls.required", String.valueOf(!ssl));
properties.setProperty("mail.smtp.ssl.checkserveridentity", "true");
properties.setProperty("mail.smtp.connectiontimeout", "10000");
properties.setProperty("mail.smtp.timeout", "10000");
properties.setProperty("mail.smtp.writetimeout", "10000");
return sender;
}
@Scheduled(fixedDelayString="${baiyue.terminal-drayage.mail.scan-delay:10000}")
public void dispatch() {
mapper.expireMails();
var sender = sender();
for (var mail : mapper.pendingMails()) {
if (mapper.claimMail(mail.id()) != 1) continue;
try {
var message = new SimpleMailMessage();
message.setFrom(from); message.setTo(mail.recipient());
message.setSubject(mail.subject()); message.setText(mail.body());
sender.send(message);
if (mapper.completeMail(mail.id()) != 1) log.warn("Terminal drayage mail completion requires review: {}", mail.id());
} catch (Exception exception) {
// SMTP 超时不等于未送达,禁止自动重发;失败阻断同柜后续邮件,交由人工核对。
mapper.failMail(mail.id(), "发送失败或结果不确定,请核对收件情况及发信配置后重试");
log.warn("Terminal drayage mail requires review: {}, {}", mail.id(), exception.getClass().getSimpleName());
}
}
}
}
@@ -0,0 +1,88 @@
package com.baiyue.firstleg.terminaldrayage.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.WarehouseSupplierClient;
import com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper;
import com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper.MailTask;
import feign.FeignException;
import jakarta.mail.internet.InternetAddress;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.util.List;
@Service
@RequiredArgsConstructor
public class TerminalDrayageMailService {
private final TerminalDrayageMapper mapper;
private final WarehouseSupplierClient suppliers;
@Value("${baiyue.security.internal-service-token}") private String internalToken;
@Value("${baiyue.terminal-drayage.mail.enabled:false}") private boolean enabled;
public record Mailbox(boolean deliveryEnabled, List<MailTask> records) {}
public Mailbox list(long containerBookingId) { return new Mailbox(enabled, mapper.mails(containerBookingId)); }
public void request(long containerId, String containerNo, long supplierId, long operatorId) {
var contact = contact(supplierId, true);
enqueue(containerId, containerNo, supplierId, contact.name(), contact.email(), "REQUEST_PICKUP", operatorId);
}
public void replaceFleet(long containerId, String containerNo, long oldSupplierId, long newSupplierId, long operatorId) {
var next = contact(newSupplierId, true);
// 取消通知发往原请求的收件地址;尚未发过请求则读取原车队资料,允许原车队已停用。
MailTask previous = mapper.lastPickupMail(containerId, oldSupplierId);
var old = previous == null ? contact(oldSupplierId, false)
: new WarehouseSupplierClient.TruckingContact(oldSupplierId, previous.fleetName(), previous.recipient());
enqueue(containerId, containerNo, oldSupplierId, old.name(), old.email(), "CANCEL_PICKUP", operatorId);
enqueue(containerId, containerNo, newSupplierId, next.name(), next.email(), "REQUEST_PICKUP", operatorId);
}
private WarehouseSupplierClient.TruckingContact contact(long supplierId, boolean requireActive) {
try {
var response = suppliers.truckingContact(internalToken, supplierId, requireActive);
if (response == null || !response.successful() || response.data() == null
|| response.data().supplierId() != supplierId) {
throw new BizException(ResultCode.CONFLICT, "车队联系资料不可用,请检查供应商资料");
}
var value = response.data();
validateAddress(value.email());
if (!StringUtils.hasText(value.name())) throw new BizException(ResultCode.CONFLICT, "车队名称缺失");
return value;
} catch (FeignException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "车队联系资料服务暂不可用");
}
}
static void validateAddress(String email) {
try {
if (!StringUtils.hasText(email) || email.contains("\r") || email.contains("\n")) throw new IllegalArgumentException();
InternetAddress[] parsed = InternetAddress.parse(email.trim(), true);
if (parsed.length != 1 || parsed[0].isGroup() || !email.trim().equals(parsed[0].getAddress())) throw new IllegalArgumentException();
parsed[0].validate();
if (!parsed[0].getAddress().contains("@")) throw new IllegalArgumentException();
} catch (Exception exception) {
throw new BizException(ResultCode.BAD_REQUEST, "车队未配置有效邮箱,请先在供应商资料中维护单个收件邮箱");
}
}
private void enqueue(long containerId, String containerNo, long supplierId, String fleetName,
String email, String kind, long operatorId) {
if (!StringUtils.hasText(containerNo)) throw new BizException(ResultCode.CONFLICT, "请先补齐真实集装箱号再发送邮件");
validateAddress(email);
var content = TerminalDrayageMailTemplates.render(kind, containerNo, fleetName);
mapper.insertMail(IdWorker.getId(), containerId, supplierId, fleetName, email.trim(), kind,
content.subject(), content.body(), operatorId);
}
@Transactional
public void retry(long containerId, long mailId, long operatorId) {
if (mapper.retryMail(containerId, mailId, operatorId) != 1) {
throw new BizException(ResultCode.CONFLICT, "只有发送失败的邮件可以重试,请刷新邮件记录");
}
}
}
@@ -0,0 +1,20 @@
package com.baiyue.firstleg.terminaldrayage.service;
/** 临时英文模板。正式邮件文案到位后只替换此处,不改变任务与发送流程。 */
public final class TerminalDrayageMailTemplates {
private TerminalDrayageMailTemplates() {}
public record Content(String subject, String body) {}
public static Content render(String kind, String containerNo, String fleetName) {
boolean cancel = "CANCEL_PICKUP".equals(kind);
String subject = (cancel ? "Cancel port pickup - " : "Port pickup request - ") + containerNo;
String body = "Dear " + fleetName + ",\n\n"
+ (cancel ? "Please cancel our port pickup request for container "
: "Please arrange port pickup for container ")
+ containerNo + ".\n"
+ (cancel ? "Please do not dispatch a truck for this request.\n"
: "Please contact our operations team to confirm the pickup details before dispatch.\n")
+ "\nPlease reply to confirm receipt.\nThank you.\nOperations Team";
return new Content(subject, body);
}
}
@@ -46,6 +46,7 @@ public class TerminalDrayageService {
private final WarehouseSupplierSnapshotService suppliers;
private final CarrierScheduleQueryService carrierSchedules;
private final ObjectMapper objectMapper;
private final TerminalDrayageMailService mails;
private Clock clock = Clock.systemDefaultZone();
public View detail(long containerBookingId) {
@@ -95,6 +96,10 @@ public class TerminalDrayageService {
requireVersion(value, request.expectedVersion());
if (actualPickupAt(context, value) != null) throw conflict("实际提柜后不可更换码头车队");
var fleet = suppliers.trucking(request.fleetSupplierId(), request.fleetSupplierName());
if (value != null && Objects.equals(value.getFleetSupplierId(), fleet.supplierId())) return view(context, value);
if (value != null && value.getFleetSupplierId() != null) {
mails.replaceFleet(containerBookingId, context.base().containerNo(), value.getFleetSupplierId(), fleet.supplierId(), operatorId);
}
int fromVersion = version(value);
String fromStatus = status(context, value);
if (value == null) value = create(containerBookingId, operatorId, operatorName);
@@ -197,6 +202,23 @@ public class TerminalDrayageService {
actualAt, null, null, StringUtils.hasText(reason) ? reason.trim() : "码头拖车操作同步"));
}
@Transactional
public View sendPickupEmail(long containerBookingId, long operatorId, String operatorName, String key,
com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.EmailRequest request) {
String action = "SEND_PICKUP_EMAIL";
WorkContext context = requiredLockedContext(containerBookingId);
View replay = replay(context, action, key, request);
if (replay != null) return replay;
TerminalDrayageEntity value = requiredAggregate(containerBookingId);
requireVersion(value, request.expectedVersion());
if (!allowedActions(context, value).contains(action)) throw conflict("当前货柜不能发送提柜请求邮件");
int fromVersion = version(value);
String fromStatus = status(context, value);
mails.request(containerBookingId, context.base().containerNo(), value.getFleetSupplierId(), operatorId);
persist(value, fromVersion, operatorId, operatorName);
return finish(context, value, action, key, request, fromStatus, fromVersion, "提柜邮件已加入发送队列", operatorId, operatorName);
}
private View finish(WorkContext context, TerminalDrayageEntity value, String action, String key, Object request,
String fromStatus, int fromVersion, String reason,
long operatorId, String operatorName) {
@@ -267,6 +289,8 @@ public class TerminalDrayageService {
private List<String> allowedActions(WorkContext context, TerminalDrayageEntity value) {
List<String> result = new ArrayList<>();
if (value != null && value.getFleetSupplierId() != null && actualPickupAt(context, value) == null
&& actualEmptyReturnAt(context, value) == null && context.base().unloadingCompletedAt() == null) result.add("SEND_PICKUP_EMAIL");
CarrierScheduleNode current = node(context.facts().latestActualNode());
if (current != null && current.sequence() >= CarrierScheduleNode.ARRIVED_DESTINATION_PORT.sequence()
&& current.sequence() < CarrierScheduleNode.PICKED_UP.sequence()
@@ -371,6 +395,7 @@ public class TerminalDrayageService {
private static String permission(String action) {
return switch (action) {
case "SELECT_FLEET" -> "platform:firstleg:terminal-drayage:fleet:edit";
case "SEND_PICKUP_EMAIL" -> "platform:firstleg:terminal-drayage:fleet:edit";
case "SCHEDULE_PICKUP" -> "platform:firstleg:terminal-drayage:pickup-appointment:edit";
case "RECORD_PICKUP" -> "platform:firstleg:terminal-drayage:pickup:confirm";
case "RECORD_EMPTY_RETURN" -> "platform:firstleg:terminal-drayage:empty-return:confirm";