From 284f4e39d7599320deec6586eb382ec6cf2f4341 Mon Sep 17 00:00:00 2001 From: "cnb.asek4HHRAKA" Date: Wed, 1 Oct 2025 20:43:33 +0800 Subject: [PATCH] =?UTF-8?q?add:=20=E6=96=B0=E5=A2=9E=E8=99=9A=E6=8B=9F?= =?UTF-8?q?=E7=8E=AF=E5=A2=83=E6=94=AF=E6=8C=81=E5=92=8C=E8=B0=83=E8=AF=95?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=E5=8A=9F=E8=83=BD=EF=BC=8C=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E6=97=A5=E5=BF=97=E7=B3=BB=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- requirements-beancount-v3.txt | 44 ++++++++++++ script/config.go | 90 ++++++++++++++++++++++-- script/file.go | 22 ++++-- script/log.go | 118 ++++++++++++++++++++++++++++++++ server.go | 90 +++++++++++++++++++++++- utils/venv/venv.go | 124 ++++++++++++++++++++++++++++++++++ 6 files changed, 476 insertions(+), 12 deletions(-) create mode 100644 requirements-beancount-v3.txt create mode 100644 utils/venv/venv.go diff --git a/requirements-beancount-v3.txt b/requirements-beancount-v3.txt new file mode 100644 index 0000000..487fa0c --- /dev/null +++ b/requirements-beancount-v3.txt @@ -0,0 +1,44 @@ +anyio==4.11.0 +babel==2.17.0 +beancount==3.2.0 +beangulp==0.2.0 +beanquery==0.2.0 +beautifulsoup4==4.14.0 +blinker==1.9.0 +chardet==5.2.0 +cheroot==10.0.1 +click==8.3.0 +dateparser==1.2.2 +debugpy==1.8.16 +fava==1.30.6 +Flask==3.1.2 +flask-babel==4.0.0 +idna==3.10 +iniconfig==2.1.0 +itsdangerous==2.2.0 +jaraco.functools==4.3.0 +Jinja2==3.1.6 +lxml==6.0.2 +markdown2==2.5.4 +MarkupSafe==3.0.3 +more-itertools==10.8.0 +packaging==25.0 +pluggy==1.6.0 +ply==3.11 +pycryptodomex==3.23.0 +Pygments==2.19.2 +pytest==8.4.2 +python-dateutil==2.9.0.post0 +python-magic==0.4.27 +pytz==2025.2 +pyzipper==0.3.6 +regex==2025.9.18 +simplejson==3.20.2 +six==1.17.0 +sniffio==1.3.1 +soupsieve==2.8 +TatSu-LTS==5.13.2 +typing_extensions==4.15.0 +tzlocal==5.3.1 +watchfiles==1.1.0 +Werkzeug==3.1.3 diff --git a/script/config.go b/script/config.go index 9d62219..a116726 100644 --- a/script/config.go +++ b/script/config.go @@ -6,8 +6,10 @@ import ( "os" "sort" "strings" + "sync" "time" + "github.com/beancount-gs/utils/venv" // 添加这个导入 "github.com/gin-gonic/gin" ) @@ -20,6 +22,14 @@ var ledgerAccountTypesMap map[string]map[string]string var ledgerCurrencyMap map[string][]LedgerCurrency var whiteList []string +var ( + // 添加虚拟环境相关的变量 + venvPath string + venvExecutor *venv.VenvExecutor + venvPathLock sync.RWMutex + venvExecLock sync.RWMutex +) + type Config struct { Id string `json:"id,omitempty"` Mail string `json:"mail,omitempty"` @@ -30,6 +40,7 @@ type Config struct { IsBak bool `json:"isBak"` OpeningBalances string `json:"openingBalances"` CreateDate string `json:"createDate,omitempty"` + DebugMode bool `json:"debugMode"` } type Account struct { @@ -85,12 +96,15 @@ func GetServerConfig() Config { func LoadServerConfig() error { filePath := GetServerConfigFilePath() + LogSystemInfo("Load config file (" + filePath + ")") if !FileIfExist(filePath) { serverConfig = Config{ - OpeningBalances: "Equity:OpeningBalances", + OpeningBalances: "Equity:Opening-Balances", OperatingCurrency: "CNY", StartDate: "1970-01-01", IsBak: true, + DebugMode: false, // 添加默认值 + DataPath: GetDataPath(), // 添加默认值 } return nil } @@ -131,15 +145,52 @@ func LoadServerConfig() error { return nil } +// 获取当前调试模式状态 +func IsDebugMode() bool { + return serverConfig.DebugMode +} + +// 设置调试模式并保存到配置文件 +func SetDebugMode(debug bool) error { + serverConfig.DebugMode = debug + return UpdateServerConfig(serverConfig) +} + +// 为方便使用,添加调试日志函数 +func DebugLog(format string, args ...interface{}) { + if IsDebugMode() { + message := fmt.Sprintf(format, args...) + LogSystemInfo("[DEBUG] " + message) + } +} + +// 添加带上下文信息的调试日志 +func DebugLogWithContext(context string, format string, args ...interface{}) { + if IsDebugMode() { + message := fmt.Sprintf("[%s] "+format, append([]interface{}{context}, args...)...) + LogSystemInfo("[DEBUG] " + message) + } +} + +// 添加带上下文信息的警告日志 +func WarnLogWithContext(context string, format string, args ...interface{}) { + message := fmt.Sprintf("[%s] "+format, append([]interface{}{context}, args...)...) + LogSystemInfo("[WARN] " + message) +} + func UpdateServerConfig(config Config) error { bytes, err := json.Marshal(config) if err != nil { return err } - err = WriteFile(GetServerConfigFilePath(), string(bytes)) + + // 使用新的带目录创建功能的写入函数 + configPath := GetServerConfigFilePath() + err = WriteFileWithDir(configPath, string(bytes)) if err != nil { return err } + serverConfig = config return nil } @@ -335,15 +386,16 @@ func LoadLedgerAccounts(ledgerId string) error { key := words[2] temp = accountMap[key] account := Account{Acc: key, Type: nil, StartDate: "", EndDate: ""} - if words[1] == "open" { + switch words[1] { + case "open": // 最晚的开户日期设置为账户开户日期 account.StartDate = getMaxDate(words[0], temp.StartDate) // 货币单位 if len(words) >= 4 { account.Currency = words[3] } - } else if words[1] == "close" { - //账户最晚的关闭日期设置为账户关闭日期 + case "close": + // 账户最晚的关闭日期设置为账户关闭日期 account.EndDate = getMaxDate(words[0], temp.EndDate) } if account.EndDate != "" && account.StartDate == getMaxDate(account.StartDate, account.EndDate) { @@ -588,3 +640,31 @@ func GetAccountIconName(account string) string { nodes := strings.Split(account, ":") return strings.Join(nodes, "_") } + +// 新增函数:设置虚拟环境路径 +func SetVenvPath(path string) { + venvPathLock.Lock() + defer venvPathLock.Unlock() + venvPath = path +} + +// 新增函数:获取虚拟环境路径 +func GetVenvPath() string { + venvPathLock.RLock() + defer venvPathLock.RUnlock() + return venvPath +} + +// 新增函数:设置虚拟环境执行器 +func SetVenvExecutor(executor *venv.VenvExecutor) { + venvExecLock.Lock() + defer venvExecLock.Unlock() + venvExecutor = executor +} + +// 新增函数:获取虚拟环境执行器 +func GetVenvExecutor() *venv.VenvExecutor { + venvExecLock.RLock() + defer venvExecLock.RUnlock() + return venvExecutor +} diff --git a/script/file.go b/script/file.go index 23901c3..fed6b2f 100644 --- a/script/file.go +++ b/script/file.go @@ -295,10 +295,7 @@ func getAccountWithNumber(str string) string { func IsComment(line string) bool { trimmed := strings.TrimLeft(line, " ") - if strings.HasPrefix(trimmed, ";") { - return true - } - return false + return strings.HasPrefix(trimmed, ";") } // 删除指定行范围的内容 @@ -360,3 +357,20 @@ func WriteToFile(filePath string, lines []string) error { LogSystemInfo("Success write content in file " + filePath) return writer.Flush() } + +// EnsureDirExists 确保目录存在,如果不存在则创建 +func EnsureDirExists(path string) error { + dir := filepath.Dir(path) + return os.MkdirAll(dir, 0755) +} + +// WriteFileWithDir 写入文件,确保目录存在 +func WriteFileWithDir(filename string, data string) error { + // 确保目录存在 + if err := EnsureDirExists(filename); err != nil { + return err + } + + // 写入文件 + return WriteFile(filename, data) +} diff --git a/script/log.go b/script/log.go index e955878..fd1664a 100644 --- a/script/log.go +++ b/script/log.go @@ -5,18 +5,136 @@ import ( "time" ) +// Info级别日志函数组 + +// LogInfo 记录信息级别的日志 +// ledgerName: 账本名称,用于标识日志来源 +// message: 需要记录的日志信息 func LogInfo(ledgerName string, message string) { fmt.Printf("[Info] [%s] [%s]: %s\n", time.Now().Format("2006-01-02 15:04:05"), ledgerName, message) } +// LogSystemInfo 记录系统信息日志 +// message: 要记录的系统信息消息 func LogSystemInfo(message string) { LogInfo("System", message) } +// Warn级别日志函数组 + +// LogWarn 记录警告级别的日志 +// ledgerName: 账本名称,用于标识日志来源 +// context: 日志上下文,提供更多分类信息 +// format: 格式化字符串,定义日志消息格式 +// args: 可变参数,用于填充格式化字符串 +func LogWarn(ledgerName string, context string, format string, args ...interface{}) { + message := fmt.Sprintf(format, args...) + fmt.Printf("[Warn] [%s] [%s][%s]: %s\n", + time.Now().Format("2006-01-02 15:04:05"), + ledgerName, + context, + message) +} + +// LogSystemWarn 记录系统级警告日志 +// context: 日志上下文标识 +// format: 格式化字符串 +// args: 格式化参数 +func LogSystemWarn(context string, format string, args ...interface{}) { + LogWarn("System", context, format, args...) +} + +// Error级别日志函数组 + +// LogError 记录错误日志,格式为:[Error] [时间] [账本名称]: 错误信息 +// ledgerName: 账本名称 +// message: 需要记录的错误信息 func LogError(ledgerName string, message string) { fmt.Printf("[Error] [%s] [%s]: %s\n", time.Now().Format("2006-01-02 15:04:05"), ledgerName, message) } +// LogErrorDetailed 记录带有详细信息的错误日志 +// ledgerName: 账本名称,用于标识日志来源 +// context: 上下文信息,帮助定位错误发生的场景 +// format: 格式化字符串,用于构建错误信息 +// args: 格式化字符串的参数 +// 日志格式为:[Error] [时间] [账本名称][上下文]: 错误信息 +func LogErrorDetailed(ledgerName string, context string, format string, args ...interface{}) { + message := fmt.Sprintf(format, args...) + fmt.Printf("[Error] [%s] [%s][%s]: %s\n", + time.Now().Format("2006-01-02 15:04:05"), + ledgerName, + context, + message) +} + +// LogSystemError 记录系统级错误信息 +// message: 错误描述信息 +// 该函数是对LogError的封装,专门用于记录系统模块的错误日志 func LogSystemError(message string) { LogError("System", message) } + +// LogSystemErrorDetailed 记录系统级错误日志,包含详细上下文信息 +// context: 错误发生的上下文环境 +// format: 格式化字符串,用于描述错误信息 +// args: 格式化字符串的参数 +func LogSystemErrorDetailed(context string, format string, args ...interface{}) { + LogErrorDetailed("System", context, format, args...) +} + +// Debug级别日志函数组 + +// LogDebug 在调试模式下记录调试日志,格式为:[Debug] [时间] [账本名称]: 消息 +// 仅当 IsDebugMode() 返回 true 时才会输出日志 +func LogDebug(ledgerName string, message string) { + if IsDebugMode() { + fmt.Printf("[Debug] [%s] [%s]: %s\n", time.Now().Format("2006-01-02 15:04:05"), ledgerName, message) + } +} + +// LogDebugDetailed 在调试模式下记录详细的调试日志 +// ledgerName: 账本名称,用于标识日志来源 +// context: 上下文信息,提供额外的日志分类 +// format: 格式化字符串,用于构建日志消息 +// args: 格式化字符串的参数 +// 日志格式: [Debug] [时间] [账本名称][上下文]: 消息内容 +// 注意: 仅在调试模式(IsDebugMode返回true)下输出日志 +func LogDebugDetailed(ledgerName string, context string, format string, args ...interface{}) { + if IsDebugMode() { + message := fmt.Sprintf(format, args...) + fmt.Printf("[Debug] [%s] [%s][%s]: %s\n", + time.Now().Format("2006-01-02 15:04:05"), + ledgerName, + context, + message) + } +} + +// LogSystemDebug 记录系统级别的调试日志 +// message: 需要记录的调试信息 +func LogSystemDebug(message string) { + LogDebug("System", message) +} + +// LogSystemDebugDetailed 记录系统级调试日志,包含详细上下文信息 +// context: 日志上下文标识 +// format: 日志格式字符串 +// args: 格式化参数 +func LogSystemDebugDetailed(context string, format string, args ...interface{}) { + LogDebugDetailed("System", context, format, args...) +} + +// 特殊功能日志函数 + +// LogBQLQueryDebug 在调试模式下记录BQL查询日志 +// ledgerName: 账本名称 +// context: 查询上下文信息 +// format: 日志格式字符串 +// args: 格式化参数 +// 注意: 仅在调试模式开启时才会记录日志 +func LogBQLQueryDebug(ledgerName string, context string, format string, args ...interface{}) { + if IsDebugMode() { + LogDebugDetailed(ledgerName, "BQL:"+context, format, args...) + } +} diff --git a/server.go b/server.go index ef5b233..987b454 100644 --- a/server.go +++ b/server.go @@ -3,14 +3,25 @@ package main import ( "flag" "fmt" - "github.com/beancount-gs/script" - "github.com/beancount-gs/service" - "github.com/gin-gonic/gin" "io" "net/http" "os" + + "github.com/beancount-gs/script" + "github.com/beancount-gs/service" + "github.com/beancount-gs/utils/venv" + "github.com/gin-gonic/gin" ) +// 全局变量,方便其他模块使用 +var venvExecutor *venv.VenvExecutor +var venvPath string // 新增:虚拟环境路径变量 + +/* + * 初始化服务器文件 + * 检查账本目录是否存在,如果不存在则创建 + * 返回error,表示操作是否成功 + */ func InitServerFiles() error { dataPath := script.GetServerConfig().DataPath // 账本目录不存在,则创建 @@ -20,6 +31,11 @@ func InitServerFiles() error { return nil } +/* + * 加载服务器缓存 + * 加载账本配置映射和账户映射 + * 返回error,表示加载过程中是否出错 + */ func LoadServerCache() error { err := script.LoadLedgerConfigMap() if err != nil { @@ -28,6 +44,11 @@ func LoadServerCache() error { return script.LoadLedgerAccountsMap() } +/* + * 授权中间件 + * 检查请求头中的ledgerId是否有效 + * 如果有效则继续处理请求,否则返回未授权错误 + */ func AuthorizedHandler() gin.HandlerFunc { return func(c *gin.Context) { ledgerId := c.GetHeader("ledgerId") @@ -42,18 +63,24 @@ func AuthorizedHandler() gin.HandlerFunc { } } +/* + * 注册路由 + * 配置静态文件服务、API路由和需要授权的路由组 + */ func RegisterRouter(router *gin.Engine) { // fix wildcard and static file router conflict, https://github.com/gin-gonic/gin/issues/360 router.GET("/", func(c *gin.Context) { c.Redirect(http.StatusMovedPermanently, "/web") }) router.StaticFS("/web", http.Dir("./public")) + // 公开API路由,无需授权 router.GET("/api/version", service.QueryVersion) router.POST("/api/check", service.CheckBeancount) router.GET("/api/config", service.QueryServerConfig) router.POST("/api/config", service.UpdateServerConfig) router.GET("/api/ledger", service.QueryLedgerList) router.POST("/api/ledger", service.OpenOrCreateLedger) + // 需要授权的API路由组 authorized := router.Group("/api/auth/") authorized.Use(AuthorizedHandler()) { @@ -106,19 +133,71 @@ func RegisterRouter(router *gin.Engine) { } } +// initVenvExecutor 初始化虚拟环境执行器 +func initVenvExecutor(venvDir string) { + venvPath = venvDir + script.SetVenvPath(venvDir) // 设置路径到 script 包 + + // 检查虚拟环境是否存在 + if !venv.CheckVenvExists(venvPath) { + script.LogSystemError("虚拟环境不存在,请先运行 setup script: " + venvPath) + fmt.Println("警告: 虚拟环境不存在,某些功能可能无法正常工作") + fmt.Println("请运行: ./start_dev.sh 或手动创建虚拟环境") + return + } + + venvExecutor = venv.NewVenvExecutor(venvPath) + script.SetVenvExecutor(venvExecutor) // 设置执行器到 script 包 + + // 测试 bean-query 是否可用 + _, err := venvExecutor.GetCommandPath("bean-query") + if err != nil { + script.LogSystemError("bean-query 不可用: " + err.Error()) + fmt.Println("警告: bean-query 命令不可用,价格查询功能将受限") + } else { + script.LogSystemInfo("虚拟环境初始化成功: bean-query 可用, 路径: " + venvPath) + fmt.Println("虚拟环境初始化成功: " + venvPath) + } +} + func main() { var secret string var port int + var debugFlag bool + var venvDir string // 新增:虚拟环境目录参数 + flag.StringVar(&secret, "secret", "", "服务器密钥") flag.IntVar(&port, "p", 10000, "端口号") + flag.BoolVar(&debugFlag, "debug", false, "调试模式") + flag.StringVar(&venvDir, "venv", ".env_beancount-v3", "虚拟环境目录名称,默认值为 .env_beancount-v3") // 新增参数 + flag.Parse() + // 初始化虚拟环境执行器 + initVenvExecutor(venvDir) + // 读取配置文件 err := script.LoadServerConfig() if err != nil { script.LogSystemError("Failed to load server config, " + err.Error()) return } + + // 如果命令行指定了debug参数,覆盖配置文件中的设置 + if debugFlag { + err = script.SetDebugMode(true) + if err != nil { + fmt.Println("Warning: Failed to set debug mode:", err) + } + } + + // 现在可以在任何地方使用 script.IsDebugMode() 来检查调试模式 + if script.IsDebugMode() { + fmt.Println("调试模式已启用") + } else { + fmt.Println("调试模式未启用") + } + serverConfig := script.GetServerConfig() // 若 DataPath == "" 则配置未初始化 if serverConfig.DataPath != "" { @@ -135,11 +214,13 @@ func main() { return } } + // gin 日志设置 gin.DisableConsoleColor() fs, _ := os.Create("logs/gin.log") gin.DefaultWriter = io.MultiWriter(fs, os.Stdout) router := gin.Default() + // 注册路由 RegisterRouter(router) @@ -151,10 +232,13 @@ func main() { startLog += " or http://" + ip + portStr } script.LogSystemInfo(startLog) + // 打开浏览器 script.OpenBrowser(url) + // 打印密钥 script.LogSystemInfo("Secret token is " + script.GenerateServerSecret(secret)) + // 启动服务 err = router.Run(portStr) if err != nil { diff --git a/utils/venv/venv.go b/utils/venv/venv.go new file mode 100644 index 0000000..8754cbb --- /dev/null +++ b/utils/venv/venv.go @@ -0,0 +1,124 @@ +/* + * @Author: liangzai450 + * @Date: 2025-09-17 20:50:11 + * @LastEditors: liangzai liangzai450@qq.com + * @LastEditTime: 2025-09-18 08:15:42 + * @FilePath: \\cnb-beancount\\utils\\venv\\venv.go + * @Description: + * Copyright (c) 2025 by ${git_name_email}, All Rights Reserved. + * ============================================== + */ + +package venv + +import ( + "bytes" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" +) + +// VenvExecutor 虚拟环境执行器 +type VenvExecutor struct { + venvPath string +} + +// NewVenvExecutor 创建新的虚拟环境执行器 +func NewVenvExecutor(venvPath string) *VenvExecutor { + return &VenvExecutor{venvPath: venvPath} +} + +// GetCommandPath 获取虚拟环境中命令的完整路径 +func (v *VenvExecutor) GetCommandPath(command string) (string, error) { + var cmdPath string + + if runtime.GOOS == "windows" { + cmdPath = filepath.Join(v.venvPath, "Scripts", command+".exe") + } else { + cmdPath = filepath.Join(v.venvPath, "bin", command) + } + + if _, err := os.Stat(cmdPath); os.IsNotExist(err) { + return "", fmt.Errorf("command %s not found at: %s", command, cmdPath) + } + + return cmdPath, nil +} + +// Execute 执行虚拟环境中的命令 +func (v *VenvExecutor) Execute(command string, args ...string) error { + cmdPath, err := v.GetCommandPath(command) + if err != nil { + return err + } + + cmd := exec.Command(cmdPath, args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + + return cmd.Run() +} + +// ExecuteWithOutput 执行命令并返回输出 +func (v *VenvExecutor) ExecuteWithOutput(command string, args ...string) ([]byte, []byte, error) { + cmdPath, err := v.GetCommandPath(command) + if err != nil { + return nil, nil, err + } + + cmd := exec.Command(cmdPath, args...) + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err = cmd.Run() + return stdout.Bytes(), stderr.Bytes(), err +} + +// BeanQueryStdout 执行 bean-query 命令,只返回纯净的标准输出 +func (v *VenvExecutor) BeanQueryStdout(beancountFile, query string) ([]byte, error) { + stdout, stderr, err := v.ExecuteWithOutput("bean-query", beancountFile, query) + if err != nil { + // 你可以选择记录 stderr 或根据错误类型处理 + fmt.Printf("Bean-query stderr: %s", string(stderr)) + return nil, err + } + return stdout, nil +} + +// BeanQuery 执行 bean-query 命令 +// 保持原有的 BeanQuery 函数(如果需要兼容旧代码) +func (v *VenvExecutor) BeanQuery(beancountFile, query string) ([]byte, error) { + return v.BeanQueryStdout(beancountFile, query) +} + +// BeanCheck 执行 bean-check 语法检查 +func (v *VenvExecutor) BeanCheck(beancountFile string) error { + _, err := v.BeanQueryStdout("bean-check", beancountFile) + return err +} + +// Fava 启动 fava 服务器 +func (v *VenvExecutor) Fava(beancountFile string, port int) error { + return v.Execute("fava", beancountFile, "--port", fmt.Sprintf("%d", port)) +} + +// CheckVenvExists 检查虚拟环境是否存在 +func CheckVenvExists(venvPath string) bool { + if runtime.GOOS == "windows" { + return dirExists(filepath.Join(venvPath, "Scripts")) + } + return dirExists(filepath.Join(venvPath, "bin")) +} + +func dirExists(path string) bool { + info, err := os.Stat(path) + if os.IsNotExist(err) { + return false + } + return info.IsDir() +}