"""登记「申请加入 GitHub 组织」这条飞书审批流,并接受两个方向的来源;外加邀请对账 cron。 Helix 从此可以往这条**已经存在**的审批定义(`AAF99056-F989-4D8C-93E4- 321CB602CBA6`)发单,同时继续接住员工直接在飞书上提交的那些单子。 **为什么复用现有定义而不新建一个。** 这条流上的 org owner 审批节点是 按所选组织路由的 —— 那是它唯一真正有价值的部分,而重建必然要重配审批人 分派。代价是我们得迁就一张不是自己设计的表单(包括那个「lark profile 里 是否已设置 GitHub ID」的必填单选,Helix 提交时恒填「已设置」:走 Helix 入口意味着 handle 就在这张单子上,那个自查问题对这条路径没有意义)。 **为什么同时开 `accept_external_origin`。** 飞书那边的表单入口不会在 上线当天就消失,而且已经有在途的单子。两个来源共用同一个执行分支: Helix 发起的单子有本地申请单行,飞书直接提交的没有 —— 后者由 `_synthesize_external_origin_request` 合成父行,执行时按表单里的 GitHub ID + 组织现场解析。少了这一半,过渡期里从飞书提交的人会静默 地拿不到自动化。 **widget id 存进 `payload_schema`,不写进代码。** 那几个 `widget16663393539990001` 是飞书控件的内部标识:发单时必须原样回填, 读回调时又要按它取值。写进常量就意味着「飞书那边动了一下表单」要改代码 再发一次版;存进数据行,它是一次编辑。取值全部取自 2026-08-29 对生产 审批定义的只读读取。 单选控件的选项值(`l9i7ho50-fynujzxma69-0` 这种)**不在这里**—— 组织的那一份在 `github_orgs.feishu_option_value`(迁移 566 已播种), 社区成员是/否这一份在下面的 `choice_values` 里,因为它不属于任何一个 组织。 Revision ID: 569 Revises: 568 """ from __future__ import annotations import json as _json import uuid import sqlalchemy as sa from alembic import op revision = "569" down_revision = "568" branch_labels = None depends_on = None FLOW_KEY = "github_org_join_request" RECONCILE_JOB_KEY = "github_org.invitation_reconcile" FLOW_LABEL = "申请加入 GitHub 组织" EXTERNAL_CODE = "AAF99056-F989-4D8C-93E4-321CB602CBA6" #: 规范字段名 → 飞书控件。值取自生产审批定义(2026-08-29 只读读取)。 #: #: ``lark_profile_ack`` 是那个「GitHub ID attribute is set in the lark #: profile」的必填单选。Helix 提交时恒填「已设置」——走 Helix 入口意味着 #: handle 就在这张单子上,那个自查问题对这条路径没有意义;但控件必填, #: 不填飞书会拒。 #: #: ``notice`` 是一个只读说明控件(type=text),飞书把它标成 required。 #: 它有一个固定的 ``value``,发单时必须原样回填。 _PAYLOAD_SCHEMA: dict = { "github_login": { "id": "widget16663393539990001", "type": "input", }, "lark_profile_ack": { "id": "widget17586944787090001", "type": "radioV2", # 「Yes, it has been set」 "value": "$i18n-mfxkttdz-ghzpvb3fu4h-9", }, "notice": { "id": "widget17586945457820001", "type": "text", "value": ( 'Have you completed setting the "GitHub ID" attribute in your ' "lark profile? If not, please contact your HRBP for guidance to " "complete it." ), }, "org": { # 选项值按组织存在 github_orgs.feishu_option_value 上。 "id": "widget16663393860820001", "type": "radioV2", }, "is_community_member": { "id": "widget16663394599560001", "type": "radioV2", "choice_values": { "yes": "l9i7ho50-fynujzxma69-0", "no": "l9i7gwlb-m85dygpcxcr-1", }, }, "community_team": { # 只在「是社区成员」时显示。选项值是 team 名到内部标识的映射, # 本期不由 Helix 发起(自助入口不提供社区 team 选择),所以这里 # 只登记控件本身,让回调侧能把员工在飞书上选的 team 读回来。 "id": "widget16663394906130001", "type": "radioV2", }, "reason": { "id": "widget16663395111310001", "type": "textarea", }, } def _flow_row(bind, integration_id): return bind.execute( sa.text( "SELECT id, accept_external_origin FROM integration_approval_flows " "WHERE integration_id = :int_id AND flow_key = :flow_key" ), {"int_id": integration_id, "flow_key": FLOW_KEY}, ).fetchone() def upgrade() -> None: bind = op.get_bind() # cron 先播,**与飞书集成是否已配置无关**。它对账的是 GitHub 侧的 # 邀请状态,一行飞书审批流映射有没有建好跟它没有半点关系。 # # 这一行原本写在函数末尾,于是「没有飞书集成 → 提前 return」把它 # 一起跳过了 —— 全新环境(以及上线初期还没配飞书的那段时间)会 # 永远没有这个 cron,而台账里的 pending_invite 会永远停在那里。 # 本地全新库跑一遍就抓到了。 _seed_reconcile_cron(bind) row = bind.execute( sa.text( "SELECT id FROM integrations " "WHERE kind = 'chat_approval' AND provider = 'feishu' " " AND enabled = 1 " "ORDER BY is_primary DESC, created_at ASC LIMIT 1" ) ).fetchone() if not row: # 全新本地库还没配飞书集成 —— 管理员之后配好时,这一行由 # 「新增审批流」的管理面补上。迁移不该因此失败。 print("[569] no enabled Feishu integration — flow seed skipped") return integration_id = row[0] schema_json = _json.dumps(_PAYLOAD_SCHEMA, ensure_ascii=False, sort_keys=True) existing = _flow_row(bind, integration_id) if existing: # 已经有行(手工建过 / 重跑)。**只补 payload_schema 与 # external_origin**,不碰 ``active``:那是运维可能在事故后 # 刻意关掉的开关,一次迁移重跑不该把它悄悄打开。 bind.execute( sa.text( "UPDATE integration_approval_flows " "SET payload_schema = :schema, external_code = :code " "WHERE id = :id" ), {"id": existing[0], "schema": schema_json, "code": EXTERNAL_CODE}, ) bind.execute( sa.text( "UPDATE integration_approval_flows " "SET accept_external_origin = 1 " "WHERE id = :id " " AND (accept_external_origin IS NULL " " OR accept_external_origin = 0)" ), {"id": existing[0]}, ) return bind.execute( sa.text( "INSERT INTO integration_approval_flows " "(id, integration_id, flow_key, label, external_code, " " payload_schema, active, accept_external_origin) " "VALUES (:id, :int_id, :flow_key, :label, :code, :schema, 1, 1)" ), { "id": str(uuid.uuid4()), "int_id": integration_id, "flow_key": FLOW_KEY, "label": FLOW_LABEL, "code": EXTERNAL_CODE, "schema": schema_json, }, ) _RECONCILE_DESC = ( "把台账里「已邀请」的记录推进到真实状态:对方接受了就翻 active," "超过 7 天没接受记 expired 并自动重发一次(并通知本人)," "没到期却已不在待接受列表里记 declined(明确拒绝,不重发)。\n" "为什么需要它:GitHub 的组织邀请 7 天过期,而它**不发 webhook、" "过期时也不留痕** —— 没有这次对账,一张过期的邀请在 Helix 里会永远" "显示成「已邀请」,而本人早就错过了,还没有任何地方提示他。" ) def _seed_reconcile_cron(bind) -> None: if not bind.execute( sa.text( "SELECT 1 FROM information_schema.tables " "WHERE table_schema = DATABASE() AND table_name = 'scheduled_jobs'" ) ).scalar(): return bind.execute( sa.text( "INSERT INTO scheduled_jobs " "(id, name, display_name, description, handler_key, " " trigger_type, cron_expr, timezone, enabled, config, " " run_count, failure_count, created_at, updated_at) " "SELECT :id, :name, :display, :desc, :handler, 'cron', " ":cron, 'Asia/Shanghai', 1, :config, 0, 0, NOW(), NOW() " "FROM DUAL WHERE NOT EXISTS " "(SELECT 1 FROM scheduled_jobs WHERE handler_key = :handler)", ), { "id": str(uuid.uuid4()), "name": RECONCILE_JOB_KEY, "display": "GitHub 组织邀请对账", "desc": _RECONCILE_DESC, "handler": RECONCILE_JOB_KEY, # 每 4 小时。邀请的时间尺度是「7 天」,所以按小时对账已经 # 远快于需要;更密只是在给 GitHub 的 API 配额做无用功,而 # 每次对账都要拉每个组织的成员全量。 "cron": "17 */4 * * *", "config": "{}", }, ) def downgrade() -> None: bind = op.get_bind() # cron 行先删:留一个 handler 已经不存在的启用行,会让 worker 每次 # 调度都报 unknown handler。 if bind.execute( sa.text( "SELECT 1 FROM information_schema.tables " "WHERE table_schema = DATABASE() AND table_name = 'scheduled_jobs'" ) ).scalar(): bind.execute( sa.text("DELETE FROM scheduled_jobs WHERE handler_key = :h"), {"h": RECONCILE_JOB_KEY}, ) bind.execute( sa.text( "DELETE FROM integration_approval_flows WHERE flow_key = :flow_key" ), {"flow_key": FLOW_KEY}, )