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
This commit is contained in:
clz
2026-04-02 17:52:38 +08:00
parent c4d8c2e105
commit ee163e123d
9 changed files with 259 additions and 80 deletions

View File

@@ -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

View File

@@ -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=

View File

@@ -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,
})
}

View File

@@ -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())

125
server/service/changelog.go Normal file
View File

@@ -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)
}

View File

@@ -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))
}
}