[{"data":1,"prerenderedAt":13812},["ShallowReactive",2],{"post-docker-musl-build":3,"series-source-docker-musl-build":854},{"id":4,"title":5,"author":6,"body":7,"description":838,"difficulty":839,"draft":840,"extension":841,"image":842,"meta":843,"navigation":88,"path":844,"pinned":840,"published":845,"seo":846,"series":847,"seriesOrder":35,"stem":848,"tags":849,"updated":845,"__hash__":853},"posts\u002Fposts\u002Fdocker-musl-build\u002Findex.md","Docker 编译 musl 可执行文件",null,{"type":8,"value":9,"toc":827},"minimark",[10,14,18,21,279,283,327,330,348,351,502,505,641,644,647,705,708,823],[11,12,13],"h2",{"id":13},"解决问题",[15,16,17],"p",{},"用 Go 进行跨平台编译时往往步骤复杂，用 Docker 可以轻松解决。但 Docker 的当前最小镜像 Alpine 其动态链接库采用 musl，与主流的 glibc 不同，往往不能方便执行。",[11,19,20],{"id":20},"查阅资料",[22,23,28],"pre",{"className":24,"code":25,"language":26,"meta":27,"style":27},"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","",[29,30,31,40,83,90,96,125,144,160,166,180,203,212,217,223,234,245,250,259,264],"code",{"__ignoreMap":27},[32,33,36],"span",{"class":34,"line":35},"line",1,[32,37,39],{"class":38},"sHbNN","# 启动容器并挂载当前目录到 `\u002Fapp`\n",[32,41,43,47,51,55,58,61,64,68,71,74,77,80],{"class":34,"line":42},2,[32,44,46],{"class":45},"sw2iP","docker",[32,48,50],{"class":49},"scXbn"," run",[32,52,54],{"class":53},"suQ91"," -it",[32,56,57],{"class":53}," --rm",[32,59,60],{"class":53}," -v",[32,62,63],{"class":49}," \"",[32,65,67],{"class":66},"sIX_F","$PWD",[32,69,70],{"class":49},"\":\u002Fapp",[32,72,73],{"class":53}," -w",[32,75,76],{"class":49}," \u002Fapp",[32,78,79],{"class":49}," ubuntu:20.04",[32,81,82],{"class":49}," bash\n",[32,84,86],{"class":34,"line":85},3,[32,87,89],{"emptyLinePlaceholder":88},true,"\n",[32,91,93],{"class":34,"line":92},4,[32,94,95],{"class":38},"# 在容器内执行以下操作：\n",[32,97,99,102,105,108,110,113,116,119,122],{"class":34,"line":98},5,[32,100,101],{"class":45},"apt-get",[32,103,104],{"class":49}," update",[32,106,107],{"class":66}," && ",[32,109,101],{"class":45},[32,111,112],{"class":49}," install",[32,114,115],{"class":53}," -y",[32,117,118],{"class":49}," musl-tools",[32,120,121],{"class":49}," golang-go",[32,123,124],{"class":38},"  # 安装 musl 和 Go\n",[32,126,128,132,135,138,141],{"class":34,"line":127},6,[32,129,131],{"class":130},"siTax","export",[32,133,134],{"class":66}," CC",[32,136,137],{"class":130},"=",[32,139,140],{"class":66},"musl-gcc  ",[32,142,143],{"class":38},"# 指定 musl 编译器\n",[32,145,147,149,152,154,157],{"class":34,"line":146},7,[32,148,131],{"class":130},[32,150,151],{"class":66}," CGO_ENABLED",[32,153,137],{"class":130},[32,155,156],{"class":53},"1",[32,158,159],{"class":38},"  # 启用 CGO\n",[32,161,163],{"class":34,"line":162},8,[32,164,165],{"class":38},"# 设置代理\n",[32,167,169,172,175,177],{"class":34,"line":168},9,[32,170,171],{"class":45},"go",[32,173,174],{"class":49}," env",[32,176,73],{"class":53},[32,178,179],{"class":49}," GOPROXY=https:\u002F\u002Fgoproxy.cn,direct\n",[32,181,183,185,188,191,194,197,200],{"class":34,"line":182},10,[32,184,171],{"class":45},[32,186,187],{"class":49}," build",[32,189,190],{"class":53}," -ldflags",[32,192,193],{"class":49}," '-linkmode external -extldflags \"-static\"'",[32,195,196],{"class":53}," -o",[32,198,199],{"class":49}," myapp",[32,201,202],{"class":38},"  # 静态编译\n",[32,204,206,209],{"class":34,"line":205},11,[32,207,208],{"class":53},"exit",[32,210,211],{"class":38},"  # 退出容器\n",[32,213,215],{"class":34,"line":214},12,[32,216,89],{"emptyLinePlaceholder":88},[32,218,220],{"class":34,"line":219},13,[32,221,222],{"class":38},"# 验证结果（在宿主机执行）\n",[32,224,226,229,231],{"class":34,"line":225},14,[32,227,228],{"class":45},"file",[32,230,199],{"class":49},[32,232,233],{"class":38},"  # 应显示 \"statically linked\"\n",[32,235,237,240,242],{"class":34,"line":236},15,[32,238,239],{"class":45},"ldd",[32,241,199],{"class":49},[32,243,244],{"class":38},"   # 应显示 \"not a dynamic executable\"\n",[32,246,248],{"class":34,"line":247},16,[32,249,89],{"emptyLinePlaceholder":88},[32,251,253,256],{"class":34,"line":252},17,[32,254,255],{"class":45},"wget",[32,257,258],{"class":49}," https:\u002F\u002Fgo.dev\u002Fdl\u002Fgo1.21.linux-amd64.tar.gz\n",[32,260,262],{"class":34,"line":261},18,[32,263,89],{"emptyLinePlaceholder":88},[32,265,267,270,273,276],{"class":34,"line":266},19,[32,268,269],{"class":45},"ENV",[32,271,272],{"class":49}," PATH=\"\u002Fdata\u002Fgo\u002Fbin:",[32,274,275],{"class":66},"$PATH",[32,277,278],{"class":49},"\"\n",[280,281,282],"h3",{"id":282},"遇到问题",[284,285,286,313],"ol",{},[287,288,289,292,293,296,299,300,303,305,306,308],"li",{},[29,290,291],{},"go mod tidy"," 时报错 ",[29,294,295],{},"tls: failed to verify certificate: x509: certificate",[297,298],"br",{},"解决：",[29,301,302],{},"RUN apt-get update && apt-get install -y ca-certificates && rm -rf \u002Fvar\u002Flib\u002Fapt\u002Flists\u002F*",[297,304],{},"基础镜像的证书未更新，导致 https 的请求出现类似错误。",[297,307],{},[309,310],"img",{"alt":311,"src":312},"image-20250327100813512","\u002Fposts\u002Fdocker-musl-build\u002Fimg\u002Fimage-20250327100813512.png",[287,314,315,316,319,320,322,323,326],{},"直接安装 ",[29,317,318],{},"golang-go"," 其版本过低",[297,321],{},"解决: ",[29,324,325],{},"PATH=\"\u002Fdata\u002Fgo\u002Fbin:$PATH\""," 手动指定环境",[11,328,329],{"id":329},"最终方案",[284,331,332,339,342],{},[287,333,334,335,338],{},"下载 ",[29,336,337],{},"go1.24.1.linux-amd64.tar.gz"," 解压",[287,340,341],{},"编写 Dockerfile",[287,343,344,345],{},"执行 ",[29,346,347],{},"docker build -t go-build:v1 .",[15,349,350],{},"Dockerfile 内容：",[22,352,356],{"className":353,"code":354,"language":355,"meta":27,"style":27},"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",[29,357,358,366,370,381,385,393,397,407,411,419,423,428,435,439,446,451,458,462,470,474,482,487],{"__ignoreMap":27},[32,359,360,363],{"class":34,"line":35},[32,361,362],{"class":130},"FROM",[32,364,365],{"class":66}," ubuntu:20.04\n",[32,367,368],{"class":34,"line":42},[32,369,89],{"emptyLinePlaceholder":88},[32,371,372,375,378],{"class":34,"line":85},[32,373,374],{"class":130},"LABEL",[32,376,377],{"class":66}," org.opencontainers.image.authors=",[32,379,380],{"class":49},"\"sutong\"\n",[32,382,383],{"class":34,"line":92},[32,384,89],{"emptyLinePlaceholder":88},[32,386,387,390],{"class":34,"line":98},[32,388,389],{"class":130},"COPY",[32,391,392],{"class":66}," go\u002F \u002Fgo\u002F\n",[32,394,395],{"class":34,"line":127},[32,396,89],{"emptyLinePlaceholder":88},[32,398,399,401,404],{"class":34,"line":146},[32,400,269],{"class":130},[32,402,403],{"class":66}," PATH=",[32,405,406],{"class":49},"\"\u002Fgo\u002Fbin:$PATH\"\n",[32,408,409],{"class":34,"line":162},[32,410,89],{"emptyLinePlaceholder":88},[32,412,413,416],{"class":34,"line":168},[32,414,415],{"class":130},"RUN",[32,417,418],{"class":66}," apt-get update && apt-get install -y musl-tools\n",[32,420,421],{"class":34,"line":182},[32,422,89],{"emptyLinePlaceholder":88},[32,424,425],{"class":34,"line":205},[32,426,427],{"class":38},"## go mod tidy 时报错 tls: failed to verify certificate: x509: certificate\n",[32,429,430,432],{"class":34,"line":214},[32,431,415],{"class":130},[32,433,434],{"class":66}," apt-get update && apt-get install -y ca-certificates && rm -rf \u002Fvar\u002Flib\u002Fapt\u002Flists\u002F*\n",[32,436,437],{"class":34,"line":219},[32,438,143],{"class":38},[32,440,441,443],{"class":34,"line":225},[32,442,269],{"class":130},[32,444,445],{"class":66}," CC=musl-gcc\n",[32,447,448],{"class":34,"line":236},[32,449,450],{"class":38},"# 启用 CGO\n",[32,452,453,455],{"class":34,"line":247},[32,454,269],{"class":130},[32,456,457],{"class":66}," CGO_ENABLED=1\n",[32,459,460],{"class":34,"line":252},[32,461,89],{"emptyLinePlaceholder":88},[32,463,464,467],{"class":34,"line":261},[32,465,466],{"class":130},"VOLUME",[32,468,469],{"class":66}," \u002Fdata\n",[32,471,472],{"class":34,"line":266},[32,473,89],{"emptyLinePlaceholder":88},[32,475,477,480],{"class":34,"line":476},20,[32,478,479],{"class":130},"WORKDIR",[32,481,469],{"class":66},[32,483,485],{"class":34,"line":484},21,[32,486,89],{"emptyLinePlaceholder":88},[32,488,490,493,496,499],{"class":34,"line":489},22,[32,491,492],{"class":130},"CMD",[32,494,495],{"class":66}," [",[32,497,498],{"class":49},"\"\u002Fbin\u002Fbash\"",[32,500,501],{"class":66},"]\n",[280,503,504],{"id":504},"构建",[22,506,508],{"className":24,"code":507,"language":26,"meta":27,"style":27},"# 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",[29,509,510,515,519,524,550,554,559,594,599,603,636],{"__ignoreMap":27},[32,511,512],{"class":34,"line":35},[32,513,514],{"class":38},"# docker build -t go-build:v1 .\n",[32,516,517],{"class":34,"line":42},[32,518,89],{"emptyLinePlaceholder":88},[32,520,521],{"class":34,"line":85},[32,522,523],{"class":38},"# 直接执行编译 编译后删除\n",[32,525,526,528,530,532,534,537,540,543,545,547],{"class":34,"line":92},[32,527,46],{"class":45},[32,529,50],{"class":49},[32,531,57],{"class":53},[32,533,60],{"class":53},[32,535,536],{"class":49}," .:\u002Fdata",[32,538,539],{"class":49}," go-build:v1",[32,541,542],{"class":49}," go",[32,544,187],{"class":49},[32,546,196],{"class":53},[32,548,549],{"class":49}," app_musl\n",[32,551,552],{"class":34,"line":98},[32,553,89],{"emptyLinePlaceholder":88},[32,555,556],{"class":34,"line":127},[32,557,558],{"class":38},"# 构建编译环境\n",[32,560,561,563,565,567,570,572,574,577,580,583,586,589,591],{"class":34,"line":146},[32,562,46],{"class":45},[32,564,50],{"class":49},[32,566,54],{"class":53},[32,568,569],{"class":53}," -d",[32,571,60],{"class":53},[32,573,536],{"class":49},[32,575,576],{"class":53}," --name",[32,578,579],{"class":130}," \u003C",[32,581,582],{"class":49},"容器名",[32,584,585],{"class":66},"称",[32,587,588],{"class":130},">",[32,590,539],{"class":49},[32,592,593],{"class":49}," \u002Fbin\u002Fbash\n",[32,595,596],{"class":34,"line":162},[32,597,598],{"class":38},"# eg：docker run -it -d -v .:\u002Fdata --name logbus-build go-build:v1 \u002Fbin\u002Fbash\n",[32,600,601],{"class":34,"line":168},[32,602,89],{"emptyLinePlaceholder":88},[32,604,605,607,610,612,614,616,618,620,622,624,626,628,631,633],{"class":34,"line":182},[32,606,46],{"class":45},[32,608,609],{"class":49}," exec",[32,611,54],{"class":53},[32,613,579],{"class":130},[32,615,582],{"class":49},[32,617,585],{"class":66},[32,619,588],{"class":130},[32,621,542],{"class":49},[32,623,187],{"class":49},[32,625,196],{"class":53},[32,627,579],{"class":130},[32,629,630],{"class":49},"myap",[32,632,15],{"class":66},[32,634,635],{"class":130},">\n",[32,637,638],{"class":34,"line":205},[32,639,640],{"class":38},"# eg：docker exec -it logbus-build go build -o logbus_musl_1\n",[280,642,643],{"id":643},"额外注意",[15,645,646],{},"若没科学上网需要设置代理：",[22,648,650],{"className":24,"code":649,"language":26,"meta":27,"style":27},"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",[29,651,652,670,680,696],{"__ignoreMap":27},[32,653,654,656,658,660,662,664,666,668],{"class":34,"line":35},[32,655,46],{"class":45},[32,657,609],{"class":49},[32,659,54],{"class":53},[32,661,579],{"class":130},[32,663,582],{"class":49},[32,665,585],{"class":66},[32,667,588],{"class":130},[32,669,593],{"class":49},[32,671,672,674,676,678],{"class":34,"line":42},[32,673,171],{"class":45},[32,675,174],{"class":49},[32,677,73],{"class":53},[32,679,179],{"class":49},[32,681,682,684,686,688,690,692,694],{"class":34,"line":85},[32,683,171],{"class":45},[32,685,187],{"class":49},[32,687,190],{"class":53},[32,689,193],{"class":49},[32,691,196],{"class":53},[32,693,199],{"class":49},[32,695,202],{"class":38},[32,697,698,700,702],{"class":34,"line":92},[32,699,171],{"class":45},[32,701,187],{"class":49},[32,703,704],{"class":38}," # musl 库编译\n",[280,706,707],{"id":707},"运行环境示例",[22,709,711],{"className":353,"code":710,"language":355,"meta":27,"style":27},"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",[29,712,713,720,724,732,736,743,747,754,761,765,772,776,783,787,809,813,818],{"__ignoreMap":27},[32,714,715,717],{"class":34,"line":35},[32,716,362],{"class":130},[32,718,719],{"class":66}," alpine:latest\n",[32,721,722],{"class":34,"line":42},[32,723,89],{"emptyLinePlaceholder":88},[32,725,726,728,730],{"class":34,"line":85},[32,727,374],{"class":130},[32,729,377],{"class":66},[32,731,380],{"class":49},[32,733,734],{"class":34,"line":92},[32,735,89],{"emptyLinePlaceholder":88},[32,737,738,740],{"class":34,"line":98},[32,739,389],{"class":130},[32,741,742],{"class":66}," glog_musl \u002Fapp\u002F\n",[32,744,745],{"class":34,"line":127},[32,746,89],{"emptyLinePlaceholder":88},[32,748,749,751],{"class":34,"line":146},[32,750,389],{"class":130},[32,752,753],{"class":66}," conf.ini \u002Fdata\u002F\n",[32,755,756,758],{"class":34,"line":162},[32,757,389],{"class":130},[32,759,760],{"class":66}," ipname.ini \u002Fdata\u002F\n",[32,762,763],{"class":34,"line":168},[32,764,89],{"emptyLinePlaceholder":88},[32,766,767,769],{"class":34,"line":182},[32,768,479],{"class":130},[32,770,771],{"class":66}," \u002Fapp\n",[32,773,774],{"class":34,"line":205},[32,775,89],{"emptyLinePlaceholder":88},[32,777,778,780],{"class":34,"line":214},[32,779,415],{"class":130},[32,781,782],{"class":66}," chmod 777 \u002Fapp\u002Fglog_musl\n",[32,784,785],{"class":34,"line":219},[32,786,89],{"emptyLinePlaceholder":88},[32,788,789,791,793,796,799,802,804,807],{"class":34,"line":225},[32,790,492],{"class":130},[32,792,495],{"class":66},[32,794,795],{"class":49},"\".\u002Fglog_musl\"",[32,797,798],{"class":66},", ",[32,800,801],{"class":49},"\"logsweb\"",[32,803,798],{"class":66},[32,805,806],{"class":49},"\"\u002Fdata\u002Fconf.ini\"",[32,808,501],{"class":66},[32,810,811],{"class":34,"line":236},[32,812,89],{"emptyLinePlaceholder":88},[32,814,815],{"class":34,"line":247},[32,816,817],{"class":38},"# docker build -t glog:v1 .\n",[32,819,820],{"class":34,"line":252},[32,821,822],{"class":38},"# docker run -p 6801:6801 glog:v1\n",[824,825,826],"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":27,"searchDepth":42,"depth":85,"links":828},[829,830,833],{"id":13,"depth":42,"text":13},{"id":20,"depth":42,"text":20,"children":831},[832],{"id":282,"depth":85,"text":282},{"id":329,"depth":42,"text":329,"children":834},[835,836,837],{"id":504,"depth":85,"text":504},{"id":643,"depth":85,"text":643},{"id":707,"depth":85,"text":707},"解决 Go 语言跨平台编译问题，使用 Docker + musl 编译静态链接的可执行文件。","进阶",false,"md","\u002Fposts\u002Fdocker-musl-build\u002Fimg\u002Fcover.svg",{},"\u002Fposts\u002Fdocker-musl-build","2025-03-27",{"title":5,"description":838},"Docker 实践笔记","posts\u002Fdocker-musl-build\u002Findex",[850,851,852],"Docker","Go","运维","YLtdOOc-gMVDSNjE-ntKXz-7hff-VtvMB2dB5RXNi1o",[855,1498,1919,2150,2568,3759,4038,6107,6889,7868,9277,9837,11016,12552],{"id":4,"title":5,"author":6,"body":856,"description":838,"difficulty":839,"draft":840,"extension":841,"image":842,"meta":1495,"navigation":88,"path":844,"pinned":840,"published":845,"seo":1496,"series":847,"seriesOrder":35,"stem":848,"tags":1497,"updated":845,"__hash__":853},{"type":8,"value":857,"toc":1484},[858,860,862,864,1034,1036,1062,1064,1076,1078,1200,1202,1318,1320,1322,1378,1380,1482],[11,859,13],{"id":13},[15,861,17],{},[11,863,20],{"id":20},[22,865,866],{"className":24,"code":25,"language":26,"meta":27,"style":27},[29,867,868,872,898,902,906,926,938,950,954,964,980,986,990,994,1002,1010,1014,1020,1024],{"__ignoreMap":27},[32,869,870],{"class":34,"line":35},[32,871,39],{"class":38},[32,873,874,876,878,880,882,884,886,888,890,892,894,896],{"class":34,"line":42},[32,875,46],{"class":45},[32,877,50],{"class":49},[32,879,54],{"class":53},[32,881,57],{"class":53},[32,883,60],{"class":53},[32,885,63],{"class":49},[32,887,67],{"class":66},[32,889,70],{"class":49},[32,891,73],{"class":53},[32,893,76],{"class":49},[32,895,79],{"class":49},[32,897,82],{"class":49},[32,899,900],{"class":34,"line":85},[32,901,89],{"emptyLinePlaceholder":88},[32,903,904],{"class":34,"line":92},[32,905,95],{"class":38},[32,907,908,910,912,914,916,918,920,922,924],{"class":34,"line":98},[32,909,101],{"class":45},[32,911,104],{"class":49},[32,913,107],{"class":66},[32,915,101],{"class":45},[32,917,112],{"class":49},[32,919,115],{"class":53},[32,921,118],{"class":49},[32,923,121],{"class":49},[32,925,124],{"class":38},[32,927,928,930,932,934,936],{"class":34,"line":127},[32,929,131],{"class":130},[32,931,134],{"class":66},[32,933,137],{"class":130},[32,935,140],{"class":66},[32,937,143],{"class":38},[32,939,940,942,944,946,948],{"class":34,"line":146},[32,941,131],{"class":130},[32,943,151],{"class":66},[32,945,137],{"class":130},[32,947,156],{"class":53},[32,949,159],{"class":38},[32,951,952],{"class":34,"line":162},[32,953,165],{"class":38},[32,955,956,958,960,962],{"class":34,"line":168},[32,957,171],{"class":45},[32,959,174],{"class":49},[32,961,73],{"class":53},[32,963,179],{"class":49},[32,965,966,968,970,972,974,976,978],{"class":34,"line":182},[32,967,171],{"class":45},[32,969,187],{"class":49},[32,971,190],{"class":53},[32,973,193],{"class":49},[32,975,196],{"class":53},[32,977,199],{"class":49},[32,979,202],{"class":38},[32,981,982,984],{"class":34,"line":205},[32,983,208],{"class":53},[32,985,211],{"class":38},[32,987,988],{"class":34,"line":214},[32,989,89],{"emptyLinePlaceholder":88},[32,991,992],{"class":34,"line":219},[32,993,222],{"class":38},[32,995,996,998,1000],{"class":34,"line":225},[32,997,228],{"class":45},[32,999,199],{"class":49},[32,1001,233],{"class":38},[32,1003,1004,1006,1008],{"class":34,"line":236},[32,1005,239],{"class":45},[32,1007,199],{"class":49},[32,1009,244],{"class":38},[32,1011,1012],{"class":34,"line":247},[32,1013,89],{"emptyLinePlaceholder":88},[32,1015,1016,1018],{"class":34,"line":252},[32,1017,255],{"class":45},[32,1019,258],{"class":49},[32,1021,1022],{"class":34,"line":261},[32,1023,89],{"emptyLinePlaceholder":88},[32,1025,1026,1028,1030,1032],{"class":34,"line":266},[32,1027,269],{"class":45},[32,1029,272],{"class":49},[32,1031,275],{"class":66},[32,1033,278],{"class":49},[280,1035,282],{"id":282},[284,1037,1038,1054],{},[287,1039,1040,292,1042,1044,299,1046,1048,305,1050,1052],{},[29,1041,291],{},[29,1043,295],{},[297,1045],{},[29,1047,302],{},[297,1049],{},[297,1051],{},[309,1053],{"alt":311,"src":312},[287,1055,315,1056,319,1058,322,1060,326],{},[29,1057,318],{},[297,1059],{},[29,1061,325],{},[11,1063,329],{"id":329},[284,1065,1066,1070,1072],{},[287,1067,334,1068,338],{},[29,1069,337],{},[287,1071,341],{},[287,1073,344,1074],{},[29,1075,347],{},[15,1077,350],{},[22,1079,1080],{"className":353,"code":354,"language":355,"meta":27,"style":27},[29,1081,1082,1088,1092,1100,1104,1110,1114,1122,1126,1132,1136,1140,1146,1150,1156,1160,1166,1170,1176,1180,1186,1190],{"__ignoreMap":27},[32,1083,1084,1086],{"class":34,"line":35},[32,1085,362],{"class":130},[32,1087,365],{"class":66},[32,1089,1090],{"class":34,"line":42},[32,1091,89],{"emptyLinePlaceholder":88},[32,1093,1094,1096,1098],{"class":34,"line":85},[32,1095,374],{"class":130},[32,1097,377],{"class":66},[32,1099,380],{"class":49},[32,1101,1102],{"class":34,"line":92},[32,1103,89],{"emptyLinePlaceholder":88},[32,1105,1106,1108],{"class":34,"line":98},[32,1107,389],{"class":130},[32,1109,392],{"class":66},[32,1111,1112],{"class":34,"line":127},[32,1113,89],{"emptyLinePlaceholder":88},[32,1115,1116,1118,1120],{"class":34,"line":146},[32,1117,269],{"class":130},[32,1119,403],{"class":66},[32,1121,406],{"class":49},[32,1123,1124],{"class":34,"line":162},[32,1125,89],{"emptyLinePlaceholder":88},[32,1127,1128,1130],{"class":34,"line":168},[32,1129,415],{"class":130},[32,1131,418],{"class":66},[32,1133,1134],{"class":34,"line":182},[32,1135,89],{"emptyLinePlaceholder":88},[32,1137,1138],{"class":34,"line":205},[32,1139,427],{"class":38},[32,1141,1142,1144],{"class":34,"line":214},[32,1143,415],{"class":130},[32,1145,434],{"class":66},[32,1147,1148],{"class":34,"line":219},[32,1149,143],{"class":38},[32,1151,1152,1154],{"class":34,"line":225},[32,1153,269],{"class":130},[32,1155,445],{"class":66},[32,1157,1158],{"class":34,"line":236},[32,1159,450],{"class":38},[32,1161,1162,1164],{"class":34,"line":247},[32,1163,269],{"class":130},[32,1165,457],{"class":66},[32,1167,1168],{"class":34,"line":252},[32,1169,89],{"emptyLinePlaceholder":88},[32,1171,1172,1174],{"class":34,"line":261},[32,1173,466],{"class":130},[32,1175,469],{"class":66},[32,1177,1178],{"class":34,"line":266},[32,1179,89],{"emptyLinePlaceholder":88},[32,1181,1182,1184],{"class":34,"line":476},[32,1183,479],{"class":130},[32,1185,469],{"class":66},[32,1187,1188],{"class":34,"line":484},[32,1189,89],{"emptyLinePlaceholder":88},[32,1191,1192,1194,1196,1198],{"class":34,"line":489},[32,1193,492],{"class":130},[32,1195,495],{"class":66},[32,1197,498],{"class":49},[32,1199,501],{"class":66},[280,1201,504],{"id":504},[22,1203,1204],{"className":24,"code":507,"language":26,"meta":27,"style":27},[29,1205,1206,1210,1214,1218,1240,1244,1248,1276,1280,1284,1314],{"__ignoreMap":27},[32,1207,1208],{"class":34,"line":35},[32,1209,514],{"class":38},[32,1211,1212],{"class":34,"line":42},[32,1213,89],{"emptyLinePlaceholder":88},[32,1215,1216],{"class":34,"line":85},[32,1217,523],{"class":38},[32,1219,1220,1222,1224,1226,1228,1230,1232,1234,1236,1238],{"class":34,"line":92},[32,1221,46],{"class":45},[32,1223,50],{"class":49},[32,1225,57],{"class":53},[32,1227,60],{"class":53},[32,1229,536],{"class":49},[32,1231,539],{"class":49},[32,1233,542],{"class":49},[32,1235,187],{"class":49},[32,1237,196],{"class":53},[32,1239,549],{"class":49},[32,1241,1242],{"class":34,"line":98},[32,1243,89],{"emptyLinePlaceholder":88},[32,1245,1246],{"class":34,"line":127},[32,1247,558],{"class":38},[32,1249,1250,1252,1254,1256,1258,1260,1262,1264,1266,1268,1270,1272,1274],{"class":34,"line":146},[32,1251,46],{"class":45},[32,1253,50],{"class":49},[32,1255,54],{"class":53},[32,1257,569],{"class":53},[32,1259,60],{"class":53},[32,1261,536],{"class":49},[32,1263,576],{"class":53},[32,1265,579],{"class":130},[32,1267,582],{"class":49},[32,1269,585],{"class":66},[32,1271,588],{"class":130},[32,1273,539],{"class":49},[32,1275,593],{"class":49},[32,1277,1278],{"class":34,"line":162},[32,1279,598],{"class":38},[32,1281,1282],{"class":34,"line":168},[32,1283,89],{"emptyLinePlaceholder":88},[32,1285,1286,1288,1290,1292,1294,1296,1298,1300,1302,1304,1306,1308,1310,1312],{"class":34,"line":182},[32,1287,46],{"class":45},[32,1289,609],{"class":49},[32,1291,54],{"class":53},[32,1293,579],{"class":130},[32,1295,582],{"class":49},[32,1297,585],{"class":66},[32,1299,588],{"class":130},[32,1301,542],{"class":49},[32,1303,187],{"class":49},[32,1305,196],{"class":53},[32,1307,579],{"class":130},[32,1309,630],{"class":49},[32,1311,15],{"class":66},[32,1313,635],{"class":130},[32,1315,1316],{"class":34,"line":205},[32,1317,640],{"class":38},[280,1319,643],{"id":643},[15,1321,646],{},[22,1323,1324],{"className":24,"code":649,"language":26,"meta":27,"style":27},[29,1325,1326,1344,1354,1370],{"__ignoreMap":27},[32,1327,1328,1330,1332,1334,1336,1338,1340,1342],{"class":34,"line":35},[32,1329,46],{"class":45},[32,1331,609],{"class":49},[32,1333,54],{"class":53},[32,1335,579],{"class":130},[32,1337,582],{"class":49},[32,1339,585],{"class":66},[32,1341,588],{"class":130},[32,1343,593],{"class":49},[32,1345,1346,1348,1350,1352],{"class":34,"line":42},[32,1347,171],{"class":45},[32,1349,174],{"class":49},[32,1351,73],{"class":53},[32,1353,179],{"class":49},[32,1355,1356,1358,1360,1362,1364,1366,1368],{"class":34,"line":85},[32,1357,171],{"class":45},[32,1359,187],{"class":49},[32,1361,190],{"class":53},[32,1363,193],{"class":49},[32,1365,196],{"class":53},[32,1367,199],{"class":49},[32,1369,202],{"class":38},[32,1371,1372,1374,1376],{"class":34,"line":92},[32,1373,171],{"class":45},[32,1375,187],{"class":49},[32,1377,704],{"class":38},[280,1379,707],{"id":707},[22,1381,1382],{"className":353,"code":710,"language":355,"meta":27,"style":27},[29,1383,1384,1390,1394,1402,1406,1412,1416,1422,1428,1432,1438,1442,1448,1452,1470,1474,1478],{"__ignoreMap":27},[32,1385,1386,1388],{"class":34,"line":35},[32,1387,362],{"class":130},[32,1389,719],{"class":66},[32,1391,1392],{"class":34,"line":42},[32,1393,89],{"emptyLinePlaceholder":88},[32,1395,1396,1398,1400],{"class":34,"line":85},[32,1397,374],{"class":130},[32,1399,377],{"class":66},[32,1401,380],{"class":49},[32,1403,1404],{"class":34,"line":92},[32,1405,89],{"emptyLinePlaceholder":88},[32,1407,1408,1410],{"class":34,"line":98},[32,1409,389],{"class":130},[32,1411,742],{"class":66},[32,1413,1414],{"class":34,"line":127},[32,1415,89],{"emptyLinePlaceholder":88},[32,1417,1418,1420],{"class":34,"line":146},[32,1419,389],{"class":130},[32,1421,753],{"class":66},[32,1423,1424,1426],{"class":34,"line":162},[32,1425,389],{"class":130},[32,1427,760],{"class":66},[32,1429,1430],{"class":34,"line":168},[32,1431,89],{"emptyLinePlaceholder":88},[32,1433,1434,1436],{"class":34,"line":182},[32,1435,479],{"class":130},[32,1437,771],{"class":66},[32,1439,1440],{"class":34,"line":205},[32,1441,89],{"emptyLinePlaceholder":88},[32,1443,1444,1446],{"class":34,"line":214},[32,1445,415],{"class":130},[32,1447,782],{"class":66},[32,1449,1450],{"class":34,"line":219},[32,1451,89],{"emptyLinePlaceholder":88},[32,1453,1454,1456,1458,1460,1462,1464,1466,1468],{"class":34,"line":225},[32,1455,492],{"class":130},[32,1457,495],{"class":66},[32,1459,795],{"class":49},[32,1461,798],{"class":66},[32,1463,801],{"class":49},[32,1465,798],{"class":66},[32,1467,806],{"class":49},[32,1469,501],{"class":66},[32,1471,1472],{"class":34,"line":236},[32,1473,89],{"emptyLinePlaceholder":88},[32,1475,1476],{"class":34,"line":247},[32,1477,817],{"class":38},[32,1479,1480],{"class":34,"line":252},[32,1481,822],{"class":38},[824,1483,826],{},{"title":27,"searchDepth":42,"depth":85,"links":1485},[1486,1487,1490],{"id":13,"depth":42,"text":13},{"id":20,"depth":42,"text":20,"children":1488},[1489],{"id":282,"depth":85,"text":282},{"id":329,"depth":42,"text":329,"children":1491},[1492,1493,1494],{"id":504,"depth":85,"text":504},{"id":643,"depth":85,"text":643},{"id":707,"depth":85,"text":707},{},{"title":5,"description":838},[850,851,852],{"id":1499,"title":1500,"author":6,"body":1501,"description":1908,"difficulty":1909,"draft":840,"extension":841,"image":1910,"meta":1911,"navigation":88,"path":1912,"pinned":840,"published":1913,"seo":1914,"series":847,"seriesOrder":42,"stem":1915,"tags":1916,"updated":1913,"__hash__":1918},"posts\u002Fposts\u002Fdocker-user-start\u002Findex.md","Docker 构建时指定用户启动",{"type":8,"value":1502,"toc":1898},[1503,1506,1509,1512,1516,1519,1536,1540,1573,1577,1584,1634,1637,1856,1859,1862,1892,1895],[11,1504,1505],{"id":1505},"前言",[15,1507,1508],{},"我们在 Linux 使用应用时，往往不会直接使用 root 用户作为应用启动项，但 Dockerfile 默认就是 root 用户，导致挂载生成的文件路径往往是 root 权限，非 root 用户访问就很不方便。",[11,1510,1511],{"id":1511},"解决方案",[280,1513,1515],{"id":1514},"_1-安装用户管理工具","1. 安装用户管理工具",[15,1517,1518],{},"若镜像非自带用户管理：",[22,1520,1522],{"className":353,"code":1521,"language":355,"meta":27,"style":27},"# 权限管理\nRUN apk add --no-cache shadow\n",[29,1523,1524,1529],{"__ignoreMap":27},[32,1525,1526],{"class":34,"line":35},[32,1527,1528],{"class":38},"# 权限管理\n",[32,1530,1531,1533],{"class":34,"line":42},[32,1532,415],{"class":130},[32,1534,1535],{"class":66}," apk add --no-cache shadow\n",[280,1537,1539],{"id":1538},"_2-创建用户并修改权限","2. 创建用户并修改权限",[22,1541,1543],{"className":353,"code":1542,"language":355,"meta":27,"style":27},"# 创建用户组和用户\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",[29,1544,1545,1550,1557,1561,1566],{"__ignoreMap":27},[32,1546,1547],{"class":34,"line":35},[32,1548,1549],{"class":38},"# 创建用户组和用户\n",[32,1551,1552,1554],{"class":34,"line":42},[32,1553,415],{"class":130},[32,1555,1556],{"class":66}," addgroup -S \u003Capp_user_group> && adduser -S -G \u003Capp_user_group> \u003Capp_user>\n",[32,1558,1559],{"class":34,"line":85},[32,1560,89],{"emptyLinePlaceholder":88},[32,1562,1563],{"class":34,"line":92},[32,1564,1565],{"class":38},"# 修改目录权限\n",[32,1567,1568,1570],{"class":34,"line":98},[32,1569,415],{"class":130},[32,1571,1572],{"class":66}," chown -R \u003Capp_user_group>:\u003Capp_user> \u002Fdata\n",[280,1574,1576],{"id":1575},"_3-指定用户启动","3. 指定用户启动",[15,1578,1579,1580,1583],{},"执行 docker 时加 ",[29,1581,1582],{},"--user"," 参数：",[22,1585,1587],{"className":24,"code":1586,"language":26,"meta":27,"style":27},"docker run -it --user \u003Capp_user> \u003Capp_image>:\u003Cimage_version>\n",[29,1588,1589],{"__ignoreMap":27},[32,1590,1591,1593,1595,1597,1600,1602,1605,1608,1610,1612,1615,1618,1620,1623,1626,1629,1632],{"class":34,"line":35},[32,1592,46],{"class":45},[32,1594,50],{"class":49},[32,1596,54],{"class":53},[32,1598,1599],{"class":53}," --user",[32,1601,579],{"class":130},[32,1603,1604],{"class":49},"app_use",[32,1606,1607],{"class":66},"r",[32,1609,588],{"class":130},[32,1611,579],{"class":130},[32,1613,1614],{"class":49},"app_imag",[32,1616,1617],{"class":66},"e",[32,1619,588],{"class":130},[32,1621,1622],{"class":49},":",[32,1624,1625],{"class":130},"\u003C",[32,1627,1628],{"class":49},"image_versio",[32,1630,1631],{"class":66},"n",[32,1633,635],{"class":130},[11,1635,1636],{"id":1636},"完整示例",[22,1638,1640],{"className":353,"code":1639,"language":355,"meta":27,"style":27},"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",[29,1641,1642,1648,1652,1657,1670,1674,1681,1685,1691,1696,1703,1708,1715,1719,1726,1730,1734,1741,1745,1752,1756,1763,1767,1781,1788,1793,1799,1807,1815,1820,1826,1835],{"__ignoreMap":27},[32,1643,1644,1646],{"class":34,"line":35},[32,1645,362],{"class":130},[32,1647,719],{"class":66},[32,1649,1650],{"class":34,"line":42},[32,1651,89],{"emptyLinePlaceholder":88},[32,1653,1654],{"class":34,"line":85},[32,1655,1656],{"class":38},"# 国内源\n",[32,1658,1659,1661,1664,1667],{"class":34,"line":92},[32,1660,415],{"class":130},[32,1662,1663],{"class":66}," sed -i ",[32,1665,1666],{"class":49},"'s\u002Fdl-cdn.alpinelinux.org\u002Fmirrors.ustc.edu.cn\u002Fg'",[32,1668,1669],{"class":66}," \u002Fetc\u002Fapk\u002Frepositories\n",[32,1671,1672],{"class":34,"line":98},[32,1673,89],{"emptyLinePlaceholder":88},[32,1675,1676,1678],{"class":34,"line":127},[32,1677,415],{"class":130},[32,1679,1680],{"class":66}," apk update --no-cache\n",[32,1682,1683],{"class":34,"line":146},[32,1684,1528],{"class":38},[32,1686,1687,1689],{"class":34,"line":162},[32,1688,415],{"class":130},[32,1690,1535],{"class":66},[32,1692,1693],{"class":34,"line":168},[32,1694,1695],{"class":38},"# 调试\n",[32,1697,1698,1700],{"class":34,"line":182},[32,1699,415],{"class":130},[32,1701,1702],{"class":66}," apk add --no-cache bash\n",[32,1704,1705],{"class":34,"line":205},[32,1706,1707],{"class":38},"# 设置时区\n",[32,1709,1710,1712],{"class":34,"line":214},[32,1711,415],{"class":130},[32,1713,1714],{"class":66}," apk add --no-cache tzdata\n",[32,1716,1717],{"class":34,"line":219},[32,1718,89],{"emptyLinePlaceholder":88},[32,1720,1721,1723],{"class":34,"line":225},[32,1722,269],{"class":130},[32,1724,1725],{"class":66}," TZ=Asia\u002FShanghai\n",[32,1727,1728],{"class":34,"line":236},[32,1729,89],{"emptyLinePlaceholder":88},[32,1731,1732],{"class":34,"line":247},[32,1733,1549],{"class":38},[32,1735,1736,1738],{"class":34,"line":252},[32,1737,415],{"class":130},[32,1739,1740],{"class":66}," addgroup -S glog && adduser -S -G glog glog\n",[32,1742,1743],{"class":34,"line":261},[32,1744,89],{"emptyLinePlaceholder":88},[32,1746,1747,1749],{"class":34,"line":266},[32,1748,389],{"class":130},[32,1750,1751],{"class":66}," .\u002Fglog_static_musl_1 \u002Fapp\u002F\n",[32,1753,1754],{"class":34,"line":476},[32,1755,89],{"emptyLinePlaceholder":88},[32,1757,1758,1760],{"class":34,"line":484},[32,1759,415],{"class":130},[32,1761,1762],{"class":66}," mkdir \u002Fdata\n",[32,1764,1765],{"class":34,"line":489},[32,1766,89],{"emptyLinePlaceholder":88},[32,1768,1770,1772,1775,1778],{"class":34,"line":1769},23,[32,1771,466],{"class":130},[32,1773,1774],{"class":66}," [ ",[32,1776,1777],{"class":49},"\"\u002Fdata\"",[32,1779,1780],{"class":66}," ]\n",[32,1782,1784,1786],{"class":34,"line":1783},24,[32,1785,479],{"class":130},[32,1787,469],{"class":66},[32,1789,1791],{"class":34,"line":1790},25,[32,1792,89],{"emptyLinePlaceholder":88},[32,1794,1796],{"class":34,"line":1795},26,[32,1797,1798],{"class":38},"# 修改权限\n",[32,1800,1802,1804],{"class":34,"line":1801},27,[32,1803,415],{"class":130},[32,1805,1806],{"class":66}," chown -R glog:glog \u002Fdata\n",[32,1808,1810,1812],{"class":34,"line":1809},28,[32,1811,415],{"class":130},[32,1813,1814],{"class":66}," chown -R glog:glog \u002Fapp\n",[32,1816,1818],{"class":34,"line":1817},29,[32,1819,89],{"emptyLinePlaceholder":88},[32,1821,1823],{"class":34,"line":1822},30,[32,1824,1825],{"class":38},"# 指定用户启动\n",[32,1827,1829,1832],{"class":34,"line":1828},31,[32,1830,1831],{"class":130},"USER",[32,1833,1834],{"class":66}," glog\n",[32,1836,1838,1840,1842,1845,1847,1849,1851,1854],{"class":34,"line":1837},32,[32,1839,492],{"class":130},[32,1841,1774],{"class":66},[32,1843,1844],{"class":49},"\"\u002Fapp\u002Fglog_static_musl_1\"",[32,1846,798],{"class":66},[32,1848,801],{"class":49},[32,1850,798],{"class":66},[32,1852,1853],{"class":49},"\".\u002Fconf.ini\"",[32,1855,1780],{"class":66},[11,1857,1858],{"id":1858},"总结",[15,1860,1861],{},"关键步骤：",[284,1863,1864,1870,1880,1886],{},[287,1865,1866,1869],{},[29,1867,1868],{},"apk add --no-cache shadow"," - 安装用户管理工具",[287,1871,1872,1875,1876,1879],{},[29,1873,1874],{},"addgroup"," + ",[29,1877,1878],{},"adduser"," - 创建用户",[287,1881,1882,1885],{},[29,1883,1884],{},"chown -R"," - 修改目录权限",[287,1887,1888,1891],{},[29,1889,1890],{},"USER \u003Cusername>"," - 指定运行用户",[15,1893,1894],{},"这样容器内的应用就不再以 root 身份运行，更加安全。",[824,1896,1897],{},"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":27,"searchDepth":42,"depth":85,"links":1899},[1900,1901,1906,1907],{"id":1505,"depth":42,"text":1505},{"id":1511,"depth":42,"text":1511,"children":1902},[1903,1904,1905],{"id":1514,"depth":85,"text":1515},{"id":1538,"depth":85,"text":1539},{"id":1575,"depth":85,"text":1576},{"id":1636,"depth":42,"text":1636},{"id":1858,"depth":42,"text":1858},"解决 Docker 容器默认以 root 用户运行导致的权限问题，指定非 root 用户启动应用。","入门","\u002Fposts\u002Fdocker-user-start\u002Fimg\u002Fcover.svg",{},"\u002Fposts\u002Fdocker-user-start","2025-04-01",{"title":1500,"description":1908},"posts\u002Fdocker-user-start\u002Findex",[850,852,1917],"Linux","b4Uyw5ms87xr7NhpIkavHnNbT20oaEPtXz8k1w6UPj0",{"id":1920,"title":1921,"author":6,"body":1922,"description":2140,"difficulty":6,"draft":840,"extension":841,"image":2141,"meta":2142,"navigation":88,"path":2143,"pinned":840,"published":2144,"seo":2145,"series":6,"seriesOrder":6,"stem":2146,"tags":2147,"updated":6,"__hash__":2149},"posts\u002Fposts\u002Fsveltekit-intro\u002Findex.md","SvelteKit 入门：为什么它比你想象的更简单",{"type":8,"value":1923,"toc":2130},[1924,1928,1931,1937,1940,1944,1952,2004,2008,2014,2022,2026,2029,2032,2108,2110,2121,2127],[11,1925,1927],{"id":1926},"什么是-sveltekit","什么是 SvelteKit？",[15,1929,1930],{},"SvelteKit 是 Svelte 的官方全栈框架，类似于 React 之于 Next.js 的关系。它提供了路由、SSR、SSG 等能力，让开发者可以快速构建现代 Web 应用。",[15,1932,1933],{},[309,1934],{"alt":1935,"src":1936},"SvelteKit Logo","https:\u002F\u002Fraw.githubusercontent.com\u002Fsveltejs\u002Fbranding\u002Fmaster\u002Fsvelte-logo.svg",[11,1938,1939],{"id":1939},"核心特点",[280,1941,1943],{"id":1942},"_1-编译时框架","1. 编译时框架",[15,1945,1946,1947,1951],{},"Svelte 和 React\u002FVue 最大的区别在于：",[1948,1949,1950],"strong",{},"它在编译阶段就把组件转成原生 JS","，运行时没有虚拟 DOM。",[22,1953,1957],{"className":1954,"code":1955,"language":1956,"meta":27,"style":27},"language-typescript shiki shiki-themes github-light github-dark","\u002F\u002F Svelte 组件（编译后变成直接的 DOM 操作）\nlet count = 0\nfunction increment() {\n  count += 1\n}\n","typescript",[29,1958,1959,1964,1977,1988,1999],{"__ignoreMap":27},[32,1960,1961],{"class":34,"line":35},[32,1962,1963],{"class":38},"\u002F\u002F Svelte 组件（编译后变成直接的 DOM 操作）\n",[32,1965,1966,1969,1972,1974],{"class":34,"line":42},[32,1967,1968],{"class":130},"let",[32,1970,1971],{"class":66}," count ",[32,1973,137],{"class":130},[32,1975,1976],{"class":53}," 0\n",[32,1978,1979,1982,1985],{"class":34,"line":85},[32,1980,1981],{"class":130},"function",[32,1983,1984],{"class":45}," increment",[32,1986,1987],{"class":66},"() {\n",[32,1989,1990,1993,1996],{"class":34,"line":92},[32,1991,1992],{"class":66},"  count ",[32,1994,1995],{"class":130},"+=",[32,1997,1998],{"class":53}," 1\n",[32,2000,2001],{"class":34,"line":98},[32,2002,2003],{"class":66},"}\n",[280,2005,2007],{"id":2006},"_2-文件路由","2. 文件路由",[15,2009,2010,2013],{},[29,2011,2012],{},"src\u002Froutes\u002F"," 目录结构即 URL，不需要手动配置路由表：",[22,2015,2020],{"className":2016,"code":2018,"language":2019},[2017],"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",[29,2021,2018],{"__ignoreMap":27},[280,2023,2025],{"id":2024},"_3-全栈能力","3. 全栈能力",[15,2027,2028],{},"SvelteKit 内置了服务端渲染（SSR）、静态站点生成（SSG）、API 路由等功能，开箱即用。",[11,2030,2031],{"id":2031},"和其他框架对比",[2033,2034,2035,2054],"table",{},[2036,2037,2038],"thead",{},[2039,2040,2041,2045,2048,2051],"tr",{},[2042,2043,2044],"th",{},"特性",[2042,2046,2047],{},"SvelteKit",[2042,2049,2050],{},"Next.js",[2042,2052,2053],{},"Nuxt",[2055,2056,2057,2071,2084,2097],"tbody",{},[2039,2058,2059,2063,2066,2069],{},[2060,2061,2062],"td",{},"学习曲线",[2060,2064,2065],{},"低",[2060,2067,2068],{},"中",[2060,2070,2068],{},[2039,2072,2073,2076,2079,2082],{},[2060,2074,2075],{},"包体积",[2060,2077,2078],{},"小",[2060,2080,2081],{},"大",[2060,2083,2068],{},[2039,2085,2086,2089,2092,2095],{},[2060,2087,2088],{},"运行时开销",[2060,2090,2091],{},"无",[2060,2093,2094],{},"有",[2060,2096,2094],{},[2039,2098,2099,2102,2104,2106],{},[2060,2100,2101],{},"生态",[2060,2103,2078],{},[2060,2105,2081],{},[2060,2107,2081],{},[11,2109,1858],{"id":1858},[15,2111,2112,2113,2116,2117,2120],{},"如果你追求",[1948,2114,2115],{},"简洁的代码","和",[1948,2118,2119],{},"极致的性能","，SvelteKit 是一个非常值得尝试的选择。它的学习曲线平缓，写起来就像在写原生 HTML\u002FCSS\u002FJS。",[15,2122,2123],{},[309,2124],{"alt":2125,"src":2126},"Svelte 编译过程","https:\u002F\u002Fsvelte.dev\u002Fsvelte-logo-horizontal.svg",[824,2128,2129],{},"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":27,"searchDepth":42,"depth":85,"links":2131},[2132,2133,2138,2139],{"id":1926,"depth":42,"text":1927},{"id":1939,"depth":42,"text":1939,"children":2134},[2135,2136,2137],{"id":1942,"depth":85,"text":1943},{"id":2006,"depth":85,"text":2007},{"id":2024,"depth":85,"text":2025},{"id":2031,"depth":42,"text":2031},{"id":1858,"depth":42,"text":1858},"SvelteKit 是一个全栈 Svelte 框架，这篇文章带你了解它的核心概念和优势。","\u002Fposts\u002Fsveltekit-intro\u002Fimg\u002Fcover.jpg",{},"\u002Fposts\u002Fsveltekit-intro","2026-05-30",{"title":1921,"description":2140},"posts\u002Fsveltekit-intro\u002Findex",[2047,2148],"前端框架","vsEGyunpCWR7DiJAdyV1SPDfa57xXLITBI-lKM8UeGQ",{"id":2151,"title":2152,"author":6,"body":2153,"description":2556,"difficulty":1909,"draft":840,"extension":841,"image":2557,"meta":2558,"navigation":88,"path":2559,"pinned":88,"published":2560,"seo":2561,"series":2562,"seriesOrder":35,"stem":2563,"tags":2564,"updated":2566,"__hash__":2567},"posts\u002Fposts\u002Fhello-world\u002Findex.md","Hello World - 欢迎使用 SuBlog",{"type":8,"value":2154,"toc":2547},[2155,2159,2166,2169,2172,2175,2249,2252,2255,2318,2321,2324,2390,2393,2459,2462,2465,2481,2488,2491,2522,2525,2536,2539,2544],[11,2156,2158],{"id":2157},"博客上线啦","博客上线啦！",[15,2160,2161,2162,2165],{},"Hello World！欢迎使用 ",[1948,2163,2164],{},"SuBlog","，这是站点的第一篇示例文章。",[15,2167,2168],{},"经过一段时间的折腾，这个基于 Nuxt 3 的个人博客终于上线了。在这里记录一下这个项目的技术选型和功能特性。",[11,2170,2171],{"id":2171},"技术栈",[15,2173,2174],{},"本站使用了以下技术：",[2033,2176,2177,2187],{},[2036,2178,2179],{},[2039,2180,2181,2184],{},[2042,2182,2183],{},"技术",[2042,2185,2186],{},"用途",[2055,2188,2189,2199,2209,2219,2229,2239],{},[2039,2190,2191,2196],{},[2060,2192,2193],{},[1948,2194,2195],{},"Nuxt 3",[2060,2197,2198],{},"Vue 3 全栈框架，提供 SSG、路由、API 等能力",[2039,2200,2201,2206],{},[2060,2202,2203],{},[1948,2204,2205],{},"TypeScript",[2060,2207,2208],{},"类型安全，提升开发体验",[2039,2210,2211,2216],{},[2060,2212,2213],{},[1948,2214,2215],{},"Tailwind CSS v4",[2060,2217,2218],{},"原子化 CSS，快速构建 UI",[2039,2220,2221,2226],{},[2060,2222,2223],{},[1948,2224,2225],{},"@nuxt\u002Fcontent",[2060,2227,2228],{},"Markdown 驱动的内容管理",[2039,2230,2231,2236],{},[2060,2232,2233],{},[1948,2234,2235],{},"Shiki",[2060,2237,2238],{},"代码语法高亮",[2039,2240,2241,2246],{},[2060,2242,2243],{},[1948,2244,2245],{},"Giscus",[2060,2247,2248],{},"基于 GitHub Discussions 的评论系统",[11,2250,2251],{"id":2251},"功能特性",[15,2253,2254],{},"目前已实现的功能：",[2256,2257,2258,2264,2270,2276,2282,2288,2294,2300,2306,2312],"ul",{},[287,2259,2260,2263],{},[1948,2261,2262],{},"博客系统"," - Markdown 文章，支持代码高亮、目录导航、搜索",[287,2265,2266,2269],{},[1948,2267,2268],{},"暗色模式"," - 跟随系统或手动切换",[287,2271,2272,2275],{},[1948,2273,2274],{},"文章搜索"," - 支持标题、描述、正文、标签多维度搜索",[287,2277,2278,2281],{},[1948,2279,2280],{},"标签系统"," - 文章标签分类，高频标签快速筛选",[287,2283,2284,2287],{},[1948,2285,2286],{},"友链页面"," - 展示友情链接，支持申请",[287,2289,2290,2293],{},[1948,2291,2292],{},"赞助页面"," - 微信收款码展示",[287,2295,2296,2299],{},[1948,2297,2298],{},"封面制作"," - 在线生成封面图片工具",[287,2301,2302,2305],{},[1948,2303,2304],{},"网易云热评"," - 首页随机展示音乐评论",[287,2307,2308,2311],{},[1948,2309,2310],{},"访客统计"," - 不蒜子计数器，带数字滚动动画",[287,2313,2314,2317],{},[1948,2315,2316],{},"SEO 优化"," - 自动生成站点地图、Open Graph 标签",[11,2319,2320],{"id":2320},"代码示例",[15,2322,2323],{},"本站的配置非常集中，一个文件搞定全站定制：",[22,2325,2327],{"className":1954,"code":2326,"language":1956,"meta":27,"style":27},"\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",[29,2328,2329,2334,2350,2361,2371,2381,2386],{"__ignoreMap":27},[32,2330,2331],{"class":34,"line":35},[32,2332,2333],{"class":38},"\u002F\u002F config\u002Fsite.ts\n",[32,2335,2336,2338,2341,2344,2347],{"class":34,"line":42},[32,2337,131],{"class":130},[32,2339,2340],{"class":130}," const",[32,2342,2343],{"class":53}," siteConfig",[32,2345,2346],{"class":130}," =",[32,2348,2349],{"class":66}," {\n",[32,2351,2352,2355,2358],{"class":34,"line":85},[32,2353,2354],{"class":66},"  siteName: ",[32,2356,2357],{"class":49},"'我的博客'",[32,2359,2360],{"class":66},",\n",[32,2362,2363,2366,2369],{"class":34,"line":92},[32,2364,2365],{"class":66},"  url: ",[32,2367,2368],{"class":49},"'https:\u002F\u002Fexample.com\u002F'",[32,2370,2360],{"class":66},[32,2372,2373,2376,2379],{"class":34,"line":98},[32,2374,2375],{"class":66},"  avatar: ",[32,2377,2378],{"class":49},"'https:\u002F\u002Fq2.qlogo.cn\u002Fheadimg_dl?dst_uin=xxx&spec=100'",[32,2380,2360],{"class":66},[32,2382,2383],{"class":34,"line":127},[32,2384,2385],{"class":38},"  \u002F\u002F ... 改这一个文件，全站生效\n",[32,2387,2388],{"class":34,"line":146},[32,2389,2003],{"class":66},[15,2391,2392],{},"文章的 frontmatter 也很简洁：",[22,2394,2398],{"className":2395,"code":2396,"language":2397,"meta":27,"style":27},"language-yaml shiki shiki-themes github-light github-dark","---\ntitle: 文章标题\npublished: 2026-05-31T08:00:00\ndescription: 文章简介\ntags: ['Nuxt', 'TypeScript']\n---\n","yaml",[29,2399,2400,2405,2417,2427,2437,2455],{"__ignoreMap":27},[32,2401,2402],{"class":34,"line":35},[32,2403,2404],{"class":45},"---\n",[32,2406,2407,2411,2414],{"class":34,"line":42},[32,2408,2410],{"class":2409},"sbB4o","title",[32,2412,2413],{"class":66},": ",[32,2415,2416],{"class":49},"文章标题\n",[32,2418,2419,2422,2424],{"class":34,"line":85},[32,2420,2421],{"class":2409},"published",[32,2423,2413],{"class":66},[32,2425,2426],{"class":53},"2026-05-31T08:00:00\n",[32,2428,2429,2432,2434],{"class":34,"line":92},[32,2430,2431],{"class":2409},"description",[32,2433,2413],{"class":66},[32,2435,2436],{"class":49},"文章简介\n",[32,2438,2439,2442,2445,2448,2450,2453],{"class":34,"line":98},[32,2440,2441],{"class":2409},"tags",[32,2443,2444],{"class":66},": [",[32,2446,2447],{"class":49},"'Nuxt'",[32,2449,798],{"class":66},[32,2451,2452],{"class":49},"'TypeScript'",[32,2454,501],{"class":66},[32,2456,2457],{"class":34,"line":127},[32,2458,2404],{"class":45},[11,2460,2461],{"id":2461},"部署",[15,2463,2464],{},"本站使用 SSG 静态生成，构建后直接部署到静态托管即可：",[22,2466,2468],{"className":24,"code":2467,"language":26,"meta":27,"style":27},"npx nuxi generate\n",[29,2469,2470],{"__ignoreMap":27},[32,2471,2472,2475,2478],{"class":34,"line":35},[32,2473,2474],{"class":45},"npx",[32,2476,2477],{"class":49}," nuxi",[32,2479,2480],{"class":49}," generate\n",[15,2482,2483,2484,2487],{},"构建产物在 ",[29,2485,2486],{},".output\u002Fpublic"," 目录下，可以直接丢到 Nginx、Vercel、Netlify 等平台。",[11,2489,2490],{"id":2490},"后续计划",[2256,2492,2495,2504,2510,2516],{"className":2493},[2494],"contains-task-list",[287,2496,2499,2503],{"className":2497},[2498],"task-list-item",[2500,2501],"input",{"disabled":88,"type":2502},"checkbox"," RSS 订阅",[287,2505,2507,2509],{"className":2506},[2498],[2500,2508],{"disabled":88,"type":2502}," 文章目录优化",[287,2511,2513,2515],{"className":2512},[2498],[2500,2514],{"disabled":88,"type":2502}," 更多小工具",[287,2517,2519,2521],{"className":2518},[2498],[2500,2520],{"disabled":88,"type":2502}," 持续输出内容",[11,2523,2524],{"id":2524},"致谢",[15,2526,2527,2528,2535],{},"感谢 ",[2529,2530,2534],"a",{"href":2531,"rel":2532},"https:\u002F\u002F2x.nz",[2533],"nofollow","二叉树树"," 的开源项目，本站的 UI 设计参考了他的项目。",[2537,2538],"hr",{},[15,2540,2541],{},[1948,2542,2543],{},"感谢阅读！如果觉得不错，欢迎点个 Star 或者留个评论 (☞ﾟヮﾟ)☞",[824,2545,2546],{},"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":27,"searchDepth":42,"depth":85,"links":2548},[2549,2550,2551,2552,2553,2554,2555],{"id":2157,"depth":42,"text":2158},{"id":2171,"depth":42,"text":2171},{"id":2251,"depth":42,"text":2251},{"id":2320,"depth":42,"text":2320},{"id":2461,"depth":42,"text":2461},{"id":2490,"depth":42,"text":2490},{"id":2524,"depth":42,"text":2524},"博客上线啦！这篇文章介绍本站的技术架构、功能特性和搭建过程中的一些思考。","\u002Fposts\u002Fhello-world\u002Fimg\u002Fcover.jpg",{},"\u002Fposts\u002Fhello-world","2026-05-31T08:00:00",{"title":2152,"description":2556},"Nuxt 博客开发","posts\u002Fhello-world\u002Findex",[2565,2053],"博客","2026-05-31","f9MCboCBfQIUxT8aKv6aKGDFxysiG8ZG0YXr5X5LTFo",{"id":2569,"title":2570,"author":6,"body":2571,"description":3748,"difficulty":839,"draft":840,"extension":841,"image":3749,"meta":3750,"navigation":88,"path":3751,"pinned":840,"published":3752,"seo":3753,"series":2562,"seriesOrder":42,"stem":3754,"tags":3755,"updated":2566,"__hash__":3758},"posts\u002Fposts\u002Fnuxt-content-lessons\u002Findex.md","Nuxt Content v3 踩坑记录：目录、图片与那些反直觉的设计",{"type":8,"value":2572,"toc":3738},[2573,2577,2580,2583,2587,2590,2601,2604,2611,2679,2686,2688,2695,2974,2984,2988,2991,2994,3020,3034,3037,3060,3066,3069,3072,3075,3080,3087,3314,3319,3363,3367,3370,3377,3380,3430,3436,3665,3675,3677,3729,3732,3735],[2574,2575,2576],"h1",{"id":2576},"背景",[15,2578,2579],{},"最近在用 Nuxt 3 + Nuxt Content v3 搭建一个个人博客，参考了一个 SvelteKit 项目（svaf）的架构。原以为换个框架只是语法差异，没想到在几个关键功能上踩了不少坑。",[15,2581,2582],{},"这篇文章记录了三个核心问题的排查过程和最终方案。",[2574,2584,2586],{"id":2585},"问题一目录导航toc只有-h2","问题一：目录导航（TOC）只有 h2",[11,2588,2589],{"id":2589},"现象",[15,2591,2592,2593,2596,2597,2600],{},"Nuxt Content 内置了 TOC 数据，挂在 ",[29,2594,2595],{},"post.body.toc"," 上。直接用它渲染目录，发现",[1948,2598,2599],{},"只有 h2 标题，h3 全部丢失","。",[11,2602,2603],{"id":2603},"排查",[15,2605,2606,2607,2610],{},"查了官方文档，发现 ",[29,2608,2609],{},"build.markdown.toc.depth"," 可以控制深度：",[22,2612,2614],{"className":1954,"code":2613,"language":1956,"meta":27,"style":27},"\u002F\u002F nuxt.config.ts\ncontent: {\n  build: {\n    markdown: {\n      toc: { depth: 3 }  \u002F\u002F 包含 h3\n    }\n  }\n}\n",[29,2615,2616,2621,2629,2636,2643,2665,2670,2675],{"__ignoreMap":27},[32,2617,2618],{"class":34,"line":35},[32,2619,2620],{"class":38},"\u002F\u002F nuxt.config.ts\n",[32,2622,2623,2626],{"class":34,"line":42},[32,2624,2625],{"class":45},"content",[32,2627,2628],{"class":66},": {\n",[32,2630,2631,2634],{"class":34,"line":85},[32,2632,2633],{"class":45},"  build",[32,2635,2628],{"class":66},[32,2637,2638,2641],{"class":34,"line":92},[32,2639,2640],{"class":45},"    markdown",[32,2642,2628],{"class":66},[32,2644,2645,2648,2651,2654,2656,2659,2662],{"class":34,"line":98},[32,2646,2647],{"class":45},"      toc",[32,2649,2650],{"class":66},": { ",[32,2652,2653],{"class":45},"depth",[32,2655,2413],{"class":66},[32,2657,2658],{"class":53},"3",[32,2660,2661],{"class":66}," }  ",[32,2663,2664],{"class":38},"\u002F\u002F 包含 h3\n",[32,2666,2667],{"class":34,"line":127},[32,2668,2669],{"class":66},"    }\n",[32,2671,2672],{"class":34,"line":146},[32,2673,2674],{"class":66},"  }\n",[32,2676,2677],{"class":34,"line":162},[32,2678,2003],{"class":66},[15,2680,2681,2682,2685],{},"配置改了，清了缓存重启，",[1948,2683,2684],{},"依然只有 h2","。这个配置似乎不起作用。",[11,2687,329],{"id":329},[15,2689,2690,2691,2694],{},"放弃依赖 Nuxt Content 的 toc，直接从 ",[29,2692,2693],{},"post.body"," 的 AST 里递归提取所有标题：",[22,2696,2698],{"className":1954,"code":2697,"language":1956,"meta":27,"style":27},"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",[29,2699,2700,2744,2764,2777,2782,2843,2873,2905,2930,2934,2959,2963,2970],{"__ignoreMap":27},[32,2701,2702,2704,2707,2710,2714,2716,2719,2721,2724,2726,2729,2732,2734,2737,2739,2741],{"class":34,"line":35},[32,2703,1981],{"class":130},[32,2705,2706],{"class":45}," extractHeadings",[32,2708,2709],{"class":66},"(",[32,2711,2713],{"class":2712},"sPK6S","node",[32,2715,1622],{"class":130},[32,2717,2718],{"class":53}," any",[32,2720,798],{"class":66},[32,2722,2723],{"class":2712},"list",[32,2725,1622],{"class":130},[32,2727,2728],{"class":45}," Heading",[32,2730,2731],{"class":66},"[] ",[32,2733,137],{"class":130},[32,2735,2736],{"class":66}," [])",[32,2738,1622],{"class":130},[32,2740,2728],{"class":45},[32,2742,2743],{"class":66},"[] {\n",[32,2745,2746,2749,2752,2755,2758,2761],{"class":34,"line":42},[32,2747,2748],{"class":130},"  if",[32,2750,2751],{"class":66}," (",[32,2753,2754],{"class":130},"!",[32,2756,2757],{"class":66},"node) ",[32,2759,2760],{"class":130},"return",[32,2762,2763],{"class":66}," list\n",[32,2765,2766,2768,2771,2774],{"class":34,"line":85},[32,2767,2748],{"class":130},[32,2769,2770],{"class":66}," (Array.",[32,2772,2773],{"class":45},"isArray",[32,2775,2776],{"class":66},"(node)) {\n",[32,2778,2779],{"class":34,"line":92},[32,2780,2781],{"class":38},"    \u002F\u002F minimark 格式：[\"h2\", {id: \"xxx\"}, \"文本\"]\n",[32,2783,2784,2787,2789,2792,2795,2798,2801,2804,2807,2810,2813,2816,2820,2823,2826,2829,2832,2835,2838,2840],{"class":34,"line":98},[32,2785,2786],{"class":130},"    if",[32,2788,2751],{"class":66},[32,2790,2791],{"class":130},"typeof",[32,2793,2794],{"class":66}," node[",[32,2796,2797],{"class":53},"0",[32,2799,2800],{"class":66},"] ",[32,2802,2803],{"class":130},"===",[32,2805,2806],{"class":49}," 'string'",[32,2808,2809],{"class":130}," &&",[32,2811,2812],{"class":49}," \u002F",[32,2814,2815],{"class":130},"^",[32,2817,2819],{"class":2818},"sPKmS","h",[32,2821,2822],{"class":53},"[1-6]",[32,2824,2825],{"class":130},"$",[32,2827,2828],{"class":49},"\u002F",[32,2830,2831],{"class":66},".",[32,2833,2834],{"class":45},"test",[32,2836,2837],{"class":66},"(node[",[32,2839,2797],{"class":53},[32,2841,2842],{"class":66},"])) {\n",[32,2844,2845,2848,2851,2853,2856,2858,2860,2863,2866,2868,2870],{"class":34,"line":127},[32,2846,2847],{"class":130},"      const",[32,2849,2850],{"class":53}," level",[32,2852,2346],{"class":130},[32,2854,2855],{"class":45}," Number",[32,2857,2837],{"class":66},[32,2859,2797],{"class":53},[32,2861,2862],{"class":66},"].",[32,2864,2865],{"class":45},"charAt",[32,2867,2709],{"class":66},[32,2869,156],{"class":53},[32,2871,2872],{"class":66},"))\n",[32,2874,2875,2877,2880,2882,2885,2888,2891,2893,2896,2899,2902],{"class":34,"line":146},[32,2876,2847],{"class":130},[32,2878,2879],{"class":53}," text",[32,2881,2346],{"class":130},[32,2883,2884],{"class":45}," extractText",[32,2886,2887],{"class":66},"(node.",[32,2889,2890],{"class":45},"slice",[32,2892,2709],{"class":66},[32,2894,2895],{"class":53},"2",[32,2897,2898],{"class":66},")).",[32,2900,2901],{"class":45},"trim",[32,2903,2904],{"class":66},"()\n",[32,2906,2907,2910,2913,2916,2918,2921,2924,2927],{"class":34,"line":162},[32,2908,2909],{"class":66},"      list.",[32,2911,2912],{"class":45},"push",[32,2914,2915],{"class":66},"({ id: node[",[32,2917,156],{"class":53},[32,2919,2920],{"class":66},"]?.id ",[32,2922,2923],{"class":130},"||",[32,2925,2926],{"class":45}," slugify",[32,2928,2929],{"class":66},"(text), text, level })\n",[32,2931,2932],{"class":34,"line":168},[32,2933,2669],{"class":66},[32,2935,2936,2939,2941,2944,2947,2950,2953,2956],{"class":34,"line":182},[32,2937,2938],{"class":130},"    for",[32,2940,2751],{"class":66},[32,2942,2943],{"class":130},"const",[32,2945,2946],{"class":53}," child",[32,2948,2949],{"class":130}," of",[32,2951,2952],{"class":66}," node) ",[32,2954,2955],{"class":45},"extractHeadings",[32,2957,2958],{"class":66},"(child, list)\n",[32,2960,2961],{"class":34,"line":205},[32,2962,2674],{"class":66},[32,2964,2965,2968],{"class":34,"line":214},[32,2966,2967],{"class":130},"  return",[32,2969,2763],{"class":66},[32,2971,2972],{"class":34,"line":219},[32,2973,2003],{"class":66},[15,2975,2976,2977,2979,2980,2983],{},"关键点：Nuxt Content v3 的 body 使用 ",[1948,2978,8],{}," 格式，标题节点是数组 ",[29,2981,2982],{},"[\"h2\", {id: \"xxx\"}, \"标题文本\"]","，不是对象。",[2574,2985,2987],{"id":2986},"问题二图片路径解析错误","问题二：图片路径解析错误",[11,2989,2589],{"id":2990},"现象-1",[15,2992,2993],{},"markdown 里用相对路径引用图片：",[22,2995,2999],{"className":2996,"code":2997,"language":2998,"meta":27,"style":27},"language-markdown shiki shiki-themes github-light github-dark","![SSHFS挂载效果](img\u002Fsshfs.avif)\n","markdown",[29,3000,3001],{"__ignoreMap":27},[32,3002,3003,3006,3010,3013,3017],{"class":34,"line":35},[32,3004,3005],{"class":66},"![",[32,3007,3009],{"class":3008},"smiIW","SSHFS挂载效果",[32,3011,3012],{"class":66},"](",[32,3014,3016],{"class":3015},"szbl0","img\u002Fsshfs.avif",[32,3018,3019],{"class":66},")\n",[15,3021,3022,3023,3026,3027,3030,3031,2600],{},"HTML 输出的 ",[29,3024,3025],{},"\u003Cimg src=\"img\u002Fsshfs.avif\">"," 看起来没问题，但浏览器请求的却是 ",[29,3028,3029],{},"\u002Fposts\u002Fimg\u002Fsshfs.avif"," 而不是 ",[29,3032,3033],{},"\u002Fposts\u002Fsshfs\u002Fimg\u002Fsshfs.avif",[11,3035,2603],{"id":3036},"排查-1",[284,3038,3039,3046,3049,3055],{},[287,3040,3041,3042,3045],{},"检查了 ",[29,3043,3044],{},"\u003Cbase>"," 标签 —— 没有",[287,3047,3048],{},"检查了重定向 —— 没有",[287,3050,3051,3052,3054],{},"直接访问 ",[29,3053,3033],{}," —— 返回 200 OK",[287,3056,3051,3057,3059],{},[29,3058,3029],{}," —— 404",[15,3061,3062,3063,2600],{},"服务器路由没问题，问题是",[1948,3064,3065],{},"浏览器解析相对路径时基于了错误的基准 URL",[15,3067,3068],{},"这可能和 Nuxt 的客户端路由有关：SPA 导航时，浏览器的\"当前页面\"和 Vue Router 的\"当前路由\"不同步，导致相对路径解析出错。",[11,3070,329],{"id":3071},"最终方案-1",[15,3073,3074],{},"两步解决：",[15,3076,3077],{},[1948,3078,3079],{},"第一步：自定义 ProseImg 组件",[15,3081,3082,3083,3086],{},"创建 ",[29,3084,3085],{},"components\u002Fcontent\u002FProseImg.vue","，覆盖 Nuxt Content 默认的图片渲染：",[22,3088,3092],{"className":3089,"code":3090,"language":3091,"meta":27,"style":27},"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",[29,3093,3094,3106,3120,3140,3156,3194,3229,3253,3258,3267,3271,3280,3306],{"__ignoreMap":27},[32,3095,3096,3098,3101,3104],{"class":34,"line":35},[32,3097,1625],{"class":66},[32,3099,3100],{"class":2409},"script",[32,3102,3103],{"class":45}," setup",[32,3105,635],{"class":66},[32,3107,3108,3110,3113,3115,3118],{"class":34,"line":42},[32,3109,2943],{"class":130},[32,3111,3112],{"class":53}," route",[32,3114,2346],{"class":130},[32,3116,3117],{"class":45}," useRoute",[32,3119,2904],{"class":66},[32,3121,3122,3124,3127,3129,3132,3135,3138],{"class":34,"line":85},[32,3123,2943],{"class":130},[32,3125,3126],{"class":53}," resolvedSrc",[32,3128,2346],{"class":130},[32,3130,3131],{"class":45}," computed",[32,3133,3134],{"class":66},"(() ",[32,3136,3137],{"class":130},"=>",[32,3139,2349],{"class":66},[32,3141,3142,3144,3146,3148,3151,3153],{"class":34,"line":92},[32,3143,2748],{"class":130},[32,3145,2751],{"class":66},[32,3147,2754],{"class":130},[32,3149,3150],{"class":66},"props.src) ",[32,3152,2760],{"class":130},[32,3154,3155],{"class":49}," ''\n",[32,3157,3158,3160,3163,3166,3168,3171,3174,3176,3179,3181,3183,3186,3189,3191],{"class":34,"line":98},[32,3159,2748],{"class":130},[32,3161,3162],{"class":66}," (props.src.",[32,3164,3165],{"class":45},"startsWith",[32,3167,2709],{"class":66},[32,3169,3170],{"class":49},"'\u002F'",[32,3172,3173],{"class":66},") ",[32,3175,2923],{"class":130},[32,3177,3178],{"class":66}," props.src.",[32,3180,3165],{"class":45},[32,3182,2709],{"class":66},[32,3184,3185],{"class":49},"'http'",[32,3187,3188],{"class":66},")) ",[32,3190,2760],{"class":130},[32,3192,3193],{"class":66}," props.src\n",[32,3195,3196,3199,3202,3204,3207,3210,3212,3214,3218,3220,3222,3224,3227],{"class":34,"line":127},[32,3197,3198],{"class":130},"  const",[32,3200,3201],{"class":53}," base",[32,3203,2346],{"class":130},[32,3205,3206],{"class":66}," route.path.",[32,3208,3209],{"class":45},"replace",[32,3211,2709],{"class":66},[32,3213,2828],{"class":49},[32,3215,3217],{"class":3216},"sCnZR","\\\u002F",[32,3219,2825],{"class":130},[32,3221,2828],{"class":49},[32,3223,798],{"class":66},[32,3225,3226],{"class":49},"''",[32,3228,3019],{"class":66},[32,3230,3231,3233,3236,3239,3242,3245,3247,3250],{"class":34,"line":146},[32,3232,2967],{"class":130},[32,3234,3235],{"class":49}," `${",[32,3237,3238],{"class":66},"base",[32,3240,3241],{"class":49},"}\u002F${",[32,3243,3244],{"class":66},"props",[32,3246,2831],{"class":49},[32,3248,3249],{"class":66},"src",[32,3251,3252],{"class":49},"}`\n",[32,3254,3255],{"class":34,"line":162},[32,3256,3257],{"class":66},"})\n",[32,3259,3260,3263,3265],{"class":34,"line":168},[32,3261,3262],{"class":66},"\u003C\u002F",[32,3264,3100],{"class":2409},[32,3266,635],{"class":66},[32,3268,3269],{"class":34,"line":182},[32,3270,89],{"emptyLinePlaceholder":88},[32,3272,3273,3275,3278],{"class":34,"line":205},[32,3274,1625],{"class":66},[32,3276,3277],{"class":2409},"template",[32,3279,635],{"class":66},[32,3281,3282,3285,3287,3290,3292,3295,3298,3300,3303],{"class":34,"line":214},[32,3283,3284],{"class":66},"  \u003C",[32,3286,309],{"class":2409},[32,3288,3289],{"class":45}," :src",[32,3291,137],{"class":66},[32,3293,3294],{"class":49},"\"resolvedSrc\"",[32,3296,3297],{"class":45}," :alt",[32,3299,137],{"class":66},[32,3301,3302],{"class":49},"\"alt\"",[32,3304,3305],{"class":66}," \u002F>\n",[32,3307,3308,3310,3312],{"class":34,"line":219},[32,3309,3262],{"class":66},[32,3311,3277],{"class":2409},[32,3313,635],{"class":66},[15,3315,3316],{},[1948,3317,3318],{},"第二步：启用 MDC prose 组件",[22,3320,3322],{"className":1954,"code":3321,"language":1956,"meta":27,"style":27},"\u002F\u002F nuxt.config.ts\nmdc: {\n  components: {\n    prose: true  \u002F\u002F 让 Nuxt Content 使用自定义 ProseImg\n  }\n}\n",[29,3323,3324,3328,3335,3342,3355,3359],{"__ignoreMap":27},[32,3325,3326],{"class":34,"line":35},[32,3327,2620],{"class":38},[32,3329,3330,3333],{"class":34,"line":42},[32,3331,3332],{"class":45},"mdc",[32,3334,2628],{"class":66},[32,3336,3337,3340],{"class":34,"line":85},[32,3338,3339],{"class":45},"  components",[32,3341,2628],{"class":66},[32,3343,3344,3347,3349,3352],{"class":34,"line":92},[32,3345,3346],{"class":45},"    prose",[32,3348,2413],{"class":66},[32,3350,3351],{"class":53},"true",[32,3353,3354],{"class":38},"  \u002F\u002F 让 Nuxt Content 使用自定义 ProseImg\n",[32,3356,3357],{"class":34,"line":98},[32,3358,2674],{"class":66},[32,3360,3361],{"class":34,"line":127},[32,3362,2003],{"class":66},[2574,3364,3366],{"id":3365},"问题三content-目录的图片如何服务","问题三：content 目录的图片如何服务",[11,3368,2589],{"id":3369},"现象-2",[15,3371,3372,3373,3376],{},"原站（SvelteKit）的图片放在 ",[29,3374,3375],{},"content\u002Fposts\u002F{slug}\u002Fimg\u002F"," 下，markdown 用相对路径引用，一切正常。Nuxt 项目里，content 目录的文件不会被当成静态资源服务。",[11,3378,3379],{"id":3379},"方案对比",[2033,3381,3382,3395],{},[2036,3383,3384],{},[2039,3385,3386,3389,3392],{},[2042,3387,3388],{},"方案",[2042,3390,3391],{},"优点",[2042,3393,3394],{},"缺点",[2055,3396,3397,3408,3419],{},[2039,3398,3399,3402,3405],{},[2060,3400,3401],{},"复制到 public\u002F",[2060,3403,3404],{},"简单",[2060,3406,3407],{},"文件重复，需同步",[2039,3409,3410,3413,3416],{},[2060,3411,3412],{},"Vite 插件拦截",[2060,3414,3415],{},"原站方案",[2060,3417,3418],{},"Nuxt 不直接支持",[2039,3420,3421,3424,3427],{},[2060,3422,3423],{},"Server Route",[2060,3425,3426],{},"不复制文件",[2060,3428,3429],{},"需要手动写路由",[15,3431,3432,3433,3435],{},"最终选择 ",[1948,3434,3423],{},"，和原站的 Vite 插件思路一致：",[22,3437,3439],{"className":1954,"code":3438,"language":1956,"meta":27,"style":27},"\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",[29,3440,3441,3446,3472,3487,3551,3565,3569,3591,3617,3635,3654,3661],{"__ignoreMap":27},[32,3442,3443],{"class":34,"line":35},[32,3444,3445],{"class":38},"\u002F\u002F server\u002Froutes\u002Fposts\u002F[slug]\u002Fimg\u002F[...file].ts\n",[32,3447,3448,3450,3453,3456,3458,3461,3463,3466,3468,3470],{"class":34,"line":42},[32,3449,131],{"class":130},[32,3451,3452],{"class":130}," default",[32,3454,3455],{"class":45}," defineEventHandler",[32,3457,2709],{"class":66},[32,3459,3460],{"class":130},"async",[32,3462,2751],{"class":66},[32,3464,3465],{"class":2712},"event",[32,3467,3173],{"class":66},[32,3469,3137],{"class":130},[32,3471,2349],{"class":66},[32,3473,3474,3476,3479,3481,3484],{"class":34,"line":85},[32,3475,3198],{"class":130},[32,3477,3478],{"class":53}," url",[32,3480,2346],{"class":130},[32,3482,3483],{"class":45}," getRequestURL",[32,3485,3486],{"class":66},"(event)\n",[32,3488,3489,3491,3494,3496,3499,3502,3504,3506,3508,3510,3513,3515,3517,3520,3522,3525,3528,3531,3533,3535,3537,3539,3541,3543,3545,3547,3549],{"class":34,"line":92},[32,3490,3198],{"class":130},[32,3492,3493],{"class":53}," match",[32,3495,2346],{"class":130},[32,3497,3498],{"class":66}," url.pathname.",[32,3500,3501],{"class":45},"match",[32,3503,2709],{"class":66},[32,3505,2828],{"class":49},[32,3507,2815],{"class":130},[32,3509,3217],{"class":3216},[32,3511,3512],{"class":2818},"posts",[32,3514,3217],{"class":3216},[32,3516,2709],{"class":2818},[32,3518,3519],{"class":53},"[",[32,3521,2815],{"class":130},[32,3523,3524],{"class":53},"\u002F]",[32,3526,3527],{"class":130},"+",[32,3529,3530],{"class":2818},")",[32,3532,3217],{"class":3216},[32,3534,309],{"class":2818},[32,3536,3217],{"class":3216},[32,3538,2709],{"class":2818},[32,3540,2831],{"class":53},[32,3542,3527],{"class":130},[32,3544,3530],{"class":2818},[32,3546,2825],{"class":130},[32,3548,2828],{"class":49},[32,3550,3019],{"class":66},[32,3552,3553,3555,3557,3559,3562],{"class":34,"line":98},[32,3554,2748],{"class":130},[32,3556,2751],{"class":66},[32,3558,2754],{"class":130},[32,3560,3561],{"class":66},"match) ",[32,3563,3564],{"class":130},"return\n",[32,3566,3567],{"class":34,"line":127},[32,3568,89],{"emptyLinePlaceholder":88},[32,3570,3571,3573,3576,3579,3581,3584,3586,3588],{"class":34,"line":146},[32,3572,3198],{"class":130},[32,3574,3575],{"class":66}," [, ",[32,3577,3578],{"class":53},"slug",[32,3580,798],{"class":66},[32,3582,3583],{"class":53},"filename",[32,3585,2800],{"class":66},[32,3587,137],{"class":130},[32,3589,3590],{"class":66}," match\n",[32,3592,3593,3595,3598,3600,3603,3605,3608,3611,3614],{"class":34,"line":162},[32,3594,3198],{"class":130},[32,3596,3597],{"class":53}," filePath",[32,3599,2346],{"class":130},[32,3601,3602],{"class":45}," resolve",[32,3604,2709],{"class":66},[32,3606,3607],{"class":49},"'content\u002Fposts'",[32,3609,3610],{"class":66},", slug, ",[32,3612,3613],{"class":49},"'img'",[32,3615,3616],{"class":66},", filename)\n",[32,3618,3619,3621,3624,3626,3629,3632],{"class":34,"line":168},[32,3620,3198],{"class":130},[32,3622,3623],{"class":53}," data",[32,3625,2346],{"class":130},[32,3627,3628],{"class":130}," await",[32,3630,3631],{"class":45}," readFile",[32,3633,3634],{"class":66},"(filePath)\n",[32,3636,3637,3640,3643,3646,3648,3651],{"class":34,"line":182},[32,3638,3639],{"class":45},"  setResponseHeader",[32,3641,3642],{"class":66},"(event, ",[32,3644,3645],{"class":49},"'content-type'",[32,3647,798],{"class":66},[32,3649,3650],{"class":53},"MIME",[32,3652,3653],{"class":66},"[ext])\n",[32,3655,3656,3658],{"class":34,"line":205},[32,3657,2967],{"class":130},[32,3659,3660],{"class":66}," data\n",[32,3662,3663],{"class":34,"line":214},[32,3664,3257],{"class":66},[15,3666,3667,3668,3670,3671,3674],{},"请求 ",[29,3669,3033],{}," → 读取 ",[29,3672,3673],{},"content\u002Fposts\u002Fsshfs\u002Fimg\u002Fsshfs.avif"," → 返回图片。",[2574,3676,1858],{"id":1858},[2033,3678,3679,3691],{},[2036,3680,3681],{},[2039,3682,3683,3686,3689],{},[2042,3684,3685],{},"问题",[2042,3687,3688],{},"根因",[2042,3690,3388],{},[2055,3692,3693,3707,3718],{},[2039,3694,3695,3698,3704],{},[2060,3696,3697],{},"TOC 只有 h2",[2060,3699,3700,3703],{},[29,3701,3702],{},"toc.depth"," 配置不生效",[2060,3705,3706],{},"从 body AST 手动提取",[2039,3708,3709,3712,3715],{},[2060,3710,3711],{},"图片路径错误",[2060,3713,3714],{},"SPA 路由导致相对路径解析基准错误",[2060,3716,3717],{},"ProseImg 组件 + mdc.prose 配置",[2039,3719,3720,3723,3726],{},[2060,3721,3722],{},"content 图片 404",[2060,3724,3725],{},"content 目录不暴露给浏览器",[2060,3727,3728],{},"Server Route 直接读取返回",[15,3730,3731],{},"Nuxt Content v3 的设计理念是\"图片放 public\u002F，用绝对路径\"。如果想像 SvelteKit 项目那样把图片和文章放一起，需要额外做不少工作。",[15,3733,3734],{},"不过一旦这些坑都踩过，整体开发体验还是很流畅的。Nuxt 的生态系统、自动导入、模块系统都比 SvelteKit 成熟很多。",[824,3736,3737],{},"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":27,"searchDepth":42,"depth":85,"links":3739},[3740,3741,3742,3743,3744,3745,3746,3747],{"id":2589,"depth":42,"text":2589},{"id":2603,"depth":42,"text":2603},{"id":329,"depth":42,"text":329},{"id":2990,"depth":42,"text":2589},{"id":3036,"depth":42,"text":2603},{"id":3071,"depth":42,"text":329},{"id":3369,"depth":42,"text":2589},{"id":3379,"depth":42,"text":3379},"从 SvelteKit 迁移到 Nuxt 3 的过程中，遇到了不少 Nuxt Content v3 的坑。这篇文章记录了目录导航、图片路径、ProseImg 组件等问题的排查和解决过程。","\u002Fposts\u002Fnuxt-content-lessons\u002Fimg\u002Fcover.jpg",{},"\u002Fposts\u002Fnuxt-content-lessons","2026-05-31T14:00:00",{"title":2570,"description":3748},"posts\u002Fnuxt-content-lessons\u002Findex",[2053,3756,3757],"踩坑","Nuxt Content","vbSt6ND1z7xt7L8FxgZ8Qe3EdxV9InsRerN3E23GC7s",{"id":3760,"title":3761,"author":2164,"body":3762,"description":4027,"difficulty":839,"draft":840,"extension":841,"image":4028,"meta":4029,"navigation":88,"path":4030,"pinned":840,"published":4031,"seo":4032,"series":2562,"seriesOrder":85,"stem":4033,"tags":4034,"updated":2566,"__hash__":4037},"posts\u002Fposts\u002Fnuxt-tailwind-underline-fix\u002Findex.md","Nuxt 3 + Tailwind CSS 客户端导航链接下划线闪烁问题",{"type":8,"value":3763,"toc":4020},[3764,3767,3770,3785,3788,3791,3794,3835,3846,3853,3875,3882,3886,3889,3892,3894,3897,3920,3931,3988,3993,4006,4008,4014,4017],[11,3765,3766],{"id":3766},"问题描述",[15,3768,3769],{},"在 Nuxt 3 项目中使用 Tailwind CSS 时，发现一个奇怪的现象：",[2256,3771,3772,3782],{},[287,3773,3774,3775,3778,3779],{},"首次通过客户端导航（点击 ",[29,3776,3777],{},"\u003CNuxtLink>","）跳转到某个页面时，",[1948,3780,3781],{},"所有链接文字都会出现下划线",[287,3783,3784],{},"手动刷新页面后，下划线消失，样式恢复正常",[15,3786,3787],{},"这个问题在开发模式和生产构建后都会出现。",[11,3789,3790],{"id":3790},"原因分析",[15,3792,3793],{},"Tailwind CSS 的 Preflight（基于 modern-normalize）会重置链接样式：",[22,3795,3799],{"className":3796,"code":3797,"language":3798,"meta":27,"style":27},"language-css shiki shiki-themes github-light github-dark","a {\n  color: inherit;\n  text-decoration: inherit;\n}\n","css",[29,3800,3801,3807,3820,3831],{"__ignoreMap":27},[32,3802,3803,3805],{"class":34,"line":35},[32,3804,2529],{"class":2409},[32,3806,2349],{"class":66},[32,3808,3809,3812,3814,3817],{"class":34,"line":42},[32,3810,3811],{"class":53},"  color",[32,3813,2413],{"class":66},[32,3815,3816],{"class":53},"inherit",[32,3818,3819],{"class":66},";\n",[32,3821,3822,3825,3827,3829],{"class":34,"line":85},[32,3823,3824],{"class":53},"  text-decoration",[32,3826,2413],{"class":66},[32,3828,3816],{"class":53},[32,3830,3819],{"class":66},[32,3832,3833],{"class":34,"line":92},[32,3834,2003],{"class":66},[15,3836,3837,3838,3841,3842,3845],{},"正常情况下，这会让链接继承父元素的 ",[29,3839,3840],{},"text-decoration","（通常是 ",[29,3843,3844],{},"none","），从而去掉浏览器默认的下划线。",[15,3847,3848,3849,3852],{},"问题出在 ",[1948,3850,3851],{},"Nuxt 的客户端导航机制","：",[284,3854,3855,3861,3864,3869,3872],{},[287,3856,3857,3858,3860],{},"用户点击 ",[29,3859,3777],{},"，Vue Router 拦截导航",[287,3862,3863],{},"新页面组件异步加载并渲染",[287,3865,3866],{},[1948,3867,3868],{},"在 CSS 完全加载\u002F应用之前，页面已经渲染了",[287,3870,3871],{},"此时浏览器使用默认样式（链接带下划线）",[287,3873,3874],{},"CSS 加载完成后，下划线消失",[15,3876,3877,3878,3881],{},"这就是典型的 ",[1948,3879,3880],{},"FOUC（Flash of Unstyled Content）"," 问题。",[11,3883,3885],{"id":3884},"为什么刷新就正常了","为什么刷新就正常了？",[15,3887,3888],{},"刷新页面时，浏览器会等待所有 CSS 加载完成后再渲染页面（SSR 模式下 HTML 和 CSS 是一起返回的）。所以刷新后样式是正确的。",[15,3890,3891],{},"而客户端导航是 JavaScript 驱动的，CSS 的加载和页面的渲染是异步的，就出现了时序差。",[11,3893,1511],{"id":1511},[15,3895,3896],{},"尝试了多种方案：",[2256,3898,3899,3905,3911,3917],{},[287,3900,3901,3904],{},[29,3902,3903],{},"@layer base"," 覆盖 — 无效，CSS layers 的优先级问题",[287,3906,3907,3910],{},[29,3908,3909],{},"corePlugins.preflight: false"," — 无效，且破坏其他基础样式",[287,3912,3913,3916],{},[29,3914,3915],{},"features.inlineStyles: true"," — 无效",[287,3918,3919],{},"页面过渡动画 — 只是掩盖，不能根治",[15,3921,3922,3923,3926,3927,3930],{},"最终有效的方案是在 ",[29,3924,3925],{},"app.vue"," 中用 ",[29,3928,3929],{},"!important"," 强制覆盖：",[22,3932,3934],{"className":3089,"code":3933,"language":3091,"meta":27,"style":27},"\u003Cstyle>\na:not(.prose a) {\n  text-decoration-line: none !important;\n}\n\u003C\u002Fstyle>\n",[29,3935,3936,3944,3962,3976,3980],{"__ignoreMap":27},[32,3937,3938,3940,3942],{"class":34,"line":35},[32,3939,1625],{"class":66},[32,3941,824],{"class":2409},[32,3943,635],{"class":66},[32,3945,3946,3948,3951,3953,3956,3959],{"class":34,"line":42},[32,3947,2529],{"class":2409},[32,3949,3950],{"class":45},":not",[32,3952,2709],{"class":66},[32,3954,3955],{"class":45},".prose",[32,3957,3958],{"class":2409}," a",[32,3960,3961],{"class":66},") {\n",[32,3963,3964,3967,3969,3971,3974],{"class":34,"line":85},[32,3965,3966],{"class":53},"  text-decoration-line",[32,3968,2413],{"class":66},[32,3970,3844],{"class":53},[32,3972,3973],{"class":130}," !important",[32,3975,3819],{"class":66},[32,3977,3978],{"class":34,"line":92},[32,3979,2003],{"class":66},[32,3981,3982,3984,3986],{"class":34,"line":98},[32,3983,3262],{"class":66},[32,3985,824],{"class":2409},[32,3987,635],{"class":66},[15,3989,3990],{},[1948,3991,3992],{},"关键点：",[2256,3994,3995,4000],{},[287,3996,3997,3999],{},[29,3998,3929],{}," 是为了对抗 Tailwind CSS layers 的优先级",[287,4001,4002,4005],{},[29,4003,4004],{},":not(.prose a)"," 排除文章正文区域，保留文章内链接的下划线（可读性需要）",[11,4007,1858],{"id":1858},[15,4009,4010,4011,4013],{},"这是 Nuxt 3 + Tailwind CSS 的一个已知问题，本质上是 CSS 加载时序导致的 FOUC。",[29,4012,3929],{}," 虽然不够优雅，但在这个场景下是务实的解决方案。",[15,4015,4016],{},"如果你有更好的方案，欢迎在项目仓库的 Issues 中提出！",[824,4018,4019],{},"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":27,"searchDepth":42,"depth":85,"links":4021},[4022,4023,4024,4025,4026],{"id":3766,"depth":42,"text":3766},{"id":3790,"depth":42,"text":3790},{"id":3884,"depth":42,"text":3885},{"id":1511,"depth":42,"text":1511},{"id":1858,"depth":42,"text":1858},"记录 Nuxt 3 配合 Tailwind CSS 时，客户端导航导致链接文字出现下划线闪烁的问题及解决方案。","\u002Fposts\u002Fnuxt-tailwind-underline-fix\u002Fimg\u002Fcover.png",{},"\u002Fposts\u002Fnuxt-tailwind-underline-fix","2026-05-31T18:47:00",{"title":3761,"description":4027},"posts\u002Fnuxt-tailwind-underline-fix\u002Findex",[2053,4035,4036,3756],"Tailwind CSS","CSS","WMLpTEsIoJqufwEfXyIiBgUYxcPY2XkvOwqAD4hbe6g",{"id":4039,"title":4040,"author":6,"body":4041,"description":6092,"difficulty":839,"draft":840,"extension":841,"image":6093,"meta":6094,"navigation":88,"path":6095,"pinned":840,"published":6096,"seo":6097,"series":6098,"seriesOrder":35,"stem":6099,"tags":6100,"updated":6105,"__hash__":6106},"posts\u002Fposts\u002Fgo-mcp-gin\u002Findex.md","Go-MCP 接入 Gin：从零搭建 MCP 服务器",{"type":8,"value":4042,"toc":6073},[4043,4047,4050,4053,4057,4064,4070,4073,4076,4096,4099,4103,4127,4131,4231,4244,4248,4412,4415,4632,4636,4647,4746,4753,4757,5031,5034,5115,5119,5122,5124,5135,5139,5145,5512,5702,5705,5761,5764,5917,5920,5923,5994,5996,6061,6070],[11,4044,4046],{"id":4045},"什么是-mcp","什么是 MCP",[15,4048,4049],{},"MCP（Model Context Protocol）是 Anthropic 提出的开放协议，用于让 AI 助手（如 Claude）与外部服务进行标准化交互。简单来说：你提供一个 MCP 服务器，AI 就能发现并调用你定义的工具（Tools），就像给 AI 装了一双手。",[15,4051,4052],{},"传统做法是写 REST API 然后让 AI 通过函数调用（Function Calling）间接使用，但 MCP 统一了协议层，AI 客户端只需要配置一个 URL 就能接入所有工具。",[11,4054,4056],{"id":4055},"为什么选-gin-mcp-go","为什么选 Gin + mcp-go",[15,4058,4059,4060,4063],{},"Go 生态里做 MCP 服务器最成熟的库是 ",[29,4061,4062],{},"mark3labs\u002Fmcp-go","，它支持 StreamableHTTP 传输模式，可以直接挂载到任意 HTTP 框架上。Gin 作为 Go 最流行的 HTTP 框架，天然适配。",[15,4065,4066,4067],{},"核心思路：",[1948,4068,4069],{},"MCP 服务器本质上就是一个 HTTP Handler，挂到 Gin 路由上即可。",[11,4071,4072],{"id":4072},"需求分析",[15,4074,4075],{},"我们要搭建一个具备以下能力的 MCP 服务器：",[284,4077,4078,4084,4090],{},[287,4079,4080,4083],{},[1948,4081,4082],{},"工具注册"," — 向 AI 暴露可调用的工具（列出资源、查看详情、创建\u002F删除）",[287,4085,4086,4089],{},[1948,4087,4088],{},"HTTP 集成"," — MCP 端点作为 Gin 路由的一部分，与业务 API 共存",[287,4091,4092,4095],{},[1948,4093,4094],{},"鉴权"," — MCP 请求必须经过身份验证，工具处理器能获取当前用户信息",[11,4097,4098],{"id":4098},"实现步骤",[280,4100,4102],{"id":4101},"_1-安装依赖","1. 安装依赖",[22,4104,4106],{"className":24,"code":4105,"language":26,"meta":27,"style":27},"go get github.com\u002Fmark3labs\u002Fmcp-go@v0.54.0\ngo get github.com\u002Fgin-gonic\u002Fgin@v1.12.0\n",[29,4107,4108,4118],{"__ignoreMap":27},[32,4109,4110,4112,4115],{"class":34,"line":35},[32,4111,171],{"class":45},[32,4113,4114],{"class":49}," get",[32,4116,4117],{"class":49}," github.com\u002Fmark3labs\u002Fmcp-go@v0.54.0\n",[32,4119,4120,4122,4124],{"class":34,"line":42},[32,4121,171],{"class":45},[32,4123,4114],{"class":49},[32,4125,4126],{"class":49}," github.com\u002Fgin-gonic\u002Fgin@v1.12.0\n",[280,4128,4130],{"id":4129},"_2-创建-mcp-服务器","2. 创建 MCP 服务器",[22,4132,4135],{"className":4133,"code":4134,"language":171,"meta":27,"style":27},"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",[29,4136,4137,4149,4153,4170,4181,4192,4203,4212,4227],{"__ignoreMap":27},[32,4138,4139,4142,4144,4147],{"class":34,"line":35},[32,4140,4141],{"class":130},"import",[32,4143,63],{"class":49},[32,4145,4146],{"class":45},"github.com\u002Fmark3labs\u002Fmcp-go\u002Fserver",[32,4148,278],{"class":49},[32,4150,4151],{"class":34,"line":42},[32,4152,89],{"emptyLinePlaceholder":88},[32,4154,4155,4158,4161,4164,4167],{"class":34,"line":85},[32,4156,4157],{"class":66},"mcpServer ",[32,4159,4160],{"class":130},":=",[32,4162,4163],{"class":66}," server.",[32,4165,4166],{"class":45},"NewMCPServer",[32,4168,4169],{"class":66},"(\n",[32,4171,4172,4175,4178],{"class":34,"line":92},[32,4173,4174],{"class":49},"    \"my-app\"",[32,4176,4177],{"class":66},",    ",[32,4179,4180],{"class":38},"\u002F\u002F 服务器名称\n",[32,4182,4183,4186,4189],{"class":34,"line":98},[32,4184,4185],{"class":49},"    \"1.0.0\"",[32,4187,4188],{"class":66},",     ",[32,4190,4191],{"class":38},"\u002F\u002F 版本\n",[32,4193,4194,4197,4200],{"class":34,"line":127},[32,4195,4196],{"class":66},"    server.",[32,4198,4199],{"class":45},"WithLogging",[32,4201,4202],{"class":66},"(),\n",[32,4204,4205,4207,4210],{"class":34,"line":146},[32,4206,4196],{"class":66},[32,4208,4209],{"class":45},"WithRecovery",[32,4211,4202],{"class":66},[32,4213,4214,4216,4219,4221,4224],{"class":34,"line":162},[32,4215,4196],{"class":66},[32,4217,4218],{"class":45},"WithInstructions",[32,4220,2709],{"class":66},[32,4222,4223],{"class":49},"\"这是一个 MCP 服务器示例\"",[32,4225,4226],{"class":66},"),\n",[32,4228,4229],{"class":34,"line":168},[32,4230,3019],{"class":66},[15,4232,4233,4235,4236,4239,4240,4243],{},[29,4234,4166],{}," 创建一个 MCP 协议实例，它负责处理工具发现（",[29,4237,4238],{},"tools\u002Flist","）和工具调用（",[29,4241,4242],{},"tools\u002Fcall","）的协议逻辑。",[280,4245,4247],{"id":4246},"_3-注册工具","3. 注册工具",[22,4249,4251],{"className":4133,"code":4250,"language":171,"meta":27,"style":27},"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",[29,4252,4253,4264,4268,4273,4293,4308,4312,4316,4321,4382,4387,4408],{"__ignoreMap":27},[32,4254,4255,4257,4259,4262],{"class":34,"line":35},[32,4256,4141],{"class":130},[32,4258,63],{"class":49},[32,4260,4261],{"class":45},"github.com\u002Fmark3labs\u002Fmcp-go\u002Fmcp",[32,4263,278],{"class":49},[32,4265,4266],{"class":34,"line":42},[32,4267,89],{"emptyLinePlaceholder":88},[32,4269,4270],{"class":34,"line":85},[32,4271,4272],{"class":38},"\u002F\u002F 定义工具\n",[32,4274,4275,4278,4280,4283,4286,4288,4291],{"class":34,"line":92},[32,4276,4277],{"class":66},"listTool ",[32,4279,4160],{"class":130},[32,4281,4282],{"class":66}," mcp.",[32,4284,4285],{"class":45},"NewTool",[32,4287,2709],{"class":66},[32,4289,4290],{"class":49},"\"list_items\"",[32,4292,2360],{"class":66},[32,4294,4295,4298,4301,4303,4306],{"class":34,"line":98},[32,4296,4297],{"class":66},"    mcp.",[32,4299,4300],{"class":45},"WithDescription",[32,4302,2709],{"class":66},[32,4304,4305],{"class":49},"\"列出所有项目\"",[32,4307,4226],{"class":66},[32,4309,4310],{"class":34,"line":127},[32,4311,3019],{"class":66},[32,4313,4314],{"class":34,"line":146},[32,4315,89],{"emptyLinePlaceholder":88},[32,4317,4318],{"class":34,"line":162},[32,4319,4320],{"class":38},"\u002F\u002F 注册处理器\n",[32,4322,4323,4326,4329,4332,4335,4337,4340,4343,4345,4348,4350,4353,4356,4358,4361,4364,4367,4370,4372,4375,4377,4380],{"class":34,"line":168},[32,4324,4325],{"class":66},"mcpServer.",[32,4327,4328],{"class":45},"AddTool",[32,4330,4331],{"class":66},"(listTool, ",[32,4333,4334],{"class":130},"func",[32,4336,2709],{"class":66},[32,4338,4339],{"class":2712},"ctx",[32,4341,4342],{"class":45}," context",[32,4344,2831],{"class":66},[32,4346,4347],{"class":45},"Context",[32,4349,798],{"class":66},[32,4351,4352],{"class":2712},"req",[32,4354,4355],{"class":45}," mcp",[32,4357,2831],{"class":66},[32,4359,4360],{"class":45},"CallToolRequest",[32,4362,4363],{"class":66},") (",[32,4365,4366],{"class":130},"*",[32,4368,4369],{"class":45},"mcp",[32,4371,2831],{"class":66},[32,4373,4374],{"class":45},"CallToolResult",[32,4376,798],{"class":66},[32,4378,4379],{"class":130},"error",[32,4381,3961],{"class":66},[32,4383,4384],{"class":34,"line":182},[32,4385,4386],{"class":38},"    \u002F\u002F 业务逻辑\n",[32,4388,4389,4392,4394,4397,4399,4402,4405],{"class":34,"line":205},[32,4390,4391],{"class":130},"    return",[32,4393,4282],{"class":66},[32,4395,4396],{"class":45},"NewToolResultText",[32,4398,2709],{"class":66},[32,4400,4401],{"class":49},"\"返回结果\"",[32,4403,4404],{"class":66},"), ",[32,4406,4407],{"class":53},"nil\n",[32,4409,4410],{"class":34,"line":214},[32,4411,3257],{"class":66},[15,4413,4414],{},"带参数的工具：",[22,4416,4418],{"className":4133,"code":4417,"language":171,"meta":27,"style":27},"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",[29,4419,4420,4438,4451,4465,4475,4489,4494,4498,4502,4549,4568,4583,4602,4606,4611,4628],{"__ignoreMap":27},[32,4421,4422,4425,4427,4429,4431,4433,4436],{"class":34,"line":35},[32,4423,4424],{"class":66},"getTool ",[32,4426,4160],{"class":130},[32,4428,4282],{"class":66},[32,4430,4285],{"class":45},[32,4432,2709],{"class":66},[32,4434,4435],{"class":49},"\"get_item\"",[32,4437,2360],{"class":66},[32,4439,4440,4442,4444,4446,4449],{"class":34,"line":42},[32,4441,4297],{"class":66},[32,4443,4300],{"class":45},[32,4445,2709],{"class":66},[32,4447,4448],{"class":49},"\"获取指定项目的详情\"",[32,4450,4226],{"class":66},[32,4452,4453,4455,4458,4460,4463],{"class":34,"line":85},[32,4454,4297],{"class":66},[32,4456,4457],{"class":45},"WithString",[32,4459,2709],{"class":66},[32,4461,4462],{"class":49},"\"item_id\"",[32,4464,2360],{"class":66},[32,4466,4467,4470,4473],{"class":34,"line":92},[32,4468,4469],{"class":66},"        mcp.",[32,4471,4472],{"class":45},"Required",[32,4474,4202],{"class":66},[32,4476,4477,4479,4482,4484,4487],{"class":34,"line":98},[32,4478,4469],{"class":66},[32,4480,4481],{"class":45},"Description",[32,4483,2709],{"class":66},[32,4485,4486],{"class":49},"\"项目 ID\"",[32,4488,4226],{"class":66},[32,4490,4491],{"class":34,"line":127},[32,4492,4493],{"class":66},"    ),\n",[32,4495,4496],{"class":34,"line":146},[32,4497,3019],{"class":66},[32,4499,4500],{"class":34,"line":162},[32,4501,89],{"emptyLinePlaceholder":88},[32,4503,4504,4506,4508,4511,4513,4515,4517,4519,4521,4523,4525,4527,4529,4531,4533,4535,4537,4539,4541,4543,4545,4547],{"class":34,"line":168},[32,4505,4325],{"class":66},[32,4507,4328],{"class":45},[32,4509,4510],{"class":66},"(getTool, ",[32,4512,4334],{"class":130},[32,4514,2709],{"class":66},[32,4516,4339],{"class":2712},[32,4518,4342],{"class":45},[32,4520,2831],{"class":66},[32,4522,4347],{"class":45},[32,4524,798],{"class":66},[32,4526,4352],{"class":2712},[32,4528,4355],{"class":45},[32,4530,2831],{"class":66},[32,4532,4360],{"class":45},[32,4534,4363],{"class":66},[32,4536,4366],{"class":130},[32,4538,4369],{"class":45},[32,4540,2831],{"class":66},[32,4542,4374],{"class":45},[32,4544,798],{"class":66},[32,4546,4379],{"class":130},[32,4548,3961],{"class":66},[32,4550,4551,4554,4556,4559,4562,4564,4566],{"class":34,"line":182},[32,4552,4553],{"class":66},"    itemID, err ",[32,4555,4160],{"class":130},[32,4557,4558],{"class":66}," req.",[32,4560,4561],{"class":45},"RequireString",[32,4563,2709],{"class":66},[32,4565,4462],{"class":49},[32,4567,3019],{"class":66},[32,4569,4570,4572,4575,4578,4581],{"class":34,"line":205},[32,4571,2786],{"class":130},[32,4573,4574],{"class":66}," err ",[32,4576,4577],{"class":130},"!=",[32,4579,4580],{"class":53}," nil",[32,4582,2349],{"class":66},[32,4584,4585,4588,4590,4593,4595,4598,4600],{"class":34,"line":214},[32,4586,4587],{"class":130},"        return",[32,4589,4282],{"class":66},[32,4591,4592],{"class":45},"NewToolResultError",[32,4594,2709],{"class":66},[32,4596,4597],{"class":49},"\"缺少 item_id 参数\"",[32,4599,4404],{"class":66},[32,4601,4407],{"class":53},[32,4603,4604],{"class":34,"line":219},[32,4605,2669],{"class":66},[32,4607,4608],{"class":34,"line":225},[32,4609,4610],{"class":38},"    \u002F\u002F 查询并返回\n",[32,4612,4613,4615,4617,4619,4621,4624,4626],{"class":34,"line":236},[32,4614,4391],{"class":130},[32,4616,4282],{"class":66},[32,4618,4396],{"class":45},[32,4620,2709],{"class":66},[32,4622,4623],{"class":49},"\"项目详情...\"",[32,4625,4404],{"class":66},[32,4627,4407],{"class":53},[32,4629,4630],{"class":34,"line":247},[32,4631,3257],{"class":66},[280,4633,4635],{"id":4634},"_4-挂载到-gin-路由","4. 挂载到 Gin 路由",[15,4637,4638,4639,4642,4643,4646],{},"关键一步：用 ",[29,4640,4641],{},"server.NewStreamableHTTPServer"," 把 MCP 实例转成 HTTP Handler，再用 ",[29,4644,4645],{},"gin.WrapH"," 适配 Gin。",[22,4648,4650],{"className":4133,"code":4649,"language":171,"meta":27,"style":27},"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",[29,4651,4652,4662,4666,4681,4695,4712,4716,4720,4725],{"__ignoreMap":27},[32,4653,4654,4656,4658,4660],{"class":34,"line":35},[32,4655,4141],{"class":130},[32,4657,63],{"class":49},[32,4659,4146],{"class":45},[32,4661,278],{"class":49},[32,4663,4664],{"class":34,"line":42},[32,4665,89],{"emptyLinePlaceholder":88},[32,4667,4668,4671,4673,4675,4678],{"class":34,"line":85},[32,4669,4670],{"class":66},"mcpHTTPServer ",[32,4672,4160],{"class":130},[32,4674,4163],{"class":66},[32,4676,4677],{"class":45},"NewStreamableHTTPServer",[32,4679,4680],{"class":66},"(mcpServer,\n",[32,4682,4683,4685,4688,4690,4693],{"class":34,"line":92},[32,4684,4196],{"class":66},[32,4686,4687],{"class":45},"WithEndpointPath",[32,4689,2709],{"class":66},[32,4691,4692],{"class":49},"\"\u002Fmcp\"",[32,4694,4226],{"class":66},[32,4696,4697,4699,4702,4704,4707,4709],{"class":34,"line":98},[32,4698,4196],{"class":66},[32,4700,4701],{"class":45},"WithHeartbeatInterval",[32,4703,2709],{"class":66},[32,4705,4706],{"class":53},"30",[32,4708,4366],{"class":130},[32,4710,4711],{"class":66},"time.Second),\n",[32,4713,4714],{"class":34,"line":127},[32,4715,3019],{"class":66},[32,4717,4718],{"class":34,"line":146},[32,4719,89],{"emptyLinePlaceholder":88},[32,4721,4722],{"class":34,"line":162},[32,4723,4724],{"class":38},"\u002F\u002F 挂载到 Gin\n",[32,4726,4727,4730,4733,4735,4737,4740,4743],{"class":34,"line":168},[32,4728,4729],{"class":66},"r.",[32,4731,4732],{"class":45},"Any",[32,4734,2709],{"class":66},[32,4736,4692],{"class":49},[32,4738,4739],{"class":66},", gin.",[32,4741,4742],{"class":45},"WrapH",[32,4744,4745],{"class":66},"(mcpHTTPServer))\n",[15,4747,4748,4749,4752],{},"就这样，",[29,4750,4751],{},"POST \u002Fmcp"," 端点就生效了。AI 客户端通过这个 URL 发送 JSON-RPC 请求，MCP 服务器自动处理协议握手、工具列表返回、工具调用路由。",[280,4754,4756],{"id":4755},"_5-完整的服务启动代码","5. 完整的服务启动代码",[22,4758,4760],{"className":4133,"code":4759,"language":171,"meta":27,"style":27},"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",[29,4761,4762,4771,4786,4790,4795,4818,4827,4835,4840,4844,4849,4857,4861,4866,4879,4891,4905,4909,4927,4931,4936,4969,5004,5009,5013,5027],{"__ignoreMap":27},[32,4763,4764,4766,4769],{"class":34,"line":35},[32,4765,4334],{"class":130},[32,4767,4768],{"class":45}," main",[32,4770,1987],{"class":66},[32,4772,4773,4776,4778,4781,4784],{"class":34,"line":42},[32,4774,4775],{"class":66},"    r ",[32,4777,4160],{"class":130},[32,4779,4780],{"class":66}," gin.",[32,4782,4783],{"class":45},"Default",[32,4785,2904],{"class":66},[32,4787,4788],{"class":34,"line":85},[32,4789,89],{"emptyLinePlaceholder":88},[32,4791,4792],{"class":34,"line":92},[32,4793,4794],{"class":38},"    \u002F\u002F 创建 MCP 服务器\n",[32,4796,4797,4800,4802,4804,4806,4808,4811,4813,4816],{"class":34,"line":98},[32,4798,4799],{"class":66},"    mcpServer ",[32,4801,4160],{"class":130},[32,4803,4163],{"class":66},[32,4805,4166],{"class":45},[32,4807,2709],{"class":66},[32,4809,4810],{"class":49},"\"my-app\"",[32,4812,798],{"class":66},[32,4814,4815],{"class":49},"\"1.0.0\"",[32,4817,2360],{"class":66},[32,4819,4820,4823,4825],{"class":34,"line":127},[32,4821,4822],{"class":66},"        server.",[32,4824,4199],{"class":45},[32,4826,4202],{"class":66},[32,4828,4829,4831,4833],{"class":34,"line":146},[32,4830,4822],{"class":66},[32,4832,4209],{"class":45},[32,4834,4202],{"class":66},[32,4836,4837],{"class":34,"line":162},[32,4838,4839],{"class":66},"    )\n",[32,4841,4842],{"class":34,"line":168},[32,4843,89],{"emptyLinePlaceholder":88},[32,4845,4846],{"class":34,"line":182},[32,4847,4848],{"class":38},"    \u002F\u002F 注册工具\n",[32,4850,4851,4854],{"class":34,"line":205},[32,4852,4853],{"class":45},"    registerTools",[32,4855,4856],{"class":66},"(mcpServer)\n",[32,4858,4859],{"class":34,"line":214},[32,4860,89],{"emptyLinePlaceholder":88},[32,4862,4863],{"class":34,"line":219},[32,4864,4865],{"class":38},"    \u002F\u002F 挂载到 Gin\n",[32,4867,4868,4871,4873,4875,4877],{"class":34,"line":225},[32,4869,4870],{"class":66},"    mcpHTTP ",[32,4872,4160],{"class":130},[32,4874,4163],{"class":66},[32,4876,4677],{"class":45},[32,4878,4680],{"class":66},[32,4880,4881,4883,4885,4887,4889],{"class":34,"line":236},[32,4882,4822],{"class":66},[32,4884,4687],{"class":45},[32,4886,2709],{"class":66},[32,4888,4692],{"class":49},[32,4890,4226],{"class":66},[32,4892,4893,4895,4897,4899,4901,4903],{"class":34,"line":247},[32,4894,4822],{"class":66},[32,4896,4701],{"class":45},[32,4898,2709],{"class":66},[32,4900,4706],{"class":53},[32,4902,4366],{"class":130},[32,4904,4711],{"class":66},[32,4906,4907],{"class":34,"line":252},[32,4908,4839],{"class":66},[32,4910,4911,4914,4916,4918,4920,4922,4924],{"class":34,"line":261},[32,4912,4913],{"class":66},"    r.",[32,4915,4732],{"class":45},[32,4917,2709],{"class":66},[32,4919,4692],{"class":49},[32,4921,4739],{"class":66},[32,4923,4742],{"class":45},[32,4925,4926],{"class":66},"(mcpHTTP))\n",[32,4928,4929],{"class":34,"line":266},[32,4930,89],{"emptyLinePlaceholder":88},[32,4932,4933],{"class":34,"line":476},[32,4934,4935],{"class":38},"    \u002F\u002F 其他业务路由\n",[32,4937,4938,4940,4943,4945,4948,4950,4952,4954,4957,4960,4963,4965,4967],{"class":34,"line":484},[32,4939,4913],{"class":66},[32,4941,4942],{"class":45},"GET",[32,4944,2709],{"class":66},[32,4946,4947],{"class":49},"\"\u002Fapi\u002Fhealth\"",[32,4949,798],{"class":66},[32,4951,4334],{"class":130},[32,4953,2709],{"class":66},[32,4955,4956],{"class":2712},"c",[32,4958,4959],{"class":130}," *",[32,4961,4962],{"class":45},"gin",[32,4964,2831],{"class":66},[32,4966,4347],{"class":45},[32,4968,3961],{"class":66},[32,4970,4971,4974,4977,4979,4982,4984,4986,4988,4991,4994,4997,4999,5002],{"class":34,"line":489},[32,4972,4973],{"class":66},"        c.",[32,4975,4976],{"class":45},"JSON",[32,4978,2709],{"class":66},[32,4980,4981],{"class":53},"200",[32,4983,798],{"class":66},[32,4985,4962],{"class":45},[32,4987,2831],{"class":66},[32,4989,4990],{"class":45},"H",[32,4992,4993],{"class":66},"{",[32,4995,4996],{"class":49},"\"status\"",[32,4998,2413],{"class":66},[32,5000,5001],{"class":49},"\"ok\"",[32,5003,3257],{"class":66},[32,5005,5006],{"class":34,"line":1769},[32,5007,5008],{"class":66},"    })\n",[32,5010,5011],{"class":34,"line":1783},[32,5012,89],{"emptyLinePlaceholder":88},[32,5014,5015,5017,5020,5022,5025],{"class":34,"line":1790},[32,5016,4913],{"class":66},[32,5018,5019],{"class":45},"Run",[32,5021,2709],{"class":66},[32,5023,5024],{"class":49},"\":8080\"",[32,5026,3019],{"class":66},[32,5028,5029],{"class":34,"line":1795},[32,5030,2003],{"class":66},[15,5032,5033],{},"此时启动服务，用 curl 测试：",[22,5035,5037],{"className":24,"code":5036,"language":26,"meta":27,"style":27},"# 发现工具列表\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",[29,5038,5039,5044,5061,5071,5079,5083,5088,5100,5108],{"__ignoreMap":27},[32,5040,5041],{"class":34,"line":35},[32,5042,5043],{"class":38},"# 发现工具列表\n",[32,5045,5046,5049,5052,5055,5058],{"class":34,"line":42},[32,5047,5048],{"class":45},"curl",[32,5050,5051],{"class":53}," -X",[32,5053,5054],{"class":49}," POST",[32,5056,5057],{"class":49}," http:\u002F\u002Flocalhost:8080\u002Fmcp",[32,5059,5060],{"class":53}," \\\n",[32,5062,5063,5066,5069],{"class":34,"line":85},[32,5064,5065],{"class":53},"  -H",[32,5067,5068],{"class":49}," \"Content-Type: application\u002Fjson\"",[32,5070,5060],{"class":53},[32,5072,5073,5076],{"class":34,"line":92},[32,5074,5075],{"class":53},"  -d",[32,5077,5078],{"class":49}," '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools\u002Flist\"}'\n",[32,5080,5081],{"class":34,"line":98},[32,5082,89],{"emptyLinePlaceholder":88},[32,5084,5085],{"class":34,"line":127},[32,5086,5087],{"class":38},"# 调用工具\n",[32,5089,5090,5092,5094,5096,5098],{"class":34,"line":146},[32,5091,5048],{"class":45},[32,5093,5051],{"class":53},[32,5095,5054],{"class":49},[32,5097,5057],{"class":49},[32,5099,5060],{"class":53},[32,5101,5102,5104,5106],{"class":34,"line":162},[32,5103,5065],{"class":53},[32,5105,5068],{"class":49},[32,5107,5060],{"class":53},[32,5109,5110,5112],{"class":34,"line":168},[32,5111,5075],{"class":53},[32,5113,5114],{"class":49}," '{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools\u002Fcall\",\"params\":{\"name\":\"list_items\",\"arguments\":{}}}'\n",[11,5116,5118],{"id":5117},"拓展接入鉴权","拓展：接入鉴权",[15,5120,5121],{},"裸奔的 MCP 端点任何人都能调用，生产环境必须加鉴权。思路和普通 API 鉴权一致：Bearer Token。",[280,5123,3685],{"id":3685},[15,5125,5126,5127,5130,5131,5134],{},"MCP 的工具处理器签名为 ",[29,5128,5129],{},"func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error)","，它不接收 Gin 的 ",[29,5132,5133],{},"*gin.Context","，所以无法直接在工具处理器里读取请求头。",[280,5136,5138],{"id":5137},"解决方案中间件传递用户信息","解决方案：中间件传递用户信息",[15,5140,5141,5142,2600],{},"用两层中间件：第一层做 Gin 的 Token 验证，第二层把用户信息从 Gin Context 复制到 ",[29,5143,5144],{},"request.Context",[22,5146,5148],{"className":4133,"code":5147,"language":171,"meta":27,"style":27},"\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",[29,5149,5150,5155,5183,5204,5224,5240,5273,5278,5283,5287,5312,5352,5381,5385,5389,5393,5413,5425,5454,5458,5462,5466,5481,5495,5504,5508],{"__ignoreMap":27},[32,5151,5152],{"class":34,"line":35},[32,5153,5154],{"class":38},"\u002F\u002F Gin 鉴权中间件\n",[32,5156,5157,5159,5162,5164,5167,5169,5172,5174,5176,5178,5181],{"class":34,"line":42},[32,5158,4334],{"class":130},[32,5160,5161],{"class":45}," AuthRequired",[32,5163,2709],{"class":66},[32,5165,5166],{"class":2712},"authService",[32,5168,4959],{"class":130},[32,5170,5171],{"class":45},"AuthService",[32,5173,3173],{"class":66},[32,5175,4962],{"class":45},[32,5177,2831],{"class":66},[32,5179,5180],{"class":45},"HandlerFunc",[32,5182,2349],{"class":66},[32,5184,5185,5187,5190,5192,5194,5196,5198,5200,5202],{"class":34,"line":85},[32,5186,4391],{"class":130},[32,5188,5189],{"class":130}," func",[32,5191,2709],{"class":66},[32,5193,4956],{"class":2712},[32,5195,4959],{"class":130},[32,5197,4962],{"class":45},[32,5199,2831],{"class":66},[32,5201,4347],{"class":45},[32,5203,3961],{"class":66},[32,5205,5206,5209,5211,5214,5217,5219,5222],{"class":34,"line":92},[32,5207,5208],{"class":66},"        authHeader ",[32,5210,4160],{"class":130},[32,5212,5213],{"class":66}," c.",[32,5215,5216],{"class":45},"GetHeader",[32,5218,2709],{"class":66},[32,5220,5221],{"class":49},"\"Authorization\"",[32,5223,3019],{"class":66},[32,5225,5226,5229,5232,5235,5238],{"class":34,"line":98},[32,5227,5228],{"class":130},"        if",[32,5230,5231],{"class":66}," authHeader ",[32,5233,5234],{"class":130},"==",[32,5236,5237],{"class":49}," \"\"",[32,5239,2349],{"class":66},[32,5241,5242,5245,5248,5250,5253,5255,5257,5259,5261,5263,5266,5268,5271],{"class":34,"line":127},[32,5243,5244],{"class":66},"            c.",[32,5246,5247],{"class":45},"AbortWithStatusJSON",[32,5249,2709],{"class":66},[32,5251,5252],{"class":53},"401",[32,5254,798],{"class":66},[32,5256,4962],{"class":45},[32,5258,2831],{"class":66},[32,5260,4990],{"class":45},[32,5262,4993],{"class":66},[32,5264,5265],{"class":49},"\"message\"",[32,5267,2413],{"class":66},[32,5269,5270],{"class":49},"\"缺少认证令牌\"",[32,5272,3257],{"class":66},[32,5274,5275],{"class":34,"line":146},[32,5276,5277],{"class":130},"            return\n",[32,5279,5280],{"class":34,"line":162},[32,5281,5282],{"class":66},"        }\n",[32,5284,5285],{"class":34,"line":168},[32,5286,89],{"emptyLinePlaceholder":88},[32,5288,5289,5292,5294,5297,5300,5303,5306,5308,5310],{"class":34,"line":182},[32,5290,5291],{"class":66},"        parts ",[32,5293,4160],{"class":130},[32,5295,5296],{"class":66}," strings.",[32,5298,5299],{"class":45},"SplitN",[32,5301,5302],{"class":66},"(authHeader, ",[32,5304,5305],{"class":49},"\" \"",[32,5307,798],{"class":66},[32,5309,2895],{"class":53},[32,5311,3019],{"class":66},[32,5313,5314,5316,5319,5322,5324,5327,5330,5333,5336,5339,5342,5344,5347,5350],{"class":34,"line":205},[32,5315,5228],{"class":130},[32,5317,5318],{"class":45}," len",[32,5320,5321],{"class":66},"(parts) ",[32,5323,4577],{"class":130},[32,5325,5326],{"class":53}," 2",[32,5328,5329],{"class":130}," ||",[32,5331,5332],{"class":130}," !",[32,5334,5335],{"class":66},"strings.",[32,5337,5338],{"class":45},"EqualFold",[32,5340,5341],{"class":66},"(parts[",[32,5343,2797],{"class":53},[32,5345,5346],{"class":66},"], ",[32,5348,5349],{"class":49},"\"Bearer\"",[32,5351,3961],{"class":66},[32,5353,5354,5356,5358,5360,5362,5364,5366,5368,5370,5372,5374,5376,5379],{"class":34,"line":214},[32,5355,5244],{"class":66},[32,5357,5247],{"class":45},[32,5359,2709],{"class":66},[32,5361,5252],{"class":53},[32,5363,798],{"class":66},[32,5365,4962],{"class":45},[32,5367,2831],{"class":66},[32,5369,4990],{"class":45},[32,5371,4993],{"class":66},[32,5373,5265],{"class":49},[32,5375,2413],{"class":66},[32,5377,5378],{"class":49},"\"格式错误，请使用 Bearer \u003Ctoken>\"",[32,5380,3257],{"class":66},[32,5382,5383],{"class":34,"line":219},[32,5384,5277],{"class":130},[32,5386,5387],{"class":34,"line":225},[32,5388,5282],{"class":66},[32,5390,5391],{"class":34,"line":236},[32,5392,89],{"emptyLinePlaceholder":88},[32,5394,5395,5398,5400,5403,5406,5408,5410],{"class":34,"line":247},[32,5396,5397],{"class":66},"        user, err ",[32,5399,4160],{"class":130},[32,5401,5402],{"class":66}," authService.",[32,5404,5405],{"class":45},"GetUserByToken",[32,5407,5341],{"class":66},[32,5409,156],{"class":53},[32,5411,5412],{"class":66},"])\n",[32,5414,5415,5417,5419,5421,5423],{"class":34,"line":252},[32,5416,5228],{"class":130},[32,5418,4574],{"class":66},[32,5420,4577],{"class":130},[32,5422,4580],{"class":53},[32,5424,2349],{"class":66},[32,5426,5427,5429,5431,5433,5435,5437,5439,5441,5443,5445,5447,5449,5452],{"class":34,"line":261},[32,5428,5244],{"class":66},[32,5430,5247],{"class":45},[32,5432,2709],{"class":66},[32,5434,5252],{"class":53},[32,5436,798],{"class":66},[32,5438,4962],{"class":45},[32,5440,2831],{"class":66},[32,5442,4990],{"class":45},[32,5444,4993],{"class":66},[32,5446,5265],{"class":49},[32,5448,2413],{"class":66},[32,5450,5451],{"class":49},"\"无效的认证令牌\"",[32,5453,3257],{"class":66},[32,5455,5456],{"class":34,"line":266},[32,5457,5277],{"class":130},[32,5459,5460],{"class":34,"line":476},[32,5461,5282],{"class":66},[32,5463,5464],{"class":34,"line":484},[32,5465,89],{"emptyLinePlaceholder":88},[32,5467,5468,5470,5473,5475,5478],{"class":34,"line":489},[32,5469,4973],{"class":66},[32,5471,5472],{"class":45},"Set",[32,5474,2709],{"class":66},[32,5476,5477],{"class":49},"\"userID\"",[32,5479,5480],{"class":66},", user.ID)\n",[32,5482,5483,5485,5487,5489,5492],{"class":34,"line":1769},[32,5484,4973],{"class":66},[32,5486,5472],{"class":45},[32,5488,2709],{"class":66},[32,5490,5491],{"class":49},"\"username\"",[32,5493,5494],{"class":66},", user.Username)\n",[32,5496,5497,5499,5502],{"class":34,"line":1783},[32,5498,4973],{"class":66},[32,5500,5501],{"class":45},"Next",[32,5503,2904],{"class":66},[32,5505,5506],{"class":34,"line":1790},[32,5507,2669],{"class":66},[32,5509,5510],{"class":34,"line":1795},[32,5511,2003],{"class":66},[22,5513,5515],{"className":4133,"code":5514,"language":171,"meta":27,"style":27},"\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",[29,5516,5517,5522,5533,5540,5553,5565,5569,5573,5591,5611,5625,5649,5671,5686,5694,5698],{"__ignoreMap":27},[32,5518,5519],{"class":34,"line":35},[32,5520,5521],{"class":38},"\u002F\u002F 用户信息桥接中间件：Gin Context → request.Context\n",[32,5523,5524,5527,5530],{"class":34,"line":42},[32,5525,5526],{"class":130},"type",[32,5528,5529],{"class":45}," ctxKey",[32,5531,5532],{"class":130}," string\n",[32,5534,5535,5537],{"class":34,"line":85},[32,5536,2943],{"class":130},[32,5538,5539],{"class":66}," (\n",[32,5541,5542,5545,5548,5550],{"class":34,"line":92},[32,5543,5544],{"class":53},"    ctxUserID",[32,5546,5547],{"class":45},"   ctxKey",[32,5549,2346],{"class":130},[32,5551,5552],{"class":49}," \"userID\"\n",[32,5554,5555,5558,5560,5562],{"class":34,"line":98},[32,5556,5557],{"class":53},"    ctxUsername",[32,5559,5529],{"class":45},[32,5561,2346],{"class":130},[32,5563,5564],{"class":49}," \"username\"\n",[32,5566,5567],{"class":34,"line":127},[32,5568,3019],{"class":66},[32,5570,5571],{"class":34,"line":146},[32,5572,89],{"emptyLinePlaceholder":88},[32,5574,5575,5577,5580,5583,5585,5587,5589],{"class":34,"line":162},[32,5576,4334],{"class":130},[32,5578,5579],{"class":45}," WithUserContext",[32,5581,5582],{"class":66},"() ",[32,5584,4962],{"class":45},[32,5586,2831],{"class":66},[32,5588,5180],{"class":45},[32,5590,2349],{"class":66},[32,5592,5593,5595,5597,5599,5601,5603,5605,5607,5609],{"class":34,"line":168},[32,5594,4391],{"class":130},[32,5596,5189],{"class":130},[32,5598,2709],{"class":66},[32,5600,4956],{"class":2712},[32,5602,4959],{"class":130},[32,5604,4962],{"class":45},[32,5606,2831],{"class":66},[32,5608,4347],{"class":45},[32,5610,3961],{"class":66},[32,5612,5613,5616,5618,5621,5623],{"class":34,"line":182},[32,5614,5615],{"class":66},"        ctx ",[32,5617,4160],{"class":130},[32,5619,5620],{"class":66}," c.Request.",[32,5622,4347],{"class":45},[32,5624,2904],{"class":66},[32,5626,5627,5629,5631,5634,5637,5640,5643,5645,5647],{"class":34,"line":205},[32,5628,5615],{"class":66},[32,5630,137],{"class":130},[32,5632,5633],{"class":66}," context.",[32,5635,5636],{"class":45},"WithValue",[32,5638,5639],{"class":66},"(ctx, ctxUserID, c.",[32,5641,5642],{"class":45},"GetUint",[32,5644,2709],{"class":66},[32,5646,5477],{"class":49},[32,5648,2872],{"class":66},[32,5650,5651,5653,5655,5657,5659,5662,5665,5667,5669],{"class":34,"line":214},[32,5652,5615],{"class":66},[32,5654,137],{"class":130},[32,5656,5633],{"class":66},[32,5658,5636],{"class":45},[32,5660,5661],{"class":66},"(ctx, ctxUsername, c.",[32,5663,5664],{"class":45},"GetString",[32,5666,2709],{"class":66},[32,5668,5491],{"class":49},[32,5670,2872],{"class":66},[32,5672,5673,5676,5678,5680,5683],{"class":34,"line":219},[32,5674,5675],{"class":66},"        c.Request ",[32,5677,137],{"class":130},[32,5679,5620],{"class":66},[32,5681,5682],{"class":45},"WithContext",[32,5684,5685],{"class":66},"(ctx)\n",[32,5687,5688,5690,5692],{"class":34,"line":225},[32,5689,4973],{"class":66},[32,5691,5501],{"class":45},[32,5693,2904],{"class":66},[32,5695,5696],{"class":34,"line":236},[32,5697,2669],{"class":66},[32,5699,5700],{"class":34,"line":247},[32,5701,2003],{"class":66},[280,5703,5704],{"id":5704},"组合使用",[22,5706,5708],{"className":4133,"code":5707,"language":171,"meta":27,"style":27},"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",[29,5709,5710,5722,5733,5744,5757],{"__ignoreMap":27},[32,5711,5712,5714,5716,5718,5720],{"class":34,"line":35},[32,5713,4729],{"class":66},[32,5715,4732],{"class":45},[32,5717,2709],{"class":66},[32,5719,4692],{"class":49},[32,5721,2360],{"class":66},[32,5723,5724,5727,5730],{"class":34,"line":42},[32,5725,5726],{"class":45},"    AuthRequired",[32,5728,5729],{"class":66},"(authService),  ",[32,5731,5732],{"class":38},"\u002F\u002F 1. 验证 Token\n",[32,5734,5735,5738,5741],{"class":34,"line":85},[32,5736,5737],{"class":45},"    WithUserContext",[32,5739,5740],{"class":66},"(),           ",[32,5742,5743],{"class":38},"\u002F\u002F 2. 桥接用户信息到 request.Context\n",[32,5745,5746,5749,5751,5754],{"class":34,"line":92},[32,5747,5748],{"class":66},"    gin.",[32,5750,4742],{"class":45},[32,5752,5753],{"class":66},"(mcpHTTP),         ",[32,5755,5756],{"class":38},"\u002F\u002F 3. MCP 处理\n",[32,5758,5759],{"class":34,"line":98},[32,5760,3019],{"class":66},[15,5762,5763],{},"在工具处理器里就能拿到用户信息了：",[22,5765,5767],{"className":4133,"code":5766,"language":171,"meta":27,"style":27},"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",[29,5768,5769,5824,5845,5854,5871,5875,5879,5895,5900,5913],{"__ignoreMap":27},[32,5770,5771,5773,5775,5778,5780,5783,5785,5788,5790,5792,5794,5796,5798,5800,5802,5804,5806,5808,5810,5812,5814,5816,5818,5820,5822],{"class":34,"line":35},[32,5772,4334],{"class":130},[32,5774,2751],{"class":66},[32,5776,5777],{"class":2712},"h ",[32,5779,4366],{"class":130},[32,5781,5782],{"class":45},"Handler",[32,5784,3173],{"class":66},[32,5786,5787],{"class":45},"handleListItems",[32,5789,2709],{"class":66},[32,5791,4339],{"class":2712},[32,5793,4342],{"class":45},[32,5795,2831],{"class":66},[32,5797,4347],{"class":45},[32,5799,798],{"class":66},[32,5801,4352],{"class":2712},[32,5803,4355],{"class":45},[32,5805,2831],{"class":66},[32,5807,4360],{"class":45},[32,5809,4363],{"class":66},[32,5811,4366],{"class":130},[32,5813,4369],{"class":45},[32,5815,2831],{"class":66},[32,5817,4374],{"class":45},[32,5819,798],{"class":66},[32,5821,4379],{"class":130},[32,5823,3961],{"class":66},[32,5825,5826,5829,5831,5834,5837,5840,5843],{"class":34,"line":42},[32,5827,5828],{"class":66},"    userID, ok ",[32,5830,4160],{"class":130},[32,5832,5833],{"class":66}," ctx.",[32,5835,5836],{"class":45},"Value",[32,5838,5839],{"class":66},"(ctxUserID).(",[32,5841,5842],{"class":130},"uint",[32,5844,3019],{"class":66},[32,5846,5847,5849,5851],{"class":34,"line":85},[32,5848,2786],{"class":130},[32,5850,5332],{"class":130},[32,5852,5853],{"class":66},"ok {\n",[32,5855,5856,5858,5860,5862,5864,5867,5869],{"class":34,"line":92},[32,5857,4587],{"class":130},[32,5859,4282],{"class":66},[32,5861,4592],{"class":45},[32,5863,2709],{"class":66},[32,5865,5866],{"class":49},"\"未认证\"",[32,5868,4404],{"class":66},[32,5870,4407],{"class":53},[32,5872,5873],{"class":34,"line":98},[32,5874,2669],{"class":66},[32,5876,5877],{"class":34,"line":127},[32,5878,89],{"emptyLinePlaceholder":88},[32,5880,5881,5884,5886,5889,5892],{"class":34,"line":146},[32,5882,5883],{"class":66},"    items, _ ",[32,5885,4160],{"class":130},[32,5887,5888],{"class":66}," h.service.",[32,5890,5891],{"class":45},"ListByUser",[32,5893,5894],{"class":66},"(userID)\n",[32,5896,5897],{"class":34,"line":162},[32,5898,5899],{"class":38},"    \u002F\u002F ...\n",[32,5901,5902,5904,5906,5908,5911],{"class":34,"line":168},[32,5903,4391],{"class":130},[32,5905,4282],{"class":66},[32,5907,4396],{"class":45},[32,5909,5910],{"class":66},"(result), ",[32,5912,4407],{"class":53},[32,5914,5915],{"class":34,"line":182},[32,5916,2003],{"class":66},[280,5918,5919],{"id":5919},"客户端配置",[15,5921,5922],{},"AI 客户端（如 Claude Desktop）配置 MCP 服务器时带上 Authorization 头：",[22,5924,5928],{"className":5925,"code":5926,"language":5927,"meta":27,"style":27},"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",[29,5929,5930,5935,5942,5948,5960,5967,5977,5982,5986,5990],{"__ignoreMap":27},[32,5931,5932],{"class":34,"line":35},[32,5933,5934],{"class":66},"{\n",[32,5936,5937,5940],{"class":34,"line":42},[32,5938,5939],{"class":53},"  \"mcpServers\"",[32,5941,2628],{"class":66},[32,5943,5944,5946],{"class":34,"line":85},[32,5945,4174],{"class":53},[32,5947,2628],{"class":66},[32,5949,5950,5953,5955,5958],{"class":34,"line":92},[32,5951,5952],{"class":53},"      \"url\"",[32,5954,2413],{"class":66},[32,5956,5957],{"class":49},"\"http:\u002F\u002Flocalhost:8080\u002Fmcp\"",[32,5959,2360],{"class":66},[32,5961,5962,5965],{"class":34,"line":98},[32,5963,5964],{"class":53},"      \"headers\"",[32,5966,2628],{"class":66},[32,5968,5969,5972,5974],{"class":34,"line":127},[32,5970,5971],{"class":53},"        \"Authorization\"",[32,5973,2413],{"class":66},[32,5975,5976],{"class":49},"\"Bearer your-token-here\"\n",[32,5978,5979],{"class":34,"line":146},[32,5980,5981],{"class":66},"      }\n",[32,5983,5984],{"class":34,"line":162},[32,5985,2669],{"class":66},[32,5987,5988],{"class":34,"line":168},[32,5989,2674],{"class":66},[32,5991,5992],{"class":34,"line":182},[32,5993,2003],{"class":66},[11,5995,1858],{"id":1858},[2033,5997,5998,6008],{},[2036,5999,6000],{},[2039,6001,6002,6005],{},[2042,6003,6004],{},"步骤",[2042,6006,6007],{},"说明",[2055,6009,6010,6019,6030,6039,6048],{},[2039,6011,6012,6016],{},[2060,6013,6014],{},[29,6015,4166],{},[2060,6017,6018],{},"创建 MCP 协议实例",[2039,6020,6021,6027],{},[2060,6022,6023,1875,6025],{},[29,6024,4285],{},[29,6026,4328],{},[2060,6028,6029],{},"定义并注册工具",[2039,6031,6032,6036],{},[2060,6033,6034],{},[29,6035,4677],{},[2060,6037,6038],{},"转为 HTTP Handler",[2039,6040,6041,6045],{},[2060,6042,6043],{},[29,6044,4645],{},[2060,6046,6047],{},"挂载到 Gin 路由",[2039,6049,6050,6058],{},[2060,6051,6052,1875,6055],{},[29,6053,6054],{},"AuthRequired",[29,6056,6057],{},"WithUserContext",[2060,6059,6060],{},"鉴权 + 用户信息桥接",[15,6062,6063,6064,6067,6068,2600],{},"核心就一句话：",[1948,6065,6066],{},"MCP 是 HTTP Handler，挂上去就行。"," 鉴权复用你已有的中间件体系，只需要多写一个桥接函数把用户信息传进 ",[29,6069,5144],{},[824,6071,6072],{},"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":27,"searchDepth":42,"depth":85,"links":6074},[6075,6076,6077,6078,6085,6091],{"id":4045,"depth":42,"text":4046},{"id":4055,"depth":42,"text":4056},{"id":4072,"depth":42,"text":4072},{"id":4098,"depth":42,"text":4098,"children":6079},[6080,6081,6082,6083,6084],{"id":4101,"depth":85,"text":4102},{"id":4129,"depth":85,"text":4130},{"id":4246,"depth":85,"text":4247},{"id":4634,"depth":85,"text":4635},{"id":4755,"depth":85,"text":4756},{"id":5117,"depth":42,"text":5118,"children":6086},[6087,6088,6089,6090],{"id":3685,"depth":85,"text":3685},{"id":5137,"depth":85,"text":5138},{"id":5704,"depth":85,"text":5704},{"id":5919,"depth":85,"text":5919},{"id":1858,"depth":42,"text":1858},"介绍如何使用 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":4040,"description":6092},"Go 工程实践","posts\u002Fgo-mcp-gin\u002Findex",[851,6101,6102,6103,6104],"MCP","Gin","AI Agent","后端","2026-06-01","5fZl1Vigs2lHb5fHPz-0YZFyFD38ws1heOouZUG1g6M",{"id":6108,"title":6109,"author":6,"body":6110,"description":6877,"difficulty":6,"draft":840,"extension":841,"image":6878,"meta":6879,"navigation":88,"path":6880,"pinned":840,"published":6881,"seo":6882,"series":6,"seriesOrder":6,"stem":6883,"tags":6884,"updated":6,"__hash__":6888},"posts\u002Fposts\u002Fdws-dingtalk-cli\u002Findex.md","DWS — 钉钉官方出品的 AI 友好命令行工具",{"type":8,"value":6111,"toc":6863},[6112,6116,6119,6126,6252,6255,6258,6314,6317,6332,6335,6338,6345,6577,6584,6588,6591,6611,6614,6629,6632,6652,6655,6658,6665,6731,6734,6737,6741,6744,6796,6800,6821,6825,6828,6842,6845,6849,6852,6854,6857,6860],[11,6113,6115],{"id":6114},"dws-是什么","dws 是什么",[15,6117,6118],{},"dws（DingTalk Workspace CLI）是阿里巴巴 2026 年开源的钉钉命令行工具，Apache-2.0 协议，Go 语言编写。",[15,6120,6121,6122,6125],{},"它的定位很明确：",[1948,6123,6124],{},"把钉钉的所有产品能力暴露给 AI Agent 和命令行用户","。不用打开钉钉 App，不用写 API 调用代码，一条命令搞定。",[22,6127,6129],{"className":24,"code":6128,"language":26,"meta":27,"style":27},"# 查看今天有什么会\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",[29,6130,6131,6136,6162,6166,6171,6193,6197,6202,6227,6231,6236],{"__ignoreMap":27},[32,6132,6133],{"class":34,"line":35},[32,6134,6135],{"class":38},"# 查看今天有什么会\n",[32,6137,6138,6141,6144,6147,6150,6153,6156,6159],{"class":34,"line":42},[32,6139,6140],{"class":45},"dws",[32,6142,6143],{"class":49}," calendar",[32,6145,6146],{"class":49}," event",[32,6148,6149],{"class":49}," list",[32,6151,6152],{"class":53}," --start",[32,6154,6155],{"class":49}," 2026-06-01",[32,6157,6158],{"class":53}," --end",[32,6160,6161],{"class":49}," 2026-06-01\n",[32,6163,6164],{"class":34,"line":85},[32,6165,89],{"emptyLinePlaceholder":88},[32,6167,6168],{"class":34,"line":92},[32,6169,6170],{"class":38},"# 给群里发条消息\n",[32,6172,6173,6175,6178,6181,6184,6187,6190],{"class":34,"line":98},[32,6174,6140],{"class":45},[32,6176,6177],{"class":49}," chat",[32,6179,6180],{"class":49}," send",[32,6182,6183],{"class":53}," --group-id",[32,6185,6186],{"class":49}," xxx",[32,6188,6189],{"class":53}," --content",[32,6191,6192],{"class":49}," \"今天发布推迟到下午3点\"\n",[32,6194,6195],{"class":34,"line":127},[32,6196,89],{"emptyLinePlaceholder":88},[32,6198,6199],{"class":34,"line":146},[32,6200,6201],{"class":38},"# 创建一个待办\n",[32,6203,6204,6206,6209,6212,6215,6218,6221,6224],{"class":34,"line":162},[32,6205,6140],{"class":45},[32,6207,6208],{"class":49}," todo",[32,6210,6211],{"class":49}," task",[32,6213,6214],{"class":49}," create",[32,6216,6217],{"class":53}," --subject",[32,6219,6220],{"class":49}," \"Review PR #123\"",[32,6222,6223],{"class":53}," --due-date",[32,6225,6226],{"class":49}," 2026-06-02\n",[32,6228,6229],{"class":34,"line":168},[32,6230,89],{"emptyLinePlaceholder":88},[32,6232,6233],{"class":34,"line":182},[32,6234,6235],{"class":38},"# 读取钉钉文档\n",[32,6237,6238,6240,6243,6246,6249],{"class":34,"line":205},[32,6239,6140],{"class":45},[32,6241,6242],{"class":49}," doc",[32,6244,6245],{"class":49}," read",[32,6247,6248],{"class":53}," --url",[32,6250,6251],{"class":49}," \"https:\u002F\u002Falidocs.dingtalk.com\u002Fi\u002Fnodes\u002Fxxx\"\n",[11,6253,6254],{"id":6254},"安装",[15,6256,6257],{},"三种方式，选你喜欢的：",[22,6259,6261],{"className":24,"code":6260,"language":26,"meta":27,"style":27},"# 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",[29,6262,6263,6268,6281,6285,6290,6300,6304,6309],{"__ignoreMap":27},[32,6264,6265],{"class":34,"line":35},[32,6266,6267],{"class":38},"# npm（推荐，跨平台自动下载二进制）\n",[32,6269,6270,6273,6275,6278],{"class":34,"line":42},[32,6271,6272],{"class":45},"npm",[32,6274,112],{"class":49},[32,6276,6277],{"class":53}," -g",[32,6279,6280],{"class":49}," dingtalk-workspace-cli\n",[32,6282,6283],{"class":34,"line":85},[32,6284,89],{"emptyLinePlaceholder":88},[32,6286,6287],{"class":34,"line":92},[32,6288,6289],{"class":38},"# Homebrew（macOS\u002FLinux）\n",[32,6291,6292,6295,6297],{"class":34,"line":98},[32,6293,6294],{"class":45},"brew",[32,6296,112],{"class":49},[32,6298,6299],{"class":49}," dws\n",[32,6301,6302],{"class":34,"line":127},[32,6303,89],{"emptyLinePlaceholder":88},[32,6305,6306],{"class":34,"line":146},[32,6307,6308],{"class":38},"# 直接下载二进制\n",[32,6310,6311],{"class":34,"line":162},[32,6312,6313],{"class":38},"# https:\u002F\u002Fgithub.com\u002Fopen-dingtalk\u002Fdingtalk-workspace-cli\u002Freleases\n",[15,6315,6316],{},"安装后认证：",[22,6318,6320],{"className":24,"code":6319,"language":26,"meta":27,"style":27},"dws auth login\n",[29,6321,6322],{"__ignoreMap":27},[32,6323,6324,6326,6329],{"class":34,"line":35},[32,6325,6140],{"class":45},[32,6327,6328],{"class":49}," auth",[32,6330,6331],{"class":49}," login\n",[15,6333,6334],{},"会打开浏览器走 OAuth 设备流认证，token 加密存储在系统钥匙串里（PBKDF2 + AES-256-GCM）。",[11,6336,6337],{"id":6337},"覆盖的钉钉产品",[15,6339,6340,6341,6344],{},"这是最夸张的部分 — ",[1948,6342,6343],{},"19 个产品，213 个命令","，基本把钉钉全家桶都搬到了命令行：",[2033,6346,6347,6359],{},[2036,6348,6349],{},[2039,6350,6351,6354,6357],{},[2042,6352,6353],{},"产品",[2042,6355,6356],{},"命令数",[2042,6358,6007],{},[2055,6360,6361,6375,6389,6403,6417,6431,6445,6459,6472,6486,6499,6513,6527,6540,6553,6566],{},[2039,6362,6363,6369,6372],{},[2060,6364,6365,6366],{},"聊天 ",[29,6367,6368],{},"chat",[2060,6370,6371],{},"57",[2060,6373,6374],{},"消息收发、群管理、机器人、已读状态、Webhook",[2039,6376,6377,6383,6386],{},[2060,6378,6379,6380],{},"AI 表格 ",[29,6381,6382],{},"aitable",[2060,6384,6385],{},"42",[2060,6387,6388],{},"Base\u002F数据表\u002F字段\u002F记录\u002F视图\u002F图表\u002F仪表盘，完整 CRUD",[2039,6390,6391,6397,6400],{},[2060,6392,6393,6394],{},"电子表格 ",[29,6395,6396],{},"sheet",[2060,6398,6399],{},"34",[2060,6401,6402],{},"工作表读写、行列操作、合并、筛选视图、导出",[2039,6404,6405,6411,6414],{},[2060,6406,6407,6408],{},"文档 ",[29,6409,6410],{},"doc",[2060,6412,6413],{},"21",[2060,6415,6416],{},"搜索、读写、块级编辑、评论、复制移动",[2039,6418,6419,6425,6428],{},[2060,6420,6421,6422],{},"会议纪要 ",[29,6423,6424],{},"minutes",[2060,6426,6427],{},"19",[2060,6429,6430],{},"列表、摘要、转写、思维导图、发言人、热词",[2039,6432,6433,6439,6442],{},[2060,6434,6435,6436],{},"日历 ",[29,6437,6438],{},"calendar",[2060,6440,6441],{},"14",[2060,6443,6444],{},"事件 CRUD、会议室、忙闲查询、时间建议",[2039,6446,6447,6453,6456],{},[2060,6448,6449,6450],{},"审批 ",[29,6451,6452],{},"oa",[2060,6454,6455],{},"9",[2060,6457,6458],{},"待办、发起、同意\u002F拒绝\u002F撤销",[2039,6460,6461,6467,6469],{},[2060,6462,6463,6464],{},"云盘 ",[29,6465,6466],{},"drive",[2060,6468,6455],{},[2060,6470,6471],{},"列表、下载、上传、创建目录",[2039,6473,6474,6480,6483],{},[2060,6475,6476,6477],{},"知识库 ",[29,6478,6479],{},"wiki",[2060,6481,6482],{},"7",[2060,6484,6485],{},"空间管理、成员管理",[2039,6487,6488,6494,6496],{},[2060,6489,6490,6491],{},"汇报 ",[29,6492,6493],{},"report",[2060,6495,6482],{},[2060,6497,6498],{},"日志\u002F日报\u002F周报，按模板创建、统计",[2039,6500,6501,6507,6510],{},[2060,6502,6503,6504],{},"待办 ",[29,6505,6506],{},"todo",[2060,6508,6509],{},"6",[2060,6511,6512],{},"创建、完成、删除，支持优先级和循环",[2039,6514,6515,6521,6524],{},[2060,6516,6517,6518],{},"邮件 ",[29,6519,6520],{},"mail",[2060,6522,6523],{},"4",[2060,6525,6526],{},"邮箱查询、邮件搜索、发送",[2039,6528,6529,6535,6537],{},[2060,6530,6531,6532],{},"考勤 ",[29,6533,6534],{},"attendance",[2060,6536,6523],{},[2060,6538,6539],{},"打卡记录、排班、汇总",[2039,6541,6542,6548,6550],{},[2060,6543,6544,6545],{},"通讯录 ",[29,6546,6547],{},"contact",[2060,6549,2658],{},[2060,6551,6552],{},"用户搜索、部门查询",[2039,6554,6555,6561,6563],{},[2060,6556,6557,6558],{},"DING 消息 ",[29,6559,6560],{},"ding",[2060,6562,2895],{},[2060,6564,6565],{},"发送\u002F撤回（应用内、短信、电话）",[2039,6567,6568,6571,6574],{},[2060,6569,6570],{},"其他",[2060,6572,6573],{},"若干",[2060,6575,6576],{},"AI 搜索、AI 应用、直播、开放平台文档",[15,6578,6579,6580,6583],{},"还有一个万能命令 ",[29,6581,6582],{},"dws api","，可以直接调用任意钉钉 OpenAPI，不受这 213 个命令的限制。",[11,6585,6587],{"id":6586},"agent-skill-系统","Agent Skill 系统",[15,6589,6590],{},"dws 内置了一套完整的 Agent Skill，可以直接被 AI 编程工具发现和使用：",[2256,6592,6593,6599,6605],{},[287,6594,6595,6598],{},[1948,6596,6597],{},"Claude Code"," — 通过 SKILL.md 自动加载",[287,6600,6601,6604],{},[1948,6602,6603],{},"Cursor"," — 支持 MCP 协议接入",[287,6606,6607,6610],{},[1948,6608,6609],{},"Codex \u002F OpenCode \u002F Qoder"," — 通过 skill 安装命令接入",[15,6612,6613],{},"安装 skill：",[22,6615,6617],{"className":24,"code":6616,"language":26,"meta":27,"style":27},"dws skill setup\n",[29,6618,6619],{"__ignoreMap":27},[32,6620,6621,6623,6626],{"class":34,"line":35},[32,6622,6140],{"class":45},[32,6624,6625],{"class":49}," skill",[32,6627,6628],{"class":49}," setup\n",[15,6630,6631],{},"SKILL.md 里定义了：",[2256,6633,6634,6643,6646,6649],{},[287,6635,6636,6637,6639,6640,6642],{},"16 个产品的意图判断决策树（用户说\"表格\"→ ",[29,6638,6382],{},"，说\"打卡\"→ ",[29,6641,6534],{},"）",[287,6644,6645],{},"危险操作确认流程（删除、撤回等必须先确认）",[287,6647,6648],{},"错误处理规范（verbose 重试、recovery 事件 ID）",[287,6650,6651],{},"URL 分流规则（alidocs.dingtalk.com 的多种路径格式自动识别）",[15,6653,6654],{},"Agent 读到这份 SKILL.md 就知道什么时候该调什么命令，不需要额外的 prompt 工程。",[11,6656,6657],{"id":6657},"自动升级",[15,6659,6660,6661,6664],{},"内置 ",[29,6662,6663],{},"dws upgrade","，不需要重新下载安装包：",[22,6666,6668],{"className":24,"code":6667,"language":26,"meta":27,"style":27},"dws upgrade              # 交互式升级到最新版\ndws upgrade --check      # 仅检查是否有新版本\ndws upgrade --list       # 列出最近版本\ndws upgrade --version v1.0.5  # 升级到指定版本\ndws upgrade --rollback   # 回滚到上一版本\n",[29,6669,6670,6680,6692,6704,6719],{"__ignoreMap":27},[32,6671,6672,6674,6677],{"class":34,"line":35},[32,6673,6140],{"class":45},[32,6675,6676],{"class":49}," upgrade",[32,6678,6679],{"class":38},"              # 交互式升级到最新版\n",[32,6681,6682,6684,6686,6689],{"class":34,"line":42},[32,6683,6140],{"class":45},[32,6685,6676],{"class":49},[32,6687,6688],{"class":53}," --check",[32,6690,6691],{"class":38},"      # 仅检查是否有新版本\n",[32,6693,6694,6696,6698,6701],{"class":34,"line":85},[32,6695,6140],{"class":45},[32,6697,6676],{"class":49},[32,6699,6700],{"class":53}," --list",[32,6702,6703],{"class":38},"       # 列出最近版本\n",[32,6705,6706,6708,6710,6713,6716],{"class":34,"line":92},[32,6707,6140],{"class":45},[32,6709,6676],{"class":49},[32,6711,6712],{"class":53}," --version",[32,6714,6715],{"class":49}," v1.0.5",[32,6717,6718],{"class":38},"  # 升级到指定版本\n",[32,6720,6721,6723,6725,6728],{"class":34,"line":98},[32,6722,6140],{"class":45},[32,6724,6676],{"class":49},[32,6726,6727],{"class":53}," --rollback",[32,6729,6730],{"class":38},"   # 回滚到上一版本\n",[15,6732,6733],{},"升级流程：下载 → SHA256 校验 → 备份当前版本 → 原子替换 → 更新 Skill。失败可一键回滚。",[11,6735,6736],{"id":6736},"亮点",[280,6738,6740],{"id":6739},"_1-为-ai-agent-专门优化","1. 为 AI Agent 专门优化",[15,6742,6743],{},"这可能是目前对 AI 最友好的 CLI 工具：",[2256,6745,6746,6767,6777,6786],{},[287,6747,6748,6751,6752,6755,6756,6759,6760,6755,6763,6766],{},[1948,6749,6750],{},"智能纠错"," — AI 模型经常生成错误的参数格式（",[29,6753,6754],{},"--userId"," 应该是 ",[29,6757,6758],{},"--user-id","，",[29,6761,6762],{},"--limit100",[29,6764,6765],{},"--limit 100","），dws 的 5 阶段 Pipeline 会自动修正",[287,6768,6769,6772,6773,6776],{},[1948,6770,6771],{},"Schema 发现"," — ",[29,6774,6775],{},"dws schema"," 让 Agent 动态查询可用工具和参数，不需要硬编码",[287,6778,6779,6772,6782,6785],{},[1948,6780,6781],{},"jq 过滤",[29,6783,6784],{},"--jq '.data[] | {name, id}'"," 直接过滤输出，减少 Agent 消耗的 token",[287,6787,6788,6791,6792,6795],{},[1948,6789,6790],{},"JSON 输出"," — 所有命令支持 ",[29,6793,6794],{},"--format json","，方便程序解析",[280,6797,6799],{"id":6798},"_2-安全设计","2. 安全设计",[2256,6801,6802,6805,6808,6811,6818],{},[287,6803,6804],{},"token 用 PBKDF2 + AES-256-GCM 加密，密钥绑定设备 MAC 地址",[287,6806,6807],{},"域名白名单，只能访问钉钉官方 API",[287,6809,6810],{},"强制 HTTPS",[287,6812,6813,6814,6817],{},"写操作必须显式 ",[29,6815,6816],{},"--yes"," 确认",[287,6819,6820],{},"完整审计链",[280,6822,6824],{"id":6823},"_3-发现驱动架构","3. 发现驱动架构",[15,6826,6827],{},"不是把所有命令硬编码在代码里，而是：",[284,6829,6830,6833,6836,6839],{},[287,6831,6832],{},"从钉钉 MCP 服务器注册中心拉取元数据",[287,6834,6835],{},"运行时发现服务端能力",[287,6837,6838],{},"转换成统一的中间表示（IR）",[287,6840,6841],{},"动态挂载到命令树",[15,6843,6844],{},"这意味着钉钉新增产品能力时，dws 不需要发版就能支持。",[280,6846,6848],{"id":6847},"_4-多平台分发","4. 多平台分发",[15,6850,6851],{},"GoReleaser 交叉编译 + Homebrew formula + npm 包 + shell\u002FPowerShell 安装脚本，覆盖 Windows、macOS、Linux。",[11,6853,1858],{"id":1858},[15,6855,6856],{},"dws 把钉钉从一个\"必须打开 App 才能用\"的工具变成了一个\"命令行 + AI Agent 随时调用\"的平台。213 个命令覆盖日常办公的方方面面，Agent Skill 系统让 AI 编程工具可以直接理解和使用这些能力。",[15,6858,6859],{},"如果你是钉钉重度用户，或者在做企业内部的 AI Agent 集成，dws 值得一试。",[824,6861,6862],{},"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":27,"searchDepth":42,"depth":85,"links":6864},[6865,6866,6867,6868,6869,6870,6876],{"id":6114,"depth":42,"text":6115},{"id":6254,"depth":42,"text":6254},{"id":6337,"depth":42,"text":6337},{"id":6586,"depth":42,"text":6587},{"id":6657,"depth":42,"text":6657},{"id":6736,"depth":42,"text":6736,"children":6871},[6872,6873,6874,6875],{"id":6739,"depth":85,"text":6740},{"id":6798,"depth":85,"text":6799},{"id":6823,"depth":85,"text":6824},{"id":6847,"depth":85,"text":6848},{"id":1858,"depth":42,"text":1858},"介绍阿里巴巴官方的 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":6109,"description":6877},"posts\u002Fdws-dingtalk-cli\u002Findex",[6885,6886,6103,851,6887],"钉钉","CLI","效率工具","4ca65VUZ2Sz9yG8dNtftrBDDHE6QowA3ed9g4egQkKI",{"id":6890,"title":6891,"author":6,"body":6892,"description":7855,"difficulty":839,"draft":840,"extension":841,"image":7856,"meta":7857,"navigation":88,"path":7858,"pinned":840,"published":7859,"seo":7860,"series":6098,"seriesOrder":42,"stem":7861,"tags":7862,"updated":7866,"__hash__":7867},"posts\u002Fposts\u002Fgo-embed-frontend\u002Findex.md","Go 单文件打包实战：把前端塞进二进制",{"type":8,"value":6893,"toc":7838},[6894,6896,6903,6906,6909,6920,6923,6927,7260,7264,7372,7376,7510,7513,7517,7526,7543,7554,7573,7587,7593,7601,7700,7706,7710,7717,7726,7729,7765,7768,7773,7812,7814,7820,7832,7835],[11,6895,2576],{"id":2576},[15,6897,6898,6899,6902],{},"一个 Gin + Vue 3 的全栈项目，之前的发布方式是：一个 Go 二进制 + 一个 ",[29,6900,6901],{},"web\u002F"," 前端静态目录，必须放一起才能运行。",[15,6904,6905],{},"为了让部署更丝滑——一个 exe 拖哪都能跑——决定把前端构建产物嵌入到 Go 二进制里。",[11,6907,6908],{"id":6908},"核心思路",[15,6910,6911,6912,6915,6916,6919],{},"利用 Go 1.16 引入的 ",[29,6913,6914],{},"\u002F\u002Fgo:embed"," 指令，在编译时将前端 ",[29,6917,6918],{},"dist\u002F"," 目录嵌入二进制，运行时通过嵌入的文件系统提供 SPA 服务。",[11,6921,6922],{"id":6922},"逐步实现",[280,6924,6926],{"id":6925},"_1-创建嵌入模块","1. 创建嵌入模块",[22,6928,6930],{"className":4133,"code":6929,"language":171,"meta":27,"style":27},"\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",[29,6931,6932,6937,6945,6949,6955,6965,6974,6983,6992,7001,7010,7014,7023,7027,7031,7036,7051,7055,7072,7093,7097,7117,7137,7150,7160,7164,7168,7183,7195,7200,7219,7223,7227,7250,7255],{"__ignoreMap":27},[32,6933,6934],{"class":34,"line":35},[32,6935,6936],{"class":38},"\u002F\u002F service\u002Fweb\u002Fembed.go\n",[32,6938,6939,6942],{"class":34,"line":42},[32,6940,6941],{"class":130},"package",[32,6943,6944],{"class":45}," web\n",[32,6946,6947],{"class":34,"line":85},[32,6948,89],{"emptyLinePlaceholder":88},[32,6950,6951,6953],{"class":34,"line":92},[32,6952,4141],{"class":130},[32,6954,5539],{"class":66},[32,6956,6957,6960,6963],{"class":34,"line":98},[32,6958,6959],{"class":49},"    \"",[32,6961,6962],{"class":45},"embed",[32,6964,278],{"class":49},[32,6966,6967,6969,6972],{"class":34,"line":127},[32,6968,6959],{"class":49},[32,6970,6971],{"class":45},"io\u002Ffs",[32,6973,278],{"class":49},[32,6975,6976,6978,6981],{"class":34,"line":146},[32,6977,6959],{"class":49},[32,6979,6980],{"class":45},"mime",[32,6982,278],{"class":49},[32,6984,6985,6987,6990],{"class":34,"line":162},[32,6986,6959],{"class":49},[32,6988,6989],{"class":45},"net\u002Fhttp",[32,6991,278],{"class":49},[32,6993,6994,6996,6999],{"class":34,"line":168},[32,6995,6959],{"class":49},[32,6997,6998],{"class":45},"path\u002Ffilepath",[32,7000,278],{"class":49},[32,7002,7003,7005,7008],{"class":34,"line":182},[32,7004,6959],{"class":49},[32,7006,7007],{"class":45},"strings",[32,7009,278],{"class":49},[32,7011,7012],{"class":34,"line":205},[32,7013,89],{"emptyLinePlaceholder":88},[32,7015,7016,7018,7021],{"class":34,"line":214},[32,7017,6959],{"class":49},[32,7019,7020],{"class":45},"github.com\u002Fgin-gonic\u002Fgin",[32,7022,278],{"class":49},[32,7024,7025],{"class":34,"line":219},[32,7026,3019],{"class":66},[32,7028,7029],{"class":34,"line":225},[32,7030,89],{"emptyLinePlaceholder":88},[32,7032,7033],{"class":34,"line":236},[32,7034,7035],{"class":38},"\u002F\u002Fgo:embed dist\n",[32,7037,7038,7041,7044,7046,7048],{"class":34,"line":247},[32,7039,7040],{"class":130},"var",[32,7042,7043],{"class":66}," embedFS ",[32,7045,6962],{"class":45},[32,7047,2831],{"class":66},[32,7049,7050],{"class":45},"FS\n",[32,7052,7053],{"class":34,"line":252},[32,7054,89],{"emptyLinePlaceholder":88},[32,7056,7057,7059,7062,7064,7066,7068,7070],{"class":34,"line":261},[32,7058,4334],{"class":130},[32,7060,7061],{"class":45}," SPAHandler",[32,7063,5582],{"class":66},[32,7065,4962],{"class":45},[32,7067,2831],{"class":66},[32,7069,5180],{"class":45},[32,7071,2349],{"class":66},[32,7073,7074,7077,7079,7082,7085,7088,7091],{"class":34,"line":266},[32,7075,7076],{"class":66},"    sub, _ ",[32,7078,4160],{"class":130},[32,7080,7081],{"class":66}," fs.",[32,7083,7084],{"class":45},"Sub",[32,7086,7087],{"class":66},"(embedFS, ",[32,7089,7090],{"class":49},"\"dist\"",[32,7092,3019],{"class":66},[32,7094,7095],{"class":34,"line":476},[32,7096,89],{"emptyLinePlaceholder":88},[32,7098,7099,7101,7103,7105,7107,7109,7111,7113,7115],{"class":34,"line":484},[32,7100,4391],{"class":130},[32,7102,5189],{"class":130},[32,7104,2709],{"class":66},[32,7106,4956],{"class":2712},[32,7108,4959],{"class":130},[32,7110,4962],{"class":45},[32,7112,2831],{"class":66},[32,7114,4347],{"class":45},[32,7116,3961],{"class":66},[32,7118,7119,7122,7124,7126,7129,7132,7135],{"class":34,"line":489},[32,7120,7121],{"class":66},"        p ",[32,7123,4160],{"class":130},[32,7125,5296],{"class":66},[32,7127,7128],{"class":45},"TrimPrefix",[32,7130,7131],{"class":66},"(c.Request.URL.Path, ",[32,7133,7134],{"class":49},"\"\u002F\"",[32,7136,3019],{"class":66},[32,7138,7139,7141,7144,7146,7148],{"class":34,"line":1769},[32,7140,5228],{"class":130},[32,7142,7143],{"class":66}," p ",[32,7145,5234],{"class":130},[32,7147,5237],{"class":49},[32,7149,2349],{"class":66},[32,7151,7152,7155,7157],{"class":34,"line":1783},[32,7153,7154],{"class":66},"            p ",[32,7156,137],{"class":130},[32,7158,7159],{"class":49}," \"index.html\"\n",[32,7161,7162],{"class":34,"line":1790},[32,7163,5282],{"class":66},[32,7165,7166],{"class":34,"line":1795},[32,7167,89],{"emptyLinePlaceholder":88},[32,7169,7170,7173,7175,7177,7180],{"class":34,"line":1801},[32,7171,7172],{"class":66},"        data, err ",[32,7174,4160],{"class":130},[32,7176,7081],{"class":66},[32,7178,7179],{"class":45},"ReadFile",[32,7181,7182],{"class":66},"(sub, p)\n",[32,7184,7185,7187,7189,7191,7193],{"class":34,"line":1809},[32,7186,5228],{"class":130},[32,7188,4574],{"class":66},[32,7190,4577],{"class":130},[32,7192,4580],{"class":53},[32,7194,2349],{"class":66},[32,7196,7197],{"class":34,"line":1817},[32,7198,7199],{"class":38},"            \u002F\u002F SPA fallback：文件不存在则返回 index.html\n",[32,7201,7202,7205,7207,7209,7211,7214,7217],{"class":34,"line":1822},[32,7203,7204],{"class":66},"            data, _ ",[32,7206,137],{"class":130},[32,7208,7081],{"class":66},[32,7210,7179],{"class":45},[32,7212,7213],{"class":66},"(sub, ",[32,7215,7216],{"class":49},"\"index.html\"",[32,7218,3019],{"class":66},[32,7220,7221],{"class":34,"line":1828},[32,7222,5282],{"class":66},[32,7224,7225],{"class":34,"line":1837},[32,7226,89],{"emptyLinePlaceholder":88},[32,7228,7230,7232,7235,7238,7241,7244,7247],{"class":34,"line":7229},33,[32,7231,4973],{"class":66},[32,7233,7234],{"class":45},"Data",[32,7236,7237],{"class":66},"(http.StatusOK, mime.",[32,7239,7240],{"class":45},"TypeByExtension",[32,7242,7243],{"class":66},"(filepath.",[32,7245,7246],{"class":45},"Ext",[32,7248,7249],{"class":66},"(p)), data)\n",[32,7251,7253],{"class":34,"line":7252},34,[32,7254,2669],{"class":66},[32,7256,7258],{"class":34,"line":7257},35,[32,7259,2003],{"class":66},[280,7261,7263],{"id":7262},"_2-改造路由","2. 改造路由",[22,7265,7267],{"className":4133,"code":7266,"language":171,"meta":27,"style":27},"\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",[29,7268,7269,7274,7285,7289,7294,7309,7323,7336,7349,7362],{"__ignoreMap":27},[32,7270,7271],{"class":34,"line":35},[32,7272,7273],{"class":38},"\u002F\u002F router.go\n",[32,7275,7276,7278,7280,7283],{"class":34,"line":42},[32,7277,4141],{"class":130},[32,7279,63],{"class":49},[32,7281,7282],{"class":45},"myproject\u002Fweb",[32,7284,278],{"class":49},[32,7286,7287],{"class":34,"line":85},[32,7288,89],{"emptyLinePlaceholder":88},[32,7290,7291],{"class":34,"line":92},[32,7292,7293],{"class":38},"\u002F\u002F 替换原来的磁盘静态文件服务\n",[32,7295,7296,7299,7301,7304,7307],{"class":34,"line":98},[32,7297,7298],{"class":66},"webHandler ",[32,7300,4160],{"class":130},[32,7302,7303],{"class":66}," web.",[32,7305,7306],{"class":45},"SPAHandler",[32,7308,2904],{"class":66},[32,7310,7311,7314,7316,7318,7320],{"class":34,"line":127},[32,7312,7313],{"class":66},"router.",[32,7315,4942],{"class":45},[32,7317,2709],{"class":66},[32,7319,7134],{"class":49},[32,7321,7322],{"class":66},", webHandler)\n",[32,7324,7325,7327,7329,7331,7334],{"class":34,"line":146},[32,7326,7313],{"class":66},[32,7328,4942],{"class":45},[32,7330,2709],{"class":66},[32,7332,7333],{"class":49},"\"\u002Fassets\u002F*filepath\"",[32,7335,7322],{"class":66},[32,7337,7338,7340,7342,7344,7347],{"class":34,"line":162},[32,7339,7313],{"class":66},[32,7341,4942],{"class":45},[32,7343,2709],{"class":66},[32,7345,7346],{"class":49},"\"\u002Ffavicon.ico\"",[32,7348,7322],{"class":66},[32,7350,7351,7353,7355,7357,7360],{"class":34,"line":168},[32,7352,7313],{"class":66},[32,7354,4942],{"class":45},[32,7356,2709],{"class":66},[32,7358,7359],{"class":49},"\"\u002Ffavicon.svg\"",[32,7361,7322],{"class":66},[32,7363,7364,7366,7369],{"class":34,"line":182},[32,7365,7313],{"class":66},[32,7367,7368],{"class":45},"NoRoute",[32,7370,7371],{"class":66},"(webHandler)\n",[280,7373,7375],{"id":7374},"_3-一键构建脚本","3. 一键构建脚本",[22,7377,7379],{"className":24,"code":7378,"language":26,"meta":27,"style":27},"#!\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",[29,7380,7381,7386,7391,7395,7403,7407,7412,7422,7432,7436,7441,7452,7465,7469,7474,7482,7495,7505],{"__ignoreMap":27},[32,7382,7383],{"class":34,"line":35},[32,7384,7385],{"class":38},"#!\u002Fbin\u002Fbash\n",[32,7387,7388],{"class":34,"line":42},[32,7389,7390],{"class":38},"# build-standalone.sh\n",[32,7392,7393],{"class":34,"line":85},[32,7394,89],{"emptyLinePlaceholder":88},[32,7396,7397,7400],{"class":34,"line":92},[32,7398,7399],{"class":53},"set",[32,7401,7402],{"class":53}," -e\n",[32,7404,7405],{"class":34,"line":98},[32,7406,89],{"emptyLinePlaceholder":88},[32,7408,7409],{"class":34,"line":127},[32,7410,7411],{"class":38},"# 1. 构建前端\n",[32,7413,7414,7417,7419],{"class":34,"line":146},[32,7415,7416],{"class":45},"pnpm",[32,7418,112],{"class":49},[32,7420,7421],{"class":53}," --no-frozen-lockfile\n",[32,7423,7424,7426,7429],{"class":34,"line":162},[32,7425,2474],{"class":45},[32,7427,7428],{"class":49}," vite",[32,7430,7431],{"class":49}," build\n",[32,7433,7434],{"class":34,"line":168},[32,7435,89],{"emptyLinePlaceholder":88},[32,7437,7438],{"class":34,"line":182},[32,7439,7440],{"class":38},"# 2. 复制到嵌入目录\n",[32,7442,7443,7446,7449],{"class":34,"line":205},[32,7444,7445],{"class":45},"rm",[32,7447,7448],{"class":53}," -rf",[32,7450,7451],{"class":49}," service\u002Fweb\u002Fdist\n",[32,7453,7454,7457,7460,7463],{"class":34,"line":214},[32,7455,7456],{"class":45},"cp",[32,7458,7459],{"class":53}," -r",[32,7461,7462],{"class":49}," dist",[32,7464,7451],{"class":49},[32,7466,7467],{"class":34,"line":219},[32,7468,89],{"emptyLinePlaceholder":88},[32,7470,7471],{"class":34,"line":225},[32,7472,7473],{"class":38},"# 3. 编译 Go 二进制\n",[32,7475,7476,7479],{"class":34,"line":236},[32,7477,7478],{"class":53},"cd",[32,7480,7481],{"class":49}," service\n",[32,7483,7484,7486,7488,7490,7493],{"class":34,"line":247},[32,7485,171],{"class":45},[32,7487,187],{"class":49},[32,7489,196],{"class":53},[32,7491,7492],{"class":49}," myapp.exe",[32,7494,5060],{"class":53},[32,7496,7497,7500,7503],{"class":34,"line":252},[32,7498,7499],{"class":53},"    -ldflags=",[32,7501,7502],{"class":49},"\"-X myproject\u002Fglobal.RUNCODE=release\"",[32,7504,5060],{"class":53},[32,7506,7507],{"class":34,"line":261},[32,7508,7509],{"class":49},"    main.go\n",[11,7511,7512],{"id":7512},"踩坑记录",[280,7514,7516],{"id":7515},"坑一路由不匹配静态资源-404","坑一：路由不匹配，静态资源 404",[15,7518,7519,7520,7522,7523,7525],{},"首次实现时用了 Gin 的 ",[29,7521,7368],{}," 兜底，但部分静态资源返回 404。排查发现 ",[29,7524,7368],{}," 在某些路由未显式注册时不会按预期触发。",[15,7527,7528,7531,7532,7535,7536,7539,7540,7542],{},[1948,7529,7530],{},"解决","：显式注册 ",[29,7533,7534],{},"\u002Fassets\u002F*filepath","、",[29,7537,7538],{},"\u002Ffavicon.ico"," 等路由，",[29,7541,7368],{}," 仅作为兜底。",[280,7544,7546,7547,7549,7550,7553],{"id":7545},"坑二goembed-排除-_-开头文件","坑二：",[29,7548,6914],{}," 排除 ",[29,7551,7552],{},"_"," 开头文件",[15,7555,7556,7557,7560,7561,7563,7564,2600],{},"这是最坑的。Vite 构建产出了一个 ",[29,7558,7559],{},"_plugin-vue_export-helper-xxx.js","，Go 的 ",[29,7562,6914],{}," 当模式为目录时，",[1948,7565,7566,7567,7569,7570,7572],{},"硬编码排除了 ",[29,7568,7552],{}," 和 ",[29,7571,2831],{}," 开头的文件",[22,7574,7576],{"className":4133,"code":7575,"language":171,"meta":27,"style":27},"\u002F\u002F 这行会跳过 dist\u002Fassets\u002F_plugin-xxx.js\n\u002F\u002Fgo:embed dist\n",[29,7577,7578,7583],{"__ignoreMap":27},[32,7579,7580],{"class":34,"line":35},[32,7581,7582],{"class":38},"\u002F\u002F 这行会跳过 dist\u002Fassets\u002F_plugin-xxx.js\n",[32,7584,7585],{"class":34,"line":42},[32,7586,7035],{"class":38},[15,7588,7589,7590,2600],{},"错误表现：JS 文件返回的是 HTML 内容（SPA fallback），浏览器报 ",[29,7591,7592],{},"Unexpected token '\u003C'",[15,7594,7595,7597,7598,7600],{},[1948,7596,7530],{},"：改 Vite 配置，去除了 ",[29,7599,7552],{}," 前缀：",[22,7602,7606],{"className":7603,"code":7604,"language":7605,"meta":27,"style":27},"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",[29,7607,7608,7613,7620,7627,7634,7642,7680,7685,7690,7695],{"__ignoreMap":27},[32,7609,7610],{"class":34,"line":35},[32,7611,7612],{"class":38},"\u002F\u002F vite.config.ts\n",[32,7614,7615,7618],{"class":34,"line":42},[32,7616,7617],{"class":45},"build",[32,7619,2628],{"class":66},[32,7621,7622,7625],{"class":34,"line":85},[32,7623,7624],{"class":45},"  rollupOptions",[32,7626,2628],{"class":66},[32,7628,7629,7632],{"class":34,"line":92},[32,7630,7631],{"class":45},"    output",[32,7633,2628],{"class":66},[32,7635,7636,7639],{"class":34,"line":98},[32,7637,7638],{"class":45},"      chunkFileNames",[32,7640,7641],{"class":66},"(chunkInfo) {\n",[32,7643,7644,7646,7649,7652,7654,7657,7659,7661,7663,7665,7667,7669,7671,7673,7675,7677],{"class":34,"line":127},[32,7645,4587],{"class":130},[32,7647,7648],{"class":49}," `assets\u002F${",[32,7650,7651],{"class":66},"chunkInfo",[32,7653,2831],{"class":49},[32,7655,7656],{"class":66},"name",[32,7658,2831],{"class":49},[32,7660,3209],{"class":45},[32,7662,2709],{"class":49},[32,7664,2828],{"class":49},[32,7666,2815],{"class":130},[32,7668,7552],{"class":2818},[32,7670,2828],{"class":49},[32,7672,798],{"class":49},[32,7674,3226],{"class":49},[32,7676,3530],{"class":49},[32,7678,7679],{"class":49},"}-[hash].js`\n",[32,7681,7682],{"class":34,"line":146},[32,7683,7684],{"class":66},"      },\n",[32,7686,7687],{"class":34,"line":162},[32,7688,7689],{"class":66},"    },\n",[32,7691,7692],{"class":34,"line":168},[32,7693,7694],{"class":66},"  },\n",[32,7696,7697],{"class":34,"line":182},[32,7698,7699],{"class":66},"},\n",[15,7701,7702,7703,7705],{},"这样就避免产生 ",[29,7704,7552],{}," 前缀文件名的同时，保持构建产物的一致性和引用关系的正确性。",[280,7707,7709],{"id":7708},"坑三构建缓存导致-embed-内容未更新","坑三：构建缓存导致 embed 内容未更新",[15,7711,7712,7713,7716],{},"多次 ",[29,7714,7715],{},"go build"," 后，Go 的构建缓存可能使用了旧版本的 embed 内容，导致实际运行的文件不是最新的。",[15,7718,7719,7721,7722,7725],{},[1948,7720,7530],{},"：使用 ",[29,7723,7724],{},"go build -a"," 强制重新编译所有包。",[11,7727,7728],{"id":7728},"最终效果",[22,7730,7732],{"className":24,"code":7731,"language":26,"meta":27,"style":27},"$ bash build-standalone.sh\n=== 构建成功 ===\n输出: service\u002Fmyapp.exe  41M\n",[29,7733,7734,7744,7754],{"__ignoreMap":27},[32,7735,7736,7738,7741],{"class":34,"line":35},[32,7737,2825],{"class":45},[32,7739,7740],{"class":49}," bash",[32,7742,7743],{"class":49}," build-standalone.sh\n",[32,7745,7746,7748,7751],{"class":34,"line":42},[32,7747,2803],{"class":49},[32,7749,7750],{"class":49}," 构建成功",[32,7752,7753],{"class":49}," ===\n",[32,7755,7756,7759,7762],{"class":34,"line":85},[32,7757,7758],{"class":45},"输出:",[32,7760,7761],{"class":49}," service\u002Fmyapp.exe",[32,7763,7764],{"class":49},"  41M\n",[15,7766,7767],{},"一个 41MB 的 exe，内置前端页面、API 后端、SQLite 数据库（运行时初始化），拖到哪都能跑。",[15,7769,7770],{},[1948,7771,7772],{},"部署对比：",[2033,7774,7775,7788],{},[2036,7776,7777],{},[2039,7778,7779,7782,7785],{},[2042,7780,7781],{},"方式",[2042,7783,7784],{},"文件数",[2042,7786,7787],{},"操作",[2055,7789,7790,7801],{},[2039,7791,7792,7795,7798],{},[2060,7793,7794],{},"旧方案",[2060,7796,7797],{},"1 exe + 1 web 目录",[2060,7799,7800],{},"解压→保持目录结构",[2039,7802,7803,7806,7809],{},[2060,7804,7805],{},"新方案",[2060,7807,7808],{},"1 个 exe",[2060,7810,7811],{},"直接扔到服务器运行",[11,7813,1858],{"id":1858},[15,7815,7816,7817,7819],{},"Go 的 ",[29,7818,6914],{}," 是一个强大的工具，但有两个隐藏行为需要注意：",[284,7821,7822,7829],{},[287,7823,7824,7825,7569,7827,7572],{},"嵌入目录时自动排除 ",[29,7826,7552],{},[29,7828,2831],{},[287,7830,7831],{},"构建缓存可能导致 embed 不是最新的",[15,7833,7834],{},"对于前端+后端合体的场景，这是性价比最高的方案——不需要额外的打包工具，纯标准库搞定。",[824,7836,7837],{},"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":27,"searchDepth":42,"depth":85,"links":7839},[7840,7841,7842,7847,7853,7854],{"id":2576,"depth":42,"text":2576},{"id":6908,"depth":42,"text":6908},{"id":6922,"depth":42,"text":6922,"children":7843},[7844,7845,7846],{"id":6925,"depth":85,"text":6926},{"id":7262,"depth":85,"text":7263},{"id":7374,"depth":85,"text":7375},{"id":7512,"depth":42,"text":7512,"children":7848},[7849,7850,7852],{"id":7515,"depth":85,"text":7516},{"id":7545,"depth":85,"text":7851},"坑二：\u002F\u002Fgo:embed 排除 _ 开头文件",{"id":7708,"depth":85,"text":7709},{"id":7728,"depth":42,"text":7728},{"id":1858,"depth":42,"text":1858},"记录将 Vue 3 前端嵌入 Go 二进制实现单 exe 发布的完整流程，以及遇到的坑和解决方案。","\u002Fposts\u002Fgo-embed-frontend\u002Fimg\u002Fcover.jpg",{},"\u002Fposts\u002Fgo-embed-frontend","2026-06-06T16:00:00",{"title":6891,"description":7855},"posts\u002Fgo-embed-frontend\u002Findex",[851,7863,7864,7865],"Vue","编译","埋坑","2026-06-06","SIQNL24wVAFb18wIGWSB3lNVZUQ9B76jWgQ55TASTYk",{"id":7869,"title":7870,"author":6,"body":7871,"description":9267,"difficulty":1909,"draft":840,"extension":841,"image":9268,"meta":9269,"navigation":88,"path":9270,"pinned":840,"published":9271,"seo":9272,"series":6,"seriesOrder":6,"stem":9273,"tags":9274,"updated":9271,"__hash__":9276},"posts\u002Fposts\u002Fjira-cli-intro\u002Findex.md","jira_cli — 把企业 Jira 装进命令行，AI Agent 直接调用",{"type":8,"value":7872,"toc":9246},[7873,7877,7892,7959,7962,7965,8042,8044,8077,8084,8087,8091,8203,8207,8338,8342,8410,8413,8476,8479,8481,8485,8488,8508,8511,8526,8529,8533,8539,8677,8688,8719,8723,8726,8823,8827,8830,8948,8952,8955,9019,9022,9026,9029,9097,9100,9106,9109,9143,9150,9153,9213,9215,9218,9235,9243],[11,7874,7876],{"id":7875},"jira_cli-是什么","jira_cli 是什么",[15,7878,7879,7884,7885,7569,7888,7891],{},[2529,7880,7883],{"href":7881,"rel":7882},"https:\u002F\u002Fgithub.com\u002Fyourname\u002Fjira_cli",[2533],"jira_cli"," 是一个面向企业 Jira（Server \u002F Data Center）的本地命令行工具，Go 语言编写，MIT 协议开源。它同时提供 ",[1948,7886,7887],{},"Cobra CLI",[1948,7889,7890],{},"MCP stdio server"," 两种入口，既能当日常命令行用，又能直接接入 AI 编程工具。",[22,7893,7895],{"className":24,"code":7894,"language":26,"meta":27,"style":27},"# 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",[29,7896,7897,7902,7915,7926,7941,7945,7950],{"__ignoreMap":27},[32,7898,7899],{"class":34,"line":35},[32,7900,7901],{"class":38},"# CLI 日常使用\n",[32,7903,7904,7906,7909,7912],{"class":34,"line":42},[32,7905,7883],{"class":45},[32,7907,7908],{"class":49}," issue",[32,7910,7911],{"class":49}," search",[32,7913,7914],{"class":49}," \"project = PROJ ORDER BY updated DESC\"\n",[32,7916,7917,7919,7921,7923],{"class":34,"line":85},[32,7918,7883],{"class":45},[32,7920,7908],{"class":49},[32,7922,4114],{"class":49},[32,7924,7925],{"class":49}," PROJ-123\n",[32,7927,7928,7930,7932,7935,7938],{"class":34,"line":92},[32,7929,7883],{"class":45},[32,7931,7908],{"class":49},[32,7933,7934],{"class":49}," comment",[32,7936,7937],{"class":49}," PROJ-123",[32,7939,7940],{"class":49}," \"处理完成，请测试验证\"\n",[32,7942,7943],{"class":34,"line":98},[32,7944,89],{"emptyLinePlaceholder":88},[32,7946,7947],{"class":34,"line":127},[32,7948,7949],{"class":38},"# MCP 模式 — 给 AI 装上操作 Jira 的手\n",[32,7951,7952,7954,7956],{"class":34,"line":146},[32,7953,7883],{"class":45},[32,7955,4355],{"class":49},[32,7957,7958],{"class":53}," --user=work\n",[11,7960,7961],{"id":7961},"为什么需要它",[15,7963,7964],{},"大多数 Jira 命令行工具只解决\"开发者手动操作 Jira\"这一个场景。但 2026 年 AI 编程工具已经普及，你的 AI Agent（Claude Code、Codex、Cursor 等）也需要读写 Jira。痛点就来了：",[2033,7966,7967,7977],{},[2036,7968,7969],{},[2039,7970,7971,7974],{},[2042,7972,7973],{},"痛点",[2042,7975,7976],{},"jira_cli 怎么解",[2055,7978,7979,7987,7995,8007,8019,8030],{},[2039,7980,7981,7984],{},[2060,7982,7983],{},"企业 Jira 在内网，没有公网 API",[2060,7985,7986],{},"本地 stdio MCP server，不经过外网",[2039,7988,7989,7992],{},[2060,7990,7991],{},"AI 可能误删、误写 Jira 数据",[2060,7993,7994],{},"账号级只读策略，AI 只能查不能改",[2039,7996,7997,8000],{},[2060,7998,7999],{},"团队多人多 Jira 实例",[2060,8001,8002,8003,8006],{},"多 Profile 管理，",[29,8004,8005],{},"--user=work"," 切换",[2039,8008,8009,8012],{},[2060,8010,8011],{},"Windows 终端体验差",[2060,8013,8014,8015,8018],{},"原生 ",[29,8016,8017],{},".exe"," 构建 + 一键安装脚本",[2039,8020,8021,8024],{},[2060,8022,8023],{},"AI 生成 JQL 困难",[2060,8025,8026,8027],{},"字段元数据发现：",[29,8028,8029],{},"field list --query xxx",[2039,8031,8032,8035],{},[2060,8033,8034],{},"MCP 直接拿原始 JSON 太冗长",[2060,8036,8037,8038,8041],{},"紧凑视图 + ",[29,8039,8040],{},"--view summary"," 减少 token 消耗",[11,8043,6254],{"id":6254},[22,8045,8047],{"className":24,"code":8046,"language":26,"meta":27,"style":27},"# 从源码安装（要求 Go 1.26.3+）\ngo install github.com\u002Fyourname\u002Fjira_cli@latest\n\n# 或下载预编译二进制\n# https:\u002F\u002Fgithub.com\u002Fyourname\u002Fjira_cli\u002Freleases\n",[29,8048,8049,8054,8063,8067,8072],{"__ignoreMap":27},[32,8050,8051],{"class":34,"line":35},[32,8052,8053],{"class":38},"# 从源码安装（要求 Go 1.26.3+）\n",[32,8055,8056,8058,8060],{"class":34,"line":42},[32,8057,171],{"class":45},[32,8059,112],{"class":49},[32,8061,8062],{"class":49}," github.com\u002Fyourname\u002Fjira_cli@latest\n",[32,8064,8065],{"class":34,"line":85},[32,8066,89],{"emptyLinePlaceholder":88},[32,8068,8069],{"class":34,"line":92},[32,8070,8071],{"class":38},"# 或下载预编译二进制\n",[32,8073,8074],{"class":34,"line":98},[32,8075,8076],{"class":38},"# https:\u002F\u002Fgithub.com\u002Fyourname\u002Fjira_cli\u002Freleases\n",[15,8078,8079,8080,8083],{},"Windows 用户下载 zip 包解压后双击 ",[29,8081,8082],{},"install.bat"," 即可全局安装，自动加入 PATH。",[11,8085,8086],{"id":8086},"快速上手",[280,8088,8090],{"id":8089},"_1-配置认证","1. 配置认证",[22,8092,8094],{"className":24,"code":8093,"language":26,"meta":27,"style":27},"# 设置 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",[29,8095,8096,8101,8117,8121,8126,8146,8150,8155,8184,8188,8193],{"__ignoreMap":27},[32,8097,8098],{"class":34,"line":35},[32,8099,8100],{"class":38},"# 设置 Jira 地址\n",[32,8102,8103,8105,8108,8111,8114],{"class":34,"line":42},[32,8104,7883],{"class":45},[32,8106,8107],{"class":49}," set",[32,8109,8110],{"class":49}," base-url",[32,8112,8113],{"class":49}," https:\u002F\u002Fjira.your-company.com",[32,8115,8116],{"class":53}," --global\n",[32,8118,8119],{"class":34,"line":85},[32,8120,89],{"emptyLinePlaceholder":88},[32,8122,8123],{"class":34,"line":92},[32,8124,8125],{"class":38},"# Token 认证（推荐）\n",[32,8127,8128,8130,8132,8135,8137,8140,8142,8144],{"class":34,"line":98},[32,8129,7883],{"class":45},[32,8131,8107],{"class":49},[32,8133,8134],{"class":49}," token",[32,8136,579],{"class":130},[32,8138,8139],{"class":49},"your-toke",[32,8141,1631],{"class":66},[32,8143,588],{"class":130},[32,8145,8116],{"class":53},[32,8147,8148],{"class":34,"line":127},[32,8149,89],{"emptyLinePlaceholder":88},[32,8151,8152],{"class":34,"line":146},[32,8153,8154],{"class":38},"# 或用户名密码认证\n",[32,8156,8157,8159,8161,8163,8165,8168,8170,8172,8174,8177,8180,8182],{"class":34,"line":162},[32,8158,7883],{"class":45},[32,8160,8107],{"class":49},[32,8162,6328],{"class":49},[32,8164,579],{"class":130},[32,8166,8167],{"class":49},"usernam",[32,8169,1617],{"class":66},[32,8171,588],{"class":130},[32,8173,579],{"class":130},[32,8175,8176],{"class":49},"passwor",[32,8178,8179],{"class":66},"d",[32,8181,588],{"class":130},[32,8183,8116],{"class":53},[32,8185,8186],{"class":34,"line":168},[32,8187,89],{"emptyLinePlaceholder":88},[32,8189,8190],{"class":34,"line":182},[32,8191,8192],{"class":38},"# 验证一下\n",[32,8194,8195,8197,8200],{"class":34,"line":205},[32,8196,7883],{"class":45},[32,8198,8199],{"class":49}," user",[32,8201,8202],{"class":49}," me\n",[280,8204,8206],{"id":8205},"_2-日常使用","2. 日常使用",[22,8208,8210],{"className":24,"code":8209,"language":26,"meta":27,"style":27},"# 查自己今天的任务\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",[29,8211,8212,8217,8228,8232,8237,8247,8251,8256,8270,8287,8291,8296,8309,8313,8318],{"__ignoreMap":27},[32,8213,8214],{"class":34,"line":35},[32,8215,8216],{"class":38},"# 查自己今天的任务\n",[32,8218,8219,8221,8223,8225],{"class":34,"line":42},[32,8220,7883],{"class":45},[32,8222,7908],{"class":49},[32,8224,7911],{"class":49},[32,8226,8227],{"class":49}," \"assignee = currentUser() ORDER BY updated DESC\"\n",[32,8229,8230],{"class":34,"line":85},[32,8231,89],{"emptyLinePlaceholder":88},[32,8233,8234],{"class":34,"line":92},[32,8235,8236],{"class":38},"# 看某个任务详情\n",[32,8238,8239,8241,8243,8245],{"class":34,"line":98},[32,8240,7883],{"class":45},[32,8242,7908],{"class":49},[32,8244,4114],{"class":49},[32,8246,7925],{"class":49},[32,8248,8249],{"class":34,"line":127},[32,8250,89],{"emptyLinePlaceholder":88},[32,8252,8253],{"class":34,"line":146},[32,8254,8255],{"class":38},"# 流转状态\n",[32,8257,8258,8260,8262,8265,8267],{"class":34,"line":162},[32,8259,7883],{"class":45},[32,8261,7908],{"class":49},[32,8263,8264],{"class":49}," transitions",[32,8266,7937],{"class":49},[32,8268,8269],{"class":38},"                    # 可以转哪些\n",[32,8271,8272,8274,8276,8279,8281,8284],{"class":34,"line":168},[32,8273,7883],{"class":45},[32,8275,7908],{"class":49},[32,8277,8278],{"class":49}," do-transition",[32,8280,7937],{"class":49},[32,8282,8283],{"class":53}," 21",[32,8285,8286],{"class":38},"               # 执行流转\n",[32,8288,8289],{"class":34,"line":182},[32,8290,89],{"emptyLinePlaceholder":88},[32,8292,8293],{"class":34,"line":205},[32,8294,8295],{"class":38},"# 添加备注\n",[32,8297,8298,8300,8302,8304,8306],{"class":34,"line":214},[32,8299,7883],{"class":45},[32,8301,7908],{"class":49},[32,8303,7934],{"class":49},[32,8305,7937],{"class":49},[32,8307,8308],{"class":49}," \"已定位到问题，正在修复\"\n",[32,8310,8311],{"class":34,"line":219},[32,8312,89],{"emptyLinePlaceholder":88},[32,8314,8315],{"class":34,"line":225},[32,8316,8317],{"class":38},"# 创建任务\n",[32,8319,8320,8322,8324,8326,8329,8332,8335],{"class":34,"line":236},[32,8321,7883],{"class":45},[32,8323,7908],{"class":49},[32,8325,6214],{"class":49},[32,8327,8328],{"class":49}," PROJ",[32,8330,8331],{"class":49}," Task",[32,8333,8334],{"class":53}," --fields",[32,8336,8337],{"class":49}," @fields.json\n",[280,8339,8341],{"id":8340},"_3-配给-ai-使用","3. 配给 AI 使用",[22,8343,8345],{"className":24,"code":8344,"language":26,"meta":27,"style":27},"# 先配置一个只读账号给 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",[29,8346,8347,8352,8369,8392],{"__ignoreMap":27},[32,8348,8349],{"class":34,"line":35},[32,8350,8351],{"class":38},"# 先配置一个只读账号给 AI 用\n",[32,8353,8354,8356,8358,8360,8362,8364,8367],{"class":34,"line":42},[32,8355,7883],{"class":45},[32,8357,8107],{"class":49},[32,8359,8110],{"class":49},[32,8361,8113],{"class":49},[32,8363,576],{"class":53},[32,8365,8366],{"class":49}," ai",[32,8368,8116],{"class":53},[32,8370,8371,8373,8375,8377,8379,8382,8384,8386,8388,8390],{"class":34,"line":85},[32,8372,7883],{"class":45},[32,8374,8107],{"class":49},[32,8376,8134],{"class":49},[32,8378,579],{"class":130},[32,8380,8381],{"class":49},"toke",[32,8383,1631],{"class":66},[32,8385,588],{"class":130},[32,8387,576],{"class":53},[32,8389,8366],{"class":49},[32,8391,8116],{"class":53},[32,8393,8394,8396,8398,8401,8404,8406,8408],{"class":34,"line":92},[32,8395,7883],{"class":45},[32,8397,8107],{"class":49},[32,8399,8400],{"class":49}," readonly",[32,8402,8403],{"class":49}," on",[32,8405,576],{"class":53},[32,8407,8366],{"class":49},[32,8409,8116],{"class":53},[15,8411,8412],{},"然后在 AI 工具的 MCP 配置里加上：",[22,8414,8416],{"className":5925,"code":8415,"language":5927,"meta":27,"style":27},"{\n  \"mcpServers\": {\n    \"jira\": {\n      \"command\": \"jira_cli\",\n      \"args\": [\"mcp\", \"--user=ai\"]\n    }\n  }\n}\n",[29,8417,8418,8422,8428,8435,8447,8464,8468,8472],{"__ignoreMap":27},[32,8419,8420],{"class":34,"line":35},[32,8421,5934],{"class":66},[32,8423,8424,8426],{"class":34,"line":42},[32,8425,5939],{"class":53},[32,8427,2628],{"class":66},[32,8429,8430,8433],{"class":34,"line":85},[32,8431,8432],{"class":53},"    \"jira\"",[32,8434,2628],{"class":66},[32,8436,8437,8440,8442,8445],{"class":34,"line":92},[32,8438,8439],{"class":53},"      \"command\"",[32,8441,2413],{"class":66},[32,8443,8444],{"class":49},"\"jira_cli\"",[32,8446,2360],{"class":66},[32,8448,8449,8452,8454,8457,8459,8462],{"class":34,"line":98},[32,8450,8451],{"class":53},"      \"args\"",[32,8453,2444],{"class":66},[32,8455,8456],{"class":49},"\"mcp\"",[32,8458,798],{"class":66},[32,8460,8461],{"class":49},"\"--user=ai\"",[32,8463,501],{"class":66},[32,8465,8466],{"class":34,"line":127},[32,8467,2669],{"class":66},[32,8469,8470],{"class":34,"line":146},[32,8471,2674],{"class":66},[32,8473,8474],{"class":34,"line":162},[32,8475,2003],{"class":66},[15,8477,8478],{},"这样 AI 就能查 Jira 但不能修改，既有了上下文，又不用担心误操作。",[11,8480,6736],{"id":6736},[280,8482,8484],{"id":8483},"_1-账号级只读策略","1. 账号级只读策略",[15,8486,8487],{},"这是 jira_cli 最实用的设计。给 AI Agent 配置只读账号：",[22,8489,8491],{"className":24,"code":8490,"language":26,"meta":27,"style":27},"jira_cli set readonly on --name ai\n",[29,8492,8493],{"__ignoreMap":27},[32,8494,8495,8497,8499,8501,8503,8505],{"class":34,"line":35},[32,8496,7883],{"class":45},[32,8498,8107],{"class":49},[32,8500,8400],{"class":49},[32,8502,8403],{"class":49},[32,8504,576],{"class":53},[32,8506,8507],{"class":49}," ai\n",[15,8509,8510],{},"只读模式下：",[2256,8512,8513,8516,8519],{},[287,8514,8515],{},"CLI 写命令被隐藏和拒绝",[287,8517,8518],{},"MCP 的写入工具不会注册",[287,8520,8521,8522,8525],{},"环境变量 ",[29,8523,8524],{},"JIRA_READONLY=true"," 也可以临时覆盖",[15,8527,8528],{},"AI 只能查 JQL、看任务、查字段、读评论，绝对不会误改数据。需要创建\u002F更新时，另配一个有写入权限的账号由开发者自己操作。",[280,8530,8532],{"id":8531},"_2-多-profile-切换","2. 多 Profile 切换",[15,8534,8535,8536,8538],{},"一个配置文件里管理多个 Jira 账号，",[29,8537,1582],{}," 切换：",[22,8540,8542],{"className":24,"code":8541,"language":26,"meta":27,"style":27},"# 工作账号\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",[29,8543,8544,8549,8567,8590,8594,8599,8617,8640,8644,8649,8663],{"__ignoreMap":27},[32,8545,8546],{"class":34,"line":35},[32,8547,8548],{"class":38},"# 工作账号\n",[32,8550,8551,8553,8555,8557,8560,8562,8565],{"class":34,"line":42},[32,8552,7883],{"class":45},[32,8554,8107],{"class":49},[32,8556,8110],{"class":49},[32,8558,8559],{"class":49}," https:\u002F\u002Fjira.company.com",[32,8561,576],{"class":53},[32,8563,8564],{"class":49}," work",[32,8566,8116],{"class":53},[32,8568,8569,8571,8573,8575,8577,8580,8582,8584,8586,8588],{"class":34,"line":85},[32,8570,7883],{"class":45},[32,8572,8107],{"class":49},[32,8574,8134],{"class":49},[32,8576,579],{"class":130},[32,8578,8579],{"class":49},"work-toke",[32,8581,1631],{"class":66},[32,8583,588],{"class":130},[32,8585,576],{"class":53},[32,8587,8564],{"class":49},[32,8589,8116],{"class":53},[32,8591,8592],{"class":34,"line":92},[32,8593,89],{"emptyLinePlaceholder":88},[32,8595,8596],{"class":34,"line":98},[32,8597,8598],{"class":38},"# 个人账号\n",[32,8600,8601,8603,8605,8607,8610,8612,8615],{"class":34,"line":127},[32,8602,7883],{"class":45},[32,8604,8107],{"class":49},[32,8606,8110],{"class":49},[32,8608,8609],{"class":49}," https:\u002F\u002Fjira.personal.com",[32,8611,576],{"class":53},[32,8613,8614],{"class":49}," personal",[32,8616,8116],{"class":53},[32,8618,8619,8621,8623,8625,8627,8630,8632,8634,8636,8638],{"class":34,"line":146},[32,8620,7883],{"class":45},[32,8622,8107],{"class":49},[32,8624,8134],{"class":49},[32,8626,579],{"class":130},[32,8628,8629],{"class":49},"personal-toke",[32,8631,1631],{"class":66},[32,8633,588],{"class":130},[32,8635,576],{"class":53},[32,8637,8614],{"class":49},[32,8639,8116],{"class":53},[32,8641,8642],{"class":34,"line":162},[32,8643,89],{"emptyLinePlaceholder":88},[32,8645,8646],{"class":34,"line":168},[32,8647,8648],{"class":38},"# 使用时切换\n",[32,8650,8651,8653,8656,8658,8660],{"class":34,"line":182},[32,8652,7883],{"class":45},[32,8654,8655],{"class":53}," --user=work",[32,8657,7908],{"class":49},[32,8659,7911],{"class":49},[32,8661,8662],{"class":49}," \"project = PROJ\"\n",[32,8664,8665,8667,8670,8672,8674],{"class":34,"line":205},[32,8666,7883],{"class":45},[32,8668,8669],{"class":53}," --user=personal",[32,8671,7908],{"class":49},[32,8673,7911],{"class":49},[32,8675,8676],{"class":49}," \"project = SIDE\"\n",[15,8678,8679,8680,8683,8684,8687],{},"同时支持",[1948,8681,8682],{},"项目级配置","（",[29,8685,8686],{},"--local","）和工作目录绑定：",[22,8689,8691],{"className":24,"code":8690,"language":26,"meta":27,"style":27},"# 在项目目录下，配置只对当前项目生效\ncd ~\u002Fprojects\u002Fbackend\njira_cli set base-url https:\u002F\u002Fjira-backend.company.com --local\n",[29,8692,8693,8698,8705],{"__ignoreMap":27},[32,8694,8695],{"class":34,"line":35},[32,8696,8697],{"class":38},"# 在项目目录下，配置只对当前项目生效\n",[32,8699,8700,8702],{"class":34,"line":42},[32,8701,7478],{"class":53},[32,8703,8704],{"class":49}," ~\u002Fprojects\u002Fbackend\n",[32,8706,8707,8709,8711,8713,8716],{"class":34,"line":85},[32,8708,7883],{"class":45},[32,8710,8107],{"class":49},[32,8712,8110],{"class":49},[32,8714,8715],{"class":49}," https:\u002F\u002Fjira-backend.company.com",[32,8717,8718],{"class":53}," --local\n",[280,8720,8722],{"id":8721},"_3-对-ai-友好的输出","3. 对 AI 友好的输出",[15,8724,8725],{},"jira_cli 在输出设计上充分考虑了 AI Agent 的 token 消耗：",[22,8727,8729],{"className":24,"code":8728,"language":26,"meta":27,"style":27},"# 紧凑视图 — 去掉 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",[29,8730,8731,8736,8752,8756,8761,8778,8782,8787,8802,8806,8811],{"__ignoreMap":27},[32,8732,8733],{"class":34,"line":35},[32,8734,8735],{"class":38},"# 紧凑视图 — 去掉 self\u002Favatar\u002F缩略图等无用字段\n",[32,8737,8738,8740,8742,8744,8746,8749],{"class":34,"line":42},[32,8739,7883],{"class":45},[32,8741,7908],{"class":49},[32,8743,4114],{"class":49},[32,8745,7937],{"class":49},[32,8747,8748],{"class":53}," --view",[32,8750,8751],{"class":49}," summary\n",[32,8753,8754],{"class":34,"line":85},[32,8755,89],{"emptyLinePlaceholder":88},[32,8757,8758],{"class":34,"line":92},[32,8759,8760],{"class":38},"# 表格输出 — 一眼看到关键信息\n",[32,8762,8763,8765,8767,8769,8772,8775],{"class":34,"line":98},[32,8764,7883],{"class":45},[32,8766,7908],{"class":49},[32,8768,7911],{"class":49},[32,8770,8771],{"class":49}," \"project = PROJ\"",[32,8773,8774],{"class":53}," --output",[32,8776,8777],{"class":49}," table\n",[32,8779,8780],{"class":34,"line":127},[32,8781,89],{"emptyLinePlaceholder":88},[32,8783,8784],{"class":34,"line":146},[32,8785,8786],{"class":38},"# JSON 输出 — 程序解析，不转义 Unicode\n",[32,8788,8789,8791,8793,8796,8798,8800],{"class":34,"line":162},[32,8790,7883],{"class":45},[32,8792,8774],{"class":53},[32,8794,8795],{"class":49}," json",[32,8797,7908],{"class":49},[32,8799,4114],{"class":49},[32,8801,7925],{"class":49},[32,8803,8804],{"class":34,"line":168},[32,8805,89],{"emptyLinePlaceholder":88},[32,8807,8808],{"class":34,"line":182},[32,8809,8810],{"class":38},"# 自定义搜索列 — 只要需要的字段\n",[32,8812,8813,8815,8817,8820],{"class":34,"line":205},[32,8814,7883],{"class":45},[32,8816,8107],{"class":49},[32,8818,8819],{"class":49}," search-columns",[32,8821,8822],{"class":49}," key,summary,fixVersions\n",[280,8824,8826],{"id":8825},"_4-字段元数据发现","4. 字段元数据发现",[15,8828,8829],{},"Jira 的 customfield 让人头疼，jira_cli 提供了完整的字段发现能力：",[22,8831,8833],{"className":24,"code":8832,"language":26,"meta":27,"style":27},"# 列出所有字段及其类型\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",[29,8834,8835,8840,8849,8853,8858,8872,8876,8881,8892,8896,8901,8918,8922,8927],{"__ignoreMap":27},[32,8836,8837],{"class":34,"line":35},[32,8838,8839],{"class":38},"# 列出所有字段及其类型\n",[32,8841,8842,8844,8847],{"class":34,"line":42},[32,8843,7883],{"class":45},[32,8845,8846],{"class":49}," field",[32,8848,2763],{"class":49},[32,8850,8851],{"class":34,"line":85},[32,8852,89],{"emptyLinePlaceholder":88},[32,8854,8855],{"class":34,"line":92},[32,8856,8857],{"class":38},"# 搜索包含特定关键词的字段\n",[32,8859,8860,8862,8864,8866,8869],{"class":34,"line":98},[32,8861,7883],{"class":45},[32,8863,8846],{"class":49},[32,8865,6149],{"class":49},[32,8867,8868],{"class":53}," --query",[32,8870,8871],{"class":49}," \"版本\"\n",[32,8873,8874],{"class":34,"line":127},[32,8875,89],{"emptyLinePlaceholder":88},[32,8877,8878],{"class":34,"line":146},[32,8879,8880],{"class":38},"# 只看自定义字段\n",[32,8882,8883,8885,8887,8889],{"class":34,"line":162},[32,8884,7883],{"class":45},[32,8886,8846],{"class":49},[32,8888,6149],{"class":49},[32,8890,8891],{"class":53}," --custom-only\n",[32,8893,8894],{"class":34,"line":168},[32,8895,89],{"emptyLinePlaceholder":88},[32,8897,8898],{"class":34,"line":182},[32,8899,8900],{"class":38},"# 创建任务前，先看看需要哪些字段\n",[32,8902,8903,8905,8907,8910,8912,8915],{"class":34,"line":205},[32,8904,7883],{"class":45},[32,8906,7908],{"class":49},[32,8908,8909],{"class":49}," create-meta",[32,8911,8328],{"class":49},[32,8913,8914],{"class":53}," --issue-type",[32,8916,8917],{"class":49}," Task\n",[32,8919,8920],{"class":34,"line":214},[32,8921,89],{"emptyLinePlaceholder":88},[32,8923,8924],{"class":34,"line":219},[32,8925,8926],{"class":38},"# 分页查看可选值\n",[32,8928,8929,8931,8933,8936,8938,8940,8943,8945],{"class":34,"line":225},[32,8930,7883],{"class":45},[32,8932,7908],{"class":49},[32,8934,8935],{"class":49}," create-meta-values",[32,8937,8328],{"class":49},[32,8939,8331],{"class":49},[32,8941,8942],{"class":49}," priority",[32,8944,8868],{"class":53},[32,8946,8947],{"class":49}," High\n",[280,8949,8951],{"id":8950},"_5-mcp-写入控制","5. MCP 写入控制",[15,8953,8954],{},"除了全局只读，MCP 还有更细粒度的控制：",[2033,8956,8957,8967],{},[2036,8958,8959],{},[2039,8960,8961,8964],{},[2042,8962,8963],{},"环境变量",[2042,8965,8966],{},"作用",[2055,8968,8969,8979,8989,8999,9009],{},[2039,8970,8971,8976],{},[2060,8972,8973],{},[29,8974,8975],{},"JIRA_CREATE_OPEN=false",[2060,8977,8978],{},"禁用创建\u002F更新工具",[2039,8980,8981,8986],{},[2060,8982,8983],{},[29,8984,8985],{},"JIRA_DISABLE_DISCLAIMER=true",[2060,8987,8988],{},"去掉 AI 自动添加的备注声明",[2039,8990,8991,8996],{},[2060,8992,8993],{},[29,8994,8995],{},"JIRA_CREATE_SUMMARY_MAX",[2060,8997,8998],{},"限制摘要长度",[2039,9000,9001,9006],{},[2060,9002,9003],{},[29,9004,9005],{},"JIRA_CREATE_DESCRIPTION_MAX",[2060,9007,9008],{},"限制描述长度",[2039,9010,9011,9016],{},[2060,9012,9013],{},[29,9014,9015],{},"JIRA_CREATE_FIELDS_MAX_BYTES",[2060,9017,9018],{},"限制创建字段的 JSON 大小",[15,9020,9021],{},"这些限制确保 AI 即使有写入权限也不会\"失控\"——例如防止 AI 往描述里塞几万字的废话。",[280,9023,9025],{"id":9024},"_6-中英双语帮助","6. 中英双语帮助",[15,9027,9028],{},"帮助信息默认中文，也支持一键切英文：",[22,9030,9032],{"className":24,"code":9031,"language":26,"meta":27,"style":27},"# 保存英文偏好\njira_cli set help-language en\n\n# 切回中文\njira_cli set help-language zh\n\n# 临时覆盖\njira_cli --lang en issue search -h\n",[29,9033,9034,9039,9051,9055,9060,9071,9075,9080],{"__ignoreMap":27},[32,9035,9036],{"class":34,"line":35},[32,9037,9038],{"class":38},"# 保存英文偏好\n",[32,9040,9041,9043,9045,9048],{"class":34,"line":42},[32,9042,7883],{"class":45},[32,9044,8107],{"class":49},[32,9046,9047],{"class":49}," help-language",[32,9049,9050],{"class":49}," en\n",[32,9052,9053],{"class":34,"line":85},[32,9054,89],{"emptyLinePlaceholder":88},[32,9056,9057],{"class":34,"line":92},[32,9058,9059],{"class":38},"# 切回中文\n",[32,9061,9062,9064,9066,9068],{"class":34,"line":98},[32,9063,7883],{"class":45},[32,9065,8107],{"class":49},[32,9067,9047],{"class":49},[32,9069,9070],{"class":49}," zh\n",[32,9072,9073],{"class":34,"line":127},[32,9074,89],{"emptyLinePlaceholder":88},[32,9076,9077],{"class":34,"line":146},[32,9078,9079],{"class":38},"# 临时覆盖\n",[32,9081,9082,9084,9087,9090,9092,9094],{"class":34,"line":162},[32,9083,7883],{"class":45},[32,9085,9086],{"class":53}," --lang",[32,9088,9089],{"class":49}," en",[32,9091,7908],{"class":49},[32,9093,7911],{"class":49},[32,9095,9096],{"class":53}," -h\n",[11,9098,9099],{"id":9099},"架构一览",[22,9101,9104],{"className":9102,"code":9103,"language":2019},[2017],"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",[29,9105,9103],{"__ignoreMap":27},[15,9107,9108],{},"设计原则清晰：",[2256,9110,9111,9119,9127,9135],{},[287,9112,9113,9118],{},[1948,9114,9115],{},[29,9116,9117],{},"pkg\u002Fjira"," 是纯 Jira 客户端，HTTP、认证、上下文处理都在这里",[287,9120,9121,9126],{},[1948,9122,9123],{},[29,9124,9125],{},"cmd"," 只管 Cobra 解析和呈现",[287,9128,9129,9134],{},[1948,9130,9131],{},[29,9132,9133],{},"internal\u002Ftools\u002Fjira_tools"," 只管 MCP schema 验证和结果适配",[287,9136,9137,9142],{},[1948,9138,9139],{},[29,9140,9141],{},"internal\u002Fconfig"," 拥有 Profile 的全部逻辑，不分散到 cmd",[15,9144,9145,9146,9149],{},"每一层职责单一，新增 Jira 能力时按 ",[29,9147,9148],{},"endpoints.go → Client 方法 → CLI 命令 → MCP tool → 策略注册"," 的路径走就行。",[11,9151,9152],{"id":9152},"和已有方案的对比",[2033,9154,9155,9167],{},[2036,9156,9157],{},[2039,9158,9159,9161,9164],{},[2042,9160,3388],{},[2042,9162,9163],{},"适合场景",[2042,9165,9166],{},"jira_cli 的区别",[2055,9168,9169,9180,9191,9202],{},[2039,9170,9171,9174,9177],{},[2060,9172,9173],{},"Atlassian Rovo MCP Server",[2060,9175,9176],{},"Jira Cloud、官方 OAuth",[2060,9178,9179],{},"jira_cli 是本地 stdio，适合内网 Server\u002FDC",[2039,9181,9182,9185,9188],{},[2060,9183,9184],{},"go-jira \u002F jira-cli 社区版",[2060,9186,9187],{},"纯命令行管理",[2060,9189,9190],{},"jira_cli 同时提供 MCP server，面向 AI 工作流",[2039,9192,9193,9196,9199],{},[2060,9194,9195],{},"直接调 REST API",[2060,9197,9198],{},"自定义集成",[2060,9200,9201],{},"jira_cli 封装了认证、只读策略、字段元数据",[2039,9203,9204,9207,9210],{},[2060,9205,9206],{},"Jira 网页",[2060,9208,9209],{},"日常使用",[2060,9211,9212],{},"jira_cli 把 Jira 变成终端里的一行命令",[11,9214,1858],{"id":1858},[15,9216,9217],{},"如果你的团队满足以下任一条件，jira_cli 值得一试：",[2256,9219,9220,9223,9226,9229,9232],{},[287,9221,9222],{},"企业 Jira 部署在内网，想接入 AI 编程工具",[287,9224,9225],{},"想给 AI Agent 只读权限来查 Jira 上下文",[287,9227,9228],{},"需要命令行快速查看\u002F操作 Jira，不想切浏览器",[287,9230,9231],{},"需要管理多个 Jira 实例和多套账号",[287,9233,9234],{},"Windows 用户想要无摩擦的 CLI 体验",[15,9236,9237,9238,9242],{},"GitHub：",[2529,9239,9241],{"href":7881,"rel":9240},[2533],"github.com\u002Fyourname\u002Fjira_cli"," | MIT 协议 | Go 1.26.3+",[824,9244,9245],{},"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":27,"searchDepth":42,"depth":85,"links":9247},[9248,9249,9250,9251,9256,9264,9265,9266],{"id":7875,"depth":42,"text":7876},{"id":7961,"depth":42,"text":7961},{"id":6254,"depth":42,"text":6254},{"id":8086,"depth":42,"text":8086,"children":9252},[9253,9254,9255],{"id":8089,"depth":85,"text":8090},{"id":8205,"depth":85,"text":8206},{"id":8340,"depth":85,"text":8341},{"id":6736,"depth":42,"text":6736,"children":9257},[9258,9259,9260,9261,9262,9263],{"id":8483,"depth":85,"text":8484},{"id":8531,"depth":85,"text":8532},{"id":8721,"depth":85,"text":8722},{"id":8825,"depth":85,"text":8826},{"id":8950,"depth":85,"text":8951},{"id":9024,"depth":85,"text":9025},{"id":9099,"depth":42,"text":9099},{"id":9152,"depth":42,"text":9152},{"id":1858,"depth":42,"text":1858},"介绍 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":7870,"description":9267},"posts\u002Fjira-cli-intro\u002Findex",[9275,6886,6101,851,6103,6887],"Jira","vNH6sF2mMFmSjbHoEI2OqmYCgpFmaqyKt-MUfcQqXrU",{"id":9278,"title":9279,"author":6,"body":9280,"description":9824,"difficulty":1909,"draft":840,"extension":841,"image":9825,"meta":9826,"navigation":88,"path":9827,"pinned":840,"published":9828,"seo":9829,"series":9830,"seriesOrder":35,"stem":9831,"tags":9832,"updated":9835,"__hash__":9836},"posts\u002Fposts\u002Fsublog-evolution\u002Findex.md","尼克的小窝 架构变迁：从纯静态到前后端分离",{"type":8,"value":9281,"toc":9804},[9282,9286,9289,9300,9304,9308,9314,9318,9349,9352,9366,9370,9373,9441,9445,9448,9454,9458,9468,9498,9501,9505,9513,9516,9560,9564,9685,9689,9696,9700,9706,9712,9716,9719,9770,9774,9781,9788,9794,9797,9799],[11,9283,9285],{"id":9284},"写在前面","📝 写在前面",[15,9287,9288],{},"SuBlog 最初只是一个简单的技术博客——Nuxt 3 静态生成，Markdown 写文章，部署到静态托管完事。但随着功能需求的增长，纯静态的架构开始显露出局限性。",[15,9290,9291,9292,9295,9296,9299],{},"这篇文章记录从 ",[1948,9293,9294],{},"纯静态"," 到 ",[1948,9297,9298],{},"前后端分离"," 的演进过程，带你了解每一步背后的思考。",[11,9301,9303],{"id":9302},"第一代纯静态nuxt-3-nuxtcontent","📦 第一代：纯静态（Nuxt 3 + @nuxt\u002Fcontent）",[280,9305,9307],{"id":9306},"️-架构图","🏗️ 架构图",[22,9309,9312],{"className":9310,"code":9311,"language":2019,"meta":27},[2017],"┌─────────────────────────────────────┐\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",[29,9313,9311],{"__ignoreMap":27},[280,9315,9317],{"id":9316},"做了什么","🔧 做了什么",[2256,9319,9320,9329,9334,9339],{},[287,9321,9322,1875,9324,1875,9326,9328],{},[1948,9323,2195],{},[1948,9325,2205],{},[1948,9327,2215],{}," 搭建前端",[287,9330,9331,9333],{},[1948,9332,2225],{}," 管理 Markdown 文章，编译时生成静态页面",[287,9335,9336,9338],{},[1948,9337,2245],{}," 嵌入评论，依赖 GitHub Discussions",[287,9340,9341,9342,9345,9346,9348],{},"部署通过 ",[29,9343,9344],{},"nuxt generate"," 输出 ",[29,9347,2486],{},"，丢到 Nginx 或 Vercel",[280,9350,9351],{"id":3391},"✅ 优点",[2256,9353,9354,9360,9363],{},[287,9355,9356,9357,9359],{},"部署极简，一个 ",[29,9358,9344],{}," 搞定",[287,9361,9362],{},"加载飞快，全是 HTML，没有服务端开销",[287,9364,9365],{},"无需后端，零运维成本",[280,9367,9369],{"id":9368},"局限","❌ 局限",[15,9371,9372],{},"随着时间的推移，纯静态的问题越来越明显：",[2033,9374,9375,9384],{},[2036,9376,9377],{},[2039,9378,9379,9381],{},[2042,9380,3685],{},[2042,9382,9383],{},"具体表现",[2055,9385,9386,9397,9408,9419,9430],{},[2039,9387,9388,9394],{},[2060,9389,9390,9391],{},"🐢 ",[1948,9392,9393],{},"内容更新慢",[2060,9395,9396],{},"改一篇文章要重新构建、重新部署，不能实时发布",[2039,9398,9399,9405],{},[2060,9400,9401,9402],{},"👤 ",[1948,9403,9404],{},"没有用户体系",[2060,9406,9407],{},"无法区分读者和管理员，所有内容对所有人一致可见",[2039,9409,9410,9416],{},[2060,9411,9412,9413],{},"🔒 ",[1948,9414,9415],{},"评论受限",[2060,9417,9418],{},"Giscus 需要 GitHub 账号才能评论，门槛高",[2039,9420,9421,9427],{},[2060,9422,9423,9424],{},"🚫 ",[1948,9425,9426],{},"无法做动态功能",[2060,9428,9429],{},"点赞、收藏、阅读统计、个性化推荐……统统做不了",[2039,9431,9432,9438],{},[2060,9433,9434,9435],{},"🔗 ",[1948,9436,9437],{},"前端锁死",[2060,9439,9440],{},"被 Nuxt\u002FVue 生态绑定，想换技术栈得重写",[11,9442,9444],{"id":9443},"第二代前后端分离react-go","⚡ 第二代：前后端分离（React + Go）",[280,9446,9307],{"id":9447},"️-架构图-1",[22,9449,9452],{"className":9450,"code":9451,"language":2019,"meta":27},[2017],"┌───────────────────────┐       ┌────────────────────┐\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",[29,9453,9451],{"__ignoreMap":27},[280,9455,9457],{"id":9456},"️-前端sublog-react","🖥️ 前端：sublog-react",[15,9459,9460,9461,9463,9464,9467],{},"选用 ",[1948,9462,2050],{},"（App Router） + ",[1948,9465,9466],{},"React 19","，理由是：",[2256,9469,9470,9477,9484,9491],{},[287,9471,9472,9473,9476],{},"🎨 ",[1948,9474,9475],{},"React 生态","——组件库成熟，开发效率高",[287,9478,9479,9480,9483],{},"📐 ",[1948,9481,9482],{},"App Router","——支持布局嵌套，页面组织清晰",[287,9485,9486,9487,9490],{},"📦 ",[1948,9488,9489],{},"静态导出","——仍可部署到静态托管",[287,9492,9493,9494,9497],{},"⚙️ ",[1948,9495,9496],{},"运行时配置","——API 地址可动态注入，切换环境无需重新构建",[15,9499,9500],{},"前端不再直接读取 Markdown 文件，而是通过 HTTP API 获取文章数据。静态导出 + 客户端数据获取的组合，既保留了首屏加载速度，又实现了内容的动态更新。",[280,9502,9504],{"id":9503},"️-后端sublog-s","⚙️ 后端：sublog-s",[15,9506,9507,9508,1875,9510,9512],{},"后端使用 ",[1948,9509,851],{},[1948,9511,6102],{},"，按业务逻辑分层组织代码。",[15,9514,9515],{},"核心技术选型：",[2033,9517,9518,9526],{},[2036,9519,9520],{},[2039,9521,9522,9524],{},[2042,9523,2183],{},[2042,9525,2186],{},[2055,9527,9528,9538,9549],{},[2039,9529,9530,9535],{},[2060,9531,9532,9533],{},"🚀 ",[1948,9534,6102],{},[2060,9536,9537],{},"HTTP 框架",[2039,9539,9540,9546],{},[2060,9541,9542,9543],{},"🗄️ ",[1948,9544,9545],{},"GORM",[2060,9547,9548],{},"ORM，数据库操作",[2039,9550,9551,9557],{},[2060,9552,9553,9554],{},"🔐 ",[1948,9555,9556],{},"JWT",[2060,9558,9559],{},"用户认证",[11,9561,9563],{"id":9562},"前后对比","📊 前后对比",[2033,9565,9566,9579],{},[2036,9567,9568],{},[2039,9569,9570,9573,9576],{},[2042,9571,9572],{},"维度",[2042,9574,9575],{},"第一代（纯静态）",[2042,9577,9578],{},"第二代（前后端分离）",[2055,9580,9581,9593,9605,9619,9632,9645,9659,9671],{},[2039,9582,9583,9587,9590],{},[2060,9584,9472,9585],{},[1948,9586,2148],{},[2060,9588,9589],{},"Nuxt 3 (Vue)",[2060,9591,9592],{},"Next.js (React 19)",[2039,9594,9595,9600,9602],{},[2060,9596,9597,9598],{},"🖥️ ",[1948,9599,6104],{},[2060,9601,2091],{},[2060,9603,9604],{},"Go + Gin",[2039,9606,9607,9613,9616],{},[2060,9608,9609,9610],{},"📄 ",[1948,9611,9612],{},"内容来源",[2060,9614,9615],{},"Markdown 文件",[2060,9617,9618],{},"API 数据库",[2039,9620,9621,9626,9629],{},[2060,9622,9532,9623],{},[1948,9624,9625],{},"发布方式",[2060,9627,9628],{},"全量重新构建",[2060,9630,9631],{},"API 实时发布",[2039,9633,9634,9639,9642],{},[2060,9635,9401,9636],{},[1948,9637,9638],{},"用户系统",[2060,9640,9641],{},"❌ 无",[2060,9643,9644],{},"✅ 注册\u002F登录",[2039,9646,9647,9653,9656],{},[2060,9648,9649,9650],{},"💬 ",[1948,9651,9652],{},"评论",[2060,9654,9655],{},"Giscus（需 GitHub）",[2060,9657,9658],{},"自有评论系统（规划中）",[2039,9660,9661,9665,9668],{},[2060,9662,9486,9663],{},[1948,9664,2461],{},[2060,9666,9667],{},"1 个静态目录",[2060,9669,9670],{},"前端静态 + 后端二进制",[2039,9672,9673,9679,9682],{},[2060,9674,9675,9676],{},"⚡ ",[1948,9677,9678],{},"更新体验",[2060,9680,9681],{},"改文章 → 构建 → 部署",[2060,9683,9684],{},"改文章 → 立即生效",[11,9686,9688],{"id":9687},"新功能注册账号","🎉 新功能：注册账号",[15,9690,9691,9692,9695],{},"伴随着架构升级，",[1948,9693,9694],{},"用户账号系统","也上线了。",[280,9697,9699],{"id":9698},"目前支持","📋 目前支持",[15,9701,9702,9703,3852],{},"现在你可以在尼克的小窝 ",[1948,9704,9705],{},"注册一个账号",[22,9707,9710],{"className":9708,"code":9709,"language":2019},[2017],"1. ✏️ 填写用户名、邮箱、密码\n2. 📧 查收激活邮件\n3. 🔗 点击链接激活账号\n4. 🚪 登录后查看和管理个人信息\n",[29,9711,9709],{"__ignoreMap":27},[11,9713,9715],{"id":9714},"️-后续计划","🗓️ 后续计划",[15,9717,9718],{},"正在开发或规划中的功能：",[2256,9720,9722,9728,9734,9740,9746,9752,9758,9764],{"className":9721},[2494],[287,9723,9725,9727],{"className":9724},[2498],[2500,9726],{"checked":88,"disabled":88,"type":2502}," 用户注册\u002F登录（已上线 🎉）",[287,9729,9731,9733],{"className":9730},[2498],[2500,9732],{"checked":88,"disabled":88,"type":2502}," 个人资料编辑（已上线 🎉）",[287,9735,9737,9739],{"className":9736},[2498],[2500,9738],{"disabled":88,"type":2502}," 💬 站内评论系统",[287,9741,9743,9745],{"className":9742},[2498],[2500,9744],{"disabled":88,"type":2502}," 🔖 文章收藏",[287,9747,9749,9751],{"className":9748},[2498],[2500,9750],{"disabled":88,"type":2502}," 📚 阅读历史",[287,9753,9755,9757],{"className":9754},[2498],[2500,9756],{"disabled":88,"type":2502}," 👍 文章点赞",[287,9759,9761,9763],{"className":9760},[2498],[2500,9762],{"disabled":88,"type":2502}," 🔔 关注通知",[287,9765,9767,9769],{"className":9766},[2498],[2500,9768],{"disabled":88,"type":2502}," 🎨 个人首页定制",[11,9771,9773],{"id":9772},"结语","💡 结语",[15,9775,9776,9777,9780],{},"从 Nuxt 3 纯静态到 React + Go 前后端分离，SuBlog 的架构变迁本质上是一个",[1948,9778,9779],{},"功能驱动","的过程。静态博客有它的位置——写写文章、展示内容完全够用。但当你想做的不仅仅是\"展示\"时，后端的能力就不可或缺了。",[15,9782,9783,9784,9787],{},"这次重构最大的感触是：",[1948,9785,9786],{},"架构选型不是越复杂越好，而是刚好够用最好","。纯静态阶段用 Nuxt 3 一点问题没有，直到功能需求超出了静态的边界，才自然推动到前后端分离。",[15,9789,9790,9791],{},"如果你也在纠结\"要不要上后端\"，不妨先问问自己：",[1948,9792,9793],{},"你要做的是\"展示内容\"还是\"提供服务\"？",[15,9795,9796],{},"前者，静态就够了；后者，来吧，后端欢迎你。😄",[2537,9798],{},[15,9800,9801],{},[1948,9802,9803],{},"🎉 目前注册功能已经可以用了，后续更多好玩的功能正在路上。部分功能可能需要登录才能体验，快注册一个账号吧！",{"title":27,"searchDepth":42,"depth":85,"links":9805},[9806,9807,9813,9818,9819,9822,9823],{"id":9284,"depth":42,"text":9285},{"id":9302,"depth":42,"text":9303,"children":9808},[9809,9810,9811,9812],{"id":9306,"depth":85,"text":9307},{"id":9316,"depth":85,"text":9317},{"id":3391,"depth":85,"text":9351},{"id":9368,"depth":85,"text":9369},{"id":9443,"depth":42,"text":9444,"children":9814},[9815,9816,9817],{"id":9447,"depth":85,"text":9307},{"id":9456,"depth":85,"text":9457},{"id":9503,"depth":85,"text":9504},{"id":9562,"depth":42,"text":9563},{"id":9687,"depth":42,"text":9688,"children":9820},[9821],{"id":9698,"depth":85,"text":9699},{"id":9714,"depth":42,"text":9715},{"id":9772,"depth":42,"text":9773},"记录尼克的小窝从 Nuxt 3 纯静态博客演进到 React + Go 前后端分离架构的全过程，包括架构设计的思考、技术选型的变化，以及新增的账号注册功能。","\u002Fposts\u002Fsublog-evolution\u002Fimg\u002Fcover.jpg",{},"\u002Fposts\u002Fsublog-evolution","2026-07-23T12:00:00",{"title":9279,"description":9824},"尼克的小窝 开发记录","posts\u002Fsublog-evolution\u002Findex",[9833,9834,2050,851],"架构","React","2026-07-23","VMQ6A2K8GQ5t3DSlezWPd3KgXnmDVMn9QVeaI28Hcp8",{"id":9838,"title":9839,"author":6,"body":9840,"description":11003,"difficulty":1909,"draft":840,"extension":841,"image":11004,"meta":11005,"navigation":88,"path":11006,"pinned":840,"published":11007,"seo":11008,"series":6,"seriesOrder":6,"stem":11009,"tags":11010,"updated":11014,"__hash__":11015},"posts\u002Fposts\u002Fip-geolocation-guide\u002Findex.md","IP 属地是怎么查出来的？本地查询与在线查询方案对比",{"type":8,"value":9841,"toc":10979},[9842,9844,9850,9853,9864,9874,9878,9884,9897,9903,9914,9921,9924,9931,9935,9946,9950,9961,9964,9968,9974,9980,9983,9987,9994,10012,10015,10018,10022,10053,10092,10098,10102,10241,10277,10286,10290,10330,10335,10339,10350,10353,10387,10390,10394,10397,10401,10592,10596,10679,10683,10690,10797,10801,10810,10896,10899,10902,10938,10941,10944,10952,10973,10976],[11,9843,9284],{"id":9284},[15,9845,9846,9847],{},"很多网站会在「登录记录」「安全中心」里显示你的登录 IP 归属地，比如「福建·福州 · 中国移动」。你可能好奇过：",[1948,9848,9849],{},"服务器是怎么根据一个 IP 就判断出我在哪个城市、哪个运营商的？",[15,9851,9852],{},"这篇科普文章讲清楚三件事：",[284,9854,9855,9858,9861],{},[287,9856,9857],{},"IP 属地到底查的是什么；",[287,9859,9860],{},"纯本地查询为什么能做到毫秒级且不联网；",[287,9862,9863],{},"几种主流查询方案的优缺点对比，方便你按场景选型。",[9865,9866,9867],"blockquote",{},[15,9868,9869,9870,9873],{},"说明：文中用到的 IP 均为 RFC 5737 保留的文档示例段（TEST-NET-2，",[29,9871,9872],{},"198.51.100.0\u002F24","），不会对应任何真实地址。",[11,9875,9877],{"id":9876},"先想清楚ip-属地查的是什么","先想清楚：IP 属地查的是什么",[15,9879,9880,9881,2600],{},"一个常见误区是「每个 IP 对应一个具体地址」。实际上 IP 属地的本质是 ",[1948,9882,9883],{},"IP 段与归属地的映射",[15,9885,9886,9887,3852],{},"运营商和机构在申请 IP 时，是按",[1948,9888,9889,9890,9896],{},"网段（CIDR）",[1948,9891,9892,9893,9895],{},"成块分配的。比如 ",[29,9894,9872],{}," 这一个段，一整块都归属同一地区、同一运营商。所以归属地数据天然可以","按段存储",[22,9898,9901],{"className":9899,"code":9900,"language":2019,"meta":27},[2017],"IP 起始          IP 结束         归属地\n────────────────────────────────────────\n198.51.100.0     198.51.100.255  中国|福建|福州|移动\n198.51.101.0     198.51.101.255  中国|浙江|杭州|电信\n",[29,9902,9900],{"__ignoreMap":27},[15,9904,9905,9906,9909,9910,9913],{},"全球公网 IP 约有 42 亿个，但",[1948,9907,9908],{},"网段只有几十万条","。正因为存的是「段」而不是「每个 IP」，完整的归属地数据文件才只有几 MB（ip2region 的 ",[29,9911,9912],{},".xdb"," 约 11MB）。这是「本地离线查询」可行的第一前提。",[15,9915,9916,9917,9920],{},"所以，",[1948,9918,9919],{},"IP 属地从来不是精确到街道的定位","，它返回的是段级的「省｜市｜运营商」粗粒度信息。",[11,9922,9923],{"id":9923},"纯本地查询的原理",[15,9925,9926,9927,9930],{},"以开源的 ",[1948,9928,9929],{},"ip2region"," 为例，它的查询完全在本地完成，不联网、不请求第三方，几十微秒到毫秒级返回。",[280,9932,9934],{"id":9933},"第一步把-ip-变成整数","第一步：把 IP 变成整数",[15,9936,9937,9938,9941,9942,9945],{},"以 ",[29,9939,9940],{},"198.51.100.42"," 为例，它会转成一个 32 位整数 ",[29,9943,9944],{},"0xC633642A","。IP 的四个数字本质就是 4 个字节，拼起来就是一个整数，这一步没有任何玄学。",[280,9947,9949],{"id":9948},"第二步二分查找","第二步：二分查找",[15,9951,9952,9953,9956,9957,9960],{},"在按起始 IP 排序的段列表里做",[1948,9954,9955],{},"二分查找","，找到满足 ",[29,9958,9959],{},"段的起始IP ≤ 目标整数 ≤ 段的结束IP"," 的那一段。",[15,9962,9963],{},"二分查找的复杂度是 O(log N)，N 是几十万条，所以很快——这就是「不靠网络也能毫秒返回」的核心。",[280,9965,9967],{"id":9966},"第三步xdb-文件的两级结构","第三步：.xdb 文件的两级结构",[15,9969,9970,9971,9973],{},"ip2region 的 ",[29,9972,9912],{}," 不是简单顺序文件，内部分两个区，进一步加速：",[22,9975,9978],{"className":9976,"code":9977,"language":2019,"meta":27},[2017],"┌─────────────────────────────┐\n│  索引区 (Index)             │  \u002F\u002F 每段一条定长记录：startIP \u002F endIP \u002F 数据区指针 + 数据长度\n│  …… 二分快速定位段 ……       │\n├─────────────────────────────┤\n│  数据区 (Data)              │  \u002F\u002F 真正存放各段的地点字符串\n│  ……                         │\n└─────────────────────────────┘\n",[29,9979,9977],{"__ignoreMap":27},[15,9981,9982],{},"先二分索引区定位到「是哪一段」，再按指针去数据区读出地点字符串，一次磁盘读就能拿到结果。",[280,9984,9986],{"id":9985},"为什么离线数据不会很快过期","为什么「离线数据」不会很快过期",[15,9988,9989,9990,9993],{},"IP 归属取决于",[1948,9991,9992],{},"注册机构和运营商的分配","，一个网段通常长期稳定属于某地区、某运营商。所以：",[2256,9995,9996,9999,10009],{},[287,9997,9998],{},"下载一次数据文件，可以用很久；",[287,10000,10001,10002,10008],{},"归属偶尔变化时，",[1948,10003,10004,10005,10007],{},"每月更新一次 ",[29,10006,9912],{}," 文件","即可（替换文件、不用重启服务，见下文实战）；",[287,10010,10011],{},"数据源来自 IANA\u002FAPNIC 等机构的分配表与公开归属信息，不是实时探测。",[11,10013,10014],{"id":10014},"主流查询方案对比",[15,10016,10017],{},"实际开发中，查询 IP 属地主要有三条路线。",[280,10019,10021],{"id":10020},"方案一ip2region纯本地离线推荐","方案一：ip2region（纯本地离线，推荐）",[22,10023,10025],{"className":4133,"code":10024,"language":171,"meta":27,"style":27},"\u002F\u002F Go 中的用法（github.com\u002Flionsoul2014\u002Fip2region\u002Fbinding\u002Fgolang）\nregion, err := searcher.SearchByStr(ip)\n\u002F\u002F 返回类似 \"中国|福建省|福州市|移动\"\n",[29,10026,10027,10032,10048],{"__ignoreMap":27},[32,10028,10029],{"class":34,"line":35},[32,10030,10031],{"class":38},"\u002F\u002F Go 中的用法（github.com\u002Flionsoul2014\u002Fip2region\u002Fbinding\u002Fgolang）\n",[32,10033,10034,10037,10039,10042,10045],{"class":34,"line":42},[32,10035,10036],{"class":66},"region, err ",[32,10038,4160],{"class":130},[32,10040,10041],{"class":66}," searcher.",[32,10043,10044],{"class":45},"SearchByStr",[32,10046,10047],{"class":66},"(ip)\n",[32,10049,10050],{"class":34,"line":85},[32,10051,10052],{"class":38},"\u002F\u002F 返回类似 \"中国|福建省|福州市|移动\"\n",[2033,10054,10055,10063],{},[2036,10056,10057],{},[2039,10058,10059,10061],{},[2042,10060,3391],{},[2042,10062,3394],{},[2055,10064,10065,10073,10081],{},[2039,10066,10067,10070],{},[2060,10068,10069],{},"免费、无网络依赖、无 QPS 限制",[2060,10071,10072],{},"段级粒度，个别段可能显示到相邻地区",[2039,10074,10075,10078],{},[2060,10076,10077],{},"毫秒级查询，永不因外部服务挂掉",[2060,10079,10080],{},"数据按月更新，最新分配的段可能短暂缺失",[2039,10082,10083,10086],{},[2060,10084,10085],{},"数据文件可随版本更新，不重启",[2060,10087,10088,10089],{},"只返回省市区+运营商，无经纬度；",[1948,10090,10091],{},"仅支持 IPv4",[15,10093,10094,10097],{},[1948,10095,10096],{},"适用","：博客\u002F后台的「登录记录显示归属地」这类展示用途。",[280,10099,10101],{"id":10100},"方案二在线-apiip-api-高德-腾讯位置服务","方案二：在线 API（ip-api \u002F 高德 \u002F 腾讯位置服务）",[22,10103,10105],{"className":4133,"code":10104,"language":171,"meta":27,"style":27},"\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",[29,10106,10107,10112,10140,10150,10181,10197,10210,10220,10224,10236],{"__ignoreMap":27},[32,10108,10109],{"class":34,"line":35},[32,10110,10111],{"class":38},"\u002F\u002F 以 ip-api.com 为例（免费版约 45 次\u002F分钟，非商用）\n",[32,10113,10114,10117,10119,10121,10124,10127,10130,10133,10135,10137],{"class":34,"line":42},[32,10115,10116],{"class":66},"ctx, cancel ",[32,10118,4160],{"class":130},[32,10120,5633],{"class":66},[32,10122,10123],{"class":45},"WithTimeout",[32,10125,10126],{"class":66},"(context.",[32,10128,10129],{"class":45},"Background",[32,10131,10132],{"class":66},"(), ",[32,10134,2658],{"class":53},[32,10136,4366],{"class":130},[32,10138,10139],{"class":66},"time.Second)\n",[32,10141,10142,10145,10148],{"class":34,"line":85},[32,10143,10144],{"class":130},"defer",[32,10146,10147],{"class":45}," cancel",[32,10149,2904],{"class":66},[32,10151,10152,10155,10157,10160,10163,10166,10169,10171,10174,10176,10179],{"class":34,"line":92},[32,10153,10154],{"class":66},"req, _ ",[32,10156,4160],{"class":130},[32,10158,10159],{"class":66}," http.",[32,10161,10162],{"class":45},"NewRequestWithContext",[32,10164,10165],{"class":66},"(ctx, ",[32,10167,10168],{"class":49},"\"GET\"",[32,10170,798],{"class":66},[32,10172,10173],{"class":49},"\"https:\u002F\u002Fip-api.com\u002Fjson\u002F198.51.100.42\"",[32,10175,798],{"class":66},[32,10177,10178],{"class":53},"nil",[32,10180,3019],{"class":66},[32,10182,10183,10186,10188,10191,10194],{"class":34,"line":98},[32,10184,10185],{"class":66},"resp, err ",[32,10187,4160],{"class":130},[32,10189,10190],{"class":66}," http.DefaultClient.",[32,10192,10193],{"class":45},"Do",[32,10195,10196],{"class":66},"(req)\n",[32,10198,10199,10202,10204,10206,10208],{"class":34,"line":127},[32,10200,10201],{"class":130},"if",[32,10203,4574],{"class":66},[32,10205,4577],{"class":130},[32,10207,4580],{"class":53},[32,10209,2349],{"class":66},[32,10211,10212,10214,10217],{"class":34,"line":146},[32,10213,4391],{"class":130},[32,10215,10216],{"class":49}," \"未知\"",[32,10218,10219],{"class":38}," \u002F\u002F 断网\u002F超时降级：展示「未知」而不是报错\n",[32,10221,10222],{"class":34,"line":162},[32,10223,2003],{"class":66},[32,10225,10226,10228,10231,10234],{"class":34,"line":168},[32,10227,10144],{"class":130},[32,10229,10230],{"class":66}," resp.Body.",[32,10232,10233],{"class":45},"Close",[32,10235,2904],{"class":66},[32,10237,10238],{"class":34,"line":182},[32,10239,10240],{"class":38},"\u002F\u002F 解析 JSON，status != \"success\" 时同样降级\n",[2033,10242,10243,10251],{},[2036,10244,10245],{},[2039,10246,10247,10249],{},[2042,10248,3391],{},[2042,10250,3394],{},[2055,10252,10253,10261,10269],{},[2039,10254,10255,10258],{},[2060,10256,10257],{},"数据最新、可能更精确",[2060,10259,10260],{},"有频率限制（免费版限 QPS）",[2039,10262,10263,10266],{},[2060,10264,10265],{},"部分服务还返回经纬度\u002F时区",[2060,10267,10268],{},"依赖第三方，断网\u002F被墙\u002F限流时拿不到",[2039,10270,10271,10274],{},[2060,10272,10273],{},"无需维护数据文件",[2060,10275,10276],{},"每次查询都要发一次 HTTP 请求",[15,10278,10279,10281,10282,10285],{},[1948,10280,10096],{},"：对精度要求高、且能接受外部依赖与限流的场景。",[1948,10283,10284],{},"务必加超时和降级","，否则第三方一抖，你自己的页面就卡住。",[280,10287,10289],{"id":10288},"方案三maxmind-geolite2离线-mmdb","方案三：MaxMind GeoLite2（离线 .mmdb）",[2033,10291,10292,10300],{},[2036,10293,10294],{},[2039,10295,10296,10298],{},[2042,10297,3391],{},[2042,10299,3394],{},[2055,10301,10302,10310,10322],{},[2039,10303,10304,10307],{},[2060,10305,10306],{},"全球数据，覆盖广、精度较好",[2060,10308,10309],{},"免费但需注册账号并遵守 EULA；商用需 GeoIP2 商业授权",[2039,10311,10312,10319],{},[2060,10313,10314,10315,10318],{},"标准 ",[29,10316,10317],{},".mmdb"," 格式，各语言都有库，支持 IPv6",[2060,10320,10321],{},"国内 IP 的省市粒度有时不如 ip2region 细",[2039,10323,10324,10327],{},[2060,10325,10326],{},"离线查询",[2060,10328,10329],{},"数据同样要定期更新",[15,10331,10332,10334],{},[1948,10333,10096],{},"：面向全球用户的场景。",[11,10336,10338],{"id":10337},"ipv6本地离线方案的一个盲点","IPv6：本地离线方案的一个盲点",[15,10340,10341,10342,10345,10346,10349],{},"前面聊的都是 IPv4（32 位）。但家宽和移动网络越来越多地",[1948,10343,10344],{},"只下发 IPv6","（128 位）地址，而 ",[1948,10347,10348],{},"ip2region 目前只支持 IPv4","——拿到一个纯 IPv6 地址是查不出归属地的，本地库会返回空结果。",[15,10351,10352],{},"实际影响与常见应对：",[284,10354,10355,10365,10371,10381],{},[287,10356,10357,10360,10361,10364],{},[1948,10358,10359],{},"判断版本","：Go 里 ",[29,10362,10363],{},"net.ParseIP(ip).To4() != nil"," 就是 IPv4，先判断再查询；",[287,10366,10367,10370],{},[1948,10368,10369],{},"降级展示","：IPv6 直接显示「IPv6 地址」，不显示属地，诚实第一；",[287,10372,10373,10376,10377,10380],{},[1948,10374,10375],{},"优先取 IPv4","：有反向代理时，从 ",[29,10378,10379],{},"X-Forwarded-For"," 里挑一个 IPv4 来查；",[287,10382,10383,10386],{},[1948,10384,10385],{},"需要 v6 归属","：换支持 IPv6 的数据源（如 MaxMind GeoLite2）。",[15,10388,10389],{},"这一点在接入时很容易漏掉，上线前记得拿一个纯 IPv6 地址回归一遍。",[11,10391,10393],{"id":10392},"实战给登录记录加-ip-属地","实战：给登录记录加 IP 属地",[15,10395,10396],{},"subLog 博客的登录记录就用了 ip2region。完整接入一共四步。",[280,10398,10400],{"id":10399},"_1-进程启动时加载数据文件","1. 进程启动时加载数据文件",[22,10402,10404],{"className":4133,"code":10403,"language":171,"meta":27,"style":27},"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",[29,10405,10406,10412,10421,10430,10434,10438,10443,10470,10474,10487,10509,10521,10528,10532,10548,10560,10566,10570,10581,10588],{"__ignoreMap":27},[32,10407,10408,10410],{"class":34,"line":35},[32,10409,4141],{"class":130},[32,10411,5539],{"class":66},[32,10413,10414,10416,10419],{"class":34,"line":42},[32,10415,6959],{"class":49},[32,10417,10418],{"class":45},"sync\u002Fatomic",[32,10420,278],{"class":49},[32,10422,10423,10425,10428],{"class":34,"line":85},[32,10424,6959],{"class":49},[32,10426,10427],{"class":45},"github.com\u002Flionsoul2014\u002Fip2region\u002Fbinding\u002Fgolang\u002Fxdb",[32,10429,278],{"class":49},[32,10431,10432],{"class":34,"line":92},[32,10433,3019],{"class":66},[32,10435,10436],{"class":34,"line":98},[32,10437,89],{"emptyLinePlaceholder":88},[32,10439,10440],{"class":34,"line":127},[32,10441,10442],{"class":38},"\u002F\u002F 用 atomic.Pointer 持有查询器，方便后面热更新\n",[32,10444,10445,10447,10450,10453,10455,10458,10460,10463,10465,10468],{"class":34,"line":146},[32,10446,7040],{"class":130},[32,10448,10449],{"class":66}," searcher ",[32,10451,10452],{"class":45},"atomic",[32,10454,2831],{"class":66},[32,10456,10457],{"class":45},"Pointer",[32,10459,3519],{"class":66},[32,10461,10462],{"class":45},"xdb",[32,10464,2831],{"class":66},[32,10466,10467],{"class":45},"Searcher",[32,10469,501],{"class":66},[32,10471,10472],{"class":34,"line":162},[32,10473,89],{"emptyLinePlaceholder":88},[32,10475,10476,10478,10481,10483,10485],{"class":34,"line":168},[32,10477,4334],{"class":130},[32,10479,10480],{"class":45}," initSearcher",[32,10482,5582],{"class":66},[32,10484,4379],{"class":130},[32,10486,2349],{"class":66},[32,10488,10489,10492,10494,10497,10499,10501,10504,10506],{"class":34,"line":182},[32,10490,10491],{"class":66},"    content, err ",[32,10493,4160],{"class":130},[32,10495,10496],{"class":66}," os.",[32,10498,7179],{"class":45},[32,10500,2709],{"class":66},[32,10502,10503],{"class":49},"\".\u002Fdata\u002Fip2region.xdb\"",[32,10505,3173],{"class":66},[32,10507,10508],{"class":38},"\u002F\u002F 约 11MB，一次性读入内存\n",[32,10510,10511,10513,10515,10517,10519],{"class":34,"line":205},[32,10512,2786],{"class":130},[32,10514,4574],{"class":66},[32,10516,4577],{"class":130},[32,10518,4580],{"class":53},[32,10520,2349],{"class":66},[32,10522,10523,10525],{"class":34,"line":214},[32,10524,4587],{"class":130},[32,10526,10527],{"class":66}," err\n",[32,10529,10530],{"class":34,"line":219},[32,10531,2669],{"class":66},[32,10533,10534,10537,10539,10542,10545],{"class":34,"line":225},[32,10535,10536],{"class":66},"    s, err ",[32,10538,4160],{"class":130},[32,10540,10541],{"class":66}," xdb.",[32,10543,10544],{"class":45},"NewWithBuffer",[32,10546,10547],{"class":66},"(content)\n",[32,10549,10550,10552,10554,10556,10558],{"class":34,"line":236},[32,10551,2786],{"class":130},[32,10553,4574],{"class":66},[32,10555,4577],{"class":130},[32,10557,4580],{"class":53},[32,10559,2349],{"class":66},[32,10561,10562,10564],{"class":34,"line":247},[32,10563,4587],{"class":130},[32,10565,10527],{"class":66},[32,10567,10568],{"class":34,"line":252},[32,10569,2669],{"class":66},[32,10571,10572,10575,10578],{"class":34,"line":261},[32,10573,10574],{"class":66},"    searcher.",[32,10576,10577],{"class":45},"Store",[32,10579,10580],{"class":66},"(s)\n",[32,10582,10583,10585],{"class":34,"line":266},[32,10584,4391],{"class":130},[32,10586,10587],{"class":53}," nil\n",[32,10589,10590],{"class":34,"line":476},[32,10591,2003],{"class":66},[280,10593,10595],{"id":10594},"_2-查询一次","2. 查询一次",[22,10597,10599],{"className":4133,"code":10598,"language":171,"meta":27,"style":27},"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",[29,10600,10601,10623,10642,10654,10661,10665,10675],{"__ignoreMap":27},[32,10602,10603,10605,10608,10610,10613,10616,10618,10621],{"class":34,"line":35},[32,10604,4334],{"class":130},[32,10606,10607],{"class":45}," ipRegion",[32,10609,2709],{"class":66},[32,10611,10612],{"class":2712},"ip",[32,10614,10615],{"class":130}," string",[32,10617,3173],{"class":66},[32,10619,10620],{"class":130},"string",[32,10622,2349],{"class":66},[32,10624,10625,10628,10630,10632,10635,10638,10640],{"class":34,"line":42},[32,10626,10627],{"class":66},"    region, err ",[32,10629,4160],{"class":130},[32,10631,10041],{"class":66},[32,10633,10634],{"class":45},"Load",[32,10636,10637],{"class":66},"().",[32,10639,10044],{"class":45},[32,10641,10047],{"class":66},[32,10643,10644,10646,10648,10650,10652],{"class":34,"line":85},[32,10645,2786],{"class":130},[32,10647,4574],{"class":66},[32,10649,4577],{"class":130},[32,10651,4580],{"class":53},[32,10653,2349],{"class":66},[32,10655,10656,10658],{"class":34,"line":92},[32,10657,4587],{"class":130},[32,10659,10660],{"class":49}," \"未知\"\n",[32,10662,10663],{"class":34,"line":98},[32,10664,2669],{"class":66},[32,10666,10667,10669,10672],{"class":34,"line":127},[32,10668,4391],{"class":130},[32,10670,10671],{"class":66}," region ",[32,10673,10674],{"class":38},"\u002F\u002F \"中国|福建省|福州市|移动\"\n",[32,10676,10677],{"class":34,"line":146},[32,10678,2003],{"class":66},[280,10680,10682],{"id":10681},"_3-加一层内存缓存","3. 加一层内存缓存",[15,10684,10685,10686,10689],{},"登录记录里同一个 IP 可能反复出现，用 ",[29,10687,10688],{},"sync.Map"," 按 IP 缓存结果，一次查询终身复用：",[22,10691,10693],{"className":4133,"code":10692,"language":171,"meta":27,"style":27},"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",[29,10694,10695,10710,10714,10733,10750,10761,10765,10776,10786,10793],{"__ignoreMap":27},[32,10696,10697,10699,10702,10705,10707],{"class":34,"line":35},[32,10698,7040],{"class":130},[32,10700,10701],{"class":66}," regionCache ",[32,10703,10704],{"class":45},"sync",[32,10706,2831],{"class":66},[32,10708,10709],{"class":45},"Map\n",[32,10711,10712],{"class":34,"line":42},[32,10713,89],{"emptyLinePlaceholder":88},[32,10715,10716,10718,10721,10723,10725,10727,10729,10731],{"class":34,"line":85},[32,10717,4334],{"class":130},[32,10719,10720],{"class":45}," ipRegionCached",[32,10722,2709],{"class":66},[32,10724,10612],{"class":2712},[32,10726,10615],{"class":130},[32,10728,3173],{"class":66},[32,10730,10620],{"class":130},[32,10732,2349],{"class":66},[32,10734,10735,10737,10740,10742,10745,10747],{"class":34,"line":92},[32,10736,2786],{"class":130},[32,10738,10739],{"class":66}," v, ok ",[32,10741,4160],{"class":130},[32,10743,10744],{"class":66}," regionCache.",[32,10746,10634],{"class":45},[32,10748,10749],{"class":66},"(ip); ok {\n",[32,10751,10752,10754,10757,10759],{"class":34,"line":98},[32,10753,4587],{"class":130},[32,10755,10756],{"class":66}," v.(",[32,10758,10620],{"class":130},[32,10760,3019],{"class":66},[32,10762,10763],{"class":34,"line":127},[32,10764,2669],{"class":66},[32,10766,10767,10770,10772,10774],{"class":34,"line":146},[32,10768,10769],{"class":66},"    region ",[32,10771,4160],{"class":130},[32,10773,10607],{"class":45},[32,10775,10047],{"class":66},[32,10777,10778,10781,10783],{"class":34,"line":162},[32,10779,10780],{"class":66},"    regionCache.",[32,10782,10577],{"class":45},[32,10784,10785],{"class":66},"(ip, region)\n",[32,10787,10788,10790],{"class":34,"line":168},[32,10789,4391],{"class":130},[32,10791,10792],{"class":66}," region\n",[32,10794,10795],{"class":34,"line":182},[32,10796,2003],{"class":66},[280,10798,10800],{"id":10799},"_4-数据热更新不用重启","4. 数据热更新（不用重启）",[15,10802,10803,10804,10806,10807,10809],{},"ip2region 每月发布新数据。下载新文件后重新 ",[29,10805,10544],{}," 并 ",[29,10808,10577],{}," 覆盖即可，进程完全不用重启：",[22,10811,10813],{"className":4133,"code":10812,"language":171,"meta":27,"style":27},"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",[29,10814,10815,10839,10852,10864,10870,10874,10886,10892],{"__ignoreMap":27},[32,10816,10817,10819,10822,10824,10827,10830,10833,10835,10837],{"class":34,"line":35},[32,10818,4334],{"class":130},[32,10820,10821],{"class":45}," reloadSearcher",[32,10823,2709],{"class":66},[32,10825,10826],{"class":2712},"newContent",[32,10828,10829],{"class":66}," []",[32,10831,10832],{"class":130},"byte",[32,10834,3173],{"class":66},[32,10836,4379],{"class":130},[32,10838,2349],{"class":66},[32,10840,10841,10843,10845,10847,10849],{"class":34,"line":42},[32,10842,10536],{"class":66},[32,10844,4160],{"class":130},[32,10846,10541],{"class":66},[32,10848,10544],{"class":45},[32,10850,10851],{"class":66},"(newContent)\n",[32,10853,10854,10856,10858,10860,10862],{"class":34,"line":85},[32,10855,2786],{"class":130},[32,10857,4574],{"class":66},[32,10859,4577],{"class":130},[32,10861,4580],{"class":53},[32,10863,2349],{"class":66},[32,10865,10866,10868],{"class":34,"line":92},[32,10867,4587],{"class":130},[32,10869,10527],{"class":66},[32,10871,10872],{"class":34,"line":98},[32,10873,2669],{"class":66},[32,10875,10876,10878,10880,10883],{"class":34,"line":127},[32,10877,10574],{"class":66},[32,10879,10577],{"class":45},[32,10881,10882],{"class":66},"(s) ",[32,10884,10885],{"class":38},"\u002F\u002F 原子切换，老查询继续走旧数据，新查询走新数据\n",[32,10887,10888,10890],{"class":34,"line":146},[32,10889,4391],{"class":130},[32,10891,10587],{"class":53},[32,10893,10894],{"class":34,"line":162},[32,10895,2003],{"class":66},[11,10897,10898],{"id":10898},"局限与诚实提醒",[15,10900,10901],{},"无论用哪种方案，都要清楚 IP 属地的边界：",[2256,10903,10904,10910,10916,10922,10928],{},[287,10905,10906,10909],{},[1948,10907,10908],{},"段级精度","：网段归属是「绝大多数」准确，个别 IP（大型企业自建段、跨省调度段）可能显示到相邻地区或只到省。",[287,10911,10912,10915],{},[1948,10913,10914],{},"运营商级，不是定位","：拿到的是「省｜市｜运营商」，不是精确街道或基站位置——精确位置需要 GPS 或运营商三角定位，纯 IP 归属做不到。",[287,10917,10918,10921],{},[1948,10919,10920],{},"动态 IP","：家宽通常是动态 IP，重拨会变，所以「上次登录在福州，这次在广州」不一定是异常，可能是 IP 变了。",[287,10923,10924,10927],{},[1948,10925,10926],{},"别用于安全判定","：用 IP 段做封禁或风控判定风险自负，段级数据不适合当精确依据。",[287,10929,10930,10933,10934,10937],{},[1948,10931,10932],{},"隐私与合规","：IP 地址属于个人信息（《个人信息保护法》）。归属地展示建议只出现在",[1948,10935,10936],{},"用户本人可见","的页面（登录记录、个人中心）；若在评论区等公开场合展示，建议只显示到省市并明确告知用户。",[11,10939,10940],{"id":10940},"小结",[15,10942,10943],{},"一句话总结：",[9865,10945,10946],{},[15,10947,10948,10951],{},[1948,10949,10950],{},"IP 属地 = 几十万条「IP 段 → 归属地」映射 + 一次二分查找。"," 它查的是网段归属，不是精确位置。",[2256,10953,10954,10960,10967],{},[287,10955,10956,10957,10959],{},"想省心、免费、稳定，用 ",[1948,10958,9929],{}," 本地离线查询（注意 IPv6 盲点）；",[287,10961,10962,10963,10966],{},"想要最新最细、能接受外部依赖，用",[1948,10964,10965],{},"在线 API","（记得超时降级）；",[287,10968,10969,10970,2600],{},"面向全球用户、要 IPv6 覆盖，用 ",[1948,10971,10972],{},"MaxMind GeoLite2",[15,10974,10975],{},"对个人博客「登录记录显示归属地」这种场景，本地离线方案通常是性价比最高的选择。",[824,10977,10978],{},"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":27,"searchDepth":42,"depth":85,"links":10980},[10981,10982,10983,10989,10994,10995,11001,11002],{"id":9284,"depth":42,"text":9284},{"id":9876,"depth":42,"text":9877},{"id":9923,"depth":42,"text":9923,"children":10984},[10985,10986,10987,10988],{"id":9933,"depth":85,"text":9934},{"id":9948,"depth":85,"text":9949},{"id":9966,"depth":85,"text":9967},{"id":9985,"depth":85,"text":9986},{"id":10014,"depth":42,"text":10014,"children":10990},[10991,10992,10993],{"id":10020,"depth":85,"text":10021},{"id":10100,"depth":85,"text":10101},{"id":10288,"depth":85,"text":10289},{"id":10337,"depth":42,"text":10338},{"id":10392,"depth":42,"text":10393,"children":10996},[10997,10998,10999,11000],{"id":10399,"depth":85,"text":10400},{"id":10594,"depth":85,"text":10595},{"id":10681,"depth":85,"text":10682},{"id":10799,"depth":85,"text":10800},{"id":10898,"depth":42,"text":10898},{"id":10940,"depth":42,"text":10940},"科普 IP 属地查询的原理：IP 段与归属地的映射、纯本地离线查询为何能做到毫秒级，对比 ip2region、在线 API 与 MaxMind 三种方案的优劣，并附登录记录场景的 Go 实战与 IPv6 盲点提醒。","\u002Fposts\u002Fip-geolocation-guide\u002Fimg\u002Fcover.svg",{},"\u002Fposts\u002Fip-geolocation-guide","2026-08-07T12:00:00",{"title":9839,"description":11003},"posts\u002Fip-geolocation-guide\u002Findex",[11011,11012,11013],"IP","网络","科普","2026-08-16","RY1UPgJaiKsjpC51uqMyG7w_GpCRS13_CX5j1hDD_dM",{"id":11017,"title":11018,"author":6,"body":11019,"description":12541,"difficulty":839,"draft":840,"extension":841,"image":12542,"meta":12543,"navigation":88,"path":12544,"pinned":840,"published":12545,"seo":12546,"series":6098,"seriesOrder":85,"stem":12547,"tags":12548,"updated":11014,"__hash__":12551},"posts\u002Fposts\u002Fgo-embed-react-single-binary\u002Findex.md","Go 单文件打包进阶：React 前端 + 交叉编译，一次踩完所有坑",{"type":8,"value":11020,"toc":12520},[11021,11023,11034,11045,11052,11055,11091,11093,11102,11104,11108,11118,11162,11204,11222,11226,11247,11278,11282,11574,11577,11615,11619,11962,11965,11969,12166,12176,12178,12189,12203,12216,12227,12231,12238,12254,12267,12272,12276,12292,12297,12342,12356,12367,12371,12386,12395,12417,12426,12430,12440,12442,12469,12478,12480,12483,12510,12517],[11,11022,2576],{"id":2576},[15,11024,11025,11026,11029,11030,11033],{},"这是「Go 单文件打包」系列第三篇。上一篇（",[2529,11027,11028],{"href":7858},"把前端塞进二进制","）记录了一个 Gin + Vue 3 项目的 embed 方案。这次换了个项目继续踩坑：",[1948,11031,11032],{},"Magic Img","——一个自托管、本地优先的 AI 生图提示词管理工具，技术栈是：",[2256,11035,11036,11039],{},[287,11037,11038],{},"前端：React 19 + TypeScript + Vite + Tailwind v4（UI 库 Magic UI + shadcn\u002Fui）",[287,11040,11041,11042,6642],{},"后端：Go + Gin + GORM + SQLite（",[1948,11043,11044],{},"modernc 纯 Go 驱动，无 cgo",[15,11046,11047,11048,11051],{},"目标一样：",[1948,11049,11050],{},"一个二进制拖到任何机器上就能跑","——前端、API、数据库全部内嵌。",[15,11053,11054],{},"和上篇不同的地方有三个，正好是这篇要讲的重点：",[284,11056,11057,11071,11084],{},[287,11058,11059,11060,11066,11067,11070],{},"前端用了 ",[1948,11061,11062,11063],{},"HashRouter + Vite base ",[29,11064,11065],{},"\u002Fdist","，服务端",[1948,11068,11069],{},"不再需要 SPA fallback","（上篇坑一的新解法）；",[287,11072,11073,11074,11080,11081,11083],{},"embed 用 ",[1948,11075,11076,11079],{},[29,11077,11078],{},"all:"," 前缀","一劳永逸解决 ",[29,11082,7552],{}," 开头文件被排除的问题（上篇坑二的升级解法）；",[287,11085,11086,11087,11090],{},"需要",[1948,11088,11089],{},"在 Windows 上交叉编译 Linux 产物","，踩了「生成的文件无法在 Linux 执行」的坑。",[11,11092,6908],{"id":6908},[15,11094,11095,11096,11098,11099,11101],{},"和上篇一致：",[29,11097,6914],{}," 在编译期把前端 ",[29,11100,6918],{}," 塞进二进制，运行时由 Go 直接提供静态服务。差异在于路由策略和缓存策略，后面细说。",[11,11103,6922],{"id":6922},[280,11105,11107],{"id":11106},"_1-前端hash-路由-base-dist","1. 前端：hash 路由 + base \u002Fdist",[15,11109,11110,11111,11114,11115,3852],{},"vite.config.ts 里固定 ",[29,11112,11113],{},"base: \"\u002Fdist\u002F\"","，路由用 ",[29,11116,11117],{},"HashRouter",[22,11119,11121],{"className":7603,"code":11120,"language":7605,"meta":27,"style":27},"\u002F\u002F vite.config.ts\nexport default defineConfig({\n  base: \"\u002Fdist\u002F\",   \u002F\u002F 所有资源路径以 \u002Fdist\u002F 开头\n  ...\n})\n",[29,11122,11123,11127,11139,11153,11158],{"__ignoreMap":27},[32,11124,11125],{"class":34,"line":35},[32,11126,7612],{"class":38},[32,11128,11129,11131,11133,11136],{"class":34,"line":42},[32,11130,131],{"class":130},[32,11132,3452],{"class":130},[32,11134,11135],{"class":45}," defineConfig",[32,11137,11138],{"class":66},"({\n",[32,11140,11141,11144,11147,11150],{"class":34,"line":85},[32,11142,11143],{"class":66},"  base: ",[32,11145,11146],{"class":49},"\"\u002Fdist\u002F\"",[32,11148,11149],{"class":66},",   ",[32,11151,11152],{"class":38},"\u002F\u002F 所有资源路径以 \u002Fdist\u002F 开头\n",[32,11154,11155],{"class":34,"line":92},[32,11156,11157],{"class":130},"  ...\n",[32,11159,11160],{"class":34,"line":98},[32,11161,3257],{"class":66},[22,11163,11167],{"className":11164,"code":11165,"language":11166,"meta":27,"style":27},"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",[29,11168,11169,11174,11182,11196],{"__ignoreMap":27},[32,11170,11171],{"class":34,"line":35},[32,11172,11173],{"class":38},"\u002F\u002F App.tsx —— HashRouter：路径信息全在 location.hash 里\n",[32,11175,11176,11178,11180],{"class":34,"line":42},[32,11177,1625],{"class":66},[32,11179,11117],{"class":53},[32,11181,635],{"class":66},[32,11183,11184,11186,11189,11192,11194],{"class":34,"line":85},[32,11185,3284],{"class":66},[32,11187,11188],{"class":53},"Routes",[32,11190,11191],{"class":66},">...\u003C\u002F",[32,11193,11188],{"class":53},[32,11195,635],{"class":66},[32,11197,11198,11200,11202],{"class":34,"line":92},[32,11199,3262],{"class":66},[32,11201,11117],{"class":53},[32,11203,635],{"class":66},[15,11205,11206,11209,11210,11213,11214,11217,11218,11221],{},[1948,11207,11208],{},"为什么这样选","：history 路由需要服务端把未知路径全部 fallback 回 ",[29,11211,11212],{},"index.html","（上篇坑一）。hash 路由下浏览器请求的永远是 ",[29,11215,11216],{},"\u002Fdist\u002F"," 这一个入口，",[1948,11219,11220],{},"静态服务直接返回 index.html 就完事，一个 fallback 都不用写","。内嵌场景里这是最省心的组合。",[280,11223,11225],{"id":11224},"_2-构建产物复制进后端目录","2. 构建产物复制进后端目录",[15,11227,11228,11231,11232,11234,11235,11238,11239,11242,11243,11246],{},[29,11229,11230],{},"npm run build"," 除了编译，还会把 ",[29,11233,6918],{}," 复制到 ",[29,11236,11237],{},"backend\u002Fweb\u002Fdist","（一个小脚本 ",[29,11240,11241],{},"scripts\u002Fcopy-dist.mjs"," 干这个活），供 ",[29,11244,11245],{},"go:embed"," 使用：",[22,11248,11250],{"className":5925,"code":11249,"language":5927,"meta":27,"style":27},"\u002F\u002F frontend\u002Fpackage.json\n\"scripts\": {\n  \"build\": \"tsc -b && vite build && node scripts\u002Fcopy-dist.mjs\"\n}\n",[29,11251,11252,11257,11264,11274],{"__ignoreMap":27},[32,11253,11254],{"class":34,"line":35},[32,11255,11256],{"class":38},"\u002F\u002F frontend\u002Fpackage.json\n",[32,11258,11259,11262],{"class":34,"line":42},[32,11260,11261],{"class":49},"\"scripts\"",[32,11263,2628],{"class":66},[32,11265,11266,11269,11271],{"class":34,"line":85},[32,11267,11268],{"class":53},"  \"build\"",[32,11270,2413],{"class":66},[32,11272,11273],{"class":49},"\"tsc -b && vite build && node scripts\u002Fcopy-dist.mjs\"\n",[32,11275,11276],{"class":34,"line":92},[32,11277,2003],{"class":66},[280,11279,11281],{"id":11280},"_3-后端embed-httpfilesystem-包装","3. 后端：embed + http.FileSystem 包装",[22,11283,11285],{"className":4133,"code":11284,"language":171,"meta":27,"style":27},"\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",[29,11286,11287,11292,11298,11302,11308,11316,11324,11332,11336,11340,11345,11358,11362,11367,11404,11408,11420,11428,11441,11445,11449,11488,11502,11512,11522,11549,11553,11570],{"__ignoreMap":27},[32,11288,11289],{"class":34,"line":35},[32,11290,11291],{"class":38},"\u002F\u002F backend\u002Fweb\u002Fembed.go\n",[32,11293,11294,11296],{"class":34,"line":42},[32,11295,6941],{"class":130},[32,11297,6944],{"class":45},[32,11299,11300],{"class":34,"line":85},[32,11301,89],{"emptyLinePlaceholder":88},[32,11303,11304,11306],{"class":34,"line":92},[32,11305,4141],{"class":130},[32,11307,5539],{"class":66},[32,11309,11310,11312,11314],{"class":34,"line":98},[32,11311,6959],{"class":49},[32,11313,6962],{"class":45},[32,11315,278],{"class":49},[32,11317,11318,11320,11322],{"class":34,"line":127},[32,11319,6959],{"class":49},[32,11321,6989],{"class":45},[32,11323,278],{"class":49},[32,11325,11326,11328,11330],{"class":34,"line":146},[32,11327,6959],{"class":49},[32,11329,7007],{"class":45},[32,11331,278],{"class":49},[32,11333,11334],{"class":34,"line":162},[32,11335,3019],{"class":66},[32,11337,11338],{"class":34,"line":168},[32,11339,89],{"emptyLinePlaceholder":88},[32,11341,11342],{"class":34,"line":182},[32,11343,11344],{"class":38},"\u002F\u002Fgo:embed all:dist\n",[32,11346,11347,11349,11352,11354,11356],{"class":34,"line":205},[32,11348,7040],{"class":130},[32,11350,11351],{"class":66}," distFS ",[32,11353,6962],{"class":45},[32,11355,2831],{"class":66},[32,11357,7050],{"class":45},[32,11359,11360],{"class":34,"line":214},[32,11361,89],{"emptyLinePlaceholder":88},[32,11363,11364],{"class":34,"line":219},[32,11365,11366],{"class":38},"\u002F\u002F Dist 内嵌前端静态文件系统\n",[32,11368,11369,11371,11374,11377,11379,11382,11384,11387,11390,11393,11395,11398,11401],{"class":34,"line":225},[32,11370,7040],{"class":130},[32,11372,11373],{"class":66}," Dist ",[32,11375,11376],{"class":45},"http",[32,11378,2831],{"class":66},[32,11380,11381],{"class":45},"FileSystem",[32,11383,2346],{"class":130},[32,11385,11386],{"class":130}," &",[32,11388,11389],{"class":45},"fileSystem",[32,11391,11392],{"class":66},"{prefix: ",[32,11394,7090],{"class":49},[32,11396,11397],{"class":66},", fs: ",[32,11399,11400],{"class":130},"&",[32,11402,11403],{"class":66},"distFS}\n",[32,11405,11406],{"class":34,"line":236},[32,11407,89],{"emptyLinePlaceholder":88},[32,11409,11410,11412,11415,11418],{"class":34,"line":247},[32,11411,5526],{"class":130},[32,11413,11414],{"class":45}," fileSystem",[32,11416,11417],{"class":130}," struct",[32,11419,2349],{"class":66},[32,11421,11422,11425],{"class":34,"line":252},[32,11423,11424],{"class":66},"    prefix ",[32,11426,11427],{"class":130},"string\n",[32,11429,11430,11433,11435,11437,11439],{"class":34,"line":261},[32,11431,11432],{"class":66},"    fs     ",[32,11434,4366],{"class":130},[32,11436,6962],{"class":45},[32,11438,2831],{"class":66},[32,11440,7050],{"class":45},[32,11442,11443],{"class":34,"line":266},[32,11444,2003],{"class":66},[32,11446,11447],{"class":34,"line":476},[32,11448,89],{"emptyLinePlaceholder":88},[32,11450,11451,11453,11455,11458,11460,11462,11464,11467,11469,11471,11473,11475,11477,11479,11482,11484,11486],{"class":34,"line":484},[32,11452,4334],{"class":130},[32,11454,2751],{"class":66},[32,11456,11457],{"class":2712},"f ",[32,11459,4366],{"class":130},[32,11461,11389],{"class":45},[32,11463,3173],{"class":66},[32,11465,11466],{"class":45},"Open",[32,11468,2709],{"class":66},[32,11470,7656],{"class":2712},[32,11472,10615],{"class":130},[32,11474,4363],{"class":66},[32,11476,11376],{"class":45},[32,11478,2831],{"class":66},[32,11480,11481],{"class":45},"File",[32,11483,798],{"class":66},[32,11485,4379],{"class":130},[32,11487,3961],{"class":66},[32,11489,11490,11492,11495,11497,11500],{"class":34,"line":489},[32,11491,2786],{"class":130},[32,11493,11494],{"class":66}," name ",[32,11496,5234],{"class":130},[32,11498,11499],{"class":49}," \"\u002F\"",[32,11501,2349],{"class":66},[32,11503,11504,11507,11509],{"class":34,"line":1769},[32,11505,11506],{"class":66},"        name ",[32,11508,137],{"class":130},[32,11510,11511],{"class":66}," f.prefix\n",[32,11513,11514,11517,11520],{"class":34,"line":1783},[32,11515,11516],{"class":66},"    } ",[32,11518,11519],{"class":130},"else",[32,11521,2349],{"class":66},[32,11523,11524,11526,11528,11531,11533,11535,11538,11540,11542,11545,11547],{"class":34,"line":1790},[32,11525,11506],{"class":66},[32,11527,137],{"class":130},[32,11529,11530],{"class":66}," f.prefix ",[32,11532,3527],{"class":130},[32,11534,11499],{"class":49},[32,11536,11537],{"class":130}," +",[32,11539,5296],{"class":66},[32,11541,7128],{"class":45},[32,11543,11544],{"class":66},"(name, ",[32,11546,7134],{"class":49},[32,11548,3019],{"class":66},[32,11550,11551],{"class":34,"line":1795},[32,11552,2669],{"class":66},[32,11554,11555,11557,11559,11562,11565,11567],{"class":34,"line":1801},[32,11556,4391],{"class":130},[32,11558,10159],{"class":66},[32,11560,11561],{"class":45},"FS",[32,11563,11564],{"class":66},"(f.fs).",[32,11566,11466],{"class":45},[32,11568,11569],{"class":66},"(name)\n",[32,11571,11572],{"class":34,"line":1809},[32,11573,2003],{"class":66},[15,11575,11576],{},"两个细节：",[2256,11578,11579,11602],{},[287,11580,11581,11589,11590,7569,11592,11594,11595,11597,11598,11601],{},[1948,11582,11583,11586,11587,11079],{},[29,11584,11585],{},"all:dist"," 里的 ",[29,11588,11078],{},"：embed 目录时默认会排除 ",[29,11591,7552],{},[29,11593,2831],{}," 开头的文件（上篇坑二就是被这个坑了，当时靠改 Vite 输出名绕开）。",[29,11596,11078],{}," 前缀表示「全部包含」，前端构建产物里那些 ",[29,11599,11600],{},"_xxx.js"," 助手文件直接安全落地，不用再改前端配置；",[287,11603,11604,11607,11608,11610,11611,11614],{},[29,11605,11606],{},"http.FileSystem"," 包装：加了一层 ",[29,11609,6918],{}," 前缀映射，",[29,11612,11613],{},"http.FileServer"," 和 Gin 都能直接用。",[280,11616,11618],{"id":11617},"_4-路由静态服务-缓存策略","4. 路由：静态服务 + 缓存策略",[22,11620,11622],{"className":4133,"code":11621,"language":171,"meta":27,"style":27},"\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",[29,11623,11624,11629,11671,11683,11688,11703,11748,11777,11795,11816,11821,11840,11863,11868,11885,11889,11910,11914,11954,11958],{"__ignoreMap":27},[32,11625,11626],{"class":34,"line":35},[32,11627,11628],{"class":38},"\u002F\u002F backend\u002Finternal\u002Fhandler\u002Frouter.go\n",[32,11630,11631,11633,11636,11638,11641,11643,11646,11648,11651,11653,11656,11658,11660,11662,11664,11666,11669],{"class":34,"line":42},[32,11632,4334],{"class":130},[32,11634,11635],{"class":45}," NewRouter",[32,11637,2709],{"class":66},[32,11639,11640],{"class":2712},"db",[32,11642,4959],{"class":130},[32,11644,11645],{"class":45},"gorm",[32,11647,2831],{"class":66},[32,11649,11650],{"class":45},"DB",[32,11652,798],{"class":66},[32,11654,11655],{"class":2712},"uploadDir",[32,11657,10615],{"class":130},[32,11659,3173],{"class":66},[32,11661,4366],{"class":130},[32,11663,4962],{"class":45},[32,11665,2831],{"class":66},[32,11667,11668],{"class":45},"Engine",[32,11670,2349],{"class":66},[32,11672,11673,11675,11677,11679,11681],{"class":34,"line":85},[32,11674,4775],{"class":66},[32,11676,4160],{"class":130},[32,11678,4780],{"class":66},[32,11680,4783],{"class":45},[32,11682,2904],{"class":66},[32,11684,11685],{"class":34,"line":92},[32,11686,11687],{"class":130},"    ...\n",[32,11689,11690,11693,11695,11697,11700],{"class":34,"line":98},[32,11691,11692],{"class":66},"    dist ",[32,11694,4160],{"class":130},[32,11696,10159],{"class":66},[32,11698,11699],{"class":45},"FileServer",[32,11701,11702],{"class":66},"(web.Dist)\n",[32,11704,11705,11707,11709,11711,11714,11716,11718,11720,11722,11724,11726,11728,11730,11733,11736,11738,11741,11743,11745],{"class":34,"line":127},[32,11706,4913],{"class":66},[32,11708,4942],{"class":45},[32,11710,2709],{"class":66},[32,11712,11713],{"class":49},"\"\u002Fdist\"",[32,11715,798],{"class":66},[32,11717,4334],{"class":130},[32,11719,2709],{"class":66},[32,11721,4956],{"class":2712},[32,11723,4959],{"class":130},[32,11725,4962],{"class":45},[32,11727,2831],{"class":66},[32,11729,4347],{"class":45},[32,11731,11732],{"class":66},") { c.",[32,11734,11735],{"class":45},"Redirect",[32,11737,2709],{"class":66},[32,11739,11740],{"class":53},"302",[32,11742,798],{"class":66},[32,11744,11146],{"class":49},[32,11746,11747],{"class":66},") })\n",[32,11749,11750,11752,11754,11756,11759,11761,11763,11765,11767,11769,11771,11773,11775],{"class":34,"line":146},[32,11751,4913],{"class":66},[32,11753,4942],{"class":45},[32,11755,2709],{"class":66},[32,11757,11758],{"class":49},"\"\u002Fdist\u002F*filepath\"",[32,11760,798],{"class":66},[32,11762,4334],{"class":130},[32,11764,2709],{"class":66},[32,11766,4956],{"class":2712},[32,11768,4959],{"class":130},[32,11770,4962],{"class":45},[32,11772,2831],{"class":66},[32,11774,4347],{"class":45},[32,11776,3961],{"class":66},[32,11778,11779,11781,11783,11785,11788,11790,11793],{"class":34,"line":162},[32,11780,7121],{"class":66},[32,11782,4160],{"class":130},[32,11784,5213],{"class":66},[32,11786,11787],{"class":45},"Param",[32,11789,2709],{"class":66},[32,11791,11792],{"class":49},"\"filepath\"",[32,11794,3019],{"class":66},[32,11796,11797,11799,11801,11803,11805,11807,11809,11811,11814],{"class":34,"line":168},[32,11798,5228],{"class":130},[32,11800,7143],{"class":66},[32,11802,5234],{"class":130},[32,11804,11499],{"class":49},[32,11806,5329],{"class":130},[32,11808,7143],{"class":66},[32,11810,5234],{"class":130},[32,11812,11813],{"class":49}," \"\u002Findex.html\"",[32,11815,2349],{"class":66},[32,11817,11818],{"class":34,"line":182},[32,11819,11820],{"class":38},"            \u002F\u002F index.html 禁缓存：升级后浏览器立刻拿到新入口\n",[32,11822,11823,11825,11828,11830,11833,11835,11838],{"class":34,"line":205},[32,11824,5244],{"class":66},[32,11826,11827],{"class":45},"Header",[32,11829,2709],{"class":66},[32,11831,11832],{"class":49},"\"Cache-Control\"",[32,11834,798],{"class":66},[32,11836,11837],{"class":49},"\"no-cache, no-store, must-revalidate\"",[32,11839,3019],{"class":66},[32,11841,11842,11845,11847,11850,11852,11855,11858,11861],{"class":34,"line":214},[32,11843,11844],{"class":66},"        } ",[32,11846,11519],{"class":130},[32,11848,11849],{"class":130}," if",[32,11851,5296],{"class":66},[32,11853,11854],{"class":45},"HasPrefix",[32,11856,11857],{"class":66},"(p, ",[32,11859,11860],{"class":49},"\"\u002Fassets\u002F\"",[32,11862,3961],{"class":66},[32,11864,11865],{"class":34,"line":219},[32,11866,11867],{"class":38},"            \u002F\u002F 带内容 hash 的资源可放心长缓存\n",[32,11869,11870,11872,11874,11876,11878,11880,11883],{"class":34,"line":225},[32,11871,5244],{"class":66},[32,11873,11827],{"class":45},[32,11875,2709],{"class":66},[32,11877,11832],{"class":49},[32,11879,798],{"class":66},[32,11881,11882],{"class":49},"\"public, max-age=31536000, immutable\"",[32,11884,3019],{"class":66},[32,11886,11887],{"class":34,"line":236},[32,11888,5282],{"class":66},[32,11890,11891,11894,11897,11899,11901,11904,11907],{"class":34,"line":247},[32,11892,11893],{"class":66},"        http.",[32,11895,11896],{"class":45},"StripPrefix",[32,11898,2709],{"class":66},[32,11900,11713],{"class":49},[32,11902,11903],{"class":66},", dist).",[32,11905,11906],{"class":45},"ServeHTTP",[32,11908,11909],{"class":66},"(c.Writer, c.Request)\n",[32,11911,11912],{"class":34,"line":252},[32,11913,5008],{"class":66},[32,11915,11916,11918,11920,11922,11924,11926,11928,11930,11932,11934,11936,11938,11940,11942,11944,11946,11948,11950,11952],{"class":34,"line":261},[32,11917,4913],{"class":66},[32,11919,4942],{"class":45},[32,11921,2709],{"class":66},[32,11923,7134],{"class":49},[32,11925,798],{"class":66},[32,11927,4334],{"class":130},[32,11929,2709],{"class":66},[32,11931,4956],{"class":2712},[32,11933,4959],{"class":130},[32,11935,4962],{"class":45},[32,11937,2831],{"class":66},[32,11939,4347],{"class":45},[32,11941,11732],{"class":66},[32,11943,11735],{"class":45},[32,11945,2709],{"class":66},[32,11947,11740],{"class":53},[32,11949,798],{"class":66},[32,11951,11146],{"class":49},[32,11953,11747],{"class":66},[32,11955,11956],{"class":34,"line":266},[32,11957,11687],{"class":130},[32,11959,11960],{"class":34,"line":476},[32,11961,2003],{"class":66},[15,11963,11964],{},"缓存策略是内嵌发布的必修课（坑二细说）：入口禁缓存、hash 资源长缓存。",[280,11966,11968],{"id":11967},"_5-打包脚本windows-版-linux-交叉编译版","5. 打包脚本：Windows 版 + Linux 交叉编译版",[22,11970,11972],{"className":24,"code":11971,"language":26,"meta":27,"style":27},"#!\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",[29,11973,11974,11979,11984,11994,12012,12016,12037,12041,12049,12068,12072,12079,12136,12151,12155],{"__ignoreMap":27},[32,11975,11976],{"class":34,"line":35},[32,11977,11978],{"class":38},"#!\u002Fusr\u002Fbin\u002Fenv bash\n",[32,11980,11981],{"class":34,"line":42},[32,11982,11983],{"class":38},"# build-linux.sh —— 在 Windows\u002FmacOS\u002FLinux 任意平台交叉编译 linux\u002Famd64 产物\n",[32,11985,11986,11988,11991],{"class":34,"line":85},[32,11987,7399],{"class":53},[32,11989,11990],{"class":53}," -euo",[32,11992,11993],{"class":49}," pipefail\n",[32,11995,11996,11998,12001,12004,12006,12009],{"class":34,"line":92},[32,11997,7478],{"class":53},[32,11999,12000],{"class":49}," \"$(",[32,12002,12003],{"class":45},"dirname",[32,12005,63],{"class":49},[32,12007,12008],{"class":53},"$0",[32,12010,12011],{"class":49},"\")\"\n",[32,12013,12014],{"class":34,"line":98},[32,12015,89],{"emptyLinePlaceholder":88},[32,12017,12018,12021,12023,12026,12028,12031,12034],{"class":34,"line":127},[32,12019,12020],{"class":66},"BINARY",[32,12022,137],{"class":130},[32,12024,12025],{"class":49},"\"${",[32,12027,12020],{"class":66},[32,12029,12030],{"class":130},":-",[32,12032,12033],{"class":66},"magic-img",[32,12035,12036],{"class":49},"}\"\n",[32,12038,12039],{"class":34,"line":146},[32,12040,89],{"emptyLinePlaceholder":88},[32,12042,12043,12046],{"class":34,"line":162},[32,12044,12045],{"class":53},"echo",[32,12047,12048],{"class":49}," \"[1\u002F3] 构建前端并复制产物到 backend\u002Fweb\u002Fdist ...\"\n",[32,12050,12051,12053,12055,12058,12060,12062,12064,12066],{"class":34,"line":168},[32,12052,2709],{"class":66},[32,12054,7478],{"class":53},[32,12056,12057],{"class":49}," frontend",[32,12059,107],{"class":66},[32,12061,6272],{"class":45},[32,12063,50],{"class":49},[32,12065,187],{"class":49},[32,12067,3019],{"class":66},[32,12069,12070],{"class":34,"line":182},[32,12071,89],{"emptyLinePlaceholder":88},[32,12073,12074,12076],{"class":34,"line":205},[32,12075,12045],{"class":53},[32,12077,12078],{"class":49}," \"[2\u002F3] 交叉编译后端并内嵌前端 (CGO_ENABLED=0 GOOS=linux GOARCH=amd64) ...\"\n",[32,12080,12081,12083,12085,12088,12091,12093,12095,12098,12100,12103,12106,12108,12111,12113,12115,12117,12119,12122,12125,12128,12131,12134],{"class":34,"line":214},[32,12082,2709],{"class":66},[32,12084,7478],{"class":53},[32,12086,12087],{"class":49}," backend",[32,12089,12090],{"class":66}," && CGO_ENABLED",[32,12092,137],{"class":130},[32,12094,2797],{"class":49},[32,12096,12097],{"class":66}," GOOS",[32,12099,137],{"class":130},[32,12101,12102],{"class":49},"linux",[32,12104,12105],{"class":66}," GOARCH",[32,12107,137],{"class":130},[32,12109,12110],{"class":49},"amd64",[32,12112,542],{"class":45},[32,12114,187],{"class":49},[32,12116,196],{"class":53},[32,12118,63],{"class":49},[32,12120,12121],{"class":66},"$BINARY",[32,12123,12124],{"class":49},"\"",[32,12126,12127],{"class":53}," -ldflags=",[32,12129,12130],{"class":49},"\"-s -w\"",[32,12132,12133],{"class":49}," .\u002Fcmd\u002Fserver",[32,12135,3019],{"class":66},[32,12137,12138,12141,12144,12147,12149],{"class":34,"line":219},[32,12139,12140],{"class":45},"chmod",[32,12142,12143],{"class":49}," +x",[32,12145,12146],{"class":49}," \"backend\u002F",[32,12148,12121],{"class":66},[32,12150,278],{"class":49},[32,12152,12153],{"class":34,"line":225},[32,12154,89],{"emptyLinePlaceholder":88},[32,12156,12157,12159,12162,12164],{"class":34,"line":236},[32,12158,12045],{"class":53},[32,12160,12161],{"class":49}," \"[3\u002F3] 打包完成: backend\u002F",[32,12163,12121],{"class":66},[32,12165,278],{"class":49},[15,12167,12168,12169,12172,12173,2600],{},"Windows 版（",[29,12170,12171],{},"build-win.bat","）同理，只是产物叫 ",[29,12174,12175],{},"magic-img.exe",[11,12177,7512],{"id":7512},[280,12179,12181,12182,12185,12186,12188],{"id":12180},"坑一goembed-dist-静默漏掉-_-开头文件升级解法","坑一：",[29,12183,12184],{},"\u002F\u002Fgo:embed dist"," 静默漏掉 ",[29,12187,7552],{}," 开头文件（升级解法）",[15,12190,12191,12192,12199,12200,12202],{},"上篇说过：embed 目录时",[1948,12193,12194,12195,7569,12197,7553],{},"硬编码排除 ",[29,12196,7552],{},[29,12198,2831],{},"。上篇的解法是改 Vite 输出文件名去掉 ",[29,12201,7552],{}," 前缀；这次直接用：",[22,12204,12206],{"className":4133,"code":12205,"language":171,"meta":27,"style":27},"\u002F\u002Fgo:embed all:dist   \u002F\u002F all: 前缀 = 包含全部文件，不再静默跳过\n",[29,12207,12208],{"__ignoreMap":27},[32,12209,12210,12213],{"class":34,"line":35},[32,12211,12212],{"class":38},"\u002F\u002Fgo:embed all:dist",[32,12214,12215],{"class":38},"   \u002F\u002F all: 前缀 = 包含全部文件，不再静默跳过\n",[15,12217,12218],{},[1948,12219,12220,12221,12223,12224,12226],{},"结论：能用 ",[29,12222,11078],{}," 就用 ",[29,12225,11078],{},"，从根上消灭这类\"文件莫名 404\"的问题。",[280,12228,12230],{"id":12229},"坑二开发版是新的打包版却是旧的","坑二：开发版是新的、打包版却是旧的",[15,12232,12233,12234,12237],{},"现象：Vite 开发服务一切正常，",[29,12235,12236],{},":8080\u002Fdist\u002F"," 打包版却是旧界面，强刷也不一定好。",[15,12239,12240,12241,12243,12244,12247,12248,12253],{},"排查过程值得记录：先是怀疑 ",[29,12242,7715],{}," 缓存了旧 embed（上篇坑三的思路），重编重启无效；然后直接抓取 8080 返回的 HTML 和 JS，发现",[1948,12245,12246],{},"服务端内容已经是新的","——问题出在",[1948,12249,12250,12251],{},"浏览器缓存了 ",[29,12252,11212],{},"：它引用旧 hash 的资源，一直不更新。",[15,12255,12256,12258,12259,12262,12263,12266],{},[1948,12257,7530],{},"：入口文件响应 ",[29,12260,12261],{},"Cache-Control: no-store","（见上面路由代码），带 hash 的资源给 ",[29,12264,12265],{},"immutable"," 长缓存。升级后浏览器每次拉新入口，资源文件名一变就自动换新，两全其美。",[9865,12268,12269],{},[15,12270,12271],{},"教训：排查\"新旧不一致\"先分清是哪一层——服务端产物、浏览器入口缓存、还是资源缓存。抓包看响应内容再动手。",[280,12273,12275],{"id":12274},"坑三windows-上跑的-build-linuxsh产出的文件-linux-却执行不了","坑三：Windows 上跑的 build-linux.sh，产出的文件 Linux 却执行不了",[15,12277,12278,12279,6759,12282,12288,12289,12291],{},"最初脚本只写了 ",[29,12280,12281],{},"CGO_ENABLED=0 go build",[1948,12283,12284,12285],{},"没指定 ",[29,12286,12287],{},"GOOS\u002FGOARCH","。在 Windows 上跑，产出的其实是个 Windows PE 文件（只是没叫 ",[29,12290,8017],{},"），传到 Linux 自然「无法执行」。",[15,12293,12294,12296],{},[1948,12295,7530],{},"：脚本里显式交叉编译：",[22,12298,12300],{"className":24,"code":12299,"language":26,"meta":27,"style":27},"CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o \"$BINARY\" -ldflags=\"-s -w\" .\u002Fcmd\u002Fserver\n",[29,12301,12302],{"__ignoreMap":27},[32,12303,12304,12307,12309,12311,12313,12315,12317,12319,12321,12323,12325,12327,12329,12331,12333,12335,12337,12339],{"class":34,"line":35},[32,12305,12306],{"class":66},"CGO_ENABLED",[32,12308,137],{"class":130},[32,12310,2797],{"class":49},[32,12312,12097],{"class":66},[32,12314,137],{"class":130},[32,12316,12102],{"class":49},[32,12318,12105],{"class":66},[32,12320,137],{"class":130},[32,12322,12110],{"class":49},[32,12324,542],{"class":45},[32,12326,187],{"class":49},[32,12328,196],{"class":53},[32,12330,63],{"class":49},[32,12332,12121],{"class":66},[32,12334,12124],{"class":49},[32,12336,12127],{"class":53},[32,12338,12130],{"class":49},[32,12340,12341],{"class":49}," .\u002Fcmd\u002Fserver\n",[15,12343,12344,12345,12348,12349,12352,12353],{},"能这么干的前提是",[1948,12346,12347],{},"全依赖树无 cgo","——SQLite 用的是 modernc 纯 Go 驱动，bcrypt 用 ",[29,12350,12351],{},"x\u002Fcrypto","。这也是选型时就要留意的点：",[1948,12354,12355],{},"想跨平台交叉编译，先把 cgo 依赖清干净。",[15,12357,12358,12359,12362,12363,12366],{},"另外两个小提醒：Windows 上编出的文件没有 Unix 执行位，上传服务器后记得 ",[29,12360,12361],{},"chmod +x","；",[29,12364,12365],{},"-ldflags=\"-s -w\""," 剥离符号，体积从 30MB+ 降到 26MB。",[280,12368,12370],{"id":12369},"坑四dist-被-gitignore-后embed-直接编译失败","坑四：dist 被 gitignore 后，embed 直接编译失败",[15,12372,12373,12375,12376,12378,12379,12382,12383,12385],{},[29,12374,11237],{}," 是构建产物、入了 .gitignore，但 ",[29,12377,6914],{}," 在",[1948,12380,12381],{},"编译期","就要读它——刚 clone 下来直接 ",[29,12384,7715],{}," 会报 embed 找不到文件。",[15,12387,12388,12390,12391,12394],{},[1948,12389,7530],{},"：gitignore 里保留 ",[29,12392,12393],{},"dist\u002F.gitkeep","（一个空文件），让目录存在于版本库中：",[22,12396,12400],{"className":12397,"code":12398,"language":12399,"meta":27,"style":27},"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",[29,12401,12402,12407,12412],{"__ignoreMap":27},[32,12403,12404],{"class":34,"line":35},[32,12405,12406],{},"backend\u002Fweb\u002Fdist\u002F*\n",[32,12408,12409],{"class":34,"line":42},[32,12410,12411],{},"!backend\u002Fweb\u002Fdist\u002F\n",[32,12413,12414],{"class":34,"line":85},[32,12415,12416],{},"!backend\u002Fweb\u002Fdist\u002F.gitkeep\n",[15,12418,12419,12420,12422,12423,12425],{},"这样 clone 后可以先 ",[29,12421,7715],{}," 通过，再 ",[29,12424,11230],{}," 灌入真实前端。",[280,12427,12429],{"id":12428},"坑五500kb-chunk-警告别慌","坑五：>500KB chunk 警告，别慌",[15,12431,12432,12433,12436,12437,2600],{},"Vite 构建会警告单 chunk 超过 500KB。这个项目里 Base UI 组件库全量引入，单 chunk 约 640KB——",[1948,12434,12435],{},"属正常，不要为了消警告去拆配置","。真要优化就上路由级 ",[29,12438,12439],{},"React.lazy",[11,12441,7728],{"id":7728},[22,12443,12445],{"className":24,"code":12444,"language":26,"meta":27,"style":27},"$ build-win.bat        # backendmagic-img.exe\n$ bash build-linux.sh  # backendmagic-img（linux\u002Famd64 ELF）\n",[29,12446,12447,12457],{"__ignoreMap":27},[32,12448,12449,12451,12454],{"class":34,"line":35},[32,12450,2825],{"class":45},[32,12452,12453],{"class":49}," build-win.bat",[32,12455,12456],{"class":38},"        # backendmagic-img.exe\n",[32,12458,12459,12461,12463,12466],{"class":34,"line":42},[32,12460,2825],{"class":45},[32,12462,7740],{"class":49},[32,12464,12465],{"class":49}," build-linux.sh",[32,12467,12468],{"class":38},"  # backendmagic-img（linux\u002Famd64 ELF）\n",[15,12470,12471,12472,12475,12476,2600],{},"一个约 26MB 的二进制，内嵌 React 前端、Gin API、SQLite 数据初始化，拷到 Windows\u002FLinux 机器直接跑，浏览器访问 ",[29,12473,12474],{},"http:\u002F\u002Flocalhost:8080"," 自动重定向到 ",[29,12477,11216],{},[11,12479,1858],{"id":1858},[15,12481,12482],{},"这轮相比上篇的三点升级：",[284,12484,12485,12491,12501],{},[287,12486,12487,12490],{},[1948,12488,12489],{},"hash 路由 + base \u002Fdist","：省掉 SPA fallback，静态服务即开即用；",[287,12492,12493,3852,12498,12500],{},[1948,12494,12495,12497],{},[29,12496,11078],{}," embed 前缀",[29,12499,7552],{}," 开头文件问题一劳永逸；",[287,12502,12503,12509],{},[1948,12504,12505,12506,12508],{},"显式 ",[29,12507,12287],{}," 交叉编译","：让「一个脚本、任意平台、产出 Linux 二进制」成为现实。",[15,12511,12512,12513,12516],{},"加上",[1948,12514,12515],{},"入口 no-store \u002F 资源 immutable"," 的缓存策略，单文件分发的体验才算闭环——不然每次升级都要和浏览器缓存打架。",[824,12518,12519],{},"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":27,"searchDepth":42,"depth":85,"links":12521},[12522,12523,12524,12531,12539,12540],{"id":2576,"depth":42,"text":2576},{"id":6908,"depth":42,"text":6908},{"id":6922,"depth":42,"text":6922,"children":12525},[12526,12527,12528,12529,12530],{"id":11106,"depth":85,"text":11107},{"id":11224,"depth":85,"text":11225},{"id":11280,"depth":85,"text":11281},{"id":11617,"depth":85,"text":11618},{"id":11967,"depth":85,"text":11968},{"id":7512,"depth":42,"text":7512,"children":12532},[12533,12535,12536,12537,12538],{"id":12180,"depth":85,"text":12534},"坑一：\u002F\u002Fgo:embed dist 静默漏掉 _ 开头文件（升级解法）",{"id":12229,"depth":85,"text":12230},{"id":12274,"depth":85,"text":12275},{"id":12369,"depth":85,"text":12370},{"id":12428,"depth":85,"text":12429},{"id":7728,"depth":42,"text":7728},{"id":1858,"depth":42,"text":1858},"以 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":11018,"description":12541},"posts\u002Fgo-embed-react-single-binary\u002Findex",[851,9834,6962,12549,12550],"交叉编译","打包","WV-T-MOO5Cu-aycw-Ony8s9hjA3_k0S4XU-T0QsCow0",{"id":12553,"title":12554,"author":6,"body":12555,"description":13800,"difficulty":1909,"draft":840,"extension":841,"image":13801,"meta":13802,"navigation":88,"path":13803,"pinned":840,"published":12545,"seo":13804,"series":13805,"seriesOrder":35,"stem":13806,"tags":13807,"updated":11014,"__hash__":13811},"posts\u002Fposts\u002Freact-photo-album-masonry\u002Findex.md","用 react-photo-album 给生图图库做瀑布流排版",{"type":8,"value":12556,"toc":13783},[12557,12560,12563,12581,12588,12652,12663,12666,12689,12692,12696,12710,12714,12730,12908,12912,13309,13312,13346,13349,13353,13363,13369,13394,13399,13410,13416,13427,13430,13470,13483,13606,13618,13622,13629,13647,13652,13742,13748,13752,13763,13766,13769,13775,13780],[11,12558,12559],{"id":12559},"技术背景",[15,12561,12562],{},"Magic Img 是我做的一个 AI 生图提示词管理工具（自托管、本地优先），其中「生图图库」页面要把所有生成图片以图片墙的形式展示出来。图片墙有两个天然矛盾：",[284,12564,12565,12571],{},[287,12566,12567,12570],{},[1948,12568,12569],{},"等宽网格","（CSS Grid）：整齐，但每行高度由最高的那张决定——不同比例的图片混排时，矮图下面会留下大洞，看起来\"很乱\"；",[287,12572,12573,12576,12577,12580],{},[1948,12574,12575],{},"原生比例展示","：图库必须不裁剪（这是产品约束），所以不能用 ",[29,12578,12579],{},"object-cover"," 强行统一比例。",[15,12582,12583,12584,12587],{},"业内的标准解法是 ",[1948,12585,12586],{},"Masonry 瀑布流","：按列紧密堆放，每张图保持原始比例，列间高度错落但整体紧凑。实现瀑布流的常见方案有三类：",[2033,12589,12590,12604],{},[2036,12591,12592],{},[2039,12593,12594,12596,12599,12602],{},[2042,12595,3388],{},[2042,12597,12598],{},"依赖",[2042,12600,12601],{},"顺序",[2042,12603,6007],{},[2055,12605,12606,12623,12636],{},[2039,12607,12608,12615,12617,12620],{},[2060,12609,12610,12611,12614],{},"CSS ",[29,12612,12613],{},"columns"," 多列",[2060,12616,2091],{},[2060,12618,12619],{},"纵向阅读序（先下后右）",[2060,12621,12622],{},"最新图片不再出现在左上角，时间语义丢失",[2039,12624,12625,12628,12630,12633],{},[2060,12626,12627],{},"grid row-span 自研",[2060,12629,2091],{},[2060,12631,12632],{},"保持",[2060,12634,12635],{},"需固定列数 + 固定行高，逻辑要自己写",[2039,12637,12638,12643,12646,12649],{},[2060,12639,12640],{},[1948,12641,12642],{},"react-photo-album",[2060,12644,12645],{},"~4KB，无依赖",[2060,12647,12648],{},"基本保持（逐列最短列填充）",[2060,12650,12651],{},"专为相册墙设计，TS 友好，维护活跃",[15,12653,12654,12655,12658,12659,12662],{},"最终选了 ",[1948,12656,12657],{},"react-photo-album v3","：体积小、类型齐全，而且它的 Masonry 模式恰好需要「每张图的 ",[29,12660,12661],{},"width\u002Fheight","」——我们的数据库里本来就存了，零额外成本。",[11,12664,12665],{"id":12665},"需求梳理",[284,12667,12668,12674,12680,12683,12686],{},[287,12669,12670,12671,12362],{},"展示全部生成图（21 张起，会持续增长），",[1948,12672,12673],{},"原比例、不裁剪",[287,12675,12676,12677,12362],{},"不同尺寸（2304×1728、4608×3456 等）混排时",[1948,12678,12679],{},"紧凑、不留洞",[287,12681,12682],{},"顺序基本保持「新图靠前」；",[287,12684,12685],{},"点击任意一张打开大图预览（YARL 灯箱），悬停显示所属提示词；",[287,12687,12688],{},"平板性能：墙上只用 300px 缩略图，原图只进灯箱按需加载。",[11,12690,12691],{"id":12691},"安装与使用",[280,12693,12695],{"id":12694},"_1-安装","1. 安装",[22,12697,12699],{"className":24,"code":12698,"language":26,"meta":27,"style":27},"npm install react-photo-album\n",[29,12700,12701],{"__ignoreMap":27},[32,12702,12703,12705,12707],{"class":34,"line":35},[32,12704,6272],{"class":45},[32,12706,112],{"class":49},[32,12708,12709],{"class":49}," react-photo-album\n",[280,12711,12713],{"id":12712},"_2-准备数据关键输入是宽高","2. 准备数据（关键输入是宽高）",[15,12715,12716,12717,12720,12721,7535,12723,7535,12726,12729],{},"库要求的 ",[29,12718,12719],{},"Photo"," 对象只有三个必需字段：",[29,12722,3249],{},[29,12724,12725],{},"width",[29,12727,12728],{},"height","。我们的接口本来就返回每张图的原始宽高：",[22,12731,12733],{"className":11164,"code":12732,"language":11166,"meta":27,"style":27},"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",[29,12734,12735,12753,12758,12765,12769,12785,12795,12799,12803,12836,12847,12866,12871,12876,12898,12903],{"__ignoreMap":27},[32,12736,12737,12739,12742,12744,12747,12750],{"class":34,"line":35},[32,12738,4141],{"class":130},[32,12740,12741],{"class":66}," PhotoAlbum, { ",[32,12743,5526],{"class":130},[32,12745,12746],{"class":66}," Photo } ",[32,12748,12749],{"class":130},"from",[32,12751,12752],{"class":49}," \"react-photo-album\"\n",[32,12754,12755],{"class":34,"line":42},[32,12756,12757],{"class":38},"\u002F\u002F 必须引入样式表！容器\u002F列的布局规则都在这里（坑 1 详述）\n",[32,12759,12760,12762],{"class":34,"line":85},[32,12761,4141],{"class":130},[32,12763,12764],{"class":49}," \"react-photo-album\u002Fstyles.css\"\n",[32,12766,12767],{"class":34,"line":92},[32,12768,89],{"emptyLinePlaceholder":88},[32,12770,12771,12774,12777,12780,12783],{"class":34,"line":98},[32,12772,12773],{"class":130},"interface",[32,12775,12776],{"class":45}," GalleryPhoto",[32,12778,12779],{"class":130}," extends",[32,12781,12782],{"class":45}," Photo",[32,12784,2349],{"class":66},[32,12786,12787,12790,12793],{"class":34,"line":127},[32,12788,12789],{"class":2712},"  prompt_text",[32,12791,12792],{"class":130},"?:",[32,12794,5532],{"class":53},[32,12796,12797],{"class":34,"line":146},[32,12798,2003],{"class":66},[32,12800,12801],{"class":34,"line":162},[32,12802,89],{"emptyLinePlaceholder":88},[32,12804,12805,12807,12810,12812,12814,12816,12818,12821,12824,12827,12829,12831,12833],{"class":34,"line":168},[32,12806,2943],{"class":130},[32,12808,12809],{"class":53}," albumPhotos",[32,12811,1622],{"class":130},[32,12813,12776],{"class":45},[32,12815,2731],{"class":66},[32,12817,137],{"class":130},[32,12819,12820],{"class":66}," images.",[32,12822,12823],{"class":45},"map",[32,12825,12826],{"class":66},"((",[32,12828,309],{"class":2712},[32,12830,3173],{"class":66},[32,12832,3137],{"class":130},[32,12834,12835],{"class":66}," ({\n",[32,12837,12838,12841,12844],{"class":34,"line":182},[32,12839,12840],{"class":66},"  key: ",[32,12842,12843],{"class":45},"String",[32,12845,12846],{"class":66},"(img.id),\n",[32,12848,12849,12852,12855,12858,12860,12863],{"class":34,"line":205},[32,12850,12851],{"class":66},"  src: ",[32,12853,12854],{"class":45},"assetUrl",[32,12856,12857],{"class":66},"(img.thumb_path) ",[32,12859,2923],{"class":130},[32,12861,12862],{"class":45}," assetUrl",[32,12864,12865],{"class":66},"(img.file_path),\n",[32,12867,12868],{"class":34,"line":214},[32,12869,12870],{"class":66},"  width: img.width,\n",[32,12872,12873],{"class":34,"line":219},[32,12874,12875],{"class":66},"  height: img.height,\n",[32,12877,12878,12881,12883,12886,12888,12890,12893,12896],{"class":34,"line":225},[32,12879,12880],{"class":66},"  alt: img.prompt_text ",[32,12882,2923],{"class":130},[32,12884,12885],{"class":49}," `生成图 ${",[32,12887,309],{"class":66},[32,12889,2831],{"class":49},[32,12891,12892],{"class":66},"id",[32,12894,12895],{"class":49},"}`",[32,12897,2360],{"class":66},[32,12899,12900],{"class":34,"line":236},[32,12901,12902],{"class":66},"  prompt_text: img.prompt_text,\n",[32,12904,12905],{"class":34,"line":247},[32,12906,12907],{"class":66},"}))\n",[280,12909,12911],{"id":12910},"_3-使用-masonry-布局","3. 使用 Masonry 布局",[22,12913,12915],{"className":11164,"code":12914,"language":11166,"meta":27,"style":27},"\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",[29,12916,12917,12924,12934,13002,13016,13029,13039,13063,13073,13094,13112,13140,13147,13181,13189,13199,13215,13225,13235,13240,13250,13267,13272,13281,13290,13295,13299,13304],{"__ignoreMap":27},[32,12918,12919,12921],{"class":34,"line":35},[32,12920,1625],{"class":66},[32,12922,12923],{"class":53},"PhotoAlbum\n",[32,12925,12926,12929,12931],{"class":34,"line":42},[32,12927,12928],{"class":45},"  layout",[32,12930,137],{"class":130},[32,12932,12933],{"class":49},"\"masonry\"\n",[32,12935,12936,12939,12941,12944,12947,12949,12951,12954,12956,12959,12962,12964,12967,12970,12972,12975,12977,12980,12982,12984,12986,12989,12991,12994,12996,12999],{"class":34,"line":85},[32,12937,12938],{"class":45},"  columns",[32,12940,137],{"class":130},[32,12942,12943],{"class":66},"{(",[32,12945,12946],{"class":2712},"cw",[32,12948,3173],{"class":66},[32,12950,3137],{"class":130},[32,12952,12953],{"class":66}," (cw ",[32,12955,1625],{"class":130},[32,12957,12958],{"class":53}," 640",[32,12960,12961],{"class":130}," ?",[32,12963,5326],{"class":53},[32,12965,12966],{"class":130}," :",[32,12968,12969],{"class":66}," cw ",[32,12971,1625],{"class":130},[32,12973,12974],{"class":53}," 960",[32,12976,12961],{"class":130},[32,12978,12979],{"class":53}," 3",[32,12981,12966],{"class":130},[32,12983,12969],{"class":66},[32,12985,1625],{"class":130},[32,12987,12988],{"class":53}," 1280",[32,12990,12961],{"class":130},[32,12992,12993],{"class":53}," 4",[32,12995,12966],{"class":130},[32,12997,12998],{"class":53}," 5",[32,13000,13001],{"class":66},")}\n",[32,13003,13004,13007,13009,13011,13014],{"class":34,"line":92},[32,13005,13006],{"class":45},"  spacing",[32,13008,137],{"class":130},[32,13010,4993],{"class":66},[32,13012,13013],{"class":53},"16",[32,13015,2003],{"class":66},[32,13017,13018,13021,13023,13025,13027],{"class":34,"line":98},[32,13019,13020],{"class":45},"  padding",[32,13022,137],{"class":130},[32,13024,4993],{"class":66},[32,13026,2797],{"class":53},[32,13028,2003],{"class":66},[32,13030,13031,13034,13036],{"class":34,"line":127},[32,13032,13033],{"class":45},"  photos",[32,13035,137],{"class":130},[32,13037,13038],{"class":66},"{albumPhotos}\n",[32,13040,13041,13044,13046,13049,13052,13055,13057,13060],{"class":34,"line":146},[32,13042,13043],{"class":45},"  onClick",[32,13045,137],{"class":130},[32,13047,13048],{"class":66},"{({ ",[32,13050,13051],{"class":2712},"index",[32,13053,13054],{"class":66}," }) ",[32,13056,3137],{"class":130},[32,13058,13059],{"class":45}," openPreview",[32,13061,13062],{"class":66},"(index)}\n",[32,13064,13065,13068,13070],{"class":34,"line":162},[32,13066,13067],{"class":45},"  render",[32,13069,137],{"class":130},[32,13071,13072],{"class":66},"{{\n",[32,13074,13075,13078,13081,13083,13085,13088,13090,13092],{"class":34,"line":168},[32,13076,13077],{"class":45},"    photo",[32,13079,13080],{"class":66},": (",[32,13082,3244],{"class":2712},[32,13084,798],{"class":66},[32,13086,13087],{"class":2712},"context",[32,13089,3173],{"class":66},[32,13091,3137],{"class":130},[32,13093,2349],{"class":66},[32,13095,13096,13098,13101,13104,13107,13109],{"class":34,"line":182},[32,13097,2847],{"class":130},[32,13099,13100],{"class":66}," { ",[32,13102,13103],{"class":53},"onClick",[32,13105,13106],{"class":66}," } ",[32,13108,137],{"class":130},[32,13110,13111],{"class":66}," props\n",[32,13113,13114,13116,13118,13121,13123,13125,13127,13129,13131,13133,13135,13137],{"class":34,"line":205},[32,13115,2847],{"class":130},[32,13117,13100],{"class":66},[32,13119,13120],{"class":53},"photo",[32,13122,798],{"class":66},[32,13124,13051],{"class":53},[32,13126,798],{"class":66},[32,13128,12725],{"class":53},[32,13130,798],{"class":66},[32,13132,12728],{"class":53},[32,13134,13106],{"class":66},[32,13136,137],{"class":130},[32,13138,13139],{"class":66}," context\n",[32,13141,13142,13145],{"class":34,"line":214},[32,13143,13144],{"class":130},"      return",[32,13146,5539],{"class":66},[32,13148,13149,13152,13155,13158,13160,13163,13166,13168,13171,13174,13176,13179],{"class":34,"line":219},[32,13150,13151],{"class":66},"        \u003C",[32,13153,13154],{"class":2409},"button",[32,13156,13157],{"class":45}," type",[32,13159,137],{"class":130},[32,13161,13162],{"class":49},"\"button\"",[32,13164,13165],{"class":45}," onClick",[32,13167,137],{"class":130},[32,13169,13170],{"class":66},"{onClick} ",[32,13172,13173],{"class":45},"className",[32,13175,137],{"class":130},[32,13177,13178],{"class":49},"\"group relative block w-full ...\"",[32,13180,635],{"class":66},[32,13182,13183,13186],{"class":34,"line":225},[32,13184,13185],{"class":66},"          \u003C",[32,13187,13188],{"class":2409},"img\n",[32,13190,13191,13194,13196],{"class":34,"line":236},[32,13192,13193],{"class":45},"            src",[32,13195,137],{"class":130},[32,13197,13198],{"class":66},"{photo.src}\n",[32,13200,13201,13204,13206,13209,13211,13213],{"class":34,"line":247},[32,13202,13203],{"class":45},"            alt",[32,13205,137],{"class":130},[32,13207,13208],{"class":66},"{photo.alt ",[32,13210,2923],{"class":130},[32,13212,5237],{"class":49},[32,13214,2003],{"class":66},[32,13216,13217,13220,13222],{"class":34,"line":252},[32,13218,13219],{"class":45},"            decoding",[32,13221,137],{"class":130},[32,13223,13224],{"class":49},"\"async\"\n",[32,13226,13227,13230,13232],{"class":34,"line":261},[32,13228,13229],{"class":45},"            style",[32,13231,137],{"class":130},[32,13233,13234],{"class":66},"{{ width, height }}\n",[32,13236,13237],{"class":34,"line":266},[32,13238,13239],{"class":66},"          \u002F>\n",[32,13241,13242,13245,13248],{"class":34,"line":476},[32,13243,13244],{"class":66},"          {",[32,13246,13247],{"class":38},"\u002F* hover 提示词浮层 *\u002F",[32,13249,2003],{"class":66},[32,13251,13252,13254,13257,13260,13262,13265],{"class":34,"line":484},[32,13253,13185],{"class":66},[32,13255,13256],{"class":2409},"div",[32,13258,13259],{"class":45}," className",[32,13261,137],{"class":130},[32,13263,13264],{"class":49},"\"... opacity-0 group-hover:opacity-100\"",[32,13266,635],{"class":66},[32,13268,13269],{"class":34,"line":489},[32,13270,13271],{"class":66},"            {photo.prompt_text}\n",[32,13273,13274,13277,13279],{"class":34,"line":1769},[32,13275,13276],{"class":66},"          \u003C\u002F",[32,13278,13256],{"class":2409},[32,13280,635],{"class":66},[32,13282,13283,13286,13288],{"class":34,"line":1783},[32,13284,13285],{"class":66},"        \u003C\u002F",[32,13287,13154],{"class":2409},[32,13289,635],{"class":66},[32,13291,13292],{"class":34,"line":1790},[32,13293,13294],{"class":66},"      )\n",[32,13296,13297],{"class":34,"line":1795},[32,13298,7689],{"class":66},[32,13300,13301],{"class":34,"line":1801},[32,13302,13303],{"class":66},"  }}\n",[32,13305,13306],{"class":34,"line":1809},[32,13307,13308],{"class":66},"\u002F>\n",[15,13310,13311],{},"几个要点：",[2256,13313,13314,13323,13336],{},[287,13315,13316,13318,13319,13322],{},[29,13317,12613],{}," 可以是数字或 ",[29,13320,13321],{},"(containerWidth) => number"," 函数，做响应式列数很方便；",[287,13324,13325,13326,13328,13329,13332,13333,13335],{},"点击回调 ",[29,13327,13103],{}," 里拿到的是",[1948,13330,13331],{},"原始数组下标"," ",[29,13334,13051],{},"，直接传给灯箱即可；",[287,13337,13338,13339,13342,13343,13345],{},"自定义 ",[29,13340,13341],{},"render.photo"," 后，图片尺寸要自己用 context 里的 ",[29,13344,12661],{},"（渲染尺寸）设置——这两个值库已经按列宽算好了。",[11,13347,13348],{"id":13348},"遇到的坑",[280,13350,13352],{"id":13351},"坑-1忘记引入样式表-一张一列","坑 1：忘记引入样式表 → 「一张一列」",[15,13354,13355,13356,13359,13360,2600],{},"这是最坑的一个。装完包、写好 ",[29,13357,13358],{},"\u003CPhotoAlbum layout=\"masonry\">","，页面渲染出来却是：",[1948,13361,13362],{},"每张图占一整行，右侧大片置灰空白",[15,13364,13365,13366,13368],{},"排查过程：先怀疑是 ",[29,13367,12613],{}," 函数没生效，改成固定数字无效；再检查 DOM——容器和 5 个列（track）都在，列的宽度也算对了，但图片全都堆在布局里。",[15,13370,13371,13372,13375,13376,7535,13379,13382,13383,13389,13390,13393],{},"最后翻包的 ",[29,13373,13374],{},"package.json"," 发现它导出了独立的 CSS 文件（",[29,13377,13378],{},".\u002Fstyles.css",[29,13380,13381],{},".\u002Fmasonry.css"," 等），而",[1948,13384,13385,13386],{},"官方 README 示例都带了一行 ",[29,13387,13388],{},"import \"react-photo-album\u002Fstyles.css\"","。容器是 flex、列是 flex-column、列宽按 ",[29,13391,13392],{},"--react-photo-album--columns"," 计算——这些规则全在这个样式表里，不引入就退化成普通块级堆叠。",[15,13395,13396,13398],{},[1948,13397,7530],{},"：顶部加一行：",[22,13400,13402],{"className":11164,"code":13401,"language":11166,"meta":27,"style":27},"import \"react-photo-album\u002Fstyles.css\"\n",[29,13403,13404],{"__ignoreMap":27},[32,13405,13406,13408],{"class":34,"line":35},[32,13407,4141],{"class":130},[32,13409,12764],{"class":49},[15,13411,13412,13413],{},"经验：",[1948,13414,13415],{},"用这类\"无运行时依赖\"的布局库，先看它的 exports 里有没有独立 CSS，有就一定要引。",[280,13417,13419,13420,13422,13423,13426],{"id":13418},"坑-2renderphoto-是-props-context-双参签名","坑 2：",[29,13421,13341],{}," 是 ",[29,13424,13425],{},"(props, context)"," 双参签名",[15,13428,13429],{},"自定义渲染时我凭直觉写了：",[22,13431,13433],{"className":11164,"code":13432,"language":11166,"meta":27,"style":27},"photo: ({ photo, index, width, height, onClick }) => ... \u002F\u002F ❌ 报错：Property 'photo' does not exist\n",[29,13434,13435],{"__ignoreMap":27},[32,13436,13437,13439,13442,13444,13446,13448,13450,13452,13454,13456,13458,13460,13462,13464,13467],{"class":34,"line":35},[32,13438,13120],{"class":45},[32,13440,13441],{"class":66},": ({ ",[32,13443,13120],{"class":2712},[32,13445,798],{"class":66},[32,13447,13051],{"class":2712},[32,13449,798],{"class":66},[32,13451,12725],{"class":2712},[32,13453,798],{"class":66},[32,13455,12728],{"class":2712},[32,13457,798],{"class":66},[32,13459,13103],{"class":2712},[32,13461,13054],{"class":66},[32,13463,3137],{"class":130},[32,13465,13466],{"class":130}," ...",[32,13468,13469],{"class":38}," \u002F\u002F ❌ 报错：Property 'photo' does not exist\n",[15,13471,13472,13473,13475,13476,13479,13480,3852],{},"TS 报错提示 ",[29,13474,13120],{}," 等属性不存在于 ",[29,13477,13478],{},"RenderPhotoProps","。看类型定义才发现 v3 的渲染函数是",[1948,13481,13482],{},"两个参数",[22,13484,13486],{"className":11164,"code":13485,"language":11166,"meta":27,"style":27},"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",[29,13487,13488,13534,13539,13559,13573,13598,13602],{"__ignoreMap":27},[32,13489,13490,13492,13495,13497,13500,13502,13504,13507,13509,13511,13513,13515,13518,13520,13522,13524,13527,13529,13531],{"class":34,"line":35},[32,13491,5526],{"class":130},[32,13493,13494],{"class":45}," RenderFunction",[32,13496,1625],{"class":66},[32,13498,13499],{"class":45},"Props",[32,13501,798],{"class":66},[32,13503,4347],{"class":45},[32,13505,13506],{"class":66},"> ",[32,13508,137],{"class":130},[32,13510,2751],{"class":66},[32,13512,3244],{"class":2712},[32,13514,1622],{"class":130},[32,13516,13517],{"class":45}," Props",[32,13519,798],{"class":66},[32,13521,13087],{"class":2712},[32,13523,1622],{"class":130},[32,13525,13526],{"class":45}," Context",[32,13528,3173],{"class":66},[32,13530,3137],{"class":130},[32,13532,13533],{"class":45}," ReactNode\n",[32,13535,13536],{"class":34,"line":42},[32,13537,13538],{"class":38},"\u002F\u002F photo 的渲染上下文（photo\u002Findex\u002Fwidth\u002Fheight）在第二个参数里\n",[32,13540,13541,13543,13545,13547,13549,13551,13553,13555,13557],{"class":34,"line":85},[32,13542,13120],{"class":45},[32,13544,1622],{"class":130},[32,13546,2751],{"class":66},[32,13548,3244],{"class":2712},[32,13550,798],{"class":66},[32,13552,13087],{"class":2712},[32,13554,3173],{"class":66},[32,13556,3137],{"class":130},[32,13558,2349],{"class":66},[32,13560,13561,13563,13565,13567,13570],{"class":34,"line":92},[32,13562,3198],{"class":45},[32,13564,13100],{"class":66},[32,13566,13103],{"class":2712},[32,13568,13569],{"class":66}," } = ",[32,13571,13572],{"class":2712},"props\n",[32,13574,13575,13577,13579,13581,13583,13585,13587,13589,13591,13593,13595],{"class":34,"line":98},[32,13576,3198],{"class":45},[32,13578,13100],{"class":66},[32,13580,13120],{"class":2712},[32,13582,798],{"class":66},[32,13584,13051],{"class":2712},[32,13586,798],{"class":66},[32,13588,12725],{"class":2712},[32,13590,798],{"class":66},[32,13592,12728],{"class":2712},[32,13594,13569],{"class":66},[32,13596,13597],{"class":2712},"context\n",[32,13599,13600],{"class":34,"line":127},[32,13601,11157],{"class":130},[32,13603,13604],{"class":34,"line":146},[32,13605,2003],{"class":66},[15,13607,13608,13610,13611,13613,13614,13617],{},[1948,13609,7530],{},"：按 ",[29,13612,13425],{}," 拆解即可。装新库先扫一眼 ",[29,13615,13616],{},".d.ts"," 里的 Render 类型签名，能少走很多弯路。",[280,13619,13621],{"id":13620},"坑-3测试断言把dom-顺序当成了视觉顺序","坑 3：测试断言把「DOM 顺序」当成了「视觉顺序」",[15,13623,13624,13625,13628],{},"布局修好后，我在浏览器自测脚本里加了一条断言：取前两个 ",[29,13626,13627],{},"\u003Cimg>","，比较它们的左坐标，认为应该并排（不同列）。",[15,13630,13631,13632,13635,13636,7569,13639,13642,13643,13646],{},"结果断言失败——但页面明明是 5 列并排。排查发现：",[1948,13633,13634],{},"瀑布流的 DOM 是按\"列\"组织的","（第 1 列的全部图片在前，第 2 列的在后），所以 ",[29,13637,13638],{},"img[0]",[29,13640,13641],{},"img[1]"," 属于",[1948,13644,13645],{},"同一列","，左坐标当然相同。",[15,13648,13649,13651],{},[1948,13650,7530],{},"：断言改为比较「第 1 列的首张图 vs 第 2 列的首张图」：",[22,13653,13657],{"className":13654,"code":13655,"language":13656,"meta":27,"style":27},"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",[29,13658,13659,13684,13707],{"__ignoreMap":27},[32,13660,13661,13663,13665,13667,13670,13672,13674,13677,13679,13682],{"class":34,"line":35},[32,13662,2943],{"class":130},[32,13664,3958],{"class":53},[32,13666,2346],{"class":130},[32,13668,13669],{"class":66}," tracks[",[32,13671,2797],{"class":53},[32,13673,2862],{"class":66},[32,13675,13676],{"class":45},"querySelector",[32,13678,2709],{"class":66},[32,13680,13681],{"class":49},"\"img\"",[32,13683,3019],{"class":66},[32,13685,13686,13688,13691,13693,13695,13697,13699,13701,13703,13705],{"class":34,"line":42},[32,13687,2943],{"class":130},[32,13689,13690],{"class":53}," b",[32,13692,2346],{"class":130},[32,13694,13669],{"class":66},[32,13696,156],{"class":53},[32,13698,2862],{"class":66},[32,13700,13676],{"class":45},[32,13702,2709],{"class":66},[32,13704,13681],{"class":49},[32,13706,3019],{"class":66},[32,13708,13709,13711,13714,13717,13720,13723,13726,13729,13732,13734,13737,13739],{"class":34,"line":85},[32,13710,2760],{"class":130},[32,13712,13713],{"class":66}," Math.",[32,13715,13716],{"class":45},"abs",[32,13718,13719],{"class":66},"(a.",[32,13721,13722],{"class":45},"getBoundingClientRect",[32,13724,13725],{"class":66},"().left ",[32,13727,13728],{"class":130},"-",[32,13730,13731],{"class":66}," b.",[32,13733,13722],{"class":45},[32,13735,13736],{"class":66},"().left) ",[32,13738,588],{"class":130},[32,13740,13741],{"class":53}," 10\n",[15,13743,13412,13744,13747],{},[1948,13745,13746],{},"验证布局类库时，先搞清楚它生成的 DOM 结构，再写断言","；另外把「真实浏览器探针」当排查手段（直接打印各 track 的 x 坐标和子元素数量），比盯着代码猜快得多。",[280,13749,13751],{"id":13750},"坑-4等宽网格的洞引入前就存在的产品问题","坑 4：等宽网格的\"洞\"（引入前就存在的产品问题）",[15,13753,13754,13755,13758,13759,13762],{},"顺带记录一下为什么换掉原来的布局：CSS Grid ",[29,13756,13757],{},"repeat(auto-fill, minmax(220px, 1fr))"," 等宽铺满，图片 ",[29,13760,13761],{},"w-full h-auto","。当一行里混有不同比例时，行高取最高的那张，矮图底部留空，几行错落下来就显得乱；另外窗口宽度\"差一点放不下下一张\"时，右侧会空出近一整列。",[15,13764,13765],{},"换成 Masonry 后这两个问题都消失了——列内紧密堆叠、列数自适应。",[11,13767,13768],{"id":13768},"效果与小结",[15,13770,13771,13772,13774],{},"最终效果：21 张不同比例的生成图在 5 列瀑布流里紧密排列，原比例不裁剪，悬停出提示词浮层，点击进 YARL 大图预览；缩略图总大小仅 360KB，进页面即全量加载 + ",[29,13773,12265],{}," 长缓存，平板上快速滚动也跟手。",[15,13776,10943,13777],{},[1948,13778,13779],{},"react-photo-album 是个小但专业的相册布局库，只要记住三件事——引样式表、喂宽高、render 函数是双参——就能在十分钟内把图片墙升级成真正的瀑布流。",[824,13781,13782],{},"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":27,"searchDepth":42,"depth":85,"links":13784},[13785,13786,13787,13792,13799],{"id":12559,"depth":42,"text":12559},{"id":12665,"depth":42,"text":12665},{"id":12691,"depth":42,"text":12691,"children":13788},[13789,13790,13791],{"id":12694,"depth":85,"text":12695},{"id":12712,"depth":85,"text":12713},{"id":12910,"depth":85,"text":12911},{"id":13348,"depth":42,"text":13348,"children":13793},[13794,13795,13797,13798],{"id":13351,"depth":85,"text":13352},{"id":13418,"depth":85,"text":13796},"坑 2：render.photo 是 (props, context) 双参签名",{"id":13620,"depth":85,"text":13621},{"id":13750,"depth":85,"text":13751},{"id":13768,"depth":42,"text":13768},"记录在 Magic Img 生图图库中引入 react-photo-album 实现 Masonry 瀑布流的过程：技术选型背景、需求梳理、安装使用，以及「忘记引入样式表导致一张一列」等踩坑与排查方法。","\u002Fposts\u002Freact-photo-album-masonry\u002Fimg\u002Fcover.svg",{},"\u002Fposts\u002Freact-photo-album-masonry",{"title":12554,"description":13800},"前端工程实践","posts\u002Freact-photo-album-masonry\u002Findex",[9834,12642,13808,13809,13810],"瀑布流","前端","Tailwind","-O-kAQLEyRzwVhOpuo-yBxZim9LtvhChPkpP92ipyjM",1786887349178]