Files
yunke/yunke-server/app/db/models.py
T
浅念 58d274096d fix(management): first_reply_fact 同毫秒并列主键冲突+定序抖动——reply_msg_id 进 PK/按(ts,msg_id)排序
评审修正(代码质量评审 Critical#1 + Important#2/#3):
- reply_msg_id 进复合 PK(模型+migration b7c8d9e0f1a2 原地改,本地
  down/up 重建,表空未部署):同毫秒 F,T,F,T 交换的两个样本
  (thread_end_ts, reply_ts) 完全并列不再撞主键(原会中断整个
  recompute_all 事务);兼作下钻证据定位回复消息。
- load_conversation 增查 msg_id 并按 (ts, msg_id) 排序,
  compute_conversation 协议改 3 元组——SQL 等键顺序未定义,原实现
  同毫秒 [F,T] 归属随查询抖动(0/1 answered 摇摆)。
- recompute_pairs 每 500 会话显式 flush+进度日志(内存上界对齐
  summary.recompute_all 惯例,不再依赖隐式 autoflush)。
- 测试:旧 9 个更新协议;新增同毫秒 F,T,F,T 回归/并列确定性/
  空会话幂等清空,fr_sum 总量断言并入双串测试;
  test_management_models 构造补新主键列。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-09 13:18:08 +08:00

1262 lines
61 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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``,
``YkAccount``, ``SyncRun``, ``MomentsPost`` carry no ``channel`` column.
* 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
from sqlalchemy.dialects.mysql import MEDIUMTEXT # noqa: E402
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)
db_size: Mapped[int | None] = mapped_column(BigInteger, nullable=True) # 字节数可超 32 位 INT(实测 2.17GB,2026-08-21)
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)
# 云客 wxUser/list 返回顺序(对齐云客员工列表排列;NULL=未回填按 wx_id 排)
list_order: 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()
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)
add_time: Mapped[int | None] = mapped_column(BigInteger, nullable=True) # ms epoch; from raw_json.addTime
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",
),
# /api/analytics/health MAX(updated_at) 新鲜度探针;无索引时全表扫。
# 「模型+迁移双声明」防 alembic autogenerate 反 DROP(c3d4e5f6a7b8)。
Index("ix_customer_updated_at", "updated_at"),
)
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()
class ChatGroup(Base):
"""聊天群 — source: ``POST /wechatweb/chatroom/getChatroomList``.
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"
# 复合主键(channel, owner, room):同一群在每个员工名下各一行(2026-08-24)
# 原按 room_id 单列 PK 会把多员工同群去重(云客张士蓓 64 群 vs 我们 42)
room_id: Mapped[str] = mapped_column(String(128), primary_key=True)
channel: Mapped[str] = mapped_column(String(16), primary_key=True)
owner_account_id: Mapped[str] = mapped_column(String(128), primary_key=True)
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)
# 通讯录·微信群 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
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",
),
# /api/analytics/health MAX(updated_at) 新鲜度探针(c3d4e5f6a7b8)。
Index("ix_chat_group_updated_at", "updated_at"),
)
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
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)
# 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)
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)
# 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)
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",
),
# 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",
),
# 群聊消息窗实际查询形如 ``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",
),
# 今日收发聚合热路径: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",
),
# C1 敏感词增量扫描水位:MAX(fetched_at) / fetched_at > 水位 定位新入库
# 消息(按消息 timestamp 做水位会永久漏扫增量拉回的历史消息,spec §3.6)。
# 「模型+迁移双声明」防 alembic autogenerate 反 DROP。
Index(
"ix_message_fetched_at",
"fetched_at",
),
)
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|...
original_url: Mapped[str | None] = mapped_column(sa.Text, nullable=True)
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"),
# /api/analytics/health MAX(updated_at) 新鲜度探针——media 5 万宽行
# (多个 TEXT/JSON 列)在 128MB buffer pool 下最伤(c3d4e5f6a7b8)。
Index("ix_media_updated_at", "updated_at"),
)
# ===========================================================================
# 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)
# 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)
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"),
# /api/analytics/health MAX(updated_at) 新鲜度探针(c3d4e5f6a7b8)。
Index("ix_phone_call_updated_at", "updated_at"),
)
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)
# 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)
# 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)
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"),)
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"),)
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()
# /api/analytics/health MAX(updated_at) 新鲜度探针(c3d4e5f6a7b8)。
__table_args__ = (Index("ix_moments_post_updated_at", "updated_at"),)
# ===========================================================================
# 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"),)
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 批1)按「消息串」口径,见
:func:`app.storage.summary._thread_stats`:串=客户连续消息不被员工
消息打断的段(is_send=NULL 不参与串判定);日切近似——日末未闭合
串计 threads 不计 answered,跨夜的串在次日段重新起算。
"""
__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)
# 当日客户串数(串=客户连续消息不被员工消息打断的段;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")
)
updated_at: Mapped[dt.datetime] = _updated_at()
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 批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")
)
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
)
# 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()
)
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"
)
# 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()
)
__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-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)
# 回复消息 msg_id:同毫秒 F,T,F,T 交换的 (thread_end_ts, reply_ts) 并列
# 去重(一条回复恰好闭合一个串);兼作下钻证据定位回复消息
reply_msg_id: Mapped[str] = mapped_column(String(64), primary_key=True)
# 串末条所在上海自然日 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")
)
# fact 是否已覆盖当日(宽表生成前置条件;False=串类列可能滞后)
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)——通话无消息渠道维度,独立于宽表。
phone_call.channel 是通话类型(phone_call/voice_call)非消息渠道,
塞进 (day,channel,user) 宽表会在 channel=all 聚合时双计。接通 =
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 实现)。
MVP 无可视化编辑器,受控 CLI 种子维护。
JSON 列(config_json 等)修改须整列重赋值,原地修改不会提交。"""
__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)
config_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
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"),
)
__all__ = [
"ChatGroup",
"Customer",
"Department",
"EmployeeCallDaily",
"EmployeeMetricDaily",
"EmployeeScoreConfig",
"EmployeeScoreSnapshot",
"FirstReplyFact",
"GroupMember",
"ManagementFollowup",
"Media",
"Message",
"MomentsPost",
"MsgDailyStats",
"PhoneCall",
"SensitiveHit",
"SensitiveScanState",
"SensitiveWord",
"Sms",
"Staff",
"StatDailyStaff",
"SyncRun",
"SyncState",
"VoiceClip",
"WechatAccount",
"WhatsappAccount",
"YkAccount",
"message_table_name",
]