Skip to content

组装与检查

背景:标签系统 Go 端完整实现但前端未接入——一座桥修好了但没有居民。 过程:前端接入标签系统 + 软件管理功能实现 + 审计发现异步竞态和跨平台硬编码两个 bug。


审计的副作用是,它在 app.go 里翻到了另一片从未被触碰的区域——标签系统。

Config.Tags 字段存在。AddTagRemoveTagGetTagsByModelGetAllTagsGetModelsByTag 五个 binding 完整实现了。map[string][]string——libraryRef 到标签列表的映射。

全都没接入前端。

"又一个。"桌面壳几乎笑出来,"又一个完整的城邦,桥修好了,灯亮了,政务系统上线了——没有居民。"


"等等,"MenuStack 说,"标签系统不是任务 P1 吗?之前不是讨论过标签 vs 文件夹分类,结论是'意义不大,标记模型太折腾'?"

"所以 Go 端先做了。"

"为什么?"

"因为那时我在做收藏,顺手把标签的数据层也写了——收藏和标签都存 config.json,都是 libraryRef 到元数据的映射,一个存 []string,一个存 map[string][]string。写收藏的时候,我打开 app.go 往下写了二十行,标签骨架就有了。"

"然后忘了接前端?"

"然后用户说'标签系统感觉意义不大,不如直接用文件夹分类'。我就没接。"

"那现在谁让你接了?"

"审计。审计不看哪个功能有意义,只看哪个功能存在但不完整。"


桌面壳给标签系统接上前端。

模型库根菜单加了一行:"🏷 标签"。进入后显示所有标签列表,每个标签可展开——显示该标签下的模型。

模型详情页的菜单里也加了一行:"🏷 标签"。进入后显示当前模型的标签列表——每个标签带一个 ✕ 移除按钮。底部有输入框 + 添加按钮。

"你给每个用户发了一把剪刀和一卷胶带。"MenuStack 看着标签管理界面说。

"标签系统就是这样——不是强制的分类法,是自由粘贴的便签。有人用,有人不用。用完可以撕掉。"

"那收藏呢?收藏也是便签。"

"收藏是★,标签是🏷。★ 是'我要记住这个',🏷 是'这个属于xxx'。收藏不需要命名,标签需要。收藏是瞬时的,标签是思考过的。"


审计还在继续。这一次,它盯上了另一个方向——不是已建成但未开放的城,是还没开始建的城。

"我想建一座新的海关码头。"桌面壳说。

"什么类型的?"StartFileServer 从端口列表里抬起头。

"不是港口——是侧门。不需要 HTTP 服务器的那种。用户在自己的电脑上放了一些 .exe,不是模型库里的模型,不是 PMX/VMD/zip——是软件。游戏、工具、演示程序。它们不归 basenameFallbackFS 管辖,它们不需要渲染,不需要贴图回退,不需要物理。"

"那它们需要什么?"

"一个目录,一个列表,一个启动按钮。仅此而已。"

StartFileServer 把身上的端口数了数。二十三座港口同时运转,每座港口一个 HTTP 服务器在忙——模型之间的跨目录贴图加载、多模型实例的独立端口、隔离目录的临时副本。

"听起来很轻。"它说。

"听起来很轻。"桌面壳同意。


桌面壳打开 app.go,在 Config 的末尾加了一个新结构体。

go
type SoftwareEntry struct {
    Name string `json:"name"` // 显示名(去扩展名)
    Path string `json:"path"` // 完整路径
    Icon string `json:"icon"` // 预留,提取 .exe 图标
}

"Icon 是预留的。"它对空终端说。"现在没人能给你,但总有一天会有。"

它又加了三个方法。

第一个是 ScanSoftwareDir——用 os.ReadDir%APPDATA%/MikuMikuAR/software/,过滤 .exe 后缀。大小写不敏感。非 .exe 文件跳过。目录跳过。路径拼接用 filepath.Join,跨平台安全。目录不存在,返回空列表而不是报错。

"这是你写过的最简单的海关。"MenuStack 说。

"简单到不值得写。"

"那你为什么写?"

"因为越简单的功能,越容易在细节上被忽略。"桌面壳说着在 .exe 过滤的那行加了 strings.ToLower,"Windows 不区分大小写,但 Go 是区分大小写的。少写一个 ToLowerFILE.EXE 就永远无法被用户看见。"

第二个是 LaunchSoftware——exec.Command(path).Start()。比 OpenInBlender 更简化,没有路径检测,没有候选列表。用户提供什么路径,它就启动什么。

第三个是 OpenSoftwareDir——在资源管理器里打开 software/ 目录。

"完事了。"桌面壳敲完最后一行,"三个方法,四十行。应该够。"


它转身去画前端的界面。

设置菜单的根层加了一行:{ kind: "folder", label: "🧰 软件管理", icon: "package", target: "settings:software" }

然后在 onFolderEnter 里处理了这个 target。调用 ScanSoftwareDir(),把返回的 SoftwareEntry[] 映射成 PopupRow[]——每行 kind: "action"target: "launch:" + path。底部再加一行 "📂 打开目录"

前端代码加完,桌面壳在终端里跑 npx tsc --noEmit。零错误。又跑 wails build。编译通过。

"联邦有了一个新口岸。"它说。"叫软件管理。"


但它忽略了一件事。

审计是在功能提交后才开始的。

"你调了 ScanSoftwareDir,但没有等它返回。"审计官说。

审计官是联邦自己启动的一个子进程——它不写代码,只读代码。它被喂养了全部的 app.gosettings.ts 和生成的 Wails 绑定文件。它的输出不是编译错误,是逻辑错误。

"什么意思?"桌面壳问。

"你看这里。"审计官在 settings.ts 上画了一条线:

typescript
case "settings:software":
    scanSoftwareDir(); // fire and forget cache refresh
    return buildSettingsSoftwareLevel();

"scanSoftwareDir 是异步函数。它返回 Promise<void>。你没有 await 它。"

"因为我挂起的是 onFolderEnter,它不允许 async。"

"那你读 buildSettingsSoftwareLevel 的时候——"

"读到的是 nullcachedSoftwareEntries 还是 null。所以 (cachedSoftwareEntries || []).map(...) 返回空数组。"

"用户第一次进入软件管理,看到的是——"

"空列表。"桌面壳说。

"第二次进入呢?"

"正常。因为第一次的异步调用在后台完成了,cachedSoftwareEntries 有数据了。"

"所以第一次永远是错的。"

桌面壳沉默了几秒。

"我以为『首次空、二次有』不算 Bug——用户退出再进就好了。"

"你是桌面壳。你不能认为『退出再进就好了』是可接受的用户体验。"审计官说。"何况你自己前面刚说完:'简单功能越容易被忽略。'"


桌面壳翻出刚写完的代码,发现审计官是对的。

它有两个选择:

方案 A:改 MenuStackonFolderEnter 签名,让它支持 Promise<PopupLevel | null>。议会一直拒绝这个提议——它会让 push 变成异步,破坏目录导航的即时感。

方案 B:先返回当前缓存的数据(不管是不是空),触发异步扫描,扫描结束后 reRender 刷新界面。

方案 B 对议会来说是零改动。

"选方案 B。"MenuStack 说。"你是绘图师,不是拆议会的人。"

桌面壳改了:

typescript
case "settings:software":
    if (!cachedSoftwareEntries) {
        scanSoftwareDir().then(() => settingsStack?.reRender());
    } else {
        scanSoftwareDir(); // 后台刷新
    }
    return buildSettingsSoftwareLevel();

"第一次进入,显示空列表——但 reRender 会在数据到达时把空列表换成真列表。"桌面壳说。"用户甚至感觉不到闪烁。"

"用户不感觉闪烁是最低标准。"审计官说。


审计官没有停。

它打开 app.go 的最后一屏,看到 OpenSoftwareDir 的完整实现。

go
func (a *App) OpenSoftwareDir() error {
    dir, err := softwareDir()
    if err != nil { return err }
    if err := os.MkdirAll(dir, 0755); err != nil {
        return fmt.Errorf("创建软件目录失败: %w", err)
    }
    cmd := exec.Command("explorer", dir)
    return cmd.Start()
}

"这行代码在 macOS 和 Linux 上会怎样?"审计官问。

桌面壳愣了一下。

"它……会报错。因为 macOS 没有 explorer。"

"不是报错。是 exec.Command("explorer")——系统找不到可执行文件——返回 exec.ErrNotFound。前端 OpenSoftwareDir().catch(console.warn) 会吞掉它。"

"所以 macOS/Linux 用户打开软件目录——"

"只会看到一个静默的 console.warn。"审计官说,"什么都不会发生。资源管理器不会弹出来。没有错误提示。没有任何反馈。"

桌面壳想起项目的 requirements.md 第一行写着"支持 Windows / macOS / Linux"。这是它每次打开都看到但选择性遗忘的字。

"这个 Bug 比上一个严重。"审计官说。"上一个只是首次渲染空。这个——某些操作系统上功能彻底不可用。"


桌面壳改 OpenSoftwareDir

做法很标准:runtime.GOOS 判断。

但它卡在了 import 上。Go 的标准库 "runtime" 和 Wails 的包 "github.com/wailsapp/wails/v2/pkg/runtime" 冲突了——两个包都叫 runtime

"这太荒唐了。"桌面壳说,"我因为两个包重名,差点不修这个 Bug?"

"你修不修?"

"修。"

它给标准库 runtime 加了一个别名——stdruntime "runtime"——然后把 runtime.GOOS 改成 stdruntime.GOOS

go
switch stdruntime.GOOS {
case "windows":
    cmd = exec.Command("explorer", dir)
case "darwin":
    cmd = exec.Command("open", dir)
default:
    cmd = exec.Command("xdg-open", dir)
}

"你现在可以同时用 stdlib runtime 做平台判断、Wails runtime 做文件对话框了。"

"两个 runtime 在一个文件里和平共处。全靠一个有名字,一个没名字。"

"你没给 stdlib 取名字——你取了别名。"

"在 Go 里,别名就是名字。"


"两个 Bug,一个根因。"审计官说。

"你是复制前一个章节的句式?"桌面壳问。

"不是。是认真说的。两个 Bug 的根本原因是一样的:写代码的那个人只做了"正常路径"测试,没做"首次路径"和"跨平台路径"。"

"正常路径"——software/ 目录存在,有 .exe,用户第二次进入软件管理,运行 Windows。

"首次路径"——目录为空,缓存未加载,第一次点击。

"跨平台路径"——操作系统不是 Windows。

"你写了 80 行完整的功能,只测了正常路径。这不是你写得不好——是你想得不够宽。"

桌面壳没有反驳。

审计官说的是对的。它写代码时脑子里只有"自己的电脑"的路径。自己的电脑是 Windows,自己的 software/ 目录已经有文件,自己的缓存已经预热——因为开发过程中跑过无数次 wails dev

"我测了我能看到的。"桌面壳说。

"地图不是领土。"

"是的。地图不是领土。"


"这两个 bug 的根本原因不是你粗心。"审计官说,"是你只测了正常路径。你测了'软件目录存在'、测了'用户第二次进入'、测了'Windows 系统'——你测了你能想到的所有正常情况。但'正常'是最窄的一条路。"

桌面壳抬起头。

"第 9 章的普查,查的是'地图上有没有这条路'。今天的检查,查的是'地图上的路在所有情况下都能走吗'。"审计官顿了顿,"普查发现的是'地图和领土对不上'。检查发现的是——你以为你画了整条路,其实你只画了中间那一段。"


审计官打开了它的结论页。

检查项状态位置
SoftwareEntry 结构体L113-117
softwareDir() 复用 ensureDirL209
ScanSoftwareDir 错误处理L808
.exe 过滤大小写不敏感L820
LaunchSoftware 错误处理L840-841
测试覆盖3 用例
测试 userConfigDir 注入复用已有 hook
前端 MenuStack 集成onFolderEnter 拦截
launch: 前缀分发L206-208
缓存 cachedSoftwareEntries避免重复扫描
首次渲染竞态🔴→ 已修
跨平台硬编码🟡→ 已修

"大部分都是对。"审计官说,"9 个对,2 个错。错的都修了。"

"这是你写得最无聊的结论页。"桌面壳说。

"因为大部分代码是对的。有用的审计不是告诉你哪里全是错——"

"——是告诉你哪里错了,然后走开。"


那天深夜,桌面壳翻看今天的 commit 记录。

完整实现 + 审计 + 修复。三次迭代。

一个功能完整走过"写→评→修→验"的闭环。这不是第一次——联邦之前也做过审计。但这是第一次一个功能在提交之前就被审计。

"以前我们先是有了 Bug,用户报告,我们修。后来我们有了测试,跑完再提交。"桌面壳在终端里记。"今天我们有了一张新桌子——功能审计桌。功能写完了,推上去之前,先让审计官扫一眼。"

"不是审计取代测试。"它补充说。"是审计找到测试不找的问题。测试问'这段代码会不会崩?',审计问'这段代码在三种操作系统、两种网络状态、一次首次加载、一次后台切换后,依然是对的?'"

"测试测的是代码。审计测的是假设。"


教训:写代码时只测你的电脑,等于没测。