From 3b7c1cd82bea4cfffc2402c7f958be02d99b0061 Mon Sep 17 00:00:00 2001 From: CHE LIANG ZHAO Date: Fri, 16 Jan 2026 11:15:05 +0800 Subject: [PATCH] chore(release): v1.0.7 - README/CHANGELOG: add v1.0.7 entry\n- Server: JWT expiry validated server-side (401 codes)\n- Web: logout/redirect on 401; proxy forwards Authorization\n- Server: bill service uses repository consistently --- CHANGELOG.md | 10 ++ README.md | 1 + server/config.yaml | 2 +- server/config/config.go | 2 +- server/database/mongo.go | 1 - server/handler/auth.go | 15 +- server/handler/review.go | 1 - server/main.go | 23 +-- server/middleware/auth.go | 75 ++++++++ server/model/response.go | 1 - server/router/router.go | 30 ++-- server/service/bill.go | 229 ++++-------------------- server/service/extractor.go | 1 - web/package.json | 2 +- web/src/lib/api.ts | 42 ++++- web/src/routes/+layout.svelte | 32 ++-- web/src/routes/api/[...path]/+server.ts | 9 +- 17 files changed, 226 insertions(+), 250 deletions(-) create mode 100644 server/middleware/auth.go diff --git a/CHANGELOG.md b/CHANGELOG.md index e918dc6..d80c1bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ 格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/), 版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。 +## [1.0.7] - 2026-01-16 + +### 优化 +- **认证过期策略调整** - Token 过期/无效由后端统一判断,前端不再自行解析过期时间 + - 后端新增 JWT 鉴权中间件,统一返回 401 并携带错误码 + - 前端在收到 401 后清理登录状态并跳转到登录页 + +### 重构 +- **后端数据访问层收敛** - 账单相关服务统一通过 repository 访问 MongoDB,减少多套数据层并存 + ## [1.0.6] - 2026-01-08 ### 修复 diff --git a/README.md b/README.md index ea6afde..60afa16 100644 --- a/README.md +++ b/README.md @@ -273,6 +273,7 @@ python server.py | 版本 | 日期 | 主要更新 | |------|------|----------| +| **v1.0.7** | 2026-01-16 | 🔐 Token 过期由后端统一判断、401 自动退登;后端数据访问层收敛 | | **v1.0.6** | 2026-01-08 | 🐛 修复数据分析页面总支出和大盘数据错误 | | **v1.0.5** | 2026-01-08 | 🐛 修复支付宝时间格式解析错误,修复WebHook编译错误 | | **v1.0.4** | 2026-01-13 | 🚀 Gitea Webhook 自动部署、零停机热更新 | diff --git a/server/config.yaml b/server/config.yaml index 7512181..1004990 100644 --- a/server/config.yaml +++ b/server/config.yaml @@ -1,7 +1,7 @@ # BillAI 服务器配置文件 # 应用版本 -version: "1.0.6" +version: "1.0.7" # 服务配置 server: diff --git a/server/config/config.go b/server/config/config.go index ab13ebc..32d5ed4 100644 --- a/server/config/config.go +++ b/server/config/config.go @@ -145,7 +145,7 @@ func Load() { flag.Parse() // 设置默认值 - Global.Version = "0.0.1" + Global.Version = "1.0.7" Global.Port = getEnvOrDefault("PORT", "8080") Global.ProjectRoot = getDefaultProjectRoot() Global.PythonPath = getDefaultPythonPath() diff --git a/server/database/mongo.go b/server/database/mongo.go index 21e2f21..56650a0 100644 --- a/server/database/mongo.go +++ b/server/database/mongo.go @@ -69,4 +69,3 @@ func Disconnect() error { fmt.Println("🍃 MongoDB 连接已断开") return nil } - diff --git a/server/handler/auth.go b/server/handler/auth.go index 6e5ce18..6db9e21 100644 --- a/server/handler/auth.go +++ b/server/handler/auth.go @@ -3,6 +3,7 @@ package handler import ( "crypto/sha256" "encoding/hex" + "errors" "net/http" "time" @@ -131,6 +132,7 @@ func ValidateToken(c *gin.Context) { c.JSON(http.StatusUnauthorized, gin.H{ "success": false, "error": "未提供 Token", + "code": "TOKEN_MISSING", }) return } @@ -147,12 +149,20 @@ func ValidateToken(c *gin.Context) { token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) { return []byte(secret), nil - }) + }, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name})) if err != nil || !token.Valid { + code := "TOKEN_INVALID" + message := "Token 无效" + if err != nil && errors.Is(err, jwt.ErrTokenExpired) { + code = "TOKEN_EXPIRED" + message = "Token 已过期" + } + c.JSON(http.StatusUnauthorized, gin.H{ "success": false, - "error": "Token 无效或已过期", + "error": message, + "code": code, }) return } @@ -162,6 +172,7 @@ func ValidateToken(c *gin.Context) { c.JSON(http.StatusUnauthorized, gin.H{ "success": false, "error": "Token 解析失败", + "code": "TOKEN_INVALID", }) return } diff --git a/server/handler/review.go b/server/handler/review.go index 3fdc210..fb1b4a7 100644 --- a/server/handler/review.go +++ b/server/handler/review.go @@ -69,4 +69,3 @@ func Review(c *gin.Context) { }, }) } - diff --git a/server/main.go b/server/main.go index a040911..c4171fb 100644 --- a/server/main.go +++ b/server/main.go @@ -12,7 +12,6 @@ import ( adapterHttp "billai-server/adapter/http" "billai-server/adapter/python" "billai-server/config" - "billai-server/database" "billai-server/repository" repoMongo "billai-server/repository/mongo" "billai-server/router" @@ -44,21 +43,13 @@ func main() { initAdapters() // 初始化数据层 - if err := initRepository(); err != nil { + repo, err := initRepository() + if err != nil { fmt.Printf("⚠️ 警告: 数据层初始化失败: %v\n", err) fmt.Println(" 账单数据将不会存储到数据库") os.Exit(1) } - - // 连接 MongoDB(保持兼容旧代码,后续可移除) - if err := database.Connect(); err != nil { - fmt.Printf("⚠️ 警告: MongoDB 连接失败: %v\n", err) - fmt.Println(" 账单数据将不会存储到数据库") - os.Exit(1) - } else { - // 优雅关闭时断开连接 - defer database.Disconnect() - } + defer repo.Disconnect() // 创建路由 r := gin.Default() @@ -75,7 +66,7 @@ func main() { signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) <-quit fmt.Println("\n🛑 正在关闭服务...") - database.Disconnect() + repo.Disconnect() os.Exit(0) }() @@ -153,14 +144,14 @@ func initAdapters() { // initRepository 初始化数据存储层 // 在这里配置数据持久化方式 // 后续可以通过修改这里来切换不同的存储实现(如 PostgreSQL、MySQL 等) -func initRepository() error { +func initRepository() (repository.BillRepository, error) { // 初始化 MongoDB 存储 mongoRepo := repoMongo.NewRepository() if err := mongoRepo.Connect(); err != nil { - return err + return nil, err } repository.SetRepository(mongoRepo) fmt.Println("💾 数据层初始化完成") - return nil + return mongoRepo, nil } diff --git a/server/middleware/auth.go b/server/middleware/auth.go new file mode 100644 index 0000000..2fe58e3 --- /dev/null +++ b/server/middleware/auth.go @@ -0,0 +1,75 @@ +package middleware + +import ( + "errors" + "net/http" + "strings" + + "billai-server/config" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" +) + +// Claims JWT claims (duplicated here to avoid cross-package import from handler). +type Claims struct { + Username string `json:"username"` + Name string `json:"name"` + Role string `json:"role"` + jwt.RegisteredClaims +} + +func AuthRequired() gin.HandlerFunc { + return func(c *gin.Context) { + tokenString := c.GetHeader("Authorization") + if tokenString == "" { + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "error": "未提供 Token", + "code": "TOKEN_MISSING", + }) + c.Abort() + return + } + + if strings.HasPrefix(tokenString, "Bearer ") { + tokenString = strings.TrimPrefix(tokenString, "Bearer ") + } + + secret := config.Global.JWTSecret + if secret == "" { + secret = "billai-default-secret" + } + + token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) { + return []byte(secret), nil + }, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name})) + + if err != nil || !token.Valid { + code := "TOKEN_INVALID" + message := "Token 无效" + if err != nil && errors.Is(err, jwt.ErrTokenExpired) { + code = "TOKEN_EXPIRED" + message = "Token 已过期" + } + + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "error": message, + "code": code, + }) + c.Abort() + return + } + + if claims, ok := token.Claims.(*Claims); ok { + c.Set("user", gin.H{ + "username": claims.Username, + "name": claims.Name, + "role": claims.Role, + }) + } + + c.Next() + } +} diff --git a/server/model/response.go b/server/model/response.go index 1f512b0..37d83df 100644 --- a/server/model/response.go +++ b/server/model/response.go @@ -43,4 +43,3 @@ type ReviewResponse struct { Message string `json:"message"` Data *ReviewData `json:"data,omitempty"` } - diff --git a/server/router/router.go b/server/router/router.go index b92d5a9..c1ac8e4 100644 --- a/server/router/router.go +++ b/server/router/router.go @@ -7,6 +7,7 @@ import ( "github.com/gin-gonic/gin" "billai-server/handler" + "billai-server/middleware" ) // Config 路由配置参数 @@ -45,22 +46,27 @@ func setupAPIRoutes(r *gin.Engine) { api.POST("/auth/login", handler.Login) api.GET("/auth/validate", handler.ValidateToken) - // 账单上传 - api.POST("/upload", handler.Upload) + // 需要登录的 API + authed := api.Group("/") + authed.Use(middleware.AuthRequired()) + { + // 账单上传 + authed.POST("/upload", handler.Upload) - // 复核相关 - api.GET("/review", handler.Review) + // 复核相关 + authed.GET("/review", handler.Review) - // 账单查询 - api.GET("/bills", handler.ListBills) + // 账单查询 + authed.GET("/bills", handler.ListBills) - // 手动创建账单 - api.POST("/bills/manual", handler.CreateManualBills) + // 手动创建账单 + authed.POST("/bills/manual", handler.CreateManualBills) - // 月度统计(全部数据) - api.GET("/monthly-stats", handler.MonthlyStats) + // 月度统计(全部数据) + authed.GET("/monthly-stats", handler.MonthlyStats) - // 待复核数据统计 - api.GET("/review-stats", handler.ReviewStats) + // 待复核数据统计 + authed.GET("/review-stats", handler.ReviewStats) + } } } diff --git a/server/service/bill.go b/server/service/bill.go index e0fbf06..192a4c4 100644 --- a/server/service/bill.go +++ b/server/service/bill.go @@ -1,7 +1,8 @@ package service import ( - "context" + "billai-server/model" + "billai-server/repository" "encoding/csv" "encoding/json" "fmt" @@ -9,11 +10,6 @@ import ( "strconv" "strings" "time" - - "go.mongodb.org/mongo-driver/bson" - - "billai-server/database" - "billai-server/model" ) // SaveResult 存储结果 @@ -23,29 +19,8 @@ type SaveResult struct { DuplicateCount int // 重复数据跳过数量 } -// checkDuplicate 检查记录是否重复 -// 优先使用 transaction_id 判断,如果为空则使用 时间+金额+商户 组合判断 -func checkDuplicate(ctx context.Context, bill *model.CleanedBill) bool { - var filter bson.M - - if bill.TransactionID != "" { - // 优先用交易订单号判断 - filter = bson.M{"transaction_id": bill.TransactionID} - } else { - // 回退到 时间+金额+商户 组合判断 - filter = bson.M{ - "time": bill.Time.Time(), // 转换为 time.Time 用于 MongoDB 查询 - "amount": bill.Amount, - "merchant": bill.Merchant, - } - } - - count, err := database.CleanedBillCollection.CountDocuments(ctx, filter) - if err != nil { - return false // 查询出错时不认为是重复 - } - - return count > 0 +func getRepo() repository.BillRepository { + return repository.GetRepository() } // DeduplicateResult 去重结果 @@ -60,6 +35,11 @@ type DeduplicateResult struct { // DeduplicateRawFile 对原始文件进行去重检查,返回去重后的文件路径 // 如果全部重复,返回错误 func DeduplicateRawFile(filePath, uploadBatch string) (*DeduplicateResult, error) { + repo := getRepo() + if repo == nil { + return nil, fmt.Errorf("数据库未连接") + } + file, err := os.Open(filePath) if err != nil { return nil, fmt.Errorf("打开文件失败: %w", err) @@ -94,10 +74,6 @@ func DeduplicateRawFile(filePath, uploadBatch string) (*DeduplicateResult, error return result, nil } - // 创建上下文 - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - // 检查每行是否重复 var newRows [][]string for _, row := range dataRows { @@ -112,17 +88,14 @@ func DeduplicateRawFile(filePath, uploadBatch string) (*DeduplicateResult, error continue } - // 检查是否已存在 - count, err := database.RawBillCollection.CountDocuments(ctx, bson.M{ - "raw_data." + header[idFieldIdx]: transactionID, - }) + isDup, err := repo.CheckRawDuplicate(header[idFieldIdx], transactionID) if err != nil { // 查询出错,保留该行 newRows = append(newRows, row) continue } - if count == 0 { + if !isDup { // 不重复,保留 newRows = append(newRows, row) } else { @@ -198,6 +171,11 @@ func detectBillTypeAndIdField(header []string) (billType string, idFieldIdx int) // SaveRawBillsFromFile 从原始上传文件读取数据并存入原始数据集合 func SaveRawBillsFromFile(filePath, billType, sourceFile, uploadBatch string) (int, error) { + repo := getRepo() + if repo == nil { + return 0, fmt.Errorf("数据库未连接") + } + file, err := os.Open(filePath) if err != nil { return 0, fmt.Errorf("打开文件失败: %w", err) @@ -219,7 +197,7 @@ func SaveRawBillsFromFile(filePath, billType, sourceFile, uploadBatch string) (i now := time.Now() // 构建原始数据文档 - var rawBills []interface{} + var rawBills []model.RawBill for rowIdx, row := range rows[1:] { rawData := make(map[string]interface{}) for colIdx, col := range header { @@ -244,16 +222,7 @@ func SaveRawBillsFromFile(filePath, billType, sourceFile, uploadBatch string) (i return 0, nil } - // 批量插入原始数据集合 - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - result, err := database.RawBillCollection.InsertMany(ctx, rawBills) - if err != nil { - return 0, fmt.Errorf("插入原始数据失败: %w", err) - } - - return len(result.InsertedIDs), nil + return repo.SaveRawBills(rawBills) } // SaveCleanedBillsFromFile 从清洗后的文件读取数据并存入清洗后数据集合 @@ -268,6 +237,11 @@ func SaveCleanedBillsFromFile(filePath, format, billType, sourceFile, uploadBatc // saveCleanedBillsFromCSV 从 CSV 文件读取并存储清洗后账单 // 返回: (插入数量, 重复跳过数量, 错误) func saveCleanedBillsFromCSV(filePath, billType, sourceFile, uploadBatch string) (int, int, error) { + repo := getRepo() + if repo == nil { + return 0, 0, fmt.Errorf("数据库未连接") + } + file, err := os.Open(filePath) if err != nil { return 0, 0, fmt.Errorf("打开文件失败: %w", err) @@ -291,13 +265,8 @@ func saveCleanedBillsFromCSV(filePath, billType, sourceFile, uploadBatch string) colIdx[col] = i } - // 创建上下文用于去重检查 - ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) - defer cancel() - // 解析数据行 - var bills []interface{} - duplicateCount := 0 + var bills []model.CleanedBill now := time.Now() for _, row := range rows[1:] { @@ -361,31 +330,24 @@ func saveCleanedBillsFromCSV(filePath, billType, sourceFile, uploadBatch string) bill.ReviewLevel = row[idx] } - // 检查是否重复 - if checkDuplicate(ctx, &bill) { - duplicateCount++ - continue // 跳过重复记录 - } - bills = append(bills, bill) } - if len(bills) == 0 { - return 0, duplicateCount, nil - } - - // 批量插入清洗后数据集合 - result, err := database.CleanedBillCollection.InsertMany(ctx, bills) + saved, duplicates, err := repo.SaveCleanedBills(bills) if err != nil { - return 0, duplicateCount, fmt.Errorf("插入清洗后数据失败: %w", err) + return 0, 0, err } - - return len(result.InsertedIDs), duplicateCount, nil + return saved, duplicates, nil } // saveCleanedBillsFromJSON 从 JSON 文件读取并存储清洗后账单 // 返回: (插入数量, 重复跳过数量, 错误) func saveCleanedBillsFromJSON(filePath, billType, sourceFile, uploadBatch string) (int, int, error) { + repo := getRepo() + if repo == nil { + return 0, 0, fmt.Errorf("数据库未连接") + } + file, err := os.Open(filePath) if err != nil { return 0, 0, fmt.Errorf("打开文件失败: %w", err) @@ -402,13 +364,8 @@ func saveCleanedBillsFromJSON(filePath, billType, sourceFile, uploadBatch string return 0, 0, nil } - // 创建上下文用于去重检查 - ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) - defer cancel() - // 解析数据 - var bills []interface{} - duplicateCount := 0 + var bills []model.CleanedBill now := time.Now() for _, item := range data { @@ -467,25 +424,14 @@ func saveCleanedBillsFromJSON(filePath, billType, sourceFile, uploadBatch string bill.ReviewLevel = v } - // 检查是否重复 - if checkDuplicate(ctx, &bill) { - duplicateCount++ - continue // 跳过重复记录 - } - bills = append(bills, bill) } - if len(bills) == 0 { - return 0, duplicateCount, nil - } - - result, err := database.CleanedBillCollection.InsertMany(ctx, bills) + saved, duplicates, err := repo.SaveCleanedBills(bills) if err != nil { - return 0, duplicateCount, fmt.Errorf("插入清洗后数据失败: %w", err) + return 0, 0, err } - - return len(result.InsertedIDs), duplicateCount, nil + return saved, duplicates, nil } // parseTime 解析时间字符串 @@ -559,106 +505,3 @@ func parseAmount(s string) float64 { } return 0 } - -// GetCleanedBillsByBatch 根据批次获取清洗后账单 -func GetCleanedBillsByBatch(uploadBatch string) ([]model.CleanedBill, error) { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - cursor, err := database.CleanedBillCollection.Find(ctx, bson.M{"upload_batch": uploadBatch}) - if err != nil { - return nil, fmt.Errorf("查询失败: %w", err) - } - defer cursor.Close(ctx) - - var bills []model.CleanedBill - if err := cursor.All(ctx, &bills); err != nil { - return nil, fmt.Errorf("解析结果失败: %w", err) - } - - return bills, nil -} - -// GetRawBillsByBatch 根据批次获取原始账单 -func GetRawBillsByBatch(uploadBatch string) ([]model.RawBill, error) { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - cursor, err := database.RawBillCollection.Find(ctx, bson.M{"upload_batch": uploadBatch}) - if err != nil { - return nil, fmt.Errorf("查询失败: %w", err) - } - defer cursor.Close(ctx) - - var bills []model.RawBill - if err := cursor.All(ctx, &bills); err != nil { - return nil, fmt.Errorf("解析结果失败: %w", err) - } - - return bills, nil -} - -// GetBillStats 获取账单统计信息 -func GetBillStats() (map[string]interface{}, error) { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - // 原始数据总数 - rawTotal, err := database.RawBillCollection.CountDocuments(ctx, bson.M{}) - if err != nil { - return nil, err - } - - // 清洗后数据总数 - cleanedTotal, err := database.CleanedBillCollection.CountDocuments(ctx, bson.M{}) - if err != nil { - return nil, err - } - - // 支出总额(从清洗后数据统计) - expensePipeline := []bson.M{ - {"$match": bson.M{"income_expense": "支出"}}, - {"$group": bson.M{"_id": nil, "total": bson.M{"$sum": "$amount"}}}, - } - expenseCursor, err := database.CleanedBillCollection.Aggregate(ctx, expensePipeline) - if err != nil { - return nil, err - } - defer expenseCursor.Close(ctx) - - var expenseResult []bson.M - expenseCursor.All(ctx, &expenseResult) - totalExpense := 0.0 - if len(expenseResult) > 0 { - if v, ok := expenseResult[0]["total"].(float64); ok { - totalExpense = v - } - } - - // 收入总额(从清洗后数据统计) - incomePipeline := []bson.M{ - {"$match": bson.M{"income_expense": "收入"}}, - {"$group": bson.M{"_id": nil, "total": bson.M{"$sum": "$amount"}}}, - } - incomeCursor, err := database.CleanedBillCollection.Aggregate(ctx, incomePipeline) - if err != nil { - return nil, err - } - defer incomeCursor.Close(ctx) - - var incomeResult []bson.M - incomeCursor.All(ctx, &incomeResult) - totalIncome := 0.0 - if len(incomeResult) > 0 { - if v, ok := incomeResult[0]["total"].(float64); ok { - totalIncome = v - } - } - - return map[string]interface{}{ - "raw_records": rawTotal, - "cleaned_records": cleanedTotal, - "total_expense": totalExpense, - "total_income": totalIncome, - }, nil -} diff --git a/server/service/extractor.go b/server/service/extractor.go index 7e3d263..a37ddeb 100644 --- a/server/service/extractor.go +++ b/server/service/extractor.go @@ -131,4 +131,3 @@ func extractFromJSON(filePath string) []model.ReviewRecord { return records } - diff --git a/web/package.json b/web/package.json index 9dd8fd4..1c3afa8 100644 --- a/web/package.json +++ b/web/package.json @@ -1,7 +1,7 @@ { "name": "web", "private": true, - "version": "1.0.3", + "version": "1.0.7", "type": "module", "scripts": { "dev": "vite dev", diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 682d321..40cb12e 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -1,6 +1,32 @@ +import { browser } from '$app/environment'; +import { auth } from '$lib/stores/auth'; + // API 配置 - 使用相对路径,由 SvelteKit 代理到后端 const API_BASE = ''; +async function apiFetch(input: RequestInfo | URL, init: RequestInit = {}) { + const headers = new Headers(init.headers); + + if (browser) { + const token = auth.getToken(); + if (token) { + headers.set('Authorization', `Bearer ${token}`); + } + } + + const response = await fetch(input, { ...init, headers }); + + if (browser && response.status === 401) { + // 由后端判断 Token 是否过期/无效,这里只负责清理和退登 + auth.logout(); + if (window.location.pathname !== '/login') { + window.location.href = '/login'; + } + } + + return response; +} + // 健康检查 export async function checkHealth(): Promise { try { @@ -99,7 +125,7 @@ export async function uploadBill( formData.append('month', options.month.toString()); } - const response = await fetch(`${API_BASE}/api/upload`, { + const response = await apiFetch(`${API_BASE}/api/upload`, { method: 'POST', body: formData, }); @@ -113,7 +139,7 @@ export async function uploadBill( // 获取复核记录 export async function getReviewRecords(fileName: string): Promise { - const response = await fetch(`${API_BASE}/api/review?file=${encodeURIComponent(fileName)}`); + const response = await apiFetch(`${API_BASE}/api/review?file=${encodeURIComponent(fileName)}`); if (!response.ok) { throw new Error(`HTTP ${response.status}`); @@ -124,7 +150,7 @@ export async function getReviewRecords(fileName: string): Promise { - const response = await fetch(`${API_BASE}/api/monthly-stats`); + const response = await apiFetch(`${API_BASE}/api/monthly-stats`); if (!response.ok) { throw new Error(`HTTP ${response.status}`); @@ -140,7 +166,7 @@ export function getDownloadUrl(fileUrl: string): string { // 解析账单内容(用于前端展示全部记录) export async function fetchBillContent(fileName: string): Promise { - const response = await fetch(`${API_BASE}/download/${fileName}`); + const response = await apiFetch(`${API_BASE}/download/${fileName}`); if (!response.ok) { throw new Error(`HTTP ${response.status}`); @@ -264,7 +290,7 @@ export async function fetchBills(params: FetchBillsParams = {}): Promise { - const response = await fetch(`${API_BASE}/api/bills/manual`, { + const response = await apiFetch(`${API_BASE}/api/bills/manual`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -321,7 +347,7 @@ export async function createManualBills(bills: ManualBillInput[]): Promise { - const response = await fetch(`${API_BASE}/api/review-stats`); + const response = await apiFetch(`${API_BASE}/api/review-stats`); if (!response.ok) { throw new Error(`HTTP ${response.status}`); @@ -332,7 +358,7 @@ export async function fetchReviewStats(): Promise { // 获取所有待复核的账单(完整数据) export async function fetchBillsByReviewLevel(): Promise { - const response = await fetch(`${API_BASE}/api/bills?page=1&page_size=1000&review_level=HIGH,LOW`); + const response = await apiFetch(`${API_BASE}/api/bills?page=1&page_size=1000&review_level=HIGH,LOW`); if (!response.ok) { throw new Error(`HTTP ${response.status}`); diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index fcce07c..7ce061f 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -67,16 +67,28 @@ onMount(() => { themeMode = loadThemeFromStorage(); applyThemeToDocument(themeMode); - - // 检查登录状态,未登录则跳转到登录页 - const pathname = $page.url.pathname; - if (!auth.check() && pathname !== '/login' && pathname !== '/health') { - goto('/login'); - return; - } - - // 检查服务器状态 - checkServerHealth(); + + (async () => { + // 检查登录状态,未登录则跳转到登录页 + const pathname = $page.url.pathname; + if (!auth.check() && pathname !== '/login' && pathname !== '/health') { + goto('/login'); + return; + } + + // 由后端判断 Token 是否过期/无效 + if (auth.check() && pathname !== '/login') { + const ok = await auth.validateToken(); + if (!ok) { + goto('/login'); + return; + } + } + + // 检查服务器状态 + checkServerHealth(); + })(); + // 每 30 秒检查一次 const healthInterval = setInterval(checkServerHealth, 30000); diff --git a/web/src/routes/api/[...path]/+server.ts b/web/src/routes/api/[...path]/+server.ts index 76f653f..5233b8c 100644 --- a/web/src/routes/api/[...path]/+server.ts +++ b/web/src/routes/api/[...path]/+server.ts @@ -4,11 +4,15 @@ import type { RequestHandler } from './$types'; // 服务端使用 Docker 内部地址,默认使用 localhost const API_URL = env.API_URL || 'http://localhost:8080'; -export const GET: RequestHandler = async ({ params, url, fetch }) => { +export const GET: RequestHandler = async ({ params, url, request, fetch }) => { const path = params.path; const queryString = url.search; - const response = await fetch(`${API_URL}/api/${path}${queryString}`); + const response = await fetch(`${API_URL}/api/${path}${queryString}`, { + headers: { + 'Authorization': request.headers.get('Authorization') || '', + }, + }); return new Response(response.body, { status: response.status, @@ -27,6 +31,7 @@ export const POST: RequestHandler = async ({ params, request, fetch }) => { body: await request.arrayBuffer(), headers: { 'Content-Type': request.headers.get('Content-Type') || 'application/octet-stream', + 'Authorization': request.headers.get('Authorization') || '', }, });