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 API | Deepseek 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:05、2006/01/02、01/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 增强匹配流程:
- 收集所有未匹配的 A 表行
- 以 8 行为一批 分批调用 Deepseek API
- 对每批,根据时间窗口过滤 B 表候选行(带 3 小时余量防止批次边界遗漏)
- 无时间列时限制 B 表最多 200 条候选(控制 token 消耗)
- 构建提示词,让 AI 从 B 表中找出最匹配的记录
- 解析 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 BOM(
0xEF, 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(前端传空路径参数)在审查中被发现并修复,避免了生产环境中的数据丢失。
相关链接
- 项目地址:
https://github.com/sakuradairong/office-data-matcher - Wails 框架:https://wails.io
- excelize:https://github.com/xuri/excelize
- Deepseek:https://platform.deepseek.com