release: v1.1.0

移除 V1 死代码(6 个导出方法、4 个内部函数、~300 行),
新增 cache.go/ai.go/matcher.go/export.go 拆分 app.go(原 1645 行),
修复 AICache 3 个并发 bug(TOCTOU、反序列化、全排序),
提取 8 个命名常量,前端添加 isRunning 守卫和 CSS 变量。
Go 升级至 1.24.0。
This commit is contained in:
sakuradairong
2026-06-05 14:40:55 +08:00
parent b3ec20fd77
commit 40745f5632
9 changed files with 723 additions and 1162 deletions

179
ai.go Normal file
View File

@@ -0,0 +1,179 @@
package main
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// ---------- Deepseek API 类型 ----------
type deepseekMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
type deepseekRequest struct {
Model string `json:"model"`
Messages []deepseekMessage `json:"messages"`
Temperature float64 `json:"temperature"`
MaxTokens int `json:"max_tokens,omitempty"`
}
type deepseekResponse struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
Error *struct {
Message string `json:"message"`
} `json:"error,omitempty"`
}
// ---------- Deepseek AI 增强匹配 ----------
// hashPrompt 对 prompt 消息计算 SHA256用于缓存键
func hashPrompt(messages []deepseekMessage) string {
h := sha256.New()
for _, m := range messages {
h.Write([]byte(m.Role))
h.Write([]byte{0})
h.Write([]byte(m.Content))
h.Write([]byte{0})
}
return hex.EncodeToString(h.Sum(nil))
}
// callDeepseekAPI 调用 Deepseek Chat API带缓存
func (a *App) callDeepseekAPI(messages []deepseekMessage) (string, error) {
if a.deepseekKey == "" {
return "", fmt.Errorf("请先设置 Deepseek API 密钥")
}
hash := hashPrompt(messages)
// 先查缓存
if cached, ok := a.aiCache.get(hash); ok {
fmt.Printf("[CACHE] ✓ 命中 AI 缓存 (hash=%s)\n", hash[:12])
return cached, nil
}
fmt.Printf("[CACHE] ✗ 缓存未命中 (hash=%s),调用 API...\n", hash[:12])
reqBody := deepseekRequest{
Model: deepseekModel,
Messages: messages,
Temperature: deepseekTemperature,
MaxTokens: deepseekMaxTokens,
}
bodyBytes, _ := json.Marshal(reqBody)
httpReq, err := http.NewRequest("POST", "https://api.deepseek.com/v1/chat/completions",
bytes.NewReader(bodyBytes))
if err != nil {
return "", fmt.Errorf("创建请求失败: %v", err)
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+a.deepseekKey)
client := &http.Client{Timeout: 60 * time.Second}
resp, err := client.Do(httpReq)
if err != nil {
return "", fmt.Errorf("调用 Deepseek API 失败: %v", err)
}
defer resp.Body.Close()
respBytes, _ := io.ReadAll(resp.Body)
var dr deepseekResponse
if err := json.Unmarshal(respBytes, &dr); err != nil {
return "", fmt.Errorf("解析 Deepseek 响应失败: %v", err)
}
if dr.Error != nil {
return "", fmt.Errorf("Deepseek API 错误: %s", dr.Error.Message)
}
if len(dr.Choices) == 0 {
return "", fmt.Errorf("Deepseek 未返回有效结果")
}
result := strings.TrimSpace(dr.Choices[0].Message.Content)
// 写入缓存并持久化
a.aiCache.put(hash, result)
a.aiCache.saveToFile()
return result, nil
}
// buildGenericAIPrompt 构建通用 AI 匹配提示词
func (a *App) buildGenericAIPrompt(unmatched, bRows [][]string, config MatchConfig, windowDuration time.Duration, hasTime bool) []deepseekMessage {
var sb strings.Builder
sb.WriteString("你是一个数据匹配专家。请根据以下 A 表记录,从 B 表数据中找出最匹配的记录。\n\n")
sb.WriteString("匹配规则:\n")
sb.WriteString("1. 根据文本相似度匹配(注意中文字段的核心含义,忽略字母数字前缀后缀)\n")
if hasTime {
sb.WriteString(fmt.Sprintf("2. 时间差应在 %.0f 小时内\n", windowDuration.Hours()))
}
sb.WriteString(fmt.Sprintf("3. 返回匹配到的 B 表记录的目标列值(第 %d 列)\n\n", config.ColBExtractIndex+1))
sb.WriteString("请严格按照以下 JSON 格式返回结果:\n")
sb.WriteString(`{"matches":[{"index":0,"value":"匹配到的目标列值"},{"index":1,"value":""}]}` + "\n")
sb.WriteString("如果某条无法匹配value 设为空字符串。\n\n")
sb.WriteString(fmt.Sprintf("A 表记录(需要匹配,共 %d 条):\n", len(unmatched)))
for i, row := range unmatched {
matchVal := getCell(row, config.ColAMatchIndex)
sb.WriteString(fmt.Sprintf("- 索引 %d: 「%s」", i, matchVal))
if hasTime {
sb.WriteString(fmt.Sprintf(", 时间=%s", getCell(row, config.ColATimeIndex)))
}
sb.WriteString("\n")
}
sb.WriteString(fmt.Sprintf("\nB 表参考数据(共 %d 条):\n", len(bRows)))
for _, row := range bRows {
matchVal := getCell(row, config.ColBMatchIndex)
extractVal := getCell(row, config.ColBExtractIndex)
sb.WriteString(fmt.Sprintf(" 「%s」 → 目标列值: 「%s」", matchVal, extractVal))
if hasTime {
sb.WriteString(fmt.Sprintf(", 时间=%s", getCell(row, config.ColBTimeIndex)))
}
sb.WriteString("\n")
}
sb.WriteString("\n请返回 JSON 格式的匹配结果。")
return []deepseekMessage{
{Role: "system", Content: "你是一个数据匹配专家。请严格按照 JSON 格式返回结果,不要添加额外说明。"},
{Role: "user", Content: sb.String()},
}
}
// formatTimeDiff 格式化时间差为可读字符串
func formatTimeDiff(d time.Duration) string {
abs := d
if abs < 0 {
abs = -abs
}
hours := int(abs.Hours())
mins := int(abs.Minutes()) % 60
secs := int(abs.Seconds()) % 60
sign := ""
if d < 0 {
sign = "-"
}
if hours > 0 {
return fmt.Sprintf("%s%dh%dm%ds", sign, hours, mins, secs)
} else if mins > 0 {
return fmt.Sprintf("%s%dm%ds", sign, mins, secs)
}
return fmt.Sprintf("%s%ds", sign, secs)
}