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:
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
+35
@@ -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")
|
||||
@@ -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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''')
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user