"""GitHub 组织域 ORM 模型(迁移 566;GitHub ID 自助修改单 579)。 六张表,各自回答一个问题: * :class:`GithubOrg` —— **我们管着哪些组织**。登记表,不是硬编码常量。 以前这 5 个组织只存在于飞书表单的一个单选控件里;要加第 6 个,得 改飞书、改代码、改前端三处。登记表把它变成一行数据。 * :class:`GithubOrgMembership` —— **谁在里面**。台账。离职回收、访问 复核、与 GitHub 的对账全部以它为准。 * :class:`GithubOrgMembershipEvent` —— **这条成员资格经历过什么**。 只追加。与审计日志分工不同,审计日志答「平台上谁做了什么」,这里 答「这一条成员资格的来龙去脉」—— 出事时问的是后者。 * :class:`GithubOrgJoinRequest` —— **申请单**。形状与 ``VpnApplication`` / ``SsoGrant`` / ``DomainRegistration`` 逐字对齐。 * :class:`GithubOrgAutoJoinRule` —— **哪些人天然就该在里面**。部门子树 规则,入职时评估。 * :class:`GithubIdChangeRequest` —— **员工自己改 handle 的单子**。上面 五张表全都依赖 ``identity_persons.github_id`` 认人,而那一列此前只在 入职时采集、之后没有任何更新入口;这张单子是那个入口。 为什么台账与申请单分成两张表:一条成员资格可以没有申请单(存量导入、 自动加入、管理员手工加),一张申请单也可能永远不产生成员资格(被拒、 被撤销)。把它们塞进一张表,就得给一半的列准备「这种来源下这列没有 意义」的解释,而每一条这样的解释都是将来某个查询会漏掉的条件。 """ from __future__ import annotations import uuid from datetime import datetime from typing import Any from sqlalchemy import ( JSON, Boolean, Computed, DateTime, ForeignKey, Index, Integer, String, Text, UniqueConstraint, func, text, ) from sqlalchemy.orm import Mapped, mapped_column from app.core.database import Base from . import constants as const class GithubOrg(Base): """一个我们纳管的 GitHub 组织。 ``slug`` 是自然键(GitHub 上的 org login,例如 ``pingcap-inc``)。 整张表是**登记表**语义:一行在这里,才意味着 Helix 认这个组织 —— 自助申请的下拉、自动加入规则的目标、离职扫描的范围,全部从这里取, 没有第二份清单。 """ __tablename__ = "github_orgs" # ``eager_defaults`` 让 server_default 列(created_at / updated_at) # 在 INSERT 时就随 RETURNING/额外 SELECT 取回。不加的话它们保持 # 未加载状态,路由在 ``await db.commit()`` **之后**序列化时才第一次 # 碰到它们 —— 那已经不在 greenlet 上下文里,直接 MissingGreenlet。 __mapper_args__ = {"eager_defaults": True} id: Mapped[str] = mapped_column( String(36), primary_key=True, default=lambda: str(uuid.uuid4()), ) #: GitHub 上的组织 login。大小写不敏感地唯一 —— 见 ``slug_lower``。 slug: Mapped[str] = mapped_column( String(const.GITHUB_ORG_SLUG_MAX_LEN), nullable=False, ) #: ``slug`` 的小写副本,唯一约束挂在它上面。 #: #: 为什么不直接给 ``slug`` 加唯一索引:MySQL/TiDB 的默认排序规则 #: 是否大小写不敏感取决于建库时的 collation,依赖它等于把一条正确性 #: 约束寄托在环境配置上。存一列小写值,约束就与 collation 无关。 #: 同样的理由,``github_org_memberships.github_login_lower`` 也这么做。 slug_lower: Mapped[str] = mapped_column( String(const.GITHUB_ORG_SLUG_MAX_LEN), nullable=False, ) display_name: Mapped[str] = mapped_column(String(128), nullable=False) description: Mapped[str | None] = mapped_column(Text) #: 社区组织(pingcap / tikv)。仅作展示与默认值推断,**不**直接决定 #: 离职行为 —— 那由 ``offboarding_policy`` 说了算,因为「是社区组织」 #: 和「离职后可以留下」是两个判断,前者是事实,后者是政策。 is_community: Mapped[bool] = mapped_column( Boolean, nullable=False, default=False, server_default="0", ) #: 离职时怎么处理这个组织的成员资格。见 constants 里的说明。 offboarding_policy: Mapped[str] = mapped_column( String(16), nullable=False, default=const.OFFBOARDING_POLICY_REMOVE, server_default=const.OFFBOARDING_POLICY_REMOVE, ) #: 员工能不能在 Helix 自助申请加入这个组织。 request_enabled: Mapped[bool] = mapped_column( Boolean, nullable=False, default=True, server_default="1", ) #: 自动加入规则能不能作用于这个组织。与 ``request_enabled`` 分开: #: 一个组织可以「能申请但不自动进」(需要人看一眼),反过来也成立。 auto_join_enabled: Mapped[bool] = mapped_column( Boolean, nullable=False, default=False, server_default="0", ) #: 邀请时授予的组织角色。默认 member —— owner 永远不应该由自动化发出。 default_role: Mapped[str] = mapped_column( String(16), nullable=False, default=const.MEMBERSHIP_ROLE_MEMBER, server_default=const.MEMBERSHIP_ROLE_MEMBER, ) #: 飞书审批表单里「GitHub Organization」单选控件对应的选项值, #: 形如 ``l9i7g355-les25siqrij-0``。 #: #: Helix 发起审批时必须原样回填这个值,否则飞书会拒绝表单。它属于 #: 上游控件的内部标识,既不可推导也不稳定 —— 存在登记表里,是为了 #: 让「飞书那边加了一个新组织选项」变成改一行数据,而不是改一次代码 #: 再发一次版。为空表示这个组织还没在飞书表单里出现,此时自助申请 #: 会被挡在提交入口(见 service 层的校验),而不是发出一张飞书会拒的单。 feishu_option_value: Mapped[str | None] = mapped_column(String(128)) #: 由哪个 ``integrations`` 行(kind=github_org)持有这个组织的凭证。 #: 允许为空:登记先行、凭证后到是正常的上线顺序,此时所有写操作 #: 降级为人工兜底而不是报错。 integration_id: Mapped[str | None] = mapped_column( String(36), ForeignKey("integrations.id", ondelete="SET NULL"), ) sort_order: Mapped[int] = mapped_column( Integer, nullable=False, default=0, server_default="0", ) is_active: Mapped[bool] = mapped_column( Boolean, nullable=False, default=True, server_default="1", ) notes: Mapped[str | None] = mapped_column(Text) #: 坐席快照,由每日成员对账顺带刷新。 #: #: ``seat_plan`` **决定另外两列有没有意义**,所以它必须一起存:free 计划 #: 报 ``seats=92`` 而 ``filled_seats=203``(按字面算余量 -111,一个永久 #: 亮着的假警报);enterprise 报的是整个 enterprise 的池子。只有 #: ``team`` / ``business`` 这类计划的数字是「这个组织的、我们能动的」上限。 #: 读的人不看 ``seat_plan`` 就无从判断,所以别只存两个数字。 seat_plan: Mapped[str | None] = mapped_column(String(32)) seat_filled: Mapped[int | None] = mapped_column(Integer) seat_total: Mapped[int | None] = mapped_column(Integer) seat_checked_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), ) #: 最近一次与 GitHub 对账的时间 / 结果摘要。NULL = 从未对过账, #: 与「对过账但一个差异都没有」不是一回事。 last_reconciled_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), ) last_reconcile_error: Mapped[str | None] = mapped_column(String(512)) created_by: Mapped[str | None] = mapped_column( String(36), ForeignKey("identity_persons.id", ondelete="SET NULL"), ) 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(), ) deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) __table_args__ = ( UniqueConstraint("slug_lower", name="uq_github_orgs_slug_lower"), Index("ix_github_orgs_active", "is_active", "deleted_at"), ) def __repr__(self) -> str: # pragma: no cover - 调试用 return f"" class GithubOrgMembership(Base): """一条「某个 handle 在某个组织里」的台账记录。 自然键是 ``(org_id, github_login_lower)`` —— **不是** ``(org_id, person_id)``。理由:GitHub 侧的事实是按 handle 说的,而 handle 未必 能对上人(外部协作者、机器账号、离职后改名、我们还没登记映射)。 以 handle 为键,对账时「GitHub 上有、Helix 没有」就能如实落一行, ``person_id`` 留空表示「还没认出这是谁」—— 那正是需要人看的东西, 而不是应该被丢弃的噪音。 一个 (org, handle) 只有一行,状态在这一行上流转;历史进 :class:`GithubOrgMembershipEvent`。重新加入不新建行,而是把同一行 从 ``removed`` 推回 ``pending_invite`` —— 这样「这个人进出过几次」 是一次事件流查询,而不是一次容易漏 ``deleted_at`` 条件的分组统计。 """ __tablename__ = "github_org_memberships" # ``eager_defaults`` 让 server_default 列(created_at / updated_at) # 在 INSERT 时就随 RETURNING/额外 SELECT 取回。不加的话它们保持 # 未加载状态,路由在 ``await db.commit()`` **之后**序列化时才第一次 # 碰到它们 —— 那已经不在 greenlet 上下文里,直接 MissingGreenlet。 __mapper_args__ = {"eager_defaults": True} id: Mapped[str] = mapped_column( String(36), primary_key=True, default=lambda: str(uuid.uuid4()), ) org_id: Mapped[str] = mapped_column( String(36), ForeignKey("github_orgs.id", ondelete="CASCADE"), nullable=False, ) #: 可空 —— 见类文档:认不出人不是错误状态,是待办状态。 person_id: Mapped[str | None] = mapped_column( String(36), ForeignKey("identity_persons.id", ondelete="SET NULL"), ) github_login: Mapped[str] = mapped_column( String(const.GITHUB_LOGIN_MAX_LEN), nullable=False, ) github_login_lower: Mapped[str] = mapped_column( String(const.GITHUB_LOGIN_MAX_LEN), nullable=False, ) state: Mapped[str] = mapped_column( String(24), nullable=False, default=const.MEMBERSHIP_STATE_PENDING_INVITE, server_default=const.MEMBERSHIP_STATE_PENDING_INVITE, ) role: Mapped[str] = mapped_column( String(16), nullable=False, default=const.MEMBERSHIP_ROLE_MEMBER, server_default=const.MEMBERSHIP_ROLE_MEMBER, ) source: Mapped[str] = mapped_column( String(24), nullable=False, default=const.SOURCE_MANUAL, server_default=const.SOURCE_MANUAL, ) join_request_id: Mapped[str | None] = mapped_column( String(36), ForeignKey("github_org_join_requests.id", ondelete="SET NULL"), ) #: 命中的自动加入规则。规则被删掉后置空 —— 规则消失不该把已经发生的 #: 事实一起带走,来源仍然记在 ``source`` 上。 auto_join_rule_id: Mapped[str | None] = mapped_column( String(36), ForeignKey("github_org_auto_join_rules.id", ondelete="SET NULL"), ) #: GitHub 侧待接受邀请的 id,用于取消/重发。接受或过期后置空。 external_invitation_id: Mapped[str | None] = mapped_column(String(64)) invited_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) #: 邀请的失效时刻(``invited_at`` + 7 天)。存下来而不是每次算, #: 是为了让「快过期了」能进 SQL 而不是进 Python 循环。 invitation_expires_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), ) joined_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) removed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) removed_reason: Mapped[str | None] = mapped_column(String(255)) removal_source: Mapped[str | None] = mapped_column(String(24)) removed_by: Mapped[str | None] = mapped_column( String(36), ForeignKey("identity_persons.id", ondelete="SET NULL"), ) #: 最近一次对账在 GitHub 上确认看到它。用于识别「Helix 说在、GitHub #: 说不在」的漂移 —— 一条 active 但很久没被看到的记录是可疑的。 last_verified_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), ) #: GitHub 公开个人资料的快照(``name`` / ``company`` / ``email`` / #: ``bio`` / ``blog`` / ``location``)。 #: #: 存下来而不是每次现拉,有两个理由。一是量:一次匹配要看几百个 #: handle,现拉就是几百次 API 调用外加限流。二是**人和 AI 必须看到 #: 同一份依据** —— 界面上那句「company: @pingcap」如果和 AI 当时 #: 看的不是同一份数据,人就没法复核 AI 的判断。 github_profile: Mapped[dict | None] = mapped_column(JSON) profile_fetched_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), ) #: 人确认过「这个 handle 不是我们的员工」。NULL = 还没认。 #: #: 这一列存在的唯一理由是让「认不出是谁」这个队列**能收敛**。 #: 导入生产数据后有 331 条认不出人;抽样拉个人资料发现其中相当一部分 #: company 写着别家公司或基金会 —— 他们本来就不是员工,永远也「认」 #: 不出来。没有这一列,那 331 条就是一个永久非空的待办数,而一个永久 #: 非空的待办数等于没有待办数:看的人第二周就不看了。 #: #: 被确认为外部的行**仍然留在台账里** —— 外部协作者确实持有访问权, #: 只是离职回收对他们不适用(他们不在 HRIS 里)。这是把「谁该管这条」 #: 说清楚,不是把它扫到地毯下面。 external_confirmed_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), ) external_confirmed_by: Mapped[str | None] = mapped_column( String(36), ForeignKey("identity_persons.id", ondelete="SET NULL"), ) external_note: Mapped[str | None] = mapped_column(String(255)) #: 确认成外部之后,**是哪一种**外部:``bot`` 还是 ``outside_human``。 #: #: 两者要问的问题完全不同。别家公司的**人**持有访问权,访问复核问的是 #: 「他还需要吗」;机器账号问的是「这个自动化还在用吗、token 谁管」。 #: 混在一格里,「已确认外部 N 条」这个数就再也回答不了任何问题。 #: #: 只在 ``external_confirmed_at`` 非空时有意义。刻意不加 CHECK 约束 #: (TiDB 对它的支持历史上反复);这条不变量由 service 层守, #: ``confirm_external`` 是唯一写入口。 external_kind: Mapped[str | None] = mapped_column(String(16)) 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( "org_id", "github_login_lower", name="uq_github_org_memberships_org_login", ), Index("ix_github_org_memberships_person", "person_id", "state"), Index("ix_github_org_memberships_state", "state", "org_id"), Index( "ix_github_org_memberships_invite_expiry", "state", "invitation_expires_at", ), # 治理面每次都问「还没认的有多少」,那是一个 # (person_id IS NULL, external_confirmed_at IS NULL, state) 的组合。 Index( "ix_github_org_memberships_link_triage", "person_id", "external_confirmed_at", "state", ), ) def __repr__(self) -> str: # pragma: no cover - 调试用 return ( f"" ) class GithubOrgMembershipEvent(Base): """一条成员资格的只追加历史。 与审计日志的分工:审计日志按「谁做了什么」建索引,回答的是平台层面 的追责;这张表按成员资格建索引,回答的是「这个人为什么在这个组织里, 从什么时候开始」—— 访问复核时问的正是后者,而在审计日志里做这件事 要跨多种 action 拼时间线。 """ __tablename__ = "github_org_membership_events" # ``eager_defaults`` 让 server_default 列(created_at / updated_at) # 在 INSERT 时就随 RETURNING/额外 SELECT 取回。不加的话它们保持 # 未加载状态,路由在 ``await db.commit()`` **之后**序列化时才第一次 # 碰到它们 —— 那已经不在 greenlet 上下文里,直接 MissingGreenlet。 __mapper_args__ = {"eager_defaults": True} id: Mapped[str] = mapped_column( String(36), primary_key=True, default=lambda: str(uuid.uuid4()), ) membership_id: Mapped[str] = mapped_column( String(36), ForeignKey("github_org_memberships.id", ondelete="CASCADE"), nullable=False, ) event_type: Mapped[str] = mapped_column(String(32), nullable=False) from_state: Mapped[str | None] = mapped_column(String(24)) to_state: Mapped[str | None] = mapped_column(String(24)) #: 谁触发的。系统触发(cron / 离职流程)时为空,由 ``actor_kind`` 区分。 actor_person_id: Mapped[str | None] = mapped_column( String(36), ForeignKey("identity_persons.id", ondelete="SET NULL"), ) actor_kind: Mapped[str] = mapped_column( String(16), nullable=False, default="system", server_default="system", ) detail: Mapped[dict[str, Any] | None] = mapped_column(JSON) #: 128,不是 64。这个域的 cron 用 ``f"{job_key}:{uuid4()}"`` 做 #: trace_id,``github_org.invitation_reconcile:`` 就是 68 个 #: 字符 —— 64 时每一次真实的状态推进都在 flush 上被 1406 拒掉,而 #: 报出来的是下游的 PendingRollbackError。宽度由 #: ``test_invariants.test_every_cron_trace_id_fits_the_event_column`` #: 按注册表逐个量着。 trace_id: Mapped[str | None] = mapped_column(String(128)) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), ) __table_args__ = ( Index( "ix_github_org_membership_events_membership", "membership_id", "created_at", ), ) class GithubOrgJoinRequest(Base): """一张「申请加入 GitHub 组织」的单子。 形状与 ``VpnApplication`` 对齐:提交落 pending,飞书审批回调翻状态, 通过后执行并落一条成员台账。 **这里没有 ``membership_id``**,是有意的。两张表互指会在 ``Base.metadata.sorted_tables`` 上形成一个不可解的环(SQLAlchemy 会 警告并**放弃**考虑这些 FK,测试的建表/清表顺序因此失去保障),而这条 反向边是冗余的:成员台账的自然键就是 ``(org_id, github_login)``,而 申请单两者都有 —— "这张单子产出了哪条成员资格"是一次按自然键的查询, 不需要再存一列去和事实竞争。台账那边的 ``join_request_id`` 保留,它 答的是另一个方向的问题("这条成员资格最近一次是谁批的")。 ``github_login`` 独立存在这里而不是复用 ``identity_persons.github_id``: 后者由 HRIS(SF ``cust_Other``)同步覆盖,写回去会在下一次同步时被 上游值盖掉。申请单记录的是**提交这一刻申请人声明的 handle**,它是 这条成员资格的依据,不该随人员主数据漂移。 """ __tablename__ = "github_org_join_requests" # ``eager_defaults`` 让 server_default 列(created_at / updated_at) # 在 INSERT 时就随 RETURNING/额外 SELECT 取回。不加的话它们保持 # 未加载状态,路由在 ``await db.commit()`` **之后**序列化时才第一次 # 碰到它们 —— 那已经不在 greenlet 上下文里,直接 MissingGreenlet。 __mapper_args__ = {"eager_defaults": True} id: Mapped[str] = mapped_column( String(36), primary_key=True, default=lambda: str(uuid.uuid4()), ) person_id: Mapped[str] = mapped_column( String(36), ForeignKey("identity_persons.id"), nullable=False, ) org_id: Mapped[str] = mapped_column( String(36), ForeignKey("github_orgs.id", ondelete="RESTRICT"), nullable=False, ) github_login: Mapped[str] = mapped_column( String(const.GITHUB_LOGIN_MAX_LEN), nullable=False, ) #: 是否社区成员 + 社区 team。飞书表单里是两个联动控件,原样带过来 —— #: 它决定 org owner 审批时看到的信息,即便本期不自动开 team。 is_community_member: Mapped[bool] = mapped_column( Boolean, nullable=False, default=False, server_default="0", ) community_team: Mapped[str | None] = mapped_column(String(128)) reason: Mapped[str | None] = mapped_column(Text) status: Mapped[str] = mapped_column( String(16), nullable=False, default=const.JOIN_REQUEST_STATUS_PENDING, server_default=const.JOIN_REQUEST_STATUS_PENDING, ) feishu_instance_code: Mapped[str | None] = mapped_column(String(64)) approval_request_id: Mapped[str | None] = mapped_column( String(36), ForeignKey("approval_requests.id", ondelete="SET NULL"), ) decided_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) executed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) #: 执行失败的原因。与 ``status='failed'`` 配套 —— 一个失败的单子必须 #: 能自己说出为什么,否则运维只能去翻日志。 last_error: Mapped[str | None] = mapped_column(String(512)) 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__ = ( Index("ix_github_org_join_requests_person", "person_id", "status"), Index("ix_github_org_join_requests_status", "status", "created_at"), Index("ix_github_org_join_requests_instance", "feishu_instance_code"), ) class GithubOrgAutoJoinRule(Base): """「哪些人入职后天然就该在这个组织里」。 三个可选匹配维度(部门子树 / 职位 / 工作地),NULL = 通配。与 ``provision_policies`` 的规范匹配器(``app/core/provision_match``) 有一处**故意的差异**:部门按**子树**匹配,不是精确相等。 为什么:软件席位的策略按精确部门配是合理的(不同二级部门用的工具 不一样);而「研发线的人都该在 pingcap-inc 里」这句话,如果要精确 匹配,得把研发线下面每一个叶子部门都列一遍,并且组织架构一调整就 静默失效 —— 这正是自动化想消灭的那种手工维护。 子树语义按**路径段**比较,不是字符串前缀:规则 ``Engineering`` 命中 ``Engineering / TiDB``,但**不**命中 ``Engineering Support``。裸前缀 匹配会吃掉后者,那是对外 API 部门筛选踩过的坑,这里不重犯。 """ __tablename__ = "github_org_auto_join_rules" # ``eager_defaults`` 让 server_default 列(created_at / updated_at) # 在 INSERT 时就随 RETURNING/额外 SELECT 取回。不加的话它们保持 # 未加载状态,路由在 ``await db.commit()`` **之后**序列化时才第一次 # 碰到它们 —— 那已经不在 greenlet 上下文里,直接 MissingGreenlet。 __mapper_args__ = {"eager_defaults": True} id: Mapped[str] = mapped_column( String(36), primary_key=True, default=lambda: str(uuid.uuid4()), ) org_id: Mapped[str] = mapped_column( String(36), ForeignKey("github_orgs.id", ondelete="CASCADE"), nullable=False, ) #: 部门路径,剥掉租户根后的斜杠拼接形式,例如 #: ``Engineering Group / TiDB``。NULL = 不限部门。 match_department_path: Mapped[str | None] = mapped_column(String(512)) match_job_title: Mapped[str | None] = mapped_column(String(128)) #: 工作地代码(``identity_persons.work_location_code``),与 #: ``provision_policies.match_location`` 同一口径。 match_location: Mapped[str | None] = mapped_column(String(64)) is_active: Mapped[bool] = mapped_column( Boolean, nullable=False, default=True, server_default="1", ) notes: Mapped[str | None] = mapped_column(Text) created_by: Mapped[str | None] = mapped_column( String(36), ForeignKey("identity_persons.id", ondelete="SET NULL"), ) 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(), ) deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) __table_args__ = ( Index("ix_github_org_auto_join_rules_org", "org_id", "is_active"), ) class GithubIdChangeRequest(Base): """一张「我要改自己的 GitHub ID」的单子(迁移 579)。 与 :class:`GithubOrgJoinRequest` 的关键区别:那张单子记录的是**申请 这一刻声明的 handle**,刻意不回写人员主数据;这张单子的**全部目的** 就是回写 —— 通过后 ``identity_persons.github_id``、SF ``cust_Other. cust_id``、飞书 ``custom_attrs.github_id`` 三处一起变。 审批不走飞书审批实例,走站内:审批人是**随人而变的数据** (``hris_hrops_person_id``,缺失时回退 HRBP),不是一个固定的飞书 审批人 —— 与 ``EmailGroupJoinRequest`` 的 owner 审批同构。 ``approver_person_id`` 在提交时解析并存下来,不是每次现算:人员主 数据会变,而一张三个月前的单子必须还能答出「当时该谁批」。真正点了 按钮的人记在 ``decided_by_person_id`` —— 代批(HR / global_admin)是 允许的,两者因此不能合并成一列。 ``open_request_key`` 是只在 ``pending`` 时非 NULL 的生成列,UNIQUE 索引落在它上面 = 「一个人同时只能有一张在途单」,被驳回后可以重提。 """ __tablename__ = "github_id_change_requests" # 见 GithubOrgJoinRequest 的同款注释:不加的话 server_default 列在 # commit 之后第一次被序列化时才加载,那已经出了 greenlet 上下文。 __mapper_args__ = {"eager_defaults": True} id: Mapped[str] = mapped_column( String(36), primary_key=True, default=lambda: str(uuid.uuid4()), ) person_id: Mapped[str] = mapped_column( String(36), ForeignKey("identity_persons.id", ondelete="CASCADE"), nullable=False, ) #: 提交这一刻的旧值。NULL = 首次登记(154/417 在职员工属于这一类)。 old_github_id: Mapped[str | None] = mapped_column( String(const.GITHUB_LOGIN_MAX_LEN), ) new_github_id: Mapped[str] = mapped_column( String(const.GITHUB_LOGIN_MAX_LEN), nullable=False, ) reason: Mapped[str | None] = mapped_column(Text) status: Mapped[str] = mapped_column( String(16), nullable=False, default=const.HANDLE_CHANGE_STATUS_PENDING, server_default=const.HANDLE_CHANGE_STATUS_PENDING, ) approver_person_id: Mapped[str | None] = mapped_column( String(36), ForeignKey("identity_persons.id", ondelete="SET NULL"), ) decided_by_person_id: Mapped[str | None] = mapped_column( String(36), ForeignKey("identity_persons.id", ondelete="SET NULL"), ) decided_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) decision_note: Mapped[str | None] = mapped_column( String(const.HANDLE_CHANGE_NOTE_MAX_LEN), ) #: 提交时向 GitHub 求证「这个 handle 存不存在」的结果。三态,且三态 #: 不能塌成两态:``True`` 存在 / ``False`` GitHub 明确 404 / ``None`` #: 没问到。审批面把 ``None`` 显示成「未校验」而不是红字 —— 否则一次 #: GitHub 抖动就会换来一次冤枉的驳回。它是**咨询信息**,不参与任何闸。 github_exists: Mapped[bool | None] = mapped_column(Boolean) #: 回写 HRIS 的结果。``failed`` 需要有人去把 SF 手工改过来,``skipped`` #: 不需要 —— 两者必须分开,否则运维看到的是一堆分不清要不要处理的行。 hris_write_status: Mapped[str | None] = mapped_column(String(16)) hris_write_error: Mapped[str | None] = mapped_column(String(512)) #: 「SF 已经收敛」不记在这里 —— 记在 ``identity_persons. #: github_id_locked_at``(清空 = 已收敛)。hr 域不能 import 本域的模型 #: (跨域 import 是硬禁令),所以由 hr 同步写的那个事实只能落在人员行 #: 上;在这里再放一列,就等于放一列永远没人写的假信息。 open_request_key: Mapped[str | None] = mapped_column( String(40), Computed( "CASE WHEN status = 'pending' THEN person_id ELSE NULL END", ), nullable=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__ = ( Index("ix_github_id_change_requests_person", "person_id", "status"), Index( "ix_github_id_change_requests_approver", "approver_person_id", "status", ), Index("ix_github_id_change_requests_status", "status", "created_at"), UniqueConstraint( "open_request_key", name="uq_github_id_change_requests_open", ), ) class GithubHandleLabel(Base): """标签词表 —— 小的、管理员维护的一份受控词汇。 **为什么是词表而不是自由输入**:585 给了一个 255 字的 ``external_note``, 而自由文本必然腐烂 —— 三个人标同一家公司会写成 ``ByteDance`` / ``字节`` / ``bytedance 的人``,然后「把 ByteDance 的人筛出来做年度复核」 这个动作永远做不成。词表化是唯一能让筛选长期有效的做法。 ``key`` 是自然键(``employer:bytedance``),不用代理 id:标签在界面、 策略规则、审计详情里都是按 key 引用的,再加一层 uuid 只会让每处都要多 一次翻译。 停用(``is_active=False``)而不是删除:一个被用过的标签删掉之后,历史上 那些链接就成了悬空引用,而「这个人当年被标成什么」正是复核要回看的。 """ __tablename__ = "github_handle_labels" __mapper_args__ = {"eager_defaults": True} key: Mapped[str] = mapped_column(String(64), primary_key=True) #: ``account`` / ``employer`` / ``community`` / ``purpose``。 #: 只四类,而且每类回答一个不同的问题 —— 分类多了人会犹豫该放哪一类, #: 然后就不放了。 category: Mapped[str] = mapped_column(String(24), nullable=False) display_zh: Mapped[str] = mapped_column(String(64), nullable=False) display_en: Mapped[str] = mapped_column(String(64), nullable=False) color: Mapped[str] = mapped_column( String(16), nullable=False, server_default="default", ) description: Mapped[str | None] = mapped_column(Text) is_active: Mapped[bool] = mapped_column( Boolean, nullable=False, server_default=text("1"), default=True, ) sort_order: Mapped[int] = mapped_column( Integer, nullable=False, server_default="0", default=0, ) created_by: Mapped[str | None] = mapped_column( String(36), ForeignKey("identity_persons.id", ondelete="SET NULL"), ) 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__ = ( Index("ix_github_handle_labels_category", "category", "sort_order"), ) def __repr__(self) -> str: # pragma: no cover - 调试用 return f"" class GithubHandleLabelLink(Base): """一个 handle 挂了一个标签。 **主键是 (handle, label),而且 handle 不是 (org, handle)。** 这是 585 做错、这里改掉的地方:实测认不出的 217 个 handle 摊在 331 行台账上, ``sre-bot`` / ``ti-chi-bot`` / ``crazycs520`` 各在 5 个组织里。 「这个 handle 是什么」是 handle 的属性 —— ``crazycs520`` 如果是 ByteDance 的人,他在 5 个组织里都是。挂在成员资格上意味着标一次要写 5 行,而 5 行会漂移(有人只改了 2 行,界面上看不出来)。 主键含 label_key ⇒ 重复打同一个标签是**幂等**的,而不是攒出两行。 刻意不给 ``label_key`` 建 FK:词表用「停用」而不是删除,一个 FK 会把 「停用」和「能不能保留历史」绑在一起。悬空引用由服务层显示成 「已停用的标签」而不是让它消失。 """ __tablename__ = "github_handle_label_links" __mapper_args__ = {"eager_defaults": True} github_login_lower: Mapped[str] = mapped_column( String(64), primary_key=True, ) label_key: Mapped[str] = mapped_column(String(64), primary_key=True) #: 原样保留人当时看到的大小写用于显示;比对一律用 lower。 github_login: Mapped[str] = mapped_column(String(64), nullable=False) note: Mapped[str | None] = mapped_column(String(255)) created_by: Mapped[str | None] = mapped_column( String(36), ForeignKey("identity_persons.id", ondelete="SET NULL"), ) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), ) __table_args__ = ( Index("ix_github_handle_label_links_label", "label_key"), ) def __repr__(self) -> str: # pragma: no cover - 调试用 return f""