[{"data":1,"prerenderedAt":13499},["ShallowReactive",2],{"post-docker-user-start":3,"series-source-docker-user-start":493},{"id":4,"title":5,"author":6,"body":7,"description":477,"difficulty":478,"draft":479,"extension":480,"image":481,"meta":482,"navigation":85,"path":483,"pinned":479,"published":484,"seo":485,"series":486,"seriesOrder":50,"stem":487,"tags":488,"updated":484,"__hash__":492},"posts\u002Fposts\u002Fdocker-user-start\u002Findex.md","Docker 构建时指定用户启动",null,{"type":8,"value":9,"toc":467},"minimark",[10,14,18,21,26,29,59,63,101,105,112,173,176,422,425,428,460,463],[11,12,13],"h2",{"id":13},"前言",[15,16,17],"p",{},"我们在 Linux 使用应用时，往往不会直接使用 root 用户作为应用启动项，但 Dockerfile 默认就是 root 用户，导致挂载生成的文件路径往往是 root 权限，非 root 用户访问就很不方便。",[11,19,20],{"id":20},"解决方案",[22,23,25],"h3",{"id":24},"_1-安装用户管理工具","1. 安装用户管理工具",[15,27,28],{},"若镜像非自带用户管理：",[30,31,36],"pre",{"className":32,"code":33,"language":34,"meta":35,"style":35},"language-dockerfile shiki shiki-themes github-light github-dark","# 权限管理\nRUN apk add --no-cache shadow\n","dockerfile","",[37,38,39,48],"code",{"__ignoreMap":35},[40,41,44],"span",{"class":42,"line":43},"line",1,[40,45,47],{"class":46},"sHbNN","# 权限管理\n",[40,49,51,55],{"class":42,"line":50},2,[40,52,54],{"class":53},"siTax","RUN",[40,56,58],{"class":57},"sIX_F"," apk add --no-cache shadow\n",[22,60,62],{"id":61},"_2-创建用户并修改权限","2. 创建用户并修改权限",[30,64,66],{"className":32,"code":65,"language":34,"meta":35,"style":35},"# 创建用户组和用户\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",[37,67,68,73,80,87,93],{"__ignoreMap":35},[40,69,70],{"class":42,"line":43},[40,71,72],{"class":46},"# 创建用户组和用户\n",[40,74,75,77],{"class":42,"line":50},[40,76,54],{"class":53},[40,78,79],{"class":57}," addgroup -S \u003Capp_user_group> && adduser -S -G \u003Capp_user_group> \u003Capp_user>\n",[40,81,83],{"class":42,"line":82},3,[40,84,86],{"emptyLinePlaceholder":85},true,"\n",[40,88,90],{"class":42,"line":89},4,[40,91,92],{"class":46},"# 修改目录权限\n",[40,94,96,98],{"class":42,"line":95},5,[40,97,54],{"class":53},[40,99,100],{"class":57}," chown -R \u003Capp_user_group>:\u003Capp_user> \u002Fdata\n",[22,102,104],{"id":103},"_3-指定用户启动","3. 指定用户启动",[15,106,107,108,111],{},"执行 docker 时加 ",[37,109,110],{},"--user"," 参数：",[30,113,117],{"className":114,"code":115,"language":116,"meta":35,"style":35},"language-bash shiki shiki-themes github-light github-dark","docker run -it --user \u003Capp_user> \u003Capp_image>:\u003Cimage_version>\n","bash",[37,118,119],{"__ignoreMap":35},[40,120,121,125,129,133,136,139,142,145,148,150,153,156,158,161,164,167,170],{"class":42,"line":43},[40,122,124],{"class":123},"sw2iP","docker",[40,126,128],{"class":127},"scXbn"," run",[40,130,132],{"class":131},"suQ91"," -it",[40,134,135],{"class":131}," --user",[40,137,138],{"class":53}," \u003C",[40,140,141],{"class":127},"app_use",[40,143,144],{"class":57},"r",[40,146,147],{"class":53},">",[40,149,138],{"class":53},[40,151,152],{"class":127},"app_imag",[40,154,155],{"class":57},"e",[40,157,147],{"class":53},[40,159,160],{"class":127},":",[40,162,163],{"class":53},"\u003C",[40,165,166],{"class":127},"image_versio",[40,168,169],{"class":57},"n",[40,171,172],{"class":53},">\n",[11,174,175],{"id":175},"完整示例",[30,177,179],{"className":32,"code":178,"language":34,"meta":35,"style":35},"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",[37,180,181,189,193,198,211,215,223,228,235,241,249,255,263,268,277,282,287,295,300,309,314,322,327,342,351,356,362,370,378,383,389,398],{"__ignoreMap":35},[40,182,183,186],{"class":42,"line":43},[40,184,185],{"class":53},"FROM",[40,187,188],{"class":57}," alpine:latest\n",[40,190,191],{"class":42,"line":50},[40,192,86],{"emptyLinePlaceholder":85},[40,194,195],{"class":42,"line":82},[40,196,197],{"class":46},"# 国内源\n",[40,199,200,202,205,208],{"class":42,"line":89},[40,201,54],{"class":53},[40,203,204],{"class":57}," sed -i ",[40,206,207],{"class":127},"'s\u002Fdl-cdn.alpinelinux.org\u002Fmirrors.ustc.edu.cn\u002Fg'",[40,209,210],{"class":57}," \u002Fetc\u002Fapk\u002Frepositories\n",[40,212,213],{"class":42,"line":95},[40,214,86],{"emptyLinePlaceholder":85},[40,216,218,220],{"class":42,"line":217},6,[40,219,54],{"class":53},[40,221,222],{"class":57}," apk update --no-cache\n",[40,224,226],{"class":42,"line":225},7,[40,227,47],{"class":46},[40,229,231,233],{"class":42,"line":230},8,[40,232,54],{"class":53},[40,234,58],{"class":57},[40,236,238],{"class":42,"line":237},9,[40,239,240],{"class":46},"# 调试\n",[40,242,244,246],{"class":42,"line":243},10,[40,245,54],{"class":53},[40,247,248],{"class":57}," apk add --no-cache bash\n",[40,250,252],{"class":42,"line":251},11,[40,253,254],{"class":46},"# 设置时区\n",[40,256,258,260],{"class":42,"line":257},12,[40,259,54],{"class":53},[40,261,262],{"class":57}," apk add --no-cache tzdata\n",[40,264,266],{"class":42,"line":265},13,[40,267,86],{"emptyLinePlaceholder":85},[40,269,271,274],{"class":42,"line":270},14,[40,272,273],{"class":53},"ENV",[40,275,276],{"class":57}," TZ=Asia\u002FShanghai\n",[40,278,280],{"class":42,"line":279},15,[40,281,86],{"emptyLinePlaceholder":85},[40,283,285],{"class":42,"line":284},16,[40,286,72],{"class":46},[40,288,290,292],{"class":42,"line":289},17,[40,291,54],{"class":53},[40,293,294],{"class":57}," addgroup -S glog && adduser -S -G glog glog\n",[40,296,298],{"class":42,"line":297},18,[40,299,86],{"emptyLinePlaceholder":85},[40,301,303,306],{"class":42,"line":302},19,[40,304,305],{"class":53},"COPY",[40,307,308],{"class":57}," .\u002Fglog_static_musl_1 \u002Fapp\u002F\n",[40,310,312],{"class":42,"line":311},20,[40,313,86],{"emptyLinePlaceholder":85},[40,315,317,319],{"class":42,"line":316},21,[40,318,54],{"class":53},[40,320,321],{"class":57}," mkdir \u002Fdata\n",[40,323,325],{"class":42,"line":324},22,[40,326,86],{"emptyLinePlaceholder":85},[40,328,330,333,336,339],{"class":42,"line":329},23,[40,331,332],{"class":53},"VOLUME",[40,334,335],{"class":57}," [ ",[40,337,338],{"class":127},"\"\u002Fdata\"",[40,340,341],{"class":57}," ]\n",[40,343,345,348],{"class":42,"line":344},24,[40,346,347],{"class":53},"WORKDIR",[40,349,350],{"class":57}," \u002Fdata\n",[40,352,354],{"class":42,"line":353},25,[40,355,86],{"emptyLinePlaceholder":85},[40,357,359],{"class":42,"line":358},26,[40,360,361],{"class":46},"# 修改权限\n",[40,363,365,367],{"class":42,"line":364},27,[40,366,54],{"class":53},[40,368,369],{"class":57}," chown -R glog:glog \u002Fdata\n",[40,371,373,375],{"class":42,"line":372},28,[40,374,54],{"class":53},[40,376,377],{"class":57}," chown -R glog:glog \u002Fapp\n",[40,379,381],{"class":42,"line":380},29,[40,382,86],{"emptyLinePlaceholder":85},[40,384,386],{"class":42,"line":385},30,[40,387,388],{"class":46},"# 指定用户启动\n",[40,390,392,395],{"class":42,"line":391},31,[40,393,394],{"class":53},"USER",[40,396,397],{"class":57}," glog\n",[40,399,401,404,406,409,412,415,417,420],{"class":42,"line":400},32,[40,402,403],{"class":53},"CMD",[40,405,335],{"class":57},[40,407,408],{"class":127},"\"\u002Fapp\u002Fglog_static_musl_1\"",[40,410,411],{"class":57},", ",[40,413,414],{"class":127},"\"logsweb\"",[40,416,411],{"class":57},[40,418,419],{"class":127},"\".\u002Fconf.ini\"",[40,421,341],{"class":57},[11,423,424],{"id":424},"总结",[15,426,427],{},"关键步骤：",[429,430,431,438,448,454],"ol",{},[432,433,434,437],"li",{},[37,435,436],{},"apk add --no-cache shadow"," - 安装用户管理工具",[432,439,440,443,444,447],{},[37,441,442],{},"addgroup"," + ",[37,445,446],{},"adduser"," - 创建用户",[432,449,450,453],{},[37,451,452],{},"chown -R"," - 修改目录权限",[432,455,456,459],{},[37,457,458],{},"USER \u003Cusername>"," - 指定运行用户",[15,461,462],{},"这样容器内的应用就不再以 root 身份运行，更加安全。",[464,465,466],"style",{},"html pre.shiki code .sHbNN, html code.shiki .sHbNN{--shiki-light:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .siTax, html code.shiki .siTax{--shiki-light:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sIX_F, html code.shiki .sIX_F{--shiki-light:#24292E;--shiki-dark:#E1E4E8}html .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":35,"searchDepth":50,"depth":82,"links":468},[469,470,475,476],{"id":13,"depth":50,"text":13},{"id":20,"depth":50,"text":20,"children":471},[472,473,474],{"id":24,"depth":82,"text":25},{"id":61,"depth":82,"text":62},{"id":103,"depth":82,"text":104},{"id":175,"depth":50,"text":175},{"id":424,"depth":50,"text":424},"解决 Docker 容器默认以 root 用户运行导致的权限问题，指定非 root 用户启动应用。","入门",false,"md","\u002Fposts\u002Fdocker-user-start\u002Fimg\u002Fcover.svg",{},"\u002Fposts\u002Fdocker-user-start","2025-04-01",{"title":5,"description":477},"Docker 实践笔记","posts\u002Fdocker-user-start\u002Findex",[489,490,491],"Docker","运维","Linux","b4Uyw5ms87xr7NhpIkavHnNbT20oaEPtXz8k1w6UPj0",[494,1276,1606,1837,2255,3446,3725,5794,6576,7555,8964,9524,10703,12239],{"id":495,"title":496,"author":6,"body":497,"description":1265,"difficulty":1266,"draft":479,"extension":480,"image":1267,"meta":1268,"navigation":85,"path":1269,"pinned":479,"published":1270,"seo":1271,"series":486,"seriesOrder":43,"stem":1272,"tags":1273,"updated":1270,"__hash__":1275},"posts\u002Fposts\u002Fdocker-musl-build\u002Findex.md","Docker 编译 musl 可执行文件",{"type":8,"value":498,"toc":1254},[499,502,505,508,728,731,773,776,794,797,936,939,1072,1075,1078,1136,1139,1251],[11,500,501],{"id":501},"解决问题",[15,503,504],{},"用 Go 进行跨平台编译时往往步骤复杂，用 Docker 可以轻松解决。但 Docker 的当前最小镜像 Alpine 其动态链接库采用 musl，与主流的 glibc 不同，往往不能方便执行。",[11,506,507],{"id":507},"查阅资料",[30,509,511],{"className":114,"code":510,"language":116,"meta":35,"style":35},"# 启动容器并挂载当前目录到 `\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",[37,512,513,518,553,557,562,590,607,622,627,640,662,670,674,679,689,699,703,711,715],{"__ignoreMap":35},[40,514,515],{"class":42,"line":43},[40,516,517],{"class":46},"# 启动容器并挂载当前目录到 `\u002Fapp`\n",[40,519,520,522,524,526,529,532,535,538,541,544,547,550],{"class":42,"line":50},[40,521,124],{"class":123},[40,523,128],{"class":127},[40,525,132],{"class":131},[40,527,528],{"class":131}," --rm",[40,530,531],{"class":131}," -v",[40,533,534],{"class":127}," \"",[40,536,537],{"class":57},"$PWD",[40,539,540],{"class":127},"\":\u002Fapp",[40,542,543],{"class":131}," -w",[40,545,546],{"class":127}," \u002Fapp",[40,548,549],{"class":127}," ubuntu:20.04",[40,551,552],{"class":127}," bash\n",[40,554,555],{"class":42,"line":82},[40,556,86],{"emptyLinePlaceholder":85},[40,558,559],{"class":42,"line":89},[40,560,561],{"class":46},"# 在容器内执行以下操作：\n",[40,563,564,567,570,573,575,578,581,584,587],{"class":42,"line":95},[40,565,566],{"class":123},"apt-get",[40,568,569],{"class":127}," update",[40,571,572],{"class":57}," && ",[40,574,566],{"class":123},[40,576,577],{"class":127}," install",[40,579,580],{"class":131}," -y",[40,582,583],{"class":127}," musl-tools",[40,585,586],{"class":127}," golang-go",[40,588,589],{"class":46},"  # 安装 musl 和 Go\n",[40,591,592,595,598,601,604],{"class":42,"line":217},[40,593,594],{"class":53},"export",[40,596,597],{"class":57}," CC",[40,599,600],{"class":53},"=",[40,602,603],{"class":57},"musl-gcc  ",[40,605,606],{"class":46},"# 指定 musl 编译器\n",[40,608,609,611,614,616,619],{"class":42,"line":225},[40,610,594],{"class":53},[40,612,613],{"class":57}," CGO_ENABLED",[40,615,600],{"class":53},[40,617,618],{"class":131},"1",[40,620,621],{"class":46},"  # 启用 CGO\n",[40,623,624],{"class":42,"line":230},[40,625,626],{"class":46},"# 设置代理\n",[40,628,629,632,635,637],{"class":42,"line":237},[40,630,631],{"class":123},"go",[40,633,634],{"class":127}," env",[40,636,543],{"class":131},[40,638,639],{"class":127}," GOPROXY=https:\u002F\u002Fgoproxy.cn,direct\n",[40,641,642,644,647,650,653,656,659],{"class":42,"line":243},[40,643,631],{"class":123},[40,645,646],{"class":127}," build",[40,648,649],{"class":131}," -ldflags",[40,651,652],{"class":127}," '-linkmode external -extldflags \"-static\"'",[40,654,655],{"class":131}," -o",[40,657,658],{"class":127}," myapp",[40,660,661],{"class":46},"  # 静态编译\n",[40,663,664,667],{"class":42,"line":251},[40,665,666],{"class":131},"exit",[40,668,669],{"class":46},"  # 退出容器\n",[40,671,672],{"class":42,"line":257},[40,673,86],{"emptyLinePlaceholder":85},[40,675,676],{"class":42,"line":265},[40,677,678],{"class":46},"# 验证结果（在宿主机执行）\n",[40,680,681,684,686],{"class":42,"line":270},[40,682,683],{"class":123},"file",[40,685,658],{"class":127},[40,687,688],{"class":46},"  # 应显示 \"statically linked\"\n",[40,690,691,694,696],{"class":42,"line":279},[40,692,693],{"class":123},"ldd",[40,695,658],{"class":127},[40,697,698],{"class":46},"   # 应显示 \"not a dynamic executable\"\n",[40,700,701],{"class":42,"line":284},[40,702,86],{"emptyLinePlaceholder":85},[40,704,705,708],{"class":42,"line":289},[40,706,707],{"class":123},"wget",[40,709,710],{"class":127}," https:\u002F\u002Fgo.dev\u002Fdl\u002Fgo1.21.linux-amd64.tar.gz\n",[40,712,713],{"class":42,"line":297},[40,714,86],{"emptyLinePlaceholder":85},[40,716,717,719,722,725],{"class":42,"line":302},[40,718,273],{"class":123},[40,720,721],{"class":127}," PATH=\"\u002Fdata\u002Fgo\u002Fbin:",[40,723,724],{"class":57},"$PATH",[40,726,727],{"class":127},"\"\n",[22,729,730],{"id":730},"遇到问题",[429,732,733,759],{},[432,734,735,738,739,742,745,746,749,751,752,754],{},[37,736,737],{},"go mod tidy"," 时报错 ",[37,740,741],{},"tls: failed to verify certificate: x509: certificate",[743,744],"br",{},"解决：",[37,747,748],{},"RUN apt-get update && apt-get install -y ca-certificates && rm -rf \u002Fvar\u002Flib\u002Fapt\u002Flists\u002F*",[743,750],{},"基础镜像的证书未更新，导致 https 的请求出现类似错误。",[743,753],{},[755,756],"img",{"alt":757,"src":758},"image-20250327100813512","\u002Fposts\u002Fdocker-musl-build\u002Fimg\u002Fimage-20250327100813512.png",[432,760,761,762,765,766,768,769,772],{},"直接安装 ",[37,763,764],{},"golang-go"," 其版本过低",[743,767],{},"解决: ",[37,770,771],{},"PATH=\"\u002Fdata\u002Fgo\u002Fbin:$PATH\""," 手动指定环境",[11,774,775],{"id":775},"最终方案",[429,777,778,785,788],{},[432,779,780,781,784],{},"下载 ",[37,782,783],{},"go1.24.1.linux-amd64.tar.gz"," 解压",[432,786,787],{},"编写 Dockerfile",[432,789,790,791],{},"执行 ",[37,792,793],{},"docker build -t go-build:v1 .",[15,795,796],{},"Dockerfile 内容：",[30,798,800],{"className":32,"code":799,"language":34,"meta":35,"style":35},"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",[37,801,802,809,813,824,828,835,839,849,853,860,864,869,876,880,887,892,899,903,909,913,919,923],{"__ignoreMap":35},[40,803,804,806],{"class":42,"line":43},[40,805,185],{"class":53},[40,807,808],{"class":57}," ubuntu:20.04\n",[40,810,811],{"class":42,"line":50},[40,812,86],{"emptyLinePlaceholder":85},[40,814,815,818,821],{"class":42,"line":82},[40,816,817],{"class":53},"LABEL",[40,819,820],{"class":57}," org.opencontainers.image.authors=",[40,822,823],{"class":127},"\"sutong\"\n",[40,825,826],{"class":42,"line":89},[40,827,86],{"emptyLinePlaceholder":85},[40,829,830,832],{"class":42,"line":95},[40,831,305],{"class":53},[40,833,834],{"class":57}," go\u002F \u002Fgo\u002F\n",[40,836,837],{"class":42,"line":217},[40,838,86],{"emptyLinePlaceholder":85},[40,840,841,843,846],{"class":42,"line":225},[40,842,273],{"class":53},[40,844,845],{"class":57}," PATH=",[40,847,848],{"class":127},"\"\u002Fgo\u002Fbin:$PATH\"\n",[40,850,851],{"class":42,"line":230},[40,852,86],{"emptyLinePlaceholder":85},[40,854,855,857],{"class":42,"line":237},[40,856,54],{"class":53},[40,858,859],{"class":57}," apt-get update && apt-get install -y musl-tools\n",[40,861,862],{"class":42,"line":243},[40,863,86],{"emptyLinePlaceholder":85},[40,865,866],{"class":42,"line":251},[40,867,868],{"class":46},"## go mod tidy 时报错 tls: failed to verify certificate: x509: certificate\n",[40,870,871,873],{"class":42,"line":257},[40,872,54],{"class":53},[40,874,875],{"class":57}," apt-get update && apt-get install -y ca-certificates && rm -rf \u002Fvar\u002Flib\u002Fapt\u002Flists\u002F*\n",[40,877,878],{"class":42,"line":265},[40,879,606],{"class":46},[40,881,882,884],{"class":42,"line":270},[40,883,273],{"class":53},[40,885,886],{"class":57}," CC=musl-gcc\n",[40,888,889],{"class":42,"line":279},[40,890,891],{"class":46},"# 启用 CGO\n",[40,893,894,896],{"class":42,"line":284},[40,895,273],{"class":53},[40,897,898],{"class":57}," CGO_ENABLED=1\n",[40,900,901],{"class":42,"line":289},[40,902,86],{"emptyLinePlaceholder":85},[40,904,905,907],{"class":42,"line":297},[40,906,332],{"class":53},[40,908,350],{"class":57},[40,910,911],{"class":42,"line":302},[40,912,86],{"emptyLinePlaceholder":85},[40,914,915,917],{"class":42,"line":311},[40,916,347],{"class":53},[40,918,350],{"class":57},[40,920,921],{"class":42,"line":316},[40,922,86],{"emptyLinePlaceholder":85},[40,924,925,927,930,933],{"class":42,"line":324},[40,926,403],{"class":53},[40,928,929],{"class":57}," [",[40,931,932],{"class":127},"\"\u002Fbin\u002Fbash\"",[40,934,935],{"class":57},"]\n",[22,937,938],{"id":938},"构建",[30,940,942],{"className":114,"code":941,"language":116,"meta":35,"style":35},"# 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",[37,943,944,949,953,958,984,988,993,1026,1031,1035,1067],{"__ignoreMap":35},[40,945,946],{"class":42,"line":43},[40,947,948],{"class":46},"# docker build -t go-build:v1 .\n",[40,950,951],{"class":42,"line":50},[40,952,86],{"emptyLinePlaceholder":85},[40,954,955],{"class":42,"line":82},[40,956,957],{"class":46},"# 直接执行编译 编译后删除\n",[40,959,960,962,964,966,968,971,974,977,979,981],{"class":42,"line":89},[40,961,124],{"class":123},[40,963,128],{"class":127},[40,965,528],{"class":131},[40,967,531],{"class":131},[40,969,970],{"class":127}," .:\u002Fdata",[40,972,973],{"class":127}," go-build:v1",[40,975,976],{"class":127}," go",[40,978,646],{"class":127},[40,980,655],{"class":131},[40,982,983],{"class":127}," app_musl\n",[40,985,986],{"class":42,"line":95},[40,987,86],{"emptyLinePlaceholder":85},[40,989,990],{"class":42,"line":217},[40,991,992],{"class":46},"# 构建编译环境\n",[40,994,995,997,999,1001,1004,1006,1008,1011,1013,1016,1019,1021,1023],{"class":42,"line":225},[40,996,124],{"class":123},[40,998,128],{"class":127},[40,1000,132],{"class":131},[40,1002,1003],{"class":131}," -d",[40,1005,531],{"class":131},[40,1007,970],{"class":127},[40,1009,1010],{"class":131}," --name",[40,1012,138],{"class":53},[40,1014,1015],{"class":127},"容器名",[40,1017,1018],{"class":57},"称",[40,1020,147],{"class":53},[40,1022,973],{"class":127},[40,1024,1025],{"class":127}," \u002Fbin\u002Fbash\n",[40,1027,1028],{"class":42,"line":230},[40,1029,1030],{"class":46},"# eg：docker run -it -d -v .:\u002Fdata --name logbus-build go-build:v1 \u002Fbin\u002Fbash\n",[40,1032,1033],{"class":42,"line":237},[40,1034,86],{"emptyLinePlaceholder":85},[40,1036,1037,1039,1042,1044,1046,1048,1050,1052,1054,1056,1058,1060,1063,1065],{"class":42,"line":243},[40,1038,124],{"class":123},[40,1040,1041],{"class":127}," exec",[40,1043,132],{"class":131},[40,1045,138],{"class":53},[40,1047,1015],{"class":127},[40,1049,1018],{"class":57},[40,1051,147],{"class":53},[40,1053,976],{"class":127},[40,1055,646],{"class":127},[40,1057,655],{"class":131},[40,1059,138],{"class":53},[40,1061,1062],{"class":127},"myap",[40,1064,15],{"class":57},[40,1066,172],{"class":53},[40,1068,1069],{"class":42,"line":251},[40,1070,1071],{"class":46},"# eg：docker exec -it logbus-build go build -o logbus_musl_1\n",[22,1073,1074],{"id":1074},"额外注意",[15,1076,1077],{},"若没科学上网需要设置代理：",[30,1079,1081],{"className":114,"code":1080,"language":116,"meta":35,"style":35},"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",[37,1082,1083,1101,1111,1127],{"__ignoreMap":35},[40,1084,1085,1087,1089,1091,1093,1095,1097,1099],{"class":42,"line":43},[40,1086,124],{"class":123},[40,1088,1041],{"class":127},[40,1090,132],{"class":131},[40,1092,138],{"class":53},[40,1094,1015],{"class":127},[40,1096,1018],{"class":57},[40,1098,147],{"class":53},[40,1100,1025],{"class":127},[40,1102,1103,1105,1107,1109],{"class":42,"line":50},[40,1104,631],{"class":123},[40,1106,634],{"class":127},[40,1108,543],{"class":131},[40,1110,639],{"class":127},[40,1112,1113,1115,1117,1119,1121,1123,1125],{"class":42,"line":82},[40,1114,631],{"class":123},[40,1116,646],{"class":127},[40,1118,649],{"class":131},[40,1120,652],{"class":127},[40,1122,655],{"class":131},[40,1124,658],{"class":127},[40,1126,661],{"class":46},[40,1128,1129,1131,1133],{"class":42,"line":89},[40,1130,631],{"class":123},[40,1132,646],{"class":127},[40,1134,1135],{"class":46}," # musl 库编译\n",[22,1137,1138],{"id":1138},"运行环境示例",[30,1140,1142],{"className":32,"code":1141,"language":34,"meta":35,"style":35},"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",[37,1143,1144,1150,1154,1162,1166,1173,1177,1184,1191,1195,1202,1206,1213,1217,1237,1241,1246],{"__ignoreMap":35},[40,1145,1146,1148],{"class":42,"line":43},[40,1147,185],{"class":53},[40,1149,188],{"class":57},[40,1151,1152],{"class":42,"line":50},[40,1153,86],{"emptyLinePlaceholder":85},[40,1155,1156,1158,1160],{"class":42,"line":82},[40,1157,817],{"class":53},[40,1159,820],{"class":57},[40,1161,823],{"class":127},[40,1163,1164],{"class":42,"line":89},[40,1165,86],{"emptyLinePlaceholder":85},[40,1167,1168,1170],{"class":42,"line":95},[40,1169,305],{"class":53},[40,1171,1172],{"class":57}," glog_musl \u002Fapp\u002F\n",[40,1174,1175],{"class":42,"line":217},[40,1176,86],{"emptyLinePlaceholder":85},[40,1178,1179,1181],{"class":42,"line":225},[40,1180,305],{"class":53},[40,1182,1183],{"class":57}," conf.ini \u002Fdata\u002F\n",[40,1185,1186,1188],{"class":42,"line":230},[40,1187,305],{"class":53},[40,1189,1190],{"class":57}," ipname.ini \u002Fdata\u002F\n",[40,1192,1193],{"class":42,"line":237},[40,1194,86],{"emptyLinePlaceholder":85},[40,1196,1197,1199],{"class":42,"line":243},[40,1198,347],{"class":53},[40,1200,1201],{"class":57}," \u002Fapp\n",[40,1203,1204],{"class":42,"line":251},[40,1205,86],{"emptyLinePlaceholder":85},[40,1207,1208,1210],{"class":42,"line":257},[40,1209,54],{"class":53},[40,1211,1212],{"class":57}," chmod 777 \u002Fapp\u002Fglog_musl\n",[40,1214,1215],{"class":42,"line":265},[40,1216,86],{"emptyLinePlaceholder":85},[40,1218,1219,1221,1223,1226,1228,1230,1232,1235],{"class":42,"line":270},[40,1220,403],{"class":53},[40,1222,929],{"class":57},[40,1224,1225],{"class":127},"\".\u002Fglog_musl\"",[40,1227,411],{"class":57},[40,1229,414],{"class":127},[40,1231,411],{"class":57},[40,1233,1234],{"class":127},"\"\u002Fdata\u002Fconf.ini\"",[40,1236,935],{"class":57},[40,1238,1239],{"class":42,"line":279},[40,1240,86],{"emptyLinePlaceholder":85},[40,1242,1243],{"class":42,"line":284},[40,1244,1245],{"class":46},"# docker build -t glog:v1 .\n",[40,1247,1248],{"class":42,"line":289},[40,1249,1250],{"class":46},"# docker run -p 6801:6801 glog:v1\n",[464,1252,1253],{},"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":35,"searchDepth":50,"depth":82,"links":1255},[1256,1257,1260],{"id":501,"depth":50,"text":501},{"id":507,"depth":50,"text":507,"children":1258},[1259],{"id":730,"depth":82,"text":730},{"id":775,"depth":50,"text":775,"children":1261},[1262,1263,1264],{"id":938,"depth":82,"text":938},{"id":1074,"depth":82,"text":1074},{"id":1138,"depth":82,"text":1138},"解决 Go 语言跨平台编译问题，使用 Docker + musl 编译静态链接的可执行文件。","进阶","\u002Fposts\u002Fdocker-musl-build\u002Fimg\u002Fcover.svg",{},"\u002Fposts\u002Fdocker-musl-build","2025-03-27",{"title":496,"description":1265},"posts\u002Fdocker-musl-build\u002Findex",[489,1274,490],"Go","YLtdOOc-gMVDSNjE-ntKXz-7hff-VtvMB2dB5RXNi1o",{"id":4,"title":5,"author":6,"body":1277,"description":477,"difficulty":478,"draft":479,"extension":480,"image":481,"meta":1603,"navigation":85,"path":483,"pinned":479,"published":484,"seo":1604,"series":486,"seriesOrder":50,"stem":487,"tags":1605,"updated":484,"__hash__":492},{"type":8,"value":1278,"toc":1593},[1279,1281,1283,1285,1287,1289,1303,1305,1333,1335,1339,1379,1381,1565,1567,1569,1589,1591],[11,1280,13],{"id":13},[15,1282,17],{},[11,1284,20],{"id":20},[22,1286,25],{"id":24},[15,1288,28],{},[30,1290,1291],{"className":32,"code":33,"language":34,"meta":35,"style":35},[37,1292,1293,1297],{"__ignoreMap":35},[40,1294,1295],{"class":42,"line":43},[40,1296,47],{"class":46},[40,1298,1299,1301],{"class":42,"line":50},[40,1300,54],{"class":53},[40,1302,58],{"class":57},[22,1304,62],{"id":61},[30,1306,1307],{"className":32,"code":65,"language":34,"meta":35,"style":35},[37,1308,1309,1313,1319,1323,1327],{"__ignoreMap":35},[40,1310,1311],{"class":42,"line":43},[40,1312,72],{"class":46},[40,1314,1315,1317],{"class":42,"line":50},[40,1316,54],{"class":53},[40,1318,79],{"class":57},[40,1320,1321],{"class":42,"line":82},[40,1322,86],{"emptyLinePlaceholder":85},[40,1324,1325],{"class":42,"line":89},[40,1326,92],{"class":46},[40,1328,1329,1331],{"class":42,"line":95},[40,1330,54],{"class":53},[40,1332,100],{"class":57},[22,1334,104],{"id":103},[15,1336,107,1337,111],{},[37,1338,110],{},[30,1340,1341],{"className":114,"code":115,"language":116,"meta":35,"style":35},[37,1342,1343],{"__ignoreMap":35},[40,1344,1345,1347,1349,1351,1353,1355,1357,1359,1361,1363,1365,1367,1369,1371,1373,1375,1377],{"class":42,"line":43},[40,1346,124],{"class":123},[40,1348,128],{"class":127},[40,1350,132],{"class":131},[40,1352,135],{"class":131},[40,1354,138],{"class":53},[40,1356,141],{"class":127},[40,1358,144],{"class":57},[40,1360,147],{"class":53},[40,1362,138],{"class":53},[40,1364,152],{"class":127},[40,1366,155],{"class":57},[40,1368,147],{"class":53},[40,1370,160],{"class":127},[40,1372,163],{"class":53},[40,1374,166],{"class":127},[40,1376,169],{"class":57},[40,1378,172],{"class":53},[11,1380,175],{"id":175},[30,1382,1383],{"className":32,"code":178,"language":34,"meta":35,"style":35},[37,1384,1385,1391,1395,1399,1409,1413,1419,1423,1429,1433,1439,1443,1449,1453,1459,1463,1467,1473,1477,1483,1487,1493,1497,1507,1513,1517,1521,1527,1533,1537,1541,1547],{"__ignoreMap":35},[40,1386,1387,1389],{"class":42,"line":43},[40,1388,185],{"class":53},[40,1390,188],{"class":57},[40,1392,1393],{"class":42,"line":50},[40,1394,86],{"emptyLinePlaceholder":85},[40,1396,1397],{"class":42,"line":82},[40,1398,197],{"class":46},[40,1400,1401,1403,1405,1407],{"class":42,"line":89},[40,1402,54],{"class":53},[40,1404,204],{"class":57},[40,1406,207],{"class":127},[40,1408,210],{"class":57},[40,1410,1411],{"class":42,"line":95},[40,1412,86],{"emptyLinePlaceholder":85},[40,1414,1415,1417],{"class":42,"line":217},[40,1416,54],{"class":53},[40,1418,222],{"class":57},[40,1420,1421],{"class":42,"line":225},[40,1422,47],{"class":46},[40,1424,1425,1427],{"class":42,"line":230},[40,1426,54],{"class":53},[40,1428,58],{"class":57},[40,1430,1431],{"class":42,"line":237},[40,1432,240],{"class":46},[40,1434,1435,1437],{"class":42,"line":243},[40,1436,54],{"class":53},[40,1438,248],{"class":57},[40,1440,1441],{"class":42,"line":251},[40,1442,254],{"class":46},[40,1444,1445,1447],{"class":42,"line":257},[40,1446,54],{"class":53},[40,1448,262],{"class":57},[40,1450,1451],{"class":42,"line":265},[40,1452,86],{"emptyLinePlaceholder":85},[40,1454,1455,1457],{"class":42,"line":270},[40,1456,273],{"class":53},[40,1458,276],{"class":57},[40,1460,1461],{"class":42,"line":279},[40,1462,86],{"emptyLinePlaceholder":85},[40,1464,1465],{"class":42,"line":284},[40,1466,72],{"class":46},[40,1468,1469,1471],{"class":42,"line":289},[40,1470,54],{"class":53},[40,1472,294],{"class":57},[40,1474,1475],{"class":42,"line":297},[40,1476,86],{"emptyLinePlaceholder":85},[40,1478,1479,1481],{"class":42,"line":302},[40,1480,305],{"class":53},[40,1482,308],{"class":57},[40,1484,1485],{"class":42,"line":311},[40,1486,86],{"emptyLinePlaceholder":85},[40,1488,1489,1491],{"class":42,"line":316},[40,1490,54],{"class":53},[40,1492,321],{"class":57},[40,1494,1495],{"class":42,"line":324},[40,1496,86],{"emptyLinePlaceholder":85},[40,1498,1499,1501,1503,1505],{"class":42,"line":329},[40,1500,332],{"class":53},[40,1502,335],{"class":57},[40,1504,338],{"class":127},[40,1506,341],{"class":57},[40,1508,1509,1511],{"class":42,"line":344},[40,1510,347],{"class":53},[40,1512,350],{"class":57},[40,1514,1515],{"class":42,"line":353},[40,1516,86],{"emptyLinePlaceholder":85},[40,1518,1519],{"class":42,"line":358},[40,1520,361],{"class":46},[40,1522,1523,1525],{"class":42,"line":364},[40,1524,54],{"class":53},[40,1526,369],{"class":57},[40,1528,1529,1531],{"class":42,"line":372},[40,1530,54],{"class":53},[40,1532,377],{"class":57},[40,1534,1535],{"class":42,"line":380},[40,1536,86],{"emptyLinePlaceholder":85},[40,1538,1539],{"class":42,"line":385},[40,1540,388],{"class":46},[40,1542,1543,1545],{"class":42,"line":391},[40,1544,394],{"class":53},[40,1546,397],{"class":57},[40,1548,1549,1551,1553,1555,1557,1559,1561,1563],{"class":42,"line":400},[40,1550,403],{"class":53},[40,1552,335],{"class":57},[40,1554,408],{"class":127},[40,1556,411],{"class":57},[40,1558,414],{"class":127},[40,1560,411],{"class":57},[40,1562,419],{"class":127},[40,1564,341],{"class":57},[11,1566,424],{"id":424},[15,1568,427],{},[429,1570,1571,1575,1581,1585],{},[432,1572,1573,437],{},[37,1574,436],{},[432,1576,1577,443,1579,447],{},[37,1578,442],{},[37,1580,446],{},[432,1582,1583,453],{},[37,1584,452],{},[432,1586,1587,459],{},[37,1588,458],{},[15,1590,462],{},[464,1592,466],{},{"title":35,"searchDepth":50,"depth":82,"links":1594},[1595,1596,1601,1602],{"id":13,"depth":50,"text":13},{"id":20,"depth":50,"text":20,"children":1597},[1598,1599,1600],{"id":24,"depth":82,"text":25},{"id":61,"depth":82,"text":62},{"id":103,"depth":82,"text":104},{"id":175,"depth":50,"text":175},{"id":424,"depth":50,"text":424},{},{"title":5,"description":477},[489,490,491],{"id":1607,"title":1608,"author":6,"body":1609,"description":1827,"difficulty":6,"draft":479,"extension":480,"image":1828,"meta":1829,"navigation":85,"path":1830,"pinned":479,"published":1831,"seo":1832,"series":6,"seriesOrder":6,"stem":1833,"tags":1834,"updated":6,"__hash__":1836},"posts\u002Fposts\u002Fsveltekit-intro\u002Findex.md","SvelteKit 入门：为什么它比你想象的更简单",{"type":8,"value":1610,"toc":1817},[1611,1615,1618,1624,1627,1631,1639,1691,1695,1701,1709,1713,1716,1719,1795,1797,1808,1814],[11,1612,1614],{"id":1613},"什么是-sveltekit","什么是 SvelteKit？",[15,1616,1617],{},"SvelteKit 是 Svelte 的官方全栈框架，类似于 React 之于 Next.js 的关系。它提供了路由、SSR、SSG 等能力，让开发者可以快速构建现代 Web 应用。",[15,1619,1620],{},[755,1621],{"alt":1622,"src":1623},"SvelteKit Logo","https:\u002F\u002Fraw.githubusercontent.com\u002Fsveltejs\u002Fbranding\u002Fmaster\u002Fsvelte-logo.svg",[11,1625,1626],{"id":1626},"核心特点",[22,1628,1630],{"id":1629},"_1-编译时框架","1. 编译时框架",[15,1632,1633,1634,1638],{},"Svelte 和 React\u002FVue 最大的区别在于：",[1635,1636,1637],"strong",{},"它在编译阶段就把组件转成原生 JS","，运行时没有虚拟 DOM。",[30,1640,1644],{"className":1641,"code":1642,"language":1643,"meta":35,"style":35},"language-typescript shiki shiki-themes github-light github-dark","\u002F\u002F Svelte 组件（编译后变成直接的 DOM 操作）\nlet count = 0\nfunction increment() {\n  count += 1\n}\n","typescript",[37,1645,1646,1651,1664,1675,1686],{"__ignoreMap":35},[40,1647,1648],{"class":42,"line":43},[40,1649,1650],{"class":46},"\u002F\u002F Svelte 组件（编译后变成直接的 DOM 操作）\n",[40,1652,1653,1656,1659,1661],{"class":42,"line":50},[40,1654,1655],{"class":53},"let",[40,1657,1658],{"class":57}," count ",[40,1660,600],{"class":53},[40,1662,1663],{"class":131}," 0\n",[40,1665,1666,1669,1672],{"class":42,"line":82},[40,1667,1668],{"class":53},"function",[40,1670,1671],{"class":123}," increment",[40,1673,1674],{"class":57},"() {\n",[40,1676,1677,1680,1683],{"class":42,"line":89},[40,1678,1679],{"class":57},"  count ",[40,1681,1682],{"class":53},"+=",[40,1684,1685],{"class":131}," 1\n",[40,1687,1688],{"class":42,"line":95},[40,1689,1690],{"class":57},"}\n",[22,1692,1694],{"id":1693},"_2-文件路由","2. 文件路由",[15,1696,1697,1700],{},[37,1698,1699],{},"src\u002Froutes\u002F"," 目录结构即 URL，不需要手动配置路由表：",[30,1702,1707],{"className":1703,"code":1705,"language":1706},[1704],"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",[37,1708,1705],{"__ignoreMap":35},[22,1710,1712],{"id":1711},"_3-全栈能力","3. 全栈能力",[15,1714,1715],{},"SvelteKit 内置了服务端渲染（SSR）、静态站点生成（SSG）、API 路由等功能，开箱即用。",[11,1717,1718],{"id":1718},"和其他框架对比",[1720,1721,1722,1741],"table",{},[1723,1724,1725],"thead",{},[1726,1727,1728,1732,1735,1738],"tr",{},[1729,1730,1731],"th",{},"特性",[1729,1733,1734],{},"SvelteKit",[1729,1736,1737],{},"Next.js",[1729,1739,1740],{},"Nuxt",[1742,1743,1744,1758,1771,1784],"tbody",{},[1726,1745,1746,1750,1753,1756],{},[1747,1748,1749],"td",{},"学习曲线",[1747,1751,1752],{},"低",[1747,1754,1755],{},"中",[1747,1757,1755],{},[1726,1759,1760,1763,1766,1769],{},[1747,1761,1762],{},"包体积",[1747,1764,1765],{},"小",[1747,1767,1768],{},"大",[1747,1770,1755],{},[1726,1772,1773,1776,1779,1782],{},[1747,1774,1775],{},"运行时开销",[1747,1777,1778],{},"无",[1747,1780,1781],{},"有",[1747,1783,1781],{},[1726,1785,1786,1789,1791,1793],{},[1747,1787,1788],{},"生态",[1747,1790,1765],{},[1747,1792,1768],{},[1747,1794,1768],{},[11,1796,424],{"id":424},[15,1798,1799,1800,1803,1804,1807],{},"如果你追求",[1635,1801,1802],{},"简洁的代码","和",[1635,1805,1806],{},"极致的性能","，SvelteKit 是一个非常值得尝试的选择。它的学习曲线平缓，写起来就像在写原生 HTML\u002FCSS\u002FJS。",[15,1809,1810],{},[755,1811],{"alt":1812,"src":1813},"Svelte 编译过程","https:\u002F\u002Fsvelte.dev\u002Fsvelte-logo-horizontal.svg",[464,1815,1816],{},"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":35,"searchDepth":50,"depth":82,"links":1818},[1819,1820,1825,1826],{"id":1613,"depth":50,"text":1614},{"id":1626,"depth":50,"text":1626,"children":1821},[1822,1823,1824],{"id":1629,"depth":82,"text":1630},{"id":1693,"depth":82,"text":1694},{"id":1711,"depth":82,"text":1712},{"id":1718,"depth":50,"text":1718},{"id":424,"depth":50,"text":424},"SvelteKit 是一个全栈 Svelte 框架，这篇文章带你了解它的核心概念和优势。","\u002Fposts\u002Fsveltekit-intro\u002Fimg\u002Fcover.jpg",{},"\u002Fposts\u002Fsveltekit-intro","2026-05-30",{"title":1608,"description":1827},"posts\u002Fsveltekit-intro\u002Findex",[1734,1835],"前端框架","vsEGyunpCWR7DiJAdyV1SPDfa57xXLITBI-lKM8UeGQ",{"id":1838,"title":1839,"author":6,"body":1840,"description":2243,"difficulty":478,"draft":479,"extension":480,"image":2244,"meta":2245,"navigation":85,"path":2246,"pinned":85,"published":2247,"seo":2248,"series":2249,"seriesOrder":43,"stem":2250,"tags":2251,"updated":2253,"__hash__":2254},"posts\u002Fposts\u002Fhello-world\u002Findex.md","Hello World - 欢迎使用 SuBlog",{"type":8,"value":1841,"toc":2234},[1842,1846,1853,1856,1859,1862,1936,1939,1942,2005,2008,2011,2077,2080,2146,2149,2152,2168,2175,2178,2209,2212,2223,2226,2231],[11,1843,1845],{"id":1844},"博客上线啦","博客上线啦！",[15,1847,1848,1849,1852],{},"Hello World！欢迎使用 ",[1635,1850,1851],{},"SuBlog","，这是站点的第一篇示例文章。",[15,1854,1855],{},"经过一段时间的折腾，这个基于 Nuxt 3 的个人博客终于上线了。在这里记录一下这个项目的技术选型和功能特性。",[11,1857,1858],{"id":1858},"技术栈",[15,1860,1861],{},"本站使用了以下技术：",[1720,1863,1864,1874],{},[1723,1865,1866],{},[1726,1867,1868,1871],{},[1729,1869,1870],{},"技术",[1729,1872,1873],{},"用途",[1742,1875,1876,1886,1896,1906,1916,1926],{},[1726,1877,1878,1883],{},[1747,1879,1880],{},[1635,1881,1882],{},"Nuxt 3",[1747,1884,1885],{},"Vue 3 全栈框架，提供 SSG、路由、API 等能力",[1726,1887,1888,1893],{},[1747,1889,1890],{},[1635,1891,1892],{},"TypeScript",[1747,1894,1895],{},"类型安全，提升开发体验",[1726,1897,1898,1903],{},[1747,1899,1900],{},[1635,1901,1902],{},"Tailwind CSS v4",[1747,1904,1905],{},"原子化 CSS，快速构建 UI",[1726,1907,1908,1913],{},[1747,1909,1910],{},[1635,1911,1912],{},"@nuxt\u002Fcontent",[1747,1914,1915],{},"Markdown 驱动的内容管理",[1726,1917,1918,1923],{},[1747,1919,1920],{},[1635,1921,1922],{},"Shiki",[1747,1924,1925],{},"代码语法高亮",[1726,1927,1928,1933],{},[1747,1929,1930],{},[1635,1931,1932],{},"Giscus",[1747,1934,1935],{},"基于 GitHub Discussions 的评论系统",[11,1937,1938],{"id":1938},"功能特性",[15,1940,1941],{},"目前已实现的功能：",[1943,1944,1945,1951,1957,1963,1969,1975,1981,1987,1993,1999],"ul",{},[432,1946,1947,1950],{},[1635,1948,1949],{},"博客系统"," - Markdown 文章，支持代码高亮、目录导航、搜索",[432,1952,1953,1956],{},[1635,1954,1955],{},"暗色模式"," - 跟随系统或手动切换",[432,1958,1959,1962],{},[1635,1960,1961],{},"文章搜索"," - 支持标题、描述、正文、标签多维度搜索",[432,1964,1965,1968],{},[1635,1966,1967],{},"标签系统"," - 文章标签分类，高频标签快速筛选",[432,1970,1971,1974],{},[1635,1972,1973],{},"友链页面"," - 展示友情链接，支持申请",[432,1976,1977,1980],{},[1635,1978,1979],{},"赞助页面"," - 微信收款码展示",[432,1982,1983,1986],{},[1635,1984,1985],{},"封面制作"," - 在线生成封面图片工具",[432,1988,1989,1992],{},[1635,1990,1991],{},"网易云热评"," - 首页随机展示音乐评论",[432,1994,1995,1998],{},[1635,1996,1997],{},"访客统计"," - 不蒜子计数器，带数字滚动动画",[432,2000,2001,2004],{},[1635,2002,2003],{},"SEO 优化"," - 自动生成站点地图、Open Graph 标签",[11,2006,2007],{"id":2007},"代码示例",[15,2009,2010],{},"本站的配置非常集中，一个文件搞定全站定制：",[30,2012,2014],{"className":1641,"code":2013,"language":1643,"meta":35,"style":35},"\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",[37,2015,2016,2021,2037,2048,2058,2068,2073],{"__ignoreMap":35},[40,2017,2018],{"class":42,"line":43},[40,2019,2020],{"class":46},"\u002F\u002F config\u002Fsite.ts\n",[40,2022,2023,2025,2028,2031,2034],{"class":42,"line":50},[40,2024,594],{"class":53},[40,2026,2027],{"class":53}," const",[40,2029,2030],{"class":131}," siteConfig",[40,2032,2033],{"class":53}," =",[40,2035,2036],{"class":57}," {\n",[40,2038,2039,2042,2045],{"class":42,"line":82},[40,2040,2041],{"class":57},"  siteName: ",[40,2043,2044],{"class":127},"'我的博客'",[40,2046,2047],{"class":57},",\n",[40,2049,2050,2053,2056],{"class":42,"line":89},[40,2051,2052],{"class":57},"  url: ",[40,2054,2055],{"class":127},"'https:\u002F\u002Fexample.com\u002F'",[40,2057,2047],{"class":57},[40,2059,2060,2063,2066],{"class":42,"line":95},[40,2061,2062],{"class":57},"  avatar: ",[40,2064,2065],{"class":127},"'https:\u002F\u002Fq2.qlogo.cn\u002Fheadimg_dl?dst_uin=xxx&spec=100'",[40,2067,2047],{"class":57},[40,2069,2070],{"class":42,"line":217},[40,2071,2072],{"class":46},"  \u002F\u002F ... 改这一个文件，全站生效\n",[40,2074,2075],{"class":42,"line":225},[40,2076,1690],{"class":57},[15,2078,2079],{},"文章的 frontmatter 也很简洁：",[30,2081,2085],{"className":2082,"code":2083,"language":2084,"meta":35,"style":35},"language-yaml shiki shiki-themes github-light github-dark","---\ntitle: 文章标题\npublished: 2026-05-31T08:00:00\ndescription: 文章简介\ntags: ['Nuxt', 'TypeScript']\n---\n","yaml",[37,2086,2087,2092,2104,2114,2124,2142],{"__ignoreMap":35},[40,2088,2089],{"class":42,"line":43},[40,2090,2091],{"class":123},"---\n",[40,2093,2094,2098,2101],{"class":42,"line":50},[40,2095,2097],{"class":2096},"sbB4o","title",[40,2099,2100],{"class":57},": ",[40,2102,2103],{"class":127},"文章标题\n",[40,2105,2106,2109,2111],{"class":42,"line":82},[40,2107,2108],{"class":2096},"published",[40,2110,2100],{"class":57},[40,2112,2113],{"class":131},"2026-05-31T08:00:00\n",[40,2115,2116,2119,2121],{"class":42,"line":89},[40,2117,2118],{"class":2096},"description",[40,2120,2100],{"class":57},[40,2122,2123],{"class":127},"文章简介\n",[40,2125,2126,2129,2132,2135,2137,2140],{"class":42,"line":95},[40,2127,2128],{"class":2096},"tags",[40,2130,2131],{"class":57},": [",[40,2133,2134],{"class":127},"'Nuxt'",[40,2136,411],{"class":57},[40,2138,2139],{"class":127},"'TypeScript'",[40,2141,935],{"class":57},[40,2143,2144],{"class":42,"line":217},[40,2145,2091],{"class":123},[11,2147,2148],{"id":2148},"部署",[15,2150,2151],{},"本站使用 SSG 静态生成，构建后直接部署到静态托管即可：",[30,2153,2155],{"className":114,"code":2154,"language":116,"meta":35,"style":35},"npx nuxi generate\n",[37,2156,2157],{"__ignoreMap":35},[40,2158,2159,2162,2165],{"class":42,"line":43},[40,2160,2161],{"class":123},"npx",[40,2163,2164],{"class":127}," nuxi",[40,2166,2167],{"class":127}," generate\n",[15,2169,2170,2171,2174],{},"构建产物在 ",[37,2172,2173],{},".output\u002Fpublic"," 目录下，可以直接丢到 Nginx、Vercel、Netlify 等平台。",[11,2176,2177],{"id":2177},"后续计划",[1943,2179,2182,2191,2197,2203],{"className":2180},[2181],"contains-task-list",[432,2183,2186,2190],{"className":2184},[2185],"task-list-item",[2187,2188],"input",{"disabled":85,"type":2189},"checkbox"," RSS 订阅",[432,2192,2194,2196],{"className":2193},[2185],[2187,2195],{"disabled":85,"type":2189}," 文章目录优化",[432,2198,2200,2202],{"className":2199},[2185],[2187,2201],{"disabled":85,"type":2189}," 更多小工具",[432,2204,2206,2208],{"className":2205},[2185],[2187,2207],{"disabled":85,"type":2189}," 持续输出内容",[11,2210,2211],{"id":2211},"致谢",[15,2213,2214,2215,2222],{},"感谢 ",[2216,2217,2221],"a",{"href":2218,"rel":2219},"https:\u002F\u002F2x.nz",[2220],"nofollow","二叉树树"," 的开源项目，本站的 UI 设计参考了他的项目。",[2224,2225],"hr",{},[15,2227,2228],{},[1635,2229,2230],{},"感谢阅读！如果觉得不错，欢迎点个 Star 或者留个评论 (☞ﾟヮﾟ)☞",[464,2232,2233],{},"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":35,"searchDepth":50,"depth":82,"links":2235},[2236,2237,2238,2239,2240,2241,2242],{"id":1844,"depth":50,"text":1845},{"id":1858,"depth":50,"text":1858},{"id":1938,"depth":50,"text":1938},{"id":2007,"depth":50,"text":2007},{"id":2148,"depth":50,"text":2148},{"id":2177,"depth":50,"text":2177},{"id":2211,"depth":50,"text":2211},"博客上线啦！这篇文章介绍本站的技术架构、功能特性和搭建过程中的一些思考。","\u002Fposts\u002Fhello-world\u002Fimg\u002Fcover.jpg",{},"\u002Fposts\u002Fhello-world","2026-05-31T08:00:00",{"title":1839,"description":2243},"Nuxt 博客开发","posts\u002Fhello-world\u002Findex",[2252,1740],"博客","2026-05-31","f9MCboCBfQIUxT8aKv6aKGDFxysiG8ZG0YXr5X5LTFo",{"id":2256,"title":2257,"author":6,"body":2258,"description":3435,"difficulty":1266,"draft":479,"extension":480,"image":3436,"meta":3437,"navigation":85,"path":3438,"pinned":479,"published":3439,"seo":3440,"series":2249,"seriesOrder":50,"stem":3441,"tags":3442,"updated":2253,"__hash__":3445},"posts\u002Fposts\u002Fnuxt-content-lessons\u002Findex.md","Nuxt Content v3 踩坑记录：目录、图片与那些反直觉的设计",{"type":8,"value":2259,"toc":3425},[2260,2264,2267,2270,2274,2277,2288,2291,2298,2366,2373,2375,2382,2661,2671,2675,2678,2681,2707,2721,2724,2747,2753,2756,2759,2762,2767,2774,3001,3006,3050,3054,3057,3064,3067,3117,3123,3352,3362,3364,3416,3419,3422],[2261,2262,2263],"h1",{"id":2263},"背景",[15,2265,2266],{},"最近在用 Nuxt 3 + Nuxt Content v3 搭建一个个人博客，参考了一个 SvelteKit 项目（svaf）的架构。原以为换个框架只是语法差异，没想到在几个关键功能上踩了不少坑。",[15,2268,2269],{},"这篇文章记录了三个核心问题的排查过程和最终方案。",[2261,2271,2273],{"id":2272},"问题一目录导航toc只有-h2","问题一：目录导航（TOC）只有 h2",[11,2275,2276],{"id":2276},"现象",[15,2278,2279,2280,2283,2284,2287],{},"Nuxt Content 内置了 TOC 数据，挂在 ",[37,2281,2282],{},"post.body.toc"," 上。直接用它渲染目录，发现",[1635,2285,2286],{},"只有 h2 标题，h3 全部丢失","。",[11,2289,2290],{"id":2290},"排查",[15,2292,2293,2294,2297],{},"查了官方文档，发现 ",[37,2295,2296],{},"build.markdown.toc.depth"," 可以控制深度：",[30,2299,2301],{"className":1641,"code":2300,"language":1643,"meta":35,"style":35},"\u002F\u002F nuxt.config.ts\ncontent: {\n  build: {\n    markdown: {\n      toc: { depth: 3 }  \u002F\u002F 包含 h3\n    }\n  }\n}\n",[37,2302,2303,2308,2316,2323,2330,2352,2357,2362],{"__ignoreMap":35},[40,2304,2305],{"class":42,"line":43},[40,2306,2307],{"class":46},"\u002F\u002F nuxt.config.ts\n",[40,2309,2310,2313],{"class":42,"line":50},[40,2311,2312],{"class":123},"content",[40,2314,2315],{"class":57},": {\n",[40,2317,2318,2321],{"class":42,"line":82},[40,2319,2320],{"class":123},"  build",[40,2322,2315],{"class":57},[40,2324,2325,2328],{"class":42,"line":89},[40,2326,2327],{"class":123},"    markdown",[40,2329,2315],{"class":57},[40,2331,2332,2335,2338,2341,2343,2346,2349],{"class":42,"line":95},[40,2333,2334],{"class":123},"      toc",[40,2336,2337],{"class":57},": { ",[40,2339,2340],{"class":123},"depth",[40,2342,2100],{"class":57},[40,2344,2345],{"class":131},"3",[40,2347,2348],{"class":57}," }  ",[40,2350,2351],{"class":46},"\u002F\u002F 包含 h3\n",[40,2353,2354],{"class":42,"line":217},[40,2355,2356],{"class":57},"    }\n",[40,2358,2359],{"class":42,"line":225},[40,2360,2361],{"class":57},"  }\n",[40,2363,2364],{"class":42,"line":230},[40,2365,1690],{"class":57},[15,2367,2368,2369,2372],{},"配置改了，清了缓存重启，",[1635,2370,2371],{},"依然只有 h2","。这个配置似乎不起作用。",[11,2374,775],{"id":775},[15,2376,2377,2378,2381],{},"放弃依赖 Nuxt Content 的 toc，直接从 ",[37,2379,2380],{},"post.body"," 的 AST 里递归提取所有标题：",[30,2383,2385],{"className":1641,"code":2384,"language":1643,"meta":35,"style":35},"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",[37,2386,2387,2431,2451,2464,2469,2530,2560,2592,2617,2621,2646,2650,2657],{"__ignoreMap":35},[40,2388,2389,2391,2394,2397,2401,2403,2406,2408,2411,2413,2416,2419,2421,2424,2426,2428],{"class":42,"line":43},[40,2390,1668],{"class":53},[40,2392,2393],{"class":123}," extractHeadings",[40,2395,2396],{"class":57},"(",[40,2398,2400],{"class":2399},"sPK6S","node",[40,2402,160],{"class":53},[40,2404,2405],{"class":131}," any",[40,2407,411],{"class":57},[40,2409,2410],{"class":2399},"list",[40,2412,160],{"class":53},[40,2414,2415],{"class":123}," Heading",[40,2417,2418],{"class":57},"[] ",[40,2420,600],{"class":53},[40,2422,2423],{"class":57}," [])",[40,2425,160],{"class":53},[40,2427,2415],{"class":123},[40,2429,2430],{"class":57},"[] {\n",[40,2432,2433,2436,2439,2442,2445,2448],{"class":42,"line":50},[40,2434,2435],{"class":53},"  if",[40,2437,2438],{"class":57}," (",[40,2440,2441],{"class":53},"!",[40,2443,2444],{"class":57},"node) ",[40,2446,2447],{"class":53},"return",[40,2449,2450],{"class":57}," list\n",[40,2452,2453,2455,2458,2461],{"class":42,"line":82},[40,2454,2435],{"class":53},[40,2456,2457],{"class":57}," (Array.",[40,2459,2460],{"class":123},"isArray",[40,2462,2463],{"class":57},"(node)) {\n",[40,2465,2466],{"class":42,"line":89},[40,2467,2468],{"class":46},"    \u002F\u002F minimark 格式：[\"h2\", {id: \"xxx\"}, \"文本\"]\n",[40,2470,2471,2474,2476,2479,2482,2485,2488,2491,2494,2497,2500,2503,2507,2510,2513,2516,2519,2522,2525,2527],{"class":42,"line":95},[40,2472,2473],{"class":53},"    if",[40,2475,2438],{"class":57},[40,2477,2478],{"class":53},"typeof",[40,2480,2481],{"class":57}," node[",[40,2483,2484],{"class":131},"0",[40,2486,2487],{"class":57},"] ",[40,2489,2490],{"class":53},"===",[40,2492,2493],{"class":127}," 'string'",[40,2495,2496],{"class":53}," &&",[40,2498,2499],{"class":127}," \u002F",[40,2501,2502],{"class":53},"^",[40,2504,2506],{"class":2505},"sPKmS","h",[40,2508,2509],{"class":131},"[1-6]",[40,2511,2512],{"class":53},"$",[40,2514,2515],{"class":127},"\u002F",[40,2517,2518],{"class":57},".",[40,2520,2521],{"class":123},"test",[40,2523,2524],{"class":57},"(node[",[40,2526,2484],{"class":131},[40,2528,2529],{"class":57},"])) {\n",[40,2531,2532,2535,2538,2540,2543,2545,2547,2550,2553,2555,2557],{"class":42,"line":217},[40,2533,2534],{"class":53},"      const",[40,2536,2537],{"class":131}," level",[40,2539,2033],{"class":53},[40,2541,2542],{"class":123}," Number",[40,2544,2524],{"class":57},[40,2546,2484],{"class":131},[40,2548,2549],{"class":57},"].",[40,2551,2552],{"class":123},"charAt",[40,2554,2396],{"class":57},[40,2556,618],{"class":131},[40,2558,2559],{"class":57},"))\n",[40,2561,2562,2564,2567,2569,2572,2575,2578,2580,2583,2586,2589],{"class":42,"line":225},[40,2563,2534],{"class":53},[40,2565,2566],{"class":131}," text",[40,2568,2033],{"class":53},[40,2570,2571],{"class":123}," extractText",[40,2573,2574],{"class":57},"(node.",[40,2576,2577],{"class":123},"slice",[40,2579,2396],{"class":57},[40,2581,2582],{"class":131},"2",[40,2584,2585],{"class":57},")).",[40,2587,2588],{"class":123},"trim",[40,2590,2591],{"class":57},"()\n",[40,2593,2594,2597,2600,2603,2605,2608,2611,2614],{"class":42,"line":230},[40,2595,2596],{"class":57},"      list.",[40,2598,2599],{"class":123},"push",[40,2601,2602],{"class":57},"({ id: node[",[40,2604,618],{"class":131},[40,2606,2607],{"class":57},"]?.id ",[40,2609,2610],{"class":53},"||",[40,2612,2613],{"class":123}," slugify",[40,2615,2616],{"class":57},"(text), text, level })\n",[40,2618,2619],{"class":42,"line":237},[40,2620,2356],{"class":57},[40,2622,2623,2626,2628,2631,2634,2637,2640,2643],{"class":42,"line":243},[40,2624,2625],{"class":53},"    for",[40,2627,2438],{"class":57},[40,2629,2630],{"class":53},"const",[40,2632,2633],{"class":131}," child",[40,2635,2636],{"class":53}," of",[40,2638,2639],{"class":57}," node) ",[40,2641,2642],{"class":123},"extractHeadings",[40,2644,2645],{"class":57},"(child, list)\n",[40,2647,2648],{"class":42,"line":251},[40,2649,2361],{"class":57},[40,2651,2652,2655],{"class":42,"line":257},[40,2653,2654],{"class":53},"  return",[40,2656,2450],{"class":57},[40,2658,2659],{"class":42,"line":265},[40,2660,1690],{"class":57},[15,2662,2663,2664,2666,2667,2670],{},"关键点：Nuxt Content v3 的 body 使用 ",[1635,2665,8],{}," 格式，标题节点是数组 ",[37,2668,2669],{},"[\"h2\", {id: \"xxx\"}, \"标题文本\"]","，不是对象。",[2261,2672,2674],{"id":2673},"问题二图片路径解析错误","问题二：图片路径解析错误",[11,2676,2276],{"id":2677},"现象-1",[15,2679,2680],{},"markdown 里用相对路径引用图片：",[30,2682,2686],{"className":2683,"code":2684,"language":2685,"meta":35,"style":35},"language-markdown shiki shiki-themes github-light github-dark","![SSHFS挂载效果](img\u002Fsshfs.avif)\n","markdown",[37,2687,2688],{"__ignoreMap":35},[40,2689,2690,2693,2697,2700,2704],{"class":42,"line":43},[40,2691,2692],{"class":57},"![",[40,2694,2696],{"class":2695},"smiIW","SSHFS挂载效果",[40,2698,2699],{"class":57},"](",[40,2701,2703],{"class":2702},"szbl0","img\u002Fsshfs.avif",[40,2705,2706],{"class":57},")\n",[15,2708,2709,2710,2713,2714,2717,2718,2287],{},"HTML 输出的 ",[37,2711,2712],{},"\u003Cimg src=\"img\u002Fsshfs.avif\">"," 看起来没问题，但浏览器请求的却是 ",[37,2715,2716],{},"\u002Fposts\u002Fimg\u002Fsshfs.avif"," 而不是 ",[37,2719,2720],{},"\u002Fposts\u002Fsshfs\u002Fimg\u002Fsshfs.avif",[11,2722,2290],{"id":2723},"排查-1",[429,2725,2726,2733,2736,2742],{},[432,2727,2728,2729,2732],{},"检查了 ",[37,2730,2731],{},"\u003Cbase>"," 标签 —— 没有",[432,2734,2735],{},"检查了重定向 —— 没有",[432,2737,2738,2739,2741],{},"直接访问 ",[37,2740,2720],{}," —— 返回 200 OK",[432,2743,2738,2744,2746],{},[37,2745,2716],{}," —— 404",[15,2748,2749,2750,2287],{},"服务器路由没问题，问题是",[1635,2751,2752],{},"浏览器解析相对路径时基于了错误的基准 URL",[15,2754,2755],{},"这可能和 Nuxt 的客户端路由有关：SPA 导航时，浏览器的\"当前页面\"和 Vue Router 的\"当前路由\"不同步，导致相对路径解析出错。",[11,2757,775],{"id":2758},"最终方案-1",[15,2760,2761],{},"两步解决：",[15,2763,2764],{},[1635,2765,2766],{},"第一步：自定义 ProseImg 组件",[15,2768,2769,2770,2773],{},"创建 ",[37,2771,2772],{},"components\u002Fcontent\u002FProseImg.vue","，覆盖 Nuxt Content 默认的图片渲染：",[30,2775,2779],{"className":2776,"code":2777,"language":2778,"meta":35,"style":35},"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",[37,2780,2781,2793,2807,2827,2843,2881,2916,2940,2945,2954,2958,2967,2993],{"__ignoreMap":35},[40,2782,2783,2785,2788,2791],{"class":42,"line":43},[40,2784,163],{"class":57},[40,2786,2787],{"class":2096},"script",[40,2789,2790],{"class":123}," setup",[40,2792,172],{"class":57},[40,2794,2795,2797,2800,2802,2805],{"class":42,"line":50},[40,2796,2630],{"class":53},[40,2798,2799],{"class":131}," route",[40,2801,2033],{"class":53},[40,2803,2804],{"class":123}," useRoute",[40,2806,2591],{"class":57},[40,2808,2809,2811,2814,2816,2819,2822,2825],{"class":42,"line":82},[40,2810,2630],{"class":53},[40,2812,2813],{"class":131}," resolvedSrc",[40,2815,2033],{"class":53},[40,2817,2818],{"class":123}," computed",[40,2820,2821],{"class":57},"(() ",[40,2823,2824],{"class":53},"=>",[40,2826,2036],{"class":57},[40,2828,2829,2831,2833,2835,2838,2840],{"class":42,"line":89},[40,2830,2435],{"class":53},[40,2832,2438],{"class":57},[40,2834,2441],{"class":53},[40,2836,2837],{"class":57},"props.src) ",[40,2839,2447],{"class":53},[40,2841,2842],{"class":127}," ''\n",[40,2844,2845,2847,2850,2853,2855,2858,2861,2863,2866,2868,2870,2873,2876,2878],{"class":42,"line":95},[40,2846,2435],{"class":53},[40,2848,2849],{"class":57}," (props.src.",[40,2851,2852],{"class":123},"startsWith",[40,2854,2396],{"class":57},[40,2856,2857],{"class":127},"'\u002F'",[40,2859,2860],{"class":57},") ",[40,2862,2610],{"class":53},[40,2864,2865],{"class":57}," props.src.",[40,2867,2852],{"class":123},[40,2869,2396],{"class":57},[40,2871,2872],{"class":127},"'http'",[40,2874,2875],{"class":57},")) ",[40,2877,2447],{"class":53},[40,2879,2880],{"class":57}," props.src\n",[40,2882,2883,2886,2889,2891,2894,2897,2899,2901,2905,2907,2909,2911,2914],{"class":42,"line":217},[40,2884,2885],{"class":53},"  const",[40,2887,2888],{"class":131}," base",[40,2890,2033],{"class":53},[40,2892,2893],{"class":57}," route.path.",[40,2895,2896],{"class":123},"replace",[40,2898,2396],{"class":57},[40,2900,2515],{"class":127},[40,2902,2904],{"class":2903},"sCnZR","\\\u002F",[40,2906,2512],{"class":53},[40,2908,2515],{"class":127},[40,2910,411],{"class":57},[40,2912,2913],{"class":127},"''",[40,2915,2706],{"class":57},[40,2917,2918,2920,2923,2926,2929,2932,2934,2937],{"class":42,"line":225},[40,2919,2654],{"class":53},[40,2921,2922],{"class":127}," `${",[40,2924,2925],{"class":57},"base",[40,2927,2928],{"class":127},"}\u002F${",[40,2930,2931],{"class":57},"props",[40,2933,2518],{"class":127},[40,2935,2936],{"class":57},"src",[40,2938,2939],{"class":127},"}`\n",[40,2941,2942],{"class":42,"line":230},[40,2943,2944],{"class":57},"})\n",[40,2946,2947,2950,2952],{"class":42,"line":237},[40,2948,2949],{"class":57},"\u003C\u002F",[40,2951,2787],{"class":2096},[40,2953,172],{"class":57},[40,2955,2956],{"class":42,"line":243},[40,2957,86],{"emptyLinePlaceholder":85},[40,2959,2960,2962,2965],{"class":42,"line":251},[40,2961,163],{"class":57},[40,2963,2964],{"class":2096},"template",[40,2966,172],{"class":57},[40,2968,2969,2972,2974,2977,2979,2982,2985,2987,2990],{"class":42,"line":257},[40,2970,2971],{"class":57},"  \u003C",[40,2973,755],{"class":2096},[40,2975,2976],{"class":123}," :src",[40,2978,600],{"class":57},[40,2980,2981],{"class":127},"\"resolvedSrc\"",[40,2983,2984],{"class":123}," :alt",[40,2986,600],{"class":57},[40,2988,2989],{"class":127},"\"alt\"",[40,2991,2992],{"class":57}," \u002F>\n",[40,2994,2995,2997,2999],{"class":42,"line":265},[40,2996,2949],{"class":57},[40,2998,2964],{"class":2096},[40,3000,172],{"class":57},[15,3002,3003],{},[1635,3004,3005],{},"第二步：启用 MDC prose 组件",[30,3007,3009],{"className":1641,"code":3008,"language":1643,"meta":35,"style":35},"\u002F\u002F nuxt.config.ts\nmdc: {\n  components: {\n    prose: true  \u002F\u002F 让 Nuxt Content 使用自定义 ProseImg\n  }\n}\n",[37,3010,3011,3015,3022,3029,3042,3046],{"__ignoreMap":35},[40,3012,3013],{"class":42,"line":43},[40,3014,2307],{"class":46},[40,3016,3017,3020],{"class":42,"line":50},[40,3018,3019],{"class":123},"mdc",[40,3021,2315],{"class":57},[40,3023,3024,3027],{"class":42,"line":82},[40,3025,3026],{"class":123},"  components",[40,3028,2315],{"class":57},[40,3030,3031,3034,3036,3039],{"class":42,"line":89},[40,3032,3033],{"class":123},"    prose",[40,3035,2100],{"class":57},[40,3037,3038],{"class":131},"true",[40,3040,3041],{"class":46},"  \u002F\u002F 让 Nuxt Content 使用自定义 ProseImg\n",[40,3043,3044],{"class":42,"line":95},[40,3045,2361],{"class":57},[40,3047,3048],{"class":42,"line":217},[40,3049,1690],{"class":57},[2261,3051,3053],{"id":3052},"问题三content-目录的图片如何服务","问题三：content 目录的图片如何服务",[11,3055,2276],{"id":3056},"现象-2",[15,3058,3059,3060,3063],{},"原站（SvelteKit）的图片放在 ",[37,3061,3062],{},"content\u002Fposts\u002F{slug}\u002Fimg\u002F"," 下，markdown 用相对路径引用，一切正常。Nuxt 项目里，content 目录的文件不会被当成静态资源服务。",[11,3065,3066],{"id":3066},"方案对比",[1720,3068,3069,3082],{},[1723,3070,3071],{},[1726,3072,3073,3076,3079],{},[1729,3074,3075],{},"方案",[1729,3077,3078],{},"优点",[1729,3080,3081],{},"缺点",[1742,3083,3084,3095,3106],{},[1726,3085,3086,3089,3092],{},[1747,3087,3088],{},"复制到 public\u002F",[1747,3090,3091],{},"简单",[1747,3093,3094],{},"文件重复，需同步",[1726,3096,3097,3100,3103],{},[1747,3098,3099],{},"Vite 插件拦截",[1747,3101,3102],{},"原站方案",[1747,3104,3105],{},"Nuxt 不直接支持",[1726,3107,3108,3111,3114],{},[1747,3109,3110],{},"Server Route",[1747,3112,3113],{},"不复制文件",[1747,3115,3116],{},"需要手动写路由",[15,3118,3119,3120,3122],{},"最终选择 ",[1635,3121,3110],{},"，和原站的 Vite 插件思路一致：",[30,3124,3126],{"className":1641,"code":3125,"language":1643,"meta":35,"style":35},"\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",[37,3127,3128,3133,3159,3174,3238,3252,3256,3278,3304,3322,3341,3348],{"__ignoreMap":35},[40,3129,3130],{"class":42,"line":43},[40,3131,3132],{"class":46},"\u002F\u002F server\u002Froutes\u002Fposts\u002F[slug]\u002Fimg\u002F[...file].ts\n",[40,3134,3135,3137,3140,3143,3145,3148,3150,3153,3155,3157],{"class":42,"line":50},[40,3136,594],{"class":53},[40,3138,3139],{"class":53}," default",[40,3141,3142],{"class":123}," defineEventHandler",[40,3144,2396],{"class":57},[40,3146,3147],{"class":53},"async",[40,3149,2438],{"class":57},[40,3151,3152],{"class":2399},"event",[40,3154,2860],{"class":57},[40,3156,2824],{"class":53},[40,3158,2036],{"class":57},[40,3160,3161,3163,3166,3168,3171],{"class":42,"line":82},[40,3162,2885],{"class":53},[40,3164,3165],{"class":131}," url",[40,3167,2033],{"class":53},[40,3169,3170],{"class":123}," getRequestURL",[40,3172,3173],{"class":57},"(event)\n",[40,3175,3176,3178,3181,3183,3186,3189,3191,3193,3195,3197,3200,3202,3204,3207,3209,3212,3215,3218,3220,3222,3224,3226,3228,3230,3232,3234,3236],{"class":42,"line":89},[40,3177,2885],{"class":53},[40,3179,3180],{"class":131}," match",[40,3182,2033],{"class":53},[40,3184,3185],{"class":57}," url.pathname.",[40,3187,3188],{"class":123},"match",[40,3190,2396],{"class":57},[40,3192,2515],{"class":127},[40,3194,2502],{"class":53},[40,3196,2904],{"class":2903},[40,3198,3199],{"class":2505},"posts",[40,3201,2904],{"class":2903},[40,3203,2396],{"class":2505},[40,3205,3206],{"class":131},"[",[40,3208,2502],{"class":53},[40,3210,3211],{"class":131},"\u002F]",[40,3213,3214],{"class":53},"+",[40,3216,3217],{"class":2505},")",[40,3219,2904],{"class":2903},[40,3221,755],{"class":2505},[40,3223,2904],{"class":2903},[40,3225,2396],{"class":2505},[40,3227,2518],{"class":131},[40,3229,3214],{"class":53},[40,3231,3217],{"class":2505},[40,3233,2512],{"class":53},[40,3235,2515],{"class":127},[40,3237,2706],{"class":57},[40,3239,3240,3242,3244,3246,3249],{"class":42,"line":95},[40,3241,2435],{"class":53},[40,3243,2438],{"class":57},[40,3245,2441],{"class":53},[40,3247,3248],{"class":57},"match) ",[40,3250,3251],{"class":53},"return\n",[40,3253,3254],{"class":42,"line":217},[40,3255,86],{"emptyLinePlaceholder":85},[40,3257,3258,3260,3263,3266,3268,3271,3273,3275],{"class":42,"line":225},[40,3259,2885],{"class":53},[40,3261,3262],{"class":57}," [, ",[40,3264,3265],{"class":131},"slug",[40,3267,411],{"class":57},[40,3269,3270],{"class":131},"filename",[40,3272,2487],{"class":57},[40,3274,600],{"class":53},[40,3276,3277],{"class":57}," match\n",[40,3279,3280,3282,3285,3287,3290,3292,3295,3298,3301],{"class":42,"line":230},[40,3281,2885],{"class":53},[40,3283,3284],{"class":131}," filePath",[40,3286,2033],{"class":53},[40,3288,3289],{"class":123}," resolve",[40,3291,2396],{"class":57},[40,3293,3294],{"class":127},"'content\u002Fposts'",[40,3296,3297],{"class":57},", slug, ",[40,3299,3300],{"class":127},"'img'",[40,3302,3303],{"class":57},", filename)\n",[40,3305,3306,3308,3311,3313,3316,3319],{"class":42,"line":237},[40,3307,2885],{"class":53},[40,3309,3310],{"class":131}," data",[40,3312,2033],{"class":53},[40,3314,3315],{"class":53}," await",[40,3317,3318],{"class":123}," readFile",[40,3320,3321],{"class":57},"(filePath)\n",[40,3323,3324,3327,3330,3333,3335,3338],{"class":42,"line":243},[40,3325,3326],{"class":123},"  setResponseHeader",[40,3328,3329],{"class":57},"(event, ",[40,3331,3332],{"class":127},"'content-type'",[40,3334,411],{"class":57},[40,3336,3337],{"class":131},"MIME",[40,3339,3340],{"class":57},"[ext])\n",[40,3342,3343,3345],{"class":42,"line":251},[40,3344,2654],{"class":53},[40,3346,3347],{"class":57}," data\n",[40,3349,3350],{"class":42,"line":257},[40,3351,2944],{"class":57},[15,3353,3354,3355,3357,3358,3361],{},"请求 ",[37,3356,2720],{}," → 读取 ",[37,3359,3360],{},"content\u002Fposts\u002Fsshfs\u002Fimg\u002Fsshfs.avif"," → 返回图片。",[2261,3363,424],{"id":424},[1720,3365,3366,3378],{},[1723,3367,3368],{},[1726,3369,3370,3373,3376],{},[1729,3371,3372],{},"问题",[1729,3374,3375],{},"根因",[1729,3377,3075],{},[1742,3379,3380,3394,3405],{},[1726,3381,3382,3385,3391],{},[1747,3383,3384],{},"TOC 只有 h2",[1747,3386,3387,3390],{},[37,3388,3389],{},"toc.depth"," 配置不生效",[1747,3392,3393],{},"从 body AST 手动提取",[1726,3395,3396,3399,3402],{},[1747,3397,3398],{},"图片路径错误",[1747,3400,3401],{},"SPA 路由导致相对路径解析基准错误",[1747,3403,3404],{},"ProseImg 组件 + mdc.prose 配置",[1726,3406,3407,3410,3413],{},[1747,3408,3409],{},"content 图片 404",[1747,3411,3412],{},"content 目录不暴露给浏览器",[1747,3414,3415],{},"Server Route 直接读取返回",[15,3417,3418],{},"Nuxt Content v3 的设计理念是\"图片放 public\u002F，用绝对路径\"。如果想像 SvelteKit 项目那样把图片和文章放一起，需要额外做不少工作。",[15,3420,3421],{},"不过一旦这些坑都踩过，整体开发体验还是很流畅的。Nuxt 的生态系统、自动导入、模块系统都比 SvelteKit 成熟很多。",[464,3423,3424],{},"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":35,"searchDepth":50,"depth":82,"links":3426},[3427,3428,3429,3430,3431,3432,3433,3434],{"id":2276,"depth":50,"text":2276},{"id":2290,"depth":50,"text":2290},{"id":775,"depth":50,"text":775},{"id":2677,"depth":50,"text":2276},{"id":2723,"depth":50,"text":2290},{"id":2758,"depth":50,"text":775},{"id":3056,"depth":50,"text":2276},{"id":3066,"depth":50,"text":3066},"从 SvelteKit 迁移到 Nuxt 3 的过程中，遇到了不少 Nuxt Content v3 的坑。这篇文章记录了目录导航、图片路径、ProseImg 组件等问题的排查和解决过程。","\u002Fposts\u002Fnuxt-content-lessons\u002Fimg\u002Fcover.jpg",{},"\u002Fposts\u002Fnuxt-content-lessons","2026-05-31T14:00:00",{"title":2257,"description":3435},"posts\u002Fnuxt-content-lessons\u002Findex",[1740,3443,3444],"踩坑","Nuxt Content","vbSt6ND1z7xt7L8FxgZ8Qe3EdxV9InsRerN3E23GC7s",{"id":3447,"title":3448,"author":1851,"body":3449,"description":3714,"difficulty":1266,"draft":479,"extension":480,"image":3715,"meta":3716,"navigation":85,"path":3717,"pinned":479,"published":3718,"seo":3719,"series":2249,"seriesOrder":82,"stem":3720,"tags":3721,"updated":2253,"__hash__":3724},"posts\u002Fposts\u002Fnuxt-tailwind-underline-fix\u002Findex.md","Nuxt 3 + Tailwind CSS 客户端导航链接下划线闪烁问题",{"type":8,"value":3450,"toc":3707},[3451,3454,3457,3472,3475,3478,3481,3522,3533,3540,3562,3569,3573,3576,3579,3581,3584,3607,3618,3675,3680,3693,3695,3701,3704],[11,3452,3453],{"id":3453},"问题描述",[15,3455,3456],{},"在 Nuxt 3 项目中使用 Tailwind CSS 时，发现一个奇怪的现象：",[1943,3458,3459,3469],{},[432,3460,3461,3462,3465,3466],{},"首次通过客户端导航（点击 ",[37,3463,3464],{},"\u003CNuxtLink>","）跳转到某个页面时，",[1635,3467,3468],{},"所有链接文字都会出现下划线",[432,3470,3471],{},"手动刷新页面后，下划线消失，样式恢复正常",[15,3473,3474],{},"这个问题在开发模式和生产构建后都会出现。",[11,3476,3477],{"id":3477},"原因分析",[15,3479,3480],{},"Tailwind CSS 的 Preflight（基于 modern-normalize）会重置链接样式：",[30,3482,3486],{"className":3483,"code":3484,"language":3485,"meta":35,"style":35},"language-css shiki shiki-themes github-light github-dark","a {\n  color: inherit;\n  text-decoration: inherit;\n}\n","css",[37,3487,3488,3494,3507,3518],{"__ignoreMap":35},[40,3489,3490,3492],{"class":42,"line":43},[40,3491,2216],{"class":2096},[40,3493,2036],{"class":57},[40,3495,3496,3499,3501,3504],{"class":42,"line":50},[40,3497,3498],{"class":131},"  color",[40,3500,2100],{"class":57},[40,3502,3503],{"class":131},"inherit",[40,3505,3506],{"class":57},";\n",[40,3508,3509,3512,3514,3516],{"class":42,"line":82},[40,3510,3511],{"class":131},"  text-decoration",[40,3513,2100],{"class":57},[40,3515,3503],{"class":131},[40,3517,3506],{"class":57},[40,3519,3520],{"class":42,"line":89},[40,3521,1690],{"class":57},[15,3523,3524,3525,3528,3529,3532],{},"正常情况下，这会让链接继承父元素的 ",[37,3526,3527],{},"text-decoration","（通常是 ",[37,3530,3531],{},"none","），从而去掉浏览器默认的下划线。",[15,3534,3535,3536,3539],{},"问题出在 ",[1635,3537,3538],{},"Nuxt 的客户端导航机制","：",[429,3541,3542,3548,3551,3556,3559],{},[432,3543,3544,3545,3547],{},"用户点击 ",[37,3546,3464],{},"，Vue Router 拦截导航",[432,3549,3550],{},"新页面组件异步加载并渲染",[432,3552,3553],{},[1635,3554,3555],{},"在 CSS 完全加载\u002F应用之前，页面已经渲染了",[432,3557,3558],{},"此时浏览器使用默认样式（链接带下划线）",[432,3560,3561],{},"CSS 加载完成后，下划线消失",[15,3563,3564,3565,3568],{},"这就是典型的 ",[1635,3566,3567],{},"FOUC（Flash of Unstyled Content）"," 问题。",[11,3570,3572],{"id":3571},"为什么刷新就正常了","为什么刷新就正常了？",[15,3574,3575],{},"刷新页面时，浏览器会等待所有 CSS 加载完成后再渲染页面（SSR 模式下 HTML 和 CSS 是一起返回的）。所以刷新后样式是正确的。",[15,3577,3578],{},"而客户端导航是 JavaScript 驱动的，CSS 的加载和页面的渲染是异步的，就出现了时序差。",[11,3580,20],{"id":20},[15,3582,3583],{},"尝试了多种方案：",[1943,3585,3586,3592,3598,3604],{},[432,3587,3588,3591],{},[37,3589,3590],{},"@layer base"," 覆盖 — 无效，CSS layers 的优先级问题",[432,3593,3594,3597],{},[37,3595,3596],{},"corePlugins.preflight: false"," — 无效，且破坏其他基础样式",[432,3599,3600,3603],{},[37,3601,3602],{},"features.inlineStyles: true"," — 无效",[432,3605,3606],{},"页面过渡动画 — 只是掩盖，不能根治",[15,3608,3609,3610,3613,3614,3617],{},"最终有效的方案是在 ",[37,3611,3612],{},"app.vue"," 中用 ",[37,3615,3616],{},"!important"," 强制覆盖：",[30,3619,3621],{"className":2776,"code":3620,"language":2778,"meta":35,"style":35},"\u003Cstyle>\na:not(.prose a) {\n  text-decoration-line: none !important;\n}\n\u003C\u002Fstyle>\n",[37,3622,3623,3631,3649,3663,3667],{"__ignoreMap":35},[40,3624,3625,3627,3629],{"class":42,"line":43},[40,3626,163],{"class":57},[40,3628,464],{"class":2096},[40,3630,172],{"class":57},[40,3632,3633,3635,3638,3640,3643,3646],{"class":42,"line":50},[40,3634,2216],{"class":2096},[40,3636,3637],{"class":123},":not",[40,3639,2396],{"class":57},[40,3641,3642],{"class":123},".prose",[40,3644,3645],{"class":2096}," a",[40,3647,3648],{"class":57},") {\n",[40,3650,3651,3654,3656,3658,3661],{"class":42,"line":82},[40,3652,3653],{"class":131},"  text-decoration-line",[40,3655,2100],{"class":57},[40,3657,3531],{"class":131},[40,3659,3660],{"class":53}," !important",[40,3662,3506],{"class":57},[40,3664,3665],{"class":42,"line":89},[40,3666,1690],{"class":57},[40,3668,3669,3671,3673],{"class":42,"line":95},[40,3670,2949],{"class":57},[40,3672,464],{"class":2096},[40,3674,172],{"class":57},[15,3676,3677],{},[1635,3678,3679],{},"关键点：",[1943,3681,3682,3687],{},[432,3683,3684,3686],{},[37,3685,3616],{}," 是为了对抗 Tailwind CSS layers 的优先级",[432,3688,3689,3692],{},[37,3690,3691],{},":not(.prose a)"," 排除文章正文区域，保留文章内链接的下划线（可读性需要）",[11,3694,424],{"id":424},[15,3696,3697,3698,3700],{},"这是 Nuxt 3 + Tailwind CSS 的一个已知问题，本质上是 CSS 加载时序导致的 FOUC。",[37,3699,3616],{}," 虽然不够优雅，但在这个场景下是务实的解决方案。",[15,3702,3703],{},"如果你有更好的方案，欢迎在项目仓库的 Issues 中提出！",[464,3705,3706],{},"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":35,"searchDepth":50,"depth":82,"links":3708},[3709,3710,3711,3712,3713],{"id":3453,"depth":50,"text":3453},{"id":3477,"depth":50,"text":3477},{"id":3571,"depth":50,"text":3572},{"id":20,"depth":50,"text":20},{"id":424,"depth":50,"text":424},"记录 Nuxt 3 配合 Tailwind CSS 时，客户端导航导致链接文字出现下划线闪烁的问题及解决方案。","\u002Fposts\u002Fnuxt-tailwind-underline-fix\u002Fimg\u002Fcover.png",{},"\u002Fposts\u002Fnuxt-tailwind-underline-fix","2026-05-31T18:47:00",{"title":3448,"description":3714},"posts\u002Fnuxt-tailwind-underline-fix\u002Findex",[1740,3722,3723,3443],"Tailwind CSS","CSS","WMLpTEsIoJqufwEfXyIiBgUYxcPY2XkvOwqAD4hbe6g",{"id":3726,"title":3727,"author":6,"body":3728,"description":5779,"difficulty":1266,"draft":479,"extension":480,"image":5780,"meta":5781,"navigation":85,"path":5782,"pinned":479,"published":5783,"seo":5784,"series":5785,"seriesOrder":43,"stem":5786,"tags":5787,"updated":5792,"__hash__":5793},"posts\u002Fposts\u002Fgo-mcp-gin\u002Findex.md","Go-MCP 接入 Gin：从零搭建 MCP 服务器",{"type":8,"value":3729,"toc":5760},[3730,3734,3737,3740,3744,3751,3757,3760,3763,3783,3786,3790,3814,3818,3918,3931,3935,4099,4102,4319,4323,4334,4433,4440,4444,4718,4721,4802,4806,4809,4811,4822,4826,4832,5199,5389,5392,5448,5451,5604,5607,5610,5681,5683,5748,5757],[11,3731,3733],{"id":3732},"什么是-mcp","什么是 MCP",[15,3735,3736],{},"MCP（Model Context Protocol）是 Anthropic 提出的开放协议，用于让 AI 助手（如 Claude）与外部服务进行标准化交互。简单来说：你提供一个 MCP 服务器，AI 就能发现并调用你定义的工具（Tools），就像给 AI 装了一双手。",[15,3738,3739],{},"传统做法是写 REST API 然后让 AI 通过函数调用（Function Calling）间接使用，但 MCP 统一了协议层，AI 客户端只需要配置一个 URL 就能接入所有工具。",[11,3741,3743],{"id":3742},"为什么选-gin-mcp-go","为什么选 Gin + mcp-go",[15,3745,3746,3747,3750],{},"Go 生态里做 MCP 服务器最成熟的库是 ",[37,3748,3749],{},"mark3labs\u002Fmcp-go","，它支持 StreamableHTTP 传输模式，可以直接挂载到任意 HTTP 框架上。Gin 作为 Go 最流行的 HTTP 框架，天然适配。",[15,3752,3753,3754],{},"核心思路：",[1635,3755,3756],{},"MCP 服务器本质上就是一个 HTTP Handler，挂到 Gin 路由上即可。",[11,3758,3759],{"id":3759},"需求分析",[15,3761,3762],{},"我们要搭建一个具备以下能力的 MCP 服务器：",[429,3764,3765,3771,3777],{},[432,3766,3767,3770],{},[1635,3768,3769],{},"工具注册"," — 向 AI 暴露可调用的工具（列出资源、查看详情、创建\u002F删除）",[432,3772,3773,3776],{},[1635,3774,3775],{},"HTTP 集成"," — MCP 端点作为 Gin 路由的一部分，与业务 API 共存",[432,3778,3779,3782],{},[1635,3780,3781],{},"鉴权"," — MCP 请求必须经过身份验证，工具处理器能获取当前用户信息",[11,3784,3785],{"id":3785},"实现步骤",[22,3787,3789],{"id":3788},"_1-安装依赖","1. 安装依赖",[30,3791,3793],{"className":114,"code":3792,"language":116,"meta":35,"style":35},"go get github.com\u002Fmark3labs\u002Fmcp-go@v0.54.0\ngo get github.com\u002Fgin-gonic\u002Fgin@v1.12.0\n",[37,3794,3795,3805],{"__ignoreMap":35},[40,3796,3797,3799,3802],{"class":42,"line":43},[40,3798,631],{"class":123},[40,3800,3801],{"class":127}," get",[40,3803,3804],{"class":127}," github.com\u002Fmark3labs\u002Fmcp-go@v0.54.0\n",[40,3806,3807,3809,3811],{"class":42,"line":50},[40,3808,631],{"class":123},[40,3810,3801],{"class":127},[40,3812,3813],{"class":127}," github.com\u002Fgin-gonic\u002Fgin@v1.12.0\n",[22,3815,3817],{"id":3816},"_2-创建-mcp-服务器","2. 创建 MCP 服务器",[30,3819,3822],{"className":3820,"code":3821,"language":631,"meta":35,"style":35},"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",[37,3823,3824,3836,3840,3857,3868,3879,3890,3899,3914],{"__ignoreMap":35},[40,3825,3826,3829,3831,3834],{"class":42,"line":43},[40,3827,3828],{"class":53},"import",[40,3830,534],{"class":127},[40,3832,3833],{"class":123},"github.com\u002Fmark3labs\u002Fmcp-go\u002Fserver",[40,3835,727],{"class":127},[40,3837,3838],{"class":42,"line":50},[40,3839,86],{"emptyLinePlaceholder":85},[40,3841,3842,3845,3848,3851,3854],{"class":42,"line":82},[40,3843,3844],{"class":57},"mcpServer ",[40,3846,3847],{"class":53},":=",[40,3849,3850],{"class":57}," server.",[40,3852,3853],{"class":123},"NewMCPServer",[40,3855,3856],{"class":57},"(\n",[40,3858,3859,3862,3865],{"class":42,"line":89},[40,3860,3861],{"class":127},"    \"my-app\"",[40,3863,3864],{"class":57},",    ",[40,3866,3867],{"class":46},"\u002F\u002F 服务器名称\n",[40,3869,3870,3873,3876],{"class":42,"line":95},[40,3871,3872],{"class":127},"    \"1.0.0\"",[40,3874,3875],{"class":57},",     ",[40,3877,3878],{"class":46},"\u002F\u002F 版本\n",[40,3880,3881,3884,3887],{"class":42,"line":217},[40,3882,3883],{"class":57},"    server.",[40,3885,3886],{"class":123},"WithLogging",[40,3888,3889],{"class":57},"(),\n",[40,3891,3892,3894,3897],{"class":42,"line":225},[40,3893,3883],{"class":57},[40,3895,3896],{"class":123},"WithRecovery",[40,3898,3889],{"class":57},[40,3900,3901,3903,3906,3908,3911],{"class":42,"line":230},[40,3902,3883],{"class":57},[40,3904,3905],{"class":123},"WithInstructions",[40,3907,2396],{"class":57},[40,3909,3910],{"class":127},"\"这是一个 MCP 服务器示例\"",[40,3912,3913],{"class":57},"),\n",[40,3915,3916],{"class":42,"line":237},[40,3917,2706],{"class":57},[15,3919,3920,3922,3923,3926,3927,3930],{},[37,3921,3853],{}," 创建一个 MCP 协议实例，它负责处理工具发现（",[37,3924,3925],{},"tools\u002Flist","）和工具调用（",[37,3928,3929],{},"tools\u002Fcall","）的协议逻辑。",[22,3932,3934],{"id":3933},"_3-注册工具","3. 注册工具",[30,3936,3938],{"className":3820,"code":3937,"language":631,"meta":35,"style":35},"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",[37,3939,3940,3951,3955,3960,3980,3995,3999,4003,4008,4069,4074,4095],{"__ignoreMap":35},[40,3941,3942,3944,3946,3949],{"class":42,"line":43},[40,3943,3828],{"class":53},[40,3945,534],{"class":127},[40,3947,3948],{"class":123},"github.com\u002Fmark3labs\u002Fmcp-go\u002Fmcp",[40,3950,727],{"class":127},[40,3952,3953],{"class":42,"line":50},[40,3954,86],{"emptyLinePlaceholder":85},[40,3956,3957],{"class":42,"line":82},[40,3958,3959],{"class":46},"\u002F\u002F 定义工具\n",[40,3961,3962,3965,3967,3970,3973,3975,3978],{"class":42,"line":89},[40,3963,3964],{"class":57},"listTool ",[40,3966,3847],{"class":53},[40,3968,3969],{"class":57}," mcp.",[40,3971,3972],{"class":123},"NewTool",[40,3974,2396],{"class":57},[40,3976,3977],{"class":127},"\"list_items\"",[40,3979,2047],{"class":57},[40,3981,3982,3985,3988,3990,3993],{"class":42,"line":95},[40,3983,3984],{"class":57},"    mcp.",[40,3986,3987],{"class":123},"WithDescription",[40,3989,2396],{"class":57},[40,3991,3992],{"class":127},"\"列出所有项目\"",[40,3994,3913],{"class":57},[40,3996,3997],{"class":42,"line":217},[40,3998,2706],{"class":57},[40,4000,4001],{"class":42,"line":225},[40,4002,86],{"emptyLinePlaceholder":85},[40,4004,4005],{"class":42,"line":230},[40,4006,4007],{"class":46},"\u002F\u002F 注册处理器\n",[40,4009,4010,4013,4016,4019,4022,4024,4027,4030,4032,4035,4037,4040,4043,4045,4048,4051,4054,4057,4059,4062,4064,4067],{"class":42,"line":237},[40,4011,4012],{"class":57},"mcpServer.",[40,4014,4015],{"class":123},"AddTool",[40,4017,4018],{"class":57},"(listTool, ",[40,4020,4021],{"class":53},"func",[40,4023,2396],{"class":57},[40,4025,4026],{"class":2399},"ctx",[40,4028,4029],{"class":123}," context",[40,4031,2518],{"class":57},[40,4033,4034],{"class":123},"Context",[40,4036,411],{"class":57},[40,4038,4039],{"class":2399},"req",[40,4041,4042],{"class":123}," mcp",[40,4044,2518],{"class":57},[40,4046,4047],{"class":123},"CallToolRequest",[40,4049,4050],{"class":57},") (",[40,4052,4053],{"class":53},"*",[40,4055,4056],{"class":123},"mcp",[40,4058,2518],{"class":57},[40,4060,4061],{"class":123},"CallToolResult",[40,4063,411],{"class":57},[40,4065,4066],{"class":53},"error",[40,4068,3648],{"class":57},[40,4070,4071],{"class":42,"line":243},[40,4072,4073],{"class":46},"    \u002F\u002F 业务逻辑\n",[40,4075,4076,4079,4081,4084,4086,4089,4092],{"class":42,"line":251},[40,4077,4078],{"class":53},"    return",[40,4080,3969],{"class":57},[40,4082,4083],{"class":123},"NewToolResultText",[40,4085,2396],{"class":57},[40,4087,4088],{"class":127},"\"返回结果\"",[40,4090,4091],{"class":57},"), ",[40,4093,4094],{"class":131},"nil\n",[40,4096,4097],{"class":42,"line":257},[40,4098,2944],{"class":57},[15,4100,4101],{},"带参数的工具：",[30,4103,4105],{"className":3820,"code":4104,"language":631,"meta":35,"style":35},"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",[37,4106,4107,4125,4138,4152,4162,4176,4181,4185,4189,4236,4255,4270,4289,4293,4298,4315],{"__ignoreMap":35},[40,4108,4109,4112,4114,4116,4118,4120,4123],{"class":42,"line":43},[40,4110,4111],{"class":57},"getTool ",[40,4113,3847],{"class":53},[40,4115,3969],{"class":57},[40,4117,3972],{"class":123},[40,4119,2396],{"class":57},[40,4121,4122],{"class":127},"\"get_item\"",[40,4124,2047],{"class":57},[40,4126,4127,4129,4131,4133,4136],{"class":42,"line":50},[40,4128,3984],{"class":57},[40,4130,3987],{"class":123},[40,4132,2396],{"class":57},[40,4134,4135],{"class":127},"\"获取指定项目的详情\"",[40,4137,3913],{"class":57},[40,4139,4140,4142,4145,4147,4150],{"class":42,"line":82},[40,4141,3984],{"class":57},[40,4143,4144],{"class":123},"WithString",[40,4146,2396],{"class":57},[40,4148,4149],{"class":127},"\"item_id\"",[40,4151,2047],{"class":57},[40,4153,4154,4157,4160],{"class":42,"line":89},[40,4155,4156],{"class":57},"        mcp.",[40,4158,4159],{"class":123},"Required",[40,4161,3889],{"class":57},[40,4163,4164,4166,4169,4171,4174],{"class":42,"line":95},[40,4165,4156],{"class":57},[40,4167,4168],{"class":123},"Description",[40,4170,2396],{"class":57},[40,4172,4173],{"class":127},"\"项目 ID\"",[40,4175,3913],{"class":57},[40,4177,4178],{"class":42,"line":217},[40,4179,4180],{"class":57},"    ),\n",[40,4182,4183],{"class":42,"line":225},[40,4184,2706],{"class":57},[40,4186,4187],{"class":42,"line":230},[40,4188,86],{"emptyLinePlaceholder":85},[40,4190,4191,4193,4195,4198,4200,4202,4204,4206,4208,4210,4212,4214,4216,4218,4220,4222,4224,4226,4228,4230,4232,4234],{"class":42,"line":237},[40,4192,4012],{"class":57},[40,4194,4015],{"class":123},[40,4196,4197],{"class":57},"(getTool, ",[40,4199,4021],{"class":53},[40,4201,2396],{"class":57},[40,4203,4026],{"class":2399},[40,4205,4029],{"class":123},[40,4207,2518],{"class":57},[40,4209,4034],{"class":123},[40,4211,411],{"class":57},[40,4213,4039],{"class":2399},[40,4215,4042],{"class":123},[40,4217,2518],{"class":57},[40,4219,4047],{"class":123},[40,4221,4050],{"class":57},[40,4223,4053],{"class":53},[40,4225,4056],{"class":123},[40,4227,2518],{"class":57},[40,4229,4061],{"class":123},[40,4231,411],{"class":57},[40,4233,4066],{"class":53},[40,4235,3648],{"class":57},[40,4237,4238,4241,4243,4246,4249,4251,4253],{"class":42,"line":243},[40,4239,4240],{"class":57},"    itemID, err ",[40,4242,3847],{"class":53},[40,4244,4245],{"class":57}," req.",[40,4247,4248],{"class":123},"RequireString",[40,4250,2396],{"class":57},[40,4252,4149],{"class":127},[40,4254,2706],{"class":57},[40,4256,4257,4259,4262,4265,4268],{"class":42,"line":251},[40,4258,2473],{"class":53},[40,4260,4261],{"class":57}," err ",[40,4263,4264],{"class":53},"!=",[40,4266,4267],{"class":131}," nil",[40,4269,2036],{"class":57},[40,4271,4272,4275,4277,4280,4282,4285,4287],{"class":42,"line":257},[40,4273,4274],{"class":53},"        return",[40,4276,3969],{"class":57},[40,4278,4279],{"class":123},"NewToolResultError",[40,4281,2396],{"class":57},[40,4283,4284],{"class":127},"\"缺少 item_id 参数\"",[40,4286,4091],{"class":57},[40,4288,4094],{"class":131},[40,4290,4291],{"class":42,"line":265},[40,4292,2356],{"class":57},[40,4294,4295],{"class":42,"line":270},[40,4296,4297],{"class":46},"    \u002F\u002F 查询并返回\n",[40,4299,4300,4302,4304,4306,4308,4311,4313],{"class":42,"line":279},[40,4301,4078],{"class":53},[40,4303,3969],{"class":57},[40,4305,4083],{"class":123},[40,4307,2396],{"class":57},[40,4309,4310],{"class":127},"\"项目详情...\"",[40,4312,4091],{"class":57},[40,4314,4094],{"class":131},[40,4316,4317],{"class":42,"line":284},[40,4318,2944],{"class":57},[22,4320,4322],{"id":4321},"_4-挂载到-gin-路由","4. 挂载到 Gin 路由",[15,4324,4325,4326,4329,4330,4333],{},"关键一步：用 ",[37,4327,4328],{},"server.NewStreamableHTTPServer"," 把 MCP 实例转成 HTTP Handler，再用 ",[37,4331,4332],{},"gin.WrapH"," 适配 Gin。",[30,4335,4337],{"className":3820,"code":4336,"language":631,"meta":35,"style":35},"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",[37,4338,4339,4349,4353,4368,4382,4399,4403,4407,4412],{"__ignoreMap":35},[40,4340,4341,4343,4345,4347],{"class":42,"line":43},[40,4342,3828],{"class":53},[40,4344,534],{"class":127},[40,4346,3833],{"class":123},[40,4348,727],{"class":127},[40,4350,4351],{"class":42,"line":50},[40,4352,86],{"emptyLinePlaceholder":85},[40,4354,4355,4358,4360,4362,4365],{"class":42,"line":82},[40,4356,4357],{"class":57},"mcpHTTPServer ",[40,4359,3847],{"class":53},[40,4361,3850],{"class":57},[40,4363,4364],{"class":123},"NewStreamableHTTPServer",[40,4366,4367],{"class":57},"(mcpServer,\n",[40,4369,4370,4372,4375,4377,4380],{"class":42,"line":89},[40,4371,3883],{"class":57},[40,4373,4374],{"class":123},"WithEndpointPath",[40,4376,2396],{"class":57},[40,4378,4379],{"class":127},"\"\u002Fmcp\"",[40,4381,3913],{"class":57},[40,4383,4384,4386,4389,4391,4394,4396],{"class":42,"line":95},[40,4385,3883],{"class":57},[40,4387,4388],{"class":123},"WithHeartbeatInterval",[40,4390,2396],{"class":57},[40,4392,4393],{"class":131},"30",[40,4395,4053],{"class":53},[40,4397,4398],{"class":57},"time.Second),\n",[40,4400,4401],{"class":42,"line":217},[40,4402,2706],{"class":57},[40,4404,4405],{"class":42,"line":225},[40,4406,86],{"emptyLinePlaceholder":85},[40,4408,4409],{"class":42,"line":230},[40,4410,4411],{"class":46},"\u002F\u002F 挂载到 Gin\n",[40,4413,4414,4417,4420,4422,4424,4427,4430],{"class":42,"line":237},[40,4415,4416],{"class":57},"r.",[40,4418,4419],{"class":123},"Any",[40,4421,2396],{"class":57},[40,4423,4379],{"class":127},[40,4425,4426],{"class":57},", gin.",[40,4428,4429],{"class":123},"WrapH",[40,4431,4432],{"class":57},"(mcpHTTPServer))\n",[15,4434,4435,4436,4439],{},"就这样，",[37,4437,4438],{},"POST \u002Fmcp"," 端点就生效了。AI 客户端通过这个 URL 发送 JSON-RPC 请求，MCP 服务器自动处理协议握手、工具列表返回、工具调用路由。",[22,4441,4443],{"id":4442},"_5-完整的服务启动代码","5. 完整的服务启动代码",[30,4445,4447],{"className":3820,"code":4446,"language":631,"meta":35,"style":35},"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",[37,4448,4449,4458,4473,4477,4482,4505,4514,4522,4527,4531,4536,4544,4548,4553,4566,4578,4592,4596,4614,4618,4623,4656,4691,4696,4700,4714],{"__ignoreMap":35},[40,4450,4451,4453,4456],{"class":42,"line":43},[40,4452,4021],{"class":53},[40,4454,4455],{"class":123}," main",[40,4457,1674],{"class":57},[40,4459,4460,4463,4465,4468,4471],{"class":42,"line":50},[40,4461,4462],{"class":57},"    r ",[40,4464,3847],{"class":53},[40,4466,4467],{"class":57}," gin.",[40,4469,4470],{"class":123},"Default",[40,4472,2591],{"class":57},[40,4474,4475],{"class":42,"line":82},[40,4476,86],{"emptyLinePlaceholder":85},[40,4478,4479],{"class":42,"line":89},[40,4480,4481],{"class":46},"    \u002F\u002F 创建 MCP 服务器\n",[40,4483,4484,4487,4489,4491,4493,4495,4498,4500,4503],{"class":42,"line":95},[40,4485,4486],{"class":57},"    mcpServer ",[40,4488,3847],{"class":53},[40,4490,3850],{"class":57},[40,4492,3853],{"class":123},[40,4494,2396],{"class":57},[40,4496,4497],{"class":127},"\"my-app\"",[40,4499,411],{"class":57},[40,4501,4502],{"class":127},"\"1.0.0\"",[40,4504,2047],{"class":57},[40,4506,4507,4510,4512],{"class":42,"line":217},[40,4508,4509],{"class":57},"        server.",[40,4511,3886],{"class":123},[40,4513,3889],{"class":57},[40,4515,4516,4518,4520],{"class":42,"line":225},[40,4517,4509],{"class":57},[40,4519,3896],{"class":123},[40,4521,3889],{"class":57},[40,4523,4524],{"class":42,"line":230},[40,4525,4526],{"class":57},"    )\n",[40,4528,4529],{"class":42,"line":237},[40,4530,86],{"emptyLinePlaceholder":85},[40,4532,4533],{"class":42,"line":243},[40,4534,4535],{"class":46},"    \u002F\u002F 注册工具\n",[40,4537,4538,4541],{"class":42,"line":251},[40,4539,4540],{"class":123},"    registerTools",[40,4542,4543],{"class":57},"(mcpServer)\n",[40,4545,4546],{"class":42,"line":257},[40,4547,86],{"emptyLinePlaceholder":85},[40,4549,4550],{"class":42,"line":265},[40,4551,4552],{"class":46},"    \u002F\u002F 挂载到 Gin\n",[40,4554,4555,4558,4560,4562,4564],{"class":42,"line":270},[40,4556,4557],{"class":57},"    mcpHTTP ",[40,4559,3847],{"class":53},[40,4561,3850],{"class":57},[40,4563,4364],{"class":123},[40,4565,4367],{"class":57},[40,4567,4568,4570,4572,4574,4576],{"class":42,"line":279},[40,4569,4509],{"class":57},[40,4571,4374],{"class":123},[40,4573,2396],{"class":57},[40,4575,4379],{"class":127},[40,4577,3913],{"class":57},[40,4579,4580,4582,4584,4586,4588,4590],{"class":42,"line":284},[40,4581,4509],{"class":57},[40,4583,4388],{"class":123},[40,4585,2396],{"class":57},[40,4587,4393],{"class":131},[40,4589,4053],{"class":53},[40,4591,4398],{"class":57},[40,4593,4594],{"class":42,"line":289},[40,4595,4526],{"class":57},[40,4597,4598,4601,4603,4605,4607,4609,4611],{"class":42,"line":297},[40,4599,4600],{"class":57},"    r.",[40,4602,4419],{"class":123},[40,4604,2396],{"class":57},[40,4606,4379],{"class":127},[40,4608,4426],{"class":57},[40,4610,4429],{"class":123},[40,4612,4613],{"class":57},"(mcpHTTP))\n",[40,4615,4616],{"class":42,"line":302},[40,4617,86],{"emptyLinePlaceholder":85},[40,4619,4620],{"class":42,"line":311},[40,4621,4622],{"class":46},"    \u002F\u002F 其他业务路由\n",[40,4624,4625,4627,4630,4632,4635,4637,4639,4641,4644,4647,4650,4652,4654],{"class":42,"line":316},[40,4626,4600],{"class":57},[40,4628,4629],{"class":123},"GET",[40,4631,2396],{"class":57},[40,4633,4634],{"class":127},"\"\u002Fapi\u002Fhealth\"",[40,4636,411],{"class":57},[40,4638,4021],{"class":53},[40,4640,2396],{"class":57},[40,4642,4643],{"class":2399},"c",[40,4645,4646],{"class":53}," *",[40,4648,4649],{"class":123},"gin",[40,4651,2518],{"class":57},[40,4653,4034],{"class":123},[40,4655,3648],{"class":57},[40,4657,4658,4661,4664,4666,4669,4671,4673,4675,4678,4681,4684,4686,4689],{"class":42,"line":324},[40,4659,4660],{"class":57},"        c.",[40,4662,4663],{"class":123},"JSON",[40,4665,2396],{"class":57},[40,4667,4668],{"class":131},"200",[40,4670,411],{"class":57},[40,4672,4649],{"class":123},[40,4674,2518],{"class":57},[40,4676,4677],{"class":123},"H",[40,4679,4680],{"class":57},"{",[40,4682,4683],{"class":127},"\"status\"",[40,4685,2100],{"class":57},[40,4687,4688],{"class":127},"\"ok\"",[40,4690,2944],{"class":57},[40,4692,4693],{"class":42,"line":329},[40,4694,4695],{"class":57},"    })\n",[40,4697,4698],{"class":42,"line":344},[40,4699,86],{"emptyLinePlaceholder":85},[40,4701,4702,4704,4707,4709,4712],{"class":42,"line":353},[40,4703,4600],{"class":57},[40,4705,4706],{"class":123},"Run",[40,4708,2396],{"class":57},[40,4710,4711],{"class":127},"\":8080\"",[40,4713,2706],{"class":57},[40,4715,4716],{"class":42,"line":358},[40,4717,1690],{"class":57},[15,4719,4720],{},"此时启动服务，用 curl 测试：",[30,4722,4724],{"className":114,"code":4723,"language":116,"meta":35,"style":35},"# 发现工具列表\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",[37,4725,4726,4731,4748,4758,4766,4770,4775,4787,4795],{"__ignoreMap":35},[40,4727,4728],{"class":42,"line":43},[40,4729,4730],{"class":46},"# 发现工具列表\n",[40,4732,4733,4736,4739,4742,4745],{"class":42,"line":50},[40,4734,4735],{"class":123},"curl",[40,4737,4738],{"class":131}," -X",[40,4740,4741],{"class":127}," POST",[40,4743,4744],{"class":127}," http:\u002F\u002Flocalhost:8080\u002Fmcp",[40,4746,4747],{"class":131}," \\\n",[40,4749,4750,4753,4756],{"class":42,"line":82},[40,4751,4752],{"class":131},"  -H",[40,4754,4755],{"class":127}," \"Content-Type: application\u002Fjson\"",[40,4757,4747],{"class":131},[40,4759,4760,4763],{"class":42,"line":89},[40,4761,4762],{"class":131},"  -d",[40,4764,4765],{"class":127}," '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools\u002Flist\"}'\n",[40,4767,4768],{"class":42,"line":95},[40,4769,86],{"emptyLinePlaceholder":85},[40,4771,4772],{"class":42,"line":217},[40,4773,4774],{"class":46},"# 调用工具\n",[40,4776,4777,4779,4781,4783,4785],{"class":42,"line":225},[40,4778,4735],{"class":123},[40,4780,4738],{"class":131},[40,4782,4741],{"class":127},[40,4784,4744],{"class":127},[40,4786,4747],{"class":131},[40,4788,4789,4791,4793],{"class":42,"line":230},[40,4790,4752],{"class":131},[40,4792,4755],{"class":127},[40,4794,4747],{"class":131},[40,4796,4797,4799],{"class":42,"line":237},[40,4798,4762],{"class":131},[40,4800,4801],{"class":127}," '{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools\u002Fcall\",\"params\":{\"name\":\"list_items\",\"arguments\":{}}}'\n",[11,4803,4805],{"id":4804},"拓展接入鉴权","拓展：接入鉴权",[15,4807,4808],{},"裸奔的 MCP 端点任何人都能调用，生产环境必须加鉴权。思路和普通 API 鉴权一致：Bearer Token。",[22,4810,3372],{"id":3372},[15,4812,4813,4814,4817,4818,4821],{},"MCP 的工具处理器签名为 ",[37,4815,4816],{},"func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error)","，它不接收 Gin 的 ",[37,4819,4820],{},"*gin.Context","，所以无法直接在工具处理器里读取请求头。",[22,4823,4825],{"id":4824},"解决方案中间件传递用户信息","解决方案：中间件传递用户信息",[15,4827,4828,4829,2287],{},"用两层中间件：第一层做 Gin 的 Token 验证，第二层把用户信息从 Gin Context 复制到 ",[37,4830,4831],{},"request.Context",[30,4833,4835],{"className":3820,"code":4834,"language":631,"meta":35,"style":35},"\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",[37,4836,4837,4842,4870,4891,4911,4927,4960,4965,4970,4974,4999,5039,5068,5072,5076,5080,5100,5112,5141,5145,5149,5153,5168,5182,5191,5195],{"__ignoreMap":35},[40,4838,4839],{"class":42,"line":43},[40,4840,4841],{"class":46},"\u002F\u002F Gin 鉴权中间件\n",[40,4843,4844,4846,4849,4851,4854,4856,4859,4861,4863,4865,4868],{"class":42,"line":50},[40,4845,4021],{"class":53},[40,4847,4848],{"class":123}," AuthRequired",[40,4850,2396],{"class":57},[40,4852,4853],{"class":2399},"authService",[40,4855,4646],{"class":53},[40,4857,4858],{"class":123},"AuthService",[40,4860,2860],{"class":57},[40,4862,4649],{"class":123},[40,4864,2518],{"class":57},[40,4866,4867],{"class":123},"HandlerFunc",[40,4869,2036],{"class":57},[40,4871,4872,4874,4877,4879,4881,4883,4885,4887,4889],{"class":42,"line":82},[40,4873,4078],{"class":53},[40,4875,4876],{"class":53}," func",[40,4878,2396],{"class":57},[40,4880,4643],{"class":2399},[40,4882,4646],{"class":53},[40,4884,4649],{"class":123},[40,4886,2518],{"class":57},[40,4888,4034],{"class":123},[40,4890,3648],{"class":57},[40,4892,4893,4896,4898,4901,4904,4906,4909],{"class":42,"line":89},[40,4894,4895],{"class":57},"        authHeader ",[40,4897,3847],{"class":53},[40,4899,4900],{"class":57}," c.",[40,4902,4903],{"class":123},"GetHeader",[40,4905,2396],{"class":57},[40,4907,4908],{"class":127},"\"Authorization\"",[40,4910,2706],{"class":57},[40,4912,4913,4916,4919,4922,4925],{"class":42,"line":95},[40,4914,4915],{"class":53},"        if",[40,4917,4918],{"class":57}," authHeader ",[40,4920,4921],{"class":53},"==",[40,4923,4924],{"class":127}," \"\"",[40,4926,2036],{"class":57},[40,4928,4929,4932,4935,4937,4940,4942,4944,4946,4948,4950,4953,4955,4958],{"class":42,"line":217},[40,4930,4931],{"class":57},"            c.",[40,4933,4934],{"class":123},"AbortWithStatusJSON",[40,4936,2396],{"class":57},[40,4938,4939],{"class":131},"401",[40,4941,411],{"class":57},[40,4943,4649],{"class":123},[40,4945,2518],{"class":57},[40,4947,4677],{"class":123},[40,4949,4680],{"class":57},[40,4951,4952],{"class":127},"\"message\"",[40,4954,2100],{"class":57},[40,4956,4957],{"class":127},"\"缺少认证令牌\"",[40,4959,2944],{"class":57},[40,4961,4962],{"class":42,"line":225},[40,4963,4964],{"class":53},"            return\n",[40,4966,4967],{"class":42,"line":230},[40,4968,4969],{"class":57},"        }\n",[40,4971,4972],{"class":42,"line":237},[40,4973,86],{"emptyLinePlaceholder":85},[40,4975,4976,4979,4981,4984,4987,4990,4993,4995,4997],{"class":42,"line":243},[40,4977,4978],{"class":57},"        parts ",[40,4980,3847],{"class":53},[40,4982,4983],{"class":57}," strings.",[40,4985,4986],{"class":123},"SplitN",[40,4988,4989],{"class":57},"(authHeader, ",[40,4991,4992],{"class":127},"\" \"",[40,4994,411],{"class":57},[40,4996,2582],{"class":131},[40,4998,2706],{"class":57},[40,5000,5001,5003,5006,5009,5011,5014,5017,5020,5023,5026,5029,5031,5034,5037],{"class":42,"line":251},[40,5002,4915],{"class":53},[40,5004,5005],{"class":123}," len",[40,5007,5008],{"class":57},"(parts) ",[40,5010,4264],{"class":53},[40,5012,5013],{"class":131}," 2",[40,5015,5016],{"class":53}," ||",[40,5018,5019],{"class":53}," !",[40,5021,5022],{"class":57},"strings.",[40,5024,5025],{"class":123},"EqualFold",[40,5027,5028],{"class":57},"(parts[",[40,5030,2484],{"class":131},[40,5032,5033],{"class":57},"], ",[40,5035,5036],{"class":127},"\"Bearer\"",[40,5038,3648],{"class":57},[40,5040,5041,5043,5045,5047,5049,5051,5053,5055,5057,5059,5061,5063,5066],{"class":42,"line":257},[40,5042,4931],{"class":57},[40,5044,4934],{"class":123},[40,5046,2396],{"class":57},[40,5048,4939],{"class":131},[40,5050,411],{"class":57},[40,5052,4649],{"class":123},[40,5054,2518],{"class":57},[40,5056,4677],{"class":123},[40,5058,4680],{"class":57},[40,5060,4952],{"class":127},[40,5062,2100],{"class":57},[40,5064,5065],{"class":127},"\"格式错误，请使用 Bearer \u003Ctoken>\"",[40,5067,2944],{"class":57},[40,5069,5070],{"class":42,"line":265},[40,5071,4964],{"class":53},[40,5073,5074],{"class":42,"line":270},[40,5075,4969],{"class":57},[40,5077,5078],{"class":42,"line":279},[40,5079,86],{"emptyLinePlaceholder":85},[40,5081,5082,5085,5087,5090,5093,5095,5097],{"class":42,"line":284},[40,5083,5084],{"class":57},"        user, err ",[40,5086,3847],{"class":53},[40,5088,5089],{"class":57}," authService.",[40,5091,5092],{"class":123},"GetUserByToken",[40,5094,5028],{"class":57},[40,5096,618],{"class":131},[40,5098,5099],{"class":57},"])\n",[40,5101,5102,5104,5106,5108,5110],{"class":42,"line":289},[40,5103,4915],{"class":53},[40,5105,4261],{"class":57},[40,5107,4264],{"class":53},[40,5109,4267],{"class":131},[40,5111,2036],{"class":57},[40,5113,5114,5116,5118,5120,5122,5124,5126,5128,5130,5132,5134,5136,5139],{"class":42,"line":297},[40,5115,4931],{"class":57},[40,5117,4934],{"class":123},[40,5119,2396],{"class":57},[40,5121,4939],{"class":131},[40,5123,411],{"class":57},[40,5125,4649],{"class":123},[40,5127,2518],{"class":57},[40,5129,4677],{"class":123},[40,5131,4680],{"class":57},[40,5133,4952],{"class":127},[40,5135,2100],{"class":57},[40,5137,5138],{"class":127},"\"无效的认证令牌\"",[40,5140,2944],{"class":57},[40,5142,5143],{"class":42,"line":302},[40,5144,4964],{"class":53},[40,5146,5147],{"class":42,"line":311},[40,5148,4969],{"class":57},[40,5150,5151],{"class":42,"line":316},[40,5152,86],{"emptyLinePlaceholder":85},[40,5154,5155,5157,5160,5162,5165],{"class":42,"line":324},[40,5156,4660],{"class":57},[40,5158,5159],{"class":123},"Set",[40,5161,2396],{"class":57},[40,5163,5164],{"class":127},"\"userID\"",[40,5166,5167],{"class":57},", user.ID)\n",[40,5169,5170,5172,5174,5176,5179],{"class":42,"line":329},[40,5171,4660],{"class":57},[40,5173,5159],{"class":123},[40,5175,2396],{"class":57},[40,5177,5178],{"class":127},"\"username\"",[40,5180,5181],{"class":57},", user.Username)\n",[40,5183,5184,5186,5189],{"class":42,"line":344},[40,5185,4660],{"class":57},[40,5187,5188],{"class":123},"Next",[40,5190,2591],{"class":57},[40,5192,5193],{"class":42,"line":353},[40,5194,2356],{"class":57},[40,5196,5197],{"class":42,"line":358},[40,5198,1690],{"class":57},[30,5200,5202],{"className":3820,"code":5201,"language":631,"meta":35,"style":35},"\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",[37,5203,5204,5209,5220,5227,5240,5252,5256,5260,5278,5298,5312,5336,5358,5373,5381,5385],{"__ignoreMap":35},[40,5205,5206],{"class":42,"line":43},[40,5207,5208],{"class":46},"\u002F\u002F 用户信息桥接中间件：Gin Context → request.Context\n",[40,5210,5211,5214,5217],{"class":42,"line":50},[40,5212,5213],{"class":53},"type",[40,5215,5216],{"class":123}," ctxKey",[40,5218,5219],{"class":53}," string\n",[40,5221,5222,5224],{"class":42,"line":82},[40,5223,2630],{"class":53},[40,5225,5226],{"class":57}," (\n",[40,5228,5229,5232,5235,5237],{"class":42,"line":89},[40,5230,5231],{"class":131},"    ctxUserID",[40,5233,5234],{"class":123},"   ctxKey",[40,5236,2033],{"class":53},[40,5238,5239],{"class":127}," \"userID\"\n",[40,5241,5242,5245,5247,5249],{"class":42,"line":95},[40,5243,5244],{"class":131},"    ctxUsername",[40,5246,5216],{"class":123},[40,5248,2033],{"class":53},[40,5250,5251],{"class":127}," \"username\"\n",[40,5253,5254],{"class":42,"line":217},[40,5255,2706],{"class":57},[40,5257,5258],{"class":42,"line":225},[40,5259,86],{"emptyLinePlaceholder":85},[40,5261,5262,5264,5267,5270,5272,5274,5276],{"class":42,"line":230},[40,5263,4021],{"class":53},[40,5265,5266],{"class":123}," WithUserContext",[40,5268,5269],{"class":57},"() ",[40,5271,4649],{"class":123},[40,5273,2518],{"class":57},[40,5275,4867],{"class":123},[40,5277,2036],{"class":57},[40,5279,5280,5282,5284,5286,5288,5290,5292,5294,5296],{"class":42,"line":237},[40,5281,4078],{"class":53},[40,5283,4876],{"class":53},[40,5285,2396],{"class":57},[40,5287,4643],{"class":2399},[40,5289,4646],{"class":53},[40,5291,4649],{"class":123},[40,5293,2518],{"class":57},[40,5295,4034],{"class":123},[40,5297,3648],{"class":57},[40,5299,5300,5303,5305,5308,5310],{"class":42,"line":243},[40,5301,5302],{"class":57},"        ctx ",[40,5304,3847],{"class":53},[40,5306,5307],{"class":57}," c.Request.",[40,5309,4034],{"class":123},[40,5311,2591],{"class":57},[40,5313,5314,5316,5318,5321,5324,5327,5330,5332,5334],{"class":42,"line":251},[40,5315,5302],{"class":57},[40,5317,600],{"class":53},[40,5319,5320],{"class":57}," context.",[40,5322,5323],{"class":123},"WithValue",[40,5325,5326],{"class":57},"(ctx, ctxUserID, c.",[40,5328,5329],{"class":123},"GetUint",[40,5331,2396],{"class":57},[40,5333,5164],{"class":127},[40,5335,2559],{"class":57},[40,5337,5338,5340,5342,5344,5346,5349,5352,5354,5356],{"class":42,"line":257},[40,5339,5302],{"class":57},[40,5341,600],{"class":53},[40,5343,5320],{"class":57},[40,5345,5323],{"class":123},[40,5347,5348],{"class":57},"(ctx, ctxUsername, c.",[40,5350,5351],{"class":123},"GetString",[40,5353,2396],{"class":57},[40,5355,5178],{"class":127},[40,5357,2559],{"class":57},[40,5359,5360,5363,5365,5367,5370],{"class":42,"line":265},[40,5361,5362],{"class":57},"        c.Request ",[40,5364,600],{"class":53},[40,5366,5307],{"class":57},[40,5368,5369],{"class":123},"WithContext",[40,5371,5372],{"class":57},"(ctx)\n",[40,5374,5375,5377,5379],{"class":42,"line":270},[40,5376,4660],{"class":57},[40,5378,5188],{"class":123},[40,5380,2591],{"class":57},[40,5382,5383],{"class":42,"line":279},[40,5384,2356],{"class":57},[40,5386,5387],{"class":42,"line":284},[40,5388,1690],{"class":57},[22,5390,5391],{"id":5391},"组合使用",[30,5393,5395],{"className":3820,"code":5394,"language":631,"meta":35,"style":35},"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",[37,5396,5397,5409,5420,5431,5444],{"__ignoreMap":35},[40,5398,5399,5401,5403,5405,5407],{"class":42,"line":43},[40,5400,4416],{"class":57},[40,5402,4419],{"class":123},[40,5404,2396],{"class":57},[40,5406,4379],{"class":127},[40,5408,2047],{"class":57},[40,5410,5411,5414,5417],{"class":42,"line":50},[40,5412,5413],{"class":123},"    AuthRequired",[40,5415,5416],{"class":57},"(authService),  ",[40,5418,5419],{"class":46},"\u002F\u002F 1. 验证 Token\n",[40,5421,5422,5425,5428],{"class":42,"line":82},[40,5423,5424],{"class":123},"    WithUserContext",[40,5426,5427],{"class":57},"(),           ",[40,5429,5430],{"class":46},"\u002F\u002F 2. 桥接用户信息到 request.Context\n",[40,5432,5433,5436,5438,5441],{"class":42,"line":89},[40,5434,5435],{"class":57},"    gin.",[40,5437,4429],{"class":123},[40,5439,5440],{"class":57},"(mcpHTTP),         ",[40,5442,5443],{"class":46},"\u002F\u002F 3. MCP 处理\n",[40,5445,5446],{"class":42,"line":95},[40,5447,2706],{"class":57},[15,5449,5450],{},"在工具处理器里就能拿到用户信息了：",[30,5452,5454],{"className":3820,"code":5453,"language":631,"meta":35,"style":35},"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",[37,5455,5456,5511,5532,5541,5558,5562,5566,5582,5587,5600],{"__ignoreMap":35},[40,5457,5458,5460,5462,5465,5467,5470,5472,5475,5477,5479,5481,5483,5485,5487,5489,5491,5493,5495,5497,5499,5501,5503,5505,5507,5509],{"class":42,"line":43},[40,5459,4021],{"class":53},[40,5461,2438],{"class":57},[40,5463,5464],{"class":2399},"h ",[40,5466,4053],{"class":53},[40,5468,5469],{"class":123},"Handler",[40,5471,2860],{"class":57},[40,5473,5474],{"class":123},"handleListItems",[40,5476,2396],{"class":57},[40,5478,4026],{"class":2399},[40,5480,4029],{"class":123},[40,5482,2518],{"class":57},[40,5484,4034],{"class":123},[40,5486,411],{"class":57},[40,5488,4039],{"class":2399},[40,5490,4042],{"class":123},[40,5492,2518],{"class":57},[40,5494,4047],{"class":123},[40,5496,4050],{"class":57},[40,5498,4053],{"class":53},[40,5500,4056],{"class":123},[40,5502,2518],{"class":57},[40,5504,4061],{"class":123},[40,5506,411],{"class":57},[40,5508,4066],{"class":53},[40,5510,3648],{"class":57},[40,5512,5513,5516,5518,5521,5524,5527,5530],{"class":42,"line":50},[40,5514,5515],{"class":57},"    userID, ok ",[40,5517,3847],{"class":53},[40,5519,5520],{"class":57}," ctx.",[40,5522,5523],{"class":123},"Value",[40,5525,5526],{"class":57},"(ctxUserID).(",[40,5528,5529],{"class":53},"uint",[40,5531,2706],{"class":57},[40,5533,5534,5536,5538],{"class":42,"line":82},[40,5535,2473],{"class":53},[40,5537,5019],{"class":53},[40,5539,5540],{"class":57},"ok {\n",[40,5542,5543,5545,5547,5549,5551,5554,5556],{"class":42,"line":89},[40,5544,4274],{"class":53},[40,5546,3969],{"class":57},[40,5548,4279],{"class":123},[40,5550,2396],{"class":57},[40,5552,5553],{"class":127},"\"未认证\"",[40,5555,4091],{"class":57},[40,5557,4094],{"class":131},[40,5559,5560],{"class":42,"line":95},[40,5561,2356],{"class":57},[40,5563,5564],{"class":42,"line":217},[40,5565,86],{"emptyLinePlaceholder":85},[40,5567,5568,5571,5573,5576,5579],{"class":42,"line":225},[40,5569,5570],{"class":57},"    items, _ ",[40,5572,3847],{"class":53},[40,5574,5575],{"class":57}," h.service.",[40,5577,5578],{"class":123},"ListByUser",[40,5580,5581],{"class":57},"(userID)\n",[40,5583,5584],{"class":42,"line":230},[40,5585,5586],{"class":46},"    \u002F\u002F ...\n",[40,5588,5589,5591,5593,5595,5598],{"class":42,"line":237},[40,5590,4078],{"class":53},[40,5592,3969],{"class":57},[40,5594,4083],{"class":123},[40,5596,5597],{"class":57},"(result), ",[40,5599,4094],{"class":131},[40,5601,5602],{"class":42,"line":243},[40,5603,1690],{"class":57},[22,5605,5606],{"id":5606},"客户端配置",[15,5608,5609],{},"AI 客户端（如 Claude Desktop）配置 MCP 服务器时带上 Authorization 头：",[30,5611,5615],{"className":5612,"code":5613,"language":5614,"meta":35,"style":35},"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",[37,5616,5617,5622,5629,5635,5647,5654,5664,5669,5673,5677],{"__ignoreMap":35},[40,5618,5619],{"class":42,"line":43},[40,5620,5621],{"class":57},"{\n",[40,5623,5624,5627],{"class":42,"line":50},[40,5625,5626],{"class":131},"  \"mcpServers\"",[40,5628,2315],{"class":57},[40,5630,5631,5633],{"class":42,"line":82},[40,5632,3861],{"class":131},[40,5634,2315],{"class":57},[40,5636,5637,5640,5642,5645],{"class":42,"line":89},[40,5638,5639],{"class":131},"      \"url\"",[40,5641,2100],{"class":57},[40,5643,5644],{"class":127},"\"http:\u002F\u002Flocalhost:8080\u002Fmcp\"",[40,5646,2047],{"class":57},[40,5648,5649,5652],{"class":42,"line":95},[40,5650,5651],{"class":131},"      \"headers\"",[40,5653,2315],{"class":57},[40,5655,5656,5659,5661],{"class":42,"line":217},[40,5657,5658],{"class":131},"        \"Authorization\"",[40,5660,2100],{"class":57},[40,5662,5663],{"class":127},"\"Bearer your-token-here\"\n",[40,5665,5666],{"class":42,"line":225},[40,5667,5668],{"class":57},"      }\n",[40,5670,5671],{"class":42,"line":230},[40,5672,2356],{"class":57},[40,5674,5675],{"class":42,"line":237},[40,5676,2361],{"class":57},[40,5678,5679],{"class":42,"line":243},[40,5680,1690],{"class":57},[11,5682,424],{"id":424},[1720,5684,5685,5695],{},[1723,5686,5687],{},[1726,5688,5689,5692],{},[1729,5690,5691],{},"步骤",[1729,5693,5694],{},"说明",[1742,5696,5697,5706,5717,5726,5735],{},[1726,5698,5699,5703],{},[1747,5700,5701],{},[37,5702,3853],{},[1747,5704,5705],{},"创建 MCP 协议实例",[1726,5707,5708,5714],{},[1747,5709,5710,443,5712],{},[37,5711,3972],{},[37,5713,4015],{},[1747,5715,5716],{},"定义并注册工具",[1726,5718,5719,5723],{},[1747,5720,5721],{},[37,5722,4364],{},[1747,5724,5725],{},"转为 HTTP Handler",[1726,5727,5728,5732],{},[1747,5729,5730],{},[37,5731,4332],{},[1747,5733,5734],{},"挂载到 Gin 路由",[1726,5736,5737,5745],{},[1747,5738,5739,443,5742],{},[37,5740,5741],{},"AuthRequired",[37,5743,5744],{},"WithUserContext",[1747,5746,5747],{},"鉴权 + 用户信息桥接",[15,5749,5750,5751,5754,5755,2287],{},"核心就一句话：",[1635,5752,5753],{},"MCP 是 HTTP Handler，挂上去就行。"," 鉴权复用你已有的中间件体系，只需要多写一个桥接函数把用户信息传进 ",[37,5756,4831],{},[464,5758,5759],{},"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":35,"searchDepth":50,"depth":82,"links":5761},[5762,5763,5764,5765,5772,5778],{"id":3732,"depth":50,"text":3733},{"id":3742,"depth":50,"text":3743},{"id":3759,"depth":50,"text":3759},{"id":3785,"depth":50,"text":3785,"children":5766},[5767,5768,5769,5770,5771],{"id":3788,"depth":82,"text":3789},{"id":3816,"depth":82,"text":3817},{"id":3933,"depth":82,"text":3934},{"id":4321,"depth":82,"text":4322},{"id":4442,"depth":82,"text":4443},{"id":4804,"depth":50,"text":4805,"children":5773},[5774,5775,5776,5777],{"id":3372,"depth":82,"text":3372},{"id":4824,"depth":82,"text":4825},{"id":5391,"depth":82,"text":5391},{"id":5606,"depth":82,"text":5606},{"id":424,"depth":50,"text":424},"介绍如何使用 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":3727,"description":5779},"Go 工程实践","posts\u002Fgo-mcp-gin\u002Findex",[1274,5788,5789,5790,5791],"MCP","Gin","AI Agent","后端","2026-06-01","5fZl1Vigs2lHb5fHPz-0YZFyFD38ws1heOouZUG1g6M",{"id":5795,"title":5796,"author":6,"body":5797,"description":6564,"difficulty":6,"draft":479,"extension":480,"image":6565,"meta":6566,"navigation":85,"path":6567,"pinned":479,"published":6568,"seo":6569,"series":6,"seriesOrder":6,"stem":6570,"tags":6571,"updated":6,"__hash__":6575},"posts\u002Fposts\u002Fdws-dingtalk-cli\u002Findex.md","DWS — 钉钉官方出品的 AI 友好命令行工具",{"type":8,"value":5798,"toc":6550},[5799,5803,5806,5813,5939,5942,5945,6001,6004,6019,6022,6025,6032,6264,6271,6275,6278,6298,6301,6316,6319,6339,6342,6345,6352,6418,6421,6424,6428,6431,6483,6487,6508,6512,6515,6529,6532,6536,6539,6541,6544,6547],[11,5800,5802],{"id":5801},"dws-是什么","dws 是什么",[15,5804,5805],{},"dws（DingTalk Workspace CLI）是阿里巴巴 2026 年开源的钉钉命令行工具，Apache-2.0 协议，Go 语言编写。",[15,5807,5808,5809,5812],{},"它的定位很明确：",[1635,5810,5811],{},"把钉钉的所有产品能力暴露给 AI Agent 和命令行用户","。不用打开钉钉 App，不用写 API 调用代码，一条命令搞定。",[30,5814,5816],{"className":114,"code":5815,"language":116,"meta":35,"style":35},"# 查看今天有什么会\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",[37,5817,5818,5823,5849,5853,5858,5880,5884,5889,5914,5918,5923],{"__ignoreMap":35},[40,5819,5820],{"class":42,"line":43},[40,5821,5822],{"class":46},"# 查看今天有什么会\n",[40,5824,5825,5828,5831,5834,5837,5840,5843,5846],{"class":42,"line":50},[40,5826,5827],{"class":123},"dws",[40,5829,5830],{"class":127}," calendar",[40,5832,5833],{"class":127}," event",[40,5835,5836],{"class":127}," list",[40,5838,5839],{"class":131}," --start",[40,5841,5842],{"class":127}," 2026-06-01",[40,5844,5845],{"class":131}," --end",[40,5847,5848],{"class":127}," 2026-06-01\n",[40,5850,5851],{"class":42,"line":82},[40,5852,86],{"emptyLinePlaceholder":85},[40,5854,5855],{"class":42,"line":89},[40,5856,5857],{"class":46},"# 给群里发条消息\n",[40,5859,5860,5862,5865,5868,5871,5874,5877],{"class":42,"line":95},[40,5861,5827],{"class":123},[40,5863,5864],{"class":127}," chat",[40,5866,5867],{"class":127}," send",[40,5869,5870],{"class":131}," --group-id",[40,5872,5873],{"class":127}," xxx",[40,5875,5876],{"class":131}," --content",[40,5878,5879],{"class":127}," \"今天发布推迟到下午3点\"\n",[40,5881,5882],{"class":42,"line":217},[40,5883,86],{"emptyLinePlaceholder":85},[40,5885,5886],{"class":42,"line":225},[40,5887,5888],{"class":46},"# 创建一个待办\n",[40,5890,5891,5893,5896,5899,5902,5905,5908,5911],{"class":42,"line":230},[40,5892,5827],{"class":123},[40,5894,5895],{"class":127}," todo",[40,5897,5898],{"class":127}," task",[40,5900,5901],{"class":127}," create",[40,5903,5904],{"class":131}," --subject",[40,5906,5907],{"class":127}," \"Review PR #123\"",[40,5909,5910],{"class":131}," --due-date",[40,5912,5913],{"class":127}," 2026-06-02\n",[40,5915,5916],{"class":42,"line":237},[40,5917,86],{"emptyLinePlaceholder":85},[40,5919,5920],{"class":42,"line":243},[40,5921,5922],{"class":46},"# 读取钉钉文档\n",[40,5924,5925,5927,5930,5933,5936],{"class":42,"line":251},[40,5926,5827],{"class":123},[40,5928,5929],{"class":127}," doc",[40,5931,5932],{"class":127}," read",[40,5934,5935],{"class":131}," --url",[40,5937,5938],{"class":127}," \"https:\u002F\u002Falidocs.dingtalk.com\u002Fi\u002Fnodes\u002Fxxx\"\n",[11,5940,5941],{"id":5941},"安装",[15,5943,5944],{},"三种方式，选你喜欢的：",[30,5946,5948],{"className":114,"code":5947,"language":116,"meta":35,"style":35},"# 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",[37,5949,5950,5955,5968,5972,5977,5987,5991,5996],{"__ignoreMap":35},[40,5951,5952],{"class":42,"line":43},[40,5953,5954],{"class":46},"# npm（推荐，跨平台自动下载二进制）\n",[40,5956,5957,5960,5962,5965],{"class":42,"line":50},[40,5958,5959],{"class":123},"npm",[40,5961,577],{"class":127},[40,5963,5964],{"class":131}," -g",[40,5966,5967],{"class":127}," dingtalk-workspace-cli\n",[40,5969,5970],{"class":42,"line":82},[40,5971,86],{"emptyLinePlaceholder":85},[40,5973,5974],{"class":42,"line":89},[40,5975,5976],{"class":46},"# Homebrew（macOS\u002FLinux）\n",[40,5978,5979,5982,5984],{"class":42,"line":95},[40,5980,5981],{"class":123},"brew",[40,5983,577],{"class":127},[40,5985,5986],{"class":127}," dws\n",[40,5988,5989],{"class":42,"line":217},[40,5990,86],{"emptyLinePlaceholder":85},[40,5992,5993],{"class":42,"line":225},[40,5994,5995],{"class":46},"# 直接下载二进制\n",[40,5997,5998],{"class":42,"line":230},[40,5999,6000],{"class":46},"# https:\u002F\u002Fgithub.com\u002Fopen-dingtalk\u002Fdingtalk-workspace-cli\u002Freleases\n",[15,6002,6003],{},"安装后认证：",[30,6005,6007],{"className":114,"code":6006,"language":116,"meta":35,"style":35},"dws auth login\n",[37,6008,6009],{"__ignoreMap":35},[40,6010,6011,6013,6016],{"class":42,"line":43},[40,6012,5827],{"class":123},[40,6014,6015],{"class":127}," auth",[40,6017,6018],{"class":127}," login\n",[15,6020,6021],{},"会打开浏览器走 OAuth 设备流认证，token 加密存储在系统钥匙串里（PBKDF2 + AES-256-GCM）。",[11,6023,6024],{"id":6024},"覆盖的钉钉产品",[15,6026,6027,6028,6031],{},"这是最夸张的部分 — ",[1635,6029,6030],{},"19 个产品，213 个命令","，基本把钉钉全家桶都搬到了命令行：",[1720,6033,6034,6046],{},[1723,6035,6036],{},[1726,6037,6038,6041,6044],{},[1729,6039,6040],{},"产品",[1729,6042,6043],{},"命令数",[1729,6045,5694],{},[1742,6047,6048,6062,6076,6090,6104,6118,6132,6146,6159,6173,6186,6200,6214,6227,6240,6253],{},[1726,6049,6050,6056,6059],{},[1747,6051,6052,6053],{},"聊天 ",[37,6054,6055],{},"chat",[1747,6057,6058],{},"57",[1747,6060,6061],{},"消息收发、群管理、机器人、已读状态、Webhook",[1726,6063,6064,6070,6073],{},[1747,6065,6066,6067],{},"AI 表格 ",[37,6068,6069],{},"aitable",[1747,6071,6072],{},"42",[1747,6074,6075],{},"Base\u002F数据表\u002F字段\u002F记录\u002F视图\u002F图表\u002F仪表盘，完整 CRUD",[1726,6077,6078,6084,6087],{},[1747,6079,6080,6081],{},"电子表格 ",[37,6082,6083],{},"sheet",[1747,6085,6086],{},"34",[1747,6088,6089],{},"工作表读写、行列操作、合并、筛选视图、导出",[1726,6091,6092,6098,6101],{},[1747,6093,6094,6095],{},"文档 ",[37,6096,6097],{},"doc",[1747,6099,6100],{},"21",[1747,6102,6103],{},"搜索、读写、块级编辑、评论、复制移动",[1726,6105,6106,6112,6115],{},[1747,6107,6108,6109],{},"会议纪要 ",[37,6110,6111],{},"minutes",[1747,6113,6114],{},"19",[1747,6116,6117],{},"列表、摘要、转写、思维导图、发言人、热词",[1726,6119,6120,6126,6129],{},[1747,6121,6122,6123],{},"日历 ",[37,6124,6125],{},"calendar",[1747,6127,6128],{},"14",[1747,6130,6131],{},"事件 CRUD、会议室、忙闲查询、时间建议",[1726,6133,6134,6140,6143],{},[1747,6135,6136,6137],{},"审批 ",[37,6138,6139],{},"oa",[1747,6141,6142],{},"9",[1747,6144,6145],{},"待办、发起、同意\u002F拒绝\u002F撤销",[1726,6147,6148,6154,6156],{},[1747,6149,6150,6151],{},"云盘 ",[37,6152,6153],{},"drive",[1747,6155,6142],{},[1747,6157,6158],{},"列表、下载、上传、创建目录",[1726,6160,6161,6167,6170],{},[1747,6162,6163,6164],{},"知识库 ",[37,6165,6166],{},"wiki",[1747,6168,6169],{},"7",[1747,6171,6172],{},"空间管理、成员管理",[1726,6174,6175,6181,6183],{},[1747,6176,6177,6178],{},"汇报 ",[37,6179,6180],{},"report",[1747,6182,6169],{},[1747,6184,6185],{},"日志\u002F日报\u002F周报，按模板创建、统计",[1726,6187,6188,6194,6197],{},[1747,6189,6190,6191],{},"待办 ",[37,6192,6193],{},"todo",[1747,6195,6196],{},"6",[1747,6198,6199],{},"创建、完成、删除，支持优先级和循环",[1726,6201,6202,6208,6211],{},[1747,6203,6204,6205],{},"邮件 ",[37,6206,6207],{},"mail",[1747,6209,6210],{},"4",[1747,6212,6213],{},"邮箱查询、邮件搜索、发送",[1726,6215,6216,6222,6224],{},[1747,6217,6218,6219],{},"考勤 ",[37,6220,6221],{},"attendance",[1747,6223,6210],{},[1747,6225,6226],{},"打卡记录、排班、汇总",[1726,6228,6229,6235,6237],{},[1747,6230,6231,6232],{},"通讯录 ",[37,6233,6234],{},"contact",[1747,6236,2345],{},[1747,6238,6239],{},"用户搜索、部门查询",[1726,6241,6242,6248,6250],{},[1747,6243,6244,6245],{},"DING 消息 ",[37,6246,6247],{},"ding",[1747,6249,2582],{},[1747,6251,6252],{},"发送\u002F撤回（应用内、短信、电话）",[1726,6254,6255,6258,6261],{},[1747,6256,6257],{},"其他",[1747,6259,6260],{},"若干",[1747,6262,6263],{},"AI 搜索、AI 应用、直播、开放平台文档",[15,6265,6266,6267,6270],{},"还有一个万能命令 ",[37,6268,6269],{},"dws api","，可以直接调用任意钉钉 OpenAPI，不受这 213 个命令的限制。",[11,6272,6274],{"id":6273},"agent-skill-系统","Agent Skill 系统",[15,6276,6277],{},"dws 内置了一套完整的 Agent Skill，可以直接被 AI 编程工具发现和使用：",[1943,6279,6280,6286,6292],{},[432,6281,6282,6285],{},[1635,6283,6284],{},"Claude Code"," — 通过 SKILL.md 自动加载",[432,6287,6288,6291],{},[1635,6289,6290],{},"Cursor"," — 支持 MCP 协议接入",[432,6293,6294,6297],{},[1635,6295,6296],{},"Codex \u002F OpenCode \u002F Qoder"," — 通过 skill 安装命令接入",[15,6299,6300],{},"安装 skill：",[30,6302,6304],{"className":114,"code":6303,"language":116,"meta":35,"style":35},"dws skill setup\n",[37,6305,6306],{"__ignoreMap":35},[40,6307,6308,6310,6313],{"class":42,"line":43},[40,6309,5827],{"class":123},[40,6311,6312],{"class":127}," skill",[40,6314,6315],{"class":127}," setup\n",[15,6317,6318],{},"SKILL.md 里定义了：",[1943,6320,6321,6330,6333,6336],{},[432,6322,6323,6324,6326,6327,6329],{},"16 个产品的意图判断决策树（用户说\"表格\"→ ",[37,6325,6069],{},"，说\"打卡\"→ ",[37,6328,6221],{},"）",[432,6331,6332],{},"危险操作确认流程（删除、撤回等必须先确认）",[432,6334,6335],{},"错误处理规范（verbose 重试、recovery 事件 ID）",[432,6337,6338],{},"URL 分流规则（alidocs.dingtalk.com 的多种路径格式自动识别）",[15,6340,6341],{},"Agent 读到这份 SKILL.md 就知道什么时候该调什么命令，不需要额外的 prompt 工程。",[11,6343,6344],{"id":6344},"自动升级",[15,6346,6347,6348,6351],{},"内置 ",[37,6349,6350],{},"dws upgrade","，不需要重新下载安装包：",[30,6353,6355],{"className":114,"code":6354,"language":116,"meta":35,"style":35},"dws upgrade              # 交互式升级到最新版\ndws upgrade --check      # 仅检查是否有新版本\ndws upgrade --list       # 列出最近版本\ndws upgrade --version v1.0.5  # 升级到指定版本\ndws upgrade --rollback   # 回滚到上一版本\n",[37,6356,6357,6367,6379,6391,6406],{"__ignoreMap":35},[40,6358,6359,6361,6364],{"class":42,"line":43},[40,6360,5827],{"class":123},[40,6362,6363],{"class":127}," upgrade",[40,6365,6366],{"class":46},"              # 交互式升级到最新版\n",[40,6368,6369,6371,6373,6376],{"class":42,"line":50},[40,6370,5827],{"class":123},[40,6372,6363],{"class":127},[40,6374,6375],{"class":131}," --check",[40,6377,6378],{"class":46},"      # 仅检查是否有新版本\n",[40,6380,6381,6383,6385,6388],{"class":42,"line":82},[40,6382,5827],{"class":123},[40,6384,6363],{"class":127},[40,6386,6387],{"class":131}," --list",[40,6389,6390],{"class":46},"       # 列出最近版本\n",[40,6392,6393,6395,6397,6400,6403],{"class":42,"line":89},[40,6394,5827],{"class":123},[40,6396,6363],{"class":127},[40,6398,6399],{"class":131}," --version",[40,6401,6402],{"class":127}," v1.0.5",[40,6404,6405],{"class":46},"  # 升级到指定版本\n",[40,6407,6408,6410,6412,6415],{"class":42,"line":95},[40,6409,5827],{"class":123},[40,6411,6363],{"class":127},[40,6413,6414],{"class":131}," --rollback",[40,6416,6417],{"class":46},"   # 回滚到上一版本\n",[15,6419,6420],{},"升级流程：下载 → SHA256 校验 → 备份当前版本 → 原子替换 → 更新 Skill。失败可一键回滚。",[11,6422,6423],{"id":6423},"亮点",[22,6425,6427],{"id":6426},"_1-为-ai-agent-专门优化","1. 为 AI Agent 专门优化",[15,6429,6430],{},"这可能是目前对 AI 最友好的 CLI 工具：",[1943,6432,6433,6454,6464,6473],{},[432,6434,6435,6438,6439,6442,6443,6446,6447,6442,6450,6453],{},[1635,6436,6437],{},"智能纠错"," — AI 模型经常生成错误的参数格式（",[37,6440,6441],{},"--userId"," 应该是 ",[37,6444,6445],{},"--user-id","，",[37,6448,6449],{},"--limit100",[37,6451,6452],{},"--limit 100","），dws 的 5 阶段 Pipeline 会自动修正",[432,6455,6456,6459,6460,6463],{},[1635,6457,6458],{},"Schema 发现"," — ",[37,6461,6462],{},"dws schema"," 让 Agent 动态查询可用工具和参数，不需要硬编码",[432,6465,6466,6459,6469,6472],{},[1635,6467,6468],{},"jq 过滤",[37,6470,6471],{},"--jq '.data[] | {name, id}'"," 直接过滤输出，减少 Agent 消耗的 token",[432,6474,6475,6478,6479,6482],{},[1635,6476,6477],{},"JSON 输出"," — 所有命令支持 ",[37,6480,6481],{},"--format json","，方便程序解析",[22,6484,6486],{"id":6485},"_2-安全设计","2. 安全设计",[1943,6488,6489,6492,6495,6498,6505],{},[432,6490,6491],{},"token 用 PBKDF2 + AES-256-GCM 加密，密钥绑定设备 MAC 地址",[432,6493,6494],{},"域名白名单，只能访问钉钉官方 API",[432,6496,6497],{},"强制 HTTPS",[432,6499,6500,6501,6504],{},"写操作必须显式 ",[37,6502,6503],{},"--yes"," 确认",[432,6506,6507],{},"完整审计链",[22,6509,6511],{"id":6510},"_3-发现驱动架构","3. 发现驱动架构",[15,6513,6514],{},"不是把所有命令硬编码在代码里，而是：",[429,6516,6517,6520,6523,6526],{},[432,6518,6519],{},"从钉钉 MCP 服务器注册中心拉取元数据",[432,6521,6522],{},"运行时发现服务端能力",[432,6524,6525],{},"转换成统一的中间表示（IR）",[432,6527,6528],{},"动态挂载到命令树",[15,6530,6531],{},"这意味着钉钉新增产品能力时，dws 不需要发版就能支持。",[22,6533,6535],{"id":6534},"_4-多平台分发","4. 多平台分发",[15,6537,6538],{},"GoReleaser 交叉编译 + Homebrew formula + npm 包 + shell\u002FPowerShell 安装脚本，覆盖 Windows、macOS、Linux。",[11,6540,424],{"id":424},[15,6542,6543],{},"dws 把钉钉从一个\"必须打开 App 才能用\"的工具变成了一个\"命令行 + AI Agent 随时调用\"的平台。213 个命令覆盖日常办公的方方面面，Agent Skill 系统让 AI 编程工具可以直接理解和使用这些能力。",[15,6545,6546],{},"如果你是钉钉重度用户，或者在做企业内部的 AI Agent 集成，dws 值得一试。",[464,6548,6549],{},"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":35,"searchDepth":50,"depth":82,"links":6551},[6552,6553,6554,6555,6556,6557,6563],{"id":5801,"depth":50,"text":5802},{"id":5941,"depth":50,"text":5941},{"id":6024,"depth":50,"text":6024},{"id":6273,"depth":50,"text":6274},{"id":6344,"depth":50,"text":6344},{"id":6423,"depth":50,"text":6423,"children":6558},[6559,6560,6561,6562],{"id":6426,"depth":82,"text":6427},{"id":6485,"depth":82,"text":6486},{"id":6510,"depth":82,"text":6511},{"id":6534,"depth":82,"text":6535},{"id":424,"depth":50,"text":424},"介绍阿里巴巴官方的 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":5796,"description":6564},"posts\u002Fdws-dingtalk-cli\u002Findex",[6572,6573,5790,1274,6574],"钉钉","CLI","效率工具","4ca65VUZ2Sz9yG8dNtftrBDDHE6QowA3ed9g4egQkKI",{"id":6577,"title":6578,"author":6,"body":6579,"description":7542,"difficulty":1266,"draft":479,"extension":480,"image":7543,"meta":7544,"navigation":85,"path":7545,"pinned":479,"published":7546,"seo":7547,"series":5785,"seriesOrder":50,"stem":7548,"tags":7549,"updated":7553,"__hash__":7554},"posts\u002Fposts\u002Fgo-embed-frontend\u002Findex.md","Go 单文件打包实战：把前端塞进二进制",{"type":8,"value":6580,"toc":7525},[6581,6583,6590,6593,6596,6607,6610,6614,6947,6951,7059,7063,7197,7200,7204,7213,7230,7241,7260,7274,7280,7288,7387,7393,7397,7404,7413,7416,7452,7455,7460,7499,7501,7507,7519,7522],[11,6582,2263],{"id":2263},[15,6584,6585,6586,6589],{},"一个 Gin + Vue 3 的全栈项目，之前的发布方式是：一个 Go 二进制 + 一个 ",[37,6587,6588],{},"web\u002F"," 前端静态目录，必须放一起才能运行。",[15,6591,6592],{},"为了让部署更丝滑——一个 exe 拖哪都能跑——决定把前端构建产物嵌入到 Go 二进制里。",[11,6594,6595],{"id":6595},"核心思路",[15,6597,6598,6599,6602,6603,6606],{},"利用 Go 1.16 引入的 ",[37,6600,6601],{},"\u002F\u002Fgo:embed"," 指令，在编译时将前端 ",[37,6604,6605],{},"dist\u002F"," 目录嵌入二进制，运行时通过嵌入的文件系统提供 SPA 服务。",[11,6608,6609],{"id":6609},"逐步实现",[22,6611,6613],{"id":6612},"_1-创建嵌入模块","1. 创建嵌入模块",[30,6615,6617],{"className":3820,"code":6616,"language":631,"meta":35,"style":35},"\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",[37,6618,6619,6624,6632,6636,6642,6652,6661,6670,6679,6688,6697,6701,6710,6714,6718,6723,6738,6742,6759,6780,6784,6804,6824,6837,6847,6851,6855,6870,6882,6887,6906,6910,6914,6937,6942],{"__ignoreMap":35},[40,6620,6621],{"class":42,"line":43},[40,6622,6623],{"class":46},"\u002F\u002F service\u002Fweb\u002Fembed.go\n",[40,6625,6626,6629],{"class":42,"line":50},[40,6627,6628],{"class":53},"package",[40,6630,6631],{"class":123}," web\n",[40,6633,6634],{"class":42,"line":82},[40,6635,86],{"emptyLinePlaceholder":85},[40,6637,6638,6640],{"class":42,"line":89},[40,6639,3828],{"class":53},[40,6641,5226],{"class":57},[40,6643,6644,6647,6650],{"class":42,"line":95},[40,6645,6646],{"class":127},"    \"",[40,6648,6649],{"class":123},"embed",[40,6651,727],{"class":127},[40,6653,6654,6656,6659],{"class":42,"line":217},[40,6655,6646],{"class":127},[40,6657,6658],{"class":123},"io\u002Ffs",[40,6660,727],{"class":127},[40,6662,6663,6665,6668],{"class":42,"line":225},[40,6664,6646],{"class":127},[40,6666,6667],{"class":123},"mime",[40,6669,727],{"class":127},[40,6671,6672,6674,6677],{"class":42,"line":230},[40,6673,6646],{"class":127},[40,6675,6676],{"class":123},"net\u002Fhttp",[40,6678,727],{"class":127},[40,6680,6681,6683,6686],{"class":42,"line":237},[40,6682,6646],{"class":127},[40,6684,6685],{"class":123},"path\u002Ffilepath",[40,6687,727],{"class":127},[40,6689,6690,6692,6695],{"class":42,"line":243},[40,6691,6646],{"class":127},[40,6693,6694],{"class":123},"strings",[40,6696,727],{"class":127},[40,6698,6699],{"class":42,"line":251},[40,6700,86],{"emptyLinePlaceholder":85},[40,6702,6703,6705,6708],{"class":42,"line":257},[40,6704,6646],{"class":127},[40,6706,6707],{"class":123},"github.com\u002Fgin-gonic\u002Fgin",[40,6709,727],{"class":127},[40,6711,6712],{"class":42,"line":265},[40,6713,2706],{"class":57},[40,6715,6716],{"class":42,"line":270},[40,6717,86],{"emptyLinePlaceholder":85},[40,6719,6720],{"class":42,"line":279},[40,6721,6722],{"class":46},"\u002F\u002Fgo:embed dist\n",[40,6724,6725,6728,6731,6733,6735],{"class":42,"line":284},[40,6726,6727],{"class":53},"var",[40,6729,6730],{"class":57}," embedFS ",[40,6732,6649],{"class":123},[40,6734,2518],{"class":57},[40,6736,6737],{"class":123},"FS\n",[40,6739,6740],{"class":42,"line":289},[40,6741,86],{"emptyLinePlaceholder":85},[40,6743,6744,6746,6749,6751,6753,6755,6757],{"class":42,"line":297},[40,6745,4021],{"class":53},[40,6747,6748],{"class":123}," SPAHandler",[40,6750,5269],{"class":57},[40,6752,4649],{"class":123},[40,6754,2518],{"class":57},[40,6756,4867],{"class":123},[40,6758,2036],{"class":57},[40,6760,6761,6764,6766,6769,6772,6775,6778],{"class":42,"line":302},[40,6762,6763],{"class":57},"    sub, _ ",[40,6765,3847],{"class":53},[40,6767,6768],{"class":57}," fs.",[40,6770,6771],{"class":123},"Sub",[40,6773,6774],{"class":57},"(embedFS, ",[40,6776,6777],{"class":127},"\"dist\"",[40,6779,2706],{"class":57},[40,6781,6782],{"class":42,"line":311},[40,6783,86],{"emptyLinePlaceholder":85},[40,6785,6786,6788,6790,6792,6794,6796,6798,6800,6802],{"class":42,"line":316},[40,6787,4078],{"class":53},[40,6789,4876],{"class":53},[40,6791,2396],{"class":57},[40,6793,4643],{"class":2399},[40,6795,4646],{"class":53},[40,6797,4649],{"class":123},[40,6799,2518],{"class":57},[40,6801,4034],{"class":123},[40,6803,3648],{"class":57},[40,6805,6806,6809,6811,6813,6816,6819,6822],{"class":42,"line":324},[40,6807,6808],{"class":57},"        p ",[40,6810,3847],{"class":53},[40,6812,4983],{"class":57},[40,6814,6815],{"class":123},"TrimPrefix",[40,6817,6818],{"class":57},"(c.Request.URL.Path, ",[40,6820,6821],{"class":127},"\"\u002F\"",[40,6823,2706],{"class":57},[40,6825,6826,6828,6831,6833,6835],{"class":42,"line":329},[40,6827,4915],{"class":53},[40,6829,6830],{"class":57}," p ",[40,6832,4921],{"class":53},[40,6834,4924],{"class":127},[40,6836,2036],{"class":57},[40,6838,6839,6842,6844],{"class":42,"line":344},[40,6840,6841],{"class":57},"            p ",[40,6843,600],{"class":53},[40,6845,6846],{"class":127}," \"index.html\"\n",[40,6848,6849],{"class":42,"line":353},[40,6850,4969],{"class":57},[40,6852,6853],{"class":42,"line":358},[40,6854,86],{"emptyLinePlaceholder":85},[40,6856,6857,6860,6862,6864,6867],{"class":42,"line":364},[40,6858,6859],{"class":57},"        data, err ",[40,6861,3847],{"class":53},[40,6863,6768],{"class":57},[40,6865,6866],{"class":123},"ReadFile",[40,6868,6869],{"class":57},"(sub, p)\n",[40,6871,6872,6874,6876,6878,6880],{"class":42,"line":372},[40,6873,4915],{"class":53},[40,6875,4261],{"class":57},[40,6877,4264],{"class":53},[40,6879,4267],{"class":131},[40,6881,2036],{"class":57},[40,6883,6884],{"class":42,"line":380},[40,6885,6886],{"class":46},"            \u002F\u002F SPA fallback：文件不存在则返回 index.html\n",[40,6888,6889,6892,6894,6896,6898,6901,6904],{"class":42,"line":385},[40,6890,6891],{"class":57},"            data, _ ",[40,6893,600],{"class":53},[40,6895,6768],{"class":57},[40,6897,6866],{"class":123},[40,6899,6900],{"class":57},"(sub, ",[40,6902,6903],{"class":127},"\"index.html\"",[40,6905,2706],{"class":57},[40,6907,6908],{"class":42,"line":391},[40,6909,4969],{"class":57},[40,6911,6912],{"class":42,"line":400},[40,6913,86],{"emptyLinePlaceholder":85},[40,6915,6917,6919,6922,6925,6928,6931,6934],{"class":42,"line":6916},33,[40,6918,4660],{"class":57},[40,6920,6921],{"class":123},"Data",[40,6923,6924],{"class":57},"(http.StatusOK, mime.",[40,6926,6927],{"class":123},"TypeByExtension",[40,6929,6930],{"class":57},"(filepath.",[40,6932,6933],{"class":123},"Ext",[40,6935,6936],{"class":57},"(p)), data)\n",[40,6938,6940],{"class":42,"line":6939},34,[40,6941,2356],{"class":57},[40,6943,6945],{"class":42,"line":6944},35,[40,6946,1690],{"class":57},[22,6948,6950],{"id":6949},"_2-改造路由","2. 改造路由",[30,6952,6954],{"className":3820,"code":6953,"language":631,"meta":35,"style":35},"\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",[37,6955,6956,6961,6972,6976,6981,6996,7010,7023,7036,7049],{"__ignoreMap":35},[40,6957,6958],{"class":42,"line":43},[40,6959,6960],{"class":46},"\u002F\u002F router.go\n",[40,6962,6963,6965,6967,6970],{"class":42,"line":50},[40,6964,3828],{"class":53},[40,6966,534],{"class":127},[40,6968,6969],{"class":123},"myproject\u002Fweb",[40,6971,727],{"class":127},[40,6973,6974],{"class":42,"line":82},[40,6975,86],{"emptyLinePlaceholder":85},[40,6977,6978],{"class":42,"line":89},[40,6979,6980],{"class":46},"\u002F\u002F 替换原来的磁盘静态文件服务\n",[40,6982,6983,6986,6988,6991,6994],{"class":42,"line":95},[40,6984,6985],{"class":57},"webHandler ",[40,6987,3847],{"class":53},[40,6989,6990],{"class":57}," web.",[40,6992,6993],{"class":123},"SPAHandler",[40,6995,2591],{"class":57},[40,6997,6998,7001,7003,7005,7007],{"class":42,"line":217},[40,6999,7000],{"class":57},"router.",[40,7002,4629],{"class":123},[40,7004,2396],{"class":57},[40,7006,6821],{"class":127},[40,7008,7009],{"class":57},", webHandler)\n",[40,7011,7012,7014,7016,7018,7021],{"class":42,"line":225},[40,7013,7000],{"class":57},[40,7015,4629],{"class":123},[40,7017,2396],{"class":57},[40,7019,7020],{"class":127},"\"\u002Fassets\u002F*filepath\"",[40,7022,7009],{"class":57},[40,7024,7025,7027,7029,7031,7034],{"class":42,"line":230},[40,7026,7000],{"class":57},[40,7028,4629],{"class":123},[40,7030,2396],{"class":57},[40,7032,7033],{"class":127},"\"\u002Ffavicon.ico\"",[40,7035,7009],{"class":57},[40,7037,7038,7040,7042,7044,7047],{"class":42,"line":237},[40,7039,7000],{"class":57},[40,7041,4629],{"class":123},[40,7043,2396],{"class":57},[40,7045,7046],{"class":127},"\"\u002Ffavicon.svg\"",[40,7048,7009],{"class":57},[40,7050,7051,7053,7056],{"class":42,"line":243},[40,7052,7000],{"class":57},[40,7054,7055],{"class":123},"NoRoute",[40,7057,7058],{"class":57},"(webHandler)\n",[22,7060,7062],{"id":7061},"_3-一键构建脚本","3. 一键构建脚本",[30,7064,7066],{"className":114,"code":7065,"language":116,"meta":35,"style":35},"#!\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",[37,7067,7068,7073,7078,7082,7090,7094,7099,7109,7119,7123,7128,7139,7152,7156,7161,7169,7182,7192],{"__ignoreMap":35},[40,7069,7070],{"class":42,"line":43},[40,7071,7072],{"class":46},"#!\u002Fbin\u002Fbash\n",[40,7074,7075],{"class":42,"line":50},[40,7076,7077],{"class":46},"# build-standalone.sh\n",[40,7079,7080],{"class":42,"line":82},[40,7081,86],{"emptyLinePlaceholder":85},[40,7083,7084,7087],{"class":42,"line":89},[40,7085,7086],{"class":131},"set",[40,7088,7089],{"class":131}," -e\n",[40,7091,7092],{"class":42,"line":95},[40,7093,86],{"emptyLinePlaceholder":85},[40,7095,7096],{"class":42,"line":217},[40,7097,7098],{"class":46},"# 1. 构建前端\n",[40,7100,7101,7104,7106],{"class":42,"line":225},[40,7102,7103],{"class":123},"pnpm",[40,7105,577],{"class":127},[40,7107,7108],{"class":131}," --no-frozen-lockfile\n",[40,7110,7111,7113,7116],{"class":42,"line":230},[40,7112,2161],{"class":123},[40,7114,7115],{"class":127}," vite",[40,7117,7118],{"class":127}," build\n",[40,7120,7121],{"class":42,"line":237},[40,7122,86],{"emptyLinePlaceholder":85},[40,7124,7125],{"class":42,"line":243},[40,7126,7127],{"class":46},"# 2. 复制到嵌入目录\n",[40,7129,7130,7133,7136],{"class":42,"line":251},[40,7131,7132],{"class":123},"rm",[40,7134,7135],{"class":131}," -rf",[40,7137,7138],{"class":127}," service\u002Fweb\u002Fdist\n",[40,7140,7141,7144,7147,7150],{"class":42,"line":257},[40,7142,7143],{"class":123},"cp",[40,7145,7146],{"class":131}," -r",[40,7148,7149],{"class":127}," dist",[40,7151,7138],{"class":127},[40,7153,7154],{"class":42,"line":265},[40,7155,86],{"emptyLinePlaceholder":85},[40,7157,7158],{"class":42,"line":270},[40,7159,7160],{"class":46},"# 3. 编译 Go 二进制\n",[40,7162,7163,7166],{"class":42,"line":279},[40,7164,7165],{"class":131},"cd",[40,7167,7168],{"class":127}," service\n",[40,7170,7171,7173,7175,7177,7180],{"class":42,"line":284},[40,7172,631],{"class":123},[40,7174,646],{"class":127},[40,7176,655],{"class":131},[40,7178,7179],{"class":127}," myapp.exe",[40,7181,4747],{"class":131},[40,7183,7184,7187,7190],{"class":42,"line":289},[40,7185,7186],{"class":131},"    -ldflags=",[40,7188,7189],{"class":127},"\"-X myproject\u002Fglobal.RUNCODE=release\"",[40,7191,4747],{"class":131},[40,7193,7194],{"class":42,"line":297},[40,7195,7196],{"class":127},"    main.go\n",[11,7198,7199],{"id":7199},"踩坑记录",[22,7201,7203],{"id":7202},"坑一路由不匹配静态资源-404","坑一：路由不匹配，静态资源 404",[15,7205,7206,7207,7209,7210,7212],{},"首次实现时用了 Gin 的 ",[37,7208,7055],{}," 兜底，但部分静态资源返回 404。排查发现 ",[37,7211,7055],{}," 在某些路由未显式注册时不会按预期触发。",[15,7214,7215,7218,7219,7222,7223,7226,7227,7229],{},[1635,7216,7217],{},"解决","：显式注册 ",[37,7220,7221],{},"\u002Fassets\u002F*filepath","、",[37,7224,7225],{},"\u002Ffavicon.ico"," 等路由，",[37,7228,7055],{}," 仅作为兜底。",[22,7231,7233,7234,7236,7237,7240],{"id":7232},"坑二goembed-排除-_-开头文件","坑二：",[37,7235,6601],{}," 排除 ",[37,7238,7239],{},"_"," 开头文件",[15,7242,7243,7244,7247,7248,7250,7251,2287],{},"这是最坑的。Vite 构建产出了一个 ",[37,7245,7246],{},"_plugin-vue_export-helper-xxx.js","，Go 的 ",[37,7249,6601],{}," 当模式为目录时，",[1635,7252,7253,7254,7256,7257,7259],{},"硬编码排除了 ",[37,7255,7239],{}," 和 ",[37,7258,2518],{}," 开头的文件",[30,7261,7263],{"className":3820,"code":7262,"language":631,"meta":35,"style":35},"\u002F\u002F 这行会跳过 dist\u002Fassets\u002F_plugin-xxx.js\n\u002F\u002Fgo:embed dist\n",[37,7264,7265,7270],{"__ignoreMap":35},[40,7266,7267],{"class":42,"line":43},[40,7268,7269],{"class":46},"\u002F\u002F 这行会跳过 dist\u002Fassets\u002F_plugin-xxx.js\n",[40,7271,7272],{"class":42,"line":50},[40,7273,6722],{"class":46},[15,7275,7276,7277,2287],{},"错误表现：JS 文件返回的是 HTML 内容（SPA fallback），浏览器报 ",[37,7278,7279],{},"Unexpected token '\u003C'",[15,7281,7282,7284,7285,7287],{},[1635,7283,7217],{},"：改 Vite 配置，去除了 ",[37,7286,7239],{}," 前缀：",[30,7289,7293],{"className":7290,"code":7291,"language":7292,"meta":35,"style":35},"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",[37,7294,7295,7300,7307,7314,7321,7329,7367,7372,7377,7382],{"__ignoreMap":35},[40,7296,7297],{"class":42,"line":43},[40,7298,7299],{"class":46},"\u002F\u002F vite.config.ts\n",[40,7301,7302,7305],{"class":42,"line":50},[40,7303,7304],{"class":123},"build",[40,7306,2315],{"class":57},[40,7308,7309,7312],{"class":42,"line":82},[40,7310,7311],{"class":123},"  rollupOptions",[40,7313,2315],{"class":57},[40,7315,7316,7319],{"class":42,"line":89},[40,7317,7318],{"class":123},"    output",[40,7320,2315],{"class":57},[40,7322,7323,7326],{"class":42,"line":95},[40,7324,7325],{"class":123},"      chunkFileNames",[40,7327,7328],{"class":57},"(chunkInfo) {\n",[40,7330,7331,7333,7336,7339,7341,7344,7346,7348,7350,7352,7354,7356,7358,7360,7362,7364],{"class":42,"line":217},[40,7332,4274],{"class":53},[40,7334,7335],{"class":127}," `assets\u002F${",[40,7337,7338],{"class":57},"chunkInfo",[40,7340,2518],{"class":127},[40,7342,7343],{"class":57},"name",[40,7345,2518],{"class":127},[40,7347,2896],{"class":123},[40,7349,2396],{"class":127},[40,7351,2515],{"class":127},[40,7353,2502],{"class":53},[40,7355,7239],{"class":2505},[40,7357,2515],{"class":127},[40,7359,411],{"class":127},[40,7361,2913],{"class":127},[40,7363,3217],{"class":127},[40,7365,7366],{"class":127},"}-[hash].js`\n",[40,7368,7369],{"class":42,"line":225},[40,7370,7371],{"class":57},"      },\n",[40,7373,7374],{"class":42,"line":230},[40,7375,7376],{"class":57},"    },\n",[40,7378,7379],{"class":42,"line":237},[40,7380,7381],{"class":57},"  },\n",[40,7383,7384],{"class":42,"line":243},[40,7385,7386],{"class":57},"},\n",[15,7388,7389,7390,7392],{},"这样就避免产生 ",[37,7391,7239],{}," 前缀文件名的同时，保持构建产物的一致性和引用关系的正确性。",[22,7394,7396],{"id":7395},"坑三构建缓存导致-embed-内容未更新","坑三：构建缓存导致 embed 内容未更新",[15,7398,7399,7400,7403],{},"多次 ",[37,7401,7402],{},"go build"," 后，Go 的构建缓存可能使用了旧版本的 embed 内容，导致实际运行的文件不是最新的。",[15,7405,7406,7408,7409,7412],{},[1635,7407,7217],{},"：使用 ",[37,7410,7411],{},"go build -a"," 强制重新编译所有包。",[11,7414,7415],{"id":7415},"最终效果",[30,7417,7419],{"className":114,"code":7418,"language":116,"meta":35,"style":35},"$ bash build-standalone.sh\n=== 构建成功 ===\n输出: service\u002Fmyapp.exe  41M\n",[37,7420,7421,7431,7441],{"__ignoreMap":35},[40,7422,7423,7425,7428],{"class":42,"line":43},[40,7424,2512],{"class":123},[40,7426,7427],{"class":127}," bash",[40,7429,7430],{"class":127}," build-standalone.sh\n",[40,7432,7433,7435,7438],{"class":42,"line":50},[40,7434,2490],{"class":127},[40,7436,7437],{"class":127}," 构建成功",[40,7439,7440],{"class":127}," ===\n",[40,7442,7443,7446,7449],{"class":42,"line":82},[40,7444,7445],{"class":123},"输出:",[40,7447,7448],{"class":127}," service\u002Fmyapp.exe",[40,7450,7451],{"class":127},"  41M\n",[15,7453,7454],{},"一个 41MB 的 exe，内置前端页面、API 后端、SQLite 数据库（运行时初始化），拖到哪都能跑。",[15,7456,7457],{},[1635,7458,7459],{},"部署对比：",[1720,7461,7462,7475],{},[1723,7463,7464],{},[1726,7465,7466,7469,7472],{},[1729,7467,7468],{},"方式",[1729,7470,7471],{},"文件数",[1729,7473,7474],{},"操作",[1742,7476,7477,7488],{},[1726,7478,7479,7482,7485],{},[1747,7480,7481],{},"旧方案",[1747,7483,7484],{},"1 exe + 1 web 目录",[1747,7486,7487],{},"解压→保持目录结构",[1726,7489,7490,7493,7496],{},[1747,7491,7492],{},"新方案",[1747,7494,7495],{},"1 个 exe",[1747,7497,7498],{},"直接扔到服务器运行",[11,7500,424],{"id":424},[15,7502,7503,7504,7506],{},"Go 的 ",[37,7505,6601],{}," 是一个强大的工具，但有两个隐藏行为需要注意：",[429,7508,7509,7516],{},[432,7510,7511,7512,7256,7514,7259],{},"嵌入目录时自动排除 ",[37,7513,7239],{},[37,7515,2518],{},[432,7517,7518],{},"构建缓存可能导致 embed 不是最新的",[15,7520,7521],{},"对于前端+后端合体的场景，这是性价比最高的方案——不需要额外的打包工具，纯标准库搞定。",[464,7523,7524],{},"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":35,"searchDepth":50,"depth":82,"links":7526},[7527,7528,7529,7534,7540,7541],{"id":2263,"depth":50,"text":2263},{"id":6595,"depth":50,"text":6595},{"id":6609,"depth":50,"text":6609,"children":7530},[7531,7532,7533],{"id":6612,"depth":82,"text":6613},{"id":6949,"depth":82,"text":6950},{"id":7061,"depth":82,"text":7062},{"id":7199,"depth":50,"text":7199,"children":7535},[7536,7537,7539],{"id":7202,"depth":82,"text":7203},{"id":7232,"depth":82,"text":7538},"坑二：\u002F\u002Fgo:embed 排除 _ 开头文件",{"id":7395,"depth":82,"text":7396},{"id":7415,"depth":50,"text":7415},{"id":424,"depth":50,"text":424},"记录将 Vue 3 前端嵌入 Go 二进制实现单 exe 发布的完整流程，以及遇到的坑和解决方案。","\u002Fposts\u002Fgo-embed-frontend\u002Fimg\u002Fcover.jpg",{},"\u002Fposts\u002Fgo-embed-frontend","2026-06-06T16:00:00",{"title":6578,"description":7542},"posts\u002Fgo-embed-frontend\u002Findex",[1274,7550,7551,7552],"Vue","编译","埋坑","2026-06-06","SIQNL24wVAFb18wIGWSB3lNVZUQ9B76jWgQ55TASTYk",{"id":7556,"title":7557,"author":6,"body":7558,"description":8954,"difficulty":478,"draft":479,"extension":480,"image":8955,"meta":8956,"navigation":85,"path":8957,"pinned":479,"published":8958,"seo":8959,"series":6,"seriesOrder":6,"stem":8960,"tags":8961,"updated":8958,"__hash__":8963},"posts\u002Fposts\u002Fjira-cli-intro\u002Findex.md","jira_cli — 把企业 Jira 装进命令行，AI Agent 直接调用",{"type":8,"value":7559,"toc":8933},[7560,7564,7579,7646,7649,7652,7729,7731,7764,7771,7774,7778,7890,7894,8025,8029,8097,8100,8163,8166,8168,8172,8175,8195,8198,8213,8216,8220,8226,8364,8375,8406,8410,8413,8510,8514,8517,8635,8639,8642,8706,8709,8713,8716,8784,8787,8793,8796,8830,8837,8840,8900,8902,8905,8922,8930],[11,7561,7563],{"id":7562},"jira_cli-是什么","jira_cli 是什么",[15,7565,7566,7571,7572,7256,7575,7578],{},[2216,7567,7570],{"href":7568,"rel":7569},"https:\u002F\u002Fgithub.com\u002Fyourname\u002Fjira_cli",[2220],"jira_cli"," 是一个面向企业 Jira（Server \u002F Data Center）的本地命令行工具，Go 语言编写，MIT 协议开源。它同时提供 ",[1635,7573,7574],{},"Cobra CLI",[1635,7576,7577],{},"MCP stdio server"," 两种入口，既能当日常命令行用，又能直接接入 AI 编程工具。",[30,7580,7582],{"className":114,"code":7581,"language":116,"meta":35,"style":35},"# 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",[37,7583,7584,7589,7602,7613,7628,7632,7637],{"__ignoreMap":35},[40,7585,7586],{"class":42,"line":43},[40,7587,7588],{"class":46},"# CLI 日常使用\n",[40,7590,7591,7593,7596,7599],{"class":42,"line":50},[40,7592,7570],{"class":123},[40,7594,7595],{"class":127}," issue",[40,7597,7598],{"class":127}," search",[40,7600,7601],{"class":127}," \"project = PROJ ORDER BY updated DESC\"\n",[40,7603,7604,7606,7608,7610],{"class":42,"line":82},[40,7605,7570],{"class":123},[40,7607,7595],{"class":127},[40,7609,3801],{"class":127},[40,7611,7612],{"class":127}," PROJ-123\n",[40,7614,7615,7617,7619,7622,7625],{"class":42,"line":89},[40,7616,7570],{"class":123},[40,7618,7595],{"class":127},[40,7620,7621],{"class":127}," comment",[40,7623,7624],{"class":127}," PROJ-123",[40,7626,7627],{"class":127}," \"处理完成，请测试验证\"\n",[40,7629,7630],{"class":42,"line":95},[40,7631,86],{"emptyLinePlaceholder":85},[40,7633,7634],{"class":42,"line":217},[40,7635,7636],{"class":46},"# MCP 模式 — 给 AI 装上操作 Jira 的手\n",[40,7638,7639,7641,7643],{"class":42,"line":225},[40,7640,7570],{"class":123},[40,7642,4042],{"class":127},[40,7644,7645],{"class":131}," --user=work\n",[11,7647,7648],{"id":7648},"为什么需要它",[15,7650,7651],{},"大多数 Jira 命令行工具只解决\"开发者手动操作 Jira\"这一个场景。但 2026 年 AI 编程工具已经普及，你的 AI Agent（Claude Code、Codex、Cursor 等）也需要读写 Jira。痛点就来了：",[1720,7653,7654,7664],{},[1723,7655,7656],{},[1726,7657,7658,7661],{},[1729,7659,7660],{},"痛点",[1729,7662,7663],{},"jira_cli 怎么解",[1742,7665,7666,7674,7682,7694,7706,7717],{},[1726,7667,7668,7671],{},[1747,7669,7670],{},"企业 Jira 在内网，没有公网 API",[1747,7672,7673],{},"本地 stdio MCP server，不经过外网",[1726,7675,7676,7679],{},[1747,7677,7678],{},"AI 可能误删、误写 Jira 数据",[1747,7680,7681],{},"账号级只读策略，AI 只能查不能改",[1726,7683,7684,7687],{},[1747,7685,7686],{},"团队多人多 Jira 实例",[1747,7688,7689,7690,7693],{},"多 Profile 管理，",[37,7691,7692],{},"--user=work"," 切换",[1726,7695,7696,7699],{},[1747,7697,7698],{},"Windows 终端体验差",[1747,7700,7701,7702,7705],{},"原生 ",[37,7703,7704],{},".exe"," 构建 + 一键安装脚本",[1726,7707,7708,7711],{},[1747,7709,7710],{},"AI 生成 JQL 困难",[1747,7712,7713,7714],{},"字段元数据发现：",[37,7715,7716],{},"field list --query xxx",[1726,7718,7719,7722],{},[1747,7720,7721],{},"MCP 直接拿原始 JSON 太冗长",[1747,7723,7724,7725,7728],{},"紧凑视图 + ",[37,7726,7727],{},"--view summary"," 减少 token 消耗",[11,7730,5941],{"id":5941},[30,7732,7734],{"className":114,"code":7733,"language":116,"meta":35,"style":35},"# 从源码安装（要求 Go 1.26.3+）\ngo install github.com\u002Fyourname\u002Fjira_cli@latest\n\n# 或下载预编译二进制\n# https:\u002F\u002Fgithub.com\u002Fyourname\u002Fjira_cli\u002Freleases\n",[37,7735,7736,7741,7750,7754,7759],{"__ignoreMap":35},[40,7737,7738],{"class":42,"line":43},[40,7739,7740],{"class":46},"# 从源码安装（要求 Go 1.26.3+）\n",[40,7742,7743,7745,7747],{"class":42,"line":50},[40,7744,631],{"class":123},[40,7746,577],{"class":127},[40,7748,7749],{"class":127}," github.com\u002Fyourname\u002Fjira_cli@latest\n",[40,7751,7752],{"class":42,"line":82},[40,7753,86],{"emptyLinePlaceholder":85},[40,7755,7756],{"class":42,"line":89},[40,7757,7758],{"class":46},"# 或下载预编译二进制\n",[40,7760,7761],{"class":42,"line":95},[40,7762,7763],{"class":46},"# https:\u002F\u002Fgithub.com\u002Fyourname\u002Fjira_cli\u002Freleases\n",[15,7765,7766,7767,7770],{},"Windows 用户下载 zip 包解压后双击 ",[37,7768,7769],{},"install.bat"," 即可全局安装，自动加入 PATH。",[11,7772,7773],{"id":7773},"快速上手",[22,7775,7777],{"id":7776},"_1-配置认证","1. 配置认证",[30,7779,7781],{"className":114,"code":7780,"language":116,"meta":35,"style":35},"# 设置 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",[37,7782,7783,7788,7804,7808,7813,7833,7837,7842,7871,7875,7880],{"__ignoreMap":35},[40,7784,7785],{"class":42,"line":43},[40,7786,7787],{"class":46},"# 设置 Jira 地址\n",[40,7789,7790,7792,7795,7798,7801],{"class":42,"line":50},[40,7791,7570],{"class":123},[40,7793,7794],{"class":127}," set",[40,7796,7797],{"class":127}," base-url",[40,7799,7800],{"class":127}," https:\u002F\u002Fjira.your-company.com",[40,7802,7803],{"class":131}," --global\n",[40,7805,7806],{"class":42,"line":82},[40,7807,86],{"emptyLinePlaceholder":85},[40,7809,7810],{"class":42,"line":89},[40,7811,7812],{"class":46},"# Token 认证（推荐）\n",[40,7814,7815,7817,7819,7822,7824,7827,7829,7831],{"class":42,"line":95},[40,7816,7570],{"class":123},[40,7818,7794],{"class":127},[40,7820,7821],{"class":127}," token",[40,7823,138],{"class":53},[40,7825,7826],{"class":127},"your-toke",[40,7828,169],{"class":57},[40,7830,147],{"class":53},[40,7832,7803],{"class":131},[40,7834,7835],{"class":42,"line":217},[40,7836,86],{"emptyLinePlaceholder":85},[40,7838,7839],{"class":42,"line":225},[40,7840,7841],{"class":46},"# 或用户名密码认证\n",[40,7843,7844,7846,7848,7850,7852,7855,7857,7859,7861,7864,7867,7869],{"class":42,"line":230},[40,7845,7570],{"class":123},[40,7847,7794],{"class":127},[40,7849,6015],{"class":127},[40,7851,138],{"class":53},[40,7853,7854],{"class":127},"usernam",[40,7856,155],{"class":57},[40,7858,147],{"class":53},[40,7860,138],{"class":53},[40,7862,7863],{"class":127},"passwor",[40,7865,7866],{"class":57},"d",[40,7868,147],{"class":53},[40,7870,7803],{"class":131},[40,7872,7873],{"class":42,"line":237},[40,7874,86],{"emptyLinePlaceholder":85},[40,7876,7877],{"class":42,"line":243},[40,7878,7879],{"class":46},"# 验证一下\n",[40,7881,7882,7884,7887],{"class":42,"line":251},[40,7883,7570],{"class":123},[40,7885,7886],{"class":127}," user",[40,7888,7889],{"class":127}," me\n",[22,7891,7893],{"id":7892},"_2-日常使用","2. 日常使用",[30,7895,7897],{"className":114,"code":7896,"language":116,"meta":35,"style":35},"# 查自己今天的任务\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",[37,7898,7899,7904,7915,7919,7924,7934,7938,7943,7957,7974,7978,7983,7996,8000,8005],{"__ignoreMap":35},[40,7900,7901],{"class":42,"line":43},[40,7902,7903],{"class":46},"# 查自己今天的任务\n",[40,7905,7906,7908,7910,7912],{"class":42,"line":50},[40,7907,7570],{"class":123},[40,7909,7595],{"class":127},[40,7911,7598],{"class":127},[40,7913,7914],{"class":127}," \"assignee = currentUser() ORDER BY updated DESC\"\n",[40,7916,7917],{"class":42,"line":82},[40,7918,86],{"emptyLinePlaceholder":85},[40,7920,7921],{"class":42,"line":89},[40,7922,7923],{"class":46},"# 看某个任务详情\n",[40,7925,7926,7928,7930,7932],{"class":42,"line":95},[40,7927,7570],{"class":123},[40,7929,7595],{"class":127},[40,7931,3801],{"class":127},[40,7933,7612],{"class":127},[40,7935,7936],{"class":42,"line":217},[40,7937,86],{"emptyLinePlaceholder":85},[40,7939,7940],{"class":42,"line":225},[40,7941,7942],{"class":46},"# 流转状态\n",[40,7944,7945,7947,7949,7952,7954],{"class":42,"line":230},[40,7946,7570],{"class":123},[40,7948,7595],{"class":127},[40,7950,7951],{"class":127}," transitions",[40,7953,7624],{"class":127},[40,7955,7956],{"class":46},"                    # 可以转哪些\n",[40,7958,7959,7961,7963,7966,7968,7971],{"class":42,"line":237},[40,7960,7570],{"class":123},[40,7962,7595],{"class":127},[40,7964,7965],{"class":127}," do-transition",[40,7967,7624],{"class":127},[40,7969,7970],{"class":131}," 21",[40,7972,7973],{"class":46},"               # 执行流转\n",[40,7975,7976],{"class":42,"line":243},[40,7977,86],{"emptyLinePlaceholder":85},[40,7979,7980],{"class":42,"line":251},[40,7981,7982],{"class":46},"# 添加备注\n",[40,7984,7985,7987,7989,7991,7993],{"class":42,"line":257},[40,7986,7570],{"class":123},[40,7988,7595],{"class":127},[40,7990,7621],{"class":127},[40,7992,7624],{"class":127},[40,7994,7995],{"class":127}," \"已定位到问题，正在修复\"\n",[40,7997,7998],{"class":42,"line":265},[40,7999,86],{"emptyLinePlaceholder":85},[40,8001,8002],{"class":42,"line":270},[40,8003,8004],{"class":46},"# 创建任务\n",[40,8006,8007,8009,8011,8013,8016,8019,8022],{"class":42,"line":279},[40,8008,7570],{"class":123},[40,8010,7595],{"class":127},[40,8012,5901],{"class":127},[40,8014,8015],{"class":127}," PROJ",[40,8017,8018],{"class":127}," Task",[40,8020,8021],{"class":131}," --fields",[40,8023,8024],{"class":127}," @fields.json\n",[22,8026,8028],{"id":8027},"_3-配给-ai-使用","3. 配给 AI 使用",[30,8030,8032],{"className":114,"code":8031,"language":116,"meta":35,"style":35},"# 先配置一个只读账号给 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",[37,8033,8034,8039,8056,8079],{"__ignoreMap":35},[40,8035,8036],{"class":42,"line":43},[40,8037,8038],{"class":46},"# 先配置一个只读账号给 AI 用\n",[40,8040,8041,8043,8045,8047,8049,8051,8054],{"class":42,"line":50},[40,8042,7570],{"class":123},[40,8044,7794],{"class":127},[40,8046,7797],{"class":127},[40,8048,7800],{"class":127},[40,8050,1010],{"class":131},[40,8052,8053],{"class":127}," ai",[40,8055,7803],{"class":131},[40,8057,8058,8060,8062,8064,8066,8069,8071,8073,8075,8077],{"class":42,"line":82},[40,8059,7570],{"class":123},[40,8061,7794],{"class":127},[40,8063,7821],{"class":127},[40,8065,138],{"class":53},[40,8067,8068],{"class":127},"toke",[40,8070,169],{"class":57},[40,8072,147],{"class":53},[40,8074,1010],{"class":131},[40,8076,8053],{"class":127},[40,8078,7803],{"class":131},[40,8080,8081,8083,8085,8088,8091,8093,8095],{"class":42,"line":89},[40,8082,7570],{"class":123},[40,8084,7794],{"class":127},[40,8086,8087],{"class":127}," readonly",[40,8089,8090],{"class":127}," on",[40,8092,1010],{"class":131},[40,8094,8053],{"class":127},[40,8096,7803],{"class":131},[15,8098,8099],{},"然后在 AI 工具的 MCP 配置里加上：",[30,8101,8103],{"className":5612,"code":8102,"language":5614,"meta":35,"style":35},"{\n  \"mcpServers\": {\n    \"jira\": {\n      \"command\": \"jira_cli\",\n      \"args\": [\"mcp\", \"--user=ai\"]\n    }\n  }\n}\n",[37,8104,8105,8109,8115,8122,8134,8151,8155,8159],{"__ignoreMap":35},[40,8106,8107],{"class":42,"line":43},[40,8108,5621],{"class":57},[40,8110,8111,8113],{"class":42,"line":50},[40,8112,5626],{"class":131},[40,8114,2315],{"class":57},[40,8116,8117,8120],{"class":42,"line":82},[40,8118,8119],{"class":131},"    \"jira\"",[40,8121,2315],{"class":57},[40,8123,8124,8127,8129,8132],{"class":42,"line":89},[40,8125,8126],{"class":131},"      \"command\"",[40,8128,2100],{"class":57},[40,8130,8131],{"class":127},"\"jira_cli\"",[40,8133,2047],{"class":57},[40,8135,8136,8139,8141,8144,8146,8149],{"class":42,"line":95},[40,8137,8138],{"class":131},"      \"args\"",[40,8140,2131],{"class":57},[40,8142,8143],{"class":127},"\"mcp\"",[40,8145,411],{"class":57},[40,8147,8148],{"class":127},"\"--user=ai\"",[40,8150,935],{"class":57},[40,8152,8153],{"class":42,"line":217},[40,8154,2356],{"class":57},[40,8156,8157],{"class":42,"line":225},[40,8158,2361],{"class":57},[40,8160,8161],{"class":42,"line":230},[40,8162,1690],{"class":57},[15,8164,8165],{},"这样 AI 就能查 Jira 但不能修改，既有了上下文，又不用担心误操作。",[11,8167,6423],{"id":6423},[22,8169,8171],{"id":8170},"_1-账号级只读策略","1. 账号级只读策略",[15,8173,8174],{},"这是 jira_cli 最实用的设计。给 AI Agent 配置只读账号：",[30,8176,8178],{"className":114,"code":8177,"language":116,"meta":35,"style":35},"jira_cli set readonly on --name ai\n",[37,8179,8180],{"__ignoreMap":35},[40,8181,8182,8184,8186,8188,8190,8192],{"class":42,"line":43},[40,8183,7570],{"class":123},[40,8185,7794],{"class":127},[40,8187,8087],{"class":127},[40,8189,8090],{"class":127},[40,8191,1010],{"class":131},[40,8193,8194],{"class":127}," ai\n",[15,8196,8197],{},"只读模式下：",[1943,8199,8200,8203,8206],{},[432,8201,8202],{},"CLI 写命令被隐藏和拒绝",[432,8204,8205],{},"MCP 的写入工具不会注册",[432,8207,8208,8209,8212],{},"环境变量 ",[37,8210,8211],{},"JIRA_READONLY=true"," 也可以临时覆盖",[15,8214,8215],{},"AI 只能查 JQL、看任务、查字段、读评论，绝对不会误改数据。需要创建\u002F更新时，另配一个有写入权限的账号由开发者自己操作。",[22,8217,8219],{"id":8218},"_2-多-profile-切换","2. 多 Profile 切换",[15,8221,8222,8223,8225],{},"一个配置文件里管理多个 Jira 账号，",[37,8224,110],{}," 切换：",[30,8227,8229],{"className":114,"code":8228,"language":116,"meta":35,"style":35},"# 工作账号\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",[37,8230,8231,8236,8254,8277,8281,8286,8304,8327,8331,8336,8350],{"__ignoreMap":35},[40,8232,8233],{"class":42,"line":43},[40,8234,8235],{"class":46},"# 工作账号\n",[40,8237,8238,8240,8242,8244,8247,8249,8252],{"class":42,"line":50},[40,8239,7570],{"class":123},[40,8241,7794],{"class":127},[40,8243,7797],{"class":127},[40,8245,8246],{"class":127}," https:\u002F\u002Fjira.company.com",[40,8248,1010],{"class":131},[40,8250,8251],{"class":127}," work",[40,8253,7803],{"class":131},[40,8255,8256,8258,8260,8262,8264,8267,8269,8271,8273,8275],{"class":42,"line":82},[40,8257,7570],{"class":123},[40,8259,7794],{"class":127},[40,8261,7821],{"class":127},[40,8263,138],{"class":53},[40,8265,8266],{"class":127},"work-toke",[40,8268,169],{"class":57},[40,8270,147],{"class":53},[40,8272,1010],{"class":131},[40,8274,8251],{"class":127},[40,8276,7803],{"class":131},[40,8278,8279],{"class":42,"line":89},[40,8280,86],{"emptyLinePlaceholder":85},[40,8282,8283],{"class":42,"line":95},[40,8284,8285],{"class":46},"# 个人账号\n",[40,8287,8288,8290,8292,8294,8297,8299,8302],{"class":42,"line":217},[40,8289,7570],{"class":123},[40,8291,7794],{"class":127},[40,8293,7797],{"class":127},[40,8295,8296],{"class":127}," https:\u002F\u002Fjira.personal.com",[40,8298,1010],{"class":131},[40,8300,8301],{"class":127}," personal",[40,8303,7803],{"class":131},[40,8305,8306,8308,8310,8312,8314,8317,8319,8321,8323,8325],{"class":42,"line":225},[40,8307,7570],{"class":123},[40,8309,7794],{"class":127},[40,8311,7821],{"class":127},[40,8313,138],{"class":53},[40,8315,8316],{"class":127},"personal-toke",[40,8318,169],{"class":57},[40,8320,147],{"class":53},[40,8322,1010],{"class":131},[40,8324,8301],{"class":127},[40,8326,7803],{"class":131},[40,8328,8329],{"class":42,"line":230},[40,8330,86],{"emptyLinePlaceholder":85},[40,8332,8333],{"class":42,"line":237},[40,8334,8335],{"class":46},"# 使用时切换\n",[40,8337,8338,8340,8343,8345,8347],{"class":42,"line":243},[40,8339,7570],{"class":123},[40,8341,8342],{"class":131}," --user=work",[40,8344,7595],{"class":127},[40,8346,7598],{"class":127},[40,8348,8349],{"class":127}," \"project = PROJ\"\n",[40,8351,8352,8354,8357,8359,8361],{"class":42,"line":251},[40,8353,7570],{"class":123},[40,8355,8356],{"class":131}," --user=personal",[40,8358,7595],{"class":127},[40,8360,7598],{"class":127},[40,8362,8363],{"class":127}," \"project = SIDE\"\n",[15,8365,8366,8367,8370,8371,8374],{},"同时支持",[1635,8368,8369],{},"项目级配置","（",[37,8372,8373],{},"--local","）和工作目录绑定：",[30,8376,8378],{"className":114,"code":8377,"language":116,"meta":35,"style":35},"# 在项目目录下，配置只对当前项目生效\ncd ~\u002Fprojects\u002Fbackend\njira_cli set base-url https:\u002F\u002Fjira-backend.company.com --local\n",[37,8379,8380,8385,8392],{"__ignoreMap":35},[40,8381,8382],{"class":42,"line":43},[40,8383,8384],{"class":46},"# 在项目目录下，配置只对当前项目生效\n",[40,8386,8387,8389],{"class":42,"line":50},[40,8388,7165],{"class":131},[40,8390,8391],{"class":127}," ~\u002Fprojects\u002Fbackend\n",[40,8393,8394,8396,8398,8400,8403],{"class":42,"line":82},[40,8395,7570],{"class":123},[40,8397,7794],{"class":127},[40,8399,7797],{"class":127},[40,8401,8402],{"class":127}," https:\u002F\u002Fjira-backend.company.com",[40,8404,8405],{"class":131}," --local\n",[22,8407,8409],{"id":8408},"_3-对-ai-友好的输出","3. 对 AI 友好的输出",[15,8411,8412],{},"jira_cli 在输出设计上充分考虑了 AI Agent 的 token 消耗：",[30,8414,8416],{"className":114,"code":8415,"language":116,"meta":35,"style":35},"# 紧凑视图 — 去掉 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",[37,8417,8418,8423,8439,8443,8448,8465,8469,8474,8489,8493,8498],{"__ignoreMap":35},[40,8419,8420],{"class":42,"line":43},[40,8421,8422],{"class":46},"# 紧凑视图 — 去掉 self\u002Favatar\u002F缩略图等无用字段\n",[40,8424,8425,8427,8429,8431,8433,8436],{"class":42,"line":50},[40,8426,7570],{"class":123},[40,8428,7595],{"class":127},[40,8430,3801],{"class":127},[40,8432,7624],{"class":127},[40,8434,8435],{"class":131}," --view",[40,8437,8438],{"class":127}," summary\n",[40,8440,8441],{"class":42,"line":82},[40,8442,86],{"emptyLinePlaceholder":85},[40,8444,8445],{"class":42,"line":89},[40,8446,8447],{"class":46},"# 表格输出 — 一眼看到关键信息\n",[40,8449,8450,8452,8454,8456,8459,8462],{"class":42,"line":95},[40,8451,7570],{"class":123},[40,8453,7595],{"class":127},[40,8455,7598],{"class":127},[40,8457,8458],{"class":127}," \"project = PROJ\"",[40,8460,8461],{"class":131}," --output",[40,8463,8464],{"class":127}," table\n",[40,8466,8467],{"class":42,"line":217},[40,8468,86],{"emptyLinePlaceholder":85},[40,8470,8471],{"class":42,"line":225},[40,8472,8473],{"class":46},"# JSON 输出 — 程序解析，不转义 Unicode\n",[40,8475,8476,8478,8480,8483,8485,8487],{"class":42,"line":230},[40,8477,7570],{"class":123},[40,8479,8461],{"class":131},[40,8481,8482],{"class":127}," json",[40,8484,7595],{"class":127},[40,8486,3801],{"class":127},[40,8488,7612],{"class":127},[40,8490,8491],{"class":42,"line":237},[40,8492,86],{"emptyLinePlaceholder":85},[40,8494,8495],{"class":42,"line":243},[40,8496,8497],{"class":46},"# 自定义搜索列 — 只要需要的字段\n",[40,8499,8500,8502,8504,8507],{"class":42,"line":251},[40,8501,7570],{"class":123},[40,8503,7794],{"class":127},[40,8505,8506],{"class":127}," search-columns",[40,8508,8509],{"class":127}," key,summary,fixVersions\n",[22,8511,8513],{"id":8512},"_4-字段元数据发现","4. 字段元数据发现",[15,8515,8516],{},"Jira 的 customfield 让人头疼，jira_cli 提供了完整的字段发现能力：",[30,8518,8520],{"className":114,"code":8519,"language":116,"meta":35,"style":35},"# 列出所有字段及其类型\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",[37,8521,8522,8527,8536,8540,8545,8559,8563,8568,8579,8583,8588,8605,8609,8614],{"__ignoreMap":35},[40,8523,8524],{"class":42,"line":43},[40,8525,8526],{"class":46},"# 列出所有字段及其类型\n",[40,8528,8529,8531,8534],{"class":42,"line":50},[40,8530,7570],{"class":123},[40,8532,8533],{"class":127}," field",[40,8535,2450],{"class":127},[40,8537,8538],{"class":42,"line":82},[40,8539,86],{"emptyLinePlaceholder":85},[40,8541,8542],{"class":42,"line":89},[40,8543,8544],{"class":46},"# 搜索包含特定关键词的字段\n",[40,8546,8547,8549,8551,8553,8556],{"class":42,"line":95},[40,8548,7570],{"class":123},[40,8550,8533],{"class":127},[40,8552,5836],{"class":127},[40,8554,8555],{"class":131}," --query",[40,8557,8558],{"class":127}," \"版本\"\n",[40,8560,8561],{"class":42,"line":217},[40,8562,86],{"emptyLinePlaceholder":85},[40,8564,8565],{"class":42,"line":225},[40,8566,8567],{"class":46},"# 只看自定义字段\n",[40,8569,8570,8572,8574,8576],{"class":42,"line":230},[40,8571,7570],{"class":123},[40,8573,8533],{"class":127},[40,8575,5836],{"class":127},[40,8577,8578],{"class":131}," --custom-only\n",[40,8580,8581],{"class":42,"line":237},[40,8582,86],{"emptyLinePlaceholder":85},[40,8584,8585],{"class":42,"line":243},[40,8586,8587],{"class":46},"# 创建任务前，先看看需要哪些字段\n",[40,8589,8590,8592,8594,8597,8599,8602],{"class":42,"line":251},[40,8591,7570],{"class":123},[40,8593,7595],{"class":127},[40,8595,8596],{"class":127}," create-meta",[40,8598,8015],{"class":127},[40,8600,8601],{"class":131}," --issue-type",[40,8603,8604],{"class":127}," Task\n",[40,8606,8607],{"class":42,"line":257},[40,8608,86],{"emptyLinePlaceholder":85},[40,8610,8611],{"class":42,"line":265},[40,8612,8613],{"class":46},"# 分页查看可选值\n",[40,8615,8616,8618,8620,8623,8625,8627,8630,8632],{"class":42,"line":270},[40,8617,7570],{"class":123},[40,8619,7595],{"class":127},[40,8621,8622],{"class":127}," create-meta-values",[40,8624,8015],{"class":127},[40,8626,8018],{"class":127},[40,8628,8629],{"class":127}," priority",[40,8631,8555],{"class":131},[40,8633,8634],{"class":127}," High\n",[22,8636,8638],{"id":8637},"_5-mcp-写入控制","5. MCP 写入控制",[15,8640,8641],{},"除了全局只读，MCP 还有更细粒度的控制：",[1720,8643,8644,8654],{},[1723,8645,8646],{},[1726,8647,8648,8651],{},[1729,8649,8650],{},"环境变量",[1729,8652,8653],{},"作用",[1742,8655,8656,8666,8676,8686,8696],{},[1726,8657,8658,8663],{},[1747,8659,8660],{},[37,8661,8662],{},"JIRA_CREATE_OPEN=false",[1747,8664,8665],{},"禁用创建\u002F更新工具",[1726,8667,8668,8673],{},[1747,8669,8670],{},[37,8671,8672],{},"JIRA_DISABLE_DISCLAIMER=true",[1747,8674,8675],{},"去掉 AI 自动添加的备注声明",[1726,8677,8678,8683],{},[1747,8679,8680],{},[37,8681,8682],{},"JIRA_CREATE_SUMMARY_MAX",[1747,8684,8685],{},"限制摘要长度",[1726,8687,8688,8693],{},[1747,8689,8690],{},[37,8691,8692],{},"JIRA_CREATE_DESCRIPTION_MAX",[1747,8694,8695],{},"限制描述长度",[1726,8697,8698,8703],{},[1747,8699,8700],{},[37,8701,8702],{},"JIRA_CREATE_FIELDS_MAX_BYTES",[1747,8704,8705],{},"限制创建字段的 JSON 大小",[15,8707,8708],{},"这些限制确保 AI 即使有写入权限也不会\"失控\"——例如防止 AI 往描述里塞几万字的废话。",[22,8710,8712],{"id":8711},"_6-中英双语帮助","6. 中英双语帮助",[15,8714,8715],{},"帮助信息默认中文，也支持一键切英文：",[30,8717,8719],{"className":114,"code":8718,"language":116,"meta":35,"style":35},"# 保存英文偏好\njira_cli set help-language en\n\n# 切回中文\njira_cli set help-language zh\n\n# 临时覆盖\njira_cli --lang en issue search -h\n",[37,8720,8721,8726,8738,8742,8747,8758,8762,8767],{"__ignoreMap":35},[40,8722,8723],{"class":42,"line":43},[40,8724,8725],{"class":46},"# 保存英文偏好\n",[40,8727,8728,8730,8732,8735],{"class":42,"line":50},[40,8729,7570],{"class":123},[40,8731,7794],{"class":127},[40,8733,8734],{"class":127}," help-language",[40,8736,8737],{"class":127}," en\n",[40,8739,8740],{"class":42,"line":82},[40,8741,86],{"emptyLinePlaceholder":85},[40,8743,8744],{"class":42,"line":89},[40,8745,8746],{"class":46},"# 切回中文\n",[40,8748,8749,8751,8753,8755],{"class":42,"line":95},[40,8750,7570],{"class":123},[40,8752,7794],{"class":127},[40,8754,8734],{"class":127},[40,8756,8757],{"class":127}," zh\n",[40,8759,8760],{"class":42,"line":217},[40,8761,86],{"emptyLinePlaceholder":85},[40,8763,8764],{"class":42,"line":225},[40,8765,8766],{"class":46},"# 临时覆盖\n",[40,8768,8769,8771,8774,8777,8779,8781],{"class":42,"line":230},[40,8770,7570],{"class":123},[40,8772,8773],{"class":131}," --lang",[40,8775,8776],{"class":127}," en",[40,8778,7595],{"class":127},[40,8780,7598],{"class":127},[40,8782,8783],{"class":131}," -h\n",[11,8785,8786],{"id":8786},"架构一览",[30,8788,8791],{"className":8789,"code":8790,"language":1706},[1704],"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",[37,8792,8790],{"__ignoreMap":35},[15,8794,8795],{},"设计原则清晰：",[1943,8797,8798,8806,8814,8822],{},[432,8799,8800,8805],{},[1635,8801,8802],{},[37,8803,8804],{},"pkg\u002Fjira"," 是纯 Jira 客户端，HTTP、认证、上下文处理都在这里",[432,8807,8808,8813],{},[1635,8809,8810],{},[37,8811,8812],{},"cmd"," 只管 Cobra 解析和呈现",[432,8815,8816,8821],{},[1635,8817,8818],{},[37,8819,8820],{},"internal\u002Ftools\u002Fjira_tools"," 只管 MCP schema 验证和结果适配",[432,8823,8824,8829],{},[1635,8825,8826],{},[37,8827,8828],{},"internal\u002Fconfig"," 拥有 Profile 的全部逻辑，不分散到 cmd",[15,8831,8832,8833,8836],{},"每一层职责单一，新增 Jira 能力时按 ",[37,8834,8835],{},"endpoints.go → Client 方法 → CLI 命令 → MCP tool → 策略注册"," 的路径走就行。",[11,8838,8839],{"id":8839},"和已有方案的对比",[1720,8841,8842,8854],{},[1723,8843,8844],{},[1726,8845,8846,8848,8851],{},[1729,8847,3075],{},[1729,8849,8850],{},"适合场景",[1729,8852,8853],{},"jira_cli 的区别",[1742,8855,8856,8867,8878,8889],{},[1726,8857,8858,8861,8864],{},[1747,8859,8860],{},"Atlassian Rovo MCP Server",[1747,8862,8863],{},"Jira Cloud、官方 OAuth",[1747,8865,8866],{},"jira_cli 是本地 stdio，适合内网 Server\u002FDC",[1726,8868,8869,8872,8875],{},[1747,8870,8871],{},"go-jira \u002F jira-cli 社区版",[1747,8873,8874],{},"纯命令行管理",[1747,8876,8877],{},"jira_cli 同时提供 MCP server，面向 AI 工作流",[1726,8879,8880,8883,8886],{},[1747,8881,8882],{},"直接调 REST API",[1747,8884,8885],{},"自定义集成",[1747,8887,8888],{},"jira_cli 封装了认证、只读策略、字段元数据",[1726,8890,8891,8894,8897],{},[1747,8892,8893],{},"Jira 网页",[1747,8895,8896],{},"日常使用",[1747,8898,8899],{},"jira_cli 把 Jira 变成终端里的一行命令",[11,8901,424],{"id":424},[15,8903,8904],{},"如果你的团队满足以下任一条件，jira_cli 值得一试：",[1943,8906,8907,8910,8913,8916,8919],{},[432,8908,8909],{},"企业 Jira 部署在内网，想接入 AI 编程工具",[432,8911,8912],{},"想给 AI Agent 只读权限来查 Jira 上下文",[432,8914,8915],{},"需要命令行快速查看\u002F操作 Jira，不想切浏览器",[432,8917,8918],{},"需要管理多个 Jira 实例和多套账号",[432,8920,8921],{},"Windows 用户想要无摩擦的 CLI 体验",[15,8923,8924,8925,8929],{},"GitHub：",[2216,8926,8928],{"href":7568,"rel":8927},[2220],"github.com\u002Fyourname\u002Fjira_cli"," | MIT 协议 | Go 1.26.3+",[464,8931,8932],{},"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":35,"searchDepth":50,"depth":82,"links":8934},[8935,8936,8937,8938,8943,8951,8952,8953],{"id":7562,"depth":50,"text":7563},{"id":7648,"depth":50,"text":7648},{"id":5941,"depth":50,"text":5941},{"id":7773,"depth":50,"text":7773,"children":8939},[8940,8941,8942],{"id":7776,"depth":82,"text":7777},{"id":7892,"depth":82,"text":7893},{"id":8027,"depth":82,"text":8028},{"id":6423,"depth":50,"text":6423,"children":8944},[8945,8946,8947,8948,8949,8950],{"id":8170,"depth":82,"text":8171},{"id":8218,"depth":82,"text":8219},{"id":8408,"depth":82,"text":8409},{"id":8512,"depth":82,"text":8513},{"id":8637,"depth":82,"text":8638},{"id":8711,"depth":82,"text":8712},{"id":8786,"depth":50,"text":8786},{"id":8839,"depth":50,"text":8839},{"id":424,"depth":50,"text":424},"介绍 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":7557,"description":8954},"posts\u002Fjira-cli-intro\u002Findex",[8962,6573,5788,1274,5790,6574],"Jira","vNH6sF2mMFmSjbHoEI2OqmYCgpFmaqyKt-MUfcQqXrU",{"id":8965,"title":8966,"author":6,"body":8967,"description":9511,"difficulty":478,"draft":479,"extension":480,"image":9512,"meta":9513,"navigation":85,"path":9514,"pinned":479,"published":9515,"seo":9516,"series":9517,"seriesOrder":43,"stem":9518,"tags":9519,"updated":9522,"__hash__":9523},"posts\u002Fposts\u002Fsublog-evolution\u002Findex.md","尼克的小窝 架构变迁：从纯静态到前后端分离",{"type":8,"value":8968,"toc":9491},[8969,8973,8976,8987,8991,8995,9001,9005,9036,9039,9053,9057,9060,9128,9132,9135,9141,9145,9155,9185,9188,9192,9200,9203,9247,9251,9372,9376,9383,9387,9393,9399,9403,9406,9457,9461,9468,9475,9481,9484,9486],[11,8970,8972],{"id":8971},"写在前面","📝 写在前面",[15,8974,8975],{},"SuBlog 最初只是一个简单的技术博客——Nuxt 3 静态生成，Markdown 写文章，部署到静态托管完事。但随着功能需求的增长，纯静态的架构开始显露出局限性。",[15,8977,8978,8979,8982,8983,8986],{},"这篇文章记录从 ",[1635,8980,8981],{},"纯静态"," 到 ",[1635,8984,8985],{},"前后端分离"," 的演进过程，带你了解每一步背后的思考。",[11,8988,8990],{"id":8989},"第一代纯静态nuxt-3-nuxtcontent","📦 第一代：纯静态（Nuxt 3 + @nuxt\u002Fcontent）",[22,8992,8994],{"id":8993},"️-架构图","🏗️ 架构图",[30,8996,8999],{"className":8997,"code":8998,"language":1706,"meta":35},[1704],"┌─────────────────────────────────────┐\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",[37,9000,8998],{"__ignoreMap":35},[22,9002,9004],{"id":9003},"做了什么","🔧 做了什么",[1943,9006,9007,9016,9021,9026],{},[432,9008,9009,443,9011,443,9013,9015],{},[1635,9010,1882],{},[1635,9012,1892],{},[1635,9014,1902],{}," 搭建前端",[432,9017,9018,9020],{},[1635,9019,1912],{}," 管理 Markdown 文章，编译时生成静态页面",[432,9022,9023,9025],{},[1635,9024,1932],{}," 嵌入评论，依赖 GitHub Discussions",[432,9027,9028,9029,9032,9033,9035],{},"部署通过 ",[37,9030,9031],{},"nuxt generate"," 输出 ",[37,9034,2173],{},"，丢到 Nginx 或 Vercel",[22,9037,9038],{"id":3078},"✅ 优点",[1943,9040,9041,9047,9050],{},[432,9042,9043,9044,9046],{},"部署极简，一个 ",[37,9045,9031],{}," 搞定",[432,9048,9049],{},"加载飞快，全是 HTML，没有服务端开销",[432,9051,9052],{},"无需后端，零运维成本",[22,9054,9056],{"id":9055},"局限","❌ 局限",[15,9058,9059],{},"随着时间的推移，纯静态的问题越来越明显：",[1720,9061,9062,9071],{},[1723,9063,9064],{},[1726,9065,9066,9068],{},[1729,9067,3372],{},[1729,9069,9070],{},"具体表现",[1742,9072,9073,9084,9095,9106,9117],{},[1726,9074,9075,9081],{},[1747,9076,9077,9078],{},"🐢 ",[1635,9079,9080],{},"内容更新慢",[1747,9082,9083],{},"改一篇文章要重新构建、重新部署，不能实时发布",[1726,9085,9086,9092],{},[1747,9087,9088,9089],{},"👤 ",[1635,9090,9091],{},"没有用户体系",[1747,9093,9094],{},"无法区分读者和管理员，所有内容对所有人一致可见",[1726,9096,9097,9103],{},[1747,9098,9099,9100],{},"🔒 ",[1635,9101,9102],{},"评论受限",[1747,9104,9105],{},"Giscus 需要 GitHub 账号才能评论，门槛高",[1726,9107,9108,9114],{},[1747,9109,9110,9111],{},"🚫 ",[1635,9112,9113],{},"无法做动态功能",[1747,9115,9116],{},"点赞、收藏、阅读统计、个性化推荐……统统做不了",[1726,9118,9119,9125],{},[1747,9120,9121,9122],{},"🔗 ",[1635,9123,9124],{},"前端锁死",[1747,9126,9127],{},"被 Nuxt\u002FVue 生态绑定，想换技术栈得重写",[11,9129,9131],{"id":9130},"第二代前后端分离react-go","⚡ 第二代：前后端分离（React + Go）",[22,9133,8994],{"id":9134},"️-架构图-1",[30,9136,9139],{"className":9137,"code":9138,"language":1706,"meta":35},[1704],"┌───────────────────────┐       ┌────────────────────┐\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",[37,9140,9138],{"__ignoreMap":35},[22,9142,9144],{"id":9143},"️-前端sublog-react","🖥️ 前端：sublog-react",[15,9146,9147,9148,9150,9151,9154],{},"选用 ",[1635,9149,1737],{},"（App Router） + ",[1635,9152,9153],{},"React 19","，理由是：",[1943,9156,9157,9164,9171,9178],{},[432,9158,9159,9160,9163],{},"🎨 ",[1635,9161,9162],{},"React 生态","——组件库成熟，开发效率高",[432,9165,9166,9167,9170],{},"📐 ",[1635,9168,9169],{},"App Router","——支持布局嵌套，页面组织清晰",[432,9172,9173,9174,9177],{},"📦 ",[1635,9175,9176],{},"静态导出","——仍可部署到静态托管",[432,9179,9180,9181,9184],{},"⚙️ ",[1635,9182,9183],{},"运行时配置","——API 地址可动态注入，切换环境无需重新构建",[15,9186,9187],{},"前端不再直接读取 Markdown 文件，而是通过 HTTP API 获取文章数据。静态导出 + 客户端数据获取的组合，既保留了首屏加载速度，又实现了内容的动态更新。",[22,9189,9191],{"id":9190},"️-后端sublog-s","⚙️ 后端：sublog-s",[15,9193,9194,9195,443,9197,9199],{},"后端使用 ",[1635,9196,1274],{},[1635,9198,5789],{},"，按业务逻辑分层组织代码。",[15,9201,9202],{},"核心技术选型：",[1720,9204,9205,9213],{},[1723,9206,9207],{},[1726,9208,9209,9211],{},[1729,9210,1870],{},[1729,9212,1873],{},[1742,9214,9215,9225,9236],{},[1726,9216,9217,9222],{},[1747,9218,9219,9220],{},"🚀 ",[1635,9221,5789],{},[1747,9223,9224],{},"HTTP 框架",[1726,9226,9227,9233],{},[1747,9228,9229,9230],{},"🗄️ ",[1635,9231,9232],{},"GORM",[1747,9234,9235],{},"ORM，数据库操作",[1726,9237,9238,9244],{},[1747,9239,9240,9241],{},"🔐 ",[1635,9242,9243],{},"JWT",[1747,9245,9246],{},"用户认证",[11,9248,9250],{"id":9249},"前后对比","📊 前后对比",[1720,9252,9253,9266],{},[1723,9254,9255],{},[1726,9256,9257,9260,9263],{},[1729,9258,9259],{},"维度",[1729,9261,9262],{},"第一代（纯静态）",[1729,9264,9265],{},"第二代（前后端分离）",[1742,9267,9268,9280,9292,9306,9319,9332,9346,9358],{},[1726,9269,9270,9274,9277],{},[1747,9271,9159,9272],{},[1635,9273,1835],{},[1747,9275,9276],{},"Nuxt 3 (Vue)",[1747,9278,9279],{},"Next.js (React 19)",[1726,9281,9282,9287,9289],{},[1747,9283,9284,9285],{},"🖥️ ",[1635,9286,5791],{},[1747,9288,1778],{},[1747,9290,9291],{},"Go + Gin",[1726,9293,9294,9300,9303],{},[1747,9295,9296,9297],{},"📄 ",[1635,9298,9299],{},"内容来源",[1747,9301,9302],{},"Markdown 文件",[1747,9304,9305],{},"API 数据库",[1726,9307,9308,9313,9316],{},[1747,9309,9219,9310],{},[1635,9311,9312],{},"发布方式",[1747,9314,9315],{},"全量重新构建",[1747,9317,9318],{},"API 实时发布",[1726,9320,9321,9326,9329],{},[1747,9322,9088,9323],{},[1635,9324,9325],{},"用户系统",[1747,9327,9328],{},"❌ 无",[1747,9330,9331],{},"✅ 注册\u002F登录",[1726,9333,9334,9340,9343],{},[1747,9335,9336,9337],{},"💬 ",[1635,9338,9339],{},"评论",[1747,9341,9342],{},"Giscus（需 GitHub）",[1747,9344,9345],{},"自有评论系统（规划中）",[1726,9347,9348,9352,9355],{},[1747,9349,9173,9350],{},[1635,9351,2148],{},[1747,9353,9354],{},"1 个静态目录",[1747,9356,9357],{},"前端静态 + 后端二进制",[1726,9359,9360,9366,9369],{},[1747,9361,9362,9363],{},"⚡ ",[1635,9364,9365],{},"更新体验",[1747,9367,9368],{},"改文章 → 构建 → 部署",[1747,9370,9371],{},"改文章 → 立即生效",[11,9373,9375],{"id":9374},"新功能注册账号","🎉 新功能：注册账号",[15,9377,9378,9379,9382],{},"伴随着架构升级，",[1635,9380,9381],{},"用户账号系统","也上线了。",[22,9384,9386],{"id":9385},"目前支持","📋 目前支持",[15,9388,9389,9390,3539],{},"现在你可以在尼克的小窝 ",[1635,9391,9392],{},"注册一个账号",[30,9394,9397],{"className":9395,"code":9396,"language":1706},[1704],"1. ✏️ 填写用户名、邮箱、密码\n2. 📧 查收激活邮件\n3. 🔗 点击链接激活账号\n4. 🚪 登录后查看和管理个人信息\n",[37,9398,9396],{"__ignoreMap":35},[11,9400,9402],{"id":9401},"️-后续计划","🗓️ 后续计划",[15,9404,9405],{},"正在开发或规划中的功能：",[1943,9407,9409,9415,9421,9427,9433,9439,9445,9451],{"className":9408},[2181],[432,9410,9412,9414],{"className":9411},[2185],[2187,9413],{"checked":85,"disabled":85,"type":2189}," 用户注册\u002F登录（已上线 🎉）",[432,9416,9418,9420],{"className":9417},[2185],[2187,9419],{"checked":85,"disabled":85,"type":2189}," 个人资料编辑（已上线 🎉）",[432,9422,9424,9426],{"className":9423},[2185],[2187,9425],{"disabled":85,"type":2189}," 💬 站内评论系统",[432,9428,9430,9432],{"className":9429},[2185],[2187,9431],{"disabled":85,"type":2189}," 🔖 文章收藏",[432,9434,9436,9438],{"className":9435},[2185],[2187,9437],{"disabled":85,"type":2189}," 📚 阅读历史",[432,9440,9442,9444],{"className":9441},[2185],[2187,9443],{"disabled":85,"type":2189}," 👍 文章点赞",[432,9446,9448,9450],{"className":9447},[2185],[2187,9449],{"disabled":85,"type":2189}," 🔔 关注通知",[432,9452,9454,9456],{"className":9453},[2185],[2187,9455],{"disabled":85,"type":2189}," 🎨 个人首页定制",[11,9458,9460],{"id":9459},"结语","💡 结语",[15,9462,9463,9464,9467],{},"从 Nuxt 3 纯静态到 React + Go 前后端分离，SuBlog 的架构变迁本质上是一个",[1635,9465,9466],{},"功能驱动","的过程。静态博客有它的位置——写写文章、展示内容完全够用。但当你想做的不仅仅是\"展示\"时，后端的能力就不可或缺了。",[15,9469,9470,9471,9474],{},"这次重构最大的感触是：",[1635,9472,9473],{},"架构选型不是越复杂越好，而是刚好够用最好","。纯静态阶段用 Nuxt 3 一点问题没有，直到功能需求超出了静态的边界，才自然推动到前后端分离。",[15,9476,9477,9478],{},"如果你也在纠结\"要不要上后端\"，不妨先问问自己：",[1635,9479,9480],{},"你要做的是\"展示内容\"还是\"提供服务\"？",[15,9482,9483],{},"前者，静态就够了；后者，来吧，后端欢迎你。😄",[2224,9485],{},[15,9487,9488],{},[1635,9489,9490],{},"🎉 目前注册功能已经可以用了，后续更多好玩的功能正在路上。部分功能可能需要登录才能体验，快注册一个账号吧！",{"title":35,"searchDepth":50,"depth":82,"links":9492},[9493,9494,9500,9505,9506,9509,9510],{"id":8971,"depth":50,"text":8972},{"id":8989,"depth":50,"text":8990,"children":9495},[9496,9497,9498,9499],{"id":8993,"depth":82,"text":8994},{"id":9003,"depth":82,"text":9004},{"id":3078,"depth":82,"text":9038},{"id":9055,"depth":82,"text":9056},{"id":9130,"depth":50,"text":9131,"children":9501},[9502,9503,9504],{"id":9134,"depth":82,"text":8994},{"id":9143,"depth":82,"text":9144},{"id":9190,"depth":82,"text":9191},{"id":9249,"depth":50,"text":9250},{"id":9374,"depth":50,"text":9375,"children":9507},[9508],{"id":9385,"depth":82,"text":9386},{"id":9401,"depth":50,"text":9402},{"id":9459,"depth":50,"text":9460},"记录尼克的小窝从 Nuxt 3 纯静态博客演进到 React + Go 前后端分离架构的全过程，包括架构设计的思考、技术选型的变化，以及新增的账号注册功能。","\u002Fposts\u002Fsublog-evolution\u002Fimg\u002Fcover.jpg",{},"\u002Fposts\u002Fsublog-evolution","2026-07-23T12:00:00",{"title":8966,"description":9511},"尼克的小窝 开发记录","posts\u002Fsublog-evolution\u002Findex",[9520,9521,1737,1274],"架构","React","2026-07-23","VMQ6A2K8GQ5t3DSlezWPd3KgXnmDVMn9QVeaI28Hcp8",{"id":9525,"title":9526,"author":6,"body":9527,"description":10690,"difficulty":478,"draft":479,"extension":480,"image":10691,"meta":10692,"navigation":85,"path":10693,"pinned":479,"published":10694,"seo":10695,"series":6,"seriesOrder":6,"stem":10696,"tags":10697,"updated":10701,"__hash__":10702},"posts\u002Fposts\u002Fip-geolocation-guide\u002Findex.md","IP 属地是怎么查出来的？本地查询与在线查询方案对比",{"type":8,"value":9528,"toc":10666},[9529,9531,9537,9540,9551,9561,9565,9571,9584,9590,9601,9608,9611,9618,9622,9633,9637,9648,9651,9655,9661,9667,9670,9674,9681,9699,9702,9705,9709,9740,9779,9785,9789,9928,9964,9973,9977,10017,10022,10026,10037,10040,10074,10077,10081,10084,10088,10279,10283,10366,10370,10377,10484,10488,10497,10583,10586,10589,10625,10628,10631,10639,10660,10663],[11,9530,8971],{"id":8971},[15,9532,9533,9534],{},"很多网站会在「登录记录」「安全中心」里显示你的登录 IP 归属地，比如「福建·福州 · 中国移动」。你可能好奇过：",[1635,9535,9536],{},"服务器是怎么根据一个 IP 就判断出我在哪个城市、哪个运营商的？",[15,9538,9539],{},"这篇科普文章讲清楚三件事：",[429,9541,9542,9545,9548],{},[432,9543,9544],{},"IP 属地到底查的是什么；",[432,9546,9547],{},"纯本地查询为什么能做到毫秒级且不联网；",[432,9549,9550],{},"几种主流查询方案的优缺点对比，方便你按场景选型。",[9552,9553,9554],"blockquote",{},[15,9555,9556,9557,9560],{},"说明：文中用到的 IP 均为 RFC 5737 保留的文档示例段（TEST-NET-2，",[37,9558,9559],{},"198.51.100.0\u002F24","），不会对应任何真实地址。",[11,9562,9564],{"id":9563},"先想清楚ip-属地查的是什么","先想清楚：IP 属地查的是什么",[15,9566,9567,9568,2287],{},"一个常见误区是「每个 IP 对应一个具体地址」。实际上 IP 属地的本质是 ",[1635,9569,9570],{},"IP 段与归属地的映射",[15,9572,9573,9574,3539],{},"运营商和机构在申请 IP 时，是按",[1635,9575,9576,9577,9583],{},"网段（CIDR）",[1635,9578,9579,9580,9582],{},"成块分配的。比如 ",[37,9581,9559],{}," 这一个段，一整块都归属同一地区、同一运营商。所以归属地数据天然可以","按段存储",[30,9585,9588],{"className":9586,"code":9587,"language":1706,"meta":35},[1704],"IP 起始          IP 结束         归属地\n────────────────────────────────────────\n198.51.100.0     198.51.100.255  中国|福建|福州|移动\n198.51.101.0     198.51.101.255  中国|浙江|杭州|电信\n",[37,9589,9587],{"__ignoreMap":35},[15,9591,9592,9593,9596,9597,9600],{},"全球公网 IP 约有 42 亿个，但",[1635,9594,9595],{},"网段只有几十万条","。正因为存的是「段」而不是「每个 IP」，完整的归属地数据文件才只有几 MB（ip2region 的 ",[37,9598,9599],{},".xdb"," 约 11MB）。这是「本地离线查询」可行的第一前提。",[15,9602,9603,9604,9607],{},"所以，",[1635,9605,9606],{},"IP 属地从来不是精确到街道的定位","，它返回的是段级的「省｜市｜运营商」粗粒度信息。",[11,9609,9610],{"id":9610},"纯本地查询的原理",[15,9612,9613,9614,9617],{},"以开源的 ",[1635,9615,9616],{},"ip2region"," 为例，它的查询完全在本地完成，不联网、不请求第三方，几十微秒到毫秒级返回。",[22,9619,9621],{"id":9620},"第一步把-ip-变成整数","第一步：把 IP 变成整数",[15,9623,9624,9625,9628,9629,9632],{},"以 ",[37,9626,9627],{},"198.51.100.42"," 为例，它会转成一个 32 位整数 ",[37,9630,9631],{},"0xC633642A","。IP 的四个数字本质就是 4 个字节，拼起来就是一个整数，这一步没有任何玄学。",[22,9634,9636],{"id":9635},"第二步二分查找","第二步：二分查找",[15,9638,9639,9640,9643,9644,9647],{},"在按起始 IP 排序的段列表里做",[1635,9641,9642],{},"二分查找","，找到满足 ",[37,9645,9646],{},"段的起始IP ≤ 目标整数 ≤ 段的结束IP"," 的那一段。",[15,9649,9650],{},"二分查找的复杂度是 O(log N)，N 是几十万条，所以很快——这就是「不靠网络也能毫秒返回」的核心。",[22,9652,9654],{"id":9653},"第三步xdb-文件的两级结构","第三步：.xdb 文件的两级结构",[15,9656,9657,9658,9660],{},"ip2region 的 ",[37,9659,9599],{}," 不是简单顺序文件，内部分两个区，进一步加速：",[30,9662,9665],{"className":9663,"code":9664,"language":1706,"meta":35},[1704],"┌─────────────────────────────┐\n│  索引区 (Index)             │  \u002F\u002F 每段一条定长记录：startIP \u002F endIP \u002F 数据区指针 + 数据长度\n│  …… 二分快速定位段 ……       │\n├─────────────────────────────┤\n│  数据区 (Data)              │  \u002F\u002F 真正存放各段的地点字符串\n│  ……                         │\n└─────────────────────────────┘\n",[37,9666,9664],{"__ignoreMap":35},[15,9668,9669],{},"先二分索引区定位到「是哪一段」，再按指针去数据区读出地点字符串，一次磁盘读就能拿到结果。",[22,9671,9673],{"id":9672},"为什么离线数据不会很快过期","为什么「离线数据」不会很快过期",[15,9675,9676,9677,9680],{},"IP 归属取决于",[1635,9678,9679],{},"注册机构和运营商的分配","，一个网段通常长期稳定属于某地区、某运营商。所以：",[1943,9682,9683,9686,9696],{},[432,9684,9685],{},"下载一次数据文件，可以用很久；",[432,9687,9688,9689,9695],{},"归属偶尔变化时，",[1635,9690,9691,9692,9694],{},"每月更新一次 ",[37,9693,9599],{}," 文件","即可（替换文件、不用重启服务，见下文实战）；",[432,9697,9698],{},"数据源来自 IANA\u002FAPNIC 等机构的分配表与公开归属信息，不是实时探测。",[11,9700,9701],{"id":9701},"主流查询方案对比",[15,9703,9704],{},"实际开发中，查询 IP 属地主要有三条路线。",[22,9706,9708],{"id":9707},"方案一ip2region纯本地离线推荐","方案一：ip2region（纯本地离线，推荐）",[30,9710,9712],{"className":3820,"code":9711,"language":631,"meta":35,"style":35},"\u002F\u002F Go 中的用法（github.com\u002Flionsoul2014\u002Fip2region\u002Fbinding\u002Fgolang）\nregion, err := searcher.SearchByStr(ip)\n\u002F\u002F 返回类似 \"中国|福建省|福州市|移动\"\n",[37,9713,9714,9719,9735],{"__ignoreMap":35},[40,9715,9716],{"class":42,"line":43},[40,9717,9718],{"class":46},"\u002F\u002F Go 中的用法（github.com\u002Flionsoul2014\u002Fip2region\u002Fbinding\u002Fgolang）\n",[40,9720,9721,9724,9726,9729,9732],{"class":42,"line":50},[40,9722,9723],{"class":57},"region, err ",[40,9725,3847],{"class":53},[40,9727,9728],{"class":57}," searcher.",[40,9730,9731],{"class":123},"SearchByStr",[40,9733,9734],{"class":57},"(ip)\n",[40,9736,9737],{"class":42,"line":82},[40,9738,9739],{"class":46},"\u002F\u002F 返回类似 \"中国|福建省|福州市|移动\"\n",[1720,9741,9742,9750],{},[1723,9743,9744],{},[1726,9745,9746,9748],{},[1729,9747,3078],{},[1729,9749,3081],{},[1742,9751,9752,9760,9768],{},[1726,9753,9754,9757],{},[1747,9755,9756],{},"免费、无网络依赖、无 QPS 限制",[1747,9758,9759],{},"段级粒度，个别段可能显示到相邻地区",[1726,9761,9762,9765],{},[1747,9763,9764],{},"毫秒级查询，永不因外部服务挂掉",[1747,9766,9767],{},"数据按月更新，最新分配的段可能短暂缺失",[1726,9769,9770,9773],{},[1747,9771,9772],{},"数据文件可随版本更新，不重启",[1747,9774,9775,9776],{},"只返回省市区+运营商，无经纬度；",[1635,9777,9778],{},"仅支持 IPv4",[15,9780,9781,9784],{},[1635,9782,9783],{},"适用","：博客\u002F后台的「登录记录显示归属地」这类展示用途。",[22,9786,9788],{"id":9787},"方案二在线-apiip-api-高德-腾讯位置服务","方案二：在线 API（ip-api \u002F 高德 \u002F 腾讯位置服务）",[30,9790,9792],{"className":3820,"code":9791,"language":631,"meta":35,"style":35},"\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",[37,9793,9794,9799,9827,9837,9868,9884,9897,9907,9911,9923],{"__ignoreMap":35},[40,9795,9796],{"class":42,"line":43},[40,9797,9798],{"class":46},"\u002F\u002F 以 ip-api.com 为例（免费版约 45 次\u002F分钟，非商用）\n",[40,9800,9801,9804,9806,9808,9811,9814,9817,9820,9822,9824],{"class":42,"line":50},[40,9802,9803],{"class":57},"ctx, cancel ",[40,9805,3847],{"class":53},[40,9807,5320],{"class":57},[40,9809,9810],{"class":123},"WithTimeout",[40,9812,9813],{"class":57},"(context.",[40,9815,9816],{"class":123},"Background",[40,9818,9819],{"class":57},"(), ",[40,9821,2345],{"class":131},[40,9823,4053],{"class":53},[40,9825,9826],{"class":57},"time.Second)\n",[40,9828,9829,9832,9835],{"class":42,"line":82},[40,9830,9831],{"class":53},"defer",[40,9833,9834],{"class":123}," cancel",[40,9836,2591],{"class":57},[40,9838,9839,9842,9844,9847,9850,9853,9856,9858,9861,9863,9866],{"class":42,"line":89},[40,9840,9841],{"class":57},"req, _ ",[40,9843,3847],{"class":53},[40,9845,9846],{"class":57}," http.",[40,9848,9849],{"class":123},"NewRequestWithContext",[40,9851,9852],{"class":57},"(ctx, ",[40,9854,9855],{"class":127},"\"GET\"",[40,9857,411],{"class":57},[40,9859,9860],{"class":127},"\"https:\u002F\u002Fip-api.com\u002Fjson\u002F198.51.100.42\"",[40,9862,411],{"class":57},[40,9864,9865],{"class":131},"nil",[40,9867,2706],{"class":57},[40,9869,9870,9873,9875,9878,9881],{"class":42,"line":95},[40,9871,9872],{"class":57},"resp, err ",[40,9874,3847],{"class":53},[40,9876,9877],{"class":57}," http.DefaultClient.",[40,9879,9880],{"class":123},"Do",[40,9882,9883],{"class":57},"(req)\n",[40,9885,9886,9889,9891,9893,9895],{"class":42,"line":217},[40,9887,9888],{"class":53},"if",[40,9890,4261],{"class":57},[40,9892,4264],{"class":53},[40,9894,4267],{"class":131},[40,9896,2036],{"class":57},[40,9898,9899,9901,9904],{"class":42,"line":225},[40,9900,4078],{"class":53},[40,9902,9903],{"class":127}," \"未知\"",[40,9905,9906],{"class":46}," \u002F\u002F 断网\u002F超时降级：展示「未知」而不是报错\n",[40,9908,9909],{"class":42,"line":230},[40,9910,1690],{"class":57},[40,9912,9913,9915,9918,9921],{"class":42,"line":237},[40,9914,9831],{"class":53},[40,9916,9917],{"class":57}," resp.Body.",[40,9919,9920],{"class":123},"Close",[40,9922,2591],{"class":57},[40,9924,9925],{"class":42,"line":243},[40,9926,9927],{"class":46},"\u002F\u002F 解析 JSON，status != \"success\" 时同样降级\n",[1720,9929,9930,9938],{},[1723,9931,9932],{},[1726,9933,9934,9936],{},[1729,9935,3078],{},[1729,9937,3081],{},[1742,9939,9940,9948,9956],{},[1726,9941,9942,9945],{},[1747,9943,9944],{},"数据最新、可能更精确",[1747,9946,9947],{},"有频率限制（免费版限 QPS）",[1726,9949,9950,9953],{},[1747,9951,9952],{},"部分服务还返回经纬度\u002F时区",[1747,9954,9955],{},"依赖第三方，断网\u002F被墙\u002F限流时拿不到",[1726,9957,9958,9961],{},[1747,9959,9960],{},"无需维护数据文件",[1747,9962,9963],{},"每次查询都要发一次 HTTP 请求",[15,9965,9966,9968,9969,9972],{},[1635,9967,9783],{},"：对精度要求高、且能接受外部依赖与限流的场景。",[1635,9970,9971],{},"务必加超时和降级","，否则第三方一抖，你自己的页面就卡住。",[22,9974,9976],{"id":9975},"方案三maxmind-geolite2离线-mmdb","方案三：MaxMind GeoLite2（离线 .mmdb）",[1720,9978,9979,9987],{},[1723,9980,9981],{},[1726,9982,9983,9985],{},[1729,9984,3078],{},[1729,9986,3081],{},[1742,9988,9989,9997,10009],{},[1726,9990,9991,9994],{},[1747,9992,9993],{},"全球数据，覆盖广、精度较好",[1747,9995,9996],{},"免费但需注册账号并遵守 EULA；商用需 GeoIP2 商业授权",[1726,9998,9999,10006],{},[1747,10000,10001,10002,10005],{},"标准 ",[37,10003,10004],{},".mmdb"," 格式，各语言都有库，支持 IPv6",[1747,10007,10008],{},"国内 IP 的省市粒度有时不如 ip2region 细",[1726,10010,10011,10014],{},[1747,10012,10013],{},"离线查询",[1747,10015,10016],{},"数据同样要定期更新",[15,10018,10019,10021],{},[1635,10020,9783],{},"：面向全球用户的场景。",[11,10023,10025],{"id":10024},"ipv6本地离线方案的一个盲点","IPv6：本地离线方案的一个盲点",[15,10027,10028,10029,10032,10033,10036],{},"前面聊的都是 IPv4（32 位）。但家宽和移动网络越来越多地",[1635,10030,10031],{},"只下发 IPv6","（128 位）地址，而 ",[1635,10034,10035],{},"ip2region 目前只支持 IPv4","——拿到一个纯 IPv6 地址是查不出归属地的，本地库会返回空结果。",[15,10038,10039],{},"实际影响与常见应对：",[429,10041,10042,10052,10058,10068],{},[432,10043,10044,10047,10048,10051],{},[1635,10045,10046],{},"判断版本","：Go 里 ",[37,10049,10050],{},"net.ParseIP(ip).To4() != nil"," 就是 IPv4，先判断再查询；",[432,10053,10054,10057],{},[1635,10055,10056],{},"降级展示","：IPv6 直接显示「IPv6 地址」，不显示属地，诚实第一；",[432,10059,10060,10063,10064,10067],{},[1635,10061,10062],{},"优先取 IPv4","：有反向代理时，从 ",[37,10065,10066],{},"X-Forwarded-For"," 里挑一个 IPv4 来查；",[432,10069,10070,10073],{},[1635,10071,10072],{},"需要 v6 归属","：换支持 IPv6 的数据源（如 MaxMind GeoLite2）。",[15,10075,10076],{},"这一点在接入时很容易漏掉，上线前记得拿一个纯 IPv6 地址回归一遍。",[11,10078,10080],{"id":10079},"实战给登录记录加-ip-属地","实战：给登录记录加 IP 属地",[15,10082,10083],{},"subLog 博客的登录记录就用了 ip2region。完整接入一共四步。",[22,10085,10087],{"id":10086},"_1-进程启动时加载数据文件","1. 进程启动时加载数据文件",[30,10089,10091],{"className":3820,"code":10090,"language":631,"meta":35,"style":35},"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",[37,10092,10093,10099,10108,10117,10121,10125,10130,10157,10161,10174,10196,10208,10215,10219,10235,10247,10253,10257,10268,10275],{"__ignoreMap":35},[40,10094,10095,10097],{"class":42,"line":43},[40,10096,3828],{"class":53},[40,10098,5226],{"class":57},[40,10100,10101,10103,10106],{"class":42,"line":50},[40,10102,6646],{"class":127},[40,10104,10105],{"class":123},"sync\u002Fatomic",[40,10107,727],{"class":127},[40,10109,10110,10112,10115],{"class":42,"line":82},[40,10111,6646],{"class":127},[40,10113,10114],{"class":123},"github.com\u002Flionsoul2014\u002Fip2region\u002Fbinding\u002Fgolang\u002Fxdb",[40,10116,727],{"class":127},[40,10118,10119],{"class":42,"line":89},[40,10120,2706],{"class":57},[40,10122,10123],{"class":42,"line":95},[40,10124,86],{"emptyLinePlaceholder":85},[40,10126,10127],{"class":42,"line":217},[40,10128,10129],{"class":46},"\u002F\u002F 用 atomic.Pointer 持有查询器，方便后面热更新\n",[40,10131,10132,10134,10137,10140,10142,10145,10147,10150,10152,10155],{"class":42,"line":225},[40,10133,6727],{"class":53},[40,10135,10136],{"class":57}," searcher ",[40,10138,10139],{"class":123},"atomic",[40,10141,2518],{"class":57},[40,10143,10144],{"class":123},"Pointer",[40,10146,3206],{"class":57},[40,10148,10149],{"class":123},"xdb",[40,10151,2518],{"class":57},[40,10153,10154],{"class":123},"Searcher",[40,10156,935],{"class":57},[40,10158,10159],{"class":42,"line":230},[40,10160,86],{"emptyLinePlaceholder":85},[40,10162,10163,10165,10168,10170,10172],{"class":42,"line":237},[40,10164,4021],{"class":53},[40,10166,10167],{"class":123}," initSearcher",[40,10169,5269],{"class":57},[40,10171,4066],{"class":53},[40,10173,2036],{"class":57},[40,10175,10176,10179,10181,10184,10186,10188,10191,10193],{"class":42,"line":243},[40,10177,10178],{"class":57},"    content, err ",[40,10180,3847],{"class":53},[40,10182,10183],{"class":57}," os.",[40,10185,6866],{"class":123},[40,10187,2396],{"class":57},[40,10189,10190],{"class":127},"\".\u002Fdata\u002Fip2region.xdb\"",[40,10192,2860],{"class":57},[40,10194,10195],{"class":46},"\u002F\u002F 约 11MB，一次性读入内存\n",[40,10197,10198,10200,10202,10204,10206],{"class":42,"line":251},[40,10199,2473],{"class":53},[40,10201,4261],{"class":57},[40,10203,4264],{"class":53},[40,10205,4267],{"class":131},[40,10207,2036],{"class":57},[40,10209,10210,10212],{"class":42,"line":257},[40,10211,4274],{"class":53},[40,10213,10214],{"class":57}," err\n",[40,10216,10217],{"class":42,"line":265},[40,10218,2356],{"class":57},[40,10220,10221,10224,10226,10229,10232],{"class":42,"line":270},[40,10222,10223],{"class":57},"    s, err ",[40,10225,3847],{"class":53},[40,10227,10228],{"class":57}," xdb.",[40,10230,10231],{"class":123},"NewWithBuffer",[40,10233,10234],{"class":57},"(content)\n",[40,10236,10237,10239,10241,10243,10245],{"class":42,"line":279},[40,10238,2473],{"class":53},[40,10240,4261],{"class":57},[40,10242,4264],{"class":53},[40,10244,4267],{"class":131},[40,10246,2036],{"class":57},[40,10248,10249,10251],{"class":42,"line":284},[40,10250,4274],{"class":53},[40,10252,10214],{"class":57},[40,10254,10255],{"class":42,"line":289},[40,10256,2356],{"class":57},[40,10258,10259,10262,10265],{"class":42,"line":297},[40,10260,10261],{"class":57},"    searcher.",[40,10263,10264],{"class":123},"Store",[40,10266,10267],{"class":57},"(s)\n",[40,10269,10270,10272],{"class":42,"line":302},[40,10271,4078],{"class":53},[40,10273,10274],{"class":131}," nil\n",[40,10276,10277],{"class":42,"line":311},[40,10278,1690],{"class":57},[22,10280,10282],{"id":10281},"_2-查询一次","2. 查询一次",[30,10284,10286],{"className":3820,"code":10285,"language":631,"meta":35,"style":35},"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",[37,10287,10288,10310,10329,10341,10348,10352,10362],{"__ignoreMap":35},[40,10289,10290,10292,10295,10297,10300,10303,10305,10308],{"class":42,"line":43},[40,10291,4021],{"class":53},[40,10293,10294],{"class":123}," ipRegion",[40,10296,2396],{"class":57},[40,10298,10299],{"class":2399},"ip",[40,10301,10302],{"class":53}," string",[40,10304,2860],{"class":57},[40,10306,10307],{"class":53},"string",[40,10309,2036],{"class":57},[40,10311,10312,10315,10317,10319,10322,10325,10327],{"class":42,"line":50},[40,10313,10314],{"class":57},"    region, err ",[40,10316,3847],{"class":53},[40,10318,9728],{"class":57},[40,10320,10321],{"class":123},"Load",[40,10323,10324],{"class":57},"().",[40,10326,9731],{"class":123},[40,10328,9734],{"class":57},[40,10330,10331,10333,10335,10337,10339],{"class":42,"line":82},[40,10332,2473],{"class":53},[40,10334,4261],{"class":57},[40,10336,4264],{"class":53},[40,10338,4267],{"class":131},[40,10340,2036],{"class":57},[40,10342,10343,10345],{"class":42,"line":89},[40,10344,4274],{"class":53},[40,10346,10347],{"class":127}," \"未知\"\n",[40,10349,10350],{"class":42,"line":95},[40,10351,2356],{"class":57},[40,10353,10354,10356,10359],{"class":42,"line":217},[40,10355,4078],{"class":53},[40,10357,10358],{"class":57}," region ",[40,10360,10361],{"class":46},"\u002F\u002F \"中国|福建省|福州市|移动\"\n",[40,10363,10364],{"class":42,"line":225},[40,10365,1690],{"class":57},[22,10367,10369],{"id":10368},"_3-加一层内存缓存","3. 加一层内存缓存",[15,10371,10372,10373,10376],{},"登录记录里同一个 IP 可能反复出现，用 ",[37,10374,10375],{},"sync.Map"," 按 IP 缓存结果，一次查询终身复用：",[30,10378,10380],{"className":3820,"code":10379,"language":631,"meta":35,"style":35},"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",[37,10381,10382,10397,10401,10420,10437,10448,10452,10463,10473,10480],{"__ignoreMap":35},[40,10383,10384,10386,10389,10392,10394],{"class":42,"line":43},[40,10385,6727],{"class":53},[40,10387,10388],{"class":57}," regionCache ",[40,10390,10391],{"class":123},"sync",[40,10393,2518],{"class":57},[40,10395,10396],{"class":123},"Map\n",[40,10398,10399],{"class":42,"line":50},[40,10400,86],{"emptyLinePlaceholder":85},[40,10402,10403,10405,10408,10410,10412,10414,10416,10418],{"class":42,"line":82},[40,10404,4021],{"class":53},[40,10406,10407],{"class":123}," ipRegionCached",[40,10409,2396],{"class":57},[40,10411,10299],{"class":2399},[40,10413,10302],{"class":53},[40,10415,2860],{"class":57},[40,10417,10307],{"class":53},[40,10419,2036],{"class":57},[40,10421,10422,10424,10427,10429,10432,10434],{"class":42,"line":89},[40,10423,2473],{"class":53},[40,10425,10426],{"class":57}," v, ok ",[40,10428,3847],{"class":53},[40,10430,10431],{"class":57}," regionCache.",[40,10433,10321],{"class":123},[40,10435,10436],{"class":57},"(ip); ok {\n",[40,10438,10439,10441,10444,10446],{"class":42,"line":95},[40,10440,4274],{"class":53},[40,10442,10443],{"class":57}," v.(",[40,10445,10307],{"class":53},[40,10447,2706],{"class":57},[40,10449,10450],{"class":42,"line":217},[40,10451,2356],{"class":57},[40,10453,10454,10457,10459,10461],{"class":42,"line":225},[40,10455,10456],{"class":57},"    region ",[40,10458,3847],{"class":53},[40,10460,10294],{"class":123},[40,10462,9734],{"class":57},[40,10464,10465,10468,10470],{"class":42,"line":230},[40,10466,10467],{"class":57},"    regionCache.",[40,10469,10264],{"class":123},[40,10471,10472],{"class":57},"(ip, region)\n",[40,10474,10475,10477],{"class":42,"line":237},[40,10476,4078],{"class":53},[40,10478,10479],{"class":57}," region\n",[40,10481,10482],{"class":42,"line":243},[40,10483,1690],{"class":57},[22,10485,10487],{"id":10486},"_4-数据热更新不用重启","4. 数据热更新（不用重启）",[15,10489,10490,10491,10493,10494,10496],{},"ip2region 每月发布新数据。下载新文件后重新 ",[37,10492,10231],{}," 并 ",[37,10495,10264],{}," 覆盖即可，进程完全不用重启：",[30,10498,10500],{"className":3820,"code":10499,"language":631,"meta":35,"style":35},"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",[37,10501,10502,10526,10539,10551,10557,10561,10573,10579],{"__ignoreMap":35},[40,10503,10504,10506,10509,10511,10514,10517,10520,10522,10524],{"class":42,"line":43},[40,10505,4021],{"class":53},[40,10507,10508],{"class":123}," reloadSearcher",[40,10510,2396],{"class":57},[40,10512,10513],{"class":2399},"newContent",[40,10515,10516],{"class":57}," []",[40,10518,10519],{"class":53},"byte",[40,10521,2860],{"class":57},[40,10523,4066],{"class":53},[40,10525,2036],{"class":57},[40,10527,10528,10530,10532,10534,10536],{"class":42,"line":50},[40,10529,10223],{"class":57},[40,10531,3847],{"class":53},[40,10533,10228],{"class":57},[40,10535,10231],{"class":123},[40,10537,10538],{"class":57},"(newContent)\n",[40,10540,10541,10543,10545,10547,10549],{"class":42,"line":82},[40,10542,2473],{"class":53},[40,10544,4261],{"class":57},[40,10546,4264],{"class":53},[40,10548,4267],{"class":131},[40,10550,2036],{"class":57},[40,10552,10553,10555],{"class":42,"line":89},[40,10554,4274],{"class":53},[40,10556,10214],{"class":57},[40,10558,10559],{"class":42,"line":95},[40,10560,2356],{"class":57},[40,10562,10563,10565,10567,10570],{"class":42,"line":217},[40,10564,10261],{"class":57},[40,10566,10264],{"class":123},[40,10568,10569],{"class":57},"(s) ",[40,10571,10572],{"class":46},"\u002F\u002F 原子切换，老查询继续走旧数据，新查询走新数据\n",[40,10574,10575,10577],{"class":42,"line":225},[40,10576,4078],{"class":53},[40,10578,10274],{"class":131},[40,10580,10581],{"class":42,"line":230},[40,10582,1690],{"class":57},[11,10584,10585],{"id":10585},"局限与诚实提醒",[15,10587,10588],{},"无论用哪种方案，都要清楚 IP 属地的边界：",[1943,10590,10591,10597,10603,10609,10615],{},[432,10592,10593,10596],{},[1635,10594,10595],{},"段级精度","：网段归属是「绝大多数」准确，个别 IP（大型企业自建段、跨省调度段）可能显示到相邻地区或只到省。",[432,10598,10599,10602],{},[1635,10600,10601],{},"运营商级，不是定位","：拿到的是「省｜市｜运营商」，不是精确街道或基站位置——精确位置需要 GPS 或运营商三角定位，纯 IP 归属做不到。",[432,10604,10605,10608],{},[1635,10606,10607],{},"动态 IP","：家宽通常是动态 IP，重拨会变，所以「上次登录在福州，这次在广州」不一定是异常，可能是 IP 变了。",[432,10610,10611,10614],{},[1635,10612,10613],{},"别用于安全判定","：用 IP 段做封禁或风控判定风险自负，段级数据不适合当精确依据。",[432,10616,10617,10620,10621,10624],{},[1635,10618,10619],{},"隐私与合规","：IP 地址属于个人信息（《个人信息保护法》）。归属地展示建议只出现在",[1635,10622,10623],{},"用户本人可见","的页面（登录记录、个人中心）；若在评论区等公开场合展示，建议只显示到省市并明确告知用户。",[11,10626,10627],{"id":10627},"小结",[15,10629,10630],{},"一句话总结：",[9552,10632,10633],{},[15,10634,10635,10638],{},[1635,10636,10637],{},"IP 属地 = 几十万条「IP 段 → 归属地」映射 + 一次二分查找。"," 它查的是网段归属，不是精确位置。",[1943,10640,10641,10647,10654],{},[432,10642,10643,10644,10646],{},"想省心、免费、稳定，用 ",[1635,10645,9616],{}," 本地离线查询（注意 IPv6 盲点）；",[432,10648,10649,10650,10653],{},"想要最新最细、能接受外部依赖，用",[1635,10651,10652],{},"在线 API","（记得超时降级）；",[432,10655,10656,10657,2287],{},"面向全球用户、要 IPv6 覆盖，用 ",[1635,10658,10659],{},"MaxMind GeoLite2",[15,10661,10662],{},"对个人博客「登录记录显示归属地」这种场景，本地离线方案通常是性价比最高的选择。",[464,10664,10665],{},"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":35,"searchDepth":50,"depth":82,"links":10667},[10668,10669,10670,10676,10681,10682,10688,10689],{"id":8971,"depth":50,"text":8971},{"id":9563,"depth":50,"text":9564},{"id":9610,"depth":50,"text":9610,"children":10671},[10672,10673,10674,10675],{"id":9620,"depth":82,"text":9621},{"id":9635,"depth":82,"text":9636},{"id":9653,"depth":82,"text":9654},{"id":9672,"depth":82,"text":9673},{"id":9701,"depth":50,"text":9701,"children":10677},[10678,10679,10680],{"id":9707,"depth":82,"text":9708},{"id":9787,"depth":82,"text":9788},{"id":9975,"depth":82,"text":9976},{"id":10024,"depth":50,"text":10025},{"id":10079,"depth":50,"text":10080,"children":10683},[10684,10685,10686,10687],{"id":10086,"depth":82,"text":10087},{"id":10281,"depth":82,"text":10282},{"id":10368,"depth":82,"text":10369},{"id":10486,"depth":82,"text":10487},{"id":10585,"depth":50,"text":10585},{"id":10627,"depth":50,"text":10627},"科普 IP 属地查询的原理：IP 段与归属地的映射、纯本地离线查询为何能做到毫秒级，对比 ip2region、在线 API 与 MaxMind 三种方案的优劣，并附登录记录场景的 Go 实战与 IPv6 盲点提醒。","\u002Fposts\u002Fip-geolocation-guide\u002Fimg\u002Fcover.svg",{},"\u002Fposts\u002Fip-geolocation-guide","2026-08-07T12:00:00",{"title":9526,"description":10690},"posts\u002Fip-geolocation-guide\u002Findex",[10698,10699,10700],"IP","网络","科普","2026-08-16","RY1UPgJaiKsjpC51uqMyG7w_GpCRS13_CX5j1hDD_dM",{"id":10704,"title":10705,"author":6,"body":10706,"description":12228,"difficulty":1266,"draft":479,"extension":480,"image":12229,"meta":12230,"navigation":85,"path":12231,"pinned":479,"published":12232,"seo":12233,"series":5785,"seriesOrder":82,"stem":12234,"tags":12235,"updated":10701,"__hash__":12238},"posts\u002Fposts\u002Fgo-embed-react-single-binary\u002Findex.md","Go 单文件打包进阶：React 前端 + 交叉编译，一次踩完所有坑",{"type":8,"value":10707,"toc":12207},[10708,10710,10721,10732,10739,10742,10778,10780,10789,10791,10795,10805,10849,10891,10909,10913,10934,10965,10969,11261,11264,11302,11306,11649,11652,11656,11853,11863,11865,11876,11890,11903,11914,11918,11925,11941,11954,11959,11963,11979,11984,12029,12043,12054,12058,12073,12082,12104,12113,12117,12127,12129,12156,12165,12167,12170,12197,12204],[11,10709,2263],{"id":2263},[15,10711,10712,10713,10716,10717,10720],{},"这是「Go 单文件打包」系列第三篇。上一篇（",[2216,10714,10715],{"href":7545},"把前端塞进二进制","）记录了一个 Gin + Vue 3 项目的 embed 方案。这次换了个项目继续踩坑：",[1635,10718,10719],{},"Magic Img","——一个自托管、本地优先的 AI 生图提示词管理工具，技术栈是：",[1943,10722,10723,10726],{},[432,10724,10725],{},"前端：React 19 + TypeScript + Vite + Tailwind v4（UI 库 Magic UI + shadcn\u002Fui）",[432,10727,10728,10729,6329],{},"后端：Go + Gin + GORM + SQLite（",[1635,10730,10731],{},"modernc 纯 Go 驱动，无 cgo",[15,10733,10734,10735,10738],{},"目标一样：",[1635,10736,10737],{},"一个二进制拖到任何机器上就能跑","——前端、API、数据库全部内嵌。",[15,10740,10741],{},"和上篇不同的地方有三个，正好是这篇要讲的重点：",[429,10743,10744,10758,10771],{},[432,10745,10746,10747,10753,10754,10757],{},"前端用了 ",[1635,10748,10749,10750],{},"HashRouter + Vite base ",[37,10751,10752],{},"\u002Fdist","，服务端",[1635,10755,10756],{},"不再需要 SPA fallback","（上篇坑一的新解法）；",[432,10759,10760,10761,10767,10768,10770],{},"embed 用 ",[1635,10762,10763,10766],{},[37,10764,10765],{},"all:"," 前缀","一劳永逸解决 ",[37,10769,7239],{}," 开头文件被排除的问题（上篇坑二的升级解法）；",[432,10772,10773,10774,10777],{},"需要",[1635,10775,10776],{},"在 Windows 上交叉编译 Linux 产物","，踩了「生成的文件无法在 Linux 执行」的坑。",[11,10779,6595],{"id":6595},[15,10781,10782,10783,10785,10786,10788],{},"和上篇一致：",[37,10784,6601],{}," 在编译期把前端 ",[37,10787,6605],{}," 塞进二进制，运行时由 Go 直接提供静态服务。差异在于路由策略和缓存策略，后面细说。",[11,10790,6609],{"id":6609},[22,10792,10794],{"id":10793},"_1-前端hash-路由-base-dist","1. 前端：hash 路由 + base \u002Fdist",[15,10796,10797,10798,10801,10802,3539],{},"vite.config.ts 里固定 ",[37,10799,10800],{},"base: \"\u002Fdist\u002F\"","，路由用 ",[37,10803,10804],{},"HashRouter",[30,10806,10808],{"className":7290,"code":10807,"language":7292,"meta":35,"style":35},"\u002F\u002F vite.config.ts\nexport default defineConfig({\n  base: \"\u002Fdist\u002F\",   \u002F\u002F 所有资源路径以 \u002Fdist\u002F 开头\n  ...\n})\n",[37,10809,10810,10814,10826,10840,10845],{"__ignoreMap":35},[40,10811,10812],{"class":42,"line":43},[40,10813,7299],{"class":46},[40,10815,10816,10818,10820,10823],{"class":42,"line":50},[40,10817,594],{"class":53},[40,10819,3139],{"class":53},[40,10821,10822],{"class":123}," defineConfig",[40,10824,10825],{"class":57},"({\n",[40,10827,10828,10831,10834,10837],{"class":42,"line":82},[40,10829,10830],{"class":57},"  base: ",[40,10832,10833],{"class":127},"\"\u002Fdist\u002F\"",[40,10835,10836],{"class":57},",   ",[40,10838,10839],{"class":46},"\u002F\u002F 所有资源路径以 \u002Fdist\u002F 开头\n",[40,10841,10842],{"class":42,"line":89},[40,10843,10844],{"class":53},"  ...\n",[40,10846,10847],{"class":42,"line":95},[40,10848,2944],{"class":57},[30,10850,10854],{"className":10851,"code":10852,"language":10853,"meta":35,"style":35},"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",[37,10855,10856,10861,10869,10883],{"__ignoreMap":35},[40,10857,10858],{"class":42,"line":43},[40,10859,10860],{"class":46},"\u002F\u002F App.tsx —— HashRouter：路径信息全在 location.hash 里\n",[40,10862,10863,10865,10867],{"class":42,"line":50},[40,10864,163],{"class":57},[40,10866,10804],{"class":131},[40,10868,172],{"class":57},[40,10870,10871,10873,10876,10879,10881],{"class":42,"line":82},[40,10872,2971],{"class":57},[40,10874,10875],{"class":131},"Routes",[40,10877,10878],{"class":57},">...\u003C\u002F",[40,10880,10875],{"class":131},[40,10882,172],{"class":57},[40,10884,10885,10887,10889],{"class":42,"line":89},[40,10886,2949],{"class":57},[40,10888,10804],{"class":131},[40,10890,172],{"class":57},[15,10892,10893,10896,10897,10900,10901,10904,10905,10908],{},[1635,10894,10895],{},"为什么这样选","：history 路由需要服务端把未知路径全部 fallback 回 ",[37,10898,10899],{},"index.html","（上篇坑一）。hash 路由下浏览器请求的永远是 ",[37,10902,10903],{},"\u002Fdist\u002F"," 这一个入口，",[1635,10906,10907],{},"静态服务直接返回 index.html 就完事，一个 fallback 都不用写","。内嵌场景里这是最省心的组合。",[22,10910,10912],{"id":10911},"_2-构建产物复制进后端目录","2. 构建产物复制进后端目录",[15,10914,10915,10918,10919,10921,10922,10925,10926,10929,10930,10933],{},[37,10916,10917],{},"npm run build"," 除了编译，还会把 ",[37,10920,6605],{}," 复制到 ",[37,10923,10924],{},"backend\u002Fweb\u002Fdist","（一个小脚本 ",[37,10927,10928],{},"scripts\u002Fcopy-dist.mjs"," 干这个活），供 ",[37,10931,10932],{},"go:embed"," 使用：",[30,10935,10937],{"className":5612,"code":10936,"language":5614,"meta":35,"style":35},"\u002F\u002F frontend\u002Fpackage.json\n\"scripts\": {\n  \"build\": \"tsc -b && vite build && node scripts\u002Fcopy-dist.mjs\"\n}\n",[37,10938,10939,10944,10951,10961],{"__ignoreMap":35},[40,10940,10941],{"class":42,"line":43},[40,10942,10943],{"class":46},"\u002F\u002F frontend\u002Fpackage.json\n",[40,10945,10946,10949],{"class":42,"line":50},[40,10947,10948],{"class":127},"\"scripts\"",[40,10950,2315],{"class":57},[40,10952,10953,10956,10958],{"class":42,"line":82},[40,10954,10955],{"class":131},"  \"build\"",[40,10957,2100],{"class":57},[40,10959,10960],{"class":127},"\"tsc -b && vite build && node scripts\u002Fcopy-dist.mjs\"\n",[40,10962,10963],{"class":42,"line":89},[40,10964,1690],{"class":57},[22,10966,10968],{"id":10967},"_3-后端embed-httpfilesystem-包装","3. 后端：embed + http.FileSystem 包装",[30,10970,10972],{"className":3820,"code":10971,"language":631,"meta":35,"style":35},"\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",[37,10973,10974,10979,10985,10989,10995,11003,11011,11019,11023,11027,11032,11045,11049,11054,11091,11095,11107,11115,11128,11132,11136,11175,11189,11199,11209,11236,11240,11257],{"__ignoreMap":35},[40,10975,10976],{"class":42,"line":43},[40,10977,10978],{"class":46},"\u002F\u002F backend\u002Fweb\u002Fembed.go\n",[40,10980,10981,10983],{"class":42,"line":50},[40,10982,6628],{"class":53},[40,10984,6631],{"class":123},[40,10986,10987],{"class":42,"line":82},[40,10988,86],{"emptyLinePlaceholder":85},[40,10990,10991,10993],{"class":42,"line":89},[40,10992,3828],{"class":53},[40,10994,5226],{"class":57},[40,10996,10997,10999,11001],{"class":42,"line":95},[40,10998,6646],{"class":127},[40,11000,6649],{"class":123},[40,11002,727],{"class":127},[40,11004,11005,11007,11009],{"class":42,"line":217},[40,11006,6646],{"class":127},[40,11008,6676],{"class":123},[40,11010,727],{"class":127},[40,11012,11013,11015,11017],{"class":42,"line":225},[40,11014,6646],{"class":127},[40,11016,6694],{"class":123},[40,11018,727],{"class":127},[40,11020,11021],{"class":42,"line":230},[40,11022,2706],{"class":57},[40,11024,11025],{"class":42,"line":237},[40,11026,86],{"emptyLinePlaceholder":85},[40,11028,11029],{"class":42,"line":243},[40,11030,11031],{"class":46},"\u002F\u002Fgo:embed all:dist\n",[40,11033,11034,11036,11039,11041,11043],{"class":42,"line":251},[40,11035,6727],{"class":53},[40,11037,11038],{"class":57}," distFS ",[40,11040,6649],{"class":123},[40,11042,2518],{"class":57},[40,11044,6737],{"class":123},[40,11046,11047],{"class":42,"line":257},[40,11048,86],{"emptyLinePlaceholder":85},[40,11050,11051],{"class":42,"line":265},[40,11052,11053],{"class":46},"\u002F\u002F Dist 内嵌前端静态文件系统\n",[40,11055,11056,11058,11061,11064,11066,11069,11071,11074,11077,11080,11082,11085,11088],{"class":42,"line":270},[40,11057,6727],{"class":53},[40,11059,11060],{"class":57}," Dist ",[40,11062,11063],{"class":123},"http",[40,11065,2518],{"class":57},[40,11067,11068],{"class":123},"FileSystem",[40,11070,2033],{"class":53},[40,11072,11073],{"class":53}," &",[40,11075,11076],{"class":123},"fileSystem",[40,11078,11079],{"class":57},"{prefix: ",[40,11081,6777],{"class":127},[40,11083,11084],{"class":57},", fs: ",[40,11086,11087],{"class":53},"&",[40,11089,11090],{"class":57},"distFS}\n",[40,11092,11093],{"class":42,"line":279},[40,11094,86],{"emptyLinePlaceholder":85},[40,11096,11097,11099,11102,11105],{"class":42,"line":284},[40,11098,5213],{"class":53},[40,11100,11101],{"class":123}," fileSystem",[40,11103,11104],{"class":53}," struct",[40,11106,2036],{"class":57},[40,11108,11109,11112],{"class":42,"line":289},[40,11110,11111],{"class":57},"    prefix ",[40,11113,11114],{"class":53},"string\n",[40,11116,11117,11120,11122,11124,11126],{"class":42,"line":297},[40,11118,11119],{"class":57},"    fs     ",[40,11121,4053],{"class":53},[40,11123,6649],{"class":123},[40,11125,2518],{"class":57},[40,11127,6737],{"class":123},[40,11129,11130],{"class":42,"line":302},[40,11131,1690],{"class":57},[40,11133,11134],{"class":42,"line":311},[40,11135,86],{"emptyLinePlaceholder":85},[40,11137,11138,11140,11142,11145,11147,11149,11151,11154,11156,11158,11160,11162,11164,11166,11169,11171,11173],{"class":42,"line":316},[40,11139,4021],{"class":53},[40,11141,2438],{"class":57},[40,11143,11144],{"class":2399},"f ",[40,11146,4053],{"class":53},[40,11148,11076],{"class":123},[40,11150,2860],{"class":57},[40,11152,11153],{"class":123},"Open",[40,11155,2396],{"class":57},[40,11157,7343],{"class":2399},[40,11159,10302],{"class":53},[40,11161,4050],{"class":57},[40,11163,11063],{"class":123},[40,11165,2518],{"class":57},[40,11167,11168],{"class":123},"File",[40,11170,411],{"class":57},[40,11172,4066],{"class":53},[40,11174,3648],{"class":57},[40,11176,11177,11179,11182,11184,11187],{"class":42,"line":324},[40,11178,2473],{"class":53},[40,11180,11181],{"class":57}," name ",[40,11183,4921],{"class":53},[40,11185,11186],{"class":127}," \"\u002F\"",[40,11188,2036],{"class":57},[40,11190,11191,11194,11196],{"class":42,"line":329},[40,11192,11193],{"class":57},"        name ",[40,11195,600],{"class":53},[40,11197,11198],{"class":57}," f.prefix\n",[40,11200,11201,11204,11207],{"class":42,"line":344},[40,11202,11203],{"class":57},"    } ",[40,11205,11206],{"class":53},"else",[40,11208,2036],{"class":57},[40,11210,11211,11213,11215,11218,11220,11222,11225,11227,11229,11232,11234],{"class":42,"line":353},[40,11212,11193],{"class":57},[40,11214,600],{"class":53},[40,11216,11217],{"class":57}," f.prefix ",[40,11219,3214],{"class":53},[40,11221,11186],{"class":127},[40,11223,11224],{"class":53}," +",[40,11226,4983],{"class":57},[40,11228,6815],{"class":123},[40,11230,11231],{"class":57},"(name, ",[40,11233,6821],{"class":127},[40,11235,2706],{"class":57},[40,11237,11238],{"class":42,"line":358},[40,11239,2356],{"class":57},[40,11241,11242,11244,11246,11249,11252,11254],{"class":42,"line":364},[40,11243,4078],{"class":53},[40,11245,9846],{"class":57},[40,11247,11248],{"class":123},"FS",[40,11250,11251],{"class":57},"(f.fs).",[40,11253,11153],{"class":123},[40,11255,11256],{"class":57},"(name)\n",[40,11258,11259],{"class":42,"line":372},[40,11260,1690],{"class":57},[15,11262,11263],{},"两个细节：",[1943,11265,11266,11289],{},[432,11267,11268,11276,11277,7256,11279,11281,11282,11284,11285,11288],{},[1635,11269,11270,11273,11274,10766],{},[37,11271,11272],{},"all:dist"," 里的 ",[37,11275,10765],{},"：embed 目录时默认会排除 ",[37,11278,7239],{},[37,11280,2518],{}," 开头的文件（上篇坑二就是被这个坑了，当时靠改 Vite 输出名绕开）。",[37,11283,10765],{}," 前缀表示「全部包含」，前端构建产物里那些 ",[37,11286,11287],{},"_xxx.js"," 助手文件直接安全落地，不用再改前端配置；",[432,11290,11291,11294,11295,11297,11298,11301],{},[37,11292,11293],{},"http.FileSystem"," 包装：加了一层 ",[37,11296,6605],{}," 前缀映射，",[37,11299,11300],{},"http.FileServer"," 和 Gin 都能直接用。",[22,11303,11305],{"id":11304},"_4-路由静态服务-缓存策略","4. 路由：静态服务 + 缓存策略",[30,11307,11309],{"className":3820,"code":11308,"language":631,"meta":35,"style":35},"\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",[37,11310,11311,11316,11358,11370,11375,11390,11435,11464,11482,11503,11508,11527,11550,11555,11572,11576,11597,11601,11641,11645],{"__ignoreMap":35},[40,11312,11313],{"class":42,"line":43},[40,11314,11315],{"class":46},"\u002F\u002F backend\u002Finternal\u002Fhandler\u002Frouter.go\n",[40,11317,11318,11320,11323,11325,11328,11330,11333,11335,11338,11340,11343,11345,11347,11349,11351,11353,11356],{"class":42,"line":50},[40,11319,4021],{"class":53},[40,11321,11322],{"class":123}," NewRouter",[40,11324,2396],{"class":57},[40,11326,11327],{"class":2399},"db",[40,11329,4646],{"class":53},[40,11331,11332],{"class":123},"gorm",[40,11334,2518],{"class":57},[40,11336,11337],{"class":123},"DB",[40,11339,411],{"class":57},[40,11341,11342],{"class":2399},"uploadDir",[40,11344,10302],{"class":53},[40,11346,2860],{"class":57},[40,11348,4053],{"class":53},[40,11350,4649],{"class":123},[40,11352,2518],{"class":57},[40,11354,11355],{"class":123},"Engine",[40,11357,2036],{"class":57},[40,11359,11360,11362,11364,11366,11368],{"class":42,"line":82},[40,11361,4462],{"class":57},[40,11363,3847],{"class":53},[40,11365,4467],{"class":57},[40,11367,4470],{"class":123},[40,11369,2591],{"class":57},[40,11371,11372],{"class":42,"line":89},[40,11373,11374],{"class":53},"    ...\n",[40,11376,11377,11380,11382,11384,11387],{"class":42,"line":95},[40,11378,11379],{"class":57},"    dist ",[40,11381,3847],{"class":53},[40,11383,9846],{"class":57},[40,11385,11386],{"class":123},"FileServer",[40,11388,11389],{"class":57},"(web.Dist)\n",[40,11391,11392,11394,11396,11398,11401,11403,11405,11407,11409,11411,11413,11415,11417,11420,11423,11425,11428,11430,11432],{"class":42,"line":217},[40,11393,4600],{"class":57},[40,11395,4629],{"class":123},[40,11397,2396],{"class":57},[40,11399,11400],{"class":127},"\"\u002Fdist\"",[40,11402,411],{"class":57},[40,11404,4021],{"class":53},[40,11406,2396],{"class":57},[40,11408,4643],{"class":2399},[40,11410,4646],{"class":53},[40,11412,4649],{"class":123},[40,11414,2518],{"class":57},[40,11416,4034],{"class":123},[40,11418,11419],{"class":57},") { c.",[40,11421,11422],{"class":123},"Redirect",[40,11424,2396],{"class":57},[40,11426,11427],{"class":131},"302",[40,11429,411],{"class":57},[40,11431,10833],{"class":127},[40,11433,11434],{"class":57},") })\n",[40,11436,11437,11439,11441,11443,11446,11448,11450,11452,11454,11456,11458,11460,11462],{"class":42,"line":225},[40,11438,4600],{"class":57},[40,11440,4629],{"class":123},[40,11442,2396],{"class":57},[40,11444,11445],{"class":127},"\"\u002Fdist\u002F*filepath\"",[40,11447,411],{"class":57},[40,11449,4021],{"class":53},[40,11451,2396],{"class":57},[40,11453,4643],{"class":2399},[40,11455,4646],{"class":53},[40,11457,4649],{"class":123},[40,11459,2518],{"class":57},[40,11461,4034],{"class":123},[40,11463,3648],{"class":57},[40,11465,11466,11468,11470,11472,11475,11477,11480],{"class":42,"line":230},[40,11467,6808],{"class":57},[40,11469,3847],{"class":53},[40,11471,4900],{"class":57},[40,11473,11474],{"class":123},"Param",[40,11476,2396],{"class":57},[40,11478,11479],{"class":127},"\"filepath\"",[40,11481,2706],{"class":57},[40,11483,11484,11486,11488,11490,11492,11494,11496,11498,11501],{"class":42,"line":237},[40,11485,4915],{"class":53},[40,11487,6830],{"class":57},[40,11489,4921],{"class":53},[40,11491,11186],{"class":127},[40,11493,5016],{"class":53},[40,11495,6830],{"class":57},[40,11497,4921],{"class":53},[40,11499,11500],{"class":127}," \"\u002Findex.html\"",[40,11502,2036],{"class":57},[40,11504,11505],{"class":42,"line":243},[40,11506,11507],{"class":46},"            \u002F\u002F index.html 禁缓存：升级后浏览器立刻拿到新入口\n",[40,11509,11510,11512,11515,11517,11520,11522,11525],{"class":42,"line":251},[40,11511,4931],{"class":57},[40,11513,11514],{"class":123},"Header",[40,11516,2396],{"class":57},[40,11518,11519],{"class":127},"\"Cache-Control\"",[40,11521,411],{"class":57},[40,11523,11524],{"class":127},"\"no-cache, no-store, must-revalidate\"",[40,11526,2706],{"class":57},[40,11528,11529,11532,11534,11537,11539,11542,11545,11548],{"class":42,"line":257},[40,11530,11531],{"class":57},"        } ",[40,11533,11206],{"class":53},[40,11535,11536],{"class":53}," if",[40,11538,4983],{"class":57},[40,11540,11541],{"class":123},"HasPrefix",[40,11543,11544],{"class":57},"(p, ",[40,11546,11547],{"class":127},"\"\u002Fassets\u002F\"",[40,11549,3648],{"class":57},[40,11551,11552],{"class":42,"line":265},[40,11553,11554],{"class":46},"            \u002F\u002F 带内容 hash 的资源可放心长缓存\n",[40,11556,11557,11559,11561,11563,11565,11567,11570],{"class":42,"line":270},[40,11558,4931],{"class":57},[40,11560,11514],{"class":123},[40,11562,2396],{"class":57},[40,11564,11519],{"class":127},[40,11566,411],{"class":57},[40,11568,11569],{"class":127},"\"public, max-age=31536000, immutable\"",[40,11571,2706],{"class":57},[40,11573,11574],{"class":42,"line":279},[40,11575,4969],{"class":57},[40,11577,11578,11581,11584,11586,11588,11591,11594],{"class":42,"line":284},[40,11579,11580],{"class":57},"        http.",[40,11582,11583],{"class":123},"StripPrefix",[40,11585,2396],{"class":57},[40,11587,11400],{"class":127},[40,11589,11590],{"class":57},", dist).",[40,11592,11593],{"class":123},"ServeHTTP",[40,11595,11596],{"class":57},"(c.Writer, c.Request)\n",[40,11598,11599],{"class":42,"line":289},[40,11600,4695],{"class":57},[40,11602,11603,11605,11607,11609,11611,11613,11615,11617,11619,11621,11623,11625,11627,11629,11631,11633,11635,11637,11639],{"class":42,"line":297},[40,11604,4600],{"class":57},[40,11606,4629],{"class":123},[40,11608,2396],{"class":57},[40,11610,6821],{"class":127},[40,11612,411],{"class":57},[40,11614,4021],{"class":53},[40,11616,2396],{"class":57},[40,11618,4643],{"class":2399},[40,11620,4646],{"class":53},[40,11622,4649],{"class":123},[40,11624,2518],{"class":57},[40,11626,4034],{"class":123},[40,11628,11419],{"class":57},[40,11630,11422],{"class":123},[40,11632,2396],{"class":57},[40,11634,11427],{"class":131},[40,11636,411],{"class":57},[40,11638,10833],{"class":127},[40,11640,11434],{"class":57},[40,11642,11643],{"class":42,"line":302},[40,11644,11374],{"class":53},[40,11646,11647],{"class":42,"line":311},[40,11648,1690],{"class":57},[15,11650,11651],{},"缓存策略是内嵌发布的必修课（坑二细说）：入口禁缓存、hash 资源长缓存。",[22,11653,11655],{"id":11654},"_5-打包脚本windows-版-linux-交叉编译版","5. 打包脚本：Windows 版 + Linux 交叉编译版",[30,11657,11659],{"className":114,"code":11658,"language":116,"meta":35,"style":35},"#!\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",[37,11660,11661,11666,11671,11681,11699,11703,11724,11728,11736,11755,11759,11766,11823,11838,11842],{"__ignoreMap":35},[40,11662,11663],{"class":42,"line":43},[40,11664,11665],{"class":46},"#!\u002Fusr\u002Fbin\u002Fenv bash\n",[40,11667,11668],{"class":42,"line":50},[40,11669,11670],{"class":46},"# build-linux.sh —— 在 Windows\u002FmacOS\u002FLinux 任意平台交叉编译 linux\u002Famd64 产物\n",[40,11672,11673,11675,11678],{"class":42,"line":82},[40,11674,7086],{"class":131},[40,11676,11677],{"class":131}," -euo",[40,11679,11680],{"class":127}," pipefail\n",[40,11682,11683,11685,11688,11691,11693,11696],{"class":42,"line":89},[40,11684,7165],{"class":131},[40,11686,11687],{"class":127}," \"$(",[40,11689,11690],{"class":123},"dirname",[40,11692,534],{"class":127},[40,11694,11695],{"class":131},"$0",[40,11697,11698],{"class":127},"\")\"\n",[40,11700,11701],{"class":42,"line":95},[40,11702,86],{"emptyLinePlaceholder":85},[40,11704,11705,11708,11710,11713,11715,11718,11721],{"class":42,"line":217},[40,11706,11707],{"class":57},"BINARY",[40,11709,600],{"class":53},[40,11711,11712],{"class":127},"\"${",[40,11714,11707],{"class":57},[40,11716,11717],{"class":53},":-",[40,11719,11720],{"class":57},"magic-img",[40,11722,11723],{"class":127},"}\"\n",[40,11725,11726],{"class":42,"line":225},[40,11727,86],{"emptyLinePlaceholder":85},[40,11729,11730,11733],{"class":42,"line":230},[40,11731,11732],{"class":131},"echo",[40,11734,11735],{"class":127}," \"[1\u002F3] 构建前端并复制产物到 backend\u002Fweb\u002Fdist ...\"\n",[40,11737,11738,11740,11742,11745,11747,11749,11751,11753],{"class":42,"line":237},[40,11739,2396],{"class":57},[40,11741,7165],{"class":131},[40,11743,11744],{"class":127}," frontend",[40,11746,572],{"class":57},[40,11748,5959],{"class":123},[40,11750,128],{"class":127},[40,11752,646],{"class":127},[40,11754,2706],{"class":57},[40,11756,11757],{"class":42,"line":243},[40,11758,86],{"emptyLinePlaceholder":85},[40,11760,11761,11763],{"class":42,"line":251},[40,11762,11732],{"class":131},[40,11764,11765],{"class":127}," \"[2\u002F3] 交叉编译后端并内嵌前端 (CGO_ENABLED=0 GOOS=linux GOARCH=amd64) ...\"\n",[40,11767,11768,11770,11772,11775,11778,11780,11782,11785,11787,11790,11793,11795,11798,11800,11802,11804,11806,11809,11812,11815,11818,11821],{"class":42,"line":257},[40,11769,2396],{"class":57},[40,11771,7165],{"class":131},[40,11773,11774],{"class":127}," backend",[40,11776,11777],{"class":57}," && CGO_ENABLED",[40,11779,600],{"class":53},[40,11781,2484],{"class":127},[40,11783,11784],{"class":57}," GOOS",[40,11786,600],{"class":53},[40,11788,11789],{"class":127},"linux",[40,11791,11792],{"class":57}," GOARCH",[40,11794,600],{"class":53},[40,11796,11797],{"class":127},"amd64",[40,11799,976],{"class":123},[40,11801,646],{"class":127},[40,11803,655],{"class":131},[40,11805,534],{"class":127},[40,11807,11808],{"class":57},"$BINARY",[40,11810,11811],{"class":127},"\"",[40,11813,11814],{"class":131}," -ldflags=",[40,11816,11817],{"class":127},"\"-s -w\"",[40,11819,11820],{"class":127}," .\u002Fcmd\u002Fserver",[40,11822,2706],{"class":57},[40,11824,11825,11828,11831,11834,11836],{"class":42,"line":265},[40,11826,11827],{"class":123},"chmod",[40,11829,11830],{"class":127}," +x",[40,11832,11833],{"class":127}," \"backend\u002F",[40,11835,11808],{"class":57},[40,11837,727],{"class":127},[40,11839,11840],{"class":42,"line":270},[40,11841,86],{"emptyLinePlaceholder":85},[40,11843,11844,11846,11849,11851],{"class":42,"line":279},[40,11845,11732],{"class":131},[40,11847,11848],{"class":127}," \"[3\u002F3] 打包完成: backend\u002F",[40,11850,11808],{"class":57},[40,11852,727],{"class":127},[15,11854,11855,11856,11859,11860,2287],{},"Windows 版（",[37,11857,11858],{},"build-win.bat","）同理，只是产物叫 ",[37,11861,11862],{},"magic-img.exe",[11,11864,7199],{"id":7199},[22,11866,11868,11869,11872,11873,11875],{"id":11867},"坑一goembed-dist-静默漏掉-_-开头文件升级解法","坑一：",[37,11870,11871],{},"\u002F\u002Fgo:embed dist"," 静默漏掉 ",[37,11874,7239],{}," 开头文件（升级解法）",[15,11877,11878,11879,11886,11887,11889],{},"上篇说过：embed 目录时",[1635,11880,11881,11882,7256,11884,7240],{},"硬编码排除 ",[37,11883,7239],{},[37,11885,2518],{},"。上篇的解法是改 Vite 输出文件名去掉 ",[37,11888,7239],{}," 前缀；这次直接用：",[30,11891,11893],{"className":3820,"code":11892,"language":631,"meta":35,"style":35},"\u002F\u002Fgo:embed all:dist   \u002F\u002F all: 前缀 = 包含全部文件，不再静默跳过\n",[37,11894,11895],{"__ignoreMap":35},[40,11896,11897,11900],{"class":42,"line":43},[40,11898,11899],{"class":46},"\u002F\u002Fgo:embed all:dist",[40,11901,11902],{"class":46},"   \u002F\u002F all: 前缀 = 包含全部文件，不再静默跳过\n",[15,11904,11905],{},[1635,11906,11907,11908,11910,11911,11913],{},"结论：能用 ",[37,11909,10765],{}," 就用 ",[37,11912,10765],{},"，从根上消灭这类\"文件莫名 404\"的问题。",[22,11915,11917],{"id":11916},"坑二开发版是新的打包版却是旧的","坑二：开发版是新的、打包版却是旧的",[15,11919,11920,11921,11924],{},"现象：Vite 开发服务一切正常，",[37,11922,11923],{},":8080\u002Fdist\u002F"," 打包版却是旧界面，强刷也不一定好。",[15,11926,11927,11928,11930,11931,11934,11935,11940],{},"排查过程值得记录：先是怀疑 ",[37,11929,7402],{}," 缓存了旧 embed（上篇坑三的思路），重编重启无效；然后直接抓取 8080 返回的 HTML 和 JS，发现",[1635,11932,11933],{},"服务端内容已经是新的","——问题出在",[1635,11936,11937,11938],{},"浏览器缓存了 ",[37,11939,10899],{},"：它引用旧 hash 的资源，一直不更新。",[15,11942,11943,11945,11946,11949,11950,11953],{},[1635,11944,7217],{},"：入口文件响应 ",[37,11947,11948],{},"Cache-Control: no-store","（见上面路由代码），带 hash 的资源给 ",[37,11951,11952],{},"immutable"," 长缓存。升级后浏览器每次拉新入口，资源文件名一变就自动换新，两全其美。",[9552,11955,11956],{},[15,11957,11958],{},"教训：排查\"新旧不一致\"先分清是哪一层——服务端产物、浏览器入口缓存、还是资源缓存。抓包看响应内容再动手。",[22,11960,11962],{"id":11961},"坑三windows-上跑的-build-linuxsh产出的文件-linux-却执行不了","坑三：Windows 上跑的 build-linux.sh，产出的文件 Linux 却执行不了",[15,11964,11965,11966,6446,11969,11975,11976,11978],{},"最初脚本只写了 ",[37,11967,11968],{},"CGO_ENABLED=0 go build",[1635,11970,11971,11972],{},"没指定 ",[37,11973,11974],{},"GOOS\u002FGOARCH","。在 Windows 上跑，产出的其实是个 Windows PE 文件（只是没叫 ",[37,11977,7704],{},"），传到 Linux 自然「无法执行」。",[15,11980,11981,11983],{},[1635,11982,7217],{},"：脚本里显式交叉编译：",[30,11985,11987],{"className":114,"code":11986,"language":116,"meta":35,"style":35},"CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o \"$BINARY\" -ldflags=\"-s -w\" .\u002Fcmd\u002Fserver\n",[37,11988,11989],{"__ignoreMap":35},[40,11990,11991,11994,11996,11998,12000,12002,12004,12006,12008,12010,12012,12014,12016,12018,12020,12022,12024,12026],{"class":42,"line":43},[40,11992,11993],{"class":57},"CGO_ENABLED",[40,11995,600],{"class":53},[40,11997,2484],{"class":127},[40,11999,11784],{"class":57},[40,12001,600],{"class":53},[40,12003,11789],{"class":127},[40,12005,11792],{"class":57},[40,12007,600],{"class":53},[40,12009,11797],{"class":127},[40,12011,976],{"class":123},[40,12013,646],{"class":127},[40,12015,655],{"class":131},[40,12017,534],{"class":127},[40,12019,11808],{"class":57},[40,12021,11811],{"class":127},[40,12023,11814],{"class":131},[40,12025,11817],{"class":127},[40,12027,12028],{"class":127}," .\u002Fcmd\u002Fserver\n",[15,12030,12031,12032,12035,12036,12039,12040],{},"能这么干的前提是",[1635,12033,12034],{},"全依赖树无 cgo","——SQLite 用的是 modernc 纯 Go 驱动，bcrypt 用 ",[37,12037,12038],{},"x\u002Fcrypto","。这也是选型时就要留意的点：",[1635,12041,12042],{},"想跨平台交叉编译，先把 cgo 依赖清干净。",[15,12044,12045,12046,12049,12050,12053],{},"另外两个小提醒：Windows 上编出的文件没有 Unix 执行位，上传服务器后记得 ",[37,12047,12048],{},"chmod +x","；",[37,12051,12052],{},"-ldflags=\"-s -w\""," 剥离符号，体积从 30MB+ 降到 26MB。",[22,12055,12057],{"id":12056},"坑四dist-被-gitignore-后embed-直接编译失败","坑四：dist 被 gitignore 后，embed 直接编译失败",[15,12059,12060,12062,12063,12065,12066,12069,12070,12072],{},[37,12061,10924],{}," 是构建产物、入了 .gitignore，但 ",[37,12064,6601],{}," 在",[1635,12067,12068],{},"编译期","就要读它——刚 clone 下来直接 ",[37,12071,7402],{}," 会报 embed 找不到文件。",[15,12074,12075,12077,12078,12081],{},[1635,12076,7217],{},"：gitignore 里保留 ",[37,12079,12080],{},"dist\u002F.gitkeep","（一个空文件），让目录存在于版本库中：",[30,12083,12087],{"className":12084,"code":12085,"language":12086,"meta":35,"style":35},"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",[37,12088,12089,12094,12099],{"__ignoreMap":35},[40,12090,12091],{"class":42,"line":43},[40,12092,12093],{},"backend\u002Fweb\u002Fdist\u002F*\n",[40,12095,12096],{"class":42,"line":50},[40,12097,12098],{},"!backend\u002Fweb\u002Fdist\u002F\n",[40,12100,12101],{"class":42,"line":82},[40,12102,12103],{},"!backend\u002Fweb\u002Fdist\u002F.gitkeep\n",[15,12105,12106,12107,12109,12110,12112],{},"这样 clone 后可以先 ",[37,12108,7402],{}," 通过，再 ",[37,12111,10917],{}," 灌入真实前端。",[22,12114,12116],{"id":12115},"坑五500kb-chunk-警告别慌","坑五：>500KB chunk 警告，别慌",[15,12118,12119,12120,12123,12124,2287],{},"Vite 构建会警告单 chunk 超过 500KB。这个项目里 Base UI 组件库全量引入，单 chunk 约 640KB——",[1635,12121,12122],{},"属正常，不要为了消警告去拆配置","。真要优化就上路由级 ",[37,12125,12126],{},"React.lazy",[11,12128,7415],{"id":7415},[30,12130,12132],{"className":114,"code":12131,"language":116,"meta":35,"style":35},"$ build-win.bat        # backendmagic-img.exe\n$ bash build-linux.sh  # backendmagic-img（linux\u002Famd64 ELF）\n",[37,12133,12134,12144],{"__ignoreMap":35},[40,12135,12136,12138,12141],{"class":42,"line":43},[40,12137,2512],{"class":123},[40,12139,12140],{"class":127}," build-win.bat",[40,12142,12143],{"class":46},"        # backendmagic-img.exe\n",[40,12145,12146,12148,12150,12153],{"class":42,"line":50},[40,12147,2512],{"class":123},[40,12149,7427],{"class":127},[40,12151,12152],{"class":127}," build-linux.sh",[40,12154,12155],{"class":46},"  # backendmagic-img（linux\u002Famd64 ELF）\n",[15,12157,12158,12159,12162,12163,2287],{},"一个约 26MB 的二进制，内嵌 React 前端、Gin API、SQLite 数据初始化，拷到 Windows\u002FLinux 机器直接跑，浏览器访问 ",[37,12160,12161],{},"http:\u002F\u002Flocalhost:8080"," 自动重定向到 ",[37,12164,10903],{},[11,12166,424],{"id":424},[15,12168,12169],{},"这轮相比上篇的三点升级：",[429,12171,12172,12178,12188],{},[432,12173,12174,12177],{},[1635,12175,12176],{},"hash 路由 + base \u002Fdist","：省掉 SPA fallback，静态服务即开即用；",[432,12179,12180,3539,12185,12187],{},[1635,12181,12182,12184],{},[37,12183,10765],{}," embed 前缀",[37,12186,7239],{}," 开头文件问题一劳永逸；",[432,12189,12190,12196],{},[1635,12191,12192,12193,12195],{},"显式 ",[37,12194,11974],{}," 交叉编译","：让「一个脚本、任意平台、产出 Linux 二进制」成为现实。",[15,12198,12199,12200,12203],{},"加上",[1635,12201,12202],{},"入口 no-store \u002F 资源 immutable"," 的缓存策略，单文件分发的体验才算闭环——不然每次升级都要和浏览器缓存打架。",[464,12205,12206],{},"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":35,"searchDepth":50,"depth":82,"links":12208},[12209,12210,12211,12218,12226,12227],{"id":2263,"depth":50,"text":2263},{"id":6595,"depth":50,"text":6595},{"id":6609,"depth":50,"text":6609,"children":12212},[12213,12214,12215,12216,12217],{"id":10793,"depth":82,"text":10794},{"id":10911,"depth":82,"text":10912},{"id":10967,"depth":82,"text":10968},{"id":11304,"depth":82,"text":11305},{"id":11654,"depth":82,"text":11655},{"id":7199,"depth":50,"text":7199,"children":12219},[12220,12222,12223,12224,12225],{"id":11867,"depth":82,"text":12221},"坑一：\u002F\u002Fgo:embed dist 静默漏掉 _ 开头文件（升级解法）",{"id":11916,"depth":82,"text":11917},{"id":11961,"depth":82,"text":11962},{"id":12056,"depth":82,"text":12057},{"id":12115,"depth":82,"text":12116},{"id":7415,"depth":50,"text":7415},{"id":424,"depth":50,"text":424},"以 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":10705,"description":12228},"posts\u002Fgo-embed-react-single-binary\u002Findex",[1274,9521,6649,12236,12237],"交叉编译","打包","WV-T-MOO5Cu-aycw-Ony8s9hjA3_k0S4XU-T0QsCow0",{"id":12240,"title":12241,"author":6,"body":12242,"description":13487,"difficulty":478,"draft":479,"extension":480,"image":13488,"meta":13489,"navigation":85,"path":13490,"pinned":479,"published":12232,"seo":13491,"series":13492,"seriesOrder":43,"stem":13493,"tags":13494,"updated":10701,"__hash__":13498},"posts\u002Fposts\u002Freact-photo-album-masonry\u002Findex.md","用 react-photo-album 给生图图库做瀑布流排版",{"type":8,"value":12243,"toc":13470},[12244,12247,12250,12268,12275,12339,12350,12353,12376,12379,12383,12397,12401,12417,12595,12599,12996,12999,13033,13036,13040,13050,13056,13081,13086,13097,13103,13114,13117,13157,13170,13293,13305,13309,13316,13334,13339,13429,13435,13439,13450,13453,13456,13462,13467],[11,12245,12246],{"id":12246},"技术背景",[15,12248,12249],{},"Magic Img 是我做的一个 AI 生图提示词管理工具（自托管、本地优先），其中「生图图库」页面要把所有生成图片以图片墙的形式展示出来。图片墙有两个天然矛盾：",[429,12251,12252,12258],{},[432,12253,12254,12257],{},[1635,12255,12256],{},"等宽网格","（CSS Grid）：整齐，但每行高度由最高的那张决定——不同比例的图片混排时，矮图下面会留下大洞，看起来\"很乱\"；",[432,12259,12260,12263,12264,12267],{},[1635,12261,12262],{},"原生比例展示","：图库必须不裁剪（这是产品约束），所以不能用 ",[37,12265,12266],{},"object-cover"," 强行统一比例。",[15,12269,12270,12271,12274],{},"业内的标准解法是 ",[1635,12272,12273],{},"Masonry 瀑布流","：按列紧密堆放，每张图保持原始比例，列间高度错落但整体紧凑。实现瀑布流的常见方案有三类：",[1720,12276,12277,12291],{},[1723,12278,12279],{},[1726,12280,12281,12283,12286,12289],{},[1729,12282,3075],{},[1729,12284,12285],{},"依赖",[1729,12287,12288],{},"顺序",[1729,12290,5694],{},[1742,12292,12293,12310,12323],{},[1726,12294,12295,12302,12304,12307],{},[1747,12296,12297,12298,12301],{},"CSS ",[37,12299,12300],{},"columns"," 多列",[1747,12303,1778],{},[1747,12305,12306],{},"纵向阅读序（先下后右）",[1747,12308,12309],{},"最新图片不再出现在左上角，时间语义丢失",[1726,12311,12312,12315,12317,12320],{},[1747,12313,12314],{},"grid row-span 自研",[1747,12316,1778],{},[1747,12318,12319],{},"保持",[1747,12321,12322],{},"需固定列数 + 固定行高，逻辑要自己写",[1726,12324,12325,12330,12333,12336],{},[1747,12326,12327],{},[1635,12328,12329],{},"react-photo-album",[1747,12331,12332],{},"~4KB，无依赖",[1747,12334,12335],{},"基本保持（逐列最短列填充）",[1747,12337,12338],{},"专为相册墙设计，TS 友好，维护活跃",[15,12340,12341,12342,12345,12346,12349],{},"最终选了 ",[1635,12343,12344],{},"react-photo-album v3","：体积小、类型齐全，而且它的 Masonry 模式恰好需要「每张图的 ",[37,12347,12348],{},"width\u002Fheight","」——我们的数据库里本来就存了，零额外成本。",[11,12351,12352],{"id":12352},"需求梳理",[429,12354,12355,12361,12367,12370,12373],{},[432,12356,12357,12358,12049],{},"展示全部生成图（21 张起，会持续增长），",[1635,12359,12360],{},"原比例、不裁剪",[432,12362,12363,12364,12049],{},"不同尺寸（2304×1728、4608×3456 等）混排时",[1635,12365,12366],{},"紧凑、不留洞",[432,12368,12369],{},"顺序基本保持「新图靠前」；",[432,12371,12372],{},"点击任意一张打开大图预览（YARL 灯箱），悬停显示所属提示词；",[432,12374,12375],{},"平板性能：墙上只用 300px 缩略图，原图只进灯箱按需加载。",[11,12377,12378],{"id":12378},"安装与使用",[22,12380,12382],{"id":12381},"_1-安装","1. 安装",[30,12384,12386],{"className":114,"code":12385,"language":116,"meta":35,"style":35},"npm install react-photo-album\n",[37,12387,12388],{"__ignoreMap":35},[40,12389,12390,12392,12394],{"class":42,"line":43},[40,12391,5959],{"class":123},[40,12393,577],{"class":127},[40,12395,12396],{"class":127}," react-photo-album\n",[22,12398,12400],{"id":12399},"_2-准备数据关键输入是宽高","2. 准备数据（关键输入是宽高）",[15,12402,12403,12404,12407,12408,7222,12410,7222,12413,12416],{},"库要求的 ",[37,12405,12406],{},"Photo"," 对象只有三个必需字段：",[37,12409,2936],{},[37,12411,12412],{},"width",[37,12414,12415],{},"height","。我们的接口本来就返回每张图的原始宽高：",[30,12418,12420],{"className":10851,"code":12419,"language":10853,"meta":35,"style":35},"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",[37,12421,12422,12440,12445,12452,12456,12472,12482,12486,12490,12523,12534,12553,12558,12563,12585,12590],{"__ignoreMap":35},[40,12423,12424,12426,12429,12431,12434,12437],{"class":42,"line":43},[40,12425,3828],{"class":53},[40,12427,12428],{"class":57}," PhotoAlbum, { ",[40,12430,5213],{"class":53},[40,12432,12433],{"class":57}," Photo } ",[40,12435,12436],{"class":53},"from",[40,12438,12439],{"class":127}," \"react-photo-album\"\n",[40,12441,12442],{"class":42,"line":50},[40,12443,12444],{"class":46},"\u002F\u002F 必须引入样式表！容器\u002F列的布局规则都在这里（坑 1 详述）\n",[40,12446,12447,12449],{"class":42,"line":82},[40,12448,3828],{"class":53},[40,12450,12451],{"class":127}," \"react-photo-album\u002Fstyles.css\"\n",[40,12453,12454],{"class":42,"line":89},[40,12455,86],{"emptyLinePlaceholder":85},[40,12457,12458,12461,12464,12467,12470],{"class":42,"line":95},[40,12459,12460],{"class":53},"interface",[40,12462,12463],{"class":123}," GalleryPhoto",[40,12465,12466],{"class":53}," extends",[40,12468,12469],{"class":123}," Photo",[40,12471,2036],{"class":57},[40,12473,12474,12477,12480],{"class":42,"line":217},[40,12475,12476],{"class":2399},"  prompt_text",[40,12478,12479],{"class":53},"?:",[40,12481,5219],{"class":131},[40,12483,12484],{"class":42,"line":225},[40,12485,1690],{"class":57},[40,12487,12488],{"class":42,"line":230},[40,12489,86],{"emptyLinePlaceholder":85},[40,12491,12492,12494,12497,12499,12501,12503,12505,12508,12511,12514,12516,12518,12520],{"class":42,"line":237},[40,12493,2630],{"class":53},[40,12495,12496],{"class":131}," albumPhotos",[40,12498,160],{"class":53},[40,12500,12463],{"class":123},[40,12502,2418],{"class":57},[40,12504,600],{"class":53},[40,12506,12507],{"class":57}," images.",[40,12509,12510],{"class":123},"map",[40,12512,12513],{"class":57},"((",[40,12515,755],{"class":2399},[40,12517,2860],{"class":57},[40,12519,2824],{"class":53},[40,12521,12522],{"class":57}," ({\n",[40,12524,12525,12528,12531],{"class":42,"line":243},[40,12526,12527],{"class":57},"  key: ",[40,12529,12530],{"class":123},"String",[40,12532,12533],{"class":57},"(img.id),\n",[40,12535,12536,12539,12542,12545,12547,12550],{"class":42,"line":251},[40,12537,12538],{"class":57},"  src: ",[40,12540,12541],{"class":123},"assetUrl",[40,12543,12544],{"class":57},"(img.thumb_path) ",[40,12546,2610],{"class":53},[40,12548,12549],{"class":123}," assetUrl",[40,12551,12552],{"class":57},"(img.file_path),\n",[40,12554,12555],{"class":42,"line":257},[40,12556,12557],{"class":57},"  width: img.width,\n",[40,12559,12560],{"class":42,"line":265},[40,12561,12562],{"class":57},"  height: img.height,\n",[40,12564,12565,12568,12570,12573,12575,12577,12580,12583],{"class":42,"line":270},[40,12566,12567],{"class":57},"  alt: img.prompt_text ",[40,12569,2610],{"class":53},[40,12571,12572],{"class":127}," `生成图 ${",[40,12574,755],{"class":57},[40,12576,2518],{"class":127},[40,12578,12579],{"class":57},"id",[40,12581,12582],{"class":127},"}`",[40,12584,2047],{"class":57},[40,12586,12587],{"class":42,"line":279},[40,12588,12589],{"class":57},"  prompt_text: img.prompt_text,\n",[40,12591,12592],{"class":42,"line":284},[40,12593,12594],{"class":57},"}))\n",[22,12596,12598],{"id":12597},"_3-使用-masonry-布局","3. 使用 Masonry 布局",[30,12600,12602],{"className":10851,"code":12601,"language":10853,"meta":35,"style":35},"\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",[37,12603,12604,12611,12621,12689,12703,12716,12726,12750,12760,12781,12799,12827,12834,12868,12876,12886,12902,12912,12922,12927,12937,12954,12959,12968,12977,12982,12986,12991],{"__ignoreMap":35},[40,12605,12606,12608],{"class":42,"line":43},[40,12607,163],{"class":57},[40,12609,12610],{"class":131},"PhotoAlbum\n",[40,12612,12613,12616,12618],{"class":42,"line":50},[40,12614,12615],{"class":123},"  layout",[40,12617,600],{"class":53},[40,12619,12620],{"class":127},"\"masonry\"\n",[40,12622,12623,12626,12628,12631,12634,12636,12638,12641,12643,12646,12649,12651,12654,12657,12659,12662,12664,12667,12669,12671,12673,12676,12678,12681,12683,12686],{"class":42,"line":82},[40,12624,12625],{"class":123},"  columns",[40,12627,600],{"class":53},[40,12629,12630],{"class":57},"{(",[40,12632,12633],{"class":2399},"cw",[40,12635,2860],{"class":57},[40,12637,2824],{"class":53},[40,12639,12640],{"class":57}," (cw ",[40,12642,163],{"class":53},[40,12644,12645],{"class":131}," 640",[40,12647,12648],{"class":53}," ?",[40,12650,5013],{"class":131},[40,12652,12653],{"class":53}," :",[40,12655,12656],{"class":57}," cw ",[40,12658,163],{"class":53},[40,12660,12661],{"class":131}," 960",[40,12663,12648],{"class":53},[40,12665,12666],{"class":131}," 3",[40,12668,12653],{"class":53},[40,12670,12656],{"class":57},[40,12672,163],{"class":53},[40,12674,12675],{"class":131}," 1280",[40,12677,12648],{"class":53},[40,12679,12680],{"class":131}," 4",[40,12682,12653],{"class":53},[40,12684,12685],{"class":131}," 5",[40,12687,12688],{"class":57},")}\n",[40,12690,12691,12694,12696,12698,12701],{"class":42,"line":89},[40,12692,12693],{"class":123},"  spacing",[40,12695,600],{"class":53},[40,12697,4680],{"class":57},[40,12699,12700],{"class":131},"16",[40,12702,1690],{"class":57},[40,12704,12705,12708,12710,12712,12714],{"class":42,"line":95},[40,12706,12707],{"class":123},"  padding",[40,12709,600],{"class":53},[40,12711,4680],{"class":57},[40,12713,2484],{"class":131},[40,12715,1690],{"class":57},[40,12717,12718,12721,12723],{"class":42,"line":217},[40,12719,12720],{"class":123},"  photos",[40,12722,600],{"class":53},[40,12724,12725],{"class":57},"{albumPhotos}\n",[40,12727,12728,12731,12733,12736,12739,12742,12744,12747],{"class":42,"line":225},[40,12729,12730],{"class":123},"  onClick",[40,12732,600],{"class":53},[40,12734,12735],{"class":57},"{({ ",[40,12737,12738],{"class":2399},"index",[40,12740,12741],{"class":57}," }) ",[40,12743,2824],{"class":53},[40,12745,12746],{"class":123}," openPreview",[40,12748,12749],{"class":57},"(index)}\n",[40,12751,12752,12755,12757],{"class":42,"line":230},[40,12753,12754],{"class":123},"  render",[40,12756,600],{"class":53},[40,12758,12759],{"class":57},"{{\n",[40,12761,12762,12765,12768,12770,12772,12775,12777,12779],{"class":42,"line":237},[40,12763,12764],{"class":123},"    photo",[40,12766,12767],{"class":57},": (",[40,12769,2931],{"class":2399},[40,12771,411],{"class":57},[40,12773,12774],{"class":2399},"context",[40,12776,2860],{"class":57},[40,12778,2824],{"class":53},[40,12780,2036],{"class":57},[40,12782,12783,12785,12788,12791,12794,12796],{"class":42,"line":243},[40,12784,2534],{"class":53},[40,12786,12787],{"class":57}," { ",[40,12789,12790],{"class":131},"onClick",[40,12792,12793],{"class":57}," } ",[40,12795,600],{"class":53},[40,12797,12798],{"class":57}," props\n",[40,12800,12801,12803,12805,12808,12810,12812,12814,12816,12818,12820,12822,12824],{"class":42,"line":251},[40,12802,2534],{"class":53},[40,12804,12787],{"class":57},[40,12806,12807],{"class":131},"photo",[40,12809,411],{"class":57},[40,12811,12738],{"class":131},[40,12813,411],{"class":57},[40,12815,12412],{"class":131},[40,12817,411],{"class":57},[40,12819,12415],{"class":131},[40,12821,12793],{"class":57},[40,12823,600],{"class":53},[40,12825,12826],{"class":57}," context\n",[40,12828,12829,12832],{"class":42,"line":257},[40,12830,12831],{"class":53},"      return",[40,12833,5226],{"class":57},[40,12835,12836,12839,12842,12845,12847,12850,12853,12855,12858,12861,12863,12866],{"class":42,"line":265},[40,12837,12838],{"class":57},"        \u003C",[40,12840,12841],{"class":2096},"button",[40,12843,12844],{"class":123}," type",[40,12846,600],{"class":53},[40,12848,12849],{"class":127},"\"button\"",[40,12851,12852],{"class":123}," onClick",[40,12854,600],{"class":53},[40,12856,12857],{"class":57},"{onClick} ",[40,12859,12860],{"class":123},"className",[40,12862,600],{"class":53},[40,12864,12865],{"class":127},"\"group relative block w-full ...\"",[40,12867,172],{"class":57},[40,12869,12870,12873],{"class":42,"line":270},[40,12871,12872],{"class":57},"          \u003C",[40,12874,12875],{"class":2096},"img\n",[40,12877,12878,12881,12883],{"class":42,"line":279},[40,12879,12880],{"class":123},"            src",[40,12882,600],{"class":53},[40,12884,12885],{"class":57},"{photo.src}\n",[40,12887,12888,12891,12893,12896,12898,12900],{"class":42,"line":284},[40,12889,12890],{"class":123},"            alt",[40,12892,600],{"class":53},[40,12894,12895],{"class":57},"{photo.alt ",[40,12897,2610],{"class":53},[40,12899,4924],{"class":127},[40,12901,1690],{"class":57},[40,12903,12904,12907,12909],{"class":42,"line":289},[40,12905,12906],{"class":123},"            decoding",[40,12908,600],{"class":53},[40,12910,12911],{"class":127},"\"async\"\n",[40,12913,12914,12917,12919],{"class":42,"line":297},[40,12915,12916],{"class":123},"            style",[40,12918,600],{"class":53},[40,12920,12921],{"class":57},"{{ width, height }}\n",[40,12923,12924],{"class":42,"line":302},[40,12925,12926],{"class":57},"          \u002F>\n",[40,12928,12929,12932,12935],{"class":42,"line":311},[40,12930,12931],{"class":57},"          {",[40,12933,12934],{"class":46},"\u002F* hover 提示词浮层 *\u002F",[40,12936,1690],{"class":57},[40,12938,12939,12941,12944,12947,12949,12952],{"class":42,"line":316},[40,12940,12872],{"class":57},[40,12942,12943],{"class":2096},"div",[40,12945,12946],{"class":123}," className",[40,12948,600],{"class":53},[40,12950,12951],{"class":127},"\"... opacity-0 group-hover:opacity-100\"",[40,12953,172],{"class":57},[40,12955,12956],{"class":42,"line":324},[40,12957,12958],{"class":57},"            {photo.prompt_text}\n",[40,12960,12961,12964,12966],{"class":42,"line":329},[40,12962,12963],{"class":57},"          \u003C\u002F",[40,12965,12943],{"class":2096},[40,12967,172],{"class":57},[40,12969,12970,12973,12975],{"class":42,"line":344},[40,12971,12972],{"class":57},"        \u003C\u002F",[40,12974,12841],{"class":2096},[40,12976,172],{"class":57},[40,12978,12979],{"class":42,"line":353},[40,12980,12981],{"class":57},"      )\n",[40,12983,12984],{"class":42,"line":358},[40,12985,7376],{"class":57},[40,12987,12988],{"class":42,"line":364},[40,12989,12990],{"class":57},"  }}\n",[40,12992,12993],{"class":42,"line":372},[40,12994,12995],{"class":57},"\u002F>\n",[15,12997,12998],{},"几个要点：",[1943,13000,13001,13010,13023],{},[432,13002,13003,13005,13006,13009],{},[37,13004,12300],{}," 可以是数字或 ",[37,13007,13008],{},"(containerWidth) => number"," 函数，做响应式列数很方便；",[432,13011,13012,13013,13015,13016,13019,13020,13022],{},"点击回调 ",[37,13014,12790],{}," 里拿到的是",[1635,13017,13018],{},"原始数组下标"," ",[37,13021,12738],{},"，直接传给灯箱即可；",[432,13024,13025,13026,13029,13030,13032],{},"自定义 ",[37,13027,13028],{},"render.photo"," 后，图片尺寸要自己用 context 里的 ",[37,13031,12348],{},"（渲染尺寸）设置——这两个值库已经按列宽算好了。",[11,13034,13035],{"id":13035},"遇到的坑",[22,13037,13039],{"id":13038},"坑-1忘记引入样式表-一张一列","坑 1：忘记引入样式表 → 「一张一列」",[15,13041,13042,13043,13046,13047,2287],{},"这是最坑的一个。装完包、写好 ",[37,13044,13045],{},"\u003CPhotoAlbum layout=\"masonry\">","，页面渲染出来却是：",[1635,13048,13049],{},"每张图占一整行，右侧大片置灰空白",[15,13051,13052,13053,13055],{},"排查过程：先怀疑是 ",[37,13054,12300],{}," 函数没生效，改成固定数字无效；再检查 DOM——容器和 5 个列（track）都在，列的宽度也算对了，但图片全都堆在布局里。",[15,13057,13058,13059,13062,13063,7222,13066,13069,13070,13076,13077,13080],{},"最后翻包的 ",[37,13060,13061],{},"package.json"," 发现它导出了独立的 CSS 文件（",[37,13064,13065],{},".\u002Fstyles.css",[37,13067,13068],{},".\u002Fmasonry.css"," 等），而",[1635,13071,13072,13073],{},"官方 README 示例都带了一行 ",[37,13074,13075],{},"import \"react-photo-album\u002Fstyles.css\"","。容器是 flex、列是 flex-column、列宽按 ",[37,13078,13079],{},"--react-photo-album--columns"," 计算——这些规则全在这个样式表里，不引入就退化成普通块级堆叠。",[15,13082,13083,13085],{},[1635,13084,7217],{},"：顶部加一行：",[30,13087,13089],{"className":10851,"code":13088,"language":10853,"meta":35,"style":35},"import \"react-photo-album\u002Fstyles.css\"\n",[37,13090,13091],{"__ignoreMap":35},[40,13092,13093,13095],{"class":42,"line":43},[40,13094,3828],{"class":53},[40,13096,12451],{"class":127},[15,13098,13099,13100],{},"经验：",[1635,13101,13102],{},"用这类\"无运行时依赖\"的布局库，先看它的 exports 里有没有独立 CSS，有就一定要引。",[22,13104,13106,13107,13109,13110,13113],{"id":13105},"坑-2renderphoto-是-props-context-双参签名","坑 2：",[37,13108,13028],{}," 是 ",[37,13111,13112],{},"(props, context)"," 双参签名",[15,13115,13116],{},"自定义渲染时我凭直觉写了：",[30,13118,13120],{"className":10851,"code":13119,"language":10853,"meta":35,"style":35},"photo: ({ photo, index, width, height, onClick }) => ... \u002F\u002F ❌ 报错：Property 'photo' does not exist\n",[37,13121,13122],{"__ignoreMap":35},[40,13123,13124,13126,13129,13131,13133,13135,13137,13139,13141,13143,13145,13147,13149,13151,13154],{"class":42,"line":43},[40,13125,12807],{"class":123},[40,13127,13128],{"class":57},": ({ ",[40,13130,12807],{"class":2399},[40,13132,411],{"class":57},[40,13134,12738],{"class":2399},[40,13136,411],{"class":57},[40,13138,12412],{"class":2399},[40,13140,411],{"class":57},[40,13142,12415],{"class":2399},[40,13144,411],{"class":57},[40,13146,12790],{"class":2399},[40,13148,12741],{"class":57},[40,13150,2824],{"class":53},[40,13152,13153],{"class":53}," ...",[40,13155,13156],{"class":46}," \u002F\u002F ❌ 报错：Property 'photo' does not exist\n",[15,13158,13159,13160,13162,13163,13166,13167,3539],{},"TS 报错提示 ",[37,13161,12807],{}," 等属性不存在于 ",[37,13164,13165],{},"RenderPhotoProps","。看类型定义才发现 v3 的渲染函数是",[1635,13168,13169],{},"两个参数",[30,13171,13173],{"className":10851,"code":13172,"language":10853,"meta":35,"style":35},"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",[37,13174,13175,13221,13226,13246,13260,13285,13289],{"__ignoreMap":35},[40,13176,13177,13179,13182,13184,13187,13189,13191,13194,13196,13198,13200,13202,13205,13207,13209,13211,13214,13216,13218],{"class":42,"line":43},[40,13178,5213],{"class":53},[40,13180,13181],{"class":123}," RenderFunction",[40,13183,163],{"class":57},[40,13185,13186],{"class":123},"Props",[40,13188,411],{"class":57},[40,13190,4034],{"class":123},[40,13192,13193],{"class":57},"> ",[40,13195,600],{"class":53},[40,13197,2438],{"class":57},[40,13199,2931],{"class":2399},[40,13201,160],{"class":53},[40,13203,13204],{"class":123}," Props",[40,13206,411],{"class":57},[40,13208,12774],{"class":2399},[40,13210,160],{"class":53},[40,13212,13213],{"class":123}," Context",[40,13215,2860],{"class":57},[40,13217,2824],{"class":53},[40,13219,13220],{"class":123}," ReactNode\n",[40,13222,13223],{"class":42,"line":50},[40,13224,13225],{"class":46},"\u002F\u002F photo 的渲染上下文（photo\u002Findex\u002Fwidth\u002Fheight）在第二个参数里\n",[40,13227,13228,13230,13232,13234,13236,13238,13240,13242,13244],{"class":42,"line":82},[40,13229,12807],{"class":123},[40,13231,160],{"class":53},[40,13233,2438],{"class":57},[40,13235,2931],{"class":2399},[40,13237,411],{"class":57},[40,13239,12774],{"class":2399},[40,13241,2860],{"class":57},[40,13243,2824],{"class":53},[40,13245,2036],{"class":57},[40,13247,13248,13250,13252,13254,13257],{"class":42,"line":89},[40,13249,2885],{"class":123},[40,13251,12787],{"class":57},[40,13253,12790],{"class":2399},[40,13255,13256],{"class":57}," } = ",[40,13258,13259],{"class":2399},"props\n",[40,13261,13262,13264,13266,13268,13270,13272,13274,13276,13278,13280,13282],{"class":42,"line":95},[40,13263,2885],{"class":123},[40,13265,12787],{"class":57},[40,13267,12807],{"class":2399},[40,13269,411],{"class":57},[40,13271,12738],{"class":2399},[40,13273,411],{"class":57},[40,13275,12412],{"class":2399},[40,13277,411],{"class":57},[40,13279,12415],{"class":2399},[40,13281,13256],{"class":57},[40,13283,13284],{"class":2399},"context\n",[40,13286,13287],{"class":42,"line":217},[40,13288,10844],{"class":53},[40,13290,13291],{"class":42,"line":225},[40,13292,1690],{"class":57},[15,13294,13295,13297,13298,13300,13301,13304],{},[1635,13296,7217],{},"：按 ",[37,13299,13112],{}," 拆解即可。装新库先扫一眼 ",[37,13302,13303],{},".d.ts"," 里的 Render 类型签名，能少走很多弯路。",[22,13306,13308],{"id":13307},"坑-3测试断言把dom-顺序当成了视觉顺序","坑 3：测试断言把「DOM 顺序」当成了「视觉顺序」",[15,13310,13311,13312,13315],{},"布局修好后，我在浏览器自测脚本里加了一条断言：取前两个 ",[37,13313,13314],{},"\u003Cimg>","，比较它们的左坐标，认为应该并排（不同列）。",[15,13317,13318,13319,13322,13323,7256,13326,13329,13330,13333],{},"结果断言失败——但页面明明是 5 列并排。排查发现：",[1635,13320,13321],{},"瀑布流的 DOM 是按\"列\"组织的","（第 1 列的全部图片在前，第 2 列的在后），所以 ",[37,13324,13325],{},"img[0]",[37,13327,13328],{},"img[1]"," 属于",[1635,13331,13332],{},"同一列","，左坐标当然相同。",[15,13335,13336,13338],{},[1635,13337,7217],{},"：断言改为比较「第 1 列的首张图 vs 第 2 列的首张图」：",[30,13340,13344],{"className":13341,"code":13342,"language":13343,"meta":35,"style":35},"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",[37,13345,13346,13371,13394],{"__ignoreMap":35},[40,13347,13348,13350,13352,13354,13357,13359,13361,13364,13366,13369],{"class":42,"line":43},[40,13349,2630],{"class":53},[40,13351,3645],{"class":131},[40,13353,2033],{"class":53},[40,13355,13356],{"class":57}," tracks[",[40,13358,2484],{"class":131},[40,13360,2549],{"class":57},[40,13362,13363],{"class":123},"querySelector",[40,13365,2396],{"class":57},[40,13367,13368],{"class":127},"\"img\"",[40,13370,2706],{"class":57},[40,13372,13373,13375,13378,13380,13382,13384,13386,13388,13390,13392],{"class":42,"line":50},[40,13374,2630],{"class":53},[40,13376,13377],{"class":131}," b",[40,13379,2033],{"class":53},[40,13381,13356],{"class":57},[40,13383,618],{"class":131},[40,13385,2549],{"class":57},[40,13387,13363],{"class":123},[40,13389,2396],{"class":57},[40,13391,13368],{"class":127},[40,13393,2706],{"class":57},[40,13395,13396,13398,13401,13404,13407,13410,13413,13416,13419,13421,13424,13426],{"class":42,"line":82},[40,13397,2447],{"class":53},[40,13399,13400],{"class":57}," Math.",[40,13402,13403],{"class":123},"abs",[40,13405,13406],{"class":57},"(a.",[40,13408,13409],{"class":123},"getBoundingClientRect",[40,13411,13412],{"class":57},"().left ",[40,13414,13415],{"class":53},"-",[40,13417,13418],{"class":57}," b.",[40,13420,13409],{"class":123},[40,13422,13423],{"class":57},"().left) ",[40,13425,147],{"class":53},[40,13427,13428],{"class":131}," 10\n",[15,13430,13099,13431,13434],{},[1635,13432,13433],{},"验证布局类库时，先搞清楚它生成的 DOM 结构，再写断言","；另外把「真实浏览器探针」当排查手段（直接打印各 track 的 x 坐标和子元素数量），比盯着代码猜快得多。",[22,13436,13438],{"id":13437},"坑-4等宽网格的洞引入前就存在的产品问题","坑 4：等宽网格的\"洞\"（引入前就存在的产品问题）",[15,13440,13441,13442,13445,13446,13449],{},"顺带记录一下为什么换掉原来的布局：CSS Grid ",[37,13443,13444],{},"repeat(auto-fill, minmax(220px, 1fr))"," 等宽铺满，图片 ",[37,13447,13448],{},"w-full h-auto","。当一行里混有不同比例时，行高取最高的那张，矮图底部留空，几行错落下来就显得乱；另外窗口宽度\"差一点放不下下一张\"时，右侧会空出近一整列。",[15,13451,13452],{},"换成 Masonry 后这两个问题都消失了——列内紧密堆叠、列数自适应。",[11,13454,13455],{"id":13455},"效果与小结",[15,13457,13458,13459,13461],{},"最终效果：21 张不同比例的生成图在 5 列瀑布流里紧密排列，原比例不裁剪，悬停出提示词浮层，点击进 YARL 大图预览；缩略图总大小仅 360KB，进页面即全量加载 + ",[37,13460,11952],{}," 长缓存，平板上快速滚动也跟手。",[15,13463,10630,13464],{},[1635,13465,13466],{},"react-photo-album 是个小但专业的相册布局库，只要记住三件事——引样式表、喂宽高、render 函数是双参——就能在十分钟内把图片墙升级成真正的瀑布流。",[464,13468,13469],{},"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":35,"searchDepth":50,"depth":82,"links":13471},[13472,13473,13474,13479,13486],{"id":12246,"depth":50,"text":12246},{"id":12352,"depth":50,"text":12352},{"id":12378,"depth":50,"text":12378,"children":13475},[13476,13477,13478],{"id":12381,"depth":82,"text":12382},{"id":12399,"depth":82,"text":12400},{"id":12597,"depth":82,"text":12598},{"id":13035,"depth":50,"text":13035,"children":13480},[13481,13482,13484,13485],{"id":13038,"depth":82,"text":13039},{"id":13105,"depth":82,"text":13483},"坑 2：render.photo 是 (props, context) 双参签名",{"id":13307,"depth":82,"text":13308},{"id":13437,"depth":82,"text":13438},{"id":13455,"depth":50,"text":13455},"记录在 Magic Img 生图图库中引入 react-photo-album 实现 Masonry 瀑布流的过程：技术选型背景、需求梳理、安装使用，以及「忘记引入样式表导致一张一列」等踩坑与排查方法。","\u002Fposts\u002Freact-photo-album-masonry\u002Fimg\u002Fcover.svg",{},"\u002Fposts\u002Freact-photo-album-masonry",{"title":12241,"description":13487},"前端工程实践","posts\u002Freact-photo-album-masonry\u002Findex",[9521,12329,13495,13496,13497],"瀑布流","前端","Tailwind","-O-kAQLEyRzwVhOpuo-yBxZim9LtvhChPkpP92ipyjM",1786887349178]