用户 2026-10-04:「不只是 Agent 主动回,还有自动转发 Agent 的最后一条消息」。
## 为什么 MCP 侧需要显式上报,而插件侧不用
插件(pi / dsh / homeagent)有 `session.idle` 钩子:
宿主进程在自己会话 idle 时读到最后一条回复 → 自动 send_mail(relay=…)
MCP 宿主(Codex / Claude Desktop)**没有这个概念**:
不知道「这一轮处理完了」,也不会主动调 send_mail。
⇒ 宿主必须显式上报,服务端才可能自动转发。
所以新增 `send_mail(relay="summary", relay_key="<宿主消息 id>")`:
relay=summary 免配额(服务端既有逻辑),relay_key 提供幂等。
## 顺带修掉一个从未生效的幂等文案(既有缺陷)
MCP 侧读:str(payload, "duplicate_relay")
服务端返:{"status": "duplicate_relay", "detail": "该上游消息已转发过…"}
**字段名对不上 ⇒ 那段逻辑从来没生效过。**
后果对 MCP 接入端尤其致命:它的自动转发正是靠 relay_key 幂等兜底,
而宿主重试时看到的是「已发送」(无 mail_id、无任何异常),
于是继续重试 —— 每一轮都在消耗一次往返。
现在文案是:
该上游消息(relay_key: xxx)已转发过,本次调用未产生新邮件 —— 不需要重试。
## 判据(3 格,2 个变异都转红)
- send_mail 暴露 relay / relay_key(去掉透传 → 转红)
- relay 非空而 relay_key 为空必须被拒
- 同一 relay_key 第二次转发报重复,且**只有一封真邮件**
## 判据自己错了两次
1. `TestRelayRequiresKey` 红着但**原因不对**:没建收件人,400 发生在
「收件人不存在」那一步,掩盖了 parseRelay 的校验。
2. 幂等那格被我自己 `Skip` 了 —— `.relay-idem` 会话不存在,
于是「幂等没被击穿」的假绿。改为 `.new` 并**禁止 Skip**:
幂等是自动转发不重投的唯一保证,它绿着才敢让宿主上报。
(判据不可达 = 描述的状态不存在。)
## 双向对齐判据立刻证明了价值
加了 relay/relay_key 却没登记,它当场红:
★ send_mail 的参数 "relay" 既不在插件版、也没登记为 MCP 额外
若它不存在,加了新参数就会静默通过,而「这个能力是有意加的还是漏登记的」
正是以后没人能回答的问题。relay 登记为「额外」而非「缺失」——
插件侧走 session.idle,不需要这条参数。
14 包全绿。
488 lines
16 KiB
Go
488 lines
16 KiB
Go
package mcp
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"mime/multipart"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
|
||
"github.com/agentmail/gateway/internal/handler"
|
||
)
|
||
|
||
// httptestRecorder 造一个响应收集器。
|
||
func httptestRecorder() *httptest.ResponseRecorder { return httptest.NewRecorder() }
|
||
|
||
// jsonUnmarshal 是 json.Unmarshal 的薄封装(工具文件里用起来更短)。
|
||
func jsonUnmarshal(b []byte, v any) error { return json.Unmarshal(b, v) }
|
||
|
||
// newMultipartRequest 造一个 multipart/form-data 请求。
|
||
func newMultipartRequest(ctx context.Context, body []byte, contentType string) *http.Request {
|
||
r := httptest.NewRequest(http.MethodPost, "/api/v1/attachments", bytes.NewReader(body))
|
||
r.Header.Set("Content-Type", contentType)
|
||
return r.WithContext(ctx)
|
||
}
|
||
|
||
// buildUploadForm 组装上传用的 multipart 表单。
|
||
//
|
||
// 只**读**文件(打开 + 拷贝),不写。它读的是调用方给出的路径 ——
|
||
// 见 tool_impls.go 里 upload_attachment 的注释:这是一处「工具参数即
|
||
// 文件系统访问」,只读,且只对本服务自己跑在本机这件事成立(远程
|
||
// Streamable HTTP 部署时读的是**网关机器**的路径,不是客户端的)。
|
||
func buildUploadForm(path, filename string) ([]byte, string, error) {
|
||
if strings.TrimSpace(path) == "" {
|
||
return nil, "", fmt.Errorf("缺少 file_path")
|
||
}
|
||
f, err := os.Open(path)
|
||
if err != nil {
|
||
return nil, "", fmt.Errorf("打不开 %s:%v", path, err)
|
||
}
|
||
defer f.Close()
|
||
|
||
if filename == "" {
|
||
filename = filepath.Base(path)
|
||
}
|
||
var buf bytes.Buffer
|
||
mw := multipart.NewWriter(&buf)
|
||
// 服务端字段名是 "file"(FormFile("file"))—— 写错名字会得到
|
||
// "表单里没有 file 字段" 这种看不懂的错。
|
||
part, err := mw.CreateFormFile("file", filename)
|
||
if err != nil {
|
||
return nil, "", err
|
||
}
|
||
if _, err := io.Copy(part, f); err != nil {
|
||
return nil, "", err
|
||
}
|
||
if err := mw.Close(); err != nil {
|
||
return nil, "", err
|
||
}
|
||
return buf.Bytes(), mw.FormDataContentType(), nil
|
||
}
|
||
|
||
// summarize 把 handler 的成功响应转成模型读的文本。
|
||
//
|
||
// 为什么不直接把 JSON 甩给模型:
|
||
// - read_inbox 一次可能返回几十封,每封都带邮件正文缩略 —— 纯 JSON 里
|
||
// 转义与字段名会占掉大量 token,而且模型容易看错行(哪个是 subject、
|
||
// 哪个是 from)。
|
||
// - 人类与模型都需要**能扫读**的形状。
|
||
//
|
||
// 所以这里按已知的响应形状做小渲染;认不出来的形状回退到原始 JSON
|
||
// (宁可费 token,也不要把信息丢了 —— 那会让模型以为邮件是空的)。
|
||
func summarize(name string, payload map[string]any) (string, error) {
|
||
switch name {
|
||
case "read_inbox":
|
||
return summarizeInbox(payload), nil
|
||
case "read_mail":
|
||
return summarizeMail(payload), nil
|
||
case "read_thread":
|
||
return summarizeThread(payload), nil
|
||
case "send_mail":
|
||
return summarizeSend(payload), nil
|
||
case "forward_mail":
|
||
return summarizeSend(payload), nil
|
||
case "upload_attachment":
|
||
return summarizeUpload(payload), nil
|
||
case "suggest_address":
|
||
return summarizeSuggest(payload), nil
|
||
case "list_contacts":
|
||
return summarizeContacts(payload), nil
|
||
case "session_participants":
|
||
return summarizeParticipants(payload), nil
|
||
}
|
||
return prettyJSON(payload), nil
|
||
}
|
||
|
||
func prettyJSON(payload map[string]any) string {
|
||
b, err := json.MarshalIndent(payload, "", " ")
|
||
if err != nil {
|
||
return fmt.Sprintf("%v", payload)
|
||
}
|
||
return string(b)
|
||
}
|
||
|
||
func str(m map[string]any, k string) string {
|
||
if v, ok := m[k].(string); ok {
|
||
return v
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// summarizeInbox 渲染收件箱:每封一行,带 id(下一步要用)与是否有附件。
|
||
func summarizeInbox(payload map[string]any) string {
|
||
mails, _ := payload["mails"].([]any)
|
||
total := numberOf(payload["total"])
|
||
if len(mails) == 0 {
|
||
if total > 0 {
|
||
return fmt.Sprintf("(本会话/工作区内没有符合条件的未读,但 total=%d)", total)
|
||
}
|
||
return "收件箱为空。"
|
||
}
|
||
var b strings.Builder
|
||
fmt.Fprintf(&b, "%d 封未读(total=%d):\n\n", len(mails), total)
|
||
for _, it := range mails {
|
||
m, ok := it.(map[string]any)
|
||
if !ok {
|
||
continue
|
||
}
|
||
from := firstNonEmpty(str(m, "from_name"), str(m, "from"), "(未知)")
|
||
subj := firstNonEmpty(str(m, "subject"), "(无主题)")
|
||
id := str(m, "mail_id")
|
||
flag := ""
|
||
if n, _ := m["attachments"].([]any); len(n) > 0 {
|
||
flag = fmt.Sprintf(" [附件 %d]", len(n))
|
||
}
|
||
if ws := str(m, "session_workspace"); ws != "" {
|
||
flag += " " + ws
|
||
}
|
||
fmt.Fprintf(&b, "· %s — %s (id: %s)%s\n", from, subj, id, flag)
|
||
}
|
||
b.WriteString("\n用 read_mail(mail_id=…) 读正文。")
|
||
return b.String()
|
||
}
|
||
|
||
// summarizeMail 渲染单封:头部 + 正文 + 附件清单。
|
||
func summarizeMail(payload map[string]any) string {
|
||
m := payload
|
||
if inner, ok := payload["mail"].(map[string]any); ok {
|
||
m = inner
|
||
}
|
||
var b strings.Builder
|
||
fmt.Fprintf(&b, "发件人:%s\n主题:%s\n邮件 ID:%s\n日期:%s\n",
|
||
firstNonEmpty(str(m, "from_name"), "(未知)"),
|
||
firstNonEmpty(str(m, "subject"), "(无主题)"),
|
||
str(m, "mail_id"),
|
||
firstNonEmpty(str(m, "created_at"), str(m, "sent_at")))
|
||
if parent := str(m, "parent_mail_id"); parent != "" {
|
||
fmt.Fprintf(&b, "(这是对 %s 的回复)\n", parent)
|
||
}
|
||
b.WriteString("\n---\n")
|
||
b.WriteString(str(m, "body"))
|
||
if atts, ok := m["attachments"].([]any); ok && len(atts) > 0 {
|
||
b.WriteString("\n\n附件:")
|
||
for _, a := range atts {
|
||
am, _ := a.(map[string]any)
|
||
fmt.Fprintf(&b, "\n· %s (attachment_id: %s, %s)",
|
||
firstNonEmpty(str(am, "filename"), "(未命名)"),
|
||
str(am, "attachment_id"),
|
||
humanSize(numberOf(am["size_bytes"])))
|
||
}
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
// summarizeThread 渲染线索:按时间顺序每封一段。
|
||
func summarizeThread(payload map[string]any) string {
|
||
mails, _ := payload["mails"].([]any)
|
||
if mails == nil {
|
||
mails, _ = payload["thread"].([]any)
|
||
}
|
||
if len(mails) == 0 {
|
||
return "线索里没有邮件。"
|
||
}
|
||
var b strings.Builder
|
||
fmt.Fprintf(&b, "线索共 %d 封:\n\n", len(mails))
|
||
for _, it := range mails {
|
||
m, _ := it.(map[string]any)
|
||
fmt.Fprintf(&b, "── %s · %s(id: %s)\n%s\n\n",
|
||
firstNonEmpty(str(m, "from_name"), "(未知)"),
|
||
firstNonEmpty(str(m, "subject"), "(无主题)"),
|
||
str(m, "mail_id"),
|
||
strings.TrimSpace(str(m, "body")))
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
func summarizeSend(payload map[string]any) string {
|
||
id := str(payload, "mail_id")
|
||
sid := str(payload, "session_id")
|
||
var b strings.Builder
|
||
b.WriteString("已发送")
|
||
if id != "" {
|
||
fmt.Fprintf(&b, "(mail_id: %s)", id)
|
||
}
|
||
if sid != "" {
|
||
fmt.Fprintf(&b, ",落在会话 %s", sid)
|
||
}
|
||
// ★ 幂等重放(2026-10-04 修)。
|
||
//
|
||
// 这里原本读 `payload["duplicate_relay"]` —— 而服务端(handler/mail.go
|
||
// 的 ClaimRelay 分支)返回的是 **`status: "duplicate_relay"`**。
|
||
// 字段名对不上 ⇒ 这段逻辑从来没生效过 ⇒ 宿主重试拿到的是
|
||
// 「已发送」而没有 mail_id,看起来像投递成功,于是继续重试。
|
||
//
|
||
// 对 MCP 接入端这条尤其致命:它的「自动转发」正是靠 relay_key 幂等
|
||
// 兜底的,而宿主看到「已发送」就不会改参数 —— 于是同一条上游消息
|
||
// 被反复转发(服务端不会真的重复建信,但每一轮都在消耗一次往返)。
|
||
if st := str(payload, "status"); st == "duplicate_relay" {
|
||
b.Reset()
|
||
key := str(payload, "relay_key")
|
||
if key != "" {
|
||
fmt.Fprintf(&b, "该上游消息(relay_key: %s)已转发过,本次调用未产生新邮件 —— ", key)
|
||
} else {
|
||
b.WriteString("该上游消息已转发过,本次调用未产生新邮件 —— ")
|
||
}
|
||
b.WriteString("不需要重试。")
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
func summarizeUpload(payload map[string]any) string {
|
||
// UploadAttachment 返回的是 {attachment:{...}} 这种**嵌套**形状
|
||
// —— 按顶层解会得到空 id(homeagent 与 zcode 都踩过)。
|
||
att, _ := payload["attachment"].(map[string]any)
|
||
if att == nil {
|
||
att = payload
|
||
}
|
||
return fmt.Sprintf("已上传 %s,attachment_id: %s(%s)。在 send_mail 的 attachment_ids 里带上它才会随邮件发出。",
|
||
firstNonEmpty(str(att, "filename"), "(未命名)"),
|
||
str(att, "attachment_id"),
|
||
humanSize(numberOf(att["size_bytes"])))
|
||
}
|
||
|
||
func summarizeSuggest(payload map[string]any) string {
|
||
kind := str(payload, "kind")
|
||
|
||
// flatten 出来的形状(2026-10-02 寻址报告 A):addresses + candidates。
|
||
// 必须单独渲染 —— 它没有 suggestions 字段,走下面的分支只会回一句
|
||
// 「(没有 session_flat 建议)」,模型拿不到任何地址,等于白问。
|
||
if kind == "session_flat" {
|
||
cands, _ := payload["candidates"].([]any)
|
||
if len(cands) == 0 {
|
||
return "没有可列出的会话(对方可能从未与你通信过)。" +
|
||
"若要开新线索,用 send_mail 指定 **.new** 结尾的地址 —— " +
|
||
"但请先确认工作区:root 与 /root 是两个不同工作区。"
|
||
}
|
||
var b strings.Builder
|
||
fmt.Fprintf(&b, "%d 条可投递地址(每条自带工作区,直接放进 send_mail 的 to):\n\n", len(cands))
|
||
for _, it := range cands {
|
||
m, _ := it.(map[string]any)
|
||
flag := ""
|
||
if numberOf(m["unread"]) > 0 {
|
||
flag = fmt.Sprintf(" ★%d 未读", int(numberOf(m["unread"])))
|
||
}
|
||
// ★ 逐条把「这个工作区不该选」标出来(实测 222 条候选里 37 条
|
||
// 落在桥内部目录)。标注只在 paths[] 里的话,模型得自己把两个
|
||
// 数组对照才认得出 —— 那就是 §C 噪声换个位置复活。
|
||
warn := ""
|
||
if k := str(m, "path_kind"); k == "bridge-internal" {
|
||
warn = " ⚠ 桥内部目录,不是项目工作区"
|
||
} else if note := str(m, "path_note"); note != "" {
|
||
warn = " ⚠ " + note
|
||
}
|
||
fmt.Fprintf(&b, "· %s\n 工作区:%s%s\n 标题:%s%s\n",
|
||
str(m, "address"), str(m, "path"), warn,
|
||
firstNonEmpty(str(m, "title"), "(无标题)"), flag)
|
||
}
|
||
if paths, ok := payload["paths"].([]any); ok && len(paths) > 0 {
|
||
if notes := pathWarnings(paths); notes != "" {
|
||
b.WriteString("\n⚠ 工作区候选里的坑(别选它们):\n" + notes)
|
||
}
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
// path 形状:把「这不是工作区」与「这是相对路径」标出来。
|
||
if kind == "path" {
|
||
if paths, ok := payload["paths"].([]any); ok && len(paths) > 0 {
|
||
var b strings.Builder
|
||
b.WriteString("可选工作目录:\n")
|
||
for _, it := range paths {
|
||
m, _ := it.(map[string]any)
|
||
mark := ""
|
||
if str(m, "kind") == "bridge-internal" {
|
||
mark = " ⚠ 桥内部目录,不是项目工作区"
|
||
}
|
||
if note := str(m, "note"); note != "" && mark == "" {
|
||
mark = " ⚠ " + note
|
||
}
|
||
fmt.Fprintf(&b, "· %s%s\n", str(m, "path"), mark)
|
||
}
|
||
if notes := pathWarnings(paths); notes != "" {
|
||
b.WriteString("\n" + notes)
|
||
}
|
||
b.WriteString("\n不知道该用哪个 → 加 flatten=true 一次拿到该收件人的全部可投递地址。")
|
||
return b.String()
|
||
}
|
||
}
|
||
|
||
suggestions, _ := payload["suggestions"].([]any)
|
||
if len(suggestions) == 0 {
|
||
return fmt.Sprintf("(没有%s建议)", kind)
|
||
}
|
||
items := make([]string, 0, len(suggestions))
|
||
for _, s := range suggestions {
|
||
if str, ok := s.(string); ok {
|
||
items = append(items, str)
|
||
}
|
||
}
|
||
if len(items) == 0 {
|
||
return fmt.Sprintf("(没有%s建议)", kind)
|
||
}
|
||
return fmt.Sprintf("可选的%s:\n%s", kind, strings.Join(items, "\n"))
|
||
}
|
||
|
||
func summarizeContacts(payload map[string]any) string {
|
||
contacts, _ := payload["contacts"].([]any)
|
||
if len(contacts) == 0 {
|
||
return "没有参与过任何会话。用 suggest_address 查可投递地址。"
|
||
}
|
||
var b strings.Builder
|
||
fmt.Fprintf(&b, "共 %d 条会话:\n\n", len(contacts))
|
||
for _, it := range contacts {
|
||
c, _ := it.(map[string]any)
|
||
unread := numberOf(c["unread_count"])
|
||
flag := ""
|
||
if unread > 0 {
|
||
flag = fmt.Sprintf(" ★ %d 未读", unread)
|
||
}
|
||
fmt.Fprintf(&b, "· %s %s%s\n address: %s\n session_id: %s\n",
|
||
firstNonEmpty(str(c, "peer"), "(未知)"),
|
||
firstNonEmpty(str(c, "subject"), "(无主题)"),
|
||
flag,
|
||
firstNonEmpty(str(c, "address"), "(无可寻址地址)"),
|
||
str(c, "session_id"))
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
func summarizeParticipants(payload map[string]any) string {
|
||
parts, _ := payload["participants"].([]any)
|
||
if len(parts) == 0 {
|
||
return "会话里没有其他参与方。"
|
||
}
|
||
var b strings.Builder
|
||
b.WriteString("参与方:\n")
|
||
for _, it := range parts {
|
||
p, _ := it.(map[string]any)
|
||
flag := ""
|
||
if !boolOf(p["replied"]) {
|
||
flag = "(还没回应)"
|
||
}
|
||
fmt.Fprintf(&b, "· %s %s%s\n address: %s\n",
|
||
firstNonEmpty(str(p, "role"), "?"),
|
||
firstNonEmpty(str(p, "name"), "(未知)"),
|
||
flag,
|
||
firstNonEmpty(str(p, "address"), "(无)"))
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
// ---- 小工具 ----
|
||
|
||
func firstNonEmpty(vals ...string) string {
|
||
for _, v := range vals {
|
||
if v != "" {
|
||
return v
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func numberOf(v any) int {
|
||
switch t := v.(type) {
|
||
case float64:
|
||
return int(t)
|
||
case int:
|
||
return t
|
||
case json.Number:
|
||
n, _ := t.Int64()
|
||
return int(n)
|
||
}
|
||
return 0
|
||
}
|
||
|
||
func boolOf(v any) bool {
|
||
b, _ := v.(bool)
|
||
return b
|
||
}
|
||
|
||
func humanSize(n int) string {
|
||
switch {
|
||
case n <= 0:
|
||
return "0 B"
|
||
case n < 1024:
|
||
return fmt.Sprintf("%d B", n)
|
||
case n < 1024*1024:
|
||
return fmt.Sprintf("%.1f KB", float64(n)/1024)
|
||
default:
|
||
return fmt.Sprintf("%.1f MB", float64(n)/(1024*1024))
|
||
}
|
||
}
|
||
|
||
// agentNameOfRequest 供 handler 包在需要时取身份。
|
||
//
|
||
// 单独一个 invoke:DownloadAttachment 的响应体是**附件字节**而不是 JSON,
|
||
// 走通用的 invoke 会把二进制当 JSON 解析(静默得到 nil),
|
||
// 于是成功时也会报"解析不出 error"。所以这里自己收集。
|
||
func invokeDownload(ctx context.Context, target, id, savePath string) (int, map[string]any, string) {
|
||
// 路径参数 id 必须注入(DownloadAttachment 用 pathUUID → chi.URLParam)。
|
||
// 注入后**不要再** WithContext(ctx) —— 那会覆盖掉 RouteContext。
|
||
r := withRouteParams(newRequest(ctx, http.MethodGet, target, nil), map[string]string{"id": id})
|
||
rec := httptest.NewRecorder()
|
||
handler.DownloadAttachment(rec, r)
|
||
body := rec.Body.Bytes()
|
||
if rec.Code < 200 || rec.Code >= 300 {
|
||
var out map[string]any
|
||
_ = json.Unmarshal(body, &out)
|
||
return rec.Code, out, string(body)
|
||
}
|
||
// 落盘(建父目录)。写失败要**报错**而不是假装成功 ——
|
||
// 模型会以为文件在那儿,然后 upload_attachment 打不开它。
|
||
if dir := filepath.Dir(savePath); dir != "" && dir != "." {
|
||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||
return http.StatusInternalServerError,
|
||
map[string]any{"error": "建目录失败:" + err.Error()}, ""
|
||
}
|
||
}
|
||
if err := os.WriteFile(savePath, body, 0o644); err != nil {
|
||
return http.StatusInternalServerError,
|
||
map[string]any{"error": "写文件失败:" + err.Error()}, ""
|
||
}
|
||
return http.StatusOK, map[string]any{
|
||
"saved_path": savePath,
|
||
"size_bytes": len(body),
|
||
"description": fmt.Sprintf("已保存 %s(%s)", savePath, humanSize(len(body))),
|
||
}, ""
|
||
}
|
||
|
||
// pathWarnings 汇总「path 候选里哪些不该选」。
|
||
//
|
||
// 为什么值得单独做(2026-10-02 寻址报告 B/C 实测):64 条候选里 33 条是
|
||
// 桥的内部会话目录,且 root 与 /root 外观只差一个斜杠却是两个不同工作区。
|
||
// 混在列表里给模型挑,选中即**静默**投进错误线索(投递返回 200)。
|
||
func pathWarnings(paths []any) string {
|
||
var internal, relative []string
|
||
for _, it := range paths {
|
||
m, _ := it.(map[string]any)
|
||
if str(m, "kind") == "bridge-internal" {
|
||
internal = append(internal, str(m, "path"))
|
||
} else if ok, isBool := m["is_absolute"].(bool); isBool && !ok {
|
||
relative = append(relative, str(m, "path"))
|
||
}
|
||
}
|
||
var b strings.Builder
|
||
if len(internal) > 0 {
|
||
fmt.Fprintf(&b, "· %d 条是 Agent 桥的内部会话目录(投到那里会把会话的工作区变成它):%s\n",
|
||
len(internal), firstFew(internal, 3))
|
||
}
|
||
if len(relative) > 0 {
|
||
fmt.Fprintf(&b, "· %d 条是相对路径(与同名绝对路径是两个不同工作区):%s\n",
|
||
len(relative), firstFew(relative, 3))
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
func firstFew(items []string, n int) string {
|
||
if len(items) <= n {
|
||
return strings.Join(items, ", ")
|
||
}
|
||
return strings.Join(items[:n], ", ") + fmt.Sprintf(" …(共 %d 条)", len(items))
|
||
}
|