[{"data":1,"prerenderedAt":13326},["ShallowReactive",2],{"post-sveltekit-intro":3,"series-source-sveltekit-intro":261},{"id":4,"title":5,"author":6,"body":7,"description":248,"difficulty":6,"draft":249,"extension":250,"image":251,"meta":252,"navigation":253,"path":254,"pinned":249,"published":255,"seo":256,"series":6,"seriesOrder":6,"stem":257,"tags":258,"updated":6,"__hash__":260},"posts\u002Fposts\u002Fsveltekit-intro\u002Findex.md","SvelteKit 入门：为什么它比你想象的更简单",null,{"type":8,"value":9,"toc":238},"minimark",[10,15,19,26,29,34,42,110,114,120,128,132,135,138,214,217,228,234],[11,12,14],"h2",{"id":13},"什么是-sveltekit","什么是 SvelteKit？",[16,17,18],"p",{},"SvelteKit 是 Svelte 的官方全栈框架，类似于 React 之于 Next.js 的关系。它提供了路由、SSR、SSG 等能力，让开发者可以快速构建现代 Web 应用。",[16,20,21],{},[22,23],"img",{"alt":24,"src":25},"SvelteKit Logo","https:\u002F\u002Fraw.githubusercontent.com\u002Fsveltejs\u002Fbranding\u002Fmaster\u002Fsvelte-logo.svg",[11,27,28],{"id":28},"核心特点",[30,31,33],"h3",{"id":32},"_1-编译时框架","1. 编译时框架",[16,35,36,37,41],{},"Svelte 和 React\u002FVue 最大的区别在于：",[38,39,40],"strong",{},"它在编译阶段就把组件转成原生 JS","，运行时没有虚拟 DOM。",[43,44,49],"pre",{"className":45,"code":46,"language":47,"meta":48,"style":48},"language-typescript shiki shiki-themes github-light github-dark","\u002F\u002F Svelte 组件（编译后变成直接的 DOM 操作）\nlet count = 0\nfunction increment() {\n  count += 1\n}\n","typescript","",[50,51,52,61,79,92,104],"code",{"__ignoreMap":48},[53,54,57],"span",{"class":55,"line":56},"line",1,[53,58,60],{"class":59},"sHbNN","\u002F\u002F Svelte 组件（编译后变成直接的 DOM 操作）\n",[53,62,64,68,72,75],{"class":55,"line":63},2,[53,65,67],{"class":66},"siTax","let",[53,69,71],{"class":70},"sIX_F"," count ",[53,73,74],{"class":66},"=",[53,76,78],{"class":77},"suQ91"," 0\n",[53,80,82,85,89],{"class":55,"line":81},3,[53,83,84],{"class":66},"function",[53,86,88],{"class":87},"sw2iP"," increment",[53,90,91],{"class":70},"() {\n",[53,93,95,98,101],{"class":55,"line":94},4,[53,96,97],{"class":70},"  count ",[53,99,100],{"class":66},"+=",[53,102,103],{"class":77}," 1\n",[53,105,107],{"class":55,"line":106},5,[53,108,109],{"class":70},"}\n",[30,111,113],{"id":112},"_2-文件路由","2. 文件路由",[16,115,116,119],{},[50,117,118],{},"src\u002Froutes\u002F"," 目录结构即 URL，不需要手动配置路由表：",[43,121,126],{"className":122,"code":124,"language":125},[123],"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",[50,127,124],{"__ignoreMap":48},[30,129,131],{"id":130},"_3-全栈能力","3. 全栈能力",[16,133,134],{},"SvelteKit 内置了服务端渲染（SSR）、静态站点生成（SSG）、API 路由等功能，开箱即用。",[11,136,137],{"id":137},"和其他框架对比",[139,140,141,160],"table",{},[142,143,144],"thead",{},[145,146,147,151,154,157],"tr",{},[148,149,150],"th",{},"特性",[148,152,153],{},"SvelteKit",[148,155,156],{},"Next.js",[148,158,159],{},"Nuxt",[161,162,163,177,190,203],"tbody",{},[145,164,165,169,172,175],{},[166,167,168],"td",{},"学习曲线",[166,170,171],{},"低",[166,173,174],{},"中",[166,176,174],{},[145,178,179,182,185,188],{},[166,180,181],{},"包体积",[166,183,184],{},"小",[166,186,187],{},"大",[166,189,174],{},[145,191,192,195,198,201],{},[166,193,194],{},"运行时开销",[166,196,197],{},"无",[166,199,200],{},"有",[166,202,200],{},[145,204,205,208,210,212],{},[166,206,207],{},"生态",[166,209,184],{},[166,211,187],{},[166,213,187],{},[11,215,216],{"id":216},"总结",[16,218,219,220,223,224,227],{},"如果你追求",[38,221,222],{},"简洁的代码","和",[38,225,226],{},"极致的性能","，SvelteKit 是一个非常值得尝试的选择。它的学习曲线平缓，写起来就像在写原生 HTML\u002FCSS\u002FJS。",[16,229,230],{},[22,231],{"alt":232,"src":233},"Svelte 编译过程","https:\u002F\u002Fsvelte.dev\u002Fsvelte-logo-horizontal.svg",[235,236,237],"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 .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":48,"searchDepth":63,"depth":81,"links":239},[240,241,246,247],{"id":13,"depth":63,"text":14},{"id":28,"depth":63,"text":28,"children":242},[243,244,245],{"id":32,"depth":81,"text":33},{"id":112,"depth":81,"text":113},{"id":130,"depth":81,"text":131},{"id":137,"depth":63,"text":137},{"id":216,"depth":63,"text":216},"SvelteKit 是一个全栈 Svelte 框架，这篇文章带你了解它的核心概念和优势。",false,"md","\u002Fposts\u002Fsveltekit-intro\u002Fimg\u002Fcover.jpg",{},true,"\u002Fposts\u002Fsveltekit-intro","2026-05-30",{"title":5,"description":248},"posts\u002Fsveltekit-intro\u002Findex",[153,259],"前端框架","vsEGyunpCWR7DiJAdyV1SPDfa57xXLITBI-lKM8UeGQ",[262,1087,1507,1664,2082,3273,3552,5621,6403,7382,8791,9351,10530,12066],{"id":263,"title":264,"author":6,"body":265,"description":1073,"difficulty":1074,"draft":249,"extension":250,"image":1075,"meta":1076,"navigation":253,"path":1077,"pinned":249,"published":1078,"seo":1079,"series":1080,"seriesOrder":56,"stem":1081,"tags":1082,"updated":1078,"__hash__":1086},"posts\u002Fposts\u002Fdocker-musl-build\u002Findex.md","Docker 编译 musl 可执行文件",{"type":8,"value":266,"toc":1062},[267,270,273,276,517,520,563,566,584,587,738,741,877,880,883,941,944,1059],[11,268,269],{"id":269},"解决问题",[16,271,272],{},"用 Go 进行跨平台编译时往往步骤复杂，用 Docker 可以轻松解决。但 Docker 的当前最小镜像 Alpine 其动态链接库采用 musl，与主流的 glibc 不同，往往不能方便执行。",[11,274,275],{"id":275},"查阅资料",[43,277,281],{"className":278,"code":279,"language":280,"meta":48,"style":48},"language-bash shiki shiki-themes github-light github-dark","# 启动容器并挂载当前目录到 `\u002Fapp`\ndocker run -it --rm -v \"$PWD\":\u002Fapp -w \u002Fapp ubuntu:20.04 bash\n\n# 在容器内执行以下操作：\napt-get update && apt-get install -y musl-tools golang-go  # 安装 musl 和 Go\nexport CC=musl-gcc  # 指定 musl 编译器\nexport CGO_ENABLED=1  # 启用 CGO\n# 设置代理\ngo env -w GOPROXY=https:\u002F\u002Fgoproxy.cn,direct\ngo build -ldflags '-linkmode external -extldflags \"-static\"' -o myapp  # 静态编译\nexit  # 退出容器\n\n# 验证结果（在宿主机执行）\nfile myapp  # 应显示 \"statically linked\"\nldd myapp   # 应显示 \"not a dynamic executable\"\n\nwget https:\u002F\u002Fgo.dev\u002Fdl\u002Fgo1.21.linux-amd64.tar.gz\n\nENV PATH=\"\u002Fdata\u002Fgo\u002Fbin:$PATH\"\n","bash",[50,282,283,288,327,332,337,365,382,398,404,418,441,450,455,461,472,483,488,497,502],{"__ignoreMap":48},[53,284,285],{"class":55,"line":56},[53,286,287],{"class":59},"# 启动容器并挂载当前目录到 `\u002Fapp`\n",[53,289,290,293,297,300,303,306,309,312,315,318,321,324],{"class":55,"line":63},[53,291,292],{"class":87},"docker",[53,294,296],{"class":295},"scXbn"," run",[53,298,299],{"class":77}," -it",[53,301,302],{"class":77}," --rm",[53,304,305],{"class":77}," -v",[53,307,308],{"class":295}," \"",[53,310,311],{"class":70},"$PWD",[53,313,314],{"class":295},"\":\u002Fapp",[53,316,317],{"class":77}," -w",[53,319,320],{"class":295}," \u002Fapp",[53,322,323],{"class":295}," ubuntu:20.04",[53,325,326],{"class":295}," bash\n",[53,328,329],{"class":55,"line":81},[53,330,331],{"emptyLinePlaceholder":253},"\n",[53,333,334],{"class":55,"line":94},[53,335,336],{"class":59},"# 在容器内执行以下操作：\n",[53,338,339,342,345,348,350,353,356,359,362],{"class":55,"line":106},[53,340,341],{"class":87},"apt-get",[53,343,344],{"class":295}," update",[53,346,347],{"class":70}," && ",[53,349,341],{"class":87},[53,351,352],{"class":295}," install",[53,354,355],{"class":77}," -y",[53,357,358],{"class":295}," musl-tools",[53,360,361],{"class":295}," golang-go",[53,363,364],{"class":59},"  # 安装 musl 和 Go\n",[53,366,368,371,374,376,379],{"class":55,"line":367},6,[53,369,370],{"class":66},"export",[53,372,373],{"class":70}," CC",[53,375,74],{"class":66},[53,377,378],{"class":70},"musl-gcc  ",[53,380,381],{"class":59},"# 指定 musl 编译器\n",[53,383,385,387,390,392,395],{"class":55,"line":384},7,[53,386,370],{"class":66},[53,388,389],{"class":70}," CGO_ENABLED",[53,391,74],{"class":66},[53,393,394],{"class":77},"1",[53,396,397],{"class":59},"  # 启用 CGO\n",[53,399,401],{"class":55,"line":400},8,[53,402,403],{"class":59},"# 设置代理\n",[53,405,407,410,413,415],{"class":55,"line":406},9,[53,408,409],{"class":87},"go",[53,411,412],{"class":295}," env",[53,414,317],{"class":77},[53,416,417],{"class":295}," GOPROXY=https:\u002F\u002Fgoproxy.cn,direct\n",[53,419,421,423,426,429,432,435,438],{"class":55,"line":420},10,[53,422,409],{"class":87},[53,424,425],{"class":295}," build",[53,427,428],{"class":77}," -ldflags",[53,430,431],{"class":295}," '-linkmode external -extldflags \"-static\"'",[53,433,434],{"class":77}," -o",[53,436,437],{"class":295}," myapp",[53,439,440],{"class":59},"  # 静态编译\n",[53,442,444,447],{"class":55,"line":443},11,[53,445,446],{"class":77},"exit",[53,448,449],{"class":59},"  # 退出容器\n",[53,451,453],{"class":55,"line":452},12,[53,454,331],{"emptyLinePlaceholder":253},[53,456,458],{"class":55,"line":457},13,[53,459,460],{"class":59},"# 验证结果（在宿主机执行）\n",[53,462,464,467,469],{"class":55,"line":463},14,[53,465,466],{"class":87},"file",[53,468,437],{"class":295},[53,470,471],{"class":59},"  # 应显示 \"statically linked\"\n",[53,473,475,478,480],{"class":55,"line":474},15,[53,476,477],{"class":87},"ldd",[53,479,437],{"class":295},[53,481,482],{"class":59},"   # 应显示 \"not a dynamic executable\"\n",[53,484,486],{"class":55,"line":485},16,[53,487,331],{"emptyLinePlaceholder":253},[53,489,491,494],{"class":55,"line":490},17,[53,492,493],{"class":87},"wget",[53,495,496],{"class":295}," https:\u002F\u002Fgo.dev\u002Fdl\u002Fgo1.21.linux-amd64.tar.gz\n",[53,498,500],{"class":55,"line":499},18,[53,501,331],{"emptyLinePlaceholder":253},[53,503,505,508,511,514],{"class":55,"line":504},19,[53,506,507],{"class":87},"ENV",[53,509,510],{"class":295}," PATH=\"\u002Fdata\u002Fgo\u002Fbin:",[53,512,513],{"class":70},"$PATH",[53,515,516],{"class":295},"\"\n",[30,518,519],{"id":519},"遇到问题",[521,522,523,549],"ol",{},[524,525,526,529,530,533,536,537,540,542,543,545],"li",{},[50,527,528],{},"go mod tidy"," 时报错 ",[50,531,532],{},"tls: failed to verify certificate: x509: certificate",[534,535],"br",{},"解决：",[50,538,539],{},"RUN apt-get update && apt-get install -y ca-certificates && rm -rf \u002Fvar\u002Flib\u002Fapt\u002Flists\u002F*",[534,541],{},"基础镜像的证书未更新，导致 https 的请求出现类似错误。",[534,544],{},[22,546],{"alt":547,"src":548},"image-20250327100813512","\u002Fposts\u002Fdocker-musl-build\u002Fimg\u002Fimage-20250327100813512.png",[524,550,551,552,555,556,558,559,562],{},"直接安装 ",[50,553,554],{},"golang-go"," 其版本过低",[534,557],{},"解决: ",[50,560,561],{},"PATH=\"\u002Fdata\u002Fgo\u002Fbin:$PATH\""," 手动指定环境",[11,564,565],{"id":565},"最终方案",[521,567,568,575,578],{},[524,569,570,571,574],{},"下载 ",[50,572,573],{},"go1.24.1.linux-amd64.tar.gz"," 解压",[524,576,577],{},"编写 Dockerfile",[524,579,580,581],{},"执行 ",[50,582,583],{},"docker build -t go-build:v1 .",[16,585,586],{},"Dockerfile 内容：",[43,588,592],{"className":589,"code":590,"language":591,"meta":48,"style":48},"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",[50,593,594,602,606,617,621,629,633,643,647,655,659,664,671,675,682,687,694,698,706,710,718,723],{"__ignoreMap":48},[53,595,596,599],{"class":55,"line":56},[53,597,598],{"class":66},"FROM",[53,600,601],{"class":70}," ubuntu:20.04\n",[53,603,604],{"class":55,"line":63},[53,605,331],{"emptyLinePlaceholder":253},[53,607,608,611,614],{"class":55,"line":81},[53,609,610],{"class":66},"LABEL",[53,612,613],{"class":70}," org.opencontainers.image.authors=",[53,615,616],{"class":295},"\"sutong\"\n",[53,618,619],{"class":55,"line":94},[53,620,331],{"emptyLinePlaceholder":253},[53,622,623,626],{"class":55,"line":106},[53,624,625],{"class":66},"COPY",[53,627,628],{"class":70}," go\u002F \u002Fgo\u002F\n",[53,630,631],{"class":55,"line":367},[53,632,331],{"emptyLinePlaceholder":253},[53,634,635,637,640],{"class":55,"line":384},[53,636,507],{"class":66},[53,638,639],{"class":70}," PATH=",[53,641,642],{"class":295},"\"\u002Fgo\u002Fbin:$PATH\"\n",[53,644,645],{"class":55,"line":400},[53,646,331],{"emptyLinePlaceholder":253},[53,648,649,652],{"class":55,"line":406},[53,650,651],{"class":66},"RUN",[53,653,654],{"class":70}," apt-get update && apt-get install -y musl-tools\n",[53,656,657],{"class":55,"line":420},[53,658,331],{"emptyLinePlaceholder":253},[53,660,661],{"class":55,"line":443},[53,662,663],{"class":59},"## go mod tidy 时报错 tls: failed to verify certificate: x509: certificate\n",[53,665,666,668],{"class":55,"line":452},[53,667,651],{"class":66},[53,669,670],{"class":70}," apt-get update && apt-get install -y ca-certificates && rm -rf \u002Fvar\u002Flib\u002Fapt\u002Flists\u002F*\n",[53,672,673],{"class":55,"line":457},[53,674,381],{"class":59},[53,676,677,679],{"class":55,"line":463},[53,678,507],{"class":66},[53,680,681],{"class":70}," CC=musl-gcc\n",[53,683,684],{"class":55,"line":474},[53,685,686],{"class":59},"# 启用 CGO\n",[53,688,689,691],{"class":55,"line":485},[53,690,507],{"class":66},[53,692,693],{"class":70}," CGO_ENABLED=1\n",[53,695,696],{"class":55,"line":490},[53,697,331],{"emptyLinePlaceholder":253},[53,699,700,703],{"class":55,"line":499},[53,701,702],{"class":66},"VOLUME",[53,704,705],{"class":70}," \u002Fdata\n",[53,707,708],{"class":55,"line":504},[53,709,331],{"emptyLinePlaceholder":253},[53,711,713,716],{"class":55,"line":712},20,[53,714,715],{"class":66},"WORKDIR",[53,717,705],{"class":70},[53,719,721],{"class":55,"line":720},21,[53,722,331],{"emptyLinePlaceholder":253},[53,724,726,729,732,735],{"class":55,"line":725},22,[53,727,728],{"class":66},"CMD",[53,730,731],{"class":70}," [",[53,733,734],{"class":295},"\"\u002Fbin\u002Fbash\"",[53,736,737],{"class":70},"]\n",[30,739,740],{"id":740},"构建",[43,742,744],{"className":278,"code":743,"language":280,"meta":48,"style":48},"# 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",[50,745,746,751,755,760,786,790,795,830,835,839,872],{"__ignoreMap":48},[53,747,748],{"class":55,"line":56},[53,749,750],{"class":59},"# docker build -t go-build:v1 .\n",[53,752,753],{"class":55,"line":63},[53,754,331],{"emptyLinePlaceholder":253},[53,756,757],{"class":55,"line":81},[53,758,759],{"class":59},"# 直接执行编译 编译后删除\n",[53,761,762,764,766,768,770,773,776,779,781,783],{"class":55,"line":94},[53,763,292],{"class":87},[53,765,296],{"class":295},[53,767,302],{"class":77},[53,769,305],{"class":77},[53,771,772],{"class":295}," .:\u002Fdata",[53,774,775],{"class":295}," go-build:v1",[53,777,778],{"class":295}," go",[53,780,425],{"class":295},[53,782,434],{"class":77},[53,784,785],{"class":295}," app_musl\n",[53,787,788],{"class":55,"line":106},[53,789,331],{"emptyLinePlaceholder":253},[53,791,792],{"class":55,"line":367},[53,793,794],{"class":59},"# 构建编译环境\n",[53,796,797,799,801,803,806,808,810,813,816,819,822,825,827],{"class":55,"line":384},[53,798,292],{"class":87},[53,800,296],{"class":295},[53,802,299],{"class":77},[53,804,805],{"class":77}," -d",[53,807,305],{"class":77},[53,809,772],{"class":295},[53,811,812],{"class":77}," --name",[53,814,815],{"class":66}," \u003C",[53,817,818],{"class":295},"容器名",[53,820,821],{"class":70},"称",[53,823,824],{"class":66},">",[53,826,775],{"class":295},[53,828,829],{"class":295}," \u002Fbin\u002Fbash\n",[53,831,832],{"class":55,"line":400},[53,833,834],{"class":59},"# eg：docker run -it -d -v .:\u002Fdata --name logbus-build go-build:v1 \u002Fbin\u002Fbash\n",[53,836,837],{"class":55,"line":406},[53,838,331],{"emptyLinePlaceholder":253},[53,840,841,843,846,848,850,852,854,856,858,860,862,864,867,869],{"class":55,"line":420},[53,842,292],{"class":87},[53,844,845],{"class":295}," exec",[53,847,299],{"class":77},[53,849,815],{"class":66},[53,851,818],{"class":295},[53,853,821],{"class":70},[53,855,824],{"class":66},[53,857,778],{"class":295},[53,859,425],{"class":295},[53,861,434],{"class":77},[53,863,815],{"class":66},[53,865,866],{"class":295},"myap",[53,868,16],{"class":70},[53,870,871],{"class":66},">\n",[53,873,874],{"class":55,"line":443},[53,875,876],{"class":59},"# eg：docker exec -it logbus-build go build -o logbus_musl_1\n",[30,878,879],{"id":879},"额外注意",[16,881,882],{},"若没科学上网需要设置代理：",[43,884,886],{"className":278,"code":885,"language":280,"meta":48,"style":48},"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",[50,887,888,906,916,932],{"__ignoreMap":48},[53,889,890,892,894,896,898,900,902,904],{"class":55,"line":56},[53,891,292],{"class":87},[53,893,845],{"class":295},[53,895,299],{"class":77},[53,897,815],{"class":66},[53,899,818],{"class":295},[53,901,821],{"class":70},[53,903,824],{"class":66},[53,905,829],{"class":295},[53,907,908,910,912,914],{"class":55,"line":63},[53,909,409],{"class":87},[53,911,412],{"class":295},[53,913,317],{"class":77},[53,915,417],{"class":295},[53,917,918,920,922,924,926,928,930],{"class":55,"line":81},[53,919,409],{"class":87},[53,921,425],{"class":295},[53,923,428],{"class":77},[53,925,431],{"class":295},[53,927,434],{"class":77},[53,929,437],{"class":295},[53,931,440],{"class":59},[53,933,934,936,938],{"class":55,"line":94},[53,935,409],{"class":87},[53,937,425],{"class":295},[53,939,940],{"class":59}," # musl 库编译\n",[30,942,943],{"id":943},"运行环境示例",[43,945,947],{"className":589,"code":946,"language":591,"meta":48,"style":48},"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",[50,948,949,956,960,968,972,979,983,990,997,1001,1008,1012,1019,1023,1045,1049,1054],{"__ignoreMap":48},[53,950,951,953],{"class":55,"line":56},[53,952,598],{"class":66},[53,954,955],{"class":70}," alpine:latest\n",[53,957,958],{"class":55,"line":63},[53,959,331],{"emptyLinePlaceholder":253},[53,961,962,964,966],{"class":55,"line":81},[53,963,610],{"class":66},[53,965,613],{"class":70},[53,967,616],{"class":295},[53,969,970],{"class":55,"line":94},[53,971,331],{"emptyLinePlaceholder":253},[53,973,974,976],{"class":55,"line":106},[53,975,625],{"class":66},[53,977,978],{"class":70}," glog_musl \u002Fapp\u002F\n",[53,980,981],{"class":55,"line":367},[53,982,331],{"emptyLinePlaceholder":253},[53,984,985,987],{"class":55,"line":384},[53,986,625],{"class":66},[53,988,989],{"class":70}," conf.ini \u002Fdata\u002F\n",[53,991,992,994],{"class":55,"line":400},[53,993,625],{"class":66},[53,995,996],{"class":70}," ipname.ini \u002Fdata\u002F\n",[53,998,999],{"class":55,"line":406},[53,1000,331],{"emptyLinePlaceholder":253},[53,1002,1003,1005],{"class":55,"line":420},[53,1004,715],{"class":66},[53,1006,1007],{"class":70}," \u002Fapp\n",[53,1009,1010],{"class":55,"line":443},[53,1011,331],{"emptyLinePlaceholder":253},[53,1013,1014,1016],{"class":55,"line":452},[53,1015,651],{"class":66},[53,1017,1018],{"class":70}," chmod 777 \u002Fapp\u002Fglog_musl\n",[53,1020,1021],{"class":55,"line":457},[53,1022,331],{"emptyLinePlaceholder":253},[53,1024,1025,1027,1029,1032,1035,1038,1040,1043],{"class":55,"line":463},[53,1026,728],{"class":66},[53,1028,731],{"class":70},[53,1030,1031],{"class":295},"\".\u002Fglog_musl\"",[53,1033,1034],{"class":70},", ",[53,1036,1037],{"class":295},"\"logsweb\"",[53,1039,1034],{"class":70},[53,1041,1042],{"class":295},"\"\u002Fdata\u002Fconf.ini\"",[53,1044,737],{"class":70},[53,1046,1047],{"class":55,"line":474},[53,1048,331],{"emptyLinePlaceholder":253},[53,1050,1051],{"class":55,"line":485},[53,1052,1053],{"class":59},"# docker build -t glog:v1 .\n",[53,1055,1056],{"class":55,"line":490},[53,1057,1058],{"class":59},"# docker run -p 6801:6801 glog:v1\n",[235,1060,1061],{},"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":48,"searchDepth":63,"depth":81,"links":1063},[1064,1065,1068],{"id":269,"depth":63,"text":269},{"id":275,"depth":63,"text":275,"children":1066},[1067],{"id":519,"depth":81,"text":519},{"id":565,"depth":63,"text":565,"children":1069},[1070,1071,1072],{"id":740,"depth":81,"text":740},{"id":879,"depth":81,"text":879},{"id":943,"depth":81,"text":943},"解决 Go 语言跨平台编译问题，使用 Docker + musl 编译静态链接的可执行文件。","进阶","\u002Fposts\u002Fdocker-musl-build\u002Fimg\u002Fcover.svg",{},"\u002Fposts\u002Fdocker-musl-build","2025-03-27",{"title":264,"description":1073},"Docker 实践笔记","posts\u002Fdocker-musl-build\u002Findex",[1083,1084,1085],"Docker","Go","运维","YLtdOOc-gMVDSNjE-ntKXz-7hff-VtvMB2dB5RXNi1o",{"id":1088,"title":1089,"author":6,"body":1090,"description":1496,"difficulty":1497,"draft":249,"extension":250,"image":1498,"meta":1499,"navigation":253,"path":1500,"pinned":249,"published":1501,"seo":1502,"series":1080,"seriesOrder":63,"stem":1503,"tags":1504,"updated":1501,"__hash__":1506},"posts\u002Fposts\u002Fdocker-user-start\u002Findex.md","Docker 构建时指定用户启动",{"type":8,"value":1091,"toc":1486},[1092,1095,1098,1101,1105,1108,1125,1129,1162,1166,1173,1223,1226,1445,1447,1450,1480,1483],[11,1093,1094],{"id":1094},"前言",[16,1096,1097],{},"我们在 Linux 使用应用时，往往不会直接使用 root 用户作为应用启动项，但 Dockerfile 默认就是 root 用户，导致挂载生成的文件路径往往是 root 权限，非 root 用户访问就很不方便。",[11,1099,1100],{"id":1100},"解决方案",[30,1102,1104],{"id":1103},"_1-安装用户管理工具","1. 安装用户管理工具",[16,1106,1107],{},"若镜像非自带用户管理：",[43,1109,1111],{"className":589,"code":1110,"language":591,"meta":48,"style":48},"# 权限管理\nRUN apk add --no-cache shadow\n",[50,1112,1113,1118],{"__ignoreMap":48},[53,1114,1115],{"class":55,"line":56},[53,1116,1117],{"class":59},"# 权限管理\n",[53,1119,1120,1122],{"class":55,"line":63},[53,1121,651],{"class":66},[53,1123,1124],{"class":70}," apk add --no-cache shadow\n",[30,1126,1128],{"id":1127},"_2-创建用户并修改权限","2. 创建用户并修改权限",[43,1130,1132],{"className":589,"code":1131,"language":591,"meta":48,"style":48},"# 创建用户组和用户\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",[50,1133,1134,1139,1146,1150,1155],{"__ignoreMap":48},[53,1135,1136],{"class":55,"line":56},[53,1137,1138],{"class":59},"# 创建用户组和用户\n",[53,1140,1141,1143],{"class":55,"line":63},[53,1142,651],{"class":66},[53,1144,1145],{"class":70}," addgroup -S \u003Capp_user_group> && adduser -S -G \u003Capp_user_group> \u003Capp_user>\n",[53,1147,1148],{"class":55,"line":81},[53,1149,331],{"emptyLinePlaceholder":253},[53,1151,1152],{"class":55,"line":94},[53,1153,1154],{"class":59},"# 修改目录权限\n",[53,1156,1157,1159],{"class":55,"line":106},[53,1158,651],{"class":66},[53,1160,1161],{"class":70}," chown -R \u003Capp_user_group>:\u003Capp_user> \u002Fdata\n",[30,1163,1165],{"id":1164},"_3-指定用户启动","3. 指定用户启动",[16,1167,1168,1169,1172],{},"执行 docker 时加 ",[50,1170,1171],{},"--user"," 参数：",[43,1174,1176],{"className":278,"code":1175,"language":280,"meta":48,"style":48},"docker run -it --user \u003Capp_user> \u003Capp_image>:\u003Cimage_version>\n",[50,1177,1178],{"__ignoreMap":48},[53,1179,1180,1182,1184,1186,1189,1191,1194,1197,1199,1201,1204,1207,1209,1212,1215,1218,1221],{"class":55,"line":56},[53,1181,292],{"class":87},[53,1183,296],{"class":295},[53,1185,299],{"class":77},[53,1187,1188],{"class":77}," --user",[53,1190,815],{"class":66},[53,1192,1193],{"class":295},"app_use",[53,1195,1196],{"class":70},"r",[53,1198,824],{"class":66},[53,1200,815],{"class":66},[53,1202,1203],{"class":295},"app_imag",[53,1205,1206],{"class":70},"e",[53,1208,824],{"class":66},[53,1210,1211],{"class":295},":",[53,1213,1214],{"class":66},"\u003C",[53,1216,1217],{"class":295},"image_versio",[53,1219,1220],{"class":70},"n",[53,1222,871],{"class":66},[11,1224,1225],{"id":1225},"完整示例",[43,1227,1229],{"className":589,"code":1228,"language":591,"meta":48,"style":48},"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",[50,1230,1231,1237,1241,1246,1259,1263,1270,1274,1280,1285,1292,1297,1304,1308,1315,1319,1323,1330,1334,1341,1345,1352,1356,1370,1377,1382,1388,1396,1404,1409,1415,1424],{"__ignoreMap":48},[53,1232,1233,1235],{"class":55,"line":56},[53,1234,598],{"class":66},[53,1236,955],{"class":70},[53,1238,1239],{"class":55,"line":63},[53,1240,331],{"emptyLinePlaceholder":253},[53,1242,1243],{"class":55,"line":81},[53,1244,1245],{"class":59},"# 国内源\n",[53,1247,1248,1250,1253,1256],{"class":55,"line":94},[53,1249,651],{"class":66},[53,1251,1252],{"class":70}," sed -i ",[53,1254,1255],{"class":295},"'s\u002Fdl-cdn.alpinelinux.org\u002Fmirrors.ustc.edu.cn\u002Fg'",[53,1257,1258],{"class":70}," \u002Fetc\u002Fapk\u002Frepositories\n",[53,1260,1261],{"class":55,"line":106},[53,1262,331],{"emptyLinePlaceholder":253},[53,1264,1265,1267],{"class":55,"line":367},[53,1266,651],{"class":66},[53,1268,1269],{"class":70}," apk update --no-cache\n",[53,1271,1272],{"class":55,"line":384},[53,1273,1117],{"class":59},[53,1275,1276,1278],{"class":55,"line":400},[53,1277,651],{"class":66},[53,1279,1124],{"class":70},[53,1281,1282],{"class":55,"line":406},[53,1283,1284],{"class":59},"# 调试\n",[53,1286,1287,1289],{"class":55,"line":420},[53,1288,651],{"class":66},[53,1290,1291],{"class":70}," apk add --no-cache bash\n",[53,1293,1294],{"class":55,"line":443},[53,1295,1296],{"class":59},"# 设置时区\n",[53,1298,1299,1301],{"class":55,"line":452},[53,1300,651],{"class":66},[53,1302,1303],{"class":70}," apk add --no-cache tzdata\n",[53,1305,1306],{"class":55,"line":457},[53,1307,331],{"emptyLinePlaceholder":253},[53,1309,1310,1312],{"class":55,"line":463},[53,1311,507],{"class":66},[53,1313,1314],{"class":70}," TZ=Asia\u002FShanghai\n",[53,1316,1317],{"class":55,"line":474},[53,1318,331],{"emptyLinePlaceholder":253},[53,1320,1321],{"class":55,"line":485},[53,1322,1138],{"class":59},[53,1324,1325,1327],{"class":55,"line":490},[53,1326,651],{"class":66},[53,1328,1329],{"class":70}," addgroup -S glog && adduser -S -G glog glog\n",[53,1331,1332],{"class":55,"line":499},[53,1333,331],{"emptyLinePlaceholder":253},[53,1335,1336,1338],{"class":55,"line":504},[53,1337,625],{"class":66},[53,1339,1340],{"class":70}," .\u002Fglog_static_musl_1 \u002Fapp\u002F\n",[53,1342,1343],{"class":55,"line":712},[53,1344,331],{"emptyLinePlaceholder":253},[53,1346,1347,1349],{"class":55,"line":720},[53,1348,651],{"class":66},[53,1350,1351],{"class":70}," mkdir \u002Fdata\n",[53,1353,1354],{"class":55,"line":725},[53,1355,331],{"emptyLinePlaceholder":253},[53,1357,1359,1361,1364,1367],{"class":55,"line":1358},23,[53,1360,702],{"class":66},[53,1362,1363],{"class":70}," [ ",[53,1365,1366],{"class":295},"\"\u002Fdata\"",[53,1368,1369],{"class":70}," ]\n",[53,1371,1373,1375],{"class":55,"line":1372},24,[53,1374,715],{"class":66},[53,1376,705],{"class":70},[53,1378,1380],{"class":55,"line":1379},25,[53,1381,331],{"emptyLinePlaceholder":253},[53,1383,1385],{"class":55,"line":1384},26,[53,1386,1387],{"class":59},"# 修改权限\n",[53,1389,1391,1393],{"class":55,"line":1390},27,[53,1392,651],{"class":66},[53,1394,1395],{"class":70}," chown -R glog:glog \u002Fdata\n",[53,1397,1399,1401],{"class":55,"line":1398},28,[53,1400,651],{"class":66},[53,1402,1403],{"class":70}," chown -R glog:glog \u002Fapp\n",[53,1405,1407],{"class":55,"line":1406},29,[53,1408,331],{"emptyLinePlaceholder":253},[53,1410,1412],{"class":55,"line":1411},30,[53,1413,1414],{"class":59},"# 指定用户启动\n",[53,1416,1418,1421],{"class":55,"line":1417},31,[53,1419,1420],{"class":66},"USER",[53,1422,1423],{"class":70}," glog\n",[53,1425,1427,1429,1431,1434,1436,1438,1440,1443],{"class":55,"line":1426},32,[53,1428,728],{"class":66},[53,1430,1363],{"class":70},[53,1432,1433],{"class":295},"\"\u002Fapp\u002Fglog_static_musl_1\"",[53,1435,1034],{"class":70},[53,1437,1037],{"class":295},[53,1439,1034],{"class":70},[53,1441,1442],{"class":295},"\".\u002Fconf.ini\"",[53,1444,1369],{"class":70},[11,1446,216],{"id":216},[16,1448,1449],{},"关键步骤：",[521,1451,1452,1458,1468,1474],{},[524,1453,1454,1457],{},[50,1455,1456],{},"apk add --no-cache shadow"," - 安装用户管理工具",[524,1459,1460,1463,1464,1467],{},[50,1461,1462],{},"addgroup"," + ",[50,1465,1466],{},"adduser"," - 创建用户",[524,1469,1470,1473],{},[50,1471,1472],{},"chown -R"," - 修改目录权限",[524,1475,1476,1479],{},[50,1477,1478],{},"USER \u003Cusername>"," - 指定运行用户",[16,1481,1482],{},"这样容器内的应用就不再以 root 身份运行，更加安全。",[235,1484,1485],{},"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":48,"searchDepth":63,"depth":81,"links":1487},[1488,1489,1494,1495],{"id":1094,"depth":63,"text":1094},{"id":1100,"depth":63,"text":1100,"children":1490},[1491,1492,1493],{"id":1103,"depth":81,"text":1104},{"id":1127,"depth":81,"text":1128},{"id":1164,"depth":81,"text":1165},{"id":1225,"depth":63,"text":1225},{"id":216,"depth":63,"text":216},"解决 Docker 容器默认以 root 用户运行导致的权限问题，指定非 root 用户启动应用。","入门","\u002Fposts\u002Fdocker-user-start\u002Fimg\u002Fcover.svg",{},"\u002Fposts\u002Fdocker-user-start","2025-04-01",{"title":1089,"description":1496},"posts\u002Fdocker-user-start\u002Findex",[1083,1085,1505],"Linux","b4Uyw5ms87xr7NhpIkavHnNbT20oaEPtXz8k1w6UPj0",{"id":4,"title":5,"author":6,"body":1508,"description":248,"difficulty":6,"draft":249,"extension":250,"image":251,"meta":1661,"navigation":253,"path":254,"pinned":249,"published":255,"seo":1662,"series":6,"seriesOrder":6,"stem":257,"tags":1663,"updated":6,"__hash__":260},{"type":8,"value":1509,"toc":1651},[1510,1512,1514,1518,1520,1522,1526,1564,1566,1570,1575,1577,1579,1581,1637,1639,1645,1649],[11,1511,14],{"id":13},[16,1513,18],{},[16,1515,1516],{},[22,1517],{"alt":24,"src":25},[11,1519,28],{"id":28},[30,1521,33],{"id":32},[16,1523,36,1524,41],{},[38,1525,40],{},[43,1527,1528],{"className":45,"code":46,"language":47,"meta":48,"style":48},[50,1529,1530,1534,1544,1552,1560],{"__ignoreMap":48},[53,1531,1532],{"class":55,"line":56},[53,1533,60],{"class":59},[53,1535,1536,1538,1540,1542],{"class":55,"line":63},[53,1537,67],{"class":66},[53,1539,71],{"class":70},[53,1541,74],{"class":66},[53,1543,78],{"class":77},[53,1545,1546,1548,1550],{"class":55,"line":81},[53,1547,84],{"class":66},[53,1549,88],{"class":87},[53,1551,91],{"class":70},[53,1553,1554,1556,1558],{"class":55,"line":94},[53,1555,97],{"class":70},[53,1557,100],{"class":66},[53,1559,103],{"class":77},[53,1561,1562],{"class":55,"line":106},[53,1563,109],{"class":70},[30,1565,113],{"id":112},[16,1567,1568,119],{},[50,1569,118],{},[43,1571,1573],{"className":1572,"code":124,"language":125},[123],[50,1574,124],{"__ignoreMap":48},[30,1576,131],{"id":130},[16,1578,134],{},[11,1580,137],{"id":137},[139,1582,1583,1595],{},[142,1584,1585],{},[145,1586,1587,1589,1591,1593],{},[148,1588,150],{},[148,1590,153],{},[148,1592,156],{},[148,1594,159],{},[161,1596,1597,1607,1617,1627],{},[145,1598,1599,1601,1603,1605],{},[166,1600,168],{},[166,1602,171],{},[166,1604,174],{},[166,1606,174],{},[145,1608,1609,1611,1613,1615],{},[166,1610,181],{},[166,1612,184],{},[166,1614,187],{},[166,1616,174],{},[145,1618,1619,1621,1623,1625],{},[166,1620,194],{},[166,1622,197],{},[166,1624,200],{},[166,1626,200],{},[145,1628,1629,1631,1633,1635],{},[166,1630,207],{},[166,1632,184],{},[166,1634,187],{},[166,1636,187],{},[11,1638,216],{"id":216},[16,1640,219,1641,223,1643,227],{},[38,1642,222],{},[38,1644,226],{},[16,1646,1647],{},[22,1648],{"alt":232,"src":233},[235,1650,237],{},{"title":48,"searchDepth":63,"depth":81,"links":1652},[1653,1654,1659,1660],{"id":13,"depth":63,"text":14},{"id":28,"depth":63,"text":28,"children":1655},[1656,1657,1658],{"id":32,"depth":81,"text":33},{"id":112,"depth":81,"text":113},{"id":130,"depth":81,"text":131},{"id":137,"depth":63,"text":137},{"id":216,"depth":63,"text":216},{},{"title":5,"description":248},[153,259],{"id":1665,"title":1666,"author":6,"body":1667,"description":2070,"difficulty":1497,"draft":249,"extension":250,"image":2071,"meta":2072,"navigation":253,"path":2073,"pinned":253,"published":2074,"seo":2075,"series":2076,"seriesOrder":56,"stem":2077,"tags":2078,"updated":2080,"__hash__":2081},"posts\u002Fposts\u002Fhello-world\u002Findex.md","Hello World - 欢迎使用 SuBlog",{"type":8,"value":1668,"toc":2061},[1669,1673,1680,1683,1686,1689,1763,1766,1769,1832,1835,1838,1904,1907,1973,1976,1979,1995,2002,2005,2036,2039,2050,2053,2058],[11,1670,1672],{"id":1671},"博客上线啦","博客上线啦！",[16,1674,1675,1676,1679],{},"Hello World！欢迎使用 ",[38,1677,1678],{},"SuBlog","，这是站点的第一篇示例文章。",[16,1681,1682],{},"经过一段时间的折腾，这个基于 Nuxt 3 的个人博客终于上线了。在这里记录一下这个项目的技术选型和功能特性。",[11,1684,1685],{"id":1685},"技术栈",[16,1687,1688],{},"本站使用了以下技术：",[139,1690,1691,1701],{},[142,1692,1693],{},[145,1694,1695,1698],{},[148,1696,1697],{},"技术",[148,1699,1700],{},"用途",[161,1702,1703,1713,1723,1733,1743,1753],{},[145,1704,1705,1710],{},[166,1706,1707],{},[38,1708,1709],{},"Nuxt 3",[166,1711,1712],{},"Vue 3 全栈框架，提供 SSG、路由、API 等能力",[145,1714,1715,1720],{},[166,1716,1717],{},[38,1718,1719],{},"TypeScript",[166,1721,1722],{},"类型安全，提升开发体验",[145,1724,1725,1730],{},[166,1726,1727],{},[38,1728,1729],{},"Tailwind CSS v4",[166,1731,1732],{},"原子化 CSS，快速构建 UI",[145,1734,1735,1740],{},[166,1736,1737],{},[38,1738,1739],{},"@nuxt\u002Fcontent",[166,1741,1742],{},"Markdown 驱动的内容管理",[145,1744,1745,1750],{},[166,1746,1747],{},[38,1748,1749],{},"Shiki",[166,1751,1752],{},"代码语法高亮",[145,1754,1755,1760],{},[166,1756,1757],{},[38,1758,1759],{},"Giscus",[166,1761,1762],{},"基于 GitHub Discussions 的评论系统",[11,1764,1765],{"id":1765},"功能特性",[16,1767,1768],{},"目前已实现的功能：",[1770,1771,1772,1778,1784,1790,1796,1802,1808,1814,1820,1826],"ul",{},[524,1773,1774,1777],{},[38,1775,1776],{},"博客系统"," - Markdown 文章，支持代码高亮、目录导航、搜索",[524,1779,1780,1783],{},[38,1781,1782],{},"暗色模式"," - 跟随系统或手动切换",[524,1785,1786,1789],{},[38,1787,1788],{},"文章搜索"," - 支持标题、描述、正文、标签多维度搜索",[524,1791,1792,1795],{},[38,1793,1794],{},"标签系统"," - 文章标签分类，高频标签快速筛选",[524,1797,1798,1801],{},[38,1799,1800],{},"友链页面"," - 展示友情链接，支持申请",[524,1803,1804,1807],{},[38,1805,1806],{},"赞助页面"," - 微信收款码展示",[524,1809,1810,1813],{},[38,1811,1812],{},"封面制作"," - 在线生成封面图片工具",[524,1815,1816,1819],{},[38,1817,1818],{},"网易云热评"," - 首页随机展示音乐评论",[524,1821,1822,1825],{},[38,1823,1824],{},"访客统计"," - 不蒜子计数器，带数字滚动动画",[524,1827,1828,1831],{},[38,1829,1830],{},"SEO 优化"," - 自动生成站点地图、Open Graph 标签",[11,1833,1834],{"id":1834},"代码示例",[16,1836,1837],{},"本站的配置非常集中，一个文件搞定全站定制：",[43,1839,1841],{"className":45,"code":1840,"language":47,"meta":48,"style":48},"\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",[50,1842,1843,1848,1864,1875,1885,1895,1900],{"__ignoreMap":48},[53,1844,1845],{"class":55,"line":56},[53,1846,1847],{"class":59},"\u002F\u002F config\u002Fsite.ts\n",[53,1849,1850,1852,1855,1858,1861],{"class":55,"line":63},[53,1851,370],{"class":66},[53,1853,1854],{"class":66}," const",[53,1856,1857],{"class":77}," siteConfig",[53,1859,1860],{"class":66}," =",[53,1862,1863],{"class":70}," {\n",[53,1865,1866,1869,1872],{"class":55,"line":81},[53,1867,1868],{"class":70},"  siteName: ",[53,1870,1871],{"class":295},"'我的博客'",[53,1873,1874],{"class":70},",\n",[53,1876,1877,1880,1883],{"class":55,"line":94},[53,1878,1879],{"class":70},"  url: ",[53,1881,1882],{"class":295},"'https:\u002F\u002Fexample.com\u002F'",[53,1884,1874],{"class":70},[53,1886,1887,1890,1893],{"class":55,"line":106},[53,1888,1889],{"class":70},"  avatar: ",[53,1891,1892],{"class":295},"'https:\u002F\u002Fq2.qlogo.cn\u002Fheadimg_dl?dst_uin=xxx&spec=100'",[53,1894,1874],{"class":70},[53,1896,1897],{"class":55,"line":367},[53,1898,1899],{"class":59},"  \u002F\u002F ... 改这一个文件，全站生效\n",[53,1901,1902],{"class":55,"line":384},[53,1903,109],{"class":70},[16,1905,1906],{},"文章的 frontmatter 也很简洁：",[43,1908,1912],{"className":1909,"code":1910,"language":1911,"meta":48,"style":48},"language-yaml shiki shiki-themes github-light github-dark","---\ntitle: 文章标题\npublished: 2026-05-31T08:00:00\ndescription: 文章简介\ntags: ['Nuxt', 'TypeScript']\n---\n","yaml",[50,1913,1914,1919,1931,1941,1951,1969],{"__ignoreMap":48},[53,1915,1916],{"class":55,"line":56},[53,1917,1918],{"class":87},"---\n",[53,1920,1921,1925,1928],{"class":55,"line":63},[53,1922,1924],{"class":1923},"sbB4o","title",[53,1926,1927],{"class":70},": ",[53,1929,1930],{"class":295},"文章标题\n",[53,1932,1933,1936,1938],{"class":55,"line":81},[53,1934,1935],{"class":1923},"published",[53,1937,1927],{"class":70},[53,1939,1940],{"class":77},"2026-05-31T08:00:00\n",[53,1942,1943,1946,1948],{"class":55,"line":94},[53,1944,1945],{"class":1923},"description",[53,1947,1927],{"class":70},[53,1949,1950],{"class":295},"文章简介\n",[53,1952,1953,1956,1959,1962,1964,1967],{"class":55,"line":106},[53,1954,1955],{"class":1923},"tags",[53,1957,1958],{"class":70},": [",[53,1960,1961],{"class":295},"'Nuxt'",[53,1963,1034],{"class":70},[53,1965,1966],{"class":295},"'TypeScript'",[53,1968,737],{"class":70},[53,1970,1971],{"class":55,"line":367},[53,1972,1918],{"class":87},[11,1974,1975],{"id":1975},"部署",[16,1977,1978],{},"本站使用 SSG 静态生成，构建后直接部署到静态托管即可：",[43,1980,1982],{"className":278,"code":1981,"language":280,"meta":48,"style":48},"npx nuxi generate\n",[50,1983,1984],{"__ignoreMap":48},[53,1985,1986,1989,1992],{"class":55,"line":56},[53,1987,1988],{"class":87},"npx",[53,1990,1991],{"class":295}," nuxi",[53,1993,1994],{"class":295}," generate\n",[16,1996,1997,1998,2001],{},"构建产物在 ",[50,1999,2000],{},".output\u002Fpublic"," 目录下，可以直接丢到 Nginx、Vercel、Netlify 等平台。",[11,2003,2004],{"id":2004},"后续计划",[1770,2006,2009,2018,2024,2030],{"className":2007},[2008],"contains-task-list",[524,2010,2013,2017],{"className":2011},[2012],"task-list-item",[2014,2015],"input",{"disabled":253,"type":2016},"checkbox"," RSS 订阅",[524,2019,2021,2023],{"className":2020},[2012],[2014,2022],{"disabled":253,"type":2016}," 文章目录优化",[524,2025,2027,2029],{"className":2026},[2012],[2014,2028],{"disabled":253,"type":2016}," 更多小工具",[524,2031,2033,2035],{"className":2032},[2012],[2014,2034],{"disabled":253,"type":2016}," 持续输出内容",[11,2037,2038],{"id":2038},"致谢",[16,2040,2041,2042,2049],{},"感谢 ",[2043,2044,2048],"a",{"href":2045,"rel":2046},"https:\u002F\u002F2x.nz",[2047],"nofollow","二叉树树"," 的开源项目，本站的 UI 设计参考了他的项目。",[2051,2052],"hr",{},[16,2054,2055],{},[38,2056,2057],{},"感谢阅读！如果觉得不错，欢迎点个 Star 或者留个评论 (☞ﾟヮﾟ)☞",[235,2059,2060],{},"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":48,"searchDepth":63,"depth":81,"links":2062},[2063,2064,2065,2066,2067,2068,2069],{"id":1671,"depth":63,"text":1672},{"id":1685,"depth":63,"text":1685},{"id":1765,"depth":63,"text":1765},{"id":1834,"depth":63,"text":1834},{"id":1975,"depth":63,"text":1975},{"id":2004,"depth":63,"text":2004},{"id":2038,"depth":63,"text":2038},"博客上线啦！这篇文章介绍本站的技术架构、功能特性和搭建过程中的一些思考。","\u002Fposts\u002Fhello-world\u002Fimg\u002Fcover.jpg",{},"\u002Fposts\u002Fhello-world","2026-05-31T08:00:00",{"title":1666,"description":2070},"Nuxt 博客开发","posts\u002Fhello-world\u002Findex",[2079,159],"博客","2026-05-31","f9MCboCBfQIUxT8aKv6aKGDFxysiG8ZG0YXr5X5LTFo",{"id":2083,"title":2084,"author":6,"body":2085,"description":3262,"difficulty":1074,"draft":249,"extension":250,"image":3263,"meta":3264,"navigation":253,"path":3265,"pinned":249,"published":3266,"seo":3267,"series":2076,"seriesOrder":63,"stem":3268,"tags":3269,"updated":2080,"__hash__":3272},"posts\u002Fposts\u002Fnuxt-content-lessons\u002Findex.md","Nuxt Content v3 踩坑记录：目录、图片与那些反直觉的设计",{"type":8,"value":2086,"toc":3252},[2087,2091,2094,2097,2101,2104,2115,2118,2125,2193,2200,2202,2209,2488,2498,2502,2505,2508,2534,2548,2551,2574,2580,2583,2586,2589,2594,2601,2828,2833,2877,2881,2884,2891,2894,2944,2950,3179,3189,3191,3243,3246,3249],[2088,2089,2090],"h1",{"id":2090},"背景",[16,2092,2093],{},"最近在用 Nuxt 3 + Nuxt Content v3 搭建一个个人博客，参考了一个 SvelteKit 项目（svaf）的架构。原以为换个框架只是语法差异，没想到在几个关键功能上踩了不少坑。",[16,2095,2096],{},"这篇文章记录了三个核心问题的排查过程和最终方案。",[2088,2098,2100],{"id":2099},"问题一目录导航toc只有-h2","问题一：目录导航（TOC）只有 h2",[11,2102,2103],{"id":2103},"现象",[16,2105,2106,2107,2110,2111,2114],{},"Nuxt Content 内置了 TOC 数据，挂在 ",[50,2108,2109],{},"post.body.toc"," 上。直接用它渲染目录，发现",[38,2112,2113],{},"只有 h2 标题，h3 全部丢失","。",[11,2116,2117],{"id":2117},"排查",[16,2119,2120,2121,2124],{},"查了官方文档，发现 ",[50,2122,2123],{},"build.markdown.toc.depth"," 可以控制深度：",[43,2126,2128],{"className":45,"code":2127,"language":47,"meta":48,"style":48},"\u002F\u002F nuxt.config.ts\ncontent: {\n  build: {\n    markdown: {\n      toc: { depth: 3 }  \u002F\u002F 包含 h3\n    }\n  }\n}\n",[50,2129,2130,2135,2143,2150,2157,2179,2184,2189],{"__ignoreMap":48},[53,2131,2132],{"class":55,"line":56},[53,2133,2134],{"class":59},"\u002F\u002F nuxt.config.ts\n",[53,2136,2137,2140],{"class":55,"line":63},[53,2138,2139],{"class":87},"content",[53,2141,2142],{"class":70},": {\n",[53,2144,2145,2148],{"class":55,"line":81},[53,2146,2147],{"class":87},"  build",[53,2149,2142],{"class":70},[53,2151,2152,2155],{"class":55,"line":94},[53,2153,2154],{"class":87},"    markdown",[53,2156,2142],{"class":70},[53,2158,2159,2162,2165,2168,2170,2173,2176],{"class":55,"line":106},[53,2160,2161],{"class":87},"      toc",[53,2163,2164],{"class":70},": { ",[53,2166,2167],{"class":87},"depth",[53,2169,1927],{"class":70},[53,2171,2172],{"class":77},"3",[53,2174,2175],{"class":70}," }  ",[53,2177,2178],{"class":59},"\u002F\u002F 包含 h3\n",[53,2180,2181],{"class":55,"line":367},[53,2182,2183],{"class":70},"    }\n",[53,2185,2186],{"class":55,"line":384},[53,2187,2188],{"class":70},"  }\n",[53,2190,2191],{"class":55,"line":400},[53,2192,109],{"class":70},[16,2194,2195,2196,2199],{},"配置改了，清了缓存重启，",[38,2197,2198],{},"依然只有 h2","。这个配置似乎不起作用。",[11,2201,565],{"id":565},[16,2203,2204,2205,2208],{},"放弃依赖 Nuxt Content 的 toc，直接从 ",[50,2206,2207],{},"post.body"," 的 AST 里递归提取所有标题：",[43,2210,2212],{"className":45,"code":2211,"language":47,"meta":48,"style":48},"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",[50,2213,2214,2258,2278,2291,2296,2357,2387,2419,2444,2448,2473,2477,2484],{"__ignoreMap":48},[53,2215,2216,2218,2221,2224,2228,2230,2233,2235,2238,2240,2243,2246,2248,2251,2253,2255],{"class":55,"line":56},[53,2217,84],{"class":66},[53,2219,2220],{"class":87}," extractHeadings",[53,2222,2223],{"class":70},"(",[53,2225,2227],{"class":2226},"sPK6S","node",[53,2229,1211],{"class":66},[53,2231,2232],{"class":77}," any",[53,2234,1034],{"class":70},[53,2236,2237],{"class":2226},"list",[53,2239,1211],{"class":66},[53,2241,2242],{"class":87}," Heading",[53,2244,2245],{"class":70},"[] ",[53,2247,74],{"class":66},[53,2249,2250],{"class":70}," [])",[53,2252,1211],{"class":66},[53,2254,2242],{"class":87},[53,2256,2257],{"class":70},"[] {\n",[53,2259,2260,2263,2266,2269,2272,2275],{"class":55,"line":63},[53,2261,2262],{"class":66},"  if",[53,2264,2265],{"class":70}," (",[53,2267,2268],{"class":66},"!",[53,2270,2271],{"class":70},"node) ",[53,2273,2274],{"class":66},"return",[53,2276,2277],{"class":70}," list\n",[53,2279,2280,2282,2285,2288],{"class":55,"line":81},[53,2281,2262],{"class":66},[53,2283,2284],{"class":70}," (Array.",[53,2286,2287],{"class":87},"isArray",[53,2289,2290],{"class":70},"(node)) {\n",[53,2292,2293],{"class":55,"line":94},[53,2294,2295],{"class":59},"    \u002F\u002F minimark 格式：[\"h2\", {id: \"xxx\"}, \"文本\"]\n",[53,2297,2298,2301,2303,2306,2309,2312,2315,2318,2321,2324,2327,2330,2334,2337,2340,2343,2346,2349,2352,2354],{"class":55,"line":106},[53,2299,2300],{"class":66},"    if",[53,2302,2265],{"class":70},[53,2304,2305],{"class":66},"typeof",[53,2307,2308],{"class":70}," node[",[53,2310,2311],{"class":77},"0",[53,2313,2314],{"class":70},"] ",[53,2316,2317],{"class":66},"===",[53,2319,2320],{"class":295}," 'string'",[53,2322,2323],{"class":66}," &&",[53,2325,2326],{"class":295}," \u002F",[53,2328,2329],{"class":66},"^",[53,2331,2333],{"class":2332},"sPKmS","h",[53,2335,2336],{"class":77},"[1-6]",[53,2338,2339],{"class":66},"$",[53,2341,2342],{"class":295},"\u002F",[53,2344,2345],{"class":70},".",[53,2347,2348],{"class":87},"test",[53,2350,2351],{"class":70},"(node[",[53,2353,2311],{"class":77},[53,2355,2356],{"class":70},"])) {\n",[53,2358,2359,2362,2365,2367,2370,2372,2374,2377,2380,2382,2384],{"class":55,"line":367},[53,2360,2361],{"class":66},"      const",[53,2363,2364],{"class":77}," level",[53,2366,1860],{"class":66},[53,2368,2369],{"class":87}," Number",[53,2371,2351],{"class":70},[53,2373,2311],{"class":77},[53,2375,2376],{"class":70},"].",[53,2378,2379],{"class":87},"charAt",[53,2381,2223],{"class":70},[53,2383,394],{"class":77},[53,2385,2386],{"class":70},"))\n",[53,2388,2389,2391,2394,2396,2399,2402,2405,2407,2410,2413,2416],{"class":55,"line":384},[53,2390,2361],{"class":66},[53,2392,2393],{"class":77}," text",[53,2395,1860],{"class":66},[53,2397,2398],{"class":87}," extractText",[53,2400,2401],{"class":70},"(node.",[53,2403,2404],{"class":87},"slice",[53,2406,2223],{"class":70},[53,2408,2409],{"class":77},"2",[53,2411,2412],{"class":70},")).",[53,2414,2415],{"class":87},"trim",[53,2417,2418],{"class":70},"()\n",[53,2420,2421,2424,2427,2430,2432,2435,2438,2441],{"class":55,"line":400},[53,2422,2423],{"class":70},"      list.",[53,2425,2426],{"class":87},"push",[53,2428,2429],{"class":70},"({ id: node[",[53,2431,394],{"class":77},[53,2433,2434],{"class":70},"]?.id ",[53,2436,2437],{"class":66},"||",[53,2439,2440],{"class":87}," slugify",[53,2442,2443],{"class":70},"(text), text, level })\n",[53,2445,2446],{"class":55,"line":406},[53,2447,2183],{"class":70},[53,2449,2450,2453,2455,2458,2461,2464,2467,2470],{"class":55,"line":420},[53,2451,2452],{"class":66},"    for",[53,2454,2265],{"class":70},[53,2456,2457],{"class":66},"const",[53,2459,2460],{"class":77}," child",[53,2462,2463],{"class":66}," of",[53,2465,2466],{"class":70}," node) ",[53,2468,2469],{"class":87},"extractHeadings",[53,2471,2472],{"class":70},"(child, list)\n",[53,2474,2475],{"class":55,"line":443},[53,2476,2188],{"class":70},[53,2478,2479,2482],{"class":55,"line":452},[53,2480,2481],{"class":66},"  return",[53,2483,2277],{"class":70},[53,2485,2486],{"class":55,"line":457},[53,2487,109],{"class":70},[16,2489,2490,2491,2493,2494,2497],{},"关键点：Nuxt Content v3 的 body 使用 ",[38,2492,8],{}," 格式，标题节点是数组 ",[50,2495,2496],{},"[\"h2\", {id: \"xxx\"}, \"标题文本\"]","，不是对象。",[2088,2499,2501],{"id":2500},"问题二图片路径解析错误","问题二：图片路径解析错误",[11,2503,2103],{"id":2504},"现象-1",[16,2506,2507],{},"markdown 里用相对路径引用图片：",[43,2509,2513],{"className":2510,"code":2511,"language":2512,"meta":48,"style":48},"language-markdown shiki shiki-themes github-light github-dark","![SSHFS挂载效果](img\u002Fsshfs.avif)\n","markdown",[50,2514,2515],{"__ignoreMap":48},[53,2516,2517,2520,2524,2527,2531],{"class":55,"line":56},[53,2518,2519],{"class":70},"![",[53,2521,2523],{"class":2522},"smiIW","SSHFS挂载效果",[53,2525,2526],{"class":70},"](",[53,2528,2530],{"class":2529},"szbl0","img\u002Fsshfs.avif",[53,2532,2533],{"class":70},")\n",[16,2535,2536,2537,2540,2541,2544,2545,2114],{},"HTML 输出的 ",[50,2538,2539],{},"\u003Cimg src=\"img\u002Fsshfs.avif\">"," 看起来没问题，但浏览器请求的却是 ",[50,2542,2543],{},"\u002Fposts\u002Fimg\u002Fsshfs.avif"," 而不是 ",[50,2546,2547],{},"\u002Fposts\u002Fsshfs\u002Fimg\u002Fsshfs.avif",[11,2549,2117],{"id":2550},"排查-1",[521,2552,2553,2560,2563,2569],{},[524,2554,2555,2556,2559],{},"检查了 ",[50,2557,2558],{},"\u003Cbase>"," 标签 —— 没有",[524,2561,2562],{},"检查了重定向 —— 没有",[524,2564,2565,2566,2568],{},"直接访问 ",[50,2567,2547],{}," —— 返回 200 OK",[524,2570,2565,2571,2573],{},[50,2572,2543],{}," —— 404",[16,2575,2576,2577,2114],{},"服务器路由没问题，问题是",[38,2578,2579],{},"浏览器解析相对路径时基于了错误的基准 URL",[16,2581,2582],{},"这可能和 Nuxt 的客户端路由有关：SPA 导航时，浏览器的\"当前页面\"和 Vue Router 的\"当前路由\"不同步，导致相对路径解析出错。",[11,2584,565],{"id":2585},"最终方案-1",[16,2587,2588],{},"两步解决：",[16,2590,2591],{},[38,2592,2593],{},"第一步：自定义 ProseImg 组件",[16,2595,2596,2597,2600],{},"创建 ",[50,2598,2599],{},"components\u002Fcontent\u002FProseImg.vue","，覆盖 Nuxt Content 默认的图片渲染：",[43,2602,2606],{"className":2603,"code":2604,"language":2605,"meta":48,"style":48},"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",[50,2607,2608,2620,2634,2654,2670,2708,2743,2767,2772,2781,2785,2794,2820],{"__ignoreMap":48},[53,2609,2610,2612,2615,2618],{"class":55,"line":56},[53,2611,1214],{"class":70},[53,2613,2614],{"class":1923},"script",[53,2616,2617],{"class":87}," setup",[53,2619,871],{"class":70},[53,2621,2622,2624,2627,2629,2632],{"class":55,"line":63},[53,2623,2457],{"class":66},[53,2625,2626],{"class":77}," route",[53,2628,1860],{"class":66},[53,2630,2631],{"class":87}," useRoute",[53,2633,2418],{"class":70},[53,2635,2636,2638,2641,2643,2646,2649,2652],{"class":55,"line":81},[53,2637,2457],{"class":66},[53,2639,2640],{"class":77}," resolvedSrc",[53,2642,1860],{"class":66},[53,2644,2645],{"class":87}," computed",[53,2647,2648],{"class":70},"(() ",[53,2650,2651],{"class":66},"=>",[53,2653,1863],{"class":70},[53,2655,2656,2658,2660,2662,2665,2667],{"class":55,"line":94},[53,2657,2262],{"class":66},[53,2659,2265],{"class":70},[53,2661,2268],{"class":66},[53,2663,2664],{"class":70},"props.src) ",[53,2666,2274],{"class":66},[53,2668,2669],{"class":295}," ''\n",[53,2671,2672,2674,2677,2680,2682,2685,2688,2690,2693,2695,2697,2700,2703,2705],{"class":55,"line":106},[53,2673,2262],{"class":66},[53,2675,2676],{"class":70}," (props.src.",[53,2678,2679],{"class":87},"startsWith",[53,2681,2223],{"class":70},[53,2683,2684],{"class":295},"'\u002F'",[53,2686,2687],{"class":70},") ",[53,2689,2437],{"class":66},[53,2691,2692],{"class":70}," props.src.",[53,2694,2679],{"class":87},[53,2696,2223],{"class":70},[53,2698,2699],{"class":295},"'http'",[53,2701,2702],{"class":70},")) ",[53,2704,2274],{"class":66},[53,2706,2707],{"class":70}," props.src\n",[53,2709,2710,2713,2716,2718,2721,2724,2726,2728,2732,2734,2736,2738,2741],{"class":55,"line":367},[53,2711,2712],{"class":66},"  const",[53,2714,2715],{"class":77}," base",[53,2717,1860],{"class":66},[53,2719,2720],{"class":70}," route.path.",[53,2722,2723],{"class":87},"replace",[53,2725,2223],{"class":70},[53,2727,2342],{"class":295},[53,2729,2731],{"class":2730},"sCnZR","\\\u002F",[53,2733,2339],{"class":66},[53,2735,2342],{"class":295},[53,2737,1034],{"class":70},[53,2739,2740],{"class":295},"''",[53,2742,2533],{"class":70},[53,2744,2745,2747,2750,2753,2756,2759,2761,2764],{"class":55,"line":384},[53,2746,2481],{"class":66},[53,2748,2749],{"class":295}," `${",[53,2751,2752],{"class":70},"base",[53,2754,2755],{"class":295},"}\u002F${",[53,2757,2758],{"class":70},"props",[53,2760,2345],{"class":295},[53,2762,2763],{"class":70},"src",[53,2765,2766],{"class":295},"}`\n",[53,2768,2769],{"class":55,"line":400},[53,2770,2771],{"class":70},"})\n",[53,2773,2774,2777,2779],{"class":55,"line":406},[53,2775,2776],{"class":70},"\u003C\u002F",[53,2778,2614],{"class":1923},[53,2780,871],{"class":70},[53,2782,2783],{"class":55,"line":420},[53,2784,331],{"emptyLinePlaceholder":253},[53,2786,2787,2789,2792],{"class":55,"line":443},[53,2788,1214],{"class":70},[53,2790,2791],{"class":1923},"template",[53,2793,871],{"class":70},[53,2795,2796,2799,2801,2804,2806,2809,2812,2814,2817],{"class":55,"line":452},[53,2797,2798],{"class":70},"  \u003C",[53,2800,22],{"class":1923},[53,2802,2803],{"class":87}," :src",[53,2805,74],{"class":70},[53,2807,2808],{"class":295},"\"resolvedSrc\"",[53,2810,2811],{"class":87}," :alt",[53,2813,74],{"class":70},[53,2815,2816],{"class":295},"\"alt\"",[53,2818,2819],{"class":70}," \u002F>\n",[53,2821,2822,2824,2826],{"class":55,"line":457},[53,2823,2776],{"class":70},[53,2825,2791],{"class":1923},[53,2827,871],{"class":70},[16,2829,2830],{},[38,2831,2832],{},"第二步：启用 MDC prose 组件",[43,2834,2836],{"className":45,"code":2835,"language":47,"meta":48,"style":48},"\u002F\u002F nuxt.config.ts\nmdc: {\n  components: {\n    prose: true  \u002F\u002F 让 Nuxt Content 使用自定义 ProseImg\n  }\n}\n",[50,2837,2838,2842,2849,2856,2869,2873],{"__ignoreMap":48},[53,2839,2840],{"class":55,"line":56},[53,2841,2134],{"class":59},[53,2843,2844,2847],{"class":55,"line":63},[53,2845,2846],{"class":87},"mdc",[53,2848,2142],{"class":70},[53,2850,2851,2854],{"class":55,"line":81},[53,2852,2853],{"class":87},"  components",[53,2855,2142],{"class":70},[53,2857,2858,2861,2863,2866],{"class":55,"line":94},[53,2859,2860],{"class":87},"    prose",[53,2862,1927],{"class":70},[53,2864,2865],{"class":77},"true",[53,2867,2868],{"class":59},"  \u002F\u002F 让 Nuxt Content 使用自定义 ProseImg\n",[53,2870,2871],{"class":55,"line":106},[53,2872,2188],{"class":70},[53,2874,2875],{"class":55,"line":367},[53,2876,109],{"class":70},[2088,2878,2880],{"id":2879},"问题三content-目录的图片如何服务","问题三：content 目录的图片如何服务",[11,2882,2103],{"id":2883},"现象-2",[16,2885,2886,2887,2890],{},"原站（SvelteKit）的图片放在 ",[50,2888,2889],{},"content\u002Fposts\u002F{slug}\u002Fimg\u002F"," 下，markdown 用相对路径引用，一切正常。Nuxt 项目里，content 目录的文件不会被当成静态资源服务。",[11,2892,2893],{"id":2893},"方案对比",[139,2895,2896,2909],{},[142,2897,2898],{},[145,2899,2900,2903,2906],{},[148,2901,2902],{},"方案",[148,2904,2905],{},"优点",[148,2907,2908],{},"缺点",[161,2910,2911,2922,2933],{},[145,2912,2913,2916,2919],{},[166,2914,2915],{},"复制到 public\u002F",[166,2917,2918],{},"简单",[166,2920,2921],{},"文件重复，需同步",[145,2923,2924,2927,2930],{},[166,2925,2926],{},"Vite 插件拦截",[166,2928,2929],{},"原站方案",[166,2931,2932],{},"Nuxt 不直接支持",[145,2934,2935,2938,2941],{},[166,2936,2937],{},"Server Route",[166,2939,2940],{},"不复制文件",[166,2942,2943],{},"需要手动写路由",[16,2945,2946,2947,2949],{},"最终选择 ",[38,2948,2937],{},"，和原站的 Vite 插件思路一致：",[43,2951,2953],{"className":45,"code":2952,"language":47,"meta":48,"style":48},"\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",[50,2954,2955,2960,2986,3001,3065,3079,3083,3105,3131,3149,3168,3175],{"__ignoreMap":48},[53,2956,2957],{"class":55,"line":56},[53,2958,2959],{"class":59},"\u002F\u002F server\u002Froutes\u002Fposts\u002F[slug]\u002Fimg\u002F[...file].ts\n",[53,2961,2962,2964,2967,2970,2972,2975,2977,2980,2982,2984],{"class":55,"line":63},[53,2963,370],{"class":66},[53,2965,2966],{"class":66}," default",[53,2968,2969],{"class":87}," defineEventHandler",[53,2971,2223],{"class":70},[53,2973,2974],{"class":66},"async",[53,2976,2265],{"class":70},[53,2978,2979],{"class":2226},"event",[53,2981,2687],{"class":70},[53,2983,2651],{"class":66},[53,2985,1863],{"class":70},[53,2987,2988,2990,2993,2995,2998],{"class":55,"line":81},[53,2989,2712],{"class":66},[53,2991,2992],{"class":77}," url",[53,2994,1860],{"class":66},[53,2996,2997],{"class":87}," getRequestURL",[53,2999,3000],{"class":70},"(event)\n",[53,3002,3003,3005,3008,3010,3013,3016,3018,3020,3022,3024,3027,3029,3031,3034,3036,3039,3042,3045,3047,3049,3051,3053,3055,3057,3059,3061,3063],{"class":55,"line":94},[53,3004,2712],{"class":66},[53,3006,3007],{"class":77}," match",[53,3009,1860],{"class":66},[53,3011,3012],{"class":70}," url.pathname.",[53,3014,3015],{"class":87},"match",[53,3017,2223],{"class":70},[53,3019,2342],{"class":295},[53,3021,2329],{"class":66},[53,3023,2731],{"class":2730},[53,3025,3026],{"class":2332},"posts",[53,3028,2731],{"class":2730},[53,3030,2223],{"class":2332},[53,3032,3033],{"class":77},"[",[53,3035,2329],{"class":66},[53,3037,3038],{"class":77},"\u002F]",[53,3040,3041],{"class":66},"+",[53,3043,3044],{"class":2332},")",[53,3046,2731],{"class":2730},[53,3048,22],{"class":2332},[53,3050,2731],{"class":2730},[53,3052,2223],{"class":2332},[53,3054,2345],{"class":77},[53,3056,3041],{"class":66},[53,3058,3044],{"class":2332},[53,3060,2339],{"class":66},[53,3062,2342],{"class":295},[53,3064,2533],{"class":70},[53,3066,3067,3069,3071,3073,3076],{"class":55,"line":106},[53,3068,2262],{"class":66},[53,3070,2265],{"class":70},[53,3072,2268],{"class":66},[53,3074,3075],{"class":70},"match) ",[53,3077,3078],{"class":66},"return\n",[53,3080,3081],{"class":55,"line":367},[53,3082,331],{"emptyLinePlaceholder":253},[53,3084,3085,3087,3090,3093,3095,3098,3100,3102],{"class":55,"line":384},[53,3086,2712],{"class":66},[53,3088,3089],{"class":70}," [, ",[53,3091,3092],{"class":77},"slug",[53,3094,1034],{"class":70},[53,3096,3097],{"class":77},"filename",[53,3099,2314],{"class":70},[53,3101,74],{"class":66},[53,3103,3104],{"class":70}," match\n",[53,3106,3107,3109,3112,3114,3117,3119,3122,3125,3128],{"class":55,"line":400},[53,3108,2712],{"class":66},[53,3110,3111],{"class":77}," filePath",[53,3113,1860],{"class":66},[53,3115,3116],{"class":87}," resolve",[53,3118,2223],{"class":70},[53,3120,3121],{"class":295},"'content\u002Fposts'",[53,3123,3124],{"class":70},", slug, ",[53,3126,3127],{"class":295},"'img'",[53,3129,3130],{"class":70},", filename)\n",[53,3132,3133,3135,3138,3140,3143,3146],{"class":55,"line":406},[53,3134,2712],{"class":66},[53,3136,3137],{"class":77}," data",[53,3139,1860],{"class":66},[53,3141,3142],{"class":66}," await",[53,3144,3145],{"class":87}," readFile",[53,3147,3148],{"class":70},"(filePath)\n",[53,3150,3151,3154,3157,3160,3162,3165],{"class":55,"line":420},[53,3152,3153],{"class":87},"  setResponseHeader",[53,3155,3156],{"class":70},"(event, ",[53,3158,3159],{"class":295},"'content-type'",[53,3161,1034],{"class":70},[53,3163,3164],{"class":77},"MIME",[53,3166,3167],{"class":70},"[ext])\n",[53,3169,3170,3172],{"class":55,"line":443},[53,3171,2481],{"class":66},[53,3173,3174],{"class":70}," data\n",[53,3176,3177],{"class":55,"line":452},[53,3178,2771],{"class":70},[16,3180,3181,3182,3184,3185,3188],{},"请求 ",[50,3183,2547],{}," → 读取 ",[50,3186,3187],{},"content\u002Fposts\u002Fsshfs\u002Fimg\u002Fsshfs.avif"," → 返回图片。",[2088,3190,216],{"id":216},[139,3192,3193,3205],{},[142,3194,3195],{},[145,3196,3197,3200,3203],{},[148,3198,3199],{},"问题",[148,3201,3202],{},"根因",[148,3204,2902],{},[161,3206,3207,3221,3232],{},[145,3208,3209,3212,3218],{},[166,3210,3211],{},"TOC 只有 h2",[166,3213,3214,3217],{},[50,3215,3216],{},"toc.depth"," 配置不生效",[166,3219,3220],{},"从 body AST 手动提取",[145,3222,3223,3226,3229],{},[166,3224,3225],{},"图片路径错误",[166,3227,3228],{},"SPA 路由导致相对路径解析基准错误",[166,3230,3231],{},"ProseImg 组件 + mdc.prose 配置",[145,3233,3234,3237,3240],{},[166,3235,3236],{},"content 图片 404",[166,3238,3239],{},"content 目录不暴露给浏览器",[166,3241,3242],{},"Server Route 直接读取返回",[16,3244,3245],{},"Nuxt Content v3 的设计理念是\"图片放 public\u002F，用绝对路径\"。如果想像 SvelteKit 项目那样把图片和文章放一起，需要额外做不少工作。",[16,3247,3248],{},"不过一旦这些坑都踩过，整体开发体验还是很流畅的。Nuxt 的生态系统、自动导入、模块系统都比 SvelteKit 成熟很多。",[235,3250,3251],{},"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":48,"searchDepth":63,"depth":81,"links":3253},[3254,3255,3256,3257,3258,3259,3260,3261],{"id":2103,"depth":63,"text":2103},{"id":2117,"depth":63,"text":2117},{"id":565,"depth":63,"text":565},{"id":2504,"depth":63,"text":2103},{"id":2550,"depth":63,"text":2117},{"id":2585,"depth":63,"text":565},{"id":2883,"depth":63,"text":2103},{"id":2893,"depth":63,"text":2893},"从 SvelteKit 迁移到 Nuxt 3 的过程中，遇到了不少 Nuxt Content v3 的坑。这篇文章记录了目录导航、图片路径、ProseImg 组件等问题的排查和解决过程。","\u002Fposts\u002Fnuxt-content-lessons\u002Fimg\u002Fcover.jpg",{},"\u002Fposts\u002Fnuxt-content-lessons","2026-05-31T14:00:00",{"title":2084,"description":3262},"posts\u002Fnuxt-content-lessons\u002Findex",[159,3270,3271],"踩坑","Nuxt Content","vbSt6ND1z7xt7L8FxgZ8Qe3EdxV9InsRerN3E23GC7s",{"id":3274,"title":3275,"author":1678,"body":3276,"description":3541,"difficulty":1074,"draft":249,"extension":250,"image":3542,"meta":3543,"navigation":253,"path":3544,"pinned":249,"published":3545,"seo":3546,"series":2076,"seriesOrder":81,"stem":3547,"tags":3548,"updated":2080,"__hash__":3551},"posts\u002Fposts\u002Fnuxt-tailwind-underline-fix\u002Findex.md","Nuxt 3 + Tailwind CSS 客户端导航链接下划线闪烁问题",{"type":8,"value":3277,"toc":3534},[3278,3281,3284,3299,3302,3305,3308,3349,3360,3367,3389,3396,3400,3403,3406,3408,3411,3434,3445,3502,3507,3520,3522,3528,3531],[11,3279,3280],{"id":3280},"问题描述",[16,3282,3283],{},"在 Nuxt 3 项目中使用 Tailwind CSS 时，发现一个奇怪的现象：",[1770,3285,3286,3296],{},[524,3287,3288,3289,3292,3293],{},"首次通过客户端导航（点击 ",[50,3290,3291],{},"\u003CNuxtLink>","）跳转到某个页面时，",[38,3294,3295],{},"所有链接文字都会出现下划线",[524,3297,3298],{},"手动刷新页面后，下划线消失，样式恢复正常",[16,3300,3301],{},"这个问题在开发模式和生产构建后都会出现。",[11,3303,3304],{"id":3304},"原因分析",[16,3306,3307],{},"Tailwind CSS 的 Preflight（基于 modern-normalize）会重置链接样式：",[43,3309,3313],{"className":3310,"code":3311,"language":3312,"meta":48,"style":48},"language-css shiki shiki-themes github-light github-dark","a {\n  color: inherit;\n  text-decoration: inherit;\n}\n","css",[50,3314,3315,3321,3334,3345],{"__ignoreMap":48},[53,3316,3317,3319],{"class":55,"line":56},[53,3318,2043],{"class":1923},[53,3320,1863],{"class":70},[53,3322,3323,3326,3328,3331],{"class":55,"line":63},[53,3324,3325],{"class":77},"  color",[53,3327,1927],{"class":70},[53,3329,3330],{"class":77},"inherit",[53,3332,3333],{"class":70},";\n",[53,3335,3336,3339,3341,3343],{"class":55,"line":81},[53,3337,3338],{"class":77},"  text-decoration",[53,3340,1927],{"class":70},[53,3342,3330],{"class":77},[53,3344,3333],{"class":70},[53,3346,3347],{"class":55,"line":94},[53,3348,109],{"class":70},[16,3350,3351,3352,3355,3356,3359],{},"正常情况下，这会让链接继承父元素的 ",[50,3353,3354],{},"text-decoration","（通常是 ",[50,3357,3358],{},"none","），从而去掉浏览器默认的下划线。",[16,3361,3362,3363,3366],{},"问题出在 ",[38,3364,3365],{},"Nuxt 的客户端导航机制","：",[521,3368,3369,3375,3378,3383,3386],{},[524,3370,3371,3372,3374],{},"用户点击 ",[50,3373,3291],{},"，Vue Router 拦截导航",[524,3376,3377],{},"新页面组件异步加载并渲染",[524,3379,3380],{},[38,3381,3382],{},"在 CSS 完全加载\u002F应用之前，页面已经渲染了",[524,3384,3385],{},"此时浏览器使用默认样式（链接带下划线）",[524,3387,3388],{},"CSS 加载完成后，下划线消失",[16,3390,3391,3392,3395],{},"这就是典型的 ",[38,3393,3394],{},"FOUC（Flash of Unstyled Content）"," 问题。",[11,3397,3399],{"id":3398},"为什么刷新就正常了","为什么刷新就正常了？",[16,3401,3402],{},"刷新页面时，浏览器会等待所有 CSS 加载完成后再渲染页面（SSR 模式下 HTML 和 CSS 是一起返回的）。所以刷新后样式是正确的。",[16,3404,3405],{},"而客户端导航是 JavaScript 驱动的，CSS 的加载和页面的渲染是异步的，就出现了时序差。",[11,3407,1100],{"id":1100},[16,3409,3410],{},"尝试了多种方案：",[1770,3412,3413,3419,3425,3431],{},[524,3414,3415,3418],{},[50,3416,3417],{},"@layer base"," 覆盖 — 无效，CSS layers 的优先级问题",[524,3420,3421,3424],{},[50,3422,3423],{},"corePlugins.preflight: false"," — 无效，且破坏其他基础样式",[524,3426,3427,3430],{},[50,3428,3429],{},"features.inlineStyles: true"," — 无效",[524,3432,3433],{},"页面过渡动画 — 只是掩盖，不能根治",[16,3435,3436,3437,3440,3441,3444],{},"最终有效的方案是在 ",[50,3438,3439],{},"app.vue"," 中用 ",[50,3442,3443],{},"!important"," 强制覆盖：",[43,3446,3448],{"className":2603,"code":3447,"language":2605,"meta":48,"style":48},"\u003Cstyle>\na:not(.prose a) {\n  text-decoration-line: none !important;\n}\n\u003C\u002Fstyle>\n",[50,3449,3450,3458,3476,3490,3494],{"__ignoreMap":48},[53,3451,3452,3454,3456],{"class":55,"line":56},[53,3453,1214],{"class":70},[53,3455,235],{"class":1923},[53,3457,871],{"class":70},[53,3459,3460,3462,3465,3467,3470,3473],{"class":55,"line":63},[53,3461,2043],{"class":1923},[53,3463,3464],{"class":87},":not",[53,3466,2223],{"class":70},[53,3468,3469],{"class":87},".prose",[53,3471,3472],{"class":1923}," a",[53,3474,3475],{"class":70},") {\n",[53,3477,3478,3481,3483,3485,3488],{"class":55,"line":81},[53,3479,3480],{"class":77},"  text-decoration-line",[53,3482,1927],{"class":70},[53,3484,3358],{"class":77},[53,3486,3487],{"class":66}," !important",[53,3489,3333],{"class":70},[53,3491,3492],{"class":55,"line":94},[53,3493,109],{"class":70},[53,3495,3496,3498,3500],{"class":55,"line":106},[53,3497,2776],{"class":70},[53,3499,235],{"class":1923},[53,3501,871],{"class":70},[16,3503,3504],{},[38,3505,3506],{},"关键点：",[1770,3508,3509,3514],{},[524,3510,3511,3513],{},[50,3512,3443],{}," 是为了对抗 Tailwind CSS layers 的优先级",[524,3515,3516,3519],{},[50,3517,3518],{},":not(.prose a)"," 排除文章正文区域，保留文章内链接的下划线（可读性需要）",[11,3521,216],{"id":216},[16,3523,3524,3525,3527],{},"这是 Nuxt 3 + Tailwind CSS 的一个已知问题，本质上是 CSS 加载时序导致的 FOUC。",[50,3526,3443],{}," 虽然不够优雅，但在这个场景下是务实的解决方案。",[16,3529,3530],{},"如果你有更好的方案，欢迎在项目仓库的 Issues 中提出！",[235,3532,3533],{},"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":48,"searchDepth":63,"depth":81,"links":3535},[3536,3537,3538,3539,3540],{"id":3280,"depth":63,"text":3280},{"id":3304,"depth":63,"text":3304},{"id":3398,"depth":63,"text":3399},{"id":1100,"depth":63,"text":1100},{"id":216,"depth":63,"text":216},"记录 Nuxt 3 配合 Tailwind CSS 时，客户端导航导致链接文字出现下划线闪烁的问题及解决方案。","\u002Fposts\u002Fnuxt-tailwind-underline-fix\u002Fimg\u002Fcover.png",{},"\u002Fposts\u002Fnuxt-tailwind-underline-fix","2026-05-31T18:47:00",{"title":3275,"description":3541},"posts\u002Fnuxt-tailwind-underline-fix\u002Findex",[159,3549,3550,3270],"Tailwind CSS","CSS","WMLpTEsIoJqufwEfXyIiBgUYxcPY2XkvOwqAD4hbe6g",{"id":3553,"title":3554,"author":6,"body":3555,"description":5606,"difficulty":1074,"draft":249,"extension":250,"image":5607,"meta":5608,"navigation":253,"path":5609,"pinned":249,"published":5610,"seo":5611,"series":5612,"seriesOrder":56,"stem":5613,"tags":5614,"updated":5619,"__hash__":5620},"posts\u002Fposts\u002Fgo-mcp-gin\u002Findex.md","Go-MCP 接入 Gin：从零搭建 MCP 服务器",{"type":8,"value":3556,"toc":5587},[3557,3561,3564,3567,3571,3578,3584,3587,3590,3610,3613,3617,3641,3645,3745,3758,3762,3926,3929,4146,4150,4161,4260,4267,4271,4545,4548,4629,4633,4636,4638,4649,4653,4659,5026,5216,5219,5275,5278,5431,5434,5437,5508,5510,5575,5584],[11,3558,3560],{"id":3559},"什么是-mcp","什么是 MCP",[16,3562,3563],{},"MCP（Model Context Protocol）是 Anthropic 提出的开放协议，用于让 AI 助手（如 Claude）与外部服务进行标准化交互。简单来说：你提供一个 MCP 服务器，AI 就能发现并调用你定义的工具（Tools），就像给 AI 装了一双手。",[16,3565,3566],{},"传统做法是写 REST API 然后让 AI 通过函数调用（Function Calling）间接使用，但 MCP 统一了协议层，AI 客户端只需要配置一个 URL 就能接入所有工具。",[11,3568,3570],{"id":3569},"为什么选-gin-mcp-go","为什么选 Gin + mcp-go",[16,3572,3573,3574,3577],{},"Go 生态里做 MCP 服务器最成熟的库是 ",[50,3575,3576],{},"mark3labs\u002Fmcp-go","，它支持 StreamableHTTP 传输模式，可以直接挂载到任意 HTTP 框架上。Gin 作为 Go 最流行的 HTTP 框架，天然适配。",[16,3579,3580,3581],{},"核心思路：",[38,3582,3583],{},"MCP 服务器本质上就是一个 HTTP Handler，挂到 Gin 路由上即可。",[11,3585,3586],{"id":3586},"需求分析",[16,3588,3589],{},"我们要搭建一个具备以下能力的 MCP 服务器：",[521,3591,3592,3598,3604],{},[524,3593,3594,3597],{},[38,3595,3596],{},"工具注册"," — 向 AI 暴露可调用的工具（列出资源、查看详情、创建\u002F删除）",[524,3599,3600,3603],{},[38,3601,3602],{},"HTTP 集成"," — MCP 端点作为 Gin 路由的一部分，与业务 API 共存",[524,3605,3606,3609],{},[38,3607,3608],{},"鉴权"," — MCP 请求必须经过身份验证，工具处理器能获取当前用户信息",[11,3611,3612],{"id":3612},"实现步骤",[30,3614,3616],{"id":3615},"_1-安装依赖","1. 安装依赖",[43,3618,3620],{"className":278,"code":3619,"language":280,"meta":48,"style":48},"go get github.com\u002Fmark3labs\u002Fmcp-go@v0.54.0\ngo get github.com\u002Fgin-gonic\u002Fgin@v1.12.0\n",[50,3621,3622,3632],{"__ignoreMap":48},[53,3623,3624,3626,3629],{"class":55,"line":56},[53,3625,409],{"class":87},[53,3627,3628],{"class":295}," get",[53,3630,3631],{"class":295}," github.com\u002Fmark3labs\u002Fmcp-go@v0.54.0\n",[53,3633,3634,3636,3638],{"class":55,"line":63},[53,3635,409],{"class":87},[53,3637,3628],{"class":295},[53,3639,3640],{"class":295}," github.com\u002Fgin-gonic\u002Fgin@v1.12.0\n",[30,3642,3644],{"id":3643},"_2-创建-mcp-服务器","2. 创建 MCP 服务器",[43,3646,3649],{"className":3647,"code":3648,"language":409,"meta":48,"style":48},"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",[50,3650,3651,3663,3667,3684,3695,3706,3717,3726,3741],{"__ignoreMap":48},[53,3652,3653,3656,3658,3661],{"class":55,"line":56},[53,3654,3655],{"class":66},"import",[53,3657,308],{"class":295},[53,3659,3660],{"class":87},"github.com\u002Fmark3labs\u002Fmcp-go\u002Fserver",[53,3662,516],{"class":295},[53,3664,3665],{"class":55,"line":63},[53,3666,331],{"emptyLinePlaceholder":253},[53,3668,3669,3672,3675,3678,3681],{"class":55,"line":81},[53,3670,3671],{"class":70},"mcpServer ",[53,3673,3674],{"class":66},":=",[53,3676,3677],{"class":70}," server.",[53,3679,3680],{"class":87},"NewMCPServer",[53,3682,3683],{"class":70},"(\n",[53,3685,3686,3689,3692],{"class":55,"line":94},[53,3687,3688],{"class":295},"    \"my-app\"",[53,3690,3691],{"class":70},",    ",[53,3693,3694],{"class":59},"\u002F\u002F 服务器名称\n",[53,3696,3697,3700,3703],{"class":55,"line":106},[53,3698,3699],{"class":295},"    \"1.0.0\"",[53,3701,3702],{"class":70},",     ",[53,3704,3705],{"class":59},"\u002F\u002F 版本\n",[53,3707,3708,3711,3714],{"class":55,"line":367},[53,3709,3710],{"class":70},"    server.",[53,3712,3713],{"class":87},"WithLogging",[53,3715,3716],{"class":70},"(),\n",[53,3718,3719,3721,3724],{"class":55,"line":384},[53,3720,3710],{"class":70},[53,3722,3723],{"class":87},"WithRecovery",[53,3725,3716],{"class":70},[53,3727,3728,3730,3733,3735,3738],{"class":55,"line":400},[53,3729,3710],{"class":70},[53,3731,3732],{"class":87},"WithInstructions",[53,3734,2223],{"class":70},[53,3736,3737],{"class":295},"\"这是一个 MCP 服务器示例\"",[53,3739,3740],{"class":70},"),\n",[53,3742,3743],{"class":55,"line":406},[53,3744,2533],{"class":70},[16,3746,3747,3749,3750,3753,3754,3757],{},[50,3748,3680],{}," 创建一个 MCP 协议实例，它负责处理工具发现（",[50,3751,3752],{},"tools\u002Flist","）和工具调用（",[50,3755,3756],{},"tools\u002Fcall","）的协议逻辑。",[30,3759,3761],{"id":3760},"_3-注册工具","3. 注册工具",[43,3763,3765],{"className":3647,"code":3764,"language":409,"meta":48,"style":48},"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",[50,3766,3767,3778,3782,3787,3807,3822,3826,3830,3835,3896,3901,3922],{"__ignoreMap":48},[53,3768,3769,3771,3773,3776],{"class":55,"line":56},[53,3770,3655],{"class":66},[53,3772,308],{"class":295},[53,3774,3775],{"class":87},"github.com\u002Fmark3labs\u002Fmcp-go\u002Fmcp",[53,3777,516],{"class":295},[53,3779,3780],{"class":55,"line":63},[53,3781,331],{"emptyLinePlaceholder":253},[53,3783,3784],{"class":55,"line":81},[53,3785,3786],{"class":59},"\u002F\u002F 定义工具\n",[53,3788,3789,3792,3794,3797,3800,3802,3805],{"class":55,"line":94},[53,3790,3791],{"class":70},"listTool ",[53,3793,3674],{"class":66},[53,3795,3796],{"class":70}," mcp.",[53,3798,3799],{"class":87},"NewTool",[53,3801,2223],{"class":70},[53,3803,3804],{"class":295},"\"list_items\"",[53,3806,1874],{"class":70},[53,3808,3809,3812,3815,3817,3820],{"class":55,"line":106},[53,3810,3811],{"class":70},"    mcp.",[53,3813,3814],{"class":87},"WithDescription",[53,3816,2223],{"class":70},[53,3818,3819],{"class":295},"\"列出所有项目\"",[53,3821,3740],{"class":70},[53,3823,3824],{"class":55,"line":367},[53,3825,2533],{"class":70},[53,3827,3828],{"class":55,"line":384},[53,3829,331],{"emptyLinePlaceholder":253},[53,3831,3832],{"class":55,"line":400},[53,3833,3834],{"class":59},"\u002F\u002F 注册处理器\n",[53,3836,3837,3840,3843,3846,3849,3851,3854,3857,3859,3862,3864,3867,3870,3872,3875,3878,3881,3884,3886,3889,3891,3894],{"class":55,"line":406},[53,3838,3839],{"class":70},"mcpServer.",[53,3841,3842],{"class":87},"AddTool",[53,3844,3845],{"class":70},"(listTool, ",[53,3847,3848],{"class":66},"func",[53,3850,2223],{"class":70},[53,3852,3853],{"class":2226},"ctx",[53,3855,3856],{"class":87}," context",[53,3858,2345],{"class":70},[53,3860,3861],{"class":87},"Context",[53,3863,1034],{"class":70},[53,3865,3866],{"class":2226},"req",[53,3868,3869],{"class":87}," mcp",[53,3871,2345],{"class":70},[53,3873,3874],{"class":87},"CallToolRequest",[53,3876,3877],{"class":70},") (",[53,3879,3880],{"class":66},"*",[53,3882,3883],{"class":87},"mcp",[53,3885,2345],{"class":70},[53,3887,3888],{"class":87},"CallToolResult",[53,3890,1034],{"class":70},[53,3892,3893],{"class":66},"error",[53,3895,3475],{"class":70},[53,3897,3898],{"class":55,"line":420},[53,3899,3900],{"class":59},"    \u002F\u002F 业务逻辑\n",[53,3902,3903,3906,3908,3911,3913,3916,3919],{"class":55,"line":443},[53,3904,3905],{"class":66},"    return",[53,3907,3796],{"class":70},[53,3909,3910],{"class":87},"NewToolResultText",[53,3912,2223],{"class":70},[53,3914,3915],{"class":295},"\"返回结果\"",[53,3917,3918],{"class":70},"), ",[53,3920,3921],{"class":77},"nil\n",[53,3923,3924],{"class":55,"line":452},[53,3925,2771],{"class":70},[16,3927,3928],{},"带参数的工具：",[43,3930,3932],{"className":3647,"code":3931,"language":409,"meta":48,"style":48},"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",[50,3933,3934,3952,3965,3979,3989,4003,4008,4012,4016,4063,4082,4097,4116,4120,4125,4142],{"__ignoreMap":48},[53,3935,3936,3939,3941,3943,3945,3947,3950],{"class":55,"line":56},[53,3937,3938],{"class":70},"getTool ",[53,3940,3674],{"class":66},[53,3942,3796],{"class":70},[53,3944,3799],{"class":87},[53,3946,2223],{"class":70},[53,3948,3949],{"class":295},"\"get_item\"",[53,3951,1874],{"class":70},[53,3953,3954,3956,3958,3960,3963],{"class":55,"line":63},[53,3955,3811],{"class":70},[53,3957,3814],{"class":87},[53,3959,2223],{"class":70},[53,3961,3962],{"class":295},"\"获取指定项目的详情\"",[53,3964,3740],{"class":70},[53,3966,3967,3969,3972,3974,3977],{"class":55,"line":81},[53,3968,3811],{"class":70},[53,3970,3971],{"class":87},"WithString",[53,3973,2223],{"class":70},[53,3975,3976],{"class":295},"\"item_id\"",[53,3978,1874],{"class":70},[53,3980,3981,3984,3987],{"class":55,"line":94},[53,3982,3983],{"class":70},"        mcp.",[53,3985,3986],{"class":87},"Required",[53,3988,3716],{"class":70},[53,3990,3991,3993,3996,3998,4001],{"class":55,"line":106},[53,3992,3983],{"class":70},[53,3994,3995],{"class":87},"Description",[53,3997,2223],{"class":70},[53,3999,4000],{"class":295},"\"项目 ID\"",[53,4002,3740],{"class":70},[53,4004,4005],{"class":55,"line":367},[53,4006,4007],{"class":70},"    ),\n",[53,4009,4010],{"class":55,"line":384},[53,4011,2533],{"class":70},[53,4013,4014],{"class":55,"line":400},[53,4015,331],{"emptyLinePlaceholder":253},[53,4017,4018,4020,4022,4025,4027,4029,4031,4033,4035,4037,4039,4041,4043,4045,4047,4049,4051,4053,4055,4057,4059,4061],{"class":55,"line":406},[53,4019,3839],{"class":70},[53,4021,3842],{"class":87},[53,4023,4024],{"class":70},"(getTool, ",[53,4026,3848],{"class":66},[53,4028,2223],{"class":70},[53,4030,3853],{"class":2226},[53,4032,3856],{"class":87},[53,4034,2345],{"class":70},[53,4036,3861],{"class":87},[53,4038,1034],{"class":70},[53,4040,3866],{"class":2226},[53,4042,3869],{"class":87},[53,4044,2345],{"class":70},[53,4046,3874],{"class":87},[53,4048,3877],{"class":70},[53,4050,3880],{"class":66},[53,4052,3883],{"class":87},[53,4054,2345],{"class":70},[53,4056,3888],{"class":87},[53,4058,1034],{"class":70},[53,4060,3893],{"class":66},[53,4062,3475],{"class":70},[53,4064,4065,4068,4070,4073,4076,4078,4080],{"class":55,"line":420},[53,4066,4067],{"class":70},"    itemID, err ",[53,4069,3674],{"class":66},[53,4071,4072],{"class":70}," req.",[53,4074,4075],{"class":87},"RequireString",[53,4077,2223],{"class":70},[53,4079,3976],{"class":295},[53,4081,2533],{"class":70},[53,4083,4084,4086,4089,4092,4095],{"class":55,"line":443},[53,4085,2300],{"class":66},[53,4087,4088],{"class":70}," err ",[53,4090,4091],{"class":66},"!=",[53,4093,4094],{"class":77}," nil",[53,4096,1863],{"class":70},[53,4098,4099,4102,4104,4107,4109,4112,4114],{"class":55,"line":452},[53,4100,4101],{"class":66},"        return",[53,4103,3796],{"class":70},[53,4105,4106],{"class":87},"NewToolResultError",[53,4108,2223],{"class":70},[53,4110,4111],{"class":295},"\"缺少 item_id 参数\"",[53,4113,3918],{"class":70},[53,4115,3921],{"class":77},[53,4117,4118],{"class":55,"line":457},[53,4119,2183],{"class":70},[53,4121,4122],{"class":55,"line":463},[53,4123,4124],{"class":59},"    \u002F\u002F 查询并返回\n",[53,4126,4127,4129,4131,4133,4135,4138,4140],{"class":55,"line":474},[53,4128,3905],{"class":66},[53,4130,3796],{"class":70},[53,4132,3910],{"class":87},[53,4134,2223],{"class":70},[53,4136,4137],{"class":295},"\"项目详情...\"",[53,4139,3918],{"class":70},[53,4141,3921],{"class":77},[53,4143,4144],{"class":55,"line":485},[53,4145,2771],{"class":70},[30,4147,4149],{"id":4148},"_4-挂载到-gin-路由","4. 挂载到 Gin 路由",[16,4151,4152,4153,4156,4157,4160],{},"关键一步：用 ",[50,4154,4155],{},"server.NewStreamableHTTPServer"," 把 MCP 实例转成 HTTP Handler，再用 ",[50,4158,4159],{},"gin.WrapH"," 适配 Gin。",[43,4162,4164],{"className":3647,"code":4163,"language":409,"meta":48,"style":48},"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",[50,4165,4166,4176,4180,4195,4209,4226,4230,4234,4239],{"__ignoreMap":48},[53,4167,4168,4170,4172,4174],{"class":55,"line":56},[53,4169,3655],{"class":66},[53,4171,308],{"class":295},[53,4173,3660],{"class":87},[53,4175,516],{"class":295},[53,4177,4178],{"class":55,"line":63},[53,4179,331],{"emptyLinePlaceholder":253},[53,4181,4182,4185,4187,4189,4192],{"class":55,"line":81},[53,4183,4184],{"class":70},"mcpHTTPServer ",[53,4186,3674],{"class":66},[53,4188,3677],{"class":70},[53,4190,4191],{"class":87},"NewStreamableHTTPServer",[53,4193,4194],{"class":70},"(mcpServer,\n",[53,4196,4197,4199,4202,4204,4207],{"class":55,"line":94},[53,4198,3710],{"class":70},[53,4200,4201],{"class":87},"WithEndpointPath",[53,4203,2223],{"class":70},[53,4205,4206],{"class":295},"\"\u002Fmcp\"",[53,4208,3740],{"class":70},[53,4210,4211,4213,4216,4218,4221,4223],{"class":55,"line":106},[53,4212,3710],{"class":70},[53,4214,4215],{"class":87},"WithHeartbeatInterval",[53,4217,2223],{"class":70},[53,4219,4220],{"class":77},"30",[53,4222,3880],{"class":66},[53,4224,4225],{"class":70},"time.Second),\n",[53,4227,4228],{"class":55,"line":367},[53,4229,2533],{"class":70},[53,4231,4232],{"class":55,"line":384},[53,4233,331],{"emptyLinePlaceholder":253},[53,4235,4236],{"class":55,"line":400},[53,4237,4238],{"class":59},"\u002F\u002F 挂载到 Gin\n",[53,4240,4241,4244,4247,4249,4251,4254,4257],{"class":55,"line":406},[53,4242,4243],{"class":70},"r.",[53,4245,4246],{"class":87},"Any",[53,4248,2223],{"class":70},[53,4250,4206],{"class":295},[53,4252,4253],{"class":70},", gin.",[53,4255,4256],{"class":87},"WrapH",[53,4258,4259],{"class":70},"(mcpHTTPServer))\n",[16,4261,4262,4263,4266],{},"就这样，",[50,4264,4265],{},"POST \u002Fmcp"," 端点就生效了。AI 客户端通过这个 URL 发送 JSON-RPC 请求，MCP 服务器自动处理协议握手、工具列表返回、工具调用路由。",[30,4268,4270],{"id":4269},"_5-完整的服务启动代码","5. 完整的服务启动代码",[43,4272,4274],{"className":3647,"code":4273,"language":409,"meta":48,"style":48},"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",[50,4275,4276,4285,4300,4304,4309,4332,4341,4349,4354,4358,4363,4371,4375,4380,4393,4405,4419,4423,4441,4445,4450,4483,4518,4523,4527,4541],{"__ignoreMap":48},[53,4277,4278,4280,4283],{"class":55,"line":56},[53,4279,3848],{"class":66},[53,4281,4282],{"class":87}," main",[53,4284,91],{"class":70},[53,4286,4287,4290,4292,4295,4298],{"class":55,"line":63},[53,4288,4289],{"class":70},"    r ",[53,4291,3674],{"class":66},[53,4293,4294],{"class":70}," gin.",[53,4296,4297],{"class":87},"Default",[53,4299,2418],{"class":70},[53,4301,4302],{"class":55,"line":81},[53,4303,331],{"emptyLinePlaceholder":253},[53,4305,4306],{"class":55,"line":94},[53,4307,4308],{"class":59},"    \u002F\u002F 创建 MCP 服务器\n",[53,4310,4311,4314,4316,4318,4320,4322,4325,4327,4330],{"class":55,"line":106},[53,4312,4313],{"class":70},"    mcpServer ",[53,4315,3674],{"class":66},[53,4317,3677],{"class":70},[53,4319,3680],{"class":87},[53,4321,2223],{"class":70},[53,4323,4324],{"class":295},"\"my-app\"",[53,4326,1034],{"class":70},[53,4328,4329],{"class":295},"\"1.0.0\"",[53,4331,1874],{"class":70},[53,4333,4334,4337,4339],{"class":55,"line":367},[53,4335,4336],{"class":70},"        server.",[53,4338,3713],{"class":87},[53,4340,3716],{"class":70},[53,4342,4343,4345,4347],{"class":55,"line":384},[53,4344,4336],{"class":70},[53,4346,3723],{"class":87},[53,4348,3716],{"class":70},[53,4350,4351],{"class":55,"line":400},[53,4352,4353],{"class":70},"    )\n",[53,4355,4356],{"class":55,"line":406},[53,4357,331],{"emptyLinePlaceholder":253},[53,4359,4360],{"class":55,"line":420},[53,4361,4362],{"class":59},"    \u002F\u002F 注册工具\n",[53,4364,4365,4368],{"class":55,"line":443},[53,4366,4367],{"class":87},"    registerTools",[53,4369,4370],{"class":70},"(mcpServer)\n",[53,4372,4373],{"class":55,"line":452},[53,4374,331],{"emptyLinePlaceholder":253},[53,4376,4377],{"class":55,"line":457},[53,4378,4379],{"class":59},"    \u002F\u002F 挂载到 Gin\n",[53,4381,4382,4385,4387,4389,4391],{"class":55,"line":463},[53,4383,4384],{"class":70},"    mcpHTTP ",[53,4386,3674],{"class":66},[53,4388,3677],{"class":70},[53,4390,4191],{"class":87},[53,4392,4194],{"class":70},[53,4394,4395,4397,4399,4401,4403],{"class":55,"line":474},[53,4396,4336],{"class":70},[53,4398,4201],{"class":87},[53,4400,2223],{"class":70},[53,4402,4206],{"class":295},[53,4404,3740],{"class":70},[53,4406,4407,4409,4411,4413,4415,4417],{"class":55,"line":485},[53,4408,4336],{"class":70},[53,4410,4215],{"class":87},[53,4412,2223],{"class":70},[53,4414,4220],{"class":77},[53,4416,3880],{"class":66},[53,4418,4225],{"class":70},[53,4420,4421],{"class":55,"line":490},[53,4422,4353],{"class":70},[53,4424,4425,4428,4430,4432,4434,4436,4438],{"class":55,"line":499},[53,4426,4427],{"class":70},"    r.",[53,4429,4246],{"class":87},[53,4431,2223],{"class":70},[53,4433,4206],{"class":295},[53,4435,4253],{"class":70},[53,4437,4256],{"class":87},[53,4439,4440],{"class":70},"(mcpHTTP))\n",[53,4442,4443],{"class":55,"line":504},[53,4444,331],{"emptyLinePlaceholder":253},[53,4446,4447],{"class":55,"line":712},[53,4448,4449],{"class":59},"    \u002F\u002F 其他业务路由\n",[53,4451,4452,4454,4457,4459,4462,4464,4466,4468,4471,4474,4477,4479,4481],{"class":55,"line":720},[53,4453,4427],{"class":70},[53,4455,4456],{"class":87},"GET",[53,4458,2223],{"class":70},[53,4460,4461],{"class":295},"\"\u002Fapi\u002Fhealth\"",[53,4463,1034],{"class":70},[53,4465,3848],{"class":66},[53,4467,2223],{"class":70},[53,4469,4470],{"class":2226},"c",[53,4472,4473],{"class":66}," *",[53,4475,4476],{"class":87},"gin",[53,4478,2345],{"class":70},[53,4480,3861],{"class":87},[53,4482,3475],{"class":70},[53,4484,4485,4488,4491,4493,4496,4498,4500,4502,4505,4508,4511,4513,4516],{"class":55,"line":725},[53,4486,4487],{"class":70},"        c.",[53,4489,4490],{"class":87},"JSON",[53,4492,2223],{"class":70},[53,4494,4495],{"class":77},"200",[53,4497,1034],{"class":70},[53,4499,4476],{"class":87},[53,4501,2345],{"class":70},[53,4503,4504],{"class":87},"H",[53,4506,4507],{"class":70},"{",[53,4509,4510],{"class":295},"\"status\"",[53,4512,1927],{"class":70},[53,4514,4515],{"class":295},"\"ok\"",[53,4517,2771],{"class":70},[53,4519,4520],{"class":55,"line":1358},[53,4521,4522],{"class":70},"    })\n",[53,4524,4525],{"class":55,"line":1372},[53,4526,331],{"emptyLinePlaceholder":253},[53,4528,4529,4531,4534,4536,4539],{"class":55,"line":1379},[53,4530,4427],{"class":70},[53,4532,4533],{"class":87},"Run",[53,4535,2223],{"class":70},[53,4537,4538],{"class":295},"\":8080\"",[53,4540,2533],{"class":70},[53,4542,4543],{"class":55,"line":1384},[53,4544,109],{"class":70},[16,4546,4547],{},"此时启动服务，用 curl 测试：",[43,4549,4551],{"className":278,"code":4550,"language":280,"meta":48,"style":48},"# 发现工具列表\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",[50,4552,4553,4558,4575,4585,4593,4597,4602,4614,4622],{"__ignoreMap":48},[53,4554,4555],{"class":55,"line":56},[53,4556,4557],{"class":59},"# 发现工具列表\n",[53,4559,4560,4563,4566,4569,4572],{"class":55,"line":63},[53,4561,4562],{"class":87},"curl",[53,4564,4565],{"class":77}," -X",[53,4567,4568],{"class":295}," POST",[53,4570,4571],{"class":295}," http:\u002F\u002Flocalhost:8080\u002Fmcp",[53,4573,4574],{"class":77}," \\\n",[53,4576,4577,4580,4583],{"class":55,"line":81},[53,4578,4579],{"class":77},"  -H",[53,4581,4582],{"class":295}," \"Content-Type: application\u002Fjson\"",[53,4584,4574],{"class":77},[53,4586,4587,4590],{"class":55,"line":94},[53,4588,4589],{"class":77},"  -d",[53,4591,4592],{"class":295}," '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools\u002Flist\"}'\n",[53,4594,4595],{"class":55,"line":106},[53,4596,331],{"emptyLinePlaceholder":253},[53,4598,4599],{"class":55,"line":367},[53,4600,4601],{"class":59},"# 调用工具\n",[53,4603,4604,4606,4608,4610,4612],{"class":55,"line":384},[53,4605,4562],{"class":87},[53,4607,4565],{"class":77},[53,4609,4568],{"class":295},[53,4611,4571],{"class":295},[53,4613,4574],{"class":77},[53,4615,4616,4618,4620],{"class":55,"line":400},[53,4617,4579],{"class":77},[53,4619,4582],{"class":295},[53,4621,4574],{"class":77},[53,4623,4624,4626],{"class":55,"line":406},[53,4625,4589],{"class":77},[53,4627,4628],{"class":295}," '{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools\u002Fcall\",\"params\":{\"name\":\"list_items\",\"arguments\":{}}}'\n",[11,4630,4632],{"id":4631},"拓展接入鉴权","拓展：接入鉴权",[16,4634,4635],{},"裸奔的 MCP 端点任何人都能调用，生产环境必须加鉴权。思路和普通 API 鉴权一致：Bearer Token。",[30,4637,3199],{"id":3199},[16,4639,4640,4641,4644,4645,4648],{},"MCP 的工具处理器签名为 ",[50,4642,4643],{},"func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error)","，它不接收 Gin 的 ",[50,4646,4647],{},"*gin.Context","，所以无法直接在工具处理器里读取请求头。",[30,4650,4652],{"id":4651},"解决方案中间件传递用户信息","解决方案：中间件传递用户信息",[16,4654,4655,4656,2114],{},"用两层中间件：第一层做 Gin 的 Token 验证，第二层把用户信息从 Gin Context 复制到 ",[50,4657,4658],{},"request.Context",[43,4660,4662],{"className":3647,"code":4661,"language":409,"meta":48,"style":48},"\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",[50,4663,4664,4669,4697,4718,4738,4754,4787,4792,4797,4801,4826,4866,4895,4899,4903,4907,4927,4939,4968,4972,4976,4980,4995,5009,5018,5022],{"__ignoreMap":48},[53,4665,4666],{"class":55,"line":56},[53,4667,4668],{"class":59},"\u002F\u002F Gin 鉴权中间件\n",[53,4670,4671,4673,4676,4678,4681,4683,4686,4688,4690,4692,4695],{"class":55,"line":63},[53,4672,3848],{"class":66},[53,4674,4675],{"class":87}," AuthRequired",[53,4677,2223],{"class":70},[53,4679,4680],{"class":2226},"authService",[53,4682,4473],{"class":66},[53,4684,4685],{"class":87},"AuthService",[53,4687,2687],{"class":70},[53,4689,4476],{"class":87},[53,4691,2345],{"class":70},[53,4693,4694],{"class":87},"HandlerFunc",[53,4696,1863],{"class":70},[53,4698,4699,4701,4704,4706,4708,4710,4712,4714,4716],{"class":55,"line":81},[53,4700,3905],{"class":66},[53,4702,4703],{"class":66}," func",[53,4705,2223],{"class":70},[53,4707,4470],{"class":2226},[53,4709,4473],{"class":66},[53,4711,4476],{"class":87},[53,4713,2345],{"class":70},[53,4715,3861],{"class":87},[53,4717,3475],{"class":70},[53,4719,4720,4723,4725,4728,4731,4733,4736],{"class":55,"line":94},[53,4721,4722],{"class":70},"        authHeader ",[53,4724,3674],{"class":66},[53,4726,4727],{"class":70}," c.",[53,4729,4730],{"class":87},"GetHeader",[53,4732,2223],{"class":70},[53,4734,4735],{"class":295},"\"Authorization\"",[53,4737,2533],{"class":70},[53,4739,4740,4743,4746,4749,4752],{"class":55,"line":106},[53,4741,4742],{"class":66},"        if",[53,4744,4745],{"class":70}," authHeader ",[53,4747,4748],{"class":66},"==",[53,4750,4751],{"class":295}," \"\"",[53,4753,1863],{"class":70},[53,4755,4756,4759,4762,4764,4767,4769,4771,4773,4775,4777,4780,4782,4785],{"class":55,"line":367},[53,4757,4758],{"class":70},"            c.",[53,4760,4761],{"class":87},"AbortWithStatusJSON",[53,4763,2223],{"class":70},[53,4765,4766],{"class":77},"401",[53,4768,1034],{"class":70},[53,4770,4476],{"class":87},[53,4772,2345],{"class":70},[53,4774,4504],{"class":87},[53,4776,4507],{"class":70},[53,4778,4779],{"class":295},"\"message\"",[53,4781,1927],{"class":70},[53,4783,4784],{"class":295},"\"缺少认证令牌\"",[53,4786,2771],{"class":70},[53,4788,4789],{"class":55,"line":384},[53,4790,4791],{"class":66},"            return\n",[53,4793,4794],{"class":55,"line":400},[53,4795,4796],{"class":70},"        }\n",[53,4798,4799],{"class":55,"line":406},[53,4800,331],{"emptyLinePlaceholder":253},[53,4802,4803,4806,4808,4811,4814,4817,4820,4822,4824],{"class":55,"line":420},[53,4804,4805],{"class":70},"        parts ",[53,4807,3674],{"class":66},[53,4809,4810],{"class":70}," strings.",[53,4812,4813],{"class":87},"SplitN",[53,4815,4816],{"class":70},"(authHeader, ",[53,4818,4819],{"class":295},"\" \"",[53,4821,1034],{"class":70},[53,4823,2409],{"class":77},[53,4825,2533],{"class":70},[53,4827,4828,4830,4833,4836,4838,4841,4844,4847,4850,4853,4856,4858,4861,4864],{"class":55,"line":443},[53,4829,4742],{"class":66},[53,4831,4832],{"class":87}," len",[53,4834,4835],{"class":70},"(parts) ",[53,4837,4091],{"class":66},[53,4839,4840],{"class":77}," 2",[53,4842,4843],{"class":66}," ||",[53,4845,4846],{"class":66}," !",[53,4848,4849],{"class":70},"strings.",[53,4851,4852],{"class":87},"EqualFold",[53,4854,4855],{"class":70},"(parts[",[53,4857,2311],{"class":77},[53,4859,4860],{"class":70},"], ",[53,4862,4863],{"class":295},"\"Bearer\"",[53,4865,3475],{"class":70},[53,4867,4868,4870,4872,4874,4876,4878,4880,4882,4884,4886,4888,4890,4893],{"class":55,"line":452},[53,4869,4758],{"class":70},[53,4871,4761],{"class":87},[53,4873,2223],{"class":70},[53,4875,4766],{"class":77},[53,4877,1034],{"class":70},[53,4879,4476],{"class":87},[53,4881,2345],{"class":70},[53,4883,4504],{"class":87},[53,4885,4507],{"class":70},[53,4887,4779],{"class":295},[53,4889,1927],{"class":70},[53,4891,4892],{"class":295},"\"格式错误，请使用 Bearer \u003Ctoken>\"",[53,4894,2771],{"class":70},[53,4896,4897],{"class":55,"line":457},[53,4898,4791],{"class":66},[53,4900,4901],{"class":55,"line":463},[53,4902,4796],{"class":70},[53,4904,4905],{"class":55,"line":474},[53,4906,331],{"emptyLinePlaceholder":253},[53,4908,4909,4912,4914,4917,4920,4922,4924],{"class":55,"line":485},[53,4910,4911],{"class":70},"        user, err ",[53,4913,3674],{"class":66},[53,4915,4916],{"class":70}," authService.",[53,4918,4919],{"class":87},"GetUserByToken",[53,4921,4855],{"class":70},[53,4923,394],{"class":77},[53,4925,4926],{"class":70},"])\n",[53,4928,4929,4931,4933,4935,4937],{"class":55,"line":490},[53,4930,4742],{"class":66},[53,4932,4088],{"class":70},[53,4934,4091],{"class":66},[53,4936,4094],{"class":77},[53,4938,1863],{"class":70},[53,4940,4941,4943,4945,4947,4949,4951,4953,4955,4957,4959,4961,4963,4966],{"class":55,"line":499},[53,4942,4758],{"class":70},[53,4944,4761],{"class":87},[53,4946,2223],{"class":70},[53,4948,4766],{"class":77},[53,4950,1034],{"class":70},[53,4952,4476],{"class":87},[53,4954,2345],{"class":70},[53,4956,4504],{"class":87},[53,4958,4507],{"class":70},[53,4960,4779],{"class":295},[53,4962,1927],{"class":70},[53,4964,4965],{"class":295},"\"无效的认证令牌\"",[53,4967,2771],{"class":70},[53,4969,4970],{"class":55,"line":504},[53,4971,4791],{"class":66},[53,4973,4974],{"class":55,"line":712},[53,4975,4796],{"class":70},[53,4977,4978],{"class":55,"line":720},[53,4979,331],{"emptyLinePlaceholder":253},[53,4981,4982,4984,4987,4989,4992],{"class":55,"line":725},[53,4983,4487],{"class":70},[53,4985,4986],{"class":87},"Set",[53,4988,2223],{"class":70},[53,4990,4991],{"class":295},"\"userID\"",[53,4993,4994],{"class":70},", user.ID)\n",[53,4996,4997,4999,5001,5003,5006],{"class":55,"line":1358},[53,4998,4487],{"class":70},[53,5000,4986],{"class":87},[53,5002,2223],{"class":70},[53,5004,5005],{"class":295},"\"username\"",[53,5007,5008],{"class":70},", user.Username)\n",[53,5010,5011,5013,5016],{"class":55,"line":1372},[53,5012,4487],{"class":70},[53,5014,5015],{"class":87},"Next",[53,5017,2418],{"class":70},[53,5019,5020],{"class":55,"line":1379},[53,5021,2183],{"class":70},[53,5023,5024],{"class":55,"line":1384},[53,5025,109],{"class":70},[43,5027,5029],{"className":3647,"code":5028,"language":409,"meta":48,"style":48},"\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",[50,5030,5031,5036,5047,5054,5067,5079,5083,5087,5105,5125,5139,5163,5185,5200,5208,5212],{"__ignoreMap":48},[53,5032,5033],{"class":55,"line":56},[53,5034,5035],{"class":59},"\u002F\u002F 用户信息桥接中间件：Gin Context → request.Context\n",[53,5037,5038,5041,5044],{"class":55,"line":63},[53,5039,5040],{"class":66},"type",[53,5042,5043],{"class":87}," ctxKey",[53,5045,5046],{"class":66}," string\n",[53,5048,5049,5051],{"class":55,"line":81},[53,5050,2457],{"class":66},[53,5052,5053],{"class":70}," (\n",[53,5055,5056,5059,5062,5064],{"class":55,"line":94},[53,5057,5058],{"class":77},"    ctxUserID",[53,5060,5061],{"class":87},"   ctxKey",[53,5063,1860],{"class":66},[53,5065,5066],{"class":295}," \"userID\"\n",[53,5068,5069,5072,5074,5076],{"class":55,"line":106},[53,5070,5071],{"class":77},"    ctxUsername",[53,5073,5043],{"class":87},[53,5075,1860],{"class":66},[53,5077,5078],{"class":295}," \"username\"\n",[53,5080,5081],{"class":55,"line":367},[53,5082,2533],{"class":70},[53,5084,5085],{"class":55,"line":384},[53,5086,331],{"emptyLinePlaceholder":253},[53,5088,5089,5091,5094,5097,5099,5101,5103],{"class":55,"line":400},[53,5090,3848],{"class":66},[53,5092,5093],{"class":87}," WithUserContext",[53,5095,5096],{"class":70},"() ",[53,5098,4476],{"class":87},[53,5100,2345],{"class":70},[53,5102,4694],{"class":87},[53,5104,1863],{"class":70},[53,5106,5107,5109,5111,5113,5115,5117,5119,5121,5123],{"class":55,"line":406},[53,5108,3905],{"class":66},[53,5110,4703],{"class":66},[53,5112,2223],{"class":70},[53,5114,4470],{"class":2226},[53,5116,4473],{"class":66},[53,5118,4476],{"class":87},[53,5120,2345],{"class":70},[53,5122,3861],{"class":87},[53,5124,3475],{"class":70},[53,5126,5127,5130,5132,5135,5137],{"class":55,"line":420},[53,5128,5129],{"class":70},"        ctx ",[53,5131,3674],{"class":66},[53,5133,5134],{"class":70}," c.Request.",[53,5136,3861],{"class":87},[53,5138,2418],{"class":70},[53,5140,5141,5143,5145,5148,5151,5154,5157,5159,5161],{"class":55,"line":443},[53,5142,5129],{"class":70},[53,5144,74],{"class":66},[53,5146,5147],{"class":70}," context.",[53,5149,5150],{"class":87},"WithValue",[53,5152,5153],{"class":70},"(ctx, ctxUserID, c.",[53,5155,5156],{"class":87},"GetUint",[53,5158,2223],{"class":70},[53,5160,4991],{"class":295},[53,5162,2386],{"class":70},[53,5164,5165,5167,5169,5171,5173,5176,5179,5181,5183],{"class":55,"line":452},[53,5166,5129],{"class":70},[53,5168,74],{"class":66},[53,5170,5147],{"class":70},[53,5172,5150],{"class":87},[53,5174,5175],{"class":70},"(ctx, ctxUsername, c.",[53,5177,5178],{"class":87},"GetString",[53,5180,2223],{"class":70},[53,5182,5005],{"class":295},[53,5184,2386],{"class":70},[53,5186,5187,5190,5192,5194,5197],{"class":55,"line":457},[53,5188,5189],{"class":70},"        c.Request ",[53,5191,74],{"class":66},[53,5193,5134],{"class":70},[53,5195,5196],{"class":87},"WithContext",[53,5198,5199],{"class":70},"(ctx)\n",[53,5201,5202,5204,5206],{"class":55,"line":463},[53,5203,4487],{"class":70},[53,5205,5015],{"class":87},[53,5207,2418],{"class":70},[53,5209,5210],{"class":55,"line":474},[53,5211,2183],{"class":70},[53,5213,5214],{"class":55,"line":485},[53,5215,109],{"class":70},[30,5217,5218],{"id":5218},"组合使用",[43,5220,5222],{"className":3647,"code":5221,"language":409,"meta":48,"style":48},"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",[50,5223,5224,5236,5247,5258,5271],{"__ignoreMap":48},[53,5225,5226,5228,5230,5232,5234],{"class":55,"line":56},[53,5227,4243],{"class":70},[53,5229,4246],{"class":87},[53,5231,2223],{"class":70},[53,5233,4206],{"class":295},[53,5235,1874],{"class":70},[53,5237,5238,5241,5244],{"class":55,"line":63},[53,5239,5240],{"class":87},"    AuthRequired",[53,5242,5243],{"class":70},"(authService),  ",[53,5245,5246],{"class":59},"\u002F\u002F 1. 验证 Token\n",[53,5248,5249,5252,5255],{"class":55,"line":81},[53,5250,5251],{"class":87},"    WithUserContext",[53,5253,5254],{"class":70},"(),           ",[53,5256,5257],{"class":59},"\u002F\u002F 2. 桥接用户信息到 request.Context\n",[53,5259,5260,5263,5265,5268],{"class":55,"line":94},[53,5261,5262],{"class":70},"    gin.",[53,5264,4256],{"class":87},[53,5266,5267],{"class":70},"(mcpHTTP),         ",[53,5269,5270],{"class":59},"\u002F\u002F 3. MCP 处理\n",[53,5272,5273],{"class":55,"line":106},[53,5274,2533],{"class":70},[16,5276,5277],{},"在工具处理器里就能拿到用户信息了：",[43,5279,5281],{"className":3647,"code":5280,"language":409,"meta":48,"style":48},"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",[50,5282,5283,5338,5359,5368,5385,5389,5393,5409,5414,5427],{"__ignoreMap":48},[53,5284,5285,5287,5289,5292,5294,5297,5299,5302,5304,5306,5308,5310,5312,5314,5316,5318,5320,5322,5324,5326,5328,5330,5332,5334,5336],{"class":55,"line":56},[53,5286,3848],{"class":66},[53,5288,2265],{"class":70},[53,5290,5291],{"class":2226},"h ",[53,5293,3880],{"class":66},[53,5295,5296],{"class":87},"Handler",[53,5298,2687],{"class":70},[53,5300,5301],{"class":87},"handleListItems",[53,5303,2223],{"class":70},[53,5305,3853],{"class":2226},[53,5307,3856],{"class":87},[53,5309,2345],{"class":70},[53,5311,3861],{"class":87},[53,5313,1034],{"class":70},[53,5315,3866],{"class":2226},[53,5317,3869],{"class":87},[53,5319,2345],{"class":70},[53,5321,3874],{"class":87},[53,5323,3877],{"class":70},[53,5325,3880],{"class":66},[53,5327,3883],{"class":87},[53,5329,2345],{"class":70},[53,5331,3888],{"class":87},[53,5333,1034],{"class":70},[53,5335,3893],{"class":66},[53,5337,3475],{"class":70},[53,5339,5340,5343,5345,5348,5351,5354,5357],{"class":55,"line":63},[53,5341,5342],{"class":70},"    userID, ok ",[53,5344,3674],{"class":66},[53,5346,5347],{"class":70}," ctx.",[53,5349,5350],{"class":87},"Value",[53,5352,5353],{"class":70},"(ctxUserID).(",[53,5355,5356],{"class":66},"uint",[53,5358,2533],{"class":70},[53,5360,5361,5363,5365],{"class":55,"line":81},[53,5362,2300],{"class":66},[53,5364,4846],{"class":66},[53,5366,5367],{"class":70},"ok {\n",[53,5369,5370,5372,5374,5376,5378,5381,5383],{"class":55,"line":94},[53,5371,4101],{"class":66},[53,5373,3796],{"class":70},[53,5375,4106],{"class":87},[53,5377,2223],{"class":70},[53,5379,5380],{"class":295},"\"未认证\"",[53,5382,3918],{"class":70},[53,5384,3921],{"class":77},[53,5386,5387],{"class":55,"line":106},[53,5388,2183],{"class":70},[53,5390,5391],{"class":55,"line":367},[53,5392,331],{"emptyLinePlaceholder":253},[53,5394,5395,5398,5400,5403,5406],{"class":55,"line":384},[53,5396,5397],{"class":70},"    items, _ ",[53,5399,3674],{"class":66},[53,5401,5402],{"class":70}," h.service.",[53,5404,5405],{"class":87},"ListByUser",[53,5407,5408],{"class":70},"(userID)\n",[53,5410,5411],{"class":55,"line":400},[53,5412,5413],{"class":59},"    \u002F\u002F ...\n",[53,5415,5416,5418,5420,5422,5425],{"class":55,"line":406},[53,5417,3905],{"class":66},[53,5419,3796],{"class":70},[53,5421,3910],{"class":87},[53,5423,5424],{"class":70},"(result), ",[53,5426,3921],{"class":77},[53,5428,5429],{"class":55,"line":420},[53,5430,109],{"class":70},[30,5432,5433],{"id":5433},"客户端配置",[16,5435,5436],{},"AI 客户端（如 Claude Desktop）配置 MCP 服务器时带上 Authorization 头：",[43,5438,5442],{"className":5439,"code":5440,"language":5441,"meta":48,"style":48},"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",[50,5443,5444,5449,5456,5462,5474,5481,5491,5496,5500,5504],{"__ignoreMap":48},[53,5445,5446],{"class":55,"line":56},[53,5447,5448],{"class":70},"{\n",[53,5450,5451,5454],{"class":55,"line":63},[53,5452,5453],{"class":77},"  \"mcpServers\"",[53,5455,2142],{"class":70},[53,5457,5458,5460],{"class":55,"line":81},[53,5459,3688],{"class":77},[53,5461,2142],{"class":70},[53,5463,5464,5467,5469,5472],{"class":55,"line":94},[53,5465,5466],{"class":77},"      \"url\"",[53,5468,1927],{"class":70},[53,5470,5471],{"class":295},"\"http:\u002F\u002Flocalhost:8080\u002Fmcp\"",[53,5473,1874],{"class":70},[53,5475,5476,5479],{"class":55,"line":106},[53,5477,5478],{"class":77},"      \"headers\"",[53,5480,2142],{"class":70},[53,5482,5483,5486,5488],{"class":55,"line":367},[53,5484,5485],{"class":77},"        \"Authorization\"",[53,5487,1927],{"class":70},[53,5489,5490],{"class":295},"\"Bearer your-token-here\"\n",[53,5492,5493],{"class":55,"line":384},[53,5494,5495],{"class":70},"      }\n",[53,5497,5498],{"class":55,"line":400},[53,5499,2183],{"class":70},[53,5501,5502],{"class":55,"line":406},[53,5503,2188],{"class":70},[53,5505,5506],{"class":55,"line":420},[53,5507,109],{"class":70},[11,5509,216],{"id":216},[139,5511,5512,5522],{},[142,5513,5514],{},[145,5515,5516,5519],{},[148,5517,5518],{},"步骤",[148,5520,5521],{},"说明",[161,5523,5524,5533,5544,5553,5562],{},[145,5525,5526,5530],{},[166,5527,5528],{},[50,5529,3680],{},[166,5531,5532],{},"创建 MCP 协议实例",[145,5534,5535,5541],{},[166,5536,5537,1463,5539],{},[50,5538,3799],{},[50,5540,3842],{},[166,5542,5543],{},"定义并注册工具",[145,5545,5546,5550],{},[166,5547,5548],{},[50,5549,4191],{},[166,5551,5552],{},"转为 HTTP Handler",[145,5554,5555,5559],{},[166,5556,5557],{},[50,5558,4159],{},[166,5560,5561],{},"挂载到 Gin 路由",[145,5563,5564,5572],{},[166,5565,5566,1463,5569],{},[50,5567,5568],{},"AuthRequired",[50,5570,5571],{},"WithUserContext",[166,5573,5574],{},"鉴权 + 用户信息桥接",[16,5576,5577,5578,5581,5582,2114],{},"核心就一句话：",[38,5579,5580],{},"MCP 是 HTTP Handler，挂上去就行。"," 鉴权复用你已有的中间件体系，只需要多写一个桥接函数把用户信息传进 ",[50,5583,4658],{},[235,5585,5586],{},"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":48,"searchDepth":63,"depth":81,"links":5588},[5589,5590,5591,5592,5599,5605],{"id":3559,"depth":63,"text":3560},{"id":3569,"depth":63,"text":3570},{"id":3586,"depth":63,"text":3586},{"id":3612,"depth":63,"text":3612,"children":5593},[5594,5595,5596,5597,5598],{"id":3615,"depth":81,"text":3616},{"id":3643,"depth":81,"text":3644},{"id":3760,"depth":81,"text":3761},{"id":4148,"depth":81,"text":4149},{"id":4269,"depth":81,"text":4270},{"id":4631,"depth":63,"text":4632,"children":5600},[5601,5602,5603,5604],{"id":3199,"depth":81,"text":3199},{"id":4651,"depth":81,"text":4652},{"id":5218,"depth":81,"text":5218},{"id":5433,"depth":81,"text":5433},{"id":216,"depth":63,"text":216},"介绍如何使用 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":3554,"description":5606},"Go 工程实践","posts\u002Fgo-mcp-gin\u002Findex",[1084,5615,5616,5617,5618],"MCP","Gin","AI Agent","后端","2026-06-01","5fZl1Vigs2lHb5fHPz-0YZFyFD38ws1heOouZUG1g6M",{"id":5622,"title":5623,"author":6,"body":5624,"description":6391,"difficulty":6,"draft":249,"extension":250,"image":6392,"meta":6393,"navigation":253,"path":6394,"pinned":249,"published":6395,"seo":6396,"series":6,"seriesOrder":6,"stem":6397,"tags":6398,"updated":6,"__hash__":6402},"posts\u002Fposts\u002Fdws-dingtalk-cli\u002Findex.md","DWS — 钉钉官方出品的 AI 友好命令行工具",{"type":8,"value":5625,"toc":6377},[5626,5630,5633,5640,5766,5769,5772,5828,5831,5846,5849,5852,5859,6091,6098,6102,6105,6125,6128,6143,6146,6166,6169,6172,6179,6245,6248,6251,6255,6258,6310,6314,6335,6339,6342,6356,6359,6363,6366,6368,6371,6374],[11,5627,5629],{"id":5628},"dws-是什么","dws 是什么",[16,5631,5632],{},"dws（DingTalk Workspace CLI）是阿里巴巴 2026 年开源的钉钉命令行工具，Apache-2.0 协议，Go 语言编写。",[16,5634,5635,5636,5639],{},"它的定位很明确：",[38,5637,5638],{},"把钉钉的所有产品能力暴露给 AI Agent 和命令行用户","。不用打开钉钉 App，不用写 API 调用代码，一条命令搞定。",[43,5641,5643],{"className":278,"code":5642,"language":280,"meta":48,"style":48},"# 查看今天有什么会\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",[50,5644,5645,5650,5676,5680,5685,5707,5711,5716,5741,5745,5750],{"__ignoreMap":48},[53,5646,5647],{"class":55,"line":56},[53,5648,5649],{"class":59},"# 查看今天有什么会\n",[53,5651,5652,5655,5658,5661,5664,5667,5670,5673],{"class":55,"line":63},[53,5653,5654],{"class":87},"dws",[53,5656,5657],{"class":295}," calendar",[53,5659,5660],{"class":295}," event",[53,5662,5663],{"class":295}," list",[53,5665,5666],{"class":77}," --start",[53,5668,5669],{"class":295}," 2026-06-01",[53,5671,5672],{"class":77}," --end",[53,5674,5675],{"class":295}," 2026-06-01\n",[53,5677,5678],{"class":55,"line":81},[53,5679,331],{"emptyLinePlaceholder":253},[53,5681,5682],{"class":55,"line":94},[53,5683,5684],{"class":59},"# 给群里发条消息\n",[53,5686,5687,5689,5692,5695,5698,5701,5704],{"class":55,"line":106},[53,5688,5654],{"class":87},[53,5690,5691],{"class":295}," chat",[53,5693,5694],{"class":295}," send",[53,5696,5697],{"class":77}," --group-id",[53,5699,5700],{"class":295}," xxx",[53,5702,5703],{"class":77}," --content",[53,5705,5706],{"class":295}," \"今天发布推迟到下午3点\"\n",[53,5708,5709],{"class":55,"line":367},[53,5710,331],{"emptyLinePlaceholder":253},[53,5712,5713],{"class":55,"line":384},[53,5714,5715],{"class":59},"# 创建一个待办\n",[53,5717,5718,5720,5723,5726,5729,5732,5735,5738],{"class":55,"line":400},[53,5719,5654],{"class":87},[53,5721,5722],{"class":295}," todo",[53,5724,5725],{"class":295}," task",[53,5727,5728],{"class":295}," create",[53,5730,5731],{"class":77}," --subject",[53,5733,5734],{"class":295}," \"Review PR #123\"",[53,5736,5737],{"class":77}," --due-date",[53,5739,5740],{"class":295}," 2026-06-02\n",[53,5742,5743],{"class":55,"line":406},[53,5744,331],{"emptyLinePlaceholder":253},[53,5746,5747],{"class":55,"line":420},[53,5748,5749],{"class":59},"# 读取钉钉文档\n",[53,5751,5752,5754,5757,5760,5763],{"class":55,"line":443},[53,5753,5654],{"class":87},[53,5755,5756],{"class":295}," doc",[53,5758,5759],{"class":295}," read",[53,5761,5762],{"class":77}," --url",[53,5764,5765],{"class":295}," \"https:\u002F\u002Falidocs.dingtalk.com\u002Fi\u002Fnodes\u002Fxxx\"\n",[11,5767,5768],{"id":5768},"安装",[16,5770,5771],{},"三种方式，选你喜欢的：",[43,5773,5775],{"className":278,"code":5774,"language":280,"meta":48,"style":48},"# 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",[50,5776,5777,5782,5795,5799,5804,5814,5818,5823],{"__ignoreMap":48},[53,5778,5779],{"class":55,"line":56},[53,5780,5781],{"class":59},"# npm（推荐，跨平台自动下载二进制）\n",[53,5783,5784,5787,5789,5792],{"class":55,"line":63},[53,5785,5786],{"class":87},"npm",[53,5788,352],{"class":295},[53,5790,5791],{"class":77}," -g",[53,5793,5794],{"class":295}," dingtalk-workspace-cli\n",[53,5796,5797],{"class":55,"line":81},[53,5798,331],{"emptyLinePlaceholder":253},[53,5800,5801],{"class":55,"line":94},[53,5802,5803],{"class":59},"# Homebrew（macOS\u002FLinux）\n",[53,5805,5806,5809,5811],{"class":55,"line":106},[53,5807,5808],{"class":87},"brew",[53,5810,352],{"class":295},[53,5812,5813],{"class":295}," dws\n",[53,5815,5816],{"class":55,"line":367},[53,5817,331],{"emptyLinePlaceholder":253},[53,5819,5820],{"class":55,"line":384},[53,5821,5822],{"class":59},"# 直接下载二进制\n",[53,5824,5825],{"class":55,"line":400},[53,5826,5827],{"class":59},"# https:\u002F\u002Fgithub.com\u002Fopen-dingtalk\u002Fdingtalk-workspace-cli\u002Freleases\n",[16,5829,5830],{},"安装后认证：",[43,5832,5834],{"className":278,"code":5833,"language":280,"meta":48,"style":48},"dws auth login\n",[50,5835,5836],{"__ignoreMap":48},[53,5837,5838,5840,5843],{"class":55,"line":56},[53,5839,5654],{"class":87},[53,5841,5842],{"class":295}," auth",[53,5844,5845],{"class":295}," login\n",[16,5847,5848],{},"会打开浏览器走 OAuth 设备流认证，token 加密存储在系统钥匙串里（PBKDF2 + AES-256-GCM）。",[11,5850,5851],{"id":5851},"覆盖的钉钉产品",[16,5853,5854,5855,5858],{},"这是最夸张的部分 — ",[38,5856,5857],{},"19 个产品，213 个命令","，基本把钉钉全家桶都搬到了命令行：",[139,5860,5861,5873],{},[142,5862,5863],{},[145,5864,5865,5868,5871],{},[148,5866,5867],{},"产品",[148,5869,5870],{},"命令数",[148,5872,5521],{},[161,5874,5875,5889,5903,5917,5931,5945,5959,5973,5986,6000,6013,6027,6041,6054,6067,6080],{},[145,5876,5877,5883,5886],{},[166,5878,5879,5880],{},"聊天 ",[50,5881,5882],{},"chat",[166,5884,5885],{},"57",[166,5887,5888],{},"消息收发、群管理、机器人、已读状态、Webhook",[145,5890,5891,5897,5900],{},[166,5892,5893,5894],{},"AI 表格 ",[50,5895,5896],{},"aitable",[166,5898,5899],{},"42",[166,5901,5902],{},"Base\u002F数据表\u002F字段\u002F记录\u002F视图\u002F图表\u002F仪表盘，完整 CRUD",[145,5904,5905,5911,5914],{},[166,5906,5907,5908],{},"电子表格 ",[50,5909,5910],{},"sheet",[166,5912,5913],{},"34",[166,5915,5916],{},"工作表读写、行列操作、合并、筛选视图、导出",[145,5918,5919,5925,5928],{},[166,5920,5921,5922],{},"文档 ",[50,5923,5924],{},"doc",[166,5926,5927],{},"21",[166,5929,5930],{},"搜索、读写、块级编辑、评论、复制移动",[145,5932,5933,5939,5942],{},[166,5934,5935,5936],{},"会议纪要 ",[50,5937,5938],{},"minutes",[166,5940,5941],{},"19",[166,5943,5944],{},"列表、摘要、转写、思维导图、发言人、热词",[145,5946,5947,5953,5956],{},[166,5948,5949,5950],{},"日历 ",[50,5951,5952],{},"calendar",[166,5954,5955],{},"14",[166,5957,5958],{},"事件 CRUD、会议室、忙闲查询、时间建议",[145,5960,5961,5967,5970],{},[166,5962,5963,5964],{},"审批 ",[50,5965,5966],{},"oa",[166,5968,5969],{},"9",[166,5971,5972],{},"待办、发起、同意\u002F拒绝\u002F撤销",[145,5974,5975,5981,5983],{},[166,5976,5977,5978],{},"云盘 ",[50,5979,5980],{},"drive",[166,5982,5969],{},[166,5984,5985],{},"列表、下载、上传、创建目录",[145,5987,5988,5994,5997],{},[166,5989,5990,5991],{},"知识库 ",[50,5992,5993],{},"wiki",[166,5995,5996],{},"7",[166,5998,5999],{},"空间管理、成员管理",[145,6001,6002,6008,6010],{},[166,6003,6004,6005],{},"汇报 ",[50,6006,6007],{},"report",[166,6009,5996],{},[166,6011,6012],{},"日志\u002F日报\u002F周报，按模板创建、统计",[145,6014,6015,6021,6024],{},[166,6016,6017,6018],{},"待办 ",[50,6019,6020],{},"todo",[166,6022,6023],{},"6",[166,6025,6026],{},"创建、完成、删除，支持优先级和循环",[145,6028,6029,6035,6038],{},[166,6030,6031,6032],{},"邮件 ",[50,6033,6034],{},"mail",[166,6036,6037],{},"4",[166,6039,6040],{},"邮箱查询、邮件搜索、发送",[145,6042,6043,6049,6051],{},[166,6044,6045,6046],{},"考勤 ",[50,6047,6048],{},"attendance",[166,6050,6037],{},[166,6052,6053],{},"打卡记录、排班、汇总",[145,6055,6056,6062,6064],{},[166,6057,6058,6059],{},"通讯录 ",[50,6060,6061],{},"contact",[166,6063,2172],{},[166,6065,6066],{},"用户搜索、部门查询",[145,6068,6069,6075,6077],{},[166,6070,6071,6072],{},"DING 消息 ",[50,6073,6074],{},"ding",[166,6076,2409],{},[166,6078,6079],{},"发送\u002F撤回（应用内、短信、电话）",[145,6081,6082,6085,6088],{},[166,6083,6084],{},"其他",[166,6086,6087],{},"若干",[166,6089,6090],{},"AI 搜索、AI 应用、直播、开放平台文档",[16,6092,6093,6094,6097],{},"还有一个万能命令 ",[50,6095,6096],{},"dws api","，可以直接调用任意钉钉 OpenAPI，不受这 213 个命令的限制。",[11,6099,6101],{"id":6100},"agent-skill-系统","Agent Skill 系统",[16,6103,6104],{},"dws 内置了一套完整的 Agent Skill，可以直接被 AI 编程工具发现和使用：",[1770,6106,6107,6113,6119],{},[524,6108,6109,6112],{},[38,6110,6111],{},"Claude Code"," — 通过 SKILL.md 自动加载",[524,6114,6115,6118],{},[38,6116,6117],{},"Cursor"," — 支持 MCP 协议接入",[524,6120,6121,6124],{},[38,6122,6123],{},"Codex \u002F OpenCode \u002F Qoder"," — 通过 skill 安装命令接入",[16,6126,6127],{},"安装 skill：",[43,6129,6131],{"className":278,"code":6130,"language":280,"meta":48,"style":48},"dws skill setup\n",[50,6132,6133],{"__ignoreMap":48},[53,6134,6135,6137,6140],{"class":55,"line":56},[53,6136,5654],{"class":87},[53,6138,6139],{"class":295}," skill",[53,6141,6142],{"class":295}," setup\n",[16,6144,6145],{},"SKILL.md 里定义了：",[1770,6147,6148,6157,6160,6163],{},[524,6149,6150,6151,6153,6154,6156],{},"16 个产品的意图判断决策树（用户说\"表格\"→ ",[50,6152,5896],{},"，说\"打卡\"→ ",[50,6155,6048],{},"）",[524,6158,6159],{},"危险操作确认流程（删除、撤回等必须先确认）",[524,6161,6162],{},"错误处理规范（verbose 重试、recovery 事件 ID）",[524,6164,6165],{},"URL 分流规则（alidocs.dingtalk.com 的多种路径格式自动识别）",[16,6167,6168],{},"Agent 读到这份 SKILL.md 就知道什么时候该调什么命令，不需要额外的 prompt 工程。",[11,6170,6171],{"id":6171},"自动升级",[16,6173,6174,6175,6178],{},"内置 ",[50,6176,6177],{},"dws upgrade","，不需要重新下载安装包：",[43,6180,6182],{"className":278,"code":6181,"language":280,"meta":48,"style":48},"dws upgrade              # 交互式升级到最新版\ndws upgrade --check      # 仅检查是否有新版本\ndws upgrade --list       # 列出最近版本\ndws upgrade --version v1.0.5  # 升级到指定版本\ndws upgrade --rollback   # 回滚到上一版本\n",[50,6183,6184,6194,6206,6218,6233],{"__ignoreMap":48},[53,6185,6186,6188,6191],{"class":55,"line":56},[53,6187,5654],{"class":87},[53,6189,6190],{"class":295}," upgrade",[53,6192,6193],{"class":59},"              # 交互式升级到最新版\n",[53,6195,6196,6198,6200,6203],{"class":55,"line":63},[53,6197,5654],{"class":87},[53,6199,6190],{"class":295},[53,6201,6202],{"class":77}," --check",[53,6204,6205],{"class":59},"      # 仅检查是否有新版本\n",[53,6207,6208,6210,6212,6215],{"class":55,"line":81},[53,6209,5654],{"class":87},[53,6211,6190],{"class":295},[53,6213,6214],{"class":77}," --list",[53,6216,6217],{"class":59},"       # 列出最近版本\n",[53,6219,6220,6222,6224,6227,6230],{"class":55,"line":94},[53,6221,5654],{"class":87},[53,6223,6190],{"class":295},[53,6225,6226],{"class":77}," --version",[53,6228,6229],{"class":295}," v1.0.5",[53,6231,6232],{"class":59},"  # 升级到指定版本\n",[53,6234,6235,6237,6239,6242],{"class":55,"line":106},[53,6236,5654],{"class":87},[53,6238,6190],{"class":295},[53,6240,6241],{"class":77}," --rollback",[53,6243,6244],{"class":59},"   # 回滚到上一版本\n",[16,6246,6247],{},"升级流程：下载 → SHA256 校验 → 备份当前版本 → 原子替换 → 更新 Skill。失败可一键回滚。",[11,6249,6250],{"id":6250},"亮点",[30,6252,6254],{"id":6253},"_1-为-ai-agent-专门优化","1. 为 AI Agent 专门优化",[16,6256,6257],{},"这可能是目前对 AI 最友好的 CLI 工具：",[1770,6259,6260,6281,6291,6300],{},[524,6261,6262,6265,6266,6269,6270,6273,6274,6269,6277,6280],{},[38,6263,6264],{},"智能纠错"," — AI 模型经常生成错误的参数格式（",[50,6267,6268],{},"--userId"," 应该是 ",[50,6271,6272],{},"--user-id","，",[50,6275,6276],{},"--limit100",[50,6278,6279],{},"--limit 100","），dws 的 5 阶段 Pipeline 会自动修正",[524,6282,6283,6286,6287,6290],{},[38,6284,6285],{},"Schema 发现"," — ",[50,6288,6289],{},"dws schema"," 让 Agent 动态查询可用工具和参数，不需要硬编码",[524,6292,6293,6286,6296,6299],{},[38,6294,6295],{},"jq 过滤",[50,6297,6298],{},"--jq '.data[] | {name, id}'"," 直接过滤输出，减少 Agent 消耗的 token",[524,6301,6302,6305,6306,6309],{},[38,6303,6304],{},"JSON 输出"," — 所有命令支持 ",[50,6307,6308],{},"--format json","，方便程序解析",[30,6311,6313],{"id":6312},"_2-安全设计","2. 安全设计",[1770,6315,6316,6319,6322,6325,6332],{},[524,6317,6318],{},"token 用 PBKDF2 + AES-256-GCM 加密，密钥绑定设备 MAC 地址",[524,6320,6321],{},"域名白名单，只能访问钉钉官方 API",[524,6323,6324],{},"强制 HTTPS",[524,6326,6327,6328,6331],{},"写操作必须显式 ",[50,6329,6330],{},"--yes"," 确认",[524,6333,6334],{},"完整审计链",[30,6336,6338],{"id":6337},"_3-发现驱动架构","3. 发现驱动架构",[16,6340,6341],{},"不是把所有命令硬编码在代码里，而是：",[521,6343,6344,6347,6350,6353],{},[524,6345,6346],{},"从钉钉 MCP 服务器注册中心拉取元数据",[524,6348,6349],{},"运行时发现服务端能力",[524,6351,6352],{},"转换成统一的中间表示（IR）",[524,6354,6355],{},"动态挂载到命令树",[16,6357,6358],{},"这意味着钉钉新增产品能力时，dws 不需要发版就能支持。",[30,6360,6362],{"id":6361},"_4-多平台分发","4. 多平台分发",[16,6364,6365],{},"GoReleaser 交叉编译 + Homebrew formula + npm 包 + shell\u002FPowerShell 安装脚本，覆盖 Windows、macOS、Linux。",[11,6367,216],{"id":216},[16,6369,6370],{},"dws 把钉钉从一个\"必须打开 App 才能用\"的工具变成了一个\"命令行 + AI Agent 随时调用\"的平台。213 个命令覆盖日常办公的方方面面，Agent Skill 系统让 AI 编程工具可以直接理解和使用这些能力。",[16,6372,6373],{},"如果你是钉钉重度用户，或者在做企业内部的 AI Agent 集成，dws 值得一试。",[235,6375,6376],{},"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":48,"searchDepth":63,"depth":81,"links":6378},[6379,6380,6381,6382,6383,6384,6390],{"id":5628,"depth":63,"text":5629},{"id":5768,"depth":63,"text":5768},{"id":5851,"depth":63,"text":5851},{"id":6100,"depth":63,"text":6101},{"id":6171,"depth":63,"text":6171},{"id":6250,"depth":63,"text":6250,"children":6385},[6386,6387,6388,6389],{"id":6253,"depth":81,"text":6254},{"id":6312,"depth":81,"text":6313},{"id":6337,"depth":81,"text":6338},{"id":6361,"depth":81,"text":6362},{"id":216,"depth":63,"text":216},"介绍阿里巴巴官方的 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":5623,"description":6391},"posts\u002Fdws-dingtalk-cli\u002Findex",[6399,6400,5617,1084,6401],"钉钉","CLI","效率工具","4ca65VUZ2Sz9yG8dNtftrBDDHE6QowA3ed9g4egQkKI",{"id":6404,"title":6405,"author":6,"body":6406,"description":7369,"difficulty":1074,"draft":249,"extension":250,"image":7370,"meta":7371,"navigation":253,"path":7372,"pinned":249,"published":7373,"seo":7374,"series":5612,"seriesOrder":63,"stem":7375,"tags":7376,"updated":7380,"__hash__":7381},"posts\u002Fposts\u002Fgo-embed-frontend\u002Findex.md","Go 单文件打包实战：把前端塞进二进制",{"type":8,"value":6407,"toc":7352},[6408,6410,6417,6420,6423,6434,6437,6441,6774,6778,6886,6890,7024,7027,7031,7040,7057,7068,7087,7101,7107,7115,7214,7220,7224,7231,7240,7243,7279,7282,7287,7326,7328,7334,7346,7349],[11,6409,2090],{"id":2090},[16,6411,6412,6413,6416],{},"一个 Gin + Vue 3 的全栈项目，之前的发布方式是：一个 Go 二进制 + 一个 ",[50,6414,6415],{},"web\u002F"," 前端静态目录，必须放一起才能运行。",[16,6418,6419],{},"为了让部署更丝滑——一个 exe 拖哪都能跑——决定把前端构建产物嵌入到 Go 二进制里。",[11,6421,6422],{"id":6422},"核心思路",[16,6424,6425,6426,6429,6430,6433],{},"利用 Go 1.16 引入的 ",[50,6427,6428],{},"\u002F\u002Fgo:embed"," 指令，在编译时将前端 ",[50,6431,6432],{},"dist\u002F"," 目录嵌入二进制，运行时通过嵌入的文件系统提供 SPA 服务。",[11,6435,6436],{"id":6436},"逐步实现",[30,6438,6440],{"id":6439},"_1-创建嵌入模块","1. 创建嵌入模块",[43,6442,6444],{"className":3647,"code":6443,"language":409,"meta":48,"style":48},"\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",[50,6445,6446,6451,6459,6463,6469,6479,6488,6497,6506,6515,6524,6528,6537,6541,6545,6550,6565,6569,6586,6607,6611,6631,6651,6664,6674,6678,6682,6697,6709,6714,6733,6737,6741,6764,6769],{"__ignoreMap":48},[53,6447,6448],{"class":55,"line":56},[53,6449,6450],{"class":59},"\u002F\u002F service\u002Fweb\u002Fembed.go\n",[53,6452,6453,6456],{"class":55,"line":63},[53,6454,6455],{"class":66},"package",[53,6457,6458],{"class":87}," web\n",[53,6460,6461],{"class":55,"line":81},[53,6462,331],{"emptyLinePlaceholder":253},[53,6464,6465,6467],{"class":55,"line":94},[53,6466,3655],{"class":66},[53,6468,5053],{"class":70},[53,6470,6471,6474,6477],{"class":55,"line":106},[53,6472,6473],{"class":295},"    \"",[53,6475,6476],{"class":87},"embed",[53,6478,516],{"class":295},[53,6480,6481,6483,6486],{"class":55,"line":367},[53,6482,6473],{"class":295},[53,6484,6485],{"class":87},"io\u002Ffs",[53,6487,516],{"class":295},[53,6489,6490,6492,6495],{"class":55,"line":384},[53,6491,6473],{"class":295},[53,6493,6494],{"class":87},"mime",[53,6496,516],{"class":295},[53,6498,6499,6501,6504],{"class":55,"line":400},[53,6500,6473],{"class":295},[53,6502,6503],{"class":87},"net\u002Fhttp",[53,6505,516],{"class":295},[53,6507,6508,6510,6513],{"class":55,"line":406},[53,6509,6473],{"class":295},[53,6511,6512],{"class":87},"path\u002Ffilepath",[53,6514,516],{"class":295},[53,6516,6517,6519,6522],{"class":55,"line":420},[53,6518,6473],{"class":295},[53,6520,6521],{"class":87},"strings",[53,6523,516],{"class":295},[53,6525,6526],{"class":55,"line":443},[53,6527,331],{"emptyLinePlaceholder":253},[53,6529,6530,6532,6535],{"class":55,"line":452},[53,6531,6473],{"class":295},[53,6533,6534],{"class":87},"github.com\u002Fgin-gonic\u002Fgin",[53,6536,516],{"class":295},[53,6538,6539],{"class":55,"line":457},[53,6540,2533],{"class":70},[53,6542,6543],{"class":55,"line":463},[53,6544,331],{"emptyLinePlaceholder":253},[53,6546,6547],{"class":55,"line":474},[53,6548,6549],{"class":59},"\u002F\u002Fgo:embed dist\n",[53,6551,6552,6555,6558,6560,6562],{"class":55,"line":485},[53,6553,6554],{"class":66},"var",[53,6556,6557],{"class":70}," embedFS ",[53,6559,6476],{"class":87},[53,6561,2345],{"class":70},[53,6563,6564],{"class":87},"FS\n",[53,6566,6567],{"class":55,"line":490},[53,6568,331],{"emptyLinePlaceholder":253},[53,6570,6571,6573,6576,6578,6580,6582,6584],{"class":55,"line":499},[53,6572,3848],{"class":66},[53,6574,6575],{"class":87}," SPAHandler",[53,6577,5096],{"class":70},[53,6579,4476],{"class":87},[53,6581,2345],{"class":70},[53,6583,4694],{"class":87},[53,6585,1863],{"class":70},[53,6587,6588,6591,6593,6596,6599,6602,6605],{"class":55,"line":504},[53,6589,6590],{"class":70},"    sub, _ ",[53,6592,3674],{"class":66},[53,6594,6595],{"class":70}," fs.",[53,6597,6598],{"class":87},"Sub",[53,6600,6601],{"class":70},"(embedFS, ",[53,6603,6604],{"class":295},"\"dist\"",[53,6606,2533],{"class":70},[53,6608,6609],{"class":55,"line":712},[53,6610,331],{"emptyLinePlaceholder":253},[53,6612,6613,6615,6617,6619,6621,6623,6625,6627,6629],{"class":55,"line":720},[53,6614,3905],{"class":66},[53,6616,4703],{"class":66},[53,6618,2223],{"class":70},[53,6620,4470],{"class":2226},[53,6622,4473],{"class":66},[53,6624,4476],{"class":87},[53,6626,2345],{"class":70},[53,6628,3861],{"class":87},[53,6630,3475],{"class":70},[53,6632,6633,6636,6638,6640,6643,6646,6649],{"class":55,"line":725},[53,6634,6635],{"class":70},"        p ",[53,6637,3674],{"class":66},[53,6639,4810],{"class":70},[53,6641,6642],{"class":87},"TrimPrefix",[53,6644,6645],{"class":70},"(c.Request.URL.Path, ",[53,6647,6648],{"class":295},"\"\u002F\"",[53,6650,2533],{"class":70},[53,6652,6653,6655,6658,6660,6662],{"class":55,"line":1358},[53,6654,4742],{"class":66},[53,6656,6657],{"class":70}," p ",[53,6659,4748],{"class":66},[53,6661,4751],{"class":295},[53,6663,1863],{"class":70},[53,6665,6666,6669,6671],{"class":55,"line":1372},[53,6667,6668],{"class":70},"            p ",[53,6670,74],{"class":66},[53,6672,6673],{"class":295}," \"index.html\"\n",[53,6675,6676],{"class":55,"line":1379},[53,6677,4796],{"class":70},[53,6679,6680],{"class":55,"line":1384},[53,6681,331],{"emptyLinePlaceholder":253},[53,6683,6684,6687,6689,6691,6694],{"class":55,"line":1390},[53,6685,6686],{"class":70},"        data, err ",[53,6688,3674],{"class":66},[53,6690,6595],{"class":70},[53,6692,6693],{"class":87},"ReadFile",[53,6695,6696],{"class":70},"(sub, p)\n",[53,6698,6699,6701,6703,6705,6707],{"class":55,"line":1398},[53,6700,4742],{"class":66},[53,6702,4088],{"class":70},[53,6704,4091],{"class":66},[53,6706,4094],{"class":77},[53,6708,1863],{"class":70},[53,6710,6711],{"class":55,"line":1406},[53,6712,6713],{"class":59},"            \u002F\u002F SPA fallback：文件不存在则返回 index.html\n",[53,6715,6716,6719,6721,6723,6725,6728,6731],{"class":55,"line":1411},[53,6717,6718],{"class":70},"            data, _ ",[53,6720,74],{"class":66},[53,6722,6595],{"class":70},[53,6724,6693],{"class":87},[53,6726,6727],{"class":70},"(sub, ",[53,6729,6730],{"class":295},"\"index.html\"",[53,6732,2533],{"class":70},[53,6734,6735],{"class":55,"line":1417},[53,6736,4796],{"class":70},[53,6738,6739],{"class":55,"line":1426},[53,6740,331],{"emptyLinePlaceholder":253},[53,6742,6744,6746,6749,6752,6755,6758,6761],{"class":55,"line":6743},33,[53,6745,4487],{"class":70},[53,6747,6748],{"class":87},"Data",[53,6750,6751],{"class":70},"(http.StatusOK, mime.",[53,6753,6754],{"class":87},"TypeByExtension",[53,6756,6757],{"class":70},"(filepath.",[53,6759,6760],{"class":87},"Ext",[53,6762,6763],{"class":70},"(p)), data)\n",[53,6765,6767],{"class":55,"line":6766},34,[53,6768,2183],{"class":70},[53,6770,6772],{"class":55,"line":6771},35,[53,6773,109],{"class":70},[30,6775,6777],{"id":6776},"_2-改造路由","2. 改造路由",[43,6779,6781],{"className":3647,"code":6780,"language":409,"meta":48,"style":48},"\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",[50,6782,6783,6788,6799,6803,6808,6823,6837,6850,6863,6876],{"__ignoreMap":48},[53,6784,6785],{"class":55,"line":56},[53,6786,6787],{"class":59},"\u002F\u002F router.go\n",[53,6789,6790,6792,6794,6797],{"class":55,"line":63},[53,6791,3655],{"class":66},[53,6793,308],{"class":295},[53,6795,6796],{"class":87},"myproject\u002Fweb",[53,6798,516],{"class":295},[53,6800,6801],{"class":55,"line":81},[53,6802,331],{"emptyLinePlaceholder":253},[53,6804,6805],{"class":55,"line":94},[53,6806,6807],{"class":59},"\u002F\u002F 替换原来的磁盘静态文件服务\n",[53,6809,6810,6813,6815,6818,6821],{"class":55,"line":106},[53,6811,6812],{"class":70},"webHandler ",[53,6814,3674],{"class":66},[53,6816,6817],{"class":70}," web.",[53,6819,6820],{"class":87},"SPAHandler",[53,6822,2418],{"class":70},[53,6824,6825,6828,6830,6832,6834],{"class":55,"line":367},[53,6826,6827],{"class":70},"router.",[53,6829,4456],{"class":87},[53,6831,2223],{"class":70},[53,6833,6648],{"class":295},[53,6835,6836],{"class":70},", webHandler)\n",[53,6838,6839,6841,6843,6845,6848],{"class":55,"line":384},[53,6840,6827],{"class":70},[53,6842,4456],{"class":87},[53,6844,2223],{"class":70},[53,6846,6847],{"class":295},"\"\u002Fassets\u002F*filepath\"",[53,6849,6836],{"class":70},[53,6851,6852,6854,6856,6858,6861],{"class":55,"line":400},[53,6853,6827],{"class":70},[53,6855,4456],{"class":87},[53,6857,2223],{"class":70},[53,6859,6860],{"class":295},"\"\u002Ffavicon.ico\"",[53,6862,6836],{"class":70},[53,6864,6865,6867,6869,6871,6874],{"class":55,"line":406},[53,6866,6827],{"class":70},[53,6868,4456],{"class":87},[53,6870,2223],{"class":70},[53,6872,6873],{"class":295},"\"\u002Ffavicon.svg\"",[53,6875,6836],{"class":70},[53,6877,6878,6880,6883],{"class":55,"line":420},[53,6879,6827],{"class":70},[53,6881,6882],{"class":87},"NoRoute",[53,6884,6885],{"class":70},"(webHandler)\n",[30,6887,6889],{"id":6888},"_3-一键构建脚本","3. 一键构建脚本",[43,6891,6893],{"className":278,"code":6892,"language":280,"meta":48,"style":48},"#!\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",[50,6894,6895,6900,6905,6909,6917,6921,6926,6936,6946,6950,6955,6966,6979,6983,6988,6996,7009,7019],{"__ignoreMap":48},[53,6896,6897],{"class":55,"line":56},[53,6898,6899],{"class":59},"#!\u002Fbin\u002Fbash\n",[53,6901,6902],{"class":55,"line":63},[53,6903,6904],{"class":59},"# build-standalone.sh\n",[53,6906,6907],{"class":55,"line":81},[53,6908,331],{"emptyLinePlaceholder":253},[53,6910,6911,6914],{"class":55,"line":94},[53,6912,6913],{"class":77},"set",[53,6915,6916],{"class":77}," -e\n",[53,6918,6919],{"class":55,"line":106},[53,6920,331],{"emptyLinePlaceholder":253},[53,6922,6923],{"class":55,"line":367},[53,6924,6925],{"class":59},"# 1. 构建前端\n",[53,6927,6928,6931,6933],{"class":55,"line":384},[53,6929,6930],{"class":87},"pnpm",[53,6932,352],{"class":295},[53,6934,6935],{"class":77}," --no-frozen-lockfile\n",[53,6937,6938,6940,6943],{"class":55,"line":400},[53,6939,1988],{"class":87},[53,6941,6942],{"class":295}," vite",[53,6944,6945],{"class":295}," build\n",[53,6947,6948],{"class":55,"line":406},[53,6949,331],{"emptyLinePlaceholder":253},[53,6951,6952],{"class":55,"line":420},[53,6953,6954],{"class":59},"# 2. 复制到嵌入目录\n",[53,6956,6957,6960,6963],{"class":55,"line":443},[53,6958,6959],{"class":87},"rm",[53,6961,6962],{"class":77}," -rf",[53,6964,6965],{"class":295}," service\u002Fweb\u002Fdist\n",[53,6967,6968,6971,6974,6977],{"class":55,"line":452},[53,6969,6970],{"class":87},"cp",[53,6972,6973],{"class":77}," -r",[53,6975,6976],{"class":295}," dist",[53,6978,6965],{"class":295},[53,6980,6981],{"class":55,"line":457},[53,6982,331],{"emptyLinePlaceholder":253},[53,6984,6985],{"class":55,"line":463},[53,6986,6987],{"class":59},"# 3. 编译 Go 二进制\n",[53,6989,6990,6993],{"class":55,"line":474},[53,6991,6992],{"class":77},"cd",[53,6994,6995],{"class":295}," service\n",[53,6997,6998,7000,7002,7004,7007],{"class":55,"line":485},[53,6999,409],{"class":87},[53,7001,425],{"class":295},[53,7003,434],{"class":77},[53,7005,7006],{"class":295}," myapp.exe",[53,7008,4574],{"class":77},[53,7010,7011,7014,7017],{"class":55,"line":490},[53,7012,7013],{"class":77},"    -ldflags=",[53,7015,7016],{"class":295},"\"-X myproject\u002Fglobal.RUNCODE=release\"",[53,7018,4574],{"class":77},[53,7020,7021],{"class":55,"line":499},[53,7022,7023],{"class":295},"    main.go\n",[11,7025,7026],{"id":7026},"踩坑记录",[30,7028,7030],{"id":7029},"坑一路由不匹配静态资源-404","坑一：路由不匹配，静态资源 404",[16,7032,7033,7034,7036,7037,7039],{},"首次实现时用了 Gin 的 ",[50,7035,6882],{}," 兜底，但部分静态资源返回 404。排查发现 ",[50,7038,6882],{}," 在某些路由未显式注册时不会按预期触发。",[16,7041,7042,7045,7046,7049,7050,7053,7054,7056],{},[38,7043,7044],{},"解决","：显式注册 ",[50,7047,7048],{},"\u002Fassets\u002F*filepath","、",[50,7051,7052],{},"\u002Ffavicon.ico"," 等路由，",[50,7055,6882],{}," 仅作为兜底。",[30,7058,7060,7061,7063,7064,7067],{"id":7059},"坑二goembed-排除-_-开头文件","坑二：",[50,7062,6428],{}," 排除 ",[50,7065,7066],{},"_"," 开头文件",[16,7069,7070,7071,7074,7075,7077,7078,2114],{},"这是最坑的。Vite 构建产出了一个 ",[50,7072,7073],{},"_plugin-vue_export-helper-xxx.js","，Go 的 ",[50,7076,6428],{}," 当模式为目录时，",[38,7079,7080,7081,7083,7084,7086],{},"硬编码排除了 ",[50,7082,7066],{}," 和 ",[50,7085,2345],{}," 开头的文件",[43,7088,7090],{"className":3647,"code":7089,"language":409,"meta":48,"style":48},"\u002F\u002F 这行会跳过 dist\u002Fassets\u002F_plugin-xxx.js\n\u002F\u002Fgo:embed dist\n",[50,7091,7092,7097],{"__ignoreMap":48},[53,7093,7094],{"class":55,"line":56},[53,7095,7096],{"class":59},"\u002F\u002F 这行会跳过 dist\u002Fassets\u002F_plugin-xxx.js\n",[53,7098,7099],{"class":55,"line":63},[53,7100,6549],{"class":59},[16,7102,7103,7104,2114],{},"错误表现：JS 文件返回的是 HTML 内容（SPA fallback），浏览器报 ",[50,7105,7106],{},"Unexpected token '\u003C'",[16,7108,7109,7111,7112,7114],{},[38,7110,7044],{},"：改 Vite 配置，去除了 ",[50,7113,7066],{}," 前缀：",[43,7116,7120],{"className":7117,"code":7118,"language":7119,"meta":48,"style":48},"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",[50,7121,7122,7127,7134,7141,7148,7156,7194,7199,7204,7209],{"__ignoreMap":48},[53,7123,7124],{"class":55,"line":56},[53,7125,7126],{"class":59},"\u002F\u002F vite.config.ts\n",[53,7128,7129,7132],{"class":55,"line":63},[53,7130,7131],{"class":87},"build",[53,7133,2142],{"class":70},[53,7135,7136,7139],{"class":55,"line":81},[53,7137,7138],{"class":87},"  rollupOptions",[53,7140,2142],{"class":70},[53,7142,7143,7146],{"class":55,"line":94},[53,7144,7145],{"class":87},"    output",[53,7147,2142],{"class":70},[53,7149,7150,7153],{"class":55,"line":106},[53,7151,7152],{"class":87},"      chunkFileNames",[53,7154,7155],{"class":70},"(chunkInfo) {\n",[53,7157,7158,7160,7163,7166,7168,7171,7173,7175,7177,7179,7181,7183,7185,7187,7189,7191],{"class":55,"line":367},[53,7159,4101],{"class":66},[53,7161,7162],{"class":295}," `assets\u002F${",[53,7164,7165],{"class":70},"chunkInfo",[53,7167,2345],{"class":295},[53,7169,7170],{"class":70},"name",[53,7172,2345],{"class":295},[53,7174,2723],{"class":87},[53,7176,2223],{"class":295},[53,7178,2342],{"class":295},[53,7180,2329],{"class":66},[53,7182,7066],{"class":2332},[53,7184,2342],{"class":295},[53,7186,1034],{"class":295},[53,7188,2740],{"class":295},[53,7190,3044],{"class":295},[53,7192,7193],{"class":295},"}-[hash].js`\n",[53,7195,7196],{"class":55,"line":384},[53,7197,7198],{"class":70},"      },\n",[53,7200,7201],{"class":55,"line":400},[53,7202,7203],{"class":70},"    },\n",[53,7205,7206],{"class":55,"line":406},[53,7207,7208],{"class":70},"  },\n",[53,7210,7211],{"class":55,"line":420},[53,7212,7213],{"class":70},"},\n",[16,7215,7216,7217,7219],{},"这样就避免产生 ",[50,7218,7066],{}," 前缀文件名的同时，保持构建产物的一致性和引用关系的正确性。",[30,7221,7223],{"id":7222},"坑三构建缓存导致-embed-内容未更新","坑三：构建缓存导致 embed 内容未更新",[16,7225,7226,7227,7230],{},"多次 ",[50,7228,7229],{},"go build"," 后，Go 的构建缓存可能使用了旧版本的 embed 内容，导致实际运行的文件不是最新的。",[16,7232,7233,7235,7236,7239],{},[38,7234,7044],{},"：使用 ",[50,7237,7238],{},"go build -a"," 强制重新编译所有包。",[11,7241,7242],{"id":7242},"最终效果",[43,7244,7246],{"className":278,"code":7245,"language":280,"meta":48,"style":48},"$ bash build-standalone.sh\n=== 构建成功 ===\n输出: service\u002Fmyapp.exe  41M\n",[50,7247,7248,7258,7268],{"__ignoreMap":48},[53,7249,7250,7252,7255],{"class":55,"line":56},[53,7251,2339],{"class":87},[53,7253,7254],{"class":295}," bash",[53,7256,7257],{"class":295}," build-standalone.sh\n",[53,7259,7260,7262,7265],{"class":55,"line":63},[53,7261,2317],{"class":295},[53,7263,7264],{"class":295}," 构建成功",[53,7266,7267],{"class":295}," ===\n",[53,7269,7270,7273,7276],{"class":55,"line":81},[53,7271,7272],{"class":87},"输出:",[53,7274,7275],{"class":295}," service\u002Fmyapp.exe",[53,7277,7278],{"class":295},"  41M\n",[16,7280,7281],{},"一个 41MB 的 exe，内置前端页面、API 后端、SQLite 数据库（运行时初始化），拖到哪都能跑。",[16,7283,7284],{},[38,7285,7286],{},"部署对比：",[139,7288,7289,7302],{},[142,7290,7291],{},[145,7292,7293,7296,7299],{},[148,7294,7295],{},"方式",[148,7297,7298],{},"文件数",[148,7300,7301],{},"操作",[161,7303,7304,7315],{},[145,7305,7306,7309,7312],{},[166,7307,7308],{},"旧方案",[166,7310,7311],{},"1 exe + 1 web 目录",[166,7313,7314],{},"解压→保持目录结构",[145,7316,7317,7320,7323],{},[166,7318,7319],{},"新方案",[166,7321,7322],{},"1 个 exe",[166,7324,7325],{},"直接扔到服务器运行",[11,7327,216],{"id":216},[16,7329,7330,7331,7333],{},"Go 的 ",[50,7332,6428],{}," 是一个强大的工具，但有两个隐藏行为需要注意：",[521,7335,7336,7343],{},[524,7337,7338,7339,7083,7341,7086],{},"嵌入目录时自动排除 ",[50,7340,7066],{},[50,7342,2345],{},[524,7344,7345],{},"构建缓存可能导致 embed 不是最新的",[16,7347,7348],{},"对于前端+后端合体的场景，这是性价比最高的方案——不需要额外的打包工具，纯标准库搞定。",[235,7350,7351],{},"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":48,"searchDepth":63,"depth":81,"links":7353},[7354,7355,7356,7361,7367,7368],{"id":2090,"depth":63,"text":2090},{"id":6422,"depth":63,"text":6422},{"id":6436,"depth":63,"text":6436,"children":7357},[7358,7359,7360],{"id":6439,"depth":81,"text":6440},{"id":6776,"depth":81,"text":6777},{"id":6888,"depth":81,"text":6889},{"id":7026,"depth":63,"text":7026,"children":7362},[7363,7364,7366],{"id":7029,"depth":81,"text":7030},{"id":7059,"depth":81,"text":7365},"坑二：\u002F\u002Fgo:embed 排除 _ 开头文件",{"id":7222,"depth":81,"text":7223},{"id":7242,"depth":63,"text":7242},{"id":216,"depth":63,"text":216},"记录将 Vue 3 前端嵌入 Go 二进制实现单 exe 发布的完整流程，以及遇到的坑和解决方案。","\u002Fposts\u002Fgo-embed-frontend\u002Fimg\u002Fcover.jpg",{},"\u002Fposts\u002Fgo-embed-frontend","2026-06-06T16:00:00",{"title":6405,"description":7369},"posts\u002Fgo-embed-frontend\u002Findex",[1084,7377,7378,7379],"Vue","编译","埋坑","2026-06-06","SIQNL24wVAFb18wIGWSB3lNVZUQ9B76jWgQ55TASTYk",{"id":7383,"title":7384,"author":6,"body":7385,"description":8781,"difficulty":1497,"draft":249,"extension":250,"image":8782,"meta":8783,"navigation":253,"path":8784,"pinned":249,"published":8785,"seo":8786,"series":6,"seriesOrder":6,"stem":8787,"tags":8788,"updated":8785,"__hash__":8790},"posts\u002Fposts\u002Fjira-cli-intro\u002Findex.md","jira_cli — 把企业 Jira 装进命令行，AI Agent 直接调用",{"type":8,"value":7386,"toc":8760},[7387,7391,7406,7473,7476,7479,7556,7558,7591,7598,7601,7605,7717,7721,7852,7856,7924,7927,7990,7993,7995,7999,8002,8022,8025,8040,8043,8047,8053,8191,8202,8233,8237,8240,8337,8341,8344,8462,8466,8469,8533,8536,8540,8543,8611,8614,8620,8623,8657,8664,8667,8727,8729,8732,8749,8757],[11,7388,7390],{"id":7389},"jira_cli-是什么","jira_cli 是什么",[16,7392,7393,7398,7399,7083,7402,7405],{},[2043,7394,7397],{"href":7395,"rel":7396},"https:\u002F\u002Fgithub.com\u002Fyourname\u002Fjira_cli",[2047],"jira_cli"," 是一个面向企业 Jira（Server \u002F Data Center）的本地命令行工具，Go 语言编写，MIT 协议开源。它同时提供 ",[38,7400,7401],{},"Cobra CLI",[38,7403,7404],{},"MCP stdio server"," 两种入口，既能当日常命令行用，又能直接接入 AI 编程工具。",[43,7407,7409],{"className":278,"code":7408,"language":280,"meta":48,"style":48},"# 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",[50,7410,7411,7416,7429,7440,7455,7459,7464],{"__ignoreMap":48},[53,7412,7413],{"class":55,"line":56},[53,7414,7415],{"class":59},"# CLI 日常使用\n",[53,7417,7418,7420,7423,7426],{"class":55,"line":63},[53,7419,7397],{"class":87},[53,7421,7422],{"class":295}," issue",[53,7424,7425],{"class":295}," search",[53,7427,7428],{"class":295}," \"project = PROJ ORDER BY updated DESC\"\n",[53,7430,7431,7433,7435,7437],{"class":55,"line":81},[53,7432,7397],{"class":87},[53,7434,7422],{"class":295},[53,7436,3628],{"class":295},[53,7438,7439],{"class":295}," PROJ-123\n",[53,7441,7442,7444,7446,7449,7452],{"class":55,"line":94},[53,7443,7397],{"class":87},[53,7445,7422],{"class":295},[53,7447,7448],{"class":295}," comment",[53,7450,7451],{"class":295}," PROJ-123",[53,7453,7454],{"class":295}," \"处理完成，请测试验证\"\n",[53,7456,7457],{"class":55,"line":106},[53,7458,331],{"emptyLinePlaceholder":253},[53,7460,7461],{"class":55,"line":367},[53,7462,7463],{"class":59},"# MCP 模式 — 给 AI 装上操作 Jira 的手\n",[53,7465,7466,7468,7470],{"class":55,"line":384},[53,7467,7397],{"class":87},[53,7469,3869],{"class":295},[53,7471,7472],{"class":77}," --user=work\n",[11,7474,7475],{"id":7475},"为什么需要它",[16,7477,7478],{},"大多数 Jira 命令行工具只解决\"开发者手动操作 Jira\"这一个场景。但 2026 年 AI 编程工具已经普及，你的 AI Agent（Claude Code、Codex、Cursor 等）也需要读写 Jira。痛点就来了：",[139,7480,7481,7491],{},[142,7482,7483],{},[145,7484,7485,7488],{},[148,7486,7487],{},"痛点",[148,7489,7490],{},"jira_cli 怎么解",[161,7492,7493,7501,7509,7521,7533,7544],{},[145,7494,7495,7498],{},[166,7496,7497],{},"企业 Jira 在内网，没有公网 API",[166,7499,7500],{},"本地 stdio MCP server，不经过外网",[145,7502,7503,7506],{},[166,7504,7505],{},"AI 可能误删、误写 Jira 数据",[166,7507,7508],{},"账号级只读策略，AI 只能查不能改",[145,7510,7511,7514],{},[166,7512,7513],{},"团队多人多 Jira 实例",[166,7515,7516,7517,7520],{},"多 Profile 管理，",[50,7518,7519],{},"--user=work"," 切换",[145,7522,7523,7526],{},[166,7524,7525],{},"Windows 终端体验差",[166,7527,7528,7529,7532],{},"原生 ",[50,7530,7531],{},".exe"," 构建 + 一键安装脚本",[145,7534,7535,7538],{},[166,7536,7537],{},"AI 生成 JQL 困难",[166,7539,7540,7541],{},"字段元数据发现：",[50,7542,7543],{},"field list --query xxx",[145,7545,7546,7549],{},[166,7547,7548],{},"MCP 直接拿原始 JSON 太冗长",[166,7550,7551,7552,7555],{},"紧凑视图 + ",[50,7553,7554],{},"--view summary"," 减少 token 消耗",[11,7557,5768],{"id":5768},[43,7559,7561],{"className":278,"code":7560,"language":280,"meta":48,"style":48},"# 从源码安装（要求 Go 1.26.3+）\ngo install github.com\u002Fyourname\u002Fjira_cli@latest\n\n# 或下载预编译二进制\n# https:\u002F\u002Fgithub.com\u002Fyourname\u002Fjira_cli\u002Freleases\n",[50,7562,7563,7568,7577,7581,7586],{"__ignoreMap":48},[53,7564,7565],{"class":55,"line":56},[53,7566,7567],{"class":59},"# 从源码安装（要求 Go 1.26.3+）\n",[53,7569,7570,7572,7574],{"class":55,"line":63},[53,7571,409],{"class":87},[53,7573,352],{"class":295},[53,7575,7576],{"class":295}," github.com\u002Fyourname\u002Fjira_cli@latest\n",[53,7578,7579],{"class":55,"line":81},[53,7580,331],{"emptyLinePlaceholder":253},[53,7582,7583],{"class":55,"line":94},[53,7584,7585],{"class":59},"# 或下载预编译二进制\n",[53,7587,7588],{"class":55,"line":106},[53,7589,7590],{"class":59},"# https:\u002F\u002Fgithub.com\u002Fyourname\u002Fjira_cli\u002Freleases\n",[16,7592,7593,7594,7597],{},"Windows 用户下载 zip 包解压后双击 ",[50,7595,7596],{},"install.bat"," 即可全局安装，自动加入 PATH。",[11,7599,7600],{"id":7600},"快速上手",[30,7602,7604],{"id":7603},"_1-配置认证","1. 配置认证",[43,7606,7608],{"className":278,"code":7607,"language":280,"meta":48,"style":48},"# 设置 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",[50,7609,7610,7615,7631,7635,7640,7660,7664,7669,7698,7702,7707],{"__ignoreMap":48},[53,7611,7612],{"class":55,"line":56},[53,7613,7614],{"class":59},"# 设置 Jira 地址\n",[53,7616,7617,7619,7622,7625,7628],{"class":55,"line":63},[53,7618,7397],{"class":87},[53,7620,7621],{"class":295}," set",[53,7623,7624],{"class":295}," base-url",[53,7626,7627],{"class":295}," https:\u002F\u002Fjira.your-company.com",[53,7629,7630],{"class":77}," --global\n",[53,7632,7633],{"class":55,"line":81},[53,7634,331],{"emptyLinePlaceholder":253},[53,7636,7637],{"class":55,"line":94},[53,7638,7639],{"class":59},"# Token 认证（推荐）\n",[53,7641,7642,7644,7646,7649,7651,7654,7656,7658],{"class":55,"line":106},[53,7643,7397],{"class":87},[53,7645,7621],{"class":295},[53,7647,7648],{"class":295}," token",[53,7650,815],{"class":66},[53,7652,7653],{"class":295},"your-toke",[53,7655,1220],{"class":70},[53,7657,824],{"class":66},[53,7659,7630],{"class":77},[53,7661,7662],{"class":55,"line":367},[53,7663,331],{"emptyLinePlaceholder":253},[53,7665,7666],{"class":55,"line":384},[53,7667,7668],{"class":59},"# 或用户名密码认证\n",[53,7670,7671,7673,7675,7677,7679,7682,7684,7686,7688,7691,7694,7696],{"class":55,"line":400},[53,7672,7397],{"class":87},[53,7674,7621],{"class":295},[53,7676,5842],{"class":295},[53,7678,815],{"class":66},[53,7680,7681],{"class":295},"usernam",[53,7683,1206],{"class":70},[53,7685,824],{"class":66},[53,7687,815],{"class":66},[53,7689,7690],{"class":295},"passwor",[53,7692,7693],{"class":70},"d",[53,7695,824],{"class":66},[53,7697,7630],{"class":77},[53,7699,7700],{"class":55,"line":406},[53,7701,331],{"emptyLinePlaceholder":253},[53,7703,7704],{"class":55,"line":420},[53,7705,7706],{"class":59},"# 验证一下\n",[53,7708,7709,7711,7714],{"class":55,"line":443},[53,7710,7397],{"class":87},[53,7712,7713],{"class":295}," user",[53,7715,7716],{"class":295}," me\n",[30,7718,7720],{"id":7719},"_2-日常使用","2. 日常使用",[43,7722,7724],{"className":278,"code":7723,"language":280,"meta":48,"style":48},"# 查自己今天的任务\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",[50,7725,7726,7731,7742,7746,7751,7761,7765,7770,7784,7801,7805,7810,7823,7827,7832],{"__ignoreMap":48},[53,7727,7728],{"class":55,"line":56},[53,7729,7730],{"class":59},"# 查自己今天的任务\n",[53,7732,7733,7735,7737,7739],{"class":55,"line":63},[53,7734,7397],{"class":87},[53,7736,7422],{"class":295},[53,7738,7425],{"class":295},[53,7740,7741],{"class":295}," \"assignee = currentUser() ORDER BY updated DESC\"\n",[53,7743,7744],{"class":55,"line":81},[53,7745,331],{"emptyLinePlaceholder":253},[53,7747,7748],{"class":55,"line":94},[53,7749,7750],{"class":59},"# 看某个任务详情\n",[53,7752,7753,7755,7757,7759],{"class":55,"line":106},[53,7754,7397],{"class":87},[53,7756,7422],{"class":295},[53,7758,3628],{"class":295},[53,7760,7439],{"class":295},[53,7762,7763],{"class":55,"line":367},[53,7764,331],{"emptyLinePlaceholder":253},[53,7766,7767],{"class":55,"line":384},[53,7768,7769],{"class":59},"# 流转状态\n",[53,7771,7772,7774,7776,7779,7781],{"class":55,"line":400},[53,7773,7397],{"class":87},[53,7775,7422],{"class":295},[53,7777,7778],{"class":295}," transitions",[53,7780,7451],{"class":295},[53,7782,7783],{"class":59},"                    # 可以转哪些\n",[53,7785,7786,7788,7790,7793,7795,7798],{"class":55,"line":406},[53,7787,7397],{"class":87},[53,7789,7422],{"class":295},[53,7791,7792],{"class":295}," do-transition",[53,7794,7451],{"class":295},[53,7796,7797],{"class":77}," 21",[53,7799,7800],{"class":59},"               # 执行流转\n",[53,7802,7803],{"class":55,"line":420},[53,7804,331],{"emptyLinePlaceholder":253},[53,7806,7807],{"class":55,"line":443},[53,7808,7809],{"class":59},"# 添加备注\n",[53,7811,7812,7814,7816,7818,7820],{"class":55,"line":452},[53,7813,7397],{"class":87},[53,7815,7422],{"class":295},[53,7817,7448],{"class":295},[53,7819,7451],{"class":295},[53,7821,7822],{"class":295}," \"已定位到问题，正在修复\"\n",[53,7824,7825],{"class":55,"line":457},[53,7826,331],{"emptyLinePlaceholder":253},[53,7828,7829],{"class":55,"line":463},[53,7830,7831],{"class":59},"# 创建任务\n",[53,7833,7834,7836,7838,7840,7843,7846,7849],{"class":55,"line":474},[53,7835,7397],{"class":87},[53,7837,7422],{"class":295},[53,7839,5728],{"class":295},[53,7841,7842],{"class":295}," PROJ",[53,7844,7845],{"class":295}," Task",[53,7847,7848],{"class":77}," --fields",[53,7850,7851],{"class":295}," @fields.json\n",[30,7853,7855],{"id":7854},"_3-配给-ai-使用","3. 配给 AI 使用",[43,7857,7859],{"className":278,"code":7858,"language":280,"meta":48,"style":48},"# 先配置一个只读账号给 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",[50,7860,7861,7866,7883,7906],{"__ignoreMap":48},[53,7862,7863],{"class":55,"line":56},[53,7864,7865],{"class":59},"# 先配置一个只读账号给 AI 用\n",[53,7867,7868,7870,7872,7874,7876,7878,7881],{"class":55,"line":63},[53,7869,7397],{"class":87},[53,7871,7621],{"class":295},[53,7873,7624],{"class":295},[53,7875,7627],{"class":295},[53,7877,812],{"class":77},[53,7879,7880],{"class":295}," ai",[53,7882,7630],{"class":77},[53,7884,7885,7887,7889,7891,7893,7896,7898,7900,7902,7904],{"class":55,"line":81},[53,7886,7397],{"class":87},[53,7888,7621],{"class":295},[53,7890,7648],{"class":295},[53,7892,815],{"class":66},[53,7894,7895],{"class":295},"toke",[53,7897,1220],{"class":70},[53,7899,824],{"class":66},[53,7901,812],{"class":77},[53,7903,7880],{"class":295},[53,7905,7630],{"class":77},[53,7907,7908,7910,7912,7915,7918,7920,7922],{"class":55,"line":94},[53,7909,7397],{"class":87},[53,7911,7621],{"class":295},[53,7913,7914],{"class":295}," readonly",[53,7916,7917],{"class":295}," on",[53,7919,812],{"class":77},[53,7921,7880],{"class":295},[53,7923,7630],{"class":77},[16,7925,7926],{},"然后在 AI 工具的 MCP 配置里加上：",[43,7928,7930],{"className":5439,"code":7929,"language":5441,"meta":48,"style":48},"{\n  \"mcpServers\": {\n    \"jira\": {\n      \"command\": \"jira_cli\",\n      \"args\": [\"mcp\", \"--user=ai\"]\n    }\n  }\n}\n",[50,7931,7932,7936,7942,7949,7961,7978,7982,7986],{"__ignoreMap":48},[53,7933,7934],{"class":55,"line":56},[53,7935,5448],{"class":70},[53,7937,7938,7940],{"class":55,"line":63},[53,7939,5453],{"class":77},[53,7941,2142],{"class":70},[53,7943,7944,7947],{"class":55,"line":81},[53,7945,7946],{"class":77},"    \"jira\"",[53,7948,2142],{"class":70},[53,7950,7951,7954,7956,7959],{"class":55,"line":94},[53,7952,7953],{"class":77},"      \"command\"",[53,7955,1927],{"class":70},[53,7957,7958],{"class":295},"\"jira_cli\"",[53,7960,1874],{"class":70},[53,7962,7963,7966,7968,7971,7973,7976],{"class":55,"line":106},[53,7964,7965],{"class":77},"      \"args\"",[53,7967,1958],{"class":70},[53,7969,7970],{"class":295},"\"mcp\"",[53,7972,1034],{"class":70},[53,7974,7975],{"class":295},"\"--user=ai\"",[53,7977,737],{"class":70},[53,7979,7980],{"class":55,"line":367},[53,7981,2183],{"class":70},[53,7983,7984],{"class":55,"line":384},[53,7985,2188],{"class":70},[53,7987,7988],{"class":55,"line":400},[53,7989,109],{"class":70},[16,7991,7992],{},"这样 AI 就能查 Jira 但不能修改，既有了上下文，又不用担心误操作。",[11,7994,6250],{"id":6250},[30,7996,7998],{"id":7997},"_1-账号级只读策略","1. 账号级只读策略",[16,8000,8001],{},"这是 jira_cli 最实用的设计。给 AI Agent 配置只读账号：",[43,8003,8005],{"className":278,"code":8004,"language":280,"meta":48,"style":48},"jira_cli set readonly on --name ai\n",[50,8006,8007],{"__ignoreMap":48},[53,8008,8009,8011,8013,8015,8017,8019],{"class":55,"line":56},[53,8010,7397],{"class":87},[53,8012,7621],{"class":295},[53,8014,7914],{"class":295},[53,8016,7917],{"class":295},[53,8018,812],{"class":77},[53,8020,8021],{"class":295}," ai\n",[16,8023,8024],{},"只读模式下：",[1770,8026,8027,8030,8033],{},[524,8028,8029],{},"CLI 写命令被隐藏和拒绝",[524,8031,8032],{},"MCP 的写入工具不会注册",[524,8034,8035,8036,8039],{},"环境变量 ",[50,8037,8038],{},"JIRA_READONLY=true"," 也可以临时覆盖",[16,8041,8042],{},"AI 只能查 JQL、看任务、查字段、读评论，绝对不会误改数据。需要创建\u002F更新时，另配一个有写入权限的账号由开发者自己操作。",[30,8044,8046],{"id":8045},"_2-多-profile-切换","2. 多 Profile 切换",[16,8048,8049,8050,8052],{},"一个配置文件里管理多个 Jira 账号，",[50,8051,1171],{}," 切换：",[43,8054,8056],{"className":278,"code":8055,"language":280,"meta":48,"style":48},"# 工作账号\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",[50,8057,8058,8063,8081,8104,8108,8113,8131,8154,8158,8163,8177],{"__ignoreMap":48},[53,8059,8060],{"class":55,"line":56},[53,8061,8062],{"class":59},"# 工作账号\n",[53,8064,8065,8067,8069,8071,8074,8076,8079],{"class":55,"line":63},[53,8066,7397],{"class":87},[53,8068,7621],{"class":295},[53,8070,7624],{"class":295},[53,8072,8073],{"class":295}," https:\u002F\u002Fjira.company.com",[53,8075,812],{"class":77},[53,8077,8078],{"class":295}," work",[53,8080,7630],{"class":77},[53,8082,8083,8085,8087,8089,8091,8094,8096,8098,8100,8102],{"class":55,"line":81},[53,8084,7397],{"class":87},[53,8086,7621],{"class":295},[53,8088,7648],{"class":295},[53,8090,815],{"class":66},[53,8092,8093],{"class":295},"work-toke",[53,8095,1220],{"class":70},[53,8097,824],{"class":66},[53,8099,812],{"class":77},[53,8101,8078],{"class":295},[53,8103,7630],{"class":77},[53,8105,8106],{"class":55,"line":94},[53,8107,331],{"emptyLinePlaceholder":253},[53,8109,8110],{"class":55,"line":106},[53,8111,8112],{"class":59},"# 个人账号\n",[53,8114,8115,8117,8119,8121,8124,8126,8129],{"class":55,"line":367},[53,8116,7397],{"class":87},[53,8118,7621],{"class":295},[53,8120,7624],{"class":295},[53,8122,8123],{"class":295}," https:\u002F\u002Fjira.personal.com",[53,8125,812],{"class":77},[53,8127,8128],{"class":295}," personal",[53,8130,7630],{"class":77},[53,8132,8133,8135,8137,8139,8141,8144,8146,8148,8150,8152],{"class":55,"line":384},[53,8134,7397],{"class":87},[53,8136,7621],{"class":295},[53,8138,7648],{"class":295},[53,8140,815],{"class":66},[53,8142,8143],{"class":295},"personal-toke",[53,8145,1220],{"class":70},[53,8147,824],{"class":66},[53,8149,812],{"class":77},[53,8151,8128],{"class":295},[53,8153,7630],{"class":77},[53,8155,8156],{"class":55,"line":400},[53,8157,331],{"emptyLinePlaceholder":253},[53,8159,8160],{"class":55,"line":406},[53,8161,8162],{"class":59},"# 使用时切换\n",[53,8164,8165,8167,8170,8172,8174],{"class":55,"line":420},[53,8166,7397],{"class":87},[53,8168,8169],{"class":77}," --user=work",[53,8171,7422],{"class":295},[53,8173,7425],{"class":295},[53,8175,8176],{"class":295}," \"project = PROJ\"\n",[53,8178,8179,8181,8184,8186,8188],{"class":55,"line":443},[53,8180,7397],{"class":87},[53,8182,8183],{"class":77}," --user=personal",[53,8185,7422],{"class":295},[53,8187,7425],{"class":295},[53,8189,8190],{"class":295}," \"project = SIDE\"\n",[16,8192,8193,8194,8197,8198,8201],{},"同时支持",[38,8195,8196],{},"项目级配置","（",[50,8199,8200],{},"--local","）和工作目录绑定：",[43,8203,8205],{"className":278,"code":8204,"language":280,"meta":48,"style":48},"# 在项目目录下，配置只对当前项目生效\ncd ~\u002Fprojects\u002Fbackend\njira_cli set base-url https:\u002F\u002Fjira-backend.company.com --local\n",[50,8206,8207,8212,8219],{"__ignoreMap":48},[53,8208,8209],{"class":55,"line":56},[53,8210,8211],{"class":59},"# 在项目目录下，配置只对当前项目生效\n",[53,8213,8214,8216],{"class":55,"line":63},[53,8215,6992],{"class":77},[53,8217,8218],{"class":295}," ~\u002Fprojects\u002Fbackend\n",[53,8220,8221,8223,8225,8227,8230],{"class":55,"line":81},[53,8222,7397],{"class":87},[53,8224,7621],{"class":295},[53,8226,7624],{"class":295},[53,8228,8229],{"class":295}," https:\u002F\u002Fjira-backend.company.com",[53,8231,8232],{"class":77}," --local\n",[30,8234,8236],{"id":8235},"_3-对-ai-友好的输出","3. 对 AI 友好的输出",[16,8238,8239],{},"jira_cli 在输出设计上充分考虑了 AI Agent 的 token 消耗：",[43,8241,8243],{"className":278,"code":8242,"language":280,"meta":48,"style":48},"# 紧凑视图 — 去掉 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",[50,8244,8245,8250,8266,8270,8275,8292,8296,8301,8316,8320,8325],{"__ignoreMap":48},[53,8246,8247],{"class":55,"line":56},[53,8248,8249],{"class":59},"# 紧凑视图 — 去掉 self\u002Favatar\u002F缩略图等无用字段\n",[53,8251,8252,8254,8256,8258,8260,8263],{"class":55,"line":63},[53,8253,7397],{"class":87},[53,8255,7422],{"class":295},[53,8257,3628],{"class":295},[53,8259,7451],{"class":295},[53,8261,8262],{"class":77}," --view",[53,8264,8265],{"class":295}," summary\n",[53,8267,8268],{"class":55,"line":81},[53,8269,331],{"emptyLinePlaceholder":253},[53,8271,8272],{"class":55,"line":94},[53,8273,8274],{"class":59},"# 表格输出 — 一眼看到关键信息\n",[53,8276,8277,8279,8281,8283,8286,8289],{"class":55,"line":106},[53,8278,7397],{"class":87},[53,8280,7422],{"class":295},[53,8282,7425],{"class":295},[53,8284,8285],{"class":295}," \"project = PROJ\"",[53,8287,8288],{"class":77}," --output",[53,8290,8291],{"class":295}," table\n",[53,8293,8294],{"class":55,"line":367},[53,8295,331],{"emptyLinePlaceholder":253},[53,8297,8298],{"class":55,"line":384},[53,8299,8300],{"class":59},"# JSON 输出 — 程序解析，不转义 Unicode\n",[53,8302,8303,8305,8307,8310,8312,8314],{"class":55,"line":400},[53,8304,7397],{"class":87},[53,8306,8288],{"class":77},[53,8308,8309],{"class":295}," json",[53,8311,7422],{"class":295},[53,8313,3628],{"class":295},[53,8315,7439],{"class":295},[53,8317,8318],{"class":55,"line":406},[53,8319,331],{"emptyLinePlaceholder":253},[53,8321,8322],{"class":55,"line":420},[53,8323,8324],{"class":59},"# 自定义搜索列 — 只要需要的字段\n",[53,8326,8327,8329,8331,8334],{"class":55,"line":443},[53,8328,7397],{"class":87},[53,8330,7621],{"class":295},[53,8332,8333],{"class":295}," search-columns",[53,8335,8336],{"class":295}," key,summary,fixVersions\n",[30,8338,8340],{"id":8339},"_4-字段元数据发现","4. 字段元数据发现",[16,8342,8343],{},"Jira 的 customfield 让人头疼，jira_cli 提供了完整的字段发现能力：",[43,8345,8347],{"className":278,"code":8346,"language":280,"meta":48,"style":48},"# 列出所有字段及其类型\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",[50,8348,8349,8354,8363,8367,8372,8386,8390,8395,8406,8410,8415,8432,8436,8441],{"__ignoreMap":48},[53,8350,8351],{"class":55,"line":56},[53,8352,8353],{"class":59},"# 列出所有字段及其类型\n",[53,8355,8356,8358,8361],{"class":55,"line":63},[53,8357,7397],{"class":87},[53,8359,8360],{"class":295}," field",[53,8362,2277],{"class":295},[53,8364,8365],{"class":55,"line":81},[53,8366,331],{"emptyLinePlaceholder":253},[53,8368,8369],{"class":55,"line":94},[53,8370,8371],{"class":59},"# 搜索包含特定关键词的字段\n",[53,8373,8374,8376,8378,8380,8383],{"class":55,"line":106},[53,8375,7397],{"class":87},[53,8377,8360],{"class":295},[53,8379,5663],{"class":295},[53,8381,8382],{"class":77}," --query",[53,8384,8385],{"class":295}," \"版本\"\n",[53,8387,8388],{"class":55,"line":367},[53,8389,331],{"emptyLinePlaceholder":253},[53,8391,8392],{"class":55,"line":384},[53,8393,8394],{"class":59},"# 只看自定义字段\n",[53,8396,8397,8399,8401,8403],{"class":55,"line":400},[53,8398,7397],{"class":87},[53,8400,8360],{"class":295},[53,8402,5663],{"class":295},[53,8404,8405],{"class":77}," --custom-only\n",[53,8407,8408],{"class":55,"line":406},[53,8409,331],{"emptyLinePlaceholder":253},[53,8411,8412],{"class":55,"line":420},[53,8413,8414],{"class":59},"# 创建任务前，先看看需要哪些字段\n",[53,8416,8417,8419,8421,8424,8426,8429],{"class":55,"line":443},[53,8418,7397],{"class":87},[53,8420,7422],{"class":295},[53,8422,8423],{"class":295}," create-meta",[53,8425,7842],{"class":295},[53,8427,8428],{"class":77}," --issue-type",[53,8430,8431],{"class":295}," Task\n",[53,8433,8434],{"class":55,"line":452},[53,8435,331],{"emptyLinePlaceholder":253},[53,8437,8438],{"class":55,"line":457},[53,8439,8440],{"class":59},"# 分页查看可选值\n",[53,8442,8443,8445,8447,8450,8452,8454,8457,8459],{"class":55,"line":463},[53,8444,7397],{"class":87},[53,8446,7422],{"class":295},[53,8448,8449],{"class":295}," create-meta-values",[53,8451,7842],{"class":295},[53,8453,7845],{"class":295},[53,8455,8456],{"class":295}," priority",[53,8458,8382],{"class":77},[53,8460,8461],{"class":295}," High\n",[30,8463,8465],{"id":8464},"_5-mcp-写入控制","5. MCP 写入控制",[16,8467,8468],{},"除了全局只读，MCP 还有更细粒度的控制：",[139,8470,8471,8481],{},[142,8472,8473],{},[145,8474,8475,8478],{},[148,8476,8477],{},"环境变量",[148,8479,8480],{},"作用",[161,8482,8483,8493,8503,8513,8523],{},[145,8484,8485,8490],{},[166,8486,8487],{},[50,8488,8489],{},"JIRA_CREATE_OPEN=false",[166,8491,8492],{},"禁用创建\u002F更新工具",[145,8494,8495,8500],{},[166,8496,8497],{},[50,8498,8499],{},"JIRA_DISABLE_DISCLAIMER=true",[166,8501,8502],{},"去掉 AI 自动添加的备注声明",[145,8504,8505,8510],{},[166,8506,8507],{},[50,8508,8509],{},"JIRA_CREATE_SUMMARY_MAX",[166,8511,8512],{},"限制摘要长度",[145,8514,8515,8520],{},[166,8516,8517],{},[50,8518,8519],{},"JIRA_CREATE_DESCRIPTION_MAX",[166,8521,8522],{},"限制描述长度",[145,8524,8525,8530],{},[166,8526,8527],{},[50,8528,8529],{},"JIRA_CREATE_FIELDS_MAX_BYTES",[166,8531,8532],{},"限制创建字段的 JSON 大小",[16,8534,8535],{},"这些限制确保 AI 即使有写入权限也不会\"失控\"——例如防止 AI 往描述里塞几万字的废话。",[30,8537,8539],{"id":8538},"_6-中英双语帮助","6. 中英双语帮助",[16,8541,8542],{},"帮助信息默认中文，也支持一键切英文：",[43,8544,8546],{"className":278,"code":8545,"language":280,"meta":48,"style":48},"# 保存英文偏好\njira_cli set help-language en\n\n# 切回中文\njira_cli set help-language zh\n\n# 临时覆盖\njira_cli --lang en issue search -h\n",[50,8547,8548,8553,8565,8569,8574,8585,8589,8594],{"__ignoreMap":48},[53,8549,8550],{"class":55,"line":56},[53,8551,8552],{"class":59},"# 保存英文偏好\n",[53,8554,8555,8557,8559,8562],{"class":55,"line":63},[53,8556,7397],{"class":87},[53,8558,7621],{"class":295},[53,8560,8561],{"class":295}," help-language",[53,8563,8564],{"class":295}," en\n",[53,8566,8567],{"class":55,"line":81},[53,8568,331],{"emptyLinePlaceholder":253},[53,8570,8571],{"class":55,"line":94},[53,8572,8573],{"class":59},"# 切回中文\n",[53,8575,8576,8578,8580,8582],{"class":55,"line":106},[53,8577,7397],{"class":87},[53,8579,7621],{"class":295},[53,8581,8561],{"class":295},[53,8583,8584],{"class":295}," zh\n",[53,8586,8587],{"class":55,"line":367},[53,8588,331],{"emptyLinePlaceholder":253},[53,8590,8591],{"class":55,"line":384},[53,8592,8593],{"class":59},"# 临时覆盖\n",[53,8595,8596,8598,8601,8604,8606,8608],{"class":55,"line":400},[53,8597,7397],{"class":87},[53,8599,8600],{"class":77}," --lang",[53,8602,8603],{"class":295}," en",[53,8605,7422],{"class":295},[53,8607,7425],{"class":295},[53,8609,8610],{"class":77}," -h\n",[11,8612,8613],{"id":8613},"架构一览",[43,8615,8618],{"className":8616,"code":8617,"language":125},[123],"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",[50,8619,8617],{"__ignoreMap":48},[16,8621,8622],{},"设计原则清晰：",[1770,8624,8625,8633,8641,8649],{},[524,8626,8627,8632],{},[38,8628,8629],{},[50,8630,8631],{},"pkg\u002Fjira"," 是纯 Jira 客户端，HTTP、认证、上下文处理都在这里",[524,8634,8635,8640],{},[38,8636,8637],{},[50,8638,8639],{},"cmd"," 只管 Cobra 解析和呈现",[524,8642,8643,8648],{},[38,8644,8645],{},[50,8646,8647],{},"internal\u002Ftools\u002Fjira_tools"," 只管 MCP schema 验证和结果适配",[524,8650,8651,8656],{},[38,8652,8653],{},[50,8654,8655],{},"internal\u002Fconfig"," 拥有 Profile 的全部逻辑，不分散到 cmd",[16,8658,8659,8660,8663],{},"每一层职责单一，新增 Jira 能力时按 ",[50,8661,8662],{},"endpoints.go → Client 方法 → CLI 命令 → MCP tool → 策略注册"," 的路径走就行。",[11,8665,8666],{"id":8666},"和已有方案的对比",[139,8668,8669,8681],{},[142,8670,8671],{},[145,8672,8673,8675,8678],{},[148,8674,2902],{},[148,8676,8677],{},"适合场景",[148,8679,8680],{},"jira_cli 的区别",[161,8682,8683,8694,8705,8716],{},[145,8684,8685,8688,8691],{},[166,8686,8687],{},"Atlassian Rovo MCP Server",[166,8689,8690],{},"Jira Cloud、官方 OAuth",[166,8692,8693],{},"jira_cli 是本地 stdio，适合内网 Server\u002FDC",[145,8695,8696,8699,8702],{},[166,8697,8698],{},"go-jira \u002F jira-cli 社区版",[166,8700,8701],{},"纯命令行管理",[166,8703,8704],{},"jira_cli 同时提供 MCP server，面向 AI 工作流",[145,8706,8707,8710,8713],{},[166,8708,8709],{},"直接调 REST API",[166,8711,8712],{},"自定义集成",[166,8714,8715],{},"jira_cli 封装了认证、只读策略、字段元数据",[145,8717,8718,8721,8724],{},[166,8719,8720],{},"Jira 网页",[166,8722,8723],{},"日常使用",[166,8725,8726],{},"jira_cli 把 Jira 变成终端里的一行命令",[11,8728,216],{"id":216},[16,8730,8731],{},"如果你的团队满足以下任一条件，jira_cli 值得一试：",[1770,8733,8734,8737,8740,8743,8746],{},[524,8735,8736],{},"企业 Jira 部署在内网，想接入 AI 编程工具",[524,8738,8739],{},"想给 AI Agent 只读权限来查 Jira 上下文",[524,8741,8742],{},"需要命令行快速查看\u002F操作 Jira，不想切浏览器",[524,8744,8745],{},"需要管理多个 Jira 实例和多套账号",[524,8747,8748],{},"Windows 用户想要无摩擦的 CLI 体验",[16,8750,8751,8752,8756],{},"GitHub：",[2043,8753,8755],{"href":7395,"rel":8754},[2047],"github.com\u002Fyourname\u002Fjira_cli"," | MIT 协议 | Go 1.26.3+",[235,8758,8759],{},"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":48,"searchDepth":63,"depth":81,"links":8761},[8762,8763,8764,8765,8770,8778,8779,8780],{"id":7389,"depth":63,"text":7390},{"id":7475,"depth":63,"text":7475},{"id":5768,"depth":63,"text":5768},{"id":7600,"depth":63,"text":7600,"children":8766},[8767,8768,8769],{"id":7603,"depth":81,"text":7604},{"id":7719,"depth":81,"text":7720},{"id":7854,"depth":81,"text":7855},{"id":6250,"depth":63,"text":6250,"children":8771},[8772,8773,8774,8775,8776,8777],{"id":7997,"depth":81,"text":7998},{"id":8045,"depth":81,"text":8046},{"id":8235,"depth":81,"text":8236},{"id":8339,"depth":81,"text":8340},{"id":8464,"depth":81,"text":8465},{"id":8538,"depth":81,"text":8539},{"id":8613,"depth":63,"text":8613},{"id":8666,"depth":63,"text":8666},{"id":216,"depth":63,"text":216},"介绍 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":7384,"description":8781},"posts\u002Fjira-cli-intro\u002Findex",[8789,6400,5615,1084,5617,6401],"Jira","vNH6sF2mMFmSjbHoEI2OqmYCgpFmaqyKt-MUfcQqXrU",{"id":8792,"title":8793,"author":6,"body":8794,"description":9338,"difficulty":1497,"draft":249,"extension":250,"image":9339,"meta":9340,"navigation":253,"path":9341,"pinned":249,"published":9342,"seo":9343,"series":9344,"seriesOrder":56,"stem":9345,"tags":9346,"updated":9349,"__hash__":9350},"posts\u002Fposts\u002Fsublog-evolution\u002Findex.md","尼克的小窝 架构变迁：从纯静态到前后端分离",{"type":8,"value":8795,"toc":9318},[8796,8800,8803,8814,8818,8822,8828,8832,8863,8866,8880,8884,8887,8955,8959,8962,8968,8972,8982,9012,9015,9019,9027,9030,9074,9078,9199,9203,9210,9214,9220,9226,9230,9233,9284,9288,9295,9302,9308,9311,9313],[11,8797,8799],{"id":8798},"写在前面","📝 写在前面",[16,8801,8802],{},"SuBlog 最初只是一个简单的技术博客——Nuxt 3 静态生成，Markdown 写文章，部署到静态托管完事。但随着功能需求的增长，纯静态的架构开始显露出局限性。",[16,8804,8805,8806,8809,8810,8813],{},"这篇文章记录从 ",[38,8807,8808],{},"纯静态"," 到 ",[38,8811,8812],{},"前后端分离"," 的演进过程，带你了解每一步背后的思考。",[11,8815,8817],{"id":8816},"第一代纯静态nuxt-3-nuxtcontent","📦 第一代：纯静态（Nuxt 3 + @nuxt\u002Fcontent）",[30,8819,8821],{"id":8820},"️-架构图","🏗️ 架构图",[43,8823,8826],{"className":8824,"code":8825,"language":125,"meta":48},[123],"┌─────────────────────────────────────┐\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",[50,8827,8825],{"__ignoreMap":48},[30,8829,8831],{"id":8830},"做了什么","🔧 做了什么",[1770,8833,8834,8843,8848,8853],{},[524,8835,8836,1463,8838,1463,8840,8842],{},[38,8837,1709],{},[38,8839,1719],{},[38,8841,1729],{}," 搭建前端",[524,8844,8845,8847],{},[38,8846,1739],{}," 管理 Markdown 文章，编译时生成静态页面",[524,8849,8850,8852],{},[38,8851,1759],{}," 嵌入评论，依赖 GitHub Discussions",[524,8854,8855,8856,8859,8860,8862],{},"部署通过 ",[50,8857,8858],{},"nuxt generate"," 输出 ",[50,8861,2000],{},"，丢到 Nginx 或 Vercel",[30,8864,8865],{"id":2905},"✅ 优点",[1770,8867,8868,8874,8877],{},[524,8869,8870,8871,8873],{},"部署极简，一个 ",[50,8872,8858],{}," 搞定",[524,8875,8876],{},"加载飞快，全是 HTML，没有服务端开销",[524,8878,8879],{},"无需后端，零运维成本",[30,8881,8883],{"id":8882},"局限","❌ 局限",[16,8885,8886],{},"随着时间的推移，纯静态的问题越来越明显：",[139,8888,8889,8898],{},[142,8890,8891],{},[145,8892,8893,8895],{},[148,8894,3199],{},[148,8896,8897],{},"具体表现",[161,8899,8900,8911,8922,8933,8944],{},[145,8901,8902,8908],{},[166,8903,8904,8905],{},"🐢 ",[38,8906,8907],{},"内容更新慢",[166,8909,8910],{},"改一篇文章要重新构建、重新部署，不能实时发布",[145,8912,8913,8919],{},[166,8914,8915,8916],{},"👤 ",[38,8917,8918],{},"没有用户体系",[166,8920,8921],{},"无法区分读者和管理员，所有内容对所有人一致可见",[145,8923,8924,8930],{},[166,8925,8926,8927],{},"🔒 ",[38,8928,8929],{},"评论受限",[166,8931,8932],{},"Giscus 需要 GitHub 账号才能评论，门槛高",[145,8934,8935,8941],{},[166,8936,8937,8938],{},"🚫 ",[38,8939,8940],{},"无法做动态功能",[166,8942,8943],{},"点赞、收藏、阅读统计、个性化推荐……统统做不了",[145,8945,8946,8952],{},[166,8947,8948,8949],{},"🔗 ",[38,8950,8951],{},"前端锁死",[166,8953,8954],{},"被 Nuxt\u002FVue 生态绑定，想换技术栈得重写",[11,8956,8958],{"id":8957},"第二代前后端分离react-go","⚡ 第二代：前后端分离（React + Go）",[30,8960,8821],{"id":8961},"️-架构图-1",[43,8963,8966],{"className":8964,"code":8965,"language":125,"meta":48},[123],"┌───────────────────────┐       ┌────────────────────┐\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",[50,8967,8965],{"__ignoreMap":48},[30,8969,8971],{"id":8970},"️-前端sublog-react","🖥️ 前端：sublog-react",[16,8973,8974,8975,8977,8978,8981],{},"选用 ",[38,8976,156],{},"（App Router） + ",[38,8979,8980],{},"React 19","，理由是：",[1770,8983,8984,8991,8998,9005],{},[524,8985,8986,8987,8990],{},"🎨 ",[38,8988,8989],{},"React 生态","——组件库成熟，开发效率高",[524,8992,8993,8994,8997],{},"📐 ",[38,8995,8996],{},"App Router","——支持布局嵌套，页面组织清晰",[524,8999,9000,9001,9004],{},"📦 ",[38,9002,9003],{},"静态导出","——仍可部署到静态托管",[524,9006,9007,9008,9011],{},"⚙️ ",[38,9009,9010],{},"运行时配置","——API 地址可动态注入，切换环境无需重新构建",[16,9013,9014],{},"前端不再直接读取 Markdown 文件，而是通过 HTTP API 获取文章数据。静态导出 + 客户端数据获取的组合，既保留了首屏加载速度，又实现了内容的动态更新。",[30,9016,9018],{"id":9017},"️-后端sublog-s","⚙️ 后端：sublog-s",[16,9020,9021,9022,1463,9024,9026],{},"后端使用 ",[38,9023,1084],{},[38,9025,5616],{},"，按业务逻辑分层组织代码。",[16,9028,9029],{},"核心技术选型：",[139,9031,9032,9040],{},[142,9033,9034],{},[145,9035,9036,9038],{},[148,9037,1697],{},[148,9039,1700],{},[161,9041,9042,9052,9063],{},[145,9043,9044,9049],{},[166,9045,9046,9047],{},"🚀 ",[38,9048,5616],{},[166,9050,9051],{},"HTTP 框架",[145,9053,9054,9060],{},[166,9055,9056,9057],{},"🗄️ ",[38,9058,9059],{},"GORM",[166,9061,9062],{},"ORM，数据库操作",[145,9064,9065,9071],{},[166,9066,9067,9068],{},"🔐 ",[38,9069,9070],{},"JWT",[166,9072,9073],{},"用户认证",[11,9075,9077],{"id":9076},"前后对比","📊 前后对比",[139,9079,9080,9093],{},[142,9081,9082],{},[145,9083,9084,9087,9090],{},[148,9085,9086],{},"维度",[148,9088,9089],{},"第一代（纯静态）",[148,9091,9092],{},"第二代（前后端分离）",[161,9094,9095,9107,9119,9133,9146,9159,9173,9185],{},[145,9096,9097,9101,9104],{},[166,9098,8986,9099],{},[38,9100,259],{},[166,9102,9103],{},"Nuxt 3 (Vue)",[166,9105,9106],{},"Next.js (React 19)",[145,9108,9109,9114,9116],{},[166,9110,9111,9112],{},"🖥️ ",[38,9113,5618],{},[166,9115,197],{},[166,9117,9118],{},"Go + Gin",[145,9120,9121,9127,9130],{},[166,9122,9123,9124],{},"📄 ",[38,9125,9126],{},"内容来源",[166,9128,9129],{},"Markdown 文件",[166,9131,9132],{},"API 数据库",[145,9134,9135,9140,9143],{},[166,9136,9046,9137],{},[38,9138,9139],{},"发布方式",[166,9141,9142],{},"全量重新构建",[166,9144,9145],{},"API 实时发布",[145,9147,9148,9153,9156],{},[166,9149,8915,9150],{},[38,9151,9152],{},"用户系统",[166,9154,9155],{},"❌ 无",[166,9157,9158],{},"✅ 注册\u002F登录",[145,9160,9161,9167,9170],{},[166,9162,9163,9164],{},"💬 ",[38,9165,9166],{},"评论",[166,9168,9169],{},"Giscus（需 GitHub）",[166,9171,9172],{},"自有评论系统（规划中）",[145,9174,9175,9179,9182],{},[166,9176,9000,9177],{},[38,9178,1975],{},[166,9180,9181],{},"1 个静态目录",[166,9183,9184],{},"前端静态 + 后端二进制",[145,9186,9187,9193,9196],{},[166,9188,9189,9190],{},"⚡ ",[38,9191,9192],{},"更新体验",[166,9194,9195],{},"改文章 → 构建 → 部署",[166,9197,9198],{},"改文章 → 立即生效",[11,9200,9202],{"id":9201},"新功能注册账号","🎉 新功能：注册账号",[16,9204,9205,9206,9209],{},"伴随着架构升级，",[38,9207,9208],{},"用户账号系统","也上线了。",[30,9211,9213],{"id":9212},"目前支持","📋 目前支持",[16,9215,9216,9217,3366],{},"现在你可以在尼克的小窝 ",[38,9218,9219],{},"注册一个账号",[43,9221,9224],{"className":9222,"code":9223,"language":125},[123],"1. ✏️ 填写用户名、邮箱、密码\n2. 📧 查收激活邮件\n3. 🔗 点击链接激活账号\n4. 🚪 登录后查看和管理个人信息\n",[50,9225,9223],{"__ignoreMap":48},[11,9227,9229],{"id":9228},"️-后续计划","🗓️ 后续计划",[16,9231,9232],{},"正在开发或规划中的功能：",[1770,9234,9236,9242,9248,9254,9260,9266,9272,9278],{"className":9235},[2008],[524,9237,9239,9241],{"className":9238},[2012],[2014,9240],{"checked":253,"disabled":253,"type":2016}," 用户注册\u002F登录（已上线 🎉）",[524,9243,9245,9247],{"className":9244},[2012],[2014,9246],{"checked":253,"disabled":253,"type":2016}," 个人资料编辑（已上线 🎉）",[524,9249,9251,9253],{"className":9250},[2012],[2014,9252],{"disabled":253,"type":2016}," 💬 站内评论系统",[524,9255,9257,9259],{"className":9256},[2012],[2014,9258],{"disabled":253,"type":2016}," 🔖 文章收藏",[524,9261,9263,9265],{"className":9262},[2012],[2014,9264],{"disabled":253,"type":2016}," 📚 阅读历史",[524,9267,9269,9271],{"className":9268},[2012],[2014,9270],{"disabled":253,"type":2016}," 👍 文章点赞",[524,9273,9275,9277],{"className":9274},[2012],[2014,9276],{"disabled":253,"type":2016}," 🔔 关注通知",[524,9279,9281,9283],{"className":9280},[2012],[2014,9282],{"disabled":253,"type":2016}," 🎨 个人首页定制",[11,9285,9287],{"id":9286},"结语","💡 结语",[16,9289,9290,9291,9294],{},"从 Nuxt 3 纯静态到 React + Go 前后端分离，SuBlog 的架构变迁本质上是一个",[38,9292,9293],{},"功能驱动","的过程。静态博客有它的位置——写写文章、展示内容完全够用。但当你想做的不仅仅是\"展示\"时，后端的能力就不可或缺了。",[16,9296,9297,9298,9301],{},"这次重构最大的感触是：",[38,9299,9300],{},"架构选型不是越复杂越好，而是刚好够用最好","。纯静态阶段用 Nuxt 3 一点问题没有，直到功能需求超出了静态的边界，才自然推动到前后端分离。",[16,9303,9304,9305],{},"如果你也在纠结\"要不要上后端\"，不妨先问问自己：",[38,9306,9307],{},"你要做的是\"展示内容\"还是\"提供服务\"？",[16,9309,9310],{},"前者，静态就够了；后者，来吧，后端欢迎你。😄",[2051,9312],{},[16,9314,9315],{},[38,9316,9317],{},"🎉 目前注册功能已经可以用了，后续更多好玩的功能正在路上。部分功能可能需要登录才能体验，快注册一个账号吧！",{"title":48,"searchDepth":63,"depth":81,"links":9319},[9320,9321,9327,9332,9333,9336,9337],{"id":8798,"depth":63,"text":8799},{"id":8816,"depth":63,"text":8817,"children":9322},[9323,9324,9325,9326],{"id":8820,"depth":81,"text":8821},{"id":8830,"depth":81,"text":8831},{"id":2905,"depth":81,"text":8865},{"id":8882,"depth":81,"text":8883},{"id":8957,"depth":63,"text":8958,"children":9328},[9329,9330,9331],{"id":8961,"depth":81,"text":8821},{"id":8970,"depth":81,"text":8971},{"id":9017,"depth":81,"text":9018},{"id":9076,"depth":63,"text":9077},{"id":9201,"depth":63,"text":9202,"children":9334},[9335],{"id":9212,"depth":81,"text":9213},{"id":9228,"depth":63,"text":9229},{"id":9286,"depth":63,"text":9287},"记录尼克的小窝从 Nuxt 3 纯静态博客演进到 React + Go 前后端分离架构的全过程，包括架构设计的思考、技术选型的变化，以及新增的账号注册功能。","\u002Fposts\u002Fsublog-evolution\u002Fimg\u002Fcover.jpg",{},"\u002Fposts\u002Fsublog-evolution","2026-07-23T12:00:00",{"title":8793,"description":9338},"尼克的小窝 开发记录","posts\u002Fsublog-evolution\u002Findex",[9347,9348,156,1084],"架构","React","2026-07-23","VMQ6A2K8GQ5t3DSlezWPd3KgXnmDVMn9QVeaI28Hcp8",{"id":9352,"title":9353,"author":6,"body":9354,"description":10517,"difficulty":1497,"draft":249,"extension":250,"image":10518,"meta":10519,"navigation":253,"path":10520,"pinned":249,"published":10521,"seo":10522,"series":6,"seriesOrder":6,"stem":10523,"tags":10524,"updated":10528,"__hash__":10529},"posts\u002Fposts\u002Fip-geolocation-guide\u002Findex.md","IP 属地是怎么查出来的？本地查询与在线查询方案对比",{"type":8,"value":9355,"toc":10493},[9356,9358,9364,9367,9378,9388,9392,9398,9411,9417,9428,9435,9438,9445,9449,9460,9464,9475,9478,9482,9488,9494,9497,9501,9508,9526,9529,9532,9536,9567,9606,9612,9616,9755,9791,9800,9804,9844,9849,9853,9864,9867,9901,9904,9908,9911,9915,10106,10110,10193,10197,10204,10311,10315,10324,10410,10413,10416,10452,10455,10458,10466,10487,10490],[11,9357,8798],{"id":8798},[16,9359,9360,9361],{},"很多网站会在「登录记录」「安全中心」里显示你的登录 IP 归属地，比如「福建·福州 · 中国移动」。你可能好奇过：",[38,9362,9363],{},"服务器是怎么根据一个 IP 就判断出我在哪个城市、哪个运营商的？",[16,9365,9366],{},"这篇科普文章讲清楚三件事：",[521,9368,9369,9372,9375],{},[524,9370,9371],{},"IP 属地到底查的是什么；",[524,9373,9374],{},"纯本地查询为什么能做到毫秒级且不联网；",[524,9376,9377],{},"几种主流查询方案的优缺点对比，方便你按场景选型。",[9379,9380,9381],"blockquote",{},[16,9382,9383,9384,9387],{},"说明：文中用到的 IP 均为 RFC 5737 保留的文档示例段（TEST-NET-2，",[50,9385,9386],{},"198.51.100.0\u002F24","），不会对应任何真实地址。",[11,9389,9391],{"id":9390},"先想清楚ip-属地查的是什么","先想清楚：IP 属地查的是什么",[16,9393,9394,9395,2114],{},"一个常见误区是「每个 IP 对应一个具体地址」。实际上 IP 属地的本质是 ",[38,9396,9397],{},"IP 段与归属地的映射",[16,9399,9400,9401,3366],{},"运营商和机构在申请 IP 时，是按",[38,9402,9403,9404,9410],{},"网段（CIDR）",[38,9405,9406,9407,9409],{},"成块分配的。比如 ",[50,9408,9386],{}," 这一个段，一整块都归属同一地区、同一运营商。所以归属地数据天然可以","按段存储",[43,9412,9415],{"className":9413,"code":9414,"language":125,"meta":48},[123],"IP 起始          IP 结束         归属地\n────────────────────────────────────────\n198.51.100.0     198.51.100.255  中国|福建|福州|移动\n198.51.101.0     198.51.101.255  中国|浙江|杭州|电信\n",[50,9416,9414],{"__ignoreMap":48},[16,9418,9419,9420,9423,9424,9427],{},"全球公网 IP 约有 42 亿个，但",[38,9421,9422],{},"网段只有几十万条","。正因为存的是「段」而不是「每个 IP」，完整的归属地数据文件才只有几 MB（ip2region 的 ",[50,9425,9426],{},".xdb"," 约 11MB）。这是「本地离线查询」可行的第一前提。",[16,9429,9430,9431,9434],{},"所以，",[38,9432,9433],{},"IP 属地从来不是精确到街道的定位","，它返回的是段级的「省｜市｜运营商」粗粒度信息。",[11,9436,9437],{"id":9437},"纯本地查询的原理",[16,9439,9440,9441,9444],{},"以开源的 ",[38,9442,9443],{},"ip2region"," 为例，它的查询完全在本地完成，不联网、不请求第三方，几十微秒到毫秒级返回。",[30,9446,9448],{"id":9447},"第一步把-ip-变成整数","第一步：把 IP 变成整数",[16,9450,9451,9452,9455,9456,9459],{},"以 ",[50,9453,9454],{},"198.51.100.42"," 为例，它会转成一个 32 位整数 ",[50,9457,9458],{},"0xC633642A","。IP 的四个数字本质就是 4 个字节，拼起来就是一个整数，这一步没有任何玄学。",[30,9461,9463],{"id":9462},"第二步二分查找","第二步：二分查找",[16,9465,9466,9467,9470,9471,9474],{},"在按起始 IP 排序的段列表里做",[38,9468,9469],{},"二分查找","，找到满足 ",[50,9472,9473],{},"段的起始IP ≤ 目标整数 ≤ 段的结束IP"," 的那一段。",[16,9476,9477],{},"二分查找的复杂度是 O(log N)，N 是几十万条，所以很快——这就是「不靠网络也能毫秒返回」的核心。",[30,9479,9481],{"id":9480},"第三步xdb-文件的两级结构","第三步：.xdb 文件的两级结构",[16,9483,9484,9485,9487],{},"ip2region 的 ",[50,9486,9426],{}," 不是简单顺序文件，内部分两个区，进一步加速：",[43,9489,9492],{"className":9490,"code":9491,"language":125,"meta":48},[123],"┌─────────────────────────────┐\n│  索引区 (Index)             │  \u002F\u002F 每段一条定长记录：startIP \u002F endIP \u002F 数据区指针 + 数据长度\n│  …… 二分快速定位段 ……       │\n├─────────────────────────────┤\n│  数据区 (Data)              │  \u002F\u002F 真正存放各段的地点字符串\n│  ……                         │\n└─────────────────────────────┘\n",[50,9493,9491],{"__ignoreMap":48},[16,9495,9496],{},"先二分索引区定位到「是哪一段」，再按指针去数据区读出地点字符串，一次磁盘读就能拿到结果。",[30,9498,9500],{"id":9499},"为什么离线数据不会很快过期","为什么「离线数据」不会很快过期",[16,9502,9503,9504,9507],{},"IP 归属取决于",[38,9505,9506],{},"注册机构和运营商的分配","，一个网段通常长期稳定属于某地区、某运营商。所以：",[1770,9509,9510,9513,9523],{},[524,9511,9512],{},"下载一次数据文件，可以用很久；",[524,9514,9515,9516,9522],{},"归属偶尔变化时，",[38,9517,9518,9519,9521],{},"每月更新一次 ",[50,9520,9426],{}," 文件","即可（替换文件、不用重启服务，见下文实战）；",[524,9524,9525],{},"数据源来自 IANA\u002FAPNIC 等机构的分配表与公开归属信息，不是实时探测。",[11,9527,9528],{"id":9528},"主流查询方案对比",[16,9530,9531],{},"实际开发中，查询 IP 属地主要有三条路线。",[30,9533,9535],{"id":9534},"方案一ip2region纯本地离线推荐","方案一：ip2region（纯本地离线，推荐）",[43,9537,9539],{"className":3647,"code":9538,"language":409,"meta":48,"style":48},"\u002F\u002F Go 中的用法（github.com\u002Flionsoul2014\u002Fip2region\u002Fbinding\u002Fgolang）\nregion, err := searcher.SearchByStr(ip)\n\u002F\u002F 返回类似 \"中国|福建省|福州市|移动\"\n",[50,9540,9541,9546,9562],{"__ignoreMap":48},[53,9542,9543],{"class":55,"line":56},[53,9544,9545],{"class":59},"\u002F\u002F Go 中的用法（github.com\u002Flionsoul2014\u002Fip2region\u002Fbinding\u002Fgolang）\n",[53,9547,9548,9551,9553,9556,9559],{"class":55,"line":63},[53,9549,9550],{"class":70},"region, err ",[53,9552,3674],{"class":66},[53,9554,9555],{"class":70}," searcher.",[53,9557,9558],{"class":87},"SearchByStr",[53,9560,9561],{"class":70},"(ip)\n",[53,9563,9564],{"class":55,"line":81},[53,9565,9566],{"class":59},"\u002F\u002F 返回类似 \"中国|福建省|福州市|移动\"\n",[139,9568,9569,9577],{},[142,9570,9571],{},[145,9572,9573,9575],{},[148,9574,2905],{},[148,9576,2908],{},[161,9578,9579,9587,9595],{},[145,9580,9581,9584],{},[166,9582,9583],{},"免费、无网络依赖、无 QPS 限制",[166,9585,9586],{},"段级粒度，个别段可能显示到相邻地区",[145,9588,9589,9592],{},[166,9590,9591],{},"毫秒级查询，永不因外部服务挂掉",[166,9593,9594],{},"数据按月更新，最新分配的段可能短暂缺失",[145,9596,9597,9600],{},[166,9598,9599],{},"数据文件可随版本更新，不重启",[166,9601,9602,9603],{},"只返回省市区+运营商，无经纬度；",[38,9604,9605],{},"仅支持 IPv4",[16,9607,9608,9611],{},[38,9609,9610],{},"适用","：博客\u002F后台的「登录记录显示归属地」这类展示用途。",[30,9613,9615],{"id":9614},"方案二在线-apiip-api-高德-腾讯位置服务","方案二：在线 API（ip-api \u002F 高德 \u002F 腾讯位置服务）",[43,9617,9619],{"className":3647,"code":9618,"language":409,"meta":48,"style":48},"\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",[50,9620,9621,9626,9654,9664,9695,9711,9724,9734,9738,9750],{"__ignoreMap":48},[53,9622,9623],{"class":55,"line":56},[53,9624,9625],{"class":59},"\u002F\u002F 以 ip-api.com 为例（免费版约 45 次\u002F分钟，非商用）\n",[53,9627,9628,9631,9633,9635,9638,9641,9644,9647,9649,9651],{"class":55,"line":63},[53,9629,9630],{"class":70},"ctx, cancel ",[53,9632,3674],{"class":66},[53,9634,5147],{"class":70},[53,9636,9637],{"class":87},"WithTimeout",[53,9639,9640],{"class":70},"(context.",[53,9642,9643],{"class":87},"Background",[53,9645,9646],{"class":70},"(), ",[53,9648,2172],{"class":77},[53,9650,3880],{"class":66},[53,9652,9653],{"class":70},"time.Second)\n",[53,9655,9656,9659,9662],{"class":55,"line":81},[53,9657,9658],{"class":66},"defer",[53,9660,9661],{"class":87}," cancel",[53,9663,2418],{"class":70},[53,9665,9666,9669,9671,9674,9677,9680,9683,9685,9688,9690,9693],{"class":55,"line":94},[53,9667,9668],{"class":70},"req, _ ",[53,9670,3674],{"class":66},[53,9672,9673],{"class":70}," http.",[53,9675,9676],{"class":87},"NewRequestWithContext",[53,9678,9679],{"class":70},"(ctx, ",[53,9681,9682],{"class":295},"\"GET\"",[53,9684,1034],{"class":70},[53,9686,9687],{"class":295},"\"https:\u002F\u002Fip-api.com\u002Fjson\u002F198.51.100.42\"",[53,9689,1034],{"class":70},[53,9691,9692],{"class":77},"nil",[53,9694,2533],{"class":70},[53,9696,9697,9700,9702,9705,9708],{"class":55,"line":106},[53,9698,9699],{"class":70},"resp, err ",[53,9701,3674],{"class":66},[53,9703,9704],{"class":70}," http.DefaultClient.",[53,9706,9707],{"class":87},"Do",[53,9709,9710],{"class":70},"(req)\n",[53,9712,9713,9716,9718,9720,9722],{"class":55,"line":367},[53,9714,9715],{"class":66},"if",[53,9717,4088],{"class":70},[53,9719,4091],{"class":66},[53,9721,4094],{"class":77},[53,9723,1863],{"class":70},[53,9725,9726,9728,9731],{"class":55,"line":384},[53,9727,3905],{"class":66},[53,9729,9730],{"class":295}," \"未知\"",[53,9732,9733],{"class":59}," \u002F\u002F 断网\u002F超时降级：展示「未知」而不是报错\n",[53,9735,9736],{"class":55,"line":400},[53,9737,109],{"class":70},[53,9739,9740,9742,9745,9748],{"class":55,"line":406},[53,9741,9658],{"class":66},[53,9743,9744],{"class":70}," resp.Body.",[53,9746,9747],{"class":87},"Close",[53,9749,2418],{"class":70},[53,9751,9752],{"class":55,"line":420},[53,9753,9754],{"class":59},"\u002F\u002F 解析 JSON，status != \"success\" 时同样降级\n",[139,9756,9757,9765],{},[142,9758,9759],{},[145,9760,9761,9763],{},[148,9762,2905],{},[148,9764,2908],{},[161,9766,9767,9775,9783],{},[145,9768,9769,9772],{},[166,9770,9771],{},"数据最新、可能更精确",[166,9773,9774],{},"有频率限制（免费版限 QPS）",[145,9776,9777,9780],{},[166,9778,9779],{},"部分服务还返回经纬度\u002F时区",[166,9781,9782],{},"依赖第三方，断网\u002F被墙\u002F限流时拿不到",[145,9784,9785,9788],{},[166,9786,9787],{},"无需维护数据文件",[166,9789,9790],{},"每次查询都要发一次 HTTP 请求",[16,9792,9793,9795,9796,9799],{},[38,9794,9610],{},"：对精度要求高、且能接受外部依赖与限流的场景。",[38,9797,9798],{},"务必加超时和降级","，否则第三方一抖，你自己的页面就卡住。",[30,9801,9803],{"id":9802},"方案三maxmind-geolite2离线-mmdb","方案三：MaxMind GeoLite2（离线 .mmdb）",[139,9805,9806,9814],{},[142,9807,9808],{},[145,9809,9810,9812],{},[148,9811,2905],{},[148,9813,2908],{},[161,9815,9816,9824,9836],{},[145,9817,9818,9821],{},[166,9819,9820],{},"全球数据，覆盖广、精度较好",[166,9822,9823],{},"免费但需注册账号并遵守 EULA；商用需 GeoIP2 商业授权",[145,9825,9826,9833],{},[166,9827,9828,9829,9832],{},"标准 ",[50,9830,9831],{},".mmdb"," 格式，各语言都有库，支持 IPv6",[166,9834,9835],{},"国内 IP 的省市粒度有时不如 ip2region 细",[145,9837,9838,9841],{},[166,9839,9840],{},"离线查询",[166,9842,9843],{},"数据同样要定期更新",[16,9845,9846,9848],{},[38,9847,9610],{},"：面向全球用户的场景。",[11,9850,9852],{"id":9851},"ipv6本地离线方案的一个盲点","IPv6：本地离线方案的一个盲点",[16,9854,9855,9856,9859,9860,9863],{},"前面聊的都是 IPv4（32 位）。但家宽和移动网络越来越多地",[38,9857,9858],{},"只下发 IPv6","（128 位）地址，而 ",[38,9861,9862],{},"ip2region 目前只支持 IPv4","——拿到一个纯 IPv6 地址是查不出归属地的，本地库会返回空结果。",[16,9865,9866],{},"实际影响与常见应对：",[521,9868,9869,9879,9885,9895],{},[524,9870,9871,9874,9875,9878],{},[38,9872,9873],{},"判断版本","：Go 里 ",[50,9876,9877],{},"net.ParseIP(ip).To4() != nil"," 就是 IPv4，先判断再查询；",[524,9880,9881,9884],{},[38,9882,9883],{},"降级展示","：IPv6 直接显示「IPv6 地址」，不显示属地，诚实第一；",[524,9886,9887,9890,9891,9894],{},[38,9888,9889],{},"优先取 IPv4","：有反向代理时，从 ",[50,9892,9893],{},"X-Forwarded-For"," 里挑一个 IPv4 来查；",[524,9896,9897,9900],{},[38,9898,9899],{},"需要 v6 归属","：换支持 IPv6 的数据源（如 MaxMind GeoLite2）。",[16,9902,9903],{},"这一点在接入时很容易漏掉，上线前记得拿一个纯 IPv6 地址回归一遍。",[11,9905,9907],{"id":9906},"实战给登录记录加-ip-属地","实战：给登录记录加 IP 属地",[16,9909,9910],{},"subLog 博客的登录记录就用了 ip2region。完整接入一共四步。",[30,9912,9914],{"id":9913},"_1-进程启动时加载数据文件","1. 进程启动时加载数据文件",[43,9916,9918],{"className":3647,"code":9917,"language":409,"meta":48,"style":48},"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",[50,9919,9920,9926,9935,9944,9948,9952,9957,9984,9988,10001,10023,10035,10042,10046,10062,10074,10080,10084,10095,10102],{"__ignoreMap":48},[53,9921,9922,9924],{"class":55,"line":56},[53,9923,3655],{"class":66},[53,9925,5053],{"class":70},[53,9927,9928,9930,9933],{"class":55,"line":63},[53,9929,6473],{"class":295},[53,9931,9932],{"class":87},"sync\u002Fatomic",[53,9934,516],{"class":295},[53,9936,9937,9939,9942],{"class":55,"line":81},[53,9938,6473],{"class":295},[53,9940,9941],{"class":87},"github.com\u002Flionsoul2014\u002Fip2region\u002Fbinding\u002Fgolang\u002Fxdb",[53,9943,516],{"class":295},[53,9945,9946],{"class":55,"line":94},[53,9947,2533],{"class":70},[53,9949,9950],{"class":55,"line":106},[53,9951,331],{"emptyLinePlaceholder":253},[53,9953,9954],{"class":55,"line":367},[53,9955,9956],{"class":59},"\u002F\u002F 用 atomic.Pointer 持有查询器，方便后面热更新\n",[53,9958,9959,9961,9964,9967,9969,9972,9974,9977,9979,9982],{"class":55,"line":384},[53,9960,6554],{"class":66},[53,9962,9963],{"class":70}," searcher ",[53,9965,9966],{"class":87},"atomic",[53,9968,2345],{"class":70},[53,9970,9971],{"class":87},"Pointer",[53,9973,3033],{"class":70},[53,9975,9976],{"class":87},"xdb",[53,9978,2345],{"class":70},[53,9980,9981],{"class":87},"Searcher",[53,9983,737],{"class":70},[53,9985,9986],{"class":55,"line":400},[53,9987,331],{"emptyLinePlaceholder":253},[53,9989,9990,9992,9995,9997,9999],{"class":55,"line":406},[53,9991,3848],{"class":66},[53,9993,9994],{"class":87}," initSearcher",[53,9996,5096],{"class":70},[53,9998,3893],{"class":66},[53,10000,1863],{"class":70},[53,10002,10003,10006,10008,10011,10013,10015,10018,10020],{"class":55,"line":420},[53,10004,10005],{"class":70},"    content, err ",[53,10007,3674],{"class":66},[53,10009,10010],{"class":70}," os.",[53,10012,6693],{"class":87},[53,10014,2223],{"class":70},[53,10016,10017],{"class":295},"\".\u002Fdata\u002Fip2region.xdb\"",[53,10019,2687],{"class":70},[53,10021,10022],{"class":59},"\u002F\u002F 约 11MB，一次性读入内存\n",[53,10024,10025,10027,10029,10031,10033],{"class":55,"line":443},[53,10026,2300],{"class":66},[53,10028,4088],{"class":70},[53,10030,4091],{"class":66},[53,10032,4094],{"class":77},[53,10034,1863],{"class":70},[53,10036,10037,10039],{"class":55,"line":452},[53,10038,4101],{"class":66},[53,10040,10041],{"class":70}," err\n",[53,10043,10044],{"class":55,"line":457},[53,10045,2183],{"class":70},[53,10047,10048,10051,10053,10056,10059],{"class":55,"line":463},[53,10049,10050],{"class":70},"    s, err ",[53,10052,3674],{"class":66},[53,10054,10055],{"class":70}," xdb.",[53,10057,10058],{"class":87},"NewWithBuffer",[53,10060,10061],{"class":70},"(content)\n",[53,10063,10064,10066,10068,10070,10072],{"class":55,"line":474},[53,10065,2300],{"class":66},[53,10067,4088],{"class":70},[53,10069,4091],{"class":66},[53,10071,4094],{"class":77},[53,10073,1863],{"class":70},[53,10075,10076,10078],{"class":55,"line":485},[53,10077,4101],{"class":66},[53,10079,10041],{"class":70},[53,10081,10082],{"class":55,"line":490},[53,10083,2183],{"class":70},[53,10085,10086,10089,10092],{"class":55,"line":499},[53,10087,10088],{"class":70},"    searcher.",[53,10090,10091],{"class":87},"Store",[53,10093,10094],{"class":70},"(s)\n",[53,10096,10097,10099],{"class":55,"line":504},[53,10098,3905],{"class":66},[53,10100,10101],{"class":77}," nil\n",[53,10103,10104],{"class":55,"line":712},[53,10105,109],{"class":70},[30,10107,10109],{"id":10108},"_2-查询一次","2. 查询一次",[43,10111,10113],{"className":3647,"code":10112,"language":409,"meta":48,"style":48},"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",[50,10114,10115,10137,10156,10168,10175,10179,10189],{"__ignoreMap":48},[53,10116,10117,10119,10122,10124,10127,10130,10132,10135],{"class":55,"line":56},[53,10118,3848],{"class":66},[53,10120,10121],{"class":87}," ipRegion",[53,10123,2223],{"class":70},[53,10125,10126],{"class":2226},"ip",[53,10128,10129],{"class":66}," string",[53,10131,2687],{"class":70},[53,10133,10134],{"class":66},"string",[53,10136,1863],{"class":70},[53,10138,10139,10142,10144,10146,10149,10152,10154],{"class":55,"line":63},[53,10140,10141],{"class":70},"    region, err ",[53,10143,3674],{"class":66},[53,10145,9555],{"class":70},[53,10147,10148],{"class":87},"Load",[53,10150,10151],{"class":70},"().",[53,10153,9558],{"class":87},[53,10155,9561],{"class":70},[53,10157,10158,10160,10162,10164,10166],{"class":55,"line":81},[53,10159,2300],{"class":66},[53,10161,4088],{"class":70},[53,10163,4091],{"class":66},[53,10165,4094],{"class":77},[53,10167,1863],{"class":70},[53,10169,10170,10172],{"class":55,"line":94},[53,10171,4101],{"class":66},[53,10173,10174],{"class":295}," \"未知\"\n",[53,10176,10177],{"class":55,"line":106},[53,10178,2183],{"class":70},[53,10180,10181,10183,10186],{"class":55,"line":367},[53,10182,3905],{"class":66},[53,10184,10185],{"class":70}," region ",[53,10187,10188],{"class":59},"\u002F\u002F \"中国|福建省|福州市|移动\"\n",[53,10190,10191],{"class":55,"line":384},[53,10192,109],{"class":70},[30,10194,10196],{"id":10195},"_3-加一层内存缓存","3. 加一层内存缓存",[16,10198,10199,10200,10203],{},"登录记录里同一个 IP 可能反复出现，用 ",[50,10201,10202],{},"sync.Map"," 按 IP 缓存结果，一次查询终身复用：",[43,10205,10207],{"className":3647,"code":10206,"language":409,"meta":48,"style":48},"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",[50,10208,10209,10224,10228,10247,10264,10275,10279,10290,10300,10307],{"__ignoreMap":48},[53,10210,10211,10213,10216,10219,10221],{"class":55,"line":56},[53,10212,6554],{"class":66},[53,10214,10215],{"class":70}," regionCache ",[53,10217,10218],{"class":87},"sync",[53,10220,2345],{"class":70},[53,10222,10223],{"class":87},"Map\n",[53,10225,10226],{"class":55,"line":63},[53,10227,331],{"emptyLinePlaceholder":253},[53,10229,10230,10232,10235,10237,10239,10241,10243,10245],{"class":55,"line":81},[53,10231,3848],{"class":66},[53,10233,10234],{"class":87}," ipRegionCached",[53,10236,2223],{"class":70},[53,10238,10126],{"class":2226},[53,10240,10129],{"class":66},[53,10242,2687],{"class":70},[53,10244,10134],{"class":66},[53,10246,1863],{"class":70},[53,10248,10249,10251,10254,10256,10259,10261],{"class":55,"line":94},[53,10250,2300],{"class":66},[53,10252,10253],{"class":70}," v, ok ",[53,10255,3674],{"class":66},[53,10257,10258],{"class":70}," regionCache.",[53,10260,10148],{"class":87},[53,10262,10263],{"class":70},"(ip); ok {\n",[53,10265,10266,10268,10271,10273],{"class":55,"line":106},[53,10267,4101],{"class":66},[53,10269,10270],{"class":70}," v.(",[53,10272,10134],{"class":66},[53,10274,2533],{"class":70},[53,10276,10277],{"class":55,"line":367},[53,10278,2183],{"class":70},[53,10280,10281,10284,10286,10288],{"class":55,"line":384},[53,10282,10283],{"class":70},"    region ",[53,10285,3674],{"class":66},[53,10287,10121],{"class":87},[53,10289,9561],{"class":70},[53,10291,10292,10295,10297],{"class":55,"line":400},[53,10293,10294],{"class":70},"    regionCache.",[53,10296,10091],{"class":87},[53,10298,10299],{"class":70},"(ip, region)\n",[53,10301,10302,10304],{"class":55,"line":406},[53,10303,3905],{"class":66},[53,10305,10306],{"class":70}," region\n",[53,10308,10309],{"class":55,"line":420},[53,10310,109],{"class":70},[30,10312,10314],{"id":10313},"_4-数据热更新不用重启","4. 数据热更新（不用重启）",[16,10316,10317,10318,10320,10321,10323],{},"ip2region 每月发布新数据。下载新文件后重新 ",[50,10319,10058],{}," 并 ",[50,10322,10091],{}," 覆盖即可，进程完全不用重启：",[43,10325,10327],{"className":3647,"code":10326,"language":409,"meta":48,"style":48},"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",[50,10328,10329,10353,10366,10378,10384,10388,10400,10406],{"__ignoreMap":48},[53,10330,10331,10333,10336,10338,10341,10344,10347,10349,10351],{"class":55,"line":56},[53,10332,3848],{"class":66},[53,10334,10335],{"class":87}," reloadSearcher",[53,10337,2223],{"class":70},[53,10339,10340],{"class":2226},"newContent",[53,10342,10343],{"class":70}," []",[53,10345,10346],{"class":66},"byte",[53,10348,2687],{"class":70},[53,10350,3893],{"class":66},[53,10352,1863],{"class":70},[53,10354,10355,10357,10359,10361,10363],{"class":55,"line":63},[53,10356,10050],{"class":70},[53,10358,3674],{"class":66},[53,10360,10055],{"class":70},[53,10362,10058],{"class":87},[53,10364,10365],{"class":70},"(newContent)\n",[53,10367,10368,10370,10372,10374,10376],{"class":55,"line":81},[53,10369,2300],{"class":66},[53,10371,4088],{"class":70},[53,10373,4091],{"class":66},[53,10375,4094],{"class":77},[53,10377,1863],{"class":70},[53,10379,10380,10382],{"class":55,"line":94},[53,10381,4101],{"class":66},[53,10383,10041],{"class":70},[53,10385,10386],{"class":55,"line":106},[53,10387,2183],{"class":70},[53,10389,10390,10392,10394,10397],{"class":55,"line":367},[53,10391,10088],{"class":70},[53,10393,10091],{"class":87},[53,10395,10396],{"class":70},"(s) ",[53,10398,10399],{"class":59},"\u002F\u002F 原子切换，老查询继续走旧数据，新查询走新数据\n",[53,10401,10402,10404],{"class":55,"line":384},[53,10403,3905],{"class":66},[53,10405,10101],{"class":77},[53,10407,10408],{"class":55,"line":400},[53,10409,109],{"class":70},[11,10411,10412],{"id":10412},"局限与诚实提醒",[16,10414,10415],{},"无论用哪种方案，都要清楚 IP 属地的边界：",[1770,10417,10418,10424,10430,10436,10442],{},[524,10419,10420,10423],{},[38,10421,10422],{},"段级精度","：网段归属是「绝大多数」准确，个别 IP（大型企业自建段、跨省调度段）可能显示到相邻地区或只到省。",[524,10425,10426,10429],{},[38,10427,10428],{},"运营商级，不是定位","：拿到的是「省｜市｜运营商」，不是精确街道或基站位置——精确位置需要 GPS 或运营商三角定位，纯 IP 归属做不到。",[524,10431,10432,10435],{},[38,10433,10434],{},"动态 IP","：家宽通常是动态 IP，重拨会变，所以「上次登录在福州，这次在广州」不一定是异常，可能是 IP 变了。",[524,10437,10438,10441],{},[38,10439,10440],{},"别用于安全判定","：用 IP 段做封禁或风控判定风险自负，段级数据不适合当精确依据。",[524,10443,10444,10447,10448,10451],{},[38,10445,10446],{},"隐私与合规","：IP 地址属于个人信息（《个人信息保护法》）。归属地展示建议只出现在",[38,10449,10450],{},"用户本人可见","的页面（登录记录、个人中心）；若在评论区等公开场合展示，建议只显示到省市并明确告知用户。",[11,10453,10454],{"id":10454},"小结",[16,10456,10457],{},"一句话总结：",[9379,10459,10460],{},[16,10461,10462,10465],{},[38,10463,10464],{},"IP 属地 = 几十万条「IP 段 → 归属地」映射 + 一次二分查找。"," 它查的是网段归属，不是精确位置。",[1770,10467,10468,10474,10481],{},[524,10469,10470,10471,10473],{},"想省心、免费、稳定，用 ",[38,10472,9443],{}," 本地离线查询（注意 IPv6 盲点）；",[524,10475,10476,10477,10480],{},"想要最新最细、能接受外部依赖，用",[38,10478,10479],{},"在线 API","（记得超时降级）；",[524,10482,10483,10484,2114],{},"面向全球用户、要 IPv6 覆盖，用 ",[38,10485,10486],{},"MaxMind GeoLite2",[16,10488,10489],{},"对个人博客「登录记录显示归属地」这种场景，本地离线方案通常是性价比最高的选择。",[235,10491,10492],{},"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":48,"searchDepth":63,"depth":81,"links":10494},[10495,10496,10497,10503,10508,10509,10515,10516],{"id":8798,"depth":63,"text":8798},{"id":9390,"depth":63,"text":9391},{"id":9437,"depth":63,"text":9437,"children":10498},[10499,10500,10501,10502],{"id":9447,"depth":81,"text":9448},{"id":9462,"depth":81,"text":9463},{"id":9480,"depth":81,"text":9481},{"id":9499,"depth":81,"text":9500},{"id":9528,"depth":63,"text":9528,"children":10504},[10505,10506,10507],{"id":9534,"depth":81,"text":9535},{"id":9614,"depth":81,"text":9615},{"id":9802,"depth":81,"text":9803},{"id":9851,"depth":63,"text":9852},{"id":9906,"depth":63,"text":9907,"children":10510},[10511,10512,10513,10514],{"id":9913,"depth":81,"text":9914},{"id":10108,"depth":81,"text":10109},{"id":10195,"depth":81,"text":10196},{"id":10313,"depth":81,"text":10314},{"id":10412,"depth":63,"text":10412},{"id":10454,"depth":63,"text":10454},"科普 IP 属地查询的原理：IP 段与归属地的映射、纯本地离线查询为何能做到毫秒级，对比 ip2region、在线 API 与 MaxMind 三种方案的优劣，并附登录记录场景的 Go 实战与 IPv6 盲点提醒。","\u002Fposts\u002Fip-geolocation-guide\u002Fimg\u002Fcover.svg",{},"\u002Fposts\u002Fip-geolocation-guide","2026-08-07T12:00:00",{"title":9353,"description":10517},"posts\u002Fip-geolocation-guide\u002Findex",[10525,10526,10527],"IP","网络","科普","2026-08-16","RY1UPgJaiKsjpC51uqMyG7w_GpCRS13_CX5j1hDD_dM",{"id":10531,"title":10532,"author":6,"body":10533,"description":12055,"difficulty":1074,"draft":249,"extension":250,"image":12056,"meta":12057,"navigation":253,"path":12058,"pinned":249,"published":12059,"seo":12060,"series":5612,"seriesOrder":81,"stem":12061,"tags":12062,"updated":10528,"__hash__":12065},"posts\u002Fposts\u002Fgo-embed-react-single-binary\u002Findex.md","Go 单文件打包进阶：React 前端 + 交叉编译，一次踩完所有坑",{"type":8,"value":10534,"toc":12034},[10535,10537,10548,10559,10566,10569,10605,10607,10616,10618,10622,10632,10676,10718,10736,10740,10761,10792,10796,11088,11091,11129,11133,11476,11479,11483,11680,11690,11692,11703,11717,11730,11741,11745,11752,11768,11781,11786,11790,11806,11811,11856,11870,11881,11885,11900,11909,11931,11940,11944,11954,11956,11983,11992,11994,11997,12024,12031],[11,10536,2090],{"id":2090},[16,10538,10539,10540,10543,10544,10547],{},"这是「Go 单文件打包」系列第三篇。上一篇（",[2043,10541,10542],{"href":7372},"把前端塞进二进制","）记录了一个 Gin + Vue 3 项目的 embed 方案。这次换了个项目继续踩坑：",[38,10545,10546],{},"Magic Img","——一个自托管、本地优先的 AI 生图提示词管理工具，技术栈是：",[1770,10549,10550,10553],{},[524,10551,10552],{},"前端：React 19 + TypeScript + Vite + Tailwind v4（UI 库 Magic UI + shadcn\u002Fui）",[524,10554,10555,10556,6156],{},"后端：Go + Gin + GORM + SQLite（",[38,10557,10558],{},"modernc 纯 Go 驱动，无 cgo",[16,10560,10561,10562,10565],{},"目标一样：",[38,10563,10564],{},"一个二进制拖到任何机器上就能跑","——前端、API、数据库全部内嵌。",[16,10567,10568],{},"和上篇不同的地方有三个，正好是这篇要讲的重点：",[521,10570,10571,10585,10598],{},[524,10572,10573,10574,10580,10581,10584],{},"前端用了 ",[38,10575,10576,10577],{},"HashRouter + Vite base ",[50,10578,10579],{},"\u002Fdist","，服务端",[38,10582,10583],{},"不再需要 SPA fallback","（上篇坑一的新解法）；",[524,10586,10587,10588,10594,10595,10597],{},"embed 用 ",[38,10589,10590,10593],{},[50,10591,10592],{},"all:"," 前缀","一劳永逸解决 ",[50,10596,7066],{}," 开头文件被排除的问题（上篇坑二的升级解法）；",[524,10599,10600,10601,10604],{},"需要",[38,10602,10603],{},"在 Windows 上交叉编译 Linux 产物","，踩了「生成的文件无法在 Linux 执行」的坑。",[11,10606,6422],{"id":6422},[16,10608,10609,10610,10612,10613,10615],{},"和上篇一致：",[50,10611,6428],{}," 在编译期把前端 ",[50,10614,6432],{}," 塞进二进制，运行时由 Go 直接提供静态服务。差异在于路由策略和缓存策略，后面细说。",[11,10617,6436],{"id":6436},[30,10619,10621],{"id":10620},"_1-前端hash-路由-base-dist","1. 前端：hash 路由 + base \u002Fdist",[16,10623,10624,10625,10628,10629,3366],{},"vite.config.ts 里固定 ",[50,10626,10627],{},"base: \"\u002Fdist\u002F\"","，路由用 ",[50,10630,10631],{},"HashRouter",[43,10633,10635],{"className":7117,"code":10634,"language":7119,"meta":48,"style":48},"\u002F\u002F vite.config.ts\nexport default defineConfig({\n  base: \"\u002Fdist\u002F\",   \u002F\u002F 所有资源路径以 \u002Fdist\u002F 开头\n  ...\n})\n",[50,10636,10637,10641,10653,10667,10672],{"__ignoreMap":48},[53,10638,10639],{"class":55,"line":56},[53,10640,7126],{"class":59},[53,10642,10643,10645,10647,10650],{"class":55,"line":63},[53,10644,370],{"class":66},[53,10646,2966],{"class":66},[53,10648,10649],{"class":87}," defineConfig",[53,10651,10652],{"class":70},"({\n",[53,10654,10655,10658,10661,10664],{"class":55,"line":81},[53,10656,10657],{"class":70},"  base: ",[53,10659,10660],{"class":295},"\"\u002Fdist\u002F\"",[53,10662,10663],{"class":70},",   ",[53,10665,10666],{"class":59},"\u002F\u002F 所有资源路径以 \u002Fdist\u002F 开头\n",[53,10668,10669],{"class":55,"line":94},[53,10670,10671],{"class":66},"  ...\n",[53,10673,10674],{"class":55,"line":106},[53,10675,2771],{"class":70},[43,10677,10681],{"className":10678,"code":10679,"language":10680,"meta":48,"style":48},"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",[50,10682,10683,10688,10696,10710],{"__ignoreMap":48},[53,10684,10685],{"class":55,"line":56},[53,10686,10687],{"class":59},"\u002F\u002F App.tsx —— HashRouter：路径信息全在 location.hash 里\n",[53,10689,10690,10692,10694],{"class":55,"line":63},[53,10691,1214],{"class":70},[53,10693,10631],{"class":77},[53,10695,871],{"class":70},[53,10697,10698,10700,10703,10706,10708],{"class":55,"line":81},[53,10699,2798],{"class":70},[53,10701,10702],{"class":77},"Routes",[53,10704,10705],{"class":70},">...\u003C\u002F",[53,10707,10702],{"class":77},[53,10709,871],{"class":70},[53,10711,10712,10714,10716],{"class":55,"line":94},[53,10713,2776],{"class":70},[53,10715,10631],{"class":77},[53,10717,871],{"class":70},[16,10719,10720,10723,10724,10727,10728,10731,10732,10735],{},[38,10721,10722],{},"为什么这样选","：history 路由需要服务端把未知路径全部 fallback 回 ",[50,10725,10726],{},"index.html","（上篇坑一）。hash 路由下浏览器请求的永远是 ",[50,10729,10730],{},"\u002Fdist\u002F"," 这一个入口，",[38,10733,10734],{},"静态服务直接返回 index.html 就完事，一个 fallback 都不用写","。内嵌场景里这是最省心的组合。",[30,10737,10739],{"id":10738},"_2-构建产物复制进后端目录","2. 构建产物复制进后端目录",[16,10741,10742,10745,10746,10748,10749,10752,10753,10756,10757,10760],{},[50,10743,10744],{},"npm run build"," 除了编译，还会把 ",[50,10747,6432],{}," 复制到 ",[50,10750,10751],{},"backend\u002Fweb\u002Fdist","（一个小脚本 ",[50,10754,10755],{},"scripts\u002Fcopy-dist.mjs"," 干这个活），供 ",[50,10758,10759],{},"go:embed"," 使用：",[43,10762,10764],{"className":5439,"code":10763,"language":5441,"meta":48,"style":48},"\u002F\u002F frontend\u002Fpackage.json\n\"scripts\": {\n  \"build\": \"tsc -b && vite build && node scripts\u002Fcopy-dist.mjs\"\n}\n",[50,10765,10766,10771,10778,10788],{"__ignoreMap":48},[53,10767,10768],{"class":55,"line":56},[53,10769,10770],{"class":59},"\u002F\u002F frontend\u002Fpackage.json\n",[53,10772,10773,10776],{"class":55,"line":63},[53,10774,10775],{"class":295},"\"scripts\"",[53,10777,2142],{"class":70},[53,10779,10780,10783,10785],{"class":55,"line":81},[53,10781,10782],{"class":77},"  \"build\"",[53,10784,1927],{"class":70},[53,10786,10787],{"class":295},"\"tsc -b && vite build && node scripts\u002Fcopy-dist.mjs\"\n",[53,10789,10790],{"class":55,"line":94},[53,10791,109],{"class":70},[30,10793,10795],{"id":10794},"_3-后端embed-httpfilesystem-包装","3. 后端：embed + http.FileSystem 包装",[43,10797,10799],{"className":3647,"code":10798,"language":409,"meta":48,"style":48},"\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",[50,10800,10801,10806,10812,10816,10822,10830,10838,10846,10850,10854,10859,10872,10876,10881,10918,10922,10934,10942,10955,10959,10963,11002,11016,11026,11036,11063,11067,11084],{"__ignoreMap":48},[53,10802,10803],{"class":55,"line":56},[53,10804,10805],{"class":59},"\u002F\u002F backend\u002Fweb\u002Fembed.go\n",[53,10807,10808,10810],{"class":55,"line":63},[53,10809,6455],{"class":66},[53,10811,6458],{"class":87},[53,10813,10814],{"class":55,"line":81},[53,10815,331],{"emptyLinePlaceholder":253},[53,10817,10818,10820],{"class":55,"line":94},[53,10819,3655],{"class":66},[53,10821,5053],{"class":70},[53,10823,10824,10826,10828],{"class":55,"line":106},[53,10825,6473],{"class":295},[53,10827,6476],{"class":87},[53,10829,516],{"class":295},[53,10831,10832,10834,10836],{"class":55,"line":367},[53,10833,6473],{"class":295},[53,10835,6503],{"class":87},[53,10837,516],{"class":295},[53,10839,10840,10842,10844],{"class":55,"line":384},[53,10841,6473],{"class":295},[53,10843,6521],{"class":87},[53,10845,516],{"class":295},[53,10847,10848],{"class":55,"line":400},[53,10849,2533],{"class":70},[53,10851,10852],{"class":55,"line":406},[53,10853,331],{"emptyLinePlaceholder":253},[53,10855,10856],{"class":55,"line":420},[53,10857,10858],{"class":59},"\u002F\u002Fgo:embed all:dist\n",[53,10860,10861,10863,10866,10868,10870],{"class":55,"line":443},[53,10862,6554],{"class":66},[53,10864,10865],{"class":70}," distFS ",[53,10867,6476],{"class":87},[53,10869,2345],{"class":70},[53,10871,6564],{"class":87},[53,10873,10874],{"class":55,"line":452},[53,10875,331],{"emptyLinePlaceholder":253},[53,10877,10878],{"class":55,"line":457},[53,10879,10880],{"class":59},"\u002F\u002F Dist 内嵌前端静态文件系统\n",[53,10882,10883,10885,10888,10891,10893,10896,10898,10901,10904,10907,10909,10912,10915],{"class":55,"line":463},[53,10884,6554],{"class":66},[53,10886,10887],{"class":70}," Dist ",[53,10889,10890],{"class":87},"http",[53,10892,2345],{"class":70},[53,10894,10895],{"class":87},"FileSystem",[53,10897,1860],{"class":66},[53,10899,10900],{"class":66}," &",[53,10902,10903],{"class":87},"fileSystem",[53,10905,10906],{"class":70},"{prefix: ",[53,10908,6604],{"class":295},[53,10910,10911],{"class":70},", fs: ",[53,10913,10914],{"class":66},"&",[53,10916,10917],{"class":70},"distFS}\n",[53,10919,10920],{"class":55,"line":474},[53,10921,331],{"emptyLinePlaceholder":253},[53,10923,10924,10926,10929,10932],{"class":55,"line":485},[53,10925,5040],{"class":66},[53,10927,10928],{"class":87}," fileSystem",[53,10930,10931],{"class":66}," struct",[53,10933,1863],{"class":70},[53,10935,10936,10939],{"class":55,"line":490},[53,10937,10938],{"class":70},"    prefix ",[53,10940,10941],{"class":66},"string\n",[53,10943,10944,10947,10949,10951,10953],{"class":55,"line":499},[53,10945,10946],{"class":70},"    fs     ",[53,10948,3880],{"class":66},[53,10950,6476],{"class":87},[53,10952,2345],{"class":70},[53,10954,6564],{"class":87},[53,10956,10957],{"class":55,"line":504},[53,10958,109],{"class":70},[53,10960,10961],{"class":55,"line":712},[53,10962,331],{"emptyLinePlaceholder":253},[53,10964,10965,10967,10969,10972,10974,10976,10978,10981,10983,10985,10987,10989,10991,10993,10996,10998,11000],{"class":55,"line":720},[53,10966,3848],{"class":66},[53,10968,2265],{"class":70},[53,10970,10971],{"class":2226},"f ",[53,10973,3880],{"class":66},[53,10975,10903],{"class":87},[53,10977,2687],{"class":70},[53,10979,10980],{"class":87},"Open",[53,10982,2223],{"class":70},[53,10984,7170],{"class":2226},[53,10986,10129],{"class":66},[53,10988,3877],{"class":70},[53,10990,10890],{"class":87},[53,10992,2345],{"class":70},[53,10994,10995],{"class":87},"File",[53,10997,1034],{"class":70},[53,10999,3893],{"class":66},[53,11001,3475],{"class":70},[53,11003,11004,11006,11009,11011,11014],{"class":55,"line":725},[53,11005,2300],{"class":66},[53,11007,11008],{"class":70}," name ",[53,11010,4748],{"class":66},[53,11012,11013],{"class":295}," \"\u002F\"",[53,11015,1863],{"class":70},[53,11017,11018,11021,11023],{"class":55,"line":1358},[53,11019,11020],{"class":70},"        name ",[53,11022,74],{"class":66},[53,11024,11025],{"class":70}," f.prefix\n",[53,11027,11028,11031,11034],{"class":55,"line":1372},[53,11029,11030],{"class":70},"    } ",[53,11032,11033],{"class":66},"else",[53,11035,1863],{"class":70},[53,11037,11038,11040,11042,11045,11047,11049,11052,11054,11056,11059,11061],{"class":55,"line":1379},[53,11039,11020],{"class":70},[53,11041,74],{"class":66},[53,11043,11044],{"class":70}," f.prefix ",[53,11046,3041],{"class":66},[53,11048,11013],{"class":295},[53,11050,11051],{"class":66}," +",[53,11053,4810],{"class":70},[53,11055,6642],{"class":87},[53,11057,11058],{"class":70},"(name, ",[53,11060,6648],{"class":295},[53,11062,2533],{"class":70},[53,11064,11065],{"class":55,"line":1384},[53,11066,2183],{"class":70},[53,11068,11069,11071,11073,11076,11079,11081],{"class":55,"line":1390},[53,11070,3905],{"class":66},[53,11072,9673],{"class":70},[53,11074,11075],{"class":87},"FS",[53,11077,11078],{"class":70},"(f.fs).",[53,11080,10980],{"class":87},[53,11082,11083],{"class":70},"(name)\n",[53,11085,11086],{"class":55,"line":1398},[53,11087,109],{"class":70},[16,11089,11090],{},"两个细节：",[1770,11092,11093,11116],{},[524,11094,11095,11103,11104,7083,11106,11108,11109,11111,11112,11115],{},[38,11096,11097,11100,11101,10593],{},[50,11098,11099],{},"all:dist"," 里的 ",[50,11102,10592],{},"：embed 目录时默认会排除 ",[50,11105,7066],{},[50,11107,2345],{}," 开头的文件（上篇坑二就是被这个坑了，当时靠改 Vite 输出名绕开）。",[50,11110,10592],{}," 前缀表示「全部包含」，前端构建产物里那些 ",[50,11113,11114],{},"_xxx.js"," 助手文件直接安全落地，不用再改前端配置；",[524,11117,11118,11121,11122,11124,11125,11128],{},[50,11119,11120],{},"http.FileSystem"," 包装：加了一层 ",[50,11123,6432],{}," 前缀映射，",[50,11126,11127],{},"http.FileServer"," 和 Gin 都能直接用。",[30,11130,11132],{"id":11131},"_4-路由静态服务-缓存策略","4. 路由：静态服务 + 缓存策略",[43,11134,11136],{"className":3647,"code":11135,"language":409,"meta":48,"style":48},"\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",[50,11137,11138,11143,11185,11197,11202,11217,11262,11291,11309,11330,11335,11354,11377,11382,11399,11403,11424,11428,11468,11472],{"__ignoreMap":48},[53,11139,11140],{"class":55,"line":56},[53,11141,11142],{"class":59},"\u002F\u002F backend\u002Finternal\u002Fhandler\u002Frouter.go\n",[53,11144,11145,11147,11150,11152,11155,11157,11160,11162,11165,11167,11170,11172,11174,11176,11178,11180,11183],{"class":55,"line":63},[53,11146,3848],{"class":66},[53,11148,11149],{"class":87}," NewRouter",[53,11151,2223],{"class":70},[53,11153,11154],{"class":2226},"db",[53,11156,4473],{"class":66},[53,11158,11159],{"class":87},"gorm",[53,11161,2345],{"class":70},[53,11163,11164],{"class":87},"DB",[53,11166,1034],{"class":70},[53,11168,11169],{"class":2226},"uploadDir",[53,11171,10129],{"class":66},[53,11173,2687],{"class":70},[53,11175,3880],{"class":66},[53,11177,4476],{"class":87},[53,11179,2345],{"class":70},[53,11181,11182],{"class":87},"Engine",[53,11184,1863],{"class":70},[53,11186,11187,11189,11191,11193,11195],{"class":55,"line":81},[53,11188,4289],{"class":70},[53,11190,3674],{"class":66},[53,11192,4294],{"class":70},[53,11194,4297],{"class":87},[53,11196,2418],{"class":70},[53,11198,11199],{"class":55,"line":94},[53,11200,11201],{"class":66},"    ...\n",[53,11203,11204,11207,11209,11211,11214],{"class":55,"line":106},[53,11205,11206],{"class":70},"    dist ",[53,11208,3674],{"class":66},[53,11210,9673],{"class":70},[53,11212,11213],{"class":87},"FileServer",[53,11215,11216],{"class":70},"(web.Dist)\n",[53,11218,11219,11221,11223,11225,11228,11230,11232,11234,11236,11238,11240,11242,11244,11247,11250,11252,11255,11257,11259],{"class":55,"line":367},[53,11220,4427],{"class":70},[53,11222,4456],{"class":87},[53,11224,2223],{"class":70},[53,11226,11227],{"class":295},"\"\u002Fdist\"",[53,11229,1034],{"class":70},[53,11231,3848],{"class":66},[53,11233,2223],{"class":70},[53,11235,4470],{"class":2226},[53,11237,4473],{"class":66},[53,11239,4476],{"class":87},[53,11241,2345],{"class":70},[53,11243,3861],{"class":87},[53,11245,11246],{"class":70},") { c.",[53,11248,11249],{"class":87},"Redirect",[53,11251,2223],{"class":70},[53,11253,11254],{"class":77},"302",[53,11256,1034],{"class":70},[53,11258,10660],{"class":295},[53,11260,11261],{"class":70},") })\n",[53,11263,11264,11266,11268,11270,11273,11275,11277,11279,11281,11283,11285,11287,11289],{"class":55,"line":384},[53,11265,4427],{"class":70},[53,11267,4456],{"class":87},[53,11269,2223],{"class":70},[53,11271,11272],{"class":295},"\"\u002Fdist\u002F*filepath\"",[53,11274,1034],{"class":70},[53,11276,3848],{"class":66},[53,11278,2223],{"class":70},[53,11280,4470],{"class":2226},[53,11282,4473],{"class":66},[53,11284,4476],{"class":87},[53,11286,2345],{"class":70},[53,11288,3861],{"class":87},[53,11290,3475],{"class":70},[53,11292,11293,11295,11297,11299,11302,11304,11307],{"class":55,"line":400},[53,11294,6635],{"class":70},[53,11296,3674],{"class":66},[53,11298,4727],{"class":70},[53,11300,11301],{"class":87},"Param",[53,11303,2223],{"class":70},[53,11305,11306],{"class":295},"\"filepath\"",[53,11308,2533],{"class":70},[53,11310,11311,11313,11315,11317,11319,11321,11323,11325,11328],{"class":55,"line":406},[53,11312,4742],{"class":66},[53,11314,6657],{"class":70},[53,11316,4748],{"class":66},[53,11318,11013],{"class":295},[53,11320,4843],{"class":66},[53,11322,6657],{"class":70},[53,11324,4748],{"class":66},[53,11326,11327],{"class":295}," \"\u002Findex.html\"",[53,11329,1863],{"class":70},[53,11331,11332],{"class":55,"line":420},[53,11333,11334],{"class":59},"            \u002F\u002F index.html 禁缓存：升级后浏览器立刻拿到新入口\n",[53,11336,11337,11339,11342,11344,11347,11349,11352],{"class":55,"line":443},[53,11338,4758],{"class":70},[53,11340,11341],{"class":87},"Header",[53,11343,2223],{"class":70},[53,11345,11346],{"class":295},"\"Cache-Control\"",[53,11348,1034],{"class":70},[53,11350,11351],{"class":295},"\"no-cache, no-store, must-revalidate\"",[53,11353,2533],{"class":70},[53,11355,11356,11359,11361,11364,11366,11369,11372,11375],{"class":55,"line":452},[53,11357,11358],{"class":70},"        } ",[53,11360,11033],{"class":66},[53,11362,11363],{"class":66}," if",[53,11365,4810],{"class":70},[53,11367,11368],{"class":87},"HasPrefix",[53,11370,11371],{"class":70},"(p, ",[53,11373,11374],{"class":295},"\"\u002Fassets\u002F\"",[53,11376,3475],{"class":70},[53,11378,11379],{"class":55,"line":457},[53,11380,11381],{"class":59},"            \u002F\u002F 带内容 hash 的资源可放心长缓存\n",[53,11383,11384,11386,11388,11390,11392,11394,11397],{"class":55,"line":463},[53,11385,4758],{"class":70},[53,11387,11341],{"class":87},[53,11389,2223],{"class":70},[53,11391,11346],{"class":295},[53,11393,1034],{"class":70},[53,11395,11396],{"class":295},"\"public, max-age=31536000, immutable\"",[53,11398,2533],{"class":70},[53,11400,11401],{"class":55,"line":474},[53,11402,4796],{"class":70},[53,11404,11405,11408,11411,11413,11415,11418,11421],{"class":55,"line":485},[53,11406,11407],{"class":70},"        http.",[53,11409,11410],{"class":87},"StripPrefix",[53,11412,2223],{"class":70},[53,11414,11227],{"class":295},[53,11416,11417],{"class":70},", dist).",[53,11419,11420],{"class":87},"ServeHTTP",[53,11422,11423],{"class":70},"(c.Writer, c.Request)\n",[53,11425,11426],{"class":55,"line":490},[53,11427,4522],{"class":70},[53,11429,11430,11432,11434,11436,11438,11440,11442,11444,11446,11448,11450,11452,11454,11456,11458,11460,11462,11464,11466],{"class":55,"line":499},[53,11431,4427],{"class":70},[53,11433,4456],{"class":87},[53,11435,2223],{"class":70},[53,11437,6648],{"class":295},[53,11439,1034],{"class":70},[53,11441,3848],{"class":66},[53,11443,2223],{"class":70},[53,11445,4470],{"class":2226},[53,11447,4473],{"class":66},[53,11449,4476],{"class":87},[53,11451,2345],{"class":70},[53,11453,3861],{"class":87},[53,11455,11246],{"class":70},[53,11457,11249],{"class":87},[53,11459,2223],{"class":70},[53,11461,11254],{"class":77},[53,11463,1034],{"class":70},[53,11465,10660],{"class":295},[53,11467,11261],{"class":70},[53,11469,11470],{"class":55,"line":504},[53,11471,11201],{"class":66},[53,11473,11474],{"class":55,"line":712},[53,11475,109],{"class":70},[16,11477,11478],{},"缓存策略是内嵌发布的必修课（坑二细说）：入口禁缓存、hash 资源长缓存。",[30,11480,11482],{"id":11481},"_5-打包脚本windows-版-linux-交叉编译版","5. 打包脚本：Windows 版 + Linux 交叉编译版",[43,11484,11486],{"className":278,"code":11485,"language":280,"meta":48,"style":48},"#!\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",[50,11487,11488,11493,11498,11508,11526,11530,11551,11555,11563,11582,11586,11593,11650,11665,11669],{"__ignoreMap":48},[53,11489,11490],{"class":55,"line":56},[53,11491,11492],{"class":59},"#!\u002Fusr\u002Fbin\u002Fenv bash\n",[53,11494,11495],{"class":55,"line":63},[53,11496,11497],{"class":59},"# build-linux.sh —— 在 Windows\u002FmacOS\u002FLinux 任意平台交叉编译 linux\u002Famd64 产物\n",[53,11499,11500,11502,11505],{"class":55,"line":81},[53,11501,6913],{"class":77},[53,11503,11504],{"class":77}," -euo",[53,11506,11507],{"class":295}," pipefail\n",[53,11509,11510,11512,11515,11518,11520,11523],{"class":55,"line":94},[53,11511,6992],{"class":77},[53,11513,11514],{"class":295}," \"$(",[53,11516,11517],{"class":87},"dirname",[53,11519,308],{"class":295},[53,11521,11522],{"class":77},"$0",[53,11524,11525],{"class":295},"\")\"\n",[53,11527,11528],{"class":55,"line":106},[53,11529,331],{"emptyLinePlaceholder":253},[53,11531,11532,11535,11537,11540,11542,11545,11548],{"class":55,"line":367},[53,11533,11534],{"class":70},"BINARY",[53,11536,74],{"class":66},[53,11538,11539],{"class":295},"\"${",[53,11541,11534],{"class":70},[53,11543,11544],{"class":66},":-",[53,11546,11547],{"class":70},"magic-img",[53,11549,11550],{"class":295},"}\"\n",[53,11552,11553],{"class":55,"line":384},[53,11554,331],{"emptyLinePlaceholder":253},[53,11556,11557,11560],{"class":55,"line":400},[53,11558,11559],{"class":77},"echo",[53,11561,11562],{"class":295}," \"[1\u002F3] 构建前端并复制产物到 backend\u002Fweb\u002Fdist ...\"\n",[53,11564,11565,11567,11569,11572,11574,11576,11578,11580],{"class":55,"line":406},[53,11566,2223],{"class":70},[53,11568,6992],{"class":77},[53,11570,11571],{"class":295}," frontend",[53,11573,347],{"class":70},[53,11575,5786],{"class":87},[53,11577,296],{"class":295},[53,11579,425],{"class":295},[53,11581,2533],{"class":70},[53,11583,11584],{"class":55,"line":420},[53,11585,331],{"emptyLinePlaceholder":253},[53,11587,11588,11590],{"class":55,"line":443},[53,11589,11559],{"class":77},[53,11591,11592],{"class":295}," \"[2\u002F3] 交叉编译后端并内嵌前端 (CGO_ENABLED=0 GOOS=linux GOARCH=amd64) ...\"\n",[53,11594,11595,11597,11599,11602,11605,11607,11609,11612,11614,11617,11620,11622,11625,11627,11629,11631,11633,11636,11639,11642,11645,11648],{"class":55,"line":452},[53,11596,2223],{"class":70},[53,11598,6992],{"class":77},[53,11600,11601],{"class":295}," backend",[53,11603,11604],{"class":70}," && CGO_ENABLED",[53,11606,74],{"class":66},[53,11608,2311],{"class":295},[53,11610,11611],{"class":70}," GOOS",[53,11613,74],{"class":66},[53,11615,11616],{"class":295},"linux",[53,11618,11619],{"class":70}," GOARCH",[53,11621,74],{"class":66},[53,11623,11624],{"class":295},"amd64",[53,11626,778],{"class":87},[53,11628,425],{"class":295},[53,11630,434],{"class":77},[53,11632,308],{"class":295},[53,11634,11635],{"class":70},"$BINARY",[53,11637,11638],{"class":295},"\"",[53,11640,11641],{"class":77}," -ldflags=",[53,11643,11644],{"class":295},"\"-s -w\"",[53,11646,11647],{"class":295}," .\u002Fcmd\u002Fserver",[53,11649,2533],{"class":70},[53,11651,11652,11655,11658,11661,11663],{"class":55,"line":457},[53,11653,11654],{"class":87},"chmod",[53,11656,11657],{"class":295}," +x",[53,11659,11660],{"class":295}," \"backend\u002F",[53,11662,11635],{"class":70},[53,11664,516],{"class":295},[53,11666,11667],{"class":55,"line":463},[53,11668,331],{"emptyLinePlaceholder":253},[53,11670,11671,11673,11676,11678],{"class":55,"line":474},[53,11672,11559],{"class":77},[53,11674,11675],{"class":295}," \"[3\u002F3] 打包完成: backend\u002F",[53,11677,11635],{"class":70},[53,11679,516],{"class":295},[16,11681,11682,11683,11686,11687,2114],{},"Windows 版（",[50,11684,11685],{},"build-win.bat","）同理，只是产物叫 ",[50,11688,11689],{},"magic-img.exe",[11,11691,7026],{"id":7026},[30,11693,11695,11696,11699,11700,11702],{"id":11694},"坑一goembed-dist-静默漏掉-_-开头文件升级解法","坑一：",[50,11697,11698],{},"\u002F\u002Fgo:embed dist"," 静默漏掉 ",[50,11701,7066],{}," 开头文件（升级解法）",[16,11704,11705,11706,11713,11714,11716],{},"上篇说过：embed 目录时",[38,11707,11708,11709,7083,11711,7067],{},"硬编码排除 ",[50,11710,7066],{},[50,11712,2345],{},"。上篇的解法是改 Vite 输出文件名去掉 ",[50,11715,7066],{}," 前缀；这次直接用：",[43,11718,11720],{"className":3647,"code":11719,"language":409,"meta":48,"style":48},"\u002F\u002Fgo:embed all:dist   \u002F\u002F all: 前缀 = 包含全部文件，不再静默跳过\n",[50,11721,11722],{"__ignoreMap":48},[53,11723,11724,11727],{"class":55,"line":56},[53,11725,11726],{"class":59},"\u002F\u002Fgo:embed all:dist",[53,11728,11729],{"class":59},"   \u002F\u002F all: 前缀 = 包含全部文件，不再静默跳过\n",[16,11731,11732],{},[38,11733,11734,11735,11737,11738,11740],{},"结论：能用 ",[50,11736,10592],{}," 就用 ",[50,11739,10592],{},"，从根上消灭这类\"文件莫名 404\"的问题。",[30,11742,11744],{"id":11743},"坑二开发版是新的打包版却是旧的","坑二：开发版是新的、打包版却是旧的",[16,11746,11747,11748,11751],{},"现象：Vite 开发服务一切正常，",[50,11749,11750],{},":8080\u002Fdist\u002F"," 打包版却是旧界面，强刷也不一定好。",[16,11753,11754,11755,11757,11758,11761,11762,11767],{},"排查过程值得记录：先是怀疑 ",[50,11756,7229],{}," 缓存了旧 embed（上篇坑三的思路），重编重启无效；然后直接抓取 8080 返回的 HTML 和 JS，发现",[38,11759,11760],{},"服务端内容已经是新的","——问题出在",[38,11763,11764,11765],{},"浏览器缓存了 ",[50,11766,10726],{},"：它引用旧 hash 的资源，一直不更新。",[16,11769,11770,11772,11773,11776,11777,11780],{},[38,11771,7044],{},"：入口文件响应 ",[50,11774,11775],{},"Cache-Control: no-store","（见上面路由代码），带 hash 的资源给 ",[50,11778,11779],{},"immutable"," 长缓存。升级后浏览器每次拉新入口，资源文件名一变就自动换新，两全其美。",[9379,11782,11783],{},[16,11784,11785],{},"教训：排查\"新旧不一致\"先分清是哪一层——服务端产物、浏览器入口缓存、还是资源缓存。抓包看响应内容再动手。",[30,11787,11789],{"id":11788},"坑三windows-上跑的-build-linuxsh产出的文件-linux-却执行不了","坑三：Windows 上跑的 build-linux.sh，产出的文件 Linux 却执行不了",[16,11791,11792,11793,6273,11796,11802,11803,11805],{},"最初脚本只写了 ",[50,11794,11795],{},"CGO_ENABLED=0 go build",[38,11797,11798,11799],{},"没指定 ",[50,11800,11801],{},"GOOS\u002FGOARCH","。在 Windows 上跑，产出的其实是个 Windows PE 文件（只是没叫 ",[50,11804,7531],{},"），传到 Linux 自然「无法执行」。",[16,11807,11808,11810],{},[38,11809,7044],{},"：脚本里显式交叉编译：",[43,11812,11814],{"className":278,"code":11813,"language":280,"meta":48,"style":48},"CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o \"$BINARY\" -ldflags=\"-s -w\" .\u002Fcmd\u002Fserver\n",[50,11815,11816],{"__ignoreMap":48},[53,11817,11818,11821,11823,11825,11827,11829,11831,11833,11835,11837,11839,11841,11843,11845,11847,11849,11851,11853],{"class":55,"line":56},[53,11819,11820],{"class":70},"CGO_ENABLED",[53,11822,74],{"class":66},[53,11824,2311],{"class":295},[53,11826,11611],{"class":70},[53,11828,74],{"class":66},[53,11830,11616],{"class":295},[53,11832,11619],{"class":70},[53,11834,74],{"class":66},[53,11836,11624],{"class":295},[53,11838,778],{"class":87},[53,11840,425],{"class":295},[53,11842,434],{"class":77},[53,11844,308],{"class":295},[53,11846,11635],{"class":70},[53,11848,11638],{"class":295},[53,11850,11641],{"class":77},[53,11852,11644],{"class":295},[53,11854,11855],{"class":295}," .\u002Fcmd\u002Fserver\n",[16,11857,11858,11859,11862,11863,11866,11867],{},"能这么干的前提是",[38,11860,11861],{},"全依赖树无 cgo","——SQLite 用的是 modernc 纯 Go 驱动，bcrypt 用 ",[50,11864,11865],{},"x\u002Fcrypto","。这也是选型时就要留意的点：",[38,11868,11869],{},"想跨平台交叉编译，先把 cgo 依赖清干净。",[16,11871,11872,11873,11876,11877,11880],{},"另外两个小提醒：Windows 上编出的文件没有 Unix 执行位，上传服务器后记得 ",[50,11874,11875],{},"chmod +x","；",[50,11878,11879],{},"-ldflags=\"-s -w\""," 剥离符号，体积从 30MB+ 降到 26MB。",[30,11882,11884],{"id":11883},"坑四dist-被-gitignore-后embed-直接编译失败","坑四：dist 被 gitignore 后，embed 直接编译失败",[16,11886,11887,11889,11890,11892,11893,11896,11897,11899],{},[50,11888,10751],{}," 是构建产物、入了 .gitignore，但 ",[50,11891,6428],{}," 在",[38,11894,11895],{},"编译期","就要读它——刚 clone 下来直接 ",[50,11898,7229],{}," 会报 embed 找不到文件。",[16,11901,11902,11904,11905,11908],{},[38,11903,7044],{},"：gitignore 里保留 ",[50,11906,11907],{},"dist\u002F.gitkeep","（一个空文件），让目录存在于版本库中：",[43,11910,11914],{"className":11911,"code":11912,"language":11913,"meta":48,"style":48},"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",[50,11915,11916,11921,11926],{"__ignoreMap":48},[53,11917,11918],{"class":55,"line":56},[53,11919,11920],{},"backend\u002Fweb\u002Fdist\u002F*\n",[53,11922,11923],{"class":55,"line":63},[53,11924,11925],{},"!backend\u002Fweb\u002Fdist\u002F\n",[53,11927,11928],{"class":55,"line":81},[53,11929,11930],{},"!backend\u002Fweb\u002Fdist\u002F.gitkeep\n",[16,11932,11933,11934,11936,11937,11939],{},"这样 clone 后可以先 ",[50,11935,7229],{}," 通过，再 ",[50,11938,10744],{}," 灌入真实前端。",[30,11941,11943],{"id":11942},"坑五500kb-chunk-警告别慌","坑五：>500KB chunk 警告，别慌",[16,11945,11946,11947,11950,11951,2114],{},"Vite 构建会警告单 chunk 超过 500KB。这个项目里 Base UI 组件库全量引入，单 chunk 约 640KB——",[38,11948,11949],{},"属正常，不要为了消警告去拆配置","。真要优化就上路由级 ",[50,11952,11953],{},"React.lazy",[11,11955,7242],{"id":7242},[43,11957,11959],{"className":278,"code":11958,"language":280,"meta":48,"style":48},"$ build-win.bat        # backendmagic-img.exe\n$ bash build-linux.sh  # backendmagic-img（linux\u002Famd64 ELF）\n",[50,11960,11961,11971],{"__ignoreMap":48},[53,11962,11963,11965,11968],{"class":55,"line":56},[53,11964,2339],{"class":87},[53,11966,11967],{"class":295}," build-win.bat",[53,11969,11970],{"class":59},"        # backendmagic-img.exe\n",[53,11972,11973,11975,11977,11980],{"class":55,"line":63},[53,11974,2339],{"class":87},[53,11976,7254],{"class":295},[53,11978,11979],{"class":295}," build-linux.sh",[53,11981,11982],{"class":59},"  # backendmagic-img（linux\u002Famd64 ELF）\n",[16,11984,11985,11986,11989,11990,2114],{},"一个约 26MB 的二进制，内嵌 React 前端、Gin API、SQLite 数据初始化，拷到 Windows\u002FLinux 机器直接跑，浏览器访问 ",[50,11987,11988],{},"http:\u002F\u002Flocalhost:8080"," 自动重定向到 ",[50,11991,10730],{},[11,11993,216],{"id":216},[16,11995,11996],{},"这轮相比上篇的三点升级：",[521,11998,11999,12005,12015],{},[524,12000,12001,12004],{},[38,12002,12003],{},"hash 路由 + base \u002Fdist","：省掉 SPA fallback，静态服务即开即用；",[524,12006,12007,3366,12012,12014],{},[38,12008,12009,12011],{},[50,12010,10592],{}," embed 前缀",[50,12013,7066],{}," 开头文件问题一劳永逸；",[524,12016,12017,12023],{},[38,12018,12019,12020,12022],{},"显式 ",[50,12021,11801],{}," 交叉编译","：让「一个脚本、任意平台、产出 Linux 二进制」成为现实。",[16,12025,12026,12027,12030],{},"加上",[38,12028,12029],{},"入口 no-store \u002F 资源 immutable"," 的缓存策略，单文件分发的体验才算闭环——不然每次升级都要和浏览器缓存打架。",[235,12032,12033],{},"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":48,"searchDepth":63,"depth":81,"links":12035},[12036,12037,12038,12045,12053,12054],{"id":2090,"depth":63,"text":2090},{"id":6422,"depth":63,"text":6422},{"id":6436,"depth":63,"text":6436,"children":12039},[12040,12041,12042,12043,12044],{"id":10620,"depth":81,"text":10621},{"id":10738,"depth":81,"text":10739},{"id":10794,"depth":81,"text":10795},{"id":11131,"depth":81,"text":11132},{"id":11481,"depth":81,"text":11482},{"id":7026,"depth":63,"text":7026,"children":12046},[12047,12049,12050,12051,12052],{"id":11694,"depth":81,"text":12048},"坑一：\u002F\u002Fgo:embed dist 静默漏掉 _ 开头文件（升级解法）",{"id":11743,"depth":81,"text":11744},{"id":11788,"depth":81,"text":11789},{"id":11883,"depth":81,"text":11884},{"id":11942,"depth":81,"text":11943},{"id":7242,"depth":63,"text":7242},{"id":216,"depth":63,"text":216},"以 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":10532,"description":12055},"posts\u002Fgo-embed-react-single-binary\u002Findex",[1084,9348,6476,12063,12064],"交叉编译","打包","WV-T-MOO5Cu-aycw-Ony8s9hjA3_k0S4XU-T0QsCow0",{"id":12067,"title":12068,"author":6,"body":12069,"description":13314,"difficulty":1497,"draft":249,"extension":250,"image":13315,"meta":13316,"navigation":253,"path":13317,"pinned":249,"published":12059,"seo":13318,"series":13319,"seriesOrder":56,"stem":13320,"tags":13321,"updated":10528,"__hash__":13325},"posts\u002Fposts\u002Freact-photo-album-masonry\u002Findex.md","用 react-photo-album 给生图图库做瀑布流排版",{"type":8,"value":12070,"toc":13297},[12071,12074,12077,12095,12102,12166,12177,12180,12203,12206,12210,12224,12228,12244,12422,12426,12823,12826,12860,12863,12867,12877,12883,12908,12913,12924,12930,12941,12944,12984,12997,13120,13132,13136,13143,13161,13166,13256,13262,13266,13277,13280,13283,13289,13294],[11,12072,12073],{"id":12073},"技术背景",[16,12075,12076],{},"Magic Img 是我做的一个 AI 生图提示词管理工具（自托管、本地优先），其中「生图图库」页面要把所有生成图片以图片墙的形式展示出来。图片墙有两个天然矛盾：",[521,12078,12079,12085],{},[524,12080,12081,12084],{},[38,12082,12083],{},"等宽网格","（CSS Grid）：整齐，但每行高度由最高的那张决定——不同比例的图片混排时，矮图下面会留下大洞，看起来\"很乱\"；",[524,12086,12087,12090,12091,12094],{},[38,12088,12089],{},"原生比例展示","：图库必须不裁剪（这是产品约束），所以不能用 ",[50,12092,12093],{},"object-cover"," 强行统一比例。",[16,12096,12097,12098,12101],{},"业内的标准解法是 ",[38,12099,12100],{},"Masonry 瀑布流","：按列紧密堆放，每张图保持原始比例，列间高度错落但整体紧凑。实现瀑布流的常见方案有三类：",[139,12103,12104,12118],{},[142,12105,12106],{},[145,12107,12108,12110,12113,12116],{},[148,12109,2902],{},[148,12111,12112],{},"依赖",[148,12114,12115],{},"顺序",[148,12117,5521],{},[161,12119,12120,12137,12150],{},[145,12121,12122,12129,12131,12134],{},[166,12123,12124,12125,12128],{},"CSS ",[50,12126,12127],{},"columns"," 多列",[166,12130,197],{},[166,12132,12133],{},"纵向阅读序（先下后右）",[166,12135,12136],{},"最新图片不再出现在左上角，时间语义丢失",[145,12138,12139,12142,12144,12147],{},[166,12140,12141],{},"grid row-span 自研",[166,12143,197],{},[166,12145,12146],{},"保持",[166,12148,12149],{},"需固定列数 + 固定行高，逻辑要自己写",[145,12151,12152,12157,12160,12163],{},[166,12153,12154],{},[38,12155,12156],{},"react-photo-album",[166,12158,12159],{},"~4KB，无依赖",[166,12161,12162],{},"基本保持（逐列最短列填充）",[166,12164,12165],{},"专为相册墙设计，TS 友好，维护活跃",[16,12167,12168,12169,12172,12173,12176],{},"最终选了 ",[38,12170,12171],{},"react-photo-album v3","：体积小、类型齐全，而且它的 Masonry 模式恰好需要「每张图的 ",[50,12174,12175],{},"width\u002Fheight","」——我们的数据库里本来就存了，零额外成本。",[11,12178,12179],{"id":12179},"需求梳理",[521,12181,12182,12188,12194,12197,12200],{},[524,12183,12184,12185,11876],{},"展示全部生成图（21 张起，会持续增长），",[38,12186,12187],{},"原比例、不裁剪",[524,12189,12190,12191,11876],{},"不同尺寸（2304×1728、4608×3456 等）混排时",[38,12192,12193],{},"紧凑、不留洞",[524,12195,12196],{},"顺序基本保持「新图靠前」；",[524,12198,12199],{},"点击任意一张打开大图预览（YARL 灯箱），悬停显示所属提示词；",[524,12201,12202],{},"平板性能：墙上只用 300px 缩略图，原图只进灯箱按需加载。",[11,12204,12205],{"id":12205},"安装与使用",[30,12207,12209],{"id":12208},"_1-安装","1. 安装",[43,12211,12213],{"className":278,"code":12212,"language":280,"meta":48,"style":48},"npm install react-photo-album\n",[50,12214,12215],{"__ignoreMap":48},[53,12216,12217,12219,12221],{"class":55,"line":56},[53,12218,5786],{"class":87},[53,12220,352],{"class":295},[53,12222,12223],{"class":295}," react-photo-album\n",[30,12225,12227],{"id":12226},"_2-准备数据关键输入是宽高","2. 准备数据（关键输入是宽高）",[16,12229,12230,12231,12234,12235,7049,12237,7049,12240,12243],{},"库要求的 ",[50,12232,12233],{},"Photo"," 对象只有三个必需字段：",[50,12236,2763],{},[50,12238,12239],{},"width",[50,12241,12242],{},"height","。我们的接口本来就返回每张图的原始宽高：",[43,12245,12247],{"className":10678,"code":12246,"language":10680,"meta":48,"style":48},"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",[50,12248,12249,12267,12272,12279,12283,12299,12309,12313,12317,12350,12361,12380,12385,12390,12412,12417],{"__ignoreMap":48},[53,12250,12251,12253,12256,12258,12261,12264],{"class":55,"line":56},[53,12252,3655],{"class":66},[53,12254,12255],{"class":70}," PhotoAlbum, { ",[53,12257,5040],{"class":66},[53,12259,12260],{"class":70}," Photo } ",[53,12262,12263],{"class":66},"from",[53,12265,12266],{"class":295}," \"react-photo-album\"\n",[53,12268,12269],{"class":55,"line":63},[53,12270,12271],{"class":59},"\u002F\u002F 必须引入样式表！容器\u002F列的布局规则都在这里（坑 1 详述）\n",[53,12273,12274,12276],{"class":55,"line":81},[53,12275,3655],{"class":66},[53,12277,12278],{"class":295}," \"react-photo-album\u002Fstyles.css\"\n",[53,12280,12281],{"class":55,"line":94},[53,12282,331],{"emptyLinePlaceholder":253},[53,12284,12285,12288,12291,12294,12297],{"class":55,"line":106},[53,12286,12287],{"class":66},"interface",[53,12289,12290],{"class":87}," GalleryPhoto",[53,12292,12293],{"class":66}," extends",[53,12295,12296],{"class":87}," Photo",[53,12298,1863],{"class":70},[53,12300,12301,12304,12307],{"class":55,"line":367},[53,12302,12303],{"class":2226},"  prompt_text",[53,12305,12306],{"class":66},"?:",[53,12308,5046],{"class":77},[53,12310,12311],{"class":55,"line":384},[53,12312,109],{"class":70},[53,12314,12315],{"class":55,"line":400},[53,12316,331],{"emptyLinePlaceholder":253},[53,12318,12319,12321,12324,12326,12328,12330,12332,12335,12338,12341,12343,12345,12347],{"class":55,"line":406},[53,12320,2457],{"class":66},[53,12322,12323],{"class":77}," albumPhotos",[53,12325,1211],{"class":66},[53,12327,12290],{"class":87},[53,12329,2245],{"class":70},[53,12331,74],{"class":66},[53,12333,12334],{"class":70}," images.",[53,12336,12337],{"class":87},"map",[53,12339,12340],{"class":70},"((",[53,12342,22],{"class":2226},[53,12344,2687],{"class":70},[53,12346,2651],{"class":66},[53,12348,12349],{"class":70}," ({\n",[53,12351,12352,12355,12358],{"class":55,"line":420},[53,12353,12354],{"class":70},"  key: ",[53,12356,12357],{"class":87},"String",[53,12359,12360],{"class":70},"(img.id),\n",[53,12362,12363,12366,12369,12372,12374,12377],{"class":55,"line":443},[53,12364,12365],{"class":70},"  src: ",[53,12367,12368],{"class":87},"assetUrl",[53,12370,12371],{"class":70},"(img.thumb_path) ",[53,12373,2437],{"class":66},[53,12375,12376],{"class":87}," assetUrl",[53,12378,12379],{"class":70},"(img.file_path),\n",[53,12381,12382],{"class":55,"line":452},[53,12383,12384],{"class":70},"  width: img.width,\n",[53,12386,12387],{"class":55,"line":457},[53,12388,12389],{"class":70},"  height: img.height,\n",[53,12391,12392,12395,12397,12400,12402,12404,12407,12410],{"class":55,"line":463},[53,12393,12394],{"class":70},"  alt: img.prompt_text ",[53,12396,2437],{"class":66},[53,12398,12399],{"class":295}," `生成图 ${",[53,12401,22],{"class":70},[53,12403,2345],{"class":295},[53,12405,12406],{"class":70},"id",[53,12408,12409],{"class":295},"}`",[53,12411,1874],{"class":70},[53,12413,12414],{"class":55,"line":474},[53,12415,12416],{"class":70},"  prompt_text: img.prompt_text,\n",[53,12418,12419],{"class":55,"line":485},[53,12420,12421],{"class":70},"}))\n",[30,12423,12425],{"id":12424},"_3-使用-masonry-布局","3. 使用 Masonry 布局",[43,12427,12429],{"className":10678,"code":12428,"language":10680,"meta":48,"style":48},"\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",[50,12430,12431,12438,12448,12516,12530,12543,12553,12577,12587,12608,12626,12654,12661,12695,12703,12713,12729,12739,12749,12754,12764,12781,12786,12795,12804,12809,12813,12818],{"__ignoreMap":48},[53,12432,12433,12435],{"class":55,"line":56},[53,12434,1214],{"class":70},[53,12436,12437],{"class":77},"PhotoAlbum\n",[53,12439,12440,12443,12445],{"class":55,"line":63},[53,12441,12442],{"class":87},"  layout",[53,12444,74],{"class":66},[53,12446,12447],{"class":295},"\"masonry\"\n",[53,12449,12450,12453,12455,12458,12461,12463,12465,12468,12470,12473,12476,12478,12481,12484,12486,12489,12491,12494,12496,12498,12500,12503,12505,12508,12510,12513],{"class":55,"line":81},[53,12451,12452],{"class":87},"  columns",[53,12454,74],{"class":66},[53,12456,12457],{"class":70},"{(",[53,12459,12460],{"class":2226},"cw",[53,12462,2687],{"class":70},[53,12464,2651],{"class":66},[53,12466,12467],{"class":70}," (cw ",[53,12469,1214],{"class":66},[53,12471,12472],{"class":77}," 640",[53,12474,12475],{"class":66}," ?",[53,12477,4840],{"class":77},[53,12479,12480],{"class":66}," :",[53,12482,12483],{"class":70}," cw ",[53,12485,1214],{"class":66},[53,12487,12488],{"class":77}," 960",[53,12490,12475],{"class":66},[53,12492,12493],{"class":77}," 3",[53,12495,12480],{"class":66},[53,12497,12483],{"class":70},[53,12499,1214],{"class":66},[53,12501,12502],{"class":77}," 1280",[53,12504,12475],{"class":66},[53,12506,12507],{"class":77}," 4",[53,12509,12480],{"class":66},[53,12511,12512],{"class":77}," 5",[53,12514,12515],{"class":70},")}\n",[53,12517,12518,12521,12523,12525,12528],{"class":55,"line":94},[53,12519,12520],{"class":87},"  spacing",[53,12522,74],{"class":66},[53,12524,4507],{"class":70},[53,12526,12527],{"class":77},"16",[53,12529,109],{"class":70},[53,12531,12532,12535,12537,12539,12541],{"class":55,"line":106},[53,12533,12534],{"class":87},"  padding",[53,12536,74],{"class":66},[53,12538,4507],{"class":70},[53,12540,2311],{"class":77},[53,12542,109],{"class":70},[53,12544,12545,12548,12550],{"class":55,"line":367},[53,12546,12547],{"class":87},"  photos",[53,12549,74],{"class":66},[53,12551,12552],{"class":70},"{albumPhotos}\n",[53,12554,12555,12558,12560,12563,12566,12569,12571,12574],{"class":55,"line":384},[53,12556,12557],{"class":87},"  onClick",[53,12559,74],{"class":66},[53,12561,12562],{"class":70},"{({ ",[53,12564,12565],{"class":2226},"index",[53,12567,12568],{"class":70}," }) ",[53,12570,2651],{"class":66},[53,12572,12573],{"class":87}," openPreview",[53,12575,12576],{"class":70},"(index)}\n",[53,12578,12579,12582,12584],{"class":55,"line":400},[53,12580,12581],{"class":87},"  render",[53,12583,74],{"class":66},[53,12585,12586],{"class":70},"{{\n",[53,12588,12589,12592,12595,12597,12599,12602,12604,12606],{"class":55,"line":406},[53,12590,12591],{"class":87},"    photo",[53,12593,12594],{"class":70},": (",[53,12596,2758],{"class":2226},[53,12598,1034],{"class":70},[53,12600,12601],{"class":2226},"context",[53,12603,2687],{"class":70},[53,12605,2651],{"class":66},[53,12607,1863],{"class":70},[53,12609,12610,12612,12615,12618,12621,12623],{"class":55,"line":420},[53,12611,2361],{"class":66},[53,12613,12614],{"class":70}," { ",[53,12616,12617],{"class":77},"onClick",[53,12619,12620],{"class":70}," } ",[53,12622,74],{"class":66},[53,12624,12625],{"class":70}," props\n",[53,12627,12628,12630,12632,12635,12637,12639,12641,12643,12645,12647,12649,12651],{"class":55,"line":443},[53,12629,2361],{"class":66},[53,12631,12614],{"class":70},[53,12633,12634],{"class":77},"photo",[53,12636,1034],{"class":70},[53,12638,12565],{"class":77},[53,12640,1034],{"class":70},[53,12642,12239],{"class":77},[53,12644,1034],{"class":70},[53,12646,12242],{"class":77},[53,12648,12620],{"class":70},[53,12650,74],{"class":66},[53,12652,12653],{"class":70}," context\n",[53,12655,12656,12659],{"class":55,"line":452},[53,12657,12658],{"class":66},"      return",[53,12660,5053],{"class":70},[53,12662,12663,12666,12669,12672,12674,12677,12680,12682,12685,12688,12690,12693],{"class":55,"line":457},[53,12664,12665],{"class":70},"        \u003C",[53,12667,12668],{"class":1923},"button",[53,12670,12671],{"class":87}," type",[53,12673,74],{"class":66},[53,12675,12676],{"class":295},"\"button\"",[53,12678,12679],{"class":87}," onClick",[53,12681,74],{"class":66},[53,12683,12684],{"class":70},"{onClick} ",[53,12686,12687],{"class":87},"className",[53,12689,74],{"class":66},[53,12691,12692],{"class":295},"\"group relative block w-full ...\"",[53,12694,871],{"class":70},[53,12696,12697,12700],{"class":55,"line":463},[53,12698,12699],{"class":70},"          \u003C",[53,12701,12702],{"class":1923},"img\n",[53,12704,12705,12708,12710],{"class":55,"line":474},[53,12706,12707],{"class":87},"            src",[53,12709,74],{"class":66},[53,12711,12712],{"class":70},"{photo.src}\n",[53,12714,12715,12718,12720,12723,12725,12727],{"class":55,"line":485},[53,12716,12717],{"class":87},"            alt",[53,12719,74],{"class":66},[53,12721,12722],{"class":70},"{photo.alt ",[53,12724,2437],{"class":66},[53,12726,4751],{"class":295},[53,12728,109],{"class":70},[53,12730,12731,12734,12736],{"class":55,"line":490},[53,12732,12733],{"class":87},"            decoding",[53,12735,74],{"class":66},[53,12737,12738],{"class":295},"\"async\"\n",[53,12740,12741,12744,12746],{"class":55,"line":499},[53,12742,12743],{"class":87},"            style",[53,12745,74],{"class":66},[53,12747,12748],{"class":70},"{{ width, height }}\n",[53,12750,12751],{"class":55,"line":504},[53,12752,12753],{"class":70},"          \u002F>\n",[53,12755,12756,12759,12762],{"class":55,"line":712},[53,12757,12758],{"class":70},"          {",[53,12760,12761],{"class":59},"\u002F* hover 提示词浮层 *\u002F",[53,12763,109],{"class":70},[53,12765,12766,12768,12771,12774,12776,12779],{"class":55,"line":720},[53,12767,12699],{"class":70},[53,12769,12770],{"class":1923},"div",[53,12772,12773],{"class":87}," className",[53,12775,74],{"class":66},[53,12777,12778],{"class":295},"\"... opacity-0 group-hover:opacity-100\"",[53,12780,871],{"class":70},[53,12782,12783],{"class":55,"line":725},[53,12784,12785],{"class":70},"            {photo.prompt_text}\n",[53,12787,12788,12791,12793],{"class":55,"line":1358},[53,12789,12790],{"class":70},"          \u003C\u002F",[53,12792,12770],{"class":1923},[53,12794,871],{"class":70},[53,12796,12797,12800,12802],{"class":55,"line":1372},[53,12798,12799],{"class":70},"        \u003C\u002F",[53,12801,12668],{"class":1923},[53,12803,871],{"class":70},[53,12805,12806],{"class":55,"line":1379},[53,12807,12808],{"class":70},"      )\n",[53,12810,12811],{"class":55,"line":1384},[53,12812,7203],{"class":70},[53,12814,12815],{"class":55,"line":1390},[53,12816,12817],{"class":70},"  }}\n",[53,12819,12820],{"class":55,"line":1398},[53,12821,12822],{"class":70},"\u002F>\n",[16,12824,12825],{},"几个要点：",[1770,12827,12828,12837,12850],{},[524,12829,12830,12832,12833,12836],{},[50,12831,12127],{}," 可以是数字或 ",[50,12834,12835],{},"(containerWidth) => number"," 函数，做响应式列数很方便；",[524,12838,12839,12840,12842,12843,12846,12847,12849],{},"点击回调 ",[50,12841,12617],{}," 里拿到的是",[38,12844,12845],{},"原始数组下标"," ",[50,12848,12565],{},"，直接传给灯箱即可；",[524,12851,12852,12853,12856,12857,12859],{},"自定义 ",[50,12854,12855],{},"render.photo"," 后，图片尺寸要自己用 context 里的 ",[50,12858,12175],{},"（渲染尺寸）设置——这两个值库已经按列宽算好了。",[11,12861,12862],{"id":12862},"遇到的坑",[30,12864,12866],{"id":12865},"坑-1忘记引入样式表-一张一列","坑 1：忘记引入样式表 → 「一张一列」",[16,12868,12869,12870,12873,12874,2114],{},"这是最坑的一个。装完包、写好 ",[50,12871,12872],{},"\u003CPhotoAlbum layout=\"masonry\">","，页面渲染出来却是：",[38,12875,12876],{},"每张图占一整行，右侧大片置灰空白",[16,12878,12879,12880,12882],{},"排查过程：先怀疑是 ",[50,12881,12127],{}," 函数没生效，改成固定数字无效；再检查 DOM——容器和 5 个列（track）都在，列的宽度也算对了，但图片全都堆在布局里。",[16,12884,12885,12886,12889,12890,7049,12893,12896,12897,12903,12904,12907],{},"最后翻包的 ",[50,12887,12888],{},"package.json"," 发现它导出了独立的 CSS 文件（",[50,12891,12892],{},".\u002Fstyles.css",[50,12894,12895],{},".\u002Fmasonry.css"," 等），而",[38,12898,12899,12900],{},"官方 README 示例都带了一行 ",[50,12901,12902],{},"import \"react-photo-album\u002Fstyles.css\"","。容器是 flex、列是 flex-column、列宽按 ",[50,12905,12906],{},"--react-photo-album--columns"," 计算——这些规则全在这个样式表里，不引入就退化成普通块级堆叠。",[16,12909,12910,12912],{},[38,12911,7044],{},"：顶部加一行：",[43,12914,12916],{"className":10678,"code":12915,"language":10680,"meta":48,"style":48},"import \"react-photo-album\u002Fstyles.css\"\n",[50,12917,12918],{"__ignoreMap":48},[53,12919,12920,12922],{"class":55,"line":56},[53,12921,3655],{"class":66},[53,12923,12278],{"class":295},[16,12925,12926,12927],{},"经验：",[38,12928,12929],{},"用这类\"无运行时依赖\"的布局库，先看它的 exports 里有没有独立 CSS，有就一定要引。",[30,12931,12933,12934,12936,12937,12940],{"id":12932},"坑-2renderphoto-是-props-context-双参签名","坑 2：",[50,12935,12855],{}," 是 ",[50,12938,12939],{},"(props, context)"," 双参签名",[16,12942,12943],{},"自定义渲染时我凭直觉写了：",[43,12945,12947],{"className":10678,"code":12946,"language":10680,"meta":48,"style":48},"photo: ({ photo, index, width, height, onClick }) => ... \u002F\u002F ❌ 报错：Property 'photo' does not exist\n",[50,12948,12949],{"__ignoreMap":48},[53,12950,12951,12953,12956,12958,12960,12962,12964,12966,12968,12970,12972,12974,12976,12978,12981],{"class":55,"line":56},[53,12952,12634],{"class":87},[53,12954,12955],{"class":70},": ({ ",[53,12957,12634],{"class":2226},[53,12959,1034],{"class":70},[53,12961,12565],{"class":2226},[53,12963,1034],{"class":70},[53,12965,12239],{"class":2226},[53,12967,1034],{"class":70},[53,12969,12242],{"class":2226},[53,12971,1034],{"class":70},[53,12973,12617],{"class":2226},[53,12975,12568],{"class":70},[53,12977,2651],{"class":66},[53,12979,12980],{"class":66}," ...",[53,12982,12983],{"class":59}," \u002F\u002F ❌ 报错：Property 'photo' does not exist\n",[16,12985,12986,12987,12989,12990,12993,12994,3366],{},"TS 报错提示 ",[50,12988,12634],{}," 等属性不存在于 ",[50,12991,12992],{},"RenderPhotoProps","。看类型定义才发现 v3 的渲染函数是",[38,12995,12996],{},"两个参数",[43,12998,13000],{"className":10678,"code":12999,"language":10680,"meta":48,"style":48},"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",[50,13001,13002,13048,13053,13073,13087,13112,13116],{"__ignoreMap":48},[53,13003,13004,13006,13009,13011,13014,13016,13018,13021,13023,13025,13027,13029,13032,13034,13036,13038,13041,13043,13045],{"class":55,"line":56},[53,13005,5040],{"class":66},[53,13007,13008],{"class":87}," RenderFunction",[53,13010,1214],{"class":70},[53,13012,13013],{"class":87},"Props",[53,13015,1034],{"class":70},[53,13017,3861],{"class":87},[53,13019,13020],{"class":70},"> ",[53,13022,74],{"class":66},[53,13024,2265],{"class":70},[53,13026,2758],{"class":2226},[53,13028,1211],{"class":66},[53,13030,13031],{"class":87}," Props",[53,13033,1034],{"class":70},[53,13035,12601],{"class":2226},[53,13037,1211],{"class":66},[53,13039,13040],{"class":87}," Context",[53,13042,2687],{"class":70},[53,13044,2651],{"class":66},[53,13046,13047],{"class":87}," ReactNode\n",[53,13049,13050],{"class":55,"line":63},[53,13051,13052],{"class":59},"\u002F\u002F photo 的渲染上下文（photo\u002Findex\u002Fwidth\u002Fheight）在第二个参数里\n",[53,13054,13055,13057,13059,13061,13063,13065,13067,13069,13071],{"class":55,"line":81},[53,13056,12634],{"class":87},[53,13058,1211],{"class":66},[53,13060,2265],{"class":70},[53,13062,2758],{"class":2226},[53,13064,1034],{"class":70},[53,13066,12601],{"class":2226},[53,13068,2687],{"class":70},[53,13070,2651],{"class":66},[53,13072,1863],{"class":70},[53,13074,13075,13077,13079,13081,13084],{"class":55,"line":94},[53,13076,2712],{"class":87},[53,13078,12614],{"class":70},[53,13080,12617],{"class":2226},[53,13082,13083],{"class":70}," } = ",[53,13085,13086],{"class":2226},"props\n",[53,13088,13089,13091,13093,13095,13097,13099,13101,13103,13105,13107,13109],{"class":55,"line":106},[53,13090,2712],{"class":87},[53,13092,12614],{"class":70},[53,13094,12634],{"class":2226},[53,13096,1034],{"class":70},[53,13098,12565],{"class":2226},[53,13100,1034],{"class":70},[53,13102,12239],{"class":2226},[53,13104,1034],{"class":70},[53,13106,12242],{"class":2226},[53,13108,13083],{"class":70},[53,13110,13111],{"class":2226},"context\n",[53,13113,13114],{"class":55,"line":367},[53,13115,10671],{"class":66},[53,13117,13118],{"class":55,"line":384},[53,13119,109],{"class":70},[16,13121,13122,13124,13125,13127,13128,13131],{},[38,13123,7044],{},"：按 ",[50,13126,12939],{}," 拆解即可。装新库先扫一眼 ",[50,13129,13130],{},".d.ts"," 里的 Render 类型签名，能少走很多弯路。",[30,13133,13135],{"id":13134},"坑-3测试断言把dom-顺序当成了视觉顺序","坑 3：测试断言把「DOM 顺序」当成了「视觉顺序」",[16,13137,13138,13139,13142],{},"布局修好后，我在浏览器自测脚本里加了一条断言：取前两个 ",[50,13140,13141],{},"\u003Cimg>","，比较它们的左坐标，认为应该并排（不同列）。",[16,13144,13145,13146,13149,13150,7083,13153,13156,13157,13160],{},"结果断言失败——但页面明明是 5 列并排。排查发现：",[38,13147,13148],{},"瀑布流的 DOM 是按\"列\"组织的","（第 1 列的全部图片在前，第 2 列的在后），所以 ",[50,13151,13152],{},"img[0]",[50,13154,13155],{},"img[1]"," 属于",[38,13158,13159],{},"同一列","，左坐标当然相同。",[16,13162,13163,13165],{},[38,13164,7044],{},"：断言改为比较「第 1 列的首张图 vs 第 2 列的首张图」：",[43,13167,13171],{"className":13168,"code":13169,"language":13170,"meta":48,"style":48},"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",[50,13172,13173,13198,13221],{"__ignoreMap":48},[53,13174,13175,13177,13179,13181,13184,13186,13188,13191,13193,13196],{"class":55,"line":56},[53,13176,2457],{"class":66},[53,13178,3472],{"class":77},[53,13180,1860],{"class":66},[53,13182,13183],{"class":70}," tracks[",[53,13185,2311],{"class":77},[53,13187,2376],{"class":70},[53,13189,13190],{"class":87},"querySelector",[53,13192,2223],{"class":70},[53,13194,13195],{"class":295},"\"img\"",[53,13197,2533],{"class":70},[53,13199,13200,13202,13205,13207,13209,13211,13213,13215,13217,13219],{"class":55,"line":63},[53,13201,2457],{"class":66},[53,13203,13204],{"class":77}," b",[53,13206,1860],{"class":66},[53,13208,13183],{"class":70},[53,13210,394],{"class":77},[53,13212,2376],{"class":70},[53,13214,13190],{"class":87},[53,13216,2223],{"class":70},[53,13218,13195],{"class":295},[53,13220,2533],{"class":70},[53,13222,13223,13225,13228,13231,13234,13237,13240,13243,13246,13248,13251,13253],{"class":55,"line":81},[53,13224,2274],{"class":66},[53,13226,13227],{"class":70}," Math.",[53,13229,13230],{"class":87},"abs",[53,13232,13233],{"class":70},"(a.",[53,13235,13236],{"class":87},"getBoundingClientRect",[53,13238,13239],{"class":70},"().left ",[53,13241,13242],{"class":66},"-",[53,13244,13245],{"class":70}," b.",[53,13247,13236],{"class":87},[53,13249,13250],{"class":70},"().left) ",[53,13252,824],{"class":66},[53,13254,13255],{"class":77}," 10\n",[16,13257,12926,13258,13261],{},[38,13259,13260],{},"验证布局类库时，先搞清楚它生成的 DOM 结构，再写断言","；另外把「真实浏览器探针」当排查手段（直接打印各 track 的 x 坐标和子元素数量），比盯着代码猜快得多。",[30,13263,13265],{"id":13264},"坑-4等宽网格的洞引入前就存在的产品问题","坑 4：等宽网格的\"洞\"（引入前就存在的产品问题）",[16,13267,13268,13269,13272,13273,13276],{},"顺带记录一下为什么换掉原来的布局：CSS Grid ",[50,13270,13271],{},"repeat(auto-fill, minmax(220px, 1fr))"," 等宽铺满，图片 ",[50,13274,13275],{},"w-full h-auto","。当一行里混有不同比例时，行高取最高的那张，矮图底部留空，几行错落下来就显得乱；另外窗口宽度\"差一点放不下下一张\"时，右侧会空出近一整列。",[16,13278,13279],{},"换成 Masonry 后这两个问题都消失了——列内紧密堆叠、列数自适应。",[11,13281,13282],{"id":13282},"效果与小结",[16,13284,13285,13286,13288],{},"最终效果：21 张不同比例的生成图在 5 列瀑布流里紧密排列，原比例不裁剪，悬停出提示词浮层，点击进 YARL 大图预览；缩略图总大小仅 360KB，进页面即全量加载 + ",[50,13287,11779],{}," 长缓存，平板上快速滚动也跟手。",[16,13290,10457,13291],{},[38,13292,13293],{},"react-photo-album 是个小但专业的相册布局库，只要记住三件事——引样式表、喂宽高、render 函数是双参——就能在十分钟内把图片墙升级成真正的瀑布流。",[235,13295,13296],{},"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":48,"searchDepth":63,"depth":81,"links":13298},[13299,13300,13301,13306,13313],{"id":12073,"depth":63,"text":12073},{"id":12179,"depth":63,"text":12179},{"id":12205,"depth":63,"text":12205,"children":13302},[13303,13304,13305],{"id":12208,"depth":81,"text":12209},{"id":12226,"depth":81,"text":12227},{"id":12424,"depth":81,"text":12425},{"id":12862,"depth":63,"text":12862,"children":13307},[13308,13309,13311,13312],{"id":12865,"depth":81,"text":12866},{"id":12932,"depth":81,"text":13310},"坑 2：render.photo 是 (props, context) 双参签名",{"id":13134,"depth":81,"text":13135},{"id":13264,"depth":81,"text":13265},{"id":13282,"depth":63,"text":13282},"记录在 Magic Img 生图图库中引入 react-photo-album 实现 Masonry 瀑布流的过程：技术选型背景、需求梳理、安装使用，以及「忘记引入样式表导致一张一列」等踩坑与排查方法。","\u002Fposts\u002Freact-photo-album-masonry\u002Fimg\u002Fcover.svg",{},"\u002Fposts\u002Freact-photo-album-masonry",{"title":12068,"description":13314},"前端工程实践","posts\u002Freact-photo-album-masonry\u002Findex",[9348,12156,13322,13323,13324],"瀑布流","前端","Tailwind","-O-kAQLEyRzwVhOpuo-yBxZim9LtvhChPkpP92ipyjM",1786887349178]