2026-08-01 11:15:34 +08:00
|
|
|
|
"""Core ORM models for the yunke data-mirror system.
|
|
|
|
|
|
|
|
|
|
|
|
Every model maps an entity observed in the yunke CRM API (see
|
|
|
|
|
|
``docs/superpowers/specs/2026-08-01-yunke-data-dictionary.md``) to a SQL table.
|
|
|
|
|
|
Models target **MySQL in production** but keep column types portable so the
|
|
|
|
|
|
suite can exercise them on **in-memory SQLite** (see ``tests/test_models.py``).
|
|
|
|
|
|
|
|
|
|
|
|
Conventions
|
|
|
|
|
|
-----------
|
|
|
|
|
|
* SQLAlchemy 2.0 ``Mapped``/``mapped_column`` style; all classes extend
|
|
|
|
|
|
``app.db.base.Base``.
|
|
|
|
|
|
* API field names are mapped to ``snake_case`` columns; the original payload is
|
|
|
|
|
|
preserved verbatim in ``raw_json`` (``sa.JSON``).
|
|
|
|
|
|
* Common audit columns on every source-mirrored table:
|
|
|
|
|
|
- ``version`` — source update stamp (string when the API gives a version
|
|
|
|
|
|
token, BigInteger when it is a ms timestamp).
|
|
|
|
|
|
- ``raw_json`` — the original API record (``sa.JSON``).
|
|
|
|
|
|
- ``fetched_at`` — when we pulled the row (server default ``now()``).
|
|
|
|
|
|
- ``updated_at`` — when the row was last written (server default + onupdate).
|
|
|
|
|
|
* ``channel`` is only added where it is *meaningful*:
|
|
|
|
|
|
- Communication tables (``Customer``, ``Message``, ``Media``, ``ChatGroup``,
|
|
|
|
|
|
``GroupMember``, ``Sms``) use ``channel`` in {``wechat``, ``whatsapp``,
|
|
|
|
|
|
``sms``}.
|
|
|
|
|
|
- ``PhoneCall`` uses ``channel`` to mean call-type in {``phone_call``,
|
|
|
|
|
|
``voice_call``} (calls are not tied to a messaging channel).
|
|
|
|
|
|
- ``SyncState`` uses ``channel`` to scope a sync cursor (together with
|
|
|
|
|
|
``entity_type``).
|
|
|
|
|
|
- ``Staff``, ``Department``, ``WechatAccount``, ``WhatsappAccount``,
|
2026-08-25 13:44:54 +08:00
|
|
|
|
``YkAccount``, ``SyncRun``, ``MomentsPost`` carry no ``channel`` column.
|
2026-08-01 11:15:34 +08:00
|
|
|
|
* Primary keys use the natural source id where one exists; otherwise an
|
|
|
|
|
|
integer surrogate PK is used (``SyncState``, ``YkAccount``, ``SyncRun``).
|
|
|
|
|
|
* ``GroupMember`` uses a composite PK ``(channel, room_id, member_id)``.
|
|
|
|
|
|
|
|
|
|
|
|
The ``Message`` model currently maps to a single physical ``message`` table.
|
|
|
|
|
|
Real-world volume is expected at millions of rows/month, so the long-term plan
|
|
|
|
|
|
is time-sharded physical tables named via :func:`message_table_name`. That
|
|
|
|
|
|
sharding is a LATER task — for now every message row lives in ``message`` and
|
|
|
|
|
|
the helper below is provided so call-sites can already agree on the naming
|
|
|
|
|
|
convention.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import datetime as dt
|
|
|
|
|
|
from typing import Any
|
|
|
|
|
|
|
|
|
|
|
|
import sqlalchemy as sa
|
2026-08-22 12:40:44 +08:00
|
|
|
|
from sqlalchemy.dialects.mysql import MEDIUMTEXT # noqa: E402
|
2026-08-01 11:15:34 +08:00
|
|
|
|
from sqlalchemy import BigInteger, Boolean, DateTime, Index, Integer, String, Text
|
|
|
|
|
|
from sqlalchemy.orm import Mapped, mapped_column
|
|
|
|
|
|
from sqlalchemy.types import JSON
|
|
|
|
|
|
|
|
|
|
|
|
from app.db.base import Base
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# Helpers
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def message_table_name(channel: str, timestamp: int | dt.datetime) -> str:
|
|
|
|
|
|
"""Return the conventional sharded table name for a message.
|
|
|
|
|
|
|
|
|
|
|
|
Format: ``message_<channel>_<YYYYMM>`` where ``YYYYMM`` is derived from the
|
|
|
|
|
|
message timestamp. ``timestamp`` may be either:
|
|
|
|
|
|
|
|
|
|
|
|
* a Unix epoch in **milliseconds** (``int``) — the form used by the
|
|
|
|
|
|
``Message.timestamp`` column and the yunke API, or
|
|
|
|
|
|
* a ``datetime.datetime`` instance (any tz).
|
|
|
|
|
|
|
|
|
|
|
|
Examples
|
|
|
|
|
|
--------
|
|
|
|
|
|
>>> import datetime as dt
|
|
|
|
|
|
>>> message_table_name("wechat", 1_700_000_000_000)
|
|
|
|
|
|
'message_wechat_202311'
|
|
|
|
|
|
>>> message_table_name("whatsapp", dt.datetime(2024, 1, 5))
|
|
|
|
|
|
'message_whatsapp_202401'
|
|
|
|
|
|
"""
|
|
|
|
|
|
if isinstance(timestamp, dt.datetime):
|
|
|
|
|
|
ts = timestamp
|
|
|
|
|
|
else:
|
|
|
|
|
|
# Treat int as ms-since-epoch (UTC). ``float`` also tolerated.
|
|
|
|
|
|
ts = dt.datetime.fromtimestamp(int(timestamp) / 1000, tz=dt.UTC)
|
|
|
|
|
|
return f"message_{channel}_{ts.strftime('%Y%m')}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# Shared column defaults
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _fetched_at() -> Mapped[dt.datetime]:
|
|
|
|
|
|
return mapped_column(
|
|
|
|
|
|
DateTime, server_default=sa.func.now(), nullable=False
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _updated_at() -> Mapped[dt.datetime]:
|
|
|
|
|
|
return mapped_column(
|
|
|
|
|
|
DateTime,
|
|
|
|
|
|
server_default=sa.func.now(),
|
|
|
|
|
|
onupdate=sa.func.now(),
|
|
|
|
|
|
nullable=False,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ===========================================================================
|
|
|
|
|
|
# 1. Organization & people
|
|
|
|
|
|
# ===========================================================================
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class Department(Base):
|
|
|
|
|
|
"""云客 部门/分组 — source: ``POST /usercenter/dept/tree``."""
|
|
|
|
|
|
|
|
|
|
|
|
__tablename__ = "department"
|
|
|
|
|
|
|
|
|
|
|
|
dept_id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
|
|
|
|
|
parent_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
|
|
|
|
name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
|
|
|
|
path: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
|
|
|
|
|
|
|
|
|
|
|
raw_json: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
|
|
|
|
|
fetched_at: Mapped[dt.datetime] = _fetched_at()
|
|
|
|
|
|
updated_at: Mapped[dt.datetime] = _updated_at()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class Staff(Base):
|
|
|
|
|
|
"""员工 — embedded in account lists (``userId`` + ``userName``)."""
|
|
|
|
|
|
|
|
|
|
|
|
__tablename__ = "staff"
|
|
|
|
|
|
|
|
|
|
|
|
user_id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
|
|
|
|
|
name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
|
|
|
|
phone: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
|
|
|
|
sale_phone: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
|
|
|
|
dept_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
|
|
|
|
is_on_job: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
|
|
|
|
|
|
|
|
|
|
|
|
raw_json: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
|
|
|
|
|
fetched_at: Mapped[dt.datetime] = _fetched_at()
|
|
|
|
|
|
updated_at: Mapped[dt.datetime] = _updated_at()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ===========================================================================
|
|
|
|
|
|
# 2. Staff messaging accounts (channel identity, so no `channel` column)
|
|
|
|
|
|
# ===========================================================================
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class WechatAccount(Base):
|
|
|
|
|
|
"""员工微信号 — source: ``POST /wechatweb/wxUser/list`` (wechatId PK)."""
|
|
|
|
|
|
|
|
|
|
|
|
__tablename__ = "wechat_account"
|
|
|
|
|
|
|
|
|
|
|
|
wx_id: Mapped[str] = mapped_column(String(128), primary_key=True)
|
|
|
|
|
|
nick_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
|
|
|
|
alias: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
|
|
|
|
phone: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
|
|
|
|
user_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
|
|
|
|
user_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
|
|
|
|
contacts_num: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
|
|
|
|
group_num: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
|
|
|
|
send_num: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
|
|
|
|
receive_num: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
|
|
|
|
is_on_job: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
|
2026-08-22 10:40:04 +08:00
|
|
|
|
db_size: Mapped[int | None] = mapped_column(BigInteger, nullable=True) # 字节数可超 32 位 INT(实测 2.17GB,2026-08-21)
|
2026-08-01 11:15:34 +08:00
|
|
|
|
online_status: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
|
|
|
|
|
last_update: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
|
|
|
|
|
|
|
|
|
|
|
version: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
2026-08-24 11:25:33 +08:00
|
|
|
|
# 云客 wxUser/list 返回顺序(对齐云客员工列表排列;NULL=未回填按 wx_id 排)
|
|
|
|
|
|
list_order: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
2026-08-01 11:15:34 +08:00
|
|
|
|
raw_json: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
|
|
|
|
|
fetched_at: Mapped[dt.datetime] = _fetched_at()
|
|
|
|
|
|
updated_at: Mapped[dt.datetime] = _updated_at()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class WhatsappAccount(Base):
|
|
|
|
|
|
"""员工 WhatsApp 账号 — source: ``POST /qqweb/WhatsAppUser/businessSalesList``.
|
|
|
|
|
|
|
|
|
|
|
|
PK is ``uid`` of the form ``<phone>@s.whatsapp.net``.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
__tablename__ = "whatsapp_account"
|
|
|
|
|
|
|
|
|
|
|
|
uid: Mapped[str] = mapped_column(String(128), primary_key=True)
|
|
|
|
|
|
user_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
|
|
|
|
user_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
|
|
|
|
nick_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
|
|
|
|
phone: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
|
|
|
|
sale_phone: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
|
|
|
|
sales_bind_phone: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
|
|
|
|
contacts_num: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
|
|
|
|
group_num: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
|
|
|
|
talker_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
|
|
|
|
friend_add_num: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
|
|
|
|
is_on_job: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
|
|
|
|
|
|
last_update: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
|
|
|
|
|
|
|
|
|
|
|
version: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
|
|
|
|
raw_json: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
|
|
|
|
|
fetched_at: Mapped[dt.datetime] = _fetched_at()
|
|
|
|
|
|
updated_at: Mapped[dt.datetime] = _updated_at()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ===========================================================================
|
|
|
|
|
|
# 3. Communication entities (carry a meaningful `channel` column)
|
|
|
|
|
|
# ===========================================================================
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class Customer(Base):
|
|
|
|
|
|
"""客户/好友 (WeChat friend OR WhatsApp contact OR SMS peer).
|
|
|
|
|
|
|
|
|
|
|
|
Source: ``POST /wechatweb/wxFriends/friend_list/es`` (WeChat) or
|
|
|
|
|
|
``POST /qqweb/WhatsAppUser/businessFriendList`` (WhatsApp). PK is the
|
|
|
|
|
|
source friend id: ``friendWxId`` for WeChat, ``<phone>@s.whatsapp.net``
|
|
|
|
|
|
for WhatsApp.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
__tablename__ = "customer"
|
|
|
|
|
|
|
|
|
|
|
|
friend_id: Mapped[str] = mapped_column(String(128), primary_key=True)
|
|
|
|
|
|
channel: Mapped[str] = mapped_column(String(16), nullable=False)
|
|
|
|
|
|
owner_account_id: Mapped[str | None] = mapped_column(
|
|
|
|
|
|
String(128), nullable=True
|
|
|
|
|
|
) # wxId (WeChat) / uid (WhatsApp) — owning staff account
|
|
|
|
|
|
friend_nickname: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
|
|
|
|
friend_remark: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
|
|
|
|
real_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
|
|
|
|
phone: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
|
|
|
|
wechat_phone: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
|
|
|
|
gender: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
|
|
|
|
region: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
|
|
|
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
|
|
|
|
sale_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
|
|
|
|
user_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
|
|
|
|
dept_route: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
|
|
|
|
dept_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
|
|
|
|
head_image_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
|
|
|
|
|
last_chat_time: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
2026-08-05 18:39:17 +08:00
|
|
|
|
add_time: Mapped[int | None] = mapped_column(BigInteger, nullable=True) # ms epoch; from raw_json.addTime
|
2026-08-01 11:15:34 +08:00
|
|
|
|
send_num: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
|
|
|
|
receive_num: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
|
|
|
|
message_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
|
|
|
|
delete_flag: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
|
|
|
|
is_block: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
|
|
|
|
|
|
friend_state: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
|
|
|
|
|
|
|
|
|
|
version: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
|
|
|
|
raw_json: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
|
|
|
|
|
fetched_at: Mapped[dt.datetime] = _fetched_at()
|
|
|
|
|
|
updated_at: Mapped[dt.datetime] = _updated_at()
|
|
|
|
|
|
|
|
|
|
|
|
__table_args__ = (
|
|
|
|
|
|
Index(
|
|
|
|
|
|
"ix_customer_owner_friend",
|
|
|
|
|
|
"channel",
|
|
|
|
|
|
"owner_account_id",
|
|
|
|
|
|
"friend_id",
|
|
|
|
|
|
),
|
2026-09-04 16:40:01 +08:00
|
|
|
|
# /api/analytics/health MAX(updated_at) 新鲜度探针;无索引时全表扫。
|
|
|
|
|
|
# 「模型+迁移双声明」防 alembic autogenerate 反 DROP(c3d4e5f6a7b8)。
|
|
|
|
|
|
Index("ix_customer_updated_at", "updated_at"),
|
2026-08-01 11:15:34 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-08 17:17:06 +08:00
|
|
|
|
class CustomerAccountLink(Base):
|
|
|
|
|
|
"""账号↔客户 多对多关联(customer_account_link,2026-09-08)。
|
|
|
|
|
|
|
|
|
|
|
|
``customer`` 表 PK=``friend_id`` 全局一客户一行,归属=最后抓取员工
|
|
|
|
|
|
(last-wins)——共享客户(同一人加多个员工)在其他员工名下「消失」:
|
|
|
|
|
|
实测 Lily 云客好友 36 vs 镜像页面 12,24 个全在别的员工名下。云客
|
|
|
|
|
|
的口径是**每个账号各算一个**(per-employee 列表连备注/收发计数都按
|
|
|
|
|
|
关系各存一份);本表每个「渠道×账号×客户」一行,由
|
|
|
|
|
|
:func:`app.storage.repos.upsert_customer` 随档案同事务维护,员工维度
|
|
|
|
|
|
的列表/计数(`/api/customers?owner_account_id=`)读这里。
|
|
|
|
|
|
|
|
|
|
|
|
快照列为员工客户页所需(昵称/备注/头像 per-relation);全字段档案
|
|
|
|
|
|
(raw_json/60 列)仍在 ``customer`` 表按 friend_id 关联。
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
__tablename__ = "customer_account_link"
|
|
|
|
|
|
|
|
|
|
|
|
channel: Mapped[str] = mapped_column(String(16), primary_key=True)
|
|
|
|
|
|
owner_account_id: Mapped[str] = mapped_column(String(128), primary_key=True)
|
|
|
|
|
|
friend_id: Mapped[str] = mapped_column(String(128), primary_key=True)
|
|
|
|
|
|
# per-relation 展示快照(云客好友记录按员工各一份;备注是员工自己的)
|
|
|
|
|
|
friend_nickname: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
|
|
|
|
friend_remark: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
|
|
|
|
head_image_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
|
|
|
|
|
real_name: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
|
|
|
|
phone: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
|
|
|
|
last_chat_time: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
|
|
|
|
|
add_time: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
|
|
|
|
|
send_num: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
|
|
|
|
receive_num: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
|
|
|
|
message_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
|
|
|
|
# 云客 fromType 代码(数据来源列;int,前端 fromTypeLabel 映射)
|
|
|
|
|
|
from_type: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
|
|
|
|
# 同 customer.delete_flag 口径(int 0/1)
|
|
|
|
|
|
delete_flag: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
|
|
|
|
|
|
|
|
|
|
fetched_at: Mapped[dt.datetime] = _fetched_at()
|
|
|
|
|
|
updated_at: Mapped[dt.datetime] = _updated_at()
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-01 11:15:34 +08:00
|
|
|
|
class ChatGroup(Base):
|
|
|
|
|
|
"""聊天群 — source: ``POST /wechatweb/chatroom/getChatroomList``.
|
|
|
|
|
|
|
2026-08-25 20:32:25 +08:00
|
|
|
|
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`.
|
2026-08-01 11:15:34 +08:00
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
__tablename__ = "chat_group"
|
|
|
|
|
|
|
2026-08-24 12:01:25 +08:00
|
|
|
|
# 复合主键(channel, owner, room):同一群在每个员工名下各一行(2026-08-24)
|
|
|
|
|
|
# 原按 room_id 单列 PK 会把多员工同群去重(云客张士蓓 64 群 vs 我们 42)
|
2026-08-01 11:15:34 +08:00
|
|
|
|
room_id: Mapped[str] = mapped_column(String(128), primary_key=True)
|
2026-08-24 12:01:25 +08:00
|
|
|
|
channel: Mapped[str] = mapped_column(String(16), primary_key=True)
|
|
|
|
|
|
owner_account_id: Mapped[str] = mapped_column(String(128), primary_key=True)
|
2026-08-01 11:15:34 +08:00
|
|
|
|
room_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
|
|
|
|
owner_account_nickname: Mapped[str | None] = mapped_column(
|
|
|
|
|
|
String(255), nullable=True
|
|
|
|
|
|
)
|
|
|
|
|
|
member_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
|
|
|
|
member_ids: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
|
|
|
|
head_image_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
|
|
|
|
|
owner_user_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
|
|
|
|
owner_user_phone: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
|
|
|
|
message_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
|
|
|
|
own_message_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
|
|
|
|
loss_members: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
|
|
|
|
add_members: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
|
|
|
|
last_chat_time: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
|
|
|
|
|
update_time: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
|
|
|
|
|
|
2026-08-25 14:32:12 +08:00
|
|
|
|
# 通讯录·微信群 tab 对齐云客的补充列(2026-08-25,从 raw_json 提取:
|
|
|
|
|
|
# ownerName/ownerWchatId/ownerAlias/ownerImage/friendCount/createTime;
|
|
|
|
|
|
# createTime 兼容 ms-int 与 'YYYY-MM-DD HH:mm:SS' 两种上游形态,归一为 ms)
|
|
|
|
|
|
owner_name: Mapped[str | None] = mapped_column(String(255), nullable=True) # 群主昵称(上游脱敏)
|
|
|
|
|
|
owner_wxid: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
|
|
|
|
|
owner_alias: Mapped[str | None] = mapped_column(String(255), nullable=True) # 群主微信号
|
|
|
|
|
|
owner_avatar: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
|
|
|
|
|
friend_count: Mapped[int | None] = mapped_column(Integer, nullable=True) # 群成员中员工好友数
|
|
|
|
|
|
create_time: Mapped[int | None] = mapped_column(BigInteger, nullable=True) # 建群时间 ms epoch
|
2026-09-09 18:09:20 +08:00
|
|
|
|
# 群备注(微信群统计页「群备注」列;getChatroomList.remark /
|
|
|
|
|
|
# friend_list/es 的 friendWxRemark,2026-09-09)
|
|
|
|
|
|
group_remark: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
2026-08-25 14:32:12 +08:00
|
|
|
|
|
2026-08-01 11:15:34 +08:00
|
|
|
|
version: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
|
|
|
|
raw_json: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
|
|
|
|
|
fetched_at: Mapped[dt.datetime] = _fetched_at()
|
|
|
|
|
|
updated_at: Mapped[dt.datetime] = _updated_at()
|
|
|
|
|
|
|
|
|
|
|
|
__table_args__ = (
|
|
|
|
|
|
Index(
|
|
|
|
|
|
"ix_chat_group_owner",
|
|
|
|
|
|
"channel",
|
|
|
|
|
|
"owner_account_id",
|
|
|
|
|
|
"room_id",
|
|
|
|
|
|
),
|
2026-09-04 16:40:01 +08:00
|
|
|
|
# /api/analytics/health MAX(updated_at) 新鲜度探针(c3d4e5f6a7b8)。
|
|
|
|
|
|
Index("ix_chat_group_updated_at", "updated_at"),
|
2026-08-01 11:15:34 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-09 18:09:20 +08:00
|
|
|
|
class ChatGroupMemberStats(Base):
|
|
|
|
|
|
"""群成员进出按天统计(chat_group_member_stats,2026-09-09)。
|
|
|
|
|
|
|
|
|
|
|
|
云客「微信群统计」的新增/退出成员数是 ``getChatroomList`` 按
|
|
|
|
|
|
``startTime~endTime`` 任意窗实时算的;本地无成员进出事件流,改为每夜
|
|
|
|
|
|
抓一次「当日窗」的 addMembers/lossMembers 落一行,页面任意日窗=按天
|
|
|
|
|
|
求和。**上线日起积累**(历史日期无数据;chat_group.add_members/
|
|
|
|
|
|
loss_members 旧列保留作最近一次抓取窗的快照)。
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
__tablename__ = "chat_group_member_stats"
|
|
|
|
|
|
|
|
|
|
|
|
channel: Mapped[str] = mapped_column(String(16), primary_key=True)
|
|
|
|
|
|
owner_account_id: Mapped[str] = mapped_column(String(128), primary_key=True)
|
|
|
|
|
|
room_id: Mapped[str] = mapped_column(String(128), primary_key=True)
|
|
|
|
|
|
# yyyymmdd(上海自然日,同 msg_daily_stats 口径)
|
|
|
|
|
|
day: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
|
|
|
|
added: Mapped[int] = mapped_column(
|
|
|
|
|
|
Integer, nullable=False, default=0, server_default=sa.text("0")
|
|
|
|
|
|
)
|
|
|
|
|
|
lost: Mapped[int] = mapped_column(
|
|
|
|
|
|
Integer, nullable=False, default=0, server_default=sa.text("0")
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
fetched_at: Mapped[dt.datetime] = _fetched_at()
|
|
|
|
|
|
updated_at: Mapped[dt.datetime] = _updated_at()
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-01 11:15:34 +08:00
|
|
|
|
class GroupMember(Base):
|
|
|
|
|
|
"""群成员 — composite PK (channel, room_id, member_id).
|
|
|
|
|
|
|
|
|
|
|
|
Source: the ``memberIds`` field of a chatroom payload, parsed into one row
|
|
|
|
|
|
per member. Per-member nickname/avatar may require a separate API call
|
|
|
|
|
|
(see data-dictionary §9.6) and are therefore nullable.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
__tablename__ = "group_member"
|
|
|
|
|
|
|
|
|
|
|
|
channel: Mapped[str] = mapped_column(String(16), primary_key=True)
|
|
|
|
|
|
room_id: Mapped[str] = mapped_column(String(128), primary_key=True)
|
|
|
|
|
|
member_id: Mapped[str] = mapped_column(String(128), primary_key=True)
|
|
|
|
|
|
nickname: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
|
|
|
|
avatar: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
|
|
|
|
|
display_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
|
|
|
|
|
|
|
|
|
|
raw_json: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
|
|
|
|
|
fetched_at: Mapped[dt.datetime] = _fetched_at()
|
|
|
|
|
|
updated_at: Mapped[dt.datetime] = _updated_at()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class Message(Base):
|
|
|
|
|
|
"""聊天消息 — WeChat ``msgSvrId`` / WhatsApp ``keyId`` as natural PK.
|
|
|
|
|
|
|
|
|
|
|
|
Sources:
|
|
|
|
|
|
* WeChat: ``POST /wechatweb/msg/chats``
|
|
|
|
|
|
* WhatsApp: ``POST /qqweb/WhatsAppUser/chatRecords``
|
|
|
|
|
|
|
|
|
|
|
|
The model currently maps to a **single** ``message`` table. The real
|
|
|
|
|
|
sharding across ``message_<channel>_<YYYYMM>`` physical tables (see
|
|
|
|
|
|
:func:`message_table_name`) is a LATER task; this single-table mapping is
|
|
|
|
|
|
sufficient for ingestion v1.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
__tablename__ = "message"
|
|
|
|
|
|
|
|
|
|
|
|
msg_id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
|
|
|
|
|
channel: Mapped[str] = mapped_column(String(16), nullable=False)
|
|
|
|
|
|
owner_account_id: Mapped[str | None] = mapped_column(
|
|
|
|
|
|
String(128), nullable=True
|
|
|
|
|
|
) # wechatId / uid of sender staff account
|
|
|
|
|
|
talker_id: Mapped[str | None] = mapped_column(
|
|
|
|
|
|
String(128), nullable=True
|
|
|
|
|
|
) # talker / friendUid — the other party
|
|
|
|
|
|
ori_talker: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
|
|
|
|
|
room_id: Mapped[str | None] = mapped_column(
|
|
|
|
|
|
String(128), nullable=True
|
|
|
|
|
|
) # @chatroom id; NULL/empty = direct message
|
|
|
|
|
|
is_send: Mapped[bool | None] = mapped_column(Boolean, nullable=True) # mine/isSend
|
2026-08-01 12:04:08 +08:00
|
|
|
|
type: Mapped[str | None] = mapped_column(
|
|
|
|
|
|
String(32), nullable=True
|
|
|
|
|
|
) # normalized enum label (text/image/voice/.../unknown); see design §4.3
|
|
|
|
|
|
type_raw: Mapped[int | None] = mapped_column(
|
|
|
|
|
|
Integer, nullable=True
|
|
|
|
|
|
) # original upstream type code (e.g. WeChat 1=text, -2=divider)
|
2026-08-22 12:40:44 +08:00
|
|
|
|
# MEDIUMTEXT(16MB):MySQL TEXT 上限 64KB 字节,实测车源清单消息最长
|
|
|
|
|
|
# 20.8万字符(≈800KB utf8mb4)超限(2026-08-21 迁移踩坑)
|
|
|
|
|
|
text: Mapped[str | None] = mapped_column(
|
|
|
|
|
|
Text().with_variant(MEDIUMTEXT(), "mysql"), nullable=True
|
|
|
|
|
|
) # content/text
|
|
|
|
|
|
file_url: Mapped[str | None] = mapped_column(sa.Text, nullable=True) # 分享链接可超1KB(实测,2026-08-21)
|
|
|
|
|
|
file_th_url: Mapped[str | None] = mapped_column(sa.Text, nullable=True)
|
|
|
|
|
|
file_hd_url: Mapped[str | None] = mapped_column(sa.Text, nullable=True)
|
2026-08-01 11:15:34 +08:00
|
|
|
|
md5: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
|
|
|
|
md5_th: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
|
|
|
|
md5_hd: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
|
|
|
|
duration: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
|
|
|
|
transcription_text: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
|
|
|
|
img_path: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
|
|
|
|
|
original_image: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
|
|
|
|
|
pic_type: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
|
|
|
|
pay_msg_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
|
|
|
|
refer_msg: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
|
|
|
|
|
sender_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
|
|
|
|
sender_head_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
|
|
|
|
|
timestamp: Mapped[int | None] = mapped_column(BigInteger, nullable=True) # ms epoch
|
|
|
|
|
|
is_del: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
|
|
|
|
|
|
|
2026-08-11 18:24:11 +08:00
|
|
|
|
# sha256 media_hash of the localized media blob (FK -> Media.media_hash);
|
|
|
|
|
|
# NULL until sync_message_images downloads it. Populated by the
|
|
|
|
|
|
# message_images sync pass; rendering prefers /api/media/{this} over the
|
|
|
|
|
|
# OSS file_url / file_th_url / file_hd_url URLs. For image messages the
|
|
|
|
|
|
# localized blob is typically the thumbnail (file_th_url) — small + fast
|
|
|
|
|
|
# + sufficient for chat-timeline display.
|
|
|
|
|
|
media_hash: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
|
|
|
|
|
2026-08-01 11:15:34 +08:00
|
|
|
|
version: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
|
|
|
|
raw_json: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
|
|
|
|
|
fetched_at: Mapped[dt.datetime] = _fetched_at()
|
|
|
|
|
|
updated_at: Mapped[dt.datetime] = _updated_at()
|
|
|
|
|
|
|
|
|
|
|
|
__table_args__ = (
|
|
|
|
|
|
# Hot query path: per-account, per-talker, time-ordered pagination.
|
|
|
|
|
|
Index(
|
|
|
|
|
|
"ix_message_channel_owner_talker_time",
|
|
|
|
|
|
"channel",
|
|
|
|
|
|
"owner_account_id",
|
|
|
|
|
|
"talker_id",
|
|
|
|
|
|
"timestamp",
|
|
|
|
|
|
),
|
2026-08-12 11:50:45 +08:00
|
|
|
|
# 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",
|
|
|
|
|
|
),
|
2026-09-07 16:59:04 +08:00
|
|
|
|
# 群聊消息窗实际查询形如 ``channel=? AND room_id=? ORDER BY timestamp``
|
|
|
|
|
|
# (不带 owner——chat_group 按 room 去重后 owner 与消息归属错位,见
|
|
|
|
|
|
# ChatMain queryFor)。上面的索引被 owner 挡住前缀用不上,优化器退化到
|
|
|
|
|
|
# room 单列索引 + filter + filesort:2026-09-07 服务器实测 4 万条群
|
|
|
|
|
|
# count 4.7s / 首页 5.1s / 末页 10.7s,串行撞前端 15s 超时
|
|
|
|
|
|
# (ECONNABORTED)。此索引让群查询与单聊同享 seek+排序直达。
|
|
|
|
|
|
# 「模型+迁移双声明」防 alembic autogenerate 反 DROP。
|
|
|
|
|
|
Index(
|
|
|
|
|
|
"ix_message_channel_room_time",
|
|
|
|
|
|
"channel",
|
|
|
|
|
|
"room_id",
|
|
|
|
|
|
"timestamp",
|
|
|
|
|
|
),
|
2026-08-27 16:23:04 +08:00
|
|
|
|
# 今日收发聚合热路径: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",
|
|
|
|
|
|
),
|
2026-09-04 15:04:33 +08:00
|
|
|
|
# C1 敏感词增量扫描水位:MAX(fetched_at) / fetched_at > 水位 定位新入库
|
|
|
|
|
|
# 消息(按消息 timestamp 做水位会永久漏扫增量拉回的历史消息,spec §3.6)。
|
|
|
|
|
|
# 「模型+迁移双声明」防 alembic autogenerate 反 DROP。
|
|
|
|
|
|
Index(
|
|
|
|
|
|
"ix_message_fetched_at",
|
|
|
|
|
|
"fetched_at",
|
|
|
|
|
|
),
|
2026-08-01 11:15:34 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class Media(Base):
|
|
|
|
|
|
"""Locally cached media (image / voice / video / file) — keyed by hash.
|
|
|
|
|
|
|
|
|
|
|
|
Deduplicates downloads across messages that reference the same file (via
|
|
|
|
|
|
``md5``). ``local_path`` is set once the blob has been fetched into the
|
|
|
|
|
|
local media store.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
__tablename__ = "media"
|
|
|
|
|
|
|
|
|
|
|
|
media_hash: Mapped[str] = mapped_column(String(64), primary_key=True)
|
|
|
|
|
|
channel: Mapped[str] = mapped_column(String(16), nullable=False)
|
|
|
|
|
|
media_type: Mapped[str | None] = mapped_column(
|
|
|
|
|
|
String(32), nullable=True
|
|
|
|
|
|
) # image|voice|video|file|...
|
2026-08-22 12:40:44 +08:00
|
|
|
|
original_url: Mapped[str | None] = mapped_column(sa.Text, nullable=True)
|
2026-08-01 11:15:34 +08:00
|
|
|
|
original_th_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
|
|
|
|
|
original_hd_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
|
|
|
|
|
local_path: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
|
|
|
|
|
size_bytes: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
|
|
|
|
|
mime_type: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
|
|
|
|
|
duration: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
|
|
|
|
|
|
|
|
|
|
raw_json: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
|
|
|
|
|
fetched_at: Mapped[dt.datetime] = _fetched_at()
|
|
|
|
|
|
updated_at: Mapped[dt.datetime] = _updated_at()
|
|
|
|
|
|
|
|
|
|
|
|
__table_args__ = (
|
|
|
|
|
|
Index("ix_media_type_channel", "media_type", "channel"),
|
2026-09-04 16:40:01 +08:00
|
|
|
|
# /api/analytics/health MAX(updated_at) 新鲜度探针——media 5 万宽行
|
|
|
|
|
|
# (多个 TEXT/JSON 列)在 128MB buffer pool 下最伤(c3d4e5f6a7b8)。
|
|
|
|
|
|
Index("ix_media_updated_at", "updated_at"),
|
2026-08-01 11:15:34 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ===========================================================================
|
|
|
|
|
|
# 4. Phone calls & SMS (prefix /pc/)
|
|
|
|
|
|
# ===========================================================================
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class PhoneCall(Base):
|
|
|
|
|
|
"""电话/语音通话记录 — source: ``POST /pc/callLog/list`` (phone calls) and
|
|
|
|
|
|
``POST /wechatweb/callRecord/queryWeChatVoiceList`` (voice calls).
|
|
|
|
|
|
|
|
|
|
|
|
``channel`` here means **call-type** in {``phone_call``, ``voice_call``}
|
|
|
|
|
|
(calls are not tied to a messaging channel). PK is the source ``id``.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
__tablename__ = "phone_call"
|
|
|
|
|
|
|
|
|
|
|
|
call_id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
|
|
|
|
|
channel: Mapped[str] = mapped_column(String(16), nullable=False)
|
|
|
|
|
|
start_call_time: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
|
|
|
|
|
call_duration: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
|
|
|
|
ring_time: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
|
|
|
|
call_status: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
|
|
|
|
incoming_call: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
|
|
|
|
user_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
|
|
|
|
user_id_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
|
|
|
|
department_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
|
|
|
|
call_number: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
|
|
|
|
sim_phone: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
|
|
|
|
sim_number: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
|
|
|
|
imei: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
|
|
|
|
|
record_file: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
2026-08-07 16:20:18 +08:00
|
|
|
|
# sha256 media_hash of the localized recording blob (FK -> Media.media_hash);
|
|
|
|
|
|
# NULL until sync_call_recordings downloads it. Populated by the
|
|
|
|
|
|
# call_recordings sync pass; playback prefers /api/media/{this} over the
|
|
|
|
|
|
# OSS record_file URL.
|
|
|
|
|
|
record_media_hash: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
2026-08-01 11:15:34 +08:00
|
|
|
|
is_upload: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
|
|
|
|
|
|
download_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
|
|
|
|
ai_asr: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
|
|
|
|
qc_status: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
|
|
|
|
plan_customer_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
|
|
|
|
|
|
|
|
|
|
version: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
|
|
|
|
raw_json: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
|
|
|
|
|
fetched_at: Mapped[dt.datetime] = _fetched_at()
|
|
|
|
|
|
updated_at: Mapped[dt.datetime] = _updated_at()
|
|
|
|
|
|
|
|
|
|
|
|
__table_args__ = (
|
|
|
|
|
|
Index("ix_phone_call_user_time", "user_id", "start_call_time"),
|
2026-09-04 16:40:01 +08:00
|
|
|
|
# /api/analytics/health MAX(updated_at) 新鲜度探针(c3d4e5f6a7b8)。
|
|
|
|
|
|
Index("ix_phone_call_updated_at", "updated_at"),
|
2026-08-01 11:15:34 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-10 18:12:28 +08:00
|
|
|
|
class VoiceClip(Base):
|
|
|
|
|
|
"""长语音片段 — source: ``POST /wechatweb/call/list`` (WeChat + WhatsApp
|
|
|
|
|
|
share the same endpoint, distinguished by the employee's channel).
|
|
|
|
|
|
|
|
|
|
|
|
Distinct from :class:`PhoneCall` (which holds ``callLog/list`` phone calls
|
|
|
|
|
|
and ``queryWeChatVoiceList`` voice-call records): a voice_clip is one of the
|
|
|
|
|
|
long audio segments listed in the 云客 微信长语音 / WA wavoicecalllist modals.
|
|
|
|
|
|
PK is the source ``id`` (a cross-employee-globally-unique bigint).
|
|
|
|
|
|
|
|
|
|
|
|
``channel`` here is the **messaging channel** in {``wechat``, ``whatsapp``}
|
|
|
|
|
|
— stamped LOCALLY by the sync orchestrator based on which staff-account
|
|
|
|
|
|
table owns the scraped ``user_id`` (WechatAccount -> ``wechat``,
|
|
|
|
|
|
WhatsappAccount -> ``whatsapp``). The upstream endpoint is shared and does
|
|
|
|
|
|
NOT distinguish the channel in its response, so the channel must come from
|
|
|
|
|
|
the employee mapping, not the record.
|
|
|
|
|
|
|
|
|
|
|
|
``start_time`` / ``end_time`` arrive as ms-epoch **strings** (e.g.
|
|
|
|
|
|
``"1786072733378"``); the scraper normalizes them to ``int``.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
__tablename__ = "voice_clip"
|
|
|
|
|
|
|
|
|
|
|
|
voice_clip_id: Mapped[int] = mapped_column(
|
|
|
|
|
|
BigInteger, primary_key=True, autoincrement=False
|
|
|
|
|
|
)
|
|
|
|
|
|
user_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
|
|
|
|
|
user_real_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
|
|
|
|
wx_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
|
|
|
|
|
talker: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
|
|
|
|
|
status: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
|
|
|
|
|
is_send: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
|
|
|
|
|
|
company_code: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
|
|
|
|
start_time: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
|
|
|
|
|
end_time: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
|
|
|
|
|
oss_file_name: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
|
|
|
|
|
mp3_duration: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
|
|
|
|
duration_format: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
|
|
|
|
|
channel: Mapped[str] = mapped_column(String(16), nullable=False)
|
|
|
|
|
|
|
2026-08-11 12:06:26 +08:00
|
|
|
|
# sha256 media_hash of the localized audio blob (FK -> Media.media_hash);
|
|
|
|
|
|
# NULL until sync_voice_clip_audio downloads it. Populated by the
|
|
|
|
|
|
# voice_clip_audio sync pass; playback prefers /api/media/{this} over the
|
|
|
|
|
|
# OSS oss_file_name URL.
|
|
|
|
|
|
media_hash: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
|
|
|
|
|
2026-08-11 10:43:34 +08:00
|
|
|
|
# LOCAL-only manual match: references customer.friend_id when an operator
|
|
|
|
|
|
# has associated this clip with a specific customer (好友). NULL = unmatched.
|
|
|
|
|
|
# The mirror never pushes this back to 云客 — it's our own bookkeeping for
|
|
|
|
|
|
# the 手动匹配 UI (spec §7). Read paths resolve the customer name via join.
|
|
|
|
|
|
matched_friend_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
|
|
|
|
|
|
2026-08-10 18:12:28 +08:00
|
|
|
|
raw_json: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
|
|
|
|
|
fetched_at: Mapped[dt.datetime] = _fetched_at()
|
|
|
|
|
|
updated_at: Mapped[dt.datetime] = _updated_at()
|
|
|
|
|
|
|
|
|
|
|
|
__table_args__ = (Index("ix_voice_clip_user_time", "user_id", "start_time"),)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-01 11:15:34 +08:00
|
|
|
|
class Sms(Base):
|
|
|
|
|
|
"""短信记录 — source: ``POST /pc/customer/smsLogsList``."""
|
|
|
|
|
|
|
|
|
|
|
|
__tablename__ = "sms"
|
|
|
|
|
|
|
|
|
|
|
|
sms_id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
|
|
|
|
|
channel: Mapped[str] = mapped_column(
|
|
|
|
|
|
String(16), nullable=False, default="sms"
|
|
|
|
|
|
) # always 'sms' today; kept for symmetry
|
|
|
|
|
|
customer_phone: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
|
|
|
|
content: Mapped[str | None] = mapped_column(Text, nullable=True) # full sms text
|
|
|
|
|
|
content_preview: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
|
|
|
|
sender: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
|
|
|
|
|
direction: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
|
|
|
|
user_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
|
|
|
|
sender_department: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
|
|
|
|
sender_department_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
|
|
|
|
receive_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
|
|
|
|
create_time: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
|
|
|
|
|
sim_phone: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
|
|
|
|
sim_number: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
|
|
|
|
source: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
|
|
|
|
|
|
|
|
|
|
version: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
|
|
|
|
raw_json: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
|
|
|
|
|
fetched_at: Mapped[dt.datetime] = _fetched_at()
|
|
|
|
|
|
updated_at: Mapped[dt.datetime] = _updated_at()
|
|
|
|
|
|
|
|
|
|
|
|
__table_args__ = (Index("ix_sms_user_time", "user_id", "create_time"),)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-25 13:44:54 +08:00
|
|
|
|
class MomentsPost(Base):
|
|
|
|
|
|
"""员工朋友圈帖子 — source: ``POST /wechatweb/wechatFriendster/getFriendsterInfo``.
|
|
|
|
|
|
|
|
|
|
|
|
PK = 云客 ``data.id``。``create_time`` 统一 **ms epoch**(云客返回秒,
|
|
|
|
|
|
scraper ×1000)。WeChat-only 实体(无 ``channel`` 列),所属员工微信号
|
|
|
|
|
|
由 ``wechat_id`` 标识。
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
__tablename__ = "moments_post"
|
|
|
|
|
|
|
|
|
|
|
|
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
|
|
|
|
|
wechat_id: Mapped[str] = mapped_column(String(128), index=True)
|
|
|
|
|
|
sns_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
|
|
|
|
author_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
|
|
|
|
author_wxid: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
|
|
|
|
|
author_avatar: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
|
|
|
|
|
content: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
|
|
|
|
type_raw: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
|
|
|
|
|
create_time: Mapped[int | None] = mapped_column(BigInteger, index=True)
|
|
|
|
|
|
praise_json: Mapped[list[dict[str, Any]] | None] = mapped_column(JSON, nullable=True)
|
|
|
|
|
|
comment_json: Mapped[list[dict[str, Any]] | None] = mapped_column(JSON, nullable=True)
|
|
|
|
|
|
image_urls: Mapped[list[str] | None] = mapped_column(JSON, nullable=True)
|
|
|
|
|
|
raw_json: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
|
|
|
|
|
fetched_at: Mapped[dt.datetime] = _fetched_at()
|
|
|
|
|
|
updated_at: Mapped[dt.datetime] = _updated_at()
|
|
|
|
|
|
|
2026-09-04 16:40:01 +08:00
|
|
|
|
# /api/analytics/health MAX(updated_at) 新鲜度探针(c3d4e5f6a7b8)。
|
|
|
|
|
|
__table_args__ = (Index("ix_moments_post_updated_at", "updated_at"),)
|
|
|
|
|
|
|
2026-08-25 13:44:54 +08:00
|
|
|
|
|
2026-08-01 11:15:34 +08:00
|
|
|
|
# ===========================================================================
|
|
|
|
|
|
# 5. Operational / bookkeeping tables (local; no raw_json/version)
|
|
|
|
|
|
# ===========================================================================
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SyncState(Base):
|
|
|
|
|
|
"""Resumable sync cursor — scoped by (entity_type, channel, scope_id).
|
|
|
|
|
|
|
|
|
|
|
|
``scope_id`` qualifies the cursor for a sub-stream (e.g. the owning staff
|
|
|
|
|
|
account id when syncing per-account message streams). ``last_version``
|
|
|
|
|
|
holds the opaque source update stamp; ``last_timestamp`` the ms epoch.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
__tablename__ = "sync_state"
|
|
|
|
|
|
|
|
|
|
|
|
state_id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
|
|
|
|
entity_type: Mapped[str] = mapped_column(String(32), nullable=False)
|
|
|
|
|
|
channel: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
|
|
|
|
|
scope_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
|
|
|
|
|
last_version: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
|
|
|
|
last_timestamp: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
|
|
|
|
|
last_synced_at: Mapped[dt.datetime | None] = mapped_column(
|
|
|
|
|
|
DateTime, nullable=True
|
|
|
|
|
|
)
|
|
|
|
|
|
updated_at: Mapped[dt.datetime] = _updated_at()
|
|
|
|
|
|
|
|
|
|
|
|
__table_args__ = (
|
|
|
|
|
|
Index(
|
|
|
|
|
|
"ix_sync_state_scope",
|
|
|
|
|
|
"entity_type",
|
|
|
|
|
|
"channel",
|
|
|
|
|
|
"scope_id",
|
|
|
|
|
|
unique=True,
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class YkAccount(Base):
|
|
|
|
|
|
"""云客 CRM login account — credentials / session for the upstream API.
|
|
|
|
|
|
|
|
|
|
|
|
The session cookie / password is stored encrypted with
|
|
|
|
|
|
``Settings.encryption_key`` (Fernet). ``user_id`` + ``company`` together
|
|
|
|
|
|
identify a yunke session (the two ``userid`` / ``company`` request headers).
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
__tablename__ = "yk_account"
|
|
|
|
|
|
|
|
|
|
|
|
account_id: Mapped[int] = mapped_column(
|
|
|
|
|
|
Integer, primary_key=True, autoincrement=True
|
|
|
|
|
|
)
|
|
|
|
|
|
user_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
|
|
|
|
|
company: Mapped[str] = mapped_column(String(64), nullable=False)
|
|
|
|
|
|
username: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
|
|
|
|
session_blob_enc: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
|
|
|
|
last_login_at: Mapped[dt.datetime | None] = mapped_column(
|
|
|
|
|
|
DateTime, nullable=True
|
|
|
|
|
|
)
|
|
|
|
|
|
is_active: Mapped[bool] = mapped_column(
|
|
|
|
|
|
Boolean, nullable=False, default=True, server_default=sa.text("1")
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
raw_json: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
|
|
|
|
|
fetched_at: Mapped[dt.datetime] = _fetched_at()
|
|
|
|
|
|
updated_at: Mapped[dt.datetime] = _updated_at()
|
|
|
|
|
|
|
|
|
|
|
|
__table_args__ = (
|
|
|
|
|
|
Index("ix_yk_account_user_company", "user_id", "company", unique=True),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SyncRun(Base):
|
|
|
|
|
|
"""Audit log for one sync execution (start / end / status / counts)."""
|
|
|
|
|
|
|
|
|
|
|
|
__tablename__ = "sync_run"
|
|
|
|
|
|
|
|
|
|
|
|
run_id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
|
|
|
|
entity_type: Mapped[str] = mapped_column(String(32), nullable=False)
|
|
|
|
|
|
channel: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
|
|
|
|
|
scope_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
|
|
|
|
|
status: Mapped[str] = mapped_column(String(16), nullable=False) # running|success|failed
|
|
|
|
|
|
started_at: Mapped[dt.datetime] = mapped_column(
|
|
|
|
|
|
DateTime, server_default=sa.func.now(), nullable=False
|
|
|
|
|
|
)
|
|
|
|
|
|
finished_at: Mapped[dt.datetime | None] = mapped_column(DateTime, nullable=True)
|
|
|
|
|
|
records_fetched: Mapped[int] = mapped_column(
|
|
|
|
|
|
Integer, nullable=False, default=0, server_default=sa.text("0")
|
|
|
|
|
|
)
|
|
|
|
|
|
records_upserted: Mapped[int] = mapped_column(
|
|
|
|
|
|
Integer, nullable=False, default=0, server_default=sa.text("0")
|
|
|
|
|
|
)
|
|
|
|
|
|
error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
|
|
|
|
|
|
|
|
|
|
updated_at: Mapped[dt.datetime] = _updated_at()
|
|
|
|
|
|
|
|
|
|
|
|
__table_args__ = (Index("ix_sync_run_entity_status", "entity_type", "status"),)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-03 16:37:04 +08:00
|
|
|
|
class MsgDailyStats(Base):
|
|
|
|
|
|
"""日粒度会话统计:每个「渠道×账号×会话×自然日」一行(2026-09-03)。
|
|
|
|
|
|
|
|
|
|
|
|
sessions(staff-chat 会话列表)与 staff-chat-stats(5 数字弹窗)的预聚
|
|
|
|
|
|
合层——这两端点原先每次请求对 owner 全量 message 做 GROUP BY(重账号
|
|
|
|
|
|
5.2 万条实测 3~7s,128MB buffer pool 下更被推过前端 15s 超时)。日行由
|
|
|
|
|
|
写路径维护:``repos.upsert_messages`` 落库后自动重算本批触及的
|
|
|
|
|
|
「会话×天」;夜间 ``recompute_all`` 全量重建自愈(裸 UPDATE/回填脚本
|
|
|
|
|
|
等绕过钩子的写入由它兜底)。
|
|
|
|
|
|
|
|
|
|
|
|
口径:
|
|
|
|
|
|
- ``day`` 一律 Asia/Shanghai 自然日(Python 计算,不用 SQL 日期函数——
|
|
|
|
|
|
from_unixtime 跟随 MySQL 会话时区,UTC 容器下与今日数口径割裂)。
|
|
|
|
|
|
- ``send_cnt``/``recv_cnt`` 仅计 is_send 真/假;NULL 两边都不计(对齐
|
|
|
|
|
|
旧聚合的 case 语义)。
|
|
|
|
|
|
- ``last_ts``/``last_is_send`` 为该日最后一条消息的时间与方向。
|
2026-09-07 14:13:09 +08:00
|
|
|
|
- 首响/回复率三列(2026-09-07 批1)按「消息串」口径,见
|
|
|
|
|
|
:func:`app.storage.summary._thread_stats`:串=客户连续消息不被员工
|
|
|
|
|
|
消息打断的段(is_send=NULL 不参与串判定);日切近似——日末未闭合
|
|
|
|
|
|
串计 threads 不计 answered,跨夜的串在次日段重新起算。
|
2026-09-03 16:37:04 +08:00
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
__tablename__ = "msg_daily_stats"
|
|
|
|
|
|
|
|
|
|
|
|
channel: Mapped[str] = mapped_column(String(16), primary_key=True)
|
|
|
|
|
|
owner_account_id: Mapped[str] = mapped_column(String(128), primary_key=True)
|
|
|
|
|
|
# friend = 1:1 私聊(peer=talker_id) / room = 群(peer=room_id)
|
|
|
|
|
|
peer_kind: Mapped[str] = mapped_column(String(8), primary_key=True)
|
|
|
|
|
|
peer_id: Mapped[str] = mapped_column(String(128), primary_key=True)
|
|
|
|
|
|
# yyyymmdd,如 20260903(上海自然日)
|
|
|
|
|
|
day: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
|
|
|
|
send_cnt: Mapped[int] = mapped_column(
|
|
|
|
|
|
Integer, nullable=False, default=0, server_default=sa.text("0")
|
|
|
|
|
|
)
|
|
|
|
|
|
recv_cnt: Mapped[int] = mapped_column(
|
|
|
|
|
|
Integer, nullable=False, default=0, server_default=sa.text("0")
|
|
|
|
|
|
)
|
|
|
|
|
|
# 当日最后一条消息(ms);当天无有效方向计数的消息也存在(is_send=NULL)
|
|
|
|
|
|
last_ts: Mapped[int | None] = mapped_column(sa.BigInteger, nullable=True)
|
|
|
|
|
|
last_is_send: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
|
2026-09-07 14:13:09 +08:00
|
|
|
|
# 当日客户串数(串=客户连续消息不被员工消息打断的段;NULL 方向不参与)
|
|
|
|
|
|
cust_threads: Mapped[int] = mapped_column(
|
|
|
|
|
|
Integer, nullable=False, default=0, server_default=sa.text("0")
|
|
|
|
|
|
)
|
|
|
|
|
|
# 被员工回复的串数 ≡ 首响样本数 fr_cnt(fr_cnt=threads_answered 不单列)
|
|
|
|
|
|
threads_answered: Mapped[int] = mapped_column(
|
|
|
|
|
|
Integer, nullable=False, default=0, server_default=sa.text("0")
|
|
|
|
|
|
)
|
|
|
|
|
|
# 首响样本时长和(ms):展示用首响时长 = fr_sum_ms / threads_answered
|
|
|
|
|
|
fr_sum_ms: Mapped[int] = mapped_column(
|
|
|
|
|
|
sa.BigInteger, nullable=False, default=0, server_default=sa.text("0")
|
|
|
|
|
|
)
|
2026-09-03 16:37:04 +08:00
|
|
|
|
|
|
|
|
|
|
updated_at: Mapped[dt.datetime] = _updated_at()
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-04 15:04:33 +08:00
|
|
|
|
class StatDailyStaff(Base):
|
|
|
|
|
|
"""员工×日 汇总(stat_daily_staff)— msg_daily_stats 按账号→员工映射的 rollup。
|
|
|
|
|
|
|
|
|
|
|
|
数据分析板块 v1 地基(2026-09-04 spec §3.1):A1 员工指标直读;A2 绩效
|
|
|
|
|
|
(二批)零基建直读。维护见 app/storage/staff_summary.py——4h 调度刷
|
|
|
|
|
|
当日+滚动 3 天 + 夜间全量自愈,**不挂写钩子**(rollup-of-rollup 无需
|
|
|
|
|
|
实时,滞后语义与「数据截至」角标一致)。映射不上的账号 user_id 存
|
|
|
|
|
|
owner_account_id 原值(读侧标注未映射)。day 口径同 msg_daily_stats
|
|
|
|
|
|
(Asia/Shanghai yyyymmdd,Python 侧计算)。
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
__tablename__ = "stat_daily_staff"
|
|
|
|
|
|
|
|
|
|
|
|
day: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
|
|
|
|
channel: Mapped[str] = mapped_column(String(16), primary_key=True)
|
|
|
|
|
|
user_id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
|
|
|
|
|
msg_sent: Mapped[int] = mapped_column(
|
|
|
|
|
|
Integer, nullable=False, default=0, server_default=sa.text("0")
|
|
|
|
|
|
)
|
|
|
|
|
|
msg_recv: Mapped[int] = mapped_column(
|
|
|
|
|
|
Integer, nullable=False, default=0, server_default=sa.text("0")
|
|
|
|
|
|
)
|
|
|
|
|
|
# 当日活跃去重数(账号合并到员工后跨账号去重 peer)
|
|
|
|
|
|
active_friends: Mapped[int] = mapped_column(
|
|
|
|
|
|
Integer, nullable=False, default=0, server_default=sa.text("0")
|
|
|
|
|
|
)
|
|
|
|
|
|
active_rooms: Mapped[int] = mapped_column(
|
|
|
|
|
|
Integer, nullable=False, default=0, server_default=sa.text("0")
|
|
|
|
|
|
)
|
2026-09-07 14:13:09 +08:00
|
|
|
|
# 首响/回复率三列(2026-09-07 批1):msg_daily_stats 同义列按账号→员工
|
|
|
|
|
|
# 累加。回复率=Σanswered/Σthreads;首响时长=Σfr_sum_ms/Σanswered。
|
|
|
|
|
|
cust_threads: Mapped[int] = mapped_column(
|
|
|
|
|
|
Integer, nullable=False, default=0, server_default=sa.text("0")
|
|
|
|
|
|
)
|
|
|
|
|
|
threads_answered: Mapped[int] = mapped_column(
|
|
|
|
|
|
Integer, nullable=False, default=0, server_default=sa.text("0")
|
|
|
|
|
|
)
|
|
|
|
|
|
fr_sum_ms: Mapped[int] = mapped_column(
|
|
|
|
|
|
sa.BigInteger, nullable=False, default=0, server_default=sa.text("0")
|
|
|
|
|
|
)
|
2026-09-04 15:04:33 +08:00
|
|
|
|
updated_at: Mapped[dt.datetime] = _updated_at()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SensitiveWord(Base):
|
|
|
|
|
|
"""敏感词库(C1)——词库内容是高敏数据,日志禁止输出词内容。"""
|
|
|
|
|
|
|
|
|
|
|
|
__tablename__ = "sensitive_word"
|
|
|
|
|
|
|
|
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
|
|
|
|
word: Mapped[str] = mapped_column(String(64), unique=True)
|
|
|
|
|
|
category: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
|
|
|
|
|
enabled: Mapped[bool] = mapped_column(
|
|
|
|
|
|
Boolean, nullable=False, default=True, server_default=sa.text("1")
|
|
|
|
|
|
)
|
|
|
|
|
|
# 短于此长度不入库(防单字误报;单字词走 LIKE 回退扫描)
|
|
|
|
|
|
min_len: Mapped[int] = mapped_column(
|
|
|
|
|
|
Integer, nullable=False, default=2, server_default=sa.text("2")
|
|
|
|
|
|
)
|
|
|
|
|
|
# JSON 数组:命中行文本含任一排除词则跳过(误报抑制)
|
|
|
|
|
|
exclude_words: Mapped[list[Any] | dict[str, Any] | None] = mapped_column(
|
|
|
|
|
|
JSON, nullable=True
|
|
|
|
|
|
)
|
2026-09-04 15:30:14 +08:00
|
|
|
|
# created_at 不用 _updated_at():无 onupdate,首次落库后不被静默重写
|
|
|
|
|
|
# (T8 改词/T10 处置命中只该动 updated_at/disposition)
|
|
|
|
|
|
created_at: Mapped[dt.datetime] = mapped_column(
|
|
|
|
|
|
sa.DateTime, nullable=False, server_default=sa.func.now()
|
|
|
|
|
|
)
|
2026-09-04 15:04:33 +08:00
|
|
|
|
updated_at: Mapped[dt.datetime] = _updated_at()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SensitiveHit(Base):
|
|
|
|
|
|
"""敏感词命中——冗余 channel/owner/talker/room 跳转字段(不回查 message)。
|
|
|
|
|
|
|
|
|
|
|
|
UNIQUE(word_id, msg_id) 防重扫重复膨胀;disposition ∈
|
|
|
|
|
|
pending/confirmed/false_positive/ignored。
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
__tablename__ = "sensitive_hit"
|
|
|
|
|
|
|
|
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
|
|
|
|
word_id: Mapped[int] = mapped_column(
|
|
|
|
|
|
sa.ForeignKey("sensitive_word.id", ondelete="CASCADE"), nullable=False
|
|
|
|
|
|
)
|
|
|
|
|
|
msg_id: Mapped[str] = mapped_column(String(128), nullable=False)
|
|
|
|
|
|
channel: Mapped[str] = mapped_column(String(16), nullable=False)
|
|
|
|
|
|
owner_account_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
|
|
|
|
|
talker_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
|
|
|
|
|
room_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
|
|
|
|
|
ts: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
|
|
|
|
|
# 命中词前后文片段(不含全文,控敏)
|
|
|
|
|
|
snippet: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
|
|
|
|
disposition: Mapped[str] = mapped_column(
|
|
|
|
|
|
String(16), nullable=False, default="pending", server_default="pending"
|
|
|
|
|
|
)
|
2026-09-04 15:30:14 +08:00
|
|
|
|
# created_at 不用 _updated_at():无 onupdate,首次落库后不被静默重写
|
|
|
|
|
|
# (T8 改词/T10 处置命中只该动 updated_at/disposition)
|
|
|
|
|
|
created_at: Mapped[dt.datetime] = mapped_column(
|
|
|
|
|
|
sa.DateTime, nullable=False, server_default=sa.func.now()
|
|
|
|
|
|
)
|
2026-09-04 15:04:33 +08:00
|
|
|
|
|
|
|
|
|
|
__table_args__ = (
|
|
|
|
|
|
sa.UniqueConstraint("word_id", "msg_id", name="uq_sensitive_hit_word_msg"),
|
|
|
|
|
|
sa.Index("ix_sensitive_hit_disp_ts", "disposition", "ts"),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SensitiveScanState(Base):
|
|
|
|
|
|
"""敏感词扫描水位——entity v1 只有 'message_text'。
|
|
|
|
|
|
|
|
|
|
|
|
水位存 message.fetched_at(入库时间,DateTime)而非消息 timestamp:
|
|
|
|
|
|
增量补扫拉回的历史消息 timestamp 早于水位但 fetched_at 是新的,按
|
|
|
|
|
|
timestamp 做水位会永久漏扫(spec §3.6 P2)。
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
__tablename__ = "sensitive_scan_state"
|
|
|
|
|
|
|
|
|
|
|
|
entity: Mapped[str] = mapped_column(String(32), primary_key=True)
|
|
|
|
|
|
last_fetched_at: Mapped[dt.datetime | None] = mapped_column(
|
|
|
|
|
|
sa.DateTime, nullable=True
|
|
|
|
|
|
)
|
|
|
|
|
|
last_run_at: Mapped[dt.datetime | None] = mapped_column(sa.DateTime, nullable=True)
|
|
|
|
|
|
scanned: Mapped[int] = mapped_column(
|
|
|
|
|
|
Integer, nullable=False, default=0, server_default=sa.text("0")
|
|
|
|
|
|
)
|
|
|
|
|
|
hits: Mapped[int] = mapped_column(
|
|
|
|
|
|
Integer, nullable=False, default=0, server_default=sa.text("0")
|
|
|
|
|
|
)
|
|
|
|
|
|
updated_at: Mapped[dt.datetime] = _updated_at()
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-09 12:04:54 +08:00
|
|
|
|
# ==========================================================================
|
|
|
|
|
|
# 经营管理后台(2026-09-08 v2 spec,批次A)——fact/宽表/通话小表 + 三张业务表
|
|
|
|
|
|
# ==========================================================================
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class FirstReplyFact(Base):
|
|
|
|
|
|
"""首响事实表(管理域)——一行 = 一个被回的 1:1 客户消息串(v2 §3.1)。
|
|
|
|
|
|
|
|
|
|
|
|
跨日精确串口径:按会话**全历史**计算,fr_ms = 员工首条回复 ts − 串末条
|
|
|
|
|
|
客户消息 ts,day = 串末条所在上海自然日。区别于 msg_daily_stats 首响
|
|
|
|
|
|
三列的日切近似(跨夜串整条丢失,v2 §1.1 #1)。只写 1:1(friend)会话
|
|
|
|
|
|
——群串语义不成立。由回填 CLI / 4h 滚动 / 夜间全量重算,**不挂写钩子**
|
|
|
|
|
|
(写钩子零改动)。用途:精确 P50/P90、2h/24h 回复率分子、下钻证据。
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
__tablename__ = "first_reply_fact"
|
|
|
|
|
|
|
|
|
|
|
|
channel: Mapped[str] = mapped_column(String(16), primary_key=True)
|
|
|
|
|
|
owner_account_id: Mapped[str] = mapped_column(String(128), primary_key=True)
|
|
|
|
|
|
peer_id: Mapped[str] = mapped_column(String(128), primary_key=True)
|
|
|
|
|
|
thread_end_ts: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
|
|
|
|
|
reply_ts: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
2026-09-09 13:18:08 +08:00
|
|
|
|
# 回复消息 msg_id:同毫秒 F,T,F,T 交换的 (thread_end_ts, reply_ts) 并列
|
|
|
|
|
|
# 去重(一条回复恰好闭合一个串);兼作下钻证据定位回复消息
|
|
|
|
|
|
reply_msg_id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
2026-09-09 12:04:54 +08:00
|
|
|
|
# 串末条所在上海自然日 yyyymmdd(冗余物化,窗口/趋势索引)
|
|
|
|
|
|
day: Mapped[int] = mapped_column(Integer, nullable=False)
|
|
|
|
|
|
fr_ms: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
|
|
|
|
|
|
|
|
|
|
|
__table_args__ = (Index("ix_fr_fact_day_channel", "day", "channel"),)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class EmployeeMetricDaily(Base):
|
|
|
|
|
|
"""员工×日管理指标宽表(v2 §3.2)——1:1 口径,管理域专用。
|
|
|
|
|
|
|
|
|
|
|
|
列来源(v2 §1.1 #2/#5):消息计数/负载列自 msg_daily_stats
|
|
|
|
|
|
(peer_kind='friend' 过滤 + account_map 归并);串类三列与首响日值由
|
|
|
|
|
|
first_reply_facts.compute_conversation 同源输出(串末条归属日)——
|
|
|
|
|
|
回复率分子分母同口径。metric_version 只影响派生列(生命周期),
|
|
|
|
|
|
默认 'v1'。account_map 映射不上或不在 staff 的 owner(幽灵员工)
|
|
|
|
|
|
过滤不落表(v2 §1.3 #11)。通话在 employee_call_daily(无渠道维)。
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
__tablename__ = "employee_metric_daily"
|
|
|
|
|
|
|
|
|
|
|
|
day: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
|
|
|
|
channel: Mapped[str] = mapped_column(String(16), primary_key=True)
|
|
|
|
|
|
user_id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
|
|
|
|
|
metric_version: Mapped[str] = mapped_column(
|
|
|
|
|
|
String(16), primary_key=True, default="v1", server_default="v1"
|
|
|
|
|
|
)
|
|
|
|
|
|
# 消息计数列(1:1 口径)
|
|
|
|
|
|
msg_sent: Mapped[int] = mapped_column(
|
|
|
|
|
|
Integer, nullable=False, default=0, server_default=sa.text("0")
|
|
|
|
|
|
)
|
|
|
|
|
|
msg_recv: Mapped[int] = mapped_column(
|
|
|
|
|
|
Integer, nullable=False, default=0, server_default=sa.text("0")
|
|
|
|
|
|
)
|
|
|
|
|
|
# 负载列(active_rooms 为群负载,唯一计入宽表的群维度)
|
|
|
|
|
|
active_friends: Mapped[int] = mapped_column(
|
|
|
|
|
|
Integer, nullable=False, default=0, server_default=sa.text("0")
|
|
|
|
|
|
)
|
|
|
|
|
|
active_rooms: Mapped[int] = mapped_column(
|
|
|
|
|
|
Integer, nullable=False, default=0, server_default=sa.text("0")
|
|
|
|
|
|
)
|
|
|
|
|
|
# 串类列(与 first_reply_fact 同源,按串末条归属日)
|
|
|
|
|
|
cust_threads: Mapped[int] = mapped_column(
|
|
|
|
|
|
Integer, nullable=False, default=0, server_default=sa.text("0")
|
|
|
|
|
|
)
|
|
|
|
|
|
threads_answered: Mapped[int] = mapped_column(
|
|
|
|
|
|
Integer, nullable=False, default=0, server_default=sa.text("0")
|
|
|
|
|
|
)
|
|
|
|
|
|
fr_sum_ms: Mapped[int] = mapped_column(
|
|
|
|
|
|
BigInteger, nullable=False, default=0, server_default=sa.text("0")
|
|
|
|
|
|
)
|
|
|
|
|
|
# 首响日值(趋势图用;评分用周期值从 fact 表窗口函数精确计算)
|
|
|
|
|
|
first_reply_p50_ms: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
|
|
|
|
|
first_reply_p90_ms: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
|
|
|
|
|
# 生命周期 4 列(距今天数口径,当日持有客户状态分布,当前归属)
|
|
|
|
|
|
new_customer_count: Mapped[int] = mapped_column(
|
|
|
|
|
|
Integer, nullable=False, default=0, server_default=sa.text("0")
|
|
|
|
|
|
)
|
|
|
|
|
|
cooling_customer_count: Mapped[int] = mapped_column(
|
|
|
|
|
|
Integer, nullable=False, default=0, server_default=sa.text("0")
|
|
|
|
|
|
)
|
|
|
|
|
|
dormant_customer_count: Mapped[int] = mapped_column(
|
|
|
|
|
|
Integer, nullable=False, default=0, server_default=sa.text("0")
|
|
|
|
|
|
)
|
|
|
|
|
|
reactivated_customer_count: Mapped[int] = mapped_column(
|
|
|
|
|
|
Integer, nullable=False, default=0, server_default=sa.text("0")
|
|
|
|
|
|
)
|
2026-09-09 16:30:05 +08:00
|
|
|
|
# 预留完整性标记:宽表生成当前恒写 True(串类列与 fact 同一算法直算,
|
|
|
|
|
|
# 不读 fact 表)——真实语义(如校验 fact 覆盖/同步完整)由批次B定义
|
2026-09-09 12:04:54 +08:00
|
|
|
|
data_complete: Mapped[bool] = mapped_column(
|
|
|
|
|
|
Boolean, nullable=False, default=False, server_default=sa.text("0")
|
|
|
|
|
|
)
|
|
|
|
|
|
computed_at: Mapped[dt.datetime] = mapped_column(
|
|
|
|
|
|
sa.DateTime, nullable=False, server_default=sa.func.now()
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class EmployeeCallDaily(Base):
|
|
|
|
|
|
"""员工×日通话小表(v2 §1.2 #9)——通话无消息渠道维度,独立于宽表。
|
|
|
|
|
|
|
2026-09-09 16:30:05 +08:00
|
|
|
|
phone_call.channel 是通话类型(实测值 voice_call/wa_voice_call,
|
|
|
|
|
|
无蜂窝 phone_call 行)非消息渠道,塞进 (day,channel,user) 宽表会在
|
|
|
|
|
|
channel=all 聚合时双计。接通 =
|
2026-09-09 12:04:54 +08:00
|
|
|
|
call_duration > 0;followup = 通话后 24h 内该员工任一账号对该客户
|
|
|
|
|
|
有消息(Task 0 已核实:1:1 通话可匹配率 100%,@chatroom 群通话除外)。
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
__tablename__ = "employee_call_daily"
|
|
|
|
|
|
|
|
|
|
|
|
day: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
|
|
|
|
user_id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
|
|
|
|
|
call_count: Mapped[int] = mapped_column(
|
|
|
|
|
|
Integer, nullable=False, default=0, server_default=sa.text("0")
|
|
|
|
|
|
)
|
|
|
|
|
|
call_seconds: Mapped[int] = mapped_column(
|
|
|
|
|
|
BigInteger, nullable=False, default=0, server_default=sa.text("0")
|
|
|
|
|
|
)
|
|
|
|
|
|
answered_call_count: Mapped[int] = mapped_column(
|
|
|
|
|
|
Integer, nullable=False, default=0, server_default=sa.text("0")
|
|
|
|
|
|
)
|
|
|
|
|
|
call_followup_count: Mapped[int] = mapped_column(
|
|
|
|
|
|
Integer, nullable=False, default=0, server_default=sa.text("0")
|
|
|
|
|
|
)
|
|
|
|
|
|
computed_at: Mapped[dt.datetime] = mapped_column(
|
|
|
|
|
|
sa.DateTime, nullable=False, server_default=sa.func.now()
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class EmployeeScoreConfig(Base):
|
|
|
|
|
|
"""评分配置版本(v1 §10.4/v2 §3.3)——config_json 存维度/指标/权重/
|
|
|
|
|
|
方向/阈值/样本门槛;激活前须过权重总和与阈值校验(批次B scoring 实现)。
|
2026-09-09 12:24:02 +08:00
|
|
|
|
MVP 无可视化编辑器,受控 CLI 种子维护。
|
|
|
|
|
|
JSON 列(config_json 等)修改须整列重赋值,原地修改不会提交。"""
|
2026-09-09 12:04:54 +08:00
|
|
|
|
|
|
|
|
|
|
__tablename__ = "employee_score_config"
|
|
|
|
|
|
|
|
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
|
|
|
|
version: Mapped[str] = mapped_column(String(32), unique=True, nullable=False)
|
|
|
|
|
|
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
|
|
|
|
|
# draft | active | retired
|
|
|
|
|
|
status: Mapped[str] = mapped_column(
|
|
|
|
|
|
String(16), nullable=False, default="draft", server_default="draft"
|
|
|
|
|
|
)
|
|
|
|
|
|
effective_from: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
|
|
|
|
effective_to: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
2026-09-09 12:24:02 +08:00
|
|
|
|
config_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
|
2026-09-09 12:04:54 +08:00
|
|
|
|
created_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
|
|
|
|
approved_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
|
|
|
|
created_at: Mapped[dt.datetime] = mapped_column(
|
|
|
|
|
|
sa.DateTime, nullable=False, server_default=sa.func.now()
|
|
|
|
|
|
)
|
|
|
|
|
|
activated_at: Mapped[dt.datetime | None] = mapped_column(sa.DateTime, nullable=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class EmployeeScoreSnapshot(Base):
|
|
|
|
|
|
"""周/月考核快照(v1 §8/v2 §3.3)——不可变:冻结后业务层禁改评分字段;
|
|
|
|
|
|
重算走 supersedes_id 替代链。同批次×员工唯一。"""
|
|
|
|
|
|
|
|
|
|
|
|
__tablename__ = "employee_score_snapshot"
|
|
|
|
|
|
|
|
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
|
|
|
|
snapshot_batch_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
|
|
|
|
|
# week | month | custom
|
|
|
|
|
|
period_type: Mapped[str] = mapped_column(String(8), nullable=False)
|
|
|
|
|
|
period_start: Mapped[int] = mapped_column(Integer, nullable=False)
|
|
|
|
|
|
period_end: Mapped[int] = mapped_column(Integer, nullable=False)
|
|
|
|
|
|
user_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
|
|
|
|
|
score_config_version: Mapped[str] = mapped_column(String(32), nullable=False)
|
|
|
|
|
|
metric_version: Mapped[str] = mapped_column(String(16), nullable=False)
|
|
|
|
|
|
total_score: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
|
|
|
|
grade: Mapped[str | None] = mapped_column(String(2), nullable=True)
|
|
|
|
|
|
# scored | insufficient_sample | incomplete_data
|
|
|
|
|
|
score_status: Mapped[str] = mapped_column(
|
|
|
|
|
|
String(24), nullable=False, default="scored", server_default="scored"
|
|
|
|
|
|
)
|
|
|
|
|
|
dimension_scores_json: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
|
|
|
|
|
metric_details_json: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
|
|
|
|
|
data_as_of: Mapped[int] = mapped_column(Integer, nullable=False)
|
|
|
|
|
|
generated_at: Mapped[dt.datetime] = mapped_column(
|
|
|
|
|
|
sa.DateTime, nullable=False, server_default=sa.func.now()
|
|
|
|
|
|
)
|
|
|
|
|
|
frozen_at: Mapped[dt.datetime | None] = mapped_column(sa.DateTime, nullable=True)
|
|
|
|
|
|
frozen_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
|
|
|
|
supersedes_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
|
|
|
|
|
|
|
|
|
|
__table_args__ = (
|
|
|
|
|
|
sa.UniqueConstraint("snapshot_batch_id", "user_id",
|
|
|
|
|
|
name="uq_score_snapshot_batch_user"),
|
|
|
|
|
|
Index("ix_score_snapshot_period", "period_type", "period_start"),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ManagementFollowup(Base):
|
|
|
|
|
|
"""管理辅导闭环(v1 §7.3/v2 §3.3)——来源指标→问题→管理动作→结果。"""
|
|
|
|
|
|
|
|
|
|
|
|
__tablename__ = "management_followup"
|
|
|
|
|
|
|
|
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
|
|
|
|
employee_user_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
|
|
|
|
|
# metric | anomaly | review | manual
|
|
|
|
|
|
source_type: Mapped[str] = mapped_column(String(16), nullable=False)
|
|
|
|
|
|
source_code: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
|
|
|
|
source_period_start: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
|
|
|
|
source_period_end: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
|
|
|
|
source_value_json: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
|
|
|
|
|
reason: Mapped[str | None] = mapped_column(sa.Text, nullable=True)
|
|
|
|
|
|
action_type: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
|
|
|
|
|
owner_manager_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
|
|
|
|
# open | in_progress | resolved | cancelled
|
|
|
|
|
|
status: Mapped[str] = mapped_column(
|
|
|
|
|
|
String(16), nullable=False, default="open", server_default="open"
|
|
|
|
|
|
)
|
|
|
|
|
|
due_at: Mapped[dt.datetime | None] = mapped_column(sa.DateTime, nullable=True)
|
|
|
|
|
|
result: Mapped[str | None] = mapped_column(sa.Text, nullable=True)
|
|
|
|
|
|
employee_feedback: Mapped[str | None] = mapped_column(sa.Text, nullable=True)
|
|
|
|
|
|
created_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
|
|
|
|
created_at: Mapped[dt.datetime] = mapped_column(
|
|
|
|
|
|
sa.DateTime, nullable=False, server_default=sa.func.now()
|
|
|
|
|
|
)
|
|
|
|
|
|
updated_at: Mapped[dt.datetime] = _updated_at()
|
|
|
|
|
|
closed_at: Mapped[dt.datetime | None] = mapped_column(sa.DateTime, nullable=True)
|
|
|
|
|
|
|
|
|
|
|
|
__table_args__ = (
|
|
|
|
|
|
Index("ix_followup_emp_status_due", "employee_user_id", "status", "due_at"),
|
|
|
|
|
|
Index("ix_followup_owner_status", "owner_manager_id", "status"),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-01 11:15:34 +08:00
|
|
|
|
__all__ = [
|
|
|
|
|
|
"ChatGroup",
|
|
|
|
|
|
"Customer",
|
|
|
|
|
|
"Department",
|
2026-09-09 12:04:54 +08:00
|
|
|
|
"EmployeeCallDaily",
|
|
|
|
|
|
"EmployeeMetricDaily",
|
|
|
|
|
|
"EmployeeScoreConfig",
|
|
|
|
|
|
"EmployeeScoreSnapshot",
|
|
|
|
|
|
"FirstReplyFact",
|
2026-08-01 11:15:34 +08:00
|
|
|
|
"GroupMember",
|
2026-09-09 12:04:54 +08:00
|
|
|
|
"ManagementFollowup",
|
2026-08-01 11:15:34 +08:00
|
|
|
|
"Media",
|
|
|
|
|
|
"Message",
|
2026-08-25 13:44:54 +08:00
|
|
|
|
"MomentsPost",
|
2026-09-03 16:37:04 +08:00
|
|
|
|
"MsgDailyStats",
|
2026-08-01 11:15:34 +08:00
|
|
|
|
"PhoneCall",
|
2026-09-04 15:04:33 +08:00
|
|
|
|
"SensitiveHit",
|
|
|
|
|
|
"SensitiveScanState",
|
|
|
|
|
|
"SensitiveWord",
|
2026-08-01 11:15:34 +08:00
|
|
|
|
"Sms",
|
|
|
|
|
|
"Staff",
|
2026-09-04 15:04:33 +08:00
|
|
|
|
"StatDailyStaff",
|
2026-08-01 11:15:34 +08:00
|
|
|
|
"SyncRun",
|
|
|
|
|
|
"SyncState",
|
2026-08-10 18:12:28 +08:00
|
|
|
|
"VoiceClip",
|
2026-08-01 11:15:34 +08:00
|
|
|
|
"WechatAccount",
|
|
|
|
|
|
"WhatsappAccount",
|
|
|
|
|
|
"YkAccount",
|
|
|
|
|
|
"message_table_name",
|
|
|
|
|
|
]
|