[{"data":1,"prerenderedAt":13169},["ShallowReactive",2],{"archive-posts":3},[4,1708,3003,4192,4770,6200,7160,7933,9951,10235,11381,11772,11985,12404],{"id":5,"title":6,"author":7,"body":8,"description":1690,"difficulty":1691,"draft":1692,"extension":1693,"image":1694,"meta":1695,"navigation":344,"path":1696,"pinned":1692,"published":1697,"seo":1698,"series":1699,"seriesOrder":164,"stem":1700,"tags":1701,"updated":1706,"__hash__":1707},"posts\u002Fposts\u002Fgo-embed-react-single-binary\u002Findex.md","Go 单文件打包进阶：React 前端 + 交叉编译，一次踩完所有坑",null,{"type":9,"value":10,"toc":1669},"minimark",[11,15,30,44,51,54,93,96,107,110,115,126,190,237,255,259,280,316,320,678,681,720,724,1086,1089,1093,1302,1313,1316,1327,1342,1355,1366,1370,1377,1394,1408,1414,1418,1436,1441,1486,1500,1511,1515,1530,1539,1561,1570,1574,1584,1587,1616,1625,1628,1631,1658,1665],[12,13,14],"h2",{"id":14},"背景",[16,17,18,19,24,25,29],"p",{},"这是「Go 单文件打包」系列第三篇。上一篇（",[20,21,23],"a",{"href":22},"\u002Fposts\u002Fgo-embed-frontend","把前端塞进二进制","）记录了一个 Gin + Vue 3 项目的 embed 方案。这次换了个项目继续踩坑：",[26,27,28],"strong",{},"Magic Img","——一个自托管、本地优先的 AI 生图提示词管理工具，技术栈是：",[31,32,33,37],"ul",{},[34,35,36],"li",{},"前端：React 19 + TypeScript + Vite + Tailwind v4（UI 库 Magic UI + shadcn\u002Fui）",[34,38,39,40,43],{},"后端：Go + Gin + GORM + SQLite（",[26,41,42],{},"modernc 纯 Go 驱动，无 cgo","）",[16,45,46,47,50],{},"目标一样：",[26,48,49],{},"一个二进制拖到任何机器上就能跑","——前端、API、数据库全部内嵌。",[16,52,53],{},"和上篇不同的地方有三个，正好是这篇要讲的重点：",[55,56,57,72,86],"ol",{},[34,58,59,60,67,68,71],{},"前端用了 ",[26,61,62,63],{},"HashRouter + Vite base ",[64,65,66],"code",{},"\u002Fdist","，服务端",[26,69,70],{},"不再需要 SPA fallback","（上篇坑一的新解法）；",[34,73,74,75,81,82,85],{},"embed 用 ",[26,76,77,80],{},[64,78,79],{},"all:"," 前缀","一劳永逸解决 ",[64,83,84],{},"_"," 开头文件被排除的问题（上篇坑二的升级解法）；",[34,87,88,89,92],{},"需要",[26,90,91],{},"在 Windows 上交叉编译 Linux 产物","，踩了「生成的文件无法在 Linux 执行」的坑。",[12,94,95],{"id":95},"核心思路",[16,97,98,99,102,103,106],{},"和上篇一致：",[64,100,101],{},"\u002F\u002Fgo:embed"," 在编译期把前端 ",[64,104,105],{},"dist\u002F"," 塞进二进制，运行时由 Go 直接提供静态服务。差异在于路由策略和缓存策略，后面细说。",[12,108,109],{"id":109},"逐步实现",[111,112,114],"h3",{"id":113},"_1-前端hash-路由-base-dist","1. 前端：hash 路由 + base \u002Fdist",[16,116,117,118,121,122,125],{},"vite.config.ts 里固定 ",[64,119,120],{},"base: \"\u002Fdist\u002F\"","，路由用 ",[64,123,124],{},"HashRouter","：",[127,128,133],"pre",{"className":129,"code":130,"language":131,"meta":132,"style":132},"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","",[64,134,135,144,162,178,184],{"__ignoreMap":132},[136,137,140],"span",{"class":138,"line":139},"line",1,[136,141,143],{"class":142},"sHbNN","\u002F\u002F vite.config.ts\n",[136,145,147,151,154,158],{"class":138,"line":146},2,[136,148,150],{"class":149},"siTax","export",[136,152,153],{"class":149}," default",[136,155,157],{"class":156},"sw2iP"," defineConfig",[136,159,161],{"class":160},"sIX_F","({\n",[136,163,165,168,172,175],{"class":138,"line":164},3,[136,166,167],{"class":160},"  base: ",[136,169,171],{"class":170},"scXbn","\"\u002Fdist\u002F\"",[136,173,174],{"class":160},",   ",[136,176,177],{"class":142},"\u002F\u002F 所有资源路径以 \u002Fdist\u002F 开头\n",[136,179,181],{"class":138,"line":180},4,[136,182,183],{"class":149},"  ...\n",[136,185,187],{"class":138,"line":186},5,[136,188,189],{"class":160},"})\n",[127,191,195],{"className":192,"code":193,"language":194,"meta":132,"style":132},"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",[64,196,197,202,213,228],{"__ignoreMap":132},[136,198,199],{"class":138,"line":139},[136,200,201],{"class":142},"\u002F\u002F App.tsx —— HashRouter：路径信息全在 location.hash 里\n",[136,203,204,207,210],{"class":138,"line":146},[136,205,206],{"class":160},"\u003C",[136,208,124],{"class":209},"suQ91",[136,211,212],{"class":160},">\n",[136,214,215,218,221,224,226],{"class":138,"line":164},[136,216,217],{"class":160},"  \u003C",[136,219,220],{"class":209},"Routes",[136,222,223],{"class":160},">...\u003C\u002F",[136,225,220],{"class":209},[136,227,212],{"class":160},[136,229,230,233,235],{"class":138,"line":180},[136,231,232],{"class":160},"\u003C\u002F",[136,234,124],{"class":209},[136,236,212],{"class":160},[16,238,239,242,243,246,247,250,251,254],{},[26,240,241],{},"为什么这样选","：history 路由需要服务端把未知路径全部 fallback 回 ",[64,244,245],{},"index.html","（上篇坑一）。hash 路由下浏览器请求的永远是 ",[64,248,249],{},"\u002Fdist\u002F"," 这一个入口，",[26,252,253],{},"静态服务直接返回 index.html 就完事，一个 fallback 都不用写","。内嵌场景里这是最省心的组合。",[111,256,258],{"id":257},"_2-构建产物复制进后端目录","2. 构建产物复制进后端目录",[16,260,261,264,265,267,268,271,272,275,276,279],{},[64,262,263],{},"npm run build"," 除了编译，还会把 ",[64,266,105],{}," 复制到 ",[64,269,270],{},"backend\u002Fweb\u002Fdist","（一个小脚本 ",[64,273,274],{},"scripts\u002Fcopy-dist.mjs"," 干这个活），供 ",[64,277,278],{},"go:embed"," 使用：",[127,281,285],{"className":282,"code":283,"language":284,"meta":132,"style":132},"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",[64,286,287,292,300,311],{"__ignoreMap":132},[136,288,289],{"class":138,"line":139},[136,290,291],{"class":142},"\u002F\u002F frontend\u002Fpackage.json\n",[136,293,294,297],{"class":138,"line":146},[136,295,296],{"class":170},"\"scripts\"",[136,298,299],{"class":160},": {\n",[136,301,302,305,308],{"class":138,"line":164},[136,303,304],{"class":209},"  \"build\"",[136,306,307],{"class":160},": ",[136,309,310],{"class":170},"\"tsc -b && vite build && node scripts\u002Fcopy-dist.mjs\"\n",[136,312,313],{"class":138,"line":180},[136,314,315],{"class":160},"}\n",[111,317,319],{"id":318},"_3-后端embed-httpfilesystem-包装","3. 后端：embed + http.FileSystem 包装",[127,321,325],{"className":322,"code":323,"language":324,"meta":132,"style":132},"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",[64,326,327,332,340,346,354,365,375,385,391,396,402,419,424,430,470,475,490,499,514,519,524,575,592,604,615,647,653,673],{"__ignoreMap":132},[136,328,329],{"class":138,"line":139},[136,330,331],{"class":142},"\u002F\u002F backend\u002Fweb\u002Fembed.go\n",[136,333,334,337],{"class":138,"line":146},[136,335,336],{"class":149},"package",[136,338,339],{"class":156}," web\n",[136,341,342],{"class":138,"line":164},[136,343,345],{"emptyLinePlaceholder":344},true,"\n",[136,347,348,351],{"class":138,"line":180},[136,349,350],{"class":149},"import",[136,352,353],{"class":160}," (\n",[136,355,356,359,362],{"class":138,"line":186},[136,357,358],{"class":170},"    \"",[136,360,361],{"class":156},"embed",[136,363,364],{"class":170},"\"\n",[136,366,368,370,373],{"class":138,"line":367},6,[136,369,358],{"class":170},[136,371,372],{"class":156},"net\u002Fhttp",[136,374,364],{"class":170},[136,376,378,380,383],{"class":138,"line":377},7,[136,379,358],{"class":170},[136,381,382],{"class":156},"strings",[136,384,364],{"class":170},[136,386,388],{"class":138,"line":387},8,[136,389,390],{"class":160},")\n",[136,392,394],{"class":138,"line":393},9,[136,395,345],{"emptyLinePlaceholder":344},[136,397,399],{"class":138,"line":398},10,[136,400,401],{"class":142},"\u002F\u002Fgo:embed all:dist\n",[136,403,405,408,411,413,416],{"class":138,"line":404},11,[136,406,407],{"class":149},"var",[136,409,410],{"class":160}," distFS ",[136,412,361],{"class":156},[136,414,415],{"class":160},".",[136,417,418],{"class":156},"FS\n",[136,420,422],{"class":138,"line":421},12,[136,423,345],{"emptyLinePlaceholder":344},[136,425,427],{"class":138,"line":426},13,[136,428,429],{"class":142},"\u002F\u002F Dist 内嵌前端静态文件系统\n",[136,431,433,435,438,441,443,446,449,452,455,458,461,464,467],{"class":138,"line":432},14,[136,434,407],{"class":149},[136,436,437],{"class":160}," Dist ",[136,439,440],{"class":156},"http",[136,442,415],{"class":160},[136,444,445],{"class":156},"FileSystem",[136,447,448],{"class":149}," =",[136,450,451],{"class":149}," &",[136,453,454],{"class":156},"fileSystem",[136,456,457],{"class":160},"{prefix: ",[136,459,460],{"class":170},"\"dist\"",[136,462,463],{"class":160},", fs: ",[136,465,466],{"class":149},"&",[136,468,469],{"class":160},"distFS}\n",[136,471,473],{"class":138,"line":472},15,[136,474,345],{"emptyLinePlaceholder":344},[136,476,478,481,484,487],{"class":138,"line":477},16,[136,479,480],{"class":149},"type",[136,482,483],{"class":156}," fileSystem",[136,485,486],{"class":149}," struct",[136,488,489],{"class":160}," {\n",[136,491,493,496],{"class":138,"line":492},17,[136,494,495],{"class":160},"    prefix ",[136,497,498],{"class":149},"string\n",[136,500,502,505,508,510,512],{"class":138,"line":501},18,[136,503,504],{"class":160},"    fs     ",[136,506,507],{"class":149},"*",[136,509,361],{"class":156},[136,511,415],{"class":160},[136,513,418],{"class":156},[136,515,517],{"class":138,"line":516},19,[136,518,315],{"class":160},[136,520,522],{"class":138,"line":521},20,[136,523,345],{"emptyLinePlaceholder":344},[136,525,527,530,533,537,539,541,544,547,550,553,556,559,561,563,566,569,572],{"class":138,"line":526},21,[136,528,529],{"class":149},"func",[136,531,532],{"class":160}," (",[136,534,536],{"class":535},"sPK6S","f ",[136,538,507],{"class":149},[136,540,454],{"class":156},[136,542,543],{"class":160},") ",[136,545,546],{"class":156},"Open",[136,548,549],{"class":160},"(",[136,551,552],{"class":535},"name",[136,554,555],{"class":149}," string",[136,557,558],{"class":160},") (",[136,560,440],{"class":156},[136,562,415],{"class":160},[136,564,565],{"class":156},"File",[136,567,568],{"class":160},", ",[136,570,571],{"class":149},"error",[136,573,574],{"class":160},") {\n",[136,576,578,581,584,587,590],{"class":138,"line":577},22,[136,579,580],{"class":149},"    if",[136,582,583],{"class":160}," name ",[136,585,586],{"class":149},"==",[136,588,589],{"class":170}," \"\u002F\"",[136,591,489],{"class":160},[136,593,595,598,601],{"class":138,"line":594},23,[136,596,597],{"class":160},"        name ",[136,599,600],{"class":149},"=",[136,602,603],{"class":160}," f.prefix\n",[136,605,607,610,613],{"class":138,"line":606},24,[136,608,609],{"class":160},"    } ",[136,611,612],{"class":149},"else",[136,614,489],{"class":160},[136,616,618,620,622,625,628,630,633,636,639,642,645],{"class":138,"line":617},25,[136,619,597],{"class":160},[136,621,600],{"class":149},[136,623,624],{"class":160}," f.prefix ",[136,626,627],{"class":149},"+",[136,629,589],{"class":170},[136,631,632],{"class":149}," +",[136,634,635],{"class":160}," strings.",[136,637,638],{"class":156},"TrimPrefix",[136,640,641],{"class":160},"(name, ",[136,643,644],{"class":170},"\"\u002F\"",[136,646,390],{"class":160},[136,648,650],{"class":138,"line":649},26,[136,651,652],{"class":160},"    }\n",[136,654,656,659,662,665,668,670],{"class":138,"line":655},27,[136,657,658],{"class":149},"    return",[136,660,661],{"class":160}," http.",[136,663,664],{"class":156},"FS",[136,666,667],{"class":160},"(f.fs).",[136,669,546],{"class":156},[136,671,672],{"class":160},"(name)\n",[136,674,676],{"class":138,"line":675},28,[136,677,315],{"class":160},[16,679,680],{},"两个细节：",[31,682,683,707],{},[34,684,685,693,694,696,697,699,700,702,703,706],{},[26,686,687,690,691,80],{},[64,688,689],{},"all:dist"," 里的 ",[64,692,79],{},"：embed 目录时默认会排除 ",[64,695,84],{}," 和 ",[64,698,415],{}," 开头的文件（上篇坑二就是被这个坑了，当时靠改 Vite 输出名绕开）。",[64,701,79],{}," 前缀表示「全部包含」，前端构建产物里那些 ",[64,704,705],{},"_xxx.js"," 助手文件直接安全落地，不用再改前端配置；",[34,708,709,712,713,715,716,719],{},[64,710,711],{},"http.FileSystem"," 包装：加了一层 ",[64,714,105],{}," 前缀映射，",[64,717,718],{},"http.FileServer"," 和 Gin 都能直接用。",[111,721,723],{"id":722},"_4-路由静态服务-缓存策略","4. 路由：静态服务 + 缓存策略",[127,725,727],{"className":322,"code":726,"language":324,"meta":132,"style":132},"\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",[64,728,729,734,778,795,800,815,864,893,913,937,942,962,985,990,1007,1012,1033,1038,1078,1082],{"__ignoreMap":132},[136,730,731],{"class":138,"line":139},[136,732,733],{"class":142},"\u002F\u002F backend\u002Finternal\u002Fhandler\u002Frouter.go\n",[136,735,736,738,741,743,746,749,752,754,757,759,762,764,766,768,771,773,776],{"class":138,"line":146},[136,737,529],{"class":149},[136,739,740],{"class":156}," NewRouter",[136,742,549],{"class":160},[136,744,745],{"class":535},"db",[136,747,748],{"class":149}," *",[136,750,751],{"class":156},"gorm",[136,753,415],{"class":160},[136,755,756],{"class":156},"DB",[136,758,568],{"class":160},[136,760,761],{"class":535},"uploadDir",[136,763,555],{"class":149},[136,765,543],{"class":160},[136,767,507],{"class":149},[136,769,770],{"class":156},"gin",[136,772,415],{"class":160},[136,774,775],{"class":156},"Engine",[136,777,489],{"class":160},[136,779,780,783,786,789,792],{"class":138,"line":164},[136,781,782],{"class":160},"    r ",[136,784,785],{"class":149},":=",[136,787,788],{"class":160}," gin.",[136,790,791],{"class":156},"Default",[136,793,794],{"class":160},"()\n",[136,796,797],{"class":138,"line":180},[136,798,799],{"class":149},"    ...\n",[136,801,802,805,807,809,812],{"class":138,"line":186},[136,803,804],{"class":160},"    dist ",[136,806,785],{"class":149},[136,808,661],{"class":160},[136,810,811],{"class":156},"FileServer",[136,813,814],{"class":160},"(web.Dist)\n",[136,816,817,820,823,825,828,830,832,834,837,839,841,843,846,849,852,854,857,859,861],{"class":138,"line":367},[136,818,819],{"class":160},"    r.",[136,821,822],{"class":156},"GET",[136,824,549],{"class":160},[136,826,827],{"class":170},"\"\u002Fdist\"",[136,829,568],{"class":160},[136,831,529],{"class":149},[136,833,549],{"class":160},[136,835,836],{"class":535},"c",[136,838,748],{"class":149},[136,840,770],{"class":156},[136,842,415],{"class":160},[136,844,845],{"class":156},"Context",[136,847,848],{"class":160},") { c.",[136,850,851],{"class":156},"Redirect",[136,853,549],{"class":160},[136,855,856],{"class":209},"302",[136,858,568],{"class":160},[136,860,171],{"class":170},[136,862,863],{"class":160},") })\n",[136,865,866,868,870,872,875,877,879,881,883,885,887,889,891],{"class":138,"line":377},[136,867,819],{"class":160},[136,869,822],{"class":156},[136,871,549],{"class":160},[136,873,874],{"class":170},"\"\u002Fdist\u002F*filepath\"",[136,876,568],{"class":160},[136,878,529],{"class":149},[136,880,549],{"class":160},[136,882,836],{"class":535},[136,884,748],{"class":149},[136,886,770],{"class":156},[136,888,415],{"class":160},[136,890,845],{"class":156},[136,892,574],{"class":160},[136,894,895,898,900,903,906,908,911],{"class":138,"line":387},[136,896,897],{"class":160},"        p ",[136,899,785],{"class":149},[136,901,902],{"class":160}," c.",[136,904,905],{"class":156},"Param",[136,907,549],{"class":160},[136,909,910],{"class":170},"\"filepath\"",[136,912,390],{"class":160},[136,914,915,918,921,923,925,928,930,932,935],{"class":138,"line":393},[136,916,917],{"class":149},"        if",[136,919,920],{"class":160}," p ",[136,922,586],{"class":149},[136,924,589],{"class":170},[136,926,927],{"class":149}," ||",[136,929,920],{"class":160},[136,931,586],{"class":149},[136,933,934],{"class":170}," \"\u002Findex.html\"",[136,936,489],{"class":160},[136,938,939],{"class":138,"line":398},[136,940,941],{"class":142},"            \u002F\u002F index.html 禁缓存：升级后浏览器立刻拿到新入口\n",[136,943,944,947,950,952,955,957,960],{"class":138,"line":404},[136,945,946],{"class":160},"            c.",[136,948,949],{"class":156},"Header",[136,951,549],{"class":160},[136,953,954],{"class":170},"\"Cache-Control\"",[136,956,568],{"class":160},[136,958,959],{"class":170},"\"no-cache, no-store, must-revalidate\"",[136,961,390],{"class":160},[136,963,964,967,969,972,974,977,980,983],{"class":138,"line":421},[136,965,966],{"class":160},"        } ",[136,968,612],{"class":149},[136,970,971],{"class":149}," if",[136,973,635],{"class":160},[136,975,976],{"class":156},"HasPrefix",[136,978,979],{"class":160},"(p, ",[136,981,982],{"class":170},"\"\u002Fassets\u002F\"",[136,984,574],{"class":160},[136,986,987],{"class":138,"line":426},[136,988,989],{"class":142},"            \u002F\u002F 带内容 hash 的资源可放心长缓存\n",[136,991,992,994,996,998,1000,1002,1005],{"class":138,"line":432},[136,993,946],{"class":160},[136,995,949],{"class":156},[136,997,549],{"class":160},[136,999,954],{"class":170},[136,1001,568],{"class":160},[136,1003,1004],{"class":170},"\"public, max-age=31536000, immutable\"",[136,1006,390],{"class":160},[136,1008,1009],{"class":138,"line":472},[136,1010,1011],{"class":160},"        }\n",[136,1013,1014,1017,1020,1022,1024,1027,1030],{"class":138,"line":477},[136,1015,1016],{"class":160},"        http.",[136,1018,1019],{"class":156},"StripPrefix",[136,1021,549],{"class":160},[136,1023,827],{"class":170},[136,1025,1026],{"class":160},", dist).",[136,1028,1029],{"class":156},"ServeHTTP",[136,1031,1032],{"class":160},"(c.Writer, c.Request)\n",[136,1034,1035],{"class":138,"line":492},[136,1036,1037],{"class":160},"    })\n",[136,1039,1040,1042,1044,1046,1048,1050,1052,1054,1056,1058,1060,1062,1064,1066,1068,1070,1072,1074,1076],{"class":138,"line":501},[136,1041,819],{"class":160},[136,1043,822],{"class":156},[136,1045,549],{"class":160},[136,1047,644],{"class":170},[136,1049,568],{"class":160},[136,1051,529],{"class":149},[136,1053,549],{"class":160},[136,1055,836],{"class":535},[136,1057,748],{"class":149},[136,1059,770],{"class":156},[136,1061,415],{"class":160},[136,1063,845],{"class":156},[136,1065,848],{"class":160},[136,1067,851],{"class":156},[136,1069,549],{"class":160},[136,1071,856],{"class":209},[136,1073,568],{"class":160},[136,1075,171],{"class":170},[136,1077,863],{"class":160},[136,1079,1080],{"class":138,"line":516},[136,1081,799],{"class":149},[136,1083,1084],{"class":138,"line":521},[136,1085,315],{"class":160},[16,1087,1088],{},"缓存策略是内嵌发布的必修课（坑二细说）：入口禁缓存、hash 资源长缓存。",[111,1090,1092],{"id":1091},"_5-打包脚本windows-版-linux-交叉编译版","5. 打包脚本：Windows 版 + Linux 交叉编译版",[127,1094,1098],{"className":1095,"code":1096,"language":1097,"meta":132,"style":132},"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",[64,1099,1100,1105,1110,1121,1141,1145,1166,1170,1178,1201,1205,1212,1272,1287,1291],{"__ignoreMap":132},[136,1101,1102],{"class":138,"line":139},[136,1103,1104],{"class":142},"#!\u002Fusr\u002Fbin\u002Fenv bash\n",[136,1106,1107],{"class":138,"line":146},[136,1108,1109],{"class":142},"# build-linux.sh —— 在 Windows\u002FmacOS\u002FLinux 任意平台交叉编译 linux\u002Famd64 产物\n",[136,1111,1112,1115,1118],{"class":138,"line":164},[136,1113,1114],{"class":209},"set",[136,1116,1117],{"class":209}," -euo",[136,1119,1120],{"class":170}," pipefail\n",[136,1122,1123,1126,1129,1132,1135,1138],{"class":138,"line":180},[136,1124,1125],{"class":209},"cd",[136,1127,1128],{"class":170}," \"$(",[136,1130,1131],{"class":156},"dirname",[136,1133,1134],{"class":170}," \"",[136,1136,1137],{"class":209},"$0",[136,1139,1140],{"class":170},"\")\"\n",[136,1142,1143],{"class":138,"line":186},[136,1144,345],{"emptyLinePlaceholder":344},[136,1146,1147,1150,1152,1155,1157,1160,1163],{"class":138,"line":367},[136,1148,1149],{"class":160},"BINARY",[136,1151,600],{"class":149},[136,1153,1154],{"class":170},"\"${",[136,1156,1149],{"class":160},[136,1158,1159],{"class":149},":-",[136,1161,1162],{"class":160},"magic-img",[136,1164,1165],{"class":170},"}\"\n",[136,1167,1168],{"class":138,"line":377},[136,1169,345],{"emptyLinePlaceholder":344},[136,1171,1172,1175],{"class":138,"line":387},[136,1173,1174],{"class":209},"echo",[136,1176,1177],{"class":170}," \"[1\u002F3] 构建前端并复制产物到 backend\u002Fweb\u002Fdist ...\"\n",[136,1179,1180,1182,1184,1187,1190,1193,1196,1199],{"class":138,"line":393},[136,1181,549],{"class":160},[136,1183,1125],{"class":209},[136,1185,1186],{"class":170}," frontend",[136,1188,1189],{"class":160}," && ",[136,1191,1192],{"class":156},"npm",[136,1194,1195],{"class":170}," run",[136,1197,1198],{"class":170}," build",[136,1200,390],{"class":160},[136,1202,1203],{"class":138,"line":398},[136,1204,345],{"emptyLinePlaceholder":344},[136,1206,1207,1209],{"class":138,"line":404},[136,1208,1174],{"class":209},[136,1210,1211],{"class":170}," \"[2\u002F3] 交叉编译后端并内嵌前端 (CGO_ENABLED=0 GOOS=linux GOARCH=amd64) ...\"\n",[136,1213,1214,1216,1218,1221,1224,1226,1229,1232,1234,1237,1240,1242,1245,1248,1250,1253,1255,1258,1261,1264,1267,1270],{"class":138,"line":421},[136,1215,549],{"class":160},[136,1217,1125],{"class":209},[136,1219,1220],{"class":170}," backend",[136,1222,1223],{"class":160}," && CGO_ENABLED",[136,1225,600],{"class":149},[136,1227,1228],{"class":170},"0",[136,1230,1231],{"class":160}," GOOS",[136,1233,600],{"class":149},[136,1235,1236],{"class":170},"linux",[136,1238,1239],{"class":160}," GOARCH",[136,1241,600],{"class":149},[136,1243,1244],{"class":170},"amd64",[136,1246,1247],{"class":156}," go",[136,1249,1198],{"class":170},[136,1251,1252],{"class":209}," -o",[136,1254,1134],{"class":170},[136,1256,1257],{"class":160},"$BINARY",[136,1259,1260],{"class":170},"\"",[136,1262,1263],{"class":209}," -ldflags=",[136,1265,1266],{"class":170},"\"-s -w\"",[136,1268,1269],{"class":170}," .\u002Fcmd\u002Fserver",[136,1271,390],{"class":160},[136,1273,1274,1277,1280,1283,1285],{"class":138,"line":426},[136,1275,1276],{"class":156},"chmod",[136,1278,1279],{"class":170}," +x",[136,1281,1282],{"class":170}," \"backend\u002F",[136,1284,1257],{"class":160},[136,1286,364],{"class":170},[136,1288,1289],{"class":138,"line":432},[136,1290,345],{"emptyLinePlaceholder":344},[136,1292,1293,1295,1298,1300],{"class":138,"line":472},[136,1294,1174],{"class":209},[136,1296,1297],{"class":170}," \"[3\u002F3] 打包完成: backend\u002F",[136,1299,1257],{"class":160},[136,1301,364],{"class":170},[16,1303,1304,1305,1308,1309,1312],{},"Windows 版（",[64,1306,1307],{},"build-win.bat","）同理，只是产物叫 ",[64,1310,1311],{},"magic-img.exe","。",[12,1314,1315],{"id":1315},"踩坑记录",[111,1317,1319,1320,1323,1324,1326],{"id":1318},"坑一goembed-dist-静默漏掉-_-开头文件升级解法","坑一：",[64,1321,1322],{},"\u002F\u002Fgo:embed dist"," 静默漏掉 ",[64,1325,84],{}," 开头文件（升级解法）",[16,1328,1329,1330,1338,1339,1341],{},"上篇说过：embed 目录时",[26,1331,1332,1333,696,1335,1337],{},"硬编码排除 ",[64,1334,84],{},[64,1336,415],{}," 开头文件","。上篇的解法是改 Vite 输出文件名去掉 ",[64,1340,84],{}," 前缀；这次直接用：",[127,1343,1345],{"className":322,"code":1344,"language":324,"meta":132,"style":132},"\u002F\u002Fgo:embed all:dist   \u002F\u002F all: 前缀 = 包含全部文件，不再静默跳过\n",[64,1346,1347],{"__ignoreMap":132},[136,1348,1349,1352],{"class":138,"line":139},[136,1350,1351],{"class":142},"\u002F\u002Fgo:embed all:dist",[136,1353,1354],{"class":142},"   \u002F\u002F all: 前缀 = 包含全部文件，不再静默跳过\n",[16,1356,1357],{},[26,1358,1359,1360,1362,1363,1365],{},"结论：能用 ",[64,1361,79],{}," 就用 ",[64,1364,79],{},"，从根上消灭这类\"文件莫名 404\"的问题。",[111,1367,1369],{"id":1368},"坑二开发版是新的打包版却是旧的","坑二：开发版是新的、打包版却是旧的",[16,1371,1372,1373,1376],{},"现象：Vite 开发服务一切正常，",[64,1374,1375],{},":8080\u002Fdist\u002F"," 打包版却是旧界面，强刷也不一定好。",[16,1378,1379,1380,1383,1384,1387,1388,1393],{},"排查过程值得记录：先是怀疑 ",[64,1381,1382],{},"go build"," 缓存了旧 embed（上篇坑三的思路），重编重启无效；然后直接抓取 8080 返回的 HTML 和 JS，发现",[26,1385,1386],{},"服务端内容已经是新的","——问题出在",[26,1389,1390,1391],{},"浏览器缓存了 ",[64,1392,245],{},"：它引用旧 hash 的资源，一直不更新。",[16,1395,1396,1399,1400,1403,1404,1407],{},[26,1397,1398],{},"解决","：入口文件响应 ",[64,1401,1402],{},"Cache-Control: no-store","（见上面路由代码），带 hash 的资源给 ",[64,1405,1406],{},"immutable"," 长缓存。升级后浏览器每次拉新入口，资源文件名一变就自动换新，两全其美。",[1409,1410,1411],"blockquote",{},[16,1412,1413],{},"教训：排查\"新旧不一致\"先分清是哪一层——服务端产物、浏览器入口缓存、还是资源缓存。抓包看响应内容再动手。",[111,1415,1417],{"id":1416},"坑三windows-上跑的-build-linuxsh产出的文件-linux-却执行不了","坑三：Windows 上跑的 build-linux.sh，产出的文件 Linux 却执行不了",[16,1419,1420,1421,1424,1425,1431,1432,1435],{},"最初脚本只写了 ",[64,1422,1423],{},"CGO_ENABLED=0 go build","，",[26,1426,1427,1428],{},"没指定 ",[64,1429,1430],{},"GOOS\u002FGOARCH","。在 Windows 上跑，产出的其实是个 Windows PE 文件（只是没叫 ",[64,1433,1434],{},".exe","），传到 Linux 自然「无法执行」。",[16,1437,1438,1440],{},[26,1439,1398],{},"：脚本里显式交叉编译：",[127,1442,1444],{"className":1095,"code":1443,"language":1097,"meta":132,"style":132},"CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o \"$BINARY\" -ldflags=\"-s -w\" .\u002Fcmd\u002Fserver\n",[64,1445,1446],{"__ignoreMap":132},[136,1447,1448,1451,1453,1455,1457,1459,1461,1463,1465,1467,1469,1471,1473,1475,1477,1479,1481,1483],{"class":138,"line":139},[136,1449,1450],{"class":160},"CGO_ENABLED",[136,1452,600],{"class":149},[136,1454,1228],{"class":170},[136,1456,1231],{"class":160},[136,1458,600],{"class":149},[136,1460,1236],{"class":170},[136,1462,1239],{"class":160},[136,1464,600],{"class":149},[136,1466,1244],{"class":170},[136,1468,1247],{"class":156},[136,1470,1198],{"class":170},[136,1472,1252],{"class":209},[136,1474,1134],{"class":170},[136,1476,1257],{"class":160},[136,1478,1260],{"class":170},[136,1480,1263],{"class":209},[136,1482,1266],{"class":170},[136,1484,1485],{"class":170}," .\u002Fcmd\u002Fserver\n",[16,1487,1488,1489,1492,1493,1496,1497],{},"能这么干的前提是",[26,1490,1491],{},"全依赖树无 cgo","——SQLite 用的是 modernc 纯 Go 驱动，bcrypt 用 ",[64,1494,1495],{},"x\u002Fcrypto","。这也是选型时就要留意的点：",[26,1498,1499],{},"想跨平台交叉编译，先把 cgo 依赖清干净。",[16,1501,1502,1503,1506,1507,1510],{},"另外两个小提醒：Windows 上编出的文件没有 Unix 执行位，上传服务器后记得 ",[64,1504,1505],{},"chmod +x","；",[64,1508,1509],{},"-ldflags=\"-s -w\""," 剥离符号，体积从 30MB+ 降到 26MB。",[111,1512,1514],{"id":1513},"坑四dist-被-gitignore-后embed-直接编译失败","坑四：dist 被 gitignore 后，embed 直接编译失败",[16,1516,1517,1519,1520,1522,1523,1526,1527,1529],{},[64,1518,270],{}," 是构建产物、入了 .gitignore，但 ",[64,1521,101],{}," 在",[26,1524,1525],{},"编译期","就要读它——刚 clone 下来直接 ",[64,1528,1382],{}," 会报 embed 找不到文件。",[16,1531,1532,1534,1535,1538],{},[26,1533,1398],{},"：gitignore 里保留 ",[64,1536,1537],{},"dist\u002F.gitkeep","（一个空文件），让目录存在于版本库中：",[127,1540,1544],{"className":1541,"code":1542,"language":1543,"meta":132,"style":132},"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",[64,1545,1546,1551,1556],{"__ignoreMap":132},[136,1547,1548],{"class":138,"line":139},[136,1549,1550],{},"backend\u002Fweb\u002Fdist\u002F*\n",[136,1552,1553],{"class":138,"line":146},[136,1554,1555],{},"!backend\u002Fweb\u002Fdist\u002F\n",[136,1557,1558],{"class":138,"line":164},[136,1559,1560],{},"!backend\u002Fweb\u002Fdist\u002F.gitkeep\n",[16,1562,1563,1564,1566,1567,1569],{},"这样 clone 后可以先 ",[64,1565,1382],{}," 通过，再 ",[64,1568,263],{}," 灌入真实前端。",[111,1571,1573],{"id":1572},"坑五500kb-chunk-警告别慌","坑五：>500KB chunk 警告，别慌",[16,1575,1576,1577,1580,1581,1312],{},"Vite 构建会警告单 chunk 超过 500KB。这个项目里 Base UI 组件库全量引入，单 chunk 约 640KB——",[26,1578,1579],{},"属正常，不要为了消警告去拆配置","。真要优化就上路由级 ",[64,1582,1583],{},"React.lazy",[12,1585,1586],{"id":1586},"最终效果",[127,1588,1590],{"className":1095,"code":1589,"language":1097,"meta":132,"style":132},"$ build-win.bat        # backendmagic-img.exe\n$ bash build-linux.sh  # backendmagic-img（linux\u002Famd64 ELF）\n",[64,1591,1592,1603],{"__ignoreMap":132},[136,1593,1594,1597,1600],{"class":138,"line":139},[136,1595,1596],{"class":156},"$",[136,1598,1599],{"class":170}," build-win.bat",[136,1601,1602],{"class":142},"        # backendmagic-img.exe\n",[136,1604,1605,1607,1610,1613],{"class":138,"line":146},[136,1606,1596],{"class":156},[136,1608,1609],{"class":170}," bash",[136,1611,1612],{"class":170}," build-linux.sh",[136,1614,1615],{"class":142},"  # backendmagic-img（linux\u002Famd64 ELF）\n",[16,1617,1618,1619,1622,1623,1312],{},"一个约 26MB 的二进制，内嵌 React 前端、Gin API、SQLite 数据初始化，拷到 Windows\u002FLinux 机器直接跑，浏览器访问 ",[64,1620,1621],{},"http:\u002F\u002Flocalhost:8080"," 自动重定向到 ",[64,1624,249],{},[12,1626,1627],{"id":1627},"总结",[16,1629,1630],{},"这轮相比上篇的三点升级：",[55,1632,1633,1639,1649],{},[34,1634,1635,1638],{},[26,1636,1637],{},"hash 路由 + base \u002Fdist","：省掉 SPA fallback，静态服务即开即用；",[34,1640,1641,125,1646,1648],{},[26,1642,1643,1645],{},[64,1644,79],{}," embed 前缀",[64,1647,84],{}," 开头文件问题一劳永逸；",[34,1650,1651,1657],{},[26,1652,1653,1654,1656],{},"显式 ",[64,1655,1430],{}," 交叉编译","：让「一个脚本、任意平台、产出 Linux 二进制」成为现实。",[16,1659,1660,1661,1664],{},"加上",[26,1662,1663],{},"入口 no-store \u002F 资源 immutable"," 的缓存策略，单文件分发的体验才算闭环——不然每次升级都要和浏览器缓存打架。",[1666,1667,1668],"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":132,"searchDepth":146,"depth":164,"links":1670},[1671,1672,1673,1680,1688,1689],{"id":14,"depth":146,"text":14},{"id":95,"depth":146,"text":95},{"id":109,"depth":146,"text":109,"children":1674},[1675,1676,1677,1678,1679],{"id":113,"depth":164,"text":114},{"id":257,"depth":164,"text":258},{"id":318,"depth":164,"text":319},{"id":722,"depth":164,"text":723},{"id":1091,"depth":164,"text":1092},{"id":1315,"depth":146,"text":1315,"children":1681},[1682,1684,1685,1686,1687],{"id":1318,"depth":164,"text":1683},"坑一：\u002F\u002Fgo:embed dist 静默漏掉 _ 开头文件（升级解法）",{"id":1368,"depth":164,"text":1369},{"id":1416,"depth":164,"text":1417},{"id":1513,"depth":164,"text":1514},{"id":1572,"depth":164,"text":1573},{"id":1586,"depth":146,"text":1586},{"id":1627,"depth":146,"text":1627},"以 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":6,"description":1690},"Go 工程实践","posts\u002Fgo-embed-react-single-binary\u002Findex",[1702,1703,361,1704,1705],"Go","React","交叉编译","打包","2026-08-16","WV-T-MOO5Cu-aycw-Ony8s9hjA3_k0S4XU-T0QsCow0",{"id":1709,"title":1710,"author":7,"body":1711,"description":2990,"difficulty":2991,"draft":1692,"extension":1693,"image":2992,"meta":2993,"navigation":344,"path":2994,"pinned":1692,"published":1697,"seo":2995,"series":2996,"seriesOrder":139,"stem":2997,"tags":2998,"updated":1706,"__hash__":3002},"posts\u002Fposts\u002Freact-photo-album-masonry\u002Findex.md","用 react-photo-album 给生图图库做瀑布流排版",{"type":9,"value":1712,"toc":2973},[1713,1716,1719,1737,1744,1817,1828,1831,1854,1857,1861,1876,1880,1898,2084,2088,2492,2495,2529,2532,2536,2546,2552,2577,2582,2593,2599,2610,2613,2653,2666,2790,2802,2806,2813,2831,2836,2931,2937,2941,2952,2955,2958,2964,2970],[12,1714,1715],{"id":1715},"技术背景",[16,1717,1718],{},"Magic Img 是我做的一个 AI 生图提示词管理工具（自托管、本地优先），其中「生图图库」页面要把所有生成图片以图片墙的形式展示出来。图片墙有两个天然矛盾：",[55,1720,1721,1727],{},[34,1722,1723,1726],{},[26,1724,1725],{},"等宽网格","（CSS Grid）：整齐，但每行高度由最高的那张决定——不同比例的图片混排时，矮图下面会留下大洞，看起来\"很乱\"；",[34,1728,1729,1732,1733,1736],{},[26,1730,1731],{},"原生比例展示","：图库必须不裁剪（这是产品约束），所以不能用 ",[64,1734,1735],{},"object-cover"," 强行统一比例。",[16,1738,1739,1740,1743],{},"业内的标准解法是 ",[26,1741,1742],{},"Masonry 瀑布流","：按列紧密堆放，每张图保持原始比例，列间高度错落但整体紧凑。实现瀑布流的常见方案有三类：",[1745,1746,1747,1766],"table",{},[1748,1749,1750],"thead",{},[1751,1752,1753,1757,1760,1763],"tr",{},[1754,1755,1756],"th",{},"方案",[1754,1758,1759],{},"依赖",[1754,1761,1762],{},"顺序",[1754,1764,1765],{},"说明",[1767,1768,1769,1788,1801],"tbody",{},[1751,1770,1771,1779,1782,1785],{},[1772,1773,1774,1775,1778],"td",{},"CSS ",[64,1776,1777],{},"columns"," 多列",[1772,1780,1781],{},"无",[1772,1783,1784],{},"纵向阅读序（先下后右）",[1772,1786,1787],{},"最新图片不再出现在左上角，时间语义丢失",[1751,1789,1790,1793,1795,1798],{},[1772,1791,1792],{},"grid row-span 自研",[1772,1794,1781],{},[1772,1796,1797],{},"保持",[1772,1799,1800],{},"需固定列数 + 固定行高，逻辑要自己写",[1751,1802,1803,1808,1811,1814],{},[1772,1804,1805],{},[26,1806,1807],{},"react-photo-album",[1772,1809,1810],{},"~4KB，无依赖",[1772,1812,1813],{},"基本保持（逐列最短列填充）",[1772,1815,1816],{},"专为相册墙设计，TS 友好，维护活跃",[16,1818,1819,1820,1823,1824,1827],{},"最终选了 ",[26,1821,1822],{},"react-photo-album v3","：体积小、类型齐全，而且它的 Masonry 模式恰好需要「每张图的 ",[64,1825,1826],{},"width\u002Fheight","」——我们的数据库里本来就存了，零额外成本。",[12,1829,1830],{"id":1830},"需求梳理",[55,1832,1833,1839,1845,1848,1851],{},[34,1834,1835,1836,1506],{},"展示全部生成图（21 张起，会持续增长），",[26,1837,1838],{},"原比例、不裁剪",[34,1840,1841,1842,1506],{},"不同尺寸（2304×1728、4608×3456 等）混排时",[26,1843,1844],{},"紧凑、不留洞",[34,1846,1847],{},"顺序基本保持「新图靠前」；",[34,1849,1850],{},"点击任意一张打开大图预览（YARL 灯箱），悬停显示所属提示词；",[34,1852,1853],{},"平板性能：墙上只用 300px 缩略图，原图只进灯箱按需加载。",[12,1855,1856],{"id":1856},"安装与使用",[111,1858,1860],{"id":1859},"_1-安装","1. 安装",[127,1862,1864],{"className":1095,"code":1863,"language":1097,"meta":132,"style":132},"npm install react-photo-album\n",[64,1865,1866],{"__ignoreMap":132},[136,1867,1868,1870,1873],{"class":138,"line":139},[136,1869,1192],{"class":156},[136,1871,1872],{"class":170}," install",[136,1874,1875],{"class":170}," react-photo-album\n",[111,1877,1879],{"id":1878},"_2-准备数据关键输入是宽高","2. 准备数据（关键输入是宽高）",[16,1881,1882,1883,1886,1887,1890,1891,1890,1894,1897],{},"库要求的 ",[64,1884,1885],{},"Photo"," 对象只有三个必需字段：",[64,1888,1889],{},"src","、",[64,1892,1893],{},"width",[64,1895,1896],{},"height","。我们的接口本来就返回每张图的原始宽高：",[127,1899,1901],{"className":192,"code":1900,"language":194,"meta":132,"style":132},"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",[64,1902,1903,1921,1926,1933,1937,1953,1964,1968,1972,2010,2021,2041,2046,2051,2074,2079],{"__ignoreMap":132},[136,1904,1905,1907,1910,1912,1915,1918],{"class":138,"line":139},[136,1906,350],{"class":149},[136,1908,1909],{"class":160}," PhotoAlbum, { ",[136,1911,480],{"class":149},[136,1913,1914],{"class":160}," Photo } ",[136,1916,1917],{"class":149},"from",[136,1919,1920],{"class":170}," \"react-photo-album\"\n",[136,1922,1923],{"class":138,"line":146},[136,1924,1925],{"class":142},"\u002F\u002F 必须引入样式表！容器\u002F列的布局规则都在这里（坑 1 详述）\n",[136,1927,1928,1930],{"class":138,"line":164},[136,1929,350],{"class":149},[136,1931,1932],{"class":170}," \"react-photo-album\u002Fstyles.css\"\n",[136,1934,1935],{"class":138,"line":180},[136,1936,345],{"emptyLinePlaceholder":344},[136,1938,1939,1942,1945,1948,1951],{"class":138,"line":186},[136,1940,1941],{"class":149},"interface",[136,1943,1944],{"class":156}," GalleryPhoto",[136,1946,1947],{"class":149}," extends",[136,1949,1950],{"class":156}," Photo",[136,1952,489],{"class":160},[136,1954,1955,1958,1961],{"class":138,"line":367},[136,1956,1957],{"class":535},"  prompt_text",[136,1959,1960],{"class":149},"?:",[136,1962,1963],{"class":209}," string\n",[136,1965,1966],{"class":138,"line":377},[136,1967,315],{"class":160},[136,1969,1970],{"class":138,"line":387},[136,1971,345],{"emptyLinePlaceholder":344},[136,1973,1974,1977,1980,1983,1985,1988,1990,1993,1996,1999,2002,2004,2007],{"class":138,"line":393},[136,1975,1976],{"class":149},"const",[136,1978,1979],{"class":209}," albumPhotos",[136,1981,1982],{"class":149},":",[136,1984,1944],{"class":156},[136,1986,1987],{"class":160},"[] ",[136,1989,600],{"class":149},[136,1991,1992],{"class":160}," images.",[136,1994,1995],{"class":156},"map",[136,1997,1998],{"class":160},"((",[136,2000,2001],{"class":535},"img",[136,2003,543],{"class":160},[136,2005,2006],{"class":149},"=>",[136,2008,2009],{"class":160}," ({\n",[136,2011,2012,2015,2018],{"class":138,"line":398},[136,2013,2014],{"class":160},"  key: ",[136,2016,2017],{"class":156},"String",[136,2019,2020],{"class":160},"(img.id),\n",[136,2022,2023,2026,2029,2032,2035,2038],{"class":138,"line":404},[136,2024,2025],{"class":160},"  src: ",[136,2027,2028],{"class":156},"assetUrl",[136,2030,2031],{"class":160},"(img.thumb_path) ",[136,2033,2034],{"class":149},"||",[136,2036,2037],{"class":156}," assetUrl",[136,2039,2040],{"class":160},"(img.file_path),\n",[136,2042,2043],{"class":138,"line":421},[136,2044,2045],{"class":160},"  width: img.width,\n",[136,2047,2048],{"class":138,"line":426},[136,2049,2050],{"class":160},"  height: img.height,\n",[136,2052,2053,2056,2058,2061,2063,2065,2068,2071],{"class":138,"line":432},[136,2054,2055],{"class":160},"  alt: img.prompt_text ",[136,2057,2034],{"class":149},[136,2059,2060],{"class":170}," `生成图 ${",[136,2062,2001],{"class":160},[136,2064,415],{"class":170},[136,2066,2067],{"class":160},"id",[136,2069,2070],{"class":170},"}`",[136,2072,2073],{"class":160},",\n",[136,2075,2076],{"class":138,"line":472},[136,2077,2078],{"class":160},"  prompt_text: img.prompt_text,\n",[136,2080,2081],{"class":138,"line":477},[136,2082,2083],{"class":160},"}))\n",[111,2085,2087],{"id":2086},"_3-使用-masonry-布局","3. 使用 Masonry 布局",[127,2089,2091],{"className":192,"code":2090,"language":194,"meta":132,"style":132},"\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",[64,2092,2093,2100,2110,2179,2194,2207,2217,2241,2251,2273,2292,2320,2327,2362,2370,2380,2397,2407,2417,2422,2432,2449,2454,2463,2472,2477,2482,2487],{"__ignoreMap":132},[136,2094,2095,2097],{"class":138,"line":139},[136,2096,206],{"class":160},[136,2098,2099],{"class":209},"PhotoAlbum\n",[136,2101,2102,2105,2107],{"class":138,"line":146},[136,2103,2104],{"class":156},"  layout",[136,2106,600],{"class":149},[136,2108,2109],{"class":170},"\"masonry\"\n",[136,2111,2112,2115,2117,2120,2123,2125,2127,2130,2132,2135,2138,2141,2144,2147,2149,2152,2154,2157,2159,2161,2163,2166,2168,2171,2173,2176],{"class":138,"line":164},[136,2113,2114],{"class":156},"  columns",[136,2116,600],{"class":149},[136,2118,2119],{"class":160},"{(",[136,2121,2122],{"class":535},"cw",[136,2124,543],{"class":160},[136,2126,2006],{"class":149},[136,2128,2129],{"class":160}," (cw ",[136,2131,206],{"class":149},[136,2133,2134],{"class":209}," 640",[136,2136,2137],{"class":149}," ?",[136,2139,2140],{"class":209}," 2",[136,2142,2143],{"class":149}," :",[136,2145,2146],{"class":160}," cw ",[136,2148,206],{"class":149},[136,2150,2151],{"class":209}," 960",[136,2153,2137],{"class":149},[136,2155,2156],{"class":209}," 3",[136,2158,2143],{"class":149},[136,2160,2146],{"class":160},[136,2162,206],{"class":149},[136,2164,2165],{"class":209}," 1280",[136,2167,2137],{"class":149},[136,2169,2170],{"class":209}," 4",[136,2172,2143],{"class":149},[136,2174,2175],{"class":209}," 5",[136,2177,2178],{"class":160},")}\n",[136,2180,2181,2184,2186,2189,2192],{"class":138,"line":180},[136,2182,2183],{"class":156},"  spacing",[136,2185,600],{"class":149},[136,2187,2188],{"class":160},"{",[136,2190,2191],{"class":209},"16",[136,2193,315],{"class":160},[136,2195,2196,2199,2201,2203,2205],{"class":138,"line":186},[136,2197,2198],{"class":156},"  padding",[136,2200,600],{"class":149},[136,2202,2188],{"class":160},[136,2204,1228],{"class":209},[136,2206,315],{"class":160},[136,2208,2209,2212,2214],{"class":138,"line":367},[136,2210,2211],{"class":156},"  photos",[136,2213,600],{"class":149},[136,2215,2216],{"class":160},"{albumPhotos}\n",[136,2218,2219,2222,2224,2227,2230,2233,2235,2238],{"class":138,"line":377},[136,2220,2221],{"class":156},"  onClick",[136,2223,600],{"class":149},[136,2225,2226],{"class":160},"{({ ",[136,2228,2229],{"class":535},"index",[136,2231,2232],{"class":160}," }) ",[136,2234,2006],{"class":149},[136,2236,2237],{"class":156}," openPreview",[136,2239,2240],{"class":160},"(index)}\n",[136,2242,2243,2246,2248],{"class":138,"line":387},[136,2244,2245],{"class":156},"  render",[136,2247,600],{"class":149},[136,2249,2250],{"class":160},"{{\n",[136,2252,2253,2256,2259,2262,2264,2267,2269,2271],{"class":138,"line":393},[136,2254,2255],{"class":156},"    photo",[136,2257,2258],{"class":160},": (",[136,2260,2261],{"class":535},"props",[136,2263,568],{"class":160},[136,2265,2266],{"class":535},"context",[136,2268,543],{"class":160},[136,2270,2006],{"class":149},[136,2272,489],{"class":160},[136,2274,2275,2278,2281,2284,2287,2289],{"class":138,"line":398},[136,2276,2277],{"class":149},"      const",[136,2279,2280],{"class":160}," { ",[136,2282,2283],{"class":209},"onClick",[136,2285,2286],{"class":160}," } ",[136,2288,600],{"class":149},[136,2290,2291],{"class":160}," props\n",[136,2293,2294,2296,2298,2301,2303,2305,2307,2309,2311,2313,2315,2317],{"class":138,"line":404},[136,2295,2277],{"class":149},[136,2297,2280],{"class":160},[136,2299,2300],{"class":209},"photo",[136,2302,568],{"class":160},[136,2304,2229],{"class":209},[136,2306,568],{"class":160},[136,2308,1893],{"class":209},[136,2310,568],{"class":160},[136,2312,1896],{"class":209},[136,2314,2286],{"class":160},[136,2316,600],{"class":149},[136,2318,2319],{"class":160}," context\n",[136,2321,2322,2325],{"class":138,"line":421},[136,2323,2324],{"class":149},"      return",[136,2326,353],{"class":160},[136,2328,2329,2332,2336,2339,2341,2344,2347,2349,2352,2355,2357,2360],{"class":138,"line":426},[136,2330,2331],{"class":160},"        \u003C",[136,2333,2335],{"class":2334},"sbB4o","button",[136,2337,2338],{"class":156}," type",[136,2340,600],{"class":149},[136,2342,2343],{"class":170},"\"button\"",[136,2345,2346],{"class":156}," onClick",[136,2348,600],{"class":149},[136,2350,2351],{"class":160},"{onClick} ",[136,2353,2354],{"class":156},"className",[136,2356,600],{"class":149},[136,2358,2359],{"class":170},"\"group relative block w-full ...\"",[136,2361,212],{"class":160},[136,2363,2364,2367],{"class":138,"line":432},[136,2365,2366],{"class":160},"          \u003C",[136,2368,2369],{"class":2334},"img\n",[136,2371,2372,2375,2377],{"class":138,"line":472},[136,2373,2374],{"class":156},"            src",[136,2376,600],{"class":149},[136,2378,2379],{"class":160},"{photo.src}\n",[136,2381,2382,2385,2387,2390,2392,2395],{"class":138,"line":477},[136,2383,2384],{"class":156},"            alt",[136,2386,600],{"class":149},[136,2388,2389],{"class":160},"{photo.alt ",[136,2391,2034],{"class":149},[136,2393,2394],{"class":170}," \"\"",[136,2396,315],{"class":160},[136,2398,2399,2402,2404],{"class":138,"line":492},[136,2400,2401],{"class":156},"            decoding",[136,2403,600],{"class":149},[136,2405,2406],{"class":170},"\"async\"\n",[136,2408,2409,2412,2414],{"class":138,"line":501},[136,2410,2411],{"class":156},"            style",[136,2413,600],{"class":149},[136,2415,2416],{"class":160},"{{ width, height }}\n",[136,2418,2419],{"class":138,"line":516},[136,2420,2421],{"class":160},"          \u002F>\n",[136,2423,2424,2427,2430],{"class":138,"line":521},[136,2425,2426],{"class":160},"          {",[136,2428,2429],{"class":142},"\u002F* hover 提示词浮层 *\u002F",[136,2431,315],{"class":160},[136,2433,2434,2436,2439,2442,2444,2447],{"class":138,"line":526},[136,2435,2366],{"class":160},[136,2437,2438],{"class":2334},"div",[136,2440,2441],{"class":156}," className",[136,2443,600],{"class":149},[136,2445,2446],{"class":170},"\"... opacity-0 group-hover:opacity-100\"",[136,2448,212],{"class":160},[136,2450,2451],{"class":138,"line":577},[136,2452,2453],{"class":160},"            {photo.prompt_text}\n",[136,2455,2456,2459,2461],{"class":138,"line":594},[136,2457,2458],{"class":160},"          \u003C\u002F",[136,2460,2438],{"class":2334},[136,2462,212],{"class":160},[136,2464,2465,2468,2470],{"class":138,"line":606},[136,2466,2467],{"class":160},"        \u003C\u002F",[136,2469,2335],{"class":2334},[136,2471,212],{"class":160},[136,2473,2474],{"class":138,"line":617},[136,2475,2476],{"class":160},"      )\n",[136,2478,2479],{"class":138,"line":649},[136,2480,2481],{"class":160},"    },\n",[136,2483,2484],{"class":138,"line":655},[136,2485,2486],{"class":160},"  }}\n",[136,2488,2489],{"class":138,"line":675},[136,2490,2491],{"class":160},"\u002F>\n",[16,2493,2494],{},"几个要点：",[31,2496,2497,2506,2519],{},[34,2498,2499,2501,2502,2505],{},[64,2500,1777],{}," 可以是数字或 ",[64,2503,2504],{},"(containerWidth) => number"," 函数，做响应式列数很方便；",[34,2507,2508,2509,2511,2512,2515,2516,2518],{},"点击回调 ",[64,2510,2283],{}," 里拿到的是",[26,2513,2514],{},"原始数组下标"," ",[64,2517,2229],{},"，直接传给灯箱即可；",[34,2520,2521,2522,2525,2526,2528],{},"自定义 ",[64,2523,2524],{},"render.photo"," 后，图片尺寸要自己用 context 里的 ",[64,2527,1826],{},"（渲染尺寸）设置——这两个值库已经按列宽算好了。",[12,2530,2531],{"id":2531},"遇到的坑",[111,2533,2535],{"id":2534},"坑-1忘记引入样式表-一张一列","坑 1：忘记引入样式表 → 「一张一列」",[16,2537,2538,2539,2542,2543,1312],{},"这是最坑的一个。装完包、写好 ",[64,2540,2541],{},"\u003CPhotoAlbum layout=\"masonry\">","，页面渲染出来却是：",[26,2544,2545],{},"每张图占一整行，右侧大片置灰空白",[16,2547,2548,2549,2551],{},"排查过程：先怀疑是 ",[64,2550,1777],{}," 函数没生效，改成固定数字无效；再检查 DOM——容器和 5 个列（track）都在，列的宽度也算对了，但图片全都堆在布局里。",[16,2553,2554,2555,2558,2559,1890,2562,2565,2566,2572,2573,2576],{},"最后翻包的 ",[64,2556,2557],{},"package.json"," 发现它导出了独立的 CSS 文件（",[64,2560,2561],{},".\u002Fstyles.css",[64,2563,2564],{},".\u002Fmasonry.css"," 等），而",[26,2567,2568,2569],{},"官方 README 示例都带了一行 ",[64,2570,2571],{},"import \"react-photo-album\u002Fstyles.css\"","。容器是 flex、列是 flex-column、列宽按 ",[64,2574,2575],{},"--react-photo-album--columns"," 计算——这些规则全在这个样式表里，不引入就退化成普通块级堆叠。",[16,2578,2579,2581],{},[26,2580,1398],{},"：顶部加一行：",[127,2583,2585],{"className":192,"code":2584,"language":194,"meta":132,"style":132},"import \"react-photo-album\u002Fstyles.css\"\n",[64,2586,2587],{"__ignoreMap":132},[136,2588,2589,2591],{"class":138,"line":139},[136,2590,350],{"class":149},[136,2592,1932],{"class":170},[16,2594,2595,2596],{},"经验：",[26,2597,2598],{},"用这类\"无运行时依赖\"的布局库，先看它的 exports 里有没有独立 CSS，有就一定要引。",[111,2600,2602,2603,2605,2606,2609],{"id":2601},"坑-2renderphoto-是-props-context-双参签名","坑 2：",[64,2604,2524],{}," 是 ",[64,2607,2608],{},"(props, context)"," 双参签名",[16,2611,2612],{},"自定义渲染时我凭直觉写了：",[127,2614,2616],{"className":192,"code":2615,"language":194,"meta":132,"style":132},"photo: ({ photo, index, width, height, onClick }) => ... \u002F\u002F ❌ 报错：Property 'photo' does not exist\n",[64,2617,2618],{"__ignoreMap":132},[136,2619,2620,2622,2625,2627,2629,2631,2633,2635,2637,2639,2641,2643,2645,2647,2650],{"class":138,"line":139},[136,2621,2300],{"class":156},[136,2623,2624],{"class":160},": ({ ",[136,2626,2300],{"class":535},[136,2628,568],{"class":160},[136,2630,2229],{"class":535},[136,2632,568],{"class":160},[136,2634,1893],{"class":535},[136,2636,568],{"class":160},[136,2638,1896],{"class":535},[136,2640,568],{"class":160},[136,2642,2283],{"class":535},[136,2644,2232],{"class":160},[136,2646,2006],{"class":149},[136,2648,2649],{"class":149}," ...",[136,2651,2652],{"class":142}," \u002F\u002F ❌ 报错：Property 'photo' does not exist\n",[16,2654,2655,2656,2658,2659,2662,2663,125],{},"TS 报错提示 ",[64,2657,2300],{}," 等属性不存在于 ",[64,2660,2661],{},"RenderPhotoProps","。看类型定义才发现 v3 的渲染函数是",[26,2664,2665],{},"两个参数",[127,2667,2669],{"className":192,"code":2668,"language":194,"meta":132,"style":132},"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",[64,2670,2671,2717,2722,2742,2757,2782,2786],{"__ignoreMap":132},[136,2672,2673,2675,2678,2680,2683,2685,2687,2690,2692,2694,2696,2698,2701,2703,2705,2707,2710,2712,2714],{"class":138,"line":139},[136,2674,480],{"class":149},[136,2676,2677],{"class":156}," RenderFunction",[136,2679,206],{"class":160},[136,2681,2682],{"class":156},"Props",[136,2684,568],{"class":160},[136,2686,845],{"class":156},[136,2688,2689],{"class":160},"> ",[136,2691,600],{"class":149},[136,2693,532],{"class":160},[136,2695,2261],{"class":535},[136,2697,1982],{"class":149},[136,2699,2700],{"class":156}," Props",[136,2702,568],{"class":160},[136,2704,2266],{"class":535},[136,2706,1982],{"class":149},[136,2708,2709],{"class":156}," Context",[136,2711,543],{"class":160},[136,2713,2006],{"class":149},[136,2715,2716],{"class":156}," ReactNode\n",[136,2718,2719],{"class":138,"line":146},[136,2720,2721],{"class":142},"\u002F\u002F photo 的渲染上下文（photo\u002Findex\u002Fwidth\u002Fheight）在第二个参数里\n",[136,2723,2724,2726,2728,2730,2732,2734,2736,2738,2740],{"class":138,"line":164},[136,2725,2300],{"class":156},[136,2727,1982],{"class":149},[136,2729,532],{"class":160},[136,2731,2261],{"class":535},[136,2733,568],{"class":160},[136,2735,2266],{"class":535},[136,2737,543],{"class":160},[136,2739,2006],{"class":149},[136,2741,489],{"class":160},[136,2743,2744,2747,2749,2751,2754],{"class":138,"line":180},[136,2745,2746],{"class":156},"  const",[136,2748,2280],{"class":160},[136,2750,2283],{"class":535},[136,2752,2753],{"class":160}," } = ",[136,2755,2756],{"class":535},"props\n",[136,2758,2759,2761,2763,2765,2767,2769,2771,2773,2775,2777,2779],{"class":138,"line":186},[136,2760,2746],{"class":156},[136,2762,2280],{"class":160},[136,2764,2300],{"class":535},[136,2766,568],{"class":160},[136,2768,2229],{"class":535},[136,2770,568],{"class":160},[136,2772,1893],{"class":535},[136,2774,568],{"class":160},[136,2776,1896],{"class":535},[136,2778,2753],{"class":160},[136,2780,2781],{"class":535},"context\n",[136,2783,2784],{"class":138,"line":367},[136,2785,183],{"class":149},[136,2787,2788],{"class":138,"line":377},[136,2789,315],{"class":160},[16,2791,2792,2794,2795,2797,2798,2801],{},[26,2793,1398],{},"：按 ",[64,2796,2608],{}," 拆解即可。装新库先扫一眼 ",[64,2799,2800],{},".d.ts"," 里的 Render 类型签名，能少走很多弯路。",[111,2803,2805],{"id":2804},"坑-3测试断言把dom-顺序当成了视觉顺序","坑 3：测试断言把「DOM 顺序」当成了「视觉顺序」",[16,2807,2808,2809,2812],{},"布局修好后，我在浏览器自测脚本里加了一条断言：取前两个 ",[64,2810,2811],{},"\u003Cimg>","，比较它们的左坐标，认为应该并排（不同列）。",[16,2814,2815,2816,2819,2820,696,2823,2826,2827,2830],{},"结果断言失败——但页面明明是 5 列并排。排查发现：",[26,2817,2818],{},"瀑布流的 DOM 是按\"列\"组织的","（第 1 列的全部图片在前，第 2 列的在后），所以 ",[64,2821,2822],{},"img[0]",[64,2824,2825],{},"img[1]"," 属于",[26,2828,2829],{},"同一列","，左坐标当然相同。",[16,2832,2833,2835],{},[26,2834,1398],{},"：断言改为比较「第 1 列的首张图 vs 第 2 列的首张图」：",[127,2837,2841],{"className":2838,"code":2839,"language":2840,"meta":132,"style":132},"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",[64,2842,2843,2870,2894],{"__ignoreMap":132},[136,2844,2845,2847,2850,2852,2855,2857,2860,2863,2865,2868],{"class":138,"line":139},[136,2846,1976],{"class":149},[136,2848,2849],{"class":209}," a",[136,2851,448],{"class":149},[136,2853,2854],{"class":160}," tracks[",[136,2856,1228],{"class":209},[136,2858,2859],{"class":160},"].",[136,2861,2862],{"class":156},"querySelector",[136,2864,549],{"class":160},[136,2866,2867],{"class":170},"\"img\"",[136,2869,390],{"class":160},[136,2871,2872,2874,2877,2879,2881,2884,2886,2888,2890,2892],{"class":138,"line":146},[136,2873,1976],{"class":149},[136,2875,2876],{"class":209}," b",[136,2878,448],{"class":149},[136,2880,2854],{"class":160},[136,2882,2883],{"class":209},"1",[136,2885,2859],{"class":160},[136,2887,2862],{"class":156},[136,2889,549],{"class":160},[136,2891,2867],{"class":170},[136,2893,390],{"class":160},[136,2895,2896,2899,2902,2905,2908,2911,2914,2917,2920,2922,2925,2928],{"class":138,"line":164},[136,2897,2898],{"class":149},"return",[136,2900,2901],{"class":160}," Math.",[136,2903,2904],{"class":156},"abs",[136,2906,2907],{"class":160},"(a.",[136,2909,2910],{"class":156},"getBoundingClientRect",[136,2912,2913],{"class":160},"().left ",[136,2915,2916],{"class":149},"-",[136,2918,2919],{"class":160}," b.",[136,2921,2910],{"class":156},[136,2923,2924],{"class":160},"().left) ",[136,2926,2927],{"class":149},">",[136,2929,2930],{"class":209}," 10\n",[16,2932,2595,2933,2936],{},[26,2934,2935],{},"验证布局类库时，先搞清楚它生成的 DOM 结构，再写断言","；另外把「真实浏览器探针」当排查手段（直接打印各 track 的 x 坐标和子元素数量），比盯着代码猜快得多。",[111,2938,2940],{"id":2939},"坑-4等宽网格的洞引入前就存在的产品问题","坑 4：等宽网格的\"洞\"（引入前就存在的产品问题）",[16,2942,2943,2944,2947,2948,2951],{},"顺带记录一下为什么换掉原来的布局：CSS Grid ",[64,2945,2946],{},"repeat(auto-fill, minmax(220px, 1fr))"," 等宽铺满，图片 ",[64,2949,2950],{},"w-full h-auto","。当一行里混有不同比例时，行高取最高的那张，矮图底部留空，几行错落下来就显得乱；另外窗口宽度\"差一点放不下下一张\"时，右侧会空出近一整列。",[16,2953,2954],{},"换成 Masonry 后这两个问题都消失了——列内紧密堆叠、列数自适应。",[12,2956,2957],{"id":2957},"效果与小结",[16,2959,2960,2961,2963],{},"最终效果：21 张不同比例的生成图在 5 列瀑布流里紧密排列，原比例不裁剪，悬停出提示词浮层，点击进 YARL 大图预览；缩略图总大小仅 360KB，进页面即全量加载 + ",[64,2962,1406],{}," 长缓存，平板上快速滚动也跟手。",[16,2965,2966,2967],{},"一句话总结：",[26,2968,2969],{},"react-photo-album 是个小但专业的相册布局库，只要记住三件事——引样式表、喂宽高、render 函数是双参——就能在十分钟内把图片墙升级成真正的瀑布流。",[1666,2971,2972],{},"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":132,"searchDepth":146,"depth":164,"links":2974},[2975,2976,2977,2982,2989],{"id":1715,"depth":146,"text":1715},{"id":1830,"depth":146,"text":1830},{"id":1856,"depth":146,"text":1856,"children":2978},[2979,2980,2981],{"id":1859,"depth":164,"text":1860},{"id":1878,"depth":164,"text":1879},{"id":2086,"depth":164,"text":2087},{"id":2531,"depth":146,"text":2531,"children":2983},[2984,2985,2987,2988],{"id":2534,"depth":164,"text":2535},{"id":2601,"depth":164,"text":2986},"坑 2：render.photo 是 (props, context) 双参签名",{"id":2804,"depth":164,"text":2805},{"id":2939,"depth":164,"text":2940},{"id":2957,"depth":146,"text":2957},"记录在 Magic Img 生图图库中引入 react-photo-album 实现 Masonry 瀑布流的过程：技术选型背景、需求梳理、安装使用，以及「忘记引入样式表导致一张一列」等踩坑与排查方法。","入门","\u002Fposts\u002Freact-photo-album-masonry\u002Fimg\u002Fcover.svg",{},"\u002Fposts\u002Freact-photo-album-masonry",{"title":1710,"description":2990},"前端工程实践","posts\u002Freact-photo-album-masonry\u002Findex",[1703,1807,2999,3000,3001],"瀑布流","前端","Tailwind","-O-kAQLEyRzwVhOpuo-yBxZim9LtvhChPkpP92ipyjM",{"id":3004,"title":3005,"author":7,"body":3006,"description":4180,"difficulty":2991,"draft":1692,"extension":1693,"image":4181,"meta":4182,"navigation":344,"path":4183,"pinned":1692,"published":4184,"seo":4185,"series":7,"seriesOrder":7,"stem":4186,"tags":4187,"updated":1706,"__hash__":4191},"posts\u002Fposts\u002Fip-geolocation-guide\u002Findex.md","IP 属地是怎么查出来的？本地查询与在线查询方案对比",{"type":9,"value":3007,"toc":4156},[3008,3011,3017,3020,3031,3040,3044,3050,3063,3071,3082,3089,3092,3099,3103,3114,3118,3129,3132,3136,3142,3148,3151,3155,3162,3180,3183,3186,3190,3221,3262,3268,3272,3415,3451,3460,3464,3504,3509,3513,3524,3527,3561,3564,3568,3571,3575,3771,3775,3857,3861,3868,3975,3979,3988,4074,4077,4080,4116,4119,4121,4129,4150,4153],[12,3009,3010],{"id":3010},"写在前面",[16,3012,3013,3014],{},"很多网站会在「登录记录」「安全中心」里显示你的登录 IP 归属地，比如「福建·福州 · 中国移动」。你可能好奇过：",[26,3015,3016],{},"服务器是怎么根据一个 IP 就判断出我在哪个城市、哪个运营商的？",[16,3018,3019],{},"这篇科普文章讲清楚三件事：",[55,3021,3022,3025,3028],{},[34,3023,3024],{},"IP 属地到底查的是什么；",[34,3026,3027],{},"纯本地查询为什么能做到毫秒级且不联网；",[34,3029,3030],{},"几种主流查询方案的优缺点对比，方便你按场景选型。",[1409,3032,3033],{},[16,3034,3035,3036,3039],{},"说明：文中用到的 IP 均为 RFC 5737 保留的文档示例段（TEST-NET-2，",[64,3037,3038],{},"198.51.100.0\u002F24","），不会对应任何真实地址。",[12,3041,3043],{"id":3042},"先想清楚ip-属地查的是什么","先想清楚：IP 属地查的是什么",[16,3045,3046,3047,1312],{},"一个常见误区是「每个 IP 对应一个具体地址」。实际上 IP 属地的本质是 ",[26,3048,3049],{},"IP 段与归属地的映射",[16,3051,3052,3053,125],{},"运营商和机构在申请 IP 时，是按",[26,3054,3055,3056,3062],{},"网段（CIDR）",[26,3057,3058,3059,3061],{},"成块分配的。比如 ",[64,3060,3038],{}," 这一个段，一整块都归属同一地区、同一运营商。所以归属地数据天然可以","按段存储",[127,3064,3069],{"className":3065,"code":3067,"language":3068,"meta":132},[3066],"language-text","IP 起始          IP 结束         归属地\n────────────────────────────────────────\n198.51.100.0     198.51.100.255  中国|福建|福州|移动\n198.51.101.0     198.51.101.255  中国|浙江|杭州|电信\n","text",[64,3070,3067],{"__ignoreMap":132},[16,3072,3073,3074,3077,3078,3081],{},"全球公网 IP 约有 42 亿个，但",[26,3075,3076],{},"网段只有几十万条","。正因为存的是「段」而不是「每个 IP」，完整的归属地数据文件才只有几 MB（ip2region 的 ",[64,3079,3080],{},".xdb"," 约 11MB）。这是「本地离线查询」可行的第一前提。",[16,3083,3084,3085,3088],{},"所以，",[26,3086,3087],{},"IP 属地从来不是精确到街道的定位","，它返回的是段级的「省｜市｜运营商」粗粒度信息。",[12,3090,3091],{"id":3091},"纯本地查询的原理",[16,3093,3094,3095,3098],{},"以开源的 ",[26,3096,3097],{},"ip2region"," 为例，它的查询完全在本地完成，不联网、不请求第三方，几十微秒到毫秒级返回。",[111,3100,3102],{"id":3101},"第一步把-ip-变成整数","第一步：把 IP 变成整数",[16,3104,3105,3106,3109,3110,3113],{},"以 ",[64,3107,3108],{},"198.51.100.42"," 为例，它会转成一个 32 位整数 ",[64,3111,3112],{},"0xC633642A","。IP 的四个数字本质就是 4 个字节，拼起来就是一个整数，这一步没有任何玄学。",[111,3115,3117],{"id":3116},"第二步二分查找","第二步：二分查找",[16,3119,3120,3121,3124,3125,3128],{},"在按起始 IP 排序的段列表里做",[26,3122,3123],{},"二分查找","，找到满足 ",[64,3126,3127],{},"段的起始IP ≤ 目标整数 ≤ 段的结束IP"," 的那一段。",[16,3130,3131],{},"二分查找的复杂度是 O(log N)，N 是几十万条，所以很快——这就是「不靠网络也能毫秒返回」的核心。",[111,3133,3135],{"id":3134},"第三步xdb-文件的两级结构","第三步：.xdb 文件的两级结构",[16,3137,3138,3139,3141],{},"ip2region 的 ",[64,3140,3080],{}," 不是简单顺序文件，内部分两个区，进一步加速：",[127,3143,3146],{"className":3144,"code":3145,"language":3068,"meta":132},[3066],"┌─────────────────────────────┐\n│  索引区 (Index)             │  \u002F\u002F 每段一条定长记录：startIP \u002F endIP \u002F 数据区指针 + 数据长度\n│  …… 二分快速定位段 ……       │\n├─────────────────────────────┤\n│  数据区 (Data)              │  \u002F\u002F 真正存放各段的地点字符串\n│  ……                         │\n└─────────────────────────────┘\n",[64,3147,3145],{"__ignoreMap":132},[16,3149,3150],{},"先二分索引区定位到「是哪一段」，再按指针去数据区读出地点字符串，一次磁盘读就能拿到结果。",[111,3152,3154],{"id":3153},"为什么离线数据不会很快过期","为什么「离线数据」不会很快过期",[16,3156,3157,3158,3161],{},"IP 归属取决于",[26,3159,3160],{},"注册机构和运营商的分配","，一个网段通常长期稳定属于某地区、某运营商。所以：",[31,3163,3164,3167,3177],{},[34,3165,3166],{},"下载一次数据文件，可以用很久；",[34,3168,3169,3170,3176],{},"归属偶尔变化时，",[26,3171,3172,3173,3175],{},"每月更新一次 ",[64,3174,3080],{}," 文件","即可（替换文件、不用重启服务，见下文实战）；",[34,3178,3179],{},"数据源来自 IANA\u002FAPNIC 等机构的分配表与公开归属信息，不是实时探测。",[12,3181,3182],{"id":3182},"主流查询方案对比",[16,3184,3185],{},"实际开发中，查询 IP 属地主要有三条路线。",[111,3187,3189],{"id":3188},"方案一ip2region纯本地离线推荐","方案一：ip2region（纯本地离线，推荐）",[127,3191,3193],{"className":322,"code":3192,"language":324,"meta":132,"style":132},"\u002F\u002F Go 中的用法（github.com\u002Flionsoul2014\u002Fip2region\u002Fbinding\u002Fgolang）\nregion, err := searcher.SearchByStr(ip)\n\u002F\u002F 返回类似 \"中国|福建省|福州市|移动\"\n",[64,3194,3195,3200,3216],{"__ignoreMap":132},[136,3196,3197],{"class":138,"line":139},[136,3198,3199],{"class":142},"\u002F\u002F Go 中的用法（github.com\u002Flionsoul2014\u002Fip2region\u002Fbinding\u002Fgolang）\n",[136,3201,3202,3205,3207,3210,3213],{"class":138,"line":146},[136,3203,3204],{"class":160},"region, err ",[136,3206,785],{"class":149},[136,3208,3209],{"class":160}," searcher.",[136,3211,3212],{"class":156},"SearchByStr",[136,3214,3215],{"class":160},"(ip)\n",[136,3217,3218],{"class":138,"line":164},[136,3219,3220],{"class":142},"\u002F\u002F 返回类似 \"中国|福建省|福州市|移动\"\n",[1745,3222,3223,3233],{},[1748,3224,3225],{},[1751,3226,3227,3230],{},[1754,3228,3229],{},"优点",[1754,3231,3232],{},"缺点",[1767,3234,3235,3243,3251],{},[1751,3236,3237,3240],{},[1772,3238,3239],{},"免费、无网络依赖、无 QPS 限制",[1772,3241,3242],{},"段级粒度，个别段可能显示到相邻地区",[1751,3244,3245,3248],{},[1772,3246,3247],{},"毫秒级查询，永不因外部服务挂掉",[1772,3249,3250],{},"数据按月更新，最新分配的段可能短暂缺失",[1751,3252,3253,3256],{},[1772,3254,3255],{},"数据文件可随版本更新，不重启",[1772,3257,3258,3259],{},"只返回省市区+运营商，无经纬度；",[26,3260,3261],{},"仅支持 IPv4",[16,3263,3264,3267],{},[26,3265,3266],{},"适用","：博客\u002F后台的「登录记录显示归属地」这类展示用途。",[111,3269,3271],{"id":3270},"方案二在线-apiip-api-高德-腾讯位置服务","方案二：在线 API（ip-api \u002F 高德 \u002F 腾讯位置服务）",[127,3273,3275],{"className":322,"code":3274,"language":324,"meta":132,"style":132},"\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",[64,3276,3277,3282,3312,3322,3352,3368,3384,3394,3398,3410],{"__ignoreMap":132},[136,3278,3279],{"class":138,"line":139},[136,3280,3281],{"class":142},"\u002F\u002F 以 ip-api.com 为例（免费版约 45 次\u002F分钟，非商用）\n",[136,3283,3284,3287,3289,3292,3295,3298,3301,3304,3307,3309],{"class":138,"line":146},[136,3285,3286],{"class":160},"ctx, cancel ",[136,3288,785],{"class":149},[136,3290,3291],{"class":160}," context.",[136,3293,3294],{"class":156},"WithTimeout",[136,3296,3297],{"class":160},"(context.",[136,3299,3300],{"class":156},"Background",[136,3302,3303],{"class":160},"(), ",[136,3305,3306],{"class":209},"3",[136,3308,507],{"class":149},[136,3310,3311],{"class":160},"time.Second)\n",[136,3313,3314,3317,3320],{"class":138,"line":164},[136,3315,3316],{"class":149},"defer",[136,3318,3319],{"class":156}," cancel",[136,3321,794],{"class":160},[136,3323,3324,3327,3329,3331,3334,3337,3340,3342,3345,3347,3350],{"class":138,"line":180},[136,3325,3326],{"class":160},"req, _ ",[136,3328,785],{"class":149},[136,3330,661],{"class":160},[136,3332,3333],{"class":156},"NewRequestWithContext",[136,3335,3336],{"class":160},"(ctx, ",[136,3338,3339],{"class":170},"\"GET\"",[136,3341,568],{"class":160},[136,3343,3344],{"class":170},"\"https:\u002F\u002Fip-api.com\u002Fjson\u002F198.51.100.42\"",[136,3346,568],{"class":160},[136,3348,3349],{"class":209},"nil",[136,3351,390],{"class":160},[136,3353,3354,3357,3359,3362,3365],{"class":138,"line":186},[136,3355,3356],{"class":160},"resp, err ",[136,3358,785],{"class":149},[136,3360,3361],{"class":160}," http.DefaultClient.",[136,3363,3364],{"class":156},"Do",[136,3366,3367],{"class":160},"(req)\n",[136,3369,3370,3373,3376,3379,3382],{"class":138,"line":367},[136,3371,3372],{"class":149},"if",[136,3374,3375],{"class":160}," err ",[136,3377,3378],{"class":149},"!=",[136,3380,3381],{"class":209}," nil",[136,3383,489],{"class":160},[136,3385,3386,3388,3391],{"class":138,"line":377},[136,3387,658],{"class":149},[136,3389,3390],{"class":170}," \"未知\"",[136,3392,3393],{"class":142}," \u002F\u002F 断网\u002F超时降级：展示「未知」而不是报错\n",[136,3395,3396],{"class":138,"line":387},[136,3397,315],{"class":160},[136,3399,3400,3402,3405,3408],{"class":138,"line":393},[136,3401,3316],{"class":149},[136,3403,3404],{"class":160}," resp.Body.",[136,3406,3407],{"class":156},"Close",[136,3409,794],{"class":160},[136,3411,3412],{"class":138,"line":398},[136,3413,3414],{"class":142},"\u002F\u002F 解析 JSON，status != \"success\" 时同样降级\n",[1745,3416,3417,3425],{},[1748,3418,3419],{},[1751,3420,3421,3423],{},[1754,3422,3229],{},[1754,3424,3232],{},[1767,3426,3427,3435,3443],{},[1751,3428,3429,3432],{},[1772,3430,3431],{},"数据最新、可能更精确",[1772,3433,3434],{},"有频率限制（免费版限 QPS）",[1751,3436,3437,3440],{},[1772,3438,3439],{},"部分服务还返回经纬度\u002F时区",[1772,3441,3442],{},"依赖第三方，断网\u002F被墙\u002F限流时拿不到",[1751,3444,3445,3448],{},[1772,3446,3447],{},"无需维护数据文件",[1772,3449,3450],{},"每次查询都要发一次 HTTP 请求",[16,3452,3453,3455,3456,3459],{},[26,3454,3266],{},"：对精度要求高、且能接受外部依赖与限流的场景。",[26,3457,3458],{},"务必加超时和降级","，否则第三方一抖，你自己的页面就卡住。",[111,3461,3463],{"id":3462},"方案三maxmind-geolite2离线-mmdb","方案三：MaxMind GeoLite2（离线 .mmdb）",[1745,3465,3466,3474],{},[1748,3467,3468],{},[1751,3469,3470,3472],{},[1754,3471,3229],{},[1754,3473,3232],{},[1767,3475,3476,3484,3496],{},[1751,3477,3478,3481],{},[1772,3479,3480],{},"全球数据，覆盖广、精度较好",[1772,3482,3483],{},"免费但需注册账号并遵守 EULA；商用需 GeoIP2 商业授权",[1751,3485,3486,3493],{},[1772,3487,3488,3489,3492],{},"标准 ",[64,3490,3491],{},".mmdb"," 格式，各语言都有库，支持 IPv6",[1772,3494,3495],{},"国内 IP 的省市粒度有时不如 ip2region 细",[1751,3497,3498,3501],{},[1772,3499,3500],{},"离线查询",[1772,3502,3503],{},"数据同样要定期更新",[16,3505,3506,3508],{},[26,3507,3266],{},"：面向全球用户的场景。",[12,3510,3512],{"id":3511},"ipv6本地离线方案的一个盲点","IPv6：本地离线方案的一个盲点",[16,3514,3515,3516,3519,3520,3523],{},"前面聊的都是 IPv4（32 位）。但家宽和移动网络越来越多地",[26,3517,3518],{},"只下发 IPv6","（128 位）地址，而 ",[26,3521,3522],{},"ip2region 目前只支持 IPv4","——拿到一个纯 IPv6 地址是查不出归属地的，本地库会返回空结果。",[16,3525,3526],{},"实际影响与常见应对：",[55,3528,3529,3539,3545,3555],{},[34,3530,3531,3534,3535,3538],{},[26,3532,3533],{},"判断版本","：Go 里 ",[64,3536,3537],{},"net.ParseIP(ip).To4() != nil"," 就是 IPv4，先判断再查询；",[34,3540,3541,3544],{},[26,3542,3543],{},"降级展示","：IPv6 直接显示「IPv6 地址」，不显示属地，诚实第一；",[34,3546,3547,3550,3551,3554],{},[26,3548,3549],{},"优先取 IPv4","：有反向代理时，从 ",[64,3552,3553],{},"X-Forwarded-For"," 里挑一个 IPv4 来查；",[34,3556,3557,3560],{},[26,3558,3559],{},"需要 v6 归属","：换支持 IPv6 的数据源（如 MaxMind GeoLite2）。",[16,3562,3563],{},"这一点在接入时很容易漏掉，上线前记得拿一个纯 IPv6 地址回归一遍。",[12,3565,3567],{"id":3566},"实战给登录记录加-ip-属地","实战：给登录记录加 IP 属地",[16,3569,3570],{},"subLog 博客的登录记录就用了 ip2region。完整接入一共四步。",[111,3572,3574],{"id":3573},"_1-进程启动时加载数据文件","1. 进程启动时加载数据文件",[127,3576,3578],{"className":322,"code":3577,"language":324,"meta":132,"style":132},"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",[64,3579,3580,3586,3595,3604,3608,3612,3617,3646,3650,3664,3687,3699,3707,3711,3727,3739,3745,3749,3760,3767],{"__ignoreMap":132},[136,3581,3582,3584],{"class":138,"line":139},[136,3583,350],{"class":149},[136,3585,353],{"class":160},[136,3587,3588,3590,3593],{"class":138,"line":146},[136,3589,358],{"class":170},[136,3591,3592],{"class":156},"sync\u002Fatomic",[136,3594,364],{"class":170},[136,3596,3597,3599,3602],{"class":138,"line":164},[136,3598,358],{"class":170},[136,3600,3601],{"class":156},"github.com\u002Flionsoul2014\u002Fip2region\u002Fbinding\u002Fgolang\u002Fxdb",[136,3603,364],{"class":170},[136,3605,3606],{"class":138,"line":180},[136,3607,390],{"class":160},[136,3609,3610],{"class":138,"line":186},[136,3611,345],{"emptyLinePlaceholder":344},[136,3613,3614],{"class":138,"line":367},[136,3615,3616],{"class":142},"\u002F\u002F 用 atomic.Pointer 持有查询器，方便后面热更新\n",[136,3618,3619,3621,3624,3627,3629,3632,3635,3638,3640,3643],{"class":138,"line":377},[136,3620,407],{"class":149},[136,3622,3623],{"class":160}," searcher ",[136,3625,3626],{"class":156},"atomic",[136,3628,415],{"class":160},[136,3630,3631],{"class":156},"Pointer",[136,3633,3634],{"class":160},"[",[136,3636,3637],{"class":156},"xdb",[136,3639,415],{"class":160},[136,3641,3642],{"class":156},"Searcher",[136,3644,3645],{"class":160},"]\n",[136,3647,3648],{"class":138,"line":387},[136,3649,345],{"emptyLinePlaceholder":344},[136,3651,3652,3654,3657,3660,3662],{"class":138,"line":393},[136,3653,529],{"class":149},[136,3655,3656],{"class":156}," initSearcher",[136,3658,3659],{"class":160},"() ",[136,3661,571],{"class":149},[136,3663,489],{"class":160},[136,3665,3666,3669,3671,3674,3677,3679,3682,3684],{"class":138,"line":398},[136,3667,3668],{"class":160},"    content, err ",[136,3670,785],{"class":149},[136,3672,3673],{"class":160}," os.",[136,3675,3676],{"class":156},"ReadFile",[136,3678,549],{"class":160},[136,3680,3681],{"class":170},"\".\u002Fdata\u002Fip2region.xdb\"",[136,3683,543],{"class":160},[136,3685,3686],{"class":142},"\u002F\u002F 约 11MB，一次性读入内存\n",[136,3688,3689,3691,3693,3695,3697],{"class":138,"line":404},[136,3690,580],{"class":149},[136,3692,3375],{"class":160},[136,3694,3378],{"class":149},[136,3696,3381],{"class":209},[136,3698,489],{"class":160},[136,3700,3701,3704],{"class":138,"line":421},[136,3702,3703],{"class":149},"        return",[136,3705,3706],{"class":160}," err\n",[136,3708,3709],{"class":138,"line":426},[136,3710,652],{"class":160},[136,3712,3713,3716,3718,3721,3724],{"class":138,"line":432},[136,3714,3715],{"class":160},"    s, err ",[136,3717,785],{"class":149},[136,3719,3720],{"class":160}," xdb.",[136,3722,3723],{"class":156},"NewWithBuffer",[136,3725,3726],{"class":160},"(content)\n",[136,3728,3729,3731,3733,3735,3737],{"class":138,"line":472},[136,3730,580],{"class":149},[136,3732,3375],{"class":160},[136,3734,3378],{"class":149},[136,3736,3381],{"class":209},[136,3738,489],{"class":160},[136,3740,3741,3743],{"class":138,"line":477},[136,3742,3703],{"class":149},[136,3744,3706],{"class":160},[136,3746,3747],{"class":138,"line":492},[136,3748,652],{"class":160},[136,3750,3751,3754,3757],{"class":138,"line":501},[136,3752,3753],{"class":160},"    searcher.",[136,3755,3756],{"class":156},"Store",[136,3758,3759],{"class":160},"(s)\n",[136,3761,3762,3764],{"class":138,"line":516},[136,3763,658],{"class":149},[136,3765,3766],{"class":209}," nil\n",[136,3768,3769],{"class":138,"line":521},[136,3770,315],{"class":160},[111,3772,3774],{"id":3773},"_2-查询一次","2. 查询一次",[127,3776,3778],{"className":322,"code":3777,"language":324,"meta":132,"style":132},"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",[64,3779,3780,3801,3820,3832,3839,3843,3853],{"__ignoreMap":132},[136,3781,3782,3784,3787,3789,3792,3794,3796,3799],{"class":138,"line":139},[136,3783,529],{"class":149},[136,3785,3786],{"class":156}," ipRegion",[136,3788,549],{"class":160},[136,3790,3791],{"class":535},"ip",[136,3793,555],{"class":149},[136,3795,543],{"class":160},[136,3797,3798],{"class":149},"string",[136,3800,489],{"class":160},[136,3802,3803,3806,3808,3810,3813,3816,3818],{"class":138,"line":146},[136,3804,3805],{"class":160},"    region, err ",[136,3807,785],{"class":149},[136,3809,3209],{"class":160},[136,3811,3812],{"class":156},"Load",[136,3814,3815],{"class":160},"().",[136,3817,3212],{"class":156},[136,3819,3215],{"class":160},[136,3821,3822,3824,3826,3828,3830],{"class":138,"line":164},[136,3823,580],{"class":149},[136,3825,3375],{"class":160},[136,3827,3378],{"class":149},[136,3829,3381],{"class":209},[136,3831,489],{"class":160},[136,3833,3834,3836],{"class":138,"line":180},[136,3835,3703],{"class":149},[136,3837,3838],{"class":170}," \"未知\"\n",[136,3840,3841],{"class":138,"line":186},[136,3842,652],{"class":160},[136,3844,3845,3847,3850],{"class":138,"line":367},[136,3846,658],{"class":149},[136,3848,3849],{"class":160}," region ",[136,3851,3852],{"class":142},"\u002F\u002F \"中国|福建省|福州市|移动\"\n",[136,3854,3855],{"class":138,"line":377},[136,3856,315],{"class":160},[111,3858,3860],{"id":3859},"_3-加一层内存缓存","3. 加一层内存缓存",[16,3862,3863,3864,3867],{},"登录记录里同一个 IP 可能反复出现，用 ",[64,3865,3866],{},"sync.Map"," 按 IP 缓存结果，一次查询终身复用：",[127,3869,3871],{"className":322,"code":3870,"language":324,"meta":132,"style":132},"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",[64,3872,3873,3888,3892,3911,3928,3939,3943,3954,3964,3971],{"__ignoreMap":132},[136,3874,3875,3877,3880,3883,3885],{"class":138,"line":139},[136,3876,407],{"class":149},[136,3878,3879],{"class":160}," regionCache ",[136,3881,3882],{"class":156},"sync",[136,3884,415],{"class":160},[136,3886,3887],{"class":156},"Map\n",[136,3889,3890],{"class":138,"line":146},[136,3891,345],{"emptyLinePlaceholder":344},[136,3893,3894,3896,3899,3901,3903,3905,3907,3909],{"class":138,"line":164},[136,3895,529],{"class":149},[136,3897,3898],{"class":156}," ipRegionCached",[136,3900,549],{"class":160},[136,3902,3791],{"class":535},[136,3904,555],{"class":149},[136,3906,543],{"class":160},[136,3908,3798],{"class":149},[136,3910,489],{"class":160},[136,3912,3913,3915,3918,3920,3923,3925],{"class":138,"line":180},[136,3914,580],{"class":149},[136,3916,3917],{"class":160}," v, ok ",[136,3919,785],{"class":149},[136,3921,3922],{"class":160}," regionCache.",[136,3924,3812],{"class":156},[136,3926,3927],{"class":160},"(ip); ok {\n",[136,3929,3930,3932,3935,3937],{"class":138,"line":186},[136,3931,3703],{"class":149},[136,3933,3934],{"class":160}," v.(",[136,3936,3798],{"class":149},[136,3938,390],{"class":160},[136,3940,3941],{"class":138,"line":367},[136,3942,652],{"class":160},[136,3944,3945,3948,3950,3952],{"class":138,"line":377},[136,3946,3947],{"class":160},"    region ",[136,3949,785],{"class":149},[136,3951,3786],{"class":156},[136,3953,3215],{"class":160},[136,3955,3956,3959,3961],{"class":138,"line":387},[136,3957,3958],{"class":160},"    regionCache.",[136,3960,3756],{"class":156},[136,3962,3963],{"class":160},"(ip, region)\n",[136,3965,3966,3968],{"class":138,"line":393},[136,3967,658],{"class":149},[136,3969,3970],{"class":160}," region\n",[136,3972,3973],{"class":138,"line":398},[136,3974,315],{"class":160},[111,3976,3978],{"id":3977},"_4-数据热更新不用重启","4. 数据热更新（不用重启）",[16,3980,3981,3982,3984,3985,3987],{},"ip2region 每月发布新数据。下载新文件后重新 ",[64,3983,3723],{}," 并 ",[64,3986,3756],{}," 覆盖即可，进程完全不用重启：",[127,3989,3991],{"className":322,"code":3990,"language":324,"meta":132,"style":132},"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",[64,3992,3993,4017,4030,4042,4048,4052,4064,4070],{"__ignoreMap":132},[136,3994,3995,3997,4000,4002,4005,4008,4011,4013,4015],{"class":138,"line":139},[136,3996,529],{"class":149},[136,3998,3999],{"class":156}," reloadSearcher",[136,4001,549],{"class":160},[136,4003,4004],{"class":535},"newContent",[136,4006,4007],{"class":160}," []",[136,4009,4010],{"class":149},"byte",[136,4012,543],{"class":160},[136,4014,571],{"class":149},[136,4016,489],{"class":160},[136,4018,4019,4021,4023,4025,4027],{"class":138,"line":146},[136,4020,3715],{"class":160},[136,4022,785],{"class":149},[136,4024,3720],{"class":160},[136,4026,3723],{"class":156},[136,4028,4029],{"class":160},"(newContent)\n",[136,4031,4032,4034,4036,4038,4040],{"class":138,"line":164},[136,4033,580],{"class":149},[136,4035,3375],{"class":160},[136,4037,3378],{"class":149},[136,4039,3381],{"class":209},[136,4041,489],{"class":160},[136,4043,4044,4046],{"class":138,"line":180},[136,4045,3703],{"class":149},[136,4047,3706],{"class":160},[136,4049,4050],{"class":138,"line":186},[136,4051,652],{"class":160},[136,4053,4054,4056,4058,4061],{"class":138,"line":367},[136,4055,3753],{"class":160},[136,4057,3756],{"class":156},[136,4059,4060],{"class":160},"(s) ",[136,4062,4063],{"class":142},"\u002F\u002F 原子切换，老查询继续走旧数据，新查询走新数据\n",[136,4065,4066,4068],{"class":138,"line":377},[136,4067,658],{"class":149},[136,4069,3766],{"class":209},[136,4071,4072],{"class":138,"line":387},[136,4073,315],{"class":160},[12,4075,4076],{"id":4076},"局限与诚实提醒",[16,4078,4079],{},"无论用哪种方案，都要清楚 IP 属地的边界：",[31,4081,4082,4088,4094,4100,4106],{},[34,4083,4084,4087],{},[26,4085,4086],{},"段级精度","：网段归属是「绝大多数」准确，个别 IP（大型企业自建段、跨省调度段）可能显示到相邻地区或只到省。",[34,4089,4090,4093],{},[26,4091,4092],{},"运营商级，不是定位","：拿到的是「省｜市｜运营商」，不是精确街道或基站位置——精确位置需要 GPS 或运营商三角定位，纯 IP 归属做不到。",[34,4095,4096,4099],{},[26,4097,4098],{},"动态 IP","：家宽通常是动态 IP，重拨会变，所以「上次登录在福州，这次在广州」不一定是异常，可能是 IP 变了。",[34,4101,4102,4105],{},[26,4103,4104],{},"别用于安全判定","：用 IP 段做封禁或风控判定风险自负，段级数据不适合当精确依据。",[34,4107,4108,4111,4112,4115],{},[26,4109,4110],{},"隐私与合规","：IP 地址属于个人信息（《个人信息保护法》）。归属地展示建议只出现在",[26,4113,4114],{},"用户本人可见","的页面（登录记录、个人中心）；若在评论区等公开场合展示，建议只显示到省市并明确告知用户。",[12,4117,4118],{"id":4118},"小结",[16,4120,2966],{},[1409,4122,4123],{},[16,4124,4125,4128],{},[26,4126,4127],{},"IP 属地 = 几十万条「IP 段 → 归属地」映射 + 一次二分查找。"," 它查的是网段归属，不是精确位置。",[31,4130,4131,4137,4144],{},[34,4132,4133,4134,4136],{},"想省心、免费、稳定，用 ",[26,4135,3097],{}," 本地离线查询（注意 IPv6 盲点）；",[34,4138,4139,4140,4143],{},"想要最新最细、能接受外部依赖，用",[26,4141,4142],{},"在线 API","（记得超时降级）；",[34,4145,4146,4147,1312],{},"面向全球用户、要 IPv6 覆盖，用 ",[26,4148,4149],{},"MaxMind GeoLite2",[16,4151,4152],{},"对个人博客「登录记录显示归属地」这种场景，本地离线方案通常是性价比最高的选择。",[1666,4154,4155],{},"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":132,"searchDepth":146,"depth":164,"links":4157},[4158,4159,4160,4166,4171,4172,4178,4179],{"id":3010,"depth":146,"text":3010},{"id":3042,"depth":146,"text":3043},{"id":3091,"depth":146,"text":3091,"children":4161},[4162,4163,4164,4165],{"id":3101,"depth":164,"text":3102},{"id":3116,"depth":164,"text":3117},{"id":3134,"depth":164,"text":3135},{"id":3153,"depth":164,"text":3154},{"id":3182,"depth":146,"text":3182,"children":4167},[4168,4169,4170],{"id":3188,"depth":164,"text":3189},{"id":3270,"depth":164,"text":3271},{"id":3462,"depth":164,"text":3463},{"id":3511,"depth":146,"text":3512},{"id":3566,"depth":146,"text":3567,"children":4173},[4174,4175,4176,4177],{"id":3573,"depth":164,"text":3574},{"id":3773,"depth":164,"text":3774},{"id":3859,"depth":164,"text":3860},{"id":3977,"depth":164,"text":3978},{"id":4076,"depth":146,"text":4076},{"id":4118,"depth":146,"text":4118},"科普 IP 属地查询的原理：IP 段与归属地的映射、纯本地离线查询为何能做到毫秒级，对比 ip2region、在线 API 与 MaxMind 三种方案的优劣，并附登录记录场景的 Go 实战与 IPv6 盲点提醒。","\u002Fposts\u002Fip-geolocation-guide\u002Fimg\u002Fcover.svg",{},"\u002Fposts\u002Fip-geolocation-guide","2026-08-07T12:00:00",{"title":3005,"description":4180},"posts\u002Fip-geolocation-guide\u002Findex",[4188,4189,4190],"IP","网络","科普","RY1UPgJaiKsjpC51uqMyG7w_GpCRS13_CX5j1hDD_dM",{"id":4193,"title":4194,"author":7,"body":4195,"description":4758,"difficulty":2991,"draft":1692,"extension":1693,"image":4759,"meta":4760,"navigation":344,"path":4761,"pinned":1692,"published":4762,"seo":4763,"series":4764,"seriesOrder":139,"stem":4765,"tags":4766,"updated":4768,"__hash__":4769},"posts\u002Fposts\u002Fsublog-evolution\u002Findex.md","尼克的小窝 架构变迁：从纯静态到前后端分离",{"type":9,"value":4196,"toc":4738},[4197,4200,4203,4214,4218,4222,4228,4232,4270,4273,4287,4291,4294,4363,4367,4370,4376,4380,4391,4421,4424,4428,4437,4440,4486,4490,4614,4618,4625,4629,4635,4641,4645,4648,4703,4707,4714,4721,4727,4730,4733],[12,4198,4199],{"id":3010},"📝 写在前面",[16,4201,4202],{},"SuBlog 最初只是一个简单的技术博客——Nuxt 3 静态生成，Markdown 写文章，部署到静态托管完事。但随着功能需求的增长，纯静态的架构开始显露出局限性。",[16,4204,4205,4206,4209,4210,4213],{},"这篇文章记录从 ",[26,4207,4208],{},"纯静态"," 到 ",[26,4211,4212],{},"前后端分离"," 的演进过程，带你了解每一步背后的思考。",[12,4215,4217],{"id":4216},"第一代纯静态nuxt-3-nuxtcontent","📦 第一代：纯静态（Nuxt 3 + @nuxt\u002Fcontent）",[111,4219,4221],{"id":4220},"️-架构图","🏗️ 架构图",[127,4223,4226],{"className":4224,"code":4225,"language":3068,"meta":132},[3066],"┌─────────────────────────────────────┐\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",[64,4227,4225],{"__ignoreMap":132},[111,4229,4231],{"id":4230},"做了什么","🔧 做了什么",[31,4233,4234,4247,4253,4259],{},[34,4235,4236,4239,4240,4239,4243,4246],{},[26,4237,4238],{},"Nuxt 3"," + ",[26,4241,4242],{},"TypeScript",[26,4244,4245],{},"Tailwind CSS v4"," 搭建前端",[34,4248,4249,4252],{},[26,4250,4251],{},"@nuxt\u002Fcontent"," 管理 Markdown 文章，编译时生成静态页面",[34,4254,4255,4258],{},[26,4256,4257],{},"Giscus"," 嵌入评论，依赖 GitHub Discussions",[34,4260,4261,4262,4265,4266,4269],{},"部署通过 ",[64,4263,4264],{},"nuxt generate"," 输出 ",[64,4267,4268],{},".output\u002Fpublic","，丢到 Nginx 或 Vercel",[111,4271,4272],{"id":3229},"✅ 优点",[31,4274,4275,4281,4284],{},[34,4276,4277,4278,4280],{},"部署极简，一个 ",[64,4279,4264],{}," 搞定",[34,4282,4283],{},"加载飞快，全是 HTML，没有服务端开销",[34,4285,4286],{},"无需后端，零运维成本",[111,4288,4290],{"id":4289},"局限","❌ 局限",[16,4292,4293],{},"随着时间的推移，纯静态的问题越来越明显：",[1745,4295,4296,4306],{},[1748,4297,4298],{},[1751,4299,4300,4303],{},[1754,4301,4302],{},"问题",[1754,4304,4305],{},"具体表现",[1767,4307,4308,4319,4330,4341,4352],{},[1751,4309,4310,4316],{},[1772,4311,4312,4313],{},"🐢 ",[26,4314,4315],{},"内容更新慢",[1772,4317,4318],{},"改一篇文章要重新构建、重新部署，不能实时发布",[1751,4320,4321,4327],{},[1772,4322,4323,4324],{},"👤 ",[26,4325,4326],{},"没有用户体系",[1772,4328,4329],{},"无法区分读者和管理员，所有内容对所有人一致可见",[1751,4331,4332,4338],{},[1772,4333,4334,4335],{},"🔒 ",[26,4336,4337],{},"评论受限",[1772,4339,4340],{},"Giscus 需要 GitHub 账号才能评论，门槛高",[1751,4342,4343,4349],{},[1772,4344,4345,4346],{},"🚫 ",[26,4347,4348],{},"无法做动态功能",[1772,4350,4351],{},"点赞、收藏、阅读统计、个性化推荐……统统做不了",[1751,4353,4354,4360],{},[1772,4355,4356,4357],{},"🔗 ",[26,4358,4359],{},"前端锁死",[1772,4361,4362],{},"被 Nuxt\u002FVue 生态绑定，想换技术栈得重写",[12,4364,4366],{"id":4365},"第二代前后端分离react-go","⚡ 第二代：前后端分离（React + Go）",[111,4368,4221],{"id":4369},"️-架构图-1",[127,4371,4374],{"className":4372,"code":4373,"language":3068,"meta":132},[3066],"┌───────────────────────┐       ┌────────────────────┐\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",[64,4375,4373],{"__ignoreMap":132},[111,4377,4379],{"id":4378},"️-前端sublog-react","🖥️ 前端：sublog-react",[16,4381,4382,4383,4386,4387,4390],{},"选用 ",[26,4384,4385],{},"Next.js","（App Router） + ",[26,4388,4389],{},"React 19","，理由是：",[31,4392,4393,4400,4407,4414],{},[34,4394,4395,4396,4399],{},"🎨 ",[26,4397,4398],{},"React 生态","——组件库成熟，开发效率高",[34,4401,4402,4403,4406],{},"📐 ",[26,4404,4405],{},"App Router","——支持布局嵌套，页面组织清晰",[34,4408,4409,4410,4413],{},"📦 ",[26,4411,4412],{},"静态导出","——仍可部署到静态托管",[34,4415,4416,4417,4420],{},"⚙️ ",[26,4418,4419],{},"运行时配置","——API 地址可动态注入，切换环境无需重新构建",[16,4422,4423],{},"前端不再直接读取 Markdown 文件，而是通过 HTTP API 获取文章数据。静态导出 + 客户端数据获取的组合，既保留了首屏加载速度，又实现了内容的动态更新。",[111,4425,4427],{"id":4426},"️-后端sublog-s","⚙️ 后端：sublog-s",[16,4429,4430,4431,4239,4433,4436],{},"后端使用 ",[26,4432,1702],{},[26,4434,4435],{},"Gin","，按业务逻辑分层组织代码。",[16,4438,4439],{},"核心技术选型：",[1745,4441,4442,4452],{},[1748,4443,4444],{},[1751,4445,4446,4449],{},[1754,4447,4448],{},"技术",[1754,4450,4451],{},"用途",[1767,4453,4454,4464,4475],{},[1751,4455,4456,4461],{},[1772,4457,4458,4459],{},"🚀 ",[26,4460,4435],{},[1772,4462,4463],{},"HTTP 框架",[1751,4465,4466,4472],{},[1772,4467,4468,4469],{},"🗄️ ",[26,4470,4471],{},"GORM",[1772,4473,4474],{},"ORM，数据库操作",[1751,4476,4477,4483],{},[1772,4478,4479,4480],{},"🔐 ",[26,4481,4482],{},"JWT",[1772,4484,4485],{},"用户认证",[12,4487,4489],{"id":4488},"前后对比","📊 前后对比",[1745,4491,4492,4505],{},[1748,4493,4494],{},[1751,4495,4496,4499,4502],{},[1754,4497,4498],{},"维度",[1754,4500,4501],{},"第一代（纯静态）",[1754,4503,4504],{},"第二代（前后端分离）",[1767,4506,4507,4520,4533,4547,4560,4573,4587,4600],{},[1751,4508,4509,4514,4517],{},[1772,4510,4395,4511],{},[26,4512,4513],{},"前端框架",[1772,4515,4516],{},"Nuxt 3 (Vue)",[1772,4518,4519],{},"Next.js (React 19)",[1751,4521,4522,4528,4530],{},[1772,4523,4524,4525],{},"🖥️ ",[26,4526,4527],{},"后端",[1772,4529,1781],{},[1772,4531,4532],{},"Go + Gin",[1751,4534,4535,4541,4544],{},[1772,4536,4537,4538],{},"📄 ",[26,4539,4540],{},"内容来源",[1772,4542,4543],{},"Markdown 文件",[1772,4545,4546],{},"API 数据库",[1751,4548,4549,4554,4557],{},[1772,4550,4458,4551],{},[26,4552,4553],{},"发布方式",[1772,4555,4556],{},"全量重新构建",[1772,4558,4559],{},"API 实时发布",[1751,4561,4562,4567,4570],{},[1772,4563,4323,4564],{},[26,4565,4566],{},"用户系统",[1772,4568,4569],{},"❌ 无",[1772,4571,4572],{},"✅ 注册\u002F登录",[1751,4574,4575,4581,4584],{},[1772,4576,4577,4578],{},"💬 ",[26,4579,4580],{},"评论",[1772,4582,4583],{},"Giscus（需 GitHub）",[1772,4585,4586],{},"自有评论系统（规划中）",[1751,4588,4589,4594,4597],{},[1772,4590,4409,4591],{},[26,4592,4593],{},"部署",[1772,4595,4596],{},"1 个静态目录",[1772,4598,4599],{},"前端静态 + 后端二进制",[1751,4601,4602,4608,4611],{},[1772,4603,4604,4605],{},"⚡ ",[26,4606,4607],{},"更新体验",[1772,4609,4610],{},"改文章 → 构建 → 部署",[1772,4612,4613],{},"改文章 → 立即生效",[12,4615,4617],{"id":4616},"新功能注册账号","🎉 新功能：注册账号",[16,4619,4620,4621,4624],{},"伴随着架构升级，",[26,4622,4623],{},"用户账号系统","也上线了。",[111,4626,4628],{"id":4627},"目前支持","📋 目前支持",[16,4630,4631,4632,125],{},"现在你可以在尼克的小窝 ",[26,4633,4634],{},"注册一个账号",[127,4636,4639],{"className":4637,"code":4638,"language":3068},[3066],"1. ✏️ 填写用户名、邮箱、密码\n2. 📧 查收激活邮件\n3. 🔗 点击链接激活账号\n4. 🚪 登录后查看和管理个人信息\n",[64,4640,4638],{"__ignoreMap":132},[12,4642,4644],{"id":4643},"️-后续计划","🗓️ 后续计划",[16,4646,4647],{},"正在开发或规划中的功能：",[31,4649,4652,4661,4667,4673,4679,4685,4691,4697],{"className":4650},[4651],"contains-task-list",[34,4653,4656,4660],{"className":4654},[4655],"task-list-item",[4657,4658],"input",{"checked":344,"disabled":344,"type":4659},"checkbox"," 用户注册\u002F登录（已上线 🎉）",[34,4662,4664,4666],{"className":4663},[4655],[4657,4665],{"checked":344,"disabled":344,"type":4659}," 个人资料编辑（已上线 🎉）",[34,4668,4670,4672],{"className":4669},[4655],[4657,4671],{"disabled":344,"type":4659}," 💬 站内评论系统",[34,4674,4676,4678],{"className":4675},[4655],[4657,4677],{"disabled":344,"type":4659}," 🔖 文章收藏",[34,4680,4682,4684],{"className":4681},[4655],[4657,4683],{"disabled":344,"type":4659}," 📚 阅读历史",[34,4686,4688,4690],{"className":4687},[4655],[4657,4689],{"disabled":344,"type":4659}," 👍 文章点赞",[34,4692,4694,4696],{"className":4693},[4655],[4657,4695],{"disabled":344,"type":4659}," 🔔 关注通知",[34,4698,4700,4702],{"className":4699},[4655],[4657,4701],{"disabled":344,"type":4659}," 🎨 个人首页定制",[12,4704,4706],{"id":4705},"结语","💡 结语",[16,4708,4709,4710,4713],{},"从 Nuxt 3 纯静态到 React + Go 前后端分离，SuBlog 的架构变迁本质上是一个",[26,4711,4712],{},"功能驱动","的过程。静态博客有它的位置——写写文章、展示内容完全够用。但当你想做的不仅仅是\"展示\"时，后端的能力就不可或缺了。",[16,4715,4716,4717,4720],{},"这次重构最大的感触是：",[26,4718,4719],{},"架构选型不是越复杂越好，而是刚好够用最好","。纯静态阶段用 Nuxt 3 一点问题没有，直到功能需求超出了静态的边界，才自然推动到前后端分离。",[16,4722,4723,4724],{},"如果你也在纠结\"要不要上后端\"，不妨先问问自己：",[26,4725,4726],{},"你要做的是\"展示内容\"还是\"提供服务\"？",[16,4728,4729],{},"前者，静态就够了；后者，来吧，后端欢迎你。😄",[4731,4732],"hr",{},[16,4734,4735],{},[26,4736,4737],{},"🎉 目前注册功能已经可以用了，后续更多好玩的功能正在路上。部分功能可能需要登录才能体验，快注册一个账号吧！",{"title":132,"searchDepth":146,"depth":164,"links":4739},[4740,4741,4747,4752,4753,4756,4757],{"id":3010,"depth":146,"text":4199},{"id":4216,"depth":146,"text":4217,"children":4742},[4743,4744,4745,4746],{"id":4220,"depth":164,"text":4221},{"id":4230,"depth":164,"text":4231},{"id":3229,"depth":164,"text":4272},{"id":4289,"depth":164,"text":4290},{"id":4365,"depth":146,"text":4366,"children":4748},[4749,4750,4751],{"id":4369,"depth":164,"text":4221},{"id":4378,"depth":164,"text":4379},{"id":4426,"depth":164,"text":4427},{"id":4488,"depth":146,"text":4489},{"id":4616,"depth":146,"text":4617,"children":4754},[4755],{"id":4627,"depth":164,"text":4628},{"id":4643,"depth":146,"text":4644},{"id":4705,"depth":146,"text":4706},"记录尼克的小窝从 Nuxt 3 纯静态博客演进到 React + Go 前后端分离架构的全过程，包括架构设计的思考、技术选型的变化，以及新增的账号注册功能。","\u002Fposts\u002Fsublog-evolution\u002Fimg\u002Fcover.jpg",{},"\u002Fposts\u002Fsublog-evolution","2026-07-23T12:00:00",{"title":4194,"description":4758},"尼克的小窝 开发记录","posts\u002Fsublog-evolution\u002Findex",[4767,1703,4385,1702],"架构","2026-07-23","VMQ6A2K8GQ5t3DSlezWPd3KgXnmDVMn9QVeaI28Hcp8",{"id":4771,"title":4772,"author":7,"body":4773,"description":6186,"difficulty":2991,"draft":1692,"extension":1693,"image":6187,"meta":6188,"navigation":344,"path":6189,"pinned":1692,"published":6190,"seo":6191,"series":7,"seriesOrder":7,"stem":6192,"tags":6193,"updated":6190,"__hash__":6199},"posts\u002Fposts\u002Fjira-cli-intro\u002Findex.md","jira_cli — 把企业 Jira 装进命令行，AI Agent 直接调用",{"type":9,"value":4774,"toc":6165},[4775,4779,4795,4864,4867,4870,4946,4949,4982,4989,4992,4996,5112,5116,5248,5252,5321,5324,5391,5394,5397,5401,5404,5424,5427,5442,5445,5449,5456,5594,5605,5636,5640,5643,5740,5744,5747,5867,5871,5874,5938,5941,5945,5948,6016,6019,6025,6028,6062,6069,6072,6132,6134,6137,6154,6162],[12,4776,4778],{"id":4777},"jira_cli-是什么","jira_cli 是什么",[16,4780,4781,4787,4788,696,4791,4794],{},[20,4782,4786],{"href":4783,"rel":4784},"https:\u002F\u002Fgithub.com\u002Fyourname\u002Fjira_cli",[4785],"nofollow","jira_cli"," 是一个面向企业 Jira（Server \u002F Data Center）的本地命令行工具，Go 语言编写，MIT 协议开源。它同时提供 ",[26,4789,4790],{},"Cobra CLI",[26,4792,4793],{},"MCP stdio server"," 两种入口，既能当日常命令行用，又能直接接入 AI 编程工具。",[127,4796,4798],{"className":1095,"code":4797,"language":1097,"meta":132,"style":132},"# 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",[64,4799,4800,4805,4818,4830,4845,4849,4854],{"__ignoreMap":132},[136,4801,4802],{"class":138,"line":139},[136,4803,4804],{"class":142},"# CLI 日常使用\n",[136,4806,4807,4809,4812,4815],{"class":138,"line":146},[136,4808,4786],{"class":156},[136,4810,4811],{"class":170}," issue",[136,4813,4814],{"class":170}," search",[136,4816,4817],{"class":170}," \"project = PROJ ORDER BY updated DESC\"\n",[136,4819,4820,4822,4824,4827],{"class":138,"line":164},[136,4821,4786],{"class":156},[136,4823,4811],{"class":170},[136,4825,4826],{"class":170}," get",[136,4828,4829],{"class":170}," PROJ-123\n",[136,4831,4832,4834,4836,4839,4842],{"class":138,"line":180},[136,4833,4786],{"class":156},[136,4835,4811],{"class":170},[136,4837,4838],{"class":170}," comment",[136,4840,4841],{"class":170}," PROJ-123",[136,4843,4844],{"class":170}," \"处理完成，请测试验证\"\n",[136,4846,4847],{"class":138,"line":186},[136,4848,345],{"emptyLinePlaceholder":344},[136,4850,4851],{"class":138,"line":367},[136,4852,4853],{"class":142},"# MCP 模式 — 给 AI 装上操作 Jira 的手\n",[136,4855,4856,4858,4861],{"class":138,"line":377},[136,4857,4786],{"class":156},[136,4859,4860],{"class":170}," mcp",[136,4862,4863],{"class":209}," --user=work\n",[12,4865,4866],{"id":4866},"为什么需要它",[16,4868,4869],{},"大多数 Jira 命令行工具只解决\"开发者手动操作 Jira\"这一个场景。但 2026 年 AI 编程工具已经普及，你的 AI Agent（Claude Code、Codex、Cursor 等）也需要读写 Jira。痛点就来了：",[1745,4871,4872,4882],{},[1748,4873,4874],{},[1751,4875,4876,4879],{},[1754,4877,4878],{},"痛点",[1754,4880,4881],{},"jira_cli 怎么解",[1767,4883,4884,4892,4900,4912,4923,4934],{},[1751,4885,4886,4889],{},[1772,4887,4888],{},"企业 Jira 在内网，没有公网 API",[1772,4890,4891],{},"本地 stdio MCP server，不经过外网",[1751,4893,4894,4897],{},[1772,4895,4896],{},"AI 可能误删、误写 Jira 数据",[1772,4898,4899],{},"账号级只读策略，AI 只能查不能改",[1751,4901,4902,4905],{},[1772,4903,4904],{},"团队多人多 Jira 实例",[1772,4906,4907,4908,4911],{},"多 Profile 管理，",[64,4909,4910],{},"--user=work"," 切换",[1751,4913,4914,4917],{},[1772,4915,4916],{},"Windows 终端体验差",[1772,4918,4919,4920,4922],{},"原生 ",[64,4921,1434],{}," 构建 + 一键安装脚本",[1751,4924,4925,4928],{},[1772,4926,4927],{},"AI 生成 JQL 困难",[1772,4929,4930,4931],{},"字段元数据发现：",[64,4932,4933],{},"field list --query xxx",[1751,4935,4936,4939],{},[1772,4937,4938],{},"MCP 直接拿原始 JSON 太冗长",[1772,4940,4941,4942,4945],{},"紧凑视图 + ",[64,4943,4944],{},"--view summary"," 减少 token 消耗",[12,4947,4948],{"id":4948},"安装",[127,4950,4952],{"className":1095,"code":4951,"language":1097,"meta":132,"style":132},"# 从源码安装（要求 Go 1.26.3+）\ngo install github.com\u002Fyourname\u002Fjira_cli@latest\n\n# 或下载预编译二进制\n# https:\u002F\u002Fgithub.com\u002Fyourname\u002Fjira_cli\u002Freleases\n",[64,4953,4954,4959,4968,4972,4977],{"__ignoreMap":132},[136,4955,4956],{"class":138,"line":139},[136,4957,4958],{"class":142},"# 从源码安装（要求 Go 1.26.3+）\n",[136,4960,4961,4963,4965],{"class":138,"line":146},[136,4962,324],{"class":156},[136,4964,1872],{"class":170},[136,4966,4967],{"class":170}," github.com\u002Fyourname\u002Fjira_cli@latest\n",[136,4969,4970],{"class":138,"line":164},[136,4971,345],{"emptyLinePlaceholder":344},[136,4973,4974],{"class":138,"line":180},[136,4975,4976],{"class":142},"# 或下载预编译二进制\n",[136,4978,4979],{"class":138,"line":186},[136,4980,4981],{"class":142},"# https:\u002F\u002Fgithub.com\u002Fyourname\u002Fjira_cli\u002Freleases\n",[16,4983,4984,4985,4988],{},"Windows 用户下载 zip 包解压后双击 ",[64,4986,4987],{},"install.bat"," 即可全局安装，自动加入 PATH。",[12,4990,4991],{"id":4991},"快速上手",[111,4993,4995],{"id":4994},"_1-配置认证","1. 配置认证",[127,4997,4999],{"className":1095,"code":4998,"language":1097,"meta":132,"style":132},"# 设置 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",[64,5000,5001,5006,5022,5026,5031,5053,5057,5062,5093,5097,5102],{"__ignoreMap":132},[136,5002,5003],{"class":138,"line":139},[136,5004,5005],{"class":142},"# 设置 Jira 地址\n",[136,5007,5008,5010,5013,5016,5019],{"class":138,"line":146},[136,5009,4786],{"class":156},[136,5011,5012],{"class":170}," set",[136,5014,5015],{"class":170}," base-url",[136,5017,5018],{"class":170}," https:\u002F\u002Fjira.your-company.com",[136,5020,5021],{"class":209}," --global\n",[136,5023,5024],{"class":138,"line":164},[136,5025,345],{"emptyLinePlaceholder":344},[136,5027,5028],{"class":138,"line":180},[136,5029,5030],{"class":142},"# Token 认证（推荐）\n",[136,5032,5033,5035,5037,5040,5043,5046,5049,5051],{"class":138,"line":186},[136,5034,4786],{"class":156},[136,5036,5012],{"class":170},[136,5038,5039],{"class":170}," token",[136,5041,5042],{"class":149}," \u003C",[136,5044,5045],{"class":170},"your-toke",[136,5047,5048],{"class":160},"n",[136,5050,2927],{"class":149},[136,5052,5021],{"class":209},[136,5054,5055],{"class":138,"line":367},[136,5056,345],{"emptyLinePlaceholder":344},[136,5058,5059],{"class":138,"line":377},[136,5060,5061],{"class":142},"# 或用户名密码认证\n",[136,5063,5064,5066,5068,5071,5073,5076,5079,5081,5083,5086,5089,5091],{"class":138,"line":387},[136,5065,4786],{"class":156},[136,5067,5012],{"class":170},[136,5069,5070],{"class":170}," auth",[136,5072,5042],{"class":149},[136,5074,5075],{"class":170},"usernam",[136,5077,5078],{"class":160},"e",[136,5080,2927],{"class":149},[136,5082,5042],{"class":149},[136,5084,5085],{"class":170},"passwor",[136,5087,5088],{"class":160},"d",[136,5090,2927],{"class":149},[136,5092,5021],{"class":209},[136,5094,5095],{"class":138,"line":393},[136,5096,345],{"emptyLinePlaceholder":344},[136,5098,5099],{"class":138,"line":398},[136,5100,5101],{"class":142},"# 验证一下\n",[136,5103,5104,5106,5109],{"class":138,"line":404},[136,5105,4786],{"class":156},[136,5107,5108],{"class":170}," user",[136,5110,5111],{"class":170}," me\n",[111,5113,5115],{"id":5114},"_2-日常使用","2. 日常使用",[127,5117,5119],{"className":1095,"code":5118,"language":1097,"meta":132,"style":132},"# 查自己今天的任务\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",[64,5120,5121,5126,5137,5141,5146,5156,5160,5165,5179,5196,5200,5205,5218,5222,5227],{"__ignoreMap":132},[136,5122,5123],{"class":138,"line":139},[136,5124,5125],{"class":142},"# 查自己今天的任务\n",[136,5127,5128,5130,5132,5134],{"class":138,"line":146},[136,5129,4786],{"class":156},[136,5131,4811],{"class":170},[136,5133,4814],{"class":170},[136,5135,5136],{"class":170}," \"assignee = currentUser() ORDER BY updated DESC\"\n",[136,5138,5139],{"class":138,"line":164},[136,5140,345],{"emptyLinePlaceholder":344},[136,5142,5143],{"class":138,"line":180},[136,5144,5145],{"class":142},"# 看某个任务详情\n",[136,5147,5148,5150,5152,5154],{"class":138,"line":186},[136,5149,4786],{"class":156},[136,5151,4811],{"class":170},[136,5153,4826],{"class":170},[136,5155,4829],{"class":170},[136,5157,5158],{"class":138,"line":367},[136,5159,345],{"emptyLinePlaceholder":344},[136,5161,5162],{"class":138,"line":377},[136,5163,5164],{"class":142},"# 流转状态\n",[136,5166,5167,5169,5171,5174,5176],{"class":138,"line":387},[136,5168,4786],{"class":156},[136,5170,4811],{"class":170},[136,5172,5173],{"class":170}," transitions",[136,5175,4841],{"class":170},[136,5177,5178],{"class":142},"                    # 可以转哪些\n",[136,5180,5181,5183,5185,5188,5190,5193],{"class":138,"line":393},[136,5182,4786],{"class":156},[136,5184,4811],{"class":170},[136,5186,5187],{"class":170}," do-transition",[136,5189,4841],{"class":170},[136,5191,5192],{"class":209}," 21",[136,5194,5195],{"class":142},"               # 执行流转\n",[136,5197,5198],{"class":138,"line":398},[136,5199,345],{"emptyLinePlaceholder":344},[136,5201,5202],{"class":138,"line":404},[136,5203,5204],{"class":142},"# 添加备注\n",[136,5206,5207,5209,5211,5213,5215],{"class":138,"line":421},[136,5208,4786],{"class":156},[136,5210,4811],{"class":170},[136,5212,4838],{"class":170},[136,5214,4841],{"class":170},[136,5216,5217],{"class":170}," \"已定位到问题，正在修复\"\n",[136,5219,5220],{"class":138,"line":426},[136,5221,345],{"emptyLinePlaceholder":344},[136,5223,5224],{"class":138,"line":432},[136,5225,5226],{"class":142},"# 创建任务\n",[136,5228,5229,5231,5233,5236,5239,5242,5245],{"class":138,"line":472},[136,5230,4786],{"class":156},[136,5232,4811],{"class":170},[136,5234,5235],{"class":170}," create",[136,5237,5238],{"class":170}," PROJ",[136,5240,5241],{"class":170}," Task",[136,5243,5244],{"class":209}," --fields",[136,5246,5247],{"class":170}," @fields.json\n",[111,5249,5251],{"id":5250},"_3-配给-ai-使用","3. 配给 AI 使用",[127,5253,5255],{"className":1095,"code":5254,"language":1097,"meta":132,"style":132},"# 先配置一个只读账号给 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",[64,5256,5257,5262,5280,5303],{"__ignoreMap":132},[136,5258,5259],{"class":138,"line":139},[136,5260,5261],{"class":142},"# 先配置一个只读账号给 AI 用\n",[136,5263,5264,5266,5268,5270,5272,5275,5278],{"class":138,"line":146},[136,5265,4786],{"class":156},[136,5267,5012],{"class":170},[136,5269,5015],{"class":170},[136,5271,5018],{"class":170},[136,5273,5274],{"class":209}," --name",[136,5276,5277],{"class":170}," ai",[136,5279,5021],{"class":209},[136,5281,5282,5284,5286,5288,5290,5293,5295,5297,5299,5301],{"class":138,"line":164},[136,5283,4786],{"class":156},[136,5285,5012],{"class":170},[136,5287,5039],{"class":170},[136,5289,5042],{"class":149},[136,5291,5292],{"class":170},"toke",[136,5294,5048],{"class":160},[136,5296,2927],{"class":149},[136,5298,5274],{"class":209},[136,5300,5277],{"class":170},[136,5302,5021],{"class":209},[136,5304,5305,5307,5309,5312,5315,5317,5319],{"class":138,"line":180},[136,5306,4786],{"class":156},[136,5308,5012],{"class":170},[136,5310,5311],{"class":170}," readonly",[136,5313,5314],{"class":170}," on",[136,5316,5274],{"class":209},[136,5318,5277],{"class":170},[136,5320,5021],{"class":209},[16,5322,5323],{},"然后在 AI 工具的 MCP 配置里加上：",[127,5325,5327],{"className":282,"code":5326,"language":284,"meta":132,"style":132},"{\n  \"mcpServers\": {\n    \"jira\": {\n      \"command\": \"jira_cli\",\n      \"args\": [\"mcp\", \"--user=ai\"]\n    }\n  }\n}\n",[64,5328,5329,5334,5341,5348,5360,5378,5382,5387],{"__ignoreMap":132},[136,5330,5331],{"class":138,"line":139},[136,5332,5333],{"class":160},"{\n",[136,5335,5336,5339],{"class":138,"line":146},[136,5337,5338],{"class":209},"  \"mcpServers\"",[136,5340,299],{"class":160},[136,5342,5343,5346],{"class":138,"line":164},[136,5344,5345],{"class":209},"    \"jira\"",[136,5347,299],{"class":160},[136,5349,5350,5353,5355,5358],{"class":138,"line":180},[136,5351,5352],{"class":209},"      \"command\"",[136,5354,307],{"class":160},[136,5356,5357],{"class":170},"\"jira_cli\"",[136,5359,2073],{"class":160},[136,5361,5362,5365,5368,5371,5373,5376],{"class":138,"line":186},[136,5363,5364],{"class":209},"      \"args\"",[136,5366,5367],{"class":160},": [",[136,5369,5370],{"class":170},"\"mcp\"",[136,5372,568],{"class":160},[136,5374,5375],{"class":170},"\"--user=ai\"",[136,5377,3645],{"class":160},[136,5379,5380],{"class":138,"line":367},[136,5381,652],{"class":160},[136,5383,5384],{"class":138,"line":377},[136,5385,5386],{"class":160},"  }\n",[136,5388,5389],{"class":138,"line":387},[136,5390,315],{"class":160},[16,5392,5393],{},"这样 AI 就能查 Jira 但不能修改，既有了上下文，又不用担心误操作。",[12,5395,5396],{"id":5396},"亮点",[111,5398,5400],{"id":5399},"_1-账号级只读策略","1. 账号级只读策略",[16,5402,5403],{},"这是 jira_cli 最实用的设计。给 AI Agent 配置只读账号：",[127,5405,5407],{"className":1095,"code":5406,"language":1097,"meta":132,"style":132},"jira_cli set readonly on --name ai\n",[64,5408,5409],{"__ignoreMap":132},[136,5410,5411,5413,5415,5417,5419,5421],{"class":138,"line":139},[136,5412,4786],{"class":156},[136,5414,5012],{"class":170},[136,5416,5311],{"class":170},[136,5418,5314],{"class":170},[136,5420,5274],{"class":209},[136,5422,5423],{"class":170}," ai\n",[16,5425,5426],{},"只读模式下：",[31,5428,5429,5432,5435],{},[34,5430,5431],{},"CLI 写命令被隐藏和拒绝",[34,5433,5434],{},"MCP 的写入工具不会注册",[34,5436,5437,5438,5441],{},"环境变量 ",[64,5439,5440],{},"JIRA_READONLY=true"," 也可以临时覆盖",[16,5443,5444],{},"AI 只能查 JQL、看任务、查字段、读评论，绝对不会误改数据。需要创建\u002F更新时，另配一个有写入权限的账号由开发者自己操作。",[111,5446,5448],{"id":5447},"_2-多-profile-切换","2. 多 Profile 切换",[16,5450,5451,5452,5455],{},"一个配置文件里管理多个 Jira 账号，",[64,5453,5454],{},"--user"," 切换：",[127,5457,5459],{"className":1095,"code":5458,"language":1097,"meta":132,"style":132},"# 工作账号\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",[64,5460,5461,5466,5484,5507,5511,5516,5534,5557,5561,5566,5580],{"__ignoreMap":132},[136,5462,5463],{"class":138,"line":139},[136,5464,5465],{"class":142},"# 工作账号\n",[136,5467,5468,5470,5472,5474,5477,5479,5482],{"class":138,"line":146},[136,5469,4786],{"class":156},[136,5471,5012],{"class":170},[136,5473,5015],{"class":170},[136,5475,5476],{"class":170}," https:\u002F\u002Fjira.company.com",[136,5478,5274],{"class":209},[136,5480,5481],{"class":170}," work",[136,5483,5021],{"class":209},[136,5485,5486,5488,5490,5492,5494,5497,5499,5501,5503,5505],{"class":138,"line":164},[136,5487,4786],{"class":156},[136,5489,5012],{"class":170},[136,5491,5039],{"class":170},[136,5493,5042],{"class":149},[136,5495,5496],{"class":170},"work-toke",[136,5498,5048],{"class":160},[136,5500,2927],{"class":149},[136,5502,5274],{"class":209},[136,5504,5481],{"class":170},[136,5506,5021],{"class":209},[136,5508,5509],{"class":138,"line":180},[136,5510,345],{"emptyLinePlaceholder":344},[136,5512,5513],{"class":138,"line":186},[136,5514,5515],{"class":142},"# 个人账号\n",[136,5517,5518,5520,5522,5524,5527,5529,5532],{"class":138,"line":367},[136,5519,4786],{"class":156},[136,5521,5012],{"class":170},[136,5523,5015],{"class":170},[136,5525,5526],{"class":170}," https:\u002F\u002Fjira.personal.com",[136,5528,5274],{"class":209},[136,5530,5531],{"class":170}," personal",[136,5533,5021],{"class":209},[136,5535,5536,5538,5540,5542,5544,5547,5549,5551,5553,5555],{"class":138,"line":377},[136,5537,4786],{"class":156},[136,5539,5012],{"class":170},[136,5541,5039],{"class":170},[136,5543,5042],{"class":149},[136,5545,5546],{"class":170},"personal-toke",[136,5548,5048],{"class":160},[136,5550,2927],{"class":149},[136,5552,5274],{"class":209},[136,5554,5531],{"class":170},[136,5556,5021],{"class":209},[136,5558,5559],{"class":138,"line":387},[136,5560,345],{"emptyLinePlaceholder":344},[136,5562,5563],{"class":138,"line":393},[136,5564,5565],{"class":142},"# 使用时切换\n",[136,5567,5568,5570,5573,5575,5577],{"class":138,"line":398},[136,5569,4786],{"class":156},[136,5571,5572],{"class":209}," --user=work",[136,5574,4811],{"class":170},[136,5576,4814],{"class":170},[136,5578,5579],{"class":170}," \"project = PROJ\"\n",[136,5581,5582,5584,5587,5589,5591],{"class":138,"line":404},[136,5583,4786],{"class":156},[136,5585,5586],{"class":209}," --user=personal",[136,5588,4811],{"class":170},[136,5590,4814],{"class":170},[136,5592,5593],{"class":170}," \"project = SIDE\"\n",[16,5595,5596,5597,5600,5601,5604],{},"同时支持",[26,5598,5599],{},"项目级配置","（",[64,5602,5603],{},"--local","）和工作目录绑定：",[127,5606,5608],{"className":1095,"code":5607,"language":1097,"meta":132,"style":132},"# 在项目目录下，配置只对当前项目生效\ncd ~\u002Fprojects\u002Fbackend\njira_cli set base-url https:\u002F\u002Fjira-backend.company.com --local\n",[64,5609,5610,5615,5622],{"__ignoreMap":132},[136,5611,5612],{"class":138,"line":139},[136,5613,5614],{"class":142},"# 在项目目录下，配置只对当前项目生效\n",[136,5616,5617,5619],{"class":138,"line":146},[136,5618,1125],{"class":209},[136,5620,5621],{"class":170}," ~\u002Fprojects\u002Fbackend\n",[136,5623,5624,5626,5628,5630,5633],{"class":138,"line":164},[136,5625,4786],{"class":156},[136,5627,5012],{"class":170},[136,5629,5015],{"class":170},[136,5631,5632],{"class":170}," https:\u002F\u002Fjira-backend.company.com",[136,5634,5635],{"class":209}," --local\n",[111,5637,5639],{"id":5638},"_3-对-ai-友好的输出","3. 对 AI 友好的输出",[16,5641,5642],{},"jira_cli 在输出设计上充分考虑了 AI Agent 的 token 消耗：",[127,5644,5646],{"className":1095,"code":5645,"language":1097,"meta":132,"style":132},"# 紧凑视图 — 去掉 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",[64,5647,5648,5653,5669,5673,5678,5695,5699,5704,5719,5723,5728],{"__ignoreMap":132},[136,5649,5650],{"class":138,"line":139},[136,5651,5652],{"class":142},"# 紧凑视图 — 去掉 self\u002Favatar\u002F缩略图等无用字段\n",[136,5654,5655,5657,5659,5661,5663,5666],{"class":138,"line":146},[136,5656,4786],{"class":156},[136,5658,4811],{"class":170},[136,5660,4826],{"class":170},[136,5662,4841],{"class":170},[136,5664,5665],{"class":209}," --view",[136,5667,5668],{"class":170}," summary\n",[136,5670,5671],{"class":138,"line":164},[136,5672,345],{"emptyLinePlaceholder":344},[136,5674,5675],{"class":138,"line":180},[136,5676,5677],{"class":142},"# 表格输出 — 一眼看到关键信息\n",[136,5679,5680,5682,5684,5686,5689,5692],{"class":138,"line":186},[136,5681,4786],{"class":156},[136,5683,4811],{"class":170},[136,5685,4814],{"class":170},[136,5687,5688],{"class":170}," \"project = PROJ\"",[136,5690,5691],{"class":209}," --output",[136,5693,5694],{"class":170}," table\n",[136,5696,5697],{"class":138,"line":367},[136,5698,345],{"emptyLinePlaceholder":344},[136,5700,5701],{"class":138,"line":377},[136,5702,5703],{"class":142},"# JSON 输出 — 程序解析，不转义 Unicode\n",[136,5705,5706,5708,5710,5713,5715,5717],{"class":138,"line":387},[136,5707,4786],{"class":156},[136,5709,5691],{"class":209},[136,5711,5712],{"class":170}," json",[136,5714,4811],{"class":170},[136,5716,4826],{"class":170},[136,5718,4829],{"class":170},[136,5720,5721],{"class":138,"line":393},[136,5722,345],{"emptyLinePlaceholder":344},[136,5724,5725],{"class":138,"line":398},[136,5726,5727],{"class":142},"# 自定义搜索列 — 只要需要的字段\n",[136,5729,5730,5732,5734,5737],{"class":138,"line":404},[136,5731,4786],{"class":156},[136,5733,5012],{"class":170},[136,5735,5736],{"class":170}," search-columns",[136,5738,5739],{"class":170}," key,summary,fixVersions\n",[111,5741,5743],{"id":5742},"_4-字段元数据发现","4. 字段元数据发现",[16,5745,5746],{},"Jira 的 customfield 让人头疼，jira_cli 提供了完整的字段发现能力：",[127,5748,5750],{"className":1095,"code":5749,"language":1097,"meta":132,"style":132},"# 列出所有字段及其类型\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",[64,5751,5752,5757,5767,5771,5776,5791,5795,5800,5811,5815,5820,5837,5841,5846],{"__ignoreMap":132},[136,5753,5754],{"class":138,"line":139},[136,5755,5756],{"class":142},"# 列出所有字段及其类型\n",[136,5758,5759,5761,5764],{"class":138,"line":146},[136,5760,4786],{"class":156},[136,5762,5763],{"class":170}," field",[136,5765,5766],{"class":170}," list\n",[136,5768,5769],{"class":138,"line":164},[136,5770,345],{"emptyLinePlaceholder":344},[136,5772,5773],{"class":138,"line":180},[136,5774,5775],{"class":142},"# 搜索包含特定关键词的字段\n",[136,5777,5778,5780,5782,5785,5788],{"class":138,"line":186},[136,5779,4786],{"class":156},[136,5781,5763],{"class":170},[136,5783,5784],{"class":170}," list",[136,5786,5787],{"class":209}," --query",[136,5789,5790],{"class":170}," \"版本\"\n",[136,5792,5793],{"class":138,"line":367},[136,5794,345],{"emptyLinePlaceholder":344},[136,5796,5797],{"class":138,"line":377},[136,5798,5799],{"class":142},"# 只看自定义字段\n",[136,5801,5802,5804,5806,5808],{"class":138,"line":387},[136,5803,4786],{"class":156},[136,5805,5763],{"class":170},[136,5807,5784],{"class":170},[136,5809,5810],{"class":209}," --custom-only\n",[136,5812,5813],{"class":138,"line":393},[136,5814,345],{"emptyLinePlaceholder":344},[136,5816,5817],{"class":138,"line":398},[136,5818,5819],{"class":142},"# 创建任务前，先看看需要哪些字段\n",[136,5821,5822,5824,5826,5829,5831,5834],{"class":138,"line":404},[136,5823,4786],{"class":156},[136,5825,4811],{"class":170},[136,5827,5828],{"class":170}," create-meta",[136,5830,5238],{"class":170},[136,5832,5833],{"class":209}," --issue-type",[136,5835,5836],{"class":170}," Task\n",[136,5838,5839],{"class":138,"line":421},[136,5840,345],{"emptyLinePlaceholder":344},[136,5842,5843],{"class":138,"line":426},[136,5844,5845],{"class":142},"# 分页查看可选值\n",[136,5847,5848,5850,5852,5855,5857,5859,5862,5864],{"class":138,"line":432},[136,5849,4786],{"class":156},[136,5851,4811],{"class":170},[136,5853,5854],{"class":170}," create-meta-values",[136,5856,5238],{"class":170},[136,5858,5241],{"class":170},[136,5860,5861],{"class":170}," priority",[136,5863,5787],{"class":209},[136,5865,5866],{"class":170}," High\n",[111,5868,5870],{"id":5869},"_5-mcp-写入控制","5. MCP 写入控制",[16,5872,5873],{},"除了全局只读，MCP 还有更细粒度的控制：",[1745,5875,5876,5886],{},[1748,5877,5878],{},[1751,5879,5880,5883],{},[1754,5881,5882],{},"环境变量",[1754,5884,5885],{},"作用",[1767,5887,5888,5898,5908,5918,5928],{},[1751,5889,5890,5895],{},[1772,5891,5892],{},[64,5893,5894],{},"JIRA_CREATE_OPEN=false",[1772,5896,5897],{},"禁用创建\u002F更新工具",[1751,5899,5900,5905],{},[1772,5901,5902],{},[64,5903,5904],{},"JIRA_DISABLE_DISCLAIMER=true",[1772,5906,5907],{},"去掉 AI 自动添加的备注声明",[1751,5909,5910,5915],{},[1772,5911,5912],{},[64,5913,5914],{},"JIRA_CREATE_SUMMARY_MAX",[1772,5916,5917],{},"限制摘要长度",[1751,5919,5920,5925],{},[1772,5921,5922],{},[64,5923,5924],{},"JIRA_CREATE_DESCRIPTION_MAX",[1772,5926,5927],{},"限制描述长度",[1751,5929,5930,5935],{},[1772,5931,5932],{},[64,5933,5934],{},"JIRA_CREATE_FIELDS_MAX_BYTES",[1772,5936,5937],{},"限制创建字段的 JSON 大小",[16,5939,5940],{},"这些限制确保 AI 即使有写入权限也不会\"失控\"——例如防止 AI 往描述里塞几万字的废话。",[111,5942,5944],{"id":5943},"_6-中英双语帮助","6. 中英双语帮助",[16,5946,5947],{},"帮助信息默认中文，也支持一键切英文：",[127,5949,5951],{"className":1095,"code":5950,"language":1097,"meta":132,"style":132},"# 保存英文偏好\njira_cli set help-language en\n\n# 切回中文\njira_cli set help-language zh\n\n# 临时覆盖\njira_cli --lang en issue search -h\n",[64,5952,5953,5958,5970,5974,5979,5990,5994,5999],{"__ignoreMap":132},[136,5954,5955],{"class":138,"line":139},[136,5956,5957],{"class":142},"# 保存英文偏好\n",[136,5959,5960,5962,5964,5967],{"class":138,"line":146},[136,5961,4786],{"class":156},[136,5963,5012],{"class":170},[136,5965,5966],{"class":170}," help-language",[136,5968,5969],{"class":170}," en\n",[136,5971,5972],{"class":138,"line":164},[136,5973,345],{"emptyLinePlaceholder":344},[136,5975,5976],{"class":138,"line":180},[136,5977,5978],{"class":142},"# 切回中文\n",[136,5980,5981,5983,5985,5987],{"class":138,"line":186},[136,5982,4786],{"class":156},[136,5984,5012],{"class":170},[136,5986,5966],{"class":170},[136,5988,5989],{"class":170}," zh\n",[136,5991,5992],{"class":138,"line":367},[136,5993,345],{"emptyLinePlaceholder":344},[136,5995,5996],{"class":138,"line":377},[136,5997,5998],{"class":142},"# 临时覆盖\n",[136,6000,6001,6003,6006,6009,6011,6013],{"class":138,"line":387},[136,6002,4786],{"class":156},[136,6004,6005],{"class":209}," --lang",[136,6007,6008],{"class":170}," en",[136,6010,4811],{"class":170},[136,6012,4814],{"class":170},[136,6014,6015],{"class":209}," -h\n",[12,6017,6018],{"id":6018},"架构一览",[127,6020,6023],{"className":6021,"code":6022,"language":3068},[3066],"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",[64,6024,6022],{"__ignoreMap":132},[16,6026,6027],{},"设计原则清晰：",[31,6029,6030,6038,6046,6054],{},[34,6031,6032,6037],{},[26,6033,6034],{},[64,6035,6036],{},"pkg\u002Fjira"," 是纯 Jira 客户端，HTTP、认证、上下文处理都在这里",[34,6039,6040,6045],{},[26,6041,6042],{},[64,6043,6044],{},"cmd"," 只管 Cobra 解析和呈现",[34,6047,6048,6053],{},[26,6049,6050],{},[64,6051,6052],{},"internal\u002Ftools\u002Fjira_tools"," 只管 MCP schema 验证和结果适配",[34,6055,6056,6061],{},[26,6057,6058],{},[64,6059,6060],{},"internal\u002Fconfig"," 拥有 Profile 的全部逻辑，不分散到 cmd",[16,6063,6064,6065,6068],{},"每一层职责单一，新增 Jira 能力时按 ",[64,6066,6067],{},"endpoints.go → Client 方法 → CLI 命令 → MCP tool → 策略注册"," 的路径走就行。",[12,6070,6071],{"id":6071},"和已有方案的对比",[1745,6073,6074,6086],{},[1748,6075,6076],{},[1751,6077,6078,6080,6083],{},[1754,6079,1756],{},[1754,6081,6082],{},"适合场景",[1754,6084,6085],{},"jira_cli 的区别",[1767,6087,6088,6099,6110,6121],{},[1751,6089,6090,6093,6096],{},[1772,6091,6092],{},"Atlassian Rovo MCP Server",[1772,6094,6095],{},"Jira Cloud、官方 OAuth",[1772,6097,6098],{},"jira_cli 是本地 stdio，适合内网 Server\u002FDC",[1751,6100,6101,6104,6107],{},[1772,6102,6103],{},"go-jira \u002F jira-cli 社区版",[1772,6105,6106],{},"纯命令行管理",[1772,6108,6109],{},"jira_cli 同时提供 MCP server，面向 AI 工作流",[1751,6111,6112,6115,6118],{},[1772,6113,6114],{},"直接调 REST API",[1772,6116,6117],{},"自定义集成",[1772,6119,6120],{},"jira_cli 封装了认证、只读策略、字段元数据",[1751,6122,6123,6126,6129],{},[1772,6124,6125],{},"Jira 网页",[1772,6127,6128],{},"日常使用",[1772,6130,6131],{},"jira_cli 把 Jira 变成终端里的一行命令",[12,6133,1627],{"id":1627},[16,6135,6136],{},"如果你的团队满足以下任一条件，jira_cli 值得一试：",[31,6138,6139,6142,6145,6148,6151],{},[34,6140,6141],{},"企业 Jira 部署在内网，想接入 AI 编程工具",[34,6143,6144],{},"想给 AI Agent 只读权限来查 Jira 上下文",[34,6146,6147],{},"需要命令行快速查看\u002F操作 Jira，不想切浏览器",[34,6149,6150],{},"需要管理多个 Jira 实例和多套账号",[34,6152,6153],{},"Windows 用户想要无摩擦的 CLI 体验",[16,6155,6156,6157,6161],{},"GitHub：",[20,6158,6160],{"href":4783,"rel":6159},[4785],"github.com\u002Fyourname\u002Fjira_cli"," | MIT 协议 | Go 1.26.3+",[1666,6163,6164],{},"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":132,"searchDepth":146,"depth":164,"links":6166},[6167,6168,6169,6170,6175,6183,6184,6185],{"id":4777,"depth":146,"text":4778},{"id":4866,"depth":146,"text":4866},{"id":4948,"depth":146,"text":4948},{"id":4991,"depth":146,"text":4991,"children":6171},[6172,6173,6174],{"id":4994,"depth":164,"text":4995},{"id":5114,"depth":164,"text":5115},{"id":5250,"depth":164,"text":5251},{"id":5396,"depth":146,"text":5396,"children":6176},[6177,6178,6179,6180,6181,6182],{"id":5399,"depth":164,"text":5400},{"id":5447,"depth":164,"text":5448},{"id":5638,"depth":164,"text":5639},{"id":5742,"depth":164,"text":5743},{"id":5869,"depth":164,"text":5870},{"id":5943,"depth":164,"text":5944},{"id":6018,"depth":146,"text":6018},{"id":6071,"depth":146,"text":6071},{"id":1627,"depth":146,"text":1627},"介绍 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":4772,"description":6186},"posts\u002Fjira-cli-intro\u002Findex",[6194,6195,6196,1702,6197,6198],"Jira","CLI","MCP","AI Agent","效率工具","vNH6sF2mMFmSjbHoEI2OqmYCgpFmaqyKt-MUfcQqXrU",{"id":6201,"title":6202,"author":7,"body":6203,"description":7148,"difficulty":1691,"draft":1692,"extension":1693,"image":7149,"meta":7150,"navigation":344,"path":22,"pinned":1692,"published":7151,"seo":7152,"series":1699,"seriesOrder":146,"stem":7153,"tags":7154,"updated":7158,"__hash__":7159},"posts\u002Fposts\u002Fgo-embed-frontend\u002Findex.md","Go 单文件打包实战：把前端塞进二进制",{"type":9,"value":6204,"toc":7131},[6205,6207,6214,6217,6219,6228,6230,6234,6560,6564,6672,6676,6810,6812,6816,6825,6840,6849,6867,6881,6887,6895,6995,7001,7005,7011,7020,7022,7058,7061,7066,7105,7107,7113,7125,7128],[12,6206,14],{"id":14},[16,6208,6209,6210,6213],{},"一个 Gin + Vue 3 的全栈项目，之前的发布方式是：一个 Go 二进制 + 一个 ",[64,6211,6212],{},"web\u002F"," 前端静态目录，必须放一起才能运行。",[16,6215,6216],{},"为了让部署更丝滑——一个 exe 拖哪都能跑——决定把前端构建产物嵌入到 Go 二进制里。",[12,6218,95],{"id":95},[16,6220,6221,6222,6224,6225,6227],{},"利用 Go 1.16 引入的 ",[64,6223,101],{}," 指令，在编译时将前端 ",[64,6226,105],{}," 目录嵌入二进制，运行时通过嵌入的文件系统提供 SPA 服务。",[12,6229,109],{"id":109},[111,6231,6233],{"id":6232},"_1-创建嵌入模块","1. 创建嵌入模块",[127,6235,6237],{"className":322,"code":6236,"language":324,"meta":132,"style":132},"\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",[64,6238,6239,6244,6250,6254,6260,6268,6277,6286,6294,6303,6311,6315,6324,6328,6332,6337,6350,6354,6372,6392,6396,6417,6434,6446,6456,6460,6464,6478,6490,6496,6516,6521,6526,6550,6555],{"__ignoreMap":132},[136,6240,6241],{"class":138,"line":139},[136,6242,6243],{"class":142},"\u002F\u002F service\u002Fweb\u002Fembed.go\n",[136,6245,6246,6248],{"class":138,"line":146},[136,6247,336],{"class":149},[136,6249,339],{"class":156},[136,6251,6252],{"class":138,"line":164},[136,6253,345],{"emptyLinePlaceholder":344},[136,6255,6256,6258],{"class":138,"line":180},[136,6257,350],{"class":149},[136,6259,353],{"class":160},[136,6261,6262,6264,6266],{"class":138,"line":186},[136,6263,358],{"class":170},[136,6265,361],{"class":156},[136,6267,364],{"class":170},[136,6269,6270,6272,6275],{"class":138,"line":367},[136,6271,358],{"class":170},[136,6273,6274],{"class":156},"io\u002Ffs",[136,6276,364],{"class":170},[136,6278,6279,6281,6284],{"class":138,"line":377},[136,6280,358],{"class":170},[136,6282,6283],{"class":156},"mime",[136,6285,364],{"class":170},[136,6287,6288,6290,6292],{"class":138,"line":387},[136,6289,358],{"class":170},[136,6291,372],{"class":156},[136,6293,364],{"class":170},[136,6295,6296,6298,6301],{"class":138,"line":393},[136,6297,358],{"class":170},[136,6299,6300],{"class":156},"path\u002Ffilepath",[136,6302,364],{"class":170},[136,6304,6305,6307,6309],{"class":138,"line":398},[136,6306,358],{"class":170},[136,6308,382],{"class":156},[136,6310,364],{"class":170},[136,6312,6313],{"class":138,"line":404},[136,6314,345],{"emptyLinePlaceholder":344},[136,6316,6317,6319,6322],{"class":138,"line":421},[136,6318,358],{"class":170},[136,6320,6321],{"class":156},"github.com\u002Fgin-gonic\u002Fgin",[136,6323,364],{"class":170},[136,6325,6326],{"class":138,"line":426},[136,6327,390],{"class":160},[136,6329,6330],{"class":138,"line":432},[136,6331,345],{"emptyLinePlaceholder":344},[136,6333,6334],{"class":138,"line":472},[136,6335,6336],{"class":142},"\u002F\u002Fgo:embed dist\n",[136,6338,6339,6341,6344,6346,6348],{"class":138,"line":477},[136,6340,407],{"class":149},[136,6342,6343],{"class":160}," embedFS ",[136,6345,361],{"class":156},[136,6347,415],{"class":160},[136,6349,418],{"class":156},[136,6351,6352],{"class":138,"line":492},[136,6353,345],{"emptyLinePlaceholder":344},[136,6355,6356,6358,6361,6363,6365,6367,6370],{"class":138,"line":501},[136,6357,529],{"class":149},[136,6359,6360],{"class":156}," SPAHandler",[136,6362,3659],{"class":160},[136,6364,770],{"class":156},[136,6366,415],{"class":160},[136,6368,6369],{"class":156},"HandlerFunc",[136,6371,489],{"class":160},[136,6373,6374,6377,6379,6382,6385,6388,6390],{"class":138,"line":516},[136,6375,6376],{"class":160},"    sub, _ ",[136,6378,785],{"class":149},[136,6380,6381],{"class":160}," fs.",[136,6383,6384],{"class":156},"Sub",[136,6386,6387],{"class":160},"(embedFS, ",[136,6389,460],{"class":170},[136,6391,390],{"class":160},[136,6393,6394],{"class":138,"line":521},[136,6395,345],{"emptyLinePlaceholder":344},[136,6397,6398,6400,6403,6405,6407,6409,6411,6413,6415],{"class":138,"line":526},[136,6399,658],{"class":149},[136,6401,6402],{"class":149}," func",[136,6404,549],{"class":160},[136,6406,836],{"class":535},[136,6408,748],{"class":149},[136,6410,770],{"class":156},[136,6412,415],{"class":160},[136,6414,845],{"class":156},[136,6416,574],{"class":160},[136,6418,6419,6421,6423,6425,6427,6430,6432],{"class":138,"line":577},[136,6420,897],{"class":160},[136,6422,785],{"class":149},[136,6424,635],{"class":160},[136,6426,638],{"class":156},[136,6428,6429],{"class":160},"(c.Request.URL.Path, ",[136,6431,644],{"class":170},[136,6433,390],{"class":160},[136,6435,6436,6438,6440,6442,6444],{"class":138,"line":594},[136,6437,917],{"class":149},[136,6439,920],{"class":160},[136,6441,586],{"class":149},[136,6443,2394],{"class":170},[136,6445,489],{"class":160},[136,6447,6448,6451,6453],{"class":138,"line":606},[136,6449,6450],{"class":160},"            p ",[136,6452,600],{"class":149},[136,6454,6455],{"class":170}," \"index.html\"\n",[136,6457,6458],{"class":138,"line":617},[136,6459,1011],{"class":160},[136,6461,6462],{"class":138,"line":649},[136,6463,345],{"emptyLinePlaceholder":344},[136,6465,6466,6469,6471,6473,6475],{"class":138,"line":655},[136,6467,6468],{"class":160},"        data, err ",[136,6470,785],{"class":149},[136,6472,6381],{"class":160},[136,6474,3676],{"class":156},[136,6476,6477],{"class":160},"(sub, p)\n",[136,6479,6480,6482,6484,6486,6488],{"class":138,"line":675},[136,6481,917],{"class":149},[136,6483,3375],{"class":160},[136,6485,3378],{"class":149},[136,6487,3381],{"class":209},[136,6489,489],{"class":160},[136,6491,6493],{"class":138,"line":6492},29,[136,6494,6495],{"class":142},"            \u002F\u002F SPA fallback：文件不存在则返回 index.html\n",[136,6497,6499,6502,6504,6506,6508,6511,6514],{"class":138,"line":6498},30,[136,6500,6501],{"class":160},"            data, _ ",[136,6503,600],{"class":149},[136,6505,6381],{"class":160},[136,6507,3676],{"class":156},[136,6509,6510],{"class":160},"(sub, ",[136,6512,6513],{"class":170},"\"index.html\"",[136,6515,390],{"class":160},[136,6517,6519],{"class":138,"line":6518},31,[136,6520,1011],{"class":160},[136,6522,6524],{"class":138,"line":6523},32,[136,6525,345],{"emptyLinePlaceholder":344},[136,6527,6529,6532,6535,6538,6541,6544,6547],{"class":138,"line":6528},33,[136,6530,6531],{"class":160},"        c.",[136,6533,6534],{"class":156},"Data",[136,6536,6537],{"class":160},"(http.StatusOK, mime.",[136,6539,6540],{"class":156},"TypeByExtension",[136,6542,6543],{"class":160},"(filepath.",[136,6545,6546],{"class":156},"Ext",[136,6548,6549],{"class":160},"(p)), data)\n",[136,6551,6553],{"class":138,"line":6552},34,[136,6554,652],{"class":160},[136,6556,6558],{"class":138,"line":6557},35,[136,6559,315],{"class":160},[111,6561,6563],{"id":6562},"_2-改造路由","2. 改造路由",[127,6565,6567],{"className":322,"code":6566,"language":324,"meta":132,"style":132},"\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",[64,6568,6569,6574,6585,6589,6594,6609,6623,6636,6649,6662],{"__ignoreMap":132},[136,6570,6571],{"class":138,"line":139},[136,6572,6573],{"class":142},"\u002F\u002F router.go\n",[136,6575,6576,6578,6580,6583],{"class":138,"line":146},[136,6577,350],{"class":149},[136,6579,1134],{"class":170},[136,6581,6582],{"class":156},"myproject\u002Fweb",[136,6584,364],{"class":170},[136,6586,6587],{"class":138,"line":164},[136,6588,345],{"emptyLinePlaceholder":344},[136,6590,6591],{"class":138,"line":180},[136,6592,6593],{"class":142},"\u002F\u002F 替换原来的磁盘静态文件服务\n",[136,6595,6596,6599,6601,6604,6607],{"class":138,"line":186},[136,6597,6598],{"class":160},"webHandler ",[136,6600,785],{"class":149},[136,6602,6603],{"class":160}," web.",[136,6605,6606],{"class":156},"SPAHandler",[136,6608,794],{"class":160},[136,6610,6611,6614,6616,6618,6620],{"class":138,"line":367},[136,6612,6613],{"class":160},"router.",[136,6615,822],{"class":156},[136,6617,549],{"class":160},[136,6619,644],{"class":170},[136,6621,6622],{"class":160},", webHandler)\n",[136,6624,6625,6627,6629,6631,6634],{"class":138,"line":377},[136,6626,6613],{"class":160},[136,6628,822],{"class":156},[136,6630,549],{"class":160},[136,6632,6633],{"class":170},"\"\u002Fassets\u002F*filepath\"",[136,6635,6622],{"class":160},[136,6637,6638,6640,6642,6644,6647],{"class":138,"line":387},[136,6639,6613],{"class":160},[136,6641,822],{"class":156},[136,6643,549],{"class":160},[136,6645,6646],{"class":170},"\"\u002Ffavicon.ico\"",[136,6648,6622],{"class":160},[136,6650,6651,6653,6655,6657,6660],{"class":138,"line":393},[136,6652,6613],{"class":160},[136,6654,822],{"class":156},[136,6656,549],{"class":160},[136,6658,6659],{"class":170},"\"\u002Ffavicon.svg\"",[136,6661,6622],{"class":160},[136,6663,6664,6666,6669],{"class":138,"line":398},[136,6665,6613],{"class":160},[136,6667,6668],{"class":156},"NoRoute",[136,6670,6671],{"class":160},"(webHandler)\n",[111,6673,6675],{"id":6674},"_3-一键构建脚本","3. 一键构建脚本",[127,6677,6679],{"className":1095,"code":6678,"language":1097,"meta":132,"style":132},"#!\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",[64,6680,6681,6686,6691,6695,6702,6706,6711,6721,6732,6736,6741,6752,6765,6769,6774,6781,6795,6805],{"__ignoreMap":132},[136,6682,6683],{"class":138,"line":139},[136,6684,6685],{"class":142},"#!\u002Fbin\u002Fbash\n",[136,6687,6688],{"class":138,"line":146},[136,6689,6690],{"class":142},"# build-standalone.sh\n",[136,6692,6693],{"class":138,"line":164},[136,6694,345],{"emptyLinePlaceholder":344},[136,6696,6697,6699],{"class":138,"line":180},[136,6698,1114],{"class":209},[136,6700,6701],{"class":209}," -e\n",[136,6703,6704],{"class":138,"line":186},[136,6705,345],{"emptyLinePlaceholder":344},[136,6707,6708],{"class":138,"line":367},[136,6709,6710],{"class":142},"# 1. 构建前端\n",[136,6712,6713,6716,6718],{"class":138,"line":377},[136,6714,6715],{"class":156},"pnpm",[136,6717,1872],{"class":170},[136,6719,6720],{"class":209}," --no-frozen-lockfile\n",[136,6722,6723,6726,6729],{"class":138,"line":387},[136,6724,6725],{"class":156},"npx",[136,6727,6728],{"class":170}," vite",[136,6730,6731],{"class":170}," build\n",[136,6733,6734],{"class":138,"line":393},[136,6735,345],{"emptyLinePlaceholder":344},[136,6737,6738],{"class":138,"line":398},[136,6739,6740],{"class":142},"# 2. 复制到嵌入目录\n",[136,6742,6743,6746,6749],{"class":138,"line":404},[136,6744,6745],{"class":156},"rm",[136,6747,6748],{"class":209}," -rf",[136,6750,6751],{"class":170}," service\u002Fweb\u002Fdist\n",[136,6753,6754,6757,6760,6763],{"class":138,"line":421},[136,6755,6756],{"class":156},"cp",[136,6758,6759],{"class":209}," -r",[136,6761,6762],{"class":170}," dist",[136,6764,6751],{"class":170},[136,6766,6767],{"class":138,"line":426},[136,6768,345],{"emptyLinePlaceholder":344},[136,6770,6771],{"class":138,"line":432},[136,6772,6773],{"class":142},"# 3. 编译 Go 二进制\n",[136,6775,6776,6778],{"class":138,"line":472},[136,6777,1125],{"class":209},[136,6779,6780],{"class":170}," service\n",[136,6782,6783,6785,6787,6789,6792],{"class":138,"line":477},[136,6784,324],{"class":156},[136,6786,1198],{"class":170},[136,6788,1252],{"class":209},[136,6790,6791],{"class":170}," myapp.exe",[136,6793,6794],{"class":209}," \\\n",[136,6796,6797,6800,6803],{"class":138,"line":492},[136,6798,6799],{"class":209},"    -ldflags=",[136,6801,6802],{"class":170},"\"-X myproject\u002Fglobal.RUNCODE=release\"",[136,6804,6794],{"class":209},[136,6806,6807],{"class":138,"line":501},[136,6808,6809],{"class":170},"    main.go\n",[12,6811,1315],{"id":1315},[111,6813,6815],{"id":6814},"坑一路由不匹配静态资源-404","坑一：路由不匹配，静态资源 404",[16,6817,6818,6819,6821,6822,6824],{},"首次实现时用了 Gin 的 ",[64,6820,6668],{}," 兜底，但部分静态资源返回 404。排查发现 ",[64,6823,6668],{}," 在某些路由未显式注册时不会按预期触发。",[16,6826,6827,6829,6830,1890,6833,6836,6837,6839],{},[26,6828,1398],{},"：显式注册 ",[64,6831,6832],{},"\u002Fassets\u002F*filepath",[64,6834,6835],{},"\u002Ffavicon.ico"," 等路由，",[64,6838,6668],{}," 仅作为兜底。",[111,6841,6843,6844,6846,6847,1337],{"id":6842},"坑二goembed-排除-_-开头文件","坑二：",[64,6845,101],{}," 排除 ",[64,6848,84],{},[16,6850,6851,6852,6855,6856,6858,6859,1312],{},"这是最坑的。Vite 构建产出了一个 ",[64,6853,6854],{},"_plugin-vue_export-helper-xxx.js","，Go 的 ",[64,6857,101],{}," 当模式为目录时，",[26,6860,6861,6862,696,6864,6866],{},"硬编码排除了 ",[64,6863,84],{},[64,6865,415],{}," 开头的文件",[127,6868,6870],{"className":322,"code":6869,"language":324,"meta":132,"style":132},"\u002F\u002F 这行会跳过 dist\u002Fassets\u002F_plugin-xxx.js\n\u002F\u002Fgo:embed dist\n",[64,6871,6872,6877],{"__ignoreMap":132},[136,6873,6874],{"class":138,"line":139},[136,6875,6876],{"class":142},"\u002F\u002F 这行会跳过 dist\u002Fassets\u002F_plugin-xxx.js\n",[136,6878,6879],{"class":138,"line":146},[136,6880,6336],{"class":142},[16,6882,6883,6884,1312],{},"错误表现：JS 文件返回的是 HTML 内容（SPA fallback），浏览器报 ",[64,6885,6886],{},"Unexpected token '\u003C'",[16,6888,6889,6891,6892,6894],{},[26,6890,1398],{},"：改 Vite 配置，去除了 ",[64,6893,84],{}," 前缀：",[127,6896,6898],{"className":129,"code":6897,"language":131,"meta":132,"style":132},"\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",[64,6899,6900,6904,6911,6918,6925,6933,6976,6981,6985,6990],{"__ignoreMap":132},[136,6901,6902],{"class":138,"line":139},[136,6903,143],{"class":142},[136,6905,6906,6909],{"class":138,"line":146},[136,6907,6908],{"class":156},"build",[136,6910,299],{"class":160},[136,6912,6913,6916],{"class":138,"line":164},[136,6914,6915],{"class":156},"  rollupOptions",[136,6917,299],{"class":160},[136,6919,6920,6923],{"class":138,"line":180},[136,6921,6922],{"class":156},"    output",[136,6924,299],{"class":160},[136,6926,6927,6930],{"class":138,"line":186},[136,6928,6929],{"class":156},"      chunkFileNames",[136,6931,6932],{"class":160},"(chunkInfo) {\n",[136,6934,6935,6937,6940,6943,6945,6947,6949,6952,6954,6957,6960,6963,6965,6967,6970,6973],{"class":138,"line":367},[136,6936,3703],{"class":149},[136,6938,6939],{"class":170}," `assets\u002F${",[136,6941,6942],{"class":160},"chunkInfo",[136,6944,415],{"class":170},[136,6946,552],{"class":160},[136,6948,415],{"class":170},[136,6950,6951],{"class":156},"replace",[136,6953,549],{"class":170},[136,6955,6956],{"class":170},"\u002F",[136,6958,6959],{"class":149},"^",[136,6961,84],{"class":6962},"sPKmS",[136,6964,6956],{"class":170},[136,6966,568],{"class":170},[136,6968,6969],{"class":170},"''",[136,6971,6972],{"class":170},")",[136,6974,6975],{"class":170},"}-[hash].js`\n",[136,6977,6978],{"class":138,"line":377},[136,6979,6980],{"class":160},"      },\n",[136,6982,6983],{"class":138,"line":387},[136,6984,2481],{"class":160},[136,6986,6987],{"class":138,"line":393},[136,6988,6989],{"class":160},"  },\n",[136,6991,6992],{"class":138,"line":398},[136,6993,6994],{"class":160},"},\n",[16,6996,6997,6998,7000],{},"这样就避免产生 ",[64,6999,84],{}," 前缀文件名的同时，保持构建产物的一致性和引用关系的正确性。",[111,7002,7004],{"id":7003},"坑三构建缓存导致-embed-内容未更新","坑三：构建缓存导致 embed 内容未更新",[16,7006,7007,7008,7010],{},"多次 ",[64,7009,1382],{}," 后，Go 的构建缓存可能使用了旧版本的 embed 内容，导致实际运行的文件不是最新的。",[16,7012,7013,7015,7016,7019],{},[26,7014,1398],{},"：使用 ",[64,7017,7018],{},"go build -a"," 强制重新编译所有包。",[12,7021,1586],{"id":1586},[127,7023,7025],{"className":1095,"code":7024,"language":1097,"meta":132,"style":132},"$ bash build-standalone.sh\n=== 构建成功 ===\n输出: service\u002Fmyapp.exe  41M\n",[64,7026,7027,7036,7047],{"__ignoreMap":132},[136,7028,7029,7031,7033],{"class":138,"line":139},[136,7030,1596],{"class":156},[136,7032,1609],{"class":170},[136,7034,7035],{"class":170}," build-standalone.sh\n",[136,7037,7038,7041,7044],{"class":138,"line":146},[136,7039,7040],{"class":170},"===",[136,7042,7043],{"class":170}," 构建成功",[136,7045,7046],{"class":170}," ===\n",[136,7048,7049,7052,7055],{"class":138,"line":164},[136,7050,7051],{"class":156},"输出:",[136,7053,7054],{"class":170}," service\u002Fmyapp.exe",[136,7056,7057],{"class":170},"  41M\n",[16,7059,7060],{},"一个 41MB 的 exe，内置前端页面、API 后端、SQLite 数据库（运行时初始化），拖到哪都能跑。",[16,7062,7063],{},[26,7064,7065],{},"部署对比：",[1745,7067,7068,7081],{},[1748,7069,7070],{},[1751,7071,7072,7075,7078],{},[1754,7073,7074],{},"方式",[1754,7076,7077],{},"文件数",[1754,7079,7080],{},"操作",[1767,7082,7083,7094],{},[1751,7084,7085,7088,7091],{},[1772,7086,7087],{},"旧方案",[1772,7089,7090],{},"1 exe + 1 web 目录",[1772,7092,7093],{},"解压→保持目录结构",[1751,7095,7096,7099,7102],{},[1772,7097,7098],{},"新方案",[1772,7100,7101],{},"1 个 exe",[1772,7103,7104],{},"直接扔到服务器运行",[12,7106,1627],{"id":1627},[16,7108,7109,7110,7112],{},"Go 的 ",[64,7111,101],{}," 是一个强大的工具，但有两个隐藏行为需要注意：",[55,7114,7115,7122],{},[34,7116,7117,7118,696,7120,6866],{},"嵌入目录时自动排除 ",[64,7119,84],{},[64,7121,415],{},[34,7123,7124],{},"构建缓存可能导致 embed 不是最新的",[16,7126,7127],{},"对于前端+后端合体的场景，这是性价比最高的方案——不需要额外的打包工具，纯标准库搞定。",[1666,7129,7130],{},"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":132,"searchDepth":146,"depth":164,"links":7132},[7133,7134,7135,7140,7146,7147],{"id":14,"depth":146,"text":14},{"id":95,"depth":146,"text":95},{"id":109,"depth":146,"text":109,"children":7136},[7137,7138,7139],{"id":6232,"depth":164,"text":6233},{"id":6562,"depth":164,"text":6563},{"id":6674,"depth":164,"text":6675},{"id":1315,"depth":146,"text":1315,"children":7141},[7142,7143,7145],{"id":6814,"depth":164,"text":6815},{"id":6842,"depth":164,"text":7144},"坑二：\u002F\u002Fgo:embed 排除 _ 开头文件",{"id":7003,"depth":164,"text":7004},{"id":1586,"depth":146,"text":1586},{"id":1627,"depth":146,"text":1627},"记录将 Vue 3 前端嵌入 Go 二进制实现单 exe 发布的完整流程，以及遇到的坑和解决方案。","\u002Fposts\u002Fgo-embed-frontend\u002Fimg\u002Fcover.jpg",{},"2026-06-06T16:00:00",{"title":6202,"description":7148},"posts\u002Fgo-embed-frontend\u002Findex",[1702,7155,7156,7157],"Vue","编译","埋坑","2026-06-06","SIQNL24wVAFb18wIGWSB3lNVZUQ9B76jWgQ55TASTYk",{"id":7161,"title":7162,"author":7,"body":7163,"description":7923,"difficulty":7,"draft":1692,"extension":1693,"image":7924,"meta":7925,"navigation":344,"path":7926,"pinned":1692,"published":7927,"seo":7928,"series":7,"seriesOrder":7,"stem":7929,"tags":7930,"updated":7,"__hash__":7932},"posts\u002Fposts\u002Fdws-dingtalk-cli\u002Findex.md","DWS — 钉钉官方出品的 AI 友好命令行工具",{"type":9,"value":7164,"toc":7909},[7165,7169,7172,7179,7303,7305,7308,7363,7366,7380,7383,7386,7393,7626,7633,7637,7640,7660,7663,7678,7681,7700,7703,7706,7713,7779,7782,7784,7788,7791,7842,7846,7867,7871,7874,7888,7891,7895,7898,7900,7903,7906],[12,7166,7168],{"id":7167},"dws-是什么","dws 是什么",[16,7170,7171],{},"dws（DingTalk Workspace CLI）是阿里巴巴 2026 年开源的钉钉命令行工具，Apache-2.0 协议，Go 语言编写。",[16,7173,7174,7175,7178],{},"它的定位很明确：",[26,7176,7177],{},"把钉钉的所有产品能力暴露给 AI Agent 和命令行用户","。不用打开钉钉 App，不用写 API 调用代码，一条命令搞定。",[127,7180,7182],{"className":1095,"code":7181,"language":1097,"meta":132,"style":132},"# 查看今天有什么会\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",[64,7183,7184,7189,7214,7218,7223,7245,7249,7254,7278,7282,7287],{"__ignoreMap":132},[136,7185,7186],{"class":138,"line":139},[136,7187,7188],{"class":142},"# 查看今天有什么会\n",[136,7190,7191,7194,7197,7200,7202,7205,7208,7211],{"class":138,"line":146},[136,7192,7193],{"class":156},"dws",[136,7195,7196],{"class":170}," calendar",[136,7198,7199],{"class":170}," event",[136,7201,5784],{"class":170},[136,7203,7204],{"class":209}," --start",[136,7206,7207],{"class":170}," 2026-06-01",[136,7209,7210],{"class":209}," --end",[136,7212,7213],{"class":170}," 2026-06-01\n",[136,7215,7216],{"class":138,"line":164},[136,7217,345],{"emptyLinePlaceholder":344},[136,7219,7220],{"class":138,"line":180},[136,7221,7222],{"class":142},"# 给群里发条消息\n",[136,7224,7225,7227,7230,7233,7236,7239,7242],{"class":138,"line":186},[136,7226,7193],{"class":156},[136,7228,7229],{"class":170}," chat",[136,7231,7232],{"class":170}," send",[136,7234,7235],{"class":209}," --group-id",[136,7237,7238],{"class":170}," xxx",[136,7240,7241],{"class":209}," --content",[136,7243,7244],{"class":170}," \"今天发布推迟到下午3点\"\n",[136,7246,7247],{"class":138,"line":367},[136,7248,345],{"emptyLinePlaceholder":344},[136,7250,7251],{"class":138,"line":377},[136,7252,7253],{"class":142},"# 创建一个待办\n",[136,7255,7256,7258,7261,7264,7266,7269,7272,7275],{"class":138,"line":387},[136,7257,7193],{"class":156},[136,7259,7260],{"class":170}," todo",[136,7262,7263],{"class":170}," task",[136,7265,5235],{"class":170},[136,7267,7268],{"class":209}," --subject",[136,7270,7271],{"class":170}," \"Review PR #123\"",[136,7273,7274],{"class":209}," --due-date",[136,7276,7277],{"class":170}," 2026-06-02\n",[136,7279,7280],{"class":138,"line":393},[136,7281,345],{"emptyLinePlaceholder":344},[136,7283,7284],{"class":138,"line":398},[136,7285,7286],{"class":142},"# 读取钉钉文档\n",[136,7288,7289,7291,7294,7297,7300],{"class":138,"line":404},[136,7290,7193],{"class":156},[136,7292,7293],{"class":170}," doc",[136,7295,7296],{"class":170}," read",[136,7298,7299],{"class":209}," --url",[136,7301,7302],{"class":170}," \"https:\u002F\u002Falidocs.dingtalk.com\u002Fi\u002Fnodes\u002Fxxx\"\n",[12,7304,4948],{"id":4948},[16,7306,7307],{},"三种方式，选你喜欢的：",[127,7309,7311],{"className":1095,"code":7310,"language":1097,"meta":132,"style":132},"# 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",[64,7312,7313,7318,7330,7334,7339,7349,7353,7358],{"__ignoreMap":132},[136,7314,7315],{"class":138,"line":139},[136,7316,7317],{"class":142},"# npm（推荐，跨平台自动下载二进制）\n",[136,7319,7320,7322,7324,7327],{"class":138,"line":146},[136,7321,1192],{"class":156},[136,7323,1872],{"class":170},[136,7325,7326],{"class":209}," -g",[136,7328,7329],{"class":170}," dingtalk-workspace-cli\n",[136,7331,7332],{"class":138,"line":164},[136,7333,345],{"emptyLinePlaceholder":344},[136,7335,7336],{"class":138,"line":180},[136,7337,7338],{"class":142},"# Homebrew（macOS\u002FLinux）\n",[136,7340,7341,7344,7346],{"class":138,"line":186},[136,7342,7343],{"class":156},"brew",[136,7345,1872],{"class":170},[136,7347,7348],{"class":170}," dws\n",[136,7350,7351],{"class":138,"line":367},[136,7352,345],{"emptyLinePlaceholder":344},[136,7354,7355],{"class":138,"line":377},[136,7356,7357],{"class":142},"# 直接下载二进制\n",[136,7359,7360],{"class":138,"line":387},[136,7361,7362],{"class":142},"# https:\u002F\u002Fgithub.com\u002Fopen-dingtalk\u002Fdingtalk-workspace-cli\u002Freleases\n",[16,7364,7365],{},"安装后认证：",[127,7367,7369],{"className":1095,"code":7368,"language":1097,"meta":132,"style":132},"dws auth login\n",[64,7370,7371],{"__ignoreMap":132},[136,7372,7373,7375,7377],{"class":138,"line":139},[136,7374,7193],{"class":156},[136,7376,5070],{"class":170},[136,7378,7379],{"class":170}," login\n",[16,7381,7382],{},"会打开浏览器走 OAuth 设备流认证，token 加密存储在系统钥匙串里（PBKDF2 + AES-256-GCM）。",[12,7384,7385],{"id":7385},"覆盖的钉钉产品",[16,7387,7388,7389,7392],{},"这是最夸张的部分 — ",[26,7390,7391],{},"19 个产品，213 个命令","，基本把钉钉全家桶都搬到了命令行：",[1745,7394,7395,7407],{},[1748,7396,7397],{},[1751,7398,7399,7402,7405],{},[1754,7400,7401],{},"产品",[1754,7403,7404],{},"命令数",[1754,7406,1765],{},[1767,7408,7409,7423,7437,7451,7465,7479,7493,7507,7520,7534,7547,7561,7575,7588,7601,7615],{},[1751,7410,7411,7417,7420],{},[1772,7412,7413,7414],{},"聊天 ",[64,7415,7416],{},"chat",[1772,7418,7419],{},"57",[1772,7421,7422],{},"消息收发、群管理、机器人、已读状态、Webhook",[1751,7424,7425,7431,7434],{},[1772,7426,7427,7428],{},"AI 表格 ",[64,7429,7430],{},"aitable",[1772,7432,7433],{},"42",[1772,7435,7436],{},"Base\u002F数据表\u002F字段\u002F记录\u002F视图\u002F图表\u002F仪表盘，完整 CRUD",[1751,7438,7439,7445,7448],{},[1772,7440,7441,7442],{},"电子表格 ",[64,7443,7444],{},"sheet",[1772,7446,7447],{},"34",[1772,7449,7450],{},"工作表读写、行列操作、合并、筛选视图、导出",[1751,7452,7453,7459,7462],{},[1772,7454,7455,7456],{},"文档 ",[64,7457,7458],{},"doc",[1772,7460,7461],{},"21",[1772,7463,7464],{},"搜索、读写、块级编辑、评论、复制移动",[1751,7466,7467,7473,7476],{},[1772,7468,7469,7470],{},"会议纪要 ",[64,7471,7472],{},"minutes",[1772,7474,7475],{},"19",[1772,7477,7478],{},"列表、摘要、转写、思维导图、发言人、热词",[1751,7480,7481,7487,7490],{},[1772,7482,7483,7484],{},"日历 ",[64,7485,7486],{},"calendar",[1772,7488,7489],{},"14",[1772,7491,7492],{},"事件 CRUD、会议室、忙闲查询、时间建议",[1751,7494,7495,7501,7504],{},[1772,7496,7497,7498],{},"审批 ",[64,7499,7500],{},"oa",[1772,7502,7503],{},"9",[1772,7505,7506],{},"待办、发起、同意\u002F拒绝\u002F撤销",[1751,7508,7509,7515,7517],{},[1772,7510,7511,7512],{},"云盘 ",[64,7513,7514],{},"drive",[1772,7516,7503],{},[1772,7518,7519],{},"列表、下载、上传、创建目录",[1751,7521,7522,7528,7531],{},[1772,7523,7524,7525],{},"知识库 ",[64,7526,7527],{},"wiki",[1772,7529,7530],{},"7",[1772,7532,7533],{},"空间管理、成员管理",[1751,7535,7536,7542,7544],{},[1772,7537,7538,7539],{},"汇报 ",[64,7540,7541],{},"report",[1772,7543,7530],{},[1772,7545,7546],{},"日志\u002F日报\u002F周报，按模板创建、统计",[1751,7548,7549,7555,7558],{},[1772,7550,7551,7552],{},"待办 ",[64,7553,7554],{},"todo",[1772,7556,7557],{},"6",[1772,7559,7560],{},"创建、完成、删除，支持优先级和循环",[1751,7562,7563,7569,7572],{},[1772,7564,7565,7566],{},"邮件 ",[64,7567,7568],{},"mail",[1772,7570,7571],{},"4",[1772,7573,7574],{},"邮箱查询、邮件搜索、发送",[1751,7576,7577,7583,7585],{},[1772,7578,7579,7580],{},"考勤 ",[64,7581,7582],{},"attendance",[1772,7584,7571],{},[1772,7586,7587],{},"打卡记录、排班、汇总",[1751,7589,7590,7596,7598],{},[1772,7591,7592,7593],{},"通讯录 ",[64,7594,7595],{},"contact",[1772,7597,3306],{},[1772,7599,7600],{},"用户搜索、部门查询",[1751,7602,7603,7609,7612],{},[1772,7604,7605,7606],{},"DING 消息 ",[64,7607,7608],{},"ding",[1772,7610,7611],{},"2",[1772,7613,7614],{},"发送\u002F撤回（应用内、短信、电话）",[1751,7616,7617,7620,7623],{},[1772,7618,7619],{},"其他",[1772,7621,7622],{},"若干",[1772,7624,7625],{},"AI 搜索、AI 应用、直播、开放平台文档",[16,7627,7628,7629,7632],{},"还有一个万能命令 ",[64,7630,7631],{},"dws api","，可以直接调用任意钉钉 OpenAPI，不受这 213 个命令的限制。",[12,7634,7636],{"id":7635},"agent-skill-系统","Agent Skill 系统",[16,7638,7639],{},"dws 内置了一套完整的 Agent Skill，可以直接被 AI 编程工具发现和使用：",[31,7641,7642,7648,7654],{},[34,7643,7644,7647],{},[26,7645,7646],{},"Claude Code"," — 通过 SKILL.md 自动加载",[34,7649,7650,7653],{},[26,7651,7652],{},"Cursor"," — 支持 MCP 协议接入",[34,7655,7656,7659],{},[26,7657,7658],{},"Codex \u002F OpenCode \u002F Qoder"," — 通过 skill 安装命令接入",[16,7661,7662],{},"安装 skill：",[127,7664,7666],{"className":1095,"code":7665,"language":1097,"meta":132,"style":132},"dws skill setup\n",[64,7667,7668],{"__ignoreMap":132},[136,7669,7670,7672,7675],{"class":138,"line":139},[136,7671,7193],{"class":156},[136,7673,7674],{"class":170}," skill",[136,7676,7677],{"class":170}," setup\n",[16,7679,7680],{},"SKILL.md 里定义了：",[31,7682,7683,7691,7694,7697],{},[34,7684,7685,7686,7688,7689,43],{},"16 个产品的意图判断决策树（用户说\"表格\"→ ",[64,7687,7430],{},"，说\"打卡\"→ ",[64,7690,7582],{},[34,7692,7693],{},"危险操作确认流程（删除、撤回等必须先确认）",[34,7695,7696],{},"错误处理规范（verbose 重试、recovery 事件 ID）",[34,7698,7699],{},"URL 分流规则（alidocs.dingtalk.com 的多种路径格式自动识别）",[16,7701,7702],{},"Agent 读到这份 SKILL.md 就知道什么时候该调什么命令，不需要额外的 prompt 工程。",[12,7704,7705],{"id":7705},"自动升级",[16,7707,7708,7709,7712],{},"内置 ",[64,7710,7711],{},"dws upgrade","，不需要重新下载安装包：",[127,7714,7716],{"className":1095,"code":7715,"language":1097,"meta":132,"style":132},"dws upgrade              # 交互式升级到最新版\ndws upgrade --check      # 仅检查是否有新版本\ndws upgrade --list       # 列出最近版本\ndws upgrade --version v1.0.5  # 升级到指定版本\ndws upgrade --rollback   # 回滚到上一版本\n",[64,7717,7718,7728,7740,7752,7767],{"__ignoreMap":132},[136,7719,7720,7722,7725],{"class":138,"line":139},[136,7721,7193],{"class":156},[136,7723,7724],{"class":170}," upgrade",[136,7726,7727],{"class":142},"              # 交互式升级到最新版\n",[136,7729,7730,7732,7734,7737],{"class":138,"line":146},[136,7731,7193],{"class":156},[136,7733,7724],{"class":170},[136,7735,7736],{"class":209}," --check",[136,7738,7739],{"class":142},"      # 仅检查是否有新版本\n",[136,7741,7742,7744,7746,7749],{"class":138,"line":164},[136,7743,7193],{"class":156},[136,7745,7724],{"class":170},[136,7747,7748],{"class":209}," --list",[136,7750,7751],{"class":142},"       # 列出最近版本\n",[136,7753,7754,7756,7758,7761,7764],{"class":138,"line":180},[136,7755,7193],{"class":156},[136,7757,7724],{"class":170},[136,7759,7760],{"class":209}," --version",[136,7762,7763],{"class":170}," v1.0.5",[136,7765,7766],{"class":142},"  # 升级到指定版本\n",[136,7768,7769,7771,7773,7776],{"class":138,"line":186},[136,7770,7193],{"class":156},[136,7772,7724],{"class":170},[136,7774,7775],{"class":209}," --rollback",[136,7777,7778],{"class":142},"   # 回滚到上一版本\n",[16,7780,7781],{},"升级流程：下载 → SHA256 校验 → 备份当前版本 → 原子替换 → 更新 Skill。失败可一键回滚。",[12,7783,5396],{"id":5396},[111,7785,7787],{"id":7786},"_1-为-ai-agent-专门优化","1. 为 AI Agent 专门优化",[16,7789,7790],{},"这可能是目前对 AI 最友好的 CLI 工具：",[31,7792,7793,7813,7823,7832],{},[34,7794,7795,7798,7799,7802,7803,1424,7806,7802,7809,7812],{},[26,7796,7797],{},"智能纠错"," — AI 模型经常生成错误的参数格式（",[64,7800,7801],{},"--userId"," 应该是 ",[64,7804,7805],{},"--user-id",[64,7807,7808],{},"--limit100",[64,7810,7811],{},"--limit 100","），dws 的 5 阶段 Pipeline 会自动修正",[34,7814,7815,7818,7819,7822],{},[26,7816,7817],{},"Schema 发现"," — ",[64,7820,7821],{},"dws schema"," 让 Agent 动态查询可用工具和参数，不需要硬编码",[34,7824,7825,7818,7828,7831],{},[26,7826,7827],{},"jq 过滤",[64,7829,7830],{},"--jq '.data[] | {name, id}'"," 直接过滤输出，减少 Agent 消耗的 token",[34,7833,7834,7837,7838,7841],{},[26,7835,7836],{},"JSON 输出"," — 所有命令支持 ",[64,7839,7840],{},"--format json","，方便程序解析",[111,7843,7845],{"id":7844},"_2-安全设计","2. 安全设计",[31,7847,7848,7851,7854,7857,7864],{},[34,7849,7850],{},"token 用 PBKDF2 + AES-256-GCM 加密，密钥绑定设备 MAC 地址",[34,7852,7853],{},"域名白名单，只能访问钉钉官方 API",[34,7855,7856],{},"强制 HTTPS",[34,7858,7859,7860,7863],{},"写操作必须显式 ",[64,7861,7862],{},"--yes"," 确认",[34,7865,7866],{},"完整审计链",[111,7868,7870],{"id":7869},"_3-发现驱动架构","3. 发现驱动架构",[16,7872,7873],{},"不是把所有命令硬编码在代码里，而是：",[55,7875,7876,7879,7882,7885],{},[34,7877,7878],{},"从钉钉 MCP 服务器注册中心拉取元数据",[34,7880,7881],{},"运行时发现服务端能力",[34,7883,7884],{},"转换成统一的中间表示（IR）",[34,7886,7887],{},"动态挂载到命令树",[16,7889,7890],{},"这意味着钉钉新增产品能力时，dws 不需要发版就能支持。",[111,7892,7894],{"id":7893},"_4-多平台分发","4. 多平台分发",[16,7896,7897],{},"GoReleaser 交叉编译 + Homebrew formula + npm 包 + shell\u002FPowerShell 安装脚本，覆盖 Windows、macOS、Linux。",[12,7899,1627],{"id":1627},[16,7901,7902],{},"dws 把钉钉从一个\"必须打开 App 才能用\"的工具变成了一个\"命令行 + AI Agent 随时调用\"的平台。213 个命令覆盖日常办公的方方面面，Agent Skill 系统让 AI 编程工具可以直接理解和使用这些能力。",[16,7904,7905],{},"如果你是钉钉重度用户，或者在做企业内部的 AI Agent 集成，dws 值得一试。",[1666,7907,7908],{},"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":132,"searchDepth":146,"depth":164,"links":7910},[7911,7912,7913,7914,7915,7916,7922],{"id":7167,"depth":146,"text":7168},{"id":4948,"depth":146,"text":4948},{"id":7385,"depth":146,"text":7385},{"id":7635,"depth":146,"text":7636},{"id":7705,"depth":146,"text":7705},{"id":5396,"depth":146,"text":5396,"children":7917},[7918,7919,7920,7921],{"id":7786,"depth":164,"text":7787},{"id":7844,"depth":164,"text":7845},{"id":7869,"depth":164,"text":7870},{"id":7893,"depth":164,"text":7894},{"id":1627,"depth":146,"text":1627},"介绍阿里巴巴官方的 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":7162,"description":7923},"posts\u002Fdws-dingtalk-cli\u002Findex",[7931,6195,6197,1702,6198],"钉钉","4ca65VUZ2Sz9yG8dNtftrBDDHE6QowA3ed9g4egQkKI",{"id":7934,"title":7935,"author":7,"body":7936,"description":9941,"difficulty":1691,"draft":1692,"extension":1693,"image":9942,"meta":9943,"navigation":344,"path":9944,"pinned":1692,"published":9945,"seo":9946,"series":1699,"seriesOrder":139,"stem":9947,"tags":9948,"updated":9949,"__hash__":9950},"posts\u002Fposts\u002Fgo-mcp-gin\u002Findex.md","Go-MCP 接入 Gin：从零搭建 MCP 服务器",{"type":9,"value":7937,"toc":9922},[7938,7942,7945,7948,7952,7959,7965,7968,7971,7991,7994,7998,8021,8025,8122,8135,8139,8296,8299,8512,8516,8527,8626,8633,8637,8901,8904,8984,8988,8991,8993,9004,9008,9014,9370,9556,9559,9615,9618,9771,9774,9777,9844,9846,9910,9919],[12,7939,7941],{"id":7940},"什么是-mcp","什么是 MCP",[16,7943,7944],{},"MCP（Model Context Protocol）是 Anthropic 提出的开放协议，用于让 AI 助手（如 Claude）与外部服务进行标准化交互。简单来说：你提供一个 MCP 服务器，AI 就能发现并调用你定义的工具（Tools），就像给 AI 装了一双手。",[16,7946,7947],{},"传统做法是写 REST API 然后让 AI 通过函数调用（Function Calling）间接使用，但 MCP 统一了协议层，AI 客户端只需要配置一个 URL 就能接入所有工具。",[12,7949,7951],{"id":7950},"为什么选-gin-mcp-go","为什么选 Gin + mcp-go",[16,7953,7954,7955,7958],{},"Go 生态里做 MCP 服务器最成熟的库是 ",[64,7956,7957],{},"mark3labs\u002Fmcp-go","，它支持 StreamableHTTP 传输模式，可以直接挂载到任意 HTTP 框架上。Gin 作为 Go 最流行的 HTTP 框架，天然适配。",[16,7960,7961,7962],{},"核心思路：",[26,7963,7964],{},"MCP 服务器本质上就是一个 HTTP Handler，挂到 Gin 路由上即可。",[12,7966,7967],{"id":7967},"需求分析",[16,7969,7970],{},"我们要搭建一个具备以下能力的 MCP 服务器：",[55,7972,7973,7979,7985],{},[34,7974,7975,7978],{},[26,7976,7977],{},"工具注册"," — 向 AI 暴露可调用的工具（列出资源、查看详情、创建\u002F删除）",[34,7980,7981,7984],{},[26,7982,7983],{},"HTTP 集成"," — MCP 端点作为 Gin 路由的一部分，与业务 API 共存",[34,7986,7987,7990],{},[26,7988,7989],{},"鉴权"," — MCP 请求必须经过身份验证，工具处理器能获取当前用户信息",[12,7992,7993],{"id":7993},"实现步骤",[111,7995,7997],{"id":7996},"_1-安装依赖","1. 安装依赖",[127,7999,8001],{"className":1095,"code":8000,"language":1097,"meta":132,"style":132},"go get github.com\u002Fmark3labs\u002Fmcp-go@v0.54.0\ngo get github.com\u002Fgin-gonic\u002Fgin@v1.12.0\n",[64,8002,8003,8012],{"__ignoreMap":132},[136,8004,8005,8007,8009],{"class":138,"line":139},[136,8006,324],{"class":156},[136,8008,4826],{"class":170},[136,8010,8011],{"class":170}," github.com\u002Fmark3labs\u002Fmcp-go@v0.54.0\n",[136,8013,8014,8016,8018],{"class":138,"line":146},[136,8015,324],{"class":156},[136,8017,4826],{"class":170},[136,8019,8020],{"class":170}," github.com\u002Fgin-gonic\u002Fgin@v1.12.0\n",[111,8022,8024],{"id":8023},"_2-创建-mcp-服务器","2. 创建 MCP 服务器",[127,8026,8028],{"className":322,"code":8027,"language":324,"meta":132,"style":132},"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",[64,8029,8030,8041,8045,8061,8072,8083,8094,8103,8118],{"__ignoreMap":132},[136,8031,8032,8034,8036,8039],{"class":138,"line":139},[136,8033,350],{"class":149},[136,8035,1134],{"class":170},[136,8037,8038],{"class":156},"github.com\u002Fmark3labs\u002Fmcp-go\u002Fserver",[136,8040,364],{"class":170},[136,8042,8043],{"class":138,"line":146},[136,8044,345],{"emptyLinePlaceholder":344},[136,8046,8047,8050,8052,8055,8058],{"class":138,"line":164},[136,8048,8049],{"class":160},"mcpServer ",[136,8051,785],{"class":149},[136,8053,8054],{"class":160}," server.",[136,8056,8057],{"class":156},"NewMCPServer",[136,8059,8060],{"class":160},"(\n",[136,8062,8063,8066,8069],{"class":138,"line":180},[136,8064,8065],{"class":170},"    \"my-app\"",[136,8067,8068],{"class":160},",    ",[136,8070,8071],{"class":142},"\u002F\u002F 服务器名称\n",[136,8073,8074,8077,8080],{"class":138,"line":186},[136,8075,8076],{"class":170},"    \"1.0.0\"",[136,8078,8079],{"class":160},",     ",[136,8081,8082],{"class":142},"\u002F\u002F 版本\n",[136,8084,8085,8088,8091],{"class":138,"line":367},[136,8086,8087],{"class":160},"    server.",[136,8089,8090],{"class":156},"WithLogging",[136,8092,8093],{"class":160},"(),\n",[136,8095,8096,8098,8101],{"class":138,"line":377},[136,8097,8087],{"class":160},[136,8099,8100],{"class":156},"WithRecovery",[136,8102,8093],{"class":160},[136,8104,8105,8107,8110,8112,8115],{"class":138,"line":387},[136,8106,8087],{"class":160},[136,8108,8109],{"class":156},"WithInstructions",[136,8111,549],{"class":160},[136,8113,8114],{"class":170},"\"这是一个 MCP 服务器示例\"",[136,8116,8117],{"class":160},"),\n",[136,8119,8120],{"class":138,"line":393},[136,8121,390],{"class":160},[16,8123,8124,8126,8127,8130,8131,8134],{},[64,8125,8057],{}," 创建一个 MCP 协议实例，它负责处理工具发现（",[64,8128,8129],{},"tools\u002Flist","）和工具调用（",[64,8132,8133],{},"tools\u002Fcall","）的协议逻辑。",[111,8136,8138],{"id":8137},"_3-注册工具","3. 注册工具",[127,8140,8142],{"className":322,"code":8141,"language":324,"meta":132,"style":132},"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",[64,8143,8144,8155,8159,8164,8184,8199,8203,8207,8212,8267,8272,8292],{"__ignoreMap":132},[136,8145,8146,8148,8150,8153],{"class":138,"line":139},[136,8147,350],{"class":149},[136,8149,1134],{"class":170},[136,8151,8152],{"class":156},"github.com\u002Fmark3labs\u002Fmcp-go\u002Fmcp",[136,8154,364],{"class":170},[136,8156,8157],{"class":138,"line":146},[136,8158,345],{"emptyLinePlaceholder":344},[136,8160,8161],{"class":138,"line":164},[136,8162,8163],{"class":142},"\u002F\u002F 定义工具\n",[136,8165,8166,8169,8171,8174,8177,8179,8182],{"class":138,"line":180},[136,8167,8168],{"class":160},"listTool ",[136,8170,785],{"class":149},[136,8172,8173],{"class":160}," mcp.",[136,8175,8176],{"class":156},"NewTool",[136,8178,549],{"class":160},[136,8180,8181],{"class":170},"\"list_items\"",[136,8183,2073],{"class":160},[136,8185,8186,8189,8192,8194,8197],{"class":138,"line":186},[136,8187,8188],{"class":160},"    mcp.",[136,8190,8191],{"class":156},"WithDescription",[136,8193,549],{"class":160},[136,8195,8196],{"class":170},"\"列出所有项目\"",[136,8198,8117],{"class":160},[136,8200,8201],{"class":138,"line":367},[136,8202,390],{"class":160},[136,8204,8205],{"class":138,"line":377},[136,8206,345],{"emptyLinePlaceholder":344},[136,8208,8209],{"class":138,"line":387},[136,8210,8211],{"class":142},"\u002F\u002F 注册处理器\n",[136,8213,8214,8217,8220,8223,8225,8227,8230,8233,8235,8237,8239,8242,8244,8246,8249,8251,8253,8256,8258,8261,8263,8265],{"class":138,"line":393},[136,8215,8216],{"class":160},"mcpServer.",[136,8218,8219],{"class":156},"AddTool",[136,8221,8222],{"class":160},"(listTool, ",[136,8224,529],{"class":149},[136,8226,549],{"class":160},[136,8228,8229],{"class":535},"ctx",[136,8231,8232],{"class":156}," context",[136,8234,415],{"class":160},[136,8236,845],{"class":156},[136,8238,568],{"class":160},[136,8240,8241],{"class":535},"req",[136,8243,4860],{"class":156},[136,8245,415],{"class":160},[136,8247,8248],{"class":156},"CallToolRequest",[136,8250,558],{"class":160},[136,8252,507],{"class":149},[136,8254,8255],{"class":156},"mcp",[136,8257,415],{"class":160},[136,8259,8260],{"class":156},"CallToolResult",[136,8262,568],{"class":160},[136,8264,571],{"class":149},[136,8266,574],{"class":160},[136,8268,8269],{"class":138,"line":398},[136,8270,8271],{"class":142},"    \u002F\u002F 业务逻辑\n",[136,8273,8274,8276,8278,8281,8283,8286,8289],{"class":138,"line":404},[136,8275,658],{"class":149},[136,8277,8173],{"class":160},[136,8279,8280],{"class":156},"NewToolResultText",[136,8282,549],{"class":160},[136,8284,8285],{"class":170},"\"返回结果\"",[136,8287,8288],{"class":160},"), ",[136,8290,8291],{"class":209},"nil\n",[136,8293,8294],{"class":138,"line":421},[136,8295,189],{"class":160},[16,8297,8298],{},"带参数的工具：",[127,8300,8302],{"className":322,"code":8301,"language":324,"meta":132,"style":132},"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",[64,8303,8304,8322,8335,8349,8359,8373,8378,8382,8386,8433,8452,8464,8482,8486,8491,8508],{"__ignoreMap":132},[136,8305,8306,8309,8311,8313,8315,8317,8320],{"class":138,"line":139},[136,8307,8308],{"class":160},"getTool ",[136,8310,785],{"class":149},[136,8312,8173],{"class":160},[136,8314,8176],{"class":156},[136,8316,549],{"class":160},[136,8318,8319],{"class":170},"\"get_item\"",[136,8321,2073],{"class":160},[136,8323,8324,8326,8328,8330,8333],{"class":138,"line":146},[136,8325,8188],{"class":160},[136,8327,8191],{"class":156},[136,8329,549],{"class":160},[136,8331,8332],{"class":170},"\"获取指定项目的详情\"",[136,8334,8117],{"class":160},[136,8336,8337,8339,8342,8344,8347],{"class":138,"line":164},[136,8338,8188],{"class":160},[136,8340,8341],{"class":156},"WithString",[136,8343,549],{"class":160},[136,8345,8346],{"class":170},"\"item_id\"",[136,8348,2073],{"class":160},[136,8350,8351,8354,8357],{"class":138,"line":180},[136,8352,8353],{"class":160},"        mcp.",[136,8355,8356],{"class":156},"Required",[136,8358,8093],{"class":160},[136,8360,8361,8363,8366,8368,8371],{"class":138,"line":186},[136,8362,8353],{"class":160},[136,8364,8365],{"class":156},"Description",[136,8367,549],{"class":160},[136,8369,8370],{"class":170},"\"项目 ID\"",[136,8372,8117],{"class":160},[136,8374,8375],{"class":138,"line":367},[136,8376,8377],{"class":160},"    ),\n",[136,8379,8380],{"class":138,"line":377},[136,8381,390],{"class":160},[136,8383,8384],{"class":138,"line":387},[136,8385,345],{"emptyLinePlaceholder":344},[136,8387,8388,8390,8392,8395,8397,8399,8401,8403,8405,8407,8409,8411,8413,8415,8417,8419,8421,8423,8425,8427,8429,8431],{"class":138,"line":393},[136,8389,8216],{"class":160},[136,8391,8219],{"class":156},[136,8393,8394],{"class":160},"(getTool, ",[136,8396,529],{"class":149},[136,8398,549],{"class":160},[136,8400,8229],{"class":535},[136,8402,8232],{"class":156},[136,8404,415],{"class":160},[136,8406,845],{"class":156},[136,8408,568],{"class":160},[136,8410,8241],{"class":535},[136,8412,4860],{"class":156},[136,8414,415],{"class":160},[136,8416,8248],{"class":156},[136,8418,558],{"class":160},[136,8420,507],{"class":149},[136,8422,8255],{"class":156},[136,8424,415],{"class":160},[136,8426,8260],{"class":156},[136,8428,568],{"class":160},[136,8430,571],{"class":149},[136,8432,574],{"class":160},[136,8434,8435,8438,8440,8443,8446,8448,8450],{"class":138,"line":398},[136,8436,8437],{"class":160},"    itemID, err ",[136,8439,785],{"class":149},[136,8441,8442],{"class":160}," req.",[136,8444,8445],{"class":156},"RequireString",[136,8447,549],{"class":160},[136,8449,8346],{"class":170},[136,8451,390],{"class":160},[136,8453,8454,8456,8458,8460,8462],{"class":138,"line":404},[136,8455,580],{"class":149},[136,8457,3375],{"class":160},[136,8459,3378],{"class":149},[136,8461,3381],{"class":209},[136,8463,489],{"class":160},[136,8465,8466,8468,8470,8473,8475,8478,8480],{"class":138,"line":421},[136,8467,3703],{"class":149},[136,8469,8173],{"class":160},[136,8471,8472],{"class":156},"NewToolResultError",[136,8474,549],{"class":160},[136,8476,8477],{"class":170},"\"缺少 item_id 参数\"",[136,8479,8288],{"class":160},[136,8481,8291],{"class":209},[136,8483,8484],{"class":138,"line":426},[136,8485,652],{"class":160},[136,8487,8488],{"class":138,"line":432},[136,8489,8490],{"class":142},"    \u002F\u002F 查询并返回\n",[136,8492,8493,8495,8497,8499,8501,8504,8506],{"class":138,"line":472},[136,8494,658],{"class":149},[136,8496,8173],{"class":160},[136,8498,8280],{"class":156},[136,8500,549],{"class":160},[136,8502,8503],{"class":170},"\"项目详情...\"",[136,8505,8288],{"class":160},[136,8507,8291],{"class":209},[136,8509,8510],{"class":138,"line":477},[136,8511,189],{"class":160},[111,8513,8515],{"id":8514},"_4-挂载到-gin-路由","4. 挂载到 Gin 路由",[16,8517,8518,8519,8522,8523,8526],{},"关键一步：用 ",[64,8520,8521],{},"server.NewStreamableHTTPServer"," 把 MCP 实例转成 HTTP Handler，再用 ",[64,8524,8525],{},"gin.WrapH"," 适配 Gin。",[127,8528,8530],{"className":322,"code":8529,"language":324,"meta":132,"style":132},"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",[64,8531,8532,8542,8546,8561,8575,8592,8596,8600,8605],{"__ignoreMap":132},[136,8533,8534,8536,8538,8540],{"class":138,"line":139},[136,8535,350],{"class":149},[136,8537,1134],{"class":170},[136,8539,8038],{"class":156},[136,8541,364],{"class":170},[136,8543,8544],{"class":138,"line":146},[136,8545,345],{"emptyLinePlaceholder":344},[136,8547,8548,8551,8553,8555,8558],{"class":138,"line":164},[136,8549,8550],{"class":160},"mcpHTTPServer ",[136,8552,785],{"class":149},[136,8554,8054],{"class":160},[136,8556,8557],{"class":156},"NewStreamableHTTPServer",[136,8559,8560],{"class":160},"(mcpServer,\n",[136,8562,8563,8565,8568,8570,8573],{"class":138,"line":180},[136,8564,8087],{"class":160},[136,8566,8567],{"class":156},"WithEndpointPath",[136,8569,549],{"class":160},[136,8571,8572],{"class":170},"\"\u002Fmcp\"",[136,8574,8117],{"class":160},[136,8576,8577,8579,8582,8584,8587,8589],{"class":138,"line":186},[136,8578,8087],{"class":160},[136,8580,8581],{"class":156},"WithHeartbeatInterval",[136,8583,549],{"class":160},[136,8585,8586],{"class":209},"30",[136,8588,507],{"class":149},[136,8590,8591],{"class":160},"time.Second),\n",[136,8593,8594],{"class":138,"line":367},[136,8595,390],{"class":160},[136,8597,8598],{"class":138,"line":377},[136,8599,345],{"emptyLinePlaceholder":344},[136,8601,8602],{"class":138,"line":387},[136,8603,8604],{"class":142},"\u002F\u002F 挂载到 Gin\n",[136,8606,8607,8610,8613,8615,8617,8620,8623],{"class":138,"line":393},[136,8608,8609],{"class":160},"r.",[136,8611,8612],{"class":156},"Any",[136,8614,549],{"class":160},[136,8616,8572],{"class":170},[136,8618,8619],{"class":160},", gin.",[136,8621,8622],{"class":156},"WrapH",[136,8624,8625],{"class":160},"(mcpHTTPServer))\n",[16,8627,8628,8629,8632],{},"就这样，",[64,8630,8631],{},"POST \u002Fmcp"," 端点就生效了。AI 客户端通过这个 URL 发送 JSON-RPC 请求，MCP 服务器自动处理协议握手、工具列表返回、工具调用路由。",[111,8634,8636],{"id":8635},"_5-完整的服务启动代码","5. 完整的服务启动代码",[127,8638,8640],{"className":322,"code":8639,"language":324,"meta":132,"style":132},"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",[64,8641,8642,8652,8664,8668,8673,8696,8705,8713,8718,8722,8727,8735,8739,8744,8757,8769,8783,8787,8804,8808,8813,8842,8875,8879,8883,8897],{"__ignoreMap":132},[136,8643,8644,8646,8649],{"class":138,"line":139},[136,8645,529],{"class":149},[136,8647,8648],{"class":156}," main",[136,8650,8651],{"class":160},"() {\n",[136,8653,8654,8656,8658,8660,8662],{"class":138,"line":146},[136,8655,782],{"class":160},[136,8657,785],{"class":149},[136,8659,788],{"class":160},[136,8661,791],{"class":156},[136,8663,794],{"class":160},[136,8665,8666],{"class":138,"line":164},[136,8667,345],{"emptyLinePlaceholder":344},[136,8669,8670],{"class":138,"line":180},[136,8671,8672],{"class":142},"    \u002F\u002F 创建 MCP 服务器\n",[136,8674,8675,8678,8680,8682,8684,8686,8689,8691,8694],{"class":138,"line":186},[136,8676,8677],{"class":160},"    mcpServer ",[136,8679,785],{"class":149},[136,8681,8054],{"class":160},[136,8683,8057],{"class":156},[136,8685,549],{"class":160},[136,8687,8688],{"class":170},"\"my-app\"",[136,8690,568],{"class":160},[136,8692,8693],{"class":170},"\"1.0.0\"",[136,8695,2073],{"class":160},[136,8697,8698,8701,8703],{"class":138,"line":367},[136,8699,8700],{"class":160},"        server.",[136,8702,8090],{"class":156},[136,8704,8093],{"class":160},[136,8706,8707,8709,8711],{"class":138,"line":377},[136,8708,8700],{"class":160},[136,8710,8100],{"class":156},[136,8712,8093],{"class":160},[136,8714,8715],{"class":138,"line":387},[136,8716,8717],{"class":160},"    )\n",[136,8719,8720],{"class":138,"line":393},[136,8721,345],{"emptyLinePlaceholder":344},[136,8723,8724],{"class":138,"line":398},[136,8725,8726],{"class":142},"    \u002F\u002F 注册工具\n",[136,8728,8729,8732],{"class":138,"line":404},[136,8730,8731],{"class":156},"    registerTools",[136,8733,8734],{"class":160},"(mcpServer)\n",[136,8736,8737],{"class":138,"line":421},[136,8738,345],{"emptyLinePlaceholder":344},[136,8740,8741],{"class":138,"line":426},[136,8742,8743],{"class":142},"    \u002F\u002F 挂载到 Gin\n",[136,8745,8746,8749,8751,8753,8755],{"class":138,"line":432},[136,8747,8748],{"class":160},"    mcpHTTP ",[136,8750,785],{"class":149},[136,8752,8054],{"class":160},[136,8754,8557],{"class":156},[136,8756,8560],{"class":160},[136,8758,8759,8761,8763,8765,8767],{"class":138,"line":472},[136,8760,8700],{"class":160},[136,8762,8567],{"class":156},[136,8764,549],{"class":160},[136,8766,8572],{"class":170},[136,8768,8117],{"class":160},[136,8770,8771,8773,8775,8777,8779,8781],{"class":138,"line":477},[136,8772,8700],{"class":160},[136,8774,8581],{"class":156},[136,8776,549],{"class":160},[136,8778,8586],{"class":209},[136,8780,507],{"class":149},[136,8782,8591],{"class":160},[136,8784,8785],{"class":138,"line":492},[136,8786,8717],{"class":160},[136,8788,8789,8791,8793,8795,8797,8799,8801],{"class":138,"line":501},[136,8790,819],{"class":160},[136,8792,8612],{"class":156},[136,8794,549],{"class":160},[136,8796,8572],{"class":170},[136,8798,8619],{"class":160},[136,8800,8622],{"class":156},[136,8802,8803],{"class":160},"(mcpHTTP))\n",[136,8805,8806],{"class":138,"line":516},[136,8807,345],{"emptyLinePlaceholder":344},[136,8809,8810],{"class":138,"line":521},[136,8811,8812],{"class":142},"    \u002F\u002F 其他业务路由\n",[136,8814,8815,8817,8819,8821,8824,8826,8828,8830,8832,8834,8836,8838,8840],{"class":138,"line":526},[136,8816,819],{"class":160},[136,8818,822],{"class":156},[136,8820,549],{"class":160},[136,8822,8823],{"class":170},"\"\u002Fapi\u002Fhealth\"",[136,8825,568],{"class":160},[136,8827,529],{"class":149},[136,8829,549],{"class":160},[136,8831,836],{"class":535},[136,8833,748],{"class":149},[136,8835,770],{"class":156},[136,8837,415],{"class":160},[136,8839,845],{"class":156},[136,8841,574],{"class":160},[136,8843,8844,8846,8849,8851,8854,8856,8858,8860,8863,8865,8868,8870,8873],{"class":138,"line":577},[136,8845,6531],{"class":160},[136,8847,8848],{"class":156},"JSON",[136,8850,549],{"class":160},[136,8852,8853],{"class":209},"200",[136,8855,568],{"class":160},[136,8857,770],{"class":156},[136,8859,415],{"class":160},[136,8861,8862],{"class":156},"H",[136,8864,2188],{"class":160},[136,8866,8867],{"class":170},"\"status\"",[136,8869,307],{"class":160},[136,8871,8872],{"class":170},"\"ok\"",[136,8874,189],{"class":160},[136,8876,8877],{"class":138,"line":594},[136,8878,1037],{"class":160},[136,8880,8881],{"class":138,"line":606},[136,8882,345],{"emptyLinePlaceholder":344},[136,8884,8885,8887,8890,8892,8895],{"class":138,"line":617},[136,8886,819],{"class":160},[136,8888,8889],{"class":156},"Run",[136,8891,549],{"class":160},[136,8893,8894],{"class":170},"\":8080\"",[136,8896,390],{"class":160},[136,8898,8899],{"class":138,"line":649},[136,8900,315],{"class":160},[16,8902,8903],{},"此时启动服务，用 curl 测试：",[127,8905,8907],{"className":1095,"code":8906,"language":1097,"meta":132,"style":132},"# 发现工具列表\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",[64,8908,8909,8914,8930,8940,8948,8952,8957,8969,8977],{"__ignoreMap":132},[136,8910,8911],{"class":138,"line":139},[136,8912,8913],{"class":142},"# 发现工具列表\n",[136,8915,8916,8919,8922,8925,8928],{"class":138,"line":146},[136,8917,8918],{"class":156},"curl",[136,8920,8921],{"class":209}," -X",[136,8923,8924],{"class":170}," POST",[136,8926,8927],{"class":170}," http:\u002F\u002Flocalhost:8080\u002Fmcp",[136,8929,6794],{"class":209},[136,8931,8932,8935,8938],{"class":138,"line":164},[136,8933,8934],{"class":209},"  -H",[136,8936,8937],{"class":170}," \"Content-Type: application\u002Fjson\"",[136,8939,6794],{"class":209},[136,8941,8942,8945],{"class":138,"line":180},[136,8943,8944],{"class":209},"  -d",[136,8946,8947],{"class":170}," '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools\u002Flist\"}'\n",[136,8949,8950],{"class":138,"line":186},[136,8951,345],{"emptyLinePlaceholder":344},[136,8953,8954],{"class":138,"line":367},[136,8955,8956],{"class":142},"# 调用工具\n",[136,8958,8959,8961,8963,8965,8967],{"class":138,"line":377},[136,8960,8918],{"class":156},[136,8962,8921],{"class":209},[136,8964,8924],{"class":170},[136,8966,8927],{"class":170},[136,8968,6794],{"class":209},[136,8970,8971,8973,8975],{"class":138,"line":387},[136,8972,8934],{"class":209},[136,8974,8937],{"class":170},[136,8976,6794],{"class":209},[136,8978,8979,8981],{"class":138,"line":393},[136,8980,8944],{"class":209},[136,8982,8983],{"class":170}," '{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools\u002Fcall\",\"params\":{\"name\":\"list_items\",\"arguments\":{}}}'\n",[12,8985,8987],{"id":8986},"拓展接入鉴权","拓展：接入鉴权",[16,8989,8990],{},"裸奔的 MCP 端点任何人都能调用，生产环境必须加鉴权。思路和普通 API 鉴权一致：Bearer Token。",[111,8992,4302],{"id":4302},[16,8994,8995,8996,8999,9000,9003],{},"MCP 的工具处理器签名为 ",[64,8997,8998],{},"func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error)","，它不接收 Gin 的 ",[64,9001,9002],{},"*gin.Context","，所以无法直接在工具处理器里读取请求头。",[111,9005,9007],{"id":9006},"解决方案中间件传递用户信息","解决方案：中间件传递用户信息",[16,9009,9010,9011,1312],{},"用两层中间件：第一层做 Gin 的 Token 验证，第二层把用户信息从 Gin Context 复制到 ",[64,9012,9013],{},"request.Context",[127,9015,9017],{"className":322,"code":9016,"language":324,"meta":132,"style":132},"\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",[64,9018,9019,9024,9051,9071,9090,9103,9135,9140,9144,9148,9172,9210,9239,9243,9247,9251,9271,9283,9312,9316,9320,9324,9339,9353,9362,9366],{"__ignoreMap":132},[136,9020,9021],{"class":138,"line":139},[136,9022,9023],{"class":142},"\u002F\u002F Gin 鉴权中间件\n",[136,9025,9026,9028,9031,9033,9036,9038,9041,9043,9045,9047,9049],{"class":138,"line":146},[136,9027,529],{"class":149},[136,9029,9030],{"class":156}," AuthRequired",[136,9032,549],{"class":160},[136,9034,9035],{"class":535},"authService",[136,9037,748],{"class":149},[136,9039,9040],{"class":156},"AuthService",[136,9042,543],{"class":160},[136,9044,770],{"class":156},[136,9046,415],{"class":160},[136,9048,6369],{"class":156},[136,9050,489],{"class":160},[136,9052,9053,9055,9057,9059,9061,9063,9065,9067,9069],{"class":138,"line":164},[136,9054,658],{"class":149},[136,9056,6402],{"class":149},[136,9058,549],{"class":160},[136,9060,836],{"class":535},[136,9062,748],{"class":149},[136,9064,770],{"class":156},[136,9066,415],{"class":160},[136,9068,845],{"class":156},[136,9070,574],{"class":160},[136,9072,9073,9076,9078,9080,9083,9085,9088],{"class":138,"line":180},[136,9074,9075],{"class":160},"        authHeader ",[136,9077,785],{"class":149},[136,9079,902],{"class":160},[136,9081,9082],{"class":156},"GetHeader",[136,9084,549],{"class":160},[136,9086,9087],{"class":170},"\"Authorization\"",[136,9089,390],{"class":160},[136,9091,9092,9094,9097,9099,9101],{"class":138,"line":186},[136,9093,917],{"class":149},[136,9095,9096],{"class":160}," authHeader ",[136,9098,586],{"class":149},[136,9100,2394],{"class":170},[136,9102,489],{"class":160},[136,9104,9105,9107,9110,9112,9115,9117,9119,9121,9123,9125,9128,9130,9133],{"class":138,"line":367},[136,9106,946],{"class":160},[136,9108,9109],{"class":156},"AbortWithStatusJSON",[136,9111,549],{"class":160},[136,9113,9114],{"class":209},"401",[136,9116,568],{"class":160},[136,9118,770],{"class":156},[136,9120,415],{"class":160},[136,9122,8862],{"class":156},[136,9124,2188],{"class":160},[136,9126,9127],{"class":170},"\"message\"",[136,9129,307],{"class":160},[136,9131,9132],{"class":170},"\"缺少认证令牌\"",[136,9134,189],{"class":160},[136,9136,9137],{"class":138,"line":377},[136,9138,9139],{"class":149},"            return\n",[136,9141,9142],{"class":138,"line":387},[136,9143,1011],{"class":160},[136,9145,9146],{"class":138,"line":393},[136,9147,345],{"emptyLinePlaceholder":344},[136,9149,9150,9153,9155,9157,9160,9163,9166,9168,9170],{"class":138,"line":398},[136,9151,9152],{"class":160},"        parts ",[136,9154,785],{"class":149},[136,9156,635],{"class":160},[136,9158,9159],{"class":156},"SplitN",[136,9161,9162],{"class":160},"(authHeader, ",[136,9164,9165],{"class":170},"\" \"",[136,9167,568],{"class":160},[136,9169,7611],{"class":209},[136,9171,390],{"class":160},[136,9173,9174,9176,9179,9182,9184,9186,9188,9191,9194,9197,9200,9202,9205,9208],{"class":138,"line":404},[136,9175,917],{"class":149},[136,9177,9178],{"class":156}," len",[136,9180,9181],{"class":160},"(parts) ",[136,9183,3378],{"class":149},[136,9185,2140],{"class":209},[136,9187,927],{"class":149},[136,9189,9190],{"class":149}," !",[136,9192,9193],{"class":160},"strings.",[136,9195,9196],{"class":156},"EqualFold",[136,9198,9199],{"class":160},"(parts[",[136,9201,1228],{"class":209},[136,9203,9204],{"class":160},"], ",[136,9206,9207],{"class":170},"\"Bearer\"",[136,9209,574],{"class":160},[136,9211,9212,9214,9216,9218,9220,9222,9224,9226,9228,9230,9232,9234,9237],{"class":138,"line":421},[136,9213,946],{"class":160},[136,9215,9109],{"class":156},[136,9217,549],{"class":160},[136,9219,9114],{"class":209},[136,9221,568],{"class":160},[136,9223,770],{"class":156},[136,9225,415],{"class":160},[136,9227,8862],{"class":156},[136,9229,2188],{"class":160},[136,9231,9127],{"class":170},[136,9233,307],{"class":160},[136,9235,9236],{"class":170},"\"格式错误，请使用 Bearer \u003Ctoken>\"",[136,9238,189],{"class":160},[136,9240,9241],{"class":138,"line":426},[136,9242,9139],{"class":149},[136,9244,9245],{"class":138,"line":432},[136,9246,1011],{"class":160},[136,9248,9249],{"class":138,"line":472},[136,9250,345],{"emptyLinePlaceholder":344},[136,9252,9253,9256,9258,9261,9264,9266,9268],{"class":138,"line":477},[136,9254,9255],{"class":160},"        user, err ",[136,9257,785],{"class":149},[136,9259,9260],{"class":160}," authService.",[136,9262,9263],{"class":156},"GetUserByToken",[136,9265,9199],{"class":160},[136,9267,2883],{"class":209},[136,9269,9270],{"class":160},"])\n",[136,9272,9273,9275,9277,9279,9281],{"class":138,"line":492},[136,9274,917],{"class":149},[136,9276,3375],{"class":160},[136,9278,3378],{"class":149},[136,9280,3381],{"class":209},[136,9282,489],{"class":160},[136,9284,9285,9287,9289,9291,9293,9295,9297,9299,9301,9303,9305,9307,9310],{"class":138,"line":501},[136,9286,946],{"class":160},[136,9288,9109],{"class":156},[136,9290,549],{"class":160},[136,9292,9114],{"class":209},[136,9294,568],{"class":160},[136,9296,770],{"class":156},[136,9298,415],{"class":160},[136,9300,8862],{"class":156},[136,9302,2188],{"class":160},[136,9304,9127],{"class":170},[136,9306,307],{"class":160},[136,9308,9309],{"class":170},"\"无效的认证令牌\"",[136,9311,189],{"class":160},[136,9313,9314],{"class":138,"line":516},[136,9315,9139],{"class":149},[136,9317,9318],{"class":138,"line":521},[136,9319,1011],{"class":160},[136,9321,9322],{"class":138,"line":526},[136,9323,345],{"emptyLinePlaceholder":344},[136,9325,9326,9328,9331,9333,9336],{"class":138,"line":577},[136,9327,6531],{"class":160},[136,9329,9330],{"class":156},"Set",[136,9332,549],{"class":160},[136,9334,9335],{"class":170},"\"userID\"",[136,9337,9338],{"class":160},", user.ID)\n",[136,9340,9341,9343,9345,9347,9350],{"class":138,"line":594},[136,9342,6531],{"class":160},[136,9344,9330],{"class":156},[136,9346,549],{"class":160},[136,9348,9349],{"class":170},"\"username\"",[136,9351,9352],{"class":160},", user.Username)\n",[136,9354,9355,9357,9360],{"class":138,"line":606},[136,9356,6531],{"class":160},[136,9358,9359],{"class":156},"Next",[136,9361,794],{"class":160},[136,9363,9364],{"class":138,"line":617},[136,9365,652],{"class":160},[136,9367,9368],{"class":138,"line":649},[136,9369,315],{"class":160},[127,9371,9373],{"className":322,"code":9372,"language":324,"meta":132,"style":132},"\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",[64,9374,9375,9380,9389,9395,9408,9420,9424,9428,9445,9465,9479,9503,9525,9540,9548,9552],{"__ignoreMap":132},[136,9376,9377],{"class":138,"line":139},[136,9378,9379],{"class":142},"\u002F\u002F 用户信息桥接中间件：Gin Context → request.Context\n",[136,9381,9382,9384,9387],{"class":138,"line":146},[136,9383,480],{"class":149},[136,9385,9386],{"class":156}," ctxKey",[136,9388,1963],{"class":149},[136,9390,9391,9393],{"class":138,"line":164},[136,9392,1976],{"class":149},[136,9394,353],{"class":160},[136,9396,9397,9400,9403,9405],{"class":138,"line":180},[136,9398,9399],{"class":209},"    ctxUserID",[136,9401,9402],{"class":156},"   ctxKey",[136,9404,448],{"class":149},[136,9406,9407],{"class":170}," \"userID\"\n",[136,9409,9410,9413,9415,9417],{"class":138,"line":186},[136,9411,9412],{"class":209},"    ctxUsername",[136,9414,9386],{"class":156},[136,9416,448],{"class":149},[136,9418,9419],{"class":170}," \"username\"\n",[136,9421,9422],{"class":138,"line":367},[136,9423,390],{"class":160},[136,9425,9426],{"class":138,"line":377},[136,9427,345],{"emptyLinePlaceholder":344},[136,9429,9430,9432,9435,9437,9439,9441,9443],{"class":138,"line":387},[136,9431,529],{"class":149},[136,9433,9434],{"class":156}," WithUserContext",[136,9436,3659],{"class":160},[136,9438,770],{"class":156},[136,9440,415],{"class":160},[136,9442,6369],{"class":156},[136,9444,489],{"class":160},[136,9446,9447,9449,9451,9453,9455,9457,9459,9461,9463],{"class":138,"line":393},[136,9448,658],{"class":149},[136,9450,6402],{"class":149},[136,9452,549],{"class":160},[136,9454,836],{"class":535},[136,9456,748],{"class":149},[136,9458,770],{"class":156},[136,9460,415],{"class":160},[136,9462,845],{"class":156},[136,9464,574],{"class":160},[136,9466,9467,9470,9472,9475,9477],{"class":138,"line":398},[136,9468,9469],{"class":160},"        ctx ",[136,9471,785],{"class":149},[136,9473,9474],{"class":160}," c.Request.",[136,9476,845],{"class":156},[136,9478,794],{"class":160},[136,9480,9481,9483,9485,9487,9490,9493,9496,9498,9500],{"class":138,"line":404},[136,9482,9469],{"class":160},[136,9484,600],{"class":149},[136,9486,3291],{"class":160},[136,9488,9489],{"class":156},"WithValue",[136,9491,9492],{"class":160},"(ctx, ctxUserID, c.",[136,9494,9495],{"class":156},"GetUint",[136,9497,549],{"class":160},[136,9499,9335],{"class":170},[136,9501,9502],{"class":160},"))\n",[136,9504,9505,9507,9509,9511,9513,9516,9519,9521,9523],{"class":138,"line":421},[136,9506,9469],{"class":160},[136,9508,600],{"class":149},[136,9510,3291],{"class":160},[136,9512,9489],{"class":156},[136,9514,9515],{"class":160},"(ctx, ctxUsername, c.",[136,9517,9518],{"class":156},"GetString",[136,9520,549],{"class":160},[136,9522,9349],{"class":170},[136,9524,9502],{"class":160},[136,9526,9527,9530,9532,9534,9537],{"class":138,"line":426},[136,9528,9529],{"class":160},"        c.Request ",[136,9531,600],{"class":149},[136,9533,9474],{"class":160},[136,9535,9536],{"class":156},"WithContext",[136,9538,9539],{"class":160},"(ctx)\n",[136,9541,9542,9544,9546],{"class":138,"line":432},[136,9543,6531],{"class":160},[136,9545,9359],{"class":156},[136,9547,794],{"class":160},[136,9549,9550],{"class":138,"line":472},[136,9551,652],{"class":160},[136,9553,9554],{"class":138,"line":477},[136,9555,315],{"class":160},[111,9557,9558],{"id":9558},"组合使用",[127,9560,9562],{"className":322,"code":9561,"language":324,"meta":132,"style":132},"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",[64,9563,9564,9576,9587,9598,9611],{"__ignoreMap":132},[136,9565,9566,9568,9570,9572,9574],{"class":138,"line":139},[136,9567,8609],{"class":160},[136,9569,8612],{"class":156},[136,9571,549],{"class":160},[136,9573,8572],{"class":170},[136,9575,2073],{"class":160},[136,9577,9578,9581,9584],{"class":138,"line":146},[136,9579,9580],{"class":156},"    AuthRequired",[136,9582,9583],{"class":160},"(authService),  ",[136,9585,9586],{"class":142},"\u002F\u002F 1. 验证 Token\n",[136,9588,9589,9592,9595],{"class":138,"line":164},[136,9590,9591],{"class":156},"    WithUserContext",[136,9593,9594],{"class":160},"(),           ",[136,9596,9597],{"class":142},"\u002F\u002F 2. 桥接用户信息到 request.Context\n",[136,9599,9600,9603,9605,9608],{"class":138,"line":180},[136,9601,9602],{"class":160},"    gin.",[136,9604,8622],{"class":156},[136,9606,9607],{"class":160},"(mcpHTTP),         ",[136,9609,9610],{"class":142},"\u002F\u002F 3. MCP 处理\n",[136,9612,9613],{"class":138,"line":186},[136,9614,390],{"class":160},[16,9616,9617],{},"在工具处理器里就能拿到用户信息了：",[127,9619,9621],{"className":322,"code":9620,"language":324,"meta":132,"style":132},"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",[64,9622,9623,9678,9699,9708,9725,9729,9733,9749,9754,9767],{"__ignoreMap":132},[136,9624,9625,9627,9629,9632,9634,9637,9639,9642,9644,9646,9648,9650,9652,9654,9656,9658,9660,9662,9664,9666,9668,9670,9672,9674,9676],{"class":138,"line":139},[136,9626,529],{"class":149},[136,9628,532],{"class":160},[136,9630,9631],{"class":535},"h ",[136,9633,507],{"class":149},[136,9635,9636],{"class":156},"Handler",[136,9638,543],{"class":160},[136,9640,9641],{"class":156},"handleListItems",[136,9643,549],{"class":160},[136,9645,8229],{"class":535},[136,9647,8232],{"class":156},[136,9649,415],{"class":160},[136,9651,845],{"class":156},[136,9653,568],{"class":160},[136,9655,8241],{"class":535},[136,9657,4860],{"class":156},[136,9659,415],{"class":160},[136,9661,8248],{"class":156},[136,9663,558],{"class":160},[136,9665,507],{"class":149},[136,9667,8255],{"class":156},[136,9669,415],{"class":160},[136,9671,8260],{"class":156},[136,9673,568],{"class":160},[136,9675,571],{"class":149},[136,9677,574],{"class":160},[136,9679,9680,9683,9685,9688,9691,9694,9697],{"class":138,"line":146},[136,9681,9682],{"class":160},"    userID, ok ",[136,9684,785],{"class":149},[136,9686,9687],{"class":160}," ctx.",[136,9689,9690],{"class":156},"Value",[136,9692,9693],{"class":160},"(ctxUserID).(",[136,9695,9696],{"class":149},"uint",[136,9698,390],{"class":160},[136,9700,9701,9703,9705],{"class":138,"line":164},[136,9702,580],{"class":149},[136,9704,9190],{"class":149},[136,9706,9707],{"class":160},"ok {\n",[136,9709,9710,9712,9714,9716,9718,9721,9723],{"class":138,"line":180},[136,9711,3703],{"class":149},[136,9713,8173],{"class":160},[136,9715,8472],{"class":156},[136,9717,549],{"class":160},[136,9719,9720],{"class":170},"\"未认证\"",[136,9722,8288],{"class":160},[136,9724,8291],{"class":209},[136,9726,9727],{"class":138,"line":186},[136,9728,652],{"class":160},[136,9730,9731],{"class":138,"line":367},[136,9732,345],{"emptyLinePlaceholder":344},[136,9734,9735,9738,9740,9743,9746],{"class":138,"line":377},[136,9736,9737],{"class":160},"    items, _ ",[136,9739,785],{"class":149},[136,9741,9742],{"class":160}," h.service.",[136,9744,9745],{"class":156},"ListByUser",[136,9747,9748],{"class":160},"(userID)\n",[136,9750,9751],{"class":138,"line":387},[136,9752,9753],{"class":142},"    \u002F\u002F ...\n",[136,9755,9756,9758,9760,9762,9765],{"class":138,"line":393},[136,9757,658],{"class":149},[136,9759,8173],{"class":160},[136,9761,8280],{"class":156},[136,9763,9764],{"class":160},"(result), ",[136,9766,8291],{"class":209},[136,9768,9769],{"class":138,"line":398},[136,9770,315],{"class":160},[111,9772,9773],{"id":9773},"客户端配置",[16,9775,9776],{},"AI 客户端（如 Claude Desktop）配置 MCP 服务器时带上 Authorization 头：",[127,9778,9780],{"className":282,"code":9779,"language":284,"meta":132,"style":132},"{\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",[64,9781,9782,9786,9792,9798,9810,9817,9827,9832,9836,9840],{"__ignoreMap":132},[136,9783,9784],{"class":138,"line":139},[136,9785,5333],{"class":160},[136,9787,9788,9790],{"class":138,"line":146},[136,9789,5338],{"class":209},[136,9791,299],{"class":160},[136,9793,9794,9796],{"class":138,"line":164},[136,9795,8065],{"class":209},[136,9797,299],{"class":160},[136,9799,9800,9803,9805,9808],{"class":138,"line":180},[136,9801,9802],{"class":209},"      \"url\"",[136,9804,307],{"class":160},[136,9806,9807],{"class":170},"\"http:\u002F\u002Flocalhost:8080\u002Fmcp\"",[136,9809,2073],{"class":160},[136,9811,9812,9815],{"class":138,"line":186},[136,9813,9814],{"class":209},"      \"headers\"",[136,9816,299],{"class":160},[136,9818,9819,9822,9824],{"class":138,"line":367},[136,9820,9821],{"class":209},"        \"Authorization\"",[136,9823,307],{"class":160},[136,9825,9826],{"class":170},"\"Bearer your-token-here\"\n",[136,9828,9829],{"class":138,"line":377},[136,9830,9831],{"class":160},"      }\n",[136,9833,9834],{"class":138,"line":387},[136,9835,652],{"class":160},[136,9837,9838],{"class":138,"line":393},[136,9839,5386],{"class":160},[136,9841,9842],{"class":138,"line":398},[136,9843,315],{"class":160},[12,9845,1627],{"id":1627},[1745,9847,9848,9857],{},[1748,9849,9850],{},[1751,9851,9852,9855],{},[1754,9853,9854],{},"步骤",[1754,9856,1765],{},[1767,9858,9859,9868,9879,9888,9897],{},[1751,9860,9861,9865],{},[1772,9862,9863],{},[64,9864,8057],{},[1772,9866,9867],{},"创建 MCP 协议实例",[1751,9869,9870,9876],{},[1772,9871,9872,4239,9874],{},[64,9873,8176],{},[64,9875,8219],{},[1772,9877,9878],{},"定义并注册工具",[1751,9880,9881,9885],{},[1772,9882,9883],{},[64,9884,8557],{},[1772,9886,9887],{},"转为 HTTP Handler",[1751,9889,9890,9894],{},[1772,9891,9892],{},[64,9893,8525],{},[1772,9895,9896],{},"挂载到 Gin 路由",[1751,9898,9899,9907],{},[1772,9900,9901,4239,9904],{},[64,9902,9903],{},"AuthRequired",[64,9905,9906],{},"WithUserContext",[1772,9908,9909],{},"鉴权 + 用户信息桥接",[16,9911,9912,9913,9916,9917,1312],{},"核心就一句话：",[26,9914,9915],{},"MCP 是 HTTP Handler，挂上去就行。"," 鉴权复用你已有的中间件体系，只需要多写一个桥接函数把用户信息传进 ",[64,9918,9013],{},[1666,9920,9921],{},"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":132,"searchDepth":146,"depth":164,"links":9923},[9924,9925,9926,9927,9934,9940],{"id":7940,"depth":146,"text":7941},{"id":7950,"depth":146,"text":7951},{"id":7967,"depth":146,"text":7967},{"id":7993,"depth":146,"text":7993,"children":9928},[9929,9930,9931,9932,9933],{"id":7996,"depth":164,"text":7997},{"id":8023,"depth":164,"text":8024},{"id":8137,"depth":164,"text":8138},{"id":8514,"depth":164,"text":8515},{"id":8635,"depth":164,"text":8636},{"id":8986,"depth":146,"text":8987,"children":9935},[9936,9937,9938,9939],{"id":4302,"depth":164,"text":4302},{"id":9006,"depth":164,"text":9007},{"id":9558,"depth":164,"text":9558},{"id":9773,"depth":164,"text":9773},{"id":1627,"depth":146,"text":1627},"介绍如何使用 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":7935,"description":9941},"posts\u002Fgo-mcp-gin\u002Findex",[1702,6196,4435,6197,4527],"2026-06-01","5fZl1Vigs2lHb5fHPz-0YZFyFD38ws1heOouZUG1g6M",{"id":9952,"title":9953,"author":9954,"body":9955,"description":10220,"difficulty":1691,"draft":1692,"extension":1693,"image":10221,"meta":10222,"navigation":344,"path":10223,"pinned":1692,"published":10224,"seo":10225,"series":10226,"seriesOrder":164,"stem":10227,"tags":10228,"updated":10233,"__hash__":10234},"posts\u002Fposts\u002Fnuxt-tailwind-underline-fix\u002Findex.md","Nuxt 3 + Tailwind CSS 客户端导航链接下划线闪烁问题","SuBlog",{"type":9,"value":9956,"toc":10213},[9957,9960,9963,9978,9981,9984,9987,10028,10039,10045,10067,10074,10078,10081,10084,10087,10090,10113,10124,10181,10186,10199,10201,10207,10210],[12,9958,9959],{"id":9959},"问题描述",[16,9961,9962],{},"在 Nuxt 3 项目中使用 Tailwind CSS 时，发现一个奇怪的现象：",[31,9964,9965,9975],{},[34,9966,9967,9968,9971,9972],{},"首次通过客户端导航（点击 ",[64,9969,9970],{},"\u003CNuxtLink>","）跳转到某个页面时，",[26,9973,9974],{},"所有链接文字都会出现下划线",[34,9976,9977],{},"手动刷新页面后，下划线消失，样式恢复正常",[16,9979,9980],{},"这个问题在开发模式和生产构建后都会出现。",[12,9982,9983],{"id":9983},"原因分析",[16,9985,9986],{},"Tailwind CSS 的 Preflight（基于 modern-normalize）会重置链接样式：",[127,9988,9992],{"className":9989,"code":9990,"language":9991,"meta":132,"style":132},"language-css shiki shiki-themes github-light github-dark","a {\n  color: inherit;\n  text-decoration: inherit;\n}\n","css",[64,9993,9994,10000,10013,10024],{"__ignoreMap":132},[136,9995,9996,9998],{"class":138,"line":139},[136,9997,20],{"class":2334},[136,9999,489],{"class":160},[136,10001,10002,10005,10007,10010],{"class":138,"line":146},[136,10003,10004],{"class":209},"  color",[136,10006,307],{"class":160},[136,10008,10009],{"class":209},"inherit",[136,10011,10012],{"class":160},";\n",[136,10014,10015,10018,10020,10022],{"class":138,"line":164},[136,10016,10017],{"class":209},"  text-decoration",[136,10019,307],{"class":160},[136,10021,10009],{"class":209},[136,10023,10012],{"class":160},[136,10025,10026],{"class":138,"line":180},[136,10027,315],{"class":160},[16,10029,10030,10031,10034,10035,10038],{},"正常情况下，这会让链接继承父元素的 ",[64,10032,10033],{},"text-decoration","（通常是 ",[64,10036,10037],{},"none","），从而去掉浏览器默认的下划线。",[16,10040,10041,10042,125],{},"问题出在 ",[26,10043,10044],{},"Nuxt 的客户端导航机制",[55,10046,10047,10053,10056,10061,10064],{},[34,10048,10049,10050,10052],{},"用户点击 ",[64,10051,9970],{},"，Vue Router 拦截导航",[34,10054,10055],{},"新页面组件异步加载并渲染",[34,10057,10058],{},[26,10059,10060],{},"在 CSS 完全加载\u002F应用之前，页面已经渲染了",[34,10062,10063],{},"此时浏览器使用默认样式（链接带下划线）",[34,10065,10066],{},"CSS 加载完成后，下划线消失",[16,10068,10069,10070,10073],{},"这就是典型的 ",[26,10071,10072],{},"FOUC（Flash of Unstyled Content）"," 问题。",[12,10075,10077],{"id":10076},"为什么刷新就正常了","为什么刷新就正常了？",[16,10079,10080],{},"刷新页面时，浏览器会等待所有 CSS 加载完成后再渲染页面（SSR 模式下 HTML 和 CSS 是一起返回的）。所以刷新后样式是正确的。",[16,10082,10083],{},"而客户端导航是 JavaScript 驱动的，CSS 的加载和页面的渲染是异步的，就出现了时序差。",[12,10085,10086],{"id":10086},"解决方案",[16,10088,10089],{},"尝试了多种方案：",[31,10091,10092,10098,10104,10110],{},[34,10093,10094,10097],{},[64,10095,10096],{},"@layer base"," 覆盖 — 无效，CSS layers 的优先级问题",[34,10099,10100,10103],{},[64,10101,10102],{},"corePlugins.preflight: false"," — 无效，且破坏其他基础样式",[34,10105,10106,10109],{},[64,10107,10108],{},"features.inlineStyles: true"," — 无效",[34,10111,10112],{},"页面过渡动画 — 只是掩盖，不能根治",[16,10114,10115,10116,10119,10120,10123],{},"最终有效的方案是在 ",[64,10117,10118],{},"app.vue"," 中用 ",[64,10121,10122],{},"!important"," 强制覆盖：",[127,10125,10129],{"className":10126,"code":10127,"language":10128,"meta":132,"style":132},"language-vue shiki shiki-themes github-light github-dark","\u003Cstyle>\na:not(.prose a) {\n  text-decoration-line: none !important;\n}\n\u003C\u002Fstyle>\n","vue",[64,10130,10131,10139,10155,10169,10173],{"__ignoreMap":132},[136,10132,10133,10135,10137],{"class":138,"line":139},[136,10134,206],{"class":160},[136,10136,1666],{"class":2334},[136,10138,212],{"class":160},[136,10140,10141,10143,10146,10148,10151,10153],{"class":138,"line":146},[136,10142,20],{"class":2334},[136,10144,10145],{"class":156},":not",[136,10147,549],{"class":160},[136,10149,10150],{"class":156},".prose",[136,10152,2849],{"class":2334},[136,10154,574],{"class":160},[136,10156,10157,10160,10162,10164,10167],{"class":138,"line":164},[136,10158,10159],{"class":209},"  text-decoration-line",[136,10161,307],{"class":160},[136,10163,10037],{"class":209},[136,10165,10166],{"class":149}," !important",[136,10168,10012],{"class":160},[136,10170,10171],{"class":138,"line":180},[136,10172,315],{"class":160},[136,10174,10175,10177,10179],{"class":138,"line":186},[136,10176,232],{"class":160},[136,10178,1666],{"class":2334},[136,10180,212],{"class":160},[16,10182,10183],{},[26,10184,10185],{},"关键点：",[31,10187,10188,10193],{},[34,10189,10190,10192],{},[64,10191,10122],{}," 是为了对抗 Tailwind CSS layers 的优先级",[34,10194,10195,10198],{},[64,10196,10197],{},":not(.prose a)"," 排除文章正文区域，保留文章内链接的下划线（可读性需要）",[12,10200,1627],{"id":1627},[16,10202,10203,10204,10206],{},"这是 Nuxt 3 + Tailwind CSS 的一个已知问题，本质上是 CSS 加载时序导致的 FOUC。",[64,10205,10122],{}," 虽然不够优雅，但在这个场景下是务实的解决方案。",[16,10208,10209],{},"如果你有更好的方案，欢迎在项目仓库的 Issues 中提出！",[1666,10211,10212],{},"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":132,"searchDepth":146,"depth":164,"links":10214},[10215,10216,10217,10218,10219],{"id":9959,"depth":146,"text":9959},{"id":9983,"depth":146,"text":9983},{"id":10076,"depth":146,"text":10077},{"id":10086,"depth":146,"text":10086},{"id":1627,"depth":146,"text":1627},"记录 Nuxt 3 配合 Tailwind CSS 时，客户端导航导致链接文字出现下划线闪烁的问题及解决方案。","\u002Fposts\u002Fnuxt-tailwind-underline-fix\u002Fimg\u002Fcover.png",{},"\u002Fposts\u002Fnuxt-tailwind-underline-fix","2026-05-31T18:47:00",{"title":9953,"description":10220},"Nuxt 博客开发","posts\u002Fnuxt-tailwind-underline-fix\u002Findex",[10229,10230,10231,10232],"Nuxt","Tailwind CSS","CSS","踩坑","2026-05-31","WMLpTEsIoJqufwEfXyIiBgUYxcPY2XkvOwqAD4hbe6g",{"id":10236,"title":10237,"author":7,"body":10238,"description":11371,"difficulty":1691,"draft":1692,"extension":1693,"image":11372,"meta":11373,"navigation":344,"path":11374,"pinned":1692,"published":11375,"seo":11376,"series":10226,"seriesOrder":146,"stem":11377,"tags":11378,"updated":10233,"__hash__":11380},"posts\u002Fposts\u002Fnuxt-content-lessons\u002Findex.md","Nuxt Content v3 踩坑记录：目录、图片与那些反直觉的设计",{"type":9,"value":10239,"toc":11361},[10240,10243,10246,10249,10253,10256,10266,10269,10276,10342,10349,10352,10359,10618,10628,10632,10635,10638,10663,10677,10680,10703,10709,10712,10715,10718,10723,10730,10945,10950,10994,10998,11001,11008,11011,11058,11064,11289,11299,11301,11352,11355,11358],[10241,10242,14],"h1",{"id":14},[16,10244,10245],{},"最近在用 Nuxt 3 + Nuxt Content v3 搭建一个个人博客，参考了一个 SvelteKit 项目（svaf）的架构。原以为换个框架只是语法差异，没想到在几个关键功能上踩了不少坑。",[16,10247,10248],{},"这篇文章记录了三个核心问题的排查过程和最终方案。",[10241,10250,10252],{"id":10251},"问题一目录导航toc只有-h2","问题一：目录导航（TOC）只有 h2",[12,10254,10255],{"id":10255},"现象",[16,10257,10258,10259,10262,10263,1312],{},"Nuxt Content 内置了 TOC 数据，挂在 ",[64,10260,10261],{},"post.body.toc"," 上。直接用它渲染目录，发现",[26,10264,10265],{},"只有 h2 标题，h3 全部丢失",[12,10267,10268],{"id":10268},"排查",[16,10270,10271,10272,10275],{},"查了官方文档，发现 ",[64,10273,10274],{},"build.markdown.toc.depth"," 可以控制深度：",[127,10277,10281],{"className":10278,"code":10279,"language":10280,"meta":132,"style":132},"language-typescript shiki shiki-themes github-light github-dark","\u002F\u002F nuxt.config.ts\ncontent: {\n  build: {\n    markdown: {\n      toc: { depth: 3 }  \u002F\u002F 包含 h3\n    }\n  }\n}\n","typescript",[64,10282,10283,10288,10295,10302,10309,10330,10334,10338],{"__ignoreMap":132},[136,10284,10285],{"class":138,"line":139},[136,10286,10287],{"class":142},"\u002F\u002F nuxt.config.ts\n",[136,10289,10290,10293],{"class":138,"line":146},[136,10291,10292],{"class":156},"content",[136,10294,299],{"class":160},[136,10296,10297,10300],{"class":138,"line":164},[136,10298,10299],{"class":156},"  build",[136,10301,299],{"class":160},[136,10303,10304,10307],{"class":138,"line":180},[136,10305,10306],{"class":156},"    markdown",[136,10308,299],{"class":160},[136,10310,10311,10314,10317,10320,10322,10324,10327],{"class":138,"line":186},[136,10312,10313],{"class":156},"      toc",[136,10315,10316],{"class":160},": { ",[136,10318,10319],{"class":156},"depth",[136,10321,307],{"class":160},[136,10323,3306],{"class":209},[136,10325,10326],{"class":160}," }  ",[136,10328,10329],{"class":142},"\u002F\u002F 包含 h3\n",[136,10331,10332],{"class":138,"line":367},[136,10333,652],{"class":160},[136,10335,10336],{"class":138,"line":377},[136,10337,5386],{"class":160},[136,10339,10340],{"class":138,"line":387},[136,10341,315],{"class":160},[16,10343,10344,10345,10348],{},"配置改了，清了缓存重启，",[26,10346,10347],{},"依然只有 h2","。这个配置似乎不起作用。",[12,10350,10351],{"id":10351},"最终方案",[16,10353,10354,10355,10358],{},"放弃依赖 Nuxt Content 的 toc，直接从 ",[64,10356,10357],{},"post.body"," 的 AST 里递归提取所有标题：",[127,10360,10362],{"className":10278,"code":10361,"language":10280,"meta":132,"style":132},"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",[64,10363,10364,10406,10423,10436,10441,10494,10521,10551,10575,10579,10603,10607,10614],{"__ignoreMap":132},[136,10365,10366,10369,10372,10374,10377,10379,10382,10384,10387,10389,10392,10394,10396,10399,10401,10403],{"class":138,"line":139},[136,10367,10368],{"class":149},"function",[136,10370,10371],{"class":156}," extractHeadings",[136,10373,549],{"class":160},[136,10375,10376],{"class":535},"node",[136,10378,1982],{"class":149},[136,10380,10381],{"class":209}," any",[136,10383,568],{"class":160},[136,10385,10386],{"class":535},"list",[136,10388,1982],{"class":149},[136,10390,10391],{"class":156}," Heading",[136,10393,1987],{"class":160},[136,10395,600],{"class":149},[136,10397,10398],{"class":160}," [])",[136,10400,1982],{"class":149},[136,10402,10391],{"class":156},[136,10404,10405],{"class":160},"[] {\n",[136,10407,10408,10411,10413,10416,10419,10421],{"class":138,"line":146},[136,10409,10410],{"class":149},"  if",[136,10412,532],{"class":160},[136,10414,10415],{"class":149},"!",[136,10417,10418],{"class":160},"node) ",[136,10420,2898],{"class":149},[136,10422,5766],{"class":160},[136,10424,10425,10427,10430,10433],{"class":138,"line":164},[136,10426,10410],{"class":149},[136,10428,10429],{"class":160}," (Array.",[136,10431,10432],{"class":156},"isArray",[136,10434,10435],{"class":160},"(node)) {\n",[136,10437,10438],{"class":138,"line":180},[136,10439,10440],{"class":142},"    \u002F\u002F minimark 格式：[\"h2\", {id: \"xxx\"}, \"文本\"]\n",[136,10442,10443,10445,10447,10450,10453,10455,10458,10460,10463,10466,10469,10471,10474,10477,10479,10481,10483,10486,10489,10491],{"class":138,"line":186},[136,10444,580],{"class":149},[136,10446,532],{"class":160},[136,10448,10449],{"class":149},"typeof",[136,10451,10452],{"class":160}," node[",[136,10454,1228],{"class":209},[136,10456,10457],{"class":160},"] ",[136,10459,7040],{"class":149},[136,10461,10462],{"class":170}," 'string'",[136,10464,10465],{"class":149}," &&",[136,10467,10468],{"class":170}," \u002F",[136,10470,6959],{"class":149},[136,10472,10473],{"class":6962},"h",[136,10475,10476],{"class":209},"[1-6]",[136,10478,1596],{"class":149},[136,10480,6956],{"class":170},[136,10482,415],{"class":160},[136,10484,10485],{"class":156},"test",[136,10487,10488],{"class":160},"(node[",[136,10490,1228],{"class":209},[136,10492,10493],{"class":160},"])) {\n",[136,10495,10496,10498,10501,10503,10506,10508,10510,10512,10515,10517,10519],{"class":138,"line":367},[136,10497,2277],{"class":149},[136,10499,10500],{"class":209}," level",[136,10502,448],{"class":149},[136,10504,10505],{"class":156}," Number",[136,10507,10488],{"class":160},[136,10509,1228],{"class":209},[136,10511,2859],{"class":160},[136,10513,10514],{"class":156},"charAt",[136,10516,549],{"class":160},[136,10518,2883],{"class":209},[136,10520,9502],{"class":160},[136,10522,10523,10525,10528,10530,10533,10536,10539,10541,10543,10546,10549],{"class":138,"line":377},[136,10524,2277],{"class":149},[136,10526,10527],{"class":209}," text",[136,10529,448],{"class":149},[136,10531,10532],{"class":156}," extractText",[136,10534,10535],{"class":160},"(node.",[136,10537,10538],{"class":156},"slice",[136,10540,549],{"class":160},[136,10542,7611],{"class":209},[136,10544,10545],{"class":160},")).",[136,10547,10548],{"class":156},"trim",[136,10550,794],{"class":160},[136,10552,10553,10556,10559,10562,10564,10567,10569,10572],{"class":138,"line":387},[136,10554,10555],{"class":160},"      list.",[136,10557,10558],{"class":156},"push",[136,10560,10561],{"class":160},"({ id: node[",[136,10563,2883],{"class":209},[136,10565,10566],{"class":160},"]?.id ",[136,10568,2034],{"class":149},[136,10570,10571],{"class":156}," slugify",[136,10573,10574],{"class":160},"(text), text, level })\n",[136,10576,10577],{"class":138,"line":393},[136,10578,652],{"class":160},[136,10580,10581,10584,10586,10588,10591,10594,10597,10600],{"class":138,"line":398},[136,10582,10583],{"class":149},"    for",[136,10585,532],{"class":160},[136,10587,1976],{"class":149},[136,10589,10590],{"class":209}," child",[136,10592,10593],{"class":149}," of",[136,10595,10596],{"class":160}," node) ",[136,10598,10599],{"class":156},"extractHeadings",[136,10601,10602],{"class":160},"(child, list)\n",[136,10604,10605],{"class":138,"line":404},[136,10606,5386],{"class":160},[136,10608,10609,10612],{"class":138,"line":421},[136,10610,10611],{"class":149},"  return",[136,10613,5766],{"class":160},[136,10615,10616],{"class":138,"line":426},[136,10617,315],{"class":160},[16,10619,10620,10621,10623,10624,10627],{},"关键点：Nuxt Content v3 的 body 使用 ",[26,10622,9],{}," 格式，标题节点是数组 ",[64,10625,10626],{},"[\"h2\", {id: \"xxx\"}, \"标题文本\"]","，不是对象。",[10241,10629,10631],{"id":10630},"问题二图片路径解析错误","问题二：图片路径解析错误",[12,10633,10255],{"id":10634},"现象-1",[16,10636,10637],{},"markdown 里用相对路径引用图片：",[127,10639,10643],{"className":10640,"code":10641,"language":10642,"meta":132,"style":132},"language-markdown shiki shiki-themes github-light github-dark","![SSHFS挂载效果](img\u002Fsshfs.avif)\n","markdown",[64,10644,10645],{"__ignoreMap":132},[136,10646,10647,10650,10654,10657,10661],{"class":138,"line":139},[136,10648,10649],{"class":160},"![",[136,10651,10653],{"class":10652},"smiIW","SSHFS挂载效果",[136,10655,10656],{"class":160},"](",[136,10658,10660],{"class":10659},"szbl0","img\u002Fsshfs.avif",[136,10662,390],{"class":160},[16,10664,10665,10666,10669,10670,10673,10674,1312],{},"HTML 输出的 ",[64,10667,10668],{},"\u003Cimg src=\"img\u002Fsshfs.avif\">"," 看起来没问题，但浏览器请求的却是 ",[64,10671,10672],{},"\u002Fposts\u002Fimg\u002Fsshfs.avif"," 而不是 ",[64,10675,10676],{},"\u002Fposts\u002Fsshfs\u002Fimg\u002Fsshfs.avif",[12,10678,10268],{"id":10679},"排查-1",[55,10681,10682,10689,10692,10698],{},[34,10683,10684,10685,10688],{},"检查了 ",[64,10686,10687],{},"\u003Cbase>"," 标签 —— 没有",[34,10690,10691],{},"检查了重定向 —— 没有",[34,10693,10694,10695,10697],{},"直接访问 ",[64,10696,10676],{}," —— 返回 200 OK",[34,10699,10694,10700,10702],{},[64,10701,10672],{}," —— 404",[16,10704,10705,10706,1312],{},"服务器路由没问题，问题是",[26,10707,10708],{},"浏览器解析相对路径时基于了错误的基准 URL",[16,10710,10711],{},"这可能和 Nuxt 的客户端路由有关：SPA 导航时，浏览器的\"当前页面\"和 Vue Router 的\"当前路由\"不同步，导致相对路径解析出错。",[12,10713,10351],{"id":10714},"最终方案-1",[16,10716,10717],{},"两步解决：",[16,10719,10720],{},[26,10721,10722],{},"第一步：自定义 ProseImg 组件",[16,10724,10725,10726,10729],{},"创建 ",[64,10727,10728],{},"components\u002Fcontent\u002FProseImg.vue","，覆盖 Nuxt Content 默认的图片渲染：",[127,10731,10733],{"className":10126,"code":10732,"language":10128,"meta":132,"style":132},"\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",[64,10734,10735,10747,10761,10780,10796,10833,10865,10887,10891,10899,10903,10912,10937],{"__ignoreMap":132},[136,10736,10737,10739,10742,10745],{"class":138,"line":139},[136,10738,206],{"class":160},[136,10740,10741],{"class":2334},"script",[136,10743,10744],{"class":156}," setup",[136,10746,212],{"class":160},[136,10748,10749,10751,10754,10756,10759],{"class":138,"line":146},[136,10750,1976],{"class":149},[136,10752,10753],{"class":209}," route",[136,10755,448],{"class":149},[136,10757,10758],{"class":156}," useRoute",[136,10760,794],{"class":160},[136,10762,10763,10765,10768,10770,10773,10776,10778],{"class":138,"line":164},[136,10764,1976],{"class":149},[136,10766,10767],{"class":209}," resolvedSrc",[136,10769,448],{"class":149},[136,10771,10772],{"class":156}," computed",[136,10774,10775],{"class":160},"(() ",[136,10777,2006],{"class":149},[136,10779,489],{"class":160},[136,10781,10782,10784,10786,10788,10791,10793],{"class":138,"line":180},[136,10783,10410],{"class":149},[136,10785,532],{"class":160},[136,10787,10415],{"class":149},[136,10789,10790],{"class":160},"props.src) ",[136,10792,2898],{"class":149},[136,10794,10795],{"class":170}," ''\n",[136,10797,10798,10800,10803,10806,10808,10811,10813,10815,10818,10820,10822,10825,10828,10830],{"class":138,"line":186},[136,10799,10410],{"class":149},[136,10801,10802],{"class":160}," (props.src.",[136,10804,10805],{"class":156},"startsWith",[136,10807,549],{"class":160},[136,10809,10810],{"class":170},"'\u002F'",[136,10812,543],{"class":160},[136,10814,2034],{"class":149},[136,10816,10817],{"class":160}," props.src.",[136,10819,10805],{"class":156},[136,10821,549],{"class":160},[136,10823,10824],{"class":170},"'http'",[136,10826,10827],{"class":160},")) ",[136,10829,2898],{"class":149},[136,10831,10832],{"class":160}," props.src\n",[136,10834,10835,10837,10840,10842,10845,10847,10849,10851,10855,10857,10859,10861,10863],{"class":138,"line":367},[136,10836,2746],{"class":149},[136,10838,10839],{"class":209}," base",[136,10841,448],{"class":149},[136,10843,10844],{"class":160}," route.path.",[136,10846,6951],{"class":156},[136,10848,549],{"class":160},[136,10850,6956],{"class":170},[136,10852,10854],{"class":10853},"sCnZR","\\\u002F",[136,10856,1596],{"class":149},[136,10858,6956],{"class":170},[136,10860,568],{"class":160},[136,10862,6969],{"class":170},[136,10864,390],{"class":160},[136,10866,10867,10869,10872,10875,10878,10880,10882,10884],{"class":138,"line":377},[136,10868,10611],{"class":149},[136,10870,10871],{"class":170}," `${",[136,10873,10874],{"class":160},"base",[136,10876,10877],{"class":170},"}\u002F${",[136,10879,2261],{"class":160},[136,10881,415],{"class":170},[136,10883,1889],{"class":160},[136,10885,10886],{"class":170},"}`\n",[136,10888,10889],{"class":138,"line":387},[136,10890,189],{"class":160},[136,10892,10893,10895,10897],{"class":138,"line":393},[136,10894,232],{"class":160},[136,10896,10741],{"class":2334},[136,10898,212],{"class":160},[136,10900,10901],{"class":138,"line":398},[136,10902,345],{"emptyLinePlaceholder":344},[136,10904,10905,10907,10910],{"class":138,"line":404},[136,10906,206],{"class":160},[136,10908,10909],{"class":2334},"template",[136,10911,212],{"class":160},[136,10913,10914,10916,10918,10921,10923,10926,10929,10931,10934],{"class":138,"line":421},[136,10915,217],{"class":160},[136,10917,2001],{"class":2334},[136,10919,10920],{"class":156}," :src",[136,10922,600],{"class":160},[136,10924,10925],{"class":170},"\"resolvedSrc\"",[136,10927,10928],{"class":156}," :alt",[136,10930,600],{"class":160},[136,10932,10933],{"class":170},"\"alt\"",[136,10935,10936],{"class":160}," \u002F>\n",[136,10938,10939,10941,10943],{"class":138,"line":426},[136,10940,232],{"class":160},[136,10942,10909],{"class":2334},[136,10944,212],{"class":160},[16,10946,10947],{},[26,10948,10949],{},"第二步：启用 MDC prose 组件",[127,10951,10953],{"className":10278,"code":10952,"language":10280,"meta":132,"style":132},"\u002F\u002F nuxt.config.ts\nmdc: {\n  components: {\n    prose: true  \u002F\u002F 让 Nuxt Content 使用自定义 ProseImg\n  }\n}\n",[64,10954,10955,10959,10966,10973,10986,10990],{"__ignoreMap":132},[136,10956,10957],{"class":138,"line":139},[136,10958,10287],{"class":142},[136,10960,10961,10964],{"class":138,"line":146},[136,10962,10963],{"class":156},"mdc",[136,10965,299],{"class":160},[136,10967,10968,10971],{"class":138,"line":164},[136,10969,10970],{"class":156},"  components",[136,10972,299],{"class":160},[136,10974,10975,10978,10980,10983],{"class":138,"line":180},[136,10976,10977],{"class":156},"    prose",[136,10979,307],{"class":160},[136,10981,10982],{"class":209},"true",[136,10984,10985],{"class":142},"  \u002F\u002F 让 Nuxt Content 使用自定义 ProseImg\n",[136,10987,10988],{"class":138,"line":186},[136,10989,5386],{"class":160},[136,10991,10992],{"class":138,"line":367},[136,10993,315],{"class":160},[10241,10995,10997],{"id":10996},"问题三content-目录的图片如何服务","问题三：content 目录的图片如何服务",[12,10999,10255],{"id":11000},"现象-2",[16,11002,11003,11004,11007],{},"原站（SvelteKit）的图片放在 ",[64,11005,11006],{},"content\u002Fposts\u002F{slug}\u002Fimg\u002F"," 下，markdown 用相对路径引用，一切正常。Nuxt 项目里，content 目录的文件不会被当成静态资源服务。",[12,11009,11010],{"id":11010},"方案对比",[1745,11012,11013,11023],{},[1748,11014,11015],{},[1751,11016,11017,11019,11021],{},[1754,11018,1756],{},[1754,11020,3229],{},[1754,11022,3232],{},[1767,11024,11025,11036,11047],{},[1751,11026,11027,11030,11033],{},[1772,11028,11029],{},"复制到 public\u002F",[1772,11031,11032],{},"简单",[1772,11034,11035],{},"文件重复，需同步",[1751,11037,11038,11041,11044],{},[1772,11039,11040],{},"Vite 插件拦截",[1772,11042,11043],{},"原站方案",[1772,11045,11046],{},"Nuxt 不直接支持",[1751,11048,11049,11052,11055],{},[1772,11050,11051],{},"Server Route",[1772,11053,11054],{},"不复制文件",[1772,11056,11057],{},"需要手动写路由",[16,11059,11060,11061,11063],{},"最终选择 ",[26,11062,11051],{},"，和原站的 Vite 插件思路一致：",[127,11065,11067],{"className":10278,"code":11066,"language":10280,"meta":132,"style":132},"\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",[64,11068,11069,11074,11099,11114,11175,11189,11193,11215,11241,11259,11278,11285],{"__ignoreMap":132},[136,11070,11071],{"class":138,"line":139},[136,11072,11073],{"class":142},"\u002F\u002F server\u002Froutes\u002Fposts\u002F[slug]\u002Fimg\u002F[...file].ts\n",[136,11075,11076,11078,11080,11083,11085,11088,11090,11093,11095,11097],{"class":138,"line":146},[136,11077,150],{"class":149},[136,11079,153],{"class":149},[136,11081,11082],{"class":156}," defineEventHandler",[136,11084,549],{"class":160},[136,11086,11087],{"class":149},"async",[136,11089,532],{"class":160},[136,11091,11092],{"class":535},"event",[136,11094,543],{"class":160},[136,11096,2006],{"class":149},[136,11098,489],{"class":160},[136,11100,11101,11103,11106,11108,11111],{"class":138,"line":164},[136,11102,2746],{"class":149},[136,11104,11105],{"class":209}," url",[136,11107,448],{"class":149},[136,11109,11110],{"class":156}," getRequestURL",[136,11112,11113],{"class":160},"(event)\n",[136,11115,11116,11118,11121,11123,11126,11129,11131,11133,11135,11137,11140,11142,11144,11146,11148,11151,11153,11155,11157,11159,11161,11163,11165,11167,11169,11171,11173],{"class":138,"line":180},[136,11117,2746],{"class":149},[136,11119,11120],{"class":209}," match",[136,11122,448],{"class":149},[136,11124,11125],{"class":160}," url.pathname.",[136,11127,11128],{"class":156},"match",[136,11130,549],{"class":160},[136,11132,6956],{"class":170},[136,11134,6959],{"class":149},[136,11136,10854],{"class":10853},[136,11138,11139],{"class":6962},"posts",[136,11141,10854],{"class":10853},[136,11143,549],{"class":6962},[136,11145,3634],{"class":209},[136,11147,6959],{"class":149},[136,11149,11150],{"class":209},"\u002F]",[136,11152,627],{"class":149},[136,11154,6972],{"class":6962},[136,11156,10854],{"class":10853},[136,11158,2001],{"class":6962},[136,11160,10854],{"class":10853},[136,11162,549],{"class":6962},[136,11164,415],{"class":209},[136,11166,627],{"class":149},[136,11168,6972],{"class":6962},[136,11170,1596],{"class":149},[136,11172,6956],{"class":170},[136,11174,390],{"class":160},[136,11176,11177,11179,11181,11183,11186],{"class":138,"line":186},[136,11178,10410],{"class":149},[136,11180,532],{"class":160},[136,11182,10415],{"class":149},[136,11184,11185],{"class":160},"match) ",[136,11187,11188],{"class":149},"return\n",[136,11190,11191],{"class":138,"line":367},[136,11192,345],{"emptyLinePlaceholder":344},[136,11194,11195,11197,11200,11203,11205,11208,11210,11212],{"class":138,"line":377},[136,11196,2746],{"class":149},[136,11198,11199],{"class":160}," [, ",[136,11201,11202],{"class":209},"slug",[136,11204,568],{"class":160},[136,11206,11207],{"class":209},"filename",[136,11209,10457],{"class":160},[136,11211,600],{"class":149},[136,11213,11214],{"class":160}," match\n",[136,11216,11217,11219,11222,11224,11227,11229,11232,11235,11238],{"class":138,"line":387},[136,11218,2746],{"class":149},[136,11220,11221],{"class":209}," filePath",[136,11223,448],{"class":149},[136,11225,11226],{"class":156}," resolve",[136,11228,549],{"class":160},[136,11230,11231],{"class":170},"'content\u002Fposts'",[136,11233,11234],{"class":160},", slug, ",[136,11236,11237],{"class":170},"'img'",[136,11239,11240],{"class":160},", filename)\n",[136,11242,11243,11245,11248,11250,11253,11256],{"class":138,"line":393},[136,11244,2746],{"class":149},[136,11246,11247],{"class":209}," data",[136,11249,448],{"class":149},[136,11251,11252],{"class":149}," await",[136,11254,11255],{"class":156}," readFile",[136,11257,11258],{"class":160},"(filePath)\n",[136,11260,11261,11264,11267,11270,11272,11275],{"class":138,"line":398},[136,11262,11263],{"class":156},"  setResponseHeader",[136,11265,11266],{"class":160},"(event, ",[136,11268,11269],{"class":170},"'content-type'",[136,11271,568],{"class":160},[136,11273,11274],{"class":209},"MIME",[136,11276,11277],{"class":160},"[ext])\n",[136,11279,11280,11282],{"class":138,"line":404},[136,11281,10611],{"class":149},[136,11283,11284],{"class":160}," data\n",[136,11286,11287],{"class":138,"line":421},[136,11288,189],{"class":160},[16,11290,11291,11292,11294,11295,11298],{},"请求 ",[64,11293,10676],{}," → 读取 ",[64,11296,11297],{},"content\u002Fposts\u002Fsshfs\u002Fimg\u002Fsshfs.avif"," → 返回图片。",[10241,11300,1627],{"id":1627},[1745,11302,11303,11314],{},[1748,11304,11305],{},[1751,11306,11307,11309,11312],{},[1754,11308,4302],{},[1754,11310,11311],{},"根因",[1754,11313,1756],{},[1767,11315,11316,11330,11341],{},[1751,11317,11318,11321,11327],{},[1772,11319,11320],{},"TOC 只有 h2",[1772,11322,11323,11326],{},[64,11324,11325],{},"toc.depth"," 配置不生效",[1772,11328,11329],{},"从 body AST 手动提取",[1751,11331,11332,11335,11338],{},[1772,11333,11334],{},"图片路径错误",[1772,11336,11337],{},"SPA 路由导致相对路径解析基准错误",[1772,11339,11340],{},"ProseImg 组件 + mdc.prose 配置",[1751,11342,11343,11346,11349],{},[1772,11344,11345],{},"content 图片 404",[1772,11347,11348],{},"content 目录不暴露给浏览器",[1772,11350,11351],{},"Server Route 直接读取返回",[16,11353,11354],{},"Nuxt Content v3 的设计理念是\"图片放 public\u002F，用绝对路径\"。如果想像 SvelteKit 项目那样把图片和文章放一起，需要额外做不少工作。",[16,11356,11357],{},"不过一旦这些坑都踩过，整体开发体验还是很流畅的。Nuxt 的生态系统、自动导入、模块系统都比 SvelteKit 成熟很多。",[1666,11359,11360],{},"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":132,"searchDepth":146,"depth":164,"links":11362},[11363,11364,11365,11366,11367,11368,11369,11370],{"id":10255,"depth":146,"text":10255},{"id":10268,"depth":146,"text":10268},{"id":10351,"depth":146,"text":10351},{"id":10634,"depth":146,"text":10255},{"id":10679,"depth":146,"text":10268},{"id":10714,"depth":146,"text":10351},{"id":11000,"depth":146,"text":10255},{"id":11010,"depth":146,"text":11010},"从 SvelteKit 迁移到 Nuxt 3 的过程中，遇到了不少 Nuxt Content v3 的坑。这篇文章记录了目录导航、图片路径、ProseImg 组件等问题的排查和解决过程。","\u002Fposts\u002Fnuxt-content-lessons\u002Fimg\u002Fcover.jpg",{},"\u002Fposts\u002Fnuxt-content-lessons","2026-05-31T14:00:00",{"title":10237,"description":11371},"posts\u002Fnuxt-content-lessons\u002Findex",[10229,10232,11379],"Nuxt Content","vbSt6ND1z7xt7L8FxgZ8Qe3EdxV9InsRerN3E23GC7s",{"id":11382,"title":11383,"author":7,"body":11384,"description":11762,"difficulty":2991,"draft":1692,"extension":1693,"image":11763,"meta":11764,"navigation":344,"path":11765,"pinned":344,"published":11766,"seo":11767,"series":10226,"seriesOrder":139,"stem":11768,"tags":11769,"updated":10233,"__hash__":11771},"posts\u002Fposts\u002Fhello-world\u002Findex.md","Hello World - 欢迎使用 SuBlog",{"type":9,"value":11385,"toc":11753},[11386,11390,11396,11399,11402,11405,11472,11475,11478,11540,11543,11546,11609,11612,11675,11677,11680,11695,11701,11704,11731,11734,11743,11745,11750],[12,11387,11389],{"id":11388},"博客上线啦","博客上线啦！",[16,11391,11392,11393,11395],{},"Hello World！欢迎使用 ",[26,11394,9954],{},"，这是站点的第一篇示例文章。",[16,11397,11398],{},"经过一段时间的折腾，这个基于 Nuxt 3 的个人博客终于上线了。在这里记录一下这个项目的技术选型和功能特性。",[12,11400,11401],{"id":11401},"技术栈",[16,11403,11404],{},"本站使用了以下技术：",[1745,11406,11407,11415],{},[1748,11408,11409],{},[1751,11410,11411,11413],{},[1754,11412,4448],{},[1754,11414,4451],{},[1767,11416,11417,11426,11435,11444,11453,11463],{},[1751,11418,11419,11423],{},[1772,11420,11421],{},[26,11422,4238],{},[1772,11424,11425],{},"Vue 3 全栈框架，提供 SSG、路由、API 等能力",[1751,11427,11428,11432],{},[1772,11429,11430],{},[26,11431,4242],{},[1772,11433,11434],{},"类型安全，提升开发体验",[1751,11436,11437,11441],{},[1772,11438,11439],{},[26,11440,4245],{},[1772,11442,11443],{},"原子化 CSS，快速构建 UI",[1751,11445,11446,11450],{},[1772,11447,11448],{},[26,11449,4251],{},[1772,11451,11452],{},"Markdown 驱动的内容管理",[1751,11454,11455,11460],{},[1772,11456,11457],{},[26,11458,11459],{},"Shiki",[1772,11461,11462],{},"代码语法高亮",[1751,11464,11465,11469],{},[1772,11466,11467],{},[26,11468,4257],{},[1772,11470,11471],{},"基于 GitHub Discussions 的评论系统",[12,11473,11474],{"id":11474},"功能特性",[16,11476,11477],{},"目前已实现的功能：",[31,11479,11480,11486,11492,11498,11504,11510,11516,11522,11528,11534],{},[34,11481,11482,11485],{},[26,11483,11484],{},"博客系统"," - Markdown 文章，支持代码高亮、目录导航、搜索",[34,11487,11488,11491],{},[26,11489,11490],{},"暗色模式"," - 跟随系统或手动切换",[34,11493,11494,11497],{},[26,11495,11496],{},"文章搜索"," - 支持标题、描述、正文、标签多维度搜索",[34,11499,11500,11503],{},[26,11501,11502],{},"标签系统"," - 文章标签分类，高频标签快速筛选",[34,11505,11506,11509],{},[26,11507,11508],{},"友链页面"," - 展示友情链接，支持申请",[34,11511,11512,11515],{},[26,11513,11514],{},"赞助页面"," - 微信收款码展示",[34,11517,11518,11521],{},[26,11519,11520],{},"封面制作"," - 在线生成封面图片工具",[34,11523,11524,11527],{},[26,11525,11526],{},"网易云热评"," - 首页随机展示音乐评论",[34,11529,11530,11533],{},[26,11531,11532],{},"访客统计"," - 不蒜子计数器，带数字滚动动画",[34,11535,11536,11539],{},[26,11537,11538],{},"SEO 优化"," - 自动生成站点地图、Open Graph 标签",[12,11541,11542],{"id":11542},"代码示例",[16,11544,11545],{},"本站的配置非常集中，一个文件搞定全站定制：",[127,11547,11549],{"className":10278,"code":11548,"language":10280,"meta":132,"style":132},"\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",[64,11550,11551,11556,11570,11580,11590,11600,11605],{"__ignoreMap":132},[136,11552,11553],{"class":138,"line":139},[136,11554,11555],{"class":142},"\u002F\u002F config\u002Fsite.ts\n",[136,11557,11558,11560,11563,11566,11568],{"class":138,"line":146},[136,11559,150],{"class":149},[136,11561,11562],{"class":149}," const",[136,11564,11565],{"class":209}," siteConfig",[136,11567,448],{"class":149},[136,11569,489],{"class":160},[136,11571,11572,11575,11578],{"class":138,"line":164},[136,11573,11574],{"class":160},"  siteName: ",[136,11576,11577],{"class":170},"'我的博客'",[136,11579,2073],{"class":160},[136,11581,11582,11585,11588],{"class":138,"line":180},[136,11583,11584],{"class":160},"  url: ",[136,11586,11587],{"class":170},"'https:\u002F\u002Fexample.com\u002F'",[136,11589,2073],{"class":160},[136,11591,11592,11595,11598],{"class":138,"line":186},[136,11593,11594],{"class":160},"  avatar: ",[136,11596,11597],{"class":170},"'https:\u002F\u002Fq2.qlogo.cn\u002Fheadimg_dl?dst_uin=xxx&spec=100'",[136,11599,2073],{"class":160},[136,11601,11602],{"class":138,"line":367},[136,11603,11604],{"class":142},"  \u002F\u002F ... 改这一个文件，全站生效\n",[136,11606,11607],{"class":138,"line":377},[136,11608,315],{"class":160},[16,11610,11611],{},"文章的 frontmatter 也很简洁：",[127,11613,11617],{"className":11614,"code":11615,"language":11616,"meta":132,"style":132},"language-yaml shiki shiki-themes github-light github-dark","---\ntitle: 文章标题\npublished: 2026-05-31T08:00:00\ndescription: 文章简介\ntags: ['Nuxt', 'TypeScript']\n---\n","yaml",[64,11618,11619,11624,11634,11644,11654,11671],{"__ignoreMap":132},[136,11620,11621],{"class":138,"line":139},[136,11622,11623],{"class":156},"---\n",[136,11625,11626,11629,11631],{"class":138,"line":146},[136,11627,11628],{"class":2334},"title",[136,11630,307],{"class":160},[136,11632,11633],{"class":170},"文章标题\n",[136,11635,11636,11639,11641],{"class":138,"line":164},[136,11637,11638],{"class":2334},"published",[136,11640,307],{"class":160},[136,11642,11643],{"class":209},"2026-05-31T08:00:00\n",[136,11645,11646,11649,11651],{"class":138,"line":180},[136,11647,11648],{"class":2334},"description",[136,11650,307],{"class":160},[136,11652,11653],{"class":170},"文章简介\n",[136,11655,11656,11659,11661,11664,11666,11669],{"class":138,"line":186},[136,11657,11658],{"class":2334},"tags",[136,11660,5367],{"class":160},[136,11662,11663],{"class":170},"'Nuxt'",[136,11665,568],{"class":160},[136,11667,11668],{"class":170},"'TypeScript'",[136,11670,3645],{"class":160},[136,11672,11673],{"class":138,"line":367},[136,11674,11623],{"class":156},[12,11676,4593],{"id":4593},[16,11678,11679],{},"本站使用 SSG 静态生成，构建后直接部署到静态托管即可：",[127,11681,11683],{"className":1095,"code":11682,"language":1097,"meta":132,"style":132},"npx nuxi generate\n",[64,11684,11685],{"__ignoreMap":132},[136,11686,11687,11689,11692],{"class":138,"line":139},[136,11688,6725],{"class":156},[136,11690,11691],{"class":170}," nuxi",[136,11693,11694],{"class":170}," generate\n",[16,11696,11697,11698,11700],{},"构建产物在 ",[64,11699,4268],{}," 目录下，可以直接丢到 Nginx、Vercel、Netlify 等平台。",[12,11702,11703],{"id":11703},"后续计划",[31,11705,11707,11713,11719,11725],{"className":11706},[4651],[34,11708,11710,11712],{"className":11709},[4655],[4657,11711],{"disabled":344,"type":4659}," RSS 订阅",[34,11714,11716,11718],{"className":11715},[4655],[4657,11717],{"disabled":344,"type":4659}," 文章目录优化",[34,11720,11722,11724],{"className":11721},[4655],[4657,11723],{"disabled":344,"type":4659}," 更多小工具",[34,11726,11728,11730],{"className":11727},[4655],[4657,11729],{"disabled":344,"type":4659}," 持续输出内容",[12,11732,11733],{"id":11733},"致谢",[16,11735,11736,11737,11742],{},"感谢 ",[20,11738,11741],{"href":11739,"rel":11740},"https:\u002F\u002F2x.nz",[4785],"二叉树树"," 的开源项目，本站的 UI 设计参考了他的项目。",[4731,11744],{},[16,11746,11747],{},[26,11748,11749],{},"感谢阅读！如果觉得不错，欢迎点个 Star 或者留个评论 (☞ﾟヮﾟ)☞",[1666,11751,11752],{},"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":132,"searchDepth":146,"depth":164,"links":11754},[11755,11756,11757,11758,11759,11760,11761],{"id":11388,"depth":146,"text":11389},{"id":11401,"depth":146,"text":11401},{"id":11474,"depth":146,"text":11474},{"id":11542,"depth":146,"text":11542},{"id":4593,"depth":146,"text":4593},{"id":11703,"depth":146,"text":11703},{"id":11733,"depth":146,"text":11733},"博客上线啦！这篇文章介绍本站的技术架构、功能特性和搭建过程中的一些思考。","\u002Fposts\u002Fhello-world\u002Fimg\u002Fcover.jpg",{},"\u002Fposts\u002Fhello-world","2026-05-31T08:00:00",{"title":11383,"description":11762},"posts\u002Fhello-world\u002Findex",[11770,10229],"博客","f9MCboCBfQIUxT8aKv6aKGDFxysiG8ZG0YXr5X5LTFo",{"id":11773,"title":11774,"author":7,"body":11775,"description":11976,"difficulty":7,"draft":1692,"extension":1693,"image":11977,"meta":11978,"navigation":344,"path":11979,"pinned":1692,"published":11980,"seo":11981,"series":7,"seriesOrder":7,"stem":11982,"tags":11983,"updated":7,"__hash__":11984},"posts\u002Fposts\u002Fsveltekit-intro\u002Findex.md","SvelteKit 入门：为什么它比你想象的更简单",{"type":9,"value":11776,"toc":11966},[11777,11781,11784,11790,11793,11797,11804,11851,11855,11861,11867,11871,11874,11877,11944,11946,11957,11963],[12,11778,11780],{"id":11779},"什么是-sveltekit","什么是 SvelteKit？",[16,11782,11783],{},"SvelteKit 是 Svelte 的官方全栈框架，类似于 React 之于 Next.js 的关系。它提供了路由、SSR、SSG 等能力，让开发者可以快速构建现代 Web 应用。",[16,11785,11786],{},[2001,11787],{"alt":11788,"src":11789},"SvelteKit Logo","https:\u002F\u002Fraw.githubusercontent.com\u002Fsveltejs\u002Fbranding\u002Fmaster\u002Fsvelte-logo.svg",[12,11791,11792],{"id":11792},"核心特点",[111,11794,11796],{"id":11795},"_1-编译时框架","1. 编译时框架",[16,11798,11799,11800,11803],{},"Svelte 和 React\u002FVue 最大的区别在于：",[26,11801,11802],{},"它在编译阶段就把组件转成原生 JS","，运行时没有虚拟 DOM。",[127,11805,11807],{"className":10278,"code":11806,"language":10280,"meta":132,"style":132},"\u002F\u002F Svelte 组件（编译后变成直接的 DOM 操作）\nlet count = 0\nfunction increment() {\n  count += 1\n}\n",[64,11808,11809,11814,11827,11836,11847],{"__ignoreMap":132},[136,11810,11811],{"class":138,"line":139},[136,11812,11813],{"class":142},"\u002F\u002F Svelte 组件（编译后变成直接的 DOM 操作）\n",[136,11815,11816,11819,11822,11824],{"class":138,"line":146},[136,11817,11818],{"class":149},"let",[136,11820,11821],{"class":160}," count ",[136,11823,600],{"class":149},[136,11825,11826],{"class":209}," 0\n",[136,11828,11829,11831,11834],{"class":138,"line":164},[136,11830,10368],{"class":149},[136,11832,11833],{"class":156}," increment",[136,11835,8651],{"class":160},[136,11837,11838,11841,11844],{"class":138,"line":180},[136,11839,11840],{"class":160},"  count ",[136,11842,11843],{"class":149},"+=",[136,11845,11846],{"class":209}," 1\n",[136,11848,11849],{"class":138,"line":186},[136,11850,315],{"class":160},[111,11852,11854],{"id":11853},"_2-文件路由","2. 文件路由",[16,11856,11857,11860],{},[64,11858,11859],{},"src\u002Froutes\u002F"," 目录结构即 URL，不需要手动配置路由表：",[127,11862,11865],{"className":11863,"code":11864,"language":3068},[3066],"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",[64,11866,11864],{"__ignoreMap":132},[111,11868,11870],{"id":11869},"_3-全栈能力","3. 全栈能力",[16,11872,11873],{},"SvelteKit 内置了服务端渲染（SSR）、静态站点生成（SSG）、API 路由等功能，开箱即用。",[12,11875,11876],{"id":11876},"和其他框架对比",[1745,11878,11879,11893],{},[1748,11880,11881],{},[1751,11882,11883,11886,11889,11891],{},[1754,11884,11885],{},"特性",[1754,11887,11888],{},"SvelteKit",[1754,11890,4385],{},[1754,11892,10229],{},[1767,11894,11895,11908,11921,11933],{},[1751,11896,11897,11900,11903,11906],{},[1772,11898,11899],{},"学习曲线",[1772,11901,11902],{},"低",[1772,11904,11905],{},"中",[1772,11907,11905],{},[1751,11909,11910,11913,11916,11919],{},[1772,11911,11912],{},"包体积",[1772,11914,11915],{},"小",[1772,11917,11918],{},"大",[1772,11920,11905],{},[1751,11922,11923,11926,11928,11931],{},[1772,11924,11925],{},"运行时开销",[1772,11927,1781],{},[1772,11929,11930],{},"有",[1772,11932,11930],{},[1751,11934,11935,11938,11940,11942],{},[1772,11936,11937],{},"生态",[1772,11939,11915],{},[1772,11941,11918],{},[1772,11943,11918],{},[12,11945,1627],{"id":1627},[16,11947,11948,11949,11952,11953,11956],{},"如果你追求",[26,11950,11951],{},"简洁的代码","和",[26,11954,11955],{},"极致的性能","，SvelteKit 是一个非常值得尝试的选择。它的学习曲线平缓，写起来就像在写原生 HTML\u002FCSS\u002FJS。",[16,11958,11959],{},[2001,11960],{"alt":11961,"src":11962},"Svelte 编译过程","https:\u002F\u002Fsvelte.dev\u002Fsvelte-logo-horizontal.svg",[1666,11964,11965],{},"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":132,"searchDepth":146,"depth":164,"links":11967},[11968,11969,11974,11975],{"id":11779,"depth":146,"text":11780},{"id":11792,"depth":146,"text":11792,"children":11970},[11971,11972,11973],{"id":11795,"depth":164,"text":11796},{"id":11853,"depth":164,"text":11854},{"id":11869,"depth":164,"text":11870},{"id":11876,"depth":146,"text":11876},{"id":1627,"depth":146,"text":1627},"SvelteKit 是一个全栈 Svelte 框架，这篇文章带你了解它的核心概念和优势。","\u002Fposts\u002Fsveltekit-intro\u002Fimg\u002Fcover.jpg",{},"\u002Fposts\u002Fsveltekit-intro","2026-05-30",{"title":11774,"description":11976},"posts\u002Fsveltekit-intro\u002Findex",[11888,4513],"vsEGyunpCWR7DiJAdyV1SPDfa57xXLITBI-lKM8UeGQ",{"id":11986,"title":11987,"author":7,"body":11988,"description":12391,"difficulty":2991,"draft":1692,"extension":1693,"image":12392,"meta":12393,"navigation":344,"path":12394,"pinned":1692,"published":12395,"seo":12396,"series":12397,"seriesOrder":146,"stem":12398,"tags":12399,"updated":12395,"__hash__":12403},"posts\u002Fposts\u002Fdocker-user-start\u002Findex.md","Docker 构建时指定用户启动",{"type":9,"value":11989,"toc":12381},[11990,11993,11996,11998,12002,12005,12025,12029,12062,12066,12072,12120,12123,12341,12343,12346,12375,12378],[12,11991,11992],{"id":11992},"前言",[16,11994,11995],{},"我们在 Linux 使用应用时，往往不会直接使用 root 用户作为应用启动项，但 Dockerfile 默认就是 root 用户，导致挂载生成的文件路径往往是 root 权限，非 root 用户访问就很不方便。",[12,11997,10086],{"id":10086},[111,11999,12001],{"id":12000},"_1-安装用户管理工具","1. 安装用户管理工具",[16,12003,12004],{},"若镜像非自带用户管理：",[127,12006,12010],{"className":12007,"code":12008,"language":12009,"meta":132,"style":132},"language-dockerfile shiki shiki-themes github-light github-dark","# 权限管理\nRUN apk add --no-cache shadow\n","dockerfile",[64,12011,12012,12017],{"__ignoreMap":132},[136,12013,12014],{"class":138,"line":139},[136,12015,12016],{"class":142},"# 权限管理\n",[136,12018,12019,12022],{"class":138,"line":146},[136,12020,12021],{"class":149},"RUN",[136,12023,12024],{"class":160}," apk add --no-cache shadow\n",[111,12026,12028],{"id":12027},"_2-创建用户并修改权限","2. 创建用户并修改权限",[127,12030,12032],{"className":12007,"code":12031,"language":12009,"meta":132,"style":132},"# 创建用户组和用户\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",[64,12033,12034,12039,12046,12050,12055],{"__ignoreMap":132},[136,12035,12036],{"class":138,"line":139},[136,12037,12038],{"class":142},"# 创建用户组和用户\n",[136,12040,12041,12043],{"class":138,"line":146},[136,12042,12021],{"class":149},[136,12044,12045],{"class":160}," addgroup -S \u003Capp_user_group> && adduser -S -G \u003Capp_user_group> \u003Capp_user>\n",[136,12047,12048],{"class":138,"line":164},[136,12049,345],{"emptyLinePlaceholder":344},[136,12051,12052],{"class":138,"line":180},[136,12053,12054],{"class":142},"# 修改目录权限\n",[136,12056,12057,12059],{"class":138,"line":186},[136,12058,12021],{"class":149},[136,12060,12061],{"class":160}," chown -R \u003Capp_user_group>:\u003Capp_user> \u002Fdata\n",[111,12063,12065],{"id":12064},"_3-指定用户启动","3. 指定用户启动",[16,12067,12068,12069,12071],{},"执行 docker 时加 ",[64,12070,5454],{}," 参数：",[127,12073,12075],{"className":1095,"code":12074,"language":1097,"meta":132,"style":132},"docker run -it --user \u003Capp_user> \u003Capp_image>:\u003Cimage_version>\n",[64,12076,12077],{"__ignoreMap":132},[136,12078,12079,12082,12084,12087,12090,12092,12095,12098,12100,12102,12105,12107,12109,12111,12113,12116,12118],{"class":138,"line":139},[136,12080,12081],{"class":156},"docker",[136,12083,1195],{"class":170},[136,12085,12086],{"class":209}," -it",[136,12088,12089],{"class":209}," --user",[136,12091,5042],{"class":149},[136,12093,12094],{"class":170},"app_use",[136,12096,12097],{"class":160},"r",[136,12099,2927],{"class":149},[136,12101,5042],{"class":149},[136,12103,12104],{"class":170},"app_imag",[136,12106,5078],{"class":160},[136,12108,2927],{"class":149},[136,12110,1982],{"class":170},[136,12112,206],{"class":149},[136,12114,12115],{"class":170},"image_versio",[136,12117,5048],{"class":160},[136,12119,212],{"class":149},[12,12121,12122],{"id":12122},"完整示例",[127,12124,12126],{"className":12007,"code":12125,"language":12009,"meta":132,"style":132},"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",[64,12127,12128,12136,12140,12145,12158,12162,12169,12173,12179,12184,12191,12196,12203,12207,12215,12219,12223,12230,12234,12242,12246,12253,12257,12271,12279,12283,12288,12295,12302,12306,12311,12319],{"__ignoreMap":132},[136,12129,12130,12133],{"class":138,"line":139},[136,12131,12132],{"class":149},"FROM",[136,12134,12135],{"class":160}," alpine:latest\n",[136,12137,12138],{"class":138,"line":146},[136,12139,345],{"emptyLinePlaceholder":344},[136,12141,12142],{"class":138,"line":164},[136,12143,12144],{"class":142},"# 国内源\n",[136,12146,12147,12149,12152,12155],{"class":138,"line":180},[136,12148,12021],{"class":149},[136,12150,12151],{"class":160}," sed -i ",[136,12153,12154],{"class":170},"'s\u002Fdl-cdn.alpinelinux.org\u002Fmirrors.ustc.edu.cn\u002Fg'",[136,12156,12157],{"class":160}," \u002Fetc\u002Fapk\u002Frepositories\n",[136,12159,12160],{"class":138,"line":186},[136,12161,345],{"emptyLinePlaceholder":344},[136,12163,12164,12166],{"class":138,"line":367},[136,12165,12021],{"class":149},[136,12167,12168],{"class":160}," apk update --no-cache\n",[136,12170,12171],{"class":138,"line":377},[136,12172,12016],{"class":142},[136,12174,12175,12177],{"class":138,"line":387},[136,12176,12021],{"class":149},[136,12178,12024],{"class":160},[136,12180,12181],{"class":138,"line":393},[136,12182,12183],{"class":142},"# 调试\n",[136,12185,12186,12188],{"class":138,"line":398},[136,12187,12021],{"class":149},[136,12189,12190],{"class":160}," apk add --no-cache bash\n",[136,12192,12193],{"class":138,"line":404},[136,12194,12195],{"class":142},"# 设置时区\n",[136,12197,12198,12200],{"class":138,"line":421},[136,12199,12021],{"class":149},[136,12201,12202],{"class":160}," apk add --no-cache tzdata\n",[136,12204,12205],{"class":138,"line":426},[136,12206,345],{"emptyLinePlaceholder":344},[136,12208,12209,12212],{"class":138,"line":432},[136,12210,12211],{"class":149},"ENV",[136,12213,12214],{"class":160}," TZ=Asia\u002FShanghai\n",[136,12216,12217],{"class":138,"line":472},[136,12218,345],{"emptyLinePlaceholder":344},[136,12220,12221],{"class":138,"line":477},[136,12222,12038],{"class":142},[136,12224,12225,12227],{"class":138,"line":492},[136,12226,12021],{"class":149},[136,12228,12229],{"class":160}," addgroup -S glog && adduser -S -G glog glog\n",[136,12231,12232],{"class":138,"line":501},[136,12233,345],{"emptyLinePlaceholder":344},[136,12235,12236,12239],{"class":138,"line":516},[136,12237,12238],{"class":149},"COPY",[136,12240,12241],{"class":160}," .\u002Fglog_static_musl_1 \u002Fapp\u002F\n",[136,12243,12244],{"class":138,"line":521},[136,12245,345],{"emptyLinePlaceholder":344},[136,12247,12248,12250],{"class":138,"line":526},[136,12249,12021],{"class":149},[136,12251,12252],{"class":160}," mkdir \u002Fdata\n",[136,12254,12255],{"class":138,"line":577},[136,12256,345],{"emptyLinePlaceholder":344},[136,12258,12259,12262,12265,12268],{"class":138,"line":594},[136,12260,12261],{"class":149},"VOLUME",[136,12263,12264],{"class":160}," [ ",[136,12266,12267],{"class":170},"\"\u002Fdata\"",[136,12269,12270],{"class":160}," ]\n",[136,12272,12273,12276],{"class":138,"line":606},[136,12274,12275],{"class":149},"WORKDIR",[136,12277,12278],{"class":160}," \u002Fdata\n",[136,12280,12281],{"class":138,"line":617},[136,12282,345],{"emptyLinePlaceholder":344},[136,12284,12285],{"class":138,"line":649},[136,12286,12287],{"class":142},"# 修改权限\n",[136,12289,12290,12292],{"class":138,"line":655},[136,12291,12021],{"class":149},[136,12293,12294],{"class":160}," chown -R glog:glog \u002Fdata\n",[136,12296,12297,12299],{"class":138,"line":675},[136,12298,12021],{"class":149},[136,12300,12301],{"class":160}," chown -R glog:glog \u002Fapp\n",[136,12303,12304],{"class":138,"line":6492},[136,12305,345],{"emptyLinePlaceholder":344},[136,12307,12308],{"class":138,"line":6498},[136,12309,12310],{"class":142},"# 指定用户启动\n",[136,12312,12313,12316],{"class":138,"line":6518},[136,12314,12315],{"class":149},"USER",[136,12317,12318],{"class":160}," glog\n",[136,12320,12321,12324,12326,12329,12331,12334,12336,12339],{"class":138,"line":6523},[136,12322,12323],{"class":149},"CMD",[136,12325,12264],{"class":160},[136,12327,12328],{"class":170},"\"\u002Fapp\u002Fglog_static_musl_1\"",[136,12330,568],{"class":160},[136,12332,12333],{"class":170},"\"logsweb\"",[136,12335,568],{"class":160},[136,12337,12338],{"class":170},"\".\u002Fconf.ini\"",[136,12340,12270],{"class":160},[12,12342,1627],{"id":1627},[16,12344,12345],{},"关键步骤：",[55,12347,12348,12354,12363,12369],{},[34,12349,12350,12353],{},[64,12351,12352],{},"apk add --no-cache shadow"," - 安装用户管理工具",[34,12355,12356,4239,12359,12362],{},[64,12357,12358],{},"addgroup",[64,12360,12361],{},"adduser"," - 创建用户",[34,12364,12365,12368],{},[64,12366,12367],{},"chown -R"," - 修改目录权限",[34,12370,12371,12374],{},[64,12372,12373],{},"USER \u003Cusername>"," - 指定运行用户",[16,12376,12377],{},"这样容器内的应用就不再以 root 身份运行，更加安全。",[1666,12379,12380],{},"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":132,"searchDepth":146,"depth":164,"links":12382},[12383,12384,12389,12390],{"id":11992,"depth":146,"text":11992},{"id":10086,"depth":146,"text":10086,"children":12385},[12386,12387,12388],{"id":12000,"depth":164,"text":12001},{"id":12027,"depth":164,"text":12028},{"id":12064,"depth":164,"text":12065},{"id":12122,"depth":146,"text":12122},{"id":1627,"depth":146,"text":1627},"解决 Docker 容器默认以 root 用户运行导致的权限问题，指定非 root 用户启动应用。","\u002Fposts\u002Fdocker-user-start\u002Fimg\u002Fcover.svg",{},"\u002Fposts\u002Fdocker-user-start","2025-04-01",{"title":11987,"description":12391},"Docker 实践笔记","posts\u002Fdocker-user-start\u002Findex",[12400,12401,12402],"Docker","运维","Linux","b4Uyw5ms87xr7NhpIkavHnNbT20oaEPtXz8k1w6UPj0",{"id":12405,"title":12406,"author":7,"body":12407,"description":13160,"difficulty":1691,"draft":1692,"extension":1693,"image":13161,"meta":13162,"navigation":344,"path":13163,"pinned":1692,"published":13164,"seo":13165,"series":12397,"seriesOrder":139,"stem":13166,"tags":13167,"updated":13164,"__hash__":13168},"posts\u002Fposts\u002Fdocker-musl-build\u002Findex.md","Docker 编译 musl 可执行文件",{"type":9,"value":12408,"toc":13149},[12409,12412,12415,12418,12628,12631,12672,12674,12692,12695,12833,12836,12967,12970,12973,13031,13034,13146],[12,12410,12411],{"id":12411},"解决问题",[16,12413,12414],{},"用 Go 进行跨平台编译时往往步骤复杂，用 Docker 可以轻松解决。但 Docker 的当前最小镜像 Alpine 其动态链接库采用 musl，与主流的 glibc 不同，往往不能方便执行。",[12,12416,12417],{"id":12417},"查阅资料",[127,12419,12421],{"className":1095,"code":12420,"language":1097,"meta":132,"style":132},"# 启动容器并挂载当前目录到 `\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",[64,12422,12423,12428,12462,12466,12471,12497,12512,12526,12531,12543,12563,12571,12575,12580,12590,12600,12604,12612,12616],{"__ignoreMap":132},[136,12424,12425],{"class":138,"line":139},[136,12426,12427],{"class":142},"# 启动容器并挂载当前目录到 `\u002Fapp`\n",[136,12429,12430,12432,12434,12436,12439,12442,12444,12447,12450,12453,12456,12459],{"class":138,"line":146},[136,12431,12081],{"class":156},[136,12433,1195],{"class":170},[136,12435,12086],{"class":209},[136,12437,12438],{"class":209}," --rm",[136,12440,12441],{"class":209}," -v",[136,12443,1134],{"class":170},[136,12445,12446],{"class":160},"$PWD",[136,12448,12449],{"class":170},"\":\u002Fapp",[136,12451,12452],{"class":209}," -w",[136,12454,12455],{"class":170}," \u002Fapp",[136,12457,12458],{"class":170}," ubuntu:20.04",[136,12460,12461],{"class":170}," bash\n",[136,12463,12464],{"class":138,"line":164},[136,12465,345],{"emptyLinePlaceholder":344},[136,12467,12468],{"class":138,"line":180},[136,12469,12470],{"class":142},"# 在容器内执行以下操作：\n",[136,12472,12473,12476,12479,12481,12483,12485,12488,12491,12494],{"class":138,"line":186},[136,12474,12475],{"class":156},"apt-get",[136,12477,12478],{"class":170}," update",[136,12480,1189],{"class":160},[136,12482,12475],{"class":156},[136,12484,1872],{"class":170},[136,12486,12487],{"class":209}," -y",[136,12489,12490],{"class":170}," musl-tools",[136,12492,12493],{"class":170}," golang-go",[136,12495,12496],{"class":142},"  # 安装 musl 和 Go\n",[136,12498,12499,12501,12504,12506,12509],{"class":138,"line":367},[136,12500,150],{"class":149},[136,12502,12503],{"class":160}," CC",[136,12505,600],{"class":149},[136,12507,12508],{"class":160},"musl-gcc  ",[136,12510,12511],{"class":142},"# 指定 musl 编译器\n",[136,12513,12514,12516,12519,12521,12523],{"class":138,"line":377},[136,12515,150],{"class":149},[136,12517,12518],{"class":160}," CGO_ENABLED",[136,12520,600],{"class":149},[136,12522,2883],{"class":209},[136,12524,12525],{"class":142},"  # 启用 CGO\n",[136,12527,12528],{"class":138,"line":387},[136,12529,12530],{"class":142},"# 设置代理\n",[136,12532,12533,12535,12538,12540],{"class":138,"line":393},[136,12534,324],{"class":156},[136,12536,12537],{"class":170}," env",[136,12539,12452],{"class":209},[136,12541,12542],{"class":170}," GOPROXY=https:\u002F\u002Fgoproxy.cn,direct\n",[136,12544,12545,12547,12549,12552,12555,12557,12560],{"class":138,"line":398},[136,12546,324],{"class":156},[136,12548,1198],{"class":170},[136,12550,12551],{"class":209}," -ldflags",[136,12553,12554],{"class":170}," '-linkmode external -extldflags \"-static\"'",[136,12556,1252],{"class":209},[136,12558,12559],{"class":170}," myapp",[136,12561,12562],{"class":142},"  # 静态编译\n",[136,12564,12565,12568],{"class":138,"line":404},[136,12566,12567],{"class":209},"exit",[136,12569,12570],{"class":142},"  # 退出容器\n",[136,12572,12573],{"class":138,"line":421},[136,12574,345],{"emptyLinePlaceholder":344},[136,12576,12577],{"class":138,"line":426},[136,12578,12579],{"class":142},"# 验证结果（在宿主机执行）\n",[136,12581,12582,12585,12587],{"class":138,"line":432},[136,12583,12584],{"class":156},"file",[136,12586,12559],{"class":170},[136,12588,12589],{"class":142},"  # 应显示 \"statically linked\"\n",[136,12591,12592,12595,12597],{"class":138,"line":472},[136,12593,12594],{"class":156},"ldd",[136,12596,12559],{"class":170},[136,12598,12599],{"class":142},"   # 应显示 \"not a dynamic executable\"\n",[136,12601,12602],{"class":138,"line":477},[136,12603,345],{"emptyLinePlaceholder":344},[136,12605,12606,12609],{"class":138,"line":492},[136,12607,12608],{"class":156},"wget",[136,12610,12611],{"class":170}," https:\u002F\u002Fgo.dev\u002Fdl\u002Fgo1.21.linux-amd64.tar.gz\n",[136,12613,12614],{"class":138,"line":501},[136,12615,345],{"emptyLinePlaceholder":344},[136,12617,12618,12620,12623,12626],{"class":138,"line":516},[136,12619,12211],{"class":156},[136,12621,12622],{"class":170}," PATH=\"\u002Fdata\u002Fgo\u002Fbin:",[136,12624,12625],{"class":160},"$PATH",[136,12627,364],{"class":170},[111,12629,12630],{"id":12630},"遇到问题",[55,12632,12633,12658],{},[34,12634,12635,12638,12639,12642,12645,12646,12649,12651,12652,12654],{},[64,12636,12637],{},"go mod tidy"," 时报错 ",[64,12640,12641],{},"tls: failed to verify certificate: x509: certificate",[12643,12644],"br",{},"解决：",[64,12647,12648],{},"RUN apt-get update && apt-get install -y ca-certificates && rm -rf \u002Fvar\u002Flib\u002Fapt\u002Flists\u002F*",[12643,12650],{},"基础镜像的证书未更新，导致 https 的请求出现类似错误。",[12643,12653],{},[2001,12655],{"alt":12656,"src":12657},"image-20250327100813512","\u002Fposts\u002Fdocker-musl-build\u002Fimg\u002Fimage-20250327100813512.png",[34,12659,12660,12661,12664,12665,12667,12668,12671],{},"直接安装 ",[64,12662,12663],{},"golang-go"," 其版本过低",[12643,12666],{},"解决: ",[64,12669,12670],{},"PATH=\"\u002Fdata\u002Fgo\u002Fbin:$PATH\""," 手动指定环境",[12,12673,10351],{"id":10351},[55,12675,12676,12683,12686],{},[34,12677,12678,12679,12682],{},"下载 ",[64,12680,12681],{},"go1.24.1.linux-amd64.tar.gz"," 解压",[34,12684,12685],{},"编写 Dockerfile",[34,12687,12688,12689],{},"执行 ",[64,12690,12691],{},"docker build -t go-build:v1 .",[16,12693,12694],{},"Dockerfile 内容：",[127,12696,12698],{"className":12007,"code":12697,"language":12009,"meta":132,"style":132},"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",[64,12699,12700,12707,12711,12722,12726,12733,12737,12747,12751,12758,12762,12767,12774,12778,12785,12790,12797,12801,12807,12811,12817,12821],{"__ignoreMap":132},[136,12701,12702,12704],{"class":138,"line":139},[136,12703,12132],{"class":149},[136,12705,12706],{"class":160}," ubuntu:20.04\n",[136,12708,12709],{"class":138,"line":146},[136,12710,345],{"emptyLinePlaceholder":344},[136,12712,12713,12716,12719],{"class":138,"line":164},[136,12714,12715],{"class":149},"LABEL",[136,12717,12718],{"class":160}," org.opencontainers.image.authors=",[136,12720,12721],{"class":170},"\"sutong\"\n",[136,12723,12724],{"class":138,"line":180},[136,12725,345],{"emptyLinePlaceholder":344},[136,12727,12728,12730],{"class":138,"line":186},[136,12729,12238],{"class":149},[136,12731,12732],{"class":160}," go\u002F \u002Fgo\u002F\n",[136,12734,12735],{"class":138,"line":367},[136,12736,345],{"emptyLinePlaceholder":344},[136,12738,12739,12741,12744],{"class":138,"line":377},[136,12740,12211],{"class":149},[136,12742,12743],{"class":160}," PATH=",[136,12745,12746],{"class":170},"\"\u002Fgo\u002Fbin:$PATH\"\n",[136,12748,12749],{"class":138,"line":387},[136,12750,345],{"emptyLinePlaceholder":344},[136,12752,12753,12755],{"class":138,"line":393},[136,12754,12021],{"class":149},[136,12756,12757],{"class":160}," apt-get update && apt-get install -y musl-tools\n",[136,12759,12760],{"class":138,"line":398},[136,12761,345],{"emptyLinePlaceholder":344},[136,12763,12764],{"class":138,"line":404},[136,12765,12766],{"class":142},"## go mod tidy 时报错 tls: failed to verify certificate: x509: certificate\n",[136,12768,12769,12771],{"class":138,"line":421},[136,12770,12021],{"class":149},[136,12772,12773],{"class":160}," apt-get update && apt-get install -y ca-certificates && rm -rf \u002Fvar\u002Flib\u002Fapt\u002Flists\u002F*\n",[136,12775,12776],{"class":138,"line":426},[136,12777,12511],{"class":142},[136,12779,12780,12782],{"class":138,"line":432},[136,12781,12211],{"class":149},[136,12783,12784],{"class":160}," CC=musl-gcc\n",[136,12786,12787],{"class":138,"line":472},[136,12788,12789],{"class":142},"# 启用 CGO\n",[136,12791,12792,12794],{"class":138,"line":477},[136,12793,12211],{"class":149},[136,12795,12796],{"class":160}," CGO_ENABLED=1\n",[136,12798,12799],{"class":138,"line":492},[136,12800,345],{"emptyLinePlaceholder":344},[136,12802,12803,12805],{"class":138,"line":501},[136,12804,12261],{"class":149},[136,12806,12278],{"class":160},[136,12808,12809],{"class":138,"line":516},[136,12810,345],{"emptyLinePlaceholder":344},[136,12812,12813,12815],{"class":138,"line":521},[136,12814,12275],{"class":149},[136,12816,12278],{"class":160},[136,12818,12819],{"class":138,"line":526},[136,12820,345],{"emptyLinePlaceholder":344},[136,12822,12823,12825,12828,12831],{"class":138,"line":577},[136,12824,12323],{"class":149},[136,12826,12827],{"class":160}," [",[136,12829,12830],{"class":170},"\"\u002Fbin\u002Fbash\"",[136,12832,3645],{"class":160},[111,12834,12835],{"id":12835},"构建",[127,12837,12839],{"className":1095,"code":12838,"language":1097,"meta":132,"style":132},"# 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",[64,12840,12841,12846,12850,12855,12880,12884,12889,12921,12926,12930,12962],{"__ignoreMap":132},[136,12842,12843],{"class":138,"line":139},[136,12844,12845],{"class":142},"# docker build -t go-build:v1 .\n",[136,12847,12848],{"class":138,"line":146},[136,12849,345],{"emptyLinePlaceholder":344},[136,12851,12852],{"class":138,"line":164},[136,12853,12854],{"class":142},"# 直接执行编译 编译后删除\n",[136,12856,12857,12859,12861,12863,12865,12868,12871,12873,12875,12877],{"class":138,"line":180},[136,12858,12081],{"class":156},[136,12860,1195],{"class":170},[136,12862,12438],{"class":209},[136,12864,12441],{"class":209},[136,12866,12867],{"class":170}," .:\u002Fdata",[136,12869,12870],{"class":170}," go-build:v1",[136,12872,1247],{"class":170},[136,12874,1198],{"class":170},[136,12876,1252],{"class":209},[136,12878,12879],{"class":170}," app_musl\n",[136,12881,12882],{"class":138,"line":186},[136,12883,345],{"emptyLinePlaceholder":344},[136,12885,12886],{"class":138,"line":367},[136,12887,12888],{"class":142},"# 构建编译环境\n",[136,12890,12891,12893,12895,12897,12900,12902,12904,12906,12908,12911,12914,12916,12918],{"class":138,"line":377},[136,12892,12081],{"class":156},[136,12894,1195],{"class":170},[136,12896,12086],{"class":209},[136,12898,12899],{"class":209}," -d",[136,12901,12441],{"class":209},[136,12903,12867],{"class":170},[136,12905,5274],{"class":209},[136,12907,5042],{"class":149},[136,12909,12910],{"class":170},"容器名",[136,12912,12913],{"class":160},"称",[136,12915,2927],{"class":149},[136,12917,12870],{"class":170},[136,12919,12920],{"class":170}," \u002Fbin\u002Fbash\n",[136,12922,12923],{"class":138,"line":387},[136,12924,12925],{"class":142},"# eg：docker run -it -d -v .:\u002Fdata --name logbus-build go-build:v1 \u002Fbin\u002Fbash\n",[136,12927,12928],{"class":138,"line":393},[136,12929,345],{"emptyLinePlaceholder":344},[136,12931,12932,12934,12937,12939,12941,12943,12945,12947,12949,12951,12953,12955,12958,12960],{"class":138,"line":398},[136,12933,12081],{"class":156},[136,12935,12936],{"class":170}," exec",[136,12938,12086],{"class":209},[136,12940,5042],{"class":149},[136,12942,12910],{"class":170},[136,12944,12913],{"class":160},[136,12946,2927],{"class":149},[136,12948,1247],{"class":170},[136,12950,1198],{"class":170},[136,12952,1252],{"class":209},[136,12954,5042],{"class":149},[136,12956,12957],{"class":170},"myap",[136,12959,16],{"class":160},[136,12961,212],{"class":149},[136,12963,12964],{"class":138,"line":404},[136,12965,12966],{"class":142},"# eg：docker exec -it logbus-build go build -o logbus_musl_1\n",[111,12968,12969],{"id":12969},"额外注意",[16,12971,12972],{},"若没科学上网需要设置代理：",[127,12974,12976],{"className":1095,"code":12975,"language":1097,"meta":132,"style":132},"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",[64,12977,12978,12996,13006,13022],{"__ignoreMap":132},[136,12979,12980,12982,12984,12986,12988,12990,12992,12994],{"class":138,"line":139},[136,12981,12081],{"class":156},[136,12983,12936],{"class":170},[136,12985,12086],{"class":209},[136,12987,5042],{"class":149},[136,12989,12910],{"class":170},[136,12991,12913],{"class":160},[136,12993,2927],{"class":149},[136,12995,12920],{"class":170},[136,12997,12998,13000,13002,13004],{"class":138,"line":146},[136,12999,324],{"class":156},[136,13001,12537],{"class":170},[136,13003,12452],{"class":209},[136,13005,12542],{"class":170},[136,13007,13008,13010,13012,13014,13016,13018,13020],{"class":138,"line":164},[136,13009,324],{"class":156},[136,13011,1198],{"class":170},[136,13013,12551],{"class":209},[136,13015,12554],{"class":170},[136,13017,1252],{"class":209},[136,13019,12559],{"class":170},[136,13021,12562],{"class":142},[136,13023,13024,13026,13028],{"class":138,"line":180},[136,13025,324],{"class":156},[136,13027,1198],{"class":170},[136,13029,13030],{"class":142}," # musl 库编译\n",[111,13032,13033],{"id":13033},"运行环境示例",[127,13035,13037],{"className":12007,"code":13036,"language":12009,"meta":132,"style":132},"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",[64,13038,13039,13045,13049,13057,13061,13068,13072,13079,13086,13090,13097,13101,13108,13112,13132,13136,13141],{"__ignoreMap":132},[136,13040,13041,13043],{"class":138,"line":139},[136,13042,12132],{"class":149},[136,13044,12135],{"class":160},[136,13046,13047],{"class":138,"line":146},[136,13048,345],{"emptyLinePlaceholder":344},[136,13050,13051,13053,13055],{"class":138,"line":164},[136,13052,12715],{"class":149},[136,13054,12718],{"class":160},[136,13056,12721],{"class":170},[136,13058,13059],{"class":138,"line":180},[136,13060,345],{"emptyLinePlaceholder":344},[136,13062,13063,13065],{"class":138,"line":186},[136,13064,12238],{"class":149},[136,13066,13067],{"class":160}," glog_musl \u002Fapp\u002F\n",[136,13069,13070],{"class":138,"line":367},[136,13071,345],{"emptyLinePlaceholder":344},[136,13073,13074,13076],{"class":138,"line":377},[136,13075,12238],{"class":149},[136,13077,13078],{"class":160}," conf.ini \u002Fdata\u002F\n",[136,13080,13081,13083],{"class":138,"line":387},[136,13082,12238],{"class":149},[136,13084,13085],{"class":160}," ipname.ini \u002Fdata\u002F\n",[136,13087,13088],{"class":138,"line":393},[136,13089,345],{"emptyLinePlaceholder":344},[136,13091,13092,13094],{"class":138,"line":398},[136,13093,12275],{"class":149},[136,13095,13096],{"class":160}," \u002Fapp\n",[136,13098,13099],{"class":138,"line":404},[136,13100,345],{"emptyLinePlaceholder":344},[136,13102,13103,13105],{"class":138,"line":421},[136,13104,12021],{"class":149},[136,13106,13107],{"class":160}," chmod 777 \u002Fapp\u002Fglog_musl\n",[136,13109,13110],{"class":138,"line":426},[136,13111,345],{"emptyLinePlaceholder":344},[136,13113,13114,13116,13118,13121,13123,13125,13127,13130],{"class":138,"line":432},[136,13115,12323],{"class":149},[136,13117,12827],{"class":160},[136,13119,13120],{"class":170},"\".\u002Fglog_musl\"",[136,13122,568],{"class":160},[136,13124,12333],{"class":170},[136,13126,568],{"class":160},[136,13128,13129],{"class":170},"\"\u002Fdata\u002Fconf.ini\"",[136,13131,3645],{"class":160},[136,13133,13134],{"class":138,"line":472},[136,13135,345],{"emptyLinePlaceholder":344},[136,13137,13138],{"class":138,"line":477},[136,13139,13140],{"class":142},"# docker build -t glog:v1 .\n",[136,13142,13143],{"class":138,"line":492},[136,13144,13145],{"class":142},"# docker run -p 6801:6801 glog:v1\n",[1666,13147,13148],{},"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":132,"searchDepth":146,"depth":164,"links":13150},[13151,13152,13155],{"id":12411,"depth":146,"text":12411},{"id":12417,"depth":146,"text":12417,"children":13153},[13154],{"id":12630,"depth":164,"text":12630},{"id":10351,"depth":146,"text":10351,"children":13156},[13157,13158,13159],{"id":12835,"depth":164,"text":12835},{"id":12969,"depth":164,"text":12969},{"id":13033,"depth":164,"text":13033},"解决 Go 语言跨平台编译问题，使用 Docker + musl 编译静态链接的可执行文件。","\u002Fposts\u002Fdocker-musl-build\u002Fimg\u002Fcover.svg",{},"\u002Fposts\u002Fdocker-musl-build","2025-03-27",{"title":12406,"description":13160},"posts\u002Fdocker-musl-build\u002Findex",[12400,1702,12401],"YLtdOOc-gMVDSNjE-ntKXz-7hff-VtvMB2dB5RXNi1o",1786887348660]