From 829b3445bc915bea00ae196932a07340211a1e9f Mon Sep 17 00:00:00 2001 From: clz Date: Sun, 11 Jan 2026 18:50:01 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E7=99=BB=E5=BD=95=E8=AE=A4=E8=AF=81=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增登录页面(使用 shadcn-svelte 组件) - 后端添加 JWT 认证 API (/api/auth/login, /api/auth/validate) - 用户账号通过 server/config.yaml 配置 - 前端路由保护(未登录跳转登录页) - 侧边栏显示当前用户信息 - 支持退出登录功能 --- server/config.yaml | 19 ++++ server/config/config.go | 44 +++++++- server/go.mod | 1 + server/go.sum | 2 + server/handler/auth.go | 177 +++++++++++++++++++++++++++++ server/handler/bills.go | 2 +- server/handler/manual_bills.go | 6 +- server/router/router.go | 10 +- web/src/lib/stores/auth.ts | 179 ++++++++++++++++++++++++++++++ web/src/routes/+layout.svelte | 50 +++++++-- web/src/routes/login/+page.svelte | 165 +++++++++++++++++++++++++++ 11 files changed, 639 insertions(+), 16 deletions(-) create mode 100644 server/handler/auth.go create mode 100644 web/src/lib/stores/auth.ts create mode 100644 web/src/routes/login/+page.svelte diff --git a/server/config.yaml b/server/config.yaml index d0c513a..0557d61 100644 --- a/server/config.yaml +++ b/server/config.yaml @@ -39,3 +39,22 @@ mongodb: # 清洗后数据集合 cleaned: bills_cleaned +# 用户认证配置 +auth: + # JWT 密钥(生产环境请使用更复杂的密钥) + jwt_secret: "billai-secret-key-2026" + # Token 过期时间(小时) + token_expiry: 168 # 7天 + # 用户列表 + users: + - username: admin + password: admin123 + name: 管理员 + email: admin@billai.com + role: admin + - username: user + password: user123 + name: 普通用户 + email: user@billai.com + role: user + diff --git a/server/config/config.go b/server/config/config.go index c0e28ea..678883d 100644 --- a/server/config/config.go +++ b/server/config/config.go @@ -28,12 +28,26 @@ type Config struct { MongoDatabase string // 数据库名称 MongoRawCollection string // 原始数据集合名称 MongoCleanedCollection string // 清洗后数据集合名称 + + // 认证配置 + JWTSecret string // JWT 密钥 + TokenExpiry int // Token 过期时间(小时) + Users []UserInfo // 用户列表 +} + +// UserInfo 用户信息 +type UserInfo struct { + Username string `json:"username"` + Password string `json:"-"` // 不序列化密码 + Name string `json:"name"` + Email string `json:"email"` + Role string `json:"role"` } // configFile YAML 配置文件结构 type configFile struct { Version string `yaml:"version"` - Server struct { + Server struct { Port int `yaml:"port"` } `yaml:"server"` Python struct { @@ -56,6 +70,17 @@ type configFile struct { Cleaned string `yaml:"cleaned"` } `yaml:"collections"` } `yaml:"mongodb"` + Auth struct { + JWTSecret string `yaml:"jwt_secret"` + TokenExpiry int `yaml:"token_expiry"` // 小时 + Users []struct { + Username string `yaml:"username"` + Password string `yaml:"password"` + Name string `yaml:"name"` + Email string `yaml:"email"` + Role string `yaml:"role"` + } `yaml:"users"` + } `yaml:"auth"` } // Global 全局配置实例 @@ -186,6 +211,23 @@ func Load() { if cfg.MongoDB.Collections.Cleaned != "" { Global.MongoCleanedCollection = cfg.MongoDB.Collections.Cleaned } + // 认证配置 + if cfg.Auth.JWTSecret != "" { + Global.JWTSecret = cfg.Auth.JWTSecret + } + if cfg.Auth.TokenExpiry > 0 { + Global.TokenExpiry = cfg.Auth.TokenExpiry + } + // 用户列表 + for _, u := range cfg.Auth.Users { + Global.Users = append(Global.Users, UserInfo{ + Username: u.Username, + Password: u.Password, + Name: u.Name, + Email: u.Email, + Role: u.Role, + }) + } } // 环境变量覆盖 diff --git a/server/go.mod b/server/go.mod index ef0fa4d..0da7bb9 100644 --- a/server/go.mod +++ b/server/go.mod @@ -17,6 +17,7 @@ require ( github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.14.0 // indirect github.com/goccy/go-json v0.10.2 // indirect + github.com/golang-jwt/jwt/v5 v5.3.0 // indirect github.com/golang/snappy v0.0.1 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.13.6 // indirect diff --git a/server/go.sum b/server/go.sum index 6e8de17..c229350 100644 --- a/server/go.sum +++ b/server/go.sum @@ -23,6 +23,8 @@ github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/snappy v0.0.1 h1:Qgr9rKW7uDUkrbSmQeiDsGa8SjGyCOGtuasMWwvp2P4= github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= diff --git a/server/handler/auth.go b/server/handler/auth.go new file mode 100644 index 0000000..6e5ce18 --- /dev/null +++ b/server/handler/auth.go @@ -0,0 +1,177 @@ +package handler + +import ( + "crypto/sha256" + "encoding/hex" + "net/http" + "time" + + "billai-server/config" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" +) + +// LoginRequest 登录请求 +type LoginRequest struct { + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` +} + +// LoginResponse 登录响应 +type LoginResponse struct { + Token string `json:"token"` + User config.UserInfo `json:"user"` +} + +// Claims JWT claims +type Claims struct { + Username string `json:"username"` + Name string `json:"name"` + Role string `json:"role"` + jwt.RegisteredClaims +} + +// hashPassword 对密码进行 SHA-256 哈希 +func hashPassword(password string) string { + hash := sha256.Sum256([]byte(password)) + return hex.EncodeToString(hash[:]) +} + +// Login 用户登录 +func Login(c *gin.Context) { + var req LoginRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "error": "请输入用户名和密码", + }) + return + } + + // 查找用户 + var foundUser *config.UserInfo + for _, user := range config.Global.Users { + if user.Username == req.Username { + foundUser = &user + break + } + } + + if foundUser == nil { + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "error": "用户名或密码错误", + }) + return + } + + // 验证密码(支持明文比较和哈希比较) + passwordMatch := foundUser.Password == req.Password || + foundUser.Password == hashPassword(req.Password) + + if !passwordMatch { + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "error": "用户名或密码错误", + }) + return + } + + // 生成 JWT Token + expiry := config.Global.TokenExpiry + if expiry <= 0 { + expiry = 168 // 默认 7 天 + } + + claims := &Claims{ + Username: foundUser.Username, + Name: foundUser.Name, + Role: foundUser.Role, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(expiry) * time.Hour)), + IssuedAt: jwt.NewNumericDate(time.Now()), + Subject: foundUser.Username, + }, + } + + secret := config.Global.JWTSecret + if secret == "" { + secret = "billai-default-secret" + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + tokenString, err := token.SignedString([]byte(secret)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "error": "生成 Token 失败", + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": LoginResponse{ + Token: tokenString, + User: config.UserInfo{ + Username: foundUser.Username, + Name: foundUser.Name, + Email: foundUser.Email, + Role: foundUser.Role, + }, + }, + }) +} + +// ValidateToken 验证 Token +func ValidateToken(c *gin.Context) { + tokenString := c.GetHeader("Authorization") + if tokenString == "" { + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "error": "未提供 Token", + }) + return + } + + // 移除 "Bearer " 前缀 + if len(tokenString) > 7 && tokenString[:7] == "Bearer " { + tokenString = tokenString[7:] + } + + 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 + }) + + if err != nil || !token.Valid { + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "error": "Token 无效或已过期", + }) + return + } + + claims, ok := token.Claims.(*Claims) + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "error": "Token 解析失败", + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": config.UserInfo{ + Username: claims.Username, + Name: claims.Name, + Role: claims.Role, + }, + }) +} diff --git a/server/handler/bills.go b/server/handler/bills.go index b4189af..23eaedc 100644 --- a/server/handler/bills.go +++ b/server/handler/bills.go @@ -202,7 +202,7 @@ func MonthlyStats(c *gin.Context) { // ReviewStats 获取待复核数据统计 func ReviewStats(c *gin.Context) { repo := repository.GetRepository() - + // 从MongoDB查询所有需要复核的账单 bills, err := repo.GetBillsNeedReview() if err != nil { diff --git a/server/handler/manual_bills.go b/server/handler/manual_bills.go index 9db4e79..53a5ac2 100644 --- a/server/handler/manual_bills.go +++ b/server/handler/manual_bills.go @@ -30,9 +30,9 @@ type CreateManualBillsRequest struct { // CreateManualBillsResponse 批量创建手动账单响应 type CreateManualBillsResponse struct { - Result bool `json:"result"` - Message string `json:"message,omitempty"` - Data *CreateManualBillsData `json:"data,omitempty"` + Result bool `json:"result"` + Message string `json:"message,omitempty"` + Data *CreateManualBillsData `json:"data,omitempty"` } // CreateManualBillsData 批量创建手动账单数据 diff --git a/server/router/router.go b/server/router/router.go index 1fb8aef..b92d5a9 100644 --- a/server/router/router.go +++ b/server/router/router.go @@ -41,6 +41,10 @@ func healthCheck(version string) gin.HandlerFunc { func setupAPIRoutes(r *gin.Engine) { api := r.Group("/api") { + // 认证相关(无需登录) + api.POST("/auth/login", handler.Login) + api.GET("/auth/validate", handler.ValidateToken) + // 账单上传 api.POST("/upload", handler.Upload) @@ -49,13 +53,13 @@ func setupAPIRoutes(r *gin.Engine) { // 账单查询 api.GET("/bills", handler.ListBills) - + // 手动创建账单 api.POST("/bills/manual", handler.CreateManualBills) - + // 月度统计(全部数据) api.GET("/monthly-stats", handler.MonthlyStats) - + // 待复核数据统计 api.GET("/review-stats", handler.ReviewStats) } diff --git a/web/src/lib/stores/auth.ts b/web/src/lib/stores/auth.ts new file mode 100644 index 0000000..d964076 --- /dev/null +++ b/web/src/lib/stores/auth.ts @@ -0,0 +1,179 @@ +import { writable } from 'svelte/store'; +import { browser } from '$app/environment'; + +export interface User { + username: string; + name: string; + email?: string; + avatar?: string; + role: 'admin' | 'user'; +} + +export interface AuthState { + isAuthenticated: boolean; + user: User | null; + token: string | null; +} + +// API 基础路径 +const API_BASE = '/api'; + +// 从 localStorage 恢复状态 +function getInitialState(): AuthState { + if (browser) { + const stored = localStorage.getItem('auth'); + if (stored) { + try { + const state = JSON.parse(stored); + if (state.isAuthenticated && state.token) { + return state; + } + localStorage.removeItem('auth'); + } catch { + localStorage.removeItem('auth'); + } + } + } + return { + isAuthenticated: false, + user: null, + token: null, + }; +} + +function createAuthStore() { + const { subscribe, set } = writable(getInitialState()); + + return { + subscribe, + + // 通过后端 API 登录验证 + loginAsync: async (username: string, password: string): Promise<{ success: boolean; error?: string }> => { + try { + const response = await fetch(`${API_BASE}/auth/login`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ username, password }), + }); + + const result = await response.json(); + + if (!response.ok || !result.success) { + return { success: false, error: result.error || '登录失败' }; + } + + const { token, user } = result.data; + + const state: AuthState = { + isAuthenticated: true, + user: { + username: user.username, + name: user.name, + email: user.email, + role: user.role, + }, + token, + }; + + if (browser) { + localStorage.setItem('auth', JSON.stringify(state)); + } + set(state); + + return { success: true }; + } catch (error) { + console.error('Login error:', error); + return { success: false, error: '网络错误,请稍后重试' }; + } + }, + + // 验证 token 是否有效 + validateToken: async (): Promise => { + if (!browser) return false; + + const stored = localStorage.getItem('auth'); + if (!stored) return false; + + try { + const state = JSON.parse(stored); + if (!state.token) return false; + + const response = await fetch(`${API_BASE}/auth/validate`, { + headers: { + 'Authorization': `Bearer ${state.token}`, + }, + }); + + if (!response.ok) { + localStorage.removeItem('auth'); + set({ isAuthenticated: false, user: null, token: null }); + return false; + } + + return true; + } catch { + return false; + } + }, + + // 兼容旧的同步登录方法 + login: (user: User, token: string) => { + const state: AuthState = { + isAuthenticated: true, + user, + token, + }; + if (browser) { + localStorage.setItem('auth', JSON.stringify(state)); + } + set(state); + }, + + logout: () => { + if (browser) { + localStorage.removeItem('auth'); + } + set({ + isAuthenticated: false, + user: null, + token: null, + }); + }, + + // 检查是否已登录 + check: (): boolean => { + if (browser) { + const stored = localStorage.getItem('auth'); + if (stored) { + try { + const state = JSON.parse(stored); + return state.isAuthenticated === true && !!state.token; + } catch { + return false; + } + } + } + return false; + }, + + // 获取当前 token + getToken: (): string | null => { + if (browser) { + const stored = localStorage.getItem('auth'); + if (stored) { + try { + const state = JSON.parse(stored); + return state.token; + } catch { + return null; + } + } + } + return null; + } + }; +} + +export const auth = createAuthStore(); diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index e45f506..fcce07c 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -1,8 +1,11 @@ +{#if isLoginPage} + {@render children()} +{:else} @@ -249,7 +282,7 @@ - + 退出登录 @@ -266,7 +299,7 @@

{getPageTitle($page.url.pathname)}

-
+
+
+
+ + +
+ + +
+ + + + + + + + +
+ + 密码已加密传输 +
+ + +

+ © 2026 BillAI. All rights reserved. +

+ +