[{"data":1,"prerenderedAt":14321},["ShallowReactive",2],{"post-jira-cli-intro":3,"series-source-jira-cli-intro":1502},{"id":4,"title":5,"author":6,"body":7,"description":1484,"difficulty":1485,"draft":1486,"extension":1487,"image":1488,"meta":1489,"navigation":104,"path":1490,"pinned":1486,"published":1491,"seo":1492,"series":6,"seriesOrder":6,"stem":1493,"tags":1494,"updated":1491,"__hash__":1501},"posts\u002Fposts\u002Fjira-cli-intro\u002Findex.md","jira_cli — 把企业 Jira 装进命令行，AI Agent 直接调用",null,{"type":8,"value":9,"toc":1463},"minimark",[10,15,35,124,127,130,213,216,251,258,261,266,389,393,529,533,602,605,681,684,687,691,694,714,717,734,737,741,748,886,897,929,933,936,1033,1037,1040,1160,1164,1167,1231,1234,1238,1241,1309,1312,1320,1323,1357,1364,1367,1428,1431,1434,1451,1459],[11,12,14],"h2",{"id":13},"jira_cli-是什么","jira_cli 是什么",[16,17,18,25,26,30,31,34],"p",{},[19,20,24],"a",{"href":21,"rel":22},"https:\u002F\u002Fgithub.com\u002Fyourname\u002Fjira_cli",[23],"nofollow","jira_cli"," 是一个面向企业 Jira（Server \u002F Data Center）的本地命令行工具，Go 语言编写，MIT 协议开源。它同时提供 ",[27,28,29],"strong",{},"Cobra CLI"," 和 ",[27,32,33],{},"MCP stdio server"," 两种入口，既能当日常命令行用，又能直接接入 AI 编程工具。",[36,37,42],"pre",{"className":38,"code":39,"language":40,"meta":41,"style":41},"language-bash shiki shiki-themes github-light github-dark","# 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","bash","",[43,44,45,54,70,83,99,106,112],"code",{"__ignoreMap":41},[46,47,50],"span",{"class":48,"line":49},"line",1,[46,51,53],{"class":52},"sHbNN","# CLI 日常使用\n",[46,55,57,60,64,67],{"class":48,"line":56},2,[46,58,24],{"class":59},"sw2iP",[46,61,63],{"class":62},"scXbn"," issue",[46,65,66],{"class":62}," search",[46,68,69],{"class":62}," \"project = PROJ ORDER BY updated DESC\"\n",[46,71,73,75,77,80],{"class":48,"line":72},3,[46,74,24],{"class":59},[46,76,63],{"class":62},[46,78,79],{"class":62}," get",[46,81,82],{"class":62}," PROJ-123\n",[46,84,86,88,90,93,96],{"class":48,"line":85},4,[46,87,24],{"class":59},[46,89,63],{"class":62},[46,91,92],{"class":62}," comment",[46,94,95],{"class":62}," PROJ-123",[46,97,98],{"class":62}," \"处理完成，请测试验证\"\n",[46,100,102],{"class":48,"line":101},5,[46,103,105],{"emptyLinePlaceholder":104},true,"\n",[46,107,109],{"class":48,"line":108},6,[46,110,111],{"class":52},"# MCP 模式 — 给 AI 装上操作 Jira 的手\n",[46,113,115,117,120],{"class":48,"line":114},7,[46,116,24],{"class":59},[46,118,119],{"class":62}," mcp",[46,121,123],{"class":122},"suQ91"," --user=work\n",[11,125,126],{"id":126},"为什么需要它",[16,128,129],{},"大多数 Jira 命令行工具只解决\"开发者手动操作 Jira\"这一个场景。但 2026 年 AI 编程工具已经普及，你的 AI Agent（Claude Code、Codex、Cursor 等）也需要读写 Jira。痛点就来了：",[131,132,133,146],"table",{},[134,135,136],"thead",{},[137,138,139,143],"tr",{},[140,141,142],"th",{},"痛点",[140,144,145],{},"jira_cli 怎么解",[147,148,149,158,166,178,190,201],"tbody",{},[137,150,151,155],{},[152,153,154],"td",{},"企业 Jira 在内网，没有公网 API",[152,156,157],{},"本地 stdio MCP server，不经过外网",[137,159,160,163],{},[152,161,162],{},"AI 可能误删、误写 Jira 数据",[152,164,165],{},"账号级只读策略，AI 只能查不能改",[137,167,168,171],{},[152,169,170],{},"团队多人多 Jira 实例",[152,172,173,174,177],{},"多 Profile 管理，",[43,175,176],{},"--user=work"," 切换",[137,179,180,183],{},[152,181,182],{},"Windows 终端体验差",[152,184,185,186,189],{},"原生 ",[43,187,188],{},".exe"," 构建 + 一键安装脚本",[137,191,192,195],{},[152,193,194],{},"AI 生成 JQL 困难",[152,196,197,198],{},"字段元数据发现：",[43,199,200],{},"field list --query xxx",[137,202,203,206],{},[152,204,205],{},"MCP 直接拿原始 JSON 太冗长",[152,207,208,209,212],{},"紧凑视图 + ",[43,210,211],{},"--view summary"," 减少 token 消耗",[11,214,215],{"id":215},"安装",[36,217,219],{"className":38,"code":218,"language":40,"meta":41,"style":41},"# 从源码安装（要求 Go 1.26.3+）\ngo install github.com\u002Fyourname\u002Fjira_cli@latest\n\n# 或下载预编译二进制\n# https:\u002F\u002Fgithub.com\u002Fyourname\u002Fjira_cli\u002Freleases\n",[43,220,221,226,237,241,246],{"__ignoreMap":41},[46,222,223],{"class":48,"line":49},[46,224,225],{"class":52},"# 从源码安装（要求 Go 1.26.3+）\n",[46,227,228,231,234],{"class":48,"line":56},[46,229,230],{"class":59},"go",[46,232,233],{"class":62}," install",[46,235,236],{"class":62}," github.com\u002Fyourname\u002Fjira_cli@latest\n",[46,238,239],{"class":48,"line":72},[46,240,105],{"emptyLinePlaceholder":104},[46,242,243],{"class":48,"line":85},[46,244,245],{"class":52},"# 或下载预编译二进制\n",[46,247,248],{"class":48,"line":101},[46,249,250],{"class":52},"# https:\u002F\u002Fgithub.com\u002Fyourname\u002Fjira_cli\u002Freleases\n",[16,252,253,254,257],{},"Windows 用户下载 zip 包解压后双击 ",[43,255,256],{},"install.bat"," 即可全局安装，自动加入 PATH。",[11,259,260],{"id":260},"快速上手",[262,263,265],"h3",{"id":264},"_1-配置认证","1. 配置认证",[36,267,269],{"className":38,"code":268,"language":40,"meta":41,"style":41},"# 设置 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",[43,270,271,276,292,296,301,326,330,335,367,372,378],{"__ignoreMap":41},[46,272,273],{"class":48,"line":49},[46,274,275],{"class":52},"# 设置 Jira 地址\n",[46,277,278,280,283,286,289],{"class":48,"line":56},[46,279,24],{"class":59},[46,281,282],{"class":62}," set",[46,284,285],{"class":62}," base-url",[46,287,288],{"class":62}," https:\u002F\u002Fjira.your-company.com",[46,290,291],{"class":122}," --global\n",[46,293,294],{"class":48,"line":72},[46,295,105],{"emptyLinePlaceholder":104},[46,297,298],{"class":48,"line":85},[46,299,300],{"class":52},"# Token 认证（推荐）\n",[46,302,303,305,307,310,314,317,321,324],{"class":48,"line":101},[46,304,24],{"class":59},[46,306,282],{"class":62},[46,308,309],{"class":62}," token",[46,311,313],{"class":312},"siTax"," \u003C",[46,315,316],{"class":62},"your-toke",[46,318,320],{"class":319},"sIX_F","n",[46,322,323],{"class":312},">",[46,325,291],{"class":122},[46,327,328],{"class":48,"line":108},[46,329,105],{"emptyLinePlaceholder":104},[46,331,332],{"class":48,"line":114},[46,333,334],{"class":52},"# 或用户名密码认证\n",[46,336,338,340,342,345,347,350,353,355,357,360,363,365],{"class":48,"line":337},8,[46,339,24],{"class":59},[46,341,282],{"class":62},[46,343,344],{"class":62}," auth",[46,346,313],{"class":312},[46,348,349],{"class":62},"usernam",[46,351,352],{"class":319},"e",[46,354,323],{"class":312},[46,356,313],{"class":312},[46,358,359],{"class":62},"passwor",[46,361,362],{"class":319},"d",[46,364,323],{"class":312},[46,366,291],{"class":122},[46,368,370],{"class":48,"line":369},9,[46,371,105],{"emptyLinePlaceholder":104},[46,373,375],{"class":48,"line":374},10,[46,376,377],{"class":52},"# 验证一下\n",[46,379,381,383,386],{"class":48,"line":380},11,[46,382,24],{"class":59},[46,384,385],{"class":62}," user",[46,387,388],{"class":62}," me\n",[262,390,392],{"id":391},"_2-日常使用","2. 日常使用",[36,394,396],{"className":38,"code":395,"language":40,"meta":41,"style":41},"# 查自己今天的任务\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",[43,397,398,403,414,418,423,433,437,442,456,473,477,482,496,501,507],{"__ignoreMap":41},[46,399,400],{"class":48,"line":49},[46,401,402],{"class":52},"# 查自己今天的任务\n",[46,404,405,407,409,411],{"class":48,"line":56},[46,406,24],{"class":59},[46,408,63],{"class":62},[46,410,66],{"class":62},[46,412,413],{"class":62}," \"assignee = currentUser() ORDER BY updated DESC\"\n",[46,415,416],{"class":48,"line":72},[46,417,105],{"emptyLinePlaceholder":104},[46,419,420],{"class":48,"line":85},[46,421,422],{"class":52},"# 看某个任务详情\n",[46,424,425,427,429,431],{"class":48,"line":101},[46,426,24],{"class":59},[46,428,63],{"class":62},[46,430,79],{"class":62},[46,432,82],{"class":62},[46,434,435],{"class":48,"line":108},[46,436,105],{"emptyLinePlaceholder":104},[46,438,439],{"class":48,"line":114},[46,440,441],{"class":52},"# 流转状态\n",[46,443,444,446,448,451,453],{"class":48,"line":337},[46,445,24],{"class":59},[46,447,63],{"class":62},[46,449,450],{"class":62}," transitions",[46,452,95],{"class":62},[46,454,455],{"class":52},"                    # 可以转哪些\n",[46,457,458,460,462,465,467,470],{"class":48,"line":369},[46,459,24],{"class":59},[46,461,63],{"class":62},[46,463,464],{"class":62}," do-transition",[46,466,95],{"class":62},[46,468,469],{"class":122}," 21",[46,471,472],{"class":52},"               # 执行流转\n",[46,474,475],{"class":48,"line":374},[46,476,105],{"emptyLinePlaceholder":104},[46,478,479],{"class":48,"line":380},[46,480,481],{"class":52},"# 添加备注\n",[46,483,485,487,489,491,493],{"class":48,"line":484},12,[46,486,24],{"class":59},[46,488,63],{"class":62},[46,490,92],{"class":62},[46,492,95],{"class":62},[46,494,495],{"class":62}," \"已定位到问题，正在修复\"\n",[46,497,499],{"class":48,"line":498},13,[46,500,105],{"emptyLinePlaceholder":104},[46,502,504],{"class":48,"line":503},14,[46,505,506],{"class":52},"# 创建任务\n",[46,508,510,512,514,517,520,523,526],{"class":48,"line":509},15,[46,511,24],{"class":59},[46,513,63],{"class":62},[46,515,516],{"class":62}," create",[46,518,519],{"class":62}," PROJ",[46,521,522],{"class":62}," Task",[46,524,525],{"class":122}," --fields",[46,527,528],{"class":62}," @fields.json\n",[262,530,532],{"id":531},"_3-配给-ai-使用","3. 配给 AI 使用",[36,534,536],{"className":38,"code":535,"language":40,"meta":41,"style":41},"# 先配置一个只读账号给 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",[43,537,538,543,561,584],{"__ignoreMap":41},[46,539,540],{"class":48,"line":49},[46,541,542],{"class":52},"# 先配置一个只读账号给 AI 用\n",[46,544,545,547,549,551,553,556,559],{"class":48,"line":56},[46,546,24],{"class":59},[46,548,282],{"class":62},[46,550,285],{"class":62},[46,552,288],{"class":62},[46,554,555],{"class":122}," --name",[46,557,558],{"class":62}," ai",[46,560,291],{"class":122},[46,562,563,565,567,569,571,574,576,578,580,582],{"class":48,"line":72},[46,564,24],{"class":59},[46,566,282],{"class":62},[46,568,309],{"class":62},[46,570,313],{"class":312},[46,572,573],{"class":62},"toke",[46,575,320],{"class":319},[46,577,323],{"class":312},[46,579,555],{"class":122},[46,581,558],{"class":62},[46,583,291],{"class":122},[46,585,586,588,590,593,596,598,600],{"class":48,"line":85},[46,587,24],{"class":59},[46,589,282],{"class":62},[46,591,592],{"class":62}," readonly",[46,594,595],{"class":62}," on",[46,597,555],{"class":122},[46,599,558],{"class":62},[46,601,291],{"class":122},[16,603,604],{},"然后在 AI 工具的 MCP 配置里加上：",[36,606,610],{"className":607,"code":608,"language":609,"meta":41,"style":41},"language-json shiki shiki-themes github-light github-dark","{\n  \"mcpServers\": {\n    \"jira\": {\n      \"command\": \"jira_cli\",\n      \"args\": [\"mcp\", \"--user=ai\"]\n    }\n  }\n}\n","json",[43,611,612,617,625,632,646,666,671,676],{"__ignoreMap":41},[46,613,614],{"class":48,"line":49},[46,615,616],{"class":319},"{\n",[46,618,619,622],{"class":48,"line":56},[46,620,621],{"class":122},"  \"mcpServers\"",[46,623,624],{"class":319},": {\n",[46,626,627,630],{"class":48,"line":72},[46,628,629],{"class":122},"    \"jira\"",[46,631,624],{"class":319},[46,633,634,637,640,643],{"class":48,"line":85},[46,635,636],{"class":122},"      \"command\"",[46,638,639],{"class":319},": ",[46,641,642],{"class":62},"\"jira_cli\"",[46,644,645],{"class":319},",\n",[46,647,648,651,654,657,660,663],{"class":48,"line":101},[46,649,650],{"class":122},"      \"args\"",[46,652,653],{"class":319},": [",[46,655,656],{"class":62},"\"mcp\"",[46,658,659],{"class":319},", ",[46,661,662],{"class":62},"\"--user=ai\"",[46,664,665],{"class":319},"]\n",[46,667,668],{"class":48,"line":108},[46,669,670],{"class":319},"    }\n",[46,672,673],{"class":48,"line":114},[46,674,675],{"class":319},"  }\n",[46,677,678],{"class":48,"line":337},[46,679,680],{"class":319},"}\n",[16,682,683],{},"这样 AI 就能查 Jira 但不能修改，既有了上下文，又不用担心误操作。",[11,685,686],{"id":686},"亮点",[262,688,690],{"id":689},"_1-账号级只读策略","1. 账号级只读策略",[16,692,693],{},"这是 jira_cli 最实用的设计。给 AI Agent 配置只读账号：",[36,695,697],{"className":38,"code":696,"language":40,"meta":41,"style":41},"jira_cli set readonly on --name ai\n",[43,698,699],{"__ignoreMap":41},[46,700,701,703,705,707,709,711],{"class":48,"line":49},[46,702,24],{"class":59},[46,704,282],{"class":62},[46,706,592],{"class":62},[46,708,595],{"class":62},[46,710,555],{"class":122},[46,712,713],{"class":62}," ai\n",[16,715,716],{},"只读模式下：",[718,719,720,724,727],"ul",{},[721,722,723],"li",{},"CLI 写命令被隐藏和拒绝",[721,725,726],{},"MCP 的写入工具不会注册",[721,728,729,730,733],{},"环境变量 ",[43,731,732],{},"JIRA_READONLY=true"," 也可以临时覆盖",[16,735,736],{},"AI 只能查 JQL、看任务、查字段、读评论，绝对不会误改数据。需要创建\u002F更新时，另配一个有写入权限的账号由开发者自己操作。",[262,738,740],{"id":739},"_2-多-profile-切换","2. 多 Profile 切换",[16,742,743,744,747],{},"一个配置文件里管理多个 Jira 账号，",[43,745,746],{},"--user"," 切换：",[36,749,751],{"className":38,"code":750,"language":40,"meta":41,"style":41},"# 工作账号\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",[43,752,753,758,776,799,803,808,826,849,853,858,872],{"__ignoreMap":41},[46,754,755],{"class":48,"line":49},[46,756,757],{"class":52},"# 工作账号\n",[46,759,760,762,764,766,769,771,774],{"class":48,"line":56},[46,761,24],{"class":59},[46,763,282],{"class":62},[46,765,285],{"class":62},[46,767,768],{"class":62}," https:\u002F\u002Fjira.company.com",[46,770,555],{"class":122},[46,772,773],{"class":62}," work",[46,775,291],{"class":122},[46,777,778,780,782,784,786,789,791,793,795,797],{"class":48,"line":72},[46,779,24],{"class":59},[46,781,282],{"class":62},[46,783,309],{"class":62},[46,785,313],{"class":312},[46,787,788],{"class":62},"work-toke",[46,790,320],{"class":319},[46,792,323],{"class":312},[46,794,555],{"class":122},[46,796,773],{"class":62},[46,798,291],{"class":122},[46,800,801],{"class":48,"line":85},[46,802,105],{"emptyLinePlaceholder":104},[46,804,805],{"class":48,"line":101},[46,806,807],{"class":52},"# 个人账号\n",[46,809,810,812,814,816,819,821,824],{"class":48,"line":108},[46,811,24],{"class":59},[46,813,282],{"class":62},[46,815,285],{"class":62},[46,817,818],{"class":62}," https:\u002F\u002Fjira.personal.com",[46,820,555],{"class":122},[46,822,823],{"class":62}," personal",[46,825,291],{"class":122},[46,827,828,830,832,834,836,839,841,843,845,847],{"class":48,"line":114},[46,829,24],{"class":59},[46,831,282],{"class":62},[46,833,309],{"class":62},[46,835,313],{"class":312},[46,837,838],{"class":62},"personal-toke",[46,840,320],{"class":319},[46,842,323],{"class":312},[46,844,555],{"class":122},[46,846,823],{"class":62},[46,848,291],{"class":122},[46,850,851],{"class":48,"line":337},[46,852,105],{"emptyLinePlaceholder":104},[46,854,855],{"class":48,"line":369},[46,856,857],{"class":52},"# 使用时切换\n",[46,859,860,862,865,867,869],{"class":48,"line":374},[46,861,24],{"class":59},[46,863,864],{"class":122}," --user=work",[46,866,63],{"class":62},[46,868,66],{"class":62},[46,870,871],{"class":62}," \"project = PROJ\"\n",[46,873,874,876,879,881,883],{"class":48,"line":380},[46,875,24],{"class":59},[46,877,878],{"class":122}," --user=personal",[46,880,63],{"class":62},[46,882,66],{"class":62},[46,884,885],{"class":62}," \"project = SIDE\"\n",[16,887,888,889,892,893,896],{},"同时支持",[27,890,891],{},"项目级配置","（",[43,894,895],{},"--local","）和工作目录绑定：",[36,898,900],{"className":38,"code":899,"language":40,"meta":41,"style":41},"# 在项目目录下，配置只对当前项目生效\ncd ~\u002Fprojects\u002Fbackend\njira_cli set base-url https:\u002F\u002Fjira-backend.company.com --local\n",[43,901,902,907,915],{"__ignoreMap":41},[46,903,904],{"class":48,"line":49},[46,905,906],{"class":52},"# 在项目目录下，配置只对当前项目生效\n",[46,908,909,912],{"class":48,"line":56},[46,910,911],{"class":122},"cd",[46,913,914],{"class":62}," ~\u002Fprojects\u002Fbackend\n",[46,916,917,919,921,923,926],{"class":48,"line":72},[46,918,24],{"class":59},[46,920,282],{"class":62},[46,922,285],{"class":62},[46,924,925],{"class":62}," https:\u002F\u002Fjira-backend.company.com",[46,927,928],{"class":122}," --local\n",[262,930,932],{"id":931},"_3-对-ai-友好的输出","3. 对 AI 友好的输出",[16,934,935],{},"jira_cli 在输出设计上充分考虑了 AI Agent 的 token 消耗：",[36,937,939],{"className":38,"code":938,"language":40,"meta":41,"style":41},"# 紧凑视图 — 去掉 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",[43,940,941,946,962,966,971,988,992,997,1012,1016,1021],{"__ignoreMap":41},[46,942,943],{"class":48,"line":49},[46,944,945],{"class":52},"# 紧凑视图 — 去掉 self\u002Favatar\u002F缩略图等无用字段\n",[46,947,948,950,952,954,956,959],{"class":48,"line":56},[46,949,24],{"class":59},[46,951,63],{"class":62},[46,953,79],{"class":62},[46,955,95],{"class":62},[46,957,958],{"class":122}," --view",[46,960,961],{"class":62}," summary\n",[46,963,964],{"class":48,"line":72},[46,965,105],{"emptyLinePlaceholder":104},[46,967,968],{"class":48,"line":85},[46,969,970],{"class":52},"# 表格输出 — 一眼看到关键信息\n",[46,972,973,975,977,979,982,985],{"class":48,"line":101},[46,974,24],{"class":59},[46,976,63],{"class":62},[46,978,66],{"class":62},[46,980,981],{"class":62}," \"project = PROJ\"",[46,983,984],{"class":122}," --output",[46,986,987],{"class":62}," table\n",[46,989,990],{"class":48,"line":108},[46,991,105],{"emptyLinePlaceholder":104},[46,993,994],{"class":48,"line":114},[46,995,996],{"class":52},"# JSON 输出 — 程序解析，不转义 Unicode\n",[46,998,999,1001,1003,1006,1008,1010],{"class":48,"line":337},[46,1000,24],{"class":59},[46,1002,984],{"class":122},[46,1004,1005],{"class":62}," json",[46,1007,63],{"class":62},[46,1009,79],{"class":62},[46,1011,82],{"class":62},[46,1013,1014],{"class":48,"line":369},[46,1015,105],{"emptyLinePlaceholder":104},[46,1017,1018],{"class":48,"line":374},[46,1019,1020],{"class":52},"# 自定义搜索列 — 只要需要的字段\n",[46,1022,1023,1025,1027,1030],{"class":48,"line":380},[46,1024,24],{"class":59},[46,1026,282],{"class":62},[46,1028,1029],{"class":62}," search-columns",[46,1031,1032],{"class":62}," key,summary,fixVersions\n",[262,1034,1036],{"id":1035},"_4-字段元数据发现","4. 字段元数据发现",[16,1038,1039],{},"Jira 的 customfield 让人头疼，jira_cli 提供了完整的字段发现能力：",[36,1041,1043],{"className":38,"code":1042,"language":40,"meta":41,"style":41},"# 列出所有字段及其类型\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",[43,1044,1045,1050,1060,1064,1069,1084,1088,1093,1104,1108,1113,1130,1134,1139],{"__ignoreMap":41},[46,1046,1047],{"class":48,"line":49},[46,1048,1049],{"class":52},"# 列出所有字段及其类型\n",[46,1051,1052,1054,1057],{"class":48,"line":56},[46,1053,24],{"class":59},[46,1055,1056],{"class":62}," field",[46,1058,1059],{"class":62}," list\n",[46,1061,1062],{"class":48,"line":72},[46,1063,105],{"emptyLinePlaceholder":104},[46,1065,1066],{"class":48,"line":85},[46,1067,1068],{"class":52},"# 搜索包含特定关键词的字段\n",[46,1070,1071,1073,1075,1078,1081],{"class":48,"line":101},[46,1072,24],{"class":59},[46,1074,1056],{"class":62},[46,1076,1077],{"class":62}," list",[46,1079,1080],{"class":122}," --query",[46,1082,1083],{"class":62}," \"版本\"\n",[46,1085,1086],{"class":48,"line":108},[46,1087,105],{"emptyLinePlaceholder":104},[46,1089,1090],{"class":48,"line":114},[46,1091,1092],{"class":52},"# 只看自定义字段\n",[46,1094,1095,1097,1099,1101],{"class":48,"line":337},[46,1096,24],{"class":59},[46,1098,1056],{"class":62},[46,1100,1077],{"class":62},[46,1102,1103],{"class":122}," --custom-only\n",[46,1105,1106],{"class":48,"line":369},[46,1107,105],{"emptyLinePlaceholder":104},[46,1109,1110],{"class":48,"line":374},[46,1111,1112],{"class":52},"# 创建任务前，先看看需要哪些字段\n",[46,1114,1115,1117,1119,1122,1124,1127],{"class":48,"line":380},[46,1116,24],{"class":59},[46,1118,63],{"class":62},[46,1120,1121],{"class":62}," create-meta",[46,1123,519],{"class":62},[46,1125,1126],{"class":122}," --issue-type",[46,1128,1129],{"class":62}," Task\n",[46,1131,1132],{"class":48,"line":484},[46,1133,105],{"emptyLinePlaceholder":104},[46,1135,1136],{"class":48,"line":498},[46,1137,1138],{"class":52},"# 分页查看可选值\n",[46,1140,1141,1143,1145,1148,1150,1152,1155,1157],{"class":48,"line":503},[46,1142,24],{"class":59},[46,1144,63],{"class":62},[46,1146,1147],{"class":62}," create-meta-values",[46,1149,519],{"class":62},[46,1151,522],{"class":62},[46,1153,1154],{"class":62}," priority",[46,1156,1080],{"class":122},[46,1158,1159],{"class":62}," High\n",[262,1161,1163],{"id":1162},"_5-mcp-写入控制","5. MCP 写入控制",[16,1165,1166],{},"除了全局只读，MCP 还有更细粒度的控制：",[131,1168,1169,1179],{},[134,1170,1171],{},[137,1172,1173,1176],{},[140,1174,1175],{},"环境变量",[140,1177,1178],{},"作用",[147,1180,1181,1191,1201,1211,1221],{},[137,1182,1183,1188],{},[152,1184,1185],{},[43,1186,1187],{},"JIRA_CREATE_OPEN=false",[152,1189,1190],{},"禁用创建\u002F更新工具",[137,1192,1193,1198],{},[152,1194,1195],{},[43,1196,1197],{},"JIRA_DISABLE_DISCLAIMER=true",[152,1199,1200],{},"去掉 AI 自动添加的备注声明",[137,1202,1203,1208],{},[152,1204,1205],{},[43,1206,1207],{},"JIRA_CREATE_SUMMARY_MAX",[152,1209,1210],{},"限制摘要长度",[137,1212,1213,1218],{},[152,1214,1215],{},[43,1216,1217],{},"JIRA_CREATE_DESCRIPTION_MAX",[152,1219,1220],{},"限制描述长度",[137,1222,1223,1228],{},[152,1224,1225],{},[43,1226,1227],{},"JIRA_CREATE_FIELDS_MAX_BYTES",[152,1229,1230],{},"限制创建字段的 JSON 大小",[16,1232,1233],{},"这些限制确保 AI 即使有写入权限也不会\"失控\"——例如防止 AI 往描述里塞几万字的废话。",[262,1235,1237],{"id":1236},"_6-中英双语帮助","6. 中英双语帮助",[16,1239,1240],{},"帮助信息默认中文，也支持一键切英文：",[36,1242,1244],{"className":38,"code":1243,"language":40,"meta":41,"style":41},"# 保存英文偏好\njira_cli set help-language en\n\n# 切回中文\njira_cli set help-language zh\n\n# 临时覆盖\njira_cli --lang en issue search -h\n",[43,1245,1246,1251,1263,1267,1272,1283,1287,1292],{"__ignoreMap":41},[46,1247,1248],{"class":48,"line":49},[46,1249,1250],{"class":52},"# 保存英文偏好\n",[46,1252,1253,1255,1257,1260],{"class":48,"line":56},[46,1254,24],{"class":59},[46,1256,282],{"class":62},[46,1258,1259],{"class":62}," help-language",[46,1261,1262],{"class":62}," en\n",[46,1264,1265],{"class":48,"line":72},[46,1266,105],{"emptyLinePlaceholder":104},[46,1268,1269],{"class":48,"line":85},[46,1270,1271],{"class":52},"# 切回中文\n",[46,1273,1274,1276,1278,1280],{"class":48,"line":101},[46,1275,24],{"class":59},[46,1277,282],{"class":62},[46,1279,1259],{"class":62},[46,1281,1282],{"class":62}," zh\n",[46,1284,1285],{"class":48,"line":108},[46,1286,105],{"emptyLinePlaceholder":104},[46,1288,1289],{"class":48,"line":114},[46,1290,1291],{"class":52},"# 临时覆盖\n",[46,1293,1294,1296,1299,1302,1304,1306],{"class":48,"line":337},[46,1295,24],{"class":59},[46,1297,1298],{"class":122}," --lang",[46,1300,1301],{"class":62}," en",[46,1303,63],{"class":62},[46,1305,66],{"class":62},[46,1307,1308],{"class":122}," -h\n",[11,1310,1311],{"id":1311},"架构一览",[36,1313,1318],{"className":1314,"code":1316,"language":1317},[1315],"language-text","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","text",[43,1319,1316],{"__ignoreMap":41},[16,1321,1322],{},"设计原则清晰：",[718,1324,1325,1333,1341,1349],{},[721,1326,1327,1332],{},[27,1328,1329],{},[43,1330,1331],{},"pkg\u002Fjira"," 是纯 Jira 客户端，HTTP、认证、上下文处理都在这里",[721,1334,1335,1340],{},[27,1336,1337],{},[43,1338,1339],{},"cmd"," 只管 Cobra 解析和呈现",[721,1342,1343,1348],{},[27,1344,1345],{},[43,1346,1347],{},"internal\u002Ftools\u002Fjira_tools"," 只管 MCP schema 验证和结果适配",[721,1350,1351,1356],{},[27,1352,1353],{},[43,1354,1355],{},"internal\u002Fconfig"," 拥有 Profile 的全部逻辑，不分散到 cmd",[16,1358,1359,1360,1363],{},"每一层职责单一，新增 Jira 能力时按 ",[43,1361,1362],{},"endpoints.go → Client 方法 → CLI 命令 → MCP tool → 策略注册"," 的路径走就行。",[11,1365,1366],{"id":1366},"和已有方案的对比",[131,1368,1369,1382],{},[134,1370,1371],{},[137,1372,1373,1376,1379],{},[140,1374,1375],{},"方案",[140,1377,1378],{},"适合场景",[140,1380,1381],{},"jira_cli 的区别",[147,1383,1384,1395,1406,1417],{},[137,1385,1386,1389,1392],{},[152,1387,1388],{},"Atlassian Rovo MCP Server",[152,1390,1391],{},"Jira Cloud、官方 OAuth",[152,1393,1394],{},"jira_cli 是本地 stdio，适合内网 Server\u002FDC",[137,1396,1397,1400,1403],{},[152,1398,1399],{},"go-jira \u002F jira-cli 社区版",[152,1401,1402],{},"纯命令行管理",[152,1404,1405],{},"jira_cli 同时提供 MCP server，面向 AI 工作流",[137,1407,1408,1411,1414],{},[152,1409,1410],{},"直接调 REST API",[152,1412,1413],{},"自定义集成",[152,1415,1416],{},"jira_cli 封装了认证、只读策略、字段元数据",[137,1418,1419,1422,1425],{},[152,1420,1421],{},"Jira 网页",[152,1423,1424],{},"日常使用",[152,1426,1427],{},"jira_cli 把 Jira 变成终端里的一行命令",[11,1429,1430],{"id":1430},"总结",[16,1432,1433],{},"如果你的团队满足以下任一条件，jira_cli 值得一试：",[718,1435,1436,1439,1442,1445,1448],{},[721,1437,1438],{},"企业 Jira 部署在内网，想接入 AI 编程工具",[721,1440,1441],{},"想给 AI Agent 只读权限来查 Jira 上下文",[721,1443,1444],{},"需要命令行快速查看\u002F操作 Jira，不想切浏览器",[721,1446,1447],{},"需要管理多个 Jira 实例和多套账号",[721,1449,1450],{},"Windows 用户想要无摩擦的 CLI 体验",[16,1452,1453,1454,1458],{},"GitHub：",[19,1455,1457],{"href":21,"rel":1456},[23],"github.com\u002Fyourname\u002Fjira_cli"," | MIT 协议 | Go 1.26.3+",[1460,1461,1462],"style",{},"html pre.shiki code .sHbNN, html code.shiki .sHbNN{--shiki-light:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .sw2iP, html code.shiki .sw2iP{--shiki-light:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .scXbn, html code.shiki .scXbn{--shiki-light:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .suQ91, html code.shiki .suQ91{--shiki-light:#005CC5;--shiki-dark:#79B8FF}html .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":41,"searchDepth":56,"depth":72,"links":1464},[1465,1466,1467,1468,1473,1481,1482,1483],{"id":13,"depth":56,"text":14},{"id":126,"depth":56,"text":126},{"id":215,"depth":56,"text":215},{"id":260,"depth":56,"text":260,"children":1469},[1470,1471,1472],{"id":264,"depth":72,"text":265},{"id":391,"depth":72,"text":392},{"id":531,"depth":72,"text":532},{"id":686,"depth":56,"text":686,"children":1474},[1475,1476,1477,1478,1479,1480],{"id":689,"depth":72,"text":690},{"id":739,"depth":72,"text":740},{"id":931,"depth":72,"text":932},{"id":1035,"depth":72,"text":1036},{"id":1162,"depth":72,"text":1163},{"id":1236,"depth":72,"text":1237},{"id":1311,"depth":56,"text":1311},{"id":1366,"depth":56,"text":1366},{"id":1430,"depth":56,"text":1430},"介绍 jira_cli，一个开源的企业 Jira CLI 命令行工具和MCP，支持 Jira Server \u002F Data Center，自带 MCP stdio server，让 Claude Code、Codex 等 AI 编程工具直接读写 Jira 任务。","入门",false,"md","\u002Fposts\u002Fjira-cli-intro\u002Fimg\u002Fcover.svg",{},"\u002Fposts\u002Fjira-cli-intro","2026-07-17",{"title":5,"description":1484},"posts\u002Fjira-cli-intro\u002Findex",[1495,1496,1497,1498,1499,1500],"Jira","CLI","MCP","Go","AI Agent","效率工具","vNH6sF2mMFmSjbHoEI2OqmYCgpFmaqyKt-MUfcQqXrU",[1503,2307,2723,2944,3356,4542,4821,6882,7657,8634,9786,10346,11525,13061],{"id":1504,"title":1505,"author":6,"body":1506,"description":2294,"difficulty":2295,"draft":1486,"extension":1487,"image":2296,"meta":2297,"navigation":104,"path":2298,"pinned":1486,"published":2299,"seo":2300,"series":2301,"seriesOrder":49,"stem":2302,"tags":2303,"updated":2299,"__hash__":2306},"posts\u002Fposts\u002Fdocker-musl-build\u002Findex.md","Docker 编译 musl 可执行文件",{"type":8,"value":1507,"toc":2283},[1508,1511,1514,1517,1743,1746,1789,1792,1810,1813,1963,1966,2099,2102,2105,2163,2166,2280],[11,1509,1510],{"id":1510},"解决问题",[16,1512,1513],{},"用 Go 进行跨平台编译时往往步骤复杂，用 Docker 可以轻松解决。但 Docker 的当前最小镜像 Alpine 其动态链接库采用 musl，与主流的 glibc 不同，往往不能方便执行。",[11,1515,1516],{"id":1516},"查阅资料",[36,1518,1520],{"className":38,"code":1519,"language":40,"meta":41,"style":41},"# 启动容器并挂载当前目录到 `\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",[43,1521,1522,1527,1565,1569,1574,1601,1618,1633,1638,1650,1672,1680,1684,1689,1699,1709,1714,1723,1728],{"__ignoreMap":41},[46,1523,1524],{"class":48,"line":49},[46,1525,1526],{"class":52},"# 启动容器并挂载当前目录到 `\u002Fapp`\n",[46,1528,1529,1532,1535,1538,1541,1544,1547,1550,1553,1556,1559,1562],{"class":48,"line":56},[46,1530,1531],{"class":59},"docker",[46,1533,1534],{"class":62}," run",[46,1536,1537],{"class":122}," -it",[46,1539,1540],{"class":122}," --rm",[46,1542,1543],{"class":122}," -v",[46,1545,1546],{"class":62}," \"",[46,1548,1549],{"class":319},"$PWD",[46,1551,1552],{"class":62},"\":\u002Fapp",[46,1554,1555],{"class":122}," -w",[46,1557,1558],{"class":62}," \u002Fapp",[46,1560,1561],{"class":62}," ubuntu:20.04",[46,1563,1564],{"class":62}," bash\n",[46,1566,1567],{"class":48,"line":72},[46,1568,105],{"emptyLinePlaceholder":104},[46,1570,1571],{"class":48,"line":85},[46,1572,1573],{"class":52},"# 在容器内执行以下操作：\n",[46,1575,1576,1579,1582,1585,1587,1589,1592,1595,1598],{"class":48,"line":101},[46,1577,1578],{"class":59},"apt-get",[46,1580,1581],{"class":62}," update",[46,1583,1584],{"class":319}," && ",[46,1586,1578],{"class":59},[46,1588,233],{"class":62},[46,1590,1591],{"class":122}," -y",[46,1593,1594],{"class":62}," musl-tools",[46,1596,1597],{"class":62}," golang-go",[46,1599,1600],{"class":52},"  # 安装 musl 和 Go\n",[46,1602,1603,1606,1609,1612,1615],{"class":48,"line":108},[46,1604,1605],{"class":312},"export",[46,1607,1608],{"class":319}," CC",[46,1610,1611],{"class":312},"=",[46,1613,1614],{"class":319},"musl-gcc  ",[46,1616,1617],{"class":52},"# 指定 musl 编译器\n",[46,1619,1620,1622,1625,1627,1630],{"class":48,"line":114},[46,1621,1605],{"class":312},[46,1623,1624],{"class":319}," CGO_ENABLED",[46,1626,1611],{"class":312},[46,1628,1629],{"class":122},"1",[46,1631,1632],{"class":52},"  # 启用 CGO\n",[46,1634,1635],{"class":48,"line":337},[46,1636,1637],{"class":52},"# 设置代理\n",[46,1639,1640,1642,1645,1647],{"class":48,"line":369},[46,1641,230],{"class":59},[46,1643,1644],{"class":62}," env",[46,1646,1555],{"class":122},[46,1648,1649],{"class":62}," GOPROXY=https:\u002F\u002Fgoproxy.cn,direct\n",[46,1651,1652,1654,1657,1660,1663,1666,1669],{"class":48,"line":374},[46,1653,230],{"class":59},[46,1655,1656],{"class":62}," build",[46,1658,1659],{"class":122}," -ldflags",[46,1661,1662],{"class":62}," '-linkmode external -extldflags \"-static\"'",[46,1664,1665],{"class":122}," -o",[46,1667,1668],{"class":62}," myapp",[46,1670,1671],{"class":52},"  # 静态编译\n",[46,1673,1674,1677],{"class":48,"line":380},[46,1675,1676],{"class":122},"exit",[46,1678,1679],{"class":52},"  # 退出容器\n",[46,1681,1682],{"class":48,"line":484},[46,1683,105],{"emptyLinePlaceholder":104},[46,1685,1686],{"class":48,"line":498},[46,1687,1688],{"class":52},"# 验证结果（在宿主机执行）\n",[46,1690,1691,1694,1696],{"class":48,"line":503},[46,1692,1693],{"class":59},"file",[46,1695,1668],{"class":62},[46,1697,1698],{"class":52},"  # 应显示 \"statically linked\"\n",[46,1700,1701,1704,1706],{"class":48,"line":509},[46,1702,1703],{"class":59},"ldd",[46,1705,1668],{"class":62},[46,1707,1708],{"class":52},"   # 应显示 \"not a dynamic executable\"\n",[46,1710,1712],{"class":48,"line":1711},16,[46,1713,105],{"emptyLinePlaceholder":104},[46,1715,1717,1720],{"class":48,"line":1716},17,[46,1718,1719],{"class":59},"wget",[46,1721,1722],{"class":62}," https:\u002F\u002Fgo.dev\u002Fdl\u002Fgo1.21.linux-amd64.tar.gz\n",[46,1724,1726],{"class":48,"line":1725},18,[46,1727,105],{"emptyLinePlaceholder":104},[46,1729,1731,1734,1737,1740],{"class":48,"line":1730},19,[46,1732,1733],{"class":59},"ENV",[46,1735,1736],{"class":62}," PATH=\"\u002Fdata\u002Fgo\u002Fbin:",[46,1738,1739],{"class":319},"$PATH",[46,1741,1742],{"class":62},"\"\n",[262,1744,1745],{"id":1745},"遇到问题",[1747,1748,1749,1775],"ol",{},[721,1750,1751,1754,1755,1758,1761,1762,1765,1767,1768,1770],{},[43,1752,1753],{},"go mod tidy"," 时报错 ",[43,1756,1757],{},"tls: failed to verify certificate: x509: certificate",[1759,1760],"br",{},"解决：",[43,1763,1764],{},"RUN apt-get update && apt-get install -y ca-certificates && rm -rf \u002Fvar\u002Flib\u002Fapt\u002Flists\u002F*",[1759,1766],{},"基础镜像的证书未更新，导致 https 的请求出现类似错误。",[1759,1769],{},[1771,1772],"img",{"alt":1773,"src":1774},"image-20250327100813512","\u002Fposts\u002Fdocker-musl-build\u002Fimg\u002Fimage-20250327100813512.png",[721,1776,1777,1778,1781,1782,1784,1785,1788],{},"直接安装 ",[43,1779,1780],{},"golang-go"," 其版本过低",[1759,1783],{},"解决: ",[43,1786,1787],{},"PATH=\"\u002Fdata\u002Fgo\u002Fbin:$PATH\""," 手动指定环境",[11,1790,1791],{"id":1791},"最终方案",[1747,1793,1794,1801,1804],{},[721,1795,1796,1797,1800],{},"下载 ",[43,1798,1799],{},"go1.24.1.linux-amd64.tar.gz"," 解压",[721,1802,1803],{},"编写 Dockerfile",[721,1805,1806,1807],{},"执行 ",[43,1808,1809],{},"docker build -t go-build:v1 .",[16,1811,1812],{},"Dockerfile 内容：",[36,1814,1818],{"className":1815,"code":1816,"language":1817,"meta":41,"style":41},"language-dockerfile shiki shiki-themes github-light github-dark","FROM ubuntu:20.04\n\nLABEL org.opencontainers.image.authors=\"sutong\"\n\nCOPY go\u002F \u002Fgo\u002F\n\nENV PATH=\"\u002Fgo\u002Fbin:$PATH\"\n\nRUN apt-get update && apt-get install -y musl-tools\n\n## go mod tidy 时报错 tls: failed to verify certificate: x509: certificate\nRUN apt-get update && apt-get install -y ca-certificates && rm -rf \u002Fvar\u002Flib\u002Fapt\u002Flists\u002F*\n# 指定 musl 编译器\nENV CC=musl-gcc\n# 启用 CGO\nENV CGO_ENABLED=1\n\nVOLUME \u002Fdata\n\nWORKDIR \u002Fdata\n\nCMD [\"\u002Fbin\u002Fbash\"]\n","dockerfile",[43,1819,1820,1828,1832,1843,1847,1855,1859,1869,1873,1881,1885,1890,1897,1901,1908,1913,1920,1924,1932,1936,1944,1949],{"__ignoreMap":41},[46,1821,1822,1825],{"class":48,"line":49},[46,1823,1824],{"class":312},"FROM",[46,1826,1827],{"class":319}," ubuntu:20.04\n",[46,1829,1830],{"class":48,"line":56},[46,1831,105],{"emptyLinePlaceholder":104},[46,1833,1834,1837,1840],{"class":48,"line":72},[46,1835,1836],{"class":312},"LABEL",[46,1838,1839],{"class":319}," org.opencontainers.image.authors=",[46,1841,1842],{"class":62},"\"sutong\"\n",[46,1844,1845],{"class":48,"line":85},[46,1846,105],{"emptyLinePlaceholder":104},[46,1848,1849,1852],{"class":48,"line":101},[46,1850,1851],{"class":312},"COPY",[46,1853,1854],{"class":319}," go\u002F \u002Fgo\u002F\n",[46,1856,1857],{"class":48,"line":108},[46,1858,105],{"emptyLinePlaceholder":104},[46,1860,1861,1863,1866],{"class":48,"line":114},[46,1862,1733],{"class":312},[46,1864,1865],{"class":319}," PATH=",[46,1867,1868],{"class":62},"\"\u002Fgo\u002Fbin:$PATH\"\n",[46,1870,1871],{"class":48,"line":337},[46,1872,105],{"emptyLinePlaceholder":104},[46,1874,1875,1878],{"class":48,"line":369},[46,1876,1877],{"class":312},"RUN",[46,1879,1880],{"class":319}," apt-get update && apt-get install -y musl-tools\n",[46,1882,1883],{"class":48,"line":374},[46,1884,105],{"emptyLinePlaceholder":104},[46,1886,1887],{"class":48,"line":380},[46,1888,1889],{"class":52},"## go mod tidy 时报错 tls: failed to verify certificate: x509: certificate\n",[46,1891,1892,1894],{"class":48,"line":484},[46,1893,1877],{"class":312},[46,1895,1896],{"class":319}," apt-get update && apt-get install -y ca-certificates && rm -rf \u002Fvar\u002Flib\u002Fapt\u002Flists\u002F*\n",[46,1898,1899],{"class":48,"line":498},[46,1900,1617],{"class":52},[46,1902,1903,1905],{"class":48,"line":503},[46,1904,1733],{"class":312},[46,1906,1907],{"class":319}," CC=musl-gcc\n",[46,1909,1910],{"class":48,"line":509},[46,1911,1912],{"class":52},"# 启用 CGO\n",[46,1914,1915,1917],{"class":48,"line":1711},[46,1916,1733],{"class":312},[46,1918,1919],{"class":319}," CGO_ENABLED=1\n",[46,1921,1922],{"class":48,"line":1716},[46,1923,105],{"emptyLinePlaceholder":104},[46,1925,1926,1929],{"class":48,"line":1725},[46,1927,1928],{"class":312},"VOLUME",[46,1930,1931],{"class":319}," \u002Fdata\n",[46,1933,1934],{"class":48,"line":1730},[46,1935,105],{"emptyLinePlaceholder":104},[46,1937,1939,1942],{"class":48,"line":1938},20,[46,1940,1941],{"class":312},"WORKDIR",[46,1943,1931],{"class":319},[46,1945,1947],{"class":48,"line":1946},21,[46,1948,105],{"emptyLinePlaceholder":104},[46,1950,1952,1955,1958,1961],{"class":48,"line":1951},22,[46,1953,1954],{"class":312},"CMD",[46,1956,1957],{"class":319}," [",[46,1959,1960],{"class":62},"\"\u002Fbin\u002Fbash\"",[46,1962,665],{"class":319},[262,1964,1965],{"id":1965},"构建",[36,1967,1969],{"className":38,"code":1968,"language":40,"meta":41,"style":41},"# 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",[43,1970,1971,1976,1980,1985,2011,2015,2020,2052,2057,2061,2094],{"__ignoreMap":41},[46,1972,1973],{"class":48,"line":49},[46,1974,1975],{"class":52},"# docker build -t go-build:v1 .\n",[46,1977,1978],{"class":48,"line":56},[46,1979,105],{"emptyLinePlaceholder":104},[46,1981,1982],{"class":48,"line":72},[46,1983,1984],{"class":52},"# 直接执行编译 编译后删除\n",[46,1986,1987,1989,1991,1993,1995,1998,2001,2004,2006,2008],{"class":48,"line":85},[46,1988,1531],{"class":59},[46,1990,1534],{"class":62},[46,1992,1540],{"class":122},[46,1994,1543],{"class":122},[46,1996,1997],{"class":62}," .:\u002Fdata",[46,1999,2000],{"class":62}," go-build:v1",[46,2002,2003],{"class":62}," go",[46,2005,1656],{"class":62},[46,2007,1665],{"class":122},[46,2009,2010],{"class":62}," app_musl\n",[46,2012,2013],{"class":48,"line":101},[46,2014,105],{"emptyLinePlaceholder":104},[46,2016,2017],{"class":48,"line":108},[46,2018,2019],{"class":52},"# 构建编译环境\n",[46,2021,2022,2024,2026,2028,2031,2033,2035,2037,2039,2042,2045,2047,2049],{"class":48,"line":114},[46,2023,1531],{"class":59},[46,2025,1534],{"class":62},[46,2027,1537],{"class":122},[46,2029,2030],{"class":122}," -d",[46,2032,1543],{"class":122},[46,2034,1997],{"class":62},[46,2036,555],{"class":122},[46,2038,313],{"class":312},[46,2040,2041],{"class":62},"容器名",[46,2043,2044],{"class":319},"称",[46,2046,323],{"class":312},[46,2048,2000],{"class":62},[46,2050,2051],{"class":62}," \u002Fbin\u002Fbash\n",[46,2053,2054],{"class":48,"line":337},[46,2055,2056],{"class":52},"# eg：docker run -it -d -v .:\u002Fdata --name logbus-build go-build:v1 \u002Fbin\u002Fbash\n",[46,2058,2059],{"class":48,"line":369},[46,2060,105],{"emptyLinePlaceholder":104},[46,2062,2063,2065,2068,2070,2072,2074,2076,2078,2080,2082,2084,2086,2089,2091],{"class":48,"line":374},[46,2064,1531],{"class":59},[46,2066,2067],{"class":62}," exec",[46,2069,1537],{"class":122},[46,2071,313],{"class":312},[46,2073,2041],{"class":62},[46,2075,2044],{"class":319},[46,2077,323],{"class":312},[46,2079,2003],{"class":62},[46,2081,1656],{"class":62},[46,2083,1665],{"class":122},[46,2085,313],{"class":312},[46,2087,2088],{"class":62},"myap",[46,2090,16],{"class":319},[46,2092,2093],{"class":312},">\n",[46,2095,2096],{"class":48,"line":380},[46,2097,2098],{"class":52},"# eg：docker exec -it logbus-build go build -o logbus_musl_1\n",[262,2100,2101],{"id":2101},"额外注意",[16,2103,2104],{},"若没科学上网需要设置代理：",[36,2106,2108],{"className":38,"code":2107,"language":40,"meta":41,"style":41},"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",[43,2109,2110,2128,2138,2154],{"__ignoreMap":41},[46,2111,2112,2114,2116,2118,2120,2122,2124,2126],{"class":48,"line":49},[46,2113,1531],{"class":59},[46,2115,2067],{"class":62},[46,2117,1537],{"class":122},[46,2119,313],{"class":312},[46,2121,2041],{"class":62},[46,2123,2044],{"class":319},[46,2125,323],{"class":312},[46,2127,2051],{"class":62},[46,2129,2130,2132,2134,2136],{"class":48,"line":56},[46,2131,230],{"class":59},[46,2133,1644],{"class":62},[46,2135,1555],{"class":122},[46,2137,1649],{"class":62},[46,2139,2140,2142,2144,2146,2148,2150,2152],{"class":48,"line":72},[46,2141,230],{"class":59},[46,2143,1656],{"class":62},[46,2145,1659],{"class":122},[46,2147,1662],{"class":62},[46,2149,1665],{"class":122},[46,2151,1668],{"class":62},[46,2153,1671],{"class":52},[46,2155,2156,2158,2160],{"class":48,"line":85},[46,2157,230],{"class":59},[46,2159,1656],{"class":62},[46,2161,2162],{"class":52}," # musl 库编译\n",[262,2164,2165],{"id":2165},"运行环境示例",[36,2167,2169],{"className":1815,"code":2168,"language":1817,"meta":41,"style":41},"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",[43,2170,2171,2178,2182,2190,2194,2201,2205,2212,2219,2223,2230,2234,2241,2245,2266,2270,2275],{"__ignoreMap":41},[46,2172,2173,2175],{"class":48,"line":49},[46,2174,1824],{"class":312},[46,2176,2177],{"class":319}," alpine:latest\n",[46,2179,2180],{"class":48,"line":56},[46,2181,105],{"emptyLinePlaceholder":104},[46,2183,2184,2186,2188],{"class":48,"line":72},[46,2185,1836],{"class":312},[46,2187,1839],{"class":319},[46,2189,1842],{"class":62},[46,2191,2192],{"class":48,"line":85},[46,2193,105],{"emptyLinePlaceholder":104},[46,2195,2196,2198],{"class":48,"line":101},[46,2197,1851],{"class":312},[46,2199,2200],{"class":319}," glog_musl \u002Fapp\u002F\n",[46,2202,2203],{"class":48,"line":108},[46,2204,105],{"emptyLinePlaceholder":104},[46,2206,2207,2209],{"class":48,"line":114},[46,2208,1851],{"class":312},[46,2210,2211],{"class":319}," conf.ini \u002Fdata\u002F\n",[46,2213,2214,2216],{"class":48,"line":337},[46,2215,1851],{"class":312},[46,2217,2218],{"class":319}," ipname.ini \u002Fdata\u002F\n",[46,2220,2221],{"class":48,"line":369},[46,2222,105],{"emptyLinePlaceholder":104},[46,2224,2225,2227],{"class":48,"line":374},[46,2226,1941],{"class":312},[46,2228,2229],{"class":319}," \u002Fapp\n",[46,2231,2232],{"class":48,"line":380},[46,2233,105],{"emptyLinePlaceholder":104},[46,2235,2236,2238],{"class":48,"line":484},[46,2237,1877],{"class":312},[46,2239,2240],{"class":319}," chmod 777 \u002Fapp\u002Fglog_musl\n",[46,2242,2243],{"class":48,"line":498},[46,2244,105],{"emptyLinePlaceholder":104},[46,2246,2247,2249,2251,2254,2256,2259,2261,2264],{"class":48,"line":503},[46,2248,1954],{"class":312},[46,2250,1957],{"class":319},[46,2252,2253],{"class":62},"\".\u002Fglog_musl\"",[46,2255,659],{"class":319},[46,2257,2258],{"class":62},"\"logsweb\"",[46,2260,659],{"class":319},[46,2262,2263],{"class":62},"\"\u002Fdata\u002Fconf.ini\"",[46,2265,665],{"class":319},[46,2267,2268],{"class":48,"line":509},[46,2269,105],{"emptyLinePlaceholder":104},[46,2271,2272],{"class":48,"line":1711},[46,2273,2274],{"class":52},"# docker build -t glog:v1 .\n",[46,2276,2277],{"class":48,"line":1716},[46,2278,2279],{"class":52},"# docker run -p 6801:6801 glog:v1\n",[1460,2281,2282],{},"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":41,"searchDepth":56,"depth":72,"links":2284},[2285,2286,2289],{"id":1510,"depth":56,"text":1510},{"id":1516,"depth":56,"text":1516,"children":2287},[2288],{"id":1745,"depth":72,"text":1745},{"id":1791,"depth":56,"text":1791,"children":2290},[2291,2292,2293],{"id":1965,"depth":72,"text":1965},{"id":2101,"depth":72,"text":2101},{"id":2165,"depth":72,"text":2165},"解决 Go 语言跨平台编译问题，使用 Docker + musl 编译静态链接的可执行文件。","进阶","\u002Fposts\u002Fdocker-musl-build\u002Fimg\u002Fcover.svg",{},"\u002Fposts\u002Fdocker-musl-build","2025-03-27",{"title":1505,"description":2294},"Docker 实践笔记","posts\u002Fdocker-musl-build\u002Findex",[2304,1498,2305],"Docker","运维","YLtdOOc-gMVDSNjE-ntKXz-7hff-VtvMB2dB5RXNi1o",{"id":2308,"title":2309,"author":6,"body":2310,"description":2713,"difficulty":1485,"draft":1486,"extension":1487,"image":2714,"meta":2715,"navigation":104,"path":2716,"pinned":1486,"published":2717,"seo":2718,"series":2301,"seriesOrder":56,"stem":2719,"tags":2720,"updated":2717,"__hash__":2722},"posts\u002Fposts\u002Fdocker-user-start\u002Findex.md","Docker 构建时指定用户启动",{"type":8,"value":2311,"toc":2703},[2312,2315,2318,2321,2325,2328,2345,2349,2382,2386,2392,2440,2443,2662,2664,2667,2697,2700],[11,2313,2314],{"id":2314},"前言",[16,2316,2317],{},"我们在 Linux 使用应用时，往往不会直接使用 root 用户作为应用启动项，但 Dockerfile 默认就是 root 用户，导致挂载生成的文件路径往往是 root 权限，非 root 用户访问就很不方便。",[11,2319,2320],{"id":2320},"解决方案",[262,2322,2324],{"id":2323},"_1-安装用户管理工具","1. 安装用户管理工具",[16,2326,2327],{},"若镜像非自带用户管理：",[36,2329,2331],{"className":1815,"code":2330,"language":1817,"meta":41,"style":41},"# 权限管理\nRUN apk add --no-cache shadow\n",[43,2332,2333,2338],{"__ignoreMap":41},[46,2334,2335],{"class":48,"line":49},[46,2336,2337],{"class":52},"# 权限管理\n",[46,2339,2340,2342],{"class":48,"line":56},[46,2341,1877],{"class":312},[46,2343,2344],{"class":319}," apk add --no-cache shadow\n",[262,2346,2348],{"id":2347},"_2-创建用户并修改权限","2. 创建用户并修改权限",[36,2350,2352],{"className":1815,"code":2351,"language":1817,"meta":41,"style":41},"# 创建用户组和用户\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",[43,2353,2354,2359,2366,2370,2375],{"__ignoreMap":41},[46,2355,2356],{"class":48,"line":49},[46,2357,2358],{"class":52},"# 创建用户组和用户\n",[46,2360,2361,2363],{"class":48,"line":56},[46,2362,1877],{"class":312},[46,2364,2365],{"class":319}," addgroup -S \u003Capp_user_group> && adduser -S -G \u003Capp_user_group> \u003Capp_user>\n",[46,2367,2368],{"class":48,"line":72},[46,2369,105],{"emptyLinePlaceholder":104},[46,2371,2372],{"class":48,"line":85},[46,2373,2374],{"class":52},"# 修改目录权限\n",[46,2376,2377,2379],{"class":48,"line":101},[46,2378,1877],{"class":312},[46,2380,2381],{"class":319}," chown -R \u003Capp_user_group>:\u003Capp_user> \u002Fdata\n",[262,2383,2385],{"id":2384},"_3-指定用户启动","3. 指定用户启动",[16,2387,2388,2389,2391],{},"执行 docker 时加 ",[43,2390,746],{}," 参数：",[36,2393,2395],{"className":38,"code":2394,"language":40,"meta":41,"style":41},"docker run -it --user \u003Capp_user> \u003Capp_image>:\u003Cimage_version>\n",[43,2396,2397],{"__ignoreMap":41},[46,2398,2399,2401,2403,2405,2408,2410,2413,2416,2418,2420,2423,2425,2427,2430,2433,2436,2438],{"class":48,"line":49},[46,2400,1531],{"class":59},[46,2402,1534],{"class":62},[46,2404,1537],{"class":122},[46,2406,2407],{"class":122}," --user",[46,2409,313],{"class":312},[46,2411,2412],{"class":62},"app_use",[46,2414,2415],{"class":319},"r",[46,2417,323],{"class":312},[46,2419,313],{"class":312},[46,2421,2422],{"class":62},"app_imag",[46,2424,352],{"class":319},[46,2426,323],{"class":312},[46,2428,2429],{"class":62},":",[46,2431,2432],{"class":312},"\u003C",[46,2434,2435],{"class":62},"image_versio",[46,2437,320],{"class":319},[46,2439,2093],{"class":312},[11,2441,2442],{"id":2442},"完整示例",[36,2444,2446],{"className":1815,"code":2445,"language":1817,"meta":41,"style":41},"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",[43,2447,2448,2454,2458,2463,2476,2480,2487,2491,2497,2502,2509,2514,2521,2525,2532,2536,2540,2547,2551,2558,2562,2569,2573,2587,2594,2599,2605,2613,2621,2626,2632,2641],{"__ignoreMap":41},[46,2449,2450,2452],{"class":48,"line":49},[46,2451,1824],{"class":312},[46,2453,2177],{"class":319},[46,2455,2456],{"class":48,"line":56},[46,2457,105],{"emptyLinePlaceholder":104},[46,2459,2460],{"class":48,"line":72},[46,2461,2462],{"class":52},"# 国内源\n",[46,2464,2465,2467,2470,2473],{"class":48,"line":85},[46,2466,1877],{"class":312},[46,2468,2469],{"class":319}," sed -i ",[46,2471,2472],{"class":62},"'s\u002Fdl-cdn.alpinelinux.org\u002Fmirrors.ustc.edu.cn\u002Fg'",[46,2474,2475],{"class":319}," \u002Fetc\u002Fapk\u002Frepositories\n",[46,2477,2478],{"class":48,"line":101},[46,2479,105],{"emptyLinePlaceholder":104},[46,2481,2482,2484],{"class":48,"line":108},[46,2483,1877],{"class":312},[46,2485,2486],{"class":319}," apk update --no-cache\n",[46,2488,2489],{"class":48,"line":114},[46,2490,2337],{"class":52},[46,2492,2493,2495],{"class":48,"line":337},[46,2494,1877],{"class":312},[46,2496,2344],{"class":319},[46,2498,2499],{"class":48,"line":369},[46,2500,2501],{"class":52},"# 调试\n",[46,2503,2504,2506],{"class":48,"line":374},[46,2505,1877],{"class":312},[46,2507,2508],{"class":319}," apk add --no-cache bash\n",[46,2510,2511],{"class":48,"line":380},[46,2512,2513],{"class":52},"# 设置时区\n",[46,2515,2516,2518],{"class":48,"line":484},[46,2517,1877],{"class":312},[46,2519,2520],{"class":319}," apk add --no-cache tzdata\n",[46,2522,2523],{"class":48,"line":498},[46,2524,105],{"emptyLinePlaceholder":104},[46,2526,2527,2529],{"class":48,"line":503},[46,2528,1733],{"class":312},[46,2530,2531],{"class":319}," TZ=Asia\u002FShanghai\n",[46,2533,2534],{"class":48,"line":509},[46,2535,105],{"emptyLinePlaceholder":104},[46,2537,2538],{"class":48,"line":1711},[46,2539,2358],{"class":52},[46,2541,2542,2544],{"class":48,"line":1716},[46,2543,1877],{"class":312},[46,2545,2546],{"class":319}," addgroup -S glog && adduser -S -G glog glog\n",[46,2548,2549],{"class":48,"line":1725},[46,2550,105],{"emptyLinePlaceholder":104},[46,2552,2553,2555],{"class":48,"line":1730},[46,2554,1851],{"class":312},[46,2556,2557],{"class":319}," .\u002Fglog_static_musl_1 \u002Fapp\u002F\n",[46,2559,2560],{"class":48,"line":1938},[46,2561,105],{"emptyLinePlaceholder":104},[46,2563,2564,2566],{"class":48,"line":1946},[46,2565,1877],{"class":312},[46,2567,2568],{"class":319}," mkdir \u002Fdata\n",[46,2570,2571],{"class":48,"line":1951},[46,2572,105],{"emptyLinePlaceholder":104},[46,2574,2576,2578,2581,2584],{"class":48,"line":2575},23,[46,2577,1928],{"class":312},[46,2579,2580],{"class":319}," [ ",[46,2582,2583],{"class":62},"\"\u002Fdata\"",[46,2585,2586],{"class":319}," ]\n",[46,2588,2590,2592],{"class":48,"line":2589},24,[46,2591,1941],{"class":312},[46,2593,1931],{"class":319},[46,2595,2597],{"class":48,"line":2596},25,[46,2598,105],{"emptyLinePlaceholder":104},[46,2600,2602],{"class":48,"line":2601},26,[46,2603,2604],{"class":52},"# 修改权限\n",[46,2606,2608,2610],{"class":48,"line":2607},27,[46,2609,1877],{"class":312},[46,2611,2612],{"class":319}," chown -R glog:glog \u002Fdata\n",[46,2614,2616,2618],{"class":48,"line":2615},28,[46,2617,1877],{"class":312},[46,2619,2620],{"class":319}," chown -R glog:glog \u002Fapp\n",[46,2622,2624],{"class":48,"line":2623},29,[46,2625,105],{"emptyLinePlaceholder":104},[46,2627,2629],{"class":48,"line":2628},30,[46,2630,2631],{"class":52},"# 指定用户启动\n",[46,2633,2635,2638],{"class":48,"line":2634},31,[46,2636,2637],{"class":312},"USER",[46,2639,2640],{"class":319}," glog\n",[46,2642,2644,2646,2648,2651,2653,2655,2657,2660],{"class":48,"line":2643},32,[46,2645,1954],{"class":312},[46,2647,2580],{"class":319},[46,2649,2650],{"class":62},"\"\u002Fapp\u002Fglog_static_musl_1\"",[46,2652,659],{"class":319},[46,2654,2258],{"class":62},[46,2656,659],{"class":319},[46,2658,2659],{"class":62},"\".\u002Fconf.ini\"",[46,2661,2586],{"class":319},[11,2663,1430],{"id":1430},[16,2665,2666],{},"关键步骤：",[1747,2668,2669,2675,2685,2691],{},[721,2670,2671,2674],{},[43,2672,2673],{},"apk add --no-cache shadow"," - 安装用户管理工具",[721,2676,2677,2680,2681,2684],{},[43,2678,2679],{},"addgroup"," + ",[43,2682,2683],{},"adduser"," - 创建用户",[721,2686,2687,2690],{},[43,2688,2689],{},"chown -R"," - 修改目录权限",[721,2692,2693,2696],{},[43,2694,2695],{},"USER \u003Cusername>"," - 指定运行用户",[16,2698,2699],{},"这样容器内的应用就不再以 root 身份运行，更加安全。",[1460,2701,2702],{},"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":41,"searchDepth":56,"depth":72,"links":2704},[2705,2706,2711,2712],{"id":2314,"depth":56,"text":2314},{"id":2320,"depth":56,"text":2320,"children":2707},[2708,2709,2710],{"id":2323,"depth":72,"text":2324},{"id":2347,"depth":72,"text":2348},{"id":2384,"depth":72,"text":2385},{"id":2442,"depth":56,"text":2442},{"id":1430,"depth":56,"text":1430},"解决 Docker 容器默认以 root 用户运行导致的权限问题，指定非 root 用户启动应用。","\u002Fposts\u002Fdocker-user-start\u002Fimg\u002Fcover.svg",{},"\u002Fposts\u002Fdocker-user-start","2025-04-01",{"title":2309,"description":2713},"posts\u002Fdocker-user-start\u002Findex",[2304,2305,2721],"Linux","b4Uyw5ms87xr7NhpIkavHnNbT20oaEPtXz8k1w6UPj0",{"id":2724,"title":2725,"author":6,"body":2726,"description":2934,"difficulty":6,"draft":1486,"extension":1487,"image":2935,"meta":2936,"navigation":104,"path":2937,"pinned":1486,"published":2938,"seo":2939,"series":6,"seriesOrder":6,"stem":2940,"tags":2941,"updated":6,"__hash__":2943},"posts\u002Fposts\u002Fsveltekit-intro\u002Findex.md","SvelteKit 入门：为什么它比你想象的更简单",{"type":8,"value":2727,"toc":2924},[2728,2732,2735,2741,2744,2748,2755,2806,2810,2816,2822,2826,2829,2832,2902,2904,2915,2921],[11,2729,2731],{"id":2730},"什么是-sveltekit","什么是 SvelteKit？",[16,2733,2734],{},"SvelteKit 是 Svelte 的官方全栈框架，类似于 React 之于 Next.js 的关系。它提供了路由、SSR、SSG 等能力，让开发者可以快速构建现代 Web 应用。",[16,2736,2737],{},[1771,2738],{"alt":2739,"src":2740},"SvelteKit Logo","https:\u002F\u002Fraw.githubusercontent.com\u002Fsveltejs\u002Fbranding\u002Fmaster\u002Fsvelte-logo.svg",[11,2742,2743],{"id":2743},"核心特点",[262,2745,2747],{"id":2746},"_1-编译时框架","1. 编译时框架",[16,2749,2750,2751,2754],{},"Svelte 和 React\u002FVue 最大的区别在于：",[27,2752,2753],{},"它在编译阶段就把组件转成原生 JS","，运行时没有虚拟 DOM。",[36,2756,2760],{"className":2757,"code":2758,"language":2759,"meta":41,"style":41},"language-typescript shiki shiki-themes github-light github-dark","\u002F\u002F Svelte 组件（编译后变成直接的 DOM 操作）\nlet count = 0\nfunction increment() {\n  count += 1\n}\n","typescript",[43,2761,2762,2767,2780,2791,2802],{"__ignoreMap":41},[46,2763,2764],{"class":48,"line":49},[46,2765,2766],{"class":52},"\u002F\u002F Svelte 组件（编译后变成直接的 DOM 操作）\n",[46,2768,2769,2772,2775,2777],{"class":48,"line":56},[46,2770,2771],{"class":312},"let",[46,2773,2774],{"class":319}," count ",[46,2776,1611],{"class":312},[46,2778,2779],{"class":122}," 0\n",[46,2781,2782,2785,2788],{"class":48,"line":72},[46,2783,2784],{"class":312},"function",[46,2786,2787],{"class":59}," increment",[46,2789,2790],{"class":319},"() {\n",[46,2792,2793,2796,2799],{"class":48,"line":85},[46,2794,2795],{"class":319},"  count ",[46,2797,2798],{"class":312},"+=",[46,2800,2801],{"class":122}," 1\n",[46,2803,2804],{"class":48,"line":101},[46,2805,680],{"class":319},[262,2807,2809],{"id":2808},"_2-文件路由","2. 文件路由",[16,2811,2812,2815],{},[43,2813,2814],{},"src\u002Froutes\u002F"," 目录结构即 URL，不需要手动配置路由表：",[36,2817,2820],{"className":2818,"code":2819,"language":1317},[1315],"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",[43,2821,2819],{"__ignoreMap":41},[262,2823,2825],{"id":2824},"_3-全栈能力","3. 全栈能力",[16,2827,2828],{},"SvelteKit 内置了服务端渲染（SSR）、静态站点生成（SSG）、API 路由等功能，开箱即用。",[11,2830,2831],{"id":2831},"和其他框架对比",[131,2833,2834,2850],{},[134,2835,2836],{},[137,2837,2838,2841,2844,2847],{},[140,2839,2840],{},"特性",[140,2842,2843],{},"SvelteKit",[140,2845,2846],{},"Next.js",[140,2848,2849],{},"Nuxt",[147,2851,2852,2865,2878,2891],{},[137,2853,2854,2857,2860,2863],{},[152,2855,2856],{},"学习曲线",[152,2858,2859],{},"低",[152,2861,2862],{},"中",[152,2864,2862],{},[137,2866,2867,2870,2873,2876],{},[152,2868,2869],{},"包体积",[152,2871,2872],{},"小",[152,2874,2875],{},"大",[152,2877,2862],{},[137,2879,2880,2883,2886,2889],{},[152,2881,2882],{},"运行时开销",[152,2884,2885],{},"无",[152,2887,2888],{},"有",[152,2890,2888],{},[137,2892,2893,2896,2898,2900],{},[152,2894,2895],{},"生态",[152,2897,2872],{},[152,2899,2875],{},[152,2901,2875],{},[11,2903,1430],{"id":1430},[16,2905,2906,2907,2910,2911,2914],{},"如果你追求",[27,2908,2909],{},"简洁的代码","和",[27,2912,2913],{},"极致的性能","，SvelteKit 是一个非常值得尝试的选择。它的学习曲线平缓，写起来就像在写原生 HTML\u002FCSS\u002FJS。",[16,2916,2917],{},[1771,2918],{"alt":2919,"src":2920},"Svelte 编译过程","https:\u002F\u002Fsvelte.dev\u002Fsvelte-logo-horizontal.svg",[1460,2922,2923],{},"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":41,"searchDepth":56,"depth":72,"links":2925},[2926,2927,2932,2933],{"id":2730,"depth":56,"text":2731},{"id":2743,"depth":56,"text":2743,"children":2928},[2929,2930,2931],{"id":2746,"depth":72,"text":2747},{"id":2808,"depth":72,"text":2809},{"id":2824,"depth":72,"text":2825},{"id":2831,"depth":56,"text":2831},{"id":1430,"depth":56,"text":1430},"SvelteKit 是一个全栈 Svelte 框架，这篇文章带你了解它的核心概念和优势。","\u002Fposts\u002Fsveltekit-intro\u002Fimg\u002Fcover.jpg",{},"\u002Fposts\u002Fsveltekit-intro","2026-05-30",{"title":2725,"description":2934},"posts\u002Fsveltekit-intro\u002Findex",[2843,2942],"前端框架","vsEGyunpCWR7DiJAdyV1SPDfa57xXLITBI-lKM8UeGQ",{"id":2945,"title":2946,"author":6,"body":2947,"description":3344,"difficulty":1485,"draft":1486,"extension":1487,"image":3345,"meta":3346,"navigation":104,"path":3347,"pinned":104,"published":3348,"seo":3349,"series":3350,"seriesOrder":49,"stem":3351,"tags":3352,"updated":3354,"__hash__":3355},"posts\u002Fposts\u002Fhello-world\u002Findex.md","Hello World - 欢迎使用 SuBlog",{"type":8,"value":2948,"toc":3335},[2949,2953,2960,2963,2966,2969,3043,3046,3049,3111,3114,3117,3182,3185,3249,3252,3255,3271,3278,3281,3312,3315,3324,3327,3332],[11,2950,2952],{"id":2951},"博客上线啦","博客上线啦！",[16,2954,2955,2956,2959],{},"Hello World！欢迎使用 ",[27,2957,2958],{},"SuBlog","，这是站点的第一篇示例文章。",[16,2961,2962],{},"经过一段时间的折腾，这个基于 Nuxt 3 的个人博客终于上线了。在这里记录一下这个项目的技术选型和功能特性。",[11,2964,2965],{"id":2965},"技术栈",[16,2967,2968],{},"本站使用了以下技术：",[131,2970,2971,2981],{},[134,2972,2973],{},[137,2974,2975,2978],{},[140,2976,2977],{},"技术",[140,2979,2980],{},"用途",[147,2982,2983,2993,3003,3013,3023,3033],{},[137,2984,2985,2990],{},[152,2986,2987],{},[27,2988,2989],{},"Nuxt 3",[152,2991,2992],{},"Vue 3 全栈框架，提供 SSG、路由、API 等能力",[137,2994,2995,3000],{},[152,2996,2997],{},[27,2998,2999],{},"TypeScript",[152,3001,3002],{},"类型安全，提升开发体验",[137,3004,3005,3010],{},[152,3006,3007],{},[27,3008,3009],{},"Tailwind CSS v4",[152,3011,3012],{},"原子化 CSS，快速构建 UI",[137,3014,3015,3020],{},[152,3016,3017],{},[27,3018,3019],{},"@nuxt\u002Fcontent",[152,3021,3022],{},"Markdown 驱动的内容管理",[137,3024,3025,3030],{},[152,3026,3027],{},[27,3028,3029],{},"Shiki",[152,3031,3032],{},"代码语法高亮",[137,3034,3035,3040],{},[152,3036,3037],{},[27,3038,3039],{},"Giscus",[152,3041,3042],{},"基于 GitHub Discussions 的评论系统",[11,3044,3045],{"id":3045},"功能特性",[16,3047,3048],{},"目前已实现的功能：",[718,3050,3051,3057,3063,3069,3075,3081,3087,3093,3099,3105],{},[721,3052,3053,3056],{},[27,3054,3055],{},"博客系统"," - Markdown 文章，支持代码高亮、目录导航、搜索",[721,3058,3059,3062],{},[27,3060,3061],{},"暗色模式"," - 跟随系统或手动切换",[721,3064,3065,3068],{},[27,3066,3067],{},"文章搜索"," - 支持标题、描述、正文、标签多维度搜索",[721,3070,3071,3074],{},[27,3072,3073],{},"标签系统"," - 文章标签分类，高频标签快速筛选",[721,3076,3077,3080],{},[27,3078,3079],{},"友链页面"," - 展示友情链接，支持申请",[721,3082,3083,3086],{},[27,3084,3085],{},"赞助页面"," - 微信收款码展示",[721,3088,3089,3092],{},[27,3090,3091],{},"封面制作"," - 在线生成封面图片工具",[721,3094,3095,3098],{},[27,3096,3097],{},"网易云热评"," - 首页随机展示音乐评论",[721,3100,3101,3104],{},[27,3102,3103],{},"访客统计"," - 不蒜子计数器，带数字滚动动画",[721,3106,3107,3110],{},[27,3108,3109],{},"SEO 优化"," - 自动生成站点地图、Open Graph 标签",[11,3112,3113],{"id":3113},"代码示例",[16,3115,3116],{},"本站的配置非常集中，一个文件搞定全站定制：",[36,3118,3120],{"className":2757,"code":3119,"language":2759,"meta":41,"style":41},"\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",[43,3121,3122,3127,3143,3153,3163,3173,3178],{"__ignoreMap":41},[46,3123,3124],{"class":48,"line":49},[46,3125,3126],{"class":52},"\u002F\u002F config\u002Fsite.ts\n",[46,3128,3129,3131,3134,3137,3140],{"class":48,"line":56},[46,3130,1605],{"class":312},[46,3132,3133],{"class":312}," const",[46,3135,3136],{"class":122}," siteConfig",[46,3138,3139],{"class":312}," =",[46,3141,3142],{"class":319}," {\n",[46,3144,3145,3148,3151],{"class":48,"line":72},[46,3146,3147],{"class":319},"  siteName: ",[46,3149,3150],{"class":62},"'我的博客'",[46,3152,645],{"class":319},[46,3154,3155,3158,3161],{"class":48,"line":85},[46,3156,3157],{"class":319},"  url: ",[46,3159,3160],{"class":62},"'https:\u002F\u002Fexample.com\u002F'",[46,3162,645],{"class":319},[46,3164,3165,3168,3171],{"class":48,"line":101},[46,3166,3167],{"class":319},"  avatar: ",[46,3169,3170],{"class":62},"'https:\u002F\u002Fq2.qlogo.cn\u002Fheadimg_dl?dst_uin=xxx&spec=100'",[46,3172,645],{"class":319},[46,3174,3175],{"class":48,"line":108},[46,3176,3177],{"class":52},"  \u002F\u002F ... 改这一个文件，全站生效\n",[46,3179,3180],{"class":48,"line":114},[46,3181,680],{"class":319},[16,3183,3184],{},"文章的 frontmatter 也很简洁：",[36,3186,3190],{"className":3187,"code":3188,"language":3189,"meta":41,"style":41},"language-yaml shiki shiki-themes github-light github-dark","---\ntitle: 文章标题\npublished: 2026-05-31T08:00:00\ndescription: 文章简介\ntags: ['Nuxt', 'TypeScript']\n---\n","yaml",[43,3191,3192,3197,3208,3218,3228,3245],{"__ignoreMap":41},[46,3193,3194],{"class":48,"line":49},[46,3195,3196],{"class":59},"---\n",[46,3198,3199,3203,3205],{"class":48,"line":56},[46,3200,3202],{"class":3201},"sbB4o","title",[46,3204,639],{"class":319},[46,3206,3207],{"class":62},"文章标题\n",[46,3209,3210,3213,3215],{"class":48,"line":72},[46,3211,3212],{"class":3201},"published",[46,3214,639],{"class":319},[46,3216,3217],{"class":122},"2026-05-31T08:00:00\n",[46,3219,3220,3223,3225],{"class":48,"line":85},[46,3221,3222],{"class":3201},"description",[46,3224,639],{"class":319},[46,3226,3227],{"class":62},"文章简介\n",[46,3229,3230,3233,3235,3238,3240,3243],{"class":48,"line":101},[46,3231,3232],{"class":3201},"tags",[46,3234,653],{"class":319},[46,3236,3237],{"class":62},"'Nuxt'",[46,3239,659],{"class":319},[46,3241,3242],{"class":62},"'TypeScript'",[46,3244,665],{"class":319},[46,3246,3247],{"class":48,"line":108},[46,3248,3196],{"class":59},[11,3250,3251],{"id":3251},"部署",[16,3253,3254],{},"本站使用 SSG 静态生成，构建后直接部署到静态托管即可：",[36,3256,3258],{"className":38,"code":3257,"language":40,"meta":41,"style":41},"npx nuxi generate\n",[43,3259,3260],{"__ignoreMap":41},[46,3261,3262,3265,3268],{"class":48,"line":49},[46,3263,3264],{"class":59},"npx",[46,3266,3267],{"class":62}," nuxi",[46,3269,3270],{"class":62}," generate\n",[16,3272,3273,3274,3277],{},"构建产物在 ",[43,3275,3276],{},".output\u002Fpublic"," 目录下，可以直接丢到 Nginx、Vercel、Netlify 等平台。",[11,3279,3280],{"id":3280},"后续计划",[718,3282,3285,3294,3300,3306],{"className":3283},[3284],"contains-task-list",[721,3286,3289,3293],{"className":3287},[3288],"task-list-item",[3290,3291],"input",{"disabled":104,"type":3292},"checkbox"," RSS 订阅",[721,3295,3297,3299],{"className":3296},[3288],[3290,3298],{"disabled":104,"type":3292}," 文章目录优化",[721,3301,3303,3305],{"className":3302},[3288],[3290,3304],{"disabled":104,"type":3292}," 更多小工具",[721,3307,3309,3311],{"className":3308},[3288],[3290,3310],{"disabled":104,"type":3292}," 持续输出内容",[11,3313,3314],{"id":3314},"致谢",[16,3316,3317,3318,3323],{},"感谢 ",[19,3319,3322],{"href":3320,"rel":3321},"https:\u002F\u002F2x.nz",[23],"二叉树树"," 的开源项目，本站的 UI 设计参考了他的项目。",[3325,3326],"hr",{},[16,3328,3329],{},[27,3330,3331],{},"感谢阅读！如果觉得不错，欢迎点个 Star 或者留个评论 (☞ﾟヮﾟ)☞",[1460,3333,3334],{},"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":41,"searchDepth":56,"depth":72,"links":3336},[3337,3338,3339,3340,3341,3342,3343],{"id":2951,"depth":56,"text":2952},{"id":2965,"depth":56,"text":2965},{"id":3045,"depth":56,"text":3045},{"id":3113,"depth":56,"text":3113},{"id":3251,"depth":56,"text":3251},{"id":3280,"depth":56,"text":3280},{"id":3314,"depth":56,"text":3314},"博客上线啦！这篇文章介绍本站的技术架构、功能特性和搭建过程中的一些思考。","\u002Fposts\u002Fhello-world\u002Fimg\u002Fcover.jpg",{},"\u002Fposts\u002Fhello-world","2026-05-31T08:00:00",{"title":2946,"description":3344},"Nuxt 博客开发","posts\u002Fhello-world\u002Findex",[3353,2849],"博客","2026-05-31","f9MCboCBfQIUxT8aKv6aKGDFxysiG8ZG0YXr5X5LTFo",{"id":3357,"title":3358,"author":6,"body":3359,"description":4531,"difficulty":2295,"draft":1486,"extension":1487,"image":4532,"meta":4533,"navigation":104,"path":4534,"pinned":1486,"published":4535,"seo":4536,"series":3350,"seriesOrder":56,"stem":4537,"tags":4538,"updated":3354,"__hash__":4541},"posts\u002Fposts\u002Fnuxt-content-lessons\u002Findex.md","Nuxt Content v3 踩坑记录：目录、图片与那些反直觉的设计",{"type":8,"value":3360,"toc":4521},[3361,3365,3368,3371,3375,3378,3389,3392,3399,3464,3471,3473,3480,3758,3768,3772,3775,3778,3804,3818,3821,3844,3850,3853,3856,3859,3864,3871,4098,4103,4147,4151,4154,4161,4164,4213,4219,4448,4458,4460,4512,4515,4518],[3362,3363,3364],"h1",{"id":3364},"背景",[16,3366,3367],{},"最近在用 Nuxt 3 + Nuxt Content v3 搭建一个个人博客，参考了一个 SvelteKit 项目（svaf）的架构。原以为换个框架只是语法差异，没想到在几个关键功能上踩了不少坑。",[16,3369,3370],{},"这篇文章记录了三个核心问题的排查过程和最终方案。",[3362,3372,3374],{"id":3373},"问题一目录导航toc只有-h2","问题一：目录导航（TOC）只有 h2",[11,3376,3377],{"id":3377},"现象",[16,3379,3380,3381,3384,3385,3388],{},"Nuxt Content 内置了 TOC 数据，挂在 ",[43,3382,3383],{},"post.body.toc"," 上。直接用它渲染目录，发现",[27,3386,3387],{},"只有 h2 标题，h3 全部丢失","。",[11,3390,3391],{"id":3391},"排查",[16,3393,3394,3395,3398],{},"查了官方文档，发现 ",[43,3396,3397],{},"build.markdown.toc.depth"," 可以控制深度：",[36,3400,3402],{"className":2757,"code":3401,"language":2759,"meta":41,"style":41},"\u002F\u002F nuxt.config.ts\ncontent: {\n  build: {\n    markdown: {\n      toc: { depth: 3 }  \u002F\u002F 包含 h3\n    }\n  }\n}\n",[43,3403,3404,3409,3416,3423,3430,3452,3456,3460],{"__ignoreMap":41},[46,3405,3406],{"class":48,"line":49},[46,3407,3408],{"class":52},"\u002F\u002F nuxt.config.ts\n",[46,3410,3411,3414],{"class":48,"line":56},[46,3412,3413],{"class":59},"content",[46,3415,624],{"class":319},[46,3417,3418,3421],{"class":48,"line":72},[46,3419,3420],{"class":59},"  build",[46,3422,624],{"class":319},[46,3424,3425,3428],{"class":48,"line":85},[46,3426,3427],{"class":59},"    markdown",[46,3429,624],{"class":319},[46,3431,3432,3435,3438,3441,3443,3446,3449],{"class":48,"line":101},[46,3433,3434],{"class":59},"      toc",[46,3436,3437],{"class":319},": { ",[46,3439,3440],{"class":59},"depth",[46,3442,639],{"class":319},[46,3444,3445],{"class":122},"3",[46,3447,3448],{"class":319}," }  ",[46,3450,3451],{"class":52},"\u002F\u002F 包含 h3\n",[46,3453,3454],{"class":48,"line":108},[46,3455,670],{"class":319},[46,3457,3458],{"class":48,"line":114},[46,3459,675],{"class":319},[46,3461,3462],{"class":48,"line":337},[46,3463,680],{"class":319},[16,3465,3466,3467,3470],{},"配置改了，清了缓存重启，",[27,3468,3469],{},"依然只有 h2","。这个配置似乎不起作用。",[11,3472,1791],{"id":1791},[16,3474,3475,3476,3479],{},"放弃依赖 Nuxt Content 的 toc，直接从 ",[43,3477,3478],{},"post.body"," 的 AST 里递归提取所有标题：",[36,3481,3483],{"className":2757,"code":3482,"language":2759,"meta":41,"style":41},"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",[43,3484,3485,3529,3548,3561,3566,3627,3657,3689,3714,3718,3743,3747,3754],{"__ignoreMap":41},[46,3486,3487,3489,3492,3495,3499,3501,3504,3506,3509,3511,3514,3517,3519,3522,3524,3526],{"class":48,"line":49},[46,3488,2784],{"class":312},[46,3490,3491],{"class":59}," extractHeadings",[46,3493,3494],{"class":319},"(",[46,3496,3498],{"class":3497},"sPK6S","node",[46,3500,2429],{"class":312},[46,3502,3503],{"class":122}," any",[46,3505,659],{"class":319},[46,3507,3508],{"class":3497},"list",[46,3510,2429],{"class":312},[46,3512,3513],{"class":59}," Heading",[46,3515,3516],{"class":319},"[] ",[46,3518,1611],{"class":312},[46,3520,3521],{"class":319}," [])",[46,3523,2429],{"class":312},[46,3525,3513],{"class":59},[46,3527,3528],{"class":319},"[] {\n",[46,3530,3531,3534,3537,3540,3543,3546],{"class":48,"line":56},[46,3532,3533],{"class":312},"  if",[46,3535,3536],{"class":319}," (",[46,3538,3539],{"class":312},"!",[46,3541,3542],{"class":319},"node) ",[46,3544,3545],{"class":312},"return",[46,3547,1059],{"class":319},[46,3549,3550,3552,3555,3558],{"class":48,"line":72},[46,3551,3533],{"class":312},[46,3553,3554],{"class":319}," (Array.",[46,3556,3557],{"class":59},"isArray",[46,3559,3560],{"class":319},"(node)) {\n",[46,3562,3563],{"class":48,"line":85},[46,3564,3565],{"class":52},"    \u002F\u002F minimark 格式：[\"h2\", {id: \"xxx\"}, \"文本\"]\n",[46,3567,3568,3571,3573,3576,3579,3582,3585,3588,3591,3594,3597,3600,3604,3607,3610,3613,3616,3619,3622,3624],{"class":48,"line":101},[46,3569,3570],{"class":312},"    if",[46,3572,3536],{"class":319},[46,3574,3575],{"class":312},"typeof",[46,3577,3578],{"class":319}," node[",[46,3580,3581],{"class":122},"0",[46,3583,3584],{"class":319},"] ",[46,3586,3587],{"class":312},"===",[46,3589,3590],{"class":62}," 'string'",[46,3592,3593],{"class":312}," &&",[46,3595,3596],{"class":62}," \u002F",[46,3598,3599],{"class":312},"^",[46,3601,3603],{"class":3602},"sPKmS","h",[46,3605,3606],{"class":122},"[1-6]",[46,3608,3609],{"class":312},"$",[46,3611,3612],{"class":62},"\u002F",[46,3614,3615],{"class":319},".",[46,3617,3618],{"class":59},"test",[46,3620,3621],{"class":319},"(node[",[46,3623,3581],{"class":122},[46,3625,3626],{"class":319},"])) {\n",[46,3628,3629,3632,3635,3637,3640,3642,3644,3647,3650,3652,3654],{"class":48,"line":108},[46,3630,3631],{"class":312},"      const",[46,3633,3634],{"class":122}," level",[46,3636,3139],{"class":312},[46,3638,3639],{"class":59}," Number",[46,3641,3621],{"class":319},[46,3643,3581],{"class":122},[46,3645,3646],{"class":319},"].",[46,3648,3649],{"class":59},"charAt",[46,3651,3494],{"class":319},[46,3653,1629],{"class":122},[46,3655,3656],{"class":319},"))\n",[46,3658,3659,3661,3664,3666,3669,3672,3675,3677,3680,3683,3686],{"class":48,"line":114},[46,3660,3631],{"class":312},[46,3662,3663],{"class":122}," text",[46,3665,3139],{"class":312},[46,3667,3668],{"class":59}," extractText",[46,3670,3671],{"class":319},"(node.",[46,3673,3674],{"class":59},"slice",[46,3676,3494],{"class":319},[46,3678,3679],{"class":122},"2",[46,3681,3682],{"class":319},")).",[46,3684,3685],{"class":59},"trim",[46,3687,3688],{"class":319},"()\n",[46,3690,3691,3694,3697,3700,3702,3705,3708,3711],{"class":48,"line":337},[46,3692,3693],{"class":319},"      list.",[46,3695,3696],{"class":59},"push",[46,3698,3699],{"class":319},"({ id: node[",[46,3701,1629],{"class":122},[46,3703,3704],{"class":319},"]?.id ",[46,3706,3707],{"class":312},"||",[46,3709,3710],{"class":59}," slugify",[46,3712,3713],{"class":319},"(text), text, level })\n",[46,3715,3716],{"class":48,"line":369},[46,3717,670],{"class":319},[46,3719,3720,3723,3725,3728,3731,3734,3737,3740],{"class":48,"line":374},[46,3721,3722],{"class":312},"    for",[46,3724,3536],{"class":319},[46,3726,3727],{"class":312},"const",[46,3729,3730],{"class":122}," child",[46,3732,3733],{"class":312}," of",[46,3735,3736],{"class":319}," node) ",[46,3738,3739],{"class":59},"extractHeadings",[46,3741,3742],{"class":319},"(child, list)\n",[46,3744,3745],{"class":48,"line":380},[46,3746,675],{"class":319},[46,3748,3749,3752],{"class":48,"line":484},[46,3750,3751],{"class":312},"  return",[46,3753,1059],{"class":319},[46,3755,3756],{"class":48,"line":498},[46,3757,680],{"class":319},[16,3759,3760,3761,3763,3764,3767],{},"关键点：Nuxt Content v3 的 body 使用 ",[27,3762,8],{}," 格式，标题节点是数组 ",[43,3765,3766],{},"[\"h2\", {id: \"xxx\"}, \"标题文本\"]","，不是对象。",[3362,3769,3771],{"id":3770},"问题二图片路径解析错误","问题二：图片路径解析错误",[11,3773,3377],{"id":3774},"现象-1",[16,3776,3777],{},"markdown 里用相对路径引用图片：",[36,3779,3783],{"className":3780,"code":3781,"language":3782,"meta":41,"style":41},"language-markdown shiki shiki-themes github-light github-dark","![SSHFS挂载效果](img\u002Fsshfs.avif)\n","markdown",[43,3784,3785],{"__ignoreMap":41},[46,3786,3787,3790,3794,3797,3801],{"class":48,"line":49},[46,3788,3789],{"class":319},"![",[46,3791,3793],{"class":3792},"smiIW","SSHFS挂载效果",[46,3795,3796],{"class":319},"](",[46,3798,3800],{"class":3799},"szbl0","img\u002Fsshfs.avif",[46,3802,3803],{"class":319},")\n",[16,3805,3806,3807,3810,3811,3814,3815,3388],{},"HTML 输出的 ",[43,3808,3809],{},"\u003Cimg src=\"img\u002Fsshfs.avif\">"," 看起来没问题，但浏览器请求的却是 ",[43,3812,3813],{},"\u002Fposts\u002Fimg\u002Fsshfs.avif"," 而不是 ",[43,3816,3817],{},"\u002Fposts\u002Fsshfs\u002Fimg\u002Fsshfs.avif",[11,3819,3391],{"id":3820},"排查-1",[1747,3822,3823,3830,3833,3839],{},[721,3824,3825,3826,3829],{},"检查了 ",[43,3827,3828],{},"\u003Cbase>"," 标签 —— 没有",[721,3831,3832],{},"检查了重定向 —— 没有",[721,3834,3835,3836,3838],{},"直接访问 ",[43,3837,3817],{}," —— 返回 200 OK",[721,3840,3835,3841,3843],{},[43,3842,3813],{}," —— 404",[16,3845,3846,3847,3388],{},"服务器路由没问题，问题是",[27,3848,3849],{},"浏览器解析相对路径时基于了错误的基准 URL",[16,3851,3852],{},"这可能和 Nuxt 的客户端路由有关：SPA 导航时，浏览器的\"当前页面\"和 Vue Router 的\"当前路由\"不同步，导致相对路径解析出错。",[11,3854,1791],{"id":3855},"最终方案-1",[16,3857,3858],{},"两步解决：",[16,3860,3861],{},[27,3862,3863],{},"第一步：自定义 ProseImg 组件",[16,3865,3866,3867,3870],{},"创建 ",[43,3868,3869],{},"components\u002Fcontent\u002FProseImg.vue","，覆盖 Nuxt Content 默认的图片渲染：",[36,3872,3876],{"className":3873,"code":3874,"language":3875,"meta":41,"style":41},"language-vue shiki shiki-themes github-light github-dark","\u003Cscript setup>\nconst route = useRoute()\nconst resolvedSrc = computed(() => {\n  if (!props.src) return ''\n  if (props.src.startsWith('\u002F') || props.src.startsWith('http')) return props.src\n  const base = route.path.replace(\u002F\\\u002F$\u002F, '')\n  return `${base}\u002F${props.src}`\n})\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003Cimg :src=\"resolvedSrc\" :alt=\"alt\" \u002F>\n\u003C\u002Ftemplate>\n","vue",[43,3877,3878,3890,3904,3924,3940,3978,4013,4037,4042,4051,4055,4064,4090],{"__ignoreMap":41},[46,3879,3880,3882,3885,3888],{"class":48,"line":49},[46,3881,2432],{"class":319},[46,3883,3884],{"class":3201},"script",[46,3886,3887],{"class":59}," setup",[46,3889,2093],{"class":319},[46,3891,3892,3894,3897,3899,3902],{"class":48,"line":56},[46,3893,3727],{"class":312},[46,3895,3896],{"class":122}," route",[46,3898,3139],{"class":312},[46,3900,3901],{"class":59}," useRoute",[46,3903,3688],{"class":319},[46,3905,3906,3908,3911,3913,3916,3919,3922],{"class":48,"line":72},[46,3907,3727],{"class":312},[46,3909,3910],{"class":122}," resolvedSrc",[46,3912,3139],{"class":312},[46,3914,3915],{"class":59}," computed",[46,3917,3918],{"class":319},"(() ",[46,3920,3921],{"class":312},"=>",[46,3923,3142],{"class":319},[46,3925,3926,3928,3930,3932,3935,3937],{"class":48,"line":85},[46,3927,3533],{"class":312},[46,3929,3536],{"class":319},[46,3931,3539],{"class":312},[46,3933,3934],{"class":319},"props.src) ",[46,3936,3545],{"class":312},[46,3938,3939],{"class":62}," ''\n",[46,3941,3942,3944,3947,3950,3952,3955,3958,3960,3963,3965,3967,3970,3973,3975],{"class":48,"line":101},[46,3943,3533],{"class":312},[46,3945,3946],{"class":319}," (props.src.",[46,3948,3949],{"class":59},"startsWith",[46,3951,3494],{"class":319},[46,3953,3954],{"class":62},"'\u002F'",[46,3956,3957],{"class":319},") ",[46,3959,3707],{"class":312},[46,3961,3962],{"class":319}," props.src.",[46,3964,3949],{"class":59},[46,3966,3494],{"class":319},[46,3968,3969],{"class":62},"'http'",[46,3971,3972],{"class":319},")) ",[46,3974,3545],{"class":312},[46,3976,3977],{"class":319}," props.src\n",[46,3979,3980,3983,3986,3988,3991,3994,3996,3998,4002,4004,4006,4008,4011],{"class":48,"line":108},[46,3981,3982],{"class":312},"  const",[46,3984,3985],{"class":122}," base",[46,3987,3139],{"class":312},[46,3989,3990],{"class":319}," route.path.",[46,3992,3993],{"class":59},"replace",[46,3995,3494],{"class":319},[46,3997,3612],{"class":62},[46,3999,4001],{"class":4000},"sCnZR","\\\u002F",[46,4003,3609],{"class":312},[46,4005,3612],{"class":62},[46,4007,659],{"class":319},[46,4009,4010],{"class":62},"''",[46,4012,3803],{"class":319},[46,4014,4015,4017,4020,4023,4026,4029,4031,4034],{"class":48,"line":114},[46,4016,3751],{"class":312},[46,4018,4019],{"class":62}," `${",[46,4021,4022],{"class":319},"base",[46,4024,4025],{"class":62},"}\u002F${",[46,4027,4028],{"class":319},"props",[46,4030,3615],{"class":62},[46,4032,4033],{"class":319},"src",[46,4035,4036],{"class":62},"}`\n",[46,4038,4039],{"class":48,"line":337},[46,4040,4041],{"class":319},"})\n",[46,4043,4044,4047,4049],{"class":48,"line":369},[46,4045,4046],{"class":319},"\u003C\u002F",[46,4048,3884],{"class":3201},[46,4050,2093],{"class":319},[46,4052,4053],{"class":48,"line":374},[46,4054,105],{"emptyLinePlaceholder":104},[46,4056,4057,4059,4062],{"class":48,"line":380},[46,4058,2432],{"class":319},[46,4060,4061],{"class":3201},"template",[46,4063,2093],{"class":319},[46,4065,4066,4069,4071,4074,4076,4079,4082,4084,4087],{"class":48,"line":484},[46,4067,4068],{"class":319},"  \u003C",[46,4070,1771],{"class":3201},[46,4072,4073],{"class":59}," :src",[46,4075,1611],{"class":319},[46,4077,4078],{"class":62},"\"resolvedSrc\"",[46,4080,4081],{"class":59}," :alt",[46,4083,1611],{"class":319},[46,4085,4086],{"class":62},"\"alt\"",[46,4088,4089],{"class":319}," \u002F>\n",[46,4091,4092,4094,4096],{"class":48,"line":498},[46,4093,4046],{"class":319},[46,4095,4061],{"class":3201},[46,4097,2093],{"class":319},[16,4099,4100],{},[27,4101,4102],{},"第二步：启用 MDC prose 组件",[36,4104,4106],{"className":2757,"code":4105,"language":2759,"meta":41,"style":41},"\u002F\u002F nuxt.config.ts\nmdc: {\n  components: {\n    prose: true  \u002F\u002F 让 Nuxt Content 使用自定义 ProseImg\n  }\n}\n",[43,4107,4108,4112,4119,4126,4139,4143],{"__ignoreMap":41},[46,4109,4110],{"class":48,"line":49},[46,4111,3408],{"class":52},[46,4113,4114,4117],{"class":48,"line":56},[46,4115,4116],{"class":59},"mdc",[46,4118,624],{"class":319},[46,4120,4121,4124],{"class":48,"line":72},[46,4122,4123],{"class":59},"  components",[46,4125,624],{"class":319},[46,4127,4128,4131,4133,4136],{"class":48,"line":85},[46,4129,4130],{"class":59},"    prose",[46,4132,639],{"class":319},[46,4134,4135],{"class":122},"true",[46,4137,4138],{"class":52},"  \u002F\u002F 让 Nuxt Content 使用自定义 ProseImg\n",[46,4140,4141],{"class":48,"line":101},[46,4142,675],{"class":319},[46,4144,4145],{"class":48,"line":108},[46,4146,680],{"class":319},[3362,4148,4150],{"id":4149},"问题三content-目录的图片如何服务","问题三：content 目录的图片如何服务",[11,4152,3377],{"id":4153},"现象-2",[16,4155,4156,4157,4160],{},"原站（SvelteKit）的图片放在 ",[43,4158,4159],{},"content\u002Fposts\u002F{slug}\u002Fimg\u002F"," 下，markdown 用相对路径引用，一切正常。Nuxt 项目里，content 目录的文件不会被当成静态资源服务。",[11,4162,4163],{"id":4163},"方案对比",[131,4165,4166,4178],{},[134,4167,4168],{},[137,4169,4170,4172,4175],{},[140,4171,1375],{},[140,4173,4174],{},"优点",[140,4176,4177],{},"缺点",[147,4179,4180,4191,4202],{},[137,4181,4182,4185,4188],{},[152,4183,4184],{},"复制到 public\u002F",[152,4186,4187],{},"简单",[152,4189,4190],{},"文件重复，需同步",[137,4192,4193,4196,4199],{},[152,4194,4195],{},"Vite 插件拦截",[152,4197,4198],{},"原站方案",[152,4200,4201],{},"Nuxt 不直接支持",[137,4203,4204,4207,4210],{},[152,4205,4206],{},"Server Route",[152,4208,4209],{},"不复制文件",[152,4211,4212],{},"需要手动写路由",[16,4214,4215,4216,4218],{},"最终选择 ",[27,4217,4206],{},"，和原站的 Vite 插件思路一致：",[36,4220,4222],{"className":2757,"code":4221,"language":2759,"meta":41,"style":41},"\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",[43,4223,4224,4229,4255,4270,4334,4348,4352,4374,4400,4418,4437,4444],{"__ignoreMap":41},[46,4225,4226],{"class":48,"line":49},[46,4227,4228],{"class":52},"\u002F\u002F server\u002Froutes\u002Fposts\u002F[slug]\u002Fimg\u002F[...file].ts\n",[46,4230,4231,4233,4236,4239,4241,4244,4246,4249,4251,4253],{"class":48,"line":56},[46,4232,1605],{"class":312},[46,4234,4235],{"class":312}," default",[46,4237,4238],{"class":59}," defineEventHandler",[46,4240,3494],{"class":319},[46,4242,4243],{"class":312},"async",[46,4245,3536],{"class":319},[46,4247,4248],{"class":3497},"event",[46,4250,3957],{"class":319},[46,4252,3921],{"class":312},[46,4254,3142],{"class":319},[46,4256,4257,4259,4262,4264,4267],{"class":48,"line":72},[46,4258,3982],{"class":312},[46,4260,4261],{"class":122}," url",[46,4263,3139],{"class":312},[46,4265,4266],{"class":59}," getRequestURL",[46,4268,4269],{"class":319},"(event)\n",[46,4271,4272,4274,4277,4279,4282,4285,4287,4289,4291,4293,4296,4298,4300,4303,4305,4308,4311,4314,4316,4318,4320,4322,4324,4326,4328,4330,4332],{"class":48,"line":85},[46,4273,3982],{"class":312},[46,4275,4276],{"class":122}," match",[46,4278,3139],{"class":312},[46,4280,4281],{"class":319}," url.pathname.",[46,4283,4284],{"class":59},"match",[46,4286,3494],{"class":319},[46,4288,3612],{"class":62},[46,4290,3599],{"class":312},[46,4292,4001],{"class":4000},[46,4294,4295],{"class":3602},"posts",[46,4297,4001],{"class":4000},[46,4299,3494],{"class":3602},[46,4301,4302],{"class":122},"[",[46,4304,3599],{"class":312},[46,4306,4307],{"class":122},"\u002F]",[46,4309,4310],{"class":312},"+",[46,4312,4313],{"class":3602},")",[46,4315,4001],{"class":4000},[46,4317,1771],{"class":3602},[46,4319,4001],{"class":4000},[46,4321,3494],{"class":3602},[46,4323,3615],{"class":122},[46,4325,4310],{"class":312},[46,4327,4313],{"class":3602},[46,4329,3609],{"class":312},[46,4331,3612],{"class":62},[46,4333,3803],{"class":319},[46,4335,4336,4338,4340,4342,4345],{"class":48,"line":101},[46,4337,3533],{"class":312},[46,4339,3536],{"class":319},[46,4341,3539],{"class":312},[46,4343,4344],{"class":319},"match) ",[46,4346,4347],{"class":312},"return\n",[46,4349,4350],{"class":48,"line":108},[46,4351,105],{"emptyLinePlaceholder":104},[46,4353,4354,4356,4359,4362,4364,4367,4369,4371],{"class":48,"line":114},[46,4355,3982],{"class":312},[46,4357,4358],{"class":319}," [, ",[46,4360,4361],{"class":122},"slug",[46,4363,659],{"class":319},[46,4365,4366],{"class":122},"filename",[46,4368,3584],{"class":319},[46,4370,1611],{"class":312},[46,4372,4373],{"class":319}," match\n",[46,4375,4376,4378,4381,4383,4386,4388,4391,4394,4397],{"class":48,"line":337},[46,4377,3982],{"class":312},[46,4379,4380],{"class":122}," filePath",[46,4382,3139],{"class":312},[46,4384,4385],{"class":59}," resolve",[46,4387,3494],{"class":319},[46,4389,4390],{"class":62},"'content\u002Fposts'",[46,4392,4393],{"class":319},", slug, ",[46,4395,4396],{"class":62},"'img'",[46,4398,4399],{"class":319},", filename)\n",[46,4401,4402,4404,4407,4409,4412,4415],{"class":48,"line":369},[46,4403,3982],{"class":312},[46,4405,4406],{"class":122}," data",[46,4408,3139],{"class":312},[46,4410,4411],{"class":312}," await",[46,4413,4414],{"class":59}," readFile",[46,4416,4417],{"class":319},"(filePath)\n",[46,4419,4420,4423,4426,4429,4431,4434],{"class":48,"line":374},[46,4421,4422],{"class":59},"  setResponseHeader",[46,4424,4425],{"class":319},"(event, ",[46,4427,4428],{"class":62},"'content-type'",[46,4430,659],{"class":319},[46,4432,4433],{"class":122},"MIME",[46,4435,4436],{"class":319},"[ext])\n",[46,4438,4439,4441],{"class":48,"line":380},[46,4440,3751],{"class":312},[46,4442,4443],{"class":319}," data\n",[46,4445,4446],{"class":48,"line":484},[46,4447,4041],{"class":319},[16,4449,4450,4451,4453,4454,4457],{},"请求 ",[43,4452,3817],{}," → 读取 ",[43,4455,4456],{},"content\u002Fposts\u002Fsshfs\u002Fimg\u002Fsshfs.avif"," → 返回图片。",[3362,4459,1430],{"id":1430},[131,4461,4462,4474],{},[134,4463,4464],{},[137,4465,4466,4469,4472],{},[140,4467,4468],{},"问题",[140,4470,4471],{},"根因",[140,4473,1375],{},[147,4475,4476,4490,4501],{},[137,4477,4478,4481,4487],{},[152,4479,4480],{},"TOC 只有 h2",[152,4482,4483,4486],{},[43,4484,4485],{},"toc.depth"," 配置不生效",[152,4488,4489],{},"从 body AST 手动提取",[137,4491,4492,4495,4498],{},[152,4493,4494],{},"图片路径错误",[152,4496,4497],{},"SPA 路由导致相对路径解析基准错误",[152,4499,4500],{},"ProseImg 组件 + mdc.prose 配置",[137,4502,4503,4506,4509],{},[152,4504,4505],{},"content 图片 404",[152,4507,4508],{},"content 目录不暴露给浏览器",[152,4510,4511],{},"Server Route 直接读取返回",[16,4513,4514],{},"Nuxt Content v3 的设计理念是\"图片放 public\u002F，用绝对路径\"。如果想像 SvelteKit 项目那样把图片和文章放一起，需要额外做不少工作。",[16,4516,4517],{},"不过一旦这些坑都踩过，整体开发体验还是很流畅的。Nuxt 的生态系统、自动导入、模块系统都比 SvelteKit 成熟很多。",[1460,4519,4520],{},"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":41,"searchDepth":56,"depth":72,"links":4522},[4523,4524,4525,4526,4527,4528,4529,4530],{"id":3377,"depth":56,"text":3377},{"id":3391,"depth":56,"text":3391},{"id":1791,"depth":56,"text":1791},{"id":3774,"depth":56,"text":3377},{"id":3820,"depth":56,"text":3391},{"id":3855,"depth":56,"text":1791},{"id":4153,"depth":56,"text":3377},{"id":4163,"depth":56,"text":4163},"从 SvelteKit 迁移到 Nuxt 3 的过程中，遇到了不少 Nuxt Content v3 的坑。这篇文章记录了目录导航、图片路径、ProseImg 组件等问题的排查和解决过程。","\u002Fposts\u002Fnuxt-content-lessons\u002Fimg\u002Fcover.jpg",{},"\u002Fposts\u002Fnuxt-content-lessons","2026-05-31T14:00:00",{"title":3358,"description":4531},"posts\u002Fnuxt-content-lessons\u002Findex",[2849,4539,4540],"踩坑","Nuxt Content","vbSt6ND1z7xt7L8FxgZ8Qe3EdxV9InsRerN3E23GC7s",{"id":4543,"title":4544,"author":2958,"body":4545,"description":4810,"difficulty":2295,"draft":1486,"extension":1487,"image":4811,"meta":4812,"navigation":104,"path":4813,"pinned":1486,"published":4814,"seo":4815,"series":3350,"seriesOrder":72,"stem":4816,"tags":4817,"updated":3354,"__hash__":4820},"posts\u002Fposts\u002Fnuxt-tailwind-underline-fix\u002Findex.md","Nuxt 3 + Tailwind CSS 客户端导航链接下划线闪烁问题",{"type":8,"value":4546,"toc":4803},[4547,4550,4553,4568,4571,4574,4577,4618,4629,4636,4658,4665,4669,4672,4675,4677,4680,4703,4714,4771,4776,4789,4791,4797,4800],[11,4548,4549],{"id":4549},"问题描述",[16,4551,4552],{},"在 Nuxt 3 项目中使用 Tailwind CSS 时，发现一个奇怪的现象：",[718,4554,4555,4565],{},[721,4556,4557,4558,4561,4562],{},"首次通过客户端导航（点击 ",[43,4559,4560],{},"\u003CNuxtLink>","）跳转到某个页面时，",[27,4563,4564],{},"所有链接文字都会出现下划线",[721,4566,4567],{},"手动刷新页面后，下划线消失，样式恢复正常",[16,4569,4570],{},"这个问题在开发模式和生产构建后都会出现。",[11,4572,4573],{"id":4573},"原因分析",[16,4575,4576],{},"Tailwind CSS 的 Preflight（基于 modern-normalize）会重置链接样式：",[36,4578,4582],{"className":4579,"code":4580,"language":4581,"meta":41,"style":41},"language-css shiki shiki-themes github-light github-dark","a {\n  color: inherit;\n  text-decoration: inherit;\n}\n","css",[43,4583,4584,4590,4603,4614],{"__ignoreMap":41},[46,4585,4586,4588],{"class":48,"line":49},[46,4587,19],{"class":3201},[46,4589,3142],{"class":319},[46,4591,4592,4595,4597,4600],{"class":48,"line":56},[46,4593,4594],{"class":122},"  color",[46,4596,639],{"class":319},[46,4598,4599],{"class":122},"inherit",[46,4601,4602],{"class":319},";\n",[46,4604,4605,4608,4610,4612],{"class":48,"line":72},[46,4606,4607],{"class":122},"  text-decoration",[46,4609,639],{"class":319},[46,4611,4599],{"class":122},[46,4613,4602],{"class":319},[46,4615,4616],{"class":48,"line":85},[46,4617,680],{"class":319},[16,4619,4620,4621,4624,4625,4628],{},"正常情况下，这会让链接继承父元素的 ",[43,4622,4623],{},"text-decoration","（通常是 ",[43,4626,4627],{},"none","），从而去掉浏览器默认的下划线。",[16,4630,4631,4632,4635],{},"问题出在 ",[27,4633,4634],{},"Nuxt 的客户端导航机制","：",[1747,4637,4638,4644,4647,4652,4655],{},[721,4639,4640,4641,4643],{},"用户点击 ",[43,4642,4560],{},"，Vue Router 拦截导航",[721,4645,4646],{},"新页面组件异步加载并渲染",[721,4648,4649],{},[27,4650,4651],{},"在 CSS 完全加载\u002F应用之前，页面已经渲染了",[721,4653,4654],{},"此时浏览器使用默认样式（链接带下划线）",[721,4656,4657],{},"CSS 加载完成后，下划线消失",[16,4659,4660,4661,4664],{},"这就是典型的 ",[27,4662,4663],{},"FOUC（Flash of Unstyled Content）"," 问题。",[11,4666,4668],{"id":4667},"为什么刷新就正常了","为什么刷新就正常了？",[16,4670,4671],{},"刷新页面时，浏览器会等待所有 CSS 加载完成后再渲染页面（SSR 模式下 HTML 和 CSS 是一起返回的）。所以刷新后样式是正确的。",[16,4673,4674],{},"而客户端导航是 JavaScript 驱动的，CSS 的加载和页面的渲染是异步的，就出现了时序差。",[11,4676,2320],{"id":2320},[16,4678,4679],{},"尝试了多种方案：",[718,4681,4682,4688,4694,4700],{},[721,4683,4684,4687],{},[43,4685,4686],{},"@layer base"," 覆盖 — 无效，CSS layers 的优先级问题",[721,4689,4690,4693],{},[43,4691,4692],{},"corePlugins.preflight: false"," — 无效，且破坏其他基础样式",[721,4695,4696,4699],{},[43,4697,4698],{},"features.inlineStyles: true"," — 无效",[721,4701,4702],{},"页面过渡动画 — 只是掩盖，不能根治",[16,4704,4705,4706,4709,4710,4713],{},"最终有效的方案是在 ",[43,4707,4708],{},"app.vue"," 中用 ",[43,4711,4712],{},"!important"," 强制覆盖：",[36,4715,4717],{"className":3873,"code":4716,"language":3875,"meta":41,"style":41},"\u003Cstyle>\na:not(.prose a) {\n  text-decoration-line: none !important;\n}\n\u003C\u002Fstyle>\n",[43,4718,4719,4727,4745,4759,4763],{"__ignoreMap":41},[46,4720,4721,4723,4725],{"class":48,"line":49},[46,4722,2432],{"class":319},[46,4724,1460],{"class":3201},[46,4726,2093],{"class":319},[46,4728,4729,4731,4734,4736,4739,4742],{"class":48,"line":56},[46,4730,19],{"class":3201},[46,4732,4733],{"class":59},":not",[46,4735,3494],{"class":319},[46,4737,4738],{"class":59},".prose",[46,4740,4741],{"class":3201}," a",[46,4743,4744],{"class":319},") {\n",[46,4746,4747,4750,4752,4754,4757],{"class":48,"line":72},[46,4748,4749],{"class":122},"  text-decoration-line",[46,4751,639],{"class":319},[46,4753,4627],{"class":122},[46,4755,4756],{"class":312}," !important",[46,4758,4602],{"class":319},[46,4760,4761],{"class":48,"line":85},[46,4762,680],{"class":319},[46,4764,4765,4767,4769],{"class":48,"line":101},[46,4766,4046],{"class":319},[46,4768,1460],{"class":3201},[46,4770,2093],{"class":319},[16,4772,4773],{},[27,4774,4775],{},"关键点：",[718,4777,4778,4783],{},[721,4779,4780,4782],{},[43,4781,4712],{}," 是为了对抗 Tailwind CSS layers 的优先级",[721,4784,4785,4788],{},[43,4786,4787],{},":not(.prose a)"," 排除文章正文区域，保留文章内链接的下划线（可读性需要）",[11,4790,1430],{"id":1430},[16,4792,4793,4794,4796],{},"这是 Nuxt 3 + Tailwind CSS 的一个已知问题，本质上是 CSS 加载时序导致的 FOUC。",[43,4795,4712],{}," 虽然不够优雅，但在这个场景下是务实的解决方案。",[16,4798,4799],{},"如果你有更好的方案，欢迎在项目仓库的 Issues 中提出！",[1460,4801,4802],{},"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":41,"searchDepth":56,"depth":72,"links":4804},[4805,4806,4807,4808,4809],{"id":4549,"depth":56,"text":4549},{"id":4573,"depth":56,"text":4573},{"id":4667,"depth":56,"text":4668},{"id":2320,"depth":56,"text":2320},{"id":1430,"depth":56,"text":1430},"记录 Nuxt 3 配合 Tailwind CSS 时，客户端导航导致链接文字出现下划线闪烁的问题及解决方案。","\u002Fposts\u002Fnuxt-tailwind-underline-fix\u002Fimg\u002Fcover.png",{},"\u002Fposts\u002Fnuxt-tailwind-underline-fix","2026-05-31T18:47:00",{"title":4544,"description":4810},"posts\u002Fnuxt-tailwind-underline-fix\u002Findex",[2849,4818,4819,4539],"Tailwind CSS","CSS","WMLpTEsIoJqufwEfXyIiBgUYxcPY2XkvOwqAD4hbe6g",{"id":4822,"title":4823,"author":6,"body":4824,"description":6869,"difficulty":2295,"draft":1486,"extension":1487,"image":6870,"meta":6871,"navigation":104,"path":6872,"pinned":1486,"published":6873,"seo":6874,"series":6875,"seriesOrder":49,"stem":6876,"tags":6877,"updated":6880,"__hash__":6881},"posts\u002Fposts\u002Fgo-mcp-gin\u002Findex.md","Go-MCP 接入 Gin：从零搭建 MCP 服务器",{"type":8,"value":4825,"toc":6850},[4826,4830,4833,4836,4840,4847,4853,4856,4859,4879,4882,4886,4909,4913,5013,5026,5030,5193,5196,5413,5417,5428,5527,5534,5538,5812,5815,5896,5900,5903,5905,5916,5920,5926,6293,6483,6486,6542,6545,6698,6701,6704,6771,6773,6838,6847],[11,4827,4829],{"id":4828},"什么是-mcp","什么是 MCP",[16,4831,4832],{},"MCP（Model Context Protocol）是 Anthropic 提出的开放协议，用于让 AI 助手（如 Claude）与外部服务进行标准化交互。简单来说：你提供一个 MCP 服务器，AI 就能发现并调用你定义的工具（Tools），就像给 AI 装了一双手。",[16,4834,4835],{},"传统做法是写 REST API 然后让 AI 通过函数调用（Function Calling）间接使用，但 MCP 统一了协议层，AI 客户端只需要配置一个 URL 就能接入所有工具。",[11,4837,4839],{"id":4838},"为什么选-gin-mcp-go","为什么选 Gin + mcp-go",[16,4841,4842,4843,4846],{},"Go 生态里做 MCP 服务器最成熟的库是 ",[43,4844,4845],{},"mark3labs\u002Fmcp-go","，它支持 StreamableHTTP 传输模式，可以直接挂载到任意 HTTP 框架上。Gin 作为 Go 最流行的 HTTP 框架，天然适配。",[16,4848,4849,4850],{},"核心思路：",[27,4851,4852],{},"MCP 服务器本质上就是一个 HTTP Handler，挂到 Gin 路由上即可。",[11,4854,4855],{"id":4855},"需求分析",[16,4857,4858],{},"我们要搭建一个具备以下能力的 MCP 服务器：",[1747,4860,4861,4867,4873],{},[721,4862,4863,4866],{},[27,4864,4865],{},"工具注册"," — 向 AI 暴露可调用的工具（列出资源、查看详情、创建\u002F删除）",[721,4868,4869,4872],{},[27,4870,4871],{},"HTTP 集成"," — MCP 端点作为 Gin 路由的一部分，与业务 API 共存",[721,4874,4875,4878],{},[27,4876,4877],{},"鉴权"," — MCP 请求必须经过身份验证，工具处理器能获取当前用户信息",[11,4880,4881],{"id":4881},"实现步骤",[262,4883,4885],{"id":4884},"_1-安装依赖","1. 安装依赖",[36,4887,4889],{"className":38,"code":4888,"language":40,"meta":41,"style":41},"go get github.com\u002Fmark3labs\u002Fmcp-go@v0.54.0\ngo get github.com\u002Fgin-gonic\u002Fgin@v1.12.0\n",[43,4890,4891,4900],{"__ignoreMap":41},[46,4892,4893,4895,4897],{"class":48,"line":49},[46,4894,230],{"class":59},[46,4896,79],{"class":62},[46,4898,4899],{"class":62}," github.com\u002Fmark3labs\u002Fmcp-go@v0.54.0\n",[46,4901,4902,4904,4906],{"class":48,"line":56},[46,4903,230],{"class":59},[46,4905,79],{"class":62},[46,4907,4908],{"class":62}," github.com\u002Fgin-gonic\u002Fgin@v1.12.0\n",[262,4910,4912],{"id":4911},"_2-创建-mcp-服务器","2. 创建 MCP 服务器",[36,4914,4917],{"className":4915,"code":4916,"language":230,"meta":41,"style":41},"language-go shiki shiki-themes github-light github-dark","import \"github.com\u002Fmark3labs\u002Fmcp-go\u002Fserver\"\n\nmcpServer := server.NewMCPServer(\n    \"my-app\",    \u002F\u002F 服务器名称\n    \"1.0.0\",     \u002F\u002F 版本\n    server.WithLogging(),\n    server.WithRecovery(),\n    server.WithInstructions(\"这是一个 MCP 服务器示例\"),\n)\n",[43,4918,4919,4931,4935,4952,4963,4974,4985,4994,5009],{"__ignoreMap":41},[46,4920,4921,4924,4926,4929],{"class":48,"line":49},[46,4922,4923],{"class":312},"import",[46,4925,1546],{"class":62},[46,4927,4928],{"class":59},"github.com\u002Fmark3labs\u002Fmcp-go\u002Fserver",[46,4930,1742],{"class":62},[46,4932,4933],{"class":48,"line":56},[46,4934,105],{"emptyLinePlaceholder":104},[46,4936,4937,4940,4943,4946,4949],{"class":48,"line":72},[46,4938,4939],{"class":319},"mcpServer ",[46,4941,4942],{"class":312},":=",[46,4944,4945],{"class":319}," server.",[46,4947,4948],{"class":59},"NewMCPServer",[46,4950,4951],{"class":319},"(\n",[46,4953,4954,4957,4960],{"class":48,"line":85},[46,4955,4956],{"class":62},"    \"my-app\"",[46,4958,4959],{"class":319},",    ",[46,4961,4962],{"class":52},"\u002F\u002F 服务器名称\n",[46,4964,4965,4968,4971],{"class":48,"line":101},[46,4966,4967],{"class":62},"    \"1.0.0\"",[46,4969,4970],{"class":319},",     ",[46,4972,4973],{"class":52},"\u002F\u002F 版本\n",[46,4975,4976,4979,4982],{"class":48,"line":108},[46,4977,4978],{"class":319},"    server.",[46,4980,4981],{"class":59},"WithLogging",[46,4983,4984],{"class":319},"(),\n",[46,4986,4987,4989,4992],{"class":48,"line":114},[46,4988,4978],{"class":319},[46,4990,4991],{"class":59},"WithRecovery",[46,4993,4984],{"class":319},[46,4995,4996,4998,5001,5003,5006],{"class":48,"line":337},[46,4997,4978],{"class":319},[46,4999,5000],{"class":59},"WithInstructions",[46,5002,3494],{"class":319},[46,5004,5005],{"class":62},"\"这是一个 MCP 服务器示例\"",[46,5007,5008],{"class":319},"),\n",[46,5010,5011],{"class":48,"line":369},[46,5012,3803],{"class":319},[16,5014,5015,5017,5018,5021,5022,5025],{},[43,5016,4948],{}," 创建一个 MCP 协议实例，它负责处理工具发现（",[43,5019,5020],{},"tools\u002Flist","）和工具调用（",[43,5023,5024],{},"tools\u002Fcall","）的协议逻辑。",[262,5027,5029],{"id":5028},"_3-注册工具","3. 注册工具",[36,5031,5033],{"className":4915,"code":5032,"language":230,"meta":41,"style":41},"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",[43,5034,5035,5046,5050,5055,5075,5090,5094,5098,5103,5163,5168,5189],{"__ignoreMap":41},[46,5036,5037,5039,5041,5044],{"class":48,"line":49},[46,5038,4923],{"class":312},[46,5040,1546],{"class":62},[46,5042,5043],{"class":59},"github.com\u002Fmark3labs\u002Fmcp-go\u002Fmcp",[46,5045,1742],{"class":62},[46,5047,5048],{"class":48,"line":56},[46,5049,105],{"emptyLinePlaceholder":104},[46,5051,5052],{"class":48,"line":72},[46,5053,5054],{"class":52},"\u002F\u002F 定义工具\n",[46,5056,5057,5060,5062,5065,5068,5070,5073],{"class":48,"line":85},[46,5058,5059],{"class":319},"listTool ",[46,5061,4942],{"class":312},[46,5063,5064],{"class":319}," mcp.",[46,5066,5067],{"class":59},"NewTool",[46,5069,3494],{"class":319},[46,5071,5072],{"class":62},"\"list_items\"",[46,5074,645],{"class":319},[46,5076,5077,5080,5083,5085,5088],{"class":48,"line":101},[46,5078,5079],{"class":319},"    mcp.",[46,5081,5082],{"class":59},"WithDescription",[46,5084,3494],{"class":319},[46,5086,5087],{"class":62},"\"列出所有项目\"",[46,5089,5008],{"class":319},[46,5091,5092],{"class":48,"line":108},[46,5093,3803],{"class":319},[46,5095,5096],{"class":48,"line":114},[46,5097,105],{"emptyLinePlaceholder":104},[46,5099,5100],{"class":48,"line":337},[46,5101,5102],{"class":52},"\u002F\u002F 注册处理器\n",[46,5104,5105,5108,5111,5114,5117,5119,5122,5125,5127,5130,5132,5135,5137,5139,5142,5145,5148,5151,5153,5156,5158,5161],{"class":48,"line":369},[46,5106,5107],{"class":319},"mcpServer.",[46,5109,5110],{"class":59},"AddTool",[46,5112,5113],{"class":319},"(listTool, ",[46,5115,5116],{"class":312},"func",[46,5118,3494],{"class":319},[46,5120,5121],{"class":3497},"ctx",[46,5123,5124],{"class":59}," context",[46,5126,3615],{"class":319},[46,5128,5129],{"class":59},"Context",[46,5131,659],{"class":319},[46,5133,5134],{"class":3497},"req",[46,5136,119],{"class":59},[46,5138,3615],{"class":319},[46,5140,5141],{"class":59},"CallToolRequest",[46,5143,5144],{"class":319},") (",[46,5146,5147],{"class":312},"*",[46,5149,5150],{"class":59},"mcp",[46,5152,3615],{"class":319},[46,5154,5155],{"class":59},"CallToolResult",[46,5157,659],{"class":319},[46,5159,5160],{"class":312},"error",[46,5162,4744],{"class":319},[46,5164,5165],{"class":48,"line":374},[46,5166,5167],{"class":52},"    \u002F\u002F 业务逻辑\n",[46,5169,5170,5173,5175,5178,5180,5183,5186],{"class":48,"line":380},[46,5171,5172],{"class":312},"    return",[46,5174,5064],{"class":319},[46,5176,5177],{"class":59},"NewToolResultText",[46,5179,3494],{"class":319},[46,5181,5182],{"class":62},"\"返回结果\"",[46,5184,5185],{"class":319},"), ",[46,5187,5188],{"class":122},"nil\n",[46,5190,5191],{"class":48,"line":484},[46,5192,4041],{"class":319},[16,5194,5195],{},"带参数的工具：",[36,5197,5199],{"className":4915,"code":5198,"language":230,"meta":41,"style":41},"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",[43,5200,5201,5219,5232,5246,5256,5270,5275,5279,5283,5330,5349,5364,5383,5387,5392,5409],{"__ignoreMap":41},[46,5202,5203,5206,5208,5210,5212,5214,5217],{"class":48,"line":49},[46,5204,5205],{"class":319},"getTool ",[46,5207,4942],{"class":312},[46,5209,5064],{"class":319},[46,5211,5067],{"class":59},[46,5213,3494],{"class":319},[46,5215,5216],{"class":62},"\"get_item\"",[46,5218,645],{"class":319},[46,5220,5221,5223,5225,5227,5230],{"class":48,"line":56},[46,5222,5079],{"class":319},[46,5224,5082],{"class":59},[46,5226,3494],{"class":319},[46,5228,5229],{"class":62},"\"获取指定项目的详情\"",[46,5231,5008],{"class":319},[46,5233,5234,5236,5239,5241,5244],{"class":48,"line":72},[46,5235,5079],{"class":319},[46,5237,5238],{"class":59},"WithString",[46,5240,3494],{"class":319},[46,5242,5243],{"class":62},"\"item_id\"",[46,5245,645],{"class":319},[46,5247,5248,5251,5254],{"class":48,"line":85},[46,5249,5250],{"class":319},"        mcp.",[46,5252,5253],{"class":59},"Required",[46,5255,4984],{"class":319},[46,5257,5258,5260,5263,5265,5268],{"class":48,"line":101},[46,5259,5250],{"class":319},[46,5261,5262],{"class":59},"Description",[46,5264,3494],{"class":319},[46,5266,5267],{"class":62},"\"项目 ID\"",[46,5269,5008],{"class":319},[46,5271,5272],{"class":48,"line":108},[46,5273,5274],{"class":319},"    ),\n",[46,5276,5277],{"class":48,"line":114},[46,5278,3803],{"class":319},[46,5280,5281],{"class":48,"line":337},[46,5282,105],{"emptyLinePlaceholder":104},[46,5284,5285,5287,5289,5292,5294,5296,5298,5300,5302,5304,5306,5308,5310,5312,5314,5316,5318,5320,5322,5324,5326,5328],{"class":48,"line":369},[46,5286,5107],{"class":319},[46,5288,5110],{"class":59},[46,5290,5291],{"class":319},"(getTool, ",[46,5293,5116],{"class":312},[46,5295,3494],{"class":319},[46,5297,5121],{"class":3497},[46,5299,5124],{"class":59},[46,5301,3615],{"class":319},[46,5303,5129],{"class":59},[46,5305,659],{"class":319},[46,5307,5134],{"class":3497},[46,5309,119],{"class":59},[46,5311,3615],{"class":319},[46,5313,5141],{"class":59},[46,5315,5144],{"class":319},[46,5317,5147],{"class":312},[46,5319,5150],{"class":59},[46,5321,3615],{"class":319},[46,5323,5155],{"class":59},[46,5325,659],{"class":319},[46,5327,5160],{"class":312},[46,5329,4744],{"class":319},[46,5331,5332,5335,5337,5340,5343,5345,5347],{"class":48,"line":374},[46,5333,5334],{"class":319},"    itemID, err ",[46,5336,4942],{"class":312},[46,5338,5339],{"class":319}," req.",[46,5341,5342],{"class":59},"RequireString",[46,5344,3494],{"class":319},[46,5346,5243],{"class":62},[46,5348,3803],{"class":319},[46,5350,5351,5353,5356,5359,5362],{"class":48,"line":380},[46,5352,3570],{"class":312},[46,5354,5355],{"class":319}," err ",[46,5357,5358],{"class":312},"!=",[46,5360,5361],{"class":122}," nil",[46,5363,3142],{"class":319},[46,5365,5366,5369,5371,5374,5376,5379,5381],{"class":48,"line":484},[46,5367,5368],{"class":312},"        return",[46,5370,5064],{"class":319},[46,5372,5373],{"class":59},"NewToolResultError",[46,5375,3494],{"class":319},[46,5377,5378],{"class":62},"\"缺少 item_id 参数\"",[46,5380,5185],{"class":319},[46,5382,5188],{"class":122},[46,5384,5385],{"class":48,"line":498},[46,5386,670],{"class":319},[46,5388,5389],{"class":48,"line":503},[46,5390,5391],{"class":52},"    \u002F\u002F 查询并返回\n",[46,5393,5394,5396,5398,5400,5402,5405,5407],{"class":48,"line":509},[46,5395,5172],{"class":312},[46,5397,5064],{"class":319},[46,5399,5177],{"class":59},[46,5401,3494],{"class":319},[46,5403,5404],{"class":62},"\"项目详情...\"",[46,5406,5185],{"class":319},[46,5408,5188],{"class":122},[46,5410,5411],{"class":48,"line":1711},[46,5412,4041],{"class":319},[262,5414,5416],{"id":5415},"_4-挂载到-gin-路由","4. 挂载到 Gin 路由",[16,5418,5419,5420,5423,5424,5427],{},"关键一步：用 ",[43,5421,5422],{},"server.NewStreamableHTTPServer"," 把 MCP 实例转成 HTTP Handler，再用 ",[43,5425,5426],{},"gin.WrapH"," 适配 Gin。",[36,5429,5431],{"className":4915,"code":5430,"language":230,"meta":41,"style":41},"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",[43,5432,5433,5443,5447,5462,5476,5493,5497,5501,5506],{"__ignoreMap":41},[46,5434,5435,5437,5439,5441],{"class":48,"line":49},[46,5436,4923],{"class":312},[46,5438,1546],{"class":62},[46,5440,4928],{"class":59},[46,5442,1742],{"class":62},[46,5444,5445],{"class":48,"line":56},[46,5446,105],{"emptyLinePlaceholder":104},[46,5448,5449,5452,5454,5456,5459],{"class":48,"line":72},[46,5450,5451],{"class":319},"mcpHTTPServer ",[46,5453,4942],{"class":312},[46,5455,4945],{"class":319},[46,5457,5458],{"class":59},"NewStreamableHTTPServer",[46,5460,5461],{"class":319},"(mcpServer,\n",[46,5463,5464,5466,5469,5471,5474],{"class":48,"line":85},[46,5465,4978],{"class":319},[46,5467,5468],{"class":59},"WithEndpointPath",[46,5470,3494],{"class":319},[46,5472,5473],{"class":62},"\"\u002Fmcp\"",[46,5475,5008],{"class":319},[46,5477,5478,5480,5483,5485,5488,5490],{"class":48,"line":101},[46,5479,4978],{"class":319},[46,5481,5482],{"class":59},"WithHeartbeatInterval",[46,5484,3494],{"class":319},[46,5486,5487],{"class":122},"30",[46,5489,5147],{"class":312},[46,5491,5492],{"class":319},"time.Second),\n",[46,5494,5495],{"class":48,"line":108},[46,5496,3803],{"class":319},[46,5498,5499],{"class":48,"line":114},[46,5500,105],{"emptyLinePlaceholder":104},[46,5502,5503],{"class":48,"line":337},[46,5504,5505],{"class":52},"\u002F\u002F 挂载到 Gin\n",[46,5507,5508,5511,5514,5516,5518,5521,5524],{"class":48,"line":369},[46,5509,5510],{"class":319},"r.",[46,5512,5513],{"class":59},"Any",[46,5515,3494],{"class":319},[46,5517,5473],{"class":62},[46,5519,5520],{"class":319},", gin.",[46,5522,5523],{"class":59},"WrapH",[46,5525,5526],{"class":319},"(mcpHTTPServer))\n",[16,5528,5529,5530,5533],{},"就这样，",[43,5531,5532],{},"POST \u002Fmcp"," 端点就生效了。AI 客户端通过这个 URL 发送 JSON-RPC 请求，MCP 服务器自动处理协议握手、工具列表返回、工具调用路由。",[262,5535,5537],{"id":5536},"_5-完整的服务启动代码","5. 完整的服务启动代码",[36,5539,5541],{"className":4915,"code":5540,"language":230,"meta":41,"style":41},"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",[43,5542,5543,5552,5567,5571,5576,5599,5608,5616,5621,5625,5630,5638,5642,5647,5660,5672,5686,5690,5708,5712,5717,5750,5785,5790,5794,5808],{"__ignoreMap":41},[46,5544,5545,5547,5550],{"class":48,"line":49},[46,5546,5116],{"class":312},[46,5548,5549],{"class":59}," main",[46,5551,2790],{"class":319},[46,5553,5554,5557,5559,5562,5565],{"class":48,"line":56},[46,5555,5556],{"class":319},"    r ",[46,5558,4942],{"class":312},[46,5560,5561],{"class":319}," gin.",[46,5563,5564],{"class":59},"Default",[46,5566,3688],{"class":319},[46,5568,5569],{"class":48,"line":72},[46,5570,105],{"emptyLinePlaceholder":104},[46,5572,5573],{"class":48,"line":85},[46,5574,5575],{"class":52},"    \u002F\u002F 创建 MCP 服务器\n",[46,5577,5578,5581,5583,5585,5587,5589,5592,5594,5597],{"class":48,"line":101},[46,5579,5580],{"class":319},"    mcpServer ",[46,5582,4942],{"class":312},[46,5584,4945],{"class":319},[46,5586,4948],{"class":59},[46,5588,3494],{"class":319},[46,5590,5591],{"class":62},"\"my-app\"",[46,5593,659],{"class":319},[46,5595,5596],{"class":62},"\"1.0.0\"",[46,5598,645],{"class":319},[46,5600,5601,5604,5606],{"class":48,"line":108},[46,5602,5603],{"class":319},"        server.",[46,5605,4981],{"class":59},[46,5607,4984],{"class":319},[46,5609,5610,5612,5614],{"class":48,"line":114},[46,5611,5603],{"class":319},[46,5613,4991],{"class":59},[46,5615,4984],{"class":319},[46,5617,5618],{"class":48,"line":337},[46,5619,5620],{"class":319},"    )\n",[46,5622,5623],{"class":48,"line":369},[46,5624,105],{"emptyLinePlaceholder":104},[46,5626,5627],{"class":48,"line":374},[46,5628,5629],{"class":52},"    \u002F\u002F 注册工具\n",[46,5631,5632,5635],{"class":48,"line":380},[46,5633,5634],{"class":59},"    registerTools",[46,5636,5637],{"class":319},"(mcpServer)\n",[46,5639,5640],{"class":48,"line":484},[46,5641,105],{"emptyLinePlaceholder":104},[46,5643,5644],{"class":48,"line":498},[46,5645,5646],{"class":52},"    \u002F\u002F 挂载到 Gin\n",[46,5648,5649,5652,5654,5656,5658],{"class":48,"line":503},[46,5650,5651],{"class":319},"    mcpHTTP ",[46,5653,4942],{"class":312},[46,5655,4945],{"class":319},[46,5657,5458],{"class":59},[46,5659,5461],{"class":319},[46,5661,5662,5664,5666,5668,5670],{"class":48,"line":509},[46,5663,5603],{"class":319},[46,5665,5468],{"class":59},[46,5667,3494],{"class":319},[46,5669,5473],{"class":62},[46,5671,5008],{"class":319},[46,5673,5674,5676,5678,5680,5682,5684],{"class":48,"line":1711},[46,5675,5603],{"class":319},[46,5677,5482],{"class":59},[46,5679,3494],{"class":319},[46,5681,5487],{"class":122},[46,5683,5147],{"class":312},[46,5685,5492],{"class":319},[46,5687,5688],{"class":48,"line":1716},[46,5689,5620],{"class":319},[46,5691,5692,5695,5697,5699,5701,5703,5705],{"class":48,"line":1725},[46,5693,5694],{"class":319},"    r.",[46,5696,5513],{"class":59},[46,5698,3494],{"class":319},[46,5700,5473],{"class":62},[46,5702,5520],{"class":319},[46,5704,5523],{"class":59},[46,5706,5707],{"class":319},"(mcpHTTP))\n",[46,5709,5710],{"class":48,"line":1730},[46,5711,105],{"emptyLinePlaceholder":104},[46,5713,5714],{"class":48,"line":1938},[46,5715,5716],{"class":52},"    \u002F\u002F 其他业务路由\n",[46,5718,5719,5721,5724,5726,5729,5731,5733,5735,5738,5741,5744,5746,5748],{"class":48,"line":1946},[46,5720,5694],{"class":319},[46,5722,5723],{"class":59},"GET",[46,5725,3494],{"class":319},[46,5727,5728],{"class":62},"\"\u002Fapi\u002Fhealth\"",[46,5730,659],{"class":319},[46,5732,5116],{"class":312},[46,5734,3494],{"class":319},[46,5736,5737],{"class":3497},"c",[46,5739,5740],{"class":312}," *",[46,5742,5743],{"class":59},"gin",[46,5745,3615],{"class":319},[46,5747,5129],{"class":59},[46,5749,4744],{"class":319},[46,5751,5752,5755,5758,5760,5763,5765,5767,5769,5772,5775,5778,5780,5783],{"class":48,"line":1951},[46,5753,5754],{"class":319},"        c.",[46,5756,5757],{"class":59},"JSON",[46,5759,3494],{"class":319},[46,5761,5762],{"class":122},"200",[46,5764,659],{"class":319},[46,5766,5743],{"class":59},[46,5768,3615],{"class":319},[46,5770,5771],{"class":59},"H",[46,5773,5774],{"class":319},"{",[46,5776,5777],{"class":62},"\"status\"",[46,5779,639],{"class":319},[46,5781,5782],{"class":62},"\"ok\"",[46,5784,4041],{"class":319},[46,5786,5787],{"class":48,"line":2575},[46,5788,5789],{"class":319},"    })\n",[46,5791,5792],{"class":48,"line":2589},[46,5793,105],{"emptyLinePlaceholder":104},[46,5795,5796,5798,5801,5803,5806],{"class":48,"line":2596},[46,5797,5694],{"class":319},[46,5799,5800],{"class":59},"Run",[46,5802,3494],{"class":319},[46,5804,5805],{"class":62},"\":8080\"",[46,5807,3803],{"class":319},[46,5809,5810],{"class":48,"line":2601},[46,5811,680],{"class":319},[16,5813,5814],{},"此时启动服务，用 curl 测试：",[36,5816,5818],{"className":38,"code":5817,"language":40,"meta":41,"style":41},"# 发现工具列表\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",[43,5819,5820,5825,5842,5852,5860,5864,5869,5881,5889],{"__ignoreMap":41},[46,5821,5822],{"class":48,"line":49},[46,5823,5824],{"class":52},"# 发现工具列表\n",[46,5826,5827,5830,5833,5836,5839],{"class":48,"line":56},[46,5828,5829],{"class":59},"curl",[46,5831,5832],{"class":122}," -X",[46,5834,5835],{"class":62}," POST",[46,5837,5838],{"class":62}," http:\u002F\u002Flocalhost:8080\u002Fmcp",[46,5840,5841],{"class":122}," \\\n",[46,5843,5844,5847,5850],{"class":48,"line":72},[46,5845,5846],{"class":122},"  -H",[46,5848,5849],{"class":62}," \"Content-Type: application\u002Fjson\"",[46,5851,5841],{"class":122},[46,5853,5854,5857],{"class":48,"line":85},[46,5855,5856],{"class":122},"  -d",[46,5858,5859],{"class":62}," '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools\u002Flist\"}'\n",[46,5861,5862],{"class":48,"line":101},[46,5863,105],{"emptyLinePlaceholder":104},[46,5865,5866],{"class":48,"line":108},[46,5867,5868],{"class":52},"# 调用工具\n",[46,5870,5871,5873,5875,5877,5879],{"class":48,"line":114},[46,5872,5829],{"class":59},[46,5874,5832],{"class":122},[46,5876,5835],{"class":62},[46,5878,5838],{"class":62},[46,5880,5841],{"class":122},[46,5882,5883,5885,5887],{"class":48,"line":337},[46,5884,5846],{"class":122},[46,5886,5849],{"class":62},[46,5888,5841],{"class":122},[46,5890,5891,5893],{"class":48,"line":369},[46,5892,5856],{"class":122},[46,5894,5895],{"class":62}," '{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools\u002Fcall\",\"params\":{\"name\":\"list_items\",\"arguments\":{}}}'\n",[11,5897,5899],{"id":5898},"拓展接入鉴权","拓展：接入鉴权",[16,5901,5902],{},"裸奔的 MCP 端点任何人都能调用，生产环境必须加鉴权。思路和普通 API 鉴权一致：Bearer Token。",[262,5904,4468],{"id":4468},[16,5906,5907,5908,5911,5912,5915],{},"MCP 的工具处理器签名为 ",[43,5909,5910],{},"func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error)","，它不接收 Gin 的 ",[43,5913,5914],{},"*gin.Context","，所以无法直接在工具处理器里读取请求头。",[262,5917,5919],{"id":5918},"解决方案中间件传递用户信息","解决方案：中间件传递用户信息",[16,5921,5922,5923,3388],{},"用两层中间件：第一层做 Gin 的 Token 验证，第二层把用户信息从 Gin Context 复制到 ",[43,5924,5925],{},"request.Context",[36,5927,5929],{"className":4915,"code":5928,"language":230,"meta":41,"style":41},"\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",[43,5930,5931,5936,5964,5985,6005,6021,6054,6059,6064,6068,6093,6133,6162,6166,6170,6174,6194,6206,6235,6239,6243,6247,6262,6276,6285,6289],{"__ignoreMap":41},[46,5932,5933],{"class":48,"line":49},[46,5934,5935],{"class":52},"\u002F\u002F Gin 鉴权中间件\n",[46,5937,5938,5940,5943,5945,5948,5950,5953,5955,5957,5959,5962],{"class":48,"line":56},[46,5939,5116],{"class":312},[46,5941,5942],{"class":59}," AuthRequired",[46,5944,3494],{"class":319},[46,5946,5947],{"class":3497},"authService",[46,5949,5740],{"class":312},[46,5951,5952],{"class":59},"AuthService",[46,5954,3957],{"class":319},[46,5956,5743],{"class":59},[46,5958,3615],{"class":319},[46,5960,5961],{"class":59},"HandlerFunc",[46,5963,3142],{"class":319},[46,5965,5966,5968,5971,5973,5975,5977,5979,5981,5983],{"class":48,"line":72},[46,5967,5172],{"class":312},[46,5969,5970],{"class":312}," func",[46,5972,3494],{"class":319},[46,5974,5737],{"class":3497},[46,5976,5740],{"class":312},[46,5978,5743],{"class":59},[46,5980,3615],{"class":319},[46,5982,5129],{"class":59},[46,5984,4744],{"class":319},[46,5986,5987,5990,5992,5995,5998,6000,6003],{"class":48,"line":85},[46,5988,5989],{"class":319},"        authHeader ",[46,5991,4942],{"class":312},[46,5993,5994],{"class":319}," c.",[46,5996,5997],{"class":59},"GetHeader",[46,5999,3494],{"class":319},[46,6001,6002],{"class":62},"\"Authorization\"",[46,6004,3803],{"class":319},[46,6006,6007,6010,6013,6016,6019],{"class":48,"line":101},[46,6008,6009],{"class":312},"        if",[46,6011,6012],{"class":319}," authHeader ",[46,6014,6015],{"class":312},"==",[46,6017,6018],{"class":62}," \"\"",[46,6020,3142],{"class":319},[46,6022,6023,6026,6029,6031,6034,6036,6038,6040,6042,6044,6047,6049,6052],{"class":48,"line":108},[46,6024,6025],{"class":319},"            c.",[46,6027,6028],{"class":59},"AbortWithStatusJSON",[46,6030,3494],{"class":319},[46,6032,6033],{"class":122},"401",[46,6035,659],{"class":319},[46,6037,5743],{"class":59},[46,6039,3615],{"class":319},[46,6041,5771],{"class":59},[46,6043,5774],{"class":319},[46,6045,6046],{"class":62},"\"message\"",[46,6048,639],{"class":319},[46,6050,6051],{"class":62},"\"缺少认证令牌\"",[46,6053,4041],{"class":319},[46,6055,6056],{"class":48,"line":114},[46,6057,6058],{"class":312},"            return\n",[46,6060,6061],{"class":48,"line":337},[46,6062,6063],{"class":319},"        }\n",[46,6065,6066],{"class":48,"line":369},[46,6067,105],{"emptyLinePlaceholder":104},[46,6069,6070,6073,6075,6078,6081,6084,6087,6089,6091],{"class":48,"line":374},[46,6071,6072],{"class":319},"        parts ",[46,6074,4942],{"class":312},[46,6076,6077],{"class":319}," strings.",[46,6079,6080],{"class":59},"SplitN",[46,6082,6083],{"class":319},"(authHeader, ",[46,6085,6086],{"class":62},"\" \"",[46,6088,659],{"class":319},[46,6090,3679],{"class":122},[46,6092,3803],{"class":319},[46,6094,6095,6097,6100,6103,6105,6108,6111,6114,6117,6120,6123,6125,6128,6131],{"class":48,"line":380},[46,6096,6009],{"class":312},[46,6098,6099],{"class":59}," len",[46,6101,6102],{"class":319},"(parts) ",[46,6104,5358],{"class":312},[46,6106,6107],{"class":122}," 2",[46,6109,6110],{"class":312}," ||",[46,6112,6113],{"class":312}," !",[46,6115,6116],{"class":319},"strings.",[46,6118,6119],{"class":59},"EqualFold",[46,6121,6122],{"class":319},"(parts[",[46,6124,3581],{"class":122},[46,6126,6127],{"class":319},"], ",[46,6129,6130],{"class":62},"\"Bearer\"",[46,6132,4744],{"class":319},[46,6134,6135,6137,6139,6141,6143,6145,6147,6149,6151,6153,6155,6157,6160],{"class":48,"line":484},[46,6136,6025],{"class":319},[46,6138,6028],{"class":59},[46,6140,3494],{"class":319},[46,6142,6033],{"class":122},[46,6144,659],{"class":319},[46,6146,5743],{"class":59},[46,6148,3615],{"class":319},[46,6150,5771],{"class":59},[46,6152,5774],{"class":319},[46,6154,6046],{"class":62},[46,6156,639],{"class":319},[46,6158,6159],{"class":62},"\"格式错误，请使用 Bearer \u003Ctoken>\"",[46,6161,4041],{"class":319},[46,6163,6164],{"class":48,"line":498},[46,6165,6058],{"class":312},[46,6167,6168],{"class":48,"line":503},[46,6169,6063],{"class":319},[46,6171,6172],{"class":48,"line":509},[46,6173,105],{"emptyLinePlaceholder":104},[46,6175,6176,6179,6181,6184,6187,6189,6191],{"class":48,"line":1711},[46,6177,6178],{"class":319},"        user, err ",[46,6180,4942],{"class":312},[46,6182,6183],{"class":319}," authService.",[46,6185,6186],{"class":59},"GetUserByToken",[46,6188,6122],{"class":319},[46,6190,1629],{"class":122},[46,6192,6193],{"class":319},"])\n",[46,6195,6196,6198,6200,6202,6204],{"class":48,"line":1716},[46,6197,6009],{"class":312},[46,6199,5355],{"class":319},[46,6201,5358],{"class":312},[46,6203,5361],{"class":122},[46,6205,3142],{"class":319},[46,6207,6208,6210,6212,6214,6216,6218,6220,6222,6224,6226,6228,6230,6233],{"class":48,"line":1725},[46,6209,6025],{"class":319},[46,6211,6028],{"class":59},[46,6213,3494],{"class":319},[46,6215,6033],{"class":122},[46,6217,659],{"class":319},[46,6219,5743],{"class":59},[46,6221,3615],{"class":319},[46,6223,5771],{"class":59},[46,6225,5774],{"class":319},[46,6227,6046],{"class":62},[46,6229,639],{"class":319},[46,6231,6232],{"class":62},"\"无效的认证令牌\"",[46,6234,4041],{"class":319},[46,6236,6237],{"class":48,"line":1730},[46,6238,6058],{"class":312},[46,6240,6241],{"class":48,"line":1938},[46,6242,6063],{"class":319},[46,6244,6245],{"class":48,"line":1946},[46,6246,105],{"emptyLinePlaceholder":104},[46,6248,6249,6251,6254,6256,6259],{"class":48,"line":1951},[46,6250,5754],{"class":319},[46,6252,6253],{"class":59},"Set",[46,6255,3494],{"class":319},[46,6257,6258],{"class":62},"\"userID\"",[46,6260,6261],{"class":319},", user.ID)\n",[46,6263,6264,6266,6268,6270,6273],{"class":48,"line":2575},[46,6265,5754],{"class":319},[46,6267,6253],{"class":59},[46,6269,3494],{"class":319},[46,6271,6272],{"class":62},"\"username\"",[46,6274,6275],{"class":319},", user.Username)\n",[46,6277,6278,6280,6283],{"class":48,"line":2589},[46,6279,5754],{"class":319},[46,6281,6282],{"class":59},"Next",[46,6284,3688],{"class":319},[46,6286,6287],{"class":48,"line":2596},[46,6288,670],{"class":319},[46,6290,6291],{"class":48,"line":2601},[46,6292,680],{"class":319},[36,6294,6296],{"className":4915,"code":6295,"language":230,"meta":41,"style":41},"\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",[43,6297,6298,6303,6314,6321,6334,6346,6350,6354,6372,6392,6406,6430,6452,6467,6475,6479],{"__ignoreMap":41},[46,6299,6300],{"class":48,"line":49},[46,6301,6302],{"class":52},"\u002F\u002F 用户信息桥接中间件：Gin Context → request.Context\n",[46,6304,6305,6308,6311],{"class":48,"line":56},[46,6306,6307],{"class":312},"type",[46,6309,6310],{"class":59}," ctxKey",[46,6312,6313],{"class":312}," string\n",[46,6315,6316,6318],{"class":48,"line":72},[46,6317,3727],{"class":312},[46,6319,6320],{"class":319}," (\n",[46,6322,6323,6326,6329,6331],{"class":48,"line":85},[46,6324,6325],{"class":122},"    ctxUserID",[46,6327,6328],{"class":59},"   ctxKey",[46,6330,3139],{"class":312},[46,6332,6333],{"class":62}," \"userID\"\n",[46,6335,6336,6339,6341,6343],{"class":48,"line":101},[46,6337,6338],{"class":122},"    ctxUsername",[46,6340,6310],{"class":59},[46,6342,3139],{"class":312},[46,6344,6345],{"class":62}," \"username\"\n",[46,6347,6348],{"class":48,"line":108},[46,6349,3803],{"class":319},[46,6351,6352],{"class":48,"line":114},[46,6353,105],{"emptyLinePlaceholder":104},[46,6355,6356,6358,6361,6364,6366,6368,6370],{"class":48,"line":337},[46,6357,5116],{"class":312},[46,6359,6360],{"class":59}," WithUserContext",[46,6362,6363],{"class":319},"() ",[46,6365,5743],{"class":59},[46,6367,3615],{"class":319},[46,6369,5961],{"class":59},[46,6371,3142],{"class":319},[46,6373,6374,6376,6378,6380,6382,6384,6386,6388,6390],{"class":48,"line":369},[46,6375,5172],{"class":312},[46,6377,5970],{"class":312},[46,6379,3494],{"class":319},[46,6381,5737],{"class":3497},[46,6383,5740],{"class":312},[46,6385,5743],{"class":59},[46,6387,3615],{"class":319},[46,6389,5129],{"class":59},[46,6391,4744],{"class":319},[46,6393,6394,6397,6399,6402,6404],{"class":48,"line":374},[46,6395,6396],{"class":319},"        ctx ",[46,6398,4942],{"class":312},[46,6400,6401],{"class":319}," c.Request.",[46,6403,5129],{"class":59},[46,6405,3688],{"class":319},[46,6407,6408,6410,6412,6415,6418,6421,6424,6426,6428],{"class":48,"line":380},[46,6409,6396],{"class":319},[46,6411,1611],{"class":312},[46,6413,6414],{"class":319}," context.",[46,6416,6417],{"class":59},"WithValue",[46,6419,6420],{"class":319},"(ctx, ctxUserID, c.",[46,6422,6423],{"class":59},"GetUint",[46,6425,3494],{"class":319},[46,6427,6258],{"class":62},[46,6429,3656],{"class":319},[46,6431,6432,6434,6436,6438,6440,6443,6446,6448,6450],{"class":48,"line":484},[46,6433,6396],{"class":319},[46,6435,1611],{"class":312},[46,6437,6414],{"class":319},[46,6439,6417],{"class":59},[46,6441,6442],{"class":319},"(ctx, ctxUsername, c.",[46,6444,6445],{"class":59},"GetString",[46,6447,3494],{"class":319},[46,6449,6272],{"class":62},[46,6451,3656],{"class":319},[46,6453,6454,6457,6459,6461,6464],{"class":48,"line":498},[46,6455,6456],{"class":319},"        c.Request ",[46,6458,1611],{"class":312},[46,6460,6401],{"class":319},[46,6462,6463],{"class":59},"WithContext",[46,6465,6466],{"class":319},"(ctx)\n",[46,6468,6469,6471,6473],{"class":48,"line":503},[46,6470,5754],{"class":319},[46,6472,6282],{"class":59},[46,6474,3688],{"class":319},[46,6476,6477],{"class":48,"line":509},[46,6478,670],{"class":319},[46,6480,6481],{"class":48,"line":1711},[46,6482,680],{"class":319},[262,6484,6485],{"id":6485},"组合使用",[36,6487,6489],{"className":4915,"code":6488,"language":230,"meta":41,"style":41},"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",[43,6490,6491,6503,6514,6525,6538],{"__ignoreMap":41},[46,6492,6493,6495,6497,6499,6501],{"class":48,"line":49},[46,6494,5510],{"class":319},[46,6496,5513],{"class":59},[46,6498,3494],{"class":319},[46,6500,5473],{"class":62},[46,6502,645],{"class":319},[46,6504,6505,6508,6511],{"class":48,"line":56},[46,6506,6507],{"class":59},"    AuthRequired",[46,6509,6510],{"class":319},"(authService),  ",[46,6512,6513],{"class":52},"\u002F\u002F 1. 验证 Token\n",[46,6515,6516,6519,6522],{"class":48,"line":72},[46,6517,6518],{"class":59},"    WithUserContext",[46,6520,6521],{"class":319},"(),           ",[46,6523,6524],{"class":52},"\u002F\u002F 2. 桥接用户信息到 request.Context\n",[46,6526,6527,6530,6532,6535],{"class":48,"line":85},[46,6528,6529],{"class":319},"    gin.",[46,6531,5523],{"class":59},[46,6533,6534],{"class":319},"(mcpHTTP),         ",[46,6536,6537],{"class":52},"\u002F\u002F 3. MCP 处理\n",[46,6539,6540],{"class":48,"line":101},[46,6541,3803],{"class":319},[16,6543,6544],{},"在工具处理器里就能拿到用户信息了：",[36,6546,6548],{"className":4915,"code":6547,"language":230,"meta":41,"style":41},"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",[43,6549,6550,6605,6626,6635,6652,6656,6660,6676,6681,6694],{"__ignoreMap":41},[46,6551,6552,6554,6556,6559,6561,6564,6566,6569,6571,6573,6575,6577,6579,6581,6583,6585,6587,6589,6591,6593,6595,6597,6599,6601,6603],{"class":48,"line":49},[46,6553,5116],{"class":312},[46,6555,3536],{"class":319},[46,6557,6558],{"class":3497},"h ",[46,6560,5147],{"class":312},[46,6562,6563],{"class":59},"Handler",[46,6565,3957],{"class":319},[46,6567,6568],{"class":59},"handleListItems",[46,6570,3494],{"class":319},[46,6572,5121],{"class":3497},[46,6574,5124],{"class":59},[46,6576,3615],{"class":319},[46,6578,5129],{"class":59},[46,6580,659],{"class":319},[46,6582,5134],{"class":3497},[46,6584,119],{"class":59},[46,6586,3615],{"class":319},[46,6588,5141],{"class":59},[46,6590,5144],{"class":319},[46,6592,5147],{"class":312},[46,6594,5150],{"class":59},[46,6596,3615],{"class":319},[46,6598,5155],{"class":59},[46,6600,659],{"class":319},[46,6602,5160],{"class":312},[46,6604,4744],{"class":319},[46,6606,6607,6610,6612,6615,6618,6621,6624],{"class":48,"line":56},[46,6608,6609],{"class":319},"    userID, ok ",[46,6611,4942],{"class":312},[46,6613,6614],{"class":319}," ctx.",[46,6616,6617],{"class":59},"Value",[46,6619,6620],{"class":319},"(ctxUserID).(",[46,6622,6623],{"class":312},"uint",[46,6625,3803],{"class":319},[46,6627,6628,6630,6632],{"class":48,"line":72},[46,6629,3570],{"class":312},[46,6631,6113],{"class":312},[46,6633,6634],{"class":319},"ok {\n",[46,6636,6637,6639,6641,6643,6645,6648,6650],{"class":48,"line":85},[46,6638,5368],{"class":312},[46,6640,5064],{"class":319},[46,6642,5373],{"class":59},[46,6644,3494],{"class":319},[46,6646,6647],{"class":62},"\"未认证\"",[46,6649,5185],{"class":319},[46,6651,5188],{"class":122},[46,6653,6654],{"class":48,"line":101},[46,6655,670],{"class":319},[46,6657,6658],{"class":48,"line":108},[46,6659,105],{"emptyLinePlaceholder":104},[46,6661,6662,6665,6667,6670,6673],{"class":48,"line":114},[46,6663,6664],{"class":319},"    items, _ ",[46,6666,4942],{"class":312},[46,6668,6669],{"class":319}," h.service.",[46,6671,6672],{"class":59},"ListByUser",[46,6674,6675],{"class":319},"(userID)\n",[46,6677,6678],{"class":48,"line":337},[46,6679,6680],{"class":52},"    \u002F\u002F ...\n",[46,6682,6683,6685,6687,6689,6692],{"class":48,"line":369},[46,6684,5172],{"class":312},[46,6686,5064],{"class":319},[46,6688,5177],{"class":59},[46,6690,6691],{"class":319},"(result), ",[46,6693,5188],{"class":122},[46,6695,6696],{"class":48,"line":374},[46,6697,680],{"class":319},[262,6699,6700],{"id":6700},"客户端配置",[16,6702,6703],{},"AI 客户端（如 Claude Desktop）配置 MCP 服务器时带上 Authorization 头：",[36,6705,6707],{"className":607,"code":6706,"language":609,"meta":41,"style":41},"{\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",[43,6708,6709,6713,6719,6725,6737,6744,6754,6759,6763,6767],{"__ignoreMap":41},[46,6710,6711],{"class":48,"line":49},[46,6712,616],{"class":319},[46,6714,6715,6717],{"class":48,"line":56},[46,6716,621],{"class":122},[46,6718,624],{"class":319},[46,6720,6721,6723],{"class":48,"line":72},[46,6722,4956],{"class":122},[46,6724,624],{"class":319},[46,6726,6727,6730,6732,6735],{"class":48,"line":85},[46,6728,6729],{"class":122},"      \"url\"",[46,6731,639],{"class":319},[46,6733,6734],{"class":62},"\"http:\u002F\u002Flocalhost:8080\u002Fmcp\"",[46,6736,645],{"class":319},[46,6738,6739,6742],{"class":48,"line":101},[46,6740,6741],{"class":122},"      \"headers\"",[46,6743,624],{"class":319},[46,6745,6746,6749,6751],{"class":48,"line":108},[46,6747,6748],{"class":122},"        \"Authorization\"",[46,6750,639],{"class":319},[46,6752,6753],{"class":62},"\"Bearer your-token-here\"\n",[46,6755,6756],{"class":48,"line":114},[46,6757,6758],{"class":319},"      }\n",[46,6760,6761],{"class":48,"line":337},[46,6762,670],{"class":319},[46,6764,6765],{"class":48,"line":369},[46,6766,675],{"class":319},[46,6768,6769],{"class":48,"line":374},[46,6770,680],{"class":319},[11,6772,1430],{"id":1430},[131,6774,6775,6785],{},[134,6776,6777],{},[137,6778,6779,6782],{},[140,6780,6781],{},"步骤",[140,6783,6784],{},"说明",[147,6786,6787,6796,6807,6816,6825],{},[137,6788,6789,6793],{},[152,6790,6791],{},[43,6792,4948],{},[152,6794,6795],{},"创建 MCP 协议实例",[137,6797,6798,6804],{},[152,6799,6800,2680,6802],{},[43,6801,5067],{},[43,6803,5110],{},[152,6805,6806],{},"定义并注册工具",[137,6808,6809,6813],{},[152,6810,6811],{},[43,6812,5458],{},[152,6814,6815],{},"转为 HTTP Handler",[137,6817,6818,6822],{},[152,6819,6820],{},[43,6821,5426],{},[152,6823,6824],{},"挂载到 Gin 路由",[137,6826,6827,6835],{},[152,6828,6829,2680,6832],{},[43,6830,6831],{},"AuthRequired",[43,6833,6834],{},"WithUserContext",[152,6836,6837],{},"鉴权 + 用户信息桥接",[16,6839,6840,6841,6844,6845,3388],{},"核心就一句话：",[27,6842,6843],{},"MCP 是 HTTP Handler，挂上去就行。"," 鉴权复用你已有的中间件体系，只需要多写一个桥接函数把用户信息传进 ",[43,6846,5925],{},[1460,6848,6849],{},"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":41,"searchDepth":56,"depth":72,"links":6851},[6852,6853,6854,6855,6862,6868],{"id":4828,"depth":56,"text":4829},{"id":4838,"depth":56,"text":4839},{"id":4855,"depth":56,"text":4855},{"id":4881,"depth":56,"text":4881,"children":6856},[6857,6858,6859,6860,6861],{"id":4884,"depth":72,"text":4885},{"id":4911,"depth":72,"text":4912},{"id":5028,"depth":72,"text":5029},{"id":5415,"depth":72,"text":5416},{"id":5536,"depth":72,"text":5537},{"id":5898,"depth":56,"text":5899,"children":6863},[6864,6865,6866,6867],{"id":4468,"depth":72,"text":4468},{"id":5918,"depth":72,"text":5919},{"id":6485,"depth":72,"text":6485},{"id":6700,"depth":72,"text":6700},{"id":1430,"depth":56,"text":1430},"介绍如何使用 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":4823,"description":6869},"Go 工程实践","posts\u002Fgo-mcp-gin\u002Findex",[1498,1497,6878,1499,6879],"Gin","后端","2026-06-01","5fZl1Vigs2lHb5fHPz-0YZFyFD38ws1heOouZUG1g6M",{"id":6883,"title":6884,"author":6,"body":6885,"description":7647,"difficulty":6,"draft":1486,"extension":1487,"image":7648,"meta":7649,"navigation":104,"path":7650,"pinned":1486,"published":7651,"seo":7652,"series":6,"seriesOrder":6,"stem":7653,"tags":7654,"updated":6,"__hash__":7656},"posts\u002Fposts\u002Fdws-dingtalk-cli\u002Findex.md","DWS — 钉钉官方出品的 AI 友好命令行工具",{"type":8,"value":6886,"toc":7633},[6887,6891,6894,6901,7025,7027,7030,7086,7089,7103,7106,7109,7116,7348,7355,7359,7362,7382,7385,7400,7403,7423,7426,7429,7436,7502,7505,7507,7511,7514,7566,7570,7591,7595,7598,7612,7615,7619,7622,7624,7627,7630],[11,6888,6890],{"id":6889},"dws-是什么","dws 是什么",[16,6892,6893],{},"dws（DingTalk Workspace CLI）是阿里巴巴 2026 年开源的钉钉命令行工具，Apache-2.0 协议，Go 语言编写。",[16,6895,6896,6897,6900],{},"它的定位很明确：",[27,6898,6899],{},"把钉钉的所有产品能力暴露给 AI Agent 和命令行用户","。不用打开钉钉 App，不用写 API 调用代码，一条命令搞定。",[36,6902,6904],{"className":38,"code":6903,"language":40,"meta":41,"style":41},"# 查看今天有什么会\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",[43,6905,6906,6911,6936,6940,6945,6967,6971,6976,7000,7004,7009],{"__ignoreMap":41},[46,6907,6908],{"class":48,"line":49},[46,6909,6910],{"class":52},"# 查看今天有什么会\n",[46,6912,6913,6916,6919,6922,6924,6927,6930,6933],{"class":48,"line":56},[46,6914,6915],{"class":59},"dws",[46,6917,6918],{"class":62}," calendar",[46,6920,6921],{"class":62}," event",[46,6923,1077],{"class":62},[46,6925,6926],{"class":122}," --start",[46,6928,6929],{"class":62}," 2026-06-01",[46,6931,6932],{"class":122}," --end",[46,6934,6935],{"class":62}," 2026-06-01\n",[46,6937,6938],{"class":48,"line":72},[46,6939,105],{"emptyLinePlaceholder":104},[46,6941,6942],{"class":48,"line":85},[46,6943,6944],{"class":52},"# 给群里发条消息\n",[46,6946,6947,6949,6952,6955,6958,6961,6964],{"class":48,"line":101},[46,6948,6915],{"class":59},[46,6950,6951],{"class":62}," chat",[46,6953,6954],{"class":62}," send",[46,6956,6957],{"class":122}," --group-id",[46,6959,6960],{"class":62}," xxx",[46,6962,6963],{"class":122}," --content",[46,6965,6966],{"class":62}," \"今天发布推迟到下午3点\"\n",[46,6968,6969],{"class":48,"line":108},[46,6970,105],{"emptyLinePlaceholder":104},[46,6972,6973],{"class":48,"line":114},[46,6974,6975],{"class":52},"# 创建一个待办\n",[46,6977,6978,6980,6983,6986,6988,6991,6994,6997],{"class":48,"line":337},[46,6979,6915],{"class":59},[46,6981,6982],{"class":62}," todo",[46,6984,6985],{"class":62}," task",[46,6987,516],{"class":62},[46,6989,6990],{"class":122}," --subject",[46,6992,6993],{"class":62}," \"Review PR #123\"",[46,6995,6996],{"class":122}," --due-date",[46,6998,6999],{"class":62}," 2026-06-02\n",[46,7001,7002],{"class":48,"line":369},[46,7003,105],{"emptyLinePlaceholder":104},[46,7005,7006],{"class":48,"line":374},[46,7007,7008],{"class":52},"# 读取钉钉文档\n",[46,7010,7011,7013,7016,7019,7022],{"class":48,"line":380},[46,7012,6915],{"class":59},[46,7014,7015],{"class":62}," doc",[46,7017,7018],{"class":62}," read",[46,7020,7021],{"class":122}," --url",[46,7023,7024],{"class":62}," \"https:\u002F\u002Falidocs.dingtalk.com\u002Fi\u002Fnodes\u002Fxxx\"\n",[11,7026,215],{"id":215},[16,7028,7029],{},"三种方式，选你喜欢的：",[36,7031,7033],{"className":38,"code":7032,"language":40,"meta":41,"style":41},"# 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",[43,7034,7035,7040,7053,7057,7062,7072,7076,7081],{"__ignoreMap":41},[46,7036,7037],{"class":48,"line":49},[46,7038,7039],{"class":52},"# npm（推荐，跨平台自动下载二进制）\n",[46,7041,7042,7045,7047,7050],{"class":48,"line":56},[46,7043,7044],{"class":59},"npm",[46,7046,233],{"class":62},[46,7048,7049],{"class":122}," -g",[46,7051,7052],{"class":62}," dingtalk-workspace-cli\n",[46,7054,7055],{"class":48,"line":72},[46,7056,105],{"emptyLinePlaceholder":104},[46,7058,7059],{"class":48,"line":85},[46,7060,7061],{"class":52},"# Homebrew（macOS\u002FLinux）\n",[46,7063,7064,7067,7069],{"class":48,"line":101},[46,7065,7066],{"class":59},"brew",[46,7068,233],{"class":62},[46,7070,7071],{"class":62}," dws\n",[46,7073,7074],{"class":48,"line":108},[46,7075,105],{"emptyLinePlaceholder":104},[46,7077,7078],{"class":48,"line":114},[46,7079,7080],{"class":52},"# 直接下载二进制\n",[46,7082,7083],{"class":48,"line":337},[46,7084,7085],{"class":52},"# https:\u002F\u002Fgithub.com\u002Fopen-dingtalk\u002Fdingtalk-workspace-cli\u002Freleases\n",[16,7087,7088],{},"安装后认证：",[36,7090,7092],{"className":38,"code":7091,"language":40,"meta":41,"style":41},"dws auth login\n",[43,7093,7094],{"__ignoreMap":41},[46,7095,7096,7098,7100],{"class":48,"line":49},[46,7097,6915],{"class":59},[46,7099,344],{"class":62},[46,7101,7102],{"class":62}," login\n",[16,7104,7105],{},"会打开浏览器走 OAuth 设备流认证，token 加密存储在系统钥匙串里（PBKDF2 + AES-256-GCM）。",[11,7107,7108],{"id":7108},"覆盖的钉钉产品",[16,7110,7111,7112,7115],{},"这是最夸张的部分 — ",[27,7113,7114],{},"19 个产品，213 个命令","，基本把钉钉全家桶都搬到了命令行：",[131,7117,7118,7130],{},[134,7119,7120],{},[137,7121,7122,7125,7128],{},[140,7123,7124],{},"产品",[140,7126,7127],{},"命令数",[140,7129,6784],{},[147,7131,7132,7146,7160,7174,7188,7202,7216,7230,7243,7257,7270,7284,7298,7311,7324,7337],{},[137,7133,7134,7140,7143],{},[152,7135,7136,7137],{},"聊天 ",[43,7138,7139],{},"chat",[152,7141,7142],{},"57",[152,7144,7145],{},"消息收发、群管理、机器人、已读状态、Webhook",[137,7147,7148,7154,7157],{},[152,7149,7150,7151],{},"AI 表格 ",[43,7152,7153],{},"aitable",[152,7155,7156],{},"42",[152,7158,7159],{},"Base\u002F数据表\u002F字段\u002F记录\u002F视图\u002F图表\u002F仪表盘，完整 CRUD",[137,7161,7162,7168,7171],{},[152,7163,7164,7165],{},"电子表格 ",[43,7166,7167],{},"sheet",[152,7169,7170],{},"34",[152,7172,7173],{},"工作表读写、行列操作、合并、筛选视图、导出",[137,7175,7176,7182,7185],{},[152,7177,7178,7179],{},"文档 ",[43,7180,7181],{},"doc",[152,7183,7184],{},"21",[152,7186,7187],{},"搜索、读写、块级编辑、评论、复制移动",[137,7189,7190,7196,7199],{},[152,7191,7192,7193],{},"会议纪要 ",[43,7194,7195],{},"minutes",[152,7197,7198],{},"19",[152,7200,7201],{},"列表、摘要、转写、思维导图、发言人、热词",[137,7203,7204,7210,7213],{},[152,7205,7206,7207],{},"日历 ",[43,7208,7209],{},"calendar",[152,7211,7212],{},"14",[152,7214,7215],{},"事件 CRUD、会议室、忙闲查询、时间建议",[137,7217,7218,7224,7227],{},[152,7219,7220,7221],{},"审批 ",[43,7222,7223],{},"oa",[152,7225,7226],{},"9",[152,7228,7229],{},"待办、发起、同意\u002F拒绝\u002F撤销",[137,7231,7232,7238,7240],{},[152,7233,7234,7235],{},"云盘 ",[43,7236,7237],{},"drive",[152,7239,7226],{},[152,7241,7242],{},"列表、下载、上传、创建目录",[137,7244,7245,7251,7254],{},[152,7246,7247,7248],{},"知识库 ",[43,7249,7250],{},"wiki",[152,7252,7253],{},"7",[152,7255,7256],{},"空间管理、成员管理",[137,7258,7259,7265,7267],{},[152,7260,7261,7262],{},"汇报 ",[43,7263,7264],{},"report",[152,7266,7253],{},[152,7268,7269],{},"日志\u002F日报\u002F周报，按模板创建、统计",[137,7271,7272,7278,7281],{},[152,7273,7274,7275],{},"待办 ",[43,7276,7277],{},"todo",[152,7279,7280],{},"6",[152,7282,7283],{},"创建、完成、删除，支持优先级和循环",[137,7285,7286,7292,7295],{},[152,7287,7288,7289],{},"邮件 ",[43,7290,7291],{},"mail",[152,7293,7294],{},"4",[152,7296,7297],{},"邮箱查询、邮件搜索、发送",[137,7299,7300,7306,7308],{},[152,7301,7302,7303],{},"考勤 ",[43,7304,7305],{},"attendance",[152,7307,7294],{},[152,7309,7310],{},"打卡记录、排班、汇总",[137,7312,7313,7319,7321],{},[152,7314,7315,7316],{},"通讯录 ",[43,7317,7318],{},"contact",[152,7320,3445],{},[152,7322,7323],{},"用户搜索、部门查询",[137,7325,7326,7332,7334],{},[152,7327,7328,7329],{},"DING 消息 ",[43,7330,7331],{},"ding",[152,7333,3679],{},[152,7335,7336],{},"发送\u002F撤回（应用内、短信、电话）",[137,7338,7339,7342,7345],{},[152,7340,7341],{},"其他",[152,7343,7344],{},"若干",[152,7346,7347],{},"AI 搜索、AI 应用、直播、开放平台文档",[16,7349,7350,7351,7354],{},"还有一个万能命令 ",[43,7352,7353],{},"dws api","，可以直接调用任意钉钉 OpenAPI，不受这 213 个命令的限制。",[11,7356,7358],{"id":7357},"agent-skill-系统","Agent Skill 系统",[16,7360,7361],{},"dws 内置了一套完整的 Agent Skill，可以直接被 AI 编程工具发现和使用：",[718,7363,7364,7370,7376],{},[721,7365,7366,7369],{},[27,7367,7368],{},"Claude Code"," — 通过 SKILL.md 自动加载",[721,7371,7372,7375],{},[27,7373,7374],{},"Cursor"," — 支持 MCP 协议接入",[721,7377,7378,7381],{},[27,7379,7380],{},"Codex \u002F OpenCode \u002F Qoder"," — 通过 skill 安装命令接入",[16,7383,7384],{},"安装 skill：",[36,7386,7388],{"className":38,"code":7387,"language":40,"meta":41,"style":41},"dws skill setup\n",[43,7389,7390],{"__ignoreMap":41},[46,7391,7392,7394,7397],{"class":48,"line":49},[46,7393,6915],{"class":59},[46,7395,7396],{"class":62}," skill",[46,7398,7399],{"class":62}," setup\n",[16,7401,7402],{},"SKILL.md 里定义了：",[718,7404,7405,7414,7417,7420],{},[721,7406,7407,7408,7410,7411,7413],{},"16 个产品的意图判断决策树（用户说\"表格\"→ ",[43,7409,7153],{},"，说\"打卡\"→ ",[43,7412,7305],{},"）",[721,7415,7416],{},"危险操作确认流程（删除、撤回等必须先确认）",[721,7418,7419],{},"错误处理规范（verbose 重试、recovery 事件 ID）",[721,7421,7422],{},"URL 分流规则（alidocs.dingtalk.com 的多种路径格式自动识别）",[16,7424,7425],{},"Agent 读到这份 SKILL.md 就知道什么时候该调什么命令，不需要额外的 prompt 工程。",[11,7427,7428],{"id":7428},"自动升级",[16,7430,7431,7432,7435],{},"内置 ",[43,7433,7434],{},"dws upgrade","，不需要重新下载安装包：",[36,7437,7439],{"className":38,"code":7438,"language":40,"meta":41,"style":41},"dws upgrade              # 交互式升级到最新版\ndws upgrade --check      # 仅检查是否有新版本\ndws upgrade --list       # 列出最近版本\ndws upgrade --version v1.0.5  # 升级到指定版本\ndws upgrade --rollback   # 回滚到上一版本\n",[43,7440,7441,7451,7463,7475,7490],{"__ignoreMap":41},[46,7442,7443,7445,7448],{"class":48,"line":49},[46,7444,6915],{"class":59},[46,7446,7447],{"class":62}," upgrade",[46,7449,7450],{"class":52},"              # 交互式升级到最新版\n",[46,7452,7453,7455,7457,7460],{"class":48,"line":56},[46,7454,6915],{"class":59},[46,7456,7447],{"class":62},[46,7458,7459],{"class":122}," --check",[46,7461,7462],{"class":52},"      # 仅检查是否有新版本\n",[46,7464,7465,7467,7469,7472],{"class":48,"line":72},[46,7466,6915],{"class":59},[46,7468,7447],{"class":62},[46,7470,7471],{"class":122}," --list",[46,7473,7474],{"class":52},"       # 列出最近版本\n",[46,7476,7477,7479,7481,7484,7487],{"class":48,"line":85},[46,7478,6915],{"class":59},[46,7480,7447],{"class":62},[46,7482,7483],{"class":122}," --version",[46,7485,7486],{"class":62}," v1.0.5",[46,7488,7489],{"class":52},"  # 升级到指定版本\n",[46,7491,7492,7494,7496,7499],{"class":48,"line":101},[46,7493,6915],{"class":59},[46,7495,7447],{"class":62},[46,7497,7498],{"class":122}," --rollback",[46,7500,7501],{"class":52},"   # 回滚到上一版本\n",[16,7503,7504],{},"升级流程：下载 → SHA256 校验 → 备份当前版本 → 原子替换 → 更新 Skill。失败可一键回滚。",[11,7506,686],{"id":686},[262,7508,7510],{"id":7509},"_1-为-ai-agent-专门优化","1. 为 AI Agent 专门优化",[16,7512,7513],{},"这可能是目前对 AI 最友好的 CLI 工具：",[718,7515,7516,7537,7547,7556],{},[721,7517,7518,7521,7522,7525,7526,7529,7530,7525,7533,7536],{},[27,7519,7520],{},"智能纠错"," — AI 模型经常生成错误的参数格式（",[43,7523,7524],{},"--userId"," 应该是 ",[43,7527,7528],{},"--user-id","，",[43,7531,7532],{},"--limit100",[43,7534,7535],{},"--limit 100","），dws 的 5 阶段 Pipeline 会自动修正",[721,7538,7539,7542,7543,7546],{},[27,7540,7541],{},"Schema 发现"," — ",[43,7544,7545],{},"dws schema"," 让 Agent 动态查询可用工具和参数，不需要硬编码",[721,7548,7549,7542,7552,7555],{},[27,7550,7551],{},"jq 过滤",[43,7553,7554],{},"--jq '.data[] | {name, id}'"," 直接过滤输出，减少 Agent 消耗的 token",[721,7557,7558,7561,7562,7565],{},[27,7559,7560],{},"JSON 输出"," — 所有命令支持 ",[43,7563,7564],{},"--format json","，方便程序解析",[262,7567,7569],{"id":7568},"_2-安全设计","2. 安全设计",[718,7571,7572,7575,7578,7581,7588],{},[721,7573,7574],{},"token 用 PBKDF2 + AES-256-GCM 加密，密钥绑定设备 MAC 地址",[721,7576,7577],{},"域名白名单，只能访问钉钉官方 API",[721,7579,7580],{},"强制 HTTPS",[721,7582,7583,7584,7587],{},"写操作必须显式 ",[43,7585,7586],{},"--yes"," 确认",[721,7589,7590],{},"完整审计链",[262,7592,7594],{"id":7593},"_3-发现驱动架构","3. 发现驱动架构",[16,7596,7597],{},"不是把所有命令硬编码在代码里，而是：",[1747,7599,7600,7603,7606,7609],{},[721,7601,7602],{},"从钉钉 MCP 服务器注册中心拉取元数据",[721,7604,7605],{},"运行时发现服务端能力",[721,7607,7608],{},"转换成统一的中间表示（IR）",[721,7610,7611],{},"动态挂载到命令树",[16,7613,7614],{},"这意味着钉钉新增产品能力时，dws 不需要发版就能支持。",[262,7616,7618],{"id":7617},"_4-多平台分发","4. 多平台分发",[16,7620,7621],{},"GoReleaser 交叉编译 + Homebrew formula + npm 包 + shell\u002FPowerShell 安装脚本，覆盖 Windows、macOS、Linux。",[11,7623,1430],{"id":1430},[16,7625,7626],{},"dws 把钉钉从一个\"必须打开 App 才能用\"的工具变成了一个\"命令行 + AI Agent 随时调用\"的平台。213 个命令覆盖日常办公的方方面面，Agent Skill 系统让 AI 编程工具可以直接理解和使用这些能力。",[16,7628,7629],{},"如果你是钉钉重度用户，或者在做企业内部的 AI Agent 集成，dws 值得一试。",[1460,7631,7632],{},"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":41,"searchDepth":56,"depth":72,"links":7634},[7635,7636,7637,7638,7639,7640,7646],{"id":6889,"depth":56,"text":6890},{"id":215,"depth":56,"text":215},{"id":7108,"depth":56,"text":7108},{"id":7357,"depth":56,"text":7358},{"id":7428,"depth":56,"text":7428},{"id":686,"depth":56,"text":686,"children":7641},[7642,7643,7644,7645],{"id":7509,"depth":72,"text":7510},{"id":7568,"depth":72,"text":7569},{"id":7593,"depth":72,"text":7594},{"id":7617,"depth":72,"text":7618},{"id":1430,"depth":56,"text":1430},"介绍阿里巴巴官方的 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":6884,"description":7647},"posts\u002Fdws-dingtalk-cli\u002Findex",[7655,1496,1499,1498,1500],"钉钉","4ca65VUZ2Sz9yG8dNtftrBDDHE6QowA3ed9g4egQkKI",{"id":7658,"title":7659,"author":6,"body":7660,"description":8621,"difficulty":2295,"draft":1486,"extension":1487,"image":8622,"meta":8623,"navigation":104,"path":8624,"pinned":1486,"published":8625,"seo":8626,"series":6875,"seriesOrder":56,"stem":8627,"tags":8628,"updated":8632,"__hash__":8633},"posts\u002Fposts\u002Fgo-embed-frontend\u002Findex.md","Go 单文件打包实战：把前端塞进二进制",{"type":8,"value":7661,"toc":8604},[7662,7664,7671,7674,7677,7688,7691,7695,8028,8032,8140,8144,8277,8280,8284,8293,8310,8321,8339,8353,8359,8367,8466,8472,8476,8483,8492,8495,8531,8534,8539,8578,8580,8586,8598,8601],[11,7663,3364],{"id":3364},[16,7665,7666,7667,7670],{},"一个 Gin + Vue 3 的全栈项目，之前的发布方式是：一个 Go 二进制 + 一个 ",[43,7668,7669],{},"web\u002F"," 前端静态目录，必须放一起才能运行。",[16,7672,7673],{},"为了让部署更丝滑——一个 exe 拖哪都能跑——决定把前端构建产物嵌入到 Go 二进制里。",[11,7675,7676],{"id":7676},"核心思路",[16,7678,7679,7680,7683,7684,7687],{},"利用 Go 1.16 引入的 ",[43,7681,7682],{},"\u002F\u002Fgo:embed"," 指令，在编译时将前端 ",[43,7685,7686],{},"dist\u002F"," 目录嵌入二进制，运行时通过嵌入的文件系统提供 SPA 服务。",[11,7689,7690],{"id":7690},"逐步实现",[262,7692,7694],{"id":7693},"_1-创建嵌入模块","1. 创建嵌入模块",[36,7696,7698],{"className":4915,"code":7697,"language":230,"meta":41,"style":41},"\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",[43,7699,7700,7705,7713,7717,7723,7733,7742,7751,7760,7769,7778,7782,7791,7795,7799,7804,7819,7823,7840,7861,7865,7885,7905,7918,7928,7932,7936,7951,7963,7968,7987,7991,7995,8018,8023],{"__ignoreMap":41},[46,7701,7702],{"class":48,"line":49},[46,7703,7704],{"class":52},"\u002F\u002F service\u002Fweb\u002Fembed.go\n",[46,7706,7707,7710],{"class":48,"line":56},[46,7708,7709],{"class":312},"package",[46,7711,7712],{"class":59}," web\n",[46,7714,7715],{"class":48,"line":72},[46,7716,105],{"emptyLinePlaceholder":104},[46,7718,7719,7721],{"class":48,"line":85},[46,7720,4923],{"class":312},[46,7722,6320],{"class":319},[46,7724,7725,7728,7731],{"class":48,"line":101},[46,7726,7727],{"class":62},"    \"",[46,7729,7730],{"class":59},"embed",[46,7732,1742],{"class":62},[46,7734,7735,7737,7740],{"class":48,"line":108},[46,7736,7727],{"class":62},[46,7738,7739],{"class":59},"io\u002Ffs",[46,7741,1742],{"class":62},[46,7743,7744,7746,7749],{"class":48,"line":114},[46,7745,7727],{"class":62},[46,7747,7748],{"class":59},"mime",[46,7750,1742],{"class":62},[46,7752,7753,7755,7758],{"class":48,"line":337},[46,7754,7727],{"class":62},[46,7756,7757],{"class":59},"net\u002Fhttp",[46,7759,1742],{"class":62},[46,7761,7762,7764,7767],{"class":48,"line":369},[46,7763,7727],{"class":62},[46,7765,7766],{"class":59},"path\u002Ffilepath",[46,7768,1742],{"class":62},[46,7770,7771,7773,7776],{"class":48,"line":374},[46,7772,7727],{"class":62},[46,7774,7775],{"class":59},"strings",[46,7777,1742],{"class":62},[46,7779,7780],{"class":48,"line":380},[46,7781,105],{"emptyLinePlaceholder":104},[46,7783,7784,7786,7789],{"class":48,"line":484},[46,7785,7727],{"class":62},[46,7787,7788],{"class":59},"github.com\u002Fgin-gonic\u002Fgin",[46,7790,1742],{"class":62},[46,7792,7793],{"class":48,"line":498},[46,7794,3803],{"class":319},[46,7796,7797],{"class":48,"line":503},[46,7798,105],{"emptyLinePlaceholder":104},[46,7800,7801],{"class":48,"line":509},[46,7802,7803],{"class":52},"\u002F\u002Fgo:embed dist\n",[46,7805,7806,7809,7812,7814,7816],{"class":48,"line":1711},[46,7807,7808],{"class":312},"var",[46,7810,7811],{"class":319}," embedFS ",[46,7813,7730],{"class":59},[46,7815,3615],{"class":319},[46,7817,7818],{"class":59},"FS\n",[46,7820,7821],{"class":48,"line":1716},[46,7822,105],{"emptyLinePlaceholder":104},[46,7824,7825,7827,7830,7832,7834,7836,7838],{"class":48,"line":1725},[46,7826,5116],{"class":312},[46,7828,7829],{"class":59}," SPAHandler",[46,7831,6363],{"class":319},[46,7833,5743],{"class":59},[46,7835,3615],{"class":319},[46,7837,5961],{"class":59},[46,7839,3142],{"class":319},[46,7841,7842,7845,7847,7850,7853,7856,7859],{"class":48,"line":1730},[46,7843,7844],{"class":319},"    sub, _ ",[46,7846,4942],{"class":312},[46,7848,7849],{"class":319}," fs.",[46,7851,7852],{"class":59},"Sub",[46,7854,7855],{"class":319},"(embedFS, ",[46,7857,7858],{"class":62},"\"dist\"",[46,7860,3803],{"class":319},[46,7862,7863],{"class":48,"line":1938},[46,7864,105],{"emptyLinePlaceholder":104},[46,7866,7867,7869,7871,7873,7875,7877,7879,7881,7883],{"class":48,"line":1946},[46,7868,5172],{"class":312},[46,7870,5970],{"class":312},[46,7872,3494],{"class":319},[46,7874,5737],{"class":3497},[46,7876,5740],{"class":312},[46,7878,5743],{"class":59},[46,7880,3615],{"class":319},[46,7882,5129],{"class":59},[46,7884,4744],{"class":319},[46,7886,7887,7890,7892,7894,7897,7900,7903],{"class":48,"line":1951},[46,7888,7889],{"class":319},"        p ",[46,7891,4942],{"class":312},[46,7893,6077],{"class":319},[46,7895,7896],{"class":59},"TrimPrefix",[46,7898,7899],{"class":319},"(c.Request.URL.Path, ",[46,7901,7902],{"class":62},"\"\u002F\"",[46,7904,3803],{"class":319},[46,7906,7907,7909,7912,7914,7916],{"class":48,"line":2575},[46,7908,6009],{"class":312},[46,7910,7911],{"class":319}," p ",[46,7913,6015],{"class":312},[46,7915,6018],{"class":62},[46,7917,3142],{"class":319},[46,7919,7920,7923,7925],{"class":48,"line":2589},[46,7921,7922],{"class":319},"            p ",[46,7924,1611],{"class":312},[46,7926,7927],{"class":62}," \"index.html\"\n",[46,7929,7930],{"class":48,"line":2596},[46,7931,6063],{"class":319},[46,7933,7934],{"class":48,"line":2601},[46,7935,105],{"emptyLinePlaceholder":104},[46,7937,7938,7941,7943,7945,7948],{"class":48,"line":2607},[46,7939,7940],{"class":319},"        data, err ",[46,7942,4942],{"class":312},[46,7944,7849],{"class":319},[46,7946,7947],{"class":59},"ReadFile",[46,7949,7950],{"class":319},"(sub, p)\n",[46,7952,7953,7955,7957,7959,7961],{"class":48,"line":2615},[46,7954,6009],{"class":312},[46,7956,5355],{"class":319},[46,7958,5358],{"class":312},[46,7960,5361],{"class":122},[46,7962,3142],{"class":319},[46,7964,7965],{"class":48,"line":2623},[46,7966,7967],{"class":52},"            \u002F\u002F SPA fallback：文件不存在则返回 index.html\n",[46,7969,7970,7973,7975,7977,7979,7982,7985],{"class":48,"line":2628},[46,7971,7972],{"class":319},"            data, _ ",[46,7974,1611],{"class":312},[46,7976,7849],{"class":319},[46,7978,7947],{"class":59},[46,7980,7981],{"class":319},"(sub, ",[46,7983,7984],{"class":62},"\"index.html\"",[46,7986,3803],{"class":319},[46,7988,7989],{"class":48,"line":2634},[46,7990,6063],{"class":319},[46,7992,7993],{"class":48,"line":2643},[46,7994,105],{"emptyLinePlaceholder":104},[46,7996,7998,8000,8003,8006,8009,8012,8015],{"class":48,"line":7997},33,[46,7999,5754],{"class":319},[46,8001,8002],{"class":59},"Data",[46,8004,8005],{"class":319},"(http.StatusOK, mime.",[46,8007,8008],{"class":59},"TypeByExtension",[46,8010,8011],{"class":319},"(filepath.",[46,8013,8014],{"class":59},"Ext",[46,8016,8017],{"class":319},"(p)), data)\n",[46,8019,8021],{"class":48,"line":8020},34,[46,8022,670],{"class":319},[46,8024,8026],{"class":48,"line":8025},35,[46,8027,680],{"class":319},[262,8029,8031],{"id":8030},"_2-改造路由","2. 改造路由",[36,8033,8035],{"className":4915,"code":8034,"language":230,"meta":41,"style":41},"\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",[43,8036,8037,8042,8053,8057,8062,8077,8091,8104,8117,8130],{"__ignoreMap":41},[46,8038,8039],{"class":48,"line":49},[46,8040,8041],{"class":52},"\u002F\u002F router.go\n",[46,8043,8044,8046,8048,8051],{"class":48,"line":56},[46,8045,4923],{"class":312},[46,8047,1546],{"class":62},[46,8049,8050],{"class":59},"myproject\u002Fweb",[46,8052,1742],{"class":62},[46,8054,8055],{"class":48,"line":72},[46,8056,105],{"emptyLinePlaceholder":104},[46,8058,8059],{"class":48,"line":85},[46,8060,8061],{"class":52},"\u002F\u002F 替换原来的磁盘静态文件服务\n",[46,8063,8064,8067,8069,8072,8075],{"class":48,"line":101},[46,8065,8066],{"class":319},"webHandler ",[46,8068,4942],{"class":312},[46,8070,8071],{"class":319}," web.",[46,8073,8074],{"class":59},"SPAHandler",[46,8076,3688],{"class":319},[46,8078,8079,8082,8084,8086,8088],{"class":48,"line":108},[46,8080,8081],{"class":319},"router.",[46,8083,5723],{"class":59},[46,8085,3494],{"class":319},[46,8087,7902],{"class":62},[46,8089,8090],{"class":319},", webHandler)\n",[46,8092,8093,8095,8097,8099,8102],{"class":48,"line":114},[46,8094,8081],{"class":319},[46,8096,5723],{"class":59},[46,8098,3494],{"class":319},[46,8100,8101],{"class":62},"\"\u002Fassets\u002F*filepath\"",[46,8103,8090],{"class":319},[46,8105,8106,8108,8110,8112,8115],{"class":48,"line":337},[46,8107,8081],{"class":319},[46,8109,5723],{"class":59},[46,8111,3494],{"class":319},[46,8113,8114],{"class":62},"\"\u002Ffavicon.ico\"",[46,8116,8090],{"class":319},[46,8118,8119,8121,8123,8125,8128],{"class":48,"line":369},[46,8120,8081],{"class":319},[46,8122,5723],{"class":59},[46,8124,3494],{"class":319},[46,8126,8127],{"class":62},"\"\u002Ffavicon.svg\"",[46,8129,8090],{"class":319},[46,8131,8132,8134,8137],{"class":48,"line":374},[46,8133,8081],{"class":319},[46,8135,8136],{"class":59},"NoRoute",[46,8138,8139],{"class":319},"(webHandler)\n",[262,8141,8143],{"id":8142},"_3-一键构建脚本","3. 一键构建脚本",[36,8145,8147],{"className":38,"code":8146,"language":40,"meta":41,"style":41},"#!\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",[43,8148,8149,8154,8159,8163,8171,8175,8180,8190,8200,8204,8209,8220,8233,8237,8242,8249,8262,8272],{"__ignoreMap":41},[46,8150,8151],{"class":48,"line":49},[46,8152,8153],{"class":52},"#!\u002Fbin\u002Fbash\n",[46,8155,8156],{"class":48,"line":56},[46,8157,8158],{"class":52},"# build-standalone.sh\n",[46,8160,8161],{"class":48,"line":72},[46,8162,105],{"emptyLinePlaceholder":104},[46,8164,8165,8168],{"class":48,"line":85},[46,8166,8167],{"class":122},"set",[46,8169,8170],{"class":122}," -e\n",[46,8172,8173],{"class":48,"line":101},[46,8174,105],{"emptyLinePlaceholder":104},[46,8176,8177],{"class":48,"line":108},[46,8178,8179],{"class":52},"# 1. 构建前端\n",[46,8181,8182,8185,8187],{"class":48,"line":114},[46,8183,8184],{"class":59},"pnpm",[46,8186,233],{"class":62},[46,8188,8189],{"class":122}," --no-frozen-lockfile\n",[46,8191,8192,8194,8197],{"class":48,"line":337},[46,8193,3264],{"class":59},[46,8195,8196],{"class":62}," vite",[46,8198,8199],{"class":62}," build\n",[46,8201,8202],{"class":48,"line":369},[46,8203,105],{"emptyLinePlaceholder":104},[46,8205,8206],{"class":48,"line":374},[46,8207,8208],{"class":52},"# 2. 复制到嵌入目录\n",[46,8210,8211,8214,8217],{"class":48,"line":380},[46,8212,8213],{"class":59},"rm",[46,8215,8216],{"class":122}," -rf",[46,8218,8219],{"class":62}," service\u002Fweb\u002Fdist\n",[46,8221,8222,8225,8228,8231],{"class":48,"line":484},[46,8223,8224],{"class":59},"cp",[46,8226,8227],{"class":122}," -r",[46,8229,8230],{"class":62}," dist",[46,8232,8219],{"class":62},[46,8234,8235],{"class":48,"line":498},[46,8236,105],{"emptyLinePlaceholder":104},[46,8238,8239],{"class":48,"line":503},[46,8240,8241],{"class":52},"# 3. 编译 Go 二进制\n",[46,8243,8244,8246],{"class":48,"line":509},[46,8245,911],{"class":122},[46,8247,8248],{"class":62}," service\n",[46,8250,8251,8253,8255,8257,8260],{"class":48,"line":1711},[46,8252,230],{"class":59},[46,8254,1656],{"class":62},[46,8256,1665],{"class":122},[46,8258,8259],{"class":62}," myapp.exe",[46,8261,5841],{"class":122},[46,8263,8264,8267,8270],{"class":48,"line":1716},[46,8265,8266],{"class":122},"    -ldflags=",[46,8268,8269],{"class":62},"\"-X myproject\u002Fglobal.RUNCODE=release\"",[46,8271,5841],{"class":122},[46,8273,8274],{"class":48,"line":1725},[46,8275,8276],{"class":62},"    main.go\n",[11,8278,8279],{"id":8279},"踩坑记录",[262,8281,8283],{"id":8282},"坑一路由不匹配静态资源-404","坑一：路由不匹配，静态资源 404",[16,8285,8286,8287,8289,8290,8292],{},"首次实现时用了 Gin 的 ",[43,8288,8136],{}," 兜底，但部分静态资源返回 404。排查发现 ",[43,8291,8136],{}," 在某些路由未显式注册时不会按预期触发。",[16,8294,8295,8298,8299,8302,8303,8306,8307,8309],{},[27,8296,8297],{},"解决","：显式注册 ",[43,8300,8301],{},"\u002Fassets\u002F*filepath","、",[43,8304,8305],{},"\u002Ffavicon.ico"," 等路由，",[43,8308,8136],{}," 仅作为兜底。",[262,8311,8313,8314,8316,8317,8320],{"id":8312},"坑二goembed-排除-_-开头文件","坑二：",[43,8315,7682],{}," 排除 ",[43,8318,8319],{},"_"," 开头文件",[16,8322,8323,8324,8327,8328,8330,8331,3388],{},"这是最坑的。Vite 构建产出了一个 ",[43,8325,8326],{},"_plugin-vue_export-helper-xxx.js","，Go 的 ",[43,8329,7682],{}," 当模式为目录时，",[27,8332,8333,8334,30,8336,8338],{},"硬编码排除了 ",[43,8335,8319],{},[43,8337,3615],{}," 开头的文件",[36,8340,8342],{"className":4915,"code":8341,"language":230,"meta":41,"style":41},"\u002F\u002F 这行会跳过 dist\u002Fassets\u002F_plugin-xxx.js\n\u002F\u002Fgo:embed dist\n",[43,8343,8344,8349],{"__ignoreMap":41},[46,8345,8346],{"class":48,"line":49},[46,8347,8348],{"class":52},"\u002F\u002F 这行会跳过 dist\u002Fassets\u002F_plugin-xxx.js\n",[46,8350,8351],{"class":48,"line":56},[46,8352,7803],{"class":52},[16,8354,8355,8356,3388],{},"错误表现：JS 文件返回的是 HTML 内容（SPA fallback），浏览器报 ",[43,8357,8358],{},"Unexpected token '\u003C'",[16,8360,8361,8363,8364,8366],{},[27,8362,8297],{},"：改 Vite 配置，去除了 ",[43,8365,8319],{}," 前缀：",[36,8368,8372],{"className":8369,"code":8370,"language":8371,"meta":41,"style":41},"language-ts shiki shiki-themes github-light github-dark","\u002F\u002F vite.config.ts\nbuild: {\n  rollupOptions: {\n    output: {\n      chunkFileNames(chunkInfo) {\n        return `assets\u002F${chunkInfo.name.replace(\u002F^_\u002F, '')}-[hash].js`\n      },\n    },\n  },\n},\n","ts",[43,8373,8374,8379,8386,8393,8400,8408,8446,8451,8456,8461],{"__ignoreMap":41},[46,8375,8376],{"class":48,"line":49},[46,8377,8378],{"class":52},"\u002F\u002F vite.config.ts\n",[46,8380,8381,8384],{"class":48,"line":56},[46,8382,8383],{"class":59},"build",[46,8385,624],{"class":319},[46,8387,8388,8391],{"class":48,"line":72},[46,8389,8390],{"class":59},"  rollupOptions",[46,8392,624],{"class":319},[46,8394,8395,8398],{"class":48,"line":85},[46,8396,8397],{"class":59},"    output",[46,8399,624],{"class":319},[46,8401,8402,8405],{"class":48,"line":101},[46,8403,8404],{"class":59},"      chunkFileNames",[46,8406,8407],{"class":319},"(chunkInfo) {\n",[46,8409,8410,8412,8415,8418,8420,8423,8425,8427,8429,8431,8433,8435,8437,8439,8441,8443],{"class":48,"line":108},[46,8411,5368],{"class":312},[46,8413,8414],{"class":62}," `assets\u002F${",[46,8416,8417],{"class":319},"chunkInfo",[46,8419,3615],{"class":62},[46,8421,8422],{"class":319},"name",[46,8424,3615],{"class":62},[46,8426,3993],{"class":59},[46,8428,3494],{"class":62},[46,8430,3612],{"class":62},[46,8432,3599],{"class":312},[46,8434,8319],{"class":3602},[46,8436,3612],{"class":62},[46,8438,659],{"class":62},[46,8440,4010],{"class":62},[46,8442,4313],{"class":62},[46,8444,8445],{"class":62},"}-[hash].js`\n",[46,8447,8448],{"class":48,"line":114},[46,8449,8450],{"class":319},"      },\n",[46,8452,8453],{"class":48,"line":337},[46,8454,8455],{"class":319},"    },\n",[46,8457,8458],{"class":48,"line":369},[46,8459,8460],{"class":319},"  },\n",[46,8462,8463],{"class":48,"line":374},[46,8464,8465],{"class":319},"},\n",[16,8467,8468,8469,8471],{},"这样就避免产生 ",[43,8470,8319],{}," 前缀文件名的同时，保持构建产物的一致性和引用关系的正确性。",[262,8473,8475],{"id":8474},"坑三构建缓存导致-embed-内容未更新","坑三：构建缓存导致 embed 内容未更新",[16,8477,8478,8479,8482],{},"多次 ",[43,8480,8481],{},"go build"," 后，Go 的构建缓存可能使用了旧版本的 embed 内容，导致实际运行的文件不是最新的。",[16,8484,8485,8487,8488,8491],{},[27,8486,8297],{},"：使用 ",[43,8489,8490],{},"go build -a"," 强制重新编译所有包。",[11,8493,8494],{"id":8494},"最终效果",[36,8496,8498],{"className":38,"code":8497,"language":40,"meta":41,"style":41},"$ bash build-standalone.sh\n=== 构建成功 ===\n输出: service\u002Fmyapp.exe  41M\n",[43,8499,8500,8510,8520],{"__ignoreMap":41},[46,8501,8502,8504,8507],{"class":48,"line":49},[46,8503,3609],{"class":59},[46,8505,8506],{"class":62}," bash",[46,8508,8509],{"class":62}," build-standalone.sh\n",[46,8511,8512,8514,8517],{"class":48,"line":56},[46,8513,3587],{"class":62},[46,8515,8516],{"class":62}," 构建成功",[46,8518,8519],{"class":62}," ===\n",[46,8521,8522,8525,8528],{"class":48,"line":72},[46,8523,8524],{"class":59},"输出:",[46,8526,8527],{"class":62}," service\u002Fmyapp.exe",[46,8529,8530],{"class":62},"  41M\n",[16,8532,8533],{},"一个 41MB 的 exe，内置前端页面、API 后端、SQLite 数据库（运行时初始化），拖到哪都能跑。",[16,8535,8536],{},[27,8537,8538],{},"部署对比：",[131,8540,8541,8554],{},[134,8542,8543],{},[137,8544,8545,8548,8551],{},[140,8546,8547],{},"方式",[140,8549,8550],{},"文件数",[140,8552,8553],{},"操作",[147,8555,8556,8567],{},[137,8557,8558,8561,8564],{},[152,8559,8560],{},"旧方案",[152,8562,8563],{},"1 exe + 1 web 目录",[152,8565,8566],{},"解压→保持目录结构",[137,8568,8569,8572,8575],{},[152,8570,8571],{},"新方案",[152,8573,8574],{},"1 个 exe",[152,8576,8577],{},"直接扔到服务器运行",[11,8579,1430],{"id":1430},[16,8581,8582,8583,8585],{},"Go 的 ",[43,8584,7682],{}," 是一个强大的工具，但有两个隐藏行为需要注意：",[1747,8587,8588,8595],{},[721,8589,8590,8591,30,8593,8338],{},"嵌入目录时自动排除 ",[43,8592,8319],{},[43,8594,3615],{},[721,8596,8597],{},"构建缓存可能导致 embed 不是最新的",[16,8599,8600],{},"对于前端+后端合体的场景，这是性价比最高的方案——不需要额外的打包工具，纯标准库搞定。",[1460,8602,8603],{},"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":41,"searchDepth":56,"depth":72,"links":8605},[8606,8607,8608,8613,8619,8620],{"id":3364,"depth":56,"text":3364},{"id":7676,"depth":56,"text":7676},{"id":7690,"depth":56,"text":7690,"children":8609},[8610,8611,8612],{"id":7693,"depth":72,"text":7694},{"id":8030,"depth":72,"text":8031},{"id":8142,"depth":72,"text":8143},{"id":8279,"depth":56,"text":8279,"children":8614},[8615,8616,8618],{"id":8282,"depth":72,"text":8283},{"id":8312,"depth":72,"text":8617},"坑二：\u002F\u002Fgo:embed 排除 _ 开头文件",{"id":8474,"depth":72,"text":8475},{"id":8494,"depth":56,"text":8494},{"id":1430,"depth":56,"text":1430},"记录将 Vue 3 前端嵌入 Go 二进制实现单 exe 发布的完整流程，以及遇到的坑和解决方案。","\u002Fposts\u002Fgo-embed-frontend\u002Fimg\u002Fcover.jpg",{},"\u002Fposts\u002Fgo-embed-frontend","2026-06-06T16:00:00",{"title":7659,"description":8621},"posts\u002Fgo-embed-frontend\u002Findex",[1498,8629,8630,8631],"Vue","编译","埋坑","2026-06-06","SIQNL24wVAFb18wIGWSB3lNVZUQ9B76jWgQ55TASTYk",{"id":4,"title":5,"author":6,"body":8635,"description":1484,"difficulty":1485,"draft":1486,"extension":1487,"image":1488,"meta":9783,"navigation":104,"path":1490,"pinned":1486,"published":1491,"seo":9784,"series":6,"seriesOrder":6,"stem":1493,"tags":9785,"updated":1491,"__hash__":1501},{"type":8,"value":8636,"toc":9762},[8637,8639,8648,8704,8706,8708,8764,8766,8794,8798,8800,8802,8898,8900,9014,9016,9078,9080,9136,9138,9140,9142,9144,9162,9164,9174,9176,9178,9182,9306,9312,9338,9340,9342,9426,9428,9430,9532,9534,9536,9588,9590,9592,9594,9652,9654,9659,9661,9687,9691,9693,9739,9741,9743,9755,9760],[11,8638,14],{"id":13},[16,8640,8641,25,8644,30,8646,34],{},[19,8642,24],{"href":21,"rel":8643},[23],[27,8645,29],{},[27,8647,33],{},[36,8649,8650],{"className":38,"code":39,"language":40,"meta":41,"style":41},[43,8651,8652,8656,8666,8676,8688,8692,8696],{"__ignoreMap":41},[46,8653,8654],{"class":48,"line":49},[46,8655,53],{"class":52},[46,8657,8658,8660,8662,8664],{"class":48,"line":56},[46,8659,24],{"class":59},[46,8661,63],{"class":62},[46,8663,66],{"class":62},[46,8665,69],{"class":62},[46,8667,8668,8670,8672,8674],{"class":48,"line":72},[46,8669,24],{"class":59},[46,8671,63],{"class":62},[46,8673,79],{"class":62},[46,8675,82],{"class":62},[46,8677,8678,8680,8682,8684,8686],{"class":48,"line":85},[46,8679,24],{"class":59},[46,8681,63],{"class":62},[46,8683,92],{"class":62},[46,8685,95],{"class":62},[46,8687,98],{"class":62},[46,8689,8690],{"class":48,"line":101},[46,8691,105],{"emptyLinePlaceholder":104},[46,8693,8694],{"class":48,"line":108},[46,8695,111],{"class":52},[46,8697,8698,8700,8702],{"class":48,"line":114},[46,8699,24],{"class":59},[46,8701,119],{"class":62},[46,8703,123],{"class":122},[11,8705,126],{"id":126},[16,8707,129],{},[131,8709,8710,8718],{},[134,8711,8712],{},[137,8713,8714,8716],{},[140,8715,142],{},[140,8717,145],{},[147,8719,8720,8726,8732,8740,8748,8756],{},[137,8721,8722,8724],{},[152,8723,154],{},[152,8725,157],{},[137,8727,8728,8730],{},[152,8729,162],{},[152,8731,165],{},[137,8733,8734,8736],{},[152,8735,170],{},[152,8737,173,8738,177],{},[43,8739,176],{},[137,8741,8742,8744],{},[152,8743,182],{},[152,8745,185,8746,189],{},[43,8747,188],{},[137,8749,8750,8752],{},[152,8751,194],{},[152,8753,197,8754],{},[43,8755,200],{},[137,8757,8758,8760],{},[152,8759,205],{},[152,8761,208,8762,212],{},[43,8763,211],{},[11,8765,215],{"id":215},[36,8767,8768],{"className":38,"code":218,"language":40,"meta":41,"style":41},[43,8769,8770,8774,8782,8786,8790],{"__ignoreMap":41},[46,8771,8772],{"class":48,"line":49},[46,8773,225],{"class":52},[46,8775,8776,8778,8780],{"class":48,"line":56},[46,8777,230],{"class":59},[46,8779,233],{"class":62},[46,8781,236],{"class":62},[46,8783,8784],{"class":48,"line":72},[46,8785,105],{"emptyLinePlaceholder":104},[46,8787,8788],{"class":48,"line":85},[46,8789,245],{"class":52},[46,8791,8792],{"class":48,"line":101},[46,8793,250],{"class":52},[16,8795,253,8796,257],{},[43,8797,256],{},[11,8799,260],{"id":260},[262,8801,265],{"id":264},[36,8803,8804],{"className":38,"code":268,"language":40,"meta":41,"style":41},[43,8805,8806,8810,8822,8826,8830,8848,8852,8856,8882,8886,8890],{"__ignoreMap":41},[46,8807,8808],{"class":48,"line":49},[46,8809,275],{"class":52},[46,8811,8812,8814,8816,8818,8820],{"class":48,"line":56},[46,8813,24],{"class":59},[46,8815,282],{"class":62},[46,8817,285],{"class":62},[46,8819,288],{"class":62},[46,8821,291],{"class":122},[46,8823,8824],{"class":48,"line":72},[46,8825,105],{"emptyLinePlaceholder":104},[46,8827,8828],{"class":48,"line":85},[46,8829,300],{"class":52},[46,8831,8832,8834,8836,8838,8840,8842,8844,8846],{"class":48,"line":101},[46,8833,24],{"class":59},[46,8835,282],{"class":62},[46,8837,309],{"class":62},[46,8839,313],{"class":312},[46,8841,316],{"class":62},[46,8843,320],{"class":319},[46,8845,323],{"class":312},[46,8847,291],{"class":122},[46,8849,8850],{"class":48,"line":108},[46,8851,105],{"emptyLinePlaceholder":104},[46,8853,8854],{"class":48,"line":114},[46,8855,334],{"class":52},[46,8857,8858,8860,8862,8864,8866,8868,8870,8872,8874,8876,8878,8880],{"class":48,"line":337},[46,8859,24],{"class":59},[46,8861,282],{"class":62},[46,8863,344],{"class":62},[46,8865,313],{"class":312},[46,8867,349],{"class":62},[46,8869,352],{"class":319},[46,8871,323],{"class":312},[46,8873,313],{"class":312},[46,8875,359],{"class":62},[46,8877,362],{"class":319},[46,8879,323],{"class":312},[46,8881,291],{"class":122},[46,8883,8884],{"class":48,"line":369},[46,8885,105],{"emptyLinePlaceholder":104},[46,8887,8888],{"class":48,"line":374},[46,8889,377],{"class":52},[46,8891,8892,8894,8896],{"class":48,"line":380},[46,8893,24],{"class":59},[46,8895,385],{"class":62},[46,8897,388],{"class":62},[262,8899,392],{"id":391},[36,8901,8902],{"className":38,"code":395,"language":40,"meta":41,"style":41},[43,8903,8904,8908,8918,8922,8926,8936,8940,8944,8956,8970,8974,8978,8990,8994,8998],{"__ignoreMap":41},[46,8905,8906],{"class":48,"line":49},[46,8907,402],{"class":52},[46,8909,8910,8912,8914,8916],{"class":48,"line":56},[46,8911,24],{"class":59},[46,8913,63],{"class":62},[46,8915,66],{"class":62},[46,8917,413],{"class":62},[46,8919,8920],{"class":48,"line":72},[46,8921,105],{"emptyLinePlaceholder":104},[46,8923,8924],{"class":48,"line":85},[46,8925,422],{"class":52},[46,8927,8928,8930,8932,8934],{"class":48,"line":101},[46,8929,24],{"class":59},[46,8931,63],{"class":62},[46,8933,79],{"class":62},[46,8935,82],{"class":62},[46,8937,8938],{"class":48,"line":108},[46,8939,105],{"emptyLinePlaceholder":104},[46,8941,8942],{"class":48,"line":114},[46,8943,441],{"class":52},[46,8945,8946,8948,8950,8952,8954],{"class":48,"line":337},[46,8947,24],{"class":59},[46,8949,63],{"class":62},[46,8951,450],{"class":62},[46,8953,95],{"class":62},[46,8955,455],{"class":52},[46,8957,8958,8960,8962,8964,8966,8968],{"class":48,"line":369},[46,8959,24],{"class":59},[46,8961,63],{"class":62},[46,8963,464],{"class":62},[46,8965,95],{"class":62},[46,8967,469],{"class":122},[46,8969,472],{"class":52},[46,8971,8972],{"class":48,"line":374},[46,8973,105],{"emptyLinePlaceholder":104},[46,8975,8976],{"class":48,"line":380},[46,8977,481],{"class":52},[46,8979,8980,8982,8984,8986,8988],{"class":48,"line":484},[46,8981,24],{"class":59},[46,8983,63],{"class":62},[46,8985,92],{"class":62},[46,8987,95],{"class":62},[46,8989,495],{"class":62},[46,8991,8992],{"class":48,"line":498},[46,8993,105],{"emptyLinePlaceholder":104},[46,8995,8996],{"class":48,"line":503},[46,8997,506],{"class":52},[46,8999,9000,9002,9004,9006,9008,9010,9012],{"class":48,"line":509},[46,9001,24],{"class":59},[46,9003,63],{"class":62},[46,9005,516],{"class":62},[46,9007,519],{"class":62},[46,9009,522],{"class":62},[46,9011,525],{"class":122},[46,9013,528],{"class":62},[262,9015,532],{"id":531},[36,9017,9018],{"className":38,"code":535,"language":40,"meta":41,"style":41},[43,9019,9020,9024,9040,9062],{"__ignoreMap":41},[46,9021,9022],{"class":48,"line":49},[46,9023,542],{"class":52},[46,9025,9026,9028,9030,9032,9034,9036,9038],{"class":48,"line":56},[46,9027,24],{"class":59},[46,9029,282],{"class":62},[46,9031,285],{"class":62},[46,9033,288],{"class":62},[46,9035,555],{"class":122},[46,9037,558],{"class":62},[46,9039,291],{"class":122},[46,9041,9042,9044,9046,9048,9050,9052,9054,9056,9058,9060],{"class":48,"line":72},[46,9043,24],{"class":59},[46,9045,282],{"class":62},[46,9047,309],{"class":62},[46,9049,313],{"class":312},[46,9051,573],{"class":62},[46,9053,320],{"class":319},[46,9055,323],{"class":312},[46,9057,555],{"class":122},[46,9059,558],{"class":62},[46,9061,291],{"class":122},[46,9063,9064,9066,9068,9070,9072,9074,9076],{"class":48,"line":85},[46,9065,24],{"class":59},[46,9067,282],{"class":62},[46,9069,592],{"class":62},[46,9071,595],{"class":62},[46,9073,555],{"class":122},[46,9075,558],{"class":62},[46,9077,291],{"class":122},[16,9079,604],{},[36,9081,9082],{"className":607,"code":608,"language":609,"meta":41,"style":41},[43,9083,9084,9088,9094,9100,9110,9124,9128,9132],{"__ignoreMap":41},[46,9085,9086],{"class":48,"line":49},[46,9087,616],{"class":319},[46,9089,9090,9092],{"class":48,"line":56},[46,9091,621],{"class":122},[46,9093,624],{"class":319},[46,9095,9096,9098],{"class":48,"line":72},[46,9097,629],{"class":122},[46,9099,624],{"class":319},[46,9101,9102,9104,9106,9108],{"class":48,"line":85},[46,9103,636],{"class":122},[46,9105,639],{"class":319},[46,9107,642],{"class":62},[46,9109,645],{"class":319},[46,9111,9112,9114,9116,9118,9120,9122],{"class":48,"line":101},[46,9113,650],{"class":122},[46,9115,653],{"class":319},[46,9117,656],{"class":62},[46,9119,659],{"class":319},[46,9121,662],{"class":62},[46,9123,665],{"class":319},[46,9125,9126],{"class":48,"line":108},[46,9127,670],{"class":319},[46,9129,9130],{"class":48,"line":114},[46,9131,675],{"class":319},[46,9133,9134],{"class":48,"line":337},[46,9135,680],{"class":319},[16,9137,683],{},[11,9139,686],{"id":686},[262,9141,690],{"id":689},[16,9143,693],{},[36,9145,9146],{"className":38,"code":696,"language":40,"meta":41,"style":41},[43,9147,9148],{"__ignoreMap":41},[46,9149,9150,9152,9154,9156,9158,9160],{"class":48,"line":49},[46,9151,24],{"class":59},[46,9153,282],{"class":62},[46,9155,592],{"class":62},[46,9157,595],{"class":62},[46,9159,555],{"class":122},[46,9161,713],{"class":62},[16,9163,716],{},[718,9165,9166,9168,9170],{},[721,9167,723],{},[721,9169,726],{},[721,9171,729,9172,733],{},[43,9173,732],{},[16,9175,736],{},[262,9177,740],{"id":739},[16,9179,743,9180,747],{},[43,9181,746],{},[36,9183,9184],{"className":38,"code":750,"language":40,"meta":41,"style":41},[43,9185,9186,9190,9206,9228,9232,9236,9252,9274,9278,9282,9294],{"__ignoreMap":41},[46,9187,9188],{"class":48,"line":49},[46,9189,757],{"class":52},[46,9191,9192,9194,9196,9198,9200,9202,9204],{"class":48,"line":56},[46,9193,24],{"class":59},[46,9195,282],{"class":62},[46,9197,285],{"class":62},[46,9199,768],{"class":62},[46,9201,555],{"class":122},[46,9203,773],{"class":62},[46,9205,291],{"class":122},[46,9207,9208,9210,9212,9214,9216,9218,9220,9222,9224,9226],{"class":48,"line":72},[46,9209,24],{"class":59},[46,9211,282],{"class":62},[46,9213,309],{"class":62},[46,9215,313],{"class":312},[46,9217,788],{"class":62},[46,9219,320],{"class":319},[46,9221,323],{"class":312},[46,9223,555],{"class":122},[46,9225,773],{"class":62},[46,9227,291],{"class":122},[46,9229,9230],{"class":48,"line":85},[46,9231,105],{"emptyLinePlaceholder":104},[46,9233,9234],{"class":48,"line":101},[46,9235,807],{"class":52},[46,9237,9238,9240,9242,9244,9246,9248,9250],{"class":48,"line":108},[46,9239,24],{"class":59},[46,9241,282],{"class":62},[46,9243,285],{"class":62},[46,9245,818],{"class":62},[46,9247,555],{"class":122},[46,9249,823],{"class":62},[46,9251,291],{"class":122},[46,9253,9254,9256,9258,9260,9262,9264,9266,9268,9270,9272],{"class":48,"line":114},[46,9255,24],{"class":59},[46,9257,282],{"class":62},[46,9259,309],{"class":62},[46,9261,313],{"class":312},[46,9263,838],{"class":62},[46,9265,320],{"class":319},[46,9267,323],{"class":312},[46,9269,555],{"class":122},[46,9271,823],{"class":62},[46,9273,291],{"class":122},[46,9275,9276],{"class":48,"line":337},[46,9277,105],{"emptyLinePlaceholder":104},[46,9279,9280],{"class":48,"line":369},[46,9281,857],{"class":52},[46,9283,9284,9286,9288,9290,9292],{"class":48,"line":374},[46,9285,24],{"class":59},[46,9287,864],{"class":122},[46,9289,63],{"class":62},[46,9291,66],{"class":62},[46,9293,871],{"class":62},[46,9295,9296,9298,9300,9302,9304],{"class":48,"line":380},[46,9297,24],{"class":59},[46,9299,878],{"class":122},[46,9301,63],{"class":62},[46,9303,66],{"class":62},[46,9305,885],{"class":62},[16,9307,888,9308,892,9310,896],{},[27,9309,891],{},[43,9311,895],{},[36,9313,9314],{"className":38,"code":899,"language":40,"meta":41,"style":41},[43,9315,9316,9320,9326],{"__ignoreMap":41},[46,9317,9318],{"class":48,"line":49},[46,9319,906],{"class":52},[46,9321,9322,9324],{"class":48,"line":56},[46,9323,911],{"class":122},[46,9325,914],{"class":62},[46,9327,9328,9330,9332,9334,9336],{"class":48,"line":72},[46,9329,24],{"class":59},[46,9331,282],{"class":62},[46,9333,285],{"class":62},[46,9335,925],{"class":62},[46,9337,928],{"class":122},[262,9339,932],{"id":931},[16,9341,935],{},[36,9343,9344],{"className":38,"code":938,"language":40,"meta":41,"style":41},[43,9345,9346,9350,9364,9368,9372,9386,9390,9394,9408,9412,9416],{"__ignoreMap":41},[46,9347,9348],{"class":48,"line":49},[46,9349,945],{"class":52},[46,9351,9352,9354,9356,9358,9360,9362],{"class":48,"line":56},[46,9353,24],{"class":59},[46,9355,63],{"class":62},[46,9357,79],{"class":62},[46,9359,95],{"class":62},[46,9361,958],{"class":122},[46,9363,961],{"class":62},[46,9365,9366],{"class":48,"line":72},[46,9367,105],{"emptyLinePlaceholder":104},[46,9369,9370],{"class":48,"line":85},[46,9371,970],{"class":52},[46,9373,9374,9376,9378,9380,9382,9384],{"class":48,"line":101},[46,9375,24],{"class":59},[46,9377,63],{"class":62},[46,9379,66],{"class":62},[46,9381,981],{"class":62},[46,9383,984],{"class":122},[46,9385,987],{"class":62},[46,9387,9388],{"class":48,"line":108},[46,9389,105],{"emptyLinePlaceholder":104},[46,9391,9392],{"class":48,"line":114},[46,9393,996],{"class":52},[46,9395,9396,9398,9400,9402,9404,9406],{"class":48,"line":337},[46,9397,24],{"class":59},[46,9399,984],{"class":122},[46,9401,1005],{"class":62},[46,9403,63],{"class":62},[46,9405,79],{"class":62},[46,9407,82],{"class":62},[46,9409,9410],{"class":48,"line":369},[46,9411,105],{"emptyLinePlaceholder":104},[46,9413,9414],{"class":48,"line":374},[46,9415,1020],{"class":52},[46,9417,9418,9420,9422,9424],{"class":48,"line":380},[46,9419,24],{"class":59},[46,9421,282],{"class":62},[46,9423,1029],{"class":62},[46,9425,1032],{"class":62},[262,9427,1036],{"id":1035},[16,9429,1039],{},[36,9431,9432],{"className":38,"code":1042,"language":40,"meta":41,"style":41},[43,9433,9434,9438,9446,9450,9454,9466,9470,9474,9484,9488,9492,9506,9510,9514],{"__ignoreMap":41},[46,9435,9436],{"class":48,"line":49},[46,9437,1049],{"class":52},[46,9439,9440,9442,9444],{"class":48,"line":56},[46,9441,24],{"class":59},[46,9443,1056],{"class":62},[46,9445,1059],{"class":62},[46,9447,9448],{"class":48,"line":72},[46,9449,105],{"emptyLinePlaceholder":104},[46,9451,9452],{"class":48,"line":85},[46,9453,1068],{"class":52},[46,9455,9456,9458,9460,9462,9464],{"class":48,"line":101},[46,9457,24],{"class":59},[46,9459,1056],{"class":62},[46,9461,1077],{"class":62},[46,9463,1080],{"class":122},[46,9465,1083],{"class":62},[46,9467,9468],{"class":48,"line":108},[46,9469,105],{"emptyLinePlaceholder":104},[46,9471,9472],{"class":48,"line":114},[46,9473,1092],{"class":52},[46,9475,9476,9478,9480,9482],{"class":48,"line":337},[46,9477,24],{"class":59},[46,9479,1056],{"class":62},[46,9481,1077],{"class":62},[46,9483,1103],{"class":122},[46,9485,9486],{"class":48,"line":369},[46,9487,105],{"emptyLinePlaceholder":104},[46,9489,9490],{"class":48,"line":374},[46,9491,1112],{"class":52},[46,9493,9494,9496,9498,9500,9502,9504],{"class":48,"line":380},[46,9495,24],{"class":59},[46,9497,63],{"class":62},[46,9499,1121],{"class":62},[46,9501,519],{"class":62},[46,9503,1126],{"class":122},[46,9505,1129],{"class":62},[46,9507,9508],{"class":48,"line":484},[46,9509,105],{"emptyLinePlaceholder":104},[46,9511,9512],{"class":48,"line":498},[46,9513,1138],{"class":52},[46,9515,9516,9518,9520,9522,9524,9526,9528,9530],{"class":48,"line":503},[46,9517,24],{"class":59},[46,9519,63],{"class":62},[46,9521,1147],{"class":62},[46,9523,519],{"class":62},[46,9525,522],{"class":62},[46,9527,1154],{"class":62},[46,9529,1080],{"class":122},[46,9531,1159],{"class":62},[262,9533,1163],{"id":1162},[16,9535,1166],{},[131,9537,9538,9546],{},[134,9539,9540],{},[137,9541,9542,9544],{},[140,9543,1175],{},[140,9545,1178],{},[147,9547,9548,9556,9564,9572,9580],{},[137,9549,9550,9554],{},[152,9551,9552],{},[43,9553,1187],{},[152,9555,1190],{},[137,9557,9558,9562],{},[152,9559,9560],{},[43,9561,1197],{},[152,9563,1200],{},[137,9565,9566,9570],{},[152,9567,9568],{},[43,9569,1207],{},[152,9571,1210],{},[137,9573,9574,9578],{},[152,9575,9576],{},[43,9577,1217],{},[152,9579,1220],{},[137,9581,9582,9586],{},[152,9583,9584],{},[43,9585,1227],{},[152,9587,1230],{},[16,9589,1233],{},[262,9591,1237],{"id":1236},[16,9593,1240],{},[36,9595,9596],{"className":38,"code":1243,"language":40,"meta":41,"style":41},[43,9597,9598,9602,9612,9616,9620,9630,9634,9638],{"__ignoreMap":41},[46,9599,9600],{"class":48,"line":49},[46,9601,1250],{"class":52},[46,9603,9604,9606,9608,9610],{"class":48,"line":56},[46,9605,24],{"class":59},[46,9607,282],{"class":62},[46,9609,1259],{"class":62},[46,9611,1262],{"class":62},[46,9613,9614],{"class":48,"line":72},[46,9615,105],{"emptyLinePlaceholder":104},[46,9617,9618],{"class":48,"line":85},[46,9619,1271],{"class":52},[46,9621,9622,9624,9626,9628],{"class":48,"line":101},[46,9623,24],{"class":59},[46,9625,282],{"class":62},[46,9627,1259],{"class":62},[46,9629,1282],{"class":62},[46,9631,9632],{"class":48,"line":108},[46,9633,105],{"emptyLinePlaceholder":104},[46,9635,9636],{"class":48,"line":114},[46,9637,1291],{"class":52},[46,9639,9640,9642,9644,9646,9648,9650],{"class":48,"line":337},[46,9641,24],{"class":59},[46,9643,1298],{"class":122},[46,9645,1301],{"class":62},[46,9647,63],{"class":62},[46,9649,66],{"class":62},[46,9651,1308],{"class":122},[11,9653,1311],{"id":1311},[36,9655,9657],{"className":9656,"code":1316,"language":1317},[1315],[43,9658,1316],{"__ignoreMap":41},[16,9660,1322],{},[718,9662,9663,9669,9675,9681],{},[721,9664,9665,1332],{},[27,9666,9667],{},[43,9668,1331],{},[721,9670,9671,1340],{},[27,9672,9673],{},[43,9674,1339],{},[721,9676,9677,1348],{},[27,9678,9679],{},[43,9680,1347],{},[721,9682,9683,1356],{},[27,9684,9685],{},[43,9686,1355],{},[16,9688,1359,9689,1363],{},[43,9690,1362],{},[11,9692,1366],{"id":1366},[131,9694,9695,9705],{},[134,9696,9697],{},[137,9698,9699,9701,9703],{},[140,9700,1375],{},[140,9702,1378],{},[140,9704,1381],{},[147,9706,9707,9715,9723,9731],{},[137,9708,9709,9711,9713],{},[152,9710,1388],{},[152,9712,1391],{},[152,9714,1394],{},[137,9716,9717,9719,9721],{},[152,9718,1399],{},[152,9720,1402],{},[152,9722,1405],{},[137,9724,9725,9727,9729],{},[152,9726,1410],{},[152,9728,1413],{},[152,9730,1416],{},[137,9732,9733,9735,9737],{},[152,9734,1421],{},[152,9736,1424],{},[152,9738,1427],{},[11,9740,1430],{"id":1430},[16,9742,1433],{},[718,9744,9745,9747,9749,9751,9753],{},[721,9746,1438],{},[721,9748,1441],{},[721,9750,1444],{},[721,9752,1447],{},[721,9754,1450],{},[16,9756,1453,9757,1458],{},[19,9758,1457],{"href":21,"rel":9759},[23],[1460,9761,1462],{},{"title":41,"searchDepth":56,"depth":72,"links":9763},[9764,9765,9766,9767,9772,9780,9781,9782],{"id":13,"depth":56,"text":14},{"id":126,"depth":56,"text":126},{"id":215,"depth":56,"text":215},{"id":260,"depth":56,"text":260,"children":9768},[9769,9770,9771],{"id":264,"depth":72,"text":265},{"id":391,"depth":72,"text":392},{"id":531,"depth":72,"text":532},{"id":686,"depth":56,"text":686,"children":9773},[9774,9775,9776,9777,9778,9779],{"id":689,"depth":72,"text":690},{"id":739,"depth":72,"text":740},{"id":931,"depth":72,"text":932},{"id":1035,"depth":72,"text":1036},{"id":1162,"depth":72,"text":1163},{"id":1236,"depth":72,"text":1237},{"id":1311,"depth":56,"text":1311},{"id":1366,"depth":56,"text":1366},{"id":1430,"depth":56,"text":1430},{},{"title":5,"description":1484},[1495,1496,1497,1498,1499,1500],{"id":9787,"title":9788,"author":6,"body":9789,"description":10333,"difficulty":1485,"draft":1486,"extension":1487,"image":10334,"meta":10335,"navigation":104,"path":10336,"pinned":1486,"published":10337,"seo":10338,"series":10339,"seriesOrder":49,"stem":10340,"tags":10341,"updated":10344,"__hash__":10345},"posts\u002Fposts\u002Fsublog-evolution\u002Findex.md","尼克的小窝 架构变迁：从纯静态到前后端分离",{"type":8,"value":9790,"toc":10313},[9791,9795,9798,9809,9813,9817,9823,9827,9858,9861,9875,9879,9882,9950,9954,9957,9963,9967,9977,10007,10010,10014,10022,10025,10069,10073,10194,10198,10205,10209,10215,10221,10225,10228,10279,10283,10290,10297,10303,10306,10308],[11,9792,9794],{"id":9793},"写在前面","📝 写在前面",[16,9796,9797],{},"SuBlog 最初只是一个简单的技术博客——Nuxt 3 静态生成，Markdown 写文章，部署到静态托管完事。但随着功能需求的增长，纯静态的架构开始显露出局限性。",[16,9799,9800,9801,9804,9805,9808],{},"这篇文章记录从 ",[27,9802,9803],{},"纯静态"," 到 ",[27,9806,9807],{},"前后端分离"," 的演进过程，带你了解每一步背后的思考。",[11,9810,9812],{"id":9811},"第一代纯静态nuxt-3-nuxtcontent","📦 第一代：纯静态（Nuxt 3 + @nuxt\u002Fcontent）",[262,9814,9816],{"id":9815},"️-架构图","🏗️ 架构图",[36,9818,9821],{"className":9819,"code":9820,"language":1317,"meta":41},[1315],"┌─────────────────────────────────────┐\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",[43,9822,9820],{"__ignoreMap":41},[262,9824,9826],{"id":9825},"做了什么","🔧 做了什么",[718,9828,9829,9838,9843,9848],{},[721,9830,9831,2680,9833,2680,9835,9837],{},[27,9832,2989],{},[27,9834,2999],{},[27,9836,3009],{}," 搭建前端",[721,9839,9840,9842],{},[27,9841,3019],{}," 管理 Markdown 文章，编译时生成静态页面",[721,9844,9845,9847],{},[27,9846,3039],{}," 嵌入评论，依赖 GitHub Discussions",[721,9849,9850,9851,9854,9855,9857],{},"部署通过 ",[43,9852,9853],{},"nuxt generate"," 输出 ",[43,9856,3276],{},"，丢到 Nginx 或 Vercel",[262,9859,9860],{"id":4174},"✅ 优点",[718,9862,9863,9869,9872],{},[721,9864,9865,9866,9868],{},"部署极简，一个 ",[43,9867,9853],{}," 搞定",[721,9870,9871],{},"加载飞快，全是 HTML，没有服务端开销",[721,9873,9874],{},"无需后端，零运维成本",[262,9876,9878],{"id":9877},"局限","❌ 局限",[16,9880,9881],{},"随着时间的推移，纯静态的问题越来越明显：",[131,9883,9884,9893],{},[134,9885,9886],{},[137,9887,9888,9890],{},[140,9889,4468],{},[140,9891,9892],{},"具体表现",[147,9894,9895,9906,9917,9928,9939],{},[137,9896,9897,9903],{},[152,9898,9899,9900],{},"🐢 ",[27,9901,9902],{},"内容更新慢",[152,9904,9905],{},"改一篇文章要重新构建、重新部署，不能实时发布",[137,9907,9908,9914],{},[152,9909,9910,9911],{},"👤 ",[27,9912,9913],{},"没有用户体系",[152,9915,9916],{},"无法区分读者和管理员，所有内容对所有人一致可见",[137,9918,9919,9925],{},[152,9920,9921,9922],{},"🔒 ",[27,9923,9924],{},"评论受限",[152,9926,9927],{},"Giscus 需要 GitHub 账号才能评论，门槛高",[137,9929,9930,9936],{},[152,9931,9932,9933],{},"🚫 ",[27,9934,9935],{},"无法做动态功能",[152,9937,9938],{},"点赞、收藏、阅读统计、个性化推荐……统统做不了",[137,9940,9941,9947],{},[152,9942,9943,9944],{},"🔗 ",[27,9945,9946],{},"前端锁死",[152,9948,9949],{},"被 Nuxt\u002FVue 生态绑定，想换技术栈得重写",[11,9951,9953],{"id":9952},"第二代前后端分离react-go","⚡ 第二代：前后端分离（React + Go）",[262,9955,9816],{"id":9956},"️-架构图-1",[36,9958,9961],{"className":9959,"code":9960,"language":1317,"meta":41},[1315],"┌───────────────────────┐       ┌────────────────────┐\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",[43,9962,9960],{"__ignoreMap":41},[262,9964,9966],{"id":9965},"️-前端sublog-react","🖥️ 前端：sublog-react",[16,9968,9969,9970,9972,9973,9976],{},"选用 ",[27,9971,2846],{},"（App Router） + ",[27,9974,9975],{},"React 19","，理由是：",[718,9978,9979,9986,9993,10000],{},[721,9980,9981,9982,9985],{},"🎨 ",[27,9983,9984],{},"React 生态","——组件库成熟，开发效率高",[721,9987,9988,9989,9992],{},"📐 ",[27,9990,9991],{},"App Router","——支持布局嵌套，页面组织清晰",[721,9994,9995,9996,9999],{},"📦 ",[27,9997,9998],{},"静态导出","——仍可部署到静态托管",[721,10001,10002,10003,10006],{},"⚙️ ",[27,10004,10005],{},"运行时配置","——API 地址可动态注入，切换环境无需重新构建",[16,10008,10009],{},"前端不再直接读取 Markdown 文件，而是通过 HTTP API 获取文章数据。静态导出 + 客户端数据获取的组合，既保留了首屏加载速度，又实现了内容的动态更新。",[262,10011,10013],{"id":10012},"️-后端sublog-s","⚙️ 后端：sublog-s",[16,10015,10016,10017,2680,10019,10021],{},"后端使用 ",[27,10018,1498],{},[27,10020,6878],{},"，按业务逻辑分层组织代码。",[16,10023,10024],{},"核心技术选型：",[131,10026,10027,10035],{},[134,10028,10029],{},[137,10030,10031,10033],{},[140,10032,2977],{},[140,10034,2980],{},[147,10036,10037,10047,10058],{},[137,10038,10039,10044],{},[152,10040,10041,10042],{},"🚀 ",[27,10043,6878],{},[152,10045,10046],{},"HTTP 框架",[137,10048,10049,10055],{},[152,10050,10051,10052],{},"🗄️ ",[27,10053,10054],{},"GORM",[152,10056,10057],{},"ORM，数据库操作",[137,10059,10060,10066],{},[152,10061,10062,10063],{},"🔐 ",[27,10064,10065],{},"JWT",[152,10067,10068],{},"用户认证",[11,10070,10072],{"id":10071},"前后对比","📊 前后对比",[131,10074,10075,10088],{},[134,10076,10077],{},[137,10078,10079,10082,10085],{},[140,10080,10081],{},"维度",[140,10083,10084],{},"第一代（纯静态）",[140,10086,10087],{},"第二代（前后端分离）",[147,10089,10090,10102,10114,10128,10141,10154,10168,10180],{},[137,10091,10092,10096,10099],{},[152,10093,9981,10094],{},[27,10095,2942],{},[152,10097,10098],{},"Nuxt 3 (Vue)",[152,10100,10101],{},"Next.js (React 19)",[137,10103,10104,10109,10111],{},[152,10105,10106,10107],{},"🖥️ ",[27,10108,6879],{},[152,10110,2885],{},[152,10112,10113],{},"Go + Gin",[137,10115,10116,10122,10125],{},[152,10117,10118,10119],{},"📄 ",[27,10120,10121],{},"内容来源",[152,10123,10124],{},"Markdown 文件",[152,10126,10127],{},"API 数据库",[137,10129,10130,10135,10138],{},[152,10131,10041,10132],{},[27,10133,10134],{},"发布方式",[152,10136,10137],{},"全量重新构建",[152,10139,10140],{},"API 实时发布",[137,10142,10143,10148,10151],{},[152,10144,9910,10145],{},[27,10146,10147],{},"用户系统",[152,10149,10150],{},"❌ 无",[152,10152,10153],{},"✅ 注册\u002F登录",[137,10155,10156,10162,10165],{},[152,10157,10158,10159],{},"💬 ",[27,10160,10161],{},"评论",[152,10163,10164],{},"Giscus（需 GitHub）",[152,10166,10167],{},"自有评论系统（规划中）",[137,10169,10170,10174,10177],{},[152,10171,9995,10172],{},[27,10173,3251],{},[152,10175,10176],{},"1 个静态目录",[152,10178,10179],{},"前端静态 + 后端二进制",[137,10181,10182,10188,10191],{},[152,10183,10184,10185],{},"⚡ ",[27,10186,10187],{},"更新体验",[152,10189,10190],{},"改文章 → 构建 → 部署",[152,10192,10193],{},"改文章 → 立即生效",[11,10195,10197],{"id":10196},"新功能注册账号","🎉 新功能：注册账号",[16,10199,10200,10201,10204],{},"伴随着架构升级，",[27,10202,10203],{},"用户账号系统","也上线了。",[262,10206,10208],{"id":10207},"目前支持","📋 目前支持",[16,10210,10211,10212,4635],{},"现在你可以在尼克的小窝 ",[27,10213,10214],{},"注册一个账号",[36,10216,10219],{"className":10217,"code":10218,"language":1317},[1315],"1. ✏️ 填写用户名、邮箱、密码\n2. 📧 查收激活邮件\n3. 🔗 点击链接激活账号\n4. 🚪 登录后查看和管理个人信息\n",[43,10220,10218],{"__ignoreMap":41},[11,10222,10224],{"id":10223},"️-后续计划","🗓️ 后续计划",[16,10226,10227],{},"正在开发或规划中的功能：",[718,10229,10231,10237,10243,10249,10255,10261,10267,10273],{"className":10230},[3284],[721,10232,10234,10236],{"className":10233},[3288],[3290,10235],{"checked":104,"disabled":104,"type":3292}," 用户注册\u002F登录（已上线 🎉）",[721,10238,10240,10242],{"className":10239},[3288],[3290,10241],{"checked":104,"disabled":104,"type":3292}," 个人资料编辑（已上线 🎉）",[721,10244,10246,10248],{"className":10245},[3288],[3290,10247],{"disabled":104,"type":3292}," 💬 站内评论系统",[721,10250,10252,10254],{"className":10251},[3288],[3290,10253],{"disabled":104,"type":3292}," 🔖 文章收藏",[721,10256,10258,10260],{"className":10257},[3288],[3290,10259],{"disabled":104,"type":3292}," 📚 阅读历史",[721,10262,10264,10266],{"className":10263},[3288],[3290,10265],{"disabled":104,"type":3292}," 👍 文章点赞",[721,10268,10270,10272],{"className":10269},[3288],[3290,10271],{"disabled":104,"type":3292}," 🔔 关注通知",[721,10274,10276,10278],{"className":10275},[3288],[3290,10277],{"disabled":104,"type":3292}," 🎨 个人首页定制",[11,10280,10282],{"id":10281},"结语","💡 结语",[16,10284,10285,10286,10289],{},"从 Nuxt 3 纯静态到 React + Go 前后端分离，SuBlog 的架构变迁本质上是一个",[27,10287,10288],{},"功能驱动","的过程。静态博客有它的位置——写写文章、展示内容完全够用。但当你想做的不仅仅是\"展示\"时，后端的能力就不可或缺了。",[16,10291,10292,10293,10296],{},"这次重构最大的感触是：",[27,10294,10295],{},"架构选型不是越复杂越好，而是刚好够用最好","。纯静态阶段用 Nuxt 3 一点问题没有，直到功能需求超出了静态的边界，才自然推动到前后端分离。",[16,10298,10299,10300],{},"如果你也在纠结\"要不要上后端\"，不妨先问问自己：",[27,10301,10302],{},"你要做的是\"展示内容\"还是\"提供服务\"？",[16,10304,10305],{},"前者，静态就够了；后者，来吧，后端欢迎你。😄",[3325,10307],{},[16,10309,10310],{},[27,10311,10312],{},"🎉 目前注册功能已经可以用了，后续更多好玩的功能正在路上。部分功能可能需要登录才能体验，快注册一个账号吧！",{"title":41,"searchDepth":56,"depth":72,"links":10314},[10315,10316,10322,10327,10328,10331,10332],{"id":9793,"depth":56,"text":9794},{"id":9811,"depth":56,"text":9812,"children":10317},[10318,10319,10320,10321],{"id":9815,"depth":72,"text":9816},{"id":9825,"depth":72,"text":9826},{"id":4174,"depth":72,"text":9860},{"id":9877,"depth":72,"text":9878},{"id":9952,"depth":56,"text":9953,"children":10323},[10324,10325,10326],{"id":9956,"depth":72,"text":9816},{"id":9965,"depth":72,"text":9966},{"id":10012,"depth":72,"text":10013},{"id":10071,"depth":56,"text":10072},{"id":10196,"depth":56,"text":10197,"children":10329},[10330],{"id":10207,"depth":72,"text":10208},{"id":10223,"depth":56,"text":10224},{"id":10281,"depth":56,"text":10282},"记录尼克的小窝从 Nuxt 3 纯静态博客演进到 React + Go 前后端分离架构的全过程，包括架构设计的思考、技术选型的变化，以及新增的账号注册功能。","\u002Fposts\u002Fsublog-evolution\u002Fimg\u002Fcover.jpg",{},"\u002Fposts\u002Fsublog-evolution","2026-07-23T12:00:00",{"title":9788,"description":10333},"尼克的小窝 开发记录","posts\u002Fsublog-evolution\u002Findex",[10342,10343,2846,1498],"架构","React","2026-07-23","VMQ6A2K8GQ5t3DSlezWPd3KgXnmDVMn9QVeaI28Hcp8",{"id":10347,"title":10348,"author":6,"body":10349,"description":11512,"difficulty":1485,"draft":1486,"extension":1487,"image":11513,"meta":11514,"navigation":104,"path":11515,"pinned":1486,"published":11516,"seo":11517,"series":6,"seriesOrder":6,"stem":11518,"tags":11519,"updated":11523,"__hash__":11524},"posts\u002Fposts\u002Fip-geolocation-guide\u002Findex.md","IP 属地是怎么查出来的？本地查询与在线查询方案对比",{"type":8,"value":10350,"toc":11488},[10351,10353,10359,10362,10373,10383,10387,10393,10406,10412,10423,10430,10433,10440,10444,10455,10459,10470,10473,10477,10483,10489,10492,10496,10503,10521,10524,10527,10531,10562,10601,10607,10611,10750,10786,10795,10799,10839,10844,10848,10859,10862,10896,10899,10903,10906,10910,11101,11105,11188,11192,11199,11306,11310,11319,11405,11408,11411,11447,11450,11453,11461,11482,11485],[11,10352,9793],{"id":9793},[16,10354,10355,10356],{},"很多网站会在「登录记录」「安全中心」里显示你的登录 IP 归属地，比如「福建·福州 · 中国移动」。你可能好奇过：",[27,10357,10358],{},"服务器是怎么根据一个 IP 就判断出我在哪个城市、哪个运营商的？",[16,10360,10361],{},"这篇科普文章讲清楚三件事：",[1747,10363,10364,10367,10370],{},[721,10365,10366],{},"IP 属地到底查的是什么；",[721,10368,10369],{},"纯本地查询为什么能做到毫秒级且不联网；",[721,10371,10372],{},"几种主流查询方案的优缺点对比，方便你按场景选型。",[10374,10375,10376],"blockquote",{},[16,10377,10378,10379,10382],{},"说明：文中用到的 IP 均为 RFC 5737 保留的文档示例段（TEST-NET-2，",[43,10380,10381],{},"198.51.100.0\u002F24","），不会对应任何真实地址。",[11,10384,10386],{"id":10385},"先想清楚ip-属地查的是什么","先想清楚：IP 属地查的是什么",[16,10388,10389,10390,3388],{},"一个常见误区是「每个 IP 对应一个具体地址」。实际上 IP 属地的本质是 ",[27,10391,10392],{},"IP 段与归属地的映射",[16,10394,10395,10396,4635],{},"运营商和机构在申请 IP 时，是按",[27,10397,10398,10399,10405],{},"网段（CIDR）",[27,10400,10401,10402,10404],{},"成块分配的。比如 ",[43,10403,10381],{}," 这一个段，一整块都归属同一地区、同一运营商。所以归属地数据天然可以","按段存储",[36,10407,10410],{"className":10408,"code":10409,"language":1317,"meta":41},[1315],"IP 起始          IP 结束         归属地\n────────────────────────────────────────\n198.51.100.0     198.51.100.255  中国|福建|福州|移动\n198.51.101.0     198.51.101.255  中国|浙江|杭州|电信\n",[43,10411,10409],{"__ignoreMap":41},[16,10413,10414,10415,10418,10419,10422],{},"全球公网 IP 约有 42 亿个，但",[27,10416,10417],{},"网段只有几十万条","。正因为存的是「段」而不是「每个 IP」，完整的归属地数据文件才只有几 MB（ip2region 的 ",[43,10420,10421],{},".xdb"," 约 11MB）。这是「本地离线查询」可行的第一前提。",[16,10424,10425,10426,10429],{},"所以，",[27,10427,10428],{},"IP 属地从来不是精确到街道的定位","，它返回的是段级的「省｜市｜运营商」粗粒度信息。",[11,10431,10432],{"id":10432},"纯本地查询的原理",[16,10434,10435,10436,10439],{},"以开源的 ",[27,10437,10438],{},"ip2region"," 为例，它的查询完全在本地完成，不联网、不请求第三方，几十微秒到毫秒级返回。",[262,10441,10443],{"id":10442},"第一步把-ip-变成整数","第一步：把 IP 变成整数",[16,10445,10446,10447,10450,10451,10454],{},"以 ",[43,10448,10449],{},"198.51.100.42"," 为例，它会转成一个 32 位整数 ",[43,10452,10453],{},"0xC633642A","。IP 的四个数字本质就是 4 个字节，拼起来就是一个整数，这一步没有任何玄学。",[262,10456,10458],{"id":10457},"第二步二分查找","第二步：二分查找",[16,10460,10461,10462,10465,10466,10469],{},"在按起始 IP 排序的段列表里做",[27,10463,10464],{},"二分查找","，找到满足 ",[43,10467,10468],{},"段的起始IP ≤ 目标整数 ≤ 段的结束IP"," 的那一段。",[16,10471,10472],{},"二分查找的复杂度是 O(log N)，N 是几十万条，所以很快——这就是「不靠网络也能毫秒返回」的核心。",[262,10474,10476],{"id":10475},"第三步xdb-文件的两级结构","第三步：.xdb 文件的两级结构",[16,10478,10479,10480,10482],{},"ip2region 的 ",[43,10481,10421],{}," 不是简单顺序文件，内部分两个区，进一步加速：",[36,10484,10487],{"className":10485,"code":10486,"language":1317,"meta":41},[1315],"┌─────────────────────────────┐\n│  索引区 (Index)             │  \u002F\u002F 每段一条定长记录：startIP \u002F endIP \u002F 数据区指针 + 数据长度\n│  …… 二分快速定位段 ……       │\n├─────────────────────────────┤\n│  数据区 (Data)              │  \u002F\u002F 真正存放各段的地点字符串\n│  ……                         │\n└─────────────────────────────┘\n",[43,10488,10486],{"__ignoreMap":41},[16,10490,10491],{},"先二分索引区定位到「是哪一段」，再按指针去数据区读出地点字符串，一次磁盘读就能拿到结果。",[262,10493,10495],{"id":10494},"为什么离线数据不会很快过期","为什么「离线数据」不会很快过期",[16,10497,10498,10499,10502],{},"IP 归属取决于",[27,10500,10501],{},"注册机构和运营商的分配","，一个网段通常长期稳定属于某地区、某运营商。所以：",[718,10504,10505,10508,10518],{},[721,10506,10507],{},"下载一次数据文件，可以用很久；",[721,10509,10510,10511,10517],{},"归属偶尔变化时，",[27,10512,10513,10514,10516],{},"每月更新一次 ",[43,10515,10421],{}," 文件","即可（替换文件、不用重启服务，见下文实战）；",[721,10519,10520],{},"数据源来自 IANA\u002FAPNIC 等机构的分配表与公开归属信息，不是实时探测。",[11,10522,10523],{"id":10523},"主流查询方案对比",[16,10525,10526],{},"实际开发中，查询 IP 属地主要有三条路线。",[262,10528,10530],{"id":10529},"方案一ip2region纯本地离线推荐","方案一：ip2region（纯本地离线，推荐）",[36,10532,10534],{"className":4915,"code":10533,"language":230,"meta":41,"style":41},"\u002F\u002F Go 中的用法（github.com\u002Flionsoul2014\u002Fip2region\u002Fbinding\u002Fgolang）\nregion, err := searcher.SearchByStr(ip)\n\u002F\u002F 返回类似 \"中国|福建省|福州市|移动\"\n",[43,10535,10536,10541,10557],{"__ignoreMap":41},[46,10537,10538],{"class":48,"line":49},[46,10539,10540],{"class":52},"\u002F\u002F Go 中的用法（github.com\u002Flionsoul2014\u002Fip2region\u002Fbinding\u002Fgolang）\n",[46,10542,10543,10546,10548,10551,10554],{"class":48,"line":56},[46,10544,10545],{"class":319},"region, err ",[46,10547,4942],{"class":312},[46,10549,10550],{"class":319}," searcher.",[46,10552,10553],{"class":59},"SearchByStr",[46,10555,10556],{"class":319},"(ip)\n",[46,10558,10559],{"class":48,"line":72},[46,10560,10561],{"class":52},"\u002F\u002F 返回类似 \"中国|福建省|福州市|移动\"\n",[131,10563,10564,10572],{},[134,10565,10566],{},[137,10567,10568,10570],{},[140,10569,4174],{},[140,10571,4177],{},[147,10573,10574,10582,10590],{},[137,10575,10576,10579],{},[152,10577,10578],{},"免费、无网络依赖、无 QPS 限制",[152,10580,10581],{},"段级粒度，个别段可能显示到相邻地区",[137,10583,10584,10587],{},[152,10585,10586],{},"毫秒级查询，永不因外部服务挂掉",[152,10588,10589],{},"数据按月更新，最新分配的段可能短暂缺失",[137,10591,10592,10595],{},[152,10593,10594],{},"数据文件可随版本更新，不重启",[152,10596,10597,10598],{},"只返回省市区+运营商，无经纬度；",[27,10599,10600],{},"仅支持 IPv4",[16,10602,10603,10606],{},[27,10604,10605],{},"适用","：博客\u002F后台的「登录记录显示归属地」这类展示用途。",[262,10608,10610],{"id":10609},"方案二在线-apiip-api-高德-腾讯位置服务","方案二：在线 API（ip-api \u002F 高德 \u002F 腾讯位置服务）",[36,10612,10614],{"className":4915,"code":10613,"language":230,"meta":41,"style":41},"\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",[43,10615,10616,10621,10649,10659,10690,10706,10719,10729,10733,10745],{"__ignoreMap":41},[46,10617,10618],{"class":48,"line":49},[46,10619,10620],{"class":52},"\u002F\u002F 以 ip-api.com 为例（免费版约 45 次\u002F分钟，非商用）\n",[46,10622,10623,10626,10628,10630,10633,10636,10639,10642,10644,10646],{"class":48,"line":56},[46,10624,10625],{"class":319},"ctx, cancel ",[46,10627,4942],{"class":312},[46,10629,6414],{"class":319},[46,10631,10632],{"class":59},"WithTimeout",[46,10634,10635],{"class":319},"(context.",[46,10637,10638],{"class":59},"Background",[46,10640,10641],{"class":319},"(), ",[46,10643,3445],{"class":122},[46,10645,5147],{"class":312},[46,10647,10648],{"class":319},"time.Second)\n",[46,10650,10651,10654,10657],{"class":48,"line":72},[46,10652,10653],{"class":312},"defer",[46,10655,10656],{"class":59}," cancel",[46,10658,3688],{"class":319},[46,10660,10661,10664,10666,10669,10672,10675,10678,10680,10683,10685,10688],{"class":48,"line":85},[46,10662,10663],{"class":319},"req, _ ",[46,10665,4942],{"class":312},[46,10667,10668],{"class":319}," http.",[46,10670,10671],{"class":59},"NewRequestWithContext",[46,10673,10674],{"class":319},"(ctx, ",[46,10676,10677],{"class":62},"\"GET\"",[46,10679,659],{"class":319},[46,10681,10682],{"class":62},"\"https:\u002F\u002Fip-api.com\u002Fjson\u002F198.51.100.42\"",[46,10684,659],{"class":319},[46,10686,10687],{"class":122},"nil",[46,10689,3803],{"class":319},[46,10691,10692,10695,10697,10700,10703],{"class":48,"line":101},[46,10693,10694],{"class":319},"resp, err ",[46,10696,4942],{"class":312},[46,10698,10699],{"class":319}," http.DefaultClient.",[46,10701,10702],{"class":59},"Do",[46,10704,10705],{"class":319},"(req)\n",[46,10707,10708,10711,10713,10715,10717],{"class":48,"line":108},[46,10709,10710],{"class":312},"if",[46,10712,5355],{"class":319},[46,10714,5358],{"class":312},[46,10716,5361],{"class":122},[46,10718,3142],{"class":319},[46,10720,10721,10723,10726],{"class":48,"line":114},[46,10722,5172],{"class":312},[46,10724,10725],{"class":62}," \"未知\"",[46,10727,10728],{"class":52}," \u002F\u002F 断网\u002F超时降级：展示「未知」而不是报错\n",[46,10730,10731],{"class":48,"line":337},[46,10732,680],{"class":319},[46,10734,10735,10737,10740,10743],{"class":48,"line":369},[46,10736,10653],{"class":312},[46,10738,10739],{"class":319}," resp.Body.",[46,10741,10742],{"class":59},"Close",[46,10744,3688],{"class":319},[46,10746,10747],{"class":48,"line":374},[46,10748,10749],{"class":52},"\u002F\u002F 解析 JSON，status != \"success\" 时同样降级\n",[131,10751,10752,10760],{},[134,10753,10754],{},[137,10755,10756,10758],{},[140,10757,4174],{},[140,10759,4177],{},[147,10761,10762,10770,10778],{},[137,10763,10764,10767],{},[152,10765,10766],{},"数据最新、可能更精确",[152,10768,10769],{},"有频率限制（免费版限 QPS）",[137,10771,10772,10775],{},[152,10773,10774],{},"部分服务还返回经纬度\u002F时区",[152,10776,10777],{},"依赖第三方，断网\u002F被墙\u002F限流时拿不到",[137,10779,10780,10783],{},[152,10781,10782],{},"无需维护数据文件",[152,10784,10785],{},"每次查询都要发一次 HTTP 请求",[16,10787,10788,10790,10791,10794],{},[27,10789,10605],{},"：对精度要求高、且能接受外部依赖与限流的场景。",[27,10792,10793],{},"务必加超时和降级","，否则第三方一抖，你自己的页面就卡住。",[262,10796,10798],{"id":10797},"方案三maxmind-geolite2离线-mmdb","方案三：MaxMind GeoLite2（离线 .mmdb）",[131,10800,10801,10809],{},[134,10802,10803],{},[137,10804,10805,10807],{},[140,10806,4174],{},[140,10808,4177],{},[147,10810,10811,10819,10831],{},[137,10812,10813,10816],{},[152,10814,10815],{},"全球数据，覆盖广、精度较好",[152,10817,10818],{},"免费但需注册账号并遵守 EULA；商用需 GeoIP2 商业授权",[137,10820,10821,10828],{},[152,10822,10823,10824,10827],{},"标准 ",[43,10825,10826],{},".mmdb"," 格式，各语言都有库，支持 IPv6",[152,10829,10830],{},"国内 IP 的省市粒度有时不如 ip2region 细",[137,10832,10833,10836],{},[152,10834,10835],{},"离线查询",[152,10837,10838],{},"数据同样要定期更新",[16,10840,10841,10843],{},[27,10842,10605],{},"：面向全球用户的场景。",[11,10845,10847],{"id":10846},"ipv6本地离线方案的一个盲点","IPv6：本地离线方案的一个盲点",[16,10849,10850,10851,10854,10855,10858],{},"前面聊的都是 IPv4（32 位）。但家宽和移动网络越来越多地",[27,10852,10853],{},"只下发 IPv6","（128 位）地址，而 ",[27,10856,10857],{},"ip2region 目前只支持 IPv4","——拿到一个纯 IPv6 地址是查不出归属地的，本地库会返回空结果。",[16,10860,10861],{},"实际影响与常见应对：",[1747,10863,10864,10874,10880,10890],{},[721,10865,10866,10869,10870,10873],{},[27,10867,10868],{},"判断版本","：Go 里 ",[43,10871,10872],{},"net.ParseIP(ip).To4() != nil"," 就是 IPv4，先判断再查询；",[721,10875,10876,10879],{},[27,10877,10878],{},"降级展示","：IPv6 直接显示「IPv6 地址」，不显示属地，诚实第一；",[721,10881,10882,10885,10886,10889],{},[27,10883,10884],{},"优先取 IPv4","：有反向代理时，从 ",[43,10887,10888],{},"X-Forwarded-For"," 里挑一个 IPv4 来查；",[721,10891,10892,10895],{},[27,10893,10894],{},"需要 v6 归属","：换支持 IPv6 的数据源（如 MaxMind GeoLite2）。",[16,10897,10898],{},"这一点在接入时很容易漏掉，上线前记得拿一个纯 IPv6 地址回归一遍。",[11,10900,10902],{"id":10901},"实战给登录记录加-ip-属地","实战：给登录记录加 IP 属地",[16,10904,10905],{},"subLog 博客的登录记录就用了 ip2region。完整接入一共四步。",[262,10907,10909],{"id":10908},"_1-进程启动时加载数据文件","1. 进程启动时加载数据文件",[36,10911,10913],{"className":4915,"code":10912,"language":230,"meta":41,"style":41},"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",[43,10914,10915,10921,10930,10939,10943,10947,10952,10979,10983,10996,11018,11030,11037,11041,11057,11069,11075,11079,11090,11097],{"__ignoreMap":41},[46,10916,10917,10919],{"class":48,"line":49},[46,10918,4923],{"class":312},[46,10920,6320],{"class":319},[46,10922,10923,10925,10928],{"class":48,"line":56},[46,10924,7727],{"class":62},[46,10926,10927],{"class":59},"sync\u002Fatomic",[46,10929,1742],{"class":62},[46,10931,10932,10934,10937],{"class":48,"line":72},[46,10933,7727],{"class":62},[46,10935,10936],{"class":59},"github.com\u002Flionsoul2014\u002Fip2region\u002Fbinding\u002Fgolang\u002Fxdb",[46,10938,1742],{"class":62},[46,10940,10941],{"class":48,"line":85},[46,10942,3803],{"class":319},[46,10944,10945],{"class":48,"line":101},[46,10946,105],{"emptyLinePlaceholder":104},[46,10948,10949],{"class":48,"line":108},[46,10950,10951],{"class":52},"\u002F\u002F 用 atomic.Pointer 持有查询器，方便后面热更新\n",[46,10953,10954,10956,10959,10962,10964,10967,10969,10972,10974,10977],{"class":48,"line":114},[46,10955,7808],{"class":312},[46,10957,10958],{"class":319}," searcher ",[46,10960,10961],{"class":59},"atomic",[46,10963,3615],{"class":319},[46,10965,10966],{"class":59},"Pointer",[46,10968,4302],{"class":319},[46,10970,10971],{"class":59},"xdb",[46,10973,3615],{"class":319},[46,10975,10976],{"class":59},"Searcher",[46,10978,665],{"class":319},[46,10980,10981],{"class":48,"line":337},[46,10982,105],{"emptyLinePlaceholder":104},[46,10984,10985,10987,10990,10992,10994],{"class":48,"line":369},[46,10986,5116],{"class":312},[46,10988,10989],{"class":59}," initSearcher",[46,10991,6363],{"class":319},[46,10993,5160],{"class":312},[46,10995,3142],{"class":319},[46,10997,10998,11001,11003,11006,11008,11010,11013,11015],{"class":48,"line":374},[46,10999,11000],{"class":319},"    content, err ",[46,11002,4942],{"class":312},[46,11004,11005],{"class":319}," os.",[46,11007,7947],{"class":59},[46,11009,3494],{"class":319},[46,11011,11012],{"class":62},"\".\u002Fdata\u002Fip2region.xdb\"",[46,11014,3957],{"class":319},[46,11016,11017],{"class":52},"\u002F\u002F 约 11MB，一次性读入内存\n",[46,11019,11020,11022,11024,11026,11028],{"class":48,"line":380},[46,11021,3570],{"class":312},[46,11023,5355],{"class":319},[46,11025,5358],{"class":312},[46,11027,5361],{"class":122},[46,11029,3142],{"class":319},[46,11031,11032,11034],{"class":48,"line":484},[46,11033,5368],{"class":312},[46,11035,11036],{"class":319}," err\n",[46,11038,11039],{"class":48,"line":498},[46,11040,670],{"class":319},[46,11042,11043,11046,11048,11051,11054],{"class":48,"line":503},[46,11044,11045],{"class":319},"    s, err ",[46,11047,4942],{"class":312},[46,11049,11050],{"class":319}," xdb.",[46,11052,11053],{"class":59},"NewWithBuffer",[46,11055,11056],{"class":319},"(content)\n",[46,11058,11059,11061,11063,11065,11067],{"class":48,"line":509},[46,11060,3570],{"class":312},[46,11062,5355],{"class":319},[46,11064,5358],{"class":312},[46,11066,5361],{"class":122},[46,11068,3142],{"class":319},[46,11070,11071,11073],{"class":48,"line":1711},[46,11072,5368],{"class":312},[46,11074,11036],{"class":319},[46,11076,11077],{"class":48,"line":1716},[46,11078,670],{"class":319},[46,11080,11081,11084,11087],{"class":48,"line":1725},[46,11082,11083],{"class":319},"    searcher.",[46,11085,11086],{"class":59},"Store",[46,11088,11089],{"class":319},"(s)\n",[46,11091,11092,11094],{"class":48,"line":1730},[46,11093,5172],{"class":312},[46,11095,11096],{"class":122}," nil\n",[46,11098,11099],{"class":48,"line":1938},[46,11100,680],{"class":319},[262,11102,11104],{"id":11103},"_2-查询一次","2. 查询一次",[36,11106,11108],{"className":4915,"code":11107,"language":230,"meta":41,"style":41},"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",[43,11109,11110,11132,11151,11163,11170,11174,11184],{"__ignoreMap":41},[46,11111,11112,11114,11117,11119,11122,11125,11127,11130],{"class":48,"line":49},[46,11113,5116],{"class":312},[46,11115,11116],{"class":59}," ipRegion",[46,11118,3494],{"class":319},[46,11120,11121],{"class":3497},"ip",[46,11123,11124],{"class":312}," string",[46,11126,3957],{"class":319},[46,11128,11129],{"class":312},"string",[46,11131,3142],{"class":319},[46,11133,11134,11137,11139,11141,11144,11147,11149],{"class":48,"line":56},[46,11135,11136],{"class":319},"    region, err ",[46,11138,4942],{"class":312},[46,11140,10550],{"class":319},[46,11142,11143],{"class":59},"Load",[46,11145,11146],{"class":319},"().",[46,11148,10553],{"class":59},[46,11150,10556],{"class":319},[46,11152,11153,11155,11157,11159,11161],{"class":48,"line":72},[46,11154,3570],{"class":312},[46,11156,5355],{"class":319},[46,11158,5358],{"class":312},[46,11160,5361],{"class":122},[46,11162,3142],{"class":319},[46,11164,11165,11167],{"class":48,"line":85},[46,11166,5368],{"class":312},[46,11168,11169],{"class":62}," \"未知\"\n",[46,11171,11172],{"class":48,"line":101},[46,11173,670],{"class":319},[46,11175,11176,11178,11181],{"class":48,"line":108},[46,11177,5172],{"class":312},[46,11179,11180],{"class":319}," region ",[46,11182,11183],{"class":52},"\u002F\u002F \"中国|福建省|福州市|移动\"\n",[46,11185,11186],{"class":48,"line":114},[46,11187,680],{"class":319},[262,11189,11191],{"id":11190},"_3-加一层内存缓存","3. 加一层内存缓存",[16,11193,11194,11195,11198],{},"登录记录里同一个 IP 可能反复出现，用 ",[43,11196,11197],{},"sync.Map"," 按 IP 缓存结果，一次查询终身复用：",[36,11200,11202],{"className":4915,"code":11201,"language":230,"meta":41,"style":41},"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",[43,11203,11204,11219,11223,11242,11259,11270,11274,11285,11295,11302],{"__ignoreMap":41},[46,11205,11206,11208,11211,11214,11216],{"class":48,"line":49},[46,11207,7808],{"class":312},[46,11209,11210],{"class":319}," regionCache ",[46,11212,11213],{"class":59},"sync",[46,11215,3615],{"class":319},[46,11217,11218],{"class":59},"Map\n",[46,11220,11221],{"class":48,"line":56},[46,11222,105],{"emptyLinePlaceholder":104},[46,11224,11225,11227,11230,11232,11234,11236,11238,11240],{"class":48,"line":72},[46,11226,5116],{"class":312},[46,11228,11229],{"class":59}," ipRegionCached",[46,11231,3494],{"class":319},[46,11233,11121],{"class":3497},[46,11235,11124],{"class":312},[46,11237,3957],{"class":319},[46,11239,11129],{"class":312},[46,11241,3142],{"class":319},[46,11243,11244,11246,11249,11251,11254,11256],{"class":48,"line":85},[46,11245,3570],{"class":312},[46,11247,11248],{"class":319}," v, ok ",[46,11250,4942],{"class":312},[46,11252,11253],{"class":319}," regionCache.",[46,11255,11143],{"class":59},[46,11257,11258],{"class":319},"(ip); ok {\n",[46,11260,11261,11263,11266,11268],{"class":48,"line":101},[46,11262,5368],{"class":312},[46,11264,11265],{"class":319}," v.(",[46,11267,11129],{"class":312},[46,11269,3803],{"class":319},[46,11271,11272],{"class":48,"line":108},[46,11273,670],{"class":319},[46,11275,11276,11279,11281,11283],{"class":48,"line":114},[46,11277,11278],{"class":319},"    region ",[46,11280,4942],{"class":312},[46,11282,11116],{"class":59},[46,11284,10556],{"class":319},[46,11286,11287,11290,11292],{"class":48,"line":337},[46,11288,11289],{"class":319},"    regionCache.",[46,11291,11086],{"class":59},[46,11293,11294],{"class":319},"(ip, region)\n",[46,11296,11297,11299],{"class":48,"line":369},[46,11298,5172],{"class":312},[46,11300,11301],{"class":319}," region\n",[46,11303,11304],{"class":48,"line":374},[46,11305,680],{"class":319},[262,11307,11309],{"id":11308},"_4-数据热更新不用重启","4. 数据热更新（不用重启）",[16,11311,11312,11313,11315,11316,11318],{},"ip2region 每月发布新数据。下载新文件后重新 ",[43,11314,11053],{}," 并 ",[43,11317,11086],{}," 覆盖即可，进程完全不用重启：",[36,11320,11322],{"className":4915,"code":11321,"language":230,"meta":41,"style":41},"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",[43,11323,11324,11348,11361,11373,11379,11383,11395,11401],{"__ignoreMap":41},[46,11325,11326,11328,11331,11333,11336,11339,11342,11344,11346],{"class":48,"line":49},[46,11327,5116],{"class":312},[46,11329,11330],{"class":59}," reloadSearcher",[46,11332,3494],{"class":319},[46,11334,11335],{"class":3497},"newContent",[46,11337,11338],{"class":319}," []",[46,11340,11341],{"class":312},"byte",[46,11343,3957],{"class":319},[46,11345,5160],{"class":312},[46,11347,3142],{"class":319},[46,11349,11350,11352,11354,11356,11358],{"class":48,"line":56},[46,11351,11045],{"class":319},[46,11353,4942],{"class":312},[46,11355,11050],{"class":319},[46,11357,11053],{"class":59},[46,11359,11360],{"class":319},"(newContent)\n",[46,11362,11363,11365,11367,11369,11371],{"class":48,"line":72},[46,11364,3570],{"class":312},[46,11366,5355],{"class":319},[46,11368,5358],{"class":312},[46,11370,5361],{"class":122},[46,11372,3142],{"class":319},[46,11374,11375,11377],{"class":48,"line":85},[46,11376,5368],{"class":312},[46,11378,11036],{"class":319},[46,11380,11381],{"class":48,"line":101},[46,11382,670],{"class":319},[46,11384,11385,11387,11389,11392],{"class":48,"line":108},[46,11386,11083],{"class":319},[46,11388,11086],{"class":59},[46,11390,11391],{"class":319},"(s) ",[46,11393,11394],{"class":52},"\u002F\u002F 原子切换，老查询继续走旧数据，新查询走新数据\n",[46,11396,11397,11399],{"class":48,"line":114},[46,11398,5172],{"class":312},[46,11400,11096],{"class":122},[46,11402,11403],{"class":48,"line":337},[46,11404,680],{"class":319},[11,11406,11407],{"id":11407},"局限与诚实提醒",[16,11409,11410],{},"无论用哪种方案，都要清楚 IP 属地的边界：",[718,11412,11413,11419,11425,11431,11437],{},[721,11414,11415,11418],{},[27,11416,11417],{},"段级精度","：网段归属是「绝大多数」准确，个别 IP（大型企业自建段、跨省调度段）可能显示到相邻地区或只到省。",[721,11420,11421,11424],{},[27,11422,11423],{},"运营商级，不是定位","：拿到的是「省｜市｜运营商」，不是精确街道或基站位置——精确位置需要 GPS 或运营商三角定位，纯 IP 归属做不到。",[721,11426,11427,11430],{},[27,11428,11429],{},"动态 IP","：家宽通常是动态 IP，重拨会变，所以「上次登录在福州，这次在广州」不一定是异常，可能是 IP 变了。",[721,11432,11433,11436],{},[27,11434,11435],{},"别用于安全判定","：用 IP 段做封禁或风控判定风险自负，段级数据不适合当精确依据。",[721,11438,11439,11442,11443,11446],{},[27,11440,11441],{},"隐私与合规","：IP 地址属于个人信息（《个人信息保护法》）。归属地展示建议只出现在",[27,11444,11445],{},"用户本人可见","的页面（登录记录、个人中心）；若在评论区等公开场合展示，建议只显示到省市并明确告知用户。",[11,11448,11449],{"id":11449},"小结",[16,11451,11452],{},"一句话总结：",[10374,11454,11455],{},[16,11456,11457,11460],{},[27,11458,11459],{},"IP 属地 = 几十万条「IP 段 → 归属地」映射 + 一次二分查找。"," 它查的是网段归属，不是精确位置。",[718,11462,11463,11469,11476],{},[721,11464,11465,11466,11468],{},"想省心、免费、稳定，用 ",[27,11467,10438],{}," 本地离线查询（注意 IPv6 盲点）；",[721,11470,11471,11472,11475],{},"想要最新最细、能接受外部依赖，用",[27,11473,11474],{},"在线 API","（记得超时降级）；",[721,11477,11478,11479,3388],{},"面向全球用户、要 IPv6 覆盖，用 ",[27,11480,11481],{},"MaxMind GeoLite2",[16,11483,11484],{},"对个人博客「登录记录显示归属地」这种场景，本地离线方案通常是性价比最高的选择。",[1460,11486,11487],{},"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":41,"searchDepth":56,"depth":72,"links":11489},[11490,11491,11492,11498,11503,11504,11510,11511],{"id":9793,"depth":56,"text":9793},{"id":10385,"depth":56,"text":10386},{"id":10432,"depth":56,"text":10432,"children":11493},[11494,11495,11496,11497],{"id":10442,"depth":72,"text":10443},{"id":10457,"depth":72,"text":10458},{"id":10475,"depth":72,"text":10476},{"id":10494,"depth":72,"text":10495},{"id":10523,"depth":56,"text":10523,"children":11499},[11500,11501,11502],{"id":10529,"depth":72,"text":10530},{"id":10609,"depth":72,"text":10610},{"id":10797,"depth":72,"text":10798},{"id":10846,"depth":56,"text":10847},{"id":10901,"depth":56,"text":10902,"children":11505},[11506,11507,11508,11509],{"id":10908,"depth":72,"text":10909},{"id":11103,"depth":72,"text":11104},{"id":11190,"depth":72,"text":11191},{"id":11308,"depth":72,"text":11309},{"id":11407,"depth":56,"text":11407},{"id":11449,"depth":56,"text":11449},"科普 IP 属地查询的原理：IP 段与归属地的映射、纯本地离线查询为何能做到毫秒级，对比 ip2region、在线 API 与 MaxMind 三种方案的优劣，并附登录记录场景的 Go 实战与 IPv6 盲点提醒。","\u002Fposts\u002Fip-geolocation-guide\u002Fimg\u002Fcover.svg",{},"\u002Fposts\u002Fip-geolocation-guide","2026-08-07T12:00:00",{"title":10348,"description":11512},"posts\u002Fip-geolocation-guide\u002Findex",[11520,11521,11522],"IP","网络","科普","2026-08-16","RY1UPgJaiKsjpC51uqMyG7w_GpCRS13_CX5j1hDD_dM",{"id":11526,"title":11527,"author":6,"body":11528,"description":13050,"difficulty":2295,"draft":1486,"extension":1487,"image":13051,"meta":13052,"navigation":104,"path":13053,"pinned":1486,"published":13054,"seo":13055,"series":6875,"seriesOrder":72,"stem":13056,"tags":13057,"updated":11523,"__hash__":13060},"posts\u002Fposts\u002Fgo-embed-react-single-binary\u002Findex.md","Go 单文件打包进阶：React 前端 + 交叉编译，一次踩完所有坑",{"type":8,"value":11529,"toc":13029},[11530,11532,11543,11554,11561,11564,11600,11602,11611,11613,11617,11627,11671,11713,11731,11735,11756,11787,11791,12083,12086,12124,12128,12471,12474,12478,12675,12685,12687,12698,12712,12725,12736,12740,12747,12763,12776,12781,12785,12801,12806,12851,12865,12876,12880,12895,12904,12926,12935,12939,12949,12951,12978,12987,12989,12992,13019,13026],[11,11531,3364],{"id":3364},[16,11533,11534,11535,11538,11539,11542],{},"这是「Go 单文件打包」系列第三篇。上一篇（",[19,11536,11537],{"href":8624},"把前端塞进二进制","）记录了一个 Gin + Vue 3 项目的 embed 方案。这次换了个项目继续踩坑：",[27,11540,11541],{},"Magic Img","——一个自托管、本地优先的 AI 生图提示词管理工具，技术栈是：",[718,11544,11545,11548],{},[721,11546,11547],{},"前端：React 19 + TypeScript + Vite + Tailwind v4（UI 库 Magic UI + shadcn\u002Fui）",[721,11549,11550,11551,7413],{},"后端：Go + Gin + GORM + SQLite（",[27,11552,11553],{},"modernc 纯 Go 驱动，无 cgo",[16,11555,11556,11557,11560],{},"目标一样：",[27,11558,11559],{},"一个二进制拖到任何机器上就能跑","——前端、API、数据库全部内嵌。",[16,11562,11563],{},"和上篇不同的地方有三个，正好是这篇要讲的重点：",[1747,11565,11566,11580,11593],{},[721,11567,11568,11569,11575,11576,11579],{},"前端用了 ",[27,11570,11571,11572],{},"HashRouter + Vite base ",[43,11573,11574],{},"\u002Fdist","，服务端",[27,11577,11578],{},"不再需要 SPA fallback","（上篇坑一的新解法）；",[721,11581,11582,11583,11589,11590,11592],{},"embed 用 ",[27,11584,11585,11588],{},[43,11586,11587],{},"all:"," 前缀","一劳永逸解决 ",[43,11591,8319],{}," 开头文件被排除的问题（上篇坑二的升级解法）；",[721,11594,11595,11596,11599],{},"需要",[27,11597,11598],{},"在 Windows 上交叉编译 Linux 产物","，踩了「生成的文件无法在 Linux 执行」的坑。",[11,11601,7676],{"id":7676},[16,11603,11604,11605,11607,11608,11610],{},"和上篇一致：",[43,11606,7682],{}," 在编译期把前端 ",[43,11609,7686],{}," 塞进二进制，运行时由 Go 直接提供静态服务。差异在于路由策略和缓存策略，后面细说。",[11,11612,7690],{"id":7690},[262,11614,11616],{"id":11615},"_1-前端hash-路由-base-dist","1. 前端：hash 路由 + base \u002Fdist",[16,11618,11619,11620,11623,11624,4635],{},"vite.config.ts 里固定 ",[43,11621,11622],{},"base: \"\u002Fdist\u002F\"","，路由用 ",[43,11625,11626],{},"HashRouter",[36,11628,11630],{"className":8369,"code":11629,"language":8371,"meta":41,"style":41},"\u002F\u002F vite.config.ts\nexport default defineConfig({\n  base: \"\u002Fdist\u002F\",   \u002F\u002F 所有资源路径以 \u002Fdist\u002F 开头\n  ...\n})\n",[43,11631,11632,11636,11648,11662,11667],{"__ignoreMap":41},[46,11633,11634],{"class":48,"line":49},[46,11635,8378],{"class":52},[46,11637,11638,11640,11642,11645],{"class":48,"line":56},[46,11639,1605],{"class":312},[46,11641,4235],{"class":312},[46,11643,11644],{"class":59}," defineConfig",[46,11646,11647],{"class":319},"({\n",[46,11649,11650,11653,11656,11659],{"class":48,"line":72},[46,11651,11652],{"class":319},"  base: ",[46,11654,11655],{"class":62},"\"\u002Fdist\u002F\"",[46,11657,11658],{"class":319},",   ",[46,11660,11661],{"class":52},"\u002F\u002F 所有资源路径以 \u002Fdist\u002F 开头\n",[46,11663,11664],{"class":48,"line":85},[46,11665,11666],{"class":312},"  ...\n",[46,11668,11669],{"class":48,"line":101},[46,11670,4041],{"class":319},[36,11672,11676],{"className":11673,"code":11674,"language":11675,"meta":41,"style":41},"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",[43,11677,11678,11683,11691,11705],{"__ignoreMap":41},[46,11679,11680],{"class":48,"line":49},[46,11681,11682],{"class":52},"\u002F\u002F App.tsx —— HashRouter：路径信息全在 location.hash 里\n",[46,11684,11685,11687,11689],{"class":48,"line":56},[46,11686,2432],{"class":319},[46,11688,11626],{"class":122},[46,11690,2093],{"class":319},[46,11692,11693,11695,11698,11701,11703],{"class":48,"line":72},[46,11694,4068],{"class":319},[46,11696,11697],{"class":122},"Routes",[46,11699,11700],{"class":319},">...\u003C\u002F",[46,11702,11697],{"class":122},[46,11704,2093],{"class":319},[46,11706,11707,11709,11711],{"class":48,"line":85},[46,11708,4046],{"class":319},[46,11710,11626],{"class":122},[46,11712,2093],{"class":319},[16,11714,11715,11718,11719,11722,11723,11726,11727,11730],{},[27,11716,11717],{},"为什么这样选","：history 路由需要服务端把未知路径全部 fallback 回 ",[43,11720,11721],{},"index.html","（上篇坑一）。hash 路由下浏览器请求的永远是 ",[43,11724,11725],{},"\u002Fdist\u002F"," 这一个入口，",[27,11728,11729],{},"静态服务直接返回 index.html 就完事，一个 fallback 都不用写","。内嵌场景里这是最省心的组合。",[262,11732,11734],{"id":11733},"_2-构建产物复制进后端目录","2. 构建产物复制进后端目录",[16,11736,11737,11740,11741,11743,11744,11747,11748,11751,11752,11755],{},[43,11738,11739],{},"npm run build"," 除了编译，还会把 ",[43,11742,7686],{}," 复制到 ",[43,11745,11746],{},"backend\u002Fweb\u002Fdist","（一个小脚本 ",[43,11749,11750],{},"scripts\u002Fcopy-dist.mjs"," 干这个活），供 ",[43,11753,11754],{},"go:embed"," 使用：",[36,11757,11759],{"className":607,"code":11758,"language":609,"meta":41,"style":41},"\u002F\u002F frontend\u002Fpackage.json\n\"scripts\": {\n  \"build\": \"tsc -b && vite build && node scripts\u002Fcopy-dist.mjs\"\n}\n",[43,11760,11761,11766,11773,11783],{"__ignoreMap":41},[46,11762,11763],{"class":48,"line":49},[46,11764,11765],{"class":52},"\u002F\u002F frontend\u002Fpackage.json\n",[46,11767,11768,11771],{"class":48,"line":56},[46,11769,11770],{"class":62},"\"scripts\"",[46,11772,624],{"class":319},[46,11774,11775,11778,11780],{"class":48,"line":72},[46,11776,11777],{"class":122},"  \"build\"",[46,11779,639],{"class":319},[46,11781,11782],{"class":62},"\"tsc -b && vite build && node scripts\u002Fcopy-dist.mjs\"\n",[46,11784,11785],{"class":48,"line":85},[46,11786,680],{"class":319},[262,11788,11790],{"id":11789},"_3-后端embed-httpfilesystem-包装","3. 后端：embed + http.FileSystem 包装",[36,11792,11794],{"className":4915,"code":11793,"language":230,"meta":41,"style":41},"\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",[43,11795,11796,11801,11807,11811,11817,11825,11833,11841,11845,11849,11854,11867,11871,11876,11913,11917,11929,11937,11950,11954,11958,11997,12011,12021,12031,12058,12062,12079],{"__ignoreMap":41},[46,11797,11798],{"class":48,"line":49},[46,11799,11800],{"class":52},"\u002F\u002F backend\u002Fweb\u002Fembed.go\n",[46,11802,11803,11805],{"class":48,"line":56},[46,11804,7709],{"class":312},[46,11806,7712],{"class":59},[46,11808,11809],{"class":48,"line":72},[46,11810,105],{"emptyLinePlaceholder":104},[46,11812,11813,11815],{"class":48,"line":85},[46,11814,4923],{"class":312},[46,11816,6320],{"class":319},[46,11818,11819,11821,11823],{"class":48,"line":101},[46,11820,7727],{"class":62},[46,11822,7730],{"class":59},[46,11824,1742],{"class":62},[46,11826,11827,11829,11831],{"class":48,"line":108},[46,11828,7727],{"class":62},[46,11830,7757],{"class":59},[46,11832,1742],{"class":62},[46,11834,11835,11837,11839],{"class":48,"line":114},[46,11836,7727],{"class":62},[46,11838,7775],{"class":59},[46,11840,1742],{"class":62},[46,11842,11843],{"class":48,"line":337},[46,11844,3803],{"class":319},[46,11846,11847],{"class":48,"line":369},[46,11848,105],{"emptyLinePlaceholder":104},[46,11850,11851],{"class":48,"line":374},[46,11852,11853],{"class":52},"\u002F\u002Fgo:embed all:dist\n",[46,11855,11856,11858,11861,11863,11865],{"class":48,"line":380},[46,11857,7808],{"class":312},[46,11859,11860],{"class":319}," distFS ",[46,11862,7730],{"class":59},[46,11864,3615],{"class":319},[46,11866,7818],{"class":59},[46,11868,11869],{"class":48,"line":484},[46,11870,105],{"emptyLinePlaceholder":104},[46,11872,11873],{"class":48,"line":498},[46,11874,11875],{"class":52},"\u002F\u002F Dist 内嵌前端静态文件系统\n",[46,11877,11878,11880,11883,11886,11888,11891,11893,11896,11899,11902,11904,11907,11910],{"class":48,"line":503},[46,11879,7808],{"class":312},[46,11881,11882],{"class":319}," Dist ",[46,11884,11885],{"class":59},"http",[46,11887,3615],{"class":319},[46,11889,11890],{"class":59},"FileSystem",[46,11892,3139],{"class":312},[46,11894,11895],{"class":312}," &",[46,11897,11898],{"class":59},"fileSystem",[46,11900,11901],{"class":319},"{prefix: ",[46,11903,7858],{"class":62},[46,11905,11906],{"class":319},", fs: ",[46,11908,11909],{"class":312},"&",[46,11911,11912],{"class":319},"distFS}\n",[46,11914,11915],{"class":48,"line":509},[46,11916,105],{"emptyLinePlaceholder":104},[46,11918,11919,11921,11924,11927],{"class":48,"line":1711},[46,11920,6307],{"class":312},[46,11922,11923],{"class":59}," fileSystem",[46,11925,11926],{"class":312}," struct",[46,11928,3142],{"class":319},[46,11930,11931,11934],{"class":48,"line":1716},[46,11932,11933],{"class":319},"    prefix ",[46,11935,11936],{"class":312},"string\n",[46,11938,11939,11942,11944,11946,11948],{"class":48,"line":1725},[46,11940,11941],{"class":319},"    fs     ",[46,11943,5147],{"class":312},[46,11945,7730],{"class":59},[46,11947,3615],{"class":319},[46,11949,7818],{"class":59},[46,11951,11952],{"class":48,"line":1730},[46,11953,680],{"class":319},[46,11955,11956],{"class":48,"line":1938},[46,11957,105],{"emptyLinePlaceholder":104},[46,11959,11960,11962,11964,11967,11969,11971,11973,11976,11978,11980,11982,11984,11986,11988,11991,11993,11995],{"class":48,"line":1946},[46,11961,5116],{"class":312},[46,11963,3536],{"class":319},[46,11965,11966],{"class":3497},"f ",[46,11968,5147],{"class":312},[46,11970,11898],{"class":59},[46,11972,3957],{"class":319},[46,11974,11975],{"class":59},"Open",[46,11977,3494],{"class":319},[46,11979,8422],{"class":3497},[46,11981,11124],{"class":312},[46,11983,5144],{"class":319},[46,11985,11885],{"class":59},[46,11987,3615],{"class":319},[46,11989,11990],{"class":59},"File",[46,11992,659],{"class":319},[46,11994,5160],{"class":312},[46,11996,4744],{"class":319},[46,11998,11999,12001,12004,12006,12009],{"class":48,"line":1951},[46,12000,3570],{"class":312},[46,12002,12003],{"class":319}," name ",[46,12005,6015],{"class":312},[46,12007,12008],{"class":62}," \"\u002F\"",[46,12010,3142],{"class":319},[46,12012,12013,12016,12018],{"class":48,"line":2575},[46,12014,12015],{"class":319},"        name ",[46,12017,1611],{"class":312},[46,12019,12020],{"class":319}," f.prefix\n",[46,12022,12023,12026,12029],{"class":48,"line":2589},[46,12024,12025],{"class":319},"    } ",[46,12027,12028],{"class":312},"else",[46,12030,3142],{"class":319},[46,12032,12033,12035,12037,12040,12042,12044,12047,12049,12051,12054,12056],{"class":48,"line":2596},[46,12034,12015],{"class":319},[46,12036,1611],{"class":312},[46,12038,12039],{"class":319}," f.prefix ",[46,12041,4310],{"class":312},[46,12043,12008],{"class":62},[46,12045,12046],{"class":312}," +",[46,12048,6077],{"class":319},[46,12050,7896],{"class":59},[46,12052,12053],{"class":319},"(name, ",[46,12055,7902],{"class":62},[46,12057,3803],{"class":319},[46,12059,12060],{"class":48,"line":2601},[46,12061,670],{"class":319},[46,12063,12064,12066,12068,12071,12074,12076],{"class":48,"line":2607},[46,12065,5172],{"class":312},[46,12067,10668],{"class":319},[46,12069,12070],{"class":59},"FS",[46,12072,12073],{"class":319},"(f.fs).",[46,12075,11975],{"class":59},[46,12077,12078],{"class":319},"(name)\n",[46,12080,12081],{"class":48,"line":2615},[46,12082,680],{"class":319},[16,12084,12085],{},"两个细节：",[718,12087,12088,12111],{},[721,12089,12090,12098,12099,30,12101,12103,12104,12106,12107,12110],{},[27,12091,12092,12095,12096,11588],{},[43,12093,12094],{},"all:dist"," 里的 ",[43,12097,11587],{},"：embed 目录时默认会排除 ",[43,12100,8319],{},[43,12102,3615],{}," 开头的文件（上篇坑二就是被这个坑了，当时靠改 Vite 输出名绕开）。",[43,12105,11587],{}," 前缀表示「全部包含」，前端构建产物里那些 ",[43,12108,12109],{},"_xxx.js"," 助手文件直接安全落地，不用再改前端配置；",[721,12112,12113,12116,12117,12119,12120,12123],{},[43,12114,12115],{},"http.FileSystem"," 包装：加了一层 ",[43,12118,7686],{}," 前缀映射，",[43,12121,12122],{},"http.FileServer"," 和 Gin 都能直接用。",[262,12125,12127],{"id":12126},"_4-路由静态服务-缓存策略","4. 路由：静态服务 + 缓存策略",[36,12129,12131],{"className":4915,"code":12130,"language":230,"meta":41,"style":41},"\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",[43,12132,12133,12138,12180,12192,12197,12212,12257,12286,12304,12325,12330,12349,12372,12377,12394,12398,12419,12423,12463,12467],{"__ignoreMap":41},[46,12134,12135],{"class":48,"line":49},[46,12136,12137],{"class":52},"\u002F\u002F backend\u002Finternal\u002Fhandler\u002Frouter.go\n",[46,12139,12140,12142,12145,12147,12150,12152,12155,12157,12160,12162,12165,12167,12169,12171,12173,12175,12178],{"class":48,"line":56},[46,12141,5116],{"class":312},[46,12143,12144],{"class":59}," NewRouter",[46,12146,3494],{"class":319},[46,12148,12149],{"class":3497},"db",[46,12151,5740],{"class":312},[46,12153,12154],{"class":59},"gorm",[46,12156,3615],{"class":319},[46,12158,12159],{"class":59},"DB",[46,12161,659],{"class":319},[46,12163,12164],{"class":3497},"uploadDir",[46,12166,11124],{"class":312},[46,12168,3957],{"class":319},[46,12170,5147],{"class":312},[46,12172,5743],{"class":59},[46,12174,3615],{"class":319},[46,12176,12177],{"class":59},"Engine",[46,12179,3142],{"class":319},[46,12181,12182,12184,12186,12188,12190],{"class":48,"line":72},[46,12183,5556],{"class":319},[46,12185,4942],{"class":312},[46,12187,5561],{"class":319},[46,12189,5564],{"class":59},[46,12191,3688],{"class":319},[46,12193,12194],{"class":48,"line":85},[46,12195,12196],{"class":312},"    ...\n",[46,12198,12199,12202,12204,12206,12209],{"class":48,"line":101},[46,12200,12201],{"class":319},"    dist ",[46,12203,4942],{"class":312},[46,12205,10668],{"class":319},[46,12207,12208],{"class":59},"FileServer",[46,12210,12211],{"class":319},"(web.Dist)\n",[46,12213,12214,12216,12218,12220,12223,12225,12227,12229,12231,12233,12235,12237,12239,12242,12245,12247,12250,12252,12254],{"class":48,"line":108},[46,12215,5694],{"class":319},[46,12217,5723],{"class":59},[46,12219,3494],{"class":319},[46,12221,12222],{"class":62},"\"\u002Fdist\"",[46,12224,659],{"class":319},[46,12226,5116],{"class":312},[46,12228,3494],{"class":319},[46,12230,5737],{"class":3497},[46,12232,5740],{"class":312},[46,12234,5743],{"class":59},[46,12236,3615],{"class":319},[46,12238,5129],{"class":59},[46,12240,12241],{"class":319},") { c.",[46,12243,12244],{"class":59},"Redirect",[46,12246,3494],{"class":319},[46,12248,12249],{"class":122},"302",[46,12251,659],{"class":319},[46,12253,11655],{"class":62},[46,12255,12256],{"class":319},") })\n",[46,12258,12259,12261,12263,12265,12268,12270,12272,12274,12276,12278,12280,12282,12284],{"class":48,"line":114},[46,12260,5694],{"class":319},[46,12262,5723],{"class":59},[46,12264,3494],{"class":319},[46,12266,12267],{"class":62},"\"\u002Fdist\u002F*filepath\"",[46,12269,659],{"class":319},[46,12271,5116],{"class":312},[46,12273,3494],{"class":319},[46,12275,5737],{"class":3497},[46,12277,5740],{"class":312},[46,12279,5743],{"class":59},[46,12281,3615],{"class":319},[46,12283,5129],{"class":59},[46,12285,4744],{"class":319},[46,12287,12288,12290,12292,12294,12297,12299,12302],{"class":48,"line":337},[46,12289,7889],{"class":319},[46,12291,4942],{"class":312},[46,12293,5994],{"class":319},[46,12295,12296],{"class":59},"Param",[46,12298,3494],{"class":319},[46,12300,12301],{"class":62},"\"filepath\"",[46,12303,3803],{"class":319},[46,12305,12306,12308,12310,12312,12314,12316,12318,12320,12323],{"class":48,"line":369},[46,12307,6009],{"class":312},[46,12309,7911],{"class":319},[46,12311,6015],{"class":312},[46,12313,12008],{"class":62},[46,12315,6110],{"class":312},[46,12317,7911],{"class":319},[46,12319,6015],{"class":312},[46,12321,12322],{"class":62}," \"\u002Findex.html\"",[46,12324,3142],{"class":319},[46,12326,12327],{"class":48,"line":374},[46,12328,12329],{"class":52},"            \u002F\u002F index.html 禁缓存：升级后浏览器立刻拿到新入口\n",[46,12331,12332,12334,12337,12339,12342,12344,12347],{"class":48,"line":380},[46,12333,6025],{"class":319},[46,12335,12336],{"class":59},"Header",[46,12338,3494],{"class":319},[46,12340,12341],{"class":62},"\"Cache-Control\"",[46,12343,659],{"class":319},[46,12345,12346],{"class":62},"\"no-cache, no-store, must-revalidate\"",[46,12348,3803],{"class":319},[46,12350,12351,12354,12356,12359,12361,12364,12367,12370],{"class":48,"line":484},[46,12352,12353],{"class":319},"        } ",[46,12355,12028],{"class":312},[46,12357,12358],{"class":312}," if",[46,12360,6077],{"class":319},[46,12362,12363],{"class":59},"HasPrefix",[46,12365,12366],{"class":319},"(p, ",[46,12368,12369],{"class":62},"\"\u002Fassets\u002F\"",[46,12371,4744],{"class":319},[46,12373,12374],{"class":48,"line":498},[46,12375,12376],{"class":52},"            \u002F\u002F 带内容 hash 的资源可放心长缓存\n",[46,12378,12379,12381,12383,12385,12387,12389,12392],{"class":48,"line":503},[46,12380,6025],{"class":319},[46,12382,12336],{"class":59},[46,12384,3494],{"class":319},[46,12386,12341],{"class":62},[46,12388,659],{"class":319},[46,12390,12391],{"class":62},"\"public, max-age=31536000, immutable\"",[46,12393,3803],{"class":319},[46,12395,12396],{"class":48,"line":509},[46,12397,6063],{"class":319},[46,12399,12400,12403,12406,12408,12410,12413,12416],{"class":48,"line":1711},[46,12401,12402],{"class":319},"        http.",[46,12404,12405],{"class":59},"StripPrefix",[46,12407,3494],{"class":319},[46,12409,12222],{"class":62},[46,12411,12412],{"class":319},", dist).",[46,12414,12415],{"class":59},"ServeHTTP",[46,12417,12418],{"class":319},"(c.Writer, c.Request)\n",[46,12420,12421],{"class":48,"line":1716},[46,12422,5789],{"class":319},[46,12424,12425,12427,12429,12431,12433,12435,12437,12439,12441,12443,12445,12447,12449,12451,12453,12455,12457,12459,12461],{"class":48,"line":1725},[46,12426,5694],{"class":319},[46,12428,5723],{"class":59},[46,12430,3494],{"class":319},[46,12432,7902],{"class":62},[46,12434,659],{"class":319},[46,12436,5116],{"class":312},[46,12438,3494],{"class":319},[46,12440,5737],{"class":3497},[46,12442,5740],{"class":312},[46,12444,5743],{"class":59},[46,12446,3615],{"class":319},[46,12448,5129],{"class":59},[46,12450,12241],{"class":319},[46,12452,12244],{"class":59},[46,12454,3494],{"class":319},[46,12456,12249],{"class":122},[46,12458,659],{"class":319},[46,12460,11655],{"class":62},[46,12462,12256],{"class":319},[46,12464,12465],{"class":48,"line":1730},[46,12466,12196],{"class":312},[46,12468,12469],{"class":48,"line":1938},[46,12470,680],{"class":319},[16,12472,12473],{},"缓存策略是内嵌发布的必修课（坑二细说）：入口禁缓存、hash 资源长缓存。",[262,12475,12477],{"id":12476},"_5-打包脚本windows-版-linux-交叉编译版","5. 打包脚本：Windows 版 + Linux 交叉编译版",[36,12479,12481],{"className":38,"code":12480,"language":40,"meta":41,"style":41},"#!\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",[43,12482,12483,12488,12493,12503,12521,12525,12546,12550,12558,12577,12581,12588,12645,12660,12664],{"__ignoreMap":41},[46,12484,12485],{"class":48,"line":49},[46,12486,12487],{"class":52},"#!\u002Fusr\u002Fbin\u002Fenv bash\n",[46,12489,12490],{"class":48,"line":56},[46,12491,12492],{"class":52},"# build-linux.sh —— 在 Windows\u002FmacOS\u002FLinux 任意平台交叉编译 linux\u002Famd64 产物\n",[46,12494,12495,12497,12500],{"class":48,"line":72},[46,12496,8167],{"class":122},[46,12498,12499],{"class":122}," -euo",[46,12501,12502],{"class":62}," pipefail\n",[46,12504,12505,12507,12510,12513,12515,12518],{"class":48,"line":85},[46,12506,911],{"class":122},[46,12508,12509],{"class":62}," \"$(",[46,12511,12512],{"class":59},"dirname",[46,12514,1546],{"class":62},[46,12516,12517],{"class":122},"$0",[46,12519,12520],{"class":62},"\")\"\n",[46,12522,12523],{"class":48,"line":101},[46,12524,105],{"emptyLinePlaceholder":104},[46,12526,12527,12530,12532,12535,12537,12540,12543],{"class":48,"line":108},[46,12528,12529],{"class":319},"BINARY",[46,12531,1611],{"class":312},[46,12533,12534],{"class":62},"\"${",[46,12536,12529],{"class":319},[46,12538,12539],{"class":312},":-",[46,12541,12542],{"class":319},"magic-img",[46,12544,12545],{"class":62},"}\"\n",[46,12547,12548],{"class":48,"line":114},[46,12549,105],{"emptyLinePlaceholder":104},[46,12551,12552,12555],{"class":48,"line":337},[46,12553,12554],{"class":122},"echo",[46,12556,12557],{"class":62}," \"[1\u002F3] 构建前端并复制产物到 backend\u002Fweb\u002Fdist ...\"\n",[46,12559,12560,12562,12564,12567,12569,12571,12573,12575],{"class":48,"line":369},[46,12561,3494],{"class":319},[46,12563,911],{"class":122},[46,12565,12566],{"class":62}," frontend",[46,12568,1584],{"class":319},[46,12570,7044],{"class":59},[46,12572,1534],{"class":62},[46,12574,1656],{"class":62},[46,12576,3803],{"class":319},[46,12578,12579],{"class":48,"line":374},[46,12580,105],{"emptyLinePlaceholder":104},[46,12582,12583,12585],{"class":48,"line":380},[46,12584,12554],{"class":122},[46,12586,12587],{"class":62}," \"[2\u002F3] 交叉编译后端并内嵌前端 (CGO_ENABLED=0 GOOS=linux GOARCH=amd64) ...\"\n",[46,12589,12590,12592,12594,12597,12600,12602,12604,12607,12609,12612,12615,12617,12620,12622,12624,12626,12628,12631,12634,12637,12640,12643],{"class":48,"line":484},[46,12591,3494],{"class":319},[46,12593,911],{"class":122},[46,12595,12596],{"class":62}," backend",[46,12598,12599],{"class":319}," && CGO_ENABLED",[46,12601,1611],{"class":312},[46,12603,3581],{"class":62},[46,12605,12606],{"class":319}," GOOS",[46,12608,1611],{"class":312},[46,12610,12611],{"class":62},"linux",[46,12613,12614],{"class":319}," GOARCH",[46,12616,1611],{"class":312},[46,12618,12619],{"class":62},"amd64",[46,12621,2003],{"class":59},[46,12623,1656],{"class":62},[46,12625,1665],{"class":122},[46,12627,1546],{"class":62},[46,12629,12630],{"class":319},"$BINARY",[46,12632,12633],{"class":62},"\"",[46,12635,12636],{"class":122}," -ldflags=",[46,12638,12639],{"class":62},"\"-s -w\"",[46,12641,12642],{"class":62}," .\u002Fcmd\u002Fserver",[46,12644,3803],{"class":319},[46,12646,12647,12650,12653,12656,12658],{"class":48,"line":498},[46,12648,12649],{"class":59},"chmod",[46,12651,12652],{"class":62}," +x",[46,12654,12655],{"class":62}," \"backend\u002F",[46,12657,12630],{"class":319},[46,12659,1742],{"class":62},[46,12661,12662],{"class":48,"line":503},[46,12663,105],{"emptyLinePlaceholder":104},[46,12665,12666,12668,12671,12673],{"class":48,"line":509},[46,12667,12554],{"class":122},[46,12669,12670],{"class":62}," \"[3\u002F3] 打包完成: backend\u002F",[46,12672,12630],{"class":319},[46,12674,1742],{"class":62},[16,12676,12677,12678,12681,12682,3388],{},"Windows 版（",[43,12679,12680],{},"build-win.bat","）同理，只是产物叫 ",[43,12683,12684],{},"magic-img.exe",[11,12686,8279],{"id":8279},[262,12688,12690,12691,12694,12695,12697],{"id":12689},"坑一goembed-dist-静默漏掉-_-开头文件升级解法","坑一：",[43,12692,12693],{},"\u002F\u002Fgo:embed dist"," 静默漏掉 ",[43,12696,8319],{}," 开头文件（升级解法）",[16,12699,12700,12701,12708,12709,12711],{},"上篇说过：embed 目录时",[27,12702,12703,12704,30,12706,8320],{},"硬编码排除 ",[43,12705,8319],{},[43,12707,3615],{},"。上篇的解法是改 Vite 输出文件名去掉 ",[43,12710,8319],{}," 前缀；这次直接用：",[36,12713,12715],{"className":4915,"code":12714,"language":230,"meta":41,"style":41},"\u002F\u002Fgo:embed all:dist   \u002F\u002F all: 前缀 = 包含全部文件，不再静默跳过\n",[43,12716,12717],{"__ignoreMap":41},[46,12718,12719,12722],{"class":48,"line":49},[46,12720,12721],{"class":52},"\u002F\u002Fgo:embed all:dist",[46,12723,12724],{"class":52},"   \u002F\u002F all: 前缀 = 包含全部文件，不再静默跳过\n",[16,12726,12727],{},[27,12728,12729,12730,12732,12733,12735],{},"结论：能用 ",[43,12731,11587],{}," 就用 ",[43,12734,11587],{},"，从根上消灭这类\"文件莫名 404\"的问题。",[262,12737,12739],{"id":12738},"坑二开发版是新的打包版却是旧的","坑二：开发版是新的、打包版却是旧的",[16,12741,12742,12743,12746],{},"现象：Vite 开发服务一切正常，",[43,12744,12745],{},":8080\u002Fdist\u002F"," 打包版却是旧界面，强刷也不一定好。",[16,12748,12749,12750,12752,12753,12756,12757,12762],{},"排查过程值得记录：先是怀疑 ",[43,12751,8481],{}," 缓存了旧 embed（上篇坑三的思路），重编重启无效；然后直接抓取 8080 返回的 HTML 和 JS，发现",[27,12754,12755],{},"服务端内容已经是新的","——问题出在",[27,12758,12759,12760],{},"浏览器缓存了 ",[43,12761,11721],{},"：它引用旧 hash 的资源，一直不更新。",[16,12764,12765,12767,12768,12771,12772,12775],{},[27,12766,8297],{},"：入口文件响应 ",[43,12769,12770],{},"Cache-Control: no-store","（见上面路由代码），带 hash 的资源给 ",[43,12773,12774],{},"immutable"," 长缓存。升级后浏览器每次拉新入口，资源文件名一变就自动换新，两全其美。",[10374,12777,12778],{},[16,12779,12780],{},"教训：排查\"新旧不一致\"先分清是哪一层——服务端产物、浏览器入口缓存、还是资源缓存。抓包看响应内容再动手。",[262,12782,12784],{"id":12783},"坑三windows-上跑的-build-linuxsh产出的文件-linux-却执行不了","坑三：Windows 上跑的 build-linux.sh，产出的文件 Linux 却执行不了",[16,12786,12787,12788,7529,12791,12797,12798,12800],{},"最初脚本只写了 ",[43,12789,12790],{},"CGO_ENABLED=0 go build",[27,12792,12793,12794],{},"没指定 ",[43,12795,12796],{},"GOOS\u002FGOARCH","。在 Windows 上跑，产出的其实是个 Windows PE 文件（只是没叫 ",[43,12799,188],{},"），传到 Linux 自然「无法执行」。",[16,12802,12803,12805],{},[27,12804,8297],{},"：脚本里显式交叉编译：",[36,12807,12809],{"className":38,"code":12808,"language":40,"meta":41,"style":41},"CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o \"$BINARY\" -ldflags=\"-s -w\" .\u002Fcmd\u002Fserver\n",[43,12810,12811],{"__ignoreMap":41},[46,12812,12813,12816,12818,12820,12822,12824,12826,12828,12830,12832,12834,12836,12838,12840,12842,12844,12846,12848],{"class":48,"line":49},[46,12814,12815],{"class":319},"CGO_ENABLED",[46,12817,1611],{"class":312},[46,12819,3581],{"class":62},[46,12821,12606],{"class":319},[46,12823,1611],{"class":312},[46,12825,12611],{"class":62},[46,12827,12614],{"class":319},[46,12829,1611],{"class":312},[46,12831,12619],{"class":62},[46,12833,2003],{"class":59},[46,12835,1656],{"class":62},[46,12837,1665],{"class":122},[46,12839,1546],{"class":62},[46,12841,12630],{"class":319},[46,12843,12633],{"class":62},[46,12845,12636],{"class":122},[46,12847,12639],{"class":62},[46,12849,12850],{"class":62}," .\u002Fcmd\u002Fserver\n",[16,12852,12853,12854,12857,12858,12861,12862],{},"能这么干的前提是",[27,12855,12856],{},"全依赖树无 cgo","——SQLite 用的是 modernc 纯 Go 驱动，bcrypt 用 ",[43,12859,12860],{},"x\u002Fcrypto","。这也是选型时就要留意的点：",[27,12863,12864],{},"想跨平台交叉编译，先把 cgo 依赖清干净。",[16,12866,12867,12868,12871,12872,12875],{},"另外两个小提醒：Windows 上编出的文件没有 Unix 执行位，上传服务器后记得 ",[43,12869,12870],{},"chmod +x","；",[43,12873,12874],{},"-ldflags=\"-s -w\""," 剥离符号，体积从 30MB+ 降到 26MB。",[262,12877,12879],{"id":12878},"坑四dist-被-gitignore-后embed-直接编译失败","坑四：dist 被 gitignore 后，embed 直接编译失败",[16,12881,12882,12884,12885,12887,12888,12891,12892,12894],{},[43,12883,11746],{}," 是构建产物、入了 .gitignore，但 ",[43,12886,7682],{}," 在",[27,12889,12890],{},"编译期","就要读它——刚 clone 下来直接 ",[43,12893,8481],{}," 会报 embed 找不到文件。",[16,12896,12897,12899,12900,12903],{},[27,12898,8297],{},"：gitignore 里保留 ",[43,12901,12902],{},"dist\u002F.gitkeep","（一个空文件），让目录存在于版本库中：",[36,12905,12909],{"className":12906,"code":12907,"language":12908,"meta":41,"style":41},"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",[43,12910,12911,12916,12921],{"__ignoreMap":41},[46,12912,12913],{"class":48,"line":49},[46,12914,12915],{},"backend\u002Fweb\u002Fdist\u002F*\n",[46,12917,12918],{"class":48,"line":56},[46,12919,12920],{},"!backend\u002Fweb\u002Fdist\u002F\n",[46,12922,12923],{"class":48,"line":72},[46,12924,12925],{},"!backend\u002Fweb\u002Fdist\u002F.gitkeep\n",[16,12927,12928,12929,12931,12932,12934],{},"这样 clone 后可以先 ",[43,12930,8481],{}," 通过，再 ",[43,12933,11739],{}," 灌入真实前端。",[262,12936,12938],{"id":12937},"坑五500kb-chunk-警告别慌","坑五：>500KB chunk 警告，别慌",[16,12940,12941,12942,12945,12946,3388],{},"Vite 构建会警告单 chunk 超过 500KB。这个项目里 Base UI 组件库全量引入，单 chunk 约 640KB——",[27,12943,12944],{},"属正常，不要为了消警告去拆配置","。真要优化就上路由级 ",[43,12947,12948],{},"React.lazy",[11,12950,8494],{"id":8494},[36,12952,12954],{"className":38,"code":12953,"language":40,"meta":41,"style":41},"$ build-win.bat        # backendmagic-img.exe\n$ bash build-linux.sh  # backendmagic-img（linux\u002Famd64 ELF）\n",[43,12955,12956,12966],{"__ignoreMap":41},[46,12957,12958,12960,12963],{"class":48,"line":49},[46,12959,3609],{"class":59},[46,12961,12962],{"class":62}," build-win.bat",[46,12964,12965],{"class":52},"        # backendmagic-img.exe\n",[46,12967,12968,12970,12972,12975],{"class":48,"line":56},[46,12969,3609],{"class":59},[46,12971,8506],{"class":62},[46,12973,12974],{"class":62}," build-linux.sh",[46,12976,12977],{"class":52},"  # backendmagic-img（linux\u002Famd64 ELF）\n",[16,12979,12980,12981,12984,12985,3388],{},"一个约 26MB 的二进制，内嵌 React 前端、Gin API、SQLite 数据初始化，拷到 Windows\u002FLinux 机器直接跑，浏览器访问 ",[43,12982,12983],{},"http:\u002F\u002Flocalhost:8080"," 自动重定向到 ",[43,12986,11725],{},[11,12988,1430],{"id":1430},[16,12990,12991],{},"这轮相比上篇的三点升级：",[1747,12993,12994,13000,13010],{},[721,12995,12996,12999],{},[27,12997,12998],{},"hash 路由 + base \u002Fdist","：省掉 SPA fallback，静态服务即开即用；",[721,13001,13002,4635,13007,13009],{},[27,13003,13004,13006],{},[43,13005,11587],{}," embed 前缀",[43,13008,8319],{}," 开头文件问题一劳永逸；",[721,13011,13012,13018],{},[27,13013,13014,13015,13017],{},"显式 ",[43,13016,12796],{}," 交叉编译","：让「一个脚本、任意平台、产出 Linux 二进制」成为现实。",[16,13020,13021,13022,13025],{},"加上",[27,13023,13024],{},"入口 no-store \u002F 资源 immutable"," 的缓存策略，单文件分发的体验才算闭环——不然每次升级都要和浏览器缓存打架。",[1460,13027,13028],{},"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":41,"searchDepth":56,"depth":72,"links":13030},[13031,13032,13033,13040,13048,13049],{"id":3364,"depth":56,"text":3364},{"id":7676,"depth":56,"text":7676},{"id":7690,"depth":56,"text":7690,"children":13034},[13035,13036,13037,13038,13039],{"id":11615,"depth":72,"text":11616},{"id":11733,"depth":72,"text":11734},{"id":11789,"depth":72,"text":11790},{"id":12126,"depth":72,"text":12127},{"id":12476,"depth":72,"text":12477},{"id":8279,"depth":56,"text":8279,"children":13041},[13042,13044,13045,13046,13047],{"id":12689,"depth":72,"text":13043},"坑一：\u002F\u002Fgo:embed dist 静默漏掉 _ 开头文件（升级解法）",{"id":12738,"depth":72,"text":12739},{"id":12783,"depth":72,"text":12784},{"id":12878,"depth":72,"text":12879},{"id":12937,"depth":72,"text":12938},{"id":8494,"depth":56,"text":8494},{"id":1430,"depth":56,"text":1430},"以 Magic Img（React 19 + Vite + Gin + SQLite）为实例，记录把前端塞进 Go 二进制、并在 Windows 上交叉编译 Linux 产物的完整过程：embed 的 all 前缀、hash 路由、缓存策略、GOOS 交叉编译等实操坑。","\u002Fposts\u002Fgo-embed-react-single-binary\u002Fimg\u002Fcover.svg",{},"\u002Fposts\u002Fgo-embed-react-single-binary","2026-08-16T00:00:00",{"title":11527,"description":13050},"posts\u002Fgo-embed-react-single-binary\u002Findex",[1498,10343,7730,13058,13059],"交叉编译","打包","WV-T-MOO5Cu-aycw-Ony8s9hjA3_k0S4XU-T0QsCow0",{"id":13062,"title":13063,"author":6,"body":13064,"description":14309,"difficulty":1485,"draft":1486,"extension":1487,"image":14310,"meta":14311,"navigation":104,"path":14312,"pinned":1486,"published":13054,"seo":14313,"series":14314,"seriesOrder":49,"stem":14315,"tags":14316,"updated":11523,"__hash__":14320},"posts\u002Fposts\u002Freact-photo-album-masonry\u002Findex.md","用 react-photo-album 给生图图库做瀑布流排版",{"type":8,"value":13065,"toc":14292},[13066,13069,13072,13090,13097,13161,13172,13175,13198,13201,13205,13219,13223,13239,13417,13421,13818,13821,13855,13858,13862,13872,13878,13903,13908,13919,13925,13936,13939,13979,13992,14115,14127,14131,14138,14156,14161,14251,14257,14261,14272,14275,14278,14284,14289],[11,13067,13068],{"id":13068},"技术背景",[16,13070,13071],{},"Magic Img 是我做的一个 AI 生图提示词管理工具（自托管、本地优先），其中「生图图库」页面要把所有生成图片以图片墙的形式展示出来。图片墙有两个天然矛盾：",[1747,13073,13074,13080],{},[721,13075,13076,13079],{},[27,13077,13078],{},"等宽网格","（CSS Grid）：整齐，但每行高度由最高的那张决定——不同比例的图片混排时，矮图下面会留下大洞，看起来\"很乱\"；",[721,13081,13082,13085,13086,13089],{},[27,13083,13084],{},"原生比例展示","：图库必须不裁剪（这是产品约束），所以不能用 ",[43,13087,13088],{},"object-cover"," 强行统一比例。",[16,13091,13092,13093,13096],{},"业内的标准解法是 ",[27,13094,13095],{},"Masonry 瀑布流","：按列紧密堆放，每张图保持原始比例，列间高度错落但整体紧凑。实现瀑布流的常见方案有三类：",[131,13098,13099,13113],{},[134,13100,13101],{},[137,13102,13103,13105,13108,13111],{},[140,13104,1375],{},[140,13106,13107],{},"依赖",[140,13109,13110],{},"顺序",[140,13112,6784],{},[147,13114,13115,13132,13145],{},[137,13116,13117,13124,13126,13129],{},[152,13118,13119,13120,13123],{},"CSS ",[43,13121,13122],{},"columns"," 多列",[152,13125,2885],{},[152,13127,13128],{},"纵向阅读序（先下后右）",[152,13130,13131],{},"最新图片不再出现在左上角，时间语义丢失",[137,13133,13134,13137,13139,13142],{},[152,13135,13136],{},"grid row-span 自研",[152,13138,2885],{},[152,13140,13141],{},"保持",[152,13143,13144],{},"需固定列数 + 固定行高，逻辑要自己写",[137,13146,13147,13152,13155,13158],{},[152,13148,13149],{},[27,13150,13151],{},"react-photo-album",[152,13153,13154],{},"~4KB，无依赖",[152,13156,13157],{},"基本保持（逐列最短列填充）",[152,13159,13160],{},"专为相册墙设计，TS 友好，维护活跃",[16,13162,13163,13164,13167,13168,13171],{},"最终选了 ",[27,13165,13166],{},"react-photo-album v3","：体积小、类型齐全，而且它的 Masonry 模式恰好需要「每张图的 ",[43,13169,13170],{},"width\u002Fheight","」——我们的数据库里本来就存了，零额外成本。",[11,13173,13174],{"id":13174},"需求梳理",[1747,13176,13177,13183,13189,13192,13195],{},[721,13178,13179,13180,12871],{},"展示全部生成图（21 张起，会持续增长），",[27,13181,13182],{},"原比例、不裁剪",[721,13184,13185,13186,12871],{},"不同尺寸（2304×1728、4608×3456 等）混排时",[27,13187,13188],{},"紧凑、不留洞",[721,13190,13191],{},"顺序基本保持「新图靠前」；",[721,13193,13194],{},"点击任意一张打开大图预览（YARL 灯箱），悬停显示所属提示词；",[721,13196,13197],{},"平板性能：墙上只用 300px 缩略图，原图只进灯箱按需加载。",[11,13199,13200],{"id":13200},"安装与使用",[262,13202,13204],{"id":13203},"_1-安装","1. 安装",[36,13206,13208],{"className":38,"code":13207,"language":40,"meta":41,"style":41},"npm install react-photo-album\n",[43,13209,13210],{"__ignoreMap":41},[46,13211,13212,13214,13216],{"class":48,"line":49},[46,13213,7044],{"class":59},[46,13215,233],{"class":62},[46,13217,13218],{"class":62}," react-photo-album\n",[262,13220,13222],{"id":13221},"_2-准备数据关键输入是宽高","2. 准备数据（关键输入是宽高）",[16,13224,13225,13226,13229,13230,8302,13232,8302,13235,13238],{},"库要求的 ",[43,13227,13228],{},"Photo"," 对象只有三个必需字段：",[43,13231,4033],{},[43,13233,13234],{},"width",[43,13236,13237],{},"height","。我们的接口本来就返回每张图的原始宽高：",[36,13240,13242],{"className":11673,"code":13241,"language":11675,"meta":41,"style":41},"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",[43,13243,13244,13262,13267,13274,13278,13294,13304,13308,13312,13345,13356,13375,13380,13385,13407,13412],{"__ignoreMap":41},[46,13245,13246,13248,13251,13253,13256,13259],{"class":48,"line":49},[46,13247,4923],{"class":312},[46,13249,13250],{"class":319}," PhotoAlbum, { ",[46,13252,6307],{"class":312},[46,13254,13255],{"class":319}," Photo } ",[46,13257,13258],{"class":312},"from",[46,13260,13261],{"class":62}," \"react-photo-album\"\n",[46,13263,13264],{"class":48,"line":56},[46,13265,13266],{"class":52},"\u002F\u002F 必须引入样式表！容器\u002F列的布局规则都在这里（坑 1 详述）\n",[46,13268,13269,13271],{"class":48,"line":72},[46,13270,4923],{"class":312},[46,13272,13273],{"class":62}," \"react-photo-album\u002Fstyles.css\"\n",[46,13275,13276],{"class":48,"line":85},[46,13277,105],{"emptyLinePlaceholder":104},[46,13279,13280,13283,13286,13289,13292],{"class":48,"line":101},[46,13281,13282],{"class":312},"interface",[46,13284,13285],{"class":59}," GalleryPhoto",[46,13287,13288],{"class":312}," extends",[46,13290,13291],{"class":59}," Photo",[46,13293,3142],{"class":319},[46,13295,13296,13299,13302],{"class":48,"line":108},[46,13297,13298],{"class":3497},"  prompt_text",[46,13300,13301],{"class":312},"?:",[46,13303,6313],{"class":122},[46,13305,13306],{"class":48,"line":114},[46,13307,680],{"class":319},[46,13309,13310],{"class":48,"line":337},[46,13311,105],{"emptyLinePlaceholder":104},[46,13313,13314,13316,13319,13321,13323,13325,13327,13330,13333,13336,13338,13340,13342],{"class":48,"line":369},[46,13315,3727],{"class":312},[46,13317,13318],{"class":122}," albumPhotos",[46,13320,2429],{"class":312},[46,13322,13285],{"class":59},[46,13324,3516],{"class":319},[46,13326,1611],{"class":312},[46,13328,13329],{"class":319}," images.",[46,13331,13332],{"class":59},"map",[46,13334,13335],{"class":319},"((",[46,13337,1771],{"class":3497},[46,13339,3957],{"class":319},[46,13341,3921],{"class":312},[46,13343,13344],{"class":319}," ({\n",[46,13346,13347,13350,13353],{"class":48,"line":374},[46,13348,13349],{"class":319},"  key: ",[46,13351,13352],{"class":59},"String",[46,13354,13355],{"class":319},"(img.id),\n",[46,13357,13358,13361,13364,13367,13369,13372],{"class":48,"line":380},[46,13359,13360],{"class":319},"  src: ",[46,13362,13363],{"class":59},"assetUrl",[46,13365,13366],{"class":319},"(img.thumb_path) ",[46,13368,3707],{"class":312},[46,13370,13371],{"class":59}," assetUrl",[46,13373,13374],{"class":319},"(img.file_path),\n",[46,13376,13377],{"class":48,"line":484},[46,13378,13379],{"class":319},"  width: img.width,\n",[46,13381,13382],{"class":48,"line":498},[46,13383,13384],{"class":319},"  height: img.height,\n",[46,13386,13387,13390,13392,13395,13397,13399,13402,13405],{"class":48,"line":503},[46,13388,13389],{"class":319},"  alt: img.prompt_text ",[46,13391,3707],{"class":312},[46,13393,13394],{"class":62}," `生成图 ${",[46,13396,1771],{"class":319},[46,13398,3615],{"class":62},[46,13400,13401],{"class":319},"id",[46,13403,13404],{"class":62},"}`",[46,13406,645],{"class":319},[46,13408,13409],{"class":48,"line":509},[46,13410,13411],{"class":319},"  prompt_text: img.prompt_text,\n",[46,13413,13414],{"class":48,"line":1711},[46,13415,13416],{"class":319},"}))\n",[262,13418,13420],{"id":13419},"_3-使用-masonry-布局","3. 使用 Masonry 布局",[36,13422,13424],{"className":11673,"code":13423,"language":11675,"meta":41,"style":41},"\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",[43,13425,13426,13433,13443,13511,13525,13538,13548,13572,13582,13603,13621,13649,13656,13690,13698,13708,13724,13734,13744,13749,13759,13776,13781,13790,13799,13804,13808,13813],{"__ignoreMap":41},[46,13427,13428,13430],{"class":48,"line":49},[46,13429,2432],{"class":319},[46,13431,13432],{"class":122},"PhotoAlbum\n",[46,13434,13435,13438,13440],{"class":48,"line":56},[46,13436,13437],{"class":59},"  layout",[46,13439,1611],{"class":312},[46,13441,13442],{"class":62},"\"masonry\"\n",[46,13444,13445,13448,13450,13453,13456,13458,13460,13463,13465,13468,13471,13473,13476,13479,13481,13484,13486,13489,13491,13493,13495,13498,13500,13503,13505,13508],{"class":48,"line":72},[46,13446,13447],{"class":59},"  columns",[46,13449,1611],{"class":312},[46,13451,13452],{"class":319},"{(",[46,13454,13455],{"class":3497},"cw",[46,13457,3957],{"class":319},[46,13459,3921],{"class":312},[46,13461,13462],{"class":319}," (cw ",[46,13464,2432],{"class":312},[46,13466,13467],{"class":122}," 640",[46,13469,13470],{"class":312}," ?",[46,13472,6107],{"class":122},[46,13474,13475],{"class":312}," :",[46,13477,13478],{"class":319}," cw ",[46,13480,2432],{"class":312},[46,13482,13483],{"class":122}," 960",[46,13485,13470],{"class":312},[46,13487,13488],{"class":122}," 3",[46,13490,13475],{"class":312},[46,13492,13478],{"class":319},[46,13494,2432],{"class":312},[46,13496,13497],{"class":122}," 1280",[46,13499,13470],{"class":312},[46,13501,13502],{"class":122}," 4",[46,13504,13475],{"class":312},[46,13506,13507],{"class":122}," 5",[46,13509,13510],{"class":319},")}\n",[46,13512,13513,13516,13518,13520,13523],{"class":48,"line":85},[46,13514,13515],{"class":59},"  spacing",[46,13517,1611],{"class":312},[46,13519,5774],{"class":319},[46,13521,13522],{"class":122},"16",[46,13524,680],{"class":319},[46,13526,13527,13530,13532,13534,13536],{"class":48,"line":101},[46,13528,13529],{"class":59},"  padding",[46,13531,1611],{"class":312},[46,13533,5774],{"class":319},[46,13535,3581],{"class":122},[46,13537,680],{"class":319},[46,13539,13540,13543,13545],{"class":48,"line":108},[46,13541,13542],{"class":59},"  photos",[46,13544,1611],{"class":312},[46,13546,13547],{"class":319},"{albumPhotos}\n",[46,13549,13550,13553,13555,13558,13561,13564,13566,13569],{"class":48,"line":114},[46,13551,13552],{"class":59},"  onClick",[46,13554,1611],{"class":312},[46,13556,13557],{"class":319},"{({ ",[46,13559,13560],{"class":3497},"index",[46,13562,13563],{"class":319}," }) ",[46,13565,3921],{"class":312},[46,13567,13568],{"class":59}," openPreview",[46,13570,13571],{"class":319},"(index)}\n",[46,13573,13574,13577,13579],{"class":48,"line":337},[46,13575,13576],{"class":59},"  render",[46,13578,1611],{"class":312},[46,13580,13581],{"class":319},"{{\n",[46,13583,13584,13587,13590,13592,13594,13597,13599,13601],{"class":48,"line":369},[46,13585,13586],{"class":59},"    photo",[46,13588,13589],{"class":319},": (",[46,13591,4028],{"class":3497},[46,13593,659],{"class":319},[46,13595,13596],{"class":3497},"context",[46,13598,3957],{"class":319},[46,13600,3921],{"class":312},[46,13602,3142],{"class":319},[46,13604,13605,13607,13610,13613,13616,13618],{"class":48,"line":374},[46,13606,3631],{"class":312},[46,13608,13609],{"class":319}," { ",[46,13611,13612],{"class":122},"onClick",[46,13614,13615],{"class":319}," } ",[46,13617,1611],{"class":312},[46,13619,13620],{"class":319}," props\n",[46,13622,13623,13625,13627,13630,13632,13634,13636,13638,13640,13642,13644,13646],{"class":48,"line":380},[46,13624,3631],{"class":312},[46,13626,13609],{"class":319},[46,13628,13629],{"class":122},"photo",[46,13631,659],{"class":319},[46,13633,13560],{"class":122},[46,13635,659],{"class":319},[46,13637,13234],{"class":122},[46,13639,659],{"class":319},[46,13641,13237],{"class":122},[46,13643,13615],{"class":319},[46,13645,1611],{"class":312},[46,13647,13648],{"class":319}," context\n",[46,13650,13651,13654],{"class":48,"line":484},[46,13652,13653],{"class":312},"      return",[46,13655,6320],{"class":319},[46,13657,13658,13661,13664,13667,13669,13672,13675,13677,13680,13683,13685,13688],{"class":48,"line":498},[46,13659,13660],{"class":319},"        \u003C",[46,13662,13663],{"class":3201},"button",[46,13665,13666],{"class":59}," type",[46,13668,1611],{"class":312},[46,13670,13671],{"class":62},"\"button\"",[46,13673,13674],{"class":59}," onClick",[46,13676,1611],{"class":312},[46,13678,13679],{"class":319},"{onClick} ",[46,13681,13682],{"class":59},"className",[46,13684,1611],{"class":312},[46,13686,13687],{"class":62},"\"group relative block w-full ...\"",[46,13689,2093],{"class":319},[46,13691,13692,13695],{"class":48,"line":503},[46,13693,13694],{"class":319},"          \u003C",[46,13696,13697],{"class":3201},"img\n",[46,13699,13700,13703,13705],{"class":48,"line":509},[46,13701,13702],{"class":59},"            src",[46,13704,1611],{"class":312},[46,13706,13707],{"class":319},"{photo.src}\n",[46,13709,13710,13713,13715,13718,13720,13722],{"class":48,"line":1711},[46,13711,13712],{"class":59},"            alt",[46,13714,1611],{"class":312},[46,13716,13717],{"class":319},"{photo.alt ",[46,13719,3707],{"class":312},[46,13721,6018],{"class":62},[46,13723,680],{"class":319},[46,13725,13726,13729,13731],{"class":48,"line":1716},[46,13727,13728],{"class":59},"            decoding",[46,13730,1611],{"class":312},[46,13732,13733],{"class":62},"\"async\"\n",[46,13735,13736,13739,13741],{"class":48,"line":1725},[46,13737,13738],{"class":59},"            style",[46,13740,1611],{"class":312},[46,13742,13743],{"class":319},"{{ width, height }}\n",[46,13745,13746],{"class":48,"line":1730},[46,13747,13748],{"class":319},"          \u002F>\n",[46,13750,13751,13754,13757],{"class":48,"line":1938},[46,13752,13753],{"class":319},"          {",[46,13755,13756],{"class":52},"\u002F* hover 提示词浮层 *\u002F",[46,13758,680],{"class":319},[46,13760,13761,13763,13766,13769,13771,13774],{"class":48,"line":1946},[46,13762,13694],{"class":319},[46,13764,13765],{"class":3201},"div",[46,13767,13768],{"class":59}," className",[46,13770,1611],{"class":312},[46,13772,13773],{"class":62},"\"... opacity-0 group-hover:opacity-100\"",[46,13775,2093],{"class":319},[46,13777,13778],{"class":48,"line":1951},[46,13779,13780],{"class":319},"            {photo.prompt_text}\n",[46,13782,13783,13786,13788],{"class":48,"line":2575},[46,13784,13785],{"class":319},"          \u003C\u002F",[46,13787,13765],{"class":3201},[46,13789,2093],{"class":319},[46,13791,13792,13795,13797],{"class":48,"line":2589},[46,13793,13794],{"class":319},"        \u003C\u002F",[46,13796,13663],{"class":3201},[46,13798,2093],{"class":319},[46,13800,13801],{"class":48,"line":2596},[46,13802,13803],{"class":319},"      )\n",[46,13805,13806],{"class":48,"line":2601},[46,13807,8455],{"class":319},[46,13809,13810],{"class":48,"line":2607},[46,13811,13812],{"class":319},"  }}\n",[46,13814,13815],{"class":48,"line":2615},[46,13816,13817],{"class":319},"\u002F>\n",[16,13819,13820],{},"几个要点：",[718,13822,13823,13832,13845],{},[721,13824,13825,13827,13828,13831],{},[43,13826,13122],{}," 可以是数字或 ",[43,13829,13830],{},"(containerWidth) => number"," 函数，做响应式列数很方便；",[721,13833,13834,13835,13837,13838,13841,13842,13844],{},"点击回调 ",[43,13836,13612],{}," 里拿到的是",[27,13839,13840],{},"原始数组下标"," ",[43,13843,13560],{},"，直接传给灯箱即可；",[721,13846,13847,13848,13851,13852,13854],{},"自定义 ",[43,13849,13850],{},"render.photo"," 后，图片尺寸要自己用 context 里的 ",[43,13853,13170],{},"（渲染尺寸）设置——这两个值库已经按列宽算好了。",[11,13856,13857],{"id":13857},"遇到的坑",[262,13859,13861],{"id":13860},"坑-1忘记引入样式表-一张一列","坑 1：忘记引入样式表 → 「一张一列」",[16,13863,13864,13865,13868,13869,3388],{},"这是最坑的一个。装完包、写好 ",[43,13866,13867],{},"\u003CPhotoAlbum layout=\"masonry\">","，页面渲染出来却是：",[27,13870,13871],{},"每张图占一整行，右侧大片置灰空白",[16,13873,13874,13875,13877],{},"排查过程：先怀疑是 ",[43,13876,13122],{}," 函数没生效，改成固定数字无效；再检查 DOM——容器和 5 个列（track）都在，列的宽度也算对了，但图片全都堆在布局里。",[16,13879,13880,13881,13884,13885,8302,13888,13891,13892,13898,13899,13902],{},"最后翻包的 ",[43,13882,13883],{},"package.json"," 发现它导出了独立的 CSS 文件（",[43,13886,13887],{},".\u002Fstyles.css",[43,13889,13890],{},".\u002Fmasonry.css"," 等），而",[27,13893,13894,13895],{},"官方 README 示例都带了一行 ",[43,13896,13897],{},"import \"react-photo-album\u002Fstyles.css\"","。容器是 flex、列是 flex-column、列宽按 ",[43,13900,13901],{},"--react-photo-album--columns"," 计算——这些规则全在这个样式表里，不引入就退化成普通块级堆叠。",[16,13904,13905,13907],{},[27,13906,8297],{},"：顶部加一行：",[36,13909,13911],{"className":11673,"code":13910,"language":11675,"meta":41,"style":41},"import \"react-photo-album\u002Fstyles.css\"\n",[43,13912,13913],{"__ignoreMap":41},[46,13914,13915,13917],{"class":48,"line":49},[46,13916,4923],{"class":312},[46,13918,13273],{"class":62},[16,13920,13921,13922],{},"经验：",[27,13923,13924],{},"用这类\"无运行时依赖\"的布局库，先看它的 exports 里有没有独立 CSS，有就一定要引。",[262,13926,13928,13929,13931,13932,13935],{"id":13927},"坑-2renderphoto-是-props-context-双参签名","坑 2：",[43,13930,13850],{}," 是 ",[43,13933,13934],{},"(props, context)"," 双参签名",[16,13937,13938],{},"自定义渲染时我凭直觉写了：",[36,13940,13942],{"className":11673,"code":13941,"language":11675,"meta":41,"style":41},"photo: ({ photo, index, width, height, onClick }) => ... \u002F\u002F ❌ 报错：Property 'photo' does not exist\n",[43,13943,13944],{"__ignoreMap":41},[46,13945,13946,13948,13951,13953,13955,13957,13959,13961,13963,13965,13967,13969,13971,13973,13976],{"class":48,"line":49},[46,13947,13629],{"class":59},[46,13949,13950],{"class":319},": ({ ",[46,13952,13629],{"class":3497},[46,13954,659],{"class":319},[46,13956,13560],{"class":3497},[46,13958,659],{"class":319},[46,13960,13234],{"class":3497},[46,13962,659],{"class":319},[46,13964,13237],{"class":3497},[46,13966,659],{"class":319},[46,13968,13612],{"class":3497},[46,13970,13563],{"class":319},[46,13972,3921],{"class":312},[46,13974,13975],{"class":312}," ...",[46,13977,13978],{"class":52}," \u002F\u002F ❌ 报错：Property 'photo' does not exist\n",[16,13980,13981,13982,13984,13985,13988,13989,4635],{},"TS 报错提示 ",[43,13983,13629],{}," 等属性不存在于 ",[43,13986,13987],{},"RenderPhotoProps","。看类型定义才发现 v3 的渲染函数是",[27,13990,13991],{},"两个参数",[36,13993,13995],{"className":11673,"code":13994,"language":11675,"meta":41,"style":41},"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",[43,13996,13997,14043,14048,14068,14082,14107,14111],{"__ignoreMap":41},[46,13998,13999,14001,14004,14006,14009,14011,14013,14016,14018,14020,14022,14024,14027,14029,14031,14033,14036,14038,14040],{"class":48,"line":49},[46,14000,6307],{"class":312},[46,14002,14003],{"class":59}," RenderFunction",[46,14005,2432],{"class":319},[46,14007,14008],{"class":59},"Props",[46,14010,659],{"class":319},[46,14012,5129],{"class":59},[46,14014,14015],{"class":319},"> ",[46,14017,1611],{"class":312},[46,14019,3536],{"class":319},[46,14021,4028],{"class":3497},[46,14023,2429],{"class":312},[46,14025,14026],{"class":59}," Props",[46,14028,659],{"class":319},[46,14030,13596],{"class":3497},[46,14032,2429],{"class":312},[46,14034,14035],{"class":59}," Context",[46,14037,3957],{"class":319},[46,14039,3921],{"class":312},[46,14041,14042],{"class":59}," ReactNode\n",[46,14044,14045],{"class":48,"line":56},[46,14046,14047],{"class":52},"\u002F\u002F photo 的渲染上下文（photo\u002Findex\u002Fwidth\u002Fheight）在第二个参数里\n",[46,14049,14050,14052,14054,14056,14058,14060,14062,14064,14066],{"class":48,"line":72},[46,14051,13629],{"class":59},[46,14053,2429],{"class":312},[46,14055,3536],{"class":319},[46,14057,4028],{"class":3497},[46,14059,659],{"class":319},[46,14061,13596],{"class":3497},[46,14063,3957],{"class":319},[46,14065,3921],{"class":312},[46,14067,3142],{"class":319},[46,14069,14070,14072,14074,14076,14079],{"class":48,"line":85},[46,14071,3982],{"class":59},[46,14073,13609],{"class":319},[46,14075,13612],{"class":3497},[46,14077,14078],{"class":319}," } = ",[46,14080,14081],{"class":3497},"props\n",[46,14083,14084,14086,14088,14090,14092,14094,14096,14098,14100,14102,14104],{"class":48,"line":101},[46,14085,3982],{"class":59},[46,14087,13609],{"class":319},[46,14089,13629],{"class":3497},[46,14091,659],{"class":319},[46,14093,13560],{"class":3497},[46,14095,659],{"class":319},[46,14097,13234],{"class":3497},[46,14099,659],{"class":319},[46,14101,13237],{"class":3497},[46,14103,14078],{"class":319},[46,14105,14106],{"class":3497},"context\n",[46,14108,14109],{"class":48,"line":108},[46,14110,11666],{"class":312},[46,14112,14113],{"class":48,"line":114},[46,14114,680],{"class":319},[16,14116,14117,14119,14120,14122,14123,14126],{},[27,14118,8297],{},"：按 ",[43,14121,13934],{}," 拆解即可。装新库先扫一眼 ",[43,14124,14125],{},".d.ts"," 里的 Render 类型签名，能少走很多弯路。",[262,14128,14130],{"id":14129},"坑-3测试断言把dom-顺序当成了视觉顺序","坑 3：测试断言把「DOM 顺序」当成了「视觉顺序」",[16,14132,14133,14134,14137],{},"布局修好后，我在浏览器自测脚本里加了一条断言：取前两个 ",[43,14135,14136],{},"\u003Cimg>","，比较它们的左坐标，认为应该并排（不同列）。",[16,14139,14140,14141,14144,14145,30,14148,14151,14152,14155],{},"结果断言失败——但页面明明是 5 列并排。排查发现：",[27,14142,14143],{},"瀑布流的 DOM 是按\"列\"组织的","（第 1 列的全部图片在前，第 2 列的在后），所以 ",[43,14146,14147],{},"img[0]",[43,14149,14150],{},"img[1]"," 属于",[27,14153,14154],{},"同一列","，左坐标当然相同。",[16,14157,14158,14160],{},[27,14159,8297],{},"：断言改为比较「第 1 列的首张图 vs 第 2 列的首张图」：",[36,14162,14166],{"className":14163,"code":14164,"language":14165,"meta":41,"style":41},"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",[43,14167,14168,14193,14216],{"__ignoreMap":41},[46,14169,14170,14172,14174,14176,14179,14181,14183,14186,14188,14191],{"class":48,"line":49},[46,14171,3727],{"class":312},[46,14173,4741],{"class":122},[46,14175,3139],{"class":312},[46,14177,14178],{"class":319}," tracks[",[46,14180,3581],{"class":122},[46,14182,3646],{"class":319},[46,14184,14185],{"class":59},"querySelector",[46,14187,3494],{"class":319},[46,14189,14190],{"class":62},"\"img\"",[46,14192,3803],{"class":319},[46,14194,14195,14197,14200,14202,14204,14206,14208,14210,14212,14214],{"class":48,"line":56},[46,14196,3727],{"class":312},[46,14198,14199],{"class":122}," b",[46,14201,3139],{"class":312},[46,14203,14178],{"class":319},[46,14205,1629],{"class":122},[46,14207,3646],{"class":319},[46,14209,14185],{"class":59},[46,14211,3494],{"class":319},[46,14213,14190],{"class":62},[46,14215,3803],{"class":319},[46,14217,14218,14220,14223,14226,14229,14232,14235,14238,14241,14243,14246,14248],{"class":48,"line":72},[46,14219,3545],{"class":312},[46,14221,14222],{"class":319}," Math.",[46,14224,14225],{"class":59},"abs",[46,14227,14228],{"class":319},"(a.",[46,14230,14231],{"class":59},"getBoundingClientRect",[46,14233,14234],{"class":319},"().left ",[46,14236,14237],{"class":312},"-",[46,14239,14240],{"class":319}," b.",[46,14242,14231],{"class":59},[46,14244,14245],{"class":319},"().left) ",[46,14247,323],{"class":312},[46,14249,14250],{"class":122}," 10\n",[16,14252,13921,14253,14256],{},[27,14254,14255],{},"验证布局类库时，先搞清楚它生成的 DOM 结构，再写断言","；另外把「真实浏览器探针」当排查手段（直接打印各 track 的 x 坐标和子元素数量），比盯着代码猜快得多。",[262,14258,14260],{"id":14259},"坑-4等宽网格的洞引入前就存在的产品问题","坑 4：等宽网格的\"洞\"（引入前就存在的产品问题）",[16,14262,14263,14264,14267,14268,14271],{},"顺带记录一下为什么换掉原来的布局：CSS Grid ",[43,14265,14266],{},"repeat(auto-fill, minmax(220px, 1fr))"," 等宽铺满，图片 ",[43,14269,14270],{},"w-full h-auto","。当一行里混有不同比例时，行高取最高的那张，矮图底部留空，几行错落下来就显得乱；另外窗口宽度\"差一点放不下下一张\"时，右侧会空出近一整列。",[16,14273,14274],{},"换成 Masonry 后这两个问题都消失了——列内紧密堆叠、列数自适应。",[11,14276,14277],{"id":14277},"效果与小结",[16,14279,14280,14281,14283],{},"最终效果：21 张不同比例的生成图在 5 列瀑布流里紧密排列，原比例不裁剪，悬停出提示词浮层，点击进 YARL 大图预览；缩略图总大小仅 360KB，进页面即全量加载 + ",[43,14282,12774],{}," 长缓存，平板上快速滚动也跟手。",[16,14285,11452,14286],{},[27,14287,14288],{},"react-photo-album 是个小但专业的相册布局库，只要记住三件事——引样式表、喂宽高、render 函数是双参——就能在十分钟内把图片墙升级成真正的瀑布流。",[1460,14290,14291],{},"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":41,"searchDepth":56,"depth":72,"links":14293},[14294,14295,14296,14301,14308],{"id":13068,"depth":56,"text":13068},{"id":13174,"depth":56,"text":13174},{"id":13200,"depth":56,"text":13200,"children":14297},[14298,14299,14300],{"id":13203,"depth":72,"text":13204},{"id":13221,"depth":72,"text":13222},{"id":13419,"depth":72,"text":13420},{"id":13857,"depth":56,"text":13857,"children":14302},[14303,14304,14306,14307],{"id":13860,"depth":72,"text":13861},{"id":13927,"depth":72,"text":14305},"坑 2：render.photo 是 (props, context) 双参签名",{"id":14129,"depth":72,"text":14130},{"id":14259,"depth":72,"text":14260},{"id":14277,"depth":56,"text":14277},"记录在 Magic Img 生图图库中引入 react-photo-album 实现 Masonry 瀑布流的过程：技术选型背景、需求梳理、安装使用，以及「忘记引入样式表导致一张一列」等踩坑与排查方法。","\u002Fposts\u002Freact-photo-album-masonry\u002Fimg\u002Fcover.svg",{},"\u002Fposts\u002Freact-photo-album-masonry",{"title":13063,"description":14309},"前端工程实践","posts\u002Freact-photo-album-masonry\u002Findex",[10343,13151,14317,14318,14319],"瀑布流","前端","Tailwind","-O-kAQLEyRzwVhOpuo-yBxZim9LtvhChPkpP92ipyjM",1786887349178]