fix(conversation): use notLikeLeft to avoid over-broad malformed-id filter

The previous notLike(column, "%:") form auto-wraps the value with extra %
on both sides AND escapes the user-supplied %, producing a %%:% pattern
that matches any id CONTAINING a colon — silently filtering out every
webchat:<key>:<visitor>, feishu:<chatId>, cron:<jobId> conversation from
the admin list / page. The frontend sidebar ends up empty.

Switch to notLikeLeft(column, ":") which only prepends the wildcard,
giving the intended NOT LIKE '%:' (does not end with a colon).

Strengthen the three malformedIdGuard tests to assert on the bound param
value ("%:" — ends-with colon) in addition to the SQL keyword, so this
regression cannot return silently. The assertions must call
getTargetSql() first to trigger MyBatis-Plus's nested-wrapper param
merge — getParamNameValuePairs() is empty on the parent until then.
This commit is contained in:
倪程伟 2026-06-18 19:16:51 +08:00 committed by matevip
parent f70e56cfc3
commit 7c36b0d752
2 changed files with 23 additions and 13 deletions

View File

@ -210,9 +210,18 @@ public class ConversationService {
* Showing them in the console surfaces threads that 500/403 on open
* because the trailing ":" makes some reverse proxies strip the path
* tail (issue #369).
*
* <p>Uses {@code notLikeLeft} rather than {@code notLike(...,"%:")}: the
* latter auto-wraps the value with extra {@code %} on both sides AND
* escapes the user-supplied {@code %}, producing a {@code %%:%} pattern
* that matches <em>any id containing a colon</em> silently filtering
* out every {@code webchat:}, {@code feishu:}, {@code cron:}
* conversation from the list. {@code notLikeLeft} only prepends the
* wildcard, giving the intended {@code NOT LIKE '%:'} ("does not end
* with a colon").
*/
private void applyMalformedIdGuard(LambdaQueryWrapper<ConversationEntity> w) {
w.notLike(ConversationEntity::getConversationId, "%:");
w.notLikeLeft(ConversationEntity::getConversationId, ":");
}
/**

View File

@ -156,12 +156,12 @@ class ConversationServiceWebchatVisibilityTest {
service.listConversations("admin", 1L, true);
// Assert on the rendered SQL (not the param values, which MyBatis-Plus
// percent-escapes internally) so the test stays independent of that
// implementation detail.
String sql = captor.getValue().getTargetSql().toLowerCase();
assertThat(sql).contains("not like");
assertThat(sql).contains("conversation_id");
// The bound LIKE pattern must be exactly "%:" (ends-with colon), not
// "%%:%" (contains colon). The earlier notLike("%:") form produced
// the latter via MyBatis-Plus auto-wrapping + percent-escape, which
// filtered out every webchat: / feishu: / cron: conversation.
assertThat(captor.getValue().getTargetSql().toLowerCase()).contains("not like");
assertThat(captor.getValue().getParamNameValuePairs().values()).contains("%:");
}
@Test
@ -175,9 +175,10 @@ class ConversationServiceWebchatVisibilityTest {
service.pageConversations("admin", 1L, 1, 20, null);
String sql = captor.getValue().getTargetSql().toLowerCase();
assertThat(sql).contains("not like");
assertThat(sql).contains("conversation_id");
// Force the nested-wrapper param merge: getParamNameValuePairs() is
// empty until getTargetSql() (or equivalent) has been called once.
assertThat(captor.getValue().getTargetSql().toLowerCase()).contains("not like");
assertThat(captor.getValue().getParamNameValuePairs().values()).contains("%:");
}
@Test
@ -189,9 +190,9 @@ class ConversationServiceWebchatVisibilityTest {
service.listConversations("admin", 1L); // strict 2-arg
String sql = captor.getValue().getTargetSql().toLowerCase();
assertThat(sql).contains("not like");
assertThat(sql).contains("conversation_id");
// Force the nested-wrapper param merge before checking values.
assertThat(captor.getValue().getTargetSql().toLowerCase()).contains("not like");
assertThat(captor.getValue().getParamNameValuePairs().values()).contains("%:");
}
private static UserEntity user(String role) {