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(
|
async_engine = create_async_engine(
|
||||||
settings.db_url,
|
settings.db_url,
|
||||||
pool_pre_ping=True,
|
pool_pre_ping=True,
|
||||||
|
pool_recycle=3600, # recycle connections before MySQL wait_timeout (default 8h) closes them
|
||||||
future=True,
|
future=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -399,6 +399,17 @@ class Message(Base):
|
|||||||
"talker_id",
|
"talker_id",
|
||||||
"timestamp",
|
"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
|
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:
|
async with engine.begin() as conn:
|
||||||
await conn.run_sync(Base.metadata.create_all)
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
session_maker = async_sessionmaker(engine, expire_on_commit=False)
|
session_maker = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
|||||||
@@ -477,7 +477,10 @@ class SyncScheduler:
|
|||||||
else:
|
else:
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
self._engine = create_async_engine(
|
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._session_maker = async_sessionmaker(
|
||||||
self._engine, expire_on_commit=False
|
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)
|
const hasSearched = computed(() => submittedQ.value.length > 0)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Highlight occurrences of the keyword inside `text`. Returns HTML with
|
* Split `text` into segments, marking ones that match the active keyword.
|
||||||
* matches wrapped in `<mark class="yk-search__hit">`. Escapes the surrounding
|
* The template renders each segment via `<mark>` / `<span>` — NO v-html, so
|
||||||
* text so user content can't break out of the snippet. Empty / no-match input
|
* user content cannot break out of the snippet (XSS-safe by construction).
|
||||||
* returns the escaped plain text.
|
* 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 {
|
interface HighlightSegment {
|
||||||
const needle = submittedQ.value.trim()
|
text: string
|
||||||
const body = text ?? ''
|
hit: boolean
|
||||||
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>`,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(s: string): string {
|
function highlightSegments(text: string | null | undefined): HighlightSegment[] {
|
||||||
return s
|
const body = text ?? ''
|
||||||
.replaceAll('&', '&')
|
if (!body) return []
|
||||||
.replaceAll('<', '<')
|
const needle = submittedQ.value.trim()
|
||||||
.replaceAll('>', '>')
|
if (!needle) return [{ text: body, hit: false }]
|
||||||
.replaceAll('"', '"')
|
const lower = body.toLowerCase()
|
||||||
.replaceAll("'", ''')
|
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). */
|
/** Snippet preview window around the first match (or the start of text). */
|
||||||
@@ -364,10 +366,12 @@ nextTick(() => {
|
|||||||
</span>
|
</span>
|
||||||
<span class="yk-search__row-time">{{ formatTime(m.timestamp) }}</span>
|
<span class="yk-search__row-time">{{ formatTime(m.timestamp) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div class="yk-search__row-snippet">
|
||||||
class="yk-search__row-snippet"
|
<template v-for="(seg, i) in highlightSegments(snippet(m.text))">
|
||||||
v-html="highlight(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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -415,18 +419,24 @@ nextTick(() => {
|
|||||||
</el-avatar>
|
</el-avatar>
|
||||||
<div class="yk-search__row-body">
|
<div class="yk-search__row-body">
|
||||||
<div class="yk-search__row-top">
|
<div class="yk-search__row-top">
|
||||||
<span
|
<span class="yk-search__row-sender">
|
||||||
class="yk-search__row-sender"
|
<template v-for="(seg, i) in highlightSegments(c.friend_nickname ?? c.friend_id)">
|
||||||
v-html="highlight(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">
|
<el-tag :type="channelTagType(c.channel)" size="small" effect="plain">
|
||||||
{{ channelLabel(c.channel) }}
|
{{ channelLabel(c.channel) }}
|
||||||
</el-tag>
|
</el-tag>
|
||||||
<span
|
<span
|
||||||
v-if="cellText(c, 'realName') !== '--'"
|
v-if="cellText(c, 'realName') !== '--'"
|
||||||
class="yk-search__row-target"
|
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">
|
<span class="yk-search__row-time">
|
||||||
最近聊天 {{ formatTime(c.last_chat_time) }}
|
最近聊天 {{ formatTime(c.last_chat_time) }}
|
||||||
</span>
|
</span>
|
||||||
@@ -434,7 +444,10 @@ nextTick(() => {
|
|||||||
<div class="yk-search__row-meta">
|
<div class="yk-search__row-meta">
|
||||||
<span v-if="cellText(c, 'phone') !== '--'">
|
<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>
|
||||||
<span v-if="cellText(c, 'friendWxAlias') !== '--'">
|
<span v-if="cellText(c, 'friendWxAlias') !== '--'">
|
||||||
· 微信号:{{ cellText(c, 'friendWxAlias') }}
|
· 微信号:{{ cellText(c, 'friendWxAlias') }}
|
||||||
@@ -684,7 +697,9 @@ nextTick(() => {
|
|||||||
gap: 8px;
|
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) {
|
.yk-search__list :deep(.yk-search__hit) {
|
||||||
background: var(--el-color-warning-light-7, #fdf6ec);
|
background: var(--el-color-warning-light-7, #fdf6ec);
|
||||||
color: var(--el-color-warning-dark-2, #b88230);
|
color: var(--el-color-warning-dark-2, #b88230);
|
||||||
|
|||||||
Reference in New Issue
Block a user