[{"data":1,"prerenderedAt":13278},["ShallowReactive",2],{"holidays":3,"wyy-comment":118,"home-posts":122},{"code":4,"holiday":5},0,{"01-01":6,"01-02":12,"01-03":16,"01-04":18,"02-14":22,"02-15":27,"02-16":30,"02-17":33,"02-18":36,"02-19":39,"02-20":42,"02-21":45,"02-22":48,"02-23":51,"02-28":54,"04-04":57,"04-05":61,"04-06":63,"05-01":65,"05-02":69,"05-03":71,"05-04":73,"05-05":75,"05-09":77,"06-19":80,"06-20":84,"06-21":86,"09-20":88,"09-25":93,"09-26":96,"09-27":98,"10-01":100,"10-02":103,"10-03":105,"10-04":107,"10-05":109,"10-06":111,"10-07":113,"10-10":115},{"holiday":7,"name":8,"wage":9,"date":10,"rest":11},true,"元旦",3,"2026-01-01",37,{"holiday":7,"name":8,"wage":13,"date":14,"rest":15},2,"2026-01-02",1,{"holiday":7,"name":8,"wage":13,"date":17,"rest":15},"2026-01-03",{"holiday":19,"name":20,"wage":15,"after":7,"target":8,"date":21,"rest":15},false,"元旦后补班","2026-01-04",{"holiday":19,"name":23,"wage":15,"after":19,"target":24,"date":25,"rest":26},"春节前补班","春节","2026-02-14",5,{"holiday":7,"name":24,"wage":13,"date":28,"rest":29},"2026-02-15",6,{"holiday":7,"name":31,"wage":9,"date":32,"rest":15},"除夕","2026-02-16",{"holiday":7,"name":34,"wage":9,"date":35,"rest":15},"初一","2026-02-17",{"holiday":7,"name":37,"wage":9,"date":38,"rest":15},"初二","2026-02-18",{"holiday":7,"name":40,"wage":9,"date":41,"rest":15},"初三","2026-02-19",{"holiday":7,"name":43,"wage":13,"date":44,"rest":15},"初四","2026-02-20",{"holiday":7,"name":46,"wage":13,"date":47,"rest":15},"初五","2026-02-21",{"holiday":7,"name":49,"wage":13,"date":50,"rest":15},"初六","2026-02-22",{"holiday":7,"name":52,"wage":13,"date":53,"rest":15},"初七","2026-02-23",{"holiday":19,"name":55,"wage":15,"target":24,"after":7,"date":56,"rest":13},"春节后补班","2026-02-28",{"holiday":7,"name":58,"wage":13,"date":59,"rest":60},"清明节","2026-04-04",27,{"holiday":7,"name":58,"wage":9,"date":62,"rest":15},"2026-04-05",{"holiday":7,"name":58,"wage":13,"date":64,"rest":15},"2026-04-06",{"holiday":7,"name":66,"wage":9,"date":67,"rest":68},"劳动节","2026-05-01",14,{"holiday":7,"name":66,"wage":9,"date":70,"rest":15},"2026-05-02",{"holiday":7,"name":66,"wage":13,"date":72,"rest":15},"2026-05-03",{"holiday":7,"name":66,"wage":13,"date":74,"rest":15},"2026-05-04",{"holiday":7,"name":66,"wage":13,"date":76,"rest":15},"2026-05-05",{"holiday":19,"name":78,"wage":15,"after":7,"target":66,"date":79,"rest":15},"劳动节后补班","2026-05-09",{"holiday":7,"name":81,"wage":9,"date":82,"rest":83},"端午节","2026-06-19",36,{"holiday":7,"name":81,"wage":13,"date":85,"rest":15},"2026-06-20",{"holiday":7,"name":81,"wage":13,"date":87,"rest":15},"2026-06-21",{"holiday":19,"name":89,"after":19,"wage":15,"target":90,"date":91,"rest":92},"中秋节前补班","中秋节","2026-09-20",35,{"holiday":7,"name":90,"wage":9,"date":94,"rest":95},"2026-09-25",40,{"holiday":7,"name":90,"wage":13,"date":97,"rest":15},"2026-09-26",{"holiday":7,"name":90,"wage":13,"date":99,"rest":15},"2026-09-27",{"holiday":7,"name":101,"wage":9,"date":102,"rest":9},"国庆节","2026-10-01",{"holiday":7,"name":101,"wage":9,"date":104,"rest":15},"2026-10-02",{"holiday":7,"name":101,"wage":9,"date":106,"rest":15},"2026-10-03",{"holiday":7,"name":101,"wage":13,"date":108,"rest":15},"2026-10-04",{"holiday":7,"name":101,"wage":13,"date":110,"rest":15},"2026-10-05",{"holiday":7,"name":101,"wage":13,"date":112,"rest":15},"2026-10-06",{"holiday":7,"name":101,"wage":13,"date":114,"rest":15},"2026-10-07",{"holiday":19,"after":7,"wage":15,"name":116,"target":101,"date":117},"国庆节后补班","2026-10-10",{"user":119,"music":120,"content":121},"Little-Farmer","梦中的婚礼","大一军事理论课，阶梯教室里有一架钢琴，老师问谁会弹，有一朋友喊了我女朋友的名字，我本以为她会害羞，没想到她竟然上去了，一曲仿佛是刻在心里的梦中的婚礼从她指尖流出，曲终，掌声雷动。下课后，她对我说，我其实是弹给你一个人听的",[123,578,2242,3528,4716,5280,6708,7666,8439,10457,10737,11881,12094,12513],{"id":124,"title":125,"author":126,"body":127,"description":563,"difficulty":564,"draft":19,"extension":565,"image":566,"meta":567,"navigation":7,"path":568,"pinned":7,"published":569,"seo":570,"series":571,"seriesOrder":15,"stem":572,"tags":573,"updated":576,"__hash__":577},"posts\u002Fposts\u002Fhello-world\u002Findex.md","Hello World - 欢迎使用 SuBlog",null,{"type":128,"value":129,"toc":554},"minimark",[130,135,144,147,150,153,233,236,239,303,306,309,391,394,463,466,469,487,494,497,528,531,542,545,550],[131,132,134],"h2",{"id":133},"博客上线啦","博客上线啦！",[136,137,138,139,143],"p",{},"Hello World！欢迎使用 ",[140,141,142],"strong",{},"SuBlog","，这是站点的第一篇示例文章。",[136,145,146],{},"经过一段时间的折腾，这个基于 Nuxt 3 的个人博客终于上线了。在这里记录一下这个项目的技术选型和功能特性。",[131,148,149],{"id":149},"技术栈",[136,151,152],{},"本站使用了以下技术：",[154,155,156,169],"table",{},[157,158,159],"thead",{},[160,161,162,166],"tr",{},[163,164,165],"th",{},"技术",[163,167,168],{},"用途",[170,171,172,183,193,203,213,223],"tbody",{},[160,173,174,180],{},[175,176,177],"td",{},[140,178,179],{},"Nuxt 3",[175,181,182],{},"Vue 3 全栈框架，提供 SSG、路由、API 等能力",[160,184,185,190],{},[175,186,187],{},[140,188,189],{},"TypeScript",[175,191,192],{},"类型安全，提升开发体验",[160,194,195,200],{},[175,196,197],{},[140,198,199],{},"Tailwind CSS v4",[175,201,202],{},"原子化 CSS，快速构建 UI",[160,204,205,210],{},[175,206,207],{},[140,208,209],{},"@nuxt\u002Fcontent",[175,211,212],{},"Markdown 驱动的内容管理",[160,214,215,220],{},[175,216,217],{},[140,218,219],{},"Shiki",[175,221,222],{},"代码语法高亮",[160,224,225,230],{},[175,226,227],{},[140,228,229],{},"Giscus",[175,231,232],{},"基于 GitHub Discussions 的评论系统",[131,234,235],{"id":235},"功能特性",[136,237,238],{},"目前已实现的功能：",[240,241,242,249,255,261,267,273,279,285,291,297],"ul",{},[243,244,245,248],"li",{},[140,246,247],{},"博客系统"," - Markdown 文章，支持代码高亮、目录导航、搜索",[243,250,251,254],{},[140,252,253],{},"暗色模式"," - 跟随系统或手动切换",[243,256,257,260],{},[140,258,259],{},"文章搜索"," - 支持标题、描述、正文、标签多维度搜索",[243,262,263,266],{},[140,264,265],{},"标签系统"," - 文章标签分类，高频标签快速筛选",[243,268,269,272],{},[140,270,271],{},"友链页面"," - 展示友情链接，支持申请",[243,274,275,278],{},[140,276,277],{},"赞助页面"," - 微信收款码展示",[243,280,281,284],{},[140,282,283],{},"封面制作"," - 在线生成封面图片工具",[243,286,287,290],{},[140,288,289],{},"网易云热评"," - 首页随机展示音乐评论",[243,292,293,296],{},[140,294,295],{},"访客统计"," - 不蒜子计数器，带数字滚动动画",[243,298,299,302],{},[140,300,301],{},"SEO 优化"," - 自动生成站点地图、Open Graph 标签",[131,304,305],{"id":305},"代码示例",[136,307,308],{},"本站的配置非常集中，一个文件搞定全站定制：",[310,311,316],"pre",{"className":312,"code":313,"language":314,"meta":315,"style":315},"language-typescript shiki shiki-themes github-light github-dark","\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","typescript","",[317,318,319,327,347,359,370,380,385],"code",{"__ignoreMap":315},[320,321,323],"span",{"class":322,"line":15},"line",[320,324,326],{"class":325},"sHbNN","\u002F\u002F config\u002Fsite.ts\n",[320,328,329,333,336,340,343],{"class":322,"line":13},[320,330,332],{"class":331},"siTax","export",[320,334,335],{"class":331}," const",[320,337,339],{"class":338},"suQ91"," siteConfig",[320,341,342],{"class":331}," =",[320,344,346],{"class":345},"sIX_F"," {\n",[320,348,349,352,356],{"class":322,"line":9},[320,350,351],{"class":345},"  siteName: ",[320,353,355],{"class":354},"scXbn","'我的博客'",[320,357,358],{"class":345},",\n",[320,360,362,365,368],{"class":322,"line":361},4,[320,363,364],{"class":345},"  url: ",[320,366,367],{"class":354},"'https:\u002F\u002Fexample.com\u002F'",[320,369,358],{"class":345},[320,371,372,375,378],{"class":322,"line":26},[320,373,374],{"class":345},"  avatar: ",[320,376,377],{"class":354},"'https:\u002F\u002Fq2.qlogo.cn\u002Fheadimg_dl?dst_uin=xxx&spec=100'",[320,379,358],{"class":345},[320,381,382],{"class":322,"line":29},[320,383,384],{"class":325},"  \u002F\u002F ... 改这一个文件，全站生效\n",[320,386,388],{"class":322,"line":387},7,[320,389,390],{"class":345},"}\n",[136,392,393],{},"文章的 frontmatter 也很简洁：",[310,395,399],{"className":396,"code":397,"language":398,"meta":315,"style":315},"language-yaml shiki shiki-themes github-light github-dark","---\ntitle: 文章标题\npublished: 2026-05-31T08:00:00\ndescription: 文章简介\ntags: ['Nuxt', 'TypeScript']\n---\n","yaml",[317,400,401,407,419,429,439,459],{"__ignoreMap":315},[320,402,403],{"class":322,"line":15},[320,404,406],{"class":405},"sw2iP","---\n",[320,408,409,413,416],{"class":322,"line":13},[320,410,412],{"class":411},"sbB4o","title",[320,414,415],{"class":345},": ",[320,417,418],{"class":354},"文章标题\n",[320,420,421,424,426],{"class":322,"line":9},[320,422,423],{"class":411},"published",[320,425,415],{"class":345},[320,427,428],{"class":338},"2026-05-31T08:00:00\n",[320,430,431,434,436],{"class":322,"line":361},[320,432,433],{"class":411},"description",[320,435,415],{"class":345},[320,437,438],{"class":354},"文章简介\n",[320,440,441,444,447,450,453,456],{"class":322,"line":26},[320,442,443],{"class":411},"tags",[320,445,446],{"class":345},": [",[320,448,449],{"class":354},"'Nuxt'",[320,451,452],{"class":345},", ",[320,454,455],{"class":354},"'TypeScript'",[320,457,458],{"class":345},"]\n",[320,460,461],{"class":322,"line":29},[320,462,406],{"class":405},[131,464,465],{"id":465},"部署",[136,467,468],{},"本站使用 SSG 静态生成，构建后直接部署到静态托管即可：",[310,470,474],{"className":471,"code":472,"language":473,"meta":315,"style":315},"language-bash shiki shiki-themes github-light github-dark","npx nuxi generate\n","bash",[317,475,476],{"__ignoreMap":315},[320,477,478,481,484],{"class":322,"line":15},[320,479,480],{"class":405},"npx",[320,482,483],{"class":354}," nuxi",[320,485,486],{"class":354}," generate\n",[136,488,489,490,493],{},"构建产物在 ",[317,491,492],{},".output\u002Fpublic"," 目录下，可以直接丢到 Nginx、Vercel、Netlify 等平台。",[131,495,496],{"id":496},"后续计划",[240,498,501,510,516,522],{"className":499},[500],"contains-task-list",[243,502,505,509],{"className":503},[504],"task-list-item",[506,507],"input",{"disabled":7,"type":508},"checkbox"," RSS 订阅",[243,511,513,515],{"className":512},[504],[506,514],{"disabled":7,"type":508}," 文章目录优化",[243,517,519,521],{"className":518},[504],[506,520],{"disabled":7,"type":508}," 更多小工具",[243,523,525,527],{"className":524},[504],[506,526],{"disabled":7,"type":508}," 持续输出内容",[131,529,530],{"id":530},"致谢",[136,532,533,534,541],{},"感谢 ",[535,536,540],"a",{"href":537,"rel":538},"https:\u002F\u002F2x.nz",[539],"nofollow","二叉树树"," 的开源项目，本站的 UI 设计参考了他的项目。",[543,544],"hr",{},[136,546,547],{},[140,548,549],{},"感谢阅读！如果觉得不错，欢迎点个 Star 或者留个评论 (☞ﾟヮﾟ)☞",[551,552,553],"style",{},"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":315,"searchDepth":13,"depth":9,"links":555},[556,557,558,559,560,561,562],{"id":133,"depth":13,"text":134},{"id":149,"depth":13,"text":149},{"id":235,"depth":13,"text":235},{"id":305,"depth":13,"text":305},{"id":465,"depth":13,"text":465},{"id":496,"depth":13,"text":496},{"id":530,"depth":13,"text":530},"博客上线啦！这篇文章介绍本站的技术架构、功能特性和搭建过程中的一些思考。","入门","md","\u002Fposts\u002Fhello-world\u002Fimg\u002Fcover.jpg",{},"\u002Fposts\u002Fhello-world","2026-05-31T08:00:00",{"title":125,"description":563},"Nuxt 博客开发","posts\u002Fhello-world\u002Findex",[574,575],"博客","Nuxt","2026-05-31","f9MCboCBfQIUxT8aKv6aKGDFxysiG8ZG0YXr5X5LTFo",{"id":579,"title":580,"author":126,"body":581,"description":2226,"difficulty":2227,"draft":19,"extension":565,"image":2228,"meta":2229,"navigation":7,"path":2230,"pinned":19,"published":2231,"seo":2232,"series":2233,"seriesOrder":9,"stem":2234,"tags":2235,"updated":2240,"__hash__":2241},"posts\u002Fposts\u002Fgo-embed-react-single-binary\u002Findex.md","Go 单文件打包进阶：React 前端 + 交叉编译，一次踩完所有坑",{"type":128,"value":582,"toc":2205},[583,586,598,610,617,620,658,661,672,675,680,691,740,786,804,808,829,863,867,1217,1220,1259,1263,1625,1628,1632,1839,1850,1853,1864,1879,1892,1903,1907,1914,1931,1945,1951,1955,1973,1978,2023,2037,2048,2052,2067,2076,2098,2107,2111,2121,2124,2153,2162,2165,2168,2195,2202],[131,584,585],{"id":585},"背景",[136,587,588,589,593,594,597],{},"这是「Go 单文件打包」系列第三篇。上一篇（",[535,590,592],{"href":591},"\u002Fposts\u002Fgo-embed-frontend","把前端塞进二进制","）记录了一个 Gin + Vue 3 项目的 embed 方案。这次换了个项目继续踩坑：",[140,595,596],{},"Magic Img","——一个自托管、本地优先的 AI 生图提示词管理工具，技术栈是：",[240,599,600,603],{},[243,601,602],{},"前端：React 19 + TypeScript + Vite + Tailwind v4（UI 库 Magic UI + shadcn\u002Fui）",[243,604,605,606,609],{},"后端：Go + Gin + GORM + SQLite（",[140,607,608],{},"modernc 纯 Go 驱动，无 cgo","）",[136,611,612,613,616],{},"目标一样：",[140,614,615],{},"一个二进制拖到任何机器上就能跑","——前端、API、数据库全部内嵌。",[136,618,619],{},"和上篇不同的地方有三个，正好是这篇要讲的重点：",[621,622,623,637,651],"ol",{},[243,624,625,626,632,633,636],{},"前端用了 ",[140,627,628,629],{},"HashRouter + Vite base ",[317,630,631],{},"\u002Fdist","，服务端",[140,634,635],{},"不再需要 SPA fallback","（上篇坑一的新解法）；",[243,638,639,640,646,647,650],{},"embed 用 ",[140,641,642,645],{},[317,643,644],{},"all:"," 前缀","一劳永逸解决 ",[317,648,649],{},"_"," 开头文件被排除的问题（上篇坑二的升级解法）；",[243,652,653,654,657],{},"需要",[140,655,656],{},"在 Windows 上交叉编译 Linux 产物","，踩了「生成的文件无法在 Linux 执行」的坑。",[131,659,660],{"id":660},"核心思路",[136,662,663,664,667,668,671],{},"和上篇一致：",[317,665,666],{},"\u002F\u002Fgo:embed"," 在编译期把前端 ",[317,669,670],{},"dist\u002F"," 塞进二进制，运行时由 Go 直接提供静态服务。差异在于路由策略和缓存策略，后面细说。",[131,673,674],{"id":674},"逐步实现",[676,677,679],"h3",{"id":678},"_1-前端hash-路由-base-dist","1. 前端：hash 路由 + base \u002Fdist",[136,681,682,683,686,687,690],{},"vite.config.ts 里固定 ",[317,684,685],{},"base: \"\u002Fdist\u002F\"","，路由用 ",[317,688,689],{},"HashRouter","：",[310,692,696],{"className":693,"code":694,"language":695,"meta":315,"style":315},"language-ts shiki shiki-themes github-light github-dark","\u002F\u002F vite.config.ts\nexport default defineConfig({\n  base: \"\u002Fdist\u002F\",   \u002F\u002F 所有资源路径以 \u002Fdist\u002F 开头\n  ...\n})\n","ts",[317,697,698,703,716,730,735],{"__ignoreMap":315},[320,699,700],{"class":322,"line":15},[320,701,702],{"class":325},"\u002F\u002F vite.config.ts\n",[320,704,705,707,710,713],{"class":322,"line":13},[320,706,332],{"class":331},[320,708,709],{"class":331}," default",[320,711,712],{"class":405}," defineConfig",[320,714,715],{"class":345},"({\n",[320,717,718,721,724,727],{"class":322,"line":9},[320,719,720],{"class":345},"  base: ",[320,722,723],{"class":354},"\"\u002Fdist\u002F\"",[320,725,726],{"class":345},",   ",[320,728,729],{"class":325},"\u002F\u002F 所有资源路径以 \u002Fdist\u002F 开头\n",[320,731,732],{"class":322,"line":361},[320,733,734],{"class":331},"  ...\n",[320,736,737],{"class":322,"line":26},[320,738,739],{"class":345},"})\n",[310,741,745],{"className":742,"code":743,"language":744,"meta":315,"style":315},"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",[317,746,747,752,762,777],{"__ignoreMap":315},[320,748,749],{"class":322,"line":15},[320,750,751],{"class":325},"\u002F\u002F App.tsx —— HashRouter：路径信息全在 location.hash 里\n",[320,753,754,757,759],{"class":322,"line":13},[320,755,756],{"class":345},"\u003C",[320,758,689],{"class":338},[320,760,761],{"class":345},">\n",[320,763,764,767,770,773,775],{"class":322,"line":9},[320,765,766],{"class":345},"  \u003C",[320,768,769],{"class":338},"Routes",[320,771,772],{"class":345},">...\u003C\u002F",[320,774,769],{"class":338},[320,776,761],{"class":345},[320,778,779,782,784],{"class":322,"line":361},[320,780,781],{"class":345},"\u003C\u002F",[320,783,689],{"class":338},[320,785,761],{"class":345},[136,787,788,791,792,795,796,799,800,803],{},[140,789,790],{},"为什么这样选","：history 路由需要服务端把未知路径全部 fallback 回 ",[317,793,794],{},"index.html","（上篇坑一）。hash 路由下浏览器请求的永远是 ",[317,797,798],{},"\u002Fdist\u002F"," 这一个入口，",[140,801,802],{},"静态服务直接返回 index.html 就完事，一个 fallback 都不用写","。内嵌场景里这是最省心的组合。",[676,805,807],{"id":806},"_2-构建产物复制进后端目录","2. 构建产物复制进后端目录",[136,809,810,813,814,816,817,820,821,824,825,828],{},[317,811,812],{},"npm run build"," 除了编译，还会把 ",[317,815,670],{}," 复制到 ",[317,818,819],{},"backend\u002Fweb\u002Fdist","（一个小脚本 ",[317,822,823],{},"scripts\u002Fcopy-dist.mjs"," 干这个活），供 ",[317,826,827],{},"go:embed"," 使用：",[310,830,834],{"className":831,"code":832,"language":833,"meta":315,"style":315},"language-json shiki shiki-themes github-light github-dark","\u002F\u002F frontend\u002Fpackage.json\n\"scripts\": {\n  \"build\": \"tsc -b && vite build && node scripts\u002Fcopy-dist.mjs\"\n}\n","json",[317,835,836,841,849,859],{"__ignoreMap":315},[320,837,838],{"class":322,"line":15},[320,839,840],{"class":325},"\u002F\u002F frontend\u002Fpackage.json\n",[320,842,843,846],{"class":322,"line":13},[320,844,845],{"class":354},"\"scripts\"",[320,847,848],{"class":345},": {\n",[320,850,851,854,856],{"class":322,"line":9},[320,852,853],{"class":338},"  \"build\"",[320,855,415],{"class":345},[320,857,858],{"class":354},"\"tsc -b && vite build && node scripts\u002Fcopy-dist.mjs\"\n",[320,860,861],{"class":322,"line":361},[320,862,390],{"class":345},[676,864,866],{"id":865},"_3-后端embed-httpfilesystem-包装","3. 后端：embed + http.FileSystem 包装",[310,868,872],{"className":869,"code":870,"language":871,"meta":315,"style":315},"language-go shiki shiki-themes github-light github-dark","\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","go",[317,873,874,879,887,892,900,911,920,929,935,940,946,963,968,974,1012,1017,1031,1040,1055,1060,1065,1115,1132,1144,1155,1187,1193,1212],{"__ignoreMap":315},[320,875,876],{"class":322,"line":15},[320,877,878],{"class":325},"\u002F\u002F backend\u002Fweb\u002Fembed.go\n",[320,880,881,884],{"class":322,"line":13},[320,882,883],{"class":331},"package",[320,885,886],{"class":405}," web\n",[320,888,889],{"class":322,"line":9},[320,890,891],{"emptyLinePlaceholder":7},"\n",[320,893,894,897],{"class":322,"line":361},[320,895,896],{"class":331},"import",[320,898,899],{"class":345}," (\n",[320,901,902,905,908],{"class":322,"line":26},[320,903,904],{"class":354},"    \"",[320,906,907],{"class":405},"embed",[320,909,910],{"class":354},"\"\n",[320,912,913,915,918],{"class":322,"line":29},[320,914,904],{"class":354},[320,916,917],{"class":405},"net\u002Fhttp",[320,919,910],{"class":354},[320,921,922,924,927],{"class":322,"line":387},[320,923,904],{"class":354},[320,925,926],{"class":405},"strings",[320,928,910],{"class":354},[320,930,932],{"class":322,"line":931},8,[320,933,934],{"class":345},")\n",[320,936,938],{"class":322,"line":937},9,[320,939,891],{"emptyLinePlaceholder":7},[320,941,943],{"class":322,"line":942},10,[320,944,945],{"class":325},"\u002F\u002Fgo:embed all:dist\n",[320,947,949,952,955,957,960],{"class":322,"line":948},11,[320,950,951],{"class":331},"var",[320,953,954],{"class":345}," distFS ",[320,956,907],{"class":405},[320,958,959],{"class":345},".",[320,961,962],{"class":405},"FS\n",[320,964,966],{"class":322,"line":965},12,[320,967,891],{"emptyLinePlaceholder":7},[320,969,971],{"class":322,"line":970},13,[320,972,973],{"class":325},"\u002F\u002F Dist 内嵌前端静态文件系统\n",[320,975,976,978,981,984,986,989,991,994,997,1000,1003,1006,1009],{"class":322,"line":68},[320,977,951],{"class":331},[320,979,980],{"class":345}," Dist ",[320,982,983],{"class":405},"http",[320,985,959],{"class":345},[320,987,988],{"class":405},"FileSystem",[320,990,342],{"class":331},[320,992,993],{"class":331}," &",[320,995,996],{"class":405},"fileSystem",[320,998,999],{"class":345},"{prefix: ",[320,1001,1002],{"class":354},"\"dist\"",[320,1004,1005],{"class":345},", fs: ",[320,1007,1008],{"class":331},"&",[320,1010,1011],{"class":345},"distFS}\n",[320,1013,1015],{"class":322,"line":1014},15,[320,1016,891],{"emptyLinePlaceholder":7},[320,1018,1020,1023,1026,1029],{"class":322,"line":1019},16,[320,1021,1022],{"class":331},"type",[320,1024,1025],{"class":405}," fileSystem",[320,1027,1028],{"class":331}," struct",[320,1030,346],{"class":345},[320,1032,1034,1037],{"class":322,"line":1033},17,[320,1035,1036],{"class":345},"    prefix ",[320,1038,1039],{"class":331},"string\n",[320,1041,1043,1046,1049,1051,1053],{"class":322,"line":1042},18,[320,1044,1045],{"class":345},"    fs     ",[320,1047,1048],{"class":331},"*",[320,1050,907],{"class":405},[320,1052,959],{"class":345},[320,1054,962],{"class":405},[320,1056,1058],{"class":322,"line":1057},19,[320,1059,390],{"class":345},[320,1061,1063],{"class":322,"line":1062},20,[320,1064,891],{"emptyLinePlaceholder":7},[320,1066,1068,1071,1074,1078,1080,1082,1085,1088,1091,1094,1097,1100,1102,1104,1107,1109,1112],{"class":322,"line":1067},21,[320,1069,1070],{"class":331},"func",[320,1072,1073],{"class":345}," (",[320,1075,1077],{"class":1076},"sPK6S","f ",[320,1079,1048],{"class":331},[320,1081,996],{"class":405},[320,1083,1084],{"class":345},") ",[320,1086,1087],{"class":405},"Open",[320,1089,1090],{"class":345},"(",[320,1092,1093],{"class":1076},"name",[320,1095,1096],{"class":331}," string",[320,1098,1099],{"class":345},") (",[320,1101,983],{"class":405},[320,1103,959],{"class":345},[320,1105,1106],{"class":405},"File",[320,1108,452],{"class":345},[320,1110,1111],{"class":331},"error",[320,1113,1114],{"class":345},") {\n",[320,1116,1118,1121,1124,1127,1130],{"class":322,"line":1117},22,[320,1119,1120],{"class":331},"    if",[320,1122,1123],{"class":345}," name ",[320,1125,1126],{"class":331},"==",[320,1128,1129],{"class":354}," \"\u002F\"",[320,1131,346],{"class":345},[320,1133,1135,1138,1141],{"class":322,"line":1134},23,[320,1136,1137],{"class":345},"        name ",[320,1139,1140],{"class":331},"=",[320,1142,1143],{"class":345}," f.prefix\n",[320,1145,1147,1150,1153],{"class":322,"line":1146},24,[320,1148,1149],{"class":345},"    } ",[320,1151,1152],{"class":331},"else",[320,1154,346],{"class":345},[320,1156,1158,1160,1162,1165,1168,1170,1173,1176,1179,1182,1185],{"class":322,"line":1157},25,[320,1159,1137],{"class":345},[320,1161,1140],{"class":331},[320,1163,1164],{"class":345}," f.prefix ",[320,1166,1167],{"class":331},"+",[320,1169,1129],{"class":354},[320,1171,1172],{"class":331}," +",[320,1174,1175],{"class":345}," strings.",[320,1177,1178],{"class":405},"TrimPrefix",[320,1180,1181],{"class":345},"(name, ",[320,1183,1184],{"class":354},"\"\u002F\"",[320,1186,934],{"class":345},[320,1188,1190],{"class":322,"line":1189},26,[320,1191,1192],{"class":345},"    }\n",[320,1194,1195,1198,1201,1204,1207,1209],{"class":322,"line":60},[320,1196,1197],{"class":331},"    return",[320,1199,1200],{"class":345}," http.",[320,1202,1203],{"class":405},"FS",[320,1205,1206],{"class":345},"(f.fs).",[320,1208,1087],{"class":405},[320,1210,1211],{"class":345},"(name)\n",[320,1213,1215],{"class":322,"line":1214},28,[320,1216,390],{"class":345},[136,1218,1219],{},"两个细节：",[240,1221,1222,1246],{},[243,1223,1224,1232,1233,1235,1236,1238,1239,1241,1242,1245],{},[140,1225,1226,1229,1230,645],{},[317,1227,1228],{},"all:dist"," 里的 ",[317,1231,644],{},"：embed 目录时默认会排除 ",[317,1234,649],{}," 和 ",[317,1237,959],{}," 开头的文件（上篇坑二就是被这个坑了，当时靠改 Vite 输出名绕开）。",[317,1240,644],{}," 前缀表示「全部包含」，前端构建产物里那些 ",[317,1243,1244],{},"_xxx.js"," 助手文件直接安全落地，不用再改前端配置；",[243,1247,1248,1251,1252,1254,1255,1258],{},[317,1249,1250],{},"http.FileSystem"," 包装：加了一层 ",[317,1253,670],{}," 前缀映射，",[317,1256,1257],{},"http.FileServer"," 和 Gin 都能直接用。",[676,1260,1262],{"id":1261},"_4-路由静态服务-缓存策略","4. 路由：静态服务 + 缓存策略",[310,1264,1266],{"className":869,"code":1265,"language":871,"meta":315,"style":315},"\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",[317,1267,1268,1273,1317,1334,1339,1354,1403,1432,1452,1476,1481,1501,1524,1529,1546,1551,1572,1577,1617,1621],{"__ignoreMap":315},[320,1269,1270],{"class":322,"line":15},[320,1271,1272],{"class":325},"\u002F\u002F backend\u002Finternal\u002Fhandler\u002Frouter.go\n",[320,1274,1275,1277,1280,1282,1285,1288,1291,1293,1296,1298,1301,1303,1305,1307,1310,1312,1315],{"class":322,"line":13},[320,1276,1070],{"class":331},[320,1278,1279],{"class":405}," NewRouter",[320,1281,1090],{"class":345},[320,1283,1284],{"class":1076},"db",[320,1286,1287],{"class":331}," *",[320,1289,1290],{"class":405},"gorm",[320,1292,959],{"class":345},[320,1294,1295],{"class":405},"DB",[320,1297,452],{"class":345},[320,1299,1300],{"class":1076},"uploadDir",[320,1302,1096],{"class":331},[320,1304,1084],{"class":345},[320,1306,1048],{"class":331},[320,1308,1309],{"class":405},"gin",[320,1311,959],{"class":345},[320,1313,1314],{"class":405},"Engine",[320,1316,346],{"class":345},[320,1318,1319,1322,1325,1328,1331],{"class":322,"line":9},[320,1320,1321],{"class":345},"    r ",[320,1323,1324],{"class":331},":=",[320,1326,1327],{"class":345}," gin.",[320,1329,1330],{"class":405},"Default",[320,1332,1333],{"class":345},"()\n",[320,1335,1336],{"class":322,"line":361},[320,1337,1338],{"class":331},"    ...\n",[320,1340,1341,1344,1346,1348,1351],{"class":322,"line":26},[320,1342,1343],{"class":345},"    dist ",[320,1345,1324],{"class":331},[320,1347,1200],{"class":345},[320,1349,1350],{"class":405},"FileServer",[320,1352,1353],{"class":345},"(web.Dist)\n",[320,1355,1356,1359,1362,1364,1367,1369,1371,1373,1376,1378,1380,1382,1385,1388,1391,1393,1396,1398,1400],{"class":322,"line":29},[320,1357,1358],{"class":345},"    r.",[320,1360,1361],{"class":405},"GET",[320,1363,1090],{"class":345},[320,1365,1366],{"class":354},"\"\u002Fdist\"",[320,1368,452],{"class":345},[320,1370,1070],{"class":331},[320,1372,1090],{"class":345},[320,1374,1375],{"class":1076},"c",[320,1377,1287],{"class":331},[320,1379,1309],{"class":405},[320,1381,959],{"class":345},[320,1383,1384],{"class":405},"Context",[320,1386,1387],{"class":345},") { c.",[320,1389,1390],{"class":405},"Redirect",[320,1392,1090],{"class":345},[320,1394,1395],{"class":338},"302",[320,1397,452],{"class":345},[320,1399,723],{"class":354},[320,1401,1402],{"class":345},") })\n",[320,1404,1405,1407,1409,1411,1414,1416,1418,1420,1422,1424,1426,1428,1430],{"class":322,"line":387},[320,1406,1358],{"class":345},[320,1408,1361],{"class":405},[320,1410,1090],{"class":345},[320,1412,1413],{"class":354},"\"\u002Fdist\u002F*filepath\"",[320,1415,452],{"class":345},[320,1417,1070],{"class":331},[320,1419,1090],{"class":345},[320,1421,1375],{"class":1076},[320,1423,1287],{"class":331},[320,1425,1309],{"class":405},[320,1427,959],{"class":345},[320,1429,1384],{"class":405},[320,1431,1114],{"class":345},[320,1433,1434,1437,1439,1442,1445,1447,1450],{"class":322,"line":931},[320,1435,1436],{"class":345},"        p ",[320,1438,1324],{"class":331},[320,1440,1441],{"class":345}," c.",[320,1443,1444],{"class":405},"Param",[320,1446,1090],{"class":345},[320,1448,1449],{"class":354},"\"filepath\"",[320,1451,934],{"class":345},[320,1453,1454,1457,1460,1462,1464,1467,1469,1471,1474],{"class":322,"line":937},[320,1455,1456],{"class":331},"        if",[320,1458,1459],{"class":345}," p ",[320,1461,1126],{"class":331},[320,1463,1129],{"class":354},[320,1465,1466],{"class":331}," ||",[320,1468,1459],{"class":345},[320,1470,1126],{"class":331},[320,1472,1473],{"class":354}," \"\u002Findex.html\"",[320,1475,346],{"class":345},[320,1477,1478],{"class":322,"line":942},[320,1479,1480],{"class":325},"            \u002F\u002F index.html 禁缓存：升级后浏览器立刻拿到新入口\n",[320,1482,1483,1486,1489,1491,1494,1496,1499],{"class":322,"line":948},[320,1484,1485],{"class":345},"            c.",[320,1487,1488],{"class":405},"Header",[320,1490,1090],{"class":345},[320,1492,1493],{"class":354},"\"Cache-Control\"",[320,1495,452],{"class":345},[320,1497,1498],{"class":354},"\"no-cache, no-store, must-revalidate\"",[320,1500,934],{"class":345},[320,1502,1503,1506,1508,1511,1513,1516,1519,1522],{"class":322,"line":965},[320,1504,1505],{"class":345},"        } ",[320,1507,1152],{"class":331},[320,1509,1510],{"class":331}," if",[320,1512,1175],{"class":345},[320,1514,1515],{"class":405},"HasPrefix",[320,1517,1518],{"class":345},"(p, ",[320,1520,1521],{"class":354},"\"\u002Fassets\u002F\"",[320,1523,1114],{"class":345},[320,1525,1526],{"class":322,"line":970},[320,1527,1528],{"class":325},"            \u002F\u002F 带内容 hash 的资源可放心长缓存\n",[320,1530,1531,1533,1535,1537,1539,1541,1544],{"class":322,"line":68},[320,1532,1485],{"class":345},[320,1534,1488],{"class":405},[320,1536,1090],{"class":345},[320,1538,1493],{"class":354},[320,1540,452],{"class":345},[320,1542,1543],{"class":354},"\"public, max-age=31536000, immutable\"",[320,1545,934],{"class":345},[320,1547,1548],{"class":322,"line":1014},[320,1549,1550],{"class":345},"        }\n",[320,1552,1553,1556,1559,1561,1563,1566,1569],{"class":322,"line":1019},[320,1554,1555],{"class":345},"        http.",[320,1557,1558],{"class":405},"StripPrefix",[320,1560,1090],{"class":345},[320,1562,1366],{"class":354},[320,1564,1565],{"class":345},", dist).",[320,1567,1568],{"class":405},"ServeHTTP",[320,1570,1571],{"class":345},"(c.Writer, c.Request)\n",[320,1573,1574],{"class":322,"line":1033},[320,1575,1576],{"class":345},"    })\n",[320,1578,1579,1581,1583,1585,1587,1589,1591,1593,1595,1597,1599,1601,1603,1605,1607,1609,1611,1613,1615],{"class":322,"line":1042},[320,1580,1358],{"class":345},[320,1582,1361],{"class":405},[320,1584,1090],{"class":345},[320,1586,1184],{"class":354},[320,1588,452],{"class":345},[320,1590,1070],{"class":331},[320,1592,1090],{"class":345},[320,1594,1375],{"class":1076},[320,1596,1287],{"class":331},[320,1598,1309],{"class":405},[320,1600,959],{"class":345},[320,1602,1384],{"class":405},[320,1604,1387],{"class":345},[320,1606,1390],{"class":405},[320,1608,1090],{"class":345},[320,1610,1395],{"class":338},[320,1612,452],{"class":345},[320,1614,723],{"class":354},[320,1616,1402],{"class":345},[320,1618,1619],{"class":322,"line":1057},[320,1620,1338],{"class":331},[320,1622,1623],{"class":322,"line":1062},[320,1624,390],{"class":345},[136,1626,1627],{},"缓存策略是内嵌发布的必修课（坑二细说）：入口禁缓存、hash 资源长缓存。",[676,1629,1631],{"id":1630},"_5-打包脚本windows-版-linux-交叉编译版","5. 打包脚本：Windows 版 + Linux 交叉编译版",[310,1633,1635],{"className":471,"code":1634,"language":473,"meta":315,"style":315},"#!\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",[317,1636,1637,1642,1647,1658,1678,1682,1703,1707,1715,1738,1742,1749,1809,1824,1828],{"__ignoreMap":315},[320,1638,1639],{"class":322,"line":15},[320,1640,1641],{"class":325},"#!\u002Fusr\u002Fbin\u002Fenv bash\n",[320,1643,1644],{"class":322,"line":13},[320,1645,1646],{"class":325},"# build-linux.sh —— 在 Windows\u002FmacOS\u002FLinux 任意平台交叉编译 linux\u002Famd64 产物\n",[320,1648,1649,1652,1655],{"class":322,"line":9},[320,1650,1651],{"class":338},"set",[320,1653,1654],{"class":338}," -euo",[320,1656,1657],{"class":354}," pipefail\n",[320,1659,1660,1663,1666,1669,1672,1675],{"class":322,"line":361},[320,1661,1662],{"class":338},"cd",[320,1664,1665],{"class":354}," \"$(",[320,1667,1668],{"class":405},"dirname",[320,1670,1671],{"class":354}," \"",[320,1673,1674],{"class":338},"$0",[320,1676,1677],{"class":354},"\")\"\n",[320,1679,1680],{"class":322,"line":26},[320,1681,891],{"emptyLinePlaceholder":7},[320,1683,1684,1687,1689,1692,1694,1697,1700],{"class":322,"line":29},[320,1685,1686],{"class":345},"BINARY",[320,1688,1140],{"class":331},[320,1690,1691],{"class":354},"\"${",[320,1693,1686],{"class":345},[320,1695,1696],{"class":331},":-",[320,1698,1699],{"class":345},"magic-img",[320,1701,1702],{"class":354},"}\"\n",[320,1704,1705],{"class":322,"line":387},[320,1706,891],{"emptyLinePlaceholder":7},[320,1708,1709,1712],{"class":322,"line":931},[320,1710,1711],{"class":338},"echo",[320,1713,1714],{"class":354}," \"[1\u002F3] 构建前端并复制产物到 backend\u002Fweb\u002Fdist ...\"\n",[320,1716,1717,1719,1721,1724,1727,1730,1733,1736],{"class":322,"line":937},[320,1718,1090],{"class":345},[320,1720,1662],{"class":338},[320,1722,1723],{"class":354}," frontend",[320,1725,1726],{"class":345}," && ",[320,1728,1729],{"class":405},"npm",[320,1731,1732],{"class":354}," run",[320,1734,1735],{"class":354}," build",[320,1737,934],{"class":345},[320,1739,1740],{"class":322,"line":942},[320,1741,891],{"emptyLinePlaceholder":7},[320,1743,1744,1746],{"class":322,"line":948},[320,1745,1711],{"class":338},[320,1747,1748],{"class":354}," \"[2\u002F3] 交叉编译后端并内嵌前端 (CGO_ENABLED=0 GOOS=linux GOARCH=amd64) ...\"\n",[320,1750,1751,1753,1755,1758,1761,1763,1766,1769,1771,1774,1777,1779,1782,1785,1787,1790,1792,1795,1798,1801,1804,1807],{"class":322,"line":965},[320,1752,1090],{"class":345},[320,1754,1662],{"class":338},[320,1756,1757],{"class":354}," backend",[320,1759,1760],{"class":345}," && CGO_ENABLED",[320,1762,1140],{"class":331},[320,1764,1765],{"class":354},"0",[320,1767,1768],{"class":345}," GOOS",[320,1770,1140],{"class":331},[320,1772,1773],{"class":354},"linux",[320,1775,1776],{"class":345}," GOARCH",[320,1778,1140],{"class":331},[320,1780,1781],{"class":354},"amd64",[320,1783,1784],{"class":405}," go",[320,1786,1735],{"class":354},[320,1788,1789],{"class":338}," -o",[320,1791,1671],{"class":354},[320,1793,1794],{"class":345},"$BINARY",[320,1796,1797],{"class":354},"\"",[320,1799,1800],{"class":338}," -ldflags=",[320,1802,1803],{"class":354},"\"-s -w\"",[320,1805,1806],{"class":354}," .\u002Fcmd\u002Fserver",[320,1808,934],{"class":345},[320,1810,1811,1814,1817,1820,1822],{"class":322,"line":970},[320,1812,1813],{"class":405},"chmod",[320,1815,1816],{"class":354}," +x",[320,1818,1819],{"class":354}," \"backend\u002F",[320,1821,1794],{"class":345},[320,1823,910],{"class":354},[320,1825,1826],{"class":322,"line":68},[320,1827,891],{"emptyLinePlaceholder":7},[320,1829,1830,1832,1835,1837],{"class":322,"line":1014},[320,1831,1711],{"class":338},[320,1833,1834],{"class":354}," \"[3\u002F3] 打包完成: backend\u002F",[320,1836,1794],{"class":345},[320,1838,910],{"class":354},[136,1840,1841,1842,1845,1846,1849],{},"Windows 版（",[317,1843,1844],{},"build-win.bat","）同理，只是产物叫 ",[317,1847,1848],{},"magic-img.exe","。",[131,1851,1852],{"id":1852},"踩坑记录",[676,1854,1856,1857,1860,1861,1863],{"id":1855},"坑一goembed-dist-静默漏掉-_-开头文件升级解法","坑一：",[317,1858,1859],{},"\u002F\u002Fgo:embed dist"," 静默漏掉 ",[317,1862,649],{}," 开头文件（升级解法）",[136,1865,1866,1867,1875,1876,1878],{},"上篇说过：embed 目录时",[140,1868,1869,1870,1235,1872,1874],{},"硬编码排除 ",[317,1871,649],{},[317,1873,959],{}," 开头文件","。上篇的解法是改 Vite 输出文件名去掉 ",[317,1877,649],{}," 前缀；这次直接用：",[310,1880,1882],{"className":869,"code":1881,"language":871,"meta":315,"style":315},"\u002F\u002Fgo:embed all:dist   \u002F\u002F all: 前缀 = 包含全部文件，不再静默跳过\n",[317,1883,1884],{"__ignoreMap":315},[320,1885,1886,1889],{"class":322,"line":15},[320,1887,1888],{"class":325},"\u002F\u002Fgo:embed all:dist",[320,1890,1891],{"class":325},"   \u002F\u002F all: 前缀 = 包含全部文件，不再静默跳过\n",[136,1893,1894],{},[140,1895,1896,1897,1899,1900,1902],{},"结论：能用 ",[317,1898,644],{}," 就用 ",[317,1901,644],{},"，从根上消灭这类\"文件莫名 404\"的问题。",[676,1904,1906],{"id":1905},"坑二开发版是新的打包版却是旧的","坑二：开发版是新的、打包版却是旧的",[136,1908,1909,1910,1913],{},"现象：Vite 开发服务一切正常，",[317,1911,1912],{},":8080\u002Fdist\u002F"," 打包版却是旧界面，强刷也不一定好。",[136,1915,1916,1917,1920,1921,1924,1925,1930],{},"排查过程值得记录：先是怀疑 ",[317,1918,1919],{},"go build"," 缓存了旧 embed（上篇坑三的思路），重编重启无效；然后直接抓取 8080 返回的 HTML 和 JS，发现",[140,1922,1923],{},"服务端内容已经是新的","——问题出在",[140,1926,1927,1928],{},"浏览器缓存了 ",[317,1929,794],{},"：它引用旧 hash 的资源，一直不更新。",[136,1932,1933,1936,1937,1940,1941,1944],{},[140,1934,1935],{},"解决","：入口文件响应 ",[317,1938,1939],{},"Cache-Control: no-store","（见上面路由代码），带 hash 的资源给 ",[317,1942,1943],{},"immutable"," 长缓存。升级后浏览器每次拉新入口，资源文件名一变就自动换新，两全其美。",[1946,1947,1948],"blockquote",{},[136,1949,1950],{},"教训：排查\"新旧不一致\"先分清是哪一层——服务端产物、浏览器入口缓存、还是资源缓存。抓包看响应内容再动手。",[676,1952,1954],{"id":1953},"坑三windows-上跑的-build-linuxsh产出的文件-linux-却执行不了","坑三：Windows 上跑的 build-linux.sh，产出的文件 Linux 却执行不了",[136,1956,1957,1958,1961,1962,1968,1969,1972],{},"最初脚本只写了 ",[317,1959,1960],{},"CGO_ENABLED=0 go build","，",[140,1963,1964,1965],{},"没指定 ",[317,1966,1967],{},"GOOS\u002FGOARCH","。在 Windows 上跑，产出的其实是个 Windows PE 文件（只是没叫 ",[317,1970,1971],{},".exe","），传到 Linux 自然「无法执行」。",[136,1974,1975,1977],{},[140,1976,1935],{},"：脚本里显式交叉编译：",[310,1979,1981],{"className":471,"code":1980,"language":473,"meta":315,"style":315},"CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o \"$BINARY\" -ldflags=\"-s -w\" .\u002Fcmd\u002Fserver\n",[317,1982,1983],{"__ignoreMap":315},[320,1984,1985,1988,1990,1992,1994,1996,1998,2000,2002,2004,2006,2008,2010,2012,2014,2016,2018,2020],{"class":322,"line":15},[320,1986,1987],{"class":345},"CGO_ENABLED",[320,1989,1140],{"class":331},[320,1991,1765],{"class":354},[320,1993,1768],{"class":345},[320,1995,1140],{"class":331},[320,1997,1773],{"class":354},[320,1999,1776],{"class":345},[320,2001,1140],{"class":331},[320,2003,1781],{"class":354},[320,2005,1784],{"class":405},[320,2007,1735],{"class":354},[320,2009,1789],{"class":338},[320,2011,1671],{"class":354},[320,2013,1794],{"class":345},[320,2015,1797],{"class":354},[320,2017,1800],{"class":338},[320,2019,1803],{"class":354},[320,2021,2022],{"class":354}," .\u002Fcmd\u002Fserver\n",[136,2024,2025,2026,2029,2030,2033,2034],{},"能这么干的前提是",[140,2027,2028],{},"全依赖树无 cgo","——SQLite 用的是 modernc 纯 Go 驱动，bcrypt 用 ",[317,2031,2032],{},"x\u002Fcrypto","。这也是选型时就要留意的点：",[140,2035,2036],{},"想跨平台交叉编译，先把 cgo 依赖清干净。",[136,2038,2039,2040,2043,2044,2047],{},"另外两个小提醒：Windows 上编出的文件没有 Unix 执行位，上传服务器后记得 ",[317,2041,2042],{},"chmod +x","；",[317,2045,2046],{},"-ldflags=\"-s -w\""," 剥离符号，体积从 30MB+ 降到 26MB。",[676,2049,2051],{"id":2050},"坑四dist-被-gitignore-后embed-直接编译失败","坑四：dist 被 gitignore 后，embed 直接编译失败",[136,2053,2054,2056,2057,2059,2060,2063,2064,2066],{},[317,2055,819],{}," 是构建产物、入了 .gitignore，但 ",[317,2058,666],{}," 在",[140,2061,2062],{},"编译期","就要读它——刚 clone 下来直接 ",[317,2065,1919],{}," 会报 embed 找不到文件。",[136,2068,2069,2071,2072,2075],{},[140,2070,1935],{},"：gitignore 里保留 ",[317,2073,2074],{},"dist\u002F.gitkeep","（一个空文件），让目录存在于版本库中：",[310,2077,2081],{"className":2078,"code":2079,"language":2080,"meta":315,"style":315},"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",[317,2082,2083,2088,2093],{"__ignoreMap":315},[320,2084,2085],{"class":322,"line":15},[320,2086,2087],{},"backend\u002Fweb\u002Fdist\u002F*\n",[320,2089,2090],{"class":322,"line":13},[320,2091,2092],{},"!backend\u002Fweb\u002Fdist\u002F\n",[320,2094,2095],{"class":322,"line":9},[320,2096,2097],{},"!backend\u002Fweb\u002Fdist\u002F.gitkeep\n",[136,2099,2100,2101,2103,2104,2106],{},"这样 clone 后可以先 ",[317,2102,1919],{}," 通过，再 ",[317,2105,812],{}," 灌入真实前端。",[676,2108,2110],{"id":2109},"坑五500kb-chunk-警告别慌","坑五：>500KB chunk 警告，别慌",[136,2112,2113,2114,2117,2118,1849],{},"Vite 构建会警告单 chunk 超过 500KB。这个项目里 Base UI 组件库全量引入，单 chunk 约 640KB——",[140,2115,2116],{},"属正常，不要为了消警告去拆配置","。真要优化就上路由级 ",[317,2119,2120],{},"React.lazy",[131,2122,2123],{"id":2123},"最终效果",[310,2125,2127],{"className":471,"code":2126,"language":473,"meta":315,"style":315},"$ build-win.bat        # backendmagic-img.exe\n$ bash build-linux.sh  # backendmagic-img（linux\u002Famd64 ELF）\n",[317,2128,2129,2140],{"__ignoreMap":315},[320,2130,2131,2134,2137],{"class":322,"line":15},[320,2132,2133],{"class":405},"$",[320,2135,2136],{"class":354}," build-win.bat",[320,2138,2139],{"class":325},"        # backendmagic-img.exe\n",[320,2141,2142,2144,2147,2150],{"class":322,"line":13},[320,2143,2133],{"class":405},[320,2145,2146],{"class":354}," bash",[320,2148,2149],{"class":354}," build-linux.sh",[320,2151,2152],{"class":325},"  # backendmagic-img（linux\u002Famd64 ELF）\n",[136,2154,2155,2156,2159,2160,1849],{},"一个约 26MB 的二进制，内嵌 React 前端、Gin API、SQLite 数据初始化，拷到 Windows\u002FLinux 机器直接跑，浏览器访问 ",[317,2157,2158],{},"http:\u002F\u002Flocalhost:8080"," 自动重定向到 ",[317,2161,798],{},[131,2163,2164],{"id":2164},"总结",[136,2166,2167],{},"这轮相比上篇的三点升级：",[621,2169,2170,2176,2186],{},[243,2171,2172,2175],{},[140,2173,2174],{},"hash 路由 + base \u002Fdist","：省掉 SPA fallback，静态服务即开即用；",[243,2177,2178,690,2183,2185],{},[140,2179,2180,2182],{},[317,2181,644],{}," embed 前缀",[317,2184,649],{}," 开头文件问题一劳永逸；",[243,2187,2188,2194],{},[140,2189,2190,2191,2193],{},"显式 ",[317,2192,1967],{}," 交叉编译","：让「一个脚本、任意平台、产出 Linux 二进制」成为现实。",[136,2196,2197,2198,2201],{},"加上",[140,2199,2200],{},"入口 no-store \u002F 资源 immutable"," 的缓存策略，单文件分发的体验才算闭环——不然每次升级都要和浏览器缓存打架。",[551,2203,2204],{},"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":315,"searchDepth":13,"depth":9,"links":2206},[2207,2208,2209,2216,2224,2225],{"id":585,"depth":13,"text":585},{"id":660,"depth":13,"text":660},{"id":674,"depth":13,"text":674,"children":2210},[2211,2212,2213,2214,2215],{"id":678,"depth":9,"text":679},{"id":806,"depth":9,"text":807},{"id":865,"depth":9,"text":866},{"id":1261,"depth":9,"text":1262},{"id":1630,"depth":9,"text":1631},{"id":1852,"depth":13,"text":1852,"children":2217},[2218,2220,2221,2222,2223],{"id":1855,"depth":9,"text":2219},"坑一：\u002F\u002Fgo:embed dist 静默漏掉 _ 开头文件（升级解法）",{"id":1905,"depth":9,"text":1906},{"id":1953,"depth":9,"text":1954},{"id":2050,"depth":9,"text":2051},{"id":2109,"depth":9,"text":2110},{"id":2123,"depth":13,"text":2123},{"id":2164,"depth":13,"text":2164},"以 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":580,"description":2226},"Go 工程实践","posts\u002Fgo-embed-react-single-binary\u002Findex",[2236,2237,907,2238,2239],"Go","React","交叉编译","打包","2026-08-16","WV-T-MOO5Cu-aycw-Ony8s9hjA3_k0S4XU-T0QsCow0",{"id":2243,"title":2244,"author":126,"body":2245,"description":3516,"difficulty":564,"draft":19,"extension":565,"image":3517,"meta":3518,"navigation":7,"path":3519,"pinned":19,"published":2231,"seo":3520,"series":3521,"seriesOrder":15,"stem":3522,"tags":3523,"updated":2240,"__hash__":3527},"posts\u002Fposts\u002Freact-photo-album-masonry\u002Findex.md","用 react-photo-album 给生图图库做瀑布流排版",{"type":128,"value":2246,"toc":3499},[2247,2250,2253,2271,2278,2345,2356,2359,2382,2385,2389,2404,2408,2426,2611,2615,3018,3021,3055,3058,3062,3072,3078,3103,3108,3119,3125,3136,3139,3179,3192,3316,3328,3332,3339,3357,3362,3457,3463,3467,3478,3481,3484,3490,3496],[131,2248,2249],{"id":2249},"技术背景",[136,2251,2252],{},"Magic Img 是我做的一个 AI 生图提示词管理工具（自托管、本地优先），其中「生图图库」页面要把所有生成图片以图片墙的形式展示出来。图片墙有两个天然矛盾：",[621,2254,2255,2261],{},[243,2256,2257,2260],{},[140,2258,2259],{},"等宽网格","（CSS Grid）：整齐，但每行高度由最高的那张决定——不同比例的图片混排时，矮图下面会留下大洞，看起来\"很乱\"；",[243,2262,2263,2266,2267,2270],{},[140,2264,2265],{},"原生比例展示","：图库必须不裁剪（这是产品约束），所以不能用 ",[317,2268,2269],{},"object-cover"," 强行统一比例。",[136,2272,2273,2274,2277],{},"业内的标准解法是 ",[140,2275,2276],{},"Masonry 瀑布流","：按列紧密堆放，每张图保持原始比例，列间高度错落但整体紧凑。实现瀑布流的常见方案有三类：",[154,2279,2280,2296],{},[157,2281,2282],{},[160,2283,2284,2287,2290,2293],{},[163,2285,2286],{},"方案",[163,2288,2289],{},"依赖",[163,2291,2292],{},"顺序",[163,2294,2295],{},"说明",[170,2297,2298,2316,2329],{},[160,2299,2300,2307,2310,2313],{},[175,2301,2302,2303,2306],{},"CSS ",[317,2304,2305],{},"columns"," 多列",[175,2308,2309],{},"无",[175,2311,2312],{},"纵向阅读序（先下后右）",[175,2314,2315],{},"最新图片不再出现在左上角，时间语义丢失",[160,2317,2318,2321,2323,2326],{},[175,2319,2320],{},"grid row-span 自研",[175,2322,2309],{},[175,2324,2325],{},"保持",[175,2327,2328],{},"需固定列数 + 固定行高，逻辑要自己写",[160,2330,2331,2336,2339,2342],{},[175,2332,2333],{},[140,2334,2335],{},"react-photo-album",[175,2337,2338],{},"~4KB，无依赖",[175,2340,2341],{},"基本保持（逐列最短列填充）",[175,2343,2344],{},"专为相册墙设计，TS 友好，维护活跃",[136,2346,2347,2348,2351,2352,2355],{},"最终选了 ",[140,2349,2350],{},"react-photo-album v3","：体积小、类型齐全，而且它的 Masonry 模式恰好需要「每张图的 ",[317,2353,2354],{},"width\u002Fheight","」——我们的数据库里本来就存了，零额外成本。",[131,2357,2358],{"id":2358},"需求梳理",[621,2360,2361,2367,2373,2376,2379],{},[243,2362,2363,2364,2043],{},"展示全部生成图（21 张起，会持续增长），",[140,2365,2366],{},"原比例、不裁剪",[243,2368,2369,2370,2043],{},"不同尺寸（2304×1728、4608×3456 等）混排时",[140,2371,2372],{},"紧凑、不留洞",[243,2374,2375],{},"顺序基本保持「新图靠前」；",[243,2377,2378],{},"点击任意一张打开大图预览（YARL 灯箱），悬停显示所属提示词；",[243,2380,2381],{},"平板性能：墙上只用 300px 缩略图，原图只进灯箱按需加载。",[131,2383,2384],{"id":2384},"安装与使用",[676,2386,2388],{"id":2387},"_1-安装","1. 安装",[310,2390,2392],{"className":471,"code":2391,"language":473,"meta":315,"style":315},"npm install react-photo-album\n",[317,2393,2394],{"__ignoreMap":315},[320,2395,2396,2398,2401],{"class":322,"line":15},[320,2397,1729],{"class":405},[320,2399,2400],{"class":354}," install",[320,2402,2403],{"class":354}," react-photo-album\n",[676,2405,2407],{"id":2406},"_2-准备数据关键输入是宽高","2. 准备数据（关键输入是宽高）",[136,2409,2410,2411,2414,2415,2418,2419,2418,2422,2425],{},"库要求的 ",[317,2412,2413],{},"Photo"," 对象只有三个必需字段：",[317,2416,2417],{},"src","、",[317,2420,2421],{},"width",[317,2423,2424],{},"height","。我们的接口本来就返回每张图的原始宽高：",[310,2427,2429],{"className":742,"code":2428,"language":744,"meta":315,"style":315},"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",[317,2430,2431,2449,2454,2461,2465,2481,2492,2496,2500,2538,2549,2569,2574,2579,2601,2606],{"__ignoreMap":315},[320,2432,2433,2435,2438,2440,2443,2446],{"class":322,"line":15},[320,2434,896],{"class":331},[320,2436,2437],{"class":345}," PhotoAlbum, { ",[320,2439,1022],{"class":331},[320,2441,2442],{"class":345}," Photo } ",[320,2444,2445],{"class":331},"from",[320,2447,2448],{"class":354}," \"react-photo-album\"\n",[320,2450,2451],{"class":322,"line":13},[320,2452,2453],{"class":325},"\u002F\u002F 必须引入样式表！容器\u002F列的布局规则都在这里（坑 1 详述）\n",[320,2455,2456,2458],{"class":322,"line":9},[320,2457,896],{"class":331},[320,2459,2460],{"class":354}," \"react-photo-album\u002Fstyles.css\"\n",[320,2462,2463],{"class":322,"line":361},[320,2464,891],{"emptyLinePlaceholder":7},[320,2466,2467,2470,2473,2476,2479],{"class":322,"line":26},[320,2468,2469],{"class":331},"interface",[320,2471,2472],{"class":405}," GalleryPhoto",[320,2474,2475],{"class":331}," extends",[320,2477,2478],{"class":405}," Photo",[320,2480,346],{"class":345},[320,2482,2483,2486,2489],{"class":322,"line":29},[320,2484,2485],{"class":1076},"  prompt_text",[320,2487,2488],{"class":331},"?:",[320,2490,2491],{"class":338}," string\n",[320,2493,2494],{"class":322,"line":387},[320,2495,390],{"class":345},[320,2497,2498],{"class":322,"line":931},[320,2499,891],{"emptyLinePlaceholder":7},[320,2501,2502,2505,2508,2511,2513,2516,2518,2521,2524,2527,2530,2532,2535],{"class":322,"line":937},[320,2503,2504],{"class":331},"const",[320,2506,2507],{"class":338}," albumPhotos",[320,2509,2510],{"class":331},":",[320,2512,2472],{"class":405},[320,2514,2515],{"class":345},"[] ",[320,2517,1140],{"class":331},[320,2519,2520],{"class":345}," images.",[320,2522,2523],{"class":405},"map",[320,2525,2526],{"class":345},"((",[320,2528,2529],{"class":1076},"img",[320,2531,1084],{"class":345},[320,2533,2534],{"class":331},"=>",[320,2536,2537],{"class":345}," ({\n",[320,2539,2540,2543,2546],{"class":322,"line":942},[320,2541,2542],{"class":345},"  key: ",[320,2544,2545],{"class":405},"String",[320,2547,2548],{"class":345},"(img.id),\n",[320,2550,2551,2554,2557,2560,2563,2566],{"class":322,"line":948},[320,2552,2553],{"class":345},"  src: ",[320,2555,2556],{"class":405},"assetUrl",[320,2558,2559],{"class":345},"(img.thumb_path) ",[320,2561,2562],{"class":331},"||",[320,2564,2565],{"class":405}," assetUrl",[320,2567,2568],{"class":345},"(img.file_path),\n",[320,2570,2571],{"class":322,"line":965},[320,2572,2573],{"class":345},"  width: img.width,\n",[320,2575,2576],{"class":322,"line":970},[320,2577,2578],{"class":345},"  height: img.height,\n",[320,2580,2581,2584,2586,2589,2591,2593,2596,2599],{"class":322,"line":68},[320,2582,2583],{"class":345},"  alt: img.prompt_text ",[320,2585,2562],{"class":331},[320,2587,2588],{"class":354}," `生成图 ${",[320,2590,2529],{"class":345},[320,2592,959],{"class":354},[320,2594,2595],{"class":345},"id",[320,2597,2598],{"class":354},"}`",[320,2600,358],{"class":345},[320,2602,2603],{"class":322,"line":1014},[320,2604,2605],{"class":345},"  prompt_text: img.prompt_text,\n",[320,2607,2608],{"class":322,"line":1019},[320,2609,2610],{"class":345},"}))\n",[676,2612,2614],{"id":2613},"_3-使用-masonry-布局","3. 使用 Masonry 布局",[310,2616,2618],{"className":742,"code":2617,"language":744,"meta":315,"style":315},"\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",[317,2619,2620,2627,2637,2706,2721,2734,2744,2768,2778,2800,2819,2847,2854,2888,2896,2906,2923,2933,2943,2948,2958,2975,2980,2989,2998,3003,3008,3013],{"__ignoreMap":315},[320,2621,2622,2624],{"class":322,"line":15},[320,2623,756],{"class":345},[320,2625,2626],{"class":338},"PhotoAlbum\n",[320,2628,2629,2632,2634],{"class":322,"line":13},[320,2630,2631],{"class":405},"  layout",[320,2633,1140],{"class":331},[320,2635,2636],{"class":354},"\"masonry\"\n",[320,2638,2639,2642,2644,2647,2650,2652,2654,2657,2659,2662,2665,2668,2671,2674,2676,2679,2681,2684,2686,2688,2690,2693,2695,2698,2700,2703],{"class":322,"line":9},[320,2640,2641],{"class":405},"  columns",[320,2643,1140],{"class":331},[320,2645,2646],{"class":345},"{(",[320,2648,2649],{"class":1076},"cw",[320,2651,1084],{"class":345},[320,2653,2534],{"class":331},[320,2655,2656],{"class":345}," (cw ",[320,2658,756],{"class":331},[320,2660,2661],{"class":338}," 640",[320,2663,2664],{"class":331}," ?",[320,2666,2667],{"class":338}," 2",[320,2669,2670],{"class":331}," :",[320,2672,2673],{"class":345}," cw ",[320,2675,756],{"class":331},[320,2677,2678],{"class":338}," 960",[320,2680,2664],{"class":331},[320,2682,2683],{"class":338}," 3",[320,2685,2670],{"class":331},[320,2687,2673],{"class":345},[320,2689,756],{"class":331},[320,2691,2692],{"class":338}," 1280",[320,2694,2664],{"class":331},[320,2696,2697],{"class":338}," 4",[320,2699,2670],{"class":331},[320,2701,2702],{"class":338}," 5",[320,2704,2705],{"class":345},")}\n",[320,2707,2708,2711,2713,2716,2719],{"class":322,"line":361},[320,2709,2710],{"class":405},"  spacing",[320,2712,1140],{"class":331},[320,2714,2715],{"class":345},"{",[320,2717,2718],{"class":338},"16",[320,2720,390],{"class":345},[320,2722,2723,2726,2728,2730,2732],{"class":322,"line":26},[320,2724,2725],{"class":405},"  padding",[320,2727,1140],{"class":331},[320,2729,2715],{"class":345},[320,2731,1765],{"class":338},[320,2733,390],{"class":345},[320,2735,2736,2739,2741],{"class":322,"line":29},[320,2737,2738],{"class":405},"  photos",[320,2740,1140],{"class":331},[320,2742,2743],{"class":345},"{albumPhotos}\n",[320,2745,2746,2749,2751,2754,2757,2760,2762,2765],{"class":322,"line":387},[320,2747,2748],{"class":405},"  onClick",[320,2750,1140],{"class":331},[320,2752,2753],{"class":345},"{({ ",[320,2755,2756],{"class":1076},"index",[320,2758,2759],{"class":345}," }) ",[320,2761,2534],{"class":331},[320,2763,2764],{"class":405}," openPreview",[320,2766,2767],{"class":345},"(index)}\n",[320,2769,2770,2773,2775],{"class":322,"line":931},[320,2771,2772],{"class":405},"  render",[320,2774,1140],{"class":331},[320,2776,2777],{"class":345},"{{\n",[320,2779,2780,2783,2786,2789,2791,2794,2796,2798],{"class":322,"line":937},[320,2781,2782],{"class":405},"    photo",[320,2784,2785],{"class":345},": (",[320,2787,2788],{"class":1076},"props",[320,2790,452],{"class":345},[320,2792,2793],{"class":1076},"context",[320,2795,1084],{"class":345},[320,2797,2534],{"class":331},[320,2799,346],{"class":345},[320,2801,2802,2805,2808,2811,2814,2816],{"class":322,"line":942},[320,2803,2804],{"class":331},"      const",[320,2806,2807],{"class":345}," { ",[320,2809,2810],{"class":338},"onClick",[320,2812,2813],{"class":345}," } ",[320,2815,1140],{"class":331},[320,2817,2818],{"class":345}," props\n",[320,2820,2821,2823,2825,2828,2830,2832,2834,2836,2838,2840,2842,2844],{"class":322,"line":948},[320,2822,2804],{"class":331},[320,2824,2807],{"class":345},[320,2826,2827],{"class":338},"photo",[320,2829,452],{"class":345},[320,2831,2756],{"class":338},[320,2833,452],{"class":345},[320,2835,2421],{"class":338},[320,2837,452],{"class":345},[320,2839,2424],{"class":338},[320,2841,2813],{"class":345},[320,2843,1140],{"class":331},[320,2845,2846],{"class":345}," context\n",[320,2848,2849,2852],{"class":322,"line":965},[320,2850,2851],{"class":331},"      return",[320,2853,899],{"class":345},[320,2855,2856,2859,2862,2865,2867,2870,2873,2875,2878,2881,2883,2886],{"class":322,"line":970},[320,2857,2858],{"class":345},"        \u003C",[320,2860,2861],{"class":411},"button",[320,2863,2864],{"class":405}," type",[320,2866,1140],{"class":331},[320,2868,2869],{"class":354},"\"button\"",[320,2871,2872],{"class":405}," onClick",[320,2874,1140],{"class":331},[320,2876,2877],{"class":345},"{onClick} ",[320,2879,2880],{"class":405},"className",[320,2882,1140],{"class":331},[320,2884,2885],{"class":354},"\"group relative block w-full ...\"",[320,2887,761],{"class":345},[320,2889,2890,2893],{"class":322,"line":68},[320,2891,2892],{"class":345},"          \u003C",[320,2894,2895],{"class":411},"img\n",[320,2897,2898,2901,2903],{"class":322,"line":1014},[320,2899,2900],{"class":405},"            src",[320,2902,1140],{"class":331},[320,2904,2905],{"class":345},"{photo.src}\n",[320,2907,2908,2911,2913,2916,2918,2921],{"class":322,"line":1019},[320,2909,2910],{"class":405},"            alt",[320,2912,1140],{"class":331},[320,2914,2915],{"class":345},"{photo.alt ",[320,2917,2562],{"class":331},[320,2919,2920],{"class":354}," \"\"",[320,2922,390],{"class":345},[320,2924,2925,2928,2930],{"class":322,"line":1033},[320,2926,2927],{"class":405},"            decoding",[320,2929,1140],{"class":331},[320,2931,2932],{"class":354},"\"async\"\n",[320,2934,2935,2938,2940],{"class":322,"line":1042},[320,2936,2937],{"class":405},"            style",[320,2939,1140],{"class":331},[320,2941,2942],{"class":345},"{{ width, height }}\n",[320,2944,2945],{"class":322,"line":1057},[320,2946,2947],{"class":345},"          \u002F>\n",[320,2949,2950,2953,2956],{"class":322,"line":1062},[320,2951,2952],{"class":345},"          {",[320,2954,2955],{"class":325},"\u002F* hover 提示词浮层 *\u002F",[320,2957,390],{"class":345},[320,2959,2960,2962,2965,2968,2970,2973],{"class":322,"line":1067},[320,2961,2892],{"class":345},[320,2963,2964],{"class":411},"div",[320,2966,2967],{"class":405}," className",[320,2969,1140],{"class":331},[320,2971,2972],{"class":354},"\"... opacity-0 group-hover:opacity-100\"",[320,2974,761],{"class":345},[320,2976,2977],{"class":322,"line":1117},[320,2978,2979],{"class":345},"            {photo.prompt_text}\n",[320,2981,2982,2985,2987],{"class":322,"line":1134},[320,2983,2984],{"class":345},"          \u003C\u002F",[320,2986,2964],{"class":411},[320,2988,761],{"class":345},[320,2990,2991,2994,2996],{"class":322,"line":1146},[320,2992,2993],{"class":345},"        \u003C\u002F",[320,2995,2861],{"class":411},[320,2997,761],{"class":345},[320,2999,3000],{"class":322,"line":1157},[320,3001,3002],{"class":345},"      )\n",[320,3004,3005],{"class":322,"line":1189},[320,3006,3007],{"class":345},"    },\n",[320,3009,3010],{"class":322,"line":60},[320,3011,3012],{"class":345},"  }}\n",[320,3014,3015],{"class":322,"line":1214},[320,3016,3017],{"class":345},"\u002F>\n",[136,3019,3020],{},"几个要点：",[240,3022,3023,3032,3045],{},[243,3024,3025,3027,3028,3031],{},[317,3026,2305],{}," 可以是数字或 ",[317,3029,3030],{},"(containerWidth) => number"," 函数，做响应式列数很方便；",[243,3033,3034,3035,3037,3038,3041,3042,3044],{},"点击回调 ",[317,3036,2810],{}," 里拿到的是",[140,3039,3040],{},"原始数组下标"," ",[317,3043,2756],{},"，直接传给灯箱即可；",[243,3046,3047,3048,3051,3052,3054],{},"自定义 ",[317,3049,3050],{},"render.photo"," 后，图片尺寸要自己用 context 里的 ",[317,3053,2354],{},"（渲染尺寸）设置——这两个值库已经按列宽算好了。",[131,3056,3057],{"id":3057},"遇到的坑",[676,3059,3061],{"id":3060},"坑-1忘记引入样式表-一张一列","坑 1：忘记引入样式表 → 「一张一列」",[136,3063,3064,3065,3068,3069,1849],{},"这是最坑的一个。装完包、写好 ",[317,3066,3067],{},"\u003CPhotoAlbum layout=\"masonry\">","，页面渲染出来却是：",[140,3070,3071],{},"每张图占一整行，右侧大片置灰空白",[136,3073,3074,3075,3077],{},"排查过程：先怀疑是 ",[317,3076,2305],{}," 函数没生效，改成固定数字无效；再检查 DOM——容器和 5 个列（track）都在，列的宽度也算对了，但图片全都堆在布局里。",[136,3079,3080,3081,3084,3085,2418,3088,3091,3092,3098,3099,3102],{},"最后翻包的 ",[317,3082,3083],{},"package.json"," 发现它导出了独立的 CSS 文件（",[317,3086,3087],{},".\u002Fstyles.css",[317,3089,3090],{},".\u002Fmasonry.css"," 等），而",[140,3093,3094,3095],{},"官方 README 示例都带了一行 ",[317,3096,3097],{},"import \"react-photo-album\u002Fstyles.css\"","。容器是 flex、列是 flex-column、列宽按 ",[317,3100,3101],{},"--react-photo-album--columns"," 计算——这些规则全在这个样式表里，不引入就退化成普通块级堆叠。",[136,3104,3105,3107],{},[140,3106,1935],{},"：顶部加一行：",[310,3109,3111],{"className":742,"code":3110,"language":744,"meta":315,"style":315},"import \"react-photo-album\u002Fstyles.css\"\n",[317,3112,3113],{"__ignoreMap":315},[320,3114,3115,3117],{"class":322,"line":15},[320,3116,896],{"class":331},[320,3118,2460],{"class":354},[136,3120,3121,3122],{},"经验：",[140,3123,3124],{},"用这类\"无运行时依赖\"的布局库，先看它的 exports 里有没有独立 CSS，有就一定要引。",[676,3126,3128,3129,3131,3132,3135],{"id":3127},"坑-2renderphoto-是-props-context-双参签名","坑 2：",[317,3130,3050],{}," 是 ",[317,3133,3134],{},"(props, context)"," 双参签名",[136,3137,3138],{},"自定义渲染时我凭直觉写了：",[310,3140,3142],{"className":742,"code":3141,"language":744,"meta":315,"style":315},"photo: ({ photo, index, width, height, onClick }) => ... \u002F\u002F ❌ 报错：Property 'photo' does not exist\n",[317,3143,3144],{"__ignoreMap":315},[320,3145,3146,3148,3151,3153,3155,3157,3159,3161,3163,3165,3167,3169,3171,3173,3176],{"class":322,"line":15},[320,3147,2827],{"class":405},[320,3149,3150],{"class":345},": ({ ",[320,3152,2827],{"class":1076},[320,3154,452],{"class":345},[320,3156,2756],{"class":1076},[320,3158,452],{"class":345},[320,3160,2421],{"class":1076},[320,3162,452],{"class":345},[320,3164,2424],{"class":1076},[320,3166,452],{"class":345},[320,3168,2810],{"class":1076},[320,3170,2759],{"class":345},[320,3172,2534],{"class":331},[320,3174,3175],{"class":331}," ...",[320,3177,3178],{"class":325}," \u002F\u002F ❌ 报错：Property 'photo' does not exist\n",[136,3180,3181,3182,3184,3185,3188,3189,690],{},"TS 报错提示 ",[317,3183,2827],{}," 等属性不存在于 ",[317,3186,3187],{},"RenderPhotoProps","。看类型定义才发现 v3 的渲染函数是",[140,3190,3191],{},"两个参数",[310,3193,3195],{"className":742,"code":3194,"language":744,"meta":315,"style":315},"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",[317,3196,3197,3243,3248,3268,3283,3308,3312],{"__ignoreMap":315},[320,3198,3199,3201,3204,3206,3209,3211,3213,3216,3218,3220,3222,3224,3227,3229,3231,3233,3236,3238,3240],{"class":322,"line":15},[320,3200,1022],{"class":331},[320,3202,3203],{"class":405}," RenderFunction",[320,3205,756],{"class":345},[320,3207,3208],{"class":405},"Props",[320,3210,452],{"class":345},[320,3212,1384],{"class":405},[320,3214,3215],{"class":345},"> ",[320,3217,1140],{"class":331},[320,3219,1073],{"class":345},[320,3221,2788],{"class":1076},[320,3223,2510],{"class":331},[320,3225,3226],{"class":405}," Props",[320,3228,452],{"class":345},[320,3230,2793],{"class":1076},[320,3232,2510],{"class":331},[320,3234,3235],{"class":405}," Context",[320,3237,1084],{"class":345},[320,3239,2534],{"class":331},[320,3241,3242],{"class":405}," ReactNode\n",[320,3244,3245],{"class":322,"line":13},[320,3246,3247],{"class":325},"\u002F\u002F photo 的渲染上下文（photo\u002Findex\u002Fwidth\u002Fheight）在第二个参数里\n",[320,3249,3250,3252,3254,3256,3258,3260,3262,3264,3266],{"class":322,"line":9},[320,3251,2827],{"class":405},[320,3253,2510],{"class":331},[320,3255,1073],{"class":345},[320,3257,2788],{"class":1076},[320,3259,452],{"class":345},[320,3261,2793],{"class":1076},[320,3263,1084],{"class":345},[320,3265,2534],{"class":331},[320,3267,346],{"class":345},[320,3269,3270,3273,3275,3277,3280],{"class":322,"line":361},[320,3271,3272],{"class":405},"  const",[320,3274,2807],{"class":345},[320,3276,2810],{"class":1076},[320,3278,3279],{"class":345}," } = ",[320,3281,3282],{"class":1076},"props\n",[320,3284,3285,3287,3289,3291,3293,3295,3297,3299,3301,3303,3305],{"class":322,"line":26},[320,3286,3272],{"class":405},[320,3288,2807],{"class":345},[320,3290,2827],{"class":1076},[320,3292,452],{"class":345},[320,3294,2756],{"class":1076},[320,3296,452],{"class":345},[320,3298,2421],{"class":1076},[320,3300,452],{"class":345},[320,3302,2424],{"class":1076},[320,3304,3279],{"class":345},[320,3306,3307],{"class":1076},"context\n",[320,3309,3310],{"class":322,"line":29},[320,3311,734],{"class":331},[320,3313,3314],{"class":322,"line":387},[320,3315,390],{"class":345},[136,3317,3318,3320,3321,3323,3324,3327],{},[140,3319,1935],{},"：按 ",[317,3322,3134],{}," 拆解即可。装新库先扫一眼 ",[317,3325,3326],{},".d.ts"," 里的 Render 类型签名，能少走很多弯路。",[676,3329,3331],{"id":3330},"坑-3测试断言把dom-顺序当成了视觉顺序","坑 3：测试断言把「DOM 顺序」当成了「视觉顺序」",[136,3333,3334,3335,3338],{},"布局修好后，我在浏览器自测脚本里加了一条断言：取前两个 ",[317,3336,3337],{},"\u003Cimg>","，比较它们的左坐标，认为应该并排（不同列）。",[136,3340,3341,3342,3345,3346,1235,3349,3352,3353,3356],{},"结果断言失败——但页面明明是 5 列并排。排查发现：",[140,3343,3344],{},"瀑布流的 DOM 是按\"列\"组织的","（第 1 列的全部图片在前，第 2 列的在后），所以 ",[317,3347,3348],{},"img[0]",[317,3350,3351],{},"img[1]"," 属于",[140,3354,3355],{},"同一列","，左坐标当然相同。",[136,3358,3359,3361],{},[140,3360,1935],{},"：断言改为比较「第 1 列的首张图 vs 第 2 列的首张图」：",[310,3363,3367],{"className":3364,"code":3365,"language":3366,"meta":315,"style":315},"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",[317,3368,3369,3396,3420],{"__ignoreMap":315},[320,3370,3371,3373,3376,3378,3381,3383,3386,3389,3391,3394],{"class":322,"line":15},[320,3372,2504],{"class":331},[320,3374,3375],{"class":338}," a",[320,3377,342],{"class":331},[320,3379,3380],{"class":345}," tracks[",[320,3382,1765],{"class":338},[320,3384,3385],{"class":345},"].",[320,3387,3388],{"class":405},"querySelector",[320,3390,1090],{"class":345},[320,3392,3393],{"class":354},"\"img\"",[320,3395,934],{"class":345},[320,3397,3398,3400,3403,3405,3407,3410,3412,3414,3416,3418],{"class":322,"line":13},[320,3399,2504],{"class":331},[320,3401,3402],{"class":338}," b",[320,3404,342],{"class":331},[320,3406,3380],{"class":345},[320,3408,3409],{"class":338},"1",[320,3411,3385],{"class":345},[320,3413,3388],{"class":405},[320,3415,1090],{"class":345},[320,3417,3393],{"class":354},[320,3419,934],{"class":345},[320,3421,3422,3425,3428,3431,3434,3437,3440,3443,3446,3448,3451,3454],{"class":322,"line":9},[320,3423,3424],{"class":331},"return",[320,3426,3427],{"class":345}," Math.",[320,3429,3430],{"class":405},"abs",[320,3432,3433],{"class":345},"(a.",[320,3435,3436],{"class":405},"getBoundingClientRect",[320,3438,3439],{"class":345},"().left ",[320,3441,3442],{"class":331},"-",[320,3444,3445],{"class":345}," b.",[320,3447,3436],{"class":405},[320,3449,3450],{"class":345},"().left) ",[320,3452,3453],{"class":331},">",[320,3455,3456],{"class":338}," 10\n",[136,3458,3121,3459,3462],{},[140,3460,3461],{},"验证布局类库时，先搞清楚它生成的 DOM 结构，再写断言","；另外把「真实浏览器探针」当排查手段（直接打印各 track 的 x 坐标和子元素数量），比盯着代码猜快得多。",[676,3464,3466],{"id":3465},"坑-4等宽网格的洞引入前就存在的产品问题","坑 4：等宽网格的\"洞\"（引入前就存在的产品问题）",[136,3468,3469,3470,3473,3474,3477],{},"顺带记录一下为什么换掉原来的布局：CSS Grid ",[317,3471,3472],{},"repeat(auto-fill, minmax(220px, 1fr))"," 等宽铺满，图片 ",[317,3475,3476],{},"w-full h-auto","。当一行里混有不同比例时，行高取最高的那张，矮图底部留空，几行错落下来就显得乱；另外窗口宽度\"差一点放不下下一张\"时，右侧会空出近一整列。",[136,3479,3480],{},"换成 Masonry 后这两个问题都消失了——列内紧密堆叠、列数自适应。",[131,3482,3483],{"id":3483},"效果与小结",[136,3485,3486,3487,3489],{},"最终效果：21 张不同比例的生成图在 5 列瀑布流里紧密排列，原比例不裁剪，悬停出提示词浮层，点击进 YARL 大图预览；缩略图总大小仅 360KB，进页面即全量加载 + ",[317,3488,1943],{}," 长缓存，平板上快速滚动也跟手。",[136,3491,3492,3493],{},"一句话总结：",[140,3494,3495],{},"react-photo-album 是个小但专业的相册布局库，只要记住三件事——引样式表、喂宽高、render 函数是双参——就能在十分钟内把图片墙升级成真正的瀑布流。",[551,3497,3498],{},"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":315,"searchDepth":13,"depth":9,"links":3500},[3501,3502,3503,3508,3515],{"id":2249,"depth":13,"text":2249},{"id":2358,"depth":13,"text":2358},{"id":2384,"depth":13,"text":2384,"children":3504},[3505,3506,3507],{"id":2387,"depth":9,"text":2388},{"id":2406,"depth":9,"text":2407},{"id":2613,"depth":9,"text":2614},{"id":3057,"depth":13,"text":3057,"children":3509},[3510,3511,3513,3514],{"id":3060,"depth":9,"text":3061},{"id":3127,"depth":9,"text":3512},"坑 2：render.photo 是 (props, context) 双参签名",{"id":3330,"depth":9,"text":3331},{"id":3465,"depth":9,"text":3466},{"id":3483,"depth":13,"text":3483},"记录在 Magic Img 生图图库中引入 react-photo-album 实现 Masonry 瀑布流的过程：技术选型背景、需求梳理、安装使用，以及「忘记引入样式表导致一张一列」等踩坑与排查方法。","\u002Fposts\u002Freact-photo-album-masonry\u002Fimg\u002Fcover.svg",{},"\u002Fposts\u002Freact-photo-album-masonry",{"title":2244,"description":3516},"前端工程实践","posts\u002Freact-photo-album-masonry\u002Findex",[2237,2335,3524,3525,3526],"瀑布流","前端","Tailwind","-O-kAQLEyRzwVhOpuo-yBxZim9LtvhChPkpP92ipyjM",{"id":3529,"title":3530,"author":126,"body":3531,"description":4704,"difficulty":564,"draft":19,"extension":565,"image":4705,"meta":4706,"navigation":7,"path":4707,"pinned":19,"published":4708,"seo":4709,"series":126,"seriesOrder":126,"stem":4710,"tags":4711,"updated":2240,"__hash__":4715},"posts\u002Fposts\u002Fip-geolocation-guide\u002Findex.md","IP 属地是怎么查出来的？本地查询与在线查询方案对比",{"type":128,"value":3532,"toc":4680},[3533,3536,3542,3545,3556,3565,3569,3575,3588,3596,3607,3614,3617,3624,3628,3639,3643,3654,3657,3661,3667,3673,3676,3680,3687,3705,3708,3711,3715,3746,3787,3793,3797,3940,3976,3985,3989,4029,4034,4038,4049,4052,4086,4089,4093,4096,4100,4295,4299,4381,4385,4392,4499,4503,4512,4598,4601,4604,4640,4643,4645,4653,4674,4677],[131,3534,3535],{"id":3535},"写在前面",[136,3537,3538,3539],{},"很多网站会在「登录记录」「安全中心」里显示你的登录 IP 归属地，比如「福建·福州 · 中国移动」。你可能好奇过：",[140,3540,3541],{},"服务器是怎么根据一个 IP 就判断出我在哪个城市、哪个运营商的？",[136,3543,3544],{},"这篇科普文章讲清楚三件事：",[621,3546,3547,3550,3553],{},[243,3548,3549],{},"IP 属地到底查的是什么；",[243,3551,3552],{},"纯本地查询为什么能做到毫秒级且不联网；",[243,3554,3555],{},"几种主流查询方案的优缺点对比，方便你按场景选型。",[1946,3557,3558],{},[136,3559,3560,3561,3564],{},"说明：文中用到的 IP 均为 RFC 5737 保留的文档示例段（TEST-NET-2，",[317,3562,3563],{},"198.51.100.0\u002F24","），不会对应任何真实地址。",[131,3566,3568],{"id":3567},"先想清楚ip-属地查的是什么","先想清楚：IP 属地查的是什么",[136,3570,3571,3572,1849],{},"一个常见误区是「每个 IP 对应一个具体地址」。实际上 IP 属地的本质是 ",[140,3573,3574],{},"IP 段与归属地的映射",[136,3576,3577,3578,690],{},"运营商和机构在申请 IP 时，是按",[140,3579,3580,3581,3587],{},"网段（CIDR）",[140,3582,3583,3584,3586],{},"成块分配的。比如 ",[317,3585,3563],{}," 这一个段，一整块都归属同一地区、同一运营商。所以归属地数据天然可以","按段存储",[310,3589,3594],{"className":3590,"code":3592,"language":3593,"meta":315},[3591],"language-text","IP 起始          IP 结束         归属地\n────────────────────────────────────────\n198.51.100.0     198.51.100.255  中国|福建|福州|移动\n198.51.101.0     198.51.101.255  中国|浙江|杭州|电信\n","text",[317,3595,3592],{"__ignoreMap":315},[136,3597,3598,3599,3602,3603,3606],{},"全球公网 IP 约有 42 亿个，但",[140,3600,3601],{},"网段只有几十万条","。正因为存的是「段」而不是「每个 IP」，完整的归属地数据文件才只有几 MB（ip2region 的 ",[317,3604,3605],{},".xdb"," 约 11MB）。这是「本地离线查询」可行的第一前提。",[136,3608,3609,3610,3613],{},"所以，",[140,3611,3612],{},"IP 属地从来不是精确到街道的定位","，它返回的是段级的「省｜市｜运营商」粗粒度信息。",[131,3615,3616],{"id":3616},"纯本地查询的原理",[136,3618,3619,3620,3623],{},"以开源的 ",[140,3621,3622],{},"ip2region"," 为例，它的查询完全在本地完成，不联网、不请求第三方，几十微秒到毫秒级返回。",[676,3625,3627],{"id":3626},"第一步把-ip-变成整数","第一步：把 IP 变成整数",[136,3629,3630,3631,3634,3635,3638],{},"以 ",[317,3632,3633],{},"198.51.100.42"," 为例，它会转成一个 32 位整数 ",[317,3636,3637],{},"0xC633642A","。IP 的四个数字本质就是 4 个字节，拼起来就是一个整数，这一步没有任何玄学。",[676,3640,3642],{"id":3641},"第二步二分查找","第二步：二分查找",[136,3644,3645,3646,3649,3650,3653],{},"在按起始 IP 排序的段列表里做",[140,3647,3648],{},"二分查找","，找到满足 ",[317,3651,3652],{},"段的起始IP ≤ 目标整数 ≤ 段的结束IP"," 的那一段。",[136,3655,3656],{},"二分查找的复杂度是 O(log N)，N 是几十万条，所以很快——这就是「不靠网络也能毫秒返回」的核心。",[676,3658,3660],{"id":3659},"第三步xdb-文件的两级结构","第三步：.xdb 文件的两级结构",[136,3662,3663,3664,3666],{},"ip2region 的 ",[317,3665,3605],{}," 不是简单顺序文件，内部分两个区，进一步加速：",[310,3668,3671],{"className":3669,"code":3670,"language":3593,"meta":315},[3591],"┌─────────────────────────────┐\n│  索引区 (Index)             │  \u002F\u002F 每段一条定长记录：startIP \u002F endIP \u002F 数据区指针 + 数据长度\n│  …… 二分快速定位段 ……       │\n├─────────────────────────────┤\n│  数据区 (Data)              │  \u002F\u002F 真正存放各段的地点字符串\n│  ……                         │\n└─────────────────────────────┘\n",[317,3672,3670],{"__ignoreMap":315},[136,3674,3675],{},"先二分索引区定位到「是哪一段」，再按指针去数据区读出地点字符串，一次磁盘读就能拿到结果。",[676,3677,3679],{"id":3678},"为什么离线数据不会很快过期","为什么「离线数据」不会很快过期",[136,3681,3682,3683,3686],{},"IP 归属取决于",[140,3684,3685],{},"注册机构和运营商的分配","，一个网段通常长期稳定属于某地区、某运营商。所以：",[240,3688,3689,3692,3702],{},[243,3690,3691],{},"下载一次数据文件，可以用很久；",[243,3693,3694,3695,3701],{},"归属偶尔变化时，",[140,3696,3697,3698,3700],{},"每月更新一次 ",[317,3699,3605],{}," 文件","即可（替换文件、不用重启服务，见下文实战）；",[243,3703,3704],{},"数据源来自 IANA\u002FAPNIC 等机构的分配表与公开归属信息，不是实时探测。",[131,3706,3707],{"id":3707},"主流查询方案对比",[136,3709,3710],{},"实际开发中，查询 IP 属地主要有三条路线。",[676,3712,3714],{"id":3713},"方案一ip2region纯本地离线推荐","方案一：ip2region（纯本地离线，推荐）",[310,3716,3718],{"className":869,"code":3717,"language":871,"meta":315,"style":315},"\u002F\u002F Go 中的用法（github.com\u002Flionsoul2014\u002Fip2region\u002Fbinding\u002Fgolang）\nregion, err := searcher.SearchByStr(ip)\n\u002F\u002F 返回类似 \"中国|福建省|福州市|移动\"\n",[317,3719,3720,3725,3741],{"__ignoreMap":315},[320,3721,3722],{"class":322,"line":15},[320,3723,3724],{"class":325},"\u002F\u002F Go 中的用法（github.com\u002Flionsoul2014\u002Fip2region\u002Fbinding\u002Fgolang）\n",[320,3726,3727,3730,3732,3735,3738],{"class":322,"line":13},[320,3728,3729],{"class":345},"region, err ",[320,3731,1324],{"class":331},[320,3733,3734],{"class":345}," searcher.",[320,3736,3737],{"class":405},"SearchByStr",[320,3739,3740],{"class":345},"(ip)\n",[320,3742,3743],{"class":322,"line":9},[320,3744,3745],{"class":325},"\u002F\u002F 返回类似 \"中国|福建省|福州市|移动\"\n",[154,3747,3748,3758],{},[157,3749,3750],{},[160,3751,3752,3755],{},[163,3753,3754],{},"优点",[163,3756,3757],{},"缺点",[170,3759,3760,3768,3776],{},[160,3761,3762,3765],{},[175,3763,3764],{},"免费、无网络依赖、无 QPS 限制",[175,3766,3767],{},"段级粒度，个别段可能显示到相邻地区",[160,3769,3770,3773],{},[175,3771,3772],{},"毫秒级查询，永不因外部服务挂掉",[175,3774,3775],{},"数据按月更新，最新分配的段可能短暂缺失",[160,3777,3778,3781],{},[175,3779,3780],{},"数据文件可随版本更新，不重启",[175,3782,3783,3784],{},"只返回省市区+运营商，无经纬度；",[140,3785,3786],{},"仅支持 IPv4",[136,3788,3789,3792],{},[140,3790,3791],{},"适用","：博客\u002F后台的「登录记录显示归属地」这类展示用途。",[676,3794,3796],{"id":3795},"方案二在线-apiip-api-高德-腾讯位置服务","方案二：在线 API（ip-api \u002F 高德 \u002F 腾讯位置服务）",[310,3798,3800],{"className":869,"code":3799,"language":871,"meta":315,"style":315},"\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",[317,3801,3802,3807,3837,3847,3877,3893,3909,3919,3923,3935],{"__ignoreMap":315},[320,3803,3804],{"class":322,"line":15},[320,3805,3806],{"class":325},"\u002F\u002F 以 ip-api.com 为例（免费版约 45 次\u002F分钟，非商用）\n",[320,3808,3809,3812,3814,3817,3820,3823,3826,3829,3832,3834],{"class":322,"line":13},[320,3810,3811],{"class":345},"ctx, cancel ",[320,3813,1324],{"class":331},[320,3815,3816],{"class":345}," context.",[320,3818,3819],{"class":405},"WithTimeout",[320,3821,3822],{"class":345},"(context.",[320,3824,3825],{"class":405},"Background",[320,3827,3828],{"class":345},"(), ",[320,3830,3831],{"class":338},"3",[320,3833,1048],{"class":331},[320,3835,3836],{"class":345},"time.Second)\n",[320,3838,3839,3842,3845],{"class":322,"line":9},[320,3840,3841],{"class":331},"defer",[320,3843,3844],{"class":405}," cancel",[320,3846,1333],{"class":345},[320,3848,3849,3852,3854,3856,3859,3862,3865,3867,3870,3872,3875],{"class":322,"line":361},[320,3850,3851],{"class":345},"req, _ ",[320,3853,1324],{"class":331},[320,3855,1200],{"class":345},[320,3857,3858],{"class":405},"NewRequestWithContext",[320,3860,3861],{"class":345},"(ctx, ",[320,3863,3864],{"class":354},"\"GET\"",[320,3866,452],{"class":345},[320,3868,3869],{"class":354},"\"https:\u002F\u002Fip-api.com\u002Fjson\u002F198.51.100.42\"",[320,3871,452],{"class":345},[320,3873,3874],{"class":338},"nil",[320,3876,934],{"class":345},[320,3878,3879,3882,3884,3887,3890],{"class":322,"line":26},[320,3880,3881],{"class":345},"resp, err ",[320,3883,1324],{"class":331},[320,3885,3886],{"class":345}," http.DefaultClient.",[320,3888,3889],{"class":405},"Do",[320,3891,3892],{"class":345},"(req)\n",[320,3894,3895,3898,3901,3904,3907],{"class":322,"line":29},[320,3896,3897],{"class":331},"if",[320,3899,3900],{"class":345}," err ",[320,3902,3903],{"class":331},"!=",[320,3905,3906],{"class":338}," nil",[320,3908,346],{"class":345},[320,3910,3911,3913,3916],{"class":322,"line":387},[320,3912,1197],{"class":331},[320,3914,3915],{"class":354}," \"未知\"",[320,3917,3918],{"class":325}," \u002F\u002F 断网\u002F超时降级：展示「未知」而不是报错\n",[320,3920,3921],{"class":322,"line":931},[320,3922,390],{"class":345},[320,3924,3925,3927,3930,3933],{"class":322,"line":937},[320,3926,3841],{"class":331},[320,3928,3929],{"class":345}," resp.Body.",[320,3931,3932],{"class":405},"Close",[320,3934,1333],{"class":345},[320,3936,3937],{"class":322,"line":942},[320,3938,3939],{"class":325},"\u002F\u002F 解析 JSON，status != \"success\" 时同样降级\n",[154,3941,3942,3950],{},[157,3943,3944],{},[160,3945,3946,3948],{},[163,3947,3754],{},[163,3949,3757],{},[170,3951,3952,3960,3968],{},[160,3953,3954,3957],{},[175,3955,3956],{},"数据最新、可能更精确",[175,3958,3959],{},"有频率限制（免费版限 QPS）",[160,3961,3962,3965],{},[175,3963,3964],{},"部分服务还返回经纬度\u002F时区",[175,3966,3967],{},"依赖第三方，断网\u002F被墙\u002F限流时拿不到",[160,3969,3970,3973],{},[175,3971,3972],{},"无需维护数据文件",[175,3974,3975],{},"每次查询都要发一次 HTTP 请求",[136,3977,3978,3980,3981,3984],{},[140,3979,3791],{},"：对精度要求高、且能接受外部依赖与限流的场景。",[140,3982,3983],{},"务必加超时和降级","，否则第三方一抖，你自己的页面就卡住。",[676,3986,3988],{"id":3987},"方案三maxmind-geolite2离线-mmdb","方案三：MaxMind GeoLite2（离线 .mmdb）",[154,3990,3991,3999],{},[157,3992,3993],{},[160,3994,3995,3997],{},[163,3996,3754],{},[163,3998,3757],{},[170,4000,4001,4009,4021],{},[160,4002,4003,4006],{},[175,4004,4005],{},"全球数据，覆盖广、精度较好",[175,4007,4008],{},"免费但需注册账号并遵守 EULA；商用需 GeoIP2 商业授权",[160,4010,4011,4018],{},[175,4012,4013,4014,4017],{},"标准 ",[317,4015,4016],{},".mmdb"," 格式，各语言都有库，支持 IPv6",[175,4019,4020],{},"国内 IP 的省市粒度有时不如 ip2region 细",[160,4022,4023,4026],{},[175,4024,4025],{},"离线查询",[175,4027,4028],{},"数据同样要定期更新",[136,4030,4031,4033],{},[140,4032,3791],{},"：面向全球用户的场景。",[131,4035,4037],{"id":4036},"ipv6本地离线方案的一个盲点","IPv6：本地离线方案的一个盲点",[136,4039,4040,4041,4044,4045,4048],{},"前面聊的都是 IPv4（32 位）。但家宽和移动网络越来越多地",[140,4042,4043],{},"只下发 IPv6","（128 位）地址，而 ",[140,4046,4047],{},"ip2region 目前只支持 IPv4","——拿到一个纯 IPv6 地址是查不出归属地的，本地库会返回空结果。",[136,4050,4051],{},"实际影响与常见应对：",[621,4053,4054,4064,4070,4080],{},[243,4055,4056,4059,4060,4063],{},[140,4057,4058],{},"判断版本","：Go 里 ",[317,4061,4062],{},"net.ParseIP(ip).To4() != nil"," 就是 IPv4，先判断再查询；",[243,4065,4066,4069],{},[140,4067,4068],{},"降级展示","：IPv6 直接显示「IPv6 地址」，不显示属地，诚实第一；",[243,4071,4072,4075,4076,4079],{},[140,4073,4074],{},"优先取 IPv4","：有反向代理时，从 ",[317,4077,4078],{},"X-Forwarded-For"," 里挑一个 IPv4 来查；",[243,4081,4082,4085],{},[140,4083,4084],{},"需要 v6 归属","：换支持 IPv6 的数据源（如 MaxMind GeoLite2）。",[136,4087,4088],{},"这一点在接入时很容易漏掉，上线前记得拿一个纯 IPv6 地址回归一遍。",[131,4090,4092],{"id":4091},"实战给登录记录加-ip-属地","实战：给登录记录加 IP 属地",[136,4094,4095],{},"subLog 博客的登录记录就用了 ip2region。完整接入一共四步。",[676,4097,4099],{"id":4098},"_1-进程启动时加载数据文件","1. 进程启动时加载数据文件",[310,4101,4103],{"className":869,"code":4102,"language":871,"meta":315,"style":315},"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",[317,4104,4105,4111,4120,4129,4133,4137,4142,4170,4174,4188,4211,4223,4231,4235,4251,4263,4269,4273,4284,4291],{"__ignoreMap":315},[320,4106,4107,4109],{"class":322,"line":15},[320,4108,896],{"class":331},[320,4110,899],{"class":345},[320,4112,4113,4115,4118],{"class":322,"line":13},[320,4114,904],{"class":354},[320,4116,4117],{"class":405},"sync\u002Fatomic",[320,4119,910],{"class":354},[320,4121,4122,4124,4127],{"class":322,"line":9},[320,4123,904],{"class":354},[320,4125,4126],{"class":405},"github.com\u002Flionsoul2014\u002Fip2region\u002Fbinding\u002Fgolang\u002Fxdb",[320,4128,910],{"class":354},[320,4130,4131],{"class":322,"line":361},[320,4132,934],{"class":345},[320,4134,4135],{"class":322,"line":26},[320,4136,891],{"emptyLinePlaceholder":7},[320,4138,4139],{"class":322,"line":29},[320,4140,4141],{"class":325},"\u002F\u002F 用 atomic.Pointer 持有查询器，方便后面热更新\n",[320,4143,4144,4146,4149,4152,4154,4157,4160,4163,4165,4168],{"class":322,"line":387},[320,4145,951],{"class":331},[320,4147,4148],{"class":345}," searcher ",[320,4150,4151],{"class":405},"atomic",[320,4153,959],{"class":345},[320,4155,4156],{"class":405},"Pointer",[320,4158,4159],{"class":345},"[",[320,4161,4162],{"class":405},"xdb",[320,4164,959],{"class":345},[320,4166,4167],{"class":405},"Searcher",[320,4169,458],{"class":345},[320,4171,4172],{"class":322,"line":931},[320,4173,891],{"emptyLinePlaceholder":7},[320,4175,4176,4178,4181,4184,4186],{"class":322,"line":937},[320,4177,1070],{"class":331},[320,4179,4180],{"class":405}," initSearcher",[320,4182,4183],{"class":345},"() ",[320,4185,1111],{"class":331},[320,4187,346],{"class":345},[320,4189,4190,4193,4195,4198,4201,4203,4206,4208],{"class":322,"line":942},[320,4191,4192],{"class":345},"    content, err ",[320,4194,1324],{"class":331},[320,4196,4197],{"class":345}," os.",[320,4199,4200],{"class":405},"ReadFile",[320,4202,1090],{"class":345},[320,4204,4205],{"class":354},"\".\u002Fdata\u002Fip2region.xdb\"",[320,4207,1084],{"class":345},[320,4209,4210],{"class":325},"\u002F\u002F 约 11MB，一次性读入内存\n",[320,4212,4213,4215,4217,4219,4221],{"class":322,"line":948},[320,4214,1120],{"class":331},[320,4216,3900],{"class":345},[320,4218,3903],{"class":331},[320,4220,3906],{"class":338},[320,4222,346],{"class":345},[320,4224,4225,4228],{"class":322,"line":965},[320,4226,4227],{"class":331},"        return",[320,4229,4230],{"class":345}," err\n",[320,4232,4233],{"class":322,"line":970},[320,4234,1192],{"class":345},[320,4236,4237,4240,4242,4245,4248],{"class":322,"line":68},[320,4238,4239],{"class":345},"    s, err ",[320,4241,1324],{"class":331},[320,4243,4244],{"class":345}," xdb.",[320,4246,4247],{"class":405},"NewWithBuffer",[320,4249,4250],{"class":345},"(content)\n",[320,4252,4253,4255,4257,4259,4261],{"class":322,"line":1014},[320,4254,1120],{"class":331},[320,4256,3900],{"class":345},[320,4258,3903],{"class":331},[320,4260,3906],{"class":338},[320,4262,346],{"class":345},[320,4264,4265,4267],{"class":322,"line":1019},[320,4266,4227],{"class":331},[320,4268,4230],{"class":345},[320,4270,4271],{"class":322,"line":1033},[320,4272,1192],{"class":345},[320,4274,4275,4278,4281],{"class":322,"line":1042},[320,4276,4277],{"class":345},"    searcher.",[320,4279,4280],{"class":405},"Store",[320,4282,4283],{"class":345},"(s)\n",[320,4285,4286,4288],{"class":322,"line":1057},[320,4287,1197],{"class":331},[320,4289,4290],{"class":338}," nil\n",[320,4292,4293],{"class":322,"line":1062},[320,4294,390],{"class":345},[676,4296,4298],{"id":4297},"_2-查询一次","2. 查询一次",[310,4300,4302],{"className":869,"code":4301,"language":871,"meta":315,"style":315},"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",[317,4303,4304,4325,4344,4356,4363,4367,4377],{"__ignoreMap":315},[320,4305,4306,4308,4311,4313,4316,4318,4320,4323],{"class":322,"line":15},[320,4307,1070],{"class":331},[320,4309,4310],{"class":405}," ipRegion",[320,4312,1090],{"class":345},[320,4314,4315],{"class":1076},"ip",[320,4317,1096],{"class":331},[320,4319,1084],{"class":345},[320,4321,4322],{"class":331},"string",[320,4324,346],{"class":345},[320,4326,4327,4330,4332,4334,4337,4340,4342],{"class":322,"line":13},[320,4328,4329],{"class":345},"    region, err ",[320,4331,1324],{"class":331},[320,4333,3734],{"class":345},[320,4335,4336],{"class":405},"Load",[320,4338,4339],{"class":345},"().",[320,4341,3737],{"class":405},[320,4343,3740],{"class":345},[320,4345,4346,4348,4350,4352,4354],{"class":322,"line":9},[320,4347,1120],{"class":331},[320,4349,3900],{"class":345},[320,4351,3903],{"class":331},[320,4353,3906],{"class":338},[320,4355,346],{"class":345},[320,4357,4358,4360],{"class":322,"line":361},[320,4359,4227],{"class":331},[320,4361,4362],{"class":354}," \"未知\"\n",[320,4364,4365],{"class":322,"line":26},[320,4366,1192],{"class":345},[320,4368,4369,4371,4374],{"class":322,"line":29},[320,4370,1197],{"class":331},[320,4372,4373],{"class":345}," region ",[320,4375,4376],{"class":325},"\u002F\u002F \"中国|福建省|福州市|移动\"\n",[320,4378,4379],{"class":322,"line":387},[320,4380,390],{"class":345},[676,4382,4384],{"id":4383},"_3-加一层内存缓存","3. 加一层内存缓存",[136,4386,4387,4388,4391],{},"登录记录里同一个 IP 可能反复出现，用 ",[317,4389,4390],{},"sync.Map"," 按 IP 缓存结果，一次查询终身复用：",[310,4393,4395],{"className":869,"code":4394,"language":871,"meta":315,"style":315},"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",[317,4396,4397,4412,4416,4435,4452,4463,4467,4478,4488,4495],{"__ignoreMap":315},[320,4398,4399,4401,4404,4407,4409],{"class":322,"line":15},[320,4400,951],{"class":331},[320,4402,4403],{"class":345}," regionCache ",[320,4405,4406],{"class":405},"sync",[320,4408,959],{"class":345},[320,4410,4411],{"class":405},"Map\n",[320,4413,4414],{"class":322,"line":13},[320,4415,891],{"emptyLinePlaceholder":7},[320,4417,4418,4420,4423,4425,4427,4429,4431,4433],{"class":322,"line":9},[320,4419,1070],{"class":331},[320,4421,4422],{"class":405}," ipRegionCached",[320,4424,1090],{"class":345},[320,4426,4315],{"class":1076},[320,4428,1096],{"class":331},[320,4430,1084],{"class":345},[320,4432,4322],{"class":331},[320,4434,346],{"class":345},[320,4436,4437,4439,4442,4444,4447,4449],{"class":322,"line":361},[320,4438,1120],{"class":331},[320,4440,4441],{"class":345}," v, ok ",[320,4443,1324],{"class":331},[320,4445,4446],{"class":345}," regionCache.",[320,4448,4336],{"class":405},[320,4450,4451],{"class":345},"(ip); ok {\n",[320,4453,4454,4456,4459,4461],{"class":322,"line":26},[320,4455,4227],{"class":331},[320,4457,4458],{"class":345}," v.(",[320,4460,4322],{"class":331},[320,4462,934],{"class":345},[320,4464,4465],{"class":322,"line":29},[320,4466,1192],{"class":345},[320,4468,4469,4472,4474,4476],{"class":322,"line":387},[320,4470,4471],{"class":345},"    region ",[320,4473,1324],{"class":331},[320,4475,4310],{"class":405},[320,4477,3740],{"class":345},[320,4479,4480,4483,4485],{"class":322,"line":931},[320,4481,4482],{"class":345},"    regionCache.",[320,4484,4280],{"class":405},[320,4486,4487],{"class":345},"(ip, region)\n",[320,4489,4490,4492],{"class":322,"line":937},[320,4491,1197],{"class":331},[320,4493,4494],{"class":345}," region\n",[320,4496,4497],{"class":322,"line":942},[320,4498,390],{"class":345},[676,4500,4502],{"id":4501},"_4-数据热更新不用重启","4. 数据热更新（不用重启）",[136,4504,4505,4506,4508,4509,4511],{},"ip2region 每月发布新数据。下载新文件后重新 ",[317,4507,4247],{}," 并 ",[317,4510,4280],{}," 覆盖即可，进程完全不用重启：",[310,4513,4515],{"className":869,"code":4514,"language":871,"meta":315,"style":315},"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",[317,4516,4517,4541,4554,4566,4572,4576,4588,4594],{"__ignoreMap":315},[320,4518,4519,4521,4524,4526,4529,4532,4535,4537,4539],{"class":322,"line":15},[320,4520,1070],{"class":331},[320,4522,4523],{"class":405}," reloadSearcher",[320,4525,1090],{"class":345},[320,4527,4528],{"class":1076},"newContent",[320,4530,4531],{"class":345}," []",[320,4533,4534],{"class":331},"byte",[320,4536,1084],{"class":345},[320,4538,1111],{"class":331},[320,4540,346],{"class":345},[320,4542,4543,4545,4547,4549,4551],{"class":322,"line":13},[320,4544,4239],{"class":345},[320,4546,1324],{"class":331},[320,4548,4244],{"class":345},[320,4550,4247],{"class":405},[320,4552,4553],{"class":345},"(newContent)\n",[320,4555,4556,4558,4560,4562,4564],{"class":322,"line":9},[320,4557,1120],{"class":331},[320,4559,3900],{"class":345},[320,4561,3903],{"class":331},[320,4563,3906],{"class":338},[320,4565,346],{"class":345},[320,4567,4568,4570],{"class":322,"line":361},[320,4569,4227],{"class":331},[320,4571,4230],{"class":345},[320,4573,4574],{"class":322,"line":26},[320,4575,1192],{"class":345},[320,4577,4578,4580,4582,4585],{"class":322,"line":29},[320,4579,4277],{"class":345},[320,4581,4280],{"class":405},[320,4583,4584],{"class":345},"(s) ",[320,4586,4587],{"class":325},"\u002F\u002F 原子切换，老查询继续走旧数据，新查询走新数据\n",[320,4589,4590,4592],{"class":322,"line":387},[320,4591,1197],{"class":331},[320,4593,4290],{"class":338},[320,4595,4596],{"class":322,"line":931},[320,4597,390],{"class":345},[131,4599,4600],{"id":4600},"局限与诚实提醒",[136,4602,4603],{},"无论用哪种方案，都要清楚 IP 属地的边界：",[240,4605,4606,4612,4618,4624,4630],{},[243,4607,4608,4611],{},[140,4609,4610],{},"段级精度","：网段归属是「绝大多数」准确，个别 IP（大型企业自建段、跨省调度段）可能显示到相邻地区或只到省。",[243,4613,4614,4617],{},[140,4615,4616],{},"运营商级，不是定位","：拿到的是「省｜市｜运营商」，不是精确街道或基站位置——精确位置需要 GPS 或运营商三角定位，纯 IP 归属做不到。",[243,4619,4620,4623],{},[140,4621,4622],{},"动态 IP","：家宽通常是动态 IP，重拨会变，所以「上次登录在福州，这次在广州」不一定是异常，可能是 IP 变了。",[243,4625,4626,4629],{},[140,4627,4628],{},"别用于安全判定","：用 IP 段做封禁或风控判定风险自负，段级数据不适合当精确依据。",[243,4631,4632,4635,4636,4639],{},[140,4633,4634],{},"隐私与合规","：IP 地址属于个人信息（《个人信息保护法》）。归属地展示建议只出现在",[140,4637,4638],{},"用户本人可见","的页面（登录记录、个人中心）；若在评论区等公开场合展示，建议只显示到省市并明确告知用户。",[131,4641,4642],{"id":4642},"小结",[136,4644,3492],{},[1946,4646,4647],{},[136,4648,4649,4652],{},[140,4650,4651],{},"IP 属地 = 几十万条「IP 段 → 归属地」映射 + 一次二分查找。"," 它查的是网段归属，不是精确位置。",[240,4654,4655,4661,4668],{},[243,4656,4657,4658,4660],{},"想省心、免费、稳定，用 ",[140,4659,3622],{}," 本地离线查询（注意 IPv6 盲点）；",[243,4662,4663,4664,4667],{},"想要最新最细、能接受外部依赖，用",[140,4665,4666],{},"在线 API","（记得超时降级）；",[243,4669,4670,4671,1849],{},"面向全球用户、要 IPv6 覆盖，用 ",[140,4672,4673],{},"MaxMind GeoLite2",[136,4675,4676],{},"对个人博客「登录记录显示归属地」这种场景，本地离线方案通常是性价比最高的选择。",[551,4678,4679],{},"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":315,"searchDepth":13,"depth":9,"links":4681},[4682,4683,4684,4690,4695,4696,4702,4703],{"id":3535,"depth":13,"text":3535},{"id":3567,"depth":13,"text":3568},{"id":3616,"depth":13,"text":3616,"children":4685},[4686,4687,4688,4689],{"id":3626,"depth":9,"text":3627},{"id":3641,"depth":9,"text":3642},{"id":3659,"depth":9,"text":3660},{"id":3678,"depth":9,"text":3679},{"id":3707,"depth":13,"text":3707,"children":4691},[4692,4693,4694],{"id":3713,"depth":9,"text":3714},{"id":3795,"depth":9,"text":3796},{"id":3987,"depth":9,"text":3988},{"id":4036,"depth":13,"text":4037},{"id":4091,"depth":13,"text":4092,"children":4697},[4698,4699,4700,4701],{"id":4098,"depth":9,"text":4099},{"id":4297,"depth":9,"text":4298},{"id":4383,"depth":9,"text":4384},{"id":4501,"depth":9,"text":4502},{"id":4600,"depth":13,"text":4600},{"id":4642,"depth":13,"text":4642},"科普 IP 属地查询的原理：IP 段与归属地的映射、纯本地离线查询为何能做到毫秒级，对比 ip2region、在线 API 与 MaxMind 三种方案的优劣，并附登录记录场景的 Go 实战与 IPv6 盲点提醒。","\u002Fposts\u002Fip-geolocation-guide\u002Fimg\u002Fcover.svg",{},"\u002Fposts\u002Fip-geolocation-guide","2026-08-07T12:00:00",{"title":3530,"description":4704},"posts\u002Fip-geolocation-guide\u002Findex",[4712,4713,4714],"IP","网络","科普","RY1UPgJaiKsjpC51uqMyG7w_GpCRS13_CX5j1hDD_dM",{"id":4717,"title":4718,"author":126,"body":4719,"description":5268,"difficulty":564,"draft":19,"extension":565,"image":5269,"meta":5270,"navigation":7,"path":5271,"pinned":19,"published":5272,"seo":5273,"series":5274,"seriesOrder":15,"stem":5275,"tags":5276,"updated":5278,"__hash__":5279},"posts\u002Fposts\u002Fsublog-evolution\u002Findex.md","尼克的小窝 架构变迁：从纯静态到前后端分离",{"type":128,"value":4720,"toc":5248},[4721,4724,4727,4738,4742,4746,4752,4756,4788,4791,4805,4809,4812,4881,4885,4888,4894,4898,4909,4939,4942,4946,4955,4958,5002,5006,5129,5133,5140,5144,5150,5156,5160,5163,5214,5218,5225,5232,5238,5241,5243],[131,4722,4723],{"id":3535},"📝 写在前面",[136,4725,4726],{},"SuBlog 最初只是一个简单的技术博客——Nuxt 3 静态生成，Markdown 写文章，部署到静态托管完事。但随着功能需求的增长，纯静态的架构开始显露出局限性。",[136,4728,4729,4730,4733,4734,4737],{},"这篇文章记录从 ",[140,4731,4732],{},"纯静态"," 到 ",[140,4735,4736],{},"前后端分离"," 的演进过程，带你了解每一步背后的思考。",[131,4739,4741],{"id":4740},"第一代纯静态nuxt-3-nuxtcontent","📦 第一代：纯静态（Nuxt 3 + @nuxt\u002Fcontent）",[676,4743,4745],{"id":4744},"️-架构图","🏗️ 架构图",[310,4747,4750],{"className":4748,"code":4749,"language":3593,"meta":315},[3591],"┌─────────────────────────────────────┐\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",[317,4751,4749],{"__ignoreMap":315},[676,4753,4755],{"id":4754},"做了什么","🔧 做了什么",[240,4757,4758,4768,4773,4778],{},[243,4759,4760,4762,4763,4762,4765,4767],{},[140,4761,179],{}," + ",[140,4764,189],{},[140,4766,199],{}," 搭建前端",[243,4769,4770,4772],{},[140,4771,209],{}," 管理 Markdown 文章，编译时生成静态页面",[243,4774,4775,4777],{},[140,4776,229],{}," 嵌入评论，依赖 GitHub Discussions",[243,4779,4780,4781,4784,4785,4787],{},"部署通过 ",[317,4782,4783],{},"nuxt generate"," 输出 ",[317,4786,492],{},"，丢到 Nginx 或 Vercel",[676,4789,4790],{"id":3754},"✅ 优点",[240,4792,4793,4799,4802],{},[243,4794,4795,4796,4798],{},"部署极简，一个 ",[317,4797,4783],{}," 搞定",[243,4800,4801],{},"加载飞快，全是 HTML，没有服务端开销",[243,4803,4804],{},"无需后端，零运维成本",[676,4806,4808],{"id":4807},"局限","❌ 局限",[136,4810,4811],{},"随着时间的推移，纯静态的问题越来越明显：",[154,4813,4814,4824],{},[157,4815,4816],{},[160,4817,4818,4821],{},[163,4819,4820],{},"问题",[163,4822,4823],{},"具体表现",[170,4825,4826,4837,4848,4859,4870],{},[160,4827,4828,4834],{},[175,4829,4830,4831],{},"🐢 ",[140,4832,4833],{},"内容更新慢",[175,4835,4836],{},"改一篇文章要重新构建、重新部署，不能实时发布",[160,4838,4839,4845],{},[175,4840,4841,4842],{},"👤 ",[140,4843,4844],{},"没有用户体系",[175,4846,4847],{},"无法区分读者和管理员，所有内容对所有人一致可见",[160,4849,4850,4856],{},[175,4851,4852,4853],{},"🔒 ",[140,4854,4855],{},"评论受限",[175,4857,4858],{},"Giscus 需要 GitHub 账号才能评论，门槛高",[160,4860,4861,4867],{},[175,4862,4863,4864],{},"🚫 ",[140,4865,4866],{},"无法做动态功能",[175,4868,4869],{},"点赞、收藏、阅读统计、个性化推荐……统统做不了",[160,4871,4872,4878],{},[175,4873,4874,4875],{},"🔗 ",[140,4876,4877],{},"前端锁死",[175,4879,4880],{},"被 Nuxt\u002FVue 生态绑定，想换技术栈得重写",[131,4882,4884],{"id":4883},"第二代前后端分离react-go","⚡ 第二代：前后端分离（React + Go）",[676,4886,4745],{"id":4887},"️-架构图-1",[310,4889,4892],{"className":4890,"code":4891,"language":3593,"meta":315},[3591],"┌───────────────────────┐       ┌────────────────────┐\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",[317,4893,4891],{"__ignoreMap":315},[676,4895,4897],{"id":4896},"️-前端sublog-react","🖥️ 前端：sublog-react",[136,4899,4900,4901,4904,4905,4908],{},"选用 ",[140,4902,4903],{},"Next.js","（App Router） + ",[140,4906,4907],{},"React 19","，理由是：",[240,4910,4911,4918,4925,4932],{},[243,4912,4913,4914,4917],{},"🎨 ",[140,4915,4916],{},"React 生态","——组件库成熟，开发效率高",[243,4919,4920,4921,4924],{},"📐 ",[140,4922,4923],{},"App Router","——支持布局嵌套，页面组织清晰",[243,4926,4927,4928,4931],{},"📦 ",[140,4929,4930],{},"静态导出","——仍可部署到静态托管",[243,4933,4934,4935,4938],{},"⚙️ ",[140,4936,4937],{},"运行时配置","——API 地址可动态注入，切换环境无需重新构建",[136,4940,4941],{},"前端不再直接读取 Markdown 文件，而是通过 HTTP API 获取文章数据。静态导出 + 客户端数据获取的组合，既保留了首屏加载速度，又实现了内容的动态更新。",[676,4943,4945],{"id":4944},"️-后端sublog-s","⚙️ 后端：sublog-s",[136,4947,4948,4949,4762,4951,4954],{},"后端使用 ",[140,4950,2236],{},[140,4952,4953],{},"Gin","，按业务逻辑分层组织代码。",[136,4956,4957],{},"核心技术选型：",[154,4959,4960,4968],{},[157,4961,4962],{},[160,4963,4964,4966],{},[163,4965,165],{},[163,4967,168],{},[170,4969,4970,4980,4991],{},[160,4971,4972,4977],{},[175,4973,4974,4975],{},"🚀 ",[140,4976,4953],{},[175,4978,4979],{},"HTTP 框架",[160,4981,4982,4988],{},[175,4983,4984,4985],{},"🗄️ ",[140,4986,4987],{},"GORM",[175,4989,4990],{},"ORM，数据库操作",[160,4992,4993,4999],{},[175,4994,4995,4996],{},"🔐 ",[140,4997,4998],{},"JWT",[175,5000,5001],{},"用户认证",[131,5003,5005],{"id":5004},"前后对比","📊 前后对比",[154,5007,5008,5021],{},[157,5009,5010],{},[160,5011,5012,5015,5018],{},[163,5013,5014],{},"维度",[163,5016,5017],{},"第一代（纯静态）",[163,5019,5020],{},"第二代（前后端分离）",[170,5022,5023,5036,5049,5063,5076,5089,5103,5115],{},[160,5024,5025,5030,5033],{},[175,5026,4913,5027],{},[140,5028,5029],{},"前端框架",[175,5031,5032],{},"Nuxt 3 (Vue)",[175,5034,5035],{},"Next.js (React 19)",[160,5037,5038,5044,5046],{},[175,5039,5040,5041],{},"🖥️ ",[140,5042,5043],{},"后端",[175,5045,2309],{},[175,5047,5048],{},"Go + Gin",[160,5050,5051,5057,5060],{},[175,5052,5053,5054],{},"📄 ",[140,5055,5056],{},"内容来源",[175,5058,5059],{},"Markdown 文件",[175,5061,5062],{},"API 数据库",[160,5064,5065,5070,5073],{},[175,5066,4974,5067],{},[140,5068,5069],{},"发布方式",[175,5071,5072],{},"全量重新构建",[175,5074,5075],{},"API 实时发布",[160,5077,5078,5083,5086],{},[175,5079,4841,5080],{},[140,5081,5082],{},"用户系统",[175,5084,5085],{},"❌ 无",[175,5087,5088],{},"✅ 注册\u002F登录",[160,5090,5091,5097,5100],{},[175,5092,5093,5094],{},"💬 ",[140,5095,5096],{},"评论",[175,5098,5099],{},"Giscus（需 GitHub）",[175,5101,5102],{},"自有评论系统（规划中）",[160,5104,5105,5109,5112],{},[175,5106,4927,5107],{},[140,5108,465],{},[175,5110,5111],{},"1 个静态目录",[175,5113,5114],{},"前端静态 + 后端二进制",[160,5116,5117,5123,5126],{},[175,5118,5119,5120],{},"⚡ ",[140,5121,5122],{},"更新体验",[175,5124,5125],{},"改文章 → 构建 → 部署",[175,5127,5128],{},"改文章 → 立即生效",[131,5130,5132],{"id":5131},"新功能注册账号","🎉 新功能：注册账号",[136,5134,5135,5136,5139],{},"伴随着架构升级，",[140,5137,5138],{},"用户账号系统","也上线了。",[676,5141,5143],{"id":5142},"目前支持","📋 目前支持",[136,5145,5146,5147,690],{},"现在你可以在尼克的小窝 ",[140,5148,5149],{},"注册一个账号",[310,5151,5154],{"className":5152,"code":5153,"language":3593},[3591],"1. ✏️ 填写用户名、邮箱、密码\n2. 📧 查收激活邮件\n3. 🔗 点击链接激活账号\n4. 🚪 登录后查看和管理个人信息\n",[317,5155,5153],{"__ignoreMap":315},[131,5157,5159],{"id":5158},"️-后续计划","🗓️ 后续计划",[136,5161,5162],{},"正在开发或规划中的功能：",[240,5164,5166,5172,5178,5184,5190,5196,5202,5208],{"className":5165},[500],[243,5167,5169,5171],{"className":5168},[504],[506,5170],{"checked":7,"disabled":7,"type":508}," 用户注册\u002F登录（已上线 🎉）",[243,5173,5175,5177],{"className":5174},[504],[506,5176],{"checked":7,"disabled":7,"type":508}," 个人资料编辑（已上线 🎉）",[243,5179,5181,5183],{"className":5180},[504],[506,5182],{"disabled":7,"type":508}," 💬 站内评论系统",[243,5185,5187,5189],{"className":5186},[504],[506,5188],{"disabled":7,"type":508}," 🔖 文章收藏",[243,5191,5193,5195],{"className":5192},[504],[506,5194],{"disabled":7,"type":508}," 📚 阅读历史",[243,5197,5199,5201],{"className":5198},[504],[506,5200],{"disabled":7,"type":508}," 👍 文章点赞",[243,5203,5205,5207],{"className":5204},[504],[506,5206],{"disabled":7,"type":508}," 🔔 关注通知",[243,5209,5211,5213],{"className":5210},[504],[506,5212],{"disabled":7,"type":508}," 🎨 个人首页定制",[131,5215,5217],{"id":5216},"结语","💡 结语",[136,5219,5220,5221,5224],{},"从 Nuxt 3 纯静态到 React + Go 前后端分离，SuBlog 的架构变迁本质上是一个",[140,5222,5223],{},"功能驱动","的过程。静态博客有它的位置——写写文章、展示内容完全够用。但当你想做的不仅仅是\"展示\"时，后端的能力就不可或缺了。",[136,5226,5227,5228,5231],{},"这次重构最大的感触是：",[140,5229,5230],{},"架构选型不是越复杂越好，而是刚好够用最好","。纯静态阶段用 Nuxt 3 一点问题没有，直到功能需求超出了静态的边界，才自然推动到前后端分离。",[136,5233,5234,5235],{},"如果你也在纠结\"要不要上后端\"，不妨先问问自己：",[140,5236,5237],{},"你要做的是\"展示内容\"还是\"提供服务\"？",[136,5239,5240],{},"前者，静态就够了；后者，来吧，后端欢迎你。😄",[543,5242],{},[136,5244,5245],{},[140,5246,5247],{},"🎉 目前注册功能已经可以用了，后续更多好玩的功能正在路上。部分功能可能需要登录才能体验，快注册一个账号吧！",{"title":315,"searchDepth":13,"depth":9,"links":5249},[5250,5251,5257,5262,5263,5266,5267],{"id":3535,"depth":13,"text":4723},{"id":4740,"depth":13,"text":4741,"children":5252},[5253,5254,5255,5256],{"id":4744,"depth":9,"text":4745},{"id":4754,"depth":9,"text":4755},{"id":3754,"depth":9,"text":4790},{"id":4807,"depth":9,"text":4808},{"id":4883,"depth":13,"text":4884,"children":5258},[5259,5260,5261],{"id":4887,"depth":9,"text":4745},{"id":4896,"depth":9,"text":4897},{"id":4944,"depth":9,"text":4945},{"id":5004,"depth":13,"text":5005},{"id":5131,"depth":13,"text":5132,"children":5264},[5265],{"id":5142,"depth":9,"text":5143},{"id":5158,"depth":13,"text":5159},{"id":5216,"depth":13,"text":5217},"记录尼克的小窝从 Nuxt 3 纯静态博客演进到 React + Go 前后端分离架构的全过程，包括架构设计的思考、技术选型的变化，以及新增的账号注册功能。","\u002Fposts\u002Fsublog-evolution\u002Fimg\u002Fcover.jpg",{},"\u002Fposts\u002Fsublog-evolution","2026-07-23T12:00:00",{"title":4718,"description":5268},"尼克的小窝 开发记录","posts\u002Fsublog-evolution\u002Findex",[5277,2237,4903,2236],"架构","2026-07-23","VMQ6A2K8GQ5t3DSlezWPd3KgXnmDVMn9QVeaI28Hcp8",{"id":5281,"title":5282,"author":126,"body":5283,"description":6694,"difficulty":564,"draft":19,"extension":565,"image":6695,"meta":6696,"navigation":7,"path":6697,"pinned":19,"published":6698,"seo":6699,"series":126,"seriesOrder":126,"stem":6700,"tags":6701,"updated":6698,"__hash__":6707},"posts\u002Fposts\u002Fjira-cli-intro\u002Findex.md","jira_cli — 把企业 Jira 装进命令行，AI Agent 直接调用",{"type":128,"value":5284,"toc":6673},[5285,5289,5304,5373,5376,5379,5455,5458,5491,5498,5501,5505,5621,5625,5757,5761,5830,5833,5899,5902,5905,5909,5912,5932,5935,5950,5953,5957,5964,6102,6113,6144,6148,6151,6248,6252,6255,6375,6379,6382,6446,6449,6453,6456,6524,6527,6533,6536,6570,6577,6580,6640,6642,6645,6662,6670],[131,5286,5288],{"id":5287},"jira_cli-是什么","jira_cli 是什么",[136,5290,5291,5296,5297,1235,5300,5303],{},[535,5292,5295],{"href":5293,"rel":5294},"https:\u002F\u002Fgithub.com\u002Fyourname\u002Fjira_cli",[539],"jira_cli"," 是一个面向企业 Jira（Server \u002F Data Center）的本地命令行工具，Go 语言编写，MIT 协议开源。它同时提供 ",[140,5298,5299],{},"Cobra CLI",[140,5301,5302],{},"MCP stdio server"," 两种入口，既能当日常命令行用，又能直接接入 AI 编程工具。",[310,5305,5307],{"className":471,"code":5306,"language":473,"meta":315,"style":315},"# 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",[317,5308,5309,5314,5327,5339,5354,5358,5363],{"__ignoreMap":315},[320,5310,5311],{"class":322,"line":15},[320,5312,5313],{"class":325},"# CLI 日常使用\n",[320,5315,5316,5318,5321,5324],{"class":322,"line":13},[320,5317,5295],{"class":405},[320,5319,5320],{"class":354}," issue",[320,5322,5323],{"class":354}," search",[320,5325,5326],{"class":354}," \"project = PROJ ORDER BY updated DESC\"\n",[320,5328,5329,5331,5333,5336],{"class":322,"line":9},[320,5330,5295],{"class":405},[320,5332,5320],{"class":354},[320,5334,5335],{"class":354}," get",[320,5337,5338],{"class":354}," PROJ-123\n",[320,5340,5341,5343,5345,5348,5351],{"class":322,"line":361},[320,5342,5295],{"class":405},[320,5344,5320],{"class":354},[320,5346,5347],{"class":354}," comment",[320,5349,5350],{"class":354}," PROJ-123",[320,5352,5353],{"class":354}," \"处理完成，请测试验证\"\n",[320,5355,5356],{"class":322,"line":26},[320,5357,891],{"emptyLinePlaceholder":7},[320,5359,5360],{"class":322,"line":29},[320,5361,5362],{"class":325},"# MCP 模式 — 给 AI 装上操作 Jira 的手\n",[320,5364,5365,5367,5370],{"class":322,"line":387},[320,5366,5295],{"class":405},[320,5368,5369],{"class":354}," mcp",[320,5371,5372],{"class":338}," --user=work\n",[131,5374,5375],{"id":5375},"为什么需要它",[136,5377,5378],{},"大多数 Jira 命令行工具只解决\"开发者手动操作 Jira\"这一个场景。但 2026 年 AI 编程工具已经普及，你的 AI Agent（Claude Code、Codex、Cursor 等）也需要读写 Jira。痛点就来了：",[154,5380,5381,5391],{},[157,5382,5383],{},[160,5384,5385,5388],{},[163,5386,5387],{},"痛点",[163,5389,5390],{},"jira_cli 怎么解",[170,5392,5393,5401,5409,5421,5432,5443],{},[160,5394,5395,5398],{},[175,5396,5397],{},"企业 Jira 在内网，没有公网 API",[175,5399,5400],{},"本地 stdio MCP server，不经过外网",[160,5402,5403,5406],{},[175,5404,5405],{},"AI 可能误删、误写 Jira 数据",[175,5407,5408],{},"账号级只读策略，AI 只能查不能改",[160,5410,5411,5414],{},[175,5412,5413],{},"团队多人多 Jira 实例",[175,5415,5416,5417,5420],{},"多 Profile 管理，",[317,5418,5419],{},"--user=work"," 切换",[160,5422,5423,5426],{},[175,5424,5425],{},"Windows 终端体验差",[175,5427,5428,5429,5431],{},"原生 ",[317,5430,1971],{}," 构建 + 一键安装脚本",[160,5433,5434,5437],{},[175,5435,5436],{},"AI 生成 JQL 困难",[175,5438,5439,5440],{},"字段元数据发现：",[317,5441,5442],{},"field list --query xxx",[160,5444,5445,5448],{},[175,5446,5447],{},"MCP 直接拿原始 JSON 太冗长",[175,5449,5450,5451,5454],{},"紧凑视图 + ",[317,5452,5453],{},"--view summary"," 减少 token 消耗",[131,5456,5457],{"id":5457},"安装",[310,5459,5461],{"className":471,"code":5460,"language":473,"meta":315,"style":315},"# 从源码安装（要求 Go 1.26.3+）\ngo install github.com\u002Fyourname\u002Fjira_cli@latest\n\n# 或下载预编译二进制\n# https:\u002F\u002Fgithub.com\u002Fyourname\u002Fjira_cli\u002Freleases\n",[317,5462,5463,5468,5477,5481,5486],{"__ignoreMap":315},[320,5464,5465],{"class":322,"line":15},[320,5466,5467],{"class":325},"# 从源码安装（要求 Go 1.26.3+）\n",[320,5469,5470,5472,5474],{"class":322,"line":13},[320,5471,871],{"class":405},[320,5473,2400],{"class":354},[320,5475,5476],{"class":354}," github.com\u002Fyourname\u002Fjira_cli@latest\n",[320,5478,5479],{"class":322,"line":9},[320,5480,891],{"emptyLinePlaceholder":7},[320,5482,5483],{"class":322,"line":361},[320,5484,5485],{"class":325},"# 或下载预编译二进制\n",[320,5487,5488],{"class":322,"line":26},[320,5489,5490],{"class":325},"# https:\u002F\u002Fgithub.com\u002Fyourname\u002Fjira_cli\u002Freleases\n",[136,5492,5493,5494,5497],{},"Windows 用户下载 zip 包解压后双击 ",[317,5495,5496],{},"install.bat"," 即可全局安装，自动加入 PATH。",[131,5499,5500],{"id":5500},"快速上手",[676,5502,5504],{"id":5503},"_1-配置认证","1. 配置认证",[310,5506,5508],{"className":471,"code":5507,"language":473,"meta":315,"style":315},"# 设置 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",[317,5509,5510,5515,5531,5535,5540,5562,5566,5571,5602,5606,5611],{"__ignoreMap":315},[320,5511,5512],{"class":322,"line":15},[320,5513,5514],{"class":325},"# 设置 Jira 地址\n",[320,5516,5517,5519,5522,5525,5528],{"class":322,"line":13},[320,5518,5295],{"class":405},[320,5520,5521],{"class":354}," set",[320,5523,5524],{"class":354}," base-url",[320,5526,5527],{"class":354}," https:\u002F\u002Fjira.your-company.com",[320,5529,5530],{"class":338}," --global\n",[320,5532,5533],{"class":322,"line":9},[320,5534,891],{"emptyLinePlaceholder":7},[320,5536,5537],{"class":322,"line":361},[320,5538,5539],{"class":325},"# Token 认证（推荐）\n",[320,5541,5542,5544,5546,5549,5552,5555,5558,5560],{"class":322,"line":26},[320,5543,5295],{"class":405},[320,5545,5521],{"class":354},[320,5547,5548],{"class":354}," token",[320,5550,5551],{"class":331}," \u003C",[320,5553,5554],{"class":354},"your-toke",[320,5556,5557],{"class":345},"n",[320,5559,3453],{"class":331},[320,5561,5530],{"class":338},[320,5563,5564],{"class":322,"line":29},[320,5565,891],{"emptyLinePlaceholder":7},[320,5567,5568],{"class":322,"line":387},[320,5569,5570],{"class":325},"# 或用户名密码认证\n",[320,5572,5573,5575,5577,5580,5582,5585,5588,5590,5592,5595,5598,5600],{"class":322,"line":931},[320,5574,5295],{"class":405},[320,5576,5521],{"class":354},[320,5578,5579],{"class":354}," auth",[320,5581,5551],{"class":331},[320,5583,5584],{"class":354},"usernam",[320,5586,5587],{"class":345},"e",[320,5589,3453],{"class":331},[320,5591,5551],{"class":331},[320,5593,5594],{"class":354},"passwor",[320,5596,5597],{"class":345},"d",[320,5599,3453],{"class":331},[320,5601,5530],{"class":338},[320,5603,5604],{"class":322,"line":937},[320,5605,891],{"emptyLinePlaceholder":7},[320,5607,5608],{"class":322,"line":942},[320,5609,5610],{"class":325},"# 验证一下\n",[320,5612,5613,5615,5618],{"class":322,"line":948},[320,5614,5295],{"class":405},[320,5616,5617],{"class":354}," user",[320,5619,5620],{"class":354}," me\n",[676,5622,5624],{"id":5623},"_2-日常使用","2. 日常使用",[310,5626,5628],{"className":471,"code":5627,"language":473,"meta":315,"style":315},"# 查自己今天的任务\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",[317,5629,5630,5635,5646,5650,5655,5665,5669,5674,5688,5705,5709,5714,5727,5731,5736],{"__ignoreMap":315},[320,5631,5632],{"class":322,"line":15},[320,5633,5634],{"class":325},"# 查自己今天的任务\n",[320,5636,5637,5639,5641,5643],{"class":322,"line":13},[320,5638,5295],{"class":405},[320,5640,5320],{"class":354},[320,5642,5323],{"class":354},[320,5644,5645],{"class":354}," \"assignee = currentUser() ORDER BY updated DESC\"\n",[320,5647,5648],{"class":322,"line":9},[320,5649,891],{"emptyLinePlaceholder":7},[320,5651,5652],{"class":322,"line":361},[320,5653,5654],{"class":325},"# 看某个任务详情\n",[320,5656,5657,5659,5661,5663],{"class":322,"line":26},[320,5658,5295],{"class":405},[320,5660,5320],{"class":354},[320,5662,5335],{"class":354},[320,5664,5338],{"class":354},[320,5666,5667],{"class":322,"line":29},[320,5668,891],{"emptyLinePlaceholder":7},[320,5670,5671],{"class":322,"line":387},[320,5672,5673],{"class":325},"# 流转状态\n",[320,5675,5676,5678,5680,5683,5685],{"class":322,"line":931},[320,5677,5295],{"class":405},[320,5679,5320],{"class":354},[320,5681,5682],{"class":354}," transitions",[320,5684,5350],{"class":354},[320,5686,5687],{"class":325},"                    # 可以转哪些\n",[320,5689,5690,5692,5694,5697,5699,5702],{"class":322,"line":937},[320,5691,5295],{"class":405},[320,5693,5320],{"class":354},[320,5695,5696],{"class":354}," do-transition",[320,5698,5350],{"class":354},[320,5700,5701],{"class":338}," 21",[320,5703,5704],{"class":325},"               # 执行流转\n",[320,5706,5707],{"class":322,"line":942},[320,5708,891],{"emptyLinePlaceholder":7},[320,5710,5711],{"class":322,"line":948},[320,5712,5713],{"class":325},"# 添加备注\n",[320,5715,5716,5718,5720,5722,5724],{"class":322,"line":965},[320,5717,5295],{"class":405},[320,5719,5320],{"class":354},[320,5721,5347],{"class":354},[320,5723,5350],{"class":354},[320,5725,5726],{"class":354}," \"已定位到问题，正在修复\"\n",[320,5728,5729],{"class":322,"line":970},[320,5730,891],{"emptyLinePlaceholder":7},[320,5732,5733],{"class":322,"line":68},[320,5734,5735],{"class":325},"# 创建任务\n",[320,5737,5738,5740,5742,5745,5748,5751,5754],{"class":322,"line":1014},[320,5739,5295],{"class":405},[320,5741,5320],{"class":354},[320,5743,5744],{"class":354}," create",[320,5746,5747],{"class":354}," PROJ",[320,5749,5750],{"class":354}," Task",[320,5752,5753],{"class":338}," --fields",[320,5755,5756],{"class":354}," @fields.json\n",[676,5758,5760],{"id":5759},"_3-配给-ai-使用","3. 配给 AI 使用",[310,5762,5764],{"className":471,"code":5763,"language":473,"meta":315,"style":315},"# 先配置一个只读账号给 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",[317,5765,5766,5771,5789,5812],{"__ignoreMap":315},[320,5767,5768],{"class":322,"line":15},[320,5769,5770],{"class":325},"# 先配置一个只读账号给 AI 用\n",[320,5772,5773,5775,5777,5779,5781,5784,5787],{"class":322,"line":13},[320,5774,5295],{"class":405},[320,5776,5521],{"class":354},[320,5778,5524],{"class":354},[320,5780,5527],{"class":354},[320,5782,5783],{"class":338}," --name",[320,5785,5786],{"class":354}," ai",[320,5788,5530],{"class":338},[320,5790,5791,5793,5795,5797,5799,5802,5804,5806,5808,5810],{"class":322,"line":9},[320,5792,5295],{"class":405},[320,5794,5521],{"class":354},[320,5796,5548],{"class":354},[320,5798,5551],{"class":331},[320,5800,5801],{"class":354},"toke",[320,5803,5557],{"class":345},[320,5805,3453],{"class":331},[320,5807,5783],{"class":338},[320,5809,5786],{"class":354},[320,5811,5530],{"class":338},[320,5813,5814,5816,5818,5821,5824,5826,5828],{"class":322,"line":361},[320,5815,5295],{"class":405},[320,5817,5521],{"class":354},[320,5819,5820],{"class":354}," readonly",[320,5822,5823],{"class":354}," on",[320,5825,5783],{"class":338},[320,5827,5786],{"class":354},[320,5829,5530],{"class":338},[136,5831,5832],{},"然后在 AI 工具的 MCP 配置里加上：",[310,5834,5836],{"className":831,"code":5835,"language":833,"meta":315,"style":315},"{\n  \"mcpServers\": {\n    \"jira\": {\n      \"command\": \"jira_cli\",\n      \"args\": [\"mcp\", \"--user=ai\"]\n    }\n  }\n}\n",[317,5837,5838,5843,5850,5857,5869,5886,5890,5895],{"__ignoreMap":315},[320,5839,5840],{"class":322,"line":15},[320,5841,5842],{"class":345},"{\n",[320,5844,5845,5848],{"class":322,"line":13},[320,5846,5847],{"class":338},"  \"mcpServers\"",[320,5849,848],{"class":345},[320,5851,5852,5855],{"class":322,"line":9},[320,5853,5854],{"class":338},"    \"jira\"",[320,5856,848],{"class":345},[320,5858,5859,5862,5864,5867],{"class":322,"line":361},[320,5860,5861],{"class":338},"      \"command\"",[320,5863,415],{"class":345},[320,5865,5866],{"class":354},"\"jira_cli\"",[320,5868,358],{"class":345},[320,5870,5871,5874,5876,5879,5881,5884],{"class":322,"line":26},[320,5872,5873],{"class":338},"      \"args\"",[320,5875,446],{"class":345},[320,5877,5878],{"class":354},"\"mcp\"",[320,5880,452],{"class":345},[320,5882,5883],{"class":354},"\"--user=ai\"",[320,5885,458],{"class":345},[320,5887,5888],{"class":322,"line":29},[320,5889,1192],{"class":345},[320,5891,5892],{"class":322,"line":387},[320,5893,5894],{"class":345},"  }\n",[320,5896,5897],{"class":322,"line":931},[320,5898,390],{"class":345},[136,5900,5901],{},"这样 AI 就能查 Jira 但不能修改，既有了上下文，又不用担心误操作。",[131,5903,5904],{"id":5904},"亮点",[676,5906,5908],{"id":5907},"_1-账号级只读策略","1. 账号级只读策略",[136,5910,5911],{},"这是 jira_cli 最实用的设计。给 AI Agent 配置只读账号：",[310,5913,5915],{"className":471,"code":5914,"language":473,"meta":315,"style":315},"jira_cli set readonly on --name ai\n",[317,5916,5917],{"__ignoreMap":315},[320,5918,5919,5921,5923,5925,5927,5929],{"class":322,"line":15},[320,5920,5295],{"class":405},[320,5922,5521],{"class":354},[320,5924,5820],{"class":354},[320,5926,5823],{"class":354},[320,5928,5783],{"class":338},[320,5930,5931],{"class":354}," ai\n",[136,5933,5934],{},"只读模式下：",[240,5936,5937,5940,5943],{},[243,5938,5939],{},"CLI 写命令被隐藏和拒绝",[243,5941,5942],{},"MCP 的写入工具不会注册",[243,5944,5945,5946,5949],{},"环境变量 ",[317,5947,5948],{},"JIRA_READONLY=true"," 也可以临时覆盖",[136,5951,5952],{},"AI 只能查 JQL、看任务、查字段、读评论，绝对不会误改数据。需要创建\u002F更新时，另配一个有写入权限的账号由开发者自己操作。",[676,5954,5956],{"id":5955},"_2-多-profile-切换","2. 多 Profile 切换",[136,5958,5959,5960,5963],{},"一个配置文件里管理多个 Jira 账号，",[317,5961,5962],{},"--user"," 切换：",[310,5965,5967],{"className":471,"code":5966,"language":473,"meta":315,"style":315},"# 工作账号\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",[317,5968,5969,5974,5992,6015,6019,6024,6042,6065,6069,6074,6088],{"__ignoreMap":315},[320,5970,5971],{"class":322,"line":15},[320,5972,5973],{"class":325},"# 工作账号\n",[320,5975,5976,5978,5980,5982,5985,5987,5990],{"class":322,"line":13},[320,5977,5295],{"class":405},[320,5979,5521],{"class":354},[320,5981,5524],{"class":354},[320,5983,5984],{"class":354}," https:\u002F\u002Fjira.company.com",[320,5986,5783],{"class":338},[320,5988,5989],{"class":354}," work",[320,5991,5530],{"class":338},[320,5993,5994,5996,5998,6000,6002,6005,6007,6009,6011,6013],{"class":322,"line":9},[320,5995,5295],{"class":405},[320,5997,5521],{"class":354},[320,5999,5548],{"class":354},[320,6001,5551],{"class":331},[320,6003,6004],{"class":354},"work-toke",[320,6006,5557],{"class":345},[320,6008,3453],{"class":331},[320,6010,5783],{"class":338},[320,6012,5989],{"class":354},[320,6014,5530],{"class":338},[320,6016,6017],{"class":322,"line":361},[320,6018,891],{"emptyLinePlaceholder":7},[320,6020,6021],{"class":322,"line":26},[320,6022,6023],{"class":325},"# 个人账号\n",[320,6025,6026,6028,6030,6032,6035,6037,6040],{"class":322,"line":29},[320,6027,5295],{"class":405},[320,6029,5521],{"class":354},[320,6031,5524],{"class":354},[320,6033,6034],{"class":354}," https:\u002F\u002Fjira.personal.com",[320,6036,5783],{"class":338},[320,6038,6039],{"class":354}," personal",[320,6041,5530],{"class":338},[320,6043,6044,6046,6048,6050,6052,6055,6057,6059,6061,6063],{"class":322,"line":387},[320,6045,5295],{"class":405},[320,6047,5521],{"class":354},[320,6049,5548],{"class":354},[320,6051,5551],{"class":331},[320,6053,6054],{"class":354},"personal-toke",[320,6056,5557],{"class":345},[320,6058,3453],{"class":331},[320,6060,5783],{"class":338},[320,6062,6039],{"class":354},[320,6064,5530],{"class":338},[320,6066,6067],{"class":322,"line":931},[320,6068,891],{"emptyLinePlaceholder":7},[320,6070,6071],{"class":322,"line":937},[320,6072,6073],{"class":325},"# 使用时切换\n",[320,6075,6076,6078,6081,6083,6085],{"class":322,"line":942},[320,6077,5295],{"class":405},[320,6079,6080],{"class":338}," --user=work",[320,6082,5320],{"class":354},[320,6084,5323],{"class":354},[320,6086,6087],{"class":354}," \"project = PROJ\"\n",[320,6089,6090,6092,6095,6097,6099],{"class":322,"line":948},[320,6091,5295],{"class":405},[320,6093,6094],{"class":338}," --user=personal",[320,6096,5320],{"class":354},[320,6098,5323],{"class":354},[320,6100,6101],{"class":354}," \"project = SIDE\"\n",[136,6103,6104,6105,6108,6109,6112],{},"同时支持",[140,6106,6107],{},"项目级配置","（",[317,6110,6111],{},"--local","）和工作目录绑定：",[310,6114,6116],{"className":471,"code":6115,"language":473,"meta":315,"style":315},"# 在项目目录下，配置只对当前项目生效\ncd ~\u002Fprojects\u002Fbackend\njira_cli set base-url https:\u002F\u002Fjira-backend.company.com --local\n",[317,6117,6118,6123,6130],{"__ignoreMap":315},[320,6119,6120],{"class":322,"line":15},[320,6121,6122],{"class":325},"# 在项目目录下，配置只对当前项目生效\n",[320,6124,6125,6127],{"class":322,"line":13},[320,6126,1662],{"class":338},[320,6128,6129],{"class":354}," ~\u002Fprojects\u002Fbackend\n",[320,6131,6132,6134,6136,6138,6141],{"class":322,"line":9},[320,6133,5295],{"class":405},[320,6135,5521],{"class":354},[320,6137,5524],{"class":354},[320,6139,6140],{"class":354}," https:\u002F\u002Fjira-backend.company.com",[320,6142,6143],{"class":338}," --local\n",[676,6145,6147],{"id":6146},"_3-对-ai-友好的输出","3. 对 AI 友好的输出",[136,6149,6150],{},"jira_cli 在输出设计上充分考虑了 AI Agent 的 token 消耗：",[310,6152,6154],{"className":471,"code":6153,"language":473,"meta":315,"style":315},"# 紧凑视图 — 去掉 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",[317,6155,6156,6161,6177,6181,6186,6203,6207,6212,6227,6231,6236],{"__ignoreMap":315},[320,6157,6158],{"class":322,"line":15},[320,6159,6160],{"class":325},"# 紧凑视图 — 去掉 self\u002Favatar\u002F缩略图等无用字段\n",[320,6162,6163,6165,6167,6169,6171,6174],{"class":322,"line":13},[320,6164,5295],{"class":405},[320,6166,5320],{"class":354},[320,6168,5335],{"class":354},[320,6170,5350],{"class":354},[320,6172,6173],{"class":338}," --view",[320,6175,6176],{"class":354}," summary\n",[320,6178,6179],{"class":322,"line":9},[320,6180,891],{"emptyLinePlaceholder":7},[320,6182,6183],{"class":322,"line":361},[320,6184,6185],{"class":325},"# 表格输出 — 一眼看到关键信息\n",[320,6187,6188,6190,6192,6194,6197,6200],{"class":322,"line":26},[320,6189,5295],{"class":405},[320,6191,5320],{"class":354},[320,6193,5323],{"class":354},[320,6195,6196],{"class":354}," \"project = PROJ\"",[320,6198,6199],{"class":338}," --output",[320,6201,6202],{"class":354}," table\n",[320,6204,6205],{"class":322,"line":29},[320,6206,891],{"emptyLinePlaceholder":7},[320,6208,6209],{"class":322,"line":387},[320,6210,6211],{"class":325},"# JSON 输出 — 程序解析，不转义 Unicode\n",[320,6213,6214,6216,6218,6221,6223,6225],{"class":322,"line":931},[320,6215,5295],{"class":405},[320,6217,6199],{"class":338},[320,6219,6220],{"class":354}," json",[320,6222,5320],{"class":354},[320,6224,5335],{"class":354},[320,6226,5338],{"class":354},[320,6228,6229],{"class":322,"line":937},[320,6230,891],{"emptyLinePlaceholder":7},[320,6232,6233],{"class":322,"line":942},[320,6234,6235],{"class":325},"# 自定义搜索列 — 只要需要的字段\n",[320,6237,6238,6240,6242,6245],{"class":322,"line":948},[320,6239,5295],{"class":405},[320,6241,5521],{"class":354},[320,6243,6244],{"class":354}," search-columns",[320,6246,6247],{"class":354}," key,summary,fixVersions\n",[676,6249,6251],{"id":6250},"_4-字段元数据发现","4. 字段元数据发现",[136,6253,6254],{},"Jira 的 customfield 让人头疼，jira_cli 提供了完整的字段发现能力：",[310,6256,6258],{"className":471,"code":6257,"language":473,"meta":315,"style":315},"# 列出所有字段及其类型\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",[317,6259,6260,6265,6275,6279,6284,6299,6303,6308,6319,6323,6328,6345,6349,6354],{"__ignoreMap":315},[320,6261,6262],{"class":322,"line":15},[320,6263,6264],{"class":325},"# 列出所有字段及其类型\n",[320,6266,6267,6269,6272],{"class":322,"line":13},[320,6268,5295],{"class":405},[320,6270,6271],{"class":354}," field",[320,6273,6274],{"class":354}," list\n",[320,6276,6277],{"class":322,"line":9},[320,6278,891],{"emptyLinePlaceholder":7},[320,6280,6281],{"class":322,"line":361},[320,6282,6283],{"class":325},"# 搜索包含特定关键词的字段\n",[320,6285,6286,6288,6290,6293,6296],{"class":322,"line":26},[320,6287,5295],{"class":405},[320,6289,6271],{"class":354},[320,6291,6292],{"class":354}," list",[320,6294,6295],{"class":338}," --query",[320,6297,6298],{"class":354}," \"版本\"\n",[320,6300,6301],{"class":322,"line":29},[320,6302,891],{"emptyLinePlaceholder":7},[320,6304,6305],{"class":322,"line":387},[320,6306,6307],{"class":325},"# 只看自定义字段\n",[320,6309,6310,6312,6314,6316],{"class":322,"line":931},[320,6311,5295],{"class":405},[320,6313,6271],{"class":354},[320,6315,6292],{"class":354},[320,6317,6318],{"class":338}," --custom-only\n",[320,6320,6321],{"class":322,"line":937},[320,6322,891],{"emptyLinePlaceholder":7},[320,6324,6325],{"class":322,"line":942},[320,6326,6327],{"class":325},"# 创建任务前，先看看需要哪些字段\n",[320,6329,6330,6332,6334,6337,6339,6342],{"class":322,"line":948},[320,6331,5295],{"class":405},[320,6333,5320],{"class":354},[320,6335,6336],{"class":354}," create-meta",[320,6338,5747],{"class":354},[320,6340,6341],{"class":338}," --issue-type",[320,6343,6344],{"class":354}," Task\n",[320,6346,6347],{"class":322,"line":965},[320,6348,891],{"emptyLinePlaceholder":7},[320,6350,6351],{"class":322,"line":970},[320,6352,6353],{"class":325},"# 分页查看可选值\n",[320,6355,6356,6358,6360,6363,6365,6367,6370,6372],{"class":322,"line":68},[320,6357,5295],{"class":405},[320,6359,5320],{"class":354},[320,6361,6362],{"class":354}," create-meta-values",[320,6364,5747],{"class":354},[320,6366,5750],{"class":354},[320,6368,6369],{"class":354}," priority",[320,6371,6295],{"class":338},[320,6373,6374],{"class":354}," High\n",[676,6376,6378],{"id":6377},"_5-mcp-写入控制","5. MCP 写入控制",[136,6380,6381],{},"除了全局只读，MCP 还有更细粒度的控制：",[154,6383,6384,6394],{},[157,6385,6386],{},[160,6387,6388,6391],{},[163,6389,6390],{},"环境变量",[163,6392,6393],{},"作用",[170,6395,6396,6406,6416,6426,6436],{},[160,6397,6398,6403],{},[175,6399,6400],{},[317,6401,6402],{},"JIRA_CREATE_OPEN=false",[175,6404,6405],{},"禁用创建\u002F更新工具",[160,6407,6408,6413],{},[175,6409,6410],{},[317,6411,6412],{},"JIRA_DISABLE_DISCLAIMER=true",[175,6414,6415],{},"去掉 AI 自动添加的备注声明",[160,6417,6418,6423],{},[175,6419,6420],{},[317,6421,6422],{},"JIRA_CREATE_SUMMARY_MAX",[175,6424,6425],{},"限制摘要长度",[160,6427,6428,6433],{},[175,6429,6430],{},[317,6431,6432],{},"JIRA_CREATE_DESCRIPTION_MAX",[175,6434,6435],{},"限制描述长度",[160,6437,6438,6443],{},[175,6439,6440],{},[317,6441,6442],{},"JIRA_CREATE_FIELDS_MAX_BYTES",[175,6444,6445],{},"限制创建字段的 JSON 大小",[136,6447,6448],{},"这些限制确保 AI 即使有写入权限也不会\"失控\"——例如防止 AI 往描述里塞几万字的废话。",[676,6450,6452],{"id":6451},"_6-中英双语帮助","6. 中英双语帮助",[136,6454,6455],{},"帮助信息默认中文，也支持一键切英文：",[310,6457,6459],{"className":471,"code":6458,"language":473,"meta":315,"style":315},"# 保存英文偏好\njira_cli set help-language en\n\n# 切回中文\njira_cli set help-language zh\n\n# 临时覆盖\njira_cli --lang en issue search -h\n",[317,6460,6461,6466,6478,6482,6487,6498,6502,6507],{"__ignoreMap":315},[320,6462,6463],{"class":322,"line":15},[320,6464,6465],{"class":325},"# 保存英文偏好\n",[320,6467,6468,6470,6472,6475],{"class":322,"line":13},[320,6469,5295],{"class":405},[320,6471,5521],{"class":354},[320,6473,6474],{"class":354}," help-language",[320,6476,6477],{"class":354}," en\n",[320,6479,6480],{"class":322,"line":9},[320,6481,891],{"emptyLinePlaceholder":7},[320,6483,6484],{"class":322,"line":361},[320,6485,6486],{"class":325},"# 切回中文\n",[320,6488,6489,6491,6493,6495],{"class":322,"line":26},[320,6490,5295],{"class":405},[320,6492,5521],{"class":354},[320,6494,6474],{"class":354},[320,6496,6497],{"class":354}," zh\n",[320,6499,6500],{"class":322,"line":29},[320,6501,891],{"emptyLinePlaceholder":7},[320,6503,6504],{"class":322,"line":387},[320,6505,6506],{"class":325},"# 临时覆盖\n",[320,6508,6509,6511,6514,6517,6519,6521],{"class":322,"line":931},[320,6510,5295],{"class":405},[320,6512,6513],{"class":338}," --lang",[320,6515,6516],{"class":354}," en",[320,6518,5320],{"class":354},[320,6520,5323],{"class":354},[320,6522,6523],{"class":338}," -h\n",[131,6525,6526],{"id":6526},"架构一览",[310,6528,6531],{"className":6529,"code":6530,"language":3593},[3591],"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",[317,6532,6530],{"__ignoreMap":315},[136,6534,6535],{},"设计原则清晰：",[240,6537,6538,6546,6554,6562],{},[243,6539,6540,6545],{},[140,6541,6542],{},[317,6543,6544],{},"pkg\u002Fjira"," 是纯 Jira 客户端，HTTP、认证、上下文处理都在这里",[243,6547,6548,6553],{},[140,6549,6550],{},[317,6551,6552],{},"cmd"," 只管 Cobra 解析和呈现",[243,6555,6556,6561],{},[140,6557,6558],{},[317,6559,6560],{},"internal\u002Ftools\u002Fjira_tools"," 只管 MCP schema 验证和结果适配",[243,6563,6564,6569],{},[140,6565,6566],{},[317,6567,6568],{},"internal\u002Fconfig"," 拥有 Profile 的全部逻辑，不分散到 cmd",[136,6571,6572,6573,6576],{},"每一层职责单一，新增 Jira 能力时按 ",[317,6574,6575],{},"endpoints.go → Client 方法 → CLI 命令 → MCP tool → 策略注册"," 的路径走就行。",[131,6578,6579],{"id":6579},"和已有方案的对比",[154,6581,6582,6594],{},[157,6583,6584],{},[160,6585,6586,6588,6591],{},[163,6587,2286],{},[163,6589,6590],{},"适合场景",[163,6592,6593],{},"jira_cli 的区别",[170,6595,6596,6607,6618,6629],{},[160,6597,6598,6601,6604],{},[175,6599,6600],{},"Atlassian Rovo MCP Server",[175,6602,6603],{},"Jira Cloud、官方 OAuth",[175,6605,6606],{},"jira_cli 是本地 stdio，适合内网 Server\u002FDC",[160,6608,6609,6612,6615],{},[175,6610,6611],{},"go-jira \u002F jira-cli 社区版",[175,6613,6614],{},"纯命令行管理",[175,6616,6617],{},"jira_cli 同时提供 MCP server，面向 AI 工作流",[160,6619,6620,6623,6626],{},[175,6621,6622],{},"直接调 REST API",[175,6624,6625],{},"自定义集成",[175,6627,6628],{},"jira_cli 封装了认证、只读策略、字段元数据",[160,6630,6631,6634,6637],{},[175,6632,6633],{},"Jira 网页",[175,6635,6636],{},"日常使用",[175,6638,6639],{},"jira_cli 把 Jira 变成终端里的一行命令",[131,6641,2164],{"id":2164},[136,6643,6644],{},"如果你的团队满足以下任一条件，jira_cli 值得一试：",[240,6646,6647,6650,6653,6656,6659],{},[243,6648,6649],{},"企业 Jira 部署在内网，想接入 AI 编程工具",[243,6651,6652],{},"想给 AI Agent 只读权限来查 Jira 上下文",[243,6654,6655],{},"需要命令行快速查看\u002F操作 Jira，不想切浏览器",[243,6657,6658],{},"需要管理多个 Jira 实例和多套账号",[243,6660,6661],{},"Windows 用户想要无摩擦的 CLI 体验",[136,6663,6664,6665,6669],{},"GitHub：",[535,6666,6668],{"href":5293,"rel":6667},[539],"github.com\u002Fyourname\u002Fjira_cli"," | MIT 协议 | Go 1.26.3+",[551,6671,6672],{},"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":315,"searchDepth":13,"depth":9,"links":6674},[6675,6676,6677,6678,6683,6691,6692,6693],{"id":5287,"depth":13,"text":5288},{"id":5375,"depth":13,"text":5375},{"id":5457,"depth":13,"text":5457},{"id":5500,"depth":13,"text":5500,"children":6679},[6680,6681,6682],{"id":5503,"depth":9,"text":5504},{"id":5623,"depth":9,"text":5624},{"id":5759,"depth":9,"text":5760},{"id":5904,"depth":13,"text":5904,"children":6684},[6685,6686,6687,6688,6689,6690],{"id":5907,"depth":9,"text":5908},{"id":5955,"depth":9,"text":5956},{"id":6146,"depth":9,"text":6147},{"id":6250,"depth":9,"text":6251},{"id":6377,"depth":9,"text":6378},{"id":6451,"depth":9,"text":6452},{"id":6526,"depth":13,"text":6526},{"id":6579,"depth":13,"text":6579},{"id":2164,"depth":13,"text":2164},"介绍 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":5282,"description":6694},"posts\u002Fjira-cli-intro\u002Findex",[6702,6703,6704,2236,6705,6706],"Jira","CLI","MCP","AI Agent","效率工具","vNH6sF2mMFmSjbHoEI2OqmYCgpFmaqyKt-MUfcQqXrU",{"id":6709,"title":6710,"author":126,"body":6711,"description":7654,"difficulty":2227,"draft":19,"extension":565,"image":7655,"meta":7656,"navigation":7,"path":591,"pinned":19,"published":7657,"seo":7658,"series":2233,"seriesOrder":13,"stem":7659,"tags":7660,"updated":7664,"__hash__":7665},"posts\u002Fposts\u002Fgo-embed-frontend\u002Findex.md","Go 单文件打包实战：把前端塞进二进制",{"type":128,"value":6712,"toc":7637},[6713,6715,6722,6725,6727,6736,6738,6742,7067,7071,7179,7183,7316,7318,7322,7331,7346,7355,7373,7387,7393,7401,7501,7507,7511,7517,7526,7528,7564,7567,7572,7611,7613,7619,7631,7634],[131,6714,585],{"id":585},[136,6716,6717,6718,6721],{},"一个 Gin + Vue 3 的全栈项目，之前的发布方式是：一个 Go 二进制 + 一个 ",[317,6719,6720],{},"web\u002F"," 前端静态目录，必须放一起才能运行。",[136,6723,6724],{},"为了让部署更丝滑——一个 exe 拖哪都能跑——决定把前端构建产物嵌入到 Go 二进制里。",[131,6726,660],{"id":660},[136,6728,6729,6730,6732,6733,6735],{},"利用 Go 1.16 引入的 ",[317,6731,666],{}," 指令，在编译时将前端 ",[317,6734,670],{}," 目录嵌入二进制，运行时通过嵌入的文件系统提供 SPA 服务。",[131,6737,674],{"id":674},[676,6739,6741],{"id":6740},"_1-创建嵌入模块","1. 创建嵌入模块",[310,6743,6745],{"className":869,"code":6744,"language":871,"meta":315,"style":315},"\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",[317,6746,6747,6752,6758,6762,6768,6776,6785,6794,6802,6811,6819,6823,6832,6836,6840,6845,6858,6862,6880,6900,6904,6925,6942,6954,6964,6968,6972,6986,6998,7004,7024,7029,7034,7058,7063],{"__ignoreMap":315},[320,6748,6749],{"class":322,"line":15},[320,6750,6751],{"class":325},"\u002F\u002F service\u002Fweb\u002Fembed.go\n",[320,6753,6754,6756],{"class":322,"line":13},[320,6755,883],{"class":331},[320,6757,886],{"class":405},[320,6759,6760],{"class":322,"line":9},[320,6761,891],{"emptyLinePlaceholder":7},[320,6763,6764,6766],{"class":322,"line":361},[320,6765,896],{"class":331},[320,6767,899],{"class":345},[320,6769,6770,6772,6774],{"class":322,"line":26},[320,6771,904],{"class":354},[320,6773,907],{"class":405},[320,6775,910],{"class":354},[320,6777,6778,6780,6783],{"class":322,"line":29},[320,6779,904],{"class":354},[320,6781,6782],{"class":405},"io\u002Ffs",[320,6784,910],{"class":354},[320,6786,6787,6789,6792],{"class":322,"line":387},[320,6788,904],{"class":354},[320,6790,6791],{"class":405},"mime",[320,6793,910],{"class":354},[320,6795,6796,6798,6800],{"class":322,"line":931},[320,6797,904],{"class":354},[320,6799,917],{"class":405},[320,6801,910],{"class":354},[320,6803,6804,6806,6809],{"class":322,"line":937},[320,6805,904],{"class":354},[320,6807,6808],{"class":405},"path\u002Ffilepath",[320,6810,910],{"class":354},[320,6812,6813,6815,6817],{"class":322,"line":942},[320,6814,904],{"class":354},[320,6816,926],{"class":405},[320,6818,910],{"class":354},[320,6820,6821],{"class":322,"line":948},[320,6822,891],{"emptyLinePlaceholder":7},[320,6824,6825,6827,6830],{"class":322,"line":965},[320,6826,904],{"class":354},[320,6828,6829],{"class":405},"github.com\u002Fgin-gonic\u002Fgin",[320,6831,910],{"class":354},[320,6833,6834],{"class":322,"line":970},[320,6835,934],{"class":345},[320,6837,6838],{"class":322,"line":68},[320,6839,891],{"emptyLinePlaceholder":7},[320,6841,6842],{"class":322,"line":1014},[320,6843,6844],{"class":325},"\u002F\u002Fgo:embed dist\n",[320,6846,6847,6849,6852,6854,6856],{"class":322,"line":1019},[320,6848,951],{"class":331},[320,6850,6851],{"class":345}," embedFS ",[320,6853,907],{"class":405},[320,6855,959],{"class":345},[320,6857,962],{"class":405},[320,6859,6860],{"class":322,"line":1033},[320,6861,891],{"emptyLinePlaceholder":7},[320,6863,6864,6866,6869,6871,6873,6875,6878],{"class":322,"line":1042},[320,6865,1070],{"class":331},[320,6867,6868],{"class":405}," SPAHandler",[320,6870,4183],{"class":345},[320,6872,1309],{"class":405},[320,6874,959],{"class":345},[320,6876,6877],{"class":405},"HandlerFunc",[320,6879,346],{"class":345},[320,6881,6882,6885,6887,6890,6893,6896,6898],{"class":322,"line":1057},[320,6883,6884],{"class":345},"    sub, _ ",[320,6886,1324],{"class":331},[320,6888,6889],{"class":345}," fs.",[320,6891,6892],{"class":405},"Sub",[320,6894,6895],{"class":345},"(embedFS, ",[320,6897,1002],{"class":354},[320,6899,934],{"class":345},[320,6901,6902],{"class":322,"line":1062},[320,6903,891],{"emptyLinePlaceholder":7},[320,6905,6906,6908,6911,6913,6915,6917,6919,6921,6923],{"class":322,"line":1067},[320,6907,1197],{"class":331},[320,6909,6910],{"class":331}," func",[320,6912,1090],{"class":345},[320,6914,1375],{"class":1076},[320,6916,1287],{"class":331},[320,6918,1309],{"class":405},[320,6920,959],{"class":345},[320,6922,1384],{"class":405},[320,6924,1114],{"class":345},[320,6926,6927,6929,6931,6933,6935,6938,6940],{"class":322,"line":1117},[320,6928,1436],{"class":345},[320,6930,1324],{"class":331},[320,6932,1175],{"class":345},[320,6934,1178],{"class":405},[320,6936,6937],{"class":345},"(c.Request.URL.Path, ",[320,6939,1184],{"class":354},[320,6941,934],{"class":345},[320,6943,6944,6946,6948,6950,6952],{"class":322,"line":1134},[320,6945,1456],{"class":331},[320,6947,1459],{"class":345},[320,6949,1126],{"class":331},[320,6951,2920],{"class":354},[320,6953,346],{"class":345},[320,6955,6956,6959,6961],{"class":322,"line":1146},[320,6957,6958],{"class":345},"            p ",[320,6960,1140],{"class":331},[320,6962,6963],{"class":354}," \"index.html\"\n",[320,6965,6966],{"class":322,"line":1157},[320,6967,1550],{"class":345},[320,6969,6970],{"class":322,"line":1189},[320,6971,891],{"emptyLinePlaceholder":7},[320,6973,6974,6977,6979,6981,6983],{"class":322,"line":60},[320,6975,6976],{"class":345},"        data, err ",[320,6978,1324],{"class":331},[320,6980,6889],{"class":345},[320,6982,4200],{"class":405},[320,6984,6985],{"class":345},"(sub, p)\n",[320,6987,6988,6990,6992,6994,6996],{"class":322,"line":1214},[320,6989,1456],{"class":331},[320,6991,3900],{"class":345},[320,6993,3903],{"class":331},[320,6995,3906],{"class":338},[320,6997,346],{"class":345},[320,6999,7001],{"class":322,"line":7000},29,[320,7002,7003],{"class":325},"            \u002F\u002F SPA fallback：文件不存在则返回 index.html\n",[320,7005,7007,7010,7012,7014,7016,7019,7022],{"class":322,"line":7006},30,[320,7008,7009],{"class":345},"            data, _ ",[320,7011,1140],{"class":331},[320,7013,6889],{"class":345},[320,7015,4200],{"class":405},[320,7017,7018],{"class":345},"(sub, ",[320,7020,7021],{"class":354},"\"index.html\"",[320,7023,934],{"class":345},[320,7025,7027],{"class":322,"line":7026},31,[320,7028,1550],{"class":345},[320,7030,7032],{"class":322,"line":7031},32,[320,7033,891],{"emptyLinePlaceholder":7},[320,7035,7037,7040,7043,7046,7049,7052,7055],{"class":322,"line":7036},33,[320,7038,7039],{"class":345},"        c.",[320,7041,7042],{"class":405},"Data",[320,7044,7045],{"class":345},"(http.StatusOK, mime.",[320,7047,7048],{"class":405},"TypeByExtension",[320,7050,7051],{"class":345},"(filepath.",[320,7053,7054],{"class":405},"Ext",[320,7056,7057],{"class":345},"(p)), data)\n",[320,7059,7061],{"class":322,"line":7060},34,[320,7062,1192],{"class":345},[320,7064,7065],{"class":322,"line":92},[320,7066,390],{"class":345},[676,7068,7070],{"id":7069},"_2-改造路由","2. 改造路由",[310,7072,7074],{"className":869,"code":7073,"language":871,"meta":315,"style":315},"\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",[317,7075,7076,7081,7092,7096,7101,7116,7130,7143,7156,7169],{"__ignoreMap":315},[320,7077,7078],{"class":322,"line":15},[320,7079,7080],{"class":325},"\u002F\u002F router.go\n",[320,7082,7083,7085,7087,7090],{"class":322,"line":13},[320,7084,896],{"class":331},[320,7086,1671],{"class":354},[320,7088,7089],{"class":405},"myproject\u002Fweb",[320,7091,910],{"class":354},[320,7093,7094],{"class":322,"line":9},[320,7095,891],{"emptyLinePlaceholder":7},[320,7097,7098],{"class":322,"line":361},[320,7099,7100],{"class":325},"\u002F\u002F 替换原来的磁盘静态文件服务\n",[320,7102,7103,7106,7108,7111,7114],{"class":322,"line":26},[320,7104,7105],{"class":345},"webHandler ",[320,7107,1324],{"class":331},[320,7109,7110],{"class":345}," web.",[320,7112,7113],{"class":405},"SPAHandler",[320,7115,1333],{"class":345},[320,7117,7118,7121,7123,7125,7127],{"class":322,"line":29},[320,7119,7120],{"class":345},"router.",[320,7122,1361],{"class":405},[320,7124,1090],{"class":345},[320,7126,1184],{"class":354},[320,7128,7129],{"class":345},", webHandler)\n",[320,7131,7132,7134,7136,7138,7141],{"class":322,"line":387},[320,7133,7120],{"class":345},[320,7135,1361],{"class":405},[320,7137,1090],{"class":345},[320,7139,7140],{"class":354},"\"\u002Fassets\u002F*filepath\"",[320,7142,7129],{"class":345},[320,7144,7145,7147,7149,7151,7154],{"class":322,"line":931},[320,7146,7120],{"class":345},[320,7148,1361],{"class":405},[320,7150,1090],{"class":345},[320,7152,7153],{"class":354},"\"\u002Ffavicon.ico\"",[320,7155,7129],{"class":345},[320,7157,7158,7160,7162,7164,7167],{"class":322,"line":937},[320,7159,7120],{"class":345},[320,7161,1361],{"class":405},[320,7163,1090],{"class":345},[320,7165,7166],{"class":354},"\"\u002Ffavicon.svg\"",[320,7168,7129],{"class":345},[320,7170,7171,7173,7176],{"class":322,"line":942},[320,7172,7120],{"class":345},[320,7174,7175],{"class":405},"NoRoute",[320,7177,7178],{"class":345},"(webHandler)\n",[676,7180,7182],{"id":7181},"_3-一键构建脚本","3. 一键构建脚本",[310,7184,7186],{"className":471,"code":7185,"language":473,"meta":315,"style":315},"#!\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",[317,7187,7188,7193,7198,7202,7209,7213,7218,7228,7238,7242,7247,7258,7271,7275,7280,7287,7301,7311],{"__ignoreMap":315},[320,7189,7190],{"class":322,"line":15},[320,7191,7192],{"class":325},"#!\u002Fbin\u002Fbash\n",[320,7194,7195],{"class":322,"line":13},[320,7196,7197],{"class":325},"# build-standalone.sh\n",[320,7199,7200],{"class":322,"line":9},[320,7201,891],{"emptyLinePlaceholder":7},[320,7203,7204,7206],{"class":322,"line":361},[320,7205,1651],{"class":338},[320,7207,7208],{"class":338}," -e\n",[320,7210,7211],{"class":322,"line":26},[320,7212,891],{"emptyLinePlaceholder":7},[320,7214,7215],{"class":322,"line":29},[320,7216,7217],{"class":325},"# 1. 构建前端\n",[320,7219,7220,7223,7225],{"class":322,"line":387},[320,7221,7222],{"class":405},"pnpm",[320,7224,2400],{"class":354},[320,7226,7227],{"class":338}," --no-frozen-lockfile\n",[320,7229,7230,7232,7235],{"class":322,"line":931},[320,7231,480],{"class":405},[320,7233,7234],{"class":354}," vite",[320,7236,7237],{"class":354}," build\n",[320,7239,7240],{"class":322,"line":937},[320,7241,891],{"emptyLinePlaceholder":7},[320,7243,7244],{"class":322,"line":942},[320,7245,7246],{"class":325},"# 2. 复制到嵌入目录\n",[320,7248,7249,7252,7255],{"class":322,"line":948},[320,7250,7251],{"class":405},"rm",[320,7253,7254],{"class":338}," -rf",[320,7256,7257],{"class":354}," service\u002Fweb\u002Fdist\n",[320,7259,7260,7263,7266,7269],{"class":322,"line":965},[320,7261,7262],{"class":405},"cp",[320,7264,7265],{"class":338}," -r",[320,7267,7268],{"class":354}," dist",[320,7270,7257],{"class":354},[320,7272,7273],{"class":322,"line":970},[320,7274,891],{"emptyLinePlaceholder":7},[320,7276,7277],{"class":322,"line":68},[320,7278,7279],{"class":325},"# 3. 编译 Go 二进制\n",[320,7281,7282,7284],{"class":322,"line":1014},[320,7283,1662],{"class":338},[320,7285,7286],{"class":354}," service\n",[320,7288,7289,7291,7293,7295,7298],{"class":322,"line":1019},[320,7290,871],{"class":405},[320,7292,1735],{"class":354},[320,7294,1789],{"class":338},[320,7296,7297],{"class":354}," myapp.exe",[320,7299,7300],{"class":338}," \\\n",[320,7302,7303,7306,7309],{"class":322,"line":1033},[320,7304,7305],{"class":338},"    -ldflags=",[320,7307,7308],{"class":354},"\"-X myproject\u002Fglobal.RUNCODE=release\"",[320,7310,7300],{"class":338},[320,7312,7313],{"class":322,"line":1042},[320,7314,7315],{"class":354},"    main.go\n",[131,7317,1852],{"id":1852},[676,7319,7321],{"id":7320},"坑一路由不匹配静态资源-404","坑一：路由不匹配，静态资源 404",[136,7323,7324,7325,7327,7328,7330],{},"首次实现时用了 Gin 的 ",[317,7326,7175],{}," 兜底，但部分静态资源返回 404。排查发现 ",[317,7329,7175],{}," 在某些路由未显式注册时不会按预期触发。",[136,7332,7333,7335,7336,2418,7339,7342,7343,7345],{},[140,7334,1935],{},"：显式注册 ",[317,7337,7338],{},"\u002Fassets\u002F*filepath",[317,7340,7341],{},"\u002Ffavicon.ico"," 等路由，",[317,7344,7175],{}," 仅作为兜底。",[676,7347,7349,7350,7352,7353,1874],{"id":7348},"坑二goembed-排除-_-开头文件","坑二：",[317,7351,666],{}," 排除 ",[317,7354,649],{},[136,7356,7357,7358,7361,7362,7364,7365,1849],{},"这是最坑的。Vite 构建产出了一个 ",[317,7359,7360],{},"_plugin-vue_export-helper-xxx.js","，Go 的 ",[317,7363,666],{}," 当模式为目录时，",[140,7366,7367,7368,1235,7370,7372],{},"硬编码排除了 ",[317,7369,649],{},[317,7371,959],{}," 开头的文件",[310,7374,7376],{"className":869,"code":7375,"language":871,"meta":315,"style":315},"\u002F\u002F 这行会跳过 dist\u002Fassets\u002F_plugin-xxx.js\n\u002F\u002Fgo:embed dist\n",[317,7377,7378,7383],{"__ignoreMap":315},[320,7379,7380],{"class":322,"line":15},[320,7381,7382],{"class":325},"\u002F\u002F 这行会跳过 dist\u002Fassets\u002F_plugin-xxx.js\n",[320,7384,7385],{"class":322,"line":13},[320,7386,6844],{"class":325},[136,7388,7389,7390,1849],{},"错误表现：JS 文件返回的是 HTML 内容（SPA fallback），浏览器报 ",[317,7391,7392],{},"Unexpected token '\u003C'",[136,7394,7395,7397,7398,7400],{},[140,7396,1935],{},"：改 Vite 配置，去除了 ",[317,7399,649],{}," 前缀：",[310,7402,7404],{"className":693,"code":7403,"language":695,"meta":315,"style":315},"\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",[317,7405,7406,7410,7417,7424,7431,7439,7482,7487,7491,7496],{"__ignoreMap":315},[320,7407,7408],{"class":322,"line":15},[320,7409,702],{"class":325},[320,7411,7412,7415],{"class":322,"line":13},[320,7413,7414],{"class":405},"build",[320,7416,848],{"class":345},[320,7418,7419,7422],{"class":322,"line":9},[320,7420,7421],{"class":405},"  rollupOptions",[320,7423,848],{"class":345},[320,7425,7426,7429],{"class":322,"line":361},[320,7427,7428],{"class":405},"    output",[320,7430,848],{"class":345},[320,7432,7433,7436],{"class":322,"line":26},[320,7434,7435],{"class":405},"      chunkFileNames",[320,7437,7438],{"class":345},"(chunkInfo) {\n",[320,7440,7441,7443,7446,7449,7451,7453,7455,7458,7460,7463,7466,7469,7471,7473,7476,7479],{"class":322,"line":29},[320,7442,4227],{"class":331},[320,7444,7445],{"class":354}," `assets\u002F${",[320,7447,7448],{"class":345},"chunkInfo",[320,7450,959],{"class":354},[320,7452,1093],{"class":345},[320,7454,959],{"class":354},[320,7456,7457],{"class":405},"replace",[320,7459,1090],{"class":354},[320,7461,7462],{"class":354},"\u002F",[320,7464,7465],{"class":331},"^",[320,7467,649],{"class":7468},"sPKmS",[320,7470,7462],{"class":354},[320,7472,452],{"class":354},[320,7474,7475],{"class":354},"''",[320,7477,7478],{"class":354},")",[320,7480,7481],{"class":354},"}-[hash].js`\n",[320,7483,7484],{"class":322,"line":387},[320,7485,7486],{"class":345},"      },\n",[320,7488,7489],{"class":322,"line":931},[320,7490,3007],{"class":345},[320,7492,7493],{"class":322,"line":937},[320,7494,7495],{"class":345},"  },\n",[320,7497,7498],{"class":322,"line":942},[320,7499,7500],{"class":345},"},\n",[136,7502,7503,7504,7506],{},"这样就避免产生 ",[317,7505,649],{}," 前缀文件名的同时，保持构建产物的一致性和引用关系的正确性。",[676,7508,7510],{"id":7509},"坑三构建缓存导致-embed-内容未更新","坑三：构建缓存导致 embed 内容未更新",[136,7512,7513,7514,7516],{},"多次 ",[317,7515,1919],{}," 后，Go 的构建缓存可能使用了旧版本的 embed 内容，导致实际运行的文件不是最新的。",[136,7518,7519,7521,7522,7525],{},[140,7520,1935],{},"：使用 ",[317,7523,7524],{},"go build -a"," 强制重新编译所有包。",[131,7527,2123],{"id":2123},[310,7529,7531],{"className":471,"code":7530,"language":473,"meta":315,"style":315},"$ bash build-standalone.sh\n=== 构建成功 ===\n输出: service\u002Fmyapp.exe  41M\n",[317,7532,7533,7542,7553],{"__ignoreMap":315},[320,7534,7535,7537,7539],{"class":322,"line":15},[320,7536,2133],{"class":405},[320,7538,2146],{"class":354},[320,7540,7541],{"class":354}," build-standalone.sh\n",[320,7543,7544,7547,7550],{"class":322,"line":13},[320,7545,7546],{"class":354},"===",[320,7548,7549],{"class":354}," 构建成功",[320,7551,7552],{"class":354}," ===\n",[320,7554,7555,7558,7561],{"class":322,"line":9},[320,7556,7557],{"class":405},"输出:",[320,7559,7560],{"class":354}," service\u002Fmyapp.exe",[320,7562,7563],{"class":354},"  41M\n",[136,7565,7566],{},"一个 41MB 的 exe，内置前端页面、API 后端、SQLite 数据库（运行时初始化），拖到哪都能跑。",[136,7568,7569],{},[140,7570,7571],{},"部署对比：",[154,7573,7574,7587],{},[157,7575,7576],{},[160,7577,7578,7581,7584],{},[163,7579,7580],{},"方式",[163,7582,7583],{},"文件数",[163,7585,7586],{},"操作",[170,7588,7589,7600],{},[160,7590,7591,7594,7597],{},[175,7592,7593],{},"旧方案",[175,7595,7596],{},"1 exe + 1 web 目录",[175,7598,7599],{},"解压→保持目录结构",[160,7601,7602,7605,7608],{},[175,7603,7604],{},"新方案",[175,7606,7607],{},"1 个 exe",[175,7609,7610],{},"直接扔到服务器运行",[131,7612,2164],{"id":2164},[136,7614,7615,7616,7618],{},"Go 的 ",[317,7617,666],{}," 是一个强大的工具，但有两个隐藏行为需要注意：",[621,7620,7621,7628],{},[243,7622,7623,7624,1235,7626,7372],{},"嵌入目录时自动排除 ",[317,7625,649],{},[317,7627,959],{},[243,7629,7630],{},"构建缓存可能导致 embed 不是最新的",[136,7632,7633],{},"对于前端+后端合体的场景，这是性价比最高的方案——不需要额外的打包工具，纯标准库搞定。",[551,7635,7636],{},"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":315,"searchDepth":13,"depth":9,"links":7638},[7639,7640,7641,7646,7652,7653],{"id":585,"depth":13,"text":585},{"id":660,"depth":13,"text":660},{"id":674,"depth":13,"text":674,"children":7642},[7643,7644,7645],{"id":6740,"depth":9,"text":6741},{"id":7069,"depth":9,"text":7070},{"id":7181,"depth":9,"text":7182},{"id":1852,"depth":13,"text":1852,"children":7647},[7648,7649,7651],{"id":7320,"depth":9,"text":7321},{"id":7348,"depth":9,"text":7650},"坑二：\u002F\u002Fgo:embed 排除 _ 开头文件",{"id":7509,"depth":9,"text":7510},{"id":2123,"depth":13,"text":2123},{"id":2164,"depth":13,"text":2164},"记录将 Vue 3 前端嵌入 Go 二进制实现单 exe 发布的完整流程，以及遇到的坑和解决方案。","\u002Fposts\u002Fgo-embed-frontend\u002Fimg\u002Fcover.jpg",{},"2026-06-06T16:00:00",{"title":6710,"description":7654},"posts\u002Fgo-embed-frontend\u002Findex",[2236,7661,7662,7663],"Vue","编译","埋坑","2026-06-06","SIQNL24wVAFb18wIGWSB3lNVZUQ9B76jWgQ55TASTYk",{"id":7667,"title":7668,"author":126,"body":7669,"description":8429,"difficulty":126,"draft":19,"extension":565,"image":8430,"meta":8431,"navigation":7,"path":8432,"pinned":19,"published":8433,"seo":8434,"series":126,"seriesOrder":126,"stem":8435,"tags":8436,"updated":126,"__hash__":8438},"posts\u002Fposts\u002Fdws-dingtalk-cli\u002Findex.md","DWS — 钉钉官方出品的 AI 友好命令行工具",{"type":128,"value":7670,"toc":8415},[7671,7675,7678,7685,7809,7811,7814,7869,7872,7886,7889,7892,7899,8132,8139,8143,8146,8166,8169,8184,8187,8206,8209,8212,8219,8285,8288,8290,8294,8297,8348,8352,8373,8377,8380,8394,8397,8401,8404,8406,8409,8412],[131,7672,7674],{"id":7673},"dws-是什么","dws 是什么",[136,7676,7677],{},"dws（DingTalk Workspace CLI）是阿里巴巴 2026 年开源的钉钉命令行工具，Apache-2.0 协议，Go 语言编写。",[136,7679,7680,7681,7684],{},"它的定位很明确：",[140,7682,7683],{},"把钉钉的所有产品能力暴露给 AI Agent 和命令行用户","。不用打开钉钉 App，不用写 API 调用代码，一条命令搞定。",[310,7686,7688],{"className":471,"code":7687,"language":473,"meta":315,"style":315},"# 查看今天有什么会\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",[317,7689,7690,7695,7720,7724,7729,7751,7755,7760,7784,7788,7793],{"__ignoreMap":315},[320,7691,7692],{"class":322,"line":15},[320,7693,7694],{"class":325},"# 查看今天有什么会\n",[320,7696,7697,7700,7703,7706,7708,7711,7714,7717],{"class":322,"line":13},[320,7698,7699],{"class":405},"dws",[320,7701,7702],{"class":354}," calendar",[320,7704,7705],{"class":354}," event",[320,7707,6292],{"class":354},[320,7709,7710],{"class":338}," --start",[320,7712,7713],{"class":354}," 2026-06-01",[320,7715,7716],{"class":338}," --end",[320,7718,7719],{"class":354}," 2026-06-01\n",[320,7721,7722],{"class":322,"line":9},[320,7723,891],{"emptyLinePlaceholder":7},[320,7725,7726],{"class":322,"line":361},[320,7727,7728],{"class":325},"# 给群里发条消息\n",[320,7730,7731,7733,7736,7739,7742,7745,7748],{"class":322,"line":26},[320,7732,7699],{"class":405},[320,7734,7735],{"class":354}," chat",[320,7737,7738],{"class":354}," send",[320,7740,7741],{"class":338}," --group-id",[320,7743,7744],{"class":354}," xxx",[320,7746,7747],{"class":338}," --content",[320,7749,7750],{"class":354}," \"今天发布推迟到下午3点\"\n",[320,7752,7753],{"class":322,"line":29},[320,7754,891],{"emptyLinePlaceholder":7},[320,7756,7757],{"class":322,"line":387},[320,7758,7759],{"class":325},"# 创建一个待办\n",[320,7761,7762,7764,7767,7770,7772,7775,7778,7781],{"class":322,"line":931},[320,7763,7699],{"class":405},[320,7765,7766],{"class":354}," todo",[320,7768,7769],{"class":354}," task",[320,7771,5744],{"class":354},[320,7773,7774],{"class":338}," --subject",[320,7776,7777],{"class":354}," \"Review PR #123\"",[320,7779,7780],{"class":338}," --due-date",[320,7782,7783],{"class":354}," 2026-06-02\n",[320,7785,7786],{"class":322,"line":937},[320,7787,891],{"emptyLinePlaceholder":7},[320,7789,7790],{"class":322,"line":942},[320,7791,7792],{"class":325},"# 读取钉钉文档\n",[320,7794,7795,7797,7800,7803,7806],{"class":322,"line":948},[320,7796,7699],{"class":405},[320,7798,7799],{"class":354}," doc",[320,7801,7802],{"class":354}," read",[320,7804,7805],{"class":338}," --url",[320,7807,7808],{"class":354}," \"https:\u002F\u002Falidocs.dingtalk.com\u002Fi\u002Fnodes\u002Fxxx\"\n",[131,7810,5457],{"id":5457},[136,7812,7813],{},"三种方式，选你喜欢的：",[310,7815,7817],{"className":471,"code":7816,"language":473,"meta":315,"style":315},"# 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",[317,7818,7819,7824,7836,7840,7845,7855,7859,7864],{"__ignoreMap":315},[320,7820,7821],{"class":322,"line":15},[320,7822,7823],{"class":325},"# npm（推荐，跨平台自动下载二进制）\n",[320,7825,7826,7828,7830,7833],{"class":322,"line":13},[320,7827,1729],{"class":405},[320,7829,2400],{"class":354},[320,7831,7832],{"class":338}," -g",[320,7834,7835],{"class":354}," dingtalk-workspace-cli\n",[320,7837,7838],{"class":322,"line":9},[320,7839,891],{"emptyLinePlaceholder":7},[320,7841,7842],{"class":322,"line":361},[320,7843,7844],{"class":325},"# Homebrew（macOS\u002FLinux）\n",[320,7846,7847,7850,7852],{"class":322,"line":26},[320,7848,7849],{"class":405},"brew",[320,7851,2400],{"class":354},[320,7853,7854],{"class":354}," dws\n",[320,7856,7857],{"class":322,"line":29},[320,7858,891],{"emptyLinePlaceholder":7},[320,7860,7861],{"class":322,"line":387},[320,7862,7863],{"class":325},"# 直接下载二进制\n",[320,7865,7866],{"class":322,"line":931},[320,7867,7868],{"class":325},"# https:\u002F\u002Fgithub.com\u002Fopen-dingtalk\u002Fdingtalk-workspace-cli\u002Freleases\n",[136,7870,7871],{},"安装后认证：",[310,7873,7875],{"className":471,"code":7874,"language":473,"meta":315,"style":315},"dws auth login\n",[317,7876,7877],{"__ignoreMap":315},[320,7878,7879,7881,7883],{"class":322,"line":15},[320,7880,7699],{"class":405},[320,7882,5579],{"class":354},[320,7884,7885],{"class":354}," login\n",[136,7887,7888],{},"会打开浏览器走 OAuth 设备流认证，token 加密存储在系统钥匙串里（PBKDF2 + AES-256-GCM）。",[131,7890,7891],{"id":7891},"覆盖的钉钉产品",[136,7893,7894,7895,7898],{},"这是最夸张的部分 — ",[140,7896,7897],{},"19 个产品，213 个命令","，基本把钉钉全家桶都搬到了命令行：",[154,7900,7901,7913],{},[157,7902,7903],{},[160,7904,7905,7908,7911],{},[163,7906,7907],{},"产品",[163,7909,7910],{},"命令数",[163,7912,2295],{},[170,7914,7915,7929,7943,7957,7971,7985,7999,8013,8026,8040,8053,8067,8081,8094,8107,8121],{},[160,7916,7917,7923,7926],{},[175,7918,7919,7920],{},"聊天 ",[317,7921,7922],{},"chat",[175,7924,7925],{},"57",[175,7927,7928],{},"消息收发、群管理、机器人、已读状态、Webhook",[160,7930,7931,7937,7940],{},[175,7932,7933,7934],{},"AI 表格 ",[317,7935,7936],{},"aitable",[175,7938,7939],{},"42",[175,7941,7942],{},"Base\u002F数据表\u002F字段\u002F记录\u002F视图\u002F图表\u002F仪表盘，完整 CRUD",[160,7944,7945,7951,7954],{},[175,7946,7947,7948],{},"电子表格 ",[317,7949,7950],{},"sheet",[175,7952,7953],{},"34",[175,7955,7956],{},"工作表读写、行列操作、合并、筛选视图、导出",[160,7958,7959,7965,7968],{},[175,7960,7961,7962],{},"文档 ",[317,7963,7964],{},"doc",[175,7966,7967],{},"21",[175,7969,7970],{},"搜索、读写、块级编辑、评论、复制移动",[160,7972,7973,7979,7982],{},[175,7974,7975,7976],{},"会议纪要 ",[317,7977,7978],{},"minutes",[175,7980,7981],{},"19",[175,7983,7984],{},"列表、摘要、转写、思维导图、发言人、热词",[160,7986,7987,7993,7996],{},[175,7988,7989,7990],{},"日历 ",[317,7991,7992],{},"calendar",[175,7994,7995],{},"14",[175,7997,7998],{},"事件 CRUD、会议室、忙闲查询、时间建议",[160,8000,8001,8007,8010],{},[175,8002,8003,8004],{},"审批 ",[317,8005,8006],{},"oa",[175,8008,8009],{},"9",[175,8011,8012],{},"待办、发起、同意\u002F拒绝\u002F撤销",[160,8014,8015,8021,8023],{},[175,8016,8017,8018],{},"云盘 ",[317,8019,8020],{},"drive",[175,8022,8009],{},[175,8024,8025],{},"列表、下载、上传、创建目录",[160,8027,8028,8034,8037],{},[175,8029,8030,8031],{},"知识库 ",[317,8032,8033],{},"wiki",[175,8035,8036],{},"7",[175,8038,8039],{},"空间管理、成员管理",[160,8041,8042,8048,8050],{},[175,8043,8044,8045],{},"汇报 ",[317,8046,8047],{},"report",[175,8049,8036],{},[175,8051,8052],{},"日志\u002F日报\u002F周报，按模板创建、统计",[160,8054,8055,8061,8064],{},[175,8056,8057,8058],{},"待办 ",[317,8059,8060],{},"todo",[175,8062,8063],{},"6",[175,8065,8066],{},"创建、完成、删除，支持优先级和循环",[160,8068,8069,8075,8078],{},[175,8070,8071,8072],{},"邮件 ",[317,8073,8074],{},"mail",[175,8076,8077],{},"4",[175,8079,8080],{},"邮箱查询、邮件搜索、发送",[160,8082,8083,8089,8091],{},[175,8084,8085,8086],{},"考勤 ",[317,8087,8088],{},"attendance",[175,8090,8077],{},[175,8092,8093],{},"打卡记录、排班、汇总",[160,8095,8096,8102,8104],{},[175,8097,8098,8099],{},"通讯录 ",[317,8100,8101],{},"contact",[175,8103,3831],{},[175,8105,8106],{},"用户搜索、部门查询",[160,8108,8109,8115,8118],{},[175,8110,8111,8112],{},"DING 消息 ",[317,8113,8114],{},"ding",[175,8116,8117],{},"2",[175,8119,8120],{},"发送\u002F撤回（应用内、短信、电话）",[160,8122,8123,8126,8129],{},[175,8124,8125],{},"其他",[175,8127,8128],{},"若干",[175,8130,8131],{},"AI 搜索、AI 应用、直播、开放平台文档",[136,8133,8134,8135,8138],{},"还有一个万能命令 ",[317,8136,8137],{},"dws api","，可以直接调用任意钉钉 OpenAPI，不受这 213 个命令的限制。",[131,8140,8142],{"id":8141},"agent-skill-系统","Agent Skill 系统",[136,8144,8145],{},"dws 内置了一套完整的 Agent Skill，可以直接被 AI 编程工具发现和使用：",[240,8147,8148,8154,8160],{},[243,8149,8150,8153],{},[140,8151,8152],{},"Claude Code"," — 通过 SKILL.md 自动加载",[243,8155,8156,8159],{},[140,8157,8158],{},"Cursor"," — 支持 MCP 协议接入",[243,8161,8162,8165],{},[140,8163,8164],{},"Codex \u002F OpenCode \u002F Qoder"," — 通过 skill 安装命令接入",[136,8167,8168],{},"安装 skill：",[310,8170,8172],{"className":471,"code":8171,"language":473,"meta":315,"style":315},"dws skill setup\n",[317,8173,8174],{"__ignoreMap":315},[320,8175,8176,8178,8181],{"class":322,"line":15},[320,8177,7699],{"class":405},[320,8179,8180],{"class":354}," skill",[320,8182,8183],{"class":354}," setup\n",[136,8185,8186],{},"SKILL.md 里定义了：",[240,8188,8189,8197,8200,8203],{},[243,8190,8191,8192,8194,8195,609],{},"16 个产品的意图判断决策树（用户说\"表格\"→ ",[317,8193,7936],{},"，说\"打卡\"→ ",[317,8196,8088],{},[243,8198,8199],{},"危险操作确认流程（删除、撤回等必须先确认）",[243,8201,8202],{},"错误处理规范（verbose 重试、recovery 事件 ID）",[243,8204,8205],{},"URL 分流规则（alidocs.dingtalk.com 的多种路径格式自动识别）",[136,8207,8208],{},"Agent 读到这份 SKILL.md 就知道什么时候该调什么命令，不需要额外的 prompt 工程。",[131,8210,8211],{"id":8211},"自动升级",[136,8213,8214,8215,8218],{},"内置 ",[317,8216,8217],{},"dws upgrade","，不需要重新下载安装包：",[310,8220,8222],{"className":471,"code":8221,"language":473,"meta":315,"style":315},"dws upgrade              # 交互式升级到最新版\ndws upgrade --check      # 仅检查是否有新版本\ndws upgrade --list       # 列出最近版本\ndws upgrade --version v1.0.5  # 升级到指定版本\ndws upgrade --rollback   # 回滚到上一版本\n",[317,8223,8224,8234,8246,8258,8273],{"__ignoreMap":315},[320,8225,8226,8228,8231],{"class":322,"line":15},[320,8227,7699],{"class":405},[320,8229,8230],{"class":354}," upgrade",[320,8232,8233],{"class":325},"              # 交互式升级到最新版\n",[320,8235,8236,8238,8240,8243],{"class":322,"line":13},[320,8237,7699],{"class":405},[320,8239,8230],{"class":354},[320,8241,8242],{"class":338}," --check",[320,8244,8245],{"class":325},"      # 仅检查是否有新版本\n",[320,8247,8248,8250,8252,8255],{"class":322,"line":9},[320,8249,7699],{"class":405},[320,8251,8230],{"class":354},[320,8253,8254],{"class":338}," --list",[320,8256,8257],{"class":325},"       # 列出最近版本\n",[320,8259,8260,8262,8264,8267,8270],{"class":322,"line":361},[320,8261,7699],{"class":405},[320,8263,8230],{"class":354},[320,8265,8266],{"class":338}," --version",[320,8268,8269],{"class":354}," v1.0.5",[320,8271,8272],{"class":325},"  # 升级到指定版本\n",[320,8274,8275,8277,8279,8282],{"class":322,"line":26},[320,8276,7699],{"class":405},[320,8278,8230],{"class":354},[320,8280,8281],{"class":338}," --rollback",[320,8283,8284],{"class":325},"   # 回滚到上一版本\n",[136,8286,8287],{},"升级流程：下载 → SHA256 校验 → 备份当前版本 → 原子替换 → 更新 Skill。失败可一键回滚。",[131,8289,5904],{"id":5904},[676,8291,8293],{"id":8292},"_1-为-ai-agent-专门优化","1. 为 AI Agent 专门优化",[136,8295,8296],{},"这可能是目前对 AI 最友好的 CLI 工具：",[240,8298,8299,8319,8329,8338],{},[243,8300,8301,8304,8305,8308,8309,1961,8312,8308,8315,8318],{},[140,8302,8303],{},"智能纠错"," — AI 模型经常生成错误的参数格式（",[317,8306,8307],{},"--userId"," 应该是 ",[317,8310,8311],{},"--user-id",[317,8313,8314],{},"--limit100",[317,8316,8317],{},"--limit 100","），dws 的 5 阶段 Pipeline 会自动修正",[243,8320,8321,8324,8325,8328],{},[140,8322,8323],{},"Schema 发现"," — ",[317,8326,8327],{},"dws schema"," 让 Agent 动态查询可用工具和参数，不需要硬编码",[243,8330,8331,8324,8334,8337],{},[140,8332,8333],{},"jq 过滤",[317,8335,8336],{},"--jq '.data[] | {name, id}'"," 直接过滤输出，减少 Agent 消耗的 token",[243,8339,8340,8343,8344,8347],{},[140,8341,8342],{},"JSON 输出"," — 所有命令支持 ",[317,8345,8346],{},"--format json","，方便程序解析",[676,8349,8351],{"id":8350},"_2-安全设计","2. 安全设计",[240,8353,8354,8357,8360,8363,8370],{},[243,8355,8356],{},"token 用 PBKDF2 + AES-256-GCM 加密，密钥绑定设备 MAC 地址",[243,8358,8359],{},"域名白名单，只能访问钉钉官方 API",[243,8361,8362],{},"强制 HTTPS",[243,8364,8365,8366,8369],{},"写操作必须显式 ",[317,8367,8368],{},"--yes"," 确认",[243,8371,8372],{},"完整审计链",[676,8374,8376],{"id":8375},"_3-发现驱动架构","3. 发现驱动架构",[136,8378,8379],{},"不是把所有命令硬编码在代码里，而是：",[621,8381,8382,8385,8388,8391],{},[243,8383,8384],{},"从钉钉 MCP 服务器注册中心拉取元数据",[243,8386,8387],{},"运行时发现服务端能力",[243,8389,8390],{},"转换成统一的中间表示（IR）",[243,8392,8393],{},"动态挂载到命令树",[136,8395,8396],{},"这意味着钉钉新增产品能力时，dws 不需要发版就能支持。",[676,8398,8400],{"id":8399},"_4-多平台分发","4. 多平台分发",[136,8402,8403],{},"GoReleaser 交叉编译 + Homebrew formula + npm 包 + shell\u002FPowerShell 安装脚本，覆盖 Windows、macOS、Linux。",[131,8405,2164],{"id":2164},[136,8407,8408],{},"dws 把钉钉从一个\"必须打开 App 才能用\"的工具变成了一个\"命令行 + AI Agent 随时调用\"的平台。213 个命令覆盖日常办公的方方面面，Agent Skill 系统让 AI 编程工具可以直接理解和使用这些能力。",[136,8410,8411],{},"如果你是钉钉重度用户，或者在做企业内部的 AI Agent 集成，dws 值得一试。",[551,8413,8414],{},"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":315,"searchDepth":13,"depth":9,"links":8416},[8417,8418,8419,8420,8421,8422,8428],{"id":7673,"depth":13,"text":7674},{"id":5457,"depth":13,"text":5457},{"id":7891,"depth":13,"text":7891},{"id":8141,"depth":13,"text":8142},{"id":8211,"depth":13,"text":8211},{"id":5904,"depth":13,"text":5904,"children":8423},[8424,8425,8426,8427],{"id":8292,"depth":9,"text":8293},{"id":8350,"depth":9,"text":8351},{"id":8375,"depth":9,"text":8376},{"id":8399,"depth":9,"text":8400},{"id":2164,"depth":13,"text":2164},"介绍阿里巴巴官方的 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":7668,"description":8429},"posts\u002Fdws-dingtalk-cli\u002Findex",[8437,6703,6705,2236,6706],"钉钉","4ca65VUZ2Sz9yG8dNtftrBDDHE6QowA3ed9g4egQkKI",{"id":8440,"title":8441,"author":126,"body":8442,"description":10447,"difficulty":2227,"draft":19,"extension":565,"image":10448,"meta":10449,"navigation":7,"path":10450,"pinned":19,"published":10451,"seo":10452,"series":2233,"seriesOrder":15,"stem":10453,"tags":10454,"updated":10455,"__hash__":10456},"posts\u002Fposts\u002Fgo-mcp-gin\u002Findex.md","Go-MCP 接入 Gin：从零搭建 MCP 服务器",{"type":128,"value":8443,"toc":10428},[8444,8448,8451,8454,8458,8465,8471,8474,8477,8497,8500,8504,8527,8531,8628,8641,8645,8802,8805,9018,9022,9033,9132,9139,9143,9407,9410,9490,9494,9497,9499,9510,9514,9520,9876,10062,10065,10121,10124,10277,10280,10283,10350,10352,10416,10425],[131,8445,8447],{"id":8446},"什么是-mcp","什么是 MCP",[136,8449,8450],{},"MCP（Model Context Protocol）是 Anthropic 提出的开放协议，用于让 AI 助手（如 Claude）与外部服务进行标准化交互。简单来说：你提供一个 MCP 服务器，AI 就能发现并调用你定义的工具（Tools），就像给 AI 装了一双手。",[136,8452,8453],{},"传统做法是写 REST API 然后让 AI 通过函数调用（Function Calling）间接使用，但 MCP 统一了协议层，AI 客户端只需要配置一个 URL 就能接入所有工具。",[131,8455,8457],{"id":8456},"为什么选-gin-mcp-go","为什么选 Gin + mcp-go",[136,8459,8460,8461,8464],{},"Go 生态里做 MCP 服务器最成熟的库是 ",[317,8462,8463],{},"mark3labs\u002Fmcp-go","，它支持 StreamableHTTP 传输模式，可以直接挂载到任意 HTTP 框架上。Gin 作为 Go 最流行的 HTTP 框架，天然适配。",[136,8466,8467,8468],{},"核心思路：",[140,8469,8470],{},"MCP 服务器本质上就是一个 HTTP Handler，挂到 Gin 路由上即可。",[131,8472,8473],{"id":8473},"需求分析",[136,8475,8476],{},"我们要搭建一个具备以下能力的 MCP 服务器：",[621,8478,8479,8485,8491],{},[243,8480,8481,8484],{},[140,8482,8483],{},"工具注册"," — 向 AI 暴露可调用的工具（列出资源、查看详情、创建\u002F删除）",[243,8486,8487,8490],{},[140,8488,8489],{},"HTTP 集成"," — MCP 端点作为 Gin 路由的一部分，与业务 API 共存",[243,8492,8493,8496],{},[140,8494,8495],{},"鉴权"," — MCP 请求必须经过身份验证，工具处理器能获取当前用户信息",[131,8498,8499],{"id":8499},"实现步骤",[676,8501,8503],{"id":8502},"_1-安装依赖","1. 安装依赖",[310,8505,8507],{"className":471,"code":8506,"language":473,"meta":315,"style":315},"go get github.com\u002Fmark3labs\u002Fmcp-go@v0.54.0\ngo get github.com\u002Fgin-gonic\u002Fgin@v1.12.0\n",[317,8508,8509,8518],{"__ignoreMap":315},[320,8510,8511,8513,8515],{"class":322,"line":15},[320,8512,871],{"class":405},[320,8514,5335],{"class":354},[320,8516,8517],{"class":354}," github.com\u002Fmark3labs\u002Fmcp-go@v0.54.0\n",[320,8519,8520,8522,8524],{"class":322,"line":13},[320,8521,871],{"class":405},[320,8523,5335],{"class":354},[320,8525,8526],{"class":354}," github.com\u002Fgin-gonic\u002Fgin@v1.12.0\n",[676,8528,8530],{"id":8529},"_2-创建-mcp-服务器","2. 创建 MCP 服务器",[310,8532,8534],{"className":869,"code":8533,"language":871,"meta":315,"style":315},"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",[317,8535,8536,8547,8551,8567,8578,8589,8600,8609,8624],{"__ignoreMap":315},[320,8537,8538,8540,8542,8545],{"class":322,"line":15},[320,8539,896],{"class":331},[320,8541,1671],{"class":354},[320,8543,8544],{"class":405},"github.com\u002Fmark3labs\u002Fmcp-go\u002Fserver",[320,8546,910],{"class":354},[320,8548,8549],{"class":322,"line":13},[320,8550,891],{"emptyLinePlaceholder":7},[320,8552,8553,8556,8558,8561,8564],{"class":322,"line":9},[320,8554,8555],{"class":345},"mcpServer ",[320,8557,1324],{"class":331},[320,8559,8560],{"class":345}," server.",[320,8562,8563],{"class":405},"NewMCPServer",[320,8565,8566],{"class":345},"(\n",[320,8568,8569,8572,8575],{"class":322,"line":361},[320,8570,8571],{"class":354},"    \"my-app\"",[320,8573,8574],{"class":345},",    ",[320,8576,8577],{"class":325},"\u002F\u002F 服务器名称\n",[320,8579,8580,8583,8586],{"class":322,"line":26},[320,8581,8582],{"class":354},"    \"1.0.0\"",[320,8584,8585],{"class":345},",     ",[320,8587,8588],{"class":325},"\u002F\u002F 版本\n",[320,8590,8591,8594,8597],{"class":322,"line":29},[320,8592,8593],{"class":345},"    server.",[320,8595,8596],{"class":405},"WithLogging",[320,8598,8599],{"class":345},"(),\n",[320,8601,8602,8604,8607],{"class":322,"line":387},[320,8603,8593],{"class":345},[320,8605,8606],{"class":405},"WithRecovery",[320,8608,8599],{"class":345},[320,8610,8611,8613,8616,8618,8621],{"class":322,"line":931},[320,8612,8593],{"class":345},[320,8614,8615],{"class":405},"WithInstructions",[320,8617,1090],{"class":345},[320,8619,8620],{"class":354},"\"这是一个 MCP 服务器示例\"",[320,8622,8623],{"class":345},"),\n",[320,8625,8626],{"class":322,"line":937},[320,8627,934],{"class":345},[136,8629,8630,8632,8633,8636,8637,8640],{},[317,8631,8563],{}," 创建一个 MCP 协议实例，它负责处理工具发现（",[317,8634,8635],{},"tools\u002Flist","）和工具调用（",[317,8638,8639],{},"tools\u002Fcall","）的协议逻辑。",[676,8642,8644],{"id":8643},"_3-注册工具","3. 注册工具",[310,8646,8648],{"className":869,"code":8647,"language":871,"meta":315,"style":315},"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",[317,8649,8650,8661,8665,8670,8690,8705,8709,8713,8718,8773,8778,8798],{"__ignoreMap":315},[320,8651,8652,8654,8656,8659],{"class":322,"line":15},[320,8653,896],{"class":331},[320,8655,1671],{"class":354},[320,8657,8658],{"class":405},"github.com\u002Fmark3labs\u002Fmcp-go\u002Fmcp",[320,8660,910],{"class":354},[320,8662,8663],{"class":322,"line":13},[320,8664,891],{"emptyLinePlaceholder":7},[320,8666,8667],{"class":322,"line":9},[320,8668,8669],{"class":325},"\u002F\u002F 定义工具\n",[320,8671,8672,8675,8677,8680,8683,8685,8688],{"class":322,"line":361},[320,8673,8674],{"class":345},"listTool ",[320,8676,1324],{"class":331},[320,8678,8679],{"class":345}," mcp.",[320,8681,8682],{"class":405},"NewTool",[320,8684,1090],{"class":345},[320,8686,8687],{"class":354},"\"list_items\"",[320,8689,358],{"class":345},[320,8691,8692,8695,8698,8700,8703],{"class":322,"line":26},[320,8693,8694],{"class":345},"    mcp.",[320,8696,8697],{"class":405},"WithDescription",[320,8699,1090],{"class":345},[320,8701,8702],{"class":354},"\"列出所有项目\"",[320,8704,8623],{"class":345},[320,8706,8707],{"class":322,"line":29},[320,8708,934],{"class":345},[320,8710,8711],{"class":322,"line":387},[320,8712,891],{"emptyLinePlaceholder":7},[320,8714,8715],{"class":322,"line":931},[320,8716,8717],{"class":325},"\u002F\u002F 注册处理器\n",[320,8719,8720,8723,8726,8729,8731,8733,8736,8739,8741,8743,8745,8748,8750,8752,8755,8757,8759,8762,8764,8767,8769,8771],{"class":322,"line":937},[320,8721,8722],{"class":345},"mcpServer.",[320,8724,8725],{"class":405},"AddTool",[320,8727,8728],{"class":345},"(listTool, ",[320,8730,1070],{"class":331},[320,8732,1090],{"class":345},[320,8734,8735],{"class":1076},"ctx",[320,8737,8738],{"class":405}," context",[320,8740,959],{"class":345},[320,8742,1384],{"class":405},[320,8744,452],{"class":345},[320,8746,8747],{"class":1076},"req",[320,8749,5369],{"class":405},[320,8751,959],{"class":345},[320,8753,8754],{"class":405},"CallToolRequest",[320,8756,1099],{"class":345},[320,8758,1048],{"class":331},[320,8760,8761],{"class":405},"mcp",[320,8763,959],{"class":345},[320,8765,8766],{"class":405},"CallToolResult",[320,8768,452],{"class":345},[320,8770,1111],{"class":331},[320,8772,1114],{"class":345},[320,8774,8775],{"class":322,"line":942},[320,8776,8777],{"class":325},"    \u002F\u002F 业务逻辑\n",[320,8779,8780,8782,8784,8787,8789,8792,8795],{"class":322,"line":948},[320,8781,1197],{"class":331},[320,8783,8679],{"class":345},[320,8785,8786],{"class":405},"NewToolResultText",[320,8788,1090],{"class":345},[320,8790,8791],{"class":354},"\"返回结果\"",[320,8793,8794],{"class":345},"), ",[320,8796,8797],{"class":338},"nil\n",[320,8799,8800],{"class":322,"line":965},[320,8801,739],{"class":345},[136,8803,8804],{},"带参数的工具：",[310,8806,8808],{"className":869,"code":8807,"language":871,"meta":315,"style":315},"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",[317,8809,8810,8828,8841,8855,8865,8879,8884,8888,8892,8939,8958,8970,8988,8992,8997,9014],{"__ignoreMap":315},[320,8811,8812,8815,8817,8819,8821,8823,8826],{"class":322,"line":15},[320,8813,8814],{"class":345},"getTool ",[320,8816,1324],{"class":331},[320,8818,8679],{"class":345},[320,8820,8682],{"class":405},[320,8822,1090],{"class":345},[320,8824,8825],{"class":354},"\"get_item\"",[320,8827,358],{"class":345},[320,8829,8830,8832,8834,8836,8839],{"class":322,"line":13},[320,8831,8694],{"class":345},[320,8833,8697],{"class":405},[320,8835,1090],{"class":345},[320,8837,8838],{"class":354},"\"获取指定项目的详情\"",[320,8840,8623],{"class":345},[320,8842,8843,8845,8848,8850,8853],{"class":322,"line":9},[320,8844,8694],{"class":345},[320,8846,8847],{"class":405},"WithString",[320,8849,1090],{"class":345},[320,8851,8852],{"class":354},"\"item_id\"",[320,8854,358],{"class":345},[320,8856,8857,8860,8863],{"class":322,"line":361},[320,8858,8859],{"class":345},"        mcp.",[320,8861,8862],{"class":405},"Required",[320,8864,8599],{"class":345},[320,8866,8867,8869,8872,8874,8877],{"class":322,"line":26},[320,8868,8859],{"class":345},[320,8870,8871],{"class":405},"Description",[320,8873,1090],{"class":345},[320,8875,8876],{"class":354},"\"项目 ID\"",[320,8878,8623],{"class":345},[320,8880,8881],{"class":322,"line":29},[320,8882,8883],{"class":345},"    ),\n",[320,8885,8886],{"class":322,"line":387},[320,8887,934],{"class":345},[320,8889,8890],{"class":322,"line":931},[320,8891,891],{"emptyLinePlaceholder":7},[320,8893,8894,8896,8898,8901,8903,8905,8907,8909,8911,8913,8915,8917,8919,8921,8923,8925,8927,8929,8931,8933,8935,8937],{"class":322,"line":937},[320,8895,8722],{"class":345},[320,8897,8725],{"class":405},[320,8899,8900],{"class":345},"(getTool, ",[320,8902,1070],{"class":331},[320,8904,1090],{"class":345},[320,8906,8735],{"class":1076},[320,8908,8738],{"class":405},[320,8910,959],{"class":345},[320,8912,1384],{"class":405},[320,8914,452],{"class":345},[320,8916,8747],{"class":1076},[320,8918,5369],{"class":405},[320,8920,959],{"class":345},[320,8922,8754],{"class":405},[320,8924,1099],{"class":345},[320,8926,1048],{"class":331},[320,8928,8761],{"class":405},[320,8930,959],{"class":345},[320,8932,8766],{"class":405},[320,8934,452],{"class":345},[320,8936,1111],{"class":331},[320,8938,1114],{"class":345},[320,8940,8941,8944,8946,8949,8952,8954,8956],{"class":322,"line":942},[320,8942,8943],{"class":345},"    itemID, err ",[320,8945,1324],{"class":331},[320,8947,8948],{"class":345}," req.",[320,8950,8951],{"class":405},"RequireString",[320,8953,1090],{"class":345},[320,8955,8852],{"class":354},[320,8957,934],{"class":345},[320,8959,8960,8962,8964,8966,8968],{"class":322,"line":948},[320,8961,1120],{"class":331},[320,8963,3900],{"class":345},[320,8965,3903],{"class":331},[320,8967,3906],{"class":338},[320,8969,346],{"class":345},[320,8971,8972,8974,8976,8979,8981,8984,8986],{"class":322,"line":965},[320,8973,4227],{"class":331},[320,8975,8679],{"class":345},[320,8977,8978],{"class":405},"NewToolResultError",[320,8980,1090],{"class":345},[320,8982,8983],{"class":354},"\"缺少 item_id 参数\"",[320,8985,8794],{"class":345},[320,8987,8797],{"class":338},[320,8989,8990],{"class":322,"line":970},[320,8991,1192],{"class":345},[320,8993,8994],{"class":322,"line":68},[320,8995,8996],{"class":325},"    \u002F\u002F 查询并返回\n",[320,8998,8999,9001,9003,9005,9007,9010,9012],{"class":322,"line":1014},[320,9000,1197],{"class":331},[320,9002,8679],{"class":345},[320,9004,8786],{"class":405},[320,9006,1090],{"class":345},[320,9008,9009],{"class":354},"\"项目详情...\"",[320,9011,8794],{"class":345},[320,9013,8797],{"class":338},[320,9015,9016],{"class":322,"line":1019},[320,9017,739],{"class":345},[676,9019,9021],{"id":9020},"_4-挂载到-gin-路由","4. 挂载到 Gin 路由",[136,9023,9024,9025,9028,9029,9032],{},"关键一步：用 ",[317,9026,9027],{},"server.NewStreamableHTTPServer"," 把 MCP 实例转成 HTTP Handler，再用 ",[317,9030,9031],{},"gin.WrapH"," 适配 Gin。",[310,9034,9036],{"className":869,"code":9035,"language":871,"meta":315,"style":315},"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",[317,9037,9038,9048,9052,9067,9081,9098,9102,9106,9111],{"__ignoreMap":315},[320,9039,9040,9042,9044,9046],{"class":322,"line":15},[320,9041,896],{"class":331},[320,9043,1671],{"class":354},[320,9045,8544],{"class":405},[320,9047,910],{"class":354},[320,9049,9050],{"class":322,"line":13},[320,9051,891],{"emptyLinePlaceholder":7},[320,9053,9054,9057,9059,9061,9064],{"class":322,"line":9},[320,9055,9056],{"class":345},"mcpHTTPServer ",[320,9058,1324],{"class":331},[320,9060,8560],{"class":345},[320,9062,9063],{"class":405},"NewStreamableHTTPServer",[320,9065,9066],{"class":345},"(mcpServer,\n",[320,9068,9069,9071,9074,9076,9079],{"class":322,"line":361},[320,9070,8593],{"class":345},[320,9072,9073],{"class":405},"WithEndpointPath",[320,9075,1090],{"class":345},[320,9077,9078],{"class":354},"\"\u002Fmcp\"",[320,9080,8623],{"class":345},[320,9082,9083,9085,9088,9090,9093,9095],{"class":322,"line":26},[320,9084,8593],{"class":345},[320,9086,9087],{"class":405},"WithHeartbeatInterval",[320,9089,1090],{"class":345},[320,9091,9092],{"class":338},"30",[320,9094,1048],{"class":331},[320,9096,9097],{"class":345},"time.Second),\n",[320,9099,9100],{"class":322,"line":29},[320,9101,934],{"class":345},[320,9103,9104],{"class":322,"line":387},[320,9105,891],{"emptyLinePlaceholder":7},[320,9107,9108],{"class":322,"line":931},[320,9109,9110],{"class":325},"\u002F\u002F 挂载到 Gin\n",[320,9112,9113,9116,9119,9121,9123,9126,9129],{"class":322,"line":937},[320,9114,9115],{"class":345},"r.",[320,9117,9118],{"class":405},"Any",[320,9120,1090],{"class":345},[320,9122,9078],{"class":354},[320,9124,9125],{"class":345},", gin.",[320,9127,9128],{"class":405},"WrapH",[320,9130,9131],{"class":345},"(mcpHTTPServer))\n",[136,9133,9134,9135,9138],{},"就这样，",[317,9136,9137],{},"POST \u002Fmcp"," 端点就生效了。AI 客户端通过这个 URL 发送 JSON-RPC 请求，MCP 服务器自动处理协议握手、工具列表返回、工具调用路由。",[676,9140,9142],{"id":9141},"_5-完整的服务启动代码","5. 完整的服务启动代码",[310,9144,9146],{"className":869,"code":9145,"language":871,"meta":315,"style":315},"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",[317,9147,9148,9158,9170,9174,9179,9202,9211,9219,9224,9228,9233,9241,9245,9250,9263,9275,9289,9293,9310,9314,9319,9348,9381,9385,9389,9403],{"__ignoreMap":315},[320,9149,9150,9152,9155],{"class":322,"line":15},[320,9151,1070],{"class":331},[320,9153,9154],{"class":405}," main",[320,9156,9157],{"class":345},"() {\n",[320,9159,9160,9162,9164,9166,9168],{"class":322,"line":13},[320,9161,1321],{"class":345},[320,9163,1324],{"class":331},[320,9165,1327],{"class":345},[320,9167,1330],{"class":405},[320,9169,1333],{"class":345},[320,9171,9172],{"class":322,"line":9},[320,9173,891],{"emptyLinePlaceholder":7},[320,9175,9176],{"class":322,"line":361},[320,9177,9178],{"class":325},"    \u002F\u002F 创建 MCP 服务器\n",[320,9180,9181,9184,9186,9188,9190,9192,9195,9197,9200],{"class":322,"line":26},[320,9182,9183],{"class":345},"    mcpServer ",[320,9185,1324],{"class":331},[320,9187,8560],{"class":345},[320,9189,8563],{"class":405},[320,9191,1090],{"class":345},[320,9193,9194],{"class":354},"\"my-app\"",[320,9196,452],{"class":345},[320,9198,9199],{"class":354},"\"1.0.0\"",[320,9201,358],{"class":345},[320,9203,9204,9207,9209],{"class":322,"line":29},[320,9205,9206],{"class":345},"        server.",[320,9208,8596],{"class":405},[320,9210,8599],{"class":345},[320,9212,9213,9215,9217],{"class":322,"line":387},[320,9214,9206],{"class":345},[320,9216,8606],{"class":405},[320,9218,8599],{"class":345},[320,9220,9221],{"class":322,"line":931},[320,9222,9223],{"class":345},"    )\n",[320,9225,9226],{"class":322,"line":937},[320,9227,891],{"emptyLinePlaceholder":7},[320,9229,9230],{"class":322,"line":942},[320,9231,9232],{"class":325},"    \u002F\u002F 注册工具\n",[320,9234,9235,9238],{"class":322,"line":948},[320,9236,9237],{"class":405},"    registerTools",[320,9239,9240],{"class":345},"(mcpServer)\n",[320,9242,9243],{"class":322,"line":965},[320,9244,891],{"emptyLinePlaceholder":7},[320,9246,9247],{"class":322,"line":970},[320,9248,9249],{"class":325},"    \u002F\u002F 挂载到 Gin\n",[320,9251,9252,9255,9257,9259,9261],{"class":322,"line":68},[320,9253,9254],{"class":345},"    mcpHTTP ",[320,9256,1324],{"class":331},[320,9258,8560],{"class":345},[320,9260,9063],{"class":405},[320,9262,9066],{"class":345},[320,9264,9265,9267,9269,9271,9273],{"class":322,"line":1014},[320,9266,9206],{"class":345},[320,9268,9073],{"class":405},[320,9270,1090],{"class":345},[320,9272,9078],{"class":354},[320,9274,8623],{"class":345},[320,9276,9277,9279,9281,9283,9285,9287],{"class":322,"line":1019},[320,9278,9206],{"class":345},[320,9280,9087],{"class":405},[320,9282,1090],{"class":345},[320,9284,9092],{"class":338},[320,9286,1048],{"class":331},[320,9288,9097],{"class":345},[320,9290,9291],{"class":322,"line":1033},[320,9292,9223],{"class":345},[320,9294,9295,9297,9299,9301,9303,9305,9307],{"class":322,"line":1042},[320,9296,1358],{"class":345},[320,9298,9118],{"class":405},[320,9300,1090],{"class":345},[320,9302,9078],{"class":354},[320,9304,9125],{"class":345},[320,9306,9128],{"class":405},[320,9308,9309],{"class":345},"(mcpHTTP))\n",[320,9311,9312],{"class":322,"line":1057},[320,9313,891],{"emptyLinePlaceholder":7},[320,9315,9316],{"class":322,"line":1062},[320,9317,9318],{"class":325},"    \u002F\u002F 其他业务路由\n",[320,9320,9321,9323,9325,9327,9330,9332,9334,9336,9338,9340,9342,9344,9346],{"class":322,"line":1067},[320,9322,1358],{"class":345},[320,9324,1361],{"class":405},[320,9326,1090],{"class":345},[320,9328,9329],{"class":354},"\"\u002Fapi\u002Fhealth\"",[320,9331,452],{"class":345},[320,9333,1070],{"class":331},[320,9335,1090],{"class":345},[320,9337,1375],{"class":1076},[320,9339,1287],{"class":331},[320,9341,1309],{"class":405},[320,9343,959],{"class":345},[320,9345,1384],{"class":405},[320,9347,1114],{"class":345},[320,9349,9350,9352,9355,9357,9360,9362,9364,9366,9369,9371,9374,9376,9379],{"class":322,"line":1117},[320,9351,7039],{"class":345},[320,9353,9354],{"class":405},"JSON",[320,9356,1090],{"class":345},[320,9358,9359],{"class":338},"200",[320,9361,452],{"class":345},[320,9363,1309],{"class":405},[320,9365,959],{"class":345},[320,9367,9368],{"class":405},"H",[320,9370,2715],{"class":345},[320,9372,9373],{"class":354},"\"status\"",[320,9375,415],{"class":345},[320,9377,9378],{"class":354},"\"ok\"",[320,9380,739],{"class":345},[320,9382,9383],{"class":322,"line":1134},[320,9384,1576],{"class":345},[320,9386,9387],{"class":322,"line":1146},[320,9388,891],{"emptyLinePlaceholder":7},[320,9390,9391,9393,9396,9398,9401],{"class":322,"line":1157},[320,9392,1358],{"class":345},[320,9394,9395],{"class":405},"Run",[320,9397,1090],{"class":345},[320,9399,9400],{"class":354},"\":8080\"",[320,9402,934],{"class":345},[320,9404,9405],{"class":322,"line":1189},[320,9406,390],{"class":345},[136,9408,9409],{},"此时启动服务，用 curl 测试：",[310,9411,9413],{"className":471,"code":9412,"language":473,"meta":315,"style":315},"# 发现工具列表\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",[317,9414,9415,9420,9436,9446,9454,9458,9463,9475,9483],{"__ignoreMap":315},[320,9416,9417],{"class":322,"line":15},[320,9418,9419],{"class":325},"# 发现工具列表\n",[320,9421,9422,9425,9428,9431,9434],{"class":322,"line":13},[320,9423,9424],{"class":405},"curl",[320,9426,9427],{"class":338}," -X",[320,9429,9430],{"class":354}," POST",[320,9432,9433],{"class":354}," http:\u002F\u002Flocalhost:8080\u002Fmcp",[320,9435,7300],{"class":338},[320,9437,9438,9441,9444],{"class":322,"line":9},[320,9439,9440],{"class":338},"  -H",[320,9442,9443],{"class":354}," \"Content-Type: application\u002Fjson\"",[320,9445,7300],{"class":338},[320,9447,9448,9451],{"class":322,"line":361},[320,9449,9450],{"class":338},"  -d",[320,9452,9453],{"class":354}," '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools\u002Flist\"}'\n",[320,9455,9456],{"class":322,"line":26},[320,9457,891],{"emptyLinePlaceholder":7},[320,9459,9460],{"class":322,"line":29},[320,9461,9462],{"class":325},"# 调用工具\n",[320,9464,9465,9467,9469,9471,9473],{"class":322,"line":387},[320,9466,9424],{"class":405},[320,9468,9427],{"class":338},[320,9470,9430],{"class":354},[320,9472,9433],{"class":354},[320,9474,7300],{"class":338},[320,9476,9477,9479,9481],{"class":322,"line":931},[320,9478,9440],{"class":338},[320,9480,9443],{"class":354},[320,9482,7300],{"class":338},[320,9484,9485,9487],{"class":322,"line":937},[320,9486,9450],{"class":338},[320,9488,9489],{"class":354}," '{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools\u002Fcall\",\"params\":{\"name\":\"list_items\",\"arguments\":{}}}'\n",[131,9491,9493],{"id":9492},"拓展接入鉴权","拓展：接入鉴权",[136,9495,9496],{},"裸奔的 MCP 端点任何人都能调用，生产环境必须加鉴权。思路和普通 API 鉴权一致：Bearer Token。",[676,9498,4820],{"id":4820},[136,9500,9501,9502,9505,9506,9509],{},"MCP 的工具处理器签名为 ",[317,9503,9504],{},"func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error)","，它不接收 Gin 的 ",[317,9507,9508],{},"*gin.Context","，所以无法直接在工具处理器里读取请求头。",[676,9511,9513],{"id":9512},"解决方案中间件传递用户信息","解决方案：中间件传递用户信息",[136,9515,9516,9517,1849],{},"用两层中间件：第一层做 Gin 的 Token 验证，第二层把用户信息从 Gin Context 复制到 ",[317,9518,9519],{},"request.Context",[310,9521,9523],{"className":869,"code":9522,"language":871,"meta":315,"style":315},"\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",[317,9524,9525,9530,9557,9577,9596,9609,9641,9646,9650,9654,9678,9716,9745,9749,9753,9757,9777,9789,9818,9822,9826,9830,9845,9859,9868,9872],{"__ignoreMap":315},[320,9526,9527],{"class":322,"line":15},[320,9528,9529],{"class":325},"\u002F\u002F Gin 鉴权中间件\n",[320,9531,9532,9534,9537,9539,9542,9544,9547,9549,9551,9553,9555],{"class":322,"line":13},[320,9533,1070],{"class":331},[320,9535,9536],{"class":405}," AuthRequired",[320,9538,1090],{"class":345},[320,9540,9541],{"class":1076},"authService",[320,9543,1287],{"class":331},[320,9545,9546],{"class":405},"AuthService",[320,9548,1084],{"class":345},[320,9550,1309],{"class":405},[320,9552,959],{"class":345},[320,9554,6877],{"class":405},[320,9556,346],{"class":345},[320,9558,9559,9561,9563,9565,9567,9569,9571,9573,9575],{"class":322,"line":9},[320,9560,1197],{"class":331},[320,9562,6910],{"class":331},[320,9564,1090],{"class":345},[320,9566,1375],{"class":1076},[320,9568,1287],{"class":331},[320,9570,1309],{"class":405},[320,9572,959],{"class":345},[320,9574,1384],{"class":405},[320,9576,1114],{"class":345},[320,9578,9579,9582,9584,9586,9589,9591,9594],{"class":322,"line":361},[320,9580,9581],{"class":345},"        authHeader ",[320,9583,1324],{"class":331},[320,9585,1441],{"class":345},[320,9587,9588],{"class":405},"GetHeader",[320,9590,1090],{"class":345},[320,9592,9593],{"class":354},"\"Authorization\"",[320,9595,934],{"class":345},[320,9597,9598,9600,9603,9605,9607],{"class":322,"line":26},[320,9599,1456],{"class":331},[320,9601,9602],{"class":345}," authHeader ",[320,9604,1126],{"class":331},[320,9606,2920],{"class":354},[320,9608,346],{"class":345},[320,9610,9611,9613,9616,9618,9621,9623,9625,9627,9629,9631,9634,9636,9639],{"class":322,"line":29},[320,9612,1485],{"class":345},[320,9614,9615],{"class":405},"AbortWithStatusJSON",[320,9617,1090],{"class":345},[320,9619,9620],{"class":338},"401",[320,9622,452],{"class":345},[320,9624,1309],{"class":405},[320,9626,959],{"class":345},[320,9628,9368],{"class":405},[320,9630,2715],{"class":345},[320,9632,9633],{"class":354},"\"message\"",[320,9635,415],{"class":345},[320,9637,9638],{"class":354},"\"缺少认证令牌\"",[320,9640,739],{"class":345},[320,9642,9643],{"class":322,"line":387},[320,9644,9645],{"class":331},"            return\n",[320,9647,9648],{"class":322,"line":931},[320,9649,1550],{"class":345},[320,9651,9652],{"class":322,"line":937},[320,9653,891],{"emptyLinePlaceholder":7},[320,9655,9656,9659,9661,9663,9666,9669,9672,9674,9676],{"class":322,"line":942},[320,9657,9658],{"class":345},"        parts ",[320,9660,1324],{"class":331},[320,9662,1175],{"class":345},[320,9664,9665],{"class":405},"SplitN",[320,9667,9668],{"class":345},"(authHeader, ",[320,9670,9671],{"class":354},"\" \"",[320,9673,452],{"class":345},[320,9675,8117],{"class":338},[320,9677,934],{"class":345},[320,9679,9680,9682,9685,9688,9690,9692,9694,9697,9700,9703,9706,9708,9711,9714],{"class":322,"line":948},[320,9681,1456],{"class":331},[320,9683,9684],{"class":405}," len",[320,9686,9687],{"class":345},"(parts) ",[320,9689,3903],{"class":331},[320,9691,2667],{"class":338},[320,9693,1466],{"class":331},[320,9695,9696],{"class":331}," !",[320,9698,9699],{"class":345},"strings.",[320,9701,9702],{"class":405},"EqualFold",[320,9704,9705],{"class":345},"(parts[",[320,9707,1765],{"class":338},[320,9709,9710],{"class":345},"], ",[320,9712,9713],{"class":354},"\"Bearer\"",[320,9715,1114],{"class":345},[320,9717,9718,9720,9722,9724,9726,9728,9730,9732,9734,9736,9738,9740,9743],{"class":322,"line":965},[320,9719,1485],{"class":345},[320,9721,9615],{"class":405},[320,9723,1090],{"class":345},[320,9725,9620],{"class":338},[320,9727,452],{"class":345},[320,9729,1309],{"class":405},[320,9731,959],{"class":345},[320,9733,9368],{"class":405},[320,9735,2715],{"class":345},[320,9737,9633],{"class":354},[320,9739,415],{"class":345},[320,9741,9742],{"class":354},"\"格式错误，请使用 Bearer \u003Ctoken>\"",[320,9744,739],{"class":345},[320,9746,9747],{"class":322,"line":970},[320,9748,9645],{"class":331},[320,9750,9751],{"class":322,"line":68},[320,9752,1550],{"class":345},[320,9754,9755],{"class":322,"line":1014},[320,9756,891],{"emptyLinePlaceholder":7},[320,9758,9759,9762,9764,9767,9770,9772,9774],{"class":322,"line":1019},[320,9760,9761],{"class":345},"        user, err ",[320,9763,1324],{"class":331},[320,9765,9766],{"class":345}," authService.",[320,9768,9769],{"class":405},"GetUserByToken",[320,9771,9705],{"class":345},[320,9773,3409],{"class":338},[320,9775,9776],{"class":345},"])\n",[320,9778,9779,9781,9783,9785,9787],{"class":322,"line":1033},[320,9780,1456],{"class":331},[320,9782,3900],{"class":345},[320,9784,3903],{"class":331},[320,9786,3906],{"class":338},[320,9788,346],{"class":345},[320,9790,9791,9793,9795,9797,9799,9801,9803,9805,9807,9809,9811,9813,9816],{"class":322,"line":1042},[320,9792,1485],{"class":345},[320,9794,9615],{"class":405},[320,9796,1090],{"class":345},[320,9798,9620],{"class":338},[320,9800,452],{"class":345},[320,9802,1309],{"class":405},[320,9804,959],{"class":345},[320,9806,9368],{"class":405},[320,9808,2715],{"class":345},[320,9810,9633],{"class":354},[320,9812,415],{"class":345},[320,9814,9815],{"class":354},"\"无效的认证令牌\"",[320,9817,739],{"class":345},[320,9819,9820],{"class":322,"line":1057},[320,9821,9645],{"class":331},[320,9823,9824],{"class":322,"line":1062},[320,9825,1550],{"class":345},[320,9827,9828],{"class":322,"line":1067},[320,9829,891],{"emptyLinePlaceholder":7},[320,9831,9832,9834,9837,9839,9842],{"class":322,"line":1117},[320,9833,7039],{"class":345},[320,9835,9836],{"class":405},"Set",[320,9838,1090],{"class":345},[320,9840,9841],{"class":354},"\"userID\"",[320,9843,9844],{"class":345},", user.ID)\n",[320,9846,9847,9849,9851,9853,9856],{"class":322,"line":1134},[320,9848,7039],{"class":345},[320,9850,9836],{"class":405},[320,9852,1090],{"class":345},[320,9854,9855],{"class":354},"\"username\"",[320,9857,9858],{"class":345},", user.Username)\n",[320,9860,9861,9863,9866],{"class":322,"line":1146},[320,9862,7039],{"class":345},[320,9864,9865],{"class":405},"Next",[320,9867,1333],{"class":345},[320,9869,9870],{"class":322,"line":1157},[320,9871,1192],{"class":345},[320,9873,9874],{"class":322,"line":1189},[320,9875,390],{"class":345},[310,9877,9879],{"className":869,"code":9878,"language":871,"meta":315,"style":315},"\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",[317,9880,9881,9886,9895,9901,9914,9926,9930,9934,9951,9971,9985,10009,10031,10046,10054,10058],{"__ignoreMap":315},[320,9882,9883],{"class":322,"line":15},[320,9884,9885],{"class":325},"\u002F\u002F 用户信息桥接中间件：Gin Context → request.Context\n",[320,9887,9888,9890,9893],{"class":322,"line":13},[320,9889,1022],{"class":331},[320,9891,9892],{"class":405}," ctxKey",[320,9894,2491],{"class":331},[320,9896,9897,9899],{"class":322,"line":9},[320,9898,2504],{"class":331},[320,9900,899],{"class":345},[320,9902,9903,9906,9909,9911],{"class":322,"line":361},[320,9904,9905],{"class":338},"    ctxUserID",[320,9907,9908],{"class":405},"   ctxKey",[320,9910,342],{"class":331},[320,9912,9913],{"class":354}," \"userID\"\n",[320,9915,9916,9919,9921,9923],{"class":322,"line":26},[320,9917,9918],{"class":338},"    ctxUsername",[320,9920,9892],{"class":405},[320,9922,342],{"class":331},[320,9924,9925],{"class":354}," \"username\"\n",[320,9927,9928],{"class":322,"line":29},[320,9929,934],{"class":345},[320,9931,9932],{"class":322,"line":387},[320,9933,891],{"emptyLinePlaceholder":7},[320,9935,9936,9938,9941,9943,9945,9947,9949],{"class":322,"line":931},[320,9937,1070],{"class":331},[320,9939,9940],{"class":405}," WithUserContext",[320,9942,4183],{"class":345},[320,9944,1309],{"class":405},[320,9946,959],{"class":345},[320,9948,6877],{"class":405},[320,9950,346],{"class":345},[320,9952,9953,9955,9957,9959,9961,9963,9965,9967,9969],{"class":322,"line":937},[320,9954,1197],{"class":331},[320,9956,6910],{"class":331},[320,9958,1090],{"class":345},[320,9960,1375],{"class":1076},[320,9962,1287],{"class":331},[320,9964,1309],{"class":405},[320,9966,959],{"class":345},[320,9968,1384],{"class":405},[320,9970,1114],{"class":345},[320,9972,9973,9976,9978,9981,9983],{"class":322,"line":942},[320,9974,9975],{"class":345},"        ctx ",[320,9977,1324],{"class":331},[320,9979,9980],{"class":345}," c.Request.",[320,9982,1384],{"class":405},[320,9984,1333],{"class":345},[320,9986,9987,9989,9991,9993,9996,9999,10002,10004,10006],{"class":322,"line":948},[320,9988,9975],{"class":345},[320,9990,1140],{"class":331},[320,9992,3816],{"class":345},[320,9994,9995],{"class":405},"WithValue",[320,9997,9998],{"class":345},"(ctx, ctxUserID, c.",[320,10000,10001],{"class":405},"GetUint",[320,10003,1090],{"class":345},[320,10005,9841],{"class":354},[320,10007,10008],{"class":345},"))\n",[320,10010,10011,10013,10015,10017,10019,10022,10025,10027,10029],{"class":322,"line":965},[320,10012,9975],{"class":345},[320,10014,1140],{"class":331},[320,10016,3816],{"class":345},[320,10018,9995],{"class":405},[320,10020,10021],{"class":345},"(ctx, ctxUsername, c.",[320,10023,10024],{"class":405},"GetString",[320,10026,1090],{"class":345},[320,10028,9855],{"class":354},[320,10030,10008],{"class":345},[320,10032,10033,10036,10038,10040,10043],{"class":322,"line":970},[320,10034,10035],{"class":345},"        c.Request ",[320,10037,1140],{"class":331},[320,10039,9980],{"class":345},[320,10041,10042],{"class":405},"WithContext",[320,10044,10045],{"class":345},"(ctx)\n",[320,10047,10048,10050,10052],{"class":322,"line":68},[320,10049,7039],{"class":345},[320,10051,9865],{"class":405},[320,10053,1333],{"class":345},[320,10055,10056],{"class":322,"line":1014},[320,10057,1192],{"class":345},[320,10059,10060],{"class":322,"line":1019},[320,10061,390],{"class":345},[676,10063,10064],{"id":10064},"组合使用",[310,10066,10068],{"className":869,"code":10067,"language":871,"meta":315,"style":315},"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",[317,10069,10070,10082,10093,10104,10117],{"__ignoreMap":315},[320,10071,10072,10074,10076,10078,10080],{"class":322,"line":15},[320,10073,9115],{"class":345},[320,10075,9118],{"class":405},[320,10077,1090],{"class":345},[320,10079,9078],{"class":354},[320,10081,358],{"class":345},[320,10083,10084,10087,10090],{"class":322,"line":13},[320,10085,10086],{"class":405},"    AuthRequired",[320,10088,10089],{"class":345},"(authService),  ",[320,10091,10092],{"class":325},"\u002F\u002F 1. 验证 Token\n",[320,10094,10095,10098,10101],{"class":322,"line":9},[320,10096,10097],{"class":405},"    WithUserContext",[320,10099,10100],{"class":345},"(),           ",[320,10102,10103],{"class":325},"\u002F\u002F 2. 桥接用户信息到 request.Context\n",[320,10105,10106,10109,10111,10114],{"class":322,"line":361},[320,10107,10108],{"class":345},"    gin.",[320,10110,9128],{"class":405},[320,10112,10113],{"class":345},"(mcpHTTP),         ",[320,10115,10116],{"class":325},"\u002F\u002F 3. MCP 处理\n",[320,10118,10119],{"class":322,"line":26},[320,10120,934],{"class":345},[136,10122,10123],{},"在工具处理器里就能拿到用户信息了：",[310,10125,10127],{"className":869,"code":10126,"language":871,"meta":315,"style":315},"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",[317,10128,10129,10184,10205,10214,10231,10235,10239,10255,10260,10273],{"__ignoreMap":315},[320,10130,10131,10133,10135,10138,10140,10143,10145,10148,10150,10152,10154,10156,10158,10160,10162,10164,10166,10168,10170,10172,10174,10176,10178,10180,10182],{"class":322,"line":15},[320,10132,1070],{"class":331},[320,10134,1073],{"class":345},[320,10136,10137],{"class":1076},"h ",[320,10139,1048],{"class":331},[320,10141,10142],{"class":405},"Handler",[320,10144,1084],{"class":345},[320,10146,10147],{"class":405},"handleListItems",[320,10149,1090],{"class":345},[320,10151,8735],{"class":1076},[320,10153,8738],{"class":405},[320,10155,959],{"class":345},[320,10157,1384],{"class":405},[320,10159,452],{"class":345},[320,10161,8747],{"class":1076},[320,10163,5369],{"class":405},[320,10165,959],{"class":345},[320,10167,8754],{"class":405},[320,10169,1099],{"class":345},[320,10171,1048],{"class":331},[320,10173,8761],{"class":405},[320,10175,959],{"class":345},[320,10177,8766],{"class":405},[320,10179,452],{"class":345},[320,10181,1111],{"class":331},[320,10183,1114],{"class":345},[320,10185,10186,10189,10191,10194,10197,10200,10203],{"class":322,"line":13},[320,10187,10188],{"class":345},"    userID, ok ",[320,10190,1324],{"class":331},[320,10192,10193],{"class":345}," ctx.",[320,10195,10196],{"class":405},"Value",[320,10198,10199],{"class":345},"(ctxUserID).(",[320,10201,10202],{"class":331},"uint",[320,10204,934],{"class":345},[320,10206,10207,10209,10211],{"class":322,"line":9},[320,10208,1120],{"class":331},[320,10210,9696],{"class":331},[320,10212,10213],{"class":345},"ok {\n",[320,10215,10216,10218,10220,10222,10224,10227,10229],{"class":322,"line":361},[320,10217,4227],{"class":331},[320,10219,8679],{"class":345},[320,10221,8978],{"class":405},[320,10223,1090],{"class":345},[320,10225,10226],{"class":354},"\"未认证\"",[320,10228,8794],{"class":345},[320,10230,8797],{"class":338},[320,10232,10233],{"class":322,"line":26},[320,10234,1192],{"class":345},[320,10236,10237],{"class":322,"line":29},[320,10238,891],{"emptyLinePlaceholder":7},[320,10240,10241,10244,10246,10249,10252],{"class":322,"line":387},[320,10242,10243],{"class":345},"    items, _ ",[320,10245,1324],{"class":331},[320,10247,10248],{"class":345}," h.service.",[320,10250,10251],{"class":405},"ListByUser",[320,10253,10254],{"class":345},"(userID)\n",[320,10256,10257],{"class":322,"line":931},[320,10258,10259],{"class":325},"    \u002F\u002F ...\n",[320,10261,10262,10264,10266,10268,10271],{"class":322,"line":937},[320,10263,1197],{"class":331},[320,10265,8679],{"class":345},[320,10267,8786],{"class":405},[320,10269,10270],{"class":345},"(result), ",[320,10272,8797],{"class":338},[320,10274,10275],{"class":322,"line":942},[320,10276,390],{"class":345},[676,10278,10279],{"id":10279},"客户端配置",[136,10281,10282],{},"AI 客户端（如 Claude Desktop）配置 MCP 服务器时带上 Authorization 头：",[310,10284,10286],{"className":831,"code":10285,"language":833,"meta":315,"style":315},"{\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",[317,10287,10288,10292,10298,10304,10316,10323,10333,10338,10342,10346],{"__ignoreMap":315},[320,10289,10290],{"class":322,"line":15},[320,10291,5842],{"class":345},[320,10293,10294,10296],{"class":322,"line":13},[320,10295,5847],{"class":338},[320,10297,848],{"class":345},[320,10299,10300,10302],{"class":322,"line":9},[320,10301,8571],{"class":338},[320,10303,848],{"class":345},[320,10305,10306,10309,10311,10314],{"class":322,"line":361},[320,10307,10308],{"class":338},"      \"url\"",[320,10310,415],{"class":345},[320,10312,10313],{"class":354},"\"http:\u002F\u002Flocalhost:8080\u002Fmcp\"",[320,10315,358],{"class":345},[320,10317,10318,10321],{"class":322,"line":26},[320,10319,10320],{"class":338},"      \"headers\"",[320,10322,848],{"class":345},[320,10324,10325,10328,10330],{"class":322,"line":29},[320,10326,10327],{"class":338},"        \"Authorization\"",[320,10329,415],{"class":345},[320,10331,10332],{"class":354},"\"Bearer your-token-here\"\n",[320,10334,10335],{"class":322,"line":387},[320,10336,10337],{"class":345},"      }\n",[320,10339,10340],{"class":322,"line":931},[320,10341,1192],{"class":345},[320,10343,10344],{"class":322,"line":937},[320,10345,5894],{"class":345},[320,10347,10348],{"class":322,"line":942},[320,10349,390],{"class":345},[131,10351,2164],{"id":2164},[154,10353,10354,10363],{},[157,10355,10356],{},[160,10357,10358,10361],{},[163,10359,10360],{},"步骤",[163,10362,2295],{},[170,10364,10365,10374,10385,10394,10403],{},[160,10366,10367,10371],{},[175,10368,10369],{},[317,10370,8563],{},[175,10372,10373],{},"创建 MCP 协议实例",[160,10375,10376,10382],{},[175,10377,10378,4762,10380],{},[317,10379,8682],{},[317,10381,8725],{},[175,10383,10384],{},"定义并注册工具",[160,10386,10387,10391],{},[175,10388,10389],{},[317,10390,9063],{},[175,10392,10393],{},"转为 HTTP Handler",[160,10395,10396,10400],{},[175,10397,10398],{},[317,10399,9031],{},[175,10401,10402],{},"挂载到 Gin 路由",[160,10404,10405,10413],{},[175,10406,10407,4762,10410],{},[317,10408,10409],{},"AuthRequired",[317,10411,10412],{},"WithUserContext",[175,10414,10415],{},"鉴权 + 用户信息桥接",[136,10417,10418,10419,10422,10423,1849],{},"核心就一句话：",[140,10420,10421],{},"MCP 是 HTTP Handler，挂上去就行。"," 鉴权复用你已有的中间件体系，只需要多写一个桥接函数把用户信息传进 ",[317,10424,9519],{},[551,10426,10427],{},"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":315,"searchDepth":13,"depth":9,"links":10429},[10430,10431,10432,10433,10440,10446],{"id":8446,"depth":13,"text":8447},{"id":8456,"depth":13,"text":8457},{"id":8473,"depth":13,"text":8473},{"id":8499,"depth":13,"text":8499,"children":10434},[10435,10436,10437,10438,10439],{"id":8502,"depth":9,"text":8503},{"id":8529,"depth":9,"text":8530},{"id":8643,"depth":9,"text":8644},{"id":9020,"depth":9,"text":9021},{"id":9141,"depth":9,"text":9142},{"id":9492,"depth":13,"text":9493,"children":10441},[10442,10443,10444,10445],{"id":4820,"depth":9,"text":4820},{"id":9512,"depth":9,"text":9513},{"id":10064,"depth":9,"text":10064},{"id":10279,"depth":9,"text":10279},{"id":2164,"depth":13,"text":2164},"介绍如何使用 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":8441,"description":10447},"posts\u002Fgo-mcp-gin\u002Findex",[2236,6704,4953,6705,5043],"2026-06-01","5fZl1Vigs2lHb5fHPz-0YZFyFD38ws1heOouZUG1g6M",{"id":10458,"title":10459,"author":142,"body":10460,"description":10725,"difficulty":2227,"draft":19,"extension":565,"image":10726,"meta":10727,"navigation":7,"path":10728,"pinned":19,"published":10729,"seo":10730,"series":571,"seriesOrder":9,"stem":10731,"tags":10732,"updated":576,"__hash__":10736},"posts\u002Fposts\u002Fnuxt-tailwind-underline-fix\u002Findex.md","Nuxt 3 + Tailwind CSS 客户端导航链接下划线闪烁问题",{"type":128,"value":10461,"toc":10718},[10462,10465,10468,10483,10486,10489,10492,10533,10544,10550,10572,10579,10583,10586,10589,10592,10595,10618,10629,10686,10691,10704,10706,10712,10715],[131,10463,10464],{"id":10464},"问题描述",[136,10466,10467],{},"在 Nuxt 3 项目中使用 Tailwind CSS 时，发现一个奇怪的现象：",[240,10469,10470,10480],{},[243,10471,10472,10473,10476,10477],{},"首次通过客户端导航（点击 ",[317,10474,10475],{},"\u003CNuxtLink>","）跳转到某个页面时，",[140,10478,10479],{},"所有链接文字都会出现下划线",[243,10481,10482],{},"手动刷新页面后，下划线消失，样式恢复正常",[136,10484,10485],{},"这个问题在开发模式和生产构建后都会出现。",[131,10487,10488],{"id":10488},"原因分析",[136,10490,10491],{},"Tailwind CSS 的 Preflight（基于 modern-normalize）会重置链接样式：",[310,10493,10497],{"className":10494,"code":10495,"language":10496,"meta":315,"style":315},"language-css shiki shiki-themes github-light github-dark","a {\n  color: inherit;\n  text-decoration: inherit;\n}\n","css",[317,10498,10499,10505,10518,10529],{"__ignoreMap":315},[320,10500,10501,10503],{"class":322,"line":15},[320,10502,535],{"class":411},[320,10504,346],{"class":345},[320,10506,10507,10510,10512,10515],{"class":322,"line":13},[320,10508,10509],{"class":338},"  color",[320,10511,415],{"class":345},[320,10513,10514],{"class":338},"inherit",[320,10516,10517],{"class":345},";\n",[320,10519,10520,10523,10525,10527],{"class":322,"line":9},[320,10521,10522],{"class":338},"  text-decoration",[320,10524,415],{"class":345},[320,10526,10514],{"class":338},[320,10528,10517],{"class":345},[320,10530,10531],{"class":322,"line":361},[320,10532,390],{"class":345},[136,10534,10535,10536,10539,10540,10543],{},"正常情况下，这会让链接继承父元素的 ",[317,10537,10538],{},"text-decoration","（通常是 ",[317,10541,10542],{},"none","），从而去掉浏览器默认的下划线。",[136,10545,10546,10547,690],{},"问题出在 ",[140,10548,10549],{},"Nuxt 的客户端导航机制",[621,10551,10552,10558,10561,10566,10569],{},[243,10553,10554,10555,10557],{},"用户点击 ",[317,10556,10475],{},"，Vue Router 拦截导航",[243,10559,10560],{},"新页面组件异步加载并渲染",[243,10562,10563],{},[140,10564,10565],{},"在 CSS 完全加载\u002F应用之前，页面已经渲染了",[243,10567,10568],{},"此时浏览器使用默认样式（链接带下划线）",[243,10570,10571],{},"CSS 加载完成后，下划线消失",[136,10573,10574,10575,10578],{},"这就是典型的 ",[140,10576,10577],{},"FOUC（Flash of Unstyled Content）"," 问题。",[131,10580,10582],{"id":10581},"为什么刷新就正常了","为什么刷新就正常了？",[136,10584,10585],{},"刷新页面时，浏览器会等待所有 CSS 加载完成后再渲染页面（SSR 模式下 HTML 和 CSS 是一起返回的）。所以刷新后样式是正确的。",[136,10587,10588],{},"而客户端导航是 JavaScript 驱动的，CSS 的加载和页面的渲染是异步的，就出现了时序差。",[131,10590,10591],{"id":10591},"解决方案",[136,10593,10594],{},"尝试了多种方案：",[240,10596,10597,10603,10609,10615],{},[243,10598,10599,10602],{},[317,10600,10601],{},"@layer base"," 覆盖 — 无效，CSS layers 的优先级问题",[243,10604,10605,10608],{},[317,10606,10607],{},"corePlugins.preflight: false"," — 无效，且破坏其他基础样式",[243,10610,10611,10614],{},[317,10612,10613],{},"features.inlineStyles: true"," — 无效",[243,10616,10617],{},"页面过渡动画 — 只是掩盖，不能根治",[136,10619,10620,10621,10624,10625,10628],{},"最终有效的方案是在 ",[317,10622,10623],{},"app.vue"," 中用 ",[317,10626,10627],{},"!important"," 强制覆盖：",[310,10630,10634],{"className":10631,"code":10632,"language":10633,"meta":315,"style":315},"language-vue shiki shiki-themes github-light github-dark","\u003Cstyle>\na:not(.prose a) {\n  text-decoration-line: none !important;\n}\n\u003C\u002Fstyle>\n","vue",[317,10635,10636,10644,10660,10674,10678],{"__ignoreMap":315},[320,10637,10638,10640,10642],{"class":322,"line":15},[320,10639,756],{"class":345},[320,10641,551],{"class":411},[320,10643,761],{"class":345},[320,10645,10646,10648,10651,10653,10656,10658],{"class":322,"line":13},[320,10647,535],{"class":411},[320,10649,10650],{"class":405},":not",[320,10652,1090],{"class":345},[320,10654,10655],{"class":405},".prose",[320,10657,3375],{"class":411},[320,10659,1114],{"class":345},[320,10661,10662,10665,10667,10669,10672],{"class":322,"line":9},[320,10663,10664],{"class":338},"  text-decoration-line",[320,10666,415],{"class":345},[320,10668,10542],{"class":338},[320,10670,10671],{"class":331}," !important",[320,10673,10517],{"class":345},[320,10675,10676],{"class":322,"line":361},[320,10677,390],{"class":345},[320,10679,10680,10682,10684],{"class":322,"line":26},[320,10681,781],{"class":345},[320,10683,551],{"class":411},[320,10685,761],{"class":345},[136,10687,10688],{},[140,10689,10690],{},"关键点：",[240,10692,10693,10698],{},[243,10694,10695,10697],{},[317,10696,10627],{}," 是为了对抗 Tailwind CSS layers 的优先级",[243,10699,10700,10703],{},[317,10701,10702],{},":not(.prose a)"," 排除文章正文区域，保留文章内链接的下划线（可读性需要）",[131,10705,2164],{"id":2164},[136,10707,10708,10709,10711],{},"这是 Nuxt 3 + Tailwind CSS 的一个已知问题，本质上是 CSS 加载时序导致的 FOUC。",[317,10710,10627],{}," 虽然不够优雅，但在这个场景下是务实的解决方案。",[136,10713,10714],{},"如果你有更好的方案，欢迎在项目仓库的 Issues 中提出！",[551,10716,10717],{},"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":315,"searchDepth":13,"depth":9,"links":10719},[10720,10721,10722,10723,10724],{"id":10464,"depth":13,"text":10464},{"id":10488,"depth":13,"text":10488},{"id":10581,"depth":13,"text":10582},{"id":10591,"depth":13,"text":10591},{"id":2164,"depth":13,"text":2164},"记录 Nuxt 3 配合 Tailwind CSS 时，客户端导航导致链接文字出现下划线闪烁的问题及解决方案。","\u002Fposts\u002Fnuxt-tailwind-underline-fix\u002Fimg\u002Fcover.png",{},"\u002Fposts\u002Fnuxt-tailwind-underline-fix","2026-05-31T18:47:00",{"title":10459,"description":10725},"posts\u002Fnuxt-tailwind-underline-fix\u002Findex",[575,10733,10734,10735],"Tailwind CSS","CSS","踩坑","WMLpTEsIoJqufwEfXyIiBgUYxcPY2XkvOwqAD4hbe6g",{"id":10738,"title":10739,"author":126,"body":10740,"description":11871,"difficulty":2227,"draft":19,"extension":565,"image":11872,"meta":11873,"navigation":7,"path":11874,"pinned":19,"published":11875,"seo":11876,"series":571,"seriesOrder":13,"stem":11877,"tags":11878,"updated":576,"__hash__":11880},"posts\u002Fposts\u002Fnuxt-content-lessons\u002Findex.md","Nuxt Content v3 踩坑记录：目录、图片与那些反直觉的设计",{"type":128,"value":10741,"toc":11861},[10742,10745,10748,10751,10755,10758,10768,10771,10778,10842,10849,10852,10859,11118,11128,11132,11135,11138,11163,11177,11180,11203,11209,11212,11215,11218,11223,11230,11445,11450,11494,11498,11501,11508,11511,11558,11564,11789,11799,11801,11852,11855,11858],[10743,10744,585],"h1",{"id":585},[136,10746,10747],{},"最近在用 Nuxt 3 + Nuxt Content v3 搭建一个个人博客，参考了一个 SvelteKit 项目（svaf）的架构。原以为换个框架只是语法差异，没想到在几个关键功能上踩了不少坑。",[136,10749,10750],{},"这篇文章记录了三个核心问题的排查过程和最终方案。",[10743,10752,10754],{"id":10753},"问题一目录导航toc只有-h2","问题一：目录导航（TOC）只有 h2",[131,10756,10757],{"id":10757},"现象",[136,10759,10760,10761,10764,10765,1849],{},"Nuxt Content 内置了 TOC 数据，挂在 ",[317,10762,10763],{},"post.body.toc"," 上。直接用它渲染目录，发现",[140,10766,10767],{},"只有 h2 标题，h3 全部丢失",[131,10769,10770],{"id":10770},"排查",[136,10772,10773,10774,10777],{},"查了官方文档，发现 ",[317,10775,10776],{},"build.markdown.toc.depth"," 可以控制深度：",[310,10779,10781],{"className":312,"code":10780,"language":314,"meta":315,"style":315},"\u002F\u002F nuxt.config.ts\ncontent: {\n  build: {\n    markdown: {\n      toc: { depth: 3 }  \u002F\u002F 包含 h3\n    }\n  }\n}\n",[317,10782,10783,10788,10795,10802,10809,10830,10834,10838],{"__ignoreMap":315},[320,10784,10785],{"class":322,"line":15},[320,10786,10787],{"class":325},"\u002F\u002F nuxt.config.ts\n",[320,10789,10790,10793],{"class":322,"line":13},[320,10791,10792],{"class":405},"content",[320,10794,848],{"class":345},[320,10796,10797,10800],{"class":322,"line":9},[320,10798,10799],{"class":405},"  build",[320,10801,848],{"class":345},[320,10803,10804,10807],{"class":322,"line":361},[320,10805,10806],{"class":405},"    markdown",[320,10808,848],{"class":345},[320,10810,10811,10814,10817,10820,10822,10824,10827],{"class":322,"line":26},[320,10812,10813],{"class":405},"      toc",[320,10815,10816],{"class":345},": { ",[320,10818,10819],{"class":405},"depth",[320,10821,415],{"class":345},[320,10823,3831],{"class":338},[320,10825,10826],{"class":345}," }  ",[320,10828,10829],{"class":325},"\u002F\u002F 包含 h3\n",[320,10831,10832],{"class":322,"line":29},[320,10833,1192],{"class":345},[320,10835,10836],{"class":322,"line":387},[320,10837,5894],{"class":345},[320,10839,10840],{"class":322,"line":931},[320,10841,390],{"class":345},[136,10843,10844,10845,10848],{},"配置改了，清了缓存重启，",[140,10846,10847],{},"依然只有 h2","。这个配置似乎不起作用。",[131,10850,10851],{"id":10851},"最终方案",[136,10853,10854,10855,10858],{},"放弃依赖 Nuxt Content 的 toc，直接从 ",[317,10856,10857],{},"post.body"," 的 AST 里递归提取所有标题：",[310,10860,10862],{"className":312,"code":10861,"language":314,"meta":315,"style":315},"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",[317,10863,10864,10906,10923,10936,10941,10994,11021,11051,11075,11079,11103,11107,11114],{"__ignoreMap":315},[320,10865,10866,10869,10872,10874,10877,10879,10882,10884,10887,10889,10892,10894,10896,10899,10901,10903],{"class":322,"line":15},[320,10867,10868],{"class":331},"function",[320,10870,10871],{"class":405}," extractHeadings",[320,10873,1090],{"class":345},[320,10875,10876],{"class":1076},"node",[320,10878,2510],{"class":331},[320,10880,10881],{"class":338}," any",[320,10883,452],{"class":345},[320,10885,10886],{"class":1076},"list",[320,10888,2510],{"class":331},[320,10890,10891],{"class":405}," Heading",[320,10893,2515],{"class":345},[320,10895,1140],{"class":331},[320,10897,10898],{"class":345}," [])",[320,10900,2510],{"class":331},[320,10902,10891],{"class":405},[320,10904,10905],{"class":345},"[] {\n",[320,10907,10908,10911,10913,10916,10919,10921],{"class":322,"line":13},[320,10909,10910],{"class":331},"  if",[320,10912,1073],{"class":345},[320,10914,10915],{"class":331},"!",[320,10917,10918],{"class":345},"node) ",[320,10920,3424],{"class":331},[320,10922,6274],{"class":345},[320,10924,10925,10927,10930,10933],{"class":322,"line":9},[320,10926,10910],{"class":331},[320,10928,10929],{"class":345}," (Array.",[320,10931,10932],{"class":405},"isArray",[320,10934,10935],{"class":345},"(node)) {\n",[320,10937,10938],{"class":322,"line":361},[320,10939,10940],{"class":325},"    \u002F\u002F minimark 格式：[\"h2\", {id: \"xxx\"}, \"文本\"]\n",[320,10942,10943,10945,10947,10950,10953,10955,10958,10960,10963,10966,10969,10971,10974,10977,10979,10981,10983,10986,10989,10991],{"class":322,"line":26},[320,10944,1120],{"class":331},[320,10946,1073],{"class":345},[320,10948,10949],{"class":331},"typeof",[320,10951,10952],{"class":345}," node[",[320,10954,1765],{"class":338},[320,10956,10957],{"class":345},"] ",[320,10959,7546],{"class":331},[320,10961,10962],{"class":354}," 'string'",[320,10964,10965],{"class":331}," &&",[320,10967,10968],{"class":354}," \u002F",[320,10970,7465],{"class":331},[320,10972,10973],{"class":7468},"h",[320,10975,10976],{"class":338},"[1-6]",[320,10978,2133],{"class":331},[320,10980,7462],{"class":354},[320,10982,959],{"class":345},[320,10984,10985],{"class":405},"test",[320,10987,10988],{"class":345},"(node[",[320,10990,1765],{"class":338},[320,10992,10993],{"class":345},"])) {\n",[320,10995,10996,10998,11001,11003,11006,11008,11010,11012,11015,11017,11019],{"class":322,"line":29},[320,10997,2804],{"class":331},[320,10999,11000],{"class":338}," level",[320,11002,342],{"class":331},[320,11004,11005],{"class":405}," Number",[320,11007,10988],{"class":345},[320,11009,1765],{"class":338},[320,11011,3385],{"class":345},[320,11013,11014],{"class":405},"charAt",[320,11016,1090],{"class":345},[320,11018,3409],{"class":338},[320,11020,10008],{"class":345},[320,11022,11023,11025,11028,11030,11033,11036,11039,11041,11043,11046,11049],{"class":322,"line":387},[320,11024,2804],{"class":331},[320,11026,11027],{"class":338}," text",[320,11029,342],{"class":331},[320,11031,11032],{"class":405}," extractText",[320,11034,11035],{"class":345},"(node.",[320,11037,11038],{"class":405},"slice",[320,11040,1090],{"class":345},[320,11042,8117],{"class":338},[320,11044,11045],{"class":345},")).",[320,11047,11048],{"class":405},"trim",[320,11050,1333],{"class":345},[320,11052,11053,11056,11059,11062,11064,11067,11069,11072],{"class":322,"line":931},[320,11054,11055],{"class":345},"      list.",[320,11057,11058],{"class":405},"push",[320,11060,11061],{"class":345},"({ id: node[",[320,11063,3409],{"class":338},[320,11065,11066],{"class":345},"]?.id ",[320,11068,2562],{"class":331},[320,11070,11071],{"class":405}," slugify",[320,11073,11074],{"class":345},"(text), text, level })\n",[320,11076,11077],{"class":322,"line":937},[320,11078,1192],{"class":345},[320,11080,11081,11084,11086,11088,11091,11094,11097,11100],{"class":322,"line":942},[320,11082,11083],{"class":331},"    for",[320,11085,1073],{"class":345},[320,11087,2504],{"class":331},[320,11089,11090],{"class":338}," child",[320,11092,11093],{"class":331}," of",[320,11095,11096],{"class":345}," node) ",[320,11098,11099],{"class":405},"extractHeadings",[320,11101,11102],{"class":345},"(child, list)\n",[320,11104,11105],{"class":322,"line":948},[320,11106,5894],{"class":345},[320,11108,11109,11112],{"class":322,"line":965},[320,11110,11111],{"class":331},"  return",[320,11113,6274],{"class":345},[320,11115,11116],{"class":322,"line":970},[320,11117,390],{"class":345},[136,11119,11120,11121,11123,11124,11127],{},"关键点：Nuxt Content v3 的 body 使用 ",[140,11122,128],{}," 格式，标题节点是数组 ",[317,11125,11126],{},"[\"h2\", {id: \"xxx\"}, \"标题文本\"]","，不是对象。",[10743,11129,11131],{"id":11130},"问题二图片路径解析错误","问题二：图片路径解析错误",[131,11133,10757],{"id":11134},"现象-1",[136,11136,11137],{},"markdown 里用相对路径引用图片：",[310,11139,11143],{"className":11140,"code":11141,"language":11142,"meta":315,"style":315},"language-markdown shiki shiki-themes github-light github-dark","![SSHFS挂载效果](img\u002Fsshfs.avif)\n","markdown",[317,11144,11145],{"__ignoreMap":315},[320,11146,11147,11150,11154,11157,11161],{"class":322,"line":15},[320,11148,11149],{"class":345},"![",[320,11151,11153],{"class":11152},"smiIW","SSHFS挂载效果",[320,11155,11156],{"class":345},"](",[320,11158,11160],{"class":11159},"szbl0","img\u002Fsshfs.avif",[320,11162,934],{"class":345},[136,11164,11165,11166,11169,11170,11173,11174,1849],{},"HTML 输出的 ",[317,11167,11168],{},"\u003Cimg src=\"img\u002Fsshfs.avif\">"," 看起来没问题，但浏览器请求的却是 ",[317,11171,11172],{},"\u002Fposts\u002Fimg\u002Fsshfs.avif"," 而不是 ",[317,11175,11176],{},"\u002Fposts\u002Fsshfs\u002Fimg\u002Fsshfs.avif",[131,11178,10770],{"id":11179},"排查-1",[621,11181,11182,11189,11192,11198],{},[243,11183,11184,11185,11188],{},"检查了 ",[317,11186,11187],{},"\u003Cbase>"," 标签 —— 没有",[243,11190,11191],{},"检查了重定向 —— 没有",[243,11193,11194,11195,11197],{},"直接访问 ",[317,11196,11176],{}," —— 返回 200 OK",[243,11199,11194,11200,11202],{},[317,11201,11172],{}," —— 404",[136,11204,11205,11206,1849],{},"服务器路由没问题，问题是",[140,11207,11208],{},"浏览器解析相对路径时基于了错误的基准 URL",[136,11210,11211],{},"这可能和 Nuxt 的客户端路由有关：SPA 导航时，浏览器的\"当前页面\"和 Vue Router 的\"当前路由\"不同步，导致相对路径解析出错。",[131,11213,10851],{"id":11214},"最终方案-1",[136,11216,11217],{},"两步解决：",[136,11219,11220],{},[140,11221,11222],{},"第一步：自定义 ProseImg 组件",[136,11224,11225,11226,11229],{},"创建 ",[317,11227,11228],{},"components\u002Fcontent\u002FProseImg.vue","，覆盖 Nuxt Content 默认的图片渲染：",[310,11231,11233],{"className":10631,"code":11232,"language":10633,"meta":315,"style":315},"\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",[317,11234,11235,11247,11261,11280,11296,11333,11365,11387,11391,11399,11403,11412,11437],{"__ignoreMap":315},[320,11236,11237,11239,11242,11245],{"class":322,"line":15},[320,11238,756],{"class":345},[320,11240,11241],{"class":411},"script",[320,11243,11244],{"class":405}," setup",[320,11246,761],{"class":345},[320,11248,11249,11251,11254,11256,11259],{"class":322,"line":13},[320,11250,2504],{"class":331},[320,11252,11253],{"class":338}," route",[320,11255,342],{"class":331},[320,11257,11258],{"class":405}," useRoute",[320,11260,1333],{"class":345},[320,11262,11263,11265,11268,11270,11273,11276,11278],{"class":322,"line":9},[320,11264,2504],{"class":331},[320,11266,11267],{"class":338}," resolvedSrc",[320,11269,342],{"class":331},[320,11271,11272],{"class":405}," computed",[320,11274,11275],{"class":345},"(() ",[320,11277,2534],{"class":331},[320,11279,346],{"class":345},[320,11281,11282,11284,11286,11288,11291,11293],{"class":322,"line":361},[320,11283,10910],{"class":331},[320,11285,1073],{"class":345},[320,11287,10915],{"class":331},[320,11289,11290],{"class":345},"props.src) ",[320,11292,3424],{"class":331},[320,11294,11295],{"class":354}," ''\n",[320,11297,11298,11300,11303,11306,11308,11311,11313,11315,11318,11320,11322,11325,11328,11330],{"class":322,"line":26},[320,11299,10910],{"class":331},[320,11301,11302],{"class":345}," (props.src.",[320,11304,11305],{"class":405},"startsWith",[320,11307,1090],{"class":345},[320,11309,11310],{"class":354},"'\u002F'",[320,11312,1084],{"class":345},[320,11314,2562],{"class":331},[320,11316,11317],{"class":345}," props.src.",[320,11319,11305],{"class":405},[320,11321,1090],{"class":345},[320,11323,11324],{"class":354},"'http'",[320,11326,11327],{"class":345},")) ",[320,11329,3424],{"class":331},[320,11331,11332],{"class":345}," props.src\n",[320,11334,11335,11337,11340,11342,11345,11347,11349,11351,11355,11357,11359,11361,11363],{"class":322,"line":29},[320,11336,3272],{"class":331},[320,11338,11339],{"class":338}," base",[320,11341,342],{"class":331},[320,11343,11344],{"class":345}," route.path.",[320,11346,7457],{"class":405},[320,11348,1090],{"class":345},[320,11350,7462],{"class":354},[320,11352,11354],{"class":11353},"sCnZR","\\\u002F",[320,11356,2133],{"class":331},[320,11358,7462],{"class":354},[320,11360,452],{"class":345},[320,11362,7475],{"class":354},[320,11364,934],{"class":345},[320,11366,11367,11369,11372,11375,11378,11380,11382,11384],{"class":322,"line":387},[320,11368,11111],{"class":331},[320,11370,11371],{"class":354}," `${",[320,11373,11374],{"class":345},"base",[320,11376,11377],{"class":354},"}\u002F${",[320,11379,2788],{"class":345},[320,11381,959],{"class":354},[320,11383,2417],{"class":345},[320,11385,11386],{"class":354},"}`\n",[320,11388,11389],{"class":322,"line":931},[320,11390,739],{"class":345},[320,11392,11393,11395,11397],{"class":322,"line":937},[320,11394,781],{"class":345},[320,11396,11241],{"class":411},[320,11398,761],{"class":345},[320,11400,11401],{"class":322,"line":942},[320,11402,891],{"emptyLinePlaceholder":7},[320,11404,11405,11407,11410],{"class":322,"line":948},[320,11406,756],{"class":345},[320,11408,11409],{"class":411},"template",[320,11411,761],{"class":345},[320,11413,11414,11416,11418,11421,11423,11426,11429,11431,11434],{"class":322,"line":965},[320,11415,766],{"class":345},[320,11417,2529],{"class":411},[320,11419,11420],{"class":405}," :src",[320,11422,1140],{"class":345},[320,11424,11425],{"class":354},"\"resolvedSrc\"",[320,11427,11428],{"class":405}," :alt",[320,11430,1140],{"class":345},[320,11432,11433],{"class":354},"\"alt\"",[320,11435,11436],{"class":345}," \u002F>\n",[320,11438,11439,11441,11443],{"class":322,"line":970},[320,11440,781],{"class":345},[320,11442,11409],{"class":411},[320,11444,761],{"class":345},[136,11446,11447],{},[140,11448,11449],{},"第二步：启用 MDC prose 组件",[310,11451,11453],{"className":312,"code":11452,"language":314,"meta":315,"style":315},"\u002F\u002F nuxt.config.ts\nmdc: {\n  components: {\n    prose: true  \u002F\u002F 让 Nuxt Content 使用自定义 ProseImg\n  }\n}\n",[317,11454,11455,11459,11466,11473,11486,11490],{"__ignoreMap":315},[320,11456,11457],{"class":322,"line":15},[320,11458,10787],{"class":325},[320,11460,11461,11464],{"class":322,"line":13},[320,11462,11463],{"class":405},"mdc",[320,11465,848],{"class":345},[320,11467,11468,11471],{"class":322,"line":9},[320,11469,11470],{"class":405},"  components",[320,11472,848],{"class":345},[320,11474,11475,11478,11480,11483],{"class":322,"line":361},[320,11476,11477],{"class":405},"    prose",[320,11479,415],{"class":345},[320,11481,11482],{"class":338},"true",[320,11484,11485],{"class":325},"  \u002F\u002F 让 Nuxt Content 使用自定义 ProseImg\n",[320,11487,11488],{"class":322,"line":26},[320,11489,5894],{"class":345},[320,11491,11492],{"class":322,"line":29},[320,11493,390],{"class":345},[10743,11495,11497],{"id":11496},"问题三content-目录的图片如何服务","问题三：content 目录的图片如何服务",[131,11499,10757],{"id":11500},"现象-2",[136,11502,11503,11504,11507],{},"原站（SvelteKit）的图片放在 ",[317,11505,11506],{},"content\u002Fposts\u002F{slug}\u002Fimg\u002F"," 下，markdown 用相对路径引用，一切正常。Nuxt 项目里，content 目录的文件不会被当成静态资源服务。",[131,11509,11510],{"id":11510},"方案对比",[154,11512,11513,11523],{},[157,11514,11515],{},[160,11516,11517,11519,11521],{},[163,11518,2286],{},[163,11520,3754],{},[163,11522,3757],{},[170,11524,11525,11536,11547],{},[160,11526,11527,11530,11533],{},[175,11528,11529],{},"复制到 public\u002F",[175,11531,11532],{},"简单",[175,11534,11535],{},"文件重复，需同步",[160,11537,11538,11541,11544],{},[175,11539,11540],{},"Vite 插件拦截",[175,11542,11543],{},"原站方案",[175,11545,11546],{},"Nuxt 不直接支持",[160,11548,11549,11552,11555],{},[175,11550,11551],{},"Server Route",[175,11553,11554],{},"不复制文件",[175,11556,11557],{},"需要手动写路由",[136,11559,11560,11561,11563],{},"最终选择 ",[140,11562,11551],{},"，和原站的 Vite 插件思路一致：",[310,11565,11567],{"className":312,"code":11566,"language":314,"meta":315,"style":315},"\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",[317,11568,11569,11574,11599,11614,11675,11689,11693,11715,11741,11759,11778,11785],{"__ignoreMap":315},[320,11570,11571],{"class":322,"line":15},[320,11572,11573],{"class":325},"\u002F\u002F server\u002Froutes\u002Fposts\u002F[slug]\u002Fimg\u002F[...file].ts\n",[320,11575,11576,11578,11580,11583,11585,11588,11590,11593,11595,11597],{"class":322,"line":13},[320,11577,332],{"class":331},[320,11579,709],{"class":331},[320,11581,11582],{"class":405}," defineEventHandler",[320,11584,1090],{"class":345},[320,11586,11587],{"class":331},"async",[320,11589,1073],{"class":345},[320,11591,11592],{"class":1076},"event",[320,11594,1084],{"class":345},[320,11596,2534],{"class":331},[320,11598,346],{"class":345},[320,11600,11601,11603,11606,11608,11611],{"class":322,"line":9},[320,11602,3272],{"class":331},[320,11604,11605],{"class":338}," url",[320,11607,342],{"class":331},[320,11609,11610],{"class":405}," getRequestURL",[320,11612,11613],{"class":345},"(event)\n",[320,11615,11616,11618,11621,11623,11626,11629,11631,11633,11635,11637,11640,11642,11644,11646,11648,11651,11653,11655,11657,11659,11661,11663,11665,11667,11669,11671,11673],{"class":322,"line":361},[320,11617,3272],{"class":331},[320,11619,11620],{"class":338}," match",[320,11622,342],{"class":331},[320,11624,11625],{"class":345}," url.pathname.",[320,11627,11628],{"class":405},"match",[320,11630,1090],{"class":345},[320,11632,7462],{"class":354},[320,11634,7465],{"class":331},[320,11636,11354],{"class":11353},[320,11638,11639],{"class":7468},"posts",[320,11641,11354],{"class":11353},[320,11643,1090],{"class":7468},[320,11645,4159],{"class":338},[320,11647,7465],{"class":331},[320,11649,11650],{"class":338},"\u002F]",[320,11652,1167],{"class":331},[320,11654,7478],{"class":7468},[320,11656,11354],{"class":11353},[320,11658,2529],{"class":7468},[320,11660,11354],{"class":11353},[320,11662,1090],{"class":7468},[320,11664,959],{"class":338},[320,11666,1167],{"class":331},[320,11668,7478],{"class":7468},[320,11670,2133],{"class":331},[320,11672,7462],{"class":354},[320,11674,934],{"class":345},[320,11676,11677,11679,11681,11683,11686],{"class":322,"line":26},[320,11678,10910],{"class":331},[320,11680,1073],{"class":345},[320,11682,10915],{"class":331},[320,11684,11685],{"class":345},"match) ",[320,11687,11688],{"class":331},"return\n",[320,11690,11691],{"class":322,"line":29},[320,11692,891],{"emptyLinePlaceholder":7},[320,11694,11695,11697,11700,11703,11705,11708,11710,11712],{"class":322,"line":387},[320,11696,3272],{"class":331},[320,11698,11699],{"class":345}," [, ",[320,11701,11702],{"class":338},"slug",[320,11704,452],{"class":345},[320,11706,11707],{"class":338},"filename",[320,11709,10957],{"class":345},[320,11711,1140],{"class":331},[320,11713,11714],{"class":345}," match\n",[320,11716,11717,11719,11722,11724,11727,11729,11732,11735,11738],{"class":322,"line":931},[320,11718,3272],{"class":331},[320,11720,11721],{"class":338}," filePath",[320,11723,342],{"class":331},[320,11725,11726],{"class":405}," resolve",[320,11728,1090],{"class":345},[320,11730,11731],{"class":354},"'content\u002Fposts'",[320,11733,11734],{"class":345},", slug, ",[320,11736,11737],{"class":354},"'img'",[320,11739,11740],{"class":345},", filename)\n",[320,11742,11743,11745,11748,11750,11753,11756],{"class":322,"line":937},[320,11744,3272],{"class":331},[320,11746,11747],{"class":338}," data",[320,11749,342],{"class":331},[320,11751,11752],{"class":331}," await",[320,11754,11755],{"class":405}," readFile",[320,11757,11758],{"class":345},"(filePath)\n",[320,11760,11761,11764,11767,11770,11772,11775],{"class":322,"line":942},[320,11762,11763],{"class":405},"  setResponseHeader",[320,11765,11766],{"class":345},"(event, ",[320,11768,11769],{"class":354},"'content-type'",[320,11771,452],{"class":345},[320,11773,11774],{"class":338},"MIME",[320,11776,11777],{"class":345},"[ext])\n",[320,11779,11780,11782],{"class":322,"line":948},[320,11781,11111],{"class":331},[320,11783,11784],{"class":345}," data\n",[320,11786,11787],{"class":322,"line":965},[320,11788,739],{"class":345},[136,11790,11791,11792,11794,11795,11798],{},"请求 ",[317,11793,11176],{}," → 读取 ",[317,11796,11797],{},"content\u002Fposts\u002Fsshfs\u002Fimg\u002Fsshfs.avif"," → 返回图片。",[10743,11800,2164],{"id":2164},[154,11802,11803,11814],{},[157,11804,11805],{},[160,11806,11807,11809,11812],{},[163,11808,4820],{},[163,11810,11811],{},"根因",[163,11813,2286],{},[170,11815,11816,11830,11841],{},[160,11817,11818,11821,11827],{},[175,11819,11820],{},"TOC 只有 h2",[175,11822,11823,11826],{},[317,11824,11825],{},"toc.depth"," 配置不生效",[175,11828,11829],{},"从 body AST 手动提取",[160,11831,11832,11835,11838],{},[175,11833,11834],{},"图片路径错误",[175,11836,11837],{},"SPA 路由导致相对路径解析基准错误",[175,11839,11840],{},"ProseImg 组件 + mdc.prose 配置",[160,11842,11843,11846,11849],{},[175,11844,11845],{},"content 图片 404",[175,11847,11848],{},"content 目录不暴露给浏览器",[175,11850,11851],{},"Server Route 直接读取返回",[136,11853,11854],{},"Nuxt Content v3 的设计理念是\"图片放 public\u002F，用绝对路径\"。如果想像 SvelteKit 项目那样把图片和文章放一起，需要额外做不少工作。",[136,11856,11857],{},"不过一旦这些坑都踩过，整体开发体验还是很流畅的。Nuxt 的生态系统、自动导入、模块系统都比 SvelteKit 成熟很多。",[551,11859,11860],{},"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":315,"searchDepth":13,"depth":9,"links":11862},[11863,11864,11865,11866,11867,11868,11869,11870],{"id":10757,"depth":13,"text":10757},{"id":10770,"depth":13,"text":10770},{"id":10851,"depth":13,"text":10851},{"id":11134,"depth":13,"text":10757},{"id":11179,"depth":13,"text":10770},{"id":11214,"depth":13,"text":10851},{"id":11500,"depth":13,"text":10757},{"id":11510,"depth":13,"text":11510},"从 SvelteKit 迁移到 Nuxt 3 的过程中，遇到了不少 Nuxt Content v3 的坑。这篇文章记录了目录导航、图片路径、ProseImg 组件等问题的排查和解决过程。","\u002Fposts\u002Fnuxt-content-lessons\u002Fimg\u002Fcover.jpg",{},"\u002Fposts\u002Fnuxt-content-lessons","2026-05-31T14:00:00",{"title":10739,"description":11871},"posts\u002Fnuxt-content-lessons\u002Findex",[575,10735,11879],"Nuxt Content","vbSt6ND1z7xt7L8FxgZ8Qe3EdxV9InsRerN3E23GC7s",{"id":11882,"title":11883,"author":126,"body":11884,"description":12085,"difficulty":126,"draft":19,"extension":565,"image":12086,"meta":12087,"navigation":7,"path":12088,"pinned":19,"published":12089,"seo":12090,"series":126,"seriesOrder":126,"stem":12091,"tags":12092,"updated":126,"__hash__":12093},"posts\u002Fposts\u002Fsveltekit-intro\u002Findex.md","SvelteKit 入门：为什么它比你想象的更简单",{"type":128,"value":11885,"toc":12075},[11886,11890,11893,11899,11902,11906,11913,11960,11964,11970,11976,11980,11983,11986,12053,12055,12066,12072],[131,11887,11889],{"id":11888},"什么是-sveltekit","什么是 SvelteKit？",[136,11891,11892],{},"SvelteKit 是 Svelte 的官方全栈框架，类似于 React 之于 Next.js 的关系。它提供了路由、SSR、SSG 等能力，让开发者可以快速构建现代 Web 应用。",[136,11894,11895],{},[2529,11896],{"alt":11897,"src":11898},"SvelteKit Logo","https:\u002F\u002Fraw.githubusercontent.com\u002Fsveltejs\u002Fbranding\u002Fmaster\u002Fsvelte-logo.svg",[131,11900,11901],{"id":11901},"核心特点",[676,11903,11905],{"id":11904},"_1-编译时框架","1. 编译时框架",[136,11907,11908,11909,11912],{},"Svelte 和 React\u002FVue 最大的区别在于：",[140,11910,11911],{},"它在编译阶段就把组件转成原生 JS","，运行时没有虚拟 DOM。",[310,11914,11916],{"className":312,"code":11915,"language":314,"meta":315,"style":315},"\u002F\u002F Svelte 组件（编译后变成直接的 DOM 操作）\nlet count = 0\nfunction increment() {\n  count += 1\n}\n",[317,11917,11918,11923,11936,11945,11956],{"__ignoreMap":315},[320,11919,11920],{"class":322,"line":15},[320,11921,11922],{"class":325},"\u002F\u002F Svelte 组件（编译后变成直接的 DOM 操作）\n",[320,11924,11925,11928,11931,11933],{"class":322,"line":13},[320,11926,11927],{"class":331},"let",[320,11929,11930],{"class":345}," count ",[320,11932,1140],{"class":331},[320,11934,11935],{"class":338}," 0\n",[320,11937,11938,11940,11943],{"class":322,"line":9},[320,11939,10868],{"class":331},[320,11941,11942],{"class":405}," increment",[320,11944,9157],{"class":345},[320,11946,11947,11950,11953],{"class":322,"line":361},[320,11948,11949],{"class":345},"  count ",[320,11951,11952],{"class":331},"+=",[320,11954,11955],{"class":338}," 1\n",[320,11957,11958],{"class":322,"line":26},[320,11959,390],{"class":345},[676,11961,11963],{"id":11962},"_2-文件路由","2. 文件路由",[136,11965,11966,11969],{},[317,11967,11968],{},"src\u002Froutes\u002F"," 目录结构即 URL，不需要手动配置路由表：",[310,11971,11974],{"className":11972,"code":11973,"language":3593},[3591],"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",[317,11975,11973],{"__ignoreMap":315},[676,11977,11979],{"id":11978},"_3-全栈能力","3. 全栈能力",[136,11981,11982],{},"SvelteKit 内置了服务端渲染（SSR）、静态站点生成（SSG）、API 路由等功能，开箱即用。",[131,11984,11985],{"id":11985},"和其他框架对比",[154,11987,11988,12002],{},[157,11989,11990],{},[160,11991,11992,11995,11998,12000],{},[163,11993,11994],{},"特性",[163,11996,11997],{},"SvelteKit",[163,11999,4903],{},[163,12001,575],{},[170,12003,12004,12017,12030,12042],{},[160,12005,12006,12009,12012,12015],{},[175,12007,12008],{},"学习曲线",[175,12010,12011],{},"低",[175,12013,12014],{},"中",[175,12016,12014],{},[160,12018,12019,12022,12025,12028],{},[175,12020,12021],{},"包体积",[175,12023,12024],{},"小",[175,12026,12027],{},"大",[175,12029,12014],{},[160,12031,12032,12035,12037,12040],{},[175,12033,12034],{},"运行时开销",[175,12036,2309],{},[175,12038,12039],{},"有",[175,12041,12039],{},[160,12043,12044,12047,12049,12051],{},[175,12045,12046],{},"生态",[175,12048,12024],{},[175,12050,12027],{},[175,12052,12027],{},[131,12054,2164],{"id":2164},[136,12056,12057,12058,12061,12062,12065],{},"如果你追求",[140,12059,12060],{},"简洁的代码","和",[140,12063,12064],{},"极致的性能","，SvelteKit 是一个非常值得尝试的选择。它的学习曲线平缓，写起来就像在写原生 HTML\u002FCSS\u002FJS。",[136,12067,12068],{},[2529,12069],{"alt":12070,"src":12071},"Svelte 编译过程","https:\u002F\u002Fsvelte.dev\u002Fsvelte-logo-horizontal.svg",[551,12073,12074],{},"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":315,"searchDepth":13,"depth":9,"links":12076},[12077,12078,12083,12084],{"id":11888,"depth":13,"text":11889},{"id":11901,"depth":13,"text":11901,"children":12079},[12080,12081,12082],{"id":11904,"depth":9,"text":11905},{"id":11962,"depth":9,"text":11963},{"id":11978,"depth":9,"text":11979},{"id":11985,"depth":13,"text":11985},{"id":2164,"depth":13,"text":2164},"SvelteKit 是一个全栈 Svelte 框架，这篇文章带你了解它的核心概念和优势。","\u002Fposts\u002Fsveltekit-intro\u002Fimg\u002Fcover.jpg",{},"\u002Fposts\u002Fsveltekit-intro","2026-05-30",{"title":11883,"description":12085},"posts\u002Fsveltekit-intro\u002Findex",[11997,5029],"vsEGyunpCWR7DiJAdyV1SPDfa57xXLITBI-lKM8UeGQ",{"id":12095,"title":12096,"author":126,"body":12097,"description":12500,"difficulty":564,"draft":19,"extension":565,"image":12501,"meta":12502,"navigation":7,"path":12503,"pinned":19,"published":12504,"seo":12505,"series":12506,"seriesOrder":13,"stem":12507,"tags":12508,"updated":12504,"__hash__":12512},"posts\u002Fposts\u002Fdocker-user-start\u002Findex.md","Docker 构建时指定用户启动",{"type":128,"value":12098,"toc":12490},[12099,12102,12105,12107,12111,12114,12134,12138,12171,12175,12181,12229,12232,12450,12452,12455,12484,12487],[131,12100,12101],{"id":12101},"前言",[136,12103,12104],{},"我们在 Linux 使用应用时，往往不会直接使用 root 用户作为应用启动项，但 Dockerfile 默认就是 root 用户，导致挂载生成的文件路径往往是 root 权限，非 root 用户访问就很不方便。",[131,12106,10591],{"id":10591},[676,12108,12110],{"id":12109},"_1-安装用户管理工具","1. 安装用户管理工具",[136,12112,12113],{},"若镜像非自带用户管理：",[310,12115,12119],{"className":12116,"code":12117,"language":12118,"meta":315,"style":315},"language-dockerfile shiki shiki-themes github-light github-dark","# 权限管理\nRUN apk add --no-cache shadow\n","dockerfile",[317,12120,12121,12126],{"__ignoreMap":315},[320,12122,12123],{"class":322,"line":15},[320,12124,12125],{"class":325},"# 权限管理\n",[320,12127,12128,12131],{"class":322,"line":13},[320,12129,12130],{"class":331},"RUN",[320,12132,12133],{"class":345}," apk add --no-cache shadow\n",[676,12135,12137],{"id":12136},"_2-创建用户并修改权限","2. 创建用户并修改权限",[310,12139,12141],{"className":12116,"code":12140,"language":12118,"meta":315,"style":315},"# 创建用户组和用户\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",[317,12142,12143,12148,12155,12159,12164],{"__ignoreMap":315},[320,12144,12145],{"class":322,"line":15},[320,12146,12147],{"class":325},"# 创建用户组和用户\n",[320,12149,12150,12152],{"class":322,"line":13},[320,12151,12130],{"class":331},[320,12153,12154],{"class":345}," addgroup -S \u003Capp_user_group> && adduser -S -G \u003Capp_user_group> \u003Capp_user>\n",[320,12156,12157],{"class":322,"line":9},[320,12158,891],{"emptyLinePlaceholder":7},[320,12160,12161],{"class":322,"line":361},[320,12162,12163],{"class":325},"# 修改目录权限\n",[320,12165,12166,12168],{"class":322,"line":26},[320,12167,12130],{"class":331},[320,12169,12170],{"class":345}," chown -R \u003Capp_user_group>:\u003Capp_user> \u002Fdata\n",[676,12172,12174],{"id":12173},"_3-指定用户启动","3. 指定用户启动",[136,12176,12177,12178,12180],{},"执行 docker 时加 ",[317,12179,5962],{}," 参数：",[310,12182,12184],{"className":471,"code":12183,"language":473,"meta":315,"style":315},"docker run -it --user \u003Capp_user> \u003Capp_image>:\u003Cimage_version>\n",[317,12185,12186],{"__ignoreMap":315},[320,12187,12188,12191,12193,12196,12199,12201,12204,12207,12209,12211,12214,12216,12218,12220,12222,12225,12227],{"class":322,"line":15},[320,12189,12190],{"class":405},"docker",[320,12192,1732],{"class":354},[320,12194,12195],{"class":338}," -it",[320,12197,12198],{"class":338}," --user",[320,12200,5551],{"class":331},[320,12202,12203],{"class":354},"app_use",[320,12205,12206],{"class":345},"r",[320,12208,3453],{"class":331},[320,12210,5551],{"class":331},[320,12212,12213],{"class":354},"app_imag",[320,12215,5587],{"class":345},[320,12217,3453],{"class":331},[320,12219,2510],{"class":354},[320,12221,756],{"class":331},[320,12223,12224],{"class":354},"image_versio",[320,12226,5557],{"class":345},[320,12228,761],{"class":331},[131,12230,12231],{"id":12231},"完整示例",[310,12233,12235],{"className":12116,"code":12234,"language":12118,"meta":315,"style":315},"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",[317,12236,12237,12245,12249,12254,12267,12271,12278,12282,12288,12293,12300,12305,12312,12316,12324,12328,12332,12339,12343,12351,12355,12362,12366,12380,12388,12392,12397,12404,12411,12415,12420,12428],{"__ignoreMap":315},[320,12238,12239,12242],{"class":322,"line":15},[320,12240,12241],{"class":331},"FROM",[320,12243,12244],{"class":345}," alpine:latest\n",[320,12246,12247],{"class":322,"line":13},[320,12248,891],{"emptyLinePlaceholder":7},[320,12250,12251],{"class":322,"line":9},[320,12252,12253],{"class":325},"# 国内源\n",[320,12255,12256,12258,12261,12264],{"class":322,"line":361},[320,12257,12130],{"class":331},[320,12259,12260],{"class":345}," sed -i ",[320,12262,12263],{"class":354},"'s\u002Fdl-cdn.alpinelinux.org\u002Fmirrors.ustc.edu.cn\u002Fg'",[320,12265,12266],{"class":345}," \u002Fetc\u002Fapk\u002Frepositories\n",[320,12268,12269],{"class":322,"line":26},[320,12270,891],{"emptyLinePlaceholder":7},[320,12272,12273,12275],{"class":322,"line":29},[320,12274,12130],{"class":331},[320,12276,12277],{"class":345}," apk update --no-cache\n",[320,12279,12280],{"class":322,"line":387},[320,12281,12125],{"class":325},[320,12283,12284,12286],{"class":322,"line":931},[320,12285,12130],{"class":331},[320,12287,12133],{"class":345},[320,12289,12290],{"class":322,"line":937},[320,12291,12292],{"class":325},"# 调试\n",[320,12294,12295,12297],{"class":322,"line":942},[320,12296,12130],{"class":331},[320,12298,12299],{"class":345}," apk add --no-cache bash\n",[320,12301,12302],{"class":322,"line":948},[320,12303,12304],{"class":325},"# 设置时区\n",[320,12306,12307,12309],{"class":322,"line":965},[320,12308,12130],{"class":331},[320,12310,12311],{"class":345}," apk add --no-cache tzdata\n",[320,12313,12314],{"class":322,"line":970},[320,12315,891],{"emptyLinePlaceholder":7},[320,12317,12318,12321],{"class":322,"line":68},[320,12319,12320],{"class":331},"ENV",[320,12322,12323],{"class":345}," TZ=Asia\u002FShanghai\n",[320,12325,12326],{"class":322,"line":1014},[320,12327,891],{"emptyLinePlaceholder":7},[320,12329,12330],{"class":322,"line":1019},[320,12331,12147],{"class":325},[320,12333,12334,12336],{"class":322,"line":1033},[320,12335,12130],{"class":331},[320,12337,12338],{"class":345}," addgroup -S glog && adduser -S -G glog glog\n",[320,12340,12341],{"class":322,"line":1042},[320,12342,891],{"emptyLinePlaceholder":7},[320,12344,12345,12348],{"class":322,"line":1057},[320,12346,12347],{"class":331},"COPY",[320,12349,12350],{"class":345}," .\u002Fglog_static_musl_1 \u002Fapp\u002F\n",[320,12352,12353],{"class":322,"line":1062},[320,12354,891],{"emptyLinePlaceholder":7},[320,12356,12357,12359],{"class":322,"line":1067},[320,12358,12130],{"class":331},[320,12360,12361],{"class":345}," mkdir \u002Fdata\n",[320,12363,12364],{"class":322,"line":1117},[320,12365,891],{"emptyLinePlaceholder":7},[320,12367,12368,12371,12374,12377],{"class":322,"line":1134},[320,12369,12370],{"class":331},"VOLUME",[320,12372,12373],{"class":345}," [ ",[320,12375,12376],{"class":354},"\"\u002Fdata\"",[320,12378,12379],{"class":345}," ]\n",[320,12381,12382,12385],{"class":322,"line":1146},[320,12383,12384],{"class":331},"WORKDIR",[320,12386,12387],{"class":345}," \u002Fdata\n",[320,12389,12390],{"class":322,"line":1157},[320,12391,891],{"emptyLinePlaceholder":7},[320,12393,12394],{"class":322,"line":1189},[320,12395,12396],{"class":325},"# 修改权限\n",[320,12398,12399,12401],{"class":322,"line":60},[320,12400,12130],{"class":331},[320,12402,12403],{"class":345}," chown -R glog:glog \u002Fdata\n",[320,12405,12406,12408],{"class":322,"line":1214},[320,12407,12130],{"class":331},[320,12409,12410],{"class":345}," chown -R glog:glog \u002Fapp\n",[320,12412,12413],{"class":322,"line":7000},[320,12414,891],{"emptyLinePlaceholder":7},[320,12416,12417],{"class":322,"line":7006},[320,12418,12419],{"class":325},"# 指定用户启动\n",[320,12421,12422,12425],{"class":322,"line":7026},[320,12423,12424],{"class":331},"USER",[320,12426,12427],{"class":345}," glog\n",[320,12429,12430,12433,12435,12438,12440,12443,12445,12448],{"class":322,"line":7031},[320,12431,12432],{"class":331},"CMD",[320,12434,12373],{"class":345},[320,12436,12437],{"class":354},"\"\u002Fapp\u002Fglog_static_musl_1\"",[320,12439,452],{"class":345},[320,12441,12442],{"class":354},"\"logsweb\"",[320,12444,452],{"class":345},[320,12446,12447],{"class":354},"\".\u002Fconf.ini\"",[320,12449,12379],{"class":345},[131,12451,2164],{"id":2164},[136,12453,12454],{},"关键步骤：",[621,12456,12457,12463,12472,12478],{},[243,12458,12459,12462],{},[317,12460,12461],{},"apk add --no-cache shadow"," - 安装用户管理工具",[243,12464,12465,4762,12468,12471],{},[317,12466,12467],{},"addgroup",[317,12469,12470],{},"adduser"," - 创建用户",[243,12473,12474,12477],{},[317,12475,12476],{},"chown -R"," - 修改目录权限",[243,12479,12480,12483],{},[317,12481,12482],{},"USER \u003Cusername>"," - 指定运行用户",[136,12485,12486],{},"这样容器内的应用就不再以 root 身份运行，更加安全。",[551,12488,12489],{},"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":315,"searchDepth":13,"depth":9,"links":12491},[12492,12493,12498,12499],{"id":12101,"depth":13,"text":12101},{"id":10591,"depth":13,"text":10591,"children":12494},[12495,12496,12497],{"id":12109,"depth":9,"text":12110},{"id":12136,"depth":9,"text":12137},{"id":12173,"depth":9,"text":12174},{"id":12231,"depth":13,"text":12231},{"id":2164,"depth":13,"text":2164},"解决 Docker 容器默认以 root 用户运行导致的权限问题，指定非 root 用户启动应用。","\u002Fposts\u002Fdocker-user-start\u002Fimg\u002Fcover.svg",{},"\u002Fposts\u002Fdocker-user-start","2025-04-01",{"title":12096,"description":12500},"Docker 实践笔记","posts\u002Fdocker-user-start\u002Findex",[12509,12510,12511],"Docker","运维","Linux","b4Uyw5ms87xr7NhpIkavHnNbT20oaEPtXz8k1w6UPj0",{"id":12514,"title":12515,"author":126,"body":12516,"description":13269,"difficulty":2227,"draft":19,"extension":565,"image":13270,"meta":13271,"navigation":7,"path":13272,"pinned":19,"published":13273,"seo":13274,"series":12506,"seriesOrder":15,"stem":13275,"tags":13276,"updated":13273,"__hash__":13277},"posts\u002Fposts\u002Fdocker-musl-build\u002Findex.md","Docker 编译 musl 可执行文件",{"type":128,"value":12517,"toc":13258},[12518,12521,12524,12527,12737,12740,12781,12783,12801,12804,12942,12945,13076,13079,13082,13140,13143,13255],[131,12519,12520],{"id":12520},"解决问题",[136,12522,12523],{},"用 Go 进行跨平台编译时往往步骤复杂，用 Docker 可以轻松解决。但 Docker 的当前最小镜像 Alpine 其动态链接库采用 musl，与主流的 glibc 不同，往往不能方便执行。",[131,12525,12526],{"id":12526},"查阅资料",[310,12528,12530],{"className":471,"code":12529,"language":473,"meta":315,"style":315},"# 启动容器并挂载当前目录到 `\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",[317,12531,12532,12537,12571,12575,12580,12606,12621,12635,12640,12652,12672,12680,12684,12689,12699,12709,12713,12721,12725],{"__ignoreMap":315},[320,12533,12534],{"class":322,"line":15},[320,12535,12536],{"class":325},"# 启动容器并挂载当前目录到 `\u002Fapp`\n",[320,12538,12539,12541,12543,12545,12548,12551,12553,12556,12559,12562,12565,12568],{"class":322,"line":13},[320,12540,12190],{"class":405},[320,12542,1732],{"class":354},[320,12544,12195],{"class":338},[320,12546,12547],{"class":338}," --rm",[320,12549,12550],{"class":338}," -v",[320,12552,1671],{"class":354},[320,12554,12555],{"class":345},"$PWD",[320,12557,12558],{"class":354},"\":\u002Fapp",[320,12560,12561],{"class":338}," -w",[320,12563,12564],{"class":354}," \u002Fapp",[320,12566,12567],{"class":354}," ubuntu:20.04",[320,12569,12570],{"class":354}," bash\n",[320,12572,12573],{"class":322,"line":9},[320,12574,891],{"emptyLinePlaceholder":7},[320,12576,12577],{"class":322,"line":361},[320,12578,12579],{"class":325},"# 在容器内执行以下操作：\n",[320,12581,12582,12585,12588,12590,12592,12594,12597,12600,12603],{"class":322,"line":26},[320,12583,12584],{"class":405},"apt-get",[320,12586,12587],{"class":354}," update",[320,12589,1726],{"class":345},[320,12591,12584],{"class":405},[320,12593,2400],{"class":354},[320,12595,12596],{"class":338}," -y",[320,12598,12599],{"class":354}," musl-tools",[320,12601,12602],{"class":354}," golang-go",[320,12604,12605],{"class":325},"  # 安装 musl 和 Go\n",[320,12607,12608,12610,12613,12615,12618],{"class":322,"line":29},[320,12609,332],{"class":331},[320,12611,12612],{"class":345}," CC",[320,12614,1140],{"class":331},[320,12616,12617],{"class":345},"musl-gcc  ",[320,12619,12620],{"class":325},"# 指定 musl 编译器\n",[320,12622,12623,12625,12628,12630,12632],{"class":322,"line":387},[320,12624,332],{"class":331},[320,12626,12627],{"class":345}," CGO_ENABLED",[320,12629,1140],{"class":331},[320,12631,3409],{"class":338},[320,12633,12634],{"class":325},"  # 启用 CGO\n",[320,12636,12637],{"class":322,"line":931},[320,12638,12639],{"class":325},"# 设置代理\n",[320,12641,12642,12644,12647,12649],{"class":322,"line":937},[320,12643,871],{"class":405},[320,12645,12646],{"class":354}," env",[320,12648,12561],{"class":338},[320,12650,12651],{"class":354}," GOPROXY=https:\u002F\u002Fgoproxy.cn,direct\n",[320,12653,12654,12656,12658,12661,12664,12666,12669],{"class":322,"line":942},[320,12655,871],{"class":405},[320,12657,1735],{"class":354},[320,12659,12660],{"class":338}," -ldflags",[320,12662,12663],{"class":354}," '-linkmode external -extldflags \"-static\"'",[320,12665,1789],{"class":338},[320,12667,12668],{"class":354}," myapp",[320,12670,12671],{"class":325},"  # 静态编译\n",[320,12673,12674,12677],{"class":322,"line":948},[320,12675,12676],{"class":338},"exit",[320,12678,12679],{"class":325},"  # 退出容器\n",[320,12681,12682],{"class":322,"line":965},[320,12683,891],{"emptyLinePlaceholder":7},[320,12685,12686],{"class":322,"line":970},[320,12687,12688],{"class":325},"# 验证结果（在宿主机执行）\n",[320,12690,12691,12694,12696],{"class":322,"line":68},[320,12692,12693],{"class":405},"file",[320,12695,12668],{"class":354},[320,12697,12698],{"class":325},"  # 应显示 \"statically linked\"\n",[320,12700,12701,12704,12706],{"class":322,"line":1014},[320,12702,12703],{"class":405},"ldd",[320,12705,12668],{"class":354},[320,12707,12708],{"class":325},"   # 应显示 \"not a dynamic executable\"\n",[320,12710,12711],{"class":322,"line":1019},[320,12712,891],{"emptyLinePlaceholder":7},[320,12714,12715,12718],{"class":322,"line":1033},[320,12716,12717],{"class":405},"wget",[320,12719,12720],{"class":354}," https:\u002F\u002Fgo.dev\u002Fdl\u002Fgo1.21.linux-amd64.tar.gz\n",[320,12722,12723],{"class":322,"line":1042},[320,12724,891],{"emptyLinePlaceholder":7},[320,12726,12727,12729,12732,12735],{"class":322,"line":1057},[320,12728,12320],{"class":405},[320,12730,12731],{"class":354}," PATH=\"\u002Fdata\u002Fgo\u002Fbin:",[320,12733,12734],{"class":345},"$PATH",[320,12736,910],{"class":354},[676,12738,12739],{"id":12739},"遇到问题",[621,12741,12742,12767],{},[243,12743,12744,12747,12748,12751,12754,12755,12758,12760,12761,12763],{},[317,12745,12746],{},"go mod tidy"," 时报错 ",[317,12749,12750],{},"tls: failed to verify certificate: x509: certificate",[12752,12753],"br",{},"解决：",[317,12756,12757],{},"RUN apt-get update && apt-get install -y ca-certificates && rm -rf \u002Fvar\u002Flib\u002Fapt\u002Flists\u002F*",[12752,12759],{},"基础镜像的证书未更新，导致 https 的请求出现类似错误。",[12752,12762],{},[2529,12764],{"alt":12765,"src":12766},"image-20250327100813512","\u002Fposts\u002Fdocker-musl-build\u002Fimg\u002Fimage-20250327100813512.png",[243,12768,12769,12770,12773,12774,12776,12777,12780],{},"直接安装 ",[317,12771,12772],{},"golang-go"," 其版本过低",[12752,12775],{},"解决: ",[317,12778,12779],{},"PATH=\"\u002Fdata\u002Fgo\u002Fbin:$PATH\""," 手动指定环境",[131,12782,10851],{"id":10851},[621,12784,12785,12792,12795],{},[243,12786,12787,12788,12791],{},"下载 ",[317,12789,12790],{},"go1.24.1.linux-amd64.tar.gz"," 解压",[243,12793,12794],{},"编写 Dockerfile",[243,12796,12797,12798],{},"执行 ",[317,12799,12800],{},"docker build -t go-build:v1 .",[136,12802,12803],{},"Dockerfile 内容：",[310,12805,12807],{"className":12116,"code":12806,"language":12118,"meta":315,"style":315},"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",[317,12808,12809,12816,12820,12831,12835,12842,12846,12856,12860,12867,12871,12876,12883,12887,12894,12899,12906,12910,12916,12920,12926,12930],{"__ignoreMap":315},[320,12810,12811,12813],{"class":322,"line":15},[320,12812,12241],{"class":331},[320,12814,12815],{"class":345}," ubuntu:20.04\n",[320,12817,12818],{"class":322,"line":13},[320,12819,891],{"emptyLinePlaceholder":7},[320,12821,12822,12825,12828],{"class":322,"line":9},[320,12823,12824],{"class":331},"LABEL",[320,12826,12827],{"class":345}," org.opencontainers.image.authors=",[320,12829,12830],{"class":354},"\"sutong\"\n",[320,12832,12833],{"class":322,"line":361},[320,12834,891],{"emptyLinePlaceholder":7},[320,12836,12837,12839],{"class":322,"line":26},[320,12838,12347],{"class":331},[320,12840,12841],{"class":345}," go\u002F \u002Fgo\u002F\n",[320,12843,12844],{"class":322,"line":29},[320,12845,891],{"emptyLinePlaceholder":7},[320,12847,12848,12850,12853],{"class":322,"line":387},[320,12849,12320],{"class":331},[320,12851,12852],{"class":345}," PATH=",[320,12854,12855],{"class":354},"\"\u002Fgo\u002Fbin:$PATH\"\n",[320,12857,12858],{"class":322,"line":931},[320,12859,891],{"emptyLinePlaceholder":7},[320,12861,12862,12864],{"class":322,"line":937},[320,12863,12130],{"class":331},[320,12865,12866],{"class":345}," apt-get update && apt-get install -y musl-tools\n",[320,12868,12869],{"class":322,"line":942},[320,12870,891],{"emptyLinePlaceholder":7},[320,12872,12873],{"class":322,"line":948},[320,12874,12875],{"class":325},"## go mod tidy 时报错 tls: failed to verify certificate: x509: certificate\n",[320,12877,12878,12880],{"class":322,"line":965},[320,12879,12130],{"class":331},[320,12881,12882],{"class":345}," apt-get update && apt-get install -y ca-certificates && rm -rf \u002Fvar\u002Flib\u002Fapt\u002Flists\u002F*\n",[320,12884,12885],{"class":322,"line":970},[320,12886,12620],{"class":325},[320,12888,12889,12891],{"class":322,"line":68},[320,12890,12320],{"class":331},[320,12892,12893],{"class":345}," CC=musl-gcc\n",[320,12895,12896],{"class":322,"line":1014},[320,12897,12898],{"class":325},"# 启用 CGO\n",[320,12900,12901,12903],{"class":322,"line":1019},[320,12902,12320],{"class":331},[320,12904,12905],{"class":345}," CGO_ENABLED=1\n",[320,12907,12908],{"class":322,"line":1033},[320,12909,891],{"emptyLinePlaceholder":7},[320,12911,12912,12914],{"class":322,"line":1042},[320,12913,12370],{"class":331},[320,12915,12387],{"class":345},[320,12917,12918],{"class":322,"line":1057},[320,12919,891],{"emptyLinePlaceholder":7},[320,12921,12922,12924],{"class":322,"line":1062},[320,12923,12384],{"class":331},[320,12925,12387],{"class":345},[320,12927,12928],{"class":322,"line":1067},[320,12929,891],{"emptyLinePlaceholder":7},[320,12931,12932,12934,12937,12940],{"class":322,"line":1117},[320,12933,12432],{"class":331},[320,12935,12936],{"class":345}," [",[320,12938,12939],{"class":354},"\"\u002Fbin\u002Fbash\"",[320,12941,458],{"class":345},[676,12943,12944],{"id":12944},"构建",[310,12946,12948],{"className":471,"code":12947,"language":473,"meta":315,"style":315},"# 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",[317,12949,12950,12955,12959,12964,12989,12993,12998,13030,13035,13039,13071],{"__ignoreMap":315},[320,12951,12952],{"class":322,"line":15},[320,12953,12954],{"class":325},"# docker build -t go-build:v1 .\n",[320,12956,12957],{"class":322,"line":13},[320,12958,891],{"emptyLinePlaceholder":7},[320,12960,12961],{"class":322,"line":9},[320,12962,12963],{"class":325},"# 直接执行编译 编译后删除\n",[320,12965,12966,12968,12970,12972,12974,12977,12980,12982,12984,12986],{"class":322,"line":361},[320,12967,12190],{"class":405},[320,12969,1732],{"class":354},[320,12971,12547],{"class":338},[320,12973,12550],{"class":338},[320,12975,12976],{"class":354}," .:\u002Fdata",[320,12978,12979],{"class":354}," go-build:v1",[320,12981,1784],{"class":354},[320,12983,1735],{"class":354},[320,12985,1789],{"class":338},[320,12987,12988],{"class":354}," app_musl\n",[320,12990,12991],{"class":322,"line":26},[320,12992,891],{"emptyLinePlaceholder":7},[320,12994,12995],{"class":322,"line":29},[320,12996,12997],{"class":325},"# 构建编译环境\n",[320,12999,13000,13002,13004,13006,13009,13011,13013,13015,13017,13020,13023,13025,13027],{"class":322,"line":387},[320,13001,12190],{"class":405},[320,13003,1732],{"class":354},[320,13005,12195],{"class":338},[320,13007,13008],{"class":338}," -d",[320,13010,12550],{"class":338},[320,13012,12976],{"class":354},[320,13014,5783],{"class":338},[320,13016,5551],{"class":331},[320,13018,13019],{"class":354},"容器名",[320,13021,13022],{"class":345},"称",[320,13024,3453],{"class":331},[320,13026,12979],{"class":354},[320,13028,13029],{"class":354}," \u002Fbin\u002Fbash\n",[320,13031,13032],{"class":322,"line":931},[320,13033,13034],{"class":325},"# eg：docker run -it -d -v .:\u002Fdata --name logbus-build go-build:v1 \u002Fbin\u002Fbash\n",[320,13036,13037],{"class":322,"line":937},[320,13038,891],{"emptyLinePlaceholder":7},[320,13040,13041,13043,13046,13048,13050,13052,13054,13056,13058,13060,13062,13064,13067,13069],{"class":322,"line":942},[320,13042,12190],{"class":405},[320,13044,13045],{"class":354}," exec",[320,13047,12195],{"class":338},[320,13049,5551],{"class":331},[320,13051,13019],{"class":354},[320,13053,13022],{"class":345},[320,13055,3453],{"class":331},[320,13057,1784],{"class":354},[320,13059,1735],{"class":354},[320,13061,1789],{"class":338},[320,13063,5551],{"class":331},[320,13065,13066],{"class":354},"myap",[320,13068,136],{"class":345},[320,13070,761],{"class":331},[320,13072,13073],{"class":322,"line":948},[320,13074,13075],{"class":325},"# eg：docker exec -it logbus-build go build -o logbus_musl_1\n",[676,13077,13078],{"id":13078},"额外注意",[136,13080,13081],{},"若没科学上网需要设置代理：",[310,13083,13085],{"className":471,"code":13084,"language":473,"meta":315,"style":315},"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",[317,13086,13087,13105,13115,13131],{"__ignoreMap":315},[320,13088,13089,13091,13093,13095,13097,13099,13101,13103],{"class":322,"line":15},[320,13090,12190],{"class":405},[320,13092,13045],{"class":354},[320,13094,12195],{"class":338},[320,13096,5551],{"class":331},[320,13098,13019],{"class":354},[320,13100,13022],{"class":345},[320,13102,3453],{"class":331},[320,13104,13029],{"class":354},[320,13106,13107,13109,13111,13113],{"class":322,"line":13},[320,13108,871],{"class":405},[320,13110,12646],{"class":354},[320,13112,12561],{"class":338},[320,13114,12651],{"class":354},[320,13116,13117,13119,13121,13123,13125,13127,13129],{"class":322,"line":9},[320,13118,871],{"class":405},[320,13120,1735],{"class":354},[320,13122,12660],{"class":338},[320,13124,12663],{"class":354},[320,13126,1789],{"class":338},[320,13128,12668],{"class":354},[320,13130,12671],{"class":325},[320,13132,13133,13135,13137],{"class":322,"line":361},[320,13134,871],{"class":405},[320,13136,1735],{"class":354},[320,13138,13139],{"class":325}," # musl 库编译\n",[676,13141,13142],{"id":13142},"运行环境示例",[310,13144,13146],{"className":12116,"code":13145,"language":12118,"meta":315,"style":315},"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",[317,13147,13148,13154,13158,13166,13170,13177,13181,13188,13195,13199,13206,13210,13217,13221,13241,13245,13250],{"__ignoreMap":315},[320,13149,13150,13152],{"class":322,"line":15},[320,13151,12241],{"class":331},[320,13153,12244],{"class":345},[320,13155,13156],{"class":322,"line":13},[320,13157,891],{"emptyLinePlaceholder":7},[320,13159,13160,13162,13164],{"class":322,"line":9},[320,13161,12824],{"class":331},[320,13163,12827],{"class":345},[320,13165,12830],{"class":354},[320,13167,13168],{"class":322,"line":361},[320,13169,891],{"emptyLinePlaceholder":7},[320,13171,13172,13174],{"class":322,"line":26},[320,13173,12347],{"class":331},[320,13175,13176],{"class":345}," glog_musl \u002Fapp\u002F\n",[320,13178,13179],{"class":322,"line":29},[320,13180,891],{"emptyLinePlaceholder":7},[320,13182,13183,13185],{"class":322,"line":387},[320,13184,12347],{"class":331},[320,13186,13187],{"class":345}," conf.ini \u002Fdata\u002F\n",[320,13189,13190,13192],{"class":322,"line":931},[320,13191,12347],{"class":331},[320,13193,13194],{"class":345}," ipname.ini \u002Fdata\u002F\n",[320,13196,13197],{"class":322,"line":937},[320,13198,891],{"emptyLinePlaceholder":7},[320,13200,13201,13203],{"class":322,"line":942},[320,13202,12384],{"class":331},[320,13204,13205],{"class":345}," \u002Fapp\n",[320,13207,13208],{"class":322,"line":948},[320,13209,891],{"emptyLinePlaceholder":7},[320,13211,13212,13214],{"class":322,"line":965},[320,13213,12130],{"class":331},[320,13215,13216],{"class":345}," chmod 777 \u002Fapp\u002Fglog_musl\n",[320,13218,13219],{"class":322,"line":970},[320,13220,891],{"emptyLinePlaceholder":7},[320,13222,13223,13225,13227,13230,13232,13234,13236,13239],{"class":322,"line":68},[320,13224,12432],{"class":331},[320,13226,12936],{"class":345},[320,13228,13229],{"class":354},"\".\u002Fglog_musl\"",[320,13231,452],{"class":345},[320,13233,12442],{"class":354},[320,13235,452],{"class":345},[320,13237,13238],{"class":354},"\"\u002Fdata\u002Fconf.ini\"",[320,13240,458],{"class":345},[320,13242,13243],{"class":322,"line":1014},[320,13244,891],{"emptyLinePlaceholder":7},[320,13246,13247],{"class":322,"line":1019},[320,13248,13249],{"class":325},"# docker build -t glog:v1 .\n",[320,13251,13252],{"class":322,"line":1033},[320,13253,13254],{"class":325},"# docker run -p 6801:6801 glog:v1\n",[551,13256,13257],{},"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":315,"searchDepth":13,"depth":9,"links":13259},[13260,13261,13264],{"id":12520,"depth":13,"text":12520},{"id":12526,"depth":13,"text":12526,"children":13262},[13263],{"id":12739,"depth":9,"text":12739},{"id":10851,"depth":13,"text":10851,"children":13265},[13266,13267,13268],{"id":12944,"depth":9,"text":12944},{"id":13078,"depth":9,"text":13078},{"id":13142,"depth":9,"text":13142},"解决 Go 语言跨平台编译问题，使用 Docker + musl 编译静态链接的可执行文件。","\u002Fposts\u002Fdocker-musl-build\u002Fimg\u002Fcover.svg",{},"\u002Fposts\u002Fdocker-musl-build","2025-03-27",{"title":12515,"description":13269},"posts\u002Fdocker-musl-build\u002Findex",[12509,2236,12510],"YLtdOOc-gMVDSNjE-ntKXz-7hff-VtvMB2dB5RXNi1o",1786887346392]