Compare commits
10 Commits
50635d0df8
...
9ba8063ca8
| Author | SHA1 | Date | |
|---|---|---|---|
| 9ba8063ca8 | |||
| 0b03c9fb0d | |||
| e82e2ebea1 | |||
| 6de1662131 | |||
| 2c5cea1565 | |||
| b2ccbee1ab | |||
| 7e8624674e | |||
| ef7f6d8a23 | |||
| 35bfebd3e3 | |||
| e602822dfb |
@@ -22,6 +22,7 @@ media/
|
||||
|
||||
# Local scratch DBs used for Alembic autogenerate (see migrations/env.py)
|
||||
*.db
|
||||
*.db.retired
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
.playwright-mcp/
|
||||
|
||||
+52
-4
@@ -74,14 +74,62 @@ docker compose ps # 五服务全部 Up(healthy)
|
||||
docker compose logs -f scheduler # 看调度器日志
|
||||
```
|
||||
|
||||
### 4. 验证
|
||||
### 4. 数据库迁移(alembic schema)
|
||||
|
||||
后端启动**不会**自动建表,schema 由 alembic 迁移链管理(镜像已打包
|
||||
`alembic.ini` + `migrations/`,`DB_URL` 由 compose 注入)。
|
||||
|
||||
**新库首次部署**(空 MySQL 直接建全量表):
|
||||
|
||||
```bash
|
||||
docker compose exec backend alembic upgrade head
|
||||
```
|
||||
|
||||
**旧库升级**(2026-08-22 `migrate_sqlite_to_mysql.py` 搬迁产物,没有
|
||||
alembic_version 表)——2026-08-25 实战踩通的完整路径,原样记录:
|
||||
|
||||
```bash
|
||||
# 1) 前置校验:chat_group 无 NULL owner_account_id(复合 PK 迁移要求 NOT NULL,
|
||||
# 非 0 则是必须人工核对的数据问题,不要带病升级)
|
||||
docker compose exec mysql mysql -uyunke -p<密码> yunke \
|
||||
-e "SELECT COUNT(*) FROM chat_group WHERE owner_account_id IS NULL"
|
||||
|
||||
# 2) stamp 到搬迁脚本对应的链上位置,再升级到 head
|
||||
docker compose exec backend alembic stamp d8eee96cd017
|
||||
docker compose exec backend alembic upgrade head
|
||||
```
|
||||
|
||||
stamp 版本号不是拍脑袋定的:`d8eee96cd017` 是与 2026-08-22
|
||||
`migrate_sqlite_to_mysql`(按当时模型 `create_all` 建表)产物 schema
|
||||
一致的链上位置——stamp 它即声明「到此为止已应用」,其后迁移(chat_group
|
||||
复合 PK、群主列、类型修正等)逐一正常补上。其它日期建的旧库须自行核对
|
||||
表结构选 stamp 点,不要照抄这个版本号。
|
||||
|
||||
**每次拉新代码后**:
|
||||
|
||||
```bash
|
||||
docker compose exec backend alembic upgrade head
|
||||
```
|
||||
|
||||
> ⚠️ 类型变更类迁移(如 fe63c6789e94)在 MySQL 上为 COPY 算法、会锁
|
||||
> `message` 等大表写入——避开抓取窗口(每 4h 增量、02:00 全量)执行。
|
||||
|
||||
涉及存量数据回填的更新另跑回填脚本(幂等,重跑 0 改动;脚本默认连
|
||||
`./smoke.db`,服务器上须显式传 DB_URL——容器 env 已带,直接透传):
|
||||
|
||||
```bash
|
||||
docker compose exec backend python -c "import asyncio,os; from scripts.backfill_chat_group_fields import main; asyncio.run(main(os.environ['DB_URL']))"
|
||||
docker compose exec backend python -c "import asyncio,os; from scripts.backfill_msg_types import main; asyncio.run(main(os.environ['DB_URL']))"
|
||||
```
|
||||
|
||||
### 5. 验证
|
||||
|
||||
```bash
|
||||
curl http://localhost:8000/api/health # {"status":"ok"}
|
||||
curl http://localhost:5173/ # 前端页面
|
||||
```
|
||||
|
||||
### 5. 数据迁移(把本地 SQLite 数据搬到服务器 MySQL)
|
||||
### 6. 数据迁移(把本地 SQLite 数据搬到服务器 MySQL)
|
||||
|
||||
**方式 A:重抓(推荐,数据最新)**
|
||||
|
||||
@@ -100,7 +148,7 @@ DB_URL="mysql+asyncmy://yunke:<密码>@mysql:3306/yunke" \
|
||||
把本地 smoke.db 和 media/ 目录传到服务器,用 Python 脚本逐表导:
|
||||
(如有需要我可以写迁移脚本,~100 行)
|
||||
|
||||
### 6. 媒体文件
|
||||
### 7. 媒体文件
|
||||
|
||||
媒体存在 Docker volume `media_data` 里。本地已有 2.5GB 媒体的话:
|
||||
|
||||
@@ -122,7 +170,7 @@ docker run --rm -v yunke_media_data:/target -v $(pwd):/src alpine \
|
||||
| 换 cookie | 改 `.env` 的 `YK_COOKIE` → `docker compose restart scheduler backend` |
|
||||
| 备份 MySQL | `docker compose exec mysql mysqldump -uroot -p<密码> yunke > backup.sql` |
|
||||
| 备份媒体 | `docker run --rm -v yunke_media_data:/data -v $(pwd):/out alpine tar czf /out/media.tgz -C /data .` |
|
||||
| 升级版本 | `git pull && docker compose up -d --build` |
|
||||
| 升级版本 | `git pull && docker compose up -d --build && docker compose exec backend alembic upgrade head`(回填见「数据库迁移」) |
|
||||
|
||||
## 已知注意事项
|
||||
|
||||
|
||||
@@ -51,6 +51,12 @@ COPY app/ ./app/
|
||||
# full_sync_all.sh 由 scheduler._page_subprocess 以 cwd=/app 调起,
|
||||
# 2026-08-21 部署阻塞点#2:镜像里必须带上)。
|
||||
COPY scripts/ ./scripts/
|
||||
# alembic.ini + migrations/ — 容器内可跑 `alembic upgrade head`(DB_URL
|
||||
# 环境变量已由 compose 注入;entrypoint 不自动迁移,由运维手动执行,
|
||||
# 见 docs/DEPLOY.md「数据库迁移」)。alembic.ini 的 script_location 用
|
||||
# %(here)s 相对 ini 自身,落在 /app/migrations 正好对上。
|
||||
COPY alembic.ini ./
|
||||
COPY migrations/ ./migrations/
|
||||
|
||||
# Install the package + dev dependencies (the dev extras include aiosqlite +
|
||||
# pytest, which are tiny and convenient for in-container smoke checks). Pin
|
||||
|
||||
@@ -44,9 +44,9 @@ async def list_groups(
|
||||
|
||||
``user_id`` maps to the ``ChatGroup.owner_user_id`` column (the staff
|
||||
``userId`` who owns the chatroom account), mirroring the ``user_id``
|
||||
filter on ``GET /api/customers``. ``room_id`` is an exact-match single-
|
||||
row lookup (chat page group-name resolution). ``q`` is a substring match
|
||||
on ``room_name``.
|
||||
filter on ``GET /api/customers``. ``room_id`` is an exact-match lookup
|
||||
(composite PK: the same room may return one row per owner; chat page
|
||||
group-name resolution). ``q`` is a substring match on ``room_name``.
|
||||
"""
|
||||
conditions = []
|
||||
if channel is not None:
|
||||
|
||||
@@ -52,19 +52,9 @@ _MSG_TYPE_FILTER: dict[str, tuple[str, ...]] = {
|
||||
"mini_program": ("mini_program",),
|
||||
}
|
||||
|
||||
MsgTypeParam = Literal[
|
||||
"text",
|
||||
"image",
|
||||
"voice",
|
||||
"video",
|
||||
"link",
|
||||
"file",
|
||||
"red_packet",
|
||||
"transfer",
|
||||
"voice_call",
|
||||
"video_call",
|
||||
"mini_program",
|
||||
]
|
||||
# 派生而非重复罗列:加一类只改上面的 dict,Literal 与 422 校验自动跟上
|
||||
# (两处手写曾会漂移——漏一边即 500/422 不一致)
|
||||
MsgTypeParam = Literal[tuple(_MSG_TYPE_FILTER)]
|
||||
|
||||
|
||||
@router.get("/messages")
|
||||
|
||||
@@ -262,9 +262,11 @@ class Customer(Base):
|
||||
class ChatGroup(Base):
|
||||
"""聊天群 — source: ``POST /wechatweb/chatroom/getChatroomList``.
|
||||
|
||||
PK ``room_id`` is the source ``chatRoomId`` (ends with ``@chatroom``).
|
||||
The raw ``memberIds`` (semicolon-joined wxids) is kept verbatim in
|
||||
``member_ids``; the parsed members are mirrored into :class:`GroupMember`.
|
||||
PK is composite (``room_id``, ``channel``, ``owner_account_id``) — the
|
||||
same room mirrors to one row per owning staff account; ``room_id`` is the
|
||||
source ``chatRoomId`` (ends with ``@chatroom``). The raw ``memberIds``
|
||||
(semicolon-joined wxids) is kept verbatim in ``member_ids``; the parsed
|
||||
members are mirrored into :class:`GroupMember`.
|
||||
"""
|
||||
|
||||
__tablename__ = "chat_group"
|
||||
@@ -427,6 +429,18 @@ class Message(Base):
|
||||
"room_id",
|
||||
"timestamp",
|
||||
),
|
||||
# 今日收发聚合热路径:channel= + owner IN + timestamp>= 定位当天消息
|
||||
# (customer 列表行内 send_today/receive_today)。上面两个索引把
|
||||
# timestamp 排在 talker/room 之后,时间范围无法直接 seek;MySQL 预编译
|
||||
# 语句(asyncmy 走服务端 prepare)优化器弃索引转全表扫 86 万含大文本
|
||||
# 的行,实测 7.7s/次(2026-08-27),裸字面量也要 0.23s 扫 92 万索引项。
|
||||
# 此索引让 (channel, owner) 前缀下按 timestamp 直接落入当天几百行。
|
||||
Index(
|
||||
"ix_message_channel_owner_time",
|
||||
"channel",
|
||||
"owner_account_id",
|
||||
"timestamp",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
"""wechat_account 补 list_order 列
|
||||
|
||||
Revision ID: 9b0d0c133035
|
||||
Revises: 099ac2097fa2
|
||||
Create Date: 2026-08-25 20:16:46.324537
|
||||
|
||||
内容
|
||||
----
|
||||
补齐模型-迁移 drift:``wechat_account.list_order``(云客 wxUser/list 返回
|
||||
顺序,2026-08-24 加进模型)一直没有对应迁移——干净库(create_all 或纯
|
||||
alembic 链)上模型建表含该列、迁移链建表不含,``/api/accounts`` SELECT
|
||||
即 500(实测两次,Task 11/12 验证时被迫对 smoke.db 手动 ALTER)。
|
||||
|
||||
幂等:smoke.db 已被手动加过该列(其 alembic_version 仍指旧 head),直接
|
||||
add_column 会报 duplicate column——用 ``sa.inspect`` 先查列是否存在,
|
||||
存在则跳过(SQLite/MySQL 通用,不需要裸 PRAGMA/information_schema)。
|
||||
downgrade 对称:列不存在时跳过。
|
||||
"""
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '9b0d0c133035'
|
||||
down_revision: str | Sequence[str] | None = '099ac2097fa2'
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def _has_column(table: str, column: str) -> bool:
|
||||
insp = sa.inspect(op.get_bind())
|
||||
return column in {c["name"] for c in insp.get_columns(table)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
if _has_column("wechat_account", "list_order"):
|
||||
return # 已手动加过(smoke.db),幂等跳过
|
||||
op.add_column("wechat_account", sa.Column("list_order", sa.Integer(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
if not _has_column("wechat_account", "list_order"):
|
||||
return
|
||||
op.drop_column("wechat_account", "list_order")
|
||||
@@ -0,0 +1,68 @@
|
||||
"""MySQL message.text 建 ngram FULLTEXT 索引(搜索 LIKE 全扫 16s 回归修复)
|
||||
|
||||
Revision ID: b41e8fa0c7d2
|
||||
Revises: fe63c6789e94
|
||||
Create Date: 2026-08-27 15:55:00.000000
|
||||
|
||||
内容
|
||||
----
|
||||
``_search_messages_mysql``(search router)早就在用 ``MATCH(m.text) AGAINST``
|
||||
查询,但**没有任何环节在 MySQL 上建过 FULLTEXT 索引**——migrate_sqlite_to_mysql
|
||||
按模型 create_all 建表(不含 FULLTEXT),迁移链也没有——索引缺失时 MATCH 报
|
||||
错、代码回退 LIKE 全扫:2026-08-27 实测 860k 行 ``LIKE '%q%'`` 要 16.3s,前端
|
||||
axios 15s 超时直接 ECONNABORTED,搜索页全空(切 Docker MySQL 单库后暴露)。
|
||||
|
||||
本迁移补上索引:
|
||||
|
||||
* 仅 MySQL 方言执行;SQLite 跳过(那边 FTS5 虚表走 app/db/fts.py 的
|
||||
ensure_fts 体系,与本索引无关)。
|
||||
* ``WITH PARSER ngram``:中文/俄文等无空格分词文本按 bigram 切词,子串式
|
||||
匹配语义最接近原 LIKE 路径。
|
||||
* 幂等:``sa.inspect`` 先查同名索引,存在即跳过(重放安全)。
|
||||
|
||||
注意:86 万行建 ngram 索引约 1-3 分钟(表重建),避开抓取窗口执行。
|
||||
"""
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'b41e8fa0c7d2'
|
||||
down_revision: str | Sequence[str] | None = 'fe63c6789e94'
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
_INDEX_NAME = 'ft_message_text'
|
||||
|
||||
|
||||
def _has_index(table: str, index: str) -> bool:
|
||||
insp = sa.inspect(op.get_bind())
|
||||
return index in {i['name'] for i in insp.get_indexes(table)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
bind = op.get_bind()
|
||||
if bind.dialect.name != 'mysql':
|
||||
return # SQLite 的 FTS5 虚表由 app/db/fts.py ensure_fts 管,与此无关
|
||||
if _has_index('message', _INDEX_NAME):
|
||||
return # 已建过,幂等跳过
|
||||
# 原生 DDL:本版 SQLAlchemy 的 Index 不认 WITH PARSER 方言参数
|
||||
bind.execute(
|
||||
sa.text(
|
||||
f"CREATE FULLTEXT INDEX {_INDEX_NAME} ON message (text) "
|
||||
f"WITH PARSER ngram"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
bind = op.get_bind()
|
||||
if bind.dialect.name != 'mysql':
|
||||
return
|
||||
if not _has_index('message', _INDEX_NAME):
|
||||
return
|
||||
bind.execute(sa.text(f"DROP INDEX {_INDEX_NAME} ON message"))
|
||||
@@ -0,0 +1,57 @@
|
||||
"""message 补 (channel, owner_account_id, timestamp) 索引(客户列表 7.7s 回归修复)
|
||||
|
||||
Revision ID: c53f90b1e6a4
|
||||
Revises: b41e8fa0c7d2
|
||||
Create Date: 2026-08-27 16:40:00.000000
|
||||
|
||||
内容
|
||||
----
|
||||
客户列表行内的「今日发送/接收」聚合(customer.py 的 send_today/receive_today)
|
||||
按 ``channel=? AND owner IN (...) AND timestamp>=今日`` GROUP BY owner/talker。
|
||||
两个既有索引把 timestamp 排在 talker/room 之后,时间范围无法 seek——字面量
|
||||
SQL 时优化器还能忍(索引全扫 92 万项 0.23s),但 asyncmy 走**服务端预编译**,
|
||||
优化器弃索引转全表扫 86 万含大文本的宽行:**7.7s/次**,前端客户列表每次翻页
|
||||
都撞(2026-08-27 用户报「微信客户列表加载慢」实测 API 7.6s)。
|
||||
|
||||
本迁移补 (channel, owner_account_id, timestamp) 三列索引:owner 前缀下按
|
||||
timestamp 直接落入当天几百行,实测 0.14s → API 全链 0.097s(78 倍)。窄列
|
||||
BTREE,86 万行在线建索引实测 16s。
|
||||
|
||||
幂等:``sa.inspect`` 先查同名索引,存在即跳过。SQLite/MySQL 通用。
|
||||
"""
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'c53f90b1e6a4'
|
||||
down_revision: str | Sequence[str] | None = 'b41e8fa0c7d2'
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
_INDEX_NAME = 'ix_message_channel_owner_time'
|
||||
|
||||
|
||||
def _has_index(table: str, index: str) -> bool:
|
||||
insp = sa.inspect(op.get_bind())
|
||||
return index in {i['name'] for i in insp.get_indexes(table)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
if _has_index('message', _INDEX_NAME):
|
||||
return # 已建过(或先手工建过),幂等跳过
|
||||
op.create_index(
|
||||
_INDEX_NAME,
|
||||
'message',
|
||||
['channel', 'owner_account_id', 'timestamp'],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
if not _has_index('message', _INDEX_NAME):
|
||||
return
|
||||
op.drop_index(_INDEX_NAME, table_name='message')
|
||||
@@ -0,0 +1,170 @@
|
||||
"""Task 14 模型类型修正落进迁移链(URL Text + db_size BigInteger)
|
||||
|
||||
Revision ID: fe63c6789e94
|
||||
Revises: 9b0d0c133035
|
||||
Create Date: 2026-08-25 20:34:26.646844
|
||||
|
||||
内容
|
||||
----
|
||||
补齐模型-迁移 drift:2026-08-22 部署实战(commit 00ae13b)在**模型**上改了
|
||||
5 列类型,但迁移链从未跟上——
|
||||
|
||||
* message.file_url / file_th_url / file_hd_url、media.original_url:
|
||||
String(1024) → Text(分享链接可超 1KB,MySQL VARCHAR(1024) 会截断);
|
||||
* wechat_account.db_size: Integer → BigInteger(字节数可超 32 位 INT,
|
||||
实测 2.17GB)。
|
||||
|
||||
后果:纯 alembic 链建的干净库(deploy 文档的新库路径)拿到旧类型,静默
|
||||
复活已修掉的截断问题;``alembic check`` 常红。本迁移把链对齐到模型。
|
||||
|
||||
幂等性:ALTER MODIFY 到相同类型是合法 SQL——MySQL 上 Docker 库的表由
|
||||
migrate_sqlite_to_mysql(按当时模型 create_all,已是 Text/BIGINT)创建,
|
||||
重放本迁移为无害 no-op,无需条件守卫。SQLite 走 batch_alter_table 表重建
|
||||
(仅改声明类型,TEXT/INTEGER 亲和性不变,数据原样拷贝)。
|
||||
|
||||
⚠️ SQLite 侧副作用与自愈:message 表重建会丢其上的 FTS5 同步触发器、
|
||||
且 external-content 索引(message_fts 按 rowid 映射)会与新表 rowid
|
||||
脱节——而 message_fts 虚拟表本身还在,search 的 fts_available 门照常
|
||||
放行,搜索会**静默**返回陈旧结果。因此 upgrade/downgrade 在 SQLite 上
|
||||
就地修复(见 ``_repair_sqlite_fts``):重建 3 个同步触发器 + ``rebuild``
|
||||
按新 rowid 重灌索引,跑完本迁移无需再手工 ensure_fts/rebuild_fts。
|
||||
MySQL(prod)无 FTS5,该修复自动跳过。
|
||||
"""
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'fe63c6789e94'
|
||||
down_revision: str | Sequence[str] | None = '9b0d0c133035'
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
# FTS5 同步触发器 DDL —— 逐字镜像 app/db/fts.py 的 _FTS_DDL(仅触发器,
|
||||
# 不含 CREATE VIRTUAL TABLE:本修复只服务已启用 FTS 的库,不主动引入
|
||||
# 索引)。改 fts.py 时必须同步这里,否则修复出来的触发器与 ORM 侧漂移。
|
||||
_FTS_TRIGGER_DDLS = [
|
||||
"""
|
||||
CREATE TRIGGER IF NOT EXISTS message_fts_ai AFTER INSERT ON message BEGIN
|
||||
INSERT INTO message_fts(rowid, text) VALUES (new.rowid, new.text);
|
||||
END
|
||||
""",
|
||||
"""
|
||||
CREATE TRIGGER IF NOT EXISTS message_fts_au AFTER UPDATE ON message BEGIN
|
||||
INSERT INTO message_fts(message_fts, rowid, text)
|
||||
VALUES ('delete', old.rowid, old.text);
|
||||
INSERT INTO message_fts(rowid, text) VALUES (new.rowid, new.text);
|
||||
END
|
||||
""",
|
||||
"""
|
||||
CREATE TRIGGER IF NOT EXISTS message_fts_ad AFTER DELETE ON message BEGIN
|
||||
INSERT INTO message_fts(message_fts, rowid, text)
|
||||
VALUES ('delete', old.rowid, old.text);
|
||||
END
|
||||
""",
|
||||
]
|
||||
|
||||
|
||||
def _repair_sqlite_fts() -> None:
|
||||
"""修复 batch 重建 message 对 FTS5 索引的破坏;非 SQLite 方言 no-op。
|
||||
|
||||
batch_alter_table 重建 message(DROP 旧表连带其上触发器,新表 rowid
|
||||
重新分配)后,message_fts 表仍在但触发器没了、rowid 映射全错——
|
||||
fts_available 依然为真,搜索静默陈旧。就地补:重建 3 个触发器 +
|
||||
``rebuild`` 按新 rowid 重灌 external-content 索引。仅在 message_fts
|
||||
已存在(该库启用过 FTS)时执行;MySQL 无 FTS5 直接跳过。
|
||||
"""
|
||||
bind = op.get_bind()
|
||||
if bind.dialect.name != "sqlite":
|
||||
return
|
||||
has_fts = bind.execute(
|
||||
sa.text(
|
||||
"SELECT count(*) FROM sqlite_master "
|
||||
"WHERE type='table' AND name='message_fts'"
|
||||
)
|
||||
).scalar_one()
|
||||
if not has_fts:
|
||||
return
|
||||
for ddl in _FTS_TRIGGER_DDLS:
|
||||
bind.execute(sa.text(ddl))
|
||||
bind.execute(sa.text("INSERT INTO message_fts(message_fts) VALUES('rebuild')"))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
with op.batch_alter_table("message") as batch_op:
|
||||
batch_op.alter_column(
|
||||
"file_url",
|
||||
existing_type=sa.String(1024),
|
||||
type_=sa.Text(),
|
||||
existing_nullable=True,
|
||||
)
|
||||
batch_op.alter_column(
|
||||
"file_th_url",
|
||||
existing_type=sa.String(1024),
|
||||
type_=sa.Text(),
|
||||
existing_nullable=True,
|
||||
)
|
||||
batch_op.alter_column(
|
||||
"file_hd_url",
|
||||
existing_type=sa.String(1024),
|
||||
type_=sa.Text(),
|
||||
existing_nullable=True,
|
||||
)
|
||||
with op.batch_alter_table("media") as batch_op:
|
||||
batch_op.alter_column(
|
||||
"original_url",
|
||||
existing_type=sa.String(1024),
|
||||
type_=sa.Text(),
|
||||
existing_nullable=True,
|
||||
)
|
||||
with op.batch_alter_table("wechat_account") as batch_op:
|
||||
batch_op.alter_column(
|
||||
"db_size",
|
||||
existing_type=sa.Integer(),
|
||||
type_=sa.BigInteger(),
|
||||
existing_nullable=True,
|
||||
)
|
||||
# message 已被上方 batch 重建 —— SQLite 上补回 FTS 触发器并重灌索引
|
||||
_repair_sqlite_fts()
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
with op.batch_alter_table("wechat_account") as batch_op:
|
||||
batch_op.alter_column(
|
||||
"db_size",
|
||||
existing_type=sa.BigInteger(),
|
||||
type_=sa.Integer(),
|
||||
existing_nullable=True,
|
||||
)
|
||||
with op.batch_alter_table("media") as batch_op:
|
||||
batch_op.alter_column(
|
||||
"original_url",
|
||||
existing_type=sa.Text(),
|
||||
type_=sa.String(1024),
|
||||
existing_nullable=True,
|
||||
)
|
||||
with op.batch_alter_table("message") as batch_op:
|
||||
batch_op.alter_column(
|
||||
"file_hd_url",
|
||||
existing_type=sa.Text(),
|
||||
type_=sa.String(1024),
|
||||
existing_nullable=True,
|
||||
)
|
||||
batch_op.alter_column(
|
||||
"file_th_url",
|
||||
existing_type=sa.Text(),
|
||||
type_=sa.String(1024),
|
||||
existing_nullable=True,
|
||||
)
|
||||
batch_op.alter_column(
|
||||
"file_url",
|
||||
existing_type=sa.Text(),
|
||||
type_=sa.String(1024),
|
||||
existing_nullable=True,
|
||||
)
|
||||
# downgrade 同样重建了 message 表 —— FTS 修复与 upgrade 对称
|
||||
_repair_sqlite_fts()
|
||||
@@ -26,13 +26,22 @@ from app.storage.repos import _upsert_one
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||
log = logging.getLogger("backfill_group_members")
|
||||
|
||||
# max(timestamp) 裸列:SQLite 保证 sender_name 取自 timestamp 最大的那行
|
||||
# row_number() 窗口取 timestamp 最新一行的 sender_name:MySQL 8 的
|
||||
# only_full_group_by 拒绝 SQLite 式「max()+裸列」(1055),窗口写法两库通用
|
||||
# 且语义严格(最新一条的昵称),不依赖方言碰巧行为
|
||||
SQL = sa_text("""
|
||||
SELECT room_id, talker_id, sender_name, max(timestamp) AS last_ts, count(*) AS msg_count
|
||||
FROM message
|
||||
WHERE channel = 'wechat' AND room_id IS NOT NULL AND room_id != ''
|
||||
AND talker_id IS NOT NULL AND talker_id != ''
|
||||
GROUP BY room_id, talker_id
|
||||
SELECT room_id, talker_id, sender_name, last_ts, msg_count
|
||||
FROM (
|
||||
SELECT room_id, talker_id, sender_name, timestamp AS last_ts,
|
||||
count(*) OVER (PARTITION BY room_id, talker_id) AS msg_count,
|
||||
row_number() OVER (
|
||||
PARTITION BY room_id, talker_id ORDER BY timestamp DESC
|
||||
) AS rn
|
||||
FROM message
|
||||
WHERE channel = 'wechat' AND room_id IS NOT NULL AND room_id != ''
|
||||
AND talker_id IS NOT NULL AND talker_id != ''
|
||||
) t
|
||||
WHERE rn = 1
|
||||
""")
|
||||
|
||||
|
||||
|
||||
@@ -1561,6 +1561,8 @@ async def _iter_moments_pages(
|
||||
rows = data.get("data") if isinstance(data, dict) else None
|
||||
if not isinstance(rows, list):
|
||||
break
|
||||
# 每页一行进度(镜像 _fetch_pages 的 "<endpoint> page N: X rows")
|
||||
logger.info(f" friendster page {page_num}: {len(rows)} rows")
|
||||
for row in rows:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
|
||||
@@ -241,7 +241,7 @@ async def test_members_empty_room_is_200(make_client):
|
||||
# room_id 精确过滤(2026-08-20:群聊页群名单行直查,替代超上限的大页拉取)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def test_room_id_filter_returns_single_group(make_client):
|
||||
async def test_room_id_filter_returns_matching_groups(make_client):
|
||||
async def seed(session):
|
||||
session.add_all([
|
||||
ChatGroup(room_id="r1@chatroom", channel="wechat", room_name="群一", owner_account_id="o1"),
|
||||
@@ -257,6 +257,25 @@ async def test_room_id_filter_returns_single_group(make_client):
|
||||
assert body["items"][0]["room_name"] == "群二"
|
||||
|
||||
|
||||
async def test_room_id_filter_multi_owner_one_row_per_owner(make_client):
|
||||
"""同 room_id 多群主(复合 PK 允许)→ room_id 精确过滤返回每个群主一行。"""
|
||||
async def seed(session):
|
||||
session.add_all([
|
||||
ChatGroup(room_id="shared@chatroom", channel="wechat", room_name="共享群",
|
||||
owner_account_id="owner_1", owner_user_id="u1"),
|
||||
ChatGroup(room_id="shared@chatroom", channel="wechat", room_name="共享群",
|
||||
owner_account_id="owner_2", owner_user_id="u2"),
|
||||
])
|
||||
await session.commit()
|
||||
|
||||
client = await make_client(seed)
|
||||
r = await client.get("/api/groups", params={"room_id": "shared@chatroom"})
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["total"] == 2
|
||||
assert {it["owner_account_id"] for it in body["items"]} == {"owner_1", "owner_2"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 群主/建群补充列(2026-08-25 对齐云客员工聊天窗口·微信群 tab)
|
||||
# raw_json 键 ownerName/ownerWchatId/ownerAlias/ownerImage/friendCount/createTime
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<!-- 员工聊天窗口右列消息流(对齐云客 wechat-window 聊天区): 复制改造自
|
||||
ChatTimeline.vue 的主区(尾页落地分页/气泡/详情 drawer/导出全套),差异——
|
||||
① 默认近 14 天窗(清空=全部时间) ② 9 类类型下拉(msg_type 服务端过滤)
|
||||
① 默认全部时间(可选日期窗) ② 9 类类型下拉(msg_type 服务端过滤)
|
||||
③ ?msgid= 定位滚动+高亮 ④ 无返回按钮/无 sidebar(左侧由 StaffChatSide+
|
||||
SessionPane 承担)。
|
||||
根元素必须保持单 div——父组件 StaffChat 靠 attrs fallthrough 挂 sc-root__chat 列宽。 -->
|
||||
@@ -101,18 +101,12 @@ const conversationTitle = computed(() => {
|
||||
return isGroup.value ? `群聊记录 · ${displayName}` : `与 ${displayName} 的聊天记录`
|
||||
})
|
||||
|
||||
// ── 日期窗:云客对齐,默认近 14 天;清空 = 全部时间(null) ─────────────────────
|
||||
// ── 日期窗:默认全部时间(null);选窗后按窗过滤,清空回到全部 ──────────────────
|
||||
// route.query 合法 ms start/end 仍优先(带日期窗的跳转入口,如红包明细)。
|
||||
|
||||
/** 近 14 天窗:今天往前 13 天的 00:00:00 ~ 今天 23:59:59.999。 */
|
||||
function defaultRange(): [Date, Date] {
|
||||
const end = new Date()
|
||||
end.setHours(23, 59, 59, 999)
|
||||
const start = new Date()
|
||||
start.setDate(start.getDate() - 13)
|
||||
start.setHours(0, 0, 0, 0)
|
||||
return [start, end]
|
||||
}
|
||||
// 2026-08-27 前 defaultRange() 默认近 14 天——实测 78% 群/89% 单聊的最新消息
|
||||
// 在任意 14 天窗之外,这些会话打开即全空(用户报「很多聊天记录都是空的」);
|
||||
// 云客默认直接展示最近消息不限窗,故对齐改为默认全部。前端本就「首屏跳到
|
||||
// 最后一页看最新 + 上翻加载更旧」,全量窗下首屏仍只拉一页,性能无忧。
|
||||
|
||||
function rangeFromQuery(): [Date, Date] | null {
|
||||
const s = route.query.start,
|
||||
@@ -125,7 +119,7 @@ function rangeFromQuery(): [Date, Date] | null {
|
||||
return null
|
||||
}
|
||||
|
||||
const dateRange = ref<[Date, Date] | null>(rangeFromQuery() ?? defaultRange())
|
||||
const dateRange = ref<[Date, Date] | null>(rangeFromQuery() ?? null)
|
||||
const winStart = computed(() => dateRange.value?.[0].getTime())
|
||||
const winEnd = computed(() => dateRange.value?.[1].getTime())
|
||||
const keyword = ref('')
|
||||
@@ -442,7 +436,10 @@ const drawerTitle = computed(() => {
|
||||
</div>
|
||||
|
||||
<template v-if="messages.length === 0 && !loading">
|
||||
<div class="yk-empty">暂无聊天记录</div>
|
||||
<!-- 空态分语:有日期窗=窗滤空(提示可清窗),无窗=真没聊过 -->
|
||||
<div class="yk-empty">
|
||||
{{ dateRange ? '所选时间范围内无消息,清空日期窗可查看全部' : '该会话无聊天记录' }}
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="visibleMessages.length === 0 && keyword && !loading">
|
||||
<div class="yk-empty">当前已加载消息中无匹配</div>
|
||||
|
||||
@@ -40,7 +40,13 @@ function clearSearch() {
|
||||
searchTruncated.value = false
|
||||
}
|
||||
|
||||
/** 请求序号: ownerId/kind/tab/模式切换后在途旧响应整体丢弃(防竞态覆盖新员工/新 tab 的数据)。
|
||||
load 与 runSearch 共用同一序号——两者共享 loading/错误态,且 ownerId 切换只重发 load,
|
||||
共号才能把旧 owner 在途的搜索响应一并作废(对齐全 pane 惯例: MomentsPane 单 seq)。 */
|
||||
let seq = 0
|
||||
|
||||
async function load() {
|
||||
const mySeq = ++seq
|
||||
loading.value = true
|
||||
errorMsg.value = null
|
||||
clearSearch()
|
||||
@@ -50,12 +56,14 @@ async function load() {
|
||||
channel: props.channel,
|
||||
...(props.kind === 'single' ? { is_del: friendTab.value === 'deleted' ? 1 : 0 } : {}),
|
||||
})
|
||||
if (mySeq !== seq) return
|
||||
sessions.value = res.sessions
|
||||
} catch (e) {
|
||||
if (mySeq !== seq) return
|
||||
errorMsg.value = (e as Error).message
|
||||
sessions.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
if (mySeq === seq) loading.value = false
|
||||
}
|
||||
}
|
||||
watch(() => [props.ownerId, props.kind] as const, load, { immediate: true })
|
||||
@@ -128,6 +136,7 @@ async function runSearch() {
|
||||
clearSearch()
|
||||
return
|
||||
}
|
||||
const mySeq = ++seq
|
||||
loading.value = true
|
||||
errorMsg.value = null
|
||||
try {
|
||||
@@ -138,6 +147,7 @@ async function runSearch() {
|
||||
page: 1,
|
||||
page_size: 100,
|
||||
})
|
||||
if (mySeq !== seq) return
|
||||
searchTotal.value = res.total
|
||||
searchTruncated.value = res.total > res.items.length
|
||||
// 已加载的会话列表提供昵称/头像;未命中(search 只回消息)再以 sender_name/键兜底
|
||||
@@ -165,10 +175,11 @@ async function runSearch() {
|
||||
(a, b) => (b.last_chat_time ?? 0) - (a.last_chat_time ?? 0),
|
||||
)
|
||||
} catch (e) {
|
||||
if (mySeq !== seq) return
|
||||
errorMsg.value = (e as Error).message
|
||||
clearSearch()
|
||||
} finally {
|
||||
loading.value = false
|
||||
if (mySeq === seq) loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -228,7 +239,8 @@ async function runSearch() {
|
||||
<div class="sc-sessions__meta">
|
||||
<div class="sc-sessions__name">
|
||||
<span>{{ displayName(s) }}</span>
|
||||
<span v-if="s.today_count > 0" class="sc-sessions__today">今日{{ s.today_count }}</span>
|
||||
<!-- spec §5: 置顶不参与今日红点 -->
|
||||
<span v-if="s.today_count > 0 && !s.pinned" class="sc-sessions__today">今日{{ s.today_count }}</span>
|
||||
</div>
|
||||
<div class="sc-sessions__time">{{ relativeChatTime(s.last_chat_time) }}</div>
|
||||
</div>
|
||||
|
||||
@@ -9,50 +9,63 @@ import type { Account } from '@/api/types'
|
||||
* getAccounts(wechat, 100) + find 实现——提取为 composable 并加模块级缓存。
|
||||
* 一次响应的 100 条整页入缓存,同页其它员工切换 owner 直接命中不再请求;
|
||||
* 未命中/失败写 null(负缓存),避免重复打失败请求。
|
||||
* 注: 沿用原实现只拉第一页 page_size=100——员工数超过 100 时之后的查不到,
|
||||
* 属提取前就存在的历史限制,行为与提取前一致。
|
||||
*/
|
||||
const cache = new Map<string, Account | null>()
|
||||
/** 在途去重:同 ownerId 的并发未命中共享同一 Promise;缓存只由该共享 Promise 的 settle 单点回写 */
|
||||
const inflight = new Map<string, Promise<Account | null>>()
|
||||
|
||||
/** 命中缓存直接回调;否则请求一页 wechat 账号,整页回写缓存后回调。 */
|
||||
async function fetchProfile(ownerId: string, apply: (a: Account | null) => void): Promise<void> {
|
||||
if (cache.has(ownerId)) {
|
||||
apply(cache.get(ownerId) ?? null)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await getAccounts({ channel: 'wechat', page: 1, page_size: 100 })
|
||||
for (const a of res.items) cache.set(a.account_id, a)
|
||||
// find 不到也写 null(负缓存)
|
||||
const hit = cache.get(ownerId) ?? null
|
||||
cache.set(ownerId, hit)
|
||||
apply(hit)
|
||||
} catch {
|
||||
cache.set(ownerId, null)
|
||||
apply(null)
|
||||
}
|
||||
/**
|
||||
* 命中缓存直接返回;否则发起(或共享在途的)一页 wechat 账号请求,整页回写缓存后返回。
|
||||
* force=true 跳过缓存命中(含负缓存)强制刷新;在途请求本身即新鲜,仍共享不重复发。
|
||||
*/
|
||||
async function fetchProfile(ownerId: string, force = false): Promise<Account | null> {
|
||||
if (!force && cache.has(ownerId)) return cache.get(ownerId) ?? null
|
||||
const pending = inflight.get(ownerId)
|
||||
if (pending) return pending
|
||||
const p = (async () => {
|
||||
try {
|
||||
const res = await getAccounts({ channel: 'wechat', page: 1, page_size: 100 })
|
||||
for (const a of res.items) cache.set(a.account_id, a)
|
||||
// find 不到也写 null(负缓存)
|
||||
const hit = cache.get(ownerId) ?? null
|
||||
cache.set(ownerId, hit)
|
||||
return hit
|
||||
} catch {
|
||||
cache.set(ownerId, null)
|
||||
return null
|
||||
} finally {
|
||||
inflight.delete(ownerId)
|
||||
}
|
||||
})()
|
||||
inflight.set(ownerId, p)
|
||||
return p
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ownerId 员工 account_id(wxid);响应式传入时自动随变更重载
|
||||
* @returns profile 资料卡(null=未命中/失败,调用方自行兜底显示 ownerId);
|
||||
* load 手动重载入口(watch immediate 已覆盖常规场景,一般无需调用)
|
||||
* load(force=false) 手动重载入口:默认读缓存(命中即时返回,watch immediate
|
||||
* 已覆盖常规场景);传 true 强制重新请求,可穿透负缓存恢复(如后端瞬时故障后刷新)
|
||||
*/
|
||||
export function useStaffProfile(ownerId: string | Ref<string>): {
|
||||
profile: Ref<Account | null>
|
||||
load: () => void
|
||||
load: (force?: boolean) => void
|
||||
} {
|
||||
const idRef = typeof ownerId === 'string' ? ref(ownerId) : ownerId
|
||||
const profile = ref<Account | null>(cache.get(idRef.value) ?? null)
|
||||
// token 守卫: ownerId 快速切换时,旧 in-flight 响应不回写新 profile
|
||||
let token = 0
|
||||
|
||||
function load(): void {
|
||||
function load(force = false): void {
|
||||
const my = ++token
|
||||
const id = idRef.value
|
||||
void fetchProfile(id, (a) => {
|
||||
void fetchProfile(idRef.value, force).then((a) => {
|
||||
if (my === token) profile.value = a
|
||||
})
|
||||
}
|
||||
|
||||
watch(idRef, load, { immediate: true })
|
||||
// 包一层箭头:watch 回调首参是新 ownerId,直传 load 会被误当成 force 参数
|
||||
watch(idRef, () => load(), { immediate: true })
|
||||
return { profile, load }
|
||||
}
|
||||
|
||||
@@ -277,40 +277,38 @@ function viewCallCustomer(row: PhoneCall): void {
|
||||
const talker = cell(row, 'talker')
|
||||
if (!owner || !talker) return
|
||||
const t = String(talker)
|
||||
// 群语音的 talker 是 @chatroom ID → 走群聊路由(1:1 路由按 talker_id 查会
|
||||
const ch = chatChannel(row.channel) ?? 'wechat'
|
||||
// 全时段进入,不带时间窗(2026-08-27 用户定调:所有聊天记录不做时间限制;
|
||||
// 此前的「通话当天」定位窗与 WA ?start= 均已移除——后者本就被 ChatTimeline
|
||||
// 的 start+end 成对校验忽略。要看某时段在聊天窗里自选日期)。
|
||||
// 群语音的 talker 是 @chatroom ID → 群路由(1:1 路由按 talker_id 查会
|
||||
// 查空,同 chat_group owner 错位类问题,2026-08-22)
|
||||
if (t.endsWith('@chatroom')) {
|
||||
router.push({
|
||||
name: 'group-chat',
|
||||
params: {
|
||||
channel: chatChannel(row.channel) ?? 'wechat',
|
||||
ownerId: String(owner),
|
||||
roomId: t,
|
||||
},
|
||||
query: row.start_call_time ? { start: String(row.start_call_time) } : undefined,
|
||||
})
|
||||
router.push(
|
||||
ch === 'wechat'
|
||||
? { name: 'staff-chat', params: { ownerId: String(owner), tab: 'group', talkerId: t } }
|
||||
: { name: 'group-chat', params: { channel: ch, ownerId: String(owner), roomId: t } },
|
||||
)
|
||||
return
|
||||
}
|
||||
router.push({
|
||||
name: 'chat',
|
||||
params: {
|
||||
channel: chatChannel(row.channel) ?? 'wechat',
|
||||
ownerId: String(owner),
|
||||
talkerId: t,
|
||||
},
|
||||
// 对齐云客:带通话时间定位(?start=),聊天窗滚动到该通通话附近
|
||||
query: row.start_call_time ? { start: String(row.start_call_time) } : undefined,
|
||||
})
|
||||
// 微信 1:1 → 新员工聊天窗口;WA 保留旧 ChatTimeline(新窗口仅微信)。
|
||||
router.push(
|
||||
ch === 'wechat'
|
||||
? { name: 'staff-chat', params: { ownerId: String(owner), tab: 'single', talkerId: t } }
|
||||
: { name: 'chat', params: { channel: ch, ownerId: String(owner), talkerId: t } },
|
||||
)
|
||||
}
|
||||
|
||||
/** 员工微信昵称 → 账号级聊天窗(复用 step 5 导航)。 */
|
||||
function viewCallStaff(row: PhoneCall): void {
|
||||
const owner = cell(row, 'weChatId')
|
||||
if (!owner) return
|
||||
router.push({
|
||||
name: 'chat',
|
||||
params: { channel: chatChannel(row.channel) ?? 'wechat', ownerId: String(owner) },
|
||||
})
|
||||
const ch = chatChannel(row.channel) ?? 'wechat'
|
||||
if (ch === 'wechat') {
|
||||
router.push({ name: 'staff-chat', params: { ownerId: String(owner), tab: 'single' } })
|
||||
return
|
||||
}
|
||||
router.push({ name: 'chat', params: { channel: ch, ownerId: String(owner) } })
|
||||
}
|
||||
|
||||
const callDrawerTitle = computed(() =>
|
||||
|
||||
@@ -207,6 +207,18 @@ function openMessage(m: Message): void {
|
||||
ElMessage.warning('该消息缺少归属账号,无法定位会话')
|
||||
return
|
||||
}
|
||||
// 微信 → 新员工聊天窗口(群消息=group tab 按 room_id);WA 保留旧 ChatTimeline。
|
||||
if (m.channel === 'wechat') {
|
||||
router.push({
|
||||
name: 'staff-chat',
|
||||
params: {
|
||||
ownerId: m.owner_account_id,
|
||||
tab: m.room_id ? 'group' : 'single',
|
||||
talkerId: (m.room_id ?? m.talker_id) as string,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
router.push({
|
||||
name: 'chat',
|
||||
params: {
|
||||
@@ -233,6 +245,14 @@ function goChatFromDrawer(): void {
|
||||
ElMessage.warning('该客户缺少归属账号,无法查看聊天记录')
|
||||
return
|
||||
}
|
||||
// 微信 → 新员工聊天窗口;WA 保留旧 ChatTimeline(与 CustomerList.viewChat 一致)。
|
||||
if (row.channel === 'wechat') {
|
||||
router.push({
|
||||
name: 'staff-chat',
|
||||
params: { ownerId: row.owner_account_id, tab: 'single', talkerId: row.friend_id },
|
||||
})
|
||||
return
|
||||
}
|
||||
router.push({
|
||||
name: 'chat',
|
||||
params: {
|
||||
|
||||
@@ -374,14 +374,14 @@ function joinFields(...vals: (string | undefined | null)[]): string {
|
||||
return parts.length ? parts.join(' ') : '--'
|
||||
}
|
||||
|
||||
/** modal 客户微信 → 该员工×客户聊天(带 StaffList 当前日期窗)。缺失 owner/talker 静默 no-op。
|
||||
* 微信 → 新员工聊天窗口 single tab(modal 跳转不带 msgid 定位)。 */
|
||||
/** modal 客户微信 → 该员工×客户聊天(全时段,不带统计日期窗——2026-08-27
|
||||
* 用户定调所有聊天记录不做时间限制,要看某时段在聊天窗里自选日期)。缺失
|
||||
* owner/talker 静默 no-op。微信 → 新员工聊天窗口 single tab(不带 msgid 定位)。 */
|
||||
function openCustomerChat(row: Account, customer: StaffChatCustomer): void {
|
||||
if (!row.account_id || !customer.talker_id) return
|
||||
router.push({
|
||||
name: 'staff-chat',
|
||||
params: { ownerId: row.account_id, tab: 'single', talkerId: customer.talker_id },
|
||||
query: { start: String(statsStart.value), end: String(statsEnd.value) },
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user