"""合作方通讯录 —— 名册 + 打标结果。 两张表,分工是「事实」和「判断」: * :class:`PartnerDirectoryMember` 是**对方说的**:CBG 飞书租户里有这么 一个人,邮箱是这个,GitHub ID 是这个。它是一份镜像,我们不修改它, 对方改了下一次同步就跟着改。 * :class:`PartnerDirectoryLink` 是**我们推的**:我们这边某个认不出人的 账号 = 对方名册里的某个人。它记的是「凭什么这么认为」和「这个结论 落到哪儿去了」。 **为什么判断要单独一张表,而不是在成员行上加两列。** 打标最终写的是 ``external_account_registry``(跨厂商,按邮箱)和 ``github_org_memberships.external_confirmed_at``(按 handle)。那两处 都只留得下结论,留不下依据 —— 而这里的依据是会被质疑的:跨域邮箱名 匹配把对方的 ``zhang.san@pingcap.cn`` 认成我们的 ``zhang.san@pingcap.com``,这是一条**推断**,不是一条事实。有人问 「凭什么说这个 Google 账号是 CBG 的」时,必须答得出「因为对方名册里 有同名邮箱名的在职员工 X,匹配方式是 email_localpart,写入时间是某 时某刻」。没有这张表,那个问题只能靠翻审计日志回答,而审计日志按 「谁做了什么」建索引,不按「这条结论怎么来的」。 顺带它还承担待确认队列:只靠姓名对上的弱匹配落在这里 (``state='pending_review'``),不往那两处写。 """ from __future__ import annotations import uuid from datetime import datetime from sqlalchemy import ( JSON, Boolean, DateTime, ForeignKey, Index, String, Text, UniqueConstraint, func, ) from sqlalchemy.orm import Mapped, mapped_column from app.core.database import Base # ── 打标落点(surface)────────────────────────────────────────────── # 一个 surface = 我们这边一类「认不出是谁」的账号。新增一类的成本是 # 一个常量 + 匹配器里的一段查询,不是一次迁移。 SURFACE_GOOGLE_ORPHAN = "google_orphan" # google_orphan_accounts SURFACE_VENDOR_SEAT = "vendor_seat" # software_external_holder(category='external') SURFACE_GITHUB_ORG = "github_org" # github_org_memberships(person_id IS NULL) LINK_SURFACES: tuple[str, ...] = ( SURFACE_GOOGLE_ORPHAN, SURFACE_VENDOR_SEAT, SURFACE_GITHUB_ORG, ) # ── 匹配方式 ──────────────────────────────────────────────────────── # 前三种是**确切匹配**,自动落库;``name`` 是弱信号,只进待确认队列。 # 这个分界线不是强弱程度的主观判断:前三种匹配的都是**由 IT 分配、 # 全局唯一**的标识符(邮箱、GitHub handle),同名撞车需要两家公司分配 # 出同一个标识符;而中文姓名在 500 人规模的两家公司之间重名是常态。 MATCH_EMAIL_EXACT = "email_exact" # 完整邮箱串相同 MATCH_EMAIL_LOCALPART = "email_localpart" # 邮箱名相同、域不同(CBG 主路径) MATCH_GITHUB_ID = "github_id" # 对方通讯录里登记的 GitHub handle MATCH_NAME = "name" # 仅姓名相同 —— 弱 EXACT_MATCH_METHODS: frozenset[str] = frozenset({ MATCH_EMAIL_EXACT, MATCH_EMAIL_LOCALPART, MATCH_GITHUB_ID, }) MATCH_METHODS: tuple[str, ...] = ( MATCH_EMAIL_EXACT, MATCH_EMAIL_LOCALPART, MATCH_GITHUB_ID, MATCH_NAME, ) # ── 链接状态 ──────────────────────────────────────────────────────── LINK_APPLIED = "applied" # 标签已经落到目标系统 LINK_PENDING_REVIEW = "pending_review" # 弱匹配,等人确认 LINK_DISMISSED = "dismissed" # 人否掉了 —— 不再复活(见下) LINK_STALE = "stale" # 目标行或对方名册行消失了 LINK_STATES: tuple[str, ...] = ( LINK_APPLIED, LINK_PENDING_REVIEW, LINK_DISMISSED, LINK_STALE, ) class PartnerDirectoryMember(Base): """合作方名册里的一个人 —— 对方通讯录的只读镜像。 自然键是 ``(integration_id, external_id)``,``external_id`` 是对方 的 open_id。**不用邮箱做键**:对方可以给一个人改邮箱,也可以把一个 离职者的邮箱给新人 —— 按邮箱做键的话,第二种情况会让新人静默继承 前任身上的所有标签。 行**不硬删**。对方名册里消失的人置 ``deleted_at``,而且只在 ``PartnerDirectorySnapshot.complete`` 为真时才置 —— 一次因为权限 过期而读到半份名册的同步,绝不能把所有人标成已离开(那会连带把 我们这边几十个账号的 CBG 标签变成僵尸链接)。 """ __tablename__ = "partner_directory_members" __mapper_args__ = {"eager_defaults": True} id: Mapped[str] = mapped_column( String(36), primary_key=True, default=lambda: str(uuid.uuid4()), ) integration_id: Mapped[str] = mapped_column( String(36), ForeignKey("integrations.id", ondelete="CASCADE"), nullable=False, ) #: 对方目录的稳定用户 id(飞书 open_id)。 external_id: Mapped[str] = mapped_column(String(128), nullable=False) union_id: Mapped[str | None] = mapped_column(String(128)) #: 对方 HR 侧的 id(飞书 user_id)。 partner_user_id: Mapped[str | None] = mapped_column(String(128)) employee_no: Mapped[str | None] = mapped_column(String(64)) name: Mapped[str | None] = mapped_column(String(255)) en_name: Mapped[str | None] = mapped_column(String(255)) #: 对方 IT 发的企业邮箱(飞书 enterprise_email),小写归一。 email: Mapped[str | None] = mapped_column(String(255)) #: 飞书 email 字段 —— 经常是私人邮箱,只参与完整串精确匹配。 secondary_email: Mapped[str | None] = mapped_column(String(255)) #: ``email`` 的 local-part,小写。**跨域匹配就靠这一列**,所以它 #: 单独存一列并建索引,而不是每次查询现算 SUBSTRING_INDEX —— #: 后者会让匹配变成一次全表扫描。 email_name: Mapped[str | None] = mapped_column(String(255)) job_title: Mapped[str | None] = mapped_column(String(255)) department_ids: Mapped[list | None] = mapped_column(JSON) #: 由外到内的部门名链,给人看的。 department_path: Mapped[list | None] = mapped_column(JSON) github_login: Mapped[str | None] = mapped_column(String(64)) github_login_lower: Mapped[str | None] = mapped_column(String(64)) #: 仍在对方在职。离职的人**保留在名册里**(is_active=False)—— #: GitHub 上残留的 handle 往往正属于已经从对方离职的人,把他们从 #: 名册里删掉,那些 handle 就又变回「认不出是谁」了。 is_active: Mapped[bool] = mapped_column( Boolean, nullable=False, default=True, server_default="1", ) #: 对方那边被停用但未离职(飞书 is_frozen)。 is_suspended: Mapped[bool] = mapped_column( Boolean, nullable=False, default=False, server_default="0", ) status_raw: Mapped[dict | None] = mapped_column(JSON) #: 其它自定义字段(HRBP / Base 地 / …),按字段 id 存原值。 custom_attrs: Mapped[dict | None] = mapped_column(JSON) first_seen_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), ) last_seen_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), ) #: 只在完整快照里缺席才置位 —— 见类文档。 deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), ) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now(), ) __table_args__ = ( UniqueConstraint( "integration_id", "external_id", name="uq_partner_directory_members_ext", ), Index("ix_partner_directory_members_email_name", "email_name"), Index("ix_partner_directory_members_email", "email"), Index( "ix_partner_directory_members_github", "github_login_lower", ), Index( "ix_partner_directory_members_live", "integration_id", "deleted_at", "is_active", ), ) def __repr__(self) -> str: # pragma: no cover - 调试用 return f"" class PartnerDirectoryLink(Base): """「我们这边这个账号 = 对方名册里那个人」的一条判断。 自然键是 ``(integration_id, surface, target_key)`` —— 一个目标账号 对一个合作方最多一条判断。``member_id`` 指向命中的那个人,可空: 对方名册行被清掉之后,判断本身(和它对目标系统造成的写入)仍然 存在,得留得下来才能解释。 ``dismissed`` 是**终态**,同步不会把它改回 pending_review。这一点 是刻意的:一条被人否掉的匹配,如果每天同步都把它重新提出来,那个 待确认队列一周之后就没人看了。要复活只能由人显式操作。 """ __tablename__ = "partner_directory_links" __mapper_args__ = {"eager_defaults": True} id: Mapped[str] = mapped_column( String(36), primary_key=True, default=lambda: str(uuid.uuid4()), ) integration_id: Mapped[str] = mapped_column( String(36), ForeignKey("integrations.id", ondelete="CASCADE"), nullable=False, ) member_id: Mapped[str | None] = mapped_column( String(36), ForeignKey("partner_directory_members.id", ondelete="SET NULL"), ) #: LINK_SURFACES 之一。 surface: Mapped[str] = mapped_column(String(32), nullable=False) #: 目标行在它自己那套体系里的键:Google 孤儿账号是主邮箱、厂商坐席 #: 是 holder_key、GitHub 是小写 handle。都小写归一。 target_key: Mapped[str] = mapped_column(String(255), nullable=False) #: 给人看的目标描述(邮箱 / handle / 厂商名),避免列表页为了显示 #: 一行字去 join 三张不同的表。 target_label: Mapped[str | None] = mapped_column(String(255)) #: MATCH_METHODS 之一。 match_method: Mapped[str] = mapped_column(String(32), nullable=False) #: LINK_STATES 之一。 state: Mapped[str] = mapped_column( String(24), nullable=False, default=LINK_PENDING_REVIEW, server_default=LINK_PENDING_REVIEW, ) #: 标签真正写进目标系统的时刻。``state='applied'`` 而这里为空, #: 意味着写入被目标系统拒绝了(例如注册表里已有管理员的显式分类) #: —— 那是需要看的,不是可以忽略的。 applied_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) #: 目标系统没有接受写入时的原因(已有人工分类 / 已关联到人 / …)。 apply_skipped_reason: Mapped[str | None] = mapped_column(String(255)) reviewed_by: Mapped[str | None] = mapped_column( String(36), ForeignKey("identity_persons.id", ondelete="SET NULL"), ) reviewed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) note: Mapped[str | None] = mapped_column(Text) last_seen_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), ) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), ) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now(), ) __table_args__ = ( UniqueConstraint( "integration_id", "surface", "target_key", name="uq_partner_directory_links_target", ), Index("ix_partner_directory_links_state", "state", "surface"), Index("ix_partner_directory_links_member", "member_id"), ) def __repr__(self) -> str: # pragma: no cover - 调试用 return ( f"" )