[{"data":1,"prerenderedAt":13470},["ShallowReactive",2],{"post-hello-world":3,"series-source-hello-world":465},{"id":4,"title":5,"author":6,"body":7,"description":449,"difficulty":450,"draft":451,"extension":452,"image":453,"meta":454,"navigation":393,"path":455,"pinned":393,"published":456,"seo":457,"series":458,"seriesOrder":203,"stem":459,"tags":460,"updated":463,"__hash__":464},"posts\u002Fposts\u002Fhello-world\u002Findex.md","Hello World - 欢迎使用 SuBlog",null,{"type":8,"value":9,"toc":440},"minimark",[10,15,24,27,30,33,113,116,119,183,186,189,276,279,348,351,354,372,379,382,414,417,428,431,436],[11,12,14],"h2",{"id":13},"博客上线啦","博客上线啦！",[16,17,18,19,23],"p",{},"Hello World！欢迎使用 ",[20,21,22],"strong",{},"SuBlog","，这是站点的第一篇示例文章。",[16,25,26],{},"经过一段时间的折腾，这个基于 Nuxt 3 的个人博客终于上线了。在这里记录一下这个项目的技术选型和功能特性。",[11,28,29],{"id":29},"技术栈",[16,31,32],{},"本站使用了以下技术：",[34,35,36,49],"table",{},[37,38,39],"thead",{},[40,41,42,46],"tr",{},[43,44,45],"th",{},"技术",[43,47,48],{},"用途",[50,51,52,63,73,83,93,103],"tbody",{},[40,53,54,60],{},[55,56,57],"td",{},[20,58,59],{},"Nuxt 3",[55,61,62],{},"Vue 3 全栈框架，提供 SSG、路由、API 等能力",[40,64,65,70],{},[55,66,67],{},[20,68,69],{},"TypeScript",[55,71,72],{},"类型安全，提升开发体验",[40,74,75,80],{},[55,76,77],{},[20,78,79],{},"Tailwind CSS v4",[55,81,82],{},"原子化 CSS，快速构建 UI",[40,84,85,90],{},[55,86,87],{},[20,88,89],{},"@nuxt\u002Fcontent",[55,91,92],{},"Markdown 驱动的内容管理",[40,94,95,100],{},[55,96,97],{},[20,98,99],{},"Shiki",[55,101,102],{},"代码语法高亮",[40,104,105,110],{},[55,106,107],{},[20,108,109],{},"Giscus",[55,111,112],{},"基于 GitHub Discussions 的评论系统",[11,114,115],{"id":115},"功能特性",[16,117,118],{},"目前已实现的功能：",[120,121,122,129,135,141,147,153,159,165,171,177],"ul",{},[123,124,125,128],"li",{},[20,126,127],{},"博客系统"," - Markdown 文章，支持代码高亮、目录导航、搜索",[123,130,131,134],{},[20,132,133],{},"暗色模式"," - 跟随系统或手动切换",[123,136,137,140],{},[20,138,139],{},"文章搜索"," - 支持标题、描述、正文、标签多维度搜索",[123,142,143,146],{},[20,144,145],{},"标签系统"," - 文章标签分类，高频标签快速筛选",[123,148,149,152],{},[20,150,151],{},"友链页面"," - 展示友情链接，支持申请",[123,154,155,158],{},[20,156,157],{},"赞助页面"," - 微信收款码展示",[123,160,161,164],{},[20,162,163],{},"封面制作"," - 在线生成封面图片工具",[123,166,167,170],{},[20,168,169],{},"网易云热评"," - 首页随机展示音乐评论",[123,172,173,176],{},[20,174,175],{},"访客统计"," - 不蒜子计数器，带数字滚动动画",[123,178,179,182],{},[20,180,181],{},"SEO 优化"," - 自动生成站点地图、Open Graph 标签",[11,184,185],{"id":185},"代码示例",[16,187,188],{},"本站的配置非常集中，一个文件搞定全站定制：",[190,191,196],"pre",{"className":192,"code":193,"language":194,"meta":195,"style":195},"language-typescript shiki shiki-themes github-light github-dark","\u002F\u002F config\u002Fsite.ts\nexport const siteConfig = {\n  siteName: '我的博客',\n  url: 'https:\u002F\u002Fexample.com\u002F',\n  avatar: 'https:\u002F\u002Fq2.qlogo.cn\u002Fheadimg_dl?dst_uin=xxx&spec=100',\n  \u002F\u002F ... 改这一个文件，全站生效\n}\n","typescript","",[197,198,199,208,229,242,253,264,270],"code",{"__ignoreMap":195},[200,201,204],"span",{"class":202,"line":203},"line",1,[200,205,207],{"class":206},"sHbNN","\u002F\u002F config\u002Fsite.ts\n",[200,209,211,215,218,222,225],{"class":202,"line":210},2,[200,212,214],{"class":213},"siTax","export",[200,216,217],{"class":213}," const",[200,219,221],{"class":220},"suQ91"," siteConfig",[200,223,224],{"class":213}," =",[200,226,228],{"class":227},"sIX_F"," {\n",[200,230,232,235,239],{"class":202,"line":231},3,[200,233,234],{"class":227},"  siteName: ",[200,236,238],{"class":237},"scXbn","'我的博客'",[200,240,241],{"class":227},",\n",[200,243,245,248,251],{"class":202,"line":244},4,[200,246,247],{"class":227},"  url: ",[200,249,250],{"class":237},"'https:\u002F\u002Fexample.com\u002F'",[200,252,241],{"class":227},[200,254,256,259,262],{"class":202,"line":255},5,[200,257,258],{"class":227},"  avatar: ",[200,260,261],{"class":237},"'https:\u002F\u002Fq2.qlogo.cn\u002Fheadimg_dl?dst_uin=xxx&spec=100'",[200,263,241],{"class":227},[200,265,267],{"class":202,"line":266},6,[200,268,269],{"class":206},"  \u002F\u002F ... 改这一个文件，全站生效\n",[200,271,273],{"class":202,"line":272},7,[200,274,275],{"class":227},"}\n",[16,277,278],{},"文章的 frontmatter 也很简洁：",[190,280,284],{"className":281,"code":282,"language":283,"meta":195,"style":195},"language-yaml shiki shiki-themes github-light github-dark","---\ntitle: 文章标题\npublished: 2026-05-31T08:00:00\ndescription: 文章简介\ntags: ['Nuxt', 'TypeScript']\n---\n","yaml",[197,285,286,292,304,314,324,344],{"__ignoreMap":195},[200,287,288],{"class":202,"line":203},[200,289,291],{"class":290},"sw2iP","---\n",[200,293,294,298,301],{"class":202,"line":210},[200,295,297],{"class":296},"sbB4o","title",[200,299,300],{"class":227},": ",[200,302,303],{"class":237},"文章标题\n",[200,305,306,309,311],{"class":202,"line":231},[200,307,308],{"class":296},"published",[200,310,300],{"class":227},[200,312,313],{"class":220},"2026-05-31T08:00:00\n",[200,315,316,319,321],{"class":202,"line":244},[200,317,318],{"class":296},"description",[200,320,300],{"class":227},[200,322,323],{"class":237},"文章简介\n",[200,325,326,329,332,335,338,341],{"class":202,"line":255},[200,327,328],{"class":296},"tags",[200,330,331],{"class":227},": [",[200,333,334],{"class":237},"'Nuxt'",[200,336,337],{"class":227},", ",[200,339,340],{"class":237},"'TypeScript'",[200,342,343],{"class":227},"]\n",[200,345,346],{"class":202,"line":266},[200,347,291],{"class":290},[11,349,350],{"id":350},"部署",[16,352,353],{},"本站使用 SSG 静态生成，构建后直接部署到静态托管即可：",[190,355,359],{"className":356,"code":357,"language":358,"meta":195,"style":195},"language-bash shiki shiki-themes github-light github-dark","npx nuxi generate\n","bash",[197,360,361],{"__ignoreMap":195},[200,362,363,366,369],{"class":202,"line":203},[200,364,365],{"class":290},"npx",[200,367,368],{"class":237}," nuxi",[200,370,371],{"class":237}," generate\n",[16,373,374,375,378],{},"构建产物在 ",[197,376,377],{},".output\u002Fpublic"," 目录下，可以直接丢到 Nginx、Vercel、Netlify 等平台。",[11,380,381],{"id":381},"后续计划",[120,383,386,396,402,408],{"className":384},[385],"contains-task-list",[123,387,390,395],{"className":388},[389],"task-list-item",[391,392],"input",{"disabled":393,"type":394},true,"checkbox"," RSS 订阅",[123,397,399,401],{"className":398},[389],[391,400],{"disabled":393,"type":394}," 文章目录优化",[123,403,405,407],{"className":404},[389],[391,406],{"disabled":393,"type":394}," 更多小工具",[123,409,411,413],{"className":410},[389],[391,412],{"disabled":393,"type":394}," 持续输出内容",[11,415,416],{"id":416},"致谢",[16,418,419,420,427],{},"感谢 ",[421,422,426],"a",{"href":423,"rel":424},"https:\u002F\u002F2x.nz",[425],"nofollow","二叉树树"," 的开源项目，本站的 UI 设计参考了他的项目。",[429,430],"hr",{},[16,432,433],{},[20,434,435],{},"感谢阅读！如果觉得不错，欢迎点个 Star 或者留个评论 (☞ﾟヮﾟ)☞",[437,438,439],"style",{},"html pre.shiki code .sHbNN, html code.shiki .sHbNN{--shiki-light:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .siTax, html code.shiki .siTax{--shiki-light:#D73A49;--shiki-dark:#F97583}html pre.shiki code .suQ91, html code.shiki .suQ91{--shiki-light:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sIX_F, html code.shiki .sIX_F{--shiki-light:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .scXbn, html code.shiki .scXbn{--shiki-light:#032F62;--shiki-dark:#9ECBFF}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sw2iP, html code.shiki .sw2iP{--shiki-light:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sbB4o, html code.shiki .sbB4o{--shiki-light:#22863A;--shiki-dark:#85E89D}",{"title":195,"searchDepth":210,"depth":231,"links":441},[442,443,444,445,446,447,448],{"id":13,"depth":210,"text":14},{"id":29,"depth":210,"text":29},{"id":115,"depth":210,"text":115},{"id":185,"depth":210,"text":185},{"id":350,"depth":210,"text":350},{"id":381,"depth":210,"text":381},{"id":416,"depth":210,"text":416},"博客上线啦！这篇文章介绍本站的技术架构、功能特性和搭建过程中的一些思考。","入门",false,"md","\u002Fposts\u002Fhello-world\u002Fimg\u002Fcover.jpg",{},"\u002Fposts\u002Fhello-world","2026-05-31T08:00:00",{"title":5,"description":449},"Nuxt 博客开发","posts\u002Fhello-world\u002Findex",[461,462],"博客","Nuxt","2026-05-31","f9MCboCBfQIUxT8aKv6aKGDFxysiG8ZG0YXr5X5LTFo",[466,1285,1705,1925,2226,3417,3696,5765,6547,7526,8935,9495,10674,12210],{"id":467,"title":468,"author":6,"body":469,"description":1271,"difficulty":1272,"draft":451,"extension":452,"image":1273,"meta":1274,"navigation":393,"path":1275,"pinned":451,"published":1276,"seo":1277,"series":1278,"seriesOrder":203,"stem":1279,"tags":1280,"updated":1276,"__hash__":1284},"posts\u002Fposts\u002Fdocker-musl-build\u002Findex.md","Docker 编译 musl 可执行文件",{"type":8,"value":470,"toc":1260},[471,474,477,480,716,720,763,766,784,787,937,940,1076,1079,1082,1140,1143,1257],[11,472,473],{"id":473},"解决问题",[16,475,476],{},"用 Go 进行跨平台编译时往往步骤复杂，用 Docker 可以轻松解决。但 Docker 的当前最小镜像 Alpine 其动态链接库采用 musl，与主流的 glibc 不同，往往不能方便执行。",[11,478,479],{"id":479},"查阅资料",[190,481,483],{"className":356,"code":482,"language":358,"meta":195,"style":195},"# 启动容器并挂载当前目录到 `\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",[197,484,485,490,528,533,538,566,582,597,603,617,640,649,654,660,671,682,687,696,701],{"__ignoreMap":195},[200,486,487],{"class":202,"line":203},[200,488,489],{"class":206},"# 启动容器并挂载当前目录到 `\u002Fapp`\n",[200,491,492,495,498,501,504,507,510,513,516,519,522,525],{"class":202,"line":210},[200,493,494],{"class":290},"docker",[200,496,497],{"class":237}," run",[200,499,500],{"class":220}," -it",[200,502,503],{"class":220}," --rm",[200,505,506],{"class":220}," -v",[200,508,509],{"class":237}," \"",[200,511,512],{"class":227},"$PWD",[200,514,515],{"class":237},"\":\u002Fapp",[200,517,518],{"class":220}," -w",[200,520,521],{"class":237}," \u002Fapp",[200,523,524],{"class":237}," ubuntu:20.04",[200,526,527],{"class":237}," bash\n",[200,529,530],{"class":202,"line":231},[200,531,532],{"emptyLinePlaceholder":393},"\n",[200,534,535],{"class":202,"line":244},[200,536,537],{"class":206},"# 在容器内执行以下操作：\n",[200,539,540,543,546,549,551,554,557,560,563],{"class":202,"line":255},[200,541,542],{"class":290},"apt-get",[200,544,545],{"class":237}," update",[200,547,548],{"class":227}," && ",[200,550,542],{"class":290},[200,552,553],{"class":237}," install",[200,555,556],{"class":220}," -y",[200,558,559],{"class":237}," musl-tools",[200,561,562],{"class":237}," golang-go",[200,564,565],{"class":206},"  # 安装 musl 和 Go\n",[200,567,568,570,573,576,579],{"class":202,"line":266},[200,569,214],{"class":213},[200,571,572],{"class":227}," CC",[200,574,575],{"class":213},"=",[200,577,578],{"class":227},"musl-gcc  ",[200,580,581],{"class":206},"# 指定 musl 编译器\n",[200,583,584,586,589,591,594],{"class":202,"line":272},[200,585,214],{"class":213},[200,587,588],{"class":227}," CGO_ENABLED",[200,590,575],{"class":213},[200,592,593],{"class":220},"1",[200,595,596],{"class":206},"  # 启用 CGO\n",[200,598,600],{"class":202,"line":599},8,[200,601,602],{"class":206},"# 设置代理\n",[200,604,606,609,612,614],{"class":202,"line":605},9,[200,607,608],{"class":290},"go",[200,610,611],{"class":237}," env",[200,613,518],{"class":220},[200,615,616],{"class":237}," GOPROXY=https:\u002F\u002Fgoproxy.cn,direct\n",[200,618,620,622,625,628,631,634,637],{"class":202,"line":619},10,[200,621,608],{"class":290},[200,623,624],{"class":237}," build",[200,626,627],{"class":220}," -ldflags",[200,629,630],{"class":237}," '-linkmode external -extldflags \"-static\"'",[200,632,633],{"class":220}," -o",[200,635,636],{"class":237}," myapp",[200,638,639],{"class":206},"  # 静态编译\n",[200,641,643,646],{"class":202,"line":642},11,[200,644,645],{"class":220},"exit",[200,647,648],{"class":206},"  # 退出容器\n",[200,650,652],{"class":202,"line":651},12,[200,653,532],{"emptyLinePlaceholder":393},[200,655,657],{"class":202,"line":656},13,[200,658,659],{"class":206},"# 验证结果（在宿主机执行）\n",[200,661,663,666,668],{"class":202,"line":662},14,[200,664,665],{"class":290},"file",[200,667,636],{"class":237},[200,669,670],{"class":206},"  # 应显示 \"statically linked\"\n",[200,672,674,677,679],{"class":202,"line":673},15,[200,675,676],{"class":290},"ldd",[200,678,636],{"class":237},[200,680,681],{"class":206},"   # 应显示 \"not a dynamic executable\"\n",[200,683,685],{"class":202,"line":684},16,[200,686,532],{"emptyLinePlaceholder":393},[200,688,690,693],{"class":202,"line":689},17,[200,691,692],{"class":290},"wget",[200,694,695],{"class":237}," https:\u002F\u002Fgo.dev\u002Fdl\u002Fgo1.21.linux-amd64.tar.gz\n",[200,697,699],{"class":202,"line":698},18,[200,700,532],{"emptyLinePlaceholder":393},[200,702,704,707,710,713],{"class":202,"line":703},19,[200,705,706],{"class":290},"ENV",[200,708,709],{"class":237}," PATH=\"\u002Fdata\u002Fgo\u002Fbin:",[200,711,712],{"class":227},"$PATH",[200,714,715],{"class":237},"\"\n",[717,718,719],"h3",{"id":719},"遇到问题",[721,722,723,749],"ol",{},[123,724,725,728,729,732,735,736,739,741,742,744],{},[197,726,727],{},"go mod tidy"," 时报错 ",[197,730,731],{},"tls: failed to verify certificate: x509: certificate",[733,734],"br",{},"解决：",[197,737,738],{},"RUN apt-get update && apt-get install -y ca-certificates && rm -rf \u002Fvar\u002Flib\u002Fapt\u002Flists\u002F*",[733,740],{},"基础镜像的证书未更新，导致 https 的请求出现类似错误。",[733,743],{},[745,746],"img",{"alt":747,"src":748},"image-20250327100813512","\u002Fposts\u002Fdocker-musl-build\u002Fimg\u002Fimage-20250327100813512.png",[123,750,751,752,755,756,758,759,762],{},"直接安装 ",[197,753,754],{},"golang-go"," 其版本过低",[733,757],{},"解决: ",[197,760,761],{},"PATH=\"\u002Fdata\u002Fgo\u002Fbin:$PATH\""," 手动指定环境",[11,764,765],{"id":765},"最终方案",[721,767,768,775,778],{},[123,769,770,771,774],{},"下载 ",[197,772,773],{},"go1.24.1.linux-amd64.tar.gz"," 解压",[123,776,777],{},"编写 Dockerfile",[123,779,780,781],{},"执行 ",[197,782,783],{},"docker build -t go-build:v1 .",[16,785,786],{},"Dockerfile 内容：",[190,788,792],{"className":789,"code":790,"language":791,"meta":195,"style":195},"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",[197,793,794,802,806,817,821,829,833,843,847,855,859,864,871,875,882,887,894,898,906,910,918,923],{"__ignoreMap":195},[200,795,796,799],{"class":202,"line":203},[200,797,798],{"class":213},"FROM",[200,800,801],{"class":227}," ubuntu:20.04\n",[200,803,804],{"class":202,"line":210},[200,805,532],{"emptyLinePlaceholder":393},[200,807,808,811,814],{"class":202,"line":231},[200,809,810],{"class":213},"LABEL",[200,812,813],{"class":227}," org.opencontainers.image.authors=",[200,815,816],{"class":237},"\"sutong\"\n",[200,818,819],{"class":202,"line":244},[200,820,532],{"emptyLinePlaceholder":393},[200,822,823,826],{"class":202,"line":255},[200,824,825],{"class":213},"COPY",[200,827,828],{"class":227}," go\u002F \u002Fgo\u002F\n",[200,830,831],{"class":202,"line":266},[200,832,532],{"emptyLinePlaceholder":393},[200,834,835,837,840],{"class":202,"line":272},[200,836,706],{"class":213},[200,838,839],{"class":227}," PATH=",[200,841,842],{"class":237},"\"\u002Fgo\u002Fbin:$PATH\"\n",[200,844,845],{"class":202,"line":599},[200,846,532],{"emptyLinePlaceholder":393},[200,848,849,852],{"class":202,"line":605},[200,850,851],{"class":213},"RUN",[200,853,854],{"class":227}," apt-get update && apt-get install -y musl-tools\n",[200,856,857],{"class":202,"line":619},[200,858,532],{"emptyLinePlaceholder":393},[200,860,861],{"class":202,"line":642},[200,862,863],{"class":206},"## go mod tidy 时报错 tls: failed to verify certificate: x509: certificate\n",[200,865,866,868],{"class":202,"line":651},[200,867,851],{"class":213},[200,869,870],{"class":227}," apt-get update && apt-get install -y ca-certificates && rm -rf \u002Fvar\u002Flib\u002Fapt\u002Flists\u002F*\n",[200,872,873],{"class":202,"line":656},[200,874,581],{"class":206},[200,876,877,879],{"class":202,"line":662},[200,878,706],{"class":213},[200,880,881],{"class":227}," CC=musl-gcc\n",[200,883,884],{"class":202,"line":673},[200,885,886],{"class":206},"# 启用 CGO\n",[200,888,889,891],{"class":202,"line":684},[200,890,706],{"class":213},[200,892,893],{"class":227}," CGO_ENABLED=1\n",[200,895,896],{"class":202,"line":689},[200,897,532],{"emptyLinePlaceholder":393},[200,899,900,903],{"class":202,"line":698},[200,901,902],{"class":213},"VOLUME",[200,904,905],{"class":227}," \u002Fdata\n",[200,907,908],{"class":202,"line":703},[200,909,532],{"emptyLinePlaceholder":393},[200,911,913,916],{"class":202,"line":912},20,[200,914,915],{"class":213},"WORKDIR",[200,917,905],{"class":227},[200,919,921],{"class":202,"line":920},21,[200,922,532],{"emptyLinePlaceholder":393},[200,924,926,929,932,935],{"class":202,"line":925},22,[200,927,928],{"class":213},"CMD",[200,930,931],{"class":227}," [",[200,933,934],{"class":237},"\"\u002Fbin\u002Fbash\"",[200,936,343],{"class":227},[717,938,939],{"id":939},"构建",[190,941,943],{"className":356,"code":942,"language":358,"meta":195,"style":195},"# 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",[197,944,945,950,954,959,985,989,994,1029,1034,1038,1071],{"__ignoreMap":195},[200,946,947],{"class":202,"line":203},[200,948,949],{"class":206},"# docker build -t go-build:v1 .\n",[200,951,952],{"class":202,"line":210},[200,953,532],{"emptyLinePlaceholder":393},[200,955,956],{"class":202,"line":231},[200,957,958],{"class":206},"# 直接执行编译 编译后删除\n",[200,960,961,963,965,967,969,972,975,978,980,982],{"class":202,"line":244},[200,962,494],{"class":290},[200,964,497],{"class":237},[200,966,503],{"class":220},[200,968,506],{"class":220},[200,970,971],{"class":237}," .:\u002Fdata",[200,973,974],{"class":237}," go-build:v1",[200,976,977],{"class":237}," go",[200,979,624],{"class":237},[200,981,633],{"class":220},[200,983,984],{"class":237}," app_musl\n",[200,986,987],{"class":202,"line":255},[200,988,532],{"emptyLinePlaceholder":393},[200,990,991],{"class":202,"line":266},[200,992,993],{"class":206},"# 构建编译环境\n",[200,995,996,998,1000,1002,1005,1007,1009,1012,1015,1018,1021,1024,1026],{"class":202,"line":272},[200,997,494],{"class":290},[200,999,497],{"class":237},[200,1001,500],{"class":220},[200,1003,1004],{"class":220}," -d",[200,1006,506],{"class":220},[200,1008,971],{"class":237},[200,1010,1011],{"class":220}," --name",[200,1013,1014],{"class":213}," \u003C",[200,1016,1017],{"class":237},"容器名",[200,1019,1020],{"class":227},"称",[200,1022,1023],{"class":213},">",[200,1025,974],{"class":237},[200,1027,1028],{"class":237}," \u002Fbin\u002Fbash\n",[200,1030,1031],{"class":202,"line":599},[200,1032,1033],{"class":206},"# eg：docker run -it -d -v .:\u002Fdata --name logbus-build go-build:v1 \u002Fbin\u002Fbash\n",[200,1035,1036],{"class":202,"line":605},[200,1037,532],{"emptyLinePlaceholder":393},[200,1039,1040,1042,1045,1047,1049,1051,1053,1055,1057,1059,1061,1063,1066,1068],{"class":202,"line":619},[200,1041,494],{"class":290},[200,1043,1044],{"class":237}," exec",[200,1046,500],{"class":220},[200,1048,1014],{"class":213},[200,1050,1017],{"class":237},[200,1052,1020],{"class":227},[200,1054,1023],{"class":213},[200,1056,977],{"class":237},[200,1058,624],{"class":237},[200,1060,633],{"class":220},[200,1062,1014],{"class":213},[200,1064,1065],{"class":237},"myap",[200,1067,16],{"class":227},[200,1069,1070],{"class":213},">\n",[200,1072,1073],{"class":202,"line":642},[200,1074,1075],{"class":206},"# eg：docker exec -it logbus-build go build -o logbus_musl_1\n",[717,1077,1078],{"id":1078},"额外注意",[16,1080,1081],{},"若没科学上网需要设置代理：",[190,1083,1085],{"className":356,"code":1084,"language":358,"meta":195,"style":195},"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",[197,1086,1087,1105,1115,1131],{"__ignoreMap":195},[200,1088,1089,1091,1093,1095,1097,1099,1101,1103],{"class":202,"line":203},[200,1090,494],{"class":290},[200,1092,1044],{"class":237},[200,1094,500],{"class":220},[200,1096,1014],{"class":213},[200,1098,1017],{"class":237},[200,1100,1020],{"class":227},[200,1102,1023],{"class":213},[200,1104,1028],{"class":237},[200,1106,1107,1109,1111,1113],{"class":202,"line":210},[200,1108,608],{"class":290},[200,1110,611],{"class":237},[200,1112,518],{"class":220},[200,1114,616],{"class":237},[200,1116,1117,1119,1121,1123,1125,1127,1129],{"class":202,"line":231},[200,1118,608],{"class":290},[200,1120,624],{"class":237},[200,1122,627],{"class":220},[200,1124,630],{"class":237},[200,1126,633],{"class":220},[200,1128,636],{"class":237},[200,1130,639],{"class":206},[200,1132,1133,1135,1137],{"class":202,"line":244},[200,1134,608],{"class":290},[200,1136,624],{"class":237},[200,1138,1139],{"class":206}," # musl 库编译\n",[717,1141,1142],{"id":1142},"运行环境示例",[190,1144,1146],{"className":789,"code":1145,"language":791,"meta":195,"style":195},"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",[197,1147,1148,1155,1159,1167,1171,1178,1182,1189,1196,1200,1207,1211,1218,1222,1243,1247,1252],{"__ignoreMap":195},[200,1149,1150,1152],{"class":202,"line":203},[200,1151,798],{"class":213},[200,1153,1154],{"class":227}," alpine:latest\n",[200,1156,1157],{"class":202,"line":210},[200,1158,532],{"emptyLinePlaceholder":393},[200,1160,1161,1163,1165],{"class":202,"line":231},[200,1162,810],{"class":213},[200,1164,813],{"class":227},[200,1166,816],{"class":237},[200,1168,1169],{"class":202,"line":244},[200,1170,532],{"emptyLinePlaceholder":393},[200,1172,1173,1175],{"class":202,"line":255},[200,1174,825],{"class":213},[200,1176,1177],{"class":227}," glog_musl \u002Fapp\u002F\n",[200,1179,1180],{"class":202,"line":266},[200,1181,532],{"emptyLinePlaceholder":393},[200,1183,1184,1186],{"class":202,"line":272},[200,1185,825],{"class":213},[200,1187,1188],{"class":227}," conf.ini \u002Fdata\u002F\n",[200,1190,1191,1193],{"class":202,"line":599},[200,1192,825],{"class":213},[200,1194,1195],{"class":227}," ipname.ini \u002Fdata\u002F\n",[200,1197,1198],{"class":202,"line":605},[200,1199,532],{"emptyLinePlaceholder":393},[200,1201,1202,1204],{"class":202,"line":619},[200,1203,915],{"class":213},[200,1205,1206],{"class":227}," \u002Fapp\n",[200,1208,1209],{"class":202,"line":642},[200,1210,532],{"emptyLinePlaceholder":393},[200,1212,1213,1215],{"class":202,"line":651},[200,1214,851],{"class":213},[200,1216,1217],{"class":227}," chmod 777 \u002Fapp\u002Fglog_musl\n",[200,1219,1220],{"class":202,"line":656},[200,1221,532],{"emptyLinePlaceholder":393},[200,1223,1224,1226,1228,1231,1233,1236,1238,1241],{"class":202,"line":662},[200,1225,928],{"class":213},[200,1227,931],{"class":227},[200,1229,1230],{"class":237},"\".\u002Fglog_musl\"",[200,1232,337],{"class":227},[200,1234,1235],{"class":237},"\"logsweb\"",[200,1237,337],{"class":227},[200,1239,1240],{"class":237},"\"\u002Fdata\u002Fconf.ini\"",[200,1242,343],{"class":227},[200,1244,1245],{"class":202,"line":673},[200,1246,532],{"emptyLinePlaceholder":393},[200,1248,1249],{"class":202,"line":684},[200,1250,1251],{"class":206},"# docker build -t glog:v1 .\n",[200,1253,1254],{"class":202,"line":689},[200,1255,1256],{"class":206},"# docker run -p 6801:6801 glog:v1\n",[437,1258,1259],{},"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":195,"searchDepth":210,"depth":231,"links":1261},[1262,1263,1266],{"id":473,"depth":210,"text":473},{"id":479,"depth":210,"text":479,"children":1264},[1265],{"id":719,"depth":231,"text":719},{"id":765,"depth":210,"text":765,"children":1267},[1268,1269,1270],{"id":939,"depth":231,"text":939},{"id":1078,"depth":231,"text":1078},{"id":1142,"depth":231,"text":1142},"解决 Go 语言跨平台编译问题，使用 Docker + musl 编译静态链接的可执行文件。","进阶","\u002Fposts\u002Fdocker-musl-build\u002Fimg\u002Fcover.svg",{},"\u002Fposts\u002Fdocker-musl-build","2025-03-27",{"title":468,"description":1271},"Docker 实践笔记","posts\u002Fdocker-musl-build\u002Findex",[1281,1282,1283],"Docker","Go","运维","YLtdOOc-gMVDSNjE-ntKXz-7hff-VtvMB2dB5RXNi1o",{"id":1286,"title":1287,"author":6,"body":1288,"description":1695,"difficulty":450,"draft":451,"extension":452,"image":1696,"meta":1697,"navigation":393,"path":1698,"pinned":451,"published":1699,"seo":1700,"series":1278,"seriesOrder":210,"stem":1701,"tags":1702,"updated":1699,"__hash__":1704},"posts\u002Fposts\u002Fdocker-user-start\u002Findex.md","Docker 构建时指定用户启动",{"type":8,"value":1289,"toc":1685},[1290,1293,1296,1299,1303,1306,1323,1327,1360,1364,1371,1421,1424,1643,1646,1649,1679,1682],[11,1291,1292],{"id":1292},"前言",[16,1294,1295],{},"我们在 Linux 使用应用时，往往不会直接使用 root 用户作为应用启动项，但 Dockerfile 默认就是 root 用户，导致挂载生成的文件路径往往是 root 权限，非 root 用户访问就很不方便。",[11,1297,1298],{"id":1298},"解决方案",[717,1300,1302],{"id":1301},"_1-安装用户管理工具","1. 安装用户管理工具",[16,1304,1305],{},"若镜像非自带用户管理：",[190,1307,1309],{"className":789,"code":1308,"language":791,"meta":195,"style":195},"# 权限管理\nRUN apk add --no-cache shadow\n",[197,1310,1311,1316],{"__ignoreMap":195},[200,1312,1313],{"class":202,"line":203},[200,1314,1315],{"class":206},"# 权限管理\n",[200,1317,1318,1320],{"class":202,"line":210},[200,1319,851],{"class":213},[200,1321,1322],{"class":227}," apk add --no-cache shadow\n",[717,1324,1326],{"id":1325},"_2-创建用户并修改权限","2. 创建用户并修改权限",[190,1328,1330],{"className":789,"code":1329,"language":791,"meta":195,"style":195},"# 创建用户组和用户\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",[197,1331,1332,1337,1344,1348,1353],{"__ignoreMap":195},[200,1333,1334],{"class":202,"line":203},[200,1335,1336],{"class":206},"# 创建用户组和用户\n",[200,1338,1339,1341],{"class":202,"line":210},[200,1340,851],{"class":213},[200,1342,1343],{"class":227}," addgroup -S \u003Capp_user_group> && adduser -S -G \u003Capp_user_group> \u003Capp_user>\n",[200,1345,1346],{"class":202,"line":231},[200,1347,532],{"emptyLinePlaceholder":393},[200,1349,1350],{"class":202,"line":244},[200,1351,1352],{"class":206},"# 修改目录权限\n",[200,1354,1355,1357],{"class":202,"line":255},[200,1356,851],{"class":213},[200,1358,1359],{"class":227}," chown -R \u003Capp_user_group>:\u003Capp_user> \u002Fdata\n",[717,1361,1363],{"id":1362},"_3-指定用户启动","3. 指定用户启动",[16,1365,1366,1367,1370],{},"执行 docker 时加 ",[197,1368,1369],{},"--user"," 参数：",[190,1372,1374],{"className":356,"code":1373,"language":358,"meta":195,"style":195},"docker run -it --user \u003Capp_user> \u003Capp_image>:\u003Cimage_version>\n",[197,1375,1376],{"__ignoreMap":195},[200,1377,1378,1380,1382,1384,1387,1389,1392,1395,1397,1399,1402,1405,1407,1410,1413,1416,1419],{"class":202,"line":203},[200,1379,494],{"class":290},[200,1381,497],{"class":237},[200,1383,500],{"class":220},[200,1385,1386],{"class":220}," --user",[200,1388,1014],{"class":213},[200,1390,1391],{"class":237},"app_use",[200,1393,1394],{"class":227},"r",[200,1396,1023],{"class":213},[200,1398,1014],{"class":213},[200,1400,1401],{"class":237},"app_imag",[200,1403,1404],{"class":227},"e",[200,1406,1023],{"class":213},[200,1408,1409],{"class":237},":",[200,1411,1412],{"class":213},"\u003C",[200,1414,1415],{"class":237},"image_versio",[200,1417,1418],{"class":227},"n",[200,1420,1070],{"class":213},[11,1422,1423],{"id":1423},"完整示例",[190,1425,1427],{"className":789,"code":1426,"language":791,"meta":195,"style":195},"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",[197,1428,1429,1435,1439,1444,1457,1461,1468,1472,1478,1483,1490,1495,1502,1506,1513,1517,1521,1528,1532,1539,1543,1550,1554,1568,1575,1580,1586,1594,1602,1607,1613,1622],{"__ignoreMap":195},[200,1430,1431,1433],{"class":202,"line":203},[200,1432,798],{"class":213},[200,1434,1154],{"class":227},[200,1436,1437],{"class":202,"line":210},[200,1438,532],{"emptyLinePlaceholder":393},[200,1440,1441],{"class":202,"line":231},[200,1442,1443],{"class":206},"# 国内源\n",[200,1445,1446,1448,1451,1454],{"class":202,"line":244},[200,1447,851],{"class":213},[200,1449,1450],{"class":227}," sed -i ",[200,1452,1453],{"class":237},"'s\u002Fdl-cdn.alpinelinux.org\u002Fmirrors.ustc.edu.cn\u002Fg'",[200,1455,1456],{"class":227}," \u002Fetc\u002Fapk\u002Frepositories\n",[200,1458,1459],{"class":202,"line":255},[200,1460,532],{"emptyLinePlaceholder":393},[200,1462,1463,1465],{"class":202,"line":266},[200,1464,851],{"class":213},[200,1466,1467],{"class":227}," apk update --no-cache\n",[200,1469,1470],{"class":202,"line":272},[200,1471,1315],{"class":206},[200,1473,1474,1476],{"class":202,"line":599},[200,1475,851],{"class":213},[200,1477,1322],{"class":227},[200,1479,1480],{"class":202,"line":605},[200,1481,1482],{"class":206},"# 调试\n",[200,1484,1485,1487],{"class":202,"line":619},[200,1486,851],{"class":213},[200,1488,1489],{"class":227}," apk add --no-cache bash\n",[200,1491,1492],{"class":202,"line":642},[200,1493,1494],{"class":206},"# 设置时区\n",[200,1496,1497,1499],{"class":202,"line":651},[200,1498,851],{"class":213},[200,1500,1501],{"class":227}," apk add --no-cache tzdata\n",[200,1503,1504],{"class":202,"line":656},[200,1505,532],{"emptyLinePlaceholder":393},[200,1507,1508,1510],{"class":202,"line":662},[200,1509,706],{"class":213},[200,1511,1512],{"class":227}," TZ=Asia\u002FShanghai\n",[200,1514,1515],{"class":202,"line":673},[200,1516,532],{"emptyLinePlaceholder":393},[200,1518,1519],{"class":202,"line":684},[200,1520,1336],{"class":206},[200,1522,1523,1525],{"class":202,"line":689},[200,1524,851],{"class":213},[200,1526,1527],{"class":227}," addgroup -S glog && adduser -S -G glog glog\n",[200,1529,1530],{"class":202,"line":698},[200,1531,532],{"emptyLinePlaceholder":393},[200,1533,1534,1536],{"class":202,"line":703},[200,1535,825],{"class":213},[200,1537,1538],{"class":227}," .\u002Fglog_static_musl_1 \u002Fapp\u002F\n",[200,1540,1541],{"class":202,"line":912},[200,1542,532],{"emptyLinePlaceholder":393},[200,1544,1545,1547],{"class":202,"line":920},[200,1546,851],{"class":213},[200,1548,1549],{"class":227}," mkdir \u002Fdata\n",[200,1551,1552],{"class":202,"line":925},[200,1553,532],{"emptyLinePlaceholder":393},[200,1555,1557,1559,1562,1565],{"class":202,"line":1556},23,[200,1558,902],{"class":213},[200,1560,1561],{"class":227}," [ ",[200,1563,1564],{"class":237},"\"\u002Fdata\"",[200,1566,1567],{"class":227}," ]\n",[200,1569,1571,1573],{"class":202,"line":1570},24,[200,1572,915],{"class":213},[200,1574,905],{"class":227},[200,1576,1578],{"class":202,"line":1577},25,[200,1579,532],{"emptyLinePlaceholder":393},[200,1581,1583],{"class":202,"line":1582},26,[200,1584,1585],{"class":206},"# 修改权限\n",[200,1587,1589,1591],{"class":202,"line":1588},27,[200,1590,851],{"class":213},[200,1592,1593],{"class":227}," chown -R glog:glog \u002Fdata\n",[200,1595,1597,1599],{"class":202,"line":1596},28,[200,1598,851],{"class":213},[200,1600,1601],{"class":227}," chown -R glog:glog \u002Fapp\n",[200,1603,1605],{"class":202,"line":1604},29,[200,1606,532],{"emptyLinePlaceholder":393},[200,1608,1610],{"class":202,"line":1609},30,[200,1611,1612],{"class":206},"# 指定用户启动\n",[200,1614,1616,1619],{"class":202,"line":1615},31,[200,1617,1618],{"class":213},"USER",[200,1620,1621],{"class":227}," glog\n",[200,1623,1625,1627,1629,1632,1634,1636,1638,1641],{"class":202,"line":1624},32,[200,1626,928],{"class":213},[200,1628,1561],{"class":227},[200,1630,1631],{"class":237},"\"\u002Fapp\u002Fglog_static_musl_1\"",[200,1633,337],{"class":227},[200,1635,1235],{"class":237},[200,1637,337],{"class":227},[200,1639,1640],{"class":237},"\".\u002Fconf.ini\"",[200,1642,1567],{"class":227},[11,1644,1645],{"id":1645},"总结",[16,1647,1648],{},"关键步骤：",[721,1650,1651,1657,1667,1673],{},[123,1652,1653,1656],{},[197,1654,1655],{},"apk add --no-cache shadow"," - 安装用户管理工具",[123,1658,1659,1662,1663,1666],{},[197,1660,1661],{},"addgroup"," + ",[197,1664,1665],{},"adduser"," - 创建用户",[123,1668,1669,1672],{},[197,1670,1671],{},"chown -R"," - 修改目录权限",[123,1674,1675,1678],{},[197,1676,1677],{},"USER \u003Cusername>"," - 指定运行用户",[16,1680,1681],{},"这样容器内的应用就不再以 root 身份运行，更加安全。",[437,1683,1684],{},"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":195,"searchDepth":210,"depth":231,"links":1686},[1687,1688,1693,1694],{"id":1292,"depth":210,"text":1292},{"id":1298,"depth":210,"text":1298,"children":1689},[1690,1691,1692],{"id":1301,"depth":231,"text":1302},{"id":1325,"depth":231,"text":1326},{"id":1362,"depth":231,"text":1363},{"id":1423,"depth":210,"text":1423},{"id":1645,"depth":210,"text":1645},"解决 Docker 容器默认以 root 用户运行导致的权限问题，指定非 root 用户启动应用。","\u002Fposts\u002Fdocker-user-start\u002Fimg\u002Fcover.svg",{},"\u002Fposts\u002Fdocker-user-start","2025-04-01",{"title":1287,"description":1695},"posts\u002Fdocker-user-start\u002Findex",[1281,1283,1703],"Linux","b4Uyw5ms87xr7NhpIkavHnNbT20oaEPtXz8k1w6UPj0",{"id":1706,"title":1707,"author":6,"body":1708,"description":1915,"difficulty":6,"draft":451,"extension":452,"image":1916,"meta":1917,"navigation":393,"path":1918,"pinned":451,"published":1919,"seo":1920,"series":6,"seriesOrder":6,"stem":1921,"tags":1922,"updated":6,"__hash__":1924},"posts\u002Fposts\u002Fsveltekit-intro\u002Findex.md","SvelteKit 入门：为什么它比你想象的更简单",{"type":8,"value":1709,"toc":1905},[1710,1714,1717,1723,1726,1730,1737,1786,1790,1796,1804,1808,1811,1814,1883,1885,1896,1902],[11,1711,1713],{"id":1712},"什么是-sveltekit","什么是 SvelteKit？",[16,1715,1716],{},"SvelteKit 是 Svelte 的官方全栈框架，类似于 React 之于 Next.js 的关系。它提供了路由、SSR、SSG 等能力，让开发者可以快速构建现代 Web 应用。",[16,1718,1719],{},[745,1720],{"alt":1721,"src":1722},"SvelteKit Logo","https:\u002F\u002Fraw.githubusercontent.com\u002Fsveltejs\u002Fbranding\u002Fmaster\u002Fsvelte-logo.svg",[11,1724,1725],{"id":1725},"核心特点",[717,1727,1729],{"id":1728},"_1-编译时框架","1. 编译时框架",[16,1731,1732,1733,1736],{},"Svelte 和 React\u002FVue 最大的区别在于：",[20,1734,1735],{},"它在编译阶段就把组件转成原生 JS","，运行时没有虚拟 DOM。",[190,1738,1740],{"className":192,"code":1739,"language":194,"meta":195,"style":195},"\u002F\u002F Svelte 组件（编译后变成直接的 DOM 操作）\nlet count = 0\nfunction increment() {\n  count += 1\n}\n",[197,1741,1742,1747,1760,1771,1782],{"__ignoreMap":195},[200,1743,1744],{"class":202,"line":203},[200,1745,1746],{"class":206},"\u002F\u002F Svelte 组件（编译后变成直接的 DOM 操作）\n",[200,1748,1749,1752,1755,1757],{"class":202,"line":210},[200,1750,1751],{"class":213},"let",[200,1753,1754],{"class":227}," count ",[200,1756,575],{"class":213},[200,1758,1759],{"class":220}," 0\n",[200,1761,1762,1765,1768],{"class":202,"line":231},[200,1763,1764],{"class":213},"function",[200,1766,1767],{"class":290}," increment",[200,1769,1770],{"class":227},"() {\n",[200,1772,1773,1776,1779],{"class":202,"line":244},[200,1774,1775],{"class":227},"  count ",[200,1777,1778],{"class":213},"+=",[200,1780,1781],{"class":220}," 1\n",[200,1783,1784],{"class":202,"line":255},[200,1785,275],{"class":227},[717,1787,1789],{"id":1788},"_2-文件路由","2. 文件路由",[16,1791,1792,1795],{},[197,1793,1794],{},"src\u002Froutes\u002F"," 目录结构即 URL，不需要手动配置路由表：",[190,1797,1802],{"className":1798,"code":1800,"language":1801},[1799],"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",[197,1803,1800],{"__ignoreMap":195},[717,1805,1807],{"id":1806},"_3-全栈能力","3. 全栈能力",[16,1809,1810],{},"SvelteKit 内置了服务端渲染（SSR）、静态站点生成（SSG）、API 路由等功能，开箱即用。",[11,1812,1813],{"id":1813},"和其他框架对比",[34,1815,1816,1831],{},[37,1817,1818],{},[40,1819,1820,1823,1826,1829],{},[43,1821,1822],{},"特性",[43,1824,1825],{},"SvelteKit",[43,1827,1828],{},"Next.js",[43,1830,462],{},[50,1832,1833,1846,1859,1872],{},[40,1834,1835,1838,1841,1844],{},[55,1836,1837],{},"学习曲线",[55,1839,1840],{},"低",[55,1842,1843],{},"中",[55,1845,1843],{},[40,1847,1848,1851,1854,1857],{},[55,1849,1850],{},"包体积",[55,1852,1853],{},"小",[55,1855,1856],{},"大",[55,1858,1843],{},[40,1860,1861,1864,1867,1870],{},[55,1862,1863],{},"运行时开销",[55,1865,1866],{},"无",[55,1868,1869],{},"有",[55,1871,1869],{},[40,1873,1874,1877,1879,1881],{},[55,1875,1876],{},"生态",[55,1878,1853],{},[55,1880,1856],{},[55,1882,1856],{},[11,1884,1645],{"id":1645},[16,1886,1887,1888,1891,1892,1895],{},"如果你追求",[20,1889,1890],{},"简洁的代码","和",[20,1893,1894],{},"极致的性能","，SvelteKit 是一个非常值得尝试的选择。它的学习曲线平缓，写起来就像在写原生 HTML\u002FCSS\u002FJS。",[16,1897,1898],{},[745,1899],{"alt":1900,"src":1901},"Svelte 编译过程","https:\u002F\u002Fsvelte.dev\u002Fsvelte-logo-horizontal.svg",[437,1903,1904],{},"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":195,"searchDepth":210,"depth":231,"links":1906},[1907,1908,1913,1914],{"id":1712,"depth":210,"text":1713},{"id":1725,"depth":210,"text":1725,"children":1909},[1910,1911,1912],{"id":1728,"depth":231,"text":1729},{"id":1788,"depth":231,"text":1789},{"id":1806,"depth":231,"text":1807},{"id":1813,"depth":210,"text":1813},{"id":1645,"depth":210,"text":1645},"SvelteKit 是一个全栈 Svelte 框架，这篇文章带你了解它的核心概念和优势。","\u002Fposts\u002Fsveltekit-intro\u002Fimg\u002Fcover.jpg",{},"\u002Fposts\u002Fsveltekit-intro","2026-05-30",{"title":1707,"description":1915},"posts\u002Fsveltekit-intro\u002Findex",[1825,1923],"前端框架","vsEGyunpCWR7DiJAdyV1SPDfa57xXLITBI-lKM8UeGQ",{"id":4,"title":5,"author":6,"body":1926,"description":449,"difficulty":450,"draft":451,"extension":452,"image":453,"meta":2223,"navigation":393,"path":455,"pinned":393,"published":456,"seo":2224,"series":458,"seriesOrder":203,"stem":459,"tags":2225,"updated":463,"__hash__":464},{"type":8,"value":1927,"toc":2214},[1928,1930,1934,1936,1938,1940,2000,2002,2004,2046,2048,2050,2102,2104,2154,2156,2158,2170,2174,2176,2199,2201,2206,2208,2212],[11,1929,14],{"id":13},[16,1931,18,1932,23],{},[20,1933,22],{},[16,1935,26],{},[11,1937,29],{"id":29},[16,1939,32],{},[34,1941,1942,1950],{},[37,1943,1944],{},[40,1945,1946,1948],{},[43,1947,45],{},[43,1949,48],{},[50,1951,1952,1960,1968,1976,1984,1992],{},[40,1953,1954,1958],{},[55,1955,1956],{},[20,1957,59],{},[55,1959,62],{},[40,1961,1962,1966],{},[55,1963,1964],{},[20,1965,69],{},[55,1967,72],{},[40,1969,1970,1974],{},[55,1971,1972],{},[20,1973,79],{},[55,1975,82],{},[40,1977,1978,1982],{},[55,1979,1980],{},[20,1981,89],{},[55,1983,92],{},[40,1985,1986,1990],{},[55,1987,1988],{},[20,1989,99],{},[55,1991,102],{},[40,1993,1994,1998],{},[55,1995,1996],{},[20,1997,109],{},[55,1999,112],{},[11,2001,115],{"id":115},[16,2003,118],{},[120,2005,2006,2010,2014,2018,2022,2026,2030,2034,2038,2042],{},[123,2007,2008,128],{},[20,2009,127],{},[123,2011,2012,134],{},[20,2013,133],{},[123,2015,2016,140],{},[20,2017,139],{},[123,2019,2020,146],{},[20,2021,145],{},[123,2023,2024,152],{},[20,2025,151],{},[123,2027,2028,158],{},[20,2029,157],{},[123,2031,2032,164],{},[20,2033,163],{},[123,2035,2036,170],{},[20,2037,169],{},[123,2039,2040,176],{},[20,2041,175],{},[123,2043,2044,182],{},[20,2045,181],{},[11,2047,185],{"id":185},[16,2049,188],{},[190,2051,2052],{"className":192,"code":193,"language":194,"meta":195,"style":195},[197,2053,2054,2058,2070,2078,2086,2094,2098],{"__ignoreMap":195},[200,2055,2056],{"class":202,"line":203},[200,2057,207],{"class":206},[200,2059,2060,2062,2064,2066,2068],{"class":202,"line":210},[200,2061,214],{"class":213},[200,2063,217],{"class":213},[200,2065,221],{"class":220},[200,2067,224],{"class":213},[200,2069,228],{"class":227},[200,2071,2072,2074,2076],{"class":202,"line":231},[200,2073,234],{"class":227},[200,2075,238],{"class":237},[200,2077,241],{"class":227},[200,2079,2080,2082,2084],{"class":202,"line":244},[200,2081,247],{"class":227},[200,2083,250],{"class":237},[200,2085,241],{"class":227},[200,2087,2088,2090,2092],{"class":202,"line":255},[200,2089,258],{"class":227},[200,2091,261],{"class":237},[200,2093,241],{"class":227},[200,2095,2096],{"class":202,"line":266},[200,2097,269],{"class":206},[200,2099,2100],{"class":202,"line":272},[200,2101,275],{"class":227},[16,2103,278],{},[190,2105,2106],{"className":281,"code":282,"language":283,"meta":195,"style":195},[197,2107,2108,2112,2120,2128,2136,2150],{"__ignoreMap":195},[200,2109,2110],{"class":202,"line":203},[200,2111,291],{"class":290},[200,2113,2114,2116,2118],{"class":202,"line":210},[200,2115,297],{"class":296},[200,2117,300],{"class":227},[200,2119,303],{"class":237},[200,2121,2122,2124,2126],{"class":202,"line":231},[200,2123,308],{"class":296},[200,2125,300],{"class":227},[200,2127,313],{"class":220},[200,2129,2130,2132,2134],{"class":202,"line":244},[200,2131,318],{"class":296},[200,2133,300],{"class":227},[200,2135,323],{"class":237},[200,2137,2138,2140,2142,2144,2146,2148],{"class":202,"line":255},[200,2139,328],{"class":296},[200,2141,331],{"class":227},[200,2143,334],{"class":237},[200,2145,337],{"class":227},[200,2147,340],{"class":237},[200,2149,343],{"class":227},[200,2151,2152],{"class":202,"line":266},[200,2153,291],{"class":290},[11,2155,350],{"id":350},[16,2157,353],{},[190,2159,2160],{"className":356,"code":357,"language":358,"meta":195,"style":195},[197,2161,2162],{"__ignoreMap":195},[200,2163,2164,2166,2168],{"class":202,"line":203},[200,2165,365],{"class":290},[200,2167,368],{"class":237},[200,2169,371],{"class":237},[16,2171,374,2172,378],{},[197,2173,377],{},[11,2175,381],{"id":381},[120,2177,2179,2184,2189,2194],{"className":2178},[385],[123,2180,2182,395],{"className":2181},[389],[391,2183],{"disabled":393,"type":394},[123,2185,2187,401],{"className":2186},[389],[391,2188],{"disabled":393,"type":394},[123,2190,2192,407],{"className":2191},[389],[391,2193],{"disabled":393,"type":394},[123,2195,2197,413],{"className":2196},[389],[391,2198],{"disabled":393,"type":394},[11,2200,416],{"id":416},[16,2202,419,2203,427],{},[421,2204,426],{"href":423,"rel":2205},[425],[429,2207],{},[16,2209,2210],{},[20,2211,435],{},[437,2213,439],{},{"title":195,"searchDepth":210,"depth":231,"links":2215},[2216,2217,2218,2219,2220,2221,2222],{"id":13,"depth":210,"text":14},{"id":29,"depth":210,"text":29},{"id":115,"depth":210,"text":115},{"id":185,"depth":210,"text":185},{"id":350,"depth":210,"text":350},{"id":381,"depth":210,"text":381},{"id":416,"depth":210,"text":416},{},{"title":5,"description":449},[461,462],{"id":2227,"title":2228,"author":6,"body":2229,"description":3406,"difficulty":1272,"draft":451,"extension":452,"image":3407,"meta":3408,"navigation":393,"path":3409,"pinned":451,"published":3410,"seo":3411,"series":458,"seriesOrder":210,"stem":3412,"tags":3413,"updated":463,"__hash__":3416},"posts\u002Fposts\u002Fnuxt-content-lessons\u002Findex.md","Nuxt Content v3 踩坑记录：目录、图片与那些反直觉的设计",{"type":8,"value":2230,"toc":3396},[2231,2235,2238,2241,2245,2248,2259,2262,2269,2337,2344,2346,2353,2632,2642,2646,2649,2652,2678,2692,2695,2718,2724,2727,2730,2733,2738,2745,2972,2977,3021,3025,3028,3035,3038,3088,3094,3323,3333,3335,3387,3390,3393],[2232,2233,2234],"h1",{"id":2234},"背景",[16,2236,2237],{},"最近在用 Nuxt 3 + Nuxt Content v3 搭建一个个人博客，参考了一个 SvelteKit 项目（svaf）的架构。原以为换个框架只是语法差异，没想到在几个关键功能上踩了不少坑。",[16,2239,2240],{},"这篇文章记录了三个核心问题的排查过程和最终方案。",[2232,2242,2244],{"id":2243},"问题一目录导航toc只有-h2","问题一：目录导航（TOC）只有 h2",[11,2246,2247],{"id":2247},"现象",[16,2249,2250,2251,2254,2255,2258],{},"Nuxt Content 内置了 TOC 数据，挂在 ",[197,2252,2253],{},"post.body.toc"," 上。直接用它渲染目录，发现",[20,2256,2257],{},"只有 h2 标题，h3 全部丢失","。",[11,2260,2261],{"id":2261},"排查",[16,2263,2264,2265,2268],{},"查了官方文档，发现 ",[197,2266,2267],{},"build.markdown.toc.depth"," 可以控制深度：",[190,2270,2272],{"className":192,"code":2271,"language":194,"meta":195,"style":195},"\u002F\u002F nuxt.config.ts\ncontent: {\n  build: {\n    markdown: {\n      toc: { depth: 3 }  \u002F\u002F 包含 h3\n    }\n  }\n}\n",[197,2273,2274,2279,2287,2294,2301,2323,2328,2333],{"__ignoreMap":195},[200,2275,2276],{"class":202,"line":203},[200,2277,2278],{"class":206},"\u002F\u002F nuxt.config.ts\n",[200,2280,2281,2284],{"class":202,"line":210},[200,2282,2283],{"class":290},"content",[200,2285,2286],{"class":227},": {\n",[200,2288,2289,2292],{"class":202,"line":231},[200,2290,2291],{"class":290},"  build",[200,2293,2286],{"class":227},[200,2295,2296,2299],{"class":202,"line":244},[200,2297,2298],{"class":290},"    markdown",[200,2300,2286],{"class":227},[200,2302,2303,2306,2309,2312,2314,2317,2320],{"class":202,"line":255},[200,2304,2305],{"class":290},"      toc",[200,2307,2308],{"class":227},": { ",[200,2310,2311],{"class":290},"depth",[200,2313,300],{"class":227},[200,2315,2316],{"class":220},"3",[200,2318,2319],{"class":227}," }  ",[200,2321,2322],{"class":206},"\u002F\u002F 包含 h3\n",[200,2324,2325],{"class":202,"line":266},[200,2326,2327],{"class":227},"    }\n",[200,2329,2330],{"class":202,"line":272},[200,2331,2332],{"class":227},"  }\n",[200,2334,2335],{"class":202,"line":599},[200,2336,275],{"class":227},[16,2338,2339,2340,2343],{},"配置改了，清了缓存重启，",[20,2341,2342],{},"依然只有 h2","。这个配置似乎不起作用。",[11,2345,765],{"id":765},[16,2347,2348,2349,2352],{},"放弃依赖 Nuxt Content 的 toc，直接从 ",[197,2350,2351],{},"post.body"," 的 AST 里递归提取所有标题：",[190,2354,2356],{"className":192,"code":2355,"language":194,"meta":195,"style":195},"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",[197,2357,2358,2402,2422,2435,2440,2501,2531,2563,2588,2592,2617,2621,2628],{"__ignoreMap":195},[200,2359,2360,2362,2365,2368,2372,2374,2377,2379,2382,2384,2387,2390,2392,2395,2397,2399],{"class":202,"line":203},[200,2361,1764],{"class":213},[200,2363,2364],{"class":290}," extractHeadings",[200,2366,2367],{"class":227},"(",[200,2369,2371],{"class":2370},"sPK6S","node",[200,2373,1409],{"class":213},[200,2375,2376],{"class":220}," any",[200,2378,337],{"class":227},[200,2380,2381],{"class":2370},"list",[200,2383,1409],{"class":213},[200,2385,2386],{"class":290}," Heading",[200,2388,2389],{"class":227},"[] ",[200,2391,575],{"class":213},[200,2393,2394],{"class":227}," [])",[200,2396,1409],{"class":213},[200,2398,2386],{"class":290},[200,2400,2401],{"class":227},"[] {\n",[200,2403,2404,2407,2410,2413,2416,2419],{"class":202,"line":210},[200,2405,2406],{"class":213},"  if",[200,2408,2409],{"class":227}," (",[200,2411,2412],{"class":213},"!",[200,2414,2415],{"class":227},"node) ",[200,2417,2418],{"class":213},"return",[200,2420,2421],{"class":227}," list\n",[200,2423,2424,2426,2429,2432],{"class":202,"line":231},[200,2425,2406],{"class":213},[200,2427,2428],{"class":227}," (Array.",[200,2430,2431],{"class":290},"isArray",[200,2433,2434],{"class":227},"(node)) {\n",[200,2436,2437],{"class":202,"line":244},[200,2438,2439],{"class":206},"    \u002F\u002F minimark 格式：[\"h2\", {id: \"xxx\"}, \"文本\"]\n",[200,2441,2442,2445,2447,2450,2453,2456,2459,2462,2465,2468,2471,2474,2478,2481,2484,2487,2490,2493,2496,2498],{"class":202,"line":255},[200,2443,2444],{"class":213},"    if",[200,2446,2409],{"class":227},[200,2448,2449],{"class":213},"typeof",[200,2451,2452],{"class":227}," node[",[200,2454,2455],{"class":220},"0",[200,2457,2458],{"class":227},"] ",[200,2460,2461],{"class":213},"===",[200,2463,2464],{"class":237}," 'string'",[200,2466,2467],{"class":213}," &&",[200,2469,2470],{"class":237}," \u002F",[200,2472,2473],{"class":213},"^",[200,2475,2477],{"class":2476},"sPKmS","h",[200,2479,2480],{"class":220},"[1-6]",[200,2482,2483],{"class":213},"$",[200,2485,2486],{"class":237},"\u002F",[200,2488,2489],{"class":227},".",[200,2491,2492],{"class":290},"test",[200,2494,2495],{"class":227},"(node[",[200,2497,2455],{"class":220},[200,2499,2500],{"class":227},"])) {\n",[200,2502,2503,2506,2509,2511,2514,2516,2518,2521,2524,2526,2528],{"class":202,"line":266},[200,2504,2505],{"class":213},"      const",[200,2507,2508],{"class":220}," level",[200,2510,224],{"class":213},[200,2512,2513],{"class":290}," Number",[200,2515,2495],{"class":227},[200,2517,2455],{"class":220},[200,2519,2520],{"class":227},"].",[200,2522,2523],{"class":290},"charAt",[200,2525,2367],{"class":227},[200,2527,593],{"class":220},[200,2529,2530],{"class":227},"))\n",[200,2532,2533,2535,2538,2540,2543,2546,2549,2551,2554,2557,2560],{"class":202,"line":272},[200,2534,2505],{"class":213},[200,2536,2537],{"class":220}," text",[200,2539,224],{"class":213},[200,2541,2542],{"class":290}," extractText",[200,2544,2545],{"class":227},"(node.",[200,2547,2548],{"class":290},"slice",[200,2550,2367],{"class":227},[200,2552,2553],{"class":220},"2",[200,2555,2556],{"class":227},")).",[200,2558,2559],{"class":290},"trim",[200,2561,2562],{"class":227},"()\n",[200,2564,2565,2568,2571,2574,2576,2579,2582,2585],{"class":202,"line":599},[200,2566,2567],{"class":227},"      list.",[200,2569,2570],{"class":290},"push",[200,2572,2573],{"class":227},"({ id: node[",[200,2575,593],{"class":220},[200,2577,2578],{"class":227},"]?.id ",[200,2580,2581],{"class":213},"||",[200,2583,2584],{"class":290}," slugify",[200,2586,2587],{"class":227},"(text), text, level })\n",[200,2589,2590],{"class":202,"line":605},[200,2591,2327],{"class":227},[200,2593,2594,2597,2599,2602,2605,2608,2611,2614],{"class":202,"line":619},[200,2595,2596],{"class":213},"    for",[200,2598,2409],{"class":227},[200,2600,2601],{"class":213},"const",[200,2603,2604],{"class":220}," child",[200,2606,2607],{"class":213}," of",[200,2609,2610],{"class":227}," node) ",[200,2612,2613],{"class":290},"extractHeadings",[200,2615,2616],{"class":227},"(child, list)\n",[200,2618,2619],{"class":202,"line":642},[200,2620,2332],{"class":227},[200,2622,2623,2626],{"class":202,"line":651},[200,2624,2625],{"class":213},"  return",[200,2627,2421],{"class":227},[200,2629,2630],{"class":202,"line":656},[200,2631,275],{"class":227},[16,2633,2634,2635,2637,2638,2641],{},"关键点：Nuxt Content v3 的 body 使用 ",[20,2636,8],{}," 格式，标题节点是数组 ",[197,2639,2640],{},"[\"h2\", {id: \"xxx\"}, \"标题文本\"]","，不是对象。",[2232,2643,2645],{"id":2644},"问题二图片路径解析错误","问题二：图片路径解析错误",[11,2647,2247],{"id":2648},"现象-1",[16,2650,2651],{},"markdown 里用相对路径引用图片：",[190,2653,2657],{"className":2654,"code":2655,"language":2656,"meta":195,"style":195},"language-markdown shiki shiki-themes github-light github-dark","![SSHFS挂载效果](img\u002Fsshfs.avif)\n","markdown",[197,2658,2659],{"__ignoreMap":195},[200,2660,2661,2664,2668,2671,2675],{"class":202,"line":203},[200,2662,2663],{"class":227},"![",[200,2665,2667],{"class":2666},"smiIW","SSHFS挂载效果",[200,2669,2670],{"class":227},"](",[200,2672,2674],{"class":2673},"szbl0","img\u002Fsshfs.avif",[200,2676,2677],{"class":227},")\n",[16,2679,2680,2681,2684,2685,2688,2689,2258],{},"HTML 输出的 ",[197,2682,2683],{},"\u003Cimg src=\"img\u002Fsshfs.avif\">"," 看起来没问题，但浏览器请求的却是 ",[197,2686,2687],{},"\u002Fposts\u002Fimg\u002Fsshfs.avif"," 而不是 ",[197,2690,2691],{},"\u002Fposts\u002Fsshfs\u002Fimg\u002Fsshfs.avif",[11,2693,2261],{"id":2694},"排查-1",[721,2696,2697,2704,2707,2713],{},[123,2698,2699,2700,2703],{},"检查了 ",[197,2701,2702],{},"\u003Cbase>"," 标签 —— 没有",[123,2705,2706],{},"检查了重定向 —— 没有",[123,2708,2709,2710,2712],{},"直接访问 ",[197,2711,2691],{}," —— 返回 200 OK",[123,2714,2709,2715,2717],{},[197,2716,2687],{}," —— 404",[16,2719,2720,2721,2258],{},"服务器路由没问题，问题是",[20,2722,2723],{},"浏览器解析相对路径时基于了错误的基准 URL",[16,2725,2726],{},"这可能和 Nuxt 的客户端路由有关：SPA 导航时，浏览器的\"当前页面\"和 Vue Router 的\"当前路由\"不同步，导致相对路径解析出错。",[11,2728,765],{"id":2729},"最终方案-1",[16,2731,2732],{},"两步解决：",[16,2734,2735],{},[20,2736,2737],{},"第一步：自定义 ProseImg 组件",[16,2739,2740,2741,2744],{},"创建 ",[197,2742,2743],{},"components\u002Fcontent\u002FProseImg.vue","，覆盖 Nuxt Content 默认的图片渲染：",[190,2746,2750],{"className":2747,"code":2748,"language":2749,"meta":195,"style":195},"language-vue shiki shiki-themes github-light github-dark","\u003Cscript setup>\nconst route = useRoute()\nconst resolvedSrc = computed(() => {\n  if (!props.src) return ''\n  if (props.src.startsWith('\u002F') || props.src.startsWith('http')) return props.src\n  const base = route.path.replace(\u002F\\\u002F$\u002F, '')\n  return `${base}\u002F${props.src}`\n})\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003Cimg :src=\"resolvedSrc\" :alt=\"alt\" \u002F>\n\u003C\u002Ftemplate>\n","vue",[197,2751,2752,2764,2778,2798,2814,2852,2887,2911,2916,2925,2929,2938,2964],{"__ignoreMap":195},[200,2753,2754,2756,2759,2762],{"class":202,"line":203},[200,2755,1412],{"class":227},[200,2757,2758],{"class":296},"script",[200,2760,2761],{"class":290}," setup",[200,2763,1070],{"class":227},[200,2765,2766,2768,2771,2773,2776],{"class":202,"line":210},[200,2767,2601],{"class":213},[200,2769,2770],{"class":220}," route",[200,2772,224],{"class":213},[200,2774,2775],{"class":290}," useRoute",[200,2777,2562],{"class":227},[200,2779,2780,2782,2785,2787,2790,2793,2796],{"class":202,"line":231},[200,2781,2601],{"class":213},[200,2783,2784],{"class":220}," resolvedSrc",[200,2786,224],{"class":213},[200,2788,2789],{"class":290}," computed",[200,2791,2792],{"class":227},"(() ",[200,2794,2795],{"class":213},"=>",[200,2797,228],{"class":227},[200,2799,2800,2802,2804,2806,2809,2811],{"class":202,"line":244},[200,2801,2406],{"class":213},[200,2803,2409],{"class":227},[200,2805,2412],{"class":213},[200,2807,2808],{"class":227},"props.src) ",[200,2810,2418],{"class":213},[200,2812,2813],{"class":237}," ''\n",[200,2815,2816,2818,2821,2824,2826,2829,2832,2834,2837,2839,2841,2844,2847,2849],{"class":202,"line":255},[200,2817,2406],{"class":213},[200,2819,2820],{"class":227}," (props.src.",[200,2822,2823],{"class":290},"startsWith",[200,2825,2367],{"class":227},[200,2827,2828],{"class":237},"'\u002F'",[200,2830,2831],{"class":227},") ",[200,2833,2581],{"class":213},[200,2835,2836],{"class":227}," props.src.",[200,2838,2823],{"class":290},[200,2840,2367],{"class":227},[200,2842,2843],{"class":237},"'http'",[200,2845,2846],{"class":227},")) ",[200,2848,2418],{"class":213},[200,2850,2851],{"class":227}," props.src\n",[200,2853,2854,2857,2860,2862,2865,2868,2870,2872,2876,2878,2880,2882,2885],{"class":202,"line":266},[200,2855,2856],{"class":213},"  const",[200,2858,2859],{"class":220}," base",[200,2861,224],{"class":213},[200,2863,2864],{"class":227}," route.path.",[200,2866,2867],{"class":290},"replace",[200,2869,2367],{"class":227},[200,2871,2486],{"class":237},[200,2873,2875],{"class":2874},"sCnZR","\\\u002F",[200,2877,2483],{"class":213},[200,2879,2486],{"class":237},[200,2881,337],{"class":227},[200,2883,2884],{"class":237},"''",[200,2886,2677],{"class":227},[200,2888,2889,2891,2894,2897,2900,2903,2905,2908],{"class":202,"line":272},[200,2890,2625],{"class":213},[200,2892,2893],{"class":237}," `${",[200,2895,2896],{"class":227},"base",[200,2898,2899],{"class":237},"}\u002F${",[200,2901,2902],{"class":227},"props",[200,2904,2489],{"class":237},[200,2906,2907],{"class":227},"src",[200,2909,2910],{"class":237},"}`\n",[200,2912,2913],{"class":202,"line":599},[200,2914,2915],{"class":227},"})\n",[200,2917,2918,2921,2923],{"class":202,"line":605},[200,2919,2920],{"class":227},"\u003C\u002F",[200,2922,2758],{"class":296},[200,2924,1070],{"class":227},[200,2926,2927],{"class":202,"line":619},[200,2928,532],{"emptyLinePlaceholder":393},[200,2930,2931,2933,2936],{"class":202,"line":642},[200,2932,1412],{"class":227},[200,2934,2935],{"class":296},"template",[200,2937,1070],{"class":227},[200,2939,2940,2943,2945,2948,2950,2953,2956,2958,2961],{"class":202,"line":651},[200,2941,2942],{"class":227},"  \u003C",[200,2944,745],{"class":296},[200,2946,2947],{"class":290}," :src",[200,2949,575],{"class":227},[200,2951,2952],{"class":237},"\"resolvedSrc\"",[200,2954,2955],{"class":290}," :alt",[200,2957,575],{"class":227},[200,2959,2960],{"class":237},"\"alt\"",[200,2962,2963],{"class":227}," \u002F>\n",[200,2965,2966,2968,2970],{"class":202,"line":656},[200,2967,2920],{"class":227},[200,2969,2935],{"class":296},[200,2971,1070],{"class":227},[16,2973,2974],{},[20,2975,2976],{},"第二步：启用 MDC prose 组件",[190,2978,2980],{"className":192,"code":2979,"language":194,"meta":195,"style":195},"\u002F\u002F nuxt.config.ts\nmdc: {\n  components: {\n    prose: true  \u002F\u002F 让 Nuxt Content 使用自定义 ProseImg\n  }\n}\n",[197,2981,2982,2986,2993,3000,3013,3017],{"__ignoreMap":195},[200,2983,2984],{"class":202,"line":203},[200,2985,2278],{"class":206},[200,2987,2988,2991],{"class":202,"line":210},[200,2989,2990],{"class":290},"mdc",[200,2992,2286],{"class":227},[200,2994,2995,2998],{"class":202,"line":231},[200,2996,2997],{"class":290},"  components",[200,2999,2286],{"class":227},[200,3001,3002,3005,3007,3010],{"class":202,"line":244},[200,3003,3004],{"class":290},"    prose",[200,3006,300],{"class":227},[200,3008,3009],{"class":220},"true",[200,3011,3012],{"class":206},"  \u002F\u002F 让 Nuxt Content 使用自定义 ProseImg\n",[200,3014,3015],{"class":202,"line":255},[200,3016,2332],{"class":227},[200,3018,3019],{"class":202,"line":266},[200,3020,275],{"class":227},[2232,3022,3024],{"id":3023},"问题三content-目录的图片如何服务","问题三：content 目录的图片如何服务",[11,3026,2247],{"id":3027},"现象-2",[16,3029,3030,3031,3034],{},"原站（SvelteKit）的图片放在 ",[197,3032,3033],{},"content\u002Fposts\u002F{slug}\u002Fimg\u002F"," 下，markdown 用相对路径引用，一切正常。Nuxt 项目里，content 目录的文件不会被当成静态资源服务。",[11,3036,3037],{"id":3037},"方案对比",[34,3039,3040,3053],{},[37,3041,3042],{},[40,3043,3044,3047,3050],{},[43,3045,3046],{},"方案",[43,3048,3049],{},"优点",[43,3051,3052],{},"缺点",[50,3054,3055,3066,3077],{},[40,3056,3057,3060,3063],{},[55,3058,3059],{},"复制到 public\u002F",[55,3061,3062],{},"简单",[55,3064,3065],{},"文件重复，需同步",[40,3067,3068,3071,3074],{},[55,3069,3070],{},"Vite 插件拦截",[55,3072,3073],{},"原站方案",[55,3075,3076],{},"Nuxt 不直接支持",[40,3078,3079,3082,3085],{},[55,3080,3081],{},"Server Route",[55,3083,3084],{},"不复制文件",[55,3086,3087],{},"需要手动写路由",[16,3089,3090,3091,3093],{},"最终选择 ",[20,3092,3081],{},"，和原站的 Vite 插件思路一致：",[190,3095,3097],{"className":192,"code":3096,"language":194,"meta":195,"style":195},"\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",[197,3098,3099,3104,3130,3145,3209,3223,3227,3249,3275,3293,3312,3319],{"__ignoreMap":195},[200,3100,3101],{"class":202,"line":203},[200,3102,3103],{"class":206},"\u002F\u002F server\u002Froutes\u002Fposts\u002F[slug]\u002Fimg\u002F[...file].ts\n",[200,3105,3106,3108,3111,3114,3116,3119,3121,3124,3126,3128],{"class":202,"line":210},[200,3107,214],{"class":213},[200,3109,3110],{"class":213}," default",[200,3112,3113],{"class":290}," defineEventHandler",[200,3115,2367],{"class":227},[200,3117,3118],{"class":213},"async",[200,3120,2409],{"class":227},[200,3122,3123],{"class":2370},"event",[200,3125,2831],{"class":227},[200,3127,2795],{"class":213},[200,3129,228],{"class":227},[200,3131,3132,3134,3137,3139,3142],{"class":202,"line":231},[200,3133,2856],{"class":213},[200,3135,3136],{"class":220}," url",[200,3138,224],{"class":213},[200,3140,3141],{"class":290}," getRequestURL",[200,3143,3144],{"class":227},"(event)\n",[200,3146,3147,3149,3152,3154,3157,3160,3162,3164,3166,3168,3171,3173,3175,3178,3180,3183,3186,3189,3191,3193,3195,3197,3199,3201,3203,3205,3207],{"class":202,"line":244},[200,3148,2856],{"class":213},[200,3150,3151],{"class":220}," match",[200,3153,224],{"class":213},[200,3155,3156],{"class":227}," url.pathname.",[200,3158,3159],{"class":290},"match",[200,3161,2367],{"class":227},[200,3163,2486],{"class":237},[200,3165,2473],{"class":213},[200,3167,2875],{"class":2874},[200,3169,3170],{"class":2476},"posts",[200,3172,2875],{"class":2874},[200,3174,2367],{"class":2476},[200,3176,3177],{"class":220},"[",[200,3179,2473],{"class":213},[200,3181,3182],{"class":220},"\u002F]",[200,3184,3185],{"class":213},"+",[200,3187,3188],{"class":2476},")",[200,3190,2875],{"class":2874},[200,3192,745],{"class":2476},[200,3194,2875],{"class":2874},[200,3196,2367],{"class":2476},[200,3198,2489],{"class":220},[200,3200,3185],{"class":213},[200,3202,3188],{"class":2476},[200,3204,2483],{"class":213},[200,3206,2486],{"class":237},[200,3208,2677],{"class":227},[200,3210,3211,3213,3215,3217,3220],{"class":202,"line":255},[200,3212,2406],{"class":213},[200,3214,2409],{"class":227},[200,3216,2412],{"class":213},[200,3218,3219],{"class":227},"match) ",[200,3221,3222],{"class":213},"return\n",[200,3224,3225],{"class":202,"line":266},[200,3226,532],{"emptyLinePlaceholder":393},[200,3228,3229,3231,3234,3237,3239,3242,3244,3246],{"class":202,"line":272},[200,3230,2856],{"class":213},[200,3232,3233],{"class":227}," [, ",[200,3235,3236],{"class":220},"slug",[200,3238,337],{"class":227},[200,3240,3241],{"class":220},"filename",[200,3243,2458],{"class":227},[200,3245,575],{"class":213},[200,3247,3248],{"class":227}," match\n",[200,3250,3251,3253,3256,3258,3261,3263,3266,3269,3272],{"class":202,"line":599},[200,3252,2856],{"class":213},[200,3254,3255],{"class":220}," filePath",[200,3257,224],{"class":213},[200,3259,3260],{"class":290}," resolve",[200,3262,2367],{"class":227},[200,3264,3265],{"class":237},"'content\u002Fposts'",[200,3267,3268],{"class":227},", slug, ",[200,3270,3271],{"class":237},"'img'",[200,3273,3274],{"class":227},", filename)\n",[200,3276,3277,3279,3282,3284,3287,3290],{"class":202,"line":605},[200,3278,2856],{"class":213},[200,3280,3281],{"class":220}," data",[200,3283,224],{"class":213},[200,3285,3286],{"class":213}," await",[200,3288,3289],{"class":290}," readFile",[200,3291,3292],{"class":227},"(filePath)\n",[200,3294,3295,3298,3301,3304,3306,3309],{"class":202,"line":619},[200,3296,3297],{"class":290},"  setResponseHeader",[200,3299,3300],{"class":227},"(event, ",[200,3302,3303],{"class":237},"'content-type'",[200,3305,337],{"class":227},[200,3307,3308],{"class":220},"MIME",[200,3310,3311],{"class":227},"[ext])\n",[200,3313,3314,3316],{"class":202,"line":642},[200,3315,2625],{"class":213},[200,3317,3318],{"class":227}," data\n",[200,3320,3321],{"class":202,"line":651},[200,3322,2915],{"class":227},[16,3324,3325,3326,3328,3329,3332],{},"请求 ",[197,3327,2691],{}," → 读取 ",[197,3330,3331],{},"content\u002Fposts\u002Fsshfs\u002Fimg\u002Fsshfs.avif"," → 返回图片。",[2232,3334,1645],{"id":1645},[34,3336,3337,3349],{},[37,3338,3339],{},[40,3340,3341,3344,3347],{},[43,3342,3343],{},"问题",[43,3345,3346],{},"根因",[43,3348,3046],{},[50,3350,3351,3365,3376],{},[40,3352,3353,3356,3362],{},[55,3354,3355],{},"TOC 只有 h2",[55,3357,3358,3361],{},[197,3359,3360],{},"toc.depth"," 配置不生效",[55,3363,3364],{},"从 body AST 手动提取",[40,3366,3367,3370,3373],{},[55,3368,3369],{},"图片路径错误",[55,3371,3372],{},"SPA 路由导致相对路径解析基准错误",[55,3374,3375],{},"ProseImg 组件 + mdc.prose 配置",[40,3377,3378,3381,3384],{},[55,3379,3380],{},"content 图片 404",[55,3382,3383],{},"content 目录不暴露给浏览器",[55,3385,3386],{},"Server Route 直接读取返回",[16,3388,3389],{},"Nuxt Content v3 的设计理念是\"图片放 public\u002F，用绝对路径\"。如果想像 SvelteKit 项目那样把图片和文章放一起，需要额外做不少工作。",[16,3391,3392],{},"不过一旦这些坑都踩过，整体开发体验还是很流畅的。Nuxt 的生态系统、自动导入、模块系统都比 SvelteKit 成熟很多。",[437,3394,3395],{},"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":195,"searchDepth":210,"depth":231,"links":3397},[3398,3399,3400,3401,3402,3403,3404,3405],{"id":2247,"depth":210,"text":2247},{"id":2261,"depth":210,"text":2261},{"id":765,"depth":210,"text":765},{"id":2648,"depth":210,"text":2247},{"id":2694,"depth":210,"text":2261},{"id":2729,"depth":210,"text":765},{"id":3027,"depth":210,"text":2247},{"id":3037,"depth":210,"text":3037},"从 SvelteKit 迁移到 Nuxt 3 的过程中，遇到了不少 Nuxt Content v3 的坑。这篇文章记录了目录导航、图片路径、ProseImg 组件等问题的排查和解决过程。","\u002Fposts\u002Fnuxt-content-lessons\u002Fimg\u002Fcover.jpg",{},"\u002Fposts\u002Fnuxt-content-lessons","2026-05-31T14:00:00",{"title":2228,"description":3406},"posts\u002Fnuxt-content-lessons\u002Findex",[462,3414,3415],"踩坑","Nuxt Content","vbSt6ND1z7xt7L8FxgZ8Qe3EdxV9InsRerN3E23GC7s",{"id":3418,"title":3419,"author":22,"body":3420,"description":3685,"difficulty":1272,"draft":451,"extension":452,"image":3686,"meta":3687,"navigation":393,"path":3688,"pinned":451,"published":3689,"seo":3690,"series":458,"seriesOrder":231,"stem":3691,"tags":3692,"updated":463,"__hash__":3695},"posts\u002Fposts\u002Fnuxt-tailwind-underline-fix\u002Findex.md","Nuxt 3 + Tailwind CSS 客户端导航链接下划线闪烁问题",{"type":8,"value":3421,"toc":3678},[3422,3425,3428,3443,3446,3449,3452,3493,3504,3511,3533,3540,3544,3547,3550,3552,3555,3578,3589,3646,3651,3664,3666,3672,3675],[11,3423,3424],{"id":3424},"问题描述",[16,3426,3427],{},"在 Nuxt 3 项目中使用 Tailwind CSS 时，发现一个奇怪的现象：",[120,3429,3430,3440],{},[123,3431,3432,3433,3436,3437],{},"首次通过客户端导航（点击 ",[197,3434,3435],{},"\u003CNuxtLink>","）跳转到某个页面时，",[20,3438,3439],{},"所有链接文字都会出现下划线",[123,3441,3442],{},"手动刷新页面后，下划线消失，样式恢复正常",[16,3444,3445],{},"这个问题在开发模式和生产构建后都会出现。",[11,3447,3448],{"id":3448},"原因分析",[16,3450,3451],{},"Tailwind CSS 的 Preflight（基于 modern-normalize）会重置链接样式：",[190,3453,3457],{"className":3454,"code":3455,"language":3456,"meta":195,"style":195},"language-css shiki shiki-themes github-light github-dark","a {\n  color: inherit;\n  text-decoration: inherit;\n}\n","css",[197,3458,3459,3465,3478,3489],{"__ignoreMap":195},[200,3460,3461,3463],{"class":202,"line":203},[200,3462,421],{"class":296},[200,3464,228],{"class":227},[200,3466,3467,3470,3472,3475],{"class":202,"line":210},[200,3468,3469],{"class":220},"  color",[200,3471,300],{"class":227},[200,3473,3474],{"class":220},"inherit",[200,3476,3477],{"class":227},";\n",[200,3479,3480,3483,3485,3487],{"class":202,"line":231},[200,3481,3482],{"class":220},"  text-decoration",[200,3484,300],{"class":227},[200,3486,3474],{"class":220},[200,3488,3477],{"class":227},[200,3490,3491],{"class":202,"line":244},[200,3492,275],{"class":227},[16,3494,3495,3496,3499,3500,3503],{},"正常情况下，这会让链接继承父元素的 ",[197,3497,3498],{},"text-decoration","（通常是 ",[197,3501,3502],{},"none","），从而去掉浏览器默认的下划线。",[16,3505,3506,3507,3510],{},"问题出在 ",[20,3508,3509],{},"Nuxt 的客户端导航机制","：",[721,3512,3513,3519,3522,3527,3530],{},[123,3514,3515,3516,3518],{},"用户点击 ",[197,3517,3435],{},"，Vue Router 拦截导航",[123,3520,3521],{},"新页面组件异步加载并渲染",[123,3523,3524],{},[20,3525,3526],{},"在 CSS 完全加载\u002F应用之前，页面已经渲染了",[123,3528,3529],{},"此时浏览器使用默认样式（链接带下划线）",[123,3531,3532],{},"CSS 加载完成后，下划线消失",[16,3534,3535,3536,3539],{},"这就是典型的 ",[20,3537,3538],{},"FOUC（Flash of Unstyled Content）"," 问题。",[11,3541,3543],{"id":3542},"为什么刷新就正常了","为什么刷新就正常了？",[16,3545,3546],{},"刷新页面时，浏览器会等待所有 CSS 加载完成后再渲染页面（SSR 模式下 HTML 和 CSS 是一起返回的）。所以刷新后样式是正确的。",[16,3548,3549],{},"而客户端导航是 JavaScript 驱动的，CSS 的加载和页面的渲染是异步的，就出现了时序差。",[11,3551,1298],{"id":1298},[16,3553,3554],{},"尝试了多种方案：",[120,3556,3557,3563,3569,3575],{},[123,3558,3559,3562],{},[197,3560,3561],{},"@layer base"," 覆盖 — 无效，CSS layers 的优先级问题",[123,3564,3565,3568],{},[197,3566,3567],{},"corePlugins.preflight: false"," — 无效，且破坏其他基础样式",[123,3570,3571,3574],{},[197,3572,3573],{},"features.inlineStyles: true"," — 无效",[123,3576,3577],{},"页面过渡动画 — 只是掩盖，不能根治",[16,3579,3580,3581,3584,3585,3588],{},"最终有效的方案是在 ",[197,3582,3583],{},"app.vue"," 中用 ",[197,3586,3587],{},"!important"," 强制覆盖：",[190,3590,3592],{"className":2747,"code":3591,"language":2749,"meta":195,"style":195},"\u003Cstyle>\na:not(.prose a) {\n  text-decoration-line: none !important;\n}\n\u003C\u002Fstyle>\n",[197,3593,3594,3602,3620,3634,3638],{"__ignoreMap":195},[200,3595,3596,3598,3600],{"class":202,"line":203},[200,3597,1412],{"class":227},[200,3599,437],{"class":296},[200,3601,1070],{"class":227},[200,3603,3604,3606,3609,3611,3614,3617],{"class":202,"line":210},[200,3605,421],{"class":296},[200,3607,3608],{"class":290},":not",[200,3610,2367],{"class":227},[200,3612,3613],{"class":290},".prose",[200,3615,3616],{"class":296}," a",[200,3618,3619],{"class":227},") {\n",[200,3621,3622,3625,3627,3629,3632],{"class":202,"line":231},[200,3623,3624],{"class":220},"  text-decoration-line",[200,3626,300],{"class":227},[200,3628,3502],{"class":220},[200,3630,3631],{"class":213}," !important",[200,3633,3477],{"class":227},[200,3635,3636],{"class":202,"line":244},[200,3637,275],{"class":227},[200,3639,3640,3642,3644],{"class":202,"line":255},[200,3641,2920],{"class":227},[200,3643,437],{"class":296},[200,3645,1070],{"class":227},[16,3647,3648],{},[20,3649,3650],{},"关键点：",[120,3652,3653,3658],{},[123,3654,3655,3657],{},[197,3656,3587],{}," 是为了对抗 Tailwind CSS layers 的优先级",[123,3659,3660,3663],{},[197,3661,3662],{},":not(.prose a)"," 排除文章正文区域，保留文章内链接的下划线（可读性需要）",[11,3665,1645],{"id":1645},[16,3667,3668,3669,3671],{},"这是 Nuxt 3 + Tailwind CSS 的一个已知问题，本质上是 CSS 加载时序导致的 FOUC。",[197,3670,3587],{}," 虽然不够优雅，但在这个场景下是务实的解决方案。",[16,3673,3674],{},"如果你有更好的方案，欢迎在项目仓库的 Issues 中提出！",[437,3676,3677],{},"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":195,"searchDepth":210,"depth":231,"links":3679},[3680,3681,3682,3683,3684],{"id":3424,"depth":210,"text":3424},{"id":3448,"depth":210,"text":3448},{"id":3542,"depth":210,"text":3543},{"id":1298,"depth":210,"text":1298},{"id":1645,"depth":210,"text":1645},"记录 Nuxt 3 配合 Tailwind CSS 时，客户端导航导致链接文字出现下划线闪烁的问题及解决方案。","\u002Fposts\u002Fnuxt-tailwind-underline-fix\u002Fimg\u002Fcover.png",{},"\u002Fposts\u002Fnuxt-tailwind-underline-fix","2026-05-31T18:47:00",{"title":3419,"description":3685},"posts\u002Fnuxt-tailwind-underline-fix\u002Findex",[462,3693,3694,3414],"Tailwind CSS","CSS","WMLpTEsIoJqufwEfXyIiBgUYxcPY2XkvOwqAD4hbe6g",{"id":3697,"title":3698,"author":6,"body":3699,"description":5750,"difficulty":1272,"draft":451,"extension":452,"image":5751,"meta":5752,"navigation":393,"path":5753,"pinned":451,"published":5754,"seo":5755,"series":5756,"seriesOrder":203,"stem":5757,"tags":5758,"updated":5763,"__hash__":5764},"posts\u002Fposts\u002Fgo-mcp-gin\u002Findex.md","Go-MCP 接入 Gin：从零搭建 MCP 服务器",{"type":8,"value":3700,"toc":5731},[3701,3705,3708,3711,3715,3722,3728,3731,3734,3754,3757,3761,3785,3789,3889,3902,3906,4070,4073,4290,4294,4305,4404,4411,4415,4689,4692,4773,4777,4780,4782,4793,4797,4803,5170,5360,5363,5419,5422,5575,5578,5581,5652,5654,5719,5728],[11,3702,3704],{"id":3703},"什么是-mcp","什么是 MCP",[16,3706,3707],{},"MCP（Model Context Protocol）是 Anthropic 提出的开放协议，用于让 AI 助手（如 Claude）与外部服务进行标准化交互。简单来说：你提供一个 MCP 服务器，AI 就能发现并调用你定义的工具（Tools），就像给 AI 装了一双手。",[16,3709,3710],{},"传统做法是写 REST API 然后让 AI 通过函数调用（Function Calling）间接使用，但 MCP 统一了协议层，AI 客户端只需要配置一个 URL 就能接入所有工具。",[11,3712,3714],{"id":3713},"为什么选-gin-mcp-go","为什么选 Gin + mcp-go",[16,3716,3717,3718,3721],{},"Go 生态里做 MCP 服务器最成熟的库是 ",[197,3719,3720],{},"mark3labs\u002Fmcp-go","，它支持 StreamableHTTP 传输模式，可以直接挂载到任意 HTTP 框架上。Gin 作为 Go 最流行的 HTTP 框架，天然适配。",[16,3723,3724,3725],{},"核心思路：",[20,3726,3727],{},"MCP 服务器本质上就是一个 HTTP Handler，挂到 Gin 路由上即可。",[11,3729,3730],{"id":3730},"需求分析",[16,3732,3733],{},"我们要搭建一个具备以下能力的 MCP 服务器：",[721,3735,3736,3742,3748],{},[123,3737,3738,3741],{},[20,3739,3740],{},"工具注册"," — 向 AI 暴露可调用的工具（列出资源、查看详情、创建\u002F删除）",[123,3743,3744,3747],{},[20,3745,3746],{},"HTTP 集成"," — MCP 端点作为 Gin 路由的一部分，与业务 API 共存",[123,3749,3750,3753],{},[20,3751,3752],{},"鉴权"," — MCP 请求必须经过身份验证，工具处理器能获取当前用户信息",[11,3755,3756],{"id":3756},"实现步骤",[717,3758,3760],{"id":3759},"_1-安装依赖","1. 安装依赖",[190,3762,3764],{"className":356,"code":3763,"language":358,"meta":195,"style":195},"go get github.com\u002Fmark3labs\u002Fmcp-go@v0.54.0\ngo get github.com\u002Fgin-gonic\u002Fgin@v1.12.0\n",[197,3765,3766,3776],{"__ignoreMap":195},[200,3767,3768,3770,3773],{"class":202,"line":203},[200,3769,608],{"class":290},[200,3771,3772],{"class":237}," get",[200,3774,3775],{"class":237}," github.com\u002Fmark3labs\u002Fmcp-go@v0.54.0\n",[200,3777,3778,3780,3782],{"class":202,"line":210},[200,3779,608],{"class":290},[200,3781,3772],{"class":237},[200,3783,3784],{"class":237}," github.com\u002Fgin-gonic\u002Fgin@v1.12.0\n",[717,3786,3788],{"id":3787},"_2-创建-mcp-服务器","2. 创建 MCP 服务器",[190,3790,3793],{"className":3791,"code":3792,"language":608,"meta":195,"style":195},"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",[197,3794,3795,3807,3811,3828,3839,3850,3861,3870,3885],{"__ignoreMap":195},[200,3796,3797,3800,3802,3805],{"class":202,"line":203},[200,3798,3799],{"class":213},"import",[200,3801,509],{"class":237},[200,3803,3804],{"class":290},"github.com\u002Fmark3labs\u002Fmcp-go\u002Fserver",[200,3806,715],{"class":237},[200,3808,3809],{"class":202,"line":210},[200,3810,532],{"emptyLinePlaceholder":393},[200,3812,3813,3816,3819,3822,3825],{"class":202,"line":231},[200,3814,3815],{"class":227},"mcpServer ",[200,3817,3818],{"class":213},":=",[200,3820,3821],{"class":227}," server.",[200,3823,3824],{"class":290},"NewMCPServer",[200,3826,3827],{"class":227},"(\n",[200,3829,3830,3833,3836],{"class":202,"line":244},[200,3831,3832],{"class":237},"    \"my-app\"",[200,3834,3835],{"class":227},",    ",[200,3837,3838],{"class":206},"\u002F\u002F 服务器名称\n",[200,3840,3841,3844,3847],{"class":202,"line":255},[200,3842,3843],{"class":237},"    \"1.0.0\"",[200,3845,3846],{"class":227},",     ",[200,3848,3849],{"class":206},"\u002F\u002F 版本\n",[200,3851,3852,3855,3858],{"class":202,"line":266},[200,3853,3854],{"class":227},"    server.",[200,3856,3857],{"class":290},"WithLogging",[200,3859,3860],{"class":227},"(),\n",[200,3862,3863,3865,3868],{"class":202,"line":272},[200,3864,3854],{"class":227},[200,3866,3867],{"class":290},"WithRecovery",[200,3869,3860],{"class":227},[200,3871,3872,3874,3877,3879,3882],{"class":202,"line":599},[200,3873,3854],{"class":227},[200,3875,3876],{"class":290},"WithInstructions",[200,3878,2367],{"class":227},[200,3880,3881],{"class":237},"\"这是一个 MCP 服务器示例\"",[200,3883,3884],{"class":227},"),\n",[200,3886,3887],{"class":202,"line":605},[200,3888,2677],{"class":227},[16,3890,3891,3893,3894,3897,3898,3901],{},[197,3892,3824],{}," 创建一个 MCP 协议实例，它负责处理工具发现（",[197,3895,3896],{},"tools\u002Flist","）和工具调用（",[197,3899,3900],{},"tools\u002Fcall","）的协议逻辑。",[717,3903,3905],{"id":3904},"_3-注册工具","3. 注册工具",[190,3907,3909],{"className":3791,"code":3908,"language":608,"meta":195,"style":195},"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",[197,3910,3911,3922,3926,3931,3951,3966,3970,3974,3979,4040,4045,4066],{"__ignoreMap":195},[200,3912,3913,3915,3917,3920],{"class":202,"line":203},[200,3914,3799],{"class":213},[200,3916,509],{"class":237},[200,3918,3919],{"class":290},"github.com\u002Fmark3labs\u002Fmcp-go\u002Fmcp",[200,3921,715],{"class":237},[200,3923,3924],{"class":202,"line":210},[200,3925,532],{"emptyLinePlaceholder":393},[200,3927,3928],{"class":202,"line":231},[200,3929,3930],{"class":206},"\u002F\u002F 定义工具\n",[200,3932,3933,3936,3938,3941,3944,3946,3949],{"class":202,"line":244},[200,3934,3935],{"class":227},"listTool ",[200,3937,3818],{"class":213},[200,3939,3940],{"class":227}," mcp.",[200,3942,3943],{"class":290},"NewTool",[200,3945,2367],{"class":227},[200,3947,3948],{"class":237},"\"list_items\"",[200,3950,241],{"class":227},[200,3952,3953,3956,3959,3961,3964],{"class":202,"line":255},[200,3954,3955],{"class":227},"    mcp.",[200,3957,3958],{"class":290},"WithDescription",[200,3960,2367],{"class":227},[200,3962,3963],{"class":237},"\"列出所有项目\"",[200,3965,3884],{"class":227},[200,3967,3968],{"class":202,"line":266},[200,3969,2677],{"class":227},[200,3971,3972],{"class":202,"line":272},[200,3973,532],{"emptyLinePlaceholder":393},[200,3975,3976],{"class":202,"line":599},[200,3977,3978],{"class":206},"\u002F\u002F 注册处理器\n",[200,3980,3981,3984,3987,3990,3993,3995,3998,4001,4003,4006,4008,4011,4014,4016,4019,4022,4025,4028,4030,4033,4035,4038],{"class":202,"line":605},[200,3982,3983],{"class":227},"mcpServer.",[200,3985,3986],{"class":290},"AddTool",[200,3988,3989],{"class":227},"(listTool, ",[200,3991,3992],{"class":213},"func",[200,3994,2367],{"class":227},[200,3996,3997],{"class":2370},"ctx",[200,3999,4000],{"class":290}," context",[200,4002,2489],{"class":227},[200,4004,4005],{"class":290},"Context",[200,4007,337],{"class":227},[200,4009,4010],{"class":2370},"req",[200,4012,4013],{"class":290}," mcp",[200,4015,2489],{"class":227},[200,4017,4018],{"class":290},"CallToolRequest",[200,4020,4021],{"class":227},") (",[200,4023,4024],{"class":213},"*",[200,4026,4027],{"class":290},"mcp",[200,4029,2489],{"class":227},[200,4031,4032],{"class":290},"CallToolResult",[200,4034,337],{"class":227},[200,4036,4037],{"class":213},"error",[200,4039,3619],{"class":227},[200,4041,4042],{"class":202,"line":619},[200,4043,4044],{"class":206},"    \u002F\u002F 业务逻辑\n",[200,4046,4047,4050,4052,4055,4057,4060,4063],{"class":202,"line":642},[200,4048,4049],{"class":213},"    return",[200,4051,3940],{"class":227},[200,4053,4054],{"class":290},"NewToolResultText",[200,4056,2367],{"class":227},[200,4058,4059],{"class":237},"\"返回结果\"",[200,4061,4062],{"class":227},"), ",[200,4064,4065],{"class":220},"nil\n",[200,4067,4068],{"class":202,"line":651},[200,4069,2915],{"class":227},[16,4071,4072],{},"带参数的工具：",[190,4074,4076],{"className":3791,"code":4075,"language":608,"meta":195,"style":195},"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",[197,4077,4078,4096,4109,4123,4133,4147,4152,4156,4160,4207,4226,4241,4260,4264,4269,4286],{"__ignoreMap":195},[200,4079,4080,4083,4085,4087,4089,4091,4094],{"class":202,"line":203},[200,4081,4082],{"class":227},"getTool ",[200,4084,3818],{"class":213},[200,4086,3940],{"class":227},[200,4088,3943],{"class":290},[200,4090,2367],{"class":227},[200,4092,4093],{"class":237},"\"get_item\"",[200,4095,241],{"class":227},[200,4097,4098,4100,4102,4104,4107],{"class":202,"line":210},[200,4099,3955],{"class":227},[200,4101,3958],{"class":290},[200,4103,2367],{"class":227},[200,4105,4106],{"class":237},"\"获取指定项目的详情\"",[200,4108,3884],{"class":227},[200,4110,4111,4113,4116,4118,4121],{"class":202,"line":231},[200,4112,3955],{"class":227},[200,4114,4115],{"class":290},"WithString",[200,4117,2367],{"class":227},[200,4119,4120],{"class":237},"\"item_id\"",[200,4122,241],{"class":227},[200,4124,4125,4128,4131],{"class":202,"line":244},[200,4126,4127],{"class":227},"        mcp.",[200,4129,4130],{"class":290},"Required",[200,4132,3860],{"class":227},[200,4134,4135,4137,4140,4142,4145],{"class":202,"line":255},[200,4136,4127],{"class":227},[200,4138,4139],{"class":290},"Description",[200,4141,2367],{"class":227},[200,4143,4144],{"class":237},"\"项目 ID\"",[200,4146,3884],{"class":227},[200,4148,4149],{"class":202,"line":266},[200,4150,4151],{"class":227},"    ),\n",[200,4153,4154],{"class":202,"line":272},[200,4155,2677],{"class":227},[200,4157,4158],{"class":202,"line":599},[200,4159,532],{"emptyLinePlaceholder":393},[200,4161,4162,4164,4166,4169,4171,4173,4175,4177,4179,4181,4183,4185,4187,4189,4191,4193,4195,4197,4199,4201,4203,4205],{"class":202,"line":605},[200,4163,3983],{"class":227},[200,4165,3986],{"class":290},[200,4167,4168],{"class":227},"(getTool, ",[200,4170,3992],{"class":213},[200,4172,2367],{"class":227},[200,4174,3997],{"class":2370},[200,4176,4000],{"class":290},[200,4178,2489],{"class":227},[200,4180,4005],{"class":290},[200,4182,337],{"class":227},[200,4184,4010],{"class":2370},[200,4186,4013],{"class":290},[200,4188,2489],{"class":227},[200,4190,4018],{"class":290},[200,4192,4021],{"class":227},[200,4194,4024],{"class":213},[200,4196,4027],{"class":290},[200,4198,2489],{"class":227},[200,4200,4032],{"class":290},[200,4202,337],{"class":227},[200,4204,4037],{"class":213},[200,4206,3619],{"class":227},[200,4208,4209,4212,4214,4217,4220,4222,4224],{"class":202,"line":619},[200,4210,4211],{"class":227},"    itemID, err ",[200,4213,3818],{"class":213},[200,4215,4216],{"class":227}," req.",[200,4218,4219],{"class":290},"RequireString",[200,4221,2367],{"class":227},[200,4223,4120],{"class":237},[200,4225,2677],{"class":227},[200,4227,4228,4230,4233,4236,4239],{"class":202,"line":642},[200,4229,2444],{"class":213},[200,4231,4232],{"class":227}," err ",[200,4234,4235],{"class":213},"!=",[200,4237,4238],{"class":220}," nil",[200,4240,228],{"class":227},[200,4242,4243,4246,4248,4251,4253,4256,4258],{"class":202,"line":651},[200,4244,4245],{"class":213},"        return",[200,4247,3940],{"class":227},[200,4249,4250],{"class":290},"NewToolResultError",[200,4252,2367],{"class":227},[200,4254,4255],{"class":237},"\"缺少 item_id 参数\"",[200,4257,4062],{"class":227},[200,4259,4065],{"class":220},[200,4261,4262],{"class":202,"line":656},[200,4263,2327],{"class":227},[200,4265,4266],{"class":202,"line":662},[200,4267,4268],{"class":206},"    \u002F\u002F 查询并返回\n",[200,4270,4271,4273,4275,4277,4279,4282,4284],{"class":202,"line":673},[200,4272,4049],{"class":213},[200,4274,3940],{"class":227},[200,4276,4054],{"class":290},[200,4278,2367],{"class":227},[200,4280,4281],{"class":237},"\"项目详情...\"",[200,4283,4062],{"class":227},[200,4285,4065],{"class":220},[200,4287,4288],{"class":202,"line":684},[200,4289,2915],{"class":227},[717,4291,4293],{"id":4292},"_4-挂载到-gin-路由","4. 挂载到 Gin 路由",[16,4295,4296,4297,4300,4301,4304],{},"关键一步：用 ",[197,4298,4299],{},"server.NewStreamableHTTPServer"," 把 MCP 实例转成 HTTP Handler，再用 ",[197,4302,4303],{},"gin.WrapH"," 适配 Gin。",[190,4306,4308],{"className":3791,"code":4307,"language":608,"meta":195,"style":195},"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",[197,4309,4310,4320,4324,4339,4353,4370,4374,4378,4383],{"__ignoreMap":195},[200,4311,4312,4314,4316,4318],{"class":202,"line":203},[200,4313,3799],{"class":213},[200,4315,509],{"class":237},[200,4317,3804],{"class":290},[200,4319,715],{"class":237},[200,4321,4322],{"class":202,"line":210},[200,4323,532],{"emptyLinePlaceholder":393},[200,4325,4326,4329,4331,4333,4336],{"class":202,"line":231},[200,4327,4328],{"class":227},"mcpHTTPServer ",[200,4330,3818],{"class":213},[200,4332,3821],{"class":227},[200,4334,4335],{"class":290},"NewStreamableHTTPServer",[200,4337,4338],{"class":227},"(mcpServer,\n",[200,4340,4341,4343,4346,4348,4351],{"class":202,"line":244},[200,4342,3854],{"class":227},[200,4344,4345],{"class":290},"WithEndpointPath",[200,4347,2367],{"class":227},[200,4349,4350],{"class":237},"\"\u002Fmcp\"",[200,4352,3884],{"class":227},[200,4354,4355,4357,4360,4362,4365,4367],{"class":202,"line":255},[200,4356,3854],{"class":227},[200,4358,4359],{"class":290},"WithHeartbeatInterval",[200,4361,2367],{"class":227},[200,4363,4364],{"class":220},"30",[200,4366,4024],{"class":213},[200,4368,4369],{"class":227},"time.Second),\n",[200,4371,4372],{"class":202,"line":266},[200,4373,2677],{"class":227},[200,4375,4376],{"class":202,"line":272},[200,4377,532],{"emptyLinePlaceholder":393},[200,4379,4380],{"class":202,"line":599},[200,4381,4382],{"class":206},"\u002F\u002F 挂载到 Gin\n",[200,4384,4385,4388,4391,4393,4395,4398,4401],{"class":202,"line":605},[200,4386,4387],{"class":227},"r.",[200,4389,4390],{"class":290},"Any",[200,4392,2367],{"class":227},[200,4394,4350],{"class":237},[200,4396,4397],{"class":227},", gin.",[200,4399,4400],{"class":290},"WrapH",[200,4402,4403],{"class":227},"(mcpHTTPServer))\n",[16,4405,4406,4407,4410],{},"就这样，",[197,4408,4409],{},"POST \u002Fmcp"," 端点就生效了。AI 客户端通过这个 URL 发送 JSON-RPC 请求，MCP 服务器自动处理协议握手、工具列表返回、工具调用路由。",[717,4412,4414],{"id":4413},"_5-完整的服务启动代码","5. 完整的服务启动代码",[190,4416,4418],{"className":3791,"code":4417,"language":608,"meta":195,"style":195},"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",[197,4419,4420,4429,4444,4448,4453,4476,4485,4493,4498,4502,4507,4515,4519,4524,4537,4549,4563,4567,4585,4589,4594,4627,4662,4667,4671,4685],{"__ignoreMap":195},[200,4421,4422,4424,4427],{"class":202,"line":203},[200,4423,3992],{"class":213},[200,4425,4426],{"class":290}," main",[200,4428,1770],{"class":227},[200,4430,4431,4434,4436,4439,4442],{"class":202,"line":210},[200,4432,4433],{"class":227},"    r ",[200,4435,3818],{"class":213},[200,4437,4438],{"class":227}," gin.",[200,4440,4441],{"class":290},"Default",[200,4443,2562],{"class":227},[200,4445,4446],{"class":202,"line":231},[200,4447,532],{"emptyLinePlaceholder":393},[200,4449,4450],{"class":202,"line":244},[200,4451,4452],{"class":206},"    \u002F\u002F 创建 MCP 服务器\n",[200,4454,4455,4458,4460,4462,4464,4466,4469,4471,4474],{"class":202,"line":255},[200,4456,4457],{"class":227},"    mcpServer ",[200,4459,3818],{"class":213},[200,4461,3821],{"class":227},[200,4463,3824],{"class":290},[200,4465,2367],{"class":227},[200,4467,4468],{"class":237},"\"my-app\"",[200,4470,337],{"class":227},[200,4472,4473],{"class":237},"\"1.0.0\"",[200,4475,241],{"class":227},[200,4477,4478,4481,4483],{"class":202,"line":266},[200,4479,4480],{"class":227},"        server.",[200,4482,3857],{"class":290},[200,4484,3860],{"class":227},[200,4486,4487,4489,4491],{"class":202,"line":272},[200,4488,4480],{"class":227},[200,4490,3867],{"class":290},[200,4492,3860],{"class":227},[200,4494,4495],{"class":202,"line":599},[200,4496,4497],{"class":227},"    )\n",[200,4499,4500],{"class":202,"line":605},[200,4501,532],{"emptyLinePlaceholder":393},[200,4503,4504],{"class":202,"line":619},[200,4505,4506],{"class":206},"    \u002F\u002F 注册工具\n",[200,4508,4509,4512],{"class":202,"line":642},[200,4510,4511],{"class":290},"    registerTools",[200,4513,4514],{"class":227},"(mcpServer)\n",[200,4516,4517],{"class":202,"line":651},[200,4518,532],{"emptyLinePlaceholder":393},[200,4520,4521],{"class":202,"line":656},[200,4522,4523],{"class":206},"    \u002F\u002F 挂载到 Gin\n",[200,4525,4526,4529,4531,4533,4535],{"class":202,"line":662},[200,4527,4528],{"class":227},"    mcpHTTP ",[200,4530,3818],{"class":213},[200,4532,3821],{"class":227},[200,4534,4335],{"class":290},[200,4536,4338],{"class":227},[200,4538,4539,4541,4543,4545,4547],{"class":202,"line":673},[200,4540,4480],{"class":227},[200,4542,4345],{"class":290},[200,4544,2367],{"class":227},[200,4546,4350],{"class":237},[200,4548,3884],{"class":227},[200,4550,4551,4553,4555,4557,4559,4561],{"class":202,"line":684},[200,4552,4480],{"class":227},[200,4554,4359],{"class":290},[200,4556,2367],{"class":227},[200,4558,4364],{"class":220},[200,4560,4024],{"class":213},[200,4562,4369],{"class":227},[200,4564,4565],{"class":202,"line":689},[200,4566,4497],{"class":227},[200,4568,4569,4572,4574,4576,4578,4580,4582],{"class":202,"line":698},[200,4570,4571],{"class":227},"    r.",[200,4573,4390],{"class":290},[200,4575,2367],{"class":227},[200,4577,4350],{"class":237},[200,4579,4397],{"class":227},[200,4581,4400],{"class":290},[200,4583,4584],{"class":227},"(mcpHTTP))\n",[200,4586,4587],{"class":202,"line":703},[200,4588,532],{"emptyLinePlaceholder":393},[200,4590,4591],{"class":202,"line":912},[200,4592,4593],{"class":206},"    \u002F\u002F 其他业务路由\n",[200,4595,4596,4598,4601,4603,4606,4608,4610,4612,4615,4618,4621,4623,4625],{"class":202,"line":920},[200,4597,4571],{"class":227},[200,4599,4600],{"class":290},"GET",[200,4602,2367],{"class":227},[200,4604,4605],{"class":237},"\"\u002Fapi\u002Fhealth\"",[200,4607,337],{"class":227},[200,4609,3992],{"class":213},[200,4611,2367],{"class":227},[200,4613,4614],{"class":2370},"c",[200,4616,4617],{"class":213}," *",[200,4619,4620],{"class":290},"gin",[200,4622,2489],{"class":227},[200,4624,4005],{"class":290},[200,4626,3619],{"class":227},[200,4628,4629,4632,4635,4637,4640,4642,4644,4646,4649,4652,4655,4657,4660],{"class":202,"line":925},[200,4630,4631],{"class":227},"        c.",[200,4633,4634],{"class":290},"JSON",[200,4636,2367],{"class":227},[200,4638,4639],{"class":220},"200",[200,4641,337],{"class":227},[200,4643,4620],{"class":290},[200,4645,2489],{"class":227},[200,4647,4648],{"class":290},"H",[200,4650,4651],{"class":227},"{",[200,4653,4654],{"class":237},"\"status\"",[200,4656,300],{"class":227},[200,4658,4659],{"class":237},"\"ok\"",[200,4661,2915],{"class":227},[200,4663,4664],{"class":202,"line":1556},[200,4665,4666],{"class":227},"    })\n",[200,4668,4669],{"class":202,"line":1570},[200,4670,532],{"emptyLinePlaceholder":393},[200,4672,4673,4675,4678,4680,4683],{"class":202,"line":1577},[200,4674,4571],{"class":227},[200,4676,4677],{"class":290},"Run",[200,4679,2367],{"class":227},[200,4681,4682],{"class":237},"\":8080\"",[200,4684,2677],{"class":227},[200,4686,4687],{"class":202,"line":1582},[200,4688,275],{"class":227},[16,4690,4691],{},"此时启动服务，用 curl 测试：",[190,4693,4695],{"className":356,"code":4694,"language":358,"meta":195,"style":195},"# 发现工具列表\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",[197,4696,4697,4702,4719,4729,4737,4741,4746,4758,4766],{"__ignoreMap":195},[200,4698,4699],{"class":202,"line":203},[200,4700,4701],{"class":206},"# 发现工具列表\n",[200,4703,4704,4707,4710,4713,4716],{"class":202,"line":210},[200,4705,4706],{"class":290},"curl",[200,4708,4709],{"class":220}," -X",[200,4711,4712],{"class":237}," POST",[200,4714,4715],{"class":237}," http:\u002F\u002Flocalhost:8080\u002Fmcp",[200,4717,4718],{"class":220}," \\\n",[200,4720,4721,4724,4727],{"class":202,"line":231},[200,4722,4723],{"class":220},"  -H",[200,4725,4726],{"class":237}," \"Content-Type: application\u002Fjson\"",[200,4728,4718],{"class":220},[200,4730,4731,4734],{"class":202,"line":244},[200,4732,4733],{"class":220},"  -d",[200,4735,4736],{"class":237}," '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools\u002Flist\"}'\n",[200,4738,4739],{"class":202,"line":255},[200,4740,532],{"emptyLinePlaceholder":393},[200,4742,4743],{"class":202,"line":266},[200,4744,4745],{"class":206},"# 调用工具\n",[200,4747,4748,4750,4752,4754,4756],{"class":202,"line":272},[200,4749,4706],{"class":290},[200,4751,4709],{"class":220},[200,4753,4712],{"class":237},[200,4755,4715],{"class":237},[200,4757,4718],{"class":220},[200,4759,4760,4762,4764],{"class":202,"line":599},[200,4761,4723],{"class":220},[200,4763,4726],{"class":237},[200,4765,4718],{"class":220},[200,4767,4768,4770],{"class":202,"line":605},[200,4769,4733],{"class":220},[200,4771,4772],{"class":237}," '{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools\u002Fcall\",\"params\":{\"name\":\"list_items\",\"arguments\":{}}}'\n",[11,4774,4776],{"id":4775},"拓展接入鉴权","拓展：接入鉴权",[16,4778,4779],{},"裸奔的 MCP 端点任何人都能调用，生产环境必须加鉴权。思路和普通 API 鉴权一致：Bearer Token。",[717,4781,3343],{"id":3343},[16,4783,4784,4785,4788,4789,4792],{},"MCP 的工具处理器签名为 ",[197,4786,4787],{},"func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error)","，它不接收 Gin 的 ",[197,4790,4791],{},"*gin.Context","，所以无法直接在工具处理器里读取请求头。",[717,4794,4796],{"id":4795},"解决方案中间件传递用户信息","解决方案：中间件传递用户信息",[16,4798,4799,4800,2258],{},"用两层中间件：第一层做 Gin 的 Token 验证，第二层把用户信息从 Gin Context 复制到 ",[197,4801,4802],{},"request.Context",[190,4804,4806],{"className":3791,"code":4805,"language":608,"meta":195,"style":195},"\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",[197,4807,4808,4813,4841,4862,4882,4898,4931,4936,4941,4945,4970,5010,5039,5043,5047,5051,5071,5083,5112,5116,5120,5124,5139,5153,5162,5166],{"__ignoreMap":195},[200,4809,4810],{"class":202,"line":203},[200,4811,4812],{"class":206},"\u002F\u002F Gin 鉴权中间件\n",[200,4814,4815,4817,4820,4822,4825,4827,4830,4832,4834,4836,4839],{"class":202,"line":210},[200,4816,3992],{"class":213},[200,4818,4819],{"class":290}," AuthRequired",[200,4821,2367],{"class":227},[200,4823,4824],{"class":2370},"authService",[200,4826,4617],{"class":213},[200,4828,4829],{"class":290},"AuthService",[200,4831,2831],{"class":227},[200,4833,4620],{"class":290},[200,4835,2489],{"class":227},[200,4837,4838],{"class":290},"HandlerFunc",[200,4840,228],{"class":227},[200,4842,4843,4845,4848,4850,4852,4854,4856,4858,4860],{"class":202,"line":231},[200,4844,4049],{"class":213},[200,4846,4847],{"class":213}," func",[200,4849,2367],{"class":227},[200,4851,4614],{"class":2370},[200,4853,4617],{"class":213},[200,4855,4620],{"class":290},[200,4857,2489],{"class":227},[200,4859,4005],{"class":290},[200,4861,3619],{"class":227},[200,4863,4864,4867,4869,4872,4875,4877,4880],{"class":202,"line":244},[200,4865,4866],{"class":227},"        authHeader ",[200,4868,3818],{"class":213},[200,4870,4871],{"class":227}," c.",[200,4873,4874],{"class":290},"GetHeader",[200,4876,2367],{"class":227},[200,4878,4879],{"class":237},"\"Authorization\"",[200,4881,2677],{"class":227},[200,4883,4884,4887,4890,4893,4896],{"class":202,"line":255},[200,4885,4886],{"class":213},"        if",[200,4888,4889],{"class":227}," authHeader ",[200,4891,4892],{"class":213},"==",[200,4894,4895],{"class":237}," \"\"",[200,4897,228],{"class":227},[200,4899,4900,4903,4906,4908,4911,4913,4915,4917,4919,4921,4924,4926,4929],{"class":202,"line":266},[200,4901,4902],{"class":227},"            c.",[200,4904,4905],{"class":290},"AbortWithStatusJSON",[200,4907,2367],{"class":227},[200,4909,4910],{"class":220},"401",[200,4912,337],{"class":227},[200,4914,4620],{"class":290},[200,4916,2489],{"class":227},[200,4918,4648],{"class":290},[200,4920,4651],{"class":227},[200,4922,4923],{"class":237},"\"message\"",[200,4925,300],{"class":227},[200,4927,4928],{"class":237},"\"缺少认证令牌\"",[200,4930,2915],{"class":227},[200,4932,4933],{"class":202,"line":272},[200,4934,4935],{"class":213},"            return\n",[200,4937,4938],{"class":202,"line":599},[200,4939,4940],{"class":227},"        }\n",[200,4942,4943],{"class":202,"line":605},[200,4944,532],{"emptyLinePlaceholder":393},[200,4946,4947,4950,4952,4955,4958,4961,4964,4966,4968],{"class":202,"line":619},[200,4948,4949],{"class":227},"        parts ",[200,4951,3818],{"class":213},[200,4953,4954],{"class":227}," strings.",[200,4956,4957],{"class":290},"SplitN",[200,4959,4960],{"class":227},"(authHeader, ",[200,4962,4963],{"class":237},"\" \"",[200,4965,337],{"class":227},[200,4967,2553],{"class":220},[200,4969,2677],{"class":227},[200,4971,4972,4974,4977,4980,4982,4985,4988,4991,4994,4997,5000,5002,5005,5008],{"class":202,"line":642},[200,4973,4886],{"class":213},[200,4975,4976],{"class":290}," len",[200,4978,4979],{"class":227},"(parts) ",[200,4981,4235],{"class":213},[200,4983,4984],{"class":220}," 2",[200,4986,4987],{"class":213}," ||",[200,4989,4990],{"class":213}," !",[200,4992,4993],{"class":227},"strings.",[200,4995,4996],{"class":290},"EqualFold",[200,4998,4999],{"class":227},"(parts[",[200,5001,2455],{"class":220},[200,5003,5004],{"class":227},"], ",[200,5006,5007],{"class":237},"\"Bearer\"",[200,5009,3619],{"class":227},[200,5011,5012,5014,5016,5018,5020,5022,5024,5026,5028,5030,5032,5034,5037],{"class":202,"line":651},[200,5013,4902],{"class":227},[200,5015,4905],{"class":290},[200,5017,2367],{"class":227},[200,5019,4910],{"class":220},[200,5021,337],{"class":227},[200,5023,4620],{"class":290},[200,5025,2489],{"class":227},[200,5027,4648],{"class":290},[200,5029,4651],{"class":227},[200,5031,4923],{"class":237},[200,5033,300],{"class":227},[200,5035,5036],{"class":237},"\"格式错误，请使用 Bearer \u003Ctoken>\"",[200,5038,2915],{"class":227},[200,5040,5041],{"class":202,"line":656},[200,5042,4935],{"class":213},[200,5044,5045],{"class":202,"line":662},[200,5046,4940],{"class":227},[200,5048,5049],{"class":202,"line":673},[200,5050,532],{"emptyLinePlaceholder":393},[200,5052,5053,5056,5058,5061,5064,5066,5068],{"class":202,"line":684},[200,5054,5055],{"class":227},"        user, err ",[200,5057,3818],{"class":213},[200,5059,5060],{"class":227}," authService.",[200,5062,5063],{"class":290},"GetUserByToken",[200,5065,4999],{"class":227},[200,5067,593],{"class":220},[200,5069,5070],{"class":227},"])\n",[200,5072,5073,5075,5077,5079,5081],{"class":202,"line":689},[200,5074,4886],{"class":213},[200,5076,4232],{"class":227},[200,5078,4235],{"class":213},[200,5080,4238],{"class":220},[200,5082,228],{"class":227},[200,5084,5085,5087,5089,5091,5093,5095,5097,5099,5101,5103,5105,5107,5110],{"class":202,"line":698},[200,5086,4902],{"class":227},[200,5088,4905],{"class":290},[200,5090,2367],{"class":227},[200,5092,4910],{"class":220},[200,5094,337],{"class":227},[200,5096,4620],{"class":290},[200,5098,2489],{"class":227},[200,5100,4648],{"class":290},[200,5102,4651],{"class":227},[200,5104,4923],{"class":237},[200,5106,300],{"class":227},[200,5108,5109],{"class":237},"\"无效的认证令牌\"",[200,5111,2915],{"class":227},[200,5113,5114],{"class":202,"line":703},[200,5115,4935],{"class":213},[200,5117,5118],{"class":202,"line":912},[200,5119,4940],{"class":227},[200,5121,5122],{"class":202,"line":920},[200,5123,532],{"emptyLinePlaceholder":393},[200,5125,5126,5128,5131,5133,5136],{"class":202,"line":925},[200,5127,4631],{"class":227},[200,5129,5130],{"class":290},"Set",[200,5132,2367],{"class":227},[200,5134,5135],{"class":237},"\"userID\"",[200,5137,5138],{"class":227},", user.ID)\n",[200,5140,5141,5143,5145,5147,5150],{"class":202,"line":1556},[200,5142,4631],{"class":227},[200,5144,5130],{"class":290},[200,5146,2367],{"class":227},[200,5148,5149],{"class":237},"\"username\"",[200,5151,5152],{"class":227},", user.Username)\n",[200,5154,5155,5157,5160],{"class":202,"line":1570},[200,5156,4631],{"class":227},[200,5158,5159],{"class":290},"Next",[200,5161,2562],{"class":227},[200,5163,5164],{"class":202,"line":1577},[200,5165,2327],{"class":227},[200,5167,5168],{"class":202,"line":1582},[200,5169,275],{"class":227},[190,5171,5173],{"className":3791,"code":5172,"language":608,"meta":195,"style":195},"\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",[197,5174,5175,5180,5191,5198,5211,5223,5227,5231,5249,5269,5283,5307,5329,5344,5352,5356],{"__ignoreMap":195},[200,5176,5177],{"class":202,"line":203},[200,5178,5179],{"class":206},"\u002F\u002F 用户信息桥接中间件：Gin Context → request.Context\n",[200,5181,5182,5185,5188],{"class":202,"line":210},[200,5183,5184],{"class":213},"type",[200,5186,5187],{"class":290}," ctxKey",[200,5189,5190],{"class":213}," string\n",[200,5192,5193,5195],{"class":202,"line":231},[200,5194,2601],{"class":213},[200,5196,5197],{"class":227}," (\n",[200,5199,5200,5203,5206,5208],{"class":202,"line":244},[200,5201,5202],{"class":220},"    ctxUserID",[200,5204,5205],{"class":290},"   ctxKey",[200,5207,224],{"class":213},[200,5209,5210],{"class":237}," \"userID\"\n",[200,5212,5213,5216,5218,5220],{"class":202,"line":255},[200,5214,5215],{"class":220},"    ctxUsername",[200,5217,5187],{"class":290},[200,5219,224],{"class":213},[200,5221,5222],{"class":237}," \"username\"\n",[200,5224,5225],{"class":202,"line":266},[200,5226,2677],{"class":227},[200,5228,5229],{"class":202,"line":272},[200,5230,532],{"emptyLinePlaceholder":393},[200,5232,5233,5235,5238,5241,5243,5245,5247],{"class":202,"line":599},[200,5234,3992],{"class":213},[200,5236,5237],{"class":290}," WithUserContext",[200,5239,5240],{"class":227},"() ",[200,5242,4620],{"class":290},[200,5244,2489],{"class":227},[200,5246,4838],{"class":290},[200,5248,228],{"class":227},[200,5250,5251,5253,5255,5257,5259,5261,5263,5265,5267],{"class":202,"line":605},[200,5252,4049],{"class":213},[200,5254,4847],{"class":213},[200,5256,2367],{"class":227},[200,5258,4614],{"class":2370},[200,5260,4617],{"class":213},[200,5262,4620],{"class":290},[200,5264,2489],{"class":227},[200,5266,4005],{"class":290},[200,5268,3619],{"class":227},[200,5270,5271,5274,5276,5279,5281],{"class":202,"line":619},[200,5272,5273],{"class":227},"        ctx ",[200,5275,3818],{"class":213},[200,5277,5278],{"class":227}," c.Request.",[200,5280,4005],{"class":290},[200,5282,2562],{"class":227},[200,5284,5285,5287,5289,5292,5295,5298,5301,5303,5305],{"class":202,"line":642},[200,5286,5273],{"class":227},[200,5288,575],{"class":213},[200,5290,5291],{"class":227}," context.",[200,5293,5294],{"class":290},"WithValue",[200,5296,5297],{"class":227},"(ctx, ctxUserID, c.",[200,5299,5300],{"class":290},"GetUint",[200,5302,2367],{"class":227},[200,5304,5135],{"class":237},[200,5306,2530],{"class":227},[200,5308,5309,5311,5313,5315,5317,5320,5323,5325,5327],{"class":202,"line":651},[200,5310,5273],{"class":227},[200,5312,575],{"class":213},[200,5314,5291],{"class":227},[200,5316,5294],{"class":290},[200,5318,5319],{"class":227},"(ctx, ctxUsername, c.",[200,5321,5322],{"class":290},"GetString",[200,5324,2367],{"class":227},[200,5326,5149],{"class":237},[200,5328,2530],{"class":227},[200,5330,5331,5334,5336,5338,5341],{"class":202,"line":656},[200,5332,5333],{"class":227},"        c.Request ",[200,5335,575],{"class":213},[200,5337,5278],{"class":227},[200,5339,5340],{"class":290},"WithContext",[200,5342,5343],{"class":227},"(ctx)\n",[200,5345,5346,5348,5350],{"class":202,"line":662},[200,5347,4631],{"class":227},[200,5349,5159],{"class":290},[200,5351,2562],{"class":227},[200,5353,5354],{"class":202,"line":673},[200,5355,2327],{"class":227},[200,5357,5358],{"class":202,"line":684},[200,5359,275],{"class":227},[717,5361,5362],{"id":5362},"组合使用",[190,5364,5366],{"className":3791,"code":5365,"language":608,"meta":195,"style":195},"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",[197,5367,5368,5380,5391,5402,5415],{"__ignoreMap":195},[200,5369,5370,5372,5374,5376,5378],{"class":202,"line":203},[200,5371,4387],{"class":227},[200,5373,4390],{"class":290},[200,5375,2367],{"class":227},[200,5377,4350],{"class":237},[200,5379,241],{"class":227},[200,5381,5382,5385,5388],{"class":202,"line":210},[200,5383,5384],{"class":290},"    AuthRequired",[200,5386,5387],{"class":227},"(authService),  ",[200,5389,5390],{"class":206},"\u002F\u002F 1. 验证 Token\n",[200,5392,5393,5396,5399],{"class":202,"line":231},[200,5394,5395],{"class":290},"    WithUserContext",[200,5397,5398],{"class":227},"(),           ",[200,5400,5401],{"class":206},"\u002F\u002F 2. 桥接用户信息到 request.Context\n",[200,5403,5404,5407,5409,5412],{"class":202,"line":244},[200,5405,5406],{"class":227},"    gin.",[200,5408,4400],{"class":290},[200,5410,5411],{"class":227},"(mcpHTTP),         ",[200,5413,5414],{"class":206},"\u002F\u002F 3. MCP 处理\n",[200,5416,5417],{"class":202,"line":255},[200,5418,2677],{"class":227},[16,5420,5421],{},"在工具处理器里就能拿到用户信息了：",[190,5423,5425],{"className":3791,"code":5424,"language":608,"meta":195,"style":195},"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",[197,5426,5427,5482,5503,5512,5529,5533,5537,5553,5558,5571],{"__ignoreMap":195},[200,5428,5429,5431,5433,5436,5438,5441,5443,5446,5448,5450,5452,5454,5456,5458,5460,5462,5464,5466,5468,5470,5472,5474,5476,5478,5480],{"class":202,"line":203},[200,5430,3992],{"class":213},[200,5432,2409],{"class":227},[200,5434,5435],{"class":2370},"h ",[200,5437,4024],{"class":213},[200,5439,5440],{"class":290},"Handler",[200,5442,2831],{"class":227},[200,5444,5445],{"class":290},"handleListItems",[200,5447,2367],{"class":227},[200,5449,3997],{"class":2370},[200,5451,4000],{"class":290},[200,5453,2489],{"class":227},[200,5455,4005],{"class":290},[200,5457,337],{"class":227},[200,5459,4010],{"class":2370},[200,5461,4013],{"class":290},[200,5463,2489],{"class":227},[200,5465,4018],{"class":290},[200,5467,4021],{"class":227},[200,5469,4024],{"class":213},[200,5471,4027],{"class":290},[200,5473,2489],{"class":227},[200,5475,4032],{"class":290},[200,5477,337],{"class":227},[200,5479,4037],{"class":213},[200,5481,3619],{"class":227},[200,5483,5484,5487,5489,5492,5495,5498,5501],{"class":202,"line":210},[200,5485,5486],{"class":227},"    userID, ok ",[200,5488,3818],{"class":213},[200,5490,5491],{"class":227}," ctx.",[200,5493,5494],{"class":290},"Value",[200,5496,5497],{"class":227},"(ctxUserID).(",[200,5499,5500],{"class":213},"uint",[200,5502,2677],{"class":227},[200,5504,5505,5507,5509],{"class":202,"line":231},[200,5506,2444],{"class":213},[200,5508,4990],{"class":213},[200,5510,5511],{"class":227},"ok {\n",[200,5513,5514,5516,5518,5520,5522,5525,5527],{"class":202,"line":244},[200,5515,4245],{"class":213},[200,5517,3940],{"class":227},[200,5519,4250],{"class":290},[200,5521,2367],{"class":227},[200,5523,5524],{"class":237},"\"未认证\"",[200,5526,4062],{"class":227},[200,5528,4065],{"class":220},[200,5530,5531],{"class":202,"line":255},[200,5532,2327],{"class":227},[200,5534,5535],{"class":202,"line":266},[200,5536,532],{"emptyLinePlaceholder":393},[200,5538,5539,5542,5544,5547,5550],{"class":202,"line":272},[200,5540,5541],{"class":227},"    items, _ ",[200,5543,3818],{"class":213},[200,5545,5546],{"class":227}," h.service.",[200,5548,5549],{"class":290},"ListByUser",[200,5551,5552],{"class":227},"(userID)\n",[200,5554,5555],{"class":202,"line":599},[200,5556,5557],{"class":206},"    \u002F\u002F ...\n",[200,5559,5560,5562,5564,5566,5569],{"class":202,"line":605},[200,5561,4049],{"class":213},[200,5563,3940],{"class":227},[200,5565,4054],{"class":290},[200,5567,5568],{"class":227},"(result), ",[200,5570,4065],{"class":220},[200,5572,5573],{"class":202,"line":619},[200,5574,275],{"class":227},[717,5576,5577],{"id":5577},"客户端配置",[16,5579,5580],{},"AI 客户端（如 Claude Desktop）配置 MCP 服务器时带上 Authorization 头：",[190,5582,5586],{"className":5583,"code":5584,"language":5585,"meta":195,"style":195},"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",[197,5587,5588,5593,5600,5606,5618,5625,5635,5640,5644,5648],{"__ignoreMap":195},[200,5589,5590],{"class":202,"line":203},[200,5591,5592],{"class":227},"{\n",[200,5594,5595,5598],{"class":202,"line":210},[200,5596,5597],{"class":220},"  \"mcpServers\"",[200,5599,2286],{"class":227},[200,5601,5602,5604],{"class":202,"line":231},[200,5603,3832],{"class":220},[200,5605,2286],{"class":227},[200,5607,5608,5611,5613,5616],{"class":202,"line":244},[200,5609,5610],{"class":220},"      \"url\"",[200,5612,300],{"class":227},[200,5614,5615],{"class":237},"\"http:\u002F\u002Flocalhost:8080\u002Fmcp\"",[200,5617,241],{"class":227},[200,5619,5620,5623],{"class":202,"line":255},[200,5621,5622],{"class":220},"      \"headers\"",[200,5624,2286],{"class":227},[200,5626,5627,5630,5632],{"class":202,"line":266},[200,5628,5629],{"class":220},"        \"Authorization\"",[200,5631,300],{"class":227},[200,5633,5634],{"class":237},"\"Bearer your-token-here\"\n",[200,5636,5637],{"class":202,"line":272},[200,5638,5639],{"class":227},"      }\n",[200,5641,5642],{"class":202,"line":599},[200,5643,2327],{"class":227},[200,5645,5646],{"class":202,"line":605},[200,5647,2332],{"class":227},[200,5649,5650],{"class":202,"line":619},[200,5651,275],{"class":227},[11,5653,1645],{"id":1645},[34,5655,5656,5666],{},[37,5657,5658],{},[40,5659,5660,5663],{},[43,5661,5662],{},"步骤",[43,5664,5665],{},"说明",[50,5667,5668,5677,5688,5697,5706],{},[40,5669,5670,5674],{},[55,5671,5672],{},[197,5673,3824],{},[55,5675,5676],{},"创建 MCP 协议实例",[40,5678,5679,5685],{},[55,5680,5681,1662,5683],{},[197,5682,3943],{},[197,5684,3986],{},[55,5686,5687],{},"定义并注册工具",[40,5689,5690,5694],{},[55,5691,5692],{},[197,5693,4335],{},[55,5695,5696],{},"转为 HTTP Handler",[40,5698,5699,5703],{},[55,5700,5701],{},[197,5702,4303],{},[55,5704,5705],{},"挂载到 Gin 路由",[40,5707,5708,5716],{},[55,5709,5710,1662,5713],{},[197,5711,5712],{},"AuthRequired",[197,5714,5715],{},"WithUserContext",[55,5717,5718],{},"鉴权 + 用户信息桥接",[16,5720,5721,5722,5725,5726,2258],{},"核心就一句话：",[20,5723,5724],{},"MCP 是 HTTP Handler，挂上去就行。"," 鉴权复用你已有的中间件体系，只需要多写一个桥接函数把用户信息传进 ",[197,5727,4802],{},[437,5729,5730],{},"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":195,"searchDepth":210,"depth":231,"links":5732},[5733,5734,5735,5736,5743,5749],{"id":3703,"depth":210,"text":3704},{"id":3713,"depth":210,"text":3714},{"id":3730,"depth":210,"text":3730},{"id":3756,"depth":210,"text":3756,"children":5737},[5738,5739,5740,5741,5742],{"id":3759,"depth":231,"text":3760},{"id":3787,"depth":231,"text":3788},{"id":3904,"depth":231,"text":3905},{"id":4292,"depth":231,"text":4293},{"id":4413,"depth":231,"text":4414},{"id":4775,"depth":210,"text":4776,"children":5744},[5745,5746,5747,5748],{"id":3343,"depth":231,"text":3343},{"id":4795,"depth":231,"text":4796},{"id":5362,"depth":231,"text":5362},{"id":5577,"depth":231,"text":5577},{"id":1645,"depth":210,"text":1645},"介绍如何使用 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":3698,"description":5750},"Go 工程实践","posts\u002Fgo-mcp-gin\u002Findex",[1282,5759,5760,5761,5762],"MCP","Gin","AI Agent","后端","2026-06-01","5fZl1Vigs2lHb5fHPz-0YZFyFD38ws1heOouZUG1g6M",{"id":5766,"title":5767,"author":6,"body":5768,"description":6535,"difficulty":6,"draft":451,"extension":452,"image":6536,"meta":6537,"navigation":393,"path":6538,"pinned":451,"published":6539,"seo":6540,"series":6,"seriesOrder":6,"stem":6541,"tags":6542,"updated":6,"__hash__":6546},"posts\u002Fposts\u002Fdws-dingtalk-cli\u002Findex.md","DWS — 钉钉官方出品的 AI 友好命令行工具",{"type":8,"value":5769,"toc":6521},[5770,5774,5777,5784,5910,5913,5916,5972,5975,5990,5993,5996,6003,6235,6242,6246,6249,6269,6272,6287,6290,6310,6313,6316,6323,6389,6392,6395,6399,6402,6454,6458,6479,6483,6486,6500,6503,6507,6510,6512,6515,6518],[11,5771,5773],{"id":5772},"dws-是什么","dws 是什么",[16,5775,5776],{},"dws（DingTalk Workspace CLI）是阿里巴巴 2026 年开源的钉钉命令行工具，Apache-2.0 协议，Go 语言编写。",[16,5778,5779,5780,5783],{},"它的定位很明确：",[20,5781,5782],{},"把钉钉的所有产品能力暴露给 AI Agent 和命令行用户","。不用打开钉钉 App，不用写 API 调用代码，一条命令搞定。",[190,5785,5787],{"className":356,"code":5786,"language":358,"meta":195,"style":195},"# 查看今天有什么会\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",[197,5788,5789,5794,5820,5824,5829,5851,5855,5860,5885,5889,5894],{"__ignoreMap":195},[200,5790,5791],{"class":202,"line":203},[200,5792,5793],{"class":206},"# 查看今天有什么会\n",[200,5795,5796,5799,5802,5805,5808,5811,5814,5817],{"class":202,"line":210},[200,5797,5798],{"class":290},"dws",[200,5800,5801],{"class":237}," calendar",[200,5803,5804],{"class":237}," event",[200,5806,5807],{"class":237}," list",[200,5809,5810],{"class":220}," --start",[200,5812,5813],{"class":237}," 2026-06-01",[200,5815,5816],{"class":220}," --end",[200,5818,5819],{"class":237}," 2026-06-01\n",[200,5821,5822],{"class":202,"line":231},[200,5823,532],{"emptyLinePlaceholder":393},[200,5825,5826],{"class":202,"line":244},[200,5827,5828],{"class":206},"# 给群里发条消息\n",[200,5830,5831,5833,5836,5839,5842,5845,5848],{"class":202,"line":255},[200,5832,5798],{"class":290},[200,5834,5835],{"class":237}," chat",[200,5837,5838],{"class":237}," send",[200,5840,5841],{"class":220}," --group-id",[200,5843,5844],{"class":237}," xxx",[200,5846,5847],{"class":220}," --content",[200,5849,5850],{"class":237}," \"今天发布推迟到下午3点\"\n",[200,5852,5853],{"class":202,"line":266},[200,5854,532],{"emptyLinePlaceholder":393},[200,5856,5857],{"class":202,"line":272},[200,5858,5859],{"class":206},"# 创建一个待办\n",[200,5861,5862,5864,5867,5870,5873,5876,5879,5882],{"class":202,"line":599},[200,5863,5798],{"class":290},[200,5865,5866],{"class":237}," todo",[200,5868,5869],{"class":237}," task",[200,5871,5872],{"class":237}," create",[200,5874,5875],{"class":220}," --subject",[200,5877,5878],{"class":237}," \"Review PR #123\"",[200,5880,5881],{"class":220}," --due-date",[200,5883,5884],{"class":237}," 2026-06-02\n",[200,5886,5887],{"class":202,"line":605},[200,5888,532],{"emptyLinePlaceholder":393},[200,5890,5891],{"class":202,"line":619},[200,5892,5893],{"class":206},"# 读取钉钉文档\n",[200,5895,5896,5898,5901,5904,5907],{"class":202,"line":642},[200,5897,5798],{"class":290},[200,5899,5900],{"class":237}," doc",[200,5902,5903],{"class":237}," read",[200,5905,5906],{"class":220}," --url",[200,5908,5909],{"class":237}," \"https:\u002F\u002Falidocs.dingtalk.com\u002Fi\u002Fnodes\u002Fxxx\"\n",[11,5911,5912],{"id":5912},"安装",[16,5914,5915],{},"三种方式，选你喜欢的：",[190,5917,5919],{"className":356,"code":5918,"language":358,"meta":195,"style":195},"# 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",[197,5920,5921,5926,5939,5943,5948,5958,5962,5967],{"__ignoreMap":195},[200,5922,5923],{"class":202,"line":203},[200,5924,5925],{"class":206},"# npm（推荐，跨平台自动下载二进制）\n",[200,5927,5928,5931,5933,5936],{"class":202,"line":210},[200,5929,5930],{"class":290},"npm",[200,5932,553],{"class":237},[200,5934,5935],{"class":220}," -g",[200,5937,5938],{"class":237}," dingtalk-workspace-cli\n",[200,5940,5941],{"class":202,"line":231},[200,5942,532],{"emptyLinePlaceholder":393},[200,5944,5945],{"class":202,"line":244},[200,5946,5947],{"class":206},"# Homebrew（macOS\u002FLinux）\n",[200,5949,5950,5953,5955],{"class":202,"line":255},[200,5951,5952],{"class":290},"brew",[200,5954,553],{"class":237},[200,5956,5957],{"class":237}," dws\n",[200,5959,5960],{"class":202,"line":266},[200,5961,532],{"emptyLinePlaceholder":393},[200,5963,5964],{"class":202,"line":272},[200,5965,5966],{"class":206},"# 直接下载二进制\n",[200,5968,5969],{"class":202,"line":599},[200,5970,5971],{"class":206},"# https:\u002F\u002Fgithub.com\u002Fopen-dingtalk\u002Fdingtalk-workspace-cli\u002Freleases\n",[16,5973,5974],{},"安装后认证：",[190,5976,5978],{"className":356,"code":5977,"language":358,"meta":195,"style":195},"dws auth login\n",[197,5979,5980],{"__ignoreMap":195},[200,5981,5982,5984,5987],{"class":202,"line":203},[200,5983,5798],{"class":290},[200,5985,5986],{"class":237}," auth",[200,5988,5989],{"class":237}," login\n",[16,5991,5992],{},"会打开浏览器走 OAuth 设备流认证，token 加密存储在系统钥匙串里（PBKDF2 + AES-256-GCM）。",[11,5994,5995],{"id":5995},"覆盖的钉钉产品",[16,5997,5998,5999,6002],{},"这是最夸张的部分 — ",[20,6000,6001],{},"19 个产品，213 个命令","，基本把钉钉全家桶都搬到了命令行：",[34,6004,6005,6017],{},[37,6006,6007],{},[40,6008,6009,6012,6015],{},[43,6010,6011],{},"产品",[43,6013,6014],{},"命令数",[43,6016,5665],{},[50,6018,6019,6033,6047,6061,6075,6089,6103,6117,6130,6144,6157,6171,6185,6198,6211,6224],{},[40,6020,6021,6027,6030],{},[55,6022,6023,6024],{},"聊天 ",[197,6025,6026],{},"chat",[55,6028,6029],{},"57",[55,6031,6032],{},"消息收发、群管理、机器人、已读状态、Webhook",[40,6034,6035,6041,6044],{},[55,6036,6037,6038],{},"AI 表格 ",[197,6039,6040],{},"aitable",[55,6042,6043],{},"42",[55,6045,6046],{},"Base\u002F数据表\u002F字段\u002F记录\u002F视图\u002F图表\u002F仪表盘，完整 CRUD",[40,6048,6049,6055,6058],{},[55,6050,6051,6052],{},"电子表格 ",[197,6053,6054],{},"sheet",[55,6056,6057],{},"34",[55,6059,6060],{},"工作表读写、行列操作、合并、筛选视图、导出",[40,6062,6063,6069,6072],{},[55,6064,6065,6066],{},"文档 ",[197,6067,6068],{},"doc",[55,6070,6071],{},"21",[55,6073,6074],{},"搜索、读写、块级编辑、评论、复制移动",[40,6076,6077,6083,6086],{},[55,6078,6079,6080],{},"会议纪要 ",[197,6081,6082],{},"minutes",[55,6084,6085],{},"19",[55,6087,6088],{},"列表、摘要、转写、思维导图、发言人、热词",[40,6090,6091,6097,6100],{},[55,6092,6093,6094],{},"日历 ",[197,6095,6096],{},"calendar",[55,6098,6099],{},"14",[55,6101,6102],{},"事件 CRUD、会议室、忙闲查询、时间建议",[40,6104,6105,6111,6114],{},[55,6106,6107,6108],{},"审批 ",[197,6109,6110],{},"oa",[55,6112,6113],{},"9",[55,6115,6116],{},"待办、发起、同意\u002F拒绝\u002F撤销",[40,6118,6119,6125,6127],{},[55,6120,6121,6122],{},"云盘 ",[197,6123,6124],{},"drive",[55,6126,6113],{},[55,6128,6129],{},"列表、下载、上传、创建目录",[40,6131,6132,6138,6141],{},[55,6133,6134,6135],{},"知识库 ",[197,6136,6137],{},"wiki",[55,6139,6140],{},"7",[55,6142,6143],{},"空间管理、成员管理",[40,6145,6146,6152,6154],{},[55,6147,6148,6149],{},"汇报 ",[197,6150,6151],{},"report",[55,6153,6140],{},[55,6155,6156],{},"日志\u002F日报\u002F周报，按模板创建、统计",[40,6158,6159,6165,6168],{},[55,6160,6161,6162],{},"待办 ",[197,6163,6164],{},"todo",[55,6166,6167],{},"6",[55,6169,6170],{},"创建、完成、删除，支持优先级和循环",[40,6172,6173,6179,6182],{},[55,6174,6175,6176],{},"邮件 ",[197,6177,6178],{},"mail",[55,6180,6181],{},"4",[55,6183,6184],{},"邮箱查询、邮件搜索、发送",[40,6186,6187,6193,6195],{},[55,6188,6189,6190],{},"考勤 ",[197,6191,6192],{},"attendance",[55,6194,6181],{},[55,6196,6197],{},"打卡记录、排班、汇总",[40,6199,6200,6206,6208],{},[55,6201,6202,6203],{},"通讯录 ",[197,6204,6205],{},"contact",[55,6207,2316],{},[55,6209,6210],{},"用户搜索、部门查询",[40,6212,6213,6219,6221],{},[55,6214,6215,6216],{},"DING 消息 ",[197,6217,6218],{},"ding",[55,6220,2553],{},[55,6222,6223],{},"发送\u002F撤回（应用内、短信、电话）",[40,6225,6226,6229,6232],{},[55,6227,6228],{},"其他",[55,6230,6231],{},"若干",[55,6233,6234],{},"AI 搜索、AI 应用、直播、开放平台文档",[16,6236,6237,6238,6241],{},"还有一个万能命令 ",[197,6239,6240],{},"dws api","，可以直接调用任意钉钉 OpenAPI，不受这 213 个命令的限制。",[11,6243,6245],{"id":6244},"agent-skill-系统","Agent Skill 系统",[16,6247,6248],{},"dws 内置了一套完整的 Agent Skill，可以直接被 AI 编程工具发现和使用：",[120,6250,6251,6257,6263],{},[123,6252,6253,6256],{},[20,6254,6255],{},"Claude Code"," — 通过 SKILL.md 自动加载",[123,6258,6259,6262],{},[20,6260,6261],{},"Cursor"," — 支持 MCP 协议接入",[123,6264,6265,6268],{},[20,6266,6267],{},"Codex \u002F OpenCode \u002F Qoder"," — 通过 skill 安装命令接入",[16,6270,6271],{},"安装 skill：",[190,6273,6275],{"className":356,"code":6274,"language":358,"meta":195,"style":195},"dws skill setup\n",[197,6276,6277],{"__ignoreMap":195},[200,6278,6279,6281,6284],{"class":202,"line":203},[200,6280,5798],{"class":290},[200,6282,6283],{"class":237}," skill",[200,6285,6286],{"class":237}," setup\n",[16,6288,6289],{},"SKILL.md 里定义了：",[120,6291,6292,6301,6304,6307],{},[123,6293,6294,6295,6297,6298,6300],{},"16 个产品的意图判断决策树（用户说\"表格\"→ ",[197,6296,6040],{},"，说\"打卡\"→ ",[197,6299,6192],{},"）",[123,6302,6303],{},"危险操作确认流程（删除、撤回等必须先确认）",[123,6305,6306],{},"错误处理规范（verbose 重试、recovery 事件 ID）",[123,6308,6309],{},"URL 分流规则（alidocs.dingtalk.com 的多种路径格式自动识别）",[16,6311,6312],{},"Agent 读到这份 SKILL.md 就知道什么时候该调什么命令，不需要额外的 prompt 工程。",[11,6314,6315],{"id":6315},"自动升级",[16,6317,6318,6319,6322],{},"内置 ",[197,6320,6321],{},"dws upgrade","，不需要重新下载安装包：",[190,6324,6326],{"className":356,"code":6325,"language":358,"meta":195,"style":195},"dws upgrade              # 交互式升级到最新版\ndws upgrade --check      # 仅检查是否有新版本\ndws upgrade --list       # 列出最近版本\ndws upgrade --version v1.0.5  # 升级到指定版本\ndws upgrade --rollback   # 回滚到上一版本\n",[197,6327,6328,6338,6350,6362,6377],{"__ignoreMap":195},[200,6329,6330,6332,6335],{"class":202,"line":203},[200,6331,5798],{"class":290},[200,6333,6334],{"class":237}," upgrade",[200,6336,6337],{"class":206},"              # 交互式升级到最新版\n",[200,6339,6340,6342,6344,6347],{"class":202,"line":210},[200,6341,5798],{"class":290},[200,6343,6334],{"class":237},[200,6345,6346],{"class":220}," --check",[200,6348,6349],{"class":206},"      # 仅检查是否有新版本\n",[200,6351,6352,6354,6356,6359],{"class":202,"line":231},[200,6353,5798],{"class":290},[200,6355,6334],{"class":237},[200,6357,6358],{"class":220}," --list",[200,6360,6361],{"class":206},"       # 列出最近版本\n",[200,6363,6364,6366,6368,6371,6374],{"class":202,"line":244},[200,6365,5798],{"class":290},[200,6367,6334],{"class":237},[200,6369,6370],{"class":220}," --version",[200,6372,6373],{"class":237}," v1.0.5",[200,6375,6376],{"class":206},"  # 升级到指定版本\n",[200,6378,6379,6381,6383,6386],{"class":202,"line":255},[200,6380,5798],{"class":290},[200,6382,6334],{"class":237},[200,6384,6385],{"class":220}," --rollback",[200,6387,6388],{"class":206},"   # 回滚到上一版本\n",[16,6390,6391],{},"升级流程：下载 → SHA256 校验 → 备份当前版本 → 原子替换 → 更新 Skill。失败可一键回滚。",[11,6393,6394],{"id":6394},"亮点",[717,6396,6398],{"id":6397},"_1-为-ai-agent-专门优化","1. 为 AI Agent 专门优化",[16,6400,6401],{},"这可能是目前对 AI 最友好的 CLI 工具：",[120,6403,6404,6425,6435,6444],{},[123,6405,6406,6409,6410,6413,6414,6417,6418,6413,6421,6424],{},[20,6407,6408],{},"智能纠错"," — AI 模型经常生成错误的参数格式（",[197,6411,6412],{},"--userId"," 应该是 ",[197,6415,6416],{},"--user-id","，",[197,6419,6420],{},"--limit100",[197,6422,6423],{},"--limit 100","），dws 的 5 阶段 Pipeline 会自动修正",[123,6426,6427,6430,6431,6434],{},[20,6428,6429],{},"Schema 发现"," — ",[197,6432,6433],{},"dws schema"," 让 Agent 动态查询可用工具和参数，不需要硬编码",[123,6436,6437,6430,6440,6443],{},[20,6438,6439],{},"jq 过滤",[197,6441,6442],{},"--jq '.data[] | {name, id}'"," 直接过滤输出，减少 Agent 消耗的 token",[123,6445,6446,6449,6450,6453],{},[20,6447,6448],{},"JSON 输出"," — 所有命令支持 ",[197,6451,6452],{},"--format json","，方便程序解析",[717,6455,6457],{"id":6456},"_2-安全设计","2. 安全设计",[120,6459,6460,6463,6466,6469,6476],{},[123,6461,6462],{},"token 用 PBKDF2 + AES-256-GCM 加密，密钥绑定设备 MAC 地址",[123,6464,6465],{},"域名白名单，只能访问钉钉官方 API",[123,6467,6468],{},"强制 HTTPS",[123,6470,6471,6472,6475],{},"写操作必须显式 ",[197,6473,6474],{},"--yes"," 确认",[123,6477,6478],{},"完整审计链",[717,6480,6482],{"id":6481},"_3-发现驱动架构","3. 发现驱动架构",[16,6484,6485],{},"不是把所有命令硬编码在代码里，而是：",[721,6487,6488,6491,6494,6497],{},[123,6489,6490],{},"从钉钉 MCP 服务器注册中心拉取元数据",[123,6492,6493],{},"运行时发现服务端能力",[123,6495,6496],{},"转换成统一的中间表示（IR）",[123,6498,6499],{},"动态挂载到命令树",[16,6501,6502],{},"这意味着钉钉新增产品能力时，dws 不需要发版就能支持。",[717,6504,6506],{"id":6505},"_4-多平台分发","4. 多平台分发",[16,6508,6509],{},"GoReleaser 交叉编译 + Homebrew formula + npm 包 + shell\u002FPowerShell 安装脚本，覆盖 Windows、macOS、Linux。",[11,6511,1645],{"id":1645},[16,6513,6514],{},"dws 把钉钉从一个\"必须打开 App 才能用\"的工具变成了一个\"命令行 + AI Agent 随时调用\"的平台。213 个命令覆盖日常办公的方方面面，Agent Skill 系统让 AI 编程工具可以直接理解和使用这些能力。",[16,6516,6517],{},"如果你是钉钉重度用户，或者在做企业内部的 AI Agent 集成，dws 值得一试。",[437,6519,6520],{},"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":195,"searchDepth":210,"depth":231,"links":6522},[6523,6524,6525,6526,6527,6528,6534],{"id":5772,"depth":210,"text":5773},{"id":5912,"depth":210,"text":5912},{"id":5995,"depth":210,"text":5995},{"id":6244,"depth":210,"text":6245},{"id":6315,"depth":210,"text":6315},{"id":6394,"depth":210,"text":6394,"children":6529},[6530,6531,6532,6533],{"id":6397,"depth":231,"text":6398},{"id":6456,"depth":231,"text":6457},{"id":6481,"depth":231,"text":6482},{"id":6505,"depth":231,"text":6506},{"id":1645,"depth":210,"text":1645},"介绍阿里巴巴官方的 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":5767,"description":6535},"posts\u002Fdws-dingtalk-cli\u002Findex",[6543,6544,5761,1282,6545],"钉钉","CLI","效率工具","4ca65VUZ2Sz9yG8dNtftrBDDHE6QowA3ed9g4egQkKI",{"id":6548,"title":6549,"author":6,"body":6550,"description":7513,"difficulty":1272,"draft":451,"extension":452,"image":7514,"meta":7515,"navigation":393,"path":7516,"pinned":451,"published":7517,"seo":7518,"series":5756,"seriesOrder":210,"stem":7519,"tags":7520,"updated":7524,"__hash__":7525},"posts\u002Fposts\u002Fgo-embed-frontend\u002Findex.md","Go 单文件打包实战：把前端塞进二进制",{"type":8,"value":6551,"toc":7496},[6552,6554,6561,6564,6567,6578,6581,6585,6918,6922,7030,7034,7168,7171,7175,7184,7201,7212,7231,7245,7251,7259,7358,7364,7368,7375,7384,7387,7423,7426,7431,7470,7472,7478,7490,7493],[11,6553,2234],{"id":2234},[16,6555,6556,6557,6560],{},"一个 Gin + Vue 3 的全栈项目，之前的发布方式是：一个 Go 二进制 + 一个 ",[197,6558,6559],{},"web\u002F"," 前端静态目录，必须放一起才能运行。",[16,6562,6563],{},"为了让部署更丝滑——一个 exe 拖哪都能跑——决定把前端构建产物嵌入到 Go 二进制里。",[11,6565,6566],{"id":6566},"核心思路",[16,6568,6569,6570,6573,6574,6577],{},"利用 Go 1.16 引入的 ",[197,6571,6572],{},"\u002F\u002Fgo:embed"," 指令，在编译时将前端 ",[197,6575,6576],{},"dist\u002F"," 目录嵌入二进制，运行时通过嵌入的文件系统提供 SPA 服务。",[11,6579,6580],{"id":6580},"逐步实现",[717,6582,6584],{"id":6583},"_1-创建嵌入模块","1. 创建嵌入模块",[190,6586,6588],{"className":3791,"code":6587,"language":608,"meta":195,"style":195},"\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",[197,6589,6590,6595,6603,6607,6613,6623,6632,6641,6650,6659,6668,6672,6681,6685,6689,6694,6709,6713,6730,6751,6755,6775,6795,6808,6818,6822,6826,6841,6853,6858,6877,6881,6885,6908,6913],{"__ignoreMap":195},[200,6591,6592],{"class":202,"line":203},[200,6593,6594],{"class":206},"\u002F\u002F service\u002Fweb\u002Fembed.go\n",[200,6596,6597,6600],{"class":202,"line":210},[200,6598,6599],{"class":213},"package",[200,6601,6602],{"class":290}," web\n",[200,6604,6605],{"class":202,"line":231},[200,6606,532],{"emptyLinePlaceholder":393},[200,6608,6609,6611],{"class":202,"line":244},[200,6610,3799],{"class":213},[200,6612,5197],{"class":227},[200,6614,6615,6618,6621],{"class":202,"line":255},[200,6616,6617],{"class":237},"    \"",[200,6619,6620],{"class":290},"embed",[200,6622,715],{"class":237},[200,6624,6625,6627,6630],{"class":202,"line":266},[200,6626,6617],{"class":237},[200,6628,6629],{"class":290},"io\u002Ffs",[200,6631,715],{"class":237},[200,6633,6634,6636,6639],{"class":202,"line":272},[200,6635,6617],{"class":237},[200,6637,6638],{"class":290},"mime",[200,6640,715],{"class":237},[200,6642,6643,6645,6648],{"class":202,"line":599},[200,6644,6617],{"class":237},[200,6646,6647],{"class":290},"net\u002Fhttp",[200,6649,715],{"class":237},[200,6651,6652,6654,6657],{"class":202,"line":605},[200,6653,6617],{"class":237},[200,6655,6656],{"class":290},"path\u002Ffilepath",[200,6658,715],{"class":237},[200,6660,6661,6663,6666],{"class":202,"line":619},[200,6662,6617],{"class":237},[200,6664,6665],{"class":290},"strings",[200,6667,715],{"class":237},[200,6669,6670],{"class":202,"line":642},[200,6671,532],{"emptyLinePlaceholder":393},[200,6673,6674,6676,6679],{"class":202,"line":651},[200,6675,6617],{"class":237},[200,6677,6678],{"class":290},"github.com\u002Fgin-gonic\u002Fgin",[200,6680,715],{"class":237},[200,6682,6683],{"class":202,"line":656},[200,6684,2677],{"class":227},[200,6686,6687],{"class":202,"line":662},[200,6688,532],{"emptyLinePlaceholder":393},[200,6690,6691],{"class":202,"line":673},[200,6692,6693],{"class":206},"\u002F\u002Fgo:embed dist\n",[200,6695,6696,6699,6702,6704,6706],{"class":202,"line":684},[200,6697,6698],{"class":213},"var",[200,6700,6701],{"class":227}," embedFS ",[200,6703,6620],{"class":290},[200,6705,2489],{"class":227},[200,6707,6708],{"class":290},"FS\n",[200,6710,6711],{"class":202,"line":689},[200,6712,532],{"emptyLinePlaceholder":393},[200,6714,6715,6717,6720,6722,6724,6726,6728],{"class":202,"line":698},[200,6716,3992],{"class":213},[200,6718,6719],{"class":290}," SPAHandler",[200,6721,5240],{"class":227},[200,6723,4620],{"class":290},[200,6725,2489],{"class":227},[200,6727,4838],{"class":290},[200,6729,228],{"class":227},[200,6731,6732,6735,6737,6740,6743,6746,6749],{"class":202,"line":703},[200,6733,6734],{"class":227},"    sub, _ ",[200,6736,3818],{"class":213},[200,6738,6739],{"class":227}," fs.",[200,6741,6742],{"class":290},"Sub",[200,6744,6745],{"class":227},"(embedFS, ",[200,6747,6748],{"class":237},"\"dist\"",[200,6750,2677],{"class":227},[200,6752,6753],{"class":202,"line":912},[200,6754,532],{"emptyLinePlaceholder":393},[200,6756,6757,6759,6761,6763,6765,6767,6769,6771,6773],{"class":202,"line":920},[200,6758,4049],{"class":213},[200,6760,4847],{"class":213},[200,6762,2367],{"class":227},[200,6764,4614],{"class":2370},[200,6766,4617],{"class":213},[200,6768,4620],{"class":290},[200,6770,2489],{"class":227},[200,6772,4005],{"class":290},[200,6774,3619],{"class":227},[200,6776,6777,6780,6782,6784,6787,6790,6793],{"class":202,"line":925},[200,6778,6779],{"class":227},"        p ",[200,6781,3818],{"class":213},[200,6783,4954],{"class":227},[200,6785,6786],{"class":290},"TrimPrefix",[200,6788,6789],{"class":227},"(c.Request.URL.Path, ",[200,6791,6792],{"class":237},"\"\u002F\"",[200,6794,2677],{"class":227},[200,6796,6797,6799,6802,6804,6806],{"class":202,"line":1556},[200,6798,4886],{"class":213},[200,6800,6801],{"class":227}," p ",[200,6803,4892],{"class":213},[200,6805,4895],{"class":237},[200,6807,228],{"class":227},[200,6809,6810,6813,6815],{"class":202,"line":1570},[200,6811,6812],{"class":227},"            p ",[200,6814,575],{"class":213},[200,6816,6817],{"class":237}," \"index.html\"\n",[200,6819,6820],{"class":202,"line":1577},[200,6821,4940],{"class":227},[200,6823,6824],{"class":202,"line":1582},[200,6825,532],{"emptyLinePlaceholder":393},[200,6827,6828,6831,6833,6835,6838],{"class":202,"line":1588},[200,6829,6830],{"class":227},"        data, err ",[200,6832,3818],{"class":213},[200,6834,6739],{"class":227},[200,6836,6837],{"class":290},"ReadFile",[200,6839,6840],{"class":227},"(sub, p)\n",[200,6842,6843,6845,6847,6849,6851],{"class":202,"line":1596},[200,6844,4886],{"class":213},[200,6846,4232],{"class":227},[200,6848,4235],{"class":213},[200,6850,4238],{"class":220},[200,6852,228],{"class":227},[200,6854,6855],{"class":202,"line":1604},[200,6856,6857],{"class":206},"            \u002F\u002F SPA fallback：文件不存在则返回 index.html\n",[200,6859,6860,6863,6865,6867,6869,6872,6875],{"class":202,"line":1609},[200,6861,6862],{"class":227},"            data, _ ",[200,6864,575],{"class":213},[200,6866,6739],{"class":227},[200,6868,6837],{"class":290},[200,6870,6871],{"class":227},"(sub, ",[200,6873,6874],{"class":237},"\"index.html\"",[200,6876,2677],{"class":227},[200,6878,6879],{"class":202,"line":1615},[200,6880,4940],{"class":227},[200,6882,6883],{"class":202,"line":1624},[200,6884,532],{"emptyLinePlaceholder":393},[200,6886,6888,6890,6893,6896,6899,6902,6905],{"class":202,"line":6887},33,[200,6889,4631],{"class":227},[200,6891,6892],{"class":290},"Data",[200,6894,6895],{"class":227},"(http.StatusOK, mime.",[200,6897,6898],{"class":290},"TypeByExtension",[200,6900,6901],{"class":227},"(filepath.",[200,6903,6904],{"class":290},"Ext",[200,6906,6907],{"class":227},"(p)), data)\n",[200,6909,6911],{"class":202,"line":6910},34,[200,6912,2327],{"class":227},[200,6914,6916],{"class":202,"line":6915},35,[200,6917,275],{"class":227},[717,6919,6921],{"id":6920},"_2-改造路由","2. 改造路由",[190,6923,6925],{"className":3791,"code":6924,"language":608,"meta":195,"style":195},"\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",[197,6926,6927,6932,6943,6947,6952,6967,6981,6994,7007,7020],{"__ignoreMap":195},[200,6928,6929],{"class":202,"line":203},[200,6930,6931],{"class":206},"\u002F\u002F router.go\n",[200,6933,6934,6936,6938,6941],{"class":202,"line":210},[200,6935,3799],{"class":213},[200,6937,509],{"class":237},[200,6939,6940],{"class":290},"myproject\u002Fweb",[200,6942,715],{"class":237},[200,6944,6945],{"class":202,"line":231},[200,6946,532],{"emptyLinePlaceholder":393},[200,6948,6949],{"class":202,"line":244},[200,6950,6951],{"class":206},"\u002F\u002F 替换原来的磁盘静态文件服务\n",[200,6953,6954,6957,6959,6962,6965],{"class":202,"line":255},[200,6955,6956],{"class":227},"webHandler ",[200,6958,3818],{"class":213},[200,6960,6961],{"class":227}," web.",[200,6963,6964],{"class":290},"SPAHandler",[200,6966,2562],{"class":227},[200,6968,6969,6972,6974,6976,6978],{"class":202,"line":266},[200,6970,6971],{"class":227},"router.",[200,6973,4600],{"class":290},[200,6975,2367],{"class":227},[200,6977,6792],{"class":237},[200,6979,6980],{"class":227},", webHandler)\n",[200,6982,6983,6985,6987,6989,6992],{"class":202,"line":272},[200,6984,6971],{"class":227},[200,6986,4600],{"class":290},[200,6988,2367],{"class":227},[200,6990,6991],{"class":237},"\"\u002Fassets\u002F*filepath\"",[200,6993,6980],{"class":227},[200,6995,6996,6998,7000,7002,7005],{"class":202,"line":599},[200,6997,6971],{"class":227},[200,6999,4600],{"class":290},[200,7001,2367],{"class":227},[200,7003,7004],{"class":237},"\"\u002Ffavicon.ico\"",[200,7006,6980],{"class":227},[200,7008,7009,7011,7013,7015,7018],{"class":202,"line":605},[200,7010,6971],{"class":227},[200,7012,4600],{"class":290},[200,7014,2367],{"class":227},[200,7016,7017],{"class":237},"\"\u002Ffavicon.svg\"",[200,7019,6980],{"class":227},[200,7021,7022,7024,7027],{"class":202,"line":619},[200,7023,6971],{"class":227},[200,7025,7026],{"class":290},"NoRoute",[200,7028,7029],{"class":227},"(webHandler)\n",[717,7031,7033],{"id":7032},"_3-一键构建脚本","3. 一键构建脚本",[190,7035,7037],{"className":356,"code":7036,"language":358,"meta":195,"style":195},"#!\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",[197,7038,7039,7044,7049,7053,7061,7065,7070,7080,7090,7094,7099,7110,7123,7127,7132,7140,7153,7163],{"__ignoreMap":195},[200,7040,7041],{"class":202,"line":203},[200,7042,7043],{"class":206},"#!\u002Fbin\u002Fbash\n",[200,7045,7046],{"class":202,"line":210},[200,7047,7048],{"class":206},"# build-standalone.sh\n",[200,7050,7051],{"class":202,"line":231},[200,7052,532],{"emptyLinePlaceholder":393},[200,7054,7055,7058],{"class":202,"line":244},[200,7056,7057],{"class":220},"set",[200,7059,7060],{"class":220}," -e\n",[200,7062,7063],{"class":202,"line":255},[200,7064,532],{"emptyLinePlaceholder":393},[200,7066,7067],{"class":202,"line":266},[200,7068,7069],{"class":206},"# 1. 构建前端\n",[200,7071,7072,7075,7077],{"class":202,"line":272},[200,7073,7074],{"class":290},"pnpm",[200,7076,553],{"class":237},[200,7078,7079],{"class":220}," --no-frozen-lockfile\n",[200,7081,7082,7084,7087],{"class":202,"line":599},[200,7083,365],{"class":290},[200,7085,7086],{"class":237}," vite",[200,7088,7089],{"class":237}," build\n",[200,7091,7092],{"class":202,"line":605},[200,7093,532],{"emptyLinePlaceholder":393},[200,7095,7096],{"class":202,"line":619},[200,7097,7098],{"class":206},"# 2. 复制到嵌入目录\n",[200,7100,7101,7104,7107],{"class":202,"line":642},[200,7102,7103],{"class":290},"rm",[200,7105,7106],{"class":220}," -rf",[200,7108,7109],{"class":237}," service\u002Fweb\u002Fdist\n",[200,7111,7112,7115,7118,7121],{"class":202,"line":651},[200,7113,7114],{"class":290},"cp",[200,7116,7117],{"class":220}," -r",[200,7119,7120],{"class":237}," dist",[200,7122,7109],{"class":237},[200,7124,7125],{"class":202,"line":656},[200,7126,532],{"emptyLinePlaceholder":393},[200,7128,7129],{"class":202,"line":662},[200,7130,7131],{"class":206},"# 3. 编译 Go 二进制\n",[200,7133,7134,7137],{"class":202,"line":673},[200,7135,7136],{"class":220},"cd",[200,7138,7139],{"class":237}," service\n",[200,7141,7142,7144,7146,7148,7151],{"class":202,"line":684},[200,7143,608],{"class":290},[200,7145,624],{"class":237},[200,7147,633],{"class":220},[200,7149,7150],{"class":237}," myapp.exe",[200,7152,4718],{"class":220},[200,7154,7155,7158,7161],{"class":202,"line":689},[200,7156,7157],{"class":220},"    -ldflags=",[200,7159,7160],{"class":237},"\"-X myproject\u002Fglobal.RUNCODE=release\"",[200,7162,4718],{"class":220},[200,7164,7165],{"class":202,"line":698},[200,7166,7167],{"class":237},"    main.go\n",[11,7169,7170],{"id":7170},"踩坑记录",[717,7172,7174],{"id":7173},"坑一路由不匹配静态资源-404","坑一：路由不匹配，静态资源 404",[16,7176,7177,7178,7180,7181,7183],{},"首次实现时用了 Gin 的 ",[197,7179,7026],{}," 兜底，但部分静态资源返回 404。排查发现 ",[197,7182,7026],{}," 在某些路由未显式注册时不会按预期触发。",[16,7185,7186,7189,7190,7193,7194,7197,7198,7200],{},[20,7187,7188],{},"解决","：显式注册 ",[197,7191,7192],{},"\u002Fassets\u002F*filepath","、",[197,7195,7196],{},"\u002Ffavicon.ico"," 等路由，",[197,7199,7026],{}," 仅作为兜底。",[717,7202,7204,7205,7207,7208,7211],{"id":7203},"坑二goembed-排除-_-开头文件","坑二：",[197,7206,6572],{}," 排除 ",[197,7209,7210],{},"_"," 开头文件",[16,7213,7214,7215,7218,7219,7221,7222,2258],{},"这是最坑的。Vite 构建产出了一个 ",[197,7216,7217],{},"_plugin-vue_export-helper-xxx.js","，Go 的 ",[197,7220,6572],{}," 当模式为目录时，",[20,7223,7224,7225,7227,7228,7230],{},"硬编码排除了 ",[197,7226,7210],{}," 和 ",[197,7229,2489],{}," 开头的文件",[190,7232,7234],{"className":3791,"code":7233,"language":608,"meta":195,"style":195},"\u002F\u002F 这行会跳过 dist\u002Fassets\u002F_plugin-xxx.js\n\u002F\u002Fgo:embed dist\n",[197,7235,7236,7241],{"__ignoreMap":195},[200,7237,7238],{"class":202,"line":203},[200,7239,7240],{"class":206},"\u002F\u002F 这行会跳过 dist\u002Fassets\u002F_plugin-xxx.js\n",[200,7242,7243],{"class":202,"line":210},[200,7244,6693],{"class":206},[16,7246,7247,7248,2258],{},"错误表现：JS 文件返回的是 HTML 内容（SPA fallback），浏览器报 ",[197,7249,7250],{},"Unexpected token '\u003C'",[16,7252,7253,7255,7256,7258],{},[20,7254,7188],{},"：改 Vite 配置，去除了 ",[197,7257,7210],{}," 前缀：",[190,7260,7264],{"className":7261,"code":7262,"language":7263,"meta":195,"style":195},"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",[197,7265,7266,7271,7278,7285,7292,7300,7338,7343,7348,7353],{"__ignoreMap":195},[200,7267,7268],{"class":202,"line":203},[200,7269,7270],{"class":206},"\u002F\u002F vite.config.ts\n",[200,7272,7273,7276],{"class":202,"line":210},[200,7274,7275],{"class":290},"build",[200,7277,2286],{"class":227},[200,7279,7280,7283],{"class":202,"line":231},[200,7281,7282],{"class":290},"  rollupOptions",[200,7284,2286],{"class":227},[200,7286,7287,7290],{"class":202,"line":244},[200,7288,7289],{"class":290},"    output",[200,7291,2286],{"class":227},[200,7293,7294,7297],{"class":202,"line":255},[200,7295,7296],{"class":290},"      chunkFileNames",[200,7298,7299],{"class":227},"(chunkInfo) {\n",[200,7301,7302,7304,7307,7310,7312,7315,7317,7319,7321,7323,7325,7327,7329,7331,7333,7335],{"class":202,"line":266},[200,7303,4245],{"class":213},[200,7305,7306],{"class":237}," `assets\u002F${",[200,7308,7309],{"class":227},"chunkInfo",[200,7311,2489],{"class":237},[200,7313,7314],{"class":227},"name",[200,7316,2489],{"class":237},[200,7318,2867],{"class":290},[200,7320,2367],{"class":237},[200,7322,2486],{"class":237},[200,7324,2473],{"class":213},[200,7326,7210],{"class":2476},[200,7328,2486],{"class":237},[200,7330,337],{"class":237},[200,7332,2884],{"class":237},[200,7334,3188],{"class":237},[200,7336,7337],{"class":237},"}-[hash].js`\n",[200,7339,7340],{"class":202,"line":272},[200,7341,7342],{"class":227},"      },\n",[200,7344,7345],{"class":202,"line":599},[200,7346,7347],{"class":227},"    },\n",[200,7349,7350],{"class":202,"line":605},[200,7351,7352],{"class":227},"  },\n",[200,7354,7355],{"class":202,"line":619},[200,7356,7357],{"class":227},"},\n",[16,7359,7360,7361,7363],{},"这样就避免产生 ",[197,7362,7210],{}," 前缀文件名的同时，保持构建产物的一致性和引用关系的正确性。",[717,7365,7367],{"id":7366},"坑三构建缓存导致-embed-内容未更新","坑三：构建缓存导致 embed 内容未更新",[16,7369,7370,7371,7374],{},"多次 ",[197,7372,7373],{},"go build"," 后，Go 的构建缓存可能使用了旧版本的 embed 内容，导致实际运行的文件不是最新的。",[16,7376,7377,7379,7380,7383],{},[20,7378,7188],{},"：使用 ",[197,7381,7382],{},"go build -a"," 强制重新编译所有包。",[11,7385,7386],{"id":7386},"最终效果",[190,7388,7390],{"className":356,"code":7389,"language":358,"meta":195,"style":195},"$ bash build-standalone.sh\n=== 构建成功 ===\n输出: service\u002Fmyapp.exe  41M\n",[197,7391,7392,7402,7412],{"__ignoreMap":195},[200,7393,7394,7396,7399],{"class":202,"line":203},[200,7395,2483],{"class":290},[200,7397,7398],{"class":237}," bash",[200,7400,7401],{"class":237}," build-standalone.sh\n",[200,7403,7404,7406,7409],{"class":202,"line":210},[200,7405,2461],{"class":237},[200,7407,7408],{"class":237}," 构建成功",[200,7410,7411],{"class":237}," ===\n",[200,7413,7414,7417,7420],{"class":202,"line":231},[200,7415,7416],{"class":290},"输出:",[200,7418,7419],{"class":237}," service\u002Fmyapp.exe",[200,7421,7422],{"class":237},"  41M\n",[16,7424,7425],{},"一个 41MB 的 exe，内置前端页面、API 后端、SQLite 数据库（运行时初始化），拖到哪都能跑。",[16,7427,7428],{},[20,7429,7430],{},"部署对比：",[34,7432,7433,7446],{},[37,7434,7435],{},[40,7436,7437,7440,7443],{},[43,7438,7439],{},"方式",[43,7441,7442],{},"文件数",[43,7444,7445],{},"操作",[50,7447,7448,7459],{},[40,7449,7450,7453,7456],{},[55,7451,7452],{},"旧方案",[55,7454,7455],{},"1 exe + 1 web 目录",[55,7457,7458],{},"解压→保持目录结构",[40,7460,7461,7464,7467],{},[55,7462,7463],{},"新方案",[55,7465,7466],{},"1 个 exe",[55,7468,7469],{},"直接扔到服务器运行",[11,7471,1645],{"id":1645},[16,7473,7474,7475,7477],{},"Go 的 ",[197,7476,6572],{}," 是一个强大的工具，但有两个隐藏行为需要注意：",[721,7479,7480,7487],{},[123,7481,7482,7483,7227,7485,7230],{},"嵌入目录时自动排除 ",[197,7484,7210],{},[197,7486,2489],{},[123,7488,7489],{},"构建缓存可能导致 embed 不是最新的",[16,7491,7492],{},"对于前端+后端合体的场景，这是性价比最高的方案——不需要额外的打包工具，纯标准库搞定。",[437,7494,7495],{},"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":195,"searchDepth":210,"depth":231,"links":7497},[7498,7499,7500,7505,7511,7512],{"id":2234,"depth":210,"text":2234},{"id":6566,"depth":210,"text":6566},{"id":6580,"depth":210,"text":6580,"children":7501},[7502,7503,7504],{"id":6583,"depth":231,"text":6584},{"id":6920,"depth":231,"text":6921},{"id":7032,"depth":231,"text":7033},{"id":7170,"depth":210,"text":7170,"children":7506},[7507,7508,7510],{"id":7173,"depth":231,"text":7174},{"id":7203,"depth":231,"text":7509},"坑二：\u002F\u002Fgo:embed 排除 _ 开头文件",{"id":7366,"depth":231,"text":7367},{"id":7386,"depth":210,"text":7386},{"id":1645,"depth":210,"text":1645},"记录将 Vue 3 前端嵌入 Go 二进制实现单 exe 发布的完整流程，以及遇到的坑和解决方案。","\u002Fposts\u002Fgo-embed-frontend\u002Fimg\u002Fcover.jpg",{},"\u002Fposts\u002Fgo-embed-frontend","2026-06-06T16:00:00",{"title":6549,"description":7513},"posts\u002Fgo-embed-frontend\u002Findex",[1282,7521,7522,7523],"Vue","编译","埋坑","2026-06-06","SIQNL24wVAFb18wIGWSB3lNVZUQ9B76jWgQ55TASTYk",{"id":7527,"title":7528,"author":6,"body":7529,"description":8925,"difficulty":450,"draft":451,"extension":452,"image":8926,"meta":8927,"navigation":393,"path":8928,"pinned":451,"published":8929,"seo":8930,"series":6,"seriesOrder":6,"stem":8931,"tags":8932,"updated":8929,"__hash__":8934},"posts\u002Fposts\u002Fjira-cli-intro\u002Findex.md","jira_cli — 把企业 Jira 装进命令行，AI Agent 直接调用",{"type":8,"value":7530,"toc":8904},[7531,7535,7550,7617,7620,7623,7700,7702,7735,7742,7745,7749,7861,7865,7996,8000,8068,8071,8134,8137,8139,8143,8146,8166,8169,8184,8187,8191,8197,8335,8346,8377,8381,8384,8481,8485,8488,8606,8610,8613,8677,8680,8684,8687,8755,8758,8764,8767,8801,8808,8811,8871,8873,8876,8893,8901],[11,7532,7534],{"id":7533},"jira_cli-是什么","jira_cli 是什么",[16,7536,7537,7542,7543,7227,7546,7549],{},[421,7538,7541],{"href":7539,"rel":7540},"https:\u002F\u002Fgithub.com\u002Fyourname\u002Fjira_cli",[425],"jira_cli"," 是一个面向企业 Jira（Server \u002F Data Center）的本地命令行工具，Go 语言编写，MIT 协议开源。它同时提供 ",[20,7544,7545],{},"Cobra CLI",[20,7547,7548],{},"MCP stdio server"," 两种入口，既能当日常命令行用，又能直接接入 AI 编程工具。",[190,7551,7553],{"className":356,"code":7552,"language":358,"meta":195,"style":195},"# 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",[197,7554,7555,7560,7573,7584,7599,7603,7608],{"__ignoreMap":195},[200,7556,7557],{"class":202,"line":203},[200,7558,7559],{"class":206},"# CLI 日常使用\n",[200,7561,7562,7564,7567,7570],{"class":202,"line":210},[200,7563,7541],{"class":290},[200,7565,7566],{"class":237}," issue",[200,7568,7569],{"class":237}," search",[200,7571,7572],{"class":237}," \"project = PROJ ORDER BY updated DESC\"\n",[200,7574,7575,7577,7579,7581],{"class":202,"line":231},[200,7576,7541],{"class":290},[200,7578,7566],{"class":237},[200,7580,3772],{"class":237},[200,7582,7583],{"class":237}," PROJ-123\n",[200,7585,7586,7588,7590,7593,7596],{"class":202,"line":244},[200,7587,7541],{"class":290},[200,7589,7566],{"class":237},[200,7591,7592],{"class":237}," comment",[200,7594,7595],{"class":237}," PROJ-123",[200,7597,7598],{"class":237}," \"处理完成，请测试验证\"\n",[200,7600,7601],{"class":202,"line":255},[200,7602,532],{"emptyLinePlaceholder":393},[200,7604,7605],{"class":202,"line":266},[200,7606,7607],{"class":206},"# MCP 模式 — 给 AI 装上操作 Jira 的手\n",[200,7609,7610,7612,7614],{"class":202,"line":272},[200,7611,7541],{"class":290},[200,7613,4013],{"class":237},[200,7615,7616],{"class":220}," --user=work\n",[11,7618,7619],{"id":7619},"为什么需要它",[16,7621,7622],{},"大多数 Jira 命令行工具只解决\"开发者手动操作 Jira\"这一个场景。但 2026 年 AI 编程工具已经普及，你的 AI Agent（Claude Code、Codex、Cursor 等）也需要读写 Jira。痛点就来了：",[34,7624,7625,7635],{},[37,7626,7627],{},[40,7628,7629,7632],{},[43,7630,7631],{},"痛点",[43,7633,7634],{},"jira_cli 怎么解",[50,7636,7637,7645,7653,7665,7677,7688],{},[40,7638,7639,7642],{},[55,7640,7641],{},"企业 Jira 在内网，没有公网 API",[55,7643,7644],{},"本地 stdio MCP server，不经过外网",[40,7646,7647,7650],{},[55,7648,7649],{},"AI 可能误删、误写 Jira 数据",[55,7651,7652],{},"账号级只读策略，AI 只能查不能改",[40,7654,7655,7658],{},[55,7656,7657],{},"团队多人多 Jira 实例",[55,7659,7660,7661,7664],{},"多 Profile 管理，",[197,7662,7663],{},"--user=work"," 切换",[40,7666,7667,7670],{},[55,7668,7669],{},"Windows 终端体验差",[55,7671,7672,7673,7676],{},"原生 ",[197,7674,7675],{},".exe"," 构建 + 一键安装脚本",[40,7678,7679,7682],{},[55,7680,7681],{},"AI 生成 JQL 困难",[55,7683,7684,7685],{},"字段元数据发现：",[197,7686,7687],{},"field list --query xxx",[40,7689,7690,7693],{},[55,7691,7692],{},"MCP 直接拿原始 JSON 太冗长",[55,7694,7695,7696,7699],{},"紧凑视图 + ",[197,7697,7698],{},"--view summary"," 减少 token 消耗",[11,7701,5912],{"id":5912},[190,7703,7705],{"className":356,"code":7704,"language":358,"meta":195,"style":195},"# 从源码安装（要求 Go 1.26.3+）\ngo install github.com\u002Fyourname\u002Fjira_cli@latest\n\n# 或下载预编译二进制\n# https:\u002F\u002Fgithub.com\u002Fyourname\u002Fjira_cli\u002Freleases\n",[197,7706,7707,7712,7721,7725,7730],{"__ignoreMap":195},[200,7708,7709],{"class":202,"line":203},[200,7710,7711],{"class":206},"# 从源码安装（要求 Go 1.26.3+）\n",[200,7713,7714,7716,7718],{"class":202,"line":210},[200,7715,608],{"class":290},[200,7717,553],{"class":237},[200,7719,7720],{"class":237}," github.com\u002Fyourname\u002Fjira_cli@latest\n",[200,7722,7723],{"class":202,"line":231},[200,7724,532],{"emptyLinePlaceholder":393},[200,7726,7727],{"class":202,"line":244},[200,7728,7729],{"class":206},"# 或下载预编译二进制\n",[200,7731,7732],{"class":202,"line":255},[200,7733,7734],{"class":206},"# https:\u002F\u002Fgithub.com\u002Fyourname\u002Fjira_cli\u002Freleases\n",[16,7736,7737,7738,7741],{},"Windows 用户下载 zip 包解压后双击 ",[197,7739,7740],{},"install.bat"," 即可全局安装，自动加入 PATH。",[11,7743,7744],{"id":7744},"快速上手",[717,7746,7748],{"id":7747},"_1-配置认证","1. 配置认证",[190,7750,7752],{"className":356,"code":7751,"language":358,"meta":195,"style":195},"# 设置 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",[197,7753,7754,7759,7775,7779,7784,7804,7808,7813,7842,7846,7851],{"__ignoreMap":195},[200,7755,7756],{"class":202,"line":203},[200,7757,7758],{"class":206},"# 设置 Jira 地址\n",[200,7760,7761,7763,7766,7769,7772],{"class":202,"line":210},[200,7762,7541],{"class":290},[200,7764,7765],{"class":237}," set",[200,7767,7768],{"class":237}," base-url",[200,7770,7771],{"class":237}," https:\u002F\u002Fjira.your-company.com",[200,7773,7774],{"class":220}," --global\n",[200,7776,7777],{"class":202,"line":231},[200,7778,532],{"emptyLinePlaceholder":393},[200,7780,7781],{"class":202,"line":244},[200,7782,7783],{"class":206},"# Token 认证（推荐）\n",[200,7785,7786,7788,7790,7793,7795,7798,7800,7802],{"class":202,"line":255},[200,7787,7541],{"class":290},[200,7789,7765],{"class":237},[200,7791,7792],{"class":237}," token",[200,7794,1014],{"class":213},[200,7796,7797],{"class":237},"your-toke",[200,7799,1418],{"class":227},[200,7801,1023],{"class":213},[200,7803,7774],{"class":220},[200,7805,7806],{"class":202,"line":266},[200,7807,532],{"emptyLinePlaceholder":393},[200,7809,7810],{"class":202,"line":272},[200,7811,7812],{"class":206},"# 或用户名密码认证\n",[200,7814,7815,7817,7819,7821,7823,7826,7828,7830,7832,7835,7838,7840],{"class":202,"line":599},[200,7816,7541],{"class":290},[200,7818,7765],{"class":237},[200,7820,5986],{"class":237},[200,7822,1014],{"class":213},[200,7824,7825],{"class":237},"usernam",[200,7827,1404],{"class":227},[200,7829,1023],{"class":213},[200,7831,1014],{"class":213},[200,7833,7834],{"class":237},"passwor",[200,7836,7837],{"class":227},"d",[200,7839,1023],{"class":213},[200,7841,7774],{"class":220},[200,7843,7844],{"class":202,"line":605},[200,7845,532],{"emptyLinePlaceholder":393},[200,7847,7848],{"class":202,"line":619},[200,7849,7850],{"class":206},"# 验证一下\n",[200,7852,7853,7855,7858],{"class":202,"line":642},[200,7854,7541],{"class":290},[200,7856,7857],{"class":237}," user",[200,7859,7860],{"class":237}," me\n",[717,7862,7864],{"id":7863},"_2-日常使用","2. 日常使用",[190,7866,7868],{"className":356,"code":7867,"language":358,"meta":195,"style":195},"# 查自己今天的任务\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",[197,7869,7870,7875,7886,7890,7895,7905,7909,7914,7928,7945,7949,7954,7967,7971,7976],{"__ignoreMap":195},[200,7871,7872],{"class":202,"line":203},[200,7873,7874],{"class":206},"# 查自己今天的任务\n",[200,7876,7877,7879,7881,7883],{"class":202,"line":210},[200,7878,7541],{"class":290},[200,7880,7566],{"class":237},[200,7882,7569],{"class":237},[200,7884,7885],{"class":237}," \"assignee = currentUser() ORDER BY updated DESC\"\n",[200,7887,7888],{"class":202,"line":231},[200,7889,532],{"emptyLinePlaceholder":393},[200,7891,7892],{"class":202,"line":244},[200,7893,7894],{"class":206},"# 看某个任务详情\n",[200,7896,7897,7899,7901,7903],{"class":202,"line":255},[200,7898,7541],{"class":290},[200,7900,7566],{"class":237},[200,7902,3772],{"class":237},[200,7904,7583],{"class":237},[200,7906,7907],{"class":202,"line":266},[200,7908,532],{"emptyLinePlaceholder":393},[200,7910,7911],{"class":202,"line":272},[200,7912,7913],{"class":206},"# 流转状态\n",[200,7915,7916,7918,7920,7923,7925],{"class":202,"line":599},[200,7917,7541],{"class":290},[200,7919,7566],{"class":237},[200,7921,7922],{"class":237}," transitions",[200,7924,7595],{"class":237},[200,7926,7927],{"class":206},"                    # 可以转哪些\n",[200,7929,7930,7932,7934,7937,7939,7942],{"class":202,"line":605},[200,7931,7541],{"class":290},[200,7933,7566],{"class":237},[200,7935,7936],{"class":237}," do-transition",[200,7938,7595],{"class":237},[200,7940,7941],{"class":220}," 21",[200,7943,7944],{"class":206},"               # 执行流转\n",[200,7946,7947],{"class":202,"line":619},[200,7948,532],{"emptyLinePlaceholder":393},[200,7950,7951],{"class":202,"line":642},[200,7952,7953],{"class":206},"# 添加备注\n",[200,7955,7956,7958,7960,7962,7964],{"class":202,"line":651},[200,7957,7541],{"class":290},[200,7959,7566],{"class":237},[200,7961,7592],{"class":237},[200,7963,7595],{"class":237},[200,7965,7966],{"class":237}," \"已定位到问题，正在修复\"\n",[200,7968,7969],{"class":202,"line":656},[200,7970,532],{"emptyLinePlaceholder":393},[200,7972,7973],{"class":202,"line":662},[200,7974,7975],{"class":206},"# 创建任务\n",[200,7977,7978,7980,7982,7984,7987,7990,7993],{"class":202,"line":673},[200,7979,7541],{"class":290},[200,7981,7566],{"class":237},[200,7983,5872],{"class":237},[200,7985,7986],{"class":237}," PROJ",[200,7988,7989],{"class":237}," Task",[200,7991,7992],{"class":220}," --fields",[200,7994,7995],{"class":237}," @fields.json\n",[717,7997,7999],{"id":7998},"_3-配给-ai-使用","3. 配给 AI 使用",[190,8001,8003],{"className":356,"code":8002,"language":358,"meta":195,"style":195},"# 先配置一个只读账号给 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",[197,8004,8005,8010,8027,8050],{"__ignoreMap":195},[200,8006,8007],{"class":202,"line":203},[200,8008,8009],{"class":206},"# 先配置一个只读账号给 AI 用\n",[200,8011,8012,8014,8016,8018,8020,8022,8025],{"class":202,"line":210},[200,8013,7541],{"class":290},[200,8015,7765],{"class":237},[200,8017,7768],{"class":237},[200,8019,7771],{"class":237},[200,8021,1011],{"class":220},[200,8023,8024],{"class":237}," ai",[200,8026,7774],{"class":220},[200,8028,8029,8031,8033,8035,8037,8040,8042,8044,8046,8048],{"class":202,"line":231},[200,8030,7541],{"class":290},[200,8032,7765],{"class":237},[200,8034,7792],{"class":237},[200,8036,1014],{"class":213},[200,8038,8039],{"class":237},"toke",[200,8041,1418],{"class":227},[200,8043,1023],{"class":213},[200,8045,1011],{"class":220},[200,8047,8024],{"class":237},[200,8049,7774],{"class":220},[200,8051,8052,8054,8056,8059,8062,8064,8066],{"class":202,"line":244},[200,8053,7541],{"class":290},[200,8055,7765],{"class":237},[200,8057,8058],{"class":237}," readonly",[200,8060,8061],{"class":237}," on",[200,8063,1011],{"class":220},[200,8065,8024],{"class":237},[200,8067,7774],{"class":220},[16,8069,8070],{},"然后在 AI 工具的 MCP 配置里加上：",[190,8072,8074],{"className":5583,"code":8073,"language":5585,"meta":195,"style":195},"{\n  \"mcpServers\": {\n    \"jira\": {\n      \"command\": \"jira_cli\",\n      \"args\": [\"mcp\", \"--user=ai\"]\n    }\n  }\n}\n",[197,8075,8076,8080,8086,8093,8105,8122,8126,8130],{"__ignoreMap":195},[200,8077,8078],{"class":202,"line":203},[200,8079,5592],{"class":227},[200,8081,8082,8084],{"class":202,"line":210},[200,8083,5597],{"class":220},[200,8085,2286],{"class":227},[200,8087,8088,8091],{"class":202,"line":231},[200,8089,8090],{"class":220},"    \"jira\"",[200,8092,2286],{"class":227},[200,8094,8095,8098,8100,8103],{"class":202,"line":244},[200,8096,8097],{"class":220},"      \"command\"",[200,8099,300],{"class":227},[200,8101,8102],{"class":237},"\"jira_cli\"",[200,8104,241],{"class":227},[200,8106,8107,8110,8112,8115,8117,8120],{"class":202,"line":255},[200,8108,8109],{"class":220},"      \"args\"",[200,8111,331],{"class":227},[200,8113,8114],{"class":237},"\"mcp\"",[200,8116,337],{"class":227},[200,8118,8119],{"class":237},"\"--user=ai\"",[200,8121,343],{"class":227},[200,8123,8124],{"class":202,"line":266},[200,8125,2327],{"class":227},[200,8127,8128],{"class":202,"line":272},[200,8129,2332],{"class":227},[200,8131,8132],{"class":202,"line":599},[200,8133,275],{"class":227},[16,8135,8136],{},"这样 AI 就能查 Jira 但不能修改，既有了上下文，又不用担心误操作。",[11,8138,6394],{"id":6394},[717,8140,8142],{"id":8141},"_1-账号级只读策略","1. 账号级只读策略",[16,8144,8145],{},"这是 jira_cli 最实用的设计。给 AI Agent 配置只读账号：",[190,8147,8149],{"className":356,"code":8148,"language":358,"meta":195,"style":195},"jira_cli set readonly on --name ai\n",[197,8150,8151],{"__ignoreMap":195},[200,8152,8153,8155,8157,8159,8161,8163],{"class":202,"line":203},[200,8154,7541],{"class":290},[200,8156,7765],{"class":237},[200,8158,8058],{"class":237},[200,8160,8061],{"class":237},[200,8162,1011],{"class":220},[200,8164,8165],{"class":237}," ai\n",[16,8167,8168],{},"只读模式下：",[120,8170,8171,8174,8177],{},[123,8172,8173],{},"CLI 写命令被隐藏和拒绝",[123,8175,8176],{},"MCP 的写入工具不会注册",[123,8178,8179,8180,8183],{},"环境变量 ",[197,8181,8182],{},"JIRA_READONLY=true"," 也可以临时覆盖",[16,8185,8186],{},"AI 只能查 JQL、看任务、查字段、读评论，绝对不会误改数据。需要创建\u002F更新时，另配一个有写入权限的账号由开发者自己操作。",[717,8188,8190],{"id":8189},"_2-多-profile-切换","2. 多 Profile 切换",[16,8192,8193,8194,8196],{},"一个配置文件里管理多个 Jira 账号，",[197,8195,1369],{}," 切换：",[190,8198,8200],{"className":356,"code":8199,"language":358,"meta":195,"style":195},"# 工作账号\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",[197,8201,8202,8207,8225,8248,8252,8257,8275,8298,8302,8307,8321],{"__ignoreMap":195},[200,8203,8204],{"class":202,"line":203},[200,8205,8206],{"class":206},"# 工作账号\n",[200,8208,8209,8211,8213,8215,8218,8220,8223],{"class":202,"line":210},[200,8210,7541],{"class":290},[200,8212,7765],{"class":237},[200,8214,7768],{"class":237},[200,8216,8217],{"class":237}," https:\u002F\u002Fjira.company.com",[200,8219,1011],{"class":220},[200,8221,8222],{"class":237}," work",[200,8224,7774],{"class":220},[200,8226,8227,8229,8231,8233,8235,8238,8240,8242,8244,8246],{"class":202,"line":231},[200,8228,7541],{"class":290},[200,8230,7765],{"class":237},[200,8232,7792],{"class":237},[200,8234,1014],{"class":213},[200,8236,8237],{"class":237},"work-toke",[200,8239,1418],{"class":227},[200,8241,1023],{"class":213},[200,8243,1011],{"class":220},[200,8245,8222],{"class":237},[200,8247,7774],{"class":220},[200,8249,8250],{"class":202,"line":244},[200,8251,532],{"emptyLinePlaceholder":393},[200,8253,8254],{"class":202,"line":255},[200,8255,8256],{"class":206},"# 个人账号\n",[200,8258,8259,8261,8263,8265,8268,8270,8273],{"class":202,"line":266},[200,8260,7541],{"class":290},[200,8262,7765],{"class":237},[200,8264,7768],{"class":237},[200,8266,8267],{"class":237}," https:\u002F\u002Fjira.personal.com",[200,8269,1011],{"class":220},[200,8271,8272],{"class":237}," personal",[200,8274,7774],{"class":220},[200,8276,8277,8279,8281,8283,8285,8288,8290,8292,8294,8296],{"class":202,"line":272},[200,8278,7541],{"class":290},[200,8280,7765],{"class":237},[200,8282,7792],{"class":237},[200,8284,1014],{"class":213},[200,8286,8287],{"class":237},"personal-toke",[200,8289,1418],{"class":227},[200,8291,1023],{"class":213},[200,8293,1011],{"class":220},[200,8295,8272],{"class":237},[200,8297,7774],{"class":220},[200,8299,8300],{"class":202,"line":599},[200,8301,532],{"emptyLinePlaceholder":393},[200,8303,8304],{"class":202,"line":605},[200,8305,8306],{"class":206},"# 使用时切换\n",[200,8308,8309,8311,8314,8316,8318],{"class":202,"line":619},[200,8310,7541],{"class":290},[200,8312,8313],{"class":220}," --user=work",[200,8315,7566],{"class":237},[200,8317,7569],{"class":237},[200,8319,8320],{"class":237}," \"project = PROJ\"\n",[200,8322,8323,8325,8328,8330,8332],{"class":202,"line":642},[200,8324,7541],{"class":290},[200,8326,8327],{"class":220}," --user=personal",[200,8329,7566],{"class":237},[200,8331,7569],{"class":237},[200,8333,8334],{"class":237}," \"project = SIDE\"\n",[16,8336,8337,8338,8341,8342,8345],{},"同时支持",[20,8339,8340],{},"项目级配置","（",[197,8343,8344],{},"--local","）和工作目录绑定：",[190,8347,8349],{"className":356,"code":8348,"language":358,"meta":195,"style":195},"# 在项目目录下，配置只对当前项目生效\ncd ~\u002Fprojects\u002Fbackend\njira_cli set base-url https:\u002F\u002Fjira-backend.company.com --local\n",[197,8350,8351,8356,8363],{"__ignoreMap":195},[200,8352,8353],{"class":202,"line":203},[200,8354,8355],{"class":206},"# 在项目目录下，配置只对当前项目生效\n",[200,8357,8358,8360],{"class":202,"line":210},[200,8359,7136],{"class":220},[200,8361,8362],{"class":237}," ~\u002Fprojects\u002Fbackend\n",[200,8364,8365,8367,8369,8371,8374],{"class":202,"line":231},[200,8366,7541],{"class":290},[200,8368,7765],{"class":237},[200,8370,7768],{"class":237},[200,8372,8373],{"class":237}," https:\u002F\u002Fjira-backend.company.com",[200,8375,8376],{"class":220}," --local\n",[717,8378,8380],{"id":8379},"_3-对-ai-友好的输出","3. 对 AI 友好的输出",[16,8382,8383],{},"jira_cli 在输出设计上充分考虑了 AI Agent 的 token 消耗：",[190,8385,8387],{"className":356,"code":8386,"language":358,"meta":195,"style":195},"# 紧凑视图 — 去掉 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",[197,8388,8389,8394,8410,8414,8419,8436,8440,8445,8460,8464,8469],{"__ignoreMap":195},[200,8390,8391],{"class":202,"line":203},[200,8392,8393],{"class":206},"# 紧凑视图 — 去掉 self\u002Favatar\u002F缩略图等无用字段\n",[200,8395,8396,8398,8400,8402,8404,8407],{"class":202,"line":210},[200,8397,7541],{"class":290},[200,8399,7566],{"class":237},[200,8401,3772],{"class":237},[200,8403,7595],{"class":237},[200,8405,8406],{"class":220}," --view",[200,8408,8409],{"class":237}," summary\n",[200,8411,8412],{"class":202,"line":231},[200,8413,532],{"emptyLinePlaceholder":393},[200,8415,8416],{"class":202,"line":244},[200,8417,8418],{"class":206},"# 表格输出 — 一眼看到关键信息\n",[200,8420,8421,8423,8425,8427,8430,8433],{"class":202,"line":255},[200,8422,7541],{"class":290},[200,8424,7566],{"class":237},[200,8426,7569],{"class":237},[200,8428,8429],{"class":237}," \"project = PROJ\"",[200,8431,8432],{"class":220}," --output",[200,8434,8435],{"class":237}," table\n",[200,8437,8438],{"class":202,"line":266},[200,8439,532],{"emptyLinePlaceholder":393},[200,8441,8442],{"class":202,"line":272},[200,8443,8444],{"class":206},"# JSON 输出 — 程序解析，不转义 Unicode\n",[200,8446,8447,8449,8451,8454,8456,8458],{"class":202,"line":599},[200,8448,7541],{"class":290},[200,8450,8432],{"class":220},[200,8452,8453],{"class":237}," json",[200,8455,7566],{"class":237},[200,8457,3772],{"class":237},[200,8459,7583],{"class":237},[200,8461,8462],{"class":202,"line":605},[200,8463,532],{"emptyLinePlaceholder":393},[200,8465,8466],{"class":202,"line":619},[200,8467,8468],{"class":206},"# 自定义搜索列 — 只要需要的字段\n",[200,8470,8471,8473,8475,8478],{"class":202,"line":642},[200,8472,7541],{"class":290},[200,8474,7765],{"class":237},[200,8476,8477],{"class":237}," search-columns",[200,8479,8480],{"class":237}," key,summary,fixVersions\n",[717,8482,8484],{"id":8483},"_4-字段元数据发现","4. 字段元数据发现",[16,8486,8487],{},"Jira 的 customfield 让人头疼，jira_cli 提供了完整的字段发现能力：",[190,8489,8491],{"className":356,"code":8490,"language":358,"meta":195,"style":195},"# 列出所有字段及其类型\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",[197,8492,8493,8498,8507,8511,8516,8530,8534,8539,8550,8554,8559,8576,8580,8585],{"__ignoreMap":195},[200,8494,8495],{"class":202,"line":203},[200,8496,8497],{"class":206},"# 列出所有字段及其类型\n",[200,8499,8500,8502,8505],{"class":202,"line":210},[200,8501,7541],{"class":290},[200,8503,8504],{"class":237}," field",[200,8506,2421],{"class":237},[200,8508,8509],{"class":202,"line":231},[200,8510,532],{"emptyLinePlaceholder":393},[200,8512,8513],{"class":202,"line":244},[200,8514,8515],{"class":206},"# 搜索包含特定关键词的字段\n",[200,8517,8518,8520,8522,8524,8527],{"class":202,"line":255},[200,8519,7541],{"class":290},[200,8521,8504],{"class":237},[200,8523,5807],{"class":237},[200,8525,8526],{"class":220}," --query",[200,8528,8529],{"class":237}," \"版本\"\n",[200,8531,8532],{"class":202,"line":266},[200,8533,532],{"emptyLinePlaceholder":393},[200,8535,8536],{"class":202,"line":272},[200,8537,8538],{"class":206},"# 只看自定义字段\n",[200,8540,8541,8543,8545,8547],{"class":202,"line":599},[200,8542,7541],{"class":290},[200,8544,8504],{"class":237},[200,8546,5807],{"class":237},[200,8548,8549],{"class":220}," --custom-only\n",[200,8551,8552],{"class":202,"line":605},[200,8553,532],{"emptyLinePlaceholder":393},[200,8555,8556],{"class":202,"line":619},[200,8557,8558],{"class":206},"# 创建任务前，先看看需要哪些字段\n",[200,8560,8561,8563,8565,8568,8570,8573],{"class":202,"line":642},[200,8562,7541],{"class":290},[200,8564,7566],{"class":237},[200,8566,8567],{"class":237}," create-meta",[200,8569,7986],{"class":237},[200,8571,8572],{"class":220}," --issue-type",[200,8574,8575],{"class":237}," Task\n",[200,8577,8578],{"class":202,"line":651},[200,8579,532],{"emptyLinePlaceholder":393},[200,8581,8582],{"class":202,"line":656},[200,8583,8584],{"class":206},"# 分页查看可选值\n",[200,8586,8587,8589,8591,8594,8596,8598,8601,8603],{"class":202,"line":662},[200,8588,7541],{"class":290},[200,8590,7566],{"class":237},[200,8592,8593],{"class":237}," create-meta-values",[200,8595,7986],{"class":237},[200,8597,7989],{"class":237},[200,8599,8600],{"class":237}," priority",[200,8602,8526],{"class":220},[200,8604,8605],{"class":237}," High\n",[717,8607,8609],{"id":8608},"_5-mcp-写入控制","5. MCP 写入控制",[16,8611,8612],{},"除了全局只读，MCP 还有更细粒度的控制：",[34,8614,8615,8625],{},[37,8616,8617],{},[40,8618,8619,8622],{},[43,8620,8621],{},"环境变量",[43,8623,8624],{},"作用",[50,8626,8627,8637,8647,8657,8667],{},[40,8628,8629,8634],{},[55,8630,8631],{},[197,8632,8633],{},"JIRA_CREATE_OPEN=false",[55,8635,8636],{},"禁用创建\u002F更新工具",[40,8638,8639,8644],{},[55,8640,8641],{},[197,8642,8643],{},"JIRA_DISABLE_DISCLAIMER=true",[55,8645,8646],{},"去掉 AI 自动添加的备注声明",[40,8648,8649,8654],{},[55,8650,8651],{},[197,8652,8653],{},"JIRA_CREATE_SUMMARY_MAX",[55,8655,8656],{},"限制摘要长度",[40,8658,8659,8664],{},[55,8660,8661],{},[197,8662,8663],{},"JIRA_CREATE_DESCRIPTION_MAX",[55,8665,8666],{},"限制描述长度",[40,8668,8669,8674],{},[55,8670,8671],{},[197,8672,8673],{},"JIRA_CREATE_FIELDS_MAX_BYTES",[55,8675,8676],{},"限制创建字段的 JSON 大小",[16,8678,8679],{},"这些限制确保 AI 即使有写入权限也不会\"失控\"——例如防止 AI 往描述里塞几万字的废话。",[717,8681,8683],{"id":8682},"_6-中英双语帮助","6. 中英双语帮助",[16,8685,8686],{},"帮助信息默认中文，也支持一键切英文：",[190,8688,8690],{"className":356,"code":8689,"language":358,"meta":195,"style":195},"# 保存英文偏好\njira_cli set help-language en\n\n# 切回中文\njira_cli set help-language zh\n\n# 临时覆盖\njira_cli --lang en issue search -h\n",[197,8691,8692,8697,8709,8713,8718,8729,8733,8738],{"__ignoreMap":195},[200,8693,8694],{"class":202,"line":203},[200,8695,8696],{"class":206},"# 保存英文偏好\n",[200,8698,8699,8701,8703,8706],{"class":202,"line":210},[200,8700,7541],{"class":290},[200,8702,7765],{"class":237},[200,8704,8705],{"class":237}," help-language",[200,8707,8708],{"class":237}," en\n",[200,8710,8711],{"class":202,"line":231},[200,8712,532],{"emptyLinePlaceholder":393},[200,8714,8715],{"class":202,"line":244},[200,8716,8717],{"class":206},"# 切回中文\n",[200,8719,8720,8722,8724,8726],{"class":202,"line":255},[200,8721,7541],{"class":290},[200,8723,7765],{"class":237},[200,8725,8705],{"class":237},[200,8727,8728],{"class":237}," zh\n",[200,8730,8731],{"class":202,"line":266},[200,8732,532],{"emptyLinePlaceholder":393},[200,8734,8735],{"class":202,"line":272},[200,8736,8737],{"class":206},"# 临时覆盖\n",[200,8739,8740,8742,8745,8748,8750,8752],{"class":202,"line":599},[200,8741,7541],{"class":290},[200,8743,8744],{"class":220}," --lang",[200,8746,8747],{"class":237}," en",[200,8749,7566],{"class":237},[200,8751,7569],{"class":237},[200,8753,8754],{"class":220}," -h\n",[11,8756,8757],{"id":8757},"架构一览",[190,8759,8762],{"className":8760,"code":8761,"language":1801},[1799],"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",[197,8763,8761],{"__ignoreMap":195},[16,8765,8766],{},"设计原则清晰：",[120,8768,8769,8777,8785,8793],{},[123,8770,8771,8776],{},[20,8772,8773],{},[197,8774,8775],{},"pkg\u002Fjira"," 是纯 Jira 客户端，HTTP、认证、上下文处理都在这里",[123,8778,8779,8784],{},[20,8780,8781],{},[197,8782,8783],{},"cmd"," 只管 Cobra 解析和呈现",[123,8786,8787,8792],{},[20,8788,8789],{},[197,8790,8791],{},"internal\u002Ftools\u002Fjira_tools"," 只管 MCP schema 验证和结果适配",[123,8794,8795,8800],{},[20,8796,8797],{},[197,8798,8799],{},"internal\u002Fconfig"," 拥有 Profile 的全部逻辑，不分散到 cmd",[16,8802,8803,8804,8807],{},"每一层职责单一，新增 Jira 能力时按 ",[197,8805,8806],{},"endpoints.go → Client 方法 → CLI 命令 → MCP tool → 策略注册"," 的路径走就行。",[11,8809,8810],{"id":8810},"和已有方案的对比",[34,8812,8813,8825],{},[37,8814,8815],{},[40,8816,8817,8819,8822],{},[43,8818,3046],{},[43,8820,8821],{},"适合场景",[43,8823,8824],{},"jira_cli 的区别",[50,8826,8827,8838,8849,8860],{},[40,8828,8829,8832,8835],{},[55,8830,8831],{},"Atlassian Rovo MCP Server",[55,8833,8834],{},"Jira Cloud、官方 OAuth",[55,8836,8837],{},"jira_cli 是本地 stdio，适合内网 Server\u002FDC",[40,8839,8840,8843,8846],{},[55,8841,8842],{},"go-jira \u002F jira-cli 社区版",[55,8844,8845],{},"纯命令行管理",[55,8847,8848],{},"jira_cli 同时提供 MCP server，面向 AI 工作流",[40,8850,8851,8854,8857],{},[55,8852,8853],{},"直接调 REST API",[55,8855,8856],{},"自定义集成",[55,8858,8859],{},"jira_cli 封装了认证、只读策略、字段元数据",[40,8861,8862,8865,8868],{},[55,8863,8864],{},"Jira 网页",[55,8866,8867],{},"日常使用",[55,8869,8870],{},"jira_cli 把 Jira 变成终端里的一行命令",[11,8872,1645],{"id":1645},[16,8874,8875],{},"如果你的团队满足以下任一条件，jira_cli 值得一试：",[120,8877,8878,8881,8884,8887,8890],{},[123,8879,8880],{},"企业 Jira 部署在内网，想接入 AI 编程工具",[123,8882,8883],{},"想给 AI Agent 只读权限来查 Jira 上下文",[123,8885,8886],{},"需要命令行快速查看\u002F操作 Jira，不想切浏览器",[123,8888,8889],{},"需要管理多个 Jira 实例和多套账号",[123,8891,8892],{},"Windows 用户想要无摩擦的 CLI 体验",[16,8894,8895,8896,8900],{},"GitHub：",[421,8897,8899],{"href":7539,"rel":8898},[425],"github.com\u002Fyourname\u002Fjira_cli"," | MIT 协议 | Go 1.26.3+",[437,8902,8903],{},"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":195,"searchDepth":210,"depth":231,"links":8905},[8906,8907,8908,8909,8914,8922,8923,8924],{"id":7533,"depth":210,"text":7534},{"id":7619,"depth":210,"text":7619},{"id":5912,"depth":210,"text":5912},{"id":7744,"depth":210,"text":7744,"children":8910},[8911,8912,8913],{"id":7747,"depth":231,"text":7748},{"id":7863,"depth":231,"text":7864},{"id":7998,"depth":231,"text":7999},{"id":6394,"depth":210,"text":6394,"children":8915},[8916,8917,8918,8919,8920,8921],{"id":8141,"depth":231,"text":8142},{"id":8189,"depth":231,"text":8190},{"id":8379,"depth":231,"text":8380},{"id":8483,"depth":231,"text":8484},{"id":8608,"depth":231,"text":8609},{"id":8682,"depth":231,"text":8683},{"id":8757,"depth":210,"text":8757},{"id":8810,"depth":210,"text":8810},{"id":1645,"depth":210,"text":1645},"介绍 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":7528,"description":8925},"posts\u002Fjira-cli-intro\u002Findex",[8933,6544,5759,1282,5761,6545],"Jira","vNH6sF2mMFmSjbHoEI2OqmYCgpFmaqyKt-MUfcQqXrU",{"id":8936,"title":8937,"author":6,"body":8938,"description":9482,"difficulty":450,"draft":451,"extension":452,"image":9483,"meta":9484,"navigation":393,"path":9485,"pinned":451,"published":9486,"seo":9487,"series":9488,"seriesOrder":203,"stem":9489,"tags":9490,"updated":9493,"__hash__":9494},"posts\u002Fposts\u002Fsublog-evolution\u002Findex.md","尼克的小窝 架构变迁：从纯静态到前后端分离",{"type":8,"value":8939,"toc":9462},[8940,8944,8947,8958,8962,8966,8972,8976,9007,9010,9024,9028,9031,9099,9103,9106,9112,9116,9126,9156,9159,9163,9171,9174,9218,9222,9343,9347,9354,9358,9364,9370,9374,9377,9428,9432,9439,9446,9452,9455,9457],[11,8941,8943],{"id":8942},"写在前面","📝 写在前面",[16,8945,8946],{},"SuBlog 最初只是一个简单的技术博客——Nuxt 3 静态生成，Markdown 写文章，部署到静态托管完事。但随着功能需求的增长，纯静态的架构开始显露出局限性。",[16,8948,8949,8950,8953,8954,8957],{},"这篇文章记录从 ",[20,8951,8952],{},"纯静态"," 到 ",[20,8955,8956],{},"前后端分离"," 的演进过程，带你了解每一步背后的思考。",[11,8959,8961],{"id":8960},"第一代纯静态nuxt-3-nuxtcontent","📦 第一代：纯静态（Nuxt 3 + @nuxt\u002Fcontent）",[717,8963,8965],{"id":8964},"️-架构图","🏗️ 架构图",[190,8967,8970],{"className":8968,"code":8969,"language":1801,"meta":195},[1799],"┌─────────────────────────────────────┐\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",[197,8971,8969],{"__ignoreMap":195},[717,8973,8975],{"id":8974},"做了什么","🔧 做了什么",[120,8977,8978,8987,8992,8997],{},[123,8979,8980,1662,8982,1662,8984,8986],{},[20,8981,59],{},[20,8983,69],{},[20,8985,79],{}," 搭建前端",[123,8988,8989,8991],{},[20,8990,89],{}," 管理 Markdown 文章，编译时生成静态页面",[123,8993,8994,8996],{},[20,8995,109],{}," 嵌入评论，依赖 GitHub Discussions",[123,8998,8999,9000,9003,9004,9006],{},"部署通过 ",[197,9001,9002],{},"nuxt generate"," 输出 ",[197,9005,377],{},"，丢到 Nginx 或 Vercel",[717,9008,9009],{"id":3049},"✅ 优点",[120,9011,9012,9018,9021],{},[123,9013,9014,9015,9017],{},"部署极简，一个 ",[197,9016,9002],{}," 搞定",[123,9019,9020],{},"加载飞快，全是 HTML，没有服务端开销",[123,9022,9023],{},"无需后端，零运维成本",[717,9025,9027],{"id":9026},"局限","❌ 局限",[16,9029,9030],{},"随着时间的推移，纯静态的问题越来越明显：",[34,9032,9033,9042],{},[37,9034,9035],{},[40,9036,9037,9039],{},[43,9038,3343],{},[43,9040,9041],{},"具体表现",[50,9043,9044,9055,9066,9077,9088],{},[40,9045,9046,9052],{},[55,9047,9048,9049],{},"🐢 ",[20,9050,9051],{},"内容更新慢",[55,9053,9054],{},"改一篇文章要重新构建、重新部署，不能实时发布",[40,9056,9057,9063],{},[55,9058,9059,9060],{},"👤 ",[20,9061,9062],{},"没有用户体系",[55,9064,9065],{},"无法区分读者和管理员，所有内容对所有人一致可见",[40,9067,9068,9074],{},[55,9069,9070,9071],{},"🔒 ",[20,9072,9073],{},"评论受限",[55,9075,9076],{},"Giscus 需要 GitHub 账号才能评论，门槛高",[40,9078,9079,9085],{},[55,9080,9081,9082],{},"🚫 ",[20,9083,9084],{},"无法做动态功能",[55,9086,9087],{},"点赞、收藏、阅读统计、个性化推荐……统统做不了",[40,9089,9090,9096],{},[55,9091,9092,9093],{},"🔗 ",[20,9094,9095],{},"前端锁死",[55,9097,9098],{},"被 Nuxt\u002FVue 生态绑定，想换技术栈得重写",[11,9100,9102],{"id":9101},"第二代前后端分离react-go","⚡ 第二代：前后端分离（React + Go）",[717,9104,8965],{"id":9105},"️-架构图-1",[190,9107,9110],{"className":9108,"code":9109,"language":1801,"meta":195},[1799],"┌───────────────────────┐       ┌────────────────────┐\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",[197,9111,9109],{"__ignoreMap":195},[717,9113,9115],{"id":9114},"️-前端sublog-react","🖥️ 前端：sublog-react",[16,9117,9118,9119,9121,9122,9125],{},"选用 ",[20,9120,1828],{},"（App Router） + ",[20,9123,9124],{},"React 19","，理由是：",[120,9127,9128,9135,9142,9149],{},[123,9129,9130,9131,9134],{},"🎨 ",[20,9132,9133],{},"React 生态","——组件库成熟，开发效率高",[123,9136,9137,9138,9141],{},"📐 ",[20,9139,9140],{},"App Router","——支持布局嵌套，页面组织清晰",[123,9143,9144,9145,9148],{},"📦 ",[20,9146,9147],{},"静态导出","——仍可部署到静态托管",[123,9150,9151,9152,9155],{},"⚙️ ",[20,9153,9154],{},"运行时配置","——API 地址可动态注入，切换环境无需重新构建",[16,9157,9158],{},"前端不再直接读取 Markdown 文件，而是通过 HTTP API 获取文章数据。静态导出 + 客户端数据获取的组合，既保留了首屏加载速度，又实现了内容的动态更新。",[717,9160,9162],{"id":9161},"️-后端sublog-s","⚙️ 后端：sublog-s",[16,9164,9165,9166,1662,9168,9170],{},"后端使用 ",[20,9167,1282],{},[20,9169,5760],{},"，按业务逻辑分层组织代码。",[16,9172,9173],{},"核心技术选型：",[34,9175,9176,9184],{},[37,9177,9178],{},[40,9179,9180,9182],{},[43,9181,45],{},[43,9183,48],{},[50,9185,9186,9196,9207],{},[40,9187,9188,9193],{},[55,9189,9190,9191],{},"🚀 ",[20,9192,5760],{},[55,9194,9195],{},"HTTP 框架",[40,9197,9198,9204],{},[55,9199,9200,9201],{},"🗄️ ",[20,9202,9203],{},"GORM",[55,9205,9206],{},"ORM，数据库操作",[40,9208,9209,9215],{},[55,9210,9211,9212],{},"🔐 ",[20,9213,9214],{},"JWT",[55,9216,9217],{},"用户认证",[11,9219,9221],{"id":9220},"前后对比","📊 前后对比",[34,9223,9224,9237],{},[37,9225,9226],{},[40,9227,9228,9231,9234],{},[43,9229,9230],{},"维度",[43,9232,9233],{},"第一代（纯静态）",[43,9235,9236],{},"第二代（前后端分离）",[50,9238,9239,9251,9263,9277,9290,9303,9317,9329],{},[40,9240,9241,9245,9248],{},[55,9242,9130,9243],{},[20,9244,1923],{},[55,9246,9247],{},"Nuxt 3 (Vue)",[55,9249,9250],{},"Next.js (React 19)",[40,9252,9253,9258,9260],{},[55,9254,9255,9256],{},"🖥️ ",[20,9257,5762],{},[55,9259,1866],{},[55,9261,9262],{},"Go + Gin",[40,9264,9265,9271,9274],{},[55,9266,9267,9268],{},"📄 ",[20,9269,9270],{},"内容来源",[55,9272,9273],{},"Markdown 文件",[55,9275,9276],{},"API 数据库",[40,9278,9279,9284,9287],{},[55,9280,9190,9281],{},[20,9282,9283],{},"发布方式",[55,9285,9286],{},"全量重新构建",[55,9288,9289],{},"API 实时发布",[40,9291,9292,9297,9300],{},[55,9293,9059,9294],{},[20,9295,9296],{},"用户系统",[55,9298,9299],{},"❌ 无",[55,9301,9302],{},"✅ 注册\u002F登录",[40,9304,9305,9311,9314],{},[55,9306,9307,9308],{},"💬 ",[20,9309,9310],{},"评论",[55,9312,9313],{},"Giscus（需 GitHub）",[55,9315,9316],{},"自有评论系统（规划中）",[40,9318,9319,9323,9326],{},[55,9320,9144,9321],{},[20,9322,350],{},[55,9324,9325],{},"1 个静态目录",[55,9327,9328],{},"前端静态 + 后端二进制",[40,9330,9331,9337,9340],{},[55,9332,9333,9334],{},"⚡ ",[20,9335,9336],{},"更新体验",[55,9338,9339],{},"改文章 → 构建 → 部署",[55,9341,9342],{},"改文章 → 立即生效",[11,9344,9346],{"id":9345},"新功能注册账号","🎉 新功能：注册账号",[16,9348,9349,9350,9353],{},"伴随着架构升级，",[20,9351,9352],{},"用户账号系统","也上线了。",[717,9355,9357],{"id":9356},"目前支持","📋 目前支持",[16,9359,9360,9361,3510],{},"现在你可以在尼克的小窝 ",[20,9362,9363],{},"注册一个账号",[190,9365,9368],{"className":9366,"code":9367,"language":1801},[1799],"1. ✏️ 填写用户名、邮箱、密码\n2. 📧 查收激活邮件\n3. 🔗 点击链接激活账号\n4. 🚪 登录后查看和管理个人信息\n",[197,9369,9367],{"__ignoreMap":195},[11,9371,9373],{"id":9372},"️-后续计划","🗓️ 后续计划",[16,9375,9376],{},"正在开发或规划中的功能：",[120,9378,9380,9386,9392,9398,9404,9410,9416,9422],{"className":9379},[385],[123,9381,9383,9385],{"className":9382},[389],[391,9384],{"checked":393,"disabled":393,"type":394}," 用户注册\u002F登录（已上线 🎉）",[123,9387,9389,9391],{"className":9388},[389],[391,9390],{"checked":393,"disabled":393,"type":394}," 个人资料编辑（已上线 🎉）",[123,9393,9395,9397],{"className":9394},[389],[391,9396],{"disabled":393,"type":394}," 💬 站内评论系统",[123,9399,9401,9403],{"className":9400},[389],[391,9402],{"disabled":393,"type":394}," 🔖 文章收藏",[123,9405,9407,9409],{"className":9406},[389],[391,9408],{"disabled":393,"type":394}," 📚 阅读历史",[123,9411,9413,9415],{"className":9412},[389],[391,9414],{"disabled":393,"type":394}," 👍 文章点赞",[123,9417,9419,9421],{"className":9418},[389],[391,9420],{"disabled":393,"type":394}," 🔔 关注通知",[123,9423,9425,9427],{"className":9424},[389],[391,9426],{"disabled":393,"type":394}," 🎨 个人首页定制",[11,9429,9431],{"id":9430},"结语","💡 结语",[16,9433,9434,9435,9438],{},"从 Nuxt 3 纯静态到 React + Go 前后端分离，SuBlog 的架构变迁本质上是一个",[20,9436,9437],{},"功能驱动","的过程。静态博客有它的位置——写写文章、展示内容完全够用。但当你想做的不仅仅是\"展示\"时，后端的能力就不可或缺了。",[16,9440,9441,9442,9445],{},"这次重构最大的感触是：",[20,9443,9444],{},"架构选型不是越复杂越好，而是刚好够用最好","。纯静态阶段用 Nuxt 3 一点问题没有，直到功能需求超出了静态的边界，才自然推动到前后端分离。",[16,9447,9448,9449],{},"如果你也在纠结\"要不要上后端\"，不妨先问问自己：",[20,9450,9451],{},"你要做的是\"展示内容\"还是\"提供服务\"？",[16,9453,9454],{},"前者，静态就够了；后者，来吧，后端欢迎你。😄",[429,9456],{},[16,9458,9459],{},[20,9460,9461],{},"🎉 目前注册功能已经可以用了，后续更多好玩的功能正在路上。部分功能可能需要登录才能体验，快注册一个账号吧！",{"title":195,"searchDepth":210,"depth":231,"links":9463},[9464,9465,9471,9476,9477,9480,9481],{"id":8942,"depth":210,"text":8943},{"id":8960,"depth":210,"text":8961,"children":9466},[9467,9468,9469,9470],{"id":8964,"depth":231,"text":8965},{"id":8974,"depth":231,"text":8975},{"id":3049,"depth":231,"text":9009},{"id":9026,"depth":231,"text":9027},{"id":9101,"depth":210,"text":9102,"children":9472},[9473,9474,9475],{"id":9105,"depth":231,"text":8965},{"id":9114,"depth":231,"text":9115},{"id":9161,"depth":231,"text":9162},{"id":9220,"depth":210,"text":9221},{"id":9345,"depth":210,"text":9346,"children":9478},[9479],{"id":9356,"depth":231,"text":9357},{"id":9372,"depth":210,"text":9373},{"id":9430,"depth":210,"text":9431},"记录尼克的小窝从 Nuxt 3 纯静态博客演进到 React + Go 前后端分离架构的全过程，包括架构设计的思考、技术选型的变化，以及新增的账号注册功能。","\u002Fposts\u002Fsublog-evolution\u002Fimg\u002Fcover.jpg",{},"\u002Fposts\u002Fsublog-evolution","2026-07-23T12:00:00",{"title":8937,"description":9482},"尼克的小窝 开发记录","posts\u002Fsublog-evolution\u002Findex",[9491,9492,1828,1282],"架构","React","2026-07-23","VMQ6A2K8GQ5t3DSlezWPd3KgXnmDVMn9QVeaI28Hcp8",{"id":9496,"title":9497,"author":6,"body":9498,"description":10661,"difficulty":450,"draft":451,"extension":452,"image":10662,"meta":10663,"navigation":393,"path":10664,"pinned":451,"published":10665,"seo":10666,"series":6,"seriesOrder":6,"stem":10667,"tags":10668,"updated":10672,"__hash__":10673},"posts\u002Fposts\u002Fip-geolocation-guide\u002Findex.md","IP 属地是怎么查出来的？本地查询与在线查询方案对比",{"type":8,"value":9499,"toc":10637},[9500,9502,9508,9511,9522,9532,9536,9542,9555,9561,9572,9579,9582,9589,9593,9604,9608,9619,9622,9626,9632,9638,9641,9645,9652,9670,9673,9676,9680,9711,9750,9756,9760,9899,9935,9944,9948,9988,9993,9997,10008,10011,10045,10048,10052,10055,10059,10250,10254,10337,10341,10348,10455,10459,10468,10554,10557,10560,10596,10599,10602,10610,10631,10634],[11,9501,8942],{"id":8942},[16,9503,9504,9505],{},"很多网站会在「登录记录」「安全中心」里显示你的登录 IP 归属地，比如「福建·福州 · 中国移动」。你可能好奇过：",[20,9506,9507],{},"服务器是怎么根据一个 IP 就判断出我在哪个城市、哪个运营商的？",[16,9509,9510],{},"这篇科普文章讲清楚三件事：",[721,9512,9513,9516,9519],{},[123,9514,9515],{},"IP 属地到底查的是什么；",[123,9517,9518],{},"纯本地查询为什么能做到毫秒级且不联网；",[123,9520,9521],{},"几种主流查询方案的优缺点对比，方便你按场景选型。",[9523,9524,9525],"blockquote",{},[16,9526,9527,9528,9531],{},"说明：文中用到的 IP 均为 RFC 5737 保留的文档示例段（TEST-NET-2，",[197,9529,9530],{},"198.51.100.0\u002F24","），不会对应任何真实地址。",[11,9533,9535],{"id":9534},"先想清楚ip-属地查的是什么","先想清楚：IP 属地查的是什么",[16,9537,9538,9539,2258],{},"一个常见误区是「每个 IP 对应一个具体地址」。实际上 IP 属地的本质是 ",[20,9540,9541],{},"IP 段与归属地的映射",[16,9543,9544,9545,3510],{},"运营商和机构在申请 IP 时，是按",[20,9546,9547,9548,9554],{},"网段（CIDR）",[20,9549,9550,9551,9553],{},"成块分配的。比如 ",[197,9552,9530],{}," 这一个段，一整块都归属同一地区、同一运营商。所以归属地数据天然可以","按段存储",[190,9556,9559],{"className":9557,"code":9558,"language":1801,"meta":195},[1799],"IP 起始          IP 结束         归属地\n────────────────────────────────────────\n198.51.100.0     198.51.100.255  中国|福建|福州|移动\n198.51.101.0     198.51.101.255  中国|浙江|杭州|电信\n",[197,9560,9558],{"__ignoreMap":195},[16,9562,9563,9564,9567,9568,9571],{},"全球公网 IP 约有 42 亿个，但",[20,9565,9566],{},"网段只有几十万条","。正因为存的是「段」而不是「每个 IP」，完整的归属地数据文件才只有几 MB（ip2region 的 ",[197,9569,9570],{},".xdb"," 约 11MB）。这是「本地离线查询」可行的第一前提。",[16,9573,9574,9575,9578],{},"所以，",[20,9576,9577],{},"IP 属地从来不是精确到街道的定位","，它返回的是段级的「省｜市｜运营商」粗粒度信息。",[11,9580,9581],{"id":9581},"纯本地查询的原理",[16,9583,9584,9585,9588],{},"以开源的 ",[20,9586,9587],{},"ip2region"," 为例，它的查询完全在本地完成，不联网、不请求第三方，几十微秒到毫秒级返回。",[717,9590,9592],{"id":9591},"第一步把-ip-变成整数","第一步：把 IP 变成整数",[16,9594,9595,9596,9599,9600,9603],{},"以 ",[197,9597,9598],{},"198.51.100.42"," 为例，它会转成一个 32 位整数 ",[197,9601,9602],{},"0xC633642A","。IP 的四个数字本质就是 4 个字节，拼起来就是一个整数，这一步没有任何玄学。",[717,9605,9607],{"id":9606},"第二步二分查找","第二步：二分查找",[16,9609,9610,9611,9614,9615,9618],{},"在按起始 IP 排序的段列表里做",[20,9612,9613],{},"二分查找","，找到满足 ",[197,9616,9617],{},"段的起始IP ≤ 目标整数 ≤ 段的结束IP"," 的那一段。",[16,9620,9621],{},"二分查找的复杂度是 O(log N)，N 是几十万条，所以很快——这就是「不靠网络也能毫秒返回」的核心。",[717,9623,9625],{"id":9624},"第三步xdb-文件的两级结构","第三步：.xdb 文件的两级结构",[16,9627,9628,9629,9631],{},"ip2region 的 ",[197,9630,9570],{}," 不是简单顺序文件，内部分两个区，进一步加速：",[190,9633,9636],{"className":9634,"code":9635,"language":1801,"meta":195},[1799],"┌─────────────────────────────┐\n│  索引区 (Index)             │  \u002F\u002F 每段一条定长记录：startIP \u002F endIP \u002F 数据区指针 + 数据长度\n│  …… 二分快速定位段 ……       │\n├─────────────────────────────┤\n│  数据区 (Data)              │  \u002F\u002F 真正存放各段的地点字符串\n│  ……                         │\n└─────────────────────────────┘\n",[197,9637,9635],{"__ignoreMap":195},[16,9639,9640],{},"先二分索引区定位到「是哪一段」，再按指针去数据区读出地点字符串，一次磁盘读就能拿到结果。",[717,9642,9644],{"id":9643},"为什么离线数据不会很快过期","为什么「离线数据」不会很快过期",[16,9646,9647,9648,9651],{},"IP 归属取决于",[20,9649,9650],{},"注册机构和运营商的分配","，一个网段通常长期稳定属于某地区、某运营商。所以：",[120,9653,9654,9657,9667],{},[123,9655,9656],{},"下载一次数据文件，可以用很久；",[123,9658,9659,9660,9666],{},"归属偶尔变化时，",[20,9661,9662,9663,9665],{},"每月更新一次 ",[197,9664,9570],{}," 文件","即可（替换文件、不用重启服务，见下文实战）；",[123,9668,9669],{},"数据源来自 IANA\u002FAPNIC 等机构的分配表与公开归属信息，不是实时探测。",[11,9671,9672],{"id":9672},"主流查询方案对比",[16,9674,9675],{},"实际开发中，查询 IP 属地主要有三条路线。",[717,9677,9679],{"id":9678},"方案一ip2region纯本地离线推荐","方案一：ip2region（纯本地离线，推荐）",[190,9681,9683],{"className":3791,"code":9682,"language":608,"meta":195,"style":195},"\u002F\u002F Go 中的用法（github.com\u002Flionsoul2014\u002Fip2region\u002Fbinding\u002Fgolang）\nregion, err := searcher.SearchByStr(ip)\n\u002F\u002F 返回类似 \"中国|福建省|福州市|移动\"\n",[197,9684,9685,9690,9706],{"__ignoreMap":195},[200,9686,9687],{"class":202,"line":203},[200,9688,9689],{"class":206},"\u002F\u002F Go 中的用法（github.com\u002Flionsoul2014\u002Fip2region\u002Fbinding\u002Fgolang）\n",[200,9691,9692,9695,9697,9700,9703],{"class":202,"line":210},[200,9693,9694],{"class":227},"region, err ",[200,9696,3818],{"class":213},[200,9698,9699],{"class":227}," searcher.",[200,9701,9702],{"class":290},"SearchByStr",[200,9704,9705],{"class":227},"(ip)\n",[200,9707,9708],{"class":202,"line":231},[200,9709,9710],{"class":206},"\u002F\u002F 返回类似 \"中国|福建省|福州市|移动\"\n",[34,9712,9713,9721],{},[37,9714,9715],{},[40,9716,9717,9719],{},[43,9718,3049],{},[43,9720,3052],{},[50,9722,9723,9731,9739],{},[40,9724,9725,9728],{},[55,9726,9727],{},"免费、无网络依赖、无 QPS 限制",[55,9729,9730],{},"段级粒度，个别段可能显示到相邻地区",[40,9732,9733,9736],{},[55,9734,9735],{},"毫秒级查询，永不因外部服务挂掉",[55,9737,9738],{},"数据按月更新，最新分配的段可能短暂缺失",[40,9740,9741,9744],{},[55,9742,9743],{},"数据文件可随版本更新，不重启",[55,9745,9746,9747],{},"只返回省市区+运营商，无经纬度；",[20,9748,9749],{},"仅支持 IPv4",[16,9751,9752,9755],{},[20,9753,9754],{},"适用","：博客\u002F后台的「登录记录显示归属地」这类展示用途。",[717,9757,9759],{"id":9758},"方案二在线-apiip-api-高德-腾讯位置服务","方案二：在线 API（ip-api \u002F 高德 \u002F 腾讯位置服务）",[190,9761,9763],{"className":3791,"code":9762,"language":608,"meta":195,"style":195},"\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",[197,9764,9765,9770,9798,9808,9839,9855,9868,9878,9882,9894],{"__ignoreMap":195},[200,9766,9767],{"class":202,"line":203},[200,9768,9769],{"class":206},"\u002F\u002F 以 ip-api.com 为例（免费版约 45 次\u002F分钟，非商用）\n",[200,9771,9772,9775,9777,9779,9782,9785,9788,9791,9793,9795],{"class":202,"line":210},[200,9773,9774],{"class":227},"ctx, cancel ",[200,9776,3818],{"class":213},[200,9778,5291],{"class":227},[200,9780,9781],{"class":290},"WithTimeout",[200,9783,9784],{"class":227},"(context.",[200,9786,9787],{"class":290},"Background",[200,9789,9790],{"class":227},"(), ",[200,9792,2316],{"class":220},[200,9794,4024],{"class":213},[200,9796,9797],{"class":227},"time.Second)\n",[200,9799,9800,9803,9806],{"class":202,"line":231},[200,9801,9802],{"class":213},"defer",[200,9804,9805],{"class":290}," cancel",[200,9807,2562],{"class":227},[200,9809,9810,9813,9815,9818,9821,9824,9827,9829,9832,9834,9837],{"class":202,"line":244},[200,9811,9812],{"class":227},"req, _ ",[200,9814,3818],{"class":213},[200,9816,9817],{"class":227}," http.",[200,9819,9820],{"class":290},"NewRequestWithContext",[200,9822,9823],{"class":227},"(ctx, ",[200,9825,9826],{"class":237},"\"GET\"",[200,9828,337],{"class":227},[200,9830,9831],{"class":237},"\"https:\u002F\u002Fip-api.com\u002Fjson\u002F198.51.100.42\"",[200,9833,337],{"class":227},[200,9835,9836],{"class":220},"nil",[200,9838,2677],{"class":227},[200,9840,9841,9844,9846,9849,9852],{"class":202,"line":255},[200,9842,9843],{"class":227},"resp, err ",[200,9845,3818],{"class":213},[200,9847,9848],{"class":227}," http.DefaultClient.",[200,9850,9851],{"class":290},"Do",[200,9853,9854],{"class":227},"(req)\n",[200,9856,9857,9860,9862,9864,9866],{"class":202,"line":266},[200,9858,9859],{"class":213},"if",[200,9861,4232],{"class":227},[200,9863,4235],{"class":213},[200,9865,4238],{"class":220},[200,9867,228],{"class":227},[200,9869,9870,9872,9875],{"class":202,"line":272},[200,9871,4049],{"class":213},[200,9873,9874],{"class":237}," \"未知\"",[200,9876,9877],{"class":206}," \u002F\u002F 断网\u002F超时降级：展示「未知」而不是报错\n",[200,9879,9880],{"class":202,"line":599},[200,9881,275],{"class":227},[200,9883,9884,9886,9889,9892],{"class":202,"line":605},[200,9885,9802],{"class":213},[200,9887,9888],{"class":227}," resp.Body.",[200,9890,9891],{"class":290},"Close",[200,9893,2562],{"class":227},[200,9895,9896],{"class":202,"line":619},[200,9897,9898],{"class":206},"\u002F\u002F 解析 JSON，status != \"success\" 时同样降级\n",[34,9900,9901,9909],{},[37,9902,9903],{},[40,9904,9905,9907],{},[43,9906,3049],{},[43,9908,3052],{},[50,9910,9911,9919,9927],{},[40,9912,9913,9916],{},[55,9914,9915],{},"数据最新、可能更精确",[55,9917,9918],{},"有频率限制（免费版限 QPS）",[40,9920,9921,9924],{},[55,9922,9923],{},"部分服务还返回经纬度\u002F时区",[55,9925,9926],{},"依赖第三方，断网\u002F被墙\u002F限流时拿不到",[40,9928,9929,9932],{},[55,9930,9931],{},"无需维护数据文件",[55,9933,9934],{},"每次查询都要发一次 HTTP 请求",[16,9936,9937,9939,9940,9943],{},[20,9938,9754],{},"：对精度要求高、且能接受外部依赖与限流的场景。",[20,9941,9942],{},"务必加超时和降级","，否则第三方一抖，你自己的页面就卡住。",[717,9945,9947],{"id":9946},"方案三maxmind-geolite2离线-mmdb","方案三：MaxMind GeoLite2（离线 .mmdb）",[34,9949,9950,9958],{},[37,9951,9952],{},[40,9953,9954,9956],{},[43,9955,3049],{},[43,9957,3052],{},[50,9959,9960,9968,9980],{},[40,9961,9962,9965],{},[55,9963,9964],{},"全球数据，覆盖广、精度较好",[55,9966,9967],{},"免费但需注册账号并遵守 EULA；商用需 GeoIP2 商业授权",[40,9969,9970,9977],{},[55,9971,9972,9973,9976],{},"标准 ",[197,9974,9975],{},".mmdb"," 格式，各语言都有库，支持 IPv6",[55,9978,9979],{},"国内 IP 的省市粒度有时不如 ip2region 细",[40,9981,9982,9985],{},[55,9983,9984],{},"离线查询",[55,9986,9987],{},"数据同样要定期更新",[16,9989,9990,9992],{},[20,9991,9754],{},"：面向全球用户的场景。",[11,9994,9996],{"id":9995},"ipv6本地离线方案的一个盲点","IPv6：本地离线方案的一个盲点",[16,9998,9999,10000,10003,10004,10007],{},"前面聊的都是 IPv4（32 位）。但家宽和移动网络越来越多地",[20,10001,10002],{},"只下发 IPv6","（128 位）地址，而 ",[20,10005,10006],{},"ip2region 目前只支持 IPv4","——拿到一个纯 IPv6 地址是查不出归属地的，本地库会返回空结果。",[16,10009,10010],{},"实际影响与常见应对：",[721,10012,10013,10023,10029,10039],{},[123,10014,10015,10018,10019,10022],{},[20,10016,10017],{},"判断版本","：Go 里 ",[197,10020,10021],{},"net.ParseIP(ip).To4() != nil"," 就是 IPv4，先判断再查询；",[123,10024,10025,10028],{},[20,10026,10027],{},"降级展示","：IPv6 直接显示「IPv6 地址」，不显示属地，诚实第一；",[123,10030,10031,10034,10035,10038],{},[20,10032,10033],{},"优先取 IPv4","：有反向代理时，从 ",[197,10036,10037],{},"X-Forwarded-For"," 里挑一个 IPv4 来查；",[123,10040,10041,10044],{},[20,10042,10043],{},"需要 v6 归属","：换支持 IPv6 的数据源（如 MaxMind GeoLite2）。",[16,10046,10047],{},"这一点在接入时很容易漏掉，上线前记得拿一个纯 IPv6 地址回归一遍。",[11,10049,10051],{"id":10050},"实战给登录记录加-ip-属地","实战：给登录记录加 IP 属地",[16,10053,10054],{},"subLog 博客的登录记录就用了 ip2region。完整接入一共四步。",[717,10056,10058],{"id":10057},"_1-进程启动时加载数据文件","1. 进程启动时加载数据文件",[190,10060,10062],{"className":3791,"code":10061,"language":608,"meta":195,"style":195},"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",[197,10063,10064,10070,10079,10088,10092,10096,10101,10128,10132,10145,10167,10179,10186,10190,10206,10218,10224,10228,10239,10246],{"__ignoreMap":195},[200,10065,10066,10068],{"class":202,"line":203},[200,10067,3799],{"class":213},[200,10069,5197],{"class":227},[200,10071,10072,10074,10077],{"class":202,"line":210},[200,10073,6617],{"class":237},[200,10075,10076],{"class":290},"sync\u002Fatomic",[200,10078,715],{"class":237},[200,10080,10081,10083,10086],{"class":202,"line":231},[200,10082,6617],{"class":237},[200,10084,10085],{"class":290},"github.com\u002Flionsoul2014\u002Fip2region\u002Fbinding\u002Fgolang\u002Fxdb",[200,10087,715],{"class":237},[200,10089,10090],{"class":202,"line":244},[200,10091,2677],{"class":227},[200,10093,10094],{"class":202,"line":255},[200,10095,532],{"emptyLinePlaceholder":393},[200,10097,10098],{"class":202,"line":266},[200,10099,10100],{"class":206},"\u002F\u002F 用 atomic.Pointer 持有查询器，方便后面热更新\n",[200,10102,10103,10105,10108,10111,10113,10116,10118,10121,10123,10126],{"class":202,"line":272},[200,10104,6698],{"class":213},[200,10106,10107],{"class":227}," searcher ",[200,10109,10110],{"class":290},"atomic",[200,10112,2489],{"class":227},[200,10114,10115],{"class":290},"Pointer",[200,10117,3177],{"class":227},[200,10119,10120],{"class":290},"xdb",[200,10122,2489],{"class":227},[200,10124,10125],{"class":290},"Searcher",[200,10127,343],{"class":227},[200,10129,10130],{"class":202,"line":599},[200,10131,532],{"emptyLinePlaceholder":393},[200,10133,10134,10136,10139,10141,10143],{"class":202,"line":605},[200,10135,3992],{"class":213},[200,10137,10138],{"class":290}," initSearcher",[200,10140,5240],{"class":227},[200,10142,4037],{"class":213},[200,10144,228],{"class":227},[200,10146,10147,10150,10152,10155,10157,10159,10162,10164],{"class":202,"line":619},[200,10148,10149],{"class":227},"    content, err ",[200,10151,3818],{"class":213},[200,10153,10154],{"class":227}," os.",[200,10156,6837],{"class":290},[200,10158,2367],{"class":227},[200,10160,10161],{"class":237},"\".\u002Fdata\u002Fip2region.xdb\"",[200,10163,2831],{"class":227},[200,10165,10166],{"class":206},"\u002F\u002F 约 11MB，一次性读入内存\n",[200,10168,10169,10171,10173,10175,10177],{"class":202,"line":642},[200,10170,2444],{"class":213},[200,10172,4232],{"class":227},[200,10174,4235],{"class":213},[200,10176,4238],{"class":220},[200,10178,228],{"class":227},[200,10180,10181,10183],{"class":202,"line":651},[200,10182,4245],{"class":213},[200,10184,10185],{"class":227}," err\n",[200,10187,10188],{"class":202,"line":656},[200,10189,2327],{"class":227},[200,10191,10192,10195,10197,10200,10203],{"class":202,"line":662},[200,10193,10194],{"class":227},"    s, err ",[200,10196,3818],{"class":213},[200,10198,10199],{"class":227}," xdb.",[200,10201,10202],{"class":290},"NewWithBuffer",[200,10204,10205],{"class":227},"(content)\n",[200,10207,10208,10210,10212,10214,10216],{"class":202,"line":673},[200,10209,2444],{"class":213},[200,10211,4232],{"class":227},[200,10213,4235],{"class":213},[200,10215,4238],{"class":220},[200,10217,228],{"class":227},[200,10219,10220,10222],{"class":202,"line":684},[200,10221,4245],{"class":213},[200,10223,10185],{"class":227},[200,10225,10226],{"class":202,"line":689},[200,10227,2327],{"class":227},[200,10229,10230,10233,10236],{"class":202,"line":698},[200,10231,10232],{"class":227},"    searcher.",[200,10234,10235],{"class":290},"Store",[200,10237,10238],{"class":227},"(s)\n",[200,10240,10241,10243],{"class":202,"line":703},[200,10242,4049],{"class":213},[200,10244,10245],{"class":220}," nil\n",[200,10247,10248],{"class":202,"line":912},[200,10249,275],{"class":227},[717,10251,10253],{"id":10252},"_2-查询一次","2. 查询一次",[190,10255,10257],{"className":3791,"code":10256,"language":608,"meta":195,"style":195},"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",[197,10258,10259,10281,10300,10312,10319,10323,10333],{"__ignoreMap":195},[200,10260,10261,10263,10266,10268,10271,10274,10276,10279],{"class":202,"line":203},[200,10262,3992],{"class":213},[200,10264,10265],{"class":290}," ipRegion",[200,10267,2367],{"class":227},[200,10269,10270],{"class":2370},"ip",[200,10272,10273],{"class":213}," string",[200,10275,2831],{"class":227},[200,10277,10278],{"class":213},"string",[200,10280,228],{"class":227},[200,10282,10283,10286,10288,10290,10293,10296,10298],{"class":202,"line":210},[200,10284,10285],{"class":227},"    region, err ",[200,10287,3818],{"class":213},[200,10289,9699],{"class":227},[200,10291,10292],{"class":290},"Load",[200,10294,10295],{"class":227},"().",[200,10297,9702],{"class":290},[200,10299,9705],{"class":227},[200,10301,10302,10304,10306,10308,10310],{"class":202,"line":231},[200,10303,2444],{"class":213},[200,10305,4232],{"class":227},[200,10307,4235],{"class":213},[200,10309,4238],{"class":220},[200,10311,228],{"class":227},[200,10313,10314,10316],{"class":202,"line":244},[200,10315,4245],{"class":213},[200,10317,10318],{"class":237}," \"未知\"\n",[200,10320,10321],{"class":202,"line":255},[200,10322,2327],{"class":227},[200,10324,10325,10327,10330],{"class":202,"line":266},[200,10326,4049],{"class":213},[200,10328,10329],{"class":227}," region ",[200,10331,10332],{"class":206},"\u002F\u002F \"中国|福建省|福州市|移动\"\n",[200,10334,10335],{"class":202,"line":272},[200,10336,275],{"class":227},[717,10338,10340],{"id":10339},"_3-加一层内存缓存","3. 加一层内存缓存",[16,10342,10343,10344,10347],{},"登录记录里同一个 IP 可能反复出现，用 ",[197,10345,10346],{},"sync.Map"," 按 IP 缓存结果，一次查询终身复用：",[190,10349,10351],{"className":3791,"code":10350,"language":608,"meta":195,"style":195},"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",[197,10352,10353,10368,10372,10391,10408,10419,10423,10434,10444,10451],{"__ignoreMap":195},[200,10354,10355,10357,10360,10363,10365],{"class":202,"line":203},[200,10356,6698],{"class":213},[200,10358,10359],{"class":227}," regionCache ",[200,10361,10362],{"class":290},"sync",[200,10364,2489],{"class":227},[200,10366,10367],{"class":290},"Map\n",[200,10369,10370],{"class":202,"line":210},[200,10371,532],{"emptyLinePlaceholder":393},[200,10373,10374,10376,10379,10381,10383,10385,10387,10389],{"class":202,"line":231},[200,10375,3992],{"class":213},[200,10377,10378],{"class":290}," ipRegionCached",[200,10380,2367],{"class":227},[200,10382,10270],{"class":2370},[200,10384,10273],{"class":213},[200,10386,2831],{"class":227},[200,10388,10278],{"class":213},[200,10390,228],{"class":227},[200,10392,10393,10395,10398,10400,10403,10405],{"class":202,"line":244},[200,10394,2444],{"class":213},[200,10396,10397],{"class":227}," v, ok ",[200,10399,3818],{"class":213},[200,10401,10402],{"class":227}," regionCache.",[200,10404,10292],{"class":290},[200,10406,10407],{"class":227},"(ip); ok {\n",[200,10409,10410,10412,10415,10417],{"class":202,"line":255},[200,10411,4245],{"class":213},[200,10413,10414],{"class":227}," v.(",[200,10416,10278],{"class":213},[200,10418,2677],{"class":227},[200,10420,10421],{"class":202,"line":266},[200,10422,2327],{"class":227},[200,10424,10425,10428,10430,10432],{"class":202,"line":272},[200,10426,10427],{"class":227},"    region ",[200,10429,3818],{"class":213},[200,10431,10265],{"class":290},[200,10433,9705],{"class":227},[200,10435,10436,10439,10441],{"class":202,"line":599},[200,10437,10438],{"class":227},"    regionCache.",[200,10440,10235],{"class":290},[200,10442,10443],{"class":227},"(ip, region)\n",[200,10445,10446,10448],{"class":202,"line":605},[200,10447,4049],{"class":213},[200,10449,10450],{"class":227}," region\n",[200,10452,10453],{"class":202,"line":619},[200,10454,275],{"class":227},[717,10456,10458],{"id":10457},"_4-数据热更新不用重启","4. 数据热更新（不用重启）",[16,10460,10461,10462,10464,10465,10467],{},"ip2region 每月发布新数据。下载新文件后重新 ",[197,10463,10202],{}," 并 ",[197,10466,10235],{}," 覆盖即可，进程完全不用重启：",[190,10469,10471],{"className":3791,"code":10470,"language":608,"meta":195,"style":195},"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",[197,10472,10473,10497,10510,10522,10528,10532,10544,10550],{"__ignoreMap":195},[200,10474,10475,10477,10480,10482,10485,10488,10491,10493,10495],{"class":202,"line":203},[200,10476,3992],{"class":213},[200,10478,10479],{"class":290}," reloadSearcher",[200,10481,2367],{"class":227},[200,10483,10484],{"class":2370},"newContent",[200,10486,10487],{"class":227}," []",[200,10489,10490],{"class":213},"byte",[200,10492,2831],{"class":227},[200,10494,4037],{"class":213},[200,10496,228],{"class":227},[200,10498,10499,10501,10503,10505,10507],{"class":202,"line":210},[200,10500,10194],{"class":227},[200,10502,3818],{"class":213},[200,10504,10199],{"class":227},[200,10506,10202],{"class":290},[200,10508,10509],{"class":227},"(newContent)\n",[200,10511,10512,10514,10516,10518,10520],{"class":202,"line":231},[200,10513,2444],{"class":213},[200,10515,4232],{"class":227},[200,10517,4235],{"class":213},[200,10519,4238],{"class":220},[200,10521,228],{"class":227},[200,10523,10524,10526],{"class":202,"line":244},[200,10525,4245],{"class":213},[200,10527,10185],{"class":227},[200,10529,10530],{"class":202,"line":255},[200,10531,2327],{"class":227},[200,10533,10534,10536,10538,10541],{"class":202,"line":266},[200,10535,10232],{"class":227},[200,10537,10235],{"class":290},[200,10539,10540],{"class":227},"(s) ",[200,10542,10543],{"class":206},"\u002F\u002F 原子切换，老查询继续走旧数据，新查询走新数据\n",[200,10545,10546,10548],{"class":202,"line":272},[200,10547,4049],{"class":213},[200,10549,10245],{"class":220},[200,10551,10552],{"class":202,"line":599},[200,10553,275],{"class":227},[11,10555,10556],{"id":10556},"局限与诚实提醒",[16,10558,10559],{},"无论用哪种方案，都要清楚 IP 属地的边界：",[120,10561,10562,10568,10574,10580,10586],{},[123,10563,10564,10567],{},[20,10565,10566],{},"段级精度","：网段归属是「绝大多数」准确，个别 IP（大型企业自建段、跨省调度段）可能显示到相邻地区或只到省。",[123,10569,10570,10573],{},[20,10571,10572],{},"运营商级，不是定位","：拿到的是「省｜市｜运营商」，不是精确街道或基站位置——精确位置需要 GPS 或运营商三角定位，纯 IP 归属做不到。",[123,10575,10576,10579],{},[20,10577,10578],{},"动态 IP","：家宽通常是动态 IP，重拨会变，所以「上次登录在福州，这次在广州」不一定是异常，可能是 IP 变了。",[123,10581,10582,10585],{},[20,10583,10584],{},"别用于安全判定","：用 IP 段做封禁或风控判定风险自负，段级数据不适合当精确依据。",[123,10587,10588,10591,10592,10595],{},[20,10589,10590],{},"隐私与合规","：IP 地址属于个人信息（《个人信息保护法》）。归属地展示建议只出现在",[20,10593,10594],{},"用户本人可见","的页面（登录记录、个人中心）；若在评论区等公开场合展示，建议只显示到省市并明确告知用户。",[11,10597,10598],{"id":10598},"小结",[16,10600,10601],{},"一句话总结：",[9523,10603,10604],{},[16,10605,10606,10609],{},[20,10607,10608],{},"IP 属地 = 几十万条「IP 段 → 归属地」映射 + 一次二分查找。"," 它查的是网段归属，不是精确位置。",[120,10611,10612,10618,10625],{},[123,10613,10614,10615,10617],{},"想省心、免费、稳定，用 ",[20,10616,9587],{}," 本地离线查询（注意 IPv6 盲点）；",[123,10619,10620,10621,10624],{},"想要最新最细、能接受外部依赖，用",[20,10622,10623],{},"在线 API","（记得超时降级）；",[123,10626,10627,10628,2258],{},"面向全球用户、要 IPv6 覆盖，用 ",[20,10629,10630],{},"MaxMind GeoLite2",[16,10632,10633],{},"对个人博客「登录记录显示归属地」这种场景，本地离线方案通常是性价比最高的选择。",[437,10635,10636],{},"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":195,"searchDepth":210,"depth":231,"links":10638},[10639,10640,10641,10647,10652,10653,10659,10660],{"id":8942,"depth":210,"text":8942},{"id":9534,"depth":210,"text":9535},{"id":9581,"depth":210,"text":9581,"children":10642},[10643,10644,10645,10646],{"id":9591,"depth":231,"text":9592},{"id":9606,"depth":231,"text":9607},{"id":9624,"depth":231,"text":9625},{"id":9643,"depth":231,"text":9644},{"id":9672,"depth":210,"text":9672,"children":10648},[10649,10650,10651],{"id":9678,"depth":231,"text":9679},{"id":9758,"depth":231,"text":9759},{"id":9946,"depth":231,"text":9947},{"id":9995,"depth":210,"text":9996},{"id":10050,"depth":210,"text":10051,"children":10654},[10655,10656,10657,10658],{"id":10057,"depth":231,"text":10058},{"id":10252,"depth":231,"text":10253},{"id":10339,"depth":231,"text":10340},{"id":10457,"depth":231,"text":10458},{"id":10556,"depth":210,"text":10556},{"id":10598,"depth":210,"text":10598},"科普 IP 属地查询的原理：IP 段与归属地的映射、纯本地离线查询为何能做到毫秒级，对比 ip2region、在线 API 与 MaxMind 三种方案的优劣，并附登录记录场景的 Go 实战与 IPv6 盲点提醒。","\u002Fposts\u002Fip-geolocation-guide\u002Fimg\u002Fcover.svg",{},"\u002Fposts\u002Fip-geolocation-guide","2026-08-07T12:00:00",{"title":9497,"description":10661},"posts\u002Fip-geolocation-guide\u002Findex",[10669,10670,10671],"IP","网络","科普","2026-08-16","RY1UPgJaiKsjpC51uqMyG7w_GpCRS13_CX5j1hDD_dM",{"id":10675,"title":10676,"author":6,"body":10677,"description":12199,"difficulty":1272,"draft":451,"extension":452,"image":12200,"meta":12201,"navigation":393,"path":12202,"pinned":451,"published":12203,"seo":12204,"series":5756,"seriesOrder":231,"stem":12205,"tags":12206,"updated":10672,"__hash__":12209},"posts\u002Fposts\u002Fgo-embed-react-single-binary\u002Findex.md","Go 单文件打包进阶：React 前端 + 交叉编译，一次踩完所有坑",{"type":8,"value":10678,"toc":12178},[10679,10681,10692,10703,10710,10713,10749,10751,10760,10762,10766,10776,10820,10862,10880,10884,10905,10936,10940,11232,11235,11273,11277,11620,11623,11627,11824,11834,11836,11847,11861,11874,11885,11889,11896,11912,11925,11930,11934,11950,11955,12000,12014,12025,12029,12044,12053,12075,12084,12088,12098,12100,12127,12136,12138,12141,12168,12175],[11,10680,2234],{"id":2234},[16,10682,10683,10684,10687,10688,10691],{},"这是「Go 单文件打包」系列第三篇。上一篇（",[421,10685,10686],{"href":7516},"把前端塞进二进制","）记录了一个 Gin + Vue 3 项目的 embed 方案。这次换了个项目继续踩坑：",[20,10689,10690],{},"Magic Img","——一个自托管、本地优先的 AI 生图提示词管理工具，技术栈是：",[120,10693,10694,10697],{},[123,10695,10696],{},"前端：React 19 + TypeScript + Vite + Tailwind v4（UI 库 Magic UI + shadcn\u002Fui）",[123,10698,10699,10700,6300],{},"后端：Go + Gin + GORM + SQLite（",[20,10701,10702],{},"modernc 纯 Go 驱动，无 cgo",[16,10704,10705,10706,10709],{},"目标一样：",[20,10707,10708],{},"一个二进制拖到任何机器上就能跑","——前端、API、数据库全部内嵌。",[16,10711,10712],{},"和上篇不同的地方有三个，正好是这篇要讲的重点：",[721,10714,10715,10729,10742],{},[123,10716,10717,10718,10724,10725,10728],{},"前端用了 ",[20,10719,10720,10721],{},"HashRouter + Vite base ",[197,10722,10723],{},"\u002Fdist","，服务端",[20,10726,10727],{},"不再需要 SPA fallback","（上篇坑一的新解法）；",[123,10730,10731,10732,10738,10739,10741],{},"embed 用 ",[20,10733,10734,10737],{},[197,10735,10736],{},"all:"," 前缀","一劳永逸解决 ",[197,10740,7210],{}," 开头文件被排除的问题（上篇坑二的升级解法）；",[123,10743,10744,10745,10748],{},"需要",[20,10746,10747],{},"在 Windows 上交叉编译 Linux 产物","，踩了「生成的文件无法在 Linux 执行」的坑。",[11,10750,6566],{"id":6566},[16,10752,10753,10754,10756,10757,10759],{},"和上篇一致：",[197,10755,6572],{}," 在编译期把前端 ",[197,10758,6576],{}," 塞进二进制，运行时由 Go 直接提供静态服务。差异在于路由策略和缓存策略，后面细说。",[11,10761,6580],{"id":6580},[717,10763,10765],{"id":10764},"_1-前端hash-路由-base-dist","1. 前端：hash 路由 + base \u002Fdist",[16,10767,10768,10769,10772,10773,3510],{},"vite.config.ts 里固定 ",[197,10770,10771],{},"base: \"\u002Fdist\u002F\"","，路由用 ",[197,10774,10775],{},"HashRouter",[190,10777,10779],{"className":7261,"code":10778,"language":7263,"meta":195,"style":195},"\u002F\u002F vite.config.ts\nexport default defineConfig({\n  base: \"\u002Fdist\u002F\",   \u002F\u002F 所有资源路径以 \u002Fdist\u002F 开头\n  ...\n})\n",[197,10780,10781,10785,10797,10811,10816],{"__ignoreMap":195},[200,10782,10783],{"class":202,"line":203},[200,10784,7270],{"class":206},[200,10786,10787,10789,10791,10794],{"class":202,"line":210},[200,10788,214],{"class":213},[200,10790,3110],{"class":213},[200,10792,10793],{"class":290}," defineConfig",[200,10795,10796],{"class":227},"({\n",[200,10798,10799,10802,10805,10808],{"class":202,"line":231},[200,10800,10801],{"class":227},"  base: ",[200,10803,10804],{"class":237},"\"\u002Fdist\u002F\"",[200,10806,10807],{"class":227},",   ",[200,10809,10810],{"class":206},"\u002F\u002F 所有资源路径以 \u002Fdist\u002F 开头\n",[200,10812,10813],{"class":202,"line":244},[200,10814,10815],{"class":213},"  ...\n",[200,10817,10818],{"class":202,"line":255},[200,10819,2915],{"class":227},[190,10821,10825],{"className":10822,"code":10823,"language":10824,"meta":195,"style":195},"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",[197,10826,10827,10832,10840,10854],{"__ignoreMap":195},[200,10828,10829],{"class":202,"line":203},[200,10830,10831],{"class":206},"\u002F\u002F App.tsx —— HashRouter：路径信息全在 location.hash 里\n",[200,10833,10834,10836,10838],{"class":202,"line":210},[200,10835,1412],{"class":227},[200,10837,10775],{"class":220},[200,10839,1070],{"class":227},[200,10841,10842,10844,10847,10850,10852],{"class":202,"line":231},[200,10843,2942],{"class":227},[200,10845,10846],{"class":220},"Routes",[200,10848,10849],{"class":227},">...\u003C\u002F",[200,10851,10846],{"class":220},[200,10853,1070],{"class":227},[200,10855,10856,10858,10860],{"class":202,"line":244},[200,10857,2920],{"class":227},[200,10859,10775],{"class":220},[200,10861,1070],{"class":227},[16,10863,10864,10867,10868,10871,10872,10875,10876,10879],{},[20,10865,10866],{},"为什么这样选","：history 路由需要服务端把未知路径全部 fallback 回 ",[197,10869,10870],{},"index.html","（上篇坑一）。hash 路由下浏览器请求的永远是 ",[197,10873,10874],{},"\u002Fdist\u002F"," 这一个入口，",[20,10877,10878],{},"静态服务直接返回 index.html 就完事，一个 fallback 都不用写","。内嵌场景里这是最省心的组合。",[717,10881,10883],{"id":10882},"_2-构建产物复制进后端目录","2. 构建产物复制进后端目录",[16,10885,10886,10889,10890,10892,10893,10896,10897,10900,10901,10904],{},[197,10887,10888],{},"npm run build"," 除了编译，还会把 ",[197,10891,6576],{}," 复制到 ",[197,10894,10895],{},"backend\u002Fweb\u002Fdist","（一个小脚本 ",[197,10898,10899],{},"scripts\u002Fcopy-dist.mjs"," 干这个活），供 ",[197,10902,10903],{},"go:embed"," 使用：",[190,10906,10908],{"className":5583,"code":10907,"language":5585,"meta":195,"style":195},"\u002F\u002F frontend\u002Fpackage.json\n\"scripts\": {\n  \"build\": \"tsc -b && vite build && node scripts\u002Fcopy-dist.mjs\"\n}\n",[197,10909,10910,10915,10922,10932],{"__ignoreMap":195},[200,10911,10912],{"class":202,"line":203},[200,10913,10914],{"class":206},"\u002F\u002F frontend\u002Fpackage.json\n",[200,10916,10917,10920],{"class":202,"line":210},[200,10918,10919],{"class":237},"\"scripts\"",[200,10921,2286],{"class":227},[200,10923,10924,10927,10929],{"class":202,"line":231},[200,10925,10926],{"class":220},"  \"build\"",[200,10928,300],{"class":227},[200,10930,10931],{"class":237},"\"tsc -b && vite build && node scripts\u002Fcopy-dist.mjs\"\n",[200,10933,10934],{"class":202,"line":244},[200,10935,275],{"class":227},[717,10937,10939],{"id":10938},"_3-后端embed-httpfilesystem-包装","3. 后端：embed + http.FileSystem 包装",[190,10941,10943],{"className":3791,"code":10942,"language":608,"meta":195,"style":195},"\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",[197,10944,10945,10950,10956,10960,10966,10974,10982,10990,10994,10998,11003,11016,11020,11025,11062,11066,11078,11086,11099,11103,11107,11146,11160,11170,11180,11207,11211,11228],{"__ignoreMap":195},[200,10946,10947],{"class":202,"line":203},[200,10948,10949],{"class":206},"\u002F\u002F backend\u002Fweb\u002Fembed.go\n",[200,10951,10952,10954],{"class":202,"line":210},[200,10953,6599],{"class":213},[200,10955,6602],{"class":290},[200,10957,10958],{"class":202,"line":231},[200,10959,532],{"emptyLinePlaceholder":393},[200,10961,10962,10964],{"class":202,"line":244},[200,10963,3799],{"class":213},[200,10965,5197],{"class":227},[200,10967,10968,10970,10972],{"class":202,"line":255},[200,10969,6617],{"class":237},[200,10971,6620],{"class":290},[200,10973,715],{"class":237},[200,10975,10976,10978,10980],{"class":202,"line":266},[200,10977,6617],{"class":237},[200,10979,6647],{"class":290},[200,10981,715],{"class":237},[200,10983,10984,10986,10988],{"class":202,"line":272},[200,10985,6617],{"class":237},[200,10987,6665],{"class":290},[200,10989,715],{"class":237},[200,10991,10992],{"class":202,"line":599},[200,10993,2677],{"class":227},[200,10995,10996],{"class":202,"line":605},[200,10997,532],{"emptyLinePlaceholder":393},[200,10999,11000],{"class":202,"line":619},[200,11001,11002],{"class":206},"\u002F\u002Fgo:embed all:dist\n",[200,11004,11005,11007,11010,11012,11014],{"class":202,"line":642},[200,11006,6698],{"class":213},[200,11008,11009],{"class":227}," distFS ",[200,11011,6620],{"class":290},[200,11013,2489],{"class":227},[200,11015,6708],{"class":290},[200,11017,11018],{"class":202,"line":651},[200,11019,532],{"emptyLinePlaceholder":393},[200,11021,11022],{"class":202,"line":656},[200,11023,11024],{"class":206},"\u002F\u002F Dist 内嵌前端静态文件系统\n",[200,11026,11027,11029,11032,11035,11037,11040,11042,11045,11048,11051,11053,11056,11059],{"class":202,"line":662},[200,11028,6698],{"class":213},[200,11030,11031],{"class":227}," Dist ",[200,11033,11034],{"class":290},"http",[200,11036,2489],{"class":227},[200,11038,11039],{"class":290},"FileSystem",[200,11041,224],{"class":213},[200,11043,11044],{"class":213}," &",[200,11046,11047],{"class":290},"fileSystem",[200,11049,11050],{"class":227},"{prefix: ",[200,11052,6748],{"class":237},[200,11054,11055],{"class":227},", fs: ",[200,11057,11058],{"class":213},"&",[200,11060,11061],{"class":227},"distFS}\n",[200,11063,11064],{"class":202,"line":673},[200,11065,532],{"emptyLinePlaceholder":393},[200,11067,11068,11070,11073,11076],{"class":202,"line":684},[200,11069,5184],{"class":213},[200,11071,11072],{"class":290}," fileSystem",[200,11074,11075],{"class":213}," struct",[200,11077,228],{"class":227},[200,11079,11080,11083],{"class":202,"line":689},[200,11081,11082],{"class":227},"    prefix ",[200,11084,11085],{"class":213},"string\n",[200,11087,11088,11091,11093,11095,11097],{"class":202,"line":698},[200,11089,11090],{"class":227},"    fs     ",[200,11092,4024],{"class":213},[200,11094,6620],{"class":290},[200,11096,2489],{"class":227},[200,11098,6708],{"class":290},[200,11100,11101],{"class":202,"line":703},[200,11102,275],{"class":227},[200,11104,11105],{"class":202,"line":912},[200,11106,532],{"emptyLinePlaceholder":393},[200,11108,11109,11111,11113,11116,11118,11120,11122,11125,11127,11129,11131,11133,11135,11137,11140,11142,11144],{"class":202,"line":920},[200,11110,3992],{"class":213},[200,11112,2409],{"class":227},[200,11114,11115],{"class":2370},"f ",[200,11117,4024],{"class":213},[200,11119,11047],{"class":290},[200,11121,2831],{"class":227},[200,11123,11124],{"class":290},"Open",[200,11126,2367],{"class":227},[200,11128,7314],{"class":2370},[200,11130,10273],{"class":213},[200,11132,4021],{"class":227},[200,11134,11034],{"class":290},[200,11136,2489],{"class":227},[200,11138,11139],{"class":290},"File",[200,11141,337],{"class":227},[200,11143,4037],{"class":213},[200,11145,3619],{"class":227},[200,11147,11148,11150,11153,11155,11158],{"class":202,"line":925},[200,11149,2444],{"class":213},[200,11151,11152],{"class":227}," name ",[200,11154,4892],{"class":213},[200,11156,11157],{"class":237}," \"\u002F\"",[200,11159,228],{"class":227},[200,11161,11162,11165,11167],{"class":202,"line":1556},[200,11163,11164],{"class":227},"        name ",[200,11166,575],{"class":213},[200,11168,11169],{"class":227}," f.prefix\n",[200,11171,11172,11175,11178],{"class":202,"line":1570},[200,11173,11174],{"class":227},"    } ",[200,11176,11177],{"class":213},"else",[200,11179,228],{"class":227},[200,11181,11182,11184,11186,11189,11191,11193,11196,11198,11200,11203,11205],{"class":202,"line":1577},[200,11183,11164],{"class":227},[200,11185,575],{"class":213},[200,11187,11188],{"class":227}," f.prefix ",[200,11190,3185],{"class":213},[200,11192,11157],{"class":237},[200,11194,11195],{"class":213}," +",[200,11197,4954],{"class":227},[200,11199,6786],{"class":290},[200,11201,11202],{"class":227},"(name, ",[200,11204,6792],{"class":237},[200,11206,2677],{"class":227},[200,11208,11209],{"class":202,"line":1582},[200,11210,2327],{"class":227},[200,11212,11213,11215,11217,11220,11223,11225],{"class":202,"line":1588},[200,11214,4049],{"class":213},[200,11216,9817],{"class":227},[200,11218,11219],{"class":290},"FS",[200,11221,11222],{"class":227},"(f.fs).",[200,11224,11124],{"class":290},[200,11226,11227],{"class":227},"(name)\n",[200,11229,11230],{"class":202,"line":1596},[200,11231,275],{"class":227},[16,11233,11234],{},"两个细节：",[120,11236,11237,11260],{},[123,11238,11239,11247,11248,7227,11250,11252,11253,11255,11256,11259],{},[20,11240,11241,11244,11245,10737],{},[197,11242,11243],{},"all:dist"," 里的 ",[197,11246,10736],{},"：embed 目录时默认会排除 ",[197,11249,7210],{},[197,11251,2489],{}," 开头的文件（上篇坑二就是被这个坑了，当时靠改 Vite 输出名绕开）。",[197,11254,10736],{}," 前缀表示「全部包含」，前端构建产物里那些 ",[197,11257,11258],{},"_xxx.js"," 助手文件直接安全落地，不用再改前端配置；",[123,11261,11262,11265,11266,11268,11269,11272],{},[197,11263,11264],{},"http.FileSystem"," 包装：加了一层 ",[197,11267,6576],{}," 前缀映射，",[197,11270,11271],{},"http.FileServer"," 和 Gin 都能直接用。",[717,11274,11276],{"id":11275},"_4-路由静态服务-缓存策略","4. 路由：静态服务 + 缓存策略",[190,11278,11280],{"className":3791,"code":11279,"language":608,"meta":195,"style":195},"\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",[197,11281,11282,11287,11329,11341,11346,11361,11406,11435,11453,11474,11479,11498,11521,11526,11543,11547,11568,11572,11612,11616],{"__ignoreMap":195},[200,11283,11284],{"class":202,"line":203},[200,11285,11286],{"class":206},"\u002F\u002F backend\u002Finternal\u002Fhandler\u002Frouter.go\n",[200,11288,11289,11291,11294,11296,11299,11301,11304,11306,11309,11311,11314,11316,11318,11320,11322,11324,11327],{"class":202,"line":210},[200,11290,3992],{"class":213},[200,11292,11293],{"class":290}," NewRouter",[200,11295,2367],{"class":227},[200,11297,11298],{"class":2370},"db",[200,11300,4617],{"class":213},[200,11302,11303],{"class":290},"gorm",[200,11305,2489],{"class":227},[200,11307,11308],{"class":290},"DB",[200,11310,337],{"class":227},[200,11312,11313],{"class":2370},"uploadDir",[200,11315,10273],{"class":213},[200,11317,2831],{"class":227},[200,11319,4024],{"class":213},[200,11321,4620],{"class":290},[200,11323,2489],{"class":227},[200,11325,11326],{"class":290},"Engine",[200,11328,228],{"class":227},[200,11330,11331,11333,11335,11337,11339],{"class":202,"line":231},[200,11332,4433],{"class":227},[200,11334,3818],{"class":213},[200,11336,4438],{"class":227},[200,11338,4441],{"class":290},[200,11340,2562],{"class":227},[200,11342,11343],{"class":202,"line":244},[200,11344,11345],{"class":213},"    ...\n",[200,11347,11348,11351,11353,11355,11358],{"class":202,"line":255},[200,11349,11350],{"class":227},"    dist ",[200,11352,3818],{"class":213},[200,11354,9817],{"class":227},[200,11356,11357],{"class":290},"FileServer",[200,11359,11360],{"class":227},"(web.Dist)\n",[200,11362,11363,11365,11367,11369,11372,11374,11376,11378,11380,11382,11384,11386,11388,11391,11394,11396,11399,11401,11403],{"class":202,"line":266},[200,11364,4571],{"class":227},[200,11366,4600],{"class":290},[200,11368,2367],{"class":227},[200,11370,11371],{"class":237},"\"\u002Fdist\"",[200,11373,337],{"class":227},[200,11375,3992],{"class":213},[200,11377,2367],{"class":227},[200,11379,4614],{"class":2370},[200,11381,4617],{"class":213},[200,11383,4620],{"class":290},[200,11385,2489],{"class":227},[200,11387,4005],{"class":290},[200,11389,11390],{"class":227},") { c.",[200,11392,11393],{"class":290},"Redirect",[200,11395,2367],{"class":227},[200,11397,11398],{"class":220},"302",[200,11400,337],{"class":227},[200,11402,10804],{"class":237},[200,11404,11405],{"class":227},") })\n",[200,11407,11408,11410,11412,11414,11417,11419,11421,11423,11425,11427,11429,11431,11433],{"class":202,"line":272},[200,11409,4571],{"class":227},[200,11411,4600],{"class":290},[200,11413,2367],{"class":227},[200,11415,11416],{"class":237},"\"\u002Fdist\u002F*filepath\"",[200,11418,337],{"class":227},[200,11420,3992],{"class":213},[200,11422,2367],{"class":227},[200,11424,4614],{"class":2370},[200,11426,4617],{"class":213},[200,11428,4620],{"class":290},[200,11430,2489],{"class":227},[200,11432,4005],{"class":290},[200,11434,3619],{"class":227},[200,11436,11437,11439,11441,11443,11446,11448,11451],{"class":202,"line":599},[200,11438,6779],{"class":227},[200,11440,3818],{"class":213},[200,11442,4871],{"class":227},[200,11444,11445],{"class":290},"Param",[200,11447,2367],{"class":227},[200,11449,11450],{"class":237},"\"filepath\"",[200,11452,2677],{"class":227},[200,11454,11455,11457,11459,11461,11463,11465,11467,11469,11472],{"class":202,"line":605},[200,11456,4886],{"class":213},[200,11458,6801],{"class":227},[200,11460,4892],{"class":213},[200,11462,11157],{"class":237},[200,11464,4987],{"class":213},[200,11466,6801],{"class":227},[200,11468,4892],{"class":213},[200,11470,11471],{"class":237}," \"\u002Findex.html\"",[200,11473,228],{"class":227},[200,11475,11476],{"class":202,"line":619},[200,11477,11478],{"class":206},"            \u002F\u002F index.html 禁缓存：升级后浏览器立刻拿到新入口\n",[200,11480,11481,11483,11486,11488,11491,11493,11496],{"class":202,"line":642},[200,11482,4902],{"class":227},[200,11484,11485],{"class":290},"Header",[200,11487,2367],{"class":227},[200,11489,11490],{"class":237},"\"Cache-Control\"",[200,11492,337],{"class":227},[200,11494,11495],{"class":237},"\"no-cache, no-store, must-revalidate\"",[200,11497,2677],{"class":227},[200,11499,11500,11503,11505,11508,11510,11513,11516,11519],{"class":202,"line":651},[200,11501,11502],{"class":227},"        } ",[200,11504,11177],{"class":213},[200,11506,11507],{"class":213}," if",[200,11509,4954],{"class":227},[200,11511,11512],{"class":290},"HasPrefix",[200,11514,11515],{"class":227},"(p, ",[200,11517,11518],{"class":237},"\"\u002Fassets\u002F\"",[200,11520,3619],{"class":227},[200,11522,11523],{"class":202,"line":656},[200,11524,11525],{"class":206},"            \u002F\u002F 带内容 hash 的资源可放心长缓存\n",[200,11527,11528,11530,11532,11534,11536,11538,11541],{"class":202,"line":662},[200,11529,4902],{"class":227},[200,11531,11485],{"class":290},[200,11533,2367],{"class":227},[200,11535,11490],{"class":237},[200,11537,337],{"class":227},[200,11539,11540],{"class":237},"\"public, max-age=31536000, immutable\"",[200,11542,2677],{"class":227},[200,11544,11545],{"class":202,"line":673},[200,11546,4940],{"class":227},[200,11548,11549,11552,11555,11557,11559,11562,11565],{"class":202,"line":684},[200,11550,11551],{"class":227},"        http.",[200,11553,11554],{"class":290},"StripPrefix",[200,11556,2367],{"class":227},[200,11558,11371],{"class":237},[200,11560,11561],{"class":227},", dist).",[200,11563,11564],{"class":290},"ServeHTTP",[200,11566,11567],{"class":227},"(c.Writer, c.Request)\n",[200,11569,11570],{"class":202,"line":689},[200,11571,4666],{"class":227},[200,11573,11574,11576,11578,11580,11582,11584,11586,11588,11590,11592,11594,11596,11598,11600,11602,11604,11606,11608,11610],{"class":202,"line":698},[200,11575,4571],{"class":227},[200,11577,4600],{"class":290},[200,11579,2367],{"class":227},[200,11581,6792],{"class":237},[200,11583,337],{"class":227},[200,11585,3992],{"class":213},[200,11587,2367],{"class":227},[200,11589,4614],{"class":2370},[200,11591,4617],{"class":213},[200,11593,4620],{"class":290},[200,11595,2489],{"class":227},[200,11597,4005],{"class":290},[200,11599,11390],{"class":227},[200,11601,11393],{"class":290},[200,11603,2367],{"class":227},[200,11605,11398],{"class":220},[200,11607,337],{"class":227},[200,11609,10804],{"class":237},[200,11611,11405],{"class":227},[200,11613,11614],{"class":202,"line":703},[200,11615,11345],{"class":213},[200,11617,11618],{"class":202,"line":912},[200,11619,275],{"class":227},[16,11621,11622],{},"缓存策略是内嵌发布的必修课（坑二细说）：入口禁缓存、hash 资源长缓存。",[717,11624,11626],{"id":11625},"_5-打包脚本windows-版-linux-交叉编译版","5. 打包脚本：Windows 版 + Linux 交叉编译版",[190,11628,11630],{"className":356,"code":11629,"language":358,"meta":195,"style":195},"#!\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",[197,11631,11632,11637,11642,11652,11670,11674,11695,11699,11707,11726,11730,11737,11794,11809,11813],{"__ignoreMap":195},[200,11633,11634],{"class":202,"line":203},[200,11635,11636],{"class":206},"#!\u002Fusr\u002Fbin\u002Fenv bash\n",[200,11638,11639],{"class":202,"line":210},[200,11640,11641],{"class":206},"# build-linux.sh —— 在 Windows\u002FmacOS\u002FLinux 任意平台交叉编译 linux\u002Famd64 产物\n",[200,11643,11644,11646,11649],{"class":202,"line":231},[200,11645,7057],{"class":220},[200,11647,11648],{"class":220}," -euo",[200,11650,11651],{"class":237}," pipefail\n",[200,11653,11654,11656,11659,11662,11664,11667],{"class":202,"line":244},[200,11655,7136],{"class":220},[200,11657,11658],{"class":237}," \"$(",[200,11660,11661],{"class":290},"dirname",[200,11663,509],{"class":237},[200,11665,11666],{"class":220},"$0",[200,11668,11669],{"class":237},"\")\"\n",[200,11671,11672],{"class":202,"line":255},[200,11673,532],{"emptyLinePlaceholder":393},[200,11675,11676,11679,11681,11684,11686,11689,11692],{"class":202,"line":266},[200,11677,11678],{"class":227},"BINARY",[200,11680,575],{"class":213},[200,11682,11683],{"class":237},"\"${",[200,11685,11678],{"class":227},[200,11687,11688],{"class":213},":-",[200,11690,11691],{"class":227},"magic-img",[200,11693,11694],{"class":237},"}\"\n",[200,11696,11697],{"class":202,"line":272},[200,11698,532],{"emptyLinePlaceholder":393},[200,11700,11701,11704],{"class":202,"line":599},[200,11702,11703],{"class":220},"echo",[200,11705,11706],{"class":237}," \"[1\u002F3] 构建前端并复制产物到 backend\u002Fweb\u002Fdist ...\"\n",[200,11708,11709,11711,11713,11716,11718,11720,11722,11724],{"class":202,"line":605},[200,11710,2367],{"class":227},[200,11712,7136],{"class":220},[200,11714,11715],{"class":237}," frontend",[200,11717,548],{"class":227},[200,11719,5930],{"class":290},[200,11721,497],{"class":237},[200,11723,624],{"class":237},[200,11725,2677],{"class":227},[200,11727,11728],{"class":202,"line":619},[200,11729,532],{"emptyLinePlaceholder":393},[200,11731,11732,11734],{"class":202,"line":642},[200,11733,11703],{"class":220},[200,11735,11736],{"class":237}," \"[2\u002F3] 交叉编译后端并内嵌前端 (CGO_ENABLED=0 GOOS=linux GOARCH=amd64) ...\"\n",[200,11738,11739,11741,11743,11746,11749,11751,11753,11756,11758,11761,11764,11766,11769,11771,11773,11775,11777,11780,11783,11786,11789,11792],{"class":202,"line":651},[200,11740,2367],{"class":227},[200,11742,7136],{"class":220},[200,11744,11745],{"class":237}," backend",[200,11747,11748],{"class":227}," && CGO_ENABLED",[200,11750,575],{"class":213},[200,11752,2455],{"class":237},[200,11754,11755],{"class":227}," GOOS",[200,11757,575],{"class":213},[200,11759,11760],{"class":237},"linux",[200,11762,11763],{"class":227}," GOARCH",[200,11765,575],{"class":213},[200,11767,11768],{"class":237},"amd64",[200,11770,977],{"class":290},[200,11772,624],{"class":237},[200,11774,633],{"class":220},[200,11776,509],{"class":237},[200,11778,11779],{"class":227},"$BINARY",[200,11781,11782],{"class":237},"\"",[200,11784,11785],{"class":220}," -ldflags=",[200,11787,11788],{"class":237},"\"-s -w\"",[200,11790,11791],{"class":237}," .\u002Fcmd\u002Fserver",[200,11793,2677],{"class":227},[200,11795,11796,11799,11802,11805,11807],{"class":202,"line":656},[200,11797,11798],{"class":290},"chmod",[200,11800,11801],{"class":237}," +x",[200,11803,11804],{"class":237}," \"backend\u002F",[200,11806,11779],{"class":227},[200,11808,715],{"class":237},[200,11810,11811],{"class":202,"line":662},[200,11812,532],{"emptyLinePlaceholder":393},[200,11814,11815,11817,11820,11822],{"class":202,"line":673},[200,11816,11703],{"class":220},[200,11818,11819],{"class":237}," \"[3\u002F3] 打包完成: backend\u002F",[200,11821,11779],{"class":227},[200,11823,715],{"class":237},[16,11825,11826,11827,11830,11831,2258],{},"Windows 版（",[197,11828,11829],{},"build-win.bat","）同理，只是产物叫 ",[197,11832,11833],{},"magic-img.exe",[11,11835,7170],{"id":7170},[717,11837,11839,11840,11843,11844,11846],{"id":11838},"坑一goembed-dist-静默漏掉-_-开头文件升级解法","坑一：",[197,11841,11842],{},"\u002F\u002Fgo:embed dist"," 静默漏掉 ",[197,11845,7210],{}," 开头文件（升级解法）",[16,11848,11849,11850,11857,11858,11860],{},"上篇说过：embed 目录时",[20,11851,11852,11853,7227,11855,7211],{},"硬编码排除 ",[197,11854,7210],{},[197,11856,2489],{},"。上篇的解法是改 Vite 输出文件名去掉 ",[197,11859,7210],{}," 前缀；这次直接用：",[190,11862,11864],{"className":3791,"code":11863,"language":608,"meta":195,"style":195},"\u002F\u002Fgo:embed all:dist   \u002F\u002F all: 前缀 = 包含全部文件，不再静默跳过\n",[197,11865,11866],{"__ignoreMap":195},[200,11867,11868,11871],{"class":202,"line":203},[200,11869,11870],{"class":206},"\u002F\u002Fgo:embed all:dist",[200,11872,11873],{"class":206},"   \u002F\u002F all: 前缀 = 包含全部文件，不再静默跳过\n",[16,11875,11876],{},[20,11877,11878,11879,11881,11882,11884],{},"结论：能用 ",[197,11880,10736],{}," 就用 ",[197,11883,10736],{},"，从根上消灭这类\"文件莫名 404\"的问题。",[717,11886,11888],{"id":11887},"坑二开发版是新的打包版却是旧的","坑二：开发版是新的、打包版却是旧的",[16,11890,11891,11892,11895],{},"现象：Vite 开发服务一切正常，",[197,11893,11894],{},":8080\u002Fdist\u002F"," 打包版却是旧界面，强刷也不一定好。",[16,11897,11898,11899,11901,11902,11905,11906,11911],{},"排查过程值得记录：先是怀疑 ",[197,11900,7373],{}," 缓存了旧 embed（上篇坑三的思路），重编重启无效；然后直接抓取 8080 返回的 HTML 和 JS，发现",[20,11903,11904],{},"服务端内容已经是新的","——问题出在",[20,11907,11908,11909],{},"浏览器缓存了 ",[197,11910,10870],{},"：它引用旧 hash 的资源，一直不更新。",[16,11913,11914,11916,11917,11920,11921,11924],{},[20,11915,7188],{},"：入口文件响应 ",[197,11918,11919],{},"Cache-Control: no-store","（见上面路由代码），带 hash 的资源给 ",[197,11922,11923],{},"immutable"," 长缓存。升级后浏览器每次拉新入口，资源文件名一变就自动换新，两全其美。",[9523,11926,11927],{},[16,11928,11929],{},"教训：排查\"新旧不一致\"先分清是哪一层——服务端产物、浏览器入口缓存、还是资源缓存。抓包看响应内容再动手。",[717,11931,11933],{"id":11932},"坑三windows-上跑的-build-linuxsh产出的文件-linux-却执行不了","坑三：Windows 上跑的 build-linux.sh，产出的文件 Linux 却执行不了",[16,11935,11936,11937,6417,11940,11946,11947,11949],{},"最初脚本只写了 ",[197,11938,11939],{},"CGO_ENABLED=0 go build",[20,11941,11942,11943],{},"没指定 ",[197,11944,11945],{},"GOOS\u002FGOARCH","。在 Windows 上跑，产出的其实是个 Windows PE 文件（只是没叫 ",[197,11948,7675],{},"），传到 Linux 自然「无法执行」。",[16,11951,11952,11954],{},[20,11953,7188],{},"：脚本里显式交叉编译：",[190,11956,11958],{"className":356,"code":11957,"language":358,"meta":195,"style":195},"CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o \"$BINARY\" -ldflags=\"-s -w\" .\u002Fcmd\u002Fserver\n",[197,11959,11960],{"__ignoreMap":195},[200,11961,11962,11965,11967,11969,11971,11973,11975,11977,11979,11981,11983,11985,11987,11989,11991,11993,11995,11997],{"class":202,"line":203},[200,11963,11964],{"class":227},"CGO_ENABLED",[200,11966,575],{"class":213},[200,11968,2455],{"class":237},[200,11970,11755],{"class":227},[200,11972,575],{"class":213},[200,11974,11760],{"class":237},[200,11976,11763],{"class":227},[200,11978,575],{"class":213},[200,11980,11768],{"class":237},[200,11982,977],{"class":290},[200,11984,624],{"class":237},[200,11986,633],{"class":220},[200,11988,509],{"class":237},[200,11990,11779],{"class":227},[200,11992,11782],{"class":237},[200,11994,11785],{"class":220},[200,11996,11788],{"class":237},[200,11998,11999],{"class":237}," .\u002Fcmd\u002Fserver\n",[16,12001,12002,12003,12006,12007,12010,12011],{},"能这么干的前提是",[20,12004,12005],{},"全依赖树无 cgo","——SQLite 用的是 modernc 纯 Go 驱动，bcrypt 用 ",[197,12008,12009],{},"x\u002Fcrypto","。这也是选型时就要留意的点：",[20,12012,12013],{},"想跨平台交叉编译，先把 cgo 依赖清干净。",[16,12015,12016,12017,12020,12021,12024],{},"另外两个小提醒：Windows 上编出的文件没有 Unix 执行位，上传服务器后记得 ",[197,12018,12019],{},"chmod +x","；",[197,12022,12023],{},"-ldflags=\"-s -w\""," 剥离符号，体积从 30MB+ 降到 26MB。",[717,12026,12028],{"id":12027},"坑四dist-被-gitignore-后embed-直接编译失败","坑四：dist 被 gitignore 后，embed 直接编译失败",[16,12030,12031,12033,12034,12036,12037,12040,12041,12043],{},[197,12032,10895],{}," 是构建产物、入了 .gitignore，但 ",[197,12035,6572],{}," 在",[20,12038,12039],{},"编译期","就要读它——刚 clone 下来直接 ",[197,12042,7373],{}," 会报 embed 找不到文件。",[16,12045,12046,12048,12049,12052],{},[20,12047,7188],{},"：gitignore 里保留 ",[197,12050,12051],{},"dist\u002F.gitkeep","（一个空文件），让目录存在于版本库中：",[190,12054,12058],{"className":12055,"code":12056,"language":12057,"meta":195,"style":195},"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",[197,12059,12060,12065,12070],{"__ignoreMap":195},[200,12061,12062],{"class":202,"line":203},[200,12063,12064],{},"backend\u002Fweb\u002Fdist\u002F*\n",[200,12066,12067],{"class":202,"line":210},[200,12068,12069],{},"!backend\u002Fweb\u002Fdist\u002F\n",[200,12071,12072],{"class":202,"line":231},[200,12073,12074],{},"!backend\u002Fweb\u002Fdist\u002F.gitkeep\n",[16,12076,12077,12078,12080,12081,12083],{},"这样 clone 后可以先 ",[197,12079,7373],{}," 通过，再 ",[197,12082,10888],{}," 灌入真实前端。",[717,12085,12087],{"id":12086},"坑五500kb-chunk-警告别慌","坑五：>500KB chunk 警告，别慌",[16,12089,12090,12091,12094,12095,2258],{},"Vite 构建会警告单 chunk 超过 500KB。这个项目里 Base UI 组件库全量引入，单 chunk 约 640KB——",[20,12092,12093],{},"属正常，不要为了消警告去拆配置","。真要优化就上路由级 ",[197,12096,12097],{},"React.lazy",[11,12099,7386],{"id":7386},[190,12101,12103],{"className":356,"code":12102,"language":358,"meta":195,"style":195},"$ build-win.bat        # backendmagic-img.exe\n$ bash build-linux.sh  # backendmagic-img（linux\u002Famd64 ELF）\n",[197,12104,12105,12115],{"__ignoreMap":195},[200,12106,12107,12109,12112],{"class":202,"line":203},[200,12108,2483],{"class":290},[200,12110,12111],{"class":237}," build-win.bat",[200,12113,12114],{"class":206},"        # backendmagic-img.exe\n",[200,12116,12117,12119,12121,12124],{"class":202,"line":210},[200,12118,2483],{"class":290},[200,12120,7398],{"class":237},[200,12122,12123],{"class":237}," build-linux.sh",[200,12125,12126],{"class":206},"  # backendmagic-img（linux\u002Famd64 ELF）\n",[16,12128,12129,12130,12133,12134,2258],{},"一个约 26MB 的二进制，内嵌 React 前端、Gin API、SQLite 数据初始化，拷到 Windows\u002FLinux 机器直接跑，浏览器访问 ",[197,12131,12132],{},"http:\u002F\u002Flocalhost:8080"," 自动重定向到 ",[197,12135,10874],{},[11,12137,1645],{"id":1645},[16,12139,12140],{},"这轮相比上篇的三点升级：",[721,12142,12143,12149,12159],{},[123,12144,12145,12148],{},[20,12146,12147],{},"hash 路由 + base \u002Fdist","：省掉 SPA fallback，静态服务即开即用；",[123,12150,12151,3510,12156,12158],{},[20,12152,12153,12155],{},[197,12154,10736],{}," embed 前缀",[197,12157,7210],{}," 开头文件问题一劳永逸；",[123,12160,12161,12167],{},[20,12162,12163,12164,12166],{},"显式 ",[197,12165,11945],{}," 交叉编译","：让「一个脚本、任意平台、产出 Linux 二进制」成为现实。",[16,12169,12170,12171,12174],{},"加上",[20,12172,12173],{},"入口 no-store \u002F 资源 immutable"," 的缓存策略，单文件分发的体验才算闭环——不然每次升级都要和浏览器缓存打架。",[437,12176,12177],{},"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":195,"searchDepth":210,"depth":231,"links":12179},[12180,12181,12182,12189,12197,12198],{"id":2234,"depth":210,"text":2234},{"id":6566,"depth":210,"text":6566},{"id":6580,"depth":210,"text":6580,"children":12183},[12184,12185,12186,12187,12188],{"id":10764,"depth":231,"text":10765},{"id":10882,"depth":231,"text":10883},{"id":10938,"depth":231,"text":10939},{"id":11275,"depth":231,"text":11276},{"id":11625,"depth":231,"text":11626},{"id":7170,"depth":210,"text":7170,"children":12190},[12191,12193,12194,12195,12196],{"id":11838,"depth":231,"text":12192},"坑一：\u002F\u002Fgo:embed dist 静默漏掉 _ 开头文件（升级解法）",{"id":11887,"depth":231,"text":11888},{"id":11932,"depth":231,"text":11933},{"id":12027,"depth":231,"text":12028},{"id":12086,"depth":231,"text":12087},{"id":7386,"depth":210,"text":7386},{"id":1645,"depth":210,"text":1645},"以 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":10676,"description":12199},"posts\u002Fgo-embed-react-single-binary\u002Findex",[1282,9492,6620,12207,12208],"交叉编译","打包","WV-T-MOO5Cu-aycw-Ony8s9hjA3_k0S4XU-T0QsCow0",{"id":12211,"title":12212,"author":6,"body":12213,"description":13458,"difficulty":450,"draft":451,"extension":452,"image":13459,"meta":13460,"navigation":393,"path":13461,"pinned":451,"published":12203,"seo":13462,"series":13463,"seriesOrder":203,"stem":13464,"tags":13465,"updated":10672,"__hash__":13469},"posts\u002Fposts\u002Freact-photo-album-masonry\u002Findex.md","用 react-photo-album 给生图图库做瀑布流排版",{"type":8,"value":12214,"toc":13441},[12215,12218,12221,12239,12246,12310,12321,12324,12347,12350,12354,12368,12372,12388,12566,12570,12967,12970,13004,13007,13011,13021,13027,13052,13057,13068,13074,13085,13088,13128,13141,13264,13276,13280,13287,13305,13310,13400,13406,13410,13421,13424,13427,13433,13438],[11,12216,12217],{"id":12217},"技术背景",[16,12219,12220],{},"Magic Img 是我做的一个 AI 生图提示词管理工具（自托管、本地优先），其中「生图图库」页面要把所有生成图片以图片墙的形式展示出来。图片墙有两个天然矛盾：",[721,12222,12223,12229],{},[123,12224,12225,12228],{},[20,12226,12227],{},"等宽网格","（CSS Grid）：整齐，但每行高度由最高的那张决定——不同比例的图片混排时，矮图下面会留下大洞，看起来\"很乱\"；",[123,12230,12231,12234,12235,12238],{},[20,12232,12233],{},"原生比例展示","：图库必须不裁剪（这是产品约束），所以不能用 ",[197,12236,12237],{},"object-cover"," 强行统一比例。",[16,12240,12241,12242,12245],{},"业内的标准解法是 ",[20,12243,12244],{},"Masonry 瀑布流","：按列紧密堆放，每张图保持原始比例，列间高度错落但整体紧凑。实现瀑布流的常见方案有三类：",[34,12247,12248,12262],{},[37,12249,12250],{},[40,12251,12252,12254,12257,12260],{},[43,12253,3046],{},[43,12255,12256],{},"依赖",[43,12258,12259],{},"顺序",[43,12261,5665],{},[50,12263,12264,12281,12294],{},[40,12265,12266,12273,12275,12278],{},[55,12267,12268,12269,12272],{},"CSS ",[197,12270,12271],{},"columns"," 多列",[55,12274,1866],{},[55,12276,12277],{},"纵向阅读序（先下后右）",[55,12279,12280],{},"最新图片不再出现在左上角，时间语义丢失",[40,12282,12283,12286,12288,12291],{},[55,12284,12285],{},"grid row-span 自研",[55,12287,1866],{},[55,12289,12290],{},"保持",[55,12292,12293],{},"需固定列数 + 固定行高，逻辑要自己写",[40,12295,12296,12301,12304,12307],{},[55,12297,12298],{},[20,12299,12300],{},"react-photo-album",[55,12302,12303],{},"~4KB，无依赖",[55,12305,12306],{},"基本保持（逐列最短列填充）",[55,12308,12309],{},"专为相册墙设计，TS 友好，维护活跃",[16,12311,12312,12313,12316,12317,12320],{},"最终选了 ",[20,12314,12315],{},"react-photo-album v3","：体积小、类型齐全，而且它的 Masonry 模式恰好需要「每张图的 ",[197,12318,12319],{},"width\u002Fheight","」——我们的数据库里本来就存了，零额外成本。",[11,12322,12323],{"id":12323},"需求梳理",[721,12325,12326,12332,12338,12341,12344],{},[123,12327,12328,12329,12020],{},"展示全部生成图（21 张起，会持续增长），",[20,12330,12331],{},"原比例、不裁剪",[123,12333,12334,12335,12020],{},"不同尺寸（2304×1728、4608×3456 等）混排时",[20,12336,12337],{},"紧凑、不留洞",[123,12339,12340],{},"顺序基本保持「新图靠前」；",[123,12342,12343],{},"点击任意一张打开大图预览（YARL 灯箱），悬停显示所属提示词；",[123,12345,12346],{},"平板性能：墙上只用 300px 缩略图，原图只进灯箱按需加载。",[11,12348,12349],{"id":12349},"安装与使用",[717,12351,12353],{"id":12352},"_1-安装","1. 安装",[190,12355,12357],{"className":356,"code":12356,"language":358,"meta":195,"style":195},"npm install react-photo-album\n",[197,12358,12359],{"__ignoreMap":195},[200,12360,12361,12363,12365],{"class":202,"line":203},[200,12362,5930],{"class":290},[200,12364,553],{"class":237},[200,12366,12367],{"class":237}," react-photo-album\n",[717,12369,12371],{"id":12370},"_2-准备数据关键输入是宽高","2. 准备数据（关键输入是宽高）",[16,12373,12374,12375,12378,12379,7193,12381,7193,12384,12387],{},"库要求的 ",[197,12376,12377],{},"Photo"," 对象只有三个必需字段：",[197,12380,2907],{},[197,12382,12383],{},"width",[197,12385,12386],{},"height","。我们的接口本来就返回每张图的原始宽高：",[190,12389,12391],{"className":10822,"code":12390,"language":10824,"meta":195,"style":195},"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",[197,12392,12393,12411,12416,12423,12427,12443,12453,12457,12461,12494,12505,12524,12529,12534,12556,12561],{"__ignoreMap":195},[200,12394,12395,12397,12400,12402,12405,12408],{"class":202,"line":203},[200,12396,3799],{"class":213},[200,12398,12399],{"class":227}," PhotoAlbum, { ",[200,12401,5184],{"class":213},[200,12403,12404],{"class":227}," Photo } ",[200,12406,12407],{"class":213},"from",[200,12409,12410],{"class":237}," \"react-photo-album\"\n",[200,12412,12413],{"class":202,"line":210},[200,12414,12415],{"class":206},"\u002F\u002F 必须引入样式表！容器\u002F列的布局规则都在这里（坑 1 详述）\n",[200,12417,12418,12420],{"class":202,"line":231},[200,12419,3799],{"class":213},[200,12421,12422],{"class":237}," \"react-photo-album\u002Fstyles.css\"\n",[200,12424,12425],{"class":202,"line":244},[200,12426,532],{"emptyLinePlaceholder":393},[200,12428,12429,12432,12435,12438,12441],{"class":202,"line":255},[200,12430,12431],{"class":213},"interface",[200,12433,12434],{"class":290}," GalleryPhoto",[200,12436,12437],{"class":213}," extends",[200,12439,12440],{"class":290}," Photo",[200,12442,228],{"class":227},[200,12444,12445,12448,12451],{"class":202,"line":266},[200,12446,12447],{"class":2370},"  prompt_text",[200,12449,12450],{"class":213},"?:",[200,12452,5190],{"class":220},[200,12454,12455],{"class":202,"line":272},[200,12456,275],{"class":227},[200,12458,12459],{"class":202,"line":599},[200,12460,532],{"emptyLinePlaceholder":393},[200,12462,12463,12465,12468,12470,12472,12474,12476,12479,12482,12485,12487,12489,12491],{"class":202,"line":605},[200,12464,2601],{"class":213},[200,12466,12467],{"class":220}," albumPhotos",[200,12469,1409],{"class":213},[200,12471,12434],{"class":290},[200,12473,2389],{"class":227},[200,12475,575],{"class":213},[200,12477,12478],{"class":227}," images.",[200,12480,12481],{"class":290},"map",[200,12483,12484],{"class":227},"((",[200,12486,745],{"class":2370},[200,12488,2831],{"class":227},[200,12490,2795],{"class":213},[200,12492,12493],{"class":227}," ({\n",[200,12495,12496,12499,12502],{"class":202,"line":619},[200,12497,12498],{"class":227},"  key: ",[200,12500,12501],{"class":290},"String",[200,12503,12504],{"class":227},"(img.id),\n",[200,12506,12507,12510,12513,12516,12518,12521],{"class":202,"line":642},[200,12508,12509],{"class":227},"  src: ",[200,12511,12512],{"class":290},"assetUrl",[200,12514,12515],{"class":227},"(img.thumb_path) ",[200,12517,2581],{"class":213},[200,12519,12520],{"class":290}," assetUrl",[200,12522,12523],{"class":227},"(img.file_path),\n",[200,12525,12526],{"class":202,"line":651},[200,12527,12528],{"class":227},"  width: img.width,\n",[200,12530,12531],{"class":202,"line":656},[200,12532,12533],{"class":227},"  height: img.height,\n",[200,12535,12536,12539,12541,12544,12546,12548,12551,12554],{"class":202,"line":662},[200,12537,12538],{"class":227},"  alt: img.prompt_text ",[200,12540,2581],{"class":213},[200,12542,12543],{"class":237}," `生成图 ${",[200,12545,745],{"class":227},[200,12547,2489],{"class":237},[200,12549,12550],{"class":227},"id",[200,12552,12553],{"class":237},"}`",[200,12555,241],{"class":227},[200,12557,12558],{"class":202,"line":673},[200,12559,12560],{"class":227},"  prompt_text: img.prompt_text,\n",[200,12562,12563],{"class":202,"line":684},[200,12564,12565],{"class":227},"}))\n",[717,12567,12569],{"id":12568},"_3-使用-masonry-布局","3. 使用 Masonry 布局",[190,12571,12573],{"className":10822,"code":12572,"language":10824,"meta":195,"style":195},"\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",[197,12574,12575,12582,12592,12660,12674,12687,12697,12721,12731,12752,12770,12798,12805,12839,12847,12857,12873,12883,12893,12898,12908,12925,12930,12939,12948,12953,12957,12962],{"__ignoreMap":195},[200,12576,12577,12579],{"class":202,"line":203},[200,12578,1412],{"class":227},[200,12580,12581],{"class":220},"PhotoAlbum\n",[200,12583,12584,12587,12589],{"class":202,"line":210},[200,12585,12586],{"class":290},"  layout",[200,12588,575],{"class":213},[200,12590,12591],{"class":237},"\"masonry\"\n",[200,12593,12594,12597,12599,12602,12605,12607,12609,12612,12614,12617,12620,12622,12625,12628,12630,12633,12635,12638,12640,12642,12644,12647,12649,12652,12654,12657],{"class":202,"line":231},[200,12595,12596],{"class":290},"  columns",[200,12598,575],{"class":213},[200,12600,12601],{"class":227},"{(",[200,12603,12604],{"class":2370},"cw",[200,12606,2831],{"class":227},[200,12608,2795],{"class":213},[200,12610,12611],{"class":227}," (cw ",[200,12613,1412],{"class":213},[200,12615,12616],{"class":220}," 640",[200,12618,12619],{"class":213}," ?",[200,12621,4984],{"class":220},[200,12623,12624],{"class":213}," :",[200,12626,12627],{"class":227}," cw ",[200,12629,1412],{"class":213},[200,12631,12632],{"class":220}," 960",[200,12634,12619],{"class":213},[200,12636,12637],{"class":220}," 3",[200,12639,12624],{"class":213},[200,12641,12627],{"class":227},[200,12643,1412],{"class":213},[200,12645,12646],{"class":220}," 1280",[200,12648,12619],{"class":213},[200,12650,12651],{"class":220}," 4",[200,12653,12624],{"class":213},[200,12655,12656],{"class":220}," 5",[200,12658,12659],{"class":227},")}\n",[200,12661,12662,12665,12667,12669,12672],{"class":202,"line":244},[200,12663,12664],{"class":290},"  spacing",[200,12666,575],{"class":213},[200,12668,4651],{"class":227},[200,12670,12671],{"class":220},"16",[200,12673,275],{"class":227},[200,12675,12676,12679,12681,12683,12685],{"class":202,"line":255},[200,12677,12678],{"class":290},"  padding",[200,12680,575],{"class":213},[200,12682,4651],{"class":227},[200,12684,2455],{"class":220},[200,12686,275],{"class":227},[200,12688,12689,12692,12694],{"class":202,"line":266},[200,12690,12691],{"class":290},"  photos",[200,12693,575],{"class":213},[200,12695,12696],{"class":227},"{albumPhotos}\n",[200,12698,12699,12702,12704,12707,12710,12713,12715,12718],{"class":202,"line":272},[200,12700,12701],{"class":290},"  onClick",[200,12703,575],{"class":213},[200,12705,12706],{"class":227},"{({ ",[200,12708,12709],{"class":2370},"index",[200,12711,12712],{"class":227}," }) ",[200,12714,2795],{"class":213},[200,12716,12717],{"class":290}," openPreview",[200,12719,12720],{"class":227},"(index)}\n",[200,12722,12723,12726,12728],{"class":202,"line":599},[200,12724,12725],{"class":290},"  render",[200,12727,575],{"class":213},[200,12729,12730],{"class":227},"{{\n",[200,12732,12733,12736,12739,12741,12743,12746,12748,12750],{"class":202,"line":605},[200,12734,12735],{"class":290},"    photo",[200,12737,12738],{"class":227},": (",[200,12740,2902],{"class":2370},[200,12742,337],{"class":227},[200,12744,12745],{"class":2370},"context",[200,12747,2831],{"class":227},[200,12749,2795],{"class":213},[200,12751,228],{"class":227},[200,12753,12754,12756,12759,12762,12765,12767],{"class":202,"line":619},[200,12755,2505],{"class":213},[200,12757,12758],{"class":227}," { ",[200,12760,12761],{"class":220},"onClick",[200,12763,12764],{"class":227}," } ",[200,12766,575],{"class":213},[200,12768,12769],{"class":227}," props\n",[200,12771,12772,12774,12776,12779,12781,12783,12785,12787,12789,12791,12793,12795],{"class":202,"line":642},[200,12773,2505],{"class":213},[200,12775,12758],{"class":227},[200,12777,12778],{"class":220},"photo",[200,12780,337],{"class":227},[200,12782,12709],{"class":220},[200,12784,337],{"class":227},[200,12786,12383],{"class":220},[200,12788,337],{"class":227},[200,12790,12386],{"class":220},[200,12792,12764],{"class":227},[200,12794,575],{"class":213},[200,12796,12797],{"class":227}," context\n",[200,12799,12800,12803],{"class":202,"line":651},[200,12801,12802],{"class":213},"      return",[200,12804,5197],{"class":227},[200,12806,12807,12810,12813,12816,12818,12821,12824,12826,12829,12832,12834,12837],{"class":202,"line":656},[200,12808,12809],{"class":227},"        \u003C",[200,12811,12812],{"class":296},"button",[200,12814,12815],{"class":290}," type",[200,12817,575],{"class":213},[200,12819,12820],{"class":237},"\"button\"",[200,12822,12823],{"class":290}," onClick",[200,12825,575],{"class":213},[200,12827,12828],{"class":227},"{onClick} ",[200,12830,12831],{"class":290},"className",[200,12833,575],{"class":213},[200,12835,12836],{"class":237},"\"group relative block w-full ...\"",[200,12838,1070],{"class":227},[200,12840,12841,12844],{"class":202,"line":662},[200,12842,12843],{"class":227},"          \u003C",[200,12845,12846],{"class":296},"img\n",[200,12848,12849,12852,12854],{"class":202,"line":673},[200,12850,12851],{"class":290},"            src",[200,12853,575],{"class":213},[200,12855,12856],{"class":227},"{photo.src}\n",[200,12858,12859,12862,12864,12867,12869,12871],{"class":202,"line":684},[200,12860,12861],{"class":290},"            alt",[200,12863,575],{"class":213},[200,12865,12866],{"class":227},"{photo.alt ",[200,12868,2581],{"class":213},[200,12870,4895],{"class":237},[200,12872,275],{"class":227},[200,12874,12875,12878,12880],{"class":202,"line":689},[200,12876,12877],{"class":290},"            decoding",[200,12879,575],{"class":213},[200,12881,12882],{"class":237},"\"async\"\n",[200,12884,12885,12888,12890],{"class":202,"line":698},[200,12886,12887],{"class":290},"            style",[200,12889,575],{"class":213},[200,12891,12892],{"class":227},"{{ width, height }}\n",[200,12894,12895],{"class":202,"line":703},[200,12896,12897],{"class":227},"          \u002F>\n",[200,12899,12900,12903,12906],{"class":202,"line":912},[200,12901,12902],{"class":227},"          {",[200,12904,12905],{"class":206},"\u002F* hover 提示词浮层 *\u002F",[200,12907,275],{"class":227},[200,12909,12910,12912,12915,12918,12920,12923],{"class":202,"line":920},[200,12911,12843],{"class":227},[200,12913,12914],{"class":296},"div",[200,12916,12917],{"class":290}," className",[200,12919,575],{"class":213},[200,12921,12922],{"class":237},"\"... opacity-0 group-hover:opacity-100\"",[200,12924,1070],{"class":227},[200,12926,12927],{"class":202,"line":925},[200,12928,12929],{"class":227},"            {photo.prompt_text}\n",[200,12931,12932,12935,12937],{"class":202,"line":1556},[200,12933,12934],{"class":227},"          \u003C\u002F",[200,12936,12914],{"class":296},[200,12938,1070],{"class":227},[200,12940,12941,12944,12946],{"class":202,"line":1570},[200,12942,12943],{"class":227},"        \u003C\u002F",[200,12945,12812],{"class":296},[200,12947,1070],{"class":227},[200,12949,12950],{"class":202,"line":1577},[200,12951,12952],{"class":227},"      )\n",[200,12954,12955],{"class":202,"line":1582},[200,12956,7347],{"class":227},[200,12958,12959],{"class":202,"line":1588},[200,12960,12961],{"class":227},"  }}\n",[200,12963,12964],{"class":202,"line":1596},[200,12965,12966],{"class":227},"\u002F>\n",[16,12968,12969],{},"几个要点：",[120,12971,12972,12981,12994],{},[123,12973,12974,12976,12977,12980],{},[197,12975,12271],{}," 可以是数字或 ",[197,12978,12979],{},"(containerWidth) => number"," 函数，做响应式列数很方便；",[123,12982,12983,12984,12986,12987,12990,12991,12993],{},"点击回调 ",[197,12985,12761],{}," 里拿到的是",[20,12988,12989],{},"原始数组下标"," ",[197,12992,12709],{},"，直接传给灯箱即可；",[123,12995,12996,12997,13000,13001,13003],{},"自定义 ",[197,12998,12999],{},"render.photo"," 后，图片尺寸要自己用 context 里的 ",[197,13002,12319],{},"（渲染尺寸）设置——这两个值库已经按列宽算好了。",[11,13005,13006],{"id":13006},"遇到的坑",[717,13008,13010],{"id":13009},"坑-1忘记引入样式表-一张一列","坑 1：忘记引入样式表 → 「一张一列」",[16,13012,13013,13014,13017,13018,2258],{},"这是最坑的一个。装完包、写好 ",[197,13015,13016],{},"\u003CPhotoAlbum layout=\"masonry\">","，页面渲染出来却是：",[20,13019,13020],{},"每张图占一整行，右侧大片置灰空白",[16,13022,13023,13024,13026],{},"排查过程：先怀疑是 ",[197,13025,12271],{}," 函数没生效，改成固定数字无效；再检查 DOM——容器和 5 个列（track）都在，列的宽度也算对了，但图片全都堆在布局里。",[16,13028,13029,13030,13033,13034,7193,13037,13040,13041,13047,13048,13051],{},"最后翻包的 ",[197,13031,13032],{},"package.json"," 发现它导出了独立的 CSS 文件（",[197,13035,13036],{},".\u002Fstyles.css",[197,13038,13039],{},".\u002Fmasonry.css"," 等），而",[20,13042,13043,13044],{},"官方 README 示例都带了一行 ",[197,13045,13046],{},"import \"react-photo-album\u002Fstyles.css\"","。容器是 flex、列是 flex-column、列宽按 ",[197,13049,13050],{},"--react-photo-album--columns"," 计算——这些规则全在这个样式表里，不引入就退化成普通块级堆叠。",[16,13053,13054,13056],{},[20,13055,7188],{},"：顶部加一行：",[190,13058,13060],{"className":10822,"code":13059,"language":10824,"meta":195,"style":195},"import \"react-photo-album\u002Fstyles.css\"\n",[197,13061,13062],{"__ignoreMap":195},[200,13063,13064,13066],{"class":202,"line":203},[200,13065,3799],{"class":213},[200,13067,12422],{"class":237},[16,13069,13070,13071],{},"经验：",[20,13072,13073],{},"用这类\"无运行时依赖\"的布局库，先看它的 exports 里有没有独立 CSS，有就一定要引。",[717,13075,13077,13078,13080,13081,13084],{"id":13076},"坑-2renderphoto-是-props-context-双参签名","坑 2：",[197,13079,12999],{}," 是 ",[197,13082,13083],{},"(props, context)"," 双参签名",[16,13086,13087],{},"自定义渲染时我凭直觉写了：",[190,13089,13091],{"className":10822,"code":13090,"language":10824,"meta":195,"style":195},"photo: ({ photo, index, width, height, onClick }) => ... \u002F\u002F ❌ 报错：Property 'photo' does not exist\n",[197,13092,13093],{"__ignoreMap":195},[200,13094,13095,13097,13100,13102,13104,13106,13108,13110,13112,13114,13116,13118,13120,13122,13125],{"class":202,"line":203},[200,13096,12778],{"class":290},[200,13098,13099],{"class":227},": ({ ",[200,13101,12778],{"class":2370},[200,13103,337],{"class":227},[200,13105,12709],{"class":2370},[200,13107,337],{"class":227},[200,13109,12383],{"class":2370},[200,13111,337],{"class":227},[200,13113,12386],{"class":2370},[200,13115,337],{"class":227},[200,13117,12761],{"class":2370},[200,13119,12712],{"class":227},[200,13121,2795],{"class":213},[200,13123,13124],{"class":213}," ...",[200,13126,13127],{"class":206}," \u002F\u002F ❌ 报错：Property 'photo' does not exist\n",[16,13129,13130,13131,13133,13134,13137,13138,3510],{},"TS 报错提示 ",[197,13132,12778],{}," 等属性不存在于 ",[197,13135,13136],{},"RenderPhotoProps","。看类型定义才发现 v3 的渲染函数是",[20,13139,13140],{},"两个参数",[190,13142,13144],{"className":10822,"code":13143,"language":10824,"meta":195,"style":195},"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",[197,13145,13146,13192,13197,13217,13231,13256,13260],{"__ignoreMap":195},[200,13147,13148,13150,13153,13155,13158,13160,13162,13165,13167,13169,13171,13173,13176,13178,13180,13182,13185,13187,13189],{"class":202,"line":203},[200,13149,5184],{"class":213},[200,13151,13152],{"class":290}," RenderFunction",[200,13154,1412],{"class":227},[200,13156,13157],{"class":290},"Props",[200,13159,337],{"class":227},[200,13161,4005],{"class":290},[200,13163,13164],{"class":227},"> ",[200,13166,575],{"class":213},[200,13168,2409],{"class":227},[200,13170,2902],{"class":2370},[200,13172,1409],{"class":213},[200,13174,13175],{"class":290}," Props",[200,13177,337],{"class":227},[200,13179,12745],{"class":2370},[200,13181,1409],{"class":213},[200,13183,13184],{"class":290}," Context",[200,13186,2831],{"class":227},[200,13188,2795],{"class":213},[200,13190,13191],{"class":290}," ReactNode\n",[200,13193,13194],{"class":202,"line":210},[200,13195,13196],{"class":206},"\u002F\u002F photo 的渲染上下文（photo\u002Findex\u002Fwidth\u002Fheight）在第二个参数里\n",[200,13198,13199,13201,13203,13205,13207,13209,13211,13213,13215],{"class":202,"line":231},[200,13200,12778],{"class":290},[200,13202,1409],{"class":213},[200,13204,2409],{"class":227},[200,13206,2902],{"class":2370},[200,13208,337],{"class":227},[200,13210,12745],{"class":2370},[200,13212,2831],{"class":227},[200,13214,2795],{"class":213},[200,13216,228],{"class":227},[200,13218,13219,13221,13223,13225,13228],{"class":202,"line":244},[200,13220,2856],{"class":290},[200,13222,12758],{"class":227},[200,13224,12761],{"class":2370},[200,13226,13227],{"class":227}," } = ",[200,13229,13230],{"class":2370},"props\n",[200,13232,13233,13235,13237,13239,13241,13243,13245,13247,13249,13251,13253],{"class":202,"line":255},[200,13234,2856],{"class":290},[200,13236,12758],{"class":227},[200,13238,12778],{"class":2370},[200,13240,337],{"class":227},[200,13242,12709],{"class":2370},[200,13244,337],{"class":227},[200,13246,12383],{"class":2370},[200,13248,337],{"class":227},[200,13250,12386],{"class":2370},[200,13252,13227],{"class":227},[200,13254,13255],{"class":2370},"context\n",[200,13257,13258],{"class":202,"line":266},[200,13259,10815],{"class":213},[200,13261,13262],{"class":202,"line":272},[200,13263,275],{"class":227},[16,13265,13266,13268,13269,13271,13272,13275],{},[20,13267,7188],{},"：按 ",[197,13270,13083],{}," 拆解即可。装新库先扫一眼 ",[197,13273,13274],{},".d.ts"," 里的 Render 类型签名，能少走很多弯路。",[717,13277,13279],{"id":13278},"坑-3测试断言把dom-顺序当成了视觉顺序","坑 3：测试断言把「DOM 顺序」当成了「视觉顺序」",[16,13281,13282,13283,13286],{},"布局修好后，我在浏览器自测脚本里加了一条断言：取前两个 ",[197,13284,13285],{},"\u003Cimg>","，比较它们的左坐标，认为应该并排（不同列）。",[16,13288,13289,13290,13293,13294,7227,13297,13300,13301,13304],{},"结果断言失败——但页面明明是 5 列并排。排查发现：",[20,13291,13292],{},"瀑布流的 DOM 是按\"列\"组织的","（第 1 列的全部图片在前，第 2 列的在后），所以 ",[197,13295,13296],{},"img[0]",[197,13298,13299],{},"img[1]"," 属于",[20,13302,13303],{},"同一列","，左坐标当然相同。",[16,13306,13307,13309],{},[20,13308,7188],{},"：断言改为比较「第 1 列的首张图 vs 第 2 列的首张图」：",[190,13311,13315],{"className":13312,"code":13313,"language":13314,"meta":195,"style":195},"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",[197,13316,13317,13342,13365],{"__ignoreMap":195},[200,13318,13319,13321,13323,13325,13328,13330,13332,13335,13337,13340],{"class":202,"line":203},[200,13320,2601],{"class":213},[200,13322,3616],{"class":220},[200,13324,224],{"class":213},[200,13326,13327],{"class":227}," tracks[",[200,13329,2455],{"class":220},[200,13331,2520],{"class":227},[200,13333,13334],{"class":290},"querySelector",[200,13336,2367],{"class":227},[200,13338,13339],{"class":237},"\"img\"",[200,13341,2677],{"class":227},[200,13343,13344,13346,13349,13351,13353,13355,13357,13359,13361,13363],{"class":202,"line":210},[200,13345,2601],{"class":213},[200,13347,13348],{"class":220}," b",[200,13350,224],{"class":213},[200,13352,13327],{"class":227},[200,13354,593],{"class":220},[200,13356,2520],{"class":227},[200,13358,13334],{"class":290},[200,13360,2367],{"class":227},[200,13362,13339],{"class":237},[200,13364,2677],{"class":227},[200,13366,13367,13369,13372,13375,13378,13381,13384,13387,13390,13392,13395,13397],{"class":202,"line":231},[200,13368,2418],{"class":213},[200,13370,13371],{"class":227}," Math.",[200,13373,13374],{"class":290},"abs",[200,13376,13377],{"class":227},"(a.",[200,13379,13380],{"class":290},"getBoundingClientRect",[200,13382,13383],{"class":227},"().left ",[200,13385,13386],{"class":213},"-",[200,13388,13389],{"class":227}," b.",[200,13391,13380],{"class":290},[200,13393,13394],{"class":227},"().left) ",[200,13396,1023],{"class":213},[200,13398,13399],{"class":220}," 10\n",[16,13401,13070,13402,13405],{},[20,13403,13404],{},"验证布局类库时，先搞清楚它生成的 DOM 结构，再写断言","；另外把「真实浏览器探针」当排查手段（直接打印各 track 的 x 坐标和子元素数量），比盯着代码猜快得多。",[717,13407,13409],{"id":13408},"坑-4等宽网格的洞引入前就存在的产品问题","坑 4：等宽网格的\"洞\"（引入前就存在的产品问题）",[16,13411,13412,13413,13416,13417,13420],{},"顺带记录一下为什么换掉原来的布局：CSS Grid ",[197,13414,13415],{},"repeat(auto-fill, minmax(220px, 1fr))"," 等宽铺满，图片 ",[197,13418,13419],{},"w-full h-auto","。当一行里混有不同比例时，行高取最高的那张，矮图底部留空，几行错落下来就显得乱；另外窗口宽度\"差一点放不下下一张\"时，右侧会空出近一整列。",[16,13422,13423],{},"换成 Masonry 后这两个问题都消失了——列内紧密堆叠、列数自适应。",[11,13425,13426],{"id":13426},"效果与小结",[16,13428,13429,13430,13432],{},"最终效果：21 张不同比例的生成图在 5 列瀑布流里紧密排列，原比例不裁剪，悬停出提示词浮层，点击进 YARL 大图预览；缩略图总大小仅 360KB，进页面即全量加载 + ",[197,13431,11923],{}," 长缓存，平板上快速滚动也跟手。",[16,13434,10601,13435],{},[20,13436,13437],{},"react-photo-album 是个小但专业的相册布局库，只要记住三件事——引样式表、喂宽高、render 函数是双参——就能在十分钟内把图片墙升级成真正的瀑布流。",[437,13439,13440],{},"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":195,"searchDepth":210,"depth":231,"links":13442},[13443,13444,13445,13450,13457],{"id":12217,"depth":210,"text":12217},{"id":12323,"depth":210,"text":12323},{"id":12349,"depth":210,"text":12349,"children":13446},[13447,13448,13449],{"id":12352,"depth":231,"text":12353},{"id":12370,"depth":231,"text":12371},{"id":12568,"depth":231,"text":12569},{"id":13006,"depth":210,"text":13006,"children":13451},[13452,13453,13455,13456],{"id":13009,"depth":231,"text":13010},{"id":13076,"depth":231,"text":13454},"坑 2：render.photo 是 (props, context) 双参签名",{"id":13278,"depth":231,"text":13279},{"id":13408,"depth":231,"text":13409},{"id":13426,"depth":210,"text":13426},"记录在 Magic Img 生图图库中引入 react-photo-album 实现 Masonry 瀑布流的过程：技术选型背景、需求梳理、安装使用，以及「忘记引入样式表导致一张一列」等踩坑与排查方法。","\u002Fposts\u002Freact-photo-album-masonry\u002Fimg\u002Fcover.svg",{},"\u002Fposts\u002Freact-photo-album-masonry",{"title":12212,"description":13458},"前端工程实践","posts\u002Freact-photo-album-masonry\u002Findex",[9492,12300,13466,13467,13468],"瀑布流","前端","Tailwind","-O-kAQLEyRzwVhOpuo-yBxZim9LtvhChPkpP92ipyjM",1786887348660]