diff --git a/yunke-server/app/db/base.py b/yunke-server/app/db/base.py index 34e6318..5cbab4f 100644 --- a/yunke-server/app/db/base.py +++ b/yunke-server/app/db/base.py @@ -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, ) diff --git a/yunke-server/app/db/models.py b/yunke-server/app/db/models.py index 7738c90..40ddc79 100644 --- a/yunke-server/app/db/models.py +++ b/yunke-server/app/db/models.py @@ -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", + ), ) diff --git a/yunke-server/app/sync/cli.py b/yunke-server/app/sync/cli.py index fca3085..2d7a8fa 100644 --- a/yunke-server/app/sync/cli.py +++ b/yunke-server/app/sync/cli.py @@ -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) diff --git a/yunke-server/app/sync/scheduler.py b/yunke-server/app/sync/scheduler.py index 1586df4..7d13d50 100644 --- a/yunke-server/app/sync/scheduler.py +++ b/yunke-server/app/sync/scheduler.py @@ -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 diff --git a/yunke-server/migrations/versions/d8eee96cd017_add_room_id_index_for_group_chat_queries.py b/yunke-server/migrations/versions/d8eee96cd017_add_room_id_index_for_group_chat_queries.py new file mode 100644 index 0000000..3d0f1ee --- /dev/null +++ b/yunke-server/migrations/versions/d8eee96cd017_add_room_id_index_for_group_chat_queries.py @@ -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") diff --git a/yunkes/src/views/SearchView.vue b/yunkes/src/views/SearchView.vue index e3ca79a..3d2e587 100644 --- a/yunkes/src/views/SearchView.vue +++ b/yunkes/src/views/SearchView.vue @@ -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 ``. 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 `` / `` — 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) => `${m}`, - ) +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(() => { {{ formatTime(m.timestamp) }} -
+
+ +
@@ -415,18 +419,24 @@ nextTick(() => {
- + + + {{ channelLabel(c.channel) }} + > + + 最近聊天 {{ formatTime(c.last_chat_time) }} @@ -434,7 +444,10 @@ nextTick(() => {
手机: - + · 微信号:{{ cellText(c, 'friendWxAlias') }} @@ -684,7 +697,9 @@ nextTick(() => { gap: 8px; } -/* Match highlight — uses :deep so v-html picks it up despite `scoped`. */ +/* Match highlight — `` 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);