[{"data":1,"prerenderedAt":13735},["ShallowReactive",2],{"post-dws-dingtalk-cli":3,"series-source-dws-dingtalk-cli":834},{"id":4,"title":5,"author":6,"body":7,"description":818,"difficulty":6,"draft":819,"extension":820,"image":821,"meta":822,"navigation":81,"path":823,"pinned":819,"published":824,"seo":825,"series":6,"seriesOrder":6,"stem":826,"tags":827,"updated":6,"__hash__":833},"posts\u002Fposts\u002Fdws-dingtalk-cli\u002Findex.md","DWS — 钉钉官方出品的 AI 友好命令行工具",null,{"type":8,"value":9,"toc":804},"minimark",[10,15,19,27,177,180,183,240,243,258,261,264,271,512,519,523,526,548,551,566,569,589,592,595,602,668,671,674,679,682,734,738,759,763,766,781,784,788,791,794,797,800],[11,12,14],"h2",{"id":13},"dws-是什么","dws 是什么",[16,17,18],"p",{},"dws（DingTalk Workspace CLI）是阿里巴巴 2026 年开源的钉钉命令行工具，Apache-2.0 协议，Go 语言编写。",[16,20,21,22,26],{},"它的定位很明确：",[23,24,25],"strong",{},"把钉钉的所有产品能力暴露给 AI Agent 和命令行用户","。不用打开钉钉 App，不用写 API 调用代码，一条命令搞定。",[28,29,34],"pre",{"className":30,"code":31,"language":32,"meta":33,"style":33},"language-bash shiki shiki-themes github-light github-dark","# 查看今天有什么会\ndws calendar event list --start 2026-06-01 --end 2026-06-01\n\n# 给群里发条消息\ndws chat send --group-id xxx --content \"今天发布推迟到下午3点\"\n\n# 创建一个待办\ndws todo task create --subject \"Review PR #123\" --due-date 2026-06-02\n\n# 读取钉钉文档\ndws doc read --url \"https:\u002F\u002Falidocs.dingtalk.com\u002Fi\u002Fnodes\u002Fxxx\"\n","bash","",[35,36,37,46,76,83,89,112,117,123,149,154,160],"code",{"__ignoreMap":33},[38,39,42],"span",{"class":40,"line":41},"line",1,[38,43,45],{"class":44},"sHbNN","# 查看今天有什么会\n",[38,47,49,53,57,60,63,67,70,73],{"class":40,"line":48},2,[38,50,52],{"class":51},"sw2iP","dws",[38,54,56],{"class":55},"scXbn"," calendar",[38,58,59],{"class":55}," event",[38,61,62],{"class":55}," list",[38,64,66],{"class":65},"suQ91"," --start",[38,68,69],{"class":55}," 2026-06-01",[38,71,72],{"class":65}," --end",[38,74,75],{"class":55}," 2026-06-01\n",[38,77,79],{"class":40,"line":78},3,[38,80,82],{"emptyLinePlaceholder":81},true,"\n",[38,84,86],{"class":40,"line":85},4,[38,87,88],{"class":44},"# 给群里发条消息\n",[38,90,92,94,97,100,103,106,109],{"class":40,"line":91},5,[38,93,52],{"class":51},[38,95,96],{"class":55}," chat",[38,98,99],{"class":55}," send",[38,101,102],{"class":65}," --group-id",[38,104,105],{"class":55}," xxx",[38,107,108],{"class":65}," --content",[38,110,111],{"class":55}," \"今天发布推迟到下午3点\"\n",[38,113,115],{"class":40,"line":114},6,[38,116,82],{"emptyLinePlaceholder":81},[38,118,120],{"class":40,"line":119},7,[38,121,122],{"class":44},"# 创建一个待办\n",[38,124,126,128,131,134,137,140,143,146],{"class":40,"line":125},8,[38,127,52],{"class":51},[38,129,130],{"class":55}," todo",[38,132,133],{"class":55}," task",[38,135,136],{"class":55}," create",[38,138,139],{"class":65}," --subject",[38,141,142],{"class":55}," \"Review PR #123\"",[38,144,145],{"class":65}," --due-date",[38,147,148],{"class":55}," 2026-06-02\n",[38,150,152],{"class":40,"line":151},9,[38,153,82],{"emptyLinePlaceholder":81},[38,155,157],{"class":40,"line":156},10,[38,158,159],{"class":44},"# 读取钉钉文档\n",[38,161,163,165,168,171,174],{"class":40,"line":162},11,[38,164,52],{"class":51},[38,166,167],{"class":55}," doc",[38,169,170],{"class":55}," read",[38,172,173],{"class":65}," --url",[38,175,176],{"class":55}," \"https:\u002F\u002Falidocs.dingtalk.com\u002Fi\u002Fnodes\u002Fxxx\"\n",[11,178,179],{"id":179},"安装",[16,181,182],{},"三种方式，选你喜欢的：",[28,184,186],{"className":30,"code":185,"language":32,"meta":33,"style":33},"# npm（推荐，跨平台自动下载二进制）\nnpm install -g dingtalk-workspace-cli\n\n# Homebrew（macOS\u002FLinux）\nbrew install dws\n\n# 直接下载二进制\n# https:\u002F\u002Fgithub.com\u002Fopen-dingtalk\u002Fdingtalk-workspace-cli\u002Freleases\n",[35,187,188,193,207,211,216,226,230,235],{"__ignoreMap":33},[38,189,190],{"class":40,"line":41},[38,191,192],{"class":44},"# npm（推荐，跨平台自动下载二进制）\n",[38,194,195,198,201,204],{"class":40,"line":48},[38,196,197],{"class":51},"npm",[38,199,200],{"class":55}," install",[38,202,203],{"class":65}," -g",[38,205,206],{"class":55}," dingtalk-workspace-cli\n",[38,208,209],{"class":40,"line":78},[38,210,82],{"emptyLinePlaceholder":81},[38,212,213],{"class":40,"line":85},[38,214,215],{"class":44},"# Homebrew（macOS\u002FLinux）\n",[38,217,218,221,223],{"class":40,"line":91},[38,219,220],{"class":51},"brew",[38,222,200],{"class":55},[38,224,225],{"class":55}," dws\n",[38,227,228],{"class":40,"line":114},[38,229,82],{"emptyLinePlaceholder":81},[38,231,232],{"class":40,"line":119},[38,233,234],{"class":44},"# 直接下载二进制\n",[38,236,237],{"class":40,"line":125},[38,238,239],{"class":44},"# https:\u002F\u002Fgithub.com\u002Fopen-dingtalk\u002Fdingtalk-workspace-cli\u002Freleases\n",[16,241,242],{},"安装后认证：",[28,244,246],{"className":30,"code":245,"language":32,"meta":33,"style":33},"dws auth login\n",[35,247,248],{"__ignoreMap":33},[38,249,250,252,255],{"class":40,"line":41},[38,251,52],{"class":51},[38,253,254],{"class":55}," auth",[38,256,257],{"class":55}," login\n",[16,259,260],{},"会打开浏览器走 OAuth 设备流认证，token 加密存储在系统钥匙串里（PBKDF2 + AES-256-GCM）。",[11,262,263],{"id":263},"覆盖的钉钉产品",[16,265,266,267,270],{},"这是最夸张的部分 — ",[23,268,269],{},"19 个产品，213 个命令","，基本把钉钉全家桶都搬到了命令行：",[272,273,274,290],"table",{},[275,276,277],"thead",{},[278,279,280,284,287],"tr",{},[281,282,283],"th",{},"产品",[281,285,286],{},"命令数",[281,288,289],{},"说明",[291,292,293,308,322,336,350,364,378,392,405,419,432,446,460,473,487,501],"tbody",{},[278,294,295,302,305],{},[296,297,298,299],"td",{},"聊天 ",[35,300,301],{},"chat",[296,303,304],{},"57",[296,306,307],{},"消息收发、群管理、机器人、已读状态、Webhook",[278,309,310,316,319],{},[296,311,312,313],{},"AI 表格 ",[35,314,315],{},"aitable",[296,317,318],{},"42",[296,320,321],{},"Base\u002F数据表\u002F字段\u002F记录\u002F视图\u002F图表\u002F仪表盘，完整 CRUD",[278,323,324,330,333],{},[296,325,326,327],{},"电子表格 ",[35,328,329],{},"sheet",[296,331,332],{},"34",[296,334,335],{},"工作表读写、行列操作、合并、筛选视图、导出",[278,337,338,344,347],{},[296,339,340,341],{},"文档 ",[35,342,343],{},"doc",[296,345,346],{},"21",[296,348,349],{},"搜索、读写、块级编辑、评论、复制移动",[278,351,352,358,361],{},[296,353,354,355],{},"会议纪要 ",[35,356,357],{},"minutes",[296,359,360],{},"19",[296,362,363],{},"列表、摘要、转写、思维导图、发言人、热词",[278,365,366,372,375],{},[296,367,368,369],{},"日历 ",[35,370,371],{},"calendar",[296,373,374],{},"14",[296,376,377],{},"事件 CRUD、会议室、忙闲查询、时间建议",[278,379,380,386,389],{},[296,381,382,383],{},"审批 ",[35,384,385],{},"oa",[296,387,388],{},"9",[296,390,391],{},"待办、发起、同意\u002F拒绝\u002F撤销",[278,393,394,400,402],{},[296,395,396,397],{},"云盘 ",[35,398,399],{},"drive",[296,401,388],{},[296,403,404],{},"列表、下载、上传、创建目录",[278,406,407,413,416],{},[296,408,409,410],{},"知识库 ",[35,411,412],{},"wiki",[296,414,415],{},"7",[296,417,418],{},"空间管理、成员管理",[278,420,421,427,429],{},[296,422,423,424],{},"汇报 ",[35,425,426],{},"report",[296,428,415],{},[296,430,431],{},"日志\u002F日报\u002F周报，按模板创建、统计",[278,433,434,440,443],{},[296,435,436,437],{},"待办 ",[35,438,439],{},"todo",[296,441,442],{},"6",[296,444,445],{},"创建、完成、删除，支持优先级和循环",[278,447,448,454,457],{},[296,449,450,451],{},"邮件 ",[35,452,453],{},"mail",[296,455,456],{},"4",[296,458,459],{},"邮箱查询、邮件搜索、发送",[278,461,462,468,470],{},[296,463,464,465],{},"考勤 ",[35,466,467],{},"attendance",[296,469,456],{},[296,471,472],{},"打卡记录、排班、汇总",[278,474,475,481,484],{},[296,476,477,478],{},"通讯录 ",[35,479,480],{},"contact",[296,482,483],{},"3",[296,485,486],{},"用户搜索、部门查询",[278,488,489,495,498],{},[296,490,491,492],{},"DING 消息 ",[35,493,494],{},"ding",[296,496,497],{},"2",[296,499,500],{},"发送\u002F撤回（应用内、短信、电话）",[278,502,503,506,509],{},[296,504,505],{},"其他",[296,507,508],{},"若干",[296,510,511],{},"AI 搜索、AI 应用、直播、开放平台文档",[16,513,514,515,518],{},"还有一个万能命令 ",[35,516,517],{},"dws api","，可以直接调用任意钉钉 OpenAPI，不受这 213 个命令的限制。",[11,520,522],{"id":521},"agent-skill-系统","Agent Skill 系统",[16,524,525],{},"dws 内置了一套完整的 Agent Skill，可以直接被 AI 编程工具发现和使用：",[527,528,529,536,542],"ul",{},[530,531,532,535],"li",{},[23,533,534],{},"Claude Code"," — 通过 SKILL.md 自动加载",[530,537,538,541],{},[23,539,540],{},"Cursor"," — 支持 MCP 协议接入",[530,543,544,547],{},[23,545,546],{},"Codex \u002F OpenCode \u002F Qoder"," — 通过 skill 安装命令接入",[16,549,550],{},"安装 skill：",[28,552,554],{"className":30,"code":553,"language":32,"meta":33,"style":33},"dws skill setup\n",[35,555,556],{"__ignoreMap":33},[38,557,558,560,563],{"class":40,"line":41},[38,559,52],{"class":51},[38,561,562],{"class":55}," skill",[38,564,565],{"class":55}," setup\n",[16,567,568],{},"SKILL.md 里定义了：",[527,570,571,580,583,586],{},[530,572,573,574,576,577,579],{},"16 个产品的意图判断决策树（用户说\"表格\"→ ",[35,575,315],{},"，说\"打卡\"→ ",[35,578,467],{},"）",[530,581,582],{},"危险操作确认流程（删除、撤回等必须先确认）",[530,584,585],{},"错误处理规范（verbose 重试、recovery 事件 ID）",[530,587,588],{},"URL 分流规则（alidocs.dingtalk.com 的多种路径格式自动识别）",[16,590,591],{},"Agent 读到这份 SKILL.md 就知道什么时候该调什么命令，不需要额外的 prompt 工程。",[11,593,594],{"id":594},"自动升级",[16,596,597,598,601],{},"内置 ",[35,599,600],{},"dws upgrade","，不需要重新下载安装包：",[28,603,605],{"className":30,"code":604,"language":32,"meta":33,"style":33},"dws upgrade              # 交互式升级到最新版\ndws upgrade --check      # 仅检查是否有新版本\ndws upgrade --list       # 列出最近版本\ndws upgrade --version v1.0.5  # 升级到指定版本\ndws upgrade --rollback   # 回滚到上一版本\n",[35,606,607,617,629,641,656],{"__ignoreMap":33},[38,608,609,611,614],{"class":40,"line":41},[38,610,52],{"class":51},[38,612,613],{"class":55}," upgrade",[38,615,616],{"class":44},"              # 交互式升级到最新版\n",[38,618,619,621,623,626],{"class":40,"line":48},[38,620,52],{"class":51},[38,622,613],{"class":55},[38,624,625],{"class":65}," --check",[38,627,628],{"class":44},"      # 仅检查是否有新版本\n",[38,630,631,633,635,638],{"class":40,"line":78},[38,632,52],{"class":51},[38,634,613],{"class":55},[38,636,637],{"class":65}," --list",[38,639,640],{"class":44},"       # 列出最近版本\n",[38,642,643,645,647,650,653],{"class":40,"line":85},[38,644,52],{"class":51},[38,646,613],{"class":55},[38,648,649],{"class":65}," --version",[38,651,652],{"class":55}," v1.0.5",[38,654,655],{"class":44},"  # 升级到指定版本\n",[38,657,658,660,662,665],{"class":40,"line":91},[38,659,52],{"class":51},[38,661,613],{"class":55},[38,663,664],{"class":65}," --rollback",[38,666,667],{"class":44},"   # 回滚到上一版本\n",[16,669,670],{},"升级流程：下载 → SHA256 校验 → 备份当前版本 → 原子替换 → 更新 Skill。失败可一键回滚。",[11,672,673],{"id":673},"亮点",[675,676,678],"h3",{"id":677},"_1-为-ai-agent-专门优化","1. 为 AI Agent 专门优化",[16,680,681],{},"这可能是目前对 AI 最友好的 CLI 工具：",[527,683,684,705,715,724],{},[530,685,686,689,690,693,694,697,698,693,701,704],{},[23,687,688],{},"智能纠错"," — AI 模型经常生成错误的参数格式（",[35,691,692],{},"--userId"," 应该是 ",[35,695,696],{},"--user-id","，",[35,699,700],{},"--limit100",[35,702,703],{},"--limit 100","），dws 的 5 阶段 Pipeline 会自动修正",[530,706,707,710,711,714],{},[23,708,709],{},"Schema 发现"," — ",[35,712,713],{},"dws schema"," 让 Agent 动态查询可用工具和参数，不需要硬编码",[530,716,717,710,720,723],{},[23,718,719],{},"jq 过滤",[35,721,722],{},"--jq '.data[] | {name, id}'"," 直接过滤输出，减少 Agent 消耗的 token",[530,725,726,729,730,733],{},[23,727,728],{},"JSON 输出"," — 所有命令支持 ",[35,731,732],{},"--format json","，方便程序解析",[675,735,737],{"id":736},"_2-安全设计","2. 安全设计",[527,739,740,743,746,749,756],{},[530,741,742],{},"token 用 PBKDF2 + AES-256-GCM 加密，密钥绑定设备 MAC 地址",[530,744,745],{},"域名白名单，只能访问钉钉官方 API",[530,747,748],{},"强制 HTTPS",[530,750,751,752,755],{},"写操作必须显式 ",[35,753,754],{},"--yes"," 确认",[530,757,758],{},"完整审计链",[675,760,762],{"id":761},"_3-发现驱动架构","3. 发现驱动架构",[16,764,765],{},"不是把所有命令硬编码在代码里，而是：",[767,768,769,772,775,778],"ol",{},[530,770,771],{},"从钉钉 MCP 服务器注册中心拉取元数据",[530,773,774],{},"运行时发现服务端能力",[530,776,777],{},"转换成统一的中间表示（IR）",[530,779,780],{},"动态挂载到命令树",[16,782,783],{},"这意味着钉钉新增产品能力时，dws 不需要发版就能支持。",[675,785,787],{"id":786},"_4-多平台分发","4. 多平台分发",[16,789,790],{},"GoReleaser 交叉编译 + Homebrew formula + npm 包 + shell\u002FPowerShell 安装脚本，覆盖 Windows、macOS、Linux。",[11,792,793],{"id":793},"总结",[16,795,796],{},"dws 把钉钉从一个\"必须打开 App 才能用\"的工具变成了一个\"命令行 + AI Agent 随时调用\"的平台。213 个命令覆盖日常办公的方方面面，Agent Skill 系统让 AI 编程工具可以直接理解和使用这些能力。",[16,798,799],{},"如果你是钉钉重度用户，或者在做企业内部的 AI Agent 集成，dws 值得一试。",[801,802,803],"style",{},"html pre.shiki code .sHbNN, html code.shiki .sHbNN{--shiki-light:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .sw2iP, html code.shiki .sw2iP{--shiki-light:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .scXbn, html code.shiki .scXbn{--shiki-light:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .suQ91, html code.shiki .suQ91{--shiki-light:#005CC5;--shiki-dark:#79B8FF}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":33,"searchDepth":48,"depth":78,"links":805},[806,807,808,809,810,811,817],{"id":13,"depth":48,"text":14},{"id":179,"depth":48,"text":179},{"id":263,"depth":48,"text":263},{"id":521,"depth":48,"text":522},{"id":594,"depth":48,"text":594},{"id":673,"depth":48,"text":673,"children":812},[813,814,815,816],{"id":677,"depth":78,"text":678},{"id":736,"depth":78,"text":737},{"id":761,"depth":78,"text":762},{"id":786,"depth":78,"text":787},{"id":793,"depth":48,"text":793},"介绍阿里巴巴官方的 DingTalk Workspace CLI（dws），一个覆盖钉钉 19 个产品、213 个命令的 AI 友好命令行工具，支持自动升级和 Agent Skill 系统。",false,"md","\u002Fposts\u002Fdws-dingtalk-cli\u002Fimg\u002Fcover.svg",{},"\u002Fposts\u002Fdws-dingtalk-cli","2026-06-01T00:51:00",{"title":5,"description":818},"posts\u002Fdws-dingtalk-cli\u002Findex",[828,829,830,831,832],"钉钉","CLI","AI Agent","Go","效率工具","4ca65VUZ2Sz9yG8dNtftrBDDHE6QowA3ed9g4egQkKI",[835,1650,2070,2294,2711,3900,4179,6246,6812,7791,9200,9760,10939,12475],{"id":836,"title":837,"author":6,"body":838,"description":1637,"difficulty":1638,"draft":819,"extension":820,"image":1639,"meta":1640,"navigation":81,"path":1641,"pinned":819,"published":1642,"seo":1643,"series":1644,"seriesOrder":41,"stem":1645,"tags":1646,"updated":1642,"__hash__":1649},"posts\u002Fposts\u002Fdocker-musl-build\u002Findex.md","Docker 编译 musl 可执行文件",{"type":8,"value":839,"toc":1626},[840,843,846,849,1082,1085,1127,1130,1148,1151,1302,1305,1441,1444,1447,1505,1508,1623],[11,841,842],{"id":842},"解决问题",[16,844,845],{},"用 Go 进行跨平台编译时往往步骤复杂，用 Docker 可以轻松解决。但 Docker 的当前最小镜像 Alpine 其动态链接库采用 musl，与主流的 glibc 不同，往往不能方便执行。",[11,847,848],{"id":848},"查阅资料",[28,850,852],{"className":30,"code":851,"language":32,"meta":33,"style":33},"# 启动容器并挂载当前目录到 `\u002Fapp`\ndocker run -it --rm -v \"$PWD\":\u002Fapp -w \u002Fapp ubuntu:20.04 bash\n\n# 在容器内执行以下操作：\napt-get update && apt-get install -y musl-tools golang-go  # 安装 musl 和 Go\nexport CC=musl-gcc  # 指定 musl 编译器\nexport CGO_ENABLED=1  # 启用 CGO\n# 设置代理\ngo env -w GOPROXY=https:\u002F\u002Fgoproxy.cn,direct\ngo build -ldflags '-linkmode external -extldflags \"-static\"' -o myapp  # 静态编译\nexit  # 退出容器\n\n# 验证结果（在宿主机执行）\nfile myapp  # 应显示 \"statically linked\"\nldd myapp   # 应显示 \"not a dynamic executable\"\n\nwget https:\u002F\u002Fgo.dev\u002Fdl\u002Fgo1.21.linux-amd64.tar.gz\n\nENV PATH=\"\u002Fdata\u002Fgo\u002Fbin:$PATH\"\n",[35,853,854,859,898,902,907,934,952,967,972,985,1007,1015,1020,1026,1037,1048,1053,1062,1067],{"__ignoreMap":33},[38,855,856],{"class":40,"line":41},[38,857,858],{"class":44},"# 启动容器并挂载当前目录到 `\u002Fapp`\n",[38,860,861,864,867,870,873,876,879,883,886,889,892,895],{"class":40,"line":48},[38,862,863],{"class":51},"docker",[38,865,866],{"class":55}," run",[38,868,869],{"class":65}," -it",[38,871,872],{"class":65}," --rm",[38,874,875],{"class":65}," -v",[38,877,878],{"class":55}," \"",[38,880,882],{"class":881},"sIX_F","$PWD",[38,884,885],{"class":55},"\":\u002Fapp",[38,887,888],{"class":65}," -w",[38,890,891],{"class":55}," \u002Fapp",[38,893,894],{"class":55}," ubuntu:20.04",[38,896,897],{"class":55}," bash\n",[38,899,900],{"class":40,"line":78},[38,901,82],{"emptyLinePlaceholder":81},[38,903,904],{"class":40,"line":85},[38,905,906],{"class":44},"# 在容器内执行以下操作：\n",[38,908,909,912,915,918,920,922,925,928,931],{"class":40,"line":91},[38,910,911],{"class":51},"apt-get",[38,913,914],{"class":55}," update",[38,916,917],{"class":881}," && ",[38,919,911],{"class":51},[38,921,200],{"class":55},[38,923,924],{"class":65}," -y",[38,926,927],{"class":55}," musl-tools",[38,929,930],{"class":55}," golang-go",[38,932,933],{"class":44},"  # 安装 musl 和 Go\n",[38,935,936,940,943,946,949],{"class":40,"line":114},[38,937,939],{"class":938},"siTax","export",[38,941,942],{"class":881}," CC",[38,944,945],{"class":938},"=",[38,947,948],{"class":881},"musl-gcc  ",[38,950,951],{"class":44},"# 指定 musl 编译器\n",[38,953,954,956,959,961,964],{"class":40,"line":119},[38,955,939],{"class":938},[38,957,958],{"class":881}," CGO_ENABLED",[38,960,945],{"class":938},[38,962,963],{"class":65},"1",[38,965,966],{"class":44},"  # 启用 CGO\n",[38,968,969],{"class":40,"line":125},[38,970,971],{"class":44},"# 设置代理\n",[38,973,974,977,980,982],{"class":40,"line":151},[38,975,976],{"class":51},"go",[38,978,979],{"class":55}," env",[38,981,888],{"class":65},[38,983,984],{"class":55}," GOPROXY=https:\u002F\u002Fgoproxy.cn,direct\n",[38,986,987,989,992,995,998,1001,1004],{"class":40,"line":156},[38,988,976],{"class":51},[38,990,991],{"class":55}," build",[38,993,994],{"class":65}," -ldflags",[38,996,997],{"class":55}," '-linkmode external -extldflags \"-static\"'",[38,999,1000],{"class":65}," -o",[38,1002,1003],{"class":55}," myapp",[38,1005,1006],{"class":44},"  # 静态编译\n",[38,1008,1009,1012],{"class":40,"line":162},[38,1010,1011],{"class":65},"exit",[38,1013,1014],{"class":44},"  # 退出容器\n",[38,1016,1018],{"class":40,"line":1017},12,[38,1019,82],{"emptyLinePlaceholder":81},[38,1021,1023],{"class":40,"line":1022},13,[38,1024,1025],{"class":44},"# 验证结果（在宿主机执行）\n",[38,1027,1029,1032,1034],{"class":40,"line":1028},14,[38,1030,1031],{"class":51},"file",[38,1033,1003],{"class":55},[38,1035,1036],{"class":44},"  # 应显示 \"statically linked\"\n",[38,1038,1040,1043,1045],{"class":40,"line":1039},15,[38,1041,1042],{"class":51},"ldd",[38,1044,1003],{"class":55},[38,1046,1047],{"class":44},"   # 应显示 \"not a dynamic executable\"\n",[38,1049,1051],{"class":40,"line":1050},16,[38,1052,82],{"emptyLinePlaceholder":81},[38,1054,1056,1059],{"class":40,"line":1055},17,[38,1057,1058],{"class":51},"wget",[38,1060,1061],{"class":55}," https:\u002F\u002Fgo.dev\u002Fdl\u002Fgo1.21.linux-amd64.tar.gz\n",[38,1063,1065],{"class":40,"line":1064},18,[38,1066,82],{"emptyLinePlaceholder":81},[38,1068,1070,1073,1076,1079],{"class":40,"line":1069},19,[38,1071,1072],{"class":51},"ENV",[38,1074,1075],{"class":55}," PATH=\"\u002Fdata\u002Fgo\u002Fbin:",[38,1077,1078],{"class":881},"$PATH",[38,1080,1081],{"class":55},"\"\n",[675,1083,1084],{"id":1084},"遇到问题",[767,1086,1087,1113],{},[530,1088,1089,1092,1093,1096,1099,1100,1103,1105,1106,1108],{},[35,1090,1091],{},"go mod tidy"," 时报错 ",[35,1094,1095],{},"tls: failed to verify certificate: x509: certificate",[1097,1098],"br",{},"解决：",[35,1101,1102],{},"RUN apt-get update && apt-get install -y ca-certificates && rm -rf \u002Fvar\u002Flib\u002Fapt\u002Flists\u002F*",[1097,1104],{},"基础镜像的证书未更新，导致 https 的请求出现类似错误。",[1097,1107],{},[1109,1110],"img",{"alt":1111,"src":1112},"image-20250327100813512","\u002Fposts\u002Fdocker-musl-build\u002Fimg\u002Fimage-20250327100813512.png",[530,1114,1115,1116,1119,1120,1122,1123,1126],{},"直接安装 ",[35,1117,1118],{},"golang-go"," 其版本过低",[1097,1121],{},"解决: ",[35,1124,1125],{},"PATH=\"\u002Fdata\u002Fgo\u002Fbin:$PATH\""," 手动指定环境",[11,1128,1129],{"id":1129},"最终方案",[767,1131,1132,1139,1142],{},[530,1133,1134,1135,1138],{},"下载 ",[35,1136,1137],{},"go1.24.1.linux-amd64.tar.gz"," 解压",[530,1140,1141],{},"编写 Dockerfile",[530,1143,1144,1145],{},"执行 ",[35,1146,1147],{},"docker build -t go-build:v1 .",[16,1149,1150],{},"Dockerfile 内容：",[28,1152,1156],{"className":1153,"code":1154,"language":1155,"meta":33,"style":33},"language-dockerfile shiki shiki-themes github-light github-dark","FROM ubuntu:20.04\n\nLABEL org.opencontainers.image.authors=\"sutong\"\n\nCOPY go\u002F \u002Fgo\u002F\n\nENV PATH=\"\u002Fgo\u002Fbin:$PATH\"\n\nRUN apt-get update && apt-get install -y musl-tools\n\n## go mod tidy 时报错 tls: failed to verify certificate: x509: certificate\nRUN apt-get update && apt-get install -y ca-certificates && rm -rf \u002Fvar\u002Flib\u002Fapt\u002Flists\u002F*\n# 指定 musl 编译器\nENV CC=musl-gcc\n# 启用 CGO\nENV CGO_ENABLED=1\n\nVOLUME \u002Fdata\n\nWORKDIR \u002Fdata\n\nCMD [\"\u002Fbin\u002Fbash\"]\n","dockerfile",[35,1157,1158,1166,1170,1181,1185,1193,1197,1207,1211,1219,1223,1228,1235,1239,1246,1251,1258,1262,1270,1274,1282,1287],{"__ignoreMap":33},[38,1159,1160,1163],{"class":40,"line":41},[38,1161,1162],{"class":938},"FROM",[38,1164,1165],{"class":881}," ubuntu:20.04\n",[38,1167,1168],{"class":40,"line":48},[38,1169,82],{"emptyLinePlaceholder":81},[38,1171,1172,1175,1178],{"class":40,"line":78},[38,1173,1174],{"class":938},"LABEL",[38,1176,1177],{"class":881}," org.opencontainers.image.authors=",[38,1179,1180],{"class":55},"\"sutong\"\n",[38,1182,1183],{"class":40,"line":85},[38,1184,82],{"emptyLinePlaceholder":81},[38,1186,1187,1190],{"class":40,"line":91},[38,1188,1189],{"class":938},"COPY",[38,1191,1192],{"class":881}," go\u002F \u002Fgo\u002F\n",[38,1194,1195],{"class":40,"line":114},[38,1196,82],{"emptyLinePlaceholder":81},[38,1198,1199,1201,1204],{"class":40,"line":119},[38,1200,1072],{"class":938},[38,1202,1203],{"class":881}," PATH=",[38,1205,1206],{"class":55},"\"\u002Fgo\u002Fbin:$PATH\"\n",[38,1208,1209],{"class":40,"line":125},[38,1210,82],{"emptyLinePlaceholder":81},[38,1212,1213,1216],{"class":40,"line":151},[38,1214,1215],{"class":938},"RUN",[38,1217,1218],{"class":881}," apt-get update && apt-get install -y musl-tools\n",[38,1220,1221],{"class":40,"line":156},[38,1222,82],{"emptyLinePlaceholder":81},[38,1224,1225],{"class":40,"line":162},[38,1226,1227],{"class":44},"## go mod tidy 时报错 tls: failed to verify certificate: x509: certificate\n",[38,1229,1230,1232],{"class":40,"line":1017},[38,1231,1215],{"class":938},[38,1233,1234],{"class":881}," apt-get update && apt-get install -y ca-certificates && rm -rf \u002Fvar\u002Flib\u002Fapt\u002Flists\u002F*\n",[38,1236,1237],{"class":40,"line":1022},[38,1238,951],{"class":44},[38,1240,1241,1243],{"class":40,"line":1028},[38,1242,1072],{"class":938},[38,1244,1245],{"class":881}," CC=musl-gcc\n",[38,1247,1248],{"class":40,"line":1039},[38,1249,1250],{"class":44},"# 启用 CGO\n",[38,1252,1253,1255],{"class":40,"line":1050},[38,1254,1072],{"class":938},[38,1256,1257],{"class":881}," CGO_ENABLED=1\n",[38,1259,1260],{"class":40,"line":1055},[38,1261,82],{"emptyLinePlaceholder":81},[38,1263,1264,1267],{"class":40,"line":1064},[38,1265,1266],{"class":938},"VOLUME",[38,1268,1269],{"class":881}," \u002Fdata\n",[38,1271,1272],{"class":40,"line":1069},[38,1273,82],{"emptyLinePlaceholder":81},[38,1275,1277,1280],{"class":40,"line":1276},20,[38,1278,1279],{"class":938},"WORKDIR",[38,1281,1269],{"class":881},[38,1283,1285],{"class":40,"line":1284},21,[38,1286,82],{"emptyLinePlaceholder":81},[38,1288,1290,1293,1296,1299],{"class":40,"line":1289},22,[38,1291,1292],{"class":938},"CMD",[38,1294,1295],{"class":881}," [",[38,1297,1298],{"class":55},"\"\u002Fbin\u002Fbash\"",[38,1300,1301],{"class":881},"]\n",[675,1303,1304],{"id":1304},"构建",[28,1306,1308],{"className":30,"code":1307,"language":32,"meta":33,"style":33},"# docker build -t go-build:v1 .\n\n# 直接执行编译 编译后删除\ndocker run --rm -v .:\u002Fdata go-build:v1 go build -o app_musl\n\n# 构建编译环境\ndocker run -it -d -v .:\u002Fdata --name \u003C容器名称> go-build:v1 \u002Fbin\u002Fbash\n# eg：docker run -it -d -v .:\u002Fdata --name logbus-build go-build:v1 \u002Fbin\u002Fbash\n\ndocker exec -it \u003C容器名称> go build -o \u003Cmyapp>\n# eg：docker exec -it logbus-build go build -o logbus_musl_1\n",[35,1309,1310,1315,1319,1324,1350,1354,1359,1394,1399,1403,1436],{"__ignoreMap":33},[38,1311,1312],{"class":40,"line":41},[38,1313,1314],{"class":44},"# docker build -t go-build:v1 .\n",[38,1316,1317],{"class":40,"line":48},[38,1318,82],{"emptyLinePlaceholder":81},[38,1320,1321],{"class":40,"line":78},[38,1322,1323],{"class":44},"# 直接执行编译 编译后删除\n",[38,1325,1326,1328,1330,1332,1334,1337,1340,1343,1345,1347],{"class":40,"line":85},[38,1327,863],{"class":51},[38,1329,866],{"class":55},[38,1331,872],{"class":65},[38,1333,875],{"class":65},[38,1335,1336],{"class":55}," .:\u002Fdata",[38,1338,1339],{"class":55}," go-build:v1",[38,1341,1342],{"class":55}," go",[38,1344,991],{"class":55},[38,1346,1000],{"class":65},[38,1348,1349],{"class":55}," app_musl\n",[38,1351,1352],{"class":40,"line":91},[38,1353,82],{"emptyLinePlaceholder":81},[38,1355,1356],{"class":40,"line":114},[38,1357,1358],{"class":44},"# 构建编译环境\n",[38,1360,1361,1363,1365,1367,1370,1372,1374,1377,1380,1383,1386,1389,1391],{"class":40,"line":119},[38,1362,863],{"class":51},[38,1364,866],{"class":55},[38,1366,869],{"class":65},[38,1368,1369],{"class":65}," -d",[38,1371,875],{"class":65},[38,1373,1336],{"class":55},[38,1375,1376],{"class":65}," --name",[38,1378,1379],{"class":938}," \u003C",[38,1381,1382],{"class":55},"容器名",[38,1384,1385],{"class":881},"称",[38,1387,1388],{"class":938},">",[38,1390,1339],{"class":55},[38,1392,1393],{"class":55}," \u002Fbin\u002Fbash\n",[38,1395,1396],{"class":40,"line":125},[38,1397,1398],{"class":44},"# eg：docker run -it -d -v .:\u002Fdata --name logbus-build go-build:v1 \u002Fbin\u002Fbash\n",[38,1400,1401],{"class":40,"line":151},[38,1402,82],{"emptyLinePlaceholder":81},[38,1404,1405,1407,1410,1412,1414,1416,1418,1420,1422,1424,1426,1428,1431,1433],{"class":40,"line":156},[38,1406,863],{"class":51},[38,1408,1409],{"class":55}," exec",[38,1411,869],{"class":65},[38,1413,1379],{"class":938},[38,1415,1382],{"class":55},[38,1417,1385],{"class":881},[38,1419,1388],{"class":938},[38,1421,1342],{"class":55},[38,1423,991],{"class":55},[38,1425,1000],{"class":65},[38,1427,1379],{"class":938},[38,1429,1430],{"class":55},"myap",[38,1432,16],{"class":881},[38,1434,1435],{"class":938},">\n",[38,1437,1438],{"class":40,"line":162},[38,1439,1440],{"class":44},"# eg：docker exec -it logbus-build go build -o logbus_musl_1\n",[675,1442,1443],{"id":1443},"额外注意",[16,1445,1446],{},"若没科学上网需要设置代理：",[28,1448,1450],{"className":30,"code":1449,"language":32,"meta":33,"style":33},"docker exec -it \u003C容器名称> \u002Fbin\u002Fbash\ngo env -w GOPROXY=https:\u002F\u002Fgoproxy.cn,direct\ngo build -ldflags '-linkmode external -extldflags \"-static\"' -o myapp  # 静态编译\ngo build # musl 库编译\n",[35,1451,1452,1470,1480,1496],{"__ignoreMap":33},[38,1453,1454,1456,1458,1460,1462,1464,1466,1468],{"class":40,"line":41},[38,1455,863],{"class":51},[38,1457,1409],{"class":55},[38,1459,869],{"class":65},[38,1461,1379],{"class":938},[38,1463,1382],{"class":55},[38,1465,1385],{"class":881},[38,1467,1388],{"class":938},[38,1469,1393],{"class":55},[38,1471,1472,1474,1476,1478],{"class":40,"line":48},[38,1473,976],{"class":51},[38,1475,979],{"class":55},[38,1477,888],{"class":65},[38,1479,984],{"class":55},[38,1481,1482,1484,1486,1488,1490,1492,1494],{"class":40,"line":78},[38,1483,976],{"class":51},[38,1485,991],{"class":55},[38,1487,994],{"class":65},[38,1489,997],{"class":55},[38,1491,1000],{"class":65},[38,1493,1003],{"class":55},[38,1495,1006],{"class":44},[38,1497,1498,1500,1502],{"class":40,"line":85},[38,1499,976],{"class":51},[38,1501,991],{"class":55},[38,1503,1504],{"class":44}," # musl 库编译\n",[675,1506,1507],{"id":1507},"运行环境示例",[28,1509,1511],{"className":1153,"code":1510,"language":1155,"meta":33,"style":33},"FROM alpine:latest\n\nLABEL org.opencontainers.image.authors=\"sutong\"\n\nCOPY glog_musl \u002Fapp\u002F\n\nCOPY conf.ini \u002Fdata\u002F\nCOPY ipname.ini \u002Fdata\u002F\n\nWORKDIR \u002Fapp\n\nRUN chmod 777 \u002Fapp\u002Fglog_musl\n\nCMD [\".\u002Fglog_musl\", \"logsweb\", \"\u002Fdata\u002Fconf.ini\"]\n\n# docker build -t glog:v1 .\n# docker run -p 6801:6801 glog:v1\n",[35,1512,1513,1520,1524,1532,1536,1543,1547,1554,1561,1565,1572,1576,1583,1587,1609,1613,1618],{"__ignoreMap":33},[38,1514,1515,1517],{"class":40,"line":41},[38,1516,1162],{"class":938},[38,1518,1519],{"class":881}," alpine:latest\n",[38,1521,1522],{"class":40,"line":48},[38,1523,82],{"emptyLinePlaceholder":81},[38,1525,1526,1528,1530],{"class":40,"line":78},[38,1527,1174],{"class":938},[38,1529,1177],{"class":881},[38,1531,1180],{"class":55},[38,1533,1534],{"class":40,"line":85},[38,1535,82],{"emptyLinePlaceholder":81},[38,1537,1538,1540],{"class":40,"line":91},[38,1539,1189],{"class":938},[38,1541,1542],{"class":881}," glog_musl \u002Fapp\u002F\n",[38,1544,1545],{"class":40,"line":114},[38,1546,82],{"emptyLinePlaceholder":81},[38,1548,1549,1551],{"class":40,"line":119},[38,1550,1189],{"class":938},[38,1552,1553],{"class":881}," conf.ini \u002Fdata\u002F\n",[38,1555,1556,1558],{"class":40,"line":125},[38,1557,1189],{"class":938},[38,1559,1560],{"class":881}," ipname.ini \u002Fdata\u002F\n",[38,1562,1563],{"class":40,"line":151},[38,1564,82],{"emptyLinePlaceholder":81},[38,1566,1567,1569],{"class":40,"line":156},[38,1568,1279],{"class":938},[38,1570,1571],{"class":881}," \u002Fapp\n",[38,1573,1574],{"class":40,"line":162},[38,1575,82],{"emptyLinePlaceholder":81},[38,1577,1578,1580],{"class":40,"line":1017},[38,1579,1215],{"class":938},[38,1581,1582],{"class":881}," chmod 777 \u002Fapp\u002Fglog_musl\n",[38,1584,1585],{"class":40,"line":1022},[38,1586,82],{"emptyLinePlaceholder":81},[38,1588,1589,1591,1593,1596,1599,1602,1604,1607],{"class":40,"line":1028},[38,1590,1292],{"class":938},[38,1592,1295],{"class":881},[38,1594,1595],{"class":55},"\".\u002Fglog_musl\"",[38,1597,1598],{"class":881},", ",[38,1600,1601],{"class":55},"\"logsweb\"",[38,1603,1598],{"class":881},[38,1605,1606],{"class":55},"\"\u002Fdata\u002Fconf.ini\"",[38,1608,1301],{"class":881},[38,1610,1611],{"class":40,"line":1039},[38,1612,82],{"emptyLinePlaceholder":81},[38,1614,1615],{"class":40,"line":1050},[38,1616,1617],{"class":44},"# docker build -t glog:v1 .\n",[38,1619,1620],{"class":40,"line":1055},[38,1621,1622],{"class":44},"# docker run -p 6801:6801 glog:v1\n",[801,1624,1625],{},"html pre.shiki code .sHbNN, html code.shiki .sHbNN{--shiki-light:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .sw2iP, html code.shiki .sw2iP{--shiki-light:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .scXbn, html code.shiki .scXbn{--shiki-light:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .suQ91, html code.shiki .suQ91{--shiki-light:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sIX_F, html code.shiki .sIX_F{--shiki-light:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .siTax, html code.shiki .siTax{--shiki-light:#D73A49;--shiki-dark:#F97583}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":33,"searchDepth":48,"depth":78,"links":1627},[1628,1629,1632],{"id":842,"depth":48,"text":842},{"id":848,"depth":48,"text":848,"children":1630},[1631],{"id":1084,"depth":78,"text":1084},{"id":1129,"depth":48,"text":1129,"children":1633},[1634,1635,1636],{"id":1304,"depth":78,"text":1304},{"id":1443,"depth":78,"text":1443},{"id":1507,"depth":78,"text":1507},"解决 Go 语言跨平台编译问题，使用 Docker + musl 编译静态链接的可执行文件。","进阶","\u002Fposts\u002Fdocker-musl-build\u002Fimg\u002Fcover.svg",{},"\u002Fposts\u002Fdocker-musl-build","2025-03-27",{"title":837,"description":1637},"Docker 实践笔记","posts\u002Fdocker-musl-build\u002Findex",[1647,831,1648],"Docker","运维","YLtdOOc-gMVDSNjE-ntKXz-7hff-VtvMB2dB5RXNi1o",{"id":1651,"title":1652,"author":6,"body":1653,"description":2059,"difficulty":2060,"draft":819,"extension":820,"image":2061,"meta":2062,"navigation":81,"path":2063,"pinned":819,"published":2064,"seo":2065,"series":1644,"seriesOrder":48,"stem":2066,"tags":2067,"updated":2064,"__hash__":2069},"posts\u002Fposts\u002Fdocker-user-start\u002Findex.md","Docker 构建时指定用户启动",{"type":8,"value":1654,"toc":2049},[1655,1658,1661,1664,1668,1671,1688,1692,1725,1729,1736,1786,1789,2008,2010,2013,2043,2046],[11,1656,1657],{"id":1657},"前言",[16,1659,1660],{},"我们在 Linux 使用应用时，往往不会直接使用 root 用户作为应用启动项，但 Dockerfile 默认就是 root 用户，导致挂载生成的文件路径往往是 root 权限，非 root 用户访问就很不方便。",[11,1662,1663],{"id":1663},"解决方案",[675,1665,1667],{"id":1666},"_1-安装用户管理工具","1. 安装用户管理工具",[16,1669,1670],{},"若镜像非自带用户管理：",[28,1672,1674],{"className":1153,"code":1673,"language":1155,"meta":33,"style":33},"# 权限管理\nRUN apk add --no-cache shadow\n",[35,1675,1676,1681],{"__ignoreMap":33},[38,1677,1678],{"class":40,"line":41},[38,1679,1680],{"class":44},"# 权限管理\n",[38,1682,1683,1685],{"class":40,"line":48},[38,1684,1215],{"class":938},[38,1686,1687],{"class":881}," apk add --no-cache shadow\n",[675,1689,1691],{"id":1690},"_2-创建用户并修改权限","2. 创建用户并修改权限",[28,1693,1695],{"className":1153,"code":1694,"language":1155,"meta":33,"style":33},"# 创建用户组和用户\nRUN addgroup -S \u003Capp_user_group> && adduser -S -G \u003Capp_user_group> \u003Capp_user>\n\n# 修改目录权限\nRUN chown -R \u003Capp_user_group>:\u003Capp_user> \u002Fdata\n",[35,1696,1697,1702,1709,1713,1718],{"__ignoreMap":33},[38,1698,1699],{"class":40,"line":41},[38,1700,1701],{"class":44},"# 创建用户组和用户\n",[38,1703,1704,1706],{"class":40,"line":48},[38,1705,1215],{"class":938},[38,1707,1708],{"class":881}," addgroup -S \u003Capp_user_group> && adduser -S -G \u003Capp_user_group> \u003Capp_user>\n",[38,1710,1711],{"class":40,"line":78},[38,1712,82],{"emptyLinePlaceholder":81},[38,1714,1715],{"class":40,"line":85},[38,1716,1717],{"class":44},"# 修改目录权限\n",[38,1719,1720,1722],{"class":40,"line":91},[38,1721,1215],{"class":938},[38,1723,1724],{"class":881}," chown -R \u003Capp_user_group>:\u003Capp_user> \u002Fdata\n",[675,1726,1728],{"id":1727},"_3-指定用户启动","3. 指定用户启动",[16,1730,1731,1732,1735],{},"执行 docker 时加 ",[35,1733,1734],{},"--user"," 参数：",[28,1737,1739],{"className":30,"code":1738,"language":32,"meta":33,"style":33},"docker run -it --user \u003Capp_user> \u003Capp_image>:\u003Cimage_version>\n",[35,1740,1741],{"__ignoreMap":33},[38,1742,1743,1745,1747,1749,1752,1754,1757,1760,1762,1764,1767,1770,1772,1775,1778,1781,1784],{"class":40,"line":41},[38,1744,863],{"class":51},[38,1746,866],{"class":55},[38,1748,869],{"class":65},[38,1750,1751],{"class":65}," --user",[38,1753,1379],{"class":938},[38,1755,1756],{"class":55},"app_use",[38,1758,1759],{"class":881},"r",[38,1761,1388],{"class":938},[38,1763,1379],{"class":938},[38,1765,1766],{"class":55},"app_imag",[38,1768,1769],{"class":881},"e",[38,1771,1388],{"class":938},[38,1773,1774],{"class":55},":",[38,1776,1777],{"class":938},"\u003C",[38,1779,1780],{"class":55},"image_versio",[38,1782,1783],{"class":881},"n",[38,1785,1435],{"class":938},[11,1787,1788],{"id":1788},"完整示例",[28,1790,1792],{"className":1153,"code":1791,"language":1155,"meta":33,"style":33},"FROM alpine:latest\n\n# 国内源\nRUN sed -i 's\u002Fdl-cdn.alpinelinux.org\u002Fmirrors.ustc.edu.cn\u002Fg' \u002Fetc\u002Fapk\u002Frepositories\n\nRUN apk update --no-cache\n# 权限管理\nRUN apk add --no-cache shadow\n# 调试\nRUN apk add --no-cache bash\n# 设置时区\nRUN apk add --no-cache tzdata\n\nENV TZ=Asia\u002FShanghai\n\n# 创建用户组和用户\nRUN addgroup -S glog && adduser -S -G glog glog\n\nCOPY .\u002Fglog_static_musl_1 \u002Fapp\u002F\n\nRUN mkdir \u002Fdata\n\nVOLUME [ \"\u002Fdata\" ]\nWORKDIR \u002Fdata\n\n# 修改权限\nRUN chown -R glog:glog \u002Fdata\nRUN chown -R glog:glog \u002Fapp\n\n# 指定用户启动\nUSER glog\nCMD [ \"\u002Fapp\u002Fglog_static_musl_1\", \"logsweb\", \".\u002Fconf.ini\" ]\n",[35,1793,1794,1800,1804,1809,1822,1826,1833,1837,1843,1848,1855,1860,1867,1871,1878,1882,1886,1893,1897,1904,1908,1915,1919,1933,1940,1945,1951,1959,1967,1972,1978,1987],{"__ignoreMap":33},[38,1795,1796,1798],{"class":40,"line":41},[38,1797,1162],{"class":938},[38,1799,1519],{"class":881},[38,1801,1802],{"class":40,"line":48},[38,1803,82],{"emptyLinePlaceholder":81},[38,1805,1806],{"class":40,"line":78},[38,1807,1808],{"class":44},"# 国内源\n",[38,1810,1811,1813,1816,1819],{"class":40,"line":85},[38,1812,1215],{"class":938},[38,1814,1815],{"class":881}," sed -i ",[38,1817,1818],{"class":55},"'s\u002Fdl-cdn.alpinelinux.org\u002Fmirrors.ustc.edu.cn\u002Fg'",[38,1820,1821],{"class":881}," \u002Fetc\u002Fapk\u002Frepositories\n",[38,1823,1824],{"class":40,"line":91},[38,1825,82],{"emptyLinePlaceholder":81},[38,1827,1828,1830],{"class":40,"line":114},[38,1829,1215],{"class":938},[38,1831,1832],{"class":881}," apk update --no-cache\n",[38,1834,1835],{"class":40,"line":119},[38,1836,1680],{"class":44},[38,1838,1839,1841],{"class":40,"line":125},[38,1840,1215],{"class":938},[38,1842,1687],{"class":881},[38,1844,1845],{"class":40,"line":151},[38,1846,1847],{"class":44},"# 调试\n",[38,1849,1850,1852],{"class":40,"line":156},[38,1851,1215],{"class":938},[38,1853,1854],{"class":881}," apk add --no-cache bash\n",[38,1856,1857],{"class":40,"line":162},[38,1858,1859],{"class":44},"# 设置时区\n",[38,1861,1862,1864],{"class":40,"line":1017},[38,1863,1215],{"class":938},[38,1865,1866],{"class":881}," apk add --no-cache tzdata\n",[38,1868,1869],{"class":40,"line":1022},[38,1870,82],{"emptyLinePlaceholder":81},[38,1872,1873,1875],{"class":40,"line":1028},[38,1874,1072],{"class":938},[38,1876,1877],{"class":881}," TZ=Asia\u002FShanghai\n",[38,1879,1880],{"class":40,"line":1039},[38,1881,82],{"emptyLinePlaceholder":81},[38,1883,1884],{"class":40,"line":1050},[38,1885,1701],{"class":44},[38,1887,1888,1890],{"class":40,"line":1055},[38,1889,1215],{"class":938},[38,1891,1892],{"class":881}," addgroup -S glog && adduser -S -G glog glog\n",[38,1894,1895],{"class":40,"line":1064},[38,1896,82],{"emptyLinePlaceholder":81},[38,1898,1899,1901],{"class":40,"line":1069},[38,1900,1189],{"class":938},[38,1902,1903],{"class":881}," .\u002Fglog_static_musl_1 \u002Fapp\u002F\n",[38,1905,1906],{"class":40,"line":1276},[38,1907,82],{"emptyLinePlaceholder":81},[38,1909,1910,1912],{"class":40,"line":1284},[38,1911,1215],{"class":938},[38,1913,1914],{"class":881}," mkdir \u002Fdata\n",[38,1916,1917],{"class":40,"line":1289},[38,1918,82],{"emptyLinePlaceholder":81},[38,1920,1922,1924,1927,1930],{"class":40,"line":1921},23,[38,1923,1266],{"class":938},[38,1925,1926],{"class":881}," [ ",[38,1928,1929],{"class":55},"\"\u002Fdata\"",[38,1931,1932],{"class":881}," ]\n",[38,1934,1936,1938],{"class":40,"line":1935},24,[38,1937,1279],{"class":938},[38,1939,1269],{"class":881},[38,1941,1943],{"class":40,"line":1942},25,[38,1944,82],{"emptyLinePlaceholder":81},[38,1946,1948],{"class":40,"line":1947},26,[38,1949,1950],{"class":44},"# 修改权限\n",[38,1952,1954,1956],{"class":40,"line":1953},27,[38,1955,1215],{"class":938},[38,1957,1958],{"class":881}," chown -R glog:glog \u002Fdata\n",[38,1960,1962,1964],{"class":40,"line":1961},28,[38,1963,1215],{"class":938},[38,1965,1966],{"class":881}," chown -R glog:glog \u002Fapp\n",[38,1968,1970],{"class":40,"line":1969},29,[38,1971,82],{"emptyLinePlaceholder":81},[38,1973,1975],{"class":40,"line":1974},30,[38,1976,1977],{"class":44},"# 指定用户启动\n",[38,1979,1981,1984],{"class":40,"line":1980},31,[38,1982,1983],{"class":938},"USER",[38,1985,1986],{"class":881}," glog\n",[38,1988,1990,1992,1994,1997,1999,2001,2003,2006],{"class":40,"line":1989},32,[38,1991,1292],{"class":938},[38,1993,1926],{"class":881},[38,1995,1996],{"class":55},"\"\u002Fapp\u002Fglog_static_musl_1\"",[38,1998,1598],{"class":881},[38,2000,1601],{"class":55},[38,2002,1598],{"class":881},[38,2004,2005],{"class":55},"\".\u002Fconf.ini\"",[38,2007,1932],{"class":881},[11,2009,793],{"id":793},[16,2011,2012],{},"关键步骤：",[767,2014,2015,2021,2031,2037],{},[530,2016,2017,2020],{},[35,2018,2019],{},"apk add --no-cache shadow"," - 安装用户管理工具",[530,2022,2023,2026,2027,2030],{},[35,2024,2025],{},"addgroup"," + ",[35,2028,2029],{},"adduser"," - 创建用户",[530,2032,2033,2036],{},[35,2034,2035],{},"chown -R"," - 修改目录权限",[530,2038,2039,2042],{},[35,2040,2041],{},"USER \u003Cusername>"," - 指定运行用户",[16,2044,2045],{},"这样容器内的应用就不再以 root 身份运行，更加安全。",[801,2047,2048],{},"html pre.shiki code .sHbNN, html code.shiki .sHbNN{--shiki-light:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .siTax, html code.shiki .siTax{--shiki-light:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sIX_F, html code.shiki .sIX_F{--shiki-light:#24292E;--shiki-dark:#E1E4E8}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sw2iP, html code.shiki .sw2iP{--shiki-light:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .scXbn, html code.shiki .scXbn{--shiki-light:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .suQ91, html code.shiki .suQ91{--shiki-light:#005CC5;--shiki-dark:#79B8FF}",{"title":33,"searchDepth":48,"depth":78,"links":2050},[2051,2052,2057,2058],{"id":1657,"depth":48,"text":1657},{"id":1663,"depth":48,"text":1663,"children":2053},[2054,2055,2056],{"id":1666,"depth":78,"text":1667},{"id":1690,"depth":78,"text":1691},{"id":1727,"depth":78,"text":1728},{"id":1788,"depth":48,"text":1788},{"id":793,"depth":48,"text":793},"解决 Docker 容器默认以 root 用户运行导致的权限问题，指定非 root 用户启动应用。","入门","\u002Fposts\u002Fdocker-user-start\u002Fimg\u002Fcover.svg",{},"\u002Fposts\u002Fdocker-user-start","2025-04-01",{"title":1652,"description":2059},"posts\u002Fdocker-user-start\u002Findex",[1647,1648,2068],"Linux","b4Uyw5ms87xr7NhpIkavHnNbT20oaEPtXz8k1w6UPj0",{"id":2071,"title":2072,"author":6,"body":2073,"description":2284,"difficulty":6,"draft":819,"extension":820,"image":2285,"meta":2286,"navigation":81,"path":2287,"pinned":819,"published":2288,"seo":2289,"series":6,"seriesOrder":6,"stem":2290,"tags":2291,"updated":6,"__hash__":2293},"posts\u002Fposts\u002Fsveltekit-intro\u002Findex.md","SvelteKit 入门：为什么它比你想象的更简单",{"type":8,"value":2074,"toc":2274},[2075,2079,2082,2088,2091,2095,2102,2154,2158,2164,2172,2176,2179,2182,2252,2254,2265,2271],[11,2076,2078],{"id":2077},"什么是-sveltekit","什么是 SvelteKit？",[16,2080,2081],{},"SvelteKit 是 Svelte 的官方全栈框架，类似于 React 之于 Next.js 的关系。它提供了路由、SSR、SSG 等能力，让开发者可以快速构建现代 Web 应用。",[16,2083,2084],{},[1109,2085],{"alt":2086,"src":2087},"SvelteKit Logo","https:\u002F\u002Fraw.githubusercontent.com\u002Fsveltejs\u002Fbranding\u002Fmaster\u002Fsvelte-logo.svg",[11,2089,2090],{"id":2090},"核心特点",[675,2092,2094],{"id":2093},"_1-编译时框架","1. 编译时框架",[16,2096,2097,2098,2101],{},"Svelte 和 React\u002FVue 最大的区别在于：",[23,2099,2100],{},"它在编译阶段就把组件转成原生 JS","，运行时没有虚拟 DOM。",[28,2103,2107],{"className":2104,"code":2105,"language":2106,"meta":33,"style":33},"language-typescript shiki shiki-themes github-light github-dark","\u002F\u002F Svelte 组件（编译后变成直接的 DOM 操作）\nlet count = 0\nfunction increment() {\n  count += 1\n}\n","typescript",[35,2108,2109,2114,2127,2138,2149],{"__ignoreMap":33},[38,2110,2111],{"class":40,"line":41},[38,2112,2113],{"class":44},"\u002F\u002F Svelte 组件（编译后变成直接的 DOM 操作）\n",[38,2115,2116,2119,2122,2124],{"class":40,"line":48},[38,2117,2118],{"class":938},"let",[38,2120,2121],{"class":881}," count ",[38,2123,945],{"class":938},[38,2125,2126],{"class":65}," 0\n",[38,2128,2129,2132,2135],{"class":40,"line":78},[38,2130,2131],{"class":938},"function",[38,2133,2134],{"class":51}," increment",[38,2136,2137],{"class":881},"() {\n",[38,2139,2140,2143,2146],{"class":40,"line":85},[38,2141,2142],{"class":881},"  count ",[38,2144,2145],{"class":938},"+=",[38,2147,2148],{"class":65}," 1\n",[38,2150,2151],{"class":40,"line":91},[38,2152,2153],{"class":881},"}\n",[675,2155,2157],{"id":2156},"_2-文件路由","2. 文件路由",[16,2159,2160,2163],{},[35,2161,2162],{},"src\u002Froutes\u002F"," 目录结构即 URL，不需要手动配置路由表：",[28,2165,2170],{"className":2166,"code":2168,"language":2169},[2167],"language-text","src\u002Froutes\u002F\n├── +page.svelte        → \u002F\n├── about\u002F\n│   └── +page.svelte    → \u002Fabout\n└── posts\u002F\n    ├── +page.svelte    → \u002Fposts\n    └── [slug]\u002F\n        └── +page.svelte → \u002Fposts\u002F:slug\n","text",[35,2171,2168],{"__ignoreMap":33},[675,2173,2175],{"id":2174},"_3-全栈能力","3. 全栈能力",[16,2177,2178],{},"SvelteKit 内置了服务端渲染（SSR）、静态站点生成（SSG）、API 路由等功能，开箱即用。",[11,2180,2181],{"id":2181},"和其他框架对比",[272,2183,2184,2200],{},[275,2185,2186],{},[278,2187,2188,2191,2194,2197],{},[281,2189,2190],{},"特性",[281,2192,2193],{},"SvelteKit",[281,2195,2196],{},"Next.js",[281,2198,2199],{},"Nuxt",[291,2201,2202,2215,2228,2241],{},[278,2203,2204,2207,2210,2213],{},[296,2205,2206],{},"学习曲线",[296,2208,2209],{},"低",[296,2211,2212],{},"中",[296,2214,2212],{},[278,2216,2217,2220,2223,2226],{},[296,2218,2219],{},"包体积",[296,2221,2222],{},"小",[296,2224,2225],{},"大",[296,2227,2212],{},[278,2229,2230,2233,2236,2239],{},[296,2231,2232],{},"运行时开销",[296,2234,2235],{},"无",[296,2237,2238],{},"有",[296,2240,2238],{},[278,2242,2243,2246,2248,2250],{},[296,2244,2245],{},"生态",[296,2247,2222],{},[296,2249,2225],{},[296,2251,2225],{},[11,2253,793],{"id":793},[16,2255,2256,2257,2260,2261,2264],{},"如果你追求",[23,2258,2259],{},"简洁的代码","和",[23,2262,2263],{},"极致的性能","，SvelteKit 是一个非常值得尝试的选择。它的学习曲线平缓，写起来就像在写原生 HTML\u002FCSS\u002FJS。",[16,2266,2267],{},[1109,2268],{"alt":2269,"src":2270},"Svelte 编译过程","https:\u002F\u002Fsvelte.dev\u002Fsvelte-logo-horizontal.svg",[801,2272,2273],{},"html pre.shiki code .sHbNN, html code.shiki .sHbNN{--shiki-light:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .siTax, html code.shiki .siTax{--shiki-light:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sIX_F, html code.shiki .sIX_F{--shiki-light:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .suQ91, html code.shiki .suQ91{--shiki-light:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sw2iP, html code.shiki .sw2iP{--shiki-light:#6F42C1;--shiki-dark:#B392F0}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":33,"searchDepth":48,"depth":78,"links":2275},[2276,2277,2282,2283],{"id":2077,"depth":48,"text":2078},{"id":2090,"depth":48,"text":2090,"children":2278},[2279,2280,2281],{"id":2093,"depth":78,"text":2094},{"id":2156,"depth":78,"text":2157},{"id":2174,"depth":78,"text":2175},{"id":2181,"depth":48,"text":2181},{"id":793,"depth":48,"text":793},"SvelteKit 是一个全栈 Svelte 框架，这篇文章带你了解它的核心概念和优势。","\u002Fposts\u002Fsveltekit-intro\u002Fimg\u002Fcover.jpg",{},"\u002Fposts\u002Fsveltekit-intro","2026-05-30",{"title":2072,"description":2284},"posts\u002Fsveltekit-intro\u002Findex",[2193,2292],"前端框架","vsEGyunpCWR7DiJAdyV1SPDfa57xXLITBI-lKM8UeGQ",{"id":2295,"title":2296,"author":6,"body":2297,"description":2699,"difficulty":2060,"draft":819,"extension":820,"image":2700,"meta":2701,"navigation":81,"path":2702,"pinned":81,"published":2703,"seo":2704,"series":2705,"seriesOrder":41,"stem":2706,"tags":2707,"updated":2709,"__hash__":2710},"posts\u002Fposts\u002Fhello-world\u002Findex.md","Hello World - 欢迎使用 SuBlog",{"type":8,"value":2298,"toc":2690},[2299,2303,2310,2313,2316,2319,2393,2396,2399,2461,2464,2467,2533,2536,2602,2605,2608,2624,2631,2634,2665,2668,2679,2682,2687],[11,2300,2302],{"id":2301},"博客上线啦","博客上线啦！",[16,2304,2305,2306,2309],{},"Hello World！欢迎使用 ",[23,2307,2308],{},"SuBlog","，这是站点的第一篇示例文章。",[16,2311,2312],{},"经过一段时间的折腾，这个基于 Nuxt 3 的个人博客终于上线了。在这里记录一下这个项目的技术选型和功能特性。",[11,2314,2315],{"id":2315},"技术栈",[16,2317,2318],{},"本站使用了以下技术：",[272,2320,2321,2331],{},[275,2322,2323],{},[278,2324,2325,2328],{},[281,2326,2327],{},"技术",[281,2329,2330],{},"用途",[291,2332,2333,2343,2353,2363,2373,2383],{},[278,2334,2335,2340],{},[296,2336,2337],{},[23,2338,2339],{},"Nuxt 3",[296,2341,2342],{},"Vue 3 全栈框架，提供 SSG、路由、API 等能力",[278,2344,2345,2350],{},[296,2346,2347],{},[23,2348,2349],{},"TypeScript",[296,2351,2352],{},"类型安全，提升开发体验",[278,2354,2355,2360],{},[296,2356,2357],{},[23,2358,2359],{},"Tailwind CSS v4",[296,2361,2362],{},"原子化 CSS，快速构建 UI",[278,2364,2365,2370],{},[296,2366,2367],{},[23,2368,2369],{},"@nuxt\u002Fcontent",[296,2371,2372],{},"Markdown 驱动的内容管理",[278,2374,2375,2380],{},[296,2376,2377],{},[23,2378,2379],{},"Shiki",[296,2381,2382],{},"代码语法高亮",[278,2384,2385,2390],{},[296,2386,2387],{},[23,2388,2389],{},"Giscus",[296,2391,2392],{},"基于 GitHub Discussions 的评论系统",[11,2394,2395],{"id":2395},"功能特性",[16,2397,2398],{},"目前已实现的功能：",[527,2400,2401,2407,2413,2419,2425,2431,2437,2443,2449,2455],{},[530,2402,2403,2406],{},[23,2404,2405],{},"博客系统"," - Markdown 文章，支持代码高亮、目录导航、搜索",[530,2408,2409,2412],{},[23,2410,2411],{},"暗色模式"," - 跟随系统或手动切换",[530,2414,2415,2418],{},[23,2416,2417],{},"文章搜索"," - 支持标题、描述、正文、标签多维度搜索",[530,2420,2421,2424],{},[23,2422,2423],{},"标签系统"," - 文章标签分类，高频标签快速筛选",[530,2426,2427,2430],{},[23,2428,2429],{},"友链页面"," - 展示友情链接，支持申请",[530,2432,2433,2436],{},[23,2434,2435],{},"赞助页面"," - 微信收款码展示",[530,2438,2439,2442],{},[23,2440,2441],{},"封面制作"," - 在线生成封面图片工具",[530,2444,2445,2448],{},[23,2446,2447],{},"网易云热评"," - 首页随机展示音乐评论",[530,2450,2451,2454],{},[23,2452,2453],{},"访客统计"," - 不蒜子计数器，带数字滚动动画",[530,2456,2457,2460],{},[23,2458,2459],{},"SEO 优化"," - 自动生成站点地图、Open Graph 标签",[11,2462,2463],{"id":2463},"代码示例",[16,2465,2466],{},"本站的配置非常集中，一个文件搞定全站定制：",[28,2468,2470],{"className":2104,"code":2469,"language":2106,"meta":33,"style":33},"\u002F\u002F config\u002Fsite.ts\nexport const siteConfig = {\n  siteName: '我的博客',\n  url: 'https:\u002F\u002Fexample.com\u002F',\n  avatar: 'https:\u002F\u002Fq2.qlogo.cn\u002Fheadimg_dl?dst_uin=xxx&spec=100',\n  \u002F\u002F ... 改这一个文件，全站生效\n}\n",[35,2471,2472,2477,2493,2504,2514,2524,2529],{"__ignoreMap":33},[38,2473,2474],{"class":40,"line":41},[38,2475,2476],{"class":44},"\u002F\u002F config\u002Fsite.ts\n",[38,2478,2479,2481,2484,2487,2490],{"class":40,"line":48},[38,2480,939],{"class":938},[38,2482,2483],{"class":938}," const",[38,2485,2486],{"class":65}," siteConfig",[38,2488,2489],{"class":938}," =",[38,2491,2492],{"class":881}," {\n",[38,2494,2495,2498,2501],{"class":40,"line":78},[38,2496,2497],{"class":881},"  siteName: ",[38,2499,2500],{"class":55},"'我的博客'",[38,2502,2503],{"class":881},",\n",[38,2505,2506,2509,2512],{"class":40,"line":85},[38,2507,2508],{"class":881},"  url: ",[38,2510,2511],{"class":55},"'https:\u002F\u002Fexample.com\u002F'",[38,2513,2503],{"class":881},[38,2515,2516,2519,2522],{"class":40,"line":91},[38,2517,2518],{"class":881},"  avatar: ",[38,2520,2521],{"class":55},"'https:\u002F\u002Fq2.qlogo.cn\u002Fheadimg_dl?dst_uin=xxx&spec=100'",[38,2523,2503],{"class":881},[38,2525,2526],{"class":40,"line":114},[38,2527,2528],{"class":44},"  \u002F\u002F ... 改这一个文件，全站生效\n",[38,2530,2531],{"class":40,"line":119},[38,2532,2153],{"class":881},[16,2534,2535],{},"文章的 frontmatter 也很简洁：",[28,2537,2541],{"className":2538,"code":2539,"language":2540,"meta":33,"style":33},"language-yaml shiki shiki-themes github-light github-dark","---\ntitle: 文章标题\npublished: 2026-05-31T08:00:00\ndescription: 文章简介\ntags: ['Nuxt', 'TypeScript']\n---\n","yaml",[35,2542,2543,2548,2560,2570,2580,2598],{"__ignoreMap":33},[38,2544,2545],{"class":40,"line":41},[38,2546,2547],{"class":51},"---\n",[38,2549,2550,2554,2557],{"class":40,"line":48},[38,2551,2553],{"class":2552},"sbB4o","title",[38,2555,2556],{"class":881},": ",[38,2558,2559],{"class":55},"文章标题\n",[38,2561,2562,2565,2567],{"class":40,"line":78},[38,2563,2564],{"class":2552},"published",[38,2566,2556],{"class":881},[38,2568,2569],{"class":65},"2026-05-31T08:00:00\n",[38,2571,2572,2575,2577],{"class":40,"line":85},[38,2573,2574],{"class":2552},"description",[38,2576,2556],{"class":881},[38,2578,2579],{"class":55},"文章简介\n",[38,2581,2582,2585,2588,2591,2593,2596],{"class":40,"line":91},[38,2583,2584],{"class":2552},"tags",[38,2586,2587],{"class":881},": [",[38,2589,2590],{"class":55},"'Nuxt'",[38,2592,1598],{"class":881},[38,2594,2595],{"class":55},"'TypeScript'",[38,2597,1301],{"class":881},[38,2599,2600],{"class":40,"line":114},[38,2601,2547],{"class":51},[11,2603,2604],{"id":2604},"部署",[16,2606,2607],{},"本站使用 SSG 静态生成，构建后直接部署到静态托管即可：",[28,2609,2611],{"className":30,"code":2610,"language":32,"meta":33,"style":33},"npx nuxi generate\n",[35,2612,2613],{"__ignoreMap":33},[38,2614,2615,2618,2621],{"class":40,"line":41},[38,2616,2617],{"class":51},"npx",[38,2619,2620],{"class":55}," nuxi",[38,2622,2623],{"class":55}," generate\n",[16,2625,2626,2627,2630],{},"构建产物在 ",[35,2628,2629],{},".output\u002Fpublic"," 目录下，可以直接丢到 Nginx、Vercel、Netlify 等平台。",[11,2632,2633],{"id":2633},"后续计划",[527,2635,2638,2647,2653,2659],{"className":2636},[2637],"contains-task-list",[530,2639,2642,2646],{"className":2640},[2641],"task-list-item",[2643,2644],"input",{"disabled":81,"type":2645},"checkbox"," RSS 订阅",[530,2648,2650,2652],{"className":2649},[2641],[2643,2651],{"disabled":81,"type":2645}," 文章目录优化",[530,2654,2656,2658],{"className":2655},[2641],[2643,2657],{"disabled":81,"type":2645}," 更多小工具",[530,2660,2662,2664],{"className":2661},[2641],[2643,2663],{"disabled":81,"type":2645}," 持续输出内容",[11,2666,2667],{"id":2667},"致谢",[16,2669,2670,2671,2678],{},"感谢 ",[2672,2673,2677],"a",{"href":2674,"rel":2675},"https:\u002F\u002F2x.nz",[2676],"nofollow","二叉树树"," 的开源项目，本站的 UI 设计参考了他的项目。",[2680,2681],"hr",{},[16,2683,2684],{},[23,2685,2686],{},"感谢阅读！如果觉得不错，欢迎点个 Star 或者留个评论 (☞ﾟヮﾟ)☞",[801,2688,2689],{},"html pre.shiki code .sHbNN, html code.shiki .sHbNN{--shiki-light:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .siTax, html code.shiki .siTax{--shiki-light:#D73A49;--shiki-dark:#F97583}html pre.shiki code .suQ91, html code.shiki .suQ91{--shiki-light:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sIX_F, html code.shiki .sIX_F{--shiki-light:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .scXbn, html code.shiki .scXbn{--shiki-light:#032F62;--shiki-dark:#9ECBFF}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sw2iP, html code.shiki .sw2iP{--shiki-light:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sbB4o, html code.shiki .sbB4o{--shiki-light:#22863A;--shiki-dark:#85E89D}",{"title":33,"searchDepth":48,"depth":78,"links":2691},[2692,2693,2694,2695,2696,2697,2698],{"id":2301,"depth":48,"text":2302},{"id":2315,"depth":48,"text":2315},{"id":2395,"depth":48,"text":2395},{"id":2463,"depth":48,"text":2463},{"id":2604,"depth":48,"text":2604},{"id":2633,"depth":48,"text":2633},{"id":2667,"depth":48,"text":2667},"博客上线啦！这篇文章介绍本站的技术架构、功能特性和搭建过程中的一些思考。","\u002Fposts\u002Fhello-world\u002Fimg\u002Fcover.jpg",{},"\u002Fposts\u002Fhello-world","2026-05-31T08:00:00",{"title":2296,"description":2699},"Nuxt 博客开发","posts\u002Fhello-world\u002Findex",[2708,2199],"博客","2026-05-31","f9MCboCBfQIUxT8aKv6aKGDFxysiG8ZG0YXr5X5LTFo",{"id":2712,"title":2713,"author":6,"body":2714,"description":3889,"difficulty":1638,"draft":819,"extension":820,"image":3890,"meta":3891,"navigation":81,"path":3892,"pinned":819,"published":3893,"seo":3894,"series":2705,"seriesOrder":48,"stem":3895,"tags":3896,"updated":2709,"__hash__":3899},"posts\u002Fposts\u002Fnuxt-content-lessons\u002Findex.md","Nuxt Content v3 踩坑记录：目录、图片与那些反直觉的设计",{"type":8,"value":2715,"toc":3879},[2716,2720,2723,2726,2730,2733,2744,2747,2754,2821,2828,2830,2837,3115,3125,3129,3132,3135,3161,3175,3178,3201,3207,3210,3213,3216,3221,3228,3455,3460,3504,3508,3511,3518,3521,3571,3577,3806,3816,3818,3870,3873,3876],[2717,2718,2719],"h1",{"id":2719},"背景",[16,2721,2722],{},"最近在用 Nuxt 3 + Nuxt Content v3 搭建一个个人博客，参考了一个 SvelteKit 项目（svaf）的架构。原以为换个框架只是语法差异，没想到在几个关键功能上踩了不少坑。",[16,2724,2725],{},"这篇文章记录了三个核心问题的排查过程和最终方案。",[2717,2727,2729],{"id":2728},"问题一目录导航toc只有-h2","问题一：目录导航（TOC）只有 h2",[11,2731,2732],{"id":2732},"现象",[16,2734,2735,2736,2739,2740,2743],{},"Nuxt Content 内置了 TOC 数据，挂在 ",[35,2737,2738],{},"post.body.toc"," 上。直接用它渲染目录，发现",[23,2741,2742],{},"只有 h2 标题，h3 全部丢失","。",[11,2745,2746],{"id":2746},"排查",[16,2748,2749,2750,2753],{},"查了官方文档，发现 ",[35,2751,2752],{},"build.markdown.toc.depth"," 可以控制深度：",[28,2755,2757],{"className":2104,"code":2756,"language":2106,"meta":33,"style":33},"\u002F\u002F nuxt.config.ts\ncontent: {\n  build: {\n    markdown: {\n      toc: { depth: 3 }  \u002F\u002F 包含 h3\n    }\n  }\n}\n",[35,2758,2759,2764,2772,2779,2786,2807,2812,2817],{"__ignoreMap":33},[38,2760,2761],{"class":40,"line":41},[38,2762,2763],{"class":44},"\u002F\u002F nuxt.config.ts\n",[38,2765,2766,2769],{"class":40,"line":48},[38,2767,2768],{"class":51},"content",[38,2770,2771],{"class":881},": {\n",[38,2773,2774,2777],{"class":40,"line":78},[38,2775,2776],{"class":51},"  build",[38,2778,2771],{"class":881},[38,2780,2781,2784],{"class":40,"line":85},[38,2782,2783],{"class":51},"    markdown",[38,2785,2771],{"class":881},[38,2787,2788,2791,2794,2797,2799,2801,2804],{"class":40,"line":91},[38,2789,2790],{"class":51},"      toc",[38,2792,2793],{"class":881},": { ",[38,2795,2796],{"class":51},"depth",[38,2798,2556],{"class":881},[38,2800,483],{"class":65},[38,2802,2803],{"class":881}," }  ",[38,2805,2806],{"class":44},"\u002F\u002F 包含 h3\n",[38,2808,2809],{"class":40,"line":114},[38,2810,2811],{"class":881},"    }\n",[38,2813,2814],{"class":40,"line":119},[38,2815,2816],{"class":881},"  }\n",[38,2818,2819],{"class":40,"line":125},[38,2820,2153],{"class":881},[16,2822,2823,2824,2827],{},"配置改了，清了缓存重启，",[23,2825,2826],{},"依然只有 h2","。这个配置似乎不起作用。",[11,2829,1129],{"id":1129},[16,2831,2832,2833,2836],{},"放弃依赖 Nuxt Content 的 toc，直接从 ",[35,2834,2835],{},"post.body"," 的 AST 里递归提取所有标题：",[28,2838,2840],{"className":2104,"code":2839,"language":2106,"meta":33,"style":33},"function extractHeadings(node: any, list: Heading[] = []): Heading[] {\n  if (!node) return list\n  if (Array.isArray(node)) {\n    \u002F\u002F minimark 格式：[\"h2\", {id: \"xxx\"}, \"文本\"]\n    if (typeof node[0] === 'string' && \u002F^h[1-6]$\u002F.test(node[0])) {\n      const level = Number(node[0].charAt(1))\n      const text = extractText(node.slice(2)).trim()\n      list.push({ id: node[1]?.id || slugify(text), text, level })\n    }\n    for (const child of node) extractHeadings(child, list)\n  }\n  return list\n}\n",[35,2841,2842,2886,2906,2919,2924,2985,3015,3046,3071,3075,3100,3104,3111],{"__ignoreMap":33},[38,2843,2844,2846,2849,2852,2856,2858,2861,2863,2866,2868,2871,2874,2876,2879,2881,2883],{"class":40,"line":41},[38,2845,2131],{"class":938},[38,2847,2848],{"class":51}," extractHeadings",[38,2850,2851],{"class":881},"(",[38,2853,2855],{"class":2854},"sPK6S","node",[38,2857,1774],{"class":938},[38,2859,2860],{"class":65}," any",[38,2862,1598],{"class":881},[38,2864,2865],{"class":2854},"list",[38,2867,1774],{"class":938},[38,2869,2870],{"class":51}," Heading",[38,2872,2873],{"class":881},"[] ",[38,2875,945],{"class":938},[38,2877,2878],{"class":881}," [])",[38,2880,1774],{"class":938},[38,2882,2870],{"class":51},[38,2884,2885],{"class":881},"[] {\n",[38,2887,2888,2891,2894,2897,2900,2903],{"class":40,"line":48},[38,2889,2890],{"class":938},"  if",[38,2892,2893],{"class":881}," (",[38,2895,2896],{"class":938},"!",[38,2898,2899],{"class":881},"node) ",[38,2901,2902],{"class":938},"return",[38,2904,2905],{"class":881}," list\n",[38,2907,2908,2910,2913,2916],{"class":40,"line":78},[38,2909,2890],{"class":938},[38,2911,2912],{"class":881}," (Array.",[38,2914,2915],{"class":51},"isArray",[38,2917,2918],{"class":881},"(node)) {\n",[38,2920,2921],{"class":40,"line":85},[38,2922,2923],{"class":44},"    \u002F\u002F minimark 格式：[\"h2\", {id: \"xxx\"}, \"文本\"]\n",[38,2925,2926,2929,2931,2934,2937,2940,2943,2946,2949,2952,2955,2958,2962,2965,2968,2971,2974,2977,2980,2982],{"class":40,"line":91},[38,2927,2928],{"class":938},"    if",[38,2930,2893],{"class":881},[38,2932,2933],{"class":938},"typeof",[38,2935,2936],{"class":881}," node[",[38,2938,2939],{"class":65},"0",[38,2941,2942],{"class":881},"] ",[38,2944,2945],{"class":938},"===",[38,2947,2948],{"class":55}," 'string'",[38,2950,2951],{"class":938}," &&",[38,2953,2954],{"class":55}," \u002F",[38,2956,2957],{"class":938},"^",[38,2959,2961],{"class":2960},"sPKmS","h",[38,2963,2964],{"class":65},"[1-6]",[38,2966,2967],{"class":938},"$",[38,2969,2970],{"class":55},"\u002F",[38,2972,2973],{"class":881},".",[38,2975,2976],{"class":51},"test",[38,2978,2979],{"class":881},"(node[",[38,2981,2939],{"class":65},[38,2983,2984],{"class":881},"])) {\n",[38,2986,2987,2990,2993,2995,2998,3000,3002,3005,3008,3010,3012],{"class":40,"line":114},[38,2988,2989],{"class":938},"      const",[38,2991,2992],{"class":65}," level",[38,2994,2489],{"class":938},[38,2996,2997],{"class":51}," Number",[38,2999,2979],{"class":881},[38,3001,2939],{"class":65},[38,3003,3004],{"class":881},"].",[38,3006,3007],{"class":51},"charAt",[38,3009,2851],{"class":881},[38,3011,963],{"class":65},[38,3013,3014],{"class":881},"))\n",[38,3016,3017,3019,3022,3024,3027,3030,3033,3035,3037,3040,3043],{"class":40,"line":119},[38,3018,2989],{"class":938},[38,3020,3021],{"class":65}," text",[38,3023,2489],{"class":938},[38,3025,3026],{"class":51}," extractText",[38,3028,3029],{"class":881},"(node.",[38,3031,3032],{"class":51},"slice",[38,3034,2851],{"class":881},[38,3036,497],{"class":65},[38,3038,3039],{"class":881},")).",[38,3041,3042],{"class":51},"trim",[38,3044,3045],{"class":881},"()\n",[38,3047,3048,3051,3054,3057,3059,3062,3065,3068],{"class":40,"line":125},[38,3049,3050],{"class":881},"      list.",[38,3052,3053],{"class":51},"push",[38,3055,3056],{"class":881},"({ id: node[",[38,3058,963],{"class":65},[38,3060,3061],{"class":881},"]?.id ",[38,3063,3064],{"class":938},"||",[38,3066,3067],{"class":51}," slugify",[38,3069,3070],{"class":881},"(text), text, level })\n",[38,3072,3073],{"class":40,"line":151},[38,3074,2811],{"class":881},[38,3076,3077,3080,3082,3085,3088,3091,3094,3097],{"class":40,"line":156},[38,3078,3079],{"class":938},"    for",[38,3081,2893],{"class":881},[38,3083,3084],{"class":938},"const",[38,3086,3087],{"class":65}," child",[38,3089,3090],{"class":938}," of",[38,3092,3093],{"class":881}," node) ",[38,3095,3096],{"class":51},"extractHeadings",[38,3098,3099],{"class":881},"(child, list)\n",[38,3101,3102],{"class":40,"line":162},[38,3103,2816],{"class":881},[38,3105,3106,3109],{"class":40,"line":1017},[38,3107,3108],{"class":938},"  return",[38,3110,2905],{"class":881},[38,3112,3113],{"class":40,"line":1022},[38,3114,2153],{"class":881},[16,3116,3117,3118,3120,3121,3124],{},"关键点：Nuxt Content v3 的 body 使用 ",[23,3119,8],{}," 格式，标题节点是数组 ",[35,3122,3123],{},"[\"h2\", {id: \"xxx\"}, \"标题文本\"]","，不是对象。",[2717,3126,3128],{"id":3127},"问题二图片路径解析错误","问题二：图片路径解析错误",[11,3130,2732],{"id":3131},"现象-1",[16,3133,3134],{},"markdown 里用相对路径引用图片：",[28,3136,3140],{"className":3137,"code":3138,"language":3139,"meta":33,"style":33},"language-markdown shiki shiki-themes github-light github-dark","![SSHFS挂载效果](img\u002Fsshfs.avif)\n","markdown",[35,3141,3142],{"__ignoreMap":33},[38,3143,3144,3147,3151,3154,3158],{"class":40,"line":41},[38,3145,3146],{"class":881},"![",[38,3148,3150],{"class":3149},"smiIW","SSHFS挂载效果",[38,3152,3153],{"class":881},"](",[38,3155,3157],{"class":3156},"szbl0","img\u002Fsshfs.avif",[38,3159,3160],{"class":881},")\n",[16,3162,3163,3164,3167,3168,3171,3172,2743],{},"HTML 输出的 ",[35,3165,3166],{},"\u003Cimg src=\"img\u002Fsshfs.avif\">"," 看起来没问题，但浏览器请求的却是 ",[35,3169,3170],{},"\u002Fposts\u002Fimg\u002Fsshfs.avif"," 而不是 ",[35,3173,3174],{},"\u002Fposts\u002Fsshfs\u002Fimg\u002Fsshfs.avif",[11,3176,2746],{"id":3177},"排查-1",[767,3179,3180,3187,3190,3196],{},[530,3181,3182,3183,3186],{},"检查了 ",[35,3184,3185],{},"\u003Cbase>"," 标签 —— 没有",[530,3188,3189],{},"检查了重定向 —— 没有",[530,3191,3192,3193,3195],{},"直接访问 ",[35,3194,3174],{}," —— 返回 200 OK",[530,3197,3192,3198,3200],{},[35,3199,3170],{}," —— 404",[16,3202,3203,3204,2743],{},"服务器路由没问题，问题是",[23,3205,3206],{},"浏览器解析相对路径时基于了错误的基准 URL",[16,3208,3209],{},"这可能和 Nuxt 的客户端路由有关：SPA 导航时，浏览器的\"当前页面\"和 Vue Router 的\"当前路由\"不同步，导致相对路径解析出错。",[11,3211,1129],{"id":3212},"最终方案-1",[16,3214,3215],{},"两步解决：",[16,3217,3218],{},[23,3219,3220],{},"第一步：自定义 ProseImg 组件",[16,3222,3223,3224,3227],{},"创建 ",[35,3225,3226],{},"components\u002Fcontent\u002FProseImg.vue","，覆盖 Nuxt Content 默认的图片渲染：",[28,3229,3233],{"className":3230,"code":3231,"language":3232,"meta":33,"style":33},"language-vue shiki shiki-themes github-light github-dark","\u003Cscript setup>\nconst route = useRoute()\nconst resolvedSrc = computed(() => {\n  if (!props.src) return ''\n  if (props.src.startsWith('\u002F') || props.src.startsWith('http')) return props.src\n  const base = route.path.replace(\u002F\\\u002F$\u002F, '')\n  return `${base}\u002F${props.src}`\n})\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003Cimg :src=\"resolvedSrc\" :alt=\"alt\" \u002F>\n\u003C\u002Ftemplate>\n","vue",[35,3234,3235,3247,3261,3281,3297,3335,3370,3394,3399,3408,3412,3421,3447],{"__ignoreMap":33},[38,3236,3237,3239,3242,3245],{"class":40,"line":41},[38,3238,1777],{"class":881},[38,3240,3241],{"class":2552},"script",[38,3243,3244],{"class":51}," setup",[38,3246,1435],{"class":881},[38,3248,3249,3251,3254,3256,3259],{"class":40,"line":48},[38,3250,3084],{"class":938},[38,3252,3253],{"class":65}," route",[38,3255,2489],{"class":938},[38,3257,3258],{"class":51}," useRoute",[38,3260,3045],{"class":881},[38,3262,3263,3265,3268,3270,3273,3276,3279],{"class":40,"line":78},[38,3264,3084],{"class":938},[38,3266,3267],{"class":65}," resolvedSrc",[38,3269,2489],{"class":938},[38,3271,3272],{"class":51}," computed",[38,3274,3275],{"class":881},"(() ",[38,3277,3278],{"class":938},"=>",[38,3280,2492],{"class":881},[38,3282,3283,3285,3287,3289,3292,3294],{"class":40,"line":85},[38,3284,2890],{"class":938},[38,3286,2893],{"class":881},[38,3288,2896],{"class":938},[38,3290,3291],{"class":881},"props.src) ",[38,3293,2902],{"class":938},[38,3295,3296],{"class":55}," ''\n",[38,3298,3299,3301,3304,3307,3309,3312,3315,3317,3320,3322,3324,3327,3330,3332],{"class":40,"line":91},[38,3300,2890],{"class":938},[38,3302,3303],{"class":881}," (props.src.",[38,3305,3306],{"class":51},"startsWith",[38,3308,2851],{"class":881},[38,3310,3311],{"class":55},"'\u002F'",[38,3313,3314],{"class":881},") ",[38,3316,3064],{"class":938},[38,3318,3319],{"class":881}," props.src.",[38,3321,3306],{"class":51},[38,3323,2851],{"class":881},[38,3325,3326],{"class":55},"'http'",[38,3328,3329],{"class":881},")) ",[38,3331,2902],{"class":938},[38,3333,3334],{"class":881}," props.src\n",[38,3336,3337,3340,3343,3345,3348,3351,3353,3355,3359,3361,3363,3365,3368],{"class":40,"line":114},[38,3338,3339],{"class":938},"  const",[38,3341,3342],{"class":65}," base",[38,3344,2489],{"class":938},[38,3346,3347],{"class":881}," route.path.",[38,3349,3350],{"class":51},"replace",[38,3352,2851],{"class":881},[38,3354,2970],{"class":55},[38,3356,3358],{"class":3357},"sCnZR","\\\u002F",[38,3360,2967],{"class":938},[38,3362,2970],{"class":55},[38,3364,1598],{"class":881},[38,3366,3367],{"class":55},"''",[38,3369,3160],{"class":881},[38,3371,3372,3374,3377,3380,3383,3386,3388,3391],{"class":40,"line":119},[38,3373,3108],{"class":938},[38,3375,3376],{"class":55}," `${",[38,3378,3379],{"class":881},"base",[38,3381,3382],{"class":55},"}\u002F${",[38,3384,3385],{"class":881},"props",[38,3387,2973],{"class":55},[38,3389,3390],{"class":881},"src",[38,3392,3393],{"class":55},"}`\n",[38,3395,3396],{"class":40,"line":125},[38,3397,3398],{"class":881},"})\n",[38,3400,3401,3404,3406],{"class":40,"line":151},[38,3402,3403],{"class":881},"\u003C\u002F",[38,3405,3241],{"class":2552},[38,3407,1435],{"class":881},[38,3409,3410],{"class":40,"line":156},[38,3411,82],{"emptyLinePlaceholder":81},[38,3413,3414,3416,3419],{"class":40,"line":162},[38,3415,1777],{"class":881},[38,3417,3418],{"class":2552},"template",[38,3420,1435],{"class":881},[38,3422,3423,3426,3428,3431,3433,3436,3439,3441,3444],{"class":40,"line":1017},[38,3424,3425],{"class":881},"  \u003C",[38,3427,1109],{"class":2552},[38,3429,3430],{"class":51}," :src",[38,3432,945],{"class":881},[38,3434,3435],{"class":55},"\"resolvedSrc\"",[38,3437,3438],{"class":51}," :alt",[38,3440,945],{"class":881},[38,3442,3443],{"class":55},"\"alt\"",[38,3445,3446],{"class":881}," \u002F>\n",[38,3448,3449,3451,3453],{"class":40,"line":1022},[38,3450,3403],{"class":881},[38,3452,3418],{"class":2552},[38,3454,1435],{"class":881},[16,3456,3457],{},[23,3458,3459],{},"第二步：启用 MDC prose 组件",[28,3461,3463],{"className":2104,"code":3462,"language":2106,"meta":33,"style":33},"\u002F\u002F nuxt.config.ts\nmdc: {\n  components: {\n    prose: true  \u002F\u002F 让 Nuxt Content 使用自定义 ProseImg\n  }\n}\n",[35,3464,3465,3469,3476,3483,3496,3500],{"__ignoreMap":33},[38,3466,3467],{"class":40,"line":41},[38,3468,2763],{"class":44},[38,3470,3471,3474],{"class":40,"line":48},[38,3472,3473],{"class":51},"mdc",[38,3475,2771],{"class":881},[38,3477,3478,3481],{"class":40,"line":78},[38,3479,3480],{"class":51},"  components",[38,3482,2771],{"class":881},[38,3484,3485,3488,3490,3493],{"class":40,"line":85},[38,3486,3487],{"class":51},"    prose",[38,3489,2556],{"class":881},[38,3491,3492],{"class":65},"true",[38,3494,3495],{"class":44},"  \u002F\u002F 让 Nuxt Content 使用自定义 ProseImg\n",[38,3497,3498],{"class":40,"line":91},[38,3499,2816],{"class":881},[38,3501,3502],{"class":40,"line":114},[38,3503,2153],{"class":881},[2717,3505,3507],{"id":3506},"问题三content-目录的图片如何服务","问题三：content 目录的图片如何服务",[11,3509,2732],{"id":3510},"现象-2",[16,3512,3513,3514,3517],{},"原站（SvelteKit）的图片放在 ",[35,3515,3516],{},"content\u002Fposts\u002F{slug}\u002Fimg\u002F"," 下，markdown 用相对路径引用，一切正常。Nuxt 项目里，content 目录的文件不会被当成静态资源服务。",[11,3519,3520],{"id":3520},"方案对比",[272,3522,3523,3536],{},[275,3524,3525],{},[278,3526,3527,3530,3533],{},[281,3528,3529],{},"方案",[281,3531,3532],{},"优点",[281,3534,3535],{},"缺点",[291,3537,3538,3549,3560],{},[278,3539,3540,3543,3546],{},[296,3541,3542],{},"复制到 public\u002F",[296,3544,3545],{},"简单",[296,3547,3548],{},"文件重复，需同步",[278,3550,3551,3554,3557],{},[296,3552,3553],{},"Vite 插件拦截",[296,3555,3556],{},"原站方案",[296,3558,3559],{},"Nuxt 不直接支持",[278,3561,3562,3565,3568],{},[296,3563,3564],{},"Server Route",[296,3566,3567],{},"不复制文件",[296,3569,3570],{},"需要手动写路由",[16,3572,3573,3574,3576],{},"最终选择 ",[23,3575,3564],{},"，和原站的 Vite 插件思路一致：",[28,3578,3580],{"className":2104,"code":3579,"language":2106,"meta":33,"style":33},"\u002F\u002F server\u002Froutes\u002Fposts\u002F[slug]\u002Fimg\u002F[...file].ts\nexport default defineEventHandler(async (event) => {\n  const url = getRequestURL(event)\n  const match = url.pathname.match(\u002F^\\\u002Fposts\\\u002F([^\u002F]+)\\\u002Fimg\\\u002F(.+)$\u002F)\n  if (!match) return\n\n  const [, slug, filename] = match\n  const filePath = resolve('content\u002Fposts', slug, 'img', filename)\n  const data = await readFile(filePath)\n  setResponseHeader(event, 'content-type', MIME[ext])\n  return data\n})\n",[35,3581,3582,3587,3613,3628,3692,3706,3710,3732,3758,3776,3795,3802],{"__ignoreMap":33},[38,3583,3584],{"class":40,"line":41},[38,3585,3586],{"class":44},"\u002F\u002F server\u002Froutes\u002Fposts\u002F[slug]\u002Fimg\u002F[...file].ts\n",[38,3588,3589,3591,3594,3597,3599,3602,3604,3607,3609,3611],{"class":40,"line":48},[38,3590,939],{"class":938},[38,3592,3593],{"class":938}," default",[38,3595,3596],{"class":51}," defineEventHandler",[38,3598,2851],{"class":881},[38,3600,3601],{"class":938},"async",[38,3603,2893],{"class":881},[38,3605,3606],{"class":2854},"event",[38,3608,3314],{"class":881},[38,3610,3278],{"class":938},[38,3612,2492],{"class":881},[38,3614,3615,3617,3620,3622,3625],{"class":40,"line":78},[38,3616,3339],{"class":938},[38,3618,3619],{"class":65}," url",[38,3621,2489],{"class":938},[38,3623,3624],{"class":51}," getRequestURL",[38,3626,3627],{"class":881},"(event)\n",[38,3629,3630,3632,3635,3637,3640,3643,3645,3647,3649,3651,3654,3656,3658,3661,3663,3666,3669,3672,3674,3676,3678,3680,3682,3684,3686,3688,3690],{"class":40,"line":85},[38,3631,3339],{"class":938},[38,3633,3634],{"class":65}," match",[38,3636,2489],{"class":938},[38,3638,3639],{"class":881}," url.pathname.",[38,3641,3642],{"class":51},"match",[38,3644,2851],{"class":881},[38,3646,2970],{"class":55},[38,3648,2957],{"class":938},[38,3650,3358],{"class":3357},[38,3652,3653],{"class":2960},"posts",[38,3655,3358],{"class":3357},[38,3657,2851],{"class":2960},[38,3659,3660],{"class":65},"[",[38,3662,2957],{"class":938},[38,3664,3665],{"class":65},"\u002F]",[38,3667,3668],{"class":938},"+",[38,3670,3671],{"class":2960},")",[38,3673,3358],{"class":3357},[38,3675,1109],{"class":2960},[38,3677,3358],{"class":3357},[38,3679,2851],{"class":2960},[38,3681,2973],{"class":65},[38,3683,3668],{"class":938},[38,3685,3671],{"class":2960},[38,3687,2967],{"class":938},[38,3689,2970],{"class":55},[38,3691,3160],{"class":881},[38,3693,3694,3696,3698,3700,3703],{"class":40,"line":91},[38,3695,2890],{"class":938},[38,3697,2893],{"class":881},[38,3699,2896],{"class":938},[38,3701,3702],{"class":881},"match) ",[38,3704,3705],{"class":938},"return\n",[38,3707,3708],{"class":40,"line":114},[38,3709,82],{"emptyLinePlaceholder":81},[38,3711,3712,3714,3717,3720,3722,3725,3727,3729],{"class":40,"line":119},[38,3713,3339],{"class":938},[38,3715,3716],{"class":881}," [, ",[38,3718,3719],{"class":65},"slug",[38,3721,1598],{"class":881},[38,3723,3724],{"class":65},"filename",[38,3726,2942],{"class":881},[38,3728,945],{"class":938},[38,3730,3731],{"class":881}," match\n",[38,3733,3734,3736,3739,3741,3744,3746,3749,3752,3755],{"class":40,"line":125},[38,3735,3339],{"class":938},[38,3737,3738],{"class":65}," filePath",[38,3740,2489],{"class":938},[38,3742,3743],{"class":51}," resolve",[38,3745,2851],{"class":881},[38,3747,3748],{"class":55},"'content\u002Fposts'",[38,3750,3751],{"class":881},", slug, ",[38,3753,3754],{"class":55},"'img'",[38,3756,3757],{"class":881},", filename)\n",[38,3759,3760,3762,3765,3767,3770,3773],{"class":40,"line":151},[38,3761,3339],{"class":938},[38,3763,3764],{"class":65}," data",[38,3766,2489],{"class":938},[38,3768,3769],{"class":938}," await",[38,3771,3772],{"class":51}," readFile",[38,3774,3775],{"class":881},"(filePath)\n",[38,3777,3778,3781,3784,3787,3789,3792],{"class":40,"line":156},[38,3779,3780],{"class":51},"  setResponseHeader",[38,3782,3783],{"class":881},"(event, ",[38,3785,3786],{"class":55},"'content-type'",[38,3788,1598],{"class":881},[38,3790,3791],{"class":65},"MIME",[38,3793,3794],{"class":881},"[ext])\n",[38,3796,3797,3799],{"class":40,"line":162},[38,3798,3108],{"class":938},[38,3800,3801],{"class":881}," data\n",[38,3803,3804],{"class":40,"line":1017},[38,3805,3398],{"class":881},[16,3807,3808,3809,3811,3812,3815],{},"请求 ",[35,3810,3174],{}," → 读取 ",[35,3813,3814],{},"content\u002Fposts\u002Fsshfs\u002Fimg\u002Fsshfs.avif"," → 返回图片。",[2717,3817,793],{"id":793},[272,3819,3820,3832],{},[275,3821,3822],{},[278,3823,3824,3827,3830],{},[281,3825,3826],{},"问题",[281,3828,3829],{},"根因",[281,3831,3529],{},[291,3833,3834,3848,3859],{},[278,3835,3836,3839,3845],{},[296,3837,3838],{},"TOC 只有 h2",[296,3840,3841,3844],{},[35,3842,3843],{},"toc.depth"," 配置不生效",[296,3846,3847],{},"从 body AST 手动提取",[278,3849,3850,3853,3856],{},[296,3851,3852],{},"图片路径错误",[296,3854,3855],{},"SPA 路由导致相对路径解析基准错误",[296,3857,3858],{},"ProseImg 组件 + mdc.prose 配置",[278,3860,3861,3864,3867],{},[296,3862,3863],{},"content 图片 404",[296,3865,3866],{},"content 目录不暴露给浏览器",[296,3868,3869],{},"Server Route 直接读取返回",[16,3871,3872],{},"Nuxt Content v3 的设计理念是\"图片放 public\u002F，用绝对路径\"。如果想像 SvelteKit 项目那样把图片和文章放一起，需要额外做不少工作。",[16,3874,3875],{},"不过一旦这些坑都踩过，整体开发体验还是很流畅的。Nuxt 的生态系统、自动导入、模块系统都比 SvelteKit 成熟很多。",[801,3877,3878],{},"html pre.shiki code .sHbNN, html code.shiki .sHbNN{--shiki-light:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .sw2iP, html code.shiki .sw2iP{--shiki-light:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sIX_F, html code.shiki .sIX_F{--shiki-light:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .suQ91, html code.shiki .suQ91{--shiki-light:#005CC5;--shiki-dark:#79B8FF}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .siTax, html code.shiki .siTax{--shiki-light:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sPK6S, html code.shiki .sPK6S{--shiki-light:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .scXbn, html code.shiki .scXbn{--shiki-light:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sPKmS, html code.shiki .sPKmS{--shiki-light:#032F62;--shiki-dark:#DBEDFF}html pre.shiki code .sCnZR, html code.shiki .sCnZR{--shiki-light:#22863A;--shiki-light-font-weight:bold;--shiki-dark:#85E89D;--shiki-dark-font-weight:bold}html pre.shiki code .smiIW, html code.shiki .smiIW{--shiki-light:#032F62;--shiki-light-text-decoration:underline;--shiki-dark:#DBEDFF;--shiki-dark-text-decoration:underline}html pre.shiki code .szbl0, html code.shiki .szbl0{--shiki-light:#24292E;--shiki-light-text-decoration:underline;--shiki-dark:#E1E4E8;--shiki-dark-text-decoration:underline}html pre.shiki code .sbB4o, html code.shiki .sbB4o{--shiki-light:#22863A;--shiki-dark:#85E89D}",{"title":33,"searchDepth":48,"depth":78,"links":3880},[3881,3882,3883,3884,3885,3886,3887,3888],{"id":2732,"depth":48,"text":2732},{"id":2746,"depth":48,"text":2746},{"id":1129,"depth":48,"text":1129},{"id":3131,"depth":48,"text":2732},{"id":3177,"depth":48,"text":2746},{"id":3212,"depth":48,"text":1129},{"id":3510,"depth":48,"text":2732},{"id":3520,"depth":48,"text":3520},"从 SvelteKit 迁移到 Nuxt 3 的过程中，遇到了不少 Nuxt Content v3 的坑。这篇文章记录了目录导航、图片路径、ProseImg 组件等问题的排查和解决过程。","\u002Fposts\u002Fnuxt-content-lessons\u002Fimg\u002Fcover.jpg",{},"\u002Fposts\u002Fnuxt-content-lessons","2026-05-31T14:00:00",{"title":2713,"description":3889},"posts\u002Fnuxt-content-lessons\u002Findex",[2199,3897,3898],"踩坑","Nuxt Content","vbSt6ND1z7xt7L8FxgZ8Qe3EdxV9InsRerN3E23GC7s",{"id":3901,"title":3902,"author":2308,"body":3903,"description":4168,"difficulty":1638,"draft":819,"extension":820,"image":4169,"meta":4170,"navigation":81,"path":4171,"pinned":819,"published":4172,"seo":4173,"series":2705,"seriesOrder":78,"stem":4174,"tags":4175,"updated":2709,"__hash__":4178},"posts\u002Fposts\u002Fnuxt-tailwind-underline-fix\u002Findex.md","Nuxt 3 + Tailwind CSS 客户端导航链接下划线闪烁问题",{"type":8,"value":3904,"toc":4161},[3905,3908,3911,3926,3929,3932,3935,3976,3987,3994,4016,4023,4027,4030,4033,4035,4038,4061,4072,4129,4134,4147,4149,4155,4158],[11,3906,3907],{"id":3907},"问题描述",[16,3909,3910],{},"在 Nuxt 3 项目中使用 Tailwind CSS 时，发现一个奇怪的现象：",[527,3912,3913,3923],{},[530,3914,3915,3916,3919,3920],{},"首次通过客户端导航（点击 ",[35,3917,3918],{},"\u003CNuxtLink>","）跳转到某个页面时，",[23,3921,3922],{},"所有链接文字都会出现下划线",[530,3924,3925],{},"手动刷新页面后，下划线消失，样式恢复正常",[16,3927,3928],{},"这个问题在开发模式和生产构建后都会出现。",[11,3930,3931],{"id":3931},"原因分析",[16,3933,3934],{},"Tailwind CSS 的 Preflight（基于 modern-normalize）会重置链接样式：",[28,3936,3940],{"className":3937,"code":3938,"language":3939,"meta":33,"style":33},"language-css shiki shiki-themes github-light github-dark","a {\n  color: inherit;\n  text-decoration: inherit;\n}\n","css",[35,3941,3942,3948,3961,3972],{"__ignoreMap":33},[38,3943,3944,3946],{"class":40,"line":41},[38,3945,2672],{"class":2552},[38,3947,2492],{"class":881},[38,3949,3950,3953,3955,3958],{"class":40,"line":48},[38,3951,3952],{"class":65},"  color",[38,3954,2556],{"class":881},[38,3956,3957],{"class":65},"inherit",[38,3959,3960],{"class":881},";\n",[38,3962,3963,3966,3968,3970],{"class":40,"line":78},[38,3964,3965],{"class":65},"  text-decoration",[38,3967,2556],{"class":881},[38,3969,3957],{"class":65},[38,3971,3960],{"class":881},[38,3973,3974],{"class":40,"line":85},[38,3975,2153],{"class":881},[16,3977,3978,3979,3982,3983,3986],{},"正常情况下，这会让链接继承父元素的 ",[35,3980,3981],{},"text-decoration","（通常是 ",[35,3984,3985],{},"none","），从而去掉浏览器默认的下划线。",[16,3988,3989,3990,3993],{},"问题出在 ",[23,3991,3992],{},"Nuxt 的客户端导航机制","：",[767,3995,3996,4002,4005,4010,4013],{},[530,3997,3998,3999,4001],{},"用户点击 ",[35,4000,3918],{},"，Vue Router 拦截导航",[530,4003,4004],{},"新页面组件异步加载并渲染",[530,4006,4007],{},[23,4008,4009],{},"在 CSS 完全加载\u002F应用之前，页面已经渲染了",[530,4011,4012],{},"此时浏览器使用默认样式（链接带下划线）",[530,4014,4015],{},"CSS 加载完成后，下划线消失",[16,4017,4018,4019,4022],{},"这就是典型的 ",[23,4020,4021],{},"FOUC（Flash of Unstyled Content）"," 问题。",[11,4024,4026],{"id":4025},"为什么刷新就正常了","为什么刷新就正常了？",[16,4028,4029],{},"刷新页面时，浏览器会等待所有 CSS 加载完成后再渲染页面（SSR 模式下 HTML 和 CSS 是一起返回的）。所以刷新后样式是正确的。",[16,4031,4032],{},"而客户端导航是 JavaScript 驱动的，CSS 的加载和页面的渲染是异步的，就出现了时序差。",[11,4034,1663],{"id":1663},[16,4036,4037],{},"尝试了多种方案：",[527,4039,4040,4046,4052,4058],{},[530,4041,4042,4045],{},[35,4043,4044],{},"@layer base"," 覆盖 — 无效，CSS layers 的优先级问题",[530,4047,4048,4051],{},[35,4049,4050],{},"corePlugins.preflight: false"," — 无效，且破坏其他基础样式",[530,4053,4054,4057],{},[35,4055,4056],{},"features.inlineStyles: true"," — 无效",[530,4059,4060],{},"页面过渡动画 — 只是掩盖，不能根治",[16,4062,4063,4064,4067,4068,4071],{},"最终有效的方案是在 ",[35,4065,4066],{},"app.vue"," 中用 ",[35,4069,4070],{},"!important"," 强制覆盖：",[28,4073,4075],{"className":3230,"code":4074,"language":3232,"meta":33,"style":33},"\u003Cstyle>\na:not(.prose a) {\n  text-decoration-line: none !important;\n}\n\u003C\u002Fstyle>\n",[35,4076,4077,4085,4103,4117,4121],{"__ignoreMap":33},[38,4078,4079,4081,4083],{"class":40,"line":41},[38,4080,1777],{"class":881},[38,4082,801],{"class":2552},[38,4084,1435],{"class":881},[38,4086,4087,4089,4092,4094,4097,4100],{"class":40,"line":48},[38,4088,2672],{"class":2552},[38,4090,4091],{"class":51},":not",[38,4093,2851],{"class":881},[38,4095,4096],{"class":51},".prose",[38,4098,4099],{"class":2552}," a",[38,4101,4102],{"class":881},") {\n",[38,4104,4105,4108,4110,4112,4115],{"class":40,"line":78},[38,4106,4107],{"class":65},"  text-decoration-line",[38,4109,2556],{"class":881},[38,4111,3985],{"class":65},[38,4113,4114],{"class":938}," !important",[38,4116,3960],{"class":881},[38,4118,4119],{"class":40,"line":85},[38,4120,2153],{"class":881},[38,4122,4123,4125,4127],{"class":40,"line":91},[38,4124,3403],{"class":881},[38,4126,801],{"class":2552},[38,4128,1435],{"class":881},[16,4130,4131],{},[23,4132,4133],{},"关键点：",[527,4135,4136,4141],{},[530,4137,4138,4140],{},[35,4139,4070],{}," 是为了对抗 Tailwind CSS layers 的优先级",[530,4142,4143,4146],{},[35,4144,4145],{},":not(.prose a)"," 排除文章正文区域，保留文章内链接的下划线（可读性需要）",[11,4148,793],{"id":793},[16,4150,4151,4152,4154],{},"这是 Nuxt 3 + Tailwind CSS 的一个已知问题，本质上是 CSS 加载时序导致的 FOUC。",[35,4153,4070],{}," 虽然不够优雅，但在这个场景下是务实的解决方案。",[16,4156,4157],{},"如果你有更好的方案，欢迎在项目仓库的 Issues 中提出！",[801,4159,4160],{},"html pre.shiki code .sbB4o, html code.shiki .sbB4o{--shiki-light:#22863A;--shiki-dark:#85E89D}html pre.shiki code .sIX_F, html code.shiki .sIX_F{--shiki-light:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .suQ91, html code.shiki .suQ91{--shiki-light:#005CC5;--shiki-dark:#79B8FF}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sw2iP, html code.shiki .sw2iP{--shiki-light:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .siTax, html code.shiki .siTax{--shiki-light:#D73A49;--shiki-dark:#F97583}",{"title":33,"searchDepth":48,"depth":78,"links":4162},[4163,4164,4165,4166,4167],{"id":3907,"depth":48,"text":3907},{"id":3931,"depth":48,"text":3931},{"id":4025,"depth":48,"text":4026},{"id":1663,"depth":48,"text":1663},{"id":793,"depth":48,"text":793},"记录 Nuxt 3 配合 Tailwind CSS 时，客户端导航导致链接文字出现下划线闪烁的问题及解决方案。","\u002Fposts\u002Fnuxt-tailwind-underline-fix\u002Fimg\u002Fcover.png",{},"\u002Fposts\u002Fnuxt-tailwind-underline-fix","2026-05-31T18:47:00",{"title":3902,"description":4168},"posts\u002Fnuxt-tailwind-underline-fix\u002Findex",[2199,4176,4177,3897],"Tailwind CSS","CSS","WMLpTEsIoJqufwEfXyIiBgUYxcPY2XkvOwqAD4hbe6g",{"id":4180,"title":4181,"author":6,"body":4182,"description":6232,"difficulty":1638,"draft":819,"extension":820,"image":6233,"meta":6234,"navigation":81,"path":6235,"pinned":819,"published":6236,"seo":6237,"series":6238,"seriesOrder":41,"stem":6239,"tags":6240,"updated":6244,"__hash__":6245},"posts\u002Fposts\u002Fgo-mcp-gin\u002Findex.md","Go-MCP 接入 Gin：从零搭建 MCP 服务器",{"type":8,"value":4183,"toc":6213},[4184,4188,4191,4194,4198,4205,4211,4214,4217,4237,4240,4244,4268,4272,4372,4385,4389,4553,4556,4773,4777,4788,4887,4894,4898,5172,5175,5256,5260,5263,5265,5276,5280,5286,5653,5843,5846,5902,5905,6058,6061,6064,6135,6137,6201,6210],[11,4185,4187],{"id":4186},"什么是-mcp","什么是 MCP",[16,4189,4190],{},"MCP（Model Context Protocol）是 Anthropic 提出的开放协议，用于让 AI 助手（如 Claude）与外部服务进行标准化交互。简单来说：你提供一个 MCP 服务器，AI 就能发现并调用你定义的工具（Tools），就像给 AI 装了一双手。",[16,4192,4193],{},"传统做法是写 REST API 然后让 AI 通过函数调用（Function Calling）间接使用，但 MCP 统一了协议层，AI 客户端只需要配置一个 URL 就能接入所有工具。",[11,4195,4197],{"id":4196},"为什么选-gin-mcp-go","为什么选 Gin + mcp-go",[16,4199,4200,4201,4204],{},"Go 生态里做 MCP 服务器最成熟的库是 ",[35,4202,4203],{},"mark3labs\u002Fmcp-go","，它支持 StreamableHTTP 传输模式，可以直接挂载到任意 HTTP 框架上。Gin 作为 Go 最流行的 HTTP 框架，天然适配。",[16,4206,4207,4208],{},"核心思路：",[23,4209,4210],{},"MCP 服务器本质上就是一个 HTTP Handler，挂到 Gin 路由上即可。",[11,4212,4213],{"id":4213},"需求分析",[16,4215,4216],{},"我们要搭建一个具备以下能力的 MCP 服务器：",[767,4218,4219,4225,4231],{},[530,4220,4221,4224],{},[23,4222,4223],{},"工具注册"," — 向 AI 暴露可调用的工具（列出资源、查看详情、创建\u002F删除）",[530,4226,4227,4230],{},[23,4228,4229],{},"HTTP 集成"," — MCP 端点作为 Gin 路由的一部分，与业务 API 共存",[530,4232,4233,4236],{},[23,4234,4235],{},"鉴权"," — MCP 请求必须经过身份验证，工具处理器能获取当前用户信息",[11,4238,4239],{"id":4239},"实现步骤",[675,4241,4243],{"id":4242},"_1-安装依赖","1. 安装依赖",[28,4245,4247],{"className":30,"code":4246,"language":32,"meta":33,"style":33},"go get github.com\u002Fmark3labs\u002Fmcp-go@v0.54.0\ngo get github.com\u002Fgin-gonic\u002Fgin@v1.12.0\n",[35,4248,4249,4259],{"__ignoreMap":33},[38,4250,4251,4253,4256],{"class":40,"line":41},[38,4252,976],{"class":51},[38,4254,4255],{"class":55}," get",[38,4257,4258],{"class":55}," github.com\u002Fmark3labs\u002Fmcp-go@v0.54.0\n",[38,4260,4261,4263,4265],{"class":40,"line":48},[38,4262,976],{"class":51},[38,4264,4255],{"class":55},[38,4266,4267],{"class":55}," github.com\u002Fgin-gonic\u002Fgin@v1.12.0\n",[675,4269,4271],{"id":4270},"_2-创建-mcp-服务器","2. 创建 MCP 服务器",[28,4273,4276],{"className":4274,"code":4275,"language":976,"meta":33,"style":33},"language-go shiki shiki-themes github-light github-dark","import \"github.com\u002Fmark3labs\u002Fmcp-go\u002Fserver\"\n\nmcpServer := server.NewMCPServer(\n    \"my-app\",    \u002F\u002F 服务器名称\n    \"1.0.0\",     \u002F\u002F 版本\n    server.WithLogging(),\n    server.WithRecovery(),\n    server.WithInstructions(\"这是一个 MCP 服务器示例\"),\n)\n",[35,4277,4278,4290,4294,4311,4322,4333,4344,4353,4368],{"__ignoreMap":33},[38,4279,4280,4283,4285,4288],{"class":40,"line":41},[38,4281,4282],{"class":938},"import",[38,4284,878],{"class":55},[38,4286,4287],{"class":51},"github.com\u002Fmark3labs\u002Fmcp-go\u002Fserver",[38,4289,1081],{"class":55},[38,4291,4292],{"class":40,"line":48},[38,4293,82],{"emptyLinePlaceholder":81},[38,4295,4296,4299,4302,4305,4308],{"class":40,"line":78},[38,4297,4298],{"class":881},"mcpServer ",[38,4300,4301],{"class":938},":=",[38,4303,4304],{"class":881}," server.",[38,4306,4307],{"class":51},"NewMCPServer",[38,4309,4310],{"class":881},"(\n",[38,4312,4313,4316,4319],{"class":40,"line":85},[38,4314,4315],{"class":55},"    \"my-app\"",[38,4317,4318],{"class":881},",    ",[38,4320,4321],{"class":44},"\u002F\u002F 服务器名称\n",[38,4323,4324,4327,4330],{"class":40,"line":91},[38,4325,4326],{"class":55},"    \"1.0.0\"",[38,4328,4329],{"class":881},",     ",[38,4331,4332],{"class":44},"\u002F\u002F 版本\n",[38,4334,4335,4338,4341],{"class":40,"line":114},[38,4336,4337],{"class":881},"    server.",[38,4339,4340],{"class":51},"WithLogging",[38,4342,4343],{"class":881},"(),\n",[38,4345,4346,4348,4351],{"class":40,"line":119},[38,4347,4337],{"class":881},[38,4349,4350],{"class":51},"WithRecovery",[38,4352,4343],{"class":881},[38,4354,4355,4357,4360,4362,4365],{"class":40,"line":125},[38,4356,4337],{"class":881},[38,4358,4359],{"class":51},"WithInstructions",[38,4361,2851],{"class":881},[38,4363,4364],{"class":55},"\"这是一个 MCP 服务器示例\"",[38,4366,4367],{"class":881},"),\n",[38,4369,4370],{"class":40,"line":151},[38,4371,3160],{"class":881},[16,4373,4374,4376,4377,4380,4381,4384],{},[35,4375,4307],{}," 创建一个 MCP 协议实例，它负责处理工具发现（",[35,4378,4379],{},"tools\u002Flist","）和工具调用（",[35,4382,4383],{},"tools\u002Fcall","）的协议逻辑。",[675,4386,4388],{"id":4387},"_3-注册工具","3. 注册工具",[28,4390,4392],{"className":4274,"code":4391,"language":976,"meta":33,"style":33},"import \"github.com\u002Fmark3labs\u002Fmcp-go\u002Fmcp\"\n\n\u002F\u002F 定义工具\nlistTool := mcp.NewTool(\"list_items\",\n    mcp.WithDescription(\"列出所有项目\"),\n)\n\n\u002F\u002F 注册处理器\nmcpServer.AddTool(listTool, func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {\n    \u002F\u002F 业务逻辑\n    return mcp.NewToolResultText(\"返回结果\"), nil\n})\n",[35,4393,4394,4405,4409,4414,4434,4449,4453,4457,4462,4523,4528,4549],{"__ignoreMap":33},[38,4395,4396,4398,4400,4403],{"class":40,"line":41},[38,4397,4282],{"class":938},[38,4399,878],{"class":55},[38,4401,4402],{"class":51},"github.com\u002Fmark3labs\u002Fmcp-go\u002Fmcp",[38,4404,1081],{"class":55},[38,4406,4407],{"class":40,"line":48},[38,4408,82],{"emptyLinePlaceholder":81},[38,4410,4411],{"class":40,"line":78},[38,4412,4413],{"class":44},"\u002F\u002F 定义工具\n",[38,4415,4416,4419,4421,4424,4427,4429,4432],{"class":40,"line":85},[38,4417,4418],{"class":881},"listTool ",[38,4420,4301],{"class":938},[38,4422,4423],{"class":881}," mcp.",[38,4425,4426],{"class":51},"NewTool",[38,4428,2851],{"class":881},[38,4430,4431],{"class":55},"\"list_items\"",[38,4433,2503],{"class":881},[38,4435,4436,4439,4442,4444,4447],{"class":40,"line":91},[38,4437,4438],{"class":881},"    mcp.",[38,4440,4441],{"class":51},"WithDescription",[38,4443,2851],{"class":881},[38,4445,4446],{"class":55},"\"列出所有项目\"",[38,4448,4367],{"class":881},[38,4450,4451],{"class":40,"line":114},[38,4452,3160],{"class":881},[38,4454,4455],{"class":40,"line":119},[38,4456,82],{"emptyLinePlaceholder":81},[38,4458,4459],{"class":40,"line":125},[38,4460,4461],{"class":44},"\u002F\u002F 注册处理器\n",[38,4463,4464,4467,4470,4473,4476,4478,4481,4484,4486,4489,4491,4494,4497,4499,4502,4505,4508,4511,4513,4516,4518,4521],{"class":40,"line":151},[38,4465,4466],{"class":881},"mcpServer.",[38,4468,4469],{"class":51},"AddTool",[38,4471,4472],{"class":881},"(listTool, ",[38,4474,4475],{"class":938},"func",[38,4477,2851],{"class":881},[38,4479,4480],{"class":2854},"ctx",[38,4482,4483],{"class":51}," context",[38,4485,2973],{"class":881},[38,4487,4488],{"class":51},"Context",[38,4490,1598],{"class":881},[38,4492,4493],{"class":2854},"req",[38,4495,4496],{"class":51}," mcp",[38,4498,2973],{"class":881},[38,4500,4501],{"class":51},"CallToolRequest",[38,4503,4504],{"class":881},") (",[38,4506,4507],{"class":938},"*",[38,4509,4510],{"class":51},"mcp",[38,4512,2973],{"class":881},[38,4514,4515],{"class":51},"CallToolResult",[38,4517,1598],{"class":881},[38,4519,4520],{"class":938},"error",[38,4522,4102],{"class":881},[38,4524,4525],{"class":40,"line":156},[38,4526,4527],{"class":44},"    \u002F\u002F 业务逻辑\n",[38,4529,4530,4533,4535,4538,4540,4543,4546],{"class":40,"line":162},[38,4531,4532],{"class":938},"    return",[38,4534,4423],{"class":881},[38,4536,4537],{"class":51},"NewToolResultText",[38,4539,2851],{"class":881},[38,4541,4542],{"class":55},"\"返回结果\"",[38,4544,4545],{"class":881},"), ",[38,4547,4548],{"class":65},"nil\n",[38,4550,4551],{"class":40,"line":1017},[38,4552,3398],{"class":881},[16,4554,4555],{},"带参数的工具：",[28,4557,4559],{"className":4274,"code":4558,"language":976,"meta":33,"style":33},"getTool := mcp.NewTool(\"get_item\",\n    mcp.WithDescription(\"获取指定项目的详情\"),\n    mcp.WithString(\"item_id\",\n        mcp.Required(),\n        mcp.Description(\"项目 ID\"),\n    ),\n)\n\nmcpServer.AddTool(getTool, func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {\n    itemID, err := req.RequireString(\"item_id\")\n    if err != nil {\n        return mcp.NewToolResultError(\"缺少 item_id 参数\"), nil\n    }\n    \u002F\u002F 查询并返回\n    return mcp.NewToolResultText(\"项目详情...\"), nil\n})\n",[35,4560,4561,4579,4592,4606,4616,4630,4635,4639,4643,4690,4709,4724,4743,4747,4752,4769],{"__ignoreMap":33},[38,4562,4563,4566,4568,4570,4572,4574,4577],{"class":40,"line":41},[38,4564,4565],{"class":881},"getTool ",[38,4567,4301],{"class":938},[38,4569,4423],{"class":881},[38,4571,4426],{"class":51},[38,4573,2851],{"class":881},[38,4575,4576],{"class":55},"\"get_item\"",[38,4578,2503],{"class":881},[38,4580,4581,4583,4585,4587,4590],{"class":40,"line":48},[38,4582,4438],{"class":881},[38,4584,4441],{"class":51},[38,4586,2851],{"class":881},[38,4588,4589],{"class":55},"\"获取指定项目的详情\"",[38,4591,4367],{"class":881},[38,4593,4594,4596,4599,4601,4604],{"class":40,"line":78},[38,4595,4438],{"class":881},[38,4597,4598],{"class":51},"WithString",[38,4600,2851],{"class":881},[38,4602,4603],{"class":55},"\"item_id\"",[38,4605,2503],{"class":881},[38,4607,4608,4611,4614],{"class":40,"line":85},[38,4609,4610],{"class":881},"        mcp.",[38,4612,4613],{"class":51},"Required",[38,4615,4343],{"class":881},[38,4617,4618,4620,4623,4625,4628],{"class":40,"line":91},[38,4619,4610],{"class":881},[38,4621,4622],{"class":51},"Description",[38,4624,2851],{"class":881},[38,4626,4627],{"class":55},"\"项目 ID\"",[38,4629,4367],{"class":881},[38,4631,4632],{"class":40,"line":114},[38,4633,4634],{"class":881},"    ),\n",[38,4636,4637],{"class":40,"line":119},[38,4638,3160],{"class":881},[38,4640,4641],{"class":40,"line":125},[38,4642,82],{"emptyLinePlaceholder":81},[38,4644,4645,4647,4649,4652,4654,4656,4658,4660,4662,4664,4666,4668,4670,4672,4674,4676,4678,4680,4682,4684,4686,4688],{"class":40,"line":151},[38,4646,4466],{"class":881},[38,4648,4469],{"class":51},[38,4650,4651],{"class":881},"(getTool, ",[38,4653,4475],{"class":938},[38,4655,2851],{"class":881},[38,4657,4480],{"class":2854},[38,4659,4483],{"class":51},[38,4661,2973],{"class":881},[38,4663,4488],{"class":51},[38,4665,1598],{"class":881},[38,4667,4493],{"class":2854},[38,4669,4496],{"class":51},[38,4671,2973],{"class":881},[38,4673,4501],{"class":51},[38,4675,4504],{"class":881},[38,4677,4507],{"class":938},[38,4679,4510],{"class":51},[38,4681,2973],{"class":881},[38,4683,4515],{"class":51},[38,4685,1598],{"class":881},[38,4687,4520],{"class":938},[38,4689,4102],{"class":881},[38,4691,4692,4695,4697,4700,4703,4705,4707],{"class":40,"line":156},[38,4693,4694],{"class":881},"    itemID, err ",[38,4696,4301],{"class":938},[38,4698,4699],{"class":881}," req.",[38,4701,4702],{"class":51},"RequireString",[38,4704,2851],{"class":881},[38,4706,4603],{"class":55},[38,4708,3160],{"class":881},[38,4710,4711,4713,4716,4719,4722],{"class":40,"line":162},[38,4712,2928],{"class":938},[38,4714,4715],{"class":881}," err ",[38,4717,4718],{"class":938},"!=",[38,4720,4721],{"class":65}," nil",[38,4723,2492],{"class":881},[38,4725,4726,4729,4731,4734,4736,4739,4741],{"class":40,"line":1017},[38,4727,4728],{"class":938},"        return",[38,4730,4423],{"class":881},[38,4732,4733],{"class":51},"NewToolResultError",[38,4735,2851],{"class":881},[38,4737,4738],{"class":55},"\"缺少 item_id 参数\"",[38,4740,4545],{"class":881},[38,4742,4548],{"class":65},[38,4744,4745],{"class":40,"line":1022},[38,4746,2811],{"class":881},[38,4748,4749],{"class":40,"line":1028},[38,4750,4751],{"class":44},"    \u002F\u002F 查询并返回\n",[38,4753,4754,4756,4758,4760,4762,4765,4767],{"class":40,"line":1039},[38,4755,4532],{"class":938},[38,4757,4423],{"class":881},[38,4759,4537],{"class":51},[38,4761,2851],{"class":881},[38,4763,4764],{"class":55},"\"项目详情...\"",[38,4766,4545],{"class":881},[38,4768,4548],{"class":65},[38,4770,4771],{"class":40,"line":1050},[38,4772,3398],{"class":881},[675,4774,4776],{"id":4775},"_4-挂载到-gin-路由","4. 挂载到 Gin 路由",[16,4778,4779,4780,4783,4784,4787],{},"关键一步：用 ",[35,4781,4782],{},"server.NewStreamableHTTPServer"," 把 MCP 实例转成 HTTP Handler，再用 ",[35,4785,4786],{},"gin.WrapH"," 适配 Gin。",[28,4789,4791],{"className":4274,"code":4790,"language":976,"meta":33,"style":33},"import \"github.com\u002Fmark3labs\u002Fmcp-go\u002Fserver\"\n\nmcpHTTPServer := server.NewStreamableHTTPServer(mcpServer,\n    server.WithEndpointPath(\"\u002Fmcp\"),\n    server.WithHeartbeatInterval(30*time.Second),\n)\n\n\u002F\u002F 挂载到 Gin\nr.Any(\"\u002Fmcp\", gin.WrapH(mcpHTTPServer))\n",[35,4792,4793,4803,4807,4822,4836,4853,4857,4861,4866],{"__ignoreMap":33},[38,4794,4795,4797,4799,4801],{"class":40,"line":41},[38,4796,4282],{"class":938},[38,4798,878],{"class":55},[38,4800,4287],{"class":51},[38,4802,1081],{"class":55},[38,4804,4805],{"class":40,"line":48},[38,4806,82],{"emptyLinePlaceholder":81},[38,4808,4809,4812,4814,4816,4819],{"class":40,"line":78},[38,4810,4811],{"class":881},"mcpHTTPServer ",[38,4813,4301],{"class":938},[38,4815,4304],{"class":881},[38,4817,4818],{"class":51},"NewStreamableHTTPServer",[38,4820,4821],{"class":881},"(mcpServer,\n",[38,4823,4824,4826,4829,4831,4834],{"class":40,"line":85},[38,4825,4337],{"class":881},[38,4827,4828],{"class":51},"WithEndpointPath",[38,4830,2851],{"class":881},[38,4832,4833],{"class":55},"\"\u002Fmcp\"",[38,4835,4367],{"class":881},[38,4837,4838,4840,4843,4845,4848,4850],{"class":40,"line":91},[38,4839,4337],{"class":881},[38,4841,4842],{"class":51},"WithHeartbeatInterval",[38,4844,2851],{"class":881},[38,4846,4847],{"class":65},"30",[38,4849,4507],{"class":938},[38,4851,4852],{"class":881},"time.Second),\n",[38,4854,4855],{"class":40,"line":114},[38,4856,3160],{"class":881},[38,4858,4859],{"class":40,"line":119},[38,4860,82],{"emptyLinePlaceholder":81},[38,4862,4863],{"class":40,"line":125},[38,4864,4865],{"class":44},"\u002F\u002F 挂载到 Gin\n",[38,4867,4868,4871,4874,4876,4878,4881,4884],{"class":40,"line":151},[38,4869,4870],{"class":881},"r.",[38,4872,4873],{"class":51},"Any",[38,4875,2851],{"class":881},[38,4877,4833],{"class":55},[38,4879,4880],{"class":881},", gin.",[38,4882,4883],{"class":51},"WrapH",[38,4885,4886],{"class":881},"(mcpHTTPServer))\n",[16,4888,4889,4890,4893],{},"就这样，",[35,4891,4892],{},"POST \u002Fmcp"," 端点就生效了。AI 客户端通过这个 URL 发送 JSON-RPC 请求，MCP 服务器自动处理协议握手、工具列表返回、工具调用路由。",[675,4895,4897],{"id":4896},"_5-完整的服务启动代码","5. 完整的服务启动代码",[28,4899,4901],{"className":4274,"code":4900,"language":976,"meta":33,"style":33},"func main() {\n    r := gin.Default()\n\n    \u002F\u002F 创建 MCP 服务器\n    mcpServer := server.NewMCPServer(\"my-app\", \"1.0.0\",\n        server.WithLogging(),\n        server.WithRecovery(),\n    )\n\n    \u002F\u002F 注册工具\n    registerTools(mcpServer)\n\n    \u002F\u002F 挂载到 Gin\n    mcpHTTP := server.NewStreamableHTTPServer(mcpServer,\n        server.WithEndpointPath(\"\u002Fmcp\"),\n        server.WithHeartbeatInterval(30*time.Second),\n    )\n    r.Any(\"\u002Fmcp\", gin.WrapH(mcpHTTP))\n\n    \u002F\u002F 其他业务路由\n    r.GET(\"\u002Fapi\u002Fhealth\", func(c *gin.Context) {\n        c.JSON(200, gin.H{\"status\": \"ok\"})\n    })\n\n    r.Run(\":8080\")\n}\n",[35,4902,4903,4912,4927,4931,4936,4959,4968,4976,4981,4985,4990,4998,5002,5007,5020,5032,5046,5050,5068,5072,5077,5110,5145,5150,5154,5168],{"__ignoreMap":33},[38,4904,4905,4907,4910],{"class":40,"line":41},[38,4906,4475],{"class":938},[38,4908,4909],{"class":51}," main",[38,4911,2137],{"class":881},[38,4913,4914,4917,4919,4922,4925],{"class":40,"line":48},[38,4915,4916],{"class":881},"    r ",[38,4918,4301],{"class":938},[38,4920,4921],{"class":881}," gin.",[38,4923,4924],{"class":51},"Default",[38,4926,3045],{"class":881},[38,4928,4929],{"class":40,"line":78},[38,4930,82],{"emptyLinePlaceholder":81},[38,4932,4933],{"class":40,"line":85},[38,4934,4935],{"class":44},"    \u002F\u002F 创建 MCP 服务器\n",[38,4937,4938,4941,4943,4945,4947,4949,4952,4954,4957],{"class":40,"line":91},[38,4939,4940],{"class":881},"    mcpServer ",[38,4942,4301],{"class":938},[38,4944,4304],{"class":881},[38,4946,4307],{"class":51},[38,4948,2851],{"class":881},[38,4950,4951],{"class":55},"\"my-app\"",[38,4953,1598],{"class":881},[38,4955,4956],{"class":55},"\"1.0.0\"",[38,4958,2503],{"class":881},[38,4960,4961,4964,4966],{"class":40,"line":114},[38,4962,4963],{"class":881},"        server.",[38,4965,4340],{"class":51},[38,4967,4343],{"class":881},[38,4969,4970,4972,4974],{"class":40,"line":119},[38,4971,4963],{"class":881},[38,4973,4350],{"class":51},[38,4975,4343],{"class":881},[38,4977,4978],{"class":40,"line":125},[38,4979,4980],{"class":881},"    )\n",[38,4982,4983],{"class":40,"line":151},[38,4984,82],{"emptyLinePlaceholder":81},[38,4986,4987],{"class":40,"line":156},[38,4988,4989],{"class":44},"    \u002F\u002F 注册工具\n",[38,4991,4992,4995],{"class":40,"line":162},[38,4993,4994],{"class":51},"    registerTools",[38,4996,4997],{"class":881},"(mcpServer)\n",[38,4999,5000],{"class":40,"line":1017},[38,5001,82],{"emptyLinePlaceholder":81},[38,5003,5004],{"class":40,"line":1022},[38,5005,5006],{"class":44},"    \u002F\u002F 挂载到 Gin\n",[38,5008,5009,5012,5014,5016,5018],{"class":40,"line":1028},[38,5010,5011],{"class":881},"    mcpHTTP ",[38,5013,4301],{"class":938},[38,5015,4304],{"class":881},[38,5017,4818],{"class":51},[38,5019,4821],{"class":881},[38,5021,5022,5024,5026,5028,5030],{"class":40,"line":1039},[38,5023,4963],{"class":881},[38,5025,4828],{"class":51},[38,5027,2851],{"class":881},[38,5029,4833],{"class":55},[38,5031,4367],{"class":881},[38,5033,5034,5036,5038,5040,5042,5044],{"class":40,"line":1050},[38,5035,4963],{"class":881},[38,5037,4842],{"class":51},[38,5039,2851],{"class":881},[38,5041,4847],{"class":65},[38,5043,4507],{"class":938},[38,5045,4852],{"class":881},[38,5047,5048],{"class":40,"line":1055},[38,5049,4980],{"class":881},[38,5051,5052,5055,5057,5059,5061,5063,5065],{"class":40,"line":1064},[38,5053,5054],{"class":881},"    r.",[38,5056,4873],{"class":51},[38,5058,2851],{"class":881},[38,5060,4833],{"class":55},[38,5062,4880],{"class":881},[38,5064,4883],{"class":51},[38,5066,5067],{"class":881},"(mcpHTTP))\n",[38,5069,5070],{"class":40,"line":1069},[38,5071,82],{"emptyLinePlaceholder":81},[38,5073,5074],{"class":40,"line":1276},[38,5075,5076],{"class":44},"    \u002F\u002F 其他业务路由\n",[38,5078,5079,5081,5084,5086,5089,5091,5093,5095,5098,5101,5104,5106,5108],{"class":40,"line":1284},[38,5080,5054],{"class":881},[38,5082,5083],{"class":51},"GET",[38,5085,2851],{"class":881},[38,5087,5088],{"class":55},"\"\u002Fapi\u002Fhealth\"",[38,5090,1598],{"class":881},[38,5092,4475],{"class":938},[38,5094,2851],{"class":881},[38,5096,5097],{"class":2854},"c",[38,5099,5100],{"class":938}," *",[38,5102,5103],{"class":51},"gin",[38,5105,2973],{"class":881},[38,5107,4488],{"class":51},[38,5109,4102],{"class":881},[38,5111,5112,5115,5118,5120,5123,5125,5127,5129,5132,5135,5138,5140,5143],{"class":40,"line":1289},[38,5113,5114],{"class":881},"        c.",[38,5116,5117],{"class":51},"JSON",[38,5119,2851],{"class":881},[38,5121,5122],{"class":65},"200",[38,5124,1598],{"class":881},[38,5126,5103],{"class":51},[38,5128,2973],{"class":881},[38,5130,5131],{"class":51},"H",[38,5133,5134],{"class":881},"{",[38,5136,5137],{"class":55},"\"status\"",[38,5139,2556],{"class":881},[38,5141,5142],{"class":55},"\"ok\"",[38,5144,3398],{"class":881},[38,5146,5147],{"class":40,"line":1921},[38,5148,5149],{"class":881},"    })\n",[38,5151,5152],{"class":40,"line":1935},[38,5153,82],{"emptyLinePlaceholder":81},[38,5155,5156,5158,5161,5163,5166],{"class":40,"line":1942},[38,5157,5054],{"class":881},[38,5159,5160],{"class":51},"Run",[38,5162,2851],{"class":881},[38,5164,5165],{"class":55},"\":8080\"",[38,5167,3160],{"class":881},[38,5169,5170],{"class":40,"line":1947},[38,5171,2153],{"class":881},[16,5173,5174],{},"此时启动服务，用 curl 测试：",[28,5176,5178],{"className":30,"code":5177,"language":32,"meta":33,"style":33},"# 发现工具列表\ncurl -X POST http:\u002F\u002Flocalhost:8080\u002Fmcp \\\n  -H \"Content-Type: application\u002Fjson\" \\\n  -d '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools\u002Flist\"}'\n\n# 调用工具\ncurl -X POST http:\u002F\u002Flocalhost:8080\u002Fmcp \\\n  -H \"Content-Type: application\u002Fjson\" \\\n  -d '{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools\u002Fcall\",\"params\":{\"name\":\"list_items\",\"arguments\":{}}}'\n",[35,5179,5180,5185,5202,5212,5220,5224,5229,5241,5249],{"__ignoreMap":33},[38,5181,5182],{"class":40,"line":41},[38,5183,5184],{"class":44},"# 发现工具列表\n",[38,5186,5187,5190,5193,5196,5199],{"class":40,"line":48},[38,5188,5189],{"class":51},"curl",[38,5191,5192],{"class":65}," -X",[38,5194,5195],{"class":55}," POST",[38,5197,5198],{"class":55}," http:\u002F\u002Flocalhost:8080\u002Fmcp",[38,5200,5201],{"class":65}," \\\n",[38,5203,5204,5207,5210],{"class":40,"line":78},[38,5205,5206],{"class":65},"  -H",[38,5208,5209],{"class":55}," \"Content-Type: application\u002Fjson\"",[38,5211,5201],{"class":65},[38,5213,5214,5217],{"class":40,"line":85},[38,5215,5216],{"class":65},"  -d",[38,5218,5219],{"class":55}," '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools\u002Flist\"}'\n",[38,5221,5222],{"class":40,"line":91},[38,5223,82],{"emptyLinePlaceholder":81},[38,5225,5226],{"class":40,"line":114},[38,5227,5228],{"class":44},"# 调用工具\n",[38,5230,5231,5233,5235,5237,5239],{"class":40,"line":119},[38,5232,5189],{"class":51},[38,5234,5192],{"class":65},[38,5236,5195],{"class":55},[38,5238,5198],{"class":55},[38,5240,5201],{"class":65},[38,5242,5243,5245,5247],{"class":40,"line":125},[38,5244,5206],{"class":65},[38,5246,5209],{"class":55},[38,5248,5201],{"class":65},[38,5250,5251,5253],{"class":40,"line":151},[38,5252,5216],{"class":65},[38,5254,5255],{"class":55}," '{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools\u002Fcall\",\"params\":{\"name\":\"list_items\",\"arguments\":{}}}'\n",[11,5257,5259],{"id":5258},"拓展接入鉴权","拓展：接入鉴权",[16,5261,5262],{},"裸奔的 MCP 端点任何人都能调用，生产环境必须加鉴权。思路和普通 API 鉴权一致：Bearer Token。",[675,5264,3826],{"id":3826},[16,5266,5267,5268,5271,5272,5275],{},"MCP 的工具处理器签名为 ",[35,5269,5270],{},"func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error)","，它不接收 Gin 的 ",[35,5273,5274],{},"*gin.Context","，所以无法直接在工具处理器里读取请求头。",[675,5277,5279],{"id":5278},"解决方案中间件传递用户信息","解决方案：中间件传递用户信息",[16,5281,5282,5283,2743],{},"用两层中间件：第一层做 Gin 的 Token 验证，第二层把用户信息从 Gin Context 复制到 ",[35,5284,5285],{},"request.Context",[28,5287,5289],{"className":4274,"code":5288,"language":976,"meta":33,"style":33},"\u002F\u002F Gin 鉴权中间件\nfunc AuthRequired(authService *AuthService) gin.HandlerFunc {\n    return func(c *gin.Context) {\n        authHeader := c.GetHeader(\"Authorization\")\n        if authHeader == \"\" {\n            c.AbortWithStatusJSON(401, gin.H{\"message\": \"缺少认证令牌\"})\n            return\n        }\n\n        parts := strings.SplitN(authHeader, \" \", 2)\n        if len(parts) != 2 || !strings.EqualFold(parts[0], \"Bearer\") {\n            c.AbortWithStatusJSON(401, gin.H{\"message\": \"格式错误，请使用 Bearer \u003Ctoken>\"})\n            return\n        }\n\n        user, err := authService.GetUserByToken(parts[1])\n        if err != nil {\n            c.AbortWithStatusJSON(401, gin.H{\"message\": \"无效的认证令牌\"})\n            return\n        }\n\n        c.Set(\"userID\", user.ID)\n        c.Set(\"username\", user.Username)\n        c.Next()\n    }\n}\n",[35,5290,5291,5296,5324,5345,5365,5381,5414,5419,5424,5428,5453,5493,5522,5526,5530,5534,5554,5566,5595,5599,5603,5607,5622,5636,5645,5649],{"__ignoreMap":33},[38,5292,5293],{"class":40,"line":41},[38,5294,5295],{"class":44},"\u002F\u002F Gin 鉴权中间件\n",[38,5297,5298,5300,5303,5305,5308,5310,5313,5315,5317,5319,5322],{"class":40,"line":48},[38,5299,4475],{"class":938},[38,5301,5302],{"class":51}," AuthRequired",[38,5304,2851],{"class":881},[38,5306,5307],{"class":2854},"authService",[38,5309,5100],{"class":938},[38,5311,5312],{"class":51},"AuthService",[38,5314,3314],{"class":881},[38,5316,5103],{"class":51},[38,5318,2973],{"class":881},[38,5320,5321],{"class":51},"HandlerFunc",[38,5323,2492],{"class":881},[38,5325,5326,5328,5331,5333,5335,5337,5339,5341,5343],{"class":40,"line":78},[38,5327,4532],{"class":938},[38,5329,5330],{"class":938}," func",[38,5332,2851],{"class":881},[38,5334,5097],{"class":2854},[38,5336,5100],{"class":938},[38,5338,5103],{"class":51},[38,5340,2973],{"class":881},[38,5342,4488],{"class":51},[38,5344,4102],{"class":881},[38,5346,5347,5350,5352,5355,5358,5360,5363],{"class":40,"line":85},[38,5348,5349],{"class":881},"        authHeader ",[38,5351,4301],{"class":938},[38,5353,5354],{"class":881}," c.",[38,5356,5357],{"class":51},"GetHeader",[38,5359,2851],{"class":881},[38,5361,5362],{"class":55},"\"Authorization\"",[38,5364,3160],{"class":881},[38,5366,5367,5370,5373,5376,5379],{"class":40,"line":91},[38,5368,5369],{"class":938},"        if",[38,5371,5372],{"class":881}," authHeader ",[38,5374,5375],{"class":938},"==",[38,5377,5378],{"class":55}," \"\"",[38,5380,2492],{"class":881},[38,5382,5383,5386,5389,5391,5394,5396,5398,5400,5402,5404,5407,5409,5412],{"class":40,"line":114},[38,5384,5385],{"class":881},"            c.",[38,5387,5388],{"class":51},"AbortWithStatusJSON",[38,5390,2851],{"class":881},[38,5392,5393],{"class":65},"401",[38,5395,1598],{"class":881},[38,5397,5103],{"class":51},[38,5399,2973],{"class":881},[38,5401,5131],{"class":51},[38,5403,5134],{"class":881},[38,5405,5406],{"class":55},"\"message\"",[38,5408,2556],{"class":881},[38,5410,5411],{"class":55},"\"缺少认证令牌\"",[38,5413,3398],{"class":881},[38,5415,5416],{"class":40,"line":119},[38,5417,5418],{"class":938},"            return\n",[38,5420,5421],{"class":40,"line":125},[38,5422,5423],{"class":881},"        }\n",[38,5425,5426],{"class":40,"line":151},[38,5427,82],{"emptyLinePlaceholder":81},[38,5429,5430,5433,5435,5438,5441,5444,5447,5449,5451],{"class":40,"line":156},[38,5431,5432],{"class":881},"        parts ",[38,5434,4301],{"class":938},[38,5436,5437],{"class":881}," strings.",[38,5439,5440],{"class":51},"SplitN",[38,5442,5443],{"class":881},"(authHeader, ",[38,5445,5446],{"class":55},"\" \"",[38,5448,1598],{"class":881},[38,5450,497],{"class":65},[38,5452,3160],{"class":881},[38,5454,5455,5457,5460,5463,5465,5468,5471,5474,5477,5480,5483,5485,5488,5491],{"class":40,"line":162},[38,5456,5369],{"class":938},[38,5458,5459],{"class":51}," len",[38,5461,5462],{"class":881},"(parts) ",[38,5464,4718],{"class":938},[38,5466,5467],{"class":65}," 2",[38,5469,5470],{"class":938}," ||",[38,5472,5473],{"class":938}," !",[38,5475,5476],{"class":881},"strings.",[38,5478,5479],{"class":51},"EqualFold",[38,5481,5482],{"class":881},"(parts[",[38,5484,2939],{"class":65},[38,5486,5487],{"class":881},"], ",[38,5489,5490],{"class":55},"\"Bearer\"",[38,5492,4102],{"class":881},[38,5494,5495,5497,5499,5501,5503,5505,5507,5509,5511,5513,5515,5517,5520],{"class":40,"line":1017},[38,5496,5385],{"class":881},[38,5498,5388],{"class":51},[38,5500,2851],{"class":881},[38,5502,5393],{"class":65},[38,5504,1598],{"class":881},[38,5506,5103],{"class":51},[38,5508,2973],{"class":881},[38,5510,5131],{"class":51},[38,5512,5134],{"class":881},[38,5514,5406],{"class":55},[38,5516,2556],{"class":881},[38,5518,5519],{"class":55},"\"格式错误，请使用 Bearer \u003Ctoken>\"",[38,5521,3398],{"class":881},[38,5523,5524],{"class":40,"line":1022},[38,5525,5418],{"class":938},[38,5527,5528],{"class":40,"line":1028},[38,5529,5423],{"class":881},[38,5531,5532],{"class":40,"line":1039},[38,5533,82],{"emptyLinePlaceholder":81},[38,5535,5536,5539,5541,5544,5547,5549,5551],{"class":40,"line":1050},[38,5537,5538],{"class":881},"        user, err ",[38,5540,4301],{"class":938},[38,5542,5543],{"class":881}," authService.",[38,5545,5546],{"class":51},"GetUserByToken",[38,5548,5482],{"class":881},[38,5550,963],{"class":65},[38,5552,5553],{"class":881},"])\n",[38,5555,5556,5558,5560,5562,5564],{"class":40,"line":1055},[38,5557,5369],{"class":938},[38,5559,4715],{"class":881},[38,5561,4718],{"class":938},[38,5563,4721],{"class":65},[38,5565,2492],{"class":881},[38,5567,5568,5570,5572,5574,5576,5578,5580,5582,5584,5586,5588,5590,5593],{"class":40,"line":1064},[38,5569,5385],{"class":881},[38,5571,5388],{"class":51},[38,5573,2851],{"class":881},[38,5575,5393],{"class":65},[38,5577,1598],{"class":881},[38,5579,5103],{"class":51},[38,5581,2973],{"class":881},[38,5583,5131],{"class":51},[38,5585,5134],{"class":881},[38,5587,5406],{"class":55},[38,5589,2556],{"class":881},[38,5591,5592],{"class":55},"\"无效的认证令牌\"",[38,5594,3398],{"class":881},[38,5596,5597],{"class":40,"line":1069},[38,5598,5418],{"class":938},[38,5600,5601],{"class":40,"line":1276},[38,5602,5423],{"class":881},[38,5604,5605],{"class":40,"line":1284},[38,5606,82],{"emptyLinePlaceholder":81},[38,5608,5609,5611,5614,5616,5619],{"class":40,"line":1289},[38,5610,5114],{"class":881},[38,5612,5613],{"class":51},"Set",[38,5615,2851],{"class":881},[38,5617,5618],{"class":55},"\"userID\"",[38,5620,5621],{"class":881},", user.ID)\n",[38,5623,5624,5626,5628,5630,5633],{"class":40,"line":1921},[38,5625,5114],{"class":881},[38,5627,5613],{"class":51},[38,5629,2851],{"class":881},[38,5631,5632],{"class":55},"\"username\"",[38,5634,5635],{"class":881},", user.Username)\n",[38,5637,5638,5640,5643],{"class":40,"line":1935},[38,5639,5114],{"class":881},[38,5641,5642],{"class":51},"Next",[38,5644,3045],{"class":881},[38,5646,5647],{"class":40,"line":1942},[38,5648,2811],{"class":881},[38,5650,5651],{"class":40,"line":1947},[38,5652,2153],{"class":881},[28,5654,5656],{"className":4274,"code":5655,"language":976,"meta":33,"style":33},"\u002F\u002F 用户信息桥接中间件：Gin Context → request.Context\ntype ctxKey string\nconst (\n    ctxUserID   ctxKey = \"userID\"\n    ctxUsername ctxKey = \"username\"\n)\n\nfunc WithUserContext() gin.HandlerFunc {\n    return func(c *gin.Context) {\n        ctx := c.Request.Context()\n        ctx = context.WithValue(ctx, ctxUserID, c.GetUint(\"userID\"))\n        ctx = context.WithValue(ctx, ctxUsername, c.GetString(\"username\"))\n        c.Request = c.Request.WithContext(ctx)\n        c.Next()\n    }\n}\n",[35,5657,5658,5663,5674,5681,5694,5706,5710,5714,5732,5752,5766,5790,5812,5827,5835,5839],{"__ignoreMap":33},[38,5659,5660],{"class":40,"line":41},[38,5661,5662],{"class":44},"\u002F\u002F 用户信息桥接中间件：Gin Context → request.Context\n",[38,5664,5665,5668,5671],{"class":40,"line":48},[38,5666,5667],{"class":938},"type",[38,5669,5670],{"class":51}," ctxKey",[38,5672,5673],{"class":938}," string\n",[38,5675,5676,5678],{"class":40,"line":78},[38,5677,3084],{"class":938},[38,5679,5680],{"class":881}," (\n",[38,5682,5683,5686,5689,5691],{"class":40,"line":85},[38,5684,5685],{"class":65},"    ctxUserID",[38,5687,5688],{"class":51},"   ctxKey",[38,5690,2489],{"class":938},[38,5692,5693],{"class":55}," \"userID\"\n",[38,5695,5696,5699,5701,5703],{"class":40,"line":91},[38,5697,5698],{"class":65},"    ctxUsername",[38,5700,5670],{"class":51},[38,5702,2489],{"class":938},[38,5704,5705],{"class":55}," \"username\"\n",[38,5707,5708],{"class":40,"line":114},[38,5709,3160],{"class":881},[38,5711,5712],{"class":40,"line":119},[38,5713,82],{"emptyLinePlaceholder":81},[38,5715,5716,5718,5721,5724,5726,5728,5730],{"class":40,"line":125},[38,5717,4475],{"class":938},[38,5719,5720],{"class":51}," WithUserContext",[38,5722,5723],{"class":881},"() ",[38,5725,5103],{"class":51},[38,5727,2973],{"class":881},[38,5729,5321],{"class":51},[38,5731,2492],{"class":881},[38,5733,5734,5736,5738,5740,5742,5744,5746,5748,5750],{"class":40,"line":151},[38,5735,4532],{"class":938},[38,5737,5330],{"class":938},[38,5739,2851],{"class":881},[38,5741,5097],{"class":2854},[38,5743,5100],{"class":938},[38,5745,5103],{"class":51},[38,5747,2973],{"class":881},[38,5749,4488],{"class":51},[38,5751,4102],{"class":881},[38,5753,5754,5757,5759,5762,5764],{"class":40,"line":156},[38,5755,5756],{"class":881},"        ctx ",[38,5758,4301],{"class":938},[38,5760,5761],{"class":881}," c.Request.",[38,5763,4488],{"class":51},[38,5765,3045],{"class":881},[38,5767,5768,5770,5772,5775,5778,5781,5784,5786,5788],{"class":40,"line":162},[38,5769,5756],{"class":881},[38,5771,945],{"class":938},[38,5773,5774],{"class":881}," context.",[38,5776,5777],{"class":51},"WithValue",[38,5779,5780],{"class":881},"(ctx, ctxUserID, c.",[38,5782,5783],{"class":51},"GetUint",[38,5785,2851],{"class":881},[38,5787,5618],{"class":55},[38,5789,3014],{"class":881},[38,5791,5792,5794,5796,5798,5800,5803,5806,5808,5810],{"class":40,"line":1017},[38,5793,5756],{"class":881},[38,5795,945],{"class":938},[38,5797,5774],{"class":881},[38,5799,5777],{"class":51},[38,5801,5802],{"class":881},"(ctx, ctxUsername, c.",[38,5804,5805],{"class":51},"GetString",[38,5807,2851],{"class":881},[38,5809,5632],{"class":55},[38,5811,3014],{"class":881},[38,5813,5814,5817,5819,5821,5824],{"class":40,"line":1022},[38,5815,5816],{"class":881},"        c.Request ",[38,5818,945],{"class":938},[38,5820,5761],{"class":881},[38,5822,5823],{"class":51},"WithContext",[38,5825,5826],{"class":881},"(ctx)\n",[38,5828,5829,5831,5833],{"class":40,"line":1028},[38,5830,5114],{"class":881},[38,5832,5642],{"class":51},[38,5834,3045],{"class":881},[38,5836,5837],{"class":40,"line":1039},[38,5838,2811],{"class":881},[38,5840,5841],{"class":40,"line":1050},[38,5842,2153],{"class":881},[675,5844,5845],{"id":5845},"组合使用",[28,5847,5849],{"className":4274,"code":5848,"language":976,"meta":33,"style":33},"r.Any(\"\u002Fmcp\",\n    AuthRequired(authService),  \u002F\u002F 1. 验证 Token\n    WithUserContext(),           \u002F\u002F 2. 桥接用户信息到 request.Context\n    gin.WrapH(mcpHTTP),         \u002F\u002F 3. MCP 处理\n)\n",[35,5850,5851,5863,5874,5885,5898],{"__ignoreMap":33},[38,5852,5853,5855,5857,5859,5861],{"class":40,"line":41},[38,5854,4870],{"class":881},[38,5856,4873],{"class":51},[38,5858,2851],{"class":881},[38,5860,4833],{"class":55},[38,5862,2503],{"class":881},[38,5864,5865,5868,5871],{"class":40,"line":48},[38,5866,5867],{"class":51},"    AuthRequired",[38,5869,5870],{"class":881},"(authService),  ",[38,5872,5873],{"class":44},"\u002F\u002F 1. 验证 Token\n",[38,5875,5876,5879,5882],{"class":40,"line":78},[38,5877,5878],{"class":51},"    WithUserContext",[38,5880,5881],{"class":881},"(),           ",[38,5883,5884],{"class":44},"\u002F\u002F 2. 桥接用户信息到 request.Context\n",[38,5886,5887,5890,5892,5895],{"class":40,"line":85},[38,5888,5889],{"class":881},"    gin.",[38,5891,4883],{"class":51},[38,5893,5894],{"class":881},"(mcpHTTP),         ",[38,5896,5897],{"class":44},"\u002F\u002F 3. MCP 处理\n",[38,5899,5900],{"class":40,"line":91},[38,5901,3160],{"class":881},[16,5903,5904],{},"在工具处理器里就能拿到用户信息了：",[28,5906,5908],{"className":4274,"code":5907,"language":976,"meta":33,"style":33},"func (h *Handler) handleListItems(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {\n    userID, ok := ctx.Value(ctxUserID).(uint)\n    if !ok {\n        return mcp.NewToolResultError(\"未认证\"), nil\n    }\n\n    items, _ := h.service.ListByUser(userID)\n    \u002F\u002F ...\n    return mcp.NewToolResultText(result), nil\n}\n",[35,5909,5910,5965,5986,5995,6012,6016,6020,6036,6041,6054],{"__ignoreMap":33},[38,5911,5912,5914,5916,5919,5921,5924,5926,5929,5931,5933,5935,5937,5939,5941,5943,5945,5947,5949,5951,5953,5955,5957,5959,5961,5963],{"class":40,"line":41},[38,5913,4475],{"class":938},[38,5915,2893],{"class":881},[38,5917,5918],{"class":2854},"h ",[38,5920,4507],{"class":938},[38,5922,5923],{"class":51},"Handler",[38,5925,3314],{"class":881},[38,5927,5928],{"class":51},"handleListItems",[38,5930,2851],{"class":881},[38,5932,4480],{"class":2854},[38,5934,4483],{"class":51},[38,5936,2973],{"class":881},[38,5938,4488],{"class":51},[38,5940,1598],{"class":881},[38,5942,4493],{"class":2854},[38,5944,4496],{"class":51},[38,5946,2973],{"class":881},[38,5948,4501],{"class":51},[38,5950,4504],{"class":881},[38,5952,4507],{"class":938},[38,5954,4510],{"class":51},[38,5956,2973],{"class":881},[38,5958,4515],{"class":51},[38,5960,1598],{"class":881},[38,5962,4520],{"class":938},[38,5964,4102],{"class":881},[38,5966,5967,5970,5972,5975,5978,5981,5984],{"class":40,"line":48},[38,5968,5969],{"class":881},"    userID, ok ",[38,5971,4301],{"class":938},[38,5973,5974],{"class":881}," ctx.",[38,5976,5977],{"class":51},"Value",[38,5979,5980],{"class":881},"(ctxUserID).(",[38,5982,5983],{"class":938},"uint",[38,5985,3160],{"class":881},[38,5987,5988,5990,5992],{"class":40,"line":78},[38,5989,2928],{"class":938},[38,5991,5473],{"class":938},[38,5993,5994],{"class":881},"ok {\n",[38,5996,5997,5999,6001,6003,6005,6008,6010],{"class":40,"line":85},[38,5998,4728],{"class":938},[38,6000,4423],{"class":881},[38,6002,4733],{"class":51},[38,6004,2851],{"class":881},[38,6006,6007],{"class":55},"\"未认证\"",[38,6009,4545],{"class":881},[38,6011,4548],{"class":65},[38,6013,6014],{"class":40,"line":91},[38,6015,2811],{"class":881},[38,6017,6018],{"class":40,"line":114},[38,6019,82],{"emptyLinePlaceholder":81},[38,6021,6022,6025,6027,6030,6033],{"class":40,"line":119},[38,6023,6024],{"class":881},"    items, _ ",[38,6026,4301],{"class":938},[38,6028,6029],{"class":881}," h.service.",[38,6031,6032],{"class":51},"ListByUser",[38,6034,6035],{"class":881},"(userID)\n",[38,6037,6038],{"class":40,"line":125},[38,6039,6040],{"class":44},"    \u002F\u002F ...\n",[38,6042,6043,6045,6047,6049,6052],{"class":40,"line":151},[38,6044,4532],{"class":938},[38,6046,4423],{"class":881},[38,6048,4537],{"class":51},[38,6050,6051],{"class":881},"(result), ",[38,6053,4548],{"class":65},[38,6055,6056],{"class":40,"line":156},[38,6057,2153],{"class":881},[675,6059,6060],{"id":6060},"客户端配置",[16,6062,6063],{},"AI 客户端（如 Claude Desktop）配置 MCP 服务器时带上 Authorization 头：",[28,6065,6069],{"className":6066,"code":6067,"language":6068,"meta":33,"style":33},"language-json shiki shiki-themes github-light github-dark","{\n  \"mcpServers\": {\n    \"my-app\": {\n      \"url\": \"http:\u002F\u002Flocalhost:8080\u002Fmcp\",\n      \"headers\": {\n        \"Authorization\": \"Bearer your-token-here\"\n      }\n    }\n  }\n}\n","json",[35,6070,6071,6076,6083,6089,6101,6108,6118,6123,6127,6131],{"__ignoreMap":33},[38,6072,6073],{"class":40,"line":41},[38,6074,6075],{"class":881},"{\n",[38,6077,6078,6081],{"class":40,"line":48},[38,6079,6080],{"class":65},"  \"mcpServers\"",[38,6082,2771],{"class":881},[38,6084,6085,6087],{"class":40,"line":78},[38,6086,4315],{"class":65},[38,6088,2771],{"class":881},[38,6090,6091,6094,6096,6099],{"class":40,"line":85},[38,6092,6093],{"class":65},"      \"url\"",[38,6095,2556],{"class":881},[38,6097,6098],{"class":55},"\"http:\u002F\u002Flocalhost:8080\u002Fmcp\"",[38,6100,2503],{"class":881},[38,6102,6103,6106],{"class":40,"line":91},[38,6104,6105],{"class":65},"      \"headers\"",[38,6107,2771],{"class":881},[38,6109,6110,6113,6115],{"class":40,"line":114},[38,6111,6112],{"class":65},"        \"Authorization\"",[38,6114,2556],{"class":881},[38,6116,6117],{"class":55},"\"Bearer your-token-here\"\n",[38,6119,6120],{"class":40,"line":119},[38,6121,6122],{"class":881},"      }\n",[38,6124,6125],{"class":40,"line":125},[38,6126,2811],{"class":881},[38,6128,6129],{"class":40,"line":151},[38,6130,2816],{"class":881},[38,6132,6133],{"class":40,"line":156},[38,6134,2153],{"class":881},[11,6136,793],{"id":793},[272,6138,6139,6148],{},[275,6140,6141],{},[278,6142,6143,6146],{},[281,6144,6145],{},"步骤",[281,6147,289],{},[291,6149,6150,6159,6170,6179,6188],{},[278,6151,6152,6156],{},[296,6153,6154],{},[35,6155,4307],{},[296,6157,6158],{},"创建 MCP 协议实例",[278,6160,6161,6167],{},[296,6162,6163,2026,6165],{},[35,6164,4426],{},[35,6166,4469],{},[296,6168,6169],{},"定义并注册工具",[278,6171,6172,6176],{},[296,6173,6174],{},[35,6175,4818],{},[296,6177,6178],{},"转为 HTTP Handler",[278,6180,6181,6185],{},[296,6182,6183],{},[35,6184,4786],{},[296,6186,6187],{},"挂载到 Gin 路由",[278,6189,6190,6198],{},[296,6191,6192,2026,6195],{},[35,6193,6194],{},"AuthRequired",[35,6196,6197],{},"WithUserContext",[296,6199,6200],{},"鉴权 + 用户信息桥接",[16,6202,6203,6204,6207,6208,2743],{},"核心就一句话：",[23,6205,6206],{},"MCP 是 HTTP Handler，挂上去就行。"," 鉴权复用你已有的中间件体系，只需要多写一个桥接函数把用户信息传进 ",[35,6209,5285],{},[801,6211,6212],{},"html pre.shiki code .sw2iP, html code.shiki .sw2iP{--shiki-light:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .scXbn, html code.shiki .scXbn{--shiki-light:#032F62;--shiki-dark:#9ECBFF}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .siTax, html code.shiki .siTax{--shiki-light:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sIX_F, html code.shiki .sIX_F{--shiki-light:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sHbNN, html code.shiki .sHbNN{--shiki-light:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .sPK6S, html code.shiki .sPK6S{--shiki-light:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .suQ91, html code.shiki .suQ91{--shiki-light:#005CC5;--shiki-dark:#79B8FF}",{"title":33,"searchDepth":48,"depth":78,"links":6214},[6215,6216,6217,6218,6225,6231],{"id":4186,"depth":48,"text":4187},{"id":4196,"depth":48,"text":4197},{"id":4213,"depth":48,"text":4213},{"id":4239,"depth":48,"text":4239,"children":6219},[6220,6221,6222,6223,6224],{"id":4242,"depth":78,"text":4243},{"id":4270,"depth":78,"text":4271},{"id":4387,"depth":78,"text":4388},{"id":4775,"depth":78,"text":4776},{"id":4896,"depth":78,"text":4897},{"id":5258,"depth":48,"text":5259,"children":6226},[6227,6228,6229,6230],{"id":3826,"depth":78,"text":3826},{"id":5278,"depth":78,"text":5279},{"id":5845,"depth":78,"text":5845},{"id":6060,"depth":78,"text":6060},{"id":793,"depth":48,"text":793},"介绍如何使用 mark3labs\u002Fmcp-go 与 Gin 框架集成，搭建一个带鉴权的 MCP（Model Context Protocol）服务器，让 AI Agent 能直接调用你的后端服务。","\u002Fposts\u002Fgo-mcp-gin\u002Fimg\u002Fcover.svg",{},"\u002Fposts\u002Fgo-mcp-gin","2026-06-01T00:50:00",{"title":4181,"description":6232},"Go 工程实践","posts\u002Fgo-mcp-gin\u002Findex",[831,6241,6242,830,6243],"MCP","Gin","后端","2026-06-01","5fZl1Vigs2lHb5fHPz-0YZFyFD38ws1heOouZUG1g6M",{"id":4,"title":5,"author":6,"body":6247,"description":818,"difficulty":6,"draft":819,"extension":820,"image":821,"meta":6809,"navigation":81,"path":823,"pinned":819,"published":824,"seo":6810,"series":6,"seriesOrder":6,"stem":826,"tags":6811,"updated":6,"__hash__":833},{"type":8,"value":6248,"toc":6795},[6249,6251,6253,6257,6353,6355,6357,6403,6405,6417,6419,6421,6425,6597,6601,6603,6605,6619,6621,6633,6635,6649,6651,6653,6657,6711,6713,6715,6717,6719,6751,6753,6767,6769,6771,6781,6783,6785,6787,6789,6791,6793],[11,6250,14],{"id":13},[16,6252,18],{},[16,6254,21,6255,26],{},[23,6256,25],{},[28,6258,6259],{"className":30,"code":31,"language":32,"meta":33,"style":33},[35,6260,6261,6265,6283,6287,6291,6307,6311,6315,6333,6337,6341],{"__ignoreMap":33},[38,6262,6263],{"class":40,"line":41},[38,6264,45],{"class":44},[38,6266,6267,6269,6271,6273,6275,6277,6279,6281],{"class":40,"line":48},[38,6268,52],{"class":51},[38,6270,56],{"class":55},[38,6272,59],{"class":55},[38,6274,62],{"class":55},[38,6276,66],{"class":65},[38,6278,69],{"class":55},[38,6280,72],{"class":65},[38,6282,75],{"class":55},[38,6284,6285],{"class":40,"line":78},[38,6286,82],{"emptyLinePlaceholder":81},[38,6288,6289],{"class":40,"line":85},[38,6290,88],{"class":44},[38,6292,6293,6295,6297,6299,6301,6303,6305],{"class":40,"line":91},[38,6294,52],{"class":51},[38,6296,96],{"class":55},[38,6298,99],{"class":55},[38,6300,102],{"class":65},[38,6302,105],{"class":55},[38,6304,108],{"class":65},[38,6306,111],{"class":55},[38,6308,6309],{"class":40,"line":114},[38,6310,82],{"emptyLinePlaceholder":81},[38,6312,6313],{"class":40,"line":119},[38,6314,122],{"class":44},[38,6316,6317,6319,6321,6323,6325,6327,6329,6331],{"class":40,"line":125},[38,6318,52],{"class":51},[38,6320,130],{"class":55},[38,6322,133],{"class":55},[38,6324,136],{"class":55},[38,6326,139],{"class":65},[38,6328,142],{"class":55},[38,6330,145],{"class":65},[38,6332,148],{"class":55},[38,6334,6335],{"class":40,"line":151},[38,6336,82],{"emptyLinePlaceholder":81},[38,6338,6339],{"class":40,"line":156},[38,6340,159],{"class":44},[38,6342,6343,6345,6347,6349,6351],{"class":40,"line":162},[38,6344,52],{"class":51},[38,6346,167],{"class":55},[38,6348,170],{"class":55},[38,6350,173],{"class":65},[38,6352,176],{"class":55},[11,6354,179],{"id":179},[16,6356,182],{},[28,6358,6359],{"className":30,"code":185,"language":32,"meta":33,"style":33},[35,6360,6361,6365,6375,6379,6383,6391,6395,6399],{"__ignoreMap":33},[38,6362,6363],{"class":40,"line":41},[38,6364,192],{"class":44},[38,6366,6367,6369,6371,6373],{"class":40,"line":48},[38,6368,197],{"class":51},[38,6370,200],{"class":55},[38,6372,203],{"class":65},[38,6374,206],{"class":55},[38,6376,6377],{"class":40,"line":78},[38,6378,82],{"emptyLinePlaceholder":81},[38,6380,6381],{"class":40,"line":85},[38,6382,215],{"class":44},[38,6384,6385,6387,6389],{"class":40,"line":91},[38,6386,220],{"class":51},[38,6388,200],{"class":55},[38,6390,225],{"class":55},[38,6392,6393],{"class":40,"line":114},[38,6394,82],{"emptyLinePlaceholder":81},[38,6396,6397],{"class":40,"line":119},[38,6398,234],{"class":44},[38,6400,6401],{"class":40,"line":125},[38,6402,239],{"class":44},[16,6404,242],{},[28,6406,6407],{"className":30,"code":245,"language":32,"meta":33,"style":33},[35,6408,6409],{"__ignoreMap":33},[38,6410,6411,6413,6415],{"class":40,"line":41},[38,6412,52],{"class":51},[38,6414,254],{"class":55},[38,6416,257],{"class":55},[16,6418,260],{},[11,6420,263],{"id":263},[16,6422,266,6423,270],{},[23,6424,269],{},[272,6426,6427,6437],{},[275,6428,6429],{},[278,6430,6431,6433,6435],{},[281,6432,283],{},[281,6434,286],{},[281,6436,289],{},[291,6438,6439,6449,6459,6469,6479,6489,6499,6509,6519,6529,6539,6549,6559,6569,6579,6589],{},[278,6440,6441,6445,6447],{},[296,6442,298,6443],{},[35,6444,301],{},[296,6446,304],{},[296,6448,307],{},[278,6450,6451,6455,6457],{},[296,6452,312,6453],{},[35,6454,315],{},[296,6456,318],{},[296,6458,321],{},[278,6460,6461,6465,6467],{},[296,6462,326,6463],{},[35,6464,329],{},[296,6466,332],{},[296,6468,335],{},[278,6470,6471,6475,6477],{},[296,6472,340,6473],{},[35,6474,343],{},[296,6476,346],{},[296,6478,349],{},[278,6480,6481,6485,6487],{},[296,6482,354,6483],{},[35,6484,357],{},[296,6486,360],{},[296,6488,363],{},[278,6490,6491,6495,6497],{},[296,6492,368,6493],{},[35,6494,371],{},[296,6496,374],{},[296,6498,377],{},[278,6500,6501,6505,6507],{},[296,6502,382,6503],{},[35,6504,385],{},[296,6506,388],{},[296,6508,391],{},[278,6510,6511,6515,6517],{},[296,6512,396,6513],{},[35,6514,399],{},[296,6516,388],{},[296,6518,404],{},[278,6520,6521,6525,6527],{},[296,6522,409,6523],{},[35,6524,412],{},[296,6526,415],{},[296,6528,418],{},[278,6530,6531,6535,6537],{},[296,6532,423,6533],{},[35,6534,426],{},[296,6536,415],{},[296,6538,431],{},[278,6540,6541,6545,6547],{},[296,6542,436,6543],{},[35,6544,439],{},[296,6546,442],{},[296,6548,445],{},[278,6550,6551,6555,6557],{},[296,6552,450,6553],{},[35,6554,453],{},[296,6556,456],{},[296,6558,459],{},[278,6560,6561,6565,6567],{},[296,6562,464,6563],{},[35,6564,467],{},[296,6566,456],{},[296,6568,472],{},[278,6570,6571,6575,6577],{},[296,6572,477,6573],{},[35,6574,480],{},[296,6576,483],{},[296,6578,486],{},[278,6580,6581,6585,6587],{},[296,6582,491,6583],{},[35,6584,494],{},[296,6586,497],{},[296,6588,500],{},[278,6590,6591,6593,6595],{},[296,6592,505],{},[296,6594,508],{},[296,6596,511],{},[16,6598,514,6599,518],{},[35,6600,517],{},[11,6602,522],{"id":521},[16,6604,525],{},[527,6606,6607,6611,6615],{},[530,6608,6609,535],{},[23,6610,534],{},[530,6612,6613,541],{},[23,6614,540],{},[530,6616,6617,547],{},[23,6618,546],{},[16,6620,550],{},[28,6622,6623],{"className":30,"code":553,"language":32,"meta":33,"style":33},[35,6624,6625],{"__ignoreMap":33},[38,6626,6627,6629,6631],{"class":40,"line":41},[38,6628,52],{"class":51},[38,6630,562],{"class":55},[38,6632,565],{"class":55},[16,6634,568],{},[527,6636,6637,6643,6645,6647],{},[530,6638,573,6639,576,6641,579],{},[35,6640,315],{},[35,6642,467],{},[530,6644,582],{},[530,6646,585],{},[530,6648,588],{},[16,6650,591],{},[11,6652,594],{"id":594},[16,6654,597,6655,601],{},[35,6656,600],{},[28,6658,6659],{"className":30,"code":604,"language":32,"meta":33,"style":33},[35,6660,6661,6669,6679,6689,6701],{"__ignoreMap":33},[38,6662,6663,6665,6667],{"class":40,"line":41},[38,6664,52],{"class":51},[38,6666,613],{"class":55},[38,6668,616],{"class":44},[38,6670,6671,6673,6675,6677],{"class":40,"line":48},[38,6672,52],{"class":51},[38,6674,613],{"class":55},[38,6676,625],{"class":65},[38,6678,628],{"class":44},[38,6680,6681,6683,6685,6687],{"class":40,"line":78},[38,6682,52],{"class":51},[38,6684,613],{"class":55},[38,6686,637],{"class":65},[38,6688,640],{"class":44},[38,6690,6691,6693,6695,6697,6699],{"class":40,"line":85},[38,6692,52],{"class":51},[38,6694,613],{"class":55},[38,6696,649],{"class":65},[38,6698,652],{"class":55},[38,6700,655],{"class":44},[38,6702,6703,6705,6707,6709],{"class":40,"line":91},[38,6704,52],{"class":51},[38,6706,613],{"class":55},[38,6708,664],{"class":65},[38,6710,667],{"class":44},[16,6712,670],{},[11,6714,673],{"id":673},[675,6716,678],{"id":677},[16,6718,681],{},[527,6720,6721,6733,6739,6745],{},[530,6722,6723,689,6725,693,6727,697,6729,693,6731,704],{},[23,6724,688],{},[35,6726,692],{},[35,6728,696],{},[35,6730,700],{},[35,6732,703],{},[530,6734,6735,710,6737,714],{},[23,6736,709],{},[35,6738,713],{},[530,6740,6741,710,6743,723],{},[23,6742,719],{},[35,6744,722],{},[530,6746,6747,729,6749,733],{},[23,6748,728],{},[35,6750,732],{},[675,6752,737],{"id":736},[527,6754,6755,6757,6759,6761,6765],{},[530,6756,742],{},[530,6758,745],{},[530,6760,748],{},[530,6762,751,6763,755],{},[35,6764,754],{},[530,6766,758],{},[675,6768,762],{"id":761},[16,6770,765],{},[767,6772,6773,6775,6777,6779],{},[530,6774,771],{},[530,6776,774],{},[530,6778,777],{},[530,6780,780],{},[16,6782,783],{},[675,6784,787],{"id":786},[16,6786,790],{},[11,6788,793],{"id":793},[16,6790,796],{},[16,6792,799],{},[801,6794,803],{},{"title":33,"searchDepth":48,"depth":78,"links":6796},[6797,6798,6799,6800,6801,6802,6808],{"id":13,"depth":48,"text":14},{"id":179,"depth":48,"text":179},{"id":263,"depth":48,"text":263},{"id":521,"depth":48,"text":522},{"id":594,"depth":48,"text":594},{"id":673,"depth":48,"text":673,"children":6803},[6804,6805,6806,6807],{"id":677,"depth":78,"text":678},{"id":736,"depth":78,"text":737},{"id":761,"depth":78,"text":762},{"id":786,"depth":78,"text":787},{"id":793,"depth":48,"text":793},{},{"title":5,"description":818},[828,829,830,831,832],{"id":6813,"title":6814,"author":6,"body":6815,"description":7778,"difficulty":1638,"draft":819,"extension":820,"image":7779,"meta":7780,"navigation":81,"path":7781,"pinned":819,"published":7782,"seo":7783,"series":6238,"seriesOrder":48,"stem":7784,"tags":7785,"updated":7789,"__hash__":7790},"posts\u002Fposts\u002Fgo-embed-frontend\u002Findex.md","Go 单文件打包实战：把前端塞进二进制",{"type":8,"value":6816,"toc":7761},[6817,6819,6826,6829,6832,6843,6846,6850,7183,7187,7295,7299,7433,7436,7440,7449,7466,7477,7496,7510,7516,7524,7623,7629,7633,7640,7649,7652,7688,7691,7696,7735,7737,7743,7755,7758],[11,6818,2719],{"id":2719},[16,6820,6821,6822,6825],{},"一个 Gin + Vue 3 的全栈项目，之前的发布方式是：一个 Go 二进制 + 一个 ",[35,6823,6824],{},"web\u002F"," 前端静态目录，必须放一起才能运行。",[16,6827,6828],{},"为了让部署更丝滑——一个 exe 拖哪都能跑——决定把前端构建产物嵌入到 Go 二进制里。",[11,6830,6831],{"id":6831},"核心思路",[16,6833,6834,6835,6838,6839,6842],{},"利用 Go 1.16 引入的 ",[35,6836,6837],{},"\u002F\u002Fgo:embed"," 指令，在编译时将前端 ",[35,6840,6841],{},"dist\u002F"," 目录嵌入二进制，运行时通过嵌入的文件系统提供 SPA 服务。",[11,6844,6845],{"id":6845},"逐步实现",[675,6847,6849],{"id":6848},"_1-创建嵌入模块","1. 创建嵌入模块",[28,6851,6853],{"className":4274,"code":6852,"language":976,"meta":33,"style":33},"\u002F\u002F service\u002Fweb\u002Fembed.go\npackage web\n\nimport (\n    \"embed\"\n    \"io\u002Ffs\"\n    \"mime\"\n    \"net\u002Fhttp\"\n    \"path\u002Ffilepath\"\n    \"strings\"\n\n    \"github.com\u002Fgin-gonic\u002Fgin\"\n)\n\n\u002F\u002Fgo:embed dist\nvar embedFS embed.FS\n\nfunc SPAHandler() gin.HandlerFunc {\n    sub, _ := fs.Sub(embedFS, \"dist\")\n\n    return func(c *gin.Context) {\n        p := strings.TrimPrefix(c.Request.URL.Path, \"\u002F\")\n        if p == \"\" {\n            p = \"index.html\"\n        }\n\n        data, err := fs.ReadFile(sub, p)\n        if err != nil {\n            \u002F\u002F SPA fallback：文件不存在则返回 index.html\n            data, _ = fs.ReadFile(sub, \"index.html\")\n        }\n\n        c.Data(http.StatusOK, mime.TypeByExtension(filepath.Ext(p)), data)\n    }\n}\n",[35,6854,6855,6860,6868,6872,6878,6888,6897,6906,6915,6924,6933,6937,6946,6950,6954,6959,6974,6978,6995,7016,7020,7040,7060,7073,7083,7087,7091,7106,7118,7123,7142,7146,7150,7173,7178],{"__ignoreMap":33},[38,6856,6857],{"class":40,"line":41},[38,6858,6859],{"class":44},"\u002F\u002F service\u002Fweb\u002Fembed.go\n",[38,6861,6862,6865],{"class":40,"line":48},[38,6863,6864],{"class":938},"package",[38,6866,6867],{"class":51}," web\n",[38,6869,6870],{"class":40,"line":78},[38,6871,82],{"emptyLinePlaceholder":81},[38,6873,6874,6876],{"class":40,"line":85},[38,6875,4282],{"class":938},[38,6877,5680],{"class":881},[38,6879,6880,6883,6886],{"class":40,"line":91},[38,6881,6882],{"class":55},"    \"",[38,6884,6885],{"class":51},"embed",[38,6887,1081],{"class":55},[38,6889,6890,6892,6895],{"class":40,"line":114},[38,6891,6882],{"class":55},[38,6893,6894],{"class":51},"io\u002Ffs",[38,6896,1081],{"class":55},[38,6898,6899,6901,6904],{"class":40,"line":119},[38,6900,6882],{"class":55},[38,6902,6903],{"class":51},"mime",[38,6905,1081],{"class":55},[38,6907,6908,6910,6913],{"class":40,"line":125},[38,6909,6882],{"class":55},[38,6911,6912],{"class":51},"net\u002Fhttp",[38,6914,1081],{"class":55},[38,6916,6917,6919,6922],{"class":40,"line":151},[38,6918,6882],{"class":55},[38,6920,6921],{"class":51},"path\u002Ffilepath",[38,6923,1081],{"class":55},[38,6925,6926,6928,6931],{"class":40,"line":156},[38,6927,6882],{"class":55},[38,6929,6930],{"class":51},"strings",[38,6932,1081],{"class":55},[38,6934,6935],{"class":40,"line":162},[38,6936,82],{"emptyLinePlaceholder":81},[38,6938,6939,6941,6944],{"class":40,"line":1017},[38,6940,6882],{"class":55},[38,6942,6943],{"class":51},"github.com\u002Fgin-gonic\u002Fgin",[38,6945,1081],{"class":55},[38,6947,6948],{"class":40,"line":1022},[38,6949,3160],{"class":881},[38,6951,6952],{"class":40,"line":1028},[38,6953,82],{"emptyLinePlaceholder":81},[38,6955,6956],{"class":40,"line":1039},[38,6957,6958],{"class":44},"\u002F\u002Fgo:embed dist\n",[38,6960,6961,6964,6967,6969,6971],{"class":40,"line":1050},[38,6962,6963],{"class":938},"var",[38,6965,6966],{"class":881}," embedFS ",[38,6968,6885],{"class":51},[38,6970,2973],{"class":881},[38,6972,6973],{"class":51},"FS\n",[38,6975,6976],{"class":40,"line":1055},[38,6977,82],{"emptyLinePlaceholder":81},[38,6979,6980,6982,6985,6987,6989,6991,6993],{"class":40,"line":1064},[38,6981,4475],{"class":938},[38,6983,6984],{"class":51}," SPAHandler",[38,6986,5723],{"class":881},[38,6988,5103],{"class":51},[38,6990,2973],{"class":881},[38,6992,5321],{"class":51},[38,6994,2492],{"class":881},[38,6996,6997,7000,7002,7005,7008,7011,7014],{"class":40,"line":1069},[38,6998,6999],{"class":881},"    sub, _ ",[38,7001,4301],{"class":938},[38,7003,7004],{"class":881}," fs.",[38,7006,7007],{"class":51},"Sub",[38,7009,7010],{"class":881},"(embedFS, ",[38,7012,7013],{"class":55},"\"dist\"",[38,7015,3160],{"class":881},[38,7017,7018],{"class":40,"line":1276},[38,7019,82],{"emptyLinePlaceholder":81},[38,7021,7022,7024,7026,7028,7030,7032,7034,7036,7038],{"class":40,"line":1284},[38,7023,4532],{"class":938},[38,7025,5330],{"class":938},[38,7027,2851],{"class":881},[38,7029,5097],{"class":2854},[38,7031,5100],{"class":938},[38,7033,5103],{"class":51},[38,7035,2973],{"class":881},[38,7037,4488],{"class":51},[38,7039,4102],{"class":881},[38,7041,7042,7045,7047,7049,7052,7055,7058],{"class":40,"line":1289},[38,7043,7044],{"class":881},"        p ",[38,7046,4301],{"class":938},[38,7048,5437],{"class":881},[38,7050,7051],{"class":51},"TrimPrefix",[38,7053,7054],{"class":881},"(c.Request.URL.Path, ",[38,7056,7057],{"class":55},"\"\u002F\"",[38,7059,3160],{"class":881},[38,7061,7062,7064,7067,7069,7071],{"class":40,"line":1921},[38,7063,5369],{"class":938},[38,7065,7066],{"class":881}," p ",[38,7068,5375],{"class":938},[38,7070,5378],{"class":55},[38,7072,2492],{"class":881},[38,7074,7075,7078,7080],{"class":40,"line":1935},[38,7076,7077],{"class":881},"            p ",[38,7079,945],{"class":938},[38,7081,7082],{"class":55}," \"index.html\"\n",[38,7084,7085],{"class":40,"line":1942},[38,7086,5423],{"class":881},[38,7088,7089],{"class":40,"line":1947},[38,7090,82],{"emptyLinePlaceholder":81},[38,7092,7093,7096,7098,7100,7103],{"class":40,"line":1953},[38,7094,7095],{"class":881},"        data, err ",[38,7097,4301],{"class":938},[38,7099,7004],{"class":881},[38,7101,7102],{"class":51},"ReadFile",[38,7104,7105],{"class":881},"(sub, p)\n",[38,7107,7108,7110,7112,7114,7116],{"class":40,"line":1961},[38,7109,5369],{"class":938},[38,7111,4715],{"class":881},[38,7113,4718],{"class":938},[38,7115,4721],{"class":65},[38,7117,2492],{"class":881},[38,7119,7120],{"class":40,"line":1969},[38,7121,7122],{"class":44},"            \u002F\u002F SPA fallback：文件不存在则返回 index.html\n",[38,7124,7125,7128,7130,7132,7134,7137,7140],{"class":40,"line":1974},[38,7126,7127],{"class":881},"            data, _ ",[38,7129,945],{"class":938},[38,7131,7004],{"class":881},[38,7133,7102],{"class":51},[38,7135,7136],{"class":881},"(sub, ",[38,7138,7139],{"class":55},"\"index.html\"",[38,7141,3160],{"class":881},[38,7143,7144],{"class":40,"line":1980},[38,7145,5423],{"class":881},[38,7147,7148],{"class":40,"line":1989},[38,7149,82],{"emptyLinePlaceholder":81},[38,7151,7153,7155,7158,7161,7164,7167,7170],{"class":40,"line":7152},33,[38,7154,5114],{"class":881},[38,7156,7157],{"class":51},"Data",[38,7159,7160],{"class":881},"(http.StatusOK, mime.",[38,7162,7163],{"class":51},"TypeByExtension",[38,7165,7166],{"class":881},"(filepath.",[38,7168,7169],{"class":51},"Ext",[38,7171,7172],{"class":881},"(p)), data)\n",[38,7174,7176],{"class":40,"line":7175},34,[38,7177,2811],{"class":881},[38,7179,7181],{"class":40,"line":7180},35,[38,7182,2153],{"class":881},[675,7184,7186],{"id":7185},"_2-改造路由","2. 改造路由",[28,7188,7190],{"className":4274,"code":7189,"language":976,"meta":33,"style":33},"\u002F\u002F router.go\nimport \"myproject\u002Fweb\"\n\n\u002F\u002F 替换原来的磁盘静态文件服务\nwebHandler := web.SPAHandler()\nrouter.GET(\"\u002F\", webHandler)\nrouter.GET(\"\u002Fassets\u002F*filepath\", webHandler)\nrouter.GET(\"\u002Ffavicon.ico\", webHandler)\nrouter.GET(\"\u002Ffavicon.svg\", webHandler)\nrouter.NoRoute(webHandler)\n",[35,7191,7192,7197,7208,7212,7217,7232,7246,7259,7272,7285],{"__ignoreMap":33},[38,7193,7194],{"class":40,"line":41},[38,7195,7196],{"class":44},"\u002F\u002F router.go\n",[38,7198,7199,7201,7203,7206],{"class":40,"line":48},[38,7200,4282],{"class":938},[38,7202,878],{"class":55},[38,7204,7205],{"class":51},"myproject\u002Fweb",[38,7207,1081],{"class":55},[38,7209,7210],{"class":40,"line":78},[38,7211,82],{"emptyLinePlaceholder":81},[38,7213,7214],{"class":40,"line":85},[38,7215,7216],{"class":44},"\u002F\u002F 替换原来的磁盘静态文件服务\n",[38,7218,7219,7222,7224,7227,7230],{"class":40,"line":91},[38,7220,7221],{"class":881},"webHandler ",[38,7223,4301],{"class":938},[38,7225,7226],{"class":881}," web.",[38,7228,7229],{"class":51},"SPAHandler",[38,7231,3045],{"class":881},[38,7233,7234,7237,7239,7241,7243],{"class":40,"line":114},[38,7235,7236],{"class":881},"router.",[38,7238,5083],{"class":51},[38,7240,2851],{"class":881},[38,7242,7057],{"class":55},[38,7244,7245],{"class":881},", webHandler)\n",[38,7247,7248,7250,7252,7254,7257],{"class":40,"line":119},[38,7249,7236],{"class":881},[38,7251,5083],{"class":51},[38,7253,2851],{"class":881},[38,7255,7256],{"class":55},"\"\u002Fassets\u002F*filepath\"",[38,7258,7245],{"class":881},[38,7260,7261,7263,7265,7267,7270],{"class":40,"line":125},[38,7262,7236],{"class":881},[38,7264,5083],{"class":51},[38,7266,2851],{"class":881},[38,7268,7269],{"class":55},"\"\u002Ffavicon.ico\"",[38,7271,7245],{"class":881},[38,7273,7274,7276,7278,7280,7283],{"class":40,"line":151},[38,7275,7236],{"class":881},[38,7277,5083],{"class":51},[38,7279,2851],{"class":881},[38,7281,7282],{"class":55},"\"\u002Ffavicon.svg\"",[38,7284,7245],{"class":881},[38,7286,7287,7289,7292],{"class":40,"line":156},[38,7288,7236],{"class":881},[38,7290,7291],{"class":51},"NoRoute",[38,7293,7294],{"class":881},"(webHandler)\n",[675,7296,7298],{"id":7297},"_3-一键构建脚本","3. 一键构建脚本",[28,7300,7302],{"className":30,"code":7301,"language":32,"meta":33,"style":33},"#!\u002Fbin\u002Fbash\n# build-standalone.sh\n\nset -e\n\n# 1. 构建前端\npnpm install --no-frozen-lockfile\nnpx vite build\n\n# 2. 复制到嵌入目录\nrm -rf service\u002Fweb\u002Fdist\ncp -r dist service\u002Fweb\u002Fdist\n\n# 3. 编译 Go 二进制\ncd service\ngo build -o myapp.exe \\\n    -ldflags=\"-X myproject\u002Fglobal.RUNCODE=release\" \\\n    main.go\n",[35,7303,7304,7309,7314,7318,7326,7330,7335,7345,7355,7359,7364,7375,7388,7392,7397,7405,7418,7428],{"__ignoreMap":33},[38,7305,7306],{"class":40,"line":41},[38,7307,7308],{"class":44},"#!\u002Fbin\u002Fbash\n",[38,7310,7311],{"class":40,"line":48},[38,7312,7313],{"class":44},"# build-standalone.sh\n",[38,7315,7316],{"class":40,"line":78},[38,7317,82],{"emptyLinePlaceholder":81},[38,7319,7320,7323],{"class":40,"line":85},[38,7321,7322],{"class":65},"set",[38,7324,7325],{"class":65}," -e\n",[38,7327,7328],{"class":40,"line":91},[38,7329,82],{"emptyLinePlaceholder":81},[38,7331,7332],{"class":40,"line":114},[38,7333,7334],{"class":44},"# 1. 构建前端\n",[38,7336,7337,7340,7342],{"class":40,"line":119},[38,7338,7339],{"class":51},"pnpm",[38,7341,200],{"class":55},[38,7343,7344],{"class":65}," --no-frozen-lockfile\n",[38,7346,7347,7349,7352],{"class":40,"line":125},[38,7348,2617],{"class":51},[38,7350,7351],{"class":55}," vite",[38,7353,7354],{"class":55}," build\n",[38,7356,7357],{"class":40,"line":151},[38,7358,82],{"emptyLinePlaceholder":81},[38,7360,7361],{"class":40,"line":156},[38,7362,7363],{"class":44},"# 2. 复制到嵌入目录\n",[38,7365,7366,7369,7372],{"class":40,"line":162},[38,7367,7368],{"class":51},"rm",[38,7370,7371],{"class":65}," -rf",[38,7373,7374],{"class":55}," service\u002Fweb\u002Fdist\n",[38,7376,7377,7380,7383,7386],{"class":40,"line":1017},[38,7378,7379],{"class":51},"cp",[38,7381,7382],{"class":65}," -r",[38,7384,7385],{"class":55}," dist",[38,7387,7374],{"class":55},[38,7389,7390],{"class":40,"line":1022},[38,7391,82],{"emptyLinePlaceholder":81},[38,7393,7394],{"class":40,"line":1028},[38,7395,7396],{"class":44},"# 3. 编译 Go 二进制\n",[38,7398,7399,7402],{"class":40,"line":1039},[38,7400,7401],{"class":65},"cd",[38,7403,7404],{"class":55}," service\n",[38,7406,7407,7409,7411,7413,7416],{"class":40,"line":1050},[38,7408,976],{"class":51},[38,7410,991],{"class":55},[38,7412,1000],{"class":65},[38,7414,7415],{"class":55}," myapp.exe",[38,7417,5201],{"class":65},[38,7419,7420,7423,7426],{"class":40,"line":1055},[38,7421,7422],{"class":65},"    -ldflags=",[38,7424,7425],{"class":55},"\"-X myproject\u002Fglobal.RUNCODE=release\"",[38,7427,5201],{"class":65},[38,7429,7430],{"class":40,"line":1064},[38,7431,7432],{"class":55},"    main.go\n",[11,7434,7435],{"id":7435},"踩坑记录",[675,7437,7439],{"id":7438},"坑一路由不匹配静态资源-404","坑一：路由不匹配，静态资源 404",[16,7441,7442,7443,7445,7446,7448],{},"首次实现时用了 Gin 的 ",[35,7444,7291],{}," 兜底，但部分静态资源返回 404。排查发现 ",[35,7447,7291],{}," 在某些路由未显式注册时不会按预期触发。",[16,7450,7451,7454,7455,7458,7459,7462,7463,7465],{},[23,7452,7453],{},"解决","：显式注册 ",[35,7456,7457],{},"\u002Fassets\u002F*filepath","、",[35,7460,7461],{},"\u002Ffavicon.ico"," 等路由，",[35,7464,7291],{}," 仅作为兜底。",[675,7467,7469,7470,7472,7473,7476],{"id":7468},"坑二goembed-排除-_-开头文件","坑二：",[35,7471,6837],{}," 排除 ",[35,7474,7475],{},"_"," 开头文件",[16,7478,7479,7480,7483,7484,7486,7487,2743],{},"这是最坑的。Vite 构建产出了一个 ",[35,7481,7482],{},"_plugin-vue_export-helper-xxx.js","，Go 的 ",[35,7485,6837],{}," 当模式为目录时，",[23,7488,7489,7490,7492,7493,7495],{},"硬编码排除了 ",[35,7491,7475],{}," 和 ",[35,7494,2973],{}," 开头的文件",[28,7497,7499],{"className":4274,"code":7498,"language":976,"meta":33,"style":33},"\u002F\u002F 这行会跳过 dist\u002Fassets\u002F_plugin-xxx.js\n\u002F\u002Fgo:embed dist\n",[35,7500,7501,7506],{"__ignoreMap":33},[38,7502,7503],{"class":40,"line":41},[38,7504,7505],{"class":44},"\u002F\u002F 这行会跳过 dist\u002Fassets\u002F_plugin-xxx.js\n",[38,7507,7508],{"class":40,"line":48},[38,7509,6958],{"class":44},[16,7511,7512,7513,2743],{},"错误表现：JS 文件返回的是 HTML 内容（SPA fallback），浏览器报 ",[35,7514,7515],{},"Unexpected token '\u003C'",[16,7517,7518,7520,7521,7523],{},[23,7519,7453],{},"：改 Vite 配置，去除了 ",[35,7522,7475],{}," 前缀：",[28,7525,7529],{"className":7526,"code":7527,"language":7528,"meta":33,"style":33},"language-ts shiki shiki-themes github-light github-dark","\u002F\u002F vite.config.ts\nbuild: {\n  rollupOptions: {\n    output: {\n      chunkFileNames(chunkInfo) {\n        return `assets\u002F${chunkInfo.name.replace(\u002F^_\u002F, '')}-[hash].js`\n      },\n    },\n  },\n},\n","ts",[35,7530,7531,7536,7543,7550,7557,7565,7603,7608,7613,7618],{"__ignoreMap":33},[38,7532,7533],{"class":40,"line":41},[38,7534,7535],{"class":44},"\u002F\u002F vite.config.ts\n",[38,7537,7538,7541],{"class":40,"line":48},[38,7539,7540],{"class":51},"build",[38,7542,2771],{"class":881},[38,7544,7545,7548],{"class":40,"line":78},[38,7546,7547],{"class":51},"  rollupOptions",[38,7549,2771],{"class":881},[38,7551,7552,7555],{"class":40,"line":85},[38,7553,7554],{"class":51},"    output",[38,7556,2771],{"class":881},[38,7558,7559,7562],{"class":40,"line":91},[38,7560,7561],{"class":51},"      chunkFileNames",[38,7563,7564],{"class":881},"(chunkInfo) {\n",[38,7566,7567,7569,7572,7575,7577,7580,7582,7584,7586,7588,7590,7592,7594,7596,7598,7600],{"class":40,"line":114},[38,7568,4728],{"class":938},[38,7570,7571],{"class":55}," `assets\u002F${",[38,7573,7574],{"class":881},"chunkInfo",[38,7576,2973],{"class":55},[38,7578,7579],{"class":881},"name",[38,7581,2973],{"class":55},[38,7583,3350],{"class":51},[38,7585,2851],{"class":55},[38,7587,2970],{"class":55},[38,7589,2957],{"class":938},[38,7591,7475],{"class":2960},[38,7593,2970],{"class":55},[38,7595,1598],{"class":55},[38,7597,3367],{"class":55},[38,7599,3671],{"class":55},[38,7601,7602],{"class":55},"}-[hash].js`\n",[38,7604,7605],{"class":40,"line":119},[38,7606,7607],{"class":881},"      },\n",[38,7609,7610],{"class":40,"line":125},[38,7611,7612],{"class":881},"    },\n",[38,7614,7615],{"class":40,"line":151},[38,7616,7617],{"class":881},"  },\n",[38,7619,7620],{"class":40,"line":156},[38,7621,7622],{"class":881},"},\n",[16,7624,7625,7626,7628],{},"这样就避免产生 ",[35,7627,7475],{}," 前缀文件名的同时，保持构建产物的一致性和引用关系的正确性。",[675,7630,7632],{"id":7631},"坑三构建缓存导致-embed-内容未更新","坑三：构建缓存导致 embed 内容未更新",[16,7634,7635,7636,7639],{},"多次 ",[35,7637,7638],{},"go build"," 后，Go 的构建缓存可能使用了旧版本的 embed 内容，导致实际运行的文件不是最新的。",[16,7641,7642,7644,7645,7648],{},[23,7643,7453],{},"：使用 ",[35,7646,7647],{},"go build -a"," 强制重新编译所有包。",[11,7650,7651],{"id":7651},"最终效果",[28,7653,7655],{"className":30,"code":7654,"language":32,"meta":33,"style":33},"$ bash build-standalone.sh\n=== 构建成功 ===\n输出: service\u002Fmyapp.exe  41M\n",[35,7656,7657,7667,7677],{"__ignoreMap":33},[38,7658,7659,7661,7664],{"class":40,"line":41},[38,7660,2967],{"class":51},[38,7662,7663],{"class":55}," bash",[38,7665,7666],{"class":55}," build-standalone.sh\n",[38,7668,7669,7671,7674],{"class":40,"line":48},[38,7670,2945],{"class":55},[38,7672,7673],{"class":55}," 构建成功",[38,7675,7676],{"class":55}," ===\n",[38,7678,7679,7682,7685],{"class":40,"line":78},[38,7680,7681],{"class":51},"输出:",[38,7683,7684],{"class":55}," service\u002Fmyapp.exe",[38,7686,7687],{"class":55},"  41M\n",[16,7689,7690],{},"一个 41MB 的 exe，内置前端页面、API 后端、SQLite 数据库（运行时初始化），拖到哪都能跑。",[16,7692,7693],{},[23,7694,7695],{},"部署对比：",[272,7697,7698,7711],{},[275,7699,7700],{},[278,7701,7702,7705,7708],{},[281,7703,7704],{},"方式",[281,7706,7707],{},"文件数",[281,7709,7710],{},"操作",[291,7712,7713,7724],{},[278,7714,7715,7718,7721],{},[296,7716,7717],{},"旧方案",[296,7719,7720],{},"1 exe + 1 web 目录",[296,7722,7723],{},"解压→保持目录结构",[278,7725,7726,7729,7732],{},[296,7727,7728],{},"新方案",[296,7730,7731],{},"1 个 exe",[296,7733,7734],{},"直接扔到服务器运行",[11,7736,793],{"id":793},[16,7738,7739,7740,7742],{},"Go 的 ",[35,7741,6837],{}," 是一个强大的工具，但有两个隐藏行为需要注意：",[767,7744,7745,7752],{},[530,7746,7747,7748,7492,7750,7495],{},"嵌入目录时自动排除 ",[35,7749,7475],{},[35,7751,2973],{},[530,7753,7754],{},"构建缓存可能导致 embed 不是最新的",[16,7756,7757],{},"对于前端+后端合体的场景，这是性价比最高的方案——不需要额外的打包工具，纯标准库搞定。",[801,7759,7760],{},"html pre.shiki code .sHbNN, html code.shiki .sHbNN{--shiki-light:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .suQ91, html code.shiki .suQ91{--shiki-light:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sw2iP, html code.shiki .sw2iP{--shiki-light:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .scXbn, html code.shiki .scXbn{--shiki-light:#032F62;--shiki-dark:#9ECBFF}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .siTax, html code.shiki .siTax{--shiki-light:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sIX_F, html code.shiki .sIX_F{--shiki-light:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sPK6S, html code.shiki .sPK6S{--shiki-light:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .sPKmS, html code.shiki .sPKmS{--shiki-light:#032F62;--shiki-dark:#DBEDFF}",{"title":33,"searchDepth":48,"depth":78,"links":7762},[7763,7764,7765,7770,7776,7777],{"id":2719,"depth":48,"text":2719},{"id":6831,"depth":48,"text":6831},{"id":6845,"depth":48,"text":6845,"children":7766},[7767,7768,7769],{"id":6848,"depth":78,"text":6849},{"id":7185,"depth":78,"text":7186},{"id":7297,"depth":78,"text":7298},{"id":7435,"depth":48,"text":7435,"children":7771},[7772,7773,7775],{"id":7438,"depth":78,"text":7439},{"id":7468,"depth":78,"text":7774},"坑二：\u002F\u002Fgo:embed 排除 _ 开头文件",{"id":7631,"depth":78,"text":7632},{"id":7651,"depth":48,"text":7651},{"id":793,"depth":48,"text":793},"记录将 Vue 3 前端嵌入 Go 二进制实现单 exe 发布的完整流程，以及遇到的坑和解决方案。","\u002Fposts\u002Fgo-embed-frontend\u002Fimg\u002Fcover.jpg",{},"\u002Fposts\u002Fgo-embed-frontend","2026-06-06T16:00:00",{"title":6814,"description":7778},"posts\u002Fgo-embed-frontend\u002Findex",[831,7786,7787,7788],"Vue","编译","埋坑","2026-06-06","SIQNL24wVAFb18wIGWSB3lNVZUQ9B76jWgQ55TASTYk",{"id":7792,"title":7793,"author":6,"body":7794,"description":9190,"difficulty":2060,"draft":819,"extension":820,"image":9191,"meta":9192,"navigation":81,"path":9193,"pinned":819,"published":9194,"seo":9195,"series":6,"seriesOrder":6,"stem":9196,"tags":9197,"updated":9194,"__hash__":9199},"posts\u002Fposts\u002Fjira-cli-intro\u002Findex.md","jira_cli — 把企业 Jira 装进命令行，AI Agent 直接调用",{"type":8,"value":7795,"toc":9169},[7796,7800,7815,7882,7885,7888,7965,7967,8000,8007,8010,8014,8126,8130,8261,8265,8333,8336,8399,8402,8404,8408,8411,8431,8434,8449,8452,8456,8462,8600,8611,8642,8646,8649,8746,8750,8753,8871,8875,8878,8942,8945,8949,8952,9020,9023,9029,9032,9066,9073,9076,9136,9138,9141,9158,9166],[11,7797,7799],{"id":7798},"jira_cli-是什么","jira_cli 是什么",[16,7801,7802,7807,7808,7492,7811,7814],{},[2672,7803,7806],{"href":7804,"rel":7805},"https:\u002F\u002Fgithub.com\u002Fyourname\u002Fjira_cli",[2676],"jira_cli"," 是一个面向企业 Jira（Server \u002F Data Center）的本地命令行工具，Go 语言编写，MIT 协议开源。它同时提供 ",[23,7809,7810],{},"Cobra CLI",[23,7812,7813],{},"MCP stdio server"," 两种入口，既能当日常命令行用，又能直接接入 AI 编程工具。",[28,7816,7818],{"className":30,"code":7817,"language":32,"meta":33,"style":33},"# CLI 日常使用\njira_cli issue search \"project = PROJ ORDER BY updated DESC\"\njira_cli issue get PROJ-123\njira_cli issue comment PROJ-123 \"处理完成，请测试验证\"\n\n# MCP 模式 — 给 AI 装上操作 Jira 的手\njira_cli mcp --user=work\n",[35,7819,7820,7825,7838,7849,7864,7868,7873],{"__ignoreMap":33},[38,7821,7822],{"class":40,"line":41},[38,7823,7824],{"class":44},"# CLI 日常使用\n",[38,7826,7827,7829,7832,7835],{"class":40,"line":48},[38,7828,7806],{"class":51},[38,7830,7831],{"class":55}," issue",[38,7833,7834],{"class":55}," search",[38,7836,7837],{"class":55}," \"project = PROJ ORDER BY updated DESC\"\n",[38,7839,7840,7842,7844,7846],{"class":40,"line":78},[38,7841,7806],{"class":51},[38,7843,7831],{"class":55},[38,7845,4255],{"class":55},[38,7847,7848],{"class":55}," PROJ-123\n",[38,7850,7851,7853,7855,7858,7861],{"class":40,"line":85},[38,7852,7806],{"class":51},[38,7854,7831],{"class":55},[38,7856,7857],{"class":55}," comment",[38,7859,7860],{"class":55}," PROJ-123",[38,7862,7863],{"class":55}," \"处理完成，请测试验证\"\n",[38,7865,7866],{"class":40,"line":91},[38,7867,82],{"emptyLinePlaceholder":81},[38,7869,7870],{"class":40,"line":114},[38,7871,7872],{"class":44},"# MCP 模式 — 给 AI 装上操作 Jira 的手\n",[38,7874,7875,7877,7879],{"class":40,"line":119},[38,7876,7806],{"class":51},[38,7878,4496],{"class":55},[38,7880,7881],{"class":65}," --user=work\n",[11,7883,7884],{"id":7884},"为什么需要它",[16,7886,7887],{},"大多数 Jira 命令行工具只解决\"开发者手动操作 Jira\"这一个场景。但 2026 年 AI 编程工具已经普及，你的 AI Agent（Claude Code、Codex、Cursor 等）也需要读写 Jira。痛点就来了：",[272,7889,7890,7900],{},[275,7891,7892],{},[278,7893,7894,7897],{},[281,7895,7896],{},"痛点",[281,7898,7899],{},"jira_cli 怎么解",[291,7901,7902,7910,7918,7930,7942,7953],{},[278,7903,7904,7907],{},[296,7905,7906],{},"企业 Jira 在内网，没有公网 API",[296,7908,7909],{},"本地 stdio MCP server，不经过外网",[278,7911,7912,7915],{},[296,7913,7914],{},"AI 可能误删、误写 Jira 数据",[296,7916,7917],{},"账号级只读策略，AI 只能查不能改",[278,7919,7920,7923],{},[296,7921,7922],{},"团队多人多 Jira 实例",[296,7924,7925,7926,7929],{},"多 Profile 管理，",[35,7927,7928],{},"--user=work"," 切换",[278,7931,7932,7935],{},[296,7933,7934],{},"Windows 终端体验差",[296,7936,7937,7938,7941],{},"原生 ",[35,7939,7940],{},".exe"," 构建 + 一键安装脚本",[278,7943,7944,7947],{},[296,7945,7946],{},"AI 生成 JQL 困难",[296,7948,7949,7950],{},"字段元数据发现：",[35,7951,7952],{},"field list --query xxx",[278,7954,7955,7958],{},[296,7956,7957],{},"MCP 直接拿原始 JSON 太冗长",[296,7959,7960,7961,7964],{},"紧凑视图 + ",[35,7962,7963],{},"--view summary"," 减少 token 消耗",[11,7966,179],{"id":179},[28,7968,7970],{"className":30,"code":7969,"language":32,"meta":33,"style":33},"# 从源码安装（要求 Go 1.26.3+）\ngo install github.com\u002Fyourname\u002Fjira_cli@latest\n\n# 或下载预编译二进制\n# https:\u002F\u002Fgithub.com\u002Fyourname\u002Fjira_cli\u002Freleases\n",[35,7971,7972,7977,7986,7990,7995],{"__ignoreMap":33},[38,7973,7974],{"class":40,"line":41},[38,7975,7976],{"class":44},"# 从源码安装（要求 Go 1.26.3+）\n",[38,7978,7979,7981,7983],{"class":40,"line":48},[38,7980,976],{"class":51},[38,7982,200],{"class":55},[38,7984,7985],{"class":55}," github.com\u002Fyourname\u002Fjira_cli@latest\n",[38,7987,7988],{"class":40,"line":78},[38,7989,82],{"emptyLinePlaceholder":81},[38,7991,7992],{"class":40,"line":85},[38,7993,7994],{"class":44},"# 或下载预编译二进制\n",[38,7996,7997],{"class":40,"line":91},[38,7998,7999],{"class":44},"# https:\u002F\u002Fgithub.com\u002Fyourname\u002Fjira_cli\u002Freleases\n",[16,8001,8002,8003,8006],{},"Windows 用户下载 zip 包解压后双击 ",[35,8004,8005],{},"install.bat"," 即可全局安装，自动加入 PATH。",[11,8008,8009],{"id":8009},"快速上手",[675,8011,8013],{"id":8012},"_1-配置认证","1. 配置认证",[28,8015,8017],{"className":30,"code":8016,"language":32,"meta":33,"style":33},"# 设置 Jira 地址\njira_cli set base-url https:\u002F\u002Fjira.your-company.com --global\n\n# Token 认证（推荐）\njira_cli set token \u003Cyour-token> --global\n\n# 或用户名密码认证\njira_cli set auth \u003Cusername> \u003Cpassword> --global\n\n# 验证一下\njira_cli user me\n",[35,8018,8019,8024,8040,8044,8049,8069,8073,8078,8107,8111,8116],{"__ignoreMap":33},[38,8020,8021],{"class":40,"line":41},[38,8022,8023],{"class":44},"# 设置 Jira 地址\n",[38,8025,8026,8028,8031,8034,8037],{"class":40,"line":48},[38,8027,7806],{"class":51},[38,8029,8030],{"class":55}," set",[38,8032,8033],{"class":55}," base-url",[38,8035,8036],{"class":55}," https:\u002F\u002Fjira.your-company.com",[38,8038,8039],{"class":65}," --global\n",[38,8041,8042],{"class":40,"line":78},[38,8043,82],{"emptyLinePlaceholder":81},[38,8045,8046],{"class":40,"line":85},[38,8047,8048],{"class":44},"# Token 认证（推荐）\n",[38,8050,8051,8053,8055,8058,8060,8063,8065,8067],{"class":40,"line":91},[38,8052,7806],{"class":51},[38,8054,8030],{"class":55},[38,8056,8057],{"class":55}," token",[38,8059,1379],{"class":938},[38,8061,8062],{"class":55},"your-toke",[38,8064,1783],{"class":881},[38,8066,1388],{"class":938},[38,8068,8039],{"class":65},[38,8070,8071],{"class":40,"line":114},[38,8072,82],{"emptyLinePlaceholder":81},[38,8074,8075],{"class":40,"line":119},[38,8076,8077],{"class":44},"# 或用户名密码认证\n",[38,8079,8080,8082,8084,8086,8088,8091,8093,8095,8097,8100,8103,8105],{"class":40,"line":125},[38,8081,7806],{"class":51},[38,8083,8030],{"class":55},[38,8085,254],{"class":55},[38,8087,1379],{"class":938},[38,8089,8090],{"class":55},"usernam",[38,8092,1769],{"class":881},[38,8094,1388],{"class":938},[38,8096,1379],{"class":938},[38,8098,8099],{"class":55},"passwor",[38,8101,8102],{"class":881},"d",[38,8104,1388],{"class":938},[38,8106,8039],{"class":65},[38,8108,8109],{"class":40,"line":151},[38,8110,82],{"emptyLinePlaceholder":81},[38,8112,8113],{"class":40,"line":156},[38,8114,8115],{"class":44},"# 验证一下\n",[38,8117,8118,8120,8123],{"class":40,"line":162},[38,8119,7806],{"class":51},[38,8121,8122],{"class":55}," user",[38,8124,8125],{"class":55}," me\n",[675,8127,8129],{"id":8128},"_2-日常使用","2. 日常使用",[28,8131,8133],{"className":30,"code":8132,"language":32,"meta":33,"style":33},"# 查自己今天的任务\njira_cli issue search \"assignee = currentUser() ORDER BY updated DESC\"\n\n# 看某个任务详情\njira_cli issue get PROJ-123\n\n# 流转状态\njira_cli issue transitions PROJ-123                    # 可以转哪些\njira_cli issue do-transition PROJ-123 21               # 执行流转\n\n# 添加备注\njira_cli issue comment PROJ-123 \"已定位到问题，正在修复\"\n\n# 创建任务\njira_cli issue create PROJ Task --fields @fields.json\n",[35,8134,8135,8140,8151,8155,8160,8170,8174,8179,8193,8210,8214,8219,8232,8236,8241],{"__ignoreMap":33},[38,8136,8137],{"class":40,"line":41},[38,8138,8139],{"class":44},"# 查自己今天的任务\n",[38,8141,8142,8144,8146,8148],{"class":40,"line":48},[38,8143,7806],{"class":51},[38,8145,7831],{"class":55},[38,8147,7834],{"class":55},[38,8149,8150],{"class":55}," \"assignee = currentUser() ORDER BY updated DESC\"\n",[38,8152,8153],{"class":40,"line":78},[38,8154,82],{"emptyLinePlaceholder":81},[38,8156,8157],{"class":40,"line":85},[38,8158,8159],{"class":44},"# 看某个任务详情\n",[38,8161,8162,8164,8166,8168],{"class":40,"line":91},[38,8163,7806],{"class":51},[38,8165,7831],{"class":55},[38,8167,4255],{"class":55},[38,8169,7848],{"class":55},[38,8171,8172],{"class":40,"line":114},[38,8173,82],{"emptyLinePlaceholder":81},[38,8175,8176],{"class":40,"line":119},[38,8177,8178],{"class":44},"# 流转状态\n",[38,8180,8181,8183,8185,8188,8190],{"class":40,"line":125},[38,8182,7806],{"class":51},[38,8184,7831],{"class":55},[38,8186,8187],{"class":55}," transitions",[38,8189,7860],{"class":55},[38,8191,8192],{"class":44},"                    # 可以转哪些\n",[38,8194,8195,8197,8199,8202,8204,8207],{"class":40,"line":151},[38,8196,7806],{"class":51},[38,8198,7831],{"class":55},[38,8200,8201],{"class":55}," do-transition",[38,8203,7860],{"class":55},[38,8205,8206],{"class":65}," 21",[38,8208,8209],{"class":44},"               # 执行流转\n",[38,8211,8212],{"class":40,"line":156},[38,8213,82],{"emptyLinePlaceholder":81},[38,8215,8216],{"class":40,"line":162},[38,8217,8218],{"class":44},"# 添加备注\n",[38,8220,8221,8223,8225,8227,8229],{"class":40,"line":1017},[38,8222,7806],{"class":51},[38,8224,7831],{"class":55},[38,8226,7857],{"class":55},[38,8228,7860],{"class":55},[38,8230,8231],{"class":55}," \"已定位到问题，正在修复\"\n",[38,8233,8234],{"class":40,"line":1022},[38,8235,82],{"emptyLinePlaceholder":81},[38,8237,8238],{"class":40,"line":1028},[38,8239,8240],{"class":44},"# 创建任务\n",[38,8242,8243,8245,8247,8249,8252,8255,8258],{"class":40,"line":1039},[38,8244,7806],{"class":51},[38,8246,7831],{"class":55},[38,8248,136],{"class":55},[38,8250,8251],{"class":55}," PROJ",[38,8253,8254],{"class":55}," Task",[38,8256,8257],{"class":65}," --fields",[38,8259,8260],{"class":55}," @fields.json\n",[675,8262,8264],{"id":8263},"_3-配给-ai-使用","3. 配给 AI 使用",[28,8266,8268],{"className":30,"code":8267,"language":32,"meta":33,"style":33},"# 先配置一个只读账号给 AI 用\njira_cli set base-url https:\u002F\u002Fjira.your-company.com --name ai --global\njira_cli set token \u003Ctoken> --name ai --global\njira_cli set readonly on --name ai --global\n",[35,8269,8270,8275,8292,8315],{"__ignoreMap":33},[38,8271,8272],{"class":40,"line":41},[38,8273,8274],{"class":44},"# 先配置一个只读账号给 AI 用\n",[38,8276,8277,8279,8281,8283,8285,8287,8290],{"class":40,"line":48},[38,8278,7806],{"class":51},[38,8280,8030],{"class":55},[38,8282,8033],{"class":55},[38,8284,8036],{"class":55},[38,8286,1376],{"class":65},[38,8288,8289],{"class":55}," ai",[38,8291,8039],{"class":65},[38,8293,8294,8296,8298,8300,8302,8305,8307,8309,8311,8313],{"class":40,"line":78},[38,8295,7806],{"class":51},[38,8297,8030],{"class":55},[38,8299,8057],{"class":55},[38,8301,1379],{"class":938},[38,8303,8304],{"class":55},"toke",[38,8306,1783],{"class":881},[38,8308,1388],{"class":938},[38,8310,1376],{"class":65},[38,8312,8289],{"class":55},[38,8314,8039],{"class":65},[38,8316,8317,8319,8321,8324,8327,8329,8331],{"class":40,"line":85},[38,8318,7806],{"class":51},[38,8320,8030],{"class":55},[38,8322,8323],{"class":55}," readonly",[38,8325,8326],{"class":55}," on",[38,8328,1376],{"class":65},[38,8330,8289],{"class":55},[38,8332,8039],{"class":65},[16,8334,8335],{},"然后在 AI 工具的 MCP 配置里加上：",[28,8337,8339],{"className":6066,"code":8338,"language":6068,"meta":33,"style":33},"{\n  \"mcpServers\": {\n    \"jira\": {\n      \"command\": \"jira_cli\",\n      \"args\": [\"mcp\", \"--user=ai\"]\n    }\n  }\n}\n",[35,8340,8341,8345,8351,8358,8370,8387,8391,8395],{"__ignoreMap":33},[38,8342,8343],{"class":40,"line":41},[38,8344,6075],{"class":881},[38,8346,8347,8349],{"class":40,"line":48},[38,8348,6080],{"class":65},[38,8350,2771],{"class":881},[38,8352,8353,8356],{"class":40,"line":78},[38,8354,8355],{"class":65},"    \"jira\"",[38,8357,2771],{"class":881},[38,8359,8360,8363,8365,8368],{"class":40,"line":85},[38,8361,8362],{"class":65},"      \"command\"",[38,8364,2556],{"class":881},[38,8366,8367],{"class":55},"\"jira_cli\"",[38,8369,2503],{"class":881},[38,8371,8372,8375,8377,8380,8382,8385],{"class":40,"line":91},[38,8373,8374],{"class":65},"      \"args\"",[38,8376,2587],{"class":881},[38,8378,8379],{"class":55},"\"mcp\"",[38,8381,1598],{"class":881},[38,8383,8384],{"class":55},"\"--user=ai\"",[38,8386,1301],{"class":881},[38,8388,8389],{"class":40,"line":114},[38,8390,2811],{"class":881},[38,8392,8393],{"class":40,"line":119},[38,8394,2816],{"class":881},[38,8396,8397],{"class":40,"line":125},[38,8398,2153],{"class":881},[16,8400,8401],{},"这样 AI 就能查 Jira 但不能修改，既有了上下文，又不用担心误操作。",[11,8403,673],{"id":673},[675,8405,8407],{"id":8406},"_1-账号级只读策略","1. 账号级只读策略",[16,8409,8410],{},"这是 jira_cli 最实用的设计。给 AI Agent 配置只读账号：",[28,8412,8414],{"className":30,"code":8413,"language":32,"meta":33,"style":33},"jira_cli set readonly on --name ai\n",[35,8415,8416],{"__ignoreMap":33},[38,8417,8418,8420,8422,8424,8426,8428],{"class":40,"line":41},[38,8419,7806],{"class":51},[38,8421,8030],{"class":55},[38,8423,8323],{"class":55},[38,8425,8326],{"class":55},[38,8427,1376],{"class":65},[38,8429,8430],{"class":55}," ai\n",[16,8432,8433],{},"只读模式下：",[527,8435,8436,8439,8442],{},[530,8437,8438],{},"CLI 写命令被隐藏和拒绝",[530,8440,8441],{},"MCP 的写入工具不会注册",[530,8443,8444,8445,8448],{},"环境变量 ",[35,8446,8447],{},"JIRA_READONLY=true"," 也可以临时覆盖",[16,8450,8451],{},"AI 只能查 JQL、看任务、查字段、读评论，绝对不会误改数据。需要创建\u002F更新时，另配一个有写入权限的账号由开发者自己操作。",[675,8453,8455],{"id":8454},"_2-多-profile-切换","2. 多 Profile 切换",[16,8457,8458,8459,8461],{},"一个配置文件里管理多个 Jira 账号，",[35,8460,1734],{}," 切换：",[28,8463,8465],{"className":30,"code":8464,"language":32,"meta":33,"style":33},"# 工作账号\njira_cli set base-url https:\u002F\u002Fjira.company.com --name work --global\njira_cli set token \u003Cwork-token> --name work --global\n\n# 个人账号\njira_cli set base-url https:\u002F\u002Fjira.personal.com --name personal --global\njira_cli set token \u003Cpersonal-token> --name personal --global\n\n# 使用时切换\njira_cli --user=work issue search \"project = PROJ\"\njira_cli --user=personal issue search \"project = SIDE\"\n",[35,8466,8467,8472,8490,8513,8517,8522,8540,8563,8567,8572,8586],{"__ignoreMap":33},[38,8468,8469],{"class":40,"line":41},[38,8470,8471],{"class":44},"# 工作账号\n",[38,8473,8474,8476,8478,8480,8483,8485,8488],{"class":40,"line":48},[38,8475,7806],{"class":51},[38,8477,8030],{"class":55},[38,8479,8033],{"class":55},[38,8481,8482],{"class":55}," https:\u002F\u002Fjira.company.com",[38,8484,1376],{"class":65},[38,8486,8487],{"class":55}," work",[38,8489,8039],{"class":65},[38,8491,8492,8494,8496,8498,8500,8503,8505,8507,8509,8511],{"class":40,"line":78},[38,8493,7806],{"class":51},[38,8495,8030],{"class":55},[38,8497,8057],{"class":55},[38,8499,1379],{"class":938},[38,8501,8502],{"class":55},"work-toke",[38,8504,1783],{"class":881},[38,8506,1388],{"class":938},[38,8508,1376],{"class":65},[38,8510,8487],{"class":55},[38,8512,8039],{"class":65},[38,8514,8515],{"class":40,"line":85},[38,8516,82],{"emptyLinePlaceholder":81},[38,8518,8519],{"class":40,"line":91},[38,8520,8521],{"class":44},"# 个人账号\n",[38,8523,8524,8526,8528,8530,8533,8535,8538],{"class":40,"line":114},[38,8525,7806],{"class":51},[38,8527,8030],{"class":55},[38,8529,8033],{"class":55},[38,8531,8532],{"class":55}," https:\u002F\u002Fjira.personal.com",[38,8534,1376],{"class":65},[38,8536,8537],{"class":55}," personal",[38,8539,8039],{"class":65},[38,8541,8542,8544,8546,8548,8550,8553,8555,8557,8559,8561],{"class":40,"line":119},[38,8543,7806],{"class":51},[38,8545,8030],{"class":55},[38,8547,8057],{"class":55},[38,8549,1379],{"class":938},[38,8551,8552],{"class":55},"personal-toke",[38,8554,1783],{"class":881},[38,8556,1388],{"class":938},[38,8558,1376],{"class":65},[38,8560,8537],{"class":55},[38,8562,8039],{"class":65},[38,8564,8565],{"class":40,"line":125},[38,8566,82],{"emptyLinePlaceholder":81},[38,8568,8569],{"class":40,"line":151},[38,8570,8571],{"class":44},"# 使用时切换\n",[38,8573,8574,8576,8579,8581,8583],{"class":40,"line":156},[38,8575,7806],{"class":51},[38,8577,8578],{"class":65}," --user=work",[38,8580,7831],{"class":55},[38,8582,7834],{"class":55},[38,8584,8585],{"class":55}," \"project = PROJ\"\n",[38,8587,8588,8590,8593,8595,8597],{"class":40,"line":162},[38,8589,7806],{"class":51},[38,8591,8592],{"class":65}," --user=personal",[38,8594,7831],{"class":55},[38,8596,7834],{"class":55},[38,8598,8599],{"class":55}," \"project = SIDE\"\n",[16,8601,8602,8603,8606,8607,8610],{},"同时支持",[23,8604,8605],{},"项目级配置","（",[35,8608,8609],{},"--local","）和工作目录绑定：",[28,8612,8614],{"className":30,"code":8613,"language":32,"meta":33,"style":33},"# 在项目目录下，配置只对当前项目生效\ncd ~\u002Fprojects\u002Fbackend\njira_cli set base-url https:\u002F\u002Fjira-backend.company.com --local\n",[35,8615,8616,8621,8628],{"__ignoreMap":33},[38,8617,8618],{"class":40,"line":41},[38,8619,8620],{"class":44},"# 在项目目录下，配置只对当前项目生效\n",[38,8622,8623,8625],{"class":40,"line":48},[38,8624,7401],{"class":65},[38,8626,8627],{"class":55}," ~\u002Fprojects\u002Fbackend\n",[38,8629,8630,8632,8634,8636,8639],{"class":40,"line":78},[38,8631,7806],{"class":51},[38,8633,8030],{"class":55},[38,8635,8033],{"class":55},[38,8637,8638],{"class":55}," https:\u002F\u002Fjira-backend.company.com",[38,8640,8641],{"class":65}," --local\n",[675,8643,8645],{"id":8644},"_3-对-ai-友好的输出","3. 对 AI 友好的输出",[16,8647,8648],{},"jira_cli 在输出设计上充分考虑了 AI Agent 的 token 消耗：",[28,8650,8652],{"className":30,"code":8651,"language":32,"meta":33,"style":33},"# 紧凑视图 — 去掉 self\u002Favatar\u002F缩略图等无用字段\njira_cli issue get PROJ-123 --view summary\n\n# 表格输出 — 一眼看到关键信息\njira_cli issue search \"project = PROJ\" --output table\n\n# JSON 输出 — 程序解析，不转义 Unicode\njira_cli --output json issue get PROJ-123\n\n# 自定义搜索列 — 只要需要的字段\njira_cli set search-columns key,summary,fixVersions\n",[35,8653,8654,8659,8675,8679,8684,8701,8705,8710,8725,8729,8734],{"__ignoreMap":33},[38,8655,8656],{"class":40,"line":41},[38,8657,8658],{"class":44},"# 紧凑视图 — 去掉 self\u002Favatar\u002F缩略图等无用字段\n",[38,8660,8661,8663,8665,8667,8669,8672],{"class":40,"line":48},[38,8662,7806],{"class":51},[38,8664,7831],{"class":55},[38,8666,4255],{"class":55},[38,8668,7860],{"class":55},[38,8670,8671],{"class":65}," --view",[38,8673,8674],{"class":55}," summary\n",[38,8676,8677],{"class":40,"line":78},[38,8678,82],{"emptyLinePlaceholder":81},[38,8680,8681],{"class":40,"line":85},[38,8682,8683],{"class":44},"# 表格输出 — 一眼看到关键信息\n",[38,8685,8686,8688,8690,8692,8695,8698],{"class":40,"line":91},[38,8687,7806],{"class":51},[38,8689,7831],{"class":55},[38,8691,7834],{"class":55},[38,8693,8694],{"class":55}," \"project = PROJ\"",[38,8696,8697],{"class":65}," --output",[38,8699,8700],{"class":55}," table\n",[38,8702,8703],{"class":40,"line":114},[38,8704,82],{"emptyLinePlaceholder":81},[38,8706,8707],{"class":40,"line":119},[38,8708,8709],{"class":44},"# JSON 输出 — 程序解析，不转义 Unicode\n",[38,8711,8712,8714,8716,8719,8721,8723],{"class":40,"line":125},[38,8713,7806],{"class":51},[38,8715,8697],{"class":65},[38,8717,8718],{"class":55}," json",[38,8720,7831],{"class":55},[38,8722,4255],{"class":55},[38,8724,7848],{"class":55},[38,8726,8727],{"class":40,"line":151},[38,8728,82],{"emptyLinePlaceholder":81},[38,8730,8731],{"class":40,"line":156},[38,8732,8733],{"class":44},"# 自定义搜索列 — 只要需要的字段\n",[38,8735,8736,8738,8740,8743],{"class":40,"line":162},[38,8737,7806],{"class":51},[38,8739,8030],{"class":55},[38,8741,8742],{"class":55}," search-columns",[38,8744,8745],{"class":55}," key,summary,fixVersions\n",[675,8747,8749],{"id":8748},"_4-字段元数据发现","4. 字段元数据发现",[16,8751,8752],{},"Jira 的 customfield 让人头疼，jira_cli 提供了完整的字段发现能力：",[28,8754,8756],{"className":30,"code":8755,"language":32,"meta":33,"style":33},"# 列出所有字段及其类型\njira_cli field list\n\n# 搜索包含特定关键词的字段\njira_cli field list --query \"版本\"\n\n# 只看自定义字段\njira_cli field list --custom-only\n\n# 创建任务前，先看看需要哪些字段\njira_cli issue create-meta PROJ --issue-type Task\n\n# 分页查看可选值\njira_cli issue create-meta-values PROJ Task priority --query High\n",[35,8757,8758,8763,8772,8776,8781,8795,8799,8804,8815,8819,8824,8841,8845,8850],{"__ignoreMap":33},[38,8759,8760],{"class":40,"line":41},[38,8761,8762],{"class":44},"# 列出所有字段及其类型\n",[38,8764,8765,8767,8770],{"class":40,"line":48},[38,8766,7806],{"class":51},[38,8768,8769],{"class":55}," field",[38,8771,2905],{"class":55},[38,8773,8774],{"class":40,"line":78},[38,8775,82],{"emptyLinePlaceholder":81},[38,8777,8778],{"class":40,"line":85},[38,8779,8780],{"class":44},"# 搜索包含特定关键词的字段\n",[38,8782,8783,8785,8787,8789,8792],{"class":40,"line":91},[38,8784,7806],{"class":51},[38,8786,8769],{"class":55},[38,8788,62],{"class":55},[38,8790,8791],{"class":65}," --query",[38,8793,8794],{"class":55}," \"版本\"\n",[38,8796,8797],{"class":40,"line":114},[38,8798,82],{"emptyLinePlaceholder":81},[38,8800,8801],{"class":40,"line":119},[38,8802,8803],{"class":44},"# 只看自定义字段\n",[38,8805,8806,8808,8810,8812],{"class":40,"line":125},[38,8807,7806],{"class":51},[38,8809,8769],{"class":55},[38,8811,62],{"class":55},[38,8813,8814],{"class":65}," --custom-only\n",[38,8816,8817],{"class":40,"line":151},[38,8818,82],{"emptyLinePlaceholder":81},[38,8820,8821],{"class":40,"line":156},[38,8822,8823],{"class":44},"# 创建任务前，先看看需要哪些字段\n",[38,8825,8826,8828,8830,8833,8835,8838],{"class":40,"line":162},[38,8827,7806],{"class":51},[38,8829,7831],{"class":55},[38,8831,8832],{"class":55}," create-meta",[38,8834,8251],{"class":55},[38,8836,8837],{"class":65}," --issue-type",[38,8839,8840],{"class":55}," Task\n",[38,8842,8843],{"class":40,"line":1017},[38,8844,82],{"emptyLinePlaceholder":81},[38,8846,8847],{"class":40,"line":1022},[38,8848,8849],{"class":44},"# 分页查看可选值\n",[38,8851,8852,8854,8856,8859,8861,8863,8866,8868],{"class":40,"line":1028},[38,8853,7806],{"class":51},[38,8855,7831],{"class":55},[38,8857,8858],{"class":55}," create-meta-values",[38,8860,8251],{"class":55},[38,8862,8254],{"class":55},[38,8864,8865],{"class":55}," priority",[38,8867,8791],{"class":65},[38,8869,8870],{"class":55}," High\n",[675,8872,8874],{"id":8873},"_5-mcp-写入控制","5. MCP 写入控制",[16,8876,8877],{},"除了全局只读，MCP 还有更细粒度的控制：",[272,8879,8880,8890],{},[275,8881,8882],{},[278,8883,8884,8887],{},[281,8885,8886],{},"环境变量",[281,8888,8889],{},"作用",[291,8891,8892,8902,8912,8922,8932],{},[278,8893,8894,8899],{},[296,8895,8896],{},[35,8897,8898],{},"JIRA_CREATE_OPEN=false",[296,8900,8901],{},"禁用创建\u002F更新工具",[278,8903,8904,8909],{},[296,8905,8906],{},[35,8907,8908],{},"JIRA_DISABLE_DISCLAIMER=true",[296,8910,8911],{},"去掉 AI 自动添加的备注声明",[278,8913,8914,8919],{},[296,8915,8916],{},[35,8917,8918],{},"JIRA_CREATE_SUMMARY_MAX",[296,8920,8921],{},"限制摘要长度",[278,8923,8924,8929],{},[296,8925,8926],{},[35,8927,8928],{},"JIRA_CREATE_DESCRIPTION_MAX",[296,8930,8931],{},"限制描述长度",[278,8933,8934,8939],{},[296,8935,8936],{},[35,8937,8938],{},"JIRA_CREATE_FIELDS_MAX_BYTES",[296,8940,8941],{},"限制创建字段的 JSON 大小",[16,8943,8944],{},"这些限制确保 AI 即使有写入权限也不会\"失控\"——例如防止 AI 往描述里塞几万字的废话。",[675,8946,8948],{"id":8947},"_6-中英双语帮助","6. 中英双语帮助",[16,8950,8951],{},"帮助信息默认中文，也支持一键切英文：",[28,8953,8955],{"className":30,"code":8954,"language":32,"meta":33,"style":33},"# 保存英文偏好\njira_cli set help-language en\n\n# 切回中文\njira_cli set help-language zh\n\n# 临时覆盖\njira_cli --lang en issue search -h\n",[35,8956,8957,8962,8974,8978,8983,8994,8998,9003],{"__ignoreMap":33},[38,8958,8959],{"class":40,"line":41},[38,8960,8961],{"class":44},"# 保存英文偏好\n",[38,8963,8964,8966,8968,8971],{"class":40,"line":48},[38,8965,7806],{"class":51},[38,8967,8030],{"class":55},[38,8969,8970],{"class":55}," help-language",[38,8972,8973],{"class":55}," en\n",[38,8975,8976],{"class":40,"line":78},[38,8977,82],{"emptyLinePlaceholder":81},[38,8979,8980],{"class":40,"line":85},[38,8981,8982],{"class":44},"# 切回中文\n",[38,8984,8985,8987,8989,8991],{"class":40,"line":91},[38,8986,7806],{"class":51},[38,8988,8030],{"class":55},[38,8990,8970],{"class":55},[38,8992,8993],{"class":55}," zh\n",[38,8995,8996],{"class":40,"line":114},[38,8997,82],{"emptyLinePlaceholder":81},[38,8999,9000],{"class":40,"line":119},[38,9001,9002],{"class":44},"# 临时覆盖\n",[38,9004,9005,9007,9010,9013,9015,9017],{"class":40,"line":125},[38,9006,7806],{"class":51},[38,9008,9009],{"class":65}," --lang",[38,9011,9012],{"class":55}," en",[38,9014,7831],{"class":55},[38,9016,7834],{"class":55},[38,9018,9019],{"class":65}," -h\n",[11,9021,9022],{"id":9022},"架构一览",[28,9024,9027],{"className":9025,"code":9026,"language":2169},[2167],"main.go\n  cmd\u002F                         CLI 命令、MCP 启动、访问策略\n  internal\u002Fconfig\u002F             运行时配置、Profile 持久化\n  internal\u002Ftools\u002Fjira_tools\u002F   MCP 输入\u002F输出适配层\n  pkg\u002Fjira\u002F                    可复用的 Jira REST 客户端\n",[35,9028,9026],{"__ignoreMap":33},[16,9030,9031],{},"设计原则清晰：",[527,9033,9034,9042,9050,9058],{},[530,9035,9036,9041],{},[23,9037,9038],{},[35,9039,9040],{},"pkg\u002Fjira"," 是纯 Jira 客户端，HTTP、认证、上下文处理都在这里",[530,9043,9044,9049],{},[23,9045,9046],{},[35,9047,9048],{},"cmd"," 只管 Cobra 解析和呈现",[530,9051,9052,9057],{},[23,9053,9054],{},[35,9055,9056],{},"internal\u002Ftools\u002Fjira_tools"," 只管 MCP schema 验证和结果适配",[530,9059,9060,9065],{},[23,9061,9062],{},[35,9063,9064],{},"internal\u002Fconfig"," 拥有 Profile 的全部逻辑，不分散到 cmd",[16,9067,9068,9069,9072],{},"每一层职责单一，新增 Jira 能力时按 ",[35,9070,9071],{},"endpoints.go → Client 方法 → CLI 命令 → MCP tool → 策略注册"," 的路径走就行。",[11,9074,9075],{"id":9075},"和已有方案的对比",[272,9077,9078,9090],{},[275,9079,9080],{},[278,9081,9082,9084,9087],{},[281,9083,3529],{},[281,9085,9086],{},"适合场景",[281,9088,9089],{},"jira_cli 的区别",[291,9091,9092,9103,9114,9125],{},[278,9093,9094,9097,9100],{},[296,9095,9096],{},"Atlassian Rovo MCP Server",[296,9098,9099],{},"Jira Cloud、官方 OAuth",[296,9101,9102],{},"jira_cli 是本地 stdio，适合内网 Server\u002FDC",[278,9104,9105,9108,9111],{},[296,9106,9107],{},"go-jira \u002F jira-cli 社区版",[296,9109,9110],{},"纯命令行管理",[296,9112,9113],{},"jira_cli 同时提供 MCP server，面向 AI 工作流",[278,9115,9116,9119,9122],{},[296,9117,9118],{},"直接调 REST API",[296,9120,9121],{},"自定义集成",[296,9123,9124],{},"jira_cli 封装了认证、只读策略、字段元数据",[278,9126,9127,9130,9133],{},[296,9128,9129],{},"Jira 网页",[296,9131,9132],{},"日常使用",[296,9134,9135],{},"jira_cli 把 Jira 变成终端里的一行命令",[11,9137,793],{"id":793},[16,9139,9140],{},"如果你的团队满足以下任一条件，jira_cli 值得一试：",[527,9142,9143,9146,9149,9152,9155],{},[530,9144,9145],{},"企业 Jira 部署在内网，想接入 AI 编程工具",[530,9147,9148],{},"想给 AI Agent 只读权限来查 Jira 上下文",[530,9150,9151],{},"需要命令行快速查看\u002F操作 Jira，不想切浏览器",[530,9153,9154],{},"需要管理多个 Jira 实例和多套账号",[530,9156,9157],{},"Windows 用户想要无摩擦的 CLI 体验",[16,9159,9160,9161,9165],{},"GitHub：",[2672,9162,9164],{"href":7804,"rel":9163},[2676],"github.com\u002Fyourname\u002Fjira_cli"," | MIT 协议 | Go 1.26.3+",[801,9167,9168],{},"html pre.shiki code .sHbNN, html code.shiki .sHbNN{--shiki-light:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .sw2iP, html code.shiki .sw2iP{--shiki-light:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .scXbn, html code.shiki .scXbn{--shiki-light:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .suQ91, html code.shiki .suQ91{--shiki-light:#005CC5;--shiki-dark:#79B8FF}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .siTax, html code.shiki .siTax{--shiki-light:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sIX_F, html code.shiki .sIX_F{--shiki-light:#24292E;--shiki-dark:#E1E4E8}",{"title":33,"searchDepth":48,"depth":78,"links":9170},[9171,9172,9173,9174,9179,9187,9188,9189],{"id":7798,"depth":48,"text":7799},{"id":7884,"depth":48,"text":7884},{"id":179,"depth":48,"text":179},{"id":8009,"depth":48,"text":8009,"children":9175},[9176,9177,9178],{"id":8012,"depth":78,"text":8013},{"id":8128,"depth":78,"text":8129},{"id":8263,"depth":78,"text":8264},{"id":673,"depth":48,"text":673,"children":9180},[9181,9182,9183,9184,9185,9186],{"id":8406,"depth":78,"text":8407},{"id":8454,"depth":78,"text":8455},{"id":8644,"depth":78,"text":8645},{"id":8748,"depth":78,"text":8749},{"id":8873,"depth":78,"text":8874},{"id":8947,"depth":78,"text":8948},{"id":9022,"depth":48,"text":9022},{"id":9075,"depth":48,"text":9075},{"id":793,"depth":48,"text":793},"介绍 jira_cli，一个开源的企业 Jira CLI 命令行工具和MCP，支持 Jira Server \u002F Data Center，自带 MCP stdio server，让 Claude Code、Codex 等 AI 编程工具直接读写 Jira 任务。","\u002Fposts\u002Fjira-cli-intro\u002Fimg\u002Fcover.svg",{},"\u002Fposts\u002Fjira-cli-intro","2026-07-17",{"title":7793,"description":9190},"posts\u002Fjira-cli-intro\u002Findex",[9198,829,6241,831,830,832],"Jira","vNH6sF2mMFmSjbHoEI2OqmYCgpFmaqyKt-MUfcQqXrU",{"id":9201,"title":9202,"author":6,"body":9203,"description":9747,"difficulty":2060,"draft":819,"extension":820,"image":9748,"meta":9749,"navigation":81,"path":9750,"pinned":819,"published":9751,"seo":9752,"series":9753,"seriesOrder":41,"stem":9754,"tags":9755,"updated":9758,"__hash__":9759},"posts\u002Fposts\u002Fsublog-evolution\u002Findex.md","尼克的小窝 架构变迁：从纯静态到前后端分离",{"type":8,"value":9204,"toc":9727},[9205,9209,9212,9223,9227,9231,9237,9241,9272,9275,9289,9293,9296,9364,9368,9371,9377,9381,9391,9421,9424,9428,9436,9439,9483,9487,9608,9612,9619,9623,9629,9635,9639,9642,9693,9697,9704,9711,9717,9720,9722],[11,9206,9208],{"id":9207},"写在前面","📝 写在前面",[16,9210,9211],{},"SuBlog 最初只是一个简单的技术博客——Nuxt 3 静态生成，Markdown 写文章，部署到静态托管完事。但随着功能需求的增长，纯静态的架构开始显露出局限性。",[16,9213,9214,9215,9218,9219,9222],{},"这篇文章记录从 ",[23,9216,9217],{},"纯静态"," 到 ",[23,9220,9221],{},"前后端分离"," 的演进过程，带你了解每一步背后的思考。",[11,9224,9226],{"id":9225},"第一代纯静态nuxt-3-nuxtcontent","📦 第一代：纯静态（Nuxt 3 + @nuxt\u002Fcontent）",[675,9228,9230],{"id":9229},"️-架构图","🏗️ 架构图",[28,9232,9235],{"className":9233,"code":9234,"language":2169,"meta":33},[2167],"┌─────────────────────────────────────┐\n│          静态托管 (Nginx\u002FVercel)      │\n│                                     │\n│  ┌───────────────────────────────┐  │\n│  │     Nuxt 3 SSG 产物            │  │\n│  │                               │  │\n│  │  pages\u002F + content\u002F + public\u002F  │  │\n│  └───────────────────────────────┘  │\n│                                     │\n│  Giscus (GitHub Discussions 评论)    │\n└─────────────────────────────────────┘\n",[35,9236,9234],{"__ignoreMap":33},[675,9238,9240],{"id":9239},"做了什么","🔧 做了什么",[527,9242,9243,9252,9257,9262],{},[530,9244,9245,2026,9247,2026,9249,9251],{},[23,9246,2339],{},[23,9248,2349],{},[23,9250,2359],{}," 搭建前端",[530,9253,9254,9256],{},[23,9255,2369],{}," 管理 Markdown 文章，编译时生成静态页面",[530,9258,9259,9261],{},[23,9260,2389],{}," 嵌入评论，依赖 GitHub Discussions",[530,9263,9264,9265,9268,9269,9271],{},"部署通过 ",[35,9266,9267],{},"nuxt generate"," 输出 ",[35,9270,2629],{},"，丢到 Nginx 或 Vercel",[675,9273,9274],{"id":3532},"✅ 优点",[527,9276,9277,9283,9286],{},[530,9278,9279,9280,9282],{},"部署极简，一个 ",[35,9281,9267],{}," 搞定",[530,9284,9285],{},"加载飞快，全是 HTML，没有服务端开销",[530,9287,9288],{},"无需后端，零运维成本",[675,9290,9292],{"id":9291},"局限","❌ 局限",[16,9294,9295],{},"随着时间的推移，纯静态的问题越来越明显：",[272,9297,9298,9307],{},[275,9299,9300],{},[278,9301,9302,9304],{},[281,9303,3826],{},[281,9305,9306],{},"具体表现",[291,9308,9309,9320,9331,9342,9353],{},[278,9310,9311,9317],{},[296,9312,9313,9314],{},"🐢 ",[23,9315,9316],{},"内容更新慢",[296,9318,9319],{},"改一篇文章要重新构建、重新部署，不能实时发布",[278,9321,9322,9328],{},[296,9323,9324,9325],{},"👤 ",[23,9326,9327],{},"没有用户体系",[296,9329,9330],{},"无法区分读者和管理员，所有内容对所有人一致可见",[278,9332,9333,9339],{},[296,9334,9335,9336],{},"🔒 ",[23,9337,9338],{},"评论受限",[296,9340,9341],{},"Giscus 需要 GitHub 账号才能评论，门槛高",[278,9343,9344,9350],{},[296,9345,9346,9347],{},"🚫 ",[23,9348,9349],{},"无法做动态功能",[296,9351,9352],{},"点赞、收藏、阅读统计、个性化推荐……统统做不了",[278,9354,9355,9361],{},[296,9356,9357,9358],{},"🔗 ",[23,9359,9360],{},"前端锁死",[296,9362,9363],{},"被 Nuxt\u002FVue 生态绑定，想换技术栈得重写",[11,9365,9367],{"id":9366},"第二代前后端分离react-go","⚡ 第二代：前后端分离（React + Go）",[675,9369,9230],{"id":9370},"️-架构图-1",[28,9372,9375],{"className":9373,"code":9374,"language":2169,"meta":33},[2167],"┌───────────────────────┐       ┌────────────────────┐\n│    静态托管 (Vercel)    │       │   Go 服务端         │\n│                       │       │                    │\n│  ┌─────────────────┐  │       │  ┌──────────────┐  │\n│  │   sublog-react   │  │  API  │  │  HTTP API    │  │\n│  │  (Next.js SSG)  │──┼───────┼─>│              │  │\n│  │                  │  │       │  │ 文章 \u002F 用户  │  │\n│  │  首页 \u002F 文章 \u002F   │  │       │  └──────────────┘  │\n│  │  用户中心        │  │       │                    │\n│  └─────────────────┘  │       │  ┌──────────────┐  │\n│                       │       │  │   数据库      │  │\n└───────────────────────┘       │  └──────────────┘  │\n                                └────────────────────┘\n",[35,9376,9374],{"__ignoreMap":33},[675,9378,9380],{"id":9379},"️-前端sublog-react","🖥️ 前端：sublog-react",[16,9382,9383,9384,9386,9387,9390],{},"选用 ",[23,9385,2196],{},"（App Router） + ",[23,9388,9389],{},"React 19","，理由是：",[527,9392,9393,9400,9407,9414],{},[530,9394,9395,9396,9399],{},"🎨 ",[23,9397,9398],{},"React 生态","——组件库成熟，开发效率高",[530,9401,9402,9403,9406],{},"📐 ",[23,9404,9405],{},"App Router","——支持布局嵌套，页面组织清晰",[530,9408,9409,9410,9413],{},"📦 ",[23,9411,9412],{},"静态导出","——仍可部署到静态托管",[530,9415,9416,9417,9420],{},"⚙️ ",[23,9418,9419],{},"运行时配置","——API 地址可动态注入，切换环境无需重新构建",[16,9422,9423],{},"前端不再直接读取 Markdown 文件，而是通过 HTTP API 获取文章数据。静态导出 + 客户端数据获取的组合，既保留了首屏加载速度，又实现了内容的动态更新。",[675,9425,9427],{"id":9426},"️-后端sublog-s","⚙️ 后端：sublog-s",[16,9429,9430,9431,2026,9433,9435],{},"后端使用 ",[23,9432,831],{},[23,9434,6242],{},"，按业务逻辑分层组织代码。",[16,9437,9438],{},"核心技术选型：",[272,9440,9441,9449],{},[275,9442,9443],{},[278,9444,9445,9447],{},[281,9446,2327],{},[281,9448,2330],{},[291,9450,9451,9461,9472],{},[278,9452,9453,9458],{},[296,9454,9455,9456],{},"🚀 ",[23,9457,6242],{},[296,9459,9460],{},"HTTP 框架",[278,9462,9463,9469],{},[296,9464,9465,9466],{},"🗄️ ",[23,9467,9468],{},"GORM",[296,9470,9471],{},"ORM，数据库操作",[278,9473,9474,9480],{},[296,9475,9476,9477],{},"🔐 ",[23,9478,9479],{},"JWT",[296,9481,9482],{},"用户认证",[11,9484,9486],{"id":9485},"前后对比","📊 前后对比",[272,9488,9489,9502],{},[275,9490,9491],{},[278,9492,9493,9496,9499],{},[281,9494,9495],{},"维度",[281,9497,9498],{},"第一代（纯静态）",[281,9500,9501],{},"第二代（前后端分离）",[291,9503,9504,9516,9528,9542,9555,9568,9582,9594],{},[278,9505,9506,9510,9513],{},[296,9507,9395,9508],{},[23,9509,2292],{},[296,9511,9512],{},"Nuxt 3 (Vue)",[296,9514,9515],{},"Next.js (React 19)",[278,9517,9518,9523,9525],{},[296,9519,9520,9521],{},"🖥️ ",[23,9522,6243],{},[296,9524,2235],{},[296,9526,9527],{},"Go + Gin",[278,9529,9530,9536,9539],{},[296,9531,9532,9533],{},"📄 ",[23,9534,9535],{},"内容来源",[296,9537,9538],{},"Markdown 文件",[296,9540,9541],{},"API 数据库",[278,9543,9544,9549,9552],{},[296,9545,9455,9546],{},[23,9547,9548],{},"发布方式",[296,9550,9551],{},"全量重新构建",[296,9553,9554],{},"API 实时发布",[278,9556,9557,9562,9565],{},[296,9558,9324,9559],{},[23,9560,9561],{},"用户系统",[296,9563,9564],{},"❌ 无",[296,9566,9567],{},"✅ 注册\u002F登录",[278,9569,9570,9576,9579],{},[296,9571,9572,9573],{},"💬 ",[23,9574,9575],{},"评论",[296,9577,9578],{},"Giscus（需 GitHub）",[296,9580,9581],{},"自有评论系统（规划中）",[278,9583,9584,9588,9591],{},[296,9585,9409,9586],{},[23,9587,2604],{},[296,9589,9590],{},"1 个静态目录",[296,9592,9593],{},"前端静态 + 后端二进制",[278,9595,9596,9602,9605],{},[296,9597,9598,9599],{},"⚡ ",[23,9600,9601],{},"更新体验",[296,9603,9604],{},"改文章 → 构建 → 部署",[296,9606,9607],{},"改文章 → 立即生效",[11,9609,9611],{"id":9610},"新功能注册账号","🎉 新功能：注册账号",[16,9613,9614,9615,9618],{},"伴随着架构升级，",[23,9616,9617],{},"用户账号系统","也上线了。",[675,9620,9622],{"id":9621},"目前支持","📋 目前支持",[16,9624,9625,9626,3993],{},"现在你可以在尼克的小窝 ",[23,9627,9628],{},"注册一个账号",[28,9630,9633],{"className":9631,"code":9632,"language":2169},[2167],"1. ✏️ 填写用户名、邮箱、密码\n2. 📧 查收激活邮件\n3. 🔗 点击链接激活账号\n4. 🚪 登录后查看和管理个人信息\n",[35,9634,9632],{"__ignoreMap":33},[11,9636,9638],{"id":9637},"️-后续计划","🗓️ 后续计划",[16,9640,9641],{},"正在开发或规划中的功能：",[527,9643,9645,9651,9657,9663,9669,9675,9681,9687],{"className":9644},[2637],[530,9646,9648,9650],{"className":9647},[2641],[2643,9649],{"checked":81,"disabled":81,"type":2645}," 用户注册\u002F登录（已上线 🎉）",[530,9652,9654,9656],{"className":9653},[2641],[2643,9655],{"checked":81,"disabled":81,"type":2645}," 个人资料编辑（已上线 🎉）",[530,9658,9660,9662],{"className":9659},[2641],[2643,9661],{"disabled":81,"type":2645}," 💬 站内评论系统",[530,9664,9666,9668],{"className":9665},[2641],[2643,9667],{"disabled":81,"type":2645}," 🔖 文章收藏",[530,9670,9672,9674],{"className":9671},[2641],[2643,9673],{"disabled":81,"type":2645}," 📚 阅读历史",[530,9676,9678,9680],{"className":9677},[2641],[2643,9679],{"disabled":81,"type":2645}," 👍 文章点赞",[530,9682,9684,9686],{"className":9683},[2641],[2643,9685],{"disabled":81,"type":2645}," 🔔 关注通知",[530,9688,9690,9692],{"className":9689},[2641],[2643,9691],{"disabled":81,"type":2645}," 🎨 个人首页定制",[11,9694,9696],{"id":9695},"结语","💡 结语",[16,9698,9699,9700,9703],{},"从 Nuxt 3 纯静态到 React + Go 前后端分离，SuBlog 的架构变迁本质上是一个",[23,9701,9702],{},"功能驱动","的过程。静态博客有它的位置——写写文章、展示内容完全够用。但当你想做的不仅仅是\"展示\"时，后端的能力就不可或缺了。",[16,9705,9706,9707,9710],{},"这次重构最大的感触是：",[23,9708,9709],{},"架构选型不是越复杂越好，而是刚好够用最好","。纯静态阶段用 Nuxt 3 一点问题没有，直到功能需求超出了静态的边界，才自然推动到前后端分离。",[16,9712,9713,9714],{},"如果你也在纠结\"要不要上后端\"，不妨先问问自己：",[23,9715,9716],{},"你要做的是\"展示内容\"还是\"提供服务\"？",[16,9718,9719],{},"前者，静态就够了；后者，来吧，后端欢迎你。😄",[2680,9721],{},[16,9723,9724],{},[23,9725,9726],{},"🎉 目前注册功能已经可以用了，后续更多好玩的功能正在路上。部分功能可能需要登录才能体验，快注册一个账号吧！",{"title":33,"searchDepth":48,"depth":78,"links":9728},[9729,9730,9736,9741,9742,9745,9746],{"id":9207,"depth":48,"text":9208},{"id":9225,"depth":48,"text":9226,"children":9731},[9732,9733,9734,9735],{"id":9229,"depth":78,"text":9230},{"id":9239,"depth":78,"text":9240},{"id":3532,"depth":78,"text":9274},{"id":9291,"depth":78,"text":9292},{"id":9366,"depth":48,"text":9367,"children":9737},[9738,9739,9740],{"id":9370,"depth":78,"text":9230},{"id":9379,"depth":78,"text":9380},{"id":9426,"depth":78,"text":9427},{"id":9485,"depth":48,"text":9486},{"id":9610,"depth":48,"text":9611,"children":9743},[9744],{"id":9621,"depth":78,"text":9622},{"id":9637,"depth":48,"text":9638},{"id":9695,"depth":48,"text":9696},"记录尼克的小窝从 Nuxt 3 纯静态博客演进到 React + Go 前后端分离架构的全过程，包括架构设计的思考、技术选型的变化，以及新增的账号注册功能。","\u002Fposts\u002Fsublog-evolution\u002Fimg\u002Fcover.jpg",{},"\u002Fposts\u002Fsublog-evolution","2026-07-23T12:00:00",{"title":9202,"description":9747},"尼克的小窝 开发记录","posts\u002Fsublog-evolution\u002Findex",[9756,9757,2196,831],"架构","React","2026-07-23","VMQ6A2K8GQ5t3DSlezWPd3KgXnmDVMn9QVeaI28Hcp8",{"id":9761,"title":9762,"author":6,"body":9763,"description":10926,"difficulty":2060,"draft":819,"extension":820,"image":10927,"meta":10928,"navigation":81,"path":10929,"pinned":819,"published":10930,"seo":10931,"series":6,"seriesOrder":6,"stem":10932,"tags":10933,"updated":10937,"__hash__":10938},"posts\u002Fposts\u002Fip-geolocation-guide\u002Findex.md","IP 属地是怎么查出来的？本地查询与在线查询方案对比",{"type":8,"value":9764,"toc":10902},[9765,9767,9773,9776,9787,9797,9801,9807,9820,9826,9837,9844,9847,9854,9858,9869,9873,9884,9887,9891,9897,9903,9906,9910,9917,9935,9938,9941,9945,9976,10015,10021,10025,10164,10200,10209,10213,10253,10258,10262,10273,10276,10310,10313,10317,10320,10324,10515,10519,10602,10606,10613,10720,10724,10733,10819,10822,10825,10861,10864,10867,10875,10896,10899],[11,9766,9207],{"id":9207},[16,9768,9769,9770],{},"很多网站会在「登录记录」「安全中心」里显示你的登录 IP 归属地，比如「福建·福州 · 中国移动」。你可能好奇过：",[23,9771,9772],{},"服务器是怎么根据一个 IP 就判断出我在哪个城市、哪个运营商的？",[16,9774,9775],{},"这篇科普文章讲清楚三件事：",[767,9777,9778,9781,9784],{},[530,9779,9780],{},"IP 属地到底查的是什么；",[530,9782,9783],{},"纯本地查询为什么能做到毫秒级且不联网；",[530,9785,9786],{},"几种主流查询方案的优缺点对比，方便你按场景选型。",[9788,9789,9790],"blockquote",{},[16,9791,9792,9793,9796],{},"说明：文中用到的 IP 均为 RFC 5737 保留的文档示例段（TEST-NET-2，",[35,9794,9795],{},"198.51.100.0\u002F24","），不会对应任何真实地址。",[11,9798,9800],{"id":9799},"先想清楚ip-属地查的是什么","先想清楚：IP 属地查的是什么",[16,9802,9803,9804,2743],{},"一个常见误区是「每个 IP 对应一个具体地址」。实际上 IP 属地的本质是 ",[23,9805,9806],{},"IP 段与归属地的映射",[16,9808,9809,9810,3993],{},"运营商和机构在申请 IP 时，是按",[23,9811,9812,9813,9819],{},"网段（CIDR）",[23,9814,9815,9816,9818],{},"成块分配的。比如 ",[35,9817,9795],{}," 这一个段，一整块都归属同一地区、同一运营商。所以归属地数据天然可以","按段存储",[28,9821,9824],{"className":9822,"code":9823,"language":2169,"meta":33},[2167],"IP 起始          IP 结束         归属地\n────────────────────────────────────────\n198.51.100.0     198.51.100.255  中国|福建|福州|移动\n198.51.101.0     198.51.101.255  中国|浙江|杭州|电信\n",[35,9825,9823],{"__ignoreMap":33},[16,9827,9828,9829,9832,9833,9836],{},"全球公网 IP 约有 42 亿个，但",[23,9830,9831],{},"网段只有几十万条","。正因为存的是「段」而不是「每个 IP」，完整的归属地数据文件才只有几 MB（ip2region 的 ",[35,9834,9835],{},".xdb"," 约 11MB）。这是「本地离线查询」可行的第一前提。",[16,9838,9839,9840,9843],{},"所以，",[23,9841,9842],{},"IP 属地从来不是精确到街道的定位","，它返回的是段级的「省｜市｜运营商」粗粒度信息。",[11,9845,9846],{"id":9846},"纯本地查询的原理",[16,9848,9849,9850,9853],{},"以开源的 ",[23,9851,9852],{},"ip2region"," 为例，它的查询完全在本地完成，不联网、不请求第三方，几十微秒到毫秒级返回。",[675,9855,9857],{"id":9856},"第一步把-ip-变成整数","第一步：把 IP 变成整数",[16,9859,9860,9861,9864,9865,9868],{},"以 ",[35,9862,9863],{},"198.51.100.42"," 为例，它会转成一个 32 位整数 ",[35,9866,9867],{},"0xC633642A","。IP 的四个数字本质就是 4 个字节，拼起来就是一个整数，这一步没有任何玄学。",[675,9870,9872],{"id":9871},"第二步二分查找","第二步：二分查找",[16,9874,9875,9876,9879,9880,9883],{},"在按起始 IP 排序的段列表里做",[23,9877,9878],{},"二分查找","，找到满足 ",[35,9881,9882],{},"段的起始IP ≤ 目标整数 ≤ 段的结束IP"," 的那一段。",[16,9885,9886],{},"二分查找的复杂度是 O(log N)，N 是几十万条，所以很快——这就是「不靠网络也能毫秒返回」的核心。",[675,9888,9890],{"id":9889},"第三步xdb-文件的两级结构","第三步：.xdb 文件的两级结构",[16,9892,9893,9894,9896],{},"ip2region 的 ",[35,9895,9835],{}," 不是简单顺序文件，内部分两个区，进一步加速：",[28,9898,9901],{"className":9899,"code":9900,"language":2169,"meta":33},[2167],"┌─────────────────────────────┐\n│  索引区 (Index)             │  \u002F\u002F 每段一条定长记录：startIP \u002F endIP \u002F 数据区指针 + 数据长度\n│  …… 二分快速定位段 ……       │\n├─────────────────────────────┤\n│  数据区 (Data)              │  \u002F\u002F 真正存放各段的地点字符串\n│  ……                         │\n└─────────────────────────────┘\n",[35,9902,9900],{"__ignoreMap":33},[16,9904,9905],{},"先二分索引区定位到「是哪一段」，再按指针去数据区读出地点字符串，一次磁盘读就能拿到结果。",[675,9907,9909],{"id":9908},"为什么离线数据不会很快过期","为什么「离线数据」不会很快过期",[16,9911,9912,9913,9916],{},"IP 归属取决于",[23,9914,9915],{},"注册机构和运营商的分配","，一个网段通常长期稳定属于某地区、某运营商。所以：",[527,9918,9919,9922,9932],{},[530,9920,9921],{},"下载一次数据文件，可以用很久；",[530,9923,9924,9925,9931],{},"归属偶尔变化时，",[23,9926,9927,9928,9930],{},"每月更新一次 ",[35,9929,9835],{}," 文件","即可（替换文件、不用重启服务，见下文实战）；",[530,9933,9934],{},"数据源来自 IANA\u002FAPNIC 等机构的分配表与公开归属信息，不是实时探测。",[11,9936,9937],{"id":9937},"主流查询方案对比",[16,9939,9940],{},"实际开发中，查询 IP 属地主要有三条路线。",[675,9942,9944],{"id":9943},"方案一ip2region纯本地离线推荐","方案一：ip2region（纯本地离线，推荐）",[28,9946,9948],{"className":4274,"code":9947,"language":976,"meta":33,"style":33},"\u002F\u002F Go 中的用法（github.com\u002Flionsoul2014\u002Fip2region\u002Fbinding\u002Fgolang）\nregion, err := searcher.SearchByStr(ip)\n\u002F\u002F 返回类似 \"中国|福建省|福州市|移动\"\n",[35,9949,9950,9955,9971],{"__ignoreMap":33},[38,9951,9952],{"class":40,"line":41},[38,9953,9954],{"class":44},"\u002F\u002F Go 中的用法（github.com\u002Flionsoul2014\u002Fip2region\u002Fbinding\u002Fgolang）\n",[38,9956,9957,9960,9962,9965,9968],{"class":40,"line":48},[38,9958,9959],{"class":881},"region, err ",[38,9961,4301],{"class":938},[38,9963,9964],{"class":881}," searcher.",[38,9966,9967],{"class":51},"SearchByStr",[38,9969,9970],{"class":881},"(ip)\n",[38,9972,9973],{"class":40,"line":78},[38,9974,9975],{"class":44},"\u002F\u002F 返回类似 \"中国|福建省|福州市|移动\"\n",[272,9977,9978,9986],{},[275,9979,9980],{},[278,9981,9982,9984],{},[281,9983,3532],{},[281,9985,3535],{},[291,9987,9988,9996,10004],{},[278,9989,9990,9993],{},[296,9991,9992],{},"免费、无网络依赖、无 QPS 限制",[296,9994,9995],{},"段级粒度，个别段可能显示到相邻地区",[278,9997,9998,10001],{},[296,9999,10000],{},"毫秒级查询，永不因外部服务挂掉",[296,10002,10003],{},"数据按月更新，最新分配的段可能短暂缺失",[278,10005,10006,10009],{},[296,10007,10008],{},"数据文件可随版本更新，不重启",[296,10010,10011,10012],{},"只返回省市区+运营商，无经纬度；",[23,10013,10014],{},"仅支持 IPv4",[16,10016,10017,10020],{},[23,10018,10019],{},"适用","：博客\u002F后台的「登录记录显示归属地」这类展示用途。",[675,10022,10024],{"id":10023},"方案二在线-apiip-api-高德-腾讯位置服务","方案二：在线 API（ip-api \u002F 高德 \u002F 腾讯位置服务）",[28,10026,10028],{"className":4274,"code":10027,"language":976,"meta":33,"style":33},"\u002F\u002F 以 ip-api.com 为例（免费版约 45 次\u002F分钟，非商用）\nctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)\ndefer cancel()\nreq, _ := http.NewRequestWithContext(ctx, \"GET\", \"https:\u002F\u002Fip-api.com\u002Fjson\u002F198.51.100.42\", nil)\nresp, err := http.DefaultClient.Do(req)\nif err != nil {\n    return \"未知\" \u002F\u002F 断网\u002F超时降级：展示「未知」而不是报错\n}\ndefer resp.Body.Close()\n\u002F\u002F 解析 JSON，status != \"success\" 时同样降级\n",[35,10029,10030,10035,10063,10073,10104,10120,10133,10143,10147,10159],{"__ignoreMap":33},[38,10031,10032],{"class":40,"line":41},[38,10033,10034],{"class":44},"\u002F\u002F 以 ip-api.com 为例（免费版约 45 次\u002F分钟，非商用）\n",[38,10036,10037,10040,10042,10044,10047,10050,10053,10056,10058,10060],{"class":40,"line":48},[38,10038,10039],{"class":881},"ctx, cancel ",[38,10041,4301],{"class":938},[38,10043,5774],{"class":881},[38,10045,10046],{"class":51},"WithTimeout",[38,10048,10049],{"class":881},"(context.",[38,10051,10052],{"class":51},"Background",[38,10054,10055],{"class":881},"(), ",[38,10057,483],{"class":65},[38,10059,4507],{"class":938},[38,10061,10062],{"class":881},"time.Second)\n",[38,10064,10065,10068,10071],{"class":40,"line":78},[38,10066,10067],{"class":938},"defer",[38,10069,10070],{"class":51}," cancel",[38,10072,3045],{"class":881},[38,10074,10075,10078,10080,10083,10086,10089,10092,10094,10097,10099,10102],{"class":40,"line":85},[38,10076,10077],{"class":881},"req, _ ",[38,10079,4301],{"class":938},[38,10081,10082],{"class":881}," http.",[38,10084,10085],{"class":51},"NewRequestWithContext",[38,10087,10088],{"class":881},"(ctx, ",[38,10090,10091],{"class":55},"\"GET\"",[38,10093,1598],{"class":881},[38,10095,10096],{"class":55},"\"https:\u002F\u002Fip-api.com\u002Fjson\u002F198.51.100.42\"",[38,10098,1598],{"class":881},[38,10100,10101],{"class":65},"nil",[38,10103,3160],{"class":881},[38,10105,10106,10109,10111,10114,10117],{"class":40,"line":91},[38,10107,10108],{"class":881},"resp, err ",[38,10110,4301],{"class":938},[38,10112,10113],{"class":881}," http.DefaultClient.",[38,10115,10116],{"class":51},"Do",[38,10118,10119],{"class":881},"(req)\n",[38,10121,10122,10125,10127,10129,10131],{"class":40,"line":114},[38,10123,10124],{"class":938},"if",[38,10126,4715],{"class":881},[38,10128,4718],{"class":938},[38,10130,4721],{"class":65},[38,10132,2492],{"class":881},[38,10134,10135,10137,10140],{"class":40,"line":119},[38,10136,4532],{"class":938},[38,10138,10139],{"class":55}," \"未知\"",[38,10141,10142],{"class":44}," \u002F\u002F 断网\u002F超时降级：展示「未知」而不是报错\n",[38,10144,10145],{"class":40,"line":125},[38,10146,2153],{"class":881},[38,10148,10149,10151,10154,10157],{"class":40,"line":151},[38,10150,10067],{"class":938},[38,10152,10153],{"class":881}," resp.Body.",[38,10155,10156],{"class":51},"Close",[38,10158,3045],{"class":881},[38,10160,10161],{"class":40,"line":156},[38,10162,10163],{"class":44},"\u002F\u002F 解析 JSON，status != \"success\" 时同样降级\n",[272,10165,10166,10174],{},[275,10167,10168],{},[278,10169,10170,10172],{},[281,10171,3532],{},[281,10173,3535],{},[291,10175,10176,10184,10192],{},[278,10177,10178,10181],{},[296,10179,10180],{},"数据最新、可能更精确",[296,10182,10183],{},"有频率限制（免费版限 QPS）",[278,10185,10186,10189],{},[296,10187,10188],{},"部分服务还返回经纬度\u002F时区",[296,10190,10191],{},"依赖第三方，断网\u002F被墙\u002F限流时拿不到",[278,10193,10194,10197],{},[296,10195,10196],{},"无需维护数据文件",[296,10198,10199],{},"每次查询都要发一次 HTTP 请求",[16,10201,10202,10204,10205,10208],{},[23,10203,10019],{},"：对精度要求高、且能接受外部依赖与限流的场景。",[23,10206,10207],{},"务必加超时和降级","，否则第三方一抖，你自己的页面就卡住。",[675,10210,10212],{"id":10211},"方案三maxmind-geolite2离线-mmdb","方案三：MaxMind GeoLite2（离线 .mmdb）",[272,10214,10215,10223],{},[275,10216,10217],{},[278,10218,10219,10221],{},[281,10220,3532],{},[281,10222,3535],{},[291,10224,10225,10233,10245],{},[278,10226,10227,10230],{},[296,10228,10229],{},"全球数据，覆盖广、精度较好",[296,10231,10232],{},"免费但需注册账号并遵守 EULA；商用需 GeoIP2 商业授权",[278,10234,10235,10242],{},[296,10236,10237,10238,10241],{},"标准 ",[35,10239,10240],{},".mmdb"," 格式，各语言都有库，支持 IPv6",[296,10243,10244],{},"国内 IP 的省市粒度有时不如 ip2region 细",[278,10246,10247,10250],{},[296,10248,10249],{},"离线查询",[296,10251,10252],{},"数据同样要定期更新",[16,10254,10255,10257],{},[23,10256,10019],{},"：面向全球用户的场景。",[11,10259,10261],{"id":10260},"ipv6本地离线方案的一个盲点","IPv6：本地离线方案的一个盲点",[16,10263,10264,10265,10268,10269,10272],{},"前面聊的都是 IPv4（32 位）。但家宽和移动网络越来越多地",[23,10266,10267],{},"只下发 IPv6","（128 位）地址，而 ",[23,10270,10271],{},"ip2region 目前只支持 IPv4","——拿到一个纯 IPv6 地址是查不出归属地的，本地库会返回空结果。",[16,10274,10275],{},"实际影响与常见应对：",[767,10277,10278,10288,10294,10304],{},[530,10279,10280,10283,10284,10287],{},[23,10281,10282],{},"判断版本","：Go 里 ",[35,10285,10286],{},"net.ParseIP(ip).To4() != nil"," 就是 IPv4，先判断再查询；",[530,10289,10290,10293],{},[23,10291,10292],{},"降级展示","：IPv6 直接显示「IPv6 地址」，不显示属地，诚实第一；",[530,10295,10296,10299,10300,10303],{},[23,10297,10298],{},"优先取 IPv4","：有反向代理时，从 ",[35,10301,10302],{},"X-Forwarded-For"," 里挑一个 IPv4 来查；",[530,10305,10306,10309],{},[23,10307,10308],{},"需要 v6 归属","：换支持 IPv6 的数据源（如 MaxMind GeoLite2）。",[16,10311,10312],{},"这一点在接入时很容易漏掉，上线前记得拿一个纯 IPv6 地址回归一遍。",[11,10314,10316],{"id":10315},"实战给登录记录加-ip-属地","实战：给登录记录加 IP 属地",[16,10318,10319],{},"subLog 博客的登录记录就用了 ip2region。完整接入一共四步。",[675,10321,10323],{"id":10322},"_1-进程启动时加载数据文件","1. 进程启动时加载数据文件",[28,10325,10327],{"className":4274,"code":10326,"language":976,"meta":33,"style":33},"import (\n    \"sync\u002Fatomic\"\n    \"github.com\u002Flionsoul2014\u002Fip2region\u002Fbinding\u002Fgolang\u002Fxdb\"\n)\n\n\u002F\u002F 用 atomic.Pointer 持有查询器，方便后面热更新\nvar searcher atomic.Pointer[xdb.Searcher]\n\nfunc initSearcher() error {\n    content, err := os.ReadFile(\".\u002Fdata\u002Fip2region.xdb\") \u002F\u002F 约 11MB，一次性读入内存\n    if err != nil {\n        return err\n    }\n    s, err := xdb.NewWithBuffer(content)\n    if err != nil {\n        return err\n    }\n    searcher.Store(s)\n    return nil\n}\n",[35,10328,10329,10335,10344,10353,10357,10361,10366,10393,10397,10410,10432,10444,10451,10455,10471,10483,10489,10493,10504,10511],{"__ignoreMap":33},[38,10330,10331,10333],{"class":40,"line":41},[38,10332,4282],{"class":938},[38,10334,5680],{"class":881},[38,10336,10337,10339,10342],{"class":40,"line":48},[38,10338,6882],{"class":55},[38,10340,10341],{"class":51},"sync\u002Fatomic",[38,10343,1081],{"class":55},[38,10345,10346,10348,10351],{"class":40,"line":78},[38,10347,6882],{"class":55},[38,10349,10350],{"class":51},"github.com\u002Flionsoul2014\u002Fip2region\u002Fbinding\u002Fgolang\u002Fxdb",[38,10352,1081],{"class":55},[38,10354,10355],{"class":40,"line":85},[38,10356,3160],{"class":881},[38,10358,10359],{"class":40,"line":91},[38,10360,82],{"emptyLinePlaceholder":81},[38,10362,10363],{"class":40,"line":114},[38,10364,10365],{"class":44},"\u002F\u002F 用 atomic.Pointer 持有查询器，方便后面热更新\n",[38,10367,10368,10370,10373,10376,10378,10381,10383,10386,10388,10391],{"class":40,"line":119},[38,10369,6963],{"class":938},[38,10371,10372],{"class":881}," searcher ",[38,10374,10375],{"class":51},"atomic",[38,10377,2973],{"class":881},[38,10379,10380],{"class":51},"Pointer",[38,10382,3660],{"class":881},[38,10384,10385],{"class":51},"xdb",[38,10387,2973],{"class":881},[38,10389,10390],{"class":51},"Searcher",[38,10392,1301],{"class":881},[38,10394,10395],{"class":40,"line":125},[38,10396,82],{"emptyLinePlaceholder":81},[38,10398,10399,10401,10404,10406,10408],{"class":40,"line":151},[38,10400,4475],{"class":938},[38,10402,10403],{"class":51}," initSearcher",[38,10405,5723],{"class":881},[38,10407,4520],{"class":938},[38,10409,2492],{"class":881},[38,10411,10412,10415,10417,10420,10422,10424,10427,10429],{"class":40,"line":156},[38,10413,10414],{"class":881},"    content, err ",[38,10416,4301],{"class":938},[38,10418,10419],{"class":881}," os.",[38,10421,7102],{"class":51},[38,10423,2851],{"class":881},[38,10425,10426],{"class":55},"\".\u002Fdata\u002Fip2region.xdb\"",[38,10428,3314],{"class":881},[38,10430,10431],{"class":44},"\u002F\u002F 约 11MB，一次性读入内存\n",[38,10433,10434,10436,10438,10440,10442],{"class":40,"line":162},[38,10435,2928],{"class":938},[38,10437,4715],{"class":881},[38,10439,4718],{"class":938},[38,10441,4721],{"class":65},[38,10443,2492],{"class":881},[38,10445,10446,10448],{"class":40,"line":1017},[38,10447,4728],{"class":938},[38,10449,10450],{"class":881}," err\n",[38,10452,10453],{"class":40,"line":1022},[38,10454,2811],{"class":881},[38,10456,10457,10460,10462,10465,10468],{"class":40,"line":1028},[38,10458,10459],{"class":881},"    s, err ",[38,10461,4301],{"class":938},[38,10463,10464],{"class":881}," xdb.",[38,10466,10467],{"class":51},"NewWithBuffer",[38,10469,10470],{"class":881},"(content)\n",[38,10472,10473,10475,10477,10479,10481],{"class":40,"line":1039},[38,10474,2928],{"class":938},[38,10476,4715],{"class":881},[38,10478,4718],{"class":938},[38,10480,4721],{"class":65},[38,10482,2492],{"class":881},[38,10484,10485,10487],{"class":40,"line":1050},[38,10486,4728],{"class":938},[38,10488,10450],{"class":881},[38,10490,10491],{"class":40,"line":1055},[38,10492,2811],{"class":881},[38,10494,10495,10498,10501],{"class":40,"line":1064},[38,10496,10497],{"class":881},"    searcher.",[38,10499,10500],{"class":51},"Store",[38,10502,10503],{"class":881},"(s)\n",[38,10505,10506,10508],{"class":40,"line":1069},[38,10507,4532],{"class":938},[38,10509,10510],{"class":65}," nil\n",[38,10512,10513],{"class":40,"line":1276},[38,10514,2153],{"class":881},[675,10516,10518],{"id":10517},"_2-查询一次","2. 查询一次",[28,10520,10522],{"className":4274,"code":10521,"language":976,"meta":33,"style":33},"func ipRegion(ip string) string {\n    region, err := searcher.Load().SearchByStr(ip)\n    if err != nil {\n        return \"未知\"\n    }\n    return region \u002F\u002F \"中国|福建省|福州市|移动\"\n}\n",[35,10523,10524,10546,10565,10577,10584,10588,10598],{"__ignoreMap":33},[38,10525,10526,10528,10531,10533,10536,10539,10541,10544],{"class":40,"line":41},[38,10527,4475],{"class":938},[38,10529,10530],{"class":51}," ipRegion",[38,10532,2851],{"class":881},[38,10534,10535],{"class":2854},"ip",[38,10537,10538],{"class":938}," string",[38,10540,3314],{"class":881},[38,10542,10543],{"class":938},"string",[38,10545,2492],{"class":881},[38,10547,10548,10551,10553,10555,10558,10561,10563],{"class":40,"line":48},[38,10549,10550],{"class":881},"    region, err ",[38,10552,4301],{"class":938},[38,10554,9964],{"class":881},[38,10556,10557],{"class":51},"Load",[38,10559,10560],{"class":881},"().",[38,10562,9967],{"class":51},[38,10564,9970],{"class":881},[38,10566,10567,10569,10571,10573,10575],{"class":40,"line":78},[38,10568,2928],{"class":938},[38,10570,4715],{"class":881},[38,10572,4718],{"class":938},[38,10574,4721],{"class":65},[38,10576,2492],{"class":881},[38,10578,10579,10581],{"class":40,"line":85},[38,10580,4728],{"class":938},[38,10582,10583],{"class":55}," \"未知\"\n",[38,10585,10586],{"class":40,"line":91},[38,10587,2811],{"class":881},[38,10589,10590,10592,10595],{"class":40,"line":114},[38,10591,4532],{"class":938},[38,10593,10594],{"class":881}," region ",[38,10596,10597],{"class":44},"\u002F\u002F \"中国|福建省|福州市|移动\"\n",[38,10599,10600],{"class":40,"line":119},[38,10601,2153],{"class":881},[675,10603,10605],{"id":10604},"_3-加一层内存缓存","3. 加一层内存缓存",[16,10607,10608,10609,10612],{},"登录记录里同一个 IP 可能反复出现，用 ",[35,10610,10611],{},"sync.Map"," 按 IP 缓存结果，一次查询终身复用：",[28,10614,10616],{"className":4274,"code":10615,"language":976,"meta":33,"style":33},"var regionCache sync.Map\n\nfunc ipRegionCached(ip string) string {\n    if v, ok := regionCache.Load(ip); ok {\n        return v.(string)\n    }\n    region := ipRegion(ip)\n    regionCache.Store(ip, region)\n    return region\n}\n",[35,10617,10618,10633,10637,10656,10673,10684,10688,10699,10709,10716],{"__ignoreMap":33},[38,10619,10620,10622,10625,10628,10630],{"class":40,"line":41},[38,10621,6963],{"class":938},[38,10623,10624],{"class":881}," regionCache ",[38,10626,10627],{"class":51},"sync",[38,10629,2973],{"class":881},[38,10631,10632],{"class":51},"Map\n",[38,10634,10635],{"class":40,"line":48},[38,10636,82],{"emptyLinePlaceholder":81},[38,10638,10639,10641,10644,10646,10648,10650,10652,10654],{"class":40,"line":78},[38,10640,4475],{"class":938},[38,10642,10643],{"class":51}," ipRegionCached",[38,10645,2851],{"class":881},[38,10647,10535],{"class":2854},[38,10649,10538],{"class":938},[38,10651,3314],{"class":881},[38,10653,10543],{"class":938},[38,10655,2492],{"class":881},[38,10657,10658,10660,10663,10665,10668,10670],{"class":40,"line":85},[38,10659,2928],{"class":938},[38,10661,10662],{"class":881}," v, ok ",[38,10664,4301],{"class":938},[38,10666,10667],{"class":881}," regionCache.",[38,10669,10557],{"class":51},[38,10671,10672],{"class":881},"(ip); ok {\n",[38,10674,10675,10677,10680,10682],{"class":40,"line":91},[38,10676,4728],{"class":938},[38,10678,10679],{"class":881}," v.(",[38,10681,10543],{"class":938},[38,10683,3160],{"class":881},[38,10685,10686],{"class":40,"line":114},[38,10687,2811],{"class":881},[38,10689,10690,10693,10695,10697],{"class":40,"line":119},[38,10691,10692],{"class":881},"    region ",[38,10694,4301],{"class":938},[38,10696,10530],{"class":51},[38,10698,9970],{"class":881},[38,10700,10701,10704,10706],{"class":40,"line":125},[38,10702,10703],{"class":881},"    regionCache.",[38,10705,10500],{"class":51},[38,10707,10708],{"class":881},"(ip, region)\n",[38,10710,10711,10713],{"class":40,"line":151},[38,10712,4532],{"class":938},[38,10714,10715],{"class":881}," region\n",[38,10717,10718],{"class":40,"line":156},[38,10719,2153],{"class":881},[675,10721,10723],{"id":10722},"_4-数据热更新不用重启","4. 数据热更新（不用重启）",[16,10725,10726,10727,10729,10730,10732],{},"ip2region 每月发布新数据。下载新文件后重新 ",[35,10728,10467],{}," 并 ",[35,10731,10500],{}," 覆盖即可，进程完全不用重启：",[28,10734,10736],{"className":4274,"code":10735,"language":976,"meta":33,"style":33},"func reloadSearcher(newContent []byte) error {\n    s, err := xdb.NewWithBuffer(newContent)\n    if err != nil {\n        return err\n    }\n    searcher.Store(s) \u002F\u002F 原子切换，老查询继续走旧数据，新查询走新数据\n    return nil\n}\n",[35,10737,10738,10762,10775,10787,10793,10797,10809,10815],{"__ignoreMap":33},[38,10739,10740,10742,10745,10747,10750,10753,10756,10758,10760],{"class":40,"line":41},[38,10741,4475],{"class":938},[38,10743,10744],{"class":51}," reloadSearcher",[38,10746,2851],{"class":881},[38,10748,10749],{"class":2854},"newContent",[38,10751,10752],{"class":881}," []",[38,10754,10755],{"class":938},"byte",[38,10757,3314],{"class":881},[38,10759,4520],{"class":938},[38,10761,2492],{"class":881},[38,10763,10764,10766,10768,10770,10772],{"class":40,"line":48},[38,10765,10459],{"class":881},[38,10767,4301],{"class":938},[38,10769,10464],{"class":881},[38,10771,10467],{"class":51},[38,10773,10774],{"class":881},"(newContent)\n",[38,10776,10777,10779,10781,10783,10785],{"class":40,"line":78},[38,10778,2928],{"class":938},[38,10780,4715],{"class":881},[38,10782,4718],{"class":938},[38,10784,4721],{"class":65},[38,10786,2492],{"class":881},[38,10788,10789,10791],{"class":40,"line":85},[38,10790,4728],{"class":938},[38,10792,10450],{"class":881},[38,10794,10795],{"class":40,"line":91},[38,10796,2811],{"class":881},[38,10798,10799,10801,10803,10806],{"class":40,"line":114},[38,10800,10497],{"class":881},[38,10802,10500],{"class":51},[38,10804,10805],{"class":881},"(s) ",[38,10807,10808],{"class":44},"\u002F\u002F 原子切换，老查询继续走旧数据，新查询走新数据\n",[38,10810,10811,10813],{"class":40,"line":119},[38,10812,4532],{"class":938},[38,10814,10510],{"class":65},[38,10816,10817],{"class":40,"line":125},[38,10818,2153],{"class":881},[11,10820,10821],{"id":10821},"局限与诚实提醒",[16,10823,10824],{},"无论用哪种方案，都要清楚 IP 属地的边界：",[527,10826,10827,10833,10839,10845,10851],{},[530,10828,10829,10832],{},[23,10830,10831],{},"段级精度","：网段归属是「绝大多数」准确，个别 IP（大型企业自建段、跨省调度段）可能显示到相邻地区或只到省。",[530,10834,10835,10838],{},[23,10836,10837],{},"运营商级，不是定位","：拿到的是「省｜市｜运营商」，不是精确街道或基站位置——精确位置需要 GPS 或运营商三角定位，纯 IP 归属做不到。",[530,10840,10841,10844],{},[23,10842,10843],{},"动态 IP","：家宽通常是动态 IP，重拨会变，所以「上次登录在福州，这次在广州」不一定是异常，可能是 IP 变了。",[530,10846,10847,10850],{},[23,10848,10849],{},"别用于安全判定","：用 IP 段做封禁或风控判定风险自负，段级数据不适合当精确依据。",[530,10852,10853,10856,10857,10860],{},[23,10854,10855],{},"隐私与合规","：IP 地址属于个人信息（《个人信息保护法》）。归属地展示建议只出现在",[23,10858,10859],{},"用户本人可见","的页面（登录记录、个人中心）；若在评论区等公开场合展示，建议只显示到省市并明确告知用户。",[11,10862,10863],{"id":10863},"小结",[16,10865,10866],{},"一句话总结：",[9788,10868,10869],{},[16,10870,10871,10874],{},[23,10872,10873],{},"IP 属地 = 几十万条「IP 段 → 归属地」映射 + 一次二分查找。"," 它查的是网段归属，不是精确位置。",[527,10876,10877,10883,10890],{},[530,10878,10879,10880,10882],{},"想省心、免费、稳定，用 ",[23,10881,9852],{}," 本地离线查询（注意 IPv6 盲点）；",[530,10884,10885,10886,10889],{},"想要最新最细、能接受外部依赖，用",[23,10887,10888],{},"在线 API","（记得超时降级）；",[530,10891,10892,10893,2743],{},"面向全球用户、要 IPv6 覆盖，用 ",[23,10894,10895],{},"MaxMind GeoLite2",[16,10897,10898],{},"对个人博客「登录记录显示归属地」这种场景，本地离线方案通常是性价比最高的选择。",[801,10900,10901],{},"html pre.shiki code .sHbNN, html code.shiki .sHbNN{--shiki-light:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .sIX_F, html code.shiki .sIX_F{--shiki-light:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .siTax, html code.shiki .siTax{--shiki-light:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sw2iP, html code.shiki .sw2iP{--shiki-light:#6F42C1;--shiki-dark:#B392F0}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .suQ91, html code.shiki .suQ91{--shiki-light:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .scXbn, html code.shiki .scXbn{--shiki-light:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sPK6S, html code.shiki .sPK6S{--shiki-light:#E36209;--shiki-dark:#FFAB70}",{"title":33,"searchDepth":48,"depth":78,"links":10903},[10904,10905,10906,10912,10917,10918,10924,10925],{"id":9207,"depth":48,"text":9207},{"id":9799,"depth":48,"text":9800},{"id":9846,"depth":48,"text":9846,"children":10907},[10908,10909,10910,10911],{"id":9856,"depth":78,"text":9857},{"id":9871,"depth":78,"text":9872},{"id":9889,"depth":78,"text":9890},{"id":9908,"depth":78,"text":9909},{"id":9937,"depth":48,"text":9937,"children":10913},[10914,10915,10916],{"id":9943,"depth":78,"text":9944},{"id":10023,"depth":78,"text":10024},{"id":10211,"depth":78,"text":10212},{"id":10260,"depth":48,"text":10261},{"id":10315,"depth":48,"text":10316,"children":10919},[10920,10921,10922,10923],{"id":10322,"depth":78,"text":10323},{"id":10517,"depth":78,"text":10518},{"id":10604,"depth":78,"text":10605},{"id":10722,"depth":78,"text":10723},{"id":10821,"depth":48,"text":10821},{"id":10863,"depth":48,"text":10863},"科普 IP 属地查询的原理：IP 段与归属地的映射、纯本地离线查询为何能做到毫秒级，对比 ip2region、在线 API 与 MaxMind 三种方案的优劣，并附登录记录场景的 Go 实战与 IPv6 盲点提醒。","\u002Fposts\u002Fip-geolocation-guide\u002Fimg\u002Fcover.svg",{},"\u002Fposts\u002Fip-geolocation-guide","2026-08-07T12:00:00",{"title":9762,"description":10926},"posts\u002Fip-geolocation-guide\u002Findex",[10934,10935,10936],"IP","网络","科普","2026-08-16","RY1UPgJaiKsjpC51uqMyG7w_GpCRS13_CX5j1hDD_dM",{"id":10940,"title":10941,"author":6,"body":10942,"description":12464,"difficulty":1638,"draft":819,"extension":820,"image":12465,"meta":12466,"navigation":81,"path":12467,"pinned":819,"published":12468,"seo":12469,"series":6238,"seriesOrder":78,"stem":12470,"tags":12471,"updated":10937,"__hash__":12474},"posts\u002Fposts\u002Fgo-embed-react-single-binary\u002Findex.md","Go 单文件打包进阶：React 前端 + 交叉编译，一次踩完所有坑",{"type":8,"value":10943,"toc":12443},[10944,10946,10957,10968,10975,10978,11014,11016,11025,11027,11031,11041,11085,11127,11145,11149,11170,11201,11205,11497,11500,11538,11542,11885,11888,11892,12089,12099,12101,12112,12126,12139,12150,12154,12161,12177,12190,12195,12199,12215,12220,12265,12279,12290,12294,12309,12318,12340,12349,12353,12363,12365,12392,12401,12403,12406,12433,12440],[11,10945,2719],{"id":2719},[16,10947,10948,10949,10952,10953,10956],{},"这是「Go 单文件打包」系列第三篇。上一篇（",[2672,10950,10951],{"href":7781},"把前端塞进二进制","）记录了一个 Gin + Vue 3 项目的 embed 方案。这次换了个项目继续踩坑：",[23,10954,10955],{},"Magic Img","——一个自托管、本地优先的 AI 生图提示词管理工具，技术栈是：",[527,10958,10959,10962],{},[530,10960,10961],{},"前端：React 19 + TypeScript + Vite + Tailwind v4（UI 库 Magic UI + shadcn\u002Fui）",[530,10963,10964,10965,579],{},"后端：Go + Gin + GORM + SQLite（",[23,10966,10967],{},"modernc 纯 Go 驱动，无 cgo",[16,10969,10970,10971,10974],{},"目标一样：",[23,10972,10973],{},"一个二进制拖到任何机器上就能跑","——前端、API、数据库全部内嵌。",[16,10976,10977],{},"和上篇不同的地方有三个，正好是这篇要讲的重点：",[767,10979,10980,10994,11007],{},[530,10981,10982,10983,10989,10990,10993],{},"前端用了 ",[23,10984,10985,10986],{},"HashRouter + Vite base ",[35,10987,10988],{},"\u002Fdist","，服务端",[23,10991,10992],{},"不再需要 SPA fallback","（上篇坑一的新解法）；",[530,10995,10996,10997,11003,11004,11006],{},"embed 用 ",[23,10998,10999,11002],{},[35,11000,11001],{},"all:"," 前缀","一劳永逸解决 ",[35,11005,7475],{}," 开头文件被排除的问题（上篇坑二的升级解法）；",[530,11008,11009,11010,11013],{},"需要",[23,11011,11012],{},"在 Windows 上交叉编译 Linux 产物","，踩了「生成的文件无法在 Linux 执行」的坑。",[11,11015,6831],{"id":6831},[16,11017,11018,11019,11021,11022,11024],{},"和上篇一致：",[35,11020,6837],{}," 在编译期把前端 ",[35,11023,6841],{}," 塞进二进制，运行时由 Go 直接提供静态服务。差异在于路由策略和缓存策略，后面细说。",[11,11026,6845],{"id":6845},[675,11028,11030],{"id":11029},"_1-前端hash-路由-base-dist","1. 前端：hash 路由 + base \u002Fdist",[16,11032,11033,11034,11037,11038,3993],{},"vite.config.ts 里固定 ",[35,11035,11036],{},"base: \"\u002Fdist\u002F\"","，路由用 ",[35,11039,11040],{},"HashRouter",[28,11042,11044],{"className":7526,"code":11043,"language":7528,"meta":33,"style":33},"\u002F\u002F vite.config.ts\nexport default defineConfig({\n  base: \"\u002Fdist\u002F\",   \u002F\u002F 所有资源路径以 \u002Fdist\u002F 开头\n  ...\n})\n",[35,11045,11046,11050,11062,11076,11081],{"__ignoreMap":33},[38,11047,11048],{"class":40,"line":41},[38,11049,7535],{"class":44},[38,11051,11052,11054,11056,11059],{"class":40,"line":48},[38,11053,939],{"class":938},[38,11055,3593],{"class":938},[38,11057,11058],{"class":51}," defineConfig",[38,11060,11061],{"class":881},"({\n",[38,11063,11064,11067,11070,11073],{"class":40,"line":78},[38,11065,11066],{"class":881},"  base: ",[38,11068,11069],{"class":55},"\"\u002Fdist\u002F\"",[38,11071,11072],{"class":881},",   ",[38,11074,11075],{"class":44},"\u002F\u002F 所有资源路径以 \u002Fdist\u002F 开头\n",[38,11077,11078],{"class":40,"line":85},[38,11079,11080],{"class":938},"  ...\n",[38,11082,11083],{"class":40,"line":91},[38,11084,3398],{"class":881},[28,11086,11090],{"className":11087,"code":11088,"language":11089,"meta":33,"style":33},"language-tsx shiki shiki-themes github-light github-dark","\u002F\u002F App.tsx —— HashRouter：路径信息全在 location.hash 里\n\u003CHashRouter>\n  \u003CRoutes>...\u003C\u002FRoutes>\n\u003C\u002FHashRouter>\n","tsx",[35,11091,11092,11097,11105,11119],{"__ignoreMap":33},[38,11093,11094],{"class":40,"line":41},[38,11095,11096],{"class":44},"\u002F\u002F App.tsx —— HashRouter：路径信息全在 location.hash 里\n",[38,11098,11099,11101,11103],{"class":40,"line":48},[38,11100,1777],{"class":881},[38,11102,11040],{"class":65},[38,11104,1435],{"class":881},[38,11106,11107,11109,11112,11115,11117],{"class":40,"line":78},[38,11108,3425],{"class":881},[38,11110,11111],{"class":65},"Routes",[38,11113,11114],{"class":881},">...\u003C\u002F",[38,11116,11111],{"class":65},[38,11118,1435],{"class":881},[38,11120,11121,11123,11125],{"class":40,"line":85},[38,11122,3403],{"class":881},[38,11124,11040],{"class":65},[38,11126,1435],{"class":881},[16,11128,11129,11132,11133,11136,11137,11140,11141,11144],{},[23,11130,11131],{},"为什么这样选","：history 路由需要服务端把未知路径全部 fallback 回 ",[35,11134,11135],{},"index.html","（上篇坑一）。hash 路由下浏览器请求的永远是 ",[35,11138,11139],{},"\u002Fdist\u002F"," 这一个入口，",[23,11142,11143],{},"静态服务直接返回 index.html 就完事，一个 fallback 都不用写","。内嵌场景里这是最省心的组合。",[675,11146,11148],{"id":11147},"_2-构建产物复制进后端目录","2. 构建产物复制进后端目录",[16,11150,11151,11154,11155,11157,11158,11161,11162,11165,11166,11169],{},[35,11152,11153],{},"npm run build"," 除了编译，还会把 ",[35,11156,6841],{}," 复制到 ",[35,11159,11160],{},"backend\u002Fweb\u002Fdist","（一个小脚本 ",[35,11163,11164],{},"scripts\u002Fcopy-dist.mjs"," 干这个活），供 ",[35,11167,11168],{},"go:embed"," 使用：",[28,11171,11173],{"className":6066,"code":11172,"language":6068,"meta":33,"style":33},"\u002F\u002F frontend\u002Fpackage.json\n\"scripts\": {\n  \"build\": \"tsc -b && vite build && node scripts\u002Fcopy-dist.mjs\"\n}\n",[35,11174,11175,11180,11187,11197],{"__ignoreMap":33},[38,11176,11177],{"class":40,"line":41},[38,11178,11179],{"class":44},"\u002F\u002F frontend\u002Fpackage.json\n",[38,11181,11182,11185],{"class":40,"line":48},[38,11183,11184],{"class":55},"\"scripts\"",[38,11186,2771],{"class":881},[38,11188,11189,11192,11194],{"class":40,"line":78},[38,11190,11191],{"class":65},"  \"build\"",[38,11193,2556],{"class":881},[38,11195,11196],{"class":55},"\"tsc -b && vite build && node scripts\u002Fcopy-dist.mjs\"\n",[38,11198,11199],{"class":40,"line":85},[38,11200,2153],{"class":881},[675,11202,11204],{"id":11203},"_3-后端embed-httpfilesystem-包装","3. 后端：embed + http.FileSystem 包装",[28,11206,11208],{"className":4274,"code":11207,"language":976,"meta":33,"style":33},"\u002F\u002F backend\u002Fweb\u002Fembed.go\npackage web\n\nimport (\n    \"embed\"\n    \"net\u002Fhttp\"\n    \"strings\"\n)\n\n\u002F\u002Fgo:embed all:dist\nvar distFS embed.FS\n\n\u002F\u002F Dist 内嵌前端静态文件系统\nvar Dist http.FileSystem = &fileSystem{prefix: \"dist\", fs: &distFS}\n\ntype fileSystem struct {\n    prefix string\n    fs     *embed.FS\n}\n\nfunc (f *fileSystem) Open(name string) (http.File, error) {\n    if name == \"\u002F\" {\n        name = f.prefix\n    } else {\n        name = f.prefix + \"\u002F\" + strings.TrimPrefix(name, \"\u002F\")\n    }\n    return http.FS(f.fs).Open(name)\n}\n",[35,11209,11210,11215,11221,11225,11231,11239,11247,11255,11259,11263,11268,11281,11285,11290,11327,11331,11343,11351,11364,11368,11372,11411,11425,11435,11445,11472,11476,11493],{"__ignoreMap":33},[38,11211,11212],{"class":40,"line":41},[38,11213,11214],{"class":44},"\u002F\u002F backend\u002Fweb\u002Fembed.go\n",[38,11216,11217,11219],{"class":40,"line":48},[38,11218,6864],{"class":938},[38,11220,6867],{"class":51},[38,11222,11223],{"class":40,"line":78},[38,11224,82],{"emptyLinePlaceholder":81},[38,11226,11227,11229],{"class":40,"line":85},[38,11228,4282],{"class":938},[38,11230,5680],{"class":881},[38,11232,11233,11235,11237],{"class":40,"line":91},[38,11234,6882],{"class":55},[38,11236,6885],{"class":51},[38,11238,1081],{"class":55},[38,11240,11241,11243,11245],{"class":40,"line":114},[38,11242,6882],{"class":55},[38,11244,6912],{"class":51},[38,11246,1081],{"class":55},[38,11248,11249,11251,11253],{"class":40,"line":119},[38,11250,6882],{"class":55},[38,11252,6930],{"class":51},[38,11254,1081],{"class":55},[38,11256,11257],{"class":40,"line":125},[38,11258,3160],{"class":881},[38,11260,11261],{"class":40,"line":151},[38,11262,82],{"emptyLinePlaceholder":81},[38,11264,11265],{"class":40,"line":156},[38,11266,11267],{"class":44},"\u002F\u002Fgo:embed all:dist\n",[38,11269,11270,11272,11275,11277,11279],{"class":40,"line":162},[38,11271,6963],{"class":938},[38,11273,11274],{"class":881}," distFS ",[38,11276,6885],{"class":51},[38,11278,2973],{"class":881},[38,11280,6973],{"class":51},[38,11282,11283],{"class":40,"line":1017},[38,11284,82],{"emptyLinePlaceholder":81},[38,11286,11287],{"class":40,"line":1022},[38,11288,11289],{"class":44},"\u002F\u002F Dist 内嵌前端静态文件系统\n",[38,11291,11292,11294,11297,11300,11302,11305,11307,11310,11313,11316,11318,11321,11324],{"class":40,"line":1028},[38,11293,6963],{"class":938},[38,11295,11296],{"class":881}," Dist ",[38,11298,11299],{"class":51},"http",[38,11301,2973],{"class":881},[38,11303,11304],{"class":51},"FileSystem",[38,11306,2489],{"class":938},[38,11308,11309],{"class":938}," &",[38,11311,11312],{"class":51},"fileSystem",[38,11314,11315],{"class":881},"{prefix: ",[38,11317,7013],{"class":55},[38,11319,11320],{"class":881},", fs: ",[38,11322,11323],{"class":938},"&",[38,11325,11326],{"class":881},"distFS}\n",[38,11328,11329],{"class":40,"line":1039},[38,11330,82],{"emptyLinePlaceholder":81},[38,11332,11333,11335,11338,11341],{"class":40,"line":1050},[38,11334,5667],{"class":938},[38,11336,11337],{"class":51}," fileSystem",[38,11339,11340],{"class":938}," struct",[38,11342,2492],{"class":881},[38,11344,11345,11348],{"class":40,"line":1055},[38,11346,11347],{"class":881},"    prefix ",[38,11349,11350],{"class":938},"string\n",[38,11352,11353,11356,11358,11360,11362],{"class":40,"line":1064},[38,11354,11355],{"class":881},"    fs     ",[38,11357,4507],{"class":938},[38,11359,6885],{"class":51},[38,11361,2973],{"class":881},[38,11363,6973],{"class":51},[38,11365,11366],{"class":40,"line":1069},[38,11367,2153],{"class":881},[38,11369,11370],{"class":40,"line":1276},[38,11371,82],{"emptyLinePlaceholder":81},[38,11373,11374,11376,11378,11381,11383,11385,11387,11390,11392,11394,11396,11398,11400,11402,11405,11407,11409],{"class":40,"line":1284},[38,11375,4475],{"class":938},[38,11377,2893],{"class":881},[38,11379,11380],{"class":2854},"f ",[38,11382,4507],{"class":938},[38,11384,11312],{"class":51},[38,11386,3314],{"class":881},[38,11388,11389],{"class":51},"Open",[38,11391,2851],{"class":881},[38,11393,7579],{"class":2854},[38,11395,10538],{"class":938},[38,11397,4504],{"class":881},[38,11399,11299],{"class":51},[38,11401,2973],{"class":881},[38,11403,11404],{"class":51},"File",[38,11406,1598],{"class":881},[38,11408,4520],{"class":938},[38,11410,4102],{"class":881},[38,11412,11413,11415,11418,11420,11423],{"class":40,"line":1289},[38,11414,2928],{"class":938},[38,11416,11417],{"class":881}," name ",[38,11419,5375],{"class":938},[38,11421,11422],{"class":55}," \"\u002F\"",[38,11424,2492],{"class":881},[38,11426,11427,11430,11432],{"class":40,"line":1921},[38,11428,11429],{"class":881},"        name ",[38,11431,945],{"class":938},[38,11433,11434],{"class":881}," f.prefix\n",[38,11436,11437,11440,11443],{"class":40,"line":1935},[38,11438,11439],{"class":881},"    } ",[38,11441,11442],{"class":938},"else",[38,11444,2492],{"class":881},[38,11446,11447,11449,11451,11454,11456,11458,11461,11463,11465,11468,11470],{"class":40,"line":1942},[38,11448,11429],{"class":881},[38,11450,945],{"class":938},[38,11452,11453],{"class":881}," f.prefix ",[38,11455,3668],{"class":938},[38,11457,11422],{"class":55},[38,11459,11460],{"class":938}," +",[38,11462,5437],{"class":881},[38,11464,7051],{"class":51},[38,11466,11467],{"class":881},"(name, ",[38,11469,7057],{"class":55},[38,11471,3160],{"class":881},[38,11473,11474],{"class":40,"line":1947},[38,11475,2811],{"class":881},[38,11477,11478,11480,11482,11485,11488,11490],{"class":40,"line":1953},[38,11479,4532],{"class":938},[38,11481,10082],{"class":881},[38,11483,11484],{"class":51},"FS",[38,11486,11487],{"class":881},"(f.fs).",[38,11489,11389],{"class":51},[38,11491,11492],{"class":881},"(name)\n",[38,11494,11495],{"class":40,"line":1961},[38,11496,2153],{"class":881},[16,11498,11499],{},"两个细节：",[527,11501,11502,11525],{},[530,11503,11504,11512,11513,7492,11515,11517,11518,11520,11521,11524],{},[23,11505,11506,11509,11510,11002],{},[35,11507,11508],{},"all:dist"," 里的 ",[35,11511,11001],{},"：embed 目录时默认会排除 ",[35,11514,7475],{},[35,11516,2973],{}," 开头的文件（上篇坑二就是被这个坑了，当时靠改 Vite 输出名绕开）。",[35,11519,11001],{}," 前缀表示「全部包含」，前端构建产物里那些 ",[35,11522,11523],{},"_xxx.js"," 助手文件直接安全落地，不用再改前端配置；",[530,11526,11527,11530,11531,11533,11534,11537],{},[35,11528,11529],{},"http.FileSystem"," 包装：加了一层 ",[35,11532,6841],{}," 前缀映射，",[35,11535,11536],{},"http.FileServer"," 和 Gin 都能直接用。",[675,11539,11541],{"id":11540},"_4-路由静态服务-缓存策略","4. 路由：静态服务 + 缓存策略",[28,11543,11545],{"className":4274,"code":11544,"language":976,"meta":33,"style":33},"\u002F\u002F backend\u002Finternal\u002Fhandler\u002Frouter.go\nfunc NewRouter(db *gorm.DB, uploadDir string) *gin.Engine {\n    r := gin.Default()\n    ...\n    dist := http.FileServer(web.Dist)\n    r.GET(\"\u002Fdist\", func(c *gin.Context) { c.Redirect(302, \"\u002Fdist\u002F\") })\n    r.GET(\"\u002Fdist\u002F*filepath\", func(c *gin.Context) {\n        p := c.Param(\"filepath\")\n        if p == \"\u002F\" || p == \"\u002Findex.html\" {\n            \u002F\u002F index.html 禁缓存：升级后浏览器立刻拿到新入口\n            c.Header(\"Cache-Control\", \"no-cache, no-store, must-revalidate\")\n        } else if strings.HasPrefix(p, \"\u002Fassets\u002F\") {\n            \u002F\u002F 带内容 hash 的资源可放心长缓存\n            c.Header(\"Cache-Control\", \"public, max-age=31536000, immutable\")\n        }\n        http.StripPrefix(\"\u002Fdist\", dist).ServeHTTP(c.Writer, c.Request)\n    })\n    r.GET(\"\u002F\", func(c *gin.Context) { c.Redirect(302, \"\u002Fdist\u002F\") })\n    ...\n}\n",[35,11546,11547,11552,11594,11606,11611,11626,11671,11700,11718,11739,11744,11763,11786,11791,11808,11812,11833,11837,11877,11881],{"__ignoreMap":33},[38,11548,11549],{"class":40,"line":41},[38,11550,11551],{"class":44},"\u002F\u002F backend\u002Finternal\u002Fhandler\u002Frouter.go\n",[38,11553,11554,11556,11559,11561,11564,11566,11569,11571,11574,11576,11579,11581,11583,11585,11587,11589,11592],{"class":40,"line":48},[38,11555,4475],{"class":938},[38,11557,11558],{"class":51}," NewRouter",[38,11560,2851],{"class":881},[38,11562,11563],{"class":2854},"db",[38,11565,5100],{"class":938},[38,11567,11568],{"class":51},"gorm",[38,11570,2973],{"class":881},[38,11572,11573],{"class":51},"DB",[38,11575,1598],{"class":881},[38,11577,11578],{"class":2854},"uploadDir",[38,11580,10538],{"class":938},[38,11582,3314],{"class":881},[38,11584,4507],{"class":938},[38,11586,5103],{"class":51},[38,11588,2973],{"class":881},[38,11590,11591],{"class":51},"Engine",[38,11593,2492],{"class":881},[38,11595,11596,11598,11600,11602,11604],{"class":40,"line":78},[38,11597,4916],{"class":881},[38,11599,4301],{"class":938},[38,11601,4921],{"class":881},[38,11603,4924],{"class":51},[38,11605,3045],{"class":881},[38,11607,11608],{"class":40,"line":85},[38,11609,11610],{"class":938},"    ...\n",[38,11612,11613,11616,11618,11620,11623],{"class":40,"line":91},[38,11614,11615],{"class":881},"    dist ",[38,11617,4301],{"class":938},[38,11619,10082],{"class":881},[38,11621,11622],{"class":51},"FileServer",[38,11624,11625],{"class":881},"(web.Dist)\n",[38,11627,11628,11630,11632,11634,11637,11639,11641,11643,11645,11647,11649,11651,11653,11656,11659,11661,11664,11666,11668],{"class":40,"line":114},[38,11629,5054],{"class":881},[38,11631,5083],{"class":51},[38,11633,2851],{"class":881},[38,11635,11636],{"class":55},"\"\u002Fdist\"",[38,11638,1598],{"class":881},[38,11640,4475],{"class":938},[38,11642,2851],{"class":881},[38,11644,5097],{"class":2854},[38,11646,5100],{"class":938},[38,11648,5103],{"class":51},[38,11650,2973],{"class":881},[38,11652,4488],{"class":51},[38,11654,11655],{"class":881},") { c.",[38,11657,11658],{"class":51},"Redirect",[38,11660,2851],{"class":881},[38,11662,11663],{"class":65},"302",[38,11665,1598],{"class":881},[38,11667,11069],{"class":55},[38,11669,11670],{"class":881},") })\n",[38,11672,11673,11675,11677,11679,11682,11684,11686,11688,11690,11692,11694,11696,11698],{"class":40,"line":119},[38,11674,5054],{"class":881},[38,11676,5083],{"class":51},[38,11678,2851],{"class":881},[38,11680,11681],{"class":55},"\"\u002Fdist\u002F*filepath\"",[38,11683,1598],{"class":881},[38,11685,4475],{"class":938},[38,11687,2851],{"class":881},[38,11689,5097],{"class":2854},[38,11691,5100],{"class":938},[38,11693,5103],{"class":51},[38,11695,2973],{"class":881},[38,11697,4488],{"class":51},[38,11699,4102],{"class":881},[38,11701,11702,11704,11706,11708,11711,11713,11716],{"class":40,"line":125},[38,11703,7044],{"class":881},[38,11705,4301],{"class":938},[38,11707,5354],{"class":881},[38,11709,11710],{"class":51},"Param",[38,11712,2851],{"class":881},[38,11714,11715],{"class":55},"\"filepath\"",[38,11717,3160],{"class":881},[38,11719,11720,11722,11724,11726,11728,11730,11732,11734,11737],{"class":40,"line":151},[38,11721,5369],{"class":938},[38,11723,7066],{"class":881},[38,11725,5375],{"class":938},[38,11727,11422],{"class":55},[38,11729,5470],{"class":938},[38,11731,7066],{"class":881},[38,11733,5375],{"class":938},[38,11735,11736],{"class":55}," \"\u002Findex.html\"",[38,11738,2492],{"class":881},[38,11740,11741],{"class":40,"line":156},[38,11742,11743],{"class":44},"            \u002F\u002F index.html 禁缓存：升级后浏览器立刻拿到新入口\n",[38,11745,11746,11748,11751,11753,11756,11758,11761],{"class":40,"line":162},[38,11747,5385],{"class":881},[38,11749,11750],{"class":51},"Header",[38,11752,2851],{"class":881},[38,11754,11755],{"class":55},"\"Cache-Control\"",[38,11757,1598],{"class":881},[38,11759,11760],{"class":55},"\"no-cache, no-store, must-revalidate\"",[38,11762,3160],{"class":881},[38,11764,11765,11768,11770,11773,11775,11778,11781,11784],{"class":40,"line":1017},[38,11766,11767],{"class":881},"        } ",[38,11769,11442],{"class":938},[38,11771,11772],{"class":938}," if",[38,11774,5437],{"class":881},[38,11776,11777],{"class":51},"HasPrefix",[38,11779,11780],{"class":881},"(p, ",[38,11782,11783],{"class":55},"\"\u002Fassets\u002F\"",[38,11785,4102],{"class":881},[38,11787,11788],{"class":40,"line":1022},[38,11789,11790],{"class":44},"            \u002F\u002F 带内容 hash 的资源可放心长缓存\n",[38,11792,11793,11795,11797,11799,11801,11803,11806],{"class":40,"line":1028},[38,11794,5385],{"class":881},[38,11796,11750],{"class":51},[38,11798,2851],{"class":881},[38,11800,11755],{"class":55},[38,11802,1598],{"class":881},[38,11804,11805],{"class":55},"\"public, max-age=31536000, immutable\"",[38,11807,3160],{"class":881},[38,11809,11810],{"class":40,"line":1039},[38,11811,5423],{"class":881},[38,11813,11814,11817,11820,11822,11824,11827,11830],{"class":40,"line":1050},[38,11815,11816],{"class":881},"        http.",[38,11818,11819],{"class":51},"StripPrefix",[38,11821,2851],{"class":881},[38,11823,11636],{"class":55},[38,11825,11826],{"class":881},", dist).",[38,11828,11829],{"class":51},"ServeHTTP",[38,11831,11832],{"class":881},"(c.Writer, c.Request)\n",[38,11834,11835],{"class":40,"line":1055},[38,11836,5149],{"class":881},[38,11838,11839,11841,11843,11845,11847,11849,11851,11853,11855,11857,11859,11861,11863,11865,11867,11869,11871,11873,11875],{"class":40,"line":1064},[38,11840,5054],{"class":881},[38,11842,5083],{"class":51},[38,11844,2851],{"class":881},[38,11846,7057],{"class":55},[38,11848,1598],{"class":881},[38,11850,4475],{"class":938},[38,11852,2851],{"class":881},[38,11854,5097],{"class":2854},[38,11856,5100],{"class":938},[38,11858,5103],{"class":51},[38,11860,2973],{"class":881},[38,11862,4488],{"class":51},[38,11864,11655],{"class":881},[38,11866,11658],{"class":51},[38,11868,2851],{"class":881},[38,11870,11663],{"class":65},[38,11872,1598],{"class":881},[38,11874,11069],{"class":55},[38,11876,11670],{"class":881},[38,11878,11879],{"class":40,"line":1069},[38,11880,11610],{"class":938},[38,11882,11883],{"class":40,"line":1276},[38,11884,2153],{"class":881},[16,11886,11887],{},"缓存策略是内嵌发布的必修课（坑二细说）：入口禁缓存、hash 资源长缓存。",[675,11889,11891],{"id":11890},"_5-打包脚本windows-版-linux-交叉编译版","5. 打包脚本：Windows 版 + Linux 交叉编译版",[28,11893,11895],{"className":30,"code":11894,"language":32,"meta":33,"style":33},"#!\u002Fusr\u002Fbin\u002Fenv bash\n# build-linux.sh —— 在 Windows\u002FmacOS\u002FLinux 任意平台交叉编译 linux\u002Famd64 产物\nset -euo pipefail\ncd \"$(dirname \"$0\")\"\n\nBINARY=\"${BINARY:-magic-img}\"\n\necho \"[1\u002F3] 构建前端并复制产物到 backend\u002Fweb\u002Fdist ...\"\n(cd frontend && npm run build)\n\necho \"[2\u002F3] 交叉编译后端并内嵌前端 (CGO_ENABLED=0 GOOS=linux GOARCH=amd64) ...\"\n(cd backend && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o \"$BINARY\" -ldflags=\"-s -w\" .\u002Fcmd\u002Fserver)\nchmod +x \"backend\u002F$BINARY\"\n\necho \"[3\u002F3] 打包完成: backend\u002F$BINARY\"\n",[35,11896,11897,11902,11907,11917,11935,11939,11960,11964,11972,11991,11995,12002,12059,12074,12078],{"__ignoreMap":33},[38,11898,11899],{"class":40,"line":41},[38,11900,11901],{"class":44},"#!\u002Fusr\u002Fbin\u002Fenv bash\n",[38,11903,11904],{"class":40,"line":48},[38,11905,11906],{"class":44},"# build-linux.sh —— 在 Windows\u002FmacOS\u002FLinux 任意平台交叉编译 linux\u002Famd64 产物\n",[38,11908,11909,11911,11914],{"class":40,"line":78},[38,11910,7322],{"class":65},[38,11912,11913],{"class":65}," -euo",[38,11915,11916],{"class":55}," pipefail\n",[38,11918,11919,11921,11924,11927,11929,11932],{"class":40,"line":85},[38,11920,7401],{"class":65},[38,11922,11923],{"class":55}," \"$(",[38,11925,11926],{"class":51},"dirname",[38,11928,878],{"class":55},[38,11930,11931],{"class":65},"$0",[38,11933,11934],{"class":55},"\")\"\n",[38,11936,11937],{"class":40,"line":91},[38,11938,82],{"emptyLinePlaceholder":81},[38,11940,11941,11944,11946,11949,11951,11954,11957],{"class":40,"line":114},[38,11942,11943],{"class":881},"BINARY",[38,11945,945],{"class":938},[38,11947,11948],{"class":55},"\"${",[38,11950,11943],{"class":881},[38,11952,11953],{"class":938},":-",[38,11955,11956],{"class":881},"magic-img",[38,11958,11959],{"class":55},"}\"\n",[38,11961,11962],{"class":40,"line":119},[38,11963,82],{"emptyLinePlaceholder":81},[38,11965,11966,11969],{"class":40,"line":125},[38,11967,11968],{"class":65},"echo",[38,11970,11971],{"class":55}," \"[1\u002F3] 构建前端并复制产物到 backend\u002Fweb\u002Fdist ...\"\n",[38,11973,11974,11976,11978,11981,11983,11985,11987,11989],{"class":40,"line":151},[38,11975,2851],{"class":881},[38,11977,7401],{"class":65},[38,11979,11980],{"class":55}," frontend",[38,11982,917],{"class":881},[38,11984,197],{"class":51},[38,11986,866],{"class":55},[38,11988,991],{"class":55},[38,11990,3160],{"class":881},[38,11992,11993],{"class":40,"line":156},[38,11994,82],{"emptyLinePlaceholder":81},[38,11996,11997,11999],{"class":40,"line":162},[38,11998,11968],{"class":65},[38,12000,12001],{"class":55}," \"[2\u002F3] 交叉编译后端并内嵌前端 (CGO_ENABLED=0 GOOS=linux GOARCH=amd64) ...\"\n",[38,12003,12004,12006,12008,12011,12014,12016,12018,12021,12023,12026,12029,12031,12034,12036,12038,12040,12042,12045,12048,12051,12054,12057],{"class":40,"line":1017},[38,12005,2851],{"class":881},[38,12007,7401],{"class":65},[38,12009,12010],{"class":55}," backend",[38,12012,12013],{"class":881}," && CGO_ENABLED",[38,12015,945],{"class":938},[38,12017,2939],{"class":55},[38,12019,12020],{"class":881}," GOOS",[38,12022,945],{"class":938},[38,12024,12025],{"class":55},"linux",[38,12027,12028],{"class":881}," GOARCH",[38,12030,945],{"class":938},[38,12032,12033],{"class":55},"amd64",[38,12035,1342],{"class":51},[38,12037,991],{"class":55},[38,12039,1000],{"class":65},[38,12041,878],{"class":55},[38,12043,12044],{"class":881},"$BINARY",[38,12046,12047],{"class":55},"\"",[38,12049,12050],{"class":65}," -ldflags=",[38,12052,12053],{"class":55},"\"-s -w\"",[38,12055,12056],{"class":55}," .\u002Fcmd\u002Fserver",[38,12058,3160],{"class":881},[38,12060,12061,12064,12067,12070,12072],{"class":40,"line":1022},[38,12062,12063],{"class":51},"chmod",[38,12065,12066],{"class":55}," +x",[38,12068,12069],{"class":55}," \"backend\u002F",[38,12071,12044],{"class":881},[38,12073,1081],{"class":55},[38,12075,12076],{"class":40,"line":1028},[38,12077,82],{"emptyLinePlaceholder":81},[38,12079,12080,12082,12085,12087],{"class":40,"line":1039},[38,12081,11968],{"class":65},[38,12083,12084],{"class":55}," \"[3\u002F3] 打包完成: backend\u002F",[38,12086,12044],{"class":881},[38,12088,1081],{"class":55},[16,12090,12091,12092,12095,12096,2743],{},"Windows 版（",[35,12093,12094],{},"build-win.bat","）同理，只是产物叫 ",[35,12097,12098],{},"magic-img.exe",[11,12100,7435],{"id":7435},[675,12102,12104,12105,12108,12109,12111],{"id":12103},"坑一goembed-dist-静默漏掉-_-开头文件升级解法","坑一：",[35,12106,12107],{},"\u002F\u002Fgo:embed dist"," 静默漏掉 ",[35,12110,7475],{}," 开头文件（升级解法）",[16,12113,12114,12115,12122,12123,12125],{},"上篇说过：embed 目录时",[23,12116,12117,12118,7492,12120,7476],{},"硬编码排除 ",[35,12119,7475],{},[35,12121,2973],{},"。上篇的解法是改 Vite 输出文件名去掉 ",[35,12124,7475],{}," 前缀；这次直接用：",[28,12127,12129],{"className":4274,"code":12128,"language":976,"meta":33,"style":33},"\u002F\u002Fgo:embed all:dist   \u002F\u002F all: 前缀 = 包含全部文件，不再静默跳过\n",[35,12130,12131],{"__ignoreMap":33},[38,12132,12133,12136],{"class":40,"line":41},[38,12134,12135],{"class":44},"\u002F\u002Fgo:embed all:dist",[38,12137,12138],{"class":44},"   \u002F\u002F all: 前缀 = 包含全部文件，不再静默跳过\n",[16,12140,12141],{},[23,12142,12143,12144,12146,12147,12149],{},"结论：能用 ",[35,12145,11001],{}," 就用 ",[35,12148,11001],{},"，从根上消灭这类\"文件莫名 404\"的问题。",[675,12151,12153],{"id":12152},"坑二开发版是新的打包版却是旧的","坑二：开发版是新的、打包版却是旧的",[16,12155,12156,12157,12160],{},"现象：Vite 开发服务一切正常，",[35,12158,12159],{},":8080\u002Fdist\u002F"," 打包版却是旧界面，强刷也不一定好。",[16,12162,12163,12164,12166,12167,12170,12171,12176],{},"排查过程值得记录：先是怀疑 ",[35,12165,7638],{}," 缓存了旧 embed（上篇坑三的思路），重编重启无效；然后直接抓取 8080 返回的 HTML 和 JS，发现",[23,12168,12169],{},"服务端内容已经是新的","——问题出在",[23,12172,12173,12174],{},"浏览器缓存了 ",[35,12175,11135],{},"：它引用旧 hash 的资源，一直不更新。",[16,12178,12179,12181,12182,12185,12186,12189],{},[23,12180,7453],{},"：入口文件响应 ",[35,12183,12184],{},"Cache-Control: no-store","（见上面路由代码），带 hash 的资源给 ",[35,12187,12188],{},"immutable"," 长缓存。升级后浏览器每次拉新入口，资源文件名一变就自动换新，两全其美。",[9788,12191,12192],{},[16,12193,12194],{},"教训：排查\"新旧不一致\"先分清是哪一层——服务端产物、浏览器入口缓存、还是资源缓存。抓包看响应内容再动手。",[675,12196,12198],{"id":12197},"坑三windows-上跑的-build-linuxsh产出的文件-linux-却执行不了","坑三：Windows 上跑的 build-linux.sh，产出的文件 Linux 却执行不了",[16,12200,12201,12202,697,12205,12211,12212,12214],{},"最初脚本只写了 ",[35,12203,12204],{},"CGO_ENABLED=0 go build",[23,12206,12207,12208],{},"没指定 ",[35,12209,12210],{},"GOOS\u002FGOARCH","。在 Windows 上跑，产出的其实是个 Windows PE 文件（只是没叫 ",[35,12213,7940],{},"），传到 Linux 自然「无法执行」。",[16,12216,12217,12219],{},[23,12218,7453],{},"：脚本里显式交叉编译：",[28,12221,12223],{"className":30,"code":12222,"language":32,"meta":33,"style":33},"CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o \"$BINARY\" -ldflags=\"-s -w\" .\u002Fcmd\u002Fserver\n",[35,12224,12225],{"__ignoreMap":33},[38,12226,12227,12230,12232,12234,12236,12238,12240,12242,12244,12246,12248,12250,12252,12254,12256,12258,12260,12262],{"class":40,"line":41},[38,12228,12229],{"class":881},"CGO_ENABLED",[38,12231,945],{"class":938},[38,12233,2939],{"class":55},[38,12235,12020],{"class":881},[38,12237,945],{"class":938},[38,12239,12025],{"class":55},[38,12241,12028],{"class":881},[38,12243,945],{"class":938},[38,12245,12033],{"class":55},[38,12247,1342],{"class":51},[38,12249,991],{"class":55},[38,12251,1000],{"class":65},[38,12253,878],{"class":55},[38,12255,12044],{"class":881},[38,12257,12047],{"class":55},[38,12259,12050],{"class":65},[38,12261,12053],{"class":55},[38,12263,12264],{"class":55}," .\u002Fcmd\u002Fserver\n",[16,12266,12267,12268,12271,12272,12275,12276],{},"能这么干的前提是",[23,12269,12270],{},"全依赖树无 cgo","——SQLite 用的是 modernc 纯 Go 驱动，bcrypt 用 ",[35,12273,12274],{},"x\u002Fcrypto","。这也是选型时就要留意的点：",[23,12277,12278],{},"想跨平台交叉编译，先把 cgo 依赖清干净。",[16,12280,12281,12282,12285,12286,12289],{},"另外两个小提醒：Windows 上编出的文件没有 Unix 执行位，上传服务器后记得 ",[35,12283,12284],{},"chmod +x","；",[35,12287,12288],{},"-ldflags=\"-s -w\""," 剥离符号，体积从 30MB+ 降到 26MB。",[675,12291,12293],{"id":12292},"坑四dist-被-gitignore-后embed-直接编译失败","坑四：dist 被 gitignore 后，embed 直接编译失败",[16,12295,12296,12298,12299,12301,12302,12305,12306,12308],{},[35,12297,11160],{}," 是构建产物、入了 .gitignore，但 ",[35,12300,6837],{}," 在",[23,12303,12304],{},"编译期","就要读它——刚 clone 下来直接 ",[35,12307,7638],{}," 会报 embed 找不到文件。",[16,12310,12311,12313,12314,12317],{},[23,12312,7453],{},"：gitignore 里保留 ",[35,12315,12316],{},"dist\u002F.gitkeep","（一个空文件），让目录存在于版本库中：",[28,12319,12323],{"className":12320,"code":12321,"language":12322,"meta":33,"style":33},"language-gitignore shiki shiki-themes github-light github-dark","backend\u002Fweb\u002Fdist\u002F*\n!backend\u002Fweb\u002Fdist\u002F\n!backend\u002Fweb\u002Fdist\u002F.gitkeep\n","gitignore",[35,12324,12325,12330,12335],{"__ignoreMap":33},[38,12326,12327],{"class":40,"line":41},[38,12328,12329],{},"backend\u002Fweb\u002Fdist\u002F*\n",[38,12331,12332],{"class":40,"line":48},[38,12333,12334],{},"!backend\u002Fweb\u002Fdist\u002F\n",[38,12336,12337],{"class":40,"line":78},[38,12338,12339],{},"!backend\u002Fweb\u002Fdist\u002F.gitkeep\n",[16,12341,12342,12343,12345,12346,12348],{},"这样 clone 后可以先 ",[35,12344,7638],{}," 通过，再 ",[35,12347,11153],{}," 灌入真实前端。",[675,12350,12352],{"id":12351},"坑五500kb-chunk-警告别慌","坑五：>500KB chunk 警告，别慌",[16,12354,12355,12356,12359,12360,2743],{},"Vite 构建会警告单 chunk 超过 500KB。这个项目里 Base UI 组件库全量引入，单 chunk 约 640KB——",[23,12357,12358],{},"属正常，不要为了消警告去拆配置","。真要优化就上路由级 ",[35,12361,12362],{},"React.lazy",[11,12364,7651],{"id":7651},[28,12366,12368],{"className":30,"code":12367,"language":32,"meta":33,"style":33},"$ build-win.bat        # backendmagic-img.exe\n$ bash build-linux.sh  # backendmagic-img（linux\u002Famd64 ELF）\n",[35,12369,12370,12380],{"__ignoreMap":33},[38,12371,12372,12374,12377],{"class":40,"line":41},[38,12373,2967],{"class":51},[38,12375,12376],{"class":55}," build-win.bat",[38,12378,12379],{"class":44},"        # backendmagic-img.exe\n",[38,12381,12382,12384,12386,12389],{"class":40,"line":48},[38,12383,2967],{"class":51},[38,12385,7663],{"class":55},[38,12387,12388],{"class":55}," build-linux.sh",[38,12390,12391],{"class":44},"  # backendmagic-img（linux\u002Famd64 ELF）\n",[16,12393,12394,12395,12398,12399,2743],{},"一个约 26MB 的二进制，内嵌 React 前端、Gin API、SQLite 数据初始化，拷到 Windows\u002FLinux 机器直接跑，浏览器访问 ",[35,12396,12397],{},"http:\u002F\u002Flocalhost:8080"," 自动重定向到 ",[35,12400,11139],{},[11,12402,793],{"id":793},[16,12404,12405],{},"这轮相比上篇的三点升级：",[767,12407,12408,12414,12424],{},[530,12409,12410,12413],{},[23,12411,12412],{},"hash 路由 + base \u002Fdist","：省掉 SPA fallback，静态服务即开即用；",[530,12415,12416,3993,12421,12423],{},[23,12417,12418,12420],{},[35,12419,11001],{}," embed 前缀",[35,12422,7475],{}," 开头文件问题一劳永逸；",[530,12425,12426,12432],{},[23,12427,12428,12429,12431],{},"显式 ",[35,12430,12210],{}," 交叉编译","：让「一个脚本、任意平台、产出 Linux 二进制」成为现实。",[16,12434,12435,12436,12439],{},"加上",[23,12437,12438],{},"入口 no-store \u002F 资源 immutable"," 的缓存策略，单文件分发的体验才算闭环——不然每次升级都要和浏览器缓存打架。",[801,12441,12442],{},"html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sHbNN, html code.shiki .sHbNN{--shiki-light:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .siTax, html code.shiki .siTax{--shiki-light:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sw2iP, html code.shiki .sw2iP{--shiki-light:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sIX_F, html code.shiki .sIX_F{--shiki-light:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .scXbn, html code.shiki .scXbn{--shiki-light:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .suQ91, html code.shiki .suQ91{--shiki-light:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sPK6S, html code.shiki .sPK6S{--shiki-light:#E36209;--shiki-dark:#FFAB70}",{"title":33,"searchDepth":48,"depth":78,"links":12444},[12445,12446,12447,12454,12462,12463],{"id":2719,"depth":48,"text":2719},{"id":6831,"depth":48,"text":6831},{"id":6845,"depth":48,"text":6845,"children":12448},[12449,12450,12451,12452,12453],{"id":11029,"depth":78,"text":11030},{"id":11147,"depth":78,"text":11148},{"id":11203,"depth":78,"text":11204},{"id":11540,"depth":78,"text":11541},{"id":11890,"depth":78,"text":11891},{"id":7435,"depth":48,"text":7435,"children":12455},[12456,12458,12459,12460,12461],{"id":12103,"depth":78,"text":12457},"坑一：\u002F\u002Fgo:embed dist 静默漏掉 _ 开头文件（升级解法）",{"id":12152,"depth":78,"text":12153},{"id":12197,"depth":78,"text":12198},{"id":12292,"depth":78,"text":12293},{"id":12351,"depth":78,"text":12352},{"id":7651,"depth":48,"text":7651},{"id":793,"depth":48,"text":793},"以 Magic Img（React 19 + Vite + Gin + SQLite）为实例，记录把前端塞进 Go 二进制、并在 Windows 上交叉编译 Linux 产物的完整过程：embed 的 all 前缀、hash 路由、缓存策略、GOOS 交叉编译等实操坑。","\u002Fposts\u002Fgo-embed-react-single-binary\u002Fimg\u002Fcover.svg",{},"\u002Fposts\u002Fgo-embed-react-single-binary","2026-08-16T00:00:00",{"title":10941,"description":12464},"posts\u002Fgo-embed-react-single-binary\u002Findex",[831,9757,6885,12472,12473],"交叉编译","打包","WV-T-MOO5Cu-aycw-Ony8s9hjA3_k0S4XU-T0QsCow0",{"id":12476,"title":12477,"author":6,"body":12478,"description":13723,"difficulty":2060,"draft":819,"extension":820,"image":13724,"meta":13725,"navigation":81,"path":13726,"pinned":819,"published":12468,"seo":13727,"series":13728,"seriesOrder":41,"stem":13729,"tags":13730,"updated":10937,"__hash__":13734},"posts\u002Fposts\u002Freact-photo-album-masonry\u002Findex.md","用 react-photo-album 给生图图库做瀑布流排版",{"type":8,"value":12479,"toc":13706},[12480,12483,12486,12504,12511,12575,12586,12589,12612,12615,12619,12633,12637,12653,12831,12835,13232,13235,13269,13272,13276,13286,13292,13317,13322,13333,13339,13350,13353,13393,13406,13529,13541,13545,13552,13570,13575,13665,13671,13675,13686,13689,13692,13698,13703],[11,12481,12482],{"id":12482},"技术背景",[16,12484,12485],{},"Magic Img 是我做的一个 AI 生图提示词管理工具（自托管、本地优先），其中「生图图库」页面要把所有生成图片以图片墙的形式展示出来。图片墙有两个天然矛盾：",[767,12487,12488,12494],{},[530,12489,12490,12493],{},[23,12491,12492],{},"等宽网格","（CSS Grid）：整齐，但每行高度由最高的那张决定——不同比例的图片混排时，矮图下面会留下大洞，看起来\"很乱\"；",[530,12495,12496,12499,12500,12503],{},[23,12497,12498],{},"原生比例展示","：图库必须不裁剪（这是产品约束），所以不能用 ",[35,12501,12502],{},"object-cover"," 强行统一比例。",[16,12505,12506,12507,12510],{},"业内的标准解法是 ",[23,12508,12509],{},"Masonry 瀑布流","：按列紧密堆放，每张图保持原始比例，列间高度错落但整体紧凑。实现瀑布流的常见方案有三类：",[272,12512,12513,12527],{},[275,12514,12515],{},[278,12516,12517,12519,12522,12525],{},[281,12518,3529],{},[281,12520,12521],{},"依赖",[281,12523,12524],{},"顺序",[281,12526,289],{},[291,12528,12529,12546,12559],{},[278,12530,12531,12538,12540,12543],{},[296,12532,12533,12534,12537],{},"CSS ",[35,12535,12536],{},"columns"," 多列",[296,12539,2235],{},[296,12541,12542],{},"纵向阅读序（先下后右）",[296,12544,12545],{},"最新图片不再出现在左上角，时间语义丢失",[278,12547,12548,12551,12553,12556],{},[296,12549,12550],{},"grid row-span 自研",[296,12552,2235],{},[296,12554,12555],{},"保持",[296,12557,12558],{},"需固定列数 + 固定行高，逻辑要自己写",[278,12560,12561,12566,12569,12572],{},[296,12562,12563],{},[23,12564,12565],{},"react-photo-album",[296,12567,12568],{},"~4KB，无依赖",[296,12570,12571],{},"基本保持（逐列最短列填充）",[296,12573,12574],{},"专为相册墙设计，TS 友好，维护活跃",[16,12576,12577,12578,12581,12582,12585],{},"最终选了 ",[23,12579,12580],{},"react-photo-album v3","：体积小、类型齐全，而且它的 Masonry 模式恰好需要「每张图的 ",[35,12583,12584],{},"width\u002Fheight","」——我们的数据库里本来就存了，零额外成本。",[11,12587,12588],{"id":12588},"需求梳理",[767,12590,12591,12597,12603,12606,12609],{},[530,12592,12593,12594,12285],{},"展示全部生成图（21 张起，会持续增长），",[23,12595,12596],{},"原比例、不裁剪",[530,12598,12599,12600,12285],{},"不同尺寸（2304×1728、4608×3456 等）混排时",[23,12601,12602],{},"紧凑、不留洞",[530,12604,12605],{},"顺序基本保持「新图靠前」；",[530,12607,12608],{},"点击任意一张打开大图预览（YARL 灯箱），悬停显示所属提示词；",[530,12610,12611],{},"平板性能：墙上只用 300px 缩略图，原图只进灯箱按需加载。",[11,12613,12614],{"id":12614},"安装与使用",[675,12616,12618],{"id":12617},"_1-安装","1. 安装",[28,12620,12622],{"className":30,"code":12621,"language":32,"meta":33,"style":33},"npm install react-photo-album\n",[35,12623,12624],{"__ignoreMap":33},[38,12625,12626,12628,12630],{"class":40,"line":41},[38,12627,197],{"class":51},[38,12629,200],{"class":55},[38,12631,12632],{"class":55}," react-photo-album\n",[675,12634,12636],{"id":12635},"_2-准备数据关键输入是宽高","2. 准备数据（关键输入是宽高）",[16,12638,12639,12640,12643,12644,7458,12646,7458,12649,12652],{},"库要求的 ",[35,12641,12642],{},"Photo"," 对象只有三个必需字段：",[35,12645,3390],{},[35,12647,12648],{},"width",[35,12650,12651],{},"height","。我们的接口本来就返回每张图的原始宽高：",[28,12654,12656],{"className":11087,"code":12655,"language":11089,"meta":33,"style":33},"import PhotoAlbum, { type Photo } from \"react-photo-album\"\n\u002F\u002F 必须引入样式表！容器\u002F列的布局规则都在这里（坑 1 详述）\nimport \"react-photo-album\u002Fstyles.css\"\n\ninterface GalleryPhoto extends Photo {\n  prompt_text?: string\n}\n\nconst albumPhotos: GalleryPhoto[] = images.map((img) => ({\n  key: String(img.id),\n  src: assetUrl(img.thumb_path) || assetUrl(img.file_path),\n  width: img.width,\n  height: img.height,\n  alt: img.prompt_text || `生成图 ${img.id}`,\n  prompt_text: img.prompt_text,\n}))\n",[35,12657,12658,12676,12681,12688,12692,12708,12718,12722,12726,12759,12770,12789,12794,12799,12821,12826],{"__ignoreMap":33},[38,12659,12660,12662,12665,12667,12670,12673],{"class":40,"line":41},[38,12661,4282],{"class":938},[38,12663,12664],{"class":881}," PhotoAlbum, { ",[38,12666,5667],{"class":938},[38,12668,12669],{"class":881}," Photo } ",[38,12671,12672],{"class":938},"from",[38,12674,12675],{"class":55}," \"react-photo-album\"\n",[38,12677,12678],{"class":40,"line":48},[38,12679,12680],{"class":44},"\u002F\u002F 必须引入样式表！容器\u002F列的布局规则都在这里（坑 1 详述）\n",[38,12682,12683,12685],{"class":40,"line":78},[38,12684,4282],{"class":938},[38,12686,12687],{"class":55}," \"react-photo-album\u002Fstyles.css\"\n",[38,12689,12690],{"class":40,"line":85},[38,12691,82],{"emptyLinePlaceholder":81},[38,12693,12694,12697,12700,12703,12706],{"class":40,"line":91},[38,12695,12696],{"class":938},"interface",[38,12698,12699],{"class":51}," GalleryPhoto",[38,12701,12702],{"class":938}," extends",[38,12704,12705],{"class":51}," Photo",[38,12707,2492],{"class":881},[38,12709,12710,12713,12716],{"class":40,"line":114},[38,12711,12712],{"class":2854},"  prompt_text",[38,12714,12715],{"class":938},"?:",[38,12717,5673],{"class":65},[38,12719,12720],{"class":40,"line":119},[38,12721,2153],{"class":881},[38,12723,12724],{"class":40,"line":125},[38,12725,82],{"emptyLinePlaceholder":81},[38,12727,12728,12730,12733,12735,12737,12739,12741,12744,12747,12750,12752,12754,12756],{"class":40,"line":151},[38,12729,3084],{"class":938},[38,12731,12732],{"class":65}," albumPhotos",[38,12734,1774],{"class":938},[38,12736,12699],{"class":51},[38,12738,2873],{"class":881},[38,12740,945],{"class":938},[38,12742,12743],{"class":881}," images.",[38,12745,12746],{"class":51},"map",[38,12748,12749],{"class":881},"((",[38,12751,1109],{"class":2854},[38,12753,3314],{"class":881},[38,12755,3278],{"class":938},[38,12757,12758],{"class":881}," ({\n",[38,12760,12761,12764,12767],{"class":40,"line":156},[38,12762,12763],{"class":881},"  key: ",[38,12765,12766],{"class":51},"String",[38,12768,12769],{"class":881},"(img.id),\n",[38,12771,12772,12775,12778,12781,12783,12786],{"class":40,"line":162},[38,12773,12774],{"class":881},"  src: ",[38,12776,12777],{"class":51},"assetUrl",[38,12779,12780],{"class":881},"(img.thumb_path) ",[38,12782,3064],{"class":938},[38,12784,12785],{"class":51}," assetUrl",[38,12787,12788],{"class":881},"(img.file_path),\n",[38,12790,12791],{"class":40,"line":1017},[38,12792,12793],{"class":881},"  width: img.width,\n",[38,12795,12796],{"class":40,"line":1022},[38,12797,12798],{"class":881},"  height: img.height,\n",[38,12800,12801,12804,12806,12809,12811,12813,12816,12819],{"class":40,"line":1028},[38,12802,12803],{"class":881},"  alt: img.prompt_text ",[38,12805,3064],{"class":938},[38,12807,12808],{"class":55}," `生成图 ${",[38,12810,1109],{"class":881},[38,12812,2973],{"class":55},[38,12814,12815],{"class":881},"id",[38,12817,12818],{"class":55},"}`",[38,12820,2503],{"class":881},[38,12822,12823],{"class":40,"line":1039},[38,12824,12825],{"class":881},"  prompt_text: img.prompt_text,\n",[38,12827,12828],{"class":40,"line":1050},[38,12829,12830],{"class":881},"}))\n",[675,12832,12834],{"id":12833},"_3-使用-masonry-布局","3. 使用 Masonry 布局",[28,12836,12838],{"className":11087,"code":12837,"language":11089,"meta":33,"style":33},"\u003CPhotoAlbum\n  layout=\"masonry\"\n  columns={(cw) => (cw \u003C 640 ? 2 : cw \u003C 960 ? 3 : cw \u003C 1280 ? 4 : 5)}\n  spacing={16}\n  padding={0}\n  photos={albumPhotos}\n  onClick={({ index }) => openPreview(index)}\n  render={{\n    photo: (props, context) => {\n      const { onClick } = props\n      const { photo, index, width, height } = context\n      return (\n        \u003Cbutton type=\"button\" onClick={onClick} className=\"group relative block w-full ...\">\n          \u003Cimg\n            src={photo.src}\n            alt={photo.alt || \"\"}\n            decoding=\"async\"\n            style={{ width, height }}\n          \u002F>\n          {\u002F* hover 提示词浮层 *\u002F}\n          \u003Cdiv className=\"... opacity-0 group-hover:opacity-100\">\n            {photo.prompt_text}\n          \u003C\u002Fdiv>\n        \u003C\u002Fbutton>\n      )\n    },\n  }}\n\u002F>\n",[35,12839,12840,12847,12857,12925,12939,12952,12962,12986,12996,13017,13035,13063,13070,13104,13112,13122,13138,13148,13158,13163,13173,13190,13195,13204,13213,13218,13222,13227],{"__ignoreMap":33},[38,12841,12842,12844],{"class":40,"line":41},[38,12843,1777],{"class":881},[38,12845,12846],{"class":65},"PhotoAlbum\n",[38,12848,12849,12852,12854],{"class":40,"line":48},[38,12850,12851],{"class":51},"  layout",[38,12853,945],{"class":938},[38,12855,12856],{"class":55},"\"masonry\"\n",[38,12858,12859,12862,12864,12867,12870,12872,12874,12877,12879,12882,12885,12887,12890,12893,12895,12898,12900,12903,12905,12907,12909,12912,12914,12917,12919,12922],{"class":40,"line":78},[38,12860,12861],{"class":51},"  columns",[38,12863,945],{"class":938},[38,12865,12866],{"class":881},"{(",[38,12868,12869],{"class":2854},"cw",[38,12871,3314],{"class":881},[38,12873,3278],{"class":938},[38,12875,12876],{"class":881}," (cw ",[38,12878,1777],{"class":938},[38,12880,12881],{"class":65}," 640",[38,12883,12884],{"class":938}," ?",[38,12886,5467],{"class":65},[38,12888,12889],{"class":938}," :",[38,12891,12892],{"class":881}," cw ",[38,12894,1777],{"class":938},[38,12896,12897],{"class":65}," 960",[38,12899,12884],{"class":938},[38,12901,12902],{"class":65}," 3",[38,12904,12889],{"class":938},[38,12906,12892],{"class":881},[38,12908,1777],{"class":938},[38,12910,12911],{"class":65}," 1280",[38,12913,12884],{"class":938},[38,12915,12916],{"class":65}," 4",[38,12918,12889],{"class":938},[38,12920,12921],{"class":65}," 5",[38,12923,12924],{"class":881},")}\n",[38,12926,12927,12930,12932,12934,12937],{"class":40,"line":85},[38,12928,12929],{"class":51},"  spacing",[38,12931,945],{"class":938},[38,12933,5134],{"class":881},[38,12935,12936],{"class":65},"16",[38,12938,2153],{"class":881},[38,12940,12941,12944,12946,12948,12950],{"class":40,"line":91},[38,12942,12943],{"class":51},"  padding",[38,12945,945],{"class":938},[38,12947,5134],{"class":881},[38,12949,2939],{"class":65},[38,12951,2153],{"class":881},[38,12953,12954,12957,12959],{"class":40,"line":114},[38,12955,12956],{"class":51},"  photos",[38,12958,945],{"class":938},[38,12960,12961],{"class":881},"{albumPhotos}\n",[38,12963,12964,12967,12969,12972,12975,12978,12980,12983],{"class":40,"line":119},[38,12965,12966],{"class":51},"  onClick",[38,12968,945],{"class":938},[38,12970,12971],{"class":881},"{({ ",[38,12973,12974],{"class":2854},"index",[38,12976,12977],{"class":881}," }) ",[38,12979,3278],{"class":938},[38,12981,12982],{"class":51}," openPreview",[38,12984,12985],{"class":881},"(index)}\n",[38,12987,12988,12991,12993],{"class":40,"line":125},[38,12989,12990],{"class":51},"  render",[38,12992,945],{"class":938},[38,12994,12995],{"class":881},"{{\n",[38,12997,12998,13001,13004,13006,13008,13011,13013,13015],{"class":40,"line":151},[38,12999,13000],{"class":51},"    photo",[38,13002,13003],{"class":881},": (",[38,13005,3385],{"class":2854},[38,13007,1598],{"class":881},[38,13009,13010],{"class":2854},"context",[38,13012,3314],{"class":881},[38,13014,3278],{"class":938},[38,13016,2492],{"class":881},[38,13018,13019,13021,13024,13027,13030,13032],{"class":40,"line":156},[38,13020,2989],{"class":938},[38,13022,13023],{"class":881}," { ",[38,13025,13026],{"class":65},"onClick",[38,13028,13029],{"class":881}," } ",[38,13031,945],{"class":938},[38,13033,13034],{"class":881}," props\n",[38,13036,13037,13039,13041,13044,13046,13048,13050,13052,13054,13056,13058,13060],{"class":40,"line":162},[38,13038,2989],{"class":938},[38,13040,13023],{"class":881},[38,13042,13043],{"class":65},"photo",[38,13045,1598],{"class":881},[38,13047,12974],{"class":65},[38,13049,1598],{"class":881},[38,13051,12648],{"class":65},[38,13053,1598],{"class":881},[38,13055,12651],{"class":65},[38,13057,13029],{"class":881},[38,13059,945],{"class":938},[38,13061,13062],{"class":881}," context\n",[38,13064,13065,13068],{"class":40,"line":1017},[38,13066,13067],{"class":938},"      return",[38,13069,5680],{"class":881},[38,13071,13072,13075,13078,13081,13083,13086,13089,13091,13094,13097,13099,13102],{"class":40,"line":1022},[38,13073,13074],{"class":881},"        \u003C",[38,13076,13077],{"class":2552},"button",[38,13079,13080],{"class":51}," type",[38,13082,945],{"class":938},[38,13084,13085],{"class":55},"\"button\"",[38,13087,13088],{"class":51}," onClick",[38,13090,945],{"class":938},[38,13092,13093],{"class":881},"{onClick} ",[38,13095,13096],{"class":51},"className",[38,13098,945],{"class":938},[38,13100,13101],{"class":55},"\"group relative block w-full ...\"",[38,13103,1435],{"class":881},[38,13105,13106,13109],{"class":40,"line":1028},[38,13107,13108],{"class":881},"          \u003C",[38,13110,13111],{"class":2552},"img\n",[38,13113,13114,13117,13119],{"class":40,"line":1039},[38,13115,13116],{"class":51},"            src",[38,13118,945],{"class":938},[38,13120,13121],{"class":881},"{photo.src}\n",[38,13123,13124,13127,13129,13132,13134,13136],{"class":40,"line":1050},[38,13125,13126],{"class":51},"            alt",[38,13128,945],{"class":938},[38,13130,13131],{"class":881},"{photo.alt ",[38,13133,3064],{"class":938},[38,13135,5378],{"class":55},[38,13137,2153],{"class":881},[38,13139,13140,13143,13145],{"class":40,"line":1055},[38,13141,13142],{"class":51},"            decoding",[38,13144,945],{"class":938},[38,13146,13147],{"class":55},"\"async\"\n",[38,13149,13150,13153,13155],{"class":40,"line":1064},[38,13151,13152],{"class":51},"            style",[38,13154,945],{"class":938},[38,13156,13157],{"class":881},"{{ width, height }}\n",[38,13159,13160],{"class":40,"line":1069},[38,13161,13162],{"class":881},"          \u002F>\n",[38,13164,13165,13168,13171],{"class":40,"line":1276},[38,13166,13167],{"class":881},"          {",[38,13169,13170],{"class":44},"\u002F* hover 提示词浮层 *\u002F",[38,13172,2153],{"class":881},[38,13174,13175,13177,13180,13183,13185,13188],{"class":40,"line":1284},[38,13176,13108],{"class":881},[38,13178,13179],{"class":2552},"div",[38,13181,13182],{"class":51}," className",[38,13184,945],{"class":938},[38,13186,13187],{"class":55},"\"... opacity-0 group-hover:opacity-100\"",[38,13189,1435],{"class":881},[38,13191,13192],{"class":40,"line":1289},[38,13193,13194],{"class":881},"            {photo.prompt_text}\n",[38,13196,13197,13200,13202],{"class":40,"line":1921},[38,13198,13199],{"class":881},"          \u003C\u002F",[38,13201,13179],{"class":2552},[38,13203,1435],{"class":881},[38,13205,13206,13209,13211],{"class":40,"line":1935},[38,13207,13208],{"class":881},"        \u003C\u002F",[38,13210,13077],{"class":2552},[38,13212,1435],{"class":881},[38,13214,13215],{"class":40,"line":1942},[38,13216,13217],{"class":881},"      )\n",[38,13219,13220],{"class":40,"line":1947},[38,13221,7612],{"class":881},[38,13223,13224],{"class":40,"line":1953},[38,13225,13226],{"class":881},"  }}\n",[38,13228,13229],{"class":40,"line":1961},[38,13230,13231],{"class":881},"\u002F>\n",[16,13233,13234],{},"几个要点：",[527,13236,13237,13246,13259],{},[530,13238,13239,13241,13242,13245],{},[35,13240,12536],{}," 可以是数字或 ",[35,13243,13244],{},"(containerWidth) => number"," 函数，做响应式列数很方便；",[530,13247,13248,13249,13251,13252,13255,13256,13258],{},"点击回调 ",[35,13250,13026],{}," 里拿到的是",[23,13253,13254],{},"原始数组下标"," ",[35,13257,12974],{},"，直接传给灯箱即可；",[530,13260,13261,13262,13265,13266,13268],{},"自定义 ",[35,13263,13264],{},"render.photo"," 后，图片尺寸要自己用 context 里的 ",[35,13267,12584],{},"（渲染尺寸）设置——这两个值库已经按列宽算好了。",[11,13270,13271],{"id":13271},"遇到的坑",[675,13273,13275],{"id":13274},"坑-1忘记引入样式表-一张一列","坑 1：忘记引入样式表 → 「一张一列」",[16,13277,13278,13279,13282,13283,2743],{},"这是最坑的一个。装完包、写好 ",[35,13280,13281],{},"\u003CPhotoAlbum layout=\"masonry\">","，页面渲染出来却是：",[23,13284,13285],{},"每张图占一整行，右侧大片置灰空白",[16,13287,13288,13289,13291],{},"排查过程：先怀疑是 ",[35,13290,12536],{}," 函数没生效，改成固定数字无效；再检查 DOM——容器和 5 个列（track）都在，列的宽度也算对了，但图片全都堆在布局里。",[16,13293,13294,13295,13298,13299,7458,13302,13305,13306,13312,13313,13316],{},"最后翻包的 ",[35,13296,13297],{},"package.json"," 发现它导出了独立的 CSS 文件（",[35,13300,13301],{},".\u002Fstyles.css",[35,13303,13304],{},".\u002Fmasonry.css"," 等），而",[23,13307,13308,13309],{},"官方 README 示例都带了一行 ",[35,13310,13311],{},"import \"react-photo-album\u002Fstyles.css\"","。容器是 flex、列是 flex-column、列宽按 ",[35,13314,13315],{},"--react-photo-album--columns"," 计算——这些规则全在这个样式表里，不引入就退化成普通块级堆叠。",[16,13318,13319,13321],{},[23,13320,7453],{},"：顶部加一行：",[28,13323,13325],{"className":11087,"code":13324,"language":11089,"meta":33,"style":33},"import \"react-photo-album\u002Fstyles.css\"\n",[35,13326,13327],{"__ignoreMap":33},[38,13328,13329,13331],{"class":40,"line":41},[38,13330,4282],{"class":938},[38,13332,12687],{"class":55},[16,13334,13335,13336],{},"经验：",[23,13337,13338],{},"用这类\"无运行时依赖\"的布局库，先看它的 exports 里有没有独立 CSS，有就一定要引。",[675,13340,13342,13343,13345,13346,13349],{"id":13341},"坑-2renderphoto-是-props-context-双参签名","坑 2：",[35,13344,13264],{}," 是 ",[35,13347,13348],{},"(props, context)"," 双参签名",[16,13351,13352],{},"自定义渲染时我凭直觉写了：",[28,13354,13356],{"className":11087,"code":13355,"language":11089,"meta":33,"style":33},"photo: ({ photo, index, width, height, onClick }) => ... \u002F\u002F ❌ 报错：Property 'photo' does not exist\n",[35,13357,13358],{"__ignoreMap":33},[38,13359,13360,13362,13365,13367,13369,13371,13373,13375,13377,13379,13381,13383,13385,13387,13390],{"class":40,"line":41},[38,13361,13043],{"class":51},[38,13363,13364],{"class":881},": ({ ",[38,13366,13043],{"class":2854},[38,13368,1598],{"class":881},[38,13370,12974],{"class":2854},[38,13372,1598],{"class":881},[38,13374,12648],{"class":2854},[38,13376,1598],{"class":881},[38,13378,12651],{"class":2854},[38,13380,1598],{"class":881},[38,13382,13026],{"class":2854},[38,13384,12977],{"class":881},[38,13386,3278],{"class":938},[38,13388,13389],{"class":938}," ...",[38,13391,13392],{"class":44}," \u002F\u002F ❌ 报错：Property 'photo' does not exist\n",[16,13394,13395,13396,13398,13399,13402,13403,3993],{},"TS 报错提示 ",[35,13397,13043],{}," 等属性不存在于 ",[35,13400,13401],{},"RenderPhotoProps","。看类型定义才发现 v3 的渲染函数是",[23,13404,13405],{},"两个参数",[28,13407,13409],{"className":11087,"code":13408,"language":11089,"meta":33,"style":33},"type RenderFunction\u003CProps, Context> = (props: Props, context: Context) => ReactNode\n\u002F\u002F photo 的渲染上下文（photo\u002Findex\u002Fwidth\u002Fheight）在第二个参数里\nphoto: (props, context) => {\n  const { onClick } = props\n  const { photo, index, width, height } = context\n  ...\n}\n",[35,13410,13411,13457,13462,13482,13496,13521,13525],{"__ignoreMap":33},[38,13412,13413,13415,13418,13420,13423,13425,13427,13430,13432,13434,13436,13438,13441,13443,13445,13447,13450,13452,13454],{"class":40,"line":41},[38,13414,5667],{"class":938},[38,13416,13417],{"class":51}," RenderFunction",[38,13419,1777],{"class":881},[38,13421,13422],{"class":51},"Props",[38,13424,1598],{"class":881},[38,13426,4488],{"class":51},[38,13428,13429],{"class":881},"> ",[38,13431,945],{"class":938},[38,13433,2893],{"class":881},[38,13435,3385],{"class":2854},[38,13437,1774],{"class":938},[38,13439,13440],{"class":51}," Props",[38,13442,1598],{"class":881},[38,13444,13010],{"class":2854},[38,13446,1774],{"class":938},[38,13448,13449],{"class":51}," Context",[38,13451,3314],{"class":881},[38,13453,3278],{"class":938},[38,13455,13456],{"class":51}," ReactNode\n",[38,13458,13459],{"class":40,"line":48},[38,13460,13461],{"class":44},"\u002F\u002F photo 的渲染上下文（photo\u002Findex\u002Fwidth\u002Fheight）在第二个参数里\n",[38,13463,13464,13466,13468,13470,13472,13474,13476,13478,13480],{"class":40,"line":78},[38,13465,13043],{"class":51},[38,13467,1774],{"class":938},[38,13469,2893],{"class":881},[38,13471,3385],{"class":2854},[38,13473,1598],{"class":881},[38,13475,13010],{"class":2854},[38,13477,3314],{"class":881},[38,13479,3278],{"class":938},[38,13481,2492],{"class":881},[38,13483,13484,13486,13488,13490,13493],{"class":40,"line":85},[38,13485,3339],{"class":51},[38,13487,13023],{"class":881},[38,13489,13026],{"class":2854},[38,13491,13492],{"class":881}," } = ",[38,13494,13495],{"class":2854},"props\n",[38,13497,13498,13500,13502,13504,13506,13508,13510,13512,13514,13516,13518],{"class":40,"line":91},[38,13499,3339],{"class":51},[38,13501,13023],{"class":881},[38,13503,13043],{"class":2854},[38,13505,1598],{"class":881},[38,13507,12974],{"class":2854},[38,13509,1598],{"class":881},[38,13511,12648],{"class":2854},[38,13513,1598],{"class":881},[38,13515,12651],{"class":2854},[38,13517,13492],{"class":881},[38,13519,13520],{"class":2854},"context\n",[38,13522,13523],{"class":40,"line":114},[38,13524,11080],{"class":938},[38,13526,13527],{"class":40,"line":119},[38,13528,2153],{"class":881},[16,13530,13531,13533,13534,13536,13537,13540],{},[23,13532,7453],{},"：按 ",[35,13535,13348],{}," 拆解即可。装新库先扫一眼 ",[35,13538,13539],{},".d.ts"," 里的 Render 类型签名，能少走很多弯路。",[675,13542,13544],{"id":13543},"坑-3测试断言把dom-顺序当成了视觉顺序","坑 3：测试断言把「DOM 顺序」当成了「视觉顺序」",[16,13546,13547,13548,13551],{},"布局修好后，我在浏览器自测脚本里加了一条断言：取前两个 ",[35,13549,13550],{},"\u003Cimg>","，比较它们的左坐标，认为应该并排（不同列）。",[16,13553,13554,13555,13558,13559,7492,13562,13565,13566,13569],{},"结果断言失败——但页面明明是 5 列并排。排查发现：",[23,13556,13557],{},"瀑布流的 DOM 是按\"列\"组织的","（第 1 列的全部图片在前，第 2 列的在后），所以 ",[35,13560,13561],{},"img[0]",[35,13563,13564],{},"img[1]"," 属于",[23,13567,13568],{},"同一列","，左坐标当然相同。",[16,13571,13572,13574],{},[23,13573,7453],{},"：断言改为比较「第 1 列的首张图 vs 第 2 列的首张图」：",[28,13576,13580],{"className":13577,"code":13578,"language":13579,"meta":33,"style":33},"language-js shiki shiki-themes github-light github-dark","const a = tracks[0].querySelector(\"img\")\nconst b = tracks[1].querySelector(\"img\")\nreturn Math.abs(a.getBoundingClientRect().left - b.getBoundingClientRect().left) > 10\n","js",[35,13581,13582,13607,13630],{"__ignoreMap":33},[38,13583,13584,13586,13588,13590,13593,13595,13597,13600,13602,13605],{"class":40,"line":41},[38,13585,3084],{"class":938},[38,13587,4099],{"class":65},[38,13589,2489],{"class":938},[38,13591,13592],{"class":881}," tracks[",[38,13594,2939],{"class":65},[38,13596,3004],{"class":881},[38,13598,13599],{"class":51},"querySelector",[38,13601,2851],{"class":881},[38,13603,13604],{"class":55},"\"img\"",[38,13606,3160],{"class":881},[38,13608,13609,13611,13614,13616,13618,13620,13622,13624,13626,13628],{"class":40,"line":48},[38,13610,3084],{"class":938},[38,13612,13613],{"class":65}," b",[38,13615,2489],{"class":938},[38,13617,13592],{"class":881},[38,13619,963],{"class":65},[38,13621,3004],{"class":881},[38,13623,13599],{"class":51},[38,13625,2851],{"class":881},[38,13627,13604],{"class":55},[38,13629,3160],{"class":881},[38,13631,13632,13634,13637,13640,13643,13646,13649,13652,13655,13657,13660,13662],{"class":40,"line":78},[38,13633,2902],{"class":938},[38,13635,13636],{"class":881}," Math.",[38,13638,13639],{"class":51},"abs",[38,13641,13642],{"class":881},"(a.",[38,13644,13645],{"class":51},"getBoundingClientRect",[38,13647,13648],{"class":881},"().left ",[38,13650,13651],{"class":938},"-",[38,13653,13654],{"class":881}," b.",[38,13656,13645],{"class":51},[38,13658,13659],{"class":881},"().left) ",[38,13661,1388],{"class":938},[38,13663,13664],{"class":65}," 10\n",[16,13666,13335,13667,13670],{},[23,13668,13669],{},"验证布局类库时，先搞清楚它生成的 DOM 结构，再写断言","；另外把「真实浏览器探针」当排查手段（直接打印各 track 的 x 坐标和子元素数量），比盯着代码猜快得多。",[675,13672,13674],{"id":13673},"坑-4等宽网格的洞引入前就存在的产品问题","坑 4：等宽网格的\"洞\"（引入前就存在的产品问题）",[16,13676,13677,13678,13681,13682,13685],{},"顺带记录一下为什么换掉原来的布局：CSS Grid ",[35,13679,13680],{},"repeat(auto-fill, minmax(220px, 1fr))"," 等宽铺满，图片 ",[35,13683,13684],{},"w-full h-auto","。当一行里混有不同比例时，行高取最高的那张，矮图底部留空，几行错落下来就显得乱；另外窗口宽度\"差一点放不下下一张\"时，右侧会空出近一整列。",[16,13687,13688],{},"换成 Masonry 后这两个问题都消失了——列内紧密堆叠、列数自适应。",[11,13690,13691],{"id":13691},"效果与小结",[16,13693,13694,13695,13697],{},"最终效果：21 张不同比例的生成图在 5 列瀑布流里紧密排列，原比例不裁剪，悬停出提示词浮层，点击进 YARL 大图预览；缩略图总大小仅 360KB，进页面即全量加载 + ",[35,13696,12188],{}," 长缓存，平板上快速滚动也跟手。",[16,13699,10866,13700],{},[23,13701,13702],{},"react-photo-album 是个小但专业的相册布局库，只要记住三件事——引样式表、喂宽高、render 函数是双参——就能在十分钟内把图片墙升级成真正的瀑布流。",[801,13704,13705],{},"html pre.shiki code .sw2iP, html code.shiki .sw2iP{--shiki-light:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .scXbn, html code.shiki .scXbn{--shiki-light:#032F62;--shiki-dark:#9ECBFF}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .siTax, html code.shiki .siTax{--shiki-light:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sIX_F, html code.shiki .sIX_F{--shiki-light:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sHbNN, html code.shiki .sHbNN{--shiki-light:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .sPK6S, html code.shiki .sPK6S{--shiki-light:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .suQ91, html code.shiki .suQ91{--shiki-light:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sbB4o, html code.shiki .sbB4o{--shiki-light:#22863A;--shiki-dark:#85E89D}",{"title":33,"searchDepth":48,"depth":78,"links":13707},[13708,13709,13710,13715,13722],{"id":12482,"depth":48,"text":12482},{"id":12588,"depth":48,"text":12588},{"id":12614,"depth":48,"text":12614,"children":13711},[13712,13713,13714],{"id":12617,"depth":78,"text":12618},{"id":12635,"depth":78,"text":12636},{"id":12833,"depth":78,"text":12834},{"id":13271,"depth":48,"text":13271,"children":13716},[13717,13718,13720,13721],{"id":13274,"depth":78,"text":13275},{"id":13341,"depth":78,"text":13719},"坑 2：render.photo 是 (props, context) 双参签名",{"id":13543,"depth":78,"text":13544},{"id":13673,"depth":78,"text":13674},{"id":13691,"depth":48,"text":13691},"记录在 Magic Img 生图图库中引入 react-photo-album 实现 Masonry 瀑布流的过程：技术选型背景、需求梳理、安装使用，以及「忘记引入样式表导致一张一列」等踩坑与排查方法。","\u002Fposts\u002Freact-photo-album-masonry\u002Fimg\u002Fcover.svg",{},"\u002Fposts\u002Freact-photo-album-masonry",{"title":12477,"description":13723},"前端工程实践","posts\u002Freact-photo-album-masonry\u002Findex",[9757,12565,13731,13732,13733],"瀑布流","前端","Tailwind","-O-kAQLEyRzwVhOpuo-yBxZim9LtvhChPkpP92ipyjM",1786887349178]