Files
yunke/yunke-server/app/api/schemas.py
T
浅念 d42895337d feat(link): customer_account_link 账号↔客户多对多——共享好友员工维度对齐云客
用户报「Lily 好友数 36,点进员工客户页只有 12」。真机探明根因:那 24 个
是共享客户(同一人加多个员工),云客 per-employee 各算一个;而 customer
表 PK=friend_id 全局单行、归属 last-wins,其他员工名下必然「消失」。
抓取器本身每员工都拿全了(日志 declared=36 rows=36),丢在入库模型层。

- 新表 customer_account_link(渠道×账号×客户一行,per-relation 快照:
  昵称/备注/头像/手机号/收发计数/最近沟通/add_time/fromType/isDel)
- 写:repos.upsert_customer 随档案同事务维护(所有好友抓取路径一处挂全);
  新增 prune_customer_links 对账
- 读:/api/customers?owner_account_id= 改读 link(与云客 contacts_num 口径
  一致);该账号 link 为空时回退档案路径(过渡安全);与 dept/user 等
  过滤组合时维持旧行为;CustomerOut 透传 from_type
- 抓取器:friends/wa-friends 逐员工整批 upsert 后按名单对账(取满声明
  total 才删,防半份名单误删)
- 前端:StaffFriends 数据来源列优先 link 的 from_type

611 测试绿(link 新 6 例);本地验证空表回退/造数接管/from_type 透传。
上线后需重跑 friends+wa-friends 回填 link(存量关系无法从档案反推)。

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

517 lines
18 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.
"""Pydantic v2 response models for the yunke data-mirror API."""
from __future__ import annotations
import datetime as dt
from typing import Any, Generic, Literal, TypeVar
from pydantic import BaseModel, ConfigDict
T = TypeVar("T")
class CustomerOut(BaseModel):
"""Public Customer view — a subset of :class:`app.db.models.Customer`.
The structured columns power the list table; the verbatim upstream record
is exposed as ``raw_json`` so the frontend can render the full ~60-field
云客 客户详情 (all-fields drawer) without a second endpoint. The model's
``raw_json`` column is picked up automatically via ``from_attributes``.
"""
model_config = ConfigDict(from_attributes=True)
friend_id: str
channel: str
owner_account_id: str | None = None
friend_nickname: str | None = None
# 今日发送/接收条数(本地消息表按 Asia/Shanghai 今日聚合,对齐云客口径;
# 非 ORM 列,由路由赋值,2026-08-22)
send_today: int | None = None
receive_today: int | None = None
friend_remark: str | None = None
real_name: str | None = None
phone: str | None = None
region: str | None = None
sale_name: str | None = None
dept_name: str | None = None
head_image_url: str | None = None
last_chat_time: int | None = None
add_time: int | None = None
message_count: int | None = None
# 累计发送/接收条数(同步自云客 friend 记录的 sendNum/receiveNum;员工
# 客户页「发送条数/接收条数」列用,2026-09-08)
send_num: int | None = None
receive_num: int | None = None
# 云客数据来源代码(customer_account_link 路径直读;档案路径为 None,
# 前端回退 raw_json.fromType)
from_type: int | None = None
version: str | None = None
# Verbatim upstream friend record (~60 camelCase fields). Surfaced so the
# all-fields drawer can render every column the 云客 source page shows
# (friendWxAlias/addTime/createTime/fromType/addType/...) without the
# backend having to enumerate each one as a structured column.
raw_json: dict[str, Any] | None = None
class Page(BaseModel, Generic[T]):
"""Generic pagination envelope returned by list endpoints."""
items: list[T]
total: int
page: int
page_size: int
# ---------------------------------------------------------------------------
# Organization & people
# ---------------------------------------------------------------------------
class DepartmentOut(BaseModel):
"""云客 department node — frontend builds the tree from ``parent_id``/``path``."""
model_config = ConfigDict(from_attributes=True)
dept_id: str
parent_id: str | None = None
name: str | None = None
path: str | None = None
class StaffOut(BaseModel):
"""Staff member (employee) row.
The structured columns power the list table; the verbatim upstream record
is exposed as ``raw_json`` so the frontend can render the all-fields drawer
without a second endpoint. Picked up automatically via ``from_attributes``.
"""
model_config = ConfigDict(from_attributes=True)
user_id: str
name: str | None = None
phone: str | None = None
sale_phone: str | None = None
dept_id: str | None = None
is_on_job: bool | None = None
raw_json: dict[str, Any] | None = None
class AccountOut(BaseModel):
"""Unified staff messaging account (WeChat or WhatsApp).
The two source tables (:class:`WechatAccount`, :class:`WhatsappAccount`)
have different PK / column names; this view projects both onto a single
shape keyed by ``channel`` + ``account_id`` so the frontend can render one
list. Built explicitly in the route handler (not via ``from_attributes``)
because no single ORM row carries this schema. ``raw_json`` carries the
verbatim upstream record (wechatId/alias/sendNum/receiveNum/dbSize/...) so
the all-fields drawer can render every column the 云客 微信号管理 page shows.
"""
channel: str
account_id: str
nickname: str | None = None
user_id: str | None = None
user_name: str | None = None
phone: str | None = None
contacts_num: int | None = None
group_num: int | None = None
is_on_job: bool | None = None
# 头像 URL — 从 raw_json.headImageUrl 提取(WeChat 有该键;WA 无 -> None;
# 对齐云客员工聊天窗口左侧头像,2026-08-25),由路由显式赋值。
avatar: str | None = None
raw_json: dict[str, Any] | None = None
# ---------------------------------------------------------------------------
# Communication entities
# ---------------------------------------------------------------------------
class MessageOut(BaseModel):
"""Chat timeline row — a single message (text/media/voice/...).
The structured columns power the bubble rendering; the verbatim upstream
record is exposed as ``raw_json`` so the frontend can render the all-fields
drawer (msgSvrId/keyId, file/fileTh/fileHd, md5/md5Th/md5Hd, paymsgid,
referMsg, imgPath, ...) without a second endpoint. Picked up automatically
via ``from_attributes``.
"""
model_config = ConfigDict(from_attributes=True)
msg_id: str
channel: str
owner_account_id: str | None = None
talker_id: str | None = None
room_id: str | None = None
is_send: bool | None = None
type: str | None = None
type_raw: int | None = None
text: str | None = None
file_url: str | None = None
# Image thumbnails / originals + voice duration / transcription so the
# timeline can render media without a second round-trip. All optional — the
# underlying columns (see Message model) are nullable.
file_th_url: str | None = None
file_hd_url: str | None = None
duration: int | None = None
transcription_text: str | None = None
md5: str | None = None
sender_name: str | None = None
sender_head_url: str | None = None
timestamp: int | None = None
# sha256 media_hash of the localized media blob (NULL until the
# message_images sync pass downloads it). When present, the frontend
# renders via ``/api/media/{media_hash}`` (local-first); when absent, it
# falls back to the OSS ``file_url`` / ``file_th_url`` / ``file_hd_url``.
media_hash: str | None = None
raw_json: dict[str, Any] | None = None
class GroupOut(BaseModel):
"""Chat group summary row.
The structured columns power the list table; the verbatim upstream record
is exposed as ``raw_json`` so the frontend can render the all-fields drawer
(chatRoomId/chatRoomName, memberIds, headImageUrl, wechatId/wechatNickName,
userName/userPhone, messageCount/ownMessageCount, lossMembers/addMembers,
lastChatTime/updateTime, ...) without a second endpoint. Picked up
automatically via ``from_attributes``.
"""
model_config = ConfigDict(from_attributes=True)
room_id: str
channel: str
room_name: str | None = None
owner_account_id: str | None = None
owner_account_nickname: str | None = None
member_count: int | None = None
message_count: int | None = None
last_chat_time: int | None = None
# 群主/建群补充列(2026-08-25 对齐云客微信群 tab;scraper/回填脚本提取)
owner_name: str | None = None
owner_wxid: str | None = None
owner_alias: str | None = None
owner_avatar: str | None = None
friend_count: int | None = None
create_time: int | None = None # 建群时间 ms epoch
raw_json: dict[str, Any] | None = None
class GroupMemberOut(BaseModel):
"""Member of a chat group (composite PK: channel + room_id + member_id)."""
model_config = ConfigDict(from_attributes=True)
channel: str
room_id: str
member_id: str
nickname: str | None = None
display_name: str | None = None
avatar: str | None = None
class CallStatsOut(BaseModel):
"""今日微信语音统计卡片(GET /api/calls/stats,对齐云客语音列表顶部)。
好友呼入/外呼量与时长、群语音量与群录音时长;时长单位秒(call_duration 累加)。
"""
friend_incoming: int
friend_outgoing: int
group_count: int
friend_incoming_duration: int
friend_outgoing_duration: int
group_duration: int
class PhoneCallOut(BaseModel):
"""Phone / voice call record. ``channel`` is the call-type.
The structured columns power the list table; the verbatim upstream record
is exposed as ``raw_json`` so the frontend can render the all-fields drawer
(id/startCallTime, callDuration/callSeconds, ringTime, callStatus,
incomingCall, userId/userIdName, departmentId, callNumber,
simPhone/simNumber/imei, recordFile, isUpload, downloadCount, aiAsr,
qcStatus, planCustomerName, version + voice-call extras ossFileName/
duration/startTime/endTime/remark) without a second endpoint. Picked up
automatically via ``from_attributes``.
"""
model_config = ConfigDict(from_attributes=True)
call_id: str
channel: str
start_call_time: int | None = None
call_duration: int | None = None
call_status: int | None = None
incoming_call: int | None = None
user_id: str | None = None
user_id_name: str | None = None
call_number: str | None = None
sim_phone: str | None = None
# OSS URL of the call recording — surfaced so the Calls view can offer a
# "播放录音" action when present.
record_file: str | None = None
# sha256 media_hash of the localized recording (NULL until downloaded).
record_media_hash: str | None = None
raw_json: dict[str, Any] | None = None
class SmsOut(BaseModel):
"""SMS log row.
The structured columns power the list table; the verbatim upstream record
is exposed as ``raw_json`` so the frontend can render the all-fields drawer
(id/customerPhone, sms/smsSub, sender, direction, userId,
senderDepartment, receiveName, createTime, simPhone/simNumber, source)
without a second endpoint. Picked up automatically via ``from_attributes``.
"""
model_config = ConfigDict(from_attributes=True)
sms_id: str
customer_phone: str | None = None
content_preview: str | None = None
# Full SMS body (the table stores it in ``content``); included so the SMS
# view can show the whole message in a detail dialog without a second call.
content: str | None = None
sender: str | None = None
direction: int | None = None
user_id: str | None = None
receive_name: str | None = None
create_time: int | None = None
sim_phone: str | None = None
raw_json: dict[str, Any] | None = None
class VoiceClipOut(BaseModel):
"""长语音 (voice_clip) row — a long audio segment from ``/wechatweb/call/list``.
The structured columns power the VoiceClipModal table (员工姓名 /
企业串码 / 文件地址 / 录音开始时间 / 录音结束时间 / 录音时长); the verbatim
upstream record is exposed as ``raw_json`` so the frontend can render any
extra fields without a second endpoint. Picked up automatically via
``from_attributes``.
"""
model_config = ConfigDict(from_attributes=True)
voice_clip_id: int
user_id: str
channel: str
user_real_name: str | None = None
wx_id: str | None = None
talker: str | None = None
status: str | None = None
is_send: bool | None = None
company_code: str | None = None
start_time: int | None = None
end_time: int | None = None
# OSS recording URL — surfaced so the modal can render an inline
# ``<audio controls>`` player (see spec §6).
oss_file_name: str | None = None
# sha256 media_hash of the localized audio (NULL until downloaded). Playback
# prefers /api/media/{this} over the OSS oss_file_name URL (local-first).
media_hash: str | None = None
mp3_duration: int | None = None
duration_format: str | None = None
# LOCAL-only 手动匹配 (spec §7): the customer.friend_id this clip has been
# associated with, or None when unmatched. ``matched_friend_name`` is NOT a
# stored column — it's resolved by the GET handler via a Customer lookup
# (remark > nickname > real_name > friend_id) so the modal can render the
# 「已匹配客户」 column without a second round-trip per row.
matched_friend_id: str | None = None
matched_friend_name: str | None = None
raw_json: dict[str, Any] | None = None
# ---------------------------------------------------------------------------
# Sync monitor
# ---------------------------------------------------------------------------
class SyncStateOut(BaseModel):
"""Resumable sync cursor (one per entity/channel/scope)."""
model_config = ConfigDict(from_attributes=True)
state_id: int
entity_type: str
channel: str | None = None
scope_id: str | None = None
last_version: str | None = None
last_timestamp: int | None = None
last_synced_at: dt.datetime | None = None
class SyncRunOut(BaseModel):
"""Audit row for a single sync execution."""
model_config = ConfigDict(from_attributes=True)
run_id: int
entity_type: str
channel: str | None = None
scope_id: str | None = None
status: str
started_at: dt.datetime
finished_at: dt.datetime | None = None
records_fetched: int
records_upserted: int
error: str | None = None
class SyncStatusOut(BaseModel):
"""Aggregate monitor payload — current cursors plus recent runs."""
states: list[SyncStateOut]
recent_runs: list[SyncRunOut]
# ---------------------------------------------------------------------------
# Staff chat stats (WeChat staff 5-number summary + customer detail)
# ---------------------------------------------------------------------------
class StaffChatCustomerOut(BaseModel):
"""单个客户在日期窗内的私聊聚合行(本地 message 表派生)。"""
talker_id: str
friend_name: str # remark > nickname > real_name > talker_id
real_name: str | None = None # 真实姓名(弹框客户微信列显示)
head_image_url: str | None = None # 头像(弹框客户微信列显示)
send_count: int # 员工发出(is_send True)
receive_count: int # 客户发出(is_send False)
interaction_days: int # 窗口内不同日期数
last_is_send: int | None # 1=员工最后发, 0=客户最后发, None=最后一条方向未知(不计入两率)
last_time: int | None = None # ms-epoch,最后一条时间
class StaffChatStatsSummary(BaseModel):
"""5 个汇总数字(从 customers 派生)。"""
chat_count: int # 沟通人数:窗口内 ≥1 条消息的去重客户数
effective_count: int # 有效沟通数:send≥1 且 receive≥1 的客户数
follow_count: int # 跟进客户数:interaction_days≥2 的客户数(近似口径)
last_reply_count: int # 员工最后回复数:last_is_send=1 的客户数
no_reply_count: int # 未回复客户数:last_is_send=0 的客户数
class StaffChatStatsOut(BaseModel):
owner_account_id: str
start: int # ms-epoch
end: int # ms-epoch
summary: StaffChatStatsSummary
customers: list[StaffChatCustomerOut]
class StaffStatsCountsOut(BaseModel):
"""GET /api/staff-stats 计数模式(无 kind)——云客员工聊天窗口 7 类汇总数字。
口径见 :mod:`app.api.routers.staff_stats`(spec §4.5):new_customers 含
「首条消息时间」兜底;del/blocked 是现存量(不受窗口约束);voice_calls
走 wechat_account.user_id 换算。``sensitive_words`` 为敏感词占位,恒 0。
"""
new_customers: int
new_groups: int
red_transfer: int
del_customers: int
blocked_customers: int
voice_calls: int
sensitive_words: int = 0
class StaffStatsDetailOut(BaseModel):
"""GET /api/staff-stats 明细模式(``?kind=``)——rows 字段随 kind 不同。
统一 ``list[dict]``,前端按 kind 配置列(字段清单见 router docstring):
new_customers {talker_id, nickname, avatar, remark, time} /
new_groups {room_id, room_name, time} /
red_transfer {op_type, talker_id, room_id, nickname, amount, time, msg_id} /
del|blocked_customers {talker_id, nickname, time} /
voice_calls {id, peer, incoming, connected, duration, start_time, media_url}
(语义对齐 CallList.vue / PhoneCallOut)。
"""
kind: str
rows: list[dict[str, Any]]
class SessionOut(BaseModel):
"""账号会话列表的一行(好友或群)。"""
type: Literal["friend", "group"]
talker_id: str | None = None
room_id: str | None = None
name: str | None = None
avatar: str | None = None
# filehelper 会话置顶标记(对齐云客置顶区,2026-08-25);其余好友/群恒 False
pinned: bool = False
today_count: int
last_chat_time: int | None = None
class SessionListOut(BaseModel):
owner_account_id: str
channel: str
today_start: int
sessions: list[SessionOut]
# ---------------------------------------------------------------------------
# Moments (员工朋友圈)
# ---------------------------------------------------------------------------
class MomentsHeaderOut(BaseModel):
"""朋友圈头部 — 员工账号昵称/头像/别名(wechat_account 表)。
员工微信号不存在时三个字段全 ``None``(不 404,前端资料卡兜底)。
``avatar`` 从 ``raw_json.headImageUrl`` 提取(共享 helper
:func:`app.api.routers.organization.account_avatar`)。
"""
nickname: str | None = None
avatar: str | None = None
alias: str | None = None
class MomentsPostOut(BaseModel):
"""朋友圈帖子行(moments_post 表,``from_attributes`` 直读)。
结构化列驱动帖子卡片(正文/图片/点赞/评论);点赞/评论为上游 JSON
数组原样透传,由前端解析渲染。
"""
model_config = ConfigDict(from_attributes=True)
id: str
wechat_id: str
sns_id: str | None = None
author_name: str | None = None
author_wxid: str | None = None
author_avatar: str | None = None
content: str | None = None
type_raw: str | None = None
create_time: int | None = None # ms epoch
praise_json: list[dict[str, Any]] | None = None
comment_json: list[dict[str, Any]] | None = None
image_urls: list[str] | None = None
class MomentsListOut(BaseModel):
"""GET /api/moments 响应:账号头部 + 分页帖子(create_time desc)。"""
wechat_id: str
header: MomentsHeaderOut
total: int
page: int
page_size: int
items: list[MomentsPostOut]