[{"data":1,"prerenderedAt":13169},["ShallowReactive",2],{"series-Docker 实践笔记":3},[4,855,1276,1507,1925,3116,3395,5464,6246,7225,8634,9194,10373,11909],{"id":5,"title":6,"author":7,"body":8,"description":839,"difficulty":840,"draft":841,"extension":842,"image":843,"meta":844,"navigation":89,"path":845,"pinned":841,"published":846,"seo":847,"series":848,"seriesOrder":36,"stem":849,"tags":850,"updated":846,"__hash__":854},"posts\u002Fposts\u002Fdocker-musl-build\u002Findex.md","Docker 编译 musl 可执行文件",null,{"type":9,"value":10,"toc":828},"minimark",[11,15,19,22,280,284,328,331,349,352,503,506,642,645,648,706,709,824],[12,13,14],"h2",{"id":14},"解决问题",[16,17,18],"p",{},"用 Go 进行跨平台编译时往往步骤复杂，用 Docker 可以轻松解决。但 Docker 的当前最小镜像 Alpine 其动态链接库采用 musl，与主流的 glibc 不同，往往不能方便执行。",[12,20,21],{"id":21},"查阅资料",[23,24,29],"pre",{"className":25,"code":26,"language":27,"meta":28,"style":28},"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","",[30,31,32,41,84,91,97,126,145,161,167,181,204,213,218,224,235,246,251,260,265],"code",{"__ignoreMap":28},[33,34,37],"span",{"class":35,"line":36},"line",1,[33,38,40],{"class":39},"sHbNN","# 启动容器并挂载当前目录到 `\u002Fapp`\n",[33,42,44,48,52,56,59,62,65,69,72,75,78,81],{"class":35,"line":43},2,[33,45,47],{"class":46},"sw2iP","docker",[33,49,51],{"class":50},"scXbn"," run",[33,53,55],{"class":54},"suQ91"," -it",[33,57,58],{"class":54}," --rm",[33,60,61],{"class":54}," -v",[33,63,64],{"class":50}," \"",[33,66,68],{"class":67},"sIX_F","$PWD",[33,70,71],{"class":50},"\":\u002Fapp",[33,73,74],{"class":54}," -w",[33,76,77],{"class":50}," \u002Fapp",[33,79,80],{"class":50}," ubuntu:20.04",[33,82,83],{"class":50}," bash\n",[33,85,87],{"class":35,"line":86},3,[33,88,90],{"emptyLinePlaceholder":89},true,"\n",[33,92,94],{"class":35,"line":93},4,[33,95,96],{"class":39},"# 在容器内执行以下操作：\n",[33,98,100,103,106,109,111,114,117,120,123],{"class":35,"line":99},5,[33,101,102],{"class":46},"apt-get",[33,104,105],{"class":50}," update",[33,107,108],{"class":67}," && ",[33,110,102],{"class":46},[33,112,113],{"class":50}," install",[33,115,116],{"class":54}," -y",[33,118,119],{"class":50}," musl-tools",[33,121,122],{"class":50}," golang-go",[33,124,125],{"class":39},"  # 安装 musl 和 Go\n",[33,127,129,133,136,139,142],{"class":35,"line":128},6,[33,130,132],{"class":131},"siTax","export",[33,134,135],{"class":67}," CC",[33,137,138],{"class":131},"=",[33,140,141],{"class":67},"musl-gcc  ",[33,143,144],{"class":39},"# 指定 musl 编译器\n",[33,146,148,150,153,155,158],{"class":35,"line":147},7,[33,149,132],{"class":131},[33,151,152],{"class":67}," CGO_ENABLED",[33,154,138],{"class":131},[33,156,157],{"class":54},"1",[33,159,160],{"class":39},"  # 启用 CGO\n",[33,162,164],{"class":35,"line":163},8,[33,165,166],{"class":39},"# 设置代理\n",[33,168,170,173,176,178],{"class":35,"line":169},9,[33,171,172],{"class":46},"go",[33,174,175],{"class":50}," env",[33,177,74],{"class":54},[33,179,180],{"class":50}," GOPROXY=https:\u002F\u002Fgoproxy.cn,direct\n",[33,182,184,186,189,192,195,198,201],{"class":35,"line":183},10,[33,185,172],{"class":46},[33,187,188],{"class":50}," build",[33,190,191],{"class":54}," -ldflags",[33,193,194],{"class":50}," '-linkmode external -extldflags \"-static\"'",[33,196,197],{"class":54}," -o",[33,199,200],{"class":50}," myapp",[33,202,203],{"class":39},"  # 静态编译\n",[33,205,207,210],{"class":35,"line":206},11,[33,208,209],{"class":54},"exit",[33,211,212],{"class":39},"  # 退出容器\n",[33,214,216],{"class":35,"line":215},12,[33,217,90],{"emptyLinePlaceholder":89},[33,219,221],{"class":35,"line":220},13,[33,222,223],{"class":39},"# 验证结果（在宿主机执行）\n",[33,225,227,230,232],{"class":35,"line":226},14,[33,228,229],{"class":46},"file",[33,231,200],{"class":50},[33,233,234],{"class":39},"  # 应显示 \"statically linked\"\n",[33,236,238,241,243],{"class":35,"line":237},15,[33,239,240],{"class":46},"ldd",[33,242,200],{"class":50},[33,244,245],{"class":39},"   # 应显示 \"not a dynamic executable\"\n",[33,247,249],{"class":35,"line":248},16,[33,250,90],{"emptyLinePlaceholder":89},[33,252,254,257],{"class":35,"line":253},17,[33,255,256],{"class":46},"wget",[33,258,259],{"class":50}," https:\u002F\u002Fgo.dev\u002Fdl\u002Fgo1.21.linux-amd64.tar.gz\n",[33,261,263],{"class":35,"line":262},18,[33,264,90],{"emptyLinePlaceholder":89},[33,266,268,271,274,277],{"class":35,"line":267},19,[33,269,270],{"class":46},"ENV",[33,272,273],{"class":50}," PATH=\"\u002Fdata\u002Fgo\u002Fbin:",[33,275,276],{"class":67},"$PATH",[33,278,279],{"class":50},"\"\n",[281,282,283],"h3",{"id":283},"遇到问题",[285,286,287,314],"ol",{},[288,289,290,293,294,297,300,301,304,306,307,309],"li",{},[30,291,292],{},"go mod tidy"," 时报错 ",[30,295,296],{},"tls: failed to verify certificate: x509: certificate",[298,299],"br",{},"解决：",[30,302,303],{},"RUN apt-get update && apt-get install -y ca-certificates && rm -rf \u002Fvar\u002Flib\u002Fapt\u002Flists\u002F*",[298,305],{},"基础镜像的证书未更新，导致 https 的请求出现类似错误。",[298,308],{},[310,311],"img",{"alt":312,"src":313},"image-20250327100813512","\u002Fposts\u002Fdocker-musl-build\u002Fimg\u002Fimage-20250327100813512.png",[288,315,316,317,320,321,323,324,327],{},"直接安装 ",[30,318,319],{},"golang-go"," 其版本过低",[298,322],{},"解决: ",[30,325,326],{},"PATH=\"\u002Fdata\u002Fgo\u002Fbin:$PATH\""," 手动指定环境",[12,329,330],{"id":330},"最终方案",[285,332,333,340,343],{},[288,334,335,336,339],{},"下载 ",[30,337,338],{},"go1.24.1.linux-amd64.tar.gz"," 解压",[288,341,342],{},"编写 Dockerfile",[288,344,345,346],{},"执行 ",[30,347,348],{},"docker build -t go-build:v1 .",[16,350,351],{},"Dockerfile 内容：",[23,353,357],{"className":354,"code":355,"language":356,"meta":28,"style":28},"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",[30,358,359,367,371,382,386,394,398,408,412,420,424,429,436,440,447,452,459,463,471,475,483,488],{"__ignoreMap":28},[33,360,361,364],{"class":35,"line":36},[33,362,363],{"class":131},"FROM",[33,365,366],{"class":67}," ubuntu:20.04\n",[33,368,369],{"class":35,"line":43},[33,370,90],{"emptyLinePlaceholder":89},[33,372,373,376,379],{"class":35,"line":86},[33,374,375],{"class":131},"LABEL",[33,377,378],{"class":67}," org.opencontainers.image.authors=",[33,380,381],{"class":50},"\"sutong\"\n",[33,383,384],{"class":35,"line":93},[33,385,90],{"emptyLinePlaceholder":89},[33,387,388,391],{"class":35,"line":99},[33,389,390],{"class":131},"COPY",[33,392,393],{"class":67}," go\u002F \u002Fgo\u002F\n",[33,395,396],{"class":35,"line":128},[33,397,90],{"emptyLinePlaceholder":89},[33,399,400,402,405],{"class":35,"line":147},[33,401,270],{"class":131},[33,403,404],{"class":67}," PATH=",[33,406,407],{"class":50},"\"\u002Fgo\u002Fbin:$PATH\"\n",[33,409,410],{"class":35,"line":163},[33,411,90],{"emptyLinePlaceholder":89},[33,413,414,417],{"class":35,"line":169},[33,415,416],{"class":131},"RUN",[33,418,419],{"class":67}," apt-get update && apt-get install -y musl-tools\n",[33,421,422],{"class":35,"line":183},[33,423,90],{"emptyLinePlaceholder":89},[33,425,426],{"class":35,"line":206},[33,427,428],{"class":39},"## go mod tidy 时报错 tls: failed to verify certificate: x509: certificate\n",[33,430,431,433],{"class":35,"line":215},[33,432,416],{"class":131},[33,434,435],{"class":67}," apt-get update && apt-get install -y ca-certificates && rm -rf \u002Fvar\u002Flib\u002Fapt\u002Flists\u002F*\n",[33,437,438],{"class":35,"line":220},[33,439,144],{"class":39},[33,441,442,444],{"class":35,"line":226},[33,443,270],{"class":131},[33,445,446],{"class":67}," CC=musl-gcc\n",[33,448,449],{"class":35,"line":237},[33,450,451],{"class":39},"# 启用 CGO\n",[33,453,454,456],{"class":35,"line":248},[33,455,270],{"class":131},[33,457,458],{"class":67}," CGO_ENABLED=1\n",[33,460,461],{"class":35,"line":253},[33,462,90],{"emptyLinePlaceholder":89},[33,464,465,468],{"class":35,"line":262},[33,466,467],{"class":131},"VOLUME",[33,469,470],{"class":67}," \u002Fdata\n",[33,472,473],{"class":35,"line":267},[33,474,90],{"emptyLinePlaceholder":89},[33,476,478,481],{"class":35,"line":477},20,[33,479,480],{"class":131},"WORKDIR",[33,482,470],{"class":67},[33,484,486],{"class":35,"line":485},21,[33,487,90],{"emptyLinePlaceholder":89},[33,489,491,494,497,500],{"class":35,"line":490},22,[33,492,493],{"class":131},"CMD",[33,495,496],{"class":67}," [",[33,498,499],{"class":50},"\"\u002Fbin\u002Fbash\"",[33,501,502],{"class":67},"]\n",[281,504,505],{"id":505},"构建",[23,507,509],{"className":25,"code":508,"language":27,"meta":28,"style":28},"# 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",[30,510,511,516,520,525,551,555,560,595,600,604,637],{"__ignoreMap":28},[33,512,513],{"class":35,"line":36},[33,514,515],{"class":39},"# docker build -t go-build:v1 .\n",[33,517,518],{"class":35,"line":43},[33,519,90],{"emptyLinePlaceholder":89},[33,521,522],{"class":35,"line":86},[33,523,524],{"class":39},"# 直接执行编译 编译后删除\n",[33,526,527,529,531,533,535,538,541,544,546,548],{"class":35,"line":93},[33,528,47],{"class":46},[33,530,51],{"class":50},[33,532,58],{"class":54},[33,534,61],{"class":54},[33,536,537],{"class":50}," .:\u002Fdata",[33,539,540],{"class":50}," go-build:v1",[33,542,543],{"class":50}," go",[33,545,188],{"class":50},[33,547,197],{"class":54},[33,549,550],{"class":50}," app_musl\n",[33,552,553],{"class":35,"line":99},[33,554,90],{"emptyLinePlaceholder":89},[33,556,557],{"class":35,"line":128},[33,558,559],{"class":39},"# 构建编译环境\n",[33,561,562,564,566,568,571,573,575,578,581,584,587,590,592],{"class":35,"line":147},[33,563,47],{"class":46},[33,565,51],{"class":50},[33,567,55],{"class":54},[33,569,570],{"class":54}," -d",[33,572,61],{"class":54},[33,574,537],{"class":50},[33,576,577],{"class":54}," --name",[33,579,580],{"class":131}," \u003C",[33,582,583],{"class":50},"容器名",[33,585,586],{"class":67},"称",[33,588,589],{"class":131},">",[33,591,540],{"class":50},[33,593,594],{"class":50}," \u002Fbin\u002Fbash\n",[33,596,597],{"class":35,"line":163},[33,598,599],{"class":39},"# eg：docker run -it -d -v .:\u002Fdata --name logbus-build go-build:v1 \u002Fbin\u002Fbash\n",[33,601,602],{"class":35,"line":169},[33,603,90],{"emptyLinePlaceholder":89},[33,605,606,608,611,613,615,617,619,621,623,625,627,629,632,634],{"class":35,"line":183},[33,607,47],{"class":46},[33,609,610],{"class":50}," exec",[33,612,55],{"class":54},[33,614,580],{"class":131},[33,616,583],{"class":50},[33,618,586],{"class":67},[33,620,589],{"class":131},[33,622,543],{"class":50},[33,624,188],{"class":50},[33,626,197],{"class":54},[33,628,580],{"class":131},[33,630,631],{"class":50},"myap",[33,633,16],{"class":67},[33,635,636],{"class":131},">\n",[33,638,639],{"class":35,"line":206},[33,640,641],{"class":39},"# eg：docker exec -it logbus-build go build -o logbus_musl_1\n",[281,643,644],{"id":644},"额外注意",[16,646,647],{},"若没科学上网需要设置代理：",[23,649,651],{"className":25,"code":650,"language":27,"meta":28,"style":28},"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",[30,652,653,671,681,697],{"__ignoreMap":28},[33,654,655,657,659,661,663,665,667,669],{"class":35,"line":36},[33,656,47],{"class":46},[33,658,610],{"class":50},[33,660,55],{"class":54},[33,662,580],{"class":131},[33,664,583],{"class":50},[33,666,586],{"class":67},[33,668,589],{"class":131},[33,670,594],{"class":50},[33,672,673,675,677,679],{"class":35,"line":43},[33,674,172],{"class":46},[33,676,175],{"class":50},[33,678,74],{"class":54},[33,680,180],{"class":50},[33,682,683,685,687,689,691,693,695],{"class":35,"line":86},[33,684,172],{"class":46},[33,686,188],{"class":50},[33,688,191],{"class":54},[33,690,194],{"class":50},[33,692,197],{"class":54},[33,694,200],{"class":50},[33,696,203],{"class":39},[33,698,699,701,703],{"class":35,"line":93},[33,700,172],{"class":46},[33,702,188],{"class":50},[33,704,705],{"class":39}," # musl 库编译\n",[281,707,708],{"id":708},"运行环境示例",[23,710,712],{"className":354,"code":711,"language":356,"meta":28,"style":28},"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",[30,713,714,721,725,733,737,744,748,755,762,766,773,777,784,788,810,814,819],{"__ignoreMap":28},[33,715,716,718],{"class":35,"line":36},[33,717,363],{"class":131},[33,719,720],{"class":67}," alpine:latest\n",[33,722,723],{"class":35,"line":43},[33,724,90],{"emptyLinePlaceholder":89},[33,726,727,729,731],{"class":35,"line":86},[33,728,375],{"class":131},[33,730,378],{"class":67},[33,732,381],{"class":50},[33,734,735],{"class":35,"line":93},[33,736,90],{"emptyLinePlaceholder":89},[33,738,739,741],{"class":35,"line":99},[33,740,390],{"class":131},[33,742,743],{"class":67}," glog_musl \u002Fapp\u002F\n",[33,745,746],{"class":35,"line":128},[33,747,90],{"emptyLinePlaceholder":89},[33,749,750,752],{"class":35,"line":147},[33,751,390],{"class":131},[33,753,754],{"class":67}," conf.ini \u002Fdata\u002F\n",[33,756,757,759],{"class":35,"line":163},[33,758,390],{"class":131},[33,760,761],{"class":67}," ipname.ini \u002Fdata\u002F\n",[33,763,764],{"class":35,"line":169},[33,765,90],{"emptyLinePlaceholder":89},[33,767,768,770],{"class":35,"line":183},[33,769,480],{"class":131},[33,771,772],{"class":67}," \u002Fapp\n",[33,774,775],{"class":35,"line":206},[33,776,90],{"emptyLinePlaceholder":89},[33,778,779,781],{"class":35,"line":215},[33,780,416],{"class":131},[33,782,783],{"class":67}," chmod 777 \u002Fapp\u002Fglog_musl\n",[33,785,786],{"class":35,"line":220},[33,787,90],{"emptyLinePlaceholder":89},[33,789,790,792,794,797,800,803,805,808],{"class":35,"line":226},[33,791,493],{"class":131},[33,793,496],{"class":67},[33,795,796],{"class":50},"\".\u002Fglog_musl\"",[33,798,799],{"class":67},", ",[33,801,802],{"class":50},"\"logsweb\"",[33,804,799],{"class":67},[33,806,807],{"class":50},"\"\u002Fdata\u002Fconf.ini\"",[33,809,502],{"class":67},[33,811,812],{"class":35,"line":237},[33,813,90],{"emptyLinePlaceholder":89},[33,815,816],{"class":35,"line":248},[33,817,818],{"class":39},"# docker build -t glog:v1 .\n",[33,820,821],{"class":35,"line":253},[33,822,823],{"class":39},"# docker run -p 6801:6801 glog:v1\n",[825,826,827],"style",{},"html pre.shiki code .sHbNN, html code.shiki .sHbNN{--shiki-light:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .sw2iP, html code.shiki .sw2iP{--shiki-light:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .scXbn, html code.shiki .scXbn{--shiki-light:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .suQ91, html code.shiki .suQ91{--shiki-light:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sIX_F, html code.shiki .sIX_F{--shiki-light:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .siTax, html code.shiki .siTax{--shiki-light:#D73A49;--shiki-dark:#F97583}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":28,"searchDepth":43,"depth":86,"links":829},[830,831,834],{"id":14,"depth":43,"text":14},{"id":21,"depth":43,"text":21,"children":832},[833],{"id":283,"depth":86,"text":283},{"id":330,"depth":43,"text":330,"children":835},[836,837,838],{"id":505,"depth":86,"text":505},{"id":644,"depth":86,"text":644},{"id":708,"depth":86,"text":708},"解决 Go 语言跨平台编译问题，使用 Docker + musl 编译静态链接的可执行文件。","进阶",false,"md","\u002Fposts\u002Fdocker-musl-build\u002Fimg\u002Fcover.svg",{},"\u002Fposts\u002Fdocker-musl-build","2025-03-27",{"title":6,"description":839},"Docker 实践笔记","posts\u002Fdocker-musl-build\u002Findex",[851,852,853],"Docker","Go","运维","YLtdOOc-gMVDSNjE-ntKXz-7hff-VtvMB2dB5RXNi1o",{"id":856,"title":857,"author":7,"body":858,"description":1265,"difficulty":1266,"draft":841,"extension":842,"image":1267,"meta":1268,"navigation":89,"path":1269,"pinned":841,"published":1270,"seo":1271,"series":848,"seriesOrder":43,"stem":1272,"tags":1273,"updated":1270,"__hash__":1275},"posts\u002Fposts\u002Fdocker-user-start\u002Findex.md","Docker 构建时指定用户启动",{"type":9,"value":859,"toc":1255},[860,863,866,869,873,876,893,897,930,934,941,991,994,1213,1216,1219,1249,1252],[12,861,862],{"id":862},"前言",[16,864,865],{},"我们在 Linux 使用应用时，往往不会直接使用 root 用户作为应用启动项，但 Dockerfile 默认就是 root 用户，导致挂载生成的文件路径往往是 root 权限，非 root 用户访问就很不方便。",[12,867,868],{"id":868},"解决方案",[281,870,872],{"id":871},"_1-安装用户管理工具","1. 安装用户管理工具",[16,874,875],{},"若镜像非自带用户管理：",[23,877,879],{"className":354,"code":878,"language":356,"meta":28,"style":28},"# 权限管理\nRUN apk add --no-cache shadow\n",[30,880,881,886],{"__ignoreMap":28},[33,882,883],{"class":35,"line":36},[33,884,885],{"class":39},"# 权限管理\n",[33,887,888,890],{"class":35,"line":43},[33,889,416],{"class":131},[33,891,892],{"class":67}," apk add --no-cache shadow\n",[281,894,896],{"id":895},"_2-创建用户并修改权限","2. 创建用户并修改权限",[23,898,900],{"className":354,"code":899,"language":356,"meta":28,"style":28},"# 创建用户组和用户\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",[30,901,902,907,914,918,923],{"__ignoreMap":28},[33,903,904],{"class":35,"line":36},[33,905,906],{"class":39},"# 创建用户组和用户\n",[33,908,909,911],{"class":35,"line":43},[33,910,416],{"class":131},[33,912,913],{"class":67}," addgroup -S \u003Capp_user_group> && adduser -S -G \u003Capp_user_group> \u003Capp_user>\n",[33,915,916],{"class":35,"line":86},[33,917,90],{"emptyLinePlaceholder":89},[33,919,920],{"class":35,"line":93},[33,921,922],{"class":39},"# 修改目录权限\n",[33,924,925,927],{"class":35,"line":99},[33,926,416],{"class":131},[33,928,929],{"class":67}," chown -R \u003Capp_user_group>:\u003Capp_user> \u002Fdata\n",[281,931,933],{"id":932},"_3-指定用户启动","3. 指定用户启动",[16,935,936,937,940],{},"执行 docker 时加 ",[30,938,939],{},"--user"," 参数：",[23,942,944],{"className":25,"code":943,"language":27,"meta":28,"style":28},"docker run -it --user \u003Capp_user> \u003Capp_image>:\u003Cimage_version>\n",[30,945,946],{"__ignoreMap":28},[33,947,948,950,952,954,957,959,962,965,967,969,972,975,977,980,983,986,989],{"class":35,"line":36},[33,949,47],{"class":46},[33,951,51],{"class":50},[33,953,55],{"class":54},[33,955,956],{"class":54}," --user",[33,958,580],{"class":131},[33,960,961],{"class":50},"app_use",[33,963,964],{"class":67},"r",[33,966,589],{"class":131},[33,968,580],{"class":131},[33,970,971],{"class":50},"app_imag",[33,973,974],{"class":67},"e",[33,976,589],{"class":131},[33,978,979],{"class":50},":",[33,981,982],{"class":131},"\u003C",[33,984,985],{"class":50},"image_versio",[33,987,988],{"class":67},"n",[33,990,636],{"class":131},[12,992,993],{"id":993},"完整示例",[23,995,997],{"className":354,"code":996,"language":356,"meta":28,"style":28},"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",[30,998,999,1005,1009,1014,1027,1031,1038,1042,1048,1053,1060,1065,1072,1076,1083,1087,1091,1098,1102,1109,1113,1120,1124,1138,1145,1150,1156,1164,1172,1177,1183,1192],{"__ignoreMap":28},[33,1000,1001,1003],{"class":35,"line":36},[33,1002,363],{"class":131},[33,1004,720],{"class":67},[33,1006,1007],{"class":35,"line":43},[33,1008,90],{"emptyLinePlaceholder":89},[33,1010,1011],{"class":35,"line":86},[33,1012,1013],{"class":39},"# 国内源\n",[33,1015,1016,1018,1021,1024],{"class":35,"line":93},[33,1017,416],{"class":131},[33,1019,1020],{"class":67}," sed -i ",[33,1022,1023],{"class":50},"'s\u002Fdl-cdn.alpinelinux.org\u002Fmirrors.ustc.edu.cn\u002Fg'",[33,1025,1026],{"class":67}," \u002Fetc\u002Fapk\u002Frepositories\n",[33,1028,1029],{"class":35,"line":99},[33,1030,90],{"emptyLinePlaceholder":89},[33,1032,1033,1035],{"class":35,"line":128},[33,1034,416],{"class":131},[33,1036,1037],{"class":67}," apk update --no-cache\n",[33,1039,1040],{"class":35,"line":147},[33,1041,885],{"class":39},[33,1043,1044,1046],{"class":35,"line":163},[33,1045,416],{"class":131},[33,1047,892],{"class":67},[33,1049,1050],{"class":35,"line":169},[33,1051,1052],{"class":39},"# 调试\n",[33,1054,1055,1057],{"class":35,"line":183},[33,1056,416],{"class":131},[33,1058,1059],{"class":67}," apk add --no-cache bash\n",[33,1061,1062],{"class":35,"line":206},[33,1063,1064],{"class":39},"# 设置时区\n",[33,1066,1067,1069],{"class":35,"line":215},[33,1068,416],{"class":131},[33,1070,1071],{"class":67}," apk add --no-cache tzdata\n",[33,1073,1074],{"class":35,"line":220},[33,1075,90],{"emptyLinePlaceholder":89},[33,1077,1078,1080],{"class":35,"line":226},[33,1079,270],{"class":131},[33,1081,1082],{"class":67}," TZ=Asia\u002FShanghai\n",[33,1084,1085],{"class":35,"line":237},[33,1086,90],{"emptyLinePlaceholder":89},[33,1088,1089],{"class":35,"line":248},[33,1090,906],{"class":39},[33,1092,1093,1095],{"class":35,"line":253},[33,1094,416],{"class":131},[33,1096,1097],{"class":67}," addgroup -S glog && adduser -S -G glog glog\n",[33,1099,1100],{"class":35,"line":262},[33,1101,90],{"emptyLinePlaceholder":89},[33,1103,1104,1106],{"class":35,"line":267},[33,1105,390],{"class":131},[33,1107,1108],{"class":67}," .\u002Fglog_static_musl_1 \u002Fapp\u002F\n",[33,1110,1111],{"class":35,"line":477},[33,1112,90],{"emptyLinePlaceholder":89},[33,1114,1115,1117],{"class":35,"line":485},[33,1116,416],{"class":131},[33,1118,1119],{"class":67}," mkdir \u002Fdata\n",[33,1121,1122],{"class":35,"line":490},[33,1123,90],{"emptyLinePlaceholder":89},[33,1125,1127,1129,1132,1135],{"class":35,"line":1126},23,[33,1128,467],{"class":131},[33,1130,1131],{"class":67}," [ ",[33,1133,1134],{"class":50},"\"\u002Fdata\"",[33,1136,1137],{"class":67}," ]\n",[33,1139,1141,1143],{"class":35,"line":1140},24,[33,1142,480],{"class":131},[33,1144,470],{"class":67},[33,1146,1148],{"class":35,"line":1147},25,[33,1149,90],{"emptyLinePlaceholder":89},[33,1151,1153],{"class":35,"line":1152},26,[33,1154,1155],{"class":39},"# 修改权限\n",[33,1157,1159,1161],{"class":35,"line":1158},27,[33,1160,416],{"class":131},[33,1162,1163],{"class":67}," chown -R glog:glog \u002Fdata\n",[33,1165,1167,1169],{"class":35,"line":1166},28,[33,1168,416],{"class":131},[33,1170,1171],{"class":67}," chown -R glog:glog \u002Fapp\n",[33,1173,1175],{"class":35,"line":1174},29,[33,1176,90],{"emptyLinePlaceholder":89},[33,1178,1180],{"class":35,"line":1179},30,[33,1181,1182],{"class":39},"# 指定用户启动\n",[33,1184,1186,1189],{"class":35,"line":1185},31,[33,1187,1188],{"class":131},"USER",[33,1190,1191],{"class":67}," glog\n",[33,1193,1195,1197,1199,1202,1204,1206,1208,1211],{"class":35,"line":1194},32,[33,1196,493],{"class":131},[33,1198,1131],{"class":67},[33,1200,1201],{"class":50},"\"\u002Fapp\u002Fglog_static_musl_1\"",[33,1203,799],{"class":67},[33,1205,802],{"class":50},[33,1207,799],{"class":67},[33,1209,1210],{"class":50},"\".\u002Fconf.ini\"",[33,1212,1137],{"class":67},[12,1214,1215],{"id":1215},"总结",[16,1217,1218],{},"关键步骤：",[285,1220,1221,1227,1237,1243],{},[288,1222,1223,1226],{},[30,1224,1225],{},"apk add --no-cache shadow"," - 安装用户管理工具",[288,1228,1229,1232,1233,1236],{},[30,1230,1231],{},"addgroup"," + ",[30,1234,1235],{},"adduser"," - 创建用户",[288,1238,1239,1242],{},[30,1240,1241],{},"chown -R"," - 修改目录权限",[288,1244,1245,1248],{},[30,1246,1247],{},"USER \u003Cusername>"," - 指定运行用户",[16,1250,1251],{},"这样容器内的应用就不再以 root 身份运行，更加安全。",[825,1253,1254],{},"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":28,"searchDepth":43,"depth":86,"links":1256},[1257,1258,1263,1264],{"id":862,"depth":43,"text":862},{"id":868,"depth":43,"text":868,"children":1259},[1260,1261,1262],{"id":871,"depth":86,"text":872},{"id":895,"depth":86,"text":896},{"id":932,"depth":86,"text":933},{"id":993,"depth":43,"text":993},{"id":1215,"depth":43,"text":1215},"解决 Docker 容器默认以 root 用户运行导致的权限问题，指定非 root 用户启动应用。","入门","\u002Fposts\u002Fdocker-user-start\u002Fimg\u002Fcover.svg",{},"\u002Fposts\u002Fdocker-user-start","2025-04-01",{"title":857,"description":1265},"posts\u002Fdocker-user-start\u002Findex",[851,853,1274],"Linux","b4Uyw5ms87xr7NhpIkavHnNbT20oaEPtXz8k1w6UPj0",{"id":1277,"title":1278,"author":7,"body":1279,"description":1497,"difficulty":7,"draft":841,"extension":842,"image":1498,"meta":1499,"navigation":89,"path":1500,"pinned":841,"published":1501,"seo":1502,"series":7,"seriesOrder":7,"stem":1503,"tags":1504,"updated":7,"__hash__":1506},"posts\u002Fposts\u002Fsveltekit-intro\u002Findex.md","SvelteKit 入门：为什么它比你想象的更简单",{"type":9,"value":1280,"toc":1487},[1281,1285,1288,1294,1297,1301,1309,1361,1365,1371,1379,1383,1386,1389,1465,1467,1478,1484],[12,1282,1284],{"id":1283},"什么是-sveltekit","什么是 SvelteKit？",[16,1286,1287],{},"SvelteKit 是 Svelte 的官方全栈框架，类似于 React 之于 Next.js 的关系。它提供了路由、SSR、SSG 等能力，让开发者可以快速构建现代 Web 应用。",[16,1289,1290],{},[310,1291],{"alt":1292,"src":1293},"SvelteKit Logo","https:\u002F\u002Fraw.githubusercontent.com\u002Fsveltejs\u002Fbranding\u002Fmaster\u002Fsvelte-logo.svg",[12,1295,1296],{"id":1296},"核心特点",[281,1298,1300],{"id":1299},"_1-编译时框架","1. 编译时框架",[16,1302,1303,1304,1308],{},"Svelte 和 React\u002FVue 最大的区别在于：",[1305,1306,1307],"strong",{},"它在编译阶段就把组件转成原生 JS","，运行时没有虚拟 DOM。",[23,1310,1314],{"className":1311,"code":1312,"language":1313,"meta":28,"style":28},"language-typescript shiki shiki-themes github-light github-dark","\u002F\u002F Svelte 组件（编译后变成直接的 DOM 操作）\nlet count = 0\nfunction increment() {\n  count += 1\n}\n","typescript",[30,1315,1316,1321,1334,1345,1356],{"__ignoreMap":28},[33,1317,1318],{"class":35,"line":36},[33,1319,1320],{"class":39},"\u002F\u002F Svelte 组件（编译后变成直接的 DOM 操作）\n",[33,1322,1323,1326,1329,1331],{"class":35,"line":43},[33,1324,1325],{"class":131},"let",[33,1327,1328],{"class":67}," count ",[33,1330,138],{"class":131},[33,1332,1333],{"class":54}," 0\n",[33,1335,1336,1339,1342],{"class":35,"line":86},[33,1337,1338],{"class":131},"function",[33,1340,1341],{"class":46}," increment",[33,1343,1344],{"class":67},"() {\n",[33,1346,1347,1350,1353],{"class":35,"line":93},[33,1348,1349],{"class":67},"  count ",[33,1351,1352],{"class":131},"+=",[33,1354,1355],{"class":54}," 1\n",[33,1357,1358],{"class":35,"line":99},[33,1359,1360],{"class":67},"}\n",[281,1362,1364],{"id":1363},"_2-文件路由","2. 文件路由",[16,1366,1367,1370],{},[30,1368,1369],{},"src\u002Froutes\u002F"," 目录结构即 URL，不需要手动配置路由表：",[23,1372,1377],{"className":1373,"code":1375,"language":1376},[1374],"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",[30,1378,1375],{"__ignoreMap":28},[281,1380,1382],{"id":1381},"_3-全栈能力","3. 全栈能力",[16,1384,1385],{},"SvelteKit 内置了服务端渲染（SSR）、静态站点生成（SSG）、API 路由等功能，开箱即用。",[12,1387,1388],{"id":1388},"和其他框架对比",[1390,1391,1392,1411],"table",{},[1393,1394,1395],"thead",{},[1396,1397,1398,1402,1405,1408],"tr",{},[1399,1400,1401],"th",{},"特性",[1399,1403,1404],{},"SvelteKit",[1399,1406,1407],{},"Next.js",[1399,1409,1410],{},"Nuxt",[1412,1413,1414,1428,1441,1454],"tbody",{},[1396,1415,1416,1420,1423,1426],{},[1417,1418,1419],"td",{},"学习曲线",[1417,1421,1422],{},"低",[1417,1424,1425],{},"中",[1417,1427,1425],{},[1396,1429,1430,1433,1436,1439],{},[1417,1431,1432],{},"包体积",[1417,1434,1435],{},"小",[1417,1437,1438],{},"大",[1417,1440,1425],{},[1396,1442,1443,1446,1449,1452],{},[1417,1444,1445],{},"运行时开销",[1417,1447,1448],{},"无",[1417,1450,1451],{},"有",[1417,1453,1451],{},[1396,1455,1456,1459,1461,1463],{},[1417,1457,1458],{},"生态",[1417,1460,1435],{},[1417,1462,1438],{},[1417,1464,1438],{},[12,1466,1215],{"id":1215},[16,1468,1469,1470,1473,1474,1477],{},"如果你追求",[1305,1471,1472],{},"简洁的代码","和",[1305,1475,1476],{},"极致的性能","，SvelteKit 是一个非常值得尝试的选择。它的学习曲线平缓，写起来就像在写原生 HTML\u002FCSS\u002FJS。",[16,1479,1480],{},[310,1481],{"alt":1482,"src":1483},"Svelte 编译过程","https:\u002F\u002Fsvelte.dev\u002Fsvelte-logo-horizontal.svg",[825,1485,1486],{},"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":28,"searchDepth":43,"depth":86,"links":1488},[1489,1490,1495,1496],{"id":1283,"depth":43,"text":1284},{"id":1296,"depth":43,"text":1296,"children":1491},[1492,1493,1494],{"id":1299,"depth":86,"text":1300},{"id":1363,"depth":86,"text":1364},{"id":1381,"depth":86,"text":1382},{"id":1388,"depth":43,"text":1388},{"id":1215,"depth":43,"text":1215},"SvelteKit 是一个全栈 Svelte 框架，这篇文章带你了解它的核心概念和优势。","\u002Fposts\u002Fsveltekit-intro\u002Fimg\u002Fcover.jpg",{},"\u002Fposts\u002Fsveltekit-intro","2026-05-30",{"title":1278,"description":1497},"posts\u002Fsveltekit-intro\u002Findex",[1404,1505],"前端框架","vsEGyunpCWR7DiJAdyV1SPDfa57xXLITBI-lKM8UeGQ",{"id":1508,"title":1509,"author":7,"body":1510,"description":1913,"difficulty":1266,"draft":841,"extension":842,"image":1914,"meta":1915,"navigation":89,"path":1916,"pinned":89,"published":1917,"seo":1918,"series":1919,"seriesOrder":36,"stem":1920,"tags":1921,"updated":1923,"__hash__":1924},"posts\u002Fposts\u002Fhello-world\u002Findex.md","Hello World - 欢迎使用 SuBlog",{"type":9,"value":1511,"toc":1904},[1512,1516,1523,1526,1529,1532,1606,1609,1612,1675,1678,1681,1747,1750,1816,1819,1822,1838,1845,1848,1879,1882,1893,1896,1901],[12,1513,1515],{"id":1514},"博客上线啦","博客上线啦！",[16,1517,1518,1519,1522],{},"Hello World！欢迎使用 ",[1305,1520,1521],{},"SuBlog","，这是站点的第一篇示例文章。",[16,1524,1525],{},"经过一段时间的折腾，这个基于 Nuxt 3 的个人博客终于上线了。在这里记录一下这个项目的技术选型和功能特性。",[12,1527,1528],{"id":1528},"技术栈",[16,1530,1531],{},"本站使用了以下技术：",[1390,1533,1534,1544],{},[1393,1535,1536],{},[1396,1537,1538,1541],{},[1399,1539,1540],{},"技术",[1399,1542,1543],{},"用途",[1412,1545,1546,1556,1566,1576,1586,1596],{},[1396,1547,1548,1553],{},[1417,1549,1550],{},[1305,1551,1552],{},"Nuxt 3",[1417,1554,1555],{},"Vue 3 全栈框架，提供 SSG、路由、API 等能力",[1396,1557,1558,1563],{},[1417,1559,1560],{},[1305,1561,1562],{},"TypeScript",[1417,1564,1565],{},"类型安全，提升开发体验",[1396,1567,1568,1573],{},[1417,1569,1570],{},[1305,1571,1572],{},"Tailwind CSS v4",[1417,1574,1575],{},"原子化 CSS，快速构建 UI",[1396,1577,1578,1583],{},[1417,1579,1580],{},[1305,1581,1582],{},"@nuxt\u002Fcontent",[1417,1584,1585],{},"Markdown 驱动的内容管理",[1396,1587,1588,1593],{},[1417,1589,1590],{},[1305,1591,1592],{},"Shiki",[1417,1594,1595],{},"代码语法高亮",[1396,1597,1598,1603],{},[1417,1599,1600],{},[1305,1601,1602],{},"Giscus",[1417,1604,1605],{},"基于 GitHub Discussions 的评论系统",[12,1607,1608],{"id":1608},"功能特性",[16,1610,1611],{},"目前已实现的功能：",[1613,1614,1615,1621,1627,1633,1639,1645,1651,1657,1663,1669],"ul",{},[288,1616,1617,1620],{},[1305,1618,1619],{},"博客系统"," - Markdown 文章，支持代码高亮、目录导航、搜索",[288,1622,1623,1626],{},[1305,1624,1625],{},"暗色模式"," - 跟随系统或手动切换",[288,1628,1629,1632],{},[1305,1630,1631],{},"文章搜索"," - 支持标题、描述、正文、标签多维度搜索",[288,1634,1635,1638],{},[1305,1636,1637],{},"标签系统"," - 文章标签分类，高频标签快速筛选",[288,1640,1641,1644],{},[1305,1642,1643],{},"友链页面"," - 展示友情链接，支持申请",[288,1646,1647,1650],{},[1305,1648,1649],{},"赞助页面"," - 微信收款码展示",[288,1652,1653,1656],{},[1305,1654,1655],{},"封面制作"," - 在线生成封面图片工具",[288,1658,1659,1662],{},[1305,1660,1661],{},"网易云热评"," - 首页随机展示音乐评论",[288,1664,1665,1668],{},[1305,1666,1667],{},"访客统计"," - 不蒜子计数器，带数字滚动动画",[288,1670,1671,1674],{},[1305,1672,1673],{},"SEO 优化"," - 自动生成站点地图、Open Graph 标签",[12,1676,1677],{"id":1677},"代码示例",[16,1679,1680],{},"本站的配置非常集中，一个文件搞定全站定制：",[23,1682,1684],{"className":1311,"code":1683,"language":1313,"meta":28,"style":28},"\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",[30,1685,1686,1691,1707,1718,1728,1738,1743],{"__ignoreMap":28},[33,1687,1688],{"class":35,"line":36},[33,1689,1690],{"class":39},"\u002F\u002F config\u002Fsite.ts\n",[33,1692,1693,1695,1698,1701,1704],{"class":35,"line":43},[33,1694,132],{"class":131},[33,1696,1697],{"class":131}," const",[33,1699,1700],{"class":54}," siteConfig",[33,1702,1703],{"class":131}," =",[33,1705,1706],{"class":67}," {\n",[33,1708,1709,1712,1715],{"class":35,"line":86},[33,1710,1711],{"class":67},"  siteName: ",[33,1713,1714],{"class":50},"'我的博客'",[33,1716,1717],{"class":67},",\n",[33,1719,1720,1723,1726],{"class":35,"line":93},[33,1721,1722],{"class":67},"  url: ",[33,1724,1725],{"class":50},"'https:\u002F\u002Fexample.com\u002F'",[33,1727,1717],{"class":67},[33,1729,1730,1733,1736],{"class":35,"line":99},[33,1731,1732],{"class":67},"  avatar: ",[33,1734,1735],{"class":50},"'https:\u002F\u002Fq2.qlogo.cn\u002Fheadimg_dl?dst_uin=xxx&spec=100'",[33,1737,1717],{"class":67},[33,1739,1740],{"class":35,"line":128},[33,1741,1742],{"class":39},"  \u002F\u002F ... 改这一个文件，全站生效\n",[33,1744,1745],{"class":35,"line":147},[33,1746,1360],{"class":67},[16,1748,1749],{},"文章的 frontmatter 也很简洁：",[23,1751,1755],{"className":1752,"code":1753,"language":1754,"meta":28,"style":28},"language-yaml shiki shiki-themes github-light github-dark","---\ntitle: 文章标题\npublished: 2026-05-31T08:00:00\ndescription: 文章简介\ntags: ['Nuxt', 'TypeScript']\n---\n","yaml",[30,1756,1757,1762,1774,1784,1794,1812],{"__ignoreMap":28},[33,1758,1759],{"class":35,"line":36},[33,1760,1761],{"class":46},"---\n",[33,1763,1764,1768,1771],{"class":35,"line":43},[33,1765,1767],{"class":1766},"sbB4o","title",[33,1769,1770],{"class":67},": ",[33,1772,1773],{"class":50},"文章标题\n",[33,1775,1776,1779,1781],{"class":35,"line":86},[33,1777,1778],{"class":1766},"published",[33,1780,1770],{"class":67},[33,1782,1783],{"class":54},"2026-05-31T08:00:00\n",[33,1785,1786,1789,1791],{"class":35,"line":93},[33,1787,1788],{"class":1766},"description",[33,1790,1770],{"class":67},[33,1792,1793],{"class":50},"文章简介\n",[33,1795,1796,1799,1802,1805,1807,1810],{"class":35,"line":99},[33,1797,1798],{"class":1766},"tags",[33,1800,1801],{"class":67},": [",[33,1803,1804],{"class":50},"'Nuxt'",[33,1806,799],{"class":67},[33,1808,1809],{"class":50},"'TypeScript'",[33,1811,502],{"class":67},[33,1813,1814],{"class":35,"line":128},[33,1815,1761],{"class":46},[12,1817,1818],{"id":1818},"部署",[16,1820,1821],{},"本站使用 SSG 静态生成，构建后直接部署到静态托管即可：",[23,1823,1825],{"className":25,"code":1824,"language":27,"meta":28,"style":28},"npx nuxi generate\n",[30,1826,1827],{"__ignoreMap":28},[33,1828,1829,1832,1835],{"class":35,"line":36},[33,1830,1831],{"class":46},"npx",[33,1833,1834],{"class":50}," nuxi",[33,1836,1837],{"class":50}," generate\n",[16,1839,1840,1841,1844],{},"构建产物在 ",[30,1842,1843],{},".output\u002Fpublic"," 目录下，可以直接丢到 Nginx、Vercel、Netlify 等平台。",[12,1846,1847],{"id":1847},"后续计划",[1613,1849,1852,1861,1867,1873],{"className":1850},[1851],"contains-task-list",[288,1853,1856,1860],{"className":1854},[1855],"task-list-item",[1857,1858],"input",{"disabled":89,"type":1859},"checkbox"," RSS 订阅",[288,1862,1864,1866],{"className":1863},[1855],[1857,1865],{"disabled":89,"type":1859}," 文章目录优化",[288,1868,1870,1872],{"className":1869},[1855],[1857,1871],{"disabled":89,"type":1859}," 更多小工具",[288,1874,1876,1878],{"className":1875},[1855],[1857,1877],{"disabled":89,"type":1859}," 持续输出内容",[12,1880,1881],{"id":1881},"致谢",[16,1883,1884,1885,1892],{},"感谢 ",[1886,1887,1891],"a",{"href":1888,"rel":1889},"https:\u002F\u002F2x.nz",[1890],"nofollow","二叉树树"," 的开源项目，本站的 UI 设计参考了他的项目。",[1894,1895],"hr",{},[16,1897,1898],{},[1305,1899,1900],{},"感谢阅读！如果觉得不错，欢迎点个 Star 或者留个评论 (☞ﾟヮﾟ)☞",[825,1902,1903],{},"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":28,"searchDepth":43,"depth":86,"links":1905},[1906,1907,1908,1909,1910,1911,1912],{"id":1514,"depth":43,"text":1515},{"id":1528,"depth":43,"text":1528},{"id":1608,"depth":43,"text":1608},{"id":1677,"depth":43,"text":1677},{"id":1818,"depth":43,"text":1818},{"id":1847,"depth":43,"text":1847},{"id":1881,"depth":43,"text":1881},"博客上线啦！这篇文章介绍本站的技术架构、功能特性和搭建过程中的一些思考。","\u002Fposts\u002Fhello-world\u002Fimg\u002Fcover.jpg",{},"\u002Fposts\u002Fhello-world","2026-05-31T08:00:00",{"title":1509,"description":1913},"Nuxt 博客开发","posts\u002Fhello-world\u002Findex",[1922,1410],"博客","2026-05-31","f9MCboCBfQIUxT8aKv6aKGDFxysiG8ZG0YXr5X5LTFo",{"id":1926,"title":1927,"author":7,"body":1928,"description":3105,"difficulty":840,"draft":841,"extension":842,"image":3106,"meta":3107,"navigation":89,"path":3108,"pinned":841,"published":3109,"seo":3110,"series":1919,"seriesOrder":43,"stem":3111,"tags":3112,"updated":1923,"__hash__":3115},"posts\u002Fposts\u002Fnuxt-content-lessons\u002Findex.md","Nuxt Content v3 踩坑记录：目录、图片与那些反直觉的设计",{"type":9,"value":1929,"toc":3095},[1930,1934,1937,1940,1944,1947,1958,1961,1968,2036,2043,2045,2052,2331,2341,2345,2348,2351,2377,2391,2394,2417,2423,2426,2429,2432,2437,2444,2671,2676,2720,2724,2727,2734,2737,2787,2793,3022,3032,3034,3086,3089,3092],[1931,1932,1933],"h1",{"id":1933},"背景",[16,1935,1936],{},"最近在用 Nuxt 3 + Nuxt Content v3 搭建一个个人博客，参考了一个 SvelteKit 项目（svaf）的架构。原以为换个框架只是语法差异，没想到在几个关键功能上踩了不少坑。",[16,1938,1939],{},"这篇文章记录了三个核心问题的排查过程和最终方案。",[1931,1941,1943],{"id":1942},"问题一目录导航toc只有-h2","问题一：目录导航（TOC）只有 h2",[12,1945,1946],{"id":1946},"现象",[16,1948,1949,1950,1953,1954,1957],{},"Nuxt Content 内置了 TOC 数据，挂在 ",[30,1951,1952],{},"post.body.toc"," 上。直接用它渲染目录，发现",[1305,1955,1956],{},"只有 h2 标题，h3 全部丢失","。",[12,1959,1960],{"id":1960},"排查",[16,1962,1963,1964,1967],{},"查了官方文档，发现 ",[30,1965,1966],{},"build.markdown.toc.depth"," 可以控制深度：",[23,1969,1971],{"className":1311,"code":1970,"language":1313,"meta":28,"style":28},"\u002F\u002F nuxt.config.ts\ncontent: {\n  build: {\n    markdown: {\n      toc: { depth: 3 }  \u002F\u002F 包含 h3\n    }\n  }\n}\n",[30,1972,1973,1978,1986,1993,2000,2022,2027,2032],{"__ignoreMap":28},[33,1974,1975],{"class":35,"line":36},[33,1976,1977],{"class":39},"\u002F\u002F nuxt.config.ts\n",[33,1979,1980,1983],{"class":35,"line":43},[33,1981,1982],{"class":46},"content",[33,1984,1985],{"class":67},": {\n",[33,1987,1988,1991],{"class":35,"line":86},[33,1989,1990],{"class":46},"  build",[33,1992,1985],{"class":67},[33,1994,1995,1998],{"class":35,"line":93},[33,1996,1997],{"class":46},"    markdown",[33,1999,1985],{"class":67},[33,2001,2002,2005,2008,2011,2013,2016,2019],{"class":35,"line":99},[33,2003,2004],{"class":46},"      toc",[33,2006,2007],{"class":67},": { ",[33,2009,2010],{"class":46},"depth",[33,2012,1770],{"class":67},[33,2014,2015],{"class":54},"3",[33,2017,2018],{"class":67}," }  ",[33,2020,2021],{"class":39},"\u002F\u002F 包含 h3\n",[33,2023,2024],{"class":35,"line":128},[33,2025,2026],{"class":67},"    }\n",[33,2028,2029],{"class":35,"line":147},[33,2030,2031],{"class":67},"  }\n",[33,2033,2034],{"class":35,"line":163},[33,2035,1360],{"class":67},[16,2037,2038,2039,2042],{},"配置改了，清了缓存重启，",[1305,2040,2041],{},"依然只有 h2","。这个配置似乎不起作用。",[12,2044,330],{"id":330},[16,2046,2047,2048,2051],{},"放弃依赖 Nuxt Content 的 toc，直接从 ",[30,2049,2050],{},"post.body"," 的 AST 里递归提取所有标题：",[23,2053,2055],{"className":1311,"code":2054,"language":1313,"meta":28,"style":28},"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",[30,2056,2057,2101,2121,2134,2139,2200,2230,2262,2287,2291,2316,2320,2327],{"__ignoreMap":28},[33,2058,2059,2061,2064,2067,2071,2073,2076,2078,2081,2083,2086,2089,2091,2094,2096,2098],{"class":35,"line":36},[33,2060,1338],{"class":131},[33,2062,2063],{"class":46}," extractHeadings",[33,2065,2066],{"class":67},"(",[33,2068,2070],{"class":2069},"sPK6S","node",[33,2072,979],{"class":131},[33,2074,2075],{"class":54}," any",[33,2077,799],{"class":67},[33,2079,2080],{"class":2069},"list",[33,2082,979],{"class":131},[33,2084,2085],{"class":46}," Heading",[33,2087,2088],{"class":67},"[] ",[33,2090,138],{"class":131},[33,2092,2093],{"class":67}," [])",[33,2095,979],{"class":131},[33,2097,2085],{"class":46},[33,2099,2100],{"class":67},"[] {\n",[33,2102,2103,2106,2109,2112,2115,2118],{"class":35,"line":43},[33,2104,2105],{"class":131},"  if",[33,2107,2108],{"class":67}," (",[33,2110,2111],{"class":131},"!",[33,2113,2114],{"class":67},"node) ",[33,2116,2117],{"class":131},"return",[33,2119,2120],{"class":67}," list\n",[33,2122,2123,2125,2128,2131],{"class":35,"line":86},[33,2124,2105],{"class":131},[33,2126,2127],{"class":67}," (Array.",[33,2129,2130],{"class":46},"isArray",[33,2132,2133],{"class":67},"(node)) {\n",[33,2135,2136],{"class":35,"line":93},[33,2137,2138],{"class":39},"    \u002F\u002F minimark 格式：[\"h2\", {id: \"xxx\"}, \"文本\"]\n",[33,2140,2141,2144,2146,2149,2152,2155,2158,2161,2164,2167,2170,2173,2177,2180,2183,2186,2189,2192,2195,2197],{"class":35,"line":99},[33,2142,2143],{"class":131},"    if",[33,2145,2108],{"class":67},[33,2147,2148],{"class":131},"typeof",[33,2150,2151],{"class":67}," node[",[33,2153,2154],{"class":54},"0",[33,2156,2157],{"class":67},"] ",[33,2159,2160],{"class":131},"===",[33,2162,2163],{"class":50}," 'string'",[33,2165,2166],{"class":131}," &&",[33,2168,2169],{"class":50}," \u002F",[33,2171,2172],{"class":131},"^",[33,2174,2176],{"class":2175},"sPKmS","h",[33,2178,2179],{"class":54},"[1-6]",[33,2181,2182],{"class":131},"$",[33,2184,2185],{"class":50},"\u002F",[33,2187,2188],{"class":67},".",[33,2190,2191],{"class":46},"test",[33,2193,2194],{"class":67},"(node[",[33,2196,2154],{"class":54},[33,2198,2199],{"class":67},"])) {\n",[33,2201,2202,2205,2208,2210,2213,2215,2217,2220,2223,2225,2227],{"class":35,"line":128},[33,2203,2204],{"class":131},"      const",[33,2206,2207],{"class":54}," level",[33,2209,1703],{"class":131},[33,2211,2212],{"class":46}," Number",[33,2214,2194],{"class":67},[33,2216,2154],{"class":54},[33,2218,2219],{"class":67},"].",[33,2221,2222],{"class":46},"charAt",[33,2224,2066],{"class":67},[33,2226,157],{"class":54},[33,2228,2229],{"class":67},"))\n",[33,2231,2232,2234,2237,2239,2242,2245,2248,2250,2253,2256,2259],{"class":35,"line":147},[33,2233,2204],{"class":131},[33,2235,2236],{"class":54}," text",[33,2238,1703],{"class":131},[33,2240,2241],{"class":46}," extractText",[33,2243,2244],{"class":67},"(node.",[33,2246,2247],{"class":46},"slice",[33,2249,2066],{"class":67},[33,2251,2252],{"class":54},"2",[33,2254,2255],{"class":67},")).",[33,2257,2258],{"class":46},"trim",[33,2260,2261],{"class":67},"()\n",[33,2263,2264,2267,2270,2273,2275,2278,2281,2284],{"class":35,"line":163},[33,2265,2266],{"class":67},"      list.",[33,2268,2269],{"class":46},"push",[33,2271,2272],{"class":67},"({ id: node[",[33,2274,157],{"class":54},[33,2276,2277],{"class":67},"]?.id ",[33,2279,2280],{"class":131},"||",[33,2282,2283],{"class":46}," slugify",[33,2285,2286],{"class":67},"(text), text, level })\n",[33,2288,2289],{"class":35,"line":169},[33,2290,2026],{"class":67},[33,2292,2293,2296,2298,2301,2304,2307,2310,2313],{"class":35,"line":183},[33,2294,2295],{"class":131},"    for",[33,2297,2108],{"class":67},[33,2299,2300],{"class":131},"const",[33,2302,2303],{"class":54}," child",[33,2305,2306],{"class":131}," of",[33,2308,2309],{"class":67}," node) ",[33,2311,2312],{"class":46},"extractHeadings",[33,2314,2315],{"class":67},"(child, list)\n",[33,2317,2318],{"class":35,"line":206},[33,2319,2031],{"class":67},[33,2321,2322,2325],{"class":35,"line":215},[33,2323,2324],{"class":131},"  return",[33,2326,2120],{"class":67},[33,2328,2329],{"class":35,"line":220},[33,2330,1360],{"class":67},[16,2332,2333,2334,2336,2337,2340],{},"关键点：Nuxt Content v3 的 body 使用 ",[1305,2335,9],{}," 格式，标题节点是数组 ",[30,2338,2339],{},"[\"h2\", {id: \"xxx\"}, \"标题文本\"]","，不是对象。",[1931,2342,2344],{"id":2343},"问题二图片路径解析错误","问题二：图片路径解析错误",[12,2346,1946],{"id":2347},"现象-1",[16,2349,2350],{},"markdown 里用相对路径引用图片：",[23,2352,2356],{"className":2353,"code":2354,"language":2355,"meta":28,"style":28},"language-markdown shiki shiki-themes github-light github-dark","![SSHFS挂载效果](img\u002Fsshfs.avif)\n","markdown",[30,2357,2358],{"__ignoreMap":28},[33,2359,2360,2363,2367,2370,2374],{"class":35,"line":36},[33,2361,2362],{"class":67},"![",[33,2364,2366],{"class":2365},"smiIW","SSHFS挂载效果",[33,2368,2369],{"class":67},"](",[33,2371,2373],{"class":2372},"szbl0","img\u002Fsshfs.avif",[33,2375,2376],{"class":67},")\n",[16,2378,2379,2380,2383,2384,2387,2388,1957],{},"HTML 输出的 ",[30,2381,2382],{},"\u003Cimg src=\"img\u002Fsshfs.avif\">"," 看起来没问题，但浏览器请求的却是 ",[30,2385,2386],{},"\u002Fposts\u002Fimg\u002Fsshfs.avif"," 而不是 ",[30,2389,2390],{},"\u002Fposts\u002Fsshfs\u002Fimg\u002Fsshfs.avif",[12,2392,1960],{"id":2393},"排查-1",[285,2395,2396,2403,2406,2412],{},[288,2397,2398,2399,2402],{},"检查了 ",[30,2400,2401],{},"\u003Cbase>"," 标签 —— 没有",[288,2404,2405],{},"检查了重定向 —— 没有",[288,2407,2408,2409,2411],{},"直接访问 ",[30,2410,2390],{}," —— 返回 200 OK",[288,2413,2408,2414,2416],{},[30,2415,2386],{}," —— 404",[16,2418,2419,2420,1957],{},"服务器路由没问题，问题是",[1305,2421,2422],{},"浏览器解析相对路径时基于了错误的基准 URL",[16,2424,2425],{},"这可能和 Nuxt 的客户端路由有关：SPA 导航时，浏览器的\"当前页面\"和 Vue Router 的\"当前路由\"不同步，导致相对路径解析出错。",[12,2427,330],{"id":2428},"最终方案-1",[16,2430,2431],{},"两步解决：",[16,2433,2434],{},[1305,2435,2436],{},"第一步：自定义 ProseImg 组件",[16,2438,2439,2440,2443],{},"创建 ",[30,2441,2442],{},"components\u002Fcontent\u002FProseImg.vue","，覆盖 Nuxt Content 默认的图片渲染：",[23,2445,2449],{"className":2446,"code":2447,"language":2448,"meta":28,"style":28},"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",[30,2450,2451,2463,2477,2497,2513,2551,2586,2610,2615,2624,2628,2637,2663],{"__ignoreMap":28},[33,2452,2453,2455,2458,2461],{"class":35,"line":36},[33,2454,982],{"class":67},[33,2456,2457],{"class":1766},"script",[33,2459,2460],{"class":46}," setup",[33,2462,636],{"class":67},[33,2464,2465,2467,2470,2472,2475],{"class":35,"line":43},[33,2466,2300],{"class":131},[33,2468,2469],{"class":54}," route",[33,2471,1703],{"class":131},[33,2473,2474],{"class":46}," useRoute",[33,2476,2261],{"class":67},[33,2478,2479,2481,2484,2486,2489,2492,2495],{"class":35,"line":86},[33,2480,2300],{"class":131},[33,2482,2483],{"class":54}," resolvedSrc",[33,2485,1703],{"class":131},[33,2487,2488],{"class":46}," computed",[33,2490,2491],{"class":67},"(() ",[33,2493,2494],{"class":131},"=>",[33,2496,1706],{"class":67},[33,2498,2499,2501,2503,2505,2508,2510],{"class":35,"line":93},[33,2500,2105],{"class":131},[33,2502,2108],{"class":67},[33,2504,2111],{"class":131},[33,2506,2507],{"class":67},"props.src) ",[33,2509,2117],{"class":131},[33,2511,2512],{"class":50}," ''\n",[33,2514,2515,2517,2520,2523,2525,2528,2531,2533,2536,2538,2540,2543,2546,2548],{"class":35,"line":99},[33,2516,2105],{"class":131},[33,2518,2519],{"class":67}," (props.src.",[33,2521,2522],{"class":46},"startsWith",[33,2524,2066],{"class":67},[33,2526,2527],{"class":50},"'\u002F'",[33,2529,2530],{"class":67},") ",[33,2532,2280],{"class":131},[33,2534,2535],{"class":67}," props.src.",[33,2537,2522],{"class":46},[33,2539,2066],{"class":67},[33,2541,2542],{"class":50},"'http'",[33,2544,2545],{"class":67},")) ",[33,2547,2117],{"class":131},[33,2549,2550],{"class":67}," props.src\n",[33,2552,2553,2556,2559,2561,2564,2567,2569,2571,2575,2577,2579,2581,2584],{"class":35,"line":128},[33,2554,2555],{"class":131},"  const",[33,2557,2558],{"class":54}," base",[33,2560,1703],{"class":131},[33,2562,2563],{"class":67}," route.path.",[33,2565,2566],{"class":46},"replace",[33,2568,2066],{"class":67},[33,2570,2185],{"class":50},[33,2572,2574],{"class":2573},"sCnZR","\\\u002F",[33,2576,2182],{"class":131},[33,2578,2185],{"class":50},[33,2580,799],{"class":67},[33,2582,2583],{"class":50},"''",[33,2585,2376],{"class":67},[33,2587,2588,2590,2593,2596,2599,2602,2604,2607],{"class":35,"line":147},[33,2589,2324],{"class":131},[33,2591,2592],{"class":50}," `${",[33,2594,2595],{"class":67},"base",[33,2597,2598],{"class":50},"}\u002F${",[33,2600,2601],{"class":67},"props",[33,2603,2188],{"class":50},[33,2605,2606],{"class":67},"src",[33,2608,2609],{"class":50},"}`\n",[33,2611,2612],{"class":35,"line":163},[33,2613,2614],{"class":67},"})\n",[33,2616,2617,2620,2622],{"class":35,"line":169},[33,2618,2619],{"class":67},"\u003C\u002F",[33,2621,2457],{"class":1766},[33,2623,636],{"class":67},[33,2625,2626],{"class":35,"line":183},[33,2627,90],{"emptyLinePlaceholder":89},[33,2629,2630,2632,2635],{"class":35,"line":206},[33,2631,982],{"class":67},[33,2633,2634],{"class":1766},"template",[33,2636,636],{"class":67},[33,2638,2639,2642,2644,2647,2649,2652,2655,2657,2660],{"class":35,"line":215},[33,2640,2641],{"class":67},"  \u003C",[33,2643,310],{"class":1766},[33,2645,2646],{"class":46}," :src",[33,2648,138],{"class":67},[33,2650,2651],{"class":50},"\"resolvedSrc\"",[33,2653,2654],{"class":46}," :alt",[33,2656,138],{"class":67},[33,2658,2659],{"class":50},"\"alt\"",[33,2661,2662],{"class":67}," \u002F>\n",[33,2664,2665,2667,2669],{"class":35,"line":220},[33,2666,2619],{"class":67},[33,2668,2634],{"class":1766},[33,2670,636],{"class":67},[16,2672,2673],{},[1305,2674,2675],{},"第二步：启用 MDC prose 组件",[23,2677,2679],{"className":1311,"code":2678,"language":1313,"meta":28,"style":28},"\u002F\u002F nuxt.config.ts\nmdc: {\n  components: {\n    prose: true  \u002F\u002F 让 Nuxt Content 使用自定义 ProseImg\n  }\n}\n",[30,2680,2681,2685,2692,2699,2712,2716],{"__ignoreMap":28},[33,2682,2683],{"class":35,"line":36},[33,2684,1977],{"class":39},[33,2686,2687,2690],{"class":35,"line":43},[33,2688,2689],{"class":46},"mdc",[33,2691,1985],{"class":67},[33,2693,2694,2697],{"class":35,"line":86},[33,2695,2696],{"class":46},"  components",[33,2698,1985],{"class":67},[33,2700,2701,2704,2706,2709],{"class":35,"line":93},[33,2702,2703],{"class":46},"    prose",[33,2705,1770],{"class":67},[33,2707,2708],{"class":54},"true",[33,2710,2711],{"class":39},"  \u002F\u002F 让 Nuxt Content 使用自定义 ProseImg\n",[33,2713,2714],{"class":35,"line":99},[33,2715,2031],{"class":67},[33,2717,2718],{"class":35,"line":128},[33,2719,1360],{"class":67},[1931,2721,2723],{"id":2722},"问题三content-目录的图片如何服务","问题三：content 目录的图片如何服务",[12,2725,1946],{"id":2726},"现象-2",[16,2728,2729,2730,2733],{},"原站（SvelteKit）的图片放在 ",[30,2731,2732],{},"content\u002Fposts\u002F{slug}\u002Fimg\u002F"," 下，markdown 用相对路径引用，一切正常。Nuxt 项目里，content 目录的文件不会被当成静态资源服务。",[12,2735,2736],{"id":2736},"方案对比",[1390,2738,2739,2752],{},[1393,2740,2741],{},[1396,2742,2743,2746,2749],{},[1399,2744,2745],{},"方案",[1399,2747,2748],{},"优点",[1399,2750,2751],{},"缺点",[1412,2753,2754,2765,2776],{},[1396,2755,2756,2759,2762],{},[1417,2757,2758],{},"复制到 public\u002F",[1417,2760,2761],{},"简单",[1417,2763,2764],{},"文件重复，需同步",[1396,2766,2767,2770,2773],{},[1417,2768,2769],{},"Vite 插件拦截",[1417,2771,2772],{},"原站方案",[1417,2774,2775],{},"Nuxt 不直接支持",[1396,2777,2778,2781,2784],{},[1417,2779,2780],{},"Server Route",[1417,2782,2783],{},"不复制文件",[1417,2785,2786],{},"需要手动写路由",[16,2788,2789,2790,2792],{},"最终选择 ",[1305,2791,2780],{},"，和原站的 Vite 插件思路一致：",[23,2794,2796],{"className":1311,"code":2795,"language":1313,"meta":28,"style":28},"\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",[30,2797,2798,2803,2829,2844,2908,2922,2926,2948,2974,2992,3011,3018],{"__ignoreMap":28},[33,2799,2800],{"class":35,"line":36},[33,2801,2802],{"class":39},"\u002F\u002F server\u002Froutes\u002Fposts\u002F[slug]\u002Fimg\u002F[...file].ts\n",[33,2804,2805,2807,2810,2813,2815,2818,2820,2823,2825,2827],{"class":35,"line":43},[33,2806,132],{"class":131},[33,2808,2809],{"class":131}," default",[33,2811,2812],{"class":46}," defineEventHandler",[33,2814,2066],{"class":67},[33,2816,2817],{"class":131},"async",[33,2819,2108],{"class":67},[33,2821,2822],{"class":2069},"event",[33,2824,2530],{"class":67},[33,2826,2494],{"class":131},[33,2828,1706],{"class":67},[33,2830,2831,2833,2836,2838,2841],{"class":35,"line":86},[33,2832,2555],{"class":131},[33,2834,2835],{"class":54}," url",[33,2837,1703],{"class":131},[33,2839,2840],{"class":46}," getRequestURL",[33,2842,2843],{"class":67},"(event)\n",[33,2845,2846,2848,2851,2853,2856,2859,2861,2863,2865,2867,2870,2872,2874,2877,2879,2882,2885,2888,2890,2892,2894,2896,2898,2900,2902,2904,2906],{"class":35,"line":93},[33,2847,2555],{"class":131},[33,2849,2850],{"class":54}," match",[33,2852,1703],{"class":131},[33,2854,2855],{"class":67}," url.pathname.",[33,2857,2858],{"class":46},"match",[33,2860,2066],{"class":67},[33,2862,2185],{"class":50},[33,2864,2172],{"class":131},[33,2866,2574],{"class":2573},[33,2868,2869],{"class":2175},"posts",[33,2871,2574],{"class":2573},[33,2873,2066],{"class":2175},[33,2875,2876],{"class":54},"[",[33,2878,2172],{"class":131},[33,2880,2881],{"class":54},"\u002F]",[33,2883,2884],{"class":131},"+",[33,2886,2887],{"class":2175},")",[33,2889,2574],{"class":2573},[33,2891,310],{"class":2175},[33,2893,2574],{"class":2573},[33,2895,2066],{"class":2175},[33,2897,2188],{"class":54},[33,2899,2884],{"class":131},[33,2901,2887],{"class":2175},[33,2903,2182],{"class":131},[33,2905,2185],{"class":50},[33,2907,2376],{"class":67},[33,2909,2910,2912,2914,2916,2919],{"class":35,"line":99},[33,2911,2105],{"class":131},[33,2913,2108],{"class":67},[33,2915,2111],{"class":131},[33,2917,2918],{"class":67},"match) ",[33,2920,2921],{"class":131},"return\n",[33,2923,2924],{"class":35,"line":128},[33,2925,90],{"emptyLinePlaceholder":89},[33,2927,2928,2930,2933,2936,2938,2941,2943,2945],{"class":35,"line":147},[33,2929,2555],{"class":131},[33,2931,2932],{"class":67}," [, ",[33,2934,2935],{"class":54},"slug",[33,2937,799],{"class":67},[33,2939,2940],{"class":54},"filename",[33,2942,2157],{"class":67},[33,2944,138],{"class":131},[33,2946,2947],{"class":67}," match\n",[33,2949,2950,2952,2955,2957,2960,2962,2965,2968,2971],{"class":35,"line":163},[33,2951,2555],{"class":131},[33,2953,2954],{"class":54}," filePath",[33,2956,1703],{"class":131},[33,2958,2959],{"class":46}," resolve",[33,2961,2066],{"class":67},[33,2963,2964],{"class":50},"'content\u002Fposts'",[33,2966,2967],{"class":67},", slug, ",[33,2969,2970],{"class":50},"'img'",[33,2972,2973],{"class":67},", filename)\n",[33,2975,2976,2978,2981,2983,2986,2989],{"class":35,"line":169},[33,2977,2555],{"class":131},[33,2979,2980],{"class":54}," data",[33,2982,1703],{"class":131},[33,2984,2985],{"class":131}," await",[33,2987,2988],{"class":46}," readFile",[33,2990,2991],{"class":67},"(filePath)\n",[33,2993,2994,2997,3000,3003,3005,3008],{"class":35,"line":183},[33,2995,2996],{"class":46},"  setResponseHeader",[33,2998,2999],{"class":67},"(event, ",[33,3001,3002],{"class":50},"'content-type'",[33,3004,799],{"class":67},[33,3006,3007],{"class":54},"MIME",[33,3009,3010],{"class":67},"[ext])\n",[33,3012,3013,3015],{"class":35,"line":206},[33,3014,2324],{"class":131},[33,3016,3017],{"class":67}," data\n",[33,3019,3020],{"class":35,"line":215},[33,3021,2614],{"class":67},[16,3023,3024,3025,3027,3028,3031],{},"请求 ",[30,3026,2390],{}," → 读取 ",[30,3029,3030],{},"content\u002Fposts\u002Fsshfs\u002Fimg\u002Fsshfs.avif"," → 返回图片。",[1931,3033,1215],{"id":1215},[1390,3035,3036,3048],{},[1393,3037,3038],{},[1396,3039,3040,3043,3046],{},[1399,3041,3042],{},"问题",[1399,3044,3045],{},"根因",[1399,3047,2745],{},[1412,3049,3050,3064,3075],{},[1396,3051,3052,3055,3061],{},[1417,3053,3054],{},"TOC 只有 h2",[1417,3056,3057,3060],{},[30,3058,3059],{},"toc.depth"," 配置不生效",[1417,3062,3063],{},"从 body AST 手动提取",[1396,3065,3066,3069,3072],{},[1417,3067,3068],{},"图片路径错误",[1417,3070,3071],{},"SPA 路由导致相对路径解析基准错误",[1417,3073,3074],{},"ProseImg 组件 + mdc.prose 配置",[1396,3076,3077,3080,3083],{},[1417,3078,3079],{},"content 图片 404",[1417,3081,3082],{},"content 目录不暴露给浏览器",[1417,3084,3085],{},"Server Route 直接读取返回",[16,3087,3088],{},"Nuxt Content v3 的设计理念是\"图片放 public\u002F，用绝对路径\"。如果想像 SvelteKit 项目那样把图片和文章放一起，需要额外做不少工作。",[16,3090,3091],{},"不过一旦这些坑都踩过，整体开发体验还是很流畅的。Nuxt 的生态系统、自动导入、模块系统都比 SvelteKit 成熟很多。",[825,3093,3094],{},"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":28,"searchDepth":43,"depth":86,"links":3096},[3097,3098,3099,3100,3101,3102,3103,3104],{"id":1946,"depth":43,"text":1946},{"id":1960,"depth":43,"text":1960},{"id":330,"depth":43,"text":330},{"id":2347,"depth":43,"text":1946},{"id":2393,"depth":43,"text":1960},{"id":2428,"depth":43,"text":330},{"id":2726,"depth":43,"text":1946},{"id":2736,"depth":43,"text":2736},"从 SvelteKit 迁移到 Nuxt 3 的过程中，遇到了不少 Nuxt Content v3 的坑。这篇文章记录了目录导航、图片路径、ProseImg 组件等问题的排查和解决过程。","\u002Fposts\u002Fnuxt-content-lessons\u002Fimg\u002Fcover.jpg",{},"\u002Fposts\u002Fnuxt-content-lessons","2026-05-31T14:00:00",{"title":1927,"description":3105},"posts\u002Fnuxt-content-lessons\u002Findex",[1410,3113,3114],"踩坑","Nuxt Content","vbSt6ND1z7xt7L8FxgZ8Qe3EdxV9InsRerN3E23GC7s",{"id":3117,"title":3118,"author":1521,"body":3119,"description":3384,"difficulty":840,"draft":841,"extension":842,"image":3385,"meta":3386,"navigation":89,"path":3387,"pinned":841,"published":3388,"seo":3389,"series":1919,"seriesOrder":86,"stem":3390,"tags":3391,"updated":1923,"__hash__":3394},"posts\u002Fposts\u002Fnuxt-tailwind-underline-fix\u002Findex.md","Nuxt 3 + Tailwind CSS 客户端导航链接下划线闪烁问题",{"type":9,"value":3120,"toc":3377},[3121,3124,3127,3142,3145,3148,3151,3192,3203,3210,3232,3239,3243,3246,3249,3251,3254,3277,3288,3345,3350,3363,3365,3371,3374],[12,3122,3123],{"id":3123},"问题描述",[16,3125,3126],{},"在 Nuxt 3 项目中使用 Tailwind CSS 时，发现一个奇怪的现象：",[1613,3128,3129,3139],{},[288,3130,3131,3132,3135,3136],{},"首次通过客户端导航（点击 ",[30,3133,3134],{},"\u003CNuxtLink>","）跳转到某个页面时，",[1305,3137,3138],{},"所有链接文字都会出现下划线",[288,3140,3141],{},"手动刷新页面后，下划线消失，样式恢复正常",[16,3143,3144],{},"这个问题在开发模式和生产构建后都会出现。",[12,3146,3147],{"id":3147},"原因分析",[16,3149,3150],{},"Tailwind CSS 的 Preflight（基于 modern-normalize）会重置链接样式：",[23,3152,3156],{"className":3153,"code":3154,"language":3155,"meta":28,"style":28},"language-css shiki shiki-themes github-light github-dark","a {\n  color: inherit;\n  text-decoration: inherit;\n}\n","css",[30,3157,3158,3164,3177,3188],{"__ignoreMap":28},[33,3159,3160,3162],{"class":35,"line":36},[33,3161,1886],{"class":1766},[33,3163,1706],{"class":67},[33,3165,3166,3169,3171,3174],{"class":35,"line":43},[33,3167,3168],{"class":54},"  color",[33,3170,1770],{"class":67},[33,3172,3173],{"class":54},"inherit",[33,3175,3176],{"class":67},";\n",[33,3178,3179,3182,3184,3186],{"class":35,"line":86},[33,3180,3181],{"class":54},"  text-decoration",[33,3183,1770],{"class":67},[33,3185,3173],{"class":54},[33,3187,3176],{"class":67},[33,3189,3190],{"class":35,"line":93},[33,3191,1360],{"class":67},[16,3193,3194,3195,3198,3199,3202],{},"正常情况下，这会让链接继承父元素的 ",[30,3196,3197],{},"text-decoration","（通常是 ",[30,3200,3201],{},"none","），从而去掉浏览器默认的下划线。",[16,3204,3205,3206,3209],{},"问题出在 ",[1305,3207,3208],{},"Nuxt 的客户端导航机制","：",[285,3211,3212,3218,3221,3226,3229],{},[288,3213,3214,3215,3217],{},"用户点击 ",[30,3216,3134],{},"，Vue Router 拦截导航",[288,3219,3220],{},"新页面组件异步加载并渲染",[288,3222,3223],{},[1305,3224,3225],{},"在 CSS 完全加载\u002F应用之前，页面已经渲染了",[288,3227,3228],{},"此时浏览器使用默认样式（链接带下划线）",[288,3230,3231],{},"CSS 加载完成后，下划线消失",[16,3233,3234,3235,3238],{},"这就是典型的 ",[1305,3236,3237],{},"FOUC（Flash of Unstyled Content）"," 问题。",[12,3240,3242],{"id":3241},"为什么刷新就正常了","为什么刷新就正常了？",[16,3244,3245],{},"刷新页面时，浏览器会等待所有 CSS 加载完成后再渲染页面（SSR 模式下 HTML 和 CSS 是一起返回的）。所以刷新后样式是正确的。",[16,3247,3248],{},"而客户端导航是 JavaScript 驱动的，CSS 的加载和页面的渲染是异步的，就出现了时序差。",[12,3250,868],{"id":868},[16,3252,3253],{},"尝试了多种方案：",[1613,3255,3256,3262,3268,3274],{},[288,3257,3258,3261],{},[30,3259,3260],{},"@layer base"," 覆盖 — 无效，CSS layers 的优先级问题",[288,3263,3264,3267],{},[30,3265,3266],{},"corePlugins.preflight: false"," — 无效，且破坏其他基础样式",[288,3269,3270,3273],{},[30,3271,3272],{},"features.inlineStyles: true"," — 无效",[288,3275,3276],{},"页面过渡动画 — 只是掩盖，不能根治",[16,3278,3279,3280,3283,3284,3287],{},"最终有效的方案是在 ",[30,3281,3282],{},"app.vue"," 中用 ",[30,3285,3286],{},"!important"," 强制覆盖：",[23,3289,3291],{"className":2446,"code":3290,"language":2448,"meta":28,"style":28},"\u003Cstyle>\na:not(.prose a) {\n  text-decoration-line: none !important;\n}\n\u003C\u002Fstyle>\n",[30,3292,3293,3301,3319,3333,3337],{"__ignoreMap":28},[33,3294,3295,3297,3299],{"class":35,"line":36},[33,3296,982],{"class":67},[33,3298,825],{"class":1766},[33,3300,636],{"class":67},[33,3302,3303,3305,3308,3310,3313,3316],{"class":35,"line":43},[33,3304,1886],{"class":1766},[33,3306,3307],{"class":46},":not",[33,3309,2066],{"class":67},[33,3311,3312],{"class":46},".prose",[33,3314,3315],{"class":1766}," a",[33,3317,3318],{"class":67},") {\n",[33,3320,3321,3324,3326,3328,3331],{"class":35,"line":86},[33,3322,3323],{"class":54},"  text-decoration-line",[33,3325,1770],{"class":67},[33,3327,3201],{"class":54},[33,3329,3330],{"class":131}," !important",[33,3332,3176],{"class":67},[33,3334,3335],{"class":35,"line":93},[33,3336,1360],{"class":67},[33,3338,3339,3341,3343],{"class":35,"line":99},[33,3340,2619],{"class":67},[33,3342,825],{"class":1766},[33,3344,636],{"class":67},[16,3346,3347],{},[1305,3348,3349],{},"关键点：",[1613,3351,3352,3357],{},[288,3353,3354,3356],{},[30,3355,3286],{}," 是为了对抗 Tailwind CSS layers 的优先级",[288,3358,3359,3362],{},[30,3360,3361],{},":not(.prose a)"," 排除文章正文区域，保留文章内链接的下划线（可读性需要）",[12,3364,1215],{"id":1215},[16,3366,3367,3368,3370],{},"这是 Nuxt 3 + Tailwind CSS 的一个已知问题，本质上是 CSS 加载时序导致的 FOUC。",[30,3369,3286],{}," 虽然不够优雅，但在这个场景下是务实的解决方案。",[16,3372,3373],{},"如果你有更好的方案，欢迎在项目仓库的 Issues 中提出！",[825,3375,3376],{},"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":28,"searchDepth":43,"depth":86,"links":3378},[3379,3380,3381,3382,3383],{"id":3123,"depth":43,"text":3123},{"id":3147,"depth":43,"text":3147},{"id":3241,"depth":43,"text":3242},{"id":868,"depth":43,"text":868},{"id":1215,"depth":43,"text":1215},"记录 Nuxt 3 配合 Tailwind CSS 时，客户端导航导致链接文字出现下划线闪烁的问题及解决方案。","\u002Fposts\u002Fnuxt-tailwind-underline-fix\u002Fimg\u002Fcover.png",{},"\u002Fposts\u002Fnuxt-tailwind-underline-fix","2026-05-31T18:47:00",{"title":3118,"description":3384},"posts\u002Fnuxt-tailwind-underline-fix\u002Findex",[1410,3392,3393,3113],"Tailwind CSS","CSS","WMLpTEsIoJqufwEfXyIiBgUYxcPY2XkvOwqAD4hbe6g",{"id":3396,"title":3397,"author":7,"body":3398,"description":5449,"difficulty":840,"draft":841,"extension":842,"image":5450,"meta":5451,"navigation":89,"path":5452,"pinned":841,"published":5453,"seo":5454,"series":5455,"seriesOrder":36,"stem":5456,"tags":5457,"updated":5462,"__hash__":5463},"posts\u002Fposts\u002Fgo-mcp-gin\u002Findex.md","Go-MCP 接入 Gin：从零搭建 MCP 服务器",{"type":9,"value":3399,"toc":5430},[3400,3404,3407,3410,3414,3421,3427,3430,3433,3453,3456,3460,3484,3488,3588,3601,3605,3769,3772,3989,3993,4004,4103,4110,4114,4388,4391,4472,4476,4479,4481,4492,4496,4502,4869,5059,5062,5118,5121,5274,5277,5280,5351,5353,5418,5427],[12,3401,3403],{"id":3402},"什么是-mcp","什么是 MCP",[16,3405,3406],{},"MCP（Model Context Protocol）是 Anthropic 提出的开放协议，用于让 AI 助手（如 Claude）与外部服务进行标准化交互。简单来说：你提供一个 MCP 服务器，AI 就能发现并调用你定义的工具（Tools），就像给 AI 装了一双手。",[16,3408,3409],{},"传统做法是写 REST API 然后让 AI 通过函数调用（Function Calling）间接使用，但 MCP 统一了协议层，AI 客户端只需要配置一个 URL 就能接入所有工具。",[12,3411,3413],{"id":3412},"为什么选-gin-mcp-go","为什么选 Gin + mcp-go",[16,3415,3416,3417,3420],{},"Go 生态里做 MCP 服务器最成熟的库是 ",[30,3418,3419],{},"mark3labs\u002Fmcp-go","，它支持 StreamableHTTP 传输模式，可以直接挂载到任意 HTTP 框架上。Gin 作为 Go 最流行的 HTTP 框架，天然适配。",[16,3422,3423,3424],{},"核心思路：",[1305,3425,3426],{},"MCP 服务器本质上就是一个 HTTP Handler，挂到 Gin 路由上即可。",[12,3428,3429],{"id":3429},"需求分析",[16,3431,3432],{},"我们要搭建一个具备以下能力的 MCP 服务器：",[285,3434,3435,3441,3447],{},[288,3436,3437,3440],{},[1305,3438,3439],{},"工具注册"," — 向 AI 暴露可调用的工具（列出资源、查看详情、创建\u002F删除）",[288,3442,3443,3446],{},[1305,3444,3445],{},"HTTP 集成"," — MCP 端点作为 Gin 路由的一部分，与业务 API 共存",[288,3448,3449,3452],{},[1305,3450,3451],{},"鉴权"," — MCP 请求必须经过身份验证，工具处理器能获取当前用户信息",[12,3454,3455],{"id":3455},"实现步骤",[281,3457,3459],{"id":3458},"_1-安装依赖","1. 安装依赖",[23,3461,3463],{"className":25,"code":3462,"language":27,"meta":28,"style":28},"go get github.com\u002Fmark3labs\u002Fmcp-go@v0.54.0\ngo get github.com\u002Fgin-gonic\u002Fgin@v1.12.0\n",[30,3464,3465,3475],{"__ignoreMap":28},[33,3466,3467,3469,3472],{"class":35,"line":36},[33,3468,172],{"class":46},[33,3470,3471],{"class":50}," get",[33,3473,3474],{"class":50}," github.com\u002Fmark3labs\u002Fmcp-go@v0.54.0\n",[33,3476,3477,3479,3481],{"class":35,"line":43},[33,3478,172],{"class":46},[33,3480,3471],{"class":50},[33,3482,3483],{"class":50}," github.com\u002Fgin-gonic\u002Fgin@v1.12.0\n",[281,3485,3487],{"id":3486},"_2-创建-mcp-服务器","2. 创建 MCP 服务器",[23,3489,3492],{"className":3490,"code":3491,"language":172,"meta":28,"style":28},"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",[30,3493,3494,3506,3510,3527,3538,3549,3560,3569,3584],{"__ignoreMap":28},[33,3495,3496,3499,3501,3504],{"class":35,"line":36},[33,3497,3498],{"class":131},"import",[33,3500,64],{"class":50},[33,3502,3503],{"class":46},"github.com\u002Fmark3labs\u002Fmcp-go\u002Fserver",[33,3505,279],{"class":50},[33,3507,3508],{"class":35,"line":43},[33,3509,90],{"emptyLinePlaceholder":89},[33,3511,3512,3515,3518,3521,3524],{"class":35,"line":86},[33,3513,3514],{"class":67},"mcpServer ",[33,3516,3517],{"class":131},":=",[33,3519,3520],{"class":67}," server.",[33,3522,3523],{"class":46},"NewMCPServer",[33,3525,3526],{"class":67},"(\n",[33,3528,3529,3532,3535],{"class":35,"line":93},[33,3530,3531],{"class":50},"    \"my-app\"",[33,3533,3534],{"class":67},",    ",[33,3536,3537],{"class":39},"\u002F\u002F 服务器名称\n",[33,3539,3540,3543,3546],{"class":35,"line":99},[33,3541,3542],{"class":50},"    \"1.0.0\"",[33,3544,3545],{"class":67},",     ",[33,3547,3548],{"class":39},"\u002F\u002F 版本\n",[33,3550,3551,3554,3557],{"class":35,"line":128},[33,3552,3553],{"class":67},"    server.",[33,3555,3556],{"class":46},"WithLogging",[33,3558,3559],{"class":67},"(),\n",[33,3561,3562,3564,3567],{"class":35,"line":147},[33,3563,3553],{"class":67},[33,3565,3566],{"class":46},"WithRecovery",[33,3568,3559],{"class":67},[33,3570,3571,3573,3576,3578,3581],{"class":35,"line":163},[33,3572,3553],{"class":67},[33,3574,3575],{"class":46},"WithInstructions",[33,3577,2066],{"class":67},[33,3579,3580],{"class":50},"\"这是一个 MCP 服务器示例\"",[33,3582,3583],{"class":67},"),\n",[33,3585,3586],{"class":35,"line":169},[33,3587,2376],{"class":67},[16,3589,3590,3592,3593,3596,3597,3600],{},[30,3591,3523],{}," 创建一个 MCP 协议实例，它负责处理工具发现（",[30,3594,3595],{},"tools\u002Flist","）和工具调用（",[30,3598,3599],{},"tools\u002Fcall","）的协议逻辑。",[281,3602,3604],{"id":3603},"_3-注册工具","3. 注册工具",[23,3606,3608],{"className":3490,"code":3607,"language":172,"meta":28,"style":28},"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",[30,3609,3610,3621,3625,3630,3650,3665,3669,3673,3678,3739,3744,3765],{"__ignoreMap":28},[33,3611,3612,3614,3616,3619],{"class":35,"line":36},[33,3613,3498],{"class":131},[33,3615,64],{"class":50},[33,3617,3618],{"class":46},"github.com\u002Fmark3labs\u002Fmcp-go\u002Fmcp",[33,3620,279],{"class":50},[33,3622,3623],{"class":35,"line":43},[33,3624,90],{"emptyLinePlaceholder":89},[33,3626,3627],{"class":35,"line":86},[33,3628,3629],{"class":39},"\u002F\u002F 定义工具\n",[33,3631,3632,3635,3637,3640,3643,3645,3648],{"class":35,"line":93},[33,3633,3634],{"class":67},"listTool ",[33,3636,3517],{"class":131},[33,3638,3639],{"class":67}," mcp.",[33,3641,3642],{"class":46},"NewTool",[33,3644,2066],{"class":67},[33,3646,3647],{"class":50},"\"list_items\"",[33,3649,1717],{"class":67},[33,3651,3652,3655,3658,3660,3663],{"class":35,"line":99},[33,3653,3654],{"class":67},"    mcp.",[33,3656,3657],{"class":46},"WithDescription",[33,3659,2066],{"class":67},[33,3661,3662],{"class":50},"\"列出所有项目\"",[33,3664,3583],{"class":67},[33,3666,3667],{"class":35,"line":128},[33,3668,2376],{"class":67},[33,3670,3671],{"class":35,"line":147},[33,3672,90],{"emptyLinePlaceholder":89},[33,3674,3675],{"class":35,"line":163},[33,3676,3677],{"class":39},"\u002F\u002F 注册处理器\n",[33,3679,3680,3683,3686,3689,3692,3694,3697,3700,3702,3705,3707,3710,3713,3715,3718,3721,3724,3727,3729,3732,3734,3737],{"class":35,"line":169},[33,3681,3682],{"class":67},"mcpServer.",[33,3684,3685],{"class":46},"AddTool",[33,3687,3688],{"class":67},"(listTool, ",[33,3690,3691],{"class":131},"func",[33,3693,2066],{"class":67},[33,3695,3696],{"class":2069},"ctx",[33,3698,3699],{"class":46}," context",[33,3701,2188],{"class":67},[33,3703,3704],{"class":46},"Context",[33,3706,799],{"class":67},[33,3708,3709],{"class":2069},"req",[33,3711,3712],{"class":46}," mcp",[33,3714,2188],{"class":67},[33,3716,3717],{"class":46},"CallToolRequest",[33,3719,3720],{"class":67},") (",[33,3722,3723],{"class":131},"*",[33,3725,3726],{"class":46},"mcp",[33,3728,2188],{"class":67},[33,3730,3731],{"class":46},"CallToolResult",[33,3733,799],{"class":67},[33,3735,3736],{"class":131},"error",[33,3738,3318],{"class":67},[33,3740,3741],{"class":35,"line":183},[33,3742,3743],{"class":39},"    \u002F\u002F 业务逻辑\n",[33,3745,3746,3749,3751,3754,3756,3759,3762],{"class":35,"line":206},[33,3747,3748],{"class":131},"    return",[33,3750,3639],{"class":67},[33,3752,3753],{"class":46},"NewToolResultText",[33,3755,2066],{"class":67},[33,3757,3758],{"class":50},"\"返回结果\"",[33,3760,3761],{"class":67},"), ",[33,3763,3764],{"class":54},"nil\n",[33,3766,3767],{"class":35,"line":215},[33,3768,2614],{"class":67},[16,3770,3771],{},"带参数的工具：",[23,3773,3775],{"className":3490,"code":3774,"language":172,"meta":28,"style":28},"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",[30,3776,3777,3795,3808,3822,3832,3846,3851,3855,3859,3906,3925,3940,3959,3963,3968,3985],{"__ignoreMap":28},[33,3778,3779,3782,3784,3786,3788,3790,3793],{"class":35,"line":36},[33,3780,3781],{"class":67},"getTool ",[33,3783,3517],{"class":131},[33,3785,3639],{"class":67},[33,3787,3642],{"class":46},[33,3789,2066],{"class":67},[33,3791,3792],{"class":50},"\"get_item\"",[33,3794,1717],{"class":67},[33,3796,3797,3799,3801,3803,3806],{"class":35,"line":43},[33,3798,3654],{"class":67},[33,3800,3657],{"class":46},[33,3802,2066],{"class":67},[33,3804,3805],{"class":50},"\"获取指定项目的详情\"",[33,3807,3583],{"class":67},[33,3809,3810,3812,3815,3817,3820],{"class":35,"line":86},[33,3811,3654],{"class":67},[33,3813,3814],{"class":46},"WithString",[33,3816,2066],{"class":67},[33,3818,3819],{"class":50},"\"item_id\"",[33,3821,1717],{"class":67},[33,3823,3824,3827,3830],{"class":35,"line":93},[33,3825,3826],{"class":67},"        mcp.",[33,3828,3829],{"class":46},"Required",[33,3831,3559],{"class":67},[33,3833,3834,3836,3839,3841,3844],{"class":35,"line":99},[33,3835,3826],{"class":67},[33,3837,3838],{"class":46},"Description",[33,3840,2066],{"class":67},[33,3842,3843],{"class":50},"\"项目 ID\"",[33,3845,3583],{"class":67},[33,3847,3848],{"class":35,"line":128},[33,3849,3850],{"class":67},"    ),\n",[33,3852,3853],{"class":35,"line":147},[33,3854,2376],{"class":67},[33,3856,3857],{"class":35,"line":163},[33,3858,90],{"emptyLinePlaceholder":89},[33,3860,3861,3863,3865,3868,3870,3872,3874,3876,3878,3880,3882,3884,3886,3888,3890,3892,3894,3896,3898,3900,3902,3904],{"class":35,"line":169},[33,3862,3682],{"class":67},[33,3864,3685],{"class":46},[33,3866,3867],{"class":67},"(getTool, ",[33,3869,3691],{"class":131},[33,3871,2066],{"class":67},[33,3873,3696],{"class":2069},[33,3875,3699],{"class":46},[33,3877,2188],{"class":67},[33,3879,3704],{"class":46},[33,3881,799],{"class":67},[33,3883,3709],{"class":2069},[33,3885,3712],{"class":46},[33,3887,2188],{"class":67},[33,3889,3717],{"class":46},[33,3891,3720],{"class":67},[33,3893,3723],{"class":131},[33,3895,3726],{"class":46},[33,3897,2188],{"class":67},[33,3899,3731],{"class":46},[33,3901,799],{"class":67},[33,3903,3736],{"class":131},[33,3905,3318],{"class":67},[33,3907,3908,3911,3913,3916,3919,3921,3923],{"class":35,"line":183},[33,3909,3910],{"class":67},"    itemID, err ",[33,3912,3517],{"class":131},[33,3914,3915],{"class":67}," req.",[33,3917,3918],{"class":46},"RequireString",[33,3920,2066],{"class":67},[33,3922,3819],{"class":50},[33,3924,2376],{"class":67},[33,3926,3927,3929,3932,3935,3938],{"class":35,"line":206},[33,3928,2143],{"class":131},[33,3930,3931],{"class":67}," err ",[33,3933,3934],{"class":131},"!=",[33,3936,3937],{"class":54}," nil",[33,3939,1706],{"class":67},[33,3941,3942,3945,3947,3950,3952,3955,3957],{"class":35,"line":215},[33,3943,3944],{"class":131},"        return",[33,3946,3639],{"class":67},[33,3948,3949],{"class":46},"NewToolResultError",[33,3951,2066],{"class":67},[33,3953,3954],{"class":50},"\"缺少 item_id 参数\"",[33,3956,3761],{"class":67},[33,3958,3764],{"class":54},[33,3960,3961],{"class":35,"line":220},[33,3962,2026],{"class":67},[33,3964,3965],{"class":35,"line":226},[33,3966,3967],{"class":39},"    \u002F\u002F 查询并返回\n",[33,3969,3970,3972,3974,3976,3978,3981,3983],{"class":35,"line":237},[33,3971,3748],{"class":131},[33,3973,3639],{"class":67},[33,3975,3753],{"class":46},[33,3977,2066],{"class":67},[33,3979,3980],{"class":50},"\"项目详情...\"",[33,3982,3761],{"class":67},[33,3984,3764],{"class":54},[33,3986,3987],{"class":35,"line":248},[33,3988,2614],{"class":67},[281,3990,3992],{"id":3991},"_4-挂载到-gin-路由","4. 挂载到 Gin 路由",[16,3994,3995,3996,3999,4000,4003],{},"关键一步：用 ",[30,3997,3998],{},"server.NewStreamableHTTPServer"," 把 MCP 实例转成 HTTP Handler，再用 ",[30,4001,4002],{},"gin.WrapH"," 适配 Gin。",[23,4005,4007],{"className":3490,"code":4006,"language":172,"meta":28,"style":28},"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",[30,4008,4009,4019,4023,4038,4052,4069,4073,4077,4082],{"__ignoreMap":28},[33,4010,4011,4013,4015,4017],{"class":35,"line":36},[33,4012,3498],{"class":131},[33,4014,64],{"class":50},[33,4016,3503],{"class":46},[33,4018,279],{"class":50},[33,4020,4021],{"class":35,"line":43},[33,4022,90],{"emptyLinePlaceholder":89},[33,4024,4025,4028,4030,4032,4035],{"class":35,"line":86},[33,4026,4027],{"class":67},"mcpHTTPServer ",[33,4029,3517],{"class":131},[33,4031,3520],{"class":67},[33,4033,4034],{"class":46},"NewStreamableHTTPServer",[33,4036,4037],{"class":67},"(mcpServer,\n",[33,4039,4040,4042,4045,4047,4050],{"class":35,"line":93},[33,4041,3553],{"class":67},[33,4043,4044],{"class":46},"WithEndpointPath",[33,4046,2066],{"class":67},[33,4048,4049],{"class":50},"\"\u002Fmcp\"",[33,4051,3583],{"class":67},[33,4053,4054,4056,4059,4061,4064,4066],{"class":35,"line":99},[33,4055,3553],{"class":67},[33,4057,4058],{"class":46},"WithHeartbeatInterval",[33,4060,2066],{"class":67},[33,4062,4063],{"class":54},"30",[33,4065,3723],{"class":131},[33,4067,4068],{"class":67},"time.Second),\n",[33,4070,4071],{"class":35,"line":128},[33,4072,2376],{"class":67},[33,4074,4075],{"class":35,"line":147},[33,4076,90],{"emptyLinePlaceholder":89},[33,4078,4079],{"class":35,"line":163},[33,4080,4081],{"class":39},"\u002F\u002F 挂载到 Gin\n",[33,4083,4084,4087,4090,4092,4094,4097,4100],{"class":35,"line":169},[33,4085,4086],{"class":67},"r.",[33,4088,4089],{"class":46},"Any",[33,4091,2066],{"class":67},[33,4093,4049],{"class":50},[33,4095,4096],{"class":67},", gin.",[33,4098,4099],{"class":46},"WrapH",[33,4101,4102],{"class":67},"(mcpHTTPServer))\n",[16,4104,4105,4106,4109],{},"就这样，",[30,4107,4108],{},"POST \u002Fmcp"," 端点就生效了。AI 客户端通过这个 URL 发送 JSON-RPC 请求，MCP 服务器自动处理协议握手、工具列表返回、工具调用路由。",[281,4111,4113],{"id":4112},"_5-完整的服务启动代码","5. 完整的服务启动代码",[23,4115,4117],{"className":3490,"code":4116,"language":172,"meta":28,"style":28},"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",[30,4118,4119,4128,4143,4147,4152,4175,4184,4192,4197,4201,4206,4214,4218,4223,4236,4248,4262,4266,4284,4288,4293,4326,4361,4366,4370,4384],{"__ignoreMap":28},[33,4120,4121,4123,4126],{"class":35,"line":36},[33,4122,3691],{"class":131},[33,4124,4125],{"class":46}," main",[33,4127,1344],{"class":67},[33,4129,4130,4133,4135,4138,4141],{"class":35,"line":43},[33,4131,4132],{"class":67},"    r ",[33,4134,3517],{"class":131},[33,4136,4137],{"class":67}," gin.",[33,4139,4140],{"class":46},"Default",[33,4142,2261],{"class":67},[33,4144,4145],{"class":35,"line":86},[33,4146,90],{"emptyLinePlaceholder":89},[33,4148,4149],{"class":35,"line":93},[33,4150,4151],{"class":39},"    \u002F\u002F 创建 MCP 服务器\n",[33,4153,4154,4157,4159,4161,4163,4165,4168,4170,4173],{"class":35,"line":99},[33,4155,4156],{"class":67},"    mcpServer ",[33,4158,3517],{"class":131},[33,4160,3520],{"class":67},[33,4162,3523],{"class":46},[33,4164,2066],{"class":67},[33,4166,4167],{"class":50},"\"my-app\"",[33,4169,799],{"class":67},[33,4171,4172],{"class":50},"\"1.0.0\"",[33,4174,1717],{"class":67},[33,4176,4177,4180,4182],{"class":35,"line":128},[33,4178,4179],{"class":67},"        server.",[33,4181,3556],{"class":46},[33,4183,3559],{"class":67},[33,4185,4186,4188,4190],{"class":35,"line":147},[33,4187,4179],{"class":67},[33,4189,3566],{"class":46},[33,4191,3559],{"class":67},[33,4193,4194],{"class":35,"line":163},[33,4195,4196],{"class":67},"    )\n",[33,4198,4199],{"class":35,"line":169},[33,4200,90],{"emptyLinePlaceholder":89},[33,4202,4203],{"class":35,"line":183},[33,4204,4205],{"class":39},"    \u002F\u002F 注册工具\n",[33,4207,4208,4211],{"class":35,"line":206},[33,4209,4210],{"class":46},"    registerTools",[33,4212,4213],{"class":67},"(mcpServer)\n",[33,4215,4216],{"class":35,"line":215},[33,4217,90],{"emptyLinePlaceholder":89},[33,4219,4220],{"class":35,"line":220},[33,4221,4222],{"class":39},"    \u002F\u002F 挂载到 Gin\n",[33,4224,4225,4228,4230,4232,4234],{"class":35,"line":226},[33,4226,4227],{"class":67},"    mcpHTTP ",[33,4229,3517],{"class":131},[33,4231,3520],{"class":67},[33,4233,4034],{"class":46},[33,4235,4037],{"class":67},[33,4237,4238,4240,4242,4244,4246],{"class":35,"line":237},[33,4239,4179],{"class":67},[33,4241,4044],{"class":46},[33,4243,2066],{"class":67},[33,4245,4049],{"class":50},[33,4247,3583],{"class":67},[33,4249,4250,4252,4254,4256,4258,4260],{"class":35,"line":248},[33,4251,4179],{"class":67},[33,4253,4058],{"class":46},[33,4255,2066],{"class":67},[33,4257,4063],{"class":54},[33,4259,3723],{"class":131},[33,4261,4068],{"class":67},[33,4263,4264],{"class":35,"line":253},[33,4265,4196],{"class":67},[33,4267,4268,4271,4273,4275,4277,4279,4281],{"class":35,"line":262},[33,4269,4270],{"class":67},"    r.",[33,4272,4089],{"class":46},[33,4274,2066],{"class":67},[33,4276,4049],{"class":50},[33,4278,4096],{"class":67},[33,4280,4099],{"class":46},[33,4282,4283],{"class":67},"(mcpHTTP))\n",[33,4285,4286],{"class":35,"line":267},[33,4287,90],{"emptyLinePlaceholder":89},[33,4289,4290],{"class":35,"line":477},[33,4291,4292],{"class":39},"    \u002F\u002F 其他业务路由\n",[33,4294,4295,4297,4300,4302,4305,4307,4309,4311,4314,4317,4320,4322,4324],{"class":35,"line":485},[33,4296,4270],{"class":67},[33,4298,4299],{"class":46},"GET",[33,4301,2066],{"class":67},[33,4303,4304],{"class":50},"\"\u002Fapi\u002Fhealth\"",[33,4306,799],{"class":67},[33,4308,3691],{"class":131},[33,4310,2066],{"class":67},[33,4312,4313],{"class":2069},"c",[33,4315,4316],{"class":131}," *",[33,4318,4319],{"class":46},"gin",[33,4321,2188],{"class":67},[33,4323,3704],{"class":46},[33,4325,3318],{"class":67},[33,4327,4328,4331,4334,4336,4339,4341,4343,4345,4348,4351,4354,4356,4359],{"class":35,"line":490},[33,4329,4330],{"class":67},"        c.",[33,4332,4333],{"class":46},"JSON",[33,4335,2066],{"class":67},[33,4337,4338],{"class":54},"200",[33,4340,799],{"class":67},[33,4342,4319],{"class":46},[33,4344,2188],{"class":67},[33,4346,4347],{"class":46},"H",[33,4349,4350],{"class":67},"{",[33,4352,4353],{"class":50},"\"status\"",[33,4355,1770],{"class":67},[33,4357,4358],{"class":50},"\"ok\"",[33,4360,2614],{"class":67},[33,4362,4363],{"class":35,"line":1126},[33,4364,4365],{"class":67},"    })\n",[33,4367,4368],{"class":35,"line":1140},[33,4369,90],{"emptyLinePlaceholder":89},[33,4371,4372,4374,4377,4379,4382],{"class":35,"line":1147},[33,4373,4270],{"class":67},[33,4375,4376],{"class":46},"Run",[33,4378,2066],{"class":67},[33,4380,4381],{"class":50},"\":8080\"",[33,4383,2376],{"class":67},[33,4385,4386],{"class":35,"line":1152},[33,4387,1360],{"class":67},[16,4389,4390],{},"此时启动服务，用 curl 测试：",[23,4392,4394],{"className":25,"code":4393,"language":27,"meta":28,"style":28},"# 发现工具列表\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",[30,4395,4396,4401,4418,4428,4436,4440,4445,4457,4465],{"__ignoreMap":28},[33,4397,4398],{"class":35,"line":36},[33,4399,4400],{"class":39},"# 发现工具列表\n",[33,4402,4403,4406,4409,4412,4415],{"class":35,"line":43},[33,4404,4405],{"class":46},"curl",[33,4407,4408],{"class":54}," -X",[33,4410,4411],{"class":50}," POST",[33,4413,4414],{"class":50}," http:\u002F\u002Flocalhost:8080\u002Fmcp",[33,4416,4417],{"class":54}," \\\n",[33,4419,4420,4423,4426],{"class":35,"line":86},[33,4421,4422],{"class":54},"  -H",[33,4424,4425],{"class":50}," \"Content-Type: application\u002Fjson\"",[33,4427,4417],{"class":54},[33,4429,4430,4433],{"class":35,"line":93},[33,4431,4432],{"class":54},"  -d",[33,4434,4435],{"class":50}," '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools\u002Flist\"}'\n",[33,4437,4438],{"class":35,"line":99},[33,4439,90],{"emptyLinePlaceholder":89},[33,4441,4442],{"class":35,"line":128},[33,4443,4444],{"class":39},"# 调用工具\n",[33,4446,4447,4449,4451,4453,4455],{"class":35,"line":147},[33,4448,4405],{"class":46},[33,4450,4408],{"class":54},[33,4452,4411],{"class":50},[33,4454,4414],{"class":50},[33,4456,4417],{"class":54},[33,4458,4459,4461,4463],{"class":35,"line":163},[33,4460,4422],{"class":54},[33,4462,4425],{"class":50},[33,4464,4417],{"class":54},[33,4466,4467,4469],{"class":35,"line":169},[33,4468,4432],{"class":54},[33,4470,4471],{"class":50}," '{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools\u002Fcall\",\"params\":{\"name\":\"list_items\",\"arguments\":{}}}'\n",[12,4473,4475],{"id":4474},"拓展接入鉴权","拓展：接入鉴权",[16,4477,4478],{},"裸奔的 MCP 端点任何人都能调用，生产环境必须加鉴权。思路和普通 API 鉴权一致：Bearer Token。",[281,4480,3042],{"id":3042},[16,4482,4483,4484,4487,4488,4491],{},"MCP 的工具处理器签名为 ",[30,4485,4486],{},"func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error)","，它不接收 Gin 的 ",[30,4489,4490],{},"*gin.Context","，所以无法直接在工具处理器里读取请求头。",[281,4493,4495],{"id":4494},"解决方案中间件传递用户信息","解决方案：中间件传递用户信息",[16,4497,4498,4499,1957],{},"用两层中间件：第一层做 Gin 的 Token 验证，第二层把用户信息从 Gin Context 复制到 ",[30,4500,4501],{},"request.Context",[23,4503,4505],{"className":3490,"code":4504,"language":172,"meta":28,"style":28},"\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",[30,4506,4507,4512,4540,4561,4581,4597,4630,4635,4640,4644,4669,4709,4738,4742,4746,4750,4770,4782,4811,4815,4819,4823,4838,4852,4861,4865],{"__ignoreMap":28},[33,4508,4509],{"class":35,"line":36},[33,4510,4511],{"class":39},"\u002F\u002F Gin 鉴权中间件\n",[33,4513,4514,4516,4519,4521,4524,4526,4529,4531,4533,4535,4538],{"class":35,"line":43},[33,4515,3691],{"class":131},[33,4517,4518],{"class":46}," AuthRequired",[33,4520,2066],{"class":67},[33,4522,4523],{"class":2069},"authService",[33,4525,4316],{"class":131},[33,4527,4528],{"class":46},"AuthService",[33,4530,2530],{"class":67},[33,4532,4319],{"class":46},[33,4534,2188],{"class":67},[33,4536,4537],{"class":46},"HandlerFunc",[33,4539,1706],{"class":67},[33,4541,4542,4544,4547,4549,4551,4553,4555,4557,4559],{"class":35,"line":86},[33,4543,3748],{"class":131},[33,4545,4546],{"class":131}," func",[33,4548,2066],{"class":67},[33,4550,4313],{"class":2069},[33,4552,4316],{"class":131},[33,4554,4319],{"class":46},[33,4556,2188],{"class":67},[33,4558,3704],{"class":46},[33,4560,3318],{"class":67},[33,4562,4563,4566,4568,4571,4574,4576,4579],{"class":35,"line":93},[33,4564,4565],{"class":67},"        authHeader ",[33,4567,3517],{"class":131},[33,4569,4570],{"class":67}," c.",[33,4572,4573],{"class":46},"GetHeader",[33,4575,2066],{"class":67},[33,4577,4578],{"class":50},"\"Authorization\"",[33,4580,2376],{"class":67},[33,4582,4583,4586,4589,4592,4595],{"class":35,"line":99},[33,4584,4585],{"class":131},"        if",[33,4587,4588],{"class":67}," authHeader ",[33,4590,4591],{"class":131},"==",[33,4593,4594],{"class":50}," \"\"",[33,4596,1706],{"class":67},[33,4598,4599,4602,4605,4607,4610,4612,4614,4616,4618,4620,4623,4625,4628],{"class":35,"line":128},[33,4600,4601],{"class":67},"            c.",[33,4603,4604],{"class":46},"AbortWithStatusJSON",[33,4606,2066],{"class":67},[33,4608,4609],{"class":54},"401",[33,4611,799],{"class":67},[33,4613,4319],{"class":46},[33,4615,2188],{"class":67},[33,4617,4347],{"class":46},[33,4619,4350],{"class":67},[33,4621,4622],{"class":50},"\"message\"",[33,4624,1770],{"class":67},[33,4626,4627],{"class":50},"\"缺少认证令牌\"",[33,4629,2614],{"class":67},[33,4631,4632],{"class":35,"line":147},[33,4633,4634],{"class":131},"            return\n",[33,4636,4637],{"class":35,"line":163},[33,4638,4639],{"class":67},"        }\n",[33,4641,4642],{"class":35,"line":169},[33,4643,90],{"emptyLinePlaceholder":89},[33,4645,4646,4649,4651,4654,4657,4660,4663,4665,4667],{"class":35,"line":183},[33,4647,4648],{"class":67},"        parts ",[33,4650,3517],{"class":131},[33,4652,4653],{"class":67}," strings.",[33,4655,4656],{"class":46},"SplitN",[33,4658,4659],{"class":67},"(authHeader, ",[33,4661,4662],{"class":50},"\" \"",[33,4664,799],{"class":67},[33,4666,2252],{"class":54},[33,4668,2376],{"class":67},[33,4670,4671,4673,4676,4679,4681,4684,4687,4690,4693,4696,4699,4701,4704,4707],{"class":35,"line":206},[33,4672,4585],{"class":131},[33,4674,4675],{"class":46}," len",[33,4677,4678],{"class":67},"(parts) ",[33,4680,3934],{"class":131},[33,4682,4683],{"class":54}," 2",[33,4685,4686],{"class":131}," ||",[33,4688,4689],{"class":131}," !",[33,4691,4692],{"class":67},"strings.",[33,4694,4695],{"class":46},"EqualFold",[33,4697,4698],{"class":67},"(parts[",[33,4700,2154],{"class":54},[33,4702,4703],{"class":67},"], ",[33,4705,4706],{"class":50},"\"Bearer\"",[33,4708,3318],{"class":67},[33,4710,4711,4713,4715,4717,4719,4721,4723,4725,4727,4729,4731,4733,4736],{"class":35,"line":215},[33,4712,4601],{"class":67},[33,4714,4604],{"class":46},[33,4716,2066],{"class":67},[33,4718,4609],{"class":54},[33,4720,799],{"class":67},[33,4722,4319],{"class":46},[33,4724,2188],{"class":67},[33,4726,4347],{"class":46},[33,4728,4350],{"class":67},[33,4730,4622],{"class":50},[33,4732,1770],{"class":67},[33,4734,4735],{"class":50},"\"格式错误，请使用 Bearer \u003Ctoken>\"",[33,4737,2614],{"class":67},[33,4739,4740],{"class":35,"line":220},[33,4741,4634],{"class":131},[33,4743,4744],{"class":35,"line":226},[33,4745,4639],{"class":67},[33,4747,4748],{"class":35,"line":237},[33,4749,90],{"emptyLinePlaceholder":89},[33,4751,4752,4755,4757,4760,4763,4765,4767],{"class":35,"line":248},[33,4753,4754],{"class":67},"        user, err ",[33,4756,3517],{"class":131},[33,4758,4759],{"class":67}," authService.",[33,4761,4762],{"class":46},"GetUserByToken",[33,4764,4698],{"class":67},[33,4766,157],{"class":54},[33,4768,4769],{"class":67},"])\n",[33,4771,4772,4774,4776,4778,4780],{"class":35,"line":253},[33,4773,4585],{"class":131},[33,4775,3931],{"class":67},[33,4777,3934],{"class":131},[33,4779,3937],{"class":54},[33,4781,1706],{"class":67},[33,4783,4784,4786,4788,4790,4792,4794,4796,4798,4800,4802,4804,4806,4809],{"class":35,"line":262},[33,4785,4601],{"class":67},[33,4787,4604],{"class":46},[33,4789,2066],{"class":67},[33,4791,4609],{"class":54},[33,4793,799],{"class":67},[33,4795,4319],{"class":46},[33,4797,2188],{"class":67},[33,4799,4347],{"class":46},[33,4801,4350],{"class":67},[33,4803,4622],{"class":50},[33,4805,1770],{"class":67},[33,4807,4808],{"class":50},"\"无效的认证令牌\"",[33,4810,2614],{"class":67},[33,4812,4813],{"class":35,"line":267},[33,4814,4634],{"class":131},[33,4816,4817],{"class":35,"line":477},[33,4818,4639],{"class":67},[33,4820,4821],{"class":35,"line":485},[33,4822,90],{"emptyLinePlaceholder":89},[33,4824,4825,4827,4830,4832,4835],{"class":35,"line":490},[33,4826,4330],{"class":67},[33,4828,4829],{"class":46},"Set",[33,4831,2066],{"class":67},[33,4833,4834],{"class":50},"\"userID\"",[33,4836,4837],{"class":67},", user.ID)\n",[33,4839,4840,4842,4844,4846,4849],{"class":35,"line":1126},[33,4841,4330],{"class":67},[33,4843,4829],{"class":46},[33,4845,2066],{"class":67},[33,4847,4848],{"class":50},"\"username\"",[33,4850,4851],{"class":67},", user.Username)\n",[33,4853,4854,4856,4859],{"class":35,"line":1140},[33,4855,4330],{"class":67},[33,4857,4858],{"class":46},"Next",[33,4860,2261],{"class":67},[33,4862,4863],{"class":35,"line":1147},[33,4864,2026],{"class":67},[33,4866,4867],{"class":35,"line":1152},[33,4868,1360],{"class":67},[23,4870,4872],{"className":3490,"code":4871,"language":172,"meta":28,"style":28},"\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",[30,4873,4874,4879,4890,4897,4910,4922,4926,4930,4948,4968,4982,5006,5028,5043,5051,5055],{"__ignoreMap":28},[33,4875,4876],{"class":35,"line":36},[33,4877,4878],{"class":39},"\u002F\u002F 用户信息桥接中间件：Gin Context → request.Context\n",[33,4880,4881,4884,4887],{"class":35,"line":43},[33,4882,4883],{"class":131},"type",[33,4885,4886],{"class":46}," ctxKey",[33,4888,4889],{"class":131}," string\n",[33,4891,4892,4894],{"class":35,"line":86},[33,4893,2300],{"class":131},[33,4895,4896],{"class":67}," (\n",[33,4898,4899,4902,4905,4907],{"class":35,"line":93},[33,4900,4901],{"class":54},"    ctxUserID",[33,4903,4904],{"class":46},"   ctxKey",[33,4906,1703],{"class":131},[33,4908,4909],{"class":50}," \"userID\"\n",[33,4911,4912,4915,4917,4919],{"class":35,"line":99},[33,4913,4914],{"class":54},"    ctxUsername",[33,4916,4886],{"class":46},[33,4918,1703],{"class":131},[33,4920,4921],{"class":50}," \"username\"\n",[33,4923,4924],{"class":35,"line":128},[33,4925,2376],{"class":67},[33,4927,4928],{"class":35,"line":147},[33,4929,90],{"emptyLinePlaceholder":89},[33,4931,4932,4934,4937,4940,4942,4944,4946],{"class":35,"line":163},[33,4933,3691],{"class":131},[33,4935,4936],{"class":46}," WithUserContext",[33,4938,4939],{"class":67},"() ",[33,4941,4319],{"class":46},[33,4943,2188],{"class":67},[33,4945,4537],{"class":46},[33,4947,1706],{"class":67},[33,4949,4950,4952,4954,4956,4958,4960,4962,4964,4966],{"class":35,"line":169},[33,4951,3748],{"class":131},[33,4953,4546],{"class":131},[33,4955,2066],{"class":67},[33,4957,4313],{"class":2069},[33,4959,4316],{"class":131},[33,4961,4319],{"class":46},[33,4963,2188],{"class":67},[33,4965,3704],{"class":46},[33,4967,3318],{"class":67},[33,4969,4970,4973,4975,4978,4980],{"class":35,"line":183},[33,4971,4972],{"class":67},"        ctx ",[33,4974,3517],{"class":131},[33,4976,4977],{"class":67}," c.Request.",[33,4979,3704],{"class":46},[33,4981,2261],{"class":67},[33,4983,4984,4986,4988,4991,4994,4997,5000,5002,5004],{"class":35,"line":206},[33,4985,4972],{"class":67},[33,4987,138],{"class":131},[33,4989,4990],{"class":67}," context.",[33,4992,4993],{"class":46},"WithValue",[33,4995,4996],{"class":67},"(ctx, ctxUserID, c.",[33,4998,4999],{"class":46},"GetUint",[33,5001,2066],{"class":67},[33,5003,4834],{"class":50},[33,5005,2229],{"class":67},[33,5007,5008,5010,5012,5014,5016,5019,5022,5024,5026],{"class":35,"line":215},[33,5009,4972],{"class":67},[33,5011,138],{"class":131},[33,5013,4990],{"class":67},[33,5015,4993],{"class":46},[33,5017,5018],{"class":67},"(ctx, ctxUsername, c.",[33,5020,5021],{"class":46},"GetString",[33,5023,2066],{"class":67},[33,5025,4848],{"class":50},[33,5027,2229],{"class":67},[33,5029,5030,5033,5035,5037,5040],{"class":35,"line":220},[33,5031,5032],{"class":67},"        c.Request ",[33,5034,138],{"class":131},[33,5036,4977],{"class":67},[33,5038,5039],{"class":46},"WithContext",[33,5041,5042],{"class":67},"(ctx)\n",[33,5044,5045,5047,5049],{"class":35,"line":226},[33,5046,4330],{"class":67},[33,5048,4858],{"class":46},[33,5050,2261],{"class":67},[33,5052,5053],{"class":35,"line":237},[33,5054,2026],{"class":67},[33,5056,5057],{"class":35,"line":248},[33,5058,1360],{"class":67},[281,5060,5061],{"id":5061},"组合使用",[23,5063,5065],{"className":3490,"code":5064,"language":172,"meta":28,"style":28},"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",[30,5066,5067,5079,5090,5101,5114],{"__ignoreMap":28},[33,5068,5069,5071,5073,5075,5077],{"class":35,"line":36},[33,5070,4086],{"class":67},[33,5072,4089],{"class":46},[33,5074,2066],{"class":67},[33,5076,4049],{"class":50},[33,5078,1717],{"class":67},[33,5080,5081,5084,5087],{"class":35,"line":43},[33,5082,5083],{"class":46},"    AuthRequired",[33,5085,5086],{"class":67},"(authService),  ",[33,5088,5089],{"class":39},"\u002F\u002F 1. 验证 Token\n",[33,5091,5092,5095,5098],{"class":35,"line":86},[33,5093,5094],{"class":46},"    WithUserContext",[33,5096,5097],{"class":67},"(),           ",[33,5099,5100],{"class":39},"\u002F\u002F 2. 桥接用户信息到 request.Context\n",[33,5102,5103,5106,5108,5111],{"class":35,"line":93},[33,5104,5105],{"class":67},"    gin.",[33,5107,4099],{"class":46},[33,5109,5110],{"class":67},"(mcpHTTP),         ",[33,5112,5113],{"class":39},"\u002F\u002F 3. MCP 处理\n",[33,5115,5116],{"class":35,"line":99},[33,5117,2376],{"class":67},[16,5119,5120],{},"在工具处理器里就能拿到用户信息了：",[23,5122,5124],{"className":3490,"code":5123,"language":172,"meta":28,"style":28},"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",[30,5125,5126,5181,5202,5211,5228,5232,5236,5252,5257,5270],{"__ignoreMap":28},[33,5127,5128,5130,5132,5135,5137,5140,5142,5145,5147,5149,5151,5153,5155,5157,5159,5161,5163,5165,5167,5169,5171,5173,5175,5177,5179],{"class":35,"line":36},[33,5129,3691],{"class":131},[33,5131,2108],{"class":67},[33,5133,5134],{"class":2069},"h ",[33,5136,3723],{"class":131},[33,5138,5139],{"class":46},"Handler",[33,5141,2530],{"class":67},[33,5143,5144],{"class":46},"handleListItems",[33,5146,2066],{"class":67},[33,5148,3696],{"class":2069},[33,5150,3699],{"class":46},[33,5152,2188],{"class":67},[33,5154,3704],{"class":46},[33,5156,799],{"class":67},[33,5158,3709],{"class":2069},[33,5160,3712],{"class":46},[33,5162,2188],{"class":67},[33,5164,3717],{"class":46},[33,5166,3720],{"class":67},[33,5168,3723],{"class":131},[33,5170,3726],{"class":46},[33,5172,2188],{"class":67},[33,5174,3731],{"class":46},[33,5176,799],{"class":67},[33,5178,3736],{"class":131},[33,5180,3318],{"class":67},[33,5182,5183,5186,5188,5191,5194,5197,5200],{"class":35,"line":43},[33,5184,5185],{"class":67},"    userID, ok ",[33,5187,3517],{"class":131},[33,5189,5190],{"class":67}," ctx.",[33,5192,5193],{"class":46},"Value",[33,5195,5196],{"class":67},"(ctxUserID).(",[33,5198,5199],{"class":131},"uint",[33,5201,2376],{"class":67},[33,5203,5204,5206,5208],{"class":35,"line":86},[33,5205,2143],{"class":131},[33,5207,4689],{"class":131},[33,5209,5210],{"class":67},"ok {\n",[33,5212,5213,5215,5217,5219,5221,5224,5226],{"class":35,"line":93},[33,5214,3944],{"class":131},[33,5216,3639],{"class":67},[33,5218,3949],{"class":46},[33,5220,2066],{"class":67},[33,5222,5223],{"class":50},"\"未认证\"",[33,5225,3761],{"class":67},[33,5227,3764],{"class":54},[33,5229,5230],{"class":35,"line":99},[33,5231,2026],{"class":67},[33,5233,5234],{"class":35,"line":128},[33,5235,90],{"emptyLinePlaceholder":89},[33,5237,5238,5241,5243,5246,5249],{"class":35,"line":147},[33,5239,5240],{"class":67},"    items, _ ",[33,5242,3517],{"class":131},[33,5244,5245],{"class":67}," h.service.",[33,5247,5248],{"class":46},"ListByUser",[33,5250,5251],{"class":67},"(userID)\n",[33,5253,5254],{"class":35,"line":163},[33,5255,5256],{"class":39},"    \u002F\u002F ...\n",[33,5258,5259,5261,5263,5265,5268],{"class":35,"line":169},[33,5260,3748],{"class":131},[33,5262,3639],{"class":67},[33,5264,3753],{"class":46},[33,5266,5267],{"class":67},"(result), ",[33,5269,3764],{"class":54},[33,5271,5272],{"class":35,"line":183},[33,5273,1360],{"class":67},[281,5275,5276],{"id":5276},"客户端配置",[16,5278,5279],{},"AI 客户端（如 Claude Desktop）配置 MCP 服务器时带上 Authorization 头：",[23,5281,5285],{"className":5282,"code":5283,"language":5284,"meta":28,"style":28},"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",[30,5286,5287,5292,5299,5305,5317,5324,5334,5339,5343,5347],{"__ignoreMap":28},[33,5288,5289],{"class":35,"line":36},[33,5290,5291],{"class":67},"{\n",[33,5293,5294,5297],{"class":35,"line":43},[33,5295,5296],{"class":54},"  \"mcpServers\"",[33,5298,1985],{"class":67},[33,5300,5301,5303],{"class":35,"line":86},[33,5302,3531],{"class":54},[33,5304,1985],{"class":67},[33,5306,5307,5310,5312,5315],{"class":35,"line":93},[33,5308,5309],{"class":54},"      \"url\"",[33,5311,1770],{"class":67},[33,5313,5314],{"class":50},"\"http:\u002F\u002Flocalhost:8080\u002Fmcp\"",[33,5316,1717],{"class":67},[33,5318,5319,5322],{"class":35,"line":99},[33,5320,5321],{"class":54},"      \"headers\"",[33,5323,1985],{"class":67},[33,5325,5326,5329,5331],{"class":35,"line":128},[33,5327,5328],{"class":54},"        \"Authorization\"",[33,5330,1770],{"class":67},[33,5332,5333],{"class":50},"\"Bearer your-token-here\"\n",[33,5335,5336],{"class":35,"line":147},[33,5337,5338],{"class":67},"      }\n",[33,5340,5341],{"class":35,"line":163},[33,5342,2026],{"class":67},[33,5344,5345],{"class":35,"line":169},[33,5346,2031],{"class":67},[33,5348,5349],{"class":35,"line":183},[33,5350,1360],{"class":67},[12,5352,1215],{"id":1215},[1390,5354,5355,5365],{},[1393,5356,5357],{},[1396,5358,5359,5362],{},[1399,5360,5361],{},"步骤",[1399,5363,5364],{},"说明",[1412,5366,5367,5376,5387,5396,5405],{},[1396,5368,5369,5373],{},[1417,5370,5371],{},[30,5372,3523],{},[1417,5374,5375],{},"创建 MCP 协议实例",[1396,5377,5378,5384],{},[1417,5379,5380,1232,5382],{},[30,5381,3642],{},[30,5383,3685],{},[1417,5385,5386],{},"定义并注册工具",[1396,5388,5389,5393],{},[1417,5390,5391],{},[30,5392,4034],{},[1417,5394,5395],{},"转为 HTTP Handler",[1396,5397,5398,5402],{},[1417,5399,5400],{},[30,5401,4002],{},[1417,5403,5404],{},"挂载到 Gin 路由",[1396,5406,5407,5415],{},[1417,5408,5409,1232,5412],{},[30,5410,5411],{},"AuthRequired",[30,5413,5414],{},"WithUserContext",[1417,5416,5417],{},"鉴权 + 用户信息桥接",[16,5419,5420,5421,5424,5425,1957],{},"核心就一句话：",[1305,5422,5423],{},"MCP 是 HTTP Handler，挂上去就行。"," 鉴权复用你已有的中间件体系，只需要多写一个桥接函数把用户信息传进 ",[30,5426,4501],{},[825,5428,5429],{},"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":28,"searchDepth":43,"depth":86,"links":5431},[5432,5433,5434,5435,5442,5448],{"id":3402,"depth":43,"text":3403},{"id":3412,"depth":43,"text":3413},{"id":3429,"depth":43,"text":3429},{"id":3455,"depth":43,"text":3455,"children":5436},[5437,5438,5439,5440,5441],{"id":3458,"depth":86,"text":3459},{"id":3486,"depth":86,"text":3487},{"id":3603,"depth":86,"text":3604},{"id":3991,"depth":86,"text":3992},{"id":4112,"depth":86,"text":4113},{"id":4474,"depth":43,"text":4475,"children":5443},[5444,5445,5446,5447],{"id":3042,"depth":86,"text":3042},{"id":4494,"depth":86,"text":4495},{"id":5061,"depth":86,"text":5061},{"id":5276,"depth":86,"text":5276},{"id":1215,"depth":43,"text":1215},"介绍如何使用 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":3397,"description":5449},"Go 工程实践","posts\u002Fgo-mcp-gin\u002Findex",[852,5458,5459,5460,5461],"MCP","Gin","AI Agent","后端","2026-06-01","5fZl1Vigs2lHb5fHPz-0YZFyFD38ws1heOouZUG1g6M",{"id":5465,"title":5466,"author":7,"body":5467,"description":6234,"difficulty":7,"draft":841,"extension":842,"image":6235,"meta":6236,"navigation":89,"path":6237,"pinned":841,"published":6238,"seo":6239,"series":7,"seriesOrder":7,"stem":6240,"tags":6241,"updated":7,"__hash__":6245},"posts\u002Fposts\u002Fdws-dingtalk-cli\u002Findex.md","DWS — 钉钉官方出品的 AI 友好命令行工具",{"type":9,"value":5468,"toc":6220},[5469,5473,5476,5483,5609,5612,5615,5671,5674,5689,5692,5695,5702,5934,5941,5945,5948,5968,5971,5986,5989,6009,6012,6015,6022,6088,6091,6094,6098,6101,6153,6157,6178,6182,6185,6199,6202,6206,6209,6211,6214,6217],[12,5470,5472],{"id":5471},"dws-是什么","dws 是什么",[16,5474,5475],{},"dws（DingTalk Workspace CLI）是阿里巴巴 2026 年开源的钉钉命令行工具，Apache-2.0 协议，Go 语言编写。",[16,5477,5478,5479,5482],{},"它的定位很明确：",[1305,5480,5481],{},"把钉钉的所有产品能力暴露给 AI Agent 和命令行用户","。不用打开钉钉 App，不用写 API 调用代码，一条命令搞定。",[23,5484,5486],{"className":25,"code":5485,"language":27,"meta":28,"style":28},"# 查看今天有什么会\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",[30,5487,5488,5493,5519,5523,5528,5550,5554,5559,5584,5588,5593],{"__ignoreMap":28},[33,5489,5490],{"class":35,"line":36},[33,5491,5492],{"class":39},"# 查看今天有什么会\n",[33,5494,5495,5498,5501,5504,5507,5510,5513,5516],{"class":35,"line":43},[33,5496,5497],{"class":46},"dws",[33,5499,5500],{"class":50}," calendar",[33,5502,5503],{"class":50}," event",[33,5505,5506],{"class":50}," list",[33,5508,5509],{"class":54}," --start",[33,5511,5512],{"class":50}," 2026-06-01",[33,5514,5515],{"class":54}," --end",[33,5517,5518],{"class":50}," 2026-06-01\n",[33,5520,5521],{"class":35,"line":86},[33,5522,90],{"emptyLinePlaceholder":89},[33,5524,5525],{"class":35,"line":93},[33,5526,5527],{"class":39},"# 给群里发条消息\n",[33,5529,5530,5532,5535,5538,5541,5544,5547],{"class":35,"line":99},[33,5531,5497],{"class":46},[33,5533,5534],{"class":50}," chat",[33,5536,5537],{"class":50}," send",[33,5539,5540],{"class":54}," --group-id",[33,5542,5543],{"class":50}," xxx",[33,5545,5546],{"class":54}," --content",[33,5548,5549],{"class":50}," \"今天发布推迟到下午3点\"\n",[33,5551,5552],{"class":35,"line":128},[33,5553,90],{"emptyLinePlaceholder":89},[33,5555,5556],{"class":35,"line":147},[33,5557,5558],{"class":39},"# 创建一个待办\n",[33,5560,5561,5563,5566,5569,5572,5575,5578,5581],{"class":35,"line":163},[33,5562,5497],{"class":46},[33,5564,5565],{"class":50}," todo",[33,5567,5568],{"class":50}," task",[33,5570,5571],{"class":50}," create",[33,5573,5574],{"class":54}," --subject",[33,5576,5577],{"class":50}," \"Review PR #123\"",[33,5579,5580],{"class":54}," --due-date",[33,5582,5583],{"class":50}," 2026-06-02\n",[33,5585,5586],{"class":35,"line":169},[33,5587,90],{"emptyLinePlaceholder":89},[33,5589,5590],{"class":35,"line":183},[33,5591,5592],{"class":39},"# 读取钉钉文档\n",[33,5594,5595,5597,5600,5603,5606],{"class":35,"line":206},[33,5596,5497],{"class":46},[33,5598,5599],{"class":50}," doc",[33,5601,5602],{"class":50}," read",[33,5604,5605],{"class":54}," --url",[33,5607,5608],{"class":50}," \"https:\u002F\u002Falidocs.dingtalk.com\u002Fi\u002Fnodes\u002Fxxx\"\n",[12,5610,5611],{"id":5611},"安装",[16,5613,5614],{},"三种方式，选你喜欢的：",[23,5616,5618],{"className":25,"code":5617,"language":27,"meta":28,"style":28},"# 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",[30,5619,5620,5625,5638,5642,5647,5657,5661,5666],{"__ignoreMap":28},[33,5621,5622],{"class":35,"line":36},[33,5623,5624],{"class":39},"# npm（推荐，跨平台自动下载二进制）\n",[33,5626,5627,5630,5632,5635],{"class":35,"line":43},[33,5628,5629],{"class":46},"npm",[33,5631,113],{"class":50},[33,5633,5634],{"class":54}," -g",[33,5636,5637],{"class":50}," dingtalk-workspace-cli\n",[33,5639,5640],{"class":35,"line":86},[33,5641,90],{"emptyLinePlaceholder":89},[33,5643,5644],{"class":35,"line":93},[33,5645,5646],{"class":39},"# Homebrew（macOS\u002FLinux）\n",[33,5648,5649,5652,5654],{"class":35,"line":99},[33,5650,5651],{"class":46},"brew",[33,5653,113],{"class":50},[33,5655,5656],{"class":50}," dws\n",[33,5658,5659],{"class":35,"line":128},[33,5660,90],{"emptyLinePlaceholder":89},[33,5662,5663],{"class":35,"line":147},[33,5664,5665],{"class":39},"# 直接下载二进制\n",[33,5667,5668],{"class":35,"line":163},[33,5669,5670],{"class":39},"# https:\u002F\u002Fgithub.com\u002Fopen-dingtalk\u002Fdingtalk-workspace-cli\u002Freleases\n",[16,5672,5673],{},"安装后认证：",[23,5675,5677],{"className":25,"code":5676,"language":27,"meta":28,"style":28},"dws auth login\n",[30,5678,5679],{"__ignoreMap":28},[33,5680,5681,5683,5686],{"class":35,"line":36},[33,5682,5497],{"class":46},[33,5684,5685],{"class":50}," auth",[33,5687,5688],{"class":50}," login\n",[16,5690,5691],{},"会打开浏览器走 OAuth 设备流认证，token 加密存储在系统钥匙串里（PBKDF2 + AES-256-GCM）。",[12,5693,5694],{"id":5694},"覆盖的钉钉产品",[16,5696,5697,5698,5701],{},"这是最夸张的部分 — ",[1305,5699,5700],{},"19 个产品，213 个命令","，基本把钉钉全家桶都搬到了命令行：",[1390,5703,5704,5716],{},[1393,5705,5706],{},[1396,5707,5708,5711,5714],{},[1399,5709,5710],{},"产品",[1399,5712,5713],{},"命令数",[1399,5715,5364],{},[1412,5717,5718,5732,5746,5760,5774,5788,5802,5816,5829,5843,5856,5870,5884,5897,5910,5923],{},[1396,5719,5720,5726,5729],{},[1417,5721,5722,5723],{},"聊天 ",[30,5724,5725],{},"chat",[1417,5727,5728],{},"57",[1417,5730,5731],{},"消息收发、群管理、机器人、已读状态、Webhook",[1396,5733,5734,5740,5743],{},[1417,5735,5736,5737],{},"AI 表格 ",[30,5738,5739],{},"aitable",[1417,5741,5742],{},"42",[1417,5744,5745],{},"Base\u002F数据表\u002F字段\u002F记录\u002F视图\u002F图表\u002F仪表盘，完整 CRUD",[1396,5747,5748,5754,5757],{},[1417,5749,5750,5751],{},"电子表格 ",[30,5752,5753],{},"sheet",[1417,5755,5756],{},"34",[1417,5758,5759],{},"工作表读写、行列操作、合并、筛选视图、导出",[1396,5761,5762,5768,5771],{},[1417,5763,5764,5765],{},"文档 ",[30,5766,5767],{},"doc",[1417,5769,5770],{},"21",[1417,5772,5773],{},"搜索、读写、块级编辑、评论、复制移动",[1396,5775,5776,5782,5785],{},[1417,5777,5778,5779],{},"会议纪要 ",[30,5780,5781],{},"minutes",[1417,5783,5784],{},"19",[1417,5786,5787],{},"列表、摘要、转写、思维导图、发言人、热词",[1396,5789,5790,5796,5799],{},[1417,5791,5792,5793],{},"日历 ",[30,5794,5795],{},"calendar",[1417,5797,5798],{},"14",[1417,5800,5801],{},"事件 CRUD、会议室、忙闲查询、时间建议",[1396,5803,5804,5810,5813],{},[1417,5805,5806,5807],{},"审批 ",[30,5808,5809],{},"oa",[1417,5811,5812],{},"9",[1417,5814,5815],{},"待办、发起、同意\u002F拒绝\u002F撤销",[1396,5817,5818,5824,5826],{},[1417,5819,5820,5821],{},"云盘 ",[30,5822,5823],{},"drive",[1417,5825,5812],{},[1417,5827,5828],{},"列表、下载、上传、创建目录",[1396,5830,5831,5837,5840],{},[1417,5832,5833,5834],{},"知识库 ",[30,5835,5836],{},"wiki",[1417,5838,5839],{},"7",[1417,5841,5842],{},"空间管理、成员管理",[1396,5844,5845,5851,5853],{},[1417,5846,5847,5848],{},"汇报 ",[30,5849,5850],{},"report",[1417,5852,5839],{},[1417,5854,5855],{},"日志\u002F日报\u002F周报，按模板创建、统计",[1396,5857,5858,5864,5867],{},[1417,5859,5860,5861],{},"待办 ",[30,5862,5863],{},"todo",[1417,5865,5866],{},"6",[1417,5868,5869],{},"创建、完成、删除，支持优先级和循环",[1396,5871,5872,5878,5881],{},[1417,5873,5874,5875],{},"邮件 ",[30,5876,5877],{},"mail",[1417,5879,5880],{},"4",[1417,5882,5883],{},"邮箱查询、邮件搜索、发送",[1396,5885,5886,5892,5894],{},[1417,5887,5888,5889],{},"考勤 ",[30,5890,5891],{},"attendance",[1417,5893,5880],{},[1417,5895,5896],{},"打卡记录、排班、汇总",[1396,5898,5899,5905,5907],{},[1417,5900,5901,5902],{},"通讯录 ",[30,5903,5904],{},"contact",[1417,5906,2015],{},[1417,5908,5909],{},"用户搜索、部门查询",[1396,5911,5912,5918,5920],{},[1417,5913,5914,5915],{},"DING 消息 ",[30,5916,5917],{},"ding",[1417,5919,2252],{},[1417,5921,5922],{},"发送\u002F撤回（应用内、短信、电话）",[1396,5924,5925,5928,5931],{},[1417,5926,5927],{},"其他",[1417,5929,5930],{},"若干",[1417,5932,5933],{},"AI 搜索、AI 应用、直播、开放平台文档",[16,5935,5936,5937,5940],{},"还有一个万能命令 ",[30,5938,5939],{},"dws api","，可以直接调用任意钉钉 OpenAPI，不受这 213 个命令的限制。",[12,5942,5944],{"id":5943},"agent-skill-系统","Agent Skill 系统",[16,5946,5947],{},"dws 内置了一套完整的 Agent Skill，可以直接被 AI 编程工具发现和使用：",[1613,5949,5950,5956,5962],{},[288,5951,5952,5955],{},[1305,5953,5954],{},"Claude Code"," — 通过 SKILL.md 自动加载",[288,5957,5958,5961],{},[1305,5959,5960],{},"Cursor"," — 支持 MCP 协议接入",[288,5963,5964,5967],{},[1305,5965,5966],{},"Codex \u002F OpenCode \u002F Qoder"," — 通过 skill 安装命令接入",[16,5969,5970],{},"安装 skill：",[23,5972,5974],{"className":25,"code":5973,"language":27,"meta":28,"style":28},"dws skill setup\n",[30,5975,5976],{"__ignoreMap":28},[33,5977,5978,5980,5983],{"class":35,"line":36},[33,5979,5497],{"class":46},[33,5981,5982],{"class":50}," skill",[33,5984,5985],{"class":50}," setup\n",[16,5987,5988],{},"SKILL.md 里定义了：",[1613,5990,5991,6000,6003,6006],{},[288,5992,5993,5994,5996,5997,5999],{},"16 个产品的意图判断决策树（用户说\"表格\"→ ",[30,5995,5739],{},"，说\"打卡\"→ ",[30,5998,5891],{},"）",[288,6001,6002],{},"危险操作确认流程（删除、撤回等必须先确认）",[288,6004,6005],{},"错误处理规范（verbose 重试、recovery 事件 ID）",[288,6007,6008],{},"URL 分流规则（alidocs.dingtalk.com 的多种路径格式自动识别）",[16,6010,6011],{},"Agent 读到这份 SKILL.md 就知道什么时候该调什么命令，不需要额外的 prompt 工程。",[12,6013,6014],{"id":6014},"自动升级",[16,6016,6017,6018,6021],{},"内置 ",[30,6019,6020],{},"dws upgrade","，不需要重新下载安装包：",[23,6023,6025],{"className":25,"code":6024,"language":27,"meta":28,"style":28},"dws upgrade              # 交互式升级到最新版\ndws upgrade --check      # 仅检查是否有新版本\ndws upgrade --list       # 列出最近版本\ndws upgrade --version v1.0.5  # 升级到指定版本\ndws upgrade --rollback   # 回滚到上一版本\n",[30,6026,6027,6037,6049,6061,6076],{"__ignoreMap":28},[33,6028,6029,6031,6034],{"class":35,"line":36},[33,6030,5497],{"class":46},[33,6032,6033],{"class":50}," upgrade",[33,6035,6036],{"class":39},"              # 交互式升级到最新版\n",[33,6038,6039,6041,6043,6046],{"class":35,"line":43},[33,6040,5497],{"class":46},[33,6042,6033],{"class":50},[33,6044,6045],{"class":54}," --check",[33,6047,6048],{"class":39},"      # 仅检查是否有新版本\n",[33,6050,6051,6053,6055,6058],{"class":35,"line":86},[33,6052,5497],{"class":46},[33,6054,6033],{"class":50},[33,6056,6057],{"class":54}," --list",[33,6059,6060],{"class":39},"       # 列出最近版本\n",[33,6062,6063,6065,6067,6070,6073],{"class":35,"line":93},[33,6064,5497],{"class":46},[33,6066,6033],{"class":50},[33,6068,6069],{"class":54}," --version",[33,6071,6072],{"class":50}," v1.0.5",[33,6074,6075],{"class":39},"  # 升级到指定版本\n",[33,6077,6078,6080,6082,6085],{"class":35,"line":99},[33,6079,5497],{"class":46},[33,6081,6033],{"class":50},[33,6083,6084],{"class":54}," --rollback",[33,6086,6087],{"class":39},"   # 回滚到上一版本\n",[16,6089,6090],{},"升级流程：下载 → SHA256 校验 → 备份当前版本 → 原子替换 → 更新 Skill。失败可一键回滚。",[12,6092,6093],{"id":6093},"亮点",[281,6095,6097],{"id":6096},"_1-为-ai-agent-专门优化","1. 为 AI Agent 专门优化",[16,6099,6100],{},"这可能是目前对 AI 最友好的 CLI 工具：",[1613,6102,6103,6124,6134,6143],{},[288,6104,6105,6108,6109,6112,6113,6116,6117,6112,6120,6123],{},[1305,6106,6107],{},"智能纠错"," — AI 模型经常生成错误的参数格式（",[30,6110,6111],{},"--userId"," 应该是 ",[30,6114,6115],{},"--user-id","，",[30,6118,6119],{},"--limit100",[30,6121,6122],{},"--limit 100","），dws 的 5 阶段 Pipeline 会自动修正",[288,6125,6126,6129,6130,6133],{},[1305,6127,6128],{},"Schema 发现"," — ",[30,6131,6132],{},"dws schema"," 让 Agent 动态查询可用工具和参数，不需要硬编码",[288,6135,6136,6129,6139,6142],{},[1305,6137,6138],{},"jq 过滤",[30,6140,6141],{},"--jq '.data[] | {name, id}'"," 直接过滤输出，减少 Agent 消耗的 token",[288,6144,6145,6148,6149,6152],{},[1305,6146,6147],{},"JSON 输出"," — 所有命令支持 ",[30,6150,6151],{},"--format json","，方便程序解析",[281,6154,6156],{"id":6155},"_2-安全设计","2. 安全设计",[1613,6158,6159,6162,6165,6168,6175],{},[288,6160,6161],{},"token 用 PBKDF2 + AES-256-GCM 加密，密钥绑定设备 MAC 地址",[288,6163,6164],{},"域名白名单，只能访问钉钉官方 API",[288,6166,6167],{},"强制 HTTPS",[288,6169,6170,6171,6174],{},"写操作必须显式 ",[30,6172,6173],{},"--yes"," 确认",[288,6176,6177],{},"完整审计链",[281,6179,6181],{"id":6180},"_3-发现驱动架构","3. 发现驱动架构",[16,6183,6184],{},"不是把所有命令硬编码在代码里，而是：",[285,6186,6187,6190,6193,6196],{},[288,6188,6189],{},"从钉钉 MCP 服务器注册中心拉取元数据",[288,6191,6192],{},"运行时发现服务端能力",[288,6194,6195],{},"转换成统一的中间表示（IR）",[288,6197,6198],{},"动态挂载到命令树",[16,6200,6201],{},"这意味着钉钉新增产品能力时，dws 不需要发版就能支持。",[281,6203,6205],{"id":6204},"_4-多平台分发","4. 多平台分发",[16,6207,6208],{},"GoReleaser 交叉编译 + Homebrew formula + npm 包 + shell\u002FPowerShell 安装脚本，覆盖 Windows、macOS、Linux。",[12,6210,1215],{"id":1215},[16,6212,6213],{},"dws 把钉钉从一个\"必须打开 App 才能用\"的工具变成了一个\"命令行 + AI Agent 随时调用\"的平台。213 个命令覆盖日常办公的方方面面，Agent Skill 系统让 AI 编程工具可以直接理解和使用这些能力。",[16,6215,6216],{},"如果你是钉钉重度用户，或者在做企业内部的 AI Agent 集成，dws 值得一试。",[825,6218,6219],{},"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":28,"searchDepth":43,"depth":86,"links":6221},[6222,6223,6224,6225,6226,6227,6233],{"id":5471,"depth":43,"text":5472},{"id":5611,"depth":43,"text":5611},{"id":5694,"depth":43,"text":5694},{"id":5943,"depth":43,"text":5944},{"id":6014,"depth":43,"text":6014},{"id":6093,"depth":43,"text":6093,"children":6228},[6229,6230,6231,6232],{"id":6096,"depth":86,"text":6097},{"id":6155,"depth":86,"text":6156},{"id":6180,"depth":86,"text":6181},{"id":6204,"depth":86,"text":6205},{"id":1215,"depth":43,"text":1215},"介绍阿里巴巴官方的 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":5466,"description":6234},"posts\u002Fdws-dingtalk-cli\u002Findex",[6242,6243,5460,852,6244],"钉钉","CLI","效率工具","4ca65VUZ2Sz9yG8dNtftrBDDHE6QowA3ed9g4egQkKI",{"id":6247,"title":6248,"author":7,"body":6249,"description":7212,"difficulty":840,"draft":841,"extension":842,"image":7213,"meta":7214,"navigation":89,"path":7215,"pinned":841,"published":7216,"seo":7217,"series":5455,"seriesOrder":43,"stem":7218,"tags":7219,"updated":7223,"__hash__":7224},"posts\u002Fposts\u002Fgo-embed-frontend\u002Findex.md","Go 单文件打包实战：把前端塞进二进制",{"type":9,"value":6250,"toc":7195},[6251,6253,6260,6263,6266,6277,6280,6284,6617,6621,6729,6733,6867,6870,6874,6883,6900,6911,6930,6944,6950,6958,7057,7063,7067,7074,7083,7086,7122,7125,7130,7169,7171,7177,7189,7192],[12,6252,1933],{"id":1933},[16,6254,6255,6256,6259],{},"一个 Gin + Vue 3 的全栈项目，之前的发布方式是：一个 Go 二进制 + 一个 ",[30,6257,6258],{},"web\u002F"," 前端静态目录，必须放一起才能运行。",[16,6261,6262],{},"为了让部署更丝滑——一个 exe 拖哪都能跑——决定把前端构建产物嵌入到 Go 二进制里。",[12,6264,6265],{"id":6265},"核心思路",[16,6267,6268,6269,6272,6273,6276],{},"利用 Go 1.16 引入的 ",[30,6270,6271],{},"\u002F\u002Fgo:embed"," 指令，在编译时将前端 ",[30,6274,6275],{},"dist\u002F"," 目录嵌入二进制，运行时通过嵌入的文件系统提供 SPA 服务。",[12,6278,6279],{"id":6279},"逐步实现",[281,6281,6283],{"id":6282},"_1-创建嵌入模块","1. 创建嵌入模块",[23,6285,6287],{"className":3490,"code":6286,"language":172,"meta":28,"style":28},"\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",[30,6288,6289,6294,6302,6306,6312,6322,6331,6340,6349,6358,6367,6371,6380,6384,6388,6393,6408,6412,6429,6450,6454,6474,6494,6507,6517,6521,6525,6540,6552,6557,6576,6580,6584,6607,6612],{"__ignoreMap":28},[33,6290,6291],{"class":35,"line":36},[33,6292,6293],{"class":39},"\u002F\u002F service\u002Fweb\u002Fembed.go\n",[33,6295,6296,6299],{"class":35,"line":43},[33,6297,6298],{"class":131},"package",[33,6300,6301],{"class":46}," web\n",[33,6303,6304],{"class":35,"line":86},[33,6305,90],{"emptyLinePlaceholder":89},[33,6307,6308,6310],{"class":35,"line":93},[33,6309,3498],{"class":131},[33,6311,4896],{"class":67},[33,6313,6314,6317,6320],{"class":35,"line":99},[33,6315,6316],{"class":50},"    \"",[33,6318,6319],{"class":46},"embed",[33,6321,279],{"class":50},[33,6323,6324,6326,6329],{"class":35,"line":128},[33,6325,6316],{"class":50},[33,6327,6328],{"class":46},"io\u002Ffs",[33,6330,279],{"class":50},[33,6332,6333,6335,6338],{"class":35,"line":147},[33,6334,6316],{"class":50},[33,6336,6337],{"class":46},"mime",[33,6339,279],{"class":50},[33,6341,6342,6344,6347],{"class":35,"line":163},[33,6343,6316],{"class":50},[33,6345,6346],{"class":46},"net\u002Fhttp",[33,6348,279],{"class":50},[33,6350,6351,6353,6356],{"class":35,"line":169},[33,6352,6316],{"class":50},[33,6354,6355],{"class":46},"path\u002Ffilepath",[33,6357,279],{"class":50},[33,6359,6360,6362,6365],{"class":35,"line":183},[33,6361,6316],{"class":50},[33,6363,6364],{"class":46},"strings",[33,6366,279],{"class":50},[33,6368,6369],{"class":35,"line":206},[33,6370,90],{"emptyLinePlaceholder":89},[33,6372,6373,6375,6378],{"class":35,"line":215},[33,6374,6316],{"class":50},[33,6376,6377],{"class":46},"github.com\u002Fgin-gonic\u002Fgin",[33,6379,279],{"class":50},[33,6381,6382],{"class":35,"line":220},[33,6383,2376],{"class":67},[33,6385,6386],{"class":35,"line":226},[33,6387,90],{"emptyLinePlaceholder":89},[33,6389,6390],{"class":35,"line":237},[33,6391,6392],{"class":39},"\u002F\u002Fgo:embed dist\n",[33,6394,6395,6398,6401,6403,6405],{"class":35,"line":248},[33,6396,6397],{"class":131},"var",[33,6399,6400],{"class":67}," embedFS ",[33,6402,6319],{"class":46},[33,6404,2188],{"class":67},[33,6406,6407],{"class":46},"FS\n",[33,6409,6410],{"class":35,"line":253},[33,6411,90],{"emptyLinePlaceholder":89},[33,6413,6414,6416,6419,6421,6423,6425,6427],{"class":35,"line":262},[33,6415,3691],{"class":131},[33,6417,6418],{"class":46}," SPAHandler",[33,6420,4939],{"class":67},[33,6422,4319],{"class":46},[33,6424,2188],{"class":67},[33,6426,4537],{"class":46},[33,6428,1706],{"class":67},[33,6430,6431,6434,6436,6439,6442,6445,6448],{"class":35,"line":267},[33,6432,6433],{"class":67},"    sub, _ ",[33,6435,3517],{"class":131},[33,6437,6438],{"class":67}," fs.",[33,6440,6441],{"class":46},"Sub",[33,6443,6444],{"class":67},"(embedFS, ",[33,6446,6447],{"class":50},"\"dist\"",[33,6449,2376],{"class":67},[33,6451,6452],{"class":35,"line":477},[33,6453,90],{"emptyLinePlaceholder":89},[33,6455,6456,6458,6460,6462,6464,6466,6468,6470,6472],{"class":35,"line":485},[33,6457,3748],{"class":131},[33,6459,4546],{"class":131},[33,6461,2066],{"class":67},[33,6463,4313],{"class":2069},[33,6465,4316],{"class":131},[33,6467,4319],{"class":46},[33,6469,2188],{"class":67},[33,6471,3704],{"class":46},[33,6473,3318],{"class":67},[33,6475,6476,6479,6481,6483,6486,6489,6492],{"class":35,"line":490},[33,6477,6478],{"class":67},"        p ",[33,6480,3517],{"class":131},[33,6482,4653],{"class":67},[33,6484,6485],{"class":46},"TrimPrefix",[33,6487,6488],{"class":67},"(c.Request.URL.Path, ",[33,6490,6491],{"class":50},"\"\u002F\"",[33,6493,2376],{"class":67},[33,6495,6496,6498,6501,6503,6505],{"class":35,"line":1126},[33,6497,4585],{"class":131},[33,6499,6500],{"class":67}," p ",[33,6502,4591],{"class":131},[33,6504,4594],{"class":50},[33,6506,1706],{"class":67},[33,6508,6509,6512,6514],{"class":35,"line":1140},[33,6510,6511],{"class":67},"            p ",[33,6513,138],{"class":131},[33,6515,6516],{"class":50}," \"index.html\"\n",[33,6518,6519],{"class":35,"line":1147},[33,6520,4639],{"class":67},[33,6522,6523],{"class":35,"line":1152},[33,6524,90],{"emptyLinePlaceholder":89},[33,6526,6527,6530,6532,6534,6537],{"class":35,"line":1158},[33,6528,6529],{"class":67},"        data, err ",[33,6531,3517],{"class":131},[33,6533,6438],{"class":67},[33,6535,6536],{"class":46},"ReadFile",[33,6538,6539],{"class":67},"(sub, p)\n",[33,6541,6542,6544,6546,6548,6550],{"class":35,"line":1166},[33,6543,4585],{"class":131},[33,6545,3931],{"class":67},[33,6547,3934],{"class":131},[33,6549,3937],{"class":54},[33,6551,1706],{"class":67},[33,6553,6554],{"class":35,"line":1174},[33,6555,6556],{"class":39},"            \u002F\u002F SPA fallback：文件不存在则返回 index.html\n",[33,6558,6559,6562,6564,6566,6568,6571,6574],{"class":35,"line":1179},[33,6560,6561],{"class":67},"            data, _ ",[33,6563,138],{"class":131},[33,6565,6438],{"class":67},[33,6567,6536],{"class":46},[33,6569,6570],{"class":67},"(sub, ",[33,6572,6573],{"class":50},"\"index.html\"",[33,6575,2376],{"class":67},[33,6577,6578],{"class":35,"line":1185},[33,6579,4639],{"class":67},[33,6581,6582],{"class":35,"line":1194},[33,6583,90],{"emptyLinePlaceholder":89},[33,6585,6587,6589,6592,6595,6598,6601,6604],{"class":35,"line":6586},33,[33,6588,4330],{"class":67},[33,6590,6591],{"class":46},"Data",[33,6593,6594],{"class":67},"(http.StatusOK, mime.",[33,6596,6597],{"class":46},"TypeByExtension",[33,6599,6600],{"class":67},"(filepath.",[33,6602,6603],{"class":46},"Ext",[33,6605,6606],{"class":67},"(p)), data)\n",[33,6608,6610],{"class":35,"line":6609},34,[33,6611,2026],{"class":67},[33,6613,6615],{"class":35,"line":6614},35,[33,6616,1360],{"class":67},[281,6618,6620],{"id":6619},"_2-改造路由","2. 改造路由",[23,6622,6624],{"className":3490,"code":6623,"language":172,"meta":28,"style":28},"\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",[30,6625,6626,6631,6642,6646,6651,6666,6680,6693,6706,6719],{"__ignoreMap":28},[33,6627,6628],{"class":35,"line":36},[33,6629,6630],{"class":39},"\u002F\u002F router.go\n",[33,6632,6633,6635,6637,6640],{"class":35,"line":43},[33,6634,3498],{"class":131},[33,6636,64],{"class":50},[33,6638,6639],{"class":46},"myproject\u002Fweb",[33,6641,279],{"class":50},[33,6643,6644],{"class":35,"line":86},[33,6645,90],{"emptyLinePlaceholder":89},[33,6647,6648],{"class":35,"line":93},[33,6649,6650],{"class":39},"\u002F\u002F 替换原来的磁盘静态文件服务\n",[33,6652,6653,6656,6658,6661,6664],{"class":35,"line":99},[33,6654,6655],{"class":67},"webHandler ",[33,6657,3517],{"class":131},[33,6659,6660],{"class":67}," web.",[33,6662,6663],{"class":46},"SPAHandler",[33,6665,2261],{"class":67},[33,6667,6668,6671,6673,6675,6677],{"class":35,"line":128},[33,6669,6670],{"class":67},"router.",[33,6672,4299],{"class":46},[33,6674,2066],{"class":67},[33,6676,6491],{"class":50},[33,6678,6679],{"class":67},", webHandler)\n",[33,6681,6682,6684,6686,6688,6691],{"class":35,"line":147},[33,6683,6670],{"class":67},[33,6685,4299],{"class":46},[33,6687,2066],{"class":67},[33,6689,6690],{"class":50},"\"\u002Fassets\u002F*filepath\"",[33,6692,6679],{"class":67},[33,6694,6695,6697,6699,6701,6704],{"class":35,"line":163},[33,6696,6670],{"class":67},[33,6698,4299],{"class":46},[33,6700,2066],{"class":67},[33,6702,6703],{"class":50},"\"\u002Ffavicon.ico\"",[33,6705,6679],{"class":67},[33,6707,6708,6710,6712,6714,6717],{"class":35,"line":169},[33,6709,6670],{"class":67},[33,6711,4299],{"class":46},[33,6713,2066],{"class":67},[33,6715,6716],{"class":50},"\"\u002Ffavicon.svg\"",[33,6718,6679],{"class":67},[33,6720,6721,6723,6726],{"class":35,"line":183},[33,6722,6670],{"class":67},[33,6724,6725],{"class":46},"NoRoute",[33,6727,6728],{"class":67},"(webHandler)\n",[281,6730,6732],{"id":6731},"_3-一键构建脚本","3. 一键构建脚本",[23,6734,6736],{"className":25,"code":6735,"language":27,"meta":28,"style":28},"#!\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",[30,6737,6738,6743,6748,6752,6760,6764,6769,6779,6789,6793,6798,6809,6822,6826,6831,6839,6852,6862],{"__ignoreMap":28},[33,6739,6740],{"class":35,"line":36},[33,6741,6742],{"class":39},"#!\u002Fbin\u002Fbash\n",[33,6744,6745],{"class":35,"line":43},[33,6746,6747],{"class":39},"# build-standalone.sh\n",[33,6749,6750],{"class":35,"line":86},[33,6751,90],{"emptyLinePlaceholder":89},[33,6753,6754,6757],{"class":35,"line":93},[33,6755,6756],{"class":54},"set",[33,6758,6759],{"class":54}," -e\n",[33,6761,6762],{"class":35,"line":99},[33,6763,90],{"emptyLinePlaceholder":89},[33,6765,6766],{"class":35,"line":128},[33,6767,6768],{"class":39},"# 1. 构建前端\n",[33,6770,6771,6774,6776],{"class":35,"line":147},[33,6772,6773],{"class":46},"pnpm",[33,6775,113],{"class":50},[33,6777,6778],{"class":54}," --no-frozen-lockfile\n",[33,6780,6781,6783,6786],{"class":35,"line":163},[33,6782,1831],{"class":46},[33,6784,6785],{"class":50}," vite",[33,6787,6788],{"class":50}," build\n",[33,6790,6791],{"class":35,"line":169},[33,6792,90],{"emptyLinePlaceholder":89},[33,6794,6795],{"class":35,"line":183},[33,6796,6797],{"class":39},"# 2. 复制到嵌入目录\n",[33,6799,6800,6803,6806],{"class":35,"line":206},[33,6801,6802],{"class":46},"rm",[33,6804,6805],{"class":54}," -rf",[33,6807,6808],{"class":50}," service\u002Fweb\u002Fdist\n",[33,6810,6811,6814,6817,6820],{"class":35,"line":215},[33,6812,6813],{"class":46},"cp",[33,6815,6816],{"class":54}," -r",[33,6818,6819],{"class":50}," dist",[33,6821,6808],{"class":50},[33,6823,6824],{"class":35,"line":220},[33,6825,90],{"emptyLinePlaceholder":89},[33,6827,6828],{"class":35,"line":226},[33,6829,6830],{"class":39},"# 3. 编译 Go 二进制\n",[33,6832,6833,6836],{"class":35,"line":237},[33,6834,6835],{"class":54},"cd",[33,6837,6838],{"class":50}," service\n",[33,6840,6841,6843,6845,6847,6850],{"class":35,"line":248},[33,6842,172],{"class":46},[33,6844,188],{"class":50},[33,6846,197],{"class":54},[33,6848,6849],{"class":50}," myapp.exe",[33,6851,4417],{"class":54},[33,6853,6854,6857,6860],{"class":35,"line":253},[33,6855,6856],{"class":54},"    -ldflags=",[33,6858,6859],{"class":50},"\"-X myproject\u002Fglobal.RUNCODE=release\"",[33,6861,4417],{"class":54},[33,6863,6864],{"class":35,"line":262},[33,6865,6866],{"class":50},"    main.go\n",[12,6868,6869],{"id":6869},"踩坑记录",[281,6871,6873],{"id":6872},"坑一路由不匹配静态资源-404","坑一：路由不匹配，静态资源 404",[16,6875,6876,6877,6879,6880,6882],{},"首次实现时用了 Gin 的 ",[30,6878,6725],{}," 兜底，但部分静态资源返回 404。排查发现 ",[30,6881,6725],{}," 在某些路由未显式注册时不会按预期触发。",[16,6884,6885,6888,6889,6892,6893,6896,6897,6899],{},[1305,6886,6887],{},"解决","：显式注册 ",[30,6890,6891],{},"\u002Fassets\u002F*filepath","、",[30,6894,6895],{},"\u002Ffavicon.ico"," 等路由，",[30,6898,6725],{}," 仅作为兜底。",[281,6901,6903,6904,6906,6907,6910],{"id":6902},"坑二goembed-排除-_-开头文件","坑二：",[30,6905,6271],{}," 排除 ",[30,6908,6909],{},"_"," 开头文件",[16,6912,6913,6914,6917,6918,6920,6921,1957],{},"这是最坑的。Vite 构建产出了一个 ",[30,6915,6916],{},"_plugin-vue_export-helper-xxx.js","，Go 的 ",[30,6919,6271],{}," 当模式为目录时，",[1305,6922,6923,6924,6926,6927,6929],{},"硬编码排除了 ",[30,6925,6909],{}," 和 ",[30,6928,2188],{}," 开头的文件",[23,6931,6933],{"className":3490,"code":6932,"language":172,"meta":28,"style":28},"\u002F\u002F 这行会跳过 dist\u002Fassets\u002F_plugin-xxx.js\n\u002F\u002Fgo:embed dist\n",[30,6934,6935,6940],{"__ignoreMap":28},[33,6936,6937],{"class":35,"line":36},[33,6938,6939],{"class":39},"\u002F\u002F 这行会跳过 dist\u002Fassets\u002F_plugin-xxx.js\n",[33,6941,6942],{"class":35,"line":43},[33,6943,6392],{"class":39},[16,6945,6946,6947,1957],{},"错误表现：JS 文件返回的是 HTML 内容（SPA fallback），浏览器报 ",[30,6948,6949],{},"Unexpected token '\u003C'",[16,6951,6952,6954,6955,6957],{},[1305,6953,6887],{},"：改 Vite 配置，去除了 ",[30,6956,6909],{}," 前缀：",[23,6959,6963],{"className":6960,"code":6961,"language":6962,"meta":28,"style":28},"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",[30,6964,6965,6970,6977,6984,6991,6999,7037,7042,7047,7052],{"__ignoreMap":28},[33,6966,6967],{"class":35,"line":36},[33,6968,6969],{"class":39},"\u002F\u002F vite.config.ts\n",[33,6971,6972,6975],{"class":35,"line":43},[33,6973,6974],{"class":46},"build",[33,6976,1985],{"class":67},[33,6978,6979,6982],{"class":35,"line":86},[33,6980,6981],{"class":46},"  rollupOptions",[33,6983,1985],{"class":67},[33,6985,6986,6989],{"class":35,"line":93},[33,6987,6988],{"class":46},"    output",[33,6990,1985],{"class":67},[33,6992,6993,6996],{"class":35,"line":99},[33,6994,6995],{"class":46},"      chunkFileNames",[33,6997,6998],{"class":67},"(chunkInfo) {\n",[33,7000,7001,7003,7006,7009,7011,7014,7016,7018,7020,7022,7024,7026,7028,7030,7032,7034],{"class":35,"line":128},[33,7002,3944],{"class":131},[33,7004,7005],{"class":50}," `assets\u002F${",[33,7007,7008],{"class":67},"chunkInfo",[33,7010,2188],{"class":50},[33,7012,7013],{"class":67},"name",[33,7015,2188],{"class":50},[33,7017,2566],{"class":46},[33,7019,2066],{"class":50},[33,7021,2185],{"class":50},[33,7023,2172],{"class":131},[33,7025,6909],{"class":2175},[33,7027,2185],{"class":50},[33,7029,799],{"class":50},[33,7031,2583],{"class":50},[33,7033,2887],{"class":50},[33,7035,7036],{"class":50},"}-[hash].js`\n",[33,7038,7039],{"class":35,"line":147},[33,7040,7041],{"class":67},"      },\n",[33,7043,7044],{"class":35,"line":163},[33,7045,7046],{"class":67},"    },\n",[33,7048,7049],{"class":35,"line":169},[33,7050,7051],{"class":67},"  },\n",[33,7053,7054],{"class":35,"line":183},[33,7055,7056],{"class":67},"},\n",[16,7058,7059,7060,7062],{},"这样就避免产生 ",[30,7061,6909],{}," 前缀文件名的同时，保持构建产物的一致性和引用关系的正确性。",[281,7064,7066],{"id":7065},"坑三构建缓存导致-embed-内容未更新","坑三：构建缓存导致 embed 内容未更新",[16,7068,7069,7070,7073],{},"多次 ",[30,7071,7072],{},"go build"," 后，Go 的构建缓存可能使用了旧版本的 embed 内容，导致实际运行的文件不是最新的。",[16,7075,7076,7078,7079,7082],{},[1305,7077,6887],{},"：使用 ",[30,7080,7081],{},"go build -a"," 强制重新编译所有包。",[12,7084,7085],{"id":7085},"最终效果",[23,7087,7089],{"className":25,"code":7088,"language":27,"meta":28,"style":28},"$ bash build-standalone.sh\n=== 构建成功 ===\n输出: service\u002Fmyapp.exe  41M\n",[30,7090,7091,7101,7111],{"__ignoreMap":28},[33,7092,7093,7095,7098],{"class":35,"line":36},[33,7094,2182],{"class":46},[33,7096,7097],{"class":50}," bash",[33,7099,7100],{"class":50}," build-standalone.sh\n",[33,7102,7103,7105,7108],{"class":35,"line":43},[33,7104,2160],{"class":50},[33,7106,7107],{"class":50}," 构建成功",[33,7109,7110],{"class":50}," ===\n",[33,7112,7113,7116,7119],{"class":35,"line":86},[33,7114,7115],{"class":46},"输出:",[33,7117,7118],{"class":50}," service\u002Fmyapp.exe",[33,7120,7121],{"class":50},"  41M\n",[16,7123,7124],{},"一个 41MB 的 exe，内置前端页面、API 后端、SQLite 数据库（运行时初始化），拖到哪都能跑。",[16,7126,7127],{},[1305,7128,7129],{},"部署对比：",[1390,7131,7132,7145],{},[1393,7133,7134],{},[1396,7135,7136,7139,7142],{},[1399,7137,7138],{},"方式",[1399,7140,7141],{},"文件数",[1399,7143,7144],{},"操作",[1412,7146,7147,7158],{},[1396,7148,7149,7152,7155],{},[1417,7150,7151],{},"旧方案",[1417,7153,7154],{},"1 exe + 1 web 目录",[1417,7156,7157],{},"解压→保持目录结构",[1396,7159,7160,7163,7166],{},[1417,7161,7162],{},"新方案",[1417,7164,7165],{},"1 个 exe",[1417,7167,7168],{},"直接扔到服务器运行",[12,7170,1215],{"id":1215},[16,7172,7173,7174,7176],{},"Go 的 ",[30,7175,6271],{}," 是一个强大的工具，但有两个隐藏行为需要注意：",[285,7178,7179,7186],{},[288,7180,7181,7182,6926,7184,6929],{},"嵌入目录时自动排除 ",[30,7183,6909],{},[30,7185,2188],{},[288,7187,7188],{},"构建缓存可能导致 embed 不是最新的",[16,7190,7191],{},"对于前端+后端合体的场景，这是性价比最高的方案——不需要额外的打包工具，纯标准库搞定。",[825,7193,7194],{},"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":28,"searchDepth":43,"depth":86,"links":7196},[7197,7198,7199,7204,7210,7211],{"id":1933,"depth":43,"text":1933},{"id":6265,"depth":43,"text":6265},{"id":6279,"depth":43,"text":6279,"children":7200},[7201,7202,7203],{"id":6282,"depth":86,"text":6283},{"id":6619,"depth":86,"text":6620},{"id":6731,"depth":86,"text":6732},{"id":6869,"depth":43,"text":6869,"children":7205},[7206,7207,7209],{"id":6872,"depth":86,"text":6873},{"id":6902,"depth":86,"text":7208},"坑二：\u002F\u002Fgo:embed 排除 _ 开头文件",{"id":7065,"depth":86,"text":7066},{"id":7085,"depth":43,"text":7085},{"id":1215,"depth":43,"text":1215},"记录将 Vue 3 前端嵌入 Go 二进制实现单 exe 发布的完整流程，以及遇到的坑和解决方案。","\u002Fposts\u002Fgo-embed-frontend\u002Fimg\u002Fcover.jpg",{},"\u002Fposts\u002Fgo-embed-frontend","2026-06-06T16:00:00",{"title":6248,"description":7212},"posts\u002Fgo-embed-frontend\u002Findex",[852,7220,7221,7222],"Vue","编译","埋坑","2026-06-06","SIQNL24wVAFb18wIGWSB3lNVZUQ9B76jWgQ55TASTYk",{"id":7226,"title":7227,"author":7,"body":7228,"description":8624,"difficulty":1266,"draft":841,"extension":842,"image":8625,"meta":8626,"navigation":89,"path":8627,"pinned":841,"published":8628,"seo":8629,"series":7,"seriesOrder":7,"stem":8630,"tags":8631,"updated":8628,"__hash__":8633},"posts\u002Fposts\u002Fjira-cli-intro\u002Findex.md","jira_cli — 把企业 Jira 装进命令行，AI Agent 直接调用",{"type":9,"value":7229,"toc":8603},[7230,7234,7249,7316,7319,7322,7399,7401,7434,7441,7444,7448,7560,7564,7695,7699,7767,7770,7833,7836,7838,7842,7845,7865,7868,7883,7886,7890,7896,8034,8045,8076,8080,8083,8180,8184,8187,8305,8309,8312,8376,8379,8383,8386,8454,8457,8463,8466,8500,8507,8510,8570,8572,8575,8592,8600],[12,7231,7233],{"id":7232},"jira_cli-是什么","jira_cli 是什么",[16,7235,7236,7241,7242,6926,7245,7248],{},[1886,7237,7240],{"href":7238,"rel":7239},"https:\u002F\u002Fgithub.com\u002Fyourname\u002Fjira_cli",[1890],"jira_cli"," 是一个面向企业 Jira（Server \u002F Data Center）的本地命令行工具，Go 语言编写，MIT 协议开源。它同时提供 ",[1305,7243,7244],{},"Cobra CLI",[1305,7246,7247],{},"MCP stdio server"," 两种入口，既能当日常命令行用，又能直接接入 AI 编程工具。",[23,7250,7252],{"className":25,"code":7251,"language":27,"meta":28,"style":28},"# 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",[30,7253,7254,7259,7272,7283,7298,7302,7307],{"__ignoreMap":28},[33,7255,7256],{"class":35,"line":36},[33,7257,7258],{"class":39},"# CLI 日常使用\n",[33,7260,7261,7263,7266,7269],{"class":35,"line":43},[33,7262,7240],{"class":46},[33,7264,7265],{"class":50}," issue",[33,7267,7268],{"class":50}," search",[33,7270,7271],{"class":50}," \"project = PROJ ORDER BY updated DESC\"\n",[33,7273,7274,7276,7278,7280],{"class":35,"line":86},[33,7275,7240],{"class":46},[33,7277,7265],{"class":50},[33,7279,3471],{"class":50},[33,7281,7282],{"class":50}," PROJ-123\n",[33,7284,7285,7287,7289,7292,7295],{"class":35,"line":93},[33,7286,7240],{"class":46},[33,7288,7265],{"class":50},[33,7290,7291],{"class":50}," comment",[33,7293,7294],{"class":50}," PROJ-123",[33,7296,7297],{"class":50}," \"处理完成，请测试验证\"\n",[33,7299,7300],{"class":35,"line":99},[33,7301,90],{"emptyLinePlaceholder":89},[33,7303,7304],{"class":35,"line":128},[33,7305,7306],{"class":39},"# MCP 模式 — 给 AI 装上操作 Jira 的手\n",[33,7308,7309,7311,7313],{"class":35,"line":147},[33,7310,7240],{"class":46},[33,7312,3712],{"class":50},[33,7314,7315],{"class":54}," --user=work\n",[12,7317,7318],{"id":7318},"为什么需要它",[16,7320,7321],{},"大多数 Jira 命令行工具只解决\"开发者手动操作 Jira\"这一个场景。但 2026 年 AI 编程工具已经普及，你的 AI Agent（Claude Code、Codex、Cursor 等）也需要读写 Jira。痛点就来了：",[1390,7323,7324,7334],{},[1393,7325,7326],{},[1396,7327,7328,7331],{},[1399,7329,7330],{},"痛点",[1399,7332,7333],{},"jira_cli 怎么解",[1412,7335,7336,7344,7352,7364,7376,7387],{},[1396,7337,7338,7341],{},[1417,7339,7340],{},"企业 Jira 在内网，没有公网 API",[1417,7342,7343],{},"本地 stdio MCP server，不经过外网",[1396,7345,7346,7349],{},[1417,7347,7348],{},"AI 可能误删、误写 Jira 数据",[1417,7350,7351],{},"账号级只读策略，AI 只能查不能改",[1396,7353,7354,7357],{},[1417,7355,7356],{},"团队多人多 Jira 实例",[1417,7358,7359,7360,7363],{},"多 Profile 管理，",[30,7361,7362],{},"--user=work"," 切换",[1396,7365,7366,7369],{},[1417,7367,7368],{},"Windows 终端体验差",[1417,7370,7371,7372,7375],{},"原生 ",[30,7373,7374],{},".exe"," 构建 + 一键安装脚本",[1396,7377,7378,7381],{},[1417,7379,7380],{},"AI 生成 JQL 困难",[1417,7382,7383,7384],{},"字段元数据发现：",[30,7385,7386],{},"field list --query xxx",[1396,7388,7389,7392],{},[1417,7390,7391],{},"MCP 直接拿原始 JSON 太冗长",[1417,7393,7394,7395,7398],{},"紧凑视图 + ",[30,7396,7397],{},"--view summary"," 减少 token 消耗",[12,7400,5611],{"id":5611},[23,7402,7404],{"className":25,"code":7403,"language":27,"meta":28,"style":28},"# 从源码安装（要求 Go 1.26.3+）\ngo install github.com\u002Fyourname\u002Fjira_cli@latest\n\n# 或下载预编译二进制\n# https:\u002F\u002Fgithub.com\u002Fyourname\u002Fjira_cli\u002Freleases\n",[30,7405,7406,7411,7420,7424,7429],{"__ignoreMap":28},[33,7407,7408],{"class":35,"line":36},[33,7409,7410],{"class":39},"# 从源码安装（要求 Go 1.26.3+）\n",[33,7412,7413,7415,7417],{"class":35,"line":43},[33,7414,172],{"class":46},[33,7416,113],{"class":50},[33,7418,7419],{"class":50}," github.com\u002Fyourname\u002Fjira_cli@latest\n",[33,7421,7422],{"class":35,"line":86},[33,7423,90],{"emptyLinePlaceholder":89},[33,7425,7426],{"class":35,"line":93},[33,7427,7428],{"class":39},"# 或下载预编译二进制\n",[33,7430,7431],{"class":35,"line":99},[33,7432,7433],{"class":39},"# https:\u002F\u002Fgithub.com\u002Fyourname\u002Fjira_cli\u002Freleases\n",[16,7435,7436,7437,7440],{},"Windows 用户下载 zip 包解压后双击 ",[30,7438,7439],{},"install.bat"," 即可全局安装，自动加入 PATH。",[12,7442,7443],{"id":7443},"快速上手",[281,7445,7447],{"id":7446},"_1-配置认证","1. 配置认证",[23,7449,7451],{"className":25,"code":7450,"language":27,"meta":28,"style":28},"# 设置 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",[30,7452,7453,7458,7474,7478,7483,7503,7507,7512,7541,7545,7550],{"__ignoreMap":28},[33,7454,7455],{"class":35,"line":36},[33,7456,7457],{"class":39},"# 设置 Jira 地址\n",[33,7459,7460,7462,7465,7468,7471],{"class":35,"line":43},[33,7461,7240],{"class":46},[33,7463,7464],{"class":50}," set",[33,7466,7467],{"class":50}," base-url",[33,7469,7470],{"class":50}," https:\u002F\u002Fjira.your-company.com",[33,7472,7473],{"class":54}," --global\n",[33,7475,7476],{"class":35,"line":86},[33,7477,90],{"emptyLinePlaceholder":89},[33,7479,7480],{"class":35,"line":93},[33,7481,7482],{"class":39},"# Token 认证（推荐）\n",[33,7484,7485,7487,7489,7492,7494,7497,7499,7501],{"class":35,"line":99},[33,7486,7240],{"class":46},[33,7488,7464],{"class":50},[33,7490,7491],{"class":50}," token",[33,7493,580],{"class":131},[33,7495,7496],{"class":50},"your-toke",[33,7498,988],{"class":67},[33,7500,589],{"class":131},[33,7502,7473],{"class":54},[33,7504,7505],{"class":35,"line":128},[33,7506,90],{"emptyLinePlaceholder":89},[33,7508,7509],{"class":35,"line":147},[33,7510,7511],{"class":39},"# 或用户名密码认证\n",[33,7513,7514,7516,7518,7520,7522,7525,7527,7529,7531,7534,7537,7539],{"class":35,"line":163},[33,7515,7240],{"class":46},[33,7517,7464],{"class":50},[33,7519,5685],{"class":50},[33,7521,580],{"class":131},[33,7523,7524],{"class":50},"usernam",[33,7526,974],{"class":67},[33,7528,589],{"class":131},[33,7530,580],{"class":131},[33,7532,7533],{"class":50},"passwor",[33,7535,7536],{"class":67},"d",[33,7538,589],{"class":131},[33,7540,7473],{"class":54},[33,7542,7543],{"class":35,"line":169},[33,7544,90],{"emptyLinePlaceholder":89},[33,7546,7547],{"class":35,"line":183},[33,7548,7549],{"class":39},"# 验证一下\n",[33,7551,7552,7554,7557],{"class":35,"line":206},[33,7553,7240],{"class":46},[33,7555,7556],{"class":50}," user",[33,7558,7559],{"class":50}," me\n",[281,7561,7563],{"id":7562},"_2-日常使用","2. 日常使用",[23,7565,7567],{"className":25,"code":7566,"language":27,"meta":28,"style":28},"# 查自己今天的任务\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",[30,7568,7569,7574,7585,7589,7594,7604,7608,7613,7627,7644,7648,7653,7666,7670,7675],{"__ignoreMap":28},[33,7570,7571],{"class":35,"line":36},[33,7572,7573],{"class":39},"# 查自己今天的任务\n",[33,7575,7576,7578,7580,7582],{"class":35,"line":43},[33,7577,7240],{"class":46},[33,7579,7265],{"class":50},[33,7581,7268],{"class":50},[33,7583,7584],{"class":50}," \"assignee = currentUser() ORDER BY updated DESC\"\n",[33,7586,7587],{"class":35,"line":86},[33,7588,90],{"emptyLinePlaceholder":89},[33,7590,7591],{"class":35,"line":93},[33,7592,7593],{"class":39},"# 看某个任务详情\n",[33,7595,7596,7598,7600,7602],{"class":35,"line":99},[33,7597,7240],{"class":46},[33,7599,7265],{"class":50},[33,7601,3471],{"class":50},[33,7603,7282],{"class":50},[33,7605,7606],{"class":35,"line":128},[33,7607,90],{"emptyLinePlaceholder":89},[33,7609,7610],{"class":35,"line":147},[33,7611,7612],{"class":39},"# 流转状态\n",[33,7614,7615,7617,7619,7622,7624],{"class":35,"line":163},[33,7616,7240],{"class":46},[33,7618,7265],{"class":50},[33,7620,7621],{"class":50}," transitions",[33,7623,7294],{"class":50},[33,7625,7626],{"class":39},"                    # 可以转哪些\n",[33,7628,7629,7631,7633,7636,7638,7641],{"class":35,"line":169},[33,7630,7240],{"class":46},[33,7632,7265],{"class":50},[33,7634,7635],{"class":50}," do-transition",[33,7637,7294],{"class":50},[33,7639,7640],{"class":54}," 21",[33,7642,7643],{"class":39},"               # 执行流转\n",[33,7645,7646],{"class":35,"line":183},[33,7647,90],{"emptyLinePlaceholder":89},[33,7649,7650],{"class":35,"line":206},[33,7651,7652],{"class":39},"# 添加备注\n",[33,7654,7655,7657,7659,7661,7663],{"class":35,"line":215},[33,7656,7240],{"class":46},[33,7658,7265],{"class":50},[33,7660,7291],{"class":50},[33,7662,7294],{"class":50},[33,7664,7665],{"class":50}," \"已定位到问题，正在修复\"\n",[33,7667,7668],{"class":35,"line":220},[33,7669,90],{"emptyLinePlaceholder":89},[33,7671,7672],{"class":35,"line":226},[33,7673,7674],{"class":39},"# 创建任务\n",[33,7676,7677,7679,7681,7683,7686,7689,7692],{"class":35,"line":237},[33,7678,7240],{"class":46},[33,7680,7265],{"class":50},[33,7682,5571],{"class":50},[33,7684,7685],{"class":50}," PROJ",[33,7687,7688],{"class":50}," Task",[33,7690,7691],{"class":54}," --fields",[33,7693,7694],{"class":50}," @fields.json\n",[281,7696,7698],{"id":7697},"_3-配给-ai-使用","3. 配给 AI 使用",[23,7700,7702],{"className":25,"code":7701,"language":27,"meta":28,"style":28},"# 先配置一个只读账号给 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",[30,7703,7704,7709,7726,7749],{"__ignoreMap":28},[33,7705,7706],{"class":35,"line":36},[33,7707,7708],{"class":39},"# 先配置一个只读账号给 AI 用\n",[33,7710,7711,7713,7715,7717,7719,7721,7724],{"class":35,"line":43},[33,7712,7240],{"class":46},[33,7714,7464],{"class":50},[33,7716,7467],{"class":50},[33,7718,7470],{"class":50},[33,7720,577],{"class":54},[33,7722,7723],{"class":50}," ai",[33,7725,7473],{"class":54},[33,7727,7728,7730,7732,7734,7736,7739,7741,7743,7745,7747],{"class":35,"line":86},[33,7729,7240],{"class":46},[33,7731,7464],{"class":50},[33,7733,7491],{"class":50},[33,7735,580],{"class":131},[33,7737,7738],{"class":50},"toke",[33,7740,988],{"class":67},[33,7742,589],{"class":131},[33,7744,577],{"class":54},[33,7746,7723],{"class":50},[33,7748,7473],{"class":54},[33,7750,7751,7753,7755,7758,7761,7763,7765],{"class":35,"line":93},[33,7752,7240],{"class":46},[33,7754,7464],{"class":50},[33,7756,7757],{"class":50}," readonly",[33,7759,7760],{"class":50}," on",[33,7762,577],{"class":54},[33,7764,7723],{"class":50},[33,7766,7473],{"class":54},[16,7768,7769],{},"然后在 AI 工具的 MCP 配置里加上：",[23,7771,7773],{"className":5282,"code":7772,"language":5284,"meta":28,"style":28},"{\n  \"mcpServers\": {\n    \"jira\": {\n      \"command\": \"jira_cli\",\n      \"args\": [\"mcp\", \"--user=ai\"]\n    }\n  }\n}\n",[30,7774,7775,7779,7785,7792,7804,7821,7825,7829],{"__ignoreMap":28},[33,7776,7777],{"class":35,"line":36},[33,7778,5291],{"class":67},[33,7780,7781,7783],{"class":35,"line":43},[33,7782,5296],{"class":54},[33,7784,1985],{"class":67},[33,7786,7787,7790],{"class":35,"line":86},[33,7788,7789],{"class":54},"    \"jira\"",[33,7791,1985],{"class":67},[33,7793,7794,7797,7799,7802],{"class":35,"line":93},[33,7795,7796],{"class":54},"      \"command\"",[33,7798,1770],{"class":67},[33,7800,7801],{"class":50},"\"jira_cli\"",[33,7803,1717],{"class":67},[33,7805,7806,7809,7811,7814,7816,7819],{"class":35,"line":99},[33,7807,7808],{"class":54},"      \"args\"",[33,7810,1801],{"class":67},[33,7812,7813],{"class":50},"\"mcp\"",[33,7815,799],{"class":67},[33,7817,7818],{"class":50},"\"--user=ai\"",[33,7820,502],{"class":67},[33,7822,7823],{"class":35,"line":128},[33,7824,2026],{"class":67},[33,7826,7827],{"class":35,"line":147},[33,7828,2031],{"class":67},[33,7830,7831],{"class":35,"line":163},[33,7832,1360],{"class":67},[16,7834,7835],{},"这样 AI 就能查 Jira 但不能修改，既有了上下文，又不用担心误操作。",[12,7837,6093],{"id":6093},[281,7839,7841],{"id":7840},"_1-账号级只读策略","1. 账号级只读策略",[16,7843,7844],{},"这是 jira_cli 最实用的设计。给 AI Agent 配置只读账号：",[23,7846,7848],{"className":25,"code":7847,"language":27,"meta":28,"style":28},"jira_cli set readonly on --name ai\n",[30,7849,7850],{"__ignoreMap":28},[33,7851,7852,7854,7856,7858,7860,7862],{"class":35,"line":36},[33,7853,7240],{"class":46},[33,7855,7464],{"class":50},[33,7857,7757],{"class":50},[33,7859,7760],{"class":50},[33,7861,577],{"class":54},[33,7863,7864],{"class":50}," ai\n",[16,7866,7867],{},"只读模式下：",[1613,7869,7870,7873,7876],{},[288,7871,7872],{},"CLI 写命令被隐藏和拒绝",[288,7874,7875],{},"MCP 的写入工具不会注册",[288,7877,7878,7879,7882],{},"环境变量 ",[30,7880,7881],{},"JIRA_READONLY=true"," 也可以临时覆盖",[16,7884,7885],{},"AI 只能查 JQL、看任务、查字段、读评论，绝对不会误改数据。需要创建\u002F更新时，另配一个有写入权限的账号由开发者自己操作。",[281,7887,7889],{"id":7888},"_2-多-profile-切换","2. 多 Profile 切换",[16,7891,7892,7893,7895],{},"一个配置文件里管理多个 Jira 账号，",[30,7894,939],{}," 切换：",[23,7897,7899],{"className":25,"code":7898,"language":27,"meta":28,"style":28},"# 工作账号\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",[30,7900,7901,7906,7924,7947,7951,7956,7974,7997,8001,8006,8020],{"__ignoreMap":28},[33,7902,7903],{"class":35,"line":36},[33,7904,7905],{"class":39},"# 工作账号\n",[33,7907,7908,7910,7912,7914,7917,7919,7922],{"class":35,"line":43},[33,7909,7240],{"class":46},[33,7911,7464],{"class":50},[33,7913,7467],{"class":50},[33,7915,7916],{"class":50}," https:\u002F\u002Fjira.company.com",[33,7918,577],{"class":54},[33,7920,7921],{"class":50}," work",[33,7923,7473],{"class":54},[33,7925,7926,7928,7930,7932,7934,7937,7939,7941,7943,7945],{"class":35,"line":86},[33,7927,7240],{"class":46},[33,7929,7464],{"class":50},[33,7931,7491],{"class":50},[33,7933,580],{"class":131},[33,7935,7936],{"class":50},"work-toke",[33,7938,988],{"class":67},[33,7940,589],{"class":131},[33,7942,577],{"class":54},[33,7944,7921],{"class":50},[33,7946,7473],{"class":54},[33,7948,7949],{"class":35,"line":93},[33,7950,90],{"emptyLinePlaceholder":89},[33,7952,7953],{"class":35,"line":99},[33,7954,7955],{"class":39},"# 个人账号\n",[33,7957,7958,7960,7962,7964,7967,7969,7972],{"class":35,"line":128},[33,7959,7240],{"class":46},[33,7961,7464],{"class":50},[33,7963,7467],{"class":50},[33,7965,7966],{"class":50}," https:\u002F\u002Fjira.personal.com",[33,7968,577],{"class":54},[33,7970,7971],{"class":50}," personal",[33,7973,7473],{"class":54},[33,7975,7976,7978,7980,7982,7984,7987,7989,7991,7993,7995],{"class":35,"line":147},[33,7977,7240],{"class":46},[33,7979,7464],{"class":50},[33,7981,7491],{"class":50},[33,7983,580],{"class":131},[33,7985,7986],{"class":50},"personal-toke",[33,7988,988],{"class":67},[33,7990,589],{"class":131},[33,7992,577],{"class":54},[33,7994,7971],{"class":50},[33,7996,7473],{"class":54},[33,7998,7999],{"class":35,"line":163},[33,8000,90],{"emptyLinePlaceholder":89},[33,8002,8003],{"class":35,"line":169},[33,8004,8005],{"class":39},"# 使用时切换\n",[33,8007,8008,8010,8013,8015,8017],{"class":35,"line":183},[33,8009,7240],{"class":46},[33,8011,8012],{"class":54}," --user=work",[33,8014,7265],{"class":50},[33,8016,7268],{"class":50},[33,8018,8019],{"class":50}," \"project = PROJ\"\n",[33,8021,8022,8024,8027,8029,8031],{"class":35,"line":206},[33,8023,7240],{"class":46},[33,8025,8026],{"class":54}," --user=personal",[33,8028,7265],{"class":50},[33,8030,7268],{"class":50},[33,8032,8033],{"class":50}," \"project = SIDE\"\n",[16,8035,8036,8037,8040,8041,8044],{},"同时支持",[1305,8038,8039],{},"项目级配置","（",[30,8042,8043],{},"--local","）和工作目录绑定：",[23,8046,8048],{"className":25,"code":8047,"language":27,"meta":28,"style":28},"# 在项目目录下，配置只对当前项目生效\ncd ~\u002Fprojects\u002Fbackend\njira_cli set base-url https:\u002F\u002Fjira-backend.company.com --local\n",[30,8049,8050,8055,8062],{"__ignoreMap":28},[33,8051,8052],{"class":35,"line":36},[33,8053,8054],{"class":39},"# 在项目目录下，配置只对当前项目生效\n",[33,8056,8057,8059],{"class":35,"line":43},[33,8058,6835],{"class":54},[33,8060,8061],{"class":50}," ~\u002Fprojects\u002Fbackend\n",[33,8063,8064,8066,8068,8070,8073],{"class":35,"line":86},[33,8065,7240],{"class":46},[33,8067,7464],{"class":50},[33,8069,7467],{"class":50},[33,8071,8072],{"class":50}," https:\u002F\u002Fjira-backend.company.com",[33,8074,8075],{"class":54}," --local\n",[281,8077,8079],{"id":8078},"_3-对-ai-友好的输出","3. 对 AI 友好的输出",[16,8081,8082],{},"jira_cli 在输出设计上充分考虑了 AI Agent 的 token 消耗：",[23,8084,8086],{"className":25,"code":8085,"language":27,"meta":28,"style":28},"# 紧凑视图 — 去掉 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",[30,8087,8088,8093,8109,8113,8118,8135,8139,8144,8159,8163,8168],{"__ignoreMap":28},[33,8089,8090],{"class":35,"line":36},[33,8091,8092],{"class":39},"# 紧凑视图 — 去掉 self\u002Favatar\u002F缩略图等无用字段\n",[33,8094,8095,8097,8099,8101,8103,8106],{"class":35,"line":43},[33,8096,7240],{"class":46},[33,8098,7265],{"class":50},[33,8100,3471],{"class":50},[33,8102,7294],{"class":50},[33,8104,8105],{"class":54}," --view",[33,8107,8108],{"class":50}," summary\n",[33,8110,8111],{"class":35,"line":86},[33,8112,90],{"emptyLinePlaceholder":89},[33,8114,8115],{"class":35,"line":93},[33,8116,8117],{"class":39},"# 表格输出 — 一眼看到关键信息\n",[33,8119,8120,8122,8124,8126,8129,8132],{"class":35,"line":99},[33,8121,7240],{"class":46},[33,8123,7265],{"class":50},[33,8125,7268],{"class":50},[33,8127,8128],{"class":50}," \"project = PROJ\"",[33,8130,8131],{"class":54}," --output",[33,8133,8134],{"class":50}," table\n",[33,8136,8137],{"class":35,"line":128},[33,8138,90],{"emptyLinePlaceholder":89},[33,8140,8141],{"class":35,"line":147},[33,8142,8143],{"class":39},"# JSON 输出 — 程序解析，不转义 Unicode\n",[33,8145,8146,8148,8150,8153,8155,8157],{"class":35,"line":163},[33,8147,7240],{"class":46},[33,8149,8131],{"class":54},[33,8151,8152],{"class":50}," json",[33,8154,7265],{"class":50},[33,8156,3471],{"class":50},[33,8158,7282],{"class":50},[33,8160,8161],{"class":35,"line":169},[33,8162,90],{"emptyLinePlaceholder":89},[33,8164,8165],{"class":35,"line":183},[33,8166,8167],{"class":39},"# 自定义搜索列 — 只要需要的字段\n",[33,8169,8170,8172,8174,8177],{"class":35,"line":206},[33,8171,7240],{"class":46},[33,8173,7464],{"class":50},[33,8175,8176],{"class":50}," search-columns",[33,8178,8179],{"class":50}," key,summary,fixVersions\n",[281,8181,8183],{"id":8182},"_4-字段元数据发现","4. 字段元数据发现",[16,8185,8186],{},"Jira 的 customfield 让人头疼，jira_cli 提供了完整的字段发现能力：",[23,8188,8190],{"className":25,"code":8189,"language":27,"meta":28,"style":28},"# 列出所有字段及其类型\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",[30,8191,8192,8197,8206,8210,8215,8229,8233,8238,8249,8253,8258,8275,8279,8284],{"__ignoreMap":28},[33,8193,8194],{"class":35,"line":36},[33,8195,8196],{"class":39},"# 列出所有字段及其类型\n",[33,8198,8199,8201,8204],{"class":35,"line":43},[33,8200,7240],{"class":46},[33,8202,8203],{"class":50}," field",[33,8205,2120],{"class":50},[33,8207,8208],{"class":35,"line":86},[33,8209,90],{"emptyLinePlaceholder":89},[33,8211,8212],{"class":35,"line":93},[33,8213,8214],{"class":39},"# 搜索包含特定关键词的字段\n",[33,8216,8217,8219,8221,8223,8226],{"class":35,"line":99},[33,8218,7240],{"class":46},[33,8220,8203],{"class":50},[33,8222,5506],{"class":50},[33,8224,8225],{"class":54}," --query",[33,8227,8228],{"class":50}," \"版本\"\n",[33,8230,8231],{"class":35,"line":128},[33,8232,90],{"emptyLinePlaceholder":89},[33,8234,8235],{"class":35,"line":147},[33,8236,8237],{"class":39},"# 只看自定义字段\n",[33,8239,8240,8242,8244,8246],{"class":35,"line":163},[33,8241,7240],{"class":46},[33,8243,8203],{"class":50},[33,8245,5506],{"class":50},[33,8247,8248],{"class":54}," --custom-only\n",[33,8250,8251],{"class":35,"line":169},[33,8252,90],{"emptyLinePlaceholder":89},[33,8254,8255],{"class":35,"line":183},[33,8256,8257],{"class":39},"# 创建任务前，先看看需要哪些字段\n",[33,8259,8260,8262,8264,8267,8269,8272],{"class":35,"line":206},[33,8261,7240],{"class":46},[33,8263,7265],{"class":50},[33,8265,8266],{"class":50}," create-meta",[33,8268,7685],{"class":50},[33,8270,8271],{"class":54}," --issue-type",[33,8273,8274],{"class":50}," Task\n",[33,8276,8277],{"class":35,"line":215},[33,8278,90],{"emptyLinePlaceholder":89},[33,8280,8281],{"class":35,"line":220},[33,8282,8283],{"class":39},"# 分页查看可选值\n",[33,8285,8286,8288,8290,8293,8295,8297,8300,8302],{"class":35,"line":226},[33,8287,7240],{"class":46},[33,8289,7265],{"class":50},[33,8291,8292],{"class":50}," create-meta-values",[33,8294,7685],{"class":50},[33,8296,7688],{"class":50},[33,8298,8299],{"class":50}," priority",[33,8301,8225],{"class":54},[33,8303,8304],{"class":50}," High\n",[281,8306,8308],{"id":8307},"_5-mcp-写入控制","5. MCP 写入控制",[16,8310,8311],{},"除了全局只读，MCP 还有更细粒度的控制：",[1390,8313,8314,8324],{},[1393,8315,8316],{},[1396,8317,8318,8321],{},[1399,8319,8320],{},"环境变量",[1399,8322,8323],{},"作用",[1412,8325,8326,8336,8346,8356,8366],{},[1396,8327,8328,8333],{},[1417,8329,8330],{},[30,8331,8332],{},"JIRA_CREATE_OPEN=false",[1417,8334,8335],{},"禁用创建\u002F更新工具",[1396,8337,8338,8343],{},[1417,8339,8340],{},[30,8341,8342],{},"JIRA_DISABLE_DISCLAIMER=true",[1417,8344,8345],{},"去掉 AI 自动添加的备注声明",[1396,8347,8348,8353],{},[1417,8349,8350],{},[30,8351,8352],{},"JIRA_CREATE_SUMMARY_MAX",[1417,8354,8355],{},"限制摘要长度",[1396,8357,8358,8363],{},[1417,8359,8360],{},[30,8361,8362],{},"JIRA_CREATE_DESCRIPTION_MAX",[1417,8364,8365],{},"限制描述长度",[1396,8367,8368,8373],{},[1417,8369,8370],{},[30,8371,8372],{},"JIRA_CREATE_FIELDS_MAX_BYTES",[1417,8374,8375],{},"限制创建字段的 JSON 大小",[16,8377,8378],{},"这些限制确保 AI 即使有写入权限也不会\"失控\"——例如防止 AI 往描述里塞几万字的废话。",[281,8380,8382],{"id":8381},"_6-中英双语帮助","6. 中英双语帮助",[16,8384,8385],{},"帮助信息默认中文，也支持一键切英文：",[23,8387,8389],{"className":25,"code":8388,"language":27,"meta":28,"style":28},"# 保存英文偏好\njira_cli set help-language en\n\n# 切回中文\njira_cli set help-language zh\n\n# 临时覆盖\njira_cli --lang en issue search -h\n",[30,8390,8391,8396,8408,8412,8417,8428,8432,8437],{"__ignoreMap":28},[33,8392,8393],{"class":35,"line":36},[33,8394,8395],{"class":39},"# 保存英文偏好\n",[33,8397,8398,8400,8402,8405],{"class":35,"line":43},[33,8399,7240],{"class":46},[33,8401,7464],{"class":50},[33,8403,8404],{"class":50}," help-language",[33,8406,8407],{"class":50}," en\n",[33,8409,8410],{"class":35,"line":86},[33,8411,90],{"emptyLinePlaceholder":89},[33,8413,8414],{"class":35,"line":93},[33,8415,8416],{"class":39},"# 切回中文\n",[33,8418,8419,8421,8423,8425],{"class":35,"line":99},[33,8420,7240],{"class":46},[33,8422,7464],{"class":50},[33,8424,8404],{"class":50},[33,8426,8427],{"class":50}," zh\n",[33,8429,8430],{"class":35,"line":128},[33,8431,90],{"emptyLinePlaceholder":89},[33,8433,8434],{"class":35,"line":147},[33,8435,8436],{"class":39},"# 临时覆盖\n",[33,8438,8439,8441,8444,8447,8449,8451],{"class":35,"line":163},[33,8440,7240],{"class":46},[33,8442,8443],{"class":54}," --lang",[33,8445,8446],{"class":50}," en",[33,8448,7265],{"class":50},[33,8450,7268],{"class":50},[33,8452,8453],{"class":54}," -h\n",[12,8455,8456],{"id":8456},"架构一览",[23,8458,8461],{"className":8459,"code":8460,"language":1376},[1374],"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",[30,8462,8460],{"__ignoreMap":28},[16,8464,8465],{},"设计原则清晰：",[1613,8467,8468,8476,8484,8492],{},[288,8469,8470,8475],{},[1305,8471,8472],{},[30,8473,8474],{},"pkg\u002Fjira"," 是纯 Jira 客户端，HTTP、认证、上下文处理都在这里",[288,8477,8478,8483],{},[1305,8479,8480],{},[30,8481,8482],{},"cmd"," 只管 Cobra 解析和呈现",[288,8485,8486,8491],{},[1305,8487,8488],{},[30,8489,8490],{},"internal\u002Ftools\u002Fjira_tools"," 只管 MCP schema 验证和结果适配",[288,8493,8494,8499],{},[1305,8495,8496],{},[30,8497,8498],{},"internal\u002Fconfig"," 拥有 Profile 的全部逻辑，不分散到 cmd",[16,8501,8502,8503,8506],{},"每一层职责单一，新增 Jira 能力时按 ",[30,8504,8505],{},"endpoints.go → Client 方法 → CLI 命令 → MCP tool → 策略注册"," 的路径走就行。",[12,8508,8509],{"id":8509},"和已有方案的对比",[1390,8511,8512,8524],{},[1393,8513,8514],{},[1396,8515,8516,8518,8521],{},[1399,8517,2745],{},[1399,8519,8520],{},"适合场景",[1399,8522,8523],{},"jira_cli 的区别",[1412,8525,8526,8537,8548,8559],{},[1396,8527,8528,8531,8534],{},[1417,8529,8530],{},"Atlassian Rovo MCP Server",[1417,8532,8533],{},"Jira Cloud、官方 OAuth",[1417,8535,8536],{},"jira_cli 是本地 stdio，适合内网 Server\u002FDC",[1396,8538,8539,8542,8545],{},[1417,8540,8541],{},"go-jira \u002F jira-cli 社区版",[1417,8543,8544],{},"纯命令行管理",[1417,8546,8547],{},"jira_cli 同时提供 MCP server，面向 AI 工作流",[1396,8549,8550,8553,8556],{},[1417,8551,8552],{},"直接调 REST API",[1417,8554,8555],{},"自定义集成",[1417,8557,8558],{},"jira_cli 封装了认证、只读策略、字段元数据",[1396,8560,8561,8564,8567],{},[1417,8562,8563],{},"Jira 网页",[1417,8565,8566],{},"日常使用",[1417,8568,8569],{},"jira_cli 把 Jira 变成终端里的一行命令",[12,8571,1215],{"id":1215},[16,8573,8574],{},"如果你的团队满足以下任一条件，jira_cli 值得一试：",[1613,8576,8577,8580,8583,8586,8589],{},[288,8578,8579],{},"企业 Jira 部署在内网，想接入 AI 编程工具",[288,8581,8582],{},"想给 AI Agent 只读权限来查 Jira 上下文",[288,8584,8585],{},"需要命令行快速查看\u002F操作 Jira，不想切浏览器",[288,8587,8588],{},"需要管理多个 Jira 实例和多套账号",[288,8590,8591],{},"Windows 用户想要无摩擦的 CLI 体验",[16,8593,8594,8595,8599],{},"GitHub：",[1886,8596,8598],{"href":7238,"rel":8597},[1890],"github.com\u002Fyourname\u002Fjira_cli"," | MIT 协议 | Go 1.26.3+",[825,8601,8602],{},"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":28,"searchDepth":43,"depth":86,"links":8604},[8605,8606,8607,8608,8613,8621,8622,8623],{"id":7232,"depth":43,"text":7233},{"id":7318,"depth":43,"text":7318},{"id":5611,"depth":43,"text":5611},{"id":7443,"depth":43,"text":7443,"children":8609},[8610,8611,8612],{"id":7446,"depth":86,"text":7447},{"id":7562,"depth":86,"text":7563},{"id":7697,"depth":86,"text":7698},{"id":6093,"depth":43,"text":6093,"children":8614},[8615,8616,8617,8618,8619,8620],{"id":7840,"depth":86,"text":7841},{"id":7888,"depth":86,"text":7889},{"id":8078,"depth":86,"text":8079},{"id":8182,"depth":86,"text":8183},{"id":8307,"depth":86,"text":8308},{"id":8381,"depth":86,"text":8382},{"id":8456,"depth":43,"text":8456},{"id":8509,"depth":43,"text":8509},{"id":1215,"depth":43,"text":1215},"介绍 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":7227,"description":8624},"posts\u002Fjira-cli-intro\u002Findex",[8632,6243,5458,852,5460,6244],"Jira","vNH6sF2mMFmSjbHoEI2OqmYCgpFmaqyKt-MUfcQqXrU",{"id":8635,"title":8636,"author":7,"body":8637,"description":9181,"difficulty":1266,"draft":841,"extension":842,"image":9182,"meta":9183,"navigation":89,"path":9184,"pinned":841,"published":9185,"seo":9186,"series":9187,"seriesOrder":36,"stem":9188,"tags":9189,"updated":9192,"__hash__":9193},"posts\u002Fposts\u002Fsublog-evolution\u002Findex.md","尼克的小窝 架构变迁：从纯静态到前后端分离",{"type":9,"value":8638,"toc":9161},[8639,8643,8646,8657,8661,8665,8671,8675,8706,8709,8723,8727,8730,8798,8802,8805,8811,8815,8825,8855,8858,8862,8870,8873,8917,8921,9042,9046,9053,9057,9063,9069,9073,9076,9127,9131,9138,9145,9151,9154,9156],[12,8640,8642],{"id":8641},"写在前面","📝 写在前面",[16,8644,8645],{},"SuBlog 最初只是一个简单的技术博客——Nuxt 3 静态生成，Markdown 写文章，部署到静态托管完事。但随着功能需求的增长，纯静态的架构开始显露出局限性。",[16,8647,8648,8649,8652,8653,8656],{},"这篇文章记录从 ",[1305,8650,8651],{},"纯静态"," 到 ",[1305,8654,8655],{},"前后端分离"," 的演进过程，带你了解每一步背后的思考。",[12,8658,8660],{"id":8659},"第一代纯静态nuxt-3-nuxtcontent","📦 第一代：纯静态（Nuxt 3 + @nuxt\u002Fcontent）",[281,8662,8664],{"id":8663},"️-架构图","🏗️ 架构图",[23,8666,8669],{"className":8667,"code":8668,"language":1376,"meta":28},[1374],"┌─────────────────────────────────────┐\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",[30,8670,8668],{"__ignoreMap":28},[281,8672,8674],{"id":8673},"做了什么","🔧 做了什么",[1613,8676,8677,8686,8691,8696],{},[288,8678,8679,1232,8681,1232,8683,8685],{},[1305,8680,1552],{},[1305,8682,1562],{},[1305,8684,1572],{}," 搭建前端",[288,8687,8688,8690],{},[1305,8689,1582],{}," 管理 Markdown 文章，编译时生成静态页面",[288,8692,8693,8695],{},[1305,8694,1602],{}," 嵌入评论，依赖 GitHub Discussions",[288,8697,8698,8699,8702,8703,8705],{},"部署通过 ",[30,8700,8701],{},"nuxt generate"," 输出 ",[30,8704,1843],{},"，丢到 Nginx 或 Vercel",[281,8707,8708],{"id":2748},"✅ 优点",[1613,8710,8711,8717,8720],{},[288,8712,8713,8714,8716],{},"部署极简，一个 ",[30,8715,8701],{}," 搞定",[288,8718,8719],{},"加载飞快，全是 HTML，没有服务端开销",[288,8721,8722],{},"无需后端，零运维成本",[281,8724,8726],{"id":8725},"局限","❌ 局限",[16,8728,8729],{},"随着时间的推移，纯静态的问题越来越明显：",[1390,8731,8732,8741],{},[1393,8733,8734],{},[1396,8735,8736,8738],{},[1399,8737,3042],{},[1399,8739,8740],{},"具体表现",[1412,8742,8743,8754,8765,8776,8787],{},[1396,8744,8745,8751],{},[1417,8746,8747,8748],{},"🐢 ",[1305,8749,8750],{},"内容更新慢",[1417,8752,8753],{},"改一篇文章要重新构建、重新部署，不能实时发布",[1396,8755,8756,8762],{},[1417,8757,8758,8759],{},"👤 ",[1305,8760,8761],{},"没有用户体系",[1417,8763,8764],{},"无法区分读者和管理员，所有内容对所有人一致可见",[1396,8766,8767,8773],{},[1417,8768,8769,8770],{},"🔒 ",[1305,8771,8772],{},"评论受限",[1417,8774,8775],{},"Giscus 需要 GitHub 账号才能评论，门槛高",[1396,8777,8778,8784],{},[1417,8779,8780,8781],{},"🚫 ",[1305,8782,8783],{},"无法做动态功能",[1417,8785,8786],{},"点赞、收藏、阅读统计、个性化推荐……统统做不了",[1396,8788,8789,8795],{},[1417,8790,8791,8792],{},"🔗 ",[1305,8793,8794],{},"前端锁死",[1417,8796,8797],{},"被 Nuxt\u002FVue 生态绑定，想换技术栈得重写",[12,8799,8801],{"id":8800},"第二代前后端分离react-go","⚡ 第二代：前后端分离（React + Go）",[281,8803,8664],{"id":8804},"️-架构图-1",[23,8806,8809],{"className":8807,"code":8808,"language":1376,"meta":28},[1374],"┌───────────────────────┐       ┌────────────────────┐\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",[30,8810,8808],{"__ignoreMap":28},[281,8812,8814],{"id":8813},"️-前端sublog-react","🖥️ 前端：sublog-react",[16,8816,8817,8818,8820,8821,8824],{},"选用 ",[1305,8819,1407],{},"（App Router） + ",[1305,8822,8823],{},"React 19","，理由是：",[1613,8826,8827,8834,8841,8848],{},[288,8828,8829,8830,8833],{},"🎨 ",[1305,8831,8832],{},"React 生态","——组件库成熟，开发效率高",[288,8835,8836,8837,8840],{},"📐 ",[1305,8838,8839],{},"App Router","——支持布局嵌套，页面组织清晰",[288,8842,8843,8844,8847],{},"📦 ",[1305,8845,8846],{},"静态导出","——仍可部署到静态托管",[288,8849,8850,8851,8854],{},"⚙️ ",[1305,8852,8853],{},"运行时配置","——API 地址可动态注入，切换环境无需重新构建",[16,8856,8857],{},"前端不再直接读取 Markdown 文件，而是通过 HTTP API 获取文章数据。静态导出 + 客户端数据获取的组合，既保留了首屏加载速度，又实现了内容的动态更新。",[281,8859,8861],{"id":8860},"️-后端sublog-s","⚙️ 后端：sublog-s",[16,8863,8864,8865,1232,8867,8869],{},"后端使用 ",[1305,8866,852],{},[1305,8868,5459],{},"，按业务逻辑分层组织代码。",[16,8871,8872],{},"核心技术选型：",[1390,8874,8875,8883],{},[1393,8876,8877],{},[1396,8878,8879,8881],{},[1399,8880,1540],{},[1399,8882,1543],{},[1412,8884,8885,8895,8906],{},[1396,8886,8887,8892],{},[1417,8888,8889,8890],{},"🚀 ",[1305,8891,5459],{},[1417,8893,8894],{},"HTTP 框架",[1396,8896,8897,8903],{},[1417,8898,8899,8900],{},"🗄️ ",[1305,8901,8902],{},"GORM",[1417,8904,8905],{},"ORM，数据库操作",[1396,8907,8908,8914],{},[1417,8909,8910,8911],{},"🔐 ",[1305,8912,8913],{},"JWT",[1417,8915,8916],{},"用户认证",[12,8918,8920],{"id":8919},"前后对比","📊 前后对比",[1390,8922,8923,8936],{},[1393,8924,8925],{},[1396,8926,8927,8930,8933],{},[1399,8928,8929],{},"维度",[1399,8931,8932],{},"第一代（纯静态）",[1399,8934,8935],{},"第二代（前后端分离）",[1412,8937,8938,8950,8962,8976,8989,9002,9016,9028],{},[1396,8939,8940,8944,8947],{},[1417,8941,8829,8942],{},[1305,8943,1505],{},[1417,8945,8946],{},"Nuxt 3 (Vue)",[1417,8948,8949],{},"Next.js (React 19)",[1396,8951,8952,8957,8959],{},[1417,8953,8954,8955],{},"🖥️ ",[1305,8956,5461],{},[1417,8958,1448],{},[1417,8960,8961],{},"Go + Gin",[1396,8963,8964,8970,8973],{},[1417,8965,8966,8967],{},"📄 ",[1305,8968,8969],{},"内容来源",[1417,8971,8972],{},"Markdown 文件",[1417,8974,8975],{},"API 数据库",[1396,8977,8978,8983,8986],{},[1417,8979,8889,8980],{},[1305,8981,8982],{},"发布方式",[1417,8984,8985],{},"全量重新构建",[1417,8987,8988],{},"API 实时发布",[1396,8990,8991,8996,8999],{},[1417,8992,8758,8993],{},[1305,8994,8995],{},"用户系统",[1417,8997,8998],{},"❌ 无",[1417,9000,9001],{},"✅ 注册\u002F登录",[1396,9003,9004,9010,9013],{},[1417,9005,9006,9007],{},"💬 ",[1305,9008,9009],{},"评论",[1417,9011,9012],{},"Giscus（需 GitHub）",[1417,9014,9015],{},"自有评论系统（规划中）",[1396,9017,9018,9022,9025],{},[1417,9019,8843,9020],{},[1305,9021,1818],{},[1417,9023,9024],{},"1 个静态目录",[1417,9026,9027],{},"前端静态 + 后端二进制",[1396,9029,9030,9036,9039],{},[1417,9031,9032,9033],{},"⚡ ",[1305,9034,9035],{},"更新体验",[1417,9037,9038],{},"改文章 → 构建 → 部署",[1417,9040,9041],{},"改文章 → 立即生效",[12,9043,9045],{"id":9044},"新功能注册账号","🎉 新功能：注册账号",[16,9047,9048,9049,9052],{},"伴随着架构升级，",[1305,9050,9051],{},"用户账号系统","也上线了。",[281,9054,9056],{"id":9055},"目前支持","📋 目前支持",[16,9058,9059,9060,3209],{},"现在你可以在尼克的小窝 ",[1305,9061,9062],{},"注册一个账号",[23,9064,9067],{"className":9065,"code":9066,"language":1376},[1374],"1. ✏️ 填写用户名、邮箱、密码\n2. 📧 查收激活邮件\n3. 🔗 点击链接激活账号\n4. 🚪 登录后查看和管理个人信息\n",[30,9068,9066],{"__ignoreMap":28},[12,9070,9072],{"id":9071},"️-后续计划","🗓️ 后续计划",[16,9074,9075],{},"正在开发或规划中的功能：",[1613,9077,9079,9085,9091,9097,9103,9109,9115,9121],{"className":9078},[1851],[288,9080,9082,9084],{"className":9081},[1855],[1857,9083],{"checked":89,"disabled":89,"type":1859}," 用户注册\u002F登录（已上线 🎉）",[288,9086,9088,9090],{"className":9087},[1855],[1857,9089],{"checked":89,"disabled":89,"type":1859}," 个人资料编辑（已上线 🎉）",[288,9092,9094,9096],{"className":9093},[1855],[1857,9095],{"disabled":89,"type":1859}," 💬 站内评论系统",[288,9098,9100,9102],{"className":9099},[1855],[1857,9101],{"disabled":89,"type":1859}," 🔖 文章收藏",[288,9104,9106,9108],{"className":9105},[1855],[1857,9107],{"disabled":89,"type":1859}," 📚 阅读历史",[288,9110,9112,9114],{"className":9111},[1855],[1857,9113],{"disabled":89,"type":1859}," 👍 文章点赞",[288,9116,9118,9120],{"className":9117},[1855],[1857,9119],{"disabled":89,"type":1859}," 🔔 关注通知",[288,9122,9124,9126],{"className":9123},[1855],[1857,9125],{"disabled":89,"type":1859}," 🎨 个人首页定制",[12,9128,9130],{"id":9129},"结语","💡 结语",[16,9132,9133,9134,9137],{},"从 Nuxt 3 纯静态到 React + Go 前后端分离，SuBlog 的架构变迁本质上是一个",[1305,9135,9136],{},"功能驱动","的过程。静态博客有它的位置——写写文章、展示内容完全够用。但当你想做的不仅仅是\"展示\"时，后端的能力就不可或缺了。",[16,9139,9140,9141,9144],{},"这次重构最大的感触是：",[1305,9142,9143],{},"架构选型不是越复杂越好，而是刚好够用最好","。纯静态阶段用 Nuxt 3 一点问题没有，直到功能需求超出了静态的边界，才自然推动到前后端分离。",[16,9146,9147,9148],{},"如果你也在纠结\"要不要上后端\"，不妨先问问自己：",[1305,9149,9150],{},"你要做的是\"展示内容\"还是\"提供服务\"？",[16,9152,9153],{},"前者，静态就够了；后者，来吧，后端欢迎你。😄",[1894,9155],{},[16,9157,9158],{},[1305,9159,9160],{},"🎉 目前注册功能已经可以用了，后续更多好玩的功能正在路上。部分功能可能需要登录才能体验，快注册一个账号吧！",{"title":28,"searchDepth":43,"depth":86,"links":9162},[9163,9164,9170,9175,9176,9179,9180],{"id":8641,"depth":43,"text":8642},{"id":8659,"depth":43,"text":8660,"children":9165},[9166,9167,9168,9169],{"id":8663,"depth":86,"text":8664},{"id":8673,"depth":86,"text":8674},{"id":2748,"depth":86,"text":8708},{"id":8725,"depth":86,"text":8726},{"id":8800,"depth":43,"text":8801,"children":9171},[9172,9173,9174],{"id":8804,"depth":86,"text":8664},{"id":8813,"depth":86,"text":8814},{"id":8860,"depth":86,"text":8861},{"id":8919,"depth":43,"text":8920},{"id":9044,"depth":43,"text":9045,"children":9177},[9178],{"id":9055,"depth":86,"text":9056},{"id":9071,"depth":43,"text":9072},{"id":9129,"depth":43,"text":9130},"记录尼克的小窝从 Nuxt 3 纯静态博客演进到 React + Go 前后端分离架构的全过程，包括架构设计的思考、技术选型的变化，以及新增的账号注册功能。","\u002Fposts\u002Fsublog-evolution\u002Fimg\u002Fcover.jpg",{},"\u002Fposts\u002Fsublog-evolution","2026-07-23T12:00:00",{"title":8636,"description":9181},"尼克的小窝 开发记录","posts\u002Fsublog-evolution\u002Findex",[9190,9191,1407,852],"架构","React","2026-07-23","VMQ6A2K8GQ5t3DSlezWPd3KgXnmDVMn9QVeaI28Hcp8",{"id":9195,"title":9196,"author":7,"body":9197,"description":10360,"difficulty":1266,"draft":841,"extension":842,"image":10361,"meta":10362,"navigation":89,"path":10363,"pinned":841,"published":10364,"seo":10365,"series":7,"seriesOrder":7,"stem":10366,"tags":10367,"updated":10371,"__hash__":10372},"posts\u002Fposts\u002Fip-geolocation-guide\u002Findex.md","IP 属地是怎么查出来的？本地查询与在线查询方案对比",{"type":9,"value":9198,"toc":10336},[9199,9201,9207,9210,9221,9231,9235,9241,9254,9260,9271,9278,9281,9288,9292,9303,9307,9318,9321,9325,9331,9337,9340,9344,9351,9369,9372,9375,9379,9410,9449,9455,9459,9598,9634,9643,9647,9687,9692,9696,9707,9710,9744,9747,9751,9754,9758,9949,9953,10036,10040,10047,10154,10158,10167,10253,10256,10259,10295,10298,10301,10309,10330,10333],[12,9200,8641],{"id":8641},[16,9202,9203,9204],{},"很多网站会在「登录记录」「安全中心」里显示你的登录 IP 归属地，比如「福建·福州 · 中国移动」。你可能好奇过：",[1305,9205,9206],{},"服务器是怎么根据一个 IP 就判断出我在哪个城市、哪个运营商的？",[16,9208,9209],{},"这篇科普文章讲清楚三件事：",[285,9211,9212,9215,9218],{},[288,9213,9214],{},"IP 属地到底查的是什么；",[288,9216,9217],{},"纯本地查询为什么能做到毫秒级且不联网；",[288,9219,9220],{},"几种主流查询方案的优缺点对比，方便你按场景选型。",[9222,9223,9224],"blockquote",{},[16,9225,9226,9227,9230],{},"说明：文中用到的 IP 均为 RFC 5737 保留的文档示例段（TEST-NET-2，",[30,9228,9229],{},"198.51.100.0\u002F24","），不会对应任何真实地址。",[12,9232,9234],{"id":9233},"先想清楚ip-属地查的是什么","先想清楚：IP 属地查的是什么",[16,9236,9237,9238,1957],{},"一个常见误区是「每个 IP 对应一个具体地址」。实际上 IP 属地的本质是 ",[1305,9239,9240],{},"IP 段与归属地的映射",[16,9242,9243,9244,3209],{},"运营商和机构在申请 IP 时，是按",[1305,9245,9246,9247,9253],{},"网段（CIDR）",[1305,9248,9249,9250,9252],{},"成块分配的。比如 ",[30,9251,9229],{}," 这一个段，一整块都归属同一地区、同一运营商。所以归属地数据天然可以","按段存储",[23,9255,9258],{"className":9256,"code":9257,"language":1376,"meta":28},[1374],"IP 起始          IP 结束         归属地\n────────────────────────────────────────\n198.51.100.0     198.51.100.255  中国|福建|福州|移动\n198.51.101.0     198.51.101.255  中国|浙江|杭州|电信\n",[30,9259,9257],{"__ignoreMap":28},[16,9261,9262,9263,9266,9267,9270],{},"全球公网 IP 约有 42 亿个，但",[1305,9264,9265],{},"网段只有几十万条","。正因为存的是「段」而不是「每个 IP」，完整的归属地数据文件才只有几 MB（ip2region 的 ",[30,9268,9269],{},".xdb"," 约 11MB）。这是「本地离线查询」可行的第一前提。",[16,9272,9273,9274,9277],{},"所以，",[1305,9275,9276],{},"IP 属地从来不是精确到街道的定位","，它返回的是段级的「省｜市｜运营商」粗粒度信息。",[12,9279,9280],{"id":9280},"纯本地查询的原理",[16,9282,9283,9284,9287],{},"以开源的 ",[1305,9285,9286],{},"ip2region"," 为例，它的查询完全在本地完成，不联网、不请求第三方，几十微秒到毫秒级返回。",[281,9289,9291],{"id":9290},"第一步把-ip-变成整数","第一步：把 IP 变成整数",[16,9293,9294,9295,9298,9299,9302],{},"以 ",[30,9296,9297],{},"198.51.100.42"," 为例，它会转成一个 32 位整数 ",[30,9300,9301],{},"0xC633642A","。IP 的四个数字本质就是 4 个字节，拼起来就是一个整数，这一步没有任何玄学。",[281,9304,9306],{"id":9305},"第二步二分查找","第二步：二分查找",[16,9308,9309,9310,9313,9314,9317],{},"在按起始 IP 排序的段列表里做",[1305,9311,9312],{},"二分查找","，找到满足 ",[30,9315,9316],{},"段的起始IP ≤ 目标整数 ≤ 段的结束IP"," 的那一段。",[16,9319,9320],{},"二分查找的复杂度是 O(log N)，N 是几十万条，所以很快——这就是「不靠网络也能毫秒返回」的核心。",[281,9322,9324],{"id":9323},"第三步xdb-文件的两级结构","第三步：.xdb 文件的两级结构",[16,9326,9327,9328,9330],{},"ip2region 的 ",[30,9329,9269],{}," 不是简单顺序文件，内部分两个区，进一步加速：",[23,9332,9335],{"className":9333,"code":9334,"language":1376,"meta":28},[1374],"┌─────────────────────────────┐\n│  索引区 (Index)             │  \u002F\u002F 每段一条定长记录：startIP \u002F endIP \u002F 数据区指针 + 数据长度\n│  …… 二分快速定位段 ……       │\n├─────────────────────────────┤\n│  数据区 (Data)              │  \u002F\u002F 真正存放各段的地点字符串\n│  ……                         │\n└─────────────────────────────┘\n",[30,9336,9334],{"__ignoreMap":28},[16,9338,9339],{},"先二分索引区定位到「是哪一段」，再按指针去数据区读出地点字符串，一次磁盘读就能拿到结果。",[281,9341,9343],{"id":9342},"为什么离线数据不会很快过期","为什么「离线数据」不会很快过期",[16,9345,9346,9347,9350],{},"IP 归属取决于",[1305,9348,9349],{},"注册机构和运营商的分配","，一个网段通常长期稳定属于某地区、某运营商。所以：",[1613,9352,9353,9356,9366],{},[288,9354,9355],{},"下载一次数据文件，可以用很久；",[288,9357,9358,9359,9365],{},"归属偶尔变化时，",[1305,9360,9361,9362,9364],{},"每月更新一次 ",[30,9363,9269],{}," 文件","即可（替换文件、不用重启服务，见下文实战）；",[288,9367,9368],{},"数据源来自 IANA\u002FAPNIC 等机构的分配表与公开归属信息，不是实时探测。",[12,9370,9371],{"id":9371},"主流查询方案对比",[16,9373,9374],{},"实际开发中，查询 IP 属地主要有三条路线。",[281,9376,9378],{"id":9377},"方案一ip2region纯本地离线推荐","方案一：ip2region（纯本地离线，推荐）",[23,9380,9382],{"className":3490,"code":9381,"language":172,"meta":28,"style":28},"\u002F\u002F Go 中的用法（github.com\u002Flionsoul2014\u002Fip2region\u002Fbinding\u002Fgolang）\nregion, err := searcher.SearchByStr(ip)\n\u002F\u002F 返回类似 \"中国|福建省|福州市|移动\"\n",[30,9383,9384,9389,9405],{"__ignoreMap":28},[33,9385,9386],{"class":35,"line":36},[33,9387,9388],{"class":39},"\u002F\u002F Go 中的用法（github.com\u002Flionsoul2014\u002Fip2region\u002Fbinding\u002Fgolang）\n",[33,9390,9391,9394,9396,9399,9402],{"class":35,"line":43},[33,9392,9393],{"class":67},"region, err ",[33,9395,3517],{"class":131},[33,9397,9398],{"class":67}," searcher.",[33,9400,9401],{"class":46},"SearchByStr",[33,9403,9404],{"class":67},"(ip)\n",[33,9406,9407],{"class":35,"line":86},[33,9408,9409],{"class":39},"\u002F\u002F 返回类似 \"中国|福建省|福州市|移动\"\n",[1390,9411,9412,9420],{},[1393,9413,9414],{},[1396,9415,9416,9418],{},[1399,9417,2748],{},[1399,9419,2751],{},[1412,9421,9422,9430,9438],{},[1396,9423,9424,9427],{},[1417,9425,9426],{},"免费、无网络依赖、无 QPS 限制",[1417,9428,9429],{},"段级粒度，个别段可能显示到相邻地区",[1396,9431,9432,9435],{},[1417,9433,9434],{},"毫秒级查询，永不因外部服务挂掉",[1417,9436,9437],{},"数据按月更新，最新分配的段可能短暂缺失",[1396,9439,9440,9443],{},[1417,9441,9442],{},"数据文件可随版本更新，不重启",[1417,9444,9445,9446],{},"只返回省市区+运营商，无经纬度；",[1305,9447,9448],{},"仅支持 IPv4",[16,9450,9451,9454],{},[1305,9452,9453],{},"适用","：博客\u002F后台的「登录记录显示归属地」这类展示用途。",[281,9456,9458],{"id":9457},"方案二在线-apiip-api-高德-腾讯位置服务","方案二：在线 API（ip-api \u002F 高德 \u002F 腾讯位置服务）",[23,9460,9462],{"className":3490,"code":9461,"language":172,"meta":28,"style":28},"\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",[30,9463,9464,9469,9497,9507,9538,9554,9567,9577,9581,9593],{"__ignoreMap":28},[33,9465,9466],{"class":35,"line":36},[33,9467,9468],{"class":39},"\u002F\u002F 以 ip-api.com 为例（免费版约 45 次\u002F分钟，非商用）\n",[33,9470,9471,9474,9476,9478,9481,9484,9487,9490,9492,9494],{"class":35,"line":43},[33,9472,9473],{"class":67},"ctx, cancel ",[33,9475,3517],{"class":131},[33,9477,4990],{"class":67},[33,9479,9480],{"class":46},"WithTimeout",[33,9482,9483],{"class":67},"(context.",[33,9485,9486],{"class":46},"Background",[33,9488,9489],{"class":67},"(), ",[33,9491,2015],{"class":54},[33,9493,3723],{"class":131},[33,9495,9496],{"class":67},"time.Second)\n",[33,9498,9499,9502,9505],{"class":35,"line":86},[33,9500,9501],{"class":131},"defer",[33,9503,9504],{"class":46}," cancel",[33,9506,2261],{"class":67},[33,9508,9509,9512,9514,9517,9520,9523,9526,9528,9531,9533,9536],{"class":35,"line":93},[33,9510,9511],{"class":67},"req, _ ",[33,9513,3517],{"class":131},[33,9515,9516],{"class":67}," http.",[33,9518,9519],{"class":46},"NewRequestWithContext",[33,9521,9522],{"class":67},"(ctx, ",[33,9524,9525],{"class":50},"\"GET\"",[33,9527,799],{"class":67},[33,9529,9530],{"class":50},"\"https:\u002F\u002Fip-api.com\u002Fjson\u002F198.51.100.42\"",[33,9532,799],{"class":67},[33,9534,9535],{"class":54},"nil",[33,9537,2376],{"class":67},[33,9539,9540,9543,9545,9548,9551],{"class":35,"line":99},[33,9541,9542],{"class":67},"resp, err ",[33,9544,3517],{"class":131},[33,9546,9547],{"class":67}," http.DefaultClient.",[33,9549,9550],{"class":46},"Do",[33,9552,9553],{"class":67},"(req)\n",[33,9555,9556,9559,9561,9563,9565],{"class":35,"line":128},[33,9557,9558],{"class":131},"if",[33,9560,3931],{"class":67},[33,9562,3934],{"class":131},[33,9564,3937],{"class":54},[33,9566,1706],{"class":67},[33,9568,9569,9571,9574],{"class":35,"line":147},[33,9570,3748],{"class":131},[33,9572,9573],{"class":50}," \"未知\"",[33,9575,9576],{"class":39}," \u002F\u002F 断网\u002F超时降级：展示「未知」而不是报错\n",[33,9578,9579],{"class":35,"line":163},[33,9580,1360],{"class":67},[33,9582,9583,9585,9588,9591],{"class":35,"line":169},[33,9584,9501],{"class":131},[33,9586,9587],{"class":67}," resp.Body.",[33,9589,9590],{"class":46},"Close",[33,9592,2261],{"class":67},[33,9594,9595],{"class":35,"line":183},[33,9596,9597],{"class":39},"\u002F\u002F 解析 JSON，status != \"success\" 时同样降级\n",[1390,9599,9600,9608],{},[1393,9601,9602],{},[1396,9603,9604,9606],{},[1399,9605,2748],{},[1399,9607,2751],{},[1412,9609,9610,9618,9626],{},[1396,9611,9612,9615],{},[1417,9613,9614],{},"数据最新、可能更精确",[1417,9616,9617],{},"有频率限制（免费版限 QPS）",[1396,9619,9620,9623],{},[1417,9621,9622],{},"部分服务还返回经纬度\u002F时区",[1417,9624,9625],{},"依赖第三方，断网\u002F被墙\u002F限流时拿不到",[1396,9627,9628,9631],{},[1417,9629,9630],{},"无需维护数据文件",[1417,9632,9633],{},"每次查询都要发一次 HTTP 请求",[16,9635,9636,9638,9639,9642],{},[1305,9637,9453],{},"：对精度要求高、且能接受外部依赖与限流的场景。",[1305,9640,9641],{},"务必加超时和降级","，否则第三方一抖，你自己的页面就卡住。",[281,9644,9646],{"id":9645},"方案三maxmind-geolite2离线-mmdb","方案三：MaxMind GeoLite2（离线 .mmdb）",[1390,9648,9649,9657],{},[1393,9650,9651],{},[1396,9652,9653,9655],{},[1399,9654,2748],{},[1399,9656,2751],{},[1412,9658,9659,9667,9679],{},[1396,9660,9661,9664],{},[1417,9662,9663],{},"全球数据，覆盖广、精度较好",[1417,9665,9666],{},"免费但需注册账号并遵守 EULA；商用需 GeoIP2 商业授权",[1396,9668,9669,9676],{},[1417,9670,9671,9672,9675],{},"标准 ",[30,9673,9674],{},".mmdb"," 格式，各语言都有库，支持 IPv6",[1417,9677,9678],{},"国内 IP 的省市粒度有时不如 ip2region 细",[1396,9680,9681,9684],{},[1417,9682,9683],{},"离线查询",[1417,9685,9686],{},"数据同样要定期更新",[16,9688,9689,9691],{},[1305,9690,9453],{},"：面向全球用户的场景。",[12,9693,9695],{"id":9694},"ipv6本地离线方案的一个盲点","IPv6：本地离线方案的一个盲点",[16,9697,9698,9699,9702,9703,9706],{},"前面聊的都是 IPv4（32 位）。但家宽和移动网络越来越多地",[1305,9700,9701],{},"只下发 IPv6","（128 位）地址，而 ",[1305,9704,9705],{},"ip2region 目前只支持 IPv4","——拿到一个纯 IPv6 地址是查不出归属地的，本地库会返回空结果。",[16,9708,9709],{},"实际影响与常见应对：",[285,9711,9712,9722,9728,9738],{},[288,9713,9714,9717,9718,9721],{},[1305,9715,9716],{},"判断版本","：Go 里 ",[30,9719,9720],{},"net.ParseIP(ip).To4() != nil"," 就是 IPv4，先判断再查询；",[288,9723,9724,9727],{},[1305,9725,9726],{},"降级展示","：IPv6 直接显示「IPv6 地址」，不显示属地，诚实第一；",[288,9729,9730,9733,9734,9737],{},[1305,9731,9732],{},"优先取 IPv4","：有反向代理时，从 ",[30,9735,9736],{},"X-Forwarded-For"," 里挑一个 IPv4 来查；",[288,9739,9740,9743],{},[1305,9741,9742],{},"需要 v6 归属","：换支持 IPv6 的数据源（如 MaxMind GeoLite2）。",[16,9745,9746],{},"这一点在接入时很容易漏掉，上线前记得拿一个纯 IPv6 地址回归一遍。",[12,9748,9750],{"id":9749},"实战给登录记录加-ip-属地","实战：给登录记录加 IP 属地",[16,9752,9753],{},"subLog 博客的登录记录就用了 ip2region。完整接入一共四步。",[281,9755,9757],{"id":9756},"_1-进程启动时加载数据文件","1. 进程启动时加载数据文件",[23,9759,9761],{"className":3490,"code":9760,"language":172,"meta":28,"style":28},"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",[30,9762,9763,9769,9778,9787,9791,9795,9800,9827,9831,9844,9866,9878,9885,9889,9905,9917,9923,9927,9938,9945],{"__ignoreMap":28},[33,9764,9765,9767],{"class":35,"line":36},[33,9766,3498],{"class":131},[33,9768,4896],{"class":67},[33,9770,9771,9773,9776],{"class":35,"line":43},[33,9772,6316],{"class":50},[33,9774,9775],{"class":46},"sync\u002Fatomic",[33,9777,279],{"class":50},[33,9779,9780,9782,9785],{"class":35,"line":86},[33,9781,6316],{"class":50},[33,9783,9784],{"class":46},"github.com\u002Flionsoul2014\u002Fip2region\u002Fbinding\u002Fgolang\u002Fxdb",[33,9786,279],{"class":50},[33,9788,9789],{"class":35,"line":93},[33,9790,2376],{"class":67},[33,9792,9793],{"class":35,"line":99},[33,9794,90],{"emptyLinePlaceholder":89},[33,9796,9797],{"class":35,"line":128},[33,9798,9799],{"class":39},"\u002F\u002F 用 atomic.Pointer 持有查询器，方便后面热更新\n",[33,9801,9802,9804,9807,9810,9812,9815,9817,9820,9822,9825],{"class":35,"line":147},[33,9803,6397],{"class":131},[33,9805,9806],{"class":67}," searcher ",[33,9808,9809],{"class":46},"atomic",[33,9811,2188],{"class":67},[33,9813,9814],{"class":46},"Pointer",[33,9816,2876],{"class":67},[33,9818,9819],{"class":46},"xdb",[33,9821,2188],{"class":67},[33,9823,9824],{"class":46},"Searcher",[33,9826,502],{"class":67},[33,9828,9829],{"class":35,"line":163},[33,9830,90],{"emptyLinePlaceholder":89},[33,9832,9833,9835,9838,9840,9842],{"class":35,"line":169},[33,9834,3691],{"class":131},[33,9836,9837],{"class":46}," initSearcher",[33,9839,4939],{"class":67},[33,9841,3736],{"class":131},[33,9843,1706],{"class":67},[33,9845,9846,9849,9851,9854,9856,9858,9861,9863],{"class":35,"line":183},[33,9847,9848],{"class":67},"    content, err ",[33,9850,3517],{"class":131},[33,9852,9853],{"class":67}," os.",[33,9855,6536],{"class":46},[33,9857,2066],{"class":67},[33,9859,9860],{"class":50},"\".\u002Fdata\u002Fip2region.xdb\"",[33,9862,2530],{"class":67},[33,9864,9865],{"class":39},"\u002F\u002F 约 11MB，一次性读入内存\n",[33,9867,9868,9870,9872,9874,9876],{"class":35,"line":206},[33,9869,2143],{"class":131},[33,9871,3931],{"class":67},[33,9873,3934],{"class":131},[33,9875,3937],{"class":54},[33,9877,1706],{"class":67},[33,9879,9880,9882],{"class":35,"line":215},[33,9881,3944],{"class":131},[33,9883,9884],{"class":67}," err\n",[33,9886,9887],{"class":35,"line":220},[33,9888,2026],{"class":67},[33,9890,9891,9894,9896,9899,9902],{"class":35,"line":226},[33,9892,9893],{"class":67},"    s, err ",[33,9895,3517],{"class":131},[33,9897,9898],{"class":67}," xdb.",[33,9900,9901],{"class":46},"NewWithBuffer",[33,9903,9904],{"class":67},"(content)\n",[33,9906,9907,9909,9911,9913,9915],{"class":35,"line":237},[33,9908,2143],{"class":131},[33,9910,3931],{"class":67},[33,9912,3934],{"class":131},[33,9914,3937],{"class":54},[33,9916,1706],{"class":67},[33,9918,9919,9921],{"class":35,"line":248},[33,9920,3944],{"class":131},[33,9922,9884],{"class":67},[33,9924,9925],{"class":35,"line":253},[33,9926,2026],{"class":67},[33,9928,9929,9932,9935],{"class":35,"line":262},[33,9930,9931],{"class":67},"    searcher.",[33,9933,9934],{"class":46},"Store",[33,9936,9937],{"class":67},"(s)\n",[33,9939,9940,9942],{"class":35,"line":267},[33,9941,3748],{"class":131},[33,9943,9944],{"class":54}," nil\n",[33,9946,9947],{"class":35,"line":477},[33,9948,1360],{"class":67},[281,9950,9952],{"id":9951},"_2-查询一次","2. 查询一次",[23,9954,9956],{"className":3490,"code":9955,"language":172,"meta":28,"style":28},"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",[30,9957,9958,9980,9999,10011,10018,10022,10032],{"__ignoreMap":28},[33,9959,9960,9962,9965,9967,9970,9973,9975,9978],{"class":35,"line":36},[33,9961,3691],{"class":131},[33,9963,9964],{"class":46}," ipRegion",[33,9966,2066],{"class":67},[33,9968,9969],{"class":2069},"ip",[33,9971,9972],{"class":131}," string",[33,9974,2530],{"class":67},[33,9976,9977],{"class":131},"string",[33,9979,1706],{"class":67},[33,9981,9982,9985,9987,9989,9992,9995,9997],{"class":35,"line":43},[33,9983,9984],{"class":67},"    region, err ",[33,9986,3517],{"class":131},[33,9988,9398],{"class":67},[33,9990,9991],{"class":46},"Load",[33,9993,9994],{"class":67},"().",[33,9996,9401],{"class":46},[33,9998,9404],{"class":67},[33,10000,10001,10003,10005,10007,10009],{"class":35,"line":86},[33,10002,2143],{"class":131},[33,10004,3931],{"class":67},[33,10006,3934],{"class":131},[33,10008,3937],{"class":54},[33,10010,1706],{"class":67},[33,10012,10013,10015],{"class":35,"line":93},[33,10014,3944],{"class":131},[33,10016,10017],{"class":50}," \"未知\"\n",[33,10019,10020],{"class":35,"line":99},[33,10021,2026],{"class":67},[33,10023,10024,10026,10029],{"class":35,"line":128},[33,10025,3748],{"class":131},[33,10027,10028],{"class":67}," region ",[33,10030,10031],{"class":39},"\u002F\u002F \"中国|福建省|福州市|移动\"\n",[33,10033,10034],{"class":35,"line":147},[33,10035,1360],{"class":67},[281,10037,10039],{"id":10038},"_3-加一层内存缓存","3. 加一层内存缓存",[16,10041,10042,10043,10046],{},"登录记录里同一个 IP 可能反复出现，用 ",[30,10044,10045],{},"sync.Map"," 按 IP 缓存结果，一次查询终身复用：",[23,10048,10050],{"className":3490,"code":10049,"language":172,"meta":28,"style":28},"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",[30,10051,10052,10067,10071,10090,10107,10118,10122,10133,10143,10150],{"__ignoreMap":28},[33,10053,10054,10056,10059,10062,10064],{"class":35,"line":36},[33,10055,6397],{"class":131},[33,10057,10058],{"class":67}," regionCache ",[33,10060,10061],{"class":46},"sync",[33,10063,2188],{"class":67},[33,10065,10066],{"class":46},"Map\n",[33,10068,10069],{"class":35,"line":43},[33,10070,90],{"emptyLinePlaceholder":89},[33,10072,10073,10075,10078,10080,10082,10084,10086,10088],{"class":35,"line":86},[33,10074,3691],{"class":131},[33,10076,10077],{"class":46}," ipRegionCached",[33,10079,2066],{"class":67},[33,10081,9969],{"class":2069},[33,10083,9972],{"class":131},[33,10085,2530],{"class":67},[33,10087,9977],{"class":131},[33,10089,1706],{"class":67},[33,10091,10092,10094,10097,10099,10102,10104],{"class":35,"line":93},[33,10093,2143],{"class":131},[33,10095,10096],{"class":67}," v, ok ",[33,10098,3517],{"class":131},[33,10100,10101],{"class":67}," regionCache.",[33,10103,9991],{"class":46},[33,10105,10106],{"class":67},"(ip); ok {\n",[33,10108,10109,10111,10114,10116],{"class":35,"line":99},[33,10110,3944],{"class":131},[33,10112,10113],{"class":67}," v.(",[33,10115,9977],{"class":131},[33,10117,2376],{"class":67},[33,10119,10120],{"class":35,"line":128},[33,10121,2026],{"class":67},[33,10123,10124,10127,10129,10131],{"class":35,"line":147},[33,10125,10126],{"class":67},"    region ",[33,10128,3517],{"class":131},[33,10130,9964],{"class":46},[33,10132,9404],{"class":67},[33,10134,10135,10138,10140],{"class":35,"line":163},[33,10136,10137],{"class":67},"    regionCache.",[33,10139,9934],{"class":46},[33,10141,10142],{"class":67},"(ip, region)\n",[33,10144,10145,10147],{"class":35,"line":169},[33,10146,3748],{"class":131},[33,10148,10149],{"class":67}," region\n",[33,10151,10152],{"class":35,"line":183},[33,10153,1360],{"class":67},[281,10155,10157],{"id":10156},"_4-数据热更新不用重启","4. 数据热更新（不用重启）",[16,10159,10160,10161,10163,10164,10166],{},"ip2region 每月发布新数据。下载新文件后重新 ",[30,10162,9901],{}," 并 ",[30,10165,9934],{}," 覆盖即可，进程完全不用重启：",[23,10168,10170],{"className":3490,"code":10169,"language":172,"meta":28,"style":28},"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",[30,10171,10172,10196,10209,10221,10227,10231,10243,10249],{"__ignoreMap":28},[33,10173,10174,10176,10179,10181,10184,10187,10190,10192,10194],{"class":35,"line":36},[33,10175,3691],{"class":131},[33,10177,10178],{"class":46}," reloadSearcher",[33,10180,2066],{"class":67},[33,10182,10183],{"class":2069},"newContent",[33,10185,10186],{"class":67}," []",[33,10188,10189],{"class":131},"byte",[33,10191,2530],{"class":67},[33,10193,3736],{"class":131},[33,10195,1706],{"class":67},[33,10197,10198,10200,10202,10204,10206],{"class":35,"line":43},[33,10199,9893],{"class":67},[33,10201,3517],{"class":131},[33,10203,9898],{"class":67},[33,10205,9901],{"class":46},[33,10207,10208],{"class":67},"(newContent)\n",[33,10210,10211,10213,10215,10217,10219],{"class":35,"line":86},[33,10212,2143],{"class":131},[33,10214,3931],{"class":67},[33,10216,3934],{"class":131},[33,10218,3937],{"class":54},[33,10220,1706],{"class":67},[33,10222,10223,10225],{"class":35,"line":93},[33,10224,3944],{"class":131},[33,10226,9884],{"class":67},[33,10228,10229],{"class":35,"line":99},[33,10230,2026],{"class":67},[33,10232,10233,10235,10237,10240],{"class":35,"line":128},[33,10234,9931],{"class":67},[33,10236,9934],{"class":46},[33,10238,10239],{"class":67},"(s) ",[33,10241,10242],{"class":39},"\u002F\u002F 原子切换，老查询继续走旧数据，新查询走新数据\n",[33,10244,10245,10247],{"class":35,"line":147},[33,10246,3748],{"class":131},[33,10248,9944],{"class":54},[33,10250,10251],{"class":35,"line":163},[33,10252,1360],{"class":67},[12,10254,10255],{"id":10255},"局限与诚实提醒",[16,10257,10258],{},"无论用哪种方案，都要清楚 IP 属地的边界：",[1613,10260,10261,10267,10273,10279,10285],{},[288,10262,10263,10266],{},[1305,10264,10265],{},"段级精度","：网段归属是「绝大多数」准确，个别 IP（大型企业自建段、跨省调度段）可能显示到相邻地区或只到省。",[288,10268,10269,10272],{},[1305,10270,10271],{},"运营商级，不是定位","：拿到的是「省｜市｜运营商」，不是精确街道或基站位置——精确位置需要 GPS 或运营商三角定位，纯 IP 归属做不到。",[288,10274,10275,10278],{},[1305,10276,10277],{},"动态 IP","：家宽通常是动态 IP，重拨会变，所以「上次登录在福州，这次在广州」不一定是异常，可能是 IP 变了。",[288,10280,10281,10284],{},[1305,10282,10283],{},"别用于安全判定","：用 IP 段做封禁或风控判定风险自负，段级数据不适合当精确依据。",[288,10286,10287,10290,10291,10294],{},[1305,10288,10289],{},"隐私与合规","：IP 地址属于个人信息（《个人信息保护法》）。归属地展示建议只出现在",[1305,10292,10293],{},"用户本人可见","的页面（登录记录、个人中心）；若在评论区等公开场合展示，建议只显示到省市并明确告知用户。",[12,10296,10297],{"id":10297},"小结",[16,10299,10300],{},"一句话总结：",[9222,10302,10303],{},[16,10304,10305,10308],{},[1305,10306,10307],{},"IP 属地 = 几十万条「IP 段 → 归属地」映射 + 一次二分查找。"," 它查的是网段归属，不是精确位置。",[1613,10310,10311,10317,10324],{},[288,10312,10313,10314,10316],{},"想省心、免费、稳定，用 ",[1305,10315,9286],{}," 本地离线查询（注意 IPv6 盲点）；",[288,10318,10319,10320,10323],{},"想要最新最细、能接受外部依赖，用",[1305,10321,10322],{},"在线 API","（记得超时降级）；",[288,10325,10326,10327,1957],{},"面向全球用户、要 IPv6 覆盖，用 ",[1305,10328,10329],{},"MaxMind GeoLite2",[16,10331,10332],{},"对个人博客「登录记录显示归属地」这种场景，本地离线方案通常是性价比最高的选择。",[825,10334,10335],{},"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":28,"searchDepth":43,"depth":86,"links":10337},[10338,10339,10340,10346,10351,10352,10358,10359],{"id":8641,"depth":43,"text":8641},{"id":9233,"depth":43,"text":9234},{"id":9280,"depth":43,"text":9280,"children":10341},[10342,10343,10344,10345],{"id":9290,"depth":86,"text":9291},{"id":9305,"depth":86,"text":9306},{"id":9323,"depth":86,"text":9324},{"id":9342,"depth":86,"text":9343},{"id":9371,"depth":43,"text":9371,"children":10347},[10348,10349,10350],{"id":9377,"depth":86,"text":9378},{"id":9457,"depth":86,"text":9458},{"id":9645,"depth":86,"text":9646},{"id":9694,"depth":43,"text":9695},{"id":9749,"depth":43,"text":9750,"children":10353},[10354,10355,10356,10357],{"id":9756,"depth":86,"text":9757},{"id":9951,"depth":86,"text":9952},{"id":10038,"depth":86,"text":10039},{"id":10156,"depth":86,"text":10157},{"id":10255,"depth":43,"text":10255},{"id":10297,"depth":43,"text":10297},"科普 IP 属地查询的原理：IP 段与归属地的映射、纯本地离线查询为何能做到毫秒级，对比 ip2region、在线 API 与 MaxMind 三种方案的优劣，并附登录记录场景的 Go 实战与 IPv6 盲点提醒。","\u002Fposts\u002Fip-geolocation-guide\u002Fimg\u002Fcover.svg",{},"\u002Fposts\u002Fip-geolocation-guide","2026-08-07T12:00:00",{"title":9196,"description":10360},"posts\u002Fip-geolocation-guide\u002Findex",[10368,10369,10370],"IP","网络","科普","2026-08-16","RY1UPgJaiKsjpC51uqMyG7w_GpCRS13_CX5j1hDD_dM",{"id":10374,"title":10375,"author":7,"body":10376,"description":11898,"difficulty":840,"draft":841,"extension":842,"image":11899,"meta":11900,"navigation":89,"path":11901,"pinned":841,"published":11902,"seo":11903,"series":5455,"seriesOrder":86,"stem":11904,"tags":11905,"updated":10371,"__hash__":11908},"posts\u002Fposts\u002Fgo-embed-react-single-binary\u002Findex.md","Go 单文件打包进阶：React 前端 + 交叉编译，一次踩完所有坑",{"type":9,"value":10377,"toc":11877},[10378,10380,10391,10402,10409,10412,10448,10450,10459,10461,10465,10475,10519,10561,10579,10583,10604,10635,10639,10931,10934,10972,10976,11319,11322,11326,11523,11533,11535,11546,11560,11573,11584,11588,11595,11611,11624,11629,11633,11649,11654,11699,11713,11724,11728,11743,11752,11774,11783,11787,11797,11799,11826,11835,11837,11840,11867,11874],[12,10379,1933],{"id":1933},[16,10381,10382,10383,10386,10387,10390],{},"这是「Go 单文件打包」系列第三篇。上一篇（",[1886,10384,10385],{"href":7215},"把前端塞进二进制","）记录了一个 Gin + Vue 3 项目的 embed 方案。这次换了个项目继续踩坑：",[1305,10388,10389],{},"Magic Img","——一个自托管、本地优先的 AI 生图提示词管理工具，技术栈是：",[1613,10392,10393,10396],{},[288,10394,10395],{},"前端：React 19 + TypeScript + Vite + Tailwind v4（UI 库 Magic UI + shadcn\u002Fui）",[288,10397,10398,10399,5999],{},"后端：Go + Gin + GORM + SQLite（",[1305,10400,10401],{},"modernc 纯 Go 驱动，无 cgo",[16,10403,10404,10405,10408],{},"目标一样：",[1305,10406,10407],{},"一个二进制拖到任何机器上就能跑","——前端、API、数据库全部内嵌。",[16,10410,10411],{},"和上篇不同的地方有三个，正好是这篇要讲的重点：",[285,10413,10414,10428,10441],{},[288,10415,10416,10417,10423,10424,10427],{},"前端用了 ",[1305,10418,10419,10420],{},"HashRouter + Vite base ",[30,10421,10422],{},"\u002Fdist","，服务端",[1305,10425,10426],{},"不再需要 SPA fallback","（上篇坑一的新解法）；",[288,10429,10430,10431,10437,10438,10440],{},"embed 用 ",[1305,10432,10433,10436],{},[30,10434,10435],{},"all:"," 前缀","一劳永逸解决 ",[30,10439,6909],{}," 开头文件被排除的问题（上篇坑二的升级解法）；",[288,10442,10443,10444,10447],{},"需要",[1305,10445,10446],{},"在 Windows 上交叉编译 Linux 产物","，踩了「生成的文件无法在 Linux 执行」的坑。",[12,10449,6265],{"id":6265},[16,10451,10452,10453,10455,10456,10458],{},"和上篇一致：",[30,10454,6271],{}," 在编译期把前端 ",[30,10457,6275],{}," 塞进二进制，运行时由 Go 直接提供静态服务。差异在于路由策略和缓存策略，后面细说。",[12,10460,6279],{"id":6279},[281,10462,10464],{"id":10463},"_1-前端hash-路由-base-dist","1. 前端：hash 路由 + base \u002Fdist",[16,10466,10467,10468,10471,10472,3209],{},"vite.config.ts 里固定 ",[30,10469,10470],{},"base: \"\u002Fdist\u002F\"","，路由用 ",[30,10473,10474],{},"HashRouter",[23,10476,10478],{"className":6960,"code":10477,"language":6962,"meta":28,"style":28},"\u002F\u002F vite.config.ts\nexport default defineConfig({\n  base: \"\u002Fdist\u002F\",   \u002F\u002F 所有资源路径以 \u002Fdist\u002F 开头\n  ...\n})\n",[30,10479,10480,10484,10496,10510,10515],{"__ignoreMap":28},[33,10481,10482],{"class":35,"line":36},[33,10483,6969],{"class":39},[33,10485,10486,10488,10490,10493],{"class":35,"line":43},[33,10487,132],{"class":131},[33,10489,2809],{"class":131},[33,10491,10492],{"class":46}," defineConfig",[33,10494,10495],{"class":67},"({\n",[33,10497,10498,10501,10504,10507],{"class":35,"line":86},[33,10499,10500],{"class":67},"  base: ",[33,10502,10503],{"class":50},"\"\u002Fdist\u002F\"",[33,10505,10506],{"class":67},",   ",[33,10508,10509],{"class":39},"\u002F\u002F 所有资源路径以 \u002Fdist\u002F 开头\n",[33,10511,10512],{"class":35,"line":93},[33,10513,10514],{"class":131},"  ...\n",[33,10516,10517],{"class":35,"line":99},[33,10518,2614],{"class":67},[23,10520,10524],{"className":10521,"code":10522,"language":10523,"meta":28,"style":28},"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",[30,10525,10526,10531,10539,10553],{"__ignoreMap":28},[33,10527,10528],{"class":35,"line":36},[33,10529,10530],{"class":39},"\u002F\u002F App.tsx —— HashRouter：路径信息全在 location.hash 里\n",[33,10532,10533,10535,10537],{"class":35,"line":43},[33,10534,982],{"class":67},[33,10536,10474],{"class":54},[33,10538,636],{"class":67},[33,10540,10541,10543,10546,10549,10551],{"class":35,"line":86},[33,10542,2641],{"class":67},[33,10544,10545],{"class":54},"Routes",[33,10547,10548],{"class":67},">...\u003C\u002F",[33,10550,10545],{"class":54},[33,10552,636],{"class":67},[33,10554,10555,10557,10559],{"class":35,"line":93},[33,10556,2619],{"class":67},[33,10558,10474],{"class":54},[33,10560,636],{"class":67},[16,10562,10563,10566,10567,10570,10571,10574,10575,10578],{},[1305,10564,10565],{},"为什么这样选","：history 路由需要服务端把未知路径全部 fallback 回 ",[30,10568,10569],{},"index.html","（上篇坑一）。hash 路由下浏览器请求的永远是 ",[30,10572,10573],{},"\u002Fdist\u002F"," 这一个入口，",[1305,10576,10577],{},"静态服务直接返回 index.html 就完事，一个 fallback 都不用写","。内嵌场景里这是最省心的组合。",[281,10580,10582],{"id":10581},"_2-构建产物复制进后端目录","2. 构建产物复制进后端目录",[16,10584,10585,10588,10589,10591,10592,10595,10596,10599,10600,10603],{},[30,10586,10587],{},"npm run build"," 除了编译，还会把 ",[30,10590,6275],{}," 复制到 ",[30,10593,10594],{},"backend\u002Fweb\u002Fdist","（一个小脚本 ",[30,10597,10598],{},"scripts\u002Fcopy-dist.mjs"," 干这个活），供 ",[30,10601,10602],{},"go:embed"," 使用：",[23,10605,10607],{"className":5282,"code":10606,"language":5284,"meta":28,"style":28},"\u002F\u002F frontend\u002Fpackage.json\n\"scripts\": {\n  \"build\": \"tsc -b && vite build && node scripts\u002Fcopy-dist.mjs\"\n}\n",[30,10608,10609,10614,10621,10631],{"__ignoreMap":28},[33,10610,10611],{"class":35,"line":36},[33,10612,10613],{"class":39},"\u002F\u002F frontend\u002Fpackage.json\n",[33,10615,10616,10619],{"class":35,"line":43},[33,10617,10618],{"class":50},"\"scripts\"",[33,10620,1985],{"class":67},[33,10622,10623,10626,10628],{"class":35,"line":86},[33,10624,10625],{"class":54},"  \"build\"",[33,10627,1770],{"class":67},[33,10629,10630],{"class":50},"\"tsc -b && vite build && node scripts\u002Fcopy-dist.mjs\"\n",[33,10632,10633],{"class":35,"line":93},[33,10634,1360],{"class":67},[281,10636,10638],{"id":10637},"_3-后端embed-httpfilesystem-包装","3. 后端：embed + http.FileSystem 包装",[23,10640,10642],{"className":3490,"code":10641,"language":172,"meta":28,"style":28},"\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",[30,10643,10644,10649,10655,10659,10665,10673,10681,10689,10693,10697,10702,10715,10719,10724,10761,10765,10777,10785,10798,10802,10806,10845,10859,10869,10879,10906,10910,10927],{"__ignoreMap":28},[33,10645,10646],{"class":35,"line":36},[33,10647,10648],{"class":39},"\u002F\u002F backend\u002Fweb\u002Fembed.go\n",[33,10650,10651,10653],{"class":35,"line":43},[33,10652,6298],{"class":131},[33,10654,6301],{"class":46},[33,10656,10657],{"class":35,"line":86},[33,10658,90],{"emptyLinePlaceholder":89},[33,10660,10661,10663],{"class":35,"line":93},[33,10662,3498],{"class":131},[33,10664,4896],{"class":67},[33,10666,10667,10669,10671],{"class":35,"line":99},[33,10668,6316],{"class":50},[33,10670,6319],{"class":46},[33,10672,279],{"class":50},[33,10674,10675,10677,10679],{"class":35,"line":128},[33,10676,6316],{"class":50},[33,10678,6346],{"class":46},[33,10680,279],{"class":50},[33,10682,10683,10685,10687],{"class":35,"line":147},[33,10684,6316],{"class":50},[33,10686,6364],{"class":46},[33,10688,279],{"class":50},[33,10690,10691],{"class":35,"line":163},[33,10692,2376],{"class":67},[33,10694,10695],{"class":35,"line":169},[33,10696,90],{"emptyLinePlaceholder":89},[33,10698,10699],{"class":35,"line":183},[33,10700,10701],{"class":39},"\u002F\u002Fgo:embed all:dist\n",[33,10703,10704,10706,10709,10711,10713],{"class":35,"line":206},[33,10705,6397],{"class":131},[33,10707,10708],{"class":67}," distFS ",[33,10710,6319],{"class":46},[33,10712,2188],{"class":67},[33,10714,6407],{"class":46},[33,10716,10717],{"class":35,"line":215},[33,10718,90],{"emptyLinePlaceholder":89},[33,10720,10721],{"class":35,"line":220},[33,10722,10723],{"class":39},"\u002F\u002F Dist 内嵌前端静态文件系统\n",[33,10725,10726,10728,10731,10734,10736,10739,10741,10744,10747,10750,10752,10755,10758],{"class":35,"line":226},[33,10727,6397],{"class":131},[33,10729,10730],{"class":67}," Dist ",[33,10732,10733],{"class":46},"http",[33,10735,2188],{"class":67},[33,10737,10738],{"class":46},"FileSystem",[33,10740,1703],{"class":131},[33,10742,10743],{"class":131}," &",[33,10745,10746],{"class":46},"fileSystem",[33,10748,10749],{"class":67},"{prefix: ",[33,10751,6447],{"class":50},[33,10753,10754],{"class":67},", fs: ",[33,10756,10757],{"class":131},"&",[33,10759,10760],{"class":67},"distFS}\n",[33,10762,10763],{"class":35,"line":237},[33,10764,90],{"emptyLinePlaceholder":89},[33,10766,10767,10769,10772,10775],{"class":35,"line":248},[33,10768,4883],{"class":131},[33,10770,10771],{"class":46}," fileSystem",[33,10773,10774],{"class":131}," struct",[33,10776,1706],{"class":67},[33,10778,10779,10782],{"class":35,"line":253},[33,10780,10781],{"class":67},"    prefix ",[33,10783,10784],{"class":131},"string\n",[33,10786,10787,10790,10792,10794,10796],{"class":35,"line":262},[33,10788,10789],{"class":67},"    fs     ",[33,10791,3723],{"class":131},[33,10793,6319],{"class":46},[33,10795,2188],{"class":67},[33,10797,6407],{"class":46},[33,10799,10800],{"class":35,"line":267},[33,10801,1360],{"class":67},[33,10803,10804],{"class":35,"line":477},[33,10805,90],{"emptyLinePlaceholder":89},[33,10807,10808,10810,10812,10815,10817,10819,10821,10824,10826,10828,10830,10832,10834,10836,10839,10841,10843],{"class":35,"line":485},[33,10809,3691],{"class":131},[33,10811,2108],{"class":67},[33,10813,10814],{"class":2069},"f ",[33,10816,3723],{"class":131},[33,10818,10746],{"class":46},[33,10820,2530],{"class":67},[33,10822,10823],{"class":46},"Open",[33,10825,2066],{"class":67},[33,10827,7013],{"class":2069},[33,10829,9972],{"class":131},[33,10831,3720],{"class":67},[33,10833,10733],{"class":46},[33,10835,2188],{"class":67},[33,10837,10838],{"class":46},"File",[33,10840,799],{"class":67},[33,10842,3736],{"class":131},[33,10844,3318],{"class":67},[33,10846,10847,10849,10852,10854,10857],{"class":35,"line":490},[33,10848,2143],{"class":131},[33,10850,10851],{"class":67}," name ",[33,10853,4591],{"class":131},[33,10855,10856],{"class":50}," \"\u002F\"",[33,10858,1706],{"class":67},[33,10860,10861,10864,10866],{"class":35,"line":1126},[33,10862,10863],{"class":67},"        name ",[33,10865,138],{"class":131},[33,10867,10868],{"class":67}," f.prefix\n",[33,10870,10871,10874,10877],{"class":35,"line":1140},[33,10872,10873],{"class":67},"    } ",[33,10875,10876],{"class":131},"else",[33,10878,1706],{"class":67},[33,10880,10881,10883,10885,10888,10890,10892,10895,10897,10899,10902,10904],{"class":35,"line":1147},[33,10882,10863],{"class":67},[33,10884,138],{"class":131},[33,10886,10887],{"class":67}," f.prefix ",[33,10889,2884],{"class":131},[33,10891,10856],{"class":50},[33,10893,10894],{"class":131}," +",[33,10896,4653],{"class":67},[33,10898,6485],{"class":46},[33,10900,10901],{"class":67},"(name, ",[33,10903,6491],{"class":50},[33,10905,2376],{"class":67},[33,10907,10908],{"class":35,"line":1152},[33,10909,2026],{"class":67},[33,10911,10912,10914,10916,10919,10922,10924],{"class":35,"line":1158},[33,10913,3748],{"class":131},[33,10915,9516],{"class":67},[33,10917,10918],{"class":46},"FS",[33,10920,10921],{"class":67},"(f.fs).",[33,10923,10823],{"class":46},[33,10925,10926],{"class":67},"(name)\n",[33,10928,10929],{"class":35,"line":1166},[33,10930,1360],{"class":67},[16,10932,10933],{},"两个细节：",[1613,10935,10936,10959],{},[288,10937,10938,10946,10947,6926,10949,10951,10952,10954,10955,10958],{},[1305,10939,10940,10943,10944,10436],{},[30,10941,10942],{},"all:dist"," 里的 ",[30,10945,10435],{},"：embed 目录时默认会排除 ",[30,10948,6909],{},[30,10950,2188],{}," 开头的文件（上篇坑二就是被这个坑了，当时靠改 Vite 输出名绕开）。",[30,10953,10435],{}," 前缀表示「全部包含」，前端构建产物里那些 ",[30,10956,10957],{},"_xxx.js"," 助手文件直接安全落地，不用再改前端配置；",[288,10960,10961,10964,10965,10967,10968,10971],{},[30,10962,10963],{},"http.FileSystem"," 包装：加了一层 ",[30,10966,6275],{}," 前缀映射，",[30,10969,10970],{},"http.FileServer"," 和 Gin 都能直接用。",[281,10973,10975],{"id":10974},"_4-路由静态服务-缓存策略","4. 路由：静态服务 + 缓存策略",[23,10977,10979],{"className":3490,"code":10978,"language":172,"meta":28,"style":28},"\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",[30,10980,10981,10986,11028,11040,11045,11060,11105,11134,11152,11173,11178,11197,11220,11225,11242,11246,11267,11271,11311,11315],{"__ignoreMap":28},[33,10982,10983],{"class":35,"line":36},[33,10984,10985],{"class":39},"\u002F\u002F backend\u002Finternal\u002Fhandler\u002Frouter.go\n",[33,10987,10988,10990,10993,10995,10998,11000,11003,11005,11008,11010,11013,11015,11017,11019,11021,11023,11026],{"class":35,"line":43},[33,10989,3691],{"class":131},[33,10991,10992],{"class":46}," NewRouter",[33,10994,2066],{"class":67},[33,10996,10997],{"class":2069},"db",[33,10999,4316],{"class":131},[33,11001,11002],{"class":46},"gorm",[33,11004,2188],{"class":67},[33,11006,11007],{"class":46},"DB",[33,11009,799],{"class":67},[33,11011,11012],{"class":2069},"uploadDir",[33,11014,9972],{"class":131},[33,11016,2530],{"class":67},[33,11018,3723],{"class":131},[33,11020,4319],{"class":46},[33,11022,2188],{"class":67},[33,11024,11025],{"class":46},"Engine",[33,11027,1706],{"class":67},[33,11029,11030,11032,11034,11036,11038],{"class":35,"line":86},[33,11031,4132],{"class":67},[33,11033,3517],{"class":131},[33,11035,4137],{"class":67},[33,11037,4140],{"class":46},[33,11039,2261],{"class":67},[33,11041,11042],{"class":35,"line":93},[33,11043,11044],{"class":131},"    ...\n",[33,11046,11047,11050,11052,11054,11057],{"class":35,"line":99},[33,11048,11049],{"class":67},"    dist ",[33,11051,3517],{"class":131},[33,11053,9516],{"class":67},[33,11055,11056],{"class":46},"FileServer",[33,11058,11059],{"class":67},"(web.Dist)\n",[33,11061,11062,11064,11066,11068,11071,11073,11075,11077,11079,11081,11083,11085,11087,11090,11093,11095,11098,11100,11102],{"class":35,"line":128},[33,11063,4270],{"class":67},[33,11065,4299],{"class":46},[33,11067,2066],{"class":67},[33,11069,11070],{"class":50},"\"\u002Fdist\"",[33,11072,799],{"class":67},[33,11074,3691],{"class":131},[33,11076,2066],{"class":67},[33,11078,4313],{"class":2069},[33,11080,4316],{"class":131},[33,11082,4319],{"class":46},[33,11084,2188],{"class":67},[33,11086,3704],{"class":46},[33,11088,11089],{"class":67},") { c.",[33,11091,11092],{"class":46},"Redirect",[33,11094,2066],{"class":67},[33,11096,11097],{"class":54},"302",[33,11099,799],{"class":67},[33,11101,10503],{"class":50},[33,11103,11104],{"class":67},") })\n",[33,11106,11107,11109,11111,11113,11116,11118,11120,11122,11124,11126,11128,11130,11132],{"class":35,"line":147},[33,11108,4270],{"class":67},[33,11110,4299],{"class":46},[33,11112,2066],{"class":67},[33,11114,11115],{"class":50},"\"\u002Fdist\u002F*filepath\"",[33,11117,799],{"class":67},[33,11119,3691],{"class":131},[33,11121,2066],{"class":67},[33,11123,4313],{"class":2069},[33,11125,4316],{"class":131},[33,11127,4319],{"class":46},[33,11129,2188],{"class":67},[33,11131,3704],{"class":46},[33,11133,3318],{"class":67},[33,11135,11136,11138,11140,11142,11145,11147,11150],{"class":35,"line":163},[33,11137,6478],{"class":67},[33,11139,3517],{"class":131},[33,11141,4570],{"class":67},[33,11143,11144],{"class":46},"Param",[33,11146,2066],{"class":67},[33,11148,11149],{"class":50},"\"filepath\"",[33,11151,2376],{"class":67},[33,11153,11154,11156,11158,11160,11162,11164,11166,11168,11171],{"class":35,"line":169},[33,11155,4585],{"class":131},[33,11157,6500],{"class":67},[33,11159,4591],{"class":131},[33,11161,10856],{"class":50},[33,11163,4686],{"class":131},[33,11165,6500],{"class":67},[33,11167,4591],{"class":131},[33,11169,11170],{"class":50}," \"\u002Findex.html\"",[33,11172,1706],{"class":67},[33,11174,11175],{"class":35,"line":183},[33,11176,11177],{"class":39},"            \u002F\u002F index.html 禁缓存：升级后浏览器立刻拿到新入口\n",[33,11179,11180,11182,11185,11187,11190,11192,11195],{"class":35,"line":206},[33,11181,4601],{"class":67},[33,11183,11184],{"class":46},"Header",[33,11186,2066],{"class":67},[33,11188,11189],{"class":50},"\"Cache-Control\"",[33,11191,799],{"class":67},[33,11193,11194],{"class":50},"\"no-cache, no-store, must-revalidate\"",[33,11196,2376],{"class":67},[33,11198,11199,11202,11204,11207,11209,11212,11215,11218],{"class":35,"line":215},[33,11200,11201],{"class":67},"        } ",[33,11203,10876],{"class":131},[33,11205,11206],{"class":131}," if",[33,11208,4653],{"class":67},[33,11210,11211],{"class":46},"HasPrefix",[33,11213,11214],{"class":67},"(p, ",[33,11216,11217],{"class":50},"\"\u002Fassets\u002F\"",[33,11219,3318],{"class":67},[33,11221,11222],{"class":35,"line":220},[33,11223,11224],{"class":39},"            \u002F\u002F 带内容 hash 的资源可放心长缓存\n",[33,11226,11227,11229,11231,11233,11235,11237,11240],{"class":35,"line":226},[33,11228,4601],{"class":67},[33,11230,11184],{"class":46},[33,11232,2066],{"class":67},[33,11234,11189],{"class":50},[33,11236,799],{"class":67},[33,11238,11239],{"class":50},"\"public, max-age=31536000, immutable\"",[33,11241,2376],{"class":67},[33,11243,11244],{"class":35,"line":237},[33,11245,4639],{"class":67},[33,11247,11248,11251,11254,11256,11258,11261,11264],{"class":35,"line":248},[33,11249,11250],{"class":67},"        http.",[33,11252,11253],{"class":46},"StripPrefix",[33,11255,2066],{"class":67},[33,11257,11070],{"class":50},[33,11259,11260],{"class":67},", dist).",[33,11262,11263],{"class":46},"ServeHTTP",[33,11265,11266],{"class":67},"(c.Writer, c.Request)\n",[33,11268,11269],{"class":35,"line":253},[33,11270,4365],{"class":67},[33,11272,11273,11275,11277,11279,11281,11283,11285,11287,11289,11291,11293,11295,11297,11299,11301,11303,11305,11307,11309],{"class":35,"line":262},[33,11274,4270],{"class":67},[33,11276,4299],{"class":46},[33,11278,2066],{"class":67},[33,11280,6491],{"class":50},[33,11282,799],{"class":67},[33,11284,3691],{"class":131},[33,11286,2066],{"class":67},[33,11288,4313],{"class":2069},[33,11290,4316],{"class":131},[33,11292,4319],{"class":46},[33,11294,2188],{"class":67},[33,11296,3704],{"class":46},[33,11298,11089],{"class":67},[33,11300,11092],{"class":46},[33,11302,2066],{"class":67},[33,11304,11097],{"class":54},[33,11306,799],{"class":67},[33,11308,10503],{"class":50},[33,11310,11104],{"class":67},[33,11312,11313],{"class":35,"line":267},[33,11314,11044],{"class":131},[33,11316,11317],{"class":35,"line":477},[33,11318,1360],{"class":67},[16,11320,11321],{},"缓存策略是内嵌发布的必修课（坑二细说）：入口禁缓存、hash 资源长缓存。",[281,11323,11325],{"id":11324},"_5-打包脚本windows-版-linux-交叉编译版","5. 打包脚本：Windows 版 + Linux 交叉编译版",[23,11327,11329],{"className":25,"code":11328,"language":27,"meta":28,"style":28},"#!\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",[30,11330,11331,11336,11341,11351,11369,11373,11394,11398,11406,11425,11429,11436,11493,11508,11512],{"__ignoreMap":28},[33,11332,11333],{"class":35,"line":36},[33,11334,11335],{"class":39},"#!\u002Fusr\u002Fbin\u002Fenv bash\n",[33,11337,11338],{"class":35,"line":43},[33,11339,11340],{"class":39},"# build-linux.sh —— 在 Windows\u002FmacOS\u002FLinux 任意平台交叉编译 linux\u002Famd64 产物\n",[33,11342,11343,11345,11348],{"class":35,"line":86},[33,11344,6756],{"class":54},[33,11346,11347],{"class":54}," -euo",[33,11349,11350],{"class":50}," pipefail\n",[33,11352,11353,11355,11358,11361,11363,11366],{"class":35,"line":93},[33,11354,6835],{"class":54},[33,11356,11357],{"class":50}," \"$(",[33,11359,11360],{"class":46},"dirname",[33,11362,64],{"class":50},[33,11364,11365],{"class":54},"$0",[33,11367,11368],{"class":50},"\")\"\n",[33,11370,11371],{"class":35,"line":99},[33,11372,90],{"emptyLinePlaceholder":89},[33,11374,11375,11378,11380,11383,11385,11388,11391],{"class":35,"line":128},[33,11376,11377],{"class":67},"BINARY",[33,11379,138],{"class":131},[33,11381,11382],{"class":50},"\"${",[33,11384,11377],{"class":67},[33,11386,11387],{"class":131},":-",[33,11389,11390],{"class":67},"magic-img",[33,11392,11393],{"class":50},"}\"\n",[33,11395,11396],{"class":35,"line":147},[33,11397,90],{"emptyLinePlaceholder":89},[33,11399,11400,11403],{"class":35,"line":163},[33,11401,11402],{"class":54},"echo",[33,11404,11405],{"class":50}," \"[1\u002F3] 构建前端并复制产物到 backend\u002Fweb\u002Fdist ...\"\n",[33,11407,11408,11410,11412,11415,11417,11419,11421,11423],{"class":35,"line":169},[33,11409,2066],{"class":67},[33,11411,6835],{"class":54},[33,11413,11414],{"class":50}," frontend",[33,11416,108],{"class":67},[33,11418,5629],{"class":46},[33,11420,51],{"class":50},[33,11422,188],{"class":50},[33,11424,2376],{"class":67},[33,11426,11427],{"class":35,"line":183},[33,11428,90],{"emptyLinePlaceholder":89},[33,11430,11431,11433],{"class":35,"line":206},[33,11432,11402],{"class":54},[33,11434,11435],{"class":50}," \"[2\u002F3] 交叉编译后端并内嵌前端 (CGO_ENABLED=0 GOOS=linux GOARCH=amd64) ...\"\n",[33,11437,11438,11440,11442,11445,11448,11450,11452,11455,11457,11460,11463,11465,11468,11470,11472,11474,11476,11479,11482,11485,11488,11491],{"class":35,"line":215},[33,11439,2066],{"class":67},[33,11441,6835],{"class":54},[33,11443,11444],{"class":50}," backend",[33,11446,11447],{"class":67}," && CGO_ENABLED",[33,11449,138],{"class":131},[33,11451,2154],{"class":50},[33,11453,11454],{"class":67}," GOOS",[33,11456,138],{"class":131},[33,11458,11459],{"class":50},"linux",[33,11461,11462],{"class":67}," GOARCH",[33,11464,138],{"class":131},[33,11466,11467],{"class":50},"amd64",[33,11469,543],{"class":46},[33,11471,188],{"class":50},[33,11473,197],{"class":54},[33,11475,64],{"class":50},[33,11477,11478],{"class":67},"$BINARY",[33,11480,11481],{"class":50},"\"",[33,11483,11484],{"class":54}," -ldflags=",[33,11486,11487],{"class":50},"\"-s -w\"",[33,11489,11490],{"class":50}," .\u002Fcmd\u002Fserver",[33,11492,2376],{"class":67},[33,11494,11495,11498,11501,11504,11506],{"class":35,"line":220},[33,11496,11497],{"class":46},"chmod",[33,11499,11500],{"class":50}," +x",[33,11502,11503],{"class":50}," \"backend\u002F",[33,11505,11478],{"class":67},[33,11507,279],{"class":50},[33,11509,11510],{"class":35,"line":226},[33,11511,90],{"emptyLinePlaceholder":89},[33,11513,11514,11516,11519,11521],{"class":35,"line":237},[33,11515,11402],{"class":54},[33,11517,11518],{"class":50}," \"[3\u002F3] 打包完成: backend\u002F",[33,11520,11478],{"class":67},[33,11522,279],{"class":50},[16,11524,11525,11526,11529,11530,1957],{},"Windows 版（",[30,11527,11528],{},"build-win.bat","）同理，只是产物叫 ",[30,11531,11532],{},"magic-img.exe",[12,11534,6869],{"id":6869},[281,11536,11538,11539,11542,11543,11545],{"id":11537},"坑一goembed-dist-静默漏掉-_-开头文件升级解法","坑一：",[30,11540,11541],{},"\u002F\u002Fgo:embed dist"," 静默漏掉 ",[30,11544,6909],{}," 开头文件（升级解法）",[16,11547,11548,11549,11556,11557,11559],{},"上篇说过：embed 目录时",[1305,11550,11551,11552,6926,11554,6910],{},"硬编码排除 ",[30,11553,6909],{},[30,11555,2188],{},"。上篇的解法是改 Vite 输出文件名去掉 ",[30,11558,6909],{}," 前缀；这次直接用：",[23,11561,11563],{"className":3490,"code":11562,"language":172,"meta":28,"style":28},"\u002F\u002Fgo:embed all:dist   \u002F\u002F all: 前缀 = 包含全部文件，不再静默跳过\n",[30,11564,11565],{"__ignoreMap":28},[33,11566,11567,11570],{"class":35,"line":36},[33,11568,11569],{"class":39},"\u002F\u002Fgo:embed all:dist",[33,11571,11572],{"class":39},"   \u002F\u002F all: 前缀 = 包含全部文件，不再静默跳过\n",[16,11574,11575],{},[1305,11576,11577,11578,11580,11581,11583],{},"结论：能用 ",[30,11579,10435],{}," 就用 ",[30,11582,10435],{},"，从根上消灭这类\"文件莫名 404\"的问题。",[281,11585,11587],{"id":11586},"坑二开发版是新的打包版却是旧的","坑二：开发版是新的、打包版却是旧的",[16,11589,11590,11591,11594],{},"现象：Vite 开发服务一切正常，",[30,11592,11593],{},":8080\u002Fdist\u002F"," 打包版却是旧界面，强刷也不一定好。",[16,11596,11597,11598,11600,11601,11604,11605,11610],{},"排查过程值得记录：先是怀疑 ",[30,11599,7072],{}," 缓存了旧 embed（上篇坑三的思路），重编重启无效；然后直接抓取 8080 返回的 HTML 和 JS，发现",[1305,11602,11603],{},"服务端内容已经是新的","——问题出在",[1305,11606,11607,11608],{},"浏览器缓存了 ",[30,11609,10569],{},"：它引用旧 hash 的资源，一直不更新。",[16,11612,11613,11615,11616,11619,11620,11623],{},[1305,11614,6887],{},"：入口文件响应 ",[30,11617,11618],{},"Cache-Control: no-store","（见上面路由代码），带 hash 的资源给 ",[30,11621,11622],{},"immutable"," 长缓存。升级后浏览器每次拉新入口，资源文件名一变就自动换新，两全其美。",[9222,11625,11626],{},[16,11627,11628],{},"教训：排查\"新旧不一致\"先分清是哪一层——服务端产物、浏览器入口缓存、还是资源缓存。抓包看响应内容再动手。",[281,11630,11632],{"id":11631},"坑三windows-上跑的-build-linuxsh产出的文件-linux-却执行不了","坑三：Windows 上跑的 build-linux.sh，产出的文件 Linux 却执行不了",[16,11634,11635,11636,6116,11639,11645,11646,11648],{},"最初脚本只写了 ",[30,11637,11638],{},"CGO_ENABLED=0 go build",[1305,11640,11641,11642],{},"没指定 ",[30,11643,11644],{},"GOOS\u002FGOARCH","。在 Windows 上跑，产出的其实是个 Windows PE 文件（只是没叫 ",[30,11647,7374],{},"），传到 Linux 自然「无法执行」。",[16,11650,11651,11653],{},[1305,11652,6887],{},"：脚本里显式交叉编译：",[23,11655,11657],{"className":25,"code":11656,"language":27,"meta":28,"style":28},"CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o \"$BINARY\" -ldflags=\"-s -w\" .\u002Fcmd\u002Fserver\n",[30,11658,11659],{"__ignoreMap":28},[33,11660,11661,11664,11666,11668,11670,11672,11674,11676,11678,11680,11682,11684,11686,11688,11690,11692,11694,11696],{"class":35,"line":36},[33,11662,11663],{"class":67},"CGO_ENABLED",[33,11665,138],{"class":131},[33,11667,2154],{"class":50},[33,11669,11454],{"class":67},[33,11671,138],{"class":131},[33,11673,11459],{"class":50},[33,11675,11462],{"class":67},[33,11677,138],{"class":131},[33,11679,11467],{"class":50},[33,11681,543],{"class":46},[33,11683,188],{"class":50},[33,11685,197],{"class":54},[33,11687,64],{"class":50},[33,11689,11478],{"class":67},[33,11691,11481],{"class":50},[33,11693,11484],{"class":54},[33,11695,11487],{"class":50},[33,11697,11698],{"class":50}," .\u002Fcmd\u002Fserver\n",[16,11700,11701,11702,11705,11706,11709,11710],{},"能这么干的前提是",[1305,11703,11704],{},"全依赖树无 cgo","——SQLite 用的是 modernc 纯 Go 驱动，bcrypt 用 ",[30,11707,11708],{},"x\u002Fcrypto","。这也是选型时就要留意的点：",[1305,11711,11712],{},"想跨平台交叉编译，先把 cgo 依赖清干净。",[16,11714,11715,11716,11719,11720,11723],{},"另外两个小提醒：Windows 上编出的文件没有 Unix 执行位，上传服务器后记得 ",[30,11717,11718],{},"chmod +x","；",[30,11721,11722],{},"-ldflags=\"-s -w\""," 剥离符号，体积从 30MB+ 降到 26MB。",[281,11725,11727],{"id":11726},"坑四dist-被-gitignore-后embed-直接编译失败","坑四：dist 被 gitignore 后，embed 直接编译失败",[16,11729,11730,11732,11733,11735,11736,11739,11740,11742],{},[30,11731,10594],{}," 是构建产物、入了 .gitignore，但 ",[30,11734,6271],{}," 在",[1305,11737,11738],{},"编译期","就要读它——刚 clone 下来直接 ",[30,11741,7072],{}," 会报 embed 找不到文件。",[16,11744,11745,11747,11748,11751],{},[1305,11746,6887],{},"：gitignore 里保留 ",[30,11749,11750],{},"dist\u002F.gitkeep","（一个空文件），让目录存在于版本库中：",[23,11753,11757],{"className":11754,"code":11755,"language":11756,"meta":28,"style":28},"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",[30,11758,11759,11764,11769],{"__ignoreMap":28},[33,11760,11761],{"class":35,"line":36},[33,11762,11763],{},"backend\u002Fweb\u002Fdist\u002F*\n",[33,11765,11766],{"class":35,"line":43},[33,11767,11768],{},"!backend\u002Fweb\u002Fdist\u002F\n",[33,11770,11771],{"class":35,"line":86},[33,11772,11773],{},"!backend\u002Fweb\u002Fdist\u002F.gitkeep\n",[16,11775,11776,11777,11779,11780,11782],{},"这样 clone 后可以先 ",[30,11778,7072],{}," 通过，再 ",[30,11781,10587],{}," 灌入真实前端。",[281,11784,11786],{"id":11785},"坑五500kb-chunk-警告别慌","坑五：>500KB chunk 警告，别慌",[16,11788,11789,11790,11793,11794,1957],{},"Vite 构建会警告单 chunk 超过 500KB。这个项目里 Base UI 组件库全量引入，单 chunk 约 640KB——",[1305,11791,11792],{},"属正常，不要为了消警告去拆配置","。真要优化就上路由级 ",[30,11795,11796],{},"React.lazy",[12,11798,7085],{"id":7085},[23,11800,11802],{"className":25,"code":11801,"language":27,"meta":28,"style":28},"$ build-win.bat        # backendmagic-img.exe\n$ bash build-linux.sh  # backendmagic-img（linux\u002Famd64 ELF）\n",[30,11803,11804,11814],{"__ignoreMap":28},[33,11805,11806,11808,11811],{"class":35,"line":36},[33,11807,2182],{"class":46},[33,11809,11810],{"class":50}," build-win.bat",[33,11812,11813],{"class":39},"        # backendmagic-img.exe\n",[33,11815,11816,11818,11820,11823],{"class":35,"line":43},[33,11817,2182],{"class":46},[33,11819,7097],{"class":50},[33,11821,11822],{"class":50}," build-linux.sh",[33,11824,11825],{"class":39},"  # backendmagic-img（linux\u002Famd64 ELF）\n",[16,11827,11828,11829,11832,11833,1957],{},"一个约 26MB 的二进制，内嵌 React 前端、Gin API、SQLite 数据初始化，拷到 Windows\u002FLinux 机器直接跑，浏览器访问 ",[30,11830,11831],{},"http:\u002F\u002Flocalhost:8080"," 自动重定向到 ",[30,11834,10573],{},[12,11836,1215],{"id":1215},[16,11838,11839],{},"这轮相比上篇的三点升级：",[285,11841,11842,11848,11858],{},[288,11843,11844,11847],{},[1305,11845,11846],{},"hash 路由 + base \u002Fdist","：省掉 SPA fallback，静态服务即开即用；",[288,11849,11850,3209,11855,11857],{},[1305,11851,11852,11854],{},[30,11853,10435],{}," embed 前缀",[30,11856,6909],{}," 开头文件问题一劳永逸；",[288,11859,11860,11866],{},[1305,11861,11862,11863,11865],{},"显式 ",[30,11864,11644],{}," 交叉编译","：让「一个脚本、任意平台、产出 Linux 二进制」成为现实。",[16,11868,11869,11870,11873],{},"加上",[1305,11871,11872],{},"入口 no-store \u002F 资源 immutable"," 的缓存策略，单文件分发的体验才算闭环——不然每次升级都要和浏览器缓存打架。",[825,11875,11876],{},"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":28,"searchDepth":43,"depth":86,"links":11878},[11879,11880,11881,11888,11896,11897],{"id":1933,"depth":43,"text":1933},{"id":6265,"depth":43,"text":6265},{"id":6279,"depth":43,"text":6279,"children":11882},[11883,11884,11885,11886,11887],{"id":10463,"depth":86,"text":10464},{"id":10581,"depth":86,"text":10582},{"id":10637,"depth":86,"text":10638},{"id":10974,"depth":86,"text":10975},{"id":11324,"depth":86,"text":11325},{"id":6869,"depth":43,"text":6869,"children":11889},[11890,11892,11893,11894,11895],{"id":11537,"depth":86,"text":11891},"坑一：\u002F\u002Fgo:embed dist 静默漏掉 _ 开头文件（升级解法）",{"id":11586,"depth":86,"text":11587},{"id":11631,"depth":86,"text":11632},{"id":11726,"depth":86,"text":11727},{"id":11785,"depth":86,"text":11786},{"id":7085,"depth":43,"text":7085},{"id":1215,"depth":43,"text":1215},"以 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":10375,"description":11898},"posts\u002Fgo-embed-react-single-binary\u002Findex",[852,9191,6319,11906,11907],"交叉编译","打包","WV-T-MOO5Cu-aycw-Ony8s9hjA3_k0S4XU-T0QsCow0",{"id":11910,"title":11911,"author":7,"body":11912,"description":13157,"difficulty":1266,"draft":841,"extension":842,"image":13158,"meta":13159,"navigation":89,"path":13160,"pinned":841,"published":11902,"seo":13161,"series":13162,"seriesOrder":36,"stem":13163,"tags":13164,"updated":10371,"__hash__":13168},"posts\u002Fposts\u002Freact-photo-album-masonry\u002Findex.md","用 react-photo-album 给生图图库做瀑布流排版",{"type":9,"value":11913,"toc":13140},[11914,11917,11920,11938,11945,12009,12020,12023,12046,12049,12053,12067,12071,12087,12265,12269,12666,12669,12703,12706,12710,12720,12726,12751,12756,12767,12773,12784,12787,12827,12840,12963,12975,12979,12986,13004,13009,13099,13105,13109,13120,13123,13126,13132,13137],[12,11915,11916],{"id":11916},"技术背景",[16,11918,11919],{},"Magic Img 是我做的一个 AI 生图提示词管理工具（自托管、本地优先），其中「生图图库」页面要把所有生成图片以图片墙的形式展示出来。图片墙有两个天然矛盾：",[285,11921,11922,11928],{},[288,11923,11924,11927],{},[1305,11925,11926],{},"等宽网格","（CSS Grid）：整齐，但每行高度由最高的那张决定——不同比例的图片混排时，矮图下面会留下大洞，看起来\"很乱\"；",[288,11929,11930,11933,11934,11937],{},[1305,11931,11932],{},"原生比例展示","：图库必须不裁剪（这是产品约束），所以不能用 ",[30,11935,11936],{},"object-cover"," 强行统一比例。",[16,11939,11940,11941,11944],{},"业内的标准解法是 ",[1305,11942,11943],{},"Masonry 瀑布流","：按列紧密堆放，每张图保持原始比例，列间高度错落但整体紧凑。实现瀑布流的常见方案有三类：",[1390,11946,11947,11961],{},[1393,11948,11949],{},[1396,11950,11951,11953,11956,11959],{},[1399,11952,2745],{},[1399,11954,11955],{},"依赖",[1399,11957,11958],{},"顺序",[1399,11960,5364],{},[1412,11962,11963,11980,11993],{},[1396,11964,11965,11972,11974,11977],{},[1417,11966,11967,11968,11971],{},"CSS ",[30,11969,11970],{},"columns"," 多列",[1417,11973,1448],{},[1417,11975,11976],{},"纵向阅读序（先下后右）",[1417,11978,11979],{},"最新图片不再出现在左上角，时间语义丢失",[1396,11981,11982,11985,11987,11990],{},[1417,11983,11984],{},"grid row-span 自研",[1417,11986,1448],{},[1417,11988,11989],{},"保持",[1417,11991,11992],{},"需固定列数 + 固定行高，逻辑要自己写",[1396,11994,11995,12000,12003,12006],{},[1417,11996,11997],{},[1305,11998,11999],{},"react-photo-album",[1417,12001,12002],{},"~4KB，无依赖",[1417,12004,12005],{},"基本保持（逐列最短列填充）",[1417,12007,12008],{},"专为相册墙设计，TS 友好，维护活跃",[16,12010,12011,12012,12015,12016,12019],{},"最终选了 ",[1305,12013,12014],{},"react-photo-album v3","：体积小、类型齐全，而且它的 Masonry 模式恰好需要「每张图的 ",[30,12017,12018],{},"width\u002Fheight","」——我们的数据库里本来就存了，零额外成本。",[12,12021,12022],{"id":12022},"需求梳理",[285,12024,12025,12031,12037,12040,12043],{},[288,12026,12027,12028,11719],{},"展示全部生成图（21 张起，会持续增长），",[1305,12029,12030],{},"原比例、不裁剪",[288,12032,12033,12034,11719],{},"不同尺寸（2304×1728、4608×3456 等）混排时",[1305,12035,12036],{},"紧凑、不留洞",[288,12038,12039],{},"顺序基本保持「新图靠前」；",[288,12041,12042],{},"点击任意一张打开大图预览（YARL 灯箱），悬停显示所属提示词；",[288,12044,12045],{},"平板性能：墙上只用 300px 缩略图，原图只进灯箱按需加载。",[12,12047,12048],{"id":12048},"安装与使用",[281,12050,12052],{"id":12051},"_1-安装","1. 安装",[23,12054,12056],{"className":25,"code":12055,"language":27,"meta":28,"style":28},"npm install react-photo-album\n",[30,12057,12058],{"__ignoreMap":28},[33,12059,12060,12062,12064],{"class":35,"line":36},[33,12061,5629],{"class":46},[33,12063,113],{"class":50},[33,12065,12066],{"class":50}," react-photo-album\n",[281,12068,12070],{"id":12069},"_2-准备数据关键输入是宽高","2. 准备数据（关键输入是宽高）",[16,12072,12073,12074,12077,12078,6892,12080,6892,12083,12086],{},"库要求的 ",[30,12075,12076],{},"Photo"," 对象只有三个必需字段：",[30,12079,2606],{},[30,12081,12082],{},"width",[30,12084,12085],{},"height","。我们的接口本来就返回每张图的原始宽高：",[23,12088,12090],{"className":10521,"code":12089,"language":10523,"meta":28,"style":28},"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",[30,12091,12092,12110,12115,12122,12126,12142,12152,12156,12160,12193,12204,12223,12228,12233,12255,12260],{"__ignoreMap":28},[33,12093,12094,12096,12099,12101,12104,12107],{"class":35,"line":36},[33,12095,3498],{"class":131},[33,12097,12098],{"class":67}," PhotoAlbum, { ",[33,12100,4883],{"class":131},[33,12102,12103],{"class":67}," Photo } ",[33,12105,12106],{"class":131},"from",[33,12108,12109],{"class":50}," \"react-photo-album\"\n",[33,12111,12112],{"class":35,"line":43},[33,12113,12114],{"class":39},"\u002F\u002F 必须引入样式表！容器\u002F列的布局规则都在这里（坑 1 详述）\n",[33,12116,12117,12119],{"class":35,"line":86},[33,12118,3498],{"class":131},[33,12120,12121],{"class":50}," \"react-photo-album\u002Fstyles.css\"\n",[33,12123,12124],{"class":35,"line":93},[33,12125,90],{"emptyLinePlaceholder":89},[33,12127,12128,12131,12134,12137,12140],{"class":35,"line":99},[33,12129,12130],{"class":131},"interface",[33,12132,12133],{"class":46}," GalleryPhoto",[33,12135,12136],{"class":131}," extends",[33,12138,12139],{"class":46}," Photo",[33,12141,1706],{"class":67},[33,12143,12144,12147,12150],{"class":35,"line":128},[33,12145,12146],{"class":2069},"  prompt_text",[33,12148,12149],{"class":131},"?:",[33,12151,4889],{"class":54},[33,12153,12154],{"class":35,"line":147},[33,12155,1360],{"class":67},[33,12157,12158],{"class":35,"line":163},[33,12159,90],{"emptyLinePlaceholder":89},[33,12161,12162,12164,12167,12169,12171,12173,12175,12178,12181,12184,12186,12188,12190],{"class":35,"line":169},[33,12163,2300],{"class":131},[33,12165,12166],{"class":54}," albumPhotos",[33,12168,979],{"class":131},[33,12170,12133],{"class":46},[33,12172,2088],{"class":67},[33,12174,138],{"class":131},[33,12176,12177],{"class":67}," images.",[33,12179,12180],{"class":46},"map",[33,12182,12183],{"class":67},"((",[33,12185,310],{"class":2069},[33,12187,2530],{"class":67},[33,12189,2494],{"class":131},[33,12191,12192],{"class":67}," ({\n",[33,12194,12195,12198,12201],{"class":35,"line":183},[33,12196,12197],{"class":67},"  key: ",[33,12199,12200],{"class":46},"String",[33,12202,12203],{"class":67},"(img.id),\n",[33,12205,12206,12209,12212,12215,12217,12220],{"class":35,"line":206},[33,12207,12208],{"class":67},"  src: ",[33,12210,12211],{"class":46},"assetUrl",[33,12213,12214],{"class":67},"(img.thumb_path) ",[33,12216,2280],{"class":131},[33,12218,12219],{"class":46}," assetUrl",[33,12221,12222],{"class":67},"(img.file_path),\n",[33,12224,12225],{"class":35,"line":215},[33,12226,12227],{"class":67},"  width: img.width,\n",[33,12229,12230],{"class":35,"line":220},[33,12231,12232],{"class":67},"  height: img.height,\n",[33,12234,12235,12238,12240,12243,12245,12247,12250,12253],{"class":35,"line":226},[33,12236,12237],{"class":67},"  alt: img.prompt_text ",[33,12239,2280],{"class":131},[33,12241,12242],{"class":50}," `生成图 ${",[33,12244,310],{"class":67},[33,12246,2188],{"class":50},[33,12248,12249],{"class":67},"id",[33,12251,12252],{"class":50},"}`",[33,12254,1717],{"class":67},[33,12256,12257],{"class":35,"line":237},[33,12258,12259],{"class":67},"  prompt_text: img.prompt_text,\n",[33,12261,12262],{"class":35,"line":248},[33,12263,12264],{"class":67},"}))\n",[281,12266,12268],{"id":12267},"_3-使用-masonry-布局","3. 使用 Masonry 布局",[23,12270,12272],{"className":10521,"code":12271,"language":10523,"meta":28,"style":28},"\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",[30,12273,12274,12281,12291,12359,12373,12386,12396,12420,12430,12451,12469,12497,12504,12538,12546,12556,12572,12582,12592,12597,12607,12624,12629,12638,12647,12652,12656,12661],{"__ignoreMap":28},[33,12275,12276,12278],{"class":35,"line":36},[33,12277,982],{"class":67},[33,12279,12280],{"class":54},"PhotoAlbum\n",[33,12282,12283,12286,12288],{"class":35,"line":43},[33,12284,12285],{"class":46},"  layout",[33,12287,138],{"class":131},[33,12289,12290],{"class":50},"\"masonry\"\n",[33,12292,12293,12296,12298,12301,12304,12306,12308,12311,12313,12316,12319,12321,12324,12327,12329,12332,12334,12337,12339,12341,12343,12346,12348,12351,12353,12356],{"class":35,"line":86},[33,12294,12295],{"class":46},"  columns",[33,12297,138],{"class":131},[33,12299,12300],{"class":67},"{(",[33,12302,12303],{"class":2069},"cw",[33,12305,2530],{"class":67},[33,12307,2494],{"class":131},[33,12309,12310],{"class":67}," (cw ",[33,12312,982],{"class":131},[33,12314,12315],{"class":54}," 640",[33,12317,12318],{"class":131}," ?",[33,12320,4683],{"class":54},[33,12322,12323],{"class":131}," :",[33,12325,12326],{"class":67}," cw ",[33,12328,982],{"class":131},[33,12330,12331],{"class":54}," 960",[33,12333,12318],{"class":131},[33,12335,12336],{"class":54}," 3",[33,12338,12323],{"class":131},[33,12340,12326],{"class":67},[33,12342,982],{"class":131},[33,12344,12345],{"class":54}," 1280",[33,12347,12318],{"class":131},[33,12349,12350],{"class":54}," 4",[33,12352,12323],{"class":131},[33,12354,12355],{"class":54}," 5",[33,12357,12358],{"class":67},")}\n",[33,12360,12361,12364,12366,12368,12371],{"class":35,"line":93},[33,12362,12363],{"class":46},"  spacing",[33,12365,138],{"class":131},[33,12367,4350],{"class":67},[33,12369,12370],{"class":54},"16",[33,12372,1360],{"class":67},[33,12374,12375,12378,12380,12382,12384],{"class":35,"line":99},[33,12376,12377],{"class":46},"  padding",[33,12379,138],{"class":131},[33,12381,4350],{"class":67},[33,12383,2154],{"class":54},[33,12385,1360],{"class":67},[33,12387,12388,12391,12393],{"class":35,"line":128},[33,12389,12390],{"class":46},"  photos",[33,12392,138],{"class":131},[33,12394,12395],{"class":67},"{albumPhotos}\n",[33,12397,12398,12401,12403,12406,12409,12412,12414,12417],{"class":35,"line":147},[33,12399,12400],{"class":46},"  onClick",[33,12402,138],{"class":131},[33,12404,12405],{"class":67},"{({ ",[33,12407,12408],{"class":2069},"index",[33,12410,12411],{"class":67}," }) ",[33,12413,2494],{"class":131},[33,12415,12416],{"class":46}," openPreview",[33,12418,12419],{"class":67},"(index)}\n",[33,12421,12422,12425,12427],{"class":35,"line":163},[33,12423,12424],{"class":46},"  render",[33,12426,138],{"class":131},[33,12428,12429],{"class":67},"{{\n",[33,12431,12432,12435,12438,12440,12442,12445,12447,12449],{"class":35,"line":169},[33,12433,12434],{"class":46},"    photo",[33,12436,12437],{"class":67},": (",[33,12439,2601],{"class":2069},[33,12441,799],{"class":67},[33,12443,12444],{"class":2069},"context",[33,12446,2530],{"class":67},[33,12448,2494],{"class":131},[33,12450,1706],{"class":67},[33,12452,12453,12455,12458,12461,12464,12466],{"class":35,"line":183},[33,12454,2204],{"class":131},[33,12456,12457],{"class":67}," { ",[33,12459,12460],{"class":54},"onClick",[33,12462,12463],{"class":67}," } ",[33,12465,138],{"class":131},[33,12467,12468],{"class":67}," props\n",[33,12470,12471,12473,12475,12478,12480,12482,12484,12486,12488,12490,12492,12494],{"class":35,"line":206},[33,12472,2204],{"class":131},[33,12474,12457],{"class":67},[33,12476,12477],{"class":54},"photo",[33,12479,799],{"class":67},[33,12481,12408],{"class":54},[33,12483,799],{"class":67},[33,12485,12082],{"class":54},[33,12487,799],{"class":67},[33,12489,12085],{"class":54},[33,12491,12463],{"class":67},[33,12493,138],{"class":131},[33,12495,12496],{"class":67}," context\n",[33,12498,12499,12502],{"class":35,"line":215},[33,12500,12501],{"class":131},"      return",[33,12503,4896],{"class":67},[33,12505,12506,12509,12512,12515,12517,12520,12523,12525,12528,12531,12533,12536],{"class":35,"line":220},[33,12507,12508],{"class":67},"        \u003C",[33,12510,12511],{"class":1766},"button",[33,12513,12514],{"class":46}," type",[33,12516,138],{"class":131},[33,12518,12519],{"class":50},"\"button\"",[33,12521,12522],{"class":46}," onClick",[33,12524,138],{"class":131},[33,12526,12527],{"class":67},"{onClick} ",[33,12529,12530],{"class":46},"className",[33,12532,138],{"class":131},[33,12534,12535],{"class":50},"\"group relative block w-full ...\"",[33,12537,636],{"class":67},[33,12539,12540,12543],{"class":35,"line":226},[33,12541,12542],{"class":67},"          \u003C",[33,12544,12545],{"class":1766},"img\n",[33,12547,12548,12551,12553],{"class":35,"line":237},[33,12549,12550],{"class":46},"            src",[33,12552,138],{"class":131},[33,12554,12555],{"class":67},"{photo.src}\n",[33,12557,12558,12561,12563,12566,12568,12570],{"class":35,"line":248},[33,12559,12560],{"class":46},"            alt",[33,12562,138],{"class":131},[33,12564,12565],{"class":67},"{photo.alt ",[33,12567,2280],{"class":131},[33,12569,4594],{"class":50},[33,12571,1360],{"class":67},[33,12573,12574,12577,12579],{"class":35,"line":253},[33,12575,12576],{"class":46},"            decoding",[33,12578,138],{"class":131},[33,12580,12581],{"class":50},"\"async\"\n",[33,12583,12584,12587,12589],{"class":35,"line":262},[33,12585,12586],{"class":46},"            style",[33,12588,138],{"class":131},[33,12590,12591],{"class":67},"{{ width, height }}\n",[33,12593,12594],{"class":35,"line":267},[33,12595,12596],{"class":67},"          \u002F>\n",[33,12598,12599,12602,12605],{"class":35,"line":477},[33,12600,12601],{"class":67},"          {",[33,12603,12604],{"class":39},"\u002F* hover 提示词浮层 *\u002F",[33,12606,1360],{"class":67},[33,12608,12609,12611,12614,12617,12619,12622],{"class":35,"line":485},[33,12610,12542],{"class":67},[33,12612,12613],{"class":1766},"div",[33,12615,12616],{"class":46}," className",[33,12618,138],{"class":131},[33,12620,12621],{"class":50},"\"... opacity-0 group-hover:opacity-100\"",[33,12623,636],{"class":67},[33,12625,12626],{"class":35,"line":490},[33,12627,12628],{"class":67},"            {photo.prompt_text}\n",[33,12630,12631,12634,12636],{"class":35,"line":1126},[33,12632,12633],{"class":67},"          \u003C\u002F",[33,12635,12613],{"class":1766},[33,12637,636],{"class":67},[33,12639,12640,12643,12645],{"class":35,"line":1140},[33,12641,12642],{"class":67},"        \u003C\u002F",[33,12644,12511],{"class":1766},[33,12646,636],{"class":67},[33,12648,12649],{"class":35,"line":1147},[33,12650,12651],{"class":67},"      )\n",[33,12653,12654],{"class":35,"line":1152},[33,12655,7046],{"class":67},[33,12657,12658],{"class":35,"line":1158},[33,12659,12660],{"class":67},"  }}\n",[33,12662,12663],{"class":35,"line":1166},[33,12664,12665],{"class":67},"\u002F>\n",[16,12667,12668],{},"几个要点：",[1613,12670,12671,12680,12693],{},[288,12672,12673,12675,12676,12679],{},[30,12674,11970],{}," 可以是数字或 ",[30,12677,12678],{},"(containerWidth) => number"," 函数，做响应式列数很方便；",[288,12681,12682,12683,12685,12686,12689,12690,12692],{},"点击回调 ",[30,12684,12460],{}," 里拿到的是",[1305,12687,12688],{},"原始数组下标"," ",[30,12691,12408],{},"，直接传给灯箱即可；",[288,12694,12695,12696,12699,12700,12702],{},"自定义 ",[30,12697,12698],{},"render.photo"," 后，图片尺寸要自己用 context 里的 ",[30,12701,12018],{},"（渲染尺寸）设置——这两个值库已经按列宽算好了。",[12,12704,12705],{"id":12705},"遇到的坑",[281,12707,12709],{"id":12708},"坑-1忘记引入样式表-一张一列","坑 1：忘记引入样式表 → 「一张一列」",[16,12711,12712,12713,12716,12717,1957],{},"这是最坑的一个。装完包、写好 ",[30,12714,12715],{},"\u003CPhotoAlbum layout=\"masonry\">","，页面渲染出来却是：",[1305,12718,12719],{},"每张图占一整行，右侧大片置灰空白",[16,12721,12722,12723,12725],{},"排查过程：先怀疑是 ",[30,12724,11970],{}," 函数没生效，改成固定数字无效；再检查 DOM——容器和 5 个列（track）都在，列的宽度也算对了，但图片全都堆在布局里。",[16,12727,12728,12729,12732,12733,6892,12736,12739,12740,12746,12747,12750],{},"最后翻包的 ",[30,12730,12731],{},"package.json"," 发现它导出了独立的 CSS 文件（",[30,12734,12735],{},".\u002Fstyles.css",[30,12737,12738],{},".\u002Fmasonry.css"," 等），而",[1305,12741,12742,12743],{},"官方 README 示例都带了一行 ",[30,12744,12745],{},"import \"react-photo-album\u002Fstyles.css\"","。容器是 flex、列是 flex-column、列宽按 ",[30,12748,12749],{},"--react-photo-album--columns"," 计算——这些规则全在这个样式表里，不引入就退化成普通块级堆叠。",[16,12752,12753,12755],{},[1305,12754,6887],{},"：顶部加一行：",[23,12757,12759],{"className":10521,"code":12758,"language":10523,"meta":28,"style":28},"import \"react-photo-album\u002Fstyles.css\"\n",[30,12760,12761],{"__ignoreMap":28},[33,12762,12763,12765],{"class":35,"line":36},[33,12764,3498],{"class":131},[33,12766,12121],{"class":50},[16,12768,12769,12770],{},"经验：",[1305,12771,12772],{},"用这类\"无运行时依赖\"的布局库，先看它的 exports 里有没有独立 CSS，有就一定要引。",[281,12774,12776,12777,12779,12780,12783],{"id":12775},"坑-2renderphoto-是-props-context-双参签名","坑 2：",[30,12778,12698],{}," 是 ",[30,12781,12782],{},"(props, context)"," 双参签名",[16,12785,12786],{},"自定义渲染时我凭直觉写了：",[23,12788,12790],{"className":10521,"code":12789,"language":10523,"meta":28,"style":28},"photo: ({ photo, index, width, height, onClick }) => ... \u002F\u002F ❌ 报错：Property 'photo' does not exist\n",[30,12791,12792],{"__ignoreMap":28},[33,12793,12794,12796,12799,12801,12803,12805,12807,12809,12811,12813,12815,12817,12819,12821,12824],{"class":35,"line":36},[33,12795,12477],{"class":46},[33,12797,12798],{"class":67},": ({ ",[33,12800,12477],{"class":2069},[33,12802,799],{"class":67},[33,12804,12408],{"class":2069},[33,12806,799],{"class":67},[33,12808,12082],{"class":2069},[33,12810,799],{"class":67},[33,12812,12085],{"class":2069},[33,12814,799],{"class":67},[33,12816,12460],{"class":2069},[33,12818,12411],{"class":67},[33,12820,2494],{"class":131},[33,12822,12823],{"class":131}," ...",[33,12825,12826],{"class":39}," \u002F\u002F ❌ 报错：Property 'photo' does not exist\n",[16,12828,12829,12830,12832,12833,12836,12837,3209],{},"TS 报错提示 ",[30,12831,12477],{}," 等属性不存在于 ",[30,12834,12835],{},"RenderPhotoProps","。看类型定义才发现 v3 的渲染函数是",[1305,12838,12839],{},"两个参数",[23,12841,12843],{"className":10521,"code":12842,"language":10523,"meta":28,"style":28},"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",[30,12844,12845,12891,12896,12916,12930,12955,12959],{"__ignoreMap":28},[33,12846,12847,12849,12852,12854,12857,12859,12861,12864,12866,12868,12870,12872,12875,12877,12879,12881,12884,12886,12888],{"class":35,"line":36},[33,12848,4883],{"class":131},[33,12850,12851],{"class":46}," RenderFunction",[33,12853,982],{"class":67},[33,12855,12856],{"class":46},"Props",[33,12858,799],{"class":67},[33,12860,3704],{"class":46},[33,12862,12863],{"class":67},"> ",[33,12865,138],{"class":131},[33,12867,2108],{"class":67},[33,12869,2601],{"class":2069},[33,12871,979],{"class":131},[33,12873,12874],{"class":46}," Props",[33,12876,799],{"class":67},[33,12878,12444],{"class":2069},[33,12880,979],{"class":131},[33,12882,12883],{"class":46}," Context",[33,12885,2530],{"class":67},[33,12887,2494],{"class":131},[33,12889,12890],{"class":46}," ReactNode\n",[33,12892,12893],{"class":35,"line":43},[33,12894,12895],{"class":39},"\u002F\u002F photo 的渲染上下文（photo\u002Findex\u002Fwidth\u002Fheight）在第二个参数里\n",[33,12897,12898,12900,12902,12904,12906,12908,12910,12912,12914],{"class":35,"line":86},[33,12899,12477],{"class":46},[33,12901,979],{"class":131},[33,12903,2108],{"class":67},[33,12905,2601],{"class":2069},[33,12907,799],{"class":67},[33,12909,12444],{"class":2069},[33,12911,2530],{"class":67},[33,12913,2494],{"class":131},[33,12915,1706],{"class":67},[33,12917,12918,12920,12922,12924,12927],{"class":35,"line":93},[33,12919,2555],{"class":46},[33,12921,12457],{"class":67},[33,12923,12460],{"class":2069},[33,12925,12926],{"class":67}," } = ",[33,12928,12929],{"class":2069},"props\n",[33,12931,12932,12934,12936,12938,12940,12942,12944,12946,12948,12950,12952],{"class":35,"line":99},[33,12933,2555],{"class":46},[33,12935,12457],{"class":67},[33,12937,12477],{"class":2069},[33,12939,799],{"class":67},[33,12941,12408],{"class":2069},[33,12943,799],{"class":67},[33,12945,12082],{"class":2069},[33,12947,799],{"class":67},[33,12949,12085],{"class":2069},[33,12951,12926],{"class":67},[33,12953,12954],{"class":2069},"context\n",[33,12956,12957],{"class":35,"line":128},[33,12958,10514],{"class":131},[33,12960,12961],{"class":35,"line":147},[33,12962,1360],{"class":67},[16,12964,12965,12967,12968,12970,12971,12974],{},[1305,12966,6887],{},"：按 ",[30,12969,12782],{}," 拆解即可。装新库先扫一眼 ",[30,12972,12973],{},".d.ts"," 里的 Render 类型签名，能少走很多弯路。",[281,12976,12978],{"id":12977},"坑-3测试断言把dom-顺序当成了视觉顺序","坑 3：测试断言把「DOM 顺序」当成了「视觉顺序」",[16,12980,12981,12982,12985],{},"布局修好后，我在浏览器自测脚本里加了一条断言：取前两个 ",[30,12983,12984],{},"\u003Cimg>","，比较它们的左坐标，认为应该并排（不同列）。",[16,12987,12988,12989,12992,12993,6926,12996,12999,13000,13003],{},"结果断言失败——但页面明明是 5 列并排。排查发现：",[1305,12990,12991],{},"瀑布流的 DOM 是按\"列\"组织的","（第 1 列的全部图片在前，第 2 列的在后），所以 ",[30,12994,12995],{},"img[0]",[30,12997,12998],{},"img[1]"," 属于",[1305,13001,13002],{},"同一列","，左坐标当然相同。",[16,13005,13006,13008],{},[1305,13007,6887],{},"：断言改为比较「第 1 列的首张图 vs 第 2 列的首张图」：",[23,13010,13014],{"className":13011,"code":13012,"language":13013,"meta":28,"style":28},"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",[30,13015,13016,13041,13064],{"__ignoreMap":28},[33,13017,13018,13020,13022,13024,13027,13029,13031,13034,13036,13039],{"class":35,"line":36},[33,13019,2300],{"class":131},[33,13021,3315],{"class":54},[33,13023,1703],{"class":131},[33,13025,13026],{"class":67}," tracks[",[33,13028,2154],{"class":54},[33,13030,2219],{"class":67},[33,13032,13033],{"class":46},"querySelector",[33,13035,2066],{"class":67},[33,13037,13038],{"class":50},"\"img\"",[33,13040,2376],{"class":67},[33,13042,13043,13045,13048,13050,13052,13054,13056,13058,13060,13062],{"class":35,"line":43},[33,13044,2300],{"class":131},[33,13046,13047],{"class":54}," b",[33,13049,1703],{"class":131},[33,13051,13026],{"class":67},[33,13053,157],{"class":54},[33,13055,2219],{"class":67},[33,13057,13033],{"class":46},[33,13059,2066],{"class":67},[33,13061,13038],{"class":50},[33,13063,2376],{"class":67},[33,13065,13066,13068,13071,13074,13077,13080,13083,13086,13089,13091,13094,13096],{"class":35,"line":86},[33,13067,2117],{"class":131},[33,13069,13070],{"class":67}," Math.",[33,13072,13073],{"class":46},"abs",[33,13075,13076],{"class":67},"(a.",[33,13078,13079],{"class":46},"getBoundingClientRect",[33,13081,13082],{"class":67},"().left ",[33,13084,13085],{"class":131},"-",[33,13087,13088],{"class":67}," b.",[33,13090,13079],{"class":46},[33,13092,13093],{"class":67},"().left) ",[33,13095,589],{"class":131},[33,13097,13098],{"class":54}," 10\n",[16,13100,12769,13101,13104],{},[1305,13102,13103],{},"验证布局类库时，先搞清楚它生成的 DOM 结构，再写断言","；另外把「真实浏览器探针」当排查手段（直接打印各 track 的 x 坐标和子元素数量），比盯着代码猜快得多。",[281,13106,13108],{"id":13107},"坑-4等宽网格的洞引入前就存在的产品问题","坑 4：等宽网格的\"洞\"（引入前就存在的产品问题）",[16,13110,13111,13112,13115,13116,13119],{},"顺带记录一下为什么换掉原来的布局：CSS Grid ",[30,13113,13114],{},"repeat(auto-fill, minmax(220px, 1fr))"," 等宽铺满，图片 ",[30,13117,13118],{},"w-full h-auto","。当一行里混有不同比例时，行高取最高的那张，矮图底部留空，几行错落下来就显得乱；另外窗口宽度\"差一点放不下下一张\"时，右侧会空出近一整列。",[16,13121,13122],{},"换成 Masonry 后这两个问题都消失了——列内紧密堆叠、列数自适应。",[12,13124,13125],{"id":13125},"效果与小结",[16,13127,13128,13129,13131],{},"最终效果：21 张不同比例的生成图在 5 列瀑布流里紧密排列，原比例不裁剪，悬停出提示词浮层，点击进 YARL 大图预览；缩略图总大小仅 360KB，进页面即全量加载 + ",[30,13130,11622],{}," 长缓存，平板上快速滚动也跟手。",[16,13133,10300,13134],{},[1305,13135,13136],{},"react-photo-album 是个小但专业的相册布局库，只要记住三件事——引样式表、喂宽高、render 函数是双参——就能在十分钟内把图片墙升级成真正的瀑布流。",[825,13138,13139],{},"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":28,"searchDepth":43,"depth":86,"links":13141},[13142,13143,13144,13149,13156],{"id":11916,"depth":43,"text":11916},{"id":12022,"depth":43,"text":12022},{"id":12048,"depth":43,"text":12048,"children":13145},[13146,13147,13148],{"id":12051,"depth":86,"text":12052},{"id":12069,"depth":86,"text":12070},{"id":12267,"depth":86,"text":12268},{"id":12705,"depth":43,"text":12705,"children":13150},[13151,13152,13154,13155],{"id":12708,"depth":86,"text":12709},{"id":12775,"depth":86,"text":13153},"坑 2：render.photo 是 (props, context) 双参签名",{"id":12977,"depth":86,"text":12978},{"id":13107,"depth":86,"text":13108},{"id":13125,"depth":43,"text":13125},"记录在 Magic Img 生图图库中引入 react-photo-album 实现 Masonry 瀑布流的过程：技术选型背景、需求梳理、安装使用，以及「忘记引入样式表导致一张一列」等踩坑与排查方法。","\u002Fposts\u002Freact-photo-album-masonry\u002Fimg\u002Fcover.svg",{},"\u002Fposts\u002Freact-photo-album-masonry",{"title":11911,"description":13157},"前端工程实践","posts\u002Freact-photo-album-masonry\u002Findex",[9191,11999,13165,13166,13167],"瀑布流","前端","Tailwind","-O-kAQLEyRzwVhOpuo-yBxZim9LtvhChPkpP92ipyjM",1786887349204]