优化
This commit is contained in:
@@ -3,8 +3,8 @@
|
||||
## 行为
|
||||
|
||||
- 首次选择车队只保存车队;列表“发送邮件”提交当前版本和幂等键,向供应商资料中的单个邮箱发送提柜请求。
|
||||
- 更换为另一车队时,与车队变更在同一数据库事务中生成 `CANCEL_PICKUP`、`REQUEST_PICKUP` 两个任务。取消邮件使用原请求的收件地址快照;没有原请求时读取原车队当前资料,允许原车队已停用。新车队必须为 ACTIVE/TRUCKING。
|
||||
- 同一车队重复选择不发邮件、不改变版本。发送操作递增拖车版本,同版本的重复点击不会再次入队。
|
||||
- 更换为另一车队时,以该柜、原车队是否存在 `REQUEST_PICKUP` 邮件记录作为“是否已下发”的事实。已下发过时,与车队变更在同一数据库事务中依次生成原车队 `CANCEL_PICKUP`、新车队 `REQUEST_PICKUP` 两个任务,取消邮件使用原请求的收件地址快照;从未下发过时只生成新车队 `REQUEST_PICKUP`,不向原车队发送取消邮件。新车队必须为 ACTIVE/TRUCKING。
|
||||
- 同一车队重复选择不发邮件、不改变版本。列表“发送邮件”允许人工重复点击,每次使用最新版本都会新增一封提柜请求,便于测试或再次通知;同一个幂等键的请求不会重复入队。
|
||||
- 同柜严格顺序投递,取消未成功时新请求等待。失败不自动重发,避免 SMTP 超时后重复通知;人工核对未收到后通过邮件记录重试。进程中断超过五分钟的发送任务转为失败待核对。
|
||||
- `SENT` 表示 SMTP 已接受邮件,不代表车队已阅读或执行。前端入队后只提示待发送,不伪装为成功投递。邮件记录展示最近 100 封,展示收件人、冻结主题和正文、状态及失败信息。
|
||||
- 邮件发送、重试复用 `platform:firstleg:terminal-drayage:fleet:edit`,记录查看复用列表权限。已提柜、已卸柜或已还柜不再开放新的提柜邮件动作。
|
||||
@@ -26,8 +26,8 @@ Firstleg 增加 Spring Boot mail starter,仅用于 SMTP,不引入独立邮
|
||||
| `TERMINAL_DRAYAGE_MAIL_ENABLED` | 默认 false,true 启用投递 |
|
||||
| `TERMINAL_DRAYAGE_MAIL_HOST` | SMTP 主机 |
|
||||
| `TERMINAL_DRAYAGE_MAIL_PORT` | 默认 587 |
|
||||
| `TERMINAL_DRAYAGE_MAIL_USERNAME` / `TERMINAL_DRAYAGE_MAIL_PASSWORD` | SMTP 账号与凭据,仅环境注入 |
|
||||
| `TERMINAL_DRAYAGE_MAIL_FROM` | 单个发件邮箱 |
|
||||
| `TERMINAL_DRAYAGE_MAIL_USERNAME` | SMTP 登录账号,同时作为邮件发件地址;QQ 邮箱填写完整邮箱地址 |
|
||||
| `TERMINAL_DRAYAGE_MAIL_PASSWORD` | SMTP 授权码,仅通过环境注入,不使用 QQ 登录密码 |
|
||||
| `TERMINAL_DRAYAGE_MAIL_SSL` | 默认 false,强制 STARTTLS;true 使用隐式 TLS,一般配合 465 端口 |
|
||||
| `TERMINAL_DRAYAGE_MAIL_SCAN_DELAY` | 默认 10000 毫秒 |
|
||||
|
||||
|
||||
+2
-3
@@ -20,11 +20,10 @@ public class TerminalDrayageMailDispatcher {
|
||||
@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);
|
||||
TerminalDrayageMailService.validateAddress(username);
|
||||
var sender = new JavaMailSenderImpl();
|
||||
sender.setHost(host); sender.setPort(port); sender.setUsername(username); sender.setPassword(password);
|
||||
sender.setDefaultEncoding("UTF-8");
|
||||
@@ -47,7 +46,7 @@ public class TerminalDrayageMailDispatcher {
|
||||
if (mapper.claimMail(mail.id()) != 1) continue;
|
||||
try {
|
||||
var message = new SimpleMailMessage();
|
||||
message.setFrom(from); message.setTo(mail.recipient());
|
||||
message.setFrom(username); 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());
|
||||
|
||||
+5
-4
@@ -34,11 +34,12 @@ public class TerminalDrayageMailService {
|
||||
|
||||
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);
|
||||
if (previous != null) {
|
||||
enqueue(containerId, containerNo, oldSupplierId, previous.fleetName(), previous.recipient(),
|
||||
"CANCEL_PICKUP", operatorId);
|
||||
}
|
||||
enqueue(containerId, containerNo, newSupplierId, next.name(), next.email(), "REQUEST_PICKUP", operatorId);
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,6 @@ baiyue:
|
||||
port: ${TERMINAL_DRAYAGE_MAIL_PORT:587}
|
||||
username: ${TERMINAL_DRAYAGE_MAIL_USERNAME:}
|
||||
password: ${TERMINAL_DRAYAGE_MAIL_PASSWORD:}
|
||||
from: ${TERMINAL_DRAYAGE_MAIL_FROM:}
|
||||
ssl: ${TERMINAL_DRAYAGE_MAIL_SSL:false}
|
||||
scan-delay: ${TERMINAL_DRAYAGE_MAIL_SCAN_DELAY:10000}
|
||||
carrier-schedule:
|
||||
|
||||
@@ -55,7 +55,7 @@ paths:
|
||||
/api/platform/v1/terminal-drayages/{containerBookingId}/pickup-email:
|
||||
post:
|
||||
summary: 将当前车队提柜邮件加入发送队列
|
||||
description: 初次选择车队不发信;此命令携带版本和幂等键,发送成功是任务已入队,不表示已送达。首次更换车队自动原子入队取消和新提柜两封邮件。同一车队重复选择不发信。
|
||||
description: 初次选择车队不发信;此命令携带版本和幂等键,发送成功是任务已入队,不表示已送达。人工可使用最新版本重复发送提柜请求。更换车队时,原车队存在提柜请求邮件记录才原子入队取消邮件,并始终为新车队入队提柜请求;原车队从未下发时不发送取消邮件。同一车队重复选择不发信。
|
||||
x-permission: platform:firstleg:terminal-drayage:fleet:edit
|
||||
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
||||
requestBody: {required: true, content: {application/json: {schema: {type: object, required: [expectedVersion], properties: {expectedVersion: {type: integer, minimum: 0}}}}}}
|
||||
|
||||
+5
-2
@@ -7,6 +7,7 @@ import org.springframework.mail.SimpleMailMessage;
|
||||
import org.springframework.mail.javamail.JavaMailSenderImpl;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import java.util.List;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
|
||||
@@ -16,7 +17,7 @@ class TerminalDrayageMailDispatcherTest {
|
||||
TerminalDrayageMailDispatcher worker = spy(new TerminalDrayageMailDispatcher(mapper));
|
||||
|
||||
void setup() {
|
||||
ReflectionTestUtils.setField(worker,"from","operations@example.com");
|
||||
ReflectionTestUtils.setField(worker,"username","operations@example.com");
|
||||
doReturn(sender).when(worker).sender();
|
||||
when(mapper.pendingMails()).thenReturn(List.of(new TerminalDrayageMapper.MailTask(
|
||||
1L,11L,2L,"Fleet","fleet@example.com","REQUEST_PICKUP","Pickup","Body","PENDING",0,null,null,null)));
|
||||
@@ -27,8 +28,10 @@ class TerminalDrayageMailDispatcherTest {
|
||||
@Test void onlyClaimsOnceAndMarksSentAfterSmtpAccepted() {
|
||||
setup(); worker.dispatch();
|
||||
var ordered = inOrder(mapper,sender);
|
||||
var sent = org.mockito.ArgumentCaptor.forClass(SimpleMailMessage.class);
|
||||
ordered.verify(mapper).claimMail(1L);
|
||||
ordered.verify(sender).send(any(SimpleMailMessage.class));
|
||||
ordered.verify(sender).send(sent.capture());
|
||||
assertThat(sent.getValue().getFrom()).isEqualTo("operations@example.com");
|
||||
ordered.verify(mapper).completeMail(1L);
|
||||
when(mapper.claimMail(1L)).thenReturn(0);
|
||||
worker.dispatch();
|
||||
|
||||
+12
@@ -27,6 +27,8 @@ class TerminalDrayageMailServiceTest {
|
||||
}
|
||||
|
||||
@Test void replacementQueuesCancellationThenRequestWithSeparateFrozenRecipients() {
|
||||
when(mapper.lastPickupMail(11L, 1L)).thenReturn(new TerminalDrayageMapper.MailTask(
|
||||
90L,11L,1L,"Old Fleet","old@example.com","REQUEST_PICKUP","subject","body","SENT",1,null,null,null));
|
||||
service.replaceFleet(11L, "MSCU1234567", 1L, 2L, 7L);
|
||||
var order = inOrder(mapper);
|
||||
order.verify(mapper).insertMail(anyLong(), eq(11L), eq(1L), eq("Old Fleet"), eq("old@example.com"),
|
||||
@@ -35,6 +37,16 @@ class TerminalDrayageMailServiceTest {
|
||||
eq("REQUEST_PICKUP"), eq("Port pickup request - MSCU1234567"), contains("Please arrange"), eq(7L));
|
||||
}
|
||||
|
||||
@Test void replacementWithoutPreviousPickupMailQueuesOnlyNewRequest() {
|
||||
service.replaceFleet(11L, "MSCU1234567", 1L, 2L, 7L);
|
||||
|
||||
verify(mapper, never()).insertMail(anyLong(), eq(11L), eq(1L), anyString(), anyString(),
|
||||
eq("CANCEL_PICKUP"), anyString(), anyString(), eq(7L));
|
||||
verify(mapper).insertMail(anyLong(), eq(11L), eq(2L), eq("New Fleet"), eq("new@example.com"),
|
||||
eq("REQUEST_PICKUP"), eq("Port pickup request - MSCU1234567"), contains("Please arrange"), eq(7L));
|
||||
verify(suppliers, never()).truckingContact("test-token", 1L, false);
|
||||
}
|
||||
|
||||
@Test void cancellationUsesOriginalRecipientEvenIfSupplierEmailChanged() {
|
||||
when(mapper.lastPickupMail(11L, 1L)).thenReturn(new TerminalDrayageMapper.MailTask(
|
||||
90L,11L,1L,"Frozen Fleet","original@example.com","REQUEST_PICKUP","subject","body","SENT",1,null,null,null));
|
||||
|
||||
+8
-3
@@ -374,18 +374,23 @@ class TerminalDrayageServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void manualPickupEmailUsesLockedVersionAndRejectsStaleClick() {
|
||||
void manualPickupEmailRejectsStaleClickAndAllowsRepeatWithLatestVersion() {
|
||||
Context context = context("ARRIVED_DESTINATION_PORT",null);
|
||||
when(mapper.lockContext(11L)).thenReturn(context);
|
||||
when(mapper.context(11L)).thenReturn(context);
|
||||
TerminalDrayageEntity row = row(3);
|
||||
when(mapper.lock(11L)).thenReturn(row);
|
||||
when(mapper.find(11L)).thenReturn(row);
|
||||
when(mapper.updateAggregate(any(),anyInt(),anyLong(),anyString())).thenAnswer(invocation -> {row.setVersion(4);return 1;});
|
||||
when(mapper.updateAggregate(any(),anyInt(),anyLong(),anyString())).thenAnswer(invocation -> {
|
||||
row.setVersion(invocation.getArgument(1, Integer.class) + 1);
|
||||
return 1;
|
||||
});
|
||||
var request = new com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.EmailRequest(3);
|
||||
service.sendPickupEmail(11L,7L,"admin","mail-1",request);
|
||||
assertThatThrownBy(() -> service.sendPickupEmail(11L,7L,"admin","mail-2",request)).isInstanceOf(BizException.class);
|
||||
verify(mails,times(1)).request(11L,"MSCU1234567",91L,7L);
|
||||
service.sendPickupEmail(11L,7L,"admin","mail-3",
|
||||
new com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.EmailRequest(4));
|
||||
verify(mails,times(2)).request(11L,"MSCU1234567",91L,7L);
|
||||
}
|
||||
|
||||
private Context context(String node, LocalDateTime unloadedAt) {
|
||||
|
||||
Reference in New Issue
Block a user