[{"data":1,"prerenderedAt":12045},["ShallowReactive",2],{"all-posts":3},[4,1541,5166,6201,7341,9677,10660],{"id":5,"title":6,"body":7,"cover":1525,"date":1526,"description":193,"extension":1527,"links":1525,"meta":1528,"navigation":1529,"path":1530,"project":1531,"seo":1532,"slug":1533,"stem":1534,"summary":1535,"tags":1536,"visible":1529,"__hash__":1540},"posts\u002Fposts\u002Fpure-go-pdf-compression.md","不引 Ghostscript：我用纯 Go 给 PDF 做了一场「压缩手术」",{"type":8,"value":9,"toc":1515},"minimark",[10,28,31,34,37,40,47,60,63,68,76,103,118,122,126,129,156,167,171,181,187,465,476,485,938,941,963,973,1116,1119,1125,1132,1213,1220,1366,1373,1392,1396,1402,1411,1417,1423,1433,1437,1483,1486,1492,1497,1500,1511],[11,12,13],"blockquote",{},[14,15,16,17,22,23,27],"p",{},"本站另有两篇 Wails 实战：《",[18,19,21],"a",{"href":20},"\u002Fblog\u002Fecom-detail-ai-part2-wails","Wails v2 实战：用 Go + Vue3 做一个真正能用的 AI 桌面应用","》和《",[18,24,26],{"href":25},"\u002Fblog\u002Fwails-v3-upgrade","Wails v3 升级实战","》。本文的主角「图片工坊」与它们同栈（Go + Vue3 + Wails），但这篇只专注其中最硬的一块：PDF 压缩。",[14,29,30],{},"先从一个具体场景说起。",[14,32,33],{},"内网办公的电脑上，一份材料要交：对面系统只收 5MB 以内的附件，手里这份十几页的扫描件 PDF 是 40MB——A4 纸按 300 DPI 扫，一页就是两三 MB，页数一多轻松翻十倍。",[14,35,36],{},"这时候能想到的路基本都不通。在线压缩网站，内网打不开；就算打得开，报销单、合同、证件的扫描件也不该往陌生服务器上传。程序员的答案通常是 Ghostscript，一条命令的事——但如果你做的是要分发给别人用的桌面工具，它就是个外部进程：macOS、Windows、银河麒麟每个平台打包一份，AGPL 许可要评估，装在内网机器上还有维护成本。对一个安装包必须自包含的工具来说，这条路比看起来贵得多。",[14,38,39],{},"「图片工坊」就是为这个场景做的离线批量图片工具（macOS \u002F Windows \u002F 银河麒麟三平台），0.2.0 加了 PDF 压缩：拖进去、点开始，输出一个更小的 PDF。交材料的场景同时定了两条底线：文字一字不动，图片不能糊到看不清。实现是纯 Go 的——生态里唯一的现成库 pdfcpu 恰好缺最关键的图像重压缩（2017 年挂到今天的 open feature request），所以分工是：pdfcpu 负责解析和回写，图像处理自己写。",[14,41,42],{},[43,44],"img",{"alt":45,"src":46},"图片工坊工作台：批量处理进行中的队列与进度","https:\u002F\u002Fname.os.bennywen.xyz\u002Fbennywen.xyz\u002Fassets\u002Ffile-processer\u002F%E5%B7%A5%E4%BD%9C%E5%8F%B0-%E6%89%B9%E9%87%8F%E5%A4%84%E7%90%86.gif",[14,48,49],{},[50,51,52,53,59],"em",{},"工作台批量处理进行中（示例为图片批次）。PDF 不用选规则，拖进去直接压缩。项目已开源：",[18,54,58],{"href":55,"rel":56},"https:\u002F\u002Fgithub.com\u002FBenny66\u002Ffile-processor",[57],"nofollow","github.com\u002FBenny66\u002Ffile-processor","，三平台安装包在 Release 页。",[14,61,62],{},"这篇文章讲三件事：业内压缩 PDF 的通用做法是什么；我们的实现具体怎么做；跟业内方案比，几处不同的设计换来了什么。",[64,65,67],"h2",{"id":66},"一业内是怎么压缩-pdf-的","一、业内是怎么压缩 PDF 的",[14,69,70,71,75],{},"先看 PDF 的体积构成。扫描件和图文混排文档的大头都是",[72,73,74],"strong",{},"内嵌图像流","，其次是字体，真正的结构性冗余（重复对象、未压缩的 xref 表、没用到的对象）通常只占零头。所以有效的压缩手段就三件：",[77,78,79,91,97],"ol",{},[80,81,82,85,86,90],"li",{},[72,83,84],{},"无损结构优化","：去重、对象流压缩、清理无用对象。qpdf 和 pdfcpu 的 ",[87,88,89],"code",{},"optimize"," 都是这个级别，收益稳定但有限，动不了图像流。",[80,92,93,96],{},[72,94,95],{},"按显示尺寸降采样","：这是最大的杠杆。扫描件常按 300–600 DPI 存储，屏幕阅读只需要 150。",[80,98,99,102],{},[72,100,101],{},"图像重存","：解出来、用更低的质量重新编码回去。",[14,104,105,106,109,110,113,114,117],{},"Ghostscript 把 2 和 3 打包成了三档预设：",[87,107,108],{},"\u002Fscreen"," = 72 DPI、",[87,111,112],{},"\u002Febook"," = 150 DPI、",[87,115,116],{},"\u002Fprinter"," = 300 DPI，只对高于阈值的图降采样，同时按档位配一个 JPEG 重存质量。Acrobat 的「减少文件大小」和各家在线工具的「三档强度」，拆开看同样是「DPI 阈值 + 质量档」的预设组合。这套方案成熟、有效，问题只有一个：它跑在外部进程里，我引不进来。",[64,119,121],{"id":120},"二我们的实现","二、我们的实现",[123,124,125],"h3",{"id":125},"整体管道",[14,127,128],{},"入口先过三道闸：加密的直接拒绝、带签名的直接拒绝（改一个字节签名就失效）、损坏的报「打不开」。之后的处理流程：",[77,130,131,137,143,150,153],{},[80,132,133,136],{},[72,134,135],{},"扫描页识别","——找出「满页单图」的页面，为每张图算出降采样目标（下文展开）；",[80,138,139,142],{},[72,140,141],{},"遍历 xref 表","，对每个图像 XObject 判定是否在安全子集（单一 DCTDecode、RGB\u002FGray 8bpc、无 Decode 映射）；不在子集的保持原字节，计入「不支持的图片类型」；",[80,144,145,146,149],{},"子集内的图解码出来，若有降采样目标先缩到目标像素，再按质量 75 重编码；",[72,147,148],{},"编码结果不比原来小就放弃","，保持原样（「已经很省空间」）；",[80,151,152],{},"更小的整体写回 xref 条目；",[80,154,155],{},"最后跑 pdfcpu 的无损结构优化（去重、对象流压缩、清理），输出并逐张计数。",[14,157,158,159,162,163,166],{},"两个术语说明一下，后面会反复出现：",[72,160,161],{},"xref 表","是 PDF 的对象目录，每个间接对象在这里有条目；",[72,164,165],{},"XObject"," 是被文档引用的流对象，图像就存在其中的 Image XObject 里，滤镜名为 DCTDecode 的就是 JPEG 字节。",[123,168,170],{"id":169},"内嵌-jpeg-重存","内嵌 JPEG 重存",[14,172,173,176,177,180],{},[72,174,175],{},"定位图像对象。"," 不解析页面内容流，直接遍历 xref 表，取出流字典后用 ",[87,178,179],{},"sd.Image()"," 判断是不是图像对象。这样一个好处是跨页共享的图像对象天然只处理一次。",[14,182,183,186],{},[72,184,185],{},"安全子集判定。"," 只处理同时满足这几个条件的图：",[188,189,194],"pre",{"className":190,"code":191,"language":192,"meta":193,"style":193},"language-go shiki shiki-themes github-dark github-light","\u002F\u002F internal\u002Fimageproc\u002Fpdf.go\nfunc recompressibleColorSpace(sd types.StreamDict) bool {\n    cs, found := sd.Dict[\"ColorSpace\"]\n    if !found {\n        return false\n    }\n    name, ok := cs.(types.Name)\n    if !ok || (name != \"DeviceRGB\" && name != \"DeviceGray\") {\n        return false \u002F\u002F CMYK 转 RGB 会偏色；Indexed 是查色表语义，重编码后对不上\n    }\n    bpc, ok := sd.Dict[\"BitsPerComponent\"].(types.Integer)\n    if !ok || bpc.Value() != 8 {\n        return false \u002F\u002F 非 8bit 的 DCT 极罕见，标准库也解不了\n    }\n    _, hasDecode := sd.Dict[\"Decode\"]\n    return !hasDecode \u002F\u002F Decode 是分量重映射，解码结果和字典声明不一致\n}\n","go","",[87,195,196,205,242,261,273,283,289,311,347,358,363,388,415,425,430,445,459],{"__ignoreMap":193},[197,198,201],"span",{"class":199,"line":200},"line",1,[197,202,204],{"class":203},"si27w","\u002F\u002F internal\u002Fimageproc\u002Fpdf.go\n",[197,206,208,212,216,220,224,227,230,233,236,239],{"class":199,"line":207},2,[197,209,211],{"class":210},"spKkM","func",[197,213,215],{"class":214},"sqoU-"," recompressibleColorSpace",[197,217,219],{"class":218},"shWlK","(",[197,221,223],{"class":222},"sdxZB","sd",[197,225,226],{"class":214}," types",[197,228,229],{"class":218},".",[197,231,232],{"class":214},"StreamDict",[197,234,235],{"class":218},") ",[197,237,238],{"class":210},"bool",[197,240,241],{"class":218}," {\n",[197,243,245,248,251,254,258],{"class":199,"line":244},3,[197,246,247],{"class":218},"    cs, found ",[197,249,250],{"class":210},":=",[197,252,253],{"class":218}," sd.Dict[",[197,255,257],{"class":256},"skb7c","\"ColorSpace\"",[197,259,260],{"class":218},"]\n",[197,262,264,267,270],{"class":199,"line":263},4,[197,265,266],{"class":210},"    if",[197,268,269],{"class":210}," !",[197,271,272],{"class":218},"found {\n",[197,274,276,279],{"class":199,"line":275},5,[197,277,278],{"class":210},"        return",[197,280,282],{"class":281},"sTU5a"," false\n",[197,284,286],{"class":199,"line":285},6,[197,287,288],{"class":218},"    }\n",[197,290,292,295,297,300,303,305,308],{"class":199,"line":291},7,[197,293,294],{"class":218},"    name, ok ",[197,296,250],{"class":210},[197,298,299],{"class":218}," cs.(",[197,301,302],{"class":214},"types",[197,304,229],{"class":218},[197,306,307],{"class":214},"Name",[197,309,310],{"class":218},")\n",[197,312,314,316,318,321,324,327,330,333,336,339,341,344],{"class":199,"line":313},8,[197,315,266],{"class":210},[197,317,269],{"class":210},[197,319,320],{"class":218},"ok ",[197,322,323],{"class":210},"||",[197,325,326],{"class":218}," (name ",[197,328,329],{"class":210},"!=",[197,331,332],{"class":256}," \"DeviceRGB\"",[197,334,335],{"class":210}," &&",[197,337,338],{"class":218}," name ",[197,340,329],{"class":210},[197,342,343],{"class":256}," \"DeviceGray\"",[197,345,346],{"class":218},") {\n",[197,348,350,352,355],{"class":199,"line":349},9,[197,351,278],{"class":210},[197,353,354],{"class":281}," false",[197,356,357],{"class":203}," \u002F\u002F CMYK 转 RGB 会偏色；Indexed 是查色表语义，重编码后对不上\n",[197,359,361],{"class":199,"line":360},10,[197,362,288],{"class":218},[197,364,366,369,371,373,376,379,381,383,386],{"class":199,"line":365},11,[197,367,368],{"class":218},"    bpc, ok ",[197,370,250],{"class":210},[197,372,253],{"class":218},[197,374,375],{"class":256},"\"BitsPerComponent\"",[197,377,378],{"class":218},"].(",[197,380,302],{"class":214},[197,382,229],{"class":218},[197,384,385],{"class":214},"Integer",[197,387,310],{"class":218},[197,389,391,393,395,397,399,402,405,408,410,413],{"class":199,"line":390},12,[197,392,266],{"class":210},[197,394,269],{"class":210},[197,396,320],{"class":218},[197,398,323],{"class":210},[197,400,401],{"class":218}," bpc.",[197,403,404],{"class":214},"Value",[197,406,407],{"class":218},"() ",[197,409,329],{"class":210},[197,411,412],{"class":281}," 8",[197,414,241],{"class":218},[197,416,418,420,422],{"class":199,"line":417},13,[197,419,278],{"class":210},[197,421,354],{"class":281},[197,423,424],{"class":203}," \u002F\u002F 非 8bit 的 DCT 极罕见，标准库也解不了\n",[197,426,428],{"class":199,"line":427},14,[197,429,288],{"class":218},[197,431,433,436,438,440,443],{"class":199,"line":432},15,[197,434,435],{"class":218},"    _, hasDecode ",[197,437,250],{"class":210},[197,439,253],{"class":218},[197,441,442],{"class":256},"\"Decode\"",[197,444,260],{"class":218},[197,446,448,451,453,456],{"class":199,"line":447},16,[197,449,450],{"class":210},"    return",[197,452,269],{"class":210},[197,454,455],{"class":218},"hasDecode ",[197,457,458],{"class":203},"\u002F\u002F Decode 是分量重映射，解码结果和字典声明不一致\n",[197,460,462],{"class":199,"line":461},17,[197,463,464],{"class":218},"}\n",[14,466,467,468,471,472,475],{},"加上滤镜必须是",[72,469,470],{},"单一 DCTDecode","（",[87,473,474],{},"HasSoleFilterNamed","），这套谓词画出的圈子很小，但每条排除都有明确的技术原因，不是拍脑袋。圈子外的图（Flate 截图、传真码 JBIG2\u002FCCITT、CMYK、索引色）保持原字节，计入「不支持的图片类型」。",[14,477,478,481,482,484],{},[72,479,480],{},"重存与替换。"," 解码出来，用标准库按质量 75 重新编码，",[72,483,148],{},"：",[188,486,488],{"className":190,"code":487,"language":192,"meta":193,"style":193},"\u002F\u002F internal\u002Fimageproc\u002Fpdf.go\nfunc recompressPDFImage(sd types.StreamDict, target *image.Point) (types.StreamDict, pdfImageResult) {\n    if !sd.HasSoleFilterNamed(filterDCTDecode) || !recompressibleColorSpace(sd) {\n        return types.StreamDict{}, pdfImageUnsupported\n    }\n    width, height := pdfImageDims(sd)\n    if width \u003C= 0 || height \u003C= 0 || int64(width)*int64(height) > MaxPixels {\n        return types.StreamDict{}, pdfImageUnsupported \u002F\u002F 100 MP 上限\n    }\n    img, _, err := image.Decode(bytes.NewReader(sd.Raw))\n    if err != nil {\n        return types.StreamDict{}, pdfImageUnsupported\n    }\n    result := pdfImageReplaced\n    if target != nil && (img.Bounds().Dx() != target.X || img.Bounds().Dy() != target.Y) {\n        img = resizeImage(img, target.X, target.Y) \u002F\u002F 扫描页降采样目标\n        result = pdfImageReplacedDownsampled\n    }\n    var out bytes.Buffer\n    if err := jpeg.Encode(&out, img, &jpeg.Options{Quality: pdfJPEGQuality}); err != nil {\n        return types.StreamDict{}, pdfImageUnsupported\n    }\n    if out.Len() >= len(sd.Raw) {\n        return types.StreamDict{}, pdfImageNotSmaller \u002F\u002F 重存不更小：保持原样\n    }\n    \u002F\u002F ……写回\n}\n",[87,489,490,494,544,568,581,585,598,641,657,661,683,697,709,713,723,774,791,801,806,823,865,878,883,905,922,927,933],{"__ignoreMap":193},[197,491,492],{"class":199,"line":200},[197,493,204],{"class":203},[197,495,496,498,501,503,505,507,509,511,514,517,520,523,525,528,531,533,535,537,539,542],{"class":199,"line":207},[197,497,211],{"class":210},[197,499,500],{"class":214}," recompressPDFImage",[197,502,219],{"class":218},[197,504,223],{"class":222},[197,506,226],{"class":214},[197,508,229],{"class":218},[197,510,232],{"class":214},[197,512,513],{"class":218},", ",[197,515,516],{"class":222},"target",[197,518,519],{"class":210}," *",[197,521,522],{"class":214},"image",[197,524,229],{"class":218},[197,526,527],{"class":214},"Point",[197,529,530],{"class":218},") (",[197,532,302],{"class":214},[197,534,229],{"class":218},[197,536,232],{"class":214},[197,538,513],{"class":218},[197,540,541],{"class":214},"pdfImageResult",[197,543,346],{"class":218},[197,545,546,548,550,553,555,558,560,562,565],{"class":199,"line":244},[197,547,266],{"class":210},[197,549,269],{"class":210},[197,551,552],{"class":218},"sd.",[197,554,474],{"class":214},[197,556,557],{"class":218},"(filterDCTDecode) ",[197,559,323],{"class":210},[197,561,269],{"class":210},[197,563,564],{"class":214},"recompressibleColorSpace",[197,566,567],{"class":218},"(sd) {\n",[197,569,570,572,574,576,578],{"class":199,"line":263},[197,571,278],{"class":210},[197,573,226],{"class":214},[197,575,229],{"class":218},[197,577,232],{"class":214},[197,579,580],{"class":218},"{}, pdfImageUnsupported\n",[197,582,583],{"class":199,"line":275},[197,584,288],{"class":218},[197,586,587,590,592,595],{"class":199,"line":285},[197,588,589],{"class":218},"    width, height ",[197,591,250],{"class":210},[197,593,594],{"class":214}," pdfImageDims",[197,596,597],{"class":218},"(sd)\n",[197,599,600,602,605,608,611,614,617,619,621,623,626,629,632,635,638],{"class":199,"line":291},[197,601,266],{"class":210},[197,603,604],{"class":218}," width ",[197,606,607],{"class":210},"\u003C=",[197,609,610],{"class":281}," 0",[197,612,613],{"class":210}," ||",[197,615,616],{"class":218}," height ",[197,618,607],{"class":210},[197,620,610],{"class":281},[197,622,613],{"class":210},[197,624,625],{"class":210}," int64",[197,627,628],{"class":218},"(width)",[197,630,631],{"class":210},"*int64",[197,633,634],{"class":218},"(height) ",[197,636,637],{"class":210},">",[197,639,640],{"class":218}," MaxPixels {\n",[197,642,643,645,647,649,651,654],{"class":199,"line":313},[197,644,278],{"class":210},[197,646,226],{"class":214},[197,648,229],{"class":218},[197,650,232],{"class":214},[197,652,653],{"class":218},"{}, pdfImageUnsupported ",[197,655,656],{"class":203},"\u002F\u002F 100 MP 上限\n",[197,658,659],{"class":199,"line":349},[197,660,288],{"class":218},[197,662,663,666,668,671,674,677,680],{"class":199,"line":360},[197,664,665],{"class":218},"    img, _, err ",[197,667,250],{"class":210},[197,669,670],{"class":218}," image.",[197,672,673],{"class":214},"Decode",[197,675,676],{"class":218},"(bytes.",[197,678,679],{"class":214},"NewReader",[197,681,682],{"class":218},"(sd.Raw))\n",[197,684,685,687,690,692,695],{"class":199,"line":365},[197,686,266],{"class":210},[197,688,689],{"class":218}," err ",[197,691,329],{"class":210},[197,693,694],{"class":281}," nil",[197,696,241],{"class":218},[197,698,699,701,703,705,707],{"class":199,"line":390},[197,700,278],{"class":210},[197,702,226],{"class":214},[197,704,229],{"class":218},[197,706,232],{"class":214},[197,708,580],{"class":218},[197,710,711],{"class":199,"line":417},[197,712,288],{"class":218},[197,714,715,718,720],{"class":199,"line":427},[197,716,717],{"class":218},"    result ",[197,719,250],{"class":210},[197,721,722],{"class":218}," pdfImageReplaced\n",[197,724,725,727,730,732,734,736,739,742,745,748,750,752,755,757,760,762,764,767,769,771],{"class":199,"line":432},[197,726,266],{"class":210},[197,728,729],{"class":218}," target ",[197,731,329],{"class":210},[197,733,694],{"class":281},[197,735,335],{"class":210},[197,737,738],{"class":218}," (img.",[197,740,741],{"class":214},"Bounds",[197,743,744],{"class":218},"().",[197,746,747],{"class":214},"Dx",[197,749,407],{"class":218},[197,751,329],{"class":210},[197,753,754],{"class":218}," target.X ",[197,756,323],{"class":210},[197,758,759],{"class":218}," img.",[197,761,741],{"class":214},[197,763,744],{"class":218},[197,765,766],{"class":214},"Dy",[197,768,407],{"class":218},[197,770,329],{"class":210},[197,772,773],{"class":218}," target.Y) {\n",[197,775,776,779,782,785,788],{"class":199,"line":447},[197,777,778],{"class":218},"        img ",[197,780,781],{"class":210},"=",[197,783,784],{"class":214}," resizeImage",[197,786,787],{"class":218},"(img, target.X, target.Y) ",[197,789,790],{"class":203},"\u002F\u002F 扫描页降采样目标\n",[197,792,793,796,798],{"class":199,"line":461},[197,794,795],{"class":218},"        result ",[197,797,781],{"class":210},[197,799,800],{"class":218}," pdfImageReplacedDownsampled\n",[197,802,804],{"class":199,"line":803},18,[197,805,288],{"class":218},[197,807,809,812,815,818,820],{"class":199,"line":808},19,[197,810,811],{"class":210},"    var",[197,813,814],{"class":218}," out ",[197,816,817],{"class":214},"bytes",[197,819,229],{"class":218},[197,821,822],{"class":214},"Buffer\n",[197,824,826,828,830,832,835,838,840,843,846,848,851,853,856,859,861,863],{"class":199,"line":825},20,[197,827,266],{"class":210},[197,829,689],{"class":218},[197,831,250],{"class":210},[197,833,834],{"class":218}," jpeg.",[197,836,837],{"class":214},"Encode",[197,839,219],{"class":218},[197,841,842],{"class":210},"&",[197,844,845],{"class":218},"out, img, ",[197,847,842],{"class":210},[197,849,850],{"class":214},"jpeg",[197,852,229],{"class":218},[197,854,855],{"class":214},"Options",[197,857,858],{"class":218},"{Quality: pdfJPEGQuality}); err ",[197,860,329],{"class":210},[197,862,694],{"class":281},[197,864,241],{"class":218},[197,866,868,870,872,874,876],{"class":199,"line":867},21,[197,869,278],{"class":210},[197,871,226],{"class":214},[197,873,229],{"class":218},[197,875,232],{"class":214},[197,877,580],{"class":218},[197,879,881],{"class":199,"line":880},22,[197,882,288],{"class":218},[197,884,886,888,891,894,896,899,902],{"class":199,"line":885},23,[197,887,266],{"class":210},[197,889,890],{"class":218}," out.",[197,892,893],{"class":214},"Len",[197,895,407],{"class":218},[197,897,898],{"class":210},">=",[197,900,901],{"class":214}," len",[197,903,904],{"class":218},"(sd.Raw) {\n",[197,906,908,910,912,914,916,919],{"class":199,"line":907},24,[197,909,278],{"class":210},[197,911,226],{"class":214},[197,913,229],{"class":218},[197,915,232],{"class":214},[197,917,918],{"class":218},"{}, pdfImageNotSmaller ",[197,920,921],{"class":203},"\u002F\u002F 重存不更小：保持原样\n",[197,923,925],{"class":199,"line":924},25,[197,926,288],{"class":218},[197,928,930],{"class":199,"line":929},26,[197,931,932],{"class":203},"    \u002F\u002F ……写回\n",[197,934,936],{"class":199,"line":935},27,[197,937,464],{"class":218},[14,939,940],{},"质量档定死 75、不做多档：调研里 75 正是各家「Recommended」档的甜点值，多给档位只是把工程决策转嫁给用户。解码失败按「不支持」计入跳过而不是报错——个别图像异常不该毁掉整份文件。",[14,942,943,946,947,950,951,954,955,958,959,962],{},[72,944,945],{},"写回。"," 这一步的细节比想象中多，每处漏了都会产出损坏或语义漂移的 PDF：替换编码字节和 ",[87,948,949],{},"StreamLength","；缩放过的图要重申字典里的 ",[87,952,953],{},"Width","\u002F",[87,956,957],{},"Height","；色彩空间按实际编码通道数重申（灰度图重编码后仍是灰度）；删掉 ",[87,960,961],{},"DecodeParms","——那是原编码的解码参数，新的裸 JPEG 没有。SMask（透明蒙版）是独立对象，不在处理范围内，原样保留。",[14,964,965,966,968,969,972],{},"还有一个 pdfcpu 特有的坑值得单独记一笔：它的 ",[87,967,232],{}," 是",[72,970,971],{},"值类型","，从 xref 条目取出来的是副本，改副本没有任何效果，必须整体写回：",[188,974,976],{"className":190,"code":975,"language":192,"meta":193,"style":193},"for objNr, entry := range doc.XRefTable.Table {\n    sd, ok := entry.Object.(types.StreamDict)\n    if !ok || !sd.Image() {\n        continue\n    }\n    stats.ImagesTotal++\n    var target *image.Point\n    if t, hit := scanTargets[objNr]; hit {\n        target = &t\n    }\n    \u002F\u002F ……重编码（上一段）\n    entry.Object = recompressed \u002F\u002F 值类型：必须整体写回条目\n}\n",[87,977,978,994,1012,1032,1037,1041,1049,1065,1077,1090,1094,1099,1112],{"__ignoreMap":193},[197,979,980,983,986,988,991],{"class":199,"line":200},[197,981,982],{"class":210},"for",[197,984,985],{"class":218}," objNr, entry ",[197,987,250],{"class":210},[197,989,990],{"class":210}," range",[197,992,993],{"class":218}," doc.XRefTable.Table {\n",[197,995,996,999,1001,1004,1006,1008,1010],{"class":199,"line":207},[197,997,998],{"class":218},"    sd, ok ",[197,1000,250],{"class":210},[197,1002,1003],{"class":218}," entry.Object.(",[197,1005,302],{"class":214},[197,1007,229],{"class":218},[197,1009,232],{"class":214},[197,1011,310],{"class":218},[197,1013,1014,1016,1018,1020,1022,1024,1026,1029],{"class":199,"line":244},[197,1015,266],{"class":210},[197,1017,269],{"class":210},[197,1019,320],{"class":218},[197,1021,323],{"class":210},[197,1023,269],{"class":210},[197,1025,552],{"class":218},[197,1027,1028],{"class":214},"Image",[197,1030,1031],{"class":218},"() {\n",[197,1033,1034],{"class":199,"line":263},[197,1035,1036],{"class":210},"        continue\n",[197,1038,1039],{"class":199,"line":275},[197,1040,288],{"class":218},[197,1042,1043,1046],{"class":199,"line":285},[197,1044,1045],{"class":218},"    stats.ImagesTotal",[197,1047,1048],{"class":210},"++\n",[197,1050,1051,1053,1055,1058,1060,1062],{"class":199,"line":291},[197,1052,811],{"class":210},[197,1054,729],{"class":218},[197,1056,1057],{"class":210},"*",[197,1059,522],{"class":214},[197,1061,229],{"class":218},[197,1063,1064],{"class":214},"Point\n",[197,1066,1067,1069,1072,1074],{"class":199,"line":313},[197,1068,266],{"class":210},[197,1070,1071],{"class":218}," t, hit ",[197,1073,250],{"class":210},[197,1075,1076],{"class":218}," scanTargets[objNr]; hit {\n",[197,1078,1079,1082,1084,1087],{"class":199,"line":349},[197,1080,1081],{"class":218},"        target ",[197,1083,781],{"class":210},[197,1085,1086],{"class":210}," &",[197,1088,1089],{"class":218},"t\n",[197,1091,1092],{"class":199,"line":360},[197,1093,288],{"class":218},[197,1095,1096],{"class":199,"line":365},[197,1097,1098],{"class":203},"    \u002F\u002F ……重编码（上一段）\n",[197,1100,1101,1104,1106,1109],{"class":199,"line":390},[197,1102,1103],{"class":218},"    entry.Object ",[197,1105,781],{"class":210},[197,1107,1108],{"class":218}," recompressed ",[197,1110,1111],{"class":203},"\u002F\u002F 值类型：必须整体写回条目\n",[197,1113,1114],{"class":199,"line":417},[197,1115,464],{"class":218},[123,1117,1118],{"id":1118},"扫描页降采样",[14,1120,1121,1122,1124],{},"第一版只有重存、承诺「不缩小像素尺寸」，实际用下来发现这等于把业内最大的杠杆关在门外：扫描件按 300–600 DPI 存，屏幕阅读 150 就够，光靠质量重存动不了像素总量。0.2.0 修订了这个决策——满页扫描图默认压到 150 DPI，和 Ghostscript ",[87,1123,112],{}," 同款。",[14,1126,1127,1128,1131],{},"难点在",[72,1129,1130],{},"怎么判定「这一页是扫描页」","。对任意插图降采样，得解析内容流里的变换矩阵算出图像的真实显示尺寸，复杂且容易误伤插图。我们的做法是绕开内容流，只用结构信号取一个安全子集：",[1133,1134,1135,1151],"table",{},[1136,1137,1138],"thead",{},[1139,1140,1141,1145,1148],"tr",{},[1142,1143,1144],"th",{},"判定",[1142,1146,1147],{},"条件",[1142,1149,1150],{},"不满足时",[1152,1153,1154,1166,1177,1192,1202],"tbody",{},[1139,1155,1156,1160,1163],{},[1157,1158,1159],"td",{},"页面有效",[1157,1161,1162],{},"MediaBox 可读、宽高为正",[1157,1164,1165],{},"跳过该页",[1139,1167,1168,1171,1174],{},[1157,1169,1170],{},"恰有一张图",[1157,1172,1173],{},"页面资源里只有一个图像 XObject，且是直接引用（包在 Form 里的不算）",[1157,1175,1176],{},"不是扫描页",[1139,1178,1179,1182,1189],{},[1157,1180,1181],{},"图填满页面",[1157,1183,1184,1185,1188],{},"图与页的长宽比一致，±15% 容差（",[87,1186,1187],{},"\u002FRotate"," 90\u002F270 已换轴）",[1157,1190,1191],{},"不降采样",[1139,1193,1194,1197,1200],{},[1157,1195,1196],{},"值得压",[1157,1198,1199],{},"有效 DPI = 图长边 px ÷（页长边 pt ÷ 72）> 150",[1157,1201,1191],{},[1139,1203,1204,1207,1210],{},[1157,1205,1206],{},"目标一致",[1157,1208,1209],{},"同一张图被多页共享时，各页算出的目标相同",[1157,1211,1212],{},"放弃该图",[14,1214,1215,1216,1219],{},"判定成立时，有效 DPI 可以从页面尺寸",[72,1217,1218],{},"精确算出","，完全不碰内容流——PDF 的页面单位是点，1 点 = 1\u002F72 英寸：",[188,1221,1223],{"className":190,"code":1222,"language":192,"meta":193,"style":193},"\u002F\u002F internal\u002Fimageproc\u002Fpdf.go，scanImageTargets\ndpi := float64(imgLong) \u002F (pageLong \u002F 72.0)\nif dpi \u003C= pdfScanDPIFloor { \u002F\u002F 150.0\n    continue\n}\nscale := pdfScanDPIFloor \u002F dpi\nnewLong := max(1, int(math.Round(float64(imgLong)*scale)))\nnewShort := max(1, int(math.Round(float64(imgShort)*scale)))\n",[87,1224,1225,1230,1255,1271,1276,1280,1295,1334],{"__ignoreMap":193},[197,1226,1227],{"class":199,"line":200},[197,1228,1229],{"class":203},"\u002F\u002F internal\u002Fimageproc\u002Fpdf.go，scanImageTargets\n",[197,1231,1232,1235,1237,1240,1243,1245,1248,1250,1253],{"class":199,"line":207},[197,1233,1234],{"class":218},"dpi ",[197,1236,250],{"class":210},[197,1238,1239],{"class":210}," float64",[197,1241,1242],{"class":218},"(imgLong) ",[197,1244,954],{"class":210},[197,1246,1247],{"class":218}," (pageLong ",[197,1249,954],{"class":210},[197,1251,1252],{"class":281}," 72.0",[197,1254,310],{"class":218},[197,1256,1257,1260,1263,1265,1268],{"class":199,"line":244},[197,1258,1259],{"class":210},"if",[197,1261,1262],{"class":218}," dpi ",[197,1264,607],{"class":210},[197,1266,1267],{"class":218}," pdfScanDPIFloor { ",[197,1269,1270],{"class":203},"\u002F\u002F 150.0\n",[197,1272,1273],{"class":199,"line":263},[197,1274,1275],{"class":210},"    continue\n",[197,1277,1278],{"class":199,"line":275},[197,1279,464],{"class":218},[197,1281,1282,1285,1287,1290,1292],{"class":199,"line":285},[197,1283,1284],{"class":218},"scale ",[197,1286,250],{"class":210},[197,1288,1289],{"class":218}," pdfScanDPIFloor ",[197,1291,954],{"class":210},[197,1293,1294],{"class":218}," dpi\n",[197,1296,1297,1300,1302,1305,1307,1310,1312,1315,1318,1321,1323,1326,1329,1331],{"class":199,"line":291},[197,1298,1299],{"class":218},"newLong ",[197,1301,250],{"class":210},[197,1303,1304],{"class":214}," max",[197,1306,219],{"class":218},[197,1308,1309],{"class":281},"1",[197,1311,513],{"class":218},[197,1313,1314],{"class":210},"int",[197,1316,1317],{"class":218},"(math.",[197,1319,1320],{"class":214},"Round",[197,1322,219],{"class":218},[197,1324,1325],{"class":210},"float64",[197,1327,1328],{"class":218},"(imgLong)",[197,1330,1057],{"class":210},[197,1332,1333],{"class":218},"scale)))\n",[197,1335,1336,1339,1341,1343,1345,1347,1349,1351,1353,1355,1357,1359,1362,1364],{"class":199,"line":313},[197,1337,1338],{"class":218},"newShort ",[197,1340,250],{"class":210},[197,1342,1304],{"class":214},[197,1344,219],{"class":218},[197,1346,1309],{"class":281},[197,1348,513],{"class":218},[197,1350,1314],{"class":210},[197,1352,1317],{"class":218},[197,1354,1320],{"class":214},[197,1356,219],{"class":218},[197,1358,1325],{"class":210},[197,1360,1361],{"class":218},"(imgShort)",[197,1363,1057],{"class":210},[197,1365,1333],{"class":218},[14,1367,1368,1369,1372],{},"几个边角的处理都遵循同一个原则：",[72,1370,1371],{},"拿不准就不压","。长宽比差过 15% 说明图没填满页面（旁边可能有文字），不压；同一张图被多页共享而各页算出的目标矛盾，放弃这张图；DPI 略超 150 时缩放取整可能恰好等于原尺寸，此时回落成普通重存，不当作异常跳过。",[14,1374,1375,1376,1379,1380,1383,1384,1387,1388,1391],{},"实现上还有一个 pdfcpu 的坑：",[87,1377,1378],{},"ReadContext"," 之后 ",[87,1381,1382],{},"PageCount"," 可能为 0，直接逐页遍历会",[72,1385,1386],{},"一次都不执行、静默返回空结果","，必须先 ",[87,1389,1390],{},"EnsurePageCount()"," 把页树补齐。这类不报错、只是默默不生效的问题，靠读文档是发现不了的，是写测试夹具构造 300 DPI 扫描页时撞出来修掉的。",[64,1393,1395],{"id":1394},"三跟业内方案比亮点在哪","三、跟业内方案比，亮点在哪",[14,1397,1398,1401],{},[72,1399,1400],{},"1. 逐张判断，仅更小才替换。"," 预设方案是无条件套用：一张本来已经压得很好的图，被预设再压一遍，结果要么更糊、要么反而更大——这是各在线工具最常见的翻车点。我们逐张编码、逐张比较，不更小就放回原字节。这个判断也让「已经压好的文件再跑一遍」变成无害操作：第二次跑会全部命中「已经很省空间」，一个字节都不动。",[14,1403,1404,1407,1408,1410],{},[72,1405,1406],{},"2. DPI 是算出来的，不是估的。"," 从页面尺寸精确推导有效 DPI，不需要解析内容流；识别扫描页用结构信号（单图直引 + 长宽比匹配）取安全子集，宁可少压、绝不误伤排版插图。整个降采样没有魔法数字——150 对齐 Ghostscript ",[87,1409,112],{},"，75 对齐各家 Recommended 档，±15% 容差覆盖扫描页常见的轻微裁边。",[14,1412,1413,1416],{},[72,1414,1415],{},"3. 每张图的结果如实计数。"," 输出统计是五个数：图像总数、重存数、压到屏幕清晰度数、不支持的类型数、已经很省空间数。不支持的就明说不支持（传真码、CMYK 这些纯 Go 没有对应编码器），而不是悄悄跳过。界面上用户看到的是「重新保存了 x 张图片，其中 y 张压到了屏幕清晰度，z 张保持原样」——每张图发生了什么，有交代。",[14,1418,1419,1422],{},[72,1420,1421],{},"4. 纯 Go，单二进制，三平台。"," 同一套代码出 macOS（Universal）、Windows、麒麟 amd64\u002Farm64（信创交叉构建）的安装包，零外部依赖、零 CGO，没有许可问题。据我所知，开源里做「对象级 PDF 图像重压缩」的纯 Go 实现目前没有第二个。",[14,1424,1425,1428,1429,1432],{},[72,1426,1427],{},"5. 该不动的不动，该拒绝的拒绝。"," 文字和矢量内容字节级不变；加密的拒绝处理；带签名的拒绝处理（",[87,1430,1431],{},"SigFlags"," bit0 判定，只含未签署签名表单字段的普通表单不算，照常处理）。",[64,1434,1436],{"id":1435},"四实测结果","四、实测结果",[1133,1438,1439,1449],{},[1136,1440,1441],{},[1139,1442,1443,1446],{},[1142,1444,1445],{},"项目",[1142,1447,1448],{},"数字",[1152,1450,1451,1467,1475],{},[1139,1452,1453,1456],{},[1157,1454,1455],{},"spike：2400×2400 DCT 图像，质量 75 重编码",[1157,1457,1458,1459,1462,1463,1466],{},"整文件 ",[72,1460,1461],{},"-42.9%","，pdfcpu validate 通过，渲染像素对比平均差 ",[72,1464,1465],{},"0.41\u002F765","（765 为 RGB 三通道差值上限，肉眼不可辨）",[1139,1468,1469,1472],{},[1157,1470,1471],{},"300 DPI 扫描夹具（1200px 图 + 288pt 方页）",[1157,1473,1474],{},"压到 600×600（4 英寸 × 150 DPI），测试读回输出验证像素",[1139,1476,1477,1480],{},[1157,1478,1479],{},"单文件 \u002F 单图上限",[1157,1481,1482],{},"512 MB \u002F 100 MP",[14,1484,1485],{},"测试矩阵：高噪声 JPEG（质量 100 与 75 之间体积差异巨大，断言确定性）显著变小；300 DPI 满页图降采样并读回验证；长宽比不符不降采样；Flate 图计入不支持；加密、损坏分别返回对应错误码；窄带回落普通重存。",[14,1487,1488],{},[43,1489],{"alt":1490,"src":1491},"批次完成后的队列与汇总","https:\u002F\u002Fname.os.bennywen.xyz\u002Fbennywen.xyz\u002Fassets\u002Ffile-processer\u002F%E5%B7%A5%E4%BD%9C%E5%8F%B0-%E5%A4%84%E7%90%86%E7%BB%93%E6%9E%9C.png",[14,1493,1494],{},[50,1495,1496],{},"批次结束的汇总与逐项明细（示例为图片批次）：每行给出原体积 → 输出体积与节省比例，汇总条给累计节省。PDF 批次同样逐项报告——「重新保存了 x 张图片，其中 y 张压到了屏幕清晰度，z 张保持原样」。",[14,1498,1499],{},"已知限制照实写在产品文档里：Flate\u002FJBIG2\u002FCCITT\u002FJPX 编码与 CMYK\u002F索引色的图保持原样并计数；内嵌字体不做子集化；150 DPI 是屏幕清晰度，打印可能不够。",[14,1501,1502,1503,471,1507,1510],{},"完整实现在 ",[18,1504,1506],{"href":55,"rel":1505},[57],"Benny66\u002Ffile-processor",[87,1508,1509],{},"internal\u002Fimageproc\u002Fpdf.go","，363 行含注释），三平台安装包在 Release 页。文中代码与数字均出自该仓库，行号以 2026-10-24 版本为准。",[1512,1513,1514],"style",{},"html pre.shiki code .si27w, html code.shiki .si27w{--shiki-default:#6A737D;--shiki-light:#6A737D}html pre.shiki code .spKkM, html code.shiki .spKkM{--shiki-default:#F97583;--shiki-light:#D73A49}html pre.shiki code .sqoU-, html code.shiki .sqoU-{--shiki-default:#B392F0;--shiki-light:#6F42C1}html pre.shiki code .shWlK, html code.shiki .shWlK{--shiki-default:#E1E4E8;--shiki-light:#24292E}html pre.shiki code .sdxZB, html code.shiki .sdxZB{--shiki-default:#FFAB70;--shiki-light:#E36209}html pre.shiki code .skb7c, html code.shiki .skb7c{--shiki-default:#9ECBFF;--shiki-light:#032F62}html pre.shiki code .sTU5a, html code.shiki .sTU5a{--shiki-default:#79B8FF;--shiki-light:#005CC5}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-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.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);}",{"title":193,"searchDepth":207,"depth":207,"links":1516},[1517,1518,1523,1524],{"id":66,"depth":207,"text":67},{"id":120,"depth":207,"text":121,"children":1519},[1520,1521,1522],{"id":125,"depth":244,"text":125},{"id":169,"depth":244,"text":170},{"id":1118,"depth":244,"text":1118},{"id":1394,"depth":207,"text":1395},{"id":1435,"depth":207,"text":1436},null,"2026-10-24","md",{},true,"\u002Fposts\u002Fpure-go-pdf-compression","image-workshop",{"title":6,"description":193},"pure-go-pdf-compression","posts\u002Fpure-go-pdf-compression","内网交材料，系统限 5MB，扫描件 40MB——在线工具进不去内网，Ghostscript 是外部进程引不进要自包含的桌面工具。这篇讲清楚业内「降采样 + 重存 + 无损优化」的本质，以及一个纯 Go 的对象级实现：安全子集怎么画、150 DPI 怎么从页面尺寸精确算出、写回有哪些坑。",[1537,1538,1539],"Go","PDF","图像处理","WAZnKLPSHdx2D65otHsOBwBX91FLsjiwDT9EYhE087w",{"id":1542,"title":1543,"body":1544,"cover":1525,"date":5155,"description":193,"extension":1527,"links":1525,"meta":5156,"navigation":1529,"path":5157,"project":5158,"seo":5159,"slug":5160,"stem":5161,"summary":5162,"tags":5163,"visible":1529,"__hash__":5165},"posts\u002Fposts\u002Fwails-v3-upgrade.md","Wails v3 升级实战：桌面提醒客户端从 v2 升到 v3，踩了 10 个不报错的坑",{"type":8,"value":1545,"toc":5092},[1546,1573,1576,1581,1584,1587,1590,1663,1666,1669,1673,1676,1681,1684,1688,1694,1783,1787,1813,1819,1825,1831,1835,1841,1847,1853,1862,1865,1872,1874,1878,1881,1885,1892,1896,1899,1905,1908,1911,1914,1922,1928,1934,1937,1940,1945,1952,1955,1961,1966,1977,1980,1983,2018,2021,2023,2027,2030,2034,2040,2105,2111,2199,2205,2222,2233,2237,2252,2256,2290,2293,2313,2321,2324,2333,2375,2383,2433,2445,2488,2498,2502,2507,2511,2527,2530,2539,2543,2551,2563,2574,2577,2775,2777,2781,2787,2790,2797,2800,2804,2832,2838,2845,2850,2854,2891,2895,2898,2983,2990,2994,3035,3045,3051,3055,3058,3061,3082,3088,3092,3125,3135,3139,3142,3145,3260,3265,3268,3270,3274,3277,3284,3288,3291,3298,3369,3378,3381,3396,3401,3404,3408,3411,3414,3476,3481,3484,3539,3542,3545,3635,3648,3658,3662,3669,3675,3681,3746,3757,3760,3764,3767,3809,3816,3820,3823,3851,3854,4012,4017,4021,4028,4050,4057,4064,4073,4094,4110,4113,4119,4129,4137,4147,4150,4169,4173,4179,4186,4196,4200,4213,4228,4231,4233,4237,4240,4247,4256,4259,4279,4282,4309,4318,4322,4325,4328,4388,4394,4397,4413,4420,4425,4460,4464,4467,4474,4479,4565,4568,4587,4590,4645,4654,4660,4664,4667,4672,4740,4745,4755,4761,4770,4780,4783,4790,4796,4800,4806,4812,4826,4903,4908,4915,4922,4925,4928,4951,4957,4959,4963,4966,4992,4995,5006,5010,5016,5029,5032,5039,5050,5052,5055,5078,5080,5089],[11,1547,1548,1559],{},[14,1549,1550,1551,1554,1555,1558],{},"姊妹篇：",[18,1552,1553],{"href":20},"《Wails v2 实战：用 Go + Vue3 做一个真正能用的 AI 桌面应用》"," 讲的是「第一次用 Wails 做桌面端」——为什么不用 Electron、绑定和事件怎么配合、SQLite 怎么当唯一真相。这篇讲另一个问题：",[72,1556,1557],{},"框架换代","。Wails 从 v2 升到 v3，哪些地方变了、为什么变、怎么迁、迁完踩了什么。",[14,1560,1561,1562,1566,1567,1572],{},"项目：",[18,1563,1565],{"href":1564},"\u002Fprojects\u002Fmorrow","Morrow · 桌面健康提醒"," · ",[18,1568,1571],{"href":1569,"rel":1570},"https:\u002F\u002Fgithub.com\u002FBenny66\u002Fmorrow",[57],"GitHub","。技术栈 Go 1.25 + Vue 3 + SQLite，Wails 从 v2.15.0 升到 v3.0.0-beta.23。",[64,1574,1575],{"id":1575},"先说这是个什么项目",[14,1577,1578],{},[72,1579,1580],{},"Morrow 是一个桌面提醒客户端。",[14,1582,1583],{},"它干的事很朴素：你在电脑前坐久了，它提醒你喝水、起来走走、看一眼远处。不是又一个手机 App，是装在你 Mac \u002F Windows \u002F Linux 上的一个桌面程序。",[14,1585,1586],{},"它有点特别的地方是：提醒不是靠弹窗，而是靠一只叫「小满」的桌面宠物——一滴水，头顶长了株芽，你每完成一次喝水或起身，芽就长一点。",[14,1588,1589],{},"之所以拿它当例子讲 Wails，是因为它几乎把桌面端的\"麻烦需求\"踩了个遍：",[1133,1591,1592,1602],{},[1136,1593,1594],{},[1139,1595,1596,1599],{},[1142,1597,1598],{},"项",[1142,1600,1601],{},"情况",[1152,1603,1604,1612,1620,1628,1636,1644],{},[1139,1605,1606,1609],{},[1157,1607,1608],{},"形态",[1157,1610,1611],{},"桌面客户端，单文件安装，没有服务端",[1139,1613,1614,1617],{},[1157,1615,1616],{},"平台",[1157,1618,1619],{},"macOS \u002F Windows \u002F Linux",[1139,1621,1622,1625],{},[1157,1623,1624],{},"窗口",[1157,1626,1627],{},"4 类：主窗、桌面宠物（透明、置顶、浮在所有窗口上）、全屏强提醒、副屏强提醒",[1139,1629,1630,1633],{},[1157,1631,1632],{},"系统能力",[1157,1634,1635],{},"托盘常驻、原生通知、全局快捷键、录屏时自动隐身、防止重复启动",[1139,1637,1638,1641],{},[1157,1639,1640],{},"数据",[1157,1642,1643],{},"全部存本地 SQLite，无账号、无云同步",[1139,1645,1646,1649],{},[1157,1647,1648],{},"代码量",[1157,1650,1651,1654,1655,1658,1659,1662],{},[87,1652,1653],{},"main.go"," 413 行，",[87,1656,1657],{},"app.go"," 2682 行，",[87,1660,1661],{},"internal\u002F"," 162 个 Go 文件",[14,1664,1665],{},"这些需求，Hello World 一个都碰不到，但真做一个能用的桌面软件，你一定会碰到。",[1667,1668],"hr",{},[64,1670,1672],{"id":1671},"一wails-到底是个啥","一、Wails 到底是个啥",[123,1674,1675],{"id":1675},"一句话说清",[14,1677,1678],{},[72,1679,1680],{},"Wails 就是让 Go 开一个原生窗口，窗口里装个浏览器，然后 Go 的函数可以直接被网页里的 JS 调用。",[14,1682,1683],{},"你写 Go 写后端，写 Vue \u002F React 写界面，Wails 把两边接起来，最后打成一个可执行文件。",[123,1685,1687],{"id":1686},"和-electron-的区别","和 Electron 的区别",[14,1689,1690,1691,484],{},"很多人一听\"用网页写桌面软件\"就想到 Electron。两者最大的不同是",[72,1692,1693],{},"浏览器从哪来",[1133,1695,1696,1711],{},[1136,1697,1698],{},[1139,1699,1700,1702,1705,1708],{},[1142,1701],{},[1142,1703,1704],{},"Electron",[1142,1706,1707],{},"Wails",[1142,1709,1710],{},"Tauri",[1152,1712,1713,1728,1743,1757,1771],{},[1139,1714,1715,1718,1721,1726],{},[1157,1716,1717],{},"浏览器",[1157,1719,1720],{},"自己带一个 Chromium",[1157,1722,1723],{},[72,1724,1725],{},"用系统自带的",[1157,1727,1725],{},[1139,1729,1730,1733,1736,1740],{},[1157,1731,1732],{},"后端语言",[1157,1734,1735],{},"Node.js",[1157,1737,1738],{},[72,1739,1537],{},[1157,1741,1742],{},"Rust",[1139,1744,1745,1748,1751,1754],{},[1157,1746,1747],{},"安装包大小",[1157,1749,1750],{},"上百 MB",[1157,1752,1753],{},"十几 MB",[1157,1755,1756],{},"几 MB",[1139,1758,1759,1762,1765,1768],{},[1157,1760,1761],{},"内存占用",[1157,1763,1764],{},"高（Chromium 进程多）",[1157,1766,1767],{},"中",[1157,1769,1770],{},"低",[1139,1772,1773,1776,1779,1781],{},[1157,1774,1775],{},"前端技术",[1157,1777,1778],{},"什么都能用",[1157,1780,1778],{},[1157,1782,1778],{},[123,1784,1786],{"id":1785},"优点我真实感受到的","优点（我真实感受到的）",[14,1788,1789,1792,1793,1796,1797,1800,1801,1804,1805,1808,1809,1812],{},[72,1790,1791],{},"1. 就一个文件，不用装运行时。","\n前端打包出来的 ",[87,1794,1795],{},"dist"," 用 ",[87,1798,1799],{},"\u002F\u002Fgo:embed"," 塞进 Go 二进制里。发给用户就是一个 ",[87,1802,1803],{},".app"," 或者 ",[87,1806,1807],{},".exe","，没有 ",[87,1810,1811],{},"node_modules","，没有额外依赖。",[14,1814,1815,1818],{},[72,1816,1817],{},"2. 后端就是 Go，不用写中间层。","\n想读个文件、连个数据库，直接写 Go。不像 Electron 那样，主进程和渲染进程之间要来回发消息，写一堆 IPC。",[14,1820,1821,1824],{},[72,1822,1823],{},"3. 该有的系统能力都有。","\n托盘、通知、多窗口、全局快捷键、单实例锁——做桌面软件要用的东西，v3 基本齐了。",[14,1826,1827,1830],{},[72,1828,1829],{},"4. 跨平台是真的跨。","\nmacOS 用系统 WebView，Windows 用 WebView2，Linux 用 WebKitGTK，一套代码三处跑。",[123,1832,1834],{"id":1833},"缺点也得说清楚","缺点（也得说清楚）",[14,1836,1837,1840],{},[72,1838,1839],{},"1. 浏览器版本不可控。","\n用户系统里的 WebView 可能是三年前的版本。你写的 CSS，在你自己机器上好好的，到用户那儿可能就错位了。Electron 没这个问题，因为它自带浏览器。",[14,1842,1843,1846],{},[72,1844,1845],{},"2. 有些功能得走\"歪门邪道\"。","\n比如在 macOS 上让窗口真透明，得用苹果没公开的私有 API，还要在编译时加个特殊参数。这类事官方文档一句话带过，你得自己啃源码。",[14,1848,1849,1852],{},[72,1850,1851],{},"3. 生态小，遇事只能自己查。","\nElectron 报个错，搜索引擎一堆答案。Wails 报个错，可能全网就你一个。",[14,1854,1855,1858,1859],{},[72,1856,1857],{},"4. 文档跟不上代码。","\n尤其 v3 还在 beta，文档经常落后源码半个版本。",[72,1860,1861],{},"最靠谱的资料来源是源码注释和 GitHub issue。",[14,1863,1864],{},"一句话总结：",[11,1866,1867],{},[14,1868,1869],{},[72,1870,1871],{},"Wails 轻、干净、够用，但它不是\"开箱即用\"的框架，是\"给你一副好骨架，血肉自己长\"。",[1667,1873],{},[64,1875,1877],{"id":1876},"二我为什么要升级","二、我为什么要升级",[14,1879,1880],{},"这点得说实话，因为动机决定了后面所有取舍。",[123,1882,1884],{"id":1883},"不是因为版本号更高","不是因为\"版本号更高\"",[14,1886,1887,1888,1891],{},"v3 在我升级那会儿还是 beta（",[87,1889,1890],{},"v3.0.0-beta.23","），生产项目上 beta 是有风险的。如果只是\"新版本更好看\"，我不会动。",[123,1893,1895],{"id":1894},"真正的原因v3-有个-mcp","真正的原因：v3 有个 MCP",[14,1897,1898],{},"MCP 是让 AI 直接操作应用的一套协议。v3 把它内置了，意思是：",[14,1900,1901,1904],{},[72,1902,1903],{},"AI 可以直接驱动你正在运行的这个桌面程序","——点按钮、读页面元素、调 Go 函数、等事件发生，全程不用人写一行测试代码。",[14,1906,1907],{},"这个能力对我意味着什么？",[14,1909,1910],{},"以前 AI 帮我改完代码，验证方式是：我人肉点界面、截图、看日志、告诉它\"行\"或\"不行\"。改十次，我就得点十次。",[14,1912,1913],{},"现在 AI 改完能自己验收：",[188,1915,1920],{"className":1916,"code":1918,"language":1919},[1917],"language-text","AI 改代码 → 重新构建 → 启动应用 → 调 Go 方法 → 读页面 → 断言成功\n","text",[87,1921,1918],{"__ignoreMap":193},[14,1923,1924,1927],{},[72,1925,1926],{},"这是\"能力从无到有\"，不是\"效率提高一点\"。"," 而 MCP 只在 v3 里有，v2 永远不会加回来。",[14,1929,1930,1931],{},"所以升级的理由只有一条：",[72,1932,1933],{},"等稳定版 = 无限期搁置这件事。",[123,1935,1936],{"id":1936},"顺带解决两个老问题",[14,1938,1939],{},"除了 MCP，v3 还顺手解决了 v2 时代两个憋屈的妥协：",[14,1941,1942],{},[72,1943,1944],{},"1. macOS 上终于有系统托盘了。",[14,1946,1947,1948,1951],{},"v2 时代 macOS 没有托盘，原因很技术：Wails 自己占着 macOS 的 ",[87,1949,1950],{},"NSApplication"," delegate（一个全局回调入口），而所有第三方的托盘库也要抢这个位置。两个抢一个，结果是编译期符号冲突、运行时崩溃。",[14,1953,1954],{},"项目当初的解决办法是：macOS 上直接不做托盘，另外还 fork 了一个托盘库。",[14,1956,1957,1958],{},"v3 把托盘做进框架了，冲突从根上没了。于是：",[72,1959,1960],{},"一个 no-op 文件、一个 fork 依赖、一段\"为什么 macOS 不行\"的注释，全删了。",[14,1962,1963],{},[72,1964,1965],{},"2. 日志适配器可以删了。",[14,1967,1968,1969,1972,1973,1976],{},"v2 的日志接口是自定义的，项目写了个 ",[87,1970,1971],{},"WailsAdapter"," 手工桥接到标准日志库。v3 直接用 Go 标准的 ",[87,1974,1975],{},"*slog.Logger","，桥接层删掉。",[123,1978,1979],{"id":1979},"升级的代价我怎么对冲",[14,1981,1982],{},"因为用的是 beta，我的做法是：",[1984,1985,1986,2002,2012],"ul",{},[80,1987,1988,484,1991,1994,1995,1997,1998,2001],{},[72,1989,1990],{},"版本钉死",[87,1992,1993],{},"go.mod"," 和依赖清单都写死 ",[87,1996,1890],{},"，不用 ",[87,1999,2000],{},"@latest","。",[80,2003,2004,2007,2008,2011],{},[72,2005,2006],{},"靠编译兜不住的，靠实跑兜","：API 改名这类问题，",[87,2009,2010],{},"go build"," 会直接报错，不会漏。真正危险的是\"编译过了但行为变了\"——这类只能靠实际跑起来验证。",[80,2013,2014,2017],{},[72,2015,2016],{},"把\"编译通过\"和\"实跑通过\"分成两道关卡","，不合并。",[14,2019,2020],{},"这条纪律后面救了我好几次。",[1667,2022],{},[64,2024,2026],{"id":2025},"三v2-和-v3-到底差在哪","三、v2 和 v3 到底差在哪",[14,2028,2029],{},"差异不少，但真正影响你写代码的是这五处。",[123,2031,2033],{"id":2032},"_1-启动方式一调到底-三步走","1. 启动方式：一调到底 → 三步走",[14,2035,2036,2039],{},[72,2037,2038],{},"v2："," 一个函数搞定。",[188,2041,2043],{"className":190,"code":2042,"language":192,"meta":193,"style":193},"wails.Run(&options.App{\n    Title:  \"Morrow\",\n    Width:  1024,\n    Bind:   []interface{}{app},\n})\n",[87,2044,2045,2068,2079,2089,2100],{"__ignoreMap":193},[197,2046,2047,2050,2053,2055,2057,2060,2062,2065],{"class":199,"line":200},[197,2048,2049],{"class":218},"wails.",[197,2051,2052],{"class":214},"Run",[197,2054,219],{"class":218},[197,2056,842],{"class":210},[197,2058,2059],{"class":214},"options",[197,2061,229],{"class":218},[197,2063,2064],{"class":214},"App",[197,2066,2067],{"class":218},"{\n",[197,2069,2070,2073,2076],{"class":199,"line":207},[197,2071,2072],{"class":218},"    Title:  ",[197,2074,2075],{"class":256},"\"Morrow\"",[197,2077,2078],{"class":218},",\n",[197,2080,2081,2084,2087],{"class":199,"line":244},[197,2082,2083],{"class":218},"    Width:  ",[197,2085,2086],{"class":281},"1024",[197,2088,2078],{"class":218},[197,2090,2091,2094,2097],{"class":199,"line":263},[197,2092,2093],{"class":218},"    Bind:   []",[197,2095,2096],{"class":210},"interface",[197,2098,2099],{"class":218},"{}{app},\n",[197,2101,2102],{"class":199,"line":275},[197,2103,2104],{"class":218},"})\n",[14,2106,2107,2110],{},[72,2108,2109],{},"v3："," 拆成三步。",[188,2112,2114],{"className":190,"code":2113,"language":192,"meta":193,"style":193},"\u002F\u002F 第一步：建应用（服务、资源、日志、错误处理、单实例）\nwailsApp := application.New(application.Options{...})\n\n\u002F\u002F 第二步：建窗口\nwindow := wailsApp.Window.NewWithOptions(...)\n\n\u002F\u002F 第三步：跑起来\nwailsApp.Run()\n",[87,2115,2116,2121,2151,2156,2161,2180,2184,2189],{"__ignoreMap":193},[197,2117,2118],{"class":199,"line":200},[197,2119,2120],{"class":203},"\u002F\u002F 第一步：建应用（服务、资源、日志、错误处理、单实例）\n",[197,2122,2123,2126,2128,2131,2134,2136,2139,2141,2143,2146,2149],{"class":199,"line":207},[197,2124,2125],{"class":218},"wailsApp ",[197,2127,250],{"class":210},[197,2129,2130],{"class":218}," application.",[197,2132,2133],{"class":214},"New",[197,2135,219],{"class":218},[197,2137,2138],{"class":214},"application",[197,2140,229],{"class":218},[197,2142,855],{"class":214},[197,2144,2145],{"class":218},"{",[197,2147,2148],{"class":210},"...",[197,2150,2104],{"class":218},[197,2152,2153],{"class":199,"line":244},[197,2154,2155],{"emptyLinePlaceholder":1529},"\n",[197,2157,2158],{"class":199,"line":263},[197,2159,2160],{"class":203},"\u002F\u002F 第二步：建窗口\n",[197,2162,2163,2166,2168,2171,2174,2176,2178],{"class":199,"line":275},[197,2164,2165],{"class":218},"window ",[197,2167,250],{"class":210},[197,2169,2170],{"class":218}," wailsApp.Window.",[197,2172,2173],{"class":214},"NewWithOptions",[197,2175,219],{"class":218},[197,2177,2148],{"class":210},[197,2179,310],{"class":218},[197,2181,2182],{"class":199,"line":285},[197,2183,2155],{"emptyLinePlaceholder":1529},[197,2185,2186],{"class":199,"line":291},[197,2187,2188],{"class":203},"\u002F\u002F 第三步：跑起来\n",[197,2190,2191,2194,2196],{"class":199,"line":313},[197,2192,2193],{"class":218},"wailsApp.",[197,2195,2052],{"class":214},[197,2197,2198],{"class":218},"()\n",[14,2200,2201,2204],{},[72,2202,2203],{},"为什么这么改："," 因为窗口不再只有一个。v2 那个写法默认\"一个应用一个窗口\"，v3 要支持多窗口，就得把\"建应用\"和\"建窗口\"分开。",[14,2206,2207,2210,2211,2214,2215,2217,2218,2221],{},[72,2208,2209],{},"带来一个坑："," 第三步 ",[87,2212,2213],{},"Run()"," 一进去就会回调所有服务的启动方法。所以你要在 ",[87,2216,2213],{}," ",[72,2219,2220],{},"之前","把应用实例注入给业务代码，否则启动方法里用到了就 nil panic——用户看到的是\"双击没反应\"。",[123,2223,2225,2226,2229,2230],{"id":2224},"_2-绑定bind-services","2. 绑定：",[87,2227,2228],{},"Bind"," → ",[87,2231,2232],{},"Services",[14,2234,2235],{},[72,2236,2038],{},[188,2238,2240],{"className":190,"code":2239,"language":192,"meta":193,"style":193},"Bind: []interface{}{app}\n",[87,2241,2242],{"__ignoreMap":193},[197,2243,2244,2247,2249],{"class":199,"line":200},[197,2245,2246],{"class":218},"Bind: []",[197,2248,2096],{"class":210},[197,2250,2251],{"class":218},"{}{app}\n",[14,2253,2254],{},[72,2255,2109],{},[188,2257,2259],{"className":190,"code":2258,"language":192,"meta":193,"style":193},"Services: []application.Service{\n    application.NewService(app),\n}\n",[87,2260,2261,2275,2286],{"__ignoreMap":193},[197,2262,2263,2266,2268,2270,2273],{"class":199,"line":200},[197,2264,2265],{"class":218},"Services: []",[197,2267,2138],{"class":214},[197,2269,229],{"class":218},[197,2271,2272],{"class":214},"Service",[197,2274,2067],{"class":218},[197,2276,2277,2280,2283],{"class":199,"line":207},[197,2278,2279],{"class":218},"    application.",[197,2281,2282],{"class":214},"NewService",[197,2284,2285],{"class":218},"(app),\n",[197,2287,2288],{"class":199,"line":244},[197,2289,464],{"class":218},[14,2291,2292],{},"看起来只是换了个名字，其实语义变了：",[1984,2294,2295,2310],{},[80,2296,2297,2298,2301,2302,2305,2306,2309],{},"只有",[72,2299,2300],{},"导出的方法","会被暴露给前端，生命周期方法（",[87,2303,2304],{},"ServiceStartup"," \u002F ",[87,2307,2308],{},"ServiceShutdown","）会被框架自动排除；",[80,2311,2312],{},"服务有了生命周期概念，不用再自己找地方初始化。",[123,2314,2316,2317,2320],{"id":2315},"_3-runtime-包没了能力长在对象上","3. ",[87,2318,2319],{},"runtime"," 包没了，能力长在对象上",[14,2322,2323],{},"这是 v3 最核心的变化。",[14,2325,2326,2328,2329,2332],{},[72,2327,2038],{}," 启动时存一个 ",[87,2330,2331],{},"ctx","，之后干什么都把它传进去。",[188,2334,2336],{"className":190,"code":2335,"language":192,"meta":193,"style":193},"runtime.WindowShow(ctx)\nruntime.EventsEmit(ctx, \"event\", data)\nruntime.MessageDialog(ctx, opts)\n",[87,2337,2338,2349,2365],{"__ignoreMap":193},[197,2339,2340,2343,2346],{"class":199,"line":200},[197,2341,2342],{"class":218},"runtime.",[197,2344,2345],{"class":214},"WindowShow",[197,2347,2348],{"class":218},"(ctx)\n",[197,2350,2351,2353,2356,2359,2362],{"class":199,"line":207},[197,2352,2342],{"class":218},[197,2354,2355],{"class":214},"EventsEmit",[197,2357,2358],{"class":218},"(ctx, ",[197,2360,2361],{"class":256},"\"event\"",[197,2363,2364],{"class":218},", data)\n",[197,2366,2367,2369,2372],{"class":199,"line":244},[197,2368,2342],{"class":218},[197,2370,2371],{"class":214},"MessageDialog",[197,2373,2374],{"class":218},"(ctx, opts)\n",[14,2376,2377,2379,2380,2382],{},[72,2378,2109],{}," 没有 ",[87,2381,2319],{}," 了，能力挂在对象上。",[188,2384,2386],{"className":190,"code":2385,"language":192,"meta":193,"style":193},"app.Window.GetByName(\"main\").Show()\napp.Event.Emit(\"event\", data)\napp.Dialog.OpenFile()\n",[87,2387,2388,2409,2423],{"__ignoreMap":193},[197,2389,2390,2393,2396,2398,2401,2404,2407],{"class":199,"line":200},[197,2391,2392],{"class":218},"app.Window.",[197,2394,2395],{"class":214},"GetByName",[197,2397,219],{"class":218},[197,2399,2400],{"class":256},"\"main\"",[197,2402,2403],{"class":218},").",[197,2405,2406],{"class":214},"Show",[197,2408,2198],{"class":218},[197,2410,2411,2414,2417,2419,2421],{"class":199,"line":207},[197,2412,2413],{"class":218},"app.Event.",[197,2415,2416],{"class":214},"Emit",[197,2418,219],{"class":218},[197,2420,2361],{"class":256},[197,2422,2364],{"class":218},[197,2424,2425,2428,2431],{"class":199,"line":244},[197,2426,2427],{"class":218},"app.Dialog.",[197,2429,2430],{"class":214},"OpenFile",[197,2432,2198],{"class":218},[14,2434,2435,2436,2438,2439,2441,2442,484],{},"所以 ",[87,2437,2064],{}," 结构体里那个 ",[87,2440,2331],{}," 字段，换成了 ",[87,2443,2444],{},"*application.App",[188,2446,2448],{"className":190,"code":2447,"language":192,"meta":193,"style":193},"type App struct {\n    app *application.App   \u002F\u002F v2 这里是 ctx context.Context\n    ...\n}\n",[87,2449,2450,2463,2479,2484],{"__ignoreMap":193},[197,2451,2452,2455,2458,2461],{"class":199,"line":200},[197,2453,2454],{"class":210},"type",[197,2456,2457],{"class":214}," App",[197,2459,2460],{"class":210}," struct",[197,2462,241],{"class":218},[197,2464,2465,2468,2470,2472,2474,2476],{"class":199,"line":207},[197,2466,2467],{"class":218},"    app ",[197,2469,1057],{"class":210},[197,2471,2138],{"class":214},[197,2473,229],{"class":218},[197,2475,2064],{"class":214},[197,2477,2478],{"class":203},"   \u002F\u002F v2 这里是 ctx context.Context\n",[197,2480,2481],{"class":199,"line":244},[197,2482,2483],{"class":210},"    ...\n",[197,2485,2486],{"class":199,"line":263},[197,2487,464],{"class":218},[14,2489,2490,2493,2494,2497],{},[72,2491,2492],{},"好处："," 依赖变显式了，能 mock，能分层。\n",[72,2495,2496],{},"代价："," 你得自己想清楚\"谁先创建、谁后创建\"。",[123,2499,2501],{"id":2500},"_4-日志从自定义接口-直接用标准库","4. 日志：从自定义接口 → 直接用标准库",[14,2503,2504,2506],{},[72,2505,2038],{}," 自定义 logger 接口，项目为此写了适配器。",[14,2508,2509],{},[72,2510,2109],{},[188,2512,2514],{"className":190,"code":2513,"language":192,"meta":193,"style":193},"Logger: slog.Default(),\n",[87,2515,2516],{"__ignoreMap":193},[197,2517,2518,2521,2524],{"class":199,"line":200},[197,2519,2520],{"class":218},"Logger: slog.",[197,2522,2523],{"class":214},"Default",[197,2525,2526],{"class":218},"(),\n",[14,2528,2529],{},"适配器删掉，代码变少。",[14,2531,2532,2535,2536,2001],{},[72,2533,2534],{},"但这里埋了一个大坑","——后面第五部分会讲，production 构建下这个默认 logger ",[72,2537,2538],{},"是往黑洞里写的",[123,2540,2542],{"id":2541},"_5-构建从-cli-直调-taskfile-驱动","5. 构建：从 CLI 直调 → Taskfile 驱动",[14,2544,2545,2217,2547,2550],{},[72,2546,2038],{},[87,2548,2549],{},"wails build"," 一条命令，参数直接传。",[14,2552,2553,2217,2555,2558,2559,2562],{},[72,2554,2109],{},[87,2556,2557],{},"wails3 build"," 只是入口，真正的编译命令在 ",[87,2560,2561],{},"build\u002F\u003C平台>\u002FTaskfile.yml"," 里。",[14,2564,2565,2567,2568,2217,2570,2573],{},[72,2566,2492],{}," 构建逻辑可读可改。比如给 macOS 加私有 API 参数，改 Taskfile 就行。\n",[72,2569,2496],{},[87,2571,2572],{},"-ldflags"," 不再转发（版本号注入要靠这个），得自己接一条链。",[123,2575,2576],{"id":2576},"一张表看完",[1133,2578,2579,2594],{},[1136,2580,2581],{},[1139,2582,2583,2585,2588,2591],{},[1142,2584,1598],{},[1142,2586,2587],{},"v2 怎么写",[1142,2589,2590],{},"v3 怎么写",[1142,2592,2593],{},"影响",[1152,2595,2596,2617,2635,2657,2672,2690,2704,2721,2739,2757],{},[1139,2597,2598,2601,2606,2614],{},[1157,2599,2600],{},"启动",[1157,2602,2603],{},[87,2604,2605],{},"wails.Run(&options.App{})",[1157,2607,2608,2229,2610,2229,2612],{},[87,2609,2133],{},[87,2611,2173],{},[87,2613,2052],{},[1157,2615,2616],{},"得想清楚顺序",[1139,2618,2619,2622,2627,2632],{},[1157,2620,2621],{},"绑定",[1157,2623,2624],{},[87,2625,2626],{},"Bind: []interface{}{}",[1157,2628,2629],{},[87,2630,2631],{},"Services: []application.Service{}",[1157,2633,2634],{},"语义变了",[1139,2636,2637,2640,2645,2650],{},[1157,2638,2639],{},"调框架",[1157,2641,2642],{},[87,2643,2644],{},"runtime.Xxx(ctx, ...)",[1157,2646,2647],{},[87,2648,2649],{},"app.Xxx()",[1157,2651,2652,2654,2655],{},[87,2653,2064],{}," 不再要 ",[87,2656,2331],{},[1139,2658,2659,2662,2665,2669],{},[1157,2660,2661],{},"日志",[1157,2663,2664],{},"自定义接口 + 适配器",[1157,2666,2667],{},[87,2668,1975],{},[1157,2670,2671],{},"适配器删掉",[1139,2673,2674,2677,2682,2687],{},[1157,2675,2676],{},"错误",[1157,2678,2679],{},[87,2680,2681],{},"ErrorFormatter func(error) any",[1157,2683,2684],{},[87,2685,2686],{},"MarshalError func(error) []byte",[1157,2688,2689],{},"前端不用改",[1139,2691,2692,2695,2698,2701],{},[1157,2693,2694],{},"托盘",[1157,2696,2697],{},"第三方库，macOS 不行",[1157,2699,2700],{},"内置，三平台都行",[1157,2702,2703],{},"删依赖、删 no-op",[1139,2705,2706,2709,2713,2718],{},[1157,2707,2708],{},"构建",[1157,2710,2711],{},[87,2712,2549],{},[1157,2714,2715,2717],{},[87,2716,2557],{}," + Taskfile",[1157,2719,2720],{},"参数不直传了",[1139,2722,2723,2726,2731,2736],{},[1157,2724,2725],{},"配置",[1157,2727,2728],{},[87,2729,2730],{},"wails.json",[1157,2732,2733],{},[87,2734,2735],{},"build\u002Fconfig.yml",[1157,2737,2738],{},"文件名和格式都变了",[1139,2740,2741,2744,2749,2754],{},[1157,2742,2743],{},"单实例",[1157,2745,2746],{},[87,2747,2748],{},"UniqueId",[1157,2750,2751],{},[87,2752,2753],{},"UniqueID",[1157,2755,2756],{},"大小写变了",[1139,2758,2759,2762,2767,2772],{},[1157,2760,2761],{},"绑定产物",[1157,2763,2764],{},[87,2765,2766],{},"wailsjs\u002Fgo\u002Fmain\u002FApp",[1157,2768,2769],{},[87,2770,2771],{},"frontend\u002Fbindings\u002F\u003C模块>\u002F",[1157,2773,2774],{},"路径变了",[1667,2776],{},[64,2778,2780],{"id":2779},"四怎么升级","四、怎么升级",[14,2782,2783,2784,2001],{},"我分了 8 个阶段，每个阶段有明确的\"出口\"——",[72,2785,2786],{},"过了这个出口才能进下一步",[14,2788,2789],{},"关键纪律只有一条：",[11,2791,2792],{},[14,2793,2794],{},[72,2795,2796],{},"\"编译通过\"和\"实跑通过\"是两件事，不要合并。",[14,2798,2799],{},"因为 API 改名这种问题编译时就会报；但\"行为变了\"编译时完全看不出来。",[123,2801,2803],{"id":2802},"阶段-0准备别急着改代码","阶段 0：准备（别急着改代码）",[188,2805,2809],{"className":2806,"code":2807,"language":2808,"meta":193,"style":193},"language-bash shiki shiki-themes github-dark github-light","go install github.com\u002Fwailsapp\u002Fwails\u002Fv3\u002Fcmd\u002Fwails3@v3.0.0-beta.23\nwails3 version   # 必须是 beta.23，和 go.mod 对齐\n","bash",[87,2810,2811,2821],{"__ignoreMap":193},[197,2812,2813,2815,2818],{"class":199,"line":200},[197,2814,192],{"class":214},[197,2816,2817],{"class":256}," install",[197,2819,2820],{"class":256}," github.com\u002Fwailsapp\u002Fwails\u002Fv3\u002Fcmd\u002Fwails3@v3.0.0-beta.23\n",[197,2822,2823,2826,2829],{"class":199,"line":207},[197,2824,2825],{"class":214},"wails3",[197,2827,2828],{"class":256}," version",[197,2830,2831],{"class":203},"   # 必须是 beta.23，和 go.mod 对齐\n",[14,2833,2834,2835,2001],{},"然后做一件很重要的事：",[72,2836,2837],{},"开个新分支，先写个最小 demo 跑通",[14,2839,2840,2841,2844],{},"我用 ",[87,2842,2843],{},"wails3 init"," 生成了一个空项目，把几个不确定的点都试了一遍：多窗口怎么建、托盘怎么加、关闭窗口怎么拦、单实例怎么配。",[14,2846,2847],{},[72,2848,2849],{},"这一步花了两小时，省了后面两天。",[123,2851,2853],{"id":2852},"阶段-1换依赖和配置","阶段 1：换依赖和配置",[1984,2855,2856,2867,2870,2885],{},[80,2857,2858,484,2860,2229,2863,2866],{},[87,2859,1993],{},[87,2861,2862],{},"wails\u002Fv2",[87,2864,2865],{},"wails\u002Fv3","，版本钉死",[80,2868,2869],{},"删掉第三方的托盘库",[80,2871,2872,2229,2874,2876,2877,2880,2881,2884],{},[87,2873,2730],{},[87,2875,2735],{},"（注意版本字段从 ",[87,2878,2879],{},"productVersion"," 改名成 ",[87,2882,2883],{},"version","，值是带引号的 YAML）",[80,2886,2887,2888],{},"加 ",[87,2889,2890],{},"Taskfile.yml",[123,2892,2894],{"id":2893},"阶段-2改-go-代码到这里先只求编译过","阶段 2：改 Go 代码（到这里先只求编译过）",[14,2896,2897],{},"按这个顺序改，因为依赖是从下往上的：",[77,2899,2900,2905,2924,2934,2943,2958,2971,2980],{},[80,2901,2902,2904],{},[87,2903,1653],{},"：启动改三步",[80,2906,2907,484,2909,2912,2913,2916,2917,2920,2921],{},[87,2908,1653],{},[87,2910,2911],{},"Logger"," 换成 ",[87,2914,2915],{},"slog.Default()","，",[87,2918,2919],{},"ErrorFormatter"," 换 ",[87,2922,2923],{},"MarshalError",[80,2925,2926,2928,2929,2931,2932],{},[87,2927,1657],{},"：删 ",[87,2930,2331],{}," 字段，改持 ",[87,2933,2444],{},[80,2935,2936,2938,2939,2942],{},[87,2937,1657],{},"：所有 ",[87,2940,2941],{},"runtime.*"," 换成对象方法",[80,2944,2945,484,2948,2951,2952,2954,2955],{},[87,2946,2947],{},"internal\u002Fapperr",[87,2949,2950],{},"Format"," 改 ",[87,2953,2923],{},"，返回 ",[87,2956,2957],{},"[]byte",[80,2959,2960,2963,2964,2967,2968],{},[87,2961,2962],{},"internal\u002Fdialog","：改成持有 ",[87,2965,2966],{},"app","，调 ",[87,2969,2970],{},"app.Dialog.*",[80,2972,2973,2976,2977],{},[87,2974,2975],{},"internal\u002Ftray","：换成内置托盘，",[72,2978,2979],{},"删掉 macOS 的 no-op 分支",[80,2981,2982],{},"删掉日志适配器文件",[14,2984,2985,2986,2989],{},"出口：",[87,2987,2988],{},"go build .\u002F..."," 通过。",[123,2991,2993],{"id":2992},"阶段-3改前端","阶段 3：改前端",[77,2995,2996,3006,3013,3026,3032],{},[80,2997,2998,2999,3002,3003],{},"跑 ",[87,3000,3001],{},"wails3 generate bindings","，生成 ",[87,3004,3005],{},"frontend\u002Fbindings\u002F",[80,3007,3008,3009,3012],{},"删掉旧的 ",[87,3010,3011],{},"frontend\u002Fwailsjs\u002F"," 目录",[80,3014,3015,3018,3019,3022,3023],{},[87,3016,3017],{},"lib\u002Fevent.ts","：事件订阅改成 ",[87,3020,3021],{},"Events.On","，载荷取 ",[87,3024,3025],{},"ev.data",[80,3027,3028,3031],{},[87,3029,3030],{},"lib\u002Flog.ts","：日志出口改成调 log 服务",[80,3033,3034],{},"所有 import 绑定路径的地方改路径",[14,3036,2985,3037,3040,3041,3044],{},[87,3038,3039],{},"vue-tsc --noEmit"," + ESLint + ",[87,3042,3043],{},"npm run build"," 全过。",[14,3046,3047,3050],{},[72,3048,3049],{},"注意这一步比想象的大。"," 我原以为只改 4 个文件，实际有 5 个文件 import 了旧路径，还有组件里调窗口 API 的地方因为同步变异步也要改。",[123,3052,3054],{"id":3053},"阶段-4改护栏","阶段 4：改护栏",[14,3056,3057],{},"如果你的项目有静态检查（我有一堆），这一步不能漏。",[14,3059,3060],{},"因为护栏是靠解析源码做断言的，框架一换，它们解析的目标全变了：",[1984,3062,3063,3073],{},[80,3064,3065,3066,3069,3070],{},"原来找 ",[87,3067,3068],{},"options.App","，现在要找 ",[87,3071,3072],{},"application.Options",[80,3074,3075,3076,3079,3080],{},"原来看 ",[87,3077,3078],{},"wailsjs\u002F","，现在要看 ",[87,3081,3005],{},[14,3083,3084,3087],{},[72,3085,3086],{},"漏改的症状是护栏误报","，不是漏报——所以还算安全。但如果护栏写成\"找不到就跳过\"，那就变成永远绿了。",[123,3089,3091],{"id":3090},"阶段-5改构建和发布链","阶段 5：改构建和发布链",[77,3093,3094,3102,3113,3119],{},[80,3095,3096,3099,3100],{},[87,3097,3098],{},"Makefile"," 切 ",[87,3101,2825],{},[80,3103,3104,484,3107],{},[87,3105,3106],{},"scripts\u002Fbuild.sh",[72,3108,3109,3110],{},"新增一步 ",[87,3111,3112],{},"wails3 task common:update:build-assets",[80,3114,3115,3116,3118],{},"版本注入改走 Taskfile 变量（",[87,3117,2572],{}," 不转发了）",[80,3120,3121,3122,3124],{},"CI 装 wails3 CLI，版本从 ",[87,3123,1993],{}," 读",[14,3126,3127,3128,3131,3132],{},"第 2 条特别容易漏：v2 时代 ",[87,3129,3130],{},"Info.plist"," 是自动渲染的，v3 必须显式跑一条命令。",[72,3133,3134],{},"漏了的结果是：应用里显示新版本号，系统里显示旧版本号。",[123,3136,3138],{"id":3137},"阶段-6-7接新能力改文档","阶段 6-7：接新能力、改文档",[14,3140,3141],{},"到这里应用已经能跑了，剩下的是把 MCP 接上（如果你要用）、更新 README 和架构文档。",[123,3143,3144],{"id":3144},"我的实际节奏",[1133,3146,3147,3160],{},[1136,3148,3149],{},[1139,3150,3151,3154,3157],{},[1142,3152,3153],{},"阶段",[1142,3155,3156],{},"干了什么",[1142,3158,3159],{},"出口",[1152,3161,3162,3173,3183,3196,3210,3224,3238,3249],{},[1139,3163,3164,3167,3170],{},[1157,3165,3166],{},"0",[1157,3168,3169],{},"装 CLI、写 demo、验证 6 个不确定点",[1157,3171,3172],{},"demo 能跑",[1139,3174,3175,3177,3180],{},[1157,3176,1309],{},[1157,3178,3179],{},"依赖 + 配置骨架",[1157,3181,3182],{},"目录结构就位",[1139,3184,3185,3188,3191],{},[1157,3186,3187],{},"2",[1157,3189,3190],{},"Go 代码迁移",[1157,3192,3193,3195],{},[87,3194,2010],{}," 通过",[1139,3197,3198,3201,3204],{},[1157,3199,3200],{},"3",[1157,3202,3203],{},"前端迁移",[1157,3205,3206,3209],{},[87,3207,3208],{},"vue-tsc"," + 构建通过",[1139,3211,3212,3215,3218],{},[1157,3213,3214],{},"4",[1157,3216,3217],{},"护栏同步",[1157,3219,3220,3223],{},[87,3221,3222],{},"make lint"," 全绿 + 破坏性验证",[1139,3225,3226,3229,3232],{},[1157,3227,3228],{},"5",[1157,3230,3231],{},"构建链",[1157,3233,3234,3237],{},[87,3235,3236],{},"make package"," 出安装包",[1139,3239,3240,3243,3246],{},[1157,3241,3242],{},"6",[1157,3244,3245],{},"MCP 接线",[1157,3247,3248],{},"Agent 能操作运行中的应用",[1139,3250,3251,3254,3257],{},[1157,3252,3253],{},"7",[1157,3255,3256],{},"文档",[1157,3258,3259],{},"README \u002F 架构图更新",[14,3261,3262],{},[72,3263,3264],{},"其中阶段 0 和阶段 4 是最容易被跳过、也最值得做的。",[14,3266,3267],{},"阶段 0 让你在干净环境里踩坑，不污染主分支。阶段 4 保证你那些静态检查在新框架下还真的在检查东西。",[1667,3269],{},[64,3271,3273],{"id":3272},"五升级后踩的坑","五、升级后踩的坑",[14,3275,3276],{},"下面 10 个，按\"害我时间\"排序。共同点是：",[11,3278,3279],{},[14,3280,3281],{},[72,3282,3283],{},"不报错、不崩溃、日志干净。只是某个功能默默没了。",[123,3285,3287],{"id":3286},"坑-1production-构建下日志直接扔进黑洞最狠","坑 1：production 构建下，日志直接扔进黑洞（最狠）",[14,3289,3290],{},"这条我放在第一个，因为它能让你所有的排查手段全部失效。",[14,3292,3293,3294,3297],{},"v3 的默认 logger，在 ",[87,3295,3296],{},"production"," 构建标签下是这样的：",[188,3299,3301],{"className":190,"code":3300,"language":192,"meta":193,"style":193},"\u002F\u002F pkg\u002Fapplication\u002Flogger_prod.go\nfunc DefaultLogger(level slog.Leveler) *slog.Logger {\n    return slog.New(slog.NewTextHandler(io.Discard, nil))\n}\n",[87,3302,3303,3308,3341,3365],{"__ignoreMap":193},[197,3304,3305],{"class":199,"line":200},[197,3306,3307],{"class":203},"\u002F\u002F pkg\u002Fapplication\u002Flogger_prod.go\n",[197,3309,3310,3312,3315,3317,3320,3323,3325,3328,3330,3332,3335,3337,3339],{"class":199,"line":207},[197,3311,211],{"class":210},[197,3313,3314],{"class":214}," DefaultLogger",[197,3316,219],{"class":218},[197,3318,3319],{"class":222},"level",[197,3321,3322],{"class":214}," slog",[197,3324,229],{"class":218},[197,3326,3327],{"class":214},"Leveler",[197,3329,235],{"class":218},[197,3331,1057],{"class":210},[197,3333,3334],{"class":214},"slog",[197,3336,229],{"class":218},[197,3338,2911],{"class":214},[197,3340,241],{"class":218},[197,3342,3343,3345,3348,3350,3353,3356,3359,3362],{"class":199,"line":244},[197,3344,450],{"class":210},[197,3346,3347],{"class":218}," slog.",[197,3349,2133],{"class":214},[197,3351,3352],{"class":218},"(slog.",[197,3354,3355],{"class":214},"NewTextHandler",[197,3357,3358],{"class":218},"(io.Discard, ",[197,3360,3361],{"class":281},"nil",[197,3363,3364],{"class":218},"))\n",[197,3366,3367],{"class":199,"line":263},[197,3368,464],{"class":218},[14,3370,3371,3374,3375,2001],{},[87,3372,3373],{},"io.Discard"," 就是\"扔掉\"。不是\"写到 stdout 你看不到\"，是",[72,3376,3377],{},"连 stdout 都没有",[14,3379,3380],{},"所以：",[1984,3382,3383,3386,3389],{},[80,3384,3385],{},"你写的业务日志？如果没显式配 logger，全丢。",[80,3387,3388],{},"框架自己的错误日志？全丢。",[80,3390,3391,3392,3395],{},"前端日志？如果注册 log 服务时用了 ",[87,3393,3394],{},"log.New()","（它走默认 logger），也全丢。",[14,3397,3398],{},[72,3399,3400],{},"最阴的是：日志接线看起来是接了的，代码也编译过了，但生产包里的日志是空的。",[14,3402,3403],{},"我的教训是：日志这件事，护栏不能只检查\"有没有注册\"，得检查\"注册时有没有显式传 logger\"。",[123,3405,3407],{"id":3406},"坑-2关窗拦截用错了-api-就静默失效","坑 2：关窗拦截，用错了 API 就静默失效",[14,3409,3410],{},"需求：点关闭按钮 → 隐藏到托盘，不退出。",[14,3412,3413],{},"我第一版这么写的：",[188,3415,3417],{"className":190,"code":3416,"language":192,"meta":193,"style":193},"window.OnWindowEvent(events.Common.WindowClosing, func(e *application.WindowEvent) {\n    window.Hide()\n    e.Cancel()   \u002F\u002F 想拦下\"销毁窗口\"\n})\n",[87,3418,3419,3448,3458,3472],{"__ignoreMap":193},[197,3420,3421,3424,3427,3430,3432,3434,3437,3439,3441,3443,3446],{"class":199,"line":200},[197,3422,3423],{"class":218},"window.",[197,3425,3426],{"class":214},"OnWindowEvent",[197,3428,3429],{"class":218},"(events.Common.WindowClosing, ",[197,3431,211],{"class":210},[197,3433,219],{"class":218},[197,3435,3436],{"class":222},"e",[197,3438,519],{"class":210},[197,3440,2138],{"class":214},[197,3442,229],{"class":218},[197,3444,3445],{"class":214},"WindowEvent",[197,3447,346],{"class":218},[197,3449,3450,3453,3456],{"class":199,"line":207},[197,3451,3452],{"class":218},"    window.",[197,3454,3455],{"class":214},"Hide",[197,3457,2198],{"class":218},[197,3459,3460,3463,3466,3469],{"class":199,"line":244},[197,3461,3462],{"class":218},"    e.",[197,3464,3465],{"class":214},"Cancel",[197,3467,3468],{"class":218},"()   ",[197,3470,3471],{"class":203},"\u002F\u002F 想拦下\"销毁窗口\"\n",[197,3473,3474],{"class":199,"line":263},[197,3475,2104],{"class":218},[14,3477,3478],{},[72,3479,3480],{},"窗口照常销毁。",[14,3482,3483],{},"原因：v3 里有 hook 和 listener 两套机制。",[1133,3485,3486,3501],{},[1136,3487,3488],{},[1139,3489,3490,3492,3497],{},[1142,3491],{},[1142,3493,3494],{},[87,3495,3496],{},"RegisterHook",[1142,3498,3499],{},[87,3500,3426],{},[1152,3502,3503,3514,3528],{},[1139,3504,3505,3508,3511],{},[1157,3506,3507],{},"怎么跑",[1157,3509,3510],{},"同步，按顺序",[1157,3512,3513],{},"每个开个 goroutine 各跑各的",[1139,3515,3516,3522,3525],{},[1157,3517,3518,3521],{},[87,3519,3520],{},"Cancel()"," 有用吗",[1157,3523,3524],{},"有用，立刻拦下",[1157,3526,3527],{},"没用，抢不过框架自己的 listener",[1139,3529,3530,3533,3536],{},[1157,3531,3532],{},"适合干什么",[1157,3534,3535],{},"拦截、改行为",[1157,3537,3538],{},"旁观、听消息",[14,3540,3541],{},"框架自己在关窗事件上挂了个\"销毁窗口\"的 listener，你用 listener 永远抢不过它。",[14,3543,3544],{},"正确写法：",[188,3546,3548],{"className":190,"code":3547,"language":192,"meta":193,"style":193},"window.RegisterHook(events.Common.WindowClosing, func(e *application.WindowEvent) {\n    if trayRunning() {\n        window.Hide()\n        e.Cancel()\n        return\n    }\n    e.Cancel()\n    wailsApp.Quit()   \u002F\u002F 托盘没起来，就真退出\n})\n",[87,3549,3550,3574,3583,3592,3601,3606,3610,3618,3631],{"__ignoreMap":193},[197,3551,3552,3554,3556,3558,3560,3562,3564,3566,3568,3570,3572],{"class":199,"line":200},[197,3553,3423],{"class":218},[197,3555,3496],{"class":214},[197,3557,3429],{"class":218},[197,3559,211],{"class":210},[197,3561,219],{"class":218},[197,3563,3436],{"class":222},[197,3565,519],{"class":210},[197,3567,2138],{"class":214},[197,3569,229],{"class":218},[197,3571,3445],{"class":214},[197,3573,346],{"class":218},[197,3575,3576,3578,3581],{"class":199,"line":207},[197,3577,266],{"class":210},[197,3579,3580],{"class":214}," trayRunning",[197,3582,1031],{"class":218},[197,3584,3585,3588,3590],{"class":199,"line":244},[197,3586,3587],{"class":218},"        window.",[197,3589,3455],{"class":214},[197,3591,2198],{"class":218},[197,3593,3594,3597,3599],{"class":199,"line":263},[197,3595,3596],{"class":218},"        e.",[197,3598,3465],{"class":214},[197,3600,2198],{"class":218},[197,3602,3603],{"class":199,"line":275},[197,3604,3605],{"class":210},"        return\n",[197,3607,3608],{"class":199,"line":285},[197,3609,288],{"class":218},[197,3611,3612,3614,3616],{"class":199,"line":291},[197,3613,3462],{"class":218},[197,3615,3465],{"class":214},[197,3617,2198],{"class":218},[197,3619,3620,3623,3626,3628],{"class":199,"line":313},[197,3621,3622],{"class":218},"    wailsApp.",[197,3624,3625],{"class":214},"Quit",[197,3627,3468],{"class":218},[197,3629,3630],{"class":203},"\u002F\u002F 托盘没起来，就真退出\n",[197,3632,3633],{"class":199,"line":349},[197,3634,2104],{"class":218},[14,3636,3637,3638,3641,3642],{},"注意那个 ",[87,3639,3640],{},"trayRunning()","——",[72,3643,3644,3645,2001],{},"必须是运行期状态，不能写 ",[87,3646,3647],{},"runtime.GOOS == \"darwin\"",[14,3649,3650,3651,3654,3655],{},"因为托盘是在服务启动时创建的，可能失败；而关窗拦截是在 ",[87,3652,3653],{},"main()"," 里注册的，比服务启动还早。两者差一个阶段，",[72,3656,3657],{},"只有跑起来才知道托盘到底起没起来。",[123,3659,3661],{"id":3660},"坑-3前端日志的通道被整个拿掉了","坑 3：前端日志的通道被整个拿掉了",[14,3663,3664,3665,3668],{},"v2 时代，前端调 ",[87,3666,3667],{},"runtime.LogInfo()","，消息自动进后端日志文件。免费的。",[14,3670,3671,3674],{},[72,3672,3673],{},"v3 把这条通道删了。"," 前端运行时里根本没有日志函数了。",[14,3676,3677,3678,484],{},"替代方案是官方 log 服务，",[72,3679,3680],{},"必须手动注册",[188,3682,3684],{"className":190,"code":3683,"language":192,"meta":193,"style":193},"Services: []application.Service{\n    application.NewService(logservice.NewWithConfig(&logservice.Config{\n        Logger:   frontendLogger,   \u002F\u002F 必须显式传！\n        LogLevel: logLevel,\n    })),\n}\n",[87,3685,3686,3698,3724,3732,3737,3742],{"__ignoreMap":193},[197,3687,3688,3690,3692,3694,3696],{"class":199,"line":200},[197,3689,2265],{"class":218},[197,3691,2138],{"class":214},[197,3693,229],{"class":218},[197,3695,2272],{"class":214},[197,3697,2067],{"class":218},[197,3699,3700,3702,3704,3707,3710,3712,3714,3717,3719,3722],{"class":199,"line":207},[197,3701,2279],{"class":218},[197,3703,2282],{"class":214},[197,3705,3706],{"class":218},"(logservice.",[197,3708,3709],{"class":214},"NewWithConfig",[197,3711,219],{"class":218},[197,3713,842],{"class":210},[197,3715,3716],{"class":214},"logservice",[197,3718,229],{"class":218},[197,3720,3721],{"class":214},"Config",[197,3723,2067],{"class":218},[197,3725,3726,3729],{"class":199,"line":244},[197,3727,3728],{"class":218},"        Logger:   frontendLogger,   ",[197,3730,3731],{"class":203},"\u002F\u002F 必须显式传！\n",[197,3733,3734],{"class":199,"line":263},[197,3735,3736],{"class":218},"        LogLevel: logLevel,\n",[197,3738,3739],{"class":199,"line":275},[197,3740,3741],{"class":218},"    })),\n",[197,3743,3744],{"class":199,"line":285},[197,3745,464],{"class":218},[14,3747,3748,3749,3752,3753,3756],{},"不注册的后果：前端所有日志只剩 ",[87,3750,3751],{},"console","。而打包后的应用",[72,3754,3755],{},"没有开发者工具","——等于没记。",[14,3758,3759],{},"用户说\"点了没反应\"，你打开日志文件，干干净净。",[123,3761,3763],{"id":3762},"坑-4macos-托盘传空图标会直接-panic","坑 4：macOS 托盘传空图标会直接 panic",[14,3765,3766],{},"不是返回 error，是 panic。",[188,3768,3770],{"className":190,"code":3769,"language":192,"meta":193,"style":193},"\u002F\u002F 必须有兜底\nif len(icon) == 0 {\n    icon = placeholderIcon()\n}\n",[87,3771,3772,3777,3793,3805],{"__ignoreMap":193},[197,3773,3774],{"class":199,"line":200},[197,3775,3776],{"class":203},"\u002F\u002F 必须有兜底\n",[197,3778,3779,3781,3783,3786,3789,3791],{"class":199,"line":207},[197,3780,1259],{"class":210},[197,3782,901],{"class":214},[197,3784,3785],{"class":218},"(icon) ",[197,3787,3788],{"class":210},"==",[197,3790,610],{"class":281},[197,3792,241],{"class":218},[197,3794,3795,3798,3800,3803],{"class":199,"line":244},[197,3796,3797],{"class":218},"    icon ",[197,3799,781],{"class":210},[197,3801,3802],{"class":214}," placeholderIcon",[197,3804,2198],{"class":218},[197,3806,3807],{"class":199,"line":263},[197,3808,464],{"class":218},[14,3810,3811,3812,3815],{},"这个坑有个好处：",[72,3813,3814],{},"它崩得很响","，启动就崩，你立刻能发现。所以我没给它加护栏——护栏的预算要留给\"崩不了但悄悄坏掉\"的问题。",[123,3817,3819],{"id":3818},"坑-5对话框没有同步返回值了","坑 5：对话框没有同步返回值了",[14,3821,3822],{},"v2 的确认框是同步的：",[188,3824,3826],{"className":190,"code":3825,"language":192,"meta":193,"style":193},"if dialog.Confirm(\"要删除吗？\") { ... }\n",[87,3827,3828],{"__ignoreMap":193},[197,3829,3830,3832,3835,3838,3840,3843,3846,3848],{"class":199,"line":200},[197,3831,1259],{"class":210},[197,3833,3834],{"class":218}," dialog.",[197,3836,3837],{"class":214},"Confirm",[197,3839,219],{"class":218},[197,3841,3842],{"class":256},"\"要删除吗？\"",[197,3844,3845],{"class":218},") { ",[197,3847,2148],{"class":210},[197,3849,3850],{"class":218}," }\n",[14,3852,3853],{},"v3 没有同步返回，是回调式的。想保住原来的函数签名，只能自己用 channel 桥接：",[188,3855,3857],{"className":190,"code":3856,"language":192,"meta":193,"style":193},"func Confirm(msg string) bool {\n    done := make(chan bool, 1)\n    d := app.Dialog.Question()\n    d.SetTitle(msg)\n    d.AddButton(\"取消\").OnClick(func() { done \u003C- false })\n    d.AddButton(\"确定\").OnClick(func() { done \u003C- true })\n    d.Show()\n    return \u003C-done\n}\n",[87,3858,3859,3880,3904,3919,3930,3962,3990,3998,4008],{"__ignoreMap":193},[197,3860,3861,3863,3866,3868,3871,3874,3876,3878],{"class":199,"line":200},[197,3862,211],{"class":210},[197,3864,3865],{"class":214}," Confirm",[197,3867,219],{"class":218},[197,3869,3870],{"class":222},"msg",[197,3872,3873],{"class":210}," string",[197,3875,235],{"class":218},[197,3877,238],{"class":210},[197,3879,241],{"class":218},[197,3881,3882,3885,3887,3890,3892,3895,3898,3900,3902],{"class":199,"line":207},[197,3883,3884],{"class":218},"    done ",[197,3886,250],{"class":210},[197,3888,3889],{"class":214}," make",[197,3891,219],{"class":218},[197,3893,3894],{"class":210},"chan",[197,3896,3897],{"class":210}," bool",[197,3899,513],{"class":218},[197,3901,1309],{"class":281},[197,3903,310],{"class":218},[197,3905,3906,3909,3911,3914,3917],{"class":199,"line":244},[197,3907,3908],{"class":218},"    d ",[197,3910,250],{"class":210},[197,3912,3913],{"class":218}," app.Dialog.",[197,3915,3916],{"class":214},"Question",[197,3918,2198],{"class":218},[197,3920,3921,3924,3927],{"class":199,"line":263},[197,3922,3923],{"class":218},"    d.",[197,3925,3926],{"class":214},"SetTitle",[197,3928,3929],{"class":218},"(msg)\n",[197,3931,3932,3934,3937,3939,3942,3944,3947,3949,3951,3954,3957,3959],{"class":199,"line":275},[197,3933,3923],{"class":218},[197,3935,3936],{"class":214},"AddButton",[197,3938,219],{"class":218},[197,3940,3941],{"class":256},"\"取消\"",[197,3943,2403],{"class":218},[197,3945,3946],{"class":214},"OnClick",[197,3948,219],{"class":218},[197,3950,211],{"class":210},[197,3952,3953],{"class":218},"() { done ",[197,3955,3956],{"class":210},"\u003C-",[197,3958,354],{"class":281},[197,3960,3961],{"class":218}," })\n",[197,3963,3964,3966,3968,3970,3973,3975,3977,3979,3981,3983,3985,3988],{"class":199,"line":285},[197,3965,3923],{"class":218},[197,3967,3936],{"class":214},[197,3969,219],{"class":218},[197,3971,3972],{"class":256},"\"确定\"",[197,3974,2403],{"class":218},[197,3976,3946],{"class":214},[197,3978,219],{"class":218},[197,3980,211],{"class":210},[197,3982,3953],{"class":218},[197,3984,3956],{"class":210},[197,3986,3987],{"class":281}," true",[197,3989,3961],{"class":218},[197,3991,3992,3994,3996],{"class":199,"line":291},[197,3993,3923],{"class":218},[197,3995,2406],{"class":214},[197,3997,2198],{"class":218},[197,3999,4000,4002,4005],{"class":199,"line":313},[197,4001,450],{"class":210},[197,4003,4004],{"class":210}," \u003C-",[197,4006,4007],{"class":218},"done\n",[197,4009,4010],{"class":199,"line":349},[197,4011,464],{"class":218},[14,4013,4014],{},[72,4015,4016],{},"这类\"编译过了但语义变了\"的改动，只能靠实跑发现。",[123,4018,4020],{"id":4019},"坑-6绑定调用是数字-id-寻址","坑 6：绑定调用是数字 ID 寻址",[14,4022,4023,4024,4027],{},"v3 生成的绑定，前端调方法是按 ",[72,4025,4026],{},"hash ID"," 调，不是按方法名：",[188,4029,4033],{"className":4030,"code":4031,"language":4032,"meta":193,"style":193},"language-ts shiki shiki-themes github-dark github-light","Call.ByID(1234567890)\n","ts",[87,4034,4035],{"__ignoreMap":193},[197,4036,4037,4040,4043,4045,4048],{"class":199,"line":200},[197,4038,4039],{"class":218},"Call.",[197,4041,4042],{"class":214},"ByID",[197,4044,219],{"class":218},[197,4046,4047],{"class":281},"1234567890",[197,4049,310],{"class":218},[14,4051,4052,4053,4056],{},"方法改名、改包路径，ID 就变了。如果前端没重新生成绑定，",[72,4054,4055],{},"编译期完全看不出来","，运行时才报错。",[14,4058,4059,4060,4063],{},"好消息是这个 hash 是",[72,4061,4062],{},"可复算的","（FNV-1a）。所以我做了件事：护栏里自己复算一遍 ID，和生成产物逐个对账——当时全部命中。",[123,4065,4067,4068,4070,4071],{"id":4066},"坑-7wails3-build-不转发-ldflags","坑 7：",[87,4069,2557],{}," 不转发 ",[87,4072,2572],{},[188,4074,4076],{"className":2806,"code":4075,"language":2808,"meta":193,"style":193},"wails3 build -ldflags \"-X main.Version=1.2.3\"   # 不生效\n",[87,4077,4078],{"__ignoreMap":193},[197,4079,4080,4082,4085,4088,4091],{"class":199,"line":200},[197,4081,2825],{"class":214},[197,4083,4084],{"class":256}," build",[197,4086,4087],{"class":281}," -ldflags",[197,4089,4090],{"class":256}," \"-X main.Version=1.2.3\"",[197,4092,4093],{"class":203},"   # 不生效\n",[14,4095,4096,4097,2305,4100,2305,4103,4106,4107,4109],{},"实测只转发 ",[87,4098,4099],{},"EXTRA_TAGS",[87,4101,4102],{},"GOOS",[87,4104,4105],{},"ARCH"," 这几个，",[87,4108,2572],{}," 被吃掉。",[14,4111,4112],{},"版本注入只能走 Taskfile 变量：",[188,4114,4117],{"className":4115,"code":4116,"language":1919},[1917],"scripts\u002Fbuild.sh → wails3 build VERSION=x → Taskfile 的 VERSION_LDFLAGS → 各平台 -ldflags\n",[87,4118,4116],{"__ignoreMap":193},[14,4120,4121,4124,4125,4128],{},[72,4122,4123],{},"漏了这条链的症状："," 安装包里版本对，应用里版本恒为 ",[87,4126,4127],{},"dev","。因为是两个来源，一个对了一个没对。",[123,4130,4132,4133,4136],{"id":4131},"坑-8拷贝构建脚本时丢了-output-变量","坑 8：拷贝构建脚本时丢了 ",[87,4134,4135],{},"OUTPUT"," 变量",[14,4138,4139,4140,4143,4144,4146],{},"v3 迁移时照着官方模板抄平台 Taskfile，把输出路径写死成 ",[87,4141,4142],{},"BIN_DIR\u002FAPP_NAME","，顺手把 ",[87,4145,4135],{}," 变量丢了。",[14,4148,4149],{},"后果分两种：",[1984,4151,4152,4159],{},[80,4153,4154,4155,4158],{},"macOS 的 universal 构建：两次编译（amd64 \u002F arm64）输出到同一个文件，互相覆盖，",[87,4156,4157],{},"lipo"," 合并时报错。",[80,4160,4161,4162,4165,4166,4168],{},"Linux \u002F Windows：",[72,4163,4164],{},"完全静默","——传了 ",[87,4167,4135],{},"，但产物落在别处，没人发现。",[123,4170,4172],{"id":4171},"坑-9dev-模式页面一片空白","坑 9：dev 模式页面一片空白",[14,4174,4175,4178],{},[87,4176,4177],{},"wails3 dev"," 起来，窗口是白的，控制台没报错。",[14,4180,4181,4182,4185],{},"原因很无聊：前端 dev server 默认绑在 IPv6 的 ",[87,4183,4184],{},"localhost","，而 wails3 的代理强制走 IPv4。两边对不上，页面加载失败。",[14,4187,4188,4191,4192,4195],{},[72,4189,4190],{},"这类问题的排查成本极高","，因为\"白屏\"什么信息都不给你。最后是靠 ",[87,4193,4194],{},"curl"," 分别试 IPv4 和 IPv6 才定位到。",[123,4197,4199],{"id":4198},"坑-10版本号载体换地方了","坑 10：版本号载体换地方了",[14,4201,2038,4202,4204,4205,4207,4208,2217,4210,484],{},[87,4203,2730],{},"。\nv3：",[87,4206,2735],{},"，而且 ",[87,4209,3130],{},[72,4211,4212],{},"必须显式渲染",[188,4214,4216],{"className":2806,"code":4215,"language":2808,"meta":193,"style":193},"wails3 task common:update:build-assets\n",[87,4217,4218],{"__ignoreMap":193},[197,4219,4220,4222,4225],{"class":199,"line":200},[197,4221,2825],{"class":214},[197,4223,4224],{"class":256}," task",[197,4226,4227],{"class":256}," common:update:build-assets\n",[14,4229,4230],{},"漏了这一步，就会出现\"应用内新版本、系统里旧版本\"的双份真相。",[1667,4232],{},[64,4234,4236],{"id":4235},"六怎么用好-v3","六、怎么用好 v3",[14,4238,4239],{},"前面讲的是\"升级怎么不踩坑\"，这里是\"日常怎么写才顺\"。",[123,4241,4243,4244,4246],{"id":4242},"_1-main-不是启动应用是装配应用","1. ",[87,4245,3653],{}," 不是启动应用，是装配应用",[14,4248,4249,4250,4252,4253],{},"v3 把能力对象化之后，",[87,4251,3653],{}," 的职责变了：",[72,4254,4255],{},"按顺序把零件装好，然后交给框架跑。",[14,4257,4258],{},"我把它分成三段：",[188,4260,4262],{"className":190,"code":4261,"language":192,"meta":193,"style":193},"\u002F\u002F 第一段：建应用（服务、资源、日志、错误处理、单实例）\n\u002F\u002F 第二段：建窗口（主窗、宠物窗、强提醒窗）\n\u002F\u002F 第三段：注入依赖 + Run\n",[87,4263,4264,4269,4274],{"__ignoreMap":193},[197,4265,4266],{"class":199,"line":200},[197,4267,4268],{"class":203},"\u002F\u002F 第一段：建应用（服务、资源、日志、错误处理、单实例）\n",[197,4270,4271],{"class":199,"line":207},[197,4272,4273],{"class":203},"\u002F\u002F 第二段：建窗口（主窗、宠物窗、强提醒窗）\n",[197,4275,4276],{"class":199,"line":244},[197,4277,4278],{"class":203},"\u002F\u002F 第三段：注入依赖 + Run\n",[14,4280,4281],{},"关键是第三段的第一句：",[188,4283,4285],{"className":190,"code":4284,"language":192,"meta":193,"style":193},"appSvc.attach(wailsApp)   \u002F\u002F 必须在 Run 之前\nwailsApp.Run()\n",[87,4286,4287,4301],{"__ignoreMap":193},[197,4288,4289,4292,4295,4298],{"class":199,"line":200},[197,4290,4291],{"class":218},"appSvc.",[197,4293,4294],{"class":214},"attach",[197,4296,4297],{"class":218},"(wailsApp)   ",[197,4299,4300],{"class":203},"\u002F\u002F 必须在 Run 之前\n",[197,4302,4303,4305,4307],{"class":199,"line":207},[197,4304,2193],{"class":218},[197,4306,2052],{"class":214},[197,4308,2198],{"class":218},[14,4310,4311,4312,4314,4315,4317],{},"因为 ",[87,4313,2213],{}," 一进去就回调服务的启动方法，那时候 ",[87,4316,2966],{}," 得已经就位。",[123,4319,4321],{"id":4320},"_2-窗口是并列的别做子窗口","2. 窗口是并列的，别做子窗口",[14,4323,4324],{},"一旦你要做\"浮在别的应用上面的东西\"，就别想着\"在主窗里开个子窗口\"。",[14,4326,4327],{},"Morrow 的四个窗口全是平级的：",[1133,4329,4330,4342],{},[1136,4331,4332],{},[1139,4333,4334,4336,4339],{},[1142,4335,1624],{},[1142,4337,4338],{},"关键设置",[1142,4340,4341],{},"为什么必须独立",[1152,4343,4344,4355,4366,4377],{},[1139,4345,4346,4349,4352],{},[1157,4347,4348],{},"主窗",[1157,4350,4351],{},"正常窗口，macOS 保留交通灯",[1157,4353,4354],{},"—",[1139,4356,4357,4360,4363],{},[1157,4358,4359],{},"宠物窗",[1157,4361,4362],{},"透明 + 无边框 + 置顶",[1157,4364,4365],{},"要盖住别的应用，子窗口做不到",[1139,4367,4368,4371,4374],{},[1157,4369,4370],{},"强提醒",[1157,4372,4373],{},"全屏透明遮罩，启动就隐藏",[1157,4375,4376],{},"要铺满屏幕压住一切",[1139,4378,4379,4382,4385],{},[1157,4380,4381],{},"副屏强提醒",[1157,4383,4384],{},"每块屏一个",[1157,4386,4387],{},"同上",[14,4389,4390,4391],{},"多窗口带来的问题是：",[72,4392,4393],{},"同一个前端包，要有不同的界面骨架。",[14,4395,4396],{},"解法是用 hash 路由区分：",[188,4398,4400],{"className":190,"code":4399,"language":192,"meta":193,"style":193},"URL: \"\u002F#\u002Fpet\"   \u002F\u002F 宠物窗口\n",[87,4401,4402],{"__ignoreMap":193},[197,4403,4404,4407,4410],{"class":199,"line":200},[197,4405,4406],{"class":218},"URL: ",[197,4408,4409],{"class":256},"\"\u002F#\u002Fpet\"",[197,4411,4412],{"class":203},"   \u002F\u002F 宠物窗口\n",[14,4414,4415,4416,4419],{},"这条路由在 ",[87,4417,4418],{},"App.vue"," 里标记为\"裸页面\"，跳过侧栏和标题栏。一份代码，三种壳。",[14,4421,4422],{},[72,4423,4424],{},"两个建窗的坑，都属于\"看着能跑\"：",[1984,4426,4427,4447],{},[80,4428,4429,4432,4433,4436,4437,4440,4441,4443,4444,2001],{},[72,4430,4431],{},"坐标有两套。"," 建窗参数里的 ",[87,4434,4435],{},"X\u002FY"," 是\"相对某块屏幕\"的，",[87,4438,4439],{},"SetPosition()"," 用的是\"全局坐标\"。混用之后，多屏用户每次启动宠物都被拽回主屏。我干脆不用建窗的 ",[87,4442,4435],{},"，统一在创建后 ",[87,4445,4446],{},"SetPosition",[80,4448,4449,4455,4456,4459],{},[72,4450,4451,4454],{},[87,4452,4453],{},"Hidden: true"," 要写在建窗参数里","，不是创建完再 ",[87,4457,4458],{},"Hide()","。后者会先闪一下。",[123,4461,4463],{"id":4462},"_3-透明窗口三个设置-一个编译参数","3. 透明窗口：三个设置 + 一个编译参数",[14,4465,4466],{},"这是 macOS 上最容易做错的地方。",[14,4468,4469,4470,4473],{},"以为设一个 ",[87,4471,4472],{},"BackgroundTypeTransparent"," 就行？不行，你会看到宠物周围糊着一块底色。",[14,4475,4476],{},[72,4477,4478],{},"要四个条件同时成立：",[188,4480,4482],{"className":190,"code":4481,"language":192,"meta":193,"style":193},"application.WebviewWindowOptions{\n    Frameless:        true,\n    BackgroundType:   application.BackgroundTypeTransparent,\n    BackgroundColour: application.RGBA{},   \u002F\u002F 零值\n    Mac: application.MacWindow{\n        DisableShadow: true,\n        Backdrop:      application.MacBackdropTransparent,\n    },\n}\n",[87,4483,4484,4495,4505,4510,4528,4542,4551,4556,4561],{"__ignoreMap":193},[197,4485,4486,4488,4490,4493],{"class":199,"line":200},[197,4487,2138],{"class":214},[197,4489,229],{"class":218},[197,4491,4492],{"class":214},"WebviewWindowOptions",[197,4494,2067],{"class":218},[197,4496,4497,4500,4503],{"class":199,"line":207},[197,4498,4499],{"class":218},"    Frameless:        ",[197,4501,4502],{"class":281},"true",[197,4504,2078],{"class":218},[197,4506,4507],{"class":199,"line":244},[197,4508,4509],{"class":218},"    BackgroundType:   application.BackgroundTypeTransparent,\n",[197,4511,4512,4515,4517,4519,4522,4525],{"class":199,"line":263},[197,4513,4514],{"class":218},"    BackgroundColour: ",[197,4516,2138],{"class":214},[197,4518,229],{"class":218},[197,4520,4521],{"class":214},"RGBA",[197,4523,4524],{"class":218},"{},   ",[197,4526,4527],{"class":203},"\u002F\u002F 零值\n",[197,4529,4530,4533,4535,4537,4540],{"class":199,"line":275},[197,4531,4532],{"class":218},"    Mac: ",[197,4534,2138],{"class":214},[197,4536,229],{"class":218},[197,4538,4539],{"class":214},"MacWindow",[197,4541,2067],{"class":218},[197,4543,4544,4547,4549],{"class":199,"line":285},[197,4545,4546],{"class":218},"        DisableShadow: ",[197,4548,4502],{"class":281},[197,4550,2078],{"class":218},[197,4552,4553],{"class":199,"line":291},[197,4554,4555],{"class":218},"        Backdrop:      application.MacBackdropTransparent,\n",[197,4557,4558],{"class":199,"line":313},[197,4559,4560],{"class":218},"    },\n",[197,4562,4563],{"class":199,"line":349},[197,4564,464],{"class":218},[14,4566,4567],{},"第四个在代码外面：",[188,4569,4571],{"className":2806,"code":4570,"language":2808,"meta":193,"style":193},"# 只在 macOS 构建时加\nEXTRA_TAGS=\"private_mac_apis\"\n",[87,4572,4573,4578],{"__ignoreMap":193},[197,4574,4575],{"class":199,"line":200},[197,4576,4577],{"class":203},"# 只在 macOS 构建时加\n",[197,4579,4580,4582,4584],{"class":199,"line":207},[197,4581,4099],{"class":218},[197,4583,781],{"class":210},[197,4585,4586],{"class":256},"\"private_mac_apis\"\n",[14,4588,4589],{},"缺一个的症状：",[1133,4591,4592,4602],{},[1136,4593,4594],{},[1139,4595,4596,4599],{},[1142,4597,4598],{},"缺什么",[1142,4600,4601],{},"你会看到",[1152,4603,4604,4614,4625,4635],{},[1139,4605,4606,4611],{},[1157,4607,4608],{},[87,4609,4610],{},"BackgroundType",[1157,4612,4613],{},"整个窗口是个不透明方块",[1139,4615,4616,4622],{},[1157,4617,4618,4621],{},[87,4619,4620],{},"BackgroundColour"," 零值",[1157,4623,4624],{},"边上有一圈黑边",[1139,4626,4627,4632],{},[1157,4628,4629],{},[87,4630,4631],{},"Mac.Backdrop",[1157,4633,4634],{},"原生窗口透明了，但网页本身还是白的",[1139,4636,4637,4642],{},[1157,4638,4639],{},[87,4640,4641],{},"private_mac_apis",[1157,4643,4644],{},"上面那行配了也不生效，退化成上一条",[14,4646,4647,4648],{},"最后一条最坑：",[72,4649,4650,4651,4653],{},"它是编译参数，代码里看不出来，编辑器不报错，",[87,4652,2010],{}," 也过。",[14,4655,4656,4657],{},"所以有个通用经验：",[72,4658,4659],{},"遇到\"配了但不生效\"的选项，先怀疑编译参数。",[123,4661,4663],{"id":4662},"_4-约定好数据长什么样两边照着来","4. 约定好数据长什么样，两边照着来",[14,4665,4666],{},"这是我整个项目里最想推荐的做法。",[14,4668,4669,4671],{},[87,4670,1661],{}," 下有三个包，什么业务都不做，只定义\"数据长什么样\"：",[1133,4673,4674,4687],{},[1136,4675,4676],{},[1139,4677,4678,4681,4684],{},[1142,4679,4680],{},"包",[1142,4682,4683],{},"定义什么",[1142,4685,4686],{},"前端对应",[1152,4688,4689,4707,4725],{},[1139,4690,4691,4696,4702],{},[1157,4692,4693],{},[87,4694,4695],{},"apperr",[1157,4697,4698,4699],{},"错误长这样：",[87,4700,4701],{},"{code, message, detail}",[1157,4703,4704],{},[87,4705,4706],{},"lib\u002Finvoke.ts",[1139,4708,4709,4714,4721],{},[1157,4710,4711],{},[87,4712,4713],{},"event",[1157,4715,4716,4717,4720],{},"事件名格式 ",[87,4718,4719],{},"\u003C模块>:\u003C动作>"," + 载荷",[1157,4722,4723],{},[87,4724,3017],{},[1139,4726,4727,4732,4735],{},[1157,4728,4729],{},[87,4730,4731],{},"page",[1157,4733,4734],{},"分页请求和返回",[1157,4736,4737],{},[87,4738,4739],{},"lib\u002Fpage.ts",[14,4741,4742],{},[72,4743,4744],{},"它的价值在框架换代那天才显现。",[14,4746,4747,4748,4751,4752,2001],{},"举个例子。v2 的错误处理有个著名的坑：Go 返回对象，前端拿到的是 ",[87,4749,4750],{},"\"[object Object]\"","，结构化信息全丢。所以 v2 时代 Go 侧",[72,4753,4754],{},"必须返回字符串",[14,4756,4757,4758,4760],{},"v3 改成了 ",[87,4759,2686],{},"，框架自己序列化。",[14,4762,4763],{},[72,4764,4765,4766,4769],{},"结果：Go 侧改了一处，前端 ",[87,4767,4768],{},"normalizeError"," 一行没动。",[14,4771,4772,4773,4776,4777],{},"因为前端拿到的",[72,4774,4775],{},"还是\"一个 message 里装着 JSON 的 Error\"","，只是这个 JSON 现在由框架保证格式。",[72,4778,4779],{},"约定没变，实现变了。",[14,4781,4782],{},"这就是把\"约定\"单独抽出来的好处：换框架时，改的是接线口，不是所有使用方。",[14,4784,4785,4786,4789],{},"代价是前端要维护一份镜像常量。我的做法是用护栏双向校验——改了 Go 不同步前端，",[87,4787,4788],{},"make test"," 就红。",[14,4791,4792,4795],{},[72,4793,4794],{},"一条纪律：镜像只许复制\"常量和类型\"，不许复制\"逻辑\"。"," 同一条规则两个实现，比一个常量两份更糟。",[123,4797,4799],{"id":4798},"_5-把不报错的问题变成会红的检查","5. 把\"不报错的问题\"变成会红的检查",[14,4801,4802,4803],{},"前面十个坑，一半以上属于同一类：",[72,4804,4805],{},"坏了不吭声。",[14,4807,4808,4809],{},"这类问题没法靠 code review，也没法靠文档。唯一可靠的办法是：",[72,4810,4811],{},"写成自动检查，让它变红。",[14,4813,4814,4815,4818,4819,4822,4823,4825],{},"我在 ",[87,4816,4817],{},"internal\u002Fguard\u002F"," 里放了 20 多个检查，全是 ",[87,4820,4821],{},"go test"," 形式，跑在 ",[87,4824,4788],{}," 里。它们用 Go 的 AST 解析自己的源码，检查\"该接的线接了没有\"：",[1133,4827,4828,4838],{},[1136,4829,4830],{},[1139,4831,4832,4835],{},[1142,4833,4834],{},"检查什么",[1142,4836,4837],{},"不检查会怎样",[1152,4839,4840,4856,4864,4876,4887,4895],{},[1139,4841,4842,4853],{},[1157,4843,4844,2305,4847,2305,4850,4852],{},[87,4845,4846],{},"Options.Logger",[87,4848,4849],{},"Assets",[87,4851,2923],{}," 都接了",[1157,4854,4855],{},"框架错误无人可见",[1139,4857,4858,4861],{},[1157,4859,4860],{},"log 服务显式注册了，且传了 logger",[1157,4862,4863],{},"前端日志全丢",[1139,4865,4866,4871],{},[1157,4867,4868,4870],{},[87,4869,2572],{}," 注入的变量真实存在",[1157,4872,4873,4874],{},"应用内版本恒为 ",[87,4875,4127],{},[1139,4877,4878,4884],{},[1157,4879,4880,4883],{},[87,4881,4882],{},"mcp"," 标签没进默认构建",[1157,4885,4886],{},"生产包多开一个本机端口",[1139,4888,4889,4892],{},[1157,4890,4891],{},"关窗逻辑是\"托盘在→隐藏、托盘不在→退出\"",[1157,4893,4894],{},"关窗后回不来 \u002F 留下僵尸进程",[1139,4896,4897,4900],{},[1157,4898,4899],{},"macOS 应用菜单挂上了",[1157,4901,4902],{},"输入框复制粘贴失效",[14,4904,4905],{},[72,4906,4907],{},"但这里有一条铁律，比检查本身重要：",[11,4909,4910],{},[14,4911,4912],{},[72,4913,4914],{},"检查本身要能发现\"自己什么都没查到\"。",[14,4916,4917,4918,4921],{},"如果检查是靠正则或 AST 找代码，一旦代码换个写法，它找不到东西了——这时候必须",[72,4919,4920],{},"报错","，不能当作\"通过\"。",[14,4923,4924],{},"否则检查会从\"拦问题\"退化成\"永远绿的摆设\"，还给你一种\"很安全\"的错觉。",[14,4926,4927],{},"我踩过两个反例：",[77,4929,4930,4942],{},[80,4931,4932,4935,4936,4938,4939,4941],{},[72,4933,4934],{},"解析范围必须等于书写范围。"," 我的绑定检查只解析 ",[87,4937,1657],{},"。于是\"所有绑定方法必须写在 ",[87,4940,1657],{},"\"变成了项目铁律——不是框架要求，是护栏只认这个文件。写到别处能编译、能生成，但护栏会误报。",[80,4943,4944,4947,4948,4950],{},[72,4945,4946],{},"只检查第一个匹配项 = 脆弱。"," 我的关窗检查只解析 ",[87,4949,1653],{}," 里第一个关窗钩子，于是\"钩子注册顺序\"变成了不能改的约定——强提醒窗口的钩子必须排在主窗之后，否则护栏抓错对象。",[14,4952,4953,4956],{},[72,4954,4955],{},"检查不是免费的，每条都在给未来加约束。"," 但比起\"用户投诉时你打开日志发现什么都没有\"，这笔买卖划算。",[1667,4958],{},[64,4960,4962],{"id":4961},"七值不值得升","七、值不值得升",[123,4964,4965],{"id":4965},"该升的情况",[1984,4967,4968,4975,4982,4989],{},[80,4969,4970,4971,4974],{},"你在用 v2，且",[72,4972,4973],{},"macOS 需要托盘","——光这一条就够",[80,4976,4977,4978,4981],{},"你想要 ",[72,4979,4980],{},"AI 能直接操作你的应用","（MCP）——只有 v3 有",[80,4983,4984,4985,4988],{},"你需要",[72,4986,4987],{},"多窗口","（悬浮窗、覆盖层、多屏）",[80,4990,4991],{},"你不介意花两三天做迁移和验证",[123,4993,4994],{"id":4994},"别升的情况",[1984,4996,4997,5000,5003],{},[80,4998,4999],{},"你的应用就是个单窗口工具，v2 跑得好好的",[80,5001,5002],{},"你受不了 beta 的 API 变动风险",[80,5004,5005],{},"你没有自动化测试\u002F检查，升级后没法验证\"行为没变\"",[123,5007,5009],{"id":5008},"关于-beta-的实话","关于 beta 的实话",[14,5011,5012,5013,5015],{},"我用的是 ",[87,5014,1890],{},"。beta 意味着：",[1984,5017,5018,5021,5026],{},[80,5019,5020],{},"API 还会动",[80,5022,5023],{},[72,5024,5025],{},"文档永远落后源码半个版本",[80,5027,5028],{},"遇到问题，GitHub issue 比文档有用",[14,5030,5031],{},"三个月下来最实用的一条经验：",[11,5033,5034],{},[14,5035,5036],{},[72,5037,5038],{},"读源码注释，比读文档有用。",[14,5040,5041,5042,5045,5046,5049],{},"前面那些\"为什么不能这么写\"的结论，大部分不是文档里抄的，是翻 ",[87,5043,5044],{},"webview_window_darwin.m","、看 ",[87,5047,5048],{},"HandleWindowEvent"," 的实现、读上游 issue #4389 看出来的。",[1667,5051],{},[64,5053,5054],{"id":5054},"最后三句",[77,5056,5057,5063,5072],{},[80,5058,5059,5062],{},[72,5060,5061],{},"Wails 是\"Go + 系统浏览器\"，轻、干净，但不是开箱即用。"," 框架该给你的可观测性、构建链、错误处理，得自己补。",[80,5064,5065,5068,5069,2001],{},[72,5066,5067],{},"v3 白给的好处是平台能力（托盘、多窗口、对象化 API），要你付的成本是接线责任（日志、事件、版本注入、应用菜单）。"," 好处你会自动享受到，成本不会——",[72,5070,5071],{},"因为它们全都不报错",[80,5073,5074,5077],{},[72,5075,5076],{},"所以真正的经验不是\"记住 10 个坑\"，是养成一个习惯：凡是\"不做也不会报错\"的事，都写成会红的检查。"," 那条检查可能就 20 行，但它替你记住了三个月后一定会忘的事。",[1667,5079],{},[14,5081,5082],{},[50,5083,5084,5085,5088],{},"项目开源在 ",[18,5086,1569],{"href":1569,"rel":5087},[57]," ，欢迎来看代码（注释比代码多，那是有意的）。",[1512,5090,5091],{},"html pre.shiki code .shWlK, html code.shiki .shWlK{--shiki-default:#E1E4E8;--shiki-light:#24292E}html pre.shiki code .sqoU-, html code.shiki .sqoU-{--shiki-default:#B392F0;--shiki-light:#6F42C1}html pre.shiki code .spKkM, html code.shiki .spKkM{--shiki-default:#F97583;--shiki-light:#D73A49}html pre.shiki code .skb7c, html code.shiki .skb7c{--shiki-default:#9ECBFF;--shiki-light:#032F62}html pre.shiki code .sTU5a, html code.shiki .sTU5a{--shiki-default:#79B8FF;--shiki-light:#005CC5}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-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.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 pre.shiki code .si27w, html code.shiki .si27w{--shiki-default:#6A737D;--shiki-light:#6A737D}html pre.shiki code .sdxZB, html code.shiki .sdxZB{--shiki-default:#FFAB70;--shiki-light:#E36209}",{"title":193,"searchDepth":207,"depth":207,"links":5093},[5094,5095,5101,5107,5117,5127,5141,5149,5154],{"id":1575,"depth":207,"text":1575},{"id":1671,"depth":207,"text":1672,"children":5096},[5097,5098,5099,5100],{"id":1675,"depth":244,"text":1675},{"id":1686,"depth":244,"text":1687},{"id":1785,"depth":244,"text":1786},{"id":1833,"depth":244,"text":1834},{"id":1876,"depth":207,"text":1877,"children":5102},[5103,5104,5105,5106],{"id":1883,"depth":244,"text":1884},{"id":1894,"depth":244,"text":1895},{"id":1936,"depth":244,"text":1936},{"id":1979,"depth":244,"text":1979},{"id":2025,"depth":207,"text":2026,"children":5108},[5109,5110,5112,5114,5115,5116],{"id":2032,"depth":244,"text":2033},{"id":2224,"depth":244,"text":5111},"2. 绑定：Bind → Services",{"id":2315,"depth":244,"text":5113},"3. runtime 包没了，能力长在对象上",{"id":2500,"depth":244,"text":2501},{"id":2541,"depth":244,"text":2542},{"id":2576,"depth":244,"text":2576},{"id":2779,"depth":207,"text":2780,"children":5118},[5119,5120,5121,5122,5123,5124,5125,5126],{"id":2802,"depth":244,"text":2803},{"id":2852,"depth":244,"text":2853},{"id":2893,"depth":244,"text":2894},{"id":2992,"depth":244,"text":2993},{"id":3053,"depth":244,"text":3054},{"id":3090,"depth":244,"text":3091},{"id":3137,"depth":244,"text":3138},{"id":3144,"depth":244,"text":3144},{"id":3272,"depth":207,"text":3273,"children":5128},[5129,5130,5131,5132,5133,5134,5135,5137,5139,5140],{"id":3286,"depth":244,"text":3287},{"id":3406,"depth":244,"text":3407},{"id":3660,"depth":244,"text":3661},{"id":3762,"depth":244,"text":3763},{"id":3818,"depth":244,"text":3819},{"id":4019,"depth":244,"text":4020},{"id":4066,"depth":244,"text":5136},"坑 7：wails3 build 不转发 -ldflags",{"id":4131,"depth":244,"text":5138},"坑 8：拷贝构建脚本时丢了 OUTPUT 变量",{"id":4171,"depth":244,"text":4172},{"id":4198,"depth":244,"text":4199},{"id":4235,"depth":207,"text":4236,"children":5142},[5143,5145,5146,5147,5148],{"id":4242,"depth":244,"text":5144},"1. main() 不是启动应用，是装配应用",{"id":4320,"depth":244,"text":4321},{"id":4462,"depth":244,"text":4463},{"id":4662,"depth":244,"text":4663},{"id":4798,"depth":244,"text":4799},{"id":4961,"depth":207,"text":4962,"children":5150},[5151,5152,5153],{"id":4965,"depth":244,"text":4965},{"id":4994,"depth":244,"text":4994},{"id":5008,"depth":244,"text":5009},{"id":5054,"depth":207,"text":5054},"2026-10-23",{},"\u002Fposts\u002Fwails-v3-upgrade","morrow",{"title":1543,"description":193},"wails-v3-upgrade","posts\u002Fwails-v3-upgrade","Wails v3 删掉了一堆 v2 时代的妥协：macOS 终于有托盘、日志直接接标准库、能力从全局函数变成对象方法。但它也把一批接线责任还给了你——日志、事件、版本注入、应用菜单，漏接一条程序照常跑，只是某块功能默默没了。这是一次从 v2.15 到 beta.23 的真实迁移：为什么升、怎么升、踩了什么。",[1537,1707,5164],"升级迁移","IscaW4T1uL4RavtgfofbQreCPKOcQDCx975oU5I3n2g",{"id":5167,"title":5168,"body":5169,"cover":6069,"date":6190,"description":193,"extension":1527,"links":1525,"meta":6191,"navigation":1529,"path":6192,"project":6155,"seo":6193,"slug":6194,"stem":6195,"summary":6196,"tags":6197,"visible":1529,"__hash__":6200},"posts\u002Fposts\u002Fchinese-poetry-rag.md","用 RAG + FAISS 构建古诗词 AI 取名系统：从向量检索到六层质量过滤",{"type":8,"value":5170,"toc":6160},[5171,5189,5193,5196,5202,5205,5277,5281,5285,5288,5328,5331,5335,5338,5344,5350,5353,5449,5452,5458,5462,5465,5472,5530,5533,5537,5541,5544,5550,5553,5560,5568,5574,5585,5588,5642,5649,5771,5774,5777,5836,5843,5847,5850,5854,5863,5866,5870,5873,5877,5880,5895,5899,5902,5928,5931,5935,5938,5945,5948,5978,5982,5985,5988,5992,5996,5999,6006,6010,6013,6016,6036,6040,6043,6047,6058,6064,6070,6075,6081,6087,6092,6098,6104,6109,6112,6116,6122,6125,6128,6130,6133,6136,6147,6150,6157],[11,5172,5173,5179,5182],{},[14,5174,5175,5176,2001],{},"用 LLM 生成古诗词名字，最大的问题不是\"好不好听\"，而是",[72,5177,5178],{},"它会编造出处",[14,5180,5181],{},"让大模型给你推荐一个出自《诗经》的名字，它大概率会自信满满地给你一个假的诗句、假的篇名、假的释义。这不是模型能力问题，而是幻觉的结构性根源——它没有引用真实语料的机制。",[14,5183,5184,5185,5188],{},"所以我做了一个系统：",[72,5186,5187],{},"每个名字都必须来自真实可检索的古诗词原文","，通过 RAG（检索增强生成）架构强制约束。以下是完整的技术拆解。",[64,5190,5192],{"id":5191},"一架构全景","一、架构全景",[14,5194,5195],{},"先看整体数据流：",[188,5197,5200],{"className":5198,"code":5199,"language":1919},[1917],"用户输入                      离线预处理\n─────────                    ──────────\n姓氏+性别+出生时间      270万句古诗词原文\n     │                        │\n     ▼                        ▼\n 八字计算 → 五行分析      繁→简转换(OpenCC)\n     │          │            句子切分(≥4字)\n     │     喜用元素           负面意象过滤\n     │          │            │\n     │     映射意象关键词     │\n     │          │            │\n     ▼          ▼            ▼\n  拼接搜索query         Embedding向量化\n     │          │            │\n     ▼          │            ▼\n  Query向量化   │       FAISS索引(525MB)\n     │          │            │\n     └────┬─────┘            │\n          ▼                  │\n   Top-K相似检索 ◄───────────┘\n          │\n          ▼\n   候选诗句池(50首)\n          │\n          ▼\n   LLM Prompt生成(严格约束不许编造)\n          │\n          ▼\n   流式输出 → SSE → 前端逐个渲染\n          │\n          ▼\n   六层质量过滤\n   ┌─────────────────────────────┐\n   │ 1. 烂大街字黑名单             │\n   │ 2. 谐音梗检测                │\n   │ 3. 生僻字过滤                │\n   │ 4. 音韵评分(pypinyin)        │\n   │ 5. 五格数理(81数理吉凶)       │\n   │ 6. 历史去重                  │\n   └─────────────────────────────┘\n          │\n          ▼\n     最终名字 + 诗词出处 + 完整解析\n",[87,5201,5199],{"__ignoreMap":193},[14,5203,5204],{},"技术选型一览：",[1133,5206,5207,5220],{},[1136,5208,5209],{},[1139,5210,5211,5214,5217],{},[1142,5212,5213],{},"环节",[1142,5215,5216],{},"选择",[1142,5218,5219],{},"理由",[1152,5221,5222,5233,5244,5255,5266],{},[1139,5223,5224,5227,5230],{},[1157,5225,5226],{},"向量化",[1157,5228,5229],{},"Zhipu Embedding-3",[1157,5231,5232],{},"中文语义理解能力强，1024 维",[1139,5234,5235,5238,5241],{},[1157,5236,5237],{},"向量检索",[1157,5239,5240],{},"FAISS（本地）",[1157,5242,5243],{},"无外部依赖，支持 mmap 加载",[1139,5245,5246,5249,5252],{},[1157,5247,5248],{},"LLM",[1157,5250,5251],{},"DeepSeek-V4",[1157,5253,5254],{},"中文能力强，OpenAI 兼容 API",[1139,5256,5257,5260,5263],{},[1157,5258,5259],{},"元数据",[1157,5261,5262],{},"SQLite + JSON 双备",[1157,5264,5265],{},"轻量，WAL 模式支持并发读",[1139,5267,5268,5271,5274],{},[1157,5269,5270],{},"流式协议",[1157,5272,5273],{},"SSE",[1157,5275,5276],{},"比 WebSocket 简单，单向推送够用",[64,5278,5280],{"id":5279},"二向量检索-域知识注入","二、向量检索 + 域知识注入",[123,5282,5284],{"id":5283},"_21-基础检索","2.1 基础检索",[14,5286,5287],{},"古诗词语料经过预处理后（繁→简转换、按句切分、负面过滤），通过 Embedding-3 向量化存入 FAISS 索引。查询时，将用户需求编码为自然语言：",[188,5289,5293],{"className":5290,"code":5291,"language":5292,"meta":193,"style":193},"language-python shiki shiki-themes github-dark github-light","query = f\"姓氏：{surname}，性别：{gender}，风格：古典诗词，意境优美\"\n\nquery_vec = embedding_client.embed(query)\nfaiss.normalize_L2(query_vec)  # L2归一化 → 等价余弦相似度\n_, indices = index.search(query_vec, k=50)\n\ncandidates = [metadata[i] for i in indices[0]]\n","python",[87,5294,5295,5300,5304,5309,5314,5319,5323],{"__ignoreMap":193},[197,5296,5297],{"class":199,"line":200},[197,5298,5299],{},"query = f\"姓氏：{surname}，性别：{gender}，风格：古典诗词，意境优美\"\n",[197,5301,5302],{"class":199,"line":207},[197,5303,2155],{"emptyLinePlaceholder":1529},[197,5305,5306],{"class":199,"line":244},[197,5307,5308],{},"query_vec = embedding_client.embed(query)\n",[197,5310,5311],{"class":199,"line":263},[197,5312,5313],{},"faiss.normalize_L2(query_vec)  # L2归一化 → 等价余弦相似度\n",[197,5315,5316],{"class":199,"line":275},[197,5317,5318],{},"_, indices = index.search(query_vec, k=50)\n",[197,5320,5321],{"class":199,"line":285},[197,5322,2155],{"emptyLinePlaceholder":1529},[197,5324,5325],{"class":199,"line":291},[197,5326,5327],{},"candidates = [metadata[i] for i in indices[0]]\n",[14,5329,5330],{},"用自然语言而非关键词拼接，是因为 Embedding-3 对语义的理解远比关键词匹配准确。\"意境优美的古诗词\"这样的描述，能检索到包含山水、清风、明月等意象的诗句，而关键词\"优美\"本身可能一句都匹配不上。",[123,5332,5334],{"id":5333},"_22-八字五行-向量偏移","2.2 八字五行 → 向量偏移",[14,5336,5337],{},"这是整个系统最独特的设计。传统取名软件的五行补救，是推荐固定偏旁（缺水带\"氵\"，缺木带\"木\"）。但古诗词取名不能这样做——你不能限制用户只能用带\"氵\"的字。",[14,5339,5340,5341,2001],{},"我的方案是：",[72,5342,5343],{},"把五行喜用转化为古诗意象关键词，注入 FAISS 检索 query，让向量检索自然偏移到含对应意象的诗句",[188,5345,5348],{"className":5346,"code":5347,"language":1919},[1917],"出生时间\n  │\n  ▼\n四柱八字(年月日时 → 天干地支)\n  │\n  ▼\n十神分析 → 日主强弱 → 旺衰法\n  │\n  ▼\n喜用神 → 意象关键词映射\n  │\n  ▼          五行 → 意象关键词\n  │         ──────────────────────\n  │          水 → \"雨 云 河 溪 澜 泉 润\"\n  │          木 → \"林 松 竹 兰 芳 蕊\"\n  │          金 → \"玉 银 锋 铭 锐 钰\"\n  │          火 → \"日 炎 晖 曜 煜 烨\"\n  │          土 → \"山 岳 峰 坤 垣 培\"\n  ▼\n注入检索query → 偏移到含对应意象的古诗\n",[87,5349,5347],{"__ignoreMap":193},[14,5351,5352],{},"核心代码：",[188,5354,5356],{"className":5290,"code":5355,"language":5292,"meta":193,"style":193},"WUXING_KEYWORDS = {\n    \"金\": [\"玉\", \"银\", \"锋\", \"铭\", \"锐\", \"钰\", \"鑫\"],\n    \"木\": [\"林\", \"松\", \"竹\", \"兰\", \"芳\", \"蕊\", \"萱\"],\n    \"水\": [\"雨\", \"云\", \"河\", \"溪\", \"澜\", \"泉\", \"润\"],\n    \"火\": [\"日\", \"炎\", \"晖\", \"曜\", \"煜\", \"烨\", \"灿\"],\n    \"土\": [\"山\", \"岳\", \"峰\", \"坤\", \"垣\", \"培\", \"城\"],\n}\n\ndef build_query(surname, gender, favored_elements):\n    \"\"\"将五行喜用映射为古诗意象，注入检索query\"\"\"\n    base = f\"姓氏：{surname}，性别：{gender}，风格：古典诗词\"\n\n    imagery = []\n    for elem in favored_elements:\n        imagery.extend(WUXING_KEYWORDS[elem][:3])\n\n    if imagery:\n        return f\"{base}，意象：{' '.join(imagery)}\"\n    return base\n",[87,5357,5358,5363,5368,5373,5378,5383,5388,5392,5396,5401,5406,5411,5415,5420,5425,5430,5434,5439,5444],{"__ignoreMap":193},[197,5359,5360],{"class":199,"line":200},[197,5361,5362],{},"WUXING_KEYWORDS = {\n",[197,5364,5365],{"class":199,"line":207},[197,5366,5367],{},"    \"金\": [\"玉\", \"银\", \"锋\", \"铭\", \"锐\", \"钰\", \"鑫\"],\n",[197,5369,5370],{"class":199,"line":244},[197,5371,5372],{},"    \"木\": [\"林\", \"松\", \"竹\", \"兰\", \"芳\", \"蕊\", \"萱\"],\n",[197,5374,5375],{"class":199,"line":263},[197,5376,5377],{},"    \"水\": [\"雨\", \"云\", \"河\", \"溪\", \"澜\", \"泉\", \"润\"],\n",[197,5379,5380],{"class":199,"line":275},[197,5381,5382],{},"    \"火\": [\"日\", \"炎\", \"晖\", \"曜\", \"煜\", \"烨\", \"灿\"],\n",[197,5384,5385],{"class":199,"line":285},[197,5386,5387],{},"    \"土\": [\"山\", \"岳\", \"峰\", \"坤\", \"垣\", \"培\", \"城\"],\n",[197,5389,5390],{"class":199,"line":291},[197,5391,464],{},[197,5393,5394],{"class":199,"line":313},[197,5395,2155],{"emptyLinePlaceholder":1529},[197,5397,5398],{"class":199,"line":349},[197,5399,5400],{},"def build_query(surname, gender, favored_elements):\n",[197,5402,5403],{"class":199,"line":360},[197,5404,5405],{},"    \"\"\"将五行喜用映射为古诗意象，注入检索query\"\"\"\n",[197,5407,5408],{"class":199,"line":365},[197,5409,5410],{},"    base = f\"姓氏：{surname}，性别：{gender}，风格：古典诗词\"\n",[197,5412,5413],{"class":199,"line":390},[197,5414,2155],{"emptyLinePlaceholder":1529},[197,5416,5417],{"class":199,"line":417},[197,5418,5419],{},"    imagery = []\n",[197,5421,5422],{"class":199,"line":427},[197,5423,5424],{},"    for elem in favored_elements:\n",[197,5426,5427],{"class":199,"line":432},[197,5428,5429],{},"        imagery.extend(WUXING_KEYWORDS[elem][:3])\n",[197,5431,5432],{"class":199,"line":447},[197,5433,2155],{"emptyLinePlaceholder":1529},[197,5435,5436],{"class":199,"line":461},[197,5437,5438],{},"    if imagery:\n",[197,5440,5441],{"class":199,"line":803},[197,5442,5443],{},"        return f\"{base}，意象：{' '.join(imagery)}\"\n",[197,5445,5446],{"class":199,"line":808},[197,5447,5448],{},"    return base\n",[14,5450,5451],{},"为什么不直接用关键词过滤（\"包含'水'字的诗句\"）？因为\"水\"的意象远不止字面包含\"水\"的诗句。\"落霞与孤鹜齐飞，秋水共长天一色\"里\"水\"不是主角，但整体意象是水。Embedding-3 能捕捉这种语义关联，关键词过滤做不到。",[14,5453,5454,5457],{},[72,5455,5456],{},"实测效果","：缺水的八字，注入\"雨 云 河 溪\"后，检索到的候选诗句中含水意象的比例从 15% 提升到 60% 以上。向量检索确实会往对应意象偏移。",[123,5459,5461],{"id":5460},"_23-换一批时的多样性策略","2.3 换一批时的多样性策略",[14,5463,5464],{},"用户点\"换一批\"时，不能简单地再跑一次同样的检索——大概率会得到相似的结果。",[14,5466,5467,5468,5471],{},"解决方案是",[72,5469,5470],{},"方向词轮转","：维护一组主题方向，每次换一批切换一个，让 FAISS 检索偏移到不同诗歌集群。",[188,5473,5475],{"className":5290,"code":5474,"language":5292,"meta":193,"style":193},"DIRECTION_WORDS = [\n    \"山水田园\", \"日月星辰\", \"花鸟鱼虫\",\n    \"德行修养\", \"志向抱负\", \"清雅温婉\"\n]\n\ndef rotate_query(base_query, used_directions):\n    \"\"\"换一批时切换意象方向，保证每轮结果不同\"\"\"\n    available = [d for d in DIRECTION_WORDS if d not in used_directions]\n    direction = random.choice(available)\n    used_directions.append(direction)\n    return f\"{base_query}，主题方向：{direction}\"\n",[87,5476,5477,5482,5487,5492,5496,5500,5505,5510,5515,5520,5525],{"__ignoreMap":193},[197,5478,5479],{"class":199,"line":200},[197,5480,5481],{},"DIRECTION_WORDS = [\n",[197,5483,5484],{"class":199,"line":207},[197,5485,5486],{},"    \"山水田园\", \"日月星辰\", \"花鸟鱼虫\",\n",[197,5488,5489],{"class":199,"line":244},[197,5490,5491],{},"    \"德行修养\", \"志向抱负\", \"清雅温婉\"\n",[197,5493,5494],{"class":199,"line":263},[197,5495,260],{},[197,5497,5498],{"class":199,"line":275},[197,5499,2155],{"emptyLinePlaceholder":1529},[197,5501,5502],{"class":199,"line":285},[197,5503,5504],{},"def rotate_query(base_query, used_directions):\n",[197,5506,5507],{"class":199,"line":291},[197,5508,5509],{},"    \"\"\"换一批时切换意象方向，保证每轮结果不同\"\"\"\n",[197,5511,5512],{"class":199,"line":313},[197,5513,5514],{},"    available = [d for d in DIRECTION_WORDS if d not in used_directions]\n",[197,5516,5517],{"class":199,"line":349},[197,5518,5519],{},"    direction = random.choice(available)\n",[197,5521,5522],{"class":199,"line":360},[197,5523,5524],{},"    used_directions.append(direction)\n",[197,5526,5527],{"class":199,"line":365},[197,5528,5529],{},"    return f\"{base_query}，主题方向：{direction}\"\n",[14,5531,5532],{},"\"山水田园\"会偏移到王维、孟浩然的诗句；\"日月星辰\"则偏向李白、苏轼。每轮结果自然不同。",[64,5534,5536],{"id":5535},"三prompt-工程与流式解析","三、Prompt 工程与流式解析",[123,5538,5540],{"id":5539},"_31-约束-llm-不编造出处","3.1 约束 LLM 不编造出处",[14,5542,5543],{},"Prompt 设计中最关键的约束：",[188,5545,5548],{"className":5546,"code":5547,"language":1919},[1917],"【硬约束】只能使用上述提供的诗句原文，绝不编造任何诗句、出处或释义。\n         如果候选诗句中没有合适的，宁可少生成一个名字，也不许杜撰。\n\n【跨诗规则】名字的每个字必须来自不同诗句。\n            最多一个字来自同一首诗，以保证文化关联的丰富性。\n\n【格式要求】使用 \u003C\u003CNAME>> 分隔符逐个输出每个名字，\n            每个名字包含：名字、出处、诗句原文、释义、思考过程\n",[87,5549,5547],{"__ignoreMap":193},[14,5551,5552],{},"跨诗规则是容易忽略但很重要的设计——如果两个字来自同一首诗，名字的文化厚度就局限在一首诗里。跨诗取字让每个名字有多重文化关联，解释空间更大。",[14,5554,5555,5556,5559],{},"但即使有严格的 prompt 约束，LLM 仍然可能编造。所以还需要",[72,5557,5558],{},"后处理校验","：把 LLM 引用的诗句在原文库里做精确匹配，不匹配的直接丢弃，触发重新生成。两道防线确保零编造。",[123,5561,5563,5564],{"id":5562},"_32-流式解析","3.2 \u003C",[5565,5566,5567],"name",{},"> 流式解析",[14,5569,5570,5571,2001],{},"LLM 流式输出最大的工程挑战是：",[72,5572,5573],{},"输出中途不是合法的 JSON",[14,5575,5576,5577,5580,5581,5584],{},"假设 LLM 要输出 ",[87,5578,5579],{},"{\"names\": [{\"name\": \"清涵\"}, ...]}","，在流式传输过程中，你收到的前几个 chunk 可能是 ",[87,5582,5583],{},"{\"names\": [{\"name\": \"清","——这不是合法 JSON，无法解析。",[14,5586,5587],{},"常见方案对比：",[1133,5589,5590,5603],{},[1136,5591,5592],{},[1139,5593,5594,5597,5600],{},[1142,5595,5596],{},"方案",[1142,5598,5599],{},"优点",[1142,5601,5602],{},"缺点",[1152,5604,5605,5616,5627],{},[1139,5606,5607,5610,5613],{},[1157,5608,5609],{},"等完整输出再解析",[1157,5611,5612],{},"实现简单",[1157,5614,5615],{},"失去流式意义，延迟高",[1139,5617,5618,5621,5624],{},[1157,5619,5620],{},"JSON 流式解析器",[1157,5622,5623],{},"能增量解析",[1157,5625,5626],{},"复杂、脆弱、LLM 输出风格微变就挂",[1139,5628,5629,5634,5639],{},[1157,5630,5631],{},[72,5632,5633],{},"自定义分隔符",[1157,5635,5636],{},[72,5637,5638],{},"简单、通用、零依赖",[1157,5640,5641],{},"需要在 prompt 里约定格式",[14,5643,5644,5645,5648],{},"我选了自定义分隔符方案。Prompt 要求 LLM 用 ",[87,5646,5647],{},"\u003C\u003CNAME>>"," 作为名字之间的分隔标记，解析端只需按这个标记切分缓冲区：",[188,5650,5652],{"className":5290,"code":5651,"language":5292,"meta":193,"style":193},"async def stream_names(llm_stream):\n    \"\"\"从LLM流中增量解析每个名字块\"\"\"\n    buffer = \"\"\n    async for chunk in llm_stream:\n        buffer += chunk\n        # 每发现一个\u003C\u003CNAME>>标记，就切出一个完整的名字块\n        while \"\u003C\u003CNAME>>\" in buffer:\n            block, buffer = buffer.split(\"\u003C\u003CNAME>>\", 1)\n            if block.strip():\n                yield parse_name_block(block)\n\n    # 最后一个块没有后续\u003C\u003CNAME>>标记，单独处理\n    if buffer.strip():\n        yield parse_name_block(buffer)\n\n\ndef parse_name_block(text):\n    \"\"\"从名字块中提取结构化数据\"\"\"\n    result = {}\n    for line in text.strip().split(\"\\n\"):\n        if \"：\" in line:\n            key, value = line.split(\"：\", 1)\n            result[key.strip()] = value.strip()\n    return result\n",[87,5653,5654,5659,5664,5669,5674,5679,5684,5689,5694,5699,5704,5708,5713,5718,5723,5727,5731,5736,5741,5746,5751,5756,5761,5766],{"__ignoreMap":193},[197,5655,5656],{"class":199,"line":200},[197,5657,5658],{},"async def stream_names(llm_stream):\n",[197,5660,5661],{"class":199,"line":207},[197,5662,5663],{},"    \"\"\"从LLM流中增量解析每个名字块\"\"\"\n",[197,5665,5666],{"class":199,"line":244},[197,5667,5668],{},"    buffer = \"\"\n",[197,5670,5671],{"class":199,"line":263},[197,5672,5673],{},"    async for chunk in llm_stream:\n",[197,5675,5676],{"class":199,"line":275},[197,5677,5678],{},"        buffer += chunk\n",[197,5680,5681],{"class":199,"line":285},[197,5682,5683],{},"        # 每发现一个\u003C\u003CNAME>>标记，就切出一个完整的名字块\n",[197,5685,5686],{"class":199,"line":291},[197,5687,5688],{},"        while \"\u003C\u003CNAME>>\" in buffer:\n",[197,5690,5691],{"class":199,"line":313},[197,5692,5693],{},"            block, buffer = buffer.split(\"\u003C\u003CNAME>>\", 1)\n",[197,5695,5696],{"class":199,"line":349},[197,5697,5698],{},"            if block.strip():\n",[197,5700,5701],{"class":199,"line":360},[197,5702,5703],{},"                yield parse_name_block(block)\n",[197,5705,5706],{"class":199,"line":365},[197,5707,2155],{"emptyLinePlaceholder":1529},[197,5709,5710],{"class":199,"line":390},[197,5711,5712],{},"    # 最后一个块没有后续\u003C\u003CNAME>>标记，单独处理\n",[197,5714,5715],{"class":199,"line":417},[197,5716,5717],{},"    if buffer.strip():\n",[197,5719,5720],{"class":199,"line":427},[197,5721,5722],{},"        yield parse_name_block(buffer)\n",[197,5724,5725],{"class":199,"line":432},[197,5726,2155],{"emptyLinePlaceholder":1529},[197,5728,5729],{"class":199,"line":447},[197,5730,2155],{"emptyLinePlaceholder":1529},[197,5732,5733],{"class":199,"line":461},[197,5734,5735],{},"def parse_name_block(text):\n",[197,5737,5738],{"class":199,"line":803},[197,5739,5740],{},"    \"\"\"从名字块中提取结构化数据\"\"\"\n",[197,5742,5743],{"class":199,"line":808},[197,5744,5745],{},"    result = {}\n",[197,5747,5748],{"class":199,"line":825},[197,5749,5750],{},"    for line in text.strip().split(\"\\n\"):\n",[197,5752,5753],{"class":199,"line":867},[197,5754,5755],{},"        if \"：\" in line:\n",[197,5757,5758],{"class":199,"line":880},[197,5759,5760],{},"            key, value = line.split(\"：\", 1)\n",[197,5762,5763],{"class":199,"line":885},[197,5764,5765],{},"            result[key.strip()] = value.strip()\n",[197,5767,5768],{"class":199,"line":907},[197,5769,5770],{},"    return result\n",[14,5772,5773],{},"前端通过 SSE（Server-Sent Events）接收，每收到一个完整的名字块就立即渲染，用户看到的效果是名字一个接一个地\"跳出来\"——比等 10 秒后一次性展示 5 个名字的体验好得多。",[14,5775,5776],{},"SSE 在 Nginx 后面有个经典坑：Nginx 默认会 buffer 响应，导致前端收不到实时推送。需要两处配置：",[188,5778,5782],{"className":5779,"code":5780,"language":5781,"meta":193,"style":193},"language-nginx shiki shiki-themes github-dark github-light","# nginx.conf\nlocation \u002Fapi\u002F {\n    proxy_buffering off;                    # 关闭proxy层buffering\n    proxy_set_header X-Accel-Buffering no;  # 关闭X-Accel层buffering\n    proxy_pass http:\u002F\u002Fbackend;\n}\n","nginx",[87,5783,5784,5789,5799,5813,5824,5832],{"__ignoreMap":193},[197,5785,5786],{"class":199,"line":200},[197,5787,5788],{"class":203},"# nginx.conf\n",[197,5790,5791,5794,5797],{"class":199,"line":207},[197,5792,5793],{"class":210},"location",[197,5795,5796],{"class":214}," \u002Fapi\u002F ",[197,5798,2067],{"class":218},[197,5800,5801,5804,5807,5810],{"class":199,"line":244},[197,5802,5803],{"class":210},"    proxy_buffering ",[197,5805,5806],{"class":281},"off",[197,5808,5809],{"class":218},";                    ",[197,5811,5812],{"class":203},"# 关闭proxy层buffering\n",[197,5814,5815,5818,5821],{"class":199,"line":263},[197,5816,5817],{"class":210},"    proxy_set_header ",[197,5819,5820],{"class":218},"X-Accel-Buffering no;  ",[197,5822,5823],{"class":203},"# 关闭X-Accel层buffering\n",[197,5825,5826,5829],{"class":199,"line":275},[197,5827,5828],{"class":210},"    proxy_pass ",[197,5830,5831],{"class":218},"http:\u002F\u002Fbackend;\n",[197,5833,5834],{"class":199,"line":285},[197,5835,464],{"class":218},[14,5837,5838,5839,5842],{},"同时后端响应头也要加 ",[87,5840,5841],{},"X-Accel-Buffering: no","，双保险。",[64,5844,5846],{"id":5845},"四六层质量过滤","四、六层质量过滤",[14,5848,5849],{},"LLM 生成的名字不能直接展示，需要经过多层质量把关。每一层解决一个具体问题：",[123,5851,5853],{"id":5852},"第-1-层烂大街字黑名单","第 1 层：烂大街字黑名单",[188,5855,5857],{"className":5290,"code":5856,"language":5292,"meta":193,"style":193},"BANNED_CHARS = set(\"梓涵轩诺萱妍泽宇欣怡浩瑞昊辰\")\n",[87,5858,5859],{"__ignoreMap":193},[197,5860,5861],{"class":199,"line":200},[197,5862,5856],{},[14,5864,5865],{},"为什么要过滤这些字？2023 年新生儿重名统计，\"梓涵\"连续多年霸榜。用了这些字，再好的诗词出处也白搭——别人一看就知道是 AI 起的\"模板名\"。宁可少生成，也不能出烂大街的字。",[123,5867,5869],{"id":5868},"第-2-层谐音梗检测","第 2 层：谐音梗检测",[14,5871,5872],{},"姓+名组合的谐音问题，比如\"杨伟\"、\"范统\"、\"杜子腾\"。维护一个谐音黑名单，每个新名字都做组合检测。这类问题一旦漏掉，用户体验直接归零。",[123,5874,5876],{"id":5875},"第-3-层生僻字过滤","第 3 层：生僻字过滤",[14,5878,5879],{},"拒绝 CJK 扩展区（0x9FFF 以上）的字。名字是用来叫的，别人叫不出来、打不出来，再有文化也白搭。",[188,5881,5883],{"className":5290,"code":5882,"language":5292,"meta":193,"style":193},"def has_rare_char(name):\n    return any(ord(c) > 0x9FFF for c in name)\n",[87,5884,5885,5890],{"__ignoreMap":193},[197,5886,5887],{"class":199,"line":200},[197,5888,5889],{},"def has_rare_char(name):\n",[197,5891,5892],{"class":199,"line":207},[197,5893,5894],{},"    return any(ord(c) > 0x9FFF for c in name)\n",[123,5896,5898],{"id":5897},"第-4-层音韵评分","第 4 层：音韵评分",[14,5900,5901],{},"用 pypinyin 拆解声母韵母和声调，对名字做可读性评分。主要检测：",[1984,5903,5904,5910,5916,5922],{},[80,5905,5906,5909],{},[72,5907,5908],{},"同声母连读","：如\"赵兆\"，zh-zh 连读拗口",[80,5911,5912,5915],{},[72,5913,5914],{},"同韵母连读","：如\"林音\"，in-in 听起来像一个字",[80,5917,5918,5921],{},[72,5919,5920],{},"三声连读","：两个三声字连读需要变调，处理不好会奇怪",[80,5923,5924,5927],{},[72,5925,5926],{},"整体韵律","：声调起伏太平或太陡都不好",[14,5929,5930],{},"评分低于 60 分的直接过滤。",[123,5932,5934],{"id":5933},"第-5-层五格数理","第 5 层：五格数理",[14,5936,5937],{},"这是最\"不技术\"的一层，但对产品转化至关重要。",[14,5939,5940,5941,5944],{},"五格数理（天格、人格、地格、外格、总格）是中国传统取名的参考体系，用姓名笔画数对应 81 数理的吉凶。开发者可能觉得这是迷信，但准爸妈们",[72,5942,5943],{},"在乎","——他们会拿着你给的名字去\"起名网\"验证五格，如果人格是\"大凶\"，他们不会用你的产品。",[14,5946,5947],{},"所以系统用康熙字典笔画数自动计算五格，人格落\"大凶\"的组合直接排除。这层不是给开发者看的，是给用户信心的。",[188,5949,5951],{"className":5290,"code":5950,"language":5292,"meta":193,"style":193},"def wuge_is_bad(surname, name):\n    \"\"\"检查五格数理是否为大凶\"\"\"\n    strokes = get_kangxi_strokes(surname + name)\n    ren_grid = strokes[0] + strokes[1]  # 人格 = 姓末字+名首字笔画\n    return ren_grid in BAD_NUMBERS  # 81数理中的大凶数\n",[87,5952,5953,5958,5963,5968,5973],{"__ignoreMap":193},[197,5954,5955],{"class":199,"line":200},[197,5956,5957],{},"def wuge_is_bad(surname, name):\n",[197,5959,5960],{"class":199,"line":207},[197,5961,5962],{},"    \"\"\"检查五格数理是否为大凶\"\"\"\n",[197,5964,5965],{"class":199,"line":244},[197,5966,5967],{},"    strokes = get_kangxi_strokes(surname + name)\n",[197,5969,5970],{"class":199,"line":263},[197,5971,5972],{},"    ren_grid = strokes[0] + strokes[1]  # 人格 = 姓末字+名首字笔画\n",[197,5974,5975],{"class":199,"line":275},[197,5976,5977],{},"    return ren_grid in BAD_NUMBERS  # 81数理中的大凶数\n",[123,5979,5981],{"id":5980},"第-6-层历史去重","第 6 层：历史去重",[14,5983,5984],{},"用户点\"换一批\"时，新生成的名字不能和之前展示过的重复。session 中维护已展示名字集合，每层过滤后做最终去重。",[14,5986,5987],{},"六层全通过，才算一个合格的名字。实测下来，LLM 每轮生成 5 个名字，平均有 1-2 个会被过滤掉，所以系统会多生成几个作为缓冲。",[64,5989,5991],{"id":5990},"五踩过的坑","五、踩过的坑",[123,5993,5995],{"id":5994},"坑-1让-llm-不编造有多难","坑 1：让 LLM \"不编造\"有多难",[14,5997,5998],{},"最初 prompt 只写了\"请使用提供的诗句\"，编造率约 30%。加了\"绝不编造任何诗句或出处\"后降到 10%。加上后处理的精确匹配校验，才做到零编造。",[14,6000,6001,6002,6005],{},"经验：",[72,6003,6004],{},"prompt 约束是第一道防线，后处理校验是兜底","。两道都不能少。只靠 prompt 就像只靠自律——大部分时候有用，但总会翻车。",[123,6007,6009],{"id":6008},"坑-2faiss-索引的冷启动","坑 2：FAISS 索引的冷启动",[14,6011,6012],{},"525MB 的索引文件，首次加载要几秒。改用 mmap 后 RSS 占用从几个 GB 降到几十 MB，但第一次查询仍然有冷启动延迟（需要把向量数据从磁盘读入页缓存）。",[14,6014,6015],{},"解决方案：服务启动时做一次空查询\"预热\"，把常用页拉入缓存。",[188,6017,6019],{"className":5290,"code":6018,"language":5292,"meta":193,"style":193},"# 服务启动后预热FAISS索引\nwarmup_vec = np.zeros((1, dim), dtype=np.float32)\nindex.search(warmup_vec, k=1)  # 触发页缓存加载\n",[87,6020,6021,6026,6031],{"__ignoreMap":193},[197,6022,6023],{"class":199,"line":200},[197,6024,6025],{},"# 服务启动后预热FAISS索引\n",[197,6027,6028],{"class":199,"line":207},[197,6029,6030],{},"warmup_vec = np.zeros((1, dim), dtype=np.float32)\n",[197,6032,6033],{"class":199,"line":244},[197,6034,6035],{},"index.search(warmup_vec, k=1)  # 触发页缓存加载\n",[123,6037,6039],{"id":6038},"坑-3embedding-查询缓存的必要性","坑 3：Embedding 查询缓存的必要性",[14,6041,6042],{},"相同输入（同姓氏+同性别+同风格）的查询会反复出现。为 query embedding 加了 LRU 缓存（maxsize=128），命中率出乎意料地高——同一个 session 内的\"换一批\"操作，只有方向词在变，基础 query 的 embedding 可以复用。",[64,6044,6046],{"id":6045},"六实际效果","六、实际效果",[14,6048,6049,6050,471,6053,6057],{},"以上技术方案已经落地为一个完整产品——",[72,6051,6052],{},"文间拾字",[18,6054,6056],{"href":6055},"\u002Fprojects\u002Fchinese-poetry-give-name","项目页","）。展示几张实际运行效果：",[14,6059,6060,6063],{},[72,6061,6062],{},"输入界面","：用户填写姓氏、性别、出生时间，选择风格偏好：",[14,6065,6066],{},[43,6067],{"alt":6068,"src":6069},"古诗词AI取名工具的输入界面：姓氏、性别、风格偏好与偏好描述表单","https:\u002F\u002Fname.os.bennywen.xyz\u002Fbennywen.xyz\u002Fassets\u002Fchinese-poetry-rag\u002Fhome.png?imageMogr2\u002Fthumbnail\u002F1200x\u002Fformat\u002Fwebp\u002Fquality\u002F88",[14,6071,6072],{},[50,6073,6074],{},"图 1：输入界面。表单式与对话式两种入口，风格偏好决定后续检索的意象方向。",[14,6076,6077,6080],{},[72,6078,6079],{},"生成过程","：通过 SSE 流式推送，名字逐个\"跳出来\"，加载期间展示古诗词轮播：",[14,6082,6083],{},[43,6084],{"alt":6085,"src":6086},"生成过程：加载期间轮播古诗词，已生成的名字卡片逐个出现","https:\u002F\u002Fname.os.bennywen.xyz\u002Fbennywen.xyz\u002Fassets\u002Fchinese-poetry-rag\u002Fgenerating.png?imageMogr2\u002Fthumbnail\u002F1200x\u002Fformat\u002Fwebp\u002Fquality\u002F88",[14,6088,6089],{},[50,6090,6091],{},"图 2：生成过程。加载期间轮播古诗词，进度条上方标出当前处在\"检索诗词\"还是\"组合名字\"阶段。",[14,6093,6094,6097],{},[72,6095,6096],{},"结果展示","：每个名字附带完整信息——诗词出处（朝代+作者+原句）、五行标注、音韵分析、取名思路：",[14,6099,6100],{},[43,6101],{"alt":6102,"src":6103},"取名结果：每个名字附带诗词出处、寓意解析与音律标注","https:\u002F\u002Fname.os.bennywen.xyz\u002Fbennywen.xyz\u002Fassets\u002Fchinese-poetry-rag\u002Fresult.png?imageMogr2\u002Fthumbnail\u002F1200x\u002Fformat\u002Fwebp\u002Fquality\u002F88",[14,6105,6106],{},[50,6107,6108],{},"图 3：取名结果。每个名字都有朝代+作者+原句可查，下面跟着逐字释义与音律标注。",[14,6110,6111],{},"每个名字卡片的信息密度很高：诗词原文保证有据可查，五行标注让用户知道\"补了什么\"，音韵分析让用户知道\"念起来好不好听\"，取名思路让用户理解\"为什么选这两个字\"。",[64,6113,6115],{"id":6114},"七写在最后","七、写在最后",[14,6117,6118,6119,2001],{},"这个系统的核心思路是：",[72,6120,6121],{},"用 RAG 架构约束 LLM 的幻觉，用向量检索保证诗词出处的真实性，用域知识（八字五行）增强检索的相关性，用多层过滤保证名字的质量",[14,6123,6124],{},"技术栈并不复杂——FAISS、Embedding、LLM、SQLite，都是成熟的工具。真正的难点在于把\"古诗词取名\"这个垂直领域的业务逻辑（五行、音韵、五格）和技术架构（向量检索、流式输出、质量过滤）有机地融合在一起。",[14,6126,6127],{},"如果你也在做 RAG 相关的项目，欢迎一起聊聊。特别是\"如何约束 LLM 不编造引用源\"这个问题，很想听听大家的方案。",[1667,6129],{},[123,6131,6132],{"id":6132},"体验一下",[14,6134,6135],{},"上面拆解的所有技术，都已经跑在这个产品里了：",[11,6137,6138,6144],{},[14,6139,6140,6143],{},[72,6141,6142],{},"微信搜索小程序「文间拾字」"," 即可体验。",[14,6145,6146],{},"收录《诗经》《楚辞》《全唐诗》《宋词》等经典，所有名字 100% 有诗词原文出处，零编造。",[14,6148,6149],{},"如果你身边有正在给宝宝起名的朋友，可以把这个工具分享给他们——比起花钱找大师，不如让 AI 翻遍 270 万句古诗词，给名字找一个真正有据可查的出处。",[14,6151,6152,6153,6156],{},"（文中代码节选自个人项目 ",[18,6154,6155],{"href":6055},"chinese-poetry-give-name","，有删减；文中索引规模与模型配置以写稿时为准。）",[1512,6158,6159],{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-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.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 pre.shiki code .si27w, html code.shiki .si27w{--shiki-default:#6A737D;--shiki-light:#6A737D}html pre.shiki code .spKkM, html code.shiki .spKkM{--shiki-default:#F97583;--shiki-light:#D73A49}html pre.shiki code .sqoU-, html code.shiki .sqoU-{--shiki-default:#B392F0;--shiki-light:#6F42C1}html pre.shiki code .shWlK, html code.shiki .shWlK{--shiki-default:#E1E4E8;--shiki-light:#24292E}html pre.shiki code .sTU5a, html code.shiki .sTU5a{--shiki-default:#79B8FF;--shiki-light:#005CC5}",{"title":193,"searchDepth":207,"depth":207,"links":6161},[6162,6163,6168,6173,6181,6186,6187],{"id":5191,"depth":207,"text":5192},{"id":5279,"depth":207,"text":5280,"children":6164},[6165,6166,6167],{"id":5283,"depth":244,"text":5284},{"id":5333,"depth":244,"text":5334},{"id":5460,"depth":244,"text":5461},{"id":5535,"depth":207,"text":5536,"children":6169},[6170,6171],{"id":5539,"depth":244,"text":5540},{"id":5562,"depth":244,"text":6172},"3.2 \u003C> 流式解析",{"id":5845,"depth":207,"text":5846,"children":6174},[6175,6176,6177,6178,6179,6180],{"id":5852,"depth":244,"text":5853},{"id":5868,"depth":244,"text":5869},{"id":5875,"depth":244,"text":5876},{"id":5897,"depth":244,"text":5898},{"id":5933,"depth":244,"text":5934},{"id":5980,"depth":244,"text":5981},{"id":5990,"depth":207,"text":5991,"children":6182},[6183,6184,6185],{"id":5994,"depth":244,"text":5995},{"id":6008,"depth":244,"text":6009},{"id":6038,"depth":244,"text":6039},{"id":6045,"depth":207,"text":6046},{"id":6114,"depth":207,"text":6115,"children":6188},[6189],{"id":6132,"depth":244,"text":6132},"2026-10-15",{},"\u002Fposts\u002Fchinese-poetry-rag",{"title":5168,"description":193},"chinese-poetry-rag","posts\u002Fchinese-poetry-rag","用 LLM 取名的最大问题不是\"不好听\"，而是它会编造出处。这套系统的做法是：让每个名字都必须命中真实可检索的古诗词原文——FAISS 向量检索、把八字五行注入检索 query、自定义分隔符做流式增量解析，最后六层质量过滤兜底。附让 LLM 不编造的两道防线。",[6198,6199,5237],"AI 应用","RAG","MhOlrECPOtKT9rS8tphoYSkLsg82UXhKWJYuFjh9DvQ",{"id":6202,"title":6203,"body":6204,"cover":7330,"date":7331,"description":193,"extension":1527,"links":1525,"meta":7332,"navigation":1529,"path":7333,"project":6226,"seo":7334,"slug":7335,"stem":7336,"summary":7337,"tags":7338,"visible":1529,"__hash__":7340},"posts\u002Fposts\u002Fecom-detail-ai-part3-pitfalls.md","一个人做 AI 产品，我踩过的 9 个真实的坑",{"type":8,"value":6205,"toc":7318},[6206,6220,6235,6239,6245,6251,6257,6262,6296,6304,6310,6313,6319,6324,6328,6333,6338,6343,6414,6421,6425,6430,6435,6440,6621,6636,6640,6645,6650,6659,6804,6807,6811,6816,6825,6830,6977,6980,6984,6989,6998,7007,7131,7146,7150,7168,7188,7191,7195,7207,7212,7228,7231,7238,7244,7248,7253,7258,7271,7275,7290,7293,7299,7304,7315],[11,6207,6208],{},[14,6209,6210,6211,6215,6216,6219],{},"系列说明：这是「AI 详情页生成工具」系列的第 3 篇，踩坑复盘。前两篇分别讲了",[18,6212,6214],{"href":6213},"\u002Fblog\u002Fecom-detail-ai-part1-architecture","架构","（多模型 Agent 流水线）和",[18,6217,6218],{"href":20},"搭建","（Wails + Go + Vue3），这篇全是血泪：每一个坑都对应项目里一段现在还在跑的代码。文中代码节选自项目源码，有删减。",[14,6221,6222,6223,6227,6228,6231,6232,2001],{},"先交代背景：我一个人做的桌面端 AI 工具（",[18,6224,6226],{"href":6225},"\u002Fprojects\u002Fecom-detail-ai","ecom-detail-ai","），上传 1 张商品图，自动跑一条\"读图→规划→生图→文案→排版→自检\"的流水线，产出整套淘宝详情页。LLM 按 token 计费、生图按张计费，全是用户自己的账号出钱。这个业态决定了踩坑的两大主题：",[72,6229,6230],{},"幻觉","和",[72,6233,6234],{},"钱",[64,6236,6238],{"id":6237},"坑-1让-ai-在图里写字它给你一屏甲骨文","坑 1：让 AI 在图里写字，它给你一屏甲骨文",[14,6240,6241,6244],{},[72,6242,6243],{},"现象","：详情页大量依赖文字排版（标题、卖点、参数标注）。早期我按直觉写提示词：\"画面左上角写上'轻松收纳'四个艺术字\"。Seedream 和 GLM-Image 交出来的图，字形扭曲、笔画乱炖，放大看像甲骨文他舅——生成式模型画文字至今依然是重灾区，而且越\"艺术字\"越完蛋。",[14,6246,6247,6250],{},[72,6248,6249],{},"原因","：扩散模型不是\"写字\"，是在\"画字的形状\"，字符结构对它来说就是纹理。你禁不掉它画糊。",[14,6252,6253,6256],{},[72,6254,6255],{},"解法","：彻底把文字从 AI 手里收走，三层设防：",[77,6258,6259],{},[80,6260,6261],{},"规划提示词写入铁律，场景描述不得携带\"画文字\"的意图：",[188,6263,6265],{"className":190,"code":6264,"language":192,"meta":193,"style":193},"const planNoTextRule = \"画面中禁止出现任何文字、数字、参数标注、标语或标签贴纸；\" +\n    \"prompt 只描述商品、场景、氛围与构图，不得要求画面书写、展示或标注任何文字\" +\n    \"（商品自身印刷的文字除外，那部分会被逐字保留）。\"\n",[87,6266,6267,6284,6291],{"__ignoreMap":193},[197,6268,6269,6272,6275,6278,6281],{"class":199,"line":200},[197,6270,6271],{"class":210},"const",[197,6273,6274],{"class":281}," planNoTextRule",[197,6276,6277],{"class":210}," =",[197,6279,6280],{"class":256}," \"画面中禁止出现任何文字、数字、参数标注、标语或标签贴纸；\"",[197,6282,6283],{"class":210}," +\n",[197,6285,6286,6289],{"class":199,"line":207},[197,6287,6288],{"class":256},"    \"prompt 只描述商品、场景、氛围与构图，不得要求画面书写、展示或标注任何文字\"",[197,6290,6283],{"class":210},[197,6292,6293],{"class":199,"line":244},[197,6294,6295],{"class":256},"    \"（商品自身印刷的文字除外，那部分会被逐字保留）。\"\n",[77,6297,6298,6301],{"start":207},[80,6299,6300],{},"渲染品质段再显式禁一次。注意第一版措辞是\"无文字水印\"，模型可能理解为\"不要水印\"然后照画文字——禁令必须精确。现在的写法：\"画面中不得出现任何文字、数字、标注、标签贴纸（商品本身印刷的文字除外）\"。",[80,6302,6303],{},"真实文字由本机 Go 排版引擎用系统 CJK 字体后期叠加。",[14,6305,6306,6309],{},[72,6307,6308],{},"反直觉的点","：加了这个\"AI 画面零文字\"约束后，出图质量的方差明显变小了。文字是生成图上最容易露怯的元素，拿掉它，整体观感反而更\"贵\"。",[14,6311,6312],{},"下面是修复后的实际效果——AI 只负责画面（干净得一个字都没有），参数表和所有文字都是本机排版引擎用真实字体叠上去的：",[14,6314,6315],{},[43,6316],{"alt":6317,"src":6318},"详情切片：参数表与文案由排版引擎叠加，画面区无任何 AI 生成文字","https:\u002F\u002Fname.os.bennywen.xyz\u002Fbennywen.xyz\u002Fassets\u002Fecom-detail-ai-part3-pitfalls\u002Fslice-02.jpg?imageMogr2\u002Fformat\u002Fwebp\u002Fquality\u002F88",[14,6320,6321],{},[50,6322,6323],{},"详情切片：参数表与文案由排版引擎叠加，画面区无任何 AI 生成文字。",[64,6325,6327],{"id":6326},"坑-2同参数重试是烧钱行为重试必须带诊断","坑 2：同参数重试是烧钱行为，重试必须带诊断",[14,6329,6330,6332],{},[72,6331,6243],{},"：自检环节发现图不合格就重试一次，听起来很合理。但第一版重试是原样重跑，结果第二张和第一张犯一模一样的错——商品左上角的提手照样变形。",[14,6334,6335,6337],{},[72,6336,6249],{},"：同样的 prompt + 同样的模型，采样温度再高也只是换种方式错。",[14,6339,6340,6342],{},[72,6341,6255],{},"：自检输出结构化诊断标签（product_drift \u002F structure_deform \u002F light_off \u002F text_error \u002F scene_mismatch），重渲染时把上一轮的失败原因翻译成人话回灌进 prompt：",[188,6344,6346],{"className":190,"code":6345,"language":192,"meta":193,"style":193},"\u002F\u002F 同参数重跑没有意义：重试必须带上上一轮的自检诊断\nprompt := basePrompt\nif lastCheck != nil {\n    prompt += \"\\n注意：上一轮生成存在问题：\" + checkTagsSummary(lastCheck) +\n        \"。重新生成时务必修正，其余要求不变。\"\n}\n",[87,6347,6348,6353,6363,6376,6405,6410],{"__ignoreMap":193},[197,6349,6350],{"class":199,"line":200},[197,6351,6352],{"class":203},"\u002F\u002F 同参数重跑没有意义：重试必须带上上一轮的自检诊断\n",[197,6354,6355,6358,6360],{"class":199,"line":207},[197,6356,6357],{"class":218},"prompt ",[197,6359,250],{"class":210},[197,6361,6362],{"class":218}," basePrompt\n",[197,6364,6365,6367,6370,6372,6374],{"class":199,"line":244},[197,6366,1259],{"class":210},[197,6368,6369],{"class":218}," lastCheck ",[197,6371,329],{"class":210},[197,6373,694],{"class":281},[197,6375,241],{"class":218},[197,6377,6378,6381,6384,6387,6390,6393,6396,6399,6402],{"class":199,"line":263},[197,6379,6380],{"class":218},"    prompt ",[197,6382,6383],{"class":210},"+=",[197,6385,6386],{"class":256}," \"",[197,6388,6389],{"class":281},"\\n",[197,6391,6392],{"class":256},"注意：上一轮生成存在问题：\"",[197,6394,6395],{"class":210}," +",[197,6397,6398],{"class":214}," checkTagsSummary",[197,6400,6401],{"class":218},"(lastCheck) ",[197,6403,6404],{"class":210},"+\n",[197,6406,6407],{"class":199,"line":275},[197,6408,6409],{"class":256},"        \"。重新生成时务必修正，其余要求不变。\"\n",[197,6411,6412],{"class":199,"line":285},[197,6413,464],{"class":218},[14,6415,6416,6417,6420],{},"\"商品与原图不一致、光影违和\"这种具体指控，模型是听进去的。同类思想也用在了首屏双候选上：两个候选必须是",[72,6418,6419],{},"不同构图","（规划构图 vs 正面平视），不同参数的候选才叫候选，同参数抽两次卡叫赌博。",[64,6422,6424],{"id":6423},"坑-3429-限流把整条流水线打瘫","坑 3：429 限流把整条流水线打瘫",[14,6426,6427,6429],{},[72,6428,6243],{},"：新注册的图像生成账号 RPM 低到感人（glm-image 初期几乎只能串行），一晚上批量测试，任务大面积失败，报错全是\"速率限制\"。",[14,6431,6432,6434],{},[72,6433,6249],{},"：Agent 流水线各环节的调用频率天然不均，生图一步连发 N 张，直接撞墙；撞墙后代码里的错误判断又识别不出这是\"可以等的错\"还是\"该死的错\"。",[14,6436,6437,6439],{},[72,6438,6255],{},"：错误特征识别 + 指数退避 + 可取消的等待：",[188,6441,6443],{"className":190,"code":6442,"language":192,"meta":193,"style":193},"var rateLimitBackoff = []time.Duration{10 * time.Second, 20 * time.Second,\n    40 * time.Second, 60 * time.Second}\n\nfunc isRateLimitError(err error) bool {\n    msg := err.Error()\n    return strings.Contains(msg, \"速率限制\") || strings.Contains(msg, \"rate limit\") ||\n        strings.Contains(msg, \"429\") || strings.Contains(msg, \"RequestLimitExceeded\") ||\n        strings.Contains(msg, \"Throttling\")\n}\n",[87,6444,6445,6484,6501,6505,6526,6541,6575,6604,6617],{"__ignoreMap":193},[197,6446,6447,6450,6453,6455,6458,6461,6463,6466,6468,6471,6473,6476,6479,6481],{"class":199,"line":200},[197,6448,6449],{"class":210},"var",[197,6451,6452],{"class":218}," rateLimitBackoff ",[197,6454,781],{"class":210},[197,6456,6457],{"class":218}," []",[197,6459,6460],{"class":214},"time",[197,6462,229],{"class":218},[197,6464,6465],{"class":214},"Duration",[197,6467,2145],{"class":218},[197,6469,6470],{"class":281},"10",[197,6472,519],{"class":210},[197,6474,6475],{"class":218}," time.Second, ",[197,6477,6478],{"class":281},"20",[197,6480,519],{"class":210},[197,6482,6483],{"class":218}," time.Second,\n",[197,6485,6486,6489,6491,6493,6496,6498],{"class":199,"line":207},[197,6487,6488],{"class":281},"    40",[197,6490,519],{"class":210},[197,6492,6475],{"class":218},[197,6494,6495],{"class":281},"60",[197,6497,519],{"class":210},[197,6499,6500],{"class":218}," time.Second}\n",[197,6502,6503],{"class":199,"line":244},[197,6504,2155],{"emptyLinePlaceholder":1529},[197,6506,6507,6509,6512,6514,6517,6520,6522,6524],{"class":199,"line":263},[197,6508,211],{"class":210},[197,6510,6511],{"class":214}," isRateLimitError",[197,6513,219],{"class":218},[197,6515,6516],{"class":222},"err",[197,6518,6519],{"class":210}," error",[197,6521,235],{"class":218},[197,6523,238],{"class":210},[197,6525,241],{"class":218},[197,6527,6528,6531,6533,6536,6539],{"class":199,"line":275},[197,6529,6530],{"class":218},"    msg ",[197,6532,250],{"class":210},[197,6534,6535],{"class":218}," err.",[197,6537,6538],{"class":214},"Error",[197,6540,2198],{"class":218},[197,6542,6543,6545,6548,6551,6554,6557,6559,6561,6563,6565,6567,6570,6572],{"class":199,"line":285},[197,6544,450],{"class":210},[197,6546,6547],{"class":218}," strings.",[197,6549,6550],{"class":214},"Contains",[197,6552,6553],{"class":218},"(msg, ",[197,6555,6556],{"class":256},"\"速率限制\"",[197,6558,235],{"class":218},[197,6560,323],{"class":210},[197,6562,6547],{"class":218},[197,6564,6550],{"class":214},[197,6566,6553],{"class":218},[197,6568,6569],{"class":256},"\"rate limit\"",[197,6571,235],{"class":218},[197,6573,6574],{"class":210},"||\n",[197,6576,6577,6580,6582,6584,6587,6589,6591,6593,6595,6597,6600,6602],{"class":199,"line":291},[197,6578,6579],{"class":218},"        strings.",[197,6581,6550],{"class":214},[197,6583,6553],{"class":218},[197,6585,6586],{"class":256},"\"429\"",[197,6588,235],{"class":218},[197,6590,323],{"class":210},[197,6592,6547],{"class":218},[197,6594,6550],{"class":214},[197,6596,6553],{"class":218},[197,6598,6599],{"class":256},"\"RequestLimitExceeded\"",[197,6601,235],{"class":218},[197,6603,6574],{"class":210},[197,6605,6606,6608,6610,6612,6615],{"class":199,"line":313},[197,6607,6579],{"class":218},[197,6609,6550],{"class":214},[197,6611,6553],{"class":218},[197,6613,6614],{"class":256},"\"Throttling\"",[197,6616,310],{"class":218},[197,6618,6619],{"class":199,"line":349},[197,6620,464],{"class":218},[14,6622,6623,6624,6627,6628,6631,6632,6635],{},"三个细节：退避等待必须用 ",[87,6625,6626],{},"select"," 同时监听 ",[87,6629,6630],{},"ctx.Done()","，否则用户点了取消还得干等 60 秒；退避状态要推进度事件（\"触发限流，20 秒后重试（2\u002F4）\"），用户看到会理解，看不到会以为程序死了；默认渲染并发直接锁 1，等账号配额养起来再调。另外各家的限流错误文案五花八门，",[87,6633,6634],{},"isRateLimitError"," 这种\"错误特征识别\"函数要留好持续补词的空间。",[64,6637,6639],{"id":6638},"坑-4模型评委爱放水服务端必须设硬门禁","坑 4：模型评委爱放水，服务端必须设硬门禁",[14,6641,6642,6644],{},[72,6643,6243],{},"：自检提示词让视觉模型当质检员判 pass\u002Ffail，模型非常\"自信\"，肉眼可见商品变形了，它照样给 pass:true。",[14,6646,6647,6649],{},[72,6648,6249],{},"：多模态模型当判官有系统性的乐观偏置，你问它\"这图合格吗\"，它倾向于说合格。",[14,6651,6652,6654,6655,6658],{},[72,6653,6255],{},"：不信任模型的结论，只信任模型的",[72,6656,6657],{},"评分素材","。模型输出保持度分数和诊断标签后，服务端再做一道确定性门限：",[188,6660,6662],{"className":190,"code":6661,"language":192,"meta":193,"style":193},"\u002F\u002F enforceCheckGate：模型判 pass 之外再做一道硬校验\nconst checkFidelityGate = 0.95\n\nfunc enforceCheckGate(r CheckResult) CheckResult {\n    for _, tg := range r.Tags {\n        switch tg {\n        case TagProductDrift, TagStructureDeform, TagTextError:\n            r.Pass = false   \u002F\u002F 硬伤 tag 一票否决\n        }\n    }\n    if r.Fidelity > 0 && r.Fidelity \u003C checkFidelityGate {\n        r.Pass = false\n    }\n    return r\n}\n",[87,6663,6664,6669,6681,6685,6707,6722,6730,6738,6750,6755,6759,6780,6789,6793,6800],{"__ignoreMap":193},[197,6665,6666],{"class":199,"line":200},[197,6667,6668],{"class":203},"\u002F\u002F enforceCheckGate：模型判 pass 之外再做一道硬校验\n",[197,6670,6671,6673,6676,6678],{"class":199,"line":207},[197,6672,6271],{"class":210},[197,6674,6675],{"class":281}," checkFidelityGate",[197,6677,6277],{"class":210},[197,6679,6680],{"class":281}," 0.95\n",[197,6682,6683],{"class":199,"line":244},[197,6684,2155],{"emptyLinePlaceholder":1529},[197,6686,6687,6689,6692,6694,6697,6700,6702,6705],{"class":199,"line":263},[197,6688,211],{"class":210},[197,6690,6691],{"class":214}," enforceCheckGate",[197,6693,219],{"class":218},[197,6695,6696],{"class":222},"r",[197,6698,6699],{"class":214}," CheckResult",[197,6701,235],{"class":218},[197,6703,6704],{"class":214},"CheckResult",[197,6706,241],{"class":218},[197,6708,6709,6712,6715,6717,6719],{"class":199,"line":275},[197,6710,6711],{"class":210},"    for",[197,6713,6714],{"class":218}," _, tg ",[197,6716,250],{"class":210},[197,6718,990],{"class":210},[197,6720,6721],{"class":218}," r.Tags {\n",[197,6723,6724,6727],{"class":199,"line":285},[197,6725,6726],{"class":210},"        switch",[197,6728,6729],{"class":218}," tg {\n",[197,6731,6732,6735],{"class":199,"line":291},[197,6733,6734],{"class":210},"        case",[197,6736,6737],{"class":218}," TagProductDrift, TagStructureDeform, TagTextError:\n",[197,6739,6740,6743,6745,6747],{"class":199,"line":313},[197,6741,6742],{"class":218},"            r.Pass ",[197,6744,781],{"class":210},[197,6746,354],{"class":281},[197,6748,6749],{"class":203},"   \u002F\u002F 硬伤 tag 一票否决\n",[197,6751,6752],{"class":199,"line":349},[197,6753,6754],{"class":218},"        }\n",[197,6756,6757],{"class":199,"line":360},[197,6758,288],{"class":218},[197,6760,6761,6763,6766,6768,6770,6772,6774,6777],{"class":199,"line":365},[197,6762,266],{"class":210},[197,6764,6765],{"class":218}," r.Fidelity ",[197,6767,637],{"class":210},[197,6769,610],{"class":281},[197,6771,335],{"class":210},[197,6773,6765],{"class":218},[197,6775,6776],{"class":210},"\u003C",[197,6778,6779],{"class":218}," checkFidelityGate {\n",[197,6781,6782,6785,6787],{"class":199,"line":390},[197,6783,6784],{"class":218},"        r.Pass ",[197,6786,781],{"class":210},[197,6788,282],{"class":281},[197,6790,6791],{"class":199,"line":417},[197,6792,288],{"class":218},[197,6794,6795,6797],{"class":199,"line":427},[197,6796,450],{"class":210},[197,6798,6799],{"class":218}," r\n",[197,6801,6802],{"class":199,"line":432},[197,6803,464],{"class":218},[14,6805,6806],{},"判分标准和提示词里写死的规则（\"fidelity\u003C0.95 或出现硬伤 tag 时 pass=false\"）两边对齐，模型就算口头放水，数字和标签也拦得住。这个\"LLM 打分 + 确定性门限\"的三明治结构，适用于一切用 LLM 做评估的场景。",[64,6808,6810],{"id":6809},"坑-5批量任务连环烧钱直到我装了断路器","坑 5：批量任务连环烧钱，直到我装了\"断路器\"",[14,6812,6813,6815],{},[72,6814,6243],{},"：内测同事导入了 60 个 SKU 的批量任务，密钥过期了。第一个任务鉴权失败，第二个、第三个……六十个任务排着队把能调的接口挨个调挂，钱和情绪双双受损。",[14,6817,6818,6820,6821,6824],{},[72,6819,6249],{},"：任务队列对\"系统性失败\"和\"个别失败\"没有区分能力。个别任务失败是常态，但",[72,6822,6823],{},"同一批次连续失败","几乎必然意味着环境问题（密钥失效、余额耗尽、断网）——这时候继续跑就是纯烧钱。",[14,6826,6827,6829],{},[72,6828,6255],{},"：调度器里加了一个断路器，规则简单粗暴——同批次连续 3 条 failed，整个批次自动暂停：",[188,6831,6833],{"className":190,"code":6832,"language":192,"meta":193,"style":193},"const batchConsecFailPause = 3\n\nswitch task.Status {\ncase \"done\":\n    s.batchFail[task.BatchID] = 0        \u002F\u002F 成功一次，计数清零\ncase \"failed\":\n    s.batchFail[task.BatchID]++\n    if s.batchFail[task.BatchID] >= batchConsecFailPause {\n        s.db.Model(&model.GenerationBatch{}).Where(\"id = ?\", task.BatchID).\n            Update(\"status\", \"paused\")   \u002F\u002F 停止该批出队\n    }\n}   \u002F\u002F canceled 不计入连续失败\n",[87,6834,6835,6847,6851,6859,6870,6882,6891,6898,6910,6944,6965,6969],{"__ignoreMap":193},[197,6836,6837,6839,6842,6844],{"class":199,"line":200},[197,6838,6271],{"class":210},[197,6840,6841],{"class":281}," batchConsecFailPause",[197,6843,6277],{"class":210},[197,6845,6846],{"class":281}," 3\n",[197,6848,6849],{"class":199,"line":207},[197,6850,2155],{"emptyLinePlaceholder":1529},[197,6852,6853,6856],{"class":199,"line":244},[197,6854,6855],{"class":210},"switch",[197,6857,6858],{"class":218}," task.Status {\n",[197,6860,6861,6864,6867],{"class":199,"line":263},[197,6862,6863],{"class":210},"case",[197,6865,6866],{"class":256}," \"done\"",[197,6868,6869],{"class":218},":\n",[197,6871,6872,6875,6877,6879],{"class":199,"line":275},[197,6873,6874],{"class":218},"    s.batchFail[task.BatchID] ",[197,6876,781],{"class":210},[197,6878,610],{"class":281},[197,6880,6881],{"class":203},"        \u002F\u002F 成功一次，计数清零\n",[197,6883,6884,6886,6889],{"class":199,"line":285},[197,6885,6863],{"class":210},[197,6887,6888],{"class":256}," \"failed\"",[197,6890,6869],{"class":218},[197,6892,6893,6896],{"class":199,"line":291},[197,6894,6895],{"class":218},"    s.batchFail[task.BatchID]",[197,6897,1048],{"class":210},[197,6899,6900,6902,6905,6907],{"class":199,"line":313},[197,6901,266],{"class":210},[197,6903,6904],{"class":218}," s.batchFail[task.BatchID] ",[197,6906,898],{"class":210},[197,6908,6909],{"class":218}," batchConsecFailPause {\n",[197,6911,6912,6915,6918,6920,6922,6925,6927,6930,6933,6936,6938,6941],{"class":199,"line":349},[197,6913,6914],{"class":218},"        s.db.",[197,6916,6917],{"class":214},"Model",[197,6919,219],{"class":218},[197,6921,842],{"class":210},[197,6923,6924],{"class":214},"model",[197,6926,229],{"class":218},[197,6928,6929],{"class":214},"GenerationBatch",[197,6931,6932],{"class":218},"{}).",[197,6934,6935],{"class":214},"Where",[197,6937,219],{"class":218},[197,6939,6940],{"class":256},"\"id = ?\"",[197,6942,6943],{"class":218},", task.BatchID).\n",[197,6945,6946,6949,6951,6954,6956,6959,6962],{"class":199,"line":360},[197,6947,6948],{"class":214},"            Update",[197,6950,219],{"class":218},[197,6952,6953],{"class":256},"\"status\"",[197,6955,513],{"class":218},[197,6957,6958],{"class":256},"\"paused\"",[197,6960,6961],{"class":218},")   ",[197,6963,6964],{"class":203},"\u002F\u002F 停止该批出队\n",[197,6966,6967],{"class":199,"line":365},[197,6968,288],{"class":218},[197,6970,6971,6974],{"class":199,"line":390},[197,6972,6973],{"class":218},"}   ",[197,6975,6976],{"class":203},"\u002F\u002F canceled 不计入连续失败\n",[14,6978,6979],{},"三个口径设计：成功一条就清零（说明只是偶发失败）；用户取消不计入（那是主动行为不是故障）；暂停不杀进程，已付费跑完的任务照常结算，只拦后续出队。UI 上给\"疑似密钥\u002F余额问题\"的提示，用户检查后点继续，队列重新入队恢复。任何会自动消耗真金白银的批处理系统，都应该在第一天就装这个断路器。",[64,6981,6983],{"id":6982},"坑-6状态机的竞态白烧一张图-01-元事小脸被打得疼","坑 6：状态机的竞态，白烧一张图 0.1 元（事小，脸被打得疼）",[14,6985,6986,6988],{},[72,6987,6243],{},"：偶发一种诡异日志：任务状态是 canceled，但产物目录里躺着刚生成的图。用户在\"排队中\"点了取消，任务还是跑了。",[14,6990,6991,6993,6994,6997],{},[72,6992,6249],{},"：调度器出队后 ",[87,6995,6996],{},"go runTask(id)","，而 runTask 开头要查库、读配置，有好几毫秒窗口；用户恰好在这个窗口点取消——取消逻辑看任务还没跑起来，直接改库置 canceled，dispatcher 也根本不知道要拦这个 goroutine。两边都没错，合起来错了。",[14,6999,7000,7002,7003,7006],{},[72,7001,6255],{},"：把\"开始执行\"做成一次",[72,7004,7005],{},"条件更新","，用数据库解决竞态：",[188,7008,7010],{"className":190,"code":7009,"language":192,"meta":193,"style":193},"\u002F\u002F 条件更新（仅 pending 才置 running）：保证「排队中取消」必然生效\nres := s.db.Model(&model.GenerationTask{}).\n    Where(\"id = ? AND status = ?\", taskID, \"pending\").\n    Updates(map[string]interface{}{\"status\": \"running\", \"started_at\": nowMillis()})\nif res.RowsAffected == 0 {\n    return   \u002F\u002F 已被取消或已到终态：不执行、不产生任何成本\n}\n",[87,7011,7012,7017,7043,7062,7107,7120,7127],{"__ignoreMap":193},[197,7013,7014],{"class":199,"line":200},[197,7015,7016],{"class":203},"\u002F\u002F 条件更新（仅 pending 才置 running）：保证「排队中取消」必然生效\n",[197,7018,7019,7022,7024,7027,7029,7031,7033,7035,7037,7040],{"class":199,"line":207},[197,7020,7021],{"class":218},"res ",[197,7023,250],{"class":210},[197,7025,7026],{"class":218}," s.db.",[197,7028,6917],{"class":214},[197,7030,219],{"class":218},[197,7032,842],{"class":210},[197,7034,6924],{"class":214},[197,7036,229],{"class":218},[197,7038,7039],{"class":214},"GenerationTask",[197,7041,7042],{"class":218},"{}).\n",[197,7044,7045,7048,7050,7053,7056,7059],{"class":199,"line":244},[197,7046,7047],{"class":214},"    Where",[197,7049,219],{"class":218},[197,7051,7052],{"class":256},"\"id = ? AND status = ?\"",[197,7054,7055],{"class":218},", taskID, ",[197,7057,7058],{"class":256},"\"pending\"",[197,7060,7061],{"class":218},").\n",[197,7063,7064,7067,7069,7072,7075,7078,7081,7083,7086,7088,7091,7094,7096,7099,7101,7104],{"class":199,"line":263},[197,7065,7066],{"class":214},"    Updates",[197,7068,219],{"class":218},[197,7070,7071],{"class":210},"map",[197,7073,7074],{"class":218},"[",[197,7076,7077],{"class":210},"string",[197,7079,7080],{"class":218},"]",[197,7082,2096],{"class":210},[197,7084,7085],{"class":218},"{}{",[197,7087,6953],{"class":256},[197,7089,7090],{"class":218},": ",[197,7092,7093],{"class":256},"\"running\"",[197,7095,513],{"class":218},[197,7097,7098],{"class":256},"\"started_at\"",[197,7100,7090],{"class":218},[197,7102,7103],{"class":214},"nowMillis",[197,7105,7106],{"class":218},"()})\n",[197,7108,7109,7111,7114,7116,7118],{"class":199,"line":275},[197,7110,1259],{"class":210},[197,7112,7113],{"class":218}," res.RowsAffected ",[197,7115,3788],{"class":210},[197,7117,610],{"class":281},[197,7119,241],{"class":218},[197,7121,7122,7124],{"class":199,"line":285},[197,7123,450],{"class":210},[197,7125,7126],{"class":203},"   \u002F\u002F 已被取消或已到终态：不执行、不产生任何成本\n",[197,7128,7129],{"class":199,"line":291},[197,7130,464],{"class":218},[14,7132,7133,7134,7137,7138,7141,7142,7145],{},"谁先改库谁赢，输家看到 ",[87,7135,7136],{},"RowsAffected == 0"," 安静退出。取消排队中任务同样用条件更新（",[87,7139,7140],{},"WHERE status='pending'"," 才置 canceled），双向都安全。教训：",[72,7143,7144],{},"进程间\u002F协程间的状态仲裁，与其靠内存锁和时序推理，不如把数据库当线性化寄存器用","——桌面应用自带 SQLite，这资源不用白不用。",[64,7147,7149],{"id":7148},"坑-7本地文件当服务入参被目录穿越和文件消失教育了两次","坑 7：本地文件当服务入参，被目录穿越和文件消失教育了两次",[14,7151,7152,7155,7156,7159,7160,7163,7164,7167],{},[72,7153,7154],{},"现象 A","：前端要预览数据目录里各处的生成图，我挂了个 ",[87,7157,7158],{},"\u002Flocal\u002F"," 静态服务，安全同学（我自己）一眼看到没做路径清洗——",[87,7161,7162],{},"\u002Flocal\u002F..\u002F..\u002Fetc\u002Fpasswd"," 全家桶。\n",[72,7165,7166],{},"现象 B","：用户创建任务后把商品原图挪走了\u002F重命名了，重试时报\"商品图不存在\"。任务还活着，素材先死了。",[14,7169,7170,7172,7173,7175,7176,7179,7180,7183,7184,7187],{},[72,7171,6255],{},"：A：",[87,7174,7158],{}," handler 统一 ",[87,7177,7178],{},"filepath.Clean"," 后强制校验 ",[87,7181,7182],{},"strings.HasPrefix(full, dataDir+separator)","，不在目录内一律 404。B：",[72,7185,7186],{},"创建任务时就把商品图复制进数据目录","（内容 sha1 去重），任务永远引用入库副本；导入失败才降级用原路径（只影响缩略图，不阻塞任务）。",[14,7189,7190],{},"顺带一提第三方服务的坑：阿里云商品分割接口只吃公网 URL，本地文件得先转存它的临时 OSS，返回的结果图 URL 也是带过期签名的临时地址，必须立即下载转存本机。对接任何云 API，先问自己三个问题：入参收不收本地文件？返回的链接活多久？endpoint 是哪个区域的（imageseg 只有上海域名，抄文档写北京直接查无此域名）。",[64,7192,7194],{"id":7193},"坑-8一个人写代码架构靠自觉是不可能的","坑 8：一个人写代码，架构靠自觉是不可能的",[14,7196,7197,7199,7200,7202,7203,7206],{},[72,7198,6243],{},"：项目快速迭代两周后，我发现 ",[87,7201,1657],{},"（Wails 绑定层）里开始出现直接调 gorm 的函数，",[87,7204,7205],{},"internal\u002Fagent"," 包里开始有人（还是我）import database。分层在 PPT 上，不在代码里。",[14,7208,7209,7211],{},[72,7210,6249],{},"：单人项目没人 review，\"就这一次破例\"是常态。写进文档的规范保质期约等于零。",[14,7213,7214,7216,7217,7220,7221,7223,7224,7227],{},[72,7215,6255],{},"：把架构规则编译成",[72,7218,7219],{},"会在 CI 里变红的测试","。项目里的 ",[87,7222,4817],{}," 包用 Go AST 解析源码做断言：绑定层不得 import gorm、model 层必须是叶子依赖、新依赖必须登记进 ",[87,7225,7226],{},"deps.yaml"," 且双向校验（清单里有的必须真实存在，存在的必须登记）。",[14,7229,7230],{},"护栏本身还有一条元规则，是护栏工程最精髓的一句话：",[11,7232,7233],{},[14,7234,7235],{},[72,7236,7237],{},"任何用正则\u002FAST 解析源码做断言的护栏，解析到 0 个结果必须报错，而非当作\"通过\"静默放行。",[14,7239,7240,7241,7243],{},"否则你把 ",[87,7242,1657],{}," 改名了，\"app.go 不许碰数据库\"这条护栏就会在零解析的状态下天天报绿——护栏瞎了，比没有护栏更危险，因为它提供虚假的安全感。所以每条护栏找不到解析目标时的报错文案都是统一的：\"写法可能已变更，请同步更新护栏解析规则\"。",[64,7245,7247],{"id":7246},"坑-9批量导入的垃圾进垃圾出被一个-csv-编码教做人","坑 9：批量导入的\"垃圾进垃圾出\"，被一个 CSV 编码教做人",[14,7249,7250,7252],{},[72,7251,6243],{},"：内测用户兴冲冲导入 60 个 SKU，任务列表里商品名全是\"鍟嗗搧\"这类火星文；还有人导入后一半行离奇消失，来问是不是 bug。",[14,7254,7255,7257],{},[72,7256,6249],{},"：国内 Excel\u002FWPS 另存 CSV 默认 GBK 编码，按 UTF-8 解析自然全是乱码；\"行消失\"则是图片路径列缺失或相对路径基准搞错，数据被静默跳过了。",[14,7259,7260,7262,7263,7266,7267,7270],{},[72,7261,6255],{},"：与其做编码嗅探（GBK\u002FUTF-8 误判率不低，判错就是整批数据污染），不如把规则收紧并显性化——",[72,7264,7265],{},"只收 UTF-8","，检测到非法编码直接报错提示\"另存为 CSV UTF-8 后重试\"；相对路径明确以 CSV 所在目录为基准；最重要的一个设计是",[72,7268,7269],{},"先预览再落库","：弹确认层展示有效行数、被跳过的行及原因、整批预估金额，用户确认后才建任务，且单批上限 200 行。批量入口的原则：宁可让用户多点一次确认，也不给他一个\"静默吃错数据\"的黑盒。",[64,7272,7274],{"id":7273},"尾声一些数字和一句总结","尾声：一些数字和一句总结",[1984,7276,7277,7280,7283],{},[80,7278,7279],{},"单套 5 图详情页实测约 8 分半，成本折算几毛钱到一块多（token + 出图张数逐任务落库，界面明示估算值）；",[80,7281,7282],{},"以上 9 个坑里，绝大多数发生在有真实用户之前的内测期，值钱；",[80,7284,7285,7286,7289],{},"最大的一条心得：",[72,7287,7288],{},"和 LLM 协作的全部工程智慧，浓缩成一句话就是\"别信它的输出，接管它的手脚\"","——输出必须过 schema 解析和确定性门限，手脚必须限定在结构化 JSON 和白名单事实源里，它负责聪明，你的代码负责靠谱。",[14,7291,7292],{},"最后放一张\"接管手脚\"的集大成产物——白底主图。它压根不走生成模型：抠图产物直接铺白，本机合成，零成本零变形：",[14,7294,7295],{},[43,7296],{"alt":7297,"src":7298},"白底主图：抠图产物本地铺白合成，不走生成模型","https:\u002F\u002Fname.os.bennywen.xyz\u002Fbennywen.xyz\u002Fassets\u002Fecom-detail-ai-part3-pitfalls\u002Fmain-white.jpg",[14,7300,7301],{},[50,7302,7303],{},"白底主图：抠图产物本地铺白合成，不走生成模型。",[14,7305,7306,7307,7310,7311,7314],{},"系列前两篇：",[18,7308,7309],{"href":6213},"第 1 篇《我用 Go 搭了一条 AI Agent 流水线》","、",[18,7312,7313],{"href":20},"第 2 篇《Wails v2 实战：用 Go + Vue3 做一个真正能用的 AI 桌面应用》","。欢迎聊聊你踩过的 AI 产品的坑。",[1512,7316,7317],{},"html pre.shiki code .spKkM, html code.shiki .spKkM{--shiki-default:#F97583;--shiki-light:#D73A49}html pre.shiki code .sTU5a, html code.shiki .sTU5a{--shiki-default:#79B8FF;--shiki-light:#005CC5}html pre.shiki code .skb7c, html code.shiki .skb7c{--shiki-default:#9ECBFF;--shiki-light:#032F62}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-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.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 pre.shiki code .si27w, html code.shiki .si27w{--shiki-default:#6A737D;--shiki-light:#6A737D}html pre.shiki code .shWlK, html code.shiki .shWlK{--shiki-default:#E1E4E8;--shiki-light:#24292E}html pre.shiki code .sqoU-, html code.shiki .sqoU-{--shiki-default:#B392F0;--shiki-light:#6F42C1}html pre.shiki code .sdxZB, html code.shiki .sdxZB{--shiki-default:#FFAB70;--shiki-light:#E36209}",{"title":193,"searchDepth":207,"depth":207,"links":7319},[7320,7321,7322,7323,7324,7325,7326,7327,7328,7329],{"id":6237,"depth":207,"text":6238},{"id":6326,"depth":207,"text":6327},{"id":6423,"depth":207,"text":6424},{"id":6638,"depth":207,"text":6639},{"id":6809,"depth":207,"text":6810},{"id":6982,"depth":207,"text":6983},{"id":7148,"depth":207,"text":7149},{"id":7193,"depth":207,"text":7194},{"id":7246,"depth":207,"text":7247},{"id":7273,"depth":207,"text":7274},"https:\u002F\u002Fname.os.bennywen.xyz\u002Fbennywen.xyz\u002Fassets\u002Fecom-detail-ai-part3-pitfalls\u002Fsop-process.png?imageMogr2\u002Fthumbnail\u002F1440x\u002Fformat\u002Fwebp\u002Fquality\u002F88","2026-10-14",{},"\u002Fposts\u002Fecom-detail-ai-part3-pitfalls",{"title":6203,"description":193},"ecom-detail-ai-part3-pitfalls","posts\u002Fecom-detail-ai-part3-pitfalls","AI 在图里写甲骨文、同参数重试纯烧钱、429 把流水线打瘫、模型评委爱放水、批量任务连环烧钱直到装了断路器……9 个坑，每一个都对应一段现在还在跑的代码。",[6198,7339],"踩坑复盘","azoQznqsDbfo1E5IVG1l15WZQLSbfBTaLOFHhoSBIj0",{"id":7342,"title":21,"body":7343,"cover":9668,"date":7331,"description":193,"extension":1527,"links":1525,"meta":9669,"navigation":1529,"path":9670,"project":6226,"seo":9671,"slug":9672,"stem":9673,"summary":9674,"tags":9675,"visible":1529,"__hash__":9676},"posts\u002Fposts\u002Fecom-detail-ai-part2-wails.md",{"type":8,"value":7344,"toc":9652},[7345,7363,7367,7370,7390,7397,7404,7408,7450,7453,7459,7471,7475,7481,7891,7894,7943,7947,7951,7956,8091,8163,8170,8174,8185,8327,8334,8401,8404,8410,8415,8421,8425,8443,8677,8694,8698,8713,8731,8735,8741,8965,8968,8996,9003,9023,9027,9033,9284,9291,9298,9309,9313,9316,9346,9456,9459,9463,9498,9509,9532,9559,9563,9566,9635,9638,9649],[11,7346,7347],{},[14,7348,7349,7350,7353,7354,7358,7359,7362],{},"系列说明：这是「AI 详情页生成工具」系列的第 2 篇，讲实战教程。第 1 篇 ",[18,7351,7352],{"href":6213},"《我用 Go 搭了一条 AI Agent 流水线》"," 拆解多模型流水线的架构设计；第 3 篇 ",[18,7355,7357],{"href":7356},"\u002Fblog\u002Fecom-detail-ai-part3-pitfalls","《一个人做 AI 产品，我踩过的 9 个真实的坑》"," 是踩坑复盘。项目背景：",[18,7360,7361],{"href":6225},"电商详情页 AI 生成客户端","——上传 1 张商品图、自动生成整套淘宝详情页的桌面客户端，Go 1.26 + Wails v2.15 + Vue 3.5。",[64,7364,7366],{"id":7365},"一为什么是-wails不是-electron","一、为什么是 Wails，不是 Electron",[14,7368,7369],{},"做 AI 工具，网页版是最省事的，但这个项目最后做成了桌面端，原因有三：",[77,7371,7372,7378,7384],{},[80,7373,7374,7377],{},[72,7375,7376],{},"隐私即卖点","。商品图是卖家的心血，产品宣称\"图片只发到用户自己的模型账号、排版合成在本机完成、我方不经手不存储\"——这话用桌面端说才成立；",[80,7379,7380,7383],{},[72,7381,7382],{},"本机排版引擎","。750px 长图合成、系统 CJK 字体渲染，这些是 Go 代码直接干的活，不需要经过网络；",[80,7385,7386,7389],{},[72,7387,7388],{},"BYOK 密钥存储","。用户 API 密钥加密存在本机用户目录，不落任何服务器。",[14,7391,7392,7393,7396],{},"选 Wails 而不是 Electron 的理由很朴素：Electron 打包动辄 200MB+、常驻内存几百 MB，而 Wails 用系统 WebView 渲染前端，Go 编译成原生二进制，产物 20MB 级别、内存占用小一个量级。更重要的是：",[72,7394,7395],{},"这个项目的重活（流水线、调度器、排版、加密）全在 Go 侧，前端只是壳","，Go 工程师写桌面应用不必再碰 Node 主进程那一套。",[14,7398,7399,7400,7403],{},"代价是 WebView 碎片化：Windows 依赖 WebView2（Win10+ 一般自带）、Linux 依赖 ",[87,7401,7402],{},"libgtk-3-dev libwebkit2gtk-4.1-dev","，不同内核偶有 CSS 差异。对内部工具完全可接受。",[64,7405,7407],{"id":7406},"二环境与项目骨架","二、环境与项目骨架",[188,7409,7411],{"className":2806,"code":7410,"language":2808,"meta":193,"style":193},"# 依赖：Go ≥ 1.25、Node ≥ 20\ngo install github.com\u002Fwailsapp\u002Fwails\u002Fv2\u002Fcmd\u002Fwails@latest\nwails init -n myapp -t vue  # 或者像本项目一样手工搭 Vite7 + Vue3 + TS\n",[87,7412,7413,7418,7427],{"__ignoreMap":193},[197,7414,7415],{"class":199,"line":200},[197,7416,7417],{"class":203},"# 依赖：Go ≥ 1.25、Node ≥ 20\n",[197,7419,7420,7422,7424],{"class":199,"line":207},[197,7421,192],{"class":214},[197,7423,2817],{"class":256},[197,7425,7426],{"class":256}," github.com\u002Fwailsapp\u002Fwails\u002Fv2\u002Fcmd\u002Fwails@latest\n",[197,7428,7429,7432,7435,7438,7441,7444,7447],{"class":199,"line":244},[197,7430,7431],{"class":214},"wails",[197,7433,7434],{"class":256}," init",[197,7436,7437],{"class":281}," -n",[197,7439,7440],{"class":256}," myapp",[197,7442,7443],{"class":281}," -t",[197,7445,7446],{"class":256}," vue",[197,7448,7449],{"class":203},"  # 或者像本项目一样手工搭 Vite7 + Vue3 + TS\n",[14,7451,7452],{},"目录结构（节选自项目实际布局）：",[188,7454,7457],{"className":7455,"code":7456,"language":1919},[1917],"├── main.go            # 入口：wails.Run 配置\n├── app.go             # App 结构体：所有前端可调用的绑定方法\n├── internal\u002F\n│   ├── agent\u002F         # LLM 步骤（感知\u002F规划\u002F文案\u002F自检），纯逻辑不碰库\n│   ├── service\u002F       # 业务编排：pipeline.go 流水线、scheduler.go 调度器\n│   ├── llm\u002F           # OpenAI 兼容网关\n│   ├── render\u002F        # 本机位图排版引擎\n│   ├── secret\u002F        # 密钥 AES-GCM 加密存储\n│   ├── config\u002F        # 本地 config.json\n│   ├── database\u002F      # gorm + SQLite\n│   └── model\u002F         # 数据模型\n└── frontend\u002F          # Vue3 + Pinia + Element Plus\n    └── wailsjs\u002F       # ⚠️ wails 自动生成的 Go↔TS 绑定，勿手改\n",[87,7458,7456],{"__ignoreMap":193},[14,7460,7461,7462,7467,7468,7470],{},"这里想强调一个架构习惯：",[72,7463,7464,7466],{},[87,7465,1657],{}," 只做\"绑定方法 + 参数校验 + 转发\"，不写业务","。项目里甚至用 AST 写了架构护栏测试，强制 ",[87,7469,1657],{}," 不允许 import gorm\u002Fdatabase——所有业务访问必须经 service 层。桌面应用最容易写成\"一锅炖\"，因为所有代码物理距离都那么近，分层纪律要靠机器守。",[64,7472,7474],{"id":7473},"三组合根maingo-里装配一切","三、组合根：main.go 里装配一切",[14,7476,7477,7478,7480],{},"Wails 的应用组装全在 ",[87,7479,1653],{},"，本项目按\"数据库 → 配置 → 密钥库 → 服务 → App\"顺序做组合根：",[188,7482,7484],{"className":190,"code":7483,"language":192,"meta":193,"style":193},"\u002F\u002Fgo:embed all:frontend\u002Fdist\nvar assets embed.FS   \u002F\u002F 前端构建产物直接嵌进二进制，发布只有一个文件\n\nfunc main() {\n    db, err := database.Init(appName)                  \u002F\u002F SQLite（纯 Go 驱动 glebarez）\n    cfg, err := config.Load(appName)                   \u002F\u002F 用户配置目录下的 config.json\n    dataDir, err := config.Dir(appName)\n    secretStore, err := secret.New(dataDir)            \u002F\u002F 密钥加密存储\n    app := NewApp(service.New(db, cfg, secretStore, dataDir), cfg)\n\n    err = wails.Run(&options.App{\n        Title:             \"ecom-detail-ai\",\n        Width:             1024,\n        Height:            768,\n        Frameless:         frameless(),                \u002F\u002F 无边框按平台分叉，见后文\n        SingleInstanceLock: &options.SingleInstanceLock{\n            UniqueId: \"ecom-detail-ai-single-instance\",\n            OnSecondInstanceLaunch: func(data options.SecondInstanceData) {\n                runtime.Show(app.ctx)                 \u002F\u002F 二次启动唤起已有窗口\n                runtime.WindowUnminimise(app.ctx)\n            },\n        },\n        HideWindowOnClose: hideWindowOnClose(),        \u002F\u002F 关闭=隐藏到托盘（Win\u002FLinux）\n        AssetServer: &assetserver.Options{\n            Assets:  assets,\n            Handler: localAssetHandler(dataDir),       \u002F\u002F 本地产物暴露给前端\n        },\n        BackgroundColour: &options.RGBA{R: 14, G: 15, B: 18, A: 1}, \u002F\u002F 与前端 --bg-base 一致，防启动白闪\n        OnStartup: app.startup,\n        OnShutdown: app.shutdown,\n        Bind:      []interface{}{app},\n    })\n}\n",[87,7485,7486,7491,7509,7513,7522,7541,7560,7575,7593,7610,7614,7638,7648,7657,7667,7681,7697,7707,7729,7742,7752,7757,7762,7776,7792,7797,7811,7815,7858,7864,7870,7880,7886],{"__ignoreMap":193},[197,7487,7488],{"class":199,"line":200},[197,7489,7490],{"class":203},"\u002F\u002Fgo:embed all:frontend\u002Fdist\n",[197,7492,7493,7495,7498,7501,7503,7506],{"class":199,"line":207},[197,7494,6449],{"class":210},[197,7496,7497],{"class":218}," assets ",[197,7499,7500],{"class":214},"embed",[197,7502,229],{"class":218},[197,7504,7505],{"class":214},"FS",[197,7507,7508],{"class":203},"   \u002F\u002F 前端构建产物直接嵌进二进制，发布只有一个文件\n",[197,7510,7511],{"class":199,"line":244},[197,7512,2155],{"emptyLinePlaceholder":1529},[197,7514,7515,7517,7520],{"class":199,"line":263},[197,7516,211],{"class":210},[197,7518,7519],{"class":214}," main",[197,7521,1031],{"class":218},[197,7523,7524,7527,7529,7532,7535,7538],{"class":199,"line":275},[197,7525,7526],{"class":218},"    db, err ",[197,7528,250],{"class":210},[197,7530,7531],{"class":218}," database.",[197,7533,7534],{"class":214},"Init",[197,7536,7537],{"class":218},"(appName)                  ",[197,7539,7540],{"class":203},"\u002F\u002F SQLite（纯 Go 驱动 glebarez）\n",[197,7542,7543,7546,7548,7551,7554,7557],{"class":199,"line":285},[197,7544,7545],{"class":218},"    cfg, err ",[197,7547,250],{"class":210},[197,7549,7550],{"class":218}," config.",[197,7552,7553],{"class":214},"Load",[197,7555,7556],{"class":218},"(appName)                   ",[197,7558,7559],{"class":203},"\u002F\u002F 用户配置目录下的 config.json\n",[197,7561,7562,7565,7567,7569,7572],{"class":199,"line":291},[197,7563,7564],{"class":218},"    dataDir, err ",[197,7566,250],{"class":210},[197,7568,7550],{"class":218},[197,7570,7571],{"class":214},"Dir",[197,7573,7574],{"class":218},"(appName)\n",[197,7576,7577,7580,7582,7585,7587,7590],{"class":199,"line":313},[197,7578,7579],{"class":218},"    secretStore, err ",[197,7581,250],{"class":210},[197,7583,7584],{"class":218}," secret.",[197,7586,2133],{"class":214},[197,7588,7589],{"class":218},"(dataDir)            ",[197,7591,7592],{"class":203},"\u002F\u002F 密钥加密存储\n",[197,7594,7595,7597,7599,7602,7605,7607],{"class":199,"line":349},[197,7596,2467],{"class":218},[197,7598,250],{"class":210},[197,7600,7601],{"class":214}," NewApp",[197,7603,7604],{"class":218},"(service.",[197,7606,2133],{"class":214},[197,7608,7609],{"class":218},"(db, cfg, secretStore, dataDir), cfg)\n",[197,7611,7612],{"class":199,"line":360},[197,7613,2155],{"emptyLinePlaceholder":1529},[197,7615,7616,7619,7621,7624,7626,7628,7630,7632,7634,7636],{"class":199,"line":365},[197,7617,7618],{"class":218},"    err ",[197,7620,781],{"class":210},[197,7622,7623],{"class":218}," wails.",[197,7625,2052],{"class":214},[197,7627,219],{"class":218},[197,7629,842],{"class":210},[197,7631,2059],{"class":214},[197,7633,229],{"class":218},[197,7635,2064],{"class":214},[197,7637,2067],{"class":218},[197,7639,7640,7643,7646],{"class":199,"line":390},[197,7641,7642],{"class":218},"        Title:             ",[197,7644,7645],{"class":256},"\"ecom-detail-ai\"",[197,7647,2078],{"class":218},[197,7649,7650,7653,7655],{"class":199,"line":417},[197,7651,7652],{"class":218},"        Width:             ",[197,7654,2086],{"class":281},[197,7656,2078],{"class":218},[197,7658,7659,7662,7665],{"class":199,"line":427},[197,7660,7661],{"class":218},"        Height:            ",[197,7663,7664],{"class":281},"768",[197,7666,2078],{"class":218},[197,7668,7669,7672,7675,7678],{"class":199,"line":432},[197,7670,7671],{"class":218},"        Frameless:         ",[197,7673,7674],{"class":214},"frameless",[197,7676,7677],{"class":218},"(),                ",[197,7679,7680],{"class":203},"\u002F\u002F 无边框按平台分叉，见后文\n",[197,7682,7683,7686,7688,7690,7692,7695],{"class":199,"line":447},[197,7684,7685],{"class":218},"        SingleInstanceLock: ",[197,7687,842],{"class":210},[197,7689,2059],{"class":214},[197,7691,229],{"class":218},[197,7693,7694],{"class":214},"SingleInstanceLock",[197,7696,2067],{"class":218},[197,7698,7699,7702,7705],{"class":199,"line":461},[197,7700,7701],{"class":218},"            UniqueId: ",[197,7703,7704],{"class":256},"\"ecom-detail-ai-single-instance\"",[197,7706,2078],{"class":218},[197,7708,7709,7712,7714,7716,7719,7722,7724,7727],{"class":199,"line":803},[197,7710,7711],{"class":218},"            OnSecondInstanceLaunch: ",[197,7713,211],{"class":210},[197,7715,219],{"class":218},[197,7717,7718],{"class":222},"data",[197,7720,7721],{"class":214}," options",[197,7723,229],{"class":218},[197,7725,7726],{"class":214},"SecondInstanceData",[197,7728,346],{"class":218},[197,7730,7731,7734,7736,7739],{"class":199,"line":808},[197,7732,7733],{"class":218},"                runtime.",[197,7735,2406],{"class":214},[197,7737,7738],{"class":218},"(app.ctx)                 ",[197,7740,7741],{"class":203},"\u002F\u002F 二次启动唤起已有窗口\n",[197,7743,7744,7746,7749],{"class":199,"line":825},[197,7745,7733],{"class":218},[197,7747,7748],{"class":214},"WindowUnminimise",[197,7750,7751],{"class":218},"(app.ctx)\n",[197,7753,7754],{"class":199,"line":867},[197,7755,7756],{"class":218},"            },\n",[197,7758,7759],{"class":199,"line":880},[197,7760,7761],{"class":218},"        },\n",[197,7763,7764,7767,7770,7773],{"class":199,"line":885},[197,7765,7766],{"class":218},"        HideWindowOnClose: ",[197,7768,7769],{"class":214},"hideWindowOnClose",[197,7771,7772],{"class":218},"(),        ",[197,7774,7775],{"class":203},"\u002F\u002F 关闭=隐藏到托盘（Win\u002FLinux）\n",[197,7777,7778,7781,7783,7786,7788,7790],{"class":199,"line":907},[197,7779,7780],{"class":218},"        AssetServer: ",[197,7782,842],{"class":210},[197,7784,7785],{"class":214},"assetserver",[197,7787,229],{"class":218},[197,7789,855],{"class":214},[197,7791,2067],{"class":218},[197,7793,7794],{"class":199,"line":924},[197,7795,7796],{"class":218},"            Assets:  assets,\n",[197,7798,7799,7802,7805,7808],{"class":199,"line":929},[197,7800,7801],{"class":218},"            Handler: ",[197,7803,7804],{"class":214},"localAssetHandler",[197,7806,7807],{"class":218},"(dataDir),       ",[197,7809,7810],{"class":203},"\u002F\u002F 本地产物暴露给前端\n",[197,7812,7813],{"class":199,"line":935},[197,7814,7761],{"class":218},[197,7816,7818,7821,7823,7825,7827,7829,7832,7835,7838,7841,7844,7847,7850,7852,7855],{"class":199,"line":7817},28,[197,7819,7820],{"class":218},"        BackgroundColour: ",[197,7822,842],{"class":210},[197,7824,2059],{"class":214},[197,7826,229],{"class":218},[197,7828,4521],{"class":214},[197,7830,7831],{"class":218},"{R: ",[197,7833,7834],{"class":281},"14",[197,7836,7837],{"class":218},", G: ",[197,7839,7840],{"class":281},"15",[197,7842,7843],{"class":218},", B: ",[197,7845,7846],{"class":281},"18",[197,7848,7849],{"class":218},", A: ",[197,7851,1309],{"class":281},[197,7853,7854],{"class":218},"}, ",[197,7856,7857],{"class":203},"\u002F\u002F 与前端 --bg-base 一致，防启动白闪\n",[197,7859,7861],{"class":199,"line":7860},29,[197,7862,7863],{"class":218},"        OnStartup: app.startup,\n",[197,7865,7867],{"class":199,"line":7866},30,[197,7868,7869],{"class":218},"        OnShutdown: app.shutdown,\n",[197,7871,7873,7876,7878],{"class":199,"line":7872},31,[197,7874,7875],{"class":218},"        Bind:      []",[197,7877,2096],{"class":210},[197,7879,2099],{"class":218},[197,7881,7883],{"class":199,"line":7882},32,[197,7884,7885],{"class":218},"    })\n",[197,7887,7889],{"class":199,"line":7888},33,[197,7890,464],{"class":218},[14,7892,7893],{},"几个实战细节都是踩过坑之后定下来的：",[1984,7895,7896,7917,7925],{},[80,7897,7898,7904,7905,7908,7909,7912,7913,7916],{},[72,7899,7900,7901],{},"数据目录用 ",[87,7902,7903],{},"os.UserConfigDir()","：Windows 落到 ",[87,7906,7907],{},"%AppData%","、macOS 落到 ",[87,7910,7911],{},"~\u002FLibrary\u002FApplication Support","、Linux 落到 ",[87,7914,7915],{},"~\u002F.config","，一个 API 跨平台，配置\u002F数据库\u002F密钥\u002F产物全放同一目录。千万别往应用安装目录写东西。",[80,7918,7919,7924],{},[72,7920,7921,7923],{},[87,7922,4620],{}," 要和前端主题底色一致","，否则每次启动白闪一下，廉价感拉满。",[80,7926,7927,7930,7931,7934,7935,7938,7939,7942],{},[72,7928,7929],{},"无边框窗口必须按平台分叉","：macOS 上 ",[87,7932,7933],{},"Frameless: true"," 会让 Wails 跳过 ",[87,7936,7937],{},"NSWindowStyleMaskTitled","，红黄绿交通灯直接消失。正确姿势是 macOS 用 ",[87,7940,7941],{},"mac.TitleBarHiddenInset()"," 原生方案，Windows\u002FLinux 才自绘标题栏。",[64,7944,7946],{"id":7945},"四go-vue-通信绑定方法-事件两板斧","四、Go ↔ Vue 通信：绑定方法 + 事件，两板斧",[123,7948,7950],{"id":7949},"_41-前端调-go绑定方法","4.1 前端调 Go：绑定方法",[14,7952,7953,7955],{},[87,7954,2228],{}," 注册的结构体上，所有导出方法自动生成 TS 声明和调用桩，前端 import 即用：",[188,7957,7959],{"className":190,"code":7958,"language":192,"meta":193,"style":193},"\u002F\u002F app.go\nfunc (a *App) SelectFile(title string) (string, error) {\n    return dialog.SelectFile(a.ctx, title)   \u002F\u002F 原生文件对话框\n}\nfunc (a *App) CreateTask(imagePath string, extraPaths []string, detailText string) (uint, error) {\n    return a.svc.CreateTask(imagePath, extraPaths, detailText)  \u002F\u002F 创建生成任务，转发给 service\n}\n",[87,7960,7961,7966,8003,8017,8021,8072,8087],{"__ignoreMap":193},[197,7962,7963],{"class":199,"line":200},[197,7964,7965],{"class":203},"\u002F\u002F app.go\n",[197,7967,7968,7970,7973,7976,7978,7980,7982,7985,7987,7990,7992,7994,7996,7998,8001],{"class":199,"line":207},[197,7969,211],{"class":210},[197,7971,7972],{"class":218}," (",[197,7974,7975],{"class":222},"a ",[197,7977,1057],{"class":210},[197,7979,2064],{"class":214},[197,7981,235],{"class":218},[197,7983,7984],{"class":214},"SelectFile",[197,7986,219],{"class":218},[197,7988,7989],{"class":222},"title",[197,7991,3873],{"class":210},[197,7993,530],{"class":218},[197,7995,7077],{"class":210},[197,7997,513],{"class":218},[197,7999,8000],{"class":210},"error",[197,8002,346],{"class":218},[197,8004,8005,8007,8009,8011,8014],{"class":199,"line":244},[197,8006,450],{"class":210},[197,8008,3834],{"class":218},[197,8010,7984],{"class":214},[197,8012,8013],{"class":218},"(a.ctx, title)   ",[197,8015,8016],{"class":203},"\u002F\u002F 原生文件对话框\n",[197,8018,8019],{"class":199,"line":263},[197,8020,464],{"class":218},[197,8022,8023,8025,8027,8029,8031,8033,8035,8038,8040,8043,8045,8047,8050,8052,8054,8056,8059,8061,8063,8066,8068,8070],{"class":199,"line":275},[197,8024,211],{"class":210},[197,8026,7972],{"class":218},[197,8028,7975],{"class":222},[197,8030,1057],{"class":210},[197,8032,2064],{"class":214},[197,8034,235],{"class":218},[197,8036,8037],{"class":214},"CreateTask",[197,8039,219],{"class":218},[197,8041,8042],{"class":222},"imagePath",[197,8044,3873],{"class":210},[197,8046,513],{"class":218},[197,8048,8049],{"class":222},"extraPaths",[197,8051,6457],{"class":218},[197,8053,7077],{"class":210},[197,8055,513],{"class":218},[197,8057,8058],{"class":222},"detailText",[197,8060,3873],{"class":210},[197,8062,530],{"class":218},[197,8064,8065],{"class":210},"uint",[197,8067,513],{"class":218},[197,8069,8000],{"class":210},[197,8071,346],{"class":218},[197,8073,8074,8076,8079,8081,8084],{"class":199,"line":285},[197,8075,450],{"class":210},[197,8077,8078],{"class":218}," a.svc.",[197,8080,8037],{"class":214},[197,8082,8083],{"class":218},"(imagePath, extraPaths, detailText)  ",[197,8085,8086],{"class":203},"\u002F\u002F 创建生成任务，转发给 service\n",[197,8088,8089],{"class":199,"line":291},[197,8090,464],{"class":218},[188,8092,8094],{"className":4030,"code":8093,"language":4032,"meta":193,"style":193},"\u002F\u002F 前端（wailsjs 自动生成的绑定）\nimport { CreateTask, SelectFile } from '..\u002Fwailsjs\u002Fgo\u002Fmain\u002FApp'\n\nconst path = await SelectFile('选择商品图')\nconst taskID = await CreateTask(path, [], '')\n",[87,8095,8096,8101,8115,8119,8141],{"__ignoreMap":193},[197,8097,8098],{"class":199,"line":200},[197,8099,8100],{"class":203},"\u002F\u002F 前端（wailsjs 自动生成的绑定）\n",[197,8102,8103,8106,8109,8112],{"class":199,"line":207},[197,8104,8105],{"class":210},"import",[197,8107,8108],{"class":218}," { CreateTask, SelectFile } ",[197,8110,8111],{"class":210},"from",[197,8113,8114],{"class":256}," '..\u002Fwailsjs\u002Fgo\u002Fmain\u002FApp'\n",[197,8116,8117],{"class":199,"line":244},[197,8118,2155],{"emptyLinePlaceholder":1529},[197,8120,8121,8123,8126,8128,8131,8134,8136,8139],{"class":199,"line":263},[197,8122,6271],{"class":210},[197,8124,8125],{"class":281}," path",[197,8127,6277],{"class":210},[197,8129,8130],{"class":210}," await",[197,8132,8133],{"class":214}," SelectFile",[197,8135,219],{"class":218},[197,8137,8138],{"class":256},"'选择商品图'",[197,8140,310],{"class":218},[197,8142,8143,8145,8148,8150,8152,8155,8158,8161],{"class":199,"line":275},[197,8144,6271],{"class":210},[197,8146,8147],{"class":281}," taskID",[197,8149,6277],{"class":210},[197,8151,8130],{"class":210},[197,8153,8154],{"class":214}," CreateTask",[197,8156,8157],{"class":218},"(path, [], ",[197,8159,8160],{"class":256},"''",[197,8162,310],{"class":218},[14,8164,8165,8166,8169],{},"结构体参数、返回值会自动 JSON 序列化，等于白送一个类型安全的 RPC。两个坑：绑定方法",[72,8167,8168],{},"不要在 UI 线程做长阻塞","（调用是异步 Promise，但耗时的活儿要进 goroutine 后靠事件推进度）；返回值里的零值字段注意前端判空。",[123,8171,8173],{"id":8172},"_42-go-推前端事件总线","4.2 Go 推前端：事件总线",[14,8175,8176,8177,8180,8181,8184],{},"这个项目的核心体验是\"流水线实时时间线\"，靠的是事件推送而不是前端轮询。接线方式：",[87,8178,8179],{},"OnStartup"," 时把 ",[87,8182,8183],{},"runtime.EventsEmit"," 注入 service，业务层持有一个抽象的 emitter：",[188,8186,8188],{"className":190,"code":8187,"language":192,"meta":193,"style":193},"\u002F\u002F app.go startup\na.svc.SetEmitter(func(name string, payload interface{}) {\n    runtime.EventsEmit(ctx, name, payload)\n    \u002F\u002F 任务完成且窗口最小化时自动弹回\n    if evt, ok := payload.(service.TaskEvent); ok && evt.Step == \"task\" && evt.Status == \"done\" {\n        if runtime.WindowIsMinimised(ctx) {\n            runtime.WindowUnminimise(ctx)\n            runtime.Show(ctx)\n        }\n    }\n})\n",[87,8189,8190,8195,8224,8234,8239,8284,8298,8307,8315,8319,8323],{"__ignoreMap":193},[197,8191,8192],{"class":199,"line":200},[197,8193,8194],{"class":203},"\u002F\u002F app.go startup\n",[197,8196,8197,8200,8203,8205,8207,8209,8211,8213,8215,8218,8221],{"class":199,"line":207},[197,8198,8199],{"class":218},"a.svc.",[197,8201,8202],{"class":214},"SetEmitter",[197,8204,219],{"class":218},[197,8206,211],{"class":210},[197,8208,219],{"class":218},[197,8210,5565],{"class":222},[197,8212,3873],{"class":210},[197,8214,513],{"class":218},[197,8216,8217],{"class":222},"payload",[197,8219,8220],{"class":210}," interface",[197,8222,8223],{"class":218},"{}) {\n",[197,8225,8226,8229,8231],{"class":199,"line":244},[197,8227,8228],{"class":218},"    runtime.",[197,8230,2355],{"class":214},[197,8232,8233],{"class":218},"(ctx, name, payload)\n",[197,8235,8236],{"class":199,"line":263},[197,8237,8238],{"class":203},"    \u002F\u002F 任务完成且窗口最小化时自动弹回\n",[197,8240,8241,8243,8246,8248,8251,8254,8256,8259,8262,8265,8268,8270,8273,8275,8278,8280,8282],{"class":199,"line":275},[197,8242,266],{"class":210},[197,8244,8245],{"class":218}," evt, ok ",[197,8247,250],{"class":210},[197,8249,8250],{"class":218}," payload.(",[197,8252,8253],{"class":214},"service",[197,8255,229],{"class":218},[197,8257,8258],{"class":214},"TaskEvent",[197,8260,8261],{"class":218},"); ok ",[197,8263,8264],{"class":210},"&&",[197,8266,8267],{"class":218}," evt.Step ",[197,8269,3788],{"class":210},[197,8271,8272],{"class":256}," \"task\"",[197,8274,335],{"class":210},[197,8276,8277],{"class":218}," evt.Status ",[197,8279,3788],{"class":210},[197,8281,6866],{"class":256},[197,8283,241],{"class":218},[197,8285,8286,8289,8292,8295],{"class":199,"line":285},[197,8287,8288],{"class":210},"        if",[197,8290,8291],{"class":218}," runtime.",[197,8293,8294],{"class":214},"WindowIsMinimised",[197,8296,8297],{"class":218},"(ctx) {\n",[197,8299,8300,8303,8305],{"class":199,"line":291},[197,8301,8302],{"class":218},"            runtime.",[197,8304,7748],{"class":214},[197,8306,2348],{"class":218},[197,8308,8309,8311,8313],{"class":199,"line":313},[197,8310,8302],{"class":218},[197,8312,2406],{"class":214},[197,8314,2348],{"class":218},[197,8316,8317],{"class":199,"line":349},[197,8318,6754],{"class":218},[197,8320,8321],{"class":199,"line":360},[197,8322,288],{"class":218},[197,8324,8325],{"class":199,"line":365},[197,8326,2104],{"class":218},[14,8328,8329,8330,8333],{},"service 层完全不知道 Wails 的存在（只拿了一个 ",[87,8331,8332],{},"func(string, interface{})","），单测里注入假 emitter 即可。前端用 runtime 监听：",[188,8335,8337],{"className":4030,"code":8336,"language":4032,"meta":193,"style":193},"import { EventsOn } from '..\u002F..\u002Fwailsjs\u002Fruntime'\n\nEventsOn('task:progress', (e: TaskEvent) => {\n  timeline.push(e)   \u002F\u002F \"感知完成：便携榨汁机\" \u002F \"第2张触发限流，20s 后重试\"\n})\n",[87,8338,8339,8351,8355,8383,8397],{"__ignoreMap":193},[197,8340,8341,8343,8346,8348],{"class":199,"line":200},[197,8342,8105],{"class":210},[197,8344,8345],{"class":218}," { EventsOn } ",[197,8347,8111],{"class":210},[197,8349,8350],{"class":256}," '..\u002F..\u002Fwailsjs\u002Fruntime'\n",[197,8352,8353],{"class":199,"line":207},[197,8354,2155],{"emptyLinePlaceholder":1529},[197,8356,8357,8360,8362,8365,8368,8370,8373,8376,8378,8381],{"class":199,"line":244},[197,8358,8359],{"class":214},"EventsOn",[197,8361,219],{"class":218},[197,8363,8364],{"class":256},"'task:progress'",[197,8366,8367],{"class":218},", (",[197,8369,3436],{"class":222},[197,8371,8372],{"class":210},":",[197,8374,8375],{"class":214}," TaskEvent",[197,8377,235],{"class":218},[197,8379,8380],{"class":210},"=>",[197,8382,241],{"class":218},[197,8384,8385,8388,8391,8394],{"class":199,"line":263},[197,8386,8387],{"class":218},"  timeline.",[197,8389,8390],{"class":214},"push",[197,8392,8393],{"class":218},"(e)   ",[197,8395,8396],{"class":203},"\u002F\u002F \"感知完成：便携榨汁机\" \u002F \"第2张触发限流，20s 后重试\"\n",[197,8398,8399],{"class":199,"line":275},[197,8400,2104],{"class":218},[14,8402,8403],{},"最终效果就是这张实拍：左列是感知与规划的结构化结果，右列是随事件一张张\"长出来\"的流式图片墙，顶部步骤条（抠图→感知→规划→渲染→文案→排版）逐项点亮：",[14,8405,8406],{},[43,8407],{"alt":8408,"src":8409},"生成页实况：事件驱动的实时时间线与流式图片墙","https:\u002F\u002Fname.os.bennywen.xyz\u002Fbennywen.xyz\u002Fassets\u002Fecom-detail-ai-part2-wails\u002Fsop-process.png?imageMogr2\u002Fthumbnail\u002F1440x\u002Fformat\u002Fwebp\u002Fquality\u002F88",[14,8411,8412],{},[50,8413,8414],{},"生成页实况：事件驱动的实时时间线与流式图片墙。",[14,8416,8417,8420],{},[72,8418,8419],{},"给后端框架注入回调而不是让它 import 框架包","，这个小动作让整个流水线层可以脱离桌面环境跑单测，强烈推荐。",[123,8422,8424],{"id":8423},"_43-本地产物怎么显示assetserver-handler","4.3 本地产物怎么显示：AssetServer Handler",[14,8426,8427,8428,8431,8432,8435,8436,8439,8440,8442],{},"生成的详情页长图存在数据目录里，",[87,8429,8430],{},"\u003Cimg>"," 没法直接引 ",[87,8433,8434],{},"file:\u002F\u002F","。Wails 的 AssetServer 允许挂一个兜底 ",[87,8437,8438],{},"http.Handler","，本项目用它把数据目录以 ",[87,8441,7158],{}," 前缀暴露：",[188,8444,8446],{"className":190,"code":8445,"language":192,"meta":193,"style":193},"func localAssetHandler(dataDir string) http.Handler {\n    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n        const prefix = \"\u002Flocal\u002F\"\n        if !strings.HasPrefix(r.URL.Path, prefix) { http.NotFound(w, r); return }\n        rel, err := url.PathUnescape(strings.TrimPrefix(r.URL.Path, prefix))\n        if err != nil { http.NotFound(w, r); return }\n        full := filepath.Clean(filepath.Join(dataDir, rel))\n        if !strings.HasPrefix(full, dataDir+string(os.PathSeparator)) {\n            http.NotFound(w, r)   \u002F\u002F 防目录穿越：清洗后必须仍在数据目录内\n        }\n        http.ServeFile(w, r, full)\n    })\n}\n",[87,8447,8448,8474,8516,8529,8555,8577,8598,8620,8641,8654,8658,8669,8673],{"__ignoreMap":193},[197,8449,8450,8452,8455,8457,8460,8462,8464,8467,8469,8472],{"class":199,"line":200},[197,8451,211],{"class":210},[197,8453,8454],{"class":214}," localAssetHandler",[197,8456,219],{"class":218},[197,8458,8459],{"class":222},"dataDir",[197,8461,3873],{"class":210},[197,8463,235],{"class":218},[197,8465,8466],{"class":214},"http",[197,8468,229],{"class":218},[197,8470,8471],{"class":214},"Handler",[197,8473,241],{"class":218},[197,8475,8476,8478,8481,8484,8486,8488,8490,8493,8496,8498,8501,8503,8505,8507,8509,8511,8514],{"class":199,"line":207},[197,8477,450],{"class":210},[197,8479,8480],{"class":218}," http.",[197,8482,8483],{"class":214},"HandlerFunc",[197,8485,219],{"class":218},[197,8487,211],{"class":210},[197,8489,219],{"class":218},[197,8491,8492],{"class":222},"w",[197,8494,8495],{"class":214}," http",[197,8497,229],{"class":218},[197,8499,8500],{"class":214},"ResponseWriter",[197,8502,513],{"class":218},[197,8504,6696],{"class":222},[197,8506,519],{"class":210},[197,8508,8466],{"class":214},[197,8510,229],{"class":218},[197,8512,8513],{"class":214},"Request",[197,8515,346],{"class":218},[197,8517,8518,8521,8524,8526],{"class":199,"line":244},[197,8519,8520],{"class":210},"        const",[197,8522,8523],{"class":281}," prefix",[197,8525,6277],{"class":210},[197,8527,8528],{"class":256}," \"\u002Flocal\u002F\"\n",[197,8530,8531,8533,8535,8538,8541,8544,8547,8550,8553],{"class":199,"line":263},[197,8532,8288],{"class":210},[197,8534,269],{"class":210},[197,8536,8537],{"class":218},"strings.",[197,8539,8540],{"class":214},"HasPrefix",[197,8542,8543],{"class":218},"(r.URL.Path, prefix) { http.",[197,8545,8546],{"class":214},"NotFound",[197,8548,8549],{"class":218},"(w, r); ",[197,8551,8552],{"class":210},"return",[197,8554,3850],{"class":218},[197,8556,8557,8560,8562,8565,8568,8571,8574],{"class":199,"line":275},[197,8558,8559],{"class":218},"        rel, err ",[197,8561,250],{"class":210},[197,8563,8564],{"class":218}," url.",[197,8566,8567],{"class":214},"PathUnescape",[197,8569,8570],{"class":218},"(strings.",[197,8572,8573],{"class":214},"TrimPrefix",[197,8575,8576],{"class":218},"(r.URL.Path, prefix))\n",[197,8578,8579,8581,8583,8585,8587,8590,8592,8594,8596],{"class":199,"line":285},[197,8580,8288],{"class":210},[197,8582,689],{"class":218},[197,8584,329],{"class":210},[197,8586,694],{"class":281},[197,8588,8589],{"class":218}," { http.",[197,8591,8546],{"class":214},[197,8593,8549],{"class":218},[197,8595,8552],{"class":210},[197,8597,3850],{"class":218},[197,8599,8600,8603,8605,8608,8611,8614,8617],{"class":199,"line":291},[197,8601,8602],{"class":218},"        full ",[197,8604,250],{"class":210},[197,8606,8607],{"class":218}," filepath.",[197,8609,8610],{"class":214},"Clean",[197,8612,8613],{"class":218},"(filepath.",[197,8615,8616],{"class":214},"Join",[197,8618,8619],{"class":218},"(dataDir, rel))\n",[197,8621,8622,8624,8626,8628,8630,8633,8636,8638],{"class":199,"line":313},[197,8623,8288],{"class":210},[197,8625,269],{"class":210},[197,8627,8537],{"class":218},[197,8629,8540],{"class":214},[197,8631,8632],{"class":218},"(full, dataDir",[197,8634,8635],{"class":210},"+",[197,8637,7077],{"class":210},[197,8639,8640],{"class":218},"(os.PathSeparator)) {\n",[197,8642,8643,8646,8648,8651],{"class":199,"line":349},[197,8644,8645],{"class":218},"            http.",[197,8647,8546],{"class":214},[197,8649,8650],{"class":218},"(w, r)   ",[197,8652,8653],{"class":203},"\u002F\u002F 防目录穿越：清洗后必须仍在数据目录内\n",[197,8655,8656],{"class":199,"line":360},[197,8657,6754],{"class":218},[197,8659,8660,8663,8666],{"class":199,"line":365},[197,8661,8662],{"class":218},"        http.",[197,8664,8665],{"class":214},"ServeFile",[197,8667,8668],{"class":218},"(w, r, full)\n",[197,8670,8671],{"class":199,"line":390},[197,8672,7885],{"class":218},[197,8674,8675],{"class":199,"line":417},[197,8676,464],{"class":218},[14,8678,8679,8680,8683,8684,2916,8687,8690,8691,8693],{},"前端 ",[87,8681,8682],{},"\u003Cimg :src=\"'\u002Flocal\u002Fassets\u002Ftask-1\u002Flongpage.png'\">"," 就能看图。",[72,8685,8686],{},"注意绝对路径也要校验前缀",[87,8688,8689],{},"..%2f"," 这类穿越必须拦。这个 handler 的另一个价值是让\"预览本机文件\"这件事零成本：不需要把产物复制到临时目录、不需要起独立 HTTP 服务、也不依赖任何自定义协议注册，一个标准 ",[87,8692,8438],{}," 解决。",[123,8695,8697],{"id":8696},"_44-前端侧三个页面-三个-store-就够","4.4 前端侧：三个页面 + 三个 store 就够",[14,8699,8700,8701,8704,8705,8708,8709,8712],{},"前端刻意保持薄。",[87,8702,8703],{},"views\u002F"," 下就四个页面：工作台（Home，选图发起任务）、生成页（实时时间线 + 流式图片墙 + 长图预览）、历史页、设置页（密钥与模型位）。",[87,8706,8707],{},"stores\u002F"," 下三个 Pinia store：主题、设置、任务。事件监听集中在任务 store 里做单一出口，页面只订阅状态，避免同一个 ",[87,8710,8711],{},"task:progress"," 被多个组件各自监听造成状态分叉。",[14,8714,8715,8716,8719,8720,8723,8724,8726,8727,8730],{},"样式上有一条机器强制的规矩：",[72,8717,8718],{},"渲染层禁止硬编码 hex 色值","，全部引用 ",[87,8721,8722],{},"styles\u002Ftokens.css"," 里的设计变量，ESLint 规则兜底。这条规矩的由来是明暗主题切换：项目初版有三十多处散落的颜色，加暗色主题改了一整周还漏；后来所有颜色收敛进 tokens（背景、文本、边框、品牌色各一组语义变量），换肤变成了改一个文件的事。同理，",[87,8725,4620],{}," 那个启动背景色也必须和 ",[87,8728,8729],{},"--bg-base"," token 保持一致——两边是同一个决策的两个出口。",[64,8732,8734],{"id":8733},"五接-ai一个兼容网关吃三家","五、接 AI：一个兼容网关吃三家",[14,8736,8737,8738,484],{},"后端对接 LLM 这件事，被压缩到了极致。智谱、DeepSeek、火山方舟全都兼容 OpenAI 的 chat\u002Fcompletions 协议，所以网关只实现一种协议，",[72,8739,8740],{},"换供应商 = 换 base_url + api_key + model 三个参数",[188,8742,8744],{"className":190,"code":8743,"language":192,"meta":193,"style":193},"type Gateway struct{ client *http.Client }\n\nfunc (g *Gateway) Chat(ctx context.Context, baseURL, apiKey, model string,\n    messages []Message, opts ...ChatOption) (string, Usage, error) {\n\n    body, _ := json.Marshal(chatRequest{Model: model, Messages: messages, Stream: false})\n    url := strings.TrimRight(baseURL, \"\u002F\") + \"\u002Fchat\u002Fcompletions\"\n    req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))\n    req.Header.Set(\"Authorization\", \"Bearer \"+apiKey)\n    \u002F\u002F ... 解析 choices[0].message.content + usage（token 用量）\n}\n",[87,8745,8746,8769,8773,8822,8858,8862,8888,8913,8933,8956,8961],{"__ignoreMap":193},[197,8747,8748,8750,8753,8755,8758,8760,8762,8764,8767],{"class":199,"line":200},[197,8749,2454],{"class":210},[197,8751,8752],{"class":214}," Gateway",[197,8754,2460],{"class":210},[197,8756,8757],{"class":218},"{ client ",[197,8759,1057],{"class":210},[197,8761,8466],{"class":214},[197,8763,229],{"class":218},[197,8765,8766],{"class":214},"Client",[197,8768,3850],{"class":218},[197,8770,8771],{"class":199,"line":207},[197,8772,2155],{"emptyLinePlaceholder":1529},[197,8774,8775,8777,8779,8782,8784,8787,8789,8792,8794,8796,8799,8801,8804,8806,8809,8811,8814,8816,8818,8820],{"class":199,"line":244},[197,8776,211],{"class":210},[197,8778,7972],{"class":218},[197,8780,8781],{"class":222},"g ",[197,8783,1057],{"class":210},[197,8785,8786],{"class":214},"Gateway",[197,8788,235],{"class":218},[197,8790,8791],{"class":214},"Chat",[197,8793,219],{"class":218},[197,8795,2331],{"class":222},[197,8797,8798],{"class":214}," context",[197,8800,229],{"class":218},[197,8802,8803],{"class":214},"Context",[197,8805,513],{"class":218},[197,8807,8808],{"class":222},"baseURL",[197,8810,513],{"class":218},[197,8812,8813],{"class":222},"apiKey",[197,8815,513],{"class":218},[197,8817,6924],{"class":222},[197,8819,3873],{"class":210},[197,8821,2078],{"class":218},[197,8823,8824,8827,8829,8832,8834,8837,8840,8843,8845,8847,8849,8852,8854,8856],{"class":199,"line":263},[197,8825,8826],{"class":222},"    messages",[197,8828,6457],{"class":218},[197,8830,8831],{"class":214},"Message",[197,8833,513],{"class":218},[197,8835,8836],{"class":222},"opts",[197,8838,8839],{"class":210}," ...",[197,8841,8842],{"class":214},"ChatOption",[197,8844,530],{"class":218},[197,8846,7077],{"class":210},[197,8848,513],{"class":218},[197,8850,8851],{"class":214},"Usage",[197,8853,513],{"class":218},[197,8855,8000],{"class":210},[197,8857,346],{"class":218},[197,8859,8860],{"class":199,"line":275},[197,8861,2155],{"emptyLinePlaceholder":1529},[197,8863,8864,8867,8869,8872,8875,8877,8880,8883,8886],{"class":199,"line":285},[197,8865,8866],{"class":218},"    body, _ ",[197,8868,250],{"class":210},[197,8870,8871],{"class":218}," json.",[197,8873,8874],{"class":214},"Marshal",[197,8876,219],{"class":218},[197,8878,8879],{"class":214},"chatRequest",[197,8881,8882],{"class":218},"{Model: model, Messages: messages, Stream: ",[197,8884,8885],{"class":281},"false",[197,8887,2104],{"class":218},[197,8889,8890,8893,8895,8897,8900,8903,8906,8908,8910],{"class":199,"line":291},[197,8891,8892],{"class":218},"    url ",[197,8894,250],{"class":210},[197,8896,6547],{"class":218},[197,8898,8899],{"class":214},"TrimRight",[197,8901,8902],{"class":218},"(baseURL, ",[197,8904,8905],{"class":256},"\"\u002F\"",[197,8907,235],{"class":218},[197,8909,8635],{"class":210},[197,8911,8912],{"class":256}," \"\u002Fchat\u002Fcompletions\"\n",[197,8914,8915,8918,8920,8922,8925,8928,8930],{"class":199,"line":313},[197,8916,8917],{"class":218},"    req, err ",[197,8919,250],{"class":210},[197,8921,8480],{"class":218},[197,8923,8924],{"class":214},"NewRequestWithContext",[197,8926,8927],{"class":218},"(ctx, http.MethodPost, url, bytes.",[197,8929,679],{"class":214},[197,8931,8932],{"class":218},"(body))\n",[197,8934,8935,8938,8941,8943,8946,8948,8951,8953],{"class":199,"line":349},[197,8936,8937],{"class":218},"    req.Header.",[197,8939,8940],{"class":214},"Set",[197,8942,219],{"class":218},[197,8944,8945],{"class":256},"\"Authorization\"",[197,8947,513],{"class":218},[197,8949,8950],{"class":256},"\"Bearer \"",[197,8952,8635],{"class":210},[197,8954,8955],{"class":218},"apiKey)\n",[197,8957,8958],{"class":199,"line":360},[197,8959,8960],{"class":203},"    \u002F\u002F ... 解析 choices[0].message.content + usage（token 用量）\n",[197,8962,8963],{"class":199,"line":365},[197,8964,464],{"class":218},[14,8966,8967],{},"两个容易忽略的点：",[1984,8969,8970,8990],{},[80,8971,8972,484,8975,8978,8979,8982,8983,471,8986,8989],{},[72,8973,8974],{},"多模态入参",[87,8976,8977],{},"Message.Content"," 是 ",[87,8980,8981],{},"interface{}","，纯文本给 string，图文混排给 ",[87,8984,8985],{},"[]ContentPart",[87,8987,8988],{},"{\"type\":\"image_url\",\"image_url\":{\"url\":\"data:image\u002Fpng;base64,...\"}}","）。本地图片直接转 base64 data URL 传给视觉模型，无需对象存储。",[80,8991,8992,8995],{},[72,8993,8994],{},"超时分级","：对话客户端 60s，生图客户端单独 120s——图生图接口一次 10–60s 是常态，用同一个 client 会互相拖累。",[14,8997,8998,8999,9002],{},"图像生成同样走 OpenAI 风格 ",[87,9000,9001],{},"\u002Fimages\u002Fgenerations","，但要正视现实：各家图生图的参考图参数形状不一致。项目的做法是把差异隔离在一个文件：",[188,9004,9006],{"className":190,"code":9005,"language":192,"meta":193,"style":193},"\u002F\u002F images.go 头部注释（节选）\n\u002F\u002F ⚠️ 联调标注：各平台图生图参数形状不一致——智谱 GLM-Image、Seedream 的\n\u002F\u002F 参考图传法各异，联调时按实测修正，只允许改本文件，调用方不受影响。\n",[87,9007,9008,9013,9018],{"__ignoreMap":193},[197,9009,9010],{"class":199,"line":200},[197,9011,9012],{"class":203},"\u002F\u002F images.go 头部注释（节选）\n",[197,9014,9015],{"class":199,"line":207},[197,9016,9017],{"class":203},"\u002F\u002F ⚠️ 联调标注：各平台图生图参数形状不一致——智谱 GLM-Image、Seedream 的\n",[197,9019,9020],{"class":199,"line":244},[197,9021,9022],{"class":203},"\u002F\u002F 参考图传法各异，联调时按实测修正，只允许改本文件，调用方不受影响。\n",[64,9024,9026],{"id":9025},"六任务持久化sqlite-当唯一真相","六、任务持久化：SQLite 当唯一真相",[14,9028,9029,9030,484],{},"桌面 AI 应用几乎都绕不开\"任务队列\"：一次生成跑十分钟，用户关窗口、程序崩了，任务状态去哪了？本项目的回答是",[72,9031,9032],{},"以数据库为唯一真相的调度器",[188,9034,9036],{"className":190,"code":9035,"language":192,"meta":193,"style":193},"\u002F\u002F 启动恢复（app.startup 里调用）\nfunc (s *Service) RecoverAndStart() {\n    \u002F\u002F 1. 上次退出时被中断的任务：running\u002Fplanned 置 failed，给用户人话提示\n    s.db.Model(&model.GenerationTask{}).\n        Where(\"status IN ?\", []string{\"running\", \"planned\"}).\n        Updates(map[string]interface{}{\"status\": \"failed\",\n            \"error_msg\": \"应用退出中断，请重新发起\", \"ended_at\": nowMillis()})\n    \u002F\u002F 2. 全部排队中(pending)任务按 id asc 重新入队\n    var ids []uint\n    s.db.Model(&model.GenerationTask{}).\n        Where(\"status = ?\", \"pending\").Order(\"id asc\").Pluck(\"id\", &ids)\n    for _, id := range ids {\n        s.enqueue(id)\n    }\n    \u002F\u002F 3. 起 dispatcher goroutine\n    s.StartDispatcher()\n}\n",[87,9037,9038,9043,9063,9068,9087,9114,9142,9163,9168,9178,9196,9236,9250,9261,9265,9270,9280],{"__ignoreMap":193},[197,9039,9040],{"class":199,"line":200},[197,9041,9042],{"class":203},"\u002F\u002F 启动恢复（app.startup 里调用）\n",[197,9044,9045,9047,9049,9052,9054,9056,9058,9061],{"class":199,"line":207},[197,9046,211],{"class":210},[197,9048,7972],{"class":218},[197,9050,9051],{"class":222},"s ",[197,9053,1057],{"class":210},[197,9055,2272],{"class":214},[197,9057,235],{"class":218},[197,9059,9060],{"class":214},"RecoverAndStart",[197,9062,1031],{"class":218},[197,9064,9065],{"class":199,"line":244},[197,9066,9067],{"class":203},"    \u002F\u002F 1. 上次退出时被中断的任务：running\u002Fplanned 置 failed，给用户人话提示\n",[197,9069,9070,9073,9075,9077,9079,9081,9083,9085],{"class":199,"line":263},[197,9071,9072],{"class":218},"    s.db.",[197,9074,6917],{"class":214},[197,9076,219],{"class":218},[197,9078,842],{"class":210},[197,9080,6924],{"class":214},[197,9082,229],{"class":218},[197,9084,7039],{"class":214},[197,9086,7042],{"class":218},[197,9088,9089,9092,9094,9097,9100,9102,9104,9106,9108,9111],{"class":199,"line":275},[197,9090,9091],{"class":214},"        Where",[197,9093,219],{"class":218},[197,9095,9096],{"class":256},"\"status IN ?\"",[197,9098,9099],{"class":218},", []",[197,9101,7077],{"class":210},[197,9103,2145],{"class":218},[197,9105,7093],{"class":256},[197,9107,513],{"class":218},[197,9109,9110],{"class":256},"\"planned\"",[197,9112,9113],{"class":218},"}).\n",[197,9115,9116,9119,9121,9123,9125,9127,9129,9131,9133,9135,9137,9140],{"class":199,"line":285},[197,9117,9118],{"class":214},"        Updates",[197,9120,219],{"class":218},[197,9122,7071],{"class":210},[197,9124,7074],{"class":218},[197,9126,7077],{"class":210},[197,9128,7080],{"class":218},[197,9130,2096],{"class":210},[197,9132,7085],{"class":218},[197,9134,6953],{"class":256},[197,9136,7090],{"class":218},[197,9138,9139],{"class":256},"\"failed\"",[197,9141,2078],{"class":218},[197,9143,9144,9147,9149,9152,9154,9157,9159,9161],{"class":199,"line":291},[197,9145,9146],{"class":256},"            \"error_msg\"",[197,9148,7090],{"class":218},[197,9150,9151],{"class":256},"\"应用退出中断，请重新发起\"",[197,9153,513],{"class":218},[197,9155,9156],{"class":256},"\"ended_at\"",[197,9158,7090],{"class":218},[197,9160,7103],{"class":214},[197,9162,7106],{"class":218},[197,9164,9165],{"class":199,"line":313},[197,9166,9167],{"class":203},"    \u002F\u002F 2. 全部排队中(pending)任务按 id asc 重新入队\n",[197,9169,9170,9172,9175],{"class":199,"line":349},[197,9171,811],{"class":210},[197,9173,9174],{"class":218}," ids []",[197,9176,9177],{"class":210},"uint\n",[197,9179,9180,9182,9184,9186,9188,9190,9192,9194],{"class":199,"line":360},[197,9181,9072],{"class":218},[197,9183,6917],{"class":214},[197,9185,219],{"class":218},[197,9187,842],{"class":210},[197,9189,6924],{"class":214},[197,9191,229],{"class":218},[197,9193,7039],{"class":214},[197,9195,7042],{"class":218},[197,9197,9198,9200,9202,9205,9207,9209,9211,9214,9216,9219,9221,9224,9226,9229,9231,9233],{"class":199,"line":365},[197,9199,9091],{"class":214},[197,9201,219],{"class":218},[197,9203,9204],{"class":256},"\"status = ?\"",[197,9206,513],{"class":218},[197,9208,7058],{"class":256},[197,9210,2403],{"class":218},[197,9212,9213],{"class":214},"Order",[197,9215,219],{"class":218},[197,9217,9218],{"class":256},"\"id asc\"",[197,9220,2403],{"class":218},[197,9222,9223],{"class":214},"Pluck",[197,9225,219],{"class":218},[197,9227,9228],{"class":256},"\"id\"",[197,9230,513],{"class":218},[197,9232,842],{"class":210},[197,9234,9235],{"class":218},"ids)\n",[197,9237,9238,9240,9243,9245,9247],{"class":199,"line":390},[197,9239,6711],{"class":210},[197,9241,9242],{"class":218}," _, id ",[197,9244,250],{"class":210},[197,9246,990],{"class":210},[197,9248,9249],{"class":218}," ids {\n",[197,9251,9252,9255,9258],{"class":199,"line":417},[197,9253,9254],{"class":218},"        s.",[197,9256,9257],{"class":214},"enqueue",[197,9259,9260],{"class":218},"(id)\n",[197,9262,9263],{"class":199,"line":427},[197,9264,288],{"class":218},[197,9266,9267],{"class":199,"line":432},[197,9268,9269],{"class":203},"    \u002F\u002F 3. 起 dispatcher goroutine\n",[197,9271,9272,9275,9278],{"class":199,"line":447},[197,9273,9274],{"class":218},"    s.",[197,9276,9277],{"class":214},"StartDispatcher",[197,9279,2198],{"class":218},[197,9281,9282],{"class":199,"line":461},[197,9283,464],{"class":218},[14,9285,9286,9287,9290],{},"dispatcher 是单 goroutine 常驻 + channel 唤醒，出队时对每个任务 ID 做",[72,9288,9289],{},"四重校验","（任务仍存在 \u002F 状态仍 pending \u002F 批次存在 \u002F 批次未暂停）——内存队列只是缓存，与 DB 的任何不一致都会在出队时自愈。这个设计让\"重启恢复可靠\"不再是特性，而是顺理成章的结果。并发上限 1–3 实时读配置热生效；改上限只影响后续出队，不打断运行中任务。",[14,9292,9293,9294,9297],{},"数据库用 GORM + ",[72,9295,9296],{},"glebarez\u002Fsqlite（纯 Go 驱动）","，没有 CGO，交叉编译 Windows 产物时不折腾 C 工具链——桌面应用场景下这一点值千金。",[14,9299,9300,9301,9304,9305,9308],{},"表结构上有一对值得说的配角：",[87,9302,9303],{},"TaskStep","（任务每个子步骤的状态与错误信息）和 ",[87,9306,9307],{},"GeneratedAsset","（每张产物图的路径、kind、序号、版本号）。有了\"步骤流水账\"，任何时刻重启后都能回答\"这个任务跑到哪一步了\"；有了\"资产表 + 版本号\"，单张图重新生成不覆盖旧版，用户在结果页可以横向对比来回切版本，导出时取最新版本即可。这两张表让\"断点续查、版本化产物、成本审计\"三件事共享同一份存储，不需要额外的状态文件。",[64,9310,9312],{"id":9311},"七byok-密钥aes-256-gcm-的轻量方案","七、BYOK 密钥：AES-256-GCM 的轻量方案",[14,9314,9315],{},"用户要填四家服务商的 API 密钥，明文绝不能落盘。桌面端的 OS 钥匙串各有平台 API，项目用了一个轻量的加密文件方案（约 200 行）：",[1984,9317,9318,9325,9332,9339],{},[80,9319,9320,9321,9324],{},"首次使用时生成 32 字节随机主密钥，存 ",[87,9322,9323],{},"keystore.key","（权限 0600）；",[80,9326,9327,9328,9331],{},"密钥明文以 AES-256-GCM 加密（nonce 前置拼进密文），base64 后写 ",[87,9329,9330],{},"secrets.json","（0600）；",[80,9333,9334,9335,9338],{},"对外只暴露配置状态和",[72,9336,9337],{},"尾号 4 位","供 UI 比对，不提供明文枚举接口；",[80,9340,9341,9342,9345],{},"目录权限 0700，全程 ",[87,9343,9344],{},"sync.Mutex"," 串行化（绑定方法可能被前端并发调用）。",[188,9347,9349],{"className":190,"code":9348,"language":192,"meta":193,"style":193},"nonce := make([]byte, aead.NonceSize())\nrand.Read(nonce)\nct := aead.Seal(nonce, nonce, []byte(plaintext), nil)  \u002F\u002F nonce+密文一起存\nb[provider] = entry{\n    CT:   base64.StdEncoding.EncodeToString(ct),\n    Tail: keyTail(plaintext),                          \u002F\u002F 只存展示用尾号\n}\n",[87,9350,9351,9375,9386,9415,9427,9438,9452],{"__ignoreMap":193},[197,9352,9353,9356,9358,9360,9363,9366,9369,9372],{"class":199,"line":200},[197,9354,9355],{"class":218},"nonce ",[197,9357,250],{"class":210},[197,9359,3889],{"class":214},[197,9361,9362],{"class":218},"([]",[197,9364,9365],{"class":210},"byte",[197,9367,9368],{"class":218},", aead.",[197,9370,9371],{"class":214},"NonceSize",[197,9373,9374],{"class":218},"())\n",[197,9376,9377,9380,9383],{"class":199,"line":207},[197,9378,9379],{"class":218},"rand.",[197,9381,9382],{"class":214},"Read",[197,9384,9385],{"class":218},"(nonce)\n",[197,9387,9388,9391,9393,9396,9399,9402,9404,9407,9409,9412],{"class":199,"line":244},[197,9389,9390],{"class":218},"ct ",[197,9392,250],{"class":210},[197,9394,9395],{"class":218}," aead.",[197,9397,9398],{"class":214},"Seal",[197,9400,9401],{"class":218},"(nonce, nonce, []",[197,9403,9365],{"class":210},[197,9405,9406],{"class":218},"(plaintext), ",[197,9408,3361],{"class":281},[197,9410,9411],{"class":218},")  ",[197,9413,9414],{"class":203},"\u002F\u002F nonce+密文一起存\n",[197,9416,9417,9420,9422,9425],{"class":199,"line":263},[197,9418,9419],{"class":218},"b[provider] ",[197,9421,781],{"class":210},[197,9423,9424],{"class":214}," entry",[197,9426,2067],{"class":218},[197,9428,9429,9432,9435],{"class":199,"line":275},[197,9430,9431],{"class":218},"    CT:   base64.StdEncoding.",[197,9433,9434],{"class":214},"EncodeToString",[197,9436,9437],{"class":218},"(ct),\n",[197,9439,9440,9443,9446,9449],{"class":199,"line":285},[197,9441,9442],{"class":218},"    Tail: ",[197,9444,9445],{"class":214},"keyTail",[197,9447,9448],{"class":218},"(plaintext),                          ",[197,9450,9451],{"class":203},"\u002F\u002F 只存展示用尾号\n",[197,9453,9454],{"class":199,"line":291},[197,9455,464],{"class":218},[14,9457,9458],{},"诚实说明威胁模型：这防的是\"文件被拷走\u002F同步盘泄露\"类低成本窥探，是 OS 钥匙串的轻量等价；本机已失陷的场景任何本地方案都无解。文档里写明白，比吹\"军事级加密\"体面得多。",[64,9460,9462],{"id":9461},"八开发调试与三平台打包","八、开发、调试与三平台打包",[188,9464,9466],{"className":2806,"code":9465,"language":2808,"meta":193,"style":193},"make dev       # wails dev：前端 Vite 热更新 + Go 侧改动自动重编译\nmake build     # 当前平台产物\nmake package   # 真安装包：make package os=windows|macos|linux → exe\u002Fdmg\u002FAppImage\n",[87,9467,9468,9479,9488],{"__ignoreMap":193},[197,9469,9470,9473,9476],{"class":199,"line":200},[197,9471,9472],{"class":214},"make",[197,9474,9475],{"class":256}," dev",[197,9477,9478],{"class":203},"       # wails dev：前端 Vite 热更新 + Go 侧改动自动重编译\n",[197,9480,9481,9483,9485],{"class":199,"line":207},[197,9482,9472],{"class":214},[197,9484,4084],{"class":256},[197,9486,9487],{"class":203},"     # 当前平台产物\n",[197,9489,9490,9492,9495],{"class":199,"line":244},[197,9491,9472],{"class":214},[197,9493,9494],{"class":256}," package",[197,9496,9497],{"class":203},"   # 真安装包：make package os=windows|macos|linux → exe\u002Fdmg\u002FAppImage\n",[14,9499,9500,9501,9504,9505,9508],{},"调试小技巧：Go 侧日志用 ",[87,9502,9503],{},"log\u002Fslog"," 写文件（lumberjack 滚动），生产态安装包没有终端可看，日志文件是唯一眼睛。开发态 ",[87,9506,9507],{},"wails dev"," 自带 Chromium DevTools，前端问题常规处理。",[14,9510,9511,9513,9514,9517,9518,9520,9521,9524,9525,9528,9529,9531],{},[87,9512,3236],{}," 背后是一个自写的 ",[87,9515,9516],{},"scripts\u002Fpackage.sh","，把各平台差异收敛成一条命令：macOS 编译出 ",[87,9519,1803],{}," 后用系统自带的 ",[87,9522,9523],{},"hdiutil"," 封装 dmg（零额外依赖）；Windows 用 ",[87,9526,9527],{},"wails build -nsis"," 出安装器，检测本机没有 makensis 时明确降级为绿色 exe 并打印提示——不假装成功。CI 上 GitHub Actions 三个 runner 各跑一遍 ",[87,9530,3236],{}," 传 artifact 即可。注意 Windows 代码签名和 macOS 公证是\"用户看到 SmartScreen 红牌与否\"的分水岭，预算允许尽早买证书。",[14,9533,9534,9535,471,9538,9540,9541,471,9544,9546,9547,9550,9551,9554,9555,9558],{},"还有一些桌面体验的\"最后一公里\"，Wails 都留了接口，本项目用到的有：",[72,9536,9537],{},"系统托盘",[87,9539,2975],{}," 包，Win\u002FLinux 上\"关闭=隐藏到托盘\"，macOS 无托盘则关闭即退出——这个差异要在代码里按平台分叉，不要想当然）；",[72,9542,9543],{},"原生对话框",[87,9545,2319],{}," 包的文件选择\u002F保存\u002F消息框，比 HTML ",[87,9548,9549],{},"\u003Cinput type=file>"," 体验好太多，而且拿到的是真实磁盘路径）；",[72,9552,9553],{},"外链处理","（应用内所有\"去官网注册密钥\"的链接必须走 ",[87,9556,9557],{},"runtime.BrowserOpenURL"," 交给系统浏览器，桌面应用里嵌外链页面是灾难）。这些细节单个都不难，凑起来就是\"像正经软件\"和\"像网页套壳\"的区别。",[64,9560,9562],{"id":9561},"九小结","九、小结",[14,9564,9565],{},"回头看这个技术选型组合：",[1133,9567,9568,9577],{},[1136,9569,9570],{},[1139,9571,9572,9575],{},[1142,9573,9574],{},"需求",[1142,9576,5596],{},[1152,9578,9579,9587,9595,9603,9611,9619,9627],{},[1139,9580,9581,9584],{},[1157,9582,9583],{},"桌面壳 + 前端生态",[1157,9585,9586],{},"Wails v2 + Vue3，产物嵌 embed.FS",[1139,9588,9589,9592],{},[1157,9590,9591],{},"前端调后端",[1157,9593,9594],{},"绑定方法（自动 TS 桩）",[1139,9596,9597,9600],{},[1157,9598,9599],{},"后端推进度",[1157,9601,9602],{},"EventsEmit\u002FEventsOn，service 持回调不 import wails",[1139,9604,9605,9608],{},[1157,9606,9607],{},"本地图片预览",[1157,9609,9610],{},"AssetServer Handler + 目录穿越校验",[1139,9612,9613,9616],{},[1157,9614,9615],{},"多模型接入",[1157,9617,9618],{},"OpenAI 兼容网关，一协议三参数",[1139,9620,9621,9624],{},[1157,9622,9623],{},"任务不丢",[1157,9625,9626],{},"SQLite 唯一真相 + 出队四重校验 + 启动恢复",[1139,9628,9629,9632],{},[1157,9630,9631],{},"密钥安全",[1157,9633,9634],{},"AES-256-GCM 加密文件，0600，只露尾号",[14,9636,9637],{},"Wails 的成熟度比我预期高：单实例锁、托盘、原生对话框、无边框这些桌面细节全有官方支持，一周就能把壳搭好；真正花时间的地方在第六、七节这些\"数据与钱\"的设计上。",[14,9639,9640,9641,9644,9645,9648],{},"系列",[18,9642,9643],{"href":6213},"第 1 篇","（流水线架构）和",[18,9646,9647],{"href":7356},"第 3 篇","（踩坑复盘）见本站。欢迎聊聊你用 Wails 或 Electron 的看法。",[1512,9650,9651],{},"html pre.shiki code .si27w, html code.shiki .si27w{--shiki-default:#6A737D;--shiki-light:#6A737D}html pre.shiki code .spKkM, html code.shiki .spKkM{--shiki-default:#F97583;--shiki-light:#D73A49}html pre.shiki code .shWlK, html code.shiki .shWlK{--shiki-default:#E1E4E8;--shiki-light:#24292E}html pre.shiki code .sqoU-, html code.shiki .sqoU-{--shiki-default:#B392F0;--shiki-light:#6F42C1}html pre.shiki code .skb7c, html code.shiki .skb7c{--shiki-default:#9ECBFF;--shiki-light:#032F62}html pre.shiki code .sTU5a, html code.shiki .sTU5a{--shiki-default:#79B8FF;--shiki-light:#005CC5}html pre.shiki code .sdxZB, html code.shiki .sdxZB{--shiki-default:#FFAB70;--shiki-light:#E36209}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-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.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);}",{"title":193,"searchDepth":207,"depth":207,"links":9653},[9654,9655,9656,9657,9663,9664,9665,9666,9667],{"id":7365,"depth":207,"text":7366},{"id":7406,"depth":207,"text":7407},{"id":7473,"depth":207,"text":7474},{"id":7945,"depth":207,"text":7946,"children":9658},[9659,9660,9661,9662],{"id":7949,"depth":244,"text":7950},{"id":8172,"depth":244,"text":8173},{"id":8423,"depth":244,"text":8424},{"id":8696,"depth":244,"text":8697},{"id":8733,"depth":207,"text":8734},{"id":9025,"depth":207,"text":9026},{"id":9311,"depth":207,"text":9312},{"id":9461,"depth":207,"text":9462},{"id":9561,"depth":207,"text":9562},"https:\u002F\u002Fname.os.bennywen.xyz\u002Fbennywen.xyz\u002Fassets\u002Fecom-detail-ai-part2-wails\u002Fmain-scene1.jpg",{},"\u002Fposts\u002Fecom-detail-ai-part2-wails",{"title":21,"description":193},"ecom-detail-ai-part2-wails","posts\u002Fecom-detail-ai-part2-wails","为什么是 Wails 不是 Electron、Go ↔ Vue 用绑定方法与事件两板斧、把 SQLite 当调度器的唯一真相、AES-256-GCM 存密钥——把一个 AI 桌面应用\"真正能用\"的那部分讲完。",[1537,1707],"rKFH8f0KFUjobHtU1zgRzY8k17msR8OhnlUCGq7wFq8",{"id":9678,"title":9679,"body":9680,"cover":10650,"date":7331,"description":193,"extension":1527,"links":1525,"meta":10651,"navigation":1529,"path":10652,"project":6226,"seo":10653,"slug":10654,"stem":10655,"summary":10656,"tags":10657,"visible":1529,"__hash__":10659},"posts\u002Fposts\u002Fecom-detail-ai-part1-architecture.md","我用 Go 搭了一条 AI Agent 流水线：从 1 张商品图到一整套淘宝详情页",{"type":8,"value":9681,"toc":10638},[9682,9693,9697,9700,9710,9713,9716,9722,9727,9733,9738,9742,9745,9751,9758,9768,9780,9786,9791,9795,9801,9926,9929,9934,9941,9944,9971,9975,9978,9989,10053,10056,10067,10071,10074,10084,10090,10096,10203,10206,10209,10216,10235,10242,10246,10249,10255,10276,10279,10286,10290,10297,10369,10379,10382,10389,10393,10396,10406,10477,10486,10555,10565,10572,10576,10579,10590,10593,10597,10600,10620,10629,10635],[11,9683,9684],{},[14,9685,9686,9687,9689,9690,9692],{},"系列说明：这是「AI 详情页生成工具」系列的第 1 篇，讲架构解析。第 2 篇 ",[18,9688,1553],{"href":20}," 讲怎么把这套东西落成桌面应用；第 3 篇 ",[18,9691,7357],{"href":7356}," 是踩坑复盘。",[64,9694,9696],{"id":9695},"一为什么做这件事","一、为什么做这件事",[14,9698,9699],{},"淘宝\u002F天猫的中小卖家上架一个新品，详情页通常要找外包美工：一套 300–1500 元，等 3–7 天。市面上的 AI 工具要么只能\"一键美化\"，要么生成的图里商品被\"重新设计\"得面目全非，还有的直接让大模型在图里\"写字\"，出来一堆变形假汉字。",[14,9701,9702,9703,9705,9706,9709],{},"我做的这个项目（",[18,9704,6226],{"href":6225},"）目标是：",[72,9707,9708],{},"上传 1 张商品图，全程不选品类、不选风格，约 10 分钟得到一整套带真实感光影的详情页长图 + 主图包 + 文案","。它是一个 Wails v2 桌面客户端（Go 后端 + Vue3 前端），核心是一条全自动的 Agent 流水线。",[14,9711,9712],{},"这篇文章把这条流水线完整拆开，讲讲每一步的设计取舍。文中代码节选自项目源码，有删减。",[14,9714,9715],{},"先看一眼最终产出效果（示例商品，均为项目真实生成结果）：",[14,9717,9718],{},[43,9719],{"alt":9720,"src":9721},"AI 生成的场景主图：逆光石墙前的商品，光影与商品结构均来自图生图编辑","https:\u002F\u002Fname.os.bennywen.xyz\u002Fbennywen.xyz\u002Fassets\u002Fecom-detail-ai-part1-architecture\u002Fmain-scene1.jpg",[14,9723,9724],{},[50,9725,9726],{},"图 1：AI 生成的场景主图。",[14,9728,9729],{},[43,9730],{"alt":9731,"src":9732},"排版引擎合成的详情切片：参数表与文字为真实字体叠加，画面区无任何 AI 生成文字","https:\u002F\u002Fname.os.bennywen.xyz\u002Fbennywen.xyz\u002Fassets\u002Fecom-detail-ai-part1-architecture\u002Fslice-02.jpg?imageMogr2\u002Fformat\u002Fwebp\u002Fquality\u002F88",[14,9734,9735],{},[50,9736,9737],{},"图 2：排版引擎合成的详情切片。参数表与文字为真实字体叠加。",[64,9739,9741],{"id":9740},"二流水线全景","二、流水线全景",[14,9743,9744],{},"一条任务从创建到完成，实际走过的步骤是：",[188,9746,9749],{"className":9747,"code":9748,"language":1919},[1917],"抠图(matte) → 读图感知(perceive) → 自主规划(plan) → [规划确认门]\n    → 渲染×N(render，含自检重渲染) → 画面描述(caption)\n    → 文案(copy) → 本机排版合成(layout) → [SOP 卡片] → 完成(成本落库)\n",[87,9750,9748],{"__ignoreMap":193},[14,9752,9753,9754,9757],{},"编排核心在 ",[87,9755,9756],{},"internal\u002Fservice\u002Fpipeline.go","。有两个设计决策值得先说：",[14,9759,9760,9763,9764,9767],{},[72,9761,9762],{},"第一，LLM 步骤和确定性步骤严格分家。"," 感知、规划、文案、自检这四个\"需要智商\"的环节收敛在 ",[87,9765,9766],{},"internal\u002Fagent\u002F"," 包里，这个包只做\"调模型 + 解析结构化结果\"，不碰数据库；抠图、渲染、排版、导出这些\"不需要智商但要紧\"的环节全是确定性代码。LLM 负责决策，代码负责执行，两边接口就是 JSON。",[14,9769,9770,9773,9774,9776,9777,9779],{},[72,9771,9772],{},"第二，每一步都持久化、都可观测。"," 每个子步骤写 ",[87,9775,9303],{}," 记录并通过 Wails 事件 ",[87,9778,8711],{}," 推给前端，用户在界面上能看到\"正在读图 → 识别为『便携榨汁机』→ 规划了 5 张图 → 第 2 张渲染触发限流，20 秒后重试……\"这样的实时时间线。做长耗时 Agent 产品，进度可见不是锦上添花，是信任的底座。",[14,9781,9782],{},[43,9783],{"alt":9784,"src":9785},"生成过程实况：左侧 AI 识别结果与生成计划，右侧流式图片墙与步骤进度条","https:\u002F\u002Fname.os.bennywen.xyz\u002Fbennywen.xyz\u002Fassets\u002Fecom-detail-ai-part1-architecture\u002Fsop-process.png?imageMogr2\u002Fthumbnail\u002F1440x\u002Fformat\u002Fwebp\u002Fquality\u002F88",[14,9787,9788],{},[50,9789,9790],{},"图 3：生成过程实况。左侧是 AI 识别结果与自主规划，右侧是随事件一张张\"长出来\"的流式图片墙，顶部是流水线步骤进度。",[64,9792,9794],{"id":9793},"三第一步感知先把商品看懂","三、第一步：感知——先把商品\"看懂\"",[14,9796,9797,9798,484],{},"流水线的质量上限由第一环决定。感知步骤用视觉模型（GLM-4.6V）读商品图，输出一个结构化 ",[87,9799,9800],{},"Analysis",[188,9802,9804],{"className":190,"code":9803,"language":192,"meta":193,"style":193},"type Analysis struct {\n    Name          string   `json:\"name\"`           \u002F\u002F 商品名建议\n    Category      string   `json:\"category\"`       \u002F\u002F 一级类目\n    Material      string   `json:\"material\"`       \u002F\u002F 材质\n    SellingPoints []string `json:\"selling_points\"` \u002F\u002F 卖点线索\n    Details       []string `json:\"details\"`        \u002F\u002F 结构特征：顶部提手\u002F侧面散热孔\u002Flogo位置\n    VisibleText   []string `json:\"visible_text\"`   \u002F\u002F 图中真实印有的文字，逐字转写\n    Params        []Param  `json:\"params\"`         \u002F\u002F 图中可见参数（键值对）\n    Palette       string   `json:\"palette\"`        \u002F\u002F 商品主色 hex，驱动排版配色\n}\n",[87,9805,9806,9817,9830,9843,9856,9869,9882,9895,9909,9922],{"__ignoreMap":193},[197,9807,9808,9810,9813,9815],{"class":199,"line":200},[197,9809,2454],{"class":210},[197,9811,9812],{"class":214}," Analysis",[197,9814,2460],{"class":210},[197,9816,241],{"class":218},[197,9818,9819,9822,9824,9827],{"class":199,"line":207},[197,9820,9821],{"class":218},"    Name          ",[197,9823,7077],{"class":210},[197,9825,9826],{"class":256},"   `json:\"name\"`",[197,9828,9829],{"class":203},"           \u002F\u002F 商品名建议\n",[197,9831,9832,9835,9837,9840],{"class":199,"line":244},[197,9833,9834],{"class":218},"    Category      ",[197,9836,7077],{"class":210},[197,9838,9839],{"class":256},"   `json:\"category\"`",[197,9841,9842],{"class":203},"       \u002F\u002F 一级类目\n",[197,9844,9845,9848,9850,9853],{"class":199,"line":263},[197,9846,9847],{"class":218},"    Material      ",[197,9849,7077],{"class":210},[197,9851,9852],{"class":256},"   `json:\"material\"`",[197,9854,9855],{"class":203},"       \u002F\u002F 材质\n",[197,9857,9858,9861,9863,9866],{"class":199,"line":275},[197,9859,9860],{"class":218},"    SellingPoints []",[197,9862,7077],{"class":210},[197,9864,9865],{"class":256}," `json:\"selling_points\"`",[197,9867,9868],{"class":203}," \u002F\u002F 卖点线索\n",[197,9870,9871,9874,9876,9879],{"class":199,"line":285},[197,9872,9873],{"class":218},"    Details       []",[197,9875,7077],{"class":210},[197,9877,9878],{"class":256}," `json:\"details\"`",[197,9880,9881],{"class":203},"        \u002F\u002F 结构特征：顶部提手\u002F侧面散热孔\u002Flogo位置\n",[197,9883,9884,9887,9889,9892],{"class":199,"line":291},[197,9885,9886],{"class":218},"    VisibleText   []",[197,9888,7077],{"class":210},[197,9890,9891],{"class":256}," `json:\"visible_text\"`",[197,9893,9894],{"class":203},"   \u002F\u002F 图中真实印有的文字，逐字转写\n",[197,9896,9897,9900,9903,9906],{"class":199,"line":313},[197,9898,9899],{"class":218},"    Params        []",[197,9901,9902],{"class":214},"Param",[197,9904,9905],{"class":256},"  `json:\"params\"`",[197,9907,9908],{"class":203},"         \u002F\u002F 图中可见参数（键值对）\n",[197,9910,9911,9914,9916,9919],{"class":199,"line":349},[197,9912,9913],{"class":218},"    Palette       ",[197,9915,7077],{"class":210},[197,9917,9918],{"class":256},"   `json:\"palette\"`",[197,9920,9921],{"class":203},"        \u002F\u002F 商品主色 hex，驱动排版配色\n",[197,9923,9924],{"class":199,"line":360},[197,9925,464],{"class":218},[14,9927,9928],{},"系统提示词里有一条铁律，是整个项目对抗幻觉的第一道闸：",[11,9930,9931],{},[14,9932,9933],{},"\"visible_text 与 params 只允许逐字转写图中真实可见的内容，看不清或没有就返回空数组，禁止凭常识推断补全——编造参数属于违规。\"",[14,9935,9936,9937,9940],{},"这条规则后面还会反复出现：",[72,9938,9939],{},"下游任何环节（规划、文案、参数表）能引用的事实，最多到感知输出 ∪ 用户输入为止","。模型看不到的，宁可空着，不许编。",[14,9942,9943],{},"另外两个细节：",[1984,9945,9946,9953],{},[80,9947,9948,9949,9952],{},"对\"抽 JSON\"这类任务，视觉模型的思考模式要显式关掉（",[87,9950,9951],{},"WithThinkingDisabled()","）。长推理链只会拖慢请求甚至拖挂，对抽取质量没有帮助。",[80,9954,9955,9956,9959,9960,9963,9964,9966,9967,9970],{},"解析模型输出时不能天真地 ",[87,9957,9958],{},"json.Unmarshal","。模型爱包 markdown 代码围栏、爱加前后缀解释语，所以有一个 ",[87,9961,9962],{},"unmarshalJSON","：剥掉围栏，取首个 ",[87,9965,2145],{}," 到末个 ",[87,9968,9969],{},"}"," 再解析。这是所有要求纯 JSON 输出的 LLM 应用的标配防御。",[64,9972,9974],{"id":9973},"四第二步规划让模型当导演但给它剧本框架","四、第二步：规划——让模型当导演，但给它剧本框架",[14,9976,9977],{},"规划步骤（DeepSeek）根据识别结果决定：出几张图、每张什么 kind（whitebg \u002F hero \u002F scene \u002F detail \u002F selling）、什么场景和光影、详情页模块序列、文案基调。同样是纯 JSON 输出。",[14,9979,9980,9981,9984,9985,9988],{},"这里踩过一个很典型的坑：",[72,9982,9983],{},"当你想\"让模型自己决定数量\"时，提示词里绝不能出现数字 0","。早期版本用户不指定张数时传 ",[87,9986,9987],{},"image_count: 0","，模型非常听话地规划了 0 张图，任务直接中断。现在的写法：",[188,9990,9992],{"className":190,"code":9991,"language":192,"meta":193,"style":193},"countHint := fmt.Sprintf(\"计划 %d 张图\", imageCount)\nif imageCount \u003C= 0 {\n    \u002F\u002F 0 不能出现在提示词里，否则模型会\"听话地\"返回 0 个条目\n    countHint = \"渲染张数由你决定（3-8 张）\"\n}\n",[87,9993,9994,10021,10034,10039,10049],{"__ignoreMap":193},[197,9995,9996,9999,10001,10004,10007,10009,10012,10015,10018],{"class":199,"line":200},[197,9997,9998],{"class":218},"countHint ",[197,10000,250],{"class":210},[197,10002,10003],{"class":218}," fmt.",[197,10005,10006],{"class":214},"Sprintf",[197,10008,219],{"class":218},[197,10010,10011],{"class":256},"\"计划 ",[197,10013,10014],{"class":281},"%d",[197,10016,10017],{"class":256}," 张图\"",[197,10019,10020],{"class":218},", imageCount)\n",[197,10022,10023,10025,10028,10030,10032],{"class":199,"line":207},[197,10024,1259],{"class":210},[197,10026,10027],{"class":218}," imageCount ",[197,10029,607],{"class":210},[197,10031,610],{"class":281},[197,10033,241],{"class":218},[197,10035,10036],{"class":199,"line":244},[197,10037,10038],{"class":203},"    \u002F\u002F 0 不能出现在提示词里，否则模型会\"听话地\"返回 0 个条目\n",[197,10040,10041,10044,10046],{"class":199,"line":263},[197,10042,10043],{"class":218},"    countHint ",[197,10045,781],{"class":210},[197,10047,10048],{"class":256}," \"渲染张数由你决定（3-8 张）\"\n",[197,10050,10051],{"class":199,"line":275},[197,10052,464],{"class":218},[14,10054,10055],{},"规划还是整条流水线的第一环，输出空 items 会直接中断任务，所以规划调用内置了一次\"自动纠正重试\"：解析失败或 items 为空时，追加一条用户消息骂它\"必须输出至少 3 个条目\"，再给它一次机会。",[14,10057,10058,10059,10062,10063,10066],{},"规划完成后有一个",[72,10060,10061],{},"规划确认门","：默认模式下停留 30 秒\"机会窗口\"（用户可以顺手调整计划）后自动继续；确认模式下任务置为 ",[87,10064,10065],{},"planned"," 状态最长挂 10 分钟等用户点继续。全自动和人在回路之间，我选择两个都要，用配置切换。",[64,10068,10070],{"id":10069},"五第三步渲染五段式提示词与商品保真","五、第三步：渲染——五段式提示词与商品保真",[14,10072,10073],{},"渲染是整条流水线最贵的环节（按张计费），也是最容易翻车的环节：图生图模型会\"顺手优化\"你的商品——换个 logo、改个孔位，一套图下来已经是另一个产品了。",[14,10075,10076,10077,471,10080,10083],{},"对策是",[72,10078,10079],{},"五段式渲染提示词",[87,10081,10082],{},"internal\u002Fagent\u002Fprompt.go","），把一条 prompt 拆成模型自由度截然不同的五段：",[188,10085,10088],{"className":10086,"code":10087,"language":1919},[1917],"保留段（零自由）+ 场景段（模型规划）+ 光影段（词库映射）+ 构图段（按kind固定）+ 品质段（全kind共享）\n",[87,10089,10087],{"__ignoreMap":193},[14,10091,10092,10093,484],{},"保留段不是一句\"保持一致\"，而是引用感知结果",[72,10094,10095],{},"逐条点名",[188,10097,10099],{"className":190,"code":10098,"language":192,"meta":193,"style":193},"b.WriteString(\"严格保持参考图中的商品原样不变\")\nif len(a.Details) > 0 {\n    b.WriteString(\"，以下特征必须原样保留、不得改动：\")\n    b.WriteString(strings.Join(a.Details, \"、\"))  \u002F\u002F 顶部提手、侧面散热孔……\n}\nif len(a.VisibleText) > 0 {\n    b.WriteString(\"商品上的文字「…」必须逐字保持原样、清晰无变形。\")\n}\n",[87,10100,10101,10116,10131,10145,10167,10171,10186,10199],{"__ignoreMap":193},[197,10102,10103,10106,10109,10111,10114],{"class":199,"line":200},[197,10104,10105],{"class":218},"b.",[197,10107,10108],{"class":214},"WriteString",[197,10110,219],{"class":218},[197,10112,10113],{"class":256},"\"严格保持参考图中的商品原样不变\"",[197,10115,310],{"class":218},[197,10117,10118,10120,10122,10125,10127,10129],{"class":199,"line":207},[197,10119,1259],{"class":210},[197,10121,901],{"class":214},[197,10123,10124],{"class":218},"(a.Details) ",[197,10126,637],{"class":210},[197,10128,610],{"class":281},[197,10130,241],{"class":218},[197,10132,10133,10136,10138,10140,10143],{"class":199,"line":244},[197,10134,10135],{"class":218},"    b.",[197,10137,10108],{"class":214},[197,10139,219],{"class":218},[197,10141,10142],{"class":256},"\"，以下特征必须原样保留、不得改动：\"",[197,10144,310],{"class":218},[197,10146,10147,10149,10151,10153,10155,10158,10161,10164],{"class":199,"line":263},[197,10148,10135],{"class":218},[197,10150,10108],{"class":214},[197,10152,8570],{"class":218},[197,10154,8616],{"class":214},[197,10156,10157],{"class":218},"(a.Details, ",[197,10159,10160],{"class":256},"\"、\"",[197,10162,10163],{"class":218},"))  ",[197,10165,10166],{"class":203},"\u002F\u002F 顶部提手、侧面散热孔……\n",[197,10168,10169],{"class":199,"line":275},[197,10170,464],{"class":218},[197,10172,10173,10175,10177,10180,10182,10184],{"class":199,"line":285},[197,10174,1259],{"class":210},[197,10176,901],{"class":214},[197,10178,10179],{"class":218},"(a.VisibleText) ",[197,10181,637],{"class":210},[197,10183,610],{"class":281},[197,10185,241],{"class":218},[197,10187,10188,10190,10192,10194,10197],{"class":199,"line":291},[197,10189,10135],{"class":218},[197,10191,10108],{"class":214},[197,10193,219],{"class":218},[197,10195,10196],{"class":256},"\"商品上的文字「…」必须逐字保持原样、清晰无变形。\"",[197,10198,310],{"class":218},[197,10200,10201],{"class":199,"line":313},[197,10202,464],{"class":218},[14,10204,10205],{},"（显式列出保留项而不是笼统说\"保持一致\"，这是 Google 编辑模型官方指南的要求，实测也确实有效。）",[14,10207,10208],{},"光影段用中文预设映射到摄影术语词库——\"柔光\"映射为\"柔光箱布光，光线均匀柔和，阴影过渡自然\"，比扔形容词给模型稳定得多。构图段按 kind 写死（hero 固定\"三分之二侧视角，低机位平视，商品占画面 60%~70%\"），品质段全 kind 共享，保证整套详情页视觉统一。品质段里有一句\"无霓虹描边、光影符合真实商业摄影物理规律\"，来自实测教训：规划给出\"轮廓光勾边\"时，Seedream 会字面化执行成赛博朋克霓虹描边。",[14,10210,10211,10212,10215],{},"渲染的工程骨架是 ",[87,10213,10214],{},"renderOne","：多参考图 data URL → 图生图 → 下载落盘 → 自检。两个细节：",[1984,10217,10218,10229],{},[80,10219,10220,10223,10224,6627,10226,10228],{},[72,10221,10222],{},"限流指数退避","：生图新账户 RPM 极低，429 是常态。识别\"速率限制\u002F429\u002FThrottling\"等错误特征，按 10s\u002F20s\u002F40s\u002F60s 退避重试，等待期间用 ",[87,10225,6626],{},[87,10227,6630],{},"，用户点取消立刻退出等待。",[80,10230,10231,10234],{},[72,10232,10233],{},"重试必须带诊断","：自检不过触发重渲染时，同参数重跑毫无意义。第二次的 prompt 会追加\"上一轮生成存在问题：商品与原图不一致、光影违和。重新生成时务必修正，其余要求不变。\"——把评审意见回灌给生成模型。",[14,10236,10237,10238,10241],{},"首屏（hero）是转化关键位，我给它加了",[72,10239,10240],{},"双候选选优","：规划构图和\"正面平视\"构图各渲染一次，由自检打分择优保留。best-of-N 是图像生成里性价比最高的质量杠杆，一次 +0.1 元的成本。但注意代码注释里的实测教训：双候选本身已是有效重试，单候选就不再叠加自检重试了，否则最坏情况一个槽位烧掉 4 张图的钱。",[64,10243,10245],{"id":10244},"六文字零幻觉这个项目我最满意的设计","六、\"文字零幻觉\"：这个项目我最满意的设计",[14,10247,10248],{},"电商详情页有海量的文字：标题、卖点、参数表。让生图模型画文字，出来必然是变形假汉字；那能不能让模型\"尽量别画字\"？也不行，模型管不住自己的手。",[14,10250,10251,10252,484],{},"我的方案是",[72,10253,10254],{},"从源头把文字这件事从 AI 手里收走",[77,10256,10257,10264,10267],{},[80,10258,10259,10260,10263],{},"规划提示词里写入铁律（",[87,10261,10262],{},"planNoTextRule","）：场景描述不得携带\"画文字\"的意图，prompt 只描述商品、场景、氛围与构图；",[80,10265,10266],{},"渲染品质段再显式禁止：\"画面中不得出现任何文字、数字、标注、标签贴纸（商品本身印刷的文字除外）\"——注意例外条款要与保留段的\"商品上印刷的文字逐字保留\"自洽，否则模型会困惑；",[80,10268,10269,2001,10272,10275],{},[72,10270,10271],{},"真实文字由本机排版引擎后期叠加",[87,10273,10274],{},"internal\u002Frender\u002F"," 是一个手写的 750px 宽位图合成引擎，用 golang.org\u002Fx\u002Fimage 加系统 CJK 字体，把文案以真实字体排进对应模块槽位。",[14,10277,10278],{},"这样 AI 只负责\"画面\"，排版引擎负责\"文字\"，各干各的擅长的事。字体、字号、行距、配色全部可控，改一个字不需要重新生成图片。顺带的好处是：文案编辑后实时预览重排版只要几百毫秒，而重生成一张图要几十秒加几毛钱。",[14,10280,10281,10282,10285],{},"自检环节还会专门检查\"画面文字错误\"（",[87,10283,10284],{},"text_error"," 是硬伤 tag，直接判不通过）——禁令 + 质检双保险。",[64,10287,10289],{"id":10288},"七第四步自检模型当评委代码设门禁","七、第四步：自检——模型当评委，代码设门禁",[14,10291,10292,10293,10296],{},"自检用视觉模型做",[72,10294,10295],{},"双图对比","：参考图 + 生成图 + 规划描述一起给\"质检员\"，输出：",[188,10298,10300],{"className":190,"code":10299,"language":192,"meta":193,"style":193},"type CheckResult struct {\n    Pass       bool     `json:\"pass\"`\n    Fidelity   float64  `json:\"fidelity\"`     \u002F\u002F 商品保持度 0-1\n    Tags       []string `json:\"tags\"`         \u002F\u002F 诊断标签（五选一枚举）\n    Issue      string   `json:\"issue\"`        \u002F\u002F 不通过的人话原因\n    ...\n}\n",[87,10301,10302,10312,10322,10335,10348,10361,10365],{"__ignoreMap":193},[197,10303,10304,10306,10308,10310],{"class":199,"line":200},[197,10305,2454],{"class":210},[197,10307,6699],{"class":214},[197,10309,2460],{"class":210},[197,10311,241],{"class":218},[197,10313,10314,10317,10319],{"class":199,"line":207},[197,10315,10316],{"class":218},"    Pass       ",[197,10318,238],{"class":210},[197,10320,10321],{"class":256},"     `json:\"pass\"`\n",[197,10323,10324,10327,10329,10332],{"class":199,"line":244},[197,10325,10326],{"class":218},"    Fidelity   ",[197,10328,1325],{"class":210},[197,10330,10331],{"class":256},"  `json:\"fidelity\"`",[197,10333,10334],{"class":203},"     \u002F\u002F 商品保持度 0-1\n",[197,10336,10337,10340,10342,10345],{"class":199,"line":263},[197,10338,10339],{"class":218},"    Tags       []",[197,10341,7077],{"class":210},[197,10343,10344],{"class":256}," `json:\"tags\"`",[197,10346,10347],{"class":203},"         \u002F\u002F 诊断标签（五选一枚举）\n",[197,10349,10350,10353,10355,10358],{"class":199,"line":275},[197,10351,10352],{"class":218},"    Issue      ",[197,10354,7077],{"class":210},[197,10356,10357],{"class":256},"   `json:\"issue\"`",[197,10359,10360],{"class":203},"        \u002F\u002F 不通过的人话原因\n",[197,10362,10363],{"class":199,"line":285},[197,10364,2483],{"class":210},[197,10366,10367],{"class":199,"line":291},[197,10368,464],{"class":218},[14,10370,10371,10372,10375,10376,2001],{},"关键认知：",[72,10373,10374],{},"不能只信模型的 pass 判断，服务端要做一道确定性门限","。模型评委有放水倾向，所以拿到评分后代码再硬校验一遍——fidelity \u003C 0.95 直接判不过；出现 product_drift \u002F structure_deform \u002F text_error 任一硬伤 tag 直接判不过；light_off、scene_mismatch 这类氛围问题单独出现时可放行但保留提示。这就是 ",[87,10377,10378],{},"enforceCheckGate",[14,10380,10381],{},"诊断 tags 同时服务两个消费方：回灌重渲染的 prompt（机器读），和前端时间线上\"待人工确认\"的 warn 状态（人读）。一张\"自检存疑\"的图不会被扔掉，而是标黄交给用户决定——自动化系统的最后一格永远留给人。",[14,10383,10384,10385,10388],{},"场景图\u002F卖点图自检仍不过时，还有",[72,10386,10387],{},"合成保真兜底","：文生图只生成纯背景（提示词明确\"不要出现任何商品\"），再把真实抠图的商品像素贴回去。商品像素 100% 真实，不存在漂移，直接免自检。这也是\"省流模式\"的底层路线，更便宜且成本可预期。",[64,10390,10392],{"id":10391},"八多模型分工一个网关五个模型位","八、多模型分工：一个网关、五个模型位",[14,10394,10395],{},"没有任何一家平台的每个模态都最强，所以我按\"每个环节用该环节性价比最优的模型\"设计，同时用架构把供应商锁定风险压到零。",[14,10397,10398,10399,471,10402,10405],{},"配置抽象成五个",[72,10400,10401],{},"模型位",[87,10403,10404],{},"internal\u002Fconfig\u002Fconfig.go","）：",[1133,10407,10408,10420],{},[1136,10409,10410],{},[1139,10411,10412,10414,10417],{},[1142,10413,10401],{},[1142,10415,10416],{},"默认配置",[1142,10418,10419],{},"职责",[1152,10421,10422,10433,10444,10455,10466],{},[1139,10423,10424,10427,10430],{},[1157,10425,10426],{},"perception",[1157,10428,10429],{},"智谱 GLM-4.6V",[1157,10431,10432],{},"读图感知 + 成品自检",[1139,10434,10435,10438,10441],{},[1157,10436,10437],{},"brain",[1157,10439,10440],{},"DeepSeek flash（fallback: pro）",[1157,10442,10443],{},"规划（Agent 大脑）",[1139,10445,10446,10449,10452],{},[1157,10447,10448],{},"copy",[1157,10450,10451],{},"DeepSeek flash",[1157,10453,10454],{},"详情文案",[1139,10456,10457,10460,10463],{},[1157,10458,10459],{},"render",[1157,10461,10462],{},"GLM-Image（可切方舟 Seedream）",[1157,10464,10465],{},"图像生成",[1139,10467,10468,10471,10474],{},[1157,10469,10470],{},"matting",[1157,10472,10473],{},"阿里云商品分割",[1157,10475,10476],{},"抠图",[14,10478,10479,10480,10483,10484,484],{},"而网关层（",[87,10481,10482],{},"internal\u002Fllm\u002Fgateway.go","）只实现 OpenAI 兼容协议，",[72,10485,8740],{},[188,10487,10489],{"className":190,"code":10488,"language":192,"meta":193,"style":193},"var presetBaseURL = map[string]string{\n    \"zhipu\":    \"https:\u002F\u002Fopen.bigmodel.cn\u002Fapi\u002Fpaas\u002Fv4\",\n    \"deepseek\": \"https:\u002F\u002Fapi.deepseek.com\",\n    \"ark\":      \"https:\u002F\u002Fark.cn-beijing.volces.com\u002Fapi\u002Fv3\",\n}\n",[87,10490,10491,10513,10526,10538,10551],{"__ignoreMap":193},[197,10492,10493,10495,10498,10500,10503,10505,10507,10509,10511],{"class":199,"line":200},[197,10494,6449],{"class":210},[197,10496,10497],{"class":218}," presetBaseURL ",[197,10499,781],{"class":210},[197,10501,10502],{"class":210}," map",[197,10504,7074],{"class":218},[197,10506,7077],{"class":210},[197,10508,7080],{"class":218},[197,10510,7077],{"class":210},[197,10512,2067],{"class":218},[197,10514,10515,10518,10521,10524],{"class":199,"line":207},[197,10516,10517],{"class":256},"    \"zhipu\"",[197,10519,10520],{"class":218},":    ",[197,10522,10523],{"class":256},"\"https:\u002F\u002Fopen.bigmodel.cn\u002Fapi\u002Fpaas\u002Fv4\"",[197,10525,2078],{"class":218},[197,10527,10528,10531,10533,10536],{"class":199,"line":244},[197,10529,10530],{"class":256},"    \"deepseek\"",[197,10532,7090],{"class":218},[197,10534,10535],{"class":256},"\"https:\u002F\u002Fapi.deepseek.com\"",[197,10537,2078],{"class":218},[197,10539,10540,10543,10546,10549],{"class":199,"line":263},[197,10541,10542],{"class":256},"    \"ark\"",[197,10544,10545],{"class":218},":      ",[197,10547,10548],{"class":256},"\"https:\u002F\u002Fark.cn-beijing.volces.com\u002Fapi\u002Fv3\"",[197,10550,2078],{"class":218},[197,10552,10553],{"class":199,"line":275},[197,10554,464],{"class":218},[14,10556,10557,10558,10560,10561,10564],{},"智谱、DeepSeek、火山方舟全部兼容 chat\u002Fcompletions，一个 ",[87,10559,8791],{}," 方法通吃。唯一的协议例外是图生图——各家参考图参数形状不一致，我把它隔离在 ",[87,10562,10563],{},"images.go"," 一个文件里，注释写明\"联调时只允许改本文件，调用方不受影响\"。脏活收敛在最小面积里。",[14,10566,10567,10568,10571],{},"BYOK（用户自带密钥）则顺便解决了合规和成本透明：费用直接结算在用户自己的模型账号，每个任务结束时按刊例价折算消耗（",[87,10569,10570],{},"taskCost"," 记录 tokens 与出图张数）在界面明示。产品方不经手资金、不存储商品图，排版合成全在本机。",[64,10573,10575],{"id":10574},"九文案与排版的防呆契约","九、文案与排版的防呆契约",[14,10577,10578],{},"文案（DeepSeek）按模块撰写，有两条铁律写进 system prompt：参数只能用\"图中可见参数 ∪ 卖家输入\"，都没有就写\"详细参数以实物为准\"；不同模块之间不得复述同一卖点，\"重复即浪费\"。",[14,10580,10581,10582,10585,10586,10589],{},"模型输出与排版引擎之间还有一层容量契约（",[87,10583,10584],{},"CleanCopy","）：标题 ≤12 字、正文 ≤80 字，与排版字号档位配套，超限截断防溢出；空块丢弃；清洗后一块不剩就回退到用感知字段拼装的基础文案——",[72,10587,10588],{},"流水线可以降级，但绝不产出空结果","。文案步骤整体失败也不阻塞任务，兜底文案照常出，前端提示可手动修改。",[14,10591,10592],{},"顺带一提\"文案对图\"的小设计：渲染完成后每张成图会过一遍视觉模型生成一句 ≤40 字的画面描述（caption），文案的 user 消息里会带上\"第 2 张 scene\u002F卧室床头：暖光下商品立在床头柜上……\"，让文字模块写出来的话和画面真正呼应，消灭图文两张皮。",[64,10594,10596],{"id":10595},"十小结","十、小结",[14,10598,10599],{},"回头看，这条流水线的核心方法论就三条：",[77,10601,10602,10608,10614],{},[80,10603,10604,10607],{},[72,10605,10606],{},"LLM 输出全部收敛为纯 JSON + 结构化解析 + 确定性门限","。模型负责创意和判断，代码负责校验和执行，评委放水就硬拦。",[80,10609,10610,10613],{},[72,10611,10612],{},"每个易幻觉的环节都有\"事实源白名单\"","：参数只能来自图中可见 ∪ 卖家输入；文字只能由排版引擎写入画面。",[80,10615,10616,10619],{},[72,10617,10618],{},"失败路径层层兜底且成本如实","：限流退避 → 诊断回灌重渲染 → 合成保真兜底 → 本地裁切兜底 → 基础文案兜底；同时每一张付费生成的图都计数进成本明细，重试也计费，绝不自欺。",[14,10621,10622,10623,6231,10626,10628],{},"这个工具是怎么在桌面端把这套东西跑起来的（Wails 绑定、SQLite 调度器、密钥加密、三平台打包），以及一路上踩过的具体的坑，分别是系列",[18,10624,10625],{"href":20},"第 2 篇",[18,10627,9647],{"href":7356},"的主题。",[14,10630,10631,10632,10634],{},"（项目代码节选自个人开源\u002F私有项目 ",[18,10633,6226],{"href":6225},"，均已做删减；文中模型价格与配置以写稿时为准。）",[1512,10636,10637],{},"html pre.shiki code .spKkM, html code.shiki .spKkM{--shiki-default:#F97583;--shiki-light:#D73A49}html pre.shiki code .sqoU-, html code.shiki .sqoU-{--shiki-default:#B392F0;--shiki-light:#6F42C1}html pre.shiki code .shWlK, html code.shiki .shWlK{--shiki-default:#E1E4E8;--shiki-light:#24292E}html pre.shiki code .skb7c, html code.shiki .skb7c{--shiki-default:#9ECBFF;--shiki-light:#032F62}html pre.shiki code .si27w, html code.shiki .si27w{--shiki-default:#6A737D;--shiki-light:#6A737D}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-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.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 pre.shiki code .sTU5a, html code.shiki .sTU5a{--shiki-default:#79B8FF;--shiki-light:#005CC5}",{"title":193,"searchDepth":207,"depth":207,"links":10639},[10640,10641,10642,10643,10644,10645,10646,10647,10648,10649],{"id":9695,"depth":207,"text":9696},{"id":9740,"depth":207,"text":9741},{"id":9793,"depth":207,"text":9794},{"id":9973,"depth":207,"text":9974},{"id":10069,"depth":207,"text":10070},{"id":10244,"depth":207,"text":10245},{"id":10288,"depth":207,"text":10289},{"id":10391,"depth":207,"text":10392},{"id":10574,"depth":207,"text":10575},{"id":10595,"depth":207,"text":10596},"https:\u002F\u002Fname.os.bennywen.xyz\u002Fbennywen.xyz\u002Fassets\u002Fecom-detail-ai\u002Fecom-detail-hero.webp",{},"\u002Fposts\u002Fecom-detail-ai-part1-architecture",{"title":9679,"description":193},"ecom-detail-ai-part1-architecture","posts\u002Fecom-detail-ai-part1-architecture","抠图 → 感知 → 规划 → 渲染 ×N → 文案 → 本机排版 → 自检，一条全自动 Agent 流水线。五个模型位怎么分工、文字零幻觉怎么做到、模型评委放水怎么硬拦——都在这里。",[6198,10658,1537],"Agent","ia1uLxgj0mONsQ0zKn8DbiAxf1LvbzrTe7FSjf6_aJ4",{"id":10661,"title":10662,"body":10663,"cover":1525,"date":12034,"description":193,"extension":1527,"links":1525,"meta":12035,"navigation":1529,"path":12036,"project":6226,"seo":12037,"slug":12038,"stem":12039,"summary":12040,"tags":12041,"visible":1529,"__hash__":12044},"posts\u002Fposts\u002Fai-requirement-landing.md","AI 时代，如何把一个需求真正落地成应用？——一条\"文档驱动 + 实测修订\"的实战路径",{"type":8,"value":10664,"toc":12009},[10665,10670,10674,10677,10688,10694,10714,10717,10724,10727,10730,10734,10738,10743,10749,10755,10760,10766,10772,10777,10781,10784,10803,10814,10820,10825,10829,10832,10878,10882,10959,10963,10966,10986,10990,10993,11000,11085,11092,11096,11102,11118,11121,11127,11145,11152,11158,11162,11173,11177,11180,11284,11291,11295,11298,11305,11308,11313,11320,11327,11331,11334,11398,11401,11404,11418,11422,11425,11433,11436,11440,11443,11448,11451,11463,11470,11473,11491,11494,11500,11504,11515,11518,11524,11530,11536,11542,11576,11594,11604,11608,11614,11697,11700,11709,11718,11724,11728,11731,11746,11749,11775,11781,11785,11792,11829,11835,11839,11842,11848,11853,11856,11962,11965,11988,11991,11993,12000],[11,10666,10667],{},[14,10668,10669],{},"本文素材全部来自一个真实项目：ecom-detail-ai——上传 1 张商品图，约 10 分钟得到一整套带真实感光影渲染的淘宝详情页。项目 2026-09-12 起步，到本文写作时刚满一个月，M1-M4 里程碑已全部完成。文中所有数据、版本记录、决策原文均为生产仓库实录。",[64,10671,10673],{"id":10672},"一ai-时代最大的悖论代码变便宜了想清楚变贵了","一、AI 时代最大的悖论：代码变便宜了，\"想清楚\"变贵了",[14,10675,10676],{},"2026 年做一个 AI 应用，技术门槛已经低到离谱：",[1984,10678,10679,10682,10685],{},[80,10680,10681],{},"模型？国产 API 全部兼容 OpenAI 协议，换个 base_url 就能切；",[80,10683,10684],{},"代码？AI 编码工具一小时能给你撸出一个完整脚手架；",[80,10686,10687],{},"Demo？一个周末能搭三个。",[14,10689,10690,10691,484],{},"但打开任何一个 AI 产品社区，你会看到另一番景象：Demo 满天飞，能用的产品很少。大量项目死在同一个位置——",[72,10692,10693],{},"从\"需求\"到\"落地\"之间的那条沟",[1984,10695,10696,10699,10702,10705,10708],{},[80,10697,10698],{},"需求没拍板就动手，做到一半发现方向错了；",[80,10700,10701],{},"模型选型拍脑袋，上线才发现成本结构根本不成立；",[80,10703,10704],{},"关键决策不留痕，三个月后没人说得清当时为什么这么设计，重构时反复翻烧饼；",[80,10706,10707],{},"实施靠感觉，没有\"问题在哪、改哪里、怎么验证\"的闭环；",[80,10709,10710,10711,2001],{},"最要命的：",[72,10712,10713],{},"文档写完就束之高阁，代码和文档各跑各的",[14,10715,10716],{},"这篇不讲具体某个技术点，讲一条我们用一个月时间、从一句产品构想到跑通全链路的方法论。概括成一句话：",[11,10718,10719],{},[14,10720,10721],{},[72,10722,10723],{},"文档驱动开发，实测修订文档。",[14,10725,10726],{},"需求、选型、决策、实施、验收，每一步都先落成文档；每一次实测得到的结论，回头修订文档。文档不是汇报材料，是开发的\"单一真相源\"和\"驾驶仪表\"。",[14,10728,10729],{},"下面按实战顺序拆开讲。为了照顾不了解案例背景的读者，第二节先把项目交代清楚，后面所有举例都基于它。",[64,10731,10733],{"id":10732},"二先交代案例这个项目是什么","二、先交代案例：这个项目是什么",[123,10735,10737],{"id":10736},"_21-一句话与核心链路","2.1 一句话与核心链路",[14,10739,10740,10742],{},[72,10741,6226],{},"：一个 Wails v2（Go + Vue3）桌面应用，面向淘宝\u002F天猫中小卖家——上传 1 张商品图，约 10 分钟拿到一整套详情页（主图 + 长图切片 + 文案），全程不用选品类、不用选风格。替代的是\"外包 300-1500 元\u002F套、3-7 天交付\"的传统做法。",[14,10744,10745,10746,484],{},"它的核心是一条",[72,10747,10748],{},"全自动 Agent 流水线",[14,10750,10751],{},[43,10752],{"alt":10753,"src":10754},"全自动 Agent 流水线：从上传商品图到淘宝规格导出的九个环节","https:\u002F\u002Fname.os.bennywen.xyz\u002Fbennywen.xyz\u002Fassets\u002Fai-requirement-landing\u002Fagent-pipeline.svg",[14,10756,10757],{},[50,10758,10759],{},"图 1：全自动 Agent 流水线。左列自上而下是预处理与规划，右列自下而上是生成与交付；红色回环是自检不达标时的自动重渲染。",[14,10761,10762,10763,2001],{},"四个 LLM\u002F视觉服务商分工协作，用户自带密钥（BYOK），单套 API 成本约 1-3 元。实测单套（5 图）",[72,10764,10765],{},"8 分 27 秒",[14,10767,10768],{},[43,10769],{"alt":10770,"src":10771},"生成过程实录：左侧 AI 识别结果与生成计划，右侧渲染出图墙","https:\u002F\u002Fname.os.bennywen.xyz\u002Fbennywen.xyz\u002Fassets\u002Fai-requirement-landing\u002Fsop-process.png?imageMogr2\u002Fthumbnail\u002F1132x\u002Fformat\u002Fwebp\u002Fquality\u002F88",[14,10773,10774],{},[50,10775,10776],{},"图 2：生成过程实录。左侧是 AI 识别结果与自主规划（本任务规划了 6 张图），右侧是渲染出图墙，顶部是流水线步骤进度，左下角是自检报告。",[123,10778,10780],{"id":10779},"_22-成品长什么样","2.2 成品长什么样",[14,10782,10783],{},"一次真实任务（任务 #4，露肩碎花连衣裙）的导出产物：",[18,10785,10794,10795],{"className":10786,"href":10788,"target":10789,"rel":10790,"title":10793},[10787],"shot-zoom","https:\u002F\u002Fname.os.bennywen.xyz\u002Fbennywen.xyz\u002Fassets\u002Fai-requirement-landing\u002Fsop-long-image.png","_blank",[10791,10792],"noopener","noreferrer","点击查看完整长图","\n  ",[43,10796],{"className":10797,"src":10799,"alt":10800,"loading":10801,"decoding":10802},[10798],"tall-shot","https:\u002F\u002Fname.os.bennywen.xyz\u002Fbennywen.xyz\u002Fassets\u002Fai-requirement-landing\u002Fsop-long-image.png?imageMogr2\u002Fformat\u002Fwebp\u002Fquality\u002F88","750px 详情长图：首屏、卖点、参数、场景、售后模块由 Agent 自主规划","lazy","async",[14,10804,10805,10808,10809,10813],{},[50,10806,10807],{},"图 3：750px 详情长图。首屏、卖点、参数、场景、售后等模块由 Agent 自主规划，排版合成本机完成。","\n（原图 750×5586 过长，此处限高展示，",[18,10810,10812],{"href":10788,"rel":10811},[57],"点开看完整长图 ↗","）",[14,10815,10816],{},[43,10817],{"alt":10818,"src":10819},"800×800 白底主图：淘宝规格硬性要求","https:\u002F\u002Fname.os.bennywen.xyz\u002Fbennywen.xyz\u002Fassets\u002Fai-requirement-landing\u002Fmain-white.jpg",[14,10821,10822],{},[50,10823,10824],{},"图 4：同一任务的主图包产物之一——800×800 白底图。淘宝规格要求 5 张主图里至少有一张纯白背景，这张由阿里云商品分割直接输出，不经过生成模型重绘。",[123,10826,10828],{"id":10827},"_23-四个关键事实","2.3 四个关键事实",[14,10830,10831],{},"先记住这四个事实，后面的例子都挂在它们上面：",[1133,10833,10834,10844],{},[1136,10835,10836],{},[1139,10837,10838,10841],{},[1142,10839,10840],{},"事实",[1142,10842,10843],{},"内容",[1152,10845,10846,10854,10862,10870],{},[1139,10847,10848,10851],{},[1157,10849,10850],{},"① 多模型协作",[1157,10852,10853],{},"四家服务商四个岗位：智谱（读图+自检）、DeepSeek（规划+文案）、火山方舟（出图）、阿里云（抠图）",[1139,10855,10856,10859],{},[1157,10857,10858],{},"② BYOK 模式",[1157,10860,10861],{},"用户自带模型密钥，商品图只发到用户自己账户下的模型服务，产品方不经手不存储；每个任务的消耗金额明示给用户",[1139,10863,10864,10867],{},[1157,10865,10866],{},"③ 成本敏感",[1157,10868,10869],{},"单套 API 成本 1-3 元，其中图像生成占 95% 以上——成本结构直接决定商业模式",[1139,10871,10872,10875],{},[1157,10873,10874],{},"④ 桌面客户端",[1157,10876,10877],{},"Wails（Go + Vue3），排版合成本机完成，批量任务走本地队列调度",[123,10879,10881],{"id":10880},"_24-术语卡后文不再解释","2.4 术语卡（后文不再解释）",[1133,10883,10884,10894],{},[1136,10885,10886],{},[1139,10887,10888,10891],{},[1142,10889,10890],{},"术语",[1142,10892,10893],{},"一句话解释",[1152,10895,10896,10904,10912,10920,10928,10935,10943,10951],{},[1139,10897,10898,10901],{},[1157,10899,10900],{},"详情页",[1157,10902,10903],{},"淘宝商品页里那套决定转化的长图 + 图文组合",[1139,10905,10906,10909],{},[1157,10907,10908],{},"主图 \u002F 白底图",[1157,10910,10911],{},"商品列表页 800×800 方图，平台强制至少一张纯白背景",[1139,10913,10914,10917],{},[1157,10915,10916],{},"切片",[1157,10918,10919],{},"长图按平台高度限制切成多张上传",[1139,10921,10922,10925],{},[1157,10923,10924],{},"BYOK",[1157,10926,10927],{},"Bring Your Own Key，用户自带模型密钥，费用走自己账户",[1139,10929,10930,10932],{},[1157,10931,10401],{},[1157,10933,10934],{},"流水线里某个环节对模型的\"岗位\"，一个岗位一个选型",[1139,10936,10937,10940],{},[1157,10938,10939],{},"VLM",[1157,10941,10942],{},"视觉语言模型，能\"看图\"的大模型",[1139,10944,10945,10948],{},[1157,10946,10947],{},"盲测",[1157,10949,10950],{},"同一商品图喂给多个候选模型，人工对比选优",[1139,10952,10953,10956],{},[1157,10954,10955],{},"限流（RPM）",[1157,10957,10958],{},"模型 API 的调用速率配额，超了返回 429",[123,10960,10962],{"id":10961},"_25-为什么拿它当案例","2.5 为什么拿它当案例",[14,10964,10965],{},"三个原因：",[77,10967,10968,10974,10980],{},[80,10969,10970,10973],{},[72,10971,10972],{},"链路完整","：从一句产品构想到 M1-M4 里程碑跑完，每个阶段都有文档产物，是\"需求落地方法论\"的完整观察样本；",[80,10975,10976,10979],{},[72,10977,10978],{},"AI 原生成分高","：概率性依赖（模型输出不稳定）、多模型协作、成本敏感、需要自检闭环——传统软件的坑它一个不少，还多一层\"依赖是租来的\"；",[80,10981,10982,10985],{},[72,10983,10984],{},"坑都落在文档里","：盲测反转、限流约束、联调数据全部有记录——这正是方法论最好的验证材料。",[64,10987,10989],{"id":10988},"三第一步prd-是拍板记录不是需求散文","三、第一步：PRD 是\"拍板记录\"，不是\"需求散文\"",[14,10991,10992],{},"先看一个反面样本：大多数项目的 PRD 是一篇散文——背景、目标、功能列表，写完评审完就躺进硬盘，直到项目结束没人再打开。",[14,10994,10995,10996,10999],{},"我们的做法：",[72,10997,10998],{},"PRD 是一份带修订记录的活文档，每个版本解决一类问题","。看它的版本演变：",[1133,11001,11002,11015],{},[1136,11003,11004],{},[1139,11005,11006,11009,11012],{},[1142,11007,11008],{},"版本",[1142,11010,11011],{},"日期",[1142,11013,11014],{},"这个版本在记什么",[1152,11016,11017,11028,11038,11048,11061,11075],{},[1139,11018,11019,11022,11025],{},[1157,11020,11021],{},"v0.9",[1157,11023,11024],{},"09-12",[1157,11026,11027],{},"需求探索：8 个模块全景 + 5 个待拍板问题（Q1-Q5）",[1139,11029,11030,11033,11035],{},[1157,11031,11032],{},"v1.0",[1157,11034,11024],{},[1157,11036,11037],{},"Q1-Q5 全部拍板，升级为正式 PRD；补业界对标、平台规格、FR 编号与优先级",[1139,11039,11040,11043,11045],{},[1157,11041,11042],{},"v1.1",[1157,11044,11024],{},[1157,11046,11047],{},"内部评审修订：修掉隐私口径矛盾、AI 标识合规、TaskStep 粒度等一串问题",[1139,11049,11050,11053,11055],{},[1157,11051,11052],{},"v1.3",[1157,11054,11024],{},[1157,11056,11057,11060],{},[72,11058,11059],{},"真实 key 全流程联调通过","：联调数据、踩坑、结论回写（第八节细讲）",[1139,11062,11063,11066,11069],{},[1157,11064,11065],{},"v1.5",[1157,11067,11068],{},"09-13",[1157,11070,11071,11074],{},[72,11072,11073],{},"盲测结果导致选型反转","：渲染主选更换（第四节细讲）",[1139,11076,11077,11080,11082],{},[1157,11078,11079],{},"v1.7",[1157,11081,11068],{},[1157,11083,11084],{},"平台水印可控性 + 标识分层的实测结论",[14,11086,11087,11088,11091],{},"注意 v1.3、v1.5、v1.7 的特殊性：它们都不是\"需求变更\"，而是",[72,11089,11090],{},"实测结论倒逼文档修订","。PRD 在这里不是立项时写一次的石碑，而是跟着项目一起生长的账本。",[123,11093,11095],{"id":11094},"_31-三个可以直接抄走的写法","3.1 三个可以直接抄走的写法",[14,11097,11098,11101],{},[72,11099,11100],{},"第一，用户故事用标准三段式，一个故事一个验收点。"," \"批量生成\"功能（一次导入 100 个 SKU 挂机跑完，补的是稿定 CSV 批量套版的能力缺口）的需求是这么写的：",[11,11103,11104],{},[14,11105,11106,11109,11110,11113,11114,11117],{},[72,11107,11108],{},"As a"," 新注册低 RPM 配额的卖家，",[72,11111,11112],{},"I want"," 批量任务按全局并发上限（默认 1，可配 1-3）排队逐个执行，",[72,11115,11116],{},"so that"," 不会因为多任务并行撞 API 限流导致整批失败。",[14,11119,11120],{},"一句话里包含：角色、动作、可量化的参数（并发 1-3）、要防的风险（限流）。评审时哪个参数有问题，当场就能吵清楚。",[14,11122,11123,11126],{},[72,11124,11125],{},"第二，\"明确不做\u002F边界\"比\"要做什么\"更重要。"," 同一份批量 PRD 里专门有一节：",[1984,11128,11129,11132,11139,11142],{},[80,11130,11131],{},"不动渲染管线内部：感知\u002F规划\u002F渲染\u002F文案\u002F排版各环节零改动，批处理只是调度层；",[80,11133,11134,11135,11138],{},"零新增依赖：CSV 解析用标准库 ",[87,11136,11137],{},"encoding\u002Fcsv","，不引 Excel 转码库；",[80,11140,11141],{},"不做批量专属管理页：复用现有任务列表；",[80,11143,11144],{},"留 P2：夜间定时批跑、断点续跑、Excel 直接解析。",[14,11146,11147,11148,11151],{},"这一节是 MVP 的护栏。AI 编码时代\"顺手多做一个功能\"的边际成本极低，",[72,11149,11150],{},"不做清单是防止范围失控的唯一物理阻断","——和上一篇讲的架构护栏一个道理：约定必须变成某种形式的\"断言\"。",[14,11153,11154,11157],{},[72,11155,11156],{},"第三，待确认问题单独成节，允许带着问题上会。"," Q1-Q6 每个问题给一个\"建议答案 + 理由\"，评审的工作从\"讨论\"降级为\"确认或否决建议\"。小团队这个写法能把评审会从两小时压到二十分钟。",[64,11159,11161],{"id":11160},"四第二步选型先于编码且必须拆位-实测","四、第二步：选型先于编码，且必须\"拆位 + 实测\"",[14,11163,11164,11165,11168,11169,11172],{},"AI 应用和传统软件最大的不同：",[72,11166,11167],{},"你的核心能力是租来的","。租什么、怎么租、租金多少，直接决定产品成不成立。所以我们的顺序是：产品构想 → PRD 拍板 → ",[72,11170,11171],{},"选型验证"," → 才写第一行业务代码。",[123,11174,11176],{"id":11175},"_41-拆模型位不要指望一个模型全包","4.1 拆模型位：不要指望一个模型全包",[14,11178,11179],{},"选型文档的第一件事，是把流水线拆成 6 个\"模型位\"（对应 2.1 节链路图的各个环节）：",[1133,11181,11182,11197],{},[1136,11183,11184],{},[1139,11185,11186,11188,11191,11194],{},[1142,11187,10401],{},[1142,11189,11190],{},"任务",[1142,11192,11193],{},"关键要求",[1142,11195,11196],{},"类型",[1152,11198,11199,11213,11227,11240,11254,11267],{},[1139,11200,11201,11204,11207,11210],{},[1157,11202,11203],{},"① 感知 VLM",[1157,11205,11206],{},"读商品图 → 结构化 JSON",[1157,11208,11209],{},"读图准、JSON 稳、便宜（高频）",[1157,11211,11212],{},"多模态",[1139,11214,11215,11218,11221,11224],{},[1157,11216,11217],{},"② Agent 大脑",[1157,11219,11220],{},"推理规划：选管线、定张数、排模块",[1157,11222,11223],{},"指令遵循、结构化输出",[1157,11225,11226],{},"文本 LLM",[1139,11228,11229,11232,11235,11238],{},[1157,11230,11231],{},"③ 文案",[1157,11233,11234],{},"卖点文案、参数介绍",[1157,11236,11237],{},"中文电商语感、性价比",[1157,11239,11226],{},[1139,11241,11242,11245,11248,11251],{},[1157,11243,11244],{},"④ 抠图",[1157,11246,11247],{},"提取透明产品 PNG",[1157,11249,11250],{},"边缘精细度",[1157,11252,11253],{},"视觉模型",[1139,11255,11256,11259,11262,11265],{},[1157,11257,11258],{},"⑤ 视觉重建",[1157,11260,11261],{},"深度图、法线图",[1157,11263,11264],{},"几何准确性",[1157,11266,11253],{},[1139,11268,11269,11272,11278,11281],{},[1157,11270,11271],{},"⑥ 图像生成",[1157,11273,11274,11275,10813],{},"统一光影出图（",[72,11276,11277],{},"成本大头",[1157,11279,11280],{},"光影真实感、商品一致性",[1157,11282,11283],{},"生成模型",[14,11285,11286,11287,11290],{},"拆完位，结论自然浮出水面：",[72,11288,11289],{},"这 6 个位对模型的要求完全不同，必须多模型分层路由","——这是 2026 年 Agent 工程的第一共识。感知位要\"便宜大碗\"（单次读图不到 1 分钱），大脑位要\"指令遵循和 JSON 稳定性\"，出图位要\"商品一致性\"。用一个旗舰模型全包？钱包先死。",[123,11292,11294],{"id":11293},"_42-一次真实的盲测反转价格不是选型的唯一标准","4.2 一次真实的\"盲测反转\"：价格不是选型的唯一标准",[14,11296,11297],{},"这是整个选型阶段最值钱的一课。",[14,11299,11300,11301,11304],{},"初版选型，出图位主推 GLM-Image：",[72,11302,11303],{},"0.1 元\u002F张，MIT 开源，指令遵循和文字渲染突出","，账面成本碾压 Seedream 4.5（0.25 元\u002F张）。文档里连降级顺位都排好了。",[14,11306,11307],{},"结果用真实商品（折叠手机支架）盲测，结论当场反转：",[11,11309,11310],{},[14,11311,11312],{},"Seedream 4.5 商品一致性接近完美——铰链、折叠臂、挂钩、底座全保留，甚至延续原图场景；GLM-Image 是\"同类不同款\"的重绘。",[14,11314,11315,11316,11319],{},"对电商详情页这个场景，",[72,11317,11318],{},"商品一致性是生死线","：支架的铰链结构变了，消费者收到货和详情页两回事，这是假图，是平台合规红线。0.15 元\u002F张的差价，在\"能不能用\"面前不值一提。选型文档随即修订：Seedream 4.5 升为主选，GLM-Image 降为低成本备选。",[14,11321,11322,11323,11326],{},"这个案例的方法论价值：",[72,11324,11325],{},"选型文档不是调研报告，是待验证的假设清单","。每个\"主推\"都要标注验证方式和验证成本。盲测三个候选，成本是几十块钱加半天——这点成本在\"上线后返工\"面前等于零。AI 时代试错变便宜了，\"先实测再拍板\"从奢侈品变成了基本操作。",[123,11328,11330],{"id":11329},"_43-成本测算先算账再动手","4.3 成本测算：先算账，再动手",[14,11332,11333],{},"选型文档按最终组合重算了单套成本（10 张图口径）：",[1133,11335,11336,11345],{},[1136,11337,11338],{},[1139,11339,11340,11342],{},[1142,11341,5213],{},[1142,11343,11344],{},"成本",[1152,11346,11347,11355,11363,11370,11378,11386],{},[1139,11348,11349,11352],{},[1157,11350,11351],{},"感知（GLM-4.6V，~5K in \u002F 1K out）",[1157,11353,11354],{},"~0.01 元",[1139,11356,11357,11360],{},[1157,11358,11359],{},"大脑规划（DeepSeek Flash）",[1157,11361,11362],{},"~0.05 元",[1139,11364,11365,11368],{},[1157,11366,11367],{},"文案（同上）",[1157,11369,11362],{},[1139,11371,11372,11375],{},[1157,11373,11374],{},"出图 10 张（GLM-Image @0.1）",[1157,11376,11377],{},"1.0 元",[1139,11379,11380,11383],{},[1157,11381,11382],{},"自检（GLM-4.6V）",[1157,11384,11385],{},"~0.02 元",[1139,11387,11388,11393],{},[1157,11389,11390],{},[72,11391,11392],{},"合计（GLM-Image 低成本方案）",[1157,11394,11395],{},[72,11396,11397],{},"≈ 1.1 元\u002F套",[14,11399,11400],{},"（盲测反转后若全程用 Seedream 4.5 主选出图，单套约 2.6 元；产品定价按 2-8 元\u002F套的 API 成本核算，空间立得住。）",[14,11402,11403],{},"两个关键认知：",[77,11405,11406,11412],{},[80,11407,11408,11411],{},[72,11409,11410],{},"LLM 环节合计不到 1 毛钱，可以随便用；成本几乎全在出图","（占 95% 以上）——所以优化成本要盯着出图位打，在感知\u002F文案上抠性价比是捡芝麻；",[80,11413,11414,11417],{},[72,11415,11416],{},"成本结构决定商业模式","：单套 1-3 元的边际成本，让\"BYOK + 成本透明展示\"成立——我们敢把每个任务的消耗金额明示给用户看，就是因为算得过这笔账。",[123,11419,11421],{"id":11420},"_44-一个坑位预警模型能力边界要写进落地清单","4.4 一个坑位预警：模型能力边界要写进落地清单",[14,11423,11424],{},"选型文档专门记了落地注意点，其中一条救了后面无数时间：",[11,11426,11427],{},[14,11428,11429,11432],{},[72,11430,11431],{},"DeepSeek V4.1-Flash 是纯文本模型","——读图和自检全部走 GLM-4.6V，大脑只消费结构化 JSON 文本，编排层注意不要给 DeepSeek 传图。",[14,11434,11435],{},"这类\"能力边界 + 编排纪律\"，在选型阶段写下来，实现阶段就是 checklist；不写，就是某个深夜的一次玄学 bug。",[64,11437,11439],{"id":11438},"五第三步决策留痕用-adr-把为什么钉住","五、第三步：决策留痕，用 ADR 把\"为什么\"钉住",[14,11441,11442],{},"代码回答\"怎么做\"，文档回答\"为什么\"。大多数项目只记前者，于是三个月后的重构会议上，大家对着一段\"看不懂但不敢删\"的代码面面相觑。",[14,11444,10995,11445,2001],{},[72,11446,11447],{},"关键决策一人一篇 ADR（架构决策记录），固定三段式：背景 → 决策 → 后果",[14,11449,11450],{},"以 ADR-0007 为例，它在解决一个真实的两难：",[1984,11452,11453,11460],{},[80,11454,11455,11456,11459],{},"上一轮刚把\"商品类目\"（如\"羽绒服\"\"零食\"）升级为可配置实体，但类目的备注里反复写着\"尺码表\u002F配料\u002F资质数据",[72,11457,11458],{},"必须来自用户输入","\"——羽绒服的面料成分、零食的保质期，AI 从商品图里根本看不出来，只能编；而编造参数在电商场景是假图红线。可系统里当时根本没有\"用户结构化输入\"的通道，参数全靠 AI 猜；",[80,11461,11462],{},"同时卡片视觉模板系统已跑起来，但没有任何东西替不同品类\"选一个合适的默认模板\"。",[14,11464,11465,11466,11469],{},"决策部分干净利落：纳入 A（结构化参数表单）\u002F C（类目默认模板）\u002F D（生成默认开关）三个方向，",[72,11467,11468],{},"B（合规红线结构化校验）明确不做","，留待后续。",[14,11471,11472],{},"而全文最值得学的是**\"后果\"一节——主动写下代价和要守住的东西**：",[1984,11474,11475,11482,11485],{},[80,11476,11477,11478,11481],{},"人像硬约束引入\"敏感类目\"标志（如内衣\u002F泳装强制关闭人像场景），必须用显式 id 集合判定，",[72,11479,11480],{},"不得依赖关键词模糊匹配","（安全项要确定性）；",[80,11483,11484],{},"C 让全局默认从\"最终决定\"退为\"兜底\"，设置页文案必须说清，避免用户困惑\"我设的没生效\"；",[80,11486,11487,11488,2001],{},"优先级链统一遵循：",[72,11489,11490],{},"显式用户选择 > 类目默认 > 全局\u002FAI 兜底",[14,11492,11493],{},"一段决策，附带三条\"未来可能踩的坑\"和一条统一的优先级哲学。后来者（包括三个月后的你自己）读到这篇，五分钟后就能获得作者当时三天的上下文。",[14,11495,11496,11499],{},[72,11497,11498],{},"ADR 的写作要领：决策部分可以很短，但\"后果 + 代价\"不能省。"," 只写\"我们决定用 X\"的 ADR 是半成品；写出\"选了 X，我们付出了 Y，未来要守住 Z\"才是完整品。",[64,11501,11503],{"id":11502},"六第四步需求到任务的翻译层openspec-规格治理","六、第四步：需求到任务的\"翻译层\"——OpenSpec 规格治理",[14,11505,11506,11507,11510,11511,11514],{},"PRD 说的是\"产品要什么\"，ADR 说的是\"为什么这么设计\"，还差一层：",[72,11508,11509],{},"这个变更具体改哪些文件、动哪些能力、怎么验收","。这一层我们交给 OpenSpec 管，流程是 ",[87,11512,11513],{},"proposal → design → specs → tasks","，落地后归档留痕。",[14,11516,11517],{},"看一个真实提案的骨架——\"首次启动配置向导\"（BYOK 产品的第一道门槛：新用户打开应用后，四步引导配好模型密钥，否则连工作台都进不去）：",[14,11519,11520,11523],{},[72,11521,11522],{},"Why","：首次使用门槛是最大流失点——用户要自己判断四把 key 哪两把必需、找到设置页、逐张展开指引，配完面对空工作台。",[14,11525,11526,11529],{},[72,11527,11528],{},"What Changes","：五条变更，每条一句话，包括\"首次启动以本地不存在配置文件为唯一信号\"\"隐私承诺前移到任何真实调用与扣费之前\"。",[14,11531,11532,11535],{},[72,11533,11534],{},"Capabilities","：新建 \u002F 修改哪些能力规格。",[14,11537,11538,11541],{},[72,11539,11540],{},"Impact","：落到文件级——",[1984,11543,11544,11556,11564,11570],{},[80,11545,11546,11548,11549,2305,11552,11555],{},[87,11547,10404],{},"：新增 ",[87,11550,11551],{},"OnboardingDone",[87,11553,11554],{},"OnboardingSkipped"," 持久化字段；",[80,11557,11558,11560,11561,11563],{},[87,11559,1657],{}," + ",[87,11562,3011],{},"：新增绑定方法，需重新生成；",[80,11565,11566,11569],{},[87,11567,11568],{},"frontend\u002Fsrc\u002Fcomponents\u002FOnboardingWizard.vue","（新）：四步覆层；",[80,11571,11572,11573,11575],{},"零新依赖（",[87,11574,7226],{}," 不动）；样式一律走既有设计 tokens。",[14,11577,11578,11579,11582,11583,11586,11587,11589,11590,11593],{},"为什么 Impact 要写到文件名？这是 ",[72,11580,11581],{},"AI 时代的新变化","：文档的第一读者已经从人变成了 AI 编码 agent。agent 没有你大脑里的项目上下文，\"改 ",[87,11584,11585],{},"onboarding.go","、在 ",[87,11588,4418],{}," 加引导闸门、复用 ",[87,11591,11592],{},"ProviderGuidePanel.vue","\"这种粒度，恰好是 agent 能直接执行、人也能 review 的甜点。写得粗（\"做个向导\"），agent 自由发挥，产出随机；写得细（到文件和复用组件），产出稳定。",[14,11595,11596,11597,11600,11601,2001],{},"顺带说 Capabilities 的规格库：项目把能力按域维护在 ",[87,11598,11599],{},"openspec\u002Fspecs\u002F"," 下（onboarding-wizard、architecture-guardrails、dependency-registry 等二十多个），新变更提案必须声明新建\u002F修改哪些能力——这和上一篇的护栏是同一套哲学：",[72,11602,11603],{},"约定要变成可校验的结构，而不是散落的散文",[64,11605,11607],{"id":11606},"七第五步实施用问题定位表驱动而不是感觉驱动","七、第五步：实施用\"问题定位表\"驱动，而不是感觉驱动",[14,11609,11610,11611,484],{},"到了写代码阶段，最常见的失败模式是\"重构一时爽，改完到处火\"。我们的做法来自一份真实优化方案：初版流水线跑通后，产出的详情页\"效果一般\"，这份方案的任务就是定位\"一般在哪\"——它的核心是一张",[72,11612,11613],{},"问题定位表",[1133,11615,11616,11632],{},[1136,11617,11618],{},[1139,11619,11620,11623,11626,11629],{},[1142,11621,11622],{},"#",[1142,11624,11625],{},"病灶",[1142,11627,11628],{},"代码位置",[1142,11630,11631],{},"后果",[1152,11633,11634,11649,11664,11682],{},[1139,11635,11636,11638,11641,11646],{},[1157,11637,1309],{},[1157,11639,11640],{},"感知仅 7 字段，无细节特征、无可见参数",[1157,11642,11643],{},[87,11644,11645],{},"internal\u002Fagent\u002Fagent.go:72-73",[1157,11647,11648],{},"下游规划\u002F渲染\u002F文案全部泛化；参数表无数据源只能编造",[1139,11650,11651,11653,11656,11661],{},[1157,11652,3187],{},[1157,11654,11655],{},"规划是\"伪规划\"：模块固定 5 个写死、prompt ≤60 字",[1157,11657,11658],{},[87,11659,11660],{},"agent.go:93-95",[1157,11662,11663],{},"出图缺乏摄影语义；模块结构千篇一律",[1139,11665,11666,11668,11671,11679],{},[1157,11667,3200],{},[1157,11669,11670],{},"自检是\"盲检\"：只传生成图，不给原图与场景描述",[1157,11672,11673,7310,11676],{},[87,11674,11675],{},"agent.go:161-167",[87,11677,11678],{},"pipeline.go:421-425",[1157,11680,11681],{},"自检形同虚设；同参数重跑无意义",[1139,11683,11684,11686,11689,11694],{},[1157,11685,3214],{},[1157,11687,11688],{},"文案模型看不到图，输入只有 7 字段",[1157,11690,11691],{},[87,11692,11693],{},"agent.go:140-149",[1157,11695,11696],{},"套话文案，图文不对齐",[14,11698,11699],{},"三个设计要点：",[14,11701,11702,11705,11706,11708],{},[72,11703,11704],{},"第一，病灶必须锚定到代码行号。"," \"效果一般\"不是问题，\"",[87,11707,11660],{}," 模块写死\"才是问题。前者只能开会，后者可以直接改。这一条是 AI 编码时代的放大器：你把带行号的问题表丢给 AI，它改得比谁都准；只丢一句\"优化下效果\"，它只能瞎猜。",[14,11710,11711,11714,11715],{},[72,11712,11713],{},"第二，优先搬\"已验证的设计\"，别发明轮子。"," 方案里专门列了姊妹项目（同脚手架的闲鱼素材生成器）的借鉴清单：粘贴电商详情作为核心输入（≤4000 字）、按角色差异化文案 + 跨卡不重复铁律、图片完整显示不裁切（原话：\"卖货最忌讳图切没了\"）——每条都标注了\"已验证结论\"再落入本期方案。",[72,11716,11717],{},"别的项目踩过的坑，是你最便宜的选型依据。",[14,11719,11720,11723],{},[72,11721,11722],{},"第三，P0-P2 分级 + 设计原则前置。"," 方案开头五条设计原则：\"商品像素尽量不过生成器\"\"prompt 分层\"\"编造即违规：参数\u002F资质类信息只允许来自图中可见转写或用户输入，缺失就降级占位\"——原则是排序工具，P0\u002FP1 打架时按原则裁断。其中\"编造即违规\"直接对着电商假图红线：参数卡的数据源只有两个——图中转写，或用户输入，AI 猜的不算数。",[64,11725,11727],{"id":11726},"八第六步实测数据回写文档形成闭环","八、第六步：实测数据回写文档，形成闭环",[14,11729,11730],{},"这是整套方法论里最容易被跳过、但 ROI 最高的一步。看 PRD v1.3 的真实联调记录（节选）：",[11,11732,11733],{},[14,11734,11735,11738,11739,11742,11743,11745],{},[72,11736,11737],{},"M1\u002FM2 开发完成并真实 key 全流程联调通过","：三把密钥连通（GLM-4.6V 需关思考模式，640ms）；GLM-Image 图生图参数形状实测可用；新账户 glm-image 限流严格，",[72,11740,11741],{},"渲染并发降为 1 + 指数退避重试（10\u002F20\u002F40\u002F60s）","；单套（5 图）实测 ",[72,11744,10765],{},"，符合 ≤10 分钟目标。",[14,11747,11748],{},"一段话，四类信息：",[77,11750,11751,11757,11763,11769],{},[80,11752,11753,11756],{},[72,11754,11755],{},"踩坑","：GLM-4.6V 要关思考模式——不记下来，下次配环境再踩一遍；",[80,11758,11759,11762],{},[72,11760,11761],{},"约束","：新账户限流（RPM）严格，出图并发一高就撞 429——所以渲染并发从设计值降为 1（一张一张来），退避序列定为 10\u002F20\u002F40\u002F60 秒。这个约束后来直接影响了批量调度的并发上限设计（默认 1、可配 1-3）；",[80,11764,11765,11768],{},[72,11766,11767],{},"验收","：8 分 27 秒 vs ≤10 分钟目标——价值主张\"约 10 分钟得到整套详情页\"从此是实测背书，不是营销话术；",[80,11770,11771,11774],{},[72,11772,11773],{},"修订","：渲染并发、重试策略、批量并发默认值，全部因为这段实测回写而改变。",[14,11776,11777,11780],{},[72,11778,11779],{},"文档的尊严是这么来的：它的每个数字都有人在现场为其负责。"," 反观大多数项目，PRD 里的\"支持高并发\"直到上线都没人验证过。",[64,11782,11784],{"id":11783},"九第七步质量兜底把稳定性当需求做","九、第七步：质量兜底——把\"稳定性\"当需求做",[14,11786,11787,11788,11791],{},"AI 应用和传统软件还有个区别：",[72,11789,11790],{},"你的依赖是概率性的","。同一个 prompt，这次返回合法 JSON，下次裹一层 markdown 围栏；这次商品一致，下次铰链漂移。所以\"质量控制\"不是测试阶段的事，是架构阶段的事。我们把四道闸做进了流水线：",[77,11793,11794,11807,11817,11823],{},[80,11795,11796,11799,11800,954,11803,11806],{},[72,11797,11798],{},"结构化输出校验","：所有模型输出强制 JSON，解析前剥围栏；字段级容错（模型输出 ",[87,11801,11802],{},"key",[87,11804,11805],{},"label"," 都能认）；失败自动重试——选型文档原话：\"这是 Agent 稳定性的第一道闸\"。",[80,11808,11809,11812,11813,11816],{},[72,11810,11811],{},"自检门限","：自检环节双图对比（原图 vs 生成图），商品保持度 fidelity \u003C 0.95 直接 fail（对齐 OpenAI Image Evals 门限），不通过自动重渲染，",[72,11814,11815],{},"且诊断 tags 回灌下一次重试","——\"同参数重跑无意义\"是业内用教训换来的结论。",[80,11818,11819,11822],{},[72,11820,11821],{},"编造拦截","：前面说的\"参数只来自图中转写或用户输入\"，落到代码就是 KV 权威合并规则：用户填的 > AI 猜的，AI 只补空。",[80,11824,11825,11828],{},[72,11826,11827],{},"架构护栏","：约定即测试——app.go 不许 import gorm、新模型必须登记 AllModels、新增依赖必须登记 deps.yaml。上一篇专门讲过，不展开。",[14,11830,11831,11832],{},"四道闸的共同哲学：",[72,11833,11834],{},"概率性依赖，要用确定性工程兜底。",[64,11836,11838],{"id":11837},"十总结一条可复制的落地路径","十、总结：一条可复制的落地路径",[14,11840,11841],{},"整个路径画出来是这样：",[14,11843,11844],{},[43,11845],{"alt":11846,"src":11847},"需求落地七步路径：①-④ 规划期，⑤-⑦ 执行期，实测结论回写形成闭环","https:\u002F\u002Fname.os.bennywen.xyz\u002Fbennywen.xyz\u002Fassets\u002Fai-requirement-landing\u002Fseven-steps-path.svg",[14,11849,11850],{},[50,11851,11852],{},"图 5：七步路径。①-④ 是规划期，想清楚再动手；⑤-⑦ 是执行期，用确定性工程兜住概率性依赖；红色回环是这套方法的心脏——实测结论回头修订文档。",[14,11854,11855],{},"把整个实战过程压缩成一张表：",[1133,11857,11858,11870],{},[1136,11859,11860],{},[1139,11861,11862,11864,11867],{},[1142,11863,3153],{},[1142,11865,11866],{},"产出物",[1142,11868,11869],{},"核心动作",[1152,11871,11872,11886,11901,11915,11929,11940,11951],{},[1139,11873,11874,11877,11880],{},[1157,11875,11876],{},"① 需求",[1157,11878,11879],{},"活 PRD（带修订记录）",[1157,11881,11882,11883],{},"拍板问题清单；用户故事三段式；",[72,11884,11885],{},"明确不做清单",[1139,11887,11888,11891,11894],{},[1157,11889,11890],{},"② 选型",[1157,11892,11893],{},"选型对比 + 假设清单",[1157,11895,11896,11897,11900],{},"拆模型位；多模型分层路由；",[72,11898,11899],{},"盲测验证后再定主推","；成本测算",[1139,11902,11903,11906,11909],{},[1157,11904,11905],{},"③ 决策",[1157,11907,11908],{},"ADR",[1157,11910,11911,11912],{},"背景\u002F决策\u002F后果三段式；",[72,11913,11914],{},"后果里写代价和要守住的线",[1139,11916,11917,11920,11923],{},[1157,11918,11919],{},"④ 翻译",[1157,11921,11922],{},"变更提案（Impact 到文件级）",[1157,11924,11925,11926],{},"能力规格声明；零新增依赖声明；",[72,11927,11928],{},"写给 AI agent 也能执行的粒度",[1139,11930,11931,11934,11937],{},[1157,11932,11933],{},"⑤ 实施",[1157,11935,11936],{},"问题定位表 + 分级方案",[1157,11938,11939],{},"病灶锚定代码行号；搬已验证设计；P0-P2 + 设计原则",[1139,11941,11942,11945,11948],{},[1157,11943,11944],{},"⑥ 验收",[1157,11946,11947],{},"实测数据回写",[1157,11949,11950],{},"联调记录进 PRD 版本；踩坑\u002F约束\u002F验收三类信息",[1139,11952,11953,11956,11959],{},[1157,11954,11955],{},"⑦ 兜底",[1157,11957,11958],{},"四道闸",[1157,11960,11961],{},"结构化校验；自检门限 + tags 回灌；编造拦截；架构护栏",[14,11963,11964],{},"最后说三条 AI 时代特有的体会，也是这篇最想传递的东西：",[77,11966,11967,11976,11982],{},[80,11968,11969,11972,11973],{},[72,11970,11971],{},"文档的读者变了。"," 以前文档写给人看，现在第一读者是 AI 编码 agent——Impact 要写到文件名，约定要变成护栏断言，问题要锚定到代码行号。",[72,11974,11975],{},"文档质量的下限，就是 AI 产出的上限。",[80,11977,11978,11981],{},[72,11979,11980],{},"试错变便宜了，\"先验证再拍板\"从美德变成了本分。"," 盲测三个模型几十块钱，联调一次半天。因为便宜，所以必须做——省下的小钱，都会在上线后变成大坑。",[80,11983,11984,11987],{},[72,11985,11986],{},"代码是最便宜的部分，\"想清楚\"最贵。"," 这个项目真正的资产不是那几万行 Go 和 Vue，是那份被实测反复修订过、每个数字都有人负责的文档体系。它保证了：三个月后的任何人（和任何 AI）接手这个项目，都能在半小时内获得作者当时三天的上下文。",[14,11989,11990],{},"需求落地没有银弹，但这条\"文档驱动 + 实测修订\"的路径，是我们用一个月真实项目从构想到跑通验证过的实战。坑都替你们踩完了。",[1667,11992],{},[14,11994,11995,11996,11999],{},"如果这篇对你有启发，点赞收藏关注三连。下一篇进硬货：",[72,11997,11998],{},"多模型 Agent 流水线实战","——四家服务商、六个模型位，\"读图 → 规划 → 出图 → 文案 → 排版 → 自检\"怎么用 Go 串成全自动闭环，单套 8 分半背后的调度、降级与重试设计。",[14,12001,12002],{},[50,12003,12004,12005,12008],{},"作者按：文中所有版本记录、盲测结论、联调数据均取自生产仓库 ",[87,12006,12007],{},"docs\u002F"," 目录实录，未做演绎。",{"title":193,"searchDepth":207,"depth":207,"links":12010},[12011,12012,12019,12022,12028,12029,12030,12031,12032,12033],{"id":10672,"depth":207,"text":10673},{"id":10732,"depth":207,"text":10733,"children":12013},[12014,12015,12016,12017,12018],{"id":10736,"depth":244,"text":10737},{"id":10779,"depth":244,"text":10780},{"id":10827,"depth":244,"text":10828},{"id":10880,"depth":244,"text":10881},{"id":10961,"depth":244,"text":10962},{"id":10988,"depth":207,"text":10989,"children":12020},[12021],{"id":11094,"depth":244,"text":11095},{"id":11160,"depth":207,"text":11161,"children":12023},[12024,12025,12026,12027],{"id":11175,"depth":244,"text":11176},{"id":11293,"depth":244,"text":11294},{"id":11329,"depth":244,"text":11330},{"id":11420,"depth":244,"text":11421},{"id":11438,"depth":207,"text":11439},{"id":11502,"depth":207,"text":11503},{"id":11606,"depth":207,"text":11607},{"id":11726,"depth":207,"text":11727},{"id":11783,"depth":207,"text":11784},{"id":11837,"depth":207,"text":11838},"2026-10-13",{},"\u002Fposts\u002Fai-requirement-landing",{"title":10662,"description":193},"ai-requirement-landing","posts\u002Fai-requirement-landing","代码变便宜了，「想清楚」反而变贵了。一条来自真实项目、跑满一个月的落地路径：PRD → 选型 → ADR → 规格 → 实施 → 实测回写 → 质量兜底，每一步先落成文档，再用实测回头修订文档。",[12042,6198,12043],"工程实践","方法论","R6cZGZLVFGW9BSIXYKbbLvSyoietb3gMoYq6yp8xMyc",1792857941987]