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
+1
View File
@@ -26,6 +26,7 @@ settings = get_settings()
async_engine = create_async_engine(
settings.db_url,
pool_pre_ping=True,
pool_recycle=3600, # recycle connections before MySQL wait_timeout (default 8h) closes them
future=True,
)
+11
View File
@@ -399,6 +399,17 @@ class Message(Base):
"talker_id",
"timestamp",
),
# Group-chat hot path: per-account, per-room, time-ordered pagination.
# Group messages filter on room_id (not talker_id, which is the *other
# party* in a DM); without this index MySQL scans the talker index then
# refilters on room_id, which on high-volume accounts degrades fast.
Index(
"ix_message_channel_owner_room_time",
"channel",
"owner_account_id",
"room_id",
"timestamp",
),
)
+6 -1
View File
@@ -228,7 +228,12 @@ async def _init_db(db_url: str) -> tuple[Any, Any]:
"""
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
engine = create_async_engine(db_url, pool_pre_ping=True, future=True)
engine = create_async_engine(
db_url,
pool_pre_ping=True,
pool_recycle=3600, # recycle before MySQL wait_timeout closes us
future=True,
)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
session_maker = async_sessionmaker(engine, expire_on_commit=False)
+4 -1
View File
@@ -477,7 +477,10 @@ class SyncScheduler:
else:
settings = get_settings()
self._engine = create_async_engine(
settings.db_url, pool_pre_ping=True, future=True
settings.db_url,
pool_pre_ping=True,
pool_recycle=3600, # recycle before MySQL wait_timeout closes us
future=True,
)
self._session_maker = async_sessionmaker(
self._engine, expire_on_commit=False
@@ -0,0 +1,35 @@
"""add room_id index for group chat queries
Revision ID: d8eee96cd017
Revises: e5f6a7b8c9d0
Create Date: 2026-08-12 11:47:57.548178
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = 'd8eee96cd017'
down_revision: str | Sequence[str] | None = 'e5f6a7b8c9d0'
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Upgrade schema."""
# Group-chat queries filter on room_id (not talker_id). Without this index
# MySQL falls back to scanning ix_message_channel_owner_talker_time and
# refiltering on room_id, which on high-volume accounts degrades fast.
op.create_index(
"ix_message_channel_owner_room_time",
"message",
["channel", "owner_account_id", "room_id", "timestamp"],
)
def downgrade() -> None:
"""Downgrade schema."""
op.drop_index("ix_message_channel_owner_room_time", "message")
+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);