数据智能匹配工具:一个 Wails 桌面应用的开发实录

作者:Administrator 发布时间: 2026-05-17 阅读量:5 评论数:0

2026 年 5 月 · 开源项目 · 桌面应用

一、项目简介

office-data-matcher 是一个基于 Wails v2(Go + Vue 3)构建的桌面端数据匹配工具。它的核心场景是:给定两张表格(Excel/CSV),A 表作为基准表,B 表作为数据源表,通过模糊匹配AI 增强匹配将 B 表的目标列值提取到 A 表对应的行中。

项目地址:https://github.com/sakuradairong/office-data-matcher

技术栈:

层级技术
桌面框架Wails v2.12.0(Go 前端绑定)
后端Go 1.24
前端Vue 3 (Composition API) + Vite
Excel 处理excelize v2.10.1
AI APIDeepseek Chat API

二、应用场景

典型的使用场景是数据归并:

你有 A 表(人员名单、订单号、项目列表),B 表(带有额外字段的原始数据),但两表之间没有直接的 ID 关联,只能靠文本相似度时间窗口来匹配。

例如:A 表是「员工姓名 + 日期」,B 表是「日报标题 + 工作量 + 日期」——你需要把 B 表的日报数据匹配到 A 表的员工名下,但姓名可能不精确一致("张三" vs "张 三" vs "张三丰"),需要通过模糊匹配和时间窗口来找到最可能的对应关系。

三、核心功能

3.1 文件读取与表头解析

支持 .xlsx / .xls / .csv 三种格式。CSV 解析器是手写的(非标准库 encoding/csv),支持双引号包裹字段转义双引号 """

func parseCSVLine(line string) []string {
    var fields []string
    var current strings.Builder
    inQuotes := false
    runes := []rune(line)
    for i := 0; i < len(runes); i++ {
        ch := runes[i]
        switch {
        case ch == '"':
            if inQuotes && i+1 < len(runes) && runes[i+1] == '"' {
                current.WriteRune('"')
                i++
            } else {
                inQuotes = !inQuotes
            }
        case ch == ',' && !inQuotes:
            fields = append(fields, strings.TrimSpace(current.String()))
            current.Reset()
        default:
            current.WriteRune(ch)
        }
    }
    fields = append(fields, strings.TrimSpace(current.String()))
    return fields
}

文件加载后,ParseHeaders 提取第一行作为表头数组返回给前端,供列映射下拉框动态渲染。

3.2 动态列映射

这是工具的核心交互设计——用户在前端为 A、B 两张表分别选择:

  • 匹配列(MatchColumn):用于文本相似度计算的列
  • 时间列(TimeColumn):可选,用于时间窗口剪枝
  • 提取列(ExtractColumn,仅 B 表):最终要提取到结果中的目标列

所有列索引通过 MatchConfig 结构体传递:

type MatchConfig struct {
    FileAPath string `json:"fileAPath"`
    FileBPath string `json:"fileBPath"`

    ColAMatchIndex int `json:"colAMatchIndex"`
    ColATimeIndex  int `json:"colATimeIndex"`

    ColBMatchIndex   int `json:"colBMatchIndex"`
    ColBTimeIndex    int `json:"colBTimeIndex"`
    ColBExtractIndex int `json:"colBExtractIndex"`

    RegexPattern string  `json:"regexPattern"`
    TimeWindow   float64 `json:"timeWindow"`
    Threshold    float64 `json:"threshold"`

    AllMatches    bool   `json:"allMatches"`
    CaseSensitive bool   `json:"caseSensitive"`
    SortBy        string `json:"sortBy"`
    ExportFormat  string `json:"exportFormat"`
    IncludeHeader bool   `json:"includeHeader"`
}

3.3 正则清洗

用户可以在前端输入自定义正则表达式来清洗文本(默认保留纯中文字符),剔除无意义的字母数字前缀后缀,让匹配更精准:

func cleanWithRegex(input string, reg *regexp.Regexp) string {
    if reg == nil { return input }
    return reg.ReplaceAllString(input, "")
}

3.4 时间窗口剪枝

当两表都配置了时间列时,匹配算法先计算 A 表行与 B 表行的时间差,超过配置窗口(如 12 小时)的记录直接跳过,大幅减少候选比较次数:

td := timeA.Sub(tB)
if td < -windowDuration || td > windowDuration { continue }

时间解析支持 14 种常见格式,覆盖 2006-01-02 15:04:052006/01/0201/02/2006 15:04 等中英文日期格式:

var timeFormats = []string{
    "2006-01-02 15:04:05", "2006-01-02 15:04",
    "2006/01/02 15:04:05", "2006/01/02 15:04",
    "2006-1-2 15:04:05",   "2006-1-2 15:04",
    "2006/1/2 15:04:05",   "2006/1/2 15:04",
    "2006-01-02T15:04:05", "2006/01/02T15:04:05",
    "01/02/2006 15:04",    "1/2/2006 15:04",
    "2006-01-02",          "2006/01/02",
}

3.5 Levenshtein 模糊匹配

核心的文本相似度算法使用 编辑距离(Levenshtein Distance),使用一维数组优化空间复杂度从 O(m×n) 降到 O(n):

func levenshteinDistance(s1, s2 string) int {
    runes1 := []rune(s1)
    runes2 := []rune(s2)
    m, n := len(runes1), len(runes2)

    dp := make([]int, n+1)
    for j := range dp { dp[j] = j }

    for i := 1; i <= m; i++ {
        prev := dp[0]
        dp[0] = i
        for j := 1; j <= n; j++ {
            temp := dp[j]
            cost := 1
            if runes1[i-1] == runes2[j-1] { cost = 0 }
            dp[j] = min(dp[j]+1, min(dp[j-1]+1, prev+cost))
            prev = temp
        }
    }
    return dp[n]
}

相似度归一化公式:1.0 - distance / max(len1, len2)

支持配置选项:

  • CaseSensitive:大小写是否敏感
  • Threshold:阈值,默认 0.65。高于阈值的才作为候选结果
  • AllMatches:是否返回所有高于阈值的候选(默认仅返回最佳匹配)
  • SortBy:结果排序方式(按相似度或时间差)

3.6 Deepseek AI 增强匹配

基础匹配完成后,未被匹配到的 A 表行进入 AI 增强匹配流程:

  1. 收集所有未匹配的 A 表行
  2. 8 行为一批 分批调用 Deepseek API
  3. 对每批,根据时间窗口过滤 B 表候选行(带 3 小时余量防止批次边界遗漏)
  4. 无时间列时限制 B 表最多 200 条候选(控制 token 消耗)
  5. 构建提示词,让 AI 从 B 表中找出最匹配的记录
  6. 解析 AI 返回的 JSON 格式结果,合并到最终匹配结果中

AI 缓存系统:为了提高效率并节省 API 费用,实现了基于 SHA256 的持久化 AI 响应缓存:

type AICache struct {
    Entries  []AICacheEntry `json:"entries"`
    mu       sync.RWMutex
    filePath string
    maxSize  int
}

type AICacheEntry struct {
    PromptHash string `json:"promptHash"`
    Response   string `json:"response"`
    CreatedAt  int64  `json:"createdAt"`
}
  • 缓存文件存储在 os.TempDir()/data-matcher-ai-cache.json
  • 默认最多 500 条条目,超过时淘汰最旧的
  • 缓存键:对所有 message(role + content)计算 SHA256
  • 命中缓存时直接返回,不调用 API
  • 前端可查看缓存状态和手动清除

提示词构建的优化要点:

  • 按批次的时间范围过滤 B 表,大幅减少 token 消耗
  • 严格控制 JSON 格式响应(前置示例 + system prompt 约束)
  • AI 响应的解析有二次容错:标准 JSON 解析失败后,尝试提取 {...} 子串再次解析
parseErr := json.Unmarshal([]byte(aiResp), &matchResp)
if parseErr != nil {
    if idx := strings.Index(aiResp, "{"); idx >= 0 {
        if endIdx := strings.LastIndex(aiResp, "}"); endIdx > idx {
            parseErr = json.Unmarshal([]byte(aiResp[idx:endIdx+1]), &matchResp)
        }
    }
}

3.7 结果导出

支持导出为 Excel(xlsx)和 CSV 两种格式:

  • Excel:使用 excelize 库创建带样式(蓝色粗体表头)的工作表,自动调整列宽
  • CSV:内置编码了 UTF-8 BOM0xEF, 0xBB, 0xBF),确保 Excel 打开 CSV 时正确识别中文
if _, err := f.Write([]byte{0xEF, 0xBB, 0xBF}); err != nil {
    return "", fmt.Errorf("写入 BOM 失败: %v", err)
}

四、架构设计

Wails 双向绑定

后端 App 结构体通过 Wails 的 bind 机制暴露给前端 JavaScript:

type App struct {
    ctx         context.Context
    deepseekKey string
    aiCache     *AICache
}

前端通过 wailsjs/go/main/App 调用 Go 方法:

import { RunMatchWithAI, ExportResults, SetDeepseekAPIKey } from '../wailsjs/go/main/App';

const results = await RunMatchWithAI(config);

进度事件推送

耗时操作(匹配、AI 增强)通过 Wails 的 EventsEmit 向前端实时推送进度:

func (a *App) emitProgress(current, total int, message, phase string) {
    runtime.EventsEmit(a.ctx, "match-progress", ProgressPayload{
        Current: current,
        Total:   total,
        Message: message,
        Phase:   phase,  // "reading" / "matching" / "ai-enhancing" / "done"
    })
}

五、版本历史

项目虽然只有 3 次提交,但每次都有清晰的焦点:

v0.1 — 项目初始化(2cef098

Wails 脚手架 + 基本的文件读写和表头解析。

v0.2 — AI 缓存系统(d31c3e4

核心变更:

  • AICache 持久化缓存(SHA256 键,存储在临时文件)
  • callDeepseekAPI 自动查缓存,命中跳过 API 调用
  • buildAIPrompt 按时间窗口过滤日报记录,减少 token 消耗
  • AI 结果匹配改用 map 查找替代 O(n²) 遍历
  • 前端添加缓存状态显示和「清除缓存」按钮

v0.3 — 代码审查修复(b3ec20f

这是最近的一次提交(2026-05-07),修复了 8 项代码审查发现的问题,按严重程度分 P0/P1/P2:

P0(必须修复):

  • RunMatchWithAI(配置驱动方式)替代 DeepseekEnhanceMatching 中硬编码的阈值
  • 前端 startAIEnhance 使用 fileAPath/fileBPath 替代未赋值的 monthlyPath/dailyPath——之前传的是空字符串

P1(重要):

  • reasonMap 移至批循环外部,仅构建一次
  • CSV 解析器增加双引号转义支持
  • twoHours 变量重命名

P2(次要):

  • 删除未使用的 rowAndScore
  • calcSimilarity 实现 CaseSensitive 参数传递
  • AI 解析失败增加日志详细程度
  • 时间窗口过滤改用用户配置的 windowDuration 替代硬编码的 3 小时,旧函数标注弃用

六、技术亮点

1. 手写 CSV 解析器

Go 标准库的 encoding/csv 在某些边缘场景下不够灵活(如对引号的处理),项目手写了一个解析器,准确支持 RFC 4180 的双引号转义。

2. 通用的匹配引擎

runMatchInternal 是一个完全由配置驱动的通用匹配函数,不依赖具体业务字段名。列索引、阈值、排序方式、时间窗口全部由 MatchConfig 控制。这意味着同一套引擎可以处理日报匹配、订单匹配、人员名单匹配等完全不同的业务场景。

3. AI 缓存命中率最大化

为了节省 Deepseek API 费用(且提高响应速度),缓存策略做了多层次的优化:

  • 所有 message 的 role + content 做 SHA256,全量相同才命中
  • 限制最大条目数 + 淘汰最旧策略
  • 持久化到临时文件,重启后缓存仍在
  • 前端提供可视化的缓存状态和清除能力

4. 柔性 JSON 解析

AI 模型有时会在 JSON 前后加额外文本说明。解析器在标准解析失败后,尝试从响应中提取 {...} 子串再次解析——这在调试过程中从实际 AI 响应中发现的常见模式。

七、总结

office-data-matcher 是一个典型的小工具大逻辑项目:桌面端 3 次提交、Go 后端 1150 行、Vue 前端一个页面,但涵盖了从文件解析、正则清洗、时间解析、编辑距离算法、AI API 集成、缓存系统到结果导出的完整数据匹配流水线。

项目的开发流程也体现了代码审查的实践价值——P0 级 bug(前端传空路径参数)在审查中被发现并修复,避免了生产环境中的数据丢失。


相关链接

评论