补齐任务列表状态筛选和邮件HTML安全字段

This commit is contained in:
andy
2026-07-09 00:15:51 +08:00
parent a0cb3a05d7
commit 855396e553
13 changed files with 203 additions and 43 deletions

View File

@@ -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,

View File

@@ -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;
}
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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);
}

View File

@@ -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(),

View File

@@ -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(),

View File

@@ -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")

View File

@@ -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(