fix(xss+perf): SearchView XSS消除(无v-html)+room_id索引+pool_recycle

- SearchView.vue: 用 highlightSegments() 返回 {text,hit} 数组替代 v-html+escapeHtml+regex,模板用 <template v-for> + <mark>/<span> 渲染,从结构上消除 XSS 风险(4 处 v-html 全部移除: 消息片段/客户昵称/realName/手机号)
- models.py: Message 新增 ix_message_channel_owner_room_time(channel,owner_account_id,room_id,timestamp),群聊查询过滤 room_id 而非 talker_id,原 talker 索引无法覆盖
- migrations/d8eee96cd017: 新增 room_id 索引的 upgrade/downgrade (offline SQL 已验证)
- base.py + scheduler.py + cli.py: 所有生产 create_async_engine 增加 pool_recycle=3600,在 MySQL wait_timeout(默认 8h)断连前主动回收

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
浅念
2026-08-12 11:50:45 +08:00
parent c261b2bc9a
commit 9a953553ec
6 changed files with 108 additions and 38 deletions
+51 -36
View File
@@ -79,33 +79,35 @@ function imageCell(row: Customer, camelKey: string): string | undefined {
const hasSearched = computed(() => submittedQ.value.length > 0)
/**
* Highlight occurrences of the keyword inside `text`. Returns HTML with
* matches wrapped in `<mark class="yk-search__hit">`. Escapes the surrounding
* text so user content can't break out of the snippet. Empty / no-match input
* returns the escaped plain text.
* Split `text` into segments, marking ones that match the active keyword.
* The template renders each segment via `<mark>` / `<span>` — NO v-html, so
* user content cannot break out of the snippet (XSS-safe by construction).
* Empty needle → single non-hit segment. Matching is case-insensitive; the
* original case is preserved in the output text.
*/
function highlight(text: string | null | undefined): string {
const needle = submittedQ.value.trim()
const body = text ?? ''
if (!body) return ''
const escaped = escapeHtml(body)
if (!needle) return escaped
const needleEsc = escapeHtml(needle)
// Escape regex metacharacters in the needle so a literal search is performed.
const needleRegex = needleEsc.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
return escaped.replace(
new RegExp(needleRegex, 'gi'),
(m) => `<mark class="yk-search__hit">${m}</mark>`,
)
interface HighlightSegment {
text: string
hit: boolean
}
function escapeHtml(s: string): string {
return s
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;')
function highlightSegments(text: string | null | undefined): HighlightSegment[] {
const body = text ?? ''
if (!body) return []
const needle = submittedQ.value.trim()
if (!needle) return [{ text: body, hit: false }]
const lower = body.toLowerCase()
const needleLower = needle.toLowerCase()
const segments: HighlightSegment[] = []
let lastIdx = 0
let idx = lower.indexOf(needleLower)
while (idx >= 0) {
if (idx > lastIdx) segments.push({ text: body.slice(lastIdx, idx), hit: false })
segments.push({ text: body.slice(idx, idx + needle.length), hit: true })
lastIdx = idx + needle.length
idx = lower.indexOf(needleLower, lastIdx)
}
if (lastIdx < body.length) segments.push({ text: body.slice(lastIdx), hit: false })
return segments
}
/** Snippet preview window around the first match (or the start of text). */
@@ -364,10 +366,12 @@ nextTick(() => {
</span>
<span class="yk-search__row-time">{{ formatTime(m.timestamp) }}</span>
</div>
<div
class="yk-search__row-snippet"
v-html="highlight(snippet(m.text))"
/>
<div class="yk-search__row-snippet">
<template v-for="(seg, i) in highlightSegments(snippet(m.text))">
<mark v-if="seg.hit" :key="`m${i}`" class="yk-search__hit">{{ seg.text }}</mark>
<span v-else :key="`s${i}`">{{ seg.text }}</span>
</template>
</div>
</div>
</div>
@@ -415,18 +419,24 @@ nextTick(() => {
</el-avatar>
<div class="yk-search__row-body">
<div class="yk-search__row-top">
<span
class="yk-search__row-sender"
v-html="highlight(c.friend_nickname ?? c.friend_id)"
/>
<span class="yk-search__row-sender">
<template v-for="(seg, i) in highlightSegments(c.friend_nickname ?? c.friend_id)">
<mark v-if="seg.hit" :key="`m${i}`" class="yk-search__hit">{{ seg.text }}</mark>
<span v-else :key="`s${i}`">{{ seg.text }}</span>
</template>
</span>
<el-tag :type="channelTagType(c.channel)" size="small" effect="plain">
{{ channelLabel(c.channel) }}
</el-tag>
<span
v-if="cellText(c, 'realName') !== '--'"
class="yk-search__row-target"
v-html="highlight(cellText(c, 'realName'))"
/>
>
<template v-for="(seg, i) in highlightSegments(cellText(c, 'realName'))">
<mark v-if="seg.hit" :key="`m${i}`" class="yk-search__hit">{{ seg.text }}</mark>
<span v-else :key="`s${i}`">{{ seg.text }}</span>
</template>
</span>
<span class="yk-search__row-time">
最近聊天 {{ formatTime(c.last_chat_time) }}
</span>
@@ -434,7 +444,10 @@ nextTick(() => {
<div class="yk-search__row-meta">
<span v-if="cellText(c, 'phone') !== '--'">
手机:
<span v-html="highlight(cellText(c, 'phone'))" />
<template v-for="(seg, i) in highlightSegments(cellText(c, 'phone'))">
<mark v-if="seg.hit" :key="`m${i}`" class="yk-search__hit">{{ seg.text }}</mark>
<span v-else :key="`s${i}`">{{ seg.text }}</span>
</template>
</span>
<span v-if="cellText(c, 'friendWxAlias') !== '--'">
· 微信号:{{ cellText(c, 'friendWxAlias') }}
@@ -684,7 +697,9 @@ nextTick(() => {
gap: 8px;
}
/* Match highlight — uses :deep so v-html <mark> picks it up despite `scoped`. */
/* Match highlight — `<mark>` elements are rendered in this template, so they
* carry the scoped attribute; `:deep` is retained so the rule still applies if
* the markup ever moves into a child component. */
.yk-search__list :deep(.yk-search__hit) {
background: var(--el-color-warning-light-7, #fdf6ec);
color: var(--el-color-warning-dark-2, #b88230);