[{"data":1,"prerenderedAt":13372},["ShallowReactive",2],{"post-nuxt-tailwind-underline-fix":3,"series-source-nuxt-tailwind-underline-fix":326},{"id":4,"title":5,"author":6,"body":7,"description":307,"difficulty":308,"draft":309,"extension":310,"image":311,"meta":312,"navigation":313,"path":314,"pinned":309,"published":315,"seo":316,"series":317,"seriesOrder":86,"stem":318,"tags":319,"updated":324,"__hash__":325},"posts\u002Fposts\u002Fnuxt-tailwind-underline-fix\u002Findex.md","Nuxt 3 + Tailwind CSS 客户端导航链接下划线闪烁问题","SuBlog",{"type":8,"value":9,"toc":300},"minimark",[10,14,18,37,40,43,46,102,113,120,143,150,154,157,160,163,166,189,200,267,272,285,288,294,297],[11,12,13],"h2",{"id":13},"问题描述",[15,16,17],"p",{},"在 Nuxt 3 项目中使用 Tailwind CSS 时，发现一个奇怪的现象：",[19,20,21,34],"ul",{},[22,23,24,25,29,30],"li",{},"首次通过客户端导航（点击 ",[26,27,28],"code",{},"\u003CNuxtLink>","）跳转到某个页面时，",[31,32,33],"strong",{},"所有链接文字都会出现下划线",[22,35,36],{},"手动刷新页面后，下划线消失，样式恢复正常",[15,38,39],{},"这个问题在开发模式和生产构建后都会出现。",[11,41,42],{"id":42},"原因分析",[15,44,45],{},"Tailwind CSS 的 Preflight（基于 modern-normalize）会重置链接样式：",[47,48,53],"pre",{"className":49,"code":50,"language":51,"meta":52,"style":52},"language-css shiki shiki-themes github-light github-dark","a {\n  color: inherit;\n  text-decoration: inherit;\n}\n","css","",[26,54,55,68,84,96],{"__ignoreMap":52},[56,57,60,64],"span",{"class":58,"line":59},"line",1,[56,61,63],{"class":62},"sbB4o","a",[56,65,67],{"class":66},"sIX_F"," {\n",[56,69,71,75,78,81],{"class":58,"line":70},2,[56,72,74],{"class":73},"suQ91","  color",[56,76,77],{"class":66},": ",[56,79,80],{"class":73},"inherit",[56,82,83],{"class":66},";\n",[56,85,87,90,92,94],{"class":58,"line":86},3,[56,88,89],{"class":73},"  text-decoration",[56,91,77],{"class":66},[56,93,80],{"class":73},[56,95,83],{"class":66},[56,97,99],{"class":58,"line":98},4,[56,100,101],{"class":66},"}\n",[15,103,104,105,108,109,112],{},"正常情况下，这会让链接继承父元素的 ",[26,106,107],{},"text-decoration","（通常是 ",[26,110,111],{},"none","），从而去掉浏览器默认的下划线。",[15,114,115,116,119],{},"问题出在 ",[31,117,118],{},"Nuxt 的客户端导航机制","：",[121,122,123,129,132,137,140],"ol",{},[22,124,125,126,128],{},"用户点击 ",[26,127,28],{},"，Vue Router 拦截导航",[22,130,131],{},"新页面组件异步加载并渲染",[22,133,134],{},[31,135,136],{},"在 CSS 完全加载\u002F应用之前，页面已经渲染了",[22,138,139],{},"此时浏览器使用默认样式（链接带下划线）",[22,141,142],{},"CSS 加载完成后，下划线消失",[15,144,145,146,149],{},"这就是典型的 ",[31,147,148],{},"FOUC（Flash of Unstyled Content）"," 问题。",[11,151,153],{"id":152},"为什么刷新就正常了","为什么刷新就正常了？",[15,155,156],{},"刷新页面时，浏览器会等待所有 CSS 加载完成后再渲染页面（SSR 模式下 HTML 和 CSS 是一起返回的）。所以刷新后样式是正确的。",[15,158,159],{},"而客户端导航是 JavaScript 驱动的，CSS 的加载和页面的渲染是异步的，就出现了时序差。",[11,161,162],{"id":162},"解决方案",[15,164,165],{},"尝试了多种方案：",[19,167,168,174,180,186],{},[22,169,170,173],{},[26,171,172],{},"@layer base"," 覆盖 — 无效，CSS layers 的优先级问题",[22,175,176,179],{},[26,177,178],{},"corePlugins.preflight: false"," — 无效，且破坏其他基础样式",[22,181,182,185],{},[26,183,184],{},"features.inlineStyles: true"," — 无效",[22,187,188],{},"页面过渡动画 — 只是掩盖，不能根治",[15,190,191,192,195,196,199],{},"最终有效的方案是在 ",[26,193,194],{},"app.vue"," 中用 ",[26,197,198],{},"!important"," 强制覆盖：",[47,201,205],{"className":202,"code":203,"language":204,"meta":52,"style":52},"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",[26,206,207,218,238,253,257],{"__ignoreMap":52},[56,208,209,212,215],{"class":58,"line":59},[56,210,211],{"class":66},"\u003C",[56,213,214],{"class":62},"style",[56,216,217],{"class":66},">\n",[56,219,220,222,226,229,232,235],{"class":58,"line":70},[56,221,63],{"class":62},[56,223,225],{"class":224},"sw2iP",":not",[56,227,228],{"class":66},"(",[56,230,231],{"class":224},".prose",[56,233,234],{"class":62}," a",[56,236,237],{"class":66},") {\n",[56,239,240,243,245,247,251],{"class":58,"line":86},[56,241,242],{"class":73},"  text-decoration-line",[56,244,77],{"class":66},[56,246,111],{"class":73},[56,248,250],{"class":249},"siTax"," !important",[56,252,83],{"class":66},[56,254,255],{"class":58,"line":98},[56,256,101],{"class":66},[56,258,260,263,265],{"class":58,"line":259},5,[56,261,262],{"class":66},"\u003C\u002F",[56,264,214],{"class":62},[56,266,217],{"class":66},[15,268,269],{},[31,270,271],{},"关键点：",[19,273,274,279],{},[22,275,276,278],{},[26,277,198],{}," 是为了对抗 Tailwind CSS layers 的优先级",[22,280,281,284],{},[26,282,283],{},":not(.prose a)"," 排除文章正文区域，保留文章内链接的下划线（可读性需要）",[11,286,287],{"id":287},"总结",[15,289,290,291,293],{},"这是 Nuxt 3 + Tailwind CSS 的一个已知问题，本质上是 CSS 加载时序导致的 FOUC。",[26,292,198],{}," 虽然不够优雅，但在这个场景下是务实的解决方案。",[15,295,296],{},"如果你有更好的方案，欢迎在项目仓库的 Issues 中提出！",[214,298,299],{},"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":52,"searchDepth":70,"depth":86,"links":301},[302,303,304,305,306],{"id":13,"depth":70,"text":13},{"id":42,"depth":70,"text":42},{"id":152,"depth":70,"text":153},{"id":162,"depth":70,"text":162},{"id":287,"depth":70,"text":287},"记录 Nuxt 3 配合 Tailwind CSS 时，客户端导航导致链接文字出现下划线闪烁的问题及解决方案。","进阶",false,"md","\u002Fposts\u002Fnuxt-tailwind-underline-fix\u002Fimg\u002Fcover.png",{},true,"\u002Fposts\u002Fnuxt-tailwind-underline-fix","2026-05-31T18:47:00",{"title":5,"description":307},"Nuxt 博客开发","posts\u002Fnuxt-tailwind-underline-fix\u002Findex",[320,321,322,323],"Nuxt","Tailwind CSS","CSS","踩坑","2026-05-31","WMLpTEsIoJqufwEfXyIiBgUYxcPY2XkvOwqAD4hbe6g",[327,1153,1571,1799,2209,3395,3598,5667,6449,7428,8837,9397,10576,12112],{"id":328,"title":329,"author":330,"body":331,"description":1140,"difficulty":308,"draft":309,"extension":310,"image":1141,"meta":1142,"navigation":313,"path":1143,"pinned":309,"published":1144,"seo":1145,"series":1146,"seriesOrder":59,"stem":1147,"tags":1148,"updated":1144,"__hash__":1152},"posts\u002Fposts\u002Fdocker-musl-build\u002Findex.md","Docker 编译 musl 可执行文件",null,{"type":8,"value":332,"toc":1129},[333,336,339,342,585,589,631,634,652,655,806,809,944,947,950,1008,1011,1126],[11,334,335],{"id":335},"解决问题",[15,337,338],{},"用 Go 进行跨平台编译时往往步骤复杂，用 Docker 可以轻松解决。但 Docker 的当前最小镜像 Alpine 其动态链接库采用 musl，与主流的 glibc 不同，往往不能方便执行。",[11,340,341],{"id":341},"查阅资料",[47,343,347],{"className":344,"code":345,"language":346,"meta":52,"style":52},"language-bash shiki shiki-themes github-light github-dark","# 启动容器并挂载当前目录到 `\u002Fapp`\ndocker run -it --rm -v \"$PWD\":\u002Fapp -w \u002Fapp ubuntu:20.04 bash\n\n# 在容器内执行以下操作：\napt-get update && apt-get install -y musl-tools golang-go  # 安装 musl 和 Go\nexport CC=musl-gcc  # 指定 musl 编译器\nexport CGO_ENABLED=1  # 启用 CGO\n# 设置代理\ngo env -w GOPROXY=https:\u002F\u002Fgoproxy.cn,direct\ngo build -ldflags '-linkmode external -extldflags \"-static\"' -o myapp  # 静态编译\nexit  # 退出容器\n\n# 验证结果（在宿主机执行）\nfile myapp  # 应显示 \"statically linked\"\nldd myapp   # 应显示 \"not a dynamic executable\"\n\nwget https:\u002F\u002Fgo.dev\u002Fdl\u002Fgo1.21.linux-amd64.tar.gz\n\nENV PATH=\"\u002Fdata\u002Fgo\u002Fbin:$PATH\"\n","bash",[26,348,349,355,394,399,404,432,450,466,472,486,509,518,523,529,540,551,556,565,570],{"__ignoreMap":52},[56,350,351],{"class":58,"line":59},[56,352,354],{"class":353},"sHbNN","# 启动容器并挂载当前目录到 `\u002Fapp`\n",[56,356,357,360,364,367,370,373,376,379,382,385,388,391],{"class":58,"line":70},[56,358,359],{"class":224},"docker",[56,361,363],{"class":362},"scXbn"," run",[56,365,366],{"class":73}," -it",[56,368,369],{"class":73}," --rm",[56,371,372],{"class":73}," -v",[56,374,375],{"class":362}," \"",[56,377,378],{"class":66},"$PWD",[56,380,381],{"class":362},"\":\u002Fapp",[56,383,384],{"class":73}," -w",[56,386,387],{"class":362}," \u002Fapp",[56,389,390],{"class":362}," ubuntu:20.04",[56,392,393],{"class":362}," bash\n",[56,395,396],{"class":58,"line":86},[56,397,398],{"emptyLinePlaceholder":313},"\n",[56,400,401],{"class":58,"line":98},[56,402,403],{"class":353},"# 在容器内执行以下操作：\n",[56,405,406,409,412,415,417,420,423,426,429],{"class":58,"line":259},[56,407,408],{"class":224},"apt-get",[56,410,411],{"class":362}," update",[56,413,414],{"class":66}," && ",[56,416,408],{"class":224},[56,418,419],{"class":362}," install",[56,421,422],{"class":73}," -y",[56,424,425],{"class":362}," musl-tools",[56,427,428],{"class":362}," golang-go",[56,430,431],{"class":353},"  # 安装 musl 和 Go\n",[56,433,435,438,441,444,447],{"class":58,"line":434},6,[56,436,437],{"class":249},"export",[56,439,440],{"class":66}," CC",[56,442,443],{"class":249},"=",[56,445,446],{"class":66},"musl-gcc  ",[56,448,449],{"class":353},"# 指定 musl 编译器\n",[56,451,453,455,458,460,463],{"class":58,"line":452},7,[56,454,437],{"class":249},[56,456,457],{"class":66}," CGO_ENABLED",[56,459,443],{"class":249},[56,461,462],{"class":73},"1",[56,464,465],{"class":353},"  # 启用 CGO\n",[56,467,469],{"class":58,"line":468},8,[56,470,471],{"class":353},"# 设置代理\n",[56,473,475,478,481,483],{"class":58,"line":474},9,[56,476,477],{"class":224},"go",[56,479,480],{"class":362}," env",[56,482,384],{"class":73},[56,484,485],{"class":362}," GOPROXY=https:\u002F\u002Fgoproxy.cn,direct\n",[56,487,489,491,494,497,500,503,506],{"class":58,"line":488},10,[56,490,477],{"class":224},[56,492,493],{"class":362}," build",[56,495,496],{"class":73}," -ldflags",[56,498,499],{"class":362}," '-linkmode external -extldflags \"-static\"'",[56,501,502],{"class":73}," -o",[56,504,505],{"class":362}," myapp",[56,507,508],{"class":353},"  # 静态编译\n",[56,510,512,515],{"class":58,"line":511},11,[56,513,514],{"class":73},"exit",[56,516,517],{"class":353},"  # 退出容器\n",[56,519,521],{"class":58,"line":520},12,[56,522,398],{"emptyLinePlaceholder":313},[56,524,526],{"class":58,"line":525},13,[56,527,528],{"class":353},"# 验证结果（在宿主机执行）\n",[56,530,532,535,537],{"class":58,"line":531},14,[56,533,534],{"class":224},"file",[56,536,505],{"class":362},[56,538,539],{"class":353},"  # 应显示 \"statically linked\"\n",[56,541,543,546,548],{"class":58,"line":542},15,[56,544,545],{"class":224},"ldd",[56,547,505],{"class":362},[56,549,550],{"class":353},"   # 应显示 \"not a dynamic executable\"\n",[56,552,554],{"class":58,"line":553},16,[56,555,398],{"emptyLinePlaceholder":313},[56,557,559,562],{"class":58,"line":558},17,[56,560,561],{"class":224},"wget",[56,563,564],{"class":362}," https:\u002F\u002Fgo.dev\u002Fdl\u002Fgo1.21.linux-amd64.tar.gz\n",[56,566,568],{"class":58,"line":567},18,[56,569,398],{"emptyLinePlaceholder":313},[56,571,573,576,579,582],{"class":58,"line":572},19,[56,574,575],{"class":224},"ENV",[56,577,578],{"class":362}," PATH=\"\u002Fdata\u002Fgo\u002Fbin:",[56,580,581],{"class":66},"$PATH",[56,583,584],{"class":362},"\"\n",[586,587,588],"h3",{"id":588},"遇到问题",[121,590,591,617],{},[22,592,593,596,597,600,603,604,607,609,610,612],{},[26,594,595],{},"go mod tidy"," 时报错 ",[26,598,599],{},"tls: failed to verify certificate: x509: certificate",[601,602],"br",{},"解决：",[26,605,606],{},"RUN apt-get update && apt-get install -y ca-certificates && rm -rf \u002Fvar\u002Flib\u002Fapt\u002Flists\u002F*",[601,608],{},"基础镜像的证书未更新，导致 https 的请求出现类似错误。",[601,611],{},[613,614],"img",{"alt":615,"src":616},"image-20250327100813512","\u002Fposts\u002Fdocker-musl-build\u002Fimg\u002Fimage-20250327100813512.png",[22,618,619,620,623,624,626,627,630],{},"直接安装 ",[26,621,622],{},"golang-go"," 其版本过低",[601,625],{},"解决: ",[26,628,629],{},"PATH=\"\u002Fdata\u002Fgo\u002Fbin:$PATH\""," 手动指定环境",[11,632,633],{"id":633},"最终方案",[121,635,636,643,646],{},[22,637,638,639,642],{},"下载 ",[26,640,641],{},"go1.24.1.linux-amd64.tar.gz"," 解压",[22,644,645],{},"编写 Dockerfile",[22,647,648,649],{},"执行 ",[26,650,651],{},"docker build -t go-build:v1 .",[15,653,654],{},"Dockerfile 内容：",[47,656,660],{"className":657,"code":658,"language":659,"meta":52,"style":52},"language-dockerfile shiki shiki-themes github-light github-dark","FROM ubuntu:20.04\n\nLABEL org.opencontainers.image.authors=\"sutong\"\n\nCOPY go\u002F \u002Fgo\u002F\n\nENV PATH=\"\u002Fgo\u002Fbin:$PATH\"\n\nRUN apt-get update && apt-get install -y musl-tools\n\n## go mod tidy 时报错 tls: failed to verify certificate: x509: certificate\nRUN apt-get update && apt-get install -y ca-certificates && rm -rf \u002Fvar\u002Flib\u002Fapt\u002Flists\u002F*\n# 指定 musl 编译器\nENV CC=musl-gcc\n# 启用 CGO\nENV CGO_ENABLED=1\n\nVOLUME \u002Fdata\n\nWORKDIR \u002Fdata\n\nCMD [\"\u002Fbin\u002Fbash\"]\n","dockerfile",[26,661,662,670,674,685,689,697,701,711,715,723,727,732,739,743,750,755,762,766,774,778,786,791],{"__ignoreMap":52},[56,663,664,667],{"class":58,"line":59},[56,665,666],{"class":249},"FROM",[56,668,669],{"class":66}," ubuntu:20.04\n",[56,671,672],{"class":58,"line":70},[56,673,398],{"emptyLinePlaceholder":313},[56,675,676,679,682],{"class":58,"line":86},[56,677,678],{"class":249},"LABEL",[56,680,681],{"class":66}," org.opencontainers.image.authors=",[56,683,684],{"class":362},"\"sutong\"\n",[56,686,687],{"class":58,"line":98},[56,688,398],{"emptyLinePlaceholder":313},[56,690,691,694],{"class":58,"line":259},[56,692,693],{"class":249},"COPY",[56,695,696],{"class":66}," go\u002F \u002Fgo\u002F\n",[56,698,699],{"class":58,"line":434},[56,700,398],{"emptyLinePlaceholder":313},[56,702,703,705,708],{"class":58,"line":452},[56,704,575],{"class":249},[56,706,707],{"class":66}," PATH=",[56,709,710],{"class":362},"\"\u002Fgo\u002Fbin:$PATH\"\n",[56,712,713],{"class":58,"line":468},[56,714,398],{"emptyLinePlaceholder":313},[56,716,717,720],{"class":58,"line":474},[56,718,719],{"class":249},"RUN",[56,721,722],{"class":66}," apt-get update && apt-get install -y musl-tools\n",[56,724,725],{"class":58,"line":488},[56,726,398],{"emptyLinePlaceholder":313},[56,728,729],{"class":58,"line":511},[56,730,731],{"class":353},"## go mod tidy 时报错 tls: failed to verify certificate: x509: certificate\n",[56,733,734,736],{"class":58,"line":520},[56,735,719],{"class":249},[56,737,738],{"class":66}," apt-get update && apt-get install -y ca-certificates && rm -rf \u002Fvar\u002Flib\u002Fapt\u002Flists\u002F*\n",[56,740,741],{"class":58,"line":525},[56,742,449],{"class":353},[56,744,745,747],{"class":58,"line":531},[56,746,575],{"class":249},[56,748,749],{"class":66}," CC=musl-gcc\n",[56,751,752],{"class":58,"line":542},[56,753,754],{"class":353},"# 启用 CGO\n",[56,756,757,759],{"class":58,"line":553},[56,758,575],{"class":249},[56,760,761],{"class":66}," CGO_ENABLED=1\n",[56,763,764],{"class":58,"line":558},[56,765,398],{"emptyLinePlaceholder":313},[56,767,768,771],{"class":58,"line":567},[56,769,770],{"class":249},"VOLUME",[56,772,773],{"class":66}," \u002Fdata\n",[56,775,776],{"class":58,"line":572},[56,777,398],{"emptyLinePlaceholder":313},[56,779,781,784],{"class":58,"line":780},20,[56,782,783],{"class":249},"WORKDIR",[56,785,773],{"class":66},[56,787,789],{"class":58,"line":788},21,[56,790,398],{"emptyLinePlaceholder":313},[56,792,794,797,800,803],{"class":58,"line":793},22,[56,795,796],{"class":249},"CMD",[56,798,799],{"class":66}," [",[56,801,802],{"class":362},"\"\u002Fbin\u002Fbash\"",[56,804,805],{"class":66},"]\n",[586,807,808],{"id":808},"构建",[47,810,812],{"className":344,"code":811,"language":346,"meta":52,"style":52},"# 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",[26,813,814,819,823,828,854,858,863,898,903,907,939],{"__ignoreMap":52},[56,815,816],{"class":58,"line":59},[56,817,818],{"class":353},"# docker build -t go-build:v1 .\n",[56,820,821],{"class":58,"line":70},[56,822,398],{"emptyLinePlaceholder":313},[56,824,825],{"class":58,"line":86},[56,826,827],{"class":353},"# 直接执行编译 编译后删除\n",[56,829,830,832,834,836,838,841,844,847,849,851],{"class":58,"line":98},[56,831,359],{"class":224},[56,833,363],{"class":362},[56,835,369],{"class":73},[56,837,372],{"class":73},[56,839,840],{"class":362}," .:\u002Fdata",[56,842,843],{"class":362}," go-build:v1",[56,845,846],{"class":362}," go",[56,848,493],{"class":362},[56,850,502],{"class":73},[56,852,853],{"class":362}," app_musl\n",[56,855,856],{"class":58,"line":259},[56,857,398],{"emptyLinePlaceholder":313},[56,859,860],{"class":58,"line":434},[56,861,862],{"class":353},"# 构建编译环境\n",[56,864,865,867,869,871,874,876,878,881,884,887,890,893,895],{"class":58,"line":452},[56,866,359],{"class":224},[56,868,363],{"class":362},[56,870,366],{"class":73},[56,872,873],{"class":73}," -d",[56,875,372],{"class":73},[56,877,840],{"class":362},[56,879,880],{"class":73}," --name",[56,882,883],{"class":249}," \u003C",[56,885,886],{"class":362},"容器名",[56,888,889],{"class":66},"称",[56,891,892],{"class":249},">",[56,894,843],{"class":362},[56,896,897],{"class":362}," \u002Fbin\u002Fbash\n",[56,899,900],{"class":58,"line":468},[56,901,902],{"class":353},"# eg：docker run -it -d -v .:\u002Fdata --name logbus-build go-build:v1 \u002Fbin\u002Fbash\n",[56,904,905],{"class":58,"line":474},[56,906,398],{"emptyLinePlaceholder":313},[56,908,909,911,914,916,918,920,922,924,926,928,930,932,935,937],{"class":58,"line":488},[56,910,359],{"class":224},[56,912,913],{"class":362}," exec",[56,915,366],{"class":73},[56,917,883],{"class":249},[56,919,886],{"class":362},[56,921,889],{"class":66},[56,923,892],{"class":249},[56,925,846],{"class":362},[56,927,493],{"class":362},[56,929,502],{"class":73},[56,931,883],{"class":249},[56,933,934],{"class":362},"myap",[56,936,15],{"class":66},[56,938,217],{"class":249},[56,940,941],{"class":58,"line":511},[56,942,943],{"class":353},"# eg：docker exec -it logbus-build go build -o logbus_musl_1\n",[586,945,946],{"id":946},"额外注意",[15,948,949],{},"若没科学上网需要设置代理：",[47,951,953],{"className":344,"code":952,"language":346,"meta":52,"style":52},"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",[26,954,955,973,983,999],{"__ignoreMap":52},[56,956,957,959,961,963,965,967,969,971],{"class":58,"line":59},[56,958,359],{"class":224},[56,960,913],{"class":362},[56,962,366],{"class":73},[56,964,883],{"class":249},[56,966,886],{"class":362},[56,968,889],{"class":66},[56,970,892],{"class":249},[56,972,897],{"class":362},[56,974,975,977,979,981],{"class":58,"line":70},[56,976,477],{"class":224},[56,978,480],{"class":362},[56,980,384],{"class":73},[56,982,485],{"class":362},[56,984,985,987,989,991,993,995,997],{"class":58,"line":86},[56,986,477],{"class":224},[56,988,493],{"class":362},[56,990,496],{"class":73},[56,992,499],{"class":362},[56,994,502],{"class":73},[56,996,505],{"class":362},[56,998,508],{"class":353},[56,1000,1001,1003,1005],{"class":58,"line":98},[56,1002,477],{"class":224},[56,1004,493],{"class":362},[56,1006,1007],{"class":353}," # musl 库编译\n",[586,1009,1010],{"id":1010},"运行环境示例",[47,1012,1014],{"className":657,"code":1013,"language":659,"meta":52,"style":52},"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",[26,1015,1016,1023,1027,1035,1039,1046,1050,1057,1064,1068,1075,1079,1086,1090,1112,1116,1121],{"__ignoreMap":52},[56,1017,1018,1020],{"class":58,"line":59},[56,1019,666],{"class":249},[56,1021,1022],{"class":66}," alpine:latest\n",[56,1024,1025],{"class":58,"line":70},[56,1026,398],{"emptyLinePlaceholder":313},[56,1028,1029,1031,1033],{"class":58,"line":86},[56,1030,678],{"class":249},[56,1032,681],{"class":66},[56,1034,684],{"class":362},[56,1036,1037],{"class":58,"line":98},[56,1038,398],{"emptyLinePlaceholder":313},[56,1040,1041,1043],{"class":58,"line":259},[56,1042,693],{"class":249},[56,1044,1045],{"class":66}," glog_musl \u002Fapp\u002F\n",[56,1047,1048],{"class":58,"line":434},[56,1049,398],{"emptyLinePlaceholder":313},[56,1051,1052,1054],{"class":58,"line":452},[56,1053,693],{"class":249},[56,1055,1056],{"class":66}," conf.ini \u002Fdata\u002F\n",[56,1058,1059,1061],{"class":58,"line":468},[56,1060,693],{"class":249},[56,1062,1063],{"class":66}," ipname.ini \u002Fdata\u002F\n",[56,1065,1066],{"class":58,"line":474},[56,1067,398],{"emptyLinePlaceholder":313},[56,1069,1070,1072],{"class":58,"line":488},[56,1071,783],{"class":249},[56,1073,1074],{"class":66}," \u002Fapp\n",[56,1076,1077],{"class":58,"line":511},[56,1078,398],{"emptyLinePlaceholder":313},[56,1080,1081,1083],{"class":58,"line":520},[56,1082,719],{"class":249},[56,1084,1085],{"class":66}," chmod 777 \u002Fapp\u002Fglog_musl\n",[56,1087,1088],{"class":58,"line":525},[56,1089,398],{"emptyLinePlaceholder":313},[56,1091,1092,1094,1096,1099,1102,1105,1107,1110],{"class":58,"line":531},[56,1093,796],{"class":249},[56,1095,799],{"class":66},[56,1097,1098],{"class":362},"\".\u002Fglog_musl\"",[56,1100,1101],{"class":66},", ",[56,1103,1104],{"class":362},"\"logsweb\"",[56,1106,1101],{"class":66},[56,1108,1109],{"class":362},"\"\u002Fdata\u002Fconf.ini\"",[56,1111,805],{"class":66},[56,1113,1114],{"class":58,"line":542},[56,1115,398],{"emptyLinePlaceholder":313},[56,1117,1118],{"class":58,"line":553},[56,1119,1120],{"class":353},"# docker build -t glog:v1 .\n",[56,1122,1123],{"class":58,"line":558},[56,1124,1125],{"class":353},"# docker run -p 6801:6801 glog:v1\n",[214,1127,1128],{},"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":52,"searchDepth":70,"depth":86,"links":1130},[1131,1132,1135],{"id":335,"depth":70,"text":335},{"id":341,"depth":70,"text":341,"children":1133},[1134],{"id":588,"depth":86,"text":588},{"id":633,"depth":70,"text":633,"children":1136},[1137,1138,1139],{"id":808,"depth":86,"text":808},{"id":946,"depth":86,"text":946},{"id":1010,"depth":86,"text":1010},"解决 Go 语言跨平台编译问题，使用 Docker + musl 编译静态链接的可执行文件。","\u002Fposts\u002Fdocker-musl-build\u002Fimg\u002Fcover.svg",{},"\u002Fposts\u002Fdocker-musl-build","2025-03-27",{"title":329,"description":1140},"Docker 实践笔记","posts\u002Fdocker-musl-build\u002Findex",[1149,1150,1151],"Docker","Go","运维","YLtdOOc-gMVDSNjE-ntKXz-7hff-VtvMB2dB5RXNi1o",{"id":1154,"title":1155,"author":330,"body":1156,"description":1560,"difficulty":1561,"draft":309,"extension":310,"image":1562,"meta":1563,"navigation":313,"path":1564,"pinned":309,"published":1565,"seo":1566,"series":1146,"seriesOrder":70,"stem":1567,"tags":1568,"updated":1565,"__hash__":1570},"posts\u002Fposts\u002Fdocker-user-start\u002Findex.md","Docker 构建时指定用户启动",{"type":8,"value":1157,"toc":1550},[1158,1161,1164,1166,1170,1173,1190,1194,1227,1231,1238,1287,1290,1509,1511,1514,1544,1547],[11,1159,1160],{"id":1160},"前言",[15,1162,1163],{},"我们在 Linux 使用应用时，往往不会直接使用 root 用户作为应用启动项，但 Dockerfile 默认就是 root 用户，导致挂载生成的文件路径往往是 root 权限，非 root 用户访问就很不方便。",[11,1165,162],{"id":162},[586,1167,1169],{"id":1168},"_1-安装用户管理工具","1. 安装用户管理工具",[15,1171,1172],{},"若镜像非自带用户管理：",[47,1174,1176],{"className":657,"code":1175,"language":659,"meta":52,"style":52},"# 权限管理\nRUN apk add --no-cache shadow\n",[26,1177,1178,1183],{"__ignoreMap":52},[56,1179,1180],{"class":58,"line":59},[56,1181,1182],{"class":353},"# 权限管理\n",[56,1184,1185,1187],{"class":58,"line":70},[56,1186,719],{"class":249},[56,1188,1189],{"class":66}," apk add --no-cache shadow\n",[586,1191,1193],{"id":1192},"_2-创建用户并修改权限","2. 创建用户并修改权限",[47,1195,1197],{"className":657,"code":1196,"language":659,"meta":52,"style":52},"# 创建用户组和用户\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",[26,1198,1199,1204,1211,1215,1220],{"__ignoreMap":52},[56,1200,1201],{"class":58,"line":59},[56,1202,1203],{"class":353},"# 创建用户组和用户\n",[56,1205,1206,1208],{"class":58,"line":70},[56,1207,719],{"class":249},[56,1209,1210],{"class":66}," addgroup -S \u003Capp_user_group> && adduser -S -G \u003Capp_user_group> \u003Capp_user>\n",[56,1212,1213],{"class":58,"line":86},[56,1214,398],{"emptyLinePlaceholder":313},[56,1216,1217],{"class":58,"line":98},[56,1218,1219],{"class":353},"# 修改目录权限\n",[56,1221,1222,1224],{"class":58,"line":259},[56,1223,719],{"class":249},[56,1225,1226],{"class":66}," chown -R \u003Capp_user_group>:\u003Capp_user> \u002Fdata\n",[586,1228,1230],{"id":1229},"_3-指定用户启动","3. 指定用户启动",[15,1232,1233,1234,1237],{},"执行 docker 时加 ",[26,1235,1236],{},"--user"," 参数：",[47,1239,1241],{"className":344,"code":1240,"language":346,"meta":52,"style":52},"docker run -it --user \u003Capp_user> \u003Capp_image>:\u003Cimage_version>\n",[26,1242,1243],{"__ignoreMap":52},[56,1244,1245,1247,1249,1251,1254,1256,1259,1262,1264,1266,1269,1272,1274,1277,1279,1282,1285],{"class":58,"line":59},[56,1246,359],{"class":224},[56,1248,363],{"class":362},[56,1250,366],{"class":73},[56,1252,1253],{"class":73}," --user",[56,1255,883],{"class":249},[56,1257,1258],{"class":362},"app_use",[56,1260,1261],{"class":66},"r",[56,1263,892],{"class":249},[56,1265,883],{"class":249},[56,1267,1268],{"class":362},"app_imag",[56,1270,1271],{"class":66},"e",[56,1273,892],{"class":249},[56,1275,1276],{"class":362},":",[56,1278,211],{"class":249},[56,1280,1281],{"class":362},"image_versio",[56,1283,1284],{"class":66},"n",[56,1286,217],{"class":249},[11,1288,1289],{"id":1289},"完整示例",[47,1291,1293],{"className":657,"code":1292,"language":659,"meta":52,"style":52},"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",[26,1294,1295,1301,1305,1310,1323,1327,1334,1338,1344,1349,1356,1361,1368,1372,1379,1383,1387,1394,1398,1405,1409,1416,1420,1434,1441,1446,1452,1460,1468,1473,1479,1488],{"__ignoreMap":52},[56,1296,1297,1299],{"class":58,"line":59},[56,1298,666],{"class":249},[56,1300,1022],{"class":66},[56,1302,1303],{"class":58,"line":70},[56,1304,398],{"emptyLinePlaceholder":313},[56,1306,1307],{"class":58,"line":86},[56,1308,1309],{"class":353},"# 国内源\n",[56,1311,1312,1314,1317,1320],{"class":58,"line":98},[56,1313,719],{"class":249},[56,1315,1316],{"class":66}," sed -i ",[56,1318,1319],{"class":362},"'s\u002Fdl-cdn.alpinelinux.org\u002Fmirrors.ustc.edu.cn\u002Fg'",[56,1321,1322],{"class":66}," \u002Fetc\u002Fapk\u002Frepositories\n",[56,1324,1325],{"class":58,"line":259},[56,1326,398],{"emptyLinePlaceholder":313},[56,1328,1329,1331],{"class":58,"line":434},[56,1330,719],{"class":249},[56,1332,1333],{"class":66}," apk update --no-cache\n",[56,1335,1336],{"class":58,"line":452},[56,1337,1182],{"class":353},[56,1339,1340,1342],{"class":58,"line":468},[56,1341,719],{"class":249},[56,1343,1189],{"class":66},[56,1345,1346],{"class":58,"line":474},[56,1347,1348],{"class":353},"# 调试\n",[56,1350,1351,1353],{"class":58,"line":488},[56,1352,719],{"class":249},[56,1354,1355],{"class":66}," apk add --no-cache bash\n",[56,1357,1358],{"class":58,"line":511},[56,1359,1360],{"class":353},"# 设置时区\n",[56,1362,1363,1365],{"class":58,"line":520},[56,1364,719],{"class":249},[56,1366,1367],{"class":66}," apk add --no-cache tzdata\n",[56,1369,1370],{"class":58,"line":525},[56,1371,398],{"emptyLinePlaceholder":313},[56,1373,1374,1376],{"class":58,"line":531},[56,1375,575],{"class":249},[56,1377,1378],{"class":66}," TZ=Asia\u002FShanghai\n",[56,1380,1381],{"class":58,"line":542},[56,1382,398],{"emptyLinePlaceholder":313},[56,1384,1385],{"class":58,"line":553},[56,1386,1203],{"class":353},[56,1388,1389,1391],{"class":58,"line":558},[56,1390,719],{"class":249},[56,1392,1393],{"class":66}," addgroup -S glog && adduser -S -G glog glog\n",[56,1395,1396],{"class":58,"line":567},[56,1397,398],{"emptyLinePlaceholder":313},[56,1399,1400,1402],{"class":58,"line":572},[56,1401,693],{"class":249},[56,1403,1404],{"class":66}," .\u002Fglog_static_musl_1 \u002Fapp\u002F\n",[56,1406,1407],{"class":58,"line":780},[56,1408,398],{"emptyLinePlaceholder":313},[56,1410,1411,1413],{"class":58,"line":788},[56,1412,719],{"class":249},[56,1414,1415],{"class":66}," mkdir \u002Fdata\n",[56,1417,1418],{"class":58,"line":793},[56,1419,398],{"emptyLinePlaceholder":313},[56,1421,1423,1425,1428,1431],{"class":58,"line":1422},23,[56,1424,770],{"class":249},[56,1426,1427],{"class":66}," [ ",[56,1429,1430],{"class":362},"\"\u002Fdata\"",[56,1432,1433],{"class":66}," ]\n",[56,1435,1437,1439],{"class":58,"line":1436},24,[56,1438,783],{"class":249},[56,1440,773],{"class":66},[56,1442,1444],{"class":58,"line":1443},25,[56,1445,398],{"emptyLinePlaceholder":313},[56,1447,1449],{"class":58,"line":1448},26,[56,1450,1451],{"class":353},"# 修改权限\n",[56,1453,1455,1457],{"class":58,"line":1454},27,[56,1456,719],{"class":249},[56,1458,1459],{"class":66}," chown -R glog:glog \u002Fdata\n",[56,1461,1463,1465],{"class":58,"line":1462},28,[56,1464,719],{"class":249},[56,1466,1467],{"class":66}," chown -R glog:glog \u002Fapp\n",[56,1469,1471],{"class":58,"line":1470},29,[56,1472,398],{"emptyLinePlaceholder":313},[56,1474,1476],{"class":58,"line":1475},30,[56,1477,1478],{"class":353},"# 指定用户启动\n",[56,1480,1482,1485],{"class":58,"line":1481},31,[56,1483,1484],{"class":249},"USER",[56,1486,1487],{"class":66}," glog\n",[56,1489,1491,1493,1495,1498,1500,1502,1504,1507],{"class":58,"line":1490},32,[56,1492,796],{"class":249},[56,1494,1427],{"class":66},[56,1496,1497],{"class":362},"\"\u002Fapp\u002Fglog_static_musl_1\"",[56,1499,1101],{"class":66},[56,1501,1104],{"class":362},[56,1503,1101],{"class":66},[56,1505,1506],{"class":362},"\".\u002Fconf.ini\"",[56,1508,1433],{"class":66},[11,1510,287],{"id":287},[15,1512,1513],{},"关键步骤：",[121,1515,1516,1522,1532,1538],{},[22,1517,1518,1521],{},[26,1519,1520],{},"apk add --no-cache shadow"," - 安装用户管理工具",[22,1523,1524,1527,1528,1531],{},[26,1525,1526],{},"addgroup"," + ",[26,1529,1530],{},"adduser"," - 创建用户",[22,1533,1534,1537],{},[26,1535,1536],{},"chown -R"," - 修改目录权限",[22,1539,1540,1543],{},[26,1541,1542],{},"USER \u003Cusername>"," - 指定运行用户",[15,1545,1546],{},"这样容器内的应用就不再以 root 身份运行，更加安全。",[214,1548,1549],{},"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":52,"searchDepth":70,"depth":86,"links":1551},[1552,1553,1558,1559],{"id":1160,"depth":70,"text":1160},{"id":162,"depth":70,"text":162,"children":1554},[1555,1556,1557],{"id":1168,"depth":86,"text":1169},{"id":1192,"depth":86,"text":1193},{"id":1229,"depth":86,"text":1230},{"id":1289,"depth":70,"text":1289},{"id":287,"depth":70,"text":287},"解决 Docker 容器默认以 root 用户运行导致的权限问题，指定非 root 用户启动应用。","入门","\u002Fposts\u002Fdocker-user-start\u002Fimg\u002Fcover.svg",{},"\u002Fposts\u002Fdocker-user-start","2025-04-01",{"title":1155,"description":1560},"posts\u002Fdocker-user-start\u002Findex",[1149,1151,1569],"Linux","b4Uyw5ms87xr7NhpIkavHnNbT20oaEPtXz8k1w6UPj0",{"id":1572,"title":1573,"author":330,"body":1574,"description":1789,"difficulty":330,"draft":309,"extension":310,"image":1790,"meta":1791,"navigation":313,"path":1792,"pinned":309,"published":1793,"seo":1794,"series":330,"seriesOrder":330,"stem":1795,"tags":1796,"updated":330,"__hash__":1798},"posts\u002Fposts\u002Fsveltekit-intro\u002Findex.md","SvelteKit 入门：为什么它比你想象的更简单",{"type":8,"value":1575,"toc":1779},[1576,1580,1583,1589,1592,1596,1603,1654,1658,1664,1672,1676,1679,1682,1757,1759,1770,1776],[11,1577,1579],{"id":1578},"什么是-sveltekit","什么是 SvelteKit？",[15,1581,1582],{},"SvelteKit 是 Svelte 的官方全栈框架，类似于 React 之于 Next.js 的关系。它提供了路由、SSR、SSG 等能力，让开发者可以快速构建现代 Web 应用。",[15,1584,1585],{},[613,1586],{"alt":1587,"src":1588},"SvelteKit Logo","https:\u002F\u002Fraw.githubusercontent.com\u002Fsveltejs\u002Fbranding\u002Fmaster\u002Fsvelte-logo.svg",[11,1590,1591],{"id":1591},"核心特点",[586,1593,1595],{"id":1594},"_1-编译时框架","1. 编译时框架",[15,1597,1598,1599,1602],{},"Svelte 和 React\u002FVue 最大的区别在于：",[31,1600,1601],{},"它在编译阶段就把组件转成原生 JS","，运行时没有虚拟 DOM。",[47,1604,1608],{"className":1605,"code":1606,"language":1607,"meta":52,"style":52},"language-typescript shiki shiki-themes github-light github-dark","\u002F\u002F Svelte 组件（编译后变成直接的 DOM 操作）\nlet count = 0\nfunction increment() {\n  count += 1\n}\n","typescript",[26,1609,1610,1615,1628,1639,1650],{"__ignoreMap":52},[56,1611,1612],{"class":58,"line":59},[56,1613,1614],{"class":353},"\u002F\u002F Svelte 组件（编译后变成直接的 DOM 操作）\n",[56,1616,1617,1620,1623,1625],{"class":58,"line":70},[56,1618,1619],{"class":249},"let",[56,1621,1622],{"class":66}," count ",[56,1624,443],{"class":249},[56,1626,1627],{"class":73}," 0\n",[56,1629,1630,1633,1636],{"class":58,"line":86},[56,1631,1632],{"class":249},"function",[56,1634,1635],{"class":224}," increment",[56,1637,1638],{"class":66},"() {\n",[56,1640,1641,1644,1647],{"class":58,"line":98},[56,1642,1643],{"class":66},"  count ",[56,1645,1646],{"class":249},"+=",[56,1648,1649],{"class":73}," 1\n",[56,1651,1652],{"class":58,"line":259},[56,1653,101],{"class":66},[586,1655,1657],{"id":1656},"_2-文件路由","2. 文件路由",[15,1659,1660,1663],{},[26,1661,1662],{},"src\u002Froutes\u002F"," 目录结构即 URL，不需要手动配置路由表：",[47,1665,1670],{"className":1666,"code":1668,"language":1669},[1667],"language-text","src\u002Froutes\u002F\n├── +page.svelte        → \u002F\n├── about\u002F\n│   └── +page.svelte    → \u002Fabout\n└── posts\u002F\n    ├── +page.svelte    → \u002Fposts\n    └── [slug]\u002F\n        └── +page.svelte → \u002Fposts\u002F:slug\n","text",[26,1671,1668],{"__ignoreMap":52},[586,1673,1675],{"id":1674},"_3-全栈能力","3. 全栈能力",[15,1677,1678],{},"SvelteKit 内置了服务端渲染（SSR）、静态站点生成（SSG）、API 路由等功能，开箱即用。",[11,1680,1681],{"id":1681},"和其他框架对比",[1683,1684,1685,1703],"table",{},[1686,1687,1688],"thead",{},[1689,1690,1691,1695,1698,1701],"tr",{},[1692,1693,1694],"th",{},"特性",[1692,1696,1697],{},"SvelteKit",[1692,1699,1700],{},"Next.js",[1692,1702,320],{},[1704,1705,1706,1720,1733,1746],"tbody",{},[1689,1707,1708,1712,1715,1718],{},[1709,1710,1711],"td",{},"学习曲线",[1709,1713,1714],{},"低",[1709,1716,1717],{},"中",[1709,1719,1717],{},[1689,1721,1722,1725,1728,1731],{},[1709,1723,1724],{},"包体积",[1709,1726,1727],{},"小",[1709,1729,1730],{},"大",[1709,1732,1717],{},[1689,1734,1735,1738,1741,1744],{},[1709,1736,1737],{},"运行时开销",[1709,1739,1740],{},"无",[1709,1742,1743],{},"有",[1709,1745,1743],{},[1689,1747,1748,1751,1753,1755],{},[1709,1749,1750],{},"生态",[1709,1752,1727],{},[1709,1754,1730],{},[1709,1756,1730],{},[11,1758,287],{"id":287},[15,1760,1761,1762,1765,1766,1769],{},"如果你追求",[31,1763,1764],{},"简洁的代码","和",[31,1767,1768],{},"极致的性能","，SvelteKit 是一个非常值得尝试的选择。它的学习曲线平缓，写起来就像在写原生 HTML\u002FCSS\u002FJS。",[15,1771,1772],{},[613,1773],{"alt":1774,"src":1775},"Svelte 编译过程","https:\u002F\u002Fsvelte.dev\u002Fsvelte-logo-horizontal.svg",[214,1777,1778],{},"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":52,"searchDepth":70,"depth":86,"links":1780},[1781,1782,1787,1788],{"id":1578,"depth":70,"text":1579},{"id":1591,"depth":70,"text":1591,"children":1783},[1784,1785,1786],{"id":1594,"depth":86,"text":1595},{"id":1656,"depth":86,"text":1657},{"id":1674,"depth":86,"text":1675},{"id":1681,"depth":70,"text":1681},{"id":287,"depth":70,"text":287},"SvelteKit 是一个全栈 Svelte 框架，这篇文章带你了解它的核心概念和优势。","\u002Fposts\u002Fsveltekit-intro\u002Fimg\u002Fcover.jpg",{},"\u002Fposts\u002Fsveltekit-intro","2026-05-30",{"title":1573,"description":1789},"posts\u002Fsveltekit-intro\u002Findex",[1697,1797],"前端框架","vsEGyunpCWR7DiJAdyV1SPDfa57xXLITBI-lKM8UeGQ",{"id":1800,"title":1801,"author":330,"body":1802,"description":2199,"difficulty":1561,"draft":309,"extension":310,"image":2200,"meta":2201,"navigation":313,"path":2202,"pinned":313,"published":2203,"seo":2204,"series":317,"seriesOrder":59,"stem":2205,"tags":2206,"updated":324,"__hash__":2208},"posts\u002Fposts\u002Fhello-world\u002Findex.md","Hello World - 欢迎使用 SuBlog",{"type":8,"value":1803,"toc":2190},[1804,1808,1814,1817,1820,1823,1897,1900,1903,1965,1968,1971,2036,2039,2103,2106,2109,2125,2132,2135,2166,2169,2179,2182,2187],[11,1805,1807],{"id":1806},"博客上线啦","博客上线啦！",[15,1809,1810,1811,1813],{},"Hello World！欢迎使用 ",[31,1812,6],{},"，这是站点的第一篇示例文章。",[15,1815,1816],{},"经过一段时间的折腾，这个基于 Nuxt 3 的个人博客终于上线了。在这里记录一下这个项目的技术选型和功能特性。",[11,1818,1819],{"id":1819},"技术栈",[15,1821,1822],{},"本站使用了以下技术：",[1683,1824,1825,1835],{},[1686,1826,1827],{},[1689,1828,1829,1832],{},[1692,1830,1831],{},"技术",[1692,1833,1834],{},"用途",[1704,1836,1837,1847,1857,1867,1877,1887],{},[1689,1838,1839,1844],{},[1709,1840,1841],{},[31,1842,1843],{},"Nuxt 3",[1709,1845,1846],{},"Vue 3 全栈框架，提供 SSG、路由、API 等能力",[1689,1848,1849,1854],{},[1709,1850,1851],{},[31,1852,1853],{},"TypeScript",[1709,1855,1856],{},"类型安全，提升开发体验",[1689,1858,1859,1864],{},[1709,1860,1861],{},[31,1862,1863],{},"Tailwind CSS v4",[1709,1865,1866],{},"原子化 CSS，快速构建 UI",[1689,1868,1869,1874],{},[1709,1870,1871],{},[31,1872,1873],{},"@nuxt\u002Fcontent",[1709,1875,1876],{},"Markdown 驱动的内容管理",[1689,1878,1879,1884],{},[1709,1880,1881],{},[31,1882,1883],{},"Shiki",[1709,1885,1886],{},"代码语法高亮",[1689,1888,1889,1894],{},[1709,1890,1891],{},[31,1892,1893],{},"Giscus",[1709,1895,1896],{},"基于 GitHub Discussions 的评论系统",[11,1898,1899],{"id":1899},"功能特性",[15,1901,1902],{},"目前已实现的功能：",[19,1904,1905,1911,1917,1923,1929,1935,1941,1947,1953,1959],{},[22,1906,1907,1910],{},[31,1908,1909],{},"博客系统"," - Markdown 文章，支持代码高亮、目录导航、搜索",[22,1912,1913,1916],{},[31,1914,1915],{},"暗色模式"," - 跟随系统或手动切换",[22,1918,1919,1922],{},[31,1920,1921],{},"文章搜索"," - 支持标题、描述、正文、标签多维度搜索",[22,1924,1925,1928],{},[31,1926,1927],{},"标签系统"," - 文章标签分类，高频标签快速筛选",[22,1930,1931,1934],{},[31,1932,1933],{},"友链页面"," - 展示友情链接，支持申请",[22,1936,1937,1940],{},[31,1938,1939],{},"赞助页面"," - 微信收款码展示",[22,1942,1943,1946],{},[31,1944,1945],{},"封面制作"," - 在线生成封面图片工具",[22,1948,1949,1952],{},[31,1950,1951],{},"网易云热评"," - 首页随机展示音乐评论",[22,1954,1955,1958],{},[31,1956,1957],{},"访客统计"," - 不蒜子计数器，带数字滚动动画",[22,1960,1961,1964],{},[31,1962,1963],{},"SEO 优化"," - 自动生成站点地图、Open Graph 标签",[11,1966,1967],{"id":1967},"代码示例",[15,1969,1970],{},"本站的配置非常集中，一个文件搞定全站定制：",[47,1972,1974],{"className":1605,"code":1973,"language":1607,"meta":52,"style":52},"\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",[26,1975,1976,1981,1996,2007,2017,2027,2032],{"__ignoreMap":52},[56,1977,1978],{"class":58,"line":59},[56,1979,1980],{"class":353},"\u002F\u002F config\u002Fsite.ts\n",[56,1982,1983,1985,1988,1991,1994],{"class":58,"line":70},[56,1984,437],{"class":249},[56,1986,1987],{"class":249}," const",[56,1989,1990],{"class":73}," siteConfig",[56,1992,1993],{"class":249}," =",[56,1995,67],{"class":66},[56,1997,1998,2001,2004],{"class":58,"line":86},[56,1999,2000],{"class":66},"  siteName: ",[56,2002,2003],{"class":362},"'我的博客'",[56,2005,2006],{"class":66},",\n",[56,2008,2009,2012,2015],{"class":58,"line":98},[56,2010,2011],{"class":66},"  url: ",[56,2013,2014],{"class":362},"'https:\u002F\u002Fexample.com\u002F'",[56,2016,2006],{"class":66},[56,2018,2019,2022,2025],{"class":58,"line":259},[56,2020,2021],{"class":66},"  avatar: ",[56,2023,2024],{"class":362},"'https:\u002F\u002Fq2.qlogo.cn\u002Fheadimg_dl?dst_uin=xxx&spec=100'",[56,2026,2006],{"class":66},[56,2028,2029],{"class":58,"line":434},[56,2030,2031],{"class":353},"  \u002F\u002F ... 改这一个文件，全站生效\n",[56,2033,2034],{"class":58,"line":452},[56,2035,101],{"class":66},[15,2037,2038],{},"文章的 frontmatter 也很简洁：",[47,2040,2044],{"className":2041,"code":2042,"language":2043,"meta":52,"style":52},"language-yaml shiki shiki-themes github-light github-dark","---\ntitle: 文章标题\npublished: 2026-05-31T08:00:00\ndescription: 文章简介\ntags: ['Nuxt', 'TypeScript']\n---\n","yaml",[26,2045,2046,2051,2061,2071,2081,2099],{"__ignoreMap":52},[56,2047,2048],{"class":58,"line":59},[56,2049,2050],{"class":224},"---\n",[56,2052,2053,2056,2058],{"class":58,"line":70},[56,2054,2055],{"class":62},"title",[56,2057,77],{"class":66},[56,2059,2060],{"class":362},"文章标题\n",[56,2062,2063,2066,2068],{"class":58,"line":86},[56,2064,2065],{"class":62},"published",[56,2067,77],{"class":66},[56,2069,2070],{"class":73},"2026-05-31T08:00:00\n",[56,2072,2073,2076,2078],{"class":58,"line":98},[56,2074,2075],{"class":62},"description",[56,2077,77],{"class":66},[56,2079,2080],{"class":362},"文章简介\n",[56,2082,2083,2086,2089,2092,2094,2097],{"class":58,"line":259},[56,2084,2085],{"class":62},"tags",[56,2087,2088],{"class":66},": [",[56,2090,2091],{"class":362},"'Nuxt'",[56,2093,1101],{"class":66},[56,2095,2096],{"class":362},"'TypeScript'",[56,2098,805],{"class":66},[56,2100,2101],{"class":58,"line":434},[56,2102,2050],{"class":224},[11,2104,2105],{"id":2105},"部署",[15,2107,2108],{},"本站使用 SSG 静态生成，构建后直接部署到静态托管即可：",[47,2110,2112],{"className":344,"code":2111,"language":346,"meta":52,"style":52},"npx nuxi generate\n",[26,2113,2114],{"__ignoreMap":52},[56,2115,2116,2119,2122],{"class":58,"line":59},[56,2117,2118],{"class":224},"npx",[56,2120,2121],{"class":362}," nuxi",[56,2123,2124],{"class":362}," generate\n",[15,2126,2127,2128,2131],{},"构建产物在 ",[26,2129,2130],{},".output\u002Fpublic"," 目录下，可以直接丢到 Nginx、Vercel、Netlify 等平台。",[11,2133,2134],{"id":2134},"后续计划",[19,2136,2139,2148,2154,2160],{"className":2137},[2138],"contains-task-list",[22,2140,2143,2147],{"className":2141},[2142],"task-list-item",[2144,2145],"input",{"disabled":313,"type":2146},"checkbox"," RSS 订阅",[22,2149,2151,2153],{"className":2150},[2142],[2144,2152],{"disabled":313,"type":2146}," 文章目录优化",[22,2155,2157,2159],{"className":2156},[2142],[2144,2158],{"disabled":313,"type":2146}," 更多小工具",[22,2161,2163,2165],{"className":2162},[2142],[2144,2164],{"disabled":313,"type":2146}," 持续输出内容",[11,2167,2168],{"id":2168},"致谢",[15,2170,2171,2172,2178],{},"感谢 ",[63,2173,2177],{"href":2174,"rel":2175},"https:\u002F\u002F2x.nz",[2176],"nofollow","二叉树树"," 的开源项目，本站的 UI 设计参考了他的项目。",[2180,2181],"hr",{},[15,2183,2184],{},[31,2185,2186],{},"感谢阅读！如果觉得不错，欢迎点个 Star 或者留个评论 (☞ﾟヮﾟ)☞",[214,2188,2189],{},"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":52,"searchDepth":70,"depth":86,"links":2191},[2192,2193,2194,2195,2196,2197,2198],{"id":1806,"depth":70,"text":1807},{"id":1819,"depth":70,"text":1819},{"id":1899,"depth":70,"text":1899},{"id":1967,"depth":70,"text":1967},{"id":2105,"depth":70,"text":2105},{"id":2134,"depth":70,"text":2134},{"id":2168,"depth":70,"text":2168},"博客上线啦！这篇文章介绍本站的技术架构、功能特性和搭建过程中的一些思考。","\u002Fposts\u002Fhello-world\u002Fimg\u002Fcover.jpg",{},"\u002Fposts\u002Fhello-world","2026-05-31T08:00:00",{"title":1801,"description":2199},"posts\u002Fhello-world\u002Findex",[2207,320],"博客","f9MCboCBfQIUxT8aKv6aKGDFxysiG8ZG0YXr5X5LTFo",{"id":2210,"title":2211,"author":330,"body":2212,"description":3385,"difficulty":308,"draft":309,"extension":310,"image":3386,"meta":3387,"navigation":313,"path":3388,"pinned":309,"published":3389,"seo":3390,"series":317,"seriesOrder":70,"stem":3391,"tags":3392,"updated":324,"__hash__":3394},"posts\u002Fposts\u002Fnuxt-content-lessons\u002Findex.md","Nuxt Content v3 踩坑记录：目录、图片与那些反直觉的设计",{"type":8,"value":2213,"toc":3375},[2214,2218,2221,2224,2228,2231,2242,2245,2252,2320,2327,2329,2336,2614,2624,2628,2631,2634,2660,2674,2677,2700,2706,2709,2712,2715,2720,2727,2951,2956,3000,3004,3007,3014,3017,3067,3073,3302,3312,3314,3366,3369,3372],[2215,2216,2217],"h1",{"id":2217},"背景",[15,2219,2220],{},"最近在用 Nuxt 3 + Nuxt Content v3 搭建一个个人博客，参考了一个 SvelteKit 项目（svaf）的架构。原以为换个框架只是语法差异，没想到在几个关键功能上踩了不少坑。",[15,2222,2223],{},"这篇文章记录了三个核心问题的排查过程和最终方案。",[2215,2225,2227],{"id":2226},"问题一目录导航toc只有-h2","问题一：目录导航（TOC）只有 h2",[11,2229,2230],{"id":2230},"现象",[15,2232,2233,2234,2237,2238,2241],{},"Nuxt Content 内置了 TOC 数据，挂在 ",[26,2235,2236],{},"post.body.toc"," 上。直接用它渲染目录，发现",[31,2239,2240],{},"只有 h2 标题，h3 全部丢失","。",[11,2243,2244],{"id":2244},"排查",[15,2246,2247,2248,2251],{},"查了官方文档，发现 ",[26,2249,2250],{},"build.markdown.toc.depth"," 可以控制深度：",[47,2253,2255],{"className":1605,"code":2254,"language":1607,"meta":52,"style":52},"\u002F\u002F nuxt.config.ts\ncontent: {\n  build: {\n    markdown: {\n      toc: { depth: 3 }  \u002F\u002F 包含 h3\n    }\n  }\n}\n",[26,2256,2257,2262,2270,2277,2284,2306,2311,2316],{"__ignoreMap":52},[56,2258,2259],{"class":58,"line":59},[56,2260,2261],{"class":353},"\u002F\u002F nuxt.config.ts\n",[56,2263,2264,2267],{"class":58,"line":70},[56,2265,2266],{"class":224},"content",[56,2268,2269],{"class":66},": {\n",[56,2271,2272,2275],{"class":58,"line":86},[56,2273,2274],{"class":224},"  build",[56,2276,2269],{"class":66},[56,2278,2279,2282],{"class":58,"line":98},[56,2280,2281],{"class":224},"    markdown",[56,2283,2269],{"class":66},[56,2285,2286,2289,2292,2295,2297,2300,2303],{"class":58,"line":259},[56,2287,2288],{"class":224},"      toc",[56,2290,2291],{"class":66},": { ",[56,2293,2294],{"class":224},"depth",[56,2296,77],{"class":66},[56,2298,2299],{"class":73},"3",[56,2301,2302],{"class":66}," }  ",[56,2304,2305],{"class":353},"\u002F\u002F 包含 h3\n",[56,2307,2308],{"class":58,"line":434},[56,2309,2310],{"class":66},"    }\n",[56,2312,2313],{"class":58,"line":452},[56,2314,2315],{"class":66},"  }\n",[56,2317,2318],{"class":58,"line":468},[56,2319,101],{"class":66},[15,2321,2322,2323,2326],{},"配置改了，清了缓存重启，",[31,2324,2325],{},"依然只有 h2","。这个配置似乎不起作用。",[11,2328,633],{"id":633},[15,2330,2331,2332,2335],{},"放弃依赖 Nuxt Content 的 toc，直接从 ",[26,2333,2334],{},"post.body"," 的 AST 里递归提取所有标题：",[47,2337,2339],{"className":1605,"code":2338,"language":1607,"meta":52,"style":52},"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",[26,2340,2341,2384,2404,2417,2422,2483,2513,2545,2570,2574,2599,2603,2610],{"__ignoreMap":52},[56,2342,2343,2345,2348,2350,2354,2356,2359,2361,2364,2366,2369,2372,2374,2377,2379,2381],{"class":58,"line":59},[56,2344,1632],{"class":249},[56,2346,2347],{"class":224}," extractHeadings",[56,2349,228],{"class":66},[56,2351,2353],{"class":2352},"sPK6S","node",[56,2355,1276],{"class":249},[56,2357,2358],{"class":73}," any",[56,2360,1101],{"class":66},[56,2362,2363],{"class":2352},"list",[56,2365,1276],{"class":249},[56,2367,2368],{"class":224}," Heading",[56,2370,2371],{"class":66},"[] ",[56,2373,443],{"class":249},[56,2375,2376],{"class":66}," [])",[56,2378,1276],{"class":249},[56,2380,2368],{"class":224},[56,2382,2383],{"class":66},"[] {\n",[56,2385,2386,2389,2392,2395,2398,2401],{"class":58,"line":70},[56,2387,2388],{"class":249},"  if",[56,2390,2391],{"class":66}," (",[56,2393,2394],{"class":249},"!",[56,2396,2397],{"class":66},"node) ",[56,2399,2400],{"class":249},"return",[56,2402,2403],{"class":66}," list\n",[56,2405,2406,2408,2411,2414],{"class":58,"line":86},[56,2407,2388],{"class":249},[56,2409,2410],{"class":66}," (Array.",[56,2412,2413],{"class":224},"isArray",[56,2415,2416],{"class":66},"(node)) {\n",[56,2418,2419],{"class":58,"line":98},[56,2420,2421],{"class":353},"    \u002F\u002F minimark 格式：[\"h2\", {id: \"xxx\"}, \"文本\"]\n",[56,2423,2424,2427,2429,2432,2435,2438,2441,2444,2447,2450,2453,2456,2460,2463,2466,2469,2472,2475,2478,2480],{"class":58,"line":259},[56,2425,2426],{"class":249},"    if",[56,2428,2391],{"class":66},[56,2430,2431],{"class":249},"typeof",[56,2433,2434],{"class":66}," node[",[56,2436,2437],{"class":73},"0",[56,2439,2440],{"class":66},"] ",[56,2442,2443],{"class":249},"===",[56,2445,2446],{"class":362}," 'string'",[56,2448,2449],{"class":249}," &&",[56,2451,2452],{"class":362}," \u002F",[56,2454,2455],{"class":249},"^",[56,2457,2459],{"class":2458},"sPKmS","h",[56,2461,2462],{"class":73},"[1-6]",[56,2464,2465],{"class":249},"$",[56,2467,2468],{"class":362},"\u002F",[56,2470,2471],{"class":66},".",[56,2473,2474],{"class":224},"test",[56,2476,2477],{"class":66},"(node[",[56,2479,2437],{"class":73},[56,2481,2482],{"class":66},"])) {\n",[56,2484,2485,2488,2491,2493,2496,2498,2500,2503,2506,2508,2510],{"class":58,"line":434},[56,2486,2487],{"class":249},"      const",[56,2489,2490],{"class":73}," level",[56,2492,1993],{"class":249},[56,2494,2495],{"class":224}," Number",[56,2497,2477],{"class":66},[56,2499,2437],{"class":73},[56,2501,2502],{"class":66},"].",[56,2504,2505],{"class":224},"charAt",[56,2507,228],{"class":66},[56,2509,462],{"class":73},[56,2511,2512],{"class":66},"))\n",[56,2514,2515,2517,2520,2522,2525,2528,2531,2533,2536,2539,2542],{"class":58,"line":452},[56,2516,2487],{"class":249},[56,2518,2519],{"class":73}," text",[56,2521,1993],{"class":249},[56,2523,2524],{"class":224}," extractText",[56,2526,2527],{"class":66},"(node.",[56,2529,2530],{"class":224},"slice",[56,2532,228],{"class":66},[56,2534,2535],{"class":73},"2",[56,2537,2538],{"class":66},")).",[56,2540,2541],{"class":224},"trim",[56,2543,2544],{"class":66},"()\n",[56,2546,2547,2550,2553,2556,2558,2561,2564,2567],{"class":58,"line":468},[56,2548,2549],{"class":66},"      list.",[56,2551,2552],{"class":224},"push",[56,2554,2555],{"class":66},"({ id: node[",[56,2557,462],{"class":73},[56,2559,2560],{"class":66},"]?.id ",[56,2562,2563],{"class":249},"||",[56,2565,2566],{"class":224}," slugify",[56,2568,2569],{"class":66},"(text), text, level })\n",[56,2571,2572],{"class":58,"line":474},[56,2573,2310],{"class":66},[56,2575,2576,2579,2581,2584,2587,2590,2593,2596],{"class":58,"line":488},[56,2577,2578],{"class":249},"    for",[56,2580,2391],{"class":66},[56,2582,2583],{"class":249},"const",[56,2585,2586],{"class":73}," child",[56,2588,2589],{"class":249}," of",[56,2591,2592],{"class":66}," node) ",[56,2594,2595],{"class":224},"extractHeadings",[56,2597,2598],{"class":66},"(child, list)\n",[56,2600,2601],{"class":58,"line":511},[56,2602,2315],{"class":66},[56,2604,2605,2608],{"class":58,"line":520},[56,2606,2607],{"class":249},"  return",[56,2609,2403],{"class":66},[56,2611,2612],{"class":58,"line":525},[56,2613,101],{"class":66},[15,2615,2616,2617,2619,2620,2623],{},"关键点：Nuxt Content v3 的 body 使用 ",[31,2618,8],{}," 格式，标题节点是数组 ",[26,2621,2622],{},"[\"h2\", {id: \"xxx\"}, \"标题文本\"]","，不是对象。",[2215,2625,2627],{"id":2626},"问题二图片路径解析错误","问题二：图片路径解析错误",[11,2629,2230],{"id":2630},"现象-1",[15,2632,2633],{},"markdown 里用相对路径引用图片：",[47,2635,2639],{"className":2636,"code":2637,"language":2638,"meta":52,"style":52},"language-markdown shiki shiki-themes github-light github-dark","![SSHFS挂载效果](img\u002Fsshfs.avif)\n","markdown",[26,2640,2641],{"__ignoreMap":52},[56,2642,2643,2646,2650,2653,2657],{"class":58,"line":59},[56,2644,2645],{"class":66},"![",[56,2647,2649],{"class":2648},"smiIW","SSHFS挂载效果",[56,2651,2652],{"class":66},"](",[56,2654,2656],{"class":2655},"szbl0","img\u002Fsshfs.avif",[56,2658,2659],{"class":66},")\n",[15,2661,2662,2663,2666,2667,2670,2671,2241],{},"HTML 输出的 ",[26,2664,2665],{},"\u003Cimg src=\"img\u002Fsshfs.avif\">"," 看起来没问题，但浏览器请求的却是 ",[26,2668,2669],{},"\u002Fposts\u002Fimg\u002Fsshfs.avif"," 而不是 ",[26,2672,2673],{},"\u002Fposts\u002Fsshfs\u002Fimg\u002Fsshfs.avif",[11,2675,2244],{"id":2676},"排查-1",[121,2678,2679,2686,2689,2695],{},[22,2680,2681,2682,2685],{},"检查了 ",[26,2683,2684],{},"\u003Cbase>"," 标签 —— 没有",[22,2687,2688],{},"检查了重定向 —— 没有",[22,2690,2691,2692,2694],{},"直接访问 ",[26,2693,2673],{}," —— 返回 200 OK",[22,2696,2691,2697,2699],{},[26,2698,2669],{}," —— 404",[15,2701,2702,2703,2241],{},"服务器路由没问题，问题是",[31,2704,2705],{},"浏览器解析相对路径时基于了错误的基准 URL",[15,2707,2708],{},"这可能和 Nuxt 的客户端路由有关：SPA 导航时，浏览器的\"当前页面\"和 Vue Router 的\"当前路由\"不同步，导致相对路径解析出错。",[11,2710,633],{"id":2711},"最终方案-1",[15,2713,2714],{},"两步解决：",[15,2716,2717],{},[31,2718,2719],{},"第一步：自定义 ProseImg 组件",[15,2721,2722,2723,2726],{},"创建 ",[26,2724,2725],{},"components\u002Fcontent\u002FProseImg.vue","，覆盖 Nuxt Content 默认的图片渲染：",[47,2728,2730],{"className":202,"code":2729,"language":204,"meta":52,"style":52},"\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",[26,2731,2732,2744,2758,2778,2794,2832,2867,2891,2896,2904,2908,2917,2943],{"__ignoreMap":52},[56,2733,2734,2736,2739,2742],{"class":58,"line":59},[56,2735,211],{"class":66},[56,2737,2738],{"class":62},"script",[56,2740,2741],{"class":224}," setup",[56,2743,217],{"class":66},[56,2745,2746,2748,2751,2753,2756],{"class":58,"line":70},[56,2747,2583],{"class":249},[56,2749,2750],{"class":73}," route",[56,2752,1993],{"class":249},[56,2754,2755],{"class":224}," useRoute",[56,2757,2544],{"class":66},[56,2759,2760,2762,2765,2767,2770,2773,2776],{"class":58,"line":86},[56,2761,2583],{"class":249},[56,2763,2764],{"class":73}," resolvedSrc",[56,2766,1993],{"class":249},[56,2768,2769],{"class":224}," computed",[56,2771,2772],{"class":66},"(() ",[56,2774,2775],{"class":249},"=>",[56,2777,67],{"class":66},[56,2779,2780,2782,2784,2786,2789,2791],{"class":58,"line":98},[56,2781,2388],{"class":249},[56,2783,2391],{"class":66},[56,2785,2394],{"class":249},[56,2787,2788],{"class":66},"props.src) ",[56,2790,2400],{"class":249},[56,2792,2793],{"class":362}," ''\n",[56,2795,2796,2798,2801,2804,2806,2809,2812,2814,2817,2819,2821,2824,2827,2829],{"class":58,"line":259},[56,2797,2388],{"class":249},[56,2799,2800],{"class":66}," (props.src.",[56,2802,2803],{"class":224},"startsWith",[56,2805,228],{"class":66},[56,2807,2808],{"class":362},"'\u002F'",[56,2810,2811],{"class":66},") ",[56,2813,2563],{"class":249},[56,2815,2816],{"class":66}," props.src.",[56,2818,2803],{"class":224},[56,2820,228],{"class":66},[56,2822,2823],{"class":362},"'http'",[56,2825,2826],{"class":66},")) ",[56,2828,2400],{"class":249},[56,2830,2831],{"class":66}," props.src\n",[56,2833,2834,2837,2840,2842,2845,2848,2850,2852,2856,2858,2860,2862,2865],{"class":58,"line":434},[56,2835,2836],{"class":249},"  const",[56,2838,2839],{"class":73}," base",[56,2841,1993],{"class":249},[56,2843,2844],{"class":66}," route.path.",[56,2846,2847],{"class":224},"replace",[56,2849,228],{"class":66},[56,2851,2468],{"class":362},[56,2853,2855],{"class":2854},"sCnZR","\\\u002F",[56,2857,2465],{"class":249},[56,2859,2468],{"class":362},[56,2861,1101],{"class":66},[56,2863,2864],{"class":362},"''",[56,2866,2659],{"class":66},[56,2868,2869,2871,2874,2877,2880,2883,2885,2888],{"class":58,"line":452},[56,2870,2607],{"class":249},[56,2872,2873],{"class":362}," `${",[56,2875,2876],{"class":66},"base",[56,2878,2879],{"class":362},"}\u002F${",[56,2881,2882],{"class":66},"props",[56,2884,2471],{"class":362},[56,2886,2887],{"class":66},"src",[56,2889,2890],{"class":362},"}`\n",[56,2892,2893],{"class":58,"line":468},[56,2894,2895],{"class":66},"})\n",[56,2897,2898,2900,2902],{"class":58,"line":474},[56,2899,262],{"class":66},[56,2901,2738],{"class":62},[56,2903,217],{"class":66},[56,2905,2906],{"class":58,"line":488},[56,2907,398],{"emptyLinePlaceholder":313},[56,2909,2910,2912,2915],{"class":58,"line":511},[56,2911,211],{"class":66},[56,2913,2914],{"class":62},"template",[56,2916,217],{"class":66},[56,2918,2919,2922,2924,2927,2929,2932,2935,2937,2940],{"class":58,"line":520},[56,2920,2921],{"class":66},"  \u003C",[56,2923,613],{"class":62},[56,2925,2926],{"class":224}," :src",[56,2928,443],{"class":66},[56,2930,2931],{"class":362},"\"resolvedSrc\"",[56,2933,2934],{"class":224}," :alt",[56,2936,443],{"class":66},[56,2938,2939],{"class":362},"\"alt\"",[56,2941,2942],{"class":66}," \u002F>\n",[56,2944,2945,2947,2949],{"class":58,"line":525},[56,2946,262],{"class":66},[56,2948,2914],{"class":62},[56,2950,217],{"class":66},[15,2952,2953],{},[31,2954,2955],{},"第二步：启用 MDC prose 组件",[47,2957,2959],{"className":1605,"code":2958,"language":1607,"meta":52,"style":52},"\u002F\u002F nuxt.config.ts\nmdc: {\n  components: {\n    prose: true  \u002F\u002F 让 Nuxt Content 使用自定义 ProseImg\n  }\n}\n",[26,2960,2961,2965,2972,2979,2992,2996],{"__ignoreMap":52},[56,2962,2963],{"class":58,"line":59},[56,2964,2261],{"class":353},[56,2966,2967,2970],{"class":58,"line":70},[56,2968,2969],{"class":224},"mdc",[56,2971,2269],{"class":66},[56,2973,2974,2977],{"class":58,"line":86},[56,2975,2976],{"class":224},"  components",[56,2978,2269],{"class":66},[56,2980,2981,2984,2986,2989],{"class":58,"line":98},[56,2982,2983],{"class":224},"    prose",[56,2985,77],{"class":66},[56,2987,2988],{"class":73},"true",[56,2990,2991],{"class":353},"  \u002F\u002F 让 Nuxt Content 使用自定义 ProseImg\n",[56,2993,2994],{"class":58,"line":259},[56,2995,2315],{"class":66},[56,2997,2998],{"class":58,"line":434},[56,2999,101],{"class":66},[2215,3001,3003],{"id":3002},"问题三content-目录的图片如何服务","问题三：content 目录的图片如何服务",[11,3005,2230],{"id":3006},"现象-2",[15,3008,3009,3010,3013],{},"原站（SvelteKit）的图片放在 ",[26,3011,3012],{},"content\u002Fposts\u002F{slug}\u002Fimg\u002F"," 下，markdown 用相对路径引用，一切正常。Nuxt 项目里，content 目录的文件不会被当成静态资源服务。",[11,3015,3016],{"id":3016},"方案对比",[1683,3018,3019,3032],{},[1686,3020,3021],{},[1689,3022,3023,3026,3029],{},[1692,3024,3025],{},"方案",[1692,3027,3028],{},"优点",[1692,3030,3031],{},"缺点",[1704,3033,3034,3045,3056],{},[1689,3035,3036,3039,3042],{},[1709,3037,3038],{},"复制到 public\u002F",[1709,3040,3041],{},"简单",[1709,3043,3044],{},"文件重复，需同步",[1689,3046,3047,3050,3053],{},[1709,3048,3049],{},"Vite 插件拦截",[1709,3051,3052],{},"原站方案",[1709,3054,3055],{},"Nuxt 不直接支持",[1689,3057,3058,3061,3064],{},[1709,3059,3060],{},"Server Route",[1709,3062,3063],{},"不复制文件",[1709,3065,3066],{},"需要手动写路由",[15,3068,3069,3070,3072],{},"最终选择 ",[31,3071,3060],{},"，和原站的 Vite 插件思路一致：",[47,3074,3076],{"className":1605,"code":3075,"language":1607,"meta":52,"style":52},"\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",[26,3077,3078,3083,3109,3124,3188,3202,3206,3228,3254,3272,3291,3298],{"__ignoreMap":52},[56,3079,3080],{"class":58,"line":59},[56,3081,3082],{"class":353},"\u002F\u002F server\u002Froutes\u002Fposts\u002F[slug]\u002Fimg\u002F[...file].ts\n",[56,3084,3085,3087,3090,3093,3095,3098,3100,3103,3105,3107],{"class":58,"line":70},[56,3086,437],{"class":249},[56,3088,3089],{"class":249}," default",[56,3091,3092],{"class":224}," defineEventHandler",[56,3094,228],{"class":66},[56,3096,3097],{"class":249},"async",[56,3099,2391],{"class":66},[56,3101,3102],{"class":2352},"event",[56,3104,2811],{"class":66},[56,3106,2775],{"class":249},[56,3108,67],{"class":66},[56,3110,3111,3113,3116,3118,3121],{"class":58,"line":86},[56,3112,2836],{"class":249},[56,3114,3115],{"class":73}," url",[56,3117,1993],{"class":249},[56,3119,3120],{"class":224}," getRequestURL",[56,3122,3123],{"class":66},"(event)\n",[56,3125,3126,3128,3131,3133,3136,3139,3141,3143,3145,3147,3150,3152,3154,3157,3159,3162,3165,3168,3170,3172,3174,3176,3178,3180,3182,3184,3186],{"class":58,"line":98},[56,3127,2836],{"class":249},[56,3129,3130],{"class":73}," match",[56,3132,1993],{"class":249},[56,3134,3135],{"class":66}," url.pathname.",[56,3137,3138],{"class":224},"match",[56,3140,228],{"class":66},[56,3142,2468],{"class":362},[56,3144,2455],{"class":249},[56,3146,2855],{"class":2854},[56,3148,3149],{"class":2458},"posts",[56,3151,2855],{"class":2854},[56,3153,228],{"class":2458},[56,3155,3156],{"class":73},"[",[56,3158,2455],{"class":249},[56,3160,3161],{"class":73},"\u002F]",[56,3163,3164],{"class":249},"+",[56,3166,3167],{"class":2458},")",[56,3169,2855],{"class":2854},[56,3171,613],{"class":2458},[56,3173,2855],{"class":2854},[56,3175,228],{"class":2458},[56,3177,2471],{"class":73},[56,3179,3164],{"class":249},[56,3181,3167],{"class":2458},[56,3183,2465],{"class":249},[56,3185,2468],{"class":362},[56,3187,2659],{"class":66},[56,3189,3190,3192,3194,3196,3199],{"class":58,"line":259},[56,3191,2388],{"class":249},[56,3193,2391],{"class":66},[56,3195,2394],{"class":249},[56,3197,3198],{"class":66},"match) ",[56,3200,3201],{"class":249},"return\n",[56,3203,3204],{"class":58,"line":434},[56,3205,398],{"emptyLinePlaceholder":313},[56,3207,3208,3210,3213,3216,3218,3221,3223,3225],{"class":58,"line":452},[56,3209,2836],{"class":249},[56,3211,3212],{"class":66}," [, ",[56,3214,3215],{"class":73},"slug",[56,3217,1101],{"class":66},[56,3219,3220],{"class":73},"filename",[56,3222,2440],{"class":66},[56,3224,443],{"class":249},[56,3226,3227],{"class":66}," match\n",[56,3229,3230,3232,3235,3237,3240,3242,3245,3248,3251],{"class":58,"line":468},[56,3231,2836],{"class":249},[56,3233,3234],{"class":73}," filePath",[56,3236,1993],{"class":249},[56,3238,3239],{"class":224}," resolve",[56,3241,228],{"class":66},[56,3243,3244],{"class":362},"'content\u002Fposts'",[56,3246,3247],{"class":66},", slug, ",[56,3249,3250],{"class":362},"'img'",[56,3252,3253],{"class":66},", filename)\n",[56,3255,3256,3258,3261,3263,3266,3269],{"class":58,"line":474},[56,3257,2836],{"class":249},[56,3259,3260],{"class":73}," data",[56,3262,1993],{"class":249},[56,3264,3265],{"class":249}," await",[56,3267,3268],{"class":224}," readFile",[56,3270,3271],{"class":66},"(filePath)\n",[56,3273,3274,3277,3280,3283,3285,3288],{"class":58,"line":488},[56,3275,3276],{"class":224},"  setResponseHeader",[56,3278,3279],{"class":66},"(event, ",[56,3281,3282],{"class":362},"'content-type'",[56,3284,1101],{"class":66},[56,3286,3287],{"class":73},"MIME",[56,3289,3290],{"class":66},"[ext])\n",[56,3292,3293,3295],{"class":58,"line":511},[56,3294,2607],{"class":249},[56,3296,3297],{"class":66}," data\n",[56,3299,3300],{"class":58,"line":520},[56,3301,2895],{"class":66},[15,3303,3304,3305,3307,3308,3311],{},"请求 ",[26,3306,2673],{}," → 读取 ",[26,3309,3310],{},"content\u002Fposts\u002Fsshfs\u002Fimg\u002Fsshfs.avif"," → 返回图片。",[2215,3313,287],{"id":287},[1683,3315,3316,3328],{},[1686,3317,3318],{},[1689,3319,3320,3323,3326],{},[1692,3321,3322],{},"问题",[1692,3324,3325],{},"根因",[1692,3327,3025],{},[1704,3329,3330,3344,3355],{},[1689,3331,3332,3335,3341],{},[1709,3333,3334],{},"TOC 只有 h2",[1709,3336,3337,3340],{},[26,3338,3339],{},"toc.depth"," 配置不生效",[1709,3342,3343],{},"从 body AST 手动提取",[1689,3345,3346,3349,3352],{},[1709,3347,3348],{},"图片路径错误",[1709,3350,3351],{},"SPA 路由导致相对路径解析基准错误",[1709,3353,3354],{},"ProseImg 组件 + mdc.prose 配置",[1689,3356,3357,3360,3363],{},[1709,3358,3359],{},"content 图片 404",[1709,3361,3362],{},"content 目录不暴露给浏览器",[1709,3364,3365],{},"Server Route 直接读取返回",[15,3367,3368],{},"Nuxt Content v3 的设计理念是\"图片放 public\u002F，用绝对路径\"。如果想像 SvelteKit 项目那样把图片和文章放一起，需要额外做不少工作。",[15,3370,3371],{},"不过一旦这些坑都踩过，整体开发体验还是很流畅的。Nuxt 的生态系统、自动导入、模块系统都比 SvelteKit 成熟很多。",[214,3373,3374],{},"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":52,"searchDepth":70,"depth":86,"links":3376},[3377,3378,3379,3380,3381,3382,3383,3384],{"id":2230,"depth":70,"text":2230},{"id":2244,"depth":70,"text":2244},{"id":633,"depth":70,"text":633},{"id":2630,"depth":70,"text":2230},{"id":2676,"depth":70,"text":2244},{"id":2711,"depth":70,"text":633},{"id":3006,"depth":70,"text":2230},{"id":3016,"depth":70,"text":3016},"从 SvelteKit 迁移到 Nuxt 3 的过程中，遇到了不少 Nuxt Content v3 的坑。这篇文章记录了目录导航、图片路径、ProseImg 组件等问题的排查和解决过程。","\u002Fposts\u002Fnuxt-content-lessons\u002Fimg\u002Fcover.jpg",{},"\u002Fposts\u002Fnuxt-content-lessons","2026-05-31T14:00:00",{"title":2211,"description":3385},"posts\u002Fnuxt-content-lessons\u002Findex",[320,323,3393],"Nuxt Content","vbSt6ND1z7xt7L8FxgZ8Qe3EdxV9InsRerN3E23GC7s",{"id":4,"title":5,"author":6,"body":3396,"description":307,"difficulty":308,"draft":309,"extension":310,"image":311,"meta":3595,"navigation":313,"path":314,"pinned":309,"published":315,"seo":3596,"series":317,"seriesOrder":86,"stem":318,"tags":3597,"updated":324,"__hash__":325},{"type":8,"value":3397,"toc":3588},[3398,3400,3402,3412,3414,3416,3418,3452,3458,3462,3478,3482,3484,3486,3488,3490,3492,3508,3514,3564,3568,3578,3580,3584,3586],[11,3399,13],{"id":13},[15,3401,17],{},[19,3403,3404,3410],{},[22,3405,24,3406,29,3408],{},[26,3407,28],{},[31,3409,33],{},[22,3411,36],{},[15,3413,39],{},[11,3415,42],{"id":42},[15,3417,45],{},[47,3419,3420],{"className":49,"code":50,"language":51,"meta":52,"style":52},[26,3421,3422,3428,3438,3448],{"__ignoreMap":52},[56,3423,3424,3426],{"class":58,"line":59},[56,3425,63],{"class":62},[56,3427,67],{"class":66},[56,3429,3430,3432,3434,3436],{"class":58,"line":70},[56,3431,74],{"class":73},[56,3433,77],{"class":66},[56,3435,80],{"class":73},[56,3437,83],{"class":66},[56,3439,3440,3442,3444,3446],{"class":58,"line":86},[56,3441,89],{"class":73},[56,3443,77],{"class":66},[56,3445,80],{"class":73},[56,3447,83],{"class":66},[56,3449,3450],{"class":58,"line":98},[56,3451,101],{"class":66},[15,3453,104,3454,108,3456,112],{},[26,3455,107],{},[26,3457,111],{},[15,3459,115,3460,119],{},[31,3461,118],{},[121,3463,3464,3468,3470,3474,3476],{},[22,3465,125,3466,128],{},[26,3467,28],{},[22,3469,131],{},[22,3471,3472],{},[31,3473,136],{},[22,3475,139],{},[22,3477,142],{},[15,3479,145,3480,149],{},[31,3481,148],{},[11,3483,153],{"id":152},[15,3485,156],{},[15,3487,159],{},[11,3489,162],{"id":162},[15,3491,165],{},[19,3493,3494,3498,3502,3506],{},[22,3495,3496,173],{},[26,3497,172],{},[22,3499,3500,179],{},[26,3501,178],{},[22,3503,3504,185],{},[26,3505,184],{},[22,3507,188],{},[15,3509,191,3510,195,3512,199],{},[26,3511,194],{},[26,3513,198],{},[47,3515,3516],{"className":202,"code":203,"language":204,"meta":52,"style":52},[26,3517,3518,3526,3540,3552,3556],{"__ignoreMap":52},[56,3519,3520,3522,3524],{"class":58,"line":59},[56,3521,211],{"class":66},[56,3523,214],{"class":62},[56,3525,217],{"class":66},[56,3527,3528,3530,3532,3534,3536,3538],{"class":58,"line":70},[56,3529,63],{"class":62},[56,3531,225],{"class":224},[56,3533,228],{"class":66},[56,3535,231],{"class":224},[56,3537,234],{"class":62},[56,3539,237],{"class":66},[56,3541,3542,3544,3546,3548,3550],{"class":58,"line":86},[56,3543,242],{"class":73},[56,3545,77],{"class":66},[56,3547,111],{"class":73},[56,3549,250],{"class":249},[56,3551,83],{"class":66},[56,3553,3554],{"class":58,"line":98},[56,3555,101],{"class":66},[56,3557,3558,3560,3562],{"class":58,"line":259},[56,3559,262],{"class":66},[56,3561,214],{"class":62},[56,3563,217],{"class":66},[15,3565,3566],{},[31,3567,271],{},[19,3569,3570,3574],{},[22,3571,3572,278],{},[26,3573,198],{},[22,3575,3576,284],{},[26,3577,283],{},[11,3579,287],{"id":287},[15,3581,290,3582,293],{},[26,3583,198],{},[15,3585,296],{},[214,3587,299],{},{"title":52,"searchDepth":70,"depth":86,"links":3589},[3590,3591,3592,3593,3594],{"id":13,"depth":70,"text":13},{"id":42,"depth":70,"text":42},{"id":152,"depth":70,"text":153},{"id":162,"depth":70,"text":162},{"id":287,"depth":70,"text":287},{},{"title":5,"description":307},[320,321,322,323],{"id":3599,"title":3600,"author":330,"body":3601,"description":5652,"difficulty":308,"draft":309,"extension":310,"image":5653,"meta":5654,"navigation":313,"path":5655,"pinned":309,"published":5656,"seo":5657,"series":5658,"seriesOrder":59,"stem":5659,"tags":5660,"updated":5665,"__hash__":5666},"posts\u002Fposts\u002Fgo-mcp-gin\u002Findex.md","Go-MCP 接入 Gin：从零搭建 MCP 服务器",{"type":8,"value":3602,"toc":5633},[3603,3607,3610,3613,3617,3624,3630,3633,3636,3656,3659,3663,3687,3691,3791,3804,3808,3972,3975,4192,4196,4207,4306,4313,4317,4591,4594,4675,4679,4682,4684,4695,4699,4705,5072,5262,5265,5321,5324,5477,5480,5483,5554,5556,5621,5630],[11,3604,3606],{"id":3605},"什么是-mcp","什么是 MCP",[15,3608,3609],{},"MCP（Model Context Protocol）是 Anthropic 提出的开放协议，用于让 AI 助手（如 Claude）与外部服务进行标准化交互。简单来说：你提供一个 MCP 服务器，AI 就能发现并调用你定义的工具（Tools），就像给 AI 装了一双手。",[15,3611,3612],{},"传统做法是写 REST API 然后让 AI 通过函数调用（Function Calling）间接使用，但 MCP 统一了协议层，AI 客户端只需要配置一个 URL 就能接入所有工具。",[11,3614,3616],{"id":3615},"为什么选-gin-mcp-go","为什么选 Gin + mcp-go",[15,3618,3619,3620,3623],{},"Go 生态里做 MCP 服务器最成熟的库是 ",[26,3621,3622],{},"mark3labs\u002Fmcp-go","，它支持 StreamableHTTP 传输模式，可以直接挂载到任意 HTTP 框架上。Gin 作为 Go 最流行的 HTTP 框架，天然适配。",[15,3625,3626,3627],{},"核心思路：",[31,3628,3629],{},"MCP 服务器本质上就是一个 HTTP Handler，挂到 Gin 路由上即可。",[11,3631,3632],{"id":3632},"需求分析",[15,3634,3635],{},"我们要搭建一个具备以下能力的 MCP 服务器：",[121,3637,3638,3644,3650],{},[22,3639,3640,3643],{},[31,3641,3642],{},"工具注册"," — 向 AI 暴露可调用的工具（列出资源、查看详情、创建\u002F删除）",[22,3645,3646,3649],{},[31,3647,3648],{},"HTTP 集成"," — MCP 端点作为 Gin 路由的一部分，与业务 API 共存",[22,3651,3652,3655],{},[31,3653,3654],{},"鉴权"," — MCP 请求必须经过身份验证，工具处理器能获取当前用户信息",[11,3657,3658],{"id":3658},"实现步骤",[586,3660,3662],{"id":3661},"_1-安装依赖","1. 安装依赖",[47,3664,3666],{"className":344,"code":3665,"language":346,"meta":52,"style":52},"go get github.com\u002Fmark3labs\u002Fmcp-go@v0.54.0\ngo get github.com\u002Fgin-gonic\u002Fgin@v1.12.0\n",[26,3667,3668,3678],{"__ignoreMap":52},[56,3669,3670,3672,3675],{"class":58,"line":59},[56,3671,477],{"class":224},[56,3673,3674],{"class":362}," get",[56,3676,3677],{"class":362}," github.com\u002Fmark3labs\u002Fmcp-go@v0.54.0\n",[56,3679,3680,3682,3684],{"class":58,"line":70},[56,3681,477],{"class":224},[56,3683,3674],{"class":362},[56,3685,3686],{"class":362}," github.com\u002Fgin-gonic\u002Fgin@v1.12.0\n",[586,3688,3690],{"id":3689},"_2-创建-mcp-服务器","2. 创建 MCP 服务器",[47,3692,3695],{"className":3693,"code":3694,"language":477,"meta":52,"style":52},"language-go shiki shiki-themes github-light github-dark","import \"github.com\u002Fmark3labs\u002Fmcp-go\u002Fserver\"\n\nmcpServer := server.NewMCPServer(\n    \"my-app\",    \u002F\u002F 服务器名称\n    \"1.0.0\",     \u002F\u002F 版本\n    server.WithLogging(),\n    server.WithRecovery(),\n    server.WithInstructions(\"这是一个 MCP 服务器示例\"),\n)\n",[26,3696,3697,3709,3713,3730,3741,3752,3763,3772,3787],{"__ignoreMap":52},[56,3698,3699,3702,3704,3707],{"class":58,"line":59},[56,3700,3701],{"class":249},"import",[56,3703,375],{"class":362},[56,3705,3706],{"class":224},"github.com\u002Fmark3labs\u002Fmcp-go\u002Fserver",[56,3708,584],{"class":362},[56,3710,3711],{"class":58,"line":70},[56,3712,398],{"emptyLinePlaceholder":313},[56,3714,3715,3718,3721,3724,3727],{"class":58,"line":86},[56,3716,3717],{"class":66},"mcpServer ",[56,3719,3720],{"class":249},":=",[56,3722,3723],{"class":66}," server.",[56,3725,3726],{"class":224},"NewMCPServer",[56,3728,3729],{"class":66},"(\n",[56,3731,3732,3735,3738],{"class":58,"line":98},[56,3733,3734],{"class":362},"    \"my-app\"",[56,3736,3737],{"class":66},",    ",[56,3739,3740],{"class":353},"\u002F\u002F 服务器名称\n",[56,3742,3743,3746,3749],{"class":58,"line":259},[56,3744,3745],{"class":362},"    \"1.0.0\"",[56,3747,3748],{"class":66},",     ",[56,3750,3751],{"class":353},"\u002F\u002F 版本\n",[56,3753,3754,3757,3760],{"class":58,"line":434},[56,3755,3756],{"class":66},"    server.",[56,3758,3759],{"class":224},"WithLogging",[56,3761,3762],{"class":66},"(),\n",[56,3764,3765,3767,3770],{"class":58,"line":452},[56,3766,3756],{"class":66},[56,3768,3769],{"class":224},"WithRecovery",[56,3771,3762],{"class":66},[56,3773,3774,3776,3779,3781,3784],{"class":58,"line":468},[56,3775,3756],{"class":66},[56,3777,3778],{"class":224},"WithInstructions",[56,3780,228],{"class":66},[56,3782,3783],{"class":362},"\"这是一个 MCP 服务器示例\"",[56,3785,3786],{"class":66},"),\n",[56,3788,3789],{"class":58,"line":474},[56,3790,2659],{"class":66},[15,3792,3793,3795,3796,3799,3800,3803],{},[26,3794,3726],{}," 创建一个 MCP 协议实例，它负责处理工具发现（",[26,3797,3798],{},"tools\u002Flist","）和工具调用（",[26,3801,3802],{},"tools\u002Fcall","）的协议逻辑。",[586,3805,3807],{"id":3806},"_3-注册工具","3. 注册工具",[47,3809,3811],{"className":3693,"code":3810,"language":477,"meta":52,"style":52},"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",[26,3812,3813,3824,3828,3833,3853,3868,3872,3876,3881,3942,3947,3968],{"__ignoreMap":52},[56,3814,3815,3817,3819,3822],{"class":58,"line":59},[56,3816,3701],{"class":249},[56,3818,375],{"class":362},[56,3820,3821],{"class":224},"github.com\u002Fmark3labs\u002Fmcp-go\u002Fmcp",[56,3823,584],{"class":362},[56,3825,3826],{"class":58,"line":70},[56,3827,398],{"emptyLinePlaceholder":313},[56,3829,3830],{"class":58,"line":86},[56,3831,3832],{"class":353},"\u002F\u002F 定义工具\n",[56,3834,3835,3838,3840,3843,3846,3848,3851],{"class":58,"line":98},[56,3836,3837],{"class":66},"listTool ",[56,3839,3720],{"class":249},[56,3841,3842],{"class":66}," mcp.",[56,3844,3845],{"class":224},"NewTool",[56,3847,228],{"class":66},[56,3849,3850],{"class":362},"\"list_items\"",[56,3852,2006],{"class":66},[56,3854,3855,3858,3861,3863,3866],{"class":58,"line":259},[56,3856,3857],{"class":66},"    mcp.",[56,3859,3860],{"class":224},"WithDescription",[56,3862,228],{"class":66},[56,3864,3865],{"class":362},"\"列出所有项目\"",[56,3867,3786],{"class":66},[56,3869,3870],{"class":58,"line":434},[56,3871,2659],{"class":66},[56,3873,3874],{"class":58,"line":452},[56,3875,398],{"emptyLinePlaceholder":313},[56,3877,3878],{"class":58,"line":468},[56,3879,3880],{"class":353},"\u002F\u002F 注册处理器\n",[56,3882,3883,3886,3889,3892,3895,3897,3900,3903,3905,3908,3910,3913,3916,3918,3921,3924,3927,3930,3932,3935,3937,3940],{"class":58,"line":474},[56,3884,3885],{"class":66},"mcpServer.",[56,3887,3888],{"class":224},"AddTool",[56,3890,3891],{"class":66},"(listTool, ",[56,3893,3894],{"class":249},"func",[56,3896,228],{"class":66},[56,3898,3899],{"class":2352},"ctx",[56,3901,3902],{"class":224}," context",[56,3904,2471],{"class":66},[56,3906,3907],{"class":224},"Context",[56,3909,1101],{"class":66},[56,3911,3912],{"class":2352},"req",[56,3914,3915],{"class":224}," mcp",[56,3917,2471],{"class":66},[56,3919,3920],{"class":224},"CallToolRequest",[56,3922,3923],{"class":66},") (",[56,3925,3926],{"class":249},"*",[56,3928,3929],{"class":224},"mcp",[56,3931,2471],{"class":66},[56,3933,3934],{"class":224},"CallToolResult",[56,3936,1101],{"class":66},[56,3938,3939],{"class":249},"error",[56,3941,237],{"class":66},[56,3943,3944],{"class":58,"line":488},[56,3945,3946],{"class":353},"    \u002F\u002F 业务逻辑\n",[56,3948,3949,3952,3954,3957,3959,3962,3965],{"class":58,"line":511},[56,3950,3951],{"class":249},"    return",[56,3953,3842],{"class":66},[56,3955,3956],{"class":224},"NewToolResultText",[56,3958,228],{"class":66},[56,3960,3961],{"class":362},"\"返回结果\"",[56,3963,3964],{"class":66},"), ",[56,3966,3967],{"class":73},"nil\n",[56,3969,3970],{"class":58,"line":520},[56,3971,2895],{"class":66},[15,3973,3974],{},"带参数的工具：",[47,3976,3978],{"className":3693,"code":3977,"language":477,"meta":52,"style":52},"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",[26,3979,3980,3998,4011,4025,4035,4049,4054,4058,4062,4109,4128,4143,4162,4166,4171,4188],{"__ignoreMap":52},[56,3981,3982,3985,3987,3989,3991,3993,3996],{"class":58,"line":59},[56,3983,3984],{"class":66},"getTool ",[56,3986,3720],{"class":249},[56,3988,3842],{"class":66},[56,3990,3845],{"class":224},[56,3992,228],{"class":66},[56,3994,3995],{"class":362},"\"get_item\"",[56,3997,2006],{"class":66},[56,3999,4000,4002,4004,4006,4009],{"class":58,"line":70},[56,4001,3857],{"class":66},[56,4003,3860],{"class":224},[56,4005,228],{"class":66},[56,4007,4008],{"class":362},"\"获取指定项目的详情\"",[56,4010,3786],{"class":66},[56,4012,4013,4015,4018,4020,4023],{"class":58,"line":86},[56,4014,3857],{"class":66},[56,4016,4017],{"class":224},"WithString",[56,4019,228],{"class":66},[56,4021,4022],{"class":362},"\"item_id\"",[56,4024,2006],{"class":66},[56,4026,4027,4030,4033],{"class":58,"line":98},[56,4028,4029],{"class":66},"        mcp.",[56,4031,4032],{"class":224},"Required",[56,4034,3762],{"class":66},[56,4036,4037,4039,4042,4044,4047],{"class":58,"line":259},[56,4038,4029],{"class":66},[56,4040,4041],{"class":224},"Description",[56,4043,228],{"class":66},[56,4045,4046],{"class":362},"\"项目 ID\"",[56,4048,3786],{"class":66},[56,4050,4051],{"class":58,"line":434},[56,4052,4053],{"class":66},"    ),\n",[56,4055,4056],{"class":58,"line":452},[56,4057,2659],{"class":66},[56,4059,4060],{"class":58,"line":468},[56,4061,398],{"emptyLinePlaceholder":313},[56,4063,4064,4066,4068,4071,4073,4075,4077,4079,4081,4083,4085,4087,4089,4091,4093,4095,4097,4099,4101,4103,4105,4107],{"class":58,"line":474},[56,4065,3885],{"class":66},[56,4067,3888],{"class":224},[56,4069,4070],{"class":66},"(getTool, ",[56,4072,3894],{"class":249},[56,4074,228],{"class":66},[56,4076,3899],{"class":2352},[56,4078,3902],{"class":224},[56,4080,2471],{"class":66},[56,4082,3907],{"class":224},[56,4084,1101],{"class":66},[56,4086,3912],{"class":2352},[56,4088,3915],{"class":224},[56,4090,2471],{"class":66},[56,4092,3920],{"class":224},[56,4094,3923],{"class":66},[56,4096,3926],{"class":249},[56,4098,3929],{"class":224},[56,4100,2471],{"class":66},[56,4102,3934],{"class":224},[56,4104,1101],{"class":66},[56,4106,3939],{"class":249},[56,4108,237],{"class":66},[56,4110,4111,4114,4116,4119,4122,4124,4126],{"class":58,"line":488},[56,4112,4113],{"class":66},"    itemID, err ",[56,4115,3720],{"class":249},[56,4117,4118],{"class":66}," req.",[56,4120,4121],{"class":224},"RequireString",[56,4123,228],{"class":66},[56,4125,4022],{"class":362},[56,4127,2659],{"class":66},[56,4129,4130,4132,4135,4138,4141],{"class":58,"line":511},[56,4131,2426],{"class":249},[56,4133,4134],{"class":66}," err ",[56,4136,4137],{"class":249},"!=",[56,4139,4140],{"class":73}," nil",[56,4142,67],{"class":66},[56,4144,4145,4148,4150,4153,4155,4158,4160],{"class":58,"line":520},[56,4146,4147],{"class":249},"        return",[56,4149,3842],{"class":66},[56,4151,4152],{"class":224},"NewToolResultError",[56,4154,228],{"class":66},[56,4156,4157],{"class":362},"\"缺少 item_id 参数\"",[56,4159,3964],{"class":66},[56,4161,3967],{"class":73},[56,4163,4164],{"class":58,"line":525},[56,4165,2310],{"class":66},[56,4167,4168],{"class":58,"line":531},[56,4169,4170],{"class":353},"    \u002F\u002F 查询并返回\n",[56,4172,4173,4175,4177,4179,4181,4184,4186],{"class":58,"line":542},[56,4174,3951],{"class":249},[56,4176,3842],{"class":66},[56,4178,3956],{"class":224},[56,4180,228],{"class":66},[56,4182,4183],{"class":362},"\"项目详情...\"",[56,4185,3964],{"class":66},[56,4187,3967],{"class":73},[56,4189,4190],{"class":58,"line":553},[56,4191,2895],{"class":66},[586,4193,4195],{"id":4194},"_4-挂载到-gin-路由","4. 挂载到 Gin 路由",[15,4197,4198,4199,4202,4203,4206],{},"关键一步：用 ",[26,4200,4201],{},"server.NewStreamableHTTPServer"," 把 MCP 实例转成 HTTP Handler，再用 ",[26,4204,4205],{},"gin.WrapH"," 适配 Gin。",[47,4208,4210],{"className":3693,"code":4209,"language":477,"meta":52,"style":52},"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",[26,4211,4212,4222,4226,4241,4255,4272,4276,4280,4285],{"__ignoreMap":52},[56,4213,4214,4216,4218,4220],{"class":58,"line":59},[56,4215,3701],{"class":249},[56,4217,375],{"class":362},[56,4219,3706],{"class":224},[56,4221,584],{"class":362},[56,4223,4224],{"class":58,"line":70},[56,4225,398],{"emptyLinePlaceholder":313},[56,4227,4228,4231,4233,4235,4238],{"class":58,"line":86},[56,4229,4230],{"class":66},"mcpHTTPServer ",[56,4232,3720],{"class":249},[56,4234,3723],{"class":66},[56,4236,4237],{"class":224},"NewStreamableHTTPServer",[56,4239,4240],{"class":66},"(mcpServer,\n",[56,4242,4243,4245,4248,4250,4253],{"class":58,"line":98},[56,4244,3756],{"class":66},[56,4246,4247],{"class":224},"WithEndpointPath",[56,4249,228],{"class":66},[56,4251,4252],{"class":362},"\"\u002Fmcp\"",[56,4254,3786],{"class":66},[56,4256,4257,4259,4262,4264,4267,4269],{"class":58,"line":259},[56,4258,3756],{"class":66},[56,4260,4261],{"class":224},"WithHeartbeatInterval",[56,4263,228],{"class":66},[56,4265,4266],{"class":73},"30",[56,4268,3926],{"class":249},[56,4270,4271],{"class":66},"time.Second),\n",[56,4273,4274],{"class":58,"line":434},[56,4275,2659],{"class":66},[56,4277,4278],{"class":58,"line":452},[56,4279,398],{"emptyLinePlaceholder":313},[56,4281,4282],{"class":58,"line":468},[56,4283,4284],{"class":353},"\u002F\u002F 挂载到 Gin\n",[56,4286,4287,4290,4293,4295,4297,4300,4303],{"class":58,"line":474},[56,4288,4289],{"class":66},"r.",[56,4291,4292],{"class":224},"Any",[56,4294,228],{"class":66},[56,4296,4252],{"class":362},[56,4298,4299],{"class":66},", gin.",[56,4301,4302],{"class":224},"WrapH",[56,4304,4305],{"class":66},"(mcpHTTPServer))\n",[15,4307,4308,4309,4312],{},"就这样，",[26,4310,4311],{},"POST \u002Fmcp"," 端点就生效了。AI 客户端通过这个 URL 发送 JSON-RPC 请求，MCP 服务器自动处理协议握手、工具列表返回、工具调用路由。",[586,4314,4316],{"id":4315},"_5-完整的服务启动代码","5. 完整的服务启动代码",[47,4318,4320],{"className":3693,"code":4319,"language":477,"meta":52,"style":52},"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",[26,4321,4322,4331,4346,4350,4355,4378,4387,4395,4400,4404,4409,4417,4421,4426,4439,4451,4465,4469,4487,4491,4496,4529,4564,4569,4573,4587],{"__ignoreMap":52},[56,4323,4324,4326,4329],{"class":58,"line":59},[56,4325,3894],{"class":249},[56,4327,4328],{"class":224}," main",[56,4330,1638],{"class":66},[56,4332,4333,4336,4338,4341,4344],{"class":58,"line":70},[56,4334,4335],{"class":66},"    r ",[56,4337,3720],{"class":249},[56,4339,4340],{"class":66}," gin.",[56,4342,4343],{"class":224},"Default",[56,4345,2544],{"class":66},[56,4347,4348],{"class":58,"line":86},[56,4349,398],{"emptyLinePlaceholder":313},[56,4351,4352],{"class":58,"line":98},[56,4353,4354],{"class":353},"    \u002F\u002F 创建 MCP 服务器\n",[56,4356,4357,4360,4362,4364,4366,4368,4371,4373,4376],{"class":58,"line":259},[56,4358,4359],{"class":66},"    mcpServer ",[56,4361,3720],{"class":249},[56,4363,3723],{"class":66},[56,4365,3726],{"class":224},[56,4367,228],{"class":66},[56,4369,4370],{"class":362},"\"my-app\"",[56,4372,1101],{"class":66},[56,4374,4375],{"class":362},"\"1.0.0\"",[56,4377,2006],{"class":66},[56,4379,4380,4383,4385],{"class":58,"line":434},[56,4381,4382],{"class":66},"        server.",[56,4384,3759],{"class":224},[56,4386,3762],{"class":66},[56,4388,4389,4391,4393],{"class":58,"line":452},[56,4390,4382],{"class":66},[56,4392,3769],{"class":224},[56,4394,3762],{"class":66},[56,4396,4397],{"class":58,"line":468},[56,4398,4399],{"class":66},"    )\n",[56,4401,4402],{"class":58,"line":474},[56,4403,398],{"emptyLinePlaceholder":313},[56,4405,4406],{"class":58,"line":488},[56,4407,4408],{"class":353},"    \u002F\u002F 注册工具\n",[56,4410,4411,4414],{"class":58,"line":511},[56,4412,4413],{"class":224},"    registerTools",[56,4415,4416],{"class":66},"(mcpServer)\n",[56,4418,4419],{"class":58,"line":520},[56,4420,398],{"emptyLinePlaceholder":313},[56,4422,4423],{"class":58,"line":525},[56,4424,4425],{"class":353},"    \u002F\u002F 挂载到 Gin\n",[56,4427,4428,4431,4433,4435,4437],{"class":58,"line":531},[56,4429,4430],{"class":66},"    mcpHTTP ",[56,4432,3720],{"class":249},[56,4434,3723],{"class":66},[56,4436,4237],{"class":224},[56,4438,4240],{"class":66},[56,4440,4441,4443,4445,4447,4449],{"class":58,"line":542},[56,4442,4382],{"class":66},[56,4444,4247],{"class":224},[56,4446,228],{"class":66},[56,4448,4252],{"class":362},[56,4450,3786],{"class":66},[56,4452,4453,4455,4457,4459,4461,4463],{"class":58,"line":553},[56,4454,4382],{"class":66},[56,4456,4261],{"class":224},[56,4458,228],{"class":66},[56,4460,4266],{"class":73},[56,4462,3926],{"class":249},[56,4464,4271],{"class":66},[56,4466,4467],{"class":58,"line":558},[56,4468,4399],{"class":66},[56,4470,4471,4474,4476,4478,4480,4482,4484],{"class":58,"line":567},[56,4472,4473],{"class":66},"    r.",[56,4475,4292],{"class":224},[56,4477,228],{"class":66},[56,4479,4252],{"class":362},[56,4481,4299],{"class":66},[56,4483,4302],{"class":224},[56,4485,4486],{"class":66},"(mcpHTTP))\n",[56,4488,4489],{"class":58,"line":572},[56,4490,398],{"emptyLinePlaceholder":313},[56,4492,4493],{"class":58,"line":780},[56,4494,4495],{"class":353},"    \u002F\u002F 其他业务路由\n",[56,4497,4498,4500,4503,4505,4508,4510,4512,4514,4517,4520,4523,4525,4527],{"class":58,"line":788},[56,4499,4473],{"class":66},[56,4501,4502],{"class":224},"GET",[56,4504,228],{"class":66},[56,4506,4507],{"class":362},"\"\u002Fapi\u002Fhealth\"",[56,4509,1101],{"class":66},[56,4511,3894],{"class":249},[56,4513,228],{"class":66},[56,4515,4516],{"class":2352},"c",[56,4518,4519],{"class":249}," *",[56,4521,4522],{"class":224},"gin",[56,4524,2471],{"class":66},[56,4526,3907],{"class":224},[56,4528,237],{"class":66},[56,4530,4531,4534,4537,4539,4542,4544,4546,4548,4551,4554,4557,4559,4562],{"class":58,"line":793},[56,4532,4533],{"class":66},"        c.",[56,4535,4536],{"class":224},"JSON",[56,4538,228],{"class":66},[56,4540,4541],{"class":73},"200",[56,4543,1101],{"class":66},[56,4545,4522],{"class":224},[56,4547,2471],{"class":66},[56,4549,4550],{"class":224},"H",[56,4552,4553],{"class":66},"{",[56,4555,4556],{"class":362},"\"status\"",[56,4558,77],{"class":66},[56,4560,4561],{"class":362},"\"ok\"",[56,4563,2895],{"class":66},[56,4565,4566],{"class":58,"line":1422},[56,4567,4568],{"class":66},"    })\n",[56,4570,4571],{"class":58,"line":1436},[56,4572,398],{"emptyLinePlaceholder":313},[56,4574,4575,4577,4580,4582,4585],{"class":58,"line":1443},[56,4576,4473],{"class":66},[56,4578,4579],{"class":224},"Run",[56,4581,228],{"class":66},[56,4583,4584],{"class":362},"\":8080\"",[56,4586,2659],{"class":66},[56,4588,4589],{"class":58,"line":1448},[56,4590,101],{"class":66},[15,4592,4593],{},"此时启动服务，用 curl 测试：",[47,4595,4597],{"className":344,"code":4596,"language":346,"meta":52,"style":52},"# 发现工具列表\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",[26,4598,4599,4604,4621,4631,4639,4643,4648,4660,4668],{"__ignoreMap":52},[56,4600,4601],{"class":58,"line":59},[56,4602,4603],{"class":353},"# 发现工具列表\n",[56,4605,4606,4609,4612,4615,4618],{"class":58,"line":70},[56,4607,4608],{"class":224},"curl",[56,4610,4611],{"class":73}," -X",[56,4613,4614],{"class":362}," POST",[56,4616,4617],{"class":362}," http:\u002F\u002Flocalhost:8080\u002Fmcp",[56,4619,4620],{"class":73}," \\\n",[56,4622,4623,4626,4629],{"class":58,"line":86},[56,4624,4625],{"class":73},"  -H",[56,4627,4628],{"class":362}," \"Content-Type: application\u002Fjson\"",[56,4630,4620],{"class":73},[56,4632,4633,4636],{"class":58,"line":98},[56,4634,4635],{"class":73},"  -d",[56,4637,4638],{"class":362}," '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools\u002Flist\"}'\n",[56,4640,4641],{"class":58,"line":259},[56,4642,398],{"emptyLinePlaceholder":313},[56,4644,4645],{"class":58,"line":434},[56,4646,4647],{"class":353},"# 调用工具\n",[56,4649,4650,4652,4654,4656,4658],{"class":58,"line":452},[56,4651,4608],{"class":224},[56,4653,4611],{"class":73},[56,4655,4614],{"class":362},[56,4657,4617],{"class":362},[56,4659,4620],{"class":73},[56,4661,4662,4664,4666],{"class":58,"line":468},[56,4663,4625],{"class":73},[56,4665,4628],{"class":362},[56,4667,4620],{"class":73},[56,4669,4670,4672],{"class":58,"line":474},[56,4671,4635],{"class":73},[56,4673,4674],{"class":362}," '{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools\u002Fcall\",\"params\":{\"name\":\"list_items\",\"arguments\":{}}}'\n",[11,4676,4678],{"id":4677},"拓展接入鉴权","拓展：接入鉴权",[15,4680,4681],{},"裸奔的 MCP 端点任何人都能调用，生产环境必须加鉴权。思路和普通 API 鉴权一致：Bearer Token。",[586,4683,3322],{"id":3322},[15,4685,4686,4687,4690,4691,4694],{},"MCP 的工具处理器签名为 ",[26,4688,4689],{},"func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error)","，它不接收 Gin 的 ",[26,4692,4693],{},"*gin.Context","，所以无法直接在工具处理器里读取请求头。",[586,4696,4698],{"id":4697},"解决方案中间件传递用户信息","解决方案：中间件传递用户信息",[15,4700,4701,4702,2241],{},"用两层中间件：第一层做 Gin 的 Token 验证，第二层把用户信息从 Gin Context 复制到 ",[26,4703,4704],{},"request.Context",[47,4706,4708],{"className":3693,"code":4707,"language":477,"meta":52,"style":52},"\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",[26,4709,4710,4715,4743,4764,4784,4800,4833,4838,4843,4847,4872,4912,4941,4945,4949,4953,4973,4985,5014,5018,5022,5026,5041,5055,5064,5068],{"__ignoreMap":52},[56,4711,4712],{"class":58,"line":59},[56,4713,4714],{"class":353},"\u002F\u002F Gin 鉴权中间件\n",[56,4716,4717,4719,4722,4724,4727,4729,4732,4734,4736,4738,4741],{"class":58,"line":70},[56,4718,3894],{"class":249},[56,4720,4721],{"class":224}," AuthRequired",[56,4723,228],{"class":66},[56,4725,4726],{"class":2352},"authService",[56,4728,4519],{"class":249},[56,4730,4731],{"class":224},"AuthService",[56,4733,2811],{"class":66},[56,4735,4522],{"class":224},[56,4737,2471],{"class":66},[56,4739,4740],{"class":224},"HandlerFunc",[56,4742,67],{"class":66},[56,4744,4745,4747,4750,4752,4754,4756,4758,4760,4762],{"class":58,"line":86},[56,4746,3951],{"class":249},[56,4748,4749],{"class":249}," func",[56,4751,228],{"class":66},[56,4753,4516],{"class":2352},[56,4755,4519],{"class":249},[56,4757,4522],{"class":224},[56,4759,2471],{"class":66},[56,4761,3907],{"class":224},[56,4763,237],{"class":66},[56,4765,4766,4769,4771,4774,4777,4779,4782],{"class":58,"line":98},[56,4767,4768],{"class":66},"        authHeader ",[56,4770,3720],{"class":249},[56,4772,4773],{"class":66}," c.",[56,4775,4776],{"class":224},"GetHeader",[56,4778,228],{"class":66},[56,4780,4781],{"class":362},"\"Authorization\"",[56,4783,2659],{"class":66},[56,4785,4786,4789,4792,4795,4798],{"class":58,"line":259},[56,4787,4788],{"class":249},"        if",[56,4790,4791],{"class":66}," authHeader ",[56,4793,4794],{"class":249},"==",[56,4796,4797],{"class":362}," \"\"",[56,4799,67],{"class":66},[56,4801,4802,4805,4808,4810,4813,4815,4817,4819,4821,4823,4826,4828,4831],{"class":58,"line":434},[56,4803,4804],{"class":66},"            c.",[56,4806,4807],{"class":224},"AbortWithStatusJSON",[56,4809,228],{"class":66},[56,4811,4812],{"class":73},"401",[56,4814,1101],{"class":66},[56,4816,4522],{"class":224},[56,4818,2471],{"class":66},[56,4820,4550],{"class":224},[56,4822,4553],{"class":66},[56,4824,4825],{"class":362},"\"message\"",[56,4827,77],{"class":66},[56,4829,4830],{"class":362},"\"缺少认证令牌\"",[56,4832,2895],{"class":66},[56,4834,4835],{"class":58,"line":452},[56,4836,4837],{"class":249},"            return\n",[56,4839,4840],{"class":58,"line":468},[56,4841,4842],{"class":66},"        }\n",[56,4844,4845],{"class":58,"line":474},[56,4846,398],{"emptyLinePlaceholder":313},[56,4848,4849,4852,4854,4857,4860,4863,4866,4868,4870],{"class":58,"line":488},[56,4850,4851],{"class":66},"        parts ",[56,4853,3720],{"class":249},[56,4855,4856],{"class":66}," strings.",[56,4858,4859],{"class":224},"SplitN",[56,4861,4862],{"class":66},"(authHeader, ",[56,4864,4865],{"class":362},"\" \"",[56,4867,1101],{"class":66},[56,4869,2535],{"class":73},[56,4871,2659],{"class":66},[56,4873,4874,4876,4879,4882,4884,4887,4890,4893,4896,4899,4902,4904,4907,4910],{"class":58,"line":511},[56,4875,4788],{"class":249},[56,4877,4878],{"class":224}," len",[56,4880,4881],{"class":66},"(parts) ",[56,4883,4137],{"class":249},[56,4885,4886],{"class":73}," 2",[56,4888,4889],{"class":249}," ||",[56,4891,4892],{"class":249}," !",[56,4894,4895],{"class":66},"strings.",[56,4897,4898],{"class":224},"EqualFold",[56,4900,4901],{"class":66},"(parts[",[56,4903,2437],{"class":73},[56,4905,4906],{"class":66},"], ",[56,4908,4909],{"class":362},"\"Bearer\"",[56,4911,237],{"class":66},[56,4913,4914,4916,4918,4920,4922,4924,4926,4928,4930,4932,4934,4936,4939],{"class":58,"line":520},[56,4915,4804],{"class":66},[56,4917,4807],{"class":224},[56,4919,228],{"class":66},[56,4921,4812],{"class":73},[56,4923,1101],{"class":66},[56,4925,4522],{"class":224},[56,4927,2471],{"class":66},[56,4929,4550],{"class":224},[56,4931,4553],{"class":66},[56,4933,4825],{"class":362},[56,4935,77],{"class":66},[56,4937,4938],{"class":362},"\"格式错误，请使用 Bearer \u003Ctoken>\"",[56,4940,2895],{"class":66},[56,4942,4943],{"class":58,"line":525},[56,4944,4837],{"class":249},[56,4946,4947],{"class":58,"line":531},[56,4948,4842],{"class":66},[56,4950,4951],{"class":58,"line":542},[56,4952,398],{"emptyLinePlaceholder":313},[56,4954,4955,4958,4960,4963,4966,4968,4970],{"class":58,"line":553},[56,4956,4957],{"class":66},"        user, err ",[56,4959,3720],{"class":249},[56,4961,4962],{"class":66}," authService.",[56,4964,4965],{"class":224},"GetUserByToken",[56,4967,4901],{"class":66},[56,4969,462],{"class":73},[56,4971,4972],{"class":66},"])\n",[56,4974,4975,4977,4979,4981,4983],{"class":58,"line":558},[56,4976,4788],{"class":249},[56,4978,4134],{"class":66},[56,4980,4137],{"class":249},[56,4982,4140],{"class":73},[56,4984,67],{"class":66},[56,4986,4987,4989,4991,4993,4995,4997,4999,5001,5003,5005,5007,5009,5012],{"class":58,"line":567},[56,4988,4804],{"class":66},[56,4990,4807],{"class":224},[56,4992,228],{"class":66},[56,4994,4812],{"class":73},[56,4996,1101],{"class":66},[56,4998,4522],{"class":224},[56,5000,2471],{"class":66},[56,5002,4550],{"class":224},[56,5004,4553],{"class":66},[56,5006,4825],{"class":362},[56,5008,77],{"class":66},[56,5010,5011],{"class":362},"\"无效的认证令牌\"",[56,5013,2895],{"class":66},[56,5015,5016],{"class":58,"line":572},[56,5017,4837],{"class":249},[56,5019,5020],{"class":58,"line":780},[56,5021,4842],{"class":66},[56,5023,5024],{"class":58,"line":788},[56,5025,398],{"emptyLinePlaceholder":313},[56,5027,5028,5030,5033,5035,5038],{"class":58,"line":793},[56,5029,4533],{"class":66},[56,5031,5032],{"class":224},"Set",[56,5034,228],{"class":66},[56,5036,5037],{"class":362},"\"userID\"",[56,5039,5040],{"class":66},", user.ID)\n",[56,5042,5043,5045,5047,5049,5052],{"class":58,"line":1422},[56,5044,4533],{"class":66},[56,5046,5032],{"class":224},[56,5048,228],{"class":66},[56,5050,5051],{"class":362},"\"username\"",[56,5053,5054],{"class":66},", user.Username)\n",[56,5056,5057,5059,5062],{"class":58,"line":1436},[56,5058,4533],{"class":66},[56,5060,5061],{"class":224},"Next",[56,5063,2544],{"class":66},[56,5065,5066],{"class":58,"line":1443},[56,5067,2310],{"class":66},[56,5069,5070],{"class":58,"line":1448},[56,5071,101],{"class":66},[47,5073,5075],{"className":3693,"code":5074,"language":477,"meta":52,"style":52},"\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",[26,5076,5077,5082,5093,5100,5113,5125,5129,5133,5151,5171,5185,5209,5231,5246,5254,5258],{"__ignoreMap":52},[56,5078,5079],{"class":58,"line":59},[56,5080,5081],{"class":353},"\u002F\u002F 用户信息桥接中间件：Gin Context → request.Context\n",[56,5083,5084,5087,5090],{"class":58,"line":70},[56,5085,5086],{"class":249},"type",[56,5088,5089],{"class":224}," ctxKey",[56,5091,5092],{"class":249}," string\n",[56,5094,5095,5097],{"class":58,"line":86},[56,5096,2583],{"class":249},[56,5098,5099],{"class":66}," (\n",[56,5101,5102,5105,5108,5110],{"class":58,"line":98},[56,5103,5104],{"class":73},"    ctxUserID",[56,5106,5107],{"class":224},"   ctxKey",[56,5109,1993],{"class":249},[56,5111,5112],{"class":362}," \"userID\"\n",[56,5114,5115,5118,5120,5122],{"class":58,"line":259},[56,5116,5117],{"class":73},"    ctxUsername",[56,5119,5089],{"class":224},[56,5121,1993],{"class":249},[56,5123,5124],{"class":362}," \"username\"\n",[56,5126,5127],{"class":58,"line":434},[56,5128,2659],{"class":66},[56,5130,5131],{"class":58,"line":452},[56,5132,398],{"emptyLinePlaceholder":313},[56,5134,5135,5137,5140,5143,5145,5147,5149],{"class":58,"line":468},[56,5136,3894],{"class":249},[56,5138,5139],{"class":224}," WithUserContext",[56,5141,5142],{"class":66},"() ",[56,5144,4522],{"class":224},[56,5146,2471],{"class":66},[56,5148,4740],{"class":224},[56,5150,67],{"class":66},[56,5152,5153,5155,5157,5159,5161,5163,5165,5167,5169],{"class":58,"line":474},[56,5154,3951],{"class":249},[56,5156,4749],{"class":249},[56,5158,228],{"class":66},[56,5160,4516],{"class":2352},[56,5162,4519],{"class":249},[56,5164,4522],{"class":224},[56,5166,2471],{"class":66},[56,5168,3907],{"class":224},[56,5170,237],{"class":66},[56,5172,5173,5176,5178,5181,5183],{"class":58,"line":488},[56,5174,5175],{"class":66},"        ctx ",[56,5177,3720],{"class":249},[56,5179,5180],{"class":66}," c.Request.",[56,5182,3907],{"class":224},[56,5184,2544],{"class":66},[56,5186,5187,5189,5191,5194,5197,5200,5203,5205,5207],{"class":58,"line":511},[56,5188,5175],{"class":66},[56,5190,443],{"class":249},[56,5192,5193],{"class":66}," context.",[56,5195,5196],{"class":224},"WithValue",[56,5198,5199],{"class":66},"(ctx, ctxUserID, c.",[56,5201,5202],{"class":224},"GetUint",[56,5204,228],{"class":66},[56,5206,5037],{"class":362},[56,5208,2512],{"class":66},[56,5210,5211,5213,5215,5217,5219,5222,5225,5227,5229],{"class":58,"line":520},[56,5212,5175],{"class":66},[56,5214,443],{"class":249},[56,5216,5193],{"class":66},[56,5218,5196],{"class":224},[56,5220,5221],{"class":66},"(ctx, ctxUsername, c.",[56,5223,5224],{"class":224},"GetString",[56,5226,228],{"class":66},[56,5228,5051],{"class":362},[56,5230,2512],{"class":66},[56,5232,5233,5236,5238,5240,5243],{"class":58,"line":525},[56,5234,5235],{"class":66},"        c.Request ",[56,5237,443],{"class":249},[56,5239,5180],{"class":66},[56,5241,5242],{"class":224},"WithContext",[56,5244,5245],{"class":66},"(ctx)\n",[56,5247,5248,5250,5252],{"class":58,"line":531},[56,5249,4533],{"class":66},[56,5251,5061],{"class":224},[56,5253,2544],{"class":66},[56,5255,5256],{"class":58,"line":542},[56,5257,2310],{"class":66},[56,5259,5260],{"class":58,"line":553},[56,5261,101],{"class":66},[586,5263,5264],{"id":5264},"组合使用",[47,5266,5268],{"className":3693,"code":5267,"language":477,"meta":52,"style":52},"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",[26,5269,5270,5282,5293,5304,5317],{"__ignoreMap":52},[56,5271,5272,5274,5276,5278,5280],{"class":58,"line":59},[56,5273,4289],{"class":66},[56,5275,4292],{"class":224},[56,5277,228],{"class":66},[56,5279,4252],{"class":362},[56,5281,2006],{"class":66},[56,5283,5284,5287,5290],{"class":58,"line":70},[56,5285,5286],{"class":224},"    AuthRequired",[56,5288,5289],{"class":66},"(authService),  ",[56,5291,5292],{"class":353},"\u002F\u002F 1. 验证 Token\n",[56,5294,5295,5298,5301],{"class":58,"line":86},[56,5296,5297],{"class":224},"    WithUserContext",[56,5299,5300],{"class":66},"(),           ",[56,5302,5303],{"class":353},"\u002F\u002F 2. 桥接用户信息到 request.Context\n",[56,5305,5306,5309,5311,5314],{"class":58,"line":98},[56,5307,5308],{"class":66},"    gin.",[56,5310,4302],{"class":224},[56,5312,5313],{"class":66},"(mcpHTTP),         ",[56,5315,5316],{"class":353},"\u002F\u002F 3. MCP 处理\n",[56,5318,5319],{"class":58,"line":259},[56,5320,2659],{"class":66},[15,5322,5323],{},"在工具处理器里就能拿到用户信息了：",[47,5325,5327],{"className":3693,"code":5326,"language":477,"meta":52,"style":52},"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",[26,5328,5329,5384,5405,5414,5431,5435,5439,5455,5460,5473],{"__ignoreMap":52},[56,5330,5331,5333,5335,5338,5340,5343,5345,5348,5350,5352,5354,5356,5358,5360,5362,5364,5366,5368,5370,5372,5374,5376,5378,5380,5382],{"class":58,"line":59},[56,5332,3894],{"class":249},[56,5334,2391],{"class":66},[56,5336,5337],{"class":2352},"h ",[56,5339,3926],{"class":249},[56,5341,5342],{"class":224},"Handler",[56,5344,2811],{"class":66},[56,5346,5347],{"class":224},"handleListItems",[56,5349,228],{"class":66},[56,5351,3899],{"class":2352},[56,5353,3902],{"class":224},[56,5355,2471],{"class":66},[56,5357,3907],{"class":224},[56,5359,1101],{"class":66},[56,5361,3912],{"class":2352},[56,5363,3915],{"class":224},[56,5365,2471],{"class":66},[56,5367,3920],{"class":224},[56,5369,3923],{"class":66},[56,5371,3926],{"class":249},[56,5373,3929],{"class":224},[56,5375,2471],{"class":66},[56,5377,3934],{"class":224},[56,5379,1101],{"class":66},[56,5381,3939],{"class":249},[56,5383,237],{"class":66},[56,5385,5386,5389,5391,5394,5397,5400,5403],{"class":58,"line":70},[56,5387,5388],{"class":66},"    userID, ok ",[56,5390,3720],{"class":249},[56,5392,5393],{"class":66}," ctx.",[56,5395,5396],{"class":224},"Value",[56,5398,5399],{"class":66},"(ctxUserID).(",[56,5401,5402],{"class":249},"uint",[56,5404,2659],{"class":66},[56,5406,5407,5409,5411],{"class":58,"line":86},[56,5408,2426],{"class":249},[56,5410,4892],{"class":249},[56,5412,5413],{"class":66},"ok {\n",[56,5415,5416,5418,5420,5422,5424,5427,5429],{"class":58,"line":98},[56,5417,4147],{"class":249},[56,5419,3842],{"class":66},[56,5421,4152],{"class":224},[56,5423,228],{"class":66},[56,5425,5426],{"class":362},"\"未认证\"",[56,5428,3964],{"class":66},[56,5430,3967],{"class":73},[56,5432,5433],{"class":58,"line":259},[56,5434,2310],{"class":66},[56,5436,5437],{"class":58,"line":434},[56,5438,398],{"emptyLinePlaceholder":313},[56,5440,5441,5444,5446,5449,5452],{"class":58,"line":452},[56,5442,5443],{"class":66},"    items, _ ",[56,5445,3720],{"class":249},[56,5447,5448],{"class":66}," h.service.",[56,5450,5451],{"class":224},"ListByUser",[56,5453,5454],{"class":66},"(userID)\n",[56,5456,5457],{"class":58,"line":468},[56,5458,5459],{"class":353},"    \u002F\u002F ...\n",[56,5461,5462,5464,5466,5468,5471],{"class":58,"line":474},[56,5463,3951],{"class":249},[56,5465,3842],{"class":66},[56,5467,3956],{"class":224},[56,5469,5470],{"class":66},"(result), ",[56,5472,3967],{"class":73},[56,5474,5475],{"class":58,"line":488},[56,5476,101],{"class":66},[586,5478,5479],{"id":5479},"客户端配置",[15,5481,5482],{},"AI 客户端（如 Claude Desktop）配置 MCP 服务器时带上 Authorization 头：",[47,5484,5488],{"className":5485,"code":5486,"language":5487,"meta":52,"style":52},"language-json shiki shiki-themes github-light github-dark","{\n  \"mcpServers\": {\n    \"my-app\": {\n      \"url\": \"http:\u002F\u002Flocalhost:8080\u002Fmcp\",\n      \"headers\": {\n        \"Authorization\": \"Bearer your-token-here\"\n      }\n    }\n  }\n}\n","json",[26,5489,5490,5495,5502,5508,5520,5527,5537,5542,5546,5550],{"__ignoreMap":52},[56,5491,5492],{"class":58,"line":59},[56,5493,5494],{"class":66},"{\n",[56,5496,5497,5500],{"class":58,"line":70},[56,5498,5499],{"class":73},"  \"mcpServers\"",[56,5501,2269],{"class":66},[56,5503,5504,5506],{"class":58,"line":86},[56,5505,3734],{"class":73},[56,5507,2269],{"class":66},[56,5509,5510,5513,5515,5518],{"class":58,"line":98},[56,5511,5512],{"class":73},"      \"url\"",[56,5514,77],{"class":66},[56,5516,5517],{"class":362},"\"http:\u002F\u002Flocalhost:8080\u002Fmcp\"",[56,5519,2006],{"class":66},[56,5521,5522,5525],{"class":58,"line":259},[56,5523,5524],{"class":73},"      \"headers\"",[56,5526,2269],{"class":66},[56,5528,5529,5532,5534],{"class":58,"line":434},[56,5530,5531],{"class":73},"        \"Authorization\"",[56,5533,77],{"class":66},[56,5535,5536],{"class":362},"\"Bearer your-token-here\"\n",[56,5538,5539],{"class":58,"line":452},[56,5540,5541],{"class":66},"      }\n",[56,5543,5544],{"class":58,"line":468},[56,5545,2310],{"class":66},[56,5547,5548],{"class":58,"line":474},[56,5549,2315],{"class":66},[56,5551,5552],{"class":58,"line":488},[56,5553,101],{"class":66},[11,5555,287],{"id":287},[1683,5557,5558,5568],{},[1686,5559,5560],{},[1689,5561,5562,5565],{},[1692,5563,5564],{},"步骤",[1692,5566,5567],{},"说明",[1704,5569,5570,5579,5590,5599,5608],{},[1689,5571,5572,5576],{},[1709,5573,5574],{},[26,5575,3726],{},[1709,5577,5578],{},"创建 MCP 协议实例",[1689,5580,5581,5587],{},[1709,5582,5583,1527,5585],{},[26,5584,3845],{},[26,5586,3888],{},[1709,5588,5589],{},"定义并注册工具",[1689,5591,5592,5596],{},[1709,5593,5594],{},[26,5595,4237],{},[1709,5597,5598],{},"转为 HTTP Handler",[1689,5600,5601,5605],{},[1709,5602,5603],{},[26,5604,4205],{},[1709,5606,5607],{},"挂载到 Gin 路由",[1689,5609,5610,5618],{},[1709,5611,5612,1527,5615],{},[26,5613,5614],{},"AuthRequired",[26,5616,5617],{},"WithUserContext",[1709,5619,5620],{},"鉴权 + 用户信息桥接",[15,5622,5623,5624,5627,5628,2241],{},"核心就一句话：",[31,5625,5626],{},"MCP 是 HTTP Handler，挂上去就行。"," 鉴权复用你已有的中间件体系，只需要多写一个桥接函数把用户信息传进 ",[26,5629,4704],{},[214,5631,5632],{},"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":52,"searchDepth":70,"depth":86,"links":5634},[5635,5636,5637,5638,5645,5651],{"id":3605,"depth":70,"text":3606},{"id":3615,"depth":70,"text":3616},{"id":3632,"depth":70,"text":3632},{"id":3658,"depth":70,"text":3658,"children":5639},[5640,5641,5642,5643,5644],{"id":3661,"depth":86,"text":3662},{"id":3689,"depth":86,"text":3690},{"id":3806,"depth":86,"text":3807},{"id":4194,"depth":86,"text":4195},{"id":4315,"depth":86,"text":4316},{"id":4677,"depth":70,"text":4678,"children":5646},[5647,5648,5649,5650],{"id":3322,"depth":86,"text":3322},{"id":4697,"depth":86,"text":4698},{"id":5264,"depth":86,"text":5264},{"id":5479,"depth":86,"text":5479},{"id":287,"depth":70,"text":287},"介绍如何使用 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":3600,"description":5652},"Go 工程实践","posts\u002Fgo-mcp-gin\u002Findex",[1150,5661,5662,5663,5664],"MCP","Gin","AI Agent","后端","2026-06-01","5fZl1Vigs2lHb5fHPz-0YZFyFD38ws1heOouZUG1g6M",{"id":5668,"title":5669,"author":330,"body":5670,"description":6437,"difficulty":330,"draft":309,"extension":310,"image":6438,"meta":6439,"navigation":313,"path":6440,"pinned":309,"published":6441,"seo":6442,"series":330,"seriesOrder":330,"stem":6443,"tags":6444,"updated":330,"__hash__":6448},"posts\u002Fposts\u002Fdws-dingtalk-cli\u002Findex.md","DWS — 钉钉官方出品的 AI 友好命令行工具",{"type":8,"value":5671,"toc":6423},[5672,5676,5679,5686,5812,5815,5818,5874,5877,5892,5895,5898,5905,6137,6144,6148,6151,6171,6174,6189,6192,6212,6215,6218,6225,6291,6294,6297,6301,6304,6356,6360,6381,6385,6388,6402,6405,6409,6412,6414,6417,6420],[11,5673,5675],{"id":5674},"dws-是什么","dws 是什么",[15,5677,5678],{},"dws（DingTalk Workspace CLI）是阿里巴巴 2026 年开源的钉钉命令行工具，Apache-2.0 协议，Go 语言编写。",[15,5680,5681,5682,5685],{},"它的定位很明确：",[31,5683,5684],{},"把钉钉的所有产品能力暴露给 AI Agent 和命令行用户","。不用打开钉钉 App，不用写 API 调用代码，一条命令搞定。",[47,5687,5689],{"className":344,"code":5688,"language":346,"meta":52,"style":52},"# 查看今天有什么会\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",[26,5690,5691,5696,5722,5726,5731,5753,5757,5762,5787,5791,5796],{"__ignoreMap":52},[56,5692,5693],{"class":58,"line":59},[56,5694,5695],{"class":353},"# 查看今天有什么会\n",[56,5697,5698,5701,5704,5707,5710,5713,5716,5719],{"class":58,"line":70},[56,5699,5700],{"class":224},"dws",[56,5702,5703],{"class":362}," calendar",[56,5705,5706],{"class":362}," event",[56,5708,5709],{"class":362}," list",[56,5711,5712],{"class":73}," --start",[56,5714,5715],{"class":362}," 2026-06-01",[56,5717,5718],{"class":73}," --end",[56,5720,5721],{"class":362}," 2026-06-01\n",[56,5723,5724],{"class":58,"line":86},[56,5725,398],{"emptyLinePlaceholder":313},[56,5727,5728],{"class":58,"line":98},[56,5729,5730],{"class":353},"# 给群里发条消息\n",[56,5732,5733,5735,5738,5741,5744,5747,5750],{"class":58,"line":259},[56,5734,5700],{"class":224},[56,5736,5737],{"class":362}," chat",[56,5739,5740],{"class":362}," send",[56,5742,5743],{"class":73}," --group-id",[56,5745,5746],{"class":362}," xxx",[56,5748,5749],{"class":73}," --content",[56,5751,5752],{"class":362}," \"今天发布推迟到下午3点\"\n",[56,5754,5755],{"class":58,"line":434},[56,5756,398],{"emptyLinePlaceholder":313},[56,5758,5759],{"class":58,"line":452},[56,5760,5761],{"class":353},"# 创建一个待办\n",[56,5763,5764,5766,5769,5772,5775,5778,5781,5784],{"class":58,"line":468},[56,5765,5700],{"class":224},[56,5767,5768],{"class":362}," todo",[56,5770,5771],{"class":362}," task",[56,5773,5774],{"class":362}," create",[56,5776,5777],{"class":73}," --subject",[56,5779,5780],{"class":362}," \"Review PR #123\"",[56,5782,5783],{"class":73}," --due-date",[56,5785,5786],{"class":362}," 2026-06-02\n",[56,5788,5789],{"class":58,"line":474},[56,5790,398],{"emptyLinePlaceholder":313},[56,5792,5793],{"class":58,"line":488},[56,5794,5795],{"class":353},"# 读取钉钉文档\n",[56,5797,5798,5800,5803,5806,5809],{"class":58,"line":511},[56,5799,5700],{"class":224},[56,5801,5802],{"class":362}," doc",[56,5804,5805],{"class":362}," read",[56,5807,5808],{"class":73}," --url",[56,5810,5811],{"class":362}," \"https:\u002F\u002Falidocs.dingtalk.com\u002Fi\u002Fnodes\u002Fxxx\"\n",[11,5813,5814],{"id":5814},"安装",[15,5816,5817],{},"三种方式，选你喜欢的：",[47,5819,5821],{"className":344,"code":5820,"language":346,"meta":52,"style":52},"# 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",[26,5822,5823,5828,5841,5845,5850,5860,5864,5869],{"__ignoreMap":52},[56,5824,5825],{"class":58,"line":59},[56,5826,5827],{"class":353},"# npm（推荐，跨平台自动下载二进制）\n",[56,5829,5830,5833,5835,5838],{"class":58,"line":70},[56,5831,5832],{"class":224},"npm",[56,5834,419],{"class":362},[56,5836,5837],{"class":73}," -g",[56,5839,5840],{"class":362}," dingtalk-workspace-cli\n",[56,5842,5843],{"class":58,"line":86},[56,5844,398],{"emptyLinePlaceholder":313},[56,5846,5847],{"class":58,"line":98},[56,5848,5849],{"class":353},"# Homebrew（macOS\u002FLinux）\n",[56,5851,5852,5855,5857],{"class":58,"line":259},[56,5853,5854],{"class":224},"brew",[56,5856,419],{"class":362},[56,5858,5859],{"class":362}," dws\n",[56,5861,5862],{"class":58,"line":434},[56,5863,398],{"emptyLinePlaceholder":313},[56,5865,5866],{"class":58,"line":452},[56,5867,5868],{"class":353},"# 直接下载二进制\n",[56,5870,5871],{"class":58,"line":468},[56,5872,5873],{"class":353},"# https:\u002F\u002Fgithub.com\u002Fopen-dingtalk\u002Fdingtalk-workspace-cli\u002Freleases\n",[15,5875,5876],{},"安装后认证：",[47,5878,5880],{"className":344,"code":5879,"language":346,"meta":52,"style":52},"dws auth login\n",[26,5881,5882],{"__ignoreMap":52},[56,5883,5884,5886,5889],{"class":58,"line":59},[56,5885,5700],{"class":224},[56,5887,5888],{"class":362}," auth",[56,5890,5891],{"class":362}," login\n",[15,5893,5894],{},"会打开浏览器走 OAuth 设备流认证，token 加密存储在系统钥匙串里（PBKDF2 + AES-256-GCM）。",[11,5896,5897],{"id":5897},"覆盖的钉钉产品",[15,5899,5900,5901,5904],{},"这是最夸张的部分 — ",[31,5902,5903],{},"19 个产品，213 个命令","，基本把钉钉全家桶都搬到了命令行：",[1683,5906,5907,5919],{},[1686,5908,5909],{},[1689,5910,5911,5914,5917],{},[1692,5912,5913],{},"产品",[1692,5915,5916],{},"命令数",[1692,5918,5567],{},[1704,5920,5921,5935,5949,5963,5977,5991,6005,6019,6032,6046,6059,6073,6087,6100,6113,6126],{},[1689,5922,5923,5929,5932],{},[1709,5924,5925,5926],{},"聊天 ",[26,5927,5928],{},"chat",[1709,5930,5931],{},"57",[1709,5933,5934],{},"消息收发、群管理、机器人、已读状态、Webhook",[1689,5936,5937,5943,5946],{},[1709,5938,5939,5940],{},"AI 表格 ",[26,5941,5942],{},"aitable",[1709,5944,5945],{},"42",[1709,5947,5948],{},"Base\u002F数据表\u002F字段\u002F记录\u002F视图\u002F图表\u002F仪表盘，完整 CRUD",[1689,5950,5951,5957,5960],{},[1709,5952,5953,5954],{},"电子表格 ",[26,5955,5956],{},"sheet",[1709,5958,5959],{},"34",[1709,5961,5962],{},"工作表读写、行列操作、合并、筛选视图、导出",[1689,5964,5965,5971,5974],{},[1709,5966,5967,5968],{},"文档 ",[26,5969,5970],{},"doc",[1709,5972,5973],{},"21",[1709,5975,5976],{},"搜索、读写、块级编辑、评论、复制移动",[1689,5978,5979,5985,5988],{},[1709,5980,5981,5982],{},"会议纪要 ",[26,5983,5984],{},"minutes",[1709,5986,5987],{},"19",[1709,5989,5990],{},"列表、摘要、转写、思维导图、发言人、热词",[1689,5992,5993,5999,6002],{},[1709,5994,5995,5996],{},"日历 ",[26,5997,5998],{},"calendar",[1709,6000,6001],{},"14",[1709,6003,6004],{},"事件 CRUD、会议室、忙闲查询、时间建议",[1689,6006,6007,6013,6016],{},[1709,6008,6009,6010],{},"审批 ",[26,6011,6012],{},"oa",[1709,6014,6015],{},"9",[1709,6017,6018],{},"待办、发起、同意\u002F拒绝\u002F撤销",[1689,6020,6021,6027,6029],{},[1709,6022,6023,6024],{},"云盘 ",[26,6025,6026],{},"drive",[1709,6028,6015],{},[1709,6030,6031],{},"列表、下载、上传、创建目录",[1689,6033,6034,6040,6043],{},[1709,6035,6036,6037],{},"知识库 ",[26,6038,6039],{},"wiki",[1709,6041,6042],{},"7",[1709,6044,6045],{},"空间管理、成员管理",[1689,6047,6048,6054,6056],{},[1709,6049,6050,6051],{},"汇报 ",[26,6052,6053],{},"report",[1709,6055,6042],{},[1709,6057,6058],{},"日志\u002F日报\u002F周报，按模板创建、统计",[1689,6060,6061,6067,6070],{},[1709,6062,6063,6064],{},"待办 ",[26,6065,6066],{},"todo",[1709,6068,6069],{},"6",[1709,6071,6072],{},"创建、完成、删除，支持优先级和循环",[1689,6074,6075,6081,6084],{},[1709,6076,6077,6078],{},"邮件 ",[26,6079,6080],{},"mail",[1709,6082,6083],{},"4",[1709,6085,6086],{},"邮箱查询、邮件搜索、发送",[1689,6088,6089,6095,6097],{},[1709,6090,6091,6092],{},"考勤 ",[26,6093,6094],{},"attendance",[1709,6096,6083],{},[1709,6098,6099],{},"打卡记录、排班、汇总",[1689,6101,6102,6108,6110],{},[1709,6103,6104,6105],{},"通讯录 ",[26,6106,6107],{},"contact",[1709,6109,2299],{},[1709,6111,6112],{},"用户搜索、部门查询",[1689,6114,6115,6121,6123],{},[1709,6116,6117,6118],{},"DING 消息 ",[26,6119,6120],{},"ding",[1709,6122,2535],{},[1709,6124,6125],{},"发送\u002F撤回（应用内、短信、电话）",[1689,6127,6128,6131,6134],{},[1709,6129,6130],{},"其他",[1709,6132,6133],{},"若干",[1709,6135,6136],{},"AI 搜索、AI 应用、直播、开放平台文档",[15,6138,6139,6140,6143],{},"还有一个万能命令 ",[26,6141,6142],{},"dws api","，可以直接调用任意钉钉 OpenAPI，不受这 213 个命令的限制。",[11,6145,6147],{"id":6146},"agent-skill-系统","Agent Skill 系统",[15,6149,6150],{},"dws 内置了一套完整的 Agent Skill，可以直接被 AI 编程工具发现和使用：",[19,6152,6153,6159,6165],{},[22,6154,6155,6158],{},[31,6156,6157],{},"Claude Code"," — 通过 SKILL.md 自动加载",[22,6160,6161,6164],{},[31,6162,6163],{},"Cursor"," — 支持 MCP 协议接入",[22,6166,6167,6170],{},[31,6168,6169],{},"Codex \u002F OpenCode \u002F Qoder"," — 通过 skill 安装命令接入",[15,6172,6173],{},"安装 skill：",[47,6175,6177],{"className":344,"code":6176,"language":346,"meta":52,"style":52},"dws skill setup\n",[26,6178,6179],{"__ignoreMap":52},[56,6180,6181,6183,6186],{"class":58,"line":59},[56,6182,5700],{"class":224},[56,6184,6185],{"class":362}," skill",[56,6187,6188],{"class":362}," setup\n",[15,6190,6191],{},"SKILL.md 里定义了：",[19,6193,6194,6203,6206,6209],{},[22,6195,6196,6197,6199,6200,6202],{},"16 个产品的意图判断决策树（用户说\"表格\"→ ",[26,6198,5942],{},"，说\"打卡\"→ ",[26,6201,6094],{},"）",[22,6204,6205],{},"危险操作确认流程（删除、撤回等必须先确认）",[22,6207,6208],{},"错误处理规范（verbose 重试、recovery 事件 ID）",[22,6210,6211],{},"URL 分流规则（alidocs.dingtalk.com 的多种路径格式自动识别）",[15,6213,6214],{},"Agent 读到这份 SKILL.md 就知道什么时候该调什么命令，不需要额外的 prompt 工程。",[11,6216,6217],{"id":6217},"自动升级",[15,6219,6220,6221,6224],{},"内置 ",[26,6222,6223],{},"dws upgrade","，不需要重新下载安装包：",[47,6226,6228],{"className":344,"code":6227,"language":346,"meta":52,"style":52},"dws upgrade              # 交互式升级到最新版\ndws upgrade --check      # 仅检查是否有新版本\ndws upgrade --list       # 列出最近版本\ndws upgrade --version v1.0.5  # 升级到指定版本\ndws upgrade --rollback   # 回滚到上一版本\n",[26,6229,6230,6240,6252,6264,6279],{"__ignoreMap":52},[56,6231,6232,6234,6237],{"class":58,"line":59},[56,6233,5700],{"class":224},[56,6235,6236],{"class":362}," upgrade",[56,6238,6239],{"class":353},"              # 交互式升级到最新版\n",[56,6241,6242,6244,6246,6249],{"class":58,"line":70},[56,6243,5700],{"class":224},[56,6245,6236],{"class":362},[56,6247,6248],{"class":73}," --check",[56,6250,6251],{"class":353},"      # 仅检查是否有新版本\n",[56,6253,6254,6256,6258,6261],{"class":58,"line":86},[56,6255,5700],{"class":224},[56,6257,6236],{"class":362},[56,6259,6260],{"class":73}," --list",[56,6262,6263],{"class":353},"       # 列出最近版本\n",[56,6265,6266,6268,6270,6273,6276],{"class":58,"line":98},[56,6267,5700],{"class":224},[56,6269,6236],{"class":362},[56,6271,6272],{"class":73}," --version",[56,6274,6275],{"class":362}," v1.0.5",[56,6277,6278],{"class":353},"  # 升级到指定版本\n",[56,6280,6281,6283,6285,6288],{"class":58,"line":259},[56,6282,5700],{"class":224},[56,6284,6236],{"class":362},[56,6286,6287],{"class":73}," --rollback",[56,6289,6290],{"class":353},"   # 回滚到上一版本\n",[15,6292,6293],{},"升级流程：下载 → SHA256 校验 → 备份当前版本 → 原子替换 → 更新 Skill。失败可一键回滚。",[11,6295,6296],{"id":6296},"亮点",[586,6298,6300],{"id":6299},"_1-为-ai-agent-专门优化","1. 为 AI Agent 专门优化",[15,6302,6303],{},"这可能是目前对 AI 最友好的 CLI 工具：",[19,6305,6306,6327,6337,6346],{},[22,6307,6308,6311,6312,6315,6316,6319,6320,6315,6323,6326],{},[31,6309,6310],{},"智能纠错"," — AI 模型经常生成错误的参数格式（",[26,6313,6314],{},"--userId"," 应该是 ",[26,6317,6318],{},"--user-id","，",[26,6321,6322],{},"--limit100",[26,6324,6325],{},"--limit 100","），dws 的 5 阶段 Pipeline 会自动修正",[22,6328,6329,6332,6333,6336],{},[31,6330,6331],{},"Schema 发现"," — ",[26,6334,6335],{},"dws schema"," 让 Agent 动态查询可用工具和参数，不需要硬编码",[22,6338,6339,6332,6342,6345],{},[31,6340,6341],{},"jq 过滤",[26,6343,6344],{},"--jq '.data[] | {name, id}'"," 直接过滤输出，减少 Agent 消耗的 token",[22,6347,6348,6351,6352,6355],{},[31,6349,6350],{},"JSON 输出"," — 所有命令支持 ",[26,6353,6354],{},"--format json","，方便程序解析",[586,6357,6359],{"id":6358},"_2-安全设计","2. 安全设计",[19,6361,6362,6365,6368,6371,6378],{},[22,6363,6364],{},"token 用 PBKDF2 + AES-256-GCM 加密，密钥绑定设备 MAC 地址",[22,6366,6367],{},"域名白名单，只能访问钉钉官方 API",[22,6369,6370],{},"强制 HTTPS",[22,6372,6373,6374,6377],{},"写操作必须显式 ",[26,6375,6376],{},"--yes"," 确认",[22,6379,6380],{},"完整审计链",[586,6382,6384],{"id":6383},"_3-发现驱动架构","3. 发现驱动架构",[15,6386,6387],{},"不是把所有命令硬编码在代码里，而是：",[121,6389,6390,6393,6396,6399],{},[22,6391,6392],{},"从钉钉 MCP 服务器注册中心拉取元数据",[22,6394,6395],{},"运行时发现服务端能力",[22,6397,6398],{},"转换成统一的中间表示（IR）",[22,6400,6401],{},"动态挂载到命令树",[15,6403,6404],{},"这意味着钉钉新增产品能力时，dws 不需要发版就能支持。",[586,6406,6408],{"id":6407},"_4-多平台分发","4. 多平台分发",[15,6410,6411],{},"GoReleaser 交叉编译 + Homebrew formula + npm 包 + shell\u002FPowerShell 安装脚本，覆盖 Windows、macOS、Linux。",[11,6413,287],{"id":287},[15,6415,6416],{},"dws 把钉钉从一个\"必须打开 App 才能用\"的工具变成了一个\"命令行 + AI Agent 随时调用\"的平台。213 个命令覆盖日常办公的方方面面，Agent Skill 系统让 AI 编程工具可以直接理解和使用这些能力。",[15,6418,6419],{},"如果你是钉钉重度用户，或者在做企业内部的 AI Agent 集成，dws 值得一试。",[214,6421,6422],{},"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":52,"searchDepth":70,"depth":86,"links":6424},[6425,6426,6427,6428,6429,6430,6436],{"id":5674,"depth":70,"text":5675},{"id":5814,"depth":70,"text":5814},{"id":5897,"depth":70,"text":5897},{"id":6146,"depth":70,"text":6147},{"id":6217,"depth":70,"text":6217},{"id":6296,"depth":70,"text":6296,"children":6431},[6432,6433,6434,6435],{"id":6299,"depth":86,"text":6300},{"id":6358,"depth":86,"text":6359},{"id":6383,"depth":86,"text":6384},{"id":6407,"depth":86,"text":6408},{"id":287,"depth":70,"text":287},"介绍阿里巴巴官方的 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":5669,"description":6437},"posts\u002Fdws-dingtalk-cli\u002Findex",[6445,6446,5663,1150,6447],"钉钉","CLI","效率工具","4ca65VUZ2Sz9yG8dNtftrBDDHE6QowA3ed9g4egQkKI",{"id":6450,"title":6451,"author":330,"body":6452,"description":7415,"difficulty":308,"draft":309,"extension":310,"image":7416,"meta":7417,"navigation":313,"path":7418,"pinned":309,"published":7419,"seo":7420,"series":5658,"seriesOrder":70,"stem":7421,"tags":7422,"updated":7426,"__hash__":7427},"posts\u002Fposts\u002Fgo-embed-frontend\u002Findex.md","Go 单文件打包实战：把前端塞进二进制",{"type":8,"value":6453,"toc":7398},[6454,6456,6463,6466,6469,6480,6483,6487,6820,6824,6932,6936,7070,7073,7077,7086,7103,7114,7133,7147,7153,7161,7260,7266,7270,7277,7286,7289,7325,7328,7333,7372,7374,7380,7392,7395],[11,6455,2217],{"id":2217},[15,6457,6458,6459,6462],{},"一个 Gin + Vue 3 的全栈项目，之前的发布方式是：一个 Go 二进制 + 一个 ",[26,6460,6461],{},"web\u002F"," 前端静态目录，必须放一起才能运行。",[15,6464,6465],{},"为了让部署更丝滑——一个 exe 拖哪都能跑——决定把前端构建产物嵌入到 Go 二进制里。",[11,6467,6468],{"id":6468},"核心思路",[15,6470,6471,6472,6475,6476,6479],{},"利用 Go 1.16 引入的 ",[26,6473,6474],{},"\u002F\u002Fgo:embed"," 指令，在编译时将前端 ",[26,6477,6478],{},"dist\u002F"," 目录嵌入二进制，运行时通过嵌入的文件系统提供 SPA 服务。",[11,6481,6482],{"id":6482},"逐步实现",[586,6484,6486],{"id":6485},"_1-创建嵌入模块","1. 创建嵌入模块",[47,6488,6490],{"className":3693,"code":6489,"language":477,"meta":52,"style":52},"\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",[26,6491,6492,6497,6505,6509,6515,6525,6534,6543,6552,6561,6570,6574,6583,6587,6591,6596,6611,6615,6632,6653,6657,6677,6697,6710,6720,6724,6728,6743,6755,6760,6779,6783,6787,6810,6815],{"__ignoreMap":52},[56,6493,6494],{"class":58,"line":59},[56,6495,6496],{"class":353},"\u002F\u002F service\u002Fweb\u002Fembed.go\n",[56,6498,6499,6502],{"class":58,"line":70},[56,6500,6501],{"class":249},"package",[56,6503,6504],{"class":224}," web\n",[56,6506,6507],{"class":58,"line":86},[56,6508,398],{"emptyLinePlaceholder":313},[56,6510,6511,6513],{"class":58,"line":98},[56,6512,3701],{"class":249},[56,6514,5099],{"class":66},[56,6516,6517,6520,6523],{"class":58,"line":259},[56,6518,6519],{"class":362},"    \"",[56,6521,6522],{"class":224},"embed",[56,6524,584],{"class":362},[56,6526,6527,6529,6532],{"class":58,"line":434},[56,6528,6519],{"class":362},[56,6530,6531],{"class":224},"io\u002Ffs",[56,6533,584],{"class":362},[56,6535,6536,6538,6541],{"class":58,"line":452},[56,6537,6519],{"class":362},[56,6539,6540],{"class":224},"mime",[56,6542,584],{"class":362},[56,6544,6545,6547,6550],{"class":58,"line":468},[56,6546,6519],{"class":362},[56,6548,6549],{"class":224},"net\u002Fhttp",[56,6551,584],{"class":362},[56,6553,6554,6556,6559],{"class":58,"line":474},[56,6555,6519],{"class":362},[56,6557,6558],{"class":224},"path\u002Ffilepath",[56,6560,584],{"class":362},[56,6562,6563,6565,6568],{"class":58,"line":488},[56,6564,6519],{"class":362},[56,6566,6567],{"class":224},"strings",[56,6569,584],{"class":362},[56,6571,6572],{"class":58,"line":511},[56,6573,398],{"emptyLinePlaceholder":313},[56,6575,6576,6578,6581],{"class":58,"line":520},[56,6577,6519],{"class":362},[56,6579,6580],{"class":224},"github.com\u002Fgin-gonic\u002Fgin",[56,6582,584],{"class":362},[56,6584,6585],{"class":58,"line":525},[56,6586,2659],{"class":66},[56,6588,6589],{"class":58,"line":531},[56,6590,398],{"emptyLinePlaceholder":313},[56,6592,6593],{"class":58,"line":542},[56,6594,6595],{"class":353},"\u002F\u002Fgo:embed dist\n",[56,6597,6598,6601,6604,6606,6608],{"class":58,"line":553},[56,6599,6600],{"class":249},"var",[56,6602,6603],{"class":66}," embedFS ",[56,6605,6522],{"class":224},[56,6607,2471],{"class":66},[56,6609,6610],{"class":224},"FS\n",[56,6612,6613],{"class":58,"line":558},[56,6614,398],{"emptyLinePlaceholder":313},[56,6616,6617,6619,6622,6624,6626,6628,6630],{"class":58,"line":567},[56,6618,3894],{"class":249},[56,6620,6621],{"class":224}," SPAHandler",[56,6623,5142],{"class":66},[56,6625,4522],{"class":224},[56,6627,2471],{"class":66},[56,6629,4740],{"class":224},[56,6631,67],{"class":66},[56,6633,6634,6637,6639,6642,6645,6648,6651],{"class":58,"line":572},[56,6635,6636],{"class":66},"    sub, _ ",[56,6638,3720],{"class":249},[56,6640,6641],{"class":66}," fs.",[56,6643,6644],{"class":224},"Sub",[56,6646,6647],{"class":66},"(embedFS, ",[56,6649,6650],{"class":362},"\"dist\"",[56,6652,2659],{"class":66},[56,6654,6655],{"class":58,"line":780},[56,6656,398],{"emptyLinePlaceholder":313},[56,6658,6659,6661,6663,6665,6667,6669,6671,6673,6675],{"class":58,"line":788},[56,6660,3951],{"class":249},[56,6662,4749],{"class":249},[56,6664,228],{"class":66},[56,6666,4516],{"class":2352},[56,6668,4519],{"class":249},[56,6670,4522],{"class":224},[56,6672,2471],{"class":66},[56,6674,3907],{"class":224},[56,6676,237],{"class":66},[56,6678,6679,6682,6684,6686,6689,6692,6695],{"class":58,"line":793},[56,6680,6681],{"class":66},"        p ",[56,6683,3720],{"class":249},[56,6685,4856],{"class":66},[56,6687,6688],{"class":224},"TrimPrefix",[56,6690,6691],{"class":66},"(c.Request.URL.Path, ",[56,6693,6694],{"class":362},"\"\u002F\"",[56,6696,2659],{"class":66},[56,6698,6699,6701,6704,6706,6708],{"class":58,"line":1422},[56,6700,4788],{"class":249},[56,6702,6703],{"class":66}," p ",[56,6705,4794],{"class":249},[56,6707,4797],{"class":362},[56,6709,67],{"class":66},[56,6711,6712,6715,6717],{"class":58,"line":1436},[56,6713,6714],{"class":66},"            p ",[56,6716,443],{"class":249},[56,6718,6719],{"class":362}," \"index.html\"\n",[56,6721,6722],{"class":58,"line":1443},[56,6723,4842],{"class":66},[56,6725,6726],{"class":58,"line":1448},[56,6727,398],{"emptyLinePlaceholder":313},[56,6729,6730,6733,6735,6737,6740],{"class":58,"line":1454},[56,6731,6732],{"class":66},"        data, err ",[56,6734,3720],{"class":249},[56,6736,6641],{"class":66},[56,6738,6739],{"class":224},"ReadFile",[56,6741,6742],{"class":66},"(sub, p)\n",[56,6744,6745,6747,6749,6751,6753],{"class":58,"line":1462},[56,6746,4788],{"class":249},[56,6748,4134],{"class":66},[56,6750,4137],{"class":249},[56,6752,4140],{"class":73},[56,6754,67],{"class":66},[56,6756,6757],{"class":58,"line":1470},[56,6758,6759],{"class":353},"            \u002F\u002F SPA fallback：文件不存在则返回 index.html\n",[56,6761,6762,6765,6767,6769,6771,6774,6777],{"class":58,"line":1475},[56,6763,6764],{"class":66},"            data, _ ",[56,6766,443],{"class":249},[56,6768,6641],{"class":66},[56,6770,6739],{"class":224},[56,6772,6773],{"class":66},"(sub, ",[56,6775,6776],{"class":362},"\"index.html\"",[56,6778,2659],{"class":66},[56,6780,6781],{"class":58,"line":1481},[56,6782,4842],{"class":66},[56,6784,6785],{"class":58,"line":1490},[56,6786,398],{"emptyLinePlaceholder":313},[56,6788,6790,6792,6795,6798,6801,6804,6807],{"class":58,"line":6789},33,[56,6791,4533],{"class":66},[56,6793,6794],{"class":224},"Data",[56,6796,6797],{"class":66},"(http.StatusOK, mime.",[56,6799,6800],{"class":224},"TypeByExtension",[56,6802,6803],{"class":66},"(filepath.",[56,6805,6806],{"class":224},"Ext",[56,6808,6809],{"class":66},"(p)), data)\n",[56,6811,6813],{"class":58,"line":6812},34,[56,6814,2310],{"class":66},[56,6816,6818],{"class":58,"line":6817},35,[56,6819,101],{"class":66},[586,6821,6823],{"id":6822},"_2-改造路由","2. 改造路由",[47,6825,6827],{"className":3693,"code":6826,"language":477,"meta":52,"style":52},"\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",[26,6828,6829,6834,6845,6849,6854,6869,6883,6896,6909,6922],{"__ignoreMap":52},[56,6830,6831],{"class":58,"line":59},[56,6832,6833],{"class":353},"\u002F\u002F router.go\n",[56,6835,6836,6838,6840,6843],{"class":58,"line":70},[56,6837,3701],{"class":249},[56,6839,375],{"class":362},[56,6841,6842],{"class":224},"myproject\u002Fweb",[56,6844,584],{"class":362},[56,6846,6847],{"class":58,"line":86},[56,6848,398],{"emptyLinePlaceholder":313},[56,6850,6851],{"class":58,"line":98},[56,6852,6853],{"class":353},"\u002F\u002F 替换原来的磁盘静态文件服务\n",[56,6855,6856,6859,6861,6864,6867],{"class":58,"line":259},[56,6857,6858],{"class":66},"webHandler ",[56,6860,3720],{"class":249},[56,6862,6863],{"class":66}," web.",[56,6865,6866],{"class":224},"SPAHandler",[56,6868,2544],{"class":66},[56,6870,6871,6874,6876,6878,6880],{"class":58,"line":434},[56,6872,6873],{"class":66},"router.",[56,6875,4502],{"class":224},[56,6877,228],{"class":66},[56,6879,6694],{"class":362},[56,6881,6882],{"class":66},", webHandler)\n",[56,6884,6885,6887,6889,6891,6894],{"class":58,"line":452},[56,6886,6873],{"class":66},[56,6888,4502],{"class":224},[56,6890,228],{"class":66},[56,6892,6893],{"class":362},"\"\u002Fassets\u002F*filepath\"",[56,6895,6882],{"class":66},[56,6897,6898,6900,6902,6904,6907],{"class":58,"line":468},[56,6899,6873],{"class":66},[56,6901,4502],{"class":224},[56,6903,228],{"class":66},[56,6905,6906],{"class":362},"\"\u002Ffavicon.ico\"",[56,6908,6882],{"class":66},[56,6910,6911,6913,6915,6917,6920],{"class":58,"line":474},[56,6912,6873],{"class":66},[56,6914,4502],{"class":224},[56,6916,228],{"class":66},[56,6918,6919],{"class":362},"\"\u002Ffavicon.svg\"",[56,6921,6882],{"class":66},[56,6923,6924,6926,6929],{"class":58,"line":488},[56,6925,6873],{"class":66},[56,6927,6928],{"class":224},"NoRoute",[56,6930,6931],{"class":66},"(webHandler)\n",[586,6933,6935],{"id":6934},"_3-一键构建脚本","3. 一键构建脚本",[47,6937,6939],{"className":344,"code":6938,"language":346,"meta":52,"style":52},"#!\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",[26,6940,6941,6946,6951,6955,6963,6967,6972,6982,6992,6996,7001,7012,7025,7029,7034,7042,7055,7065],{"__ignoreMap":52},[56,6942,6943],{"class":58,"line":59},[56,6944,6945],{"class":353},"#!\u002Fbin\u002Fbash\n",[56,6947,6948],{"class":58,"line":70},[56,6949,6950],{"class":353},"# build-standalone.sh\n",[56,6952,6953],{"class":58,"line":86},[56,6954,398],{"emptyLinePlaceholder":313},[56,6956,6957,6960],{"class":58,"line":98},[56,6958,6959],{"class":73},"set",[56,6961,6962],{"class":73}," -e\n",[56,6964,6965],{"class":58,"line":259},[56,6966,398],{"emptyLinePlaceholder":313},[56,6968,6969],{"class":58,"line":434},[56,6970,6971],{"class":353},"# 1. 构建前端\n",[56,6973,6974,6977,6979],{"class":58,"line":452},[56,6975,6976],{"class":224},"pnpm",[56,6978,419],{"class":362},[56,6980,6981],{"class":73}," --no-frozen-lockfile\n",[56,6983,6984,6986,6989],{"class":58,"line":468},[56,6985,2118],{"class":224},[56,6987,6988],{"class":362}," vite",[56,6990,6991],{"class":362}," build\n",[56,6993,6994],{"class":58,"line":474},[56,6995,398],{"emptyLinePlaceholder":313},[56,6997,6998],{"class":58,"line":488},[56,6999,7000],{"class":353},"# 2. 复制到嵌入目录\n",[56,7002,7003,7006,7009],{"class":58,"line":511},[56,7004,7005],{"class":224},"rm",[56,7007,7008],{"class":73}," -rf",[56,7010,7011],{"class":362}," service\u002Fweb\u002Fdist\n",[56,7013,7014,7017,7020,7023],{"class":58,"line":520},[56,7015,7016],{"class":224},"cp",[56,7018,7019],{"class":73}," -r",[56,7021,7022],{"class":362}," dist",[56,7024,7011],{"class":362},[56,7026,7027],{"class":58,"line":525},[56,7028,398],{"emptyLinePlaceholder":313},[56,7030,7031],{"class":58,"line":531},[56,7032,7033],{"class":353},"# 3. 编译 Go 二进制\n",[56,7035,7036,7039],{"class":58,"line":542},[56,7037,7038],{"class":73},"cd",[56,7040,7041],{"class":362}," service\n",[56,7043,7044,7046,7048,7050,7053],{"class":58,"line":553},[56,7045,477],{"class":224},[56,7047,493],{"class":362},[56,7049,502],{"class":73},[56,7051,7052],{"class":362}," myapp.exe",[56,7054,4620],{"class":73},[56,7056,7057,7060,7063],{"class":58,"line":558},[56,7058,7059],{"class":73},"    -ldflags=",[56,7061,7062],{"class":362},"\"-X myproject\u002Fglobal.RUNCODE=release\"",[56,7064,4620],{"class":73},[56,7066,7067],{"class":58,"line":567},[56,7068,7069],{"class":362},"    main.go\n",[11,7071,7072],{"id":7072},"踩坑记录",[586,7074,7076],{"id":7075},"坑一路由不匹配静态资源-404","坑一：路由不匹配，静态资源 404",[15,7078,7079,7080,7082,7083,7085],{},"首次实现时用了 Gin 的 ",[26,7081,6928],{}," 兜底，但部分静态资源返回 404。排查发现 ",[26,7084,6928],{}," 在某些路由未显式注册时不会按预期触发。",[15,7087,7088,7091,7092,7095,7096,7099,7100,7102],{},[31,7089,7090],{},"解决","：显式注册 ",[26,7093,7094],{},"\u002Fassets\u002F*filepath","、",[26,7097,7098],{},"\u002Ffavicon.ico"," 等路由，",[26,7101,6928],{}," 仅作为兜底。",[586,7104,7106,7107,7109,7110,7113],{"id":7105},"坑二goembed-排除-_-开头文件","坑二：",[26,7108,6474],{}," 排除 ",[26,7111,7112],{},"_"," 开头文件",[15,7115,7116,7117,7120,7121,7123,7124,2241],{},"这是最坑的。Vite 构建产出了一个 ",[26,7118,7119],{},"_plugin-vue_export-helper-xxx.js","，Go 的 ",[26,7122,6474],{}," 当模式为目录时，",[31,7125,7126,7127,7129,7130,7132],{},"硬编码排除了 ",[26,7128,7112],{}," 和 ",[26,7131,2471],{}," 开头的文件",[47,7134,7136],{"className":3693,"code":7135,"language":477,"meta":52,"style":52},"\u002F\u002F 这行会跳过 dist\u002Fassets\u002F_plugin-xxx.js\n\u002F\u002Fgo:embed dist\n",[26,7137,7138,7143],{"__ignoreMap":52},[56,7139,7140],{"class":58,"line":59},[56,7141,7142],{"class":353},"\u002F\u002F 这行会跳过 dist\u002Fassets\u002F_plugin-xxx.js\n",[56,7144,7145],{"class":58,"line":70},[56,7146,6595],{"class":353},[15,7148,7149,7150,2241],{},"错误表现：JS 文件返回的是 HTML 内容（SPA fallback），浏览器报 ",[26,7151,7152],{},"Unexpected token '\u003C'",[15,7154,7155,7157,7158,7160],{},[31,7156,7090],{},"：改 Vite 配置，去除了 ",[26,7159,7112],{}," 前缀：",[47,7162,7166],{"className":7163,"code":7164,"language":7165,"meta":52,"style":52},"language-ts shiki shiki-themes github-light github-dark","\u002F\u002F vite.config.ts\nbuild: {\n  rollupOptions: {\n    output: {\n      chunkFileNames(chunkInfo) {\n        return `assets\u002F${chunkInfo.name.replace(\u002F^_\u002F, '')}-[hash].js`\n      },\n    },\n  },\n},\n","ts",[26,7167,7168,7173,7180,7187,7194,7202,7240,7245,7250,7255],{"__ignoreMap":52},[56,7169,7170],{"class":58,"line":59},[56,7171,7172],{"class":353},"\u002F\u002F vite.config.ts\n",[56,7174,7175,7178],{"class":58,"line":70},[56,7176,7177],{"class":224},"build",[56,7179,2269],{"class":66},[56,7181,7182,7185],{"class":58,"line":86},[56,7183,7184],{"class":224},"  rollupOptions",[56,7186,2269],{"class":66},[56,7188,7189,7192],{"class":58,"line":98},[56,7190,7191],{"class":224},"    output",[56,7193,2269],{"class":66},[56,7195,7196,7199],{"class":58,"line":259},[56,7197,7198],{"class":224},"      chunkFileNames",[56,7200,7201],{"class":66},"(chunkInfo) {\n",[56,7203,7204,7206,7209,7212,7214,7217,7219,7221,7223,7225,7227,7229,7231,7233,7235,7237],{"class":58,"line":434},[56,7205,4147],{"class":249},[56,7207,7208],{"class":362}," `assets\u002F${",[56,7210,7211],{"class":66},"chunkInfo",[56,7213,2471],{"class":362},[56,7215,7216],{"class":66},"name",[56,7218,2471],{"class":362},[56,7220,2847],{"class":224},[56,7222,228],{"class":362},[56,7224,2468],{"class":362},[56,7226,2455],{"class":249},[56,7228,7112],{"class":2458},[56,7230,2468],{"class":362},[56,7232,1101],{"class":362},[56,7234,2864],{"class":362},[56,7236,3167],{"class":362},[56,7238,7239],{"class":362},"}-[hash].js`\n",[56,7241,7242],{"class":58,"line":452},[56,7243,7244],{"class":66},"      },\n",[56,7246,7247],{"class":58,"line":468},[56,7248,7249],{"class":66},"    },\n",[56,7251,7252],{"class":58,"line":474},[56,7253,7254],{"class":66},"  },\n",[56,7256,7257],{"class":58,"line":488},[56,7258,7259],{"class":66},"},\n",[15,7261,7262,7263,7265],{},"这样就避免产生 ",[26,7264,7112],{}," 前缀文件名的同时，保持构建产物的一致性和引用关系的正确性。",[586,7267,7269],{"id":7268},"坑三构建缓存导致-embed-内容未更新","坑三：构建缓存导致 embed 内容未更新",[15,7271,7272,7273,7276],{},"多次 ",[26,7274,7275],{},"go build"," 后，Go 的构建缓存可能使用了旧版本的 embed 内容，导致实际运行的文件不是最新的。",[15,7278,7279,7281,7282,7285],{},[31,7280,7090],{},"：使用 ",[26,7283,7284],{},"go build -a"," 强制重新编译所有包。",[11,7287,7288],{"id":7288},"最终效果",[47,7290,7292],{"className":344,"code":7291,"language":346,"meta":52,"style":52},"$ bash build-standalone.sh\n=== 构建成功 ===\n输出: service\u002Fmyapp.exe  41M\n",[26,7293,7294,7304,7314],{"__ignoreMap":52},[56,7295,7296,7298,7301],{"class":58,"line":59},[56,7297,2465],{"class":224},[56,7299,7300],{"class":362}," bash",[56,7302,7303],{"class":362}," build-standalone.sh\n",[56,7305,7306,7308,7311],{"class":58,"line":70},[56,7307,2443],{"class":362},[56,7309,7310],{"class":362}," 构建成功",[56,7312,7313],{"class":362}," ===\n",[56,7315,7316,7319,7322],{"class":58,"line":86},[56,7317,7318],{"class":224},"输出:",[56,7320,7321],{"class":362}," service\u002Fmyapp.exe",[56,7323,7324],{"class":362},"  41M\n",[15,7326,7327],{},"一个 41MB 的 exe，内置前端页面、API 后端、SQLite 数据库（运行时初始化），拖到哪都能跑。",[15,7329,7330],{},[31,7331,7332],{},"部署对比：",[1683,7334,7335,7348],{},[1686,7336,7337],{},[1689,7338,7339,7342,7345],{},[1692,7340,7341],{},"方式",[1692,7343,7344],{},"文件数",[1692,7346,7347],{},"操作",[1704,7349,7350,7361],{},[1689,7351,7352,7355,7358],{},[1709,7353,7354],{},"旧方案",[1709,7356,7357],{},"1 exe + 1 web 目录",[1709,7359,7360],{},"解压→保持目录结构",[1689,7362,7363,7366,7369],{},[1709,7364,7365],{},"新方案",[1709,7367,7368],{},"1 个 exe",[1709,7370,7371],{},"直接扔到服务器运行",[11,7373,287],{"id":287},[15,7375,7376,7377,7379],{},"Go 的 ",[26,7378,6474],{}," 是一个强大的工具，但有两个隐藏行为需要注意：",[121,7381,7382,7389],{},[22,7383,7384,7385,7129,7387,7132],{},"嵌入目录时自动排除 ",[26,7386,7112],{},[26,7388,2471],{},[22,7390,7391],{},"构建缓存可能导致 embed 不是最新的",[15,7393,7394],{},"对于前端+后端合体的场景，这是性价比最高的方案——不需要额外的打包工具，纯标准库搞定。",[214,7396,7397],{},"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":52,"searchDepth":70,"depth":86,"links":7399},[7400,7401,7402,7407,7413,7414],{"id":2217,"depth":70,"text":2217},{"id":6468,"depth":70,"text":6468},{"id":6482,"depth":70,"text":6482,"children":7403},[7404,7405,7406],{"id":6485,"depth":86,"text":6486},{"id":6822,"depth":86,"text":6823},{"id":6934,"depth":86,"text":6935},{"id":7072,"depth":70,"text":7072,"children":7408},[7409,7410,7412],{"id":7075,"depth":86,"text":7076},{"id":7105,"depth":86,"text":7411},"坑二：\u002F\u002Fgo:embed 排除 _ 开头文件",{"id":7268,"depth":86,"text":7269},{"id":7288,"depth":70,"text":7288},{"id":287,"depth":70,"text":287},"记录将 Vue 3 前端嵌入 Go 二进制实现单 exe 发布的完整流程，以及遇到的坑和解决方案。","\u002Fposts\u002Fgo-embed-frontend\u002Fimg\u002Fcover.jpg",{},"\u002Fposts\u002Fgo-embed-frontend","2026-06-06T16:00:00",{"title":6451,"description":7415},"posts\u002Fgo-embed-frontend\u002Findex",[1150,7423,7424,7425],"Vue","编译","埋坑","2026-06-06","SIQNL24wVAFb18wIGWSB3lNVZUQ9B76jWgQ55TASTYk",{"id":7429,"title":7430,"author":330,"body":7431,"description":8827,"difficulty":1561,"draft":309,"extension":310,"image":8828,"meta":8829,"navigation":313,"path":8830,"pinned":309,"published":8831,"seo":8832,"series":330,"seriesOrder":330,"stem":8833,"tags":8834,"updated":8831,"__hash__":8836},"posts\u002Fposts\u002Fjira-cli-intro\u002Findex.md","jira_cli — 把企业 Jira 装进命令行，AI Agent 直接调用",{"type":8,"value":7432,"toc":8806},[7433,7437,7452,7519,7522,7525,7602,7604,7637,7644,7647,7651,7763,7767,7898,7902,7970,7973,8036,8039,8041,8045,8048,8068,8071,8086,8089,8093,8099,8237,8248,8279,8283,8286,8383,8387,8390,8508,8512,8515,8579,8582,8586,8589,8657,8660,8666,8669,8703,8710,8713,8773,8775,8778,8795,8803],[11,7434,7436],{"id":7435},"jira_cli-是什么","jira_cli 是什么",[15,7438,7439,7444,7445,7129,7448,7451],{},[63,7440,7443],{"href":7441,"rel":7442},"https:\u002F\u002Fgithub.com\u002Fyourname\u002Fjira_cli",[2176],"jira_cli"," 是一个面向企业 Jira（Server \u002F Data Center）的本地命令行工具，Go 语言编写，MIT 协议开源。它同时提供 ",[31,7446,7447],{},"Cobra CLI",[31,7449,7450],{},"MCP stdio server"," 两种入口，既能当日常命令行用，又能直接接入 AI 编程工具。",[47,7453,7455],{"className":344,"code":7454,"language":346,"meta":52,"style":52},"# 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",[26,7456,7457,7462,7475,7486,7501,7505,7510],{"__ignoreMap":52},[56,7458,7459],{"class":58,"line":59},[56,7460,7461],{"class":353},"# CLI 日常使用\n",[56,7463,7464,7466,7469,7472],{"class":58,"line":70},[56,7465,7443],{"class":224},[56,7467,7468],{"class":362}," issue",[56,7470,7471],{"class":362}," search",[56,7473,7474],{"class":362}," \"project = PROJ ORDER BY updated DESC\"\n",[56,7476,7477,7479,7481,7483],{"class":58,"line":86},[56,7478,7443],{"class":224},[56,7480,7468],{"class":362},[56,7482,3674],{"class":362},[56,7484,7485],{"class":362}," PROJ-123\n",[56,7487,7488,7490,7492,7495,7498],{"class":58,"line":98},[56,7489,7443],{"class":224},[56,7491,7468],{"class":362},[56,7493,7494],{"class":362}," comment",[56,7496,7497],{"class":362}," PROJ-123",[56,7499,7500],{"class":362}," \"处理完成，请测试验证\"\n",[56,7502,7503],{"class":58,"line":259},[56,7504,398],{"emptyLinePlaceholder":313},[56,7506,7507],{"class":58,"line":434},[56,7508,7509],{"class":353},"# MCP 模式 — 给 AI 装上操作 Jira 的手\n",[56,7511,7512,7514,7516],{"class":58,"line":452},[56,7513,7443],{"class":224},[56,7515,3915],{"class":362},[56,7517,7518],{"class":73}," --user=work\n",[11,7520,7521],{"id":7521},"为什么需要它",[15,7523,7524],{},"大多数 Jira 命令行工具只解决\"开发者手动操作 Jira\"这一个场景。但 2026 年 AI 编程工具已经普及，你的 AI Agent（Claude Code、Codex、Cursor 等）也需要读写 Jira。痛点就来了：",[1683,7526,7527,7537],{},[1686,7528,7529],{},[1689,7530,7531,7534],{},[1692,7532,7533],{},"痛点",[1692,7535,7536],{},"jira_cli 怎么解",[1704,7538,7539,7547,7555,7567,7579,7590],{},[1689,7540,7541,7544],{},[1709,7542,7543],{},"企业 Jira 在内网，没有公网 API",[1709,7545,7546],{},"本地 stdio MCP server，不经过外网",[1689,7548,7549,7552],{},[1709,7550,7551],{},"AI 可能误删、误写 Jira 数据",[1709,7553,7554],{},"账号级只读策略，AI 只能查不能改",[1689,7556,7557,7560],{},[1709,7558,7559],{},"团队多人多 Jira 实例",[1709,7561,7562,7563,7566],{},"多 Profile 管理，",[26,7564,7565],{},"--user=work"," 切换",[1689,7568,7569,7572],{},[1709,7570,7571],{},"Windows 终端体验差",[1709,7573,7574,7575,7578],{},"原生 ",[26,7576,7577],{},".exe"," 构建 + 一键安装脚本",[1689,7580,7581,7584],{},[1709,7582,7583],{},"AI 生成 JQL 困难",[1709,7585,7586,7587],{},"字段元数据发现：",[26,7588,7589],{},"field list --query xxx",[1689,7591,7592,7595],{},[1709,7593,7594],{},"MCP 直接拿原始 JSON 太冗长",[1709,7596,7597,7598,7601],{},"紧凑视图 + ",[26,7599,7600],{},"--view summary"," 减少 token 消耗",[11,7603,5814],{"id":5814},[47,7605,7607],{"className":344,"code":7606,"language":346,"meta":52,"style":52},"# 从源码安装（要求 Go 1.26.3+）\ngo install github.com\u002Fyourname\u002Fjira_cli@latest\n\n# 或下载预编译二进制\n# https:\u002F\u002Fgithub.com\u002Fyourname\u002Fjira_cli\u002Freleases\n",[26,7608,7609,7614,7623,7627,7632],{"__ignoreMap":52},[56,7610,7611],{"class":58,"line":59},[56,7612,7613],{"class":353},"# 从源码安装（要求 Go 1.26.3+）\n",[56,7615,7616,7618,7620],{"class":58,"line":70},[56,7617,477],{"class":224},[56,7619,419],{"class":362},[56,7621,7622],{"class":362}," github.com\u002Fyourname\u002Fjira_cli@latest\n",[56,7624,7625],{"class":58,"line":86},[56,7626,398],{"emptyLinePlaceholder":313},[56,7628,7629],{"class":58,"line":98},[56,7630,7631],{"class":353},"# 或下载预编译二进制\n",[56,7633,7634],{"class":58,"line":259},[56,7635,7636],{"class":353},"# https:\u002F\u002Fgithub.com\u002Fyourname\u002Fjira_cli\u002Freleases\n",[15,7638,7639,7640,7643],{},"Windows 用户下载 zip 包解压后双击 ",[26,7641,7642],{},"install.bat"," 即可全局安装，自动加入 PATH。",[11,7645,7646],{"id":7646},"快速上手",[586,7648,7650],{"id":7649},"_1-配置认证","1. 配置认证",[47,7652,7654],{"className":344,"code":7653,"language":346,"meta":52,"style":52},"# 设置 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",[26,7655,7656,7661,7677,7681,7686,7706,7710,7715,7744,7748,7753],{"__ignoreMap":52},[56,7657,7658],{"class":58,"line":59},[56,7659,7660],{"class":353},"# 设置 Jira 地址\n",[56,7662,7663,7665,7668,7671,7674],{"class":58,"line":70},[56,7664,7443],{"class":224},[56,7666,7667],{"class":362}," set",[56,7669,7670],{"class":362}," base-url",[56,7672,7673],{"class":362}," https:\u002F\u002Fjira.your-company.com",[56,7675,7676],{"class":73}," --global\n",[56,7678,7679],{"class":58,"line":86},[56,7680,398],{"emptyLinePlaceholder":313},[56,7682,7683],{"class":58,"line":98},[56,7684,7685],{"class":353},"# Token 认证（推荐）\n",[56,7687,7688,7690,7692,7695,7697,7700,7702,7704],{"class":58,"line":259},[56,7689,7443],{"class":224},[56,7691,7667],{"class":362},[56,7693,7694],{"class":362}," token",[56,7696,883],{"class":249},[56,7698,7699],{"class":362},"your-toke",[56,7701,1284],{"class":66},[56,7703,892],{"class":249},[56,7705,7676],{"class":73},[56,7707,7708],{"class":58,"line":434},[56,7709,398],{"emptyLinePlaceholder":313},[56,7711,7712],{"class":58,"line":452},[56,7713,7714],{"class":353},"# 或用户名密码认证\n",[56,7716,7717,7719,7721,7723,7725,7728,7730,7732,7734,7737,7740,7742],{"class":58,"line":468},[56,7718,7443],{"class":224},[56,7720,7667],{"class":362},[56,7722,5888],{"class":362},[56,7724,883],{"class":249},[56,7726,7727],{"class":362},"usernam",[56,7729,1271],{"class":66},[56,7731,892],{"class":249},[56,7733,883],{"class":249},[56,7735,7736],{"class":362},"passwor",[56,7738,7739],{"class":66},"d",[56,7741,892],{"class":249},[56,7743,7676],{"class":73},[56,7745,7746],{"class":58,"line":474},[56,7747,398],{"emptyLinePlaceholder":313},[56,7749,7750],{"class":58,"line":488},[56,7751,7752],{"class":353},"# 验证一下\n",[56,7754,7755,7757,7760],{"class":58,"line":511},[56,7756,7443],{"class":224},[56,7758,7759],{"class":362}," user",[56,7761,7762],{"class":362}," me\n",[586,7764,7766],{"id":7765},"_2-日常使用","2. 日常使用",[47,7768,7770],{"className":344,"code":7769,"language":346,"meta":52,"style":52},"# 查自己今天的任务\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",[26,7771,7772,7777,7788,7792,7797,7807,7811,7816,7830,7847,7851,7856,7869,7873,7878],{"__ignoreMap":52},[56,7773,7774],{"class":58,"line":59},[56,7775,7776],{"class":353},"# 查自己今天的任务\n",[56,7778,7779,7781,7783,7785],{"class":58,"line":70},[56,7780,7443],{"class":224},[56,7782,7468],{"class":362},[56,7784,7471],{"class":362},[56,7786,7787],{"class":362}," \"assignee = currentUser() ORDER BY updated DESC\"\n",[56,7789,7790],{"class":58,"line":86},[56,7791,398],{"emptyLinePlaceholder":313},[56,7793,7794],{"class":58,"line":98},[56,7795,7796],{"class":353},"# 看某个任务详情\n",[56,7798,7799,7801,7803,7805],{"class":58,"line":259},[56,7800,7443],{"class":224},[56,7802,7468],{"class":362},[56,7804,3674],{"class":362},[56,7806,7485],{"class":362},[56,7808,7809],{"class":58,"line":434},[56,7810,398],{"emptyLinePlaceholder":313},[56,7812,7813],{"class":58,"line":452},[56,7814,7815],{"class":353},"# 流转状态\n",[56,7817,7818,7820,7822,7825,7827],{"class":58,"line":468},[56,7819,7443],{"class":224},[56,7821,7468],{"class":362},[56,7823,7824],{"class":362}," transitions",[56,7826,7497],{"class":362},[56,7828,7829],{"class":353},"                    # 可以转哪些\n",[56,7831,7832,7834,7836,7839,7841,7844],{"class":58,"line":474},[56,7833,7443],{"class":224},[56,7835,7468],{"class":362},[56,7837,7838],{"class":362}," do-transition",[56,7840,7497],{"class":362},[56,7842,7843],{"class":73}," 21",[56,7845,7846],{"class":353},"               # 执行流转\n",[56,7848,7849],{"class":58,"line":488},[56,7850,398],{"emptyLinePlaceholder":313},[56,7852,7853],{"class":58,"line":511},[56,7854,7855],{"class":353},"# 添加备注\n",[56,7857,7858,7860,7862,7864,7866],{"class":58,"line":520},[56,7859,7443],{"class":224},[56,7861,7468],{"class":362},[56,7863,7494],{"class":362},[56,7865,7497],{"class":362},[56,7867,7868],{"class":362}," \"已定位到问题，正在修复\"\n",[56,7870,7871],{"class":58,"line":525},[56,7872,398],{"emptyLinePlaceholder":313},[56,7874,7875],{"class":58,"line":531},[56,7876,7877],{"class":353},"# 创建任务\n",[56,7879,7880,7882,7884,7886,7889,7892,7895],{"class":58,"line":542},[56,7881,7443],{"class":224},[56,7883,7468],{"class":362},[56,7885,5774],{"class":362},[56,7887,7888],{"class":362}," PROJ",[56,7890,7891],{"class":362}," Task",[56,7893,7894],{"class":73}," --fields",[56,7896,7897],{"class":362}," @fields.json\n",[586,7899,7901],{"id":7900},"_3-配给-ai-使用","3. 配给 AI 使用",[47,7903,7905],{"className":344,"code":7904,"language":346,"meta":52,"style":52},"# 先配置一个只读账号给 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",[26,7906,7907,7912,7929,7952],{"__ignoreMap":52},[56,7908,7909],{"class":58,"line":59},[56,7910,7911],{"class":353},"# 先配置一个只读账号给 AI 用\n",[56,7913,7914,7916,7918,7920,7922,7924,7927],{"class":58,"line":70},[56,7915,7443],{"class":224},[56,7917,7667],{"class":362},[56,7919,7670],{"class":362},[56,7921,7673],{"class":362},[56,7923,880],{"class":73},[56,7925,7926],{"class":362}," ai",[56,7928,7676],{"class":73},[56,7930,7931,7933,7935,7937,7939,7942,7944,7946,7948,7950],{"class":58,"line":86},[56,7932,7443],{"class":224},[56,7934,7667],{"class":362},[56,7936,7694],{"class":362},[56,7938,883],{"class":249},[56,7940,7941],{"class":362},"toke",[56,7943,1284],{"class":66},[56,7945,892],{"class":249},[56,7947,880],{"class":73},[56,7949,7926],{"class":362},[56,7951,7676],{"class":73},[56,7953,7954,7956,7958,7961,7964,7966,7968],{"class":58,"line":98},[56,7955,7443],{"class":224},[56,7957,7667],{"class":362},[56,7959,7960],{"class":362}," readonly",[56,7962,7963],{"class":362}," on",[56,7965,880],{"class":73},[56,7967,7926],{"class":362},[56,7969,7676],{"class":73},[15,7971,7972],{},"然后在 AI 工具的 MCP 配置里加上：",[47,7974,7976],{"className":5485,"code":7975,"language":5487,"meta":52,"style":52},"{\n  \"mcpServers\": {\n    \"jira\": {\n      \"command\": \"jira_cli\",\n      \"args\": [\"mcp\", \"--user=ai\"]\n    }\n  }\n}\n",[26,7977,7978,7982,7988,7995,8007,8024,8028,8032],{"__ignoreMap":52},[56,7979,7980],{"class":58,"line":59},[56,7981,5494],{"class":66},[56,7983,7984,7986],{"class":58,"line":70},[56,7985,5499],{"class":73},[56,7987,2269],{"class":66},[56,7989,7990,7993],{"class":58,"line":86},[56,7991,7992],{"class":73},"    \"jira\"",[56,7994,2269],{"class":66},[56,7996,7997,8000,8002,8005],{"class":58,"line":98},[56,7998,7999],{"class":73},"      \"command\"",[56,8001,77],{"class":66},[56,8003,8004],{"class":362},"\"jira_cli\"",[56,8006,2006],{"class":66},[56,8008,8009,8012,8014,8017,8019,8022],{"class":58,"line":259},[56,8010,8011],{"class":73},"      \"args\"",[56,8013,2088],{"class":66},[56,8015,8016],{"class":362},"\"mcp\"",[56,8018,1101],{"class":66},[56,8020,8021],{"class":362},"\"--user=ai\"",[56,8023,805],{"class":66},[56,8025,8026],{"class":58,"line":434},[56,8027,2310],{"class":66},[56,8029,8030],{"class":58,"line":452},[56,8031,2315],{"class":66},[56,8033,8034],{"class":58,"line":468},[56,8035,101],{"class":66},[15,8037,8038],{},"这样 AI 就能查 Jira 但不能修改，既有了上下文，又不用担心误操作。",[11,8040,6296],{"id":6296},[586,8042,8044],{"id":8043},"_1-账号级只读策略","1. 账号级只读策略",[15,8046,8047],{},"这是 jira_cli 最实用的设计。给 AI Agent 配置只读账号：",[47,8049,8051],{"className":344,"code":8050,"language":346,"meta":52,"style":52},"jira_cli set readonly on --name ai\n",[26,8052,8053],{"__ignoreMap":52},[56,8054,8055,8057,8059,8061,8063,8065],{"class":58,"line":59},[56,8056,7443],{"class":224},[56,8058,7667],{"class":362},[56,8060,7960],{"class":362},[56,8062,7963],{"class":362},[56,8064,880],{"class":73},[56,8066,8067],{"class":362}," ai\n",[15,8069,8070],{},"只读模式下：",[19,8072,8073,8076,8079],{},[22,8074,8075],{},"CLI 写命令被隐藏和拒绝",[22,8077,8078],{},"MCP 的写入工具不会注册",[22,8080,8081,8082,8085],{},"环境变量 ",[26,8083,8084],{},"JIRA_READONLY=true"," 也可以临时覆盖",[15,8087,8088],{},"AI 只能查 JQL、看任务、查字段、读评论，绝对不会误改数据。需要创建\u002F更新时，另配一个有写入权限的账号由开发者自己操作。",[586,8090,8092],{"id":8091},"_2-多-profile-切换","2. 多 Profile 切换",[15,8094,8095,8096,8098],{},"一个配置文件里管理多个 Jira 账号，",[26,8097,1236],{}," 切换：",[47,8100,8102],{"className":344,"code":8101,"language":346,"meta":52,"style":52},"# 工作账号\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",[26,8103,8104,8109,8127,8150,8154,8159,8177,8200,8204,8209,8223],{"__ignoreMap":52},[56,8105,8106],{"class":58,"line":59},[56,8107,8108],{"class":353},"# 工作账号\n",[56,8110,8111,8113,8115,8117,8120,8122,8125],{"class":58,"line":70},[56,8112,7443],{"class":224},[56,8114,7667],{"class":362},[56,8116,7670],{"class":362},[56,8118,8119],{"class":362}," https:\u002F\u002Fjira.company.com",[56,8121,880],{"class":73},[56,8123,8124],{"class":362}," work",[56,8126,7676],{"class":73},[56,8128,8129,8131,8133,8135,8137,8140,8142,8144,8146,8148],{"class":58,"line":86},[56,8130,7443],{"class":224},[56,8132,7667],{"class":362},[56,8134,7694],{"class":362},[56,8136,883],{"class":249},[56,8138,8139],{"class":362},"work-toke",[56,8141,1284],{"class":66},[56,8143,892],{"class":249},[56,8145,880],{"class":73},[56,8147,8124],{"class":362},[56,8149,7676],{"class":73},[56,8151,8152],{"class":58,"line":98},[56,8153,398],{"emptyLinePlaceholder":313},[56,8155,8156],{"class":58,"line":259},[56,8157,8158],{"class":353},"# 个人账号\n",[56,8160,8161,8163,8165,8167,8170,8172,8175],{"class":58,"line":434},[56,8162,7443],{"class":224},[56,8164,7667],{"class":362},[56,8166,7670],{"class":362},[56,8168,8169],{"class":362}," https:\u002F\u002Fjira.personal.com",[56,8171,880],{"class":73},[56,8173,8174],{"class":362}," personal",[56,8176,7676],{"class":73},[56,8178,8179,8181,8183,8185,8187,8190,8192,8194,8196,8198],{"class":58,"line":452},[56,8180,7443],{"class":224},[56,8182,7667],{"class":362},[56,8184,7694],{"class":362},[56,8186,883],{"class":249},[56,8188,8189],{"class":362},"personal-toke",[56,8191,1284],{"class":66},[56,8193,892],{"class":249},[56,8195,880],{"class":73},[56,8197,8174],{"class":362},[56,8199,7676],{"class":73},[56,8201,8202],{"class":58,"line":468},[56,8203,398],{"emptyLinePlaceholder":313},[56,8205,8206],{"class":58,"line":474},[56,8207,8208],{"class":353},"# 使用时切换\n",[56,8210,8211,8213,8216,8218,8220],{"class":58,"line":488},[56,8212,7443],{"class":224},[56,8214,8215],{"class":73}," --user=work",[56,8217,7468],{"class":362},[56,8219,7471],{"class":362},[56,8221,8222],{"class":362}," \"project = PROJ\"\n",[56,8224,8225,8227,8230,8232,8234],{"class":58,"line":511},[56,8226,7443],{"class":224},[56,8228,8229],{"class":73}," --user=personal",[56,8231,7468],{"class":362},[56,8233,7471],{"class":362},[56,8235,8236],{"class":362}," \"project = SIDE\"\n",[15,8238,8239,8240,8243,8244,8247],{},"同时支持",[31,8241,8242],{},"项目级配置","（",[26,8245,8246],{},"--local","）和工作目录绑定：",[47,8249,8251],{"className":344,"code":8250,"language":346,"meta":52,"style":52},"# 在项目目录下，配置只对当前项目生效\ncd ~\u002Fprojects\u002Fbackend\njira_cli set base-url https:\u002F\u002Fjira-backend.company.com --local\n",[26,8252,8253,8258,8265],{"__ignoreMap":52},[56,8254,8255],{"class":58,"line":59},[56,8256,8257],{"class":353},"# 在项目目录下，配置只对当前项目生效\n",[56,8259,8260,8262],{"class":58,"line":70},[56,8261,7038],{"class":73},[56,8263,8264],{"class":362}," ~\u002Fprojects\u002Fbackend\n",[56,8266,8267,8269,8271,8273,8276],{"class":58,"line":86},[56,8268,7443],{"class":224},[56,8270,7667],{"class":362},[56,8272,7670],{"class":362},[56,8274,8275],{"class":362}," https:\u002F\u002Fjira-backend.company.com",[56,8277,8278],{"class":73}," --local\n",[586,8280,8282],{"id":8281},"_3-对-ai-友好的输出","3. 对 AI 友好的输出",[15,8284,8285],{},"jira_cli 在输出设计上充分考虑了 AI Agent 的 token 消耗：",[47,8287,8289],{"className":344,"code":8288,"language":346,"meta":52,"style":52},"# 紧凑视图 — 去掉 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",[26,8290,8291,8296,8312,8316,8321,8338,8342,8347,8362,8366,8371],{"__ignoreMap":52},[56,8292,8293],{"class":58,"line":59},[56,8294,8295],{"class":353},"# 紧凑视图 — 去掉 self\u002Favatar\u002F缩略图等无用字段\n",[56,8297,8298,8300,8302,8304,8306,8309],{"class":58,"line":70},[56,8299,7443],{"class":224},[56,8301,7468],{"class":362},[56,8303,3674],{"class":362},[56,8305,7497],{"class":362},[56,8307,8308],{"class":73}," --view",[56,8310,8311],{"class":362}," summary\n",[56,8313,8314],{"class":58,"line":86},[56,8315,398],{"emptyLinePlaceholder":313},[56,8317,8318],{"class":58,"line":98},[56,8319,8320],{"class":353},"# 表格输出 — 一眼看到关键信息\n",[56,8322,8323,8325,8327,8329,8332,8335],{"class":58,"line":259},[56,8324,7443],{"class":224},[56,8326,7468],{"class":362},[56,8328,7471],{"class":362},[56,8330,8331],{"class":362}," \"project = PROJ\"",[56,8333,8334],{"class":73}," --output",[56,8336,8337],{"class":362}," table\n",[56,8339,8340],{"class":58,"line":434},[56,8341,398],{"emptyLinePlaceholder":313},[56,8343,8344],{"class":58,"line":452},[56,8345,8346],{"class":353},"# JSON 输出 — 程序解析，不转义 Unicode\n",[56,8348,8349,8351,8353,8356,8358,8360],{"class":58,"line":468},[56,8350,7443],{"class":224},[56,8352,8334],{"class":73},[56,8354,8355],{"class":362}," json",[56,8357,7468],{"class":362},[56,8359,3674],{"class":362},[56,8361,7485],{"class":362},[56,8363,8364],{"class":58,"line":474},[56,8365,398],{"emptyLinePlaceholder":313},[56,8367,8368],{"class":58,"line":488},[56,8369,8370],{"class":353},"# 自定义搜索列 — 只要需要的字段\n",[56,8372,8373,8375,8377,8380],{"class":58,"line":511},[56,8374,7443],{"class":224},[56,8376,7667],{"class":362},[56,8378,8379],{"class":362}," search-columns",[56,8381,8382],{"class":362}," key,summary,fixVersions\n",[586,8384,8386],{"id":8385},"_4-字段元数据发现","4. 字段元数据发现",[15,8388,8389],{},"Jira 的 customfield 让人头疼，jira_cli 提供了完整的字段发现能力：",[47,8391,8393],{"className":344,"code":8392,"language":346,"meta":52,"style":52},"# 列出所有字段及其类型\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",[26,8394,8395,8400,8409,8413,8418,8432,8436,8441,8452,8456,8461,8478,8482,8487],{"__ignoreMap":52},[56,8396,8397],{"class":58,"line":59},[56,8398,8399],{"class":353},"# 列出所有字段及其类型\n",[56,8401,8402,8404,8407],{"class":58,"line":70},[56,8403,7443],{"class":224},[56,8405,8406],{"class":362}," field",[56,8408,2403],{"class":362},[56,8410,8411],{"class":58,"line":86},[56,8412,398],{"emptyLinePlaceholder":313},[56,8414,8415],{"class":58,"line":98},[56,8416,8417],{"class":353},"# 搜索包含特定关键词的字段\n",[56,8419,8420,8422,8424,8426,8429],{"class":58,"line":259},[56,8421,7443],{"class":224},[56,8423,8406],{"class":362},[56,8425,5709],{"class":362},[56,8427,8428],{"class":73}," --query",[56,8430,8431],{"class":362}," \"版本\"\n",[56,8433,8434],{"class":58,"line":434},[56,8435,398],{"emptyLinePlaceholder":313},[56,8437,8438],{"class":58,"line":452},[56,8439,8440],{"class":353},"# 只看自定义字段\n",[56,8442,8443,8445,8447,8449],{"class":58,"line":468},[56,8444,7443],{"class":224},[56,8446,8406],{"class":362},[56,8448,5709],{"class":362},[56,8450,8451],{"class":73}," --custom-only\n",[56,8453,8454],{"class":58,"line":474},[56,8455,398],{"emptyLinePlaceholder":313},[56,8457,8458],{"class":58,"line":488},[56,8459,8460],{"class":353},"# 创建任务前，先看看需要哪些字段\n",[56,8462,8463,8465,8467,8470,8472,8475],{"class":58,"line":511},[56,8464,7443],{"class":224},[56,8466,7468],{"class":362},[56,8468,8469],{"class":362}," create-meta",[56,8471,7888],{"class":362},[56,8473,8474],{"class":73}," --issue-type",[56,8476,8477],{"class":362}," Task\n",[56,8479,8480],{"class":58,"line":520},[56,8481,398],{"emptyLinePlaceholder":313},[56,8483,8484],{"class":58,"line":525},[56,8485,8486],{"class":353},"# 分页查看可选值\n",[56,8488,8489,8491,8493,8496,8498,8500,8503,8505],{"class":58,"line":531},[56,8490,7443],{"class":224},[56,8492,7468],{"class":362},[56,8494,8495],{"class":362}," create-meta-values",[56,8497,7888],{"class":362},[56,8499,7891],{"class":362},[56,8501,8502],{"class":362}," priority",[56,8504,8428],{"class":73},[56,8506,8507],{"class":362}," High\n",[586,8509,8511],{"id":8510},"_5-mcp-写入控制","5. MCP 写入控制",[15,8513,8514],{},"除了全局只读，MCP 还有更细粒度的控制：",[1683,8516,8517,8527],{},[1686,8518,8519],{},[1689,8520,8521,8524],{},[1692,8522,8523],{},"环境变量",[1692,8525,8526],{},"作用",[1704,8528,8529,8539,8549,8559,8569],{},[1689,8530,8531,8536],{},[1709,8532,8533],{},[26,8534,8535],{},"JIRA_CREATE_OPEN=false",[1709,8537,8538],{},"禁用创建\u002F更新工具",[1689,8540,8541,8546],{},[1709,8542,8543],{},[26,8544,8545],{},"JIRA_DISABLE_DISCLAIMER=true",[1709,8547,8548],{},"去掉 AI 自动添加的备注声明",[1689,8550,8551,8556],{},[1709,8552,8553],{},[26,8554,8555],{},"JIRA_CREATE_SUMMARY_MAX",[1709,8557,8558],{},"限制摘要长度",[1689,8560,8561,8566],{},[1709,8562,8563],{},[26,8564,8565],{},"JIRA_CREATE_DESCRIPTION_MAX",[1709,8567,8568],{},"限制描述长度",[1689,8570,8571,8576],{},[1709,8572,8573],{},[26,8574,8575],{},"JIRA_CREATE_FIELDS_MAX_BYTES",[1709,8577,8578],{},"限制创建字段的 JSON 大小",[15,8580,8581],{},"这些限制确保 AI 即使有写入权限也不会\"失控\"——例如防止 AI 往描述里塞几万字的废话。",[586,8583,8585],{"id":8584},"_6-中英双语帮助","6. 中英双语帮助",[15,8587,8588],{},"帮助信息默认中文，也支持一键切英文：",[47,8590,8592],{"className":344,"code":8591,"language":346,"meta":52,"style":52},"# 保存英文偏好\njira_cli set help-language en\n\n# 切回中文\njira_cli set help-language zh\n\n# 临时覆盖\njira_cli --lang en issue search -h\n",[26,8593,8594,8599,8611,8615,8620,8631,8635,8640],{"__ignoreMap":52},[56,8595,8596],{"class":58,"line":59},[56,8597,8598],{"class":353},"# 保存英文偏好\n",[56,8600,8601,8603,8605,8608],{"class":58,"line":70},[56,8602,7443],{"class":224},[56,8604,7667],{"class":362},[56,8606,8607],{"class":362}," help-language",[56,8609,8610],{"class":362}," en\n",[56,8612,8613],{"class":58,"line":86},[56,8614,398],{"emptyLinePlaceholder":313},[56,8616,8617],{"class":58,"line":98},[56,8618,8619],{"class":353},"# 切回中文\n",[56,8621,8622,8624,8626,8628],{"class":58,"line":259},[56,8623,7443],{"class":224},[56,8625,7667],{"class":362},[56,8627,8607],{"class":362},[56,8629,8630],{"class":362}," zh\n",[56,8632,8633],{"class":58,"line":434},[56,8634,398],{"emptyLinePlaceholder":313},[56,8636,8637],{"class":58,"line":452},[56,8638,8639],{"class":353},"# 临时覆盖\n",[56,8641,8642,8644,8647,8650,8652,8654],{"class":58,"line":468},[56,8643,7443],{"class":224},[56,8645,8646],{"class":73}," --lang",[56,8648,8649],{"class":362}," en",[56,8651,7468],{"class":362},[56,8653,7471],{"class":362},[56,8655,8656],{"class":73}," -h\n",[11,8658,8659],{"id":8659},"架构一览",[47,8661,8664],{"className":8662,"code":8663,"language":1669},[1667],"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",[26,8665,8663],{"__ignoreMap":52},[15,8667,8668],{},"设计原则清晰：",[19,8670,8671,8679,8687,8695],{},[22,8672,8673,8678],{},[31,8674,8675],{},[26,8676,8677],{},"pkg\u002Fjira"," 是纯 Jira 客户端，HTTP、认证、上下文处理都在这里",[22,8680,8681,8686],{},[31,8682,8683],{},[26,8684,8685],{},"cmd"," 只管 Cobra 解析和呈现",[22,8688,8689,8694],{},[31,8690,8691],{},[26,8692,8693],{},"internal\u002Ftools\u002Fjira_tools"," 只管 MCP schema 验证和结果适配",[22,8696,8697,8702],{},[31,8698,8699],{},[26,8700,8701],{},"internal\u002Fconfig"," 拥有 Profile 的全部逻辑，不分散到 cmd",[15,8704,8705,8706,8709],{},"每一层职责单一，新增 Jira 能力时按 ",[26,8707,8708],{},"endpoints.go → Client 方法 → CLI 命令 → MCP tool → 策略注册"," 的路径走就行。",[11,8711,8712],{"id":8712},"和已有方案的对比",[1683,8714,8715,8727],{},[1686,8716,8717],{},[1689,8718,8719,8721,8724],{},[1692,8720,3025],{},[1692,8722,8723],{},"适合场景",[1692,8725,8726],{},"jira_cli 的区别",[1704,8728,8729,8740,8751,8762],{},[1689,8730,8731,8734,8737],{},[1709,8732,8733],{},"Atlassian Rovo MCP Server",[1709,8735,8736],{},"Jira Cloud、官方 OAuth",[1709,8738,8739],{},"jira_cli 是本地 stdio，适合内网 Server\u002FDC",[1689,8741,8742,8745,8748],{},[1709,8743,8744],{},"go-jira \u002F jira-cli 社区版",[1709,8746,8747],{},"纯命令行管理",[1709,8749,8750],{},"jira_cli 同时提供 MCP server，面向 AI 工作流",[1689,8752,8753,8756,8759],{},[1709,8754,8755],{},"直接调 REST API",[1709,8757,8758],{},"自定义集成",[1709,8760,8761],{},"jira_cli 封装了认证、只读策略、字段元数据",[1689,8763,8764,8767,8770],{},[1709,8765,8766],{},"Jira 网页",[1709,8768,8769],{},"日常使用",[1709,8771,8772],{},"jira_cli 把 Jira 变成终端里的一行命令",[11,8774,287],{"id":287},[15,8776,8777],{},"如果你的团队满足以下任一条件，jira_cli 值得一试：",[19,8779,8780,8783,8786,8789,8792],{},[22,8781,8782],{},"企业 Jira 部署在内网，想接入 AI 编程工具",[22,8784,8785],{},"想给 AI Agent 只读权限来查 Jira 上下文",[22,8787,8788],{},"需要命令行快速查看\u002F操作 Jira，不想切浏览器",[22,8790,8791],{},"需要管理多个 Jira 实例和多套账号",[22,8793,8794],{},"Windows 用户想要无摩擦的 CLI 体验",[15,8796,8797,8798,8802],{},"GitHub：",[63,8799,8801],{"href":7441,"rel":8800},[2176],"github.com\u002Fyourname\u002Fjira_cli"," | MIT 协议 | Go 1.26.3+",[214,8804,8805],{},"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":52,"searchDepth":70,"depth":86,"links":8807},[8808,8809,8810,8811,8816,8824,8825,8826],{"id":7435,"depth":70,"text":7436},{"id":7521,"depth":70,"text":7521},{"id":5814,"depth":70,"text":5814},{"id":7646,"depth":70,"text":7646,"children":8812},[8813,8814,8815],{"id":7649,"depth":86,"text":7650},{"id":7765,"depth":86,"text":7766},{"id":7900,"depth":86,"text":7901},{"id":6296,"depth":70,"text":6296,"children":8817},[8818,8819,8820,8821,8822,8823],{"id":8043,"depth":86,"text":8044},{"id":8091,"depth":86,"text":8092},{"id":8281,"depth":86,"text":8282},{"id":8385,"depth":86,"text":8386},{"id":8510,"depth":86,"text":8511},{"id":8584,"depth":86,"text":8585},{"id":8659,"depth":70,"text":8659},{"id":8712,"depth":70,"text":8712},{"id":287,"depth":70,"text":287},"介绍 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":7430,"description":8827},"posts\u002Fjira-cli-intro\u002Findex",[8835,6446,5661,1150,5663,6447],"Jira","vNH6sF2mMFmSjbHoEI2OqmYCgpFmaqyKt-MUfcQqXrU",{"id":8838,"title":8839,"author":330,"body":8840,"description":9384,"difficulty":1561,"draft":309,"extension":310,"image":9385,"meta":9386,"navigation":313,"path":9387,"pinned":309,"published":9388,"seo":9389,"series":9390,"seriesOrder":59,"stem":9391,"tags":9392,"updated":9395,"__hash__":9396},"posts\u002Fposts\u002Fsublog-evolution\u002Findex.md","尼克的小窝 架构变迁：从纯静态到前后端分离",{"type":8,"value":8841,"toc":9364},[8842,8846,8849,8860,8864,8868,8874,8878,8909,8912,8926,8930,8933,9001,9005,9008,9014,9018,9028,9058,9061,9065,9073,9076,9120,9124,9245,9249,9256,9260,9266,9272,9276,9279,9330,9334,9341,9348,9354,9357,9359],[11,8843,8845],{"id":8844},"写在前面","📝 写在前面",[15,8847,8848],{},"SuBlog 最初只是一个简单的技术博客——Nuxt 3 静态生成，Markdown 写文章，部署到静态托管完事。但随着功能需求的增长，纯静态的架构开始显露出局限性。",[15,8850,8851,8852,8855,8856,8859],{},"这篇文章记录从 ",[31,8853,8854],{},"纯静态"," 到 ",[31,8857,8858],{},"前后端分离"," 的演进过程，带你了解每一步背后的思考。",[11,8861,8863],{"id":8862},"第一代纯静态nuxt-3-nuxtcontent","📦 第一代：纯静态（Nuxt 3 + @nuxt\u002Fcontent）",[586,8865,8867],{"id":8866},"️-架构图","🏗️ 架构图",[47,8869,8872],{"className":8870,"code":8871,"language":1669,"meta":52},[1667],"┌─────────────────────────────────────┐\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",[26,8873,8871],{"__ignoreMap":52},[586,8875,8877],{"id":8876},"做了什么","🔧 做了什么",[19,8879,8880,8889,8894,8899],{},[22,8881,8882,1527,8884,1527,8886,8888],{},[31,8883,1843],{},[31,8885,1853],{},[31,8887,1863],{}," 搭建前端",[22,8890,8891,8893],{},[31,8892,1873],{}," 管理 Markdown 文章，编译时生成静态页面",[22,8895,8896,8898],{},[31,8897,1893],{}," 嵌入评论，依赖 GitHub Discussions",[22,8900,8901,8902,8905,8906,8908],{},"部署通过 ",[26,8903,8904],{},"nuxt generate"," 输出 ",[26,8907,2130],{},"，丢到 Nginx 或 Vercel",[586,8910,8911],{"id":3028},"✅ 优点",[19,8913,8914,8920,8923],{},[22,8915,8916,8917,8919],{},"部署极简，一个 ",[26,8918,8904],{}," 搞定",[22,8921,8922],{},"加载飞快，全是 HTML，没有服务端开销",[22,8924,8925],{},"无需后端，零运维成本",[586,8927,8929],{"id":8928},"局限","❌ 局限",[15,8931,8932],{},"随着时间的推移，纯静态的问题越来越明显：",[1683,8934,8935,8944],{},[1686,8936,8937],{},[1689,8938,8939,8941],{},[1692,8940,3322],{},[1692,8942,8943],{},"具体表现",[1704,8945,8946,8957,8968,8979,8990],{},[1689,8947,8948,8954],{},[1709,8949,8950,8951],{},"🐢 ",[31,8952,8953],{},"内容更新慢",[1709,8955,8956],{},"改一篇文章要重新构建、重新部署，不能实时发布",[1689,8958,8959,8965],{},[1709,8960,8961,8962],{},"👤 ",[31,8963,8964],{},"没有用户体系",[1709,8966,8967],{},"无法区分读者和管理员，所有内容对所有人一致可见",[1689,8969,8970,8976],{},[1709,8971,8972,8973],{},"🔒 ",[31,8974,8975],{},"评论受限",[1709,8977,8978],{},"Giscus 需要 GitHub 账号才能评论，门槛高",[1689,8980,8981,8987],{},[1709,8982,8983,8984],{},"🚫 ",[31,8985,8986],{},"无法做动态功能",[1709,8988,8989],{},"点赞、收藏、阅读统计、个性化推荐……统统做不了",[1689,8991,8992,8998],{},[1709,8993,8994,8995],{},"🔗 ",[31,8996,8997],{},"前端锁死",[1709,8999,9000],{},"被 Nuxt\u002FVue 生态绑定，想换技术栈得重写",[11,9002,9004],{"id":9003},"第二代前后端分离react-go","⚡ 第二代：前后端分离（React + Go）",[586,9006,8867],{"id":9007},"️-架构图-1",[47,9009,9012],{"className":9010,"code":9011,"language":1669,"meta":52},[1667],"┌───────────────────────┐       ┌────────────────────┐\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",[26,9013,9011],{"__ignoreMap":52},[586,9015,9017],{"id":9016},"️-前端sublog-react","🖥️ 前端：sublog-react",[15,9019,9020,9021,9023,9024,9027],{},"选用 ",[31,9022,1700],{},"（App Router） + ",[31,9025,9026],{},"React 19","，理由是：",[19,9029,9030,9037,9044,9051],{},[22,9031,9032,9033,9036],{},"🎨 ",[31,9034,9035],{},"React 生态","——组件库成熟，开发效率高",[22,9038,9039,9040,9043],{},"📐 ",[31,9041,9042],{},"App Router","——支持布局嵌套，页面组织清晰",[22,9045,9046,9047,9050],{},"📦 ",[31,9048,9049],{},"静态导出","——仍可部署到静态托管",[22,9052,9053,9054,9057],{},"⚙️ ",[31,9055,9056],{},"运行时配置","——API 地址可动态注入，切换环境无需重新构建",[15,9059,9060],{},"前端不再直接读取 Markdown 文件，而是通过 HTTP API 获取文章数据。静态导出 + 客户端数据获取的组合，既保留了首屏加载速度，又实现了内容的动态更新。",[586,9062,9064],{"id":9063},"️-后端sublog-s","⚙️ 后端：sublog-s",[15,9066,9067,9068,1527,9070,9072],{},"后端使用 ",[31,9069,1150],{},[31,9071,5662],{},"，按业务逻辑分层组织代码。",[15,9074,9075],{},"核心技术选型：",[1683,9077,9078,9086],{},[1686,9079,9080],{},[1689,9081,9082,9084],{},[1692,9083,1831],{},[1692,9085,1834],{},[1704,9087,9088,9098,9109],{},[1689,9089,9090,9095],{},[1709,9091,9092,9093],{},"🚀 ",[31,9094,5662],{},[1709,9096,9097],{},"HTTP 框架",[1689,9099,9100,9106],{},[1709,9101,9102,9103],{},"🗄️ ",[31,9104,9105],{},"GORM",[1709,9107,9108],{},"ORM，数据库操作",[1689,9110,9111,9117],{},[1709,9112,9113,9114],{},"🔐 ",[31,9115,9116],{},"JWT",[1709,9118,9119],{},"用户认证",[11,9121,9123],{"id":9122},"前后对比","📊 前后对比",[1683,9125,9126,9139],{},[1686,9127,9128],{},[1689,9129,9130,9133,9136],{},[1692,9131,9132],{},"维度",[1692,9134,9135],{},"第一代（纯静态）",[1692,9137,9138],{},"第二代（前后端分离）",[1704,9140,9141,9153,9165,9179,9192,9205,9219,9231],{},[1689,9142,9143,9147,9150],{},[1709,9144,9032,9145],{},[31,9146,1797],{},[1709,9148,9149],{},"Nuxt 3 (Vue)",[1709,9151,9152],{},"Next.js (React 19)",[1689,9154,9155,9160,9162],{},[1709,9156,9157,9158],{},"🖥️ ",[31,9159,5664],{},[1709,9161,1740],{},[1709,9163,9164],{},"Go + Gin",[1689,9166,9167,9173,9176],{},[1709,9168,9169,9170],{},"📄 ",[31,9171,9172],{},"内容来源",[1709,9174,9175],{},"Markdown 文件",[1709,9177,9178],{},"API 数据库",[1689,9180,9181,9186,9189],{},[1709,9182,9092,9183],{},[31,9184,9185],{},"发布方式",[1709,9187,9188],{},"全量重新构建",[1709,9190,9191],{},"API 实时发布",[1689,9193,9194,9199,9202],{},[1709,9195,8961,9196],{},[31,9197,9198],{},"用户系统",[1709,9200,9201],{},"❌ 无",[1709,9203,9204],{},"✅ 注册\u002F登录",[1689,9206,9207,9213,9216],{},[1709,9208,9209,9210],{},"💬 ",[31,9211,9212],{},"评论",[1709,9214,9215],{},"Giscus（需 GitHub）",[1709,9217,9218],{},"自有评论系统（规划中）",[1689,9220,9221,9225,9228],{},[1709,9222,9046,9223],{},[31,9224,2105],{},[1709,9226,9227],{},"1 个静态目录",[1709,9229,9230],{},"前端静态 + 后端二进制",[1689,9232,9233,9239,9242],{},[1709,9234,9235,9236],{},"⚡ ",[31,9237,9238],{},"更新体验",[1709,9240,9241],{},"改文章 → 构建 → 部署",[1709,9243,9244],{},"改文章 → 立即生效",[11,9246,9248],{"id":9247},"新功能注册账号","🎉 新功能：注册账号",[15,9250,9251,9252,9255],{},"伴随着架构升级，",[31,9253,9254],{},"用户账号系统","也上线了。",[586,9257,9259],{"id":9258},"目前支持","📋 目前支持",[15,9261,9262,9263,119],{},"现在你可以在尼克的小窝 ",[31,9264,9265],{},"注册一个账号",[47,9267,9270],{"className":9268,"code":9269,"language":1669},[1667],"1. ✏️ 填写用户名、邮箱、密码\n2. 📧 查收激活邮件\n3. 🔗 点击链接激活账号\n4. 🚪 登录后查看和管理个人信息\n",[26,9271,9269],{"__ignoreMap":52},[11,9273,9275],{"id":9274},"️-后续计划","🗓️ 后续计划",[15,9277,9278],{},"正在开发或规划中的功能：",[19,9280,9282,9288,9294,9300,9306,9312,9318,9324],{"className":9281},[2138],[22,9283,9285,9287],{"className":9284},[2142],[2144,9286],{"checked":313,"disabled":313,"type":2146}," 用户注册\u002F登录（已上线 🎉）",[22,9289,9291,9293],{"className":9290},[2142],[2144,9292],{"checked":313,"disabled":313,"type":2146}," 个人资料编辑（已上线 🎉）",[22,9295,9297,9299],{"className":9296},[2142],[2144,9298],{"disabled":313,"type":2146}," 💬 站内评论系统",[22,9301,9303,9305],{"className":9302},[2142],[2144,9304],{"disabled":313,"type":2146}," 🔖 文章收藏",[22,9307,9309,9311],{"className":9308},[2142],[2144,9310],{"disabled":313,"type":2146}," 📚 阅读历史",[22,9313,9315,9317],{"className":9314},[2142],[2144,9316],{"disabled":313,"type":2146}," 👍 文章点赞",[22,9319,9321,9323],{"className":9320},[2142],[2144,9322],{"disabled":313,"type":2146}," 🔔 关注通知",[22,9325,9327,9329],{"className":9326},[2142],[2144,9328],{"disabled":313,"type":2146}," 🎨 个人首页定制",[11,9331,9333],{"id":9332},"结语","💡 结语",[15,9335,9336,9337,9340],{},"从 Nuxt 3 纯静态到 React + Go 前后端分离，SuBlog 的架构变迁本质上是一个",[31,9338,9339],{},"功能驱动","的过程。静态博客有它的位置——写写文章、展示内容完全够用。但当你想做的不仅仅是\"展示\"时，后端的能力就不可或缺了。",[15,9342,9343,9344,9347],{},"这次重构最大的感触是：",[31,9345,9346],{},"架构选型不是越复杂越好，而是刚好够用最好","。纯静态阶段用 Nuxt 3 一点问题没有，直到功能需求超出了静态的边界，才自然推动到前后端分离。",[15,9349,9350,9351],{},"如果你也在纠结\"要不要上后端\"，不妨先问问自己：",[31,9352,9353],{},"你要做的是\"展示内容\"还是\"提供服务\"？",[15,9355,9356],{},"前者，静态就够了；后者，来吧，后端欢迎你。😄",[2180,9358],{},[15,9360,9361],{},[31,9362,9363],{},"🎉 目前注册功能已经可以用了，后续更多好玩的功能正在路上。部分功能可能需要登录才能体验，快注册一个账号吧！",{"title":52,"searchDepth":70,"depth":86,"links":9365},[9366,9367,9373,9378,9379,9382,9383],{"id":8844,"depth":70,"text":8845},{"id":8862,"depth":70,"text":8863,"children":9368},[9369,9370,9371,9372],{"id":8866,"depth":86,"text":8867},{"id":8876,"depth":86,"text":8877},{"id":3028,"depth":86,"text":8911},{"id":8928,"depth":86,"text":8929},{"id":9003,"depth":70,"text":9004,"children":9374},[9375,9376,9377],{"id":9007,"depth":86,"text":8867},{"id":9016,"depth":86,"text":9017},{"id":9063,"depth":86,"text":9064},{"id":9122,"depth":70,"text":9123},{"id":9247,"depth":70,"text":9248,"children":9380},[9381],{"id":9258,"depth":86,"text":9259},{"id":9274,"depth":70,"text":9275},{"id":9332,"depth":70,"text":9333},"记录尼克的小窝从 Nuxt 3 纯静态博客演进到 React + Go 前后端分离架构的全过程，包括架构设计的思考、技术选型的变化，以及新增的账号注册功能。","\u002Fposts\u002Fsublog-evolution\u002Fimg\u002Fcover.jpg",{},"\u002Fposts\u002Fsublog-evolution","2026-07-23T12:00:00",{"title":8839,"description":9384},"尼克的小窝 开发记录","posts\u002Fsublog-evolution\u002Findex",[9393,9394,1700,1150],"架构","React","2026-07-23","VMQ6A2K8GQ5t3DSlezWPd3KgXnmDVMn9QVeaI28Hcp8",{"id":9398,"title":9399,"author":330,"body":9400,"description":10563,"difficulty":1561,"draft":309,"extension":310,"image":10564,"meta":10565,"navigation":313,"path":10566,"pinned":309,"published":10567,"seo":10568,"series":330,"seriesOrder":330,"stem":10569,"tags":10570,"updated":10574,"__hash__":10575},"posts\u002Fposts\u002Fip-geolocation-guide\u002Findex.md","IP 属地是怎么查出来的？本地查询与在线查询方案对比",{"type":8,"value":9401,"toc":10539},[9402,9404,9410,9413,9424,9434,9438,9444,9457,9463,9474,9481,9484,9491,9495,9506,9510,9521,9524,9528,9534,9540,9543,9547,9554,9572,9575,9578,9582,9613,9652,9658,9662,9801,9837,9846,9850,9890,9895,9899,9910,9913,9947,9950,9954,9957,9961,10152,10156,10239,10243,10250,10357,10361,10370,10456,10459,10462,10498,10501,10504,10512,10533,10536],[11,9403,8844],{"id":8844},[15,9405,9406,9407],{},"很多网站会在「登录记录」「安全中心」里显示你的登录 IP 归属地，比如「福建·福州 · 中国移动」。你可能好奇过：",[31,9408,9409],{},"服务器是怎么根据一个 IP 就判断出我在哪个城市、哪个运营商的？",[15,9411,9412],{},"这篇科普文章讲清楚三件事：",[121,9414,9415,9418,9421],{},[22,9416,9417],{},"IP 属地到底查的是什么；",[22,9419,9420],{},"纯本地查询为什么能做到毫秒级且不联网；",[22,9422,9423],{},"几种主流查询方案的优缺点对比，方便你按场景选型。",[9425,9426,9427],"blockquote",{},[15,9428,9429,9430,9433],{},"说明：文中用到的 IP 均为 RFC 5737 保留的文档示例段（TEST-NET-2，",[26,9431,9432],{},"198.51.100.0\u002F24","），不会对应任何真实地址。",[11,9435,9437],{"id":9436},"先想清楚ip-属地查的是什么","先想清楚：IP 属地查的是什么",[15,9439,9440,9441,2241],{},"一个常见误区是「每个 IP 对应一个具体地址」。实际上 IP 属地的本质是 ",[31,9442,9443],{},"IP 段与归属地的映射",[15,9445,9446,9447,119],{},"运营商和机构在申请 IP 时，是按",[31,9448,9449,9450,9456],{},"网段（CIDR）",[31,9451,9452,9453,9455],{},"成块分配的。比如 ",[26,9454,9432],{}," 这一个段，一整块都归属同一地区、同一运营商。所以归属地数据天然可以","按段存储",[47,9458,9461],{"className":9459,"code":9460,"language":1669,"meta":52},[1667],"IP 起始          IP 结束         归属地\n────────────────────────────────────────\n198.51.100.0     198.51.100.255  中国|福建|福州|移动\n198.51.101.0     198.51.101.255  中国|浙江|杭州|电信\n",[26,9462,9460],{"__ignoreMap":52},[15,9464,9465,9466,9469,9470,9473],{},"全球公网 IP 约有 42 亿个，但",[31,9467,9468],{},"网段只有几十万条","。正因为存的是「段」而不是「每个 IP」，完整的归属地数据文件才只有几 MB（ip2region 的 ",[26,9471,9472],{},".xdb"," 约 11MB）。这是「本地离线查询」可行的第一前提。",[15,9475,9476,9477,9480],{},"所以，",[31,9478,9479],{},"IP 属地从来不是精确到街道的定位","，它返回的是段级的「省｜市｜运营商」粗粒度信息。",[11,9482,9483],{"id":9483},"纯本地查询的原理",[15,9485,9486,9487,9490],{},"以开源的 ",[31,9488,9489],{},"ip2region"," 为例，它的查询完全在本地完成，不联网、不请求第三方，几十微秒到毫秒级返回。",[586,9492,9494],{"id":9493},"第一步把-ip-变成整数","第一步：把 IP 变成整数",[15,9496,9497,9498,9501,9502,9505],{},"以 ",[26,9499,9500],{},"198.51.100.42"," 为例，它会转成一个 32 位整数 ",[26,9503,9504],{},"0xC633642A","。IP 的四个数字本质就是 4 个字节，拼起来就是一个整数，这一步没有任何玄学。",[586,9507,9509],{"id":9508},"第二步二分查找","第二步：二分查找",[15,9511,9512,9513,9516,9517,9520],{},"在按起始 IP 排序的段列表里做",[31,9514,9515],{},"二分查找","，找到满足 ",[26,9518,9519],{},"段的起始IP ≤ 目标整数 ≤ 段的结束IP"," 的那一段。",[15,9522,9523],{},"二分查找的复杂度是 O(log N)，N 是几十万条，所以很快——这就是「不靠网络也能毫秒返回」的核心。",[586,9525,9527],{"id":9526},"第三步xdb-文件的两级结构","第三步：.xdb 文件的两级结构",[15,9529,9530,9531,9533],{},"ip2region 的 ",[26,9532,9472],{}," 不是简单顺序文件，内部分两个区，进一步加速：",[47,9535,9538],{"className":9536,"code":9537,"language":1669,"meta":52},[1667],"┌─────────────────────────────┐\n│  索引区 (Index)             │  \u002F\u002F 每段一条定长记录：startIP \u002F endIP \u002F 数据区指针 + 数据长度\n│  …… 二分快速定位段 ……       │\n├─────────────────────────────┤\n│  数据区 (Data)              │  \u002F\u002F 真正存放各段的地点字符串\n│  ……                         │\n└─────────────────────────────┘\n",[26,9539,9537],{"__ignoreMap":52},[15,9541,9542],{},"先二分索引区定位到「是哪一段」，再按指针去数据区读出地点字符串，一次磁盘读就能拿到结果。",[586,9544,9546],{"id":9545},"为什么离线数据不会很快过期","为什么「离线数据」不会很快过期",[15,9548,9549,9550,9553],{},"IP 归属取决于",[31,9551,9552],{},"注册机构和运营商的分配","，一个网段通常长期稳定属于某地区、某运营商。所以：",[19,9555,9556,9559,9569],{},[22,9557,9558],{},"下载一次数据文件，可以用很久；",[22,9560,9561,9562,9568],{},"归属偶尔变化时，",[31,9563,9564,9565,9567],{},"每月更新一次 ",[26,9566,9472],{}," 文件","即可（替换文件、不用重启服务，见下文实战）；",[22,9570,9571],{},"数据源来自 IANA\u002FAPNIC 等机构的分配表与公开归属信息，不是实时探测。",[11,9573,9574],{"id":9574},"主流查询方案对比",[15,9576,9577],{},"实际开发中，查询 IP 属地主要有三条路线。",[586,9579,9581],{"id":9580},"方案一ip2region纯本地离线推荐","方案一：ip2region（纯本地离线，推荐）",[47,9583,9585],{"className":3693,"code":9584,"language":477,"meta":52,"style":52},"\u002F\u002F Go 中的用法（github.com\u002Flionsoul2014\u002Fip2region\u002Fbinding\u002Fgolang）\nregion, err := searcher.SearchByStr(ip)\n\u002F\u002F 返回类似 \"中国|福建省|福州市|移动\"\n",[26,9586,9587,9592,9608],{"__ignoreMap":52},[56,9588,9589],{"class":58,"line":59},[56,9590,9591],{"class":353},"\u002F\u002F Go 中的用法（github.com\u002Flionsoul2014\u002Fip2region\u002Fbinding\u002Fgolang）\n",[56,9593,9594,9597,9599,9602,9605],{"class":58,"line":70},[56,9595,9596],{"class":66},"region, err ",[56,9598,3720],{"class":249},[56,9600,9601],{"class":66}," searcher.",[56,9603,9604],{"class":224},"SearchByStr",[56,9606,9607],{"class":66},"(ip)\n",[56,9609,9610],{"class":58,"line":86},[56,9611,9612],{"class":353},"\u002F\u002F 返回类似 \"中国|福建省|福州市|移动\"\n",[1683,9614,9615,9623],{},[1686,9616,9617],{},[1689,9618,9619,9621],{},[1692,9620,3028],{},[1692,9622,3031],{},[1704,9624,9625,9633,9641],{},[1689,9626,9627,9630],{},[1709,9628,9629],{},"免费、无网络依赖、无 QPS 限制",[1709,9631,9632],{},"段级粒度，个别段可能显示到相邻地区",[1689,9634,9635,9638],{},[1709,9636,9637],{},"毫秒级查询，永不因外部服务挂掉",[1709,9639,9640],{},"数据按月更新，最新分配的段可能短暂缺失",[1689,9642,9643,9646],{},[1709,9644,9645],{},"数据文件可随版本更新，不重启",[1709,9647,9648,9649],{},"只返回省市区+运营商，无经纬度；",[31,9650,9651],{},"仅支持 IPv4",[15,9653,9654,9657],{},[31,9655,9656],{},"适用","：博客\u002F后台的「登录记录显示归属地」这类展示用途。",[586,9659,9661],{"id":9660},"方案二在线-apiip-api-高德-腾讯位置服务","方案二：在线 API（ip-api \u002F 高德 \u002F 腾讯位置服务）",[47,9663,9665],{"className":3693,"code":9664,"language":477,"meta":52,"style":52},"\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",[26,9666,9667,9672,9700,9710,9741,9757,9770,9780,9784,9796],{"__ignoreMap":52},[56,9668,9669],{"class":58,"line":59},[56,9670,9671],{"class":353},"\u002F\u002F 以 ip-api.com 为例（免费版约 45 次\u002F分钟，非商用）\n",[56,9673,9674,9677,9679,9681,9684,9687,9690,9693,9695,9697],{"class":58,"line":70},[56,9675,9676],{"class":66},"ctx, cancel ",[56,9678,3720],{"class":249},[56,9680,5193],{"class":66},[56,9682,9683],{"class":224},"WithTimeout",[56,9685,9686],{"class":66},"(context.",[56,9688,9689],{"class":224},"Background",[56,9691,9692],{"class":66},"(), ",[56,9694,2299],{"class":73},[56,9696,3926],{"class":249},[56,9698,9699],{"class":66},"time.Second)\n",[56,9701,9702,9705,9708],{"class":58,"line":86},[56,9703,9704],{"class":249},"defer",[56,9706,9707],{"class":224}," cancel",[56,9709,2544],{"class":66},[56,9711,9712,9715,9717,9720,9723,9726,9729,9731,9734,9736,9739],{"class":58,"line":98},[56,9713,9714],{"class":66},"req, _ ",[56,9716,3720],{"class":249},[56,9718,9719],{"class":66}," http.",[56,9721,9722],{"class":224},"NewRequestWithContext",[56,9724,9725],{"class":66},"(ctx, ",[56,9727,9728],{"class":362},"\"GET\"",[56,9730,1101],{"class":66},[56,9732,9733],{"class":362},"\"https:\u002F\u002Fip-api.com\u002Fjson\u002F198.51.100.42\"",[56,9735,1101],{"class":66},[56,9737,9738],{"class":73},"nil",[56,9740,2659],{"class":66},[56,9742,9743,9746,9748,9751,9754],{"class":58,"line":259},[56,9744,9745],{"class":66},"resp, err ",[56,9747,3720],{"class":249},[56,9749,9750],{"class":66}," http.DefaultClient.",[56,9752,9753],{"class":224},"Do",[56,9755,9756],{"class":66},"(req)\n",[56,9758,9759,9762,9764,9766,9768],{"class":58,"line":434},[56,9760,9761],{"class":249},"if",[56,9763,4134],{"class":66},[56,9765,4137],{"class":249},[56,9767,4140],{"class":73},[56,9769,67],{"class":66},[56,9771,9772,9774,9777],{"class":58,"line":452},[56,9773,3951],{"class":249},[56,9775,9776],{"class":362}," \"未知\"",[56,9778,9779],{"class":353}," \u002F\u002F 断网\u002F超时降级：展示「未知」而不是报错\n",[56,9781,9782],{"class":58,"line":468},[56,9783,101],{"class":66},[56,9785,9786,9788,9791,9794],{"class":58,"line":474},[56,9787,9704],{"class":249},[56,9789,9790],{"class":66}," resp.Body.",[56,9792,9793],{"class":224},"Close",[56,9795,2544],{"class":66},[56,9797,9798],{"class":58,"line":488},[56,9799,9800],{"class":353},"\u002F\u002F 解析 JSON，status != \"success\" 时同样降级\n",[1683,9802,9803,9811],{},[1686,9804,9805],{},[1689,9806,9807,9809],{},[1692,9808,3028],{},[1692,9810,3031],{},[1704,9812,9813,9821,9829],{},[1689,9814,9815,9818],{},[1709,9816,9817],{},"数据最新、可能更精确",[1709,9819,9820],{},"有频率限制（免费版限 QPS）",[1689,9822,9823,9826],{},[1709,9824,9825],{},"部分服务还返回经纬度\u002F时区",[1709,9827,9828],{},"依赖第三方，断网\u002F被墙\u002F限流时拿不到",[1689,9830,9831,9834],{},[1709,9832,9833],{},"无需维护数据文件",[1709,9835,9836],{},"每次查询都要发一次 HTTP 请求",[15,9838,9839,9841,9842,9845],{},[31,9840,9656],{},"：对精度要求高、且能接受外部依赖与限流的场景。",[31,9843,9844],{},"务必加超时和降级","，否则第三方一抖，你自己的页面就卡住。",[586,9847,9849],{"id":9848},"方案三maxmind-geolite2离线-mmdb","方案三：MaxMind GeoLite2（离线 .mmdb）",[1683,9851,9852,9860],{},[1686,9853,9854],{},[1689,9855,9856,9858],{},[1692,9857,3028],{},[1692,9859,3031],{},[1704,9861,9862,9870,9882],{},[1689,9863,9864,9867],{},[1709,9865,9866],{},"全球数据，覆盖广、精度较好",[1709,9868,9869],{},"免费但需注册账号并遵守 EULA；商用需 GeoIP2 商业授权",[1689,9871,9872,9879],{},[1709,9873,9874,9875,9878],{},"标准 ",[26,9876,9877],{},".mmdb"," 格式，各语言都有库，支持 IPv6",[1709,9880,9881],{},"国内 IP 的省市粒度有时不如 ip2region 细",[1689,9883,9884,9887],{},[1709,9885,9886],{},"离线查询",[1709,9888,9889],{},"数据同样要定期更新",[15,9891,9892,9894],{},[31,9893,9656],{},"：面向全球用户的场景。",[11,9896,9898],{"id":9897},"ipv6本地离线方案的一个盲点","IPv6：本地离线方案的一个盲点",[15,9900,9901,9902,9905,9906,9909],{},"前面聊的都是 IPv4（32 位）。但家宽和移动网络越来越多地",[31,9903,9904],{},"只下发 IPv6","（128 位）地址，而 ",[31,9907,9908],{},"ip2region 目前只支持 IPv4","——拿到一个纯 IPv6 地址是查不出归属地的，本地库会返回空结果。",[15,9911,9912],{},"实际影响与常见应对：",[121,9914,9915,9925,9931,9941],{},[22,9916,9917,9920,9921,9924],{},[31,9918,9919],{},"判断版本","：Go 里 ",[26,9922,9923],{},"net.ParseIP(ip).To4() != nil"," 就是 IPv4，先判断再查询；",[22,9926,9927,9930],{},[31,9928,9929],{},"降级展示","：IPv6 直接显示「IPv6 地址」，不显示属地，诚实第一；",[22,9932,9933,9936,9937,9940],{},[31,9934,9935],{},"优先取 IPv4","：有反向代理时，从 ",[26,9938,9939],{},"X-Forwarded-For"," 里挑一个 IPv4 来查；",[22,9942,9943,9946],{},[31,9944,9945],{},"需要 v6 归属","：换支持 IPv6 的数据源（如 MaxMind GeoLite2）。",[15,9948,9949],{},"这一点在接入时很容易漏掉，上线前记得拿一个纯 IPv6 地址回归一遍。",[11,9951,9953],{"id":9952},"实战给登录记录加-ip-属地","实战：给登录记录加 IP 属地",[15,9955,9956],{},"subLog 博客的登录记录就用了 ip2region。完整接入一共四步。",[586,9958,9960],{"id":9959},"_1-进程启动时加载数据文件","1. 进程启动时加载数据文件",[47,9962,9964],{"className":3693,"code":9963,"language":477,"meta":52,"style":52},"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",[26,9965,9966,9972,9981,9990,9994,9998,10003,10030,10034,10047,10069,10081,10088,10092,10108,10120,10126,10130,10141,10148],{"__ignoreMap":52},[56,9967,9968,9970],{"class":58,"line":59},[56,9969,3701],{"class":249},[56,9971,5099],{"class":66},[56,9973,9974,9976,9979],{"class":58,"line":70},[56,9975,6519],{"class":362},[56,9977,9978],{"class":224},"sync\u002Fatomic",[56,9980,584],{"class":362},[56,9982,9983,9985,9988],{"class":58,"line":86},[56,9984,6519],{"class":362},[56,9986,9987],{"class":224},"github.com\u002Flionsoul2014\u002Fip2region\u002Fbinding\u002Fgolang\u002Fxdb",[56,9989,584],{"class":362},[56,9991,9992],{"class":58,"line":98},[56,9993,2659],{"class":66},[56,9995,9996],{"class":58,"line":259},[56,9997,398],{"emptyLinePlaceholder":313},[56,9999,10000],{"class":58,"line":434},[56,10001,10002],{"class":353},"\u002F\u002F 用 atomic.Pointer 持有查询器，方便后面热更新\n",[56,10004,10005,10007,10010,10013,10015,10018,10020,10023,10025,10028],{"class":58,"line":452},[56,10006,6600],{"class":249},[56,10008,10009],{"class":66}," searcher ",[56,10011,10012],{"class":224},"atomic",[56,10014,2471],{"class":66},[56,10016,10017],{"class":224},"Pointer",[56,10019,3156],{"class":66},[56,10021,10022],{"class":224},"xdb",[56,10024,2471],{"class":66},[56,10026,10027],{"class":224},"Searcher",[56,10029,805],{"class":66},[56,10031,10032],{"class":58,"line":468},[56,10033,398],{"emptyLinePlaceholder":313},[56,10035,10036,10038,10041,10043,10045],{"class":58,"line":474},[56,10037,3894],{"class":249},[56,10039,10040],{"class":224}," initSearcher",[56,10042,5142],{"class":66},[56,10044,3939],{"class":249},[56,10046,67],{"class":66},[56,10048,10049,10052,10054,10057,10059,10061,10064,10066],{"class":58,"line":488},[56,10050,10051],{"class":66},"    content, err ",[56,10053,3720],{"class":249},[56,10055,10056],{"class":66}," os.",[56,10058,6739],{"class":224},[56,10060,228],{"class":66},[56,10062,10063],{"class":362},"\".\u002Fdata\u002Fip2region.xdb\"",[56,10065,2811],{"class":66},[56,10067,10068],{"class":353},"\u002F\u002F 约 11MB，一次性读入内存\n",[56,10070,10071,10073,10075,10077,10079],{"class":58,"line":511},[56,10072,2426],{"class":249},[56,10074,4134],{"class":66},[56,10076,4137],{"class":249},[56,10078,4140],{"class":73},[56,10080,67],{"class":66},[56,10082,10083,10085],{"class":58,"line":520},[56,10084,4147],{"class":249},[56,10086,10087],{"class":66}," err\n",[56,10089,10090],{"class":58,"line":525},[56,10091,2310],{"class":66},[56,10093,10094,10097,10099,10102,10105],{"class":58,"line":531},[56,10095,10096],{"class":66},"    s, err ",[56,10098,3720],{"class":249},[56,10100,10101],{"class":66}," xdb.",[56,10103,10104],{"class":224},"NewWithBuffer",[56,10106,10107],{"class":66},"(content)\n",[56,10109,10110,10112,10114,10116,10118],{"class":58,"line":542},[56,10111,2426],{"class":249},[56,10113,4134],{"class":66},[56,10115,4137],{"class":249},[56,10117,4140],{"class":73},[56,10119,67],{"class":66},[56,10121,10122,10124],{"class":58,"line":553},[56,10123,4147],{"class":249},[56,10125,10087],{"class":66},[56,10127,10128],{"class":58,"line":558},[56,10129,2310],{"class":66},[56,10131,10132,10135,10138],{"class":58,"line":567},[56,10133,10134],{"class":66},"    searcher.",[56,10136,10137],{"class":224},"Store",[56,10139,10140],{"class":66},"(s)\n",[56,10142,10143,10145],{"class":58,"line":572},[56,10144,3951],{"class":249},[56,10146,10147],{"class":73}," nil\n",[56,10149,10150],{"class":58,"line":780},[56,10151,101],{"class":66},[586,10153,10155],{"id":10154},"_2-查询一次","2. 查询一次",[47,10157,10159],{"className":3693,"code":10158,"language":477,"meta":52,"style":52},"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",[26,10160,10161,10183,10202,10214,10221,10225,10235],{"__ignoreMap":52},[56,10162,10163,10165,10168,10170,10173,10176,10178,10181],{"class":58,"line":59},[56,10164,3894],{"class":249},[56,10166,10167],{"class":224}," ipRegion",[56,10169,228],{"class":66},[56,10171,10172],{"class":2352},"ip",[56,10174,10175],{"class":249}," string",[56,10177,2811],{"class":66},[56,10179,10180],{"class":249},"string",[56,10182,67],{"class":66},[56,10184,10185,10188,10190,10192,10195,10198,10200],{"class":58,"line":70},[56,10186,10187],{"class":66},"    region, err ",[56,10189,3720],{"class":249},[56,10191,9601],{"class":66},[56,10193,10194],{"class":224},"Load",[56,10196,10197],{"class":66},"().",[56,10199,9604],{"class":224},[56,10201,9607],{"class":66},[56,10203,10204,10206,10208,10210,10212],{"class":58,"line":86},[56,10205,2426],{"class":249},[56,10207,4134],{"class":66},[56,10209,4137],{"class":249},[56,10211,4140],{"class":73},[56,10213,67],{"class":66},[56,10215,10216,10218],{"class":58,"line":98},[56,10217,4147],{"class":249},[56,10219,10220],{"class":362}," \"未知\"\n",[56,10222,10223],{"class":58,"line":259},[56,10224,2310],{"class":66},[56,10226,10227,10229,10232],{"class":58,"line":434},[56,10228,3951],{"class":249},[56,10230,10231],{"class":66}," region ",[56,10233,10234],{"class":353},"\u002F\u002F \"中国|福建省|福州市|移动\"\n",[56,10236,10237],{"class":58,"line":452},[56,10238,101],{"class":66},[586,10240,10242],{"id":10241},"_3-加一层内存缓存","3. 加一层内存缓存",[15,10244,10245,10246,10249],{},"登录记录里同一个 IP 可能反复出现，用 ",[26,10247,10248],{},"sync.Map"," 按 IP 缓存结果，一次查询终身复用：",[47,10251,10253],{"className":3693,"code":10252,"language":477,"meta":52,"style":52},"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",[26,10254,10255,10270,10274,10293,10310,10321,10325,10336,10346,10353],{"__ignoreMap":52},[56,10256,10257,10259,10262,10265,10267],{"class":58,"line":59},[56,10258,6600],{"class":249},[56,10260,10261],{"class":66}," regionCache ",[56,10263,10264],{"class":224},"sync",[56,10266,2471],{"class":66},[56,10268,10269],{"class":224},"Map\n",[56,10271,10272],{"class":58,"line":70},[56,10273,398],{"emptyLinePlaceholder":313},[56,10275,10276,10278,10281,10283,10285,10287,10289,10291],{"class":58,"line":86},[56,10277,3894],{"class":249},[56,10279,10280],{"class":224}," ipRegionCached",[56,10282,228],{"class":66},[56,10284,10172],{"class":2352},[56,10286,10175],{"class":249},[56,10288,2811],{"class":66},[56,10290,10180],{"class":249},[56,10292,67],{"class":66},[56,10294,10295,10297,10300,10302,10305,10307],{"class":58,"line":98},[56,10296,2426],{"class":249},[56,10298,10299],{"class":66}," v, ok ",[56,10301,3720],{"class":249},[56,10303,10304],{"class":66}," regionCache.",[56,10306,10194],{"class":224},[56,10308,10309],{"class":66},"(ip); ok {\n",[56,10311,10312,10314,10317,10319],{"class":58,"line":259},[56,10313,4147],{"class":249},[56,10315,10316],{"class":66}," v.(",[56,10318,10180],{"class":249},[56,10320,2659],{"class":66},[56,10322,10323],{"class":58,"line":434},[56,10324,2310],{"class":66},[56,10326,10327,10330,10332,10334],{"class":58,"line":452},[56,10328,10329],{"class":66},"    region ",[56,10331,3720],{"class":249},[56,10333,10167],{"class":224},[56,10335,9607],{"class":66},[56,10337,10338,10341,10343],{"class":58,"line":468},[56,10339,10340],{"class":66},"    regionCache.",[56,10342,10137],{"class":224},[56,10344,10345],{"class":66},"(ip, region)\n",[56,10347,10348,10350],{"class":58,"line":474},[56,10349,3951],{"class":249},[56,10351,10352],{"class":66}," region\n",[56,10354,10355],{"class":58,"line":488},[56,10356,101],{"class":66},[586,10358,10360],{"id":10359},"_4-数据热更新不用重启","4. 数据热更新（不用重启）",[15,10362,10363,10364,10366,10367,10369],{},"ip2region 每月发布新数据。下载新文件后重新 ",[26,10365,10104],{}," 并 ",[26,10368,10137],{}," 覆盖即可，进程完全不用重启：",[47,10371,10373],{"className":3693,"code":10372,"language":477,"meta":52,"style":52},"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",[26,10374,10375,10399,10412,10424,10430,10434,10446,10452],{"__ignoreMap":52},[56,10376,10377,10379,10382,10384,10387,10390,10393,10395,10397],{"class":58,"line":59},[56,10378,3894],{"class":249},[56,10380,10381],{"class":224}," reloadSearcher",[56,10383,228],{"class":66},[56,10385,10386],{"class":2352},"newContent",[56,10388,10389],{"class":66}," []",[56,10391,10392],{"class":249},"byte",[56,10394,2811],{"class":66},[56,10396,3939],{"class":249},[56,10398,67],{"class":66},[56,10400,10401,10403,10405,10407,10409],{"class":58,"line":70},[56,10402,10096],{"class":66},[56,10404,3720],{"class":249},[56,10406,10101],{"class":66},[56,10408,10104],{"class":224},[56,10410,10411],{"class":66},"(newContent)\n",[56,10413,10414,10416,10418,10420,10422],{"class":58,"line":86},[56,10415,2426],{"class":249},[56,10417,4134],{"class":66},[56,10419,4137],{"class":249},[56,10421,4140],{"class":73},[56,10423,67],{"class":66},[56,10425,10426,10428],{"class":58,"line":98},[56,10427,4147],{"class":249},[56,10429,10087],{"class":66},[56,10431,10432],{"class":58,"line":259},[56,10433,2310],{"class":66},[56,10435,10436,10438,10440,10443],{"class":58,"line":434},[56,10437,10134],{"class":66},[56,10439,10137],{"class":224},[56,10441,10442],{"class":66},"(s) ",[56,10444,10445],{"class":353},"\u002F\u002F 原子切换，老查询继续走旧数据，新查询走新数据\n",[56,10447,10448,10450],{"class":58,"line":452},[56,10449,3951],{"class":249},[56,10451,10147],{"class":73},[56,10453,10454],{"class":58,"line":468},[56,10455,101],{"class":66},[11,10457,10458],{"id":10458},"局限与诚实提醒",[15,10460,10461],{},"无论用哪种方案，都要清楚 IP 属地的边界：",[19,10463,10464,10470,10476,10482,10488],{},[22,10465,10466,10469],{},[31,10467,10468],{},"段级精度","：网段归属是「绝大多数」准确，个别 IP（大型企业自建段、跨省调度段）可能显示到相邻地区或只到省。",[22,10471,10472,10475],{},[31,10473,10474],{},"运营商级，不是定位","：拿到的是「省｜市｜运营商」，不是精确街道或基站位置——精确位置需要 GPS 或运营商三角定位，纯 IP 归属做不到。",[22,10477,10478,10481],{},[31,10479,10480],{},"动态 IP","：家宽通常是动态 IP，重拨会变，所以「上次登录在福州，这次在广州」不一定是异常，可能是 IP 变了。",[22,10483,10484,10487],{},[31,10485,10486],{},"别用于安全判定","：用 IP 段做封禁或风控判定风险自负，段级数据不适合当精确依据。",[22,10489,10490,10493,10494,10497],{},[31,10491,10492],{},"隐私与合规","：IP 地址属于个人信息（《个人信息保护法》）。归属地展示建议只出现在",[31,10495,10496],{},"用户本人可见","的页面（登录记录、个人中心）；若在评论区等公开场合展示，建议只显示到省市并明确告知用户。",[11,10499,10500],{"id":10500},"小结",[15,10502,10503],{},"一句话总结：",[9425,10505,10506],{},[15,10507,10508,10511],{},[31,10509,10510],{},"IP 属地 = 几十万条「IP 段 → 归属地」映射 + 一次二分查找。"," 它查的是网段归属，不是精确位置。",[19,10513,10514,10520,10527],{},[22,10515,10516,10517,10519],{},"想省心、免费、稳定，用 ",[31,10518,9489],{}," 本地离线查询（注意 IPv6 盲点）；",[22,10521,10522,10523,10526],{},"想要最新最细、能接受外部依赖，用",[31,10524,10525],{},"在线 API","（记得超时降级）；",[22,10528,10529,10530,2241],{},"面向全球用户、要 IPv6 覆盖，用 ",[31,10531,10532],{},"MaxMind GeoLite2",[15,10534,10535],{},"对个人博客「登录记录显示归属地」这种场景，本地离线方案通常是性价比最高的选择。",[214,10537,10538],{},"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":52,"searchDepth":70,"depth":86,"links":10540},[10541,10542,10543,10549,10554,10555,10561,10562],{"id":8844,"depth":70,"text":8844},{"id":9436,"depth":70,"text":9437},{"id":9483,"depth":70,"text":9483,"children":10544},[10545,10546,10547,10548],{"id":9493,"depth":86,"text":9494},{"id":9508,"depth":86,"text":9509},{"id":9526,"depth":86,"text":9527},{"id":9545,"depth":86,"text":9546},{"id":9574,"depth":70,"text":9574,"children":10550},[10551,10552,10553],{"id":9580,"depth":86,"text":9581},{"id":9660,"depth":86,"text":9661},{"id":9848,"depth":86,"text":9849},{"id":9897,"depth":70,"text":9898},{"id":9952,"depth":70,"text":9953,"children":10556},[10557,10558,10559,10560],{"id":9959,"depth":86,"text":9960},{"id":10154,"depth":86,"text":10155},{"id":10241,"depth":86,"text":10242},{"id":10359,"depth":86,"text":10360},{"id":10458,"depth":70,"text":10458},{"id":10500,"depth":70,"text":10500},"科普 IP 属地查询的原理：IP 段与归属地的映射、纯本地离线查询为何能做到毫秒级，对比 ip2region、在线 API 与 MaxMind 三种方案的优劣，并附登录记录场景的 Go 实战与 IPv6 盲点提醒。","\u002Fposts\u002Fip-geolocation-guide\u002Fimg\u002Fcover.svg",{},"\u002Fposts\u002Fip-geolocation-guide","2026-08-07T12:00:00",{"title":9399,"description":10563},"posts\u002Fip-geolocation-guide\u002Findex",[10571,10572,10573],"IP","网络","科普","2026-08-16","RY1UPgJaiKsjpC51uqMyG7w_GpCRS13_CX5j1hDD_dM",{"id":10577,"title":10578,"author":330,"body":10579,"description":12101,"difficulty":308,"draft":309,"extension":310,"image":12102,"meta":12103,"navigation":313,"path":12104,"pinned":309,"published":12105,"seo":12106,"series":5658,"seriesOrder":86,"stem":12107,"tags":12108,"updated":10574,"__hash__":12111},"posts\u002Fposts\u002Fgo-embed-react-single-binary\u002Findex.md","Go 单文件打包进阶：React 前端 + 交叉编译，一次踩完所有坑",{"type":8,"value":10580,"toc":12080},[10581,10583,10594,10605,10612,10615,10651,10653,10662,10664,10668,10678,10722,10764,10782,10786,10807,10838,10842,11134,11137,11175,11179,11522,11525,11529,11726,11736,11738,11749,11763,11776,11787,11791,11798,11814,11827,11832,11836,11852,11857,11902,11916,11927,11931,11946,11955,11977,11986,11990,12000,12002,12029,12038,12040,12043,12070,12077],[11,10582,2217],{"id":2217},[15,10584,10585,10586,10589,10590,10593],{},"这是「Go 单文件打包」系列第三篇。上一篇（",[63,10587,10588],{"href":7418},"把前端塞进二进制","）记录了一个 Gin + Vue 3 项目的 embed 方案。这次换了个项目继续踩坑：",[31,10591,10592],{},"Magic Img","——一个自托管、本地优先的 AI 生图提示词管理工具，技术栈是：",[19,10595,10596,10599],{},[22,10597,10598],{},"前端：React 19 + TypeScript + Vite + Tailwind v4（UI 库 Magic UI + shadcn\u002Fui）",[22,10600,10601,10602,6202],{},"后端：Go + Gin + GORM + SQLite（",[31,10603,10604],{},"modernc 纯 Go 驱动，无 cgo",[15,10606,10607,10608,10611],{},"目标一样：",[31,10609,10610],{},"一个二进制拖到任何机器上就能跑","——前端、API、数据库全部内嵌。",[15,10613,10614],{},"和上篇不同的地方有三个，正好是这篇要讲的重点：",[121,10616,10617,10631,10644],{},[22,10618,10619,10620,10626,10627,10630],{},"前端用了 ",[31,10621,10622,10623],{},"HashRouter + Vite base ",[26,10624,10625],{},"\u002Fdist","，服务端",[31,10628,10629],{},"不再需要 SPA fallback","（上篇坑一的新解法）；",[22,10632,10633,10634,10640,10641,10643],{},"embed 用 ",[31,10635,10636,10639],{},[26,10637,10638],{},"all:"," 前缀","一劳永逸解决 ",[26,10642,7112],{}," 开头文件被排除的问题（上篇坑二的升级解法）；",[22,10645,10646,10647,10650],{},"需要",[31,10648,10649],{},"在 Windows 上交叉编译 Linux 产物","，踩了「生成的文件无法在 Linux 执行」的坑。",[11,10652,6468],{"id":6468},[15,10654,10655,10656,10658,10659,10661],{},"和上篇一致：",[26,10657,6474],{}," 在编译期把前端 ",[26,10660,6478],{}," 塞进二进制，运行时由 Go 直接提供静态服务。差异在于路由策略和缓存策略，后面细说。",[11,10663,6482],{"id":6482},[586,10665,10667],{"id":10666},"_1-前端hash-路由-base-dist","1. 前端：hash 路由 + base \u002Fdist",[15,10669,10670,10671,10674,10675,119],{},"vite.config.ts 里固定 ",[26,10672,10673],{},"base: \"\u002Fdist\u002F\"","，路由用 ",[26,10676,10677],{},"HashRouter",[47,10679,10681],{"className":7163,"code":10680,"language":7165,"meta":52,"style":52},"\u002F\u002F vite.config.ts\nexport default defineConfig({\n  base: \"\u002Fdist\u002F\",   \u002F\u002F 所有资源路径以 \u002Fdist\u002F 开头\n  ...\n})\n",[26,10682,10683,10687,10699,10713,10718],{"__ignoreMap":52},[56,10684,10685],{"class":58,"line":59},[56,10686,7172],{"class":353},[56,10688,10689,10691,10693,10696],{"class":58,"line":70},[56,10690,437],{"class":249},[56,10692,3089],{"class":249},[56,10694,10695],{"class":224}," defineConfig",[56,10697,10698],{"class":66},"({\n",[56,10700,10701,10704,10707,10710],{"class":58,"line":86},[56,10702,10703],{"class":66},"  base: ",[56,10705,10706],{"class":362},"\"\u002Fdist\u002F\"",[56,10708,10709],{"class":66},",   ",[56,10711,10712],{"class":353},"\u002F\u002F 所有资源路径以 \u002Fdist\u002F 开头\n",[56,10714,10715],{"class":58,"line":98},[56,10716,10717],{"class":249},"  ...\n",[56,10719,10720],{"class":58,"line":259},[56,10721,2895],{"class":66},[47,10723,10727],{"className":10724,"code":10725,"language":10726,"meta":52,"style":52},"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",[26,10728,10729,10734,10742,10756],{"__ignoreMap":52},[56,10730,10731],{"class":58,"line":59},[56,10732,10733],{"class":353},"\u002F\u002F App.tsx —— HashRouter：路径信息全在 location.hash 里\n",[56,10735,10736,10738,10740],{"class":58,"line":70},[56,10737,211],{"class":66},[56,10739,10677],{"class":73},[56,10741,217],{"class":66},[56,10743,10744,10746,10749,10752,10754],{"class":58,"line":86},[56,10745,2921],{"class":66},[56,10747,10748],{"class":73},"Routes",[56,10750,10751],{"class":66},">...\u003C\u002F",[56,10753,10748],{"class":73},[56,10755,217],{"class":66},[56,10757,10758,10760,10762],{"class":58,"line":98},[56,10759,262],{"class":66},[56,10761,10677],{"class":73},[56,10763,217],{"class":66},[15,10765,10766,10769,10770,10773,10774,10777,10778,10781],{},[31,10767,10768],{},"为什么这样选","：history 路由需要服务端把未知路径全部 fallback 回 ",[26,10771,10772],{},"index.html","（上篇坑一）。hash 路由下浏览器请求的永远是 ",[26,10775,10776],{},"\u002Fdist\u002F"," 这一个入口，",[31,10779,10780],{},"静态服务直接返回 index.html 就完事，一个 fallback 都不用写","。内嵌场景里这是最省心的组合。",[586,10783,10785],{"id":10784},"_2-构建产物复制进后端目录","2. 构建产物复制进后端目录",[15,10787,10788,10791,10792,10794,10795,10798,10799,10802,10803,10806],{},[26,10789,10790],{},"npm run build"," 除了编译，还会把 ",[26,10793,6478],{}," 复制到 ",[26,10796,10797],{},"backend\u002Fweb\u002Fdist","（一个小脚本 ",[26,10800,10801],{},"scripts\u002Fcopy-dist.mjs"," 干这个活），供 ",[26,10804,10805],{},"go:embed"," 使用：",[47,10808,10810],{"className":5485,"code":10809,"language":5487,"meta":52,"style":52},"\u002F\u002F frontend\u002Fpackage.json\n\"scripts\": {\n  \"build\": \"tsc -b && vite build && node scripts\u002Fcopy-dist.mjs\"\n}\n",[26,10811,10812,10817,10824,10834],{"__ignoreMap":52},[56,10813,10814],{"class":58,"line":59},[56,10815,10816],{"class":353},"\u002F\u002F frontend\u002Fpackage.json\n",[56,10818,10819,10822],{"class":58,"line":70},[56,10820,10821],{"class":362},"\"scripts\"",[56,10823,2269],{"class":66},[56,10825,10826,10829,10831],{"class":58,"line":86},[56,10827,10828],{"class":73},"  \"build\"",[56,10830,77],{"class":66},[56,10832,10833],{"class":362},"\"tsc -b && vite build && node scripts\u002Fcopy-dist.mjs\"\n",[56,10835,10836],{"class":58,"line":98},[56,10837,101],{"class":66},[586,10839,10841],{"id":10840},"_3-后端embed-httpfilesystem-包装","3. 后端：embed + http.FileSystem 包装",[47,10843,10845],{"className":3693,"code":10844,"language":477,"meta":52,"style":52},"\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",[26,10846,10847,10852,10858,10862,10868,10876,10884,10892,10896,10900,10905,10918,10922,10927,10964,10968,10980,10988,11001,11005,11009,11048,11062,11072,11082,11109,11113,11130],{"__ignoreMap":52},[56,10848,10849],{"class":58,"line":59},[56,10850,10851],{"class":353},"\u002F\u002F backend\u002Fweb\u002Fembed.go\n",[56,10853,10854,10856],{"class":58,"line":70},[56,10855,6501],{"class":249},[56,10857,6504],{"class":224},[56,10859,10860],{"class":58,"line":86},[56,10861,398],{"emptyLinePlaceholder":313},[56,10863,10864,10866],{"class":58,"line":98},[56,10865,3701],{"class":249},[56,10867,5099],{"class":66},[56,10869,10870,10872,10874],{"class":58,"line":259},[56,10871,6519],{"class":362},[56,10873,6522],{"class":224},[56,10875,584],{"class":362},[56,10877,10878,10880,10882],{"class":58,"line":434},[56,10879,6519],{"class":362},[56,10881,6549],{"class":224},[56,10883,584],{"class":362},[56,10885,10886,10888,10890],{"class":58,"line":452},[56,10887,6519],{"class":362},[56,10889,6567],{"class":224},[56,10891,584],{"class":362},[56,10893,10894],{"class":58,"line":468},[56,10895,2659],{"class":66},[56,10897,10898],{"class":58,"line":474},[56,10899,398],{"emptyLinePlaceholder":313},[56,10901,10902],{"class":58,"line":488},[56,10903,10904],{"class":353},"\u002F\u002Fgo:embed all:dist\n",[56,10906,10907,10909,10912,10914,10916],{"class":58,"line":511},[56,10908,6600],{"class":249},[56,10910,10911],{"class":66}," distFS ",[56,10913,6522],{"class":224},[56,10915,2471],{"class":66},[56,10917,6610],{"class":224},[56,10919,10920],{"class":58,"line":520},[56,10921,398],{"emptyLinePlaceholder":313},[56,10923,10924],{"class":58,"line":525},[56,10925,10926],{"class":353},"\u002F\u002F Dist 内嵌前端静态文件系统\n",[56,10928,10929,10931,10934,10937,10939,10942,10944,10947,10950,10953,10955,10958,10961],{"class":58,"line":531},[56,10930,6600],{"class":249},[56,10932,10933],{"class":66}," Dist ",[56,10935,10936],{"class":224},"http",[56,10938,2471],{"class":66},[56,10940,10941],{"class":224},"FileSystem",[56,10943,1993],{"class":249},[56,10945,10946],{"class":249}," &",[56,10948,10949],{"class":224},"fileSystem",[56,10951,10952],{"class":66},"{prefix: ",[56,10954,6650],{"class":362},[56,10956,10957],{"class":66},", fs: ",[56,10959,10960],{"class":249},"&",[56,10962,10963],{"class":66},"distFS}\n",[56,10965,10966],{"class":58,"line":542},[56,10967,398],{"emptyLinePlaceholder":313},[56,10969,10970,10972,10975,10978],{"class":58,"line":553},[56,10971,5086],{"class":249},[56,10973,10974],{"class":224}," fileSystem",[56,10976,10977],{"class":249}," struct",[56,10979,67],{"class":66},[56,10981,10982,10985],{"class":58,"line":558},[56,10983,10984],{"class":66},"    prefix ",[56,10986,10987],{"class":249},"string\n",[56,10989,10990,10993,10995,10997,10999],{"class":58,"line":567},[56,10991,10992],{"class":66},"    fs     ",[56,10994,3926],{"class":249},[56,10996,6522],{"class":224},[56,10998,2471],{"class":66},[56,11000,6610],{"class":224},[56,11002,11003],{"class":58,"line":572},[56,11004,101],{"class":66},[56,11006,11007],{"class":58,"line":780},[56,11008,398],{"emptyLinePlaceholder":313},[56,11010,11011,11013,11015,11018,11020,11022,11024,11027,11029,11031,11033,11035,11037,11039,11042,11044,11046],{"class":58,"line":788},[56,11012,3894],{"class":249},[56,11014,2391],{"class":66},[56,11016,11017],{"class":2352},"f ",[56,11019,3926],{"class":249},[56,11021,10949],{"class":224},[56,11023,2811],{"class":66},[56,11025,11026],{"class":224},"Open",[56,11028,228],{"class":66},[56,11030,7216],{"class":2352},[56,11032,10175],{"class":249},[56,11034,3923],{"class":66},[56,11036,10936],{"class":224},[56,11038,2471],{"class":66},[56,11040,11041],{"class":224},"File",[56,11043,1101],{"class":66},[56,11045,3939],{"class":249},[56,11047,237],{"class":66},[56,11049,11050,11052,11055,11057,11060],{"class":58,"line":793},[56,11051,2426],{"class":249},[56,11053,11054],{"class":66}," name ",[56,11056,4794],{"class":249},[56,11058,11059],{"class":362}," \"\u002F\"",[56,11061,67],{"class":66},[56,11063,11064,11067,11069],{"class":58,"line":1422},[56,11065,11066],{"class":66},"        name ",[56,11068,443],{"class":249},[56,11070,11071],{"class":66}," f.prefix\n",[56,11073,11074,11077,11080],{"class":58,"line":1436},[56,11075,11076],{"class":66},"    } ",[56,11078,11079],{"class":249},"else",[56,11081,67],{"class":66},[56,11083,11084,11086,11088,11091,11093,11095,11098,11100,11102,11105,11107],{"class":58,"line":1443},[56,11085,11066],{"class":66},[56,11087,443],{"class":249},[56,11089,11090],{"class":66}," f.prefix ",[56,11092,3164],{"class":249},[56,11094,11059],{"class":362},[56,11096,11097],{"class":249}," +",[56,11099,4856],{"class":66},[56,11101,6688],{"class":224},[56,11103,11104],{"class":66},"(name, ",[56,11106,6694],{"class":362},[56,11108,2659],{"class":66},[56,11110,11111],{"class":58,"line":1448},[56,11112,2310],{"class":66},[56,11114,11115,11117,11119,11122,11125,11127],{"class":58,"line":1454},[56,11116,3951],{"class":249},[56,11118,9719],{"class":66},[56,11120,11121],{"class":224},"FS",[56,11123,11124],{"class":66},"(f.fs).",[56,11126,11026],{"class":224},[56,11128,11129],{"class":66},"(name)\n",[56,11131,11132],{"class":58,"line":1462},[56,11133,101],{"class":66},[15,11135,11136],{},"两个细节：",[19,11138,11139,11162],{},[22,11140,11141,11149,11150,7129,11152,11154,11155,11157,11158,11161],{},[31,11142,11143,11146,11147,10639],{},[26,11144,11145],{},"all:dist"," 里的 ",[26,11148,10638],{},"：embed 目录时默认会排除 ",[26,11151,7112],{},[26,11153,2471],{}," 开头的文件（上篇坑二就是被这个坑了，当时靠改 Vite 输出名绕开）。",[26,11156,10638],{}," 前缀表示「全部包含」，前端构建产物里那些 ",[26,11159,11160],{},"_xxx.js"," 助手文件直接安全落地，不用再改前端配置；",[22,11163,11164,11167,11168,11170,11171,11174],{},[26,11165,11166],{},"http.FileSystem"," 包装：加了一层 ",[26,11169,6478],{}," 前缀映射，",[26,11172,11173],{},"http.FileServer"," 和 Gin 都能直接用。",[586,11176,11178],{"id":11177},"_4-路由静态服务-缓存策略","4. 路由：静态服务 + 缓存策略",[47,11180,11182],{"className":3693,"code":11181,"language":477,"meta":52,"style":52},"\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",[26,11183,11184,11189,11231,11243,11248,11263,11308,11337,11355,11376,11381,11400,11423,11428,11445,11449,11470,11474,11514,11518],{"__ignoreMap":52},[56,11185,11186],{"class":58,"line":59},[56,11187,11188],{"class":353},"\u002F\u002F backend\u002Finternal\u002Fhandler\u002Frouter.go\n",[56,11190,11191,11193,11196,11198,11201,11203,11206,11208,11211,11213,11216,11218,11220,11222,11224,11226,11229],{"class":58,"line":70},[56,11192,3894],{"class":249},[56,11194,11195],{"class":224}," NewRouter",[56,11197,228],{"class":66},[56,11199,11200],{"class":2352},"db",[56,11202,4519],{"class":249},[56,11204,11205],{"class":224},"gorm",[56,11207,2471],{"class":66},[56,11209,11210],{"class":224},"DB",[56,11212,1101],{"class":66},[56,11214,11215],{"class":2352},"uploadDir",[56,11217,10175],{"class":249},[56,11219,2811],{"class":66},[56,11221,3926],{"class":249},[56,11223,4522],{"class":224},[56,11225,2471],{"class":66},[56,11227,11228],{"class":224},"Engine",[56,11230,67],{"class":66},[56,11232,11233,11235,11237,11239,11241],{"class":58,"line":86},[56,11234,4335],{"class":66},[56,11236,3720],{"class":249},[56,11238,4340],{"class":66},[56,11240,4343],{"class":224},[56,11242,2544],{"class":66},[56,11244,11245],{"class":58,"line":98},[56,11246,11247],{"class":249},"    ...\n",[56,11249,11250,11253,11255,11257,11260],{"class":58,"line":259},[56,11251,11252],{"class":66},"    dist ",[56,11254,3720],{"class":249},[56,11256,9719],{"class":66},[56,11258,11259],{"class":224},"FileServer",[56,11261,11262],{"class":66},"(web.Dist)\n",[56,11264,11265,11267,11269,11271,11274,11276,11278,11280,11282,11284,11286,11288,11290,11293,11296,11298,11301,11303,11305],{"class":58,"line":434},[56,11266,4473],{"class":66},[56,11268,4502],{"class":224},[56,11270,228],{"class":66},[56,11272,11273],{"class":362},"\"\u002Fdist\"",[56,11275,1101],{"class":66},[56,11277,3894],{"class":249},[56,11279,228],{"class":66},[56,11281,4516],{"class":2352},[56,11283,4519],{"class":249},[56,11285,4522],{"class":224},[56,11287,2471],{"class":66},[56,11289,3907],{"class":224},[56,11291,11292],{"class":66},") { c.",[56,11294,11295],{"class":224},"Redirect",[56,11297,228],{"class":66},[56,11299,11300],{"class":73},"302",[56,11302,1101],{"class":66},[56,11304,10706],{"class":362},[56,11306,11307],{"class":66},") })\n",[56,11309,11310,11312,11314,11316,11319,11321,11323,11325,11327,11329,11331,11333,11335],{"class":58,"line":452},[56,11311,4473],{"class":66},[56,11313,4502],{"class":224},[56,11315,228],{"class":66},[56,11317,11318],{"class":362},"\"\u002Fdist\u002F*filepath\"",[56,11320,1101],{"class":66},[56,11322,3894],{"class":249},[56,11324,228],{"class":66},[56,11326,4516],{"class":2352},[56,11328,4519],{"class":249},[56,11330,4522],{"class":224},[56,11332,2471],{"class":66},[56,11334,3907],{"class":224},[56,11336,237],{"class":66},[56,11338,11339,11341,11343,11345,11348,11350,11353],{"class":58,"line":468},[56,11340,6681],{"class":66},[56,11342,3720],{"class":249},[56,11344,4773],{"class":66},[56,11346,11347],{"class":224},"Param",[56,11349,228],{"class":66},[56,11351,11352],{"class":362},"\"filepath\"",[56,11354,2659],{"class":66},[56,11356,11357,11359,11361,11363,11365,11367,11369,11371,11374],{"class":58,"line":474},[56,11358,4788],{"class":249},[56,11360,6703],{"class":66},[56,11362,4794],{"class":249},[56,11364,11059],{"class":362},[56,11366,4889],{"class":249},[56,11368,6703],{"class":66},[56,11370,4794],{"class":249},[56,11372,11373],{"class":362}," \"\u002Findex.html\"",[56,11375,67],{"class":66},[56,11377,11378],{"class":58,"line":488},[56,11379,11380],{"class":353},"            \u002F\u002F index.html 禁缓存：升级后浏览器立刻拿到新入口\n",[56,11382,11383,11385,11388,11390,11393,11395,11398],{"class":58,"line":511},[56,11384,4804],{"class":66},[56,11386,11387],{"class":224},"Header",[56,11389,228],{"class":66},[56,11391,11392],{"class":362},"\"Cache-Control\"",[56,11394,1101],{"class":66},[56,11396,11397],{"class":362},"\"no-cache, no-store, must-revalidate\"",[56,11399,2659],{"class":66},[56,11401,11402,11405,11407,11410,11412,11415,11418,11421],{"class":58,"line":520},[56,11403,11404],{"class":66},"        } ",[56,11406,11079],{"class":249},[56,11408,11409],{"class":249}," if",[56,11411,4856],{"class":66},[56,11413,11414],{"class":224},"HasPrefix",[56,11416,11417],{"class":66},"(p, ",[56,11419,11420],{"class":362},"\"\u002Fassets\u002F\"",[56,11422,237],{"class":66},[56,11424,11425],{"class":58,"line":525},[56,11426,11427],{"class":353},"            \u002F\u002F 带内容 hash 的资源可放心长缓存\n",[56,11429,11430,11432,11434,11436,11438,11440,11443],{"class":58,"line":531},[56,11431,4804],{"class":66},[56,11433,11387],{"class":224},[56,11435,228],{"class":66},[56,11437,11392],{"class":362},[56,11439,1101],{"class":66},[56,11441,11442],{"class":362},"\"public, max-age=31536000, immutable\"",[56,11444,2659],{"class":66},[56,11446,11447],{"class":58,"line":542},[56,11448,4842],{"class":66},[56,11450,11451,11454,11457,11459,11461,11464,11467],{"class":58,"line":553},[56,11452,11453],{"class":66},"        http.",[56,11455,11456],{"class":224},"StripPrefix",[56,11458,228],{"class":66},[56,11460,11273],{"class":362},[56,11462,11463],{"class":66},", dist).",[56,11465,11466],{"class":224},"ServeHTTP",[56,11468,11469],{"class":66},"(c.Writer, c.Request)\n",[56,11471,11472],{"class":58,"line":558},[56,11473,4568],{"class":66},[56,11475,11476,11478,11480,11482,11484,11486,11488,11490,11492,11494,11496,11498,11500,11502,11504,11506,11508,11510,11512],{"class":58,"line":567},[56,11477,4473],{"class":66},[56,11479,4502],{"class":224},[56,11481,228],{"class":66},[56,11483,6694],{"class":362},[56,11485,1101],{"class":66},[56,11487,3894],{"class":249},[56,11489,228],{"class":66},[56,11491,4516],{"class":2352},[56,11493,4519],{"class":249},[56,11495,4522],{"class":224},[56,11497,2471],{"class":66},[56,11499,3907],{"class":224},[56,11501,11292],{"class":66},[56,11503,11295],{"class":224},[56,11505,228],{"class":66},[56,11507,11300],{"class":73},[56,11509,1101],{"class":66},[56,11511,10706],{"class":362},[56,11513,11307],{"class":66},[56,11515,11516],{"class":58,"line":572},[56,11517,11247],{"class":249},[56,11519,11520],{"class":58,"line":780},[56,11521,101],{"class":66},[15,11523,11524],{},"缓存策略是内嵌发布的必修课（坑二细说）：入口禁缓存、hash 资源长缓存。",[586,11526,11528],{"id":11527},"_5-打包脚本windows-版-linux-交叉编译版","5. 打包脚本：Windows 版 + Linux 交叉编译版",[47,11530,11532],{"className":344,"code":11531,"language":346,"meta":52,"style":52},"#!\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",[26,11533,11534,11539,11544,11554,11572,11576,11597,11601,11609,11628,11632,11639,11696,11711,11715],{"__ignoreMap":52},[56,11535,11536],{"class":58,"line":59},[56,11537,11538],{"class":353},"#!\u002Fusr\u002Fbin\u002Fenv bash\n",[56,11540,11541],{"class":58,"line":70},[56,11542,11543],{"class":353},"# build-linux.sh —— 在 Windows\u002FmacOS\u002FLinux 任意平台交叉编译 linux\u002Famd64 产物\n",[56,11545,11546,11548,11551],{"class":58,"line":86},[56,11547,6959],{"class":73},[56,11549,11550],{"class":73}," -euo",[56,11552,11553],{"class":362}," pipefail\n",[56,11555,11556,11558,11561,11564,11566,11569],{"class":58,"line":98},[56,11557,7038],{"class":73},[56,11559,11560],{"class":362}," \"$(",[56,11562,11563],{"class":224},"dirname",[56,11565,375],{"class":362},[56,11567,11568],{"class":73},"$0",[56,11570,11571],{"class":362},"\")\"\n",[56,11573,11574],{"class":58,"line":259},[56,11575,398],{"emptyLinePlaceholder":313},[56,11577,11578,11581,11583,11586,11588,11591,11594],{"class":58,"line":434},[56,11579,11580],{"class":66},"BINARY",[56,11582,443],{"class":249},[56,11584,11585],{"class":362},"\"${",[56,11587,11580],{"class":66},[56,11589,11590],{"class":249},":-",[56,11592,11593],{"class":66},"magic-img",[56,11595,11596],{"class":362},"}\"\n",[56,11598,11599],{"class":58,"line":452},[56,11600,398],{"emptyLinePlaceholder":313},[56,11602,11603,11606],{"class":58,"line":468},[56,11604,11605],{"class":73},"echo",[56,11607,11608],{"class":362}," \"[1\u002F3] 构建前端并复制产物到 backend\u002Fweb\u002Fdist ...\"\n",[56,11610,11611,11613,11615,11618,11620,11622,11624,11626],{"class":58,"line":474},[56,11612,228],{"class":66},[56,11614,7038],{"class":73},[56,11616,11617],{"class":362}," frontend",[56,11619,414],{"class":66},[56,11621,5832],{"class":224},[56,11623,363],{"class":362},[56,11625,493],{"class":362},[56,11627,2659],{"class":66},[56,11629,11630],{"class":58,"line":488},[56,11631,398],{"emptyLinePlaceholder":313},[56,11633,11634,11636],{"class":58,"line":511},[56,11635,11605],{"class":73},[56,11637,11638],{"class":362}," \"[2\u002F3] 交叉编译后端并内嵌前端 (CGO_ENABLED=0 GOOS=linux GOARCH=amd64) ...\"\n",[56,11640,11641,11643,11645,11648,11651,11653,11655,11658,11660,11663,11666,11668,11671,11673,11675,11677,11679,11682,11685,11688,11691,11694],{"class":58,"line":520},[56,11642,228],{"class":66},[56,11644,7038],{"class":73},[56,11646,11647],{"class":362}," backend",[56,11649,11650],{"class":66}," && CGO_ENABLED",[56,11652,443],{"class":249},[56,11654,2437],{"class":362},[56,11656,11657],{"class":66}," GOOS",[56,11659,443],{"class":249},[56,11661,11662],{"class":362},"linux",[56,11664,11665],{"class":66}," GOARCH",[56,11667,443],{"class":249},[56,11669,11670],{"class":362},"amd64",[56,11672,846],{"class":224},[56,11674,493],{"class":362},[56,11676,502],{"class":73},[56,11678,375],{"class":362},[56,11680,11681],{"class":66},"$BINARY",[56,11683,11684],{"class":362},"\"",[56,11686,11687],{"class":73}," -ldflags=",[56,11689,11690],{"class":362},"\"-s -w\"",[56,11692,11693],{"class":362}," .\u002Fcmd\u002Fserver",[56,11695,2659],{"class":66},[56,11697,11698,11701,11704,11707,11709],{"class":58,"line":525},[56,11699,11700],{"class":224},"chmod",[56,11702,11703],{"class":362}," +x",[56,11705,11706],{"class":362}," \"backend\u002F",[56,11708,11681],{"class":66},[56,11710,584],{"class":362},[56,11712,11713],{"class":58,"line":531},[56,11714,398],{"emptyLinePlaceholder":313},[56,11716,11717,11719,11722,11724],{"class":58,"line":542},[56,11718,11605],{"class":73},[56,11720,11721],{"class":362}," \"[3\u002F3] 打包完成: backend\u002F",[56,11723,11681],{"class":66},[56,11725,584],{"class":362},[15,11727,11728,11729,11732,11733,2241],{},"Windows 版（",[26,11730,11731],{},"build-win.bat","）同理，只是产物叫 ",[26,11734,11735],{},"magic-img.exe",[11,11737,7072],{"id":7072},[586,11739,11741,11742,11745,11746,11748],{"id":11740},"坑一goembed-dist-静默漏掉-_-开头文件升级解法","坑一：",[26,11743,11744],{},"\u002F\u002Fgo:embed dist"," 静默漏掉 ",[26,11747,7112],{}," 开头文件（升级解法）",[15,11750,11751,11752,11759,11760,11762],{},"上篇说过：embed 目录时",[31,11753,11754,11755,7129,11757,7113],{},"硬编码排除 ",[26,11756,7112],{},[26,11758,2471],{},"。上篇的解法是改 Vite 输出文件名去掉 ",[26,11761,7112],{}," 前缀；这次直接用：",[47,11764,11766],{"className":3693,"code":11765,"language":477,"meta":52,"style":52},"\u002F\u002Fgo:embed all:dist   \u002F\u002F all: 前缀 = 包含全部文件，不再静默跳过\n",[26,11767,11768],{"__ignoreMap":52},[56,11769,11770,11773],{"class":58,"line":59},[56,11771,11772],{"class":353},"\u002F\u002Fgo:embed all:dist",[56,11774,11775],{"class":353},"   \u002F\u002F all: 前缀 = 包含全部文件，不再静默跳过\n",[15,11777,11778],{},[31,11779,11780,11781,11783,11784,11786],{},"结论：能用 ",[26,11782,10638],{}," 就用 ",[26,11785,10638],{},"，从根上消灭这类\"文件莫名 404\"的问题。",[586,11788,11790],{"id":11789},"坑二开发版是新的打包版却是旧的","坑二：开发版是新的、打包版却是旧的",[15,11792,11793,11794,11797],{},"现象：Vite 开发服务一切正常，",[26,11795,11796],{},":8080\u002Fdist\u002F"," 打包版却是旧界面，强刷也不一定好。",[15,11799,11800,11801,11803,11804,11807,11808,11813],{},"排查过程值得记录：先是怀疑 ",[26,11802,7275],{}," 缓存了旧 embed（上篇坑三的思路），重编重启无效；然后直接抓取 8080 返回的 HTML 和 JS，发现",[31,11805,11806],{},"服务端内容已经是新的","——问题出在",[31,11809,11810,11811],{},"浏览器缓存了 ",[26,11812,10772],{},"：它引用旧 hash 的资源，一直不更新。",[15,11815,11816,11818,11819,11822,11823,11826],{},[31,11817,7090],{},"：入口文件响应 ",[26,11820,11821],{},"Cache-Control: no-store","（见上面路由代码），带 hash 的资源给 ",[26,11824,11825],{},"immutable"," 长缓存。升级后浏览器每次拉新入口，资源文件名一变就自动换新，两全其美。",[9425,11828,11829],{},[15,11830,11831],{},"教训：排查\"新旧不一致\"先分清是哪一层——服务端产物、浏览器入口缓存、还是资源缓存。抓包看响应内容再动手。",[586,11833,11835],{"id":11834},"坑三windows-上跑的-build-linuxsh产出的文件-linux-却执行不了","坑三：Windows 上跑的 build-linux.sh，产出的文件 Linux 却执行不了",[15,11837,11838,11839,6319,11842,11848,11849,11851],{},"最初脚本只写了 ",[26,11840,11841],{},"CGO_ENABLED=0 go build",[31,11843,11844,11845],{},"没指定 ",[26,11846,11847],{},"GOOS\u002FGOARCH","。在 Windows 上跑，产出的其实是个 Windows PE 文件（只是没叫 ",[26,11850,7577],{},"），传到 Linux 自然「无法执行」。",[15,11853,11854,11856],{},[31,11855,7090],{},"：脚本里显式交叉编译：",[47,11858,11860],{"className":344,"code":11859,"language":346,"meta":52,"style":52},"CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o \"$BINARY\" -ldflags=\"-s -w\" .\u002Fcmd\u002Fserver\n",[26,11861,11862],{"__ignoreMap":52},[56,11863,11864,11867,11869,11871,11873,11875,11877,11879,11881,11883,11885,11887,11889,11891,11893,11895,11897,11899],{"class":58,"line":59},[56,11865,11866],{"class":66},"CGO_ENABLED",[56,11868,443],{"class":249},[56,11870,2437],{"class":362},[56,11872,11657],{"class":66},[56,11874,443],{"class":249},[56,11876,11662],{"class":362},[56,11878,11665],{"class":66},[56,11880,443],{"class":249},[56,11882,11670],{"class":362},[56,11884,846],{"class":224},[56,11886,493],{"class":362},[56,11888,502],{"class":73},[56,11890,375],{"class":362},[56,11892,11681],{"class":66},[56,11894,11684],{"class":362},[56,11896,11687],{"class":73},[56,11898,11690],{"class":362},[56,11900,11901],{"class":362}," .\u002Fcmd\u002Fserver\n",[15,11903,11904,11905,11908,11909,11912,11913],{},"能这么干的前提是",[31,11906,11907],{},"全依赖树无 cgo","——SQLite 用的是 modernc 纯 Go 驱动，bcrypt 用 ",[26,11910,11911],{},"x\u002Fcrypto","。这也是选型时就要留意的点：",[31,11914,11915],{},"想跨平台交叉编译，先把 cgo 依赖清干净。",[15,11917,11918,11919,11922,11923,11926],{},"另外两个小提醒：Windows 上编出的文件没有 Unix 执行位，上传服务器后记得 ",[26,11920,11921],{},"chmod +x","；",[26,11924,11925],{},"-ldflags=\"-s -w\""," 剥离符号，体积从 30MB+ 降到 26MB。",[586,11928,11930],{"id":11929},"坑四dist-被-gitignore-后embed-直接编译失败","坑四：dist 被 gitignore 后，embed 直接编译失败",[15,11932,11933,11935,11936,11938,11939,11942,11943,11945],{},[26,11934,10797],{}," 是构建产物、入了 .gitignore，但 ",[26,11937,6474],{}," 在",[31,11940,11941],{},"编译期","就要读它——刚 clone 下来直接 ",[26,11944,7275],{}," 会报 embed 找不到文件。",[15,11947,11948,11950,11951,11954],{},[31,11949,7090],{},"：gitignore 里保留 ",[26,11952,11953],{},"dist\u002F.gitkeep","（一个空文件），让目录存在于版本库中：",[47,11956,11960],{"className":11957,"code":11958,"language":11959,"meta":52,"style":52},"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",[26,11961,11962,11967,11972],{"__ignoreMap":52},[56,11963,11964],{"class":58,"line":59},[56,11965,11966],{},"backend\u002Fweb\u002Fdist\u002F*\n",[56,11968,11969],{"class":58,"line":70},[56,11970,11971],{},"!backend\u002Fweb\u002Fdist\u002F\n",[56,11973,11974],{"class":58,"line":86},[56,11975,11976],{},"!backend\u002Fweb\u002Fdist\u002F.gitkeep\n",[15,11978,11979,11980,11982,11983,11985],{},"这样 clone 后可以先 ",[26,11981,7275],{}," 通过，再 ",[26,11984,10790],{}," 灌入真实前端。",[586,11987,11989],{"id":11988},"坑五500kb-chunk-警告别慌","坑五：>500KB chunk 警告，别慌",[15,11991,11992,11993,11996,11997,2241],{},"Vite 构建会警告单 chunk 超过 500KB。这个项目里 Base UI 组件库全量引入，单 chunk 约 640KB——",[31,11994,11995],{},"属正常，不要为了消警告去拆配置","。真要优化就上路由级 ",[26,11998,11999],{},"React.lazy",[11,12001,7288],{"id":7288},[47,12003,12005],{"className":344,"code":12004,"language":346,"meta":52,"style":52},"$ build-win.bat        # backendmagic-img.exe\n$ bash build-linux.sh  # backendmagic-img（linux\u002Famd64 ELF）\n",[26,12006,12007,12017],{"__ignoreMap":52},[56,12008,12009,12011,12014],{"class":58,"line":59},[56,12010,2465],{"class":224},[56,12012,12013],{"class":362}," build-win.bat",[56,12015,12016],{"class":353},"        # backendmagic-img.exe\n",[56,12018,12019,12021,12023,12026],{"class":58,"line":70},[56,12020,2465],{"class":224},[56,12022,7300],{"class":362},[56,12024,12025],{"class":362}," build-linux.sh",[56,12027,12028],{"class":353},"  # backendmagic-img（linux\u002Famd64 ELF）\n",[15,12030,12031,12032,12035,12036,2241],{},"一个约 26MB 的二进制，内嵌 React 前端、Gin API、SQLite 数据初始化，拷到 Windows\u002FLinux 机器直接跑，浏览器访问 ",[26,12033,12034],{},"http:\u002F\u002Flocalhost:8080"," 自动重定向到 ",[26,12037,10776],{},[11,12039,287],{"id":287},[15,12041,12042],{},"这轮相比上篇的三点升级：",[121,12044,12045,12051,12061],{},[22,12046,12047,12050],{},[31,12048,12049],{},"hash 路由 + base \u002Fdist","：省掉 SPA fallback，静态服务即开即用；",[22,12052,12053,119,12058,12060],{},[31,12054,12055,12057],{},[26,12056,10638],{}," embed 前缀",[26,12059,7112],{}," 开头文件问题一劳永逸；",[22,12062,12063,12069],{},[31,12064,12065,12066,12068],{},"显式 ",[26,12067,11847],{}," 交叉编译","：让「一个脚本、任意平台、产出 Linux 二进制」成为现实。",[15,12071,12072,12073,12076],{},"加上",[31,12074,12075],{},"入口 no-store \u002F 资源 immutable"," 的缓存策略，单文件分发的体验才算闭环——不然每次升级都要和浏览器缓存打架。",[214,12078,12079],{},"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":52,"searchDepth":70,"depth":86,"links":12081},[12082,12083,12084,12091,12099,12100],{"id":2217,"depth":70,"text":2217},{"id":6468,"depth":70,"text":6468},{"id":6482,"depth":70,"text":6482,"children":12085},[12086,12087,12088,12089,12090],{"id":10666,"depth":86,"text":10667},{"id":10784,"depth":86,"text":10785},{"id":10840,"depth":86,"text":10841},{"id":11177,"depth":86,"text":11178},{"id":11527,"depth":86,"text":11528},{"id":7072,"depth":70,"text":7072,"children":12092},[12093,12095,12096,12097,12098],{"id":11740,"depth":86,"text":12094},"坑一：\u002F\u002Fgo:embed dist 静默漏掉 _ 开头文件（升级解法）",{"id":11789,"depth":86,"text":11790},{"id":11834,"depth":86,"text":11835},{"id":11929,"depth":86,"text":11930},{"id":11988,"depth":86,"text":11989},{"id":7288,"depth":70,"text":7288},{"id":287,"depth":70,"text":287},"以 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":10578,"description":12101},"posts\u002Fgo-embed-react-single-binary\u002Findex",[1150,9394,6522,12109,12110],"交叉编译","打包","WV-T-MOO5Cu-aycw-Ony8s9hjA3_k0S4XU-T0QsCow0",{"id":12113,"title":12114,"author":330,"body":12115,"description":13360,"difficulty":1561,"draft":309,"extension":310,"image":13361,"meta":13362,"navigation":313,"path":13363,"pinned":309,"published":12105,"seo":13364,"series":13365,"seriesOrder":59,"stem":13366,"tags":13367,"updated":10574,"__hash__":13371},"posts\u002Fposts\u002Freact-photo-album-masonry\u002Findex.md","用 react-photo-album 给生图图库做瀑布流排版",{"type":8,"value":12116,"toc":13343},[12117,12120,12123,12141,12148,12212,12223,12226,12249,12252,12256,12270,12274,12290,12468,12472,12869,12872,12906,12909,12913,12923,12929,12954,12959,12970,12976,12987,12990,13030,13043,13166,13178,13182,13189,13207,13212,13302,13308,13312,13323,13326,13329,13335,13340],[11,12118,12119],{"id":12119},"技术背景",[15,12121,12122],{},"Magic Img 是我做的一个 AI 生图提示词管理工具（自托管、本地优先），其中「生图图库」页面要把所有生成图片以图片墙的形式展示出来。图片墙有两个天然矛盾：",[121,12124,12125,12131],{},[22,12126,12127,12130],{},[31,12128,12129],{},"等宽网格","（CSS Grid）：整齐，但每行高度由最高的那张决定——不同比例的图片混排时，矮图下面会留下大洞，看起来\"很乱\"；",[22,12132,12133,12136,12137,12140],{},[31,12134,12135],{},"原生比例展示","：图库必须不裁剪（这是产品约束），所以不能用 ",[26,12138,12139],{},"object-cover"," 强行统一比例。",[15,12142,12143,12144,12147],{},"业内的标准解法是 ",[31,12145,12146],{},"Masonry 瀑布流","：按列紧密堆放，每张图保持原始比例，列间高度错落但整体紧凑。实现瀑布流的常见方案有三类：",[1683,12149,12150,12164],{},[1686,12151,12152],{},[1689,12153,12154,12156,12159,12162],{},[1692,12155,3025],{},[1692,12157,12158],{},"依赖",[1692,12160,12161],{},"顺序",[1692,12163,5567],{},[1704,12165,12166,12183,12196],{},[1689,12167,12168,12175,12177,12180],{},[1709,12169,12170,12171,12174],{},"CSS ",[26,12172,12173],{},"columns"," 多列",[1709,12176,1740],{},[1709,12178,12179],{},"纵向阅读序（先下后右）",[1709,12181,12182],{},"最新图片不再出现在左上角，时间语义丢失",[1689,12184,12185,12188,12190,12193],{},[1709,12186,12187],{},"grid row-span 自研",[1709,12189,1740],{},[1709,12191,12192],{},"保持",[1709,12194,12195],{},"需固定列数 + 固定行高，逻辑要自己写",[1689,12197,12198,12203,12206,12209],{},[1709,12199,12200],{},[31,12201,12202],{},"react-photo-album",[1709,12204,12205],{},"~4KB，无依赖",[1709,12207,12208],{},"基本保持（逐列最短列填充）",[1709,12210,12211],{},"专为相册墙设计，TS 友好，维护活跃",[15,12213,12214,12215,12218,12219,12222],{},"最终选了 ",[31,12216,12217],{},"react-photo-album v3","：体积小、类型齐全，而且它的 Masonry 模式恰好需要「每张图的 ",[26,12220,12221],{},"width\u002Fheight","」——我们的数据库里本来就存了，零额外成本。",[11,12224,12225],{"id":12225},"需求梳理",[121,12227,12228,12234,12240,12243,12246],{},[22,12229,12230,12231,11922],{},"展示全部生成图（21 张起，会持续增长），",[31,12232,12233],{},"原比例、不裁剪",[22,12235,12236,12237,11922],{},"不同尺寸（2304×1728、4608×3456 等）混排时",[31,12238,12239],{},"紧凑、不留洞",[22,12241,12242],{},"顺序基本保持「新图靠前」；",[22,12244,12245],{},"点击任意一张打开大图预览（YARL 灯箱），悬停显示所属提示词；",[22,12247,12248],{},"平板性能：墙上只用 300px 缩略图，原图只进灯箱按需加载。",[11,12250,12251],{"id":12251},"安装与使用",[586,12253,12255],{"id":12254},"_1-安装","1. 安装",[47,12257,12259],{"className":344,"code":12258,"language":346,"meta":52,"style":52},"npm install react-photo-album\n",[26,12260,12261],{"__ignoreMap":52},[56,12262,12263,12265,12267],{"class":58,"line":59},[56,12264,5832],{"class":224},[56,12266,419],{"class":362},[56,12268,12269],{"class":362}," react-photo-album\n",[586,12271,12273],{"id":12272},"_2-准备数据关键输入是宽高","2. 准备数据（关键输入是宽高）",[15,12275,12276,12277,12280,12281,7095,12283,7095,12286,12289],{},"库要求的 ",[26,12278,12279],{},"Photo"," 对象只有三个必需字段：",[26,12282,2887],{},[26,12284,12285],{},"width",[26,12287,12288],{},"height","。我们的接口本来就返回每张图的原始宽高：",[47,12291,12293],{"className":10724,"code":12292,"language":10726,"meta":52,"style":52},"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",[26,12294,12295,12313,12318,12325,12329,12345,12355,12359,12363,12396,12407,12426,12431,12436,12458,12463],{"__ignoreMap":52},[56,12296,12297,12299,12302,12304,12307,12310],{"class":58,"line":59},[56,12298,3701],{"class":249},[56,12300,12301],{"class":66}," PhotoAlbum, { ",[56,12303,5086],{"class":249},[56,12305,12306],{"class":66}," Photo } ",[56,12308,12309],{"class":249},"from",[56,12311,12312],{"class":362}," \"react-photo-album\"\n",[56,12314,12315],{"class":58,"line":70},[56,12316,12317],{"class":353},"\u002F\u002F 必须引入样式表！容器\u002F列的布局规则都在这里（坑 1 详述）\n",[56,12319,12320,12322],{"class":58,"line":86},[56,12321,3701],{"class":249},[56,12323,12324],{"class":362}," \"react-photo-album\u002Fstyles.css\"\n",[56,12326,12327],{"class":58,"line":98},[56,12328,398],{"emptyLinePlaceholder":313},[56,12330,12331,12334,12337,12340,12343],{"class":58,"line":259},[56,12332,12333],{"class":249},"interface",[56,12335,12336],{"class":224}," GalleryPhoto",[56,12338,12339],{"class":249}," extends",[56,12341,12342],{"class":224}," Photo",[56,12344,67],{"class":66},[56,12346,12347,12350,12353],{"class":58,"line":434},[56,12348,12349],{"class":2352},"  prompt_text",[56,12351,12352],{"class":249},"?:",[56,12354,5092],{"class":73},[56,12356,12357],{"class":58,"line":452},[56,12358,101],{"class":66},[56,12360,12361],{"class":58,"line":468},[56,12362,398],{"emptyLinePlaceholder":313},[56,12364,12365,12367,12370,12372,12374,12376,12378,12381,12384,12387,12389,12391,12393],{"class":58,"line":474},[56,12366,2583],{"class":249},[56,12368,12369],{"class":73}," albumPhotos",[56,12371,1276],{"class":249},[56,12373,12336],{"class":224},[56,12375,2371],{"class":66},[56,12377,443],{"class":249},[56,12379,12380],{"class":66}," images.",[56,12382,12383],{"class":224},"map",[56,12385,12386],{"class":66},"((",[56,12388,613],{"class":2352},[56,12390,2811],{"class":66},[56,12392,2775],{"class":249},[56,12394,12395],{"class":66}," ({\n",[56,12397,12398,12401,12404],{"class":58,"line":488},[56,12399,12400],{"class":66},"  key: ",[56,12402,12403],{"class":224},"String",[56,12405,12406],{"class":66},"(img.id),\n",[56,12408,12409,12412,12415,12418,12420,12423],{"class":58,"line":511},[56,12410,12411],{"class":66},"  src: ",[56,12413,12414],{"class":224},"assetUrl",[56,12416,12417],{"class":66},"(img.thumb_path) ",[56,12419,2563],{"class":249},[56,12421,12422],{"class":224}," assetUrl",[56,12424,12425],{"class":66},"(img.file_path),\n",[56,12427,12428],{"class":58,"line":520},[56,12429,12430],{"class":66},"  width: img.width,\n",[56,12432,12433],{"class":58,"line":525},[56,12434,12435],{"class":66},"  height: img.height,\n",[56,12437,12438,12441,12443,12446,12448,12450,12453,12456],{"class":58,"line":531},[56,12439,12440],{"class":66},"  alt: img.prompt_text ",[56,12442,2563],{"class":249},[56,12444,12445],{"class":362}," `生成图 ${",[56,12447,613],{"class":66},[56,12449,2471],{"class":362},[56,12451,12452],{"class":66},"id",[56,12454,12455],{"class":362},"}`",[56,12457,2006],{"class":66},[56,12459,12460],{"class":58,"line":542},[56,12461,12462],{"class":66},"  prompt_text: img.prompt_text,\n",[56,12464,12465],{"class":58,"line":553},[56,12466,12467],{"class":66},"}))\n",[586,12469,12471],{"id":12470},"_3-使用-masonry-布局","3. 使用 Masonry 布局",[47,12473,12475],{"className":10724,"code":12474,"language":10726,"meta":52,"style":52},"\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",[26,12476,12477,12484,12494,12562,12576,12589,12599,12623,12633,12654,12672,12700,12707,12741,12749,12759,12775,12785,12795,12800,12810,12827,12832,12841,12850,12855,12859,12864],{"__ignoreMap":52},[56,12478,12479,12481],{"class":58,"line":59},[56,12480,211],{"class":66},[56,12482,12483],{"class":73},"PhotoAlbum\n",[56,12485,12486,12489,12491],{"class":58,"line":70},[56,12487,12488],{"class":224},"  layout",[56,12490,443],{"class":249},[56,12492,12493],{"class":362},"\"masonry\"\n",[56,12495,12496,12499,12501,12504,12507,12509,12511,12514,12516,12519,12522,12524,12527,12530,12532,12535,12537,12540,12542,12544,12546,12549,12551,12554,12556,12559],{"class":58,"line":86},[56,12497,12498],{"class":224},"  columns",[56,12500,443],{"class":249},[56,12502,12503],{"class":66},"{(",[56,12505,12506],{"class":2352},"cw",[56,12508,2811],{"class":66},[56,12510,2775],{"class":249},[56,12512,12513],{"class":66}," (cw ",[56,12515,211],{"class":249},[56,12517,12518],{"class":73}," 640",[56,12520,12521],{"class":249}," ?",[56,12523,4886],{"class":73},[56,12525,12526],{"class":249}," :",[56,12528,12529],{"class":66}," cw ",[56,12531,211],{"class":249},[56,12533,12534],{"class":73}," 960",[56,12536,12521],{"class":249},[56,12538,12539],{"class":73}," 3",[56,12541,12526],{"class":249},[56,12543,12529],{"class":66},[56,12545,211],{"class":249},[56,12547,12548],{"class":73}," 1280",[56,12550,12521],{"class":249},[56,12552,12553],{"class":73}," 4",[56,12555,12526],{"class":249},[56,12557,12558],{"class":73}," 5",[56,12560,12561],{"class":66},")}\n",[56,12563,12564,12567,12569,12571,12574],{"class":58,"line":98},[56,12565,12566],{"class":224},"  spacing",[56,12568,443],{"class":249},[56,12570,4553],{"class":66},[56,12572,12573],{"class":73},"16",[56,12575,101],{"class":66},[56,12577,12578,12581,12583,12585,12587],{"class":58,"line":259},[56,12579,12580],{"class":224},"  padding",[56,12582,443],{"class":249},[56,12584,4553],{"class":66},[56,12586,2437],{"class":73},[56,12588,101],{"class":66},[56,12590,12591,12594,12596],{"class":58,"line":434},[56,12592,12593],{"class":224},"  photos",[56,12595,443],{"class":249},[56,12597,12598],{"class":66},"{albumPhotos}\n",[56,12600,12601,12604,12606,12609,12612,12615,12617,12620],{"class":58,"line":452},[56,12602,12603],{"class":224},"  onClick",[56,12605,443],{"class":249},[56,12607,12608],{"class":66},"{({ ",[56,12610,12611],{"class":2352},"index",[56,12613,12614],{"class":66}," }) ",[56,12616,2775],{"class":249},[56,12618,12619],{"class":224}," openPreview",[56,12621,12622],{"class":66},"(index)}\n",[56,12624,12625,12628,12630],{"class":58,"line":468},[56,12626,12627],{"class":224},"  render",[56,12629,443],{"class":249},[56,12631,12632],{"class":66},"{{\n",[56,12634,12635,12638,12641,12643,12645,12648,12650,12652],{"class":58,"line":474},[56,12636,12637],{"class":224},"    photo",[56,12639,12640],{"class":66},": (",[56,12642,2882],{"class":2352},[56,12644,1101],{"class":66},[56,12646,12647],{"class":2352},"context",[56,12649,2811],{"class":66},[56,12651,2775],{"class":249},[56,12653,67],{"class":66},[56,12655,12656,12658,12661,12664,12667,12669],{"class":58,"line":488},[56,12657,2487],{"class":249},[56,12659,12660],{"class":66}," { ",[56,12662,12663],{"class":73},"onClick",[56,12665,12666],{"class":66}," } ",[56,12668,443],{"class":249},[56,12670,12671],{"class":66}," props\n",[56,12673,12674,12676,12678,12681,12683,12685,12687,12689,12691,12693,12695,12697],{"class":58,"line":511},[56,12675,2487],{"class":249},[56,12677,12660],{"class":66},[56,12679,12680],{"class":73},"photo",[56,12682,1101],{"class":66},[56,12684,12611],{"class":73},[56,12686,1101],{"class":66},[56,12688,12285],{"class":73},[56,12690,1101],{"class":66},[56,12692,12288],{"class":73},[56,12694,12666],{"class":66},[56,12696,443],{"class":249},[56,12698,12699],{"class":66}," context\n",[56,12701,12702,12705],{"class":58,"line":520},[56,12703,12704],{"class":249},"      return",[56,12706,5099],{"class":66},[56,12708,12709,12712,12715,12718,12720,12723,12726,12728,12731,12734,12736,12739],{"class":58,"line":525},[56,12710,12711],{"class":66},"        \u003C",[56,12713,12714],{"class":62},"button",[56,12716,12717],{"class":224}," type",[56,12719,443],{"class":249},[56,12721,12722],{"class":362},"\"button\"",[56,12724,12725],{"class":224}," onClick",[56,12727,443],{"class":249},[56,12729,12730],{"class":66},"{onClick} ",[56,12732,12733],{"class":224},"className",[56,12735,443],{"class":249},[56,12737,12738],{"class":362},"\"group relative block w-full ...\"",[56,12740,217],{"class":66},[56,12742,12743,12746],{"class":58,"line":531},[56,12744,12745],{"class":66},"          \u003C",[56,12747,12748],{"class":62},"img\n",[56,12750,12751,12754,12756],{"class":58,"line":542},[56,12752,12753],{"class":224},"            src",[56,12755,443],{"class":249},[56,12757,12758],{"class":66},"{photo.src}\n",[56,12760,12761,12764,12766,12769,12771,12773],{"class":58,"line":553},[56,12762,12763],{"class":224},"            alt",[56,12765,443],{"class":249},[56,12767,12768],{"class":66},"{photo.alt ",[56,12770,2563],{"class":249},[56,12772,4797],{"class":362},[56,12774,101],{"class":66},[56,12776,12777,12780,12782],{"class":58,"line":558},[56,12778,12779],{"class":224},"            decoding",[56,12781,443],{"class":249},[56,12783,12784],{"class":362},"\"async\"\n",[56,12786,12787,12790,12792],{"class":58,"line":567},[56,12788,12789],{"class":224},"            style",[56,12791,443],{"class":249},[56,12793,12794],{"class":66},"{{ width, height }}\n",[56,12796,12797],{"class":58,"line":572},[56,12798,12799],{"class":66},"          \u002F>\n",[56,12801,12802,12805,12808],{"class":58,"line":780},[56,12803,12804],{"class":66},"          {",[56,12806,12807],{"class":353},"\u002F* hover 提示词浮层 *\u002F",[56,12809,101],{"class":66},[56,12811,12812,12814,12817,12820,12822,12825],{"class":58,"line":788},[56,12813,12745],{"class":66},[56,12815,12816],{"class":62},"div",[56,12818,12819],{"class":224}," className",[56,12821,443],{"class":249},[56,12823,12824],{"class":362},"\"... opacity-0 group-hover:opacity-100\"",[56,12826,217],{"class":66},[56,12828,12829],{"class":58,"line":793},[56,12830,12831],{"class":66},"            {photo.prompt_text}\n",[56,12833,12834,12837,12839],{"class":58,"line":1422},[56,12835,12836],{"class":66},"          \u003C\u002F",[56,12838,12816],{"class":62},[56,12840,217],{"class":66},[56,12842,12843,12846,12848],{"class":58,"line":1436},[56,12844,12845],{"class":66},"        \u003C\u002F",[56,12847,12714],{"class":62},[56,12849,217],{"class":66},[56,12851,12852],{"class":58,"line":1443},[56,12853,12854],{"class":66},"      )\n",[56,12856,12857],{"class":58,"line":1448},[56,12858,7249],{"class":66},[56,12860,12861],{"class":58,"line":1454},[56,12862,12863],{"class":66},"  }}\n",[56,12865,12866],{"class":58,"line":1462},[56,12867,12868],{"class":66},"\u002F>\n",[15,12870,12871],{},"几个要点：",[19,12873,12874,12883,12896],{},[22,12875,12876,12878,12879,12882],{},[26,12877,12173],{}," 可以是数字或 ",[26,12880,12881],{},"(containerWidth) => number"," 函数，做响应式列数很方便；",[22,12884,12885,12886,12888,12889,12892,12893,12895],{},"点击回调 ",[26,12887,12663],{}," 里拿到的是",[31,12890,12891],{},"原始数组下标"," ",[26,12894,12611],{},"，直接传给灯箱即可；",[22,12897,12898,12899,12902,12903,12905],{},"自定义 ",[26,12900,12901],{},"render.photo"," 后，图片尺寸要自己用 context 里的 ",[26,12904,12221],{},"（渲染尺寸）设置——这两个值库已经按列宽算好了。",[11,12907,12908],{"id":12908},"遇到的坑",[586,12910,12912],{"id":12911},"坑-1忘记引入样式表-一张一列","坑 1：忘记引入样式表 → 「一张一列」",[15,12914,12915,12916,12919,12920,2241],{},"这是最坑的一个。装完包、写好 ",[26,12917,12918],{},"\u003CPhotoAlbum layout=\"masonry\">","，页面渲染出来却是：",[31,12921,12922],{},"每张图占一整行，右侧大片置灰空白",[15,12924,12925,12926,12928],{},"排查过程：先怀疑是 ",[26,12927,12173],{}," 函数没生效，改成固定数字无效；再检查 DOM——容器和 5 个列（track）都在，列的宽度也算对了，但图片全都堆在布局里。",[15,12930,12931,12932,12935,12936,7095,12939,12942,12943,12949,12950,12953],{},"最后翻包的 ",[26,12933,12934],{},"package.json"," 发现它导出了独立的 CSS 文件（",[26,12937,12938],{},".\u002Fstyles.css",[26,12940,12941],{},".\u002Fmasonry.css"," 等），而",[31,12944,12945,12946],{},"官方 README 示例都带了一行 ",[26,12947,12948],{},"import \"react-photo-album\u002Fstyles.css\"","。容器是 flex、列是 flex-column、列宽按 ",[26,12951,12952],{},"--react-photo-album--columns"," 计算——这些规则全在这个样式表里，不引入就退化成普通块级堆叠。",[15,12955,12956,12958],{},[31,12957,7090],{},"：顶部加一行：",[47,12960,12962],{"className":10724,"code":12961,"language":10726,"meta":52,"style":52},"import \"react-photo-album\u002Fstyles.css\"\n",[26,12963,12964],{"__ignoreMap":52},[56,12965,12966,12968],{"class":58,"line":59},[56,12967,3701],{"class":249},[56,12969,12324],{"class":362},[15,12971,12972,12973],{},"经验：",[31,12974,12975],{},"用这类\"无运行时依赖\"的布局库，先看它的 exports 里有没有独立 CSS，有就一定要引。",[586,12977,12979,12980,12982,12983,12986],{"id":12978},"坑-2renderphoto-是-props-context-双参签名","坑 2：",[26,12981,12901],{}," 是 ",[26,12984,12985],{},"(props, context)"," 双参签名",[15,12988,12989],{},"自定义渲染时我凭直觉写了：",[47,12991,12993],{"className":10724,"code":12992,"language":10726,"meta":52,"style":52},"photo: ({ photo, index, width, height, onClick }) => ... \u002F\u002F ❌ 报错：Property 'photo' does not exist\n",[26,12994,12995],{"__ignoreMap":52},[56,12996,12997,12999,13002,13004,13006,13008,13010,13012,13014,13016,13018,13020,13022,13024,13027],{"class":58,"line":59},[56,12998,12680],{"class":224},[56,13000,13001],{"class":66},": ({ ",[56,13003,12680],{"class":2352},[56,13005,1101],{"class":66},[56,13007,12611],{"class":2352},[56,13009,1101],{"class":66},[56,13011,12285],{"class":2352},[56,13013,1101],{"class":66},[56,13015,12288],{"class":2352},[56,13017,1101],{"class":66},[56,13019,12663],{"class":2352},[56,13021,12614],{"class":66},[56,13023,2775],{"class":249},[56,13025,13026],{"class":249}," ...",[56,13028,13029],{"class":353}," \u002F\u002F ❌ 报错：Property 'photo' does not exist\n",[15,13031,13032,13033,13035,13036,13039,13040,119],{},"TS 报错提示 ",[26,13034,12680],{}," 等属性不存在于 ",[26,13037,13038],{},"RenderPhotoProps","。看类型定义才发现 v3 的渲染函数是",[31,13041,13042],{},"两个参数",[47,13044,13046],{"className":10724,"code":13045,"language":10726,"meta":52,"style":52},"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",[26,13047,13048,13094,13099,13119,13133,13158,13162],{"__ignoreMap":52},[56,13049,13050,13052,13055,13057,13060,13062,13064,13067,13069,13071,13073,13075,13078,13080,13082,13084,13087,13089,13091],{"class":58,"line":59},[56,13051,5086],{"class":249},[56,13053,13054],{"class":224}," RenderFunction",[56,13056,211],{"class":66},[56,13058,13059],{"class":224},"Props",[56,13061,1101],{"class":66},[56,13063,3907],{"class":224},[56,13065,13066],{"class":66},"> ",[56,13068,443],{"class":249},[56,13070,2391],{"class":66},[56,13072,2882],{"class":2352},[56,13074,1276],{"class":249},[56,13076,13077],{"class":224}," Props",[56,13079,1101],{"class":66},[56,13081,12647],{"class":2352},[56,13083,1276],{"class":249},[56,13085,13086],{"class":224}," Context",[56,13088,2811],{"class":66},[56,13090,2775],{"class":249},[56,13092,13093],{"class":224}," ReactNode\n",[56,13095,13096],{"class":58,"line":70},[56,13097,13098],{"class":353},"\u002F\u002F photo 的渲染上下文（photo\u002Findex\u002Fwidth\u002Fheight）在第二个参数里\n",[56,13100,13101,13103,13105,13107,13109,13111,13113,13115,13117],{"class":58,"line":86},[56,13102,12680],{"class":224},[56,13104,1276],{"class":249},[56,13106,2391],{"class":66},[56,13108,2882],{"class":2352},[56,13110,1101],{"class":66},[56,13112,12647],{"class":2352},[56,13114,2811],{"class":66},[56,13116,2775],{"class":249},[56,13118,67],{"class":66},[56,13120,13121,13123,13125,13127,13130],{"class":58,"line":98},[56,13122,2836],{"class":224},[56,13124,12660],{"class":66},[56,13126,12663],{"class":2352},[56,13128,13129],{"class":66}," } = ",[56,13131,13132],{"class":2352},"props\n",[56,13134,13135,13137,13139,13141,13143,13145,13147,13149,13151,13153,13155],{"class":58,"line":259},[56,13136,2836],{"class":224},[56,13138,12660],{"class":66},[56,13140,12680],{"class":2352},[56,13142,1101],{"class":66},[56,13144,12611],{"class":2352},[56,13146,1101],{"class":66},[56,13148,12285],{"class":2352},[56,13150,1101],{"class":66},[56,13152,12288],{"class":2352},[56,13154,13129],{"class":66},[56,13156,13157],{"class":2352},"context\n",[56,13159,13160],{"class":58,"line":434},[56,13161,10717],{"class":249},[56,13163,13164],{"class":58,"line":452},[56,13165,101],{"class":66},[15,13167,13168,13170,13171,13173,13174,13177],{},[31,13169,7090],{},"：按 ",[26,13172,12985],{}," 拆解即可。装新库先扫一眼 ",[26,13175,13176],{},".d.ts"," 里的 Render 类型签名，能少走很多弯路。",[586,13179,13181],{"id":13180},"坑-3测试断言把dom-顺序当成了视觉顺序","坑 3：测试断言把「DOM 顺序」当成了「视觉顺序」",[15,13183,13184,13185,13188],{},"布局修好后，我在浏览器自测脚本里加了一条断言：取前两个 ",[26,13186,13187],{},"\u003Cimg>","，比较它们的左坐标，认为应该并排（不同列）。",[15,13190,13191,13192,13195,13196,7129,13199,13202,13203,13206],{},"结果断言失败——但页面明明是 5 列并排。排查发现：",[31,13193,13194],{},"瀑布流的 DOM 是按\"列\"组织的","（第 1 列的全部图片在前，第 2 列的在后），所以 ",[26,13197,13198],{},"img[0]",[26,13200,13201],{},"img[1]"," 属于",[31,13204,13205],{},"同一列","，左坐标当然相同。",[15,13208,13209,13211],{},[31,13210,7090],{},"：断言改为比较「第 1 列的首张图 vs 第 2 列的首张图」：",[47,13213,13217],{"className":13214,"code":13215,"language":13216,"meta":52,"style":52},"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",[26,13218,13219,13244,13267],{"__ignoreMap":52},[56,13220,13221,13223,13225,13227,13230,13232,13234,13237,13239,13242],{"class":58,"line":59},[56,13222,2583],{"class":249},[56,13224,234],{"class":73},[56,13226,1993],{"class":249},[56,13228,13229],{"class":66}," tracks[",[56,13231,2437],{"class":73},[56,13233,2502],{"class":66},[56,13235,13236],{"class":224},"querySelector",[56,13238,228],{"class":66},[56,13240,13241],{"class":362},"\"img\"",[56,13243,2659],{"class":66},[56,13245,13246,13248,13251,13253,13255,13257,13259,13261,13263,13265],{"class":58,"line":70},[56,13247,2583],{"class":249},[56,13249,13250],{"class":73}," b",[56,13252,1993],{"class":249},[56,13254,13229],{"class":66},[56,13256,462],{"class":73},[56,13258,2502],{"class":66},[56,13260,13236],{"class":224},[56,13262,228],{"class":66},[56,13264,13241],{"class":362},[56,13266,2659],{"class":66},[56,13268,13269,13271,13274,13277,13280,13283,13286,13289,13292,13294,13297,13299],{"class":58,"line":86},[56,13270,2400],{"class":249},[56,13272,13273],{"class":66}," Math.",[56,13275,13276],{"class":224},"abs",[56,13278,13279],{"class":66},"(a.",[56,13281,13282],{"class":224},"getBoundingClientRect",[56,13284,13285],{"class":66},"().left ",[56,13287,13288],{"class":249},"-",[56,13290,13291],{"class":66}," b.",[56,13293,13282],{"class":224},[56,13295,13296],{"class":66},"().left) ",[56,13298,892],{"class":249},[56,13300,13301],{"class":73}," 10\n",[15,13303,12972,13304,13307],{},[31,13305,13306],{},"验证布局类库时，先搞清楚它生成的 DOM 结构，再写断言","；另外把「真实浏览器探针」当排查手段（直接打印各 track 的 x 坐标和子元素数量），比盯着代码猜快得多。",[586,13309,13311],{"id":13310},"坑-4等宽网格的洞引入前就存在的产品问题","坑 4：等宽网格的\"洞\"（引入前就存在的产品问题）",[15,13313,13314,13315,13318,13319,13322],{},"顺带记录一下为什么换掉原来的布局：CSS Grid ",[26,13316,13317],{},"repeat(auto-fill, minmax(220px, 1fr))"," 等宽铺满，图片 ",[26,13320,13321],{},"w-full h-auto","。当一行里混有不同比例时，行高取最高的那张，矮图底部留空，几行错落下来就显得乱；另外窗口宽度\"差一点放不下下一张\"时，右侧会空出近一整列。",[15,13324,13325],{},"换成 Masonry 后这两个问题都消失了——列内紧密堆叠、列数自适应。",[11,13327,13328],{"id":13328},"效果与小结",[15,13330,13331,13332,13334],{},"最终效果：21 张不同比例的生成图在 5 列瀑布流里紧密排列，原比例不裁剪，悬停出提示词浮层，点击进 YARL 大图预览；缩略图总大小仅 360KB，进页面即全量加载 + ",[26,13333,11825],{}," 长缓存，平板上快速滚动也跟手。",[15,13336,10503,13337],{},[31,13338,13339],{},"react-photo-album 是个小但专业的相册布局库，只要记住三件事——引样式表、喂宽高、render 函数是双参——就能在十分钟内把图片墙升级成真正的瀑布流。",[214,13341,13342],{},"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":52,"searchDepth":70,"depth":86,"links":13344},[13345,13346,13347,13352,13359],{"id":12119,"depth":70,"text":12119},{"id":12225,"depth":70,"text":12225},{"id":12251,"depth":70,"text":12251,"children":13348},[13349,13350,13351],{"id":12254,"depth":86,"text":12255},{"id":12272,"depth":86,"text":12273},{"id":12470,"depth":86,"text":12471},{"id":12908,"depth":70,"text":12908,"children":13353},[13354,13355,13357,13358],{"id":12911,"depth":86,"text":12912},{"id":12978,"depth":86,"text":13356},"坑 2：render.photo 是 (props, context) 双参签名",{"id":13180,"depth":86,"text":13181},{"id":13310,"depth":86,"text":13311},{"id":13328,"depth":70,"text":13328},"记录在 Magic Img 生图图库中引入 react-photo-album 实现 Masonry 瀑布流的过程：技术选型背景、需求梳理、安装使用，以及「忘记引入样式表导致一张一列」等踩坑与排查方法。","\u002Fposts\u002Freact-photo-album-masonry\u002Fimg\u002Fcover.svg",{},"\u002Fposts\u002Freact-photo-album-masonry",{"title":12114,"description":13360},"前端工程实践","posts\u002Freact-photo-album-masonry\u002Findex",[9394,12202,13368,13369,13370],"瀑布流","前端","Tailwind","-O-kAQLEyRzwVhOpuo-yBxZim9LtvhChPkpP92ipyjM",1786887349178]