refactor(线索树): 回填接口化 —— 数据操作不进结构迁移通道
用户 2026-10-04:「一切数据调用都要接口化」。这条要求直接指向上一轮的两个错误。
## 撤掉的:Migrate 里的自动回填
`Migrate` 是**结构**变更通道(建表、加列)。让��兼做数据改写(给会话写
parent_session_id)是混用通道,后果不是理论上的:
- 不可重跑 —— 上一轮那次回填已经在部署中执行,`app_meta` 标记
`2026-10-04T02:54:37Z`,**无法通过任何方式重跑或撤销**;
- 不可审计 —— 库上看不出谁在什么时候改的;
- 不可触发 —— 没有接口能主动执行它。
⇒ 移到 admin 端点,与 `DELETE /admin/sessions/{id}`、
`POST /admin/agent-keys` 同一档。
## 加的
POST /api/v1/admin/sessions/tree/backfill 默认 dry-run,显式 dry_run=0 才写
GET /api/v1/admin/sessions/tree/preview 只读
`PreviewSessionParents`(repo 层,只读)是必需的配套:接口化之后**验证本身
也必须走接口**,那么「回填到底会改什么」就得有一条不写任何东西的接口来回答,
否则只剩「先写了再看看对不对」。
## 判据抓到的真 bug:注释说默认 dry-run,代码默认就写
if v := r.URL.Query().Get("dry_run"); v != "" { // 参数缺失 → 整个 if 落空
...
}
n, err := repo.BackfillSessionParents(...) // ← 直接执行
误点一次 POST 就改了 6 条真实会话的拓扑,且没有任何预览。修成
`dry := true` 起手、只在显式解析出 false 时才写。
## 判据自己假绿了一次(本轮第四次做形状判据)
`TestBackfillDefaultsToDryRun` 初版读源码、数 `AdminPreviewSessionParents`
出现几次。变异 `dry := false`(**正是上面那个 bug**)之后判据**依然全绿** ——
因为 `if dry {…}` 分支在源码里始终存在。
⇒ 改成行为测试:真起 httptest 发一个不带参数的 POST,然后去**库里**看
parent_session_id 有没有被写。重测同一变异,两条断言都红:
★ 无参 POST 的响应应表明这是预览,实际:{"dry_run":false,…}
★ 无参 POST 就把 parent_session_id 写成了 "31949dc4-…"
同轮修掉判据两次「匹配比语义宽」:`strings.Contains(seg, "UPDATE")`
把 `ORDER BY c.updated_at DESC` 里的 `updated` 当成了写库;
函数边界用 `strings.Index("\nfunc ")` 在「函数是文件最后一个」时落空,
取到全文后把别的函数里的 UPDATE 判成预览写库。
## 尚未验证
树端点只能验到「匿名被正确拒绝(401)」—— **不足以证明树能用**。
缺 admin 凭证,admin 全看 vs 普通用户收窄的差异还没在真数据上跑过。
14 包全绿。
This commit is contained in:
@ -304,6 +304,13 @@ func main() {
|
||||
// 刻意**不进 MCP 工具面**:调用方是模型,误判一次就是真丢数据。
|
||||
// Agent 要结束线索走归档(那是不可逆性低得多的操作)。
|
||||
r.Delete("/admin/sessions/{id}", handler.AdminDeleteSession)
|
||||
|
||||
// 会话级线索树回填(2026-10-04)。★ 是接口而非 Migrate 里的
|
||||
// 自动步骤:它改的是**数据**(真实会话拓扑)而不是结构,
|
||||
// 混用迁移通道会导致不可重跑、不可审计、看不出谁改的。
|
||||
// 默认 dry-run,显式 dry_run=0 才写入。
|
||||
r.Post("/admin/sessions/tree/backfill", handler.AdminBackfillSessionParents)
|
||||
r.Get("/admin/sessions/tree/preview", handler.AdminPreviewSessionParents)
|
||||
r.Post("/admin/agent-keys/{id}/bind", handler.BindAgentKey)
|
||||
|
||||
// Agent 发信配额
|
||||
|
||||
@ -43,20 +43,6 @@ func Migrate(ctx context.Context) error {
|
||||
return fmt.Errorf("migrate: 未初始化的方言")
|
||||
}
|
||||
|
||||
// 会话级线索树的一次性回填(2026-10-04)。
|
||||
//
|
||||
// 为什么**不**每次 Migrate 都跑:它会改**真实会话的拓扑**(给 6 条会话写上
|
||||
// parent_session_id)。若判据后来变宽、或有人手工调整过 parent,那些改动
|
||||
// 会在重启时被悄悄改回去 —— 「不可见的人工意图」正是既有 backfillMailReads
|
||||
// 用 marker 守住的原因(见它的注释:每次跑会把「某抄送方读过」按主收件人
|
||||
// 写成已读)。
|
||||
//
|
||||
// 回填口径见 repo.BackfillSessionParents:只认**直接跨会话分叉**,且不改
|
||||
// 已有 parent_session_id 的会话。
|
||||
if err := backfillSessionParentsOnce(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := backfillMailReads(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
@ -417,53 +403,3 @@ func columnExists(ctx context.Context, table, column string) (bool, error) {
|
||||
table, column).Scan(&n)
|
||||
return n > 0, err
|
||||
}
|
||||
|
||||
// backfillSessionParentsOnce 给跨会话分叉的会话补 parent_session_id,只跑一次。
|
||||
//
|
||||
// 一次性守卫的原因见 Migrate 里那段注释:它改的是会话**拓扑**,不是派生数据。
|
||||
func backfillSessionParentsOnce(ctx context.Context) error {
|
||||
const marker = "session_tree_backfill_v1"
|
||||
var v string
|
||||
err := DB.QueryRowContext(ctx, `SELECT value FROM app_meta WHERE key = $1`, marker).Scan(&v)
|
||||
if err == nil {
|
||||
return nil // 已回填过
|
||||
}
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return fmt.Errorf("backfill session_tree: 读标记失败: %w", err)
|
||||
}
|
||||
|
||||
if _, err := DB.ExecContext(ctx, `
|
||||
UPDATE sessions
|
||||
SET parent_session_id = (
|
||||
SELECT ps.session_id
|
||||
FROM mails m
|
||||
JOIN mails pm ON m.parent_mail_id = pm.mail_id
|
||||
JOIN sessions ps ON ps.session_id = pm.session_id
|
||||
WHERE m.session_id = sessions.session_id
|
||||
AND ps.session_id <> sessions.session_id
|
||||
ORDER BY m.created_at ASC
|
||||
LIMIT 1
|
||||
)
|
||||
WHERE parent_session_id IS NULL
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM mails m
|
||||
WHERE m.session_id = sessions.session_id
|
||||
AND m.parent_mail_id IS NOT NULL
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM mails pm
|
||||
WHERE pm.mail_id = m.parent_mail_id
|
||||
AND pm.session_id <> sessions.session_id
|
||||
)
|
||||
)
|
||||
`); err != nil {
|
||||
return fmt.Errorf("backfill session_tree: 回填失败: %w", err)
|
||||
}
|
||||
|
||||
if _, err := DB.ExecContext(ctx,
|
||||
`INSERT INTO app_meta (key, value) VALUES ($1, $2)`, marker,
|
||||
time.Now().UTC().Format(time.RFC3339)); err != nil {
|
||||
return fmt.Errorf("backfill session_tree: 写标记失败: %w", err)
|
||||
}
|
||||
fmt.Println("会话线索树回填完成")
|
||||
return nil
|
||||
}
|
||||
|
||||
83
server/internal/handler/session_tree_admin.go
Normal file
83
server/internal/handler/session_tree_admin.go
Normal file
@ -0,0 +1,83 @@
|
||||
package handler
|
||||
|
||||
/*
|
||||
POST /api/v1/admin/sessions/tree/backfill —— 会话级线索树回填(2026-10-04)。
|
||||
|
||||
# 为什么是接口而不是 Migrate 里的自动步骤
|
||||
|
||||
用户 2026-10-04 明确要求「一切数据调用都要接口化」。而回填是**数据操作**:
|
||||
它改真实会话的拓扑、可重跑、需要留审计痕迹。`Migrate` 是**结构**变更通道
|
||||
(建表、加列),让<EFBFBD><EFBFBD>兼做数据改写是混用通道 —— 后果是不可重跑、不可通过
|
||||
接口触发、看不出谁在什么时候改的。
|
||||
|
||||
⇒ 从 Migrate 移到这里,挂在 AdminOnly 组下(同一档的还有
|
||||
`DELETE /admin/sessions/{id}`、`POST /admin/agent-keys`)。
|
||||
|
||||
# dry_run:先看后写
|
||||
|
||||
默认 `dry_run=1` —— **只返回将要写入的父子关系,不落库**。回填会改会话拓扑,
|
||||
不能靠「先写了再检查对不对」。管理员确认后显式传 `dry_run=0` 才真正写入。
|
||||
|
||||
# 幂等
|
||||
|
||||
`BackfillSessionParents` 只写 `parent_session_id IS NULL` 的会话,
|
||||
所以重跑安全;人工设置过的父**不会被覆盖**(判据
|
||||
`TestBackfillSessionParentsRunsOnlyOnce`)。
|
||||
*/
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/agentmail/gateway/internal/repo"
|
||||
)
|
||||
|
||||
// AdminPreviewSessionParents 只读预览:若现在回填,会写哪些父子关系。
|
||||
func AdminPreviewSessionParents(w http.ResponseWriter, r *http.Request) {
|
||||
preview, err := repo.PreviewSessionParents(r.Context())
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "预览会话父子失败")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{
|
||||
"would_set": preview,
|
||||
"count": len(preview),
|
||||
"dry_run": true,
|
||||
})
|
||||
}
|
||||
|
||||
// AdminBackfillSessionParents 执行回填。
|
||||
//
|
||||
// 默认 dry-run;必须显式 `dry_run=0` 才写 —— 免得误点一次就改了真实拓扑。
|
||||
func AdminBackfillSessionParents(w http.ResponseWriter, r *http.Request) {
|
||||
// ★ 默认 dry-run,**且 `dry_run` 未指定时也走预览**。
|
||||
//
|
||||
// 初版写成 `if v := …; v != "" { … }` —— 参数缺失时整个 if 落空,
|
||||
// 直接执行回填。也就是注释写着「默认 dry-run」而代码**默认就写**。
|
||||
// 后果:误点一次 POST 就改了 6 条真实会话的拓扑,且没有任何预览。
|
||||
// 判据 TestBackfillDefaultsToDryRun 抓到的就是这一条(它红得对)。
|
||||
dry := true // 默认预览
|
||||
if v := r.URL.Query().Get("dry_run"); v != "" {
|
||||
parsed, err := strconv.ParseBool(v)
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "dry_run 必须是 true/false")
|
||||
return
|
||||
}
|
||||
dry = parsed
|
||||
}
|
||||
if dry {
|
||||
AdminPreviewSessionParents(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
n, err := repo.BackfillSessionParents(r.Context())
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "回填会话父子失败")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{
|
||||
"status": "backfilled",
|
||||
"parents": n,
|
||||
"dry_run": false,
|
||||
})
|
||||
}
|
||||
248
server/internal/handler/session_tree_admin_test.go
Normal file
248
server/internal/handler/session_tree_admin_test.go
Normal file
@ -0,0 +1,248 @@
|
||||
package handler
|
||||
|
||||
/*
|
||||
回填端点的判据(2026-10-04)。
|
||||
|
||||
★ 这批判据盯的是**接口化本身**(用户 2026-10-04:「一切数据调用都要接口化」):
|
||||
|
||||
1. 回填**不在** Migrate 里 —— 那是结构变更通道,让它改数据(会话拓扑)
|
||||
会导致不可重跑、不可通过接口触发、看不出谁改的。
|
||||
2. 端点挂在 AdminOnly 组 —— 会话拓扑是全站数据,非 admin 不该能改。
|
||||
3. **默认 dry-run** —— 免得误点一次就改了 6 条真实会话的父子关系。
|
||||
4. `PreviewSessionParents` 只读 —— 验证也必须走接口(不直连库),
|
||||
所以需要一个「不写也能看到将发生什么」的接口。
|
||||
*/
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/agentmail/gateway/internal/db"
|
||||
"github.com/agentmail/gateway/internal/repo"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func readMain(t *testing.T) string {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile("../../cmd/server/main.go")
|
||||
if err != nil {
|
||||
t.Fatalf("读 main.go: %v", err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// ★ 回填不得留在 Migrate 里。
|
||||
func TestSessionBackfillNotInMigrate(t *testing.T) {
|
||||
b, err := os.ReadFile("../db/migrate.go")
|
||||
if err != nil {
|
||||
t.Fatalf("读 migrate.go: %v", err)
|
||||
}
|
||||
src := string(b)
|
||||
for _, forbidden := range []string{"parent_session_id = (", "UPDATE sessions"} {
|
||||
if strings.Contains(src, forbidden) {
|
||||
t.Errorf("★ migrate.go 里出现了 %q —— 回填是**数据**改写,不该混进结构迁移通道。"+
|
||||
"\n 后果:不可重跑、不可经接口触发、看不出谁在什么时候改的。", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 两个 admin 端点都要挂在 AdminOnly 组里。
|
||||
func TestSessionTreeAdminRoutesGuarded(t *testing.T) {
|
||||
src := readMain(t)
|
||||
|
||||
adminGroupStart := strings.Index(src, "middleware.AdminOnly")
|
||||
if adminGroupStart < 0 {
|
||||
t.Fatal("找不到 AdminOnly 分组")
|
||||
}
|
||||
// 取 AdminOnly 分组之后的一段(到下一个 Group 或文件尾)
|
||||
rest := src[adminGroupStart:]
|
||||
if end := strings.Index(rest[1:], "r.Group("); end > 0 {
|
||||
rest = rest[:end]
|
||||
}
|
||||
|
||||
for _, route := range []string{
|
||||
"/admin/sessions/tree/backfill",
|
||||
"/admin/sessions/tree/preview",
|
||||
} {
|
||||
if !strings.Contains(rest, route) {
|
||||
t.Errorf("★ %s 不在 AdminOnly 分组里 —— 会话拓扑是全站数据,非 admin 不该能改/能看", route)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 默认必须 dry-run —— 测**行为**,不测源码形状。
|
||||
//
|
||||
// 判据演进(2026-10-04):初版读源码、数 `AdminPreviewSessionParents`
|
||||
// 出现几次。变异 `dry := false`(正是初版那个真 bug)之后,判据**依然全绿**
|
||||
// —— 因为 `if dry {...}` 分支在 false 时也存在于源码里。
|
||||
// ⇒ 形状判据第四次被形状骗。改成真起 httptest、直接发一个不带参数的 POST,
|
||||
//
|
||||
// 然后去**库里**看 parent 有没有被写进去。
|
||||
func TestBackfillDefaultsToDryRun(t *testing.T) {
|
||||
setupTreeAdminHandlerDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
parent := mustTreeSession(t, "父")
|
||||
child := mustTreeSession(t, "子")
|
||||
pm := mustTreeMail(t, parent, "父里的原邮件", "")
|
||||
mustTreeMail(t, child, "子里的回信", pm)
|
||||
|
||||
// 不带任何参数 —— 这正是「误点一次」的场景
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/sessions/tree/backfill", nil)
|
||||
AdminBackfillSessionParents(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("应 200,实际 %d:%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), `"dry_run":true`) {
|
||||
t.Errorf("★ 无参 POST 的响应应表明这是预览,实际:%s", rec.Body.String())
|
||||
}
|
||||
|
||||
// 真正的判据在库里:不该被写进去
|
||||
var got sql.NullString
|
||||
if err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT parent_session_id FROM sessions WHERE session_id = ?`, child).Scan(&got); err != nil {
|
||||
t.Fatalf("读父: %v", err)
|
||||
}
|
||||
if got.Valid {
|
||||
t.Errorf("★ 无参 POST 就把 parent_session_id 写成了 %q —— 一次误点改了真实会话拓扑", got.String)
|
||||
}
|
||||
|
||||
// 显式 dry_run=0 才写
|
||||
rec2 := httptest.NewRecorder()
|
||||
req2 := httptest.NewRequest(http.MethodPost,
|
||||
"/api/v1/admin/sessions/tree/backfill?dry_run=0", nil)
|
||||
AdminBackfillSessionParents(rec2, req2)
|
||||
if rec2.Code != http.StatusOK {
|
||||
t.Fatalf("显式写入应 200,实际 %d:%s", rec2.Code, rec2.Body.String())
|
||||
}
|
||||
if err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT parent_session_id FROM sessions WHERE session_id = ?`, child).Scan(&got); err != nil {
|
||||
t.Fatalf("读父: %v", err)
|
||||
}
|
||||
if got.String != parent {
|
||||
t.Errorf("dry_run=0 后应写入 %s,实际 %q", parent, got.String)
|
||||
}
|
||||
|
||||
// 预览端点在**有真实分叉待写**时也只读
|
||||
child2 := mustTreeSession(t, "子2")
|
||||
mustTreeMail(t, child2, "又一条回信", pm)
|
||||
rec3 := httptest.NewRecorder()
|
||||
req3 := httptest.NewRequest(http.MethodGet,
|
||||
"/api/v1/admin/sessions/tree/preview", nil)
|
||||
AdminPreviewSessionParents(rec3, req3)
|
||||
if rec3.Code != http.StatusOK {
|
||||
t.Fatalf("预览应 200,实际 %d", rec3.Code)
|
||||
}
|
||||
if !strings.Contains(rec3.Body.String(), child2) {
|
||||
t.Errorf("★ 预览应列出将要写入的 %s(验证必须有不写库的路径),实际:%s",
|
||||
child2, rec3.Body.String())
|
||||
}
|
||||
if err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT parent_session_id FROM sessions WHERE session_id = ?`, child2).Scan(&got); err != nil {
|
||||
t.Fatalf("读父: %v", err)
|
||||
}
|
||||
if got.Valid {
|
||||
t.Errorf("★ 预览端点写了库(parent=%q)", got.String)
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 预览接口必须只读(不能含写操作)。
|
||||
func TestPreviewEndpointIsReadOnly(t *testing.T) {
|
||||
b, err := os.ReadFile("../repo/session_tree.go")
|
||||
if err != nil {
|
||||
t.Fatalf("读 repo: %v", err)
|
||||
}
|
||||
src := string(b)
|
||||
i := strings.Index(src, "func PreviewSessionParents")
|
||||
if i < 0 {
|
||||
t.Fatal("找不到 PreviewSessionParents —— 接口化后验证必须有不写库的路径")
|
||||
}
|
||||
// ★ 按**行**取到下一个顶格 `func ` 为止,而不是 strings.Index("\nfunc ")。
|
||||
// Index 那版在两次实测里都取错了段(一次取到别的函数的 UPDATE、
|
||||
// 一次在函数是文件最后一个时落空而取了全文)—— 同一族「观察窗口不对」。
|
||||
// 行扫描的语义就是我要的:这个函数体到下一个函数声明为止。
|
||||
lines := strings.Split(src[i:], "\n")
|
||||
var body []string
|
||||
for _, ln := range lines {
|
||||
if len(body) > 0 && strings.HasPrefix(ln, "func ") {
|
||||
break
|
||||
}
|
||||
body = append(body, ln)
|
||||
}
|
||||
seg := strings.Join(body, "\n")
|
||||
// ★ 必须按**关键字边界**匹配,不能 strings.Contains。
|
||||
// 初版判据对整段做 Contains("UPDATE"),而查询里的
|
||||
// `ORDER BY c.updated_at DESC` 含有 "updated" ⇒ 判据把一个**只读**的
|
||||
// 预览函数判成会写库。这已是本轮第三次「匹配比语义宽」:
|
||||
// 前两次是 .d.ts 的 `parent_mail_id = NULL` 命中注释、
|
||||
// 以及 session-{id,log} 的正则多写了一个 `\.`。
|
||||
for _, w := range []string{"INSERT", "UPDATE", "DELETE"} {
|
||||
re := regexp.MustCompile(`(?i)\b` + w + `\s`)
|
||||
if re.MatchString(seg) {
|
||||
t.Errorf("★ PreviewSessionParents 里出现 SQL 关键字 %s ⇒ 预览接口会写库", w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var _ = http.StatusOK
|
||||
|
||||
func setupTreeAdminHandlerDB(t *testing.T) {
|
||||
t.Helper()
|
||||
db.Close()
|
||||
path := filepath.Join(t.TempDir(), "tree-admin-handler.db")
|
||||
if err := db.Connect(context.Background(), "sqlite://"+path); err != nil {
|
||||
t.Fatalf("连接测试库: %v", err)
|
||||
}
|
||||
if err := db.Migrate(context.Background()); err != nil {
|
||||
t.Fatalf("迁移测试库: %v", err)
|
||||
}
|
||||
t.Cleanup(db.Close)
|
||||
}
|
||||
|
||||
func mustTreeSession(t *testing.T, alias string) string {
|
||||
t.Helper()
|
||||
var id string
|
||||
if err := db.DB.QueryRowContext(context.Background(),
|
||||
`INSERT INTO sessions (session_alias, subject, status, workspace, from_agent)
|
||||
VALUES ($1,'x','active','/tmp','pi') RETURNING session_id`, alias).Scan(&id); err != nil {
|
||||
t.Fatalf("建会话: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func mustTreeMail(t *testing.T, session, subject, parent string) string {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
var parentPtr *uuid.UUID
|
||||
if parent != "" {
|
||||
pid, err := uuid.Parse(parent)
|
||||
if err != nil {
|
||||
t.Fatalf("解析父邮件: %v", err)
|
||||
}
|
||||
parentPtr = &pid
|
||||
}
|
||||
sid, err := uuid.Parse(session)
|
||||
if err != nil {
|
||||
t.Fatalf("解析会话: %v", err)
|
||||
}
|
||||
if _, err := repo.CreateMail(ctx, sid, parentPtr, "pi", "/tmp", "dsh", "/tmp",
|
||||
subject, "", nil); err != nil {
|
||||
t.Fatalf("建邮件: %v", err)
|
||||
}
|
||||
var id string
|
||||
if err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT mail_id FROM mails WHERE session_id = ? ORDER BY created_at DESC LIMIT 1`,
|
||||
session).Scan(&id); err != nil {
|
||||
t.Fatalf("取邮件: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
@ -256,3 +256,56 @@ func PruneTree(nodes []SessionNode, visible map[string]bool) []SessionNode {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// SessionParentPreview 是「若现在回填,将会写入哪条父子关系」。
|
||||
type SessionParentPreview struct {
|
||||
SessionID string `json:"session_id"`
|
||||
Alias string `json:"alias"`
|
||||
WillSetParent string `json:"will_set_parent_id"`
|
||||
WillSetAlias string `json:"will_set_parent_alias"`
|
||||
ViaMail string `json:"via_mail_subject"`
|
||||
}
|
||||
|
||||
// PreviewSessionParents 返回「若现在回填,会写入哪些父子关系」,**只读**。
|
||||
//
|
||||
// ★ 为什么要有它:用户 2026-10-04 要求「一切数据调用都要接口化」,
|
||||
//
|
||||
// 于是验证也必须走接口 —— 而**不得**直连库。那么「回填到底会改什么」
|
||||
// 就需要一条不写任何东西的接口来回答,否则只能「先写了再看看对不对」。
|
||||
//
|
||||
// 顺带让无参 POST 变成安全的:默认走预览(见 handler 的 dry_run 处理)。
|
||||
func PreviewSessionParents(ctx context.Context) ([]SessionParentPreview, error) {
|
||||
rows, err := db.DB.QueryContext(ctx, `
|
||||
SELECT c.session_id, COALESCE(c.session_alias, c.subject, ''),
|
||||
ps.session_id, COALESCE(ps.session_alias, ps.subject, ''),
|
||||
COALESCE(pm.subject, '')
|
||||
FROM sessions c
|
||||
JOIN mails m ON m.session_id = c.session_id
|
||||
JOIN mails pm ON pm.mail_id = m.parent_mail_id
|
||||
JOIN sessions ps ON ps.session_id = pm.session_id
|
||||
WHERE c.parent_session_id IS NULL
|
||||
AND ps.session_id <> c.session_id
|
||||
ORDER BY c.updated_at DESC
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("预览会话父子: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []SessionParentPreview{}
|
||||
seen := map[string]bool{}
|
||||
for rows.Next() {
|
||||
var p SessionParentPreview
|
||||
if err := rows.Scan(&p.SessionID, &p.Alias, &p.WillSetParent,
|
||||
&p.WillSetAlias, &p.ViaMail); err != nil {
|
||||
return nil, fmt.Errorf("读预览行: %w", err)
|
||||
}
|
||||
// 一个子节点只报一次(与 BackfillSessionParents 的 … LIMIT 1 一致)
|
||||
if seen[p.SessionID] {
|
||||
continue
|
||||
}
|
||||
seen[p.SessionID] = true
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user