Files
Cyrene_v2/internal/runtime/memory/store.go
T
AskaEth 44fbd29836 feat(memory): sensitive 标记 + 提取器敏感判据,私事退出受限车道召回
M63(M60 审计 P0-1/P0-2)。作用域 ≠ 敏感度:`scope='user'` 是共享域,里
面却装着住址(花溪 64 条 active)/私称(叶酱 63)/作息(早睡 38),于是受限车
道(群聊/陌生人)把共享域整锅注入上下文 —— 2026-10-29 群 531082168 的住址泄漏
即此路径。本片给 memory_items 加 `sensitive` 列,把「这条归谁」(scope)与
「这条能不能外说」(sensitive)拆成两根正交的轴。

- 迁移 0028:加列(INTEGER NOT NULL DEFAULT 0)+ **同迁移幂等回填**(精确短语
  白名单 花溪/叶酱/早睡,`AND sensitive=0`,不改 updated_at),down 只撤结构;
  memory 包内 `ensureSensitiveSchema` 同形前滚(任意 *sql.DB 自举)。
- 受限判据:新增 `MemoryItem.SharedVisible()` = 共享域 且 !sensitive,成为
  restricted 侧唯一起点;`loop.FilterSharedVisible` 与复用它的 knowledge 工具
  自动跟随。主人车道不过这道判据,照常读到全部记忆(sensitive 不是权限系统)。
- 提取器:prompt 追加 sensitive 位(住址/私人称呼/家人/健康/作息与行程 → true,
  口味/兴趣/项目约定 → false,含正反例);产出侧与落库侧共用
  `resolveSensitive`(模型的类目判断 ∨ 已实证短语白名单),只加标不撤标。
- 回填入口 `BackfillSensitive(ctx, db, dryRun)`:幂等、可 dry-run、返回候选池
  前后规模与逐短语计数(生产快照实测 3116 → 2948,标 725 行)。
- 读路径并到 15 列(scanItem + 7 处 SELECT);store.go 拆分 store_insert.go
  守住 500 行上限。

验收:go build/vet/test 全绿(memory + loop 单包复跑绿);删实现三处变异分别打红
(sensitiveColumn 恒 0 / SharedVisible 忽略 Sensitive / Extractor 跳过白名单);
生产库快照上跑 0028 后 Go 侧回填 marked=0(迁移 SQL 与 privateFacts 等价)。
2026-10-30 09:26:15 +08:00

488 lines
22 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// memory 包实现 Cyrene v2 记忆子系统,规格见
// DevDocs/design/03-记忆.md。
//
// 它对外暴露三层:
//
// - Store:基于 SQLite 的长期记忆存储。它按角色隔离持久化记忆条目
// (design §0 layer ②、§4),经 AddScoped/ListScope/ListProject/
// DeleteScoped 施加三作用域访问规则(design §3),并提供
// Add/Get/ListByPersona/Delete,外加一条手动的
// 「记住」路径(design §1)。每条条目可携带 project_id,链到
// 项目索引层级。
// - WorkingMemory:每会话的消息滑动窗口加一份摘要
// (design §0 layer ①)。
// - Retriever:相关性(关键词 + 可选语义)× 衰减检索,带
// 溯源与上下文注入(design §2)。它可选接收
// vector.Index 作为持久化语义路径,以及 projectindex.Store,使
// 项目作用域的命中带出其链接的项目条目。
//
// 提取(design §1)经 Extractor 接口暴露,有两个
// 实现:ManualExtractor(规则式,记录一条显式的「记住 X」
// 指令)与 LLMExtractor(驱动 model.Adapter 对
// 一轮对话去噪去重,经 Extract 同步运行或经 ExtractAsync 异步运行)。
//
// M4 里程碑在此实现:schema 携带 decay_weight 与
// project_id,检索器接入可选的 embedding.Embedder / vector.Index
// 与项目索引存储,存储层施加 user/persona/project
// 作用域规则。除既有包外不需要任何外部依赖。
package memory
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"strings"
"sync"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory/projectindex"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory/vector"
)
// Scope 分类一条长期记忆归属于谁。它与 Kind 正交,
// 两者都存于每条条目上(design §3 注)。
type Scope string
const (
// ScopeUser 是关于用户习惯的记忆,跨角色共享。
ScopeUser Scope = "user"
// ScopePersona 是角色的日常记忆;其他角色只读。
ScopePersona Scope = "persona"
// ScopeProject 是项目作用域记忆,人人可读可写。
ScopeProject Scope = "project"
)
// valid 报告 s 是否为已定义的作用域之一。
func (s Scope) valid() bool {
switch s {
case ScopeUser, ScopePersona, ScopeProject:
return true
default:
return false
}
}
// Kind 分类一条长期记忆条目的内容类别。
type Kind string
const (
// KindRelationship 是关于「你和我」的记忆:偏好、习惯、
// 约定(design §0「关系记忆」)。
KindRelationship Kind = "relationship"
// KindWorldEvent 是关于客观事实与世界事件的记忆
// (design §0「世界/事件记忆」)。
KindWorldEvent Kind = "world_event"
)
// valid 报告 k 是否为已定义的 kind 之一。
func (k Kind) valid() bool {
switch k {
case KindRelationship, KindWorldEvent:
return true
default:
return false
}
}
// MemoryItem 是一条长期记忆记录。零值不是合法
// 条目:PersonaID 与 Content 必填,Scope 与 Kind 由 Add 规范化到
// 其默认值。
type MemoryItem struct {
ID string
PersonaID string
RoleID string
ProjectID string
Scope Scope
Kind Kind
Content string
Tags []string
Confidence float64
SourceConversation string
CreatedAt time.Time
UpdatedAt time.Time
DecayWeight float64
// ExpiresAt 是**临时记忆**的有效期末(design/03 §9.6、design/23 §18.1)。
// 零值 = 不设过期,落库为空串,四条列表读路径恒可见;非零值落库为定宽 UTC
// 文本,到期后该行即从读路径消失,直到后台清扫物理删除(design/23 §19)。
//
// 它与 RetentionTier 是**同一根轴的两种表达**(会到期 ⇔ temp),因此两者由
// `Store.Add` 里的同一个判定与同一处换算落库,绝不会分叉:显式给非零值而
// 档位不是 temp 会被拒(ErrRetentionConflict),给 temp 而不给则按临时 TTL 算。
//
// ⚠️ **读回语义是「半开」的**(别把这一条读成「完全只写不读」):
//
// - `Add` 会把它写进 `expires_at` 列,**并把最终落库的时刻回填到返回的条目上**
// (调用方因此能立刻看到「这条被判成临时、有效期到哪」);
// - 但 `Get` / `List*` 一律不 SELECT 该列(读侧闸是纯 SQL,见 timegate.go),
// 故**从库里读回来的**条目 ExpiresAt 恒为零值。
//
// 要读回时效需同步 15 列 SELECT 与 scanItem,并连带 tidy/preinsert 的内部召回
// SQL —— 那是改扫描口径,不在本切片授权范围。
ExpiresAt time.Time
// RetentionTier 是「留多久 / 干什么用」这根轴(迁移 0026、retention.go)。
// 它与 Kind(关于什么)、Scope(谁的)**正交,互不推导**。
//
// 写入侧语义:空串 = 由 `Store.Add` 的单一判定填(见 resolveRetention);
// 非空 = 调用方的显式断言,判据不许推翻它。读回来时:
// **空串 = 尚未分型**(0026 之前的历史行 / 受限来源新行),绝不是某一档。
RetentionTier RetentionTier
// Temporal 是**提取器给出的时间性元数据位**(迁移 0027、temporal.go):
// one_off / recurring / durable / unknown,空串 = 未分型(历史行与没做时间性
// 判断的写入路径,口径见 temporal.go 头注)。
//
// 🔴 **它目前只观测、不参与判定**(r1 复审裁决 D):`resolveRetention` 不读它,
// 判定的结论集与「没有这一位」逐字相同。保留它是为了积累「模型到底怎么标」的
// 证据 —— 接线前必须先看那份分布(temporal.go 头注里给了查询)。
//
// ⚠️ **与 ExpiresAt 同样是「只写不读」的列**:`Add` 把它写进 `temporal` 列,
// 但四条列表读路径的 SELECT 与 scanItem 都不读它 —— 改扫描口径要动全部读点
// (rules.go / store_list.go / tidy_store.go / preinsert_store.go,现为 15 列,
// 加它就是 16 列),那是另一件事,不在本片授权范围。要按时间性位巡检/回填时
// 直读该列即可;写入侧的校验/编码见 store_temporal.go。
Temporal TemporalKind
// Sensitive 是**敏感标记**(迁移 0028、sensitive.go):true = 这条记忆是主人的
// 私事(住址 / 私称 / 作息 ……),因此**受限车道**(群聊 / 陌生人)的召回过滤器
// 会把整条排除在候选池外。
//
// 🔴 它是**数据层标记,不是权限系统**:主人车道(非受限环境)照常读到全部记忆
// (含 sensitive=1),没有任何读路径因它拒绝主人;它只影响
// `MemoryItem.SharedVisible` 这一条判据。谁是主人仍只由 actor 元数据 + 白名单
// + 策略引擎决定(AGENTS §9)。判据与理由见 sensitive.go 头注。
Sensitive bool
}
// 区分 memory 包各类失败的哨兵错误。每个都在
// 调用点用 %w 包裹,调用方可用 errors.Is 判断。
var (
// ErrMissingPersona 在条目没有 PersonaID 时返回。
ErrMissingPersona = errors.New("memory: persona_id is required")
// ErrMissingContent 在条目没有内容时返回。
ErrMissingContent = errors.New("memory: content is required")
// ErrInvalidScope 在条目的 Scope 不是已定义作用域时返回。
ErrInvalidScope = errors.New("memory: invalid scope")
// ErrInvalidKind 在条目的 Kind 不是已定义 kind 时返回。
ErrInvalidKind = errors.New("memory: invalid kind")
// ErrInvalidRetentionTier 在条目的 RetentionTier(或列表读路径的档位过滤)
// 不是闭集内的值时返回。空串**不在**此列:它是「尚未分型」这一合法状态。
ErrInvalidRetentionTier = errors.New("memory: invalid retention tier")
// ErrRetentionConflict 在条目同时给出「非 temp 的档位」与「非零的 expires_at」
// 时返回 —— 那是自相矛盾的输入(一行标着长期却会到期消失),既不猜也不静默
// 丢弃其中一半。
ErrRetentionConflict = errors.New("memory: retention tier conflicts with expires_at")
// ErrNotFound 在条目 id 不存在时返回。
ErrNotFound = errors.New("memory: item not found")
// ErrNilDB 在 NewStore 收到 nil *sql.DB 时返回。
ErrNilDB = errors.New("memory: nil db")
)
// schema 默认值与时间窗口常量。
const (
defaultConfidence = 1.0
defaultDecayWeight = 1.0
)
// Store 是基于 *sql.DB 的长期记忆存储。
//
// 存储自己拥有 schema:NewStore 幂等地创建 memory_items 表及其
// 索引。并发交由 *sql.DB,按约定它可安全
// 并发使用,因此除语义索引的接入外,Store 不再持锁。每个查询都
// 参数化(?)并显式列出列(无 SELECT *),读取带
// context.Cancel。
type Store struct {
db *sql.DB
log *slog.Logger
// vecMu 保护 vec,使持久语义索引能在装配后并发接入,而写入
// 路径每写一条记忆就 embed + add 到它(见 indexItem)。vec 为
// nil 表示没有配置持久索引,检索据此回退到内存余弦。
vecMu sync.RWMutex
vec vector.Index
// projMu 保护 proj,使项目索引存储能在装配后并发接入,而写入路径
// 每写一条项目作用域记忆就确保它链接的项目索引条目存在(见
// ensureProjectIndex)。proj 为 nil 表示未接入项目索引,此时写入
// 不触碰项目索引(design §3 项目记忆链接项目索引)。
projMu sync.RWMutex
proj *projectindex.Store
// reviewMu 保护 review,使入库前审查(design/23 §15.1 D3)能在装配后
// 并发接入:接入后每条经 Add 落库的条目都会**先过同步入库闸**(确定性判据
// 命中即落 merged,见 preinsert_gate.go),闸没拦下的再投入异步审查队列,
// 由独立的记忆管理会话判定 merge/modify/create/skip。review 为 nil 表示
// 未启用或未接线,此时写入路径行为逐字与接入前一致。
reviewMu sync.RWMutex
review *PreInsertReviewer
}
// NewStore 在 db 上构建 Store,确保 memory_items schema 存在。
//
// db 必须是存活且已迁移的连接(迁移框架本身由
// internal/db 拥有)。nil db 返回 ErrNilDB。log 可为 nil,此时
// 使用丢弃型 logger。ctx 控制 schema 创建,调用方必须
// 遵守。任何旧的变宽时间戳都就地
// 规范化(见 normalizeMemoryTimestamps),使文本顺序与时间顺序一致。
func NewStore(ctx context.Context, db *sql.DB, log *slog.Logger) (*Store, error) {
if db == nil {
return nil, ErrNilDB
}
log = logger.WithSource(discardLogger(log), "memory")
for _, ddl := range []string{
createMemoryItemsTable,
createMemoryItemsPersonaIndex,
createMemoryItemsPersonaCreatedIndex,
} {
if _, err := db.ExecContext(ctx, ddl); err != nil {
return nil, fmt.Errorf("memory: apply schema: %w", err)
}
}
if err := ensureProjectIDColumn(ctx, db); err != nil {
return nil, err
}
// 记忆整理(design/23 §10)的列与两张新表:同样在这一处幂等前滚,
// 使「在任意 *sql.DB 上都能整理」与 memory_items 自身口径一致。
if err := ensureTidySchema(ctx, db); err != nil {
return nil, err
}
// 入库前审查(design/23 §15.1 D3)的留痕表:同一口径 —— 表在这里幂等
// 创建,因此审查能力在任何 *sql.DB 上都可用;未接线审查时它只是一张空表。
if err := ensureIngestReviewSchema(ctx, db); err != nil {
return nil, err
}
// 「留多久 / 干什么用」轴(迁移 0026):既有库补列 + 建读侧索引。它排在
// 建表与整理列之后,因为索引依赖的列必须先存在(见 retention_schema.go)。
if err := ensureRetentionSchema(ctx, db); err != nil {
return nil, err
}
// 时间性元数据位(迁移 0027):只补列、不建索引、不回填(见 temporal_schema.go)。
if err := ensureTemporalSchema(ctx, db); err != nil {
return nil, err
}
// 敏感标记(迁移 0028):同上,只补列、不回填 —— 历史数据的打标是独立的一次性
// 动作(BackfillSensitive),使结构前滚保持零副作用(见 sensitive_schema.go)。
if err := ensureSensitiveSchema(ctx, db); err != nil {
return nil, err
}
if err := normalizeMemoryTimestamps(ctx, db); err != nil {
return nil, err
}
return &Store{db: db, log: log}, nil
}
// Add 持久化 item,分配 id 与缺失的时间戳,并把
// Scope/Kind/Confidence/DecayWeight 规范化到默认值。它返回已落库的
// 条目,id 与时间戳均已填充。
//
// 🔴 **同步入库闸**(主人 2026-10-07 裁定):本函数是全仓**唯一**写路径,因此
// 闸就摆在这里 —— 落库**之前**先在同分区内跑一次确定性判定(复用
// judgeIngest → tidy_rules.go / tidy_containment.go),判出「真重复」时这一行
// 直接落 status='merged' + merged_into=<存活行>(**照常插入、一个字不丢、可
// revert、从不进 active 集合**),于是 active 集合当场就是干净的。闸的总墙钟
// 预算显式且有界(见 defaultReviewGateBudget),超预算/候选源不可用一律
// fail-open(照常插入 + 留痕,**绝不静默丢记忆**)。闸未接线或被开关关掉时,
// 这段逻辑是零成本的 passthrough,写入路径逐字回到改动之前。
//
// 接入持久语义索引(见 attachVectorIndex)时,落库后同一条目也会
// 被向量化并写入索引,使索引与 memory_items 同步 —— 语义检索路径因此
// 真的有向量可查,而不是对一个恒空索引搜索(design/03 §3「真正启用
// 向量检索」、design/04 §7)。索引写入失败只记日志:条目已经在
// 关系表里,语义增强的失败绝不使记忆写入失败,检索侧也会回退到
// 内存余弦(见 Retriever.semanticScores)。被闸判为重复的行**不建索引**:
// 它不进召回、也不该占掉语义检索 top-K 的一个位置。
//
// `item.ExpiresAt`(临时记忆的时效,design/03 §9.6)同在这一处落库为
// `expires_at` 列:Add 是全仓唯一 INSERT 路径(design/23 §15.1 D3-1),
// 因此时效列的真生产者**只能**在这里 —— 读侧时效闸(timegate.go)比对的
// 就是这一列,若写入侧不写出它,闸就退化成恒真的空壳。
//
// 🔴 **档位判定与有效期换算都在这一处**(迁移 0026、retention.go):
// `resolveRetention` 是全仓唯一的档位判据(调用方显式档位 > 显式有效期 >
// 受限来源不分型 > 留存规则 → temp > 默认 long),`plan.tempDeadline` 是唯一的
// 「档位 → expires_at」换算。两列因此**同一个判定、同一笔 INSERT**落下,
// 不存在「判了 temp 却没写有效期」的中间态 —— M2 时代登记过的「INSERT 与补写
// expires_at 之间的短窗口」在本片被结构性关闭(不再有第二笔 UPDATE)。
//
// 🆕 切片 M45:`item.Temporal`(时间性元数据位,迁移 0027)也随**这一笔** INSERT
// 落下(`temporal` 列)。**它只落库、不参与判定**(r1 复审裁决 D):`resolveRetention`
// 不读这一位,因此档位/有效期与「没有这一位」逐字相同;列值的作用是**观测**——
// 事后能按它分组统计、抽样看模型把哪些行标成了 `one_off`(这是将来决定要不要接线
// 的唯一证据来源,见 temporal.go 头注的查询)。
//
// 🆕 切片 M63:`item.Sensitive`(敏感标记,迁移 0028)也随**这一笔** INSERT 落下。
// 它**不参与任何判定**:档位 / 时效 / 读路径的可见性集合与「没有这一位」逐字相同
// —— 唯一的消费者是受限车道的过滤判据(sensitive.go 的 SharedVisible),而主人
// 车道根本不过那道判据。写入侧另有一条**白名单兜底**(见下面 Add 内的判定)。
func (s *Store) Add(ctx context.Context, item MemoryItem) (MemoryItem, error) {
if err := item.normalize(); err != nil {
return MemoryItem{}, err
}
var err error
if item, err = applyTimestamps(item); err != nil {
return MemoryItem{}, err
}
// 🆕 切片 M63(迁移 0028):敏感标记的**写入侧兜底** —— 判据靠代码,不靠提示词
// (AGENTS §9)。提取器的 `sensitive` 位是**类目级**判断(新私事的措辞无法穷举,
// 见 llmextractor.go 的正反例);这里是**已实证短语**的确定性兜底
// (IsPrivateContent,判据与理由见 sensitive.go)。合成只在 resolveSensitive
// 一处做(「或」,只加标不撤标),提取器产出侧与这里走同一个函数。
if resolved := resolveSensitive(item.Sensitive, item.Content); resolved != item.Sensitive {
item.Sensitive = resolved
s.log.Debug("memory: sensitive flag set by private-fact whitelist",
"id", item.ID, "scope", string(item.Scope))
}
// 🔴 档位判定(唯一入口):把结果写回条目,闸与异步审查因此**只读**这一位,
// 不再各自跑一遍留存规则(见 preinsert_judge.go 的 defer 分支)。
verdict, err := resolveRetention(item)
if err != nil {
return MemoryItem{}, err
}
item.RetentionTier = verdict.Tier
if verdict.Rule != retentionNone {
// 具体命中的反例规则码只进日志(进留痕列的是闭集里的 t0_low_value):
// 「n1~n7 谁命中过」是 M27 体检缺的那一条观测线。
s.log.Debug("memory: retention judged temporary",
"id", item.ID, "scope", string(item.Scope), "rule", string(verdict.Rule))
}
tagsJSON, err := json.Marshal(item.Tags)
if err != nil {
return MemoryItem{}, fmt.Errorf("memory: encode tags: %w", err)
}
plan := s.planIngest(ctx, item)
item.ExpiresAt = plan.ExpiresAt
if err := s.insertItem(ctx, item, tagsJSON, plan); err != nil {
return MemoryItem{}, err
}
s.finishIngest(ctx, item, plan)
if plan.Action != ingestPlanAbsorbNew {
s.indexItem(ctx, item)
s.ensureProjectIndex(ctx, item)
}
if r := s.ingestReviewer(); r != nil {
r.recordGate(ctx, item, plan, time.Now().UTC())
}
// 入库前审查(design/23 §15.1 D3)的**异步**那一层:闸没拦下的(真新 /
// 拿不准 / 闸关闭)照旧投入审查队列,由后台用语义召回再判一次;闸挡下的
// 那条**绝不再投**(同一件事不该有两条路径)。投递是非阻塞的。未接线审查
// 时是空操作。
if plan.Async {
s.enqueueReview(item)
}
return item, nil
}
// Delete 删除给定 id 的条目,id 为空或
// 没有删除任何行时返回 ErrNotFound。调用方只应传入经
// 角色作用域读取(ListByPersona/Get)得到的 id,使删除始终留在
// 调用方的角色边界内。
func (s *Store) Delete(ctx context.Context, id string) error {
if strings.TrimSpace(id) == "" {
return ErrNotFound
}
res, err := s.db.ExecContext(ctx, `DELETE FROM memory_items WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("memory: delete item: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("memory: delete row count: %w", err)
}
if n == 0 {
return ErrNotFound
}
return nil
}
// RememberManual 为 content 文本写入一条长期记忆条目 —— design §1 中的
// 「记住 X」路径。条目默认取角色作用域,
// 并以满置信度记为关系记忆。返回已落库的条目。
//
// 🔴 **显式档位 = long**(迁移 0026):这是**人工**动作(有人明确说「记住 X」),
// 按主人 2026-10-26 的裁定「人工动作默认应为 long」,留存规则不许把它降成临时层
// (否则一条「记住 晚安」会在 7 天后被静默删掉)。resolveRetention 的第一条分支
// 就是「调用方显式档位不许被规则推翻」,这里据此钉死长期意图。
func (s *Store) RememberManual(ctx context.Context, personaID, roleID, text string) (MemoryItem, error) {
item := MemoryItem{
PersonaID: personaID,
RoleID: roleID,
Scope: ScopePersona,
Kind: KindRelationship,
Content: strings.TrimSpace(text),
RetentionTier: RetentionLong,
}
if item.Content == "" {
return MemoryItem{}, ErrMissingContent
}
return s.Add(ctx, item)
}
// normalize 填充默认的 Scope/Kind/Confidence/DecayWeight,然后校验。
// 它由 Add 调用,因此调用方可以省略取值,同时仍能
// 捕获显式非法值。
//
// RetentionTier **不在这里填默认值**:空串是「还没分型」这一合法状态,它由 Add 的
// 单一判定(resolveRetention)落定 —— 在 normalize 里塞一个默认档会把「判定没跑」
// 与「判定结论是 long」变成同一件事。
func (it *MemoryItem) normalize() error {
if it.Scope == "" {
it.Scope = ScopePersona
}
if it.Kind == "" {
it.Kind = KindRelationship
}
if it.Confidence == 0 {
it.Confidence = defaultConfidence
}
if it.DecayWeight == 0 {
it.DecayWeight = defaultDecayWeight
}
return it.validate()
}
// validate 检查必填与枚举字段。它是纯函数:不施加
// 默认值,因此也可用于拒绝调用方提供的值。
func (it *MemoryItem) validate() error {
if strings.TrimSpace(it.PersonaID) == "" {
return ErrMissingPersona
}
if strings.TrimSpace(it.Content) == "" {
return ErrMissingContent
}
if !it.Scope.valid() {
return ErrInvalidScope
}
if !it.Kind.valid() {
return ErrInvalidKind
}
if !it.RetentionTier.valid() {
return fmt.Errorf("%w: %q", ErrInvalidRetentionTier, it.RetentionTier)
}
if err := it.validateTemporal(); err != nil {
return err
}
return nil
}
// discardLogger 在 log 非 nil 时返回 log,否则返回一个不写任何地方的 logger。
func discardLogger(log *slog.Logger) *slog.Logger {
if log != nil {
return log
}
return slog.New(slog.NewTextHandler(io.Discard, nil))
}