From ee163e123df4aa2e91c66f573152b960e335474c Mon Sep 17 00:00:00 2001 From: clz Date: Thu, 2 Apr 2026 17:52:38 +0800 Subject: [PATCH] feat: implement dynamic changelog loading from API - Add GET /api/changelog endpoint to fetch changelog from CHANGELOG.md - Create service/changelog.go to parse CHANGELOG.md markdown file - Add handler/changelog.go to handle changelog requests - Update ChangelogDrawer component to fetch from API instead of hardcoded data - Export apiFetch from lib/api.ts for public use - Add changelog parser tests with 14 version entries verified --- AGENTS.md | 11 +- server/go.mod | 2 +- server/go.sum | 13 +- server/handler/changelog.go | 26 ++++ server/router/router.go | 3 + server/service/changelog.go | 125 ++++++++++++++++++ server/service/changelog_test.go | 38 ++++++ web/src/lib/api.ts | 2 +- web/src/lib/components/ChangelogDrawer.svelte | 119 ++++++++--------- 9 files changed, 259 insertions(+), 80 deletions(-) create mode 100644 server/handler/changelog.go create mode 100644 server/service/changelog.go create mode 100644 server/service/changelog_test.go diff --git a/AGENTS.md b/AGENTS.md index 8c78b97..5db4388 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,9 +2,11 @@ Guidelines for AI coding agents working on BillAI - a microservices bill analysis system. +**Current Version:** 1.3.1 | Go: 1.24.0 | Node: 20+ | Python: 3.12+ + ## Architecture - `web/` - SvelteKit 5 + TailwindCSS 4 + TypeScript (Frontend, port 3000) -- `server/` - Go 1.21 + Gin + MongoDB (API, port 8080) +- `server/` - Go 1.24 + Gin + MongoDB (API, port 8080) - `analyzer/` - Python 3.12 + FastAPI (Data cleaning, port 8001) SvelteKit proxies `/api/*` requests to Go backend via `web/src/routes/api/[...path]/+server.ts`. @@ -112,3 +114,10 @@ Upload: ZIP/XLSX → Extract → Convert UTF-8 CSV → Detect bill type → Dedu | `analyzer/server.py` | FastAPI entry | | `analyzer/cleaners/base.py` | BaseCleaner ABC | | `analyzer/category.py` | Category inference | + +## Agent Guidelines +- **Before coding:** Search codebase to understand existing patterns and dependencies +- **Dependencies:** Check `package.json`/`go.mod`/`requirements.txt` before adding new packages +- **Tests:** Always run relevant test suite before committing changes +- **Git commits:** Provide clear messages explaining the "why" of changes +- **File references:** Use `file_path:line_number` format when mentioning code locations diff --git a/server/go.mod b/server/go.mod index 93c7229..a8279b9 100644 --- a/server/go.mod +++ b/server/go.mod @@ -5,6 +5,7 @@ go 1.24.0 require ( github.com/gin-gonic/gin v1.9.1 github.com/golang-jwt/jwt/v5 v5.3.0 + github.com/xuri/excelize/v2 v2.10.1 github.com/yeka/zip v0.0.0-20231116150916-03d6312748a9 go.mongodb.org/mongo-driver v1.13.1 golang.org/x/text v0.34.0 @@ -39,7 +40,6 @@ require ( github.com/xdg-go/scram v1.1.2 // indirect github.com/xdg-go/stringprep v1.0.4 // indirect github.com/xuri/efp v0.0.1 // indirect - github.com/xuri/excelize/v2 v2.10.1 // indirect github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d // indirect golang.org/x/arch v0.3.0 // indirect diff --git a/server/go.sum b/server/go.sum index f96eb27..7f7965d 100644 --- a/server/go.sum +++ b/server/go.sum @@ -67,9 +67,9 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY= github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44= github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= @@ -101,21 +101,18 @@ golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g= -golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ= +golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M= -golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4 h1:uVc8UZUe6tr40fFVnUP5Oj+veunVezqYl9z7DYw9xzw= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= @@ -127,8 +124,6 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU= -golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= @@ -139,8 +134,6 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE= -golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/server/handler/changelog.go b/server/handler/changelog.go new file mode 100644 index 0000000..1a54478 --- /dev/null +++ b/server/handler/changelog.go @@ -0,0 +1,26 @@ +package handler + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "billai-server/service" +) + +// GetChangelog GET /api/changelog 获取版本变更日志 +func GetChangelog(c *gin.Context) { + changelog, err := service.ParseChangelog() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "result": false, + "message": "获取变更日志失败: " + err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "result": true, + "data": changelog, + }) +} diff --git a/server/router/router.go b/server/router/router.go index 70f1518..e9fb4d5 100644 --- a/server/router/router.go +++ b/server/router/router.go @@ -46,6 +46,9 @@ func setupAPIRoutes(r *gin.Engine) { api.POST("/auth/login", handler.Login) api.GET("/auth/validate", handler.ValidateToken) + // 公开接口(无需登录) + api.GET("/changelog", handler.GetChangelog) + // 需要登录的 API authed := api.Group("/") authed.Use(middleware.AuthRequired()) diff --git a/server/service/changelog.go b/server/service/changelog.go new file mode 100644 index 0000000..4394c7d --- /dev/null +++ b/server/service/changelog.go @@ -0,0 +1,125 @@ +package service + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "strings" +) + +// ChangelogEntry 变更日志条目 +type ChangelogEntry struct { + Version string `json:"version"` + Date string `json:"date"` + Changes map[string][]string `json:"changes"` +} + +// ParseChangelog 解析 CHANGELOG.md 文件 +func ParseChangelog() ([]ChangelogEntry, error) { + // 获取项目根目录 + rootDir := os.Getenv("PROJECT_ROOT") + if rootDir == "" { + // 如果未设置,使用相对路径推测 + rootDir = ".." + } + + changelogPath := filepath.Join(rootDir, "CHANGELOG.md") + + file, err := os.Open(changelogPath) + if err != nil { + return nil, fmt.Errorf("打开 CHANGELOG.md 失败: %w", err) + } + defer file.Close() + + var entries []ChangelogEntry + var currentEntry *ChangelogEntry + var currentCategory string + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := scanner.Text() + + // 匹配版本号行 ## [1.4.0] - 2026-03-23 + if strings.HasPrefix(line, "## [") && strings.Contains(line, "]") { + // 保存前一个 entry + if currentEntry != nil { + entries = append(entries, *currentEntry) + } + + // 解析版本号和日期 + version, date := parseVersionLine(line) + if version != "" && version != "Unreleased" { + currentEntry = &ChangelogEntry{ + Version: version, + Date: date, + Changes: make(map[string][]string), + } + currentCategory = "" + } else { + currentEntry = nil + } + continue + } + + // 跳过 Unreleased 和其他非版本行 + if currentEntry == nil { + continue + } + + // 匹配分类行 ### 新增、### 优化等 + if strings.HasPrefix(line, "### ") { + currentCategory = strings.TrimPrefix(line, "### ") + if currentEntry.Changes[currentCategory] == nil { + currentEntry.Changes[currentCategory] = []string{} + } + continue + } + + // 匹配项目行 - 项目描述 + if strings.HasPrefix(line, "- ") && currentCategory != "" { + item := strings.TrimPrefix(line, "- ") + // 移除加粗标记和链接等 + item = cleanItem(item) + currentEntry.Changes[currentCategory] = append(currentEntry.Changes[currentCategory], item) + } + } + + // 保存最后一个 entry + if currentEntry != nil { + entries = append(entries, *currentEntry) + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("扫描文件失败: %w", err) + } + + return entries, nil +} + +// parseVersionLine 解析版本行 ## [1.4.0] - 2026-03-23 +func parseVersionLine(line string) (version, date string) { + // 提取版本号 + startIdx := strings.Index(line, "[") + endIdx := strings.Index(line, "]") + if startIdx >= 0 && endIdx > startIdx { + version = line[startIdx+1 : endIdx] + } + + // 提取日期 + dateStartIdx := strings.LastIndex(line, "- ") + 2 + if dateStartIdx > 1 { + date = strings.TrimSpace(line[dateStartIdx:]) + } + + return +} + +// cleanItem 清理项目描述(移除加粗标记等) +func cleanItem(item string) string { + // 移除加粗标记 **text** + item = strings.ReplaceAll(item, "**", "") + // 移除代码标记 `text` + item = strings.ReplaceAll(item, "`", "") + return strings.TrimSpace(item) +} diff --git a/server/service/changelog_test.go b/server/service/changelog_test.go new file mode 100644 index 0000000..3f46c61 --- /dev/null +++ b/server/service/changelog_test.go @@ -0,0 +1,38 @@ +package service + +import ( + "os" + "testing" +) + +func TestParseChangelog(t *testing.T) { + // 设置项目根目录 + os.Setenv("PROJECT_ROOT", "../..") + + changelog, err := ParseChangelog() + if err != nil { + t.Fatalf("ParseChangelog failed: %v", err) + } + + if len(changelog) == 0 { + t.Fatal("No changelog entries parsed") + } + + // 验证第一个条目(应该是 1.4.0) + firstEntry := changelog[0] + t.Logf("First entry: v%s - %s", firstEntry.Version, firstEntry.Date) + + if firstEntry.Version != "1.4.0" { + t.Errorf("Expected first version to be 1.4.0, got %s", firstEntry.Version) + } + + if len(firstEntry.Changes) == 0 { + t.Error("First entry has no changes") + } + + // 打印所有版本 + t.Logf("Total versions: %d", len(changelog)) + for _, entry := range changelog { + t.Logf(" - v%s: %d categories", entry.Version, len(entry.Changes)) + } +} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index fe10773..5c6d569 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -5,7 +5,7 @@ import type { UIBill } from '$lib/models/bill'; // API 配置 - 使用相对路径,由 SvelteKit 代理到后端 const API_BASE = ''; -async function apiFetch(input: RequestInfo | URL, init: RequestInit = {}) { +export async function apiFetch(input: RequestInfo | URL, init: RequestInit = {}) { const headers = new Headers(init.headers); if (browser) { diff --git a/web/src/lib/components/ChangelogDrawer.svelte b/web/src/lib/components/ChangelogDrawer.svelte index c266abb..626d05b 100644 --- a/web/src/lib/components/ChangelogDrawer.svelte +++ b/web/src/lib/components/ChangelogDrawer.svelte @@ -3,77 +3,48 @@ import { Button } from '$lib/components/ui/button'; import Calendar from '@lucide/svelte/icons/calendar'; import Tag from '@lucide/svelte/icons/tag'; + import { onMount } from 'svelte'; + import { apiFetch } from '$lib/api'; let { open = $bindable(false) } = $props(); - // Changelog 内容(从 CHANGELOG.md 解析或硬编码) - const changelog = [ - { - version: '1.3.1', - date: '2026-01-26', - changes: { - 优化: [ - '版本号显示优化 - 侧边栏版本号按钮样式改进', - '移至次级导航区域,与其他菜单项样式一致', - '更新日志改用 Sheet 组件(右侧滑出),替代底部 Drawer', - '统一暗色主题下的视觉效果' - ] + interface ChangelogEntry { + version: string; + date: string; + changes: Record; + } + + let changelog = $state([]); + let isLoading = $state(false); + let error = $state(null); + + // 获取更新日志 + async function fetchChangelog() { + isLoading = true; + error = null; + try { + const response = await apiFetch('/api/changelog'); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); } - }, - { - version: '1.3.0', - date: '2026-01-26', - changes: { - 新增: [ - '京东账单支持 - 支持京东白条账单上传和清洗', - '自动识别京东账单类型(交易流水 ZIP)', - '解析京东白条账单 CSV 格式(含还款日期信息)', - '京东专属分类映射配置', - '支持京东外卖、京东平台商户等商户识别', - '上传页面和账单列表页面添加"京东"选项' - ], - 优化: [ - '京东订单智能去重 - 上传京东账单时自动软删除其他来源中的京东订单', - '分类推断复核等级优化 - 京东账单引入 LOW 复核等级', - '京东平台商户关键词扩展' - ], - 技术改进: [ - '新增京东账单清理器', - '新增京东专属配置', - '后端新增软删除接口', - '新增单元测试(11 个测试用例)' - ] - } - }, - { - version: '1.2.1', - date: '2026-01-23', - changes: { - 优化: [ - '智能复核快捷确认 - 在复核列表每行添加快捷确认按钮', - '无需打开详情页面即可确认分类正确', - '自动更新统计数据', - '提升复核效率,支持快速批量确认' - ], - 文档: ['AGENTS.md 更新 - 精简为 150 行,专为 AI 编程助手设计'] - } - }, - { - version: '1.2.0', - date: '2026-01-25', - changes: { - 新增: [ - '账单删除功能 - 支持在账单详情抽屉中删除账单(软删除)', - '删除按钮带二次确认,防止误操作', - '已删除的账单在所有查询中自动过滤' - ], - 技术改进: [ - '后端 MongoDB 查询方法添加软删除过滤', - '新增 DELETE /api/bills/:id 接口' - ] + const data = await response.json(); + if (data.result && Array.isArray(data.data)) { + changelog = data.data; + } else { + throw new Error('Invalid response format'); } + } catch (err) { + error = err instanceof Error ? err.message : 'Failed to fetch changelog'; + console.error('Failed to fetch changelog:', err); + } finally { + isLoading = false; } - ]; + } + + // 组件挂载时获取数据 + onMount(() => { + fetchChangelog(); + }); @@ -85,7 +56,20 @@ -
+
+ {#if isLoading} +
+
加载中...
+
+ {:else if error} +
+
{error}
+
+ {:else if changelog.length === 0} +
+
暂无更新日志
+
+ {:else}
{#each changelog as release}
@@ -120,7 +104,8 @@
{/each}
-
+ {/if} +