[{"data":1,"prerenderedAt":13573},["ShallowReactive",2],{"post-sublog-evolution":3,"series-source-sublog-evolution":613},{"id":4,"title":5,"author":6,"body":7,"description":596,"difficulty":597,"draft":598,"extension":599,"image":600,"meta":601,"navigation":494,"path":602,"pinned":598,"published":603,"seo":604,"series":605,"seriesOrder":606,"stem":607,"tags":608,"updated":611,"__hash__":612},"posts\u002Fposts\u002Fsublog-evolution\u002Findex.md","尼克的小窝 架构变迁：从纯静态到前后端分离",null,{"type":8,"value":9,"toc":574},"minimark",[10,15,19,31,35,40,51,55,95,99,113,117,120,195,199,202,208,212,223,253,256,260,270,273,319,323,448,452,459,463,470,476,480,483,539,543,550,557,563,566,569],[11,12,14],"h2",{"id":13},"写在前面","📝 写在前面",[16,17,18],"p",{},"SuBlog 最初只是一个简单的技术博客——Nuxt 3 静态生成，Markdown 写文章，部署到静态托管完事。但随着功能需求的增长，纯静态的架构开始显露出局限性。",[16,20,21,22,26,27,30],{},"这篇文章记录从 ",[23,24,25],"strong",{},"纯静态"," 到 ",[23,28,29],{},"前后端分离"," 的演进过程，带你了解每一步背后的思考。",[11,32,34],{"id":33},"第一代纯静态nuxt-3-nuxtcontent","📦 第一代：纯静态（Nuxt 3 + @nuxt\u002Fcontent）",[36,37,39],"h3",{"id":38},"️-架构图","🏗️ 架构图",[41,42,48],"pre",{"className":43,"code":45,"language":46,"meta":47},[44],"language-text","┌─────────────────────────────────────┐\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","text","",[49,50,45],"code",{"__ignoreMap":47},[36,52,54],{"id":53},"做了什么","🔧 做了什么",[56,57,58,72,78,84],"ul",{},[59,60,61,64,65,64,68,71],"li",{},[23,62,63],{},"Nuxt 3"," + ",[23,66,67],{},"TypeScript",[23,69,70],{},"Tailwind CSS v4"," 搭建前端",[59,73,74,77],{},[23,75,76],{},"@nuxt\u002Fcontent"," 管理 Markdown 文章，编译时生成静态页面",[59,79,80,83],{},[23,81,82],{},"Giscus"," 嵌入评论，依赖 GitHub Discussions",[59,85,86,87,90,91,94],{},"部署通过 ",[49,88,89],{},"nuxt generate"," 输出 ",[49,92,93],{},".output\u002Fpublic","，丢到 Nginx 或 Vercel",[36,96,98],{"id":97},"优点","✅ 优点",[56,100,101,107,110],{},[59,102,103,104,106],{},"部署极简，一个 ",[49,105,89],{}," 搞定",[59,108,109],{},"加载飞快，全是 HTML，没有服务端开销",[59,111,112],{},"无需后端，零运维成本",[36,114,116],{"id":115},"局限","❌ 局限",[16,118,119],{},"随着时间的推移，纯静态的问题越来越明显：",[121,122,123,136],"table",{},[124,125,126],"thead",{},[127,128,129,133],"tr",{},[130,131,132],"th",{},"问题",[130,134,135],{},"具体表现",[137,138,139,151,162,173,184],"tbody",{},[127,140,141,148],{},[142,143,144,145],"td",{},"🐢 ",[23,146,147],{},"内容更新慢",[142,149,150],{},"改一篇文章要重新构建、重新部署，不能实时发布",[127,152,153,159],{},[142,154,155,156],{},"👤 ",[23,157,158],{},"没有用户体系",[142,160,161],{},"无法区分读者和管理员，所有内容对所有人一致可见",[127,163,164,170],{},[142,165,166,167],{},"🔒 ",[23,168,169],{},"评论受限",[142,171,172],{},"Giscus 需要 GitHub 账号才能评论，门槛高",[127,174,175,181],{},[142,176,177,178],{},"🚫 ",[23,179,180],{},"无法做动态功能",[142,182,183],{},"点赞、收藏、阅读统计、个性化推荐……统统做不了",[127,185,186,192],{},[142,187,188,189],{},"🔗 ",[23,190,191],{},"前端锁死",[142,193,194],{},"被 Nuxt\u002FVue 生态绑定，想换技术栈得重写",[11,196,198],{"id":197},"第二代前后端分离react-go","⚡ 第二代：前后端分离（React + Go）",[36,200,39],{"id":201},"️-架构图-1",[41,203,206],{"className":204,"code":205,"language":46,"meta":47},[44],"┌───────────────────────┐       ┌────────────────────┐\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",[49,207,205],{"__ignoreMap":47},[36,209,211],{"id":210},"️-前端sublog-react","🖥️ 前端：sublog-react",[16,213,214,215,218,219,222],{},"选用 ",[23,216,217],{},"Next.js","（App Router） + ",[23,220,221],{},"React 19","，理由是：",[56,224,225,232,239,246],{},[59,226,227,228,231],{},"🎨 ",[23,229,230],{},"React 生态","——组件库成熟，开发效率高",[59,233,234,235,238],{},"📐 ",[23,236,237],{},"App Router","——支持布局嵌套，页面组织清晰",[59,240,241,242,245],{},"📦 ",[23,243,244],{},"静态导出","——仍可部署到静态托管",[59,247,248,249,252],{},"⚙️ ",[23,250,251],{},"运行时配置","——API 地址可动态注入，切换环境无需重新构建",[16,254,255],{},"前端不再直接读取 Markdown 文件，而是通过 HTTP API 获取文章数据。静态导出 + 客户端数据获取的组合，既保留了首屏加载速度，又实现了内容的动态更新。",[36,257,259],{"id":258},"️-后端sublog-s","⚙️ 后端：sublog-s",[16,261,262,263,64,266,269],{},"后端使用 ",[23,264,265],{},"Go",[23,267,268],{},"Gin","，按业务逻辑分层组织代码。",[16,271,272],{},"核心技术选型：",[121,274,275,285],{},[124,276,277],{},[127,278,279,282],{},[130,280,281],{},"技术",[130,283,284],{},"用途",[137,286,287,297,308],{},[127,288,289,294],{},[142,290,291,292],{},"🚀 ",[23,293,268],{},[142,295,296],{},"HTTP 框架",[127,298,299,305],{},[142,300,301,302],{},"🗄️ ",[23,303,304],{},"GORM",[142,306,307],{},"ORM，数据库操作",[127,309,310,316],{},[142,311,312,313],{},"🔐 ",[23,314,315],{},"JWT",[142,317,318],{},"用户认证",[11,320,322],{"id":321},"前后对比","📊 前后对比",[121,324,325,338],{},[124,326,327],{},[127,328,329,332,335],{},[130,330,331],{},"维度",[130,333,334],{},"第一代（纯静态）",[130,336,337],{},"第二代（前后端分离）",[137,339,340,353,367,381,394,407,421,434],{},[127,341,342,347,350],{},[142,343,227,344],{},[23,345,346],{},"前端框架",[142,348,349],{},"Nuxt 3 (Vue)",[142,351,352],{},"Next.js (React 19)",[127,354,355,361,364],{},[142,356,357,358],{},"🖥️ ",[23,359,360],{},"后端",[142,362,363],{},"无",[142,365,366],{},"Go + Gin",[127,368,369,375,378],{},[142,370,371,372],{},"📄 ",[23,373,374],{},"内容来源",[142,376,377],{},"Markdown 文件",[142,379,380],{},"API 数据库",[127,382,383,388,391],{},[142,384,291,385],{},[23,386,387],{},"发布方式",[142,389,390],{},"全量重新构建",[142,392,393],{},"API 实时发布",[127,395,396,401,404],{},[142,397,155,398],{},[23,399,400],{},"用户系统",[142,402,403],{},"❌ 无",[142,405,406],{},"✅ 注册\u002F登录",[127,408,409,415,418],{},[142,410,411,412],{},"💬 ",[23,413,414],{},"评论",[142,416,417],{},"Giscus（需 GitHub）",[142,419,420],{},"自有评论系统（规划中）",[127,422,423,428,431],{},[142,424,241,425],{},[23,426,427],{},"部署",[142,429,430],{},"1 个静态目录",[142,432,433],{},"前端静态 + 后端二进制",[127,435,436,442,445],{},[142,437,438,439],{},"⚡ ",[23,440,441],{},"更新体验",[142,443,444],{},"改文章 → 构建 → 部署",[142,446,447],{},"改文章 → 立即生效",[11,449,451],{"id":450},"新功能注册账号","🎉 新功能：注册账号",[16,453,454,455,458],{},"伴随着架构升级，",[23,456,457],{},"用户账号系统","也上线了。",[36,460,462],{"id":461},"目前支持","📋 目前支持",[16,464,465,466,469],{},"现在你可以在尼克的小窝 ",[23,467,468],{},"注册一个账号","：",[41,471,474],{"className":472,"code":473,"language":46},[44],"1. ✏️ 填写用户名、邮箱、密码\n2. 📧 查收激活邮件\n3. 🔗 点击链接激活账号\n4. 🚪 登录后查看和管理个人信息\n",[49,475,473],{"__ignoreMap":47},[11,477,479],{"id":478},"️-后续计划","🗓️ 后续计划",[16,481,482],{},"正在开发或规划中的功能：",[56,484,487,497,503,509,515,521,527,533],{"className":485},[486],"contains-task-list",[59,488,491,496],{"className":489},[490],"task-list-item",[492,493],"input",{"checked":494,"disabled":494,"type":495},true,"checkbox"," 用户注册\u002F登录（已上线 🎉）",[59,498,500,502],{"className":499},[490],[492,501],{"checked":494,"disabled":494,"type":495}," 个人资料编辑（已上线 🎉）",[59,504,506,508],{"className":505},[490],[492,507],{"disabled":494,"type":495}," 💬 站内评论系统",[59,510,512,514],{"className":511},[490],[492,513],{"disabled":494,"type":495}," 🔖 文章收藏",[59,516,518,520],{"className":517},[490],[492,519],{"disabled":494,"type":495}," 📚 阅读历史",[59,522,524,526],{"className":523},[490],[492,525],{"disabled":494,"type":495}," 👍 文章点赞",[59,528,530,532],{"className":529},[490],[492,531],{"disabled":494,"type":495}," 🔔 关注通知",[59,534,536,538],{"className":535},[490],[492,537],{"disabled":494,"type":495}," 🎨 个人首页定制",[11,540,542],{"id":541},"结语","💡 结语",[16,544,545,546,549],{},"从 Nuxt 3 纯静态到 React + Go 前后端分离，SuBlog 的架构变迁本质上是一个",[23,547,548],{},"功能驱动","的过程。静态博客有它的位置——写写文章、展示内容完全够用。但当你想做的不仅仅是\"展示\"时，后端的能力就不可或缺了。",[16,551,552,553,556],{},"这次重构最大的感触是：",[23,554,555],{},"架构选型不是越复杂越好，而是刚好够用最好","。纯静态阶段用 Nuxt 3 一点问题没有，直到功能需求超出了静态的边界，才自然推动到前后端分离。",[16,558,559,560],{},"如果你也在纠结\"要不要上后端\"，不妨先问问自己：",[23,561,562],{},"你要做的是\"展示内容\"还是\"提供服务\"？",[16,564,565],{},"前者，静态就够了；后者，来吧，后端欢迎你。😄",[567,568],"hr",{},[16,570,571],{},[23,572,573],{},"🎉 目前注册功能已经可以用了，后续更多好玩的功能正在路上。部分功能可能需要登录才能体验，快注册一个账号吧！",{"title":47,"searchDepth":575,"depth":576,"links":577},2,3,[578,579,585,590,591,594,595],{"id":13,"depth":575,"text":14},{"id":33,"depth":575,"text":34,"children":580},[581,582,583,584],{"id":38,"depth":576,"text":39},{"id":53,"depth":576,"text":54},{"id":97,"depth":576,"text":98},{"id":115,"depth":576,"text":116},{"id":197,"depth":575,"text":198,"children":586},[587,588,589],{"id":201,"depth":576,"text":39},{"id":210,"depth":576,"text":211},{"id":258,"depth":576,"text":259},{"id":321,"depth":575,"text":322},{"id":450,"depth":575,"text":451,"children":592},[593],{"id":461,"depth":576,"text":462},{"id":478,"depth":575,"text":479},{"id":541,"depth":575,"text":542},"记录尼克的小窝从 Nuxt 3 纯静态博客演进到 React + Go 前后端分离架构的全过程，包括架构设计的思考、技术选型的变化，以及新增的账号注册功能。","入门",false,"md","\u002Fposts\u002Fsublog-evolution\u002Fimg\u002Fcover.jpg",{},"\u002Fposts\u002Fsublog-evolution","2026-07-23T12:00:00",{"title":5,"description":596},"尼克的小窝 开发记录",1,"posts\u002Fsublog-evolution\u002Findex",[609,610,217,265],"架构","React","2026-07-23","VMQ6A2K8GQ5t3DSlezWPd3KgXnmDVMn9QVeaI28Hcp8",[614,1449,1868,2087,2490,3679,3957,6024,6806,7785,9194,9598,10777,12313],{"id":615,"title":616,"author":6,"body":617,"description":1436,"difficulty":1437,"draft":598,"extension":599,"image":1438,"meta":1439,"navigation":494,"path":1440,"pinned":598,"published":1441,"seo":1442,"series":1443,"seriesOrder":606,"stem":1444,"tags":1445,"updated":1441,"__hash__":1448},"posts\u002Fposts\u002Fdocker-musl-build\u002Findex.md","Docker 编译 musl 可执行文件",{"type":8,"value":618,"toc":1425},[619,622,625,628,879,882,925,928,946,949,1100,1103,1239,1242,1245,1303,1306,1421],[11,620,621],{"id":621},"解决问题",[16,623,624],{},"用 Go 进行跨平台编译时往往步骤复杂，用 Docker 可以轻松解决。但 Docker 的当前最小镜像 Alpine 其动态链接库采用 musl，与主流的 glibc 不同，往往不能方便执行。",[11,626,627],{"id":627},"查阅资料",[41,629,633],{"className":630,"code":631,"language":632,"meta":47,"style":47},"language-bash shiki shiki-themes github-light github-dark","# 启动容器并挂载当前目录到 `\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","bash",[49,634,635,643,685,690,696,725,744,760,766,780,803,812,817,823,834,845,850,859,864],{"__ignoreMap":47},[636,637,639],"span",{"class":638,"line":606},"line",[636,640,642],{"class":641},"sHbNN","# 启动容器并挂载当前目录到 `\u002Fapp`\n",[636,644,645,649,653,657,660,663,666,670,673,676,679,682],{"class":638,"line":575},[636,646,648],{"class":647},"sw2iP","docker",[636,650,652],{"class":651},"scXbn"," run",[636,654,656],{"class":655},"suQ91"," -it",[636,658,659],{"class":655}," --rm",[636,661,662],{"class":655}," -v",[636,664,665],{"class":651}," \"",[636,667,669],{"class":668},"sIX_F","$PWD",[636,671,672],{"class":651},"\":\u002Fapp",[636,674,675],{"class":655}," -w",[636,677,678],{"class":651}," \u002Fapp",[636,680,681],{"class":651}," ubuntu:20.04",[636,683,684],{"class":651}," bash\n",[636,686,687],{"class":638,"line":576},[636,688,689],{"emptyLinePlaceholder":494},"\n",[636,691,693],{"class":638,"line":692},4,[636,694,695],{"class":641},"# 在容器内执行以下操作：\n",[636,697,699,702,705,708,710,713,716,719,722],{"class":638,"line":698},5,[636,700,701],{"class":647},"apt-get",[636,703,704],{"class":651}," update",[636,706,707],{"class":668}," && ",[636,709,701],{"class":647},[636,711,712],{"class":651}," install",[636,714,715],{"class":655}," -y",[636,717,718],{"class":651}," musl-tools",[636,720,721],{"class":651}," golang-go",[636,723,724],{"class":641},"  # 安装 musl 和 Go\n",[636,726,728,732,735,738,741],{"class":638,"line":727},6,[636,729,731],{"class":730},"siTax","export",[636,733,734],{"class":668}," CC",[636,736,737],{"class":730},"=",[636,739,740],{"class":668},"musl-gcc  ",[636,742,743],{"class":641},"# 指定 musl 编译器\n",[636,745,747,749,752,754,757],{"class":638,"line":746},7,[636,748,731],{"class":730},[636,750,751],{"class":668}," CGO_ENABLED",[636,753,737],{"class":730},[636,755,756],{"class":655},"1",[636,758,759],{"class":641},"  # 启用 CGO\n",[636,761,763],{"class":638,"line":762},8,[636,764,765],{"class":641},"# 设置代理\n",[636,767,769,772,775,777],{"class":638,"line":768},9,[636,770,771],{"class":647},"go",[636,773,774],{"class":651}," env",[636,776,675],{"class":655},[636,778,779],{"class":651}," GOPROXY=https:\u002F\u002Fgoproxy.cn,direct\n",[636,781,783,785,788,791,794,797,800],{"class":638,"line":782},10,[636,784,771],{"class":647},[636,786,787],{"class":651}," build",[636,789,790],{"class":655}," -ldflags",[636,792,793],{"class":651}," '-linkmode external -extldflags \"-static\"'",[636,795,796],{"class":655}," -o",[636,798,799],{"class":651}," myapp",[636,801,802],{"class":641},"  # 静态编译\n",[636,804,806,809],{"class":638,"line":805},11,[636,807,808],{"class":655},"exit",[636,810,811],{"class":641},"  # 退出容器\n",[636,813,815],{"class":638,"line":814},12,[636,816,689],{"emptyLinePlaceholder":494},[636,818,820],{"class":638,"line":819},13,[636,821,822],{"class":641},"# 验证结果（在宿主机执行）\n",[636,824,826,829,831],{"class":638,"line":825},14,[636,827,828],{"class":647},"file",[636,830,799],{"class":651},[636,832,833],{"class":641},"  # 应显示 \"statically linked\"\n",[636,835,837,840,842],{"class":638,"line":836},15,[636,838,839],{"class":647},"ldd",[636,841,799],{"class":651},[636,843,844],{"class":641},"   # 应显示 \"not a dynamic executable\"\n",[636,846,848],{"class":638,"line":847},16,[636,849,689],{"emptyLinePlaceholder":494},[636,851,853,856],{"class":638,"line":852},17,[636,854,855],{"class":647},"wget",[636,857,858],{"class":651}," https:\u002F\u002Fgo.dev\u002Fdl\u002Fgo1.21.linux-amd64.tar.gz\n",[636,860,862],{"class":638,"line":861},18,[636,863,689],{"emptyLinePlaceholder":494},[636,865,867,870,873,876],{"class":638,"line":866},19,[636,868,869],{"class":647},"ENV",[636,871,872],{"class":651}," PATH=\"\u002Fdata\u002Fgo\u002Fbin:",[636,874,875],{"class":668},"$PATH",[636,877,878],{"class":651},"\"\n",[36,880,881],{"id":881},"遇到问题",[883,884,885,911],"ol",{},[59,886,887,890,891,894,897,898,901,903,904,906],{},[49,888,889],{},"go mod tidy"," 时报错 ",[49,892,893],{},"tls: failed to verify certificate: x509: certificate",[895,896],"br",{},"解决：",[49,899,900],{},"RUN apt-get update && apt-get install -y ca-certificates && rm -rf \u002Fvar\u002Flib\u002Fapt\u002Flists\u002F*",[895,902],{},"基础镜像的证书未更新，导致 https 的请求出现类似错误。",[895,905],{},[907,908],"img",{"alt":909,"src":910},"image-20250327100813512","\u002Fposts\u002Fdocker-musl-build\u002Fimg\u002Fimage-20250327100813512.png",[59,912,913,914,917,918,920,921,924],{},"直接安装 ",[49,915,916],{},"golang-go"," 其版本过低",[895,919],{},"解决: ",[49,922,923],{},"PATH=\"\u002Fdata\u002Fgo\u002Fbin:$PATH\""," 手动指定环境",[11,926,927],{"id":927},"最终方案",[883,929,930,937,940],{},[59,931,932,933,936],{},"下载 ",[49,934,935],{},"go1.24.1.linux-amd64.tar.gz"," 解压",[59,938,939],{},"编写 Dockerfile",[59,941,942,943],{},"执行 ",[49,944,945],{},"docker build -t go-build:v1 .",[16,947,948],{},"Dockerfile 内容：",[41,950,954],{"className":951,"code":952,"language":953,"meta":47,"style":47},"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",[49,955,956,964,968,979,983,991,995,1005,1009,1017,1021,1026,1033,1037,1044,1049,1056,1060,1068,1072,1080,1085],{"__ignoreMap":47},[636,957,958,961],{"class":638,"line":606},[636,959,960],{"class":730},"FROM",[636,962,963],{"class":668}," ubuntu:20.04\n",[636,965,966],{"class":638,"line":575},[636,967,689],{"emptyLinePlaceholder":494},[636,969,970,973,976],{"class":638,"line":576},[636,971,972],{"class":730},"LABEL",[636,974,975],{"class":668}," org.opencontainers.image.authors=",[636,977,978],{"class":651},"\"sutong\"\n",[636,980,981],{"class":638,"line":692},[636,982,689],{"emptyLinePlaceholder":494},[636,984,985,988],{"class":638,"line":698},[636,986,987],{"class":730},"COPY",[636,989,990],{"class":668}," go\u002F \u002Fgo\u002F\n",[636,992,993],{"class":638,"line":727},[636,994,689],{"emptyLinePlaceholder":494},[636,996,997,999,1002],{"class":638,"line":746},[636,998,869],{"class":730},[636,1000,1001],{"class":668}," PATH=",[636,1003,1004],{"class":651},"\"\u002Fgo\u002Fbin:$PATH\"\n",[636,1006,1007],{"class":638,"line":762},[636,1008,689],{"emptyLinePlaceholder":494},[636,1010,1011,1014],{"class":638,"line":768},[636,1012,1013],{"class":730},"RUN",[636,1015,1016],{"class":668}," apt-get update && apt-get install -y musl-tools\n",[636,1018,1019],{"class":638,"line":782},[636,1020,689],{"emptyLinePlaceholder":494},[636,1022,1023],{"class":638,"line":805},[636,1024,1025],{"class":641},"## go mod tidy 时报错 tls: failed to verify certificate: x509: certificate\n",[636,1027,1028,1030],{"class":638,"line":814},[636,1029,1013],{"class":730},[636,1031,1032],{"class":668}," apt-get update && apt-get install -y ca-certificates && rm -rf \u002Fvar\u002Flib\u002Fapt\u002Flists\u002F*\n",[636,1034,1035],{"class":638,"line":819},[636,1036,743],{"class":641},[636,1038,1039,1041],{"class":638,"line":825},[636,1040,869],{"class":730},[636,1042,1043],{"class":668}," CC=musl-gcc\n",[636,1045,1046],{"class":638,"line":836},[636,1047,1048],{"class":641},"# 启用 CGO\n",[636,1050,1051,1053],{"class":638,"line":847},[636,1052,869],{"class":730},[636,1054,1055],{"class":668}," CGO_ENABLED=1\n",[636,1057,1058],{"class":638,"line":852},[636,1059,689],{"emptyLinePlaceholder":494},[636,1061,1062,1065],{"class":638,"line":861},[636,1063,1064],{"class":730},"VOLUME",[636,1066,1067],{"class":668}," \u002Fdata\n",[636,1069,1070],{"class":638,"line":866},[636,1071,689],{"emptyLinePlaceholder":494},[636,1073,1075,1078],{"class":638,"line":1074},20,[636,1076,1077],{"class":730},"WORKDIR",[636,1079,1067],{"class":668},[636,1081,1083],{"class":638,"line":1082},21,[636,1084,689],{"emptyLinePlaceholder":494},[636,1086,1088,1091,1094,1097],{"class":638,"line":1087},22,[636,1089,1090],{"class":730},"CMD",[636,1092,1093],{"class":668}," [",[636,1095,1096],{"class":651},"\"\u002Fbin\u002Fbash\"",[636,1098,1099],{"class":668},"]\n",[36,1101,1102],{"id":1102},"构建",[41,1104,1106],{"className":630,"code":1105,"language":632,"meta":47,"style":47},"# 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",[49,1107,1108,1113,1117,1122,1148,1152,1157,1192,1197,1201,1234],{"__ignoreMap":47},[636,1109,1110],{"class":638,"line":606},[636,1111,1112],{"class":641},"# docker build -t go-build:v1 .\n",[636,1114,1115],{"class":638,"line":575},[636,1116,689],{"emptyLinePlaceholder":494},[636,1118,1119],{"class":638,"line":576},[636,1120,1121],{"class":641},"# 直接执行编译 编译后删除\n",[636,1123,1124,1126,1128,1130,1132,1135,1138,1141,1143,1145],{"class":638,"line":692},[636,1125,648],{"class":647},[636,1127,652],{"class":651},[636,1129,659],{"class":655},[636,1131,662],{"class":655},[636,1133,1134],{"class":651}," .:\u002Fdata",[636,1136,1137],{"class":651}," go-build:v1",[636,1139,1140],{"class":651}," go",[636,1142,787],{"class":651},[636,1144,796],{"class":655},[636,1146,1147],{"class":651}," app_musl\n",[636,1149,1150],{"class":638,"line":698},[636,1151,689],{"emptyLinePlaceholder":494},[636,1153,1154],{"class":638,"line":727},[636,1155,1156],{"class":641},"# 构建编译环境\n",[636,1158,1159,1161,1163,1165,1168,1170,1172,1175,1178,1181,1184,1187,1189],{"class":638,"line":746},[636,1160,648],{"class":647},[636,1162,652],{"class":651},[636,1164,656],{"class":655},[636,1166,1167],{"class":655}," -d",[636,1169,662],{"class":655},[636,1171,1134],{"class":651},[636,1173,1174],{"class":655}," --name",[636,1176,1177],{"class":730}," \u003C",[636,1179,1180],{"class":651},"容器名",[636,1182,1183],{"class":668},"称",[636,1185,1186],{"class":730},">",[636,1188,1137],{"class":651},[636,1190,1191],{"class":651}," \u002Fbin\u002Fbash\n",[636,1193,1194],{"class":638,"line":762},[636,1195,1196],{"class":641},"# eg：docker run -it -d -v .:\u002Fdata --name logbus-build go-build:v1 \u002Fbin\u002Fbash\n",[636,1198,1199],{"class":638,"line":768},[636,1200,689],{"emptyLinePlaceholder":494},[636,1202,1203,1205,1208,1210,1212,1214,1216,1218,1220,1222,1224,1226,1229,1231],{"class":638,"line":782},[636,1204,648],{"class":647},[636,1206,1207],{"class":651}," exec",[636,1209,656],{"class":655},[636,1211,1177],{"class":730},[636,1213,1180],{"class":651},[636,1215,1183],{"class":668},[636,1217,1186],{"class":730},[636,1219,1140],{"class":651},[636,1221,787],{"class":651},[636,1223,796],{"class":655},[636,1225,1177],{"class":730},[636,1227,1228],{"class":651},"myap",[636,1230,16],{"class":668},[636,1232,1233],{"class":730},">\n",[636,1235,1236],{"class":638,"line":805},[636,1237,1238],{"class":641},"# eg：docker exec -it logbus-build go build -o logbus_musl_1\n",[36,1240,1241],{"id":1241},"额外注意",[16,1243,1244],{},"若没科学上网需要设置代理：",[41,1246,1248],{"className":630,"code":1247,"language":632,"meta":47,"style":47},"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",[49,1249,1250,1268,1278,1294],{"__ignoreMap":47},[636,1251,1252,1254,1256,1258,1260,1262,1264,1266],{"class":638,"line":606},[636,1253,648],{"class":647},[636,1255,1207],{"class":651},[636,1257,656],{"class":655},[636,1259,1177],{"class":730},[636,1261,1180],{"class":651},[636,1263,1183],{"class":668},[636,1265,1186],{"class":730},[636,1267,1191],{"class":651},[636,1269,1270,1272,1274,1276],{"class":638,"line":575},[636,1271,771],{"class":647},[636,1273,774],{"class":651},[636,1275,675],{"class":655},[636,1277,779],{"class":651},[636,1279,1280,1282,1284,1286,1288,1290,1292],{"class":638,"line":576},[636,1281,771],{"class":647},[636,1283,787],{"class":651},[636,1285,790],{"class":655},[636,1287,793],{"class":651},[636,1289,796],{"class":655},[636,1291,799],{"class":651},[636,1293,802],{"class":641},[636,1295,1296,1298,1300],{"class":638,"line":692},[636,1297,771],{"class":647},[636,1299,787],{"class":651},[636,1301,1302],{"class":641}," # musl 库编译\n",[36,1304,1305],{"id":1305},"运行环境示例",[41,1307,1309],{"className":951,"code":1308,"language":953,"meta":47,"style":47},"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",[49,1310,1311,1318,1322,1330,1334,1341,1345,1352,1359,1363,1370,1374,1381,1385,1407,1411,1416],{"__ignoreMap":47},[636,1312,1313,1315],{"class":638,"line":606},[636,1314,960],{"class":730},[636,1316,1317],{"class":668}," alpine:latest\n",[636,1319,1320],{"class":638,"line":575},[636,1321,689],{"emptyLinePlaceholder":494},[636,1323,1324,1326,1328],{"class":638,"line":576},[636,1325,972],{"class":730},[636,1327,975],{"class":668},[636,1329,978],{"class":651},[636,1331,1332],{"class":638,"line":692},[636,1333,689],{"emptyLinePlaceholder":494},[636,1335,1336,1338],{"class":638,"line":698},[636,1337,987],{"class":730},[636,1339,1340],{"class":668}," glog_musl \u002Fapp\u002F\n",[636,1342,1343],{"class":638,"line":727},[636,1344,689],{"emptyLinePlaceholder":494},[636,1346,1347,1349],{"class":638,"line":746},[636,1348,987],{"class":730},[636,1350,1351],{"class":668}," conf.ini \u002Fdata\u002F\n",[636,1353,1354,1356],{"class":638,"line":762},[636,1355,987],{"class":730},[636,1357,1358],{"class":668}," ipname.ini \u002Fdata\u002F\n",[636,1360,1361],{"class":638,"line":768},[636,1362,689],{"emptyLinePlaceholder":494},[636,1364,1365,1367],{"class":638,"line":782},[636,1366,1077],{"class":730},[636,1368,1369],{"class":668}," \u002Fapp\n",[636,1371,1372],{"class":638,"line":805},[636,1373,689],{"emptyLinePlaceholder":494},[636,1375,1376,1378],{"class":638,"line":814},[636,1377,1013],{"class":730},[636,1379,1380],{"class":668}," chmod 777 \u002Fapp\u002Fglog_musl\n",[636,1382,1383],{"class":638,"line":819},[636,1384,689],{"emptyLinePlaceholder":494},[636,1386,1387,1389,1391,1394,1397,1400,1402,1405],{"class":638,"line":825},[636,1388,1090],{"class":730},[636,1390,1093],{"class":668},[636,1392,1393],{"class":651},"\".\u002Fglog_musl\"",[636,1395,1396],{"class":668},", ",[636,1398,1399],{"class":651},"\"logsweb\"",[636,1401,1396],{"class":668},[636,1403,1404],{"class":651},"\"\u002Fdata\u002Fconf.ini\"",[636,1406,1099],{"class":668},[636,1408,1409],{"class":638,"line":836},[636,1410,689],{"emptyLinePlaceholder":494},[636,1412,1413],{"class":638,"line":847},[636,1414,1415],{"class":641},"# docker build -t glog:v1 .\n",[636,1417,1418],{"class":638,"line":852},[636,1419,1420],{"class":641},"# docker run -p 6801:6801 glog:v1\n",[1422,1423,1424],"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 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":47,"searchDepth":575,"depth":576,"links":1426},[1427,1428,1431],{"id":621,"depth":575,"text":621},{"id":627,"depth":575,"text":627,"children":1429},[1430],{"id":881,"depth":576,"text":881},{"id":927,"depth":575,"text":927,"children":1432},[1433,1434,1435],{"id":1102,"depth":576,"text":1102},{"id":1241,"depth":576,"text":1241},{"id":1305,"depth":576,"text":1305},"解决 Go 语言跨平台编译问题，使用 Docker + musl 编译静态链接的可执行文件。","进阶","\u002Fposts\u002Fdocker-musl-build\u002Fimg\u002Fcover.svg",{},"\u002Fposts\u002Fdocker-musl-build","2025-03-27",{"title":616,"description":1436},"Docker 实践笔记","posts\u002Fdocker-musl-build\u002Findex",[1446,265,1447],"Docker","运维","YLtdOOc-gMVDSNjE-ntKXz-7hff-VtvMB2dB5RXNi1o",{"id":1450,"title":1451,"author":6,"body":1452,"description":1858,"difficulty":597,"draft":598,"extension":599,"image":1859,"meta":1860,"navigation":494,"path":1861,"pinned":598,"published":1862,"seo":1863,"series":1443,"seriesOrder":575,"stem":1864,"tags":1865,"updated":1862,"__hash__":1867},"posts\u002Fposts\u002Fdocker-user-start\u002Findex.md","Docker 构建时指定用户启动",{"type":8,"value":1453,"toc":1848},[1454,1457,1460,1463,1467,1470,1487,1491,1524,1528,1535,1585,1588,1807,1810,1813,1842,1845],[11,1455,1456],{"id":1456},"前言",[16,1458,1459],{},"我们在 Linux 使用应用时，往往不会直接使用 root 用户作为应用启动项，但 Dockerfile 默认就是 root 用户，导致挂载生成的文件路径往往是 root 权限，非 root 用户访问就很不方便。",[11,1461,1462],{"id":1462},"解决方案",[36,1464,1466],{"id":1465},"_1-安装用户管理工具","1. 安装用户管理工具",[16,1468,1469],{},"若镜像非自带用户管理：",[41,1471,1473],{"className":951,"code":1472,"language":953,"meta":47,"style":47},"# 权限管理\nRUN apk add --no-cache shadow\n",[49,1474,1475,1480],{"__ignoreMap":47},[636,1476,1477],{"class":638,"line":606},[636,1478,1479],{"class":641},"# 权限管理\n",[636,1481,1482,1484],{"class":638,"line":575},[636,1483,1013],{"class":730},[636,1485,1486],{"class":668}," apk add --no-cache shadow\n",[36,1488,1490],{"id":1489},"_2-创建用户并修改权限","2. 创建用户并修改权限",[41,1492,1494],{"className":951,"code":1493,"language":953,"meta":47,"style":47},"# 创建用户组和用户\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",[49,1495,1496,1501,1508,1512,1517],{"__ignoreMap":47},[636,1497,1498],{"class":638,"line":606},[636,1499,1500],{"class":641},"# 创建用户组和用户\n",[636,1502,1503,1505],{"class":638,"line":575},[636,1504,1013],{"class":730},[636,1506,1507],{"class":668}," addgroup -S \u003Capp_user_group> && adduser -S -G \u003Capp_user_group> \u003Capp_user>\n",[636,1509,1510],{"class":638,"line":576},[636,1511,689],{"emptyLinePlaceholder":494},[636,1513,1514],{"class":638,"line":692},[636,1515,1516],{"class":641},"# 修改目录权限\n",[636,1518,1519,1521],{"class":638,"line":698},[636,1520,1013],{"class":730},[636,1522,1523],{"class":668}," chown -R \u003Capp_user_group>:\u003Capp_user> \u002Fdata\n",[36,1525,1527],{"id":1526},"_3-指定用户启动","3. 指定用户启动",[16,1529,1530,1531,1534],{},"执行 docker 时加 ",[49,1532,1533],{},"--user"," 参数：",[41,1536,1538],{"className":630,"code":1537,"language":632,"meta":47,"style":47},"docker run -it --user \u003Capp_user> \u003Capp_image>:\u003Cimage_version>\n",[49,1539,1540],{"__ignoreMap":47},[636,1541,1542,1544,1546,1548,1551,1553,1556,1559,1561,1563,1566,1569,1571,1574,1577,1580,1583],{"class":638,"line":606},[636,1543,648],{"class":647},[636,1545,652],{"class":651},[636,1547,656],{"class":655},[636,1549,1550],{"class":655}," --user",[636,1552,1177],{"class":730},[636,1554,1555],{"class":651},"app_use",[636,1557,1558],{"class":668},"r",[636,1560,1186],{"class":730},[636,1562,1177],{"class":730},[636,1564,1565],{"class":651},"app_imag",[636,1567,1568],{"class":668},"e",[636,1570,1186],{"class":730},[636,1572,1573],{"class":651},":",[636,1575,1576],{"class":730},"\u003C",[636,1578,1579],{"class":651},"image_versio",[636,1581,1582],{"class":668},"n",[636,1584,1233],{"class":730},[11,1586,1587],{"id":1587},"完整示例",[41,1589,1591],{"className":951,"code":1590,"language":953,"meta":47,"style":47},"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",[49,1592,1593,1599,1603,1608,1621,1625,1632,1636,1642,1647,1654,1659,1666,1670,1677,1681,1685,1692,1696,1703,1707,1714,1718,1732,1739,1744,1750,1758,1766,1771,1777,1786],{"__ignoreMap":47},[636,1594,1595,1597],{"class":638,"line":606},[636,1596,960],{"class":730},[636,1598,1317],{"class":668},[636,1600,1601],{"class":638,"line":575},[636,1602,689],{"emptyLinePlaceholder":494},[636,1604,1605],{"class":638,"line":576},[636,1606,1607],{"class":641},"# 国内源\n",[636,1609,1610,1612,1615,1618],{"class":638,"line":692},[636,1611,1013],{"class":730},[636,1613,1614],{"class":668}," sed -i ",[636,1616,1617],{"class":651},"'s\u002Fdl-cdn.alpinelinux.org\u002Fmirrors.ustc.edu.cn\u002Fg'",[636,1619,1620],{"class":668}," \u002Fetc\u002Fapk\u002Frepositories\n",[636,1622,1623],{"class":638,"line":698},[636,1624,689],{"emptyLinePlaceholder":494},[636,1626,1627,1629],{"class":638,"line":727},[636,1628,1013],{"class":730},[636,1630,1631],{"class":668}," apk update --no-cache\n",[636,1633,1634],{"class":638,"line":746},[636,1635,1479],{"class":641},[636,1637,1638,1640],{"class":638,"line":762},[636,1639,1013],{"class":730},[636,1641,1486],{"class":668},[636,1643,1644],{"class":638,"line":768},[636,1645,1646],{"class":641},"# 调试\n",[636,1648,1649,1651],{"class":638,"line":782},[636,1650,1013],{"class":730},[636,1652,1653],{"class":668}," apk add --no-cache bash\n",[636,1655,1656],{"class":638,"line":805},[636,1657,1658],{"class":641},"# 设置时区\n",[636,1660,1661,1663],{"class":638,"line":814},[636,1662,1013],{"class":730},[636,1664,1665],{"class":668}," apk add --no-cache tzdata\n",[636,1667,1668],{"class":638,"line":819},[636,1669,689],{"emptyLinePlaceholder":494},[636,1671,1672,1674],{"class":638,"line":825},[636,1673,869],{"class":730},[636,1675,1676],{"class":668}," TZ=Asia\u002FShanghai\n",[636,1678,1679],{"class":638,"line":836},[636,1680,689],{"emptyLinePlaceholder":494},[636,1682,1683],{"class":638,"line":847},[636,1684,1500],{"class":641},[636,1686,1687,1689],{"class":638,"line":852},[636,1688,1013],{"class":730},[636,1690,1691],{"class":668}," addgroup -S glog && adduser -S -G glog glog\n",[636,1693,1694],{"class":638,"line":861},[636,1695,689],{"emptyLinePlaceholder":494},[636,1697,1698,1700],{"class":638,"line":866},[636,1699,987],{"class":730},[636,1701,1702],{"class":668}," .\u002Fglog_static_musl_1 \u002Fapp\u002F\n",[636,1704,1705],{"class":638,"line":1074},[636,1706,689],{"emptyLinePlaceholder":494},[636,1708,1709,1711],{"class":638,"line":1082},[636,1710,1013],{"class":730},[636,1712,1713],{"class":668}," mkdir \u002Fdata\n",[636,1715,1716],{"class":638,"line":1087},[636,1717,689],{"emptyLinePlaceholder":494},[636,1719,1721,1723,1726,1729],{"class":638,"line":1720},23,[636,1722,1064],{"class":730},[636,1724,1725],{"class":668}," [ ",[636,1727,1728],{"class":651},"\"\u002Fdata\"",[636,1730,1731],{"class":668}," ]\n",[636,1733,1735,1737],{"class":638,"line":1734},24,[636,1736,1077],{"class":730},[636,1738,1067],{"class":668},[636,1740,1742],{"class":638,"line":1741},25,[636,1743,689],{"emptyLinePlaceholder":494},[636,1745,1747],{"class":638,"line":1746},26,[636,1748,1749],{"class":641},"# 修改权限\n",[636,1751,1753,1755],{"class":638,"line":1752},27,[636,1754,1013],{"class":730},[636,1756,1757],{"class":668}," chown -R glog:glog \u002Fdata\n",[636,1759,1761,1763],{"class":638,"line":1760},28,[636,1762,1013],{"class":730},[636,1764,1765],{"class":668}," chown -R glog:glog \u002Fapp\n",[636,1767,1769],{"class":638,"line":1768},29,[636,1770,689],{"emptyLinePlaceholder":494},[636,1772,1774],{"class":638,"line":1773},30,[636,1775,1776],{"class":641},"# 指定用户启动\n",[636,1778,1780,1783],{"class":638,"line":1779},31,[636,1781,1782],{"class":730},"USER",[636,1784,1785],{"class":668}," glog\n",[636,1787,1789,1791,1793,1796,1798,1800,1802,1805],{"class":638,"line":1788},32,[636,1790,1090],{"class":730},[636,1792,1725],{"class":668},[636,1794,1795],{"class":651},"\"\u002Fapp\u002Fglog_static_musl_1\"",[636,1797,1396],{"class":668},[636,1799,1399],{"class":651},[636,1801,1396],{"class":668},[636,1803,1804],{"class":651},"\".\u002Fconf.ini\"",[636,1806,1731],{"class":668},[11,1808,1809],{"id":1809},"总结",[16,1811,1812],{},"关键步骤：",[883,1814,1815,1821,1830,1836],{},[59,1816,1817,1820],{},[49,1818,1819],{},"apk add --no-cache shadow"," - 安装用户管理工具",[59,1822,1823,64,1826,1829],{},[49,1824,1825],{},"addgroup",[49,1827,1828],{},"adduser"," - 创建用户",[59,1831,1832,1835],{},[49,1833,1834],{},"chown -R"," - 修改目录权限",[59,1837,1838,1841],{},[49,1839,1840],{},"USER \u003Cusername>"," - 指定运行用户",[16,1843,1844],{},"这样容器内的应用就不再以 root 身份运行，更加安全。",[1422,1846,1847],{},"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":47,"searchDepth":575,"depth":576,"links":1849},[1850,1851,1856,1857],{"id":1456,"depth":575,"text":1456},{"id":1462,"depth":575,"text":1462,"children":1852},[1853,1854,1855],{"id":1465,"depth":576,"text":1466},{"id":1489,"depth":576,"text":1490},{"id":1526,"depth":576,"text":1527},{"id":1587,"depth":575,"text":1587},{"id":1809,"depth":575,"text":1809},"解决 Docker 容器默认以 root 用户运行导致的权限问题，指定非 root 用户启动应用。","\u002Fposts\u002Fdocker-user-start\u002Fimg\u002Fcover.svg",{},"\u002Fposts\u002Fdocker-user-start","2025-04-01",{"title":1451,"description":1858},"posts\u002Fdocker-user-start\u002Findex",[1446,1447,1866],"Linux","b4Uyw5ms87xr7NhpIkavHnNbT20oaEPtXz8k1w6UPj0",{"id":1869,"title":1870,"author":6,"body":1871,"description":2078,"difficulty":6,"draft":598,"extension":599,"image":2079,"meta":2080,"navigation":494,"path":2081,"pinned":598,"published":2082,"seo":2083,"series":6,"seriesOrder":6,"stem":2084,"tags":2085,"updated":6,"__hash__":2086},"posts\u002Fposts\u002Fsveltekit-intro\u002Findex.md","SvelteKit 入门：为什么它比你想象的更简单",{"type":8,"value":1872,"toc":2068},[1873,1877,1880,1886,1889,1893,1900,1952,1956,1962,1968,1972,1975,1978,2046,2048,2059,2065],[11,1874,1876],{"id":1875},"什么是-sveltekit","什么是 SvelteKit？",[16,1878,1879],{},"SvelteKit 是 Svelte 的官方全栈框架，类似于 React 之于 Next.js 的关系。它提供了路由、SSR、SSG 等能力，让开发者可以快速构建现代 Web 应用。",[16,1881,1882],{},[907,1883],{"alt":1884,"src":1885},"SvelteKit Logo","https:\u002F\u002Fraw.githubusercontent.com\u002Fsveltejs\u002Fbranding\u002Fmaster\u002Fsvelte-logo.svg",[11,1887,1888],{"id":1888},"核心特点",[36,1890,1892],{"id":1891},"_1-编译时框架","1. 编译时框架",[16,1894,1895,1896,1899],{},"Svelte 和 React\u002FVue 最大的区别在于：",[23,1897,1898],{},"它在编译阶段就把组件转成原生 JS","，运行时没有虚拟 DOM。",[41,1901,1905],{"className":1902,"code":1903,"language":1904,"meta":47,"style":47},"language-typescript shiki shiki-themes github-light github-dark","\u002F\u002F Svelte 组件（编译后变成直接的 DOM 操作）\nlet count = 0\nfunction increment() {\n  count += 1\n}\n","typescript",[49,1906,1907,1912,1925,1936,1947],{"__ignoreMap":47},[636,1908,1909],{"class":638,"line":606},[636,1910,1911],{"class":641},"\u002F\u002F Svelte 组件（编译后变成直接的 DOM 操作）\n",[636,1913,1914,1917,1920,1922],{"class":638,"line":575},[636,1915,1916],{"class":730},"let",[636,1918,1919],{"class":668}," count ",[636,1921,737],{"class":730},[636,1923,1924],{"class":655}," 0\n",[636,1926,1927,1930,1933],{"class":638,"line":576},[636,1928,1929],{"class":730},"function",[636,1931,1932],{"class":647}," increment",[636,1934,1935],{"class":668},"() {\n",[636,1937,1938,1941,1944],{"class":638,"line":692},[636,1939,1940],{"class":668},"  count ",[636,1942,1943],{"class":730},"+=",[636,1945,1946],{"class":655}," 1\n",[636,1948,1949],{"class":638,"line":698},[636,1950,1951],{"class":668},"}\n",[36,1953,1955],{"id":1954},"_2-文件路由","2. 文件路由",[16,1957,1958,1961],{},[49,1959,1960],{},"src\u002Froutes\u002F"," 目录结构即 URL，不需要手动配置路由表：",[41,1963,1966],{"className":1964,"code":1965,"language":46},[44],"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",[49,1967,1965],{"__ignoreMap":47},[36,1969,1971],{"id":1970},"_3-全栈能力","3. 全栈能力",[16,1973,1974],{},"SvelteKit 内置了服务端渲染（SSR）、静态站点生成（SSG）、API 路由等功能，开箱即用。",[11,1976,1977],{"id":1977},"和其他框架对比",[121,1979,1980,1995],{},[124,1981,1982],{},[127,1983,1984,1987,1990,1992],{},[130,1985,1986],{},"特性",[130,1988,1989],{},"SvelteKit",[130,1991,217],{},[130,1993,1994],{},"Nuxt",[137,1996,1997,2010,2023,2035],{},[127,1998,1999,2002,2005,2008],{},[142,2000,2001],{},"学习曲线",[142,2003,2004],{},"低",[142,2006,2007],{},"中",[142,2009,2007],{},[127,2011,2012,2015,2018,2021],{},[142,2013,2014],{},"包体积",[142,2016,2017],{},"小",[142,2019,2020],{},"大",[142,2022,2007],{},[127,2024,2025,2028,2030,2033],{},[142,2026,2027],{},"运行时开销",[142,2029,363],{},[142,2031,2032],{},"有",[142,2034,2032],{},[127,2036,2037,2040,2042,2044],{},[142,2038,2039],{},"生态",[142,2041,2017],{},[142,2043,2020],{},[142,2045,2020],{},[11,2047,1809],{"id":1809},[16,2049,2050,2051,2054,2055,2058],{},"如果你追求",[23,2052,2053],{},"简洁的代码","和",[23,2056,2057],{},"极致的性能","，SvelteKit 是一个非常值得尝试的选择。它的学习曲线平缓，写起来就像在写原生 HTML\u002FCSS\u002FJS。",[16,2060,2061],{},[907,2062],{"alt":2063,"src":2064},"Svelte 编译过程","https:\u002F\u002Fsvelte.dev\u002Fsvelte-logo-horizontal.svg",[1422,2066,2067],{},"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":47,"searchDepth":575,"depth":576,"links":2069},[2070,2071,2076,2077],{"id":1875,"depth":575,"text":1876},{"id":1888,"depth":575,"text":1888,"children":2072},[2073,2074,2075],{"id":1891,"depth":576,"text":1892},{"id":1954,"depth":576,"text":1955},{"id":1970,"depth":576,"text":1971},{"id":1977,"depth":575,"text":1977},{"id":1809,"depth":575,"text":1809},"SvelteKit 是一个全栈 Svelte 框架，这篇文章带你了解它的核心概念和优势。","\u002Fposts\u002Fsveltekit-intro\u002Fimg\u002Fcover.jpg",{},"\u002Fposts\u002Fsveltekit-intro","2026-05-30",{"title":1870,"description":2078},"posts\u002Fsveltekit-intro\u002Findex",[1989,346],"vsEGyunpCWR7DiJAdyV1SPDfa57xXLITBI-lKM8UeGQ",{"id":2088,"title":2089,"author":6,"body":2090,"description":2478,"difficulty":597,"draft":598,"extension":599,"image":2479,"meta":2480,"navigation":494,"path":2481,"pinned":494,"published":2482,"seo":2483,"series":2484,"seriesOrder":606,"stem":2485,"tags":2486,"updated":2488,"__hash__":2489},"posts\u002Fposts\u002Fhello-world\u002Findex.md","Hello World - 欢迎使用 SuBlog",{"type":8,"value":2091,"toc":2469},[2092,2096,2103,2106,2109,2112,2179,2182,2185,2247,2250,2253,2319,2322,2388,2390,2393,2409,2415,2418,2445,2448,2459,2461,2466],[11,2093,2095],{"id":2094},"博客上线啦","博客上线啦！",[16,2097,2098,2099,2102],{},"Hello World！欢迎使用 ",[23,2100,2101],{},"SuBlog","，这是站点的第一篇示例文章。",[16,2104,2105],{},"经过一段时间的折腾，这个基于 Nuxt 3 的个人博客终于上线了。在这里记录一下这个项目的技术选型和功能特性。",[11,2107,2108],{"id":2108},"技术栈",[16,2110,2111],{},"本站使用了以下技术：",[121,2113,2114,2122],{},[124,2115,2116],{},[127,2117,2118,2120],{},[130,2119,281],{},[130,2121,284],{},[137,2123,2124,2133,2142,2151,2160,2170],{},[127,2125,2126,2130],{},[142,2127,2128],{},[23,2129,63],{},[142,2131,2132],{},"Vue 3 全栈框架，提供 SSG、路由、API 等能力",[127,2134,2135,2139],{},[142,2136,2137],{},[23,2138,67],{},[142,2140,2141],{},"类型安全，提升开发体验",[127,2143,2144,2148],{},[142,2145,2146],{},[23,2147,70],{},[142,2149,2150],{},"原子化 CSS，快速构建 UI",[127,2152,2153,2157],{},[142,2154,2155],{},[23,2156,76],{},[142,2158,2159],{},"Markdown 驱动的内容管理",[127,2161,2162,2167],{},[142,2163,2164],{},[23,2165,2166],{},"Shiki",[142,2168,2169],{},"代码语法高亮",[127,2171,2172,2176],{},[142,2173,2174],{},[23,2175,82],{},[142,2177,2178],{},"基于 GitHub Discussions 的评论系统",[11,2180,2181],{"id":2181},"功能特性",[16,2183,2184],{},"目前已实现的功能：",[56,2186,2187,2193,2199,2205,2211,2217,2223,2229,2235,2241],{},[59,2188,2189,2192],{},[23,2190,2191],{},"博客系统"," - Markdown 文章，支持代码高亮、目录导航、搜索",[59,2194,2195,2198],{},[23,2196,2197],{},"暗色模式"," - 跟随系统或手动切换",[59,2200,2201,2204],{},[23,2202,2203],{},"文章搜索"," - 支持标题、描述、正文、标签多维度搜索",[59,2206,2207,2210],{},[23,2208,2209],{},"标签系统"," - 文章标签分类，高频标签快速筛选",[59,2212,2213,2216],{},[23,2214,2215],{},"友链页面"," - 展示友情链接，支持申请",[59,2218,2219,2222],{},[23,2220,2221],{},"赞助页面"," - 微信收款码展示",[59,2224,2225,2228],{},[23,2226,2227],{},"封面制作"," - 在线生成封面图片工具",[59,2230,2231,2234],{},[23,2232,2233],{},"网易云热评"," - 首页随机展示音乐评论",[59,2236,2237,2240],{},[23,2238,2239],{},"访客统计"," - 不蒜子计数器，带数字滚动动画",[59,2242,2243,2246],{},[23,2244,2245],{},"SEO 优化"," - 自动生成站点地图、Open Graph 标签",[11,2248,2249],{"id":2249},"代码示例",[16,2251,2252],{},"本站的配置非常集中，一个文件搞定全站定制：",[41,2254,2256],{"className":1902,"code":2255,"language":1904,"meta":47,"style":47},"\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",[49,2257,2258,2263,2279,2290,2300,2310,2315],{"__ignoreMap":47},[636,2259,2260],{"class":638,"line":606},[636,2261,2262],{"class":641},"\u002F\u002F config\u002Fsite.ts\n",[636,2264,2265,2267,2270,2273,2276],{"class":638,"line":575},[636,2266,731],{"class":730},[636,2268,2269],{"class":730}," const",[636,2271,2272],{"class":655}," siteConfig",[636,2274,2275],{"class":730}," =",[636,2277,2278],{"class":668}," {\n",[636,2280,2281,2284,2287],{"class":638,"line":576},[636,2282,2283],{"class":668},"  siteName: ",[636,2285,2286],{"class":651},"'我的博客'",[636,2288,2289],{"class":668},",\n",[636,2291,2292,2295,2298],{"class":638,"line":692},[636,2293,2294],{"class":668},"  url: ",[636,2296,2297],{"class":651},"'https:\u002F\u002Fexample.com\u002F'",[636,2299,2289],{"class":668},[636,2301,2302,2305,2308],{"class":638,"line":698},[636,2303,2304],{"class":668},"  avatar: ",[636,2306,2307],{"class":651},"'https:\u002F\u002Fq2.qlogo.cn\u002Fheadimg_dl?dst_uin=xxx&spec=100'",[636,2309,2289],{"class":668},[636,2311,2312],{"class":638,"line":727},[636,2313,2314],{"class":641},"  \u002F\u002F ... 改这一个文件，全站生效\n",[636,2316,2317],{"class":638,"line":746},[636,2318,1951],{"class":668},[16,2320,2321],{},"文章的 frontmatter 也很简洁：",[41,2323,2327],{"className":2324,"code":2325,"language":2326,"meta":47,"style":47},"language-yaml shiki shiki-themes github-light github-dark","---\ntitle: 文章标题\npublished: 2026-05-31T08:00:00\ndescription: 文章简介\ntags: ['Nuxt', 'TypeScript']\n---\n","yaml",[49,2328,2329,2334,2346,2356,2366,2384],{"__ignoreMap":47},[636,2330,2331],{"class":638,"line":606},[636,2332,2333],{"class":647},"---\n",[636,2335,2336,2340,2343],{"class":638,"line":575},[636,2337,2339],{"class":2338},"sbB4o","title",[636,2341,2342],{"class":668},": ",[636,2344,2345],{"class":651},"文章标题\n",[636,2347,2348,2351,2353],{"class":638,"line":576},[636,2349,2350],{"class":2338},"published",[636,2352,2342],{"class":668},[636,2354,2355],{"class":655},"2026-05-31T08:00:00\n",[636,2357,2358,2361,2363],{"class":638,"line":692},[636,2359,2360],{"class":2338},"description",[636,2362,2342],{"class":668},[636,2364,2365],{"class":651},"文章简介\n",[636,2367,2368,2371,2374,2377,2379,2382],{"class":638,"line":698},[636,2369,2370],{"class":2338},"tags",[636,2372,2373],{"class":668},": [",[636,2375,2376],{"class":651},"'Nuxt'",[636,2378,1396],{"class":668},[636,2380,2381],{"class":651},"'TypeScript'",[636,2383,1099],{"class":668},[636,2385,2386],{"class":638,"line":727},[636,2387,2333],{"class":647},[11,2389,427],{"id":427},[16,2391,2392],{},"本站使用 SSG 静态生成，构建后直接部署到静态托管即可：",[41,2394,2396],{"className":630,"code":2395,"language":632,"meta":47,"style":47},"npx nuxi generate\n",[49,2397,2398],{"__ignoreMap":47},[636,2399,2400,2403,2406],{"class":638,"line":606},[636,2401,2402],{"class":647},"npx",[636,2404,2405],{"class":651}," nuxi",[636,2407,2408],{"class":651}," generate\n",[16,2410,2411,2412,2414],{},"构建产物在 ",[49,2413,93],{}," 目录下，可以直接丢到 Nginx、Vercel、Netlify 等平台。",[11,2416,2417],{"id":2417},"后续计划",[56,2419,2421,2427,2433,2439],{"className":2420},[486],[59,2422,2424,2426],{"className":2423},[490],[492,2425],{"disabled":494,"type":495}," RSS 订阅",[59,2428,2430,2432],{"className":2429},[490],[492,2431],{"disabled":494,"type":495}," 文章目录优化",[59,2434,2436,2438],{"className":2435},[490],[492,2437],{"disabled":494,"type":495}," 更多小工具",[59,2440,2442,2444],{"className":2441},[490],[492,2443],{"disabled":494,"type":495}," 持续输出内容",[11,2446,2447],{"id":2447},"致谢",[16,2449,2450,2451,2458],{},"感谢 ",[2452,2453,2457],"a",{"href":2454,"rel":2455},"https:\u002F\u002F2x.nz",[2456],"nofollow","二叉树树"," 的开源项目，本站的 UI 设计参考了他的项目。",[567,2460],{},[16,2462,2463],{},[23,2464,2465],{},"感谢阅读！如果觉得不错，欢迎点个 Star 或者留个评论 (☞ﾟヮﾟ)☞",[1422,2467,2468],{},"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":47,"searchDepth":575,"depth":576,"links":2470},[2471,2472,2473,2474,2475,2476,2477],{"id":2094,"depth":575,"text":2095},{"id":2108,"depth":575,"text":2108},{"id":2181,"depth":575,"text":2181},{"id":2249,"depth":575,"text":2249},{"id":427,"depth":575,"text":427},{"id":2417,"depth":575,"text":2417},{"id":2447,"depth":575,"text":2447},"博客上线啦！这篇文章介绍本站的技术架构、功能特性和搭建过程中的一些思考。","\u002Fposts\u002Fhello-world\u002Fimg\u002Fcover.jpg",{},"\u002Fposts\u002Fhello-world","2026-05-31T08:00:00",{"title":2089,"description":2478},"Nuxt 博客开发","posts\u002Fhello-world\u002Findex",[2487,1994],"博客","2026-05-31","f9MCboCBfQIUxT8aKv6aKGDFxysiG8ZG0YXr5X5LTFo",{"id":2491,"title":2492,"author":6,"body":2493,"description":3668,"difficulty":1437,"draft":598,"extension":599,"image":3669,"meta":3670,"navigation":494,"path":3671,"pinned":598,"published":3672,"seo":3673,"series":2484,"seriesOrder":575,"stem":3674,"tags":3675,"updated":2488,"__hash__":3678},"posts\u002Fposts\u002Fnuxt-content-lessons\u002Findex.md","Nuxt Content v3 踩坑记录：目录、图片与那些反直觉的设计",{"type":8,"value":2494,"toc":3658},[2495,2499,2502,2505,2509,2512,2523,2526,2533,2601,2608,2610,2617,2896,2906,2910,2913,2916,2942,2956,2959,2982,2988,2991,2994,2997,3002,3009,3236,3241,3285,3289,3292,3299,3302,3351,3357,3586,3596,3598,3649,3652,3655],[2496,2497,2498],"h1",{"id":2498},"背景",[16,2500,2501],{},"最近在用 Nuxt 3 + Nuxt Content v3 搭建一个个人博客，参考了一个 SvelteKit 项目（svaf）的架构。原以为换个框架只是语法差异，没想到在几个关键功能上踩了不少坑。",[16,2503,2504],{},"这篇文章记录了三个核心问题的排查过程和最终方案。",[2496,2506,2508],{"id":2507},"问题一目录导航toc只有-h2","问题一：目录导航（TOC）只有 h2",[11,2510,2511],{"id":2511},"现象",[16,2513,2514,2515,2518,2519,2522],{},"Nuxt Content 内置了 TOC 数据，挂在 ",[49,2516,2517],{},"post.body.toc"," 上。直接用它渲染目录，发现",[23,2520,2521],{},"只有 h2 标题，h3 全部丢失","。",[11,2524,2525],{"id":2525},"排查",[16,2527,2528,2529,2532],{},"查了官方文档，发现 ",[49,2530,2531],{},"build.markdown.toc.depth"," 可以控制深度：",[41,2534,2536],{"className":1902,"code":2535,"language":1904,"meta":47,"style":47},"\u002F\u002F nuxt.config.ts\ncontent: {\n  build: {\n    markdown: {\n      toc: { depth: 3 }  \u002F\u002F 包含 h3\n    }\n  }\n}\n",[49,2537,2538,2543,2551,2558,2565,2587,2592,2597],{"__ignoreMap":47},[636,2539,2540],{"class":638,"line":606},[636,2541,2542],{"class":641},"\u002F\u002F nuxt.config.ts\n",[636,2544,2545,2548],{"class":638,"line":575},[636,2546,2547],{"class":647},"content",[636,2549,2550],{"class":668},": {\n",[636,2552,2553,2556],{"class":638,"line":576},[636,2554,2555],{"class":647},"  build",[636,2557,2550],{"class":668},[636,2559,2560,2563],{"class":638,"line":692},[636,2561,2562],{"class":647},"    markdown",[636,2564,2550],{"class":668},[636,2566,2567,2570,2573,2576,2578,2581,2584],{"class":638,"line":698},[636,2568,2569],{"class":647},"      toc",[636,2571,2572],{"class":668},": { ",[636,2574,2575],{"class":647},"depth",[636,2577,2342],{"class":668},[636,2579,2580],{"class":655},"3",[636,2582,2583],{"class":668}," }  ",[636,2585,2586],{"class":641},"\u002F\u002F 包含 h3\n",[636,2588,2589],{"class":638,"line":727},[636,2590,2591],{"class":668},"    }\n",[636,2593,2594],{"class":638,"line":746},[636,2595,2596],{"class":668},"  }\n",[636,2598,2599],{"class":638,"line":762},[636,2600,1951],{"class":668},[16,2602,2603,2604,2607],{},"配置改了，清了缓存重启，",[23,2605,2606],{},"依然只有 h2","。这个配置似乎不起作用。",[11,2609,927],{"id":927},[16,2611,2612,2613,2616],{},"放弃依赖 Nuxt Content 的 toc，直接从 ",[49,2614,2615],{},"post.body"," 的 AST 里递归提取所有标题：",[41,2618,2620],{"className":1902,"code":2619,"language":1904,"meta":47,"style":47},"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",[49,2621,2622,2666,2686,2699,2704,2765,2795,2827,2852,2856,2881,2885,2892],{"__ignoreMap":47},[636,2623,2624,2626,2629,2632,2636,2638,2641,2643,2646,2648,2651,2654,2656,2659,2661,2663],{"class":638,"line":606},[636,2625,1929],{"class":730},[636,2627,2628],{"class":647}," extractHeadings",[636,2630,2631],{"class":668},"(",[636,2633,2635],{"class":2634},"sPK6S","node",[636,2637,1573],{"class":730},[636,2639,2640],{"class":655}," any",[636,2642,1396],{"class":668},[636,2644,2645],{"class":2634},"list",[636,2647,1573],{"class":730},[636,2649,2650],{"class":647}," Heading",[636,2652,2653],{"class":668},"[] ",[636,2655,737],{"class":730},[636,2657,2658],{"class":668}," [])",[636,2660,1573],{"class":730},[636,2662,2650],{"class":647},[636,2664,2665],{"class":668},"[] {\n",[636,2667,2668,2671,2674,2677,2680,2683],{"class":638,"line":575},[636,2669,2670],{"class":730},"  if",[636,2672,2673],{"class":668}," (",[636,2675,2676],{"class":730},"!",[636,2678,2679],{"class":668},"node) ",[636,2681,2682],{"class":730},"return",[636,2684,2685],{"class":668}," list\n",[636,2687,2688,2690,2693,2696],{"class":638,"line":576},[636,2689,2670],{"class":730},[636,2691,2692],{"class":668}," (Array.",[636,2694,2695],{"class":647},"isArray",[636,2697,2698],{"class":668},"(node)) {\n",[636,2700,2701],{"class":638,"line":692},[636,2702,2703],{"class":641},"    \u002F\u002F minimark 格式：[\"h2\", {id: \"xxx\"}, \"文本\"]\n",[636,2705,2706,2709,2711,2714,2717,2720,2723,2726,2729,2732,2735,2738,2742,2745,2748,2751,2754,2757,2760,2762],{"class":638,"line":698},[636,2707,2708],{"class":730},"    if",[636,2710,2673],{"class":668},[636,2712,2713],{"class":730},"typeof",[636,2715,2716],{"class":668}," node[",[636,2718,2719],{"class":655},"0",[636,2721,2722],{"class":668},"] ",[636,2724,2725],{"class":730},"===",[636,2727,2728],{"class":651}," 'string'",[636,2730,2731],{"class":730}," &&",[636,2733,2734],{"class":651}," \u002F",[636,2736,2737],{"class":730},"^",[636,2739,2741],{"class":2740},"sPKmS","h",[636,2743,2744],{"class":655},"[1-6]",[636,2746,2747],{"class":730},"$",[636,2749,2750],{"class":651},"\u002F",[636,2752,2753],{"class":668},".",[636,2755,2756],{"class":647},"test",[636,2758,2759],{"class":668},"(node[",[636,2761,2719],{"class":655},[636,2763,2764],{"class":668},"])) {\n",[636,2766,2767,2770,2773,2775,2778,2780,2782,2785,2788,2790,2792],{"class":638,"line":727},[636,2768,2769],{"class":730},"      const",[636,2771,2772],{"class":655}," level",[636,2774,2275],{"class":730},[636,2776,2777],{"class":647}," Number",[636,2779,2759],{"class":668},[636,2781,2719],{"class":655},[636,2783,2784],{"class":668},"].",[636,2786,2787],{"class":647},"charAt",[636,2789,2631],{"class":668},[636,2791,756],{"class":655},[636,2793,2794],{"class":668},"))\n",[636,2796,2797,2799,2802,2804,2807,2810,2813,2815,2818,2821,2824],{"class":638,"line":746},[636,2798,2769],{"class":730},[636,2800,2801],{"class":655}," text",[636,2803,2275],{"class":730},[636,2805,2806],{"class":647}," extractText",[636,2808,2809],{"class":668},"(node.",[636,2811,2812],{"class":647},"slice",[636,2814,2631],{"class":668},[636,2816,2817],{"class":655},"2",[636,2819,2820],{"class":668},")).",[636,2822,2823],{"class":647},"trim",[636,2825,2826],{"class":668},"()\n",[636,2828,2829,2832,2835,2838,2840,2843,2846,2849],{"class":638,"line":762},[636,2830,2831],{"class":668},"      list.",[636,2833,2834],{"class":647},"push",[636,2836,2837],{"class":668},"({ id: node[",[636,2839,756],{"class":655},[636,2841,2842],{"class":668},"]?.id ",[636,2844,2845],{"class":730},"||",[636,2847,2848],{"class":647}," slugify",[636,2850,2851],{"class":668},"(text), text, level })\n",[636,2853,2854],{"class":638,"line":768},[636,2855,2591],{"class":668},[636,2857,2858,2861,2863,2866,2869,2872,2875,2878],{"class":638,"line":782},[636,2859,2860],{"class":730},"    for",[636,2862,2673],{"class":668},[636,2864,2865],{"class":730},"const",[636,2867,2868],{"class":655}," child",[636,2870,2871],{"class":730}," of",[636,2873,2874],{"class":668}," node) ",[636,2876,2877],{"class":647},"extractHeadings",[636,2879,2880],{"class":668},"(child, list)\n",[636,2882,2883],{"class":638,"line":805},[636,2884,2596],{"class":668},[636,2886,2887,2890],{"class":638,"line":814},[636,2888,2889],{"class":730},"  return",[636,2891,2685],{"class":668},[636,2893,2894],{"class":638,"line":819},[636,2895,1951],{"class":668},[16,2897,2898,2899,2901,2902,2905],{},"关键点：Nuxt Content v3 的 body 使用 ",[23,2900,8],{}," 格式，标题节点是数组 ",[49,2903,2904],{},"[\"h2\", {id: \"xxx\"}, \"标题文本\"]","，不是对象。",[2496,2907,2909],{"id":2908},"问题二图片路径解析错误","问题二：图片路径解析错误",[11,2911,2511],{"id":2912},"现象-1",[16,2914,2915],{},"markdown 里用相对路径引用图片：",[41,2917,2921],{"className":2918,"code":2919,"language":2920,"meta":47,"style":47},"language-markdown shiki shiki-themes github-light github-dark","![SSHFS挂载效果](img\u002Fsshfs.avif)\n","markdown",[49,2922,2923],{"__ignoreMap":47},[636,2924,2925,2928,2932,2935,2939],{"class":638,"line":606},[636,2926,2927],{"class":668},"![",[636,2929,2931],{"class":2930},"smiIW","SSHFS挂载效果",[636,2933,2934],{"class":668},"](",[636,2936,2938],{"class":2937},"szbl0","img\u002Fsshfs.avif",[636,2940,2941],{"class":668},")\n",[16,2943,2944,2945,2948,2949,2952,2953,2522],{},"HTML 输出的 ",[49,2946,2947],{},"\u003Cimg src=\"img\u002Fsshfs.avif\">"," 看起来没问题，但浏览器请求的却是 ",[49,2950,2951],{},"\u002Fposts\u002Fimg\u002Fsshfs.avif"," 而不是 ",[49,2954,2955],{},"\u002Fposts\u002Fsshfs\u002Fimg\u002Fsshfs.avif",[11,2957,2525],{"id":2958},"排查-1",[883,2960,2961,2968,2971,2977],{},[59,2962,2963,2964,2967],{},"检查了 ",[49,2965,2966],{},"\u003Cbase>"," 标签 —— 没有",[59,2969,2970],{},"检查了重定向 —— 没有",[59,2972,2973,2974,2976],{},"直接访问 ",[49,2975,2955],{}," —— 返回 200 OK",[59,2978,2973,2979,2981],{},[49,2980,2951],{}," —— 404",[16,2983,2984,2985,2522],{},"服务器路由没问题，问题是",[23,2986,2987],{},"浏览器解析相对路径时基于了错误的基准 URL",[16,2989,2990],{},"这可能和 Nuxt 的客户端路由有关：SPA 导航时，浏览器的\"当前页面\"和 Vue Router 的\"当前路由\"不同步，导致相对路径解析出错。",[11,2992,927],{"id":2993},"最终方案-1",[16,2995,2996],{},"两步解决：",[16,2998,2999],{},[23,3000,3001],{},"第一步：自定义 ProseImg 组件",[16,3003,3004,3005,3008],{},"创建 ",[49,3006,3007],{},"components\u002Fcontent\u002FProseImg.vue","，覆盖 Nuxt Content 默认的图片渲染：",[41,3010,3014],{"className":3011,"code":3012,"language":3013,"meta":47,"style":47},"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",[49,3015,3016,3028,3042,3062,3078,3116,3151,3175,3180,3189,3193,3202,3228],{"__ignoreMap":47},[636,3017,3018,3020,3023,3026],{"class":638,"line":606},[636,3019,1576],{"class":668},[636,3021,3022],{"class":2338},"script",[636,3024,3025],{"class":647}," setup",[636,3027,1233],{"class":668},[636,3029,3030,3032,3035,3037,3040],{"class":638,"line":575},[636,3031,2865],{"class":730},[636,3033,3034],{"class":655}," route",[636,3036,2275],{"class":730},[636,3038,3039],{"class":647}," useRoute",[636,3041,2826],{"class":668},[636,3043,3044,3046,3049,3051,3054,3057,3060],{"class":638,"line":576},[636,3045,2865],{"class":730},[636,3047,3048],{"class":655}," resolvedSrc",[636,3050,2275],{"class":730},[636,3052,3053],{"class":647}," computed",[636,3055,3056],{"class":668},"(() ",[636,3058,3059],{"class":730},"=>",[636,3061,2278],{"class":668},[636,3063,3064,3066,3068,3070,3073,3075],{"class":638,"line":692},[636,3065,2670],{"class":730},[636,3067,2673],{"class":668},[636,3069,2676],{"class":730},[636,3071,3072],{"class":668},"props.src) ",[636,3074,2682],{"class":730},[636,3076,3077],{"class":651}," ''\n",[636,3079,3080,3082,3085,3088,3090,3093,3096,3098,3101,3103,3105,3108,3111,3113],{"class":638,"line":698},[636,3081,2670],{"class":730},[636,3083,3084],{"class":668}," (props.src.",[636,3086,3087],{"class":647},"startsWith",[636,3089,2631],{"class":668},[636,3091,3092],{"class":651},"'\u002F'",[636,3094,3095],{"class":668},") ",[636,3097,2845],{"class":730},[636,3099,3100],{"class":668}," props.src.",[636,3102,3087],{"class":647},[636,3104,2631],{"class":668},[636,3106,3107],{"class":651},"'http'",[636,3109,3110],{"class":668},")) ",[636,3112,2682],{"class":730},[636,3114,3115],{"class":668}," props.src\n",[636,3117,3118,3121,3124,3126,3129,3132,3134,3136,3140,3142,3144,3146,3149],{"class":638,"line":727},[636,3119,3120],{"class":730},"  const",[636,3122,3123],{"class":655}," base",[636,3125,2275],{"class":730},[636,3127,3128],{"class":668}," route.path.",[636,3130,3131],{"class":647},"replace",[636,3133,2631],{"class":668},[636,3135,2750],{"class":651},[636,3137,3139],{"class":3138},"sCnZR","\\\u002F",[636,3141,2747],{"class":730},[636,3143,2750],{"class":651},[636,3145,1396],{"class":668},[636,3147,3148],{"class":651},"''",[636,3150,2941],{"class":668},[636,3152,3153,3155,3158,3161,3164,3167,3169,3172],{"class":638,"line":746},[636,3154,2889],{"class":730},[636,3156,3157],{"class":651}," `${",[636,3159,3160],{"class":668},"base",[636,3162,3163],{"class":651},"}\u002F${",[636,3165,3166],{"class":668},"props",[636,3168,2753],{"class":651},[636,3170,3171],{"class":668},"src",[636,3173,3174],{"class":651},"}`\n",[636,3176,3177],{"class":638,"line":762},[636,3178,3179],{"class":668},"})\n",[636,3181,3182,3185,3187],{"class":638,"line":768},[636,3183,3184],{"class":668},"\u003C\u002F",[636,3186,3022],{"class":2338},[636,3188,1233],{"class":668},[636,3190,3191],{"class":638,"line":782},[636,3192,689],{"emptyLinePlaceholder":494},[636,3194,3195,3197,3200],{"class":638,"line":805},[636,3196,1576],{"class":668},[636,3198,3199],{"class":2338},"template",[636,3201,1233],{"class":668},[636,3203,3204,3207,3209,3212,3214,3217,3220,3222,3225],{"class":638,"line":814},[636,3205,3206],{"class":668},"  \u003C",[636,3208,907],{"class":2338},[636,3210,3211],{"class":647}," :src",[636,3213,737],{"class":668},[636,3215,3216],{"class":651},"\"resolvedSrc\"",[636,3218,3219],{"class":647}," :alt",[636,3221,737],{"class":668},[636,3223,3224],{"class":651},"\"alt\"",[636,3226,3227],{"class":668}," \u002F>\n",[636,3229,3230,3232,3234],{"class":638,"line":819},[636,3231,3184],{"class":668},[636,3233,3199],{"class":2338},[636,3235,1233],{"class":668},[16,3237,3238],{},[23,3239,3240],{},"第二步：启用 MDC prose 组件",[41,3242,3244],{"className":1902,"code":3243,"language":1904,"meta":47,"style":47},"\u002F\u002F nuxt.config.ts\nmdc: {\n  components: {\n    prose: true  \u002F\u002F 让 Nuxt Content 使用自定义 ProseImg\n  }\n}\n",[49,3245,3246,3250,3257,3264,3277,3281],{"__ignoreMap":47},[636,3247,3248],{"class":638,"line":606},[636,3249,2542],{"class":641},[636,3251,3252,3255],{"class":638,"line":575},[636,3253,3254],{"class":647},"mdc",[636,3256,2550],{"class":668},[636,3258,3259,3262],{"class":638,"line":576},[636,3260,3261],{"class":647},"  components",[636,3263,2550],{"class":668},[636,3265,3266,3269,3271,3274],{"class":638,"line":692},[636,3267,3268],{"class":647},"    prose",[636,3270,2342],{"class":668},[636,3272,3273],{"class":655},"true",[636,3275,3276],{"class":641},"  \u002F\u002F 让 Nuxt Content 使用自定义 ProseImg\n",[636,3278,3279],{"class":638,"line":698},[636,3280,2596],{"class":668},[636,3282,3283],{"class":638,"line":727},[636,3284,1951],{"class":668},[2496,3286,3288],{"id":3287},"问题三content-目录的图片如何服务","问题三：content 目录的图片如何服务",[11,3290,2511],{"id":3291},"现象-2",[16,3293,3294,3295,3298],{},"原站（SvelteKit）的图片放在 ",[49,3296,3297],{},"content\u002Fposts\u002F{slug}\u002Fimg\u002F"," 下，markdown 用相对路径引用，一切正常。Nuxt 项目里，content 目录的文件不会被当成静态资源服务。",[11,3300,3301],{"id":3301},"方案对比",[121,3303,3304,3316],{},[124,3305,3306],{},[127,3307,3308,3311,3313],{},[130,3309,3310],{},"方案",[130,3312,97],{},[130,3314,3315],{},"缺点",[137,3317,3318,3329,3340],{},[127,3319,3320,3323,3326],{},[142,3321,3322],{},"复制到 public\u002F",[142,3324,3325],{},"简单",[142,3327,3328],{},"文件重复，需同步",[127,3330,3331,3334,3337],{},[142,3332,3333],{},"Vite 插件拦截",[142,3335,3336],{},"原站方案",[142,3338,3339],{},"Nuxt 不直接支持",[127,3341,3342,3345,3348],{},[142,3343,3344],{},"Server Route",[142,3346,3347],{},"不复制文件",[142,3349,3350],{},"需要手动写路由",[16,3352,3353,3354,3356],{},"最终选择 ",[23,3355,3344],{},"，和原站的 Vite 插件思路一致：",[41,3358,3360],{"className":1902,"code":3359,"language":1904,"meta":47,"style":47},"\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",[49,3361,3362,3367,3393,3408,3472,3486,3490,3512,3538,3556,3575,3582],{"__ignoreMap":47},[636,3363,3364],{"class":638,"line":606},[636,3365,3366],{"class":641},"\u002F\u002F server\u002Froutes\u002Fposts\u002F[slug]\u002Fimg\u002F[...file].ts\n",[636,3368,3369,3371,3374,3377,3379,3382,3384,3387,3389,3391],{"class":638,"line":575},[636,3370,731],{"class":730},[636,3372,3373],{"class":730}," default",[636,3375,3376],{"class":647}," defineEventHandler",[636,3378,2631],{"class":668},[636,3380,3381],{"class":730},"async",[636,3383,2673],{"class":668},[636,3385,3386],{"class":2634},"event",[636,3388,3095],{"class":668},[636,3390,3059],{"class":730},[636,3392,2278],{"class":668},[636,3394,3395,3397,3400,3402,3405],{"class":638,"line":576},[636,3396,3120],{"class":730},[636,3398,3399],{"class":655}," url",[636,3401,2275],{"class":730},[636,3403,3404],{"class":647}," getRequestURL",[636,3406,3407],{"class":668},"(event)\n",[636,3409,3410,3412,3415,3417,3420,3423,3425,3427,3429,3431,3434,3436,3438,3441,3443,3446,3449,3452,3454,3456,3458,3460,3462,3464,3466,3468,3470],{"class":638,"line":692},[636,3411,3120],{"class":730},[636,3413,3414],{"class":655}," match",[636,3416,2275],{"class":730},[636,3418,3419],{"class":668}," url.pathname.",[636,3421,3422],{"class":647},"match",[636,3424,2631],{"class":668},[636,3426,2750],{"class":651},[636,3428,2737],{"class":730},[636,3430,3139],{"class":3138},[636,3432,3433],{"class":2740},"posts",[636,3435,3139],{"class":3138},[636,3437,2631],{"class":2740},[636,3439,3440],{"class":655},"[",[636,3442,2737],{"class":730},[636,3444,3445],{"class":655},"\u002F]",[636,3447,3448],{"class":730},"+",[636,3450,3451],{"class":2740},")",[636,3453,3139],{"class":3138},[636,3455,907],{"class":2740},[636,3457,3139],{"class":3138},[636,3459,2631],{"class":2740},[636,3461,2753],{"class":655},[636,3463,3448],{"class":730},[636,3465,3451],{"class":2740},[636,3467,2747],{"class":730},[636,3469,2750],{"class":651},[636,3471,2941],{"class":668},[636,3473,3474,3476,3478,3480,3483],{"class":638,"line":698},[636,3475,2670],{"class":730},[636,3477,2673],{"class":668},[636,3479,2676],{"class":730},[636,3481,3482],{"class":668},"match) ",[636,3484,3485],{"class":730},"return\n",[636,3487,3488],{"class":638,"line":727},[636,3489,689],{"emptyLinePlaceholder":494},[636,3491,3492,3494,3497,3500,3502,3505,3507,3509],{"class":638,"line":746},[636,3493,3120],{"class":730},[636,3495,3496],{"class":668}," [, ",[636,3498,3499],{"class":655},"slug",[636,3501,1396],{"class":668},[636,3503,3504],{"class":655},"filename",[636,3506,2722],{"class":668},[636,3508,737],{"class":730},[636,3510,3511],{"class":668}," match\n",[636,3513,3514,3516,3519,3521,3524,3526,3529,3532,3535],{"class":638,"line":762},[636,3515,3120],{"class":730},[636,3517,3518],{"class":655}," filePath",[636,3520,2275],{"class":730},[636,3522,3523],{"class":647}," resolve",[636,3525,2631],{"class":668},[636,3527,3528],{"class":651},"'content\u002Fposts'",[636,3530,3531],{"class":668},", slug, ",[636,3533,3534],{"class":651},"'img'",[636,3536,3537],{"class":668},", filename)\n",[636,3539,3540,3542,3545,3547,3550,3553],{"class":638,"line":768},[636,3541,3120],{"class":730},[636,3543,3544],{"class":655}," data",[636,3546,2275],{"class":730},[636,3548,3549],{"class":730}," await",[636,3551,3552],{"class":647}," readFile",[636,3554,3555],{"class":668},"(filePath)\n",[636,3557,3558,3561,3564,3567,3569,3572],{"class":638,"line":782},[636,3559,3560],{"class":647},"  setResponseHeader",[636,3562,3563],{"class":668},"(event, ",[636,3565,3566],{"class":651},"'content-type'",[636,3568,1396],{"class":668},[636,3570,3571],{"class":655},"MIME",[636,3573,3574],{"class":668},"[ext])\n",[636,3576,3577,3579],{"class":638,"line":805},[636,3578,2889],{"class":730},[636,3580,3581],{"class":668}," data\n",[636,3583,3584],{"class":638,"line":814},[636,3585,3179],{"class":668},[16,3587,3588,3589,3591,3592,3595],{},"请求 ",[49,3590,2955],{}," → 读取 ",[49,3593,3594],{},"content\u002Fposts\u002Fsshfs\u002Fimg\u002Fsshfs.avif"," → 返回图片。",[2496,3597,1809],{"id":1809},[121,3599,3600,3611],{},[124,3601,3602],{},[127,3603,3604,3606,3609],{},[130,3605,132],{},[130,3607,3608],{},"根因",[130,3610,3310],{},[137,3612,3613,3627,3638],{},[127,3614,3615,3618,3624],{},[142,3616,3617],{},"TOC 只有 h2",[142,3619,3620,3623],{},[49,3621,3622],{},"toc.depth"," 配置不生效",[142,3625,3626],{},"从 body AST 手动提取",[127,3628,3629,3632,3635],{},[142,3630,3631],{},"图片路径错误",[142,3633,3634],{},"SPA 路由导致相对路径解析基准错误",[142,3636,3637],{},"ProseImg 组件 + mdc.prose 配置",[127,3639,3640,3643,3646],{},[142,3641,3642],{},"content 图片 404",[142,3644,3645],{},"content 目录不暴露给浏览器",[142,3647,3648],{},"Server Route 直接读取返回",[16,3650,3651],{},"Nuxt Content v3 的设计理念是\"图片放 public\u002F，用绝对路径\"。如果想像 SvelteKit 项目那样把图片和文章放一起，需要额外做不少工作。",[16,3653,3654],{},"不过一旦这些坑都踩过，整体开发体验还是很流畅的。Nuxt 的生态系统、自动导入、模块系统都比 SvelteKit 成熟很多。",[1422,3656,3657],{},"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":47,"searchDepth":575,"depth":576,"links":3659},[3660,3661,3662,3663,3664,3665,3666,3667],{"id":2511,"depth":575,"text":2511},{"id":2525,"depth":575,"text":2525},{"id":927,"depth":575,"text":927},{"id":2912,"depth":575,"text":2511},{"id":2958,"depth":575,"text":2525},{"id":2993,"depth":575,"text":927},{"id":3291,"depth":575,"text":2511},{"id":3301,"depth":575,"text":3301},"从 SvelteKit 迁移到 Nuxt 3 的过程中，遇到了不少 Nuxt Content v3 的坑。这篇文章记录了目录导航、图片路径、ProseImg 组件等问题的排查和解决过程。","\u002Fposts\u002Fnuxt-content-lessons\u002Fimg\u002Fcover.jpg",{},"\u002Fposts\u002Fnuxt-content-lessons","2026-05-31T14:00:00",{"title":2492,"description":3668},"posts\u002Fnuxt-content-lessons\u002Findex",[1994,3676,3677],"踩坑","Nuxt Content","vbSt6ND1z7xt7L8FxgZ8Qe3EdxV9InsRerN3E23GC7s",{"id":3680,"title":3681,"author":2101,"body":3682,"description":3946,"difficulty":1437,"draft":598,"extension":599,"image":3947,"meta":3948,"navigation":494,"path":3949,"pinned":598,"published":3950,"seo":3951,"series":2484,"seriesOrder":576,"stem":3952,"tags":3953,"updated":2488,"__hash__":3956},"posts\u002Fposts\u002Fnuxt-tailwind-underline-fix\u002Findex.md","Nuxt 3 + Tailwind CSS 客户端导航链接下划线闪烁问题",{"type":8,"value":3683,"toc":3939},[3684,3687,3690,3705,3708,3711,3714,3755,3766,3772,3794,3801,3805,3808,3811,3813,3816,3839,3850,3907,3912,3925,3927,3933,3936],[11,3685,3686],{"id":3686},"问题描述",[16,3688,3689],{},"在 Nuxt 3 项目中使用 Tailwind CSS 时，发现一个奇怪的现象：",[56,3691,3692,3702],{},[59,3693,3694,3695,3698,3699],{},"首次通过客户端导航（点击 ",[49,3696,3697],{},"\u003CNuxtLink>","）跳转到某个页面时，",[23,3700,3701],{},"所有链接文字都会出现下划线",[59,3703,3704],{},"手动刷新页面后，下划线消失，样式恢复正常",[16,3706,3707],{},"这个问题在开发模式和生产构建后都会出现。",[11,3709,3710],{"id":3710},"原因分析",[16,3712,3713],{},"Tailwind CSS 的 Preflight（基于 modern-normalize）会重置链接样式：",[41,3715,3719],{"className":3716,"code":3717,"language":3718,"meta":47,"style":47},"language-css shiki shiki-themes github-light github-dark","a {\n  color: inherit;\n  text-decoration: inherit;\n}\n","css",[49,3720,3721,3727,3740,3751],{"__ignoreMap":47},[636,3722,3723,3725],{"class":638,"line":606},[636,3724,2452],{"class":2338},[636,3726,2278],{"class":668},[636,3728,3729,3732,3734,3737],{"class":638,"line":575},[636,3730,3731],{"class":655},"  color",[636,3733,2342],{"class":668},[636,3735,3736],{"class":655},"inherit",[636,3738,3739],{"class":668},";\n",[636,3741,3742,3745,3747,3749],{"class":638,"line":576},[636,3743,3744],{"class":655},"  text-decoration",[636,3746,2342],{"class":668},[636,3748,3736],{"class":655},[636,3750,3739],{"class":668},[636,3752,3753],{"class":638,"line":692},[636,3754,1951],{"class":668},[16,3756,3757,3758,3761,3762,3765],{},"正常情况下，这会让链接继承父元素的 ",[49,3759,3760],{},"text-decoration","（通常是 ",[49,3763,3764],{},"none","），从而去掉浏览器默认的下划线。",[16,3767,3768,3769,469],{},"问题出在 ",[23,3770,3771],{},"Nuxt 的客户端导航机制",[883,3773,3774,3780,3783,3788,3791],{},[59,3775,3776,3777,3779],{},"用户点击 ",[49,3778,3697],{},"，Vue Router 拦截导航",[59,3781,3782],{},"新页面组件异步加载并渲染",[59,3784,3785],{},[23,3786,3787],{},"在 CSS 完全加载\u002F应用之前，页面已经渲染了",[59,3789,3790],{},"此时浏览器使用默认样式（链接带下划线）",[59,3792,3793],{},"CSS 加载完成后，下划线消失",[16,3795,3796,3797,3800],{},"这就是典型的 ",[23,3798,3799],{},"FOUC（Flash of Unstyled Content）"," 问题。",[11,3802,3804],{"id":3803},"为什么刷新就正常了","为什么刷新就正常了？",[16,3806,3807],{},"刷新页面时，浏览器会等待所有 CSS 加载完成后再渲染页面（SSR 模式下 HTML 和 CSS 是一起返回的）。所以刷新后样式是正确的。",[16,3809,3810],{},"而客户端导航是 JavaScript 驱动的，CSS 的加载和页面的渲染是异步的，就出现了时序差。",[11,3812,1462],{"id":1462},[16,3814,3815],{},"尝试了多种方案：",[56,3817,3818,3824,3830,3836],{},[59,3819,3820,3823],{},[49,3821,3822],{},"@layer base"," 覆盖 — 无效，CSS layers 的优先级问题",[59,3825,3826,3829],{},[49,3827,3828],{},"corePlugins.preflight: false"," — 无效，且破坏其他基础样式",[59,3831,3832,3835],{},[49,3833,3834],{},"features.inlineStyles: true"," — 无效",[59,3837,3838],{},"页面过渡动画 — 只是掩盖，不能根治",[16,3840,3841,3842,3845,3846,3849],{},"最终有效的方案是在 ",[49,3843,3844],{},"app.vue"," 中用 ",[49,3847,3848],{},"!important"," 强制覆盖：",[41,3851,3853],{"className":3011,"code":3852,"language":3013,"meta":47,"style":47},"\u003Cstyle>\na:not(.prose a) {\n  text-decoration-line: none !important;\n}\n\u003C\u002Fstyle>\n",[49,3854,3855,3863,3881,3895,3899],{"__ignoreMap":47},[636,3856,3857,3859,3861],{"class":638,"line":606},[636,3858,1576],{"class":668},[636,3860,1422],{"class":2338},[636,3862,1233],{"class":668},[636,3864,3865,3867,3870,3872,3875,3878],{"class":638,"line":575},[636,3866,2452],{"class":2338},[636,3868,3869],{"class":647},":not",[636,3871,2631],{"class":668},[636,3873,3874],{"class":647},".prose",[636,3876,3877],{"class":2338}," a",[636,3879,3880],{"class":668},") {\n",[636,3882,3883,3886,3888,3890,3893],{"class":638,"line":576},[636,3884,3885],{"class":655},"  text-decoration-line",[636,3887,2342],{"class":668},[636,3889,3764],{"class":655},[636,3891,3892],{"class":730}," !important",[636,3894,3739],{"class":668},[636,3896,3897],{"class":638,"line":692},[636,3898,1951],{"class":668},[636,3900,3901,3903,3905],{"class":638,"line":698},[636,3902,3184],{"class":668},[636,3904,1422],{"class":2338},[636,3906,1233],{"class":668},[16,3908,3909],{},[23,3910,3911],{},"关键点：",[56,3913,3914,3919],{},[59,3915,3916,3918],{},[49,3917,3848],{}," 是为了对抗 Tailwind CSS layers 的优先级",[59,3920,3921,3924],{},[49,3922,3923],{},":not(.prose a)"," 排除文章正文区域，保留文章内链接的下划线（可读性需要）",[11,3926,1809],{"id":1809},[16,3928,3929,3930,3932],{},"这是 Nuxt 3 + Tailwind CSS 的一个已知问题，本质上是 CSS 加载时序导致的 FOUC。",[49,3931,3848],{}," 虽然不够优雅，但在这个场景下是务实的解决方案。",[16,3934,3935],{},"如果你有更好的方案，欢迎在项目仓库的 Issues 中提出！",[1422,3937,3938],{},"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":47,"searchDepth":575,"depth":576,"links":3940},[3941,3942,3943,3944,3945],{"id":3686,"depth":575,"text":3686},{"id":3710,"depth":575,"text":3710},{"id":3803,"depth":575,"text":3804},{"id":1462,"depth":575,"text":1462},{"id":1809,"depth":575,"text":1809},"记录 Nuxt 3 配合 Tailwind CSS 时，客户端导航导致链接文字出现下划线闪烁的问题及解决方案。","\u002Fposts\u002Fnuxt-tailwind-underline-fix\u002Fimg\u002Fcover.png",{},"\u002Fposts\u002Fnuxt-tailwind-underline-fix","2026-05-31T18:47:00",{"title":3681,"description":3946},"posts\u002Fnuxt-tailwind-underline-fix\u002Findex",[1994,3954,3955,3676],"Tailwind CSS","CSS","WMLpTEsIoJqufwEfXyIiBgUYxcPY2XkvOwqAD4hbe6g",{"id":3958,"title":3959,"author":6,"body":3960,"description":6011,"difficulty":1437,"draft":598,"extension":599,"image":6012,"meta":6013,"navigation":494,"path":6014,"pinned":598,"published":6015,"seo":6016,"series":6017,"seriesOrder":606,"stem":6018,"tags":6019,"updated":6022,"__hash__":6023},"posts\u002Fposts\u002Fgo-mcp-gin\u002Findex.md","Go-MCP 接入 Gin：从零搭建 MCP 服务器",{"type":8,"value":3961,"toc":5992},[3962,3966,3969,3972,3976,3983,3989,3992,3995,4015,4018,4022,4046,4050,4150,4163,4167,4331,4334,4551,4555,4566,4665,4672,4676,4950,4953,5034,5038,5041,5043,5054,5058,5064,5431,5621,5624,5680,5683,5836,5839,5842,5913,5915,5980,5989],[11,3963,3965],{"id":3964},"什么是-mcp","什么是 MCP",[16,3967,3968],{},"MCP（Model Context Protocol）是 Anthropic 提出的开放协议，用于让 AI 助手（如 Claude）与外部服务进行标准化交互。简单来说：你提供一个 MCP 服务器，AI 就能发现并调用你定义的工具（Tools），就像给 AI 装了一双手。",[16,3970,3971],{},"传统做法是写 REST API 然后让 AI 通过函数调用（Function Calling）间接使用，但 MCP 统一了协议层，AI 客户端只需要配置一个 URL 就能接入所有工具。",[11,3973,3975],{"id":3974},"为什么选-gin-mcp-go","为什么选 Gin + mcp-go",[16,3977,3978,3979,3982],{},"Go 生态里做 MCP 服务器最成熟的库是 ",[49,3980,3981],{},"mark3labs\u002Fmcp-go","，它支持 StreamableHTTP 传输模式，可以直接挂载到任意 HTTP 框架上。Gin 作为 Go 最流行的 HTTP 框架，天然适配。",[16,3984,3985,3986],{},"核心思路：",[23,3987,3988],{},"MCP 服务器本质上就是一个 HTTP Handler，挂到 Gin 路由上即可。",[11,3990,3991],{"id":3991},"需求分析",[16,3993,3994],{},"我们要搭建一个具备以下能力的 MCP 服务器：",[883,3996,3997,4003,4009],{},[59,3998,3999,4002],{},[23,4000,4001],{},"工具注册"," — 向 AI 暴露可调用的工具（列出资源、查看详情、创建\u002F删除）",[59,4004,4005,4008],{},[23,4006,4007],{},"HTTP 集成"," — MCP 端点作为 Gin 路由的一部分，与业务 API 共存",[59,4010,4011,4014],{},[23,4012,4013],{},"鉴权"," — MCP 请求必须经过身份验证，工具处理器能获取当前用户信息",[11,4016,4017],{"id":4017},"实现步骤",[36,4019,4021],{"id":4020},"_1-安装依赖","1. 安装依赖",[41,4023,4025],{"className":630,"code":4024,"language":632,"meta":47,"style":47},"go get github.com\u002Fmark3labs\u002Fmcp-go@v0.54.0\ngo get github.com\u002Fgin-gonic\u002Fgin@v1.12.0\n",[49,4026,4027,4037],{"__ignoreMap":47},[636,4028,4029,4031,4034],{"class":638,"line":606},[636,4030,771],{"class":647},[636,4032,4033],{"class":651}," get",[636,4035,4036],{"class":651}," github.com\u002Fmark3labs\u002Fmcp-go@v0.54.0\n",[636,4038,4039,4041,4043],{"class":638,"line":575},[636,4040,771],{"class":647},[636,4042,4033],{"class":651},[636,4044,4045],{"class":651}," github.com\u002Fgin-gonic\u002Fgin@v1.12.0\n",[36,4047,4049],{"id":4048},"_2-创建-mcp-服务器","2. 创建 MCP 服务器",[41,4051,4054],{"className":4052,"code":4053,"language":771,"meta":47,"style":47},"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",[49,4055,4056,4068,4072,4089,4100,4111,4122,4131,4146],{"__ignoreMap":47},[636,4057,4058,4061,4063,4066],{"class":638,"line":606},[636,4059,4060],{"class":730},"import",[636,4062,665],{"class":651},[636,4064,4065],{"class":647},"github.com\u002Fmark3labs\u002Fmcp-go\u002Fserver",[636,4067,878],{"class":651},[636,4069,4070],{"class":638,"line":575},[636,4071,689],{"emptyLinePlaceholder":494},[636,4073,4074,4077,4080,4083,4086],{"class":638,"line":576},[636,4075,4076],{"class":668},"mcpServer ",[636,4078,4079],{"class":730},":=",[636,4081,4082],{"class":668}," server.",[636,4084,4085],{"class":647},"NewMCPServer",[636,4087,4088],{"class":668},"(\n",[636,4090,4091,4094,4097],{"class":638,"line":692},[636,4092,4093],{"class":651},"    \"my-app\"",[636,4095,4096],{"class":668},",    ",[636,4098,4099],{"class":641},"\u002F\u002F 服务器名称\n",[636,4101,4102,4105,4108],{"class":638,"line":698},[636,4103,4104],{"class":651},"    \"1.0.0\"",[636,4106,4107],{"class":668},",     ",[636,4109,4110],{"class":641},"\u002F\u002F 版本\n",[636,4112,4113,4116,4119],{"class":638,"line":727},[636,4114,4115],{"class":668},"    server.",[636,4117,4118],{"class":647},"WithLogging",[636,4120,4121],{"class":668},"(),\n",[636,4123,4124,4126,4129],{"class":638,"line":746},[636,4125,4115],{"class":668},[636,4127,4128],{"class":647},"WithRecovery",[636,4130,4121],{"class":668},[636,4132,4133,4135,4138,4140,4143],{"class":638,"line":762},[636,4134,4115],{"class":668},[636,4136,4137],{"class":647},"WithInstructions",[636,4139,2631],{"class":668},[636,4141,4142],{"class":651},"\"这是一个 MCP 服务器示例\"",[636,4144,4145],{"class":668},"),\n",[636,4147,4148],{"class":638,"line":768},[636,4149,2941],{"class":668},[16,4151,4152,4154,4155,4158,4159,4162],{},[49,4153,4085],{}," 创建一个 MCP 协议实例，它负责处理工具发现（",[49,4156,4157],{},"tools\u002Flist","）和工具调用（",[49,4160,4161],{},"tools\u002Fcall","）的协议逻辑。",[36,4164,4166],{"id":4165},"_3-注册工具","3. 注册工具",[41,4168,4170],{"className":4052,"code":4169,"language":771,"meta":47,"style":47},"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",[49,4171,4172,4183,4187,4192,4212,4227,4231,4235,4240,4301,4306,4327],{"__ignoreMap":47},[636,4173,4174,4176,4178,4181],{"class":638,"line":606},[636,4175,4060],{"class":730},[636,4177,665],{"class":651},[636,4179,4180],{"class":647},"github.com\u002Fmark3labs\u002Fmcp-go\u002Fmcp",[636,4182,878],{"class":651},[636,4184,4185],{"class":638,"line":575},[636,4186,689],{"emptyLinePlaceholder":494},[636,4188,4189],{"class":638,"line":576},[636,4190,4191],{"class":641},"\u002F\u002F 定义工具\n",[636,4193,4194,4197,4199,4202,4205,4207,4210],{"class":638,"line":692},[636,4195,4196],{"class":668},"listTool ",[636,4198,4079],{"class":730},[636,4200,4201],{"class":668}," mcp.",[636,4203,4204],{"class":647},"NewTool",[636,4206,2631],{"class":668},[636,4208,4209],{"class":651},"\"list_items\"",[636,4211,2289],{"class":668},[636,4213,4214,4217,4220,4222,4225],{"class":638,"line":698},[636,4215,4216],{"class":668},"    mcp.",[636,4218,4219],{"class":647},"WithDescription",[636,4221,2631],{"class":668},[636,4223,4224],{"class":651},"\"列出所有项目\"",[636,4226,4145],{"class":668},[636,4228,4229],{"class":638,"line":727},[636,4230,2941],{"class":668},[636,4232,4233],{"class":638,"line":746},[636,4234,689],{"emptyLinePlaceholder":494},[636,4236,4237],{"class":638,"line":762},[636,4238,4239],{"class":641},"\u002F\u002F 注册处理器\n",[636,4241,4242,4245,4248,4251,4254,4256,4259,4262,4264,4267,4269,4272,4275,4277,4280,4283,4286,4289,4291,4294,4296,4299],{"class":638,"line":768},[636,4243,4244],{"class":668},"mcpServer.",[636,4246,4247],{"class":647},"AddTool",[636,4249,4250],{"class":668},"(listTool, ",[636,4252,4253],{"class":730},"func",[636,4255,2631],{"class":668},[636,4257,4258],{"class":2634},"ctx",[636,4260,4261],{"class":647}," context",[636,4263,2753],{"class":668},[636,4265,4266],{"class":647},"Context",[636,4268,1396],{"class":668},[636,4270,4271],{"class":2634},"req",[636,4273,4274],{"class":647}," mcp",[636,4276,2753],{"class":668},[636,4278,4279],{"class":647},"CallToolRequest",[636,4281,4282],{"class":668},") (",[636,4284,4285],{"class":730},"*",[636,4287,4288],{"class":647},"mcp",[636,4290,2753],{"class":668},[636,4292,4293],{"class":647},"CallToolResult",[636,4295,1396],{"class":668},[636,4297,4298],{"class":730},"error",[636,4300,3880],{"class":668},[636,4302,4303],{"class":638,"line":782},[636,4304,4305],{"class":641},"    \u002F\u002F 业务逻辑\n",[636,4307,4308,4311,4313,4316,4318,4321,4324],{"class":638,"line":805},[636,4309,4310],{"class":730},"    return",[636,4312,4201],{"class":668},[636,4314,4315],{"class":647},"NewToolResultText",[636,4317,2631],{"class":668},[636,4319,4320],{"class":651},"\"返回结果\"",[636,4322,4323],{"class":668},"), ",[636,4325,4326],{"class":655},"nil\n",[636,4328,4329],{"class":638,"line":814},[636,4330,3179],{"class":668},[16,4332,4333],{},"带参数的工具：",[41,4335,4337],{"className":4052,"code":4336,"language":771,"meta":47,"style":47},"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",[49,4338,4339,4357,4370,4384,4394,4408,4413,4417,4421,4468,4487,4502,4521,4525,4530,4547],{"__ignoreMap":47},[636,4340,4341,4344,4346,4348,4350,4352,4355],{"class":638,"line":606},[636,4342,4343],{"class":668},"getTool ",[636,4345,4079],{"class":730},[636,4347,4201],{"class":668},[636,4349,4204],{"class":647},[636,4351,2631],{"class":668},[636,4353,4354],{"class":651},"\"get_item\"",[636,4356,2289],{"class":668},[636,4358,4359,4361,4363,4365,4368],{"class":638,"line":575},[636,4360,4216],{"class":668},[636,4362,4219],{"class":647},[636,4364,2631],{"class":668},[636,4366,4367],{"class":651},"\"获取指定项目的详情\"",[636,4369,4145],{"class":668},[636,4371,4372,4374,4377,4379,4382],{"class":638,"line":576},[636,4373,4216],{"class":668},[636,4375,4376],{"class":647},"WithString",[636,4378,2631],{"class":668},[636,4380,4381],{"class":651},"\"item_id\"",[636,4383,2289],{"class":668},[636,4385,4386,4389,4392],{"class":638,"line":692},[636,4387,4388],{"class":668},"        mcp.",[636,4390,4391],{"class":647},"Required",[636,4393,4121],{"class":668},[636,4395,4396,4398,4401,4403,4406],{"class":638,"line":698},[636,4397,4388],{"class":668},[636,4399,4400],{"class":647},"Description",[636,4402,2631],{"class":668},[636,4404,4405],{"class":651},"\"项目 ID\"",[636,4407,4145],{"class":668},[636,4409,4410],{"class":638,"line":727},[636,4411,4412],{"class":668},"    ),\n",[636,4414,4415],{"class":638,"line":746},[636,4416,2941],{"class":668},[636,4418,4419],{"class":638,"line":762},[636,4420,689],{"emptyLinePlaceholder":494},[636,4422,4423,4425,4427,4430,4432,4434,4436,4438,4440,4442,4444,4446,4448,4450,4452,4454,4456,4458,4460,4462,4464,4466],{"class":638,"line":768},[636,4424,4244],{"class":668},[636,4426,4247],{"class":647},[636,4428,4429],{"class":668},"(getTool, ",[636,4431,4253],{"class":730},[636,4433,2631],{"class":668},[636,4435,4258],{"class":2634},[636,4437,4261],{"class":647},[636,4439,2753],{"class":668},[636,4441,4266],{"class":647},[636,4443,1396],{"class":668},[636,4445,4271],{"class":2634},[636,4447,4274],{"class":647},[636,4449,2753],{"class":668},[636,4451,4279],{"class":647},[636,4453,4282],{"class":668},[636,4455,4285],{"class":730},[636,4457,4288],{"class":647},[636,4459,2753],{"class":668},[636,4461,4293],{"class":647},[636,4463,1396],{"class":668},[636,4465,4298],{"class":730},[636,4467,3880],{"class":668},[636,4469,4470,4473,4475,4478,4481,4483,4485],{"class":638,"line":782},[636,4471,4472],{"class":668},"    itemID, err ",[636,4474,4079],{"class":730},[636,4476,4477],{"class":668}," req.",[636,4479,4480],{"class":647},"RequireString",[636,4482,2631],{"class":668},[636,4484,4381],{"class":651},[636,4486,2941],{"class":668},[636,4488,4489,4491,4494,4497,4500],{"class":638,"line":805},[636,4490,2708],{"class":730},[636,4492,4493],{"class":668}," err ",[636,4495,4496],{"class":730},"!=",[636,4498,4499],{"class":655}," nil",[636,4501,2278],{"class":668},[636,4503,4504,4507,4509,4512,4514,4517,4519],{"class":638,"line":814},[636,4505,4506],{"class":730},"        return",[636,4508,4201],{"class":668},[636,4510,4511],{"class":647},"NewToolResultError",[636,4513,2631],{"class":668},[636,4515,4516],{"class":651},"\"缺少 item_id 参数\"",[636,4518,4323],{"class":668},[636,4520,4326],{"class":655},[636,4522,4523],{"class":638,"line":819},[636,4524,2591],{"class":668},[636,4526,4527],{"class":638,"line":825},[636,4528,4529],{"class":641},"    \u002F\u002F 查询并返回\n",[636,4531,4532,4534,4536,4538,4540,4543,4545],{"class":638,"line":836},[636,4533,4310],{"class":730},[636,4535,4201],{"class":668},[636,4537,4315],{"class":647},[636,4539,2631],{"class":668},[636,4541,4542],{"class":651},"\"项目详情...\"",[636,4544,4323],{"class":668},[636,4546,4326],{"class":655},[636,4548,4549],{"class":638,"line":847},[636,4550,3179],{"class":668},[36,4552,4554],{"id":4553},"_4-挂载到-gin-路由","4. 挂载到 Gin 路由",[16,4556,4557,4558,4561,4562,4565],{},"关键一步：用 ",[49,4559,4560],{},"server.NewStreamableHTTPServer"," 把 MCP 实例转成 HTTP Handler，再用 ",[49,4563,4564],{},"gin.WrapH"," 适配 Gin。",[41,4567,4569],{"className":4052,"code":4568,"language":771,"meta":47,"style":47},"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",[49,4570,4571,4581,4585,4600,4614,4631,4635,4639,4644],{"__ignoreMap":47},[636,4572,4573,4575,4577,4579],{"class":638,"line":606},[636,4574,4060],{"class":730},[636,4576,665],{"class":651},[636,4578,4065],{"class":647},[636,4580,878],{"class":651},[636,4582,4583],{"class":638,"line":575},[636,4584,689],{"emptyLinePlaceholder":494},[636,4586,4587,4590,4592,4594,4597],{"class":638,"line":576},[636,4588,4589],{"class":668},"mcpHTTPServer ",[636,4591,4079],{"class":730},[636,4593,4082],{"class":668},[636,4595,4596],{"class":647},"NewStreamableHTTPServer",[636,4598,4599],{"class":668},"(mcpServer,\n",[636,4601,4602,4604,4607,4609,4612],{"class":638,"line":692},[636,4603,4115],{"class":668},[636,4605,4606],{"class":647},"WithEndpointPath",[636,4608,2631],{"class":668},[636,4610,4611],{"class":651},"\"\u002Fmcp\"",[636,4613,4145],{"class":668},[636,4615,4616,4618,4621,4623,4626,4628],{"class":638,"line":698},[636,4617,4115],{"class":668},[636,4619,4620],{"class":647},"WithHeartbeatInterval",[636,4622,2631],{"class":668},[636,4624,4625],{"class":655},"30",[636,4627,4285],{"class":730},[636,4629,4630],{"class":668},"time.Second),\n",[636,4632,4633],{"class":638,"line":727},[636,4634,2941],{"class":668},[636,4636,4637],{"class":638,"line":746},[636,4638,689],{"emptyLinePlaceholder":494},[636,4640,4641],{"class":638,"line":762},[636,4642,4643],{"class":641},"\u002F\u002F 挂载到 Gin\n",[636,4645,4646,4649,4652,4654,4656,4659,4662],{"class":638,"line":768},[636,4647,4648],{"class":668},"r.",[636,4650,4651],{"class":647},"Any",[636,4653,2631],{"class":668},[636,4655,4611],{"class":651},[636,4657,4658],{"class":668},", gin.",[636,4660,4661],{"class":647},"WrapH",[636,4663,4664],{"class":668},"(mcpHTTPServer))\n",[16,4666,4667,4668,4671],{},"就这样，",[49,4669,4670],{},"POST \u002Fmcp"," 端点就生效了。AI 客户端通过这个 URL 发送 JSON-RPC 请求，MCP 服务器自动处理协议握手、工具列表返回、工具调用路由。",[36,4673,4675],{"id":4674},"_5-完整的服务启动代码","5. 完整的服务启动代码",[41,4677,4679],{"className":4052,"code":4678,"language":771,"meta":47,"style":47},"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",[49,4680,4681,4690,4705,4709,4714,4737,4746,4754,4759,4763,4768,4776,4780,4785,4798,4810,4824,4828,4846,4850,4855,4888,4923,4928,4932,4946],{"__ignoreMap":47},[636,4682,4683,4685,4688],{"class":638,"line":606},[636,4684,4253],{"class":730},[636,4686,4687],{"class":647}," main",[636,4689,1935],{"class":668},[636,4691,4692,4695,4697,4700,4703],{"class":638,"line":575},[636,4693,4694],{"class":668},"    r ",[636,4696,4079],{"class":730},[636,4698,4699],{"class":668}," gin.",[636,4701,4702],{"class":647},"Default",[636,4704,2826],{"class":668},[636,4706,4707],{"class":638,"line":576},[636,4708,689],{"emptyLinePlaceholder":494},[636,4710,4711],{"class":638,"line":692},[636,4712,4713],{"class":641},"    \u002F\u002F 创建 MCP 服务器\n",[636,4715,4716,4719,4721,4723,4725,4727,4730,4732,4735],{"class":638,"line":698},[636,4717,4718],{"class":668},"    mcpServer ",[636,4720,4079],{"class":730},[636,4722,4082],{"class":668},[636,4724,4085],{"class":647},[636,4726,2631],{"class":668},[636,4728,4729],{"class":651},"\"my-app\"",[636,4731,1396],{"class":668},[636,4733,4734],{"class":651},"\"1.0.0\"",[636,4736,2289],{"class":668},[636,4738,4739,4742,4744],{"class":638,"line":727},[636,4740,4741],{"class":668},"        server.",[636,4743,4118],{"class":647},[636,4745,4121],{"class":668},[636,4747,4748,4750,4752],{"class":638,"line":746},[636,4749,4741],{"class":668},[636,4751,4128],{"class":647},[636,4753,4121],{"class":668},[636,4755,4756],{"class":638,"line":762},[636,4757,4758],{"class":668},"    )\n",[636,4760,4761],{"class":638,"line":768},[636,4762,689],{"emptyLinePlaceholder":494},[636,4764,4765],{"class":638,"line":782},[636,4766,4767],{"class":641},"    \u002F\u002F 注册工具\n",[636,4769,4770,4773],{"class":638,"line":805},[636,4771,4772],{"class":647},"    registerTools",[636,4774,4775],{"class":668},"(mcpServer)\n",[636,4777,4778],{"class":638,"line":814},[636,4779,689],{"emptyLinePlaceholder":494},[636,4781,4782],{"class":638,"line":819},[636,4783,4784],{"class":641},"    \u002F\u002F 挂载到 Gin\n",[636,4786,4787,4790,4792,4794,4796],{"class":638,"line":825},[636,4788,4789],{"class":668},"    mcpHTTP ",[636,4791,4079],{"class":730},[636,4793,4082],{"class":668},[636,4795,4596],{"class":647},[636,4797,4599],{"class":668},[636,4799,4800,4802,4804,4806,4808],{"class":638,"line":836},[636,4801,4741],{"class":668},[636,4803,4606],{"class":647},[636,4805,2631],{"class":668},[636,4807,4611],{"class":651},[636,4809,4145],{"class":668},[636,4811,4812,4814,4816,4818,4820,4822],{"class":638,"line":847},[636,4813,4741],{"class":668},[636,4815,4620],{"class":647},[636,4817,2631],{"class":668},[636,4819,4625],{"class":655},[636,4821,4285],{"class":730},[636,4823,4630],{"class":668},[636,4825,4826],{"class":638,"line":852},[636,4827,4758],{"class":668},[636,4829,4830,4833,4835,4837,4839,4841,4843],{"class":638,"line":861},[636,4831,4832],{"class":668},"    r.",[636,4834,4651],{"class":647},[636,4836,2631],{"class":668},[636,4838,4611],{"class":651},[636,4840,4658],{"class":668},[636,4842,4661],{"class":647},[636,4844,4845],{"class":668},"(mcpHTTP))\n",[636,4847,4848],{"class":638,"line":866},[636,4849,689],{"emptyLinePlaceholder":494},[636,4851,4852],{"class":638,"line":1074},[636,4853,4854],{"class":641},"    \u002F\u002F 其他业务路由\n",[636,4856,4857,4859,4862,4864,4867,4869,4871,4873,4876,4879,4882,4884,4886],{"class":638,"line":1082},[636,4858,4832],{"class":668},[636,4860,4861],{"class":647},"GET",[636,4863,2631],{"class":668},[636,4865,4866],{"class":651},"\"\u002Fapi\u002Fhealth\"",[636,4868,1396],{"class":668},[636,4870,4253],{"class":730},[636,4872,2631],{"class":668},[636,4874,4875],{"class":2634},"c",[636,4877,4878],{"class":730}," *",[636,4880,4881],{"class":647},"gin",[636,4883,2753],{"class":668},[636,4885,4266],{"class":647},[636,4887,3880],{"class":668},[636,4889,4890,4893,4896,4898,4901,4903,4905,4907,4910,4913,4916,4918,4921],{"class":638,"line":1087},[636,4891,4892],{"class":668},"        c.",[636,4894,4895],{"class":647},"JSON",[636,4897,2631],{"class":668},[636,4899,4900],{"class":655},"200",[636,4902,1396],{"class":668},[636,4904,4881],{"class":647},[636,4906,2753],{"class":668},[636,4908,4909],{"class":647},"H",[636,4911,4912],{"class":668},"{",[636,4914,4915],{"class":651},"\"status\"",[636,4917,2342],{"class":668},[636,4919,4920],{"class":651},"\"ok\"",[636,4922,3179],{"class":668},[636,4924,4925],{"class":638,"line":1720},[636,4926,4927],{"class":668},"    })\n",[636,4929,4930],{"class":638,"line":1734},[636,4931,689],{"emptyLinePlaceholder":494},[636,4933,4934,4936,4939,4941,4944],{"class":638,"line":1741},[636,4935,4832],{"class":668},[636,4937,4938],{"class":647},"Run",[636,4940,2631],{"class":668},[636,4942,4943],{"class":651},"\":8080\"",[636,4945,2941],{"class":668},[636,4947,4948],{"class":638,"line":1746},[636,4949,1951],{"class":668},[16,4951,4952],{},"此时启动服务，用 curl 测试：",[41,4954,4956],{"className":630,"code":4955,"language":632,"meta":47,"style":47},"# 发现工具列表\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",[49,4957,4958,4963,4980,4990,4998,5002,5007,5019,5027],{"__ignoreMap":47},[636,4959,4960],{"class":638,"line":606},[636,4961,4962],{"class":641},"# 发现工具列表\n",[636,4964,4965,4968,4971,4974,4977],{"class":638,"line":575},[636,4966,4967],{"class":647},"curl",[636,4969,4970],{"class":655}," -X",[636,4972,4973],{"class":651}," POST",[636,4975,4976],{"class":651}," http:\u002F\u002Flocalhost:8080\u002Fmcp",[636,4978,4979],{"class":655}," \\\n",[636,4981,4982,4985,4988],{"class":638,"line":576},[636,4983,4984],{"class":655},"  -H",[636,4986,4987],{"class":651}," \"Content-Type: application\u002Fjson\"",[636,4989,4979],{"class":655},[636,4991,4992,4995],{"class":638,"line":692},[636,4993,4994],{"class":655},"  -d",[636,4996,4997],{"class":651}," '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools\u002Flist\"}'\n",[636,4999,5000],{"class":638,"line":698},[636,5001,689],{"emptyLinePlaceholder":494},[636,5003,5004],{"class":638,"line":727},[636,5005,5006],{"class":641},"# 调用工具\n",[636,5008,5009,5011,5013,5015,5017],{"class":638,"line":746},[636,5010,4967],{"class":647},[636,5012,4970],{"class":655},[636,5014,4973],{"class":651},[636,5016,4976],{"class":651},[636,5018,4979],{"class":655},[636,5020,5021,5023,5025],{"class":638,"line":762},[636,5022,4984],{"class":655},[636,5024,4987],{"class":651},[636,5026,4979],{"class":655},[636,5028,5029,5031],{"class":638,"line":768},[636,5030,4994],{"class":655},[636,5032,5033],{"class":651}," '{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools\u002Fcall\",\"params\":{\"name\":\"list_items\",\"arguments\":{}}}'\n",[11,5035,5037],{"id":5036},"拓展接入鉴权","拓展：接入鉴权",[16,5039,5040],{},"裸奔的 MCP 端点任何人都能调用，生产环境必须加鉴权。思路和普通 API 鉴权一致：Bearer Token。",[36,5042,132],{"id":132},[16,5044,5045,5046,5049,5050,5053],{},"MCP 的工具处理器签名为 ",[49,5047,5048],{},"func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error)","，它不接收 Gin 的 ",[49,5051,5052],{},"*gin.Context","，所以无法直接在工具处理器里读取请求头。",[36,5055,5057],{"id":5056},"解决方案中间件传递用户信息","解决方案：中间件传递用户信息",[16,5059,5060,5061,2522],{},"用两层中间件：第一层做 Gin 的 Token 验证，第二层把用户信息从 Gin Context 复制到 ",[49,5062,5063],{},"request.Context",[41,5065,5067],{"className":4052,"code":5066,"language":771,"meta":47,"style":47},"\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",[49,5068,5069,5074,5102,5123,5143,5159,5192,5197,5202,5206,5231,5271,5300,5304,5308,5312,5332,5344,5373,5377,5381,5385,5400,5414,5423,5427],{"__ignoreMap":47},[636,5070,5071],{"class":638,"line":606},[636,5072,5073],{"class":641},"\u002F\u002F Gin 鉴权中间件\n",[636,5075,5076,5078,5081,5083,5086,5088,5091,5093,5095,5097,5100],{"class":638,"line":575},[636,5077,4253],{"class":730},[636,5079,5080],{"class":647}," AuthRequired",[636,5082,2631],{"class":668},[636,5084,5085],{"class":2634},"authService",[636,5087,4878],{"class":730},[636,5089,5090],{"class":647},"AuthService",[636,5092,3095],{"class":668},[636,5094,4881],{"class":647},[636,5096,2753],{"class":668},[636,5098,5099],{"class":647},"HandlerFunc",[636,5101,2278],{"class":668},[636,5103,5104,5106,5109,5111,5113,5115,5117,5119,5121],{"class":638,"line":576},[636,5105,4310],{"class":730},[636,5107,5108],{"class":730}," func",[636,5110,2631],{"class":668},[636,5112,4875],{"class":2634},[636,5114,4878],{"class":730},[636,5116,4881],{"class":647},[636,5118,2753],{"class":668},[636,5120,4266],{"class":647},[636,5122,3880],{"class":668},[636,5124,5125,5128,5130,5133,5136,5138,5141],{"class":638,"line":692},[636,5126,5127],{"class":668},"        authHeader ",[636,5129,4079],{"class":730},[636,5131,5132],{"class":668}," c.",[636,5134,5135],{"class":647},"GetHeader",[636,5137,2631],{"class":668},[636,5139,5140],{"class":651},"\"Authorization\"",[636,5142,2941],{"class":668},[636,5144,5145,5148,5151,5154,5157],{"class":638,"line":698},[636,5146,5147],{"class":730},"        if",[636,5149,5150],{"class":668}," authHeader ",[636,5152,5153],{"class":730},"==",[636,5155,5156],{"class":651}," \"\"",[636,5158,2278],{"class":668},[636,5160,5161,5164,5167,5169,5172,5174,5176,5178,5180,5182,5185,5187,5190],{"class":638,"line":727},[636,5162,5163],{"class":668},"            c.",[636,5165,5166],{"class":647},"AbortWithStatusJSON",[636,5168,2631],{"class":668},[636,5170,5171],{"class":655},"401",[636,5173,1396],{"class":668},[636,5175,4881],{"class":647},[636,5177,2753],{"class":668},[636,5179,4909],{"class":647},[636,5181,4912],{"class":668},[636,5183,5184],{"class":651},"\"message\"",[636,5186,2342],{"class":668},[636,5188,5189],{"class":651},"\"缺少认证令牌\"",[636,5191,3179],{"class":668},[636,5193,5194],{"class":638,"line":746},[636,5195,5196],{"class":730},"            return\n",[636,5198,5199],{"class":638,"line":762},[636,5200,5201],{"class":668},"        }\n",[636,5203,5204],{"class":638,"line":768},[636,5205,689],{"emptyLinePlaceholder":494},[636,5207,5208,5211,5213,5216,5219,5222,5225,5227,5229],{"class":638,"line":782},[636,5209,5210],{"class":668},"        parts ",[636,5212,4079],{"class":730},[636,5214,5215],{"class":668}," strings.",[636,5217,5218],{"class":647},"SplitN",[636,5220,5221],{"class":668},"(authHeader, ",[636,5223,5224],{"class":651},"\" \"",[636,5226,1396],{"class":668},[636,5228,2817],{"class":655},[636,5230,2941],{"class":668},[636,5232,5233,5235,5238,5241,5243,5246,5249,5252,5255,5258,5261,5263,5266,5269],{"class":638,"line":805},[636,5234,5147],{"class":730},[636,5236,5237],{"class":647}," len",[636,5239,5240],{"class":668},"(parts) ",[636,5242,4496],{"class":730},[636,5244,5245],{"class":655}," 2",[636,5247,5248],{"class":730}," ||",[636,5250,5251],{"class":730}," !",[636,5253,5254],{"class":668},"strings.",[636,5256,5257],{"class":647},"EqualFold",[636,5259,5260],{"class":668},"(parts[",[636,5262,2719],{"class":655},[636,5264,5265],{"class":668},"], ",[636,5267,5268],{"class":651},"\"Bearer\"",[636,5270,3880],{"class":668},[636,5272,5273,5275,5277,5279,5281,5283,5285,5287,5289,5291,5293,5295,5298],{"class":638,"line":814},[636,5274,5163],{"class":668},[636,5276,5166],{"class":647},[636,5278,2631],{"class":668},[636,5280,5171],{"class":655},[636,5282,1396],{"class":668},[636,5284,4881],{"class":647},[636,5286,2753],{"class":668},[636,5288,4909],{"class":647},[636,5290,4912],{"class":668},[636,5292,5184],{"class":651},[636,5294,2342],{"class":668},[636,5296,5297],{"class":651},"\"格式错误，请使用 Bearer \u003Ctoken>\"",[636,5299,3179],{"class":668},[636,5301,5302],{"class":638,"line":819},[636,5303,5196],{"class":730},[636,5305,5306],{"class":638,"line":825},[636,5307,5201],{"class":668},[636,5309,5310],{"class":638,"line":836},[636,5311,689],{"emptyLinePlaceholder":494},[636,5313,5314,5317,5319,5322,5325,5327,5329],{"class":638,"line":847},[636,5315,5316],{"class":668},"        user, err ",[636,5318,4079],{"class":730},[636,5320,5321],{"class":668}," authService.",[636,5323,5324],{"class":647},"GetUserByToken",[636,5326,5260],{"class":668},[636,5328,756],{"class":655},[636,5330,5331],{"class":668},"])\n",[636,5333,5334,5336,5338,5340,5342],{"class":638,"line":852},[636,5335,5147],{"class":730},[636,5337,4493],{"class":668},[636,5339,4496],{"class":730},[636,5341,4499],{"class":655},[636,5343,2278],{"class":668},[636,5345,5346,5348,5350,5352,5354,5356,5358,5360,5362,5364,5366,5368,5371],{"class":638,"line":861},[636,5347,5163],{"class":668},[636,5349,5166],{"class":647},[636,5351,2631],{"class":668},[636,5353,5171],{"class":655},[636,5355,1396],{"class":668},[636,5357,4881],{"class":647},[636,5359,2753],{"class":668},[636,5361,4909],{"class":647},[636,5363,4912],{"class":668},[636,5365,5184],{"class":651},[636,5367,2342],{"class":668},[636,5369,5370],{"class":651},"\"无效的认证令牌\"",[636,5372,3179],{"class":668},[636,5374,5375],{"class":638,"line":866},[636,5376,5196],{"class":730},[636,5378,5379],{"class":638,"line":1074},[636,5380,5201],{"class":668},[636,5382,5383],{"class":638,"line":1082},[636,5384,689],{"emptyLinePlaceholder":494},[636,5386,5387,5389,5392,5394,5397],{"class":638,"line":1087},[636,5388,4892],{"class":668},[636,5390,5391],{"class":647},"Set",[636,5393,2631],{"class":668},[636,5395,5396],{"class":651},"\"userID\"",[636,5398,5399],{"class":668},", user.ID)\n",[636,5401,5402,5404,5406,5408,5411],{"class":638,"line":1720},[636,5403,4892],{"class":668},[636,5405,5391],{"class":647},[636,5407,2631],{"class":668},[636,5409,5410],{"class":651},"\"username\"",[636,5412,5413],{"class":668},", user.Username)\n",[636,5415,5416,5418,5421],{"class":638,"line":1734},[636,5417,4892],{"class":668},[636,5419,5420],{"class":647},"Next",[636,5422,2826],{"class":668},[636,5424,5425],{"class":638,"line":1741},[636,5426,2591],{"class":668},[636,5428,5429],{"class":638,"line":1746},[636,5430,1951],{"class":668},[41,5432,5434],{"className":4052,"code":5433,"language":771,"meta":47,"style":47},"\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",[49,5435,5436,5441,5452,5459,5472,5484,5488,5492,5510,5530,5544,5568,5590,5605,5613,5617],{"__ignoreMap":47},[636,5437,5438],{"class":638,"line":606},[636,5439,5440],{"class":641},"\u002F\u002F 用户信息桥接中间件：Gin Context → request.Context\n",[636,5442,5443,5446,5449],{"class":638,"line":575},[636,5444,5445],{"class":730},"type",[636,5447,5448],{"class":647}," ctxKey",[636,5450,5451],{"class":730}," string\n",[636,5453,5454,5456],{"class":638,"line":576},[636,5455,2865],{"class":730},[636,5457,5458],{"class":668}," (\n",[636,5460,5461,5464,5467,5469],{"class":638,"line":692},[636,5462,5463],{"class":655},"    ctxUserID",[636,5465,5466],{"class":647},"   ctxKey",[636,5468,2275],{"class":730},[636,5470,5471],{"class":651}," \"userID\"\n",[636,5473,5474,5477,5479,5481],{"class":638,"line":698},[636,5475,5476],{"class":655},"    ctxUsername",[636,5478,5448],{"class":647},[636,5480,2275],{"class":730},[636,5482,5483],{"class":651}," \"username\"\n",[636,5485,5486],{"class":638,"line":727},[636,5487,2941],{"class":668},[636,5489,5490],{"class":638,"line":746},[636,5491,689],{"emptyLinePlaceholder":494},[636,5493,5494,5496,5499,5502,5504,5506,5508],{"class":638,"line":762},[636,5495,4253],{"class":730},[636,5497,5498],{"class":647}," WithUserContext",[636,5500,5501],{"class":668},"() ",[636,5503,4881],{"class":647},[636,5505,2753],{"class":668},[636,5507,5099],{"class":647},[636,5509,2278],{"class":668},[636,5511,5512,5514,5516,5518,5520,5522,5524,5526,5528],{"class":638,"line":768},[636,5513,4310],{"class":730},[636,5515,5108],{"class":730},[636,5517,2631],{"class":668},[636,5519,4875],{"class":2634},[636,5521,4878],{"class":730},[636,5523,4881],{"class":647},[636,5525,2753],{"class":668},[636,5527,4266],{"class":647},[636,5529,3880],{"class":668},[636,5531,5532,5535,5537,5540,5542],{"class":638,"line":782},[636,5533,5534],{"class":668},"        ctx ",[636,5536,4079],{"class":730},[636,5538,5539],{"class":668}," c.Request.",[636,5541,4266],{"class":647},[636,5543,2826],{"class":668},[636,5545,5546,5548,5550,5553,5556,5559,5562,5564,5566],{"class":638,"line":805},[636,5547,5534],{"class":668},[636,5549,737],{"class":730},[636,5551,5552],{"class":668}," context.",[636,5554,5555],{"class":647},"WithValue",[636,5557,5558],{"class":668},"(ctx, ctxUserID, c.",[636,5560,5561],{"class":647},"GetUint",[636,5563,2631],{"class":668},[636,5565,5396],{"class":651},[636,5567,2794],{"class":668},[636,5569,5570,5572,5574,5576,5578,5581,5584,5586,5588],{"class":638,"line":814},[636,5571,5534],{"class":668},[636,5573,737],{"class":730},[636,5575,5552],{"class":668},[636,5577,5555],{"class":647},[636,5579,5580],{"class":668},"(ctx, ctxUsername, c.",[636,5582,5583],{"class":647},"GetString",[636,5585,2631],{"class":668},[636,5587,5410],{"class":651},[636,5589,2794],{"class":668},[636,5591,5592,5595,5597,5599,5602],{"class":638,"line":819},[636,5593,5594],{"class":668},"        c.Request ",[636,5596,737],{"class":730},[636,5598,5539],{"class":668},[636,5600,5601],{"class":647},"WithContext",[636,5603,5604],{"class":668},"(ctx)\n",[636,5606,5607,5609,5611],{"class":638,"line":825},[636,5608,4892],{"class":668},[636,5610,5420],{"class":647},[636,5612,2826],{"class":668},[636,5614,5615],{"class":638,"line":836},[636,5616,2591],{"class":668},[636,5618,5619],{"class":638,"line":847},[636,5620,1951],{"class":668},[36,5622,5623],{"id":5623},"组合使用",[41,5625,5627],{"className":4052,"code":5626,"language":771,"meta":47,"style":47},"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",[49,5628,5629,5641,5652,5663,5676],{"__ignoreMap":47},[636,5630,5631,5633,5635,5637,5639],{"class":638,"line":606},[636,5632,4648],{"class":668},[636,5634,4651],{"class":647},[636,5636,2631],{"class":668},[636,5638,4611],{"class":651},[636,5640,2289],{"class":668},[636,5642,5643,5646,5649],{"class":638,"line":575},[636,5644,5645],{"class":647},"    AuthRequired",[636,5647,5648],{"class":668},"(authService),  ",[636,5650,5651],{"class":641},"\u002F\u002F 1. 验证 Token\n",[636,5653,5654,5657,5660],{"class":638,"line":576},[636,5655,5656],{"class":647},"    WithUserContext",[636,5658,5659],{"class":668},"(),           ",[636,5661,5662],{"class":641},"\u002F\u002F 2. 桥接用户信息到 request.Context\n",[636,5664,5665,5668,5670,5673],{"class":638,"line":692},[636,5666,5667],{"class":668},"    gin.",[636,5669,4661],{"class":647},[636,5671,5672],{"class":668},"(mcpHTTP),         ",[636,5674,5675],{"class":641},"\u002F\u002F 3. MCP 处理\n",[636,5677,5678],{"class":638,"line":698},[636,5679,2941],{"class":668},[16,5681,5682],{},"在工具处理器里就能拿到用户信息了：",[41,5684,5686],{"className":4052,"code":5685,"language":771,"meta":47,"style":47},"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",[49,5687,5688,5743,5764,5773,5790,5794,5798,5814,5819,5832],{"__ignoreMap":47},[636,5689,5690,5692,5694,5697,5699,5702,5704,5707,5709,5711,5713,5715,5717,5719,5721,5723,5725,5727,5729,5731,5733,5735,5737,5739,5741],{"class":638,"line":606},[636,5691,4253],{"class":730},[636,5693,2673],{"class":668},[636,5695,5696],{"class":2634},"h ",[636,5698,4285],{"class":730},[636,5700,5701],{"class":647},"Handler",[636,5703,3095],{"class":668},[636,5705,5706],{"class":647},"handleListItems",[636,5708,2631],{"class":668},[636,5710,4258],{"class":2634},[636,5712,4261],{"class":647},[636,5714,2753],{"class":668},[636,5716,4266],{"class":647},[636,5718,1396],{"class":668},[636,5720,4271],{"class":2634},[636,5722,4274],{"class":647},[636,5724,2753],{"class":668},[636,5726,4279],{"class":647},[636,5728,4282],{"class":668},[636,5730,4285],{"class":730},[636,5732,4288],{"class":647},[636,5734,2753],{"class":668},[636,5736,4293],{"class":647},[636,5738,1396],{"class":668},[636,5740,4298],{"class":730},[636,5742,3880],{"class":668},[636,5744,5745,5748,5750,5753,5756,5759,5762],{"class":638,"line":575},[636,5746,5747],{"class":668},"    userID, ok ",[636,5749,4079],{"class":730},[636,5751,5752],{"class":668}," ctx.",[636,5754,5755],{"class":647},"Value",[636,5757,5758],{"class":668},"(ctxUserID).(",[636,5760,5761],{"class":730},"uint",[636,5763,2941],{"class":668},[636,5765,5766,5768,5770],{"class":638,"line":576},[636,5767,2708],{"class":730},[636,5769,5251],{"class":730},[636,5771,5772],{"class":668},"ok {\n",[636,5774,5775,5777,5779,5781,5783,5786,5788],{"class":638,"line":692},[636,5776,4506],{"class":730},[636,5778,4201],{"class":668},[636,5780,4511],{"class":647},[636,5782,2631],{"class":668},[636,5784,5785],{"class":651},"\"未认证\"",[636,5787,4323],{"class":668},[636,5789,4326],{"class":655},[636,5791,5792],{"class":638,"line":698},[636,5793,2591],{"class":668},[636,5795,5796],{"class":638,"line":727},[636,5797,689],{"emptyLinePlaceholder":494},[636,5799,5800,5803,5805,5808,5811],{"class":638,"line":746},[636,5801,5802],{"class":668},"    items, _ ",[636,5804,4079],{"class":730},[636,5806,5807],{"class":668}," h.service.",[636,5809,5810],{"class":647},"ListByUser",[636,5812,5813],{"class":668},"(userID)\n",[636,5815,5816],{"class":638,"line":762},[636,5817,5818],{"class":641},"    \u002F\u002F ...\n",[636,5820,5821,5823,5825,5827,5830],{"class":638,"line":768},[636,5822,4310],{"class":730},[636,5824,4201],{"class":668},[636,5826,4315],{"class":647},[636,5828,5829],{"class":668},"(result), ",[636,5831,4326],{"class":655},[636,5833,5834],{"class":638,"line":782},[636,5835,1951],{"class":668},[36,5837,5838],{"id":5838},"客户端配置",[16,5840,5841],{},"AI 客户端（如 Claude Desktop）配置 MCP 服务器时带上 Authorization 头：",[41,5843,5847],{"className":5844,"code":5845,"language":5846,"meta":47,"style":47},"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",[49,5848,5849,5854,5861,5867,5879,5886,5896,5901,5905,5909],{"__ignoreMap":47},[636,5850,5851],{"class":638,"line":606},[636,5852,5853],{"class":668},"{\n",[636,5855,5856,5859],{"class":638,"line":575},[636,5857,5858],{"class":655},"  \"mcpServers\"",[636,5860,2550],{"class":668},[636,5862,5863,5865],{"class":638,"line":576},[636,5864,4093],{"class":655},[636,5866,2550],{"class":668},[636,5868,5869,5872,5874,5877],{"class":638,"line":692},[636,5870,5871],{"class":655},"      \"url\"",[636,5873,2342],{"class":668},[636,5875,5876],{"class":651},"\"http:\u002F\u002Flocalhost:8080\u002Fmcp\"",[636,5878,2289],{"class":668},[636,5880,5881,5884],{"class":638,"line":698},[636,5882,5883],{"class":655},"      \"headers\"",[636,5885,2550],{"class":668},[636,5887,5888,5891,5893],{"class":638,"line":727},[636,5889,5890],{"class":655},"        \"Authorization\"",[636,5892,2342],{"class":668},[636,5894,5895],{"class":651},"\"Bearer your-token-here\"\n",[636,5897,5898],{"class":638,"line":746},[636,5899,5900],{"class":668},"      }\n",[636,5902,5903],{"class":638,"line":762},[636,5904,2591],{"class":668},[636,5906,5907],{"class":638,"line":768},[636,5908,2596],{"class":668},[636,5910,5911],{"class":638,"line":782},[636,5912,1951],{"class":668},[11,5914,1809],{"id":1809},[121,5916,5917,5927],{},[124,5918,5919],{},[127,5920,5921,5924],{},[130,5922,5923],{},"步骤",[130,5925,5926],{},"说明",[137,5928,5929,5938,5949,5958,5967],{},[127,5930,5931,5935],{},[142,5932,5933],{},[49,5934,4085],{},[142,5936,5937],{},"创建 MCP 协议实例",[127,5939,5940,5946],{},[142,5941,5942,64,5944],{},[49,5943,4204],{},[49,5945,4247],{},[142,5947,5948],{},"定义并注册工具",[127,5950,5951,5955],{},[142,5952,5953],{},[49,5954,4596],{},[142,5956,5957],{},"转为 HTTP Handler",[127,5959,5960,5964],{},[142,5961,5962],{},[49,5963,4564],{},[142,5965,5966],{},"挂载到 Gin 路由",[127,5968,5969,5977],{},[142,5970,5971,64,5974],{},[49,5972,5973],{},"AuthRequired",[49,5975,5976],{},"WithUserContext",[142,5978,5979],{},"鉴权 + 用户信息桥接",[16,5981,5982,5983,5986,5987,2522],{},"核心就一句话：",[23,5984,5985],{},"MCP 是 HTTP Handler，挂上去就行。"," 鉴权复用你已有的中间件体系，只需要多写一个桥接函数把用户信息传进 ",[49,5988,5063],{},[1422,5990,5991],{},"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":47,"searchDepth":575,"depth":576,"links":5993},[5994,5995,5996,5997,6004,6010],{"id":3964,"depth":575,"text":3965},{"id":3974,"depth":575,"text":3975},{"id":3991,"depth":575,"text":3991},{"id":4017,"depth":575,"text":4017,"children":5998},[5999,6000,6001,6002,6003],{"id":4020,"depth":576,"text":4021},{"id":4048,"depth":576,"text":4049},{"id":4165,"depth":576,"text":4166},{"id":4553,"depth":576,"text":4554},{"id":4674,"depth":576,"text":4675},{"id":5036,"depth":575,"text":5037,"children":6005},[6006,6007,6008,6009],{"id":132,"depth":576,"text":132},{"id":5056,"depth":576,"text":5057},{"id":5623,"depth":576,"text":5623},{"id":5838,"depth":576,"text":5838},{"id":1809,"depth":575,"text":1809},"介绍如何使用 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":3959,"description":6011},"Go 工程实践","posts\u002Fgo-mcp-gin\u002Findex",[265,6020,268,6021,360],"MCP","AI Agent","2026-06-01","5fZl1Vigs2lHb5fHPz-0YZFyFD38ws1heOouZUG1g6M",{"id":6025,"title":6026,"author":6,"body":6027,"description":6794,"difficulty":6,"draft":598,"extension":599,"image":6795,"meta":6796,"navigation":494,"path":6797,"pinned":598,"published":6798,"seo":6799,"series":6,"seriesOrder":6,"stem":6800,"tags":6801,"updated":6,"__hash__":6805},"posts\u002Fposts\u002Fdws-dingtalk-cli\u002Findex.md","DWS — 钉钉官方出品的 AI 友好命令行工具",{"type":8,"value":6028,"toc":6780},[6029,6033,6036,6043,6169,6172,6175,6231,6234,6249,6252,6255,6262,6494,6501,6505,6508,6528,6531,6546,6549,6569,6572,6575,6582,6648,6651,6654,6658,6661,6713,6717,6738,6742,6745,6759,6762,6766,6769,6771,6774,6777],[11,6030,6032],{"id":6031},"dws-是什么","dws 是什么",[16,6034,6035],{},"dws（DingTalk Workspace CLI）是阿里巴巴 2026 年开源的钉钉命令行工具，Apache-2.0 协议，Go 语言编写。",[16,6037,6038,6039,6042],{},"它的定位很明确：",[23,6040,6041],{},"把钉钉的所有产品能力暴露给 AI Agent 和命令行用户","。不用打开钉钉 App，不用写 API 调用代码，一条命令搞定。",[41,6044,6046],{"className":630,"code":6045,"language":632,"meta":47,"style":47},"# 查看今天有什么会\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",[49,6047,6048,6053,6079,6083,6088,6110,6114,6119,6144,6148,6153],{"__ignoreMap":47},[636,6049,6050],{"class":638,"line":606},[636,6051,6052],{"class":641},"# 查看今天有什么会\n",[636,6054,6055,6058,6061,6064,6067,6070,6073,6076],{"class":638,"line":575},[636,6056,6057],{"class":647},"dws",[636,6059,6060],{"class":651}," calendar",[636,6062,6063],{"class":651}," event",[636,6065,6066],{"class":651}," list",[636,6068,6069],{"class":655}," --start",[636,6071,6072],{"class":651}," 2026-06-01",[636,6074,6075],{"class":655}," --end",[636,6077,6078],{"class":651}," 2026-06-01\n",[636,6080,6081],{"class":638,"line":576},[636,6082,689],{"emptyLinePlaceholder":494},[636,6084,6085],{"class":638,"line":692},[636,6086,6087],{"class":641},"# 给群里发条消息\n",[636,6089,6090,6092,6095,6098,6101,6104,6107],{"class":638,"line":698},[636,6091,6057],{"class":647},[636,6093,6094],{"class":651}," chat",[636,6096,6097],{"class":651}," send",[636,6099,6100],{"class":655}," --group-id",[636,6102,6103],{"class":651}," xxx",[636,6105,6106],{"class":655}," --content",[636,6108,6109],{"class":651}," \"今天发布推迟到下午3点\"\n",[636,6111,6112],{"class":638,"line":727},[636,6113,689],{"emptyLinePlaceholder":494},[636,6115,6116],{"class":638,"line":746},[636,6117,6118],{"class":641},"# 创建一个待办\n",[636,6120,6121,6123,6126,6129,6132,6135,6138,6141],{"class":638,"line":762},[636,6122,6057],{"class":647},[636,6124,6125],{"class":651}," todo",[636,6127,6128],{"class":651}," task",[636,6130,6131],{"class":651}," create",[636,6133,6134],{"class":655}," --subject",[636,6136,6137],{"class":651}," \"Review PR #123\"",[636,6139,6140],{"class":655}," --due-date",[636,6142,6143],{"class":651}," 2026-06-02\n",[636,6145,6146],{"class":638,"line":768},[636,6147,689],{"emptyLinePlaceholder":494},[636,6149,6150],{"class":638,"line":782},[636,6151,6152],{"class":641},"# 读取钉钉文档\n",[636,6154,6155,6157,6160,6163,6166],{"class":638,"line":805},[636,6156,6057],{"class":647},[636,6158,6159],{"class":651}," doc",[636,6161,6162],{"class":651}," read",[636,6164,6165],{"class":655}," --url",[636,6167,6168],{"class":651}," \"https:\u002F\u002Falidocs.dingtalk.com\u002Fi\u002Fnodes\u002Fxxx\"\n",[11,6170,6171],{"id":6171},"安装",[16,6173,6174],{},"三种方式，选你喜欢的：",[41,6176,6178],{"className":630,"code":6177,"language":632,"meta":47,"style":47},"# 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",[49,6179,6180,6185,6198,6202,6207,6217,6221,6226],{"__ignoreMap":47},[636,6181,6182],{"class":638,"line":606},[636,6183,6184],{"class":641},"# npm（推荐，跨平台自动下载二进制）\n",[636,6186,6187,6190,6192,6195],{"class":638,"line":575},[636,6188,6189],{"class":647},"npm",[636,6191,712],{"class":651},[636,6193,6194],{"class":655}," -g",[636,6196,6197],{"class":651}," dingtalk-workspace-cli\n",[636,6199,6200],{"class":638,"line":576},[636,6201,689],{"emptyLinePlaceholder":494},[636,6203,6204],{"class":638,"line":692},[636,6205,6206],{"class":641},"# Homebrew（macOS\u002FLinux）\n",[636,6208,6209,6212,6214],{"class":638,"line":698},[636,6210,6211],{"class":647},"brew",[636,6213,712],{"class":651},[636,6215,6216],{"class":651}," dws\n",[636,6218,6219],{"class":638,"line":727},[636,6220,689],{"emptyLinePlaceholder":494},[636,6222,6223],{"class":638,"line":746},[636,6224,6225],{"class":641},"# 直接下载二进制\n",[636,6227,6228],{"class":638,"line":762},[636,6229,6230],{"class":641},"# https:\u002F\u002Fgithub.com\u002Fopen-dingtalk\u002Fdingtalk-workspace-cli\u002Freleases\n",[16,6232,6233],{},"安装后认证：",[41,6235,6237],{"className":630,"code":6236,"language":632,"meta":47,"style":47},"dws auth login\n",[49,6238,6239],{"__ignoreMap":47},[636,6240,6241,6243,6246],{"class":638,"line":606},[636,6242,6057],{"class":647},[636,6244,6245],{"class":651}," auth",[636,6247,6248],{"class":651}," login\n",[16,6250,6251],{},"会打开浏览器走 OAuth 设备流认证，token 加密存储在系统钥匙串里（PBKDF2 + AES-256-GCM）。",[11,6253,6254],{"id":6254},"覆盖的钉钉产品",[16,6256,6257,6258,6261],{},"这是最夸张的部分 — ",[23,6259,6260],{},"19 个产品，213 个命令","，基本把钉钉全家桶都搬到了命令行：",[121,6263,6264,6276],{},[124,6265,6266],{},[127,6267,6268,6271,6274],{},[130,6269,6270],{},"产品",[130,6272,6273],{},"命令数",[130,6275,5926],{},[137,6277,6278,6292,6306,6320,6334,6348,6362,6376,6389,6403,6416,6430,6444,6457,6470,6483],{},[127,6279,6280,6286,6289],{},[142,6281,6282,6283],{},"聊天 ",[49,6284,6285],{},"chat",[142,6287,6288],{},"57",[142,6290,6291],{},"消息收发、群管理、机器人、已读状态、Webhook",[127,6293,6294,6300,6303],{},[142,6295,6296,6297],{},"AI 表格 ",[49,6298,6299],{},"aitable",[142,6301,6302],{},"42",[142,6304,6305],{},"Base\u002F数据表\u002F字段\u002F记录\u002F视图\u002F图表\u002F仪表盘，完整 CRUD",[127,6307,6308,6314,6317],{},[142,6309,6310,6311],{},"电子表格 ",[49,6312,6313],{},"sheet",[142,6315,6316],{},"34",[142,6318,6319],{},"工作表读写、行列操作、合并、筛选视图、导出",[127,6321,6322,6328,6331],{},[142,6323,6324,6325],{},"文档 ",[49,6326,6327],{},"doc",[142,6329,6330],{},"21",[142,6332,6333],{},"搜索、读写、块级编辑、评论、复制移动",[127,6335,6336,6342,6345],{},[142,6337,6338,6339],{},"会议纪要 ",[49,6340,6341],{},"minutes",[142,6343,6344],{},"19",[142,6346,6347],{},"列表、摘要、转写、思维导图、发言人、热词",[127,6349,6350,6356,6359],{},[142,6351,6352,6353],{},"日历 ",[49,6354,6355],{},"calendar",[142,6357,6358],{},"14",[142,6360,6361],{},"事件 CRUD、会议室、忙闲查询、时间建议",[127,6363,6364,6370,6373],{},[142,6365,6366,6367],{},"审批 ",[49,6368,6369],{},"oa",[142,6371,6372],{},"9",[142,6374,6375],{},"待办、发起、同意\u002F拒绝\u002F撤销",[127,6377,6378,6384,6386],{},[142,6379,6380,6381],{},"云盘 ",[49,6382,6383],{},"drive",[142,6385,6372],{},[142,6387,6388],{},"列表、下载、上传、创建目录",[127,6390,6391,6397,6400],{},[142,6392,6393,6394],{},"知识库 ",[49,6395,6396],{},"wiki",[142,6398,6399],{},"7",[142,6401,6402],{},"空间管理、成员管理",[127,6404,6405,6411,6413],{},[142,6406,6407,6408],{},"汇报 ",[49,6409,6410],{},"report",[142,6412,6399],{},[142,6414,6415],{},"日志\u002F日报\u002F周报，按模板创建、统计",[127,6417,6418,6424,6427],{},[142,6419,6420,6421],{},"待办 ",[49,6422,6423],{},"todo",[142,6425,6426],{},"6",[142,6428,6429],{},"创建、完成、删除，支持优先级和循环",[127,6431,6432,6438,6441],{},[142,6433,6434,6435],{},"邮件 ",[49,6436,6437],{},"mail",[142,6439,6440],{},"4",[142,6442,6443],{},"邮箱查询、邮件搜索、发送",[127,6445,6446,6452,6454],{},[142,6447,6448,6449],{},"考勤 ",[49,6450,6451],{},"attendance",[142,6453,6440],{},[142,6455,6456],{},"打卡记录、排班、汇总",[127,6458,6459,6465,6467],{},[142,6460,6461,6462],{},"通讯录 ",[49,6463,6464],{},"contact",[142,6466,2580],{},[142,6468,6469],{},"用户搜索、部门查询",[127,6471,6472,6478,6480],{},[142,6473,6474,6475],{},"DING 消息 ",[49,6476,6477],{},"ding",[142,6479,2817],{},[142,6481,6482],{},"发送\u002F撤回（应用内、短信、电话）",[127,6484,6485,6488,6491],{},[142,6486,6487],{},"其他",[142,6489,6490],{},"若干",[142,6492,6493],{},"AI 搜索、AI 应用、直播、开放平台文档",[16,6495,6496,6497,6500],{},"还有一个万能命令 ",[49,6498,6499],{},"dws api","，可以直接调用任意钉钉 OpenAPI，不受这 213 个命令的限制。",[11,6502,6504],{"id":6503},"agent-skill-系统","Agent Skill 系统",[16,6506,6507],{},"dws 内置了一套完整的 Agent Skill，可以直接被 AI 编程工具发现和使用：",[56,6509,6510,6516,6522],{},[59,6511,6512,6515],{},[23,6513,6514],{},"Claude Code"," — 通过 SKILL.md 自动加载",[59,6517,6518,6521],{},[23,6519,6520],{},"Cursor"," — 支持 MCP 协议接入",[59,6523,6524,6527],{},[23,6525,6526],{},"Codex \u002F OpenCode \u002F Qoder"," — 通过 skill 安装命令接入",[16,6529,6530],{},"安装 skill：",[41,6532,6534],{"className":630,"code":6533,"language":632,"meta":47,"style":47},"dws skill setup\n",[49,6535,6536],{"__ignoreMap":47},[636,6537,6538,6540,6543],{"class":638,"line":606},[636,6539,6057],{"class":647},[636,6541,6542],{"class":651}," skill",[636,6544,6545],{"class":651}," setup\n",[16,6547,6548],{},"SKILL.md 里定义了：",[56,6550,6551,6560,6563,6566],{},[59,6552,6553,6554,6556,6557,6559],{},"16 个产品的意图判断决策树（用户说\"表格\"→ ",[49,6555,6299],{},"，说\"打卡\"→ ",[49,6558,6451],{},"）",[59,6561,6562],{},"危险操作确认流程（删除、撤回等必须先确认）",[59,6564,6565],{},"错误处理规范（verbose 重试、recovery 事件 ID）",[59,6567,6568],{},"URL 分流规则（alidocs.dingtalk.com 的多种路径格式自动识别）",[16,6570,6571],{},"Agent 读到这份 SKILL.md 就知道什么时候该调什么命令，不需要额外的 prompt 工程。",[11,6573,6574],{"id":6574},"自动升级",[16,6576,6577,6578,6581],{},"内置 ",[49,6579,6580],{},"dws upgrade","，不需要重新下载安装包：",[41,6583,6585],{"className":630,"code":6584,"language":632,"meta":47,"style":47},"dws upgrade              # 交互式升级到最新版\ndws upgrade --check      # 仅检查是否有新版本\ndws upgrade --list       # 列出最近版本\ndws upgrade --version v1.0.5  # 升级到指定版本\ndws upgrade --rollback   # 回滚到上一版本\n",[49,6586,6587,6597,6609,6621,6636],{"__ignoreMap":47},[636,6588,6589,6591,6594],{"class":638,"line":606},[636,6590,6057],{"class":647},[636,6592,6593],{"class":651}," upgrade",[636,6595,6596],{"class":641},"              # 交互式升级到最新版\n",[636,6598,6599,6601,6603,6606],{"class":638,"line":575},[636,6600,6057],{"class":647},[636,6602,6593],{"class":651},[636,6604,6605],{"class":655}," --check",[636,6607,6608],{"class":641},"      # 仅检查是否有新版本\n",[636,6610,6611,6613,6615,6618],{"class":638,"line":576},[636,6612,6057],{"class":647},[636,6614,6593],{"class":651},[636,6616,6617],{"class":655}," --list",[636,6619,6620],{"class":641},"       # 列出最近版本\n",[636,6622,6623,6625,6627,6630,6633],{"class":638,"line":692},[636,6624,6057],{"class":647},[636,6626,6593],{"class":651},[636,6628,6629],{"class":655}," --version",[636,6631,6632],{"class":651}," v1.0.5",[636,6634,6635],{"class":641},"  # 升级到指定版本\n",[636,6637,6638,6640,6642,6645],{"class":638,"line":698},[636,6639,6057],{"class":647},[636,6641,6593],{"class":651},[636,6643,6644],{"class":655}," --rollback",[636,6646,6647],{"class":641},"   # 回滚到上一版本\n",[16,6649,6650],{},"升级流程：下载 → SHA256 校验 → 备份当前版本 → 原子替换 → 更新 Skill。失败可一键回滚。",[11,6652,6653],{"id":6653},"亮点",[36,6655,6657],{"id":6656},"_1-为-ai-agent-专门优化","1. 为 AI Agent 专门优化",[16,6659,6660],{},"这可能是目前对 AI 最友好的 CLI 工具：",[56,6662,6663,6684,6694,6703],{},[59,6664,6665,6668,6669,6672,6673,6676,6677,6672,6680,6683],{},[23,6666,6667],{},"智能纠错"," — AI 模型经常生成错误的参数格式（",[49,6670,6671],{},"--userId"," 应该是 ",[49,6674,6675],{},"--user-id","，",[49,6678,6679],{},"--limit100",[49,6681,6682],{},"--limit 100","），dws 的 5 阶段 Pipeline 会自动修正",[59,6685,6686,6689,6690,6693],{},[23,6687,6688],{},"Schema 发现"," — ",[49,6691,6692],{},"dws schema"," 让 Agent 动态查询可用工具和参数，不需要硬编码",[59,6695,6696,6689,6699,6702],{},[23,6697,6698],{},"jq 过滤",[49,6700,6701],{},"--jq '.data[] | {name, id}'"," 直接过滤输出，减少 Agent 消耗的 token",[59,6704,6705,6708,6709,6712],{},[23,6706,6707],{},"JSON 输出"," — 所有命令支持 ",[49,6710,6711],{},"--format json","，方便程序解析",[36,6714,6716],{"id":6715},"_2-安全设计","2. 安全设计",[56,6718,6719,6722,6725,6728,6735],{},[59,6720,6721],{},"token 用 PBKDF2 + AES-256-GCM 加密，密钥绑定设备 MAC 地址",[59,6723,6724],{},"域名白名单，只能访问钉钉官方 API",[59,6726,6727],{},"强制 HTTPS",[59,6729,6730,6731,6734],{},"写操作必须显式 ",[49,6732,6733],{},"--yes"," 确认",[59,6736,6737],{},"完整审计链",[36,6739,6741],{"id":6740},"_3-发现驱动架构","3. 发现驱动架构",[16,6743,6744],{},"不是把所有命令硬编码在代码里，而是：",[883,6746,6747,6750,6753,6756],{},[59,6748,6749],{},"从钉钉 MCP 服务器注册中心拉取元数据",[59,6751,6752],{},"运行时发现服务端能力",[59,6754,6755],{},"转换成统一的中间表示（IR）",[59,6757,6758],{},"动态挂载到命令树",[16,6760,6761],{},"这意味着钉钉新增产品能力时，dws 不需要发版就能支持。",[36,6763,6765],{"id":6764},"_4-多平台分发","4. 多平台分发",[16,6767,6768],{},"GoReleaser 交叉编译 + Homebrew formula + npm 包 + shell\u002FPowerShell 安装脚本，覆盖 Windows、macOS、Linux。",[11,6770,1809],{"id":1809},[16,6772,6773],{},"dws 把钉钉从一个\"必须打开 App 才能用\"的工具变成了一个\"命令行 + AI Agent 随时调用\"的平台。213 个命令覆盖日常办公的方方面面，Agent Skill 系统让 AI 编程工具可以直接理解和使用这些能力。",[16,6775,6776],{},"如果你是钉钉重度用户，或者在做企业内部的 AI Agent 集成，dws 值得一试。",[1422,6778,6779],{},"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":47,"searchDepth":575,"depth":576,"links":6781},[6782,6783,6784,6785,6786,6787,6793],{"id":6031,"depth":575,"text":6032},{"id":6171,"depth":575,"text":6171},{"id":6254,"depth":575,"text":6254},{"id":6503,"depth":575,"text":6504},{"id":6574,"depth":575,"text":6574},{"id":6653,"depth":575,"text":6653,"children":6788},[6789,6790,6791,6792],{"id":6656,"depth":576,"text":6657},{"id":6715,"depth":576,"text":6716},{"id":6740,"depth":576,"text":6741},{"id":6764,"depth":576,"text":6765},{"id":1809,"depth":575,"text":1809},"介绍阿里巴巴官方的 DingTalk Workspace CLI（dws），一个覆盖钉钉 19 个产品、213 个命令的 AI 友好命令行工具，支持自动升级和 Agent Skill 系统。","\u002Fposts\u002Fdws-dingtalk-cli\u002Fimg\u002Fcover.svg",{},"\u002Fposts\u002Fdws-dingtalk-cli","2026-06-01T00:51:00",{"title":6026,"description":6794},"posts\u002Fdws-dingtalk-cli\u002Findex",[6802,6803,6021,265,6804],"钉钉","CLI","效率工具","4ca65VUZ2Sz9yG8dNtftrBDDHE6QowA3ed9g4egQkKI",{"id":6807,"title":6808,"author":6,"body":6809,"description":7772,"difficulty":1437,"draft":598,"extension":599,"image":7773,"meta":7774,"navigation":494,"path":7775,"pinned":598,"published":7776,"seo":7777,"series":6017,"seriesOrder":575,"stem":7778,"tags":7779,"updated":7783,"__hash__":7784},"posts\u002Fposts\u002Fgo-embed-frontend\u002Findex.md","Go 单文件打包实战：把前端塞进二进制",{"type":8,"value":6810,"toc":7755},[6811,6813,6820,6823,6826,6837,6840,6844,7177,7181,7289,7293,7427,7430,7434,7443,7460,7471,7490,7504,7510,7518,7617,7623,7627,7634,7643,7646,7682,7685,7690,7729,7731,7737,7749,7752],[11,6812,2498],{"id":2498},[16,6814,6815,6816,6819],{},"一个 Gin + Vue 3 的全栈项目，之前的发布方式是：一个 Go 二进制 + 一个 ",[49,6817,6818],{},"web\u002F"," 前端静态目录，必须放一起才能运行。",[16,6821,6822],{},"为了让部署更丝滑——一个 exe 拖哪都能跑——决定把前端构建产物嵌入到 Go 二进制里。",[11,6824,6825],{"id":6825},"核心思路",[16,6827,6828,6829,6832,6833,6836],{},"利用 Go 1.16 引入的 ",[49,6830,6831],{},"\u002F\u002Fgo:embed"," 指令，在编译时将前端 ",[49,6834,6835],{},"dist\u002F"," 目录嵌入二进制，运行时通过嵌入的文件系统提供 SPA 服务。",[11,6838,6839],{"id":6839},"逐步实现",[36,6841,6843],{"id":6842},"_1-创建嵌入模块","1. 创建嵌入模块",[41,6845,6847],{"className":4052,"code":6846,"language":771,"meta":47,"style":47},"\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",[49,6848,6849,6854,6862,6866,6872,6882,6891,6900,6909,6918,6927,6931,6940,6944,6948,6953,6968,6972,6989,7010,7014,7034,7054,7067,7077,7081,7085,7100,7112,7117,7136,7140,7144,7167,7172],{"__ignoreMap":47},[636,6850,6851],{"class":638,"line":606},[636,6852,6853],{"class":641},"\u002F\u002F service\u002Fweb\u002Fembed.go\n",[636,6855,6856,6859],{"class":638,"line":575},[636,6857,6858],{"class":730},"package",[636,6860,6861],{"class":647}," web\n",[636,6863,6864],{"class":638,"line":576},[636,6865,689],{"emptyLinePlaceholder":494},[636,6867,6868,6870],{"class":638,"line":692},[636,6869,4060],{"class":730},[636,6871,5458],{"class":668},[636,6873,6874,6877,6880],{"class":638,"line":698},[636,6875,6876],{"class":651},"    \"",[636,6878,6879],{"class":647},"embed",[636,6881,878],{"class":651},[636,6883,6884,6886,6889],{"class":638,"line":727},[636,6885,6876],{"class":651},[636,6887,6888],{"class":647},"io\u002Ffs",[636,6890,878],{"class":651},[636,6892,6893,6895,6898],{"class":638,"line":746},[636,6894,6876],{"class":651},[636,6896,6897],{"class":647},"mime",[636,6899,878],{"class":651},[636,6901,6902,6904,6907],{"class":638,"line":762},[636,6903,6876],{"class":651},[636,6905,6906],{"class":647},"net\u002Fhttp",[636,6908,878],{"class":651},[636,6910,6911,6913,6916],{"class":638,"line":768},[636,6912,6876],{"class":651},[636,6914,6915],{"class":647},"path\u002Ffilepath",[636,6917,878],{"class":651},[636,6919,6920,6922,6925],{"class":638,"line":782},[636,6921,6876],{"class":651},[636,6923,6924],{"class":647},"strings",[636,6926,878],{"class":651},[636,6928,6929],{"class":638,"line":805},[636,6930,689],{"emptyLinePlaceholder":494},[636,6932,6933,6935,6938],{"class":638,"line":814},[636,6934,6876],{"class":651},[636,6936,6937],{"class":647},"github.com\u002Fgin-gonic\u002Fgin",[636,6939,878],{"class":651},[636,6941,6942],{"class":638,"line":819},[636,6943,2941],{"class":668},[636,6945,6946],{"class":638,"line":825},[636,6947,689],{"emptyLinePlaceholder":494},[636,6949,6950],{"class":638,"line":836},[636,6951,6952],{"class":641},"\u002F\u002Fgo:embed dist\n",[636,6954,6955,6958,6961,6963,6965],{"class":638,"line":847},[636,6956,6957],{"class":730},"var",[636,6959,6960],{"class":668}," embedFS ",[636,6962,6879],{"class":647},[636,6964,2753],{"class":668},[636,6966,6967],{"class":647},"FS\n",[636,6969,6970],{"class":638,"line":852},[636,6971,689],{"emptyLinePlaceholder":494},[636,6973,6974,6976,6979,6981,6983,6985,6987],{"class":638,"line":861},[636,6975,4253],{"class":730},[636,6977,6978],{"class":647}," SPAHandler",[636,6980,5501],{"class":668},[636,6982,4881],{"class":647},[636,6984,2753],{"class":668},[636,6986,5099],{"class":647},[636,6988,2278],{"class":668},[636,6990,6991,6994,6996,6999,7002,7005,7008],{"class":638,"line":866},[636,6992,6993],{"class":668},"    sub, _ ",[636,6995,4079],{"class":730},[636,6997,6998],{"class":668}," fs.",[636,7000,7001],{"class":647},"Sub",[636,7003,7004],{"class":668},"(embedFS, ",[636,7006,7007],{"class":651},"\"dist\"",[636,7009,2941],{"class":668},[636,7011,7012],{"class":638,"line":1074},[636,7013,689],{"emptyLinePlaceholder":494},[636,7015,7016,7018,7020,7022,7024,7026,7028,7030,7032],{"class":638,"line":1082},[636,7017,4310],{"class":730},[636,7019,5108],{"class":730},[636,7021,2631],{"class":668},[636,7023,4875],{"class":2634},[636,7025,4878],{"class":730},[636,7027,4881],{"class":647},[636,7029,2753],{"class":668},[636,7031,4266],{"class":647},[636,7033,3880],{"class":668},[636,7035,7036,7039,7041,7043,7046,7049,7052],{"class":638,"line":1087},[636,7037,7038],{"class":668},"        p ",[636,7040,4079],{"class":730},[636,7042,5215],{"class":668},[636,7044,7045],{"class":647},"TrimPrefix",[636,7047,7048],{"class":668},"(c.Request.URL.Path, ",[636,7050,7051],{"class":651},"\"\u002F\"",[636,7053,2941],{"class":668},[636,7055,7056,7058,7061,7063,7065],{"class":638,"line":1720},[636,7057,5147],{"class":730},[636,7059,7060],{"class":668}," p ",[636,7062,5153],{"class":730},[636,7064,5156],{"class":651},[636,7066,2278],{"class":668},[636,7068,7069,7072,7074],{"class":638,"line":1734},[636,7070,7071],{"class":668},"            p ",[636,7073,737],{"class":730},[636,7075,7076],{"class":651}," \"index.html\"\n",[636,7078,7079],{"class":638,"line":1741},[636,7080,5201],{"class":668},[636,7082,7083],{"class":638,"line":1746},[636,7084,689],{"emptyLinePlaceholder":494},[636,7086,7087,7090,7092,7094,7097],{"class":638,"line":1752},[636,7088,7089],{"class":668},"        data, err ",[636,7091,4079],{"class":730},[636,7093,6998],{"class":668},[636,7095,7096],{"class":647},"ReadFile",[636,7098,7099],{"class":668},"(sub, p)\n",[636,7101,7102,7104,7106,7108,7110],{"class":638,"line":1760},[636,7103,5147],{"class":730},[636,7105,4493],{"class":668},[636,7107,4496],{"class":730},[636,7109,4499],{"class":655},[636,7111,2278],{"class":668},[636,7113,7114],{"class":638,"line":1768},[636,7115,7116],{"class":641},"            \u002F\u002F SPA fallback：文件不存在则返回 index.html\n",[636,7118,7119,7122,7124,7126,7128,7131,7134],{"class":638,"line":1773},[636,7120,7121],{"class":668},"            data, _ ",[636,7123,737],{"class":730},[636,7125,6998],{"class":668},[636,7127,7096],{"class":647},[636,7129,7130],{"class":668},"(sub, ",[636,7132,7133],{"class":651},"\"index.html\"",[636,7135,2941],{"class":668},[636,7137,7138],{"class":638,"line":1779},[636,7139,5201],{"class":668},[636,7141,7142],{"class":638,"line":1788},[636,7143,689],{"emptyLinePlaceholder":494},[636,7145,7147,7149,7152,7155,7158,7161,7164],{"class":638,"line":7146},33,[636,7148,4892],{"class":668},[636,7150,7151],{"class":647},"Data",[636,7153,7154],{"class":668},"(http.StatusOK, mime.",[636,7156,7157],{"class":647},"TypeByExtension",[636,7159,7160],{"class":668},"(filepath.",[636,7162,7163],{"class":647},"Ext",[636,7165,7166],{"class":668},"(p)), data)\n",[636,7168,7170],{"class":638,"line":7169},34,[636,7171,2591],{"class":668},[636,7173,7175],{"class":638,"line":7174},35,[636,7176,1951],{"class":668},[36,7178,7180],{"id":7179},"_2-改造路由","2. 改造路由",[41,7182,7184],{"className":4052,"code":7183,"language":771,"meta":47,"style":47},"\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",[49,7185,7186,7191,7202,7206,7211,7226,7240,7253,7266,7279],{"__ignoreMap":47},[636,7187,7188],{"class":638,"line":606},[636,7189,7190],{"class":641},"\u002F\u002F router.go\n",[636,7192,7193,7195,7197,7200],{"class":638,"line":575},[636,7194,4060],{"class":730},[636,7196,665],{"class":651},[636,7198,7199],{"class":647},"myproject\u002Fweb",[636,7201,878],{"class":651},[636,7203,7204],{"class":638,"line":576},[636,7205,689],{"emptyLinePlaceholder":494},[636,7207,7208],{"class":638,"line":692},[636,7209,7210],{"class":641},"\u002F\u002F 替换原来的磁盘静态文件服务\n",[636,7212,7213,7216,7218,7221,7224],{"class":638,"line":698},[636,7214,7215],{"class":668},"webHandler ",[636,7217,4079],{"class":730},[636,7219,7220],{"class":668}," web.",[636,7222,7223],{"class":647},"SPAHandler",[636,7225,2826],{"class":668},[636,7227,7228,7231,7233,7235,7237],{"class":638,"line":727},[636,7229,7230],{"class":668},"router.",[636,7232,4861],{"class":647},[636,7234,2631],{"class":668},[636,7236,7051],{"class":651},[636,7238,7239],{"class":668},", webHandler)\n",[636,7241,7242,7244,7246,7248,7251],{"class":638,"line":746},[636,7243,7230],{"class":668},[636,7245,4861],{"class":647},[636,7247,2631],{"class":668},[636,7249,7250],{"class":651},"\"\u002Fassets\u002F*filepath\"",[636,7252,7239],{"class":668},[636,7254,7255,7257,7259,7261,7264],{"class":638,"line":762},[636,7256,7230],{"class":668},[636,7258,4861],{"class":647},[636,7260,2631],{"class":668},[636,7262,7263],{"class":651},"\"\u002Ffavicon.ico\"",[636,7265,7239],{"class":668},[636,7267,7268,7270,7272,7274,7277],{"class":638,"line":768},[636,7269,7230],{"class":668},[636,7271,4861],{"class":647},[636,7273,2631],{"class":668},[636,7275,7276],{"class":651},"\"\u002Ffavicon.svg\"",[636,7278,7239],{"class":668},[636,7280,7281,7283,7286],{"class":638,"line":782},[636,7282,7230],{"class":668},[636,7284,7285],{"class":647},"NoRoute",[636,7287,7288],{"class":668},"(webHandler)\n",[36,7290,7292],{"id":7291},"_3-一键构建脚本","3. 一键构建脚本",[41,7294,7296],{"className":630,"code":7295,"language":632,"meta":47,"style":47},"#!\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",[49,7297,7298,7303,7308,7312,7320,7324,7329,7339,7349,7353,7358,7369,7382,7386,7391,7399,7412,7422],{"__ignoreMap":47},[636,7299,7300],{"class":638,"line":606},[636,7301,7302],{"class":641},"#!\u002Fbin\u002Fbash\n",[636,7304,7305],{"class":638,"line":575},[636,7306,7307],{"class":641},"# build-standalone.sh\n",[636,7309,7310],{"class":638,"line":576},[636,7311,689],{"emptyLinePlaceholder":494},[636,7313,7314,7317],{"class":638,"line":692},[636,7315,7316],{"class":655},"set",[636,7318,7319],{"class":655}," -e\n",[636,7321,7322],{"class":638,"line":698},[636,7323,689],{"emptyLinePlaceholder":494},[636,7325,7326],{"class":638,"line":727},[636,7327,7328],{"class":641},"# 1. 构建前端\n",[636,7330,7331,7334,7336],{"class":638,"line":746},[636,7332,7333],{"class":647},"pnpm",[636,7335,712],{"class":651},[636,7337,7338],{"class":655}," --no-frozen-lockfile\n",[636,7340,7341,7343,7346],{"class":638,"line":762},[636,7342,2402],{"class":647},[636,7344,7345],{"class":651}," vite",[636,7347,7348],{"class":651}," build\n",[636,7350,7351],{"class":638,"line":768},[636,7352,689],{"emptyLinePlaceholder":494},[636,7354,7355],{"class":638,"line":782},[636,7356,7357],{"class":641},"# 2. 复制到嵌入目录\n",[636,7359,7360,7363,7366],{"class":638,"line":805},[636,7361,7362],{"class":647},"rm",[636,7364,7365],{"class":655}," -rf",[636,7367,7368],{"class":651}," service\u002Fweb\u002Fdist\n",[636,7370,7371,7374,7377,7380],{"class":638,"line":814},[636,7372,7373],{"class":647},"cp",[636,7375,7376],{"class":655}," -r",[636,7378,7379],{"class":651}," dist",[636,7381,7368],{"class":651},[636,7383,7384],{"class":638,"line":819},[636,7385,689],{"emptyLinePlaceholder":494},[636,7387,7388],{"class":638,"line":825},[636,7389,7390],{"class":641},"# 3. 编译 Go 二进制\n",[636,7392,7393,7396],{"class":638,"line":836},[636,7394,7395],{"class":655},"cd",[636,7397,7398],{"class":651}," service\n",[636,7400,7401,7403,7405,7407,7410],{"class":638,"line":847},[636,7402,771],{"class":647},[636,7404,787],{"class":651},[636,7406,796],{"class":655},[636,7408,7409],{"class":651}," myapp.exe",[636,7411,4979],{"class":655},[636,7413,7414,7417,7420],{"class":638,"line":852},[636,7415,7416],{"class":655},"    -ldflags=",[636,7418,7419],{"class":651},"\"-X myproject\u002Fglobal.RUNCODE=release\"",[636,7421,4979],{"class":655},[636,7423,7424],{"class":638,"line":861},[636,7425,7426],{"class":651},"    main.go\n",[11,7428,7429],{"id":7429},"踩坑记录",[36,7431,7433],{"id":7432},"坑一路由不匹配静态资源-404","坑一：路由不匹配，静态资源 404",[16,7435,7436,7437,7439,7440,7442],{},"首次实现时用了 Gin 的 ",[49,7438,7285],{}," 兜底，但部分静态资源返回 404。排查发现 ",[49,7441,7285],{}," 在某些路由未显式注册时不会按预期触发。",[16,7444,7445,7448,7449,7452,7453,7456,7457,7459],{},[23,7446,7447],{},"解决","：显式注册 ",[49,7450,7451],{},"\u002Fassets\u002F*filepath","、",[49,7454,7455],{},"\u002Ffavicon.ico"," 等路由，",[49,7458,7285],{}," 仅作为兜底。",[36,7461,7463,7464,7466,7467,7470],{"id":7462},"坑二goembed-排除-_-开头文件","坑二：",[49,7465,6831],{}," 排除 ",[49,7468,7469],{},"_"," 开头文件",[16,7472,7473,7474,7477,7478,7480,7481,2522],{},"这是最坑的。Vite 构建产出了一个 ",[49,7475,7476],{},"_plugin-vue_export-helper-xxx.js","，Go 的 ",[49,7479,6831],{}," 当模式为目录时，",[23,7482,7483,7484,7486,7487,7489],{},"硬编码排除了 ",[49,7485,7469],{}," 和 ",[49,7488,2753],{}," 开头的文件",[41,7491,7493],{"className":4052,"code":7492,"language":771,"meta":47,"style":47},"\u002F\u002F 这行会跳过 dist\u002Fassets\u002F_plugin-xxx.js\n\u002F\u002Fgo:embed dist\n",[49,7494,7495,7500],{"__ignoreMap":47},[636,7496,7497],{"class":638,"line":606},[636,7498,7499],{"class":641},"\u002F\u002F 这行会跳过 dist\u002Fassets\u002F_plugin-xxx.js\n",[636,7501,7502],{"class":638,"line":575},[636,7503,6952],{"class":641},[16,7505,7506,7507,2522],{},"错误表现：JS 文件返回的是 HTML 内容（SPA fallback），浏览器报 ",[49,7508,7509],{},"Unexpected token '\u003C'",[16,7511,7512,7514,7515,7517],{},[23,7513,7447],{},"：改 Vite 配置，去除了 ",[49,7516,7469],{}," 前缀：",[41,7519,7523],{"className":7520,"code":7521,"language":7522,"meta":47,"style":47},"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",[49,7524,7525,7530,7537,7544,7551,7559,7597,7602,7607,7612],{"__ignoreMap":47},[636,7526,7527],{"class":638,"line":606},[636,7528,7529],{"class":641},"\u002F\u002F vite.config.ts\n",[636,7531,7532,7535],{"class":638,"line":575},[636,7533,7534],{"class":647},"build",[636,7536,2550],{"class":668},[636,7538,7539,7542],{"class":638,"line":576},[636,7540,7541],{"class":647},"  rollupOptions",[636,7543,2550],{"class":668},[636,7545,7546,7549],{"class":638,"line":692},[636,7547,7548],{"class":647},"    output",[636,7550,2550],{"class":668},[636,7552,7553,7556],{"class":638,"line":698},[636,7554,7555],{"class":647},"      chunkFileNames",[636,7557,7558],{"class":668},"(chunkInfo) {\n",[636,7560,7561,7563,7566,7569,7571,7574,7576,7578,7580,7582,7584,7586,7588,7590,7592,7594],{"class":638,"line":727},[636,7562,4506],{"class":730},[636,7564,7565],{"class":651}," `assets\u002F${",[636,7567,7568],{"class":668},"chunkInfo",[636,7570,2753],{"class":651},[636,7572,7573],{"class":668},"name",[636,7575,2753],{"class":651},[636,7577,3131],{"class":647},[636,7579,2631],{"class":651},[636,7581,2750],{"class":651},[636,7583,2737],{"class":730},[636,7585,7469],{"class":2740},[636,7587,2750],{"class":651},[636,7589,1396],{"class":651},[636,7591,3148],{"class":651},[636,7593,3451],{"class":651},[636,7595,7596],{"class":651},"}-[hash].js`\n",[636,7598,7599],{"class":638,"line":746},[636,7600,7601],{"class":668},"      },\n",[636,7603,7604],{"class":638,"line":762},[636,7605,7606],{"class":668},"    },\n",[636,7608,7609],{"class":638,"line":768},[636,7610,7611],{"class":668},"  },\n",[636,7613,7614],{"class":638,"line":782},[636,7615,7616],{"class":668},"},\n",[16,7618,7619,7620,7622],{},"这样就避免产生 ",[49,7621,7469],{}," 前缀文件名的同时，保持构建产物的一致性和引用关系的正确性。",[36,7624,7626],{"id":7625},"坑三构建缓存导致-embed-内容未更新","坑三：构建缓存导致 embed 内容未更新",[16,7628,7629,7630,7633],{},"多次 ",[49,7631,7632],{},"go build"," 后，Go 的构建缓存可能使用了旧版本的 embed 内容，导致实际运行的文件不是最新的。",[16,7635,7636,7638,7639,7642],{},[23,7637,7447],{},"：使用 ",[49,7640,7641],{},"go build -a"," 强制重新编译所有包。",[11,7644,7645],{"id":7645},"最终效果",[41,7647,7649],{"className":630,"code":7648,"language":632,"meta":47,"style":47},"$ bash build-standalone.sh\n=== 构建成功 ===\n输出: service\u002Fmyapp.exe  41M\n",[49,7650,7651,7661,7671],{"__ignoreMap":47},[636,7652,7653,7655,7658],{"class":638,"line":606},[636,7654,2747],{"class":647},[636,7656,7657],{"class":651}," bash",[636,7659,7660],{"class":651}," build-standalone.sh\n",[636,7662,7663,7665,7668],{"class":638,"line":575},[636,7664,2725],{"class":651},[636,7666,7667],{"class":651}," 构建成功",[636,7669,7670],{"class":651}," ===\n",[636,7672,7673,7676,7679],{"class":638,"line":576},[636,7674,7675],{"class":647},"输出:",[636,7677,7678],{"class":651}," service\u002Fmyapp.exe",[636,7680,7681],{"class":651},"  41M\n",[16,7683,7684],{},"一个 41MB 的 exe，内置前端页面、API 后端、SQLite 数据库（运行时初始化），拖到哪都能跑。",[16,7686,7687],{},[23,7688,7689],{},"部署对比：",[121,7691,7692,7705],{},[124,7693,7694],{},[127,7695,7696,7699,7702],{},[130,7697,7698],{},"方式",[130,7700,7701],{},"文件数",[130,7703,7704],{},"操作",[137,7706,7707,7718],{},[127,7708,7709,7712,7715],{},[142,7710,7711],{},"旧方案",[142,7713,7714],{},"1 exe + 1 web 目录",[142,7716,7717],{},"解压→保持目录结构",[127,7719,7720,7723,7726],{},[142,7721,7722],{},"新方案",[142,7724,7725],{},"1 个 exe",[142,7727,7728],{},"直接扔到服务器运行",[11,7730,1809],{"id":1809},[16,7732,7733,7734,7736],{},"Go 的 ",[49,7735,6831],{}," 是一个强大的工具，但有两个隐藏行为需要注意：",[883,7738,7739,7746],{},[59,7740,7741,7742,7486,7744,7489],{},"嵌入目录时自动排除 ",[49,7743,7469],{},[49,7745,2753],{},[59,7747,7748],{},"构建缓存可能导致 embed 不是最新的",[16,7750,7751],{},"对于前端+后端合体的场景，这是性价比最高的方案——不需要额外的打包工具，纯标准库搞定。",[1422,7753,7754],{},"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":47,"searchDepth":575,"depth":576,"links":7756},[7757,7758,7759,7764,7770,7771],{"id":2498,"depth":575,"text":2498},{"id":6825,"depth":575,"text":6825},{"id":6839,"depth":575,"text":6839,"children":7760},[7761,7762,7763],{"id":6842,"depth":576,"text":6843},{"id":7179,"depth":576,"text":7180},{"id":7291,"depth":576,"text":7292},{"id":7429,"depth":575,"text":7429,"children":7765},[7766,7767,7769],{"id":7432,"depth":576,"text":7433},{"id":7462,"depth":576,"text":7768},"坑二：\u002F\u002Fgo:embed 排除 _ 开头文件",{"id":7625,"depth":576,"text":7626},{"id":7645,"depth":575,"text":7645},{"id":1809,"depth":575,"text":1809},"记录将 Vue 3 前端嵌入 Go 二进制实现单 exe 发布的完整流程，以及遇到的坑和解决方案。","\u002Fposts\u002Fgo-embed-frontend\u002Fimg\u002Fcover.jpg",{},"\u002Fposts\u002Fgo-embed-frontend","2026-06-06T16:00:00",{"title":6808,"description":7772},"posts\u002Fgo-embed-frontend\u002Findex",[265,7780,7781,7782],"Vue","编译","埋坑","2026-06-06","SIQNL24wVAFb18wIGWSB3lNVZUQ9B76jWgQ55TASTYk",{"id":7786,"title":7787,"author":6,"body":7788,"description":9184,"difficulty":597,"draft":598,"extension":599,"image":9185,"meta":9186,"navigation":494,"path":9187,"pinned":598,"published":9188,"seo":9189,"series":6,"seriesOrder":6,"stem":9190,"tags":9191,"updated":9188,"__hash__":9193},"posts\u002Fposts\u002Fjira-cli-intro\u002Findex.md","jira_cli — 把企业 Jira 装进命令行，AI Agent 直接调用",{"type":8,"value":7789,"toc":9163},[7790,7794,7809,7876,7879,7882,7959,7961,7994,8001,8004,8008,8120,8124,8255,8259,8327,8330,8393,8396,8398,8402,8405,8425,8428,8443,8446,8450,8456,8594,8605,8636,8640,8643,8740,8744,8747,8865,8869,8872,8936,8939,8943,8946,9014,9017,9023,9026,9060,9067,9070,9130,9132,9135,9152,9160],[11,7791,7793],{"id":7792},"jira_cli-是什么","jira_cli 是什么",[16,7795,7796,7801,7802,7486,7805,7808],{},[2452,7797,7800],{"href":7798,"rel":7799},"https:\u002F\u002Fgithub.com\u002Fyourname\u002Fjira_cli",[2456],"jira_cli"," 是一个面向企业 Jira（Server \u002F Data Center）的本地命令行工具，Go 语言编写，MIT 协议开源。它同时提供 ",[23,7803,7804],{},"Cobra CLI",[23,7806,7807],{},"MCP stdio server"," 两种入口，既能当日常命令行用，又能直接接入 AI 编程工具。",[41,7810,7812],{"className":630,"code":7811,"language":632,"meta":47,"style":47},"# 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",[49,7813,7814,7819,7832,7843,7858,7862,7867],{"__ignoreMap":47},[636,7815,7816],{"class":638,"line":606},[636,7817,7818],{"class":641},"# CLI 日常使用\n",[636,7820,7821,7823,7826,7829],{"class":638,"line":575},[636,7822,7800],{"class":647},[636,7824,7825],{"class":651}," issue",[636,7827,7828],{"class":651}," search",[636,7830,7831],{"class":651}," \"project = PROJ ORDER BY updated DESC\"\n",[636,7833,7834,7836,7838,7840],{"class":638,"line":576},[636,7835,7800],{"class":647},[636,7837,7825],{"class":651},[636,7839,4033],{"class":651},[636,7841,7842],{"class":651}," PROJ-123\n",[636,7844,7845,7847,7849,7852,7855],{"class":638,"line":692},[636,7846,7800],{"class":647},[636,7848,7825],{"class":651},[636,7850,7851],{"class":651}," comment",[636,7853,7854],{"class":651}," PROJ-123",[636,7856,7857],{"class":651}," \"处理完成，请测试验证\"\n",[636,7859,7860],{"class":638,"line":698},[636,7861,689],{"emptyLinePlaceholder":494},[636,7863,7864],{"class":638,"line":727},[636,7865,7866],{"class":641},"# MCP 模式 — 给 AI 装上操作 Jira 的手\n",[636,7868,7869,7871,7873],{"class":638,"line":746},[636,7870,7800],{"class":647},[636,7872,4274],{"class":651},[636,7874,7875],{"class":655}," --user=work\n",[11,7877,7878],{"id":7878},"为什么需要它",[16,7880,7881],{},"大多数 Jira 命令行工具只解决\"开发者手动操作 Jira\"这一个场景。但 2026 年 AI 编程工具已经普及，你的 AI Agent（Claude Code、Codex、Cursor 等）也需要读写 Jira。痛点就来了：",[121,7883,7884,7894],{},[124,7885,7886],{},[127,7887,7888,7891],{},[130,7889,7890],{},"痛点",[130,7892,7893],{},"jira_cli 怎么解",[137,7895,7896,7904,7912,7924,7936,7947],{},[127,7897,7898,7901],{},[142,7899,7900],{},"企业 Jira 在内网，没有公网 API",[142,7902,7903],{},"本地 stdio MCP server，不经过外网",[127,7905,7906,7909],{},[142,7907,7908],{},"AI 可能误删、误写 Jira 数据",[142,7910,7911],{},"账号级只读策略，AI 只能查不能改",[127,7913,7914,7917],{},[142,7915,7916],{},"团队多人多 Jira 实例",[142,7918,7919,7920,7923],{},"多 Profile 管理，",[49,7921,7922],{},"--user=work"," 切换",[127,7925,7926,7929],{},[142,7927,7928],{},"Windows 终端体验差",[142,7930,7931,7932,7935],{},"原生 ",[49,7933,7934],{},".exe"," 构建 + 一键安装脚本",[127,7937,7938,7941],{},[142,7939,7940],{},"AI 生成 JQL 困难",[142,7942,7943,7944],{},"字段元数据发现：",[49,7945,7946],{},"field list --query xxx",[127,7948,7949,7952],{},[142,7950,7951],{},"MCP 直接拿原始 JSON 太冗长",[142,7953,7954,7955,7958],{},"紧凑视图 + ",[49,7956,7957],{},"--view summary"," 减少 token 消耗",[11,7960,6171],{"id":6171},[41,7962,7964],{"className":630,"code":7963,"language":632,"meta":47,"style":47},"# 从源码安装（要求 Go 1.26.3+）\ngo install github.com\u002Fyourname\u002Fjira_cli@latest\n\n# 或下载预编译二进制\n# https:\u002F\u002Fgithub.com\u002Fyourname\u002Fjira_cli\u002Freleases\n",[49,7965,7966,7971,7980,7984,7989],{"__ignoreMap":47},[636,7967,7968],{"class":638,"line":606},[636,7969,7970],{"class":641},"# 从源码安装（要求 Go 1.26.3+）\n",[636,7972,7973,7975,7977],{"class":638,"line":575},[636,7974,771],{"class":647},[636,7976,712],{"class":651},[636,7978,7979],{"class":651}," github.com\u002Fyourname\u002Fjira_cli@latest\n",[636,7981,7982],{"class":638,"line":576},[636,7983,689],{"emptyLinePlaceholder":494},[636,7985,7986],{"class":638,"line":692},[636,7987,7988],{"class":641},"# 或下载预编译二进制\n",[636,7990,7991],{"class":638,"line":698},[636,7992,7993],{"class":641},"# https:\u002F\u002Fgithub.com\u002Fyourname\u002Fjira_cli\u002Freleases\n",[16,7995,7996,7997,8000],{},"Windows 用户下载 zip 包解压后双击 ",[49,7998,7999],{},"install.bat"," 即可全局安装，自动加入 PATH。",[11,8002,8003],{"id":8003},"快速上手",[36,8005,8007],{"id":8006},"_1-配置认证","1. 配置认证",[41,8009,8011],{"className":630,"code":8010,"language":632,"meta":47,"style":47},"# 设置 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",[49,8012,8013,8018,8034,8038,8043,8063,8067,8072,8101,8105,8110],{"__ignoreMap":47},[636,8014,8015],{"class":638,"line":606},[636,8016,8017],{"class":641},"# 设置 Jira 地址\n",[636,8019,8020,8022,8025,8028,8031],{"class":638,"line":575},[636,8021,7800],{"class":647},[636,8023,8024],{"class":651}," set",[636,8026,8027],{"class":651}," base-url",[636,8029,8030],{"class":651}," https:\u002F\u002Fjira.your-company.com",[636,8032,8033],{"class":655}," --global\n",[636,8035,8036],{"class":638,"line":576},[636,8037,689],{"emptyLinePlaceholder":494},[636,8039,8040],{"class":638,"line":692},[636,8041,8042],{"class":641},"# Token 认证（推荐）\n",[636,8044,8045,8047,8049,8052,8054,8057,8059,8061],{"class":638,"line":698},[636,8046,7800],{"class":647},[636,8048,8024],{"class":651},[636,8050,8051],{"class":651}," token",[636,8053,1177],{"class":730},[636,8055,8056],{"class":651},"your-toke",[636,8058,1582],{"class":668},[636,8060,1186],{"class":730},[636,8062,8033],{"class":655},[636,8064,8065],{"class":638,"line":727},[636,8066,689],{"emptyLinePlaceholder":494},[636,8068,8069],{"class":638,"line":746},[636,8070,8071],{"class":641},"# 或用户名密码认证\n",[636,8073,8074,8076,8078,8080,8082,8085,8087,8089,8091,8094,8097,8099],{"class":638,"line":762},[636,8075,7800],{"class":647},[636,8077,8024],{"class":651},[636,8079,6245],{"class":651},[636,8081,1177],{"class":730},[636,8083,8084],{"class":651},"usernam",[636,8086,1568],{"class":668},[636,8088,1186],{"class":730},[636,8090,1177],{"class":730},[636,8092,8093],{"class":651},"passwor",[636,8095,8096],{"class":668},"d",[636,8098,1186],{"class":730},[636,8100,8033],{"class":655},[636,8102,8103],{"class":638,"line":768},[636,8104,689],{"emptyLinePlaceholder":494},[636,8106,8107],{"class":638,"line":782},[636,8108,8109],{"class":641},"# 验证一下\n",[636,8111,8112,8114,8117],{"class":638,"line":805},[636,8113,7800],{"class":647},[636,8115,8116],{"class":651}," user",[636,8118,8119],{"class":651}," me\n",[36,8121,8123],{"id":8122},"_2-日常使用","2. 日常使用",[41,8125,8127],{"className":630,"code":8126,"language":632,"meta":47,"style":47},"# 查自己今天的任务\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",[49,8128,8129,8134,8145,8149,8154,8164,8168,8173,8187,8204,8208,8213,8226,8230,8235],{"__ignoreMap":47},[636,8130,8131],{"class":638,"line":606},[636,8132,8133],{"class":641},"# 查自己今天的任务\n",[636,8135,8136,8138,8140,8142],{"class":638,"line":575},[636,8137,7800],{"class":647},[636,8139,7825],{"class":651},[636,8141,7828],{"class":651},[636,8143,8144],{"class":651}," \"assignee = currentUser() ORDER BY updated DESC\"\n",[636,8146,8147],{"class":638,"line":576},[636,8148,689],{"emptyLinePlaceholder":494},[636,8150,8151],{"class":638,"line":692},[636,8152,8153],{"class":641},"# 看某个任务详情\n",[636,8155,8156,8158,8160,8162],{"class":638,"line":698},[636,8157,7800],{"class":647},[636,8159,7825],{"class":651},[636,8161,4033],{"class":651},[636,8163,7842],{"class":651},[636,8165,8166],{"class":638,"line":727},[636,8167,689],{"emptyLinePlaceholder":494},[636,8169,8170],{"class":638,"line":746},[636,8171,8172],{"class":641},"# 流转状态\n",[636,8174,8175,8177,8179,8182,8184],{"class":638,"line":762},[636,8176,7800],{"class":647},[636,8178,7825],{"class":651},[636,8180,8181],{"class":651}," transitions",[636,8183,7854],{"class":651},[636,8185,8186],{"class":641},"                    # 可以转哪些\n",[636,8188,8189,8191,8193,8196,8198,8201],{"class":638,"line":768},[636,8190,7800],{"class":647},[636,8192,7825],{"class":651},[636,8194,8195],{"class":651}," do-transition",[636,8197,7854],{"class":651},[636,8199,8200],{"class":655}," 21",[636,8202,8203],{"class":641},"               # 执行流转\n",[636,8205,8206],{"class":638,"line":782},[636,8207,689],{"emptyLinePlaceholder":494},[636,8209,8210],{"class":638,"line":805},[636,8211,8212],{"class":641},"# 添加备注\n",[636,8214,8215,8217,8219,8221,8223],{"class":638,"line":814},[636,8216,7800],{"class":647},[636,8218,7825],{"class":651},[636,8220,7851],{"class":651},[636,8222,7854],{"class":651},[636,8224,8225],{"class":651}," \"已定位到问题，正在修复\"\n",[636,8227,8228],{"class":638,"line":819},[636,8229,689],{"emptyLinePlaceholder":494},[636,8231,8232],{"class":638,"line":825},[636,8233,8234],{"class":641},"# 创建任务\n",[636,8236,8237,8239,8241,8243,8246,8249,8252],{"class":638,"line":836},[636,8238,7800],{"class":647},[636,8240,7825],{"class":651},[636,8242,6131],{"class":651},[636,8244,8245],{"class":651}," PROJ",[636,8247,8248],{"class":651}," Task",[636,8250,8251],{"class":655}," --fields",[636,8253,8254],{"class":651}," @fields.json\n",[36,8256,8258],{"id":8257},"_3-配给-ai-使用","3. 配给 AI 使用",[41,8260,8262],{"className":630,"code":8261,"language":632,"meta":47,"style":47},"# 先配置一个只读账号给 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",[49,8263,8264,8269,8286,8309],{"__ignoreMap":47},[636,8265,8266],{"class":638,"line":606},[636,8267,8268],{"class":641},"# 先配置一个只读账号给 AI 用\n",[636,8270,8271,8273,8275,8277,8279,8281,8284],{"class":638,"line":575},[636,8272,7800],{"class":647},[636,8274,8024],{"class":651},[636,8276,8027],{"class":651},[636,8278,8030],{"class":651},[636,8280,1174],{"class":655},[636,8282,8283],{"class":651}," ai",[636,8285,8033],{"class":655},[636,8287,8288,8290,8292,8294,8296,8299,8301,8303,8305,8307],{"class":638,"line":576},[636,8289,7800],{"class":647},[636,8291,8024],{"class":651},[636,8293,8051],{"class":651},[636,8295,1177],{"class":730},[636,8297,8298],{"class":651},"toke",[636,8300,1582],{"class":668},[636,8302,1186],{"class":730},[636,8304,1174],{"class":655},[636,8306,8283],{"class":651},[636,8308,8033],{"class":655},[636,8310,8311,8313,8315,8318,8321,8323,8325],{"class":638,"line":692},[636,8312,7800],{"class":647},[636,8314,8024],{"class":651},[636,8316,8317],{"class":651}," readonly",[636,8319,8320],{"class":651}," on",[636,8322,1174],{"class":655},[636,8324,8283],{"class":651},[636,8326,8033],{"class":655},[16,8328,8329],{},"然后在 AI 工具的 MCP 配置里加上：",[41,8331,8333],{"className":5844,"code":8332,"language":5846,"meta":47,"style":47},"{\n  \"mcpServers\": {\n    \"jira\": {\n      \"command\": \"jira_cli\",\n      \"args\": [\"mcp\", \"--user=ai\"]\n    }\n  }\n}\n",[49,8334,8335,8339,8345,8352,8364,8381,8385,8389],{"__ignoreMap":47},[636,8336,8337],{"class":638,"line":606},[636,8338,5853],{"class":668},[636,8340,8341,8343],{"class":638,"line":575},[636,8342,5858],{"class":655},[636,8344,2550],{"class":668},[636,8346,8347,8350],{"class":638,"line":576},[636,8348,8349],{"class":655},"    \"jira\"",[636,8351,2550],{"class":668},[636,8353,8354,8357,8359,8362],{"class":638,"line":692},[636,8355,8356],{"class":655},"      \"command\"",[636,8358,2342],{"class":668},[636,8360,8361],{"class":651},"\"jira_cli\"",[636,8363,2289],{"class":668},[636,8365,8366,8369,8371,8374,8376,8379],{"class":638,"line":698},[636,8367,8368],{"class":655},"      \"args\"",[636,8370,2373],{"class":668},[636,8372,8373],{"class":651},"\"mcp\"",[636,8375,1396],{"class":668},[636,8377,8378],{"class":651},"\"--user=ai\"",[636,8380,1099],{"class":668},[636,8382,8383],{"class":638,"line":727},[636,8384,2591],{"class":668},[636,8386,8387],{"class":638,"line":746},[636,8388,2596],{"class":668},[636,8390,8391],{"class":638,"line":762},[636,8392,1951],{"class":668},[16,8394,8395],{},"这样 AI 就能查 Jira 但不能修改，既有了上下文，又不用担心误操作。",[11,8397,6653],{"id":6653},[36,8399,8401],{"id":8400},"_1-账号级只读策略","1. 账号级只读策略",[16,8403,8404],{},"这是 jira_cli 最实用的设计。给 AI Agent 配置只读账号：",[41,8406,8408],{"className":630,"code":8407,"language":632,"meta":47,"style":47},"jira_cli set readonly on --name ai\n",[49,8409,8410],{"__ignoreMap":47},[636,8411,8412,8414,8416,8418,8420,8422],{"class":638,"line":606},[636,8413,7800],{"class":647},[636,8415,8024],{"class":651},[636,8417,8317],{"class":651},[636,8419,8320],{"class":651},[636,8421,1174],{"class":655},[636,8423,8424],{"class":651}," ai\n",[16,8426,8427],{},"只读模式下：",[56,8429,8430,8433,8436],{},[59,8431,8432],{},"CLI 写命令被隐藏和拒绝",[59,8434,8435],{},"MCP 的写入工具不会注册",[59,8437,8438,8439,8442],{},"环境变量 ",[49,8440,8441],{},"JIRA_READONLY=true"," 也可以临时覆盖",[16,8444,8445],{},"AI 只能查 JQL、看任务、查字段、读评论，绝对不会误改数据。需要创建\u002F更新时，另配一个有写入权限的账号由开发者自己操作。",[36,8447,8449],{"id":8448},"_2-多-profile-切换","2. 多 Profile 切换",[16,8451,8452,8453,8455],{},"一个配置文件里管理多个 Jira 账号，",[49,8454,1533],{}," 切换：",[41,8457,8459],{"className":630,"code":8458,"language":632,"meta":47,"style":47},"# 工作账号\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",[49,8460,8461,8466,8484,8507,8511,8516,8534,8557,8561,8566,8580],{"__ignoreMap":47},[636,8462,8463],{"class":638,"line":606},[636,8464,8465],{"class":641},"# 工作账号\n",[636,8467,8468,8470,8472,8474,8477,8479,8482],{"class":638,"line":575},[636,8469,7800],{"class":647},[636,8471,8024],{"class":651},[636,8473,8027],{"class":651},[636,8475,8476],{"class":651}," https:\u002F\u002Fjira.company.com",[636,8478,1174],{"class":655},[636,8480,8481],{"class":651}," work",[636,8483,8033],{"class":655},[636,8485,8486,8488,8490,8492,8494,8497,8499,8501,8503,8505],{"class":638,"line":576},[636,8487,7800],{"class":647},[636,8489,8024],{"class":651},[636,8491,8051],{"class":651},[636,8493,1177],{"class":730},[636,8495,8496],{"class":651},"work-toke",[636,8498,1582],{"class":668},[636,8500,1186],{"class":730},[636,8502,1174],{"class":655},[636,8504,8481],{"class":651},[636,8506,8033],{"class":655},[636,8508,8509],{"class":638,"line":692},[636,8510,689],{"emptyLinePlaceholder":494},[636,8512,8513],{"class":638,"line":698},[636,8514,8515],{"class":641},"# 个人账号\n",[636,8517,8518,8520,8522,8524,8527,8529,8532],{"class":638,"line":727},[636,8519,7800],{"class":647},[636,8521,8024],{"class":651},[636,8523,8027],{"class":651},[636,8525,8526],{"class":651}," https:\u002F\u002Fjira.personal.com",[636,8528,1174],{"class":655},[636,8530,8531],{"class":651}," personal",[636,8533,8033],{"class":655},[636,8535,8536,8538,8540,8542,8544,8547,8549,8551,8553,8555],{"class":638,"line":746},[636,8537,7800],{"class":647},[636,8539,8024],{"class":651},[636,8541,8051],{"class":651},[636,8543,1177],{"class":730},[636,8545,8546],{"class":651},"personal-toke",[636,8548,1582],{"class":668},[636,8550,1186],{"class":730},[636,8552,1174],{"class":655},[636,8554,8531],{"class":651},[636,8556,8033],{"class":655},[636,8558,8559],{"class":638,"line":762},[636,8560,689],{"emptyLinePlaceholder":494},[636,8562,8563],{"class":638,"line":768},[636,8564,8565],{"class":641},"# 使用时切换\n",[636,8567,8568,8570,8573,8575,8577],{"class":638,"line":782},[636,8569,7800],{"class":647},[636,8571,8572],{"class":655}," --user=work",[636,8574,7825],{"class":651},[636,8576,7828],{"class":651},[636,8578,8579],{"class":651}," \"project = PROJ\"\n",[636,8581,8582,8584,8587,8589,8591],{"class":638,"line":805},[636,8583,7800],{"class":647},[636,8585,8586],{"class":655}," --user=personal",[636,8588,7825],{"class":651},[636,8590,7828],{"class":651},[636,8592,8593],{"class":651}," \"project = SIDE\"\n",[16,8595,8596,8597,8600,8601,8604],{},"同时支持",[23,8598,8599],{},"项目级配置","（",[49,8602,8603],{},"--local","）和工作目录绑定：",[41,8606,8608],{"className":630,"code":8607,"language":632,"meta":47,"style":47},"# 在项目目录下，配置只对当前项目生效\ncd ~\u002Fprojects\u002Fbackend\njira_cli set base-url https:\u002F\u002Fjira-backend.company.com --local\n",[49,8609,8610,8615,8622],{"__ignoreMap":47},[636,8611,8612],{"class":638,"line":606},[636,8613,8614],{"class":641},"# 在项目目录下，配置只对当前项目生效\n",[636,8616,8617,8619],{"class":638,"line":575},[636,8618,7395],{"class":655},[636,8620,8621],{"class":651}," ~\u002Fprojects\u002Fbackend\n",[636,8623,8624,8626,8628,8630,8633],{"class":638,"line":576},[636,8625,7800],{"class":647},[636,8627,8024],{"class":651},[636,8629,8027],{"class":651},[636,8631,8632],{"class":651}," https:\u002F\u002Fjira-backend.company.com",[636,8634,8635],{"class":655}," --local\n",[36,8637,8639],{"id":8638},"_3-对-ai-友好的输出","3. 对 AI 友好的输出",[16,8641,8642],{},"jira_cli 在输出设计上充分考虑了 AI Agent 的 token 消耗：",[41,8644,8646],{"className":630,"code":8645,"language":632,"meta":47,"style":47},"# 紧凑视图 — 去掉 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",[49,8647,8648,8653,8669,8673,8678,8695,8699,8704,8719,8723,8728],{"__ignoreMap":47},[636,8649,8650],{"class":638,"line":606},[636,8651,8652],{"class":641},"# 紧凑视图 — 去掉 self\u002Favatar\u002F缩略图等无用字段\n",[636,8654,8655,8657,8659,8661,8663,8666],{"class":638,"line":575},[636,8656,7800],{"class":647},[636,8658,7825],{"class":651},[636,8660,4033],{"class":651},[636,8662,7854],{"class":651},[636,8664,8665],{"class":655}," --view",[636,8667,8668],{"class":651}," summary\n",[636,8670,8671],{"class":638,"line":576},[636,8672,689],{"emptyLinePlaceholder":494},[636,8674,8675],{"class":638,"line":692},[636,8676,8677],{"class":641},"# 表格输出 — 一眼看到关键信息\n",[636,8679,8680,8682,8684,8686,8689,8692],{"class":638,"line":698},[636,8681,7800],{"class":647},[636,8683,7825],{"class":651},[636,8685,7828],{"class":651},[636,8687,8688],{"class":651}," \"project = PROJ\"",[636,8690,8691],{"class":655}," --output",[636,8693,8694],{"class":651}," table\n",[636,8696,8697],{"class":638,"line":727},[636,8698,689],{"emptyLinePlaceholder":494},[636,8700,8701],{"class":638,"line":746},[636,8702,8703],{"class":641},"# JSON 输出 — 程序解析，不转义 Unicode\n",[636,8705,8706,8708,8710,8713,8715,8717],{"class":638,"line":762},[636,8707,7800],{"class":647},[636,8709,8691],{"class":655},[636,8711,8712],{"class":651}," json",[636,8714,7825],{"class":651},[636,8716,4033],{"class":651},[636,8718,7842],{"class":651},[636,8720,8721],{"class":638,"line":768},[636,8722,689],{"emptyLinePlaceholder":494},[636,8724,8725],{"class":638,"line":782},[636,8726,8727],{"class":641},"# 自定义搜索列 — 只要需要的字段\n",[636,8729,8730,8732,8734,8737],{"class":638,"line":805},[636,8731,7800],{"class":647},[636,8733,8024],{"class":651},[636,8735,8736],{"class":651}," search-columns",[636,8738,8739],{"class":651}," key,summary,fixVersions\n",[36,8741,8743],{"id":8742},"_4-字段元数据发现","4. 字段元数据发现",[16,8745,8746],{},"Jira 的 customfield 让人头疼，jira_cli 提供了完整的字段发现能力：",[41,8748,8750],{"className":630,"code":8749,"language":632,"meta":47,"style":47},"# 列出所有字段及其类型\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",[49,8751,8752,8757,8766,8770,8775,8789,8793,8798,8809,8813,8818,8835,8839,8844],{"__ignoreMap":47},[636,8753,8754],{"class":638,"line":606},[636,8755,8756],{"class":641},"# 列出所有字段及其类型\n",[636,8758,8759,8761,8764],{"class":638,"line":575},[636,8760,7800],{"class":647},[636,8762,8763],{"class":651}," field",[636,8765,2685],{"class":651},[636,8767,8768],{"class":638,"line":576},[636,8769,689],{"emptyLinePlaceholder":494},[636,8771,8772],{"class":638,"line":692},[636,8773,8774],{"class":641},"# 搜索包含特定关键词的字段\n",[636,8776,8777,8779,8781,8783,8786],{"class":638,"line":698},[636,8778,7800],{"class":647},[636,8780,8763],{"class":651},[636,8782,6066],{"class":651},[636,8784,8785],{"class":655}," --query",[636,8787,8788],{"class":651}," \"版本\"\n",[636,8790,8791],{"class":638,"line":727},[636,8792,689],{"emptyLinePlaceholder":494},[636,8794,8795],{"class":638,"line":746},[636,8796,8797],{"class":641},"# 只看自定义字段\n",[636,8799,8800,8802,8804,8806],{"class":638,"line":762},[636,8801,7800],{"class":647},[636,8803,8763],{"class":651},[636,8805,6066],{"class":651},[636,8807,8808],{"class":655}," --custom-only\n",[636,8810,8811],{"class":638,"line":768},[636,8812,689],{"emptyLinePlaceholder":494},[636,8814,8815],{"class":638,"line":782},[636,8816,8817],{"class":641},"# 创建任务前，先看看需要哪些字段\n",[636,8819,8820,8822,8824,8827,8829,8832],{"class":638,"line":805},[636,8821,7800],{"class":647},[636,8823,7825],{"class":651},[636,8825,8826],{"class":651}," create-meta",[636,8828,8245],{"class":651},[636,8830,8831],{"class":655}," --issue-type",[636,8833,8834],{"class":651}," Task\n",[636,8836,8837],{"class":638,"line":814},[636,8838,689],{"emptyLinePlaceholder":494},[636,8840,8841],{"class":638,"line":819},[636,8842,8843],{"class":641},"# 分页查看可选值\n",[636,8845,8846,8848,8850,8853,8855,8857,8860,8862],{"class":638,"line":825},[636,8847,7800],{"class":647},[636,8849,7825],{"class":651},[636,8851,8852],{"class":651}," create-meta-values",[636,8854,8245],{"class":651},[636,8856,8248],{"class":651},[636,8858,8859],{"class":651}," priority",[636,8861,8785],{"class":655},[636,8863,8864],{"class":651}," High\n",[36,8866,8868],{"id":8867},"_5-mcp-写入控制","5. MCP 写入控制",[16,8870,8871],{},"除了全局只读，MCP 还有更细粒度的控制：",[121,8873,8874,8884],{},[124,8875,8876],{},[127,8877,8878,8881],{},[130,8879,8880],{},"环境变量",[130,8882,8883],{},"作用",[137,8885,8886,8896,8906,8916,8926],{},[127,8887,8888,8893],{},[142,8889,8890],{},[49,8891,8892],{},"JIRA_CREATE_OPEN=false",[142,8894,8895],{},"禁用创建\u002F更新工具",[127,8897,8898,8903],{},[142,8899,8900],{},[49,8901,8902],{},"JIRA_DISABLE_DISCLAIMER=true",[142,8904,8905],{},"去掉 AI 自动添加的备注声明",[127,8907,8908,8913],{},[142,8909,8910],{},[49,8911,8912],{},"JIRA_CREATE_SUMMARY_MAX",[142,8914,8915],{},"限制摘要长度",[127,8917,8918,8923],{},[142,8919,8920],{},[49,8921,8922],{},"JIRA_CREATE_DESCRIPTION_MAX",[142,8924,8925],{},"限制描述长度",[127,8927,8928,8933],{},[142,8929,8930],{},[49,8931,8932],{},"JIRA_CREATE_FIELDS_MAX_BYTES",[142,8934,8935],{},"限制创建字段的 JSON 大小",[16,8937,8938],{},"这些限制确保 AI 即使有写入权限也不会\"失控\"——例如防止 AI 往描述里塞几万字的废话。",[36,8940,8942],{"id":8941},"_6-中英双语帮助","6. 中英双语帮助",[16,8944,8945],{},"帮助信息默认中文，也支持一键切英文：",[41,8947,8949],{"className":630,"code":8948,"language":632,"meta":47,"style":47},"# 保存英文偏好\njira_cli set help-language en\n\n# 切回中文\njira_cli set help-language zh\n\n# 临时覆盖\njira_cli --lang en issue search -h\n",[49,8950,8951,8956,8968,8972,8977,8988,8992,8997],{"__ignoreMap":47},[636,8952,8953],{"class":638,"line":606},[636,8954,8955],{"class":641},"# 保存英文偏好\n",[636,8957,8958,8960,8962,8965],{"class":638,"line":575},[636,8959,7800],{"class":647},[636,8961,8024],{"class":651},[636,8963,8964],{"class":651}," help-language",[636,8966,8967],{"class":651}," en\n",[636,8969,8970],{"class":638,"line":576},[636,8971,689],{"emptyLinePlaceholder":494},[636,8973,8974],{"class":638,"line":692},[636,8975,8976],{"class":641},"# 切回中文\n",[636,8978,8979,8981,8983,8985],{"class":638,"line":698},[636,8980,7800],{"class":647},[636,8982,8024],{"class":651},[636,8984,8964],{"class":651},[636,8986,8987],{"class":651}," zh\n",[636,8989,8990],{"class":638,"line":727},[636,8991,689],{"emptyLinePlaceholder":494},[636,8993,8994],{"class":638,"line":746},[636,8995,8996],{"class":641},"# 临时覆盖\n",[636,8998,8999,9001,9004,9007,9009,9011],{"class":638,"line":762},[636,9000,7800],{"class":647},[636,9002,9003],{"class":655}," --lang",[636,9005,9006],{"class":651}," en",[636,9008,7825],{"class":651},[636,9010,7828],{"class":651},[636,9012,9013],{"class":655}," -h\n",[11,9015,9016],{"id":9016},"架构一览",[41,9018,9021],{"className":9019,"code":9020,"language":46},[44],"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",[49,9022,9020],{"__ignoreMap":47},[16,9024,9025],{},"设计原则清晰：",[56,9027,9028,9036,9044,9052],{},[59,9029,9030,9035],{},[23,9031,9032],{},[49,9033,9034],{},"pkg\u002Fjira"," 是纯 Jira 客户端，HTTP、认证、上下文处理都在这里",[59,9037,9038,9043],{},[23,9039,9040],{},[49,9041,9042],{},"cmd"," 只管 Cobra 解析和呈现",[59,9045,9046,9051],{},[23,9047,9048],{},[49,9049,9050],{},"internal\u002Ftools\u002Fjira_tools"," 只管 MCP schema 验证和结果适配",[59,9053,9054,9059],{},[23,9055,9056],{},[49,9057,9058],{},"internal\u002Fconfig"," 拥有 Profile 的全部逻辑，不分散到 cmd",[16,9061,9062,9063,9066],{},"每一层职责单一，新增 Jira 能力时按 ",[49,9064,9065],{},"endpoints.go → Client 方法 → CLI 命令 → MCP tool → 策略注册"," 的路径走就行。",[11,9068,9069],{"id":9069},"和已有方案的对比",[121,9071,9072,9084],{},[124,9073,9074],{},[127,9075,9076,9078,9081],{},[130,9077,3310],{},[130,9079,9080],{},"适合场景",[130,9082,9083],{},"jira_cli 的区别",[137,9085,9086,9097,9108,9119],{},[127,9087,9088,9091,9094],{},[142,9089,9090],{},"Atlassian Rovo MCP Server",[142,9092,9093],{},"Jira Cloud、官方 OAuth",[142,9095,9096],{},"jira_cli 是本地 stdio，适合内网 Server\u002FDC",[127,9098,9099,9102,9105],{},[142,9100,9101],{},"go-jira \u002F jira-cli 社区版",[142,9103,9104],{},"纯命令行管理",[142,9106,9107],{},"jira_cli 同时提供 MCP server，面向 AI 工作流",[127,9109,9110,9113,9116],{},[142,9111,9112],{},"直接调 REST API",[142,9114,9115],{},"自定义集成",[142,9117,9118],{},"jira_cli 封装了认证、只读策略、字段元数据",[127,9120,9121,9124,9127],{},[142,9122,9123],{},"Jira 网页",[142,9125,9126],{},"日常使用",[142,9128,9129],{},"jira_cli 把 Jira 变成终端里的一行命令",[11,9131,1809],{"id":1809},[16,9133,9134],{},"如果你的团队满足以下任一条件，jira_cli 值得一试：",[56,9136,9137,9140,9143,9146,9149],{},[59,9138,9139],{},"企业 Jira 部署在内网，想接入 AI 编程工具",[59,9141,9142],{},"想给 AI Agent 只读权限来查 Jira 上下文",[59,9144,9145],{},"需要命令行快速查看\u002F操作 Jira，不想切浏览器",[59,9147,9148],{},"需要管理多个 Jira 实例和多套账号",[59,9150,9151],{},"Windows 用户想要无摩擦的 CLI 体验",[16,9153,9154,9155,9159],{},"GitHub：",[2452,9156,9158],{"href":7798,"rel":9157},[2456],"github.com\u002Fyourname\u002Fjira_cli"," | MIT 协议 | Go 1.26.3+",[1422,9161,9162],{},"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":47,"searchDepth":575,"depth":576,"links":9164},[9165,9166,9167,9168,9173,9181,9182,9183],{"id":7792,"depth":575,"text":7793},{"id":7878,"depth":575,"text":7878},{"id":6171,"depth":575,"text":6171},{"id":8003,"depth":575,"text":8003,"children":9169},[9170,9171,9172],{"id":8006,"depth":576,"text":8007},{"id":8122,"depth":576,"text":8123},{"id":8257,"depth":576,"text":8258},{"id":6653,"depth":575,"text":6653,"children":9174},[9175,9176,9177,9178,9179,9180],{"id":8400,"depth":576,"text":8401},{"id":8448,"depth":576,"text":8449},{"id":8638,"depth":576,"text":8639},{"id":8742,"depth":576,"text":8743},{"id":8867,"depth":576,"text":8868},{"id":8941,"depth":576,"text":8942},{"id":9016,"depth":575,"text":9016},{"id":9069,"depth":575,"text":9069},{"id":1809,"depth":575,"text":1809},"介绍 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":7787,"description":9184},"posts\u002Fjira-cli-intro\u002Findex",[9192,6803,6020,265,6021,6804],"Jira","vNH6sF2mMFmSjbHoEI2OqmYCgpFmaqyKt-MUfcQqXrU",{"id":4,"title":5,"author":6,"body":9195,"description":596,"difficulty":597,"draft":598,"extension":599,"image":600,"meta":9595,"navigation":494,"path":602,"pinned":598,"published":603,"seo":9596,"series":605,"seriesOrder":606,"stem":607,"tags":9597,"updated":611,"__hash__":612},{"type":8,"value":9196,"toc":9575},[9197,9199,9201,9207,9209,9211,9216,9218,9242,9244,9254,9256,9258,9310,9312,9314,9319,9321,9327,9345,9347,9349,9355,9357,9393,9395,9489,9491,9495,9497,9501,9506,9508,9510,9553,9555,9559,9563,9567,9569,9571],[11,9198,14],{"id":13},[16,9200,18],{},[16,9202,21,9203,26,9205,30],{},[23,9204,25],{},[23,9206,29],{},[11,9208,34],{"id":33},[36,9210,39],{"id":38},[41,9212,9214],{"className":9213,"code":45,"language":46,"meta":47},[44],[49,9215,45],{"__ignoreMap":47},[36,9217,54],{"id":53},[56,9219,9220,9228,9232,9236],{},[59,9221,9222,64,9224,64,9226,71],{},[23,9223,63],{},[23,9225,67],{},[23,9227,70],{},[59,9229,9230,77],{},[23,9231,76],{},[59,9233,9234,83],{},[23,9235,82],{},[59,9237,86,9238,90,9240,94],{},[49,9239,89],{},[49,9241,93],{},[36,9243,98],{"id":97},[56,9245,9246,9250,9252],{},[59,9247,103,9248,106],{},[49,9249,89],{},[59,9251,109],{},[59,9253,112],{},[36,9255,116],{"id":115},[16,9257,119],{},[121,9259,9260,9268],{},[124,9261,9262],{},[127,9263,9264,9266],{},[130,9265,132],{},[130,9267,135],{},[137,9269,9270,9278,9286,9294,9302],{},[127,9271,9272,9276],{},[142,9273,144,9274],{},[23,9275,147],{},[142,9277,150],{},[127,9279,9280,9284],{},[142,9281,155,9282],{},[23,9283,158],{},[142,9285,161],{},[127,9287,9288,9292],{},[142,9289,166,9290],{},[23,9291,169],{},[142,9293,172],{},[127,9295,9296,9300],{},[142,9297,177,9298],{},[23,9299,180],{},[142,9301,183],{},[127,9303,9304,9308],{},[142,9305,188,9306],{},[23,9307,191],{},[142,9309,194],{},[11,9311,198],{"id":197},[36,9313,39],{"id":201},[41,9315,9317],{"className":9316,"code":205,"language":46,"meta":47},[44],[49,9318,205],{"__ignoreMap":47},[36,9320,211],{"id":210},[16,9322,214,9323,218,9325,222],{},[23,9324,217],{},[23,9326,221],{},[56,9328,9329,9333,9337,9341],{},[59,9330,227,9331,231],{},[23,9332,230],{},[59,9334,234,9335,238],{},[23,9336,237],{},[59,9338,241,9339,245],{},[23,9340,244],{},[59,9342,248,9343,252],{},[23,9344,251],{},[16,9346,255],{},[36,9348,259],{"id":258},[16,9350,262,9351,64,9353,269],{},[23,9352,265],{},[23,9354,268],{},[16,9356,272],{},[121,9358,9359,9367],{},[124,9360,9361],{},[127,9362,9363,9365],{},[130,9364,281],{},[130,9366,284],{},[137,9368,9369,9377,9385],{},[127,9370,9371,9375],{},[142,9372,291,9373],{},[23,9374,268],{},[142,9376,296],{},[127,9378,9379,9383],{},[142,9380,301,9381],{},[23,9382,304],{},[142,9384,307],{},[127,9386,9387,9391],{},[142,9388,312,9389],{},[23,9390,315],{},[142,9392,318],{},[11,9394,322],{"id":321},[121,9396,9397,9407],{},[124,9398,9399],{},[127,9400,9401,9403,9405],{},[130,9402,331],{},[130,9404,334],{},[130,9406,337],{},[137,9408,9409,9419,9429,9439,9449,9459,9469,9479],{},[127,9410,9411,9415,9417],{},[142,9412,227,9413],{},[23,9414,346],{},[142,9416,349],{},[142,9418,352],{},[127,9420,9421,9425,9427],{},[142,9422,357,9423],{},[23,9424,360],{},[142,9426,363],{},[142,9428,366],{},[127,9430,9431,9435,9437],{},[142,9432,371,9433],{},[23,9434,374],{},[142,9436,377],{},[142,9438,380],{},[127,9440,9441,9445,9447],{},[142,9442,291,9443],{},[23,9444,387],{},[142,9446,390],{},[142,9448,393],{},[127,9450,9451,9455,9457],{},[142,9452,155,9453],{},[23,9454,400],{},[142,9456,403],{},[142,9458,406],{},[127,9460,9461,9465,9467],{},[142,9462,411,9463],{},[23,9464,414],{},[142,9466,417],{},[142,9468,420],{},[127,9470,9471,9475,9477],{},[142,9472,241,9473],{},[23,9474,427],{},[142,9476,430],{},[142,9478,433],{},[127,9480,9481,9485,9487],{},[142,9482,438,9483],{},[23,9484,441],{},[142,9486,444],{},[142,9488,447],{},[11,9490,451],{"id":450},[16,9492,454,9493,458],{},[23,9494,457],{},[36,9496,462],{"id":461},[16,9498,465,9499,469],{},[23,9500,468],{},[41,9502,9504],{"className":9503,"code":473,"language":46},[44],[49,9505,473],{"__ignoreMap":47},[11,9507,479],{"id":478},[16,9509,482],{},[56,9511,9513,9518,9523,9528,9533,9538,9543,9548],{"className":9512},[486],[59,9514,9516,496],{"className":9515},[490],[492,9517],{"checked":494,"disabled":494,"type":495},[59,9519,9521,502],{"className":9520},[490],[492,9522],{"checked":494,"disabled":494,"type":495},[59,9524,9526,508],{"className":9525},[490],[492,9527],{"disabled":494,"type":495},[59,9529,9531,514],{"className":9530},[490],[492,9532],{"disabled":494,"type":495},[59,9534,9536,520],{"className":9535},[490],[492,9537],{"disabled":494,"type":495},[59,9539,9541,526],{"className":9540},[490],[492,9542],{"disabled":494,"type":495},[59,9544,9546,532],{"className":9545},[490],[492,9547],{"disabled":494,"type":495},[59,9549,9551,538],{"className":9550},[490],[492,9552],{"disabled":494,"type":495},[11,9554,542],{"id":541},[16,9556,545,9557,549],{},[23,9558,548],{},[16,9560,552,9561,556],{},[23,9562,555],{},[16,9564,559,9565],{},[23,9566,562],{},[16,9568,565],{},[567,9570],{},[16,9572,9573],{},[23,9574,573],{},{"title":47,"searchDepth":575,"depth":576,"links":9576},[9577,9578,9584,9589,9590,9593,9594],{"id":13,"depth":575,"text":14},{"id":33,"depth":575,"text":34,"children":9579},[9580,9581,9582,9583],{"id":38,"depth":576,"text":39},{"id":53,"depth":576,"text":54},{"id":97,"depth":576,"text":98},{"id":115,"depth":576,"text":116},{"id":197,"depth":575,"text":198,"children":9585},[9586,9587,9588],{"id":201,"depth":576,"text":39},{"id":210,"depth":576,"text":211},{"id":258,"depth":576,"text":259},{"id":321,"depth":575,"text":322},{"id":450,"depth":575,"text":451,"children":9591},[9592],{"id":461,"depth":576,"text":462},{"id":478,"depth":575,"text":479},{"id":541,"depth":575,"text":542},{},{"title":5,"description":596},[609,610,217,265],{"id":9599,"title":9600,"author":6,"body":9601,"description":10764,"difficulty":597,"draft":598,"extension":599,"image":10765,"meta":10766,"navigation":494,"path":10767,"pinned":598,"published":10768,"seo":10769,"series":6,"seriesOrder":6,"stem":10770,"tags":10771,"updated":10775,"__hash__":10776},"posts\u002Fposts\u002Fip-geolocation-guide\u002Findex.md","IP 属地是怎么查出来的？本地查询与在线查询方案对比",{"type":8,"value":9602,"toc":10740},[9603,9605,9611,9614,9625,9635,9639,9645,9658,9664,9675,9682,9685,9692,9696,9707,9711,9722,9725,9729,9735,9741,9744,9748,9755,9773,9776,9779,9783,9814,9853,9859,9863,10002,10038,10047,10051,10091,10096,10100,10111,10114,10148,10151,10155,10158,10162,10353,10357,10440,10444,10451,10558,10562,10571,10657,10660,10663,10699,10702,10705,10713,10734,10737],[11,9604,13],{"id":13},[16,9606,9607,9608],{},"很多网站会在「登录记录」「安全中心」里显示你的登录 IP 归属地，比如「福建·福州 · 中国移动」。你可能好奇过：",[23,9609,9610],{},"服务器是怎么根据一个 IP 就判断出我在哪个城市、哪个运营商的？",[16,9612,9613],{},"这篇科普文章讲清楚三件事：",[883,9615,9616,9619,9622],{},[59,9617,9618],{},"IP 属地到底查的是什么；",[59,9620,9621],{},"纯本地查询为什么能做到毫秒级且不联网；",[59,9623,9624],{},"几种主流查询方案的优缺点对比，方便你按场景选型。",[9626,9627,9628],"blockquote",{},[16,9629,9630,9631,9634],{},"说明：文中用到的 IP 均为 RFC 5737 保留的文档示例段（TEST-NET-2，",[49,9632,9633],{},"198.51.100.0\u002F24","），不会对应任何真实地址。",[11,9636,9638],{"id":9637},"先想清楚ip-属地查的是什么","先想清楚：IP 属地查的是什么",[16,9640,9641,9642,2522],{},"一个常见误区是「每个 IP 对应一个具体地址」。实际上 IP 属地的本质是 ",[23,9643,9644],{},"IP 段与归属地的映射",[16,9646,9647,9648,469],{},"运营商和机构在申请 IP 时，是按",[23,9649,9650,9651,9657],{},"网段（CIDR）",[23,9652,9653,9654,9656],{},"成块分配的。比如 ",[49,9655,9633],{}," 这一个段，一整块都归属同一地区、同一运营商。所以归属地数据天然可以","按段存储",[41,9659,9662],{"className":9660,"code":9661,"language":46,"meta":47},[44],"IP 起始          IP 结束         归属地\n────────────────────────────────────────\n198.51.100.0     198.51.100.255  中国|福建|福州|移动\n198.51.101.0     198.51.101.255  中国|浙江|杭州|电信\n",[49,9663,9661],{"__ignoreMap":47},[16,9665,9666,9667,9670,9671,9674],{},"全球公网 IP 约有 42 亿个，但",[23,9668,9669],{},"网段只有几十万条","。正因为存的是「段」而不是「每个 IP」，完整的归属地数据文件才只有几 MB（ip2region 的 ",[49,9672,9673],{},".xdb"," 约 11MB）。这是「本地离线查询」可行的第一前提。",[16,9676,9677,9678,9681],{},"所以，",[23,9679,9680],{},"IP 属地从来不是精确到街道的定位","，它返回的是段级的「省｜市｜运营商」粗粒度信息。",[11,9683,9684],{"id":9684},"纯本地查询的原理",[16,9686,9687,9688,9691],{},"以开源的 ",[23,9689,9690],{},"ip2region"," 为例，它的查询完全在本地完成，不联网、不请求第三方，几十微秒到毫秒级返回。",[36,9693,9695],{"id":9694},"第一步把-ip-变成整数","第一步：把 IP 变成整数",[16,9697,9698,9699,9702,9703,9706],{},"以 ",[49,9700,9701],{},"198.51.100.42"," 为例，它会转成一个 32 位整数 ",[49,9704,9705],{},"0xC633642A","。IP 的四个数字本质就是 4 个字节，拼起来就是一个整数，这一步没有任何玄学。",[36,9708,9710],{"id":9709},"第二步二分查找","第二步：二分查找",[16,9712,9713,9714,9717,9718,9721],{},"在按起始 IP 排序的段列表里做",[23,9715,9716],{},"二分查找","，找到满足 ",[49,9719,9720],{},"段的起始IP ≤ 目标整数 ≤ 段的结束IP"," 的那一段。",[16,9723,9724],{},"二分查找的复杂度是 O(log N)，N 是几十万条，所以很快——这就是「不靠网络也能毫秒返回」的核心。",[36,9726,9728],{"id":9727},"第三步xdb-文件的两级结构","第三步：.xdb 文件的两级结构",[16,9730,9731,9732,9734],{},"ip2region 的 ",[49,9733,9673],{}," 不是简单顺序文件，内部分两个区，进一步加速：",[41,9736,9739],{"className":9737,"code":9738,"language":46,"meta":47},[44],"┌─────────────────────────────┐\n│  索引区 (Index)             │  \u002F\u002F 每段一条定长记录：startIP \u002F endIP \u002F 数据区指针 + 数据长度\n│  …… 二分快速定位段 ……       │\n├─────────────────────────────┤\n│  数据区 (Data)              │  \u002F\u002F 真正存放各段的地点字符串\n│  ……                         │\n└─────────────────────────────┘\n",[49,9740,9738],{"__ignoreMap":47},[16,9742,9743],{},"先二分索引区定位到「是哪一段」，再按指针去数据区读出地点字符串，一次磁盘读就能拿到结果。",[36,9745,9747],{"id":9746},"为什么离线数据不会很快过期","为什么「离线数据」不会很快过期",[16,9749,9750,9751,9754],{},"IP 归属取决于",[23,9752,9753],{},"注册机构和运营商的分配","，一个网段通常长期稳定属于某地区、某运营商。所以：",[56,9756,9757,9760,9770],{},[59,9758,9759],{},"下载一次数据文件，可以用很久；",[59,9761,9762,9763,9769],{},"归属偶尔变化时，",[23,9764,9765,9766,9768],{},"每月更新一次 ",[49,9767,9673],{}," 文件","即可（替换文件、不用重启服务，见下文实战）；",[59,9771,9772],{},"数据源来自 IANA\u002FAPNIC 等机构的分配表与公开归属信息，不是实时探测。",[11,9774,9775],{"id":9775},"主流查询方案对比",[16,9777,9778],{},"实际开发中，查询 IP 属地主要有三条路线。",[36,9780,9782],{"id":9781},"方案一ip2region纯本地离线推荐","方案一：ip2region（纯本地离线，推荐）",[41,9784,9786],{"className":4052,"code":9785,"language":771,"meta":47,"style":47},"\u002F\u002F Go 中的用法（github.com\u002Flionsoul2014\u002Fip2region\u002Fbinding\u002Fgolang）\nregion, err := searcher.SearchByStr(ip)\n\u002F\u002F 返回类似 \"中国|福建省|福州市|移动\"\n",[49,9787,9788,9793,9809],{"__ignoreMap":47},[636,9789,9790],{"class":638,"line":606},[636,9791,9792],{"class":641},"\u002F\u002F Go 中的用法（github.com\u002Flionsoul2014\u002Fip2region\u002Fbinding\u002Fgolang）\n",[636,9794,9795,9798,9800,9803,9806],{"class":638,"line":575},[636,9796,9797],{"class":668},"region, err ",[636,9799,4079],{"class":730},[636,9801,9802],{"class":668}," searcher.",[636,9804,9805],{"class":647},"SearchByStr",[636,9807,9808],{"class":668},"(ip)\n",[636,9810,9811],{"class":638,"line":576},[636,9812,9813],{"class":641},"\u002F\u002F 返回类似 \"中国|福建省|福州市|移动\"\n",[121,9815,9816,9824],{},[124,9817,9818],{},[127,9819,9820,9822],{},[130,9821,97],{},[130,9823,3315],{},[137,9825,9826,9834,9842],{},[127,9827,9828,9831],{},[142,9829,9830],{},"免费、无网络依赖、无 QPS 限制",[142,9832,9833],{},"段级粒度，个别段可能显示到相邻地区",[127,9835,9836,9839],{},[142,9837,9838],{},"毫秒级查询，永不因外部服务挂掉",[142,9840,9841],{},"数据按月更新，最新分配的段可能短暂缺失",[127,9843,9844,9847],{},[142,9845,9846],{},"数据文件可随版本更新，不重启",[142,9848,9849,9850],{},"只返回省市区+运营商，无经纬度；",[23,9851,9852],{},"仅支持 IPv4",[16,9854,9855,9858],{},[23,9856,9857],{},"适用","：博客\u002F后台的「登录记录显示归属地」这类展示用途。",[36,9860,9862],{"id":9861},"方案二在线-apiip-api-高德-腾讯位置服务","方案二：在线 API（ip-api \u002F 高德 \u002F 腾讯位置服务）",[41,9864,9866],{"className":4052,"code":9865,"language":771,"meta":47,"style":47},"\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",[49,9867,9868,9873,9901,9911,9942,9958,9971,9981,9985,9997],{"__ignoreMap":47},[636,9869,9870],{"class":638,"line":606},[636,9871,9872],{"class":641},"\u002F\u002F 以 ip-api.com 为例（免费版约 45 次\u002F分钟，非商用）\n",[636,9874,9875,9878,9880,9882,9885,9888,9891,9894,9896,9898],{"class":638,"line":575},[636,9876,9877],{"class":668},"ctx, cancel ",[636,9879,4079],{"class":730},[636,9881,5552],{"class":668},[636,9883,9884],{"class":647},"WithTimeout",[636,9886,9887],{"class":668},"(context.",[636,9889,9890],{"class":647},"Background",[636,9892,9893],{"class":668},"(), ",[636,9895,2580],{"class":655},[636,9897,4285],{"class":730},[636,9899,9900],{"class":668},"time.Second)\n",[636,9902,9903,9906,9909],{"class":638,"line":576},[636,9904,9905],{"class":730},"defer",[636,9907,9908],{"class":647}," cancel",[636,9910,2826],{"class":668},[636,9912,9913,9916,9918,9921,9924,9927,9930,9932,9935,9937,9940],{"class":638,"line":692},[636,9914,9915],{"class":668},"req, _ ",[636,9917,4079],{"class":730},[636,9919,9920],{"class":668}," http.",[636,9922,9923],{"class":647},"NewRequestWithContext",[636,9925,9926],{"class":668},"(ctx, ",[636,9928,9929],{"class":651},"\"GET\"",[636,9931,1396],{"class":668},[636,9933,9934],{"class":651},"\"https:\u002F\u002Fip-api.com\u002Fjson\u002F198.51.100.42\"",[636,9936,1396],{"class":668},[636,9938,9939],{"class":655},"nil",[636,9941,2941],{"class":668},[636,9943,9944,9947,9949,9952,9955],{"class":638,"line":698},[636,9945,9946],{"class":668},"resp, err ",[636,9948,4079],{"class":730},[636,9950,9951],{"class":668}," http.DefaultClient.",[636,9953,9954],{"class":647},"Do",[636,9956,9957],{"class":668},"(req)\n",[636,9959,9960,9963,9965,9967,9969],{"class":638,"line":727},[636,9961,9962],{"class":730},"if",[636,9964,4493],{"class":668},[636,9966,4496],{"class":730},[636,9968,4499],{"class":655},[636,9970,2278],{"class":668},[636,9972,9973,9975,9978],{"class":638,"line":746},[636,9974,4310],{"class":730},[636,9976,9977],{"class":651}," \"未知\"",[636,9979,9980],{"class":641}," \u002F\u002F 断网\u002F超时降级：展示「未知」而不是报错\n",[636,9982,9983],{"class":638,"line":762},[636,9984,1951],{"class":668},[636,9986,9987,9989,9992,9995],{"class":638,"line":768},[636,9988,9905],{"class":730},[636,9990,9991],{"class":668}," resp.Body.",[636,9993,9994],{"class":647},"Close",[636,9996,2826],{"class":668},[636,9998,9999],{"class":638,"line":782},[636,10000,10001],{"class":641},"\u002F\u002F 解析 JSON，status != \"success\" 时同样降级\n",[121,10003,10004,10012],{},[124,10005,10006],{},[127,10007,10008,10010],{},[130,10009,97],{},[130,10011,3315],{},[137,10013,10014,10022,10030],{},[127,10015,10016,10019],{},[142,10017,10018],{},"数据最新、可能更精确",[142,10020,10021],{},"有频率限制（免费版限 QPS）",[127,10023,10024,10027],{},[142,10025,10026],{},"部分服务还返回经纬度\u002F时区",[142,10028,10029],{},"依赖第三方，断网\u002F被墙\u002F限流时拿不到",[127,10031,10032,10035],{},[142,10033,10034],{},"无需维护数据文件",[142,10036,10037],{},"每次查询都要发一次 HTTP 请求",[16,10039,10040,10042,10043,10046],{},[23,10041,9857],{},"：对精度要求高、且能接受外部依赖与限流的场景。",[23,10044,10045],{},"务必加超时和降级","，否则第三方一抖，你自己的页面就卡住。",[36,10048,10050],{"id":10049},"方案三maxmind-geolite2离线-mmdb","方案三：MaxMind GeoLite2（离线 .mmdb）",[121,10052,10053,10061],{},[124,10054,10055],{},[127,10056,10057,10059],{},[130,10058,97],{},[130,10060,3315],{},[137,10062,10063,10071,10083],{},[127,10064,10065,10068],{},[142,10066,10067],{},"全球数据，覆盖广、精度较好",[142,10069,10070],{},"免费但需注册账号并遵守 EULA；商用需 GeoIP2 商业授权",[127,10072,10073,10080],{},[142,10074,10075,10076,10079],{},"标准 ",[49,10077,10078],{},".mmdb"," 格式，各语言都有库，支持 IPv6",[142,10081,10082],{},"国内 IP 的省市粒度有时不如 ip2region 细",[127,10084,10085,10088],{},[142,10086,10087],{},"离线查询",[142,10089,10090],{},"数据同样要定期更新",[16,10092,10093,10095],{},[23,10094,9857],{},"：面向全球用户的场景。",[11,10097,10099],{"id":10098},"ipv6本地离线方案的一个盲点","IPv6：本地离线方案的一个盲点",[16,10101,10102,10103,10106,10107,10110],{},"前面聊的都是 IPv4（32 位）。但家宽和移动网络越来越多地",[23,10104,10105],{},"只下发 IPv6","（128 位）地址，而 ",[23,10108,10109],{},"ip2region 目前只支持 IPv4","——拿到一个纯 IPv6 地址是查不出归属地的，本地库会返回空结果。",[16,10112,10113],{},"实际影响与常见应对：",[883,10115,10116,10126,10132,10142],{},[59,10117,10118,10121,10122,10125],{},[23,10119,10120],{},"判断版本","：Go 里 ",[49,10123,10124],{},"net.ParseIP(ip).To4() != nil"," 就是 IPv4，先判断再查询；",[59,10127,10128,10131],{},[23,10129,10130],{},"降级展示","：IPv6 直接显示「IPv6 地址」，不显示属地，诚实第一；",[59,10133,10134,10137,10138,10141],{},[23,10135,10136],{},"优先取 IPv4","：有反向代理时，从 ",[49,10139,10140],{},"X-Forwarded-For"," 里挑一个 IPv4 来查；",[59,10143,10144,10147],{},[23,10145,10146],{},"需要 v6 归属","：换支持 IPv6 的数据源（如 MaxMind GeoLite2）。",[16,10149,10150],{},"这一点在接入时很容易漏掉，上线前记得拿一个纯 IPv6 地址回归一遍。",[11,10152,10154],{"id":10153},"实战给登录记录加-ip-属地","实战：给登录记录加 IP 属地",[16,10156,10157],{},"subLog 博客的登录记录就用了 ip2region。完整接入一共四步。",[36,10159,10161],{"id":10160},"_1-进程启动时加载数据文件","1. 进程启动时加载数据文件",[41,10163,10165],{"className":4052,"code":10164,"language":771,"meta":47,"style":47},"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",[49,10166,10167,10173,10182,10191,10195,10199,10204,10231,10235,10248,10270,10282,10289,10293,10309,10321,10327,10331,10342,10349],{"__ignoreMap":47},[636,10168,10169,10171],{"class":638,"line":606},[636,10170,4060],{"class":730},[636,10172,5458],{"class":668},[636,10174,10175,10177,10180],{"class":638,"line":575},[636,10176,6876],{"class":651},[636,10178,10179],{"class":647},"sync\u002Fatomic",[636,10181,878],{"class":651},[636,10183,10184,10186,10189],{"class":638,"line":576},[636,10185,6876],{"class":651},[636,10187,10188],{"class":647},"github.com\u002Flionsoul2014\u002Fip2region\u002Fbinding\u002Fgolang\u002Fxdb",[636,10190,878],{"class":651},[636,10192,10193],{"class":638,"line":692},[636,10194,2941],{"class":668},[636,10196,10197],{"class":638,"line":698},[636,10198,689],{"emptyLinePlaceholder":494},[636,10200,10201],{"class":638,"line":727},[636,10202,10203],{"class":641},"\u002F\u002F 用 atomic.Pointer 持有查询器，方便后面热更新\n",[636,10205,10206,10208,10211,10214,10216,10219,10221,10224,10226,10229],{"class":638,"line":746},[636,10207,6957],{"class":730},[636,10209,10210],{"class":668}," searcher ",[636,10212,10213],{"class":647},"atomic",[636,10215,2753],{"class":668},[636,10217,10218],{"class":647},"Pointer",[636,10220,3440],{"class":668},[636,10222,10223],{"class":647},"xdb",[636,10225,2753],{"class":668},[636,10227,10228],{"class":647},"Searcher",[636,10230,1099],{"class":668},[636,10232,10233],{"class":638,"line":762},[636,10234,689],{"emptyLinePlaceholder":494},[636,10236,10237,10239,10242,10244,10246],{"class":638,"line":768},[636,10238,4253],{"class":730},[636,10240,10241],{"class":647}," initSearcher",[636,10243,5501],{"class":668},[636,10245,4298],{"class":730},[636,10247,2278],{"class":668},[636,10249,10250,10253,10255,10258,10260,10262,10265,10267],{"class":638,"line":782},[636,10251,10252],{"class":668},"    content, err ",[636,10254,4079],{"class":730},[636,10256,10257],{"class":668}," os.",[636,10259,7096],{"class":647},[636,10261,2631],{"class":668},[636,10263,10264],{"class":651},"\".\u002Fdata\u002Fip2region.xdb\"",[636,10266,3095],{"class":668},[636,10268,10269],{"class":641},"\u002F\u002F 约 11MB，一次性读入内存\n",[636,10271,10272,10274,10276,10278,10280],{"class":638,"line":805},[636,10273,2708],{"class":730},[636,10275,4493],{"class":668},[636,10277,4496],{"class":730},[636,10279,4499],{"class":655},[636,10281,2278],{"class":668},[636,10283,10284,10286],{"class":638,"line":814},[636,10285,4506],{"class":730},[636,10287,10288],{"class":668}," err\n",[636,10290,10291],{"class":638,"line":819},[636,10292,2591],{"class":668},[636,10294,10295,10298,10300,10303,10306],{"class":638,"line":825},[636,10296,10297],{"class":668},"    s, err ",[636,10299,4079],{"class":730},[636,10301,10302],{"class":668}," xdb.",[636,10304,10305],{"class":647},"NewWithBuffer",[636,10307,10308],{"class":668},"(content)\n",[636,10310,10311,10313,10315,10317,10319],{"class":638,"line":836},[636,10312,2708],{"class":730},[636,10314,4493],{"class":668},[636,10316,4496],{"class":730},[636,10318,4499],{"class":655},[636,10320,2278],{"class":668},[636,10322,10323,10325],{"class":638,"line":847},[636,10324,4506],{"class":730},[636,10326,10288],{"class":668},[636,10328,10329],{"class":638,"line":852},[636,10330,2591],{"class":668},[636,10332,10333,10336,10339],{"class":638,"line":861},[636,10334,10335],{"class":668},"    searcher.",[636,10337,10338],{"class":647},"Store",[636,10340,10341],{"class":668},"(s)\n",[636,10343,10344,10346],{"class":638,"line":866},[636,10345,4310],{"class":730},[636,10347,10348],{"class":655}," nil\n",[636,10350,10351],{"class":638,"line":1074},[636,10352,1951],{"class":668},[36,10354,10356],{"id":10355},"_2-查询一次","2. 查询一次",[41,10358,10360],{"className":4052,"code":10359,"language":771,"meta":47,"style":47},"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",[49,10361,10362,10384,10403,10415,10422,10426,10436],{"__ignoreMap":47},[636,10363,10364,10366,10369,10371,10374,10377,10379,10382],{"class":638,"line":606},[636,10365,4253],{"class":730},[636,10367,10368],{"class":647}," ipRegion",[636,10370,2631],{"class":668},[636,10372,10373],{"class":2634},"ip",[636,10375,10376],{"class":730}," string",[636,10378,3095],{"class":668},[636,10380,10381],{"class":730},"string",[636,10383,2278],{"class":668},[636,10385,10386,10389,10391,10393,10396,10399,10401],{"class":638,"line":575},[636,10387,10388],{"class":668},"    region, err ",[636,10390,4079],{"class":730},[636,10392,9802],{"class":668},[636,10394,10395],{"class":647},"Load",[636,10397,10398],{"class":668},"().",[636,10400,9805],{"class":647},[636,10402,9808],{"class":668},[636,10404,10405,10407,10409,10411,10413],{"class":638,"line":576},[636,10406,2708],{"class":730},[636,10408,4493],{"class":668},[636,10410,4496],{"class":730},[636,10412,4499],{"class":655},[636,10414,2278],{"class":668},[636,10416,10417,10419],{"class":638,"line":692},[636,10418,4506],{"class":730},[636,10420,10421],{"class":651}," \"未知\"\n",[636,10423,10424],{"class":638,"line":698},[636,10425,2591],{"class":668},[636,10427,10428,10430,10433],{"class":638,"line":727},[636,10429,4310],{"class":730},[636,10431,10432],{"class":668}," region ",[636,10434,10435],{"class":641},"\u002F\u002F \"中国|福建省|福州市|移动\"\n",[636,10437,10438],{"class":638,"line":746},[636,10439,1951],{"class":668},[36,10441,10443],{"id":10442},"_3-加一层内存缓存","3. 加一层内存缓存",[16,10445,10446,10447,10450],{},"登录记录里同一个 IP 可能反复出现，用 ",[49,10448,10449],{},"sync.Map"," 按 IP 缓存结果，一次查询终身复用：",[41,10452,10454],{"className":4052,"code":10453,"language":771,"meta":47,"style":47},"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",[49,10455,10456,10471,10475,10494,10511,10522,10526,10537,10547,10554],{"__ignoreMap":47},[636,10457,10458,10460,10463,10466,10468],{"class":638,"line":606},[636,10459,6957],{"class":730},[636,10461,10462],{"class":668}," regionCache ",[636,10464,10465],{"class":647},"sync",[636,10467,2753],{"class":668},[636,10469,10470],{"class":647},"Map\n",[636,10472,10473],{"class":638,"line":575},[636,10474,689],{"emptyLinePlaceholder":494},[636,10476,10477,10479,10482,10484,10486,10488,10490,10492],{"class":638,"line":576},[636,10478,4253],{"class":730},[636,10480,10481],{"class":647}," ipRegionCached",[636,10483,2631],{"class":668},[636,10485,10373],{"class":2634},[636,10487,10376],{"class":730},[636,10489,3095],{"class":668},[636,10491,10381],{"class":730},[636,10493,2278],{"class":668},[636,10495,10496,10498,10501,10503,10506,10508],{"class":638,"line":692},[636,10497,2708],{"class":730},[636,10499,10500],{"class":668}," v, ok ",[636,10502,4079],{"class":730},[636,10504,10505],{"class":668}," regionCache.",[636,10507,10395],{"class":647},[636,10509,10510],{"class":668},"(ip); ok {\n",[636,10512,10513,10515,10518,10520],{"class":638,"line":698},[636,10514,4506],{"class":730},[636,10516,10517],{"class":668}," v.(",[636,10519,10381],{"class":730},[636,10521,2941],{"class":668},[636,10523,10524],{"class":638,"line":727},[636,10525,2591],{"class":668},[636,10527,10528,10531,10533,10535],{"class":638,"line":746},[636,10529,10530],{"class":668},"    region ",[636,10532,4079],{"class":730},[636,10534,10368],{"class":647},[636,10536,9808],{"class":668},[636,10538,10539,10542,10544],{"class":638,"line":762},[636,10540,10541],{"class":668},"    regionCache.",[636,10543,10338],{"class":647},[636,10545,10546],{"class":668},"(ip, region)\n",[636,10548,10549,10551],{"class":638,"line":768},[636,10550,4310],{"class":730},[636,10552,10553],{"class":668}," region\n",[636,10555,10556],{"class":638,"line":782},[636,10557,1951],{"class":668},[36,10559,10561],{"id":10560},"_4-数据热更新不用重启","4. 数据热更新（不用重启）",[16,10563,10564,10565,10567,10568,10570],{},"ip2region 每月发布新数据。下载新文件后重新 ",[49,10566,10305],{}," 并 ",[49,10569,10338],{}," 覆盖即可，进程完全不用重启：",[41,10572,10574],{"className":4052,"code":10573,"language":771,"meta":47,"style":47},"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",[49,10575,10576,10600,10613,10625,10631,10635,10647,10653],{"__ignoreMap":47},[636,10577,10578,10580,10583,10585,10588,10591,10594,10596,10598],{"class":638,"line":606},[636,10579,4253],{"class":730},[636,10581,10582],{"class":647}," reloadSearcher",[636,10584,2631],{"class":668},[636,10586,10587],{"class":2634},"newContent",[636,10589,10590],{"class":668}," []",[636,10592,10593],{"class":730},"byte",[636,10595,3095],{"class":668},[636,10597,4298],{"class":730},[636,10599,2278],{"class":668},[636,10601,10602,10604,10606,10608,10610],{"class":638,"line":575},[636,10603,10297],{"class":668},[636,10605,4079],{"class":730},[636,10607,10302],{"class":668},[636,10609,10305],{"class":647},[636,10611,10612],{"class":668},"(newContent)\n",[636,10614,10615,10617,10619,10621,10623],{"class":638,"line":576},[636,10616,2708],{"class":730},[636,10618,4493],{"class":668},[636,10620,4496],{"class":730},[636,10622,4499],{"class":655},[636,10624,2278],{"class":668},[636,10626,10627,10629],{"class":638,"line":692},[636,10628,4506],{"class":730},[636,10630,10288],{"class":668},[636,10632,10633],{"class":638,"line":698},[636,10634,2591],{"class":668},[636,10636,10637,10639,10641,10644],{"class":638,"line":727},[636,10638,10335],{"class":668},[636,10640,10338],{"class":647},[636,10642,10643],{"class":668},"(s) ",[636,10645,10646],{"class":641},"\u002F\u002F 原子切换，老查询继续走旧数据，新查询走新数据\n",[636,10648,10649,10651],{"class":638,"line":746},[636,10650,4310],{"class":730},[636,10652,10348],{"class":655},[636,10654,10655],{"class":638,"line":762},[636,10656,1951],{"class":668},[11,10658,10659],{"id":10659},"局限与诚实提醒",[16,10661,10662],{},"无论用哪种方案，都要清楚 IP 属地的边界：",[56,10664,10665,10671,10677,10683,10689],{},[59,10666,10667,10670],{},[23,10668,10669],{},"段级精度","：网段归属是「绝大多数」准确，个别 IP（大型企业自建段、跨省调度段）可能显示到相邻地区或只到省。",[59,10672,10673,10676],{},[23,10674,10675],{},"运营商级，不是定位","：拿到的是「省｜市｜运营商」，不是精确街道或基站位置——精确位置需要 GPS 或运营商三角定位，纯 IP 归属做不到。",[59,10678,10679,10682],{},[23,10680,10681],{},"动态 IP","：家宽通常是动态 IP，重拨会变，所以「上次登录在福州，这次在广州」不一定是异常，可能是 IP 变了。",[59,10684,10685,10688],{},[23,10686,10687],{},"别用于安全判定","：用 IP 段做封禁或风控判定风险自负，段级数据不适合当精确依据。",[59,10690,10691,10694,10695,10698],{},[23,10692,10693],{},"隐私与合规","：IP 地址属于个人信息（《个人信息保护法》）。归属地展示建议只出现在",[23,10696,10697],{},"用户本人可见","的页面（登录记录、个人中心）；若在评论区等公开场合展示，建议只显示到省市并明确告知用户。",[11,10700,10701],{"id":10701},"小结",[16,10703,10704],{},"一句话总结：",[9626,10706,10707],{},[16,10708,10709,10712],{},[23,10710,10711],{},"IP 属地 = 几十万条「IP 段 → 归属地」映射 + 一次二分查找。"," 它查的是网段归属，不是精确位置。",[56,10714,10715,10721,10728],{},[59,10716,10717,10718,10720],{},"想省心、免费、稳定，用 ",[23,10719,9690],{}," 本地离线查询（注意 IPv6 盲点）；",[59,10722,10723,10724,10727],{},"想要最新最细、能接受外部依赖，用",[23,10725,10726],{},"在线 API","（记得超时降级）；",[59,10729,10730,10731,2522],{},"面向全球用户、要 IPv6 覆盖，用 ",[23,10732,10733],{},"MaxMind GeoLite2",[16,10735,10736],{},"对个人博客「登录记录显示归属地」这种场景，本地离线方案通常是性价比最高的选择。",[1422,10738,10739],{},"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":47,"searchDepth":575,"depth":576,"links":10741},[10742,10743,10744,10750,10755,10756,10762,10763],{"id":13,"depth":575,"text":13},{"id":9637,"depth":575,"text":9638},{"id":9684,"depth":575,"text":9684,"children":10745},[10746,10747,10748,10749],{"id":9694,"depth":576,"text":9695},{"id":9709,"depth":576,"text":9710},{"id":9727,"depth":576,"text":9728},{"id":9746,"depth":576,"text":9747},{"id":9775,"depth":575,"text":9775,"children":10751},[10752,10753,10754],{"id":9781,"depth":576,"text":9782},{"id":9861,"depth":576,"text":9862},{"id":10049,"depth":576,"text":10050},{"id":10098,"depth":575,"text":10099},{"id":10153,"depth":575,"text":10154,"children":10757},[10758,10759,10760,10761],{"id":10160,"depth":576,"text":10161},{"id":10355,"depth":576,"text":10356},{"id":10442,"depth":576,"text":10443},{"id":10560,"depth":576,"text":10561},{"id":10659,"depth":575,"text":10659},{"id":10701,"depth":575,"text":10701},"科普 IP 属地查询的原理：IP 段与归属地的映射、纯本地离线查询为何能做到毫秒级，对比 ip2region、在线 API 与 MaxMind 三种方案的优劣，并附登录记录场景的 Go 实战与 IPv6 盲点提醒。","\u002Fposts\u002Fip-geolocation-guide\u002Fimg\u002Fcover.svg",{},"\u002Fposts\u002Fip-geolocation-guide","2026-08-07T12:00:00",{"title":9600,"description":10764},"posts\u002Fip-geolocation-guide\u002Findex",[10772,10773,10774],"IP","网络","科普","2026-08-16","RY1UPgJaiKsjpC51uqMyG7w_GpCRS13_CX5j1hDD_dM",{"id":10778,"title":10779,"author":6,"body":10780,"description":12302,"difficulty":1437,"draft":598,"extension":599,"image":12303,"meta":12304,"navigation":494,"path":12305,"pinned":598,"published":12306,"seo":12307,"series":6017,"seriesOrder":576,"stem":12308,"tags":12309,"updated":10775,"__hash__":12312},"posts\u002Fposts\u002Fgo-embed-react-single-binary\u002Findex.md","Go 单文件打包进阶：React 前端 + 交叉编译，一次踩完所有坑",{"type":8,"value":10781,"toc":12281},[10782,10784,10795,10806,10813,10816,10852,10854,10863,10865,10869,10879,10923,10965,10983,10987,11008,11039,11043,11335,11338,11376,11380,11723,11726,11730,11927,11937,11939,11950,11964,11977,11988,11992,11999,12015,12028,12033,12037,12053,12058,12103,12117,12128,12132,12147,12156,12178,12187,12191,12201,12203,12230,12239,12241,12244,12271,12278],[11,10783,2498],{"id":2498},[16,10785,10786,10787,10790,10791,10794],{},"这是「Go 单文件打包」系列第三篇。上一篇（",[2452,10788,10789],{"href":7775},"把前端塞进二进制","）记录了一个 Gin + Vue 3 项目的 embed 方案。这次换了个项目继续踩坑：",[23,10792,10793],{},"Magic Img","——一个自托管、本地优先的 AI 生图提示词管理工具，技术栈是：",[56,10796,10797,10800],{},[59,10798,10799],{},"前端：React 19 + TypeScript + Vite + Tailwind v4（UI 库 Magic UI + shadcn\u002Fui）",[59,10801,10802,10803,6559],{},"后端：Go + Gin + GORM + SQLite（",[23,10804,10805],{},"modernc 纯 Go 驱动，无 cgo",[16,10807,10808,10809,10812],{},"目标一样：",[23,10810,10811],{},"一个二进制拖到任何机器上就能跑","——前端、API、数据库全部内嵌。",[16,10814,10815],{},"和上篇不同的地方有三个，正好是这篇要讲的重点：",[883,10817,10818,10832,10845],{},[59,10819,10820,10821,10827,10828,10831],{},"前端用了 ",[23,10822,10823,10824],{},"HashRouter + Vite base ",[49,10825,10826],{},"\u002Fdist","，服务端",[23,10829,10830],{},"不再需要 SPA fallback","（上篇坑一的新解法）；",[59,10833,10834,10835,10841,10842,10844],{},"embed 用 ",[23,10836,10837,10840],{},[49,10838,10839],{},"all:"," 前缀","一劳永逸解决 ",[49,10843,7469],{}," 开头文件被排除的问题（上篇坑二的升级解法）；",[59,10846,10847,10848,10851],{},"需要",[23,10849,10850],{},"在 Windows 上交叉编译 Linux 产物","，踩了「生成的文件无法在 Linux 执行」的坑。",[11,10853,6825],{"id":6825},[16,10855,10856,10857,10859,10860,10862],{},"和上篇一致：",[49,10858,6831],{}," 在编译期把前端 ",[49,10861,6835],{}," 塞进二进制，运行时由 Go 直接提供静态服务。差异在于路由策略和缓存策略，后面细说。",[11,10864,6839],{"id":6839},[36,10866,10868],{"id":10867},"_1-前端hash-路由-base-dist","1. 前端：hash 路由 + base \u002Fdist",[16,10870,10871,10872,10875,10876,469],{},"vite.config.ts 里固定 ",[49,10873,10874],{},"base: \"\u002Fdist\u002F\"","，路由用 ",[49,10877,10878],{},"HashRouter",[41,10880,10882],{"className":7520,"code":10881,"language":7522,"meta":47,"style":47},"\u002F\u002F vite.config.ts\nexport default defineConfig({\n  base: \"\u002Fdist\u002F\",   \u002F\u002F 所有资源路径以 \u002Fdist\u002F 开头\n  ...\n})\n",[49,10883,10884,10888,10900,10914,10919],{"__ignoreMap":47},[636,10885,10886],{"class":638,"line":606},[636,10887,7529],{"class":641},[636,10889,10890,10892,10894,10897],{"class":638,"line":575},[636,10891,731],{"class":730},[636,10893,3373],{"class":730},[636,10895,10896],{"class":647}," defineConfig",[636,10898,10899],{"class":668},"({\n",[636,10901,10902,10905,10908,10911],{"class":638,"line":576},[636,10903,10904],{"class":668},"  base: ",[636,10906,10907],{"class":651},"\"\u002Fdist\u002F\"",[636,10909,10910],{"class":668},",   ",[636,10912,10913],{"class":641},"\u002F\u002F 所有资源路径以 \u002Fdist\u002F 开头\n",[636,10915,10916],{"class":638,"line":692},[636,10917,10918],{"class":730},"  ...\n",[636,10920,10921],{"class":638,"line":698},[636,10922,3179],{"class":668},[41,10924,10928],{"className":10925,"code":10926,"language":10927,"meta":47,"style":47},"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",[49,10929,10930,10935,10943,10957],{"__ignoreMap":47},[636,10931,10932],{"class":638,"line":606},[636,10933,10934],{"class":641},"\u002F\u002F App.tsx —— HashRouter：路径信息全在 location.hash 里\n",[636,10936,10937,10939,10941],{"class":638,"line":575},[636,10938,1576],{"class":668},[636,10940,10878],{"class":655},[636,10942,1233],{"class":668},[636,10944,10945,10947,10950,10953,10955],{"class":638,"line":576},[636,10946,3206],{"class":668},[636,10948,10949],{"class":655},"Routes",[636,10951,10952],{"class":668},">...\u003C\u002F",[636,10954,10949],{"class":655},[636,10956,1233],{"class":668},[636,10958,10959,10961,10963],{"class":638,"line":692},[636,10960,3184],{"class":668},[636,10962,10878],{"class":655},[636,10964,1233],{"class":668},[16,10966,10967,10970,10971,10974,10975,10978,10979,10982],{},[23,10968,10969],{},"为什么这样选","：history 路由需要服务端把未知路径全部 fallback 回 ",[49,10972,10973],{},"index.html","（上篇坑一）。hash 路由下浏览器请求的永远是 ",[49,10976,10977],{},"\u002Fdist\u002F"," 这一个入口，",[23,10980,10981],{},"静态服务直接返回 index.html 就完事，一个 fallback 都不用写","。内嵌场景里这是最省心的组合。",[36,10984,10986],{"id":10985},"_2-构建产物复制进后端目录","2. 构建产物复制进后端目录",[16,10988,10989,10992,10993,10995,10996,10999,11000,11003,11004,11007],{},[49,10990,10991],{},"npm run build"," 除了编译，还会把 ",[49,10994,6835],{}," 复制到 ",[49,10997,10998],{},"backend\u002Fweb\u002Fdist","（一个小脚本 ",[49,11001,11002],{},"scripts\u002Fcopy-dist.mjs"," 干这个活），供 ",[49,11005,11006],{},"go:embed"," 使用：",[41,11009,11011],{"className":5844,"code":11010,"language":5846,"meta":47,"style":47},"\u002F\u002F frontend\u002Fpackage.json\n\"scripts\": {\n  \"build\": \"tsc -b && vite build && node scripts\u002Fcopy-dist.mjs\"\n}\n",[49,11012,11013,11018,11025,11035],{"__ignoreMap":47},[636,11014,11015],{"class":638,"line":606},[636,11016,11017],{"class":641},"\u002F\u002F frontend\u002Fpackage.json\n",[636,11019,11020,11023],{"class":638,"line":575},[636,11021,11022],{"class":651},"\"scripts\"",[636,11024,2550],{"class":668},[636,11026,11027,11030,11032],{"class":638,"line":576},[636,11028,11029],{"class":655},"  \"build\"",[636,11031,2342],{"class":668},[636,11033,11034],{"class":651},"\"tsc -b && vite build && node scripts\u002Fcopy-dist.mjs\"\n",[636,11036,11037],{"class":638,"line":692},[636,11038,1951],{"class":668},[36,11040,11042],{"id":11041},"_3-后端embed-httpfilesystem-包装","3. 后端：embed + http.FileSystem 包装",[41,11044,11046],{"className":4052,"code":11045,"language":771,"meta":47,"style":47},"\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",[49,11047,11048,11053,11059,11063,11069,11077,11085,11093,11097,11101,11106,11119,11123,11128,11165,11169,11181,11189,11202,11206,11210,11249,11263,11273,11283,11310,11314,11331],{"__ignoreMap":47},[636,11049,11050],{"class":638,"line":606},[636,11051,11052],{"class":641},"\u002F\u002F backend\u002Fweb\u002Fembed.go\n",[636,11054,11055,11057],{"class":638,"line":575},[636,11056,6858],{"class":730},[636,11058,6861],{"class":647},[636,11060,11061],{"class":638,"line":576},[636,11062,689],{"emptyLinePlaceholder":494},[636,11064,11065,11067],{"class":638,"line":692},[636,11066,4060],{"class":730},[636,11068,5458],{"class":668},[636,11070,11071,11073,11075],{"class":638,"line":698},[636,11072,6876],{"class":651},[636,11074,6879],{"class":647},[636,11076,878],{"class":651},[636,11078,11079,11081,11083],{"class":638,"line":727},[636,11080,6876],{"class":651},[636,11082,6906],{"class":647},[636,11084,878],{"class":651},[636,11086,11087,11089,11091],{"class":638,"line":746},[636,11088,6876],{"class":651},[636,11090,6924],{"class":647},[636,11092,878],{"class":651},[636,11094,11095],{"class":638,"line":762},[636,11096,2941],{"class":668},[636,11098,11099],{"class":638,"line":768},[636,11100,689],{"emptyLinePlaceholder":494},[636,11102,11103],{"class":638,"line":782},[636,11104,11105],{"class":641},"\u002F\u002Fgo:embed all:dist\n",[636,11107,11108,11110,11113,11115,11117],{"class":638,"line":805},[636,11109,6957],{"class":730},[636,11111,11112],{"class":668}," distFS ",[636,11114,6879],{"class":647},[636,11116,2753],{"class":668},[636,11118,6967],{"class":647},[636,11120,11121],{"class":638,"line":814},[636,11122,689],{"emptyLinePlaceholder":494},[636,11124,11125],{"class":638,"line":819},[636,11126,11127],{"class":641},"\u002F\u002F Dist 内嵌前端静态文件系统\n",[636,11129,11130,11132,11135,11138,11140,11143,11145,11148,11151,11154,11156,11159,11162],{"class":638,"line":825},[636,11131,6957],{"class":730},[636,11133,11134],{"class":668}," Dist ",[636,11136,11137],{"class":647},"http",[636,11139,2753],{"class":668},[636,11141,11142],{"class":647},"FileSystem",[636,11144,2275],{"class":730},[636,11146,11147],{"class":730}," &",[636,11149,11150],{"class":647},"fileSystem",[636,11152,11153],{"class":668},"{prefix: ",[636,11155,7007],{"class":651},[636,11157,11158],{"class":668},", fs: ",[636,11160,11161],{"class":730},"&",[636,11163,11164],{"class":668},"distFS}\n",[636,11166,11167],{"class":638,"line":836},[636,11168,689],{"emptyLinePlaceholder":494},[636,11170,11171,11173,11176,11179],{"class":638,"line":847},[636,11172,5445],{"class":730},[636,11174,11175],{"class":647}," fileSystem",[636,11177,11178],{"class":730}," struct",[636,11180,2278],{"class":668},[636,11182,11183,11186],{"class":638,"line":852},[636,11184,11185],{"class":668},"    prefix ",[636,11187,11188],{"class":730},"string\n",[636,11190,11191,11194,11196,11198,11200],{"class":638,"line":861},[636,11192,11193],{"class":668},"    fs     ",[636,11195,4285],{"class":730},[636,11197,6879],{"class":647},[636,11199,2753],{"class":668},[636,11201,6967],{"class":647},[636,11203,11204],{"class":638,"line":866},[636,11205,1951],{"class":668},[636,11207,11208],{"class":638,"line":1074},[636,11209,689],{"emptyLinePlaceholder":494},[636,11211,11212,11214,11216,11219,11221,11223,11225,11228,11230,11232,11234,11236,11238,11240,11243,11245,11247],{"class":638,"line":1082},[636,11213,4253],{"class":730},[636,11215,2673],{"class":668},[636,11217,11218],{"class":2634},"f ",[636,11220,4285],{"class":730},[636,11222,11150],{"class":647},[636,11224,3095],{"class":668},[636,11226,11227],{"class":647},"Open",[636,11229,2631],{"class":668},[636,11231,7573],{"class":2634},[636,11233,10376],{"class":730},[636,11235,4282],{"class":668},[636,11237,11137],{"class":647},[636,11239,2753],{"class":668},[636,11241,11242],{"class":647},"File",[636,11244,1396],{"class":668},[636,11246,4298],{"class":730},[636,11248,3880],{"class":668},[636,11250,11251,11253,11256,11258,11261],{"class":638,"line":1087},[636,11252,2708],{"class":730},[636,11254,11255],{"class":668}," name ",[636,11257,5153],{"class":730},[636,11259,11260],{"class":651}," \"\u002F\"",[636,11262,2278],{"class":668},[636,11264,11265,11268,11270],{"class":638,"line":1720},[636,11266,11267],{"class":668},"        name ",[636,11269,737],{"class":730},[636,11271,11272],{"class":668}," f.prefix\n",[636,11274,11275,11278,11281],{"class":638,"line":1734},[636,11276,11277],{"class":668},"    } ",[636,11279,11280],{"class":730},"else",[636,11282,2278],{"class":668},[636,11284,11285,11287,11289,11292,11294,11296,11299,11301,11303,11306,11308],{"class":638,"line":1741},[636,11286,11267],{"class":668},[636,11288,737],{"class":730},[636,11290,11291],{"class":668}," f.prefix ",[636,11293,3448],{"class":730},[636,11295,11260],{"class":651},[636,11297,11298],{"class":730}," +",[636,11300,5215],{"class":668},[636,11302,7045],{"class":647},[636,11304,11305],{"class":668},"(name, ",[636,11307,7051],{"class":651},[636,11309,2941],{"class":668},[636,11311,11312],{"class":638,"line":1746},[636,11313,2591],{"class":668},[636,11315,11316,11318,11320,11323,11326,11328],{"class":638,"line":1752},[636,11317,4310],{"class":730},[636,11319,9920],{"class":668},[636,11321,11322],{"class":647},"FS",[636,11324,11325],{"class":668},"(f.fs).",[636,11327,11227],{"class":647},[636,11329,11330],{"class":668},"(name)\n",[636,11332,11333],{"class":638,"line":1760},[636,11334,1951],{"class":668},[16,11336,11337],{},"两个细节：",[56,11339,11340,11363],{},[59,11341,11342,11350,11351,7486,11353,11355,11356,11358,11359,11362],{},[23,11343,11344,11347,11348,10840],{},[49,11345,11346],{},"all:dist"," 里的 ",[49,11349,10839],{},"：embed 目录时默认会排除 ",[49,11352,7469],{},[49,11354,2753],{}," 开头的文件（上篇坑二就是被这个坑了，当时靠改 Vite 输出名绕开）。",[49,11357,10839],{}," 前缀表示「全部包含」，前端构建产物里那些 ",[49,11360,11361],{},"_xxx.js"," 助手文件直接安全落地，不用再改前端配置；",[59,11364,11365,11368,11369,11371,11372,11375],{},[49,11366,11367],{},"http.FileSystem"," 包装：加了一层 ",[49,11370,6835],{}," 前缀映射，",[49,11373,11374],{},"http.FileServer"," 和 Gin 都能直接用。",[36,11377,11379],{"id":11378},"_4-路由静态服务-缓存策略","4. 路由：静态服务 + 缓存策略",[41,11381,11383],{"className":4052,"code":11382,"language":771,"meta":47,"style":47},"\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",[49,11384,11385,11390,11432,11444,11449,11464,11509,11538,11556,11577,11582,11601,11624,11629,11646,11650,11671,11675,11715,11719],{"__ignoreMap":47},[636,11386,11387],{"class":638,"line":606},[636,11388,11389],{"class":641},"\u002F\u002F backend\u002Finternal\u002Fhandler\u002Frouter.go\n",[636,11391,11392,11394,11397,11399,11402,11404,11407,11409,11412,11414,11417,11419,11421,11423,11425,11427,11430],{"class":638,"line":575},[636,11393,4253],{"class":730},[636,11395,11396],{"class":647}," NewRouter",[636,11398,2631],{"class":668},[636,11400,11401],{"class":2634},"db",[636,11403,4878],{"class":730},[636,11405,11406],{"class":647},"gorm",[636,11408,2753],{"class":668},[636,11410,11411],{"class":647},"DB",[636,11413,1396],{"class":668},[636,11415,11416],{"class":2634},"uploadDir",[636,11418,10376],{"class":730},[636,11420,3095],{"class":668},[636,11422,4285],{"class":730},[636,11424,4881],{"class":647},[636,11426,2753],{"class":668},[636,11428,11429],{"class":647},"Engine",[636,11431,2278],{"class":668},[636,11433,11434,11436,11438,11440,11442],{"class":638,"line":576},[636,11435,4694],{"class":668},[636,11437,4079],{"class":730},[636,11439,4699],{"class":668},[636,11441,4702],{"class":647},[636,11443,2826],{"class":668},[636,11445,11446],{"class":638,"line":692},[636,11447,11448],{"class":730},"    ...\n",[636,11450,11451,11454,11456,11458,11461],{"class":638,"line":698},[636,11452,11453],{"class":668},"    dist ",[636,11455,4079],{"class":730},[636,11457,9920],{"class":668},[636,11459,11460],{"class":647},"FileServer",[636,11462,11463],{"class":668},"(web.Dist)\n",[636,11465,11466,11468,11470,11472,11475,11477,11479,11481,11483,11485,11487,11489,11491,11494,11497,11499,11502,11504,11506],{"class":638,"line":727},[636,11467,4832],{"class":668},[636,11469,4861],{"class":647},[636,11471,2631],{"class":668},[636,11473,11474],{"class":651},"\"\u002Fdist\"",[636,11476,1396],{"class":668},[636,11478,4253],{"class":730},[636,11480,2631],{"class":668},[636,11482,4875],{"class":2634},[636,11484,4878],{"class":730},[636,11486,4881],{"class":647},[636,11488,2753],{"class":668},[636,11490,4266],{"class":647},[636,11492,11493],{"class":668},") { c.",[636,11495,11496],{"class":647},"Redirect",[636,11498,2631],{"class":668},[636,11500,11501],{"class":655},"302",[636,11503,1396],{"class":668},[636,11505,10907],{"class":651},[636,11507,11508],{"class":668},") })\n",[636,11510,11511,11513,11515,11517,11520,11522,11524,11526,11528,11530,11532,11534,11536],{"class":638,"line":746},[636,11512,4832],{"class":668},[636,11514,4861],{"class":647},[636,11516,2631],{"class":668},[636,11518,11519],{"class":651},"\"\u002Fdist\u002F*filepath\"",[636,11521,1396],{"class":668},[636,11523,4253],{"class":730},[636,11525,2631],{"class":668},[636,11527,4875],{"class":2634},[636,11529,4878],{"class":730},[636,11531,4881],{"class":647},[636,11533,2753],{"class":668},[636,11535,4266],{"class":647},[636,11537,3880],{"class":668},[636,11539,11540,11542,11544,11546,11549,11551,11554],{"class":638,"line":762},[636,11541,7038],{"class":668},[636,11543,4079],{"class":730},[636,11545,5132],{"class":668},[636,11547,11548],{"class":647},"Param",[636,11550,2631],{"class":668},[636,11552,11553],{"class":651},"\"filepath\"",[636,11555,2941],{"class":668},[636,11557,11558,11560,11562,11564,11566,11568,11570,11572,11575],{"class":638,"line":768},[636,11559,5147],{"class":730},[636,11561,7060],{"class":668},[636,11563,5153],{"class":730},[636,11565,11260],{"class":651},[636,11567,5248],{"class":730},[636,11569,7060],{"class":668},[636,11571,5153],{"class":730},[636,11573,11574],{"class":651}," \"\u002Findex.html\"",[636,11576,2278],{"class":668},[636,11578,11579],{"class":638,"line":782},[636,11580,11581],{"class":641},"            \u002F\u002F index.html 禁缓存：升级后浏览器立刻拿到新入口\n",[636,11583,11584,11586,11589,11591,11594,11596,11599],{"class":638,"line":805},[636,11585,5163],{"class":668},[636,11587,11588],{"class":647},"Header",[636,11590,2631],{"class":668},[636,11592,11593],{"class":651},"\"Cache-Control\"",[636,11595,1396],{"class":668},[636,11597,11598],{"class":651},"\"no-cache, no-store, must-revalidate\"",[636,11600,2941],{"class":668},[636,11602,11603,11606,11608,11611,11613,11616,11619,11622],{"class":638,"line":814},[636,11604,11605],{"class":668},"        } ",[636,11607,11280],{"class":730},[636,11609,11610],{"class":730}," if",[636,11612,5215],{"class":668},[636,11614,11615],{"class":647},"HasPrefix",[636,11617,11618],{"class":668},"(p, ",[636,11620,11621],{"class":651},"\"\u002Fassets\u002F\"",[636,11623,3880],{"class":668},[636,11625,11626],{"class":638,"line":819},[636,11627,11628],{"class":641},"            \u002F\u002F 带内容 hash 的资源可放心长缓存\n",[636,11630,11631,11633,11635,11637,11639,11641,11644],{"class":638,"line":825},[636,11632,5163],{"class":668},[636,11634,11588],{"class":647},[636,11636,2631],{"class":668},[636,11638,11593],{"class":651},[636,11640,1396],{"class":668},[636,11642,11643],{"class":651},"\"public, max-age=31536000, immutable\"",[636,11645,2941],{"class":668},[636,11647,11648],{"class":638,"line":836},[636,11649,5201],{"class":668},[636,11651,11652,11655,11658,11660,11662,11665,11668],{"class":638,"line":847},[636,11653,11654],{"class":668},"        http.",[636,11656,11657],{"class":647},"StripPrefix",[636,11659,2631],{"class":668},[636,11661,11474],{"class":651},[636,11663,11664],{"class":668},", dist).",[636,11666,11667],{"class":647},"ServeHTTP",[636,11669,11670],{"class":668},"(c.Writer, c.Request)\n",[636,11672,11673],{"class":638,"line":852},[636,11674,4927],{"class":668},[636,11676,11677,11679,11681,11683,11685,11687,11689,11691,11693,11695,11697,11699,11701,11703,11705,11707,11709,11711,11713],{"class":638,"line":861},[636,11678,4832],{"class":668},[636,11680,4861],{"class":647},[636,11682,2631],{"class":668},[636,11684,7051],{"class":651},[636,11686,1396],{"class":668},[636,11688,4253],{"class":730},[636,11690,2631],{"class":668},[636,11692,4875],{"class":2634},[636,11694,4878],{"class":730},[636,11696,4881],{"class":647},[636,11698,2753],{"class":668},[636,11700,4266],{"class":647},[636,11702,11493],{"class":668},[636,11704,11496],{"class":647},[636,11706,2631],{"class":668},[636,11708,11501],{"class":655},[636,11710,1396],{"class":668},[636,11712,10907],{"class":651},[636,11714,11508],{"class":668},[636,11716,11717],{"class":638,"line":866},[636,11718,11448],{"class":730},[636,11720,11721],{"class":638,"line":1074},[636,11722,1951],{"class":668},[16,11724,11725],{},"缓存策略是内嵌发布的必修课（坑二细说）：入口禁缓存、hash 资源长缓存。",[36,11727,11729],{"id":11728},"_5-打包脚本windows-版-linux-交叉编译版","5. 打包脚本：Windows 版 + Linux 交叉编译版",[41,11731,11733],{"className":630,"code":11732,"language":632,"meta":47,"style":47},"#!\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",[49,11734,11735,11740,11745,11755,11773,11777,11798,11802,11810,11829,11833,11840,11897,11912,11916],{"__ignoreMap":47},[636,11736,11737],{"class":638,"line":606},[636,11738,11739],{"class":641},"#!\u002Fusr\u002Fbin\u002Fenv bash\n",[636,11741,11742],{"class":638,"line":575},[636,11743,11744],{"class":641},"# build-linux.sh —— 在 Windows\u002FmacOS\u002FLinux 任意平台交叉编译 linux\u002Famd64 产物\n",[636,11746,11747,11749,11752],{"class":638,"line":576},[636,11748,7316],{"class":655},[636,11750,11751],{"class":655}," -euo",[636,11753,11754],{"class":651}," pipefail\n",[636,11756,11757,11759,11762,11765,11767,11770],{"class":638,"line":692},[636,11758,7395],{"class":655},[636,11760,11761],{"class":651}," \"$(",[636,11763,11764],{"class":647},"dirname",[636,11766,665],{"class":651},[636,11768,11769],{"class":655},"$0",[636,11771,11772],{"class":651},"\")\"\n",[636,11774,11775],{"class":638,"line":698},[636,11776,689],{"emptyLinePlaceholder":494},[636,11778,11779,11782,11784,11787,11789,11792,11795],{"class":638,"line":727},[636,11780,11781],{"class":668},"BINARY",[636,11783,737],{"class":730},[636,11785,11786],{"class":651},"\"${",[636,11788,11781],{"class":668},[636,11790,11791],{"class":730},":-",[636,11793,11794],{"class":668},"magic-img",[636,11796,11797],{"class":651},"}\"\n",[636,11799,11800],{"class":638,"line":746},[636,11801,689],{"emptyLinePlaceholder":494},[636,11803,11804,11807],{"class":638,"line":762},[636,11805,11806],{"class":655},"echo",[636,11808,11809],{"class":651}," \"[1\u002F3] 构建前端并复制产物到 backend\u002Fweb\u002Fdist ...\"\n",[636,11811,11812,11814,11816,11819,11821,11823,11825,11827],{"class":638,"line":768},[636,11813,2631],{"class":668},[636,11815,7395],{"class":655},[636,11817,11818],{"class":651}," frontend",[636,11820,707],{"class":668},[636,11822,6189],{"class":647},[636,11824,652],{"class":651},[636,11826,787],{"class":651},[636,11828,2941],{"class":668},[636,11830,11831],{"class":638,"line":782},[636,11832,689],{"emptyLinePlaceholder":494},[636,11834,11835,11837],{"class":638,"line":805},[636,11836,11806],{"class":655},[636,11838,11839],{"class":651}," \"[2\u002F3] 交叉编译后端并内嵌前端 (CGO_ENABLED=0 GOOS=linux GOARCH=amd64) ...\"\n",[636,11841,11842,11844,11846,11849,11852,11854,11856,11859,11861,11864,11867,11869,11872,11874,11876,11878,11880,11883,11886,11889,11892,11895],{"class":638,"line":814},[636,11843,2631],{"class":668},[636,11845,7395],{"class":655},[636,11847,11848],{"class":651}," backend",[636,11850,11851],{"class":668}," && CGO_ENABLED",[636,11853,737],{"class":730},[636,11855,2719],{"class":651},[636,11857,11858],{"class":668}," GOOS",[636,11860,737],{"class":730},[636,11862,11863],{"class":651},"linux",[636,11865,11866],{"class":668}," GOARCH",[636,11868,737],{"class":730},[636,11870,11871],{"class":651},"amd64",[636,11873,1140],{"class":647},[636,11875,787],{"class":651},[636,11877,796],{"class":655},[636,11879,665],{"class":651},[636,11881,11882],{"class":668},"$BINARY",[636,11884,11885],{"class":651},"\"",[636,11887,11888],{"class":655}," -ldflags=",[636,11890,11891],{"class":651},"\"-s -w\"",[636,11893,11894],{"class":651}," .\u002Fcmd\u002Fserver",[636,11896,2941],{"class":668},[636,11898,11899,11902,11905,11908,11910],{"class":638,"line":819},[636,11900,11901],{"class":647},"chmod",[636,11903,11904],{"class":651}," +x",[636,11906,11907],{"class":651}," \"backend\u002F",[636,11909,11882],{"class":668},[636,11911,878],{"class":651},[636,11913,11914],{"class":638,"line":825},[636,11915,689],{"emptyLinePlaceholder":494},[636,11917,11918,11920,11923,11925],{"class":638,"line":836},[636,11919,11806],{"class":655},[636,11921,11922],{"class":651}," \"[3\u002F3] 打包完成: backend\u002F",[636,11924,11882],{"class":668},[636,11926,878],{"class":651},[16,11928,11929,11930,11933,11934,2522],{},"Windows 版（",[49,11931,11932],{},"build-win.bat","）同理，只是产物叫 ",[49,11935,11936],{},"magic-img.exe",[11,11938,7429],{"id":7429},[36,11940,11942,11943,11946,11947,11949],{"id":11941},"坑一goembed-dist-静默漏掉-_-开头文件升级解法","坑一：",[49,11944,11945],{},"\u002F\u002Fgo:embed dist"," 静默漏掉 ",[49,11948,7469],{}," 开头文件（升级解法）",[16,11951,11952,11953,11960,11961,11963],{},"上篇说过：embed 目录时",[23,11954,11955,11956,7486,11958,7470],{},"硬编码排除 ",[49,11957,7469],{},[49,11959,2753],{},"。上篇的解法是改 Vite 输出文件名去掉 ",[49,11962,7469],{}," 前缀；这次直接用：",[41,11965,11967],{"className":4052,"code":11966,"language":771,"meta":47,"style":47},"\u002F\u002Fgo:embed all:dist   \u002F\u002F all: 前缀 = 包含全部文件，不再静默跳过\n",[49,11968,11969],{"__ignoreMap":47},[636,11970,11971,11974],{"class":638,"line":606},[636,11972,11973],{"class":641},"\u002F\u002Fgo:embed all:dist",[636,11975,11976],{"class":641},"   \u002F\u002F all: 前缀 = 包含全部文件，不再静默跳过\n",[16,11978,11979],{},[23,11980,11981,11982,11984,11985,11987],{},"结论：能用 ",[49,11983,10839],{}," 就用 ",[49,11986,10839],{},"，从根上消灭这类\"文件莫名 404\"的问题。",[36,11989,11991],{"id":11990},"坑二开发版是新的打包版却是旧的","坑二：开发版是新的、打包版却是旧的",[16,11993,11994,11995,11998],{},"现象：Vite 开发服务一切正常，",[49,11996,11997],{},":8080\u002Fdist\u002F"," 打包版却是旧界面，强刷也不一定好。",[16,12000,12001,12002,12004,12005,12008,12009,12014],{},"排查过程值得记录：先是怀疑 ",[49,12003,7632],{}," 缓存了旧 embed（上篇坑三的思路），重编重启无效；然后直接抓取 8080 返回的 HTML 和 JS，发现",[23,12006,12007],{},"服务端内容已经是新的","——问题出在",[23,12010,12011,12012],{},"浏览器缓存了 ",[49,12013,10973],{},"：它引用旧 hash 的资源，一直不更新。",[16,12016,12017,12019,12020,12023,12024,12027],{},[23,12018,7447],{},"：入口文件响应 ",[49,12021,12022],{},"Cache-Control: no-store","（见上面路由代码），带 hash 的资源给 ",[49,12025,12026],{},"immutable"," 长缓存。升级后浏览器每次拉新入口，资源文件名一变就自动换新，两全其美。",[9626,12029,12030],{},[16,12031,12032],{},"教训：排查\"新旧不一致\"先分清是哪一层——服务端产物、浏览器入口缓存、还是资源缓存。抓包看响应内容再动手。",[36,12034,12036],{"id":12035},"坑三windows-上跑的-build-linuxsh产出的文件-linux-却执行不了","坑三：Windows 上跑的 build-linux.sh，产出的文件 Linux 却执行不了",[16,12038,12039,12040,6676,12043,12049,12050,12052],{},"最初脚本只写了 ",[49,12041,12042],{},"CGO_ENABLED=0 go build",[23,12044,12045,12046],{},"没指定 ",[49,12047,12048],{},"GOOS\u002FGOARCH","。在 Windows 上跑，产出的其实是个 Windows PE 文件（只是没叫 ",[49,12051,7934],{},"），传到 Linux 自然「无法执行」。",[16,12054,12055,12057],{},[23,12056,7447],{},"：脚本里显式交叉编译：",[41,12059,12061],{"className":630,"code":12060,"language":632,"meta":47,"style":47},"CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o \"$BINARY\" -ldflags=\"-s -w\" .\u002Fcmd\u002Fserver\n",[49,12062,12063],{"__ignoreMap":47},[636,12064,12065,12068,12070,12072,12074,12076,12078,12080,12082,12084,12086,12088,12090,12092,12094,12096,12098,12100],{"class":638,"line":606},[636,12066,12067],{"class":668},"CGO_ENABLED",[636,12069,737],{"class":730},[636,12071,2719],{"class":651},[636,12073,11858],{"class":668},[636,12075,737],{"class":730},[636,12077,11863],{"class":651},[636,12079,11866],{"class":668},[636,12081,737],{"class":730},[636,12083,11871],{"class":651},[636,12085,1140],{"class":647},[636,12087,787],{"class":651},[636,12089,796],{"class":655},[636,12091,665],{"class":651},[636,12093,11882],{"class":668},[636,12095,11885],{"class":651},[636,12097,11888],{"class":655},[636,12099,11891],{"class":651},[636,12101,12102],{"class":651}," .\u002Fcmd\u002Fserver\n",[16,12104,12105,12106,12109,12110,12113,12114],{},"能这么干的前提是",[23,12107,12108],{},"全依赖树无 cgo","——SQLite 用的是 modernc 纯 Go 驱动，bcrypt 用 ",[49,12111,12112],{},"x\u002Fcrypto","。这也是选型时就要留意的点：",[23,12115,12116],{},"想跨平台交叉编译，先把 cgo 依赖清干净。",[16,12118,12119,12120,12123,12124,12127],{},"另外两个小提醒：Windows 上编出的文件没有 Unix 执行位，上传服务器后记得 ",[49,12121,12122],{},"chmod +x","；",[49,12125,12126],{},"-ldflags=\"-s -w\""," 剥离符号，体积从 30MB+ 降到 26MB。",[36,12129,12131],{"id":12130},"坑四dist-被-gitignore-后embed-直接编译失败","坑四：dist 被 gitignore 后，embed 直接编译失败",[16,12133,12134,12136,12137,12139,12140,12143,12144,12146],{},[49,12135,10998],{}," 是构建产物、入了 .gitignore，但 ",[49,12138,6831],{}," 在",[23,12141,12142],{},"编译期","就要读它——刚 clone 下来直接 ",[49,12145,7632],{}," 会报 embed 找不到文件。",[16,12148,12149,12151,12152,12155],{},[23,12150,7447],{},"：gitignore 里保留 ",[49,12153,12154],{},"dist\u002F.gitkeep","（一个空文件），让目录存在于版本库中：",[41,12157,12161],{"className":12158,"code":12159,"language":12160,"meta":47,"style":47},"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",[49,12162,12163,12168,12173],{"__ignoreMap":47},[636,12164,12165],{"class":638,"line":606},[636,12166,12167],{},"backend\u002Fweb\u002Fdist\u002F*\n",[636,12169,12170],{"class":638,"line":575},[636,12171,12172],{},"!backend\u002Fweb\u002Fdist\u002F\n",[636,12174,12175],{"class":638,"line":576},[636,12176,12177],{},"!backend\u002Fweb\u002Fdist\u002F.gitkeep\n",[16,12179,12180,12181,12183,12184,12186],{},"这样 clone 后可以先 ",[49,12182,7632],{}," 通过，再 ",[49,12185,10991],{}," 灌入真实前端。",[36,12188,12190],{"id":12189},"坑五500kb-chunk-警告别慌","坑五：>500KB chunk 警告，别慌",[16,12192,12193,12194,12197,12198,2522],{},"Vite 构建会警告单 chunk 超过 500KB。这个项目里 Base UI 组件库全量引入，单 chunk 约 640KB——",[23,12195,12196],{},"属正常，不要为了消警告去拆配置","。真要优化就上路由级 ",[49,12199,12200],{},"React.lazy",[11,12202,7645],{"id":7645},[41,12204,12206],{"className":630,"code":12205,"language":632,"meta":47,"style":47},"$ build-win.bat        # backendmagic-img.exe\n$ bash build-linux.sh  # backendmagic-img（linux\u002Famd64 ELF）\n",[49,12207,12208,12218],{"__ignoreMap":47},[636,12209,12210,12212,12215],{"class":638,"line":606},[636,12211,2747],{"class":647},[636,12213,12214],{"class":651}," build-win.bat",[636,12216,12217],{"class":641},"        # backendmagic-img.exe\n",[636,12219,12220,12222,12224,12227],{"class":638,"line":575},[636,12221,2747],{"class":647},[636,12223,7657],{"class":651},[636,12225,12226],{"class":651}," build-linux.sh",[636,12228,12229],{"class":641},"  # backendmagic-img（linux\u002Famd64 ELF）\n",[16,12231,12232,12233,12236,12237,2522],{},"一个约 26MB 的二进制，内嵌 React 前端、Gin API、SQLite 数据初始化，拷到 Windows\u002FLinux 机器直接跑，浏览器访问 ",[49,12234,12235],{},"http:\u002F\u002Flocalhost:8080"," 自动重定向到 ",[49,12238,10977],{},[11,12240,1809],{"id":1809},[16,12242,12243],{},"这轮相比上篇的三点升级：",[883,12245,12246,12252,12262],{},[59,12247,12248,12251],{},[23,12249,12250],{},"hash 路由 + base \u002Fdist","：省掉 SPA fallback，静态服务即开即用；",[59,12253,12254,469,12259,12261],{},[23,12255,12256,12258],{},[49,12257,10839],{}," embed 前缀",[49,12260,7469],{}," 开头文件问题一劳永逸；",[59,12263,12264,12270],{},[23,12265,12266,12267,12269],{},"显式 ",[49,12268,12048],{}," 交叉编译","：让「一个脚本、任意平台、产出 Linux 二进制」成为现实。",[16,12272,12273,12274,12277],{},"加上",[23,12275,12276],{},"入口 no-store \u002F 资源 immutable"," 的缓存策略，单文件分发的体验才算闭环——不然每次升级都要和浏览器缓存打架。",[1422,12279,12280],{},"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":47,"searchDepth":575,"depth":576,"links":12282},[12283,12284,12285,12292,12300,12301],{"id":2498,"depth":575,"text":2498},{"id":6825,"depth":575,"text":6825},{"id":6839,"depth":575,"text":6839,"children":12286},[12287,12288,12289,12290,12291],{"id":10867,"depth":576,"text":10868},{"id":10985,"depth":576,"text":10986},{"id":11041,"depth":576,"text":11042},{"id":11378,"depth":576,"text":11379},{"id":11728,"depth":576,"text":11729},{"id":7429,"depth":575,"text":7429,"children":12293},[12294,12296,12297,12298,12299],{"id":11941,"depth":576,"text":12295},"坑一：\u002F\u002Fgo:embed dist 静默漏掉 _ 开头文件（升级解法）",{"id":11990,"depth":576,"text":11991},{"id":12035,"depth":576,"text":12036},{"id":12130,"depth":576,"text":12131},{"id":12189,"depth":576,"text":12190},{"id":7645,"depth":575,"text":7645},{"id":1809,"depth":575,"text":1809},"以 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":10779,"description":12302},"posts\u002Fgo-embed-react-single-binary\u002Findex",[265,610,6879,12310,12311],"交叉编译","打包","WV-T-MOO5Cu-aycw-Ony8s9hjA3_k0S4XU-T0QsCow0",{"id":12314,"title":12315,"author":6,"body":12316,"description":13561,"difficulty":597,"draft":598,"extension":599,"image":13562,"meta":13563,"navigation":494,"path":13564,"pinned":598,"published":12306,"seo":13565,"series":13566,"seriesOrder":606,"stem":13567,"tags":13568,"updated":10775,"__hash__":13572},"posts\u002Fposts\u002Freact-photo-album-masonry\u002Findex.md","用 react-photo-album 给生图图库做瀑布流排版",{"type":8,"value":12317,"toc":13544},[12318,12321,12324,12342,12349,12413,12424,12427,12450,12453,12457,12471,12475,12491,12669,12673,13070,13073,13107,13110,13114,13124,13130,13155,13160,13171,13177,13188,13191,13231,13244,13367,13379,13383,13390,13408,13413,13503,13509,13513,13524,13527,13530,13536,13541],[11,12319,12320],{"id":12320},"技术背景",[16,12322,12323],{},"Magic Img 是我做的一个 AI 生图提示词管理工具（自托管、本地优先），其中「生图图库」页面要把所有生成图片以图片墙的形式展示出来。图片墙有两个天然矛盾：",[883,12325,12326,12332],{},[59,12327,12328,12331],{},[23,12329,12330],{},"等宽网格","（CSS Grid）：整齐，但每行高度由最高的那张决定——不同比例的图片混排时，矮图下面会留下大洞，看起来\"很乱\"；",[59,12333,12334,12337,12338,12341],{},[23,12335,12336],{},"原生比例展示","：图库必须不裁剪（这是产品约束），所以不能用 ",[49,12339,12340],{},"object-cover"," 强行统一比例。",[16,12343,12344,12345,12348],{},"业内的标准解法是 ",[23,12346,12347],{},"Masonry 瀑布流","：按列紧密堆放，每张图保持原始比例，列间高度错落但整体紧凑。实现瀑布流的常见方案有三类：",[121,12350,12351,12365],{},[124,12352,12353],{},[127,12354,12355,12357,12360,12363],{},[130,12356,3310],{},[130,12358,12359],{},"依赖",[130,12361,12362],{},"顺序",[130,12364,5926],{},[137,12366,12367,12384,12397],{},[127,12368,12369,12376,12378,12381],{},[142,12370,12371,12372,12375],{},"CSS ",[49,12373,12374],{},"columns"," 多列",[142,12377,363],{},[142,12379,12380],{},"纵向阅读序（先下后右）",[142,12382,12383],{},"最新图片不再出现在左上角，时间语义丢失",[127,12385,12386,12389,12391,12394],{},[142,12387,12388],{},"grid row-span 自研",[142,12390,363],{},[142,12392,12393],{},"保持",[142,12395,12396],{},"需固定列数 + 固定行高，逻辑要自己写",[127,12398,12399,12404,12407,12410],{},[142,12400,12401],{},[23,12402,12403],{},"react-photo-album",[142,12405,12406],{},"~4KB，无依赖",[142,12408,12409],{},"基本保持（逐列最短列填充）",[142,12411,12412],{},"专为相册墙设计，TS 友好，维护活跃",[16,12414,12415,12416,12419,12420,12423],{},"最终选了 ",[23,12417,12418],{},"react-photo-album v3","：体积小、类型齐全，而且它的 Masonry 模式恰好需要「每张图的 ",[49,12421,12422],{},"width\u002Fheight","」——我们的数据库里本来就存了，零额外成本。",[11,12425,12426],{"id":12426},"需求梳理",[883,12428,12429,12435,12441,12444,12447],{},[59,12430,12431,12432,12123],{},"展示全部生成图（21 张起，会持续增长），",[23,12433,12434],{},"原比例、不裁剪",[59,12436,12437,12438,12123],{},"不同尺寸（2304×1728、4608×3456 等）混排时",[23,12439,12440],{},"紧凑、不留洞",[59,12442,12443],{},"顺序基本保持「新图靠前」；",[59,12445,12446],{},"点击任意一张打开大图预览（YARL 灯箱），悬停显示所属提示词；",[59,12448,12449],{},"平板性能：墙上只用 300px 缩略图，原图只进灯箱按需加载。",[11,12451,12452],{"id":12452},"安装与使用",[36,12454,12456],{"id":12455},"_1-安装","1. 安装",[41,12458,12460],{"className":630,"code":12459,"language":632,"meta":47,"style":47},"npm install react-photo-album\n",[49,12461,12462],{"__ignoreMap":47},[636,12463,12464,12466,12468],{"class":638,"line":606},[636,12465,6189],{"class":647},[636,12467,712],{"class":651},[636,12469,12470],{"class":651}," react-photo-album\n",[36,12472,12474],{"id":12473},"_2-准备数据关键输入是宽高","2. 准备数据（关键输入是宽高）",[16,12476,12477,12478,12481,12482,7452,12484,7452,12487,12490],{},"库要求的 ",[49,12479,12480],{},"Photo"," 对象只有三个必需字段：",[49,12483,3171],{},[49,12485,12486],{},"width",[49,12488,12489],{},"height","。我们的接口本来就返回每张图的原始宽高：",[41,12492,12494],{"className":10925,"code":12493,"language":10927,"meta":47,"style":47},"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",[49,12495,12496,12514,12519,12526,12530,12546,12556,12560,12564,12597,12608,12627,12632,12637,12659,12664],{"__ignoreMap":47},[636,12497,12498,12500,12503,12505,12508,12511],{"class":638,"line":606},[636,12499,4060],{"class":730},[636,12501,12502],{"class":668}," PhotoAlbum, { ",[636,12504,5445],{"class":730},[636,12506,12507],{"class":668}," Photo } ",[636,12509,12510],{"class":730},"from",[636,12512,12513],{"class":651}," \"react-photo-album\"\n",[636,12515,12516],{"class":638,"line":575},[636,12517,12518],{"class":641},"\u002F\u002F 必须引入样式表！容器\u002F列的布局规则都在这里（坑 1 详述）\n",[636,12520,12521,12523],{"class":638,"line":576},[636,12522,4060],{"class":730},[636,12524,12525],{"class":651}," \"react-photo-album\u002Fstyles.css\"\n",[636,12527,12528],{"class":638,"line":692},[636,12529,689],{"emptyLinePlaceholder":494},[636,12531,12532,12535,12538,12541,12544],{"class":638,"line":698},[636,12533,12534],{"class":730},"interface",[636,12536,12537],{"class":647}," GalleryPhoto",[636,12539,12540],{"class":730}," extends",[636,12542,12543],{"class":647}," Photo",[636,12545,2278],{"class":668},[636,12547,12548,12551,12554],{"class":638,"line":727},[636,12549,12550],{"class":2634},"  prompt_text",[636,12552,12553],{"class":730},"?:",[636,12555,5451],{"class":655},[636,12557,12558],{"class":638,"line":746},[636,12559,1951],{"class":668},[636,12561,12562],{"class":638,"line":762},[636,12563,689],{"emptyLinePlaceholder":494},[636,12565,12566,12568,12571,12573,12575,12577,12579,12582,12585,12588,12590,12592,12594],{"class":638,"line":768},[636,12567,2865],{"class":730},[636,12569,12570],{"class":655}," albumPhotos",[636,12572,1573],{"class":730},[636,12574,12537],{"class":647},[636,12576,2653],{"class":668},[636,12578,737],{"class":730},[636,12580,12581],{"class":668}," images.",[636,12583,12584],{"class":647},"map",[636,12586,12587],{"class":668},"((",[636,12589,907],{"class":2634},[636,12591,3095],{"class":668},[636,12593,3059],{"class":730},[636,12595,12596],{"class":668}," ({\n",[636,12598,12599,12602,12605],{"class":638,"line":782},[636,12600,12601],{"class":668},"  key: ",[636,12603,12604],{"class":647},"String",[636,12606,12607],{"class":668},"(img.id),\n",[636,12609,12610,12613,12616,12619,12621,12624],{"class":638,"line":805},[636,12611,12612],{"class":668},"  src: ",[636,12614,12615],{"class":647},"assetUrl",[636,12617,12618],{"class":668},"(img.thumb_path) ",[636,12620,2845],{"class":730},[636,12622,12623],{"class":647}," assetUrl",[636,12625,12626],{"class":668},"(img.file_path),\n",[636,12628,12629],{"class":638,"line":814},[636,12630,12631],{"class":668},"  width: img.width,\n",[636,12633,12634],{"class":638,"line":819},[636,12635,12636],{"class":668},"  height: img.height,\n",[636,12638,12639,12642,12644,12647,12649,12651,12654,12657],{"class":638,"line":825},[636,12640,12641],{"class":668},"  alt: img.prompt_text ",[636,12643,2845],{"class":730},[636,12645,12646],{"class":651}," `生成图 ${",[636,12648,907],{"class":668},[636,12650,2753],{"class":651},[636,12652,12653],{"class":668},"id",[636,12655,12656],{"class":651},"}`",[636,12658,2289],{"class":668},[636,12660,12661],{"class":638,"line":836},[636,12662,12663],{"class":668},"  prompt_text: img.prompt_text,\n",[636,12665,12666],{"class":638,"line":847},[636,12667,12668],{"class":668},"}))\n",[36,12670,12672],{"id":12671},"_3-使用-masonry-布局","3. 使用 Masonry 布局",[41,12674,12676],{"className":10925,"code":12675,"language":10927,"meta":47,"style":47},"\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",[49,12677,12678,12685,12695,12763,12777,12790,12800,12824,12834,12855,12873,12901,12908,12942,12950,12960,12976,12986,12996,13001,13011,13028,13033,13042,13051,13056,13060,13065],{"__ignoreMap":47},[636,12679,12680,12682],{"class":638,"line":606},[636,12681,1576],{"class":668},[636,12683,12684],{"class":655},"PhotoAlbum\n",[636,12686,12687,12690,12692],{"class":638,"line":575},[636,12688,12689],{"class":647},"  layout",[636,12691,737],{"class":730},[636,12693,12694],{"class":651},"\"masonry\"\n",[636,12696,12697,12700,12702,12705,12708,12710,12712,12715,12717,12720,12723,12725,12728,12731,12733,12736,12738,12741,12743,12745,12747,12750,12752,12755,12757,12760],{"class":638,"line":576},[636,12698,12699],{"class":647},"  columns",[636,12701,737],{"class":730},[636,12703,12704],{"class":668},"{(",[636,12706,12707],{"class":2634},"cw",[636,12709,3095],{"class":668},[636,12711,3059],{"class":730},[636,12713,12714],{"class":668}," (cw ",[636,12716,1576],{"class":730},[636,12718,12719],{"class":655}," 640",[636,12721,12722],{"class":730}," ?",[636,12724,5245],{"class":655},[636,12726,12727],{"class":730}," :",[636,12729,12730],{"class":668}," cw ",[636,12732,1576],{"class":730},[636,12734,12735],{"class":655}," 960",[636,12737,12722],{"class":730},[636,12739,12740],{"class":655}," 3",[636,12742,12727],{"class":730},[636,12744,12730],{"class":668},[636,12746,1576],{"class":730},[636,12748,12749],{"class":655}," 1280",[636,12751,12722],{"class":730},[636,12753,12754],{"class":655}," 4",[636,12756,12727],{"class":730},[636,12758,12759],{"class":655}," 5",[636,12761,12762],{"class":668},")}\n",[636,12764,12765,12768,12770,12772,12775],{"class":638,"line":692},[636,12766,12767],{"class":647},"  spacing",[636,12769,737],{"class":730},[636,12771,4912],{"class":668},[636,12773,12774],{"class":655},"16",[636,12776,1951],{"class":668},[636,12778,12779,12782,12784,12786,12788],{"class":638,"line":698},[636,12780,12781],{"class":647},"  padding",[636,12783,737],{"class":730},[636,12785,4912],{"class":668},[636,12787,2719],{"class":655},[636,12789,1951],{"class":668},[636,12791,12792,12795,12797],{"class":638,"line":727},[636,12793,12794],{"class":647},"  photos",[636,12796,737],{"class":730},[636,12798,12799],{"class":668},"{albumPhotos}\n",[636,12801,12802,12805,12807,12810,12813,12816,12818,12821],{"class":638,"line":746},[636,12803,12804],{"class":647},"  onClick",[636,12806,737],{"class":730},[636,12808,12809],{"class":668},"{({ ",[636,12811,12812],{"class":2634},"index",[636,12814,12815],{"class":668}," }) ",[636,12817,3059],{"class":730},[636,12819,12820],{"class":647}," openPreview",[636,12822,12823],{"class":668},"(index)}\n",[636,12825,12826,12829,12831],{"class":638,"line":762},[636,12827,12828],{"class":647},"  render",[636,12830,737],{"class":730},[636,12832,12833],{"class":668},"{{\n",[636,12835,12836,12839,12842,12844,12846,12849,12851,12853],{"class":638,"line":768},[636,12837,12838],{"class":647},"    photo",[636,12840,12841],{"class":668},": (",[636,12843,3166],{"class":2634},[636,12845,1396],{"class":668},[636,12847,12848],{"class":2634},"context",[636,12850,3095],{"class":668},[636,12852,3059],{"class":730},[636,12854,2278],{"class":668},[636,12856,12857,12859,12862,12865,12868,12870],{"class":638,"line":782},[636,12858,2769],{"class":730},[636,12860,12861],{"class":668}," { ",[636,12863,12864],{"class":655},"onClick",[636,12866,12867],{"class":668}," } ",[636,12869,737],{"class":730},[636,12871,12872],{"class":668}," props\n",[636,12874,12875,12877,12879,12882,12884,12886,12888,12890,12892,12894,12896,12898],{"class":638,"line":805},[636,12876,2769],{"class":730},[636,12878,12861],{"class":668},[636,12880,12881],{"class":655},"photo",[636,12883,1396],{"class":668},[636,12885,12812],{"class":655},[636,12887,1396],{"class":668},[636,12889,12486],{"class":655},[636,12891,1396],{"class":668},[636,12893,12489],{"class":655},[636,12895,12867],{"class":668},[636,12897,737],{"class":730},[636,12899,12900],{"class":668}," context\n",[636,12902,12903,12906],{"class":638,"line":814},[636,12904,12905],{"class":730},"      return",[636,12907,5458],{"class":668},[636,12909,12910,12913,12916,12919,12921,12924,12927,12929,12932,12935,12937,12940],{"class":638,"line":819},[636,12911,12912],{"class":668},"        \u003C",[636,12914,12915],{"class":2338},"button",[636,12917,12918],{"class":647}," type",[636,12920,737],{"class":730},[636,12922,12923],{"class":651},"\"button\"",[636,12925,12926],{"class":647}," onClick",[636,12928,737],{"class":730},[636,12930,12931],{"class":668},"{onClick} ",[636,12933,12934],{"class":647},"className",[636,12936,737],{"class":730},[636,12938,12939],{"class":651},"\"group relative block w-full ...\"",[636,12941,1233],{"class":668},[636,12943,12944,12947],{"class":638,"line":825},[636,12945,12946],{"class":668},"          \u003C",[636,12948,12949],{"class":2338},"img\n",[636,12951,12952,12955,12957],{"class":638,"line":836},[636,12953,12954],{"class":647},"            src",[636,12956,737],{"class":730},[636,12958,12959],{"class":668},"{photo.src}\n",[636,12961,12962,12965,12967,12970,12972,12974],{"class":638,"line":847},[636,12963,12964],{"class":647},"            alt",[636,12966,737],{"class":730},[636,12968,12969],{"class":668},"{photo.alt ",[636,12971,2845],{"class":730},[636,12973,5156],{"class":651},[636,12975,1951],{"class":668},[636,12977,12978,12981,12983],{"class":638,"line":852},[636,12979,12980],{"class":647},"            decoding",[636,12982,737],{"class":730},[636,12984,12985],{"class":651},"\"async\"\n",[636,12987,12988,12991,12993],{"class":638,"line":861},[636,12989,12990],{"class":647},"            style",[636,12992,737],{"class":730},[636,12994,12995],{"class":668},"{{ width, height }}\n",[636,12997,12998],{"class":638,"line":866},[636,12999,13000],{"class":668},"          \u002F>\n",[636,13002,13003,13006,13009],{"class":638,"line":1074},[636,13004,13005],{"class":668},"          {",[636,13007,13008],{"class":641},"\u002F* hover 提示词浮层 *\u002F",[636,13010,1951],{"class":668},[636,13012,13013,13015,13018,13021,13023,13026],{"class":638,"line":1082},[636,13014,12946],{"class":668},[636,13016,13017],{"class":2338},"div",[636,13019,13020],{"class":647}," className",[636,13022,737],{"class":730},[636,13024,13025],{"class":651},"\"... opacity-0 group-hover:opacity-100\"",[636,13027,1233],{"class":668},[636,13029,13030],{"class":638,"line":1087},[636,13031,13032],{"class":668},"            {photo.prompt_text}\n",[636,13034,13035,13038,13040],{"class":638,"line":1720},[636,13036,13037],{"class":668},"          \u003C\u002F",[636,13039,13017],{"class":2338},[636,13041,1233],{"class":668},[636,13043,13044,13047,13049],{"class":638,"line":1734},[636,13045,13046],{"class":668},"        \u003C\u002F",[636,13048,12915],{"class":2338},[636,13050,1233],{"class":668},[636,13052,13053],{"class":638,"line":1741},[636,13054,13055],{"class":668},"      )\n",[636,13057,13058],{"class":638,"line":1746},[636,13059,7606],{"class":668},[636,13061,13062],{"class":638,"line":1752},[636,13063,13064],{"class":668},"  }}\n",[636,13066,13067],{"class":638,"line":1760},[636,13068,13069],{"class":668},"\u002F>\n",[16,13071,13072],{},"几个要点：",[56,13074,13075,13084,13097],{},[59,13076,13077,13079,13080,13083],{},[49,13078,12374],{}," 可以是数字或 ",[49,13081,13082],{},"(containerWidth) => number"," 函数，做响应式列数很方便；",[59,13085,13086,13087,13089,13090,13093,13094,13096],{},"点击回调 ",[49,13088,12864],{}," 里拿到的是",[23,13091,13092],{},"原始数组下标"," ",[49,13095,12812],{},"，直接传给灯箱即可；",[59,13098,13099,13100,13103,13104,13106],{},"自定义 ",[49,13101,13102],{},"render.photo"," 后，图片尺寸要自己用 context 里的 ",[49,13105,12422],{},"（渲染尺寸）设置——这两个值库已经按列宽算好了。",[11,13108,13109],{"id":13109},"遇到的坑",[36,13111,13113],{"id":13112},"坑-1忘记引入样式表-一张一列","坑 1：忘记引入样式表 → 「一张一列」",[16,13115,13116,13117,13120,13121,2522],{},"这是最坑的一个。装完包、写好 ",[49,13118,13119],{},"\u003CPhotoAlbum layout=\"masonry\">","，页面渲染出来却是：",[23,13122,13123],{},"每张图占一整行，右侧大片置灰空白",[16,13125,13126,13127,13129],{},"排查过程：先怀疑是 ",[49,13128,12374],{}," 函数没生效，改成固定数字无效；再检查 DOM——容器和 5 个列（track）都在，列的宽度也算对了，但图片全都堆在布局里。",[16,13131,13132,13133,13136,13137,7452,13140,13143,13144,13150,13151,13154],{},"最后翻包的 ",[49,13134,13135],{},"package.json"," 发现它导出了独立的 CSS 文件（",[49,13138,13139],{},".\u002Fstyles.css",[49,13141,13142],{},".\u002Fmasonry.css"," 等），而",[23,13145,13146,13147],{},"官方 README 示例都带了一行 ",[49,13148,13149],{},"import \"react-photo-album\u002Fstyles.css\"","。容器是 flex、列是 flex-column、列宽按 ",[49,13152,13153],{},"--react-photo-album--columns"," 计算——这些规则全在这个样式表里，不引入就退化成普通块级堆叠。",[16,13156,13157,13159],{},[23,13158,7447],{},"：顶部加一行：",[41,13161,13163],{"className":10925,"code":13162,"language":10927,"meta":47,"style":47},"import \"react-photo-album\u002Fstyles.css\"\n",[49,13164,13165],{"__ignoreMap":47},[636,13166,13167,13169],{"class":638,"line":606},[636,13168,4060],{"class":730},[636,13170,12525],{"class":651},[16,13172,13173,13174],{},"经验：",[23,13175,13176],{},"用这类\"无运行时依赖\"的布局库，先看它的 exports 里有没有独立 CSS，有就一定要引。",[36,13178,13180,13181,13183,13184,13187],{"id":13179},"坑-2renderphoto-是-props-context-双参签名","坑 2：",[49,13182,13102],{}," 是 ",[49,13185,13186],{},"(props, context)"," 双参签名",[16,13189,13190],{},"自定义渲染时我凭直觉写了：",[41,13192,13194],{"className":10925,"code":13193,"language":10927,"meta":47,"style":47},"photo: ({ photo, index, width, height, onClick }) => ... \u002F\u002F ❌ 报错：Property 'photo' does not exist\n",[49,13195,13196],{"__ignoreMap":47},[636,13197,13198,13200,13203,13205,13207,13209,13211,13213,13215,13217,13219,13221,13223,13225,13228],{"class":638,"line":606},[636,13199,12881],{"class":647},[636,13201,13202],{"class":668},": ({ ",[636,13204,12881],{"class":2634},[636,13206,1396],{"class":668},[636,13208,12812],{"class":2634},[636,13210,1396],{"class":668},[636,13212,12486],{"class":2634},[636,13214,1396],{"class":668},[636,13216,12489],{"class":2634},[636,13218,1396],{"class":668},[636,13220,12864],{"class":2634},[636,13222,12815],{"class":668},[636,13224,3059],{"class":730},[636,13226,13227],{"class":730}," ...",[636,13229,13230],{"class":641}," \u002F\u002F ❌ 报错：Property 'photo' does not exist\n",[16,13232,13233,13234,13236,13237,13240,13241,469],{},"TS 报错提示 ",[49,13235,12881],{}," 等属性不存在于 ",[49,13238,13239],{},"RenderPhotoProps","。看类型定义才发现 v3 的渲染函数是",[23,13242,13243],{},"两个参数",[41,13245,13247],{"className":10925,"code":13246,"language":10927,"meta":47,"style":47},"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",[49,13248,13249,13295,13300,13320,13334,13359,13363],{"__ignoreMap":47},[636,13250,13251,13253,13256,13258,13261,13263,13265,13268,13270,13272,13274,13276,13279,13281,13283,13285,13288,13290,13292],{"class":638,"line":606},[636,13252,5445],{"class":730},[636,13254,13255],{"class":647}," RenderFunction",[636,13257,1576],{"class":668},[636,13259,13260],{"class":647},"Props",[636,13262,1396],{"class":668},[636,13264,4266],{"class":647},[636,13266,13267],{"class":668},"> ",[636,13269,737],{"class":730},[636,13271,2673],{"class":668},[636,13273,3166],{"class":2634},[636,13275,1573],{"class":730},[636,13277,13278],{"class":647}," Props",[636,13280,1396],{"class":668},[636,13282,12848],{"class":2634},[636,13284,1573],{"class":730},[636,13286,13287],{"class":647}," Context",[636,13289,3095],{"class":668},[636,13291,3059],{"class":730},[636,13293,13294],{"class":647}," ReactNode\n",[636,13296,13297],{"class":638,"line":575},[636,13298,13299],{"class":641},"\u002F\u002F photo 的渲染上下文（photo\u002Findex\u002Fwidth\u002Fheight）在第二个参数里\n",[636,13301,13302,13304,13306,13308,13310,13312,13314,13316,13318],{"class":638,"line":576},[636,13303,12881],{"class":647},[636,13305,1573],{"class":730},[636,13307,2673],{"class":668},[636,13309,3166],{"class":2634},[636,13311,1396],{"class":668},[636,13313,12848],{"class":2634},[636,13315,3095],{"class":668},[636,13317,3059],{"class":730},[636,13319,2278],{"class":668},[636,13321,13322,13324,13326,13328,13331],{"class":638,"line":692},[636,13323,3120],{"class":647},[636,13325,12861],{"class":668},[636,13327,12864],{"class":2634},[636,13329,13330],{"class":668}," } = ",[636,13332,13333],{"class":2634},"props\n",[636,13335,13336,13338,13340,13342,13344,13346,13348,13350,13352,13354,13356],{"class":638,"line":698},[636,13337,3120],{"class":647},[636,13339,12861],{"class":668},[636,13341,12881],{"class":2634},[636,13343,1396],{"class":668},[636,13345,12812],{"class":2634},[636,13347,1396],{"class":668},[636,13349,12486],{"class":2634},[636,13351,1396],{"class":668},[636,13353,12489],{"class":2634},[636,13355,13330],{"class":668},[636,13357,13358],{"class":2634},"context\n",[636,13360,13361],{"class":638,"line":727},[636,13362,10918],{"class":730},[636,13364,13365],{"class":638,"line":746},[636,13366,1951],{"class":668},[16,13368,13369,13371,13372,13374,13375,13378],{},[23,13370,7447],{},"：按 ",[49,13373,13186],{}," 拆解即可。装新库先扫一眼 ",[49,13376,13377],{},".d.ts"," 里的 Render 类型签名，能少走很多弯路。",[36,13380,13382],{"id":13381},"坑-3测试断言把dom-顺序当成了视觉顺序","坑 3：测试断言把「DOM 顺序」当成了「视觉顺序」",[16,13384,13385,13386,13389],{},"布局修好后，我在浏览器自测脚本里加了一条断言：取前两个 ",[49,13387,13388],{},"\u003Cimg>","，比较它们的左坐标，认为应该并排（不同列）。",[16,13391,13392,13393,13396,13397,7486,13400,13403,13404,13407],{},"结果断言失败——但页面明明是 5 列并排。排查发现：",[23,13394,13395],{},"瀑布流的 DOM 是按\"列\"组织的","（第 1 列的全部图片在前，第 2 列的在后），所以 ",[49,13398,13399],{},"img[0]",[49,13401,13402],{},"img[1]"," 属于",[23,13405,13406],{},"同一列","，左坐标当然相同。",[16,13409,13410,13412],{},[23,13411,7447],{},"：断言改为比较「第 1 列的首张图 vs 第 2 列的首张图」：",[41,13414,13418],{"className":13415,"code":13416,"language":13417,"meta":47,"style":47},"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",[49,13419,13420,13445,13468],{"__ignoreMap":47},[636,13421,13422,13424,13426,13428,13431,13433,13435,13438,13440,13443],{"class":638,"line":606},[636,13423,2865],{"class":730},[636,13425,3877],{"class":655},[636,13427,2275],{"class":730},[636,13429,13430],{"class":668}," tracks[",[636,13432,2719],{"class":655},[636,13434,2784],{"class":668},[636,13436,13437],{"class":647},"querySelector",[636,13439,2631],{"class":668},[636,13441,13442],{"class":651},"\"img\"",[636,13444,2941],{"class":668},[636,13446,13447,13449,13452,13454,13456,13458,13460,13462,13464,13466],{"class":638,"line":575},[636,13448,2865],{"class":730},[636,13450,13451],{"class":655}," b",[636,13453,2275],{"class":730},[636,13455,13430],{"class":668},[636,13457,756],{"class":655},[636,13459,2784],{"class":668},[636,13461,13437],{"class":647},[636,13463,2631],{"class":668},[636,13465,13442],{"class":651},[636,13467,2941],{"class":668},[636,13469,13470,13472,13475,13478,13481,13484,13487,13490,13493,13495,13498,13500],{"class":638,"line":576},[636,13471,2682],{"class":730},[636,13473,13474],{"class":668}," Math.",[636,13476,13477],{"class":647},"abs",[636,13479,13480],{"class":668},"(a.",[636,13482,13483],{"class":647},"getBoundingClientRect",[636,13485,13486],{"class":668},"().left ",[636,13488,13489],{"class":730},"-",[636,13491,13492],{"class":668}," b.",[636,13494,13483],{"class":647},[636,13496,13497],{"class":668},"().left) ",[636,13499,1186],{"class":730},[636,13501,13502],{"class":655}," 10\n",[16,13504,13173,13505,13508],{},[23,13506,13507],{},"验证布局类库时，先搞清楚它生成的 DOM 结构，再写断言","；另外把「真实浏览器探针」当排查手段（直接打印各 track 的 x 坐标和子元素数量），比盯着代码猜快得多。",[36,13510,13512],{"id":13511},"坑-4等宽网格的洞引入前就存在的产品问题","坑 4：等宽网格的\"洞\"（引入前就存在的产品问题）",[16,13514,13515,13516,13519,13520,13523],{},"顺带记录一下为什么换掉原来的布局：CSS Grid ",[49,13517,13518],{},"repeat(auto-fill, minmax(220px, 1fr))"," 等宽铺满，图片 ",[49,13521,13522],{},"w-full h-auto","。当一行里混有不同比例时，行高取最高的那张，矮图底部留空，几行错落下来就显得乱；另外窗口宽度\"差一点放不下下一张\"时，右侧会空出近一整列。",[16,13525,13526],{},"换成 Masonry 后这两个问题都消失了——列内紧密堆叠、列数自适应。",[11,13528,13529],{"id":13529},"效果与小结",[16,13531,13532,13533,13535],{},"最终效果：21 张不同比例的生成图在 5 列瀑布流里紧密排列，原比例不裁剪，悬停出提示词浮层，点击进 YARL 大图预览；缩略图总大小仅 360KB，进页面即全量加载 + ",[49,13534,12026],{}," 长缓存，平板上快速滚动也跟手。",[16,13537,10704,13538],{},[23,13539,13540],{},"react-photo-album 是个小但专业的相册布局库，只要记住三件事——引样式表、喂宽高、render 函数是双参——就能在十分钟内把图片墙升级成真正的瀑布流。",[1422,13542,13543],{},"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":47,"searchDepth":575,"depth":576,"links":13545},[13546,13547,13548,13553,13560],{"id":12320,"depth":575,"text":12320},{"id":12426,"depth":575,"text":12426},{"id":12452,"depth":575,"text":12452,"children":13549},[13550,13551,13552],{"id":12455,"depth":576,"text":12456},{"id":12473,"depth":576,"text":12474},{"id":12671,"depth":576,"text":12672},{"id":13109,"depth":575,"text":13109,"children":13554},[13555,13556,13558,13559],{"id":13112,"depth":576,"text":13113},{"id":13179,"depth":576,"text":13557},"坑 2：render.photo 是 (props, context) 双参签名",{"id":13381,"depth":576,"text":13382},{"id":13511,"depth":576,"text":13512},{"id":13529,"depth":575,"text":13529},"记录在 Magic Img 生图图库中引入 react-photo-album 实现 Masonry 瀑布流的过程：技术选型背景、需求梳理、安装使用，以及「忘记引入样式表导致一张一列」等踩坑与排查方法。","\u002Fposts\u002Freact-photo-album-masonry\u002Fimg\u002Fcover.svg",{},"\u002Fposts\u002Freact-photo-album-masonry",{"title":12315,"description":13561},"前端工程实践","posts\u002Freact-photo-album-masonry\u002Findex",[610,12403,13569,13570,13571],"瀑布流","前端","Tailwind","-O-kAQLEyRzwVhOpuo-yBxZim9LtvhChPkpP92ipyjM",1786887348660]