[{"data":1,"prerenderedAt":14393},["ShallowReactive",2],{"post-go-embed-react-single-binary":3,"series-source-go-embed-react-single-binary":1707},{"id":4,"title":5,"author":6,"body":7,"description":1689,"difficulty":1690,"draft":1691,"extension":1692,"image":1693,"meta":1694,"navigation":343,"path":1695,"pinned":1691,"published":1696,"seo":1697,"series":1698,"seriesOrder":163,"stem":1699,"tags":1700,"updated":1705,"__hash__":1706},"posts\u002Fposts\u002Fgo-embed-react-single-binary\u002Findex.md","Go 单文件打包进阶：React 前端 + 交叉编译，一次踩完所有坑",null,{"type":8,"value":9,"toc":1668},"minimark",[10,14,29,43,50,53,92,95,106,109,114,125,189,236,254,258,279,315,319,677,680,719,723,1085,1088,1092,1301,1312,1315,1326,1341,1354,1365,1369,1376,1393,1407,1413,1417,1435,1440,1485,1499,1510,1514,1529,1538,1560,1569,1573,1583,1586,1615,1624,1627,1630,1657,1664],[11,12,13],"h2",{"id":13},"背景",[15,16,17,18,23,24,28],"p",{},"这是「Go 单文件打包」系列第三篇。上一篇（",[19,20,22],"a",{"href":21},"\u002Fposts\u002Fgo-embed-frontend","把前端塞进二进制","）记录了一个 Gin + Vue 3 项目的 embed 方案。这次换了个项目继续踩坑：",[25,26,27],"strong",{},"Magic Img","——一个自托管、本地优先的 AI 生图提示词管理工具，技术栈是：",[30,31,32,36],"ul",{},[33,34,35],"li",{},"前端：React 19 + TypeScript + Vite + Tailwind v4（UI 库 Magic UI + shadcn\u002Fui）",[33,37,38,39,42],{},"后端：Go + Gin + GORM + SQLite（",[25,40,41],{},"modernc 纯 Go 驱动，无 cgo","）",[15,44,45,46,49],{},"目标一样：",[25,47,48],{},"一个二进制拖到任何机器上就能跑","——前端、API、数据库全部内嵌。",[15,51,52],{},"和上篇不同的地方有三个，正好是这篇要讲的重点：",[54,55,56,71,85],"ol",{},[33,57,58,59,66,67,70],{},"前端用了 ",[25,60,61,62],{},"HashRouter + Vite base ",[63,64,65],"code",{},"\u002Fdist","，服务端",[25,68,69],{},"不再需要 SPA fallback","（上篇坑一的新解法）；",[33,72,73,74,80,81,84],{},"embed 用 ",[25,75,76,79],{},[63,77,78],{},"all:"," 前缀","一劳永逸解决 ",[63,82,83],{},"_"," 开头文件被排除的问题（上篇坑二的升级解法）；",[33,86,87,88,91],{},"需要",[25,89,90],{},"在 Windows 上交叉编译 Linux 产物","，踩了「生成的文件无法在 Linux 执行」的坑。",[11,93,94],{"id":94},"核心思路",[15,96,97,98,101,102,105],{},"和上篇一致：",[63,99,100],{},"\u002F\u002Fgo:embed"," 在编译期把前端 ",[63,103,104],{},"dist\u002F"," 塞进二进制，运行时由 Go 直接提供静态服务。差异在于路由策略和缓存策略，后面细说。",[11,107,108],{"id":108},"逐步实现",[110,111,113],"h3",{"id":112},"_1-前端hash-路由-base-dist","1. 前端：hash 路由 + base \u002Fdist",[15,115,116,117,120,121,124],{},"vite.config.ts 里固定 ",[63,118,119],{},"base: \"\u002Fdist\u002F\"","，路由用 ",[63,122,123],{},"HashRouter","：",[126,127,132],"pre",{"className":128,"code":129,"language":130,"meta":131,"style":131},"language-ts shiki shiki-themes github-light github-dark","\u002F\u002F vite.config.ts\nexport default defineConfig({\n  base: \"\u002Fdist\u002F\",   \u002F\u002F 所有资源路径以 \u002Fdist\u002F 开头\n  ...\n})\n","ts","",[63,133,134,143,161,177,183],{"__ignoreMap":131},[135,136,139],"span",{"class":137,"line":138},"line",1,[135,140,142],{"class":141},"sHbNN","\u002F\u002F vite.config.ts\n",[135,144,146,150,153,157],{"class":137,"line":145},2,[135,147,149],{"class":148},"siTax","export",[135,151,152],{"class":148}," default",[135,154,156],{"class":155},"sw2iP"," defineConfig",[135,158,160],{"class":159},"sIX_F","({\n",[135,162,164,167,171,174],{"class":137,"line":163},3,[135,165,166],{"class":159},"  base: ",[135,168,170],{"class":169},"scXbn","\"\u002Fdist\u002F\"",[135,172,173],{"class":159},",   ",[135,175,176],{"class":141},"\u002F\u002F 所有资源路径以 \u002Fdist\u002F 开头\n",[135,178,180],{"class":137,"line":179},4,[135,181,182],{"class":148},"  ...\n",[135,184,186],{"class":137,"line":185},5,[135,187,188],{"class":159},"})\n",[126,190,194],{"className":191,"code":192,"language":193,"meta":131,"style":131},"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",[63,195,196,201,212,227],{"__ignoreMap":131},[135,197,198],{"class":137,"line":138},[135,199,200],{"class":141},"\u002F\u002F App.tsx —— HashRouter：路径信息全在 location.hash 里\n",[135,202,203,206,209],{"class":137,"line":145},[135,204,205],{"class":159},"\u003C",[135,207,123],{"class":208},"suQ91",[135,210,211],{"class":159},">\n",[135,213,214,217,220,223,225],{"class":137,"line":163},[135,215,216],{"class":159},"  \u003C",[135,218,219],{"class":208},"Routes",[135,221,222],{"class":159},">...\u003C\u002F",[135,224,219],{"class":208},[135,226,211],{"class":159},[135,228,229,232,234],{"class":137,"line":179},[135,230,231],{"class":159},"\u003C\u002F",[135,233,123],{"class":208},[135,235,211],{"class":159},[15,237,238,241,242,245,246,249,250,253],{},[25,239,240],{},"为什么这样选","：history 路由需要服务端把未知路径全部 fallback 回 ",[63,243,244],{},"index.html","（上篇坑一）。hash 路由下浏览器请求的永远是 ",[63,247,248],{},"\u002Fdist\u002F"," 这一个入口，",[25,251,252],{},"静态服务直接返回 index.html 就完事，一个 fallback 都不用写","。内嵌场景里这是最省心的组合。",[110,255,257],{"id":256},"_2-构建产物复制进后端目录","2. 构建产物复制进后端目录",[15,259,260,263,264,266,267,270,271,274,275,278],{},[63,261,262],{},"npm run build"," 除了编译，还会把 ",[63,265,104],{}," 复制到 ",[63,268,269],{},"backend\u002Fweb\u002Fdist","（一个小脚本 ",[63,272,273],{},"scripts\u002Fcopy-dist.mjs"," 干这个活），供 ",[63,276,277],{},"go:embed"," 使用：",[126,280,284],{"className":281,"code":282,"language":283,"meta":131,"style":131},"language-json shiki shiki-themes github-light github-dark","\u002F\u002F frontend\u002Fpackage.json\n\"scripts\": {\n  \"build\": \"tsc -b && vite build && node scripts\u002Fcopy-dist.mjs\"\n}\n","json",[63,285,286,291,299,310],{"__ignoreMap":131},[135,287,288],{"class":137,"line":138},[135,289,290],{"class":141},"\u002F\u002F frontend\u002Fpackage.json\n",[135,292,293,296],{"class":137,"line":145},[135,294,295],{"class":169},"\"scripts\"",[135,297,298],{"class":159},": {\n",[135,300,301,304,307],{"class":137,"line":163},[135,302,303],{"class":208},"  \"build\"",[135,305,306],{"class":159},": ",[135,308,309],{"class":169},"\"tsc -b && vite build && node scripts\u002Fcopy-dist.mjs\"\n",[135,311,312],{"class":137,"line":179},[135,313,314],{"class":159},"}\n",[110,316,318],{"id":317},"_3-后端embed-httpfilesystem-包装","3. 后端：embed + http.FileSystem 包装",[126,320,324],{"className":321,"code":322,"language":323,"meta":131,"style":131},"language-go shiki shiki-themes github-light github-dark","\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","go",[63,325,326,331,339,345,353,364,374,384,390,395,401,418,423,429,469,474,489,498,513,518,523,574,591,603,614,646,652,672],{"__ignoreMap":131},[135,327,328],{"class":137,"line":138},[135,329,330],{"class":141},"\u002F\u002F backend\u002Fweb\u002Fembed.go\n",[135,332,333,336],{"class":137,"line":145},[135,334,335],{"class":148},"package",[135,337,338],{"class":155}," web\n",[135,340,341],{"class":137,"line":163},[135,342,344],{"emptyLinePlaceholder":343},true,"\n",[135,346,347,350],{"class":137,"line":179},[135,348,349],{"class":148},"import",[135,351,352],{"class":159}," (\n",[135,354,355,358,361],{"class":137,"line":185},[135,356,357],{"class":169},"    \"",[135,359,360],{"class":155},"embed",[135,362,363],{"class":169},"\"\n",[135,365,367,369,372],{"class":137,"line":366},6,[135,368,357],{"class":169},[135,370,371],{"class":155},"net\u002Fhttp",[135,373,363],{"class":169},[135,375,377,379,382],{"class":137,"line":376},7,[135,378,357],{"class":169},[135,380,381],{"class":155},"strings",[135,383,363],{"class":169},[135,385,387],{"class":137,"line":386},8,[135,388,389],{"class":159},")\n",[135,391,393],{"class":137,"line":392},9,[135,394,344],{"emptyLinePlaceholder":343},[135,396,398],{"class":137,"line":397},10,[135,399,400],{"class":141},"\u002F\u002Fgo:embed all:dist\n",[135,402,404,407,410,412,415],{"class":137,"line":403},11,[135,405,406],{"class":148},"var",[135,408,409],{"class":159}," distFS ",[135,411,360],{"class":155},[135,413,414],{"class":159},".",[135,416,417],{"class":155},"FS\n",[135,419,421],{"class":137,"line":420},12,[135,422,344],{"emptyLinePlaceholder":343},[135,424,426],{"class":137,"line":425},13,[135,427,428],{"class":141},"\u002F\u002F Dist 内嵌前端静态文件系统\n",[135,430,432,434,437,440,442,445,448,451,454,457,460,463,466],{"class":137,"line":431},14,[135,433,406],{"class":148},[135,435,436],{"class":159}," Dist ",[135,438,439],{"class":155},"http",[135,441,414],{"class":159},[135,443,444],{"class":155},"FileSystem",[135,446,447],{"class":148}," =",[135,449,450],{"class":148}," &",[135,452,453],{"class":155},"fileSystem",[135,455,456],{"class":159},"{prefix: ",[135,458,459],{"class":169},"\"dist\"",[135,461,462],{"class":159},", fs: ",[135,464,465],{"class":148},"&",[135,467,468],{"class":159},"distFS}\n",[135,470,472],{"class":137,"line":471},15,[135,473,344],{"emptyLinePlaceholder":343},[135,475,477,480,483,486],{"class":137,"line":476},16,[135,478,479],{"class":148},"type",[135,481,482],{"class":155}," fileSystem",[135,484,485],{"class":148}," struct",[135,487,488],{"class":159}," {\n",[135,490,492,495],{"class":137,"line":491},17,[135,493,494],{"class":159},"    prefix ",[135,496,497],{"class":148},"string\n",[135,499,501,504,507,509,511],{"class":137,"line":500},18,[135,502,503],{"class":159},"    fs     ",[135,505,506],{"class":148},"*",[135,508,360],{"class":155},[135,510,414],{"class":159},[135,512,417],{"class":155},[135,514,516],{"class":137,"line":515},19,[135,517,314],{"class":159},[135,519,521],{"class":137,"line":520},20,[135,522,344],{"emptyLinePlaceholder":343},[135,524,526,529,532,536,538,540,543,546,549,552,555,558,560,562,565,568,571],{"class":137,"line":525},21,[135,527,528],{"class":148},"func",[135,530,531],{"class":159}," (",[135,533,535],{"class":534},"sPK6S","f ",[135,537,506],{"class":148},[135,539,453],{"class":155},[135,541,542],{"class":159},") ",[135,544,545],{"class":155},"Open",[135,547,548],{"class":159},"(",[135,550,551],{"class":534},"name",[135,553,554],{"class":148}," string",[135,556,557],{"class":159},") (",[135,559,439],{"class":155},[135,561,414],{"class":159},[135,563,564],{"class":155},"File",[135,566,567],{"class":159},", ",[135,569,570],{"class":148},"error",[135,572,573],{"class":159},") {\n",[135,575,577,580,583,586,589],{"class":137,"line":576},22,[135,578,579],{"class":148},"    if",[135,581,582],{"class":159}," name ",[135,584,585],{"class":148},"==",[135,587,588],{"class":169}," \"\u002F\"",[135,590,488],{"class":159},[135,592,594,597,600],{"class":137,"line":593},23,[135,595,596],{"class":159},"        name ",[135,598,599],{"class":148},"=",[135,601,602],{"class":159}," f.prefix\n",[135,604,606,609,612],{"class":137,"line":605},24,[135,607,608],{"class":159},"    } ",[135,610,611],{"class":148},"else",[135,613,488],{"class":159},[135,615,617,619,621,624,627,629,632,635,638,641,644],{"class":137,"line":616},25,[135,618,596],{"class":159},[135,620,599],{"class":148},[135,622,623],{"class":159}," f.prefix ",[135,625,626],{"class":148},"+",[135,628,588],{"class":169},[135,630,631],{"class":148}," +",[135,633,634],{"class":159}," strings.",[135,636,637],{"class":155},"TrimPrefix",[135,639,640],{"class":159},"(name, ",[135,642,643],{"class":169},"\"\u002F\"",[135,645,389],{"class":159},[135,647,649],{"class":137,"line":648},26,[135,650,651],{"class":159},"    }\n",[135,653,655,658,661,664,667,669],{"class":137,"line":654},27,[135,656,657],{"class":148},"    return",[135,659,660],{"class":159}," http.",[135,662,663],{"class":155},"FS",[135,665,666],{"class":159},"(f.fs).",[135,668,545],{"class":155},[135,670,671],{"class":159},"(name)\n",[135,673,675],{"class":137,"line":674},28,[135,676,314],{"class":159},[15,678,679],{},"两个细节：",[30,681,682,706],{},[33,683,684,692,693,695,696,698,699,701,702,705],{},[25,685,686,689,690,79],{},[63,687,688],{},"all:dist"," 里的 ",[63,691,78],{},"：embed 目录时默认会排除 ",[63,694,83],{}," 和 ",[63,697,414],{}," 开头的文件（上篇坑二就是被这个坑了，当时靠改 Vite 输出名绕开）。",[63,700,78],{}," 前缀表示「全部包含」，前端构建产物里那些 ",[63,703,704],{},"_xxx.js"," 助手文件直接安全落地，不用再改前端配置；",[33,707,708,711,712,714,715,718],{},[63,709,710],{},"http.FileSystem"," 包装：加了一层 ",[63,713,104],{}," 前缀映射，",[63,716,717],{},"http.FileServer"," 和 Gin 都能直接用。",[110,720,722],{"id":721},"_4-路由静态服务-缓存策略","4. 路由：静态服务 + 缓存策略",[126,724,726],{"className":321,"code":725,"language":323,"meta":131,"style":131},"\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",[63,727,728,733,777,794,799,814,863,892,912,936,941,961,984,989,1006,1011,1032,1037,1077,1081],{"__ignoreMap":131},[135,729,730],{"class":137,"line":138},[135,731,732],{"class":141},"\u002F\u002F backend\u002Finternal\u002Fhandler\u002Frouter.go\n",[135,734,735,737,740,742,745,748,751,753,756,758,761,763,765,767,770,772,775],{"class":137,"line":145},[135,736,528],{"class":148},[135,738,739],{"class":155}," NewRouter",[135,741,548],{"class":159},[135,743,744],{"class":534},"db",[135,746,747],{"class":148}," *",[135,749,750],{"class":155},"gorm",[135,752,414],{"class":159},[135,754,755],{"class":155},"DB",[135,757,567],{"class":159},[135,759,760],{"class":534},"uploadDir",[135,762,554],{"class":148},[135,764,542],{"class":159},[135,766,506],{"class":148},[135,768,769],{"class":155},"gin",[135,771,414],{"class":159},[135,773,774],{"class":155},"Engine",[135,776,488],{"class":159},[135,778,779,782,785,788,791],{"class":137,"line":163},[135,780,781],{"class":159},"    r ",[135,783,784],{"class":148},":=",[135,786,787],{"class":159}," gin.",[135,789,790],{"class":155},"Default",[135,792,793],{"class":159},"()\n",[135,795,796],{"class":137,"line":179},[135,797,798],{"class":148},"    ...\n",[135,800,801,804,806,808,811],{"class":137,"line":185},[135,802,803],{"class":159},"    dist ",[135,805,784],{"class":148},[135,807,660],{"class":159},[135,809,810],{"class":155},"FileServer",[135,812,813],{"class":159},"(web.Dist)\n",[135,815,816,819,822,824,827,829,831,833,836,838,840,842,845,848,851,853,856,858,860],{"class":137,"line":366},[135,817,818],{"class":159},"    r.",[135,820,821],{"class":155},"GET",[135,823,548],{"class":159},[135,825,826],{"class":169},"\"\u002Fdist\"",[135,828,567],{"class":159},[135,830,528],{"class":148},[135,832,548],{"class":159},[135,834,835],{"class":534},"c",[135,837,747],{"class":148},[135,839,769],{"class":155},[135,841,414],{"class":159},[135,843,844],{"class":155},"Context",[135,846,847],{"class":159},") { c.",[135,849,850],{"class":155},"Redirect",[135,852,548],{"class":159},[135,854,855],{"class":208},"302",[135,857,567],{"class":159},[135,859,170],{"class":169},[135,861,862],{"class":159},") })\n",[135,864,865,867,869,871,874,876,878,880,882,884,886,888,890],{"class":137,"line":376},[135,866,818],{"class":159},[135,868,821],{"class":155},[135,870,548],{"class":159},[135,872,873],{"class":169},"\"\u002Fdist\u002F*filepath\"",[135,875,567],{"class":159},[135,877,528],{"class":148},[135,879,548],{"class":159},[135,881,835],{"class":534},[135,883,747],{"class":148},[135,885,769],{"class":155},[135,887,414],{"class":159},[135,889,844],{"class":155},[135,891,573],{"class":159},[135,893,894,897,899,902,905,907,910],{"class":137,"line":386},[135,895,896],{"class":159},"        p ",[135,898,784],{"class":148},[135,900,901],{"class":159}," c.",[135,903,904],{"class":155},"Param",[135,906,548],{"class":159},[135,908,909],{"class":169},"\"filepath\"",[135,911,389],{"class":159},[135,913,914,917,920,922,924,927,929,931,934],{"class":137,"line":392},[135,915,916],{"class":148},"        if",[135,918,919],{"class":159}," p ",[135,921,585],{"class":148},[135,923,588],{"class":169},[135,925,926],{"class":148}," ||",[135,928,919],{"class":159},[135,930,585],{"class":148},[135,932,933],{"class":169}," \"\u002Findex.html\"",[135,935,488],{"class":159},[135,937,938],{"class":137,"line":397},[135,939,940],{"class":141},"            \u002F\u002F index.html 禁缓存：升级后浏览器立刻拿到新入口\n",[135,942,943,946,949,951,954,956,959],{"class":137,"line":403},[135,944,945],{"class":159},"            c.",[135,947,948],{"class":155},"Header",[135,950,548],{"class":159},[135,952,953],{"class":169},"\"Cache-Control\"",[135,955,567],{"class":159},[135,957,958],{"class":169},"\"no-cache, no-store, must-revalidate\"",[135,960,389],{"class":159},[135,962,963,966,968,971,973,976,979,982],{"class":137,"line":420},[135,964,965],{"class":159},"        } ",[135,967,611],{"class":148},[135,969,970],{"class":148}," if",[135,972,634],{"class":159},[135,974,975],{"class":155},"HasPrefix",[135,977,978],{"class":159},"(p, ",[135,980,981],{"class":169},"\"\u002Fassets\u002F\"",[135,983,573],{"class":159},[135,985,986],{"class":137,"line":425},[135,987,988],{"class":141},"            \u002F\u002F 带内容 hash 的资源可放心长缓存\n",[135,990,991,993,995,997,999,1001,1004],{"class":137,"line":431},[135,992,945],{"class":159},[135,994,948],{"class":155},[135,996,548],{"class":159},[135,998,953],{"class":169},[135,1000,567],{"class":159},[135,1002,1003],{"class":169},"\"public, max-age=31536000, immutable\"",[135,1005,389],{"class":159},[135,1007,1008],{"class":137,"line":471},[135,1009,1010],{"class":159},"        }\n",[135,1012,1013,1016,1019,1021,1023,1026,1029],{"class":137,"line":476},[135,1014,1015],{"class":159},"        http.",[135,1017,1018],{"class":155},"StripPrefix",[135,1020,548],{"class":159},[135,1022,826],{"class":169},[135,1024,1025],{"class":159},", dist).",[135,1027,1028],{"class":155},"ServeHTTP",[135,1030,1031],{"class":159},"(c.Writer, c.Request)\n",[135,1033,1034],{"class":137,"line":491},[135,1035,1036],{"class":159},"    })\n",[135,1038,1039,1041,1043,1045,1047,1049,1051,1053,1055,1057,1059,1061,1063,1065,1067,1069,1071,1073,1075],{"class":137,"line":500},[135,1040,818],{"class":159},[135,1042,821],{"class":155},[135,1044,548],{"class":159},[135,1046,643],{"class":169},[135,1048,567],{"class":159},[135,1050,528],{"class":148},[135,1052,548],{"class":159},[135,1054,835],{"class":534},[135,1056,747],{"class":148},[135,1058,769],{"class":155},[135,1060,414],{"class":159},[135,1062,844],{"class":155},[135,1064,847],{"class":159},[135,1066,850],{"class":155},[135,1068,548],{"class":159},[135,1070,855],{"class":208},[135,1072,567],{"class":159},[135,1074,170],{"class":169},[135,1076,862],{"class":159},[135,1078,1079],{"class":137,"line":515},[135,1080,798],{"class":148},[135,1082,1083],{"class":137,"line":520},[135,1084,314],{"class":159},[15,1086,1087],{},"缓存策略是内嵌发布的必修课（坑二细说）：入口禁缓存、hash 资源长缓存。",[110,1089,1091],{"id":1090},"_5-打包脚本windows-版-linux-交叉编译版","5. 打包脚本：Windows 版 + Linux 交叉编译版",[126,1093,1097],{"className":1094,"code":1095,"language":1096,"meta":131,"style":131},"language-bash shiki shiki-themes github-light github-dark","#!\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","bash",[63,1098,1099,1104,1109,1120,1140,1144,1165,1169,1177,1200,1204,1211,1271,1286,1290],{"__ignoreMap":131},[135,1100,1101],{"class":137,"line":138},[135,1102,1103],{"class":141},"#!\u002Fusr\u002Fbin\u002Fenv bash\n",[135,1105,1106],{"class":137,"line":145},[135,1107,1108],{"class":141},"# build-linux.sh —— 在 Windows\u002FmacOS\u002FLinux 任意平台交叉编译 linux\u002Famd64 产物\n",[135,1110,1111,1114,1117],{"class":137,"line":163},[135,1112,1113],{"class":208},"set",[135,1115,1116],{"class":208}," -euo",[135,1118,1119],{"class":169}," pipefail\n",[135,1121,1122,1125,1128,1131,1134,1137],{"class":137,"line":179},[135,1123,1124],{"class":208},"cd",[135,1126,1127],{"class":169}," \"$(",[135,1129,1130],{"class":155},"dirname",[135,1132,1133],{"class":169}," \"",[135,1135,1136],{"class":208},"$0",[135,1138,1139],{"class":169},"\")\"\n",[135,1141,1142],{"class":137,"line":185},[135,1143,344],{"emptyLinePlaceholder":343},[135,1145,1146,1149,1151,1154,1156,1159,1162],{"class":137,"line":366},[135,1147,1148],{"class":159},"BINARY",[135,1150,599],{"class":148},[135,1152,1153],{"class":169},"\"${",[135,1155,1148],{"class":159},[135,1157,1158],{"class":148},":-",[135,1160,1161],{"class":159},"magic-img",[135,1163,1164],{"class":169},"}\"\n",[135,1166,1167],{"class":137,"line":376},[135,1168,344],{"emptyLinePlaceholder":343},[135,1170,1171,1174],{"class":137,"line":386},[135,1172,1173],{"class":208},"echo",[135,1175,1176],{"class":169}," \"[1\u002F3] 构建前端并复制产物到 backend\u002Fweb\u002Fdist ...\"\n",[135,1178,1179,1181,1183,1186,1189,1192,1195,1198],{"class":137,"line":392},[135,1180,548],{"class":159},[135,1182,1124],{"class":208},[135,1184,1185],{"class":169}," frontend",[135,1187,1188],{"class":159}," && ",[135,1190,1191],{"class":155},"npm",[135,1193,1194],{"class":169}," run",[135,1196,1197],{"class":169}," build",[135,1199,389],{"class":159},[135,1201,1202],{"class":137,"line":397},[135,1203,344],{"emptyLinePlaceholder":343},[135,1205,1206,1208],{"class":137,"line":403},[135,1207,1173],{"class":208},[135,1209,1210],{"class":169}," \"[2\u002F3] 交叉编译后端并内嵌前端 (CGO_ENABLED=0 GOOS=linux GOARCH=amd64) ...\"\n",[135,1212,1213,1215,1217,1220,1223,1225,1228,1231,1233,1236,1239,1241,1244,1247,1249,1252,1254,1257,1260,1263,1266,1269],{"class":137,"line":420},[135,1214,548],{"class":159},[135,1216,1124],{"class":208},[135,1218,1219],{"class":169}," backend",[135,1221,1222],{"class":159}," && CGO_ENABLED",[135,1224,599],{"class":148},[135,1226,1227],{"class":169},"0",[135,1229,1230],{"class":159}," GOOS",[135,1232,599],{"class":148},[135,1234,1235],{"class":169},"linux",[135,1237,1238],{"class":159}," GOARCH",[135,1240,599],{"class":148},[135,1242,1243],{"class":169},"amd64",[135,1245,1246],{"class":155}," go",[135,1248,1197],{"class":169},[135,1250,1251],{"class":208}," -o",[135,1253,1133],{"class":169},[135,1255,1256],{"class":159},"$BINARY",[135,1258,1259],{"class":169},"\"",[135,1261,1262],{"class":208}," -ldflags=",[135,1264,1265],{"class":169},"\"-s -w\"",[135,1267,1268],{"class":169}," .\u002Fcmd\u002Fserver",[135,1270,389],{"class":159},[135,1272,1273,1276,1279,1282,1284],{"class":137,"line":425},[135,1274,1275],{"class":155},"chmod",[135,1277,1278],{"class":169}," +x",[135,1280,1281],{"class":169}," \"backend\u002F",[135,1283,1256],{"class":159},[135,1285,363],{"class":169},[135,1287,1288],{"class":137,"line":431},[135,1289,344],{"emptyLinePlaceholder":343},[135,1291,1292,1294,1297,1299],{"class":137,"line":471},[135,1293,1173],{"class":208},[135,1295,1296],{"class":169}," \"[3\u002F3] 打包完成: backend\u002F",[135,1298,1256],{"class":159},[135,1300,363],{"class":169},[15,1302,1303,1304,1307,1308,1311],{},"Windows 版（",[63,1305,1306],{},"build-win.bat","）同理，只是产物叫 ",[63,1309,1310],{},"magic-img.exe","。",[11,1313,1314],{"id":1314},"踩坑记录",[110,1316,1318,1319,1322,1323,1325],{"id":1317},"坑一goembed-dist-静默漏掉-_-开头文件升级解法","坑一：",[63,1320,1321],{},"\u002F\u002Fgo:embed dist"," 静默漏掉 ",[63,1324,83],{}," 开头文件（升级解法）",[15,1327,1328,1329,1337,1338,1340],{},"上篇说过：embed 目录时",[25,1330,1331,1332,695,1334,1336],{},"硬编码排除 ",[63,1333,83],{},[63,1335,414],{}," 开头文件","。上篇的解法是改 Vite 输出文件名去掉 ",[63,1339,83],{}," 前缀；这次直接用：",[126,1342,1344],{"className":321,"code":1343,"language":323,"meta":131,"style":131},"\u002F\u002Fgo:embed all:dist   \u002F\u002F all: 前缀 = 包含全部文件，不再静默跳过\n",[63,1345,1346],{"__ignoreMap":131},[135,1347,1348,1351],{"class":137,"line":138},[135,1349,1350],{"class":141},"\u002F\u002Fgo:embed all:dist",[135,1352,1353],{"class":141},"   \u002F\u002F all: 前缀 = 包含全部文件，不再静默跳过\n",[15,1355,1356],{},[25,1357,1358,1359,1361,1362,1364],{},"结论：能用 ",[63,1360,78],{}," 就用 ",[63,1363,78],{},"，从根上消灭这类\"文件莫名 404\"的问题。",[110,1366,1368],{"id":1367},"坑二开发版是新的打包版却是旧的","坑二：开发版是新的、打包版却是旧的",[15,1370,1371,1372,1375],{},"现象：Vite 开发服务一切正常，",[63,1373,1374],{},":8080\u002Fdist\u002F"," 打包版却是旧界面，强刷也不一定好。",[15,1377,1378,1379,1382,1383,1386,1387,1392],{},"排查过程值得记录：先是怀疑 ",[63,1380,1381],{},"go build"," 缓存了旧 embed（上篇坑三的思路），重编重启无效；然后直接抓取 8080 返回的 HTML 和 JS，发现",[25,1384,1385],{},"服务端内容已经是新的","——问题出在",[25,1388,1389,1390],{},"浏览器缓存了 ",[63,1391,244],{},"：它引用旧 hash 的资源，一直不更新。",[15,1394,1395,1398,1399,1402,1403,1406],{},[25,1396,1397],{},"解决","：入口文件响应 ",[63,1400,1401],{},"Cache-Control: no-store","（见上面路由代码），带 hash 的资源给 ",[63,1404,1405],{},"immutable"," 长缓存。升级后浏览器每次拉新入口，资源文件名一变就自动换新，两全其美。",[1408,1409,1410],"blockquote",{},[15,1411,1412],{},"教训：排查\"新旧不一致\"先分清是哪一层——服务端产物、浏览器入口缓存、还是资源缓存。抓包看响应内容再动手。",[110,1414,1416],{"id":1415},"坑三windows-上跑的-build-linuxsh产出的文件-linux-却执行不了","坑三：Windows 上跑的 build-linux.sh，产出的文件 Linux 却执行不了",[15,1418,1419,1420,1423,1424,1430,1431,1434],{},"最初脚本只写了 ",[63,1421,1422],{},"CGO_ENABLED=0 go build","，",[25,1425,1426,1427],{},"没指定 ",[63,1428,1429],{},"GOOS\u002FGOARCH","。在 Windows 上跑，产出的其实是个 Windows PE 文件（只是没叫 ",[63,1432,1433],{},".exe","），传到 Linux 自然「无法执行」。",[15,1436,1437,1439],{},[25,1438,1397],{},"：脚本里显式交叉编译：",[126,1441,1443],{"className":1094,"code":1442,"language":1096,"meta":131,"style":131},"CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o \"$BINARY\" -ldflags=\"-s -w\" .\u002Fcmd\u002Fserver\n",[63,1444,1445],{"__ignoreMap":131},[135,1446,1447,1450,1452,1454,1456,1458,1460,1462,1464,1466,1468,1470,1472,1474,1476,1478,1480,1482],{"class":137,"line":138},[135,1448,1449],{"class":159},"CGO_ENABLED",[135,1451,599],{"class":148},[135,1453,1227],{"class":169},[135,1455,1230],{"class":159},[135,1457,599],{"class":148},[135,1459,1235],{"class":169},[135,1461,1238],{"class":159},[135,1463,599],{"class":148},[135,1465,1243],{"class":169},[135,1467,1246],{"class":155},[135,1469,1197],{"class":169},[135,1471,1251],{"class":208},[135,1473,1133],{"class":169},[135,1475,1256],{"class":159},[135,1477,1259],{"class":169},[135,1479,1262],{"class":208},[135,1481,1265],{"class":169},[135,1483,1484],{"class":169}," .\u002Fcmd\u002Fserver\n",[15,1486,1487,1488,1491,1492,1495,1496],{},"能这么干的前提是",[25,1489,1490],{},"全依赖树无 cgo","——SQLite 用的是 modernc 纯 Go 驱动，bcrypt 用 ",[63,1493,1494],{},"x\u002Fcrypto","。这也是选型时就要留意的点：",[25,1497,1498],{},"想跨平台交叉编译，先把 cgo 依赖清干净。",[15,1500,1501,1502,1505,1506,1509],{},"另外两个小提醒：Windows 上编出的文件没有 Unix 执行位，上传服务器后记得 ",[63,1503,1504],{},"chmod +x","；",[63,1507,1508],{},"-ldflags=\"-s -w\""," 剥离符号，体积从 30MB+ 降到 26MB。",[110,1511,1513],{"id":1512},"坑四dist-被-gitignore-后embed-直接编译失败","坑四：dist 被 gitignore 后，embed 直接编译失败",[15,1515,1516,1518,1519,1521,1522,1525,1526,1528],{},[63,1517,269],{}," 是构建产物、入了 .gitignore，但 ",[63,1520,100],{}," 在",[25,1523,1524],{},"编译期","就要读它——刚 clone 下来直接 ",[63,1527,1381],{}," 会报 embed 找不到文件。",[15,1530,1531,1533,1534,1537],{},[25,1532,1397],{},"：gitignore 里保留 ",[63,1535,1536],{},"dist\u002F.gitkeep","（一个空文件），让目录存在于版本库中：",[126,1539,1543],{"className":1540,"code":1541,"language":1542,"meta":131,"style":131},"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",[63,1544,1545,1550,1555],{"__ignoreMap":131},[135,1546,1547],{"class":137,"line":138},[135,1548,1549],{},"backend\u002Fweb\u002Fdist\u002F*\n",[135,1551,1552],{"class":137,"line":145},[135,1553,1554],{},"!backend\u002Fweb\u002Fdist\u002F\n",[135,1556,1557],{"class":137,"line":163},[135,1558,1559],{},"!backend\u002Fweb\u002Fdist\u002F.gitkeep\n",[15,1561,1562,1563,1565,1566,1568],{},"这样 clone 后可以先 ",[63,1564,1381],{}," 通过，再 ",[63,1567,262],{}," 灌入真实前端。",[110,1570,1572],{"id":1571},"坑五500kb-chunk-警告别慌","坑五：>500KB chunk 警告，别慌",[15,1574,1575,1576,1579,1580,1311],{},"Vite 构建会警告单 chunk 超过 500KB。这个项目里 Base UI 组件库全量引入，单 chunk 约 640KB——",[25,1577,1578],{},"属正常，不要为了消警告去拆配置","。真要优化就上路由级 ",[63,1581,1582],{},"React.lazy",[11,1584,1585],{"id":1585},"最终效果",[126,1587,1589],{"className":1094,"code":1588,"language":1096,"meta":131,"style":131},"$ build-win.bat        # backendmagic-img.exe\n$ bash build-linux.sh  # backendmagic-img（linux\u002Famd64 ELF）\n",[63,1590,1591,1602],{"__ignoreMap":131},[135,1592,1593,1596,1599],{"class":137,"line":138},[135,1594,1595],{"class":155},"$",[135,1597,1598],{"class":169}," build-win.bat",[135,1600,1601],{"class":141},"        # backendmagic-img.exe\n",[135,1603,1604,1606,1609,1612],{"class":137,"line":145},[135,1605,1595],{"class":155},[135,1607,1608],{"class":169}," bash",[135,1610,1611],{"class":169}," build-linux.sh",[135,1613,1614],{"class":141},"  # backendmagic-img（linux\u002Famd64 ELF）\n",[15,1616,1617,1618,1621,1622,1311],{},"一个约 26MB 的二进制，内嵌 React 前端、Gin API、SQLite 数据初始化，拷到 Windows\u002FLinux 机器直接跑，浏览器访问 ",[63,1619,1620],{},"http:\u002F\u002Flocalhost:8080"," 自动重定向到 ",[63,1623,248],{},[11,1625,1626],{"id":1626},"总结",[15,1628,1629],{},"这轮相比上篇的三点升级：",[54,1631,1632,1638,1648],{},[33,1633,1634,1637],{},[25,1635,1636],{},"hash 路由 + base \u002Fdist","：省掉 SPA fallback，静态服务即开即用；",[33,1639,1640,124,1645,1647],{},[25,1641,1642,1644],{},[63,1643,78],{}," embed 前缀",[63,1646,83],{}," 开头文件问题一劳永逸；",[33,1649,1650,1656],{},[25,1651,1652,1653,1655],{},"显式 ",[63,1654,1429],{}," 交叉编译","：让「一个脚本、任意平台、产出 Linux 二进制」成为现实。",[15,1658,1659,1660,1663],{},"加上",[25,1661,1662],{},"入口 no-store \u002F 资源 immutable"," 的缓存策略，单文件分发的体验才算闭环——不然每次升级都要和浏览器缓存打架。",[1665,1666,1667],"style",{},"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":131,"searchDepth":145,"depth":163,"links":1669},[1670,1671,1672,1679,1687,1688],{"id":13,"depth":145,"text":13},{"id":94,"depth":145,"text":94},{"id":108,"depth":145,"text":108,"children":1673},[1674,1675,1676,1677,1678],{"id":112,"depth":163,"text":113},{"id":256,"depth":163,"text":257},{"id":317,"depth":163,"text":318},{"id":721,"depth":163,"text":722},{"id":1090,"depth":163,"text":1091},{"id":1314,"depth":145,"text":1314,"children":1680},[1681,1683,1684,1685,1686],{"id":1317,"depth":163,"text":1682},"坑一：\u002F\u002Fgo:embed dist 静默漏掉 _ 开头文件（升级解法）",{"id":1367,"depth":163,"text":1368},{"id":1415,"depth":163,"text":1416},{"id":1512,"depth":163,"text":1513},{"id":1571,"depth":163,"text":1572},{"id":1585,"depth":145,"text":1585},{"id":1626,"depth":145,"text":1626},"以 Magic Img（React 19 + Vite + Gin + SQLite）为实例，记录把前端塞进 Go 二进制、并在 Windows 上交叉编译 Linux 产物的完整过程：embed 的 all 前缀、hash 路由、缓存策略、GOOS 交叉编译等实操坑。","进阶",false,"md","\u002Fposts\u002Fgo-embed-react-single-binary\u002Fimg\u002Fcover.svg",{},"\u002Fposts\u002Fgo-embed-react-single-binary","2026-08-16T00:00:00",{"title":5,"description":1689},"Go 工程实践","posts\u002Fgo-embed-react-single-binary\u002Findex",[1701,1702,360,1703,1704],"Go","React","交叉编译","打包","2026-08-16","WV-T-MOO5Cu-aycw-Ony8s9hjA3_k0S4XU-T0QsCow0",[1708,2498,2911,3140,3553,4725,5002,7041,7820,8767,10175,10734,11909,13133],{"id":1709,"title":1710,"author":6,"body":1711,"description":2486,"difficulty":1690,"draft":1691,"extension":1692,"image":2487,"meta":2488,"navigation":343,"path":2489,"pinned":1691,"published":2490,"seo":2491,"series":2492,"seriesOrder":138,"stem":2493,"tags":2494,"updated":2490,"__hash__":2497},"posts\u002Fposts\u002Fdocker-musl-build\u002Findex.md","Docker 编译 musl 可执行文件",{"type":8,"value":1712,"toc":2475},[1713,1716,1719,1722,1937,1940,1982,1985,2003,2006,2154,2157,2291,2294,2297,2355,2358,2472],[11,1714,1715],{"id":1715},"解决问题",[15,1717,1718],{},"用 Go 进行跨平台编译时往往步骤复杂，用 Docker 可以轻松解决。但 Docker 的当前最小镜像 Alpine 其动态链接库采用 musl，与主流的 glibc 不同，往往不能方便执行。",[11,1720,1721],{"id":1721},"查阅资料",[126,1723,1725],{"className":1094,"code":1724,"language":1096,"meta":131,"style":131},"# 启动容器并挂载当前目录到 `\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",[63,1726,1727,1732,1768,1772,1777,1804,1819,1834,1839,1851,1871,1879,1883,1888,1898,1908,1912,1920,1924],{"__ignoreMap":131},[135,1728,1729],{"class":137,"line":138},[135,1730,1731],{"class":141},"# 启动容器并挂载当前目录到 `\u002Fapp`\n",[135,1733,1734,1737,1739,1742,1745,1748,1750,1753,1756,1759,1762,1765],{"class":137,"line":145},[135,1735,1736],{"class":155},"docker",[135,1738,1194],{"class":169},[135,1740,1741],{"class":208}," -it",[135,1743,1744],{"class":208}," --rm",[135,1746,1747],{"class":208}," -v",[135,1749,1133],{"class":169},[135,1751,1752],{"class":159},"$PWD",[135,1754,1755],{"class":169},"\":\u002Fapp",[135,1757,1758],{"class":208}," -w",[135,1760,1761],{"class":169}," \u002Fapp",[135,1763,1764],{"class":169}," ubuntu:20.04",[135,1766,1767],{"class":169}," bash\n",[135,1769,1770],{"class":137,"line":163},[135,1771,344],{"emptyLinePlaceholder":343},[135,1773,1774],{"class":137,"line":179},[135,1775,1776],{"class":141},"# 在容器内执行以下操作：\n",[135,1778,1779,1782,1785,1787,1789,1792,1795,1798,1801],{"class":137,"line":185},[135,1780,1781],{"class":155},"apt-get",[135,1783,1784],{"class":169}," update",[135,1786,1188],{"class":159},[135,1788,1781],{"class":155},[135,1790,1791],{"class":169}," install",[135,1793,1794],{"class":208}," -y",[135,1796,1797],{"class":169}," musl-tools",[135,1799,1800],{"class":169}," golang-go",[135,1802,1803],{"class":141},"  # 安装 musl 和 Go\n",[135,1805,1806,1808,1811,1813,1816],{"class":137,"line":366},[135,1807,149],{"class":148},[135,1809,1810],{"class":159}," CC",[135,1812,599],{"class":148},[135,1814,1815],{"class":159},"musl-gcc  ",[135,1817,1818],{"class":141},"# 指定 musl 编译器\n",[135,1820,1821,1823,1826,1828,1831],{"class":137,"line":376},[135,1822,149],{"class":148},[135,1824,1825],{"class":159}," CGO_ENABLED",[135,1827,599],{"class":148},[135,1829,1830],{"class":208},"1",[135,1832,1833],{"class":141},"  # 启用 CGO\n",[135,1835,1836],{"class":137,"line":386},[135,1837,1838],{"class":141},"# 设置代理\n",[135,1840,1841,1843,1846,1848],{"class":137,"line":392},[135,1842,323],{"class":155},[135,1844,1845],{"class":169}," env",[135,1847,1758],{"class":208},[135,1849,1850],{"class":169}," GOPROXY=https:\u002F\u002Fgoproxy.cn,direct\n",[135,1852,1853,1855,1857,1860,1863,1865,1868],{"class":137,"line":397},[135,1854,323],{"class":155},[135,1856,1197],{"class":169},[135,1858,1859],{"class":208}," -ldflags",[135,1861,1862],{"class":169}," '-linkmode external -extldflags \"-static\"'",[135,1864,1251],{"class":208},[135,1866,1867],{"class":169}," myapp",[135,1869,1870],{"class":141},"  # 静态编译\n",[135,1872,1873,1876],{"class":137,"line":403},[135,1874,1875],{"class":208},"exit",[135,1877,1878],{"class":141},"  # 退出容器\n",[135,1880,1881],{"class":137,"line":420},[135,1882,344],{"emptyLinePlaceholder":343},[135,1884,1885],{"class":137,"line":425},[135,1886,1887],{"class":141},"# 验证结果（在宿主机执行）\n",[135,1889,1890,1893,1895],{"class":137,"line":431},[135,1891,1892],{"class":155},"file",[135,1894,1867],{"class":169},[135,1896,1897],{"class":141},"  # 应显示 \"statically linked\"\n",[135,1899,1900,1903,1905],{"class":137,"line":471},[135,1901,1902],{"class":155},"ldd",[135,1904,1867],{"class":169},[135,1906,1907],{"class":141},"   # 应显示 \"not a dynamic executable\"\n",[135,1909,1910],{"class":137,"line":476},[135,1911,344],{"emptyLinePlaceholder":343},[135,1913,1914,1917],{"class":137,"line":491},[135,1915,1916],{"class":155},"wget",[135,1918,1919],{"class":169}," https:\u002F\u002Fgo.dev\u002Fdl\u002Fgo1.21.linux-amd64.tar.gz\n",[135,1921,1922],{"class":137,"line":500},[135,1923,344],{"emptyLinePlaceholder":343},[135,1925,1926,1929,1932,1935],{"class":137,"line":515},[135,1927,1928],{"class":155},"ENV",[135,1930,1931],{"class":169}," PATH=\"\u002Fdata\u002Fgo\u002Fbin:",[135,1933,1934],{"class":159},"$PATH",[135,1936,363],{"class":169},[110,1938,1939],{"id":1939},"遇到问题",[54,1941,1942,1968],{},[33,1943,1944,1947,1948,1951,1954,1955,1958,1960,1961,1963],{},[63,1945,1946],{},"go mod tidy"," 时报错 ",[63,1949,1950],{},"tls: failed to verify certificate: x509: certificate",[1952,1953],"br",{},"解决：",[63,1956,1957],{},"RUN apt-get update && apt-get install -y ca-certificates && rm -rf \u002Fvar\u002Flib\u002Fapt\u002Flists\u002F*",[1952,1959],{},"基础镜像的证书未更新，导致 https 的请求出现类似错误。",[1952,1962],{},[1964,1965],"img",{"alt":1966,"src":1967},"image-20250327100813512","\u002Fposts\u002Fdocker-musl-build\u002Fimg\u002Fimage-20250327100813512.png",[33,1969,1970,1971,1974,1975,1977,1978,1981],{},"直接安装 ",[63,1972,1973],{},"golang-go"," 其版本过低",[1952,1976],{},"解决: ",[63,1979,1980],{},"PATH=\"\u002Fdata\u002Fgo\u002Fbin:$PATH\""," 手动指定环境",[11,1983,1984],{"id":1984},"最终方案",[54,1986,1987,1994,1997],{},[33,1988,1989,1990,1993],{},"下载 ",[63,1991,1992],{},"go1.24.1.linux-amd64.tar.gz"," 解压",[33,1995,1996],{},"编写 Dockerfile",[33,1998,1999,2000],{},"执行 ",[63,2001,2002],{},"docker build -t go-build:v1 .",[15,2004,2005],{},"Dockerfile 内容：",[126,2007,2011],{"className":2008,"code":2009,"language":2010,"meta":131,"style":131},"language-dockerfile shiki shiki-themes github-light github-dark","FROM ubuntu:20.04\n\nLABEL org.opencontainers.image.authors=\"sutong\"\n\nCOPY go\u002F \u002Fgo\u002F\n\nENV PATH=\"\u002Fgo\u002Fbin:$PATH\"\n\nRUN apt-get update && apt-get install -y musl-tools\n\n## go mod tidy 时报错 tls: failed to verify certificate: x509: certificate\nRUN apt-get update && apt-get install -y ca-certificates && rm -rf \u002Fvar\u002Flib\u002Fapt\u002Flists\u002F*\n# 指定 musl 编译器\nENV CC=musl-gcc\n# 启用 CGO\nENV CGO_ENABLED=1\n\nVOLUME \u002Fdata\n\nWORKDIR \u002Fdata\n\nCMD [\"\u002Fbin\u002Fbash\"]\n","dockerfile",[63,2012,2013,2021,2025,2036,2040,2048,2052,2062,2066,2074,2078,2083,2090,2094,2101,2106,2113,2117,2125,2129,2136,2140],{"__ignoreMap":131},[135,2014,2015,2018],{"class":137,"line":138},[135,2016,2017],{"class":148},"FROM",[135,2019,2020],{"class":159}," ubuntu:20.04\n",[135,2022,2023],{"class":137,"line":145},[135,2024,344],{"emptyLinePlaceholder":343},[135,2026,2027,2030,2033],{"class":137,"line":163},[135,2028,2029],{"class":148},"LABEL",[135,2031,2032],{"class":159}," org.opencontainers.image.authors=",[135,2034,2035],{"class":169},"\"sutong\"\n",[135,2037,2038],{"class":137,"line":179},[135,2039,344],{"emptyLinePlaceholder":343},[135,2041,2042,2045],{"class":137,"line":185},[135,2043,2044],{"class":148},"COPY",[135,2046,2047],{"class":159}," go\u002F \u002Fgo\u002F\n",[135,2049,2050],{"class":137,"line":366},[135,2051,344],{"emptyLinePlaceholder":343},[135,2053,2054,2056,2059],{"class":137,"line":376},[135,2055,1928],{"class":148},[135,2057,2058],{"class":159}," PATH=",[135,2060,2061],{"class":169},"\"\u002Fgo\u002Fbin:$PATH\"\n",[135,2063,2064],{"class":137,"line":386},[135,2065,344],{"emptyLinePlaceholder":343},[135,2067,2068,2071],{"class":137,"line":392},[135,2069,2070],{"class":148},"RUN",[135,2072,2073],{"class":159}," apt-get update && apt-get install -y musl-tools\n",[135,2075,2076],{"class":137,"line":397},[135,2077,344],{"emptyLinePlaceholder":343},[135,2079,2080],{"class":137,"line":403},[135,2081,2082],{"class":141},"## go mod tidy 时报错 tls: failed to verify certificate: x509: certificate\n",[135,2084,2085,2087],{"class":137,"line":420},[135,2086,2070],{"class":148},[135,2088,2089],{"class":159}," apt-get update && apt-get install -y ca-certificates && rm -rf \u002Fvar\u002Flib\u002Fapt\u002Flists\u002F*\n",[135,2091,2092],{"class":137,"line":425},[135,2093,1818],{"class":141},[135,2095,2096,2098],{"class":137,"line":431},[135,2097,1928],{"class":148},[135,2099,2100],{"class":159}," CC=musl-gcc\n",[135,2102,2103],{"class":137,"line":471},[135,2104,2105],{"class":141},"# 启用 CGO\n",[135,2107,2108,2110],{"class":137,"line":476},[135,2109,1928],{"class":148},[135,2111,2112],{"class":159}," CGO_ENABLED=1\n",[135,2114,2115],{"class":137,"line":491},[135,2116,344],{"emptyLinePlaceholder":343},[135,2118,2119,2122],{"class":137,"line":500},[135,2120,2121],{"class":148},"VOLUME",[135,2123,2124],{"class":159}," \u002Fdata\n",[135,2126,2127],{"class":137,"line":515},[135,2128,344],{"emptyLinePlaceholder":343},[135,2130,2131,2134],{"class":137,"line":520},[135,2132,2133],{"class":148},"WORKDIR",[135,2135,2124],{"class":159},[135,2137,2138],{"class":137,"line":525},[135,2139,344],{"emptyLinePlaceholder":343},[135,2141,2142,2145,2148,2151],{"class":137,"line":576},[135,2143,2144],{"class":148},"CMD",[135,2146,2147],{"class":159}," [",[135,2149,2150],{"class":169},"\"\u002Fbin\u002Fbash\"",[135,2152,2153],{"class":159},"]\n",[110,2155,2156],{"id":2156},"构建",[126,2158,2160],{"className":1094,"code":2159,"language":1096,"meta":131,"style":131},"# 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",[63,2161,2162,2167,2171,2176,2201,2205,2210,2245,2250,2254,2286],{"__ignoreMap":131},[135,2163,2164],{"class":137,"line":138},[135,2165,2166],{"class":141},"# docker build -t go-build:v1 .\n",[135,2168,2169],{"class":137,"line":145},[135,2170,344],{"emptyLinePlaceholder":343},[135,2172,2173],{"class":137,"line":163},[135,2174,2175],{"class":141},"# 直接执行编译 编译后删除\n",[135,2177,2178,2180,2182,2184,2186,2189,2192,2194,2196,2198],{"class":137,"line":179},[135,2179,1736],{"class":155},[135,2181,1194],{"class":169},[135,2183,1744],{"class":208},[135,2185,1747],{"class":208},[135,2187,2188],{"class":169}," .:\u002Fdata",[135,2190,2191],{"class":169}," go-build:v1",[135,2193,1246],{"class":169},[135,2195,1197],{"class":169},[135,2197,1251],{"class":208},[135,2199,2200],{"class":169}," app_musl\n",[135,2202,2203],{"class":137,"line":185},[135,2204,344],{"emptyLinePlaceholder":343},[135,2206,2207],{"class":137,"line":366},[135,2208,2209],{"class":141},"# 构建编译环境\n",[135,2211,2212,2214,2216,2218,2221,2223,2225,2228,2231,2234,2237,2240,2242],{"class":137,"line":376},[135,2213,1736],{"class":155},[135,2215,1194],{"class":169},[135,2217,1741],{"class":208},[135,2219,2220],{"class":208}," -d",[135,2222,1747],{"class":208},[135,2224,2188],{"class":169},[135,2226,2227],{"class":208}," --name",[135,2229,2230],{"class":148}," \u003C",[135,2232,2233],{"class":169},"容器名",[135,2235,2236],{"class":159},"称",[135,2238,2239],{"class":148},">",[135,2241,2191],{"class":169},[135,2243,2244],{"class":169}," \u002Fbin\u002Fbash\n",[135,2246,2247],{"class":137,"line":386},[135,2248,2249],{"class":141},"# eg：docker run -it -d -v .:\u002Fdata --name logbus-build go-build:v1 \u002Fbin\u002Fbash\n",[135,2251,2252],{"class":137,"line":392},[135,2253,344],{"emptyLinePlaceholder":343},[135,2255,2256,2258,2261,2263,2265,2267,2269,2271,2273,2275,2277,2279,2282,2284],{"class":137,"line":397},[135,2257,1736],{"class":155},[135,2259,2260],{"class":169}," exec",[135,2262,1741],{"class":208},[135,2264,2230],{"class":148},[135,2266,2233],{"class":169},[135,2268,2236],{"class":159},[135,2270,2239],{"class":148},[135,2272,1246],{"class":169},[135,2274,1197],{"class":169},[135,2276,1251],{"class":208},[135,2278,2230],{"class":148},[135,2280,2281],{"class":169},"myap",[135,2283,15],{"class":159},[135,2285,211],{"class":148},[135,2287,2288],{"class":137,"line":403},[135,2289,2290],{"class":141},"# eg：docker exec -it logbus-build go build -o logbus_musl_1\n",[110,2292,2293],{"id":2293},"额外注意",[15,2295,2296],{},"若没科学上网需要设置代理：",[126,2298,2300],{"className":1094,"code":2299,"language":1096,"meta":131,"style":131},"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",[63,2301,2302,2320,2330,2346],{"__ignoreMap":131},[135,2303,2304,2306,2308,2310,2312,2314,2316,2318],{"class":137,"line":138},[135,2305,1736],{"class":155},[135,2307,2260],{"class":169},[135,2309,1741],{"class":208},[135,2311,2230],{"class":148},[135,2313,2233],{"class":169},[135,2315,2236],{"class":159},[135,2317,2239],{"class":148},[135,2319,2244],{"class":169},[135,2321,2322,2324,2326,2328],{"class":137,"line":145},[135,2323,323],{"class":155},[135,2325,1845],{"class":169},[135,2327,1758],{"class":208},[135,2329,1850],{"class":169},[135,2331,2332,2334,2336,2338,2340,2342,2344],{"class":137,"line":163},[135,2333,323],{"class":155},[135,2335,1197],{"class":169},[135,2337,1859],{"class":208},[135,2339,1862],{"class":169},[135,2341,1251],{"class":208},[135,2343,1867],{"class":169},[135,2345,1870],{"class":141},[135,2347,2348,2350,2352],{"class":137,"line":179},[135,2349,323],{"class":155},[135,2351,1197],{"class":169},[135,2353,2354],{"class":141}," # musl 库编译\n",[110,2356,2357],{"id":2357},"运行环境示例",[126,2359,2361],{"className":2008,"code":2360,"language":2010,"meta":131,"style":131},"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",[63,2362,2363,2370,2374,2382,2386,2393,2397,2404,2411,2415,2422,2426,2433,2437,2458,2462,2467],{"__ignoreMap":131},[135,2364,2365,2367],{"class":137,"line":138},[135,2366,2017],{"class":148},[135,2368,2369],{"class":159}," alpine:latest\n",[135,2371,2372],{"class":137,"line":145},[135,2373,344],{"emptyLinePlaceholder":343},[135,2375,2376,2378,2380],{"class":137,"line":163},[135,2377,2029],{"class":148},[135,2379,2032],{"class":159},[135,2381,2035],{"class":169},[135,2383,2384],{"class":137,"line":179},[135,2385,344],{"emptyLinePlaceholder":343},[135,2387,2388,2390],{"class":137,"line":185},[135,2389,2044],{"class":148},[135,2391,2392],{"class":159}," glog_musl \u002Fapp\u002F\n",[135,2394,2395],{"class":137,"line":366},[135,2396,344],{"emptyLinePlaceholder":343},[135,2398,2399,2401],{"class":137,"line":376},[135,2400,2044],{"class":148},[135,2402,2403],{"class":159}," conf.ini \u002Fdata\u002F\n",[135,2405,2406,2408],{"class":137,"line":386},[135,2407,2044],{"class":148},[135,2409,2410],{"class":159}," ipname.ini \u002Fdata\u002F\n",[135,2412,2413],{"class":137,"line":392},[135,2414,344],{"emptyLinePlaceholder":343},[135,2416,2417,2419],{"class":137,"line":397},[135,2418,2133],{"class":148},[135,2420,2421],{"class":159}," \u002Fapp\n",[135,2423,2424],{"class":137,"line":403},[135,2425,344],{"emptyLinePlaceholder":343},[135,2427,2428,2430],{"class":137,"line":420},[135,2429,2070],{"class":148},[135,2431,2432],{"class":159}," chmod 777 \u002Fapp\u002Fglog_musl\n",[135,2434,2435],{"class":137,"line":425},[135,2436,344],{"emptyLinePlaceholder":343},[135,2438,2439,2441,2443,2446,2448,2451,2453,2456],{"class":137,"line":431},[135,2440,2144],{"class":148},[135,2442,2147],{"class":159},[135,2444,2445],{"class":169},"\".\u002Fglog_musl\"",[135,2447,567],{"class":159},[135,2449,2450],{"class":169},"\"logsweb\"",[135,2452,567],{"class":159},[135,2454,2455],{"class":169},"\"\u002Fdata\u002Fconf.ini\"",[135,2457,2153],{"class":159},[135,2459,2460],{"class":137,"line":471},[135,2461,344],{"emptyLinePlaceholder":343},[135,2463,2464],{"class":137,"line":476},[135,2465,2466],{"class":141},"# docker build -t glog:v1 .\n",[135,2468,2469],{"class":137,"line":491},[135,2470,2471],{"class":141},"# docker run -p 6801:6801 glog:v1\n",[1665,2473,2474],{},"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":131,"searchDepth":145,"depth":163,"links":2476},[2477,2478,2481],{"id":1715,"depth":145,"text":1715},{"id":1721,"depth":145,"text":1721,"children":2479},[2480],{"id":1939,"depth":163,"text":1939},{"id":1984,"depth":145,"text":1984,"children":2482},[2483,2484,2485],{"id":2156,"depth":163,"text":2156},{"id":2293,"depth":163,"text":2293},{"id":2357,"depth":163,"text":2357},"解决 Go 语言跨平台编译问题，使用 Docker + musl 编译静态链接的可执行文件。","\u002Fposts\u002Fdocker-musl-build\u002Fimg\u002Fcover.svg",{},"\u002Fposts\u002Fdocker-musl-build","2025-03-27",{"title":1710,"description":2486},"Docker 实践笔记","posts\u002Fdocker-musl-build\u002Findex",[2495,1701,2496],"Docker","运维","YLtdOOc-gMVDSNjE-ntKXz-7hff-VtvMB2dB5RXNi1o",{"id":2499,"title":2500,"author":6,"body":2501,"description":2900,"difficulty":2901,"draft":1691,"extension":1692,"image":2902,"meta":2903,"navigation":343,"path":2904,"pinned":1691,"published":2905,"seo":2906,"series":2492,"seriesOrder":145,"stem":2907,"tags":2908,"updated":2905,"__hash__":2910},"posts\u002Fposts\u002Fdocker-user-start\u002Findex.md","Docker 构建时指定用户启动",{"type":8,"value":2502,"toc":2890},[2503,2506,2509,2512,2516,2519,2536,2540,2573,2577,2584,2633,2636,2849,2851,2854,2884,2887],[11,2504,2505],{"id":2505},"前言",[15,2507,2508],{},"我们在 Linux 使用应用时，往往不会直接使用 root 用户作为应用启动项，但 Dockerfile 默认就是 root 用户，导致挂载生成的文件路径往往是 root 权限，非 root 用户访问就很不方便。",[11,2510,2511],{"id":2511},"解决方案",[110,2513,2515],{"id":2514},"_1-安装用户管理工具","1. 安装用户管理工具",[15,2517,2518],{},"若镜像非自带用户管理：",[126,2520,2522],{"className":2008,"code":2521,"language":2010,"meta":131,"style":131},"# 权限管理\nRUN apk add --no-cache shadow\n",[63,2523,2524,2529],{"__ignoreMap":131},[135,2525,2526],{"class":137,"line":138},[135,2527,2528],{"class":141},"# 权限管理\n",[135,2530,2531,2533],{"class":137,"line":145},[135,2532,2070],{"class":148},[135,2534,2535],{"class":159}," apk add --no-cache shadow\n",[110,2537,2539],{"id":2538},"_2-创建用户并修改权限","2. 创建用户并修改权限",[126,2541,2543],{"className":2008,"code":2542,"language":2010,"meta":131,"style":131},"# 创建用户组和用户\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",[63,2544,2545,2550,2557,2561,2566],{"__ignoreMap":131},[135,2546,2547],{"class":137,"line":138},[135,2548,2549],{"class":141},"# 创建用户组和用户\n",[135,2551,2552,2554],{"class":137,"line":145},[135,2553,2070],{"class":148},[135,2555,2556],{"class":159}," addgroup -S \u003Capp_user_group> && adduser -S -G \u003Capp_user_group> \u003Capp_user>\n",[135,2558,2559],{"class":137,"line":163},[135,2560,344],{"emptyLinePlaceholder":343},[135,2562,2563],{"class":137,"line":179},[135,2564,2565],{"class":141},"# 修改目录权限\n",[135,2567,2568,2570],{"class":137,"line":185},[135,2569,2070],{"class":148},[135,2571,2572],{"class":159}," chown -R \u003Capp_user_group>:\u003Capp_user> \u002Fdata\n",[110,2574,2576],{"id":2575},"_3-指定用户启动","3. 指定用户启动",[15,2578,2579,2580,2583],{},"执行 docker 时加 ",[63,2581,2582],{},"--user"," 参数：",[126,2585,2587],{"className":1094,"code":2586,"language":1096,"meta":131,"style":131},"docker run -it --user \u003Capp_user> \u003Capp_image>:\u003Cimage_version>\n",[63,2588,2589],{"__ignoreMap":131},[135,2590,2591,2593,2595,2597,2600,2602,2605,2608,2610,2612,2615,2618,2620,2623,2625,2628,2631],{"class":137,"line":138},[135,2592,1736],{"class":155},[135,2594,1194],{"class":169},[135,2596,1741],{"class":208},[135,2598,2599],{"class":208}," --user",[135,2601,2230],{"class":148},[135,2603,2604],{"class":169},"app_use",[135,2606,2607],{"class":159},"r",[135,2609,2239],{"class":148},[135,2611,2230],{"class":148},[135,2613,2614],{"class":169},"app_imag",[135,2616,2617],{"class":159},"e",[135,2619,2239],{"class":148},[135,2621,2622],{"class":169},":",[135,2624,205],{"class":148},[135,2626,2627],{"class":169},"image_versio",[135,2629,2630],{"class":159},"n",[135,2632,211],{"class":148},[11,2634,2635],{"id":2635},"完整示例",[126,2637,2639],{"className":2008,"code":2638,"language":2010,"meta":131,"style":131},"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",[63,2640,2641,2647,2651,2656,2669,2673,2680,2684,2690,2695,2702,2707,2714,2718,2725,2729,2733,2740,2744,2751,2755,2762,2766,2779,2785,2789,2794,2801,2808,2813,2819,2828],{"__ignoreMap":131},[135,2642,2643,2645],{"class":137,"line":138},[135,2644,2017],{"class":148},[135,2646,2369],{"class":159},[135,2648,2649],{"class":137,"line":145},[135,2650,344],{"emptyLinePlaceholder":343},[135,2652,2653],{"class":137,"line":163},[135,2654,2655],{"class":141},"# 国内源\n",[135,2657,2658,2660,2663,2666],{"class":137,"line":179},[135,2659,2070],{"class":148},[135,2661,2662],{"class":159}," sed -i ",[135,2664,2665],{"class":169},"'s\u002Fdl-cdn.alpinelinux.org\u002Fmirrors.ustc.edu.cn\u002Fg'",[135,2667,2668],{"class":159}," \u002Fetc\u002Fapk\u002Frepositories\n",[135,2670,2671],{"class":137,"line":185},[135,2672,344],{"emptyLinePlaceholder":343},[135,2674,2675,2677],{"class":137,"line":366},[135,2676,2070],{"class":148},[135,2678,2679],{"class":159}," apk update --no-cache\n",[135,2681,2682],{"class":137,"line":376},[135,2683,2528],{"class":141},[135,2685,2686,2688],{"class":137,"line":386},[135,2687,2070],{"class":148},[135,2689,2535],{"class":159},[135,2691,2692],{"class":137,"line":392},[135,2693,2694],{"class":141},"# 调试\n",[135,2696,2697,2699],{"class":137,"line":397},[135,2698,2070],{"class":148},[135,2700,2701],{"class":159}," apk add --no-cache bash\n",[135,2703,2704],{"class":137,"line":403},[135,2705,2706],{"class":141},"# 设置时区\n",[135,2708,2709,2711],{"class":137,"line":420},[135,2710,2070],{"class":148},[135,2712,2713],{"class":159}," apk add --no-cache tzdata\n",[135,2715,2716],{"class":137,"line":425},[135,2717,344],{"emptyLinePlaceholder":343},[135,2719,2720,2722],{"class":137,"line":431},[135,2721,1928],{"class":148},[135,2723,2724],{"class":159}," TZ=Asia\u002FShanghai\n",[135,2726,2727],{"class":137,"line":471},[135,2728,344],{"emptyLinePlaceholder":343},[135,2730,2731],{"class":137,"line":476},[135,2732,2549],{"class":141},[135,2734,2735,2737],{"class":137,"line":491},[135,2736,2070],{"class":148},[135,2738,2739],{"class":159}," addgroup -S glog && adduser -S -G glog glog\n",[135,2741,2742],{"class":137,"line":500},[135,2743,344],{"emptyLinePlaceholder":343},[135,2745,2746,2748],{"class":137,"line":515},[135,2747,2044],{"class":148},[135,2749,2750],{"class":159}," .\u002Fglog_static_musl_1 \u002Fapp\u002F\n",[135,2752,2753],{"class":137,"line":520},[135,2754,344],{"emptyLinePlaceholder":343},[135,2756,2757,2759],{"class":137,"line":525},[135,2758,2070],{"class":148},[135,2760,2761],{"class":159}," mkdir \u002Fdata\n",[135,2763,2764],{"class":137,"line":576},[135,2765,344],{"emptyLinePlaceholder":343},[135,2767,2768,2770,2773,2776],{"class":137,"line":593},[135,2769,2121],{"class":148},[135,2771,2772],{"class":159}," [ ",[135,2774,2775],{"class":169},"\"\u002Fdata\"",[135,2777,2778],{"class":159}," ]\n",[135,2780,2781,2783],{"class":137,"line":605},[135,2782,2133],{"class":148},[135,2784,2124],{"class":159},[135,2786,2787],{"class":137,"line":616},[135,2788,344],{"emptyLinePlaceholder":343},[135,2790,2791],{"class":137,"line":648},[135,2792,2793],{"class":141},"# 修改权限\n",[135,2795,2796,2798],{"class":137,"line":654},[135,2797,2070],{"class":148},[135,2799,2800],{"class":159}," chown -R glog:glog \u002Fdata\n",[135,2802,2803,2805],{"class":137,"line":674},[135,2804,2070],{"class":148},[135,2806,2807],{"class":159}," chown -R glog:glog \u002Fapp\n",[135,2809,2811],{"class":137,"line":2810},29,[135,2812,344],{"emptyLinePlaceholder":343},[135,2814,2816],{"class":137,"line":2815},30,[135,2817,2818],{"class":141},"# 指定用户启动\n",[135,2820,2822,2825],{"class":137,"line":2821},31,[135,2823,2824],{"class":148},"USER",[135,2826,2827],{"class":159}," glog\n",[135,2829,2831,2833,2835,2838,2840,2842,2844,2847],{"class":137,"line":2830},32,[135,2832,2144],{"class":148},[135,2834,2772],{"class":159},[135,2836,2837],{"class":169},"\"\u002Fapp\u002Fglog_static_musl_1\"",[135,2839,567],{"class":159},[135,2841,2450],{"class":169},[135,2843,567],{"class":159},[135,2845,2846],{"class":169},"\".\u002Fconf.ini\"",[135,2848,2778],{"class":159},[11,2850,1626],{"id":1626},[15,2852,2853],{},"关键步骤：",[54,2855,2856,2862,2872,2878],{},[33,2857,2858,2861],{},[63,2859,2860],{},"apk add --no-cache shadow"," - 安装用户管理工具",[33,2863,2864,2867,2868,2871],{},[63,2865,2866],{},"addgroup"," + ",[63,2869,2870],{},"adduser"," - 创建用户",[33,2873,2874,2877],{},[63,2875,2876],{},"chown -R"," - 修改目录权限",[33,2879,2880,2883],{},[63,2881,2882],{},"USER \u003Cusername>"," - 指定运行用户",[15,2885,2886],{},"这样容器内的应用就不再以 root 身份运行，更加安全。",[1665,2888,2889],{},"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":131,"searchDepth":145,"depth":163,"links":2891},[2892,2893,2898,2899],{"id":2505,"depth":145,"text":2505},{"id":2511,"depth":145,"text":2511,"children":2894},[2895,2896,2897],{"id":2514,"depth":163,"text":2515},{"id":2538,"depth":163,"text":2539},{"id":2575,"depth":163,"text":2576},{"id":2635,"depth":145,"text":2635},{"id":1626,"depth":145,"text":1626},"解决 Docker 容器默认以 root 用户运行导致的权限问题，指定非 root 用户启动应用。","入门","\u002Fposts\u002Fdocker-user-start\u002Fimg\u002Fcover.svg",{},"\u002Fposts\u002Fdocker-user-start","2025-04-01",{"title":2500,"description":2900},"posts\u002Fdocker-user-start\u002Findex",[2495,2496,2909],"Linux","b4Uyw5ms87xr7NhpIkavHnNbT20oaEPtXz8k1w6UPj0",{"id":2912,"title":2913,"author":6,"body":2914,"description":3130,"difficulty":6,"draft":1691,"extension":1692,"image":3131,"meta":3132,"navigation":343,"path":3133,"pinned":1691,"published":3134,"seo":3135,"series":6,"seriesOrder":6,"stem":3136,"tags":3137,"updated":6,"__hash__":3139},"posts\u002Fposts\u002Fsveltekit-intro\u002Findex.md","SvelteKit 入门：为什么它比你想象的更简单",{"type":8,"value":2915,"toc":3120},[2916,2920,2923,2929,2932,2936,2943,2994,2998,3004,3012,3016,3019,3022,3098,3100,3111,3117],[11,2917,2919],{"id":2918},"什么是-sveltekit","什么是 SvelteKit？",[15,2921,2922],{},"SvelteKit 是 Svelte 的官方全栈框架，类似于 React 之于 Next.js 的关系。它提供了路由、SSR、SSG 等能力，让开发者可以快速构建现代 Web 应用。",[15,2924,2925],{},[1964,2926],{"alt":2927,"src":2928},"SvelteKit Logo","https:\u002F\u002Fraw.githubusercontent.com\u002Fsveltejs\u002Fbranding\u002Fmaster\u002Fsvelte-logo.svg",[11,2930,2931],{"id":2931},"核心特点",[110,2933,2935],{"id":2934},"_1-编译时框架","1. 编译时框架",[15,2937,2938,2939,2942],{},"Svelte 和 React\u002FVue 最大的区别在于：",[25,2940,2941],{},"它在编译阶段就把组件转成原生 JS","，运行时没有虚拟 DOM。",[126,2944,2948],{"className":2945,"code":2946,"language":2947,"meta":131,"style":131},"language-typescript shiki shiki-themes github-light github-dark","\u002F\u002F Svelte 组件（编译后变成直接的 DOM 操作）\nlet count = 0\nfunction increment() {\n  count += 1\n}\n","typescript",[63,2949,2950,2955,2968,2979,2990],{"__ignoreMap":131},[135,2951,2952],{"class":137,"line":138},[135,2953,2954],{"class":141},"\u002F\u002F Svelte 组件（编译后变成直接的 DOM 操作）\n",[135,2956,2957,2960,2963,2965],{"class":137,"line":145},[135,2958,2959],{"class":148},"let",[135,2961,2962],{"class":159}," count ",[135,2964,599],{"class":148},[135,2966,2967],{"class":208}," 0\n",[135,2969,2970,2973,2976],{"class":137,"line":163},[135,2971,2972],{"class":148},"function",[135,2974,2975],{"class":155}," increment",[135,2977,2978],{"class":159},"() {\n",[135,2980,2981,2984,2987],{"class":137,"line":179},[135,2982,2983],{"class":159},"  count ",[135,2985,2986],{"class":148},"+=",[135,2988,2989],{"class":208}," 1\n",[135,2991,2992],{"class":137,"line":185},[135,2993,314],{"class":159},[110,2995,2997],{"id":2996},"_2-文件路由","2. 文件路由",[15,2999,3000,3003],{},[63,3001,3002],{},"src\u002Froutes\u002F"," 目录结构即 URL，不需要手动配置路由表：",[126,3005,3010],{"className":3006,"code":3008,"language":3009},[3007],"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",[63,3011,3008],{"__ignoreMap":131},[110,3013,3015],{"id":3014},"_3-全栈能力","3. 全栈能力",[15,3017,3018],{},"SvelteKit 内置了服务端渲染（SSR）、静态站点生成（SSG）、API 路由等功能，开箱即用。",[11,3020,3021],{"id":3021},"和其他框架对比",[3023,3024,3025,3044],"table",{},[3026,3027,3028],"thead",{},[3029,3030,3031,3035,3038,3041],"tr",{},[3032,3033,3034],"th",{},"特性",[3032,3036,3037],{},"SvelteKit",[3032,3039,3040],{},"Next.js",[3032,3042,3043],{},"Nuxt",[3045,3046,3047,3061,3074,3087],"tbody",{},[3029,3048,3049,3053,3056,3059],{},[3050,3051,3052],"td",{},"学习曲线",[3050,3054,3055],{},"低",[3050,3057,3058],{},"中",[3050,3060,3058],{},[3029,3062,3063,3066,3069,3072],{},[3050,3064,3065],{},"包体积",[3050,3067,3068],{},"小",[3050,3070,3071],{},"大",[3050,3073,3058],{},[3029,3075,3076,3079,3082,3085],{},[3050,3077,3078],{},"运行时开销",[3050,3080,3081],{},"无",[3050,3083,3084],{},"有",[3050,3086,3084],{},[3029,3088,3089,3092,3094,3096],{},[3050,3090,3091],{},"生态",[3050,3093,3068],{},[3050,3095,3071],{},[3050,3097,3071],{},[11,3099,1626],{"id":1626},[15,3101,3102,3103,3106,3107,3110],{},"如果你追求",[25,3104,3105],{},"简洁的代码","和",[25,3108,3109],{},"极致的性能","，SvelteKit 是一个非常值得尝试的选择。它的学习曲线平缓，写起来就像在写原生 HTML\u002FCSS\u002FJS。",[15,3112,3113],{},[1964,3114],{"alt":3115,"src":3116},"Svelte 编译过程","https:\u002F\u002Fsvelte.dev\u002Fsvelte-logo-horizontal.svg",[1665,3118,3119],{},"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":131,"searchDepth":145,"depth":163,"links":3121},[3122,3123,3128,3129],{"id":2918,"depth":145,"text":2919},{"id":2931,"depth":145,"text":2931,"children":3124},[3125,3126,3127],{"id":2934,"depth":163,"text":2935},{"id":2996,"depth":163,"text":2997},{"id":3014,"depth":163,"text":3015},{"id":3021,"depth":145,"text":3021},{"id":1626,"depth":145,"text":1626},"SvelteKit 是一个全栈 Svelte 框架，这篇文章带你了解它的核心概念和优势。","\u002Fposts\u002Fsveltekit-intro\u002Fimg\u002Fcover.jpg",{},"\u002Fposts\u002Fsveltekit-intro","2026-05-30",{"title":2913,"description":3130},"posts\u002Fsveltekit-intro\u002Findex",[3037,3138],"前端框架","vsEGyunpCWR7DiJAdyV1SPDfa57xXLITBI-lKM8UeGQ",{"id":3141,"title":3142,"author":6,"body":3143,"description":3541,"difficulty":2901,"draft":1691,"extension":1692,"image":3542,"meta":3543,"navigation":343,"path":3544,"pinned":343,"published":3545,"seo":3546,"series":3547,"seriesOrder":138,"stem":3548,"tags":3549,"updated":3551,"__hash__":3552},"posts\u002Fposts\u002Fhello-world\u002Findex.md","Hello World - 欢迎使用 SuBlog",{"type":8,"value":3144,"toc":3532},[3145,3149,3156,3159,3162,3165,3239,3242,3245,3307,3310,3313,3377,3380,3445,3448,3451,3467,3474,3477,3508,3511,3521,3524,3529],[11,3146,3148],{"id":3147},"博客上线啦","博客上线啦！",[15,3150,3151,3152,3155],{},"Hello World！欢迎使用 ",[25,3153,3154],{},"SuBlog","，这是站点的第一篇示例文章。",[15,3157,3158],{},"经过一段时间的折腾，这个基于 Nuxt 3 的个人博客终于上线了。在这里记录一下这个项目的技术选型和功能特性。",[11,3160,3161],{"id":3161},"技术栈",[15,3163,3164],{},"本站使用了以下技术：",[3023,3166,3167,3177],{},[3026,3168,3169],{},[3029,3170,3171,3174],{},[3032,3172,3173],{},"技术",[3032,3175,3176],{},"用途",[3045,3178,3179,3189,3199,3209,3219,3229],{},[3029,3180,3181,3186],{},[3050,3182,3183],{},[25,3184,3185],{},"Nuxt 3",[3050,3187,3188],{},"Vue 3 全栈框架，提供 SSG、路由、API 等能力",[3029,3190,3191,3196],{},[3050,3192,3193],{},[25,3194,3195],{},"TypeScript",[3050,3197,3198],{},"类型安全，提升开发体验",[3029,3200,3201,3206],{},[3050,3202,3203],{},[25,3204,3205],{},"Tailwind CSS v4",[3050,3207,3208],{},"原子化 CSS，快速构建 UI",[3029,3210,3211,3216],{},[3050,3212,3213],{},[25,3214,3215],{},"@nuxt\u002Fcontent",[3050,3217,3218],{},"Markdown 驱动的内容管理",[3029,3220,3221,3226],{},[3050,3222,3223],{},[25,3224,3225],{},"Shiki",[3050,3227,3228],{},"代码语法高亮",[3029,3230,3231,3236],{},[3050,3232,3233],{},[25,3234,3235],{},"Giscus",[3050,3237,3238],{},"基于 GitHub Discussions 的评论系统",[11,3240,3241],{"id":3241},"功能特性",[15,3243,3244],{},"目前已实现的功能：",[30,3246,3247,3253,3259,3265,3271,3277,3283,3289,3295,3301],{},[33,3248,3249,3252],{},[25,3250,3251],{},"博客系统"," - Markdown 文章，支持代码高亮、目录导航、搜索",[33,3254,3255,3258],{},[25,3256,3257],{},"暗色模式"," - 跟随系统或手动切换",[33,3260,3261,3264],{},[25,3262,3263],{},"文章搜索"," - 支持标题、描述、正文、标签多维度搜索",[33,3266,3267,3270],{},[25,3268,3269],{},"标签系统"," - 文章标签分类，高频标签快速筛选",[33,3272,3273,3276],{},[25,3274,3275],{},"友链页面"," - 展示友情链接，支持申请",[33,3278,3279,3282],{},[25,3280,3281],{},"赞助页面"," - 微信收款码展示",[33,3284,3285,3288],{},[25,3286,3287],{},"封面制作"," - 在线生成封面图片工具",[33,3290,3291,3294],{},[25,3292,3293],{},"网易云热评"," - 首页随机展示音乐评论",[33,3296,3297,3300],{},[25,3298,3299],{},"访客统计"," - 不蒜子计数器，带数字滚动动画",[33,3302,3303,3306],{},[25,3304,3305],{},"SEO 优化"," - 自动生成站点地图、Open Graph 标签",[11,3308,3309],{"id":3309},"代码示例",[15,3311,3312],{},"本站的配置非常集中，一个文件搞定全站定制：",[126,3314,3316],{"className":2945,"code":3315,"language":2947,"meta":131,"style":131},"\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",[63,3317,3318,3323,3337,3348,3358,3368,3373],{"__ignoreMap":131},[135,3319,3320],{"class":137,"line":138},[135,3321,3322],{"class":141},"\u002F\u002F config\u002Fsite.ts\n",[135,3324,3325,3327,3330,3333,3335],{"class":137,"line":145},[135,3326,149],{"class":148},[135,3328,3329],{"class":148}," const",[135,3331,3332],{"class":208}," siteConfig",[135,3334,447],{"class":148},[135,3336,488],{"class":159},[135,3338,3339,3342,3345],{"class":137,"line":163},[135,3340,3341],{"class":159},"  siteName: ",[135,3343,3344],{"class":169},"'我的博客'",[135,3346,3347],{"class":159},",\n",[135,3349,3350,3353,3356],{"class":137,"line":179},[135,3351,3352],{"class":159},"  url: ",[135,3354,3355],{"class":169},"'https:\u002F\u002Fexample.com\u002F'",[135,3357,3347],{"class":159},[135,3359,3360,3363,3366],{"class":137,"line":185},[135,3361,3362],{"class":159},"  avatar: ",[135,3364,3365],{"class":169},"'https:\u002F\u002Fq2.qlogo.cn\u002Fheadimg_dl?dst_uin=xxx&spec=100'",[135,3367,3347],{"class":159},[135,3369,3370],{"class":137,"line":366},[135,3371,3372],{"class":141},"  \u002F\u002F ... 改这一个文件，全站生效\n",[135,3374,3375],{"class":137,"line":376},[135,3376,314],{"class":159},[15,3378,3379],{},"文章的 frontmatter 也很简洁：",[126,3381,3385],{"className":3382,"code":3383,"language":3384,"meta":131,"style":131},"language-yaml shiki shiki-themes github-light github-dark","---\ntitle: 文章标题\npublished: 2026-05-31T08:00:00\ndescription: 文章简介\ntags: ['Nuxt', 'TypeScript']\n---\n","yaml",[63,3386,3387,3392,3403,3413,3423,3441],{"__ignoreMap":131},[135,3388,3389],{"class":137,"line":138},[135,3390,3391],{"class":155},"---\n",[135,3393,3394,3398,3400],{"class":137,"line":145},[135,3395,3397],{"class":3396},"sbB4o","title",[135,3399,306],{"class":159},[135,3401,3402],{"class":169},"文章标题\n",[135,3404,3405,3408,3410],{"class":137,"line":163},[135,3406,3407],{"class":3396},"published",[135,3409,306],{"class":159},[135,3411,3412],{"class":208},"2026-05-31T08:00:00\n",[135,3414,3415,3418,3420],{"class":137,"line":179},[135,3416,3417],{"class":3396},"description",[135,3419,306],{"class":159},[135,3421,3422],{"class":169},"文章简介\n",[135,3424,3425,3428,3431,3434,3436,3439],{"class":137,"line":185},[135,3426,3427],{"class":3396},"tags",[135,3429,3430],{"class":159},": [",[135,3432,3433],{"class":169},"'Nuxt'",[135,3435,567],{"class":159},[135,3437,3438],{"class":169},"'TypeScript'",[135,3440,2153],{"class":159},[135,3442,3443],{"class":137,"line":366},[135,3444,3391],{"class":155},[11,3446,3447],{"id":3447},"部署",[15,3449,3450],{},"本站使用 SSG 静态生成，构建后直接部署到静态托管即可：",[126,3452,3454],{"className":1094,"code":3453,"language":1096,"meta":131,"style":131},"npx nuxi generate\n",[63,3455,3456],{"__ignoreMap":131},[135,3457,3458,3461,3464],{"class":137,"line":138},[135,3459,3460],{"class":155},"npx",[135,3462,3463],{"class":169}," nuxi",[135,3465,3466],{"class":169}," generate\n",[15,3468,3469,3470,3473],{},"构建产物在 ",[63,3471,3472],{},".output\u002Fpublic"," 目录下，可以直接丢到 Nginx、Vercel、Netlify 等平台。",[11,3475,3476],{"id":3476},"后续计划",[30,3478,3481,3490,3496,3502],{"className":3479},[3480],"contains-task-list",[33,3482,3485,3489],{"className":3483},[3484],"task-list-item",[3486,3487],"input",{"disabled":343,"type":3488},"checkbox"," RSS 订阅",[33,3491,3493,3495],{"className":3492},[3484],[3486,3494],{"disabled":343,"type":3488}," 文章目录优化",[33,3497,3499,3501],{"className":3498},[3484],[3486,3500],{"disabled":343,"type":3488}," 更多小工具",[33,3503,3505,3507],{"className":3504},[3484],[3486,3506],{"disabled":343,"type":3488}," 持续输出内容",[11,3509,3510],{"id":3510},"致谢",[15,3512,3513,3514,3520],{},"感谢 ",[19,3515,3519],{"href":3516,"rel":3517},"https:\u002F\u002F2x.nz",[3518],"nofollow","二叉树树"," 的开源项目，本站的 UI 设计参考了他的项目。",[3522,3523],"hr",{},[15,3525,3526],{},[25,3527,3528],{},"感谢阅读！如果觉得不错，欢迎点个 Star 或者留个评论 (☞ﾟヮﾟ)☞",[1665,3530,3531],{},"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":131,"searchDepth":145,"depth":163,"links":3533},[3534,3535,3536,3537,3538,3539,3540],{"id":3147,"depth":145,"text":3148},{"id":3161,"depth":145,"text":3161},{"id":3241,"depth":145,"text":3241},{"id":3309,"depth":145,"text":3309},{"id":3447,"depth":145,"text":3447},{"id":3476,"depth":145,"text":3476},{"id":3510,"depth":145,"text":3510},"博客上线啦！这篇文章介绍本站的技术架构、功能特性和搭建过程中的一些思考。","\u002Fposts\u002Fhello-world\u002Fimg\u002Fcover.jpg",{},"\u002Fposts\u002Fhello-world","2026-05-31T08:00:00",{"title":3142,"description":3541},"Nuxt 博客开发","posts\u002Fhello-world\u002Findex",[3550,3043],"博客","2026-05-31","f9MCboCBfQIUxT8aKv6aKGDFxysiG8ZG0YXr5X5LTFo",{"id":3554,"title":3555,"author":6,"body":3556,"description":4714,"difficulty":1690,"draft":1691,"extension":1692,"image":4715,"meta":4716,"navigation":343,"path":4717,"pinned":1691,"published":4718,"seo":4719,"series":3547,"seriesOrder":145,"stem":4720,"tags":4721,"updated":3551,"__hash__":4724},"posts\u002Fposts\u002Fnuxt-content-lessons\u002Findex.md","Nuxt Content v3 踩坑记录：目录、图片与那些反直觉的设计",{"type":8,"value":3557,"toc":4704},[3558,3561,3564,3567,3571,3574,3584,3587,3594,3660,3667,3669,3676,3947,3957,3961,3964,3967,3992,4006,4009,4032,4038,4041,4044,4047,4052,4059,4282,4287,4331,4335,4338,4345,4348,4398,4404,4631,4641,4643,4695,4698,4701],[3559,3560,13],"h1",{"id":13},[15,3562,3563],{},"最近在用 Nuxt 3 + Nuxt Content v3 搭建一个个人博客，参考了一个 SvelteKit 项目（svaf）的架构。原以为换个框架只是语法差异，没想到在几个关键功能上踩了不少坑。",[15,3565,3566],{},"这篇文章记录了三个核心问题的排查过程和最终方案。",[3559,3568,3570],{"id":3569},"问题一目录导航toc只有-h2","问题一：目录导航（TOC）只有 h2",[11,3572,3573],{"id":3573},"现象",[15,3575,3576,3577,3580,3581,1311],{},"Nuxt Content 内置了 TOC 数据，挂在 ",[63,3578,3579],{},"post.body.toc"," 上。直接用它渲染目录，发现",[25,3582,3583],{},"只有 h2 标题，h3 全部丢失",[11,3585,3586],{"id":3586},"排查",[15,3588,3589,3590,3593],{},"查了官方文档，发现 ",[63,3591,3592],{},"build.markdown.toc.depth"," 可以控制深度：",[126,3595,3597],{"className":2945,"code":3596,"language":2947,"meta":131,"style":131},"\u002F\u002F nuxt.config.ts\ncontent: {\n  build: {\n    markdown: {\n      toc: { depth: 3 }  \u002F\u002F 包含 h3\n    }\n  }\n}\n",[63,3598,3599,3604,3611,3618,3625,3647,3651,3656],{"__ignoreMap":131},[135,3600,3601],{"class":137,"line":138},[135,3602,3603],{"class":141},"\u002F\u002F nuxt.config.ts\n",[135,3605,3606,3609],{"class":137,"line":145},[135,3607,3608],{"class":155},"content",[135,3610,298],{"class":159},[135,3612,3613,3616],{"class":137,"line":163},[135,3614,3615],{"class":155},"  build",[135,3617,298],{"class":159},[135,3619,3620,3623],{"class":137,"line":179},[135,3621,3622],{"class":155},"    markdown",[135,3624,298],{"class":159},[135,3626,3627,3630,3633,3636,3638,3641,3644],{"class":137,"line":185},[135,3628,3629],{"class":155},"      toc",[135,3631,3632],{"class":159},": { ",[135,3634,3635],{"class":155},"depth",[135,3637,306],{"class":159},[135,3639,3640],{"class":208},"3",[135,3642,3643],{"class":159}," }  ",[135,3645,3646],{"class":141},"\u002F\u002F 包含 h3\n",[135,3648,3649],{"class":137,"line":366},[135,3650,651],{"class":159},[135,3652,3653],{"class":137,"line":376},[135,3654,3655],{"class":159},"  }\n",[135,3657,3658],{"class":137,"line":386},[135,3659,314],{"class":159},[15,3661,3662,3663,3666],{},"配置改了，清了缓存重启，",[25,3664,3665],{},"依然只有 h2","。这个配置似乎不起作用。",[11,3668,1984],{"id":1984},[15,3670,3671,3672,3675],{},"放弃依赖 Nuxt Content 的 toc，直接从 ",[63,3673,3674],{},"post.body"," 的 AST 里递归提取所有标题：",[126,3677,3679],{"className":2945,"code":3678,"language":2947,"meta":131,"style":131},"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",[63,3680,3681,3723,3742,3755,3760,3817,3847,3878,3903,3907,3932,3936,3943],{"__ignoreMap":131},[135,3682,3683,3685,3688,3690,3693,3695,3698,3700,3703,3705,3708,3711,3713,3716,3718,3720],{"class":137,"line":138},[135,3684,2972],{"class":148},[135,3686,3687],{"class":155}," extractHeadings",[135,3689,548],{"class":159},[135,3691,3692],{"class":534},"node",[135,3694,2622],{"class":148},[135,3696,3697],{"class":208}," any",[135,3699,567],{"class":159},[135,3701,3702],{"class":534},"list",[135,3704,2622],{"class":148},[135,3706,3707],{"class":155}," Heading",[135,3709,3710],{"class":159},"[] ",[135,3712,599],{"class":148},[135,3714,3715],{"class":159}," [])",[135,3717,2622],{"class":148},[135,3719,3707],{"class":155},[135,3721,3722],{"class":159},"[] {\n",[135,3724,3725,3728,3730,3733,3736,3739],{"class":137,"line":145},[135,3726,3727],{"class":148},"  if",[135,3729,531],{"class":159},[135,3731,3732],{"class":148},"!",[135,3734,3735],{"class":159},"node) ",[135,3737,3738],{"class":148},"return",[135,3740,3741],{"class":159}," list\n",[135,3743,3744,3746,3749,3752],{"class":137,"line":163},[135,3745,3727],{"class":148},[135,3747,3748],{"class":159}," (Array.",[135,3750,3751],{"class":155},"isArray",[135,3753,3754],{"class":159},"(node)) {\n",[135,3756,3757],{"class":137,"line":179},[135,3758,3759],{"class":141},"    \u002F\u002F minimark 格式：[\"h2\", {id: \"xxx\"}, \"文本\"]\n",[135,3761,3762,3764,3766,3769,3772,3774,3777,3780,3783,3786,3789,3792,3796,3799,3801,3804,3806,3809,3812,3814],{"class":137,"line":185},[135,3763,579],{"class":148},[135,3765,531],{"class":159},[135,3767,3768],{"class":148},"typeof",[135,3770,3771],{"class":159}," node[",[135,3773,1227],{"class":208},[135,3775,3776],{"class":159},"] ",[135,3778,3779],{"class":148},"===",[135,3781,3782],{"class":169}," 'string'",[135,3784,3785],{"class":148}," &&",[135,3787,3788],{"class":169}," \u002F",[135,3790,3791],{"class":148},"^",[135,3793,3795],{"class":3794},"sPKmS","h",[135,3797,3798],{"class":208},"[1-6]",[135,3800,1595],{"class":148},[135,3802,3803],{"class":169},"\u002F",[135,3805,414],{"class":159},[135,3807,3808],{"class":155},"test",[135,3810,3811],{"class":159},"(node[",[135,3813,1227],{"class":208},[135,3815,3816],{"class":159},"])) {\n",[135,3818,3819,3822,3825,3827,3830,3832,3834,3837,3840,3842,3844],{"class":137,"line":366},[135,3820,3821],{"class":148},"      const",[135,3823,3824],{"class":208}," level",[135,3826,447],{"class":148},[135,3828,3829],{"class":155}," Number",[135,3831,3811],{"class":159},[135,3833,1227],{"class":208},[135,3835,3836],{"class":159},"].",[135,3838,3839],{"class":155},"charAt",[135,3841,548],{"class":159},[135,3843,1830],{"class":208},[135,3845,3846],{"class":159},"))\n",[135,3848,3849,3851,3854,3856,3859,3862,3865,3867,3870,3873,3876],{"class":137,"line":376},[135,3850,3821],{"class":148},[135,3852,3853],{"class":208}," text",[135,3855,447],{"class":148},[135,3857,3858],{"class":155}," extractText",[135,3860,3861],{"class":159},"(node.",[135,3863,3864],{"class":155},"slice",[135,3866,548],{"class":159},[135,3868,3869],{"class":208},"2",[135,3871,3872],{"class":159},")).",[135,3874,3875],{"class":155},"trim",[135,3877,793],{"class":159},[135,3879,3880,3883,3886,3889,3891,3894,3897,3900],{"class":137,"line":386},[135,3881,3882],{"class":159},"      list.",[135,3884,3885],{"class":155},"push",[135,3887,3888],{"class":159},"({ id: node[",[135,3890,1830],{"class":208},[135,3892,3893],{"class":159},"]?.id ",[135,3895,3896],{"class":148},"||",[135,3898,3899],{"class":155}," slugify",[135,3901,3902],{"class":159},"(text), text, level })\n",[135,3904,3905],{"class":137,"line":392},[135,3906,651],{"class":159},[135,3908,3909,3912,3914,3917,3920,3923,3926,3929],{"class":137,"line":397},[135,3910,3911],{"class":148},"    for",[135,3913,531],{"class":159},[135,3915,3916],{"class":148},"const",[135,3918,3919],{"class":208}," child",[135,3921,3922],{"class":148}," of",[135,3924,3925],{"class":159}," node) ",[135,3927,3928],{"class":155},"extractHeadings",[135,3930,3931],{"class":159},"(child, list)\n",[135,3933,3934],{"class":137,"line":403},[135,3935,3655],{"class":159},[135,3937,3938,3941],{"class":137,"line":420},[135,3939,3940],{"class":148},"  return",[135,3942,3741],{"class":159},[135,3944,3945],{"class":137,"line":425},[135,3946,314],{"class":159},[15,3948,3949,3950,3952,3953,3956],{},"关键点：Nuxt Content v3 的 body 使用 ",[25,3951,8],{}," 格式，标题节点是数组 ",[63,3954,3955],{},"[\"h2\", {id: \"xxx\"}, \"标题文本\"]","，不是对象。",[3559,3958,3960],{"id":3959},"问题二图片路径解析错误","问题二：图片路径解析错误",[11,3962,3573],{"id":3963},"现象-1",[15,3965,3966],{},"markdown 里用相对路径引用图片：",[126,3968,3972],{"className":3969,"code":3970,"language":3971,"meta":131,"style":131},"language-markdown shiki shiki-themes github-light github-dark","![SSHFS挂载效果](img\u002Fsshfs.avif)\n","markdown",[63,3973,3974],{"__ignoreMap":131},[135,3975,3976,3979,3983,3986,3990],{"class":137,"line":138},[135,3977,3978],{"class":159},"![",[135,3980,3982],{"class":3981},"smiIW","SSHFS挂载效果",[135,3984,3985],{"class":159},"](",[135,3987,3989],{"class":3988},"szbl0","img\u002Fsshfs.avif",[135,3991,389],{"class":159},[15,3993,3994,3995,3998,3999,4002,4003,1311],{},"HTML 输出的 ",[63,3996,3997],{},"\u003Cimg src=\"img\u002Fsshfs.avif\">"," 看起来没问题，但浏览器请求的却是 ",[63,4000,4001],{},"\u002Fposts\u002Fimg\u002Fsshfs.avif"," 而不是 ",[63,4004,4005],{},"\u002Fposts\u002Fsshfs\u002Fimg\u002Fsshfs.avif",[11,4007,3586],{"id":4008},"排查-1",[54,4010,4011,4018,4021,4027],{},[33,4012,4013,4014,4017],{},"检查了 ",[63,4015,4016],{},"\u003Cbase>"," 标签 —— 没有",[33,4019,4020],{},"检查了重定向 —— 没有",[33,4022,4023,4024,4026],{},"直接访问 ",[63,4025,4005],{}," —— 返回 200 OK",[33,4028,4023,4029,4031],{},[63,4030,4001],{}," —— 404",[15,4033,4034,4035,1311],{},"服务器路由没问题，问题是",[25,4036,4037],{},"浏览器解析相对路径时基于了错误的基准 URL",[15,4039,4040],{},"这可能和 Nuxt 的客户端路由有关：SPA 导航时，浏览器的\"当前页面\"和 Vue Router 的\"当前路由\"不同步，导致相对路径解析出错。",[11,4042,1984],{"id":4043},"最终方案-1",[15,4045,4046],{},"两步解决：",[15,4048,4049],{},[25,4050,4051],{},"第一步：自定义 ProseImg 组件",[15,4053,4054,4055,4058],{},"创建 ",[63,4056,4057],{},"components\u002Fcontent\u002FProseImg.vue","，覆盖 Nuxt Content 默认的图片渲染：",[126,4060,4064],{"className":4061,"code":4062,"language":4063,"meta":131,"style":131},"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",[63,4065,4066,4078,4092,4112,4128,4165,4200,4224,4228,4236,4240,4249,4274],{"__ignoreMap":131},[135,4067,4068,4070,4073,4076],{"class":137,"line":138},[135,4069,205],{"class":159},[135,4071,4072],{"class":3396},"script",[135,4074,4075],{"class":155}," setup",[135,4077,211],{"class":159},[135,4079,4080,4082,4085,4087,4090],{"class":137,"line":145},[135,4081,3916],{"class":148},[135,4083,4084],{"class":208}," route",[135,4086,447],{"class":148},[135,4088,4089],{"class":155}," useRoute",[135,4091,793],{"class":159},[135,4093,4094,4096,4099,4101,4104,4107,4110],{"class":137,"line":163},[135,4095,3916],{"class":148},[135,4097,4098],{"class":208}," resolvedSrc",[135,4100,447],{"class":148},[135,4102,4103],{"class":155}," computed",[135,4105,4106],{"class":159},"(() ",[135,4108,4109],{"class":148},"=>",[135,4111,488],{"class":159},[135,4113,4114,4116,4118,4120,4123,4125],{"class":137,"line":179},[135,4115,3727],{"class":148},[135,4117,531],{"class":159},[135,4119,3732],{"class":148},[135,4121,4122],{"class":159},"props.src) ",[135,4124,3738],{"class":148},[135,4126,4127],{"class":169}," ''\n",[135,4129,4130,4132,4135,4138,4140,4143,4145,4147,4150,4152,4154,4157,4160,4162],{"class":137,"line":185},[135,4131,3727],{"class":148},[135,4133,4134],{"class":159}," (props.src.",[135,4136,4137],{"class":155},"startsWith",[135,4139,548],{"class":159},[135,4141,4142],{"class":169},"'\u002F'",[135,4144,542],{"class":159},[135,4146,3896],{"class":148},[135,4148,4149],{"class":159}," props.src.",[135,4151,4137],{"class":155},[135,4153,548],{"class":159},[135,4155,4156],{"class":169},"'http'",[135,4158,4159],{"class":159},")) ",[135,4161,3738],{"class":148},[135,4163,4164],{"class":159}," props.src\n",[135,4166,4167,4170,4173,4175,4178,4181,4183,4185,4189,4191,4193,4195,4198],{"class":137,"line":366},[135,4168,4169],{"class":148},"  const",[135,4171,4172],{"class":208}," base",[135,4174,447],{"class":148},[135,4176,4177],{"class":159}," route.path.",[135,4179,4180],{"class":155},"replace",[135,4182,548],{"class":159},[135,4184,3803],{"class":169},[135,4186,4188],{"class":4187},"sCnZR","\\\u002F",[135,4190,1595],{"class":148},[135,4192,3803],{"class":169},[135,4194,567],{"class":159},[135,4196,4197],{"class":169},"''",[135,4199,389],{"class":159},[135,4201,4202,4204,4207,4210,4213,4216,4218,4221],{"class":137,"line":376},[135,4203,3940],{"class":148},[135,4205,4206],{"class":169}," `${",[135,4208,4209],{"class":159},"base",[135,4211,4212],{"class":169},"}\u002F${",[135,4214,4215],{"class":159},"props",[135,4217,414],{"class":169},[135,4219,4220],{"class":159},"src",[135,4222,4223],{"class":169},"}`\n",[135,4225,4226],{"class":137,"line":386},[135,4227,188],{"class":159},[135,4229,4230,4232,4234],{"class":137,"line":392},[135,4231,231],{"class":159},[135,4233,4072],{"class":3396},[135,4235,211],{"class":159},[135,4237,4238],{"class":137,"line":397},[135,4239,344],{"emptyLinePlaceholder":343},[135,4241,4242,4244,4247],{"class":137,"line":403},[135,4243,205],{"class":159},[135,4245,4246],{"class":3396},"template",[135,4248,211],{"class":159},[135,4250,4251,4253,4255,4258,4260,4263,4266,4268,4271],{"class":137,"line":420},[135,4252,216],{"class":159},[135,4254,1964],{"class":3396},[135,4256,4257],{"class":155}," :src",[135,4259,599],{"class":159},[135,4261,4262],{"class":169},"\"resolvedSrc\"",[135,4264,4265],{"class":155}," :alt",[135,4267,599],{"class":159},[135,4269,4270],{"class":169},"\"alt\"",[135,4272,4273],{"class":159}," \u002F>\n",[135,4275,4276,4278,4280],{"class":137,"line":425},[135,4277,231],{"class":159},[135,4279,4246],{"class":3396},[135,4281,211],{"class":159},[15,4283,4284],{},[25,4285,4286],{},"第二步：启用 MDC prose 组件",[126,4288,4290],{"className":2945,"code":4289,"language":2947,"meta":131,"style":131},"\u002F\u002F nuxt.config.ts\nmdc: {\n  components: {\n    prose: true  \u002F\u002F 让 Nuxt Content 使用自定义 ProseImg\n  }\n}\n",[63,4291,4292,4296,4303,4310,4323,4327],{"__ignoreMap":131},[135,4293,4294],{"class":137,"line":138},[135,4295,3603],{"class":141},[135,4297,4298,4301],{"class":137,"line":145},[135,4299,4300],{"class":155},"mdc",[135,4302,298],{"class":159},[135,4304,4305,4308],{"class":137,"line":163},[135,4306,4307],{"class":155},"  components",[135,4309,298],{"class":159},[135,4311,4312,4315,4317,4320],{"class":137,"line":179},[135,4313,4314],{"class":155},"    prose",[135,4316,306],{"class":159},[135,4318,4319],{"class":208},"true",[135,4321,4322],{"class":141},"  \u002F\u002F 让 Nuxt Content 使用自定义 ProseImg\n",[135,4324,4325],{"class":137,"line":185},[135,4326,3655],{"class":159},[135,4328,4329],{"class":137,"line":366},[135,4330,314],{"class":159},[3559,4332,4334],{"id":4333},"问题三content-目录的图片如何服务","问题三：content 目录的图片如何服务",[11,4336,3573],{"id":4337},"现象-2",[15,4339,4340,4341,4344],{},"原站（SvelteKit）的图片放在 ",[63,4342,4343],{},"content\u002Fposts\u002F{slug}\u002Fimg\u002F"," 下，markdown 用相对路径引用，一切正常。Nuxt 项目里，content 目录的文件不会被当成静态资源服务。",[11,4346,4347],{"id":4347},"方案对比",[3023,4349,4350,4363],{},[3026,4351,4352],{},[3029,4353,4354,4357,4360],{},[3032,4355,4356],{},"方案",[3032,4358,4359],{},"优点",[3032,4361,4362],{},"缺点",[3045,4364,4365,4376,4387],{},[3029,4366,4367,4370,4373],{},[3050,4368,4369],{},"复制到 public\u002F",[3050,4371,4372],{},"简单",[3050,4374,4375],{},"文件重复，需同步",[3029,4377,4378,4381,4384],{},[3050,4379,4380],{},"Vite 插件拦截",[3050,4382,4383],{},"原站方案",[3050,4385,4386],{},"Nuxt 不直接支持",[3029,4388,4389,4392,4395],{},[3050,4390,4391],{},"Server Route",[3050,4393,4394],{},"不复制文件",[3050,4396,4397],{},"需要手动写路由",[15,4399,4400,4401,4403],{},"最终选择 ",[25,4402,4391],{},"，和原站的 Vite 插件思路一致：",[126,4405,4407],{"className":2945,"code":4406,"language":2947,"meta":131,"style":131},"\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",[63,4408,4409,4414,4439,4454,4517,4531,4535,4557,4583,4601,4620,4627],{"__ignoreMap":131},[135,4410,4411],{"class":137,"line":138},[135,4412,4413],{"class":141},"\u002F\u002F server\u002Froutes\u002Fposts\u002F[slug]\u002Fimg\u002F[...file].ts\n",[135,4415,4416,4418,4420,4423,4425,4428,4430,4433,4435,4437],{"class":137,"line":145},[135,4417,149],{"class":148},[135,4419,152],{"class":148},[135,4421,4422],{"class":155}," defineEventHandler",[135,4424,548],{"class":159},[135,4426,4427],{"class":148},"async",[135,4429,531],{"class":159},[135,4431,4432],{"class":534},"event",[135,4434,542],{"class":159},[135,4436,4109],{"class":148},[135,4438,488],{"class":159},[135,4440,4441,4443,4446,4448,4451],{"class":137,"line":163},[135,4442,4169],{"class":148},[135,4444,4445],{"class":208}," url",[135,4447,447],{"class":148},[135,4449,4450],{"class":155}," getRequestURL",[135,4452,4453],{"class":159},"(event)\n",[135,4455,4456,4458,4461,4463,4466,4469,4471,4473,4475,4477,4480,4482,4484,4487,4489,4492,4494,4497,4499,4501,4503,4505,4507,4509,4511,4513,4515],{"class":137,"line":179},[135,4457,4169],{"class":148},[135,4459,4460],{"class":208}," match",[135,4462,447],{"class":148},[135,4464,4465],{"class":159}," url.pathname.",[135,4467,4468],{"class":155},"match",[135,4470,548],{"class":159},[135,4472,3803],{"class":169},[135,4474,3791],{"class":148},[135,4476,4188],{"class":4187},[135,4478,4479],{"class":3794},"posts",[135,4481,4188],{"class":4187},[135,4483,548],{"class":3794},[135,4485,4486],{"class":208},"[",[135,4488,3791],{"class":148},[135,4490,4491],{"class":208},"\u002F]",[135,4493,626],{"class":148},[135,4495,4496],{"class":3794},")",[135,4498,4188],{"class":4187},[135,4500,1964],{"class":3794},[135,4502,4188],{"class":4187},[135,4504,548],{"class":3794},[135,4506,414],{"class":208},[135,4508,626],{"class":148},[135,4510,4496],{"class":3794},[135,4512,1595],{"class":148},[135,4514,3803],{"class":169},[135,4516,389],{"class":159},[135,4518,4519,4521,4523,4525,4528],{"class":137,"line":185},[135,4520,3727],{"class":148},[135,4522,531],{"class":159},[135,4524,3732],{"class":148},[135,4526,4527],{"class":159},"match) ",[135,4529,4530],{"class":148},"return\n",[135,4532,4533],{"class":137,"line":366},[135,4534,344],{"emptyLinePlaceholder":343},[135,4536,4537,4539,4542,4545,4547,4550,4552,4554],{"class":137,"line":376},[135,4538,4169],{"class":148},[135,4540,4541],{"class":159}," [, ",[135,4543,4544],{"class":208},"slug",[135,4546,567],{"class":159},[135,4548,4549],{"class":208},"filename",[135,4551,3776],{"class":159},[135,4553,599],{"class":148},[135,4555,4556],{"class":159}," match\n",[135,4558,4559,4561,4564,4566,4569,4571,4574,4577,4580],{"class":137,"line":386},[135,4560,4169],{"class":148},[135,4562,4563],{"class":208}," filePath",[135,4565,447],{"class":148},[135,4567,4568],{"class":155}," resolve",[135,4570,548],{"class":159},[135,4572,4573],{"class":169},"'content\u002Fposts'",[135,4575,4576],{"class":159},", slug, ",[135,4578,4579],{"class":169},"'img'",[135,4581,4582],{"class":159},", filename)\n",[135,4584,4585,4587,4590,4592,4595,4598],{"class":137,"line":392},[135,4586,4169],{"class":148},[135,4588,4589],{"class":208}," data",[135,4591,447],{"class":148},[135,4593,4594],{"class":148}," await",[135,4596,4597],{"class":155}," readFile",[135,4599,4600],{"class":159},"(filePath)\n",[135,4602,4603,4606,4609,4612,4614,4617],{"class":137,"line":397},[135,4604,4605],{"class":155},"  setResponseHeader",[135,4607,4608],{"class":159},"(event, ",[135,4610,4611],{"class":169},"'content-type'",[135,4613,567],{"class":159},[135,4615,4616],{"class":208},"MIME",[135,4618,4619],{"class":159},"[ext])\n",[135,4621,4622,4624],{"class":137,"line":403},[135,4623,3940],{"class":148},[135,4625,4626],{"class":159}," data\n",[135,4628,4629],{"class":137,"line":420},[135,4630,188],{"class":159},[15,4632,4633,4634,4636,4637,4640],{},"请求 ",[63,4635,4005],{}," → 读取 ",[63,4638,4639],{},"content\u002Fposts\u002Fsshfs\u002Fimg\u002Fsshfs.avif"," → 返回图片。",[3559,4642,1626],{"id":1626},[3023,4644,4645,4657],{},[3026,4646,4647],{},[3029,4648,4649,4652,4655],{},[3032,4650,4651],{},"问题",[3032,4653,4654],{},"根因",[3032,4656,4356],{},[3045,4658,4659,4673,4684],{},[3029,4660,4661,4664,4670],{},[3050,4662,4663],{},"TOC 只有 h2",[3050,4665,4666,4669],{},[63,4667,4668],{},"toc.depth"," 配置不生效",[3050,4671,4672],{},"从 body AST 手动提取",[3029,4674,4675,4678,4681],{},[3050,4676,4677],{},"图片路径错误",[3050,4679,4680],{},"SPA 路由导致相对路径解析基准错误",[3050,4682,4683],{},"ProseImg 组件 + mdc.prose 配置",[3029,4685,4686,4689,4692],{},[3050,4687,4688],{},"content 图片 404",[3050,4690,4691],{},"content 目录不暴露给浏览器",[3050,4693,4694],{},"Server Route 直接读取返回",[15,4696,4697],{},"Nuxt Content v3 的设计理念是\"图片放 public\u002F，用绝对路径\"。如果想像 SvelteKit 项目那样把图片和文章放一起，需要额外做不少工作。",[15,4699,4700],{},"不过一旦这些坑都踩过，整体开发体验还是很流畅的。Nuxt 的生态系统、自动导入、模块系统都比 SvelteKit 成熟很多。",[1665,4702,4703],{},"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":131,"searchDepth":145,"depth":163,"links":4705},[4706,4707,4708,4709,4710,4711,4712,4713],{"id":3573,"depth":145,"text":3573},{"id":3586,"depth":145,"text":3586},{"id":1984,"depth":145,"text":1984},{"id":3963,"depth":145,"text":3573},{"id":4008,"depth":145,"text":3586},{"id":4043,"depth":145,"text":1984},{"id":4337,"depth":145,"text":3573},{"id":4347,"depth":145,"text":4347},"从 SvelteKit 迁移到 Nuxt 3 的过程中，遇到了不少 Nuxt Content v3 的坑。这篇文章记录了目录导航、图片路径、ProseImg 组件等问题的排查和解决过程。","\u002Fposts\u002Fnuxt-content-lessons\u002Fimg\u002Fcover.jpg",{},"\u002Fposts\u002Fnuxt-content-lessons","2026-05-31T14:00:00",{"title":3555,"description":4714},"posts\u002Fnuxt-content-lessons\u002Findex",[3043,4722,4723],"踩坑","Nuxt Content","vbSt6ND1z7xt7L8FxgZ8Qe3EdxV9InsRerN3E23GC7s",{"id":4726,"title":4727,"author":3154,"body":4728,"description":4991,"difficulty":1690,"draft":1691,"extension":1692,"image":4992,"meta":4993,"navigation":343,"path":4994,"pinned":1691,"published":4995,"seo":4996,"series":3547,"seriesOrder":163,"stem":4997,"tags":4998,"updated":3551,"__hash__":5001},"posts\u002Fposts\u002Fnuxt-tailwind-underline-fix\u002Findex.md","Nuxt 3 + Tailwind CSS 客户端导航链接下划线闪烁问题",{"type":8,"value":4729,"toc":4984},[4730,4733,4736,4751,4754,4757,4760,4801,4812,4818,4840,4847,4851,4854,4857,4859,4862,4885,4896,4952,4957,4970,4972,4978,4981],[11,4731,4732],{"id":4732},"问题描述",[15,4734,4735],{},"在 Nuxt 3 项目中使用 Tailwind CSS 时，发现一个奇怪的现象：",[30,4737,4738,4748],{},[33,4739,4740,4741,4744,4745],{},"首次通过客户端导航（点击 ",[63,4742,4743],{},"\u003CNuxtLink>","）跳转到某个页面时，",[25,4746,4747],{},"所有链接文字都会出现下划线",[33,4749,4750],{},"手动刷新页面后，下划线消失，样式恢复正常",[15,4752,4753],{},"这个问题在开发模式和生产构建后都会出现。",[11,4755,4756],{"id":4756},"原因分析",[15,4758,4759],{},"Tailwind CSS 的 Preflight（基于 modern-normalize）会重置链接样式：",[126,4761,4765],{"className":4762,"code":4763,"language":4764,"meta":131,"style":131},"language-css shiki shiki-themes github-light github-dark","a {\n  color: inherit;\n  text-decoration: inherit;\n}\n","css",[63,4766,4767,4773,4786,4797],{"__ignoreMap":131},[135,4768,4769,4771],{"class":137,"line":138},[135,4770,19],{"class":3396},[135,4772,488],{"class":159},[135,4774,4775,4778,4780,4783],{"class":137,"line":145},[135,4776,4777],{"class":208},"  color",[135,4779,306],{"class":159},[135,4781,4782],{"class":208},"inherit",[135,4784,4785],{"class":159},";\n",[135,4787,4788,4791,4793,4795],{"class":137,"line":163},[135,4789,4790],{"class":208},"  text-decoration",[135,4792,306],{"class":159},[135,4794,4782],{"class":208},[135,4796,4785],{"class":159},[135,4798,4799],{"class":137,"line":179},[135,4800,314],{"class":159},[15,4802,4803,4804,4807,4808,4811],{},"正常情况下，这会让链接继承父元素的 ",[63,4805,4806],{},"text-decoration","（通常是 ",[63,4809,4810],{},"none","），从而去掉浏览器默认的下划线。",[15,4813,4814,4815,124],{},"问题出在 ",[25,4816,4817],{},"Nuxt 的客户端导航机制",[54,4819,4820,4826,4829,4834,4837],{},[33,4821,4822,4823,4825],{},"用户点击 ",[63,4824,4743],{},"，Vue Router 拦截导航",[33,4827,4828],{},"新页面组件异步加载并渲染",[33,4830,4831],{},[25,4832,4833],{},"在 CSS 完全加载\u002F应用之前，页面已经渲染了",[33,4835,4836],{},"此时浏览器使用默认样式（链接带下划线）",[33,4838,4839],{},"CSS 加载完成后，下划线消失",[15,4841,4842,4843,4846],{},"这就是典型的 ",[25,4844,4845],{},"FOUC（Flash of Unstyled Content）"," 问题。",[11,4848,4850],{"id":4849},"为什么刷新就正常了","为什么刷新就正常了？",[15,4852,4853],{},"刷新页面时，浏览器会等待所有 CSS 加载完成后再渲染页面（SSR 模式下 HTML 和 CSS 是一起返回的）。所以刷新后样式是正确的。",[15,4855,4856],{},"而客户端导航是 JavaScript 驱动的，CSS 的加载和页面的渲染是异步的，就出现了时序差。",[11,4858,2511],{"id":2511},[15,4860,4861],{},"尝试了多种方案：",[30,4863,4864,4870,4876,4882],{},[33,4865,4866,4869],{},[63,4867,4868],{},"@layer base"," 覆盖 — 无效，CSS layers 的优先级问题",[33,4871,4872,4875],{},[63,4873,4874],{},"corePlugins.preflight: false"," — 无效，且破坏其他基础样式",[33,4877,4878,4881],{},[63,4879,4880],{},"features.inlineStyles: true"," — 无效",[33,4883,4884],{},"页面过渡动画 — 只是掩盖，不能根治",[15,4886,4887,4888,4891,4892,4895],{},"最终有效的方案是在 ",[63,4889,4890],{},"app.vue"," 中用 ",[63,4893,4894],{},"!important"," 强制覆盖：",[126,4897,4899],{"className":4061,"code":4898,"language":4063,"meta":131,"style":131},"\u003Cstyle>\na:not(.prose a) {\n  text-decoration-line: none !important;\n}\n\u003C\u002Fstyle>\n",[63,4900,4901,4909,4926,4940,4944],{"__ignoreMap":131},[135,4902,4903,4905,4907],{"class":137,"line":138},[135,4904,205],{"class":159},[135,4906,1665],{"class":3396},[135,4908,211],{"class":159},[135,4910,4911,4913,4916,4918,4921,4924],{"class":137,"line":145},[135,4912,19],{"class":3396},[135,4914,4915],{"class":155},":not",[135,4917,548],{"class":159},[135,4919,4920],{"class":155},".prose",[135,4922,4923],{"class":3396}," a",[135,4925,573],{"class":159},[135,4927,4928,4931,4933,4935,4938],{"class":137,"line":163},[135,4929,4930],{"class":208},"  text-decoration-line",[135,4932,306],{"class":159},[135,4934,4810],{"class":208},[135,4936,4937],{"class":148}," !important",[135,4939,4785],{"class":159},[135,4941,4942],{"class":137,"line":179},[135,4943,314],{"class":159},[135,4945,4946,4948,4950],{"class":137,"line":185},[135,4947,231],{"class":159},[135,4949,1665],{"class":3396},[135,4951,211],{"class":159},[15,4953,4954],{},[25,4955,4956],{},"关键点：",[30,4958,4959,4964],{},[33,4960,4961,4963],{},[63,4962,4894],{}," 是为了对抗 Tailwind CSS layers 的优先级",[33,4965,4966,4969],{},[63,4967,4968],{},":not(.prose a)"," 排除文章正文区域，保留文章内链接的下划线（可读性需要）",[11,4971,1626],{"id":1626},[15,4973,4974,4975,4977],{},"这是 Nuxt 3 + Tailwind CSS 的一个已知问题，本质上是 CSS 加载时序导致的 FOUC。",[63,4976,4894],{}," 虽然不够优雅，但在这个场景下是务实的解决方案。",[15,4979,4980],{},"如果你有更好的方案，欢迎在项目仓库的 Issues 中提出！",[1665,4982,4983],{},"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":131,"searchDepth":145,"depth":163,"links":4985},[4986,4987,4988,4989,4990],{"id":4732,"depth":145,"text":4732},{"id":4756,"depth":145,"text":4756},{"id":4849,"depth":145,"text":4850},{"id":2511,"depth":145,"text":2511},{"id":1626,"depth":145,"text":1626},"记录 Nuxt 3 配合 Tailwind CSS 时，客户端导航导致链接文字出现下划线闪烁的问题及解决方案。","\u002Fposts\u002Fnuxt-tailwind-underline-fix\u002Fimg\u002Fcover.png",{},"\u002Fposts\u002Fnuxt-tailwind-underline-fix","2026-05-31T18:47:00",{"title":4727,"description":4991},"posts\u002Fnuxt-tailwind-underline-fix\u002Findex",[3043,4999,5000,4722],"Tailwind CSS","CSS","WMLpTEsIoJqufwEfXyIiBgUYxcPY2XkvOwqAD4hbe6g",{"id":5003,"title":5004,"author":6,"body":5005,"description":7027,"difficulty":1690,"draft":1691,"extension":1692,"image":7028,"meta":7029,"navigation":343,"path":7030,"pinned":1691,"published":7031,"seo":7032,"series":1698,"seriesOrder":138,"stem":7033,"tags":7034,"updated":7039,"__hash__":7040},"posts\u002Fposts\u002Fgo-mcp-gin\u002Findex.md","Go-MCP 接入 Gin：从零搭建 MCP 服务器",{"type":8,"value":5006,"toc":7008},[5007,5011,5014,5017,5021,5028,5034,5037,5040,5060,5063,5067,5091,5095,5192,5205,5209,5367,5370,5587,5591,5602,5701,5708,5712,5977,5980,6061,6065,6068,6070,6081,6085,6091,6451,6639,6642,6698,6701,6854,6857,6860,6929,6931,6996,7005],[11,5008,5010],{"id":5009},"什么是-mcp","什么是 MCP",[15,5012,5013],{},"MCP（Model Context Protocol）是 Anthropic 提出的开放协议，用于让 AI 助手（如 Claude）与外部服务进行标准化交互。简单来说：你提供一个 MCP 服务器，AI 就能发现并调用你定义的工具（Tools），就像给 AI 装了一双手。",[15,5015,5016],{},"传统做法是写 REST API 然后让 AI 通过函数调用（Function Calling）间接使用，但 MCP 统一了协议层，AI 客户端只需要配置一个 URL 就能接入所有工具。",[11,5018,5020],{"id":5019},"为什么选-gin-mcp-go","为什么选 Gin + mcp-go",[15,5022,5023,5024,5027],{},"Go 生态里做 MCP 服务器最成熟的库是 ",[63,5025,5026],{},"mark3labs\u002Fmcp-go","，它支持 StreamableHTTP 传输模式，可以直接挂载到任意 HTTP 框架上。Gin 作为 Go 最流行的 HTTP 框架，天然适配。",[15,5029,5030,5031],{},"核心思路：",[25,5032,5033],{},"MCP 服务器本质上就是一个 HTTP Handler，挂到 Gin 路由上即可。",[11,5035,5036],{"id":5036},"需求分析",[15,5038,5039],{},"我们要搭建一个具备以下能力的 MCP 服务器：",[54,5041,5042,5048,5054],{},[33,5043,5044,5047],{},[25,5045,5046],{},"工具注册"," — 向 AI 暴露可调用的工具（列出资源、查看详情、创建\u002F删除）",[33,5049,5050,5053],{},[25,5051,5052],{},"HTTP 集成"," — MCP 端点作为 Gin 路由的一部分，与业务 API 共存",[33,5055,5056,5059],{},[25,5057,5058],{},"鉴权"," — MCP 请求必须经过身份验证，工具处理器能获取当前用户信息",[11,5061,5062],{"id":5062},"实现步骤",[110,5064,5066],{"id":5065},"_1-安装依赖","1. 安装依赖",[126,5068,5070],{"className":1094,"code":5069,"language":1096,"meta":131,"style":131},"go get github.com\u002Fmark3labs\u002Fmcp-go@v0.54.0\ngo get github.com\u002Fgin-gonic\u002Fgin@v1.12.0\n",[63,5071,5072,5082],{"__ignoreMap":131},[135,5073,5074,5076,5079],{"class":137,"line":138},[135,5075,323],{"class":155},[135,5077,5078],{"class":169}," get",[135,5080,5081],{"class":169}," github.com\u002Fmark3labs\u002Fmcp-go@v0.54.0\n",[135,5083,5084,5086,5088],{"class":137,"line":145},[135,5085,323],{"class":155},[135,5087,5078],{"class":169},[135,5089,5090],{"class":169}," github.com\u002Fgin-gonic\u002Fgin@v1.12.0\n",[110,5092,5094],{"id":5093},"_2-创建-mcp-服务器","2. 创建 MCP 服务器",[126,5096,5098],{"className":321,"code":5097,"language":323,"meta":131,"style":131},"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",[63,5099,5100,5111,5115,5131,5142,5153,5164,5173,5188],{"__ignoreMap":131},[135,5101,5102,5104,5106,5109],{"class":137,"line":138},[135,5103,349],{"class":148},[135,5105,1133],{"class":169},[135,5107,5108],{"class":155},"github.com\u002Fmark3labs\u002Fmcp-go\u002Fserver",[135,5110,363],{"class":169},[135,5112,5113],{"class":137,"line":145},[135,5114,344],{"emptyLinePlaceholder":343},[135,5116,5117,5120,5122,5125,5128],{"class":137,"line":163},[135,5118,5119],{"class":159},"mcpServer ",[135,5121,784],{"class":148},[135,5123,5124],{"class":159}," server.",[135,5126,5127],{"class":155},"NewMCPServer",[135,5129,5130],{"class":159},"(\n",[135,5132,5133,5136,5139],{"class":137,"line":179},[135,5134,5135],{"class":169},"    \"my-app\"",[135,5137,5138],{"class":159},",    ",[135,5140,5141],{"class":141},"\u002F\u002F 服务器名称\n",[135,5143,5144,5147,5150],{"class":137,"line":185},[135,5145,5146],{"class":169},"    \"1.0.0\"",[135,5148,5149],{"class":159},",     ",[135,5151,5152],{"class":141},"\u002F\u002F 版本\n",[135,5154,5155,5158,5161],{"class":137,"line":366},[135,5156,5157],{"class":159},"    server.",[135,5159,5160],{"class":155},"WithLogging",[135,5162,5163],{"class":159},"(),\n",[135,5165,5166,5168,5171],{"class":137,"line":376},[135,5167,5157],{"class":159},[135,5169,5170],{"class":155},"WithRecovery",[135,5172,5163],{"class":159},[135,5174,5175,5177,5180,5182,5185],{"class":137,"line":386},[135,5176,5157],{"class":159},[135,5178,5179],{"class":155},"WithInstructions",[135,5181,548],{"class":159},[135,5183,5184],{"class":169},"\"这是一个 MCP 服务器示例\"",[135,5186,5187],{"class":159},"),\n",[135,5189,5190],{"class":137,"line":392},[135,5191,389],{"class":159},[15,5193,5194,5196,5197,5200,5201,5204],{},[63,5195,5127],{}," 创建一个 MCP 协议实例，它负责处理工具发现（",[63,5198,5199],{},"tools\u002Flist","）和工具调用（",[63,5202,5203],{},"tools\u002Fcall","）的协议逻辑。",[110,5206,5208],{"id":5207},"_3-注册工具","3. 注册工具",[126,5210,5212],{"className":321,"code":5211,"language":323,"meta":131,"style":131},"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",[63,5213,5214,5225,5229,5234,5254,5269,5273,5277,5282,5338,5343,5363],{"__ignoreMap":131},[135,5215,5216,5218,5220,5223],{"class":137,"line":138},[135,5217,349],{"class":148},[135,5219,1133],{"class":169},[135,5221,5222],{"class":155},"github.com\u002Fmark3labs\u002Fmcp-go\u002Fmcp",[135,5224,363],{"class":169},[135,5226,5227],{"class":137,"line":145},[135,5228,344],{"emptyLinePlaceholder":343},[135,5230,5231],{"class":137,"line":163},[135,5232,5233],{"class":141},"\u002F\u002F 定义工具\n",[135,5235,5236,5239,5241,5244,5247,5249,5252],{"class":137,"line":179},[135,5237,5238],{"class":159},"listTool ",[135,5240,784],{"class":148},[135,5242,5243],{"class":159}," mcp.",[135,5245,5246],{"class":155},"NewTool",[135,5248,548],{"class":159},[135,5250,5251],{"class":169},"\"list_items\"",[135,5253,3347],{"class":159},[135,5255,5256,5259,5262,5264,5267],{"class":137,"line":185},[135,5257,5258],{"class":159},"    mcp.",[135,5260,5261],{"class":155},"WithDescription",[135,5263,548],{"class":159},[135,5265,5266],{"class":169},"\"列出所有项目\"",[135,5268,5187],{"class":159},[135,5270,5271],{"class":137,"line":366},[135,5272,389],{"class":159},[135,5274,5275],{"class":137,"line":376},[135,5276,344],{"emptyLinePlaceholder":343},[135,5278,5279],{"class":137,"line":386},[135,5280,5281],{"class":141},"\u002F\u002F 注册处理器\n",[135,5283,5284,5287,5290,5293,5295,5297,5300,5303,5305,5307,5309,5312,5315,5317,5320,5322,5324,5327,5329,5332,5334,5336],{"class":137,"line":392},[135,5285,5286],{"class":159},"mcpServer.",[135,5288,5289],{"class":155},"AddTool",[135,5291,5292],{"class":159},"(listTool, ",[135,5294,528],{"class":148},[135,5296,548],{"class":159},[135,5298,5299],{"class":534},"ctx",[135,5301,5302],{"class":155}," context",[135,5304,414],{"class":159},[135,5306,844],{"class":155},[135,5308,567],{"class":159},[135,5310,5311],{"class":534},"req",[135,5313,5314],{"class":155}," mcp",[135,5316,414],{"class":159},[135,5318,5319],{"class":155},"CallToolRequest",[135,5321,557],{"class":159},[135,5323,506],{"class":148},[135,5325,5326],{"class":155},"mcp",[135,5328,414],{"class":159},[135,5330,5331],{"class":155},"CallToolResult",[135,5333,567],{"class":159},[135,5335,570],{"class":148},[135,5337,573],{"class":159},[135,5339,5340],{"class":137,"line":397},[135,5341,5342],{"class":141},"    \u002F\u002F 业务逻辑\n",[135,5344,5345,5347,5349,5352,5354,5357,5360],{"class":137,"line":403},[135,5346,657],{"class":148},[135,5348,5243],{"class":159},[135,5350,5351],{"class":155},"NewToolResultText",[135,5353,548],{"class":159},[135,5355,5356],{"class":169},"\"返回结果\"",[135,5358,5359],{"class":159},"), ",[135,5361,5362],{"class":208},"nil\n",[135,5364,5365],{"class":137,"line":420},[135,5366,188],{"class":159},[15,5368,5369],{},"带参数的工具：",[126,5371,5373],{"className":321,"code":5372,"language":323,"meta":131,"style":131},"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",[63,5374,5375,5393,5406,5420,5430,5444,5449,5453,5457,5504,5523,5538,5557,5561,5566,5583],{"__ignoreMap":131},[135,5376,5377,5380,5382,5384,5386,5388,5391],{"class":137,"line":138},[135,5378,5379],{"class":159},"getTool ",[135,5381,784],{"class":148},[135,5383,5243],{"class":159},[135,5385,5246],{"class":155},[135,5387,548],{"class":159},[135,5389,5390],{"class":169},"\"get_item\"",[135,5392,3347],{"class":159},[135,5394,5395,5397,5399,5401,5404],{"class":137,"line":145},[135,5396,5258],{"class":159},[135,5398,5261],{"class":155},[135,5400,548],{"class":159},[135,5402,5403],{"class":169},"\"获取指定项目的详情\"",[135,5405,5187],{"class":159},[135,5407,5408,5410,5413,5415,5418],{"class":137,"line":163},[135,5409,5258],{"class":159},[135,5411,5412],{"class":155},"WithString",[135,5414,548],{"class":159},[135,5416,5417],{"class":169},"\"item_id\"",[135,5419,3347],{"class":159},[135,5421,5422,5425,5428],{"class":137,"line":179},[135,5423,5424],{"class":159},"        mcp.",[135,5426,5427],{"class":155},"Required",[135,5429,5163],{"class":159},[135,5431,5432,5434,5437,5439,5442],{"class":137,"line":185},[135,5433,5424],{"class":159},[135,5435,5436],{"class":155},"Description",[135,5438,548],{"class":159},[135,5440,5441],{"class":169},"\"项目 ID\"",[135,5443,5187],{"class":159},[135,5445,5446],{"class":137,"line":366},[135,5447,5448],{"class":159},"    ),\n",[135,5450,5451],{"class":137,"line":376},[135,5452,389],{"class":159},[135,5454,5455],{"class":137,"line":386},[135,5456,344],{"emptyLinePlaceholder":343},[135,5458,5459,5461,5463,5466,5468,5470,5472,5474,5476,5478,5480,5482,5484,5486,5488,5490,5492,5494,5496,5498,5500,5502],{"class":137,"line":392},[135,5460,5286],{"class":159},[135,5462,5289],{"class":155},[135,5464,5465],{"class":159},"(getTool, ",[135,5467,528],{"class":148},[135,5469,548],{"class":159},[135,5471,5299],{"class":534},[135,5473,5302],{"class":155},[135,5475,414],{"class":159},[135,5477,844],{"class":155},[135,5479,567],{"class":159},[135,5481,5311],{"class":534},[135,5483,5314],{"class":155},[135,5485,414],{"class":159},[135,5487,5319],{"class":155},[135,5489,557],{"class":159},[135,5491,506],{"class":148},[135,5493,5326],{"class":155},[135,5495,414],{"class":159},[135,5497,5331],{"class":155},[135,5499,567],{"class":159},[135,5501,570],{"class":148},[135,5503,573],{"class":159},[135,5505,5506,5509,5511,5514,5517,5519,5521],{"class":137,"line":397},[135,5507,5508],{"class":159},"    itemID, err ",[135,5510,784],{"class":148},[135,5512,5513],{"class":159}," req.",[135,5515,5516],{"class":155},"RequireString",[135,5518,548],{"class":159},[135,5520,5417],{"class":169},[135,5522,389],{"class":159},[135,5524,5525,5527,5530,5533,5536],{"class":137,"line":403},[135,5526,579],{"class":148},[135,5528,5529],{"class":159}," err ",[135,5531,5532],{"class":148},"!=",[135,5534,5535],{"class":208}," nil",[135,5537,488],{"class":159},[135,5539,5540,5543,5545,5548,5550,5553,5555],{"class":137,"line":420},[135,5541,5542],{"class":148},"        return",[135,5544,5243],{"class":159},[135,5546,5547],{"class":155},"NewToolResultError",[135,5549,548],{"class":159},[135,5551,5552],{"class":169},"\"缺少 item_id 参数\"",[135,5554,5359],{"class":159},[135,5556,5362],{"class":208},[135,5558,5559],{"class":137,"line":425},[135,5560,651],{"class":159},[135,5562,5563],{"class":137,"line":431},[135,5564,5565],{"class":141},"    \u002F\u002F 查询并返回\n",[135,5567,5568,5570,5572,5574,5576,5579,5581],{"class":137,"line":471},[135,5569,657],{"class":148},[135,5571,5243],{"class":159},[135,5573,5351],{"class":155},[135,5575,548],{"class":159},[135,5577,5578],{"class":169},"\"项目详情...\"",[135,5580,5359],{"class":159},[135,5582,5362],{"class":208},[135,5584,5585],{"class":137,"line":476},[135,5586,188],{"class":159},[110,5588,5590],{"id":5589},"_4-挂载到-gin-路由","4. 挂载到 Gin 路由",[15,5592,5593,5594,5597,5598,5601],{},"关键一步：用 ",[63,5595,5596],{},"server.NewStreamableHTTPServer"," 把 MCP 实例转成 HTTP Handler，再用 ",[63,5599,5600],{},"gin.WrapH"," 适配 Gin。",[126,5603,5605],{"className":321,"code":5604,"language":323,"meta":131,"style":131},"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",[63,5606,5607,5617,5621,5636,5650,5667,5671,5675,5680],{"__ignoreMap":131},[135,5608,5609,5611,5613,5615],{"class":137,"line":138},[135,5610,349],{"class":148},[135,5612,1133],{"class":169},[135,5614,5108],{"class":155},[135,5616,363],{"class":169},[135,5618,5619],{"class":137,"line":145},[135,5620,344],{"emptyLinePlaceholder":343},[135,5622,5623,5626,5628,5630,5633],{"class":137,"line":163},[135,5624,5625],{"class":159},"mcpHTTPServer ",[135,5627,784],{"class":148},[135,5629,5124],{"class":159},[135,5631,5632],{"class":155},"NewStreamableHTTPServer",[135,5634,5635],{"class":159},"(mcpServer,\n",[135,5637,5638,5640,5643,5645,5648],{"class":137,"line":179},[135,5639,5157],{"class":159},[135,5641,5642],{"class":155},"WithEndpointPath",[135,5644,548],{"class":159},[135,5646,5647],{"class":169},"\"\u002Fmcp\"",[135,5649,5187],{"class":159},[135,5651,5652,5654,5657,5659,5662,5664],{"class":137,"line":185},[135,5653,5157],{"class":159},[135,5655,5656],{"class":155},"WithHeartbeatInterval",[135,5658,548],{"class":159},[135,5660,5661],{"class":208},"30",[135,5663,506],{"class":148},[135,5665,5666],{"class":159},"time.Second),\n",[135,5668,5669],{"class":137,"line":366},[135,5670,389],{"class":159},[135,5672,5673],{"class":137,"line":376},[135,5674,344],{"emptyLinePlaceholder":343},[135,5676,5677],{"class":137,"line":386},[135,5678,5679],{"class":141},"\u002F\u002F 挂载到 Gin\n",[135,5681,5682,5685,5688,5690,5692,5695,5698],{"class":137,"line":392},[135,5683,5684],{"class":159},"r.",[135,5686,5687],{"class":155},"Any",[135,5689,548],{"class":159},[135,5691,5647],{"class":169},[135,5693,5694],{"class":159},", gin.",[135,5696,5697],{"class":155},"WrapH",[135,5699,5700],{"class":159},"(mcpHTTPServer))\n",[15,5702,5703,5704,5707],{},"就这样，",[63,5705,5706],{},"POST \u002Fmcp"," 端点就生效了。AI 客户端通过这个 URL 发送 JSON-RPC 请求，MCP 服务器自动处理协议握手、工具列表返回、工具调用路由。",[110,5709,5711],{"id":5710},"_5-完整的服务启动代码","5. 完整的服务启动代码",[126,5713,5715],{"className":321,"code":5714,"language":323,"meta":131,"style":131},"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",[63,5716,5717,5726,5738,5742,5747,5770,5779,5787,5792,5796,5801,5809,5813,5818,5831,5843,5857,5861,5878,5882,5887,5916,5951,5955,5959,5973],{"__ignoreMap":131},[135,5718,5719,5721,5724],{"class":137,"line":138},[135,5720,528],{"class":148},[135,5722,5723],{"class":155}," main",[135,5725,2978],{"class":159},[135,5727,5728,5730,5732,5734,5736],{"class":137,"line":145},[135,5729,781],{"class":159},[135,5731,784],{"class":148},[135,5733,787],{"class":159},[135,5735,790],{"class":155},[135,5737,793],{"class":159},[135,5739,5740],{"class":137,"line":163},[135,5741,344],{"emptyLinePlaceholder":343},[135,5743,5744],{"class":137,"line":179},[135,5745,5746],{"class":141},"    \u002F\u002F 创建 MCP 服务器\n",[135,5748,5749,5752,5754,5756,5758,5760,5763,5765,5768],{"class":137,"line":185},[135,5750,5751],{"class":159},"    mcpServer ",[135,5753,784],{"class":148},[135,5755,5124],{"class":159},[135,5757,5127],{"class":155},[135,5759,548],{"class":159},[135,5761,5762],{"class":169},"\"my-app\"",[135,5764,567],{"class":159},[135,5766,5767],{"class":169},"\"1.0.0\"",[135,5769,3347],{"class":159},[135,5771,5772,5775,5777],{"class":137,"line":366},[135,5773,5774],{"class":159},"        server.",[135,5776,5160],{"class":155},[135,5778,5163],{"class":159},[135,5780,5781,5783,5785],{"class":137,"line":376},[135,5782,5774],{"class":159},[135,5784,5170],{"class":155},[135,5786,5163],{"class":159},[135,5788,5789],{"class":137,"line":386},[135,5790,5791],{"class":159},"    )\n",[135,5793,5794],{"class":137,"line":392},[135,5795,344],{"emptyLinePlaceholder":343},[135,5797,5798],{"class":137,"line":397},[135,5799,5800],{"class":141},"    \u002F\u002F 注册工具\n",[135,5802,5803,5806],{"class":137,"line":403},[135,5804,5805],{"class":155},"    registerTools",[135,5807,5808],{"class":159},"(mcpServer)\n",[135,5810,5811],{"class":137,"line":420},[135,5812,344],{"emptyLinePlaceholder":343},[135,5814,5815],{"class":137,"line":425},[135,5816,5817],{"class":141},"    \u002F\u002F 挂载到 Gin\n",[135,5819,5820,5823,5825,5827,5829],{"class":137,"line":431},[135,5821,5822],{"class":159},"    mcpHTTP ",[135,5824,784],{"class":148},[135,5826,5124],{"class":159},[135,5828,5632],{"class":155},[135,5830,5635],{"class":159},[135,5832,5833,5835,5837,5839,5841],{"class":137,"line":471},[135,5834,5774],{"class":159},[135,5836,5642],{"class":155},[135,5838,548],{"class":159},[135,5840,5647],{"class":169},[135,5842,5187],{"class":159},[135,5844,5845,5847,5849,5851,5853,5855],{"class":137,"line":476},[135,5846,5774],{"class":159},[135,5848,5656],{"class":155},[135,5850,548],{"class":159},[135,5852,5661],{"class":208},[135,5854,506],{"class":148},[135,5856,5666],{"class":159},[135,5858,5859],{"class":137,"line":491},[135,5860,5791],{"class":159},[135,5862,5863,5865,5867,5869,5871,5873,5875],{"class":137,"line":500},[135,5864,818],{"class":159},[135,5866,5687],{"class":155},[135,5868,548],{"class":159},[135,5870,5647],{"class":169},[135,5872,5694],{"class":159},[135,5874,5697],{"class":155},[135,5876,5877],{"class":159},"(mcpHTTP))\n",[135,5879,5880],{"class":137,"line":515},[135,5881,344],{"emptyLinePlaceholder":343},[135,5883,5884],{"class":137,"line":520},[135,5885,5886],{"class":141},"    \u002F\u002F 其他业务路由\n",[135,5888,5889,5891,5893,5895,5898,5900,5902,5904,5906,5908,5910,5912,5914],{"class":137,"line":525},[135,5890,818],{"class":159},[135,5892,821],{"class":155},[135,5894,548],{"class":159},[135,5896,5897],{"class":169},"\"\u002Fapi\u002Fhealth\"",[135,5899,567],{"class":159},[135,5901,528],{"class":148},[135,5903,548],{"class":159},[135,5905,835],{"class":534},[135,5907,747],{"class":148},[135,5909,769],{"class":155},[135,5911,414],{"class":159},[135,5913,844],{"class":155},[135,5915,573],{"class":159},[135,5917,5918,5921,5924,5926,5929,5931,5933,5935,5938,5941,5944,5946,5949],{"class":137,"line":576},[135,5919,5920],{"class":159},"        c.",[135,5922,5923],{"class":155},"JSON",[135,5925,548],{"class":159},[135,5927,5928],{"class":208},"200",[135,5930,567],{"class":159},[135,5932,769],{"class":155},[135,5934,414],{"class":159},[135,5936,5937],{"class":155},"H",[135,5939,5940],{"class":159},"{",[135,5942,5943],{"class":169},"\"status\"",[135,5945,306],{"class":159},[135,5947,5948],{"class":169},"\"ok\"",[135,5950,188],{"class":159},[135,5952,5953],{"class":137,"line":593},[135,5954,1036],{"class":159},[135,5956,5957],{"class":137,"line":605},[135,5958,344],{"emptyLinePlaceholder":343},[135,5960,5961,5963,5966,5968,5971],{"class":137,"line":616},[135,5962,818],{"class":159},[135,5964,5965],{"class":155},"Run",[135,5967,548],{"class":159},[135,5969,5970],{"class":169},"\":8080\"",[135,5972,389],{"class":159},[135,5974,5975],{"class":137,"line":648},[135,5976,314],{"class":159},[15,5978,5979],{},"此时启动服务，用 curl 测试：",[126,5981,5983],{"className":1094,"code":5982,"language":1096,"meta":131,"style":131},"# 发现工具列表\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",[63,5984,5985,5990,6007,6017,6025,6029,6034,6046,6054],{"__ignoreMap":131},[135,5986,5987],{"class":137,"line":138},[135,5988,5989],{"class":141},"# 发现工具列表\n",[135,5991,5992,5995,5998,6001,6004],{"class":137,"line":145},[135,5993,5994],{"class":155},"curl",[135,5996,5997],{"class":208}," -X",[135,5999,6000],{"class":169}," POST",[135,6002,6003],{"class":169}," http:\u002F\u002Flocalhost:8080\u002Fmcp",[135,6005,6006],{"class":208}," \\\n",[135,6008,6009,6012,6015],{"class":137,"line":163},[135,6010,6011],{"class":208},"  -H",[135,6013,6014],{"class":169}," \"Content-Type: application\u002Fjson\"",[135,6016,6006],{"class":208},[135,6018,6019,6022],{"class":137,"line":179},[135,6020,6021],{"class":208},"  -d",[135,6023,6024],{"class":169}," '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools\u002Flist\"}'\n",[135,6026,6027],{"class":137,"line":185},[135,6028,344],{"emptyLinePlaceholder":343},[135,6030,6031],{"class":137,"line":366},[135,6032,6033],{"class":141},"# 调用工具\n",[135,6035,6036,6038,6040,6042,6044],{"class":137,"line":376},[135,6037,5994],{"class":155},[135,6039,5997],{"class":208},[135,6041,6000],{"class":169},[135,6043,6003],{"class":169},[135,6045,6006],{"class":208},[135,6047,6048,6050,6052],{"class":137,"line":386},[135,6049,6011],{"class":208},[135,6051,6014],{"class":169},[135,6053,6006],{"class":208},[135,6055,6056,6058],{"class":137,"line":392},[135,6057,6021],{"class":208},[135,6059,6060],{"class":169}," '{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools\u002Fcall\",\"params\":{\"name\":\"list_items\",\"arguments\":{}}}'\n",[11,6062,6064],{"id":6063},"拓展接入鉴权","拓展：接入鉴权",[15,6066,6067],{},"裸奔的 MCP 端点任何人都能调用，生产环境必须加鉴权。思路和普通 API 鉴权一致：Bearer Token。",[110,6069,4651],{"id":4651},[15,6071,6072,6073,6076,6077,6080],{},"MCP 的工具处理器签名为 ",[63,6074,6075],{},"func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error)","，它不接收 Gin 的 ",[63,6078,6079],{},"*gin.Context","，所以无法直接在工具处理器里读取请求头。",[110,6082,6084],{"id":6083},"解决方案中间件传递用户信息","解决方案：中间件传递用户信息",[15,6086,6087,6088,1311],{},"用两层中间件：第一层做 Gin 的 Token 验证，第二层把用户信息从 Gin Context 复制到 ",[63,6089,6090],{},"request.Context",[126,6092,6094],{"className":321,"code":6093,"language":323,"meta":131,"style":131},"\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",[63,6095,6096,6101,6129,6150,6169,6183,6215,6220,6224,6228,6252,6291,6320,6324,6328,6332,6352,6364,6393,6397,6401,6405,6420,6434,6443,6447],{"__ignoreMap":131},[135,6097,6098],{"class":137,"line":138},[135,6099,6100],{"class":141},"\u002F\u002F Gin 鉴权中间件\n",[135,6102,6103,6105,6108,6110,6113,6115,6118,6120,6122,6124,6127],{"class":137,"line":145},[135,6104,528],{"class":148},[135,6106,6107],{"class":155}," AuthRequired",[135,6109,548],{"class":159},[135,6111,6112],{"class":534},"authService",[135,6114,747],{"class":148},[135,6116,6117],{"class":155},"AuthService",[135,6119,542],{"class":159},[135,6121,769],{"class":155},[135,6123,414],{"class":159},[135,6125,6126],{"class":155},"HandlerFunc",[135,6128,488],{"class":159},[135,6130,6131,6133,6136,6138,6140,6142,6144,6146,6148],{"class":137,"line":163},[135,6132,657],{"class":148},[135,6134,6135],{"class":148}," func",[135,6137,548],{"class":159},[135,6139,835],{"class":534},[135,6141,747],{"class":148},[135,6143,769],{"class":155},[135,6145,414],{"class":159},[135,6147,844],{"class":155},[135,6149,573],{"class":159},[135,6151,6152,6155,6157,6159,6162,6164,6167],{"class":137,"line":179},[135,6153,6154],{"class":159},"        authHeader ",[135,6156,784],{"class":148},[135,6158,901],{"class":159},[135,6160,6161],{"class":155},"GetHeader",[135,6163,548],{"class":159},[135,6165,6166],{"class":169},"\"Authorization\"",[135,6168,389],{"class":159},[135,6170,6171,6173,6176,6178,6181],{"class":137,"line":185},[135,6172,916],{"class":148},[135,6174,6175],{"class":159}," authHeader ",[135,6177,585],{"class":148},[135,6179,6180],{"class":169}," \"\"",[135,6182,488],{"class":159},[135,6184,6185,6187,6190,6192,6195,6197,6199,6201,6203,6205,6208,6210,6213],{"class":137,"line":366},[135,6186,945],{"class":159},[135,6188,6189],{"class":155},"AbortWithStatusJSON",[135,6191,548],{"class":159},[135,6193,6194],{"class":208},"401",[135,6196,567],{"class":159},[135,6198,769],{"class":155},[135,6200,414],{"class":159},[135,6202,5937],{"class":155},[135,6204,5940],{"class":159},[135,6206,6207],{"class":169},"\"message\"",[135,6209,306],{"class":159},[135,6211,6212],{"class":169},"\"缺少认证令牌\"",[135,6214,188],{"class":159},[135,6216,6217],{"class":137,"line":376},[135,6218,6219],{"class":148},"            return\n",[135,6221,6222],{"class":137,"line":386},[135,6223,1010],{"class":159},[135,6225,6226],{"class":137,"line":392},[135,6227,344],{"emptyLinePlaceholder":343},[135,6229,6230,6233,6235,6237,6240,6243,6246,6248,6250],{"class":137,"line":397},[135,6231,6232],{"class":159},"        parts ",[135,6234,784],{"class":148},[135,6236,634],{"class":159},[135,6238,6239],{"class":155},"SplitN",[135,6241,6242],{"class":159},"(authHeader, ",[135,6244,6245],{"class":169},"\" \"",[135,6247,567],{"class":159},[135,6249,3869],{"class":208},[135,6251,389],{"class":159},[135,6253,6254,6256,6259,6262,6264,6267,6269,6272,6275,6278,6281,6283,6286,6289],{"class":137,"line":403},[135,6255,916],{"class":148},[135,6257,6258],{"class":155}," len",[135,6260,6261],{"class":159},"(parts) ",[135,6263,5532],{"class":148},[135,6265,6266],{"class":208}," 2",[135,6268,926],{"class":148},[135,6270,6271],{"class":148}," !",[135,6273,6274],{"class":159},"strings.",[135,6276,6277],{"class":155},"EqualFold",[135,6279,6280],{"class":159},"(parts[",[135,6282,1227],{"class":208},[135,6284,6285],{"class":159},"], ",[135,6287,6288],{"class":169},"\"Bearer\"",[135,6290,573],{"class":159},[135,6292,6293,6295,6297,6299,6301,6303,6305,6307,6309,6311,6313,6315,6318],{"class":137,"line":420},[135,6294,945],{"class":159},[135,6296,6189],{"class":155},[135,6298,548],{"class":159},[135,6300,6194],{"class":208},[135,6302,567],{"class":159},[135,6304,769],{"class":155},[135,6306,414],{"class":159},[135,6308,5937],{"class":155},[135,6310,5940],{"class":159},[135,6312,6207],{"class":169},[135,6314,306],{"class":159},[135,6316,6317],{"class":169},"\"格式错误，请使用 Bearer \u003Ctoken>\"",[135,6319,188],{"class":159},[135,6321,6322],{"class":137,"line":425},[135,6323,6219],{"class":148},[135,6325,6326],{"class":137,"line":431},[135,6327,1010],{"class":159},[135,6329,6330],{"class":137,"line":471},[135,6331,344],{"emptyLinePlaceholder":343},[135,6333,6334,6337,6339,6342,6345,6347,6349],{"class":137,"line":476},[135,6335,6336],{"class":159},"        user, err ",[135,6338,784],{"class":148},[135,6340,6341],{"class":159}," authService.",[135,6343,6344],{"class":155},"GetUserByToken",[135,6346,6280],{"class":159},[135,6348,1830],{"class":208},[135,6350,6351],{"class":159},"])\n",[135,6353,6354,6356,6358,6360,6362],{"class":137,"line":491},[135,6355,916],{"class":148},[135,6357,5529],{"class":159},[135,6359,5532],{"class":148},[135,6361,5535],{"class":208},[135,6363,488],{"class":159},[135,6365,6366,6368,6370,6372,6374,6376,6378,6380,6382,6384,6386,6388,6391],{"class":137,"line":500},[135,6367,945],{"class":159},[135,6369,6189],{"class":155},[135,6371,548],{"class":159},[135,6373,6194],{"class":208},[135,6375,567],{"class":159},[135,6377,769],{"class":155},[135,6379,414],{"class":159},[135,6381,5937],{"class":155},[135,6383,5940],{"class":159},[135,6385,6207],{"class":169},[135,6387,306],{"class":159},[135,6389,6390],{"class":169},"\"无效的认证令牌\"",[135,6392,188],{"class":159},[135,6394,6395],{"class":137,"line":515},[135,6396,6219],{"class":148},[135,6398,6399],{"class":137,"line":520},[135,6400,1010],{"class":159},[135,6402,6403],{"class":137,"line":525},[135,6404,344],{"emptyLinePlaceholder":343},[135,6406,6407,6409,6412,6414,6417],{"class":137,"line":576},[135,6408,5920],{"class":159},[135,6410,6411],{"class":155},"Set",[135,6413,548],{"class":159},[135,6415,6416],{"class":169},"\"userID\"",[135,6418,6419],{"class":159},", user.ID)\n",[135,6421,6422,6424,6426,6428,6431],{"class":137,"line":593},[135,6423,5920],{"class":159},[135,6425,6411],{"class":155},[135,6427,548],{"class":159},[135,6429,6430],{"class":169},"\"username\"",[135,6432,6433],{"class":159},", user.Username)\n",[135,6435,6436,6438,6441],{"class":137,"line":605},[135,6437,5920],{"class":159},[135,6439,6440],{"class":155},"Next",[135,6442,793],{"class":159},[135,6444,6445],{"class":137,"line":616},[135,6446,651],{"class":159},[135,6448,6449],{"class":137,"line":648},[135,6450,314],{"class":159},[126,6452,6454],{"className":321,"code":6453,"language":323,"meta":131,"style":131},"\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",[63,6455,6456,6461,6471,6477,6490,6502,6506,6510,6528,6548,6562,6586,6608,6623,6631,6635],{"__ignoreMap":131},[135,6457,6458],{"class":137,"line":138},[135,6459,6460],{"class":141},"\u002F\u002F 用户信息桥接中间件：Gin Context → request.Context\n",[135,6462,6463,6465,6468],{"class":137,"line":145},[135,6464,479],{"class":148},[135,6466,6467],{"class":155}," ctxKey",[135,6469,6470],{"class":148}," string\n",[135,6472,6473,6475],{"class":137,"line":163},[135,6474,3916],{"class":148},[135,6476,352],{"class":159},[135,6478,6479,6482,6485,6487],{"class":137,"line":179},[135,6480,6481],{"class":208},"    ctxUserID",[135,6483,6484],{"class":155},"   ctxKey",[135,6486,447],{"class":148},[135,6488,6489],{"class":169}," \"userID\"\n",[135,6491,6492,6495,6497,6499],{"class":137,"line":185},[135,6493,6494],{"class":208},"    ctxUsername",[135,6496,6467],{"class":155},[135,6498,447],{"class":148},[135,6500,6501],{"class":169}," \"username\"\n",[135,6503,6504],{"class":137,"line":366},[135,6505,389],{"class":159},[135,6507,6508],{"class":137,"line":376},[135,6509,344],{"emptyLinePlaceholder":343},[135,6511,6512,6514,6517,6520,6522,6524,6526],{"class":137,"line":386},[135,6513,528],{"class":148},[135,6515,6516],{"class":155}," WithUserContext",[135,6518,6519],{"class":159},"() ",[135,6521,769],{"class":155},[135,6523,414],{"class":159},[135,6525,6126],{"class":155},[135,6527,488],{"class":159},[135,6529,6530,6532,6534,6536,6538,6540,6542,6544,6546],{"class":137,"line":392},[135,6531,657],{"class":148},[135,6533,6135],{"class":148},[135,6535,548],{"class":159},[135,6537,835],{"class":534},[135,6539,747],{"class":148},[135,6541,769],{"class":155},[135,6543,414],{"class":159},[135,6545,844],{"class":155},[135,6547,573],{"class":159},[135,6549,6550,6553,6555,6558,6560],{"class":137,"line":397},[135,6551,6552],{"class":159},"        ctx ",[135,6554,784],{"class":148},[135,6556,6557],{"class":159}," c.Request.",[135,6559,844],{"class":155},[135,6561,793],{"class":159},[135,6563,6564,6566,6568,6571,6574,6577,6580,6582,6584],{"class":137,"line":403},[135,6565,6552],{"class":159},[135,6567,599],{"class":148},[135,6569,6570],{"class":159}," context.",[135,6572,6573],{"class":155},"WithValue",[135,6575,6576],{"class":159},"(ctx, ctxUserID, c.",[135,6578,6579],{"class":155},"GetUint",[135,6581,548],{"class":159},[135,6583,6416],{"class":169},[135,6585,3846],{"class":159},[135,6587,6588,6590,6592,6594,6596,6599,6602,6604,6606],{"class":137,"line":420},[135,6589,6552],{"class":159},[135,6591,599],{"class":148},[135,6593,6570],{"class":159},[135,6595,6573],{"class":155},[135,6597,6598],{"class":159},"(ctx, ctxUsername, c.",[135,6600,6601],{"class":155},"GetString",[135,6603,548],{"class":159},[135,6605,6430],{"class":169},[135,6607,3846],{"class":159},[135,6609,6610,6613,6615,6617,6620],{"class":137,"line":425},[135,6611,6612],{"class":159},"        c.Request ",[135,6614,599],{"class":148},[135,6616,6557],{"class":159},[135,6618,6619],{"class":155},"WithContext",[135,6621,6622],{"class":159},"(ctx)\n",[135,6624,6625,6627,6629],{"class":137,"line":431},[135,6626,5920],{"class":159},[135,6628,6440],{"class":155},[135,6630,793],{"class":159},[135,6632,6633],{"class":137,"line":471},[135,6634,651],{"class":159},[135,6636,6637],{"class":137,"line":476},[135,6638,314],{"class":159},[110,6640,6641],{"id":6641},"组合使用",[126,6643,6645],{"className":321,"code":6644,"language":323,"meta":131,"style":131},"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",[63,6646,6647,6659,6670,6681,6694],{"__ignoreMap":131},[135,6648,6649,6651,6653,6655,6657],{"class":137,"line":138},[135,6650,5684],{"class":159},[135,6652,5687],{"class":155},[135,6654,548],{"class":159},[135,6656,5647],{"class":169},[135,6658,3347],{"class":159},[135,6660,6661,6664,6667],{"class":137,"line":145},[135,6662,6663],{"class":155},"    AuthRequired",[135,6665,6666],{"class":159},"(authService),  ",[135,6668,6669],{"class":141},"\u002F\u002F 1. 验证 Token\n",[135,6671,6672,6675,6678],{"class":137,"line":163},[135,6673,6674],{"class":155},"    WithUserContext",[135,6676,6677],{"class":159},"(),           ",[135,6679,6680],{"class":141},"\u002F\u002F 2. 桥接用户信息到 request.Context\n",[135,6682,6683,6686,6688,6691],{"class":137,"line":179},[135,6684,6685],{"class":159},"    gin.",[135,6687,5697],{"class":155},[135,6689,6690],{"class":159},"(mcpHTTP),         ",[135,6692,6693],{"class":141},"\u002F\u002F 3. MCP 处理\n",[135,6695,6696],{"class":137,"line":185},[135,6697,389],{"class":159},[15,6699,6700],{},"在工具处理器里就能拿到用户信息了：",[126,6702,6704],{"className":321,"code":6703,"language":323,"meta":131,"style":131},"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",[63,6705,6706,6761,6782,6791,6808,6812,6816,6832,6837,6850],{"__ignoreMap":131},[135,6707,6708,6710,6712,6715,6717,6720,6722,6725,6727,6729,6731,6733,6735,6737,6739,6741,6743,6745,6747,6749,6751,6753,6755,6757,6759],{"class":137,"line":138},[135,6709,528],{"class":148},[135,6711,531],{"class":159},[135,6713,6714],{"class":534},"h ",[135,6716,506],{"class":148},[135,6718,6719],{"class":155},"Handler",[135,6721,542],{"class":159},[135,6723,6724],{"class":155},"handleListItems",[135,6726,548],{"class":159},[135,6728,5299],{"class":534},[135,6730,5302],{"class":155},[135,6732,414],{"class":159},[135,6734,844],{"class":155},[135,6736,567],{"class":159},[135,6738,5311],{"class":534},[135,6740,5314],{"class":155},[135,6742,414],{"class":159},[135,6744,5319],{"class":155},[135,6746,557],{"class":159},[135,6748,506],{"class":148},[135,6750,5326],{"class":155},[135,6752,414],{"class":159},[135,6754,5331],{"class":155},[135,6756,567],{"class":159},[135,6758,570],{"class":148},[135,6760,573],{"class":159},[135,6762,6763,6766,6768,6771,6774,6777,6780],{"class":137,"line":145},[135,6764,6765],{"class":159},"    userID, ok ",[135,6767,784],{"class":148},[135,6769,6770],{"class":159}," ctx.",[135,6772,6773],{"class":155},"Value",[135,6775,6776],{"class":159},"(ctxUserID).(",[135,6778,6779],{"class":148},"uint",[135,6781,389],{"class":159},[135,6783,6784,6786,6788],{"class":137,"line":163},[135,6785,579],{"class":148},[135,6787,6271],{"class":148},[135,6789,6790],{"class":159},"ok {\n",[135,6792,6793,6795,6797,6799,6801,6804,6806],{"class":137,"line":179},[135,6794,5542],{"class":148},[135,6796,5243],{"class":159},[135,6798,5547],{"class":155},[135,6800,548],{"class":159},[135,6802,6803],{"class":169},"\"未认证\"",[135,6805,5359],{"class":159},[135,6807,5362],{"class":208},[135,6809,6810],{"class":137,"line":185},[135,6811,651],{"class":159},[135,6813,6814],{"class":137,"line":366},[135,6815,344],{"emptyLinePlaceholder":343},[135,6817,6818,6821,6823,6826,6829],{"class":137,"line":376},[135,6819,6820],{"class":159},"    items, _ ",[135,6822,784],{"class":148},[135,6824,6825],{"class":159}," h.service.",[135,6827,6828],{"class":155},"ListByUser",[135,6830,6831],{"class":159},"(userID)\n",[135,6833,6834],{"class":137,"line":386},[135,6835,6836],{"class":141},"    \u002F\u002F ...\n",[135,6838,6839,6841,6843,6845,6848],{"class":137,"line":392},[135,6840,657],{"class":148},[135,6842,5243],{"class":159},[135,6844,5351],{"class":155},[135,6846,6847],{"class":159},"(result), ",[135,6849,5362],{"class":208},[135,6851,6852],{"class":137,"line":397},[135,6853,314],{"class":159},[110,6855,6856],{"id":6856},"客户端配置",[15,6858,6859],{},"AI 客户端（如 Claude Desktop）配置 MCP 服务器时带上 Authorization 头：",[126,6861,6863],{"className":281,"code":6862,"language":283,"meta":131,"style":131},"{\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",[63,6864,6865,6870,6877,6883,6895,6902,6912,6917,6921,6925],{"__ignoreMap":131},[135,6866,6867],{"class":137,"line":138},[135,6868,6869],{"class":159},"{\n",[135,6871,6872,6875],{"class":137,"line":145},[135,6873,6874],{"class":208},"  \"mcpServers\"",[135,6876,298],{"class":159},[135,6878,6879,6881],{"class":137,"line":163},[135,6880,5135],{"class":208},[135,6882,298],{"class":159},[135,6884,6885,6888,6890,6893],{"class":137,"line":179},[135,6886,6887],{"class":208},"      \"url\"",[135,6889,306],{"class":159},[135,6891,6892],{"class":169},"\"http:\u002F\u002Flocalhost:8080\u002Fmcp\"",[135,6894,3347],{"class":159},[135,6896,6897,6900],{"class":137,"line":185},[135,6898,6899],{"class":208},"      \"headers\"",[135,6901,298],{"class":159},[135,6903,6904,6907,6909],{"class":137,"line":366},[135,6905,6906],{"class":208},"        \"Authorization\"",[135,6908,306],{"class":159},[135,6910,6911],{"class":169},"\"Bearer your-token-here\"\n",[135,6913,6914],{"class":137,"line":376},[135,6915,6916],{"class":159},"      }\n",[135,6918,6919],{"class":137,"line":386},[135,6920,651],{"class":159},[135,6922,6923],{"class":137,"line":392},[135,6924,3655],{"class":159},[135,6926,6927],{"class":137,"line":397},[135,6928,314],{"class":159},[11,6930,1626],{"id":1626},[3023,6932,6933,6943],{},[3026,6934,6935],{},[3029,6936,6937,6940],{},[3032,6938,6939],{},"步骤",[3032,6941,6942],{},"说明",[3045,6944,6945,6954,6965,6974,6983],{},[3029,6946,6947,6951],{},[3050,6948,6949],{},[63,6950,5127],{},[3050,6952,6953],{},"创建 MCP 协议实例",[3029,6955,6956,6962],{},[3050,6957,6958,2867,6960],{},[63,6959,5246],{},[63,6961,5289],{},[3050,6963,6964],{},"定义并注册工具",[3029,6966,6967,6971],{},[3050,6968,6969],{},[63,6970,5632],{},[3050,6972,6973],{},"转为 HTTP Handler",[3029,6975,6976,6980],{},[3050,6977,6978],{},[63,6979,5600],{},[3050,6981,6982],{},"挂载到 Gin 路由",[3029,6984,6985,6993],{},[3050,6986,6987,2867,6990],{},[63,6988,6989],{},"AuthRequired",[63,6991,6992],{},"WithUserContext",[3050,6994,6995],{},"鉴权 + 用户信息桥接",[15,6997,6998,6999,7002,7003,1311],{},"核心就一句话：",[25,7000,7001],{},"MCP 是 HTTP Handler，挂上去就行。"," 鉴权复用你已有的中间件体系，只需要多写一个桥接函数把用户信息传进 ",[63,7004,6090],{},[1665,7006,7007],{},"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":131,"searchDepth":145,"depth":163,"links":7009},[7010,7011,7012,7013,7020,7026],{"id":5009,"depth":145,"text":5010},{"id":5019,"depth":145,"text":5020},{"id":5036,"depth":145,"text":5036},{"id":5062,"depth":145,"text":5062,"children":7014},[7015,7016,7017,7018,7019],{"id":5065,"depth":163,"text":5066},{"id":5093,"depth":163,"text":5094},{"id":5207,"depth":163,"text":5208},{"id":5589,"depth":163,"text":5590},{"id":5710,"depth":163,"text":5711},{"id":6063,"depth":145,"text":6064,"children":7021},[7022,7023,7024,7025],{"id":4651,"depth":163,"text":4651},{"id":6083,"depth":163,"text":6084},{"id":6641,"depth":163,"text":6641},{"id":6856,"depth":163,"text":6856},{"id":1626,"depth":145,"text":1626},"介绍如何使用 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":5004,"description":7027},"posts\u002Fgo-mcp-gin\u002Findex",[1701,7035,7036,7037,7038],"MCP","Gin","AI Agent","后端","2026-06-01","5fZl1Vigs2lHb5fHPz-0YZFyFD38ws1heOouZUG1g6M",{"id":7042,"title":7043,"author":6,"body":7044,"description":7808,"difficulty":6,"draft":1691,"extension":1692,"image":7809,"meta":7810,"navigation":343,"path":7811,"pinned":1691,"published":7812,"seo":7813,"series":6,"seriesOrder":6,"stem":7814,"tags":7815,"updated":6,"__hash__":7819},"posts\u002Fposts\u002Fdws-dingtalk-cli\u002Findex.md","DWS — 钉钉官方出品的 AI 友好命令行工具",{"type":8,"value":7045,"toc":7794},[7046,7050,7053,7060,7186,7189,7192,7247,7250,7265,7268,7271,7278,7510,7517,7521,7524,7544,7547,7562,7565,7584,7587,7590,7597,7663,7666,7669,7673,7676,7727,7731,7752,7756,7759,7773,7776,7780,7783,7785,7788,7791],[11,7047,7049],{"id":7048},"dws-是什么","dws 是什么",[15,7051,7052],{},"dws（DingTalk Workspace CLI）是阿里巴巴 2026 年开源的钉钉命令行工具，Apache-2.0 协议，Go 语言编写。",[15,7054,7055,7056,7059],{},"它的定位很明确：",[25,7057,7058],{},"把钉钉的所有产品能力暴露给 AI Agent 和命令行用户","。不用打开钉钉 App，不用写 API 调用代码，一条命令搞定。",[126,7061,7063],{"className":1094,"code":7062,"language":1096,"meta":131,"style":131},"# 查看今天有什么会\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",[63,7064,7065,7070,7096,7100,7105,7127,7131,7136,7161,7165,7170],{"__ignoreMap":131},[135,7066,7067],{"class":137,"line":138},[135,7068,7069],{"class":141},"# 查看今天有什么会\n",[135,7071,7072,7075,7078,7081,7084,7087,7090,7093],{"class":137,"line":145},[135,7073,7074],{"class":155},"dws",[135,7076,7077],{"class":169}," calendar",[135,7079,7080],{"class":169}," event",[135,7082,7083],{"class":169}," list",[135,7085,7086],{"class":208}," --start",[135,7088,7089],{"class":169}," 2026-06-01",[135,7091,7092],{"class":208}," --end",[135,7094,7095],{"class":169}," 2026-06-01\n",[135,7097,7098],{"class":137,"line":163},[135,7099,344],{"emptyLinePlaceholder":343},[135,7101,7102],{"class":137,"line":179},[135,7103,7104],{"class":141},"# 给群里发条消息\n",[135,7106,7107,7109,7112,7115,7118,7121,7124],{"class":137,"line":185},[135,7108,7074],{"class":155},[135,7110,7111],{"class":169}," chat",[135,7113,7114],{"class":169}," send",[135,7116,7117],{"class":208}," --group-id",[135,7119,7120],{"class":169}," xxx",[135,7122,7123],{"class":208}," --content",[135,7125,7126],{"class":169}," \"今天发布推迟到下午3点\"\n",[135,7128,7129],{"class":137,"line":366},[135,7130,344],{"emptyLinePlaceholder":343},[135,7132,7133],{"class":137,"line":376},[135,7134,7135],{"class":141},"# 创建一个待办\n",[135,7137,7138,7140,7143,7146,7149,7152,7155,7158],{"class":137,"line":386},[135,7139,7074],{"class":155},[135,7141,7142],{"class":169}," todo",[135,7144,7145],{"class":169}," task",[135,7147,7148],{"class":169}," create",[135,7150,7151],{"class":208}," --subject",[135,7153,7154],{"class":169}," \"Review PR #123\"",[135,7156,7157],{"class":208}," --due-date",[135,7159,7160],{"class":169}," 2026-06-02\n",[135,7162,7163],{"class":137,"line":392},[135,7164,344],{"emptyLinePlaceholder":343},[135,7166,7167],{"class":137,"line":397},[135,7168,7169],{"class":141},"# 读取钉钉文档\n",[135,7171,7172,7174,7177,7180,7183],{"class":137,"line":403},[135,7173,7074],{"class":155},[135,7175,7176],{"class":169}," doc",[135,7178,7179],{"class":169}," read",[135,7181,7182],{"class":208}," --url",[135,7184,7185],{"class":169}," \"https:\u002F\u002Falidocs.dingtalk.com\u002Fi\u002Fnodes\u002Fxxx\"\n",[11,7187,7188],{"id":7188},"安装",[15,7190,7191],{},"三种方式，选你喜欢的：",[126,7193,7195],{"className":1094,"code":7194,"language":1096,"meta":131,"style":131},"# 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",[63,7196,7197,7202,7214,7218,7223,7233,7237,7242],{"__ignoreMap":131},[135,7198,7199],{"class":137,"line":138},[135,7200,7201],{"class":141},"# npm（推荐，跨平台自动下载二进制）\n",[135,7203,7204,7206,7208,7211],{"class":137,"line":145},[135,7205,1191],{"class":155},[135,7207,1791],{"class":169},[135,7209,7210],{"class":208}," -g",[135,7212,7213],{"class":169}," dingtalk-workspace-cli\n",[135,7215,7216],{"class":137,"line":163},[135,7217,344],{"emptyLinePlaceholder":343},[135,7219,7220],{"class":137,"line":179},[135,7221,7222],{"class":141},"# Homebrew（macOS\u002FLinux）\n",[135,7224,7225,7228,7230],{"class":137,"line":185},[135,7226,7227],{"class":155},"brew",[135,7229,1791],{"class":169},[135,7231,7232],{"class":169}," dws\n",[135,7234,7235],{"class":137,"line":366},[135,7236,344],{"emptyLinePlaceholder":343},[135,7238,7239],{"class":137,"line":376},[135,7240,7241],{"class":141},"# 直接下载二进制\n",[135,7243,7244],{"class":137,"line":386},[135,7245,7246],{"class":141},"# https:\u002F\u002Fgithub.com\u002Fopen-dingtalk\u002Fdingtalk-workspace-cli\u002Freleases\n",[15,7248,7249],{},"安装后认证：",[126,7251,7253],{"className":1094,"code":7252,"language":1096,"meta":131,"style":131},"dws auth login\n",[63,7254,7255],{"__ignoreMap":131},[135,7256,7257,7259,7262],{"class":137,"line":138},[135,7258,7074],{"class":155},[135,7260,7261],{"class":169}," auth",[135,7263,7264],{"class":169}," login\n",[15,7266,7267],{},"会打开浏览器走 OAuth 设备流认证，token 加密存储在系统钥匙串里（PBKDF2 + AES-256-GCM）。",[11,7269,7270],{"id":7270},"覆盖的钉钉产品",[15,7272,7273,7274,7277],{},"这是最夸张的部分 — ",[25,7275,7276],{},"19 个产品，213 个命令","，基本把钉钉全家桶都搬到了命令行：",[3023,7279,7280,7292],{},[3026,7281,7282],{},[3029,7283,7284,7287,7290],{},[3032,7285,7286],{},"产品",[3032,7288,7289],{},"命令数",[3032,7291,6942],{},[3045,7293,7294,7308,7322,7336,7350,7364,7378,7392,7405,7419,7432,7446,7460,7473,7486,7499],{},[3029,7295,7296,7302,7305],{},[3050,7297,7298,7299],{},"聊天 ",[63,7300,7301],{},"chat",[3050,7303,7304],{},"57",[3050,7306,7307],{},"消息收发、群管理、机器人、已读状态、Webhook",[3029,7309,7310,7316,7319],{},[3050,7311,7312,7313],{},"AI 表格 ",[63,7314,7315],{},"aitable",[3050,7317,7318],{},"42",[3050,7320,7321],{},"Base\u002F数据表\u002F字段\u002F记录\u002F视图\u002F图表\u002F仪表盘，完整 CRUD",[3029,7323,7324,7330,7333],{},[3050,7325,7326,7327],{},"电子表格 ",[63,7328,7329],{},"sheet",[3050,7331,7332],{},"34",[3050,7334,7335],{},"工作表读写、行列操作、合并、筛选视图、导出",[3029,7337,7338,7344,7347],{},[3050,7339,7340,7341],{},"文档 ",[63,7342,7343],{},"doc",[3050,7345,7346],{},"21",[3050,7348,7349],{},"搜索、读写、块级编辑、评论、复制移动",[3029,7351,7352,7358,7361],{},[3050,7353,7354,7355],{},"会议纪要 ",[63,7356,7357],{},"minutes",[3050,7359,7360],{},"19",[3050,7362,7363],{},"列表、摘要、转写、思维导图、发言人、热词",[3029,7365,7366,7372,7375],{},[3050,7367,7368,7369],{},"日历 ",[63,7370,7371],{},"calendar",[3050,7373,7374],{},"14",[3050,7376,7377],{},"事件 CRUD、会议室、忙闲查询、时间建议",[3029,7379,7380,7386,7389],{},[3050,7381,7382,7383],{},"审批 ",[63,7384,7385],{},"oa",[3050,7387,7388],{},"9",[3050,7390,7391],{},"待办、发起、同意\u002F拒绝\u002F撤销",[3029,7393,7394,7400,7402],{},[3050,7395,7396,7397],{},"云盘 ",[63,7398,7399],{},"drive",[3050,7401,7388],{},[3050,7403,7404],{},"列表、下载、上传、创建目录",[3029,7406,7407,7413,7416],{},[3050,7408,7409,7410],{},"知识库 ",[63,7411,7412],{},"wiki",[3050,7414,7415],{},"7",[3050,7417,7418],{},"空间管理、成员管理",[3029,7420,7421,7427,7429],{},[3050,7422,7423,7424],{},"汇报 ",[63,7425,7426],{},"report",[3050,7428,7415],{},[3050,7430,7431],{},"日志\u002F日报\u002F周报，按模板创建、统计",[3029,7433,7434,7440,7443],{},[3050,7435,7436,7437],{},"待办 ",[63,7438,7439],{},"todo",[3050,7441,7442],{},"6",[3050,7444,7445],{},"创建、完成、删除，支持优先级和循环",[3029,7447,7448,7454,7457],{},[3050,7449,7450,7451],{},"邮件 ",[63,7452,7453],{},"mail",[3050,7455,7456],{},"4",[3050,7458,7459],{},"邮箱查询、邮件搜索、发送",[3029,7461,7462,7468,7470],{},[3050,7463,7464,7465],{},"考勤 ",[63,7466,7467],{},"attendance",[3050,7469,7456],{},[3050,7471,7472],{},"打卡记录、排班、汇总",[3029,7474,7475,7481,7483],{},[3050,7476,7477,7478],{},"通讯录 ",[63,7479,7480],{},"contact",[3050,7482,3640],{},[3050,7484,7485],{},"用户搜索、部门查询",[3029,7487,7488,7494,7496],{},[3050,7489,7490,7491],{},"DING 消息 ",[63,7492,7493],{},"ding",[3050,7495,3869],{},[3050,7497,7498],{},"发送\u002F撤回（应用内、短信、电话）",[3029,7500,7501,7504,7507],{},[3050,7502,7503],{},"其他",[3050,7505,7506],{},"若干",[3050,7508,7509],{},"AI 搜索、AI 应用、直播、开放平台文档",[15,7511,7512,7513,7516],{},"还有一个万能命令 ",[63,7514,7515],{},"dws api","，可以直接调用任意钉钉 OpenAPI，不受这 213 个命令的限制。",[11,7518,7520],{"id":7519},"agent-skill-系统","Agent Skill 系统",[15,7522,7523],{},"dws 内置了一套完整的 Agent Skill，可以直接被 AI 编程工具发现和使用：",[30,7525,7526,7532,7538],{},[33,7527,7528,7531],{},[25,7529,7530],{},"Claude Code"," — 通过 SKILL.md 自动加载",[33,7533,7534,7537],{},[25,7535,7536],{},"Cursor"," — 支持 MCP 协议接入",[33,7539,7540,7543],{},[25,7541,7542],{},"Codex \u002F OpenCode \u002F Qoder"," — 通过 skill 安装命令接入",[15,7545,7546],{},"安装 skill：",[126,7548,7550],{"className":1094,"code":7549,"language":1096,"meta":131,"style":131},"dws skill setup\n",[63,7551,7552],{"__ignoreMap":131},[135,7553,7554,7556,7559],{"class":137,"line":138},[135,7555,7074],{"class":155},[135,7557,7558],{"class":169}," skill",[135,7560,7561],{"class":169}," setup\n",[15,7563,7564],{},"SKILL.md 里定义了：",[30,7566,7567,7575,7578,7581],{},[33,7568,7569,7570,7572,7573,42],{},"16 个产品的意图判断决策树（用户说\"表格\"→ ",[63,7571,7315],{},"，说\"打卡\"→ ",[63,7574,7467],{},[33,7576,7577],{},"危险操作确认流程（删除、撤回等必须先确认）",[33,7579,7580],{},"错误处理规范（verbose 重试、recovery 事件 ID）",[33,7582,7583],{},"URL 分流规则（alidocs.dingtalk.com 的多种路径格式自动识别）",[15,7585,7586],{},"Agent 读到这份 SKILL.md 就知道什么时候该调什么命令，不需要额外的 prompt 工程。",[11,7588,7589],{"id":7589},"自动升级",[15,7591,7592,7593,7596],{},"内置 ",[63,7594,7595],{},"dws upgrade","，不需要重新下载安装包：",[126,7598,7600],{"className":1094,"code":7599,"language":1096,"meta":131,"style":131},"dws upgrade              # 交互式升级到最新版\ndws upgrade --check      # 仅检查是否有新版本\ndws upgrade --list       # 列出最近版本\ndws upgrade --version v1.0.5  # 升级到指定版本\ndws upgrade --rollback   # 回滚到上一版本\n",[63,7601,7602,7612,7624,7636,7651],{"__ignoreMap":131},[135,7603,7604,7606,7609],{"class":137,"line":138},[135,7605,7074],{"class":155},[135,7607,7608],{"class":169}," upgrade",[135,7610,7611],{"class":141},"              # 交互式升级到最新版\n",[135,7613,7614,7616,7618,7621],{"class":137,"line":145},[135,7615,7074],{"class":155},[135,7617,7608],{"class":169},[135,7619,7620],{"class":208}," --check",[135,7622,7623],{"class":141},"      # 仅检查是否有新版本\n",[135,7625,7626,7628,7630,7633],{"class":137,"line":163},[135,7627,7074],{"class":155},[135,7629,7608],{"class":169},[135,7631,7632],{"class":208}," --list",[135,7634,7635],{"class":141},"       # 列出最近版本\n",[135,7637,7638,7640,7642,7645,7648],{"class":137,"line":179},[135,7639,7074],{"class":155},[135,7641,7608],{"class":169},[135,7643,7644],{"class":208}," --version",[135,7646,7647],{"class":169}," v1.0.5",[135,7649,7650],{"class":141},"  # 升级到指定版本\n",[135,7652,7653,7655,7657,7660],{"class":137,"line":185},[135,7654,7074],{"class":155},[135,7656,7608],{"class":169},[135,7658,7659],{"class":208}," --rollback",[135,7661,7662],{"class":141},"   # 回滚到上一版本\n",[15,7664,7665],{},"升级流程：下载 → SHA256 校验 → 备份当前版本 → 原子替换 → 更新 Skill。失败可一键回滚。",[11,7667,7668],{"id":7668},"亮点",[110,7670,7672],{"id":7671},"_1-为-ai-agent-专门优化","1. 为 AI Agent 专门优化",[15,7674,7675],{},"这可能是目前对 AI 最友好的 CLI 工具：",[30,7677,7678,7698,7708,7717],{},[33,7679,7680,7683,7684,7687,7688,1423,7691,7687,7694,7697],{},[25,7681,7682],{},"智能纠错"," — AI 模型经常生成错误的参数格式（",[63,7685,7686],{},"--userId"," 应该是 ",[63,7689,7690],{},"--user-id",[63,7692,7693],{},"--limit100",[63,7695,7696],{},"--limit 100","），dws 的 5 阶段 Pipeline 会自动修正",[33,7699,7700,7703,7704,7707],{},[25,7701,7702],{},"Schema 发现"," — ",[63,7705,7706],{},"dws schema"," 让 Agent 动态查询可用工具和参数，不需要硬编码",[33,7709,7710,7703,7713,7716],{},[25,7711,7712],{},"jq 过滤",[63,7714,7715],{},"--jq '.data[] | {name, id}'"," 直接过滤输出，减少 Agent 消耗的 token",[33,7718,7719,7722,7723,7726],{},[25,7720,7721],{},"JSON 输出"," — 所有命令支持 ",[63,7724,7725],{},"--format json","，方便程序解析",[110,7728,7730],{"id":7729},"_2-安全设计","2. 安全设计",[30,7732,7733,7736,7739,7742,7749],{},[33,7734,7735],{},"token 用 PBKDF2 + AES-256-GCM 加密，密钥绑定设备 MAC 地址",[33,7737,7738],{},"域名白名单，只能访问钉钉官方 API",[33,7740,7741],{},"强制 HTTPS",[33,7743,7744,7745,7748],{},"写操作必须显式 ",[63,7746,7747],{},"--yes"," 确认",[33,7750,7751],{},"完整审计链",[110,7753,7755],{"id":7754},"_3-发现驱动架构","3. 发现驱动架构",[15,7757,7758],{},"不是把所有命令硬编码在代码里，而是：",[54,7760,7761,7764,7767,7770],{},[33,7762,7763],{},"从钉钉 MCP 服务器注册中心拉取元数据",[33,7765,7766],{},"运行时发现服务端能力",[33,7768,7769],{},"转换成统一的中间表示（IR）",[33,7771,7772],{},"动态挂载到命令树",[15,7774,7775],{},"这意味着钉钉新增产品能力时，dws 不需要发版就能支持。",[110,7777,7779],{"id":7778},"_4-多平台分发","4. 多平台分发",[15,7781,7782],{},"GoReleaser 交叉编译 + Homebrew formula + npm 包 + shell\u002FPowerShell 安装脚本，覆盖 Windows、macOS、Linux。",[11,7784,1626],{"id":1626},[15,7786,7787],{},"dws 把钉钉从一个\"必须打开 App 才能用\"的工具变成了一个\"命令行 + AI Agent 随时调用\"的平台。213 个命令覆盖日常办公的方方面面，Agent Skill 系统让 AI 编程工具可以直接理解和使用这些能力。",[15,7789,7790],{},"如果你是钉钉重度用户，或者在做企业内部的 AI Agent 集成，dws 值得一试。",[1665,7792,7793],{},"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":131,"searchDepth":145,"depth":163,"links":7795},[7796,7797,7798,7799,7800,7801,7807],{"id":7048,"depth":145,"text":7049},{"id":7188,"depth":145,"text":7188},{"id":7270,"depth":145,"text":7270},{"id":7519,"depth":145,"text":7520},{"id":7589,"depth":145,"text":7589},{"id":7668,"depth":145,"text":7668,"children":7802},[7803,7804,7805,7806],{"id":7671,"depth":163,"text":7672},{"id":7729,"depth":163,"text":7730},{"id":7754,"depth":163,"text":7755},{"id":7778,"depth":163,"text":7779},{"id":1626,"depth":145,"text":1626},"介绍阿里巴巴官方的 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":7043,"description":7808},"posts\u002Fdws-dingtalk-cli\u002Findex",[7816,7817,7037,1701,7818],"钉钉","CLI","效率工具","4ca65VUZ2Sz9yG8dNtftrBDDHE6QowA3ed9g4egQkKI",{"id":7821,"title":7822,"author":6,"body":7823,"description":8755,"difficulty":1690,"draft":1691,"extension":1692,"image":8756,"meta":8757,"navigation":343,"path":21,"pinned":1691,"published":8758,"seo":8759,"series":1698,"seriesOrder":145,"stem":8760,"tags":8761,"updated":8765,"__hash__":8766},"posts\u002Fposts\u002Fgo-embed-frontend\u002Findex.md","Go 单文件打包实战：把前端塞进二进制",{"type":8,"value":7824,"toc":8738},[7825,7827,7834,7837,7839,7848,7850,7854,8174,8178,8286,8290,8422,8424,8428,8437,8453,8462,8480,8494,8500,8508,8603,8609,8613,8619,8628,8630,8665,8668,8673,8712,8714,8720,8732,8735],[11,7826,13],{"id":13},[15,7828,7829,7830,7833],{},"一个 Gin + Vue 3 的全栈项目，之前的发布方式是：一个 Go 二进制 + 一个 ",[63,7831,7832],{},"web\u002F"," 前端静态目录，必须放一起才能运行。",[15,7835,7836],{},"为了让部署更丝滑——一个 exe 拖哪都能跑——决定把前端构建产物嵌入到 Go 二进制里。",[11,7838,94],{"id":94},[15,7840,7841,7842,7844,7845,7847],{},"利用 Go 1.16 引入的 ",[63,7843,100],{}," 指令，在编译时将前端 ",[63,7846,104],{}," 目录嵌入二进制，运行时通过嵌入的文件系统提供 SPA 服务。",[11,7849,108],{"id":108},[110,7851,7853],{"id":7852},"_1-创建嵌入模块","1. 创建嵌入模块",[126,7855,7857],{"className":321,"code":7856,"language":323,"meta":131,"style":131},"\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",[63,7858,7859,7864,7870,7874,7880,7888,7897,7906,7914,7923,7931,7935,7944,7948,7952,7957,7970,7974,7991,8011,8015,8035,8052,8064,8074,8078,8082,8097,8109,8114,8133,8137,8141,8164,8169],{"__ignoreMap":131},[135,7860,7861],{"class":137,"line":138},[135,7862,7863],{"class":141},"\u002F\u002F service\u002Fweb\u002Fembed.go\n",[135,7865,7866,7868],{"class":137,"line":145},[135,7867,335],{"class":148},[135,7869,338],{"class":155},[135,7871,7872],{"class":137,"line":163},[135,7873,344],{"emptyLinePlaceholder":343},[135,7875,7876,7878],{"class":137,"line":179},[135,7877,349],{"class":148},[135,7879,352],{"class":159},[135,7881,7882,7884,7886],{"class":137,"line":185},[135,7883,357],{"class":169},[135,7885,360],{"class":155},[135,7887,363],{"class":169},[135,7889,7890,7892,7895],{"class":137,"line":366},[135,7891,357],{"class":169},[135,7893,7894],{"class":155},"io\u002Ffs",[135,7896,363],{"class":169},[135,7898,7899,7901,7904],{"class":137,"line":376},[135,7900,357],{"class":169},[135,7902,7903],{"class":155},"mime",[135,7905,363],{"class":169},[135,7907,7908,7910,7912],{"class":137,"line":386},[135,7909,357],{"class":169},[135,7911,371],{"class":155},[135,7913,363],{"class":169},[135,7915,7916,7918,7921],{"class":137,"line":392},[135,7917,357],{"class":169},[135,7919,7920],{"class":155},"path\u002Ffilepath",[135,7922,363],{"class":169},[135,7924,7925,7927,7929],{"class":137,"line":397},[135,7926,357],{"class":169},[135,7928,381],{"class":155},[135,7930,363],{"class":169},[135,7932,7933],{"class":137,"line":403},[135,7934,344],{"emptyLinePlaceholder":343},[135,7936,7937,7939,7942],{"class":137,"line":420},[135,7938,357],{"class":169},[135,7940,7941],{"class":155},"github.com\u002Fgin-gonic\u002Fgin",[135,7943,363],{"class":169},[135,7945,7946],{"class":137,"line":425},[135,7947,389],{"class":159},[135,7949,7950],{"class":137,"line":431},[135,7951,344],{"emptyLinePlaceholder":343},[135,7953,7954],{"class":137,"line":471},[135,7955,7956],{"class":141},"\u002F\u002Fgo:embed dist\n",[135,7958,7959,7961,7964,7966,7968],{"class":137,"line":476},[135,7960,406],{"class":148},[135,7962,7963],{"class":159}," embedFS ",[135,7965,360],{"class":155},[135,7967,414],{"class":159},[135,7969,417],{"class":155},[135,7971,7972],{"class":137,"line":491},[135,7973,344],{"emptyLinePlaceholder":343},[135,7975,7976,7978,7981,7983,7985,7987,7989],{"class":137,"line":500},[135,7977,528],{"class":148},[135,7979,7980],{"class":155}," SPAHandler",[135,7982,6519],{"class":159},[135,7984,769],{"class":155},[135,7986,414],{"class":159},[135,7988,6126],{"class":155},[135,7990,488],{"class":159},[135,7992,7993,7996,7998,8001,8004,8007,8009],{"class":137,"line":515},[135,7994,7995],{"class":159},"    sub, _ ",[135,7997,784],{"class":148},[135,7999,8000],{"class":159}," fs.",[135,8002,8003],{"class":155},"Sub",[135,8005,8006],{"class":159},"(embedFS, ",[135,8008,459],{"class":169},[135,8010,389],{"class":159},[135,8012,8013],{"class":137,"line":520},[135,8014,344],{"emptyLinePlaceholder":343},[135,8016,8017,8019,8021,8023,8025,8027,8029,8031,8033],{"class":137,"line":525},[135,8018,657],{"class":148},[135,8020,6135],{"class":148},[135,8022,548],{"class":159},[135,8024,835],{"class":534},[135,8026,747],{"class":148},[135,8028,769],{"class":155},[135,8030,414],{"class":159},[135,8032,844],{"class":155},[135,8034,573],{"class":159},[135,8036,8037,8039,8041,8043,8045,8048,8050],{"class":137,"line":576},[135,8038,896],{"class":159},[135,8040,784],{"class":148},[135,8042,634],{"class":159},[135,8044,637],{"class":155},[135,8046,8047],{"class":159},"(c.Request.URL.Path, ",[135,8049,643],{"class":169},[135,8051,389],{"class":159},[135,8053,8054,8056,8058,8060,8062],{"class":137,"line":593},[135,8055,916],{"class":148},[135,8057,919],{"class":159},[135,8059,585],{"class":148},[135,8061,6180],{"class":169},[135,8063,488],{"class":159},[135,8065,8066,8069,8071],{"class":137,"line":605},[135,8067,8068],{"class":159},"            p ",[135,8070,599],{"class":148},[135,8072,8073],{"class":169}," \"index.html\"\n",[135,8075,8076],{"class":137,"line":616},[135,8077,1010],{"class":159},[135,8079,8080],{"class":137,"line":648},[135,8081,344],{"emptyLinePlaceholder":343},[135,8083,8084,8087,8089,8091,8094],{"class":137,"line":654},[135,8085,8086],{"class":159},"        data, err ",[135,8088,784],{"class":148},[135,8090,8000],{"class":159},[135,8092,8093],{"class":155},"ReadFile",[135,8095,8096],{"class":159},"(sub, p)\n",[135,8098,8099,8101,8103,8105,8107],{"class":137,"line":674},[135,8100,916],{"class":148},[135,8102,5529],{"class":159},[135,8104,5532],{"class":148},[135,8106,5535],{"class":208},[135,8108,488],{"class":159},[135,8110,8111],{"class":137,"line":2810},[135,8112,8113],{"class":141},"            \u002F\u002F SPA fallback：文件不存在则返回 index.html\n",[135,8115,8116,8119,8121,8123,8125,8128,8131],{"class":137,"line":2815},[135,8117,8118],{"class":159},"            data, _ ",[135,8120,599],{"class":148},[135,8122,8000],{"class":159},[135,8124,8093],{"class":155},[135,8126,8127],{"class":159},"(sub, ",[135,8129,8130],{"class":169},"\"index.html\"",[135,8132,389],{"class":159},[135,8134,8135],{"class":137,"line":2821},[135,8136,1010],{"class":159},[135,8138,8139],{"class":137,"line":2830},[135,8140,344],{"emptyLinePlaceholder":343},[135,8142,8144,8146,8149,8152,8155,8158,8161],{"class":137,"line":8143},33,[135,8145,5920],{"class":159},[135,8147,8148],{"class":155},"Data",[135,8150,8151],{"class":159},"(http.StatusOK, mime.",[135,8153,8154],{"class":155},"TypeByExtension",[135,8156,8157],{"class":159},"(filepath.",[135,8159,8160],{"class":155},"Ext",[135,8162,8163],{"class":159},"(p)), data)\n",[135,8165,8167],{"class":137,"line":8166},34,[135,8168,651],{"class":159},[135,8170,8172],{"class":137,"line":8171},35,[135,8173,314],{"class":159},[110,8175,8177],{"id":8176},"_2-改造路由","2. 改造路由",[126,8179,8181],{"className":321,"code":8180,"language":323,"meta":131,"style":131},"\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",[63,8182,8183,8188,8199,8203,8208,8223,8237,8250,8263,8276],{"__ignoreMap":131},[135,8184,8185],{"class":137,"line":138},[135,8186,8187],{"class":141},"\u002F\u002F router.go\n",[135,8189,8190,8192,8194,8197],{"class":137,"line":145},[135,8191,349],{"class":148},[135,8193,1133],{"class":169},[135,8195,8196],{"class":155},"myproject\u002Fweb",[135,8198,363],{"class":169},[135,8200,8201],{"class":137,"line":163},[135,8202,344],{"emptyLinePlaceholder":343},[135,8204,8205],{"class":137,"line":179},[135,8206,8207],{"class":141},"\u002F\u002F 替换原来的磁盘静态文件服务\n",[135,8209,8210,8213,8215,8218,8221],{"class":137,"line":185},[135,8211,8212],{"class":159},"webHandler ",[135,8214,784],{"class":148},[135,8216,8217],{"class":159}," web.",[135,8219,8220],{"class":155},"SPAHandler",[135,8222,793],{"class":159},[135,8224,8225,8228,8230,8232,8234],{"class":137,"line":366},[135,8226,8227],{"class":159},"router.",[135,8229,821],{"class":155},[135,8231,548],{"class":159},[135,8233,643],{"class":169},[135,8235,8236],{"class":159},", webHandler)\n",[135,8238,8239,8241,8243,8245,8248],{"class":137,"line":376},[135,8240,8227],{"class":159},[135,8242,821],{"class":155},[135,8244,548],{"class":159},[135,8246,8247],{"class":169},"\"\u002Fassets\u002F*filepath\"",[135,8249,8236],{"class":159},[135,8251,8252,8254,8256,8258,8261],{"class":137,"line":386},[135,8253,8227],{"class":159},[135,8255,821],{"class":155},[135,8257,548],{"class":159},[135,8259,8260],{"class":169},"\"\u002Ffavicon.ico\"",[135,8262,8236],{"class":159},[135,8264,8265,8267,8269,8271,8274],{"class":137,"line":392},[135,8266,8227],{"class":159},[135,8268,821],{"class":155},[135,8270,548],{"class":159},[135,8272,8273],{"class":169},"\"\u002Ffavicon.svg\"",[135,8275,8236],{"class":159},[135,8277,8278,8280,8283],{"class":137,"line":397},[135,8279,8227],{"class":159},[135,8281,8282],{"class":155},"NoRoute",[135,8284,8285],{"class":159},"(webHandler)\n",[110,8287,8289],{"id":8288},"_3-一键构建脚本","3. 一键构建脚本",[126,8291,8293],{"className":1094,"code":8292,"language":1096,"meta":131,"style":131},"#!\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",[63,8294,8295,8300,8305,8309,8316,8320,8325,8335,8345,8349,8354,8365,8378,8382,8387,8394,8407,8417],{"__ignoreMap":131},[135,8296,8297],{"class":137,"line":138},[135,8298,8299],{"class":141},"#!\u002Fbin\u002Fbash\n",[135,8301,8302],{"class":137,"line":145},[135,8303,8304],{"class":141},"# build-standalone.sh\n",[135,8306,8307],{"class":137,"line":163},[135,8308,344],{"emptyLinePlaceholder":343},[135,8310,8311,8313],{"class":137,"line":179},[135,8312,1113],{"class":208},[135,8314,8315],{"class":208}," -e\n",[135,8317,8318],{"class":137,"line":185},[135,8319,344],{"emptyLinePlaceholder":343},[135,8321,8322],{"class":137,"line":366},[135,8323,8324],{"class":141},"# 1. 构建前端\n",[135,8326,8327,8330,8332],{"class":137,"line":376},[135,8328,8329],{"class":155},"pnpm",[135,8331,1791],{"class":169},[135,8333,8334],{"class":208}," --no-frozen-lockfile\n",[135,8336,8337,8339,8342],{"class":137,"line":386},[135,8338,3460],{"class":155},[135,8340,8341],{"class":169}," vite",[135,8343,8344],{"class":169}," build\n",[135,8346,8347],{"class":137,"line":392},[135,8348,344],{"emptyLinePlaceholder":343},[135,8350,8351],{"class":137,"line":397},[135,8352,8353],{"class":141},"# 2. 复制到嵌入目录\n",[135,8355,8356,8359,8362],{"class":137,"line":403},[135,8357,8358],{"class":155},"rm",[135,8360,8361],{"class":208}," -rf",[135,8363,8364],{"class":169}," service\u002Fweb\u002Fdist\n",[135,8366,8367,8370,8373,8376],{"class":137,"line":420},[135,8368,8369],{"class":155},"cp",[135,8371,8372],{"class":208}," -r",[135,8374,8375],{"class":169}," dist",[135,8377,8364],{"class":169},[135,8379,8380],{"class":137,"line":425},[135,8381,344],{"emptyLinePlaceholder":343},[135,8383,8384],{"class":137,"line":431},[135,8385,8386],{"class":141},"# 3. 编译 Go 二进制\n",[135,8388,8389,8391],{"class":137,"line":471},[135,8390,1124],{"class":208},[135,8392,8393],{"class":169}," service\n",[135,8395,8396,8398,8400,8402,8405],{"class":137,"line":476},[135,8397,323],{"class":155},[135,8399,1197],{"class":169},[135,8401,1251],{"class":208},[135,8403,8404],{"class":169}," myapp.exe",[135,8406,6006],{"class":208},[135,8408,8409,8412,8415],{"class":137,"line":491},[135,8410,8411],{"class":208},"    -ldflags=",[135,8413,8414],{"class":169},"\"-X myproject\u002Fglobal.RUNCODE=release\"",[135,8416,6006],{"class":208},[135,8418,8419],{"class":137,"line":500},[135,8420,8421],{"class":169},"    main.go\n",[11,8423,1314],{"id":1314},[110,8425,8427],{"id":8426},"坑一路由不匹配静态资源-404","坑一：路由不匹配，静态资源 404",[15,8429,8430,8431,8433,8434,8436],{},"首次实现时用了 Gin 的 ",[63,8432,8282],{}," 兜底，但部分静态资源返回 404。排查发现 ",[63,8435,8282],{}," 在某些路由未显式注册时不会按预期触发。",[15,8438,8439,8441,8442,8445,8446,8449,8450,8452],{},[25,8440,1397],{},"：显式注册 ",[63,8443,8444],{},"\u002Fassets\u002F*filepath","、",[63,8447,8448],{},"\u002Ffavicon.ico"," 等路由，",[63,8451,8282],{}," 仅作为兜底。",[110,8454,8456,8457,8459,8460,1336],{"id":8455},"坑二goembed-排除-_-开头文件","坑二：",[63,8458,100],{}," 排除 ",[63,8461,83],{},[15,8463,8464,8465,8468,8469,8471,8472,1311],{},"这是最坑的。Vite 构建产出了一个 ",[63,8466,8467],{},"_plugin-vue_export-helper-xxx.js","，Go 的 ",[63,8470,100],{}," 当模式为目录时，",[25,8473,8474,8475,695,8477,8479],{},"硬编码排除了 ",[63,8476,83],{},[63,8478,414],{}," 开头的文件",[126,8481,8483],{"className":321,"code":8482,"language":323,"meta":131,"style":131},"\u002F\u002F 这行会跳过 dist\u002Fassets\u002F_plugin-xxx.js\n\u002F\u002Fgo:embed dist\n",[63,8484,8485,8490],{"__ignoreMap":131},[135,8486,8487],{"class":137,"line":138},[135,8488,8489],{"class":141},"\u002F\u002F 这行会跳过 dist\u002Fassets\u002F_plugin-xxx.js\n",[135,8491,8492],{"class":137,"line":145},[135,8493,7956],{"class":141},[15,8495,8496,8497,1311],{},"错误表现：JS 文件返回的是 HTML 内容（SPA fallback），浏览器报 ",[63,8498,8499],{},"Unexpected token '\u003C'",[15,8501,8502,8504,8505,8507],{},[25,8503,1397],{},"：改 Vite 配置，去除了 ",[63,8506,83],{}," 前缀：",[126,8509,8511],{"className":128,"code":8510,"language":130,"meta":131,"style":131},"\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",[63,8512,8513,8517,8524,8531,8538,8546,8583,8588,8593,8598],{"__ignoreMap":131},[135,8514,8515],{"class":137,"line":138},[135,8516,142],{"class":141},[135,8518,8519,8522],{"class":137,"line":145},[135,8520,8521],{"class":155},"build",[135,8523,298],{"class":159},[135,8525,8526,8529],{"class":137,"line":163},[135,8527,8528],{"class":155},"  rollupOptions",[135,8530,298],{"class":159},[135,8532,8533,8536],{"class":137,"line":179},[135,8534,8535],{"class":155},"    output",[135,8537,298],{"class":159},[135,8539,8540,8543],{"class":137,"line":185},[135,8541,8542],{"class":155},"      chunkFileNames",[135,8544,8545],{"class":159},"(chunkInfo) {\n",[135,8547,8548,8550,8553,8556,8558,8560,8562,8564,8566,8568,8570,8572,8574,8576,8578,8580],{"class":137,"line":366},[135,8549,5542],{"class":148},[135,8551,8552],{"class":169}," `assets\u002F${",[135,8554,8555],{"class":159},"chunkInfo",[135,8557,414],{"class":169},[135,8559,551],{"class":159},[135,8561,414],{"class":169},[135,8563,4180],{"class":155},[135,8565,548],{"class":169},[135,8567,3803],{"class":169},[135,8569,3791],{"class":148},[135,8571,83],{"class":3794},[135,8573,3803],{"class":169},[135,8575,567],{"class":169},[135,8577,4197],{"class":169},[135,8579,4496],{"class":169},[135,8581,8582],{"class":169},"}-[hash].js`\n",[135,8584,8585],{"class":137,"line":376},[135,8586,8587],{"class":159},"      },\n",[135,8589,8590],{"class":137,"line":386},[135,8591,8592],{"class":159},"    },\n",[135,8594,8595],{"class":137,"line":392},[135,8596,8597],{"class":159},"  },\n",[135,8599,8600],{"class":137,"line":397},[135,8601,8602],{"class":159},"},\n",[15,8604,8605,8606,8608],{},"这样就避免产生 ",[63,8607,83],{}," 前缀文件名的同时，保持构建产物的一致性和引用关系的正确性。",[110,8610,8612],{"id":8611},"坑三构建缓存导致-embed-内容未更新","坑三：构建缓存导致 embed 内容未更新",[15,8614,8615,8616,8618],{},"多次 ",[63,8617,1381],{}," 后，Go 的构建缓存可能使用了旧版本的 embed 内容，导致实际运行的文件不是最新的。",[15,8620,8621,8623,8624,8627],{},[25,8622,1397],{},"：使用 ",[63,8625,8626],{},"go build -a"," 强制重新编译所有包。",[11,8629,1585],{"id":1585},[126,8631,8633],{"className":1094,"code":8632,"language":1096,"meta":131,"style":131},"$ bash build-standalone.sh\n=== 构建成功 ===\n输出: service\u002Fmyapp.exe  41M\n",[63,8634,8635,8644,8654],{"__ignoreMap":131},[135,8636,8637,8639,8641],{"class":137,"line":138},[135,8638,1595],{"class":155},[135,8640,1608],{"class":169},[135,8642,8643],{"class":169}," build-standalone.sh\n",[135,8645,8646,8648,8651],{"class":137,"line":145},[135,8647,3779],{"class":169},[135,8649,8650],{"class":169}," 构建成功",[135,8652,8653],{"class":169}," ===\n",[135,8655,8656,8659,8662],{"class":137,"line":163},[135,8657,8658],{"class":155},"输出:",[135,8660,8661],{"class":169}," service\u002Fmyapp.exe",[135,8663,8664],{"class":169},"  41M\n",[15,8666,8667],{},"一个 41MB 的 exe，内置前端页面、API 后端、SQLite 数据库（运行时初始化），拖到哪都能跑。",[15,8669,8670],{},[25,8671,8672],{},"部署对比：",[3023,8674,8675,8688],{},[3026,8676,8677],{},[3029,8678,8679,8682,8685],{},[3032,8680,8681],{},"方式",[3032,8683,8684],{},"文件数",[3032,8686,8687],{},"操作",[3045,8689,8690,8701],{},[3029,8691,8692,8695,8698],{},[3050,8693,8694],{},"旧方案",[3050,8696,8697],{},"1 exe + 1 web 目录",[3050,8699,8700],{},"解压→保持目录结构",[3029,8702,8703,8706,8709],{},[3050,8704,8705],{},"新方案",[3050,8707,8708],{},"1 个 exe",[3050,8710,8711],{},"直接扔到服务器运行",[11,8713,1626],{"id":1626},[15,8715,8716,8717,8719],{},"Go 的 ",[63,8718,100],{}," 是一个强大的工具，但有两个隐藏行为需要注意：",[54,8721,8722,8729],{},[33,8723,8724,8725,695,8727,8479],{},"嵌入目录时自动排除 ",[63,8726,83],{},[63,8728,414],{},[33,8730,8731],{},"构建缓存可能导致 embed 不是最新的",[15,8733,8734],{},"对于前端+后端合体的场景，这是性价比最高的方案——不需要额外的打包工具，纯标准库搞定。",[1665,8736,8737],{},"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":131,"searchDepth":145,"depth":163,"links":8739},[8740,8741,8742,8747,8753,8754],{"id":13,"depth":145,"text":13},{"id":94,"depth":145,"text":94},{"id":108,"depth":145,"text":108,"children":8743},[8744,8745,8746],{"id":7852,"depth":163,"text":7853},{"id":8176,"depth":163,"text":8177},{"id":8288,"depth":163,"text":8289},{"id":1314,"depth":145,"text":1314,"children":8748},[8749,8750,8752],{"id":8426,"depth":163,"text":8427},{"id":8455,"depth":163,"text":8751},"坑二：\u002F\u002Fgo:embed 排除 _ 开头文件",{"id":8611,"depth":163,"text":8612},{"id":1585,"depth":145,"text":1585},{"id":1626,"depth":145,"text":1626},"记录将 Vue 3 前端嵌入 Go 二进制实现单 exe 发布的完整流程，以及遇到的坑和解决方案。","\u002Fposts\u002Fgo-embed-frontend\u002Fimg\u002Fcover.jpg",{},"2026-06-06T16:00:00",{"title":7822,"description":8755},"posts\u002Fgo-embed-frontend\u002Findex",[1701,8762,8763,8764],"Vue","编译","埋坑","2026-06-06","SIQNL24wVAFb18wIGWSB3lNVZUQ9B76jWgQ55TASTYk",{"id":8768,"title":8769,"author":6,"body":8770,"description":10165,"difficulty":2901,"draft":1691,"extension":1692,"image":10166,"meta":10167,"navigation":343,"path":10168,"pinned":1691,"published":10169,"seo":10170,"series":6,"seriesOrder":6,"stem":10171,"tags":10172,"updated":10169,"__hash__":10174},"posts\u002Fposts\u002Fjira-cli-intro\u002Findex.md","jira_cli — 把企业 Jira 装进命令行，AI Agent 直接调用",{"type":8,"value":8771,"toc":10144},[8772,8776,8791,8858,8861,8864,8940,8942,8975,8982,8985,8989,9101,9105,9236,9240,9308,9311,9374,9377,9379,9383,9386,9406,9409,9424,9427,9431,9437,9575,9586,9617,9621,9624,9721,9725,9728,9846,9850,9853,9917,9920,9924,9927,9995,9998,10004,10007,10041,10048,10051,10111,10113,10116,10133,10141],[11,8773,8775],{"id":8774},"jira_cli-是什么","jira_cli 是什么",[15,8777,8778,8783,8784,695,8787,8790],{},[19,8779,8782],{"href":8780,"rel":8781},"https:\u002F\u002Fgithub.com\u002Fyourname\u002Fjira_cli",[3518],"jira_cli"," 是一个面向企业 Jira（Server \u002F Data Center）的本地命令行工具，Go 语言编写，MIT 协议开源。它同时提供 ",[25,8785,8786],{},"Cobra CLI",[25,8788,8789],{},"MCP stdio server"," 两种入口，既能当日常命令行用，又能直接接入 AI 编程工具。",[126,8792,8794],{"className":1094,"code":8793,"language":1096,"meta":131,"style":131},"# 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",[63,8795,8796,8801,8814,8825,8840,8844,8849],{"__ignoreMap":131},[135,8797,8798],{"class":137,"line":138},[135,8799,8800],{"class":141},"# CLI 日常使用\n",[135,8802,8803,8805,8808,8811],{"class":137,"line":145},[135,8804,8782],{"class":155},[135,8806,8807],{"class":169}," issue",[135,8809,8810],{"class":169}," search",[135,8812,8813],{"class":169}," \"project = PROJ ORDER BY updated DESC\"\n",[135,8815,8816,8818,8820,8822],{"class":137,"line":163},[135,8817,8782],{"class":155},[135,8819,8807],{"class":169},[135,8821,5078],{"class":169},[135,8823,8824],{"class":169}," PROJ-123\n",[135,8826,8827,8829,8831,8834,8837],{"class":137,"line":179},[135,8828,8782],{"class":155},[135,8830,8807],{"class":169},[135,8832,8833],{"class":169}," comment",[135,8835,8836],{"class":169}," PROJ-123",[135,8838,8839],{"class":169}," \"处理完成，请测试验证\"\n",[135,8841,8842],{"class":137,"line":185},[135,8843,344],{"emptyLinePlaceholder":343},[135,8845,8846],{"class":137,"line":366},[135,8847,8848],{"class":141},"# MCP 模式 — 给 AI 装上操作 Jira 的手\n",[135,8850,8851,8853,8855],{"class":137,"line":376},[135,8852,8782],{"class":155},[135,8854,5314],{"class":169},[135,8856,8857],{"class":208}," --user=work\n",[11,8859,8860],{"id":8860},"为什么需要它",[15,8862,8863],{},"大多数 Jira 命令行工具只解决\"开发者手动操作 Jira\"这一个场景。但 2026 年 AI 编程工具已经普及，你的 AI Agent（Claude Code、Codex、Cursor 等）也需要读写 Jira。痛点就来了：",[3023,8865,8866,8876],{},[3026,8867,8868],{},[3029,8869,8870,8873],{},[3032,8871,8872],{},"痛点",[3032,8874,8875],{},"jira_cli 怎么解",[3045,8877,8878,8886,8894,8906,8917,8928],{},[3029,8879,8880,8883],{},[3050,8881,8882],{},"企业 Jira 在内网，没有公网 API",[3050,8884,8885],{},"本地 stdio MCP server，不经过外网",[3029,8887,8888,8891],{},[3050,8889,8890],{},"AI 可能误删、误写 Jira 数据",[3050,8892,8893],{},"账号级只读策略，AI 只能查不能改",[3029,8895,8896,8899],{},[3050,8897,8898],{},"团队多人多 Jira 实例",[3050,8900,8901,8902,8905],{},"多 Profile 管理，",[63,8903,8904],{},"--user=work"," 切换",[3029,8907,8908,8911],{},[3050,8909,8910],{},"Windows 终端体验差",[3050,8912,8913,8914,8916],{},"原生 ",[63,8915,1433],{}," 构建 + 一键安装脚本",[3029,8918,8919,8922],{},[3050,8920,8921],{},"AI 生成 JQL 困难",[3050,8923,8924,8925],{},"字段元数据发现：",[63,8926,8927],{},"field list --query xxx",[3029,8929,8930,8933],{},[3050,8931,8932],{},"MCP 直接拿原始 JSON 太冗长",[3050,8934,8935,8936,8939],{},"紧凑视图 + ",[63,8937,8938],{},"--view summary"," 减少 token 消耗",[11,8941,7188],{"id":7188},[126,8943,8945],{"className":1094,"code":8944,"language":1096,"meta":131,"style":131},"# 从源码安装（要求 Go 1.26.3+）\ngo install github.com\u002Fyourname\u002Fjira_cli@latest\n\n# 或下载预编译二进制\n# https:\u002F\u002Fgithub.com\u002Fyourname\u002Fjira_cli\u002Freleases\n",[63,8946,8947,8952,8961,8965,8970],{"__ignoreMap":131},[135,8948,8949],{"class":137,"line":138},[135,8950,8951],{"class":141},"# 从源码安装（要求 Go 1.26.3+）\n",[135,8953,8954,8956,8958],{"class":137,"line":145},[135,8955,323],{"class":155},[135,8957,1791],{"class":169},[135,8959,8960],{"class":169}," github.com\u002Fyourname\u002Fjira_cli@latest\n",[135,8962,8963],{"class":137,"line":163},[135,8964,344],{"emptyLinePlaceholder":343},[135,8966,8967],{"class":137,"line":179},[135,8968,8969],{"class":141},"# 或下载预编译二进制\n",[135,8971,8972],{"class":137,"line":185},[135,8973,8974],{"class":141},"# https:\u002F\u002Fgithub.com\u002Fyourname\u002Fjira_cli\u002Freleases\n",[15,8976,8977,8978,8981],{},"Windows 用户下载 zip 包解压后双击 ",[63,8979,8980],{},"install.bat"," 即可全局安装，自动加入 PATH。",[11,8983,8984],{"id":8984},"快速上手",[110,8986,8988],{"id":8987},"_1-配置认证","1. 配置认证",[126,8990,8992],{"className":1094,"code":8991,"language":1096,"meta":131,"style":131},"# 设置 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",[63,8993,8994,8999,9015,9019,9024,9044,9048,9053,9082,9086,9091],{"__ignoreMap":131},[135,8995,8996],{"class":137,"line":138},[135,8997,8998],{"class":141},"# 设置 Jira 地址\n",[135,9000,9001,9003,9006,9009,9012],{"class":137,"line":145},[135,9002,8782],{"class":155},[135,9004,9005],{"class":169}," set",[135,9007,9008],{"class":169}," base-url",[135,9010,9011],{"class":169}," https:\u002F\u002Fjira.your-company.com",[135,9013,9014],{"class":208}," --global\n",[135,9016,9017],{"class":137,"line":163},[135,9018,344],{"emptyLinePlaceholder":343},[135,9020,9021],{"class":137,"line":179},[135,9022,9023],{"class":141},"# Token 认证（推荐）\n",[135,9025,9026,9028,9030,9033,9035,9038,9040,9042],{"class":137,"line":185},[135,9027,8782],{"class":155},[135,9029,9005],{"class":169},[135,9031,9032],{"class":169}," token",[135,9034,2230],{"class":148},[135,9036,9037],{"class":169},"your-toke",[135,9039,2630],{"class":159},[135,9041,2239],{"class":148},[135,9043,9014],{"class":208},[135,9045,9046],{"class":137,"line":366},[135,9047,344],{"emptyLinePlaceholder":343},[135,9049,9050],{"class":137,"line":376},[135,9051,9052],{"class":141},"# 或用户名密码认证\n",[135,9054,9055,9057,9059,9061,9063,9066,9068,9070,9072,9075,9078,9080],{"class":137,"line":386},[135,9056,8782],{"class":155},[135,9058,9005],{"class":169},[135,9060,7261],{"class":169},[135,9062,2230],{"class":148},[135,9064,9065],{"class":169},"usernam",[135,9067,2617],{"class":159},[135,9069,2239],{"class":148},[135,9071,2230],{"class":148},[135,9073,9074],{"class":169},"passwor",[135,9076,9077],{"class":159},"d",[135,9079,2239],{"class":148},[135,9081,9014],{"class":208},[135,9083,9084],{"class":137,"line":392},[135,9085,344],{"emptyLinePlaceholder":343},[135,9087,9088],{"class":137,"line":397},[135,9089,9090],{"class":141},"# 验证一下\n",[135,9092,9093,9095,9098],{"class":137,"line":403},[135,9094,8782],{"class":155},[135,9096,9097],{"class":169}," user",[135,9099,9100],{"class":169}," me\n",[110,9102,9104],{"id":9103},"_2-日常使用","2. 日常使用",[126,9106,9108],{"className":1094,"code":9107,"language":1096,"meta":131,"style":131},"# 查自己今天的任务\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",[63,9109,9110,9115,9126,9130,9135,9145,9149,9154,9168,9185,9189,9194,9207,9211,9216],{"__ignoreMap":131},[135,9111,9112],{"class":137,"line":138},[135,9113,9114],{"class":141},"# 查自己今天的任务\n",[135,9116,9117,9119,9121,9123],{"class":137,"line":145},[135,9118,8782],{"class":155},[135,9120,8807],{"class":169},[135,9122,8810],{"class":169},[135,9124,9125],{"class":169}," \"assignee = currentUser() ORDER BY updated DESC\"\n",[135,9127,9128],{"class":137,"line":163},[135,9129,344],{"emptyLinePlaceholder":343},[135,9131,9132],{"class":137,"line":179},[135,9133,9134],{"class":141},"# 看某个任务详情\n",[135,9136,9137,9139,9141,9143],{"class":137,"line":185},[135,9138,8782],{"class":155},[135,9140,8807],{"class":169},[135,9142,5078],{"class":169},[135,9144,8824],{"class":169},[135,9146,9147],{"class":137,"line":366},[135,9148,344],{"emptyLinePlaceholder":343},[135,9150,9151],{"class":137,"line":376},[135,9152,9153],{"class":141},"# 流转状态\n",[135,9155,9156,9158,9160,9163,9165],{"class":137,"line":386},[135,9157,8782],{"class":155},[135,9159,8807],{"class":169},[135,9161,9162],{"class":169}," transitions",[135,9164,8836],{"class":169},[135,9166,9167],{"class":141},"                    # 可以转哪些\n",[135,9169,9170,9172,9174,9177,9179,9182],{"class":137,"line":392},[135,9171,8782],{"class":155},[135,9173,8807],{"class":169},[135,9175,9176],{"class":169}," do-transition",[135,9178,8836],{"class":169},[135,9180,9181],{"class":208}," 21",[135,9183,9184],{"class":141},"               # 执行流转\n",[135,9186,9187],{"class":137,"line":397},[135,9188,344],{"emptyLinePlaceholder":343},[135,9190,9191],{"class":137,"line":403},[135,9192,9193],{"class":141},"# 添加备注\n",[135,9195,9196,9198,9200,9202,9204],{"class":137,"line":420},[135,9197,8782],{"class":155},[135,9199,8807],{"class":169},[135,9201,8833],{"class":169},[135,9203,8836],{"class":169},[135,9205,9206],{"class":169}," \"已定位到问题，正在修复\"\n",[135,9208,9209],{"class":137,"line":425},[135,9210,344],{"emptyLinePlaceholder":343},[135,9212,9213],{"class":137,"line":431},[135,9214,9215],{"class":141},"# 创建任务\n",[135,9217,9218,9220,9222,9224,9227,9230,9233],{"class":137,"line":471},[135,9219,8782],{"class":155},[135,9221,8807],{"class":169},[135,9223,7148],{"class":169},[135,9225,9226],{"class":169}," PROJ",[135,9228,9229],{"class":169}," Task",[135,9231,9232],{"class":208}," --fields",[135,9234,9235],{"class":169}," @fields.json\n",[110,9237,9239],{"id":9238},"_3-配给-ai-使用","3. 配给 AI 使用",[126,9241,9243],{"className":1094,"code":9242,"language":1096,"meta":131,"style":131},"# 先配置一个只读账号给 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",[63,9244,9245,9250,9267,9290],{"__ignoreMap":131},[135,9246,9247],{"class":137,"line":138},[135,9248,9249],{"class":141},"# 先配置一个只读账号给 AI 用\n",[135,9251,9252,9254,9256,9258,9260,9262,9265],{"class":137,"line":145},[135,9253,8782],{"class":155},[135,9255,9005],{"class":169},[135,9257,9008],{"class":169},[135,9259,9011],{"class":169},[135,9261,2227],{"class":208},[135,9263,9264],{"class":169}," ai",[135,9266,9014],{"class":208},[135,9268,9269,9271,9273,9275,9277,9280,9282,9284,9286,9288],{"class":137,"line":163},[135,9270,8782],{"class":155},[135,9272,9005],{"class":169},[135,9274,9032],{"class":169},[135,9276,2230],{"class":148},[135,9278,9279],{"class":169},"toke",[135,9281,2630],{"class":159},[135,9283,2239],{"class":148},[135,9285,2227],{"class":208},[135,9287,9264],{"class":169},[135,9289,9014],{"class":208},[135,9291,9292,9294,9296,9299,9302,9304,9306],{"class":137,"line":179},[135,9293,8782],{"class":155},[135,9295,9005],{"class":169},[135,9297,9298],{"class":169}," readonly",[135,9300,9301],{"class":169}," on",[135,9303,2227],{"class":208},[135,9305,9264],{"class":169},[135,9307,9014],{"class":208},[15,9309,9310],{},"然后在 AI 工具的 MCP 配置里加上：",[126,9312,9314],{"className":281,"code":9313,"language":283,"meta":131,"style":131},"{\n  \"mcpServers\": {\n    \"jira\": {\n      \"command\": \"jira_cli\",\n      \"args\": [\"mcp\", \"--user=ai\"]\n    }\n  }\n}\n",[63,9315,9316,9320,9326,9333,9345,9362,9366,9370],{"__ignoreMap":131},[135,9317,9318],{"class":137,"line":138},[135,9319,6869],{"class":159},[135,9321,9322,9324],{"class":137,"line":145},[135,9323,6874],{"class":208},[135,9325,298],{"class":159},[135,9327,9328,9331],{"class":137,"line":163},[135,9329,9330],{"class":208},"    \"jira\"",[135,9332,298],{"class":159},[135,9334,9335,9338,9340,9343],{"class":137,"line":179},[135,9336,9337],{"class":208},"      \"command\"",[135,9339,306],{"class":159},[135,9341,9342],{"class":169},"\"jira_cli\"",[135,9344,3347],{"class":159},[135,9346,9347,9350,9352,9355,9357,9360],{"class":137,"line":185},[135,9348,9349],{"class":208},"      \"args\"",[135,9351,3430],{"class":159},[135,9353,9354],{"class":169},"\"mcp\"",[135,9356,567],{"class":159},[135,9358,9359],{"class":169},"\"--user=ai\"",[135,9361,2153],{"class":159},[135,9363,9364],{"class":137,"line":366},[135,9365,651],{"class":159},[135,9367,9368],{"class":137,"line":376},[135,9369,3655],{"class":159},[135,9371,9372],{"class":137,"line":386},[135,9373,314],{"class":159},[15,9375,9376],{},"这样 AI 就能查 Jira 但不能修改，既有了上下文，又不用担心误操作。",[11,9378,7668],{"id":7668},[110,9380,9382],{"id":9381},"_1-账号级只读策略","1. 账号级只读策略",[15,9384,9385],{},"这是 jira_cli 最实用的设计。给 AI Agent 配置只读账号：",[126,9387,9389],{"className":1094,"code":9388,"language":1096,"meta":131,"style":131},"jira_cli set readonly on --name ai\n",[63,9390,9391],{"__ignoreMap":131},[135,9392,9393,9395,9397,9399,9401,9403],{"class":137,"line":138},[135,9394,8782],{"class":155},[135,9396,9005],{"class":169},[135,9398,9298],{"class":169},[135,9400,9301],{"class":169},[135,9402,2227],{"class":208},[135,9404,9405],{"class":169}," ai\n",[15,9407,9408],{},"只读模式下：",[30,9410,9411,9414,9417],{},[33,9412,9413],{},"CLI 写命令被隐藏和拒绝",[33,9415,9416],{},"MCP 的写入工具不会注册",[33,9418,9419,9420,9423],{},"环境变量 ",[63,9421,9422],{},"JIRA_READONLY=true"," 也可以临时覆盖",[15,9425,9426],{},"AI 只能查 JQL、看任务、查字段、读评论，绝对不会误改数据。需要创建\u002F更新时，另配一个有写入权限的账号由开发者自己操作。",[110,9428,9430],{"id":9429},"_2-多-profile-切换","2. 多 Profile 切换",[15,9432,9433,9434,9436],{},"一个配置文件里管理多个 Jira 账号，",[63,9435,2582],{}," 切换：",[126,9438,9440],{"className":1094,"code":9439,"language":1096,"meta":131,"style":131},"# 工作账号\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",[63,9441,9442,9447,9465,9488,9492,9497,9515,9538,9542,9547,9561],{"__ignoreMap":131},[135,9443,9444],{"class":137,"line":138},[135,9445,9446],{"class":141},"# 工作账号\n",[135,9448,9449,9451,9453,9455,9458,9460,9463],{"class":137,"line":145},[135,9450,8782],{"class":155},[135,9452,9005],{"class":169},[135,9454,9008],{"class":169},[135,9456,9457],{"class":169}," https:\u002F\u002Fjira.company.com",[135,9459,2227],{"class":208},[135,9461,9462],{"class":169}," work",[135,9464,9014],{"class":208},[135,9466,9467,9469,9471,9473,9475,9478,9480,9482,9484,9486],{"class":137,"line":163},[135,9468,8782],{"class":155},[135,9470,9005],{"class":169},[135,9472,9032],{"class":169},[135,9474,2230],{"class":148},[135,9476,9477],{"class":169},"work-toke",[135,9479,2630],{"class":159},[135,9481,2239],{"class":148},[135,9483,2227],{"class":208},[135,9485,9462],{"class":169},[135,9487,9014],{"class":208},[135,9489,9490],{"class":137,"line":179},[135,9491,344],{"emptyLinePlaceholder":343},[135,9493,9494],{"class":137,"line":185},[135,9495,9496],{"class":141},"# 个人账号\n",[135,9498,9499,9501,9503,9505,9508,9510,9513],{"class":137,"line":366},[135,9500,8782],{"class":155},[135,9502,9005],{"class":169},[135,9504,9008],{"class":169},[135,9506,9507],{"class":169}," https:\u002F\u002Fjira.personal.com",[135,9509,2227],{"class":208},[135,9511,9512],{"class":169}," personal",[135,9514,9014],{"class":208},[135,9516,9517,9519,9521,9523,9525,9528,9530,9532,9534,9536],{"class":137,"line":376},[135,9518,8782],{"class":155},[135,9520,9005],{"class":169},[135,9522,9032],{"class":169},[135,9524,2230],{"class":148},[135,9526,9527],{"class":169},"personal-toke",[135,9529,2630],{"class":159},[135,9531,2239],{"class":148},[135,9533,2227],{"class":208},[135,9535,9512],{"class":169},[135,9537,9014],{"class":208},[135,9539,9540],{"class":137,"line":386},[135,9541,344],{"emptyLinePlaceholder":343},[135,9543,9544],{"class":137,"line":392},[135,9545,9546],{"class":141},"# 使用时切换\n",[135,9548,9549,9551,9554,9556,9558],{"class":137,"line":397},[135,9550,8782],{"class":155},[135,9552,9553],{"class":208}," --user=work",[135,9555,8807],{"class":169},[135,9557,8810],{"class":169},[135,9559,9560],{"class":169}," \"project = PROJ\"\n",[135,9562,9563,9565,9568,9570,9572],{"class":137,"line":403},[135,9564,8782],{"class":155},[135,9566,9567],{"class":208}," --user=personal",[135,9569,8807],{"class":169},[135,9571,8810],{"class":169},[135,9573,9574],{"class":169}," \"project = SIDE\"\n",[15,9576,9577,9578,9581,9582,9585],{},"同时支持",[25,9579,9580],{},"项目级配置","（",[63,9583,9584],{},"--local","）和工作目录绑定：",[126,9587,9589],{"className":1094,"code":9588,"language":1096,"meta":131,"style":131},"# 在项目目录下，配置只对当前项目生效\ncd ~\u002Fprojects\u002Fbackend\njira_cli set base-url https:\u002F\u002Fjira-backend.company.com --local\n",[63,9590,9591,9596,9603],{"__ignoreMap":131},[135,9592,9593],{"class":137,"line":138},[135,9594,9595],{"class":141},"# 在项目目录下，配置只对当前项目生效\n",[135,9597,9598,9600],{"class":137,"line":145},[135,9599,1124],{"class":208},[135,9601,9602],{"class":169}," ~\u002Fprojects\u002Fbackend\n",[135,9604,9605,9607,9609,9611,9614],{"class":137,"line":163},[135,9606,8782],{"class":155},[135,9608,9005],{"class":169},[135,9610,9008],{"class":169},[135,9612,9613],{"class":169}," https:\u002F\u002Fjira-backend.company.com",[135,9615,9616],{"class":208}," --local\n",[110,9618,9620],{"id":9619},"_3-对-ai-友好的输出","3. 对 AI 友好的输出",[15,9622,9623],{},"jira_cli 在输出设计上充分考虑了 AI Agent 的 token 消耗：",[126,9625,9627],{"className":1094,"code":9626,"language":1096,"meta":131,"style":131},"# 紧凑视图 — 去掉 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",[63,9628,9629,9634,9650,9654,9659,9676,9680,9685,9700,9704,9709],{"__ignoreMap":131},[135,9630,9631],{"class":137,"line":138},[135,9632,9633],{"class":141},"# 紧凑视图 — 去掉 self\u002Favatar\u002F缩略图等无用字段\n",[135,9635,9636,9638,9640,9642,9644,9647],{"class":137,"line":145},[135,9637,8782],{"class":155},[135,9639,8807],{"class":169},[135,9641,5078],{"class":169},[135,9643,8836],{"class":169},[135,9645,9646],{"class":208}," --view",[135,9648,9649],{"class":169}," summary\n",[135,9651,9652],{"class":137,"line":163},[135,9653,344],{"emptyLinePlaceholder":343},[135,9655,9656],{"class":137,"line":179},[135,9657,9658],{"class":141},"# 表格输出 — 一眼看到关键信息\n",[135,9660,9661,9663,9665,9667,9670,9673],{"class":137,"line":185},[135,9662,8782],{"class":155},[135,9664,8807],{"class":169},[135,9666,8810],{"class":169},[135,9668,9669],{"class":169}," \"project = PROJ\"",[135,9671,9672],{"class":208}," --output",[135,9674,9675],{"class":169}," table\n",[135,9677,9678],{"class":137,"line":366},[135,9679,344],{"emptyLinePlaceholder":343},[135,9681,9682],{"class":137,"line":376},[135,9683,9684],{"class":141},"# JSON 输出 — 程序解析，不转义 Unicode\n",[135,9686,9687,9689,9691,9694,9696,9698],{"class":137,"line":386},[135,9688,8782],{"class":155},[135,9690,9672],{"class":208},[135,9692,9693],{"class":169}," json",[135,9695,8807],{"class":169},[135,9697,5078],{"class":169},[135,9699,8824],{"class":169},[135,9701,9702],{"class":137,"line":392},[135,9703,344],{"emptyLinePlaceholder":343},[135,9705,9706],{"class":137,"line":397},[135,9707,9708],{"class":141},"# 自定义搜索列 — 只要需要的字段\n",[135,9710,9711,9713,9715,9718],{"class":137,"line":403},[135,9712,8782],{"class":155},[135,9714,9005],{"class":169},[135,9716,9717],{"class":169}," search-columns",[135,9719,9720],{"class":169}," key,summary,fixVersions\n",[110,9722,9724],{"id":9723},"_4-字段元数据发现","4. 字段元数据发现",[15,9726,9727],{},"Jira 的 customfield 让人头疼，jira_cli 提供了完整的字段发现能力：",[126,9729,9731],{"className":1094,"code":9730,"language":1096,"meta":131,"style":131},"# 列出所有字段及其类型\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",[63,9732,9733,9738,9747,9751,9756,9770,9774,9779,9790,9794,9799,9816,9820,9825],{"__ignoreMap":131},[135,9734,9735],{"class":137,"line":138},[135,9736,9737],{"class":141},"# 列出所有字段及其类型\n",[135,9739,9740,9742,9745],{"class":137,"line":145},[135,9741,8782],{"class":155},[135,9743,9744],{"class":169}," field",[135,9746,3741],{"class":169},[135,9748,9749],{"class":137,"line":163},[135,9750,344],{"emptyLinePlaceholder":343},[135,9752,9753],{"class":137,"line":179},[135,9754,9755],{"class":141},"# 搜索包含特定关键词的字段\n",[135,9757,9758,9760,9762,9764,9767],{"class":137,"line":185},[135,9759,8782],{"class":155},[135,9761,9744],{"class":169},[135,9763,7083],{"class":169},[135,9765,9766],{"class":208}," --query",[135,9768,9769],{"class":169}," \"版本\"\n",[135,9771,9772],{"class":137,"line":366},[135,9773,344],{"emptyLinePlaceholder":343},[135,9775,9776],{"class":137,"line":376},[135,9777,9778],{"class":141},"# 只看自定义字段\n",[135,9780,9781,9783,9785,9787],{"class":137,"line":386},[135,9782,8782],{"class":155},[135,9784,9744],{"class":169},[135,9786,7083],{"class":169},[135,9788,9789],{"class":208}," --custom-only\n",[135,9791,9792],{"class":137,"line":392},[135,9793,344],{"emptyLinePlaceholder":343},[135,9795,9796],{"class":137,"line":397},[135,9797,9798],{"class":141},"# 创建任务前，先看看需要哪些字段\n",[135,9800,9801,9803,9805,9808,9810,9813],{"class":137,"line":403},[135,9802,8782],{"class":155},[135,9804,8807],{"class":169},[135,9806,9807],{"class":169}," create-meta",[135,9809,9226],{"class":169},[135,9811,9812],{"class":208}," --issue-type",[135,9814,9815],{"class":169}," Task\n",[135,9817,9818],{"class":137,"line":420},[135,9819,344],{"emptyLinePlaceholder":343},[135,9821,9822],{"class":137,"line":425},[135,9823,9824],{"class":141},"# 分页查看可选值\n",[135,9826,9827,9829,9831,9834,9836,9838,9841,9843],{"class":137,"line":431},[135,9828,8782],{"class":155},[135,9830,8807],{"class":169},[135,9832,9833],{"class":169}," create-meta-values",[135,9835,9226],{"class":169},[135,9837,9229],{"class":169},[135,9839,9840],{"class":169}," priority",[135,9842,9766],{"class":208},[135,9844,9845],{"class":169}," High\n",[110,9847,9849],{"id":9848},"_5-mcp-写入控制","5. MCP 写入控制",[15,9851,9852],{},"除了全局只读，MCP 还有更细粒度的控制：",[3023,9854,9855,9865],{},[3026,9856,9857],{},[3029,9858,9859,9862],{},[3032,9860,9861],{},"环境变量",[3032,9863,9864],{},"作用",[3045,9866,9867,9877,9887,9897,9907],{},[3029,9868,9869,9874],{},[3050,9870,9871],{},[63,9872,9873],{},"JIRA_CREATE_OPEN=false",[3050,9875,9876],{},"禁用创建\u002F更新工具",[3029,9878,9879,9884],{},[3050,9880,9881],{},[63,9882,9883],{},"JIRA_DISABLE_DISCLAIMER=true",[3050,9885,9886],{},"去掉 AI 自动添加的备注声明",[3029,9888,9889,9894],{},[3050,9890,9891],{},[63,9892,9893],{},"JIRA_CREATE_SUMMARY_MAX",[3050,9895,9896],{},"限制摘要长度",[3029,9898,9899,9904],{},[3050,9900,9901],{},[63,9902,9903],{},"JIRA_CREATE_DESCRIPTION_MAX",[3050,9905,9906],{},"限制描述长度",[3029,9908,9909,9914],{},[3050,9910,9911],{},[63,9912,9913],{},"JIRA_CREATE_FIELDS_MAX_BYTES",[3050,9915,9916],{},"限制创建字段的 JSON 大小",[15,9918,9919],{},"这些限制确保 AI 即使有写入权限也不会\"失控\"——例如防止 AI 往描述里塞几万字的废话。",[110,9921,9923],{"id":9922},"_6-中英双语帮助","6. 中英双语帮助",[15,9925,9926],{},"帮助信息默认中文，也支持一键切英文：",[126,9928,9930],{"className":1094,"code":9929,"language":1096,"meta":131,"style":131},"# 保存英文偏好\njira_cli set help-language en\n\n# 切回中文\njira_cli set help-language zh\n\n# 临时覆盖\njira_cli --lang en issue search -h\n",[63,9931,9932,9937,9949,9953,9958,9969,9973,9978],{"__ignoreMap":131},[135,9933,9934],{"class":137,"line":138},[135,9935,9936],{"class":141},"# 保存英文偏好\n",[135,9938,9939,9941,9943,9946],{"class":137,"line":145},[135,9940,8782],{"class":155},[135,9942,9005],{"class":169},[135,9944,9945],{"class":169}," help-language",[135,9947,9948],{"class":169}," en\n",[135,9950,9951],{"class":137,"line":163},[135,9952,344],{"emptyLinePlaceholder":343},[135,9954,9955],{"class":137,"line":179},[135,9956,9957],{"class":141},"# 切回中文\n",[135,9959,9960,9962,9964,9966],{"class":137,"line":185},[135,9961,8782],{"class":155},[135,9963,9005],{"class":169},[135,9965,9945],{"class":169},[135,9967,9968],{"class":169}," zh\n",[135,9970,9971],{"class":137,"line":366},[135,9972,344],{"emptyLinePlaceholder":343},[135,9974,9975],{"class":137,"line":376},[135,9976,9977],{"class":141},"# 临时覆盖\n",[135,9979,9980,9982,9985,9988,9990,9992],{"class":137,"line":386},[135,9981,8782],{"class":155},[135,9983,9984],{"class":208}," --lang",[135,9986,9987],{"class":169}," en",[135,9989,8807],{"class":169},[135,9991,8810],{"class":169},[135,9993,9994],{"class":208}," -h\n",[11,9996,9997],{"id":9997},"架构一览",[126,9999,10002],{"className":10000,"code":10001,"language":3009},[3007],"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",[63,10003,10001],{"__ignoreMap":131},[15,10005,10006],{},"设计原则清晰：",[30,10008,10009,10017,10025,10033],{},[33,10010,10011,10016],{},[25,10012,10013],{},[63,10014,10015],{},"pkg\u002Fjira"," 是纯 Jira 客户端，HTTP、认证、上下文处理都在这里",[33,10018,10019,10024],{},[25,10020,10021],{},[63,10022,10023],{},"cmd"," 只管 Cobra 解析和呈现",[33,10026,10027,10032],{},[25,10028,10029],{},[63,10030,10031],{},"internal\u002Ftools\u002Fjira_tools"," 只管 MCP schema 验证和结果适配",[33,10034,10035,10040],{},[25,10036,10037],{},[63,10038,10039],{},"internal\u002Fconfig"," 拥有 Profile 的全部逻辑，不分散到 cmd",[15,10042,10043,10044,10047],{},"每一层职责单一，新增 Jira 能力时按 ",[63,10045,10046],{},"endpoints.go → Client 方法 → CLI 命令 → MCP tool → 策略注册"," 的路径走就行。",[11,10049,10050],{"id":10050},"和已有方案的对比",[3023,10052,10053,10065],{},[3026,10054,10055],{},[3029,10056,10057,10059,10062],{},[3032,10058,4356],{},[3032,10060,10061],{},"适合场景",[3032,10063,10064],{},"jira_cli 的区别",[3045,10066,10067,10078,10089,10100],{},[3029,10068,10069,10072,10075],{},[3050,10070,10071],{},"Atlassian Rovo MCP Server",[3050,10073,10074],{},"Jira Cloud、官方 OAuth",[3050,10076,10077],{},"jira_cli 是本地 stdio，适合内网 Server\u002FDC",[3029,10079,10080,10083,10086],{},[3050,10081,10082],{},"go-jira \u002F jira-cli 社区版",[3050,10084,10085],{},"纯命令行管理",[3050,10087,10088],{},"jira_cli 同时提供 MCP server，面向 AI 工作流",[3029,10090,10091,10094,10097],{},[3050,10092,10093],{},"直接调 REST API",[3050,10095,10096],{},"自定义集成",[3050,10098,10099],{},"jira_cli 封装了认证、只读策略、字段元数据",[3029,10101,10102,10105,10108],{},[3050,10103,10104],{},"Jira 网页",[3050,10106,10107],{},"日常使用",[3050,10109,10110],{},"jira_cli 把 Jira 变成终端里的一行命令",[11,10112,1626],{"id":1626},[15,10114,10115],{},"如果你的团队满足以下任一条件，jira_cli 值得一试：",[30,10117,10118,10121,10124,10127,10130],{},[33,10119,10120],{},"企业 Jira 部署在内网，想接入 AI 编程工具",[33,10122,10123],{},"想给 AI Agent 只读权限来查 Jira 上下文",[33,10125,10126],{},"需要命令行快速查看\u002F操作 Jira，不想切浏览器",[33,10128,10129],{},"需要管理多个 Jira 实例和多套账号",[33,10131,10132],{},"Windows 用户想要无摩擦的 CLI 体验",[15,10134,10135,10136,10140],{},"GitHub：",[19,10137,10139],{"href":8780,"rel":10138},[3518],"github.com\u002Fyourname\u002Fjira_cli"," | MIT 协议 | Go 1.26.3+",[1665,10142,10143],{},"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":131,"searchDepth":145,"depth":163,"links":10145},[10146,10147,10148,10149,10154,10162,10163,10164],{"id":8774,"depth":145,"text":8775},{"id":8860,"depth":145,"text":8860},{"id":7188,"depth":145,"text":7188},{"id":8984,"depth":145,"text":8984,"children":10150},[10151,10152,10153],{"id":8987,"depth":163,"text":8988},{"id":9103,"depth":163,"text":9104},{"id":9238,"depth":163,"text":9239},{"id":7668,"depth":145,"text":7668,"children":10155},[10156,10157,10158,10159,10160,10161],{"id":9381,"depth":163,"text":9382},{"id":9429,"depth":163,"text":9430},{"id":9619,"depth":163,"text":9620},{"id":9723,"depth":163,"text":9724},{"id":9848,"depth":163,"text":9849},{"id":9922,"depth":163,"text":9923},{"id":9997,"depth":145,"text":9997},{"id":10050,"depth":145,"text":10050},{"id":1626,"depth":145,"text":1626},"介绍 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":8769,"description":10165},"posts\u002Fjira-cli-intro\u002Findex",[10173,7817,7035,1701,7037,7818],"Jira","vNH6sF2mMFmSjbHoEI2OqmYCgpFmaqyKt-MUfcQqXrU",{"id":10176,"title":10177,"author":6,"body":10178,"description":10722,"difficulty":2901,"draft":1691,"extension":1692,"image":10723,"meta":10724,"navigation":343,"path":10725,"pinned":1691,"published":10726,"seo":10727,"series":10728,"seriesOrder":138,"stem":10729,"tags":10730,"updated":10732,"__hash__":10733},"posts\u002Fposts\u002Fsublog-evolution\u002Findex.md","尼克的小窝 架构变迁：从纯静态到前后端分离",{"type":8,"value":10179,"toc":10702},[10180,10184,10187,10198,10202,10206,10212,10216,10247,10250,10264,10268,10271,10339,10343,10346,10352,10356,10366,10396,10399,10403,10411,10414,10458,10462,10583,10587,10594,10598,10604,10610,10614,10617,10668,10672,10679,10686,10692,10695,10697],[11,10181,10183],{"id":10182},"写在前面","📝 写在前面",[15,10185,10186],{},"SuBlog 最初只是一个简单的技术博客——Nuxt 3 静态生成，Markdown 写文章，部署到静态托管完事。但随着功能需求的增长，纯静态的架构开始显露出局限性。",[15,10188,10189,10190,10193,10194,10197],{},"这篇文章记录从 ",[25,10191,10192],{},"纯静态"," 到 ",[25,10195,10196],{},"前后端分离"," 的演进过程，带你了解每一步背后的思考。",[11,10199,10201],{"id":10200},"第一代纯静态nuxt-3-nuxtcontent","📦 第一代：纯静态（Nuxt 3 + @nuxt\u002Fcontent）",[110,10203,10205],{"id":10204},"️-架构图","🏗️ 架构图",[126,10207,10210],{"className":10208,"code":10209,"language":3009,"meta":131},[3007],"┌─────────────────────────────────────┐\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",[63,10211,10209],{"__ignoreMap":131},[110,10213,10215],{"id":10214},"做了什么","🔧 做了什么",[30,10217,10218,10227,10232,10237],{},[33,10219,10220,2867,10222,2867,10224,10226],{},[25,10221,3185],{},[25,10223,3195],{},[25,10225,3205],{}," 搭建前端",[33,10228,10229,10231],{},[25,10230,3215],{}," 管理 Markdown 文章，编译时生成静态页面",[33,10233,10234,10236],{},[25,10235,3235],{}," 嵌入评论，依赖 GitHub Discussions",[33,10238,10239,10240,10243,10244,10246],{},"部署通过 ",[63,10241,10242],{},"nuxt generate"," 输出 ",[63,10245,3472],{},"，丢到 Nginx 或 Vercel",[110,10248,10249],{"id":4359},"✅ 优点",[30,10251,10252,10258,10261],{},[33,10253,10254,10255,10257],{},"部署极简，一个 ",[63,10256,10242],{}," 搞定",[33,10259,10260],{},"加载飞快，全是 HTML，没有服务端开销",[33,10262,10263],{},"无需后端，零运维成本",[110,10265,10267],{"id":10266},"局限","❌ 局限",[15,10269,10270],{},"随着时间的推移，纯静态的问题越来越明显：",[3023,10272,10273,10282],{},[3026,10274,10275],{},[3029,10276,10277,10279],{},[3032,10278,4651],{},[3032,10280,10281],{},"具体表现",[3045,10283,10284,10295,10306,10317,10328],{},[3029,10285,10286,10292],{},[3050,10287,10288,10289],{},"🐢 ",[25,10290,10291],{},"内容更新慢",[3050,10293,10294],{},"改一篇文章要重新构建、重新部署，不能实时发布",[3029,10296,10297,10303],{},[3050,10298,10299,10300],{},"👤 ",[25,10301,10302],{},"没有用户体系",[3050,10304,10305],{},"无法区分读者和管理员，所有内容对所有人一致可见",[3029,10307,10308,10314],{},[3050,10309,10310,10311],{},"🔒 ",[25,10312,10313],{},"评论受限",[3050,10315,10316],{},"Giscus 需要 GitHub 账号才能评论，门槛高",[3029,10318,10319,10325],{},[3050,10320,10321,10322],{},"🚫 ",[25,10323,10324],{},"无法做动态功能",[3050,10326,10327],{},"点赞、收藏、阅读统计、个性化推荐……统统做不了",[3029,10329,10330,10336],{},[3050,10331,10332,10333],{},"🔗 ",[25,10334,10335],{},"前端锁死",[3050,10337,10338],{},"被 Nuxt\u002FVue 生态绑定，想换技术栈得重写",[11,10340,10342],{"id":10341},"第二代前后端分离react-go","⚡ 第二代：前后端分离（React + Go）",[110,10344,10205],{"id":10345},"️-架构图-1",[126,10347,10350],{"className":10348,"code":10349,"language":3009,"meta":131},[3007],"┌───────────────────────┐       ┌────────────────────┐\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",[63,10351,10349],{"__ignoreMap":131},[110,10353,10355],{"id":10354},"️-前端sublog-react","🖥️ 前端：sublog-react",[15,10357,10358,10359,10361,10362,10365],{},"选用 ",[25,10360,3040],{},"（App Router） + ",[25,10363,10364],{},"React 19","，理由是：",[30,10367,10368,10375,10382,10389],{},[33,10369,10370,10371,10374],{},"🎨 ",[25,10372,10373],{},"React 生态","——组件库成熟，开发效率高",[33,10376,10377,10378,10381],{},"📐 ",[25,10379,10380],{},"App Router","——支持布局嵌套，页面组织清晰",[33,10383,10384,10385,10388],{},"📦 ",[25,10386,10387],{},"静态导出","——仍可部署到静态托管",[33,10390,10391,10392,10395],{},"⚙️ ",[25,10393,10394],{},"运行时配置","——API 地址可动态注入，切换环境无需重新构建",[15,10397,10398],{},"前端不再直接读取 Markdown 文件，而是通过 HTTP API 获取文章数据。静态导出 + 客户端数据获取的组合，既保留了首屏加载速度，又实现了内容的动态更新。",[110,10400,10402],{"id":10401},"️-后端sublog-s","⚙️ 后端：sublog-s",[15,10404,10405,10406,2867,10408,10410],{},"后端使用 ",[25,10407,1701],{},[25,10409,7036],{},"，按业务逻辑分层组织代码。",[15,10412,10413],{},"核心技术选型：",[3023,10415,10416,10424],{},[3026,10417,10418],{},[3029,10419,10420,10422],{},[3032,10421,3173],{},[3032,10423,3176],{},[3045,10425,10426,10436,10447],{},[3029,10427,10428,10433],{},[3050,10429,10430,10431],{},"🚀 ",[25,10432,7036],{},[3050,10434,10435],{},"HTTP 框架",[3029,10437,10438,10444],{},[3050,10439,10440,10441],{},"🗄️ ",[25,10442,10443],{},"GORM",[3050,10445,10446],{},"ORM，数据库操作",[3029,10448,10449,10455],{},[3050,10450,10451,10452],{},"🔐 ",[25,10453,10454],{},"JWT",[3050,10456,10457],{},"用户认证",[11,10459,10461],{"id":10460},"前后对比","📊 前后对比",[3023,10463,10464,10477],{},[3026,10465,10466],{},[3029,10467,10468,10471,10474],{},[3032,10469,10470],{},"维度",[3032,10472,10473],{},"第一代（纯静态）",[3032,10475,10476],{},"第二代（前后端分离）",[3045,10478,10479,10491,10503,10517,10530,10543,10557,10569],{},[3029,10480,10481,10485,10488],{},[3050,10482,10370,10483],{},[25,10484,3138],{},[3050,10486,10487],{},"Nuxt 3 (Vue)",[3050,10489,10490],{},"Next.js (React 19)",[3029,10492,10493,10498,10500],{},[3050,10494,10495,10496],{},"🖥️ ",[25,10497,7038],{},[3050,10499,3081],{},[3050,10501,10502],{},"Go + Gin",[3029,10504,10505,10511,10514],{},[3050,10506,10507,10508],{},"📄 ",[25,10509,10510],{},"内容来源",[3050,10512,10513],{},"Markdown 文件",[3050,10515,10516],{},"API 数据库",[3029,10518,10519,10524,10527],{},[3050,10520,10430,10521],{},[25,10522,10523],{},"发布方式",[3050,10525,10526],{},"全量重新构建",[3050,10528,10529],{},"API 实时发布",[3029,10531,10532,10537,10540],{},[3050,10533,10299,10534],{},[25,10535,10536],{},"用户系统",[3050,10538,10539],{},"❌ 无",[3050,10541,10542],{},"✅ 注册\u002F登录",[3029,10544,10545,10551,10554],{},[3050,10546,10547,10548],{},"💬 ",[25,10549,10550],{},"评论",[3050,10552,10553],{},"Giscus（需 GitHub）",[3050,10555,10556],{},"自有评论系统（规划中）",[3029,10558,10559,10563,10566],{},[3050,10560,10384,10561],{},[25,10562,3447],{},[3050,10564,10565],{},"1 个静态目录",[3050,10567,10568],{},"前端静态 + 后端二进制",[3029,10570,10571,10577,10580],{},[3050,10572,10573,10574],{},"⚡ ",[25,10575,10576],{},"更新体验",[3050,10578,10579],{},"改文章 → 构建 → 部署",[3050,10581,10582],{},"改文章 → 立即生效",[11,10584,10586],{"id":10585},"新功能注册账号","🎉 新功能：注册账号",[15,10588,10589,10590,10593],{},"伴随着架构升级，",[25,10591,10592],{},"用户账号系统","也上线了。",[110,10595,10597],{"id":10596},"目前支持","📋 目前支持",[15,10599,10600,10601,124],{},"现在你可以在尼克的小窝 ",[25,10602,10603],{},"注册一个账号",[126,10605,10608],{"className":10606,"code":10607,"language":3009},[3007],"1. ✏️ 填写用户名、邮箱、密码\n2. 📧 查收激活邮件\n3. 🔗 点击链接激活账号\n4. 🚪 登录后查看和管理个人信息\n",[63,10609,10607],{"__ignoreMap":131},[11,10611,10613],{"id":10612},"️-后续计划","🗓️ 后续计划",[15,10615,10616],{},"正在开发或规划中的功能：",[30,10618,10620,10626,10632,10638,10644,10650,10656,10662],{"className":10619},[3480],[33,10621,10623,10625],{"className":10622},[3484],[3486,10624],{"checked":343,"disabled":343,"type":3488}," 用户注册\u002F登录（已上线 🎉）",[33,10627,10629,10631],{"className":10628},[3484],[3486,10630],{"checked":343,"disabled":343,"type":3488}," 个人资料编辑（已上线 🎉）",[33,10633,10635,10637],{"className":10634},[3484],[3486,10636],{"disabled":343,"type":3488}," 💬 站内评论系统",[33,10639,10641,10643],{"className":10640},[3484],[3486,10642],{"disabled":343,"type":3488}," 🔖 文章收藏",[33,10645,10647,10649],{"className":10646},[3484],[3486,10648],{"disabled":343,"type":3488}," 📚 阅读历史",[33,10651,10653,10655],{"className":10652},[3484],[3486,10654],{"disabled":343,"type":3488}," 👍 文章点赞",[33,10657,10659,10661],{"className":10658},[3484],[3486,10660],{"disabled":343,"type":3488}," 🔔 关注通知",[33,10663,10665,10667],{"className":10664},[3484],[3486,10666],{"disabled":343,"type":3488}," 🎨 个人首页定制",[11,10669,10671],{"id":10670},"结语","💡 结语",[15,10673,10674,10675,10678],{},"从 Nuxt 3 纯静态到 React + Go 前后端分离，SuBlog 的架构变迁本质上是一个",[25,10676,10677],{},"功能驱动","的过程。静态博客有它的位置——写写文章、展示内容完全够用。但当你想做的不仅仅是\"展示\"时，后端的能力就不可或缺了。",[15,10680,10681,10682,10685],{},"这次重构最大的感触是：",[25,10683,10684],{},"架构选型不是越复杂越好，而是刚好够用最好","。纯静态阶段用 Nuxt 3 一点问题没有，直到功能需求超出了静态的边界，才自然推动到前后端分离。",[15,10687,10688,10689],{},"如果你也在纠结\"要不要上后端\"，不妨先问问自己：",[25,10690,10691],{},"你要做的是\"展示内容\"还是\"提供服务\"？",[15,10693,10694],{},"前者，静态就够了；后者，来吧，后端欢迎你。😄",[3522,10696],{},[15,10698,10699],{},[25,10700,10701],{},"🎉 目前注册功能已经可以用了，后续更多好玩的功能正在路上。部分功能可能需要登录才能体验，快注册一个账号吧！",{"title":131,"searchDepth":145,"depth":163,"links":10703},[10704,10705,10711,10716,10717,10720,10721],{"id":10182,"depth":145,"text":10183},{"id":10200,"depth":145,"text":10201,"children":10706},[10707,10708,10709,10710],{"id":10204,"depth":163,"text":10205},{"id":10214,"depth":163,"text":10215},{"id":4359,"depth":163,"text":10249},{"id":10266,"depth":163,"text":10267},{"id":10341,"depth":145,"text":10342,"children":10712},[10713,10714,10715],{"id":10345,"depth":163,"text":10205},{"id":10354,"depth":163,"text":10355},{"id":10401,"depth":163,"text":10402},{"id":10460,"depth":145,"text":10461},{"id":10585,"depth":145,"text":10586,"children":10718},[10719],{"id":10596,"depth":163,"text":10597},{"id":10612,"depth":145,"text":10613},{"id":10670,"depth":145,"text":10671},"记录尼克的小窝从 Nuxt 3 纯静态博客演进到 React + Go 前后端分离架构的全过程，包括架构设计的思考、技术选型的变化，以及新增的账号注册功能。","\u002Fposts\u002Fsublog-evolution\u002Fimg\u002Fcover.jpg",{},"\u002Fposts\u002Fsublog-evolution","2026-07-23T12:00:00",{"title":10177,"description":10722},"尼克的小窝 开发记录","posts\u002Fsublog-evolution\u002Findex",[10731,1702,3040,1701],"架构","2026-07-23","VMQ6A2K8GQ5t3DSlezWPd3KgXnmDVMn9QVeaI28Hcp8",{"id":10735,"title":10736,"author":6,"body":10737,"description":11897,"difficulty":2901,"draft":1691,"extension":1692,"image":11898,"meta":11899,"navigation":343,"path":11900,"pinned":1691,"published":11901,"seo":11902,"series":6,"seriesOrder":6,"stem":11903,"tags":11904,"updated":1705,"__hash__":11908},"posts\u002Fposts\u002Fip-geolocation-guide\u002Findex.md","IP 属地是怎么查出来的？本地查询与在线查询方案对比",{"type":8,"value":10738,"toc":11873},[10739,10741,10747,10750,10761,10770,10774,10780,10793,10799,10810,10817,10820,10827,10831,10842,10846,10857,10860,10864,10870,10876,10879,10883,10890,10908,10911,10914,10918,10949,10988,10994,10998,11136,11172,11181,11185,11225,11230,11234,11245,11248,11282,11285,11289,11292,11296,11487,11491,11573,11577,11584,11691,11695,11704,11790,11793,11796,11832,11835,11838,11846,11867,11870],[11,10740,10182],{"id":10182},[15,10742,10743,10744],{},"很多网站会在「登录记录」「安全中心」里显示你的登录 IP 归属地，比如「福建·福州 · 中国移动」。你可能好奇过：",[25,10745,10746],{},"服务器是怎么根据一个 IP 就判断出我在哪个城市、哪个运营商的？",[15,10748,10749],{},"这篇科普文章讲清楚三件事：",[54,10751,10752,10755,10758],{},[33,10753,10754],{},"IP 属地到底查的是什么；",[33,10756,10757],{},"纯本地查询为什么能做到毫秒级且不联网；",[33,10759,10760],{},"几种主流查询方案的优缺点对比，方便你按场景选型。",[1408,10762,10763],{},[15,10764,10765,10766,10769],{},"说明：文中用到的 IP 均为 RFC 5737 保留的文档示例段（TEST-NET-2，",[63,10767,10768],{},"198.51.100.0\u002F24","），不会对应任何真实地址。",[11,10771,10773],{"id":10772},"先想清楚ip-属地查的是什么","先想清楚：IP 属地查的是什么",[15,10775,10776,10777,1311],{},"一个常见误区是「每个 IP 对应一个具体地址」。实际上 IP 属地的本质是 ",[25,10778,10779],{},"IP 段与归属地的映射",[15,10781,10782,10783,124],{},"运营商和机构在申请 IP 时，是按",[25,10784,10785,10786,10792],{},"网段（CIDR）",[25,10787,10788,10789,10791],{},"成块分配的。比如 ",[63,10790,10768],{}," 这一个段，一整块都归属同一地区、同一运营商。所以归属地数据天然可以","按段存储",[126,10794,10797],{"className":10795,"code":10796,"language":3009,"meta":131},[3007],"IP 起始          IP 结束         归属地\n────────────────────────────────────────\n198.51.100.0     198.51.100.255  中国|福建|福州|移动\n198.51.101.0     198.51.101.255  中国|浙江|杭州|电信\n",[63,10798,10796],{"__ignoreMap":131},[15,10800,10801,10802,10805,10806,10809],{},"全球公网 IP 约有 42 亿个，但",[25,10803,10804],{},"网段只有几十万条","。正因为存的是「段」而不是「每个 IP」，完整的归属地数据文件才只有几 MB（ip2region 的 ",[63,10807,10808],{},".xdb"," 约 11MB）。这是「本地离线查询」可行的第一前提。",[15,10811,10812,10813,10816],{},"所以，",[25,10814,10815],{},"IP 属地从来不是精确到街道的定位","，它返回的是段级的「省｜市｜运营商」粗粒度信息。",[11,10818,10819],{"id":10819},"纯本地查询的原理",[15,10821,10822,10823,10826],{},"以开源的 ",[25,10824,10825],{},"ip2region"," 为例，它的查询完全在本地完成，不联网、不请求第三方，几十微秒到毫秒级返回。",[110,10828,10830],{"id":10829},"第一步把-ip-变成整数","第一步：把 IP 变成整数",[15,10832,10833,10834,10837,10838,10841],{},"以 ",[63,10835,10836],{},"198.51.100.42"," 为例，它会转成一个 32 位整数 ",[63,10839,10840],{},"0xC633642A","。IP 的四个数字本质就是 4 个字节，拼起来就是一个整数，这一步没有任何玄学。",[110,10843,10845],{"id":10844},"第二步二分查找","第二步：二分查找",[15,10847,10848,10849,10852,10853,10856],{},"在按起始 IP 排序的段列表里做",[25,10850,10851],{},"二分查找","，找到满足 ",[63,10854,10855],{},"段的起始IP ≤ 目标整数 ≤ 段的结束IP"," 的那一段。",[15,10858,10859],{},"二分查找的复杂度是 O(log N)，N 是几十万条，所以很快——这就是「不靠网络也能毫秒返回」的核心。",[110,10861,10863],{"id":10862},"第三步xdb-文件的两级结构","第三步：.xdb 文件的两级结构",[15,10865,10866,10867,10869],{},"ip2region 的 ",[63,10868,10808],{}," 不是简单顺序文件，内部分两个区，进一步加速：",[126,10871,10874],{"className":10872,"code":10873,"language":3009,"meta":131},[3007],"┌─────────────────────────────┐\n│  索引区 (Index)             │  \u002F\u002F 每段一条定长记录：startIP \u002F endIP \u002F 数据区指针 + 数据长度\n│  …… 二分快速定位段 ……       │\n├─────────────────────────────┤\n│  数据区 (Data)              │  \u002F\u002F 真正存放各段的地点字符串\n│  ……                         │\n└─────────────────────────────┘\n",[63,10875,10873],{"__ignoreMap":131},[15,10877,10878],{},"先二分索引区定位到「是哪一段」，再按指针去数据区读出地点字符串，一次磁盘读就能拿到结果。",[110,10880,10882],{"id":10881},"为什么离线数据不会很快过期","为什么「离线数据」不会很快过期",[15,10884,10885,10886,10889],{},"IP 归属取决于",[25,10887,10888],{},"注册机构和运营商的分配","，一个网段通常长期稳定属于某地区、某运营商。所以：",[30,10891,10892,10895,10905],{},[33,10893,10894],{},"下载一次数据文件，可以用很久；",[33,10896,10897,10898,10904],{},"归属偶尔变化时，",[25,10899,10900,10901,10903],{},"每月更新一次 ",[63,10902,10808],{}," 文件","即可（替换文件、不用重启服务，见下文实战）；",[33,10906,10907],{},"数据源来自 IANA\u002FAPNIC 等机构的分配表与公开归属信息，不是实时探测。",[11,10909,10910],{"id":10910},"主流查询方案对比",[15,10912,10913],{},"实际开发中，查询 IP 属地主要有三条路线。",[110,10915,10917],{"id":10916},"方案一ip2region纯本地离线推荐","方案一：ip2region（纯本地离线，推荐）",[126,10919,10921],{"className":321,"code":10920,"language":323,"meta":131,"style":131},"\u002F\u002F Go 中的用法（github.com\u002Flionsoul2014\u002Fip2region\u002Fbinding\u002Fgolang）\nregion, err := searcher.SearchByStr(ip)\n\u002F\u002F 返回类似 \"中国|福建省|福州市|移动\"\n",[63,10922,10923,10928,10944],{"__ignoreMap":131},[135,10924,10925],{"class":137,"line":138},[135,10926,10927],{"class":141},"\u002F\u002F Go 中的用法（github.com\u002Flionsoul2014\u002Fip2region\u002Fbinding\u002Fgolang）\n",[135,10929,10930,10933,10935,10938,10941],{"class":137,"line":145},[135,10931,10932],{"class":159},"region, err ",[135,10934,784],{"class":148},[135,10936,10937],{"class":159}," searcher.",[135,10939,10940],{"class":155},"SearchByStr",[135,10942,10943],{"class":159},"(ip)\n",[135,10945,10946],{"class":137,"line":163},[135,10947,10948],{"class":141},"\u002F\u002F 返回类似 \"中国|福建省|福州市|移动\"\n",[3023,10950,10951,10959],{},[3026,10952,10953],{},[3029,10954,10955,10957],{},[3032,10956,4359],{},[3032,10958,4362],{},[3045,10960,10961,10969,10977],{},[3029,10962,10963,10966],{},[3050,10964,10965],{},"免费、无网络依赖、无 QPS 限制",[3050,10967,10968],{},"段级粒度，个别段可能显示到相邻地区",[3029,10970,10971,10974],{},[3050,10972,10973],{},"毫秒级查询，永不因外部服务挂掉",[3050,10975,10976],{},"数据按月更新，最新分配的段可能短暂缺失",[3029,10978,10979,10982],{},[3050,10980,10981],{},"数据文件可随版本更新，不重启",[3050,10983,10984,10985],{},"只返回省市区+运营商，无经纬度；",[25,10986,10987],{},"仅支持 IPv4",[15,10989,10990,10993],{},[25,10991,10992],{},"适用","：博客\u002F后台的「登录记录显示归属地」这类展示用途。",[110,10995,10997],{"id":10996},"方案二在线-apiip-api-高德-腾讯位置服务","方案二：在线 API（ip-api \u002F 高德 \u002F 腾讯位置服务）",[126,10999,11001],{"className":321,"code":11000,"language":323,"meta":131,"style":131},"\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",[63,11002,11003,11008,11036,11046,11076,11092,11105,11115,11119,11131],{"__ignoreMap":131},[135,11004,11005],{"class":137,"line":138},[135,11006,11007],{"class":141},"\u002F\u002F 以 ip-api.com 为例（免费版约 45 次\u002F分钟，非商用）\n",[135,11009,11010,11013,11015,11017,11020,11023,11026,11029,11031,11033],{"class":137,"line":145},[135,11011,11012],{"class":159},"ctx, cancel ",[135,11014,784],{"class":148},[135,11016,6570],{"class":159},[135,11018,11019],{"class":155},"WithTimeout",[135,11021,11022],{"class":159},"(context.",[135,11024,11025],{"class":155},"Background",[135,11027,11028],{"class":159},"(), ",[135,11030,3640],{"class":208},[135,11032,506],{"class":148},[135,11034,11035],{"class":159},"time.Second)\n",[135,11037,11038,11041,11044],{"class":137,"line":163},[135,11039,11040],{"class":148},"defer",[135,11042,11043],{"class":155}," cancel",[135,11045,793],{"class":159},[135,11047,11048,11051,11053,11055,11058,11061,11064,11066,11069,11071,11074],{"class":137,"line":179},[135,11049,11050],{"class":159},"req, _ ",[135,11052,784],{"class":148},[135,11054,660],{"class":159},[135,11056,11057],{"class":155},"NewRequestWithContext",[135,11059,11060],{"class":159},"(ctx, ",[135,11062,11063],{"class":169},"\"GET\"",[135,11065,567],{"class":159},[135,11067,11068],{"class":169},"\"https:\u002F\u002Fip-api.com\u002Fjson\u002F198.51.100.42\"",[135,11070,567],{"class":159},[135,11072,11073],{"class":208},"nil",[135,11075,389],{"class":159},[135,11077,11078,11081,11083,11086,11089],{"class":137,"line":185},[135,11079,11080],{"class":159},"resp, err ",[135,11082,784],{"class":148},[135,11084,11085],{"class":159}," http.DefaultClient.",[135,11087,11088],{"class":155},"Do",[135,11090,11091],{"class":159},"(req)\n",[135,11093,11094,11097,11099,11101,11103],{"class":137,"line":366},[135,11095,11096],{"class":148},"if",[135,11098,5529],{"class":159},[135,11100,5532],{"class":148},[135,11102,5535],{"class":208},[135,11104,488],{"class":159},[135,11106,11107,11109,11112],{"class":137,"line":376},[135,11108,657],{"class":148},[135,11110,11111],{"class":169}," \"未知\"",[135,11113,11114],{"class":141}," \u002F\u002F 断网\u002F超时降级：展示「未知」而不是报错\n",[135,11116,11117],{"class":137,"line":386},[135,11118,314],{"class":159},[135,11120,11121,11123,11126,11129],{"class":137,"line":392},[135,11122,11040],{"class":148},[135,11124,11125],{"class":159}," resp.Body.",[135,11127,11128],{"class":155},"Close",[135,11130,793],{"class":159},[135,11132,11133],{"class":137,"line":397},[135,11134,11135],{"class":141},"\u002F\u002F 解析 JSON，status != \"success\" 时同样降级\n",[3023,11137,11138,11146],{},[3026,11139,11140],{},[3029,11141,11142,11144],{},[3032,11143,4359],{},[3032,11145,4362],{},[3045,11147,11148,11156,11164],{},[3029,11149,11150,11153],{},[3050,11151,11152],{},"数据最新、可能更精确",[3050,11154,11155],{},"有频率限制（免费版限 QPS）",[3029,11157,11158,11161],{},[3050,11159,11160],{},"部分服务还返回经纬度\u002F时区",[3050,11162,11163],{},"依赖第三方，断网\u002F被墙\u002F限流时拿不到",[3029,11165,11166,11169],{},[3050,11167,11168],{},"无需维护数据文件",[3050,11170,11171],{},"每次查询都要发一次 HTTP 请求",[15,11173,11174,11176,11177,11180],{},[25,11175,10992],{},"：对精度要求高、且能接受外部依赖与限流的场景。",[25,11178,11179],{},"务必加超时和降级","，否则第三方一抖，你自己的页面就卡住。",[110,11182,11184],{"id":11183},"方案三maxmind-geolite2离线-mmdb","方案三：MaxMind GeoLite2（离线 .mmdb）",[3023,11186,11187,11195],{},[3026,11188,11189],{},[3029,11190,11191,11193],{},[3032,11192,4359],{},[3032,11194,4362],{},[3045,11196,11197,11205,11217],{},[3029,11198,11199,11202],{},[3050,11200,11201],{},"全球数据，覆盖广、精度较好",[3050,11203,11204],{},"免费但需注册账号并遵守 EULA；商用需 GeoIP2 商业授权",[3029,11206,11207,11214],{},[3050,11208,11209,11210,11213],{},"标准 ",[63,11211,11212],{},".mmdb"," 格式，各语言都有库，支持 IPv6",[3050,11215,11216],{},"国内 IP 的省市粒度有时不如 ip2region 细",[3029,11218,11219,11222],{},[3050,11220,11221],{},"离线查询",[3050,11223,11224],{},"数据同样要定期更新",[15,11226,11227,11229],{},[25,11228,10992],{},"：面向全球用户的场景。",[11,11231,11233],{"id":11232},"ipv6本地离线方案的一个盲点","IPv6：本地离线方案的一个盲点",[15,11235,11236,11237,11240,11241,11244],{},"前面聊的都是 IPv4（32 位）。但家宽和移动网络越来越多地",[25,11238,11239],{},"只下发 IPv6","（128 位）地址，而 ",[25,11242,11243],{},"ip2region 目前只支持 IPv4","——拿到一个纯 IPv6 地址是查不出归属地的，本地库会返回空结果。",[15,11246,11247],{},"实际影响与常见应对：",[54,11249,11250,11260,11266,11276],{},[33,11251,11252,11255,11256,11259],{},[25,11253,11254],{},"判断版本","：Go 里 ",[63,11257,11258],{},"net.ParseIP(ip).To4() != nil"," 就是 IPv4，先判断再查询；",[33,11261,11262,11265],{},[25,11263,11264],{},"降级展示","：IPv6 直接显示「IPv6 地址」，不显示属地，诚实第一；",[33,11267,11268,11271,11272,11275],{},[25,11269,11270],{},"优先取 IPv4","：有反向代理时，从 ",[63,11273,11274],{},"X-Forwarded-For"," 里挑一个 IPv4 来查；",[33,11277,11278,11281],{},[25,11279,11280],{},"需要 v6 归属","：换支持 IPv6 的数据源（如 MaxMind GeoLite2）。",[15,11283,11284],{},"这一点在接入时很容易漏掉，上线前记得拿一个纯 IPv6 地址回归一遍。",[11,11286,11288],{"id":11287},"实战给登录记录加-ip-属地","实战：给登录记录加 IP 属地",[15,11290,11291],{},"subLog 博客的登录记录就用了 ip2region。完整接入一共四步。",[110,11293,11295],{"id":11294},"_1-进程启动时加载数据文件","1. 进程启动时加载数据文件",[126,11297,11299],{"className":321,"code":11298,"language":323,"meta":131,"style":131},"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",[63,11300,11301,11307,11316,11325,11329,11333,11338,11365,11369,11382,11404,11416,11423,11427,11443,11455,11461,11465,11476,11483],{"__ignoreMap":131},[135,11302,11303,11305],{"class":137,"line":138},[135,11304,349],{"class":148},[135,11306,352],{"class":159},[135,11308,11309,11311,11314],{"class":137,"line":145},[135,11310,357],{"class":169},[135,11312,11313],{"class":155},"sync\u002Fatomic",[135,11315,363],{"class":169},[135,11317,11318,11320,11323],{"class":137,"line":163},[135,11319,357],{"class":169},[135,11321,11322],{"class":155},"github.com\u002Flionsoul2014\u002Fip2region\u002Fbinding\u002Fgolang\u002Fxdb",[135,11324,363],{"class":169},[135,11326,11327],{"class":137,"line":179},[135,11328,389],{"class":159},[135,11330,11331],{"class":137,"line":185},[135,11332,344],{"emptyLinePlaceholder":343},[135,11334,11335],{"class":137,"line":366},[135,11336,11337],{"class":141},"\u002F\u002F 用 atomic.Pointer 持有查询器，方便后面热更新\n",[135,11339,11340,11342,11345,11348,11350,11353,11355,11358,11360,11363],{"class":137,"line":376},[135,11341,406],{"class":148},[135,11343,11344],{"class":159}," searcher ",[135,11346,11347],{"class":155},"atomic",[135,11349,414],{"class":159},[135,11351,11352],{"class":155},"Pointer",[135,11354,4486],{"class":159},[135,11356,11357],{"class":155},"xdb",[135,11359,414],{"class":159},[135,11361,11362],{"class":155},"Searcher",[135,11364,2153],{"class":159},[135,11366,11367],{"class":137,"line":386},[135,11368,344],{"emptyLinePlaceholder":343},[135,11370,11371,11373,11376,11378,11380],{"class":137,"line":392},[135,11372,528],{"class":148},[135,11374,11375],{"class":155}," initSearcher",[135,11377,6519],{"class":159},[135,11379,570],{"class":148},[135,11381,488],{"class":159},[135,11383,11384,11387,11389,11392,11394,11396,11399,11401],{"class":137,"line":397},[135,11385,11386],{"class":159},"    content, err ",[135,11388,784],{"class":148},[135,11390,11391],{"class":159}," os.",[135,11393,8093],{"class":155},[135,11395,548],{"class":159},[135,11397,11398],{"class":169},"\".\u002Fdata\u002Fip2region.xdb\"",[135,11400,542],{"class":159},[135,11402,11403],{"class":141},"\u002F\u002F 约 11MB，一次性读入内存\n",[135,11405,11406,11408,11410,11412,11414],{"class":137,"line":403},[135,11407,579],{"class":148},[135,11409,5529],{"class":159},[135,11411,5532],{"class":148},[135,11413,5535],{"class":208},[135,11415,488],{"class":159},[135,11417,11418,11420],{"class":137,"line":420},[135,11419,5542],{"class":148},[135,11421,11422],{"class":159}," err\n",[135,11424,11425],{"class":137,"line":425},[135,11426,651],{"class":159},[135,11428,11429,11432,11434,11437,11440],{"class":137,"line":431},[135,11430,11431],{"class":159},"    s, err ",[135,11433,784],{"class":148},[135,11435,11436],{"class":159}," xdb.",[135,11438,11439],{"class":155},"NewWithBuffer",[135,11441,11442],{"class":159},"(content)\n",[135,11444,11445,11447,11449,11451,11453],{"class":137,"line":471},[135,11446,579],{"class":148},[135,11448,5529],{"class":159},[135,11450,5532],{"class":148},[135,11452,5535],{"class":208},[135,11454,488],{"class":159},[135,11456,11457,11459],{"class":137,"line":476},[135,11458,5542],{"class":148},[135,11460,11422],{"class":159},[135,11462,11463],{"class":137,"line":491},[135,11464,651],{"class":159},[135,11466,11467,11470,11473],{"class":137,"line":500},[135,11468,11469],{"class":159},"    searcher.",[135,11471,11472],{"class":155},"Store",[135,11474,11475],{"class":159},"(s)\n",[135,11477,11478,11480],{"class":137,"line":515},[135,11479,657],{"class":148},[135,11481,11482],{"class":208}," nil\n",[135,11484,11485],{"class":137,"line":520},[135,11486,314],{"class":159},[110,11488,11490],{"id":11489},"_2-查询一次","2. 查询一次",[126,11492,11494],{"className":321,"code":11493,"language":323,"meta":131,"style":131},"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",[63,11495,11496,11517,11536,11548,11555,11559,11569],{"__ignoreMap":131},[135,11497,11498,11500,11503,11505,11508,11510,11512,11515],{"class":137,"line":138},[135,11499,528],{"class":148},[135,11501,11502],{"class":155}," ipRegion",[135,11504,548],{"class":159},[135,11506,11507],{"class":534},"ip",[135,11509,554],{"class":148},[135,11511,542],{"class":159},[135,11513,11514],{"class":148},"string",[135,11516,488],{"class":159},[135,11518,11519,11522,11524,11526,11529,11532,11534],{"class":137,"line":145},[135,11520,11521],{"class":159},"    region, err ",[135,11523,784],{"class":148},[135,11525,10937],{"class":159},[135,11527,11528],{"class":155},"Load",[135,11530,11531],{"class":159},"().",[135,11533,10940],{"class":155},[135,11535,10943],{"class":159},[135,11537,11538,11540,11542,11544,11546],{"class":137,"line":163},[135,11539,579],{"class":148},[135,11541,5529],{"class":159},[135,11543,5532],{"class":148},[135,11545,5535],{"class":208},[135,11547,488],{"class":159},[135,11549,11550,11552],{"class":137,"line":179},[135,11551,5542],{"class":148},[135,11553,11554],{"class":169}," \"未知\"\n",[135,11556,11557],{"class":137,"line":185},[135,11558,651],{"class":159},[135,11560,11561,11563,11566],{"class":137,"line":366},[135,11562,657],{"class":148},[135,11564,11565],{"class":159}," region ",[135,11567,11568],{"class":141},"\u002F\u002F \"中国|福建省|福州市|移动\"\n",[135,11570,11571],{"class":137,"line":376},[135,11572,314],{"class":159},[110,11574,11576],{"id":11575},"_3-加一层内存缓存","3. 加一层内存缓存",[15,11578,11579,11580,11583],{},"登录记录里同一个 IP 可能反复出现，用 ",[63,11581,11582],{},"sync.Map"," 按 IP 缓存结果，一次查询终身复用：",[126,11585,11587],{"className":321,"code":11586,"language":323,"meta":131,"style":131},"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",[63,11588,11589,11604,11608,11627,11644,11655,11659,11670,11680,11687],{"__ignoreMap":131},[135,11590,11591,11593,11596,11599,11601],{"class":137,"line":138},[135,11592,406],{"class":148},[135,11594,11595],{"class":159}," regionCache ",[135,11597,11598],{"class":155},"sync",[135,11600,414],{"class":159},[135,11602,11603],{"class":155},"Map\n",[135,11605,11606],{"class":137,"line":145},[135,11607,344],{"emptyLinePlaceholder":343},[135,11609,11610,11612,11615,11617,11619,11621,11623,11625],{"class":137,"line":163},[135,11611,528],{"class":148},[135,11613,11614],{"class":155}," ipRegionCached",[135,11616,548],{"class":159},[135,11618,11507],{"class":534},[135,11620,554],{"class":148},[135,11622,542],{"class":159},[135,11624,11514],{"class":148},[135,11626,488],{"class":159},[135,11628,11629,11631,11634,11636,11639,11641],{"class":137,"line":179},[135,11630,579],{"class":148},[135,11632,11633],{"class":159}," v, ok ",[135,11635,784],{"class":148},[135,11637,11638],{"class":159}," regionCache.",[135,11640,11528],{"class":155},[135,11642,11643],{"class":159},"(ip); ok {\n",[135,11645,11646,11648,11651,11653],{"class":137,"line":185},[135,11647,5542],{"class":148},[135,11649,11650],{"class":159}," v.(",[135,11652,11514],{"class":148},[135,11654,389],{"class":159},[135,11656,11657],{"class":137,"line":366},[135,11658,651],{"class":159},[135,11660,11661,11664,11666,11668],{"class":137,"line":376},[135,11662,11663],{"class":159},"    region ",[135,11665,784],{"class":148},[135,11667,11502],{"class":155},[135,11669,10943],{"class":159},[135,11671,11672,11675,11677],{"class":137,"line":386},[135,11673,11674],{"class":159},"    regionCache.",[135,11676,11472],{"class":155},[135,11678,11679],{"class":159},"(ip, region)\n",[135,11681,11682,11684],{"class":137,"line":392},[135,11683,657],{"class":148},[135,11685,11686],{"class":159}," region\n",[135,11688,11689],{"class":137,"line":397},[135,11690,314],{"class":159},[110,11692,11694],{"id":11693},"_4-数据热更新不用重启","4. 数据热更新（不用重启）",[15,11696,11697,11698,11700,11701,11703],{},"ip2region 每月发布新数据。下载新文件后重新 ",[63,11699,11439],{}," 并 ",[63,11702,11472],{}," 覆盖即可，进程完全不用重启：",[126,11705,11707],{"className":321,"code":11706,"language":323,"meta":131,"style":131},"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",[63,11708,11709,11733,11746,11758,11764,11768,11780,11786],{"__ignoreMap":131},[135,11710,11711,11713,11716,11718,11721,11724,11727,11729,11731],{"class":137,"line":138},[135,11712,528],{"class":148},[135,11714,11715],{"class":155}," reloadSearcher",[135,11717,548],{"class":159},[135,11719,11720],{"class":534},"newContent",[135,11722,11723],{"class":159}," []",[135,11725,11726],{"class":148},"byte",[135,11728,542],{"class":159},[135,11730,570],{"class":148},[135,11732,488],{"class":159},[135,11734,11735,11737,11739,11741,11743],{"class":137,"line":145},[135,11736,11431],{"class":159},[135,11738,784],{"class":148},[135,11740,11436],{"class":159},[135,11742,11439],{"class":155},[135,11744,11745],{"class":159},"(newContent)\n",[135,11747,11748,11750,11752,11754,11756],{"class":137,"line":163},[135,11749,579],{"class":148},[135,11751,5529],{"class":159},[135,11753,5532],{"class":148},[135,11755,5535],{"class":208},[135,11757,488],{"class":159},[135,11759,11760,11762],{"class":137,"line":179},[135,11761,5542],{"class":148},[135,11763,11422],{"class":159},[135,11765,11766],{"class":137,"line":185},[135,11767,651],{"class":159},[135,11769,11770,11772,11774,11777],{"class":137,"line":366},[135,11771,11469],{"class":159},[135,11773,11472],{"class":155},[135,11775,11776],{"class":159},"(s) ",[135,11778,11779],{"class":141},"\u002F\u002F 原子切换，老查询继续走旧数据，新查询走新数据\n",[135,11781,11782,11784],{"class":137,"line":376},[135,11783,657],{"class":148},[135,11785,11482],{"class":208},[135,11787,11788],{"class":137,"line":386},[135,11789,314],{"class":159},[11,11791,11792],{"id":11792},"局限与诚实提醒",[15,11794,11795],{},"无论用哪种方案，都要清楚 IP 属地的边界：",[30,11797,11798,11804,11810,11816,11822],{},[33,11799,11800,11803],{},[25,11801,11802],{},"段级精度","：网段归属是「绝大多数」准确，个别 IP（大型企业自建段、跨省调度段）可能显示到相邻地区或只到省。",[33,11805,11806,11809],{},[25,11807,11808],{},"运营商级，不是定位","：拿到的是「省｜市｜运营商」，不是精确街道或基站位置——精确位置需要 GPS 或运营商三角定位，纯 IP 归属做不到。",[33,11811,11812,11815],{},[25,11813,11814],{},"动态 IP","：家宽通常是动态 IP，重拨会变，所以「上次登录在福州，这次在广州」不一定是异常，可能是 IP 变了。",[33,11817,11818,11821],{},[25,11819,11820],{},"别用于安全判定","：用 IP 段做封禁或风控判定风险自负，段级数据不适合当精确依据。",[33,11823,11824,11827,11828,11831],{},[25,11825,11826],{},"隐私与合规","：IP 地址属于个人信息（《个人信息保护法》）。归属地展示建议只出现在",[25,11829,11830],{},"用户本人可见","的页面（登录记录、个人中心）；若在评论区等公开场合展示，建议只显示到省市并明确告知用户。",[11,11833,11834],{"id":11834},"小结",[15,11836,11837],{},"一句话总结：",[1408,11839,11840],{},[15,11841,11842,11845],{},[25,11843,11844],{},"IP 属地 = 几十万条「IP 段 → 归属地」映射 + 一次二分查找。"," 它查的是网段归属，不是精确位置。",[30,11847,11848,11854,11861],{},[33,11849,11850,11851,11853],{},"想省心、免费、稳定，用 ",[25,11852,10825],{}," 本地离线查询（注意 IPv6 盲点）；",[33,11855,11856,11857,11860],{},"想要最新最细、能接受外部依赖，用",[25,11858,11859],{},"在线 API","（记得超时降级）；",[33,11862,11863,11864,1311],{},"面向全球用户、要 IPv6 覆盖，用 ",[25,11865,11866],{},"MaxMind GeoLite2",[15,11868,11869],{},"对个人博客「登录记录显示归属地」这种场景，本地离线方案通常是性价比最高的选择。",[1665,11871,11872],{},"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":131,"searchDepth":145,"depth":163,"links":11874},[11875,11876,11877,11883,11888,11889,11895,11896],{"id":10182,"depth":145,"text":10182},{"id":10772,"depth":145,"text":10773},{"id":10819,"depth":145,"text":10819,"children":11878},[11879,11880,11881,11882],{"id":10829,"depth":163,"text":10830},{"id":10844,"depth":163,"text":10845},{"id":10862,"depth":163,"text":10863},{"id":10881,"depth":163,"text":10882},{"id":10910,"depth":145,"text":10910,"children":11884},[11885,11886,11887],{"id":10916,"depth":163,"text":10917},{"id":10996,"depth":163,"text":10997},{"id":11183,"depth":163,"text":11184},{"id":11232,"depth":145,"text":11233},{"id":11287,"depth":145,"text":11288,"children":11890},[11891,11892,11893,11894],{"id":11294,"depth":163,"text":11295},{"id":11489,"depth":163,"text":11490},{"id":11575,"depth":163,"text":11576},{"id":11693,"depth":163,"text":11694},{"id":11792,"depth":145,"text":11792},{"id":11834,"depth":145,"text":11834},"科普 IP 属地查询的原理：IP 段与归属地的映射、纯本地离线查询为何能做到毫秒级，对比 ip2region、在线 API 与 MaxMind 三种方案的优劣，并附登录记录场景的 Go 实战与 IPv6 盲点提醒。","\u002Fposts\u002Fip-geolocation-guide\u002Fimg\u002Fcover.svg",{},"\u002Fposts\u002Fip-geolocation-guide","2026-08-07T12:00:00",{"title":10736,"description":11897},"posts\u002Fip-geolocation-guide\u002Findex",[11905,11906,11907],"IP","网络","科普","RY1UPgJaiKsjpC51uqMyG7w_GpCRS13_CX5j1hDD_dM",{"id":4,"title":5,"author":6,"body":11910,"description":1689,"difficulty":1690,"draft":1691,"extension":1692,"image":1693,"meta":13130,"navigation":343,"path":1695,"pinned":1691,"published":1696,"seo":13131,"series":1698,"seriesOrder":163,"stem":1699,"tags":13132,"updated":1705,"__hash__":1706},{"type":8,"value":11911,"toc":13110},[11912,11914,11920,11928,11932,11934,11956,11958,11964,11966,11968,11974,12010,12046,12056,12058,12070,12096,12098,12356,12358,12384,12386,12692,12694,12696,12860,12866,12868,12874,12884,12894,12902,12904,12908,12918,12926,12930,12932,12942,12946,12988,12996,13002,13004,13014,13020,13036,13042,13044,13050,13052,13074,13080,13082,13084,13104,13108],[11,11913,13],{"id":13},[15,11915,17,11916,23,11918,28],{},[19,11917,22],{"href":21},[25,11919,27],{},[30,11921,11922,11924],{},[33,11923,35],{},[33,11925,38,11926,42],{},[25,11927,41],{},[15,11929,45,11930,49],{},[25,11931,48],{},[15,11933,52],{},[54,11935,11936,11944,11952],{},[33,11937,58,11938,66,11942,70],{},[25,11939,61,11940],{},[63,11941,65],{},[25,11943,69],{},[33,11945,73,11946,80,11950,84],{},[25,11947,11948,79],{},[63,11949,78],{},[63,11951,83],{},[33,11953,87,11954,91],{},[25,11955,90],{},[11,11957,94],{"id":94},[15,11959,97,11960,101,11962,105],{},[63,11961,100],{},[63,11963,104],{},[11,11965,108],{"id":108},[110,11967,113],{"id":112},[15,11969,116,11970,120,11972,124],{},[63,11971,119],{},[63,11973,123],{},[126,11975,11976],{"className":128,"code":129,"language":130,"meta":131,"style":131},[63,11977,11978,11982,11992,12002,12006],{"__ignoreMap":131},[135,11979,11980],{"class":137,"line":138},[135,11981,142],{"class":141},[135,11983,11984,11986,11988,11990],{"class":137,"line":145},[135,11985,149],{"class":148},[135,11987,152],{"class":148},[135,11989,156],{"class":155},[135,11991,160],{"class":159},[135,11993,11994,11996,11998,12000],{"class":137,"line":163},[135,11995,166],{"class":159},[135,11997,170],{"class":169},[135,11999,173],{"class":159},[135,12001,176],{"class":141},[135,12003,12004],{"class":137,"line":179},[135,12005,182],{"class":148},[135,12007,12008],{"class":137,"line":185},[135,12009,188],{"class":159},[126,12011,12012],{"className":191,"code":192,"language":193,"meta":131,"style":131},[63,12013,12014,12018,12026,12038],{"__ignoreMap":131},[135,12015,12016],{"class":137,"line":138},[135,12017,200],{"class":141},[135,12019,12020,12022,12024],{"class":137,"line":145},[135,12021,205],{"class":159},[135,12023,123],{"class":208},[135,12025,211],{"class":159},[135,12027,12028,12030,12032,12034,12036],{"class":137,"line":163},[135,12029,216],{"class":159},[135,12031,219],{"class":208},[135,12033,222],{"class":159},[135,12035,219],{"class":208},[135,12037,211],{"class":159},[135,12039,12040,12042,12044],{"class":137,"line":179},[135,12041,231],{"class":159},[135,12043,123],{"class":208},[135,12045,211],{"class":159},[15,12047,12048,241,12050,245,12052,249,12054,253],{},[25,12049,240],{},[63,12051,244],{},[63,12053,248],{},[25,12055,252],{},[110,12057,257],{"id":256},[15,12059,12060,263,12062,266,12064,270,12066,274,12068,278],{},[63,12061,262],{},[63,12063,104],{},[63,12065,269],{},[63,12067,273],{},[63,12069,277],{},[126,12071,12072],{"className":281,"code":282,"language":283,"meta":131,"style":131},[63,12073,12074,12078,12084,12092],{"__ignoreMap":131},[135,12075,12076],{"class":137,"line":138},[135,12077,290],{"class":141},[135,12079,12080,12082],{"class":137,"line":145},[135,12081,295],{"class":169},[135,12083,298],{"class":159},[135,12085,12086,12088,12090],{"class":137,"line":163},[135,12087,303],{"class":208},[135,12089,306],{"class":159},[135,12091,309],{"class":169},[135,12093,12094],{"class":137,"line":179},[135,12095,314],{"class":159},[110,12097,318],{"id":317},[126,12099,12100],{"className":321,"code":322,"language":323,"meta":131,"style":131},[63,12101,12102,12106,12112,12116,12122,12130,12138,12146,12150,12154,12158,12170,12174,12178,12206,12210,12220,12226,12238,12242,12246,12282,12294,12302,12310,12334,12338,12352],{"__ignoreMap":131},[135,12103,12104],{"class":137,"line":138},[135,12105,330],{"class":141},[135,12107,12108,12110],{"class":137,"line":145},[135,12109,335],{"class":148},[135,12111,338],{"class":155},[135,12113,12114],{"class":137,"line":163},[135,12115,344],{"emptyLinePlaceholder":343},[135,12117,12118,12120],{"class":137,"line":179},[135,12119,349],{"class":148},[135,12121,352],{"class":159},[135,12123,12124,12126,12128],{"class":137,"line":185},[135,12125,357],{"class":169},[135,12127,360],{"class":155},[135,12129,363],{"class":169},[135,12131,12132,12134,12136],{"class":137,"line":366},[135,12133,357],{"class":169},[135,12135,371],{"class":155},[135,12137,363],{"class":169},[135,12139,12140,12142,12144],{"class":137,"line":376},[135,12141,357],{"class":169},[135,12143,381],{"class":155},[135,12145,363],{"class":169},[135,12147,12148],{"class":137,"line":386},[135,12149,389],{"class":159},[135,12151,12152],{"class":137,"line":392},[135,12153,344],{"emptyLinePlaceholder":343},[135,12155,12156],{"class":137,"line":397},[135,12157,400],{"class":141},[135,12159,12160,12162,12164,12166,12168],{"class":137,"line":403},[135,12161,406],{"class":148},[135,12163,409],{"class":159},[135,12165,360],{"class":155},[135,12167,414],{"class":159},[135,12169,417],{"class":155},[135,12171,12172],{"class":137,"line":420},[135,12173,344],{"emptyLinePlaceholder":343},[135,12175,12176],{"class":137,"line":425},[135,12177,428],{"class":141},[135,12179,12180,12182,12184,12186,12188,12190,12192,12194,12196,12198,12200,12202,12204],{"class":137,"line":431},[135,12181,406],{"class":148},[135,12183,436],{"class":159},[135,12185,439],{"class":155},[135,12187,414],{"class":159},[135,12189,444],{"class":155},[135,12191,447],{"class":148},[135,12193,450],{"class":148},[135,12195,453],{"class":155},[135,12197,456],{"class":159},[135,12199,459],{"class":169},[135,12201,462],{"class":159},[135,12203,465],{"class":148},[135,12205,468],{"class":159},[135,12207,12208],{"class":137,"line":471},[135,12209,344],{"emptyLinePlaceholder":343},[135,12211,12212,12214,12216,12218],{"class":137,"line":476},[135,12213,479],{"class":148},[135,12215,482],{"class":155},[135,12217,485],{"class":148},[135,12219,488],{"class":159},[135,12221,12222,12224],{"class":137,"line":491},[135,12223,494],{"class":159},[135,12225,497],{"class":148},[135,12227,12228,12230,12232,12234,12236],{"class":137,"line":500},[135,12229,503],{"class":159},[135,12231,506],{"class":148},[135,12233,360],{"class":155},[135,12235,414],{"class":159},[135,12237,417],{"class":155},[135,12239,12240],{"class":137,"line":515},[135,12241,314],{"class":159},[135,12243,12244],{"class":137,"line":520},[135,12245,344],{"emptyLinePlaceholder":343},[135,12247,12248,12250,12252,12254,12256,12258,12260,12262,12264,12266,12268,12270,12272,12274,12276,12278,12280],{"class":137,"line":525},[135,12249,528],{"class":148},[135,12251,531],{"class":159},[135,12253,535],{"class":534},[135,12255,506],{"class":148},[135,12257,453],{"class":155},[135,12259,542],{"class":159},[135,12261,545],{"class":155},[135,12263,548],{"class":159},[135,12265,551],{"class":534},[135,12267,554],{"class":148},[135,12269,557],{"class":159},[135,12271,439],{"class":155},[135,12273,414],{"class":159},[135,12275,564],{"class":155},[135,12277,567],{"class":159},[135,12279,570],{"class":148},[135,12281,573],{"class":159},[135,12283,12284,12286,12288,12290,12292],{"class":137,"line":576},[135,12285,579],{"class":148},[135,12287,582],{"class":159},[135,12289,585],{"class":148},[135,12291,588],{"class":169},[135,12293,488],{"class":159},[135,12295,12296,12298,12300],{"class":137,"line":593},[135,12297,596],{"class":159},[135,12299,599],{"class":148},[135,12301,602],{"class":159},[135,12303,12304,12306,12308],{"class":137,"line":605},[135,12305,608],{"class":159},[135,12307,611],{"class":148},[135,12309,488],{"class":159},[135,12311,12312,12314,12316,12318,12320,12322,12324,12326,12328,12330,12332],{"class":137,"line":616},[135,12313,596],{"class":159},[135,12315,599],{"class":148},[135,12317,623],{"class":159},[135,12319,626],{"class":148},[135,12321,588],{"class":169},[135,12323,631],{"class":148},[135,12325,634],{"class":159},[135,12327,637],{"class":155},[135,12329,640],{"class":159},[135,12331,643],{"class":169},[135,12333,389],{"class":159},[135,12335,12336],{"class":137,"line":648},[135,12337,651],{"class":159},[135,12339,12340,12342,12344,12346,12348,12350],{"class":137,"line":654},[135,12341,657],{"class":148},[135,12343,660],{"class":159},[135,12345,663],{"class":155},[135,12347,666],{"class":159},[135,12349,545],{"class":155},[135,12351,671],{"class":159},[135,12353,12354],{"class":137,"line":674},[135,12355,314],{"class":159},[15,12357,679],{},[30,12359,12360,12376],{},[33,12361,12362,692,12368,695,12370,698,12372,701,12374,705],{},[25,12363,12364,689,12366,79],{},[63,12365,688],{},[63,12367,78],{},[63,12369,83],{},[63,12371,414],{},[63,12373,78],{},[63,12375,704],{},[33,12377,12378,711,12380,714,12382,718],{},[63,12379,710],{},[63,12381,104],{},[63,12383,717],{},[110,12385,722],{"id":721},[126,12387,12388],{"className":321,"code":725,"language":323,"meta":131,"style":131},[63,12389,12390,12394,12430,12442,12446,12458,12498,12526,12542,12562,12566,12582,12600,12604,12620,12624,12640,12644,12684,12688],{"__ignoreMap":131},[135,12391,12392],{"class":137,"line":138},[135,12393,732],{"class":141},[135,12395,12396,12398,12400,12402,12404,12406,12408,12410,12412,12414,12416,12418,12420,12422,12424,12426,12428],{"class":137,"line":145},[135,12397,528],{"class":148},[135,12399,739],{"class":155},[135,12401,548],{"class":159},[135,12403,744],{"class":534},[135,12405,747],{"class":148},[135,12407,750],{"class":155},[135,12409,414],{"class":159},[135,12411,755],{"class":155},[135,12413,567],{"class":159},[135,12415,760],{"class":534},[135,12417,554],{"class":148},[135,12419,542],{"class":159},[135,12421,506],{"class":148},[135,12423,769],{"class":155},[135,12425,414],{"class":159},[135,12427,774],{"class":155},[135,12429,488],{"class":159},[135,12431,12432,12434,12436,12438,12440],{"class":137,"line":163},[135,12433,781],{"class":159},[135,12435,784],{"class":148},[135,12437,787],{"class":159},[135,12439,790],{"class":155},[135,12441,793],{"class":159},[135,12443,12444],{"class":137,"line":179},[135,12445,798],{"class":148},[135,12447,12448,12450,12452,12454,12456],{"class":137,"line":185},[135,12449,803],{"class":159},[135,12451,784],{"class":148},[135,12453,660],{"class":159},[135,12455,810],{"class":155},[135,12457,813],{"class":159},[135,12459,12460,12462,12464,12466,12468,12470,12472,12474,12476,12478,12480,12482,12484,12486,12488,12490,12492,12494,12496],{"class":137,"line":366},[135,12461,818],{"class":159},[135,12463,821],{"class":155},[135,12465,548],{"class":159},[135,12467,826],{"class":169},[135,12469,567],{"class":159},[135,12471,528],{"class":148},[135,12473,548],{"class":159},[135,12475,835],{"class":534},[135,12477,747],{"class":148},[135,12479,769],{"class":155},[135,12481,414],{"class":159},[135,12483,844],{"class":155},[135,12485,847],{"class":159},[135,12487,850],{"class":155},[135,12489,548],{"class":159},[135,12491,855],{"class":208},[135,12493,567],{"class":159},[135,12495,170],{"class":169},[135,12497,862],{"class":159},[135,12499,12500,12502,12504,12506,12508,12510,12512,12514,12516,12518,12520,12522,12524],{"class":137,"line":376},[135,12501,818],{"class":159},[135,12503,821],{"class":155},[135,12505,548],{"class":159},[135,12507,873],{"class":169},[135,12509,567],{"class":159},[135,12511,528],{"class":148},[135,12513,548],{"class":159},[135,12515,835],{"class":534},[135,12517,747],{"class":148},[135,12519,769],{"class":155},[135,12521,414],{"class":159},[135,12523,844],{"class":155},[135,12525,573],{"class":159},[135,12527,12528,12530,12532,12534,12536,12538,12540],{"class":137,"line":386},[135,12529,896],{"class":159},[135,12531,784],{"class":148},[135,12533,901],{"class":159},[135,12535,904],{"class":155},[135,12537,548],{"class":159},[135,12539,909],{"class":169},[135,12541,389],{"class":159},[135,12543,12544,12546,12548,12550,12552,12554,12556,12558,12560],{"class":137,"line":392},[135,12545,916],{"class":148},[135,12547,919],{"class":159},[135,12549,585],{"class":148},[135,12551,588],{"class":169},[135,12553,926],{"class":148},[135,12555,919],{"class":159},[135,12557,585],{"class":148},[135,12559,933],{"class":169},[135,12561,488],{"class":159},[135,12563,12564],{"class":137,"line":397},[135,12565,940],{"class":141},[135,12567,12568,12570,12572,12574,12576,12578,12580],{"class":137,"line":403},[135,12569,945],{"class":159},[135,12571,948],{"class":155},[135,12573,548],{"class":159},[135,12575,953],{"class":169},[135,12577,567],{"class":159},[135,12579,958],{"class":169},[135,12581,389],{"class":159},[135,12583,12584,12586,12588,12590,12592,12594,12596,12598],{"class":137,"line":420},[135,12585,965],{"class":159},[135,12587,611],{"class":148},[135,12589,970],{"class":148},[135,12591,634],{"class":159},[135,12593,975],{"class":155},[135,12595,978],{"class":159},[135,12597,981],{"class":169},[135,12599,573],{"class":159},[135,12601,12602],{"class":137,"line":425},[135,12603,988],{"class":141},[135,12605,12606,12608,12610,12612,12614,12616,12618],{"class":137,"line":431},[135,12607,945],{"class":159},[135,12609,948],{"class":155},[135,12611,548],{"class":159},[135,12613,953],{"class":169},[135,12615,567],{"class":159},[135,12617,1003],{"class":169},[135,12619,389],{"class":159},[135,12621,12622],{"class":137,"line":471},[135,12623,1010],{"class":159},[135,12625,12626,12628,12630,12632,12634,12636,12638],{"class":137,"line":476},[135,12627,1015],{"class":159},[135,12629,1018],{"class":155},[135,12631,548],{"class":159},[135,12633,826],{"class":169},[135,12635,1025],{"class":159},[135,12637,1028],{"class":155},[135,12639,1031],{"class":159},[135,12641,12642],{"class":137,"line":491},[135,12643,1036],{"class":159},[135,12645,12646,12648,12650,12652,12654,12656,12658,12660,12662,12664,12666,12668,12670,12672,12674,12676,12678,12680,12682],{"class":137,"line":500},[135,12647,818],{"class":159},[135,12649,821],{"class":155},[135,12651,548],{"class":159},[135,12653,643],{"class":169},[135,12655,567],{"class":159},[135,12657,528],{"class":148},[135,12659,548],{"class":159},[135,12661,835],{"class":534},[135,12663,747],{"class":148},[135,12665,769],{"class":155},[135,12667,414],{"class":159},[135,12669,844],{"class":155},[135,12671,847],{"class":159},[135,12673,850],{"class":155},[135,12675,548],{"class":159},[135,12677,855],{"class":208},[135,12679,567],{"class":159},[135,12681,170],{"class":169},[135,12683,862],{"class":159},[135,12685,12686],{"class":137,"line":515},[135,12687,798],{"class":148},[135,12689,12690],{"class":137,"line":520},[135,12691,314],{"class":159},[15,12693,1087],{},[110,12695,1091],{"id":1090},[126,12697,12698],{"className":1094,"code":1095,"language":1096,"meta":131,"style":131},[63,12699,12700,12704,12708,12716,12730,12734,12750,12754,12760,12778,12782,12788,12834,12846,12850],{"__ignoreMap":131},[135,12701,12702],{"class":137,"line":138},[135,12703,1103],{"class":141},[135,12705,12706],{"class":137,"line":145},[135,12707,1108],{"class":141},[135,12709,12710,12712,12714],{"class":137,"line":163},[135,12711,1113],{"class":208},[135,12713,1116],{"class":208},[135,12715,1119],{"class":169},[135,12717,12718,12720,12722,12724,12726,12728],{"class":137,"line":179},[135,12719,1124],{"class":208},[135,12721,1127],{"class":169},[135,12723,1130],{"class":155},[135,12725,1133],{"class":169},[135,12727,1136],{"class":208},[135,12729,1139],{"class":169},[135,12731,12732],{"class":137,"line":185},[135,12733,344],{"emptyLinePlaceholder":343},[135,12735,12736,12738,12740,12742,12744,12746,12748],{"class":137,"line":366},[135,12737,1148],{"class":159},[135,12739,599],{"class":148},[135,12741,1153],{"class":169},[135,12743,1148],{"class":159},[135,12745,1158],{"class":148},[135,12747,1161],{"class":159},[135,12749,1164],{"class":169},[135,12751,12752],{"class":137,"line":376},[135,12753,344],{"emptyLinePlaceholder":343},[135,12755,12756,12758],{"class":137,"line":386},[135,12757,1173],{"class":208},[135,12759,1176],{"class":169},[135,12761,12762,12764,12766,12768,12770,12772,12774,12776],{"class":137,"line":392},[135,12763,548],{"class":159},[135,12765,1124],{"class":208},[135,12767,1185],{"class":169},[135,12769,1188],{"class":159},[135,12771,1191],{"class":155},[135,12773,1194],{"class":169},[135,12775,1197],{"class":169},[135,12777,389],{"class":159},[135,12779,12780],{"class":137,"line":397},[135,12781,344],{"emptyLinePlaceholder":343},[135,12783,12784,12786],{"class":137,"line":403},[135,12785,1173],{"class":208},[135,12787,1210],{"class":169},[135,12789,12790,12792,12794,12796,12798,12800,12802,12804,12806,12808,12810,12812,12814,12816,12818,12820,12822,12824,12826,12828,12830,12832],{"class":137,"line":420},[135,12791,548],{"class":159},[135,12793,1124],{"class":208},[135,12795,1219],{"class":169},[135,12797,1222],{"class":159},[135,12799,599],{"class":148},[135,12801,1227],{"class":169},[135,12803,1230],{"class":159},[135,12805,599],{"class":148},[135,12807,1235],{"class":169},[135,12809,1238],{"class":159},[135,12811,599],{"class":148},[135,12813,1243],{"class":169},[135,12815,1246],{"class":155},[135,12817,1197],{"class":169},[135,12819,1251],{"class":208},[135,12821,1133],{"class":169},[135,12823,1256],{"class":159},[135,12825,1259],{"class":169},[135,12827,1262],{"class":208},[135,12829,1265],{"class":169},[135,12831,1268],{"class":169},[135,12833,389],{"class":159},[135,12835,12836,12838,12840,12842,12844],{"class":137,"line":425},[135,12837,1275],{"class":155},[135,12839,1278],{"class":169},[135,12841,1281],{"class":169},[135,12843,1256],{"class":159},[135,12845,363],{"class":169},[135,12847,12848],{"class":137,"line":431},[135,12849,344],{"emptyLinePlaceholder":343},[135,12851,12852,12854,12856,12858],{"class":137,"line":471},[135,12853,1173],{"class":208},[135,12855,1296],{"class":169},[135,12857,1256],{"class":159},[135,12859,363],{"class":169},[15,12861,1303,12862,1307,12864,1311],{},[63,12863,1306],{},[63,12865,1310],{},[11,12867,1314],{"id":1314},[110,12869,1318,12870,1322,12872,1325],{"id":1317},[63,12871,1321],{},[63,12873,83],{},[15,12875,1328,12876,1337,12882,1340],{},[25,12877,1331,12878,695,12880,1336],{},[63,12879,83],{},[63,12881,414],{},[63,12883,83],{},[126,12885,12886],{"className":321,"code":1343,"language":323,"meta":131,"style":131},[63,12887,12888],{"__ignoreMap":131},[135,12889,12890,12892],{"class":137,"line":138},[135,12891,1350],{"class":141},[135,12893,1353],{"class":141},[15,12895,12896],{},[25,12897,1358,12898,1361,12900,1364],{},[63,12899,78],{},[63,12901,78],{},[110,12903,1368],{"id":1367},[15,12905,1371,12906,1375],{},[63,12907,1374],{},[15,12909,1378,12910,1382,12912,1386,12914,1392],{},[63,12911,1381],{},[25,12913,1385],{},[25,12915,1389,12916],{},[63,12917,244],{},[15,12919,12920,1398,12922,1402,12924,1406],{},[25,12921,1397],{},[63,12923,1401],{},[63,12925,1405],{},[1408,12927,12928],{},[15,12929,1412],{},[110,12931,1416],{"id":1415},[15,12933,1419,12934,1423,12936,1430,12940,1434],{},[63,12935,1422],{},[25,12937,1426,12938],{},[63,12939,1429],{},[63,12941,1433],{},[15,12943,12944,1439],{},[25,12945,1397],{},[126,12947,12948],{"className":1094,"code":1442,"language":1096,"meta":131,"style":131},[63,12949,12950],{"__ignoreMap":131},[135,12951,12952,12954,12956,12958,12960,12962,12964,12966,12968,12970,12972,12974,12976,12978,12980,12982,12984,12986],{"class":137,"line":138},[135,12953,1449],{"class":159},[135,12955,599],{"class":148},[135,12957,1227],{"class":169},[135,12959,1230],{"class":159},[135,12961,599],{"class":148},[135,12963,1235],{"class":169},[135,12965,1238],{"class":159},[135,12967,599],{"class":148},[135,12969,1243],{"class":169},[135,12971,1246],{"class":155},[135,12973,1197],{"class":169},[135,12975,1251],{"class":208},[135,12977,1133],{"class":169},[135,12979,1256],{"class":159},[135,12981,1259],{"class":169},[135,12983,1262],{"class":208},[135,12985,1265],{"class":169},[135,12987,1484],{"class":169},[15,12989,1487,12990,1491,12992,1495,12994],{},[25,12991,1490],{},[63,12993,1494],{},[25,12995,1498],{},[15,12997,1501,12998,1505,13000,1509],{},[63,12999,1504],{},[63,13001,1508],{},[110,13003,1513],{"id":1512},[15,13005,13006,1518,13008,1521,13010,1525,13012,1528],{},[63,13007,269],{},[63,13009,100],{},[25,13011,1524],{},[63,13013,1381],{},[15,13015,13016,1533,13018,1537],{},[25,13017,1397],{},[63,13019,1536],{},[126,13021,13022],{"className":1540,"code":1541,"language":1542,"meta":131,"style":131},[63,13023,13024,13028,13032],{"__ignoreMap":131},[135,13025,13026],{"class":137,"line":138},[135,13027,1549],{},[135,13029,13030],{"class":137,"line":145},[135,13031,1554],{},[135,13033,13034],{"class":137,"line":163},[135,13035,1559],{},[15,13037,1562,13038,1565,13040,1568],{},[63,13039,1381],{},[63,13041,262],{},[110,13043,1572],{"id":1571},[15,13045,1575,13046,1579,13048,1311],{},[25,13047,1578],{},[63,13049,1582],{},[11,13051,1585],{"id":1585},[126,13053,13054],{"className":1094,"code":1588,"language":1096,"meta":131,"style":131},[63,13055,13056,13064],{"__ignoreMap":131},[135,13057,13058,13060,13062],{"class":137,"line":138},[135,13059,1595],{"class":155},[135,13061,1598],{"class":169},[135,13063,1601],{"class":141},[135,13065,13066,13068,13070,13072],{"class":137,"line":145},[135,13067,1595],{"class":155},[135,13069,1608],{"class":169},[135,13071,1611],{"class":169},[135,13073,1614],{"class":141},[15,13075,1617,13076,1621,13078,1311],{},[63,13077,1620],{},[63,13079,248],{},[11,13081,1626],{"id":1626},[15,13083,1629],{},[54,13085,13086,13090,13098],{},[33,13087,13088,1637],{},[25,13089,1636],{},[33,13091,13092,124,13096,1647],{},[25,13093,13094,1644],{},[63,13095,78],{},[63,13097,83],{},[33,13099,13100,1656],{},[25,13101,1652,13102,1655],{},[63,13103,1429],{},[15,13105,1659,13106,1663],{},[25,13107,1662],{},[1665,13109,1667],{},{"title":131,"searchDepth":145,"depth":163,"links":13111},[13112,13113,13114,13121,13128,13129],{"id":13,"depth":145,"text":13},{"id":94,"depth":145,"text":94},{"id":108,"depth":145,"text":108,"children":13115},[13116,13117,13118,13119,13120],{"id":112,"depth":163,"text":113},{"id":256,"depth":163,"text":257},{"id":317,"depth":163,"text":318},{"id":721,"depth":163,"text":722},{"id":1090,"depth":163,"text":1091},{"id":1314,"depth":145,"text":1314,"children":13122},[13123,13124,13125,13126,13127],{"id":1317,"depth":163,"text":1682},{"id":1367,"depth":163,"text":1368},{"id":1415,"depth":163,"text":1416},{"id":1512,"depth":163,"text":1513},{"id":1571,"depth":163,"text":1572},{"id":1585,"depth":145,"text":1585},{"id":1626,"depth":145,"text":1626},{},{"title":5,"description":1689},[1701,1702,360,1703,1704],{"id":13134,"title":13135,"author":6,"body":13136,"description":14381,"difficulty":2901,"draft":1691,"extension":1692,"image":14382,"meta":14383,"navigation":343,"path":14384,"pinned":1691,"published":1696,"seo":14385,"series":14386,"seriesOrder":138,"stem":14387,"tags":14388,"updated":1705,"__hash__":14392},"posts\u002Fposts\u002Freact-photo-album-masonry\u002Findex.md","用 react-photo-album 给生图图库做瀑布流排版",{"type":8,"value":13137,"toc":14364},[13138,13141,13144,13162,13169,13233,13244,13247,13270,13273,13277,13291,13295,13311,13489,13493,13890,13893,13927,13930,13934,13944,13950,13975,13980,13991,13997,14008,14011,14051,14064,14187,14199,14203,14210,14228,14233,14323,14329,14333,14344,14347,14350,14356,14361],[11,13139,13140],{"id":13140},"技术背景",[15,13142,13143],{},"Magic Img 是我做的一个 AI 生图提示词管理工具（自托管、本地优先），其中「生图图库」页面要把所有生成图片以图片墙的形式展示出来。图片墙有两个天然矛盾：",[54,13145,13146,13152],{},[33,13147,13148,13151],{},[25,13149,13150],{},"等宽网格","（CSS Grid）：整齐，但每行高度由最高的那张决定——不同比例的图片混排时，矮图下面会留下大洞，看起来\"很乱\"；",[33,13153,13154,13157,13158,13161],{},[25,13155,13156],{},"原生比例展示","：图库必须不裁剪（这是产品约束），所以不能用 ",[63,13159,13160],{},"object-cover"," 强行统一比例。",[15,13163,13164,13165,13168],{},"业内的标准解法是 ",[25,13166,13167],{},"Masonry 瀑布流","：按列紧密堆放，每张图保持原始比例，列间高度错落但整体紧凑。实现瀑布流的常见方案有三类：",[3023,13170,13171,13185],{},[3026,13172,13173],{},[3029,13174,13175,13177,13180,13183],{},[3032,13176,4356],{},[3032,13178,13179],{},"依赖",[3032,13181,13182],{},"顺序",[3032,13184,6942],{},[3045,13186,13187,13204,13217],{},[3029,13188,13189,13196,13198,13201],{},[3050,13190,13191,13192,13195],{},"CSS ",[63,13193,13194],{},"columns"," 多列",[3050,13197,3081],{},[3050,13199,13200],{},"纵向阅读序（先下后右）",[3050,13202,13203],{},"最新图片不再出现在左上角，时间语义丢失",[3029,13205,13206,13209,13211,13214],{},[3050,13207,13208],{},"grid row-span 自研",[3050,13210,3081],{},[3050,13212,13213],{},"保持",[3050,13215,13216],{},"需固定列数 + 固定行高，逻辑要自己写",[3029,13218,13219,13224,13227,13230],{},[3050,13220,13221],{},[25,13222,13223],{},"react-photo-album",[3050,13225,13226],{},"~4KB，无依赖",[3050,13228,13229],{},"基本保持（逐列最短列填充）",[3050,13231,13232],{},"专为相册墙设计，TS 友好，维护活跃",[15,13234,13235,13236,13239,13240,13243],{},"最终选了 ",[25,13237,13238],{},"react-photo-album v3","：体积小、类型齐全，而且它的 Masonry 模式恰好需要「每张图的 ",[63,13241,13242],{},"width\u002Fheight","」——我们的数据库里本来就存了，零额外成本。",[11,13245,13246],{"id":13246},"需求梳理",[54,13248,13249,13255,13261,13264,13267],{},[33,13250,13251,13252,1505],{},"展示全部生成图（21 张起，会持续增长），",[25,13253,13254],{},"原比例、不裁剪",[33,13256,13257,13258,1505],{},"不同尺寸（2304×1728、4608×3456 等）混排时",[25,13259,13260],{},"紧凑、不留洞",[33,13262,13263],{},"顺序基本保持「新图靠前」；",[33,13265,13266],{},"点击任意一张打开大图预览（YARL 灯箱），悬停显示所属提示词；",[33,13268,13269],{},"平板性能：墙上只用 300px 缩略图，原图只进灯箱按需加载。",[11,13271,13272],{"id":13272},"安装与使用",[110,13274,13276],{"id":13275},"_1-安装","1. 安装",[126,13278,13280],{"className":1094,"code":13279,"language":1096,"meta":131,"style":131},"npm install react-photo-album\n",[63,13281,13282],{"__ignoreMap":131},[135,13283,13284,13286,13288],{"class":137,"line":138},[135,13285,1191],{"class":155},[135,13287,1791],{"class":169},[135,13289,13290],{"class":169}," react-photo-album\n",[110,13292,13294],{"id":13293},"_2-准备数据关键输入是宽高","2. 准备数据（关键输入是宽高）",[15,13296,13297,13298,13301,13302,8445,13304,8445,13307,13310],{},"库要求的 ",[63,13299,13300],{},"Photo"," 对象只有三个必需字段：",[63,13303,4220],{},[63,13305,13306],{},"width",[63,13308,13309],{},"height","。我们的接口本来就返回每张图的原始宽高：",[126,13312,13314],{"className":191,"code":13313,"language":193,"meta":131,"style":131},"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",[63,13315,13316,13334,13339,13346,13350,13366,13376,13380,13384,13417,13428,13447,13452,13457,13479,13484],{"__ignoreMap":131},[135,13317,13318,13320,13323,13325,13328,13331],{"class":137,"line":138},[135,13319,349],{"class":148},[135,13321,13322],{"class":159}," PhotoAlbum, { ",[135,13324,479],{"class":148},[135,13326,13327],{"class":159}," Photo } ",[135,13329,13330],{"class":148},"from",[135,13332,13333],{"class":169}," \"react-photo-album\"\n",[135,13335,13336],{"class":137,"line":145},[135,13337,13338],{"class":141},"\u002F\u002F 必须引入样式表！容器\u002F列的布局规则都在这里（坑 1 详述）\n",[135,13340,13341,13343],{"class":137,"line":163},[135,13342,349],{"class":148},[135,13344,13345],{"class":169}," \"react-photo-album\u002Fstyles.css\"\n",[135,13347,13348],{"class":137,"line":179},[135,13349,344],{"emptyLinePlaceholder":343},[135,13351,13352,13355,13358,13361,13364],{"class":137,"line":185},[135,13353,13354],{"class":148},"interface",[135,13356,13357],{"class":155}," GalleryPhoto",[135,13359,13360],{"class":148}," extends",[135,13362,13363],{"class":155}," Photo",[135,13365,488],{"class":159},[135,13367,13368,13371,13374],{"class":137,"line":366},[135,13369,13370],{"class":534},"  prompt_text",[135,13372,13373],{"class":148},"?:",[135,13375,6470],{"class":208},[135,13377,13378],{"class":137,"line":376},[135,13379,314],{"class":159},[135,13381,13382],{"class":137,"line":386},[135,13383,344],{"emptyLinePlaceholder":343},[135,13385,13386,13388,13391,13393,13395,13397,13399,13402,13405,13408,13410,13412,13414],{"class":137,"line":392},[135,13387,3916],{"class":148},[135,13389,13390],{"class":208}," albumPhotos",[135,13392,2622],{"class":148},[135,13394,13357],{"class":155},[135,13396,3710],{"class":159},[135,13398,599],{"class":148},[135,13400,13401],{"class":159}," images.",[135,13403,13404],{"class":155},"map",[135,13406,13407],{"class":159},"((",[135,13409,1964],{"class":534},[135,13411,542],{"class":159},[135,13413,4109],{"class":148},[135,13415,13416],{"class":159}," ({\n",[135,13418,13419,13422,13425],{"class":137,"line":397},[135,13420,13421],{"class":159},"  key: ",[135,13423,13424],{"class":155},"String",[135,13426,13427],{"class":159},"(img.id),\n",[135,13429,13430,13433,13436,13439,13441,13444],{"class":137,"line":403},[135,13431,13432],{"class":159},"  src: ",[135,13434,13435],{"class":155},"assetUrl",[135,13437,13438],{"class":159},"(img.thumb_path) ",[135,13440,3896],{"class":148},[135,13442,13443],{"class":155}," assetUrl",[135,13445,13446],{"class":159},"(img.file_path),\n",[135,13448,13449],{"class":137,"line":420},[135,13450,13451],{"class":159},"  width: img.width,\n",[135,13453,13454],{"class":137,"line":425},[135,13455,13456],{"class":159},"  height: img.height,\n",[135,13458,13459,13462,13464,13467,13469,13471,13474,13477],{"class":137,"line":431},[135,13460,13461],{"class":159},"  alt: img.prompt_text ",[135,13463,3896],{"class":148},[135,13465,13466],{"class":169}," `生成图 ${",[135,13468,1964],{"class":159},[135,13470,414],{"class":169},[135,13472,13473],{"class":159},"id",[135,13475,13476],{"class":169},"}`",[135,13478,3347],{"class":159},[135,13480,13481],{"class":137,"line":471},[135,13482,13483],{"class":159},"  prompt_text: img.prompt_text,\n",[135,13485,13486],{"class":137,"line":476},[135,13487,13488],{"class":159},"}))\n",[110,13490,13492],{"id":13491},"_3-使用-masonry-布局","3. 使用 Masonry 布局",[126,13494,13496],{"className":191,"code":13495,"language":193,"meta":131,"style":131},"\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",[63,13497,13498,13505,13515,13583,13597,13610,13620,13644,13654,13675,13693,13721,13728,13762,13770,13780,13796,13806,13816,13821,13831,13848,13853,13862,13871,13876,13880,13885],{"__ignoreMap":131},[135,13499,13500,13502],{"class":137,"line":138},[135,13501,205],{"class":159},[135,13503,13504],{"class":208},"PhotoAlbum\n",[135,13506,13507,13510,13512],{"class":137,"line":145},[135,13508,13509],{"class":155},"  layout",[135,13511,599],{"class":148},[135,13513,13514],{"class":169},"\"masonry\"\n",[135,13516,13517,13520,13522,13525,13528,13530,13532,13535,13537,13540,13543,13545,13548,13551,13553,13556,13558,13561,13563,13565,13567,13570,13572,13575,13577,13580],{"class":137,"line":163},[135,13518,13519],{"class":155},"  columns",[135,13521,599],{"class":148},[135,13523,13524],{"class":159},"{(",[135,13526,13527],{"class":534},"cw",[135,13529,542],{"class":159},[135,13531,4109],{"class":148},[135,13533,13534],{"class":159}," (cw ",[135,13536,205],{"class":148},[135,13538,13539],{"class":208}," 640",[135,13541,13542],{"class":148}," ?",[135,13544,6266],{"class":208},[135,13546,13547],{"class":148}," :",[135,13549,13550],{"class":159}," cw ",[135,13552,205],{"class":148},[135,13554,13555],{"class":208}," 960",[135,13557,13542],{"class":148},[135,13559,13560],{"class":208}," 3",[135,13562,13547],{"class":148},[135,13564,13550],{"class":159},[135,13566,205],{"class":148},[135,13568,13569],{"class":208}," 1280",[135,13571,13542],{"class":148},[135,13573,13574],{"class":208}," 4",[135,13576,13547],{"class":148},[135,13578,13579],{"class":208}," 5",[135,13581,13582],{"class":159},")}\n",[135,13584,13585,13588,13590,13592,13595],{"class":137,"line":179},[135,13586,13587],{"class":155},"  spacing",[135,13589,599],{"class":148},[135,13591,5940],{"class":159},[135,13593,13594],{"class":208},"16",[135,13596,314],{"class":159},[135,13598,13599,13602,13604,13606,13608],{"class":137,"line":185},[135,13600,13601],{"class":155},"  padding",[135,13603,599],{"class":148},[135,13605,5940],{"class":159},[135,13607,1227],{"class":208},[135,13609,314],{"class":159},[135,13611,13612,13615,13617],{"class":137,"line":366},[135,13613,13614],{"class":155},"  photos",[135,13616,599],{"class":148},[135,13618,13619],{"class":159},"{albumPhotos}\n",[135,13621,13622,13625,13627,13630,13633,13636,13638,13641],{"class":137,"line":376},[135,13623,13624],{"class":155},"  onClick",[135,13626,599],{"class":148},[135,13628,13629],{"class":159},"{({ ",[135,13631,13632],{"class":534},"index",[135,13634,13635],{"class":159}," }) ",[135,13637,4109],{"class":148},[135,13639,13640],{"class":155}," openPreview",[135,13642,13643],{"class":159},"(index)}\n",[135,13645,13646,13649,13651],{"class":137,"line":386},[135,13647,13648],{"class":155},"  render",[135,13650,599],{"class":148},[135,13652,13653],{"class":159},"{{\n",[135,13655,13656,13659,13662,13664,13666,13669,13671,13673],{"class":137,"line":392},[135,13657,13658],{"class":155},"    photo",[135,13660,13661],{"class":159},": (",[135,13663,4215],{"class":534},[135,13665,567],{"class":159},[135,13667,13668],{"class":534},"context",[135,13670,542],{"class":159},[135,13672,4109],{"class":148},[135,13674,488],{"class":159},[135,13676,13677,13679,13682,13685,13688,13690],{"class":137,"line":397},[135,13678,3821],{"class":148},[135,13680,13681],{"class":159}," { ",[135,13683,13684],{"class":208},"onClick",[135,13686,13687],{"class":159}," } ",[135,13689,599],{"class":148},[135,13691,13692],{"class":159}," props\n",[135,13694,13695,13697,13699,13702,13704,13706,13708,13710,13712,13714,13716,13718],{"class":137,"line":403},[135,13696,3821],{"class":148},[135,13698,13681],{"class":159},[135,13700,13701],{"class":208},"photo",[135,13703,567],{"class":159},[135,13705,13632],{"class":208},[135,13707,567],{"class":159},[135,13709,13306],{"class":208},[135,13711,567],{"class":159},[135,13713,13309],{"class":208},[135,13715,13687],{"class":159},[135,13717,599],{"class":148},[135,13719,13720],{"class":159}," context\n",[135,13722,13723,13726],{"class":137,"line":420},[135,13724,13725],{"class":148},"      return",[135,13727,352],{"class":159},[135,13729,13730,13733,13736,13739,13741,13744,13747,13749,13752,13755,13757,13760],{"class":137,"line":425},[135,13731,13732],{"class":159},"        \u003C",[135,13734,13735],{"class":3396},"button",[135,13737,13738],{"class":155}," type",[135,13740,599],{"class":148},[135,13742,13743],{"class":169},"\"button\"",[135,13745,13746],{"class":155}," onClick",[135,13748,599],{"class":148},[135,13750,13751],{"class":159},"{onClick} ",[135,13753,13754],{"class":155},"className",[135,13756,599],{"class":148},[135,13758,13759],{"class":169},"\"group relative block w-full ...\"",[135,13761,211],{"class":159},[135,13763,13764,13767],{"class":137,"line":431},[135,13765,13766],{"class":159},"          \u003C",[135,13768,13769],{"class":3396},"img\n",[135,13771,13772,13775,13777],{"class":137,"line":471},[135,13773,13774],{"class":155},"            src",[135,13776,599],{"class":148},[135,13778,13779],{"class":159},"{photo.src}\n",[135,13781,13782,13785,13787,13790,13792,13794],{"class":137,"line":476},[135,13783,13784],{"class":155},"            alt",[135,13786,599],{"class":148},[135,13788,13789],{"class":159},"{photo.alt ",[135,13791,3896],{"class":148},[135,13793,6180],{"class":169},[135,13795,314],{"class":159},[135,13797,13798,13801,13803],{"class":137,"line":491},[135,13799,13800],{"class":155},"            decoding",[135,13802,599],{"class":148},[135,13804,13805],{"class":169},"\"async\"\n",[135,13807,13808,13811,13813],{"class":137,"line":500},[135,13809,13810],{"class":155},"            style",[135,13812,599],{"class":148},[135,13814,13815],{"class":159},"{{ width, height }}\n",[135,13817,13818],{"class":137,"line":515},[135,13819,13820],{"class":159},"          \u002F>\n",[135,13822,13823,13826,13829],{"class":137,"line":520},[135,13824,13825],{"class":159},"          {",[135,13827,13828],{"class":141},"\u002F* hover 提示词浮层 *\u002F",[135,13830,314],{"class":159},[135,13832,13833,13835,13838,13841,13843,13846],{"class":137,"line":525},[135,13834,13766],{"class":159},[135,13836,13837],{"class":3396},"div",[135,13839,13840],{"class":155}," className",[135,13842,599],{"class":148},[135,13844,13845],{"class":169},"\"... opacity-0 group-hover:opacity-100\"",[135,13847,211],{"class":159},[135,13849,13850],{"class":137,"line":576},[135,13851,13852],{"class":159},"            {photo.prompt_text}\n",[135,13854,13855,13858,13860],{"class":137,"line":593},[135,13856,13857],{"class":159},"          \u003C\u002F",[135,13859,13837],{"class":3396},[135,13861,211],{"class":159},[135,13863,13864,13867,13869],{"class":137,"line":605},[135,13865,13866],{"class":159},"        \u003C\u002F",[135,13868,13735],{"class":3396},[135,13870,211],{"class":159},[135,13872,13873],{"class":137,"line":616},[135,13874,13875],{"class":159},"      )\n",[135,13877,13878],{"class":137,"line":648},[135,13879,8592],{"class":159},[135,13881,13882],{"class":137,"line":654},[135,13883,13884],{"class":159},"  }}\n",[135,13886,13887],{"class":137,"line":674},[135,13888,13889],{"class":159},"\u002F>\n",[15,13891,13892],{},"几个要点：",[30,13894,13895,13904,13917],{},[33,13896,13897,13899,13900,13903],{},[63,13898,13194],{}," 可以是数字或 ",[63,13901,13902],{},"(containerWidth) => number"," 函数，做响应式列数很方便；",[33,13905,13906,13907,13909,13910,13913,13914,13916],{},"点击回调 ",[63,13908,13684],{}," 里拿到的是",[25,13911,13912],{},"原始数组下标"," ",[63,13915,13632],{},"，直接传给灯箱即可；",[33,13918,13919,13920,13923,13924,13926],{},"自定义 ",[63,13921,13922],{},"render.photo"," 后，图片尺寸要自己用 context 里的 ",[63,13925,13242],{},"（渲染尺寸）设置——这两个值库已经按列宽算好了。",[11,13928,13929],{"id":13929},"遇到的坑",[110,13931,13933],{"id":13932},"坑-1忘记引入样式表-一张一列","坑 1：忘记引入样式表 → 「一张一列」",[15,13935,13936,13937,13940,13941,1311],{},"这是最坑的一个。装完包、写好 ",[63,13938,13939],{},"\u003CPhotoAlbum layout=\"masonry\">","，页面渲染出来却是：",[25,13942,13943],{},"每张图占一整行，右侧大片置灰空白",[15,13945,13946,13947,13949],{},"排查过程：先怀疑是 ",[63,13948,13194],{}," 函数没生效，改成固定数字无效；再检查 DOM——容器和 5 个列（track）都在，列的宽度也算对了，但图片全都堆在布局里。",[15,13951,13952,13953,13956,13957,8445,13960,13963,13964,13970,13971,13974],{},"最后翻包的 ",[63,13954,13955],{},"package.json"," 发现它导出了独立的 CSS 文件（",[63,13958,13959],{},".\u002Fstyles.css",[63,13961,13962],{},".\u002Fmasonry.css"," 等），而",[25,13965,13966,13967],{},"官方 README 示例都带了一行 ",[63,13968,13969],{},"import \"react-photo-album\u002Fstyles.css\"","。容器是 flex、列是 flex-column、列宽按 ",[63,13972,13973],{},"--react-photo-album--columns"," 计算——这些规则全在这个样式表里，不引入就退化成普通块级堆叠。",[15,13976,13977,13979],{},[25,13978,1397],{},"：顶部加一行：",[126,13981,13983],{"className":191,"code":13982,"language":193,"meta":131,"style":131},"import \"react-photo-album\u002Fstyles.css\"\n",[63,13984,13985],{"__ignoreMap":131},[135,13986,13987,13989],{"class":137,"line":138},[135,13988,349],{"class":148},[135,13990,13345],{"class":169},[15,13992,13993,13994],{},"经验：",[25,13995,13996],{},"用这类\"无运行时依赖\"的布局库，先看它的 exports 里有没有独立 CSS，有就一定要引。",[110,13998,14000,14001,14003,14004,14007],{"id":13999},"坑-2renderphoto-是-props-context-双参签名","坑 2：",[63,14002,13922],{}," 是 ",[63,14005,14006],{},"(props, context)"," 双参签名",[15,14009,14010],{},"自定义渲染时我凭直觉写了：",[126,14012,14014],{"className":191,"code":14013,"language":193,"meta":131,"style":131},"photo: ({ photo, index, width, height, onClick }) => ... \u002F\u002F ❌ 报错：Property 'photo' does not exist\n",[63,14015,14016],{"__ignoreMap":131},[135,14017,14018,14020,14023,14025,14027,14029,14031,14033,14035,14037,14039,14041,14043,14045,14048],{"class":137,"line":138},[135,14019,13701],{"class":155},[135,14021,14022],{"class":159},": ({ ",[135,14024,13701],{"class":534},[135,14026,567],{"class":159},[135,14028,13632],{"class":534},[135,14030,567],{"class":159},[135,14032,13306],{"class":534},[135,14034,567],{"class":159},[135,14036,13309],{"class":534},[135,14038,567],{"class":159},[135,14040,13684],{"class":534},[135,14042,13635],{"class":159},[135,14044,4109],{"class":148},[135,14046,14047],{"class":148}," ...",[135,14049,14050],{"class":141}," \u002F\u002F ❌ 报错：Property 'photo' does not exist\n",[15,14052,14053,14054,14056,14057,14060,14061,124],{},"TS 报错提示 ",[63,14055,13701],{}," 等属性不存在于 ",[63,14058,14059],{},"RenderPhotoProps","。看类型定义才发现 v3 的渲染函数是",[25,14062,14063],{},"两个参数",[126,14065,14067],{"className":191,"code":14066,"language":193,"meta":131,"style":131},"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",[63,14068,14069,14115,14120,14140,14154,14179,14183],{"__ignoreMap":131},[135,14070,14071,14073,14076,14078,14081,14083,14085,14088,14090,14092,14094,14096,14099,14101,14103,14105,14108,14110,14112],{"class":137,"line":138},[135,14072,479],{"class":148},[135,14074,14075],{"class":155}," RenderFunction",[135,14077,205],{"class":159},[135,14079,14080],{"class":155},"Props",[135,14082,567],{"class":159},[135,14084,844],{"class":155},[135,14086,14087],{"class":159},"> ",[135,14089,599],{"class":148},[135,14091,531],{"class":159},[135,14093,4215],{"class":534},[135,14095,2622],{"class":148},[135,14097,14098],{"class":155}," Props",[135,14100,567],{"class":159},[135,14102,13668],{"class":534},[135,14104,2622],{"class":148},[135,14106,14107],{"class":155}," Context",[135,14109,542],{"class":159},[135,14111,4109],{"class":148},[135,14113,14114],{"class":155}," ReactNode\n",[135,14116,14117],{"class":137,"line":145},[135,14118,14119],{"class":141},"\u002F\u002F photo 的渲染上下文（photo\u002Findex\u002Fwidth\u002Fheight）在第二个参数里\n",[135,14121,14122,14124,14126,14128,14130,14132,14134,14136,14138],{"class":137,"line":163},[135,14123,13701],{"class":155},[135,14125,2622],{"class":148},[135,14127,531],{"class":159},[135,14129,4215],{"class":534},[135,14131,567],{"class":159},[135,14133,13668],{"class":534},[135,14135,542],{"class":159},[135,14137,4109],{"class":148},[135,14139,488],{"class":159},[135,14141,14142,14144,14146,14148,14151],{"class":137,"line":179},[135,14143,4169],{"class":155},[135,14145,13681],{"class":159},[135,14147,13684],{"class":534},[135,14149,14150],{"class":159}," } = ",[135,14152,14153],{"class":534},"props\n",[135,14155,14156,14158,14160,14162,14164,14166,14168,14170,14172,14174,14176],{"class":137,"line":185},[135,14157,4169],{"class":155},[135,14159,13681],{"class":159},[135,14161,13701],{"class":534},[135,14163,567],{"class":159},[135,14165,13632],{"class":534},[135,14167,567],{"class":159},[135,14169,13306],{"class":534},[135,14171,567],{"class":159},[135,14173,13309],{"class":534},[135,14175,14150],{"class":159},[135,14177,14178],{"class":534},"context\n",[135,14180,14181],{"class":137,"line":366},[135,14182,182],{"class":148},[135,14184,14185],{"class":137,"line":376},[135,14186,314],{"class":159},[15,14188,14189,14191,14192,14194,14195,14198],{},[25,14190,1397],{},"：按 ",[63,14193,14006],{}," 拆解即可。装新库先扫一眼 ",[63,14196,14197],{},".d.ts"," 里的 Render 类型签名，能少走很多弯路。",[110,14200,14202],{"id":14201},"坑-3测试断言把dom-顺序当成了视觉顺序","坑 3：测试断言把「DOM 顺序」当成了「视觉顺序」",[15,14204,14205,14206,14209],{},"布局修好后，我在浏览器自测脚本里加了一条断言：取前两个 ",[63,14207,14208],{},"\u003Cimg>","，比较它们的左坐标，认为应该并排（不同列）。",[15,14211,14212,14213,14216,14217,695,14220,14223,14224,14227],{},"结果断言失败——但页面明明是 5 列并排。排查发现：",[25,14214,14215],{},"瀑布流的 DOM 是按\"列\"组织的","（第 1 列的全部图片在前，第 2 列的在后），所以 ",[63,14218,14219],{},"img[0]",[63,14221,14222],{},"img[1]"," 属于",[25,14225,14226],{},"同一列","，左坐标当然相同。",[15,14229,14230,14232],{},[25,14231,1397],{},"：断言改为比较「第 1 列的首张图 vs 第 2 列的首张图」：",[126,14234,14238],{"className":14235,"code":14236,"language":14237,"meta":131,"style":131},"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",[63,14239,14240,14265,14288],{"__ignoreMap":131},[135,14241,14242,14244,14246,14248,14251,14253,14255,14258,14260,14263],{"class":137,"line":138},[135,14243,3916],{"class":148},[135,14245,4923],{"class":208},[135,14247,447],{"class":148},[135,14249,14250],{"class":159}," tracks[",[135,14252,1227],{"class":208},[135,14254,3836],{"class":159},[135,14256,14257],{"class":155},"querySelector",[135,14259,548],{"class":159},[135,14261,14262],{"class":169},"\"img\"",[135,14264,389],{"class":159},[135,14266,14267,14269,14272,14274,14276,14278,14280,14282,14284,14286],{"class":137,"line":145},[135,14268,3916],{"class":148},[135,14270,14271],{"class":208}," b",[135,14273,447],{"class":148},[135,14275,14250],{"class":159},[135,14277,1830],{"class":208},[135,14279,3836],{"class":159},[135,14281,14257],{"class":155},[135,14283,548],{"class":159},[135,14285,14262],{"class":169},[135,14287,389],{"class":159},[135,14289,14290,14292,14295,14298,14301,14304,14307,14310,14313,14315,14318,14320],{"class":137,"line":163},[135,14291,3738],{"class":148},[135,14293,14294],{"class":159}," Math.",[135,14296,14297],{"class":155},"abs",[135,14299,14300],{"class":159},"(a.",[135,14302,14303],{"class":155},"getBoundingClientRect",[135,14305,14306],{"class":159},"().left ",[135,14308,14309],{"class":148},"-",[135,14311,14312],{"class":159}," b.",[135,14314,14303],{"class":155},[135,14316,14317],{"class":159},"().left) ",[135,14319,2239],{"class":148},[135,14321,14322],{"class":208}," 10\n",[15,14324,13993,14325,14328],{},[25,14326,14327],{},"验证布局类库时，先搞清楚它生成的 DOM 结构，再写断言","；另外把「真实浏览器探针」当排查手段（直接打印各 track 的 x 坐标和子元素数量），比盯着代码猜快得多。",[110,14330,14332],{"id":14331},"坑-4等宽网格的洞引入前就存在的产品问题","坑 4：等宽网格的\"洞\"（引入前就存在的产品问题）",[15,14334,14335,14336,14339,14340,14343],{},"顺带记录一下为什么换掉原来的布局：CSS Grid ",[63,14337,14338],{},"repeat(auto-fill, minmax(220px, 1fr))"," 等宽铺满，图片 ",[63,14341,14342],{},"w-full h-auto","。当一行里混有不同比例时，行高取最高的那张，矮图底部留空，几行错落下来就显得乱；另外窗口宽度\"差一点放不下下一张\"时，右侧会空出近一整列。",[15,14345,14346],{},"换成 Masonry 后这两个问题都消失了——列内紧密堆叠、列数自适应。",[11,14348,14349],{"id":14349},"效果与小结",[15,14351,14352,14353,14355],{},"最终效果：21 张不同比例的生成图在 5 列瀑布流里紧密排列，原比例不裁剪，悬停出提示词浮层，点击进 YARL 大图预览；缩略图总大小仅 360KB，进页面即全量加载 + ",[63,14354,1405],{}," 长缓存，平板上快速滚动也跟手。",[15,14357,11837,14358],{},[25,14359,14360],{},"react-photo-album 是个小但专业的相册布局库，只要记住三件事——引样式表、喂宽高、render 函数是双参——就能在十分钟内把图片墙升级成真正的瀑布流。",[1665,14362,14363],{},"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":131,"searchDepth":145,"depth":163,"links":14365},[14366,14367,14368,14373,14380],{"id":13140,"depth":145,"text":13140},{"id":13246,"depth":145,"text":13246},{"id":13272,"depth":145,"text":13272,"children":14369},[14370,14371,14372],{"id":13275,"depth":163,"text":13276},{"id":13293,"depth":163,"text":13294},{"id":13491,"depth":163,"text":13492},{"id":13929,"depth":145,"text":13929,"children":14374},[14375,14376,14378,14379],{"id":13932,"depth":163,"text":13933},{"id":13999,"depth":163,"text":14377},"坑 2：render.photo 是 (props, context) 双参签名",{"id":14201,"depth":163,"text":14202},{"id":14331,"depth":163,"text":14332},{"id":14349,"depth":145,"text":14349},"记录在 Magic Img 生图图库中引入 react-photo-album 实现 Masonry 瀑布流的过程：技术选型背景、需求梳理、安装使用，以及「忘记引入样式表导致一张一列」等踩坑与排查方法。","\u002Fposts\u002Freact-photo-album-masonry\u002Fimg\u002Fcover.svg",{},"\u002Fposts\u002Freact-photo-album-masonry",{"title":13135,"description":14381},"前端工程实践","posts\u002Freact-photo-album-masonry\u002Findex",[1702,13223,14389,14390,14391],"瀑布流","前端","Tailwind","-O-kAQLEyRzwVhOpuo-yBxZim9LtvhChPkpP92ipyjM",1786887348660]