"""工单群的收尾:一行 cron + 两个保留策略设置 ## 为什么需要它 群是**不可撤销地**建出来的,而且会一直留在每个成员的会话列表里。工单结了之后 那个房间就只是噪音 —— 但没有人会想起来去删它。第一批工单群上线一周后,负责人的 飞书里就会有一排「IT 求助 · …」,其中大多数早就结束了。 ## 两个设置,两种温度 * `assistant.ticket_chat_retention_days`(默认 **7**)—— 工单结了之后保留几天。 0 = 永不自动解散(仍然可以手动解散)。 * `assistant.ticket_chat_idle_nudge_days`(默认 **14**)—— 群里多久没动静就问 一句。0 = 不问。 刻意**不自动关单**:自动关掉一张还开着的工单,会让「已关闭」这个状态不再代表 任何东西。所以第二条只是在群里问一句,并给出一条出路(「解决了 …」)。 ## cron 行**不带 config** 2026-09-08 刚踩过反过来的那一半:播种进 `scheduled_jobs.config` 的一把钥匙赢过 了平台设置,于是设置页上的改动被静默无视一周。这一行因此故意留空 config —— 平台设置就是唯一的来源。 """ from __future__ import annotations import json import uuid import sqlalchemy as sa from alembic import op revision = "634" down_revision = "633" branch_labels = None depends_on = None JOB_NAME = "assistant.ticket_chat_sweep" SETTINGS = "platform_settings" _SEED_SETTINGS = [ ( "assistant.ticket_chat_retention_days", 7, "工单关闭/撤回之后,它的飞书群保留几天再自动解散(0 = 永不自动解散)。" "解散前会确保工单上已经有一段过程记录 —— 群的聊天记录会随解散一起消失。", ), ( "assistant.ticket_chat_idle_nudge_days", 14, "工单群里多久没动静就在群里问一句「这件事还要不要」(0 = 不问)。" "刻意不自动关单:自动关掉一张还开着的工单会让「已关闭」失去意义。", ), ] _DESC = ( "每天收尾一次工单群:结了的按保留期解散(解散前保证工单上有过程记录)," "还开着但长期没动静的群里问一句。单个房间失败不算这次失败(下一次重试)," "全部失败才抛 —— 那说明权限或凭据坏了。" ) def _has_table(bind, name: str) -> bool: return bind.execute( sa.text( "SELECT 1 FROM information_schema.tables " "WHERE table_schema = DATABASE() AND table_name = :n", ), {"n": name}, ).scalar() is not None def upgrade() -> None: bind = op.get_bind() if _has_table(bind, SETTINGS): for key, value, description in _SEED_SETTINGS: # 幂等且**不覆盖**:管理员可能已经把保留期改过了,而一次发布把它 # 改回默认值是这类种子最典型也最难发现的伤害。 exists = bind.execute( sa.text(f"SELECT 1 FROM {SETTINGS} WHERE `key` = :k"), {"k": key}, ).first() if exists: continue bind.execute( sa.text( f"INSERT INTO {SETTINGS} (`key`, `value`, `description`) " "VALUES (:k, :v, :d)", ), {"k": key, "v": json.dumps(value), "d": description}, ) if not _has_table(bind, "scheduled_jobs"): return exists = bind.execute( sa.text("SELECT 1 FROM scheduled_jobs WHERE name = :name LIMIT 1"), {"name": JOB_NAME}, ).scalar() if exists: return bind.execute( sa.text( """ INSERT INTO scheduled_jobs (id, name, display_name, description, handler_key, trigger_type, interval_seconds, cron_expr, timezone, enabled, config, run_count, failure_count, created_at, updated_at) VALUES (:id, :name, :display_name, :description, :name, 'cron', NULL, '20 3 * * *', 'Asia/Shanghai', 1, :config, 0, 0, UTC_TIMESTAMP(), UTC_TIMESTAMP()) """, ), { # 必须是带横线的 uuid4:32 位无横线的 id 会让 GUID TypeDecorator # 永远匹配不上,handler 在 run 包装器外面就抛 —— scheduled_job_runs # 一行都不写,run_count / last_run_at 永远是 0 / NULL。本仓踩过四次。 "id": str(uuid.uuid4()), "name": JOB_NAME, "display_name": "助手 · 工单群收尾", "description": _DESC, # **刻意为空**:两个阈值只从平台设置读。见模块头那段关于 # 「播种进 config 的钥匙赢过平台设置」的事故。 "config": json.dumps({}), }, ) def downgrade() -> None: bind = op.get_bind() if _has_table(bind, "scheduled_jobs"): bind.execute( sa.text("DELETE FROM scheduled_jobs WHERE name = :name"), {"name": JOB_NAME}, ) if _has_table(bind, SETTINGS): for key, _value, _description in _SEED_SETTINGS: bind.execute( sa.text(f"DELETE FROM {SETTINGS} WHERE `key` = :k"), {"k": key}, )