补齐任务列表状态筛选和邮件HTML安全字段
This commit is contained in:
@@ -16,7 +16,9 @@ import java.util.List;
|
||||
* @param sourceSentAt 邮件来源发送时间,UTC
|
||||
* @param textBody 完整纯文本正文
|
||||
* @param htmlBody 完整 HTML 正文,前端展示前必须 sanitize
|
||||
* @param htmlBodySanitized 后端第一版清洗后的 HTML,前端生产展示应优先使用
|
||||
* @param htmlSanitizeRequired 是否要求前端 sanitize HTML
|
||||
* @param htmlRenderMode HTML 渲染建议模式,例如 SANITIZED_HTML 或 TEXT_ONLY
|
||||
* @param inlineImages 内联图片外链
|
||||
* @param attachments 附件外链
|
||||
* @param relatedOrders 关联订单摘要
|
||||
@@ -39,8 +41,12 @@ public record SourceMessageConversationMessageResult(
|
||||
String textBody,
|
||||
@JsonProperty("html_body")
|
||||
String htmlBody,
|
||||
@JsonProperty("html_body_sanitized")
|
||||
String htmlBodySanitized,
|
||||
@JsonProperty("html_sanitize_required")
|
||||
Boolean htmlSanitizeRequired,
|
||||
@JsonProperty("html_render_mode")
|
||||
String htmlRenderMode,
|
||||
@JsonProperty("inline_images")
|
||||
List<SourceMessageOriginalMediaResponse> inlineImages,
|
||||
List<SourceMessageOriginalMediaResponse> attachments,
|
||||
|
||||
@@ -18,6 +18,7 @@ import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.regex.Pattern;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@@ -31,6 +32,20 @@ public class SourceMessageConversationServiceImpl implements SourceMessageConver
|
||||
private static final String ACCESS_SCENE = "source-message-conversation";
|
||||
private static final String MEDIA_TYPE_INLINE_IMAGE = "INLINE_IMAGE";
|
||||
private static final String MEDIA_TYPE_ATTACHMENT = "ATTACHMENT";
|
||||
private static final String HTML_RENDER_MODE_SANITIZED = "SANITIZED_HTML";
|
||||
private static final String HTML_RENDER_MODE_TEXT_ONLY = "TEXT_ONLY";
|
||||
private static final Pattern DANGEROUS_TAG_PATTERN = Pattern.compile(
|
||||
"(?is)<\\s*(script|style|iframe|object|embed|meta|link|base|form)[^>]*>.*?<\\s*/\\s*\\1\\s*>"
|
||||
+ "|<\\s*(script|style|iframe|object|embed|meta|link|base|form)[^>]*/?\\s*>");
|
||||
private static final Pattern EVENT_ATTRIBUTE_PATTERN = Pattern.compile(
|
||||
"(?i)\\s+on[a-z0-9_-]+\\s*=\\s*(\"[^\"]*\"|'[^']*'|[^\\s>]+)");
|
||||
private static final Pattern STYLE_ATTRIBUTE_PATTERN = Pattern.compile(
|
||||
"(?i)\\s+style\\s*=\\s*(\"[^\"]*\"|'[^']*'|[^\\s>]+)");
|
||||
private static final Pattern DANGEROUS_URL_ATTRIBUTE_PATTERN = Pattern.compile(
|
||||
"(?i)\\s+(href|src|xlink:href|formaction)\\s*=\\s*"
|
||||
+ "(\"\\s*(?:javascript|data|vbscript):[^\"]*\""
|
||||
+ "|'\\s*(?:javascript|data|vbscript):[^']*'"
|
||||
+ "|\\s*(?:javascript|data|vbscript):[^\\s>]+)");
|
||||
|
||||
private final SourceMessageInboxRepository inboxRepository;
|
||||
private final List<SourceMessageRelatedContextProvider> relatedContextProviders;
|
||||
@@ -101,6 +116,7 @@ public class SourceMessageConversationServiceImpl implements SourceMessageConver
|
||||
private SourceMessageConversationMessageResult toConversationMessage(SourceMessageInboxSnapshot message) {
|
||||
SourceMessageOriginalContent originalContent = readOriginalAndAudit(message);
|
||||
SourceMessageRelatedContextResult relatedContext = findRelatedContext(message);
|
||||
String htmlBody = originalContent.htmlBody();
|
||||
return new SourceMessageConversationMessageResult(
|
||||
message.id().toString(),
|
||||
message.externalMessageId(),
|
||||
@@ -110,8 +126,10 @@ public class SourceMessageConversationServiceImpl implements SourceMessageConver
|
||||
UtcTimeFormatter.toUtcOffsetDateTime(message.receivedAt()),
|
||||
UtcTimeFormatter.toUtcOffsetDateTime(message.sourceSentAt()),
|
||||
originalContent.textBody(),
|
||||
originalContent.htmlBody(),
|
||||
htmlBody,
|
||||
sanitizeHtml(htmlBody),
|
||||
true,
|
||||
htmlRenderMode(htmlBody),
|
||||
filterMedia(originalContent.mediaItems(), MEDIA_TYPE_INLINE_IMAGE),
|
||||
filterMedia(originalContent.mediaItems(), MEDIA_TYPE_ATTACHMENT),
|
||||
relatedContext.relatedOrders(),
|
||||
@@ -173,4 +191,27 @@ public class SourceMessageConversationServiceImpl implements SourceMessageConver
|
||||
item.externalUrl(),
|
||||
item.externalMediaId());
|
||||
}
|
||||
|
||||
/**
|
||||
* 第一版 HTML 清洗:保留邮件正文基本结构,移除脚本标签、事件属性和危险协议链接。
|
||||
*/
|
||||
private String sanitizeHtml(String htmlBody) {
|
||||
if (htmlBody == null) {
|
||||
return null;
|
||||
}
|
||||
String sanitized = DANGEROUS_TAG_PATTERN.matcher(htmlBody).replaceAll("");
|
||||
sanitized = EVENT_ATTRIBUTE_PATTERN.matcher(sanitized).replaceAll("");
|
||||
sanitized = STYLE_ATTRIBUTE_PATTERN.matcher(sanitized).replaceAll("");
|
||||
return DANGEROUS_URL_ATTRIBUTE_PATTERN.matcher(sanitized).replaceAll("");
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回前端渲染建议;有 HTML 时优先使用后端清洗后的安全 HTML。
|
||||
*/
|
||||
private String htmlRenderMode(String htmlBody) {
|
||||
if (htmlBody == null || htmlBody.isBlank()) {
|
||||
return HTML_RENDER_MODE_TEXT_ONLY;
|
||||
}
|
||||
return HTML_RENDER_MODE_SANITIZED;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ package cn.nianxx.thhotel.workflows.reservation.common.request;
|
||||
* @param taskType 系统主任务类型过滤
|
||||
* @param taskStatus 任务状态过滤
|
||||
* @param taskSubtype 任务 subtype 过滤
|
||||
* @param orderStatus 任务所属订单状态过滤
|
||||
* @param queueParticipation 是否参与订单执行队列
|
||||
* @param keyword 业务号、临时订单号、任务字段或来源消息安全摘要关键词
|
||||
* @param pageNum 页码,从 1 开始
|
||||
@@ -19,6 +20,7 @@ public record ReservationTaskWorkbenchQueryRequest(
|
||||
String taskType,
|
||||
String taskStatus,
|
||||
String taskSubtype,
|
||||
String orderStatus,
|
||||
Boolean queueParticipation,
|
||||
String keyword,
|
||||
Integer pageNum,
|
||||
|
||||
@@ -39,6 +39,7 @@ public class ReservationFrontendQueryController {
|
||||
@RequestParam(name = "task_type", required = false) String taskType,
|
||||
@RequestParam(name = "task_status", required = false) String taskStatus,
|
||||
@RequestParam(name = "task_subtype", required = false) String taskSubtype,
|
||||
@RequestParam(name = "order_status", required = false) String orderStatus,
|
||||
@RequestParam(name = "queue_participation", required = false) Boolean queueParticipation,
|
||||
@RequestParam(required = false) String keyword,
|
||||
@RequestParam(name = "page_num", required = false) Integer pageNum,
|
||||
@@ -49,6 +50,7 @@ public class ReservationFrontendQueryController {
|
||||
taskType,
|
||||
taskStatus,
|
||||
taskSubtype,
|
||||
orderStatus,
|
||||
queueParticipation,
|
||||
keyword,
|
||||
pageNum,
|
||||
|
||||
@@ -2,11 +2,78 @@ package cn.nianxx.thhotel.workflows.reservation.mapper;
|
||||
|
||||
import cn.nianxx.thhotel.workflows.reservation.domain.ReservationTaskEntity;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import java.util.List;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
/**
|
||||
* Reservation 任务 Mapper,只负责本表持久化访问。
|
||||
*/
|
||||
@Mapper
|
||||
public interface ReservationTaskMapper extends BaseMapper<ReservationTaskEntity> {
|
||||
|
||||
/**
|
||||
* 分页查询前端任务列表摘要,支持按任务所属订单状态过滤。
|
||||
*/
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT t.*
|
||||
FROM workflow_reservation_task t
|
||||
LEFT JOIN workflow_reservation_order o
|
||||
ON o.hotel_id = t.hotel_id
|
||||
AND o.id = t.order_id
|
||||
WHERE t.hotel_id = #{hotelId}
|
||||
<if test="orderId != null">
|
||||
AND t.order_id = #{orderId}
|
||||
</if>
|
||||
<if test="orderStatus != null and orderStatus != ''">
|
||||
AND o.order_status = #{orderStatus}
|
||||
</if>
|
||||
<if test="taskType != null and taskType != ''">
|
||||
AND t.system_task_type = #{taskType}
|
||||
</if>
|
||||
<if test="taskStatus != null and taskStatus != ''">
|
||||
AND t.task_status = #{taskStatus}
|
||||
</if>
|
||||
<if test="taskSubtype != null and taskSubtype != ''">
|
||||
AND t.task_subtype = #{taskSubtype}
|
||||
</if>
|
||||
<if test="queueParticipation != null">
|
||||
AND t.queue_participation = #{queueParticipation}
|
||||
</if>
|
||||
<if test="keyword != null and keyword != ''">
|
||||
AND (
|
||||
t.system_task_type LIKE CONCAT('%', #{keyword}, '%')
|
||||
OR t.task_subtype LIKE CONCAT('%', #{keyword}, '%')
|
||||
<if test="keywordOrderIds != null and keywordOrderIds.size() > 0">
|
||||
OR t.order_id IN
|
||||
<foreach collection="keywordOrderIds" item="orderIdItem" open="(" separator="," close=")">
|
||||
#{orderIdItem}
|
||||
</foreach>
|
||||
</if>
|
||||
<if test="sourceMessageIds != null and sourceMessageIds.size() > 0">
|
||||
OR t.source_message_id IN
|
||||
<foreach collection="sourceMessageIds" item="sourceMessageIdItem" open="(" separator="," close=")">
|
||||
#{sourceMessageIdItem}
|
||||
</foreach>
|
||||
</if>
|
||||
)
|
||||
</if>
|
||||
ORDER BY t.order_id ASC, t.execution_order ASC
|
||||
</script>
|
||||
""")
|
||||
Page<ReservationTaskEntity> selectFrontendTaskPage(
|
||||
Page<ReservationTaskEntity> page,
|
||||
@Param("hotelId") String hotelId,
|
||||
@Param("orderId") Long orderId,
|
||||
@Param("orderStatus") String orderStatus,
|
||||
@Param("taskType") String taskType,
|
||||
@Param("taskStatus") String taskStatus,
|
||||
@Param("taskSubtype") String taskSubtype,
|
||||
@Param("queueParticipation") Boolean queueParticipation,
|
||||
@Param("keyword") String keyword,
|
||||
@Param("keywordOrderIds") List<Long> keywordOrderIds,
|
||||
@Param("sourceMessageIds") List<Long> sourceMessageIds);
|
||||
}
|
||||
|
||||
@@ -453,29 +453,18 @@ public class MybatisReservationAiWorkflowRepository implements ReservationAiWork
|
||||
int pageSize) {
|
||||
List<Long> keywordOrderIds = findOrderIdsByKeyword(request.hotelId(), request.keyword());
|
||||
List<Long> sourceMessageIds = keywordSourceMessageIds == null ? List.of() : keywordSourceMessageIds;
|
||||
Page<ReservationTaskEntity> page = taskMapper.selectPage(Page.of(pageNum, pageSize),
|
||||
Wrappers.<ReservationTaskEntity>lambdaQuery()
|
||||
.eq(ReservationTaskEntity::getHotelId, request.hotelId())
|
||||
.eq(request.orderId() != null, ReservationTaskEntity::getOrderId, request.orderId())
|
||||
.eq(hasText(request.taskType()), ReservationTaskEntity::getSystemTaskType, trim(request.taskType()))
|
||||
.eq(hasText(request.taskStatus()), ReservationTaskEntity::getTaskStatus, trim(request.taskStatus()))
|
||||
.eq(hasText(request.taskSubtype()), ReservationTaskEntity::getTaskSubtype, trim(request.taskSubtype()))
|
||||
.eq(request.queueParticipation() != null,
|
||||
ReservationTaskEntity::getQueueParticipation,
|
||||
request.queueParticipation())
|
||||
.and(hasText(request.keyword()), wrapper -> {
|
||||
wrapper.like(ReservationTaskEntity::getSystemTaskType, trim(request.keyword()))
|
||||
.or()
|
||||
.like(ReservationTaskEntity::getTaskSubtype, trim(request.keyword()));
|
||||
if (!keywordOrderIds.isEmpty()) {
|
||||
wrapper.or().in(ReservationTaskEntity::getOrderId, keywordOrderIds);
|
||||
}
|
||||
if (!sourceMessageIds.isEmpty()) {
|
||||
wrapper.or().in(ReservationTaskEntity::getSourceMessageId, sourceMessageIds);
|
||||
}
|
||||
})
|
||||
.orderByAsc(ReservationTaskEntity::getOrderId)
|
||||
.orderByAsc(ReservationTaskEntity::getExecutionOrder));
|
||||
Page<ReservationTaskEntity> page = taskMapper.selectFrontendTaskPage(
|
||||
Page.of(pageNum, pageSize),
|
||||
request.hotelId(),
|
||||
request.orderId(),
|
||||
trim(request.orderStatus()),
|
||||
trim(request.taskType()),
|
||||
trim(request.taskStatus()),
|
||||
trim(request.taskSubtype()),
|
||||
request.queueParticipation(),
|
||||
trim(request.keyword()),
|
||||
keywordOrderIds,
|
||||
sourceMessageIds);
|
||||
return new ReservationPageSnapshot<>(
|
||||
toAiQueryTaskSnapshots(request.hotelId(), page.getRecords()),
|
||||
page.getTotal(),
|
||||
|
||||
@@ -177,6 +177,7 @@ public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQ
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
DEFAULT_PAGE_NUM,
|
||||
DEFAULT_PAGE_SIZE);
|
||||
}
|
||||
@@ -186,6 +187,7 @@ public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQ
|
||||
trimToNull(request.taskType()),
|
||||
trimToNull(request.taskStatus()),
|
||||
trimToNull(request.taskSubtype()),
|
||||
trimToNull(request.orderStatus()),
|
||||
request.queueParticipation(),
|
||||
trimToNull(request.keyword()),
|
||||
request.pageNum(),
|
||||
|
||||
@@ -132,7 +132,8 @@ class SourceMessageControllerTest {
|
||||
"mail-conversation-p0-001",
|
||||
"conversation-p0-001",
|
||||
"First full text body with booking context.",
|
||||
"<html><body><p>First HTML body</p><img src=\"cid:image-001\" /></body></html>",
|
||||
"<html><body><p>First HTML body</p><img src=\"cid:image-001\" onerror=\"alert(1)\" />"
|
||||
+ "<a href=\"javascript:alert(2)\">unsafe</a><script>alert(3)</script></body></html>",
|
||||
List.of(
|
||||
media("INLINE_IMAGE", "image-001.png", "image/png", 100L,
|
||||
"https://media.example.test/image-001.png?token=secret", "image-001"),
|
||||
@@ -161,6 +162,20 @@ class SourceMessageControllerTest {
|
||||
.value(contains(containsString("First full text body"))))
|
||||
.andExpect(jsonPath("$.messages[?(@.external_message_id=='mail-conversation-p0-001')].html_body")
|
||||
.value(contains(containsString("First HTML body"))))
|
||||
.andExpect(jsonPath("$.messages[?(@.external_message_id=='mail-conversation-p0-001')].html_body")
|
||||
.value(contains(containsString("<script>alert(3)</script>"))))
|
||||
.andExpect(jsonPath("$.messages[?(@.external_message_id=='mail-conversation-p0-001')].html_body_sanitized")
|
||||
.value(contains(containsString("First HTML body"))))
|
||||
.andExpect(jsonPath("$.messages[?(@.external_message_id=='mail-conversation-p0-001')].html_body_sanitized")
|
||||
.value(contains(not(containsString("<script")))))
|
||||
.andExpect(jsonPath("$.messages[?(@.external_message_id=='mail-conversation-p0-001')].html_body_sanitized")
|
||||
.value(contains(not(containsString("onerror")))))
|
||||
.andExpect(jsonPath("$.messages[?(@.external_message_id=='mail-conversation-p0-001')].html_body_sanitized")
|
||||
.value(contains(not(containsString("javascript:")))))
|
||||
.andExpect(jsonPath("$.messages[?(@.external_message_id=='mail-conversation-p0-001')].html_sanitize_required")
|
||||
.value(contains(true)))
|
||||
.andExpect(jsonPath("$.messages[?(@.external_message_id=='mail-conversation-p0-001')].html_render_mode")
|
||||
.value(contains("SANITIZED_HTML")))
|
||||
.andExpect(jsonPath("$.messages[?(@.external_message_id=='mail-conversation-p0-001')].inline_images[0].externalUrl")
|
||||
.value(contains("https://media.example.test/image-001.png?token=secret")))
|
||||
.andExpect(jsonPath("$.messages[?(@.external_message_id=='mail-conversation-p0-001')].attachments[0].externalUrl")
|
||||
|
||||
@@ -122,6 +122,40 @@ class ReservationFrontendQueryControllerTest {
|
||||
verify(workflowRepository, never()).findQueueTasksBefore(anyString(), anyLong(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldFilterTaskWorkbenchListByOrderStatus() throws Exception {
|
||||
SourceMessageCaptureResult source = captureSourceMessage(
|
||||
"mail-frontend-task-order-status-001",
|
||||
"Frontend Query Task Order Status Smoke");
|
||||
Long activeOrderId = 930000000000001101L;
|
||||
Long endedOrderId = 930000000000001102L;
|
||||
Long activeTaskId = 930000000000001301L;
|
||||
Long endedTaskId = 930000000000001302L;
|
||||
insertGroupOrder(activeOrderId, source.inboxId(), "GRP-FRONTEND-TASK-ACTIVE-001", "ACTIVE");
|
||||
insertGroupOrder(endedOrderId, source.inboxId(), "GRP-FRONTEND-TASK-ENDED-001", "ENDED");
|
||||
insertTransition(930000000000001201L, source.inboxId(), 1, "GRP-FRONTEND-TASK-ACTIVE-001",
|
||||
"New Booking", "NEW_BOOKING", "NEW_BOOKING");
|
||||
insertTransition(930000000000001202L, source.inboxId(), 2, "GRP-FRONTEND-TASK-ENDED-001",
|
||||
"Cancel Booking", "CANCEL_BOOKING", "CANCEL_BOOKING");
|
||||
insertTask(activeTaskId, activeOrderId, source.inboxId(), 930000000000001201L, "New Booking",
|
||||
"NEW_BOOKING", "NEW_BOOKING", "PENDING_CONFIRM", 1);
|
||||
insertTask(endedTaskId, endedOrderId, source.inboxId(), 930000000000001202L, "Cancel Booking",
|
||||
"CANCEL_BOOKING", "CANCEL_BOOKING", "PENDING_CONFIRM", 1);
|
||||
|
||||
mockMvc.perform(get("/api/reservation/tasks")
|
||||
.param("hotel_id", HOTEL_ID)
|
||||
.param("order_status", "ENDED")
|
||||
.param("keyword", "GRP-FRONTEND-TASK-")
|
||||
.param("page_num", "1")
|
||||
.param("page_size", "20"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.items.length()").value(1))
|
||||
.andExpect(jsonPath("$.items[0].task_id").value(endedTaskId.toString()))
|
||||
.andExpect(jsonPath("$.items[0].order_id").value(endedOrderId.toString()))
|
||||
.andExpect(jsonPath("$.items[0].display_order_key").value("GRP-FRONTEND-TASK-ENDED-001"))
|
||||
.andExpect(jsonPath("$.page.total").value(1));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnOrderDetailWithTaskTimeline() throws Exception {
|
||||
SourceMessageCaptureResult source = captureSourceMessage(
|
||||
|
||||
Reference in New Issue
Block a user