## 起因 DSH 侧 Agent 报了一份寻址缺口(2026-10-02,全部结论有 API 实测复现)。 三段式寻址 `name@path.session` 里 session 段是**人的寻址入口**,而枚举它 必须先知道 path —— 但 path 恰恰是调用方无从得知的: 给 name → 只给 path(要再调一次才知道有哪些会话) 给 name+path → 给会话别名(但 path 得先猜对) 于是一个闭合的环。报告实测的踩坑:投 `pi@root` 返回 **200**,落进一条标题 为「拓展坞实测硬件正常…」的无关会话 —— 投递成功,所以调用方不知道自己投错了。 ## 修法 **① A 项:`flatten=1` 一次给出全部可投递地址** `SuggestAddressesForPeer` + `suggest?name=&flatten=1`。每个候选自带 `path` 与可直接塞进 send_mail 的 `address` —— 调用方不必自己拼, 拼错就是那个「猜错比报错更糟」。 可见性口径**不放宽**,与原 name+path 那一支逐条一致(「我参与过 + 与该 name 匹配」)。报告本身也确认问题不在权限:同一批数据给了 path 就能列出 17 条。 按 path 分组平铺而非嵌套:嵌套时调用方要发一封「不知道在哪个 path」的信 仍得遍历全部组;平铺一次给全,模型不必做「先猜 path 再枚举」两步。 **② B/C 项:标注而非隐藏** `paths[]` 每项带 `kind`(workspace / bridge-internal)与 `is_absolute`。 选标注不选过滤的理由:桥内部目录(`/root/.pi/mail-sessions/<uuid>`) 确实**是某些会话的真实 cwd**(实测那条 workspace='root' 的会话 uuid 正是 其中之一)—— 滤掉等于让那些会话彻底不可见;而留着不标,64 条候选里 33 条 是噪声,模型选中即静默投错(实测 64 条中 33 条是它)。 `suggestions` 保持原样与原顺序 —— SuggestPaths 按最近使用倒序 (刚用过的那个几乎总是下一封想用的),排序被打乱等于让模型取最老的那个。 ## ★★ 顺带修掉一个生产级缺陷(实测撞出来的) 给 `SessionCandidate` 加 `LastActivity` 时用了: COALESCE(s.updated_at, '0001-01-01 00:00:00+00') COALESCE 让驱动返回 **string**,扫进 time.Time 报 `unsupported Scan` ⇒ 命中 `return out, err` ⇒ **整个候选列表变空**(实测一条都列不出)。 生产影响:`updated_at` 为 NULL 的历史会话会全部静默消失。 而那个错误信息里**没有任何线索**指向「是你加的 COALESCE 害的」—— 本次是我自己加的列触发的,排查花了几步。 改为扫进 `sql.NullTime`(NULL 即零值),平台镜像那条同理。 注释里写明为什么不能 COALESCE 兜底,免得下次有人再加回去。 ## MCP 侧同步 `suggest_address` 加 `flatten` 参数,且**渲染必须单独写**: flatten 的响应没有 `suggestions` 字段,走原来的分支只会回一句 「(没有 session_flat 建议)」—— 模型拿不到任何地址,等于白问一次。 path 形状的渲染把两类坑直接顶到眼前:桥内部目录、相对路径 (`root` 与 `/root` 在数据里是两个不同工作区,实测 1 条 vs 17 条)。 ## 判据(8 格) 含「address 必须与候选自身 path/alias 一致」(那正是静默投错的解药)、 「两个工作区都要出现」(原形状缺的就是这一维)、 「不带 flatten 时行为一字未变」(各桥与 WebUI 都走那一支)、 「flatten 不得把 new 混在候选里」(没有真实会话时它看起来像出路)。 **变异验证**: COALESCE 兜底(那个真 bug) → 红 1 ✓ flatten 段放回 path=="" 之后(顺序 bug)→ 红 1 ✓(kind 变回 "path") ## 实测校准了一处报告里的数字 报告写「近似写法返回 0 条」,实测返回 **1 条,内容是 `new`** —— 服务端在任何 path 下都追加的新建占位。所以选错 path 时调用方看到的不是 「空」,而是「只有 new 可选」:**看起来像一条出路**,于是顺着它新建, 恰好落进猜错的那个工作区。比报 0 更危险(0 会让人停下,new 会让人继续)。 §E 无需修:`validateSessionAlias` 已拒绝别名含 `.`。 全量 14 包绿。
462 lines
15 KiB
Go
462 lines
15 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)
|
||
}
|
||
if relay := str(payload, "duplicate_relay"); relay != "" {
|
||
fmt.Fprintf(&b, "。注意:%s", relay)
|
||
}
|
||
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"])))
|
||
}
|
||
fmt.Fprintf(&b, "· %s\n 工作区:%s\n 标题:%s%s\n",
|
||
str(m, "address"), str(m, "path"),
|
||
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))
|
||
}
|