mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 06:02:01 +00:00
a9d7cba884
- 挂起:上一次运行在服务重启时被挂起(interrupted、没有待审批)不会自己接着执行,之后每次触发都按重叠跳过,原因写 「还没有结束」,一条通知也不推,任务就此静默停摆。interrupted 照规格仍算没结束,但跳过原因按挡路的运行分三种写 (仍在进行中、正在等待审批、已挂起并请在「记录」中继续运行或放弃),运行编号写成界面上的 #前 6 位,去掉「按设置」; 挂起的那次按 failed 事件推一次,写明原因和处理办法,不是每次触发都推。停在审批时每停一个审批点推一次(已处理状态 带上第几段执行,以前第二个审批点不再推);审批刚批完、还没接着执行(answered)不当挂起 - 没有结束时间的失败运行(服务重启时还在排队、被改成 failed)永远不会封存:等封存改按最后更新时间算,以前按「现在」 等,每一拍都等、永远不推 - 平台错误码按地址的主机认,不看所选格式:格式和地址对不上时,测试显示「已送达」、正式推送记成功,群里却收不到; 现在记失败并带上平台的错误码。群机器人的地址(飞书、企业微信、钉钉)保存时要配它平台的格式,否则 422;格式省略时 按地址认。只在地址或格式真改了时查,库里已有的旧设置不挡停用、改名 - 地址校验:端口不是 1 到 65535 的数字、夹着空格或不可见字符(零宽空格、控制字符)、httpx 建不了请求的(主机名不合 IDNA 规则)保存时就回 422 和中文说明;推送时另兜住 InvalidURL 和其他异常,测试接口不再回 500 或英文报错;端口写错 的地址也照样取出主机名 - PATCH 看值变没变,不看键在不在:整份表单回写不再重新校验没改的工作流(工作流删了也能停用),cron、时区、启用状态 真改了才重算下一次触发。启用着改 cron、时区时,从原来那个到点的时刻接着算,新表达式也覆盖那一刻就还发起这一次。 补两人同时保存的测试,证明条件 UPDATE 守住了乐观锁 - 发起中的占位:接口读到正在发起的触发按「已发起」给(run_id 为空),不再闪一次「未能发起:服务在发起运行前停止」; 库里仍先写占位,进程被强杀时照实说没有发起 - stop:先置停止标志再取任务,这一拍收尾不再新起通知扫描(以前会落下一个不在等待名单里的通知任务);循环和通知两件 事并行等,宽限缩到 3 秒和 1.5 秒,给关停时的 run_manager.shutdown 留出 docker stop 的大半时间 - 通知正文:立即运行写「手动触发」,补发标明「错过后补发」;失败不再写「原因:运行失败,详情请查看运行记录」,改写 失败节点的名字和处理办法;状态叫法和界面一致(等待审批,已挂起,可继续运行);「封存哈希」改叫「清单哈希」,运行 编号写 #前 6 位;测试消息只说收到它证明得了的事(企业微信、没配密钥时不提签名)。测试接口的 message 改成 ok、 http_status 之外的补充说明(送达时提醒去接收端确认,失败时是原因),前端接在「测试消息已送达(HTTP 200)」后面不再重复 - 422 文案:不回显 Python 的值(None 写「未填写」,列表不原样回显、点出不认识的那一项),格式、事件写中文名; copy-allow.json 为平台名「钉钉」加一条(和前端包为 terms.ts 加的那条并存,试合并不冲突) - test_loop_ticks_until_stopped:下一次放在整分上、只认发起的那一条,去掉约 1/60 的偶发失败 - 逐个做过变异,改回后对应测试都变红:挂起不推、跳过原因退回旧写法、没结束时间按现在等、错误码只看格式、去掉版本 条件、到点保留和值比较一起去掉、不屏蔽发起中的触发、stop 既不查停止标志也不兜底、answered 当挂起、审批状态不带段号、 不建请求校验地址、保存时不查格式
375 lines
19 KiB
Python
375 lines
19 KiB
Python
"""定时任务的业务规则:输入模板、和工作流入口字段的对照、发起一次触发。
|
||
|
||
调度循环(loop.py)到点调 fire_once,接口的「立即运行」也调它:重叠跳过、渲染输入、记触发、发起运行这一套
|
||
只有一份。发起本身走 engine/launch.py 的 launch_run,和 HTTP 发起是同一套规则——正式运行只能从当前发布版本
|
||
发起,这一条不会在定时任务这边被绕过。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import logging
|
||
import re
|
||
import weakref
|
||
from datetime import date, datetime, timedelta, timezone
|
||
from typing import Any
|
||
|
||
from sqlalchemy import func, select, update
|
||
from sqlalchemy.exc import IntegrityError
|
||
|
||
from app.db.base import SessionLocal, utcnow
|
||
from app.db.models import Approval, Run, Schedule, ScheduleFire
|
||
from app.engine.expressions import _FILTERS, render_template
|
||
from app.scheduler import cron
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
#: 变量清单的示例按这个时区算(GET /api/schedules/variables 没带时区时)
|
||
DEFAULT_TIMEZONE = "Asia/Shanghai"
|
||
RUN_CLASSES = ("formal", "exploratory")
|
||
#: 这几个状态的运行算「还没结束」,挡住这个任务之后的触发。interrupted 也算(规格如此):停在审批上的,批完会
|
||
#: 接着执行;服务重启挂起的却不会自己接着执行,要有人去继续或放弃——所以跳过的原因分开写(见 overlap_reason),
|
||
#: 挂起的另外按失败推一次通知(webhook._state_of),不然任务就此静默停摆
|
||
ACTIVE_RUN = ("queued", "running", "interrupted")
|
||
|
||
#: 模板里能用的变量:(变量名, 说明)。说明写在变量清单接口里,和示例一起给界面
|
||
VARIABLES: tuple[tuple[str, str], ...] = (
|
||
("today", "今天(YYYY-MM-DD)"),
|
||
("yesterday", "昨天(YYYY-MM-DD)"),
|
||
("this_week_start", "本周一(YYYY-MM-DD)"),
|
||
("last_week_start", "上周一(YYYY-MM-DD)"),
|
||
("last_week_end", "上周日(YYYY-MM-DD)"),
|
||
("this_month_start", "本月 1 日(YYYY-MM-DD)"),
|
||
("last_month_start", "上月 1 日(YYYY-MM-DD)"),
|
||
("last_month_end", "上月最后一天(YYYY-MM-DD)"),
|
||
("fire_time", "本次计划触发时间(带时区偏移的 ISO 时间)"),
|
||
("last_fire_time", "上一次计划触发时间(带时区偏移的 ISO 时间)"),
|
||
)
|
||
_NAMES = tuple(f"schedule.{key}" for key, _ in VARIABLES)
|
||
_TEMPLATE_RE = re.compile(r"\{\{(.*?)\}\}", re.DOTALL)
|
||
|
||
#: 触发记下来、运行还没发起时先写成这样:发起成功再改成 started。进程在这中间被强杀,留下的记录照实说没有发起。
|
||
#: 进程还在、只是还没发起完的,接口按「已发起」给(见 launching)
|
||
INTERRUPTED_REASON = "服务在发起运行前停止,这次触发没有发起运行"
|
||
#: launch_run 抛了 LaunchRefused 以外的异常(库出错之类)。详细信息只进日志
|
||
GENERIC_REASON = "发起运行时出错,详细信息见服务日志"
|
||
|
||
|
||
class TemplateError(ValueError):
|
||
"""输入模板写错了。str() 是给人看的中文说明,指出是哪个输入。"""
|
||
|
||
|
||
def current_value(value: Any) -> str:
|
||
"""报错末尾回显当前值的那半句:没填写说「当前未填写」,文本、数字照原样,true / false 按 JSON 的写法;列表、对象
|
||
不回显——给人看的说明里不出现 Python 的写法(None、['a', 'b'])。"""
|
||
if value is None or (isinstance(value, str) and not value.strip()):
|
||
return ",当前未填写"
|
||
if isinstance(value, bool):
|
||
return f",当前为「{'true' if value else 'false'}」"
|
||
if isinstance(value, (str, int, float)):
|
||
return f",当前为「{value}」"
|
||
return ""
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 模板变量
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
def template_values(fire_at: datetime, tz: str, cron_expr: str | cron.CronSpec) -> dict[str, str]:
|
||
"""按计划触发时间在任务时区算出的变量(键不带 schedule. 前缀)。日期写 YYYY-MM-DD,时间写带偏移的 ISO、只到秒。
|
||
|
||
按 fire_at 算而不是按「现在」:补发晚了一小时、立即运行在零点前后点下去,「昨天」都是那次触发的昨天。
|
||
last_fire_time 是按 cron 往前找的上一次计划触发,不看历史记录:同一个 (cron, 时区, fire_at) 永远渲染出同一份输入。
|
||
"""
|
||
zone = cron.zone(tz)
|
||
local = fire_at.astimezone(zone)
|
||
today = local.date()
|
||
week_start = today - timedelta(days=today.weekday())
|
||
month_start = today.replace(day=1)
|
||
last_month_end = month_start - timedelta(days=1)
|
||
previous = cron.prev_fire(cron_expr, zone, fire_at)
|
||
return {
|
||
"today": _day(today),
|
||
"yesterday": _day(today - timedelta(days=1)),
|
||
"this_week_start": _day(week_start),
|
||
"last_week_start": _day(week_start - timedelta(days=7)),
|
||
"last_week_end": _day(week_start - timedelta(days=1)),
|
||
"this_month_start": _day(month_start),
|
||
"last_month_start": _day(last_month_end.replace(day=1)),
|
||
"last_month_end": _day(last_month_end),
|
||
"fire_time": local.isoformat(timespec="seconds"),
|
||
"last_fire_time": previous.astimezone(zone).isoformat(timespec="seconds") if previous else "",
|
||
}
|
||
|
||
|
||
def _day(value: date) -> str:
|
||
return value.isoformat()
|
||
|
||
|
||
def render_inputs(template: dict[str, str], values: dict[str, str]) -> dict[str, str]:
|
||
"""渲染输入模板。用项目通用的 {{ }} 渲染(engine/expressions.render_template),变量挂在 schedule 下。"""
|
||
ctx = {"schedule": values}
|
||
return {name: render_template(text, ctx) for name, text in template.items()}
|
||
|
||
|
||
def check_template(template: Any) -> dict[str, str]:
|
||
"""校验输入模板,原样返回(None 当空模板)。写错抛 TemplateError。
|
||
|
||
项目的模板渲染取不到值时渲染成空串、不报错——画布上的半成品图要能运行。定时任务没人盯着,拼错的变量名
|
||
会让每一次运行都拿到空的日期,所以保存和预览时就要说出来。
|
||
"""
|
||
if template is None:
|
||
return {}
|
||
if not isinstance(template, dict):
|
||
raise TemplateError("输入模板需要是「字段名:模板」的对象,例如 stat_date 对应 {{ schedule.yesterday }}")
|
||
out: dict[str, str] = {}
|
||
for raw, text in template.items():
|
||
# 字段名去掉首尾空白再存:「 stat_date」交给运行,入口节点认不出它,必填项照样报缺
|
||
name = raw.strip() if isinstance(raw, str) else ""
|
||
if not name:
|
||
raise TemplateError("输入模板里有空的字段名,请填写工作流的输入字段名")
|
||
if name in out:
|
||
raise TemplateError(f"输入模板里字段「{name}」写了两次,请只保留一个")
|
||
if not isinstance(text, str):
|
||
raise TemplateError(f"输入「{name}」的值需要是文本(可以带 {{{{ schedule.yesterday }}}} 这样的变量)"
|
||
f"{current_value(text)}")
|
||
_check_text(name, text)
|
||
out[name] = text
|
||
return out
|
||
|
||
|
||
def _check_text(name: str, text: str) -> None:
|
||
for match in _TEMPLATE_RE.finditer(text):
|
||
expr = match.group(1).strip()
|
||
if not expr:
|
||
raise TemplateError(f"输入「{name}」的模板里有空的 {{{{ }}}},请在中间写变量名,例如 schedule.yesterday")
|
||
path, *filters = (part.strip() for part in expr.split("|"))
|
||
if path not in _NAMES:
|
||
raise TemplateError(f"输入「{name}」的模板里「{path}」不是可用的变量,可用的变量:{'、'.join(_NAMES)}")
|
||
for filt in filters:
|
||
if filt not in _FILTERS:
|
||
raise TemplateError(f"输入「{name}」的模板里「{filt}」不是可用的过滤器,可用的过滤器:{'、'.join(_FILTERS)}")
|
||
rest = _TEMPLATE_RE.sub("", text)
|
||
if "{{" in rest or "}}" in rest:
|
||
raise TemplateError(f"输入「{name}」的模板里 {{{{ 和 }}}} 没有成对出现,请检查是否少写了一半")
|
||
|
||
|
||
def variables(now: datetime | None = None, tz: str = DEFAULT_TIMEZONE) -> list[dict[str, str]]:
|
||
"""变量清单:[{name, label, example}]。示例按 now(默认现在)这一刻触发算;上一次计划触发时间没有 cron
|
||
可依,示例按每天同一时刻触发算。"""
|
||
moment = (now or utcnow()).replace(second=0, microsecond=0)
|
||
local = moment.astimezone(cron.zone(tz))
|
||
daily = f"{local.minute} {local.hour} * * *"
|
||
values = template_values(moment, tz, daily)
|
||
return [{"name": f"schedule.{key}", "label": label, "example": values[key]} for key, label in VARIABLES]
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 和工作流入口字段对照
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
def input_fields(graph: Any) -> list[dict[str, Any]]:
|
||
"""工作流入口节点声明的字段 [{name, required, default}],没声明(或图不成形)是空列表。"""
|
||
from app.engine.schema import GraphSpec, NodeType
|
||
|
||
try:
|
||
spec = GraphSpec.model_validate(graph)
|
||
except Exception: # noqa: BLE001 - 图不成形交给发起时报
|
||
return []
|
||
out: list[dict[str, Any]] = []
|
||
for node in spec.nodes:
|
||
if node.type != NodeType.INPUT:
|
||
continue
|
||
for field in node.config.get("fields") or []:
|
||
if isinstance(field, dict) and isinstance(field.get("name"), str) and field["name"].strip():
|
||
out.append({"name": field["name"].strip(), "required": bool(field.get("required")),
|
||
"default": field.get("default")})
|
||
return out
|
||
|
||
|
||
def check_fields(template: dict[str, str], fields: list[dict[str, Any]], workflow_name: str, *,
|
||
require: bool = True) -> None:
|
||
"""入口声明了字段时,模板里的字段名要在里面;require 时必填且没有默认值的字段要有模板。不对抛 TemplateError。
|
||
|
||
入口没声明字段的工作流,输入是开放的(运行时传什么键都收),不查。预览时用户还在填,只查多出来的名字。
|
||
"""
|
||
if not fields:
|
||
return
|
||
names = [f["name"] for f in fields]
|
||
unknown = [name for name in template if name not in names]
|
||
if unknown:
|
||
raise TemplateError(f"输入「{unknown[0]}」不是工作流「{workflow_name}」入口声明的字段,可用的字段:{'、'.join(names)}")
|
||
if not require:
|
||
return
|
||
missing = [f["name"] for f in fields
|
||
if f["required"] and f["default"] in (None, "") and f["name"] not in template]
|
||
if missing:
|
||
raise TemplateError(f"工作流「{workflow_name}」的必填输入「{'、'.join(missing)}」没有填写,"
|
||
"到点发起的运行会因缺少输入而失败")
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 发起一次触发
|
||
# --------------------------------------------------------------------------
|
||
|
||
#: 每个事件循环一把锁:发起(查重叠 → 记触发 → 发起运行 → 改记录)和通知扫描查库互斥。单进程部署,进程内
|
||
#: 互斥就够;按事件循环分开是因为测试每个用例一个新循环,跨循环用同一把 asyncio.Lock 会报错
|
||
_LOCKS: "weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, asyncio.Lock]" = weakref.WeakKeyDictionary()
|
||
|
||
|
||
def launch_lock() -> asyncio.Lock:
|
||
loop = asyncio.get_running_loop()
|
||
lock = _LOCKS.get(loop)
|
||
if lock is None:
|
||
lock = _LOCKS[loop] = asyncio.Lock()
|
||
return lock
|
||
|
||
|
||
#: 正在发起的触发:占位记录已写进库、launch_run 还没返回。通知扫描有 launch_lock 挡着,接口的读(任务列表的
|
||
#: last_fire、触发历史)没有:它们据此按「已发起」给,不把还在发起的说成「未能发起」(单进程部署,进程内的集合就够)
|
||
_LAUNCHING: set[str] = set()
|
||
|
||
|
||
def launching(fire_id: str) -> bool:
|
||
return fire_id in _LAUNCHING
|
||
|
||
|
||
def actor_label(name: str) -> str:
|
||
"""定时任务发起的运行记在谁名下。"""
|
||
return f"定时任务:{name}"[:100]
|
||
|
||
|
||
def next_run(cron_expr: str, tz: str, enabled: bool, now: datetime | None = None) -> datetime | None:
|
||
"""下一次触发的时刻(UTC);停用的任务没有下一次。"""
|
||
if not enabled:
|
||
return None
|
||
return cron.next_fire(cron_expr, tz, now or utcnow())
|
||
|
||
|
||
async def fire_once(schedule_id: str, fire_at: datetime, kind: str, *, actor: str | None = None,
|
||
) -> ScheduleFire | None:
|
||
"""处理一次触发,返回记下的触发记录。同一 (任务, fire_at, kind) 已经有记录(两拍撞在一起、立即运行连点两下)
|
||
时不再发起,返回已有的那一条;任务不存在返回 None。
|
||
|
||
上一次运行还没结束就记 skipped_overlap;输入渲染不了、launch_run 拒绝或出错记 failed_to_start(拒绝写原话,
|
||
其他异常写通用说明、详细信息进日志);发起成功记 started 和运行 id。
|
||
"""
|
||
async with launch_lock():
|
||
async with SessionLocal() as session:
|
||
schedule = await session.get(Schedule, schedule_id)
|
||
if schedule is None:
|
||
return None
|
||
name, workflow_id, run_class = schedule.name, schedule.workflow_id, schedule.run_class
|
||
tz, cron_expr, template = schedule.timezone, schedule.cron, schedule.input_template
|
||
try:
|
||
inputs = render_inputs(check_template(template), template_values(fire_at, tz, cron_expr))
|
||
except (TemplateError, cron.CronError, cron.TimezoneError) as e:
|
||
# 存进库之后才坏掉的(手改过库、时区库升级删了时区):照实说是哪一项,不发起
|
||
return await _record(schedule_id, fire_at, kind, "failed_to_start", f"无法生成这次运行的输入:{e}", {})
|
||
busy = await _active_run(schedule_id)
|
||
if busy is not None:
|
||
return await _record(schedule_id, fire_at, kind, "skipped_overlap", overlap_reason(*busy), inputs)
|
||
fire = await _record(schedule_id, fire_at, kind, "failed_to_start", INTERRUPTED_REASON, inputs, fresh=True)
|
||
if fire is None:
|
||
return await _existing(schedule_id, fire_at, kind)
|
||
from app.engine.launch import LaunchRefused, launch_run
|
||
|
||
_LAUNCHING.add(fire.id)
|
||
try:
|
||
try:
|
||
async with SessionLocal() as session:
|
||
run = await launch_run(session, workflow_id=workflow_id, run_class=run_class,
|
||
input_payload=inputs, actor=actor or actor_label(name),
|
||
schedule_id=schedule_id)
|
||
except LaunchRefused as e:
|
||
return await _finish(fire.id, status="failed_to_start", reason=e.message)
|
||
except Exception: # noqa: BLE001 - 发起失败不能让调度循环停下;详细信息进日志,记录上写通用说明
|
||
logger.exception("定时任务「%s」(%s)发起运行失败", name, schedule_id)
|
||
return await _finish(fire.id, status="failed_to_start", reason=GENERIC_REASON)
|
||
return await _finish(fire.id, status="started", reason=None, run_id=run.id)
|
||
finally:
|
||
_LAUNCHING.discard(fire.id)
|
||
|
||
|
||
async def record_skip(schedule_id: str, fire_at: datetime, reason: str) -> ScheduleFire | None:
|
||
"""记一次错过、没有补发的触发(kind=misfire,status=skipped_misfire)。"""
|
||
return await _record(schedule_id, fire_at, "misfire", "skipped_misfire", reason, {})
|
||
|
||
|
||
async def _active_run(schedule_id: str) -> tuple[str, str, bool] | None:
|
||
"""这个任务发起的、还没结束的运行(最近的那一个):(运行 id, 状态, 是否停在审批),没有返回 None。
|
||
|
||
停在审批:有 pending 的审批,或者刚批完(answered)、还没接着执行。interrupted 而两样都没有的是挂起的。"""
|
||
async with SessionLocal() as session:
|
||
row = (await session.execute(
|
||
select(Run.id, Run.status).where(Run.schedule_id == schedule_id, Run.status.in_(ACTIVE_RUN))
|
||
.order_by(Run.created_at.desc()).limit(1))).first()
|
||
if row is None:
|
||
return None
|
||
run_id, status = row
|
||
waiting = status == "interrupted" and bool(await session.scalar(
|
||
select(func.count()).select_from(Approval).where(
|
||
Approval.run_id == run_id, Approval.status.in_(("pending", "answered")))))
|
||
return run_id, status, waiting
|
||
|
||
|
||
def overlap_reason(run_id: str, status: str, waiting: bool) -> str:
|
||
"""跳过的原因按挡路的那次运行说清楚该等还是该处理。运行编号和界面上的短编号一样写(#前 6 位)。"""
|
||
tag = f"#{run_id[:6]}"
|
||
if status != "interrupted":
|
||
return f"上一次运行 {tag} 仍在进行中,本次触发已跳过"
|
||
if waiting:
|
||
return f"上一次运行 {tag} 正在等待审批,处理审批、运行结束后才会按时发起新的运行;本次触发已跳过"
|
||
return (f"上一次运行 {tag} 已挂起(通常是服务重启所致),请在「记录」中继续运行或放弃这次运行,"
|
||
"它结束后才会按时发起新的运行;本次触发已跳过")
|
||
|
||
|
||
async def _record(schedule_id: str, fire_at: datetime, kind: str, status: str, reason: str | None,
|
||
inputs: dict[str, Any], *, fresh: bool = False) -> ScheduleFire | None:
|
||
"""插入一条触发记录。撞上唯一约束(同一次触发已经记过)或任务已被删除(外键)时:fresh 返回 None,否则返回
|
||
已有的那一条(没有就是 None)。"""
|
||
fire = ScheduleFire(schedule_id=schedule_id, fire_at=fire_at, kind=kind, status=status, reason=reason,
|
||
inputs=inputs)
|
||
async with SessionLocal() as session:
|
||
session.add(fire)
|
||
try:
|
||
await session.commit()
|
||
except IntegrityError:
|
||
await session.rollback()
|
||
return None if fresh else await _existing(schedule_id, fire_at, kind)
|
||
await session.refresh(fire)
|
||
return fire
|
||
|
||
|
||
async def _existing(schedule_id: str, fire_at: datetime, kind: str) -> ScheduleFire | None:
|
||
async with SessionLocal() as session:
|
||
return await session.scalar(select(ScheduleFire).where(
|
||
ScheduleFire.schedule_id == schedule_id, ScheduleFire.fire_at == fire_at, ScheduleFire.kind == kind))
|
||
|
||
|
||
async def _finish(fire_id: str, *, status: str, reason: str | None, run_id: str | None = None) -> ScheduleFire | None:
|
||
async with SessionLocal() as session:
|
||
await session.execute(update(ScheduleFire).where(ScheduleFire.id == fire_id)
|
||
.values(status=status, reason=reason, run_id=run_id))
|
||
await session.commit()
|
||
return await session.get(ScheduleFire, fire_id)
|
||
|
||
|
||
def local_text(moment: datetime, tz: str) -> str:
|
||
"""给人看的本地时间「2026-10-05 09:00」,写进跳过原因、通知里。"""
|
||
return moment.astimezone(cron.zone(tz)).strftime("%Y-%m-%d %H:%M")
|
||
|
||
|
||
def duration_text(seconds: int) -> str:
|
||
"""补发宽限这类秒数的说法:3600 →「1 小时」,600 →「10 分钟」,90 →「90 秒」。"""
|
||
if seconds and seconds % 3600 == 0:
|
||
return f"{seconds // 3600} 小时"
|
||
if seconds and seconds % 60 == 0:
|
||
return f"{seconds // 60} 分钟"
|
||
return f"{seconds} 秒"
|
||
|
||
|
||
def as_utc(value: datetime) -> datetime:
|
||
return value.astimezone(timezone.utc) if value.tzinfo else value.replace(tzinfo=timezone.utc)
|