├── api ├── v1 │ ├── ws │ │ ├── enter.go │ │ └── msg │ │ │ ├── msg.go │ │ │ ├── write.go │ │ │ └── reader.go │ ├── enter.go │ └── common.go ├── response │ ├── response.go │ ├── product.go │ ├── common.go │ └── func.go └── request │ ├── request.go │ ├── product.go │ ├── category.go │ ├── account.go │ ├── common.go │ └── user.go ├── util ├── response.go ├── hash.go ├── redisTool │ └── redis.go ├── rand │ └── rand.go ├── data.go ├── string.go ├── jwtTool │ └── jwt.go ├── dataTool │ ├── slice.go │ └── map.go ├── timeTool │ └── timeFunc.go └── fileTool │ └── file.go ├── service ├── log │ ├── enter.go │ └── logService.go ├── common │ └── enter.go ├── template │ ├── error.go │ ├── rankService.go │ └── enter.go ├── user │ ├── enter.go │ └── userFriendService.go ├── category │ ├── error.go │ ├── enter.go │ └── task.go ├── transaction │ ├── enter.go │ ├── event.go │ └── task.go ├── product │ ├── bill │ │ ├── error.go │ │ ├── aliPay.go │ │ ├── billInfo.go │ │ └── weChatPay.go │ ├── enter.go │ ├── productService.go │ └── productBillimportService.go ├── thirdparty │ ├── enter.go │ ├── task.go │ ├── email │ │ └── enter.go │ ├── ai.go │ └── email.go ├── account │ └── enter.go └── enter.go ├── test ├── initialize │ ├── main_test.go │ └── initialize.go ├── service │ ├── user │ │ ├── enter.go │ │ └── base_test.go │ ├── transaction │ │ ├── enter.go │ │ └── base_test.go │ ├── account │ │ └── enter.go │ ├── common │ │ └── common_test.go │ └── thirdparty │ │ └── ai_test.go ├── info │ └── info.go ├── util │ ├── get.go │ ├── query.go │ ├── enter.go │ └── build.go ├── getModel.go └── enter.go ├── .editcofiging ├── .editorconfig ├── global ├── lock │ ├── key.go │ ├── redis.go │ ├── lock.go │ └── mysql.go ├── nats │ ├── dlq.go │ ├── manager │ │ ├── error.go │ │ └── enter.go │ ├── enter.go │ ├── event.go │ └── outbox.go ├── struct.go ├── cus │ ├── key.go │ ├── time.go │ └── context.go ├── global.go ├── cron │ ├── enter.go │ └── scheduler.go ├── db │ ├── db.go │ └── db_test.go ├── error.go └── constant │ └── constant.go ├── .dockerignore ├── docker ├── redis.conf ├── Dockerfile ├── Dockerfile.build ├── Dockerfile.test ├── Dockerfile.debug └── mysql.cnf ├── model ├── account │ ├── error.go │ ├── enter.go │ └── accountMappingModel.go ├── common │ ├── baseModel.go │ ├── func.go │ └── query │ │ └── func.go ├── category │ ├── enter.go │ ├── categoryFatherModel.go │ └── categoryModel.go ├── enter.go ├── user │ ├── enter.go │ ├── userLogModel.go │ ├── transactionShareConfigModel.go │ └── userModel.go ├── transaction │ ├── enter.go │ └── statisticModel.go ├── log │ ├── accountLogMappingModel.go │ ├── accountLogDao.go │ ├── accountMappingModel.go │ └── enter.go └── product │ ├── productBillModel.go │ ├── productModel.go │ ├── enter.go │ ├── productBillHeaderModel.go │ ├── productTransactionCategoryMappingModel.go │ ├── productTransactionCategoryModel.go │ └── productDao.go ├── script ├── enter.go ├── user │ └── create │ │ └── main.go ├── user.go └── transaction │ └── statistic │ └── main.go ├── router ├── v1 │ ├── enter.go │ ├── common.go │ ├── transactionImport.go │ ├── category.go │ ├── transaction.go │ ├── user.go │ └── account.go ├── router.go ├── engine │ └── engin.go ├── websocket │ └── websocket.go ├── group │ └── group.go └── middleware │ └── middleware.go ├── initialize ├── scheduler.go ├── system.go ├── captcha.go ├── thirdparty.go ├── nats.go ├── logger.go ├── mysql.go ├── redis.go ├── initialize.go └── database │ └── enter.go ├── docs ├── updateDocs.sh └── beforeDocsMake │ └── renameModel │ └── main.go ├── .gitignore ├── .github ├── workflows │ ├── push-tag.yml │ ├── CI.yml │ ├── develop-CI.yml │ ├── common-create-release.yml │ └── common-push-image.yml └── config.yaml ├── .gitattributes ├── data ├── template │ └── email │ │ ├── registerSuccess.html │ │ ├── updatePassword.html │ │ └── captcha.html └── database │ └── product.sql ├── config.yaml ├── main.go └── docker-compose.yaml /api/v1/ws/enter.go: -------------------------------------------------------------------------------- 1 | package ws 2 | -------------------------------------------------------------------------------- /util/response.go: -------------------------------------------------------------------------------- 1 | package util 2 | -------------------------------------------------------------------------------- /service/log/enter.go: -------------------------------------------------------------------------------- 1 | package logService 2 | -------------------------------------------------------------------------------- /api/response/response.go: -------------------------------------------------------------------------------- 1 | package response 2 | -------------------------------------------------------------------------------- /test/initialize/main_test.go: -------------------------------------------------------------------------------- 1 | package initialize 2 | -------------------------------------------------------------------------------- /.editcofiging: -------------------------------------------------------------------------------- 1 | [*.go] 2 | indent_style = tab 3 | indent_size = 4 -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.go] 2 | indent_style = tab 3 | indent_size = 4 -------------------------------------------------------------------------------- /global/lock/key.go: -------------------------------------------------------------------------------- 1 | package lock 2 | 3 | type Key string 4 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | local_test 2 | docker/runtime 3 | docker/log 4 | docker/data -------------------------------------------------------------------------------- /api/request/request.go: -------------------------------------------------------------------------------- 1 | package request 2 | 3 | type GetOne struct { 4 | Id int `json:"id" binding:"required"` 5 | } 6 | -------------------------------------------------------------------------------- /api/request/product.go: -------------------------------------------------------------------------------- 1 | package request 2 | 3 | type ProductMappingTransactionCategory struct { 4 | CategoryId uint 5 | } 6 | -------------------------------------------------------------------------------- /service/common/enter.go: -------------------------------------------------------------------------------- 1 | package commonService 2 | 3 | type Group struct { 4 | common 5 | } 6 | 7 | var GroupApp = new(Group) 8 | -------------------------------------------------------------------------------- /service/template/error.go: -------------------------------------------------------------------------------- 1 | package templateService 2 | 3 | import "errors" 4 | 5 | var ErrNotBelongTemplate = errors.New("不属于模板") 6 | -------------------------------------------------------------------------------- /docker/redis.conf: -------------------------------------------------------------------------------- 1 | 2 | # log 3 | logfile "redis.log" 4 | 5 | # AOF 6 | appendonly yes 7 | appendfsync everysec 8 | appendfilename "appendonly.aof" -------------------------------------------------------------------------------- /service/user/enter.go: -------------------------------------------------------------------------------- 1 | package userService 2 | 3 | type Group struct { 4 | User 5 | Friend Friend 6 | } 7 | 8 | var GroupApp = new(Group) 9 | -------------------------------------------------------------------------------- /model/account/error.go: -------------------------------------------------------------------------------- 1 | package accountModel 2 | 3 | import "github.com/pkg/errors" 4 | 5 | var ( 6 | ErrAccountType = errors.New("account type") 7 | ) 8 | -------------------------------------------------------------------------------- /service/category/error.go: -------------------------------------------------------------------------------- 1 | package categoryService 2 | 3 | import "errors" 4 | 5 | var ( 6 | ErrExistTransaction = errors.New("exist transaction") 7 | ) 8 | -------------------------------------------------------------------------------- /script/enter.go: -------------------------------------------------------------------------------- 1 | package script 2 | 3 | import "github.com/ZiRunHua/LeapLedger/service" 4 | 5 | var ( 6 | userService = service.GroupApp.UserServiceGroup 7 | ) 8 | -------------------------------------------------------------------------------- /router/v1/enter.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | v1 "github.com/ZiRunHua/LeapLedger/api/v1" 5 | ) 6 | 7 | var ( 8 | apiApp = v1.ApiGroupApp 9 | publicApi = apiApp.PublicApi 10 | ) 11 | -------------------------------------------------------------------------------- /global/nats/dlq.go: -------------------------------------------------------------------------------- 1 | package nats 2 | 3 | import "context" 4 | 5 | func RepublishDieMsg(batch int, ctx context.Context) error { 6 | _, err := dlqManage.RepublishBatch(batch, ctx) 7 | return err 8 | } 9 | -------------------------------------------------------------------------------- /model/common/baseModel.go: -------------------------------------------------------------------------------- 1 | package commonModel 2 | 3 | type Model interface { 4 | modelInterface() 5 | } 6 | 7 | type BaseModel struct { 8 | } 9 | 10 | func (base *BaseModel) modelInterface() { 11 | 12 | } 13 | -------------------------------------------------------------------------------- /service/transaction/enter.go: -------------------------------------------------------------------------------- 1 | package transactionService 2 | 3 | type Group struct { 4 | Transaction 5 | Timing Timing 6 | } 7 | 8 | var ( 9 | GroupApp = new(Group) 10 | 11 | server = &Transaction{} 12 | ) 13 | -------------------------------------------------------------------------------- /global/nats/manager/error.go: -------------------------------------------------------------------------------- 1 | package manager 2 | 3 | import "errors" 4 | 5 | var ( 6 | ErrMsgHandlerNotExist = errors.New("msg handler not exist") 7 | 8 | ErrStreamNotExist = errors.New("stream not exist") 9 | ) 10 | -------------------------------------------------------------------------------- /service/product/bill/error.go: -------------------------------------------------------------------------------- 1 | package bill 2 | 3 | import "errors" 4 | 5 | var ( 6 | ErrCategoryCannotRead = errors.New("交易类型不可读取") 7 | ErrCategoryReadFail = errors.New("读取交易类型失败") 8 | ErrCategoryMappingNotExist = errors.New("不存在关联类型") 9 | ) 10 | -------------------------------------------------------------------------------- /util/hash.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "crypto/sha256" 5 | "encoding/hex" 6 | ) 7 | 8 | func ClientPasswordHash(email, password string) string { 9 | hash := sha256.Sum256([]byte(email + password)) 10 | return hex.EncodeToString(hash[:]) 11 | } 12 | -------------------------------------------------------------------------------- /test/service/user/enter.go: -------------------------------------------------------------------------------- 1 | package transaction 2 | 3 | import ( 4 | _ "github.com/ZiRunHua/LeapLedger/test/initialize" 5 | ) 6 | import ( 7 | _service "github.com/ZiRunHua/LeapLedger/service" 8 | ) 9 | 10 | var ( 11 | service = _service.GroupApp.UserServiceGroup 12 | ) 13 | -------------------------------------------------------------------------------- /service/product/enter.go: -------------------------------------------------------------------------------- 1 | package productService 2 | 3 | import transactionService "github.com/ZiRunHua/LeapLedger/service/transaction" 4 | 5 | type Group struct { 6 | Product 7 | } 8 | 9 | var GroupApp = new(Group) 10 | 11 | var transactionServer = transactionService.GroupApp 12 | -------------------------------------------------------------------------------- /service/thirdparty/enter.go: -------------------------------------------------------------------------------- 1 | package thirdpartyService 2 | 3 | import ( 4 | "github.com/ZiRunHua/LeapLedger/service/thirdparty/email" 5 | ) 6 | 7 | type Group struct { 8 | Ai aiServer 9 | } 10 | 11 | var ( 12 | GroupApp = new(Group) 13 | emailServer = email.Service 14 | ) 15 | -------------------------------------------------------------------------------- /initialize/scheduler.go: -------------------------------------------------------------------------------- 1 | package initialize 2 | 3 | import ( 4 | "github.com/go-co-op/gocron" 5 | "time" 6 | ) 7 | 8 | type _scheduler struct { 9 | } 10 | 11 | func (m *_scheduler) do() error { 12 | Scheduler = gocron.NewScheduler(time.Local) 13 | Scheduler.StartAsync() 14 | return nil 15 | } 16 | -------------------------------------------------------------------------------- /initialize/system.go: -------------------------------------------------------------------------------- 1 | package initialize 2 | 3 | type _system struct { 4 | Addr int `yaml:"Addr"` 5 | RouterPrefix string `yaml:"RouterPrefix"` 6 | LockMode string `yaml:"LockMode"` 7 | 8 | JwtKey string `yaml:"JwtKey"` 9 | ClientSignKey string `yaml:"ClientSignKey"` 10 | } 11 | -------------------------------------------------------------------------------- /service/category/enter.go: -------------------------------------------------------------------------------- 1 | package categoryService 2 | 3 | import ( 4 | _thirdpartyService "github.com/ZiRunHua/LeapLedger/service/thirdparty" 5 | ) 6 | 7 | type Group struct { 8 | Category 9 | Task _task 10 | } 11 | 12 | var GroupApp = new(Group) 13 | 14 | var aiService = _thirdpartyService.GroupApp.Ai 15 | -------------------------------------------------------------------------------- /model/category/enter.go: -------------------------------------------------------------------------------- 1 | package categoryModel 2 | 3 | import ( 4 | "github.com/ZiRunHua/LeapLedger/global/db" 5 | ) 6 | 7 | func init() { 8 | tables := []interface{}{ 9 | Category{}, Mapping{}, Father{}, 10 | } 11 | err := db.InitDb.AutoMigrate(tables...) 12 | if err != nil { 13 | panic(err) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /docs/updateDocs.sh: -------------------------------------------------------------------------------- 1 | #!/sh 2 | echo go install github.com/swaggo/swag/cmd/swag@latest 3 | go install github.com/swaggo/swag/cmd/swag@latest 4 | #echo go run /go/LeapLedger/docs/beforeDocsMake/renameModel/main.go 5 | #go run /go/LeapLedger/docs/beforeDocsMake/renameModel/main.go 6 | echo swag init -p pascalcase 7 | swag init -p pascalcase -------------------------------------------------------------------------------- /router/v1/common.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "github.com/ZiRunHua/LeapLedger/router/group" 5 | ) 6 | 7 | func init() { 8 | // base path: /public 9 | router := group.Public 10 | { 11 | router.GET("/captcha", publicApi.Captcha) 12 | router.POST("/captcha/email/send", publicApi.SendEmailCaptcha) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /global/struct.go: -------------------------------------------------------------------------------- 1 | package global 2 | 3 | import "time" 4 | 5 | type AmountCount struct { 6 | Amount int64 7 | Count int64 8 | } 9 | 10 | type IEStatistic struct { 11 | Income AmountCount 12 | Expense AmountCount 13 | } 14 | 15 | type IEStatisticWithTime struct { 16 | IEStatistic 17 | StartTime time.Time 18 | EndTime time.Time 19 | } 20 | -------------------------------------------------------------------------------- /api/v1/ws/msg/msg.go: -------------------------------------------------------------------------------- 1 | // Package msg is the middle layer of client websocket communication 2 | // This package is used to standardize websocket message formats with clients 3 | package msg 4 | 5 | import "errors" 6 | 7 | type MsgType string 8 | type MsgHandler func([]byte) error 9 | 10 | var ErrMsgHandleNotExist = errors.New("msg handel not exist") 11 | -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.23-alpine 2 | 3 | ENV GO111MODULE=on 4 | ENV GOPROXY=https://goproxy.io,direct 5 | ENV TZ=Asia/Shanghai 6 | 7 | WORKDIR /go/LeapLedger 8 | 9 | COPY go.mod go.mod 10 | COPY go.sum go.sum 11 | RUN go mod download 12 | 13 | COPY . . 14 | 15 | RUN go build -o leapledger 16 | 17 | EXPOSE 8080 18 | 19 | CMD ["/bin/sh", "-c", "./leapledger"] -------------------------------------------------------------------------------- /global/cus/key.go: -------------------------------------------------------------------------------- 1 | package cus 2 | 3 | type Key string 4 | 5 | const Claims Key = "claims" 6 | 7 | const UserId Key = "userId" 8 | const User Key = "user" 9 | 10 | const Account Key = "account" 11 | const AccountUser Key = "accountUser" 12 | const AccountId Key = "accountId" 13 | 14 | const Db Key = "Db" 15 | const Tx Key = "Tx" 16 | const TxCommit Key = "TxCommit" 17 | -------------------------------------------------------------------------------- /model/enter.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | _ "github.com/ZiRunHua/LeapLedger/model/account" 5 | _ "github.com/ZiRunHua/LeapLedger/model/category" 6 | _ "github.com/ZiRunHua/LeapLedger/model/log" 7 | _ "github.com/ZiRunHua/LeapLedger/model/product" 8 | _ "github.com/ZiRunHua/LeapLedger/model/transaction" 9 | _ "github.com/ZiRunHua/LeapLedger/model/user" 10 | ) 11 | -------------------------------------------------------------------------------- /service/account/enter.go: -------------------------------------------------------------------------------- 1 | package accountService 2 | 3 | import ( 4 | log "github.com/ZiRunHua/LeapLedger/service/log" 5 | userService "github.com/ZiRunHua/LeapLedger/service/user" 6 | ) 7 | 8 | var GroupApp = &Group{} 9 | 10 | type Group struct { 11 | base 12 | Share share 13 | } 14 | 15 | var ( 16 | logServer = log.Log 17 | userServer = userService.GroupApp 18 | ) 19 | -------------------------------------------------------------------------------- /test/info/info.go: -------------------------------------------------------------------------------- 1 | package info 2 | 3 | import "encoding/json" 4 | 5 | var ( 6 | Data Info 7 | ) 8 | 9 | type Info struct { 10 | UserId uint 11 | Email string 12 | AccountId uint 13 | Token string 14 | } 15 | 16 | func (i *Info) ToString() string { 17 | b, err := json.Marshal(i) 18 | if err != nil { 19 | panic(err) 20 | } 21 | return string(b) 22 | } 23 | -------------------------------------------------------------------------------- /test/service/transaction/enter.go: -------------------------------------------------------------------------------- 1 | package transaction 2 | 3 | import ( 4 | _ "github.com/ZiRunHua/LeapLedger/test/initialize" 5 | testUtil "github.com/ZiRunHua/LeapLedger/test/util" 6 | ) 7 | import ( 8 | _service "github.com/ZiRunHua/LeapLedger/service" 9 | ) 10 | 11 | var ( 12 | get = &testUtil.Get{} 13 | 14 | service = _service.GroupApp.TransactionServiceGroup 15 | ) 16 | -------------------------------------------------------------------------------- /util/redisTool/redis.go: -------------------------------------------------------------------------------- 1 | package redisTool 2 | 3 | import ( 4 | "context" 5 | "github.com/ZiRunHua/LeapLedger/global" 6 | "strconv" 7 | ) 8 | 9 | var ( 10 | rdb = global.GvaRdb 11 | ) 12 | 13 | func GetInt(key string, ctx context.Context) (value int, err error) { 14 | str, err := rdb.Get(ctx, key).Result() 15 | if err != nil { 16 | return 17 | } 18 | return strconv.Atoi(str) 19 | } 20 | -------------------------------------------------------------------------------- /model/user/enter.go: -------------------------------------------------------------------------------- 1 | package userModel 2 | 3 | import "github.com/ZiRunHua/LeapLedger/global/db" 4 | 5 | func init() { 6 | tables := []interface{}{ 7 | User{}, UserClientWeb{}, UserClientAndroid{}, UserClientIos{}, Tour{}, 8 | Friend{}, FriendInvitation{}, 9 | TransactionShareConfig{}, 10 | Log{}, 11 | } 12 | err := db.InitDb.AutoMigrate(tables...) 13 | if err != nil { 14 | panic(err) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | *.idea 8 | 9 | # Test binary, built with `go test -c` 10 | local_test 11 | /log 12 | *.log 13 | 14 | runtime 15 | 16 | docker/* 17 | !docker/redis.conf 18 | !docker/mysql.cnf 19 | 20 | !docker/Dockerfile 21 | !docker/Dockerfile.build 22 | !docker/Dockerfile.debug 23 | !docker/Dockerfile.test 24 | 25 | docker-compose.override.yaml 26 | 27 | production_config.yaml -------------------------------------------------------------------------------- /api/v1/ws/msg/write.go: -------------------------------------------------------------------------------- 1 | package msg 2 | 3 | import ( 4 | "github.com/gorilla/websocket" 5 | ) 6 | 7 | type Msg struct { 8 | Type MsgType 9 | Data any 10 | } 11 | 12 | func Send[T any](conn *websocket.Conn, t MsgType, data T) error { 13 | return conn.WriteJSON(Msg{Type: t, Data: data}) 14 | } 15 | 16 | const msgTypeErr MsgType = "error" 17 | 18 | func SendError(conn *websocket.Conn, err error) error { 19 | return conn.WriteJSON(Msg{Type: msgTypeErr, Data: err.Error()}) 20 | } 21 | -------------------------------------------------------------------------------- /test/util/get.go: -------------------------------------------------------------------------------- 1 | package tUtil 2 | 3 | import ( 4 | "math/rand" 5 | 6 | categoryModel "github.com/ZiRunHua/LeapLedger/model/category" 7 | transactionModel "github.com/ZiRunHua/LeapLedger/model/transaction" 8 | ) 9 | 10 | type Get struct { 11 | } 12 | 13 | func (g *Get) Category() categoryModel.Category { 14 | return testCategoryList[rand.Intn(len(testCategoryList))] 15 | } 16 | 17 | func (g *Get) TransInfo() transactionModel.Info { 18 | return build.TransInfo(testUser, g.Category()) 19 | } 20 | -------------------------------------------------------------------------------- /test/util/query.go: -------------------------------------------------------------------------------- 1 | package tUtil 2 | 3 | import ( 4 | "github.com/ZiRunHua/LeapLedger/global/constant" 5 | "github.com/ZiRunHua/LeapLedger/global/db" 6 | categoryModel "github.com/ZiRunHua/LeapLedger/model/category" 7 | ) 8 | 9 | type Query struct { 10 | } 11 | 12 | func (q *Query) Category(accountID uint, ie constant.IncomeExpense) (category categoryModel.Category, err error) { 13 | err = db.Db.Where("account_id = ? AND income_expense = ?", accountID, ie).First(&category).Error 14 | return 15 | } 16 | -------------------------------------------------------------------------------- /model/transaction/enter.go: -------------------------------------------------------------------------------- 1 | package transactionModel 2 | 3 | import "github.com/ZiRunHua/LeapLedger/global/db" 4 | 5 | func init() { 6 | tables := []interface{}{ 7 | Transaction{}, Mapping{}, 8 | ExpenseAccountStatistic{}, ExpenseAccountUserStatistic{}, ExpenseCategoryStatistic{}, 9 | IncomeAccountStatistic{}, IncomeAccountUserStatistic{}, IncomeCategoryStatistic{}, 10 | Timing{}, TimingExec{}, 11 | } 12 | err := db.InitDb.AutoMigrate(tables...) 13 | if err != nil { 14 | panic(err) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /initialize/captcha.go: -------------------------------------------------------------------------------- 1 | package initialize 2 | 3 | type _captcha struct { 4 | KeyLong int `yaml:"KeyLong"` 5 | ImgWidth int `yaml:"ImgWidth"` 6 | ImgHeight int `yaml:"ImgHeight"` 7 | OpenCaptcha int `yaml:"OpenCaptcha"` // 防爆破验证码开启此数,0代表每次登录都需要验证码,其他数字代表错误密码此数,如3代表错误三次后出现验证码 8 | OpenCaptchaTimeOut int `yaml:"OpenCaptchaTimeOut"` // 防爆破验证码超时时间,单位:s(秒) 9 | EmailCaptcha int `yaml:"EmailCaptcha"` 10 | EmailCaptchaTimeOut int `yaml:"EmailCaptchaTimeOut"` 11 | } 12 | -------------------------------------------------------------------------------- /docker/Dockerfile.build: -------------------------------------------------------------------------------- 1 | FROM golang:1.23-alpine AS builder 2 | 3 | ENV GO111MODULE=on 4 | ENV GOPROXY=https://goproxy.io,direct 5 | ENV TZ=Asia/Shanghai 6 | 7 | WORKDIR /go/LeapLedger 8 | 9 | COPY go.mod go.mod 10 | COPY go.sum go.sum 11 | RUN go mod download 12 | 13 | COPY . . 14 | 15 | RUN go build -o leapledger 16 | 17 | FROM alpine:3.20.3 18 | 19 | ENV TZ=Asia/Shanghai 20 | 21 | WORKDIR /go/LeapLedger 22 | 23 | COPY --from=builder /go/LeapLedger /go/LeapLedger 24 | 25 | EXPOSE 8080 26 | 27 | CMD ["./leapledger"] -------------------------------------------------------------------------------- /model/common/func.go: -------------------------------------------------------------------------------- 1 | package commonModel 2 | 3 | import ( 4 | "github.com/ZiRunHua/LeapLedger/global" 5 | "github.com/pkg/errors" 6 | "gorm.io/gorm" 7 | ) 8 | 9 | func ExistOfModel(model Model, query interface{}, args ...interface{}) (bool, error) { 10 | err := global.GvaDb.Where(query, args...).Take(model).Error 11 | if errors.Is(err, gorm.ErrRecordNotFound) { 12 | return false, nil 13 | } else if err == nil { 14 | return true, nil 15 | } else { 16 | return false, errors.Wrap(err, "exist") 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /util/rand/rand.go: -------------------------------------------------------------------------------- 1 | package rand 2 | 3 | import ( 4 | mathRand "math/rand" 5 | "time" 6 | ) 7 | 8 | const charSet string = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" 9 | 10 | func String(length int) string { 11 | mathRand.New(mathRand.NewSource(time.Now().UnixNano())) 12 | var result string 13 | for i := 0; i < length; i++ { 14 | randomIndex := mathRand.Intn(len(charSet)) 15 | result += string(charSet[randomIndex]) 16 | } 17 | return result 18 | } 19 | func Int(max int) int { 20 | return mathRand.Intn(max) 21 | } 22 | -------------------------------------------------------------------------------- /.github/workflows/push-tag.yml: -------------------------------------------------------------------------------- 1 | name: Push Tag 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | create-release: 10 | uses: ./.github/workflows/common-create-release.yml 11 | with: 12 | draft: true 13 | 14 | push-image: 15 | needs: create-release 16 | uses: ./.github/workflows/common-push-image.yml 17 | with: 18 | tag: ${{ github.ref_name }} 19 | secrets: 20 | DOCKER_HUB_USERNAME: ${{ secrets.DOCKER_HUB_USERNAME }} 21 | DOCKER_HUB_ACCESS_TOKEN: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} -------------------------------------------------------------------------------- /test/service/account/enter.go: -------------------------------------------------------------------------------- 1 | package transaction 2 | 3 | import ( 4 | _ "github.com/ZiRunHua/LeapLedger/test/initialize" 5 | testUtil "github.com/ZiRunHua/LeapLedger/test/util" 6 | ) 7 | import ( 8 | _service "github.com/ZiRunHua/LeapLedger/service" 9 | ) 10 | 11 | var ( 12 | build = &testUtil.Build{} 13 | query = &testUtil.Query{} 14 | service = _service.GroupApp.AccountServiceGroup 15 | categoryService = _service.GroupApp.CategoryServiceGroup 16 | transService = _service.GroupApp.TransactionServiceGroup 17 | ) 18 | -------------------------------------------------------------------------------- /model/log/accountLogMappingModel.go: -------------------------------------------------------------------------------- 1 | package logModel 2 | 3 | import ( 4 | "gorm.io/gorm" 5 | "time" 6 | ) 7 | 8 | type AccountLogMapping struct { 9 | ID uint `gorm:"primarykey"` 10 | AccountId uint `gorm:"index:idx_account_id"` 11 | LogTable string 12 | LogId uint 13 | CreatedAt time.Time `gorm:"type:TIMESTAMP"` 14 | UpdatedAt time.Time `gorm:"type:TIMESTAMP"` 15 | DeletedAt gorm.DeletedAt `gorm:"index;type:TIMESTAMP"` 16 | } 17 | 18 | func (a *AccountLogMapping) TableName() string { 19 | return "account_log_mapping" 20 | } 21 | -------------------------------------------------------------------------------- /docker/Dockerfile.test: -------------------------------------------------------------------------------- 1 | FROM golang:1.23-alpine 2 | 3 | ENV GO111MODULE=on 4 | ENV GOPROXY=https://goproxy.io,direct 5 | ENV TZ=Asia/Shanghai 6 | 7 | WORKDIR /go/LeapLedger 8 | 9 | #RUN sed -i 's#https\?://dl-cdn.alpinelinux.org/alpine#https://mirrors.tuna.tsinghua.edu.cn/alpine#g' /etc/apk/repositories 10 | RUN apk update && apk add build-base && apk add gcc 11 | 12 | COPY go.mod go.mod 13 | COPY go.sum go.sum 14 | RUN go mod download 15 | 16 | RUN go install gotest.tools/gotestsum@latest 17 | 18 | COPY . . 19 | 20 | CMD ["gotestsum", "--junitfile", "docs/junit.xml"] -------------------------------------------------------------------------------- /docker/Dockerfile.debug: -------------------------------------------------------------------------------- 1 | FROM golang:1.23-alpine 2 | 3 | # 设置环境变量 4 | ENV GO111MODULE=on 5 | ENV GOPROXY=https://goproxy.io,direct 6 | ENV TZ=Asia/Shanghai 7 | 8 | # 设置工作目录 9 | WORKDIR /go/LeapLedger 10 | 11 | # 拷贝依赖文件并下载 12 | COPY go.mod go.mod 13 | COPY go.sum go.sum 14 | RUN go mod download 15 | 16 | RUN go install github.com/go-delve/delve/cmd/dlv@v1.23 17 | 18 | # 拷贝源代码 19 | COPY . . 20 | 21 | # 构建Go应用程序 22 | RUN go build -gcflags="-N -l" -o leapledger . 23 | 24 | # 声明服务端口 25 | EXPOSE 8080 2345 26 | # 指定容器启动命令 27 | CMD ["dlv", "--listen=:2345", "--headless=true", "--api-version=2", "exec", "./leapledger"] -------------------------------------------------------------------------------- /test/service/common/common_test.go: -------------------------------------------------------------------------------- 1 | package thirdparty 2 | 3 | import ( 4 | _ "github.com/ZiRunHua/LeapLedger/test/initialize" 5 | ) 6 | import ( 7 | _commonService "github.com/ZiRunHua/LeapLedger/service/common" 8 | "testing" 9 | ) 10 | 11 | var commonServer = _commonService.GroupApp 12 | 13 | func TestJwt(t *testing.T) { 14 | claims := commonServer.MakeCustomClaims(1) 15 | token, err := commonServer.GenerateJWT(claims) 16 | if err != nil { 17 | t.Error(err) 18 | } 19 | claims, err = commonServer.ParseToken(token) 20 | if err != nil { 21 | t.Error(err) 22 | } 23 | 24 | t.Log(claims) 25 | } 26 | -------------------------------------------------------------------------------- /initialize/thirdparty.go: -------------------------------------------------------------------------------- 1 | package initialize 2 | 3 | type _thirdParty struct { 4 | WeCom _weCom `yaml:"WeCom"` 5 | Ai _ai `yaml:"Ai"` 6 | } 7 | 8 | type _weCom struct { 9 | CorpId string `yaml:"CorpId"` 10 | CorpSecret string `yaml:"CorpSecret"` 11 | } 12 | 13 | type _ai struct { 14 | Host string `yaml:"Host"` 15 | Port string `yaml:"Port"` 16 | MinSimilarity float32 `yaml:"MinSimilarity"` 17 | } 18 | 19 | func (a _ai) GetPortalSite() string { 20 | return "http://" + a.Host + ":" + a.Port 21 | } 22 | func (a _ai) IsOpen() bool { 23 | return a.Host != "" && a.Port != "" 24 | } 25 | -------------------------------------------------------------------------------- /.github/workflows/CI.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: [ "main" ] 7 | 8 | jobs: 9 | test-and-update-docs: 10 | uses: ./.github/workflows/common-test-and-update-docs.yml 11 | with: 12 | ref: ${{ github.ref }} 13 | secrets: 14 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 15 | 16 | push-image: 17 | needs: test-and-update-docs 18 | uses: ./.github/workflows/common-push-image.yml 19 | with: 20 | tag: latest 21 | secrets: 22 | DOCKER_HUB_USERNAME: ${{ secrets.DOCKER_HUB_USERNAME }} 23 | DOCKER_HUB_ACCESS_TOKEN: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} -------------------------------------------------------------------------------- /global/global.go: -------------------------------------------------------------------------------- 1 | package global 2 | 3 | import ( 4 | "github.com/ZiRunHua/LeapLedger/initialize" 5 | "go.uber.org/zap" 6 | ) 7 | 8 | var ( 9 | GvaDb = initialize.Db 10 | GvaRdb = initialize.Rdb 11 | Config = initialize.Config 12 | Cache = initialize.Cache 13 | ) 14 | 15 | var ( 16 | RequestLogger *zap.Logger 17 | ErrorLogger *zap.Logger 18 | PanicLogger *zap.Logger 19 | ) 20 | 21 | func init() { 22 | GvaDb = initialize.Db 23 | GvaRdb = initialize.Rdb 24 | Config = initialize.Config 25 | Cache = initialize.Cache 26 | 27 | RequestLogger = initialize.RequestLogger 28 | ErrorLogger = initialize.ErrorLogger 29 | PanicLogger = initialize.PanicLogger 30 | } 31 | -------------------------------------------------------------------------------- /model/log/accountLogDao.go: -------------------------------------------------------------------------------- 1 | package logModel 2 | 3 | import ( 4 | "github.com/ZiRunHua/LeapLedger/global" 5 | "gorm.io/gorm" 6 | ) 7 | 8 | type AccountLogDao struct { 9 | db *gorm.DB 10 | } 11 | 12 | func NewDao(db ...*gorm.DB) *AccountLogDao { 13 | if len(db) > 0 { 14 | return &AccountLogDao{db: db[0]} 15 | } 16 | return &AccountLogDao{global.GvaDb} 17 | } 18 | 19 | func (d *AccountLogDao) RecordAccountLogMapping(logModel AccountLogger) (AccountLogMapping, error) { 20 | log := AccountLogMapping{ 21 | AccountId: logModel.GetAccountId(), 22 | LogTable: logModel.TableName(), 23 | LogId: logModel.GetId(), 24 | } 25 | return log, d.db.Create(&log).Error 26 | } 27 | -------------------------------------------------------------------------------- /.github/workflows/develop-CI.yml: -------------------------------------------------------------------------------- 1 | name: Develop CI 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: [ "develop" ] 7 | 8 | jobs: 9 | test-and-update-docs: 10 | uses: ./.github/workflows/common-test-and-update-docs.yml 11 | with: 12 | ref: ${{ github.ref }} 13 | secrets: 14 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 15 | 16 | push-image: 17 | needs: test-and-update-docs 18 | uses: ./.github/workflows/common-push-image.yml 19 | with: 20 | tag: ${{ github.ref_name }} 21 | secrets: 22 | DOCKER_HUB_USERNAME: ${{ secrets.DOCKER_HUB_USERNAME }} 23 | DOCKER_HUB_ACCESS_TOKEN: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} -------------------------------------------------------------------------------- /api/response/product.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | import ( 4 | "github.com/ZiRunHua/LeapLedger/global/constant" 5 | ) 6 | 7 | type ProductOne struct { 8 | UniqueKey string 9 | Name string 10 | } 11 | 12 | type ProductList struct { 13 | List []ProductOne 14 | } 15 | 16 | type ProductTransactionCategory struct { 17 | Id uint 18 | Name string 19 | IncomeExpense constant.IncomeExpense 20 | } 21 | 22 | type ProductTransactionCategoryList struct { 23 | List []ProductTransactionCategory 24 | } 25 | 26 | type ProductMappingTree struct { 27 | Tree []ProductMappingTreeFather 28 | } 29 | 30 | type ProductMappingTreeFather struct { 31 | FatherId uint 32 | Children []uint 33 | } 34 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.go whitespace=fix 2 | 3 | # Auto detect text files and perform LF normalization 4 | * text=auto 5 | 6 | # Always perform LF normalization on Go source files and related text files 7 | *.go text 8 | *.md text 9 | *.yaml text 10 | *.yml text 11 | *.json text 12 | *.sh text 13 | *.txt text 14 | *.xml text 15 | 16 | # Make sure that these Windows files always have CRLF line endings in checkout 17 | *.bat text eol=crlf 18 | *.ps1 text eol=crlf 19 | 20 | # Never perform LF normalization on these binary files 21 | *.zip binary 22 | *.tar binary 23 | *.gz binary 24 | *.bz2 binary 25 | *.7z binary 26 | *.rar binary 27 | *.xls binary 28 | *.xlsx binary -------------------------------------------------------------------------------- /data/template/email/registerSuccess.html: -------------------------------------------------------------------------------- 1 |
2 |

亲爱的 [username]

3 |

我们很高兴通知你,你的注册已成功完成。现在,你可以开始尽情享受我们的应用了。

4 |

祝你愉快的使用体验!

5 |

最诚挚的问候。

6 |

起飞咯! 7 |

-------------------------------------------------------------------------------- /data/template/email/updatePassword.html: -------------------------------------------------------------------------------- 1 |
2 |

亲爱的 [username]

3 |

我们注意到您的账户密码已经被修改。为了确保您的账户的安全性和保护您的个人信息。如果您不是执行此操作的人,请立即联系我们的客户支持团队以报告此问题。

4 |

祝你愉快的使用体验!

5 |

最诚挚的问候。

6 |

起飞咯! 7 |

-------------------------------------------------------------------------------- /model/product/productBillModel.go: -------------------------------------------------------------------------------- 1 | package productModel 2 | 3 | import ( 4 | "github.com/ZiRunHua/LeapLedger/global/constant" 5 | commonModel "github.com/ZiRunHua/LeapLedger/model/common" 6 | queryFunc "github.com/ZiRunHua/LeapLedger/model/common/query" 7 | ) 8 | 9 | type Bill struct { 10 | ProductKey Key `gorm:"primary_key;"` 11 | Encoding constant.Encoding 12 | StartRow int 13 | DateFormat string `gorm:"default:2006-01-02 15:04:05;"` 14 | commonModel.BaseModel 15 | } 16 | 17 | func (b *Bill) TableName() string { 18 | return "product_bill" 19 | } 20 | 21 | func (b *Bill) IsEmpty() bool { 22 | return b.ProductKey == "" 23 | } 24 | 25 | func (b *Bill) SelectByPrimaryKey(key string) (*Bill, error) { 26 | return queryFunc.FirstByField[*Bill]("product_key", key) 27 | } 28 | -------------------------------------------------------------------------------- /global/cus/time.go: -------------------------------------------------------------------------------- 1 | package cus 2 | 3 | import ( 4 | "gorm.io/gorm" 5 | "gorm.io/gorm/schema" 6 | "time" 7 | ) 8 | 9 | type Time time.Time 10 | 11 | // GormDataType returns the common data type in Gorm for CustomTime 12 | func (Time) GormDataType() string { 13 | return "timestamp" 14 | } 15 | 16 | // GormDBDataType returns the specific data type for a given dialect 17 | func (Time) GormDBDataType(db *gorm.DB, field *schema.Field) string { 18 | return "TIMESTAMP" 19 | } 20 | 21 | type DeletedAt gorm.DeletedAt 22 | 23 | func (DeletedAt) GormDataType() string { 24 | return "timestamp" 25 | } 26 | 27 | // GormDBDataType returns the specific data type for a given dialect 28 | func (DeletedAt) GormDBDataType(db *gorm.DB, field *schema.Field) string { 29 | return "TIMESTAMP" 30 | } 31 | -------------------------------------------------------------------------------- /.github/workflows/common-create-release.yml: -------------------------------------------------------------------------------- 1 | name: Create Release 2 | 3 | on: 4 | workflow_call: 5 | inputs: 6 | draft: 7 | required: true 8 | type: boolean 9 | 10 | jobs: 11 | create-release: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout code 15 | uses: actions/checkout@v4 16 | 17 | - name: Create GitHub Release 18 | id: create_release 19 | uses: actions/create-release@v1 20 | env: 21 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 22 | with: 23 | tag_name: ${{ github.ref_name }} 24 | release_name: "Release ${{ github.ref_name }}" 25 | body: | 26 | Release notes for version ${{ github.ref_name }}. 27 | draft: ${{ inputs.draft }} 28 | prerelease: false -------------------------------------------------------------------------------- /model/log/accountMappingModel.go: -------------------------------------------------------------------------------- 1 | package logModel 2 | 3 | import ( 4 | "gorm.io/gorm" 5 | ) 6 | 7 | type AccountMappingLogData struct { 8 | MainId uint 9 | RelatedId uint 10 | } 11 | 12 | func (m *AccountMappingLogData) Record(baseLog BaseAccountLog, tx *gorm.DB) (AccountLogger, error) { 13 | log := AccountMappingLog{BaseAccountLog: baseLog, Data: *m} 14 | return &log, tx.Create(&log).Error 15 | } 16 | 17 | type AccountMappingLog struct { 18 | BaseAccountLog `gorm:"embedded"` 19 | Data AccountMappingLogData `gorm:"embedded;embeddedPrefix:data_"` 20 | } 21 | 22 | func (a *AccountMappingLog) TableName() string { 23 | return "log_account_mapping" 24 | } 25 | 26 | func (a *AccountMappingLog) RecordMapping(tx *gorm.DB) (AccountLogMapping, error) { 27 | return NewDao(tx).RecordAccountLogMapping(a) 28 | } 29 | -------------------------------------------------------------------------------- /global/cron/enter.go: -------------------------------------------------------------------------------- 1 | package cron 2 | 3 | import ( 4 | "context" 5 | "path/filepath" 6 | 7 | "github.com/ZiRunHua/LeapLedger/global" 8 | "github.com/ZiRunHua/LeapLedger/global/constant" 9 | "github.com/ZiRunHua/LeapLedger/global/nats" 10 | "github.com/ZiRunHua/LeapLedger/initialize" 11 | "go.uber.org/zap" 12 | ) 13 | 14 | var ( 15 | logPath = filepath.Join(constant.LogPath, "cron.log") 16 | logger *zap.Logger 17 | 18 | Scheduler = initialize.Scheduler 19 | ) 20 | 21 | func init() { 22 | var err error 23 | logger, err = global.Config.Logger.New(logPath) 24 | if err != nil { 25 | panic(err) 26 | } 27 | _, err = Scheduler.Every(30).Minute().Do( 28 | MakeJobFunc( 29 | func() error { 30 | return nats.RepublishDieMsg(50, context.TODO()) 31 | }, 32 | ), 33 | ) 34 | if err != nil { 35 | panic(err) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /model/account/enter.go: -------------------------------------------------------------------------------- 1 | package accountModel 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/ZiRunHua/LeapLedger/global" 7 | "github.com/ZiRunHua/LeapLedger/global/cus" 8 | "github.com/ZiRunHua/LeapLedger/global/db" 9 | ) 10 | 11 | func init() { 12 | ctx := cus.WithDb(context.TODO(), db.InitDb) 13 | tables := []interface{}{ 14 | Account{}, Mapping{}, 15 | User{}, UserConfig{}, UserInvitation{}, 16 | } 17 | err := ctx.GetDb().AutoMigrate(tables...) 18 | if err != nil { 19 | panic(err) 20 | } 21 | err = NewDao(ctx.GetDb()).initRedis() 22 | if err != nil { 23 | panic(err) 24 | } 25 | } 26 | 27 | var ( 28 | rdb = global.GvaRdb 29 | rdbKey redisKey 30 | ) 31 | 32 | type redisKey struct { 33 | } 34 | 35 | func (r *redisKey) getLocation(id uint) string { 36 | return fmt.Sprintf("account:%d:location", id) 37 | } 38 | -------------------------------------------------------------------------------- /test/service/thirdparty/ai_test.go: -------------------------------------------------------------------------------- 1 | package thirdparty 2 | 3 | import ( 4 | _ "github.com/ZiRunHua/LeapLedger/test/initialize" 5 | ) 6 | import ( 7 | "context" 8 | _thirdpartyService "github.com/ZiRunHua/LeapLedger/service/thirdparty" 9 | "testing" 10 | ) 11 | 12 | var aiService = _thirdpartyService.GroupApp.Ai 13 | 14 | func TestChineseSimilarityMatching(t *testing.T) { 15 | sourceData := []string{ 16 | "餐饮美食", "酒店旅游", "运动户外", "美容美发", "生活服务", "爱车养车", 17 | "母婴亲子", "服饰装扮", "日用百货", "文化休闲", "数码电器", "教育培训", 18 | "家居家装", "宠物", "商业服务", "医疗健康", "其他", 19 | } 20 | 21 | targetData := []string{ 22 | "住房", "交通", "购物", "通讯", "餐饮", "杂项", 23 | "医疗", "旅游", "文化休闲", "其他", 24 | } 25 | result, err := aiService.BatchChineseSimilarityMatching(sourceData, targetData, context.TODO()) 26 | if err != nil { 27 | t.Error(err) 28 | } 29 | t.Log(result) 30 | } 31 | -------------------------------------------------------------------------------- /data/template/email/captcha.html: -------------------------------------------------------------------------------- 1 |
2 |

你好!

3 |

您正在[Action],我们需要验证你的电子邮件地址。

4 |

你的验证码是:

5 |

[Captcha]

6 |

验证码将在 [ExpirationTime] 后过期,请尽快完成验证。如果你没有进行此[Action]请求,请忽略此邮件。

7 |

祝愉快注册! 8 |

-------------------------------------------------------------------------------- /service/category/task.go: -------------------------------------------------------------------------------- 1 | package categoryService 2 | 3 | import ( 4 | "github.com/ZiRunHua/LeapLedger/global/nats" 5 | accountModel "github.com/ZiRunHua/LeapLedger/model/account" 6 | categoryModel "github.com/ZiRunHua/LeapLedger/model/category" 7 | ) 8 | 9 | type _task struct{} 10 | 11 | func init() { 12 | nats.SubscribeTaskWithPayloadAndProcessInTransaction[accountModel.Mapping]( 13 | nats.TaskMappingCategoryToAccountMapping, 14 | GroupApp.MappingCategoryToAccountMapping, 15 | ) 16 | 17 | nats.SubscribeTaskWithPayload[categoryModel.Category]( 18 | nats.TaskUpdateCategoryMapping, 19 | GroupApp.UpdateCategoryMapping, 20 | ) 21 | } 22 | 23 | func (t *_task) MappingCategoryToAccountMapping(mappingAccount accountModel.Mapping) error { 24 | _ = nats.PublishTaskWithPayload[accountModel.Mapping](nats.TaskMappingCategoryToAccountMapping, mappingAccount) 25 | return nil 26 | } 27 | -------------------------------------------------------------------------------- /service/log/logService.go: -------------------------------------------------------------------------------- 1 | package logService 2 | 3 | import ( 4 | logModel "github.com/ZiRunHua/LeapLedger/model/log" 5 | "gorm.io/gorm" 6 | ) 7 | 8 | type log struct{} 9 | 10 | var Log = new(log) 11 | 12 | type _log interface { 13 | RecordAccountLog(provider logModel.AccountLogDataProvider, baseInfo logModel.BaseAccountLog, tx *gorm.DB) ( 14 | AccountLog logModel.AccountLogger, logMapping logModel.AccountLogMapping, err error, 15 | ) 16 | } 17 | 18 | func (logSvc *log) RecordAccountLog( 19 | provider logModel.AccountLogDataProvider, 20 | baseInfo logModel.BaseAccountLog, 21 | tx *gorm.DB, 22 | ) (AccountLog logModel.AccountLogger, logMapping logModel.AccountLogMapping, err error) { 23 | data := provider.GetLogDataModel() 24 | AccountLog, err = data.Record(baseInfo, tx) 25 | if err != nil { 26 | return 27 | } 28 | logMapping, err = AccountLog.RecordMapping(tx) 29 | return 30 | } 31 | -------------------------------------------------------------------------------- /test/service/user/base_test.go: -------------------------------------------------------------------------------- 1 | package transaction 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/ZiRunHua/LeapLedger/global/constant" 8 | "github.com/ZiRunHua/LeapLedger/global/db" 9 | "github.com/ZiRunHua/LeapLedger/global/nats" 10 | accountModel "github.com/ZiRunHua/LeapLedger/model/account" 11 | _ "github.com/ZiRunHua/LeapLedger/test/initialize" 12 | "github.com/google/uuid" 13 | ) 14 | import ( 15 | "testing" 16 | ) 17 | 18 | func TestTourCreate(t *testing.T) { 19 | t.Parallel() 20 | if !nats.PublishTask(nats.TaskCreateTourist) { 21 | t.Fail() 22 | } 23 | time.Sleep(time.Second * 10) 24 | user, err := service.EnableTourist(uuid.NewString(), constant.Android, context.TODO()) 25 | if err != nil { 26 | t.Fatal(err) 27 | } 28 | var account accountModel.Account 29 | err = db.Db.Where("user_id = ?", user.ID).First(&account).Error 30 | if err != nil { 31 | t.Fatal(err) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /router/v1/transactionImport.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "github.com/ZiRunHua/LeapLedger/router/group" 5 | "github.com/ZiRunHua/LeapLedger/router/websocket" 6 | ) 7 | 8 | func init() { 9 | // base path: /product 10 | router := group.Private.Group("product") 11 | // base path: /account/{accountId}/product 12 | accountRouter := group.Account.Group("product") 13 | editRouter := group.AccountCreator.Group("product") 14 | baseApi := apiApp.ProductApi 15 | { 16 | router.GET("/list", baseApi.GetList) 17 | router.GET("/:key/transCategory", baseApi.GetTransactionCategory) 18 | accountRouter.GET("/:key/transCategory/mapping/tree", baseApi.GetMappingTree) 19 | editRouter.POST("/transCategory/:id/mapping", baseApi.MappingTransactionCategory) 20 | editRouter.DELETE("/transCategory/:id/mapping", baseApi.DeleteTransactionCategoryMapping) 21 | editRouter.GET("/:key/bill/import", websocket.Use(baseApi.ImportProductBill)) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /util/data.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "errors" 5 | "strings" 6 | ) 7 | 8 | type dataFunc interface { 9 | utilDataFunc() 10 | CopyNotEmptyStringOptional(originData *string, targetData *string) error 11 | } 12 | 13 | type data struct{} 14 | 15 | var Data data 16 | 17 | func (d *data) utilDataFunc() {} 18 | 19 | var ( 20 | ErrDataIsEmpty = errors.New("数据不可为空") 21 | ) 22 | 23 | func (d *data) NotEmptyString(originData *string) error { 24 | if originData != nil { 25 | *originData = strings.TrimSpace(*originData) 26 | if *originData == "" { 27 | return ErrDataIsEmpty 28 | } 29 | } 30 | return ErrDataIsEmpty 31 | } 32 | 33 | func (d *data) CopyNotEmptyStringOptional(originData *string, targetData *string) error { 34 | if originData != nil { 35 | *originData = strings.TrimSpace(*originData) 36 | if *originData == "" { 37 | return ErrDataIsEmpty 38 | } 39 | *targetData = *originData 40 | } 41 | return nil 42 | } 43 | -------------------------------------------------------------------------------- /.github/workflows/common-push-image.yml: -------------------------------------------------------------------------------- 1 | name: Push Image 2 | 3 | on: 4 | workflow_call: 5 | inputs: 6 | tag: 7 | required: true 8 | type: string 9 | 10 | secrets: 11 | DOCKER_HUB_USERNAME: 12 | required: true 13 | DOCKER_HUB_ACCESS_TOKEN: 14 | required: true 15 | 16 | jobs: 17 | push-image: 18 | runs-on: ubuntu-latest 19 | steps: 20 | - name: Checkout code 21 | uses: actions/checkout@v4 22 | 23 | - name: Login to GitHub Container Registry 24 | uses: docker/login-action@v3 25 | with: 26 | username: ${{ secrets.DOCKER_HUB_USERNAME }} 27 | password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} 28 | 29 | - name: Build and push Docker image 30 | uses: docker/build-push-action@v6 31 | with: 32 | context: . 33 | file: docker/Dockerfile.build 34 | push: true 35 | tags: xiaozirun/leap-ledger:${{ inputs.tag }} -------------------------------------------------------------------------------- /router/router.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | _ "github.com/ZiRunHua/LeapLedger/docs" 5 | "github.com/ZiRunHua/LeapLedger/global" 6 | "github.com/ZiRunHua/LeapLedger/global/constant" 7 | routerEngine "github.com/ZiRunHua/LeapLedger/router/engine" 8 | "github.com/ZiRunHua/LeapLedger/router/group" 9 | _ "github.com/ZiRunHua/LeapLedger/router/v1" 10 | "github.com/gin-gonic/gin" 11 | "github.com/swaggo/files" 12 | "github.com/swaggo/gin-swagger" 13 | "net/http" 14 | ) 15 | 16 | var Engine = routerEngine.Engine 17 | 18 | func init() { 19 | // health 20 | group.Public.GET( 21 | "/health", func(c *gin.Context) { 22 | c.JSON(http.StatusOK, "ok") 23 | }, 24 | ) 25 | if global.Config.Mode == constant.Debug { 26 | group.Public.GET( 27 | "/swagger/*any", ginSwagger.WrapHandler( 28 | swaggerFiles.Handler, func(config *ginSwagger.Config) { 29 | config.DocExpansion = "none" 30 | config.DeepLinking = true 31 | }, 32 | ), 33 | ) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /model/category/categoryFatherModel.go: -------------------------------------------------------------------------------- 1 | package categoryModel 2 | 3 | import ( 4 | "github.com/ZiRunHua/LeapLedger/global" 5 | "github.com/ZiRunHua/LeapLedger/global/constant" 6 | commonModel "github.com/ZiRunHua/LeapLedger/model/common" 7 | "time" 8 | ) 9 | 10 | type Father struct { 11 | ID uint `gorm:"primary_key"` 12 | AccountId uint `gorm:"index;comment:'账本ID'"` 13 | IncomeExpense constant.IncomeExpense `gorm:"comment:'收支类型'"` 14 | Name string `gorm:"size:128;comment:'名称'"` 15 | Previous uint `gorm:"comment:'前一位'"` 16 | OrderUpdatedAt time.Time `gorm:"type:TIMESTAMP"` 17 | CreatedAt time.Time `gorm:"type:TIMESTAMP"` 18 | commonModel.BaseModel 19 | } 20 | 21 | func (f *Father) TableName() string { 22 | return "category_father" 23 | } 24 | 25 | func (f *Father) SelectById(id uint) error { 26 | return global.GvaDb.First(f, id).Error 27 | } 28 | -------------------------------------------------------------------------------- /model/product/productModel.go: -------------------------------------------------------------------------------- 1 | package productModel 2 | 3 | import ( 4 | "github.com/ZiRunHua/LeapLedger/global" 5 | commonModel "github.com/ZiRunHua/LeapLedger/model/common" 6 | ) 7 | 8 | type Product struct { 9 | Key Key `gorm:"primary_key"` 10 | Name string `gorm:"comment:'名称'"` 11 | Hide uint8 `gorm:"default:0;comment:'隐藏标识'"` 12 | Weight int `gorm:"default:0;comment:'权重'"` 13 | commonModel.BaseModel 14 | } 15 | 16 | type Key string 17 | 18 | const AliPay, WeChatPay Key = "AliPay", "WeChatPay" 19 | 20 | func (p *Product) TableName() string { 21 | return "product" 22 | } 23 | 24 | func (p *Product) IsEmpty() bool { 25 | return p == nil || p.Key == "" 26 | } 27 | 28 | func (p *Product) SelectByKey(key Key) (result Product, err error) { 29 | err = global.GvaDb.Where("key = ?", key).First(&result).Error 30 | return 31 | } 32 | 33 | func (p *Product) GetBill() (*Bill, error) { 34 | bill := &Bill{} 35 | return bill.SelectByPrimaryKey(string(p.Key)) 36 | } 37 | -------------------------------------------------------------------------------- /global/nats/enter.go: -------------------------------------------------------------------------------- 1 | package nats 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "github.com/ZiRunHua/LeapLedger/global/db" 7 | "github.com/ZiRunHua/LeapLedger/global/nats/manager" 8 | ) 9 | 10 | type PayloadType interface{} 11 | 12 | type handler[Data any] func(Data, context.Context) error 13 | 14 | var ( 15 | taskManage = manager.TaskManage 16 | eventManage = manager.EventManage 17 | dlqManage = manager.DlqManage 18 | ) 19 | 20 | func init() { 21 | err := db.InitDb.AutoMigrate(&outbox{}) 22 | if err != nil { 23 | panic(err) 24 | } 25 | SubscribeTaskWithPayload( 26 | TaskOutbox, outboxService.getHandleTransaction(outboxTypeTask), 27 | ) 28 | SubscribeEvent( 29 | EventOutbox, "outbox", outboxService.getHandleTransaction(outboxTypeEvent), 30 | ) 31 | } 32 | 33 | func fromJson[T PayloadType](jsonStr []byte, data *T) error { 34 | if len(jsonStr) != 0 { 35 | if err := json.Unmarshal(jsonStr, &data); err != nil { 36 | return err 37 | } 38 | } 39 | return nil 40 | } 41 | -------------------------------------------------------------------------------- /router/engine/engin.go: -------------------------------------------------------------------------------- 1 | package engine 2 | 3 | import ( 4 | "fmt" 5 | "github.com/ZiRunHua/LeapLedger/global" 6 | "github.com/ZiRunHua/LeapLedger/global/constant" 7 | "github.com/ZiRunHua/LeapLedger/router/middleware" 8 | "github.com/gin-gonic/gin" 9 | "time" 10 | ) 11 | 12 | var Engine *gin.Engine 13 | 14 | func init() { 15 | Engine = gin.New() 16 | Engine.Use( 17 | gin.LoggerWithConfig( 18 | gin.LoggerConfig{ 19 | Formatter: func(params gin.LogFormatterParams) string { 20 | return fmt.Sprintf( 21 | "[GIN] %s | %s | %s | %d | %s | %s | %s\n", 22 | params.TimeStamp.Format(time.RFC3339), 23 | params.Method, 24 | params.Path, 25 | params.StatusCode, 26 | params.Latency, 27 | params.ClientIP, 28 | params.ErrorMessage, 29 | ) 30 | }, 31 | }, 32 | ), 33 | gin.CustomRecovery(middleware.Recovery), 34 | ) 35 | if global.Config.Mode == constant.Debug { 36 | Engine.Use(middleware.RequestLogger(global.RequestLogger)) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /service/thirdparty/task.go: -------------------------------------------------------------------------------- 1 | package thirdpartyService 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | "github.com/ZiRunHua/LeapLedger/global" 8 | "github.com/ZiRunHua/LeapLedger/global/nats" 9 | userModel "github.com/ZiRunHua/LeapLedger/model/user" 10 | ) 11 | 12 | func init() { 13 | nats.SubscribeTaskWithPayload( 14 | nats.TaskSendCaptchaEmail, func(t nats.PayloadSendCaptchaEmail, ctx context.Context) error { 15 | err := GroupApp.sendCaptchaEmail(t.Email, t.Action) 16 | if errors.Is(err, global.ErrServiceClosed) { 17 | return nil 18 | } 19 | return err 20 | }, 21 | ) 22 | nats.SubscribeTaskWithPayload( 23 | nats.TaskSendNotificationEmail, func(t nats.PayloadSendNotificationEmail, ctx context.Context) error { 24 | user, err := userModel.NewDao().SelectById(t.UserId) 25 | if err != nil { 26 | return err 27 | } 28 | err = GroupApp.sendNotificationEmail(user, t.Notification) 29 | if errors.Is(err, global.ErrServiceClosed) { 30 | return nil 31 | } 32 | return err 33 | }, 34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /service/template/rankService.go: -------------------------------------------------------------------------------- 1 | package templateService 2 | 3 | import ( 4 | "context" 5 | accountModel "github.com/ZiRunHua/LeapLedger/model/account" 6 | tmplRank "github.com/ZiRunHua/LeapLedger/service/template/rank" 7 | "strconv" 8 | "time" 9 | ) 10 | 11 | var rank tmplRank.Rank[rankMember] 12 | 13 | func initRank() { 14 | list, err := TemplateApp.GetList() 15 | if err != nil { 16 | panic(err) 17 | } 18 | members := make([]rankMember, len(list), len(list)) 19 | for i, account := range list { 20 | members[i] = newRankMember(account) 21 | } 22 | rank = tmplRank.NewRank[rankMember]("tmplAccount", members, time.Hour*24) 23 | err = rank.Init(context.TODO()) 24 | if err != nil { 25 | panic(err) 26 | } 27 | } 28 | 29 | type rankMember struct { 30 | tmplRank.Member 31 | key string 32 | id uint 33 | } 34 | 35 | func newRankMember(account accountModel.Account) rankMember { 36 | return rankMember{id: account.ID, key: strconv.Itoa(int(account.ID))} 37 | } 38 | 39 | func (rm rankMember) String() string { 40 | return rm.key 41 | } 42 | -------------------------------------------------------------------------------- /util/string.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "bytes" 5 | "regexp" 6 | ) 7 | 8 | type str struct{} 9 | 10 | var Str str 11 | 12 | func (s *str) MaskEmail(email string) string { 13 | re := regexp.MustCompile(`([^@]+)@(.+)`) 14 | matches := re.FindStringSubmatch(email) 15 | 16 | if len(matches) == 3 { 17 | username := matches[1] 18 | domain := matches[2] 19 | 20 | maskedUsername := s.MaskString(username, 3) 21 | 22 | maskedEmail := maskedUsername + "@" + domain 23 | return maskedEmail 24 | } 25 | 26 | return email 27 | } 28 | 29 | func (s *str) MaskString(input string, visibleChars int) string { 30 | if len(input) <= visibleChars { 31 | return input 32 | } 33 | 34 | maskedChars := len(input) - visibleChars 35 | maskedString := input[:visibleChars] + s.RepeatChar('*', maskedChars) 36 | return maskedString 37 | } 38 | 39 | func (s *str) RepeatChar(char byte, count int) string { 40 | return s.StringOf(char, count) 41 | } 42 | 43 | func (s *str) StringOf(char byte, count int) string { 44 | return string(bytes.Repeat([]byte{char}, count)) 45 | } 46 | -------------------------------------------------------------------------------- /service/enter.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | accountService "github.com/ZiRunHua/LeapLedger/service/account" 5 | categoryService "github.com/ZiRunHua/LeapLedger/service/category" 6 | commonService "github.com/ZiRunHua/LeapLedger/service/common" 7 | productService "github.com/ZiRunHua/LeapLedger/service/product" 8 | templateService "github.com/ZiRunHua/LeapLedger/service/template" 9 | thirdpartyService "github.com/ZiRunHua/LeapLedger/service/thirdparty" 10 | transactionService "github.com/ZiRunHua/LeapLedger/service/transaction" 11 | userService "github.com/ZiRunHua/LeapLedger/service/user" 12 | ) 13 | 14 | var GroupApp = new(Group) 15 | 16 | type Group struct { 17 | CommonServiceGroup commonService.Group 18 | CategoryServiceGroup categoryService.Group 19 | AccountServiceGroup accountService.Group 20 | TransactionServiceGroup transactionService.Group 21 | UserServiceGroup userService.Group 22 | ProductServiceGroup productService.Group 23 | TemplateServiceGroup templateService.Group 24 | ThirdpartyServiceGroup thirdpartyService.Group 25 | } 26 | -------------------------------------------------------------------------------- /script/user/create/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | userModel "github.com/ZiRunHua/LeapLedger/model/user" 8 | userService "github.com/ZiRunHua/LeapLedger/service/user" 9 | "github.com/ZiRunHua/LeapLedger/util" 10 | ) 11 | 12 | func main() { 13 | email := GetInput("email:") 14 | password := GetInput("password:") 15 | username := GetInput("username:") 16 | create(email, password, username) 17 | } 18 | func create(email, password, username string) { 19 | addData := userModel.AddData{ 20 | Email: email, 21 | Password: util.ClientPasswordHash(email, password), 22 | Username: username, 23 | } 24 | user, err := userService.GroupApp.Register( 25 | addData, context.Background(), 26 | *userService.GroupApp.NewRegisterOption().WithSendEmail(false), 27 | ) 28 | if err != nil { 29 | panic(err) 30 | } 31 | fmt.Println("create success:", user.Email, user.Username, password) 32 | } 33 | 34 | func GetInput(tip string) (userInput string) { 35 | fmt.Println(tip) 36 | _, err := fmt.Scanln(&userInput) 37 | if err != nil { 38 | return "" 39 | } 40 | return 41 | } 42 | -------------------------------------------------------------------------------- /model/product/enter.go: -------------------------------------------------------------------------------- 1 | package productModel 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "path/filepath" 7 | 8 | "github.com/ZiRunHua/LeapLedger/global/constant" 9 | "github.com/ZiRunHua/LeapLedger/global/cus" 10 | "github.com/ZiRunHua/LeapLedger/global/db" 11 | "github.com/ZiRunHua/LeapLedger/util/fileTool" 12 | "gorm.io/gorm" 13 | "gorm.io/gorm/logger" 14 | ) 15 | 16 | var initSqlFile = filepath.Clean(constant.DataPath + "/database/product.sql") 17 | 18 | func init() { 19 | // table 20 | tables := []interface{}{ 21 | Product{}, BillHeader{}, Bill{}, 22 | TransactionCategory{}, TransactionCategoryMapping{}, 23 | } 24 | err := db.InitDb.AutoMigrate(tables...) 25 | if err != nil { 26 | panic(err) 27 | } 28 | // table data 29 | sqlFile, err := os.Open(initSqlFile) 30 | if err != nil { 31 | panic(err) 32 | } 33 | err = db.Transaction( 34 | context.Background(), func(ctx *cus.TxContext) error { 35 | tx := ctx.GetDb() 36 | tx = tx.Session(&gorm.Session{Logger: tx.Logger.LogMode(logger.Silent)}) 37 | return fileTool.ExecSqlFile(sqlFile, tx) 38 | }, 39 | ) 40 | if err != nil { 41 | panic(err) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /util/jwtTool/jwt.go: -------------------------------------------------------------------------------- 1 | package jwtTool 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "github.com/golang-jwt/jwt/v5" 7 | ) 8 | 9 | var ( 10 | TokenExpired error = errors.New("Token is expired") 11 | TokenNotValidYet error = errors.New("Token not active yet") 12 | TokenMalformed error = errors.New("That's not even a token") 13 | TokenInvalid error = errors.New("Couldn't handle this token:") 14 | SignKey string = "test" 15 | ) 16 | 17 | func CreateToken(claims jwt.RegisteredClaims, key []byte) (string, error) { 18 | return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(key) 19 | } 20 | 21 | func ParseToken(tokenStr string, key []byte) (claims jwt.RegisteredClaims, err error) { 22 | token, err := jwt.ParseWithClaims( 23 | tokenStr, &claims, func(token *jwt.Token) (interface{}, error) { 24 | if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { 25 | return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) 26 | } 27 | return key, nil 28 | }, 29 | ) 30 | if err != nil { 31 | return 32 | } 33 | if !token.Valid { 34 | err = errors.New("parse token fail") 35 | } 36 | return 37 | } 38 | -------------------------------------------------------------------------------- /router/v1/category.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "github.com/ZiRunHua/LeapLedger/router/group" 5 | ) 6 | 7 | func init() { 8 | // base path: /account/{accountId}/category 9 | readRouter := group.Account.Group("category") 10 | editRouter := group.AccountCreator.Group("category") 11 | editMappingRouter := group.AccountOwnEditor.Group("category") 12 | baseApi := apiApp.CategoryApi 13 | { 14 | editRouter.POST("", baseApi.CreateOne) 15 | editRouter.PUT("/:id/move", baseApi.MoveCategory) 16 | editRouter.PUT("/:id", baseApi.Update) 17 | editRouter.DELETE("/:id", baseApi.Delete) 18 | readRouter.GET("/tree", baseApi.GetTree) 19 | readRouter.GET("/list", baseApi.GetList) 20 | } 21 | { 22 | editRouter.POST("/father", baseApi.CreateOneFather) 23 | editRouter.PUT("/father/:id/move", baseApi.MoveFather) 24 | editRouter.PUT("/father/:id", baseApi.UpdateFather) 25 | editRouter.DELETE("/father/:id", baseApi.DeleteFather) 26 | } 27 | { 28 | editMappingRouter.POST("/:id/mapping", baseApi.MappingCategory) 29 | editMappingRouter.DELETE("/:id/mapping", baseApi.DeleteCategoryMapping) 30 | editMappingRouter.GET("/mapping/tree", baseApi.GetMappingTree) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /router/websocket/websocket.go: -------------------------------------------------------------------------------- 1 | package websocket 2 | 3 | import ( 4 | "github.com/ZiRunHua/LeapLedger/global" 5 | "go.uber.org/zap" 6 | "net" 7 | "time" 8 | 9 | "github.com/gin-gonic/gin" 10 | "github.com/gorilla/websocket" 11 | ) 12 | 13 | var upgrader = websocket.Upgrader{ 14 | ReadBufferSize: 1024, 15 | WriteBufferSize: 1024, 16 | } 17 | 18 | func Use(handler func(conn *websocket.Conn, ctx *gin.Context) error) gin.HandlerFunc { 19 | return func(ctx *gin.Context) { 20 | conn, err := upgrader.Upgrade(ctx.Writer, ctx.Request, nil) 21 | if err != nil { 22 | panic(err) 23 | } 24 | conn.SetPingHandler( 25 | func(message string) error { 26 | err := conn.WriteControl(websocket.PongMessage, []byte(message), time.Now().Add(1)) 27 | if err == websocket.ErrCloseSent { 28 | return nil 29 | } else if e, ok := err.(net.Error); ok && e.Temporary() { 30 | return nil 31 | } 32 | return err 33 | }, 34 | ) 35 | conn.SetPongHandler(nil) 36 | conn.SetCloseHandler(nil) 37 | defer conn.Close() 38 | err = handler(conn, ctx) 39 | if err != nil { 40 | global.ErrorLogger.Error("websocket err", zap.Error(err)) 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /initialize/nats.go: -------------------------------------------------------------------------------- 1 | package initialize 2 | 3 | import ( 4 | "github.com/ZiRunHua/LeapLedger/global/constant" 5 | "github.com/nats-io/nats.go" 6 | ) 7 | 8 | type _nats struct { 9 | ServerUrl string `yaml:"ServerUrl"` 10 | Subjects []constant.Subject `yaml:"Subjects"` 11 | subjectMap map[constant.Subject]struct{} 12 | IsConsumerServer bool 13 | } 14 | 15 | // NatsDb is used to record and retry failure messages 16 | // Enabled in consumer server 17 | 18 | func (n *_nats) do() error { 19 | err := n.init() 20 | if err != nil { 21 | return err 22 | } 23 | Nats, err = nats.Connect(n.ServerUrl) 24 | if err != nil { 25 | return err 26 | } 27 | return err 28 | } 29 | 30 | func (n *_nats) init() error { 31 | n.subjectMap = make(map[constant.Subject]struct{}) 32 | for _, subject := range n.Subjects { 33 | n.subjectMap[subject] = struct{}{} 34 | } 35 | n.IsConsumerServer = len(n.subjectMap) > 0 36 | return nil 37 | } 38 | 39 | const allTask constant.Subject = "all" 40 | 41 | func (n *_nats) CanSubscribe(subj constant.Subject) bool { 42 | if _, ok := n.subjectMap[allTask]; ok { 43 | return true 44 | } 45 | _, ok := n.subjectMap[subj] 46 | return ok 47 | } 48 | -------------------------------------------------------------------------------- /api/request/category.go: -------------------------------------------------------------------------------- 1 | package request 2 | 3 | import ( 4 | "github.com/ZiRunHua/LeapLedger/global/constant" 5 | ) 6 | 7 | type CategoryOne struct { 8 | Id uint 9 | Name string 10 | Icon string 11 | FatherId uint 12 | IncomeExpense IncomeExpense 13 | } 14 | 15 | type CategoryCreateOne struct { 16 | Name string `binding:"required"` 17 | Icon string `binding:"required"` 18 | FatherId uint `binding:"required"` 19 | } 20 | 21 | type CategoryUpdateOne struct { 22 | Name *string 23 | Icon *string 24 | } 25 | 26 | type CategoryCreateOneFather struct { 27 | Name string 28 | IncomeExpense constant.IncomeExpense 29 | } 30 | 31 | type CategoryMove struct { 32 | Previous *uint 33 | FatherId *uint 34 | } 35 | 36 | type CategoryMoveFather struct { 37 | Previous *uint 38 | } 39 | 40 | type CategoryGetTree struct { 41 | IncomeExpense *constant.IncomeExpense 42 | } 43 | 44 | type CategoryGetList struct { 45 | IncomeExpense *constant.IncomeExpense `binding:"omitempty"` 46 | } 47 | 48 | type CategoryMapping struct { 49 | ChildCategoryId uint 50 | } 51 | 52 | type CategoryGetMappingTree struct { 53 | MappingAccountId uint `binding:"required"` 54 | } 55 | -------------------------------------------------------------------------------- /model/common/query/func.go: -------------------------------------------------------------------------------- 1 | package query 2 | 3 | import ( 4 | "github.com/ZiRunHua/LeapLedger/global" 5 | commonModel "github.com/ZiRunHua/LeapLedger/model/common" 6 | 7 | "github.com/pkg/errors" 8 | "gorm.io/gorm" 9 | ) 10 | 11 | func FirstByPrimaryKey[T commonModel.Model](id interface{}) (T, error) { 12 | var result T 13 | err := global.GvaDb.First(&result, id).Error 14 | return result, err 15 | } 16 | 17 | func FirstByField[T commonModel.Model](field string, value interface{}) (T, error) { 18 | var result T 19 | err := global.GvaDb.Where(map[string]interface{}{field: value}).First(&result).Error 20 | return result, err 21 | } 22 | 23 | func Exist[T commonModel.Model](query interface{}, args ...interface{}) (bool, error) { 24 | var result T 25 | err := global.GvaDb.Where(query, args...).First(&result).Error 26 | if errors.Is(err, gorm.ErrRecordNotFound) { 27 | return false, nil 28 | } else if err == nil { 29 | return true, nil 30 | } else { 31 | return false, errors.Wrap(err, "exist") 32 | } 33 | } 34 | 35 | func GetAmountCountStatistic(query *gorm.DB) (result global.AmountCount, err error) { 36 | err = query.Select("COUNT(*) as Count,SUM(amount) as Amount").Scan(&result).Error 37 | return 38 | } 39 | -------------------------------------------------------------------------------- /router/v1/transaction.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "github.com/ZiRunHua/LeapLedger/router/group" 5 | ) 6 | 7 | func init() { 8 | // base path: /account/{accountId}/transaction 9 | readRouter := group.AccountReader.Group("transaction") 10 | editRouter := group.AccountOwnEditor.Group("transaction") 11 | baseApi := apiApp.TransactionApi 12 | { 13 | readRouter.GET("/:id", baseApi.GetOne) 14 | editRouter.POST("", baseApi.CreateOne) 15 | editRouter.PUT("/:id", baseApi.Update) 16 | editRouter.DELETE("/:id", baseApi.Delete) 17 | 18 | readRouter.GET("/list", baseApi.GetList) 19 | readRouter.GET("/total", baseApi.GetTotal) 20 | readRouter.GET("/month/statistic", baseApi.GetMonthStatistic) 21 | readRouter.GET("/day/statistic", baseApi.GetDayStatistic) 22 | readRouter.GET("/category/amount/rank", baseApi.GetCategoryAmountRank) 23 | readRouter.GET("/amount/rank", baseApi.GetAmountRank) 24 | // timing 25 | editRouter.GET("/timing/list", baseApi.GetTimingList) 26 | editRouter.POST("/timing", baseApi.CreateTiming) 27 | editRouter.PUT("/timing/:id", baseApi.UpdateTiming) 28 | editRouter.DELETE("/timing/:id", baseApi.DeleteTiming) 29 | editRouter.PUT("/timing/:id/:operate", baseApi.HandleTiming) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /test/getModel.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | categoryModel "github.com/ZiRunHua/LeapLedger/model/category" 5 | transactionModel "github.com/ZiRunHua/LeapLedger/model/transaction" 6 | "github.com/ZiRunHua/LeapLedger/util/rand" 7 | "time" 8 | ) 9 | 10 | func getCategory() categoryModel.Category { 11 | return ExpenseCategoryList[0] 12 | } 13 | func NewTransaction() transactionModel.Transaction { 14 | return transactionModel.Transaction{ 15 | Info: NewTransInfo(), 16 | } 17 | } 18 | 19 | func NewTransTime() transactionModel.Timing { 20 | transInfo := NewTransInfo() 21 | return transactionModel.Timing{ 22 | AccountId: transInfo.AccountId, 23 | UserId: transInfo.UserId, 24 | TransInfo: transInfo, 25 | Type: transactionModel.EveryDay, 26 | OffsetDays: 1, 27 | NextTime: transInfo.TradeTime, 28 | Close: false, 29 | } 30 | } 31 | 32 | func NewTransInfo() transactionModel.Info { 33 | category := getCategory() 34 | return transactionModel.Info{ 35 | UserId: User.ID, 36 | AccountId: Account.ID, 37 | CategoryId: category.ID, 38 | IncomeExpense: category.IncomeExpense, 39 | Amount: rand.Int(1000), 40 | Remark: "test", 41 | TradeTime: time.Now(), 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /service/transaction/event.go: -------------------------------------------------------------------------------- 1 | package transactionService 2 | 3 | import ( 4 | "context" 5 | "github.com/ZiRunHua/LeapLedger/global/nats" 6 | transactionModel "github.com/ZiRunHua/LeapLedger/model/transaction" 7 | ) 8 | 9 | func init() { 10 | // statistic update 11 | nats.BindTaskToEventAndMakePayload( 12 | nats.EventTransactionCreate, nats.TaskStatisticUpdate, 13 | func(eventData transactionModel.Transaction) (transactionModel.StatisticData, error) { 14 | return eventData.GetStatisticData(true), nil 15 | }, 16 | ) 17 | 18 | nats.SubscribeEvent( 19 | nats.EventTransactionUpdate, "update_statistic_after_transaction_update", 20 | func(eventData nats.EventTransactionUpdatePayload, ctx context.Context) error { 21 | err := GroupApp.Transaction.updateStatistic(eventData.OldTrans.GetStatisticData(false), ctx) 22 | if err != nil { 23 | return err 24 | } 25 | return GroupApp.Transaction.updateStatistic(eventData.NewTrans.GetStatisticData(true), ctx) 26 | }, 27 | ) 28 | 29 | nats.BindTaskToEventAndMakePayload( 30 | nats.EventTransactionDelete, nats.TaskStatisticUpdate, 31 | func(eventData transactionModel.Transaction) (transactionModel.StatisticData, error) { 32 | return eventData.GetStatisticData(false), nil 33 | }, 34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /global/db/db.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "context" 5 | "github.com/ZiRunHua/LeapLedger/global/cus" 6 | "github.com/ZiRunHua/LeapLedger/initialize" 7 | "gorm.io/gorm" 8 | "gorm.io/gorm/logger" 9 | ) 10 | 11 | var ( 12 | Db = initialize.Db 13 | InitDb = Db.Session(&gorm.Session{Logger: Db.Logger.LogMode(logger.Silent)}) 14 | Context *cus.DbContext 15 | ) 16 | 17 | func init() { 18 | Context = cus.WithDb(context.Background(), Db) 19 | } 20 | 21 | func Get(ctx context.Context) *gorm.DB { 22 | value := ctx.Value(cus.Db) 23 | if value == nil { 24 | return Db 25 | } 26 | return ctx.Value(cus.Db).(*gorm.DB) 27 | } 28 | 29 | type TxFunc func(ctx *cus.TxContext) error 30 | 31 | func Transaction(parent context.Context, fc TxFunc) error { 32 | ctx := cus.WithTxCommitContext(parent) 33 | err := Get(ctx).Transaction( 34 | func(tx *gorm.DB) error { 35 | return fc(cus.WithTx(ctx, tx)) 36 | }, 37 | ) 38 | if err == nil { 39 | ctx.ExecCallback() 40 | } 41 | return err 42 | } 43 | 44 | // AddCommitCallback 45 | // Transaction needs to be called before using this method 46 | func AddCommitCallback(parent context.Context, callbacks ...cus.TxCommitCallback) error { 47 | return parent.Value(cus.TxCommit).(*cus.TxCommitContext).AddCallback(callbacks...) 48 | } 49 | -------------------------------------------------------------------------------- /service/thirdparty/email/enter.go: -------------------------------------------------------------------------------- 1 | package email 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/ZiRunHua/LeapLedger/global/cron" 7 | ) 8 | 9 | type emailService interface { 10 | init() 11 | Send(emails []string, subject string, contest string) error 12 | getToken() error 13 | } 14 | 15 | // 初始化 16 | var Service emailService 17 | var ServiceStatus bool = false 18 | 19 | func init() { 20 | Service = &WeCom{} 21 | Service.init() 22 | } 23 | 24 | // token过期 25 | var tokenExpiredError = &_tokenExpiredError{} 26 | 27 | type _tokenExpiredError struct { 28 | } 29 | 30 | func (e *_tokenExpiredError) Error() string { 31 | return "邮箱服务Token已过期" 32 | } 33 | 34 | // 第三方响应错误 35 | type thirdPartyResponseError struct { 36 | StatusCode int // 第三方响应的HTTP状态码 37 | ErrorCode int // 第三方响应的错误码 38 | Message string // 错误消息 39 | } 40 | 41 | func (e *thirdPartyResponseError) Error() string { 42 | return fmt.Sprintf("第三方响应错误,状态码:%d,错误码:%d,消息:%s", e.StatusCode, e.ErrorCode, e.Message) 43 | } 44 | 45 | func init() { 46 | _, err := cron.Scheduler.Every(30).Minute().Do( 47 | cron.MakeJobFunc( 48 | func() error { 49 | if false == ServiceStatus { 50 | return nil 51 | } 52 | return Service.getToken() 53 | }, 54 | ), 55 | ) 56 | if err != nil { 57 | panic(err) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /test/util/enter.go: -------------------------------------------------------------------------------- 1 | package tUtil 2 | 3 | import ( 4 | "github.com/ZiRunHua/LeapLedger/global/constant" 5 | "github.com/ZiRunHua/LeapLedger/global/db" 6 | accountModel "github.com/ZiRunHua/LeapLedger/model/account" 7 | categoryModel "github.com/ZiRunHua/LeapLedger/model/category" 8 | userModel "github.com/ZiRunHua/LeapLedger/model/user" 9 | _service "github.com/ZiRunHua/LeapLedger/service" 10 | "github.com/ZiRunHua/LeapLedger/test/initialize" 11 | ) 12 | 13 | var ( 14 | userService = _service.GroupApp.UserServiceGroup 15 | templateService = _service.GroupApp.TemplateServiceGroup 16 | 17 | testUser userModel.User 18 | testAccount accountModel.Account 19 | testCategoryList []categoryModel.Category 20 | testInfo = initialize.Info 21 | ) 22 | 23 | func init() { 24 | var err error 25 | testUser, err = userModel.NewDao().SelectById(testInfo.UserId) 26 | if err != nil { 27 | panic(err) 28 | } 29 | userInfo, err := testUser.GetUserClient(constant.Web, db.Db) 30 | if err != nil { 31 | panic(err) 32 | } 33 | testAccount, err = accountModel.NewDao().SelectById(userInfo.CurrentAccountId) 34 | if err != nil { 35 | panic(err) 36 | } 37 | testCategoryList, err = categoryModel.NewDao().GetListByAccount(testAccount, nil) 38 | if err != nil { 39 | panic(err) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /test/initialize/initialize.go: -------------------------------------------------------------------------------- 1 | package initialize 2 | 3 | import ( 4 | "github.com/ZiRunHua/LeapLedger/global/constant" 5 | "github.com/ZiRunHua/LeapLedger/global/db" 6 | _ "github.com/ZiRunHua/LeapLedger/global/nats" 7 | "github.com/ZiRunHua/LeapLedger/global/nats/manager" 8 | _ "github.com/ZiRunHua/LeapLedger/initialize/database" 9 | "github.com/ZiRunHua/LeapLedger/test/info" 10 | ) 11 | import ( 12 | accountModel "github.com/ZiRunHua/LeapLedger/model/account" 13 | categoryModel "github.com/ZiRunHua/LeapLedger/model/category" 14 | userModel "github.com/ZiRunHua/LeapLedger/model/user" 15 | ) 16 | 17 | var ( 18 | User userModel.User 19 | Account accountModel.Account 20 | ExpenseCategoryList []categoryModel.Category 21 | ) 22 | 23 | var ( 24 | Info = info.Data 25 | ) 26 | 27 | func init() { 28 | manager.UpdateTestBackOff() 29 | var err error 30 | User, err = userModel.NewDao().SelectById(Info.UserId) 31 | if err != nil { 32 | panic(err) 33 | } 34 | userInfo, err := User.GetUserClient(constant.Web, db.Db) 35 | if err != nil { 36 | panic(err) 37 | } 38 | Account, err = accountModel.NewDao().SelectById(userInfo.CurrentAccountId) 39 | if err != nil { 40 | panic(err) 41 | } 42 | ie := constant.Expense 43 | ExpenseCategoryList, err = categoryModel.NewDao().GetListByAccount(Account, &ie) 44 | if err != nil { 45 | panic(err) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /global/lock/redis.go: -------------------------------------------------------------------------------- 1 | package lock 2 | 3 | import ( 4 | "context" 5 | "github.com/ZiRunHua/LeapLedger/initialize" 6 | "github.com/go-redis/redis/v8" 7 | "github.com/google/uuid" 8 | "time" 9 | ) 10 | 11 | var ( 12 | rdb = initialize.LockRdb 13 | ) 14 | 15 | type RedisLock struct { 16 | client *redis.Client 17 | key string 18 | value string 19 | expiration time.Duration 20 | } 21 | 22 | func newRedisLock(client *redis.Client, key string, expiration time.Duration) *RedisLock { 23 | return &RedisLock{ 24 | client: client, 25 | key: key, 26 | value: uuid.New().String(), 27 | expiration: expiration, 28 | } 29 | } 30 | 31 | func (rl *RedisLock) Lock(ctx context.Context) error { 32 | success, err := rl.client.SetNX(ctx, rl.key, rl.value, rl.expiration).Result() 33 | if err != nil { 34 | return err 35 | } 36 | if false == success { 37 | return ErrLockOccupied 38 | } 39 | return nil 40 | } 41 | 42 | func (rl *RedisLock) Release(ctx context.Context) error { 43 | script := ` 44 | if redis.call("GET", KEYS[1]) == ARGV[1] then 45 | return redis.call("DEL", KEYS[1]) 46 | else 47 | return 0 48 | end 49 | ` 50 | result, err := rl.client.Eval(ctx, script, []string{rl.key}, rl.value).Int() 51 | if err != nil { 52 | return err 53 | } else if result == 0 { 54 | return ErrLockNotExist 55 | } 56 | return nil 57 | } 58 | -------------------------------------------------------------------------------- /api/v1/enter.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "github.com/ZiRunHua/LeapLedger/api/response" 5 | apiUtil "github.com/ZiRunHua/LeapLedger/api/util" 6 | "github.com/ZiRunHua/LeapLedger/service" 7 | "github.com/gin-gonic/gin" 8 | ) 9 | 10 | type PublicApi struct { 11 | } 12 | 13 | type ApiGroup struct { 14 | AccountApi 15 | CategoryApi 16 | UserApi 17 | TransactionApi 18 | PublicApi 19 | ProductApi 20 | } 21 | 22 | var ( 23 | ApiGroupApp = new(ApiGroup) 24 | ) 25 | 26 | // 服务 27 | var ( 28 | commonService = service.GroupApp.CommonServiceGroup 29 | ) 30 | var ( 31 | userService = service.GroupApp.UserServiceGroup 32 | accountService = service.GroupApp.AccountServiceGroup 33 | categoryService = service.GroupApp.CategoryServiceGroup 34 | transactionService = service.GroupApp.TransactionServiceGroup 35 | productService = service.GroupApp.ProductServiceGroup 36 | templateService = service.GroupApp.TemplateServiceGroup 37 | ) 38 | 39 | // 工具 40 | var contextFunc = apiUtil.ContextFunc 41 | var checkFunc = apiUtil.CheckFunc 42 | 43 | func handelError(err error, ctx *gin.Context) bool { 44 | if err != nil { 45 | response.FailToError(ctx, err) 46 | return true 47 | } 48 | return false 49 | } 50 | 51 | func responseError(err error, ctx *gin.Context) bool { 52 | if err != nil { 53 | response.FailToError(ctx, err) 54 | return true 55 | } 56 | return false 57 | } 58 | -------------------------------------------------------------------------------- /model/log/enter.go: -------------------------------------------------------------------------------- 1 | package logModel 2 | 3 | import ( 4 | "github.com/ZiRunHua/LeapLedger/global/constant" 5 | "github.com/ZiRunHua/LeapLedger/global/db" 6 | "gorm.io/gorm" 7 | ) 8 | 9 | func init() { 10 | tables := []interface{}{ 11 | AccountMappingLog{}, AccountLogMapping{}, 12 | } 13 | err := db.InitDb.AutoMigrate(tables...) 14 | if err != nil { 15 | panic(err) 16 | } 17 | } 18 | 19 | /*账本*/ 20 | type AccountLog[T AccountLogDataRecordable] struct { 21 | BaseAccountLog `gorm:"embedded"` 22 | Data T `gorm:"type:json"` 23 | } 24 | 25 | type BaseAccountLog struct { 26 | ID uint `gorm:"primarykey"` 27 | UserId uint `gorm:"index:idx_user_id;not null"` 28 | AccountId uint `gorm:"index:idx_account_id;not null"` 29 | Operation constant.LogOperation `gorm:"not null"` 30 | } 31 | 32 | func (b *BaseAccountLog) GetId() uint { 33 | return b.ID 34 | } 35 | func (b *BaseAccountLog) GetAccountId() uint { 36 | return b.AccountId 37 | } 38 | 39 | type AccountLogger interface { 40 | TableName() string 41 | GetId() uint 42 | GetAccountId() uint 43 | RecordMapping(tx *gorm.DB) (AccountLogMapping, error) 44 | } 45 | 46 | type AccountLogDataRecordable interface { 47 | Record(baseLog BaseAccountLog, tx *gorm.DB) (AccountLogger, error) 48 | } 49 | 50 | type AccountLogDataProvider interface { 51 | GetLogDataModel() AccountLogDataRecordable 52 | } 53 | -------------------------------------------------------------------------------- /model/user/userLogModel.go: -------------------------------------------------------------------------------- 1 | package userModel 2 | 3 | import ( 4 | "github.com/ZiRunHua/LeapLedger/global" 5 | "github.com/ZiRunHua/LeapLedger/global/constant" 6 | commonModel "github.com/ZiRunHua/LeapLedger/model/common" 7 | "gorm.io/gorm" 8 | "time" 9 | ) 10 | 11 | type Log struct { 12 | ID uint `gorm:"primarykey"` 13 | UserId uint `gorm:"comment:用户id;not null"` 14 | Action constant.UserAction `gorm:"comment:操作;not null;size:32"` 15 | Remark string `gorm:"comment:备注;not null;size:255"` 16 | CreatedAt time.Time `gorm:"type:TIMESTAMP"` 17 | UpdatedAt time.Time `gorm:"type:TIMESTAMP"` 18 | DeletedAt gorm.DeletedAt `gorm:"index;type:TIMESTAMP"` 19 | commonModel.BaseModel 20 | } 21 | 22 | func (l *Log) TableName() string { 23 | return "user_log" 24 | } 25 | 26 | func (l *Log) IsEmpty() bool { 27 | return l.ID == 0 28 | } 29 | 30 | type LogDao struct { 31 | db *gorm.DB 32 | } 33 | 34 | func NewLogDao(db *gorm.DB) *LogDao { 35 | if db == nil { 36 | db = global.GvaDb 37 | } 38 | return &LogDao{db} 39 | } 40 | 41 | type LogAddData struct { 42 | Action constant.UserAction 43 | Remark string 44 | } 45 | 46 | func (l *LogDao) Add(user User, data *LogAddData) (*Log, error) { 47 | log := &Log{ 48 | UserId: user.ID, 49 | Action: data.Action, 50 | Remark: data.Remark, 51 | } 52 | err := l.db.Create(&log).Error 53 | return log, err 54 | } 55 | -------------------------------------------------------------------------------- /docker/mysql.cnf: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2015, 2016, Oracle and/or its affiliates. All rights reserved. 2 | # 3 | # This program is free software; you can redistribute it and/or modify 4 | # it under the terms of the GNU General Public License, version 2.0, 5 | # as published by the Free Software Foundation. 6 | # 7 | # This program is also distributed with certain software (including 8 | # but not limited to OpenSSL) that is licensed under separate terms, 9 | # as designated in a particular file or component or in included license 10 | # documentation. The authors of MySQL hereby grant you an additional 11 | # permission to link the program and your derivative works with the 12 | # separately licensed software that they have included with MySQL. 13 | # 14 | # This program is distributed in the hope that it will be useful, 15 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 16 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 17 | # GNU General Public License, version 2.0, for more details. 18 | # 19 | # You should have received a copy of the GNU General Public License 20 | # along with this program; if not, write to the Free Software 21 | # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA 22 | 23 | # 24 | # The MySQL Client configuration file. 25 | # 26 | # For explanations see 27 | # http://dev.mysql.com/doc/mysql/en/server-system-variables.html 28 | 29 | [mysqld] 30 | transaction-isolation = READ-COMMITTED -------------------------------------------------------------------------------- /global/lock/lock.go: -------------------------------------------------------------------------------- 1 | package lock 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "github.com/ZiRunHua/LeapLedger/global" 7 | "time" 8 | ) 9 | 10 | var ( 11 | currentMode mode 12 | 13 | New func(key Key) Lock 14 | NewWithDuration func(key Key, duration time.Duration) Lock 15 | 16 | ErrLockNotExist = errors.New("lock not exist") 17 | ErrLockOccupied = errors.New("lock occupied") 18 | ) 19 | 20 | type mode string 21 | 22 | const ( 23 | mysqlMode mode = "mysql" 24 | redisMode mode = "redis" 25 | ) 26 | 27 | type Lock interface { 28 | Lock(context.Context) error 29 | Release(context.Context) error 30 | } 31 | 32 | func init() { 33 | currentMode = mode(global.Config.System.LockMode) 34 | updatePublicFunc() 35 | } 36 | func updatePublicFunc() { 37 | switch currentMode { 38 | case mysqlMode: 39 | mdb = global.GvaDb 40 | err := mdb.AutoMigrate(&lockTable{}) 41 | if err != nil { 42 | panic(err) 43 | } 44 | New = func(key Key) Lock { 45 | return newMysqlLock(mdb, string(key), time.Second*30) 46 | } 47 | NewWithDuration = func(key Key, duration time.Duration) Lock { 48 | return newMysqlLock(mdb, string(key), duration) 49 | } 50 | return 51 | case redisMode: 52 | rdb = global.GvaRdb 53 | if rdb == nil { 54 | panic("initialize.LockRdb is nil") 55 | } 56 | New = func(key Key) Lock { 57 | return newRedisLock(rdb, string(key), time.Second*30) 58 | } 59 | NewWithDuration = func(key Key, duration time.Duration) Lock { 60 | return newRedisLock(rdb, string(key), duration) 61 | } 62 | return 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /.github/config.yaml: -------------------------------------------------------------------------------- 1 | # 服务初始化的配置文件 执行代码详见 ./initialize/initialize.go 2 | # This is the service initialization configuration file execution code see ./initialize/initialize.go 3 | 4 | # can set `debug` or `production` 5 | # `debug` mode can print mysql and redis logs and `/public/swagger/index.html` api 6 | Mode: debug 7 | 8 | System: 9 | Addr: 8080 10 | RouterPrefix: "" 11 | JwtKey: "" 12 | # Some apis use symmetric signature keys that can be configured to improve security 13 | ClientSignKey: "" 14 | 15 | Redis: 16 | Addr: "localhost:6379" 17 | Password: "" 18 | Db: 0 19 | LockDb: 1 20 | 21 | Mysql: 22 | Path: "localhost" 23 | Port: "3306" 24 | Config: "parseTime=True&loc=Local" 25 | DbName: "leap_ledger" 26 | Username: "root" 27 | Password: "" 28 | 29 | Nats: 30 | ServerUrl: localhost:4222 31 | # This is the topic that the consumer server needs to subscribe to, such as createTourist, statisticUpdate, transactionSync. 32 | # subjects see ./global/nats/nats.go 33 | Subjects: [all] 34 | 35 | Captcha: 36 | KeyLong: 6 37 | ImgWidth: 180 38 | ImgHeight: 50 39 | OpenCaptcha: 0 40 | OpenCaptchaTimeout: 3600 41 | EmailCaptcha: 0 42 | EmailCaptchaTimeOut: 3600 43 | 44 | ThirdParty: 45 | # WeCom used to send domain email 46 | WeCom: 47 | CorpId: "" 48 | CorpSecret: "" 49 | # Ai is used to obtain Chinese similarity, which is used to match transaction types 50 | # https://huggingface.co/google-bert/bert-base-chinese 51 | Ai: 52 | Host: "" # leap-ledger-ai-server 53 | Port: "" # 5000 54 | MinSimilarity: 0.85 -------------------------------------------------------------------------------- /global/lock/mysql.go: -------------------------------------------------------------------------------- 1 | package lock 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "time" 7 | 8 | "github.com/google/uuid" 9 | "gorm.io/gorm" 10 | ) 11 | 12 | var ( 13 | mdb *gorm.DB 14 | ) 15 | 16 | type lockTable struct { 17 | Key string `gorm:"primarykey"` 18 | Value string 19 | Expiration int64 20 | } 21 | 22 | func (lt *lockTable) TableName() string { return "core_lock" } 23 | 24 | func newMysqlLock(client *gorm.DB, key string, expiration time.Duration) *MysqlLock { 25 | return &MysqlLock{ 26 | client: client, 27 | key: key, 28 | value: uuid.New().String(), 29 | expiration: expiration, 30 | } 31 | } 32 | 33 | type MysqlLock struct { 34 | client *gorm.DB 35 | key string 36 | value string 37 | expiration time.Duration 38 | } 39 | 40 | func (ml *MysqlLock) Lock(ctx context.Context) error { 41 | err := ml.client.WithContext(ctx).Create( 42 | &lockTable{ 43 | Key: ml.key, 44 | Value: ml.value, 45 | Expiration: time.Now().Add(ml.expiration).Unix(), 46 | }, 47 | ).Error 48 | if err != nil { 49 | if errors.Is(err, gorm.ErrDuplicatedKey) { 50 | return ErrLockOccupied 51 | } 52 | return err 53 | } 54 | return nil 55 | } 56 | 57 | func (ml *MysqlLock) Release(ctx context.Context) error { 58 | result := ml.client.WithContext(ctx).Where("`key` = ? AND `value` = ?", ml.key, ml.value).Delete(&lockTable{}) 59 | err := result.Error 60 | if err != nil { 61 | return err 62 | } 63 | if result.RowsAffected == 0 { 64 | return ErrLockNotExist 65 | } 66 | return nil 67 | } 68 | -------------------------------------------------------------------------------- /service/product/productService.go: -------------------------------------------------------------------------------- 1 | package productService 2 | 3 | import ( 4 | "context" 5 | "github.com/ZiRunHua/LeapLedger/global" 6 | "github.com/ZiRunHua/LeapLedger/global/db" 7 | categoryModel "github.com/ZiRunHua/LeapLedger/model/category" 8 | productModel "github.com/ZiRunHua/LeapLedger/model/product" 9 | "github.com/pkg/errors" 10 | ) 11 | 12 | type Product struct { 13 | } 14 | 15 | func (proService *Product) MappingTransactionCategory( 16 | category categoryModel.Category, productTransCat productModel.TransactionCategory, ctx context.Context, 17 | ) (*productModel.TransactionCategoryMapping, error) { 18 | if category.IncomeExpense != productTransCat.IncomeExpense { 19 | return nil, errors.Wrap(global.ErrInvalidParameter, "") 20 | } 21 | mapping := &productModel.TransactionCategoryMapping{ 22 | AccountId: category.AccountId, 23 | CategoryId: category.ID, 24 | PtcId: productTransCat.ID, 25 | ProductKey: productTransCat.ProductKey, 26 | } 27 | err := db.Get(ctx).Model(mapping).Create(mapping).Error 28 | return mapping, err 29 | } 30 | 31 | func (proService *Product) DeleteMappingTransactionCategory( 32 | category categoryModel.Category, productTransCat productModel.TransactionCategory, ctx context.Context, 33 | ) error { 34 | if category.IncomeExpense != productTransCat.IncomeExpense { 35 | return errors.Wrap(global.ErrInvalidParameter, "") 36 | } 37 | err := db.Get(ctx).Where( 38 | "category_id = ? AND ptc_id = ?", category.ID, productTransCat.ID, 39 | ).Delete(&productModel.TransactionCategoryMapping{}).Error 40 | return err 41 | } 42 | -------------------------------------------------------------------------------- /config.yaml: -------------------------------------------------------------------------------- 1 | # 服务初始化的配置文件 执行代码详见 ./initialize/initialize.go 2 | # This is the service initialization configuration file execution code see ./initialize/initialize.go 3 | 4 | # can set `debug` or `production` 5 | # `debug` mode can print mysql and redis logs and `/public/swagger/index.html` api 6 | Mode: debug 7 | 8 | System: 9 | Addr: 8080 10 | RouterPrefix: "" 11 | JwtKey: "" 12 | # Some apis use symmetric signature keys that can be configured to improve security 13 | ClientSignKey: "" 14 | 15 | Redis: 16 | Addr: "leap-ledger-redis:6379" 17 | Password: "" 18 | Db: 0 19 | LockDb: 1 20 | 21 | Mysql: 22 | Path: "leap-ledger-mysql" 23 | Port: "3306" 24 | Config: "parseTime=True&loc=Local" 25 | DbName: "leap_ledger" 26 | Username: "root" 27 | Password: "" 28 | 29 | Nats: 30 | ServerUrl: leap-ledger-nats:4222 31 | # This is the topic that the consumer server needs to subscribe to, such as createTourist, statisticUpdate, transactionSync. 32 | # subjects see ./global/nats/nats.go 33 | Subjects: [all] 34 | 35 | Captcha: 36 | KeyLong: 6 37 | ImgWidth: 180 38 | ImgHeight: 50 39 | OpenCaptcha: 0 40 | OpenCaptchaTimeout: 3600 41 | EmailCaptcha: 0 42 | EmailCaptchaTimeOut: 3600 43 | 44 | ThirdParty: 45 | # WeCom used to send domain email 46 | WeCom: 47 | CorpId: "" 48 | CorpSecret: "" 49 | # Ai is used to obtain Chinese similarity, which is used to match transaction types 50 | # https://huggingface.co/google-bert/bert-base-chinese 51 | Ai: 52 | Host: "" # leap-ledger-ai-server 53 | Port: "" # 5000 54 | MinSimilarity: 0.85 -------------------------------------------------------------------------------- /model/product/productBillHeaderModel.go: -------------------------------------------------------------------------------- 1 | package productModel 2 | 3 | import ( 4 | "github.com/ZiRunHua/LeapLedger/global" 5 | commonModel "github.com/ZiRunHua/LeapLedger/model/common" 6 | ) 7 | 8 | type BillHeader struct { 9 | ID uint 10 | ProductKey string `gorm:"not null;uniqueIndex:product_header_type,priority:1"` 11 | Name string 12 | Type BillHeaderType `gorm:"not null;uniqueIndex:product_header_type,priority:2"` 13 | commonModel.BaseModel 14 | } 15 | type BillHeaderType string 16 | 17 | const ( 18 | TransTime BillHeaderType = "trans_time" 19 | TransCategory BillHeaderType = "trans_category" 20 | Remark BillHeaderType = "remark" 21 | IncomeExpense BillHeaderType = "income_expense" 22 | Amount BillHeaderType = "amount" 23 | OrderNumber BillHeaderType = "order_number" 24 | TransStatus BillHeaderType = "trans_status" 25 | ) 26 | 27 | func (b *BillHeader) TableName() string { 28 | return "product_bill_header" 29 | } 30 | 31 | func (b *BillHeader) IsEmpty() bool { 32 | return b.ID == 0 33 | } 34 | 35 | func (tc *BillHeader) GetNameMap(productKey string) ( 36 | NameMap map[string]BillHeader, err error, 37 | ) { 38 | var billHeader BillHeader 39 | rows, err := global.GvaDb.Model(&billHeader).Where( 40 | "product_key = ? ", productKey, 41 | ).Rows() 42 | defer rows.Close() 43 | if err != nil { 44 | return 45 | } 46 | NameMap = map[string]BillHeader{} 47 | for rows.Next() { 48 | err = global.GvaDb.ScanRows(rows, &billHeader) 49 | if err != nil { 50 | return 51 | } 52 | NameMap[billHeader.Name] = billHeader 53 | } 54 | return 55 | } 56 | -------------------------------------------------------------------------------- /initialize/logger.go: -------------------------------------------------------------------------------- 1 | package initialize 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | 7 | "github.com/ZiRunHua/LeapLedger/global/constant" 8 | "go.uber.org/zap" 9 | "go.uber.org/zap/zapcore" 10 | ) 11 | 12 | type _logger struct { 13 | encoder zapcore.Encoder 14 | } 15 | 16 | var ( 17 | _requestLogPath = filepath.Join(constant.RootDir, "log", "request.log") 18 | _errorLogPath = filepath.Join(constant.RootDir, "log", "error.log") 19 | _panicLogPath = filepath.Join(constant.RootDir, "log", "panic.log") 20 | ) 21 | 22 | func (l *_logger) do() error { 23 | l.setEncoder() 24 | var err error 25 | if RequestLogger, err = l.New(_requestLogPath); err != nil { 26 | return err 27 | } 28 | if ErrorLogger, err = l.New(_errorLogPath); err != nil { 29 | return err 30 | } 31 | if PanicLogger, err = l.New(_panicLogPath); err != nil { 32 | return err 33 | } 34 | return nil 35 | } 36 | 37 | func (l *_logger) setEncoder() { 38 | encoderConfig := zap.NewProductionEncoderConfig() 39 | encoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder 40 | encoderConfig.EncodeLevel = zapcore.CapitalLevelEncoder 41 | l.encoder = zapcore.NewConsoleEncoder(encoderConfig) 42 | } 43 | 44 | func (l *_logger) New(path string) (*zap.Logger, error) { 45 | err := os.MkdirAll(filepath.Dir(path), os.ModePerm) 46 | if err != nil { 47 | return nil, err 48 | } 49 | file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666) 50 | if err != nil { 51 | return nil, err 52 | } 53 | writeSyncer := zapcore.AddSync(file) 54 | core := zapcore.NewCore(l.encoder, writeSyncer, zapcore.DebugLevel) 55 | return zap.New(core), nil 56 | } 57 | -------------------------------------------------------------------------------- /test/enter.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | categoryModel "github.com/ZiRunHua/LeapLedger/model/category" 5 | "github.com/ZiRunHua/LeapLedger/test/initialize" 6 | ) 7 | 8 | var ( 9 | User = initialize.User 10 | Account = initialize.Account 11 | ExpenseCategoryList []categoryModel.Category 12 | ) 13 | 14 | func init() { 15 | ExpenseCategoryList = initialize.ExpenseCategoryList 16 | } 17 | 18 | var Timezones = []string{ 19 | "UTC", 20 | "America/New_York", 21 | "Europe/London", 22 | "Asia/Tokyo", 23 | "Australia/Sydney", 24 | "Europe/Paris", 25 | "Asia/Shanghai", 26 | "America/Los_Angeles", 27 | "Europe/Berlin", 28 | "Asia/Kolkata", 29 | "America/Chicago", 30 | "Europe/Moscow", 31 | "Asia/Dubai", 32 | "America/Denver", 33 | "Europe/Madrid", 34 | "Asia/Singapore", 35 | "America/Phoenix", 36 | "Europe/Rome", 37 | "Asia/Hong_Kong", 38 | "America/Anchorage", 39 | "Europe/Athens", 40 | "Asia/Seoul", 41 | "America/Halifax", 42 | "Europe/Stockholm", 43 | "Asia/Bangkok", 44 | "America/St_Johns", 45 | "Europe/Helsinki", 46 | "Asia/Jakarta", 47 | "America/Sao_Paulo", 48 | "Europe/Warsaw", 49 | "Asia/Kuala_Lumpur", 50 | "America/Argentina/Buenos_Aires", 51 | "Europe/Istanbul", 52 | "Asia/Manila", 53 | "America/Mexico_City", 54 | "Europe/Brussels", 55 | "Asia/Taipei", 56 | "America/Toronto", 57 | "Europe/Vienna", 58 | "Asia/Riyadh", 59 | "America/Caracas", 60 | "Europe/Zurich", 61 | "Asia/Baghdad", 62 | "America/Lima", 63 | "Europe/Copenhagen", 64 | "Asia/Tehran", 65 | "America/Fort_Nelson", 66 | "America/Hermosillo", 67 | "America/Chicago", 68 | "America/Mexico_City", 69 | } 70 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/ZiRunHua/LeapLedger/initialize" 5 | _ "github.com/ZiRunHua/LeapLedger/initialize/database" 6 | "github.com/ZiRunHua/LeapLedger/router" 7 | ) 8 | import ( 9 | "context" 10 | "fmt" 11 | "log" 12 | "net/http" 13 | "os" 14 | "os/signal" 15 | "syscall" 16 | "time" 17 | // Import "time/tzdata" for loading time zone data, 18 | // so in order to make the binary files can be run independently, 19 | // or in need of extra "$GOROOT/lib/time/zoneinfo.Zip" file, see time.LoadLocation 20 | _ "time/tzdata" 21 | ) 22 | 23 | var httpServer *http.Server 24 | 25 | // @title LeapLedger API 26 | // @version 1.0 27 | 28 | // @contact.name ZiRunHua 29 | 30 | // @license.name AGPL 3.0 31 | // @license.url https://www.gnu.org/licenses/agpl-3.0.html 32 | 33 | // @host localhost:8080 34 | 35 | // @securityDefinitions.jwt Bearer 36 | // @in header 37 | // @name Authorization 38 | func main() { 39 | httpServer = &http.Server{ 40 | Addr: fmt.Sprintf(":%d", initialize.Config.System.Addr), 41 | Handler: router.Engine, 42 | ReadTimeout: 5 * time.Second, 43 | WriteTimeout: 5 * time.Second, 44 | MaxHeaderBytes: 1 << 20, 45 | } 46 | err := httpServer.ListenAndServe() 47 | if err != nil { 48 | panic(err) 49 | } 50 | shutDown() 51 | } 52 | 53 | func shutDown() { 54 | quit := make(chan os.Signal, 1) 55 | signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) 56 | <-quit 57 | log.Println("Shutting down server...") 58 | 59 | if err := httpServer.Shutdown(context.TODO()); err != nil { 60 | log.Fatal("Server forced to shutdown:", err) 61 | } 62 | 63 | log.Println("Server exiting") 64 | } 65 | -------------------------------------------------------------------------------- /router/group/group.go: -------------------------------------------------------------------------------- 1 | package group 2 | 3 | import ( 4 | "github.com/ZiRunHua/LeapLedger/global" 5 | "github.com/ZiRunHua/LeapLedger/global/cus" 6 | accountModel "github.com/ZiRunHua/LeapLedger/model/account" 7 | routerEngine "github.com/ZiRunHua/LeapLedger/router/engine" 8 | "github.com/ZiRunHua/LeapLedger/router/middleware" 9 | "github.com/gin-gonic/gin" 10 | ) 11 | 12 | var engine = routerEngine.Engine 13 | var ( 14 | Public, Private *gin.RouterGroup 15 | Account *gin.RouterGroup 16 | 17 | NoTourist *gin.RouterGroup 18 | 19 | AccountReader *gin.RouterGroup 20 | AccountOwnEditor *gin.RouterGroup 21 | AccountAdministrator *gin.RouterGroup 22 | AccountCreator *gin.RouterGroup 23 | ) 24 | 25 | const accountWithIdPrefixPath = "/account/:" + string(cus.AccountId) 26 | 27 | func init() { 28 | Public = engine.Group(global.Config.System.RouterPrefix + "/public") 29 | Private = engine.Group(global.Config.System.RouterPrefix, middleware.JWTAuth()) 30 | 31 | NoTourist = Private.Group("") 32 | NoTourist.Use(middleware.NoTourist()) 33 | // account router 34 | Account = Private.Group(accountWithIdPrefixPath) 35 | // account role 36 | AccountReader = createAccountRoleGroup(accountModel.UserPermissionReader) 37 | AccountOwnEditor = createAccountRoleGroup(accountModel.UserPermissionOwnEditor) 38 | AccountAdministrator = createAccountRoleGroup(accountModel.UserPermissionAdministrator) 39 | AccountCreator = createAccountRoleGroup(accountModel.UserPermissionCreator) 40 | } 41 | 42 | func createAccountRoleGroup(permission accountModel.UserPermission) *gin.RouterGroup { 43 | group := Account.Group("", middleware.AccountAuth(permission)) 44 | return group 45 | } 46 | -------------------------------------------------------------------------------- /script/user.go: -------------------------------------------------------------------------------- 1 | package script 2 | 3 | import ( 4 | "context" 5 | accountModel "github.com/ZiRunHua/LeapLedger/model/account" 6 | userModel "github.com/ZiRunHua/LeapLedger/model/user" 7 | "github.com/ZiRunHua/LeapLedger/util" 8 | "github.com/ZiRunHua/LeapLedger/util/rand" 9 | "gorm.io/gorm" 10 | ) 11 | 12 | type _user struct { 13 | } 14 | 15 | var User = _user{} 16 | 17 | // Create("template@gmail.com","1999123456","template") 18 | func (u *_user) Create(email, password, username string, ctx context.Context) (userModel.User, error) { 19 | addData := userModel.AddData{ 20 | Email: email, 21 | Password: util.ClientPasswordHash(email, password), 22 | Username: username, 23 | } 24 | return userService.Register(addData, ctx, *userService.NewRegisterOption().WithSendEmail(false)) 25 | } 26 | 27 | func (u *_user) CreateTourist(ctx context.Context) (user userModel.User, err error) { 28 | email := rand.String(12) 29 | password := rand.String(8) 30 | username := rand.String(8) 31 | addData := userModel.AddData{ 32 | Email: email, 33 | Password: util.ClientPasswordHash(email, password), 34 | Username: username, 35 | } 36 | option := userService.NewRegisterOption().WithTour(true) 37 | user, err = userService.Register(addData, ctx, *option) 38 | if err != nil { 39 | return 40 | } 41 | return 42 | } 43 | 44 | func (u *_user) ChangeCurrantAccount(accountUser accountModel.User, db *gorm.DB) (err error) { 45 | for _, client := range userModel.GetClients() { 46 | err = db.Model(&client).Where("user_id = ?", accountUser.UserId).Update( 47 | "current_account_id", accountUser.AccountId, 48 | ).Error 49 | if err != nil { 50 | return 51 | } 52 | } 53 | return 54 | } 55 | -------------------------------------------------------------------------------- /global/error.go: -------------------------------------------------------------------------------- 1 | package global 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/pkg/errors" 7 | ) 8 | 9 | var ( 10 | ErrInvalidParameter = errors.New("invalid parameter") 11 | ErrNoPermission = errors.New("无权限") 12 | 13 | ErrFrequentOperation = errors.New("操作太频繁,稍后再试") 14 | ErrDeviceNotSupported = errors.New("当前设备不支持") 15 | 16 | ErrTooManyTourists = errors.New("游客过多稍后再试") 17 | ) 18 | 19 | // 数据校验 20 | var ( 21 | ErrTimeFrameIsTooLong = errors.New("时间范围过长") 22 | 23 | ErrAccountId = errors.New("error accountId") 24 | ) 25 | 26 | type errDataIsEmpty struct { 27 | Field string 28 | } 29 | 30 | func (e *errDataIsEmpty) Error() string { 31 | return e.Field + "数据不可为空" 32 | } 33 | 34 | func NewErrDataIsEmpty(param string) error { 35 | return &errDataIsEmpty{ 36 | Field: param, 37 | } 38 | } 39 | 40 | var ErrOperationTooFrequent = errors.New("操作过于频繁,请稍后再试!") 41 | var ErrVerifyEmailCaptchaFail = errors.New("校验邮箱验证码失败!") 42 | var ErrServiceClosed = errors.New("服务未开启!") 43 | var ErrTouristHaveNoRight = errors.New("游客无权操作!") 44 | 45 | // 对应constant.UserAction 46 | var ErrUnsupportedUserAction = errors.New("暂不支持该操作") 47 | 48 | // 用户 49 | var ErrSameAsTheOldPassword = errors.New("新旧密码相同") 50 | 51 | // 账本 52 | var ErrAccountType = errors.New("账本类型不允许该操作") 53 | 54 | // 交易类型 55 | var ErrCategoryNameEmpty = errors.New("名称不可为空") 56 | var ErrCategorySameName = errors.New("类型名称相同") 57 | 58 | func NewErrThirdpartyApi(name, msg string) error { 59 | return &errThirdpartyApi{Name: name, Msg: msg} 60 | } 61 | 62 | type errThirdpartyApi struct { 63 | Name, Msg string 64 | } 65 | 66 | func (eta *errThirdpartyApi) Error() string { 67 | return fmt.Sprintf("第三方%s接口服务错误:%s", eta.Name, eta.Msg) 68 | } 69 | -------------------------------------------------------------------------------- /router/v1/user.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "github.com/ZiRunHua/LeapLedger/router/group" 5 | ) 6 | 7 | func init() { 8 | // base path: /user or /public/user 9 | router := group.Private.Group("user") 10 | publicRouter := group.Public.Group("user") 11 | noTouristRouter := group.NoTourist.Group("user") 12 | baseApi := apiApp.UserApi 13 | { 14 | // public 15 | publicRouter.POST("/login", publicApi.Login) 16 | publicRouter.POST("/register", publicApi.Register) 17 | publicRouter.PUT("/password", publicApi.UpdatePassword) 18 | publicRouter.POST("/tour", publicApi.TourRequest) 19 | // current user 20 | noTouristRouter.POST("/current/captcha/email/send", baseApi.SendCaptchaEmail) 21 | router.POST("/token/refresh", baseApi.RefreshToken) 22 | router.PUT("/client/current/account", baseApi.SetCurrentAccount) 23 | router.PUT("/client/current/share/account", baseApi.SetCurrentShareAccount) 24 | noTouristRouter.PUT("/current/password", baseApi.UpdatePassword) 25 | noTouristRouter.PUT("/current", baseApi.UpdateInfo) 26 | router.GET("/home", baseApi.Home) 27 | // all user 28 | router.GET("/search", baseApi.SearchUser) 29 | // config 30 | router.GET("/transaction/share/config", baseApi.GetTransactionShareConfig) 31 | router.PUT("/transaction/share/config", baseApi.UpdateTransactionShareConfig) 32 | // friend 33 | router.GET("/friend/list", baseApi.GetFriendList) 34 | router.POST("/friend/invitation", baseApi.CreateFriendInvitation) 35 | router.PUT("/friend/invitation/:id/accept", baseApi.AcceptFriendInvitation) 36 | router.PUT("/friend/invitation/:id/refuse", baseApi.RefuseFriendInvitation) 37 | router.GET("/friend/invitation", baseApi.GetFriendInvitationList) 38 | 39 | router.GET("/account/invitation/list", baseApi.GetAccountInvitationList) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /model/product/productTransactionCategoryMappingModel.go: -------------------------------------------------------------------------------- 1 | package productModel 2 | 3 | import ( 4 | "database/sql" 5 | "github.com/ZiRunHua/LeapLedger/global" 6 | accountModel "github.com/ZiRunHua/LeapLedger/model/account" 7 | commonModel "github.com/ZiRunHua/LeapLedger/model/common" 8 | "time" 9 | ) 10 | 11 | type TransactionCategoryMapping struct { 12 | AccountId uint `gorm:"uniqueIndex:account_ptc_mapping,priority:1"` 13 | CategoryId uint `gorm:"uniqueIndex:category_ptc_mapping,priority:1"` 14 | PtcId uint `gorm:"uniqueIndex:account_ptc_mapping,priority:2;uniqueIndex:category_ptc_mapping,priority:2"` 15 | ProductKey string 16 | CreatedAt time.Time `gorm:"type:TIMESTAMP"` 17 | UpdatedAt time.Time `gorm:"type:TIMESTAMP"` 18 | commonModel.BaseModel 19 | } 20 | 21 | func (tcm *TransactionCategoryMapping) TableName() string { 22 | return "product_transaction_category_mapping" 23 | } 24 | 25 | func (tcm *TransactionCategoryMapping) IsEmpty() bool { 26 | return tcm == nil || tcm.AccountId == 0 27 | } 28 | 29 | func (tcm *TransactionCategoryMapping) GetPtcIdMapping( 30 | account *accountModel.Account, productKey Key, 31 | ) (result map[uint]TransactionCategoryMapping, err error) { 32 | db := global.GvaDb 33 | rows, err := db.Model(&TransactionCategoryMapping{}).Where( 34 | "account_id = ? AND product_key = ?", account.ID, productKey, 35 | ).Rows() 36 | defer func(rows *sql.Rows) { 37 | if err != nil { 38 | _ = rows.Close() 39 | } else { 40 | err = rows.Close() 41 | } 42 | }(rows) 43 | if err != nil { 44 | return 45 | } 46 | row, result := TransactionCategoryMapping{}, map[uint]TransactionCategoryMapping{} 47 | for rows.Next() { 48 | err = db.ScanRows(rows, &row) 49 | if err != nil { 50 | return 51 | } 52 | result[row.PtcId] = row 53 | } 54 | return 55 | } 56 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3.3' 2 | services: 3 | leap-ledger-server: 4 | container_name: leap-ledger-server 5 | image: xiaozirun/leap-ledger:${SERVICE_VERSION:-latest} 6 | build: 7 | context: . 8 | dockerfile: docker/Dockerfile.build 9 | ports: 10 | - "8080:8080" 11 | - "2345:2345" 12 | volumes: 13 | - ./log:/go/LeapLedger/log 14 | - ./docs:/go/LeapLedger/docs 15 | networks: 16 | - leap-ledger-network 17 | depends_on: 18 | - leap-ledger-mysql 19 | - leap-ledger-nats 20 | - leap-ledger-redis 21 | leap-ledger-mysql: 22 | container_name: leap-ledger-mysql 23 | image: mysql:8.0.30 24 | environment: 25 | MYSQL_ALLOW_EMPTY_PASSWORD: "yes" 26 | MYSQL_DATABASE: leap_ledger 27 | ports: 28 | - "3306:3306" 29 | volumes: 30 | - ./docker/data/mysql:/var/lib/mysql 31 | - ./docker/mysql.cnf:/etc/mysql/conf.d/mysql.cnf 32 | command: > 33 | bash -c " 34 | chmod 644 /etc/mysql/conf.d/*.cnf 35 | && /entrypoint.sh mysqld 36 | " 37 | networks: 38 | - leap-ledger-network 39 | leap-ledger-redis: 40 | container_name: leap-ledger-redis 41 | privileged: true 42 | image: redis:latest 43 | ports: 44 | - "6379:6379" 45 | volumes: 46 | - ./docker/redis.conf:/redis.conf 47 | - ./docker/data/redis:/data 48 | networks: 49 | - leap-ledger-network 50 | command: ["redis-server", "/redis.conf"] 51 | leap-ledger-nats: 52 | container_name: leap-ledger-nats 53 | image: nats:latest 54 | command: -js -sd /data -http_port 8222 55 | ports: 56 | - "4222:4222" 57 | - "8222:8222" 58 | volumes: 59 | - ./docker/data/nats:/data 60 | networks: 61 | - leap-ledger-network 62 | networks: 63 | leap-ledger-network: -------------------------------------------------------------------------------- /model/account/accountMappingModel.go: -------------------------------------------------------------------------------- 1 | package accountModel 2 | 3 | import ( 4 | logModel "github.com/ZiRunHua/LeapLedger/model/log" 5 | "gorm.io/gorm" 6 | "time" 7 | ) 8 | 9 | type Mapping struct { 10 | ID uint `gorm:"primarykey"` 11 | MainId uint `gorm:"not null;uniqueIndex:idx_mapping,priority:1"` 12 | RelatedId uint `gorm:"not null;uniqueIndex:idx_mapping,priority:2"` 13 | CreatedAt time.Time `gorm:"type:TIMESTAMP"` 14 | UpdatedAt time.Time `gorm:"type:TIMESTAMP"` 15 | } 16 | 17 | func (m *Mapping) TableName() string { 18 | return "account_mapping" 19 | } 20 | 21 | func (m *Mapping) GetMainAccount(db *gorm.DB) (result Account, err error) { 22 | err = db.First(&result, m.MainId).Error 23 | return 24 | } 25 | 26 | func (m *Mapping) GetRelatedAccount(db *gorm.DB) (result Account, err error) { 27 | err = db.First(&result, m.RelatedId).Error 28 | return 29 | } 30 | 31 | func (m *Mapping) GetLogDataModel() logModel.AccountLogDataRecordable { 32 | result := &logModel.AccountMappingLogData{ 33 | MainId: m.MainId, 34 | RelatedId: m.RelatedId, 35 | } 36 | return result 37 | } 38 | 39 | type MappingCondition struct { 40 | mainId *uint 41 | relatedId *uint 42 | } 43 | 44 | func NewMappingCondition() *MappingCondition { 45 | return &MappingCondition{} 46 | } 47 | 48 | func (mc *MappingCondition) addConditionToQuery(db *gorm.DB) *gorm.DB { 49 | if mc.mainId != nil { 50 | db = db.Where("main_id = ?", mc.mainId) 51 | } 52 | if mc.relatedId != nil { 53 | db = db.Where("related_id = ?", mc.relatedId) 54 | } 55 | return db 56 | } 57 | 58 | func (mc *MappingCondition) WithMainId(mainId uint) *MappingCondition { 59 | mc.mainId = &mainId 60 | return mc 61 | } 62 | 63 | func (mc *MappingCondition) WithRelatedId(relatedId uint) *MappingCondition { 64 | mc.relatedId = &relatedId 65 | return mc 66 | } 67 | -------------------------------------------------------------------------------- /model/user/transactionShareConfigModel.go: -------------------------------------------------------------------------------- 1 | package userModel 2 | 3 | import ( 4 | "github.com/ZiRunHua/LeapLedger/global" 5 | "gorm.io/gorm" 6 | "time" 7 | ) 8 | 9 | type TransactionShareConfig struct { 10 | ID uint `gorm:"primarykey"` 11 | UserId uint `gorm:"comment:'用户ID';unique"` 12 | DisplayFlags Flag `gorm:"comment:'展示字段标志'"` 13 | CreatedAt time.Time `gorm:"type:TIMESTAMP"` 14 | UpdatedAt time.Time `gorm:"type:TIMESTAMP"` 15 | DeletedAt gorm.DeletedAt `gorm:"index;type:TIMESTAMP"` 16 | } 17 | 18 | type Flag uint 19 | 20 | const ( 21 | FLAG_AMOUNT Flag = 1 << iota 22 | FLAG_CATEGORY 23 | FLAG_TRADE_TIME 24 | FLAG_ACCOUNT 25 | FLAG_CREATE_TIME 26 | FLAG_UPDATE_TIME 27 | FLAG_REMARK 28 | ) 29 | const DISPLAY_FLAGS_DEFAULT = FLAG_AMOUNT + FLAG_CATEGORY + FLAG_TRADE_TIME + FLAG_ACCOUNT + FLAG_REMARK 30 | 31 | func (u *TransactionShareConfig) TableName() string { 32 | return "user_transaction_share_config" 33 | } 34 | 35 | func (u *TransactionShareConfig) SelectByUserId(userId uint) error { 36 | u.UserId = userId 37 | u.DisplayFlags = DISPLAY_FLAGS_DEFAULT 38 | return global.GvaDb.Where("user_id = ?", userId).FirstOrCreate(&u).Error 39 | } 40 | 41 | func (u *TransactionShareConfig) OpenDisplayFlag(flag Flag, db *gorm.DB) error { 42 | where := db.Where("user_id = ?", u.UserId) 43 | return where.Model(&u).Update("display_flags", gorm.Expr("display_flags | ?", flag)).Error 44 | } 45 | 46 | func (u *TransactionShareConfig) ClosedDisplayFlag(flag Flag, db *gorm.DB) error { 47 | where := db.Where("user_id = ? AND display_flags & ? >0", u.UserId, flag) 48 | return where.Model(&u).Update("display_flags", gorm.Expr("display_flags ^ ?", flag)).Error 49 | } 50 | 51 | func (u *TransactionShareConfig) GetFlagStatus(flag Flag) bool { 52 | return u.DisplayFlags&flag > 0 53 | } 54 | -------------------------------------------------------------------------------- /script/transaction/statistic/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/ZiRunHua/LeapLedger/global" 5 | "github.com/ZiRunHua/LeapLedger/global/constant" 6 | commonModel "github.com/ZiRunHua/LeapLedger/model/common" 7 | 8 | _ "github.com/ZiRunHua/LeapLedger/initialize" 9 | transactionModel "github.com/ZiRunHua/LeapLedger/model/transaction" 10 | "gorm.io/gorm" 11 | ) 12 | 13 | func main() { 14 | correctStatistic() 15 | } 16 | func correctStatistic() { 17 | tables := []commonModel.Model{ 18 | &transactionModel.ExpenseCategoryStatistic{}, &transactionModel.ExpenseAccountStatistic{}, 19 | &transactionModel.ExpenseAccountUserStatistic{}, &transactionModel.IncomeCategoryStatistic{}, 20 | &transactionModel.IncomeAccountStatistic{}, 21 | &transactionModel.IncomeAccountUserStatistic{}, 22 | } 23 | for _, table := range tables { 24 | global.GvaDb.Delete(table, "count >= ?", 0) 25 | } 26 | err := global.GvaDb.Transaction( 27 | func(tx *gorm.DB) error { 28 | return transAccumulate(tx) 29 | }, 30 | ) 31 | if err != nil { 32 | panic(err.Error()) 33 | } 34 | } 35 | 36 | func transAccumulate(tx *gorm.DB) error { 37 | var list []transactionModel.Transaction 38 | err := tx.Model(&transactionModel.Transaction{}).Find(&list).Error 39 | if err != nil { 40 | return err 41 | } 42 | 43 | for _, trans := range list { 44 | if trans.IncomeExpense == constant.Expense { 45 | err = transactionModel.ExpenseAccumulate( 46 | trans.TradeTime, trans.AccountId, trans.UserId, trans.CategoryId, trans.Amount, 1, tx, 47 | ) 48 | if err != nil { 49 | return err 50 | } 51 | } else { 52 | err = transactionModel.IncomeAccumulate( 53 | trans.TradeTime, trans.AccountId, trans.UserId, trans.CategoryId, trans.Amount, 1, tx, 54 | ) 55 | if err != nil { 56 | return err 57 | } 58 | } 59 | } 60 | return nil 61 | } 62 | -------------------------------------------------------------------------------- /global/db/db_test.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "context" 5 | "github.com/ZiRunHua/LeapLedger/global/cus" 6 | "gorm.io/gorm" 7 | "gorm.io/gorm/logger" 8 | "testing" 9 | ) 10 | 11 | func init() { 12 | Db = Db.Session(&gorm.Session{Logger: Db.Logger.LogMode(logger.Silent)}) 13 | } 14 | 15 | func callback() { 16 | sum := 0 17 | for i := 0; i < 10; i++ { 18 | sum += i * i 19 | } 20 | } 21 | 22 | func Benchmark_Gorm_ThreeTransaction(b *testing.B) { 23 | for i := 0; i < b.N; i++ { 24 | _ = Db.Transaction( 25 | func(tx *gorm.DB) error { 26 | return tx.Transaction( 27 | func(tx *gorm.DB) error { 28 | return tx.Transaction( 29 | func(tx *gorm.DB) error { 30 | callback() 31 | callback() 32 | return nil 33 | }, 34 | ) 35 | }, 36 | ) 37 | }, 38 | ) 39 | } 40 | } 41 | 42 | func Benchmark_Cus_ThreeTransaction(b *testing.B) { 43 | for i := 0; i < b.N; i++ { 44 | _ = Transaction( 45 | context.Background(), func(ctx *cus.TxContext) error { 46 | return Transaction( 47 | ctx, func(ctx *cus.TxContext) error { 48 | return Transaction( 49 | ctx, func(ctx *cus.TxContext) error { 50 | _ = AddCommitCallback(ctx, callback, callback) 51 | return nil 52 | }, 53 | ) 54 | }, 55 | ) 56 | }, 57 | ) 58 | } 59 | } 60 | func Benchmark_Gorm_Transaction(b *testing.B) { 61 | for i := 0; i < b.N; i++ { 62 | _ = Db.Transaction( 63 | func(tx *gorm.DB) error { 64 | callback() 65 | callback() 66 | return nil 67 | }, 68 | ) 69 | } 70 | } 71 | 72 | func Benchmark_Cus_Transaction(b *testing.B) { 73 | for i := 0; i < b.N; i++ { 74 | _ = Transaction( 75 | context.Background(), func(ctx *cus.TxContext) error { 76 | _ = AddCommitCallback(ctx, callback, callback) 77 | return nil 78 | }, 79 | ) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /service/product/productBillimportService.go: -------------------------------------------------------------------------------- 1 | package productService 2 | 3 | import ( 4 | "context" 5 | "io" 6 | 7 | "github.com/ZiRunHua/LeapLedger/global/db" 8 | accountModel "github.com/ZiRunHua/LeapLedger/model/account" 9 | productModel "github.com/ZiRunHua/LeapLedger/model/product" 10 | transactionModel "github.com/ZiRunHua/LeapLedger/model/transaction" 11 | "github.com/ZiRunHua/LeapLedger/service/product/bill" 12 | "github.com/ZiRunHua/LeapLedger/util/fileTool" 13 | ) 14 | 15 | type BillFile struct { 16 | fileName string 17 | fileReader io.Reader 18 | } 19 | 20 | func (bf *BillFile) GetRowReader() (func(yield func([]string) bool), error) { 21 | return fileTool.NewRowReader( 22 | bf.fileReader, 23 | fileTool.GetFileSuffix(bf.fileName), 24 | ) 25 | } 26 | 27 | func (proService *Product) GetNewBillFile(fileName string, fileReader io.Reader) BillFile { 28 | return BillFile{fileName: fileName, fileReader: fileReader} 29 | } 30 | 31 | func (proService *Product) ProcessesBill( 32 | file BillFile, product productModel.Product, accountUser accountModel.User, 33 | handler func(transInfo transactionModel.Info, err error) error, ctx context.Context, 34 | ) error { 35 | rowReader, err := file.GetRowReader() 36 | if err != nil { 37 | return err 38 | } 39 | account, err := accountModel.NewDao(db.Get(ctx)).SelectById(accountUser.AccountId) 40 | transReader, err := bill.NewReader(account, product, ctx) 41 | if err != nil { 42 | return err 43 | } 44 | 45 | var ( 46 | transInfo transactionModel.Info 47 | ignore bool 48 | ) 49 | for row := range rowReader { 50 | transInfo, ignore, err = transReader.ReaderTrans(row, ctx) 51 | if ignore { 52 | continue 53 | } 54 | transInfo.AccountId, transInfo.UserId = accountUser.AccountId, accountUser.UserId 55 | err = handler(transInfo, err) 56 | if err != nil { 57 | return err 58 | } 59 | } 60 | return nil 61 | } 62 | -------------------------------------------------------------------------------- /test/util/build.go: -------------------------------------------------------------------------------- 1 | package tUtil 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/ZiRunHua/LeapLedger/global/constant" 8 | "github.com/ZiRunHua/LeapLedger/global/cus" 9 | "github.com/ZiRunHua/LeapLedger/global/db" 10 | accountModel "github.com/ZiRunHua/LeapLedger/model/account" 11 | categoryModel "github.com/ZiRunHua/LeapLedger/model/category" 12 | transactionModel "github.com/ZiRunHua/LeapLedger/model/transaction" 13 | userModel "github.com/ZiRunHua/LeapLedger/model/user" 14 | "github.com/ZiRunHua/LeapLedger/util/rand" 15 | ) 16 | 17 | type Build struct { 18 | } 19 | 20 | var build = &Build{} 21 | 22 | func (n *Build) User() (user userModel.User, err error) { 23 | user, err = userService.CreateTourist(context.TODO()) 24 | if err != nil { 25 | return 26 | } 27 | tour, err := userModel.NewDao().SelectTour(user.ID) 28 | if err != nil { 29 | return 30 | } 31 | err = db.Transaction( 32 | context.TODO(), func(ctx *cus.TxContext) error { 33 | return tour.Use(ctx.GetDb()) 34 | }, 35 | ) 36 | return user, err 37 | } 38 | 39 | func (n *Build) Account(user userModel.User, t accountModel.Type) ( 40 | account accountModel.Account, aUser accountModel.User, 41 | err error, 42 | ) { 43 | accountTmpl := templateService.NewAccountTmpl() 44 | err = accountTmpl.ReadFromJson(constant.ExampleAccountJsonPath) 45 | if err != nil { 46 | return 47 | } 48 | accountTmpl.Type = t 49 | return templateService.CreateAccountByTemplate(accountTmpl, user, context.TODO()) 50 | } 51 | 52 | func (n *Build) TransInfo(user userModel.User, category categoryModel.Category) transactionModel.Info { 53 | return transactionModel.Info{ 54 | UserId: user.ID, 55 | AccountId: category.AccountId, 56 | CategoryId: category.ID, 57 | IncomeExpense: category.IncomeExpense, 58 | Amount: rand.Int(1000), 59 | Remark: "test", 60 | TradeTime: time.Now(), 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /service/user/userFriendService.go: -------------------------------------------------------------------------------- 1 | package userService 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "github.com/ZiRunHua/LeapLedger/global/cus" 7 | "github.com/ZiRunHua/LeapLedger/global/db" 8 | userModel "github.com/ZiRunHua/LeapLedger/model/user" 9 | "gorm.io/gorm" 10 | ) 11 | 12 | type Friend struct{} 13 | 14 | func (f *Friend) CreateInvitation( 15 | inviter userModel.User, invitee userModel.User, ctx context.Context, 16 | ) (invitation userModel.FriendInvitation, err error) { 17 | return invitation, db.Transaction( 18 | ctx, func(ctx *cus.TxContext) (err error) { 19 | tx := ctx.GetDb() 20 | dao := userModel.NewDao(tx) 21 | invitation, err = dao.CreateFriendInvitation(inviter.ID, invitee.ID) 22 | if false == errors.Is(err, gorm.ErrDuplicatedKey) { 23 | return 24 | } 25 | // 处理重复键 26 | invitation, err = dao.SelectFriendInvitation(inviter.ID, invitee.ID, true) 27 | if err != nil { 28 | return 29 | } 30 | var isRealFriend bool 31 | isRealFriend, err = dao.IsRealFriend(inviter.ID, invitee.ID) 32 | if isRealFriend || err != nil { 33 | return 34 | } 35 | if invitation.Status == userModel.InvitationStatsOfWaiting { 36 | return 37 | } 38 | return invitation.UpdateStatus(userModel.InvitationStatsOfWaiting, tx) 39 | }, 40 | ) 41 | } 42 | 43 | func (f *Friend) AcceptInvitation(Invitation *userModel.FriendInvitation, ctx context.Context) ( 44 | inviterFriend userModel.Friend, inviteeFriend userModel.Friend, err error, 45 | ) { 46 | err = db.Transaction( 47 | ctx, func(ctx *cus.TxContext) error { 48 | inviterFriend, inviteeFriend, err = Invitation.Accept(ctx.GetDb()) 49 | return err 50 | }, 51 | ) 52 | return 53 | } 54 | 55 | func (f *Friend) RefuseInvitation(Invitation *userModel.FriendInvitation, ctx context.Context) error { 56 | return db.Transaction( 57 | ctx, func(ctx *cus.TxContext) error { 58 | return Invitation.Refuse(ctx.GetDb()) 59 | }, 60 | ) 61 | } 62 | -------------------------------------------------------------------------------- /api/request/account.go: -------------------------------------------------------------------------------- 1 | package request 2 | 3 | import accountModel "github.com/ZiRunHua/LeapLedger/model/account" 4 | 5 | // AccountCreateOne 账本新建 6 | type AccountCreateOne struct { 7 | Name string `binding:"required"` 8 | Icon string `binding:"required"` 9 | Location string `binding:"required"` 10 | Type accountModel.Type `binding:"required"` 11 | } 12 | 13 | // AccountUpdateOne 账本修改 14 | type AccountUpdateOne struct { 15 | Name *string 16 | Icon *string 17 | Type accountModel.Type `binding:"required"` 18 | } 19 | 20 | // AccountTransCategoryInit 账本交易类型初始话化 21 | type AccountTransCategoryInit struct { 22 | TemplateId uint 23 | } 24 | 25 | // AccountMapping 账本关联 26 | type AccountMapping struct { 27 | AccountId uint 28 | } 29 | 30 | // UpdateAccountMapping 账本关联 31 | type UpdateAccountMapping struct { 32 | RelatedAccountId uint 33 | } 34 | 35 | // AccountCreateOneUserInvitation 账本邀请建立 36 | type AccountCreateOneUserInvitation struct { 37 | Invitee uint `binding:"required"` 38 | Role *accountModel.UserRole `binding:"omitempty"` 39 | } 40 | 41 | // AccountGetUserInvitationList 账本邀请列表 42 | type AccountGetUserInvitationList struct { 43 | AccountId uint `binding:"required"` 44 | Invitee *uint `binding:"omitempty"` 45 | Role *accountModel.UserRole `binding:"omitempty"` 46 | PageData 47 | } 48 | 49 | // AccountGetUserInfo 账本用户信息获取 50 | type AccountGetUserInfo struct { 51 | Types []InfoType 52 | } 53 | 54 | type AccountInfo struct { 55 | Types *[]InfoType `binding:"omitempty"` 56 | } 57 | 58 | type AccountUpdateUser struct { 59 | Role accountModel.UserRole `binding:"required"` 60 | } 61 | 62 | func (a *AccountUpdateUser) GetUpdateData() accountModel.UserUpdateData { 63 | return accountModel.UserUpdateData{ 64 | Permission: a.Role.ToUserPermission(), 65 | } 66 | } 67 | 68 | type AccountUserConfigFlagUpdate struct { 69 | Status bool 70 | } 71 | -------------------------------------------------------------------------------- /service/transaction/task.go: -------------------------------------------------------------------------------- 1 | package transactionService 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | "time" 8 | 9 | "github.com/ZiRunHua/LeapLedger/global/cron" 10 | "github.com/ZiRunHua/LeapLedger/global/nats" 11 | ) 12 | 13 | type _task struct{} 14 | 15 | func init() { 16 | // update statistic 17 | nats.SubscribeTaskWithPayload(nats.TaskStatisticUpdate, GroupApp.Transaction.updateStatistic) 18 | // sync trans 19 | nats.SubscribeTaskWithPayloadAndProcessInTransaction( 20 | nats.TaskTransactionSync, GroupApp.Transaction.SyncToMappingAccount, 21 | ) 22 | // timing 23 | var moments []string 24 | for i := 0; i < 24; i++ { 25 | moments = append(moments, fmt.Sprintf("%02d:00", i)) 26 | } 27 | _, err := cron.Scheduler.Every(1).Day().At(strings.Join(moments, ";")).Do( 28 | cron.PublishTaskWithMakePayload( 29 | nats.TaskTransactionTimingTaskAssign, func() (taskTransactionTimingTaskAssign, error) { 30 | now := time.Now() 31 | return taskTransactionTimingTaskAssign{ 32 | Deadline: time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), 0, 0, 0, time.Local), 33 | TaskSize: 50, 34 | }, nil 35 | }, 36 | ), 37 | ) 38 | if err != nil { 39 | panic(err) 40 | } 41 | 42 | nats.SubscribeTaskWithPayloadAndProcessInTransaction( 43 | nats.TaskTransactionTimingTaskAssign, func(assign taskTransactionTimingTaskAssign, ctx context.Context) error { 44 | return GroupApp.Timing.Exec.GenerateAndPublishTasks(assign.Deadline, assign.TaskSize, ctx) 45 | }, 46 | ) 47 | 48 | nats.SubscribeTaskWithPayloadAndProcessInTransaction( 49 | nats.TaskTransactionTimingExec, func(execTask transactionTimingExecTask, ctx context.Context) error { 50 | return GroupApp.Timing.Exec.ProcessWaitExecByStartId(execTask.StartId, execTask.Size, ctx) 51 | }, 52 | ) 53 | } 54 | 55 | type taskTransactionTimingTaskAssign struct { 56 | Deadline time.Time 57 | TaskSize int 58 | } 59 | 60 | type transactionTimingExecTask struct { 61 | StartId uint 62 | Size int 63 | } 64 | -------------------------------------------------------------------------------- /router/v1/account.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "github.com/ZiRunHua/LeapLedger/router/group" 5 | ) 6 | 7 | func init() { 8 | // base path: /account/{accountId} 9 | router := group.Private.Group("account") 10 | baseApi := apiApp.AccountApi 11 | { 12 | router.POST("", baseApi.CreateOne) 13 | group.AccountAdministrator.PUT("", baseApi.Update) 14 | group.AccountAdministrator.DELETE("", baseApi.Delete) 15 | router.GET("/list", baseApi.GetList) 16 | router.GET("/list/:type", baseApi.GetListByType) 17 | group.Account.GET("", baseApi.GetOne) 18 | group.Account.GET("/info/:type", baseApi.GetInfo) 19 | group.Account.GET("/info", baseApi.GetInfo) 20 | // 模板 21 | router.GET("/template/list", baseApi.GetAccountTemplateList) 22 | router.POST("/form/template/:id", baseApi.CreateOneByTemplate) 23 | group.AccountCreator.POST("/transaction/category/init", baseApi.InitCategoryByTemplate) 24 | // 共享 25 | group.AccountCreator.PUT("/user/:id", baseApi.UpdateUser) 26 | group.Account.GET("/user/list", baseApi.GetUserList) 27 | group.Account.GET("/user/:id/info", baseApi.GetUserInfo) 28 | router.GET("/user/invitation/list", baseApi.GetUserInvitationList) 29 | group.AccountOwnEditor.POST("/user/invitation", baseApi.CreateAccountUserInvitation) 30 | router.PUT("/user/invitation/:id/accept", baseApi.AcceptAccountUserInvitation) 31 | router.PUT("/user/invitation/:id/refuse", baseApi.RefuseAccountUserInvitation) 32 | // 账本关联 33 | group.AccountOwnEditor.GET("/mapping", baseApi.GetAccountMapping) 34 | group.AccountOwnEditor.DELETE("/mapping/:id", baseApi.DeleteAccountMapping) 35 | group.Account.GET("/mapping/list", baseApi.GetAccountMappingList) 36 | group.AccountOwnEditor.POST("/mapping", baseApi.CreateAccountMapping) 37 | group.AccountOwnEditor.PUT("/mapping/:id", baseApi.UpdateAccountMapping) 38 | // 账本用户配置 39 | group.AccountOwnEditor.GET("/user/config", baseApi.GetUserConfig) 40 | group.AccountOwnEditor.PUT("/user/config/flag/:flag", baseApi.UpdateUserConfigFlag) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /global/cus/context.go: -------------------------------------------------------------------------------- 1 | package cus 2 | 3 | import ( 4 | "context" 5 | "gorm.io/gorm" 6 | ) 7 | 8 | func WithDb(parent context.Context, db *gorm.DB) *DbContext { 9 | return &DbContext{Context: parent, db: db} 10 | } 11 | 12 | type DbContext struct { 13 | context.Context 14 | db *gorm.DB 15 | } 16 | 17 | func (dc *DbContext) Value(key any) any { 18 | if key == Db { 19 | return dc.db 20 | } 21 | return dc.Context.Value(key) 22 | } 23 | func (dc *DbContext) GetDb() *gorm.DB { 24 | return dc.db 25 | } 26 | 27 | func WithTx(parent context.Context, tx *gorm.DB) *TxContext { 28 | return &TxContext{Context: parent, tx: tx} 29 | } 30 | 31 | type TxContext struct { 32 | context.Context 33 | tx *gorm.DB 34 | } 35 | 36 | func (tc *TxContext) Value(key any) any { 37 | if key == Db || key == Tx { 38 | return tc.tx 39 | } 40 | return tc.Context.Value(key) 41 | } 42 | 43 | func (tc *TxContext) GetDb() *gorm.DB { 44 | return tc.tx 45 | } 46 | 47 | func WithTxCommitContext(parent context.Context) *TxCommitContext { 48 | return &TxCommitContext{Context: parent} 49 | } 50 | 51 | type TxCommitCallback func() 52 | 53 | type TxCommitContext struct { 54 | context.Context 55 | callbacks []TxCommitCallback 56 | } 57 | 58 | func (t *TxCommitContext) Value(key any) any { 59 | if key == TxCommit { 60 | return t 61 | } 62 | return t.Context.Value(key) 63 | } 64 | 65 | func (t *TxCommitContext) AddCallback(callback ...TxCommitCallback) error { 66 | t.callbacks = append(t.callbacks, callback...) 67 | return nil 68 | } 69 | 70 | func (t *TxCommitContext) ExecCallback() { 71 | if len(t.callbacks) == 0 { 72 | return 73 | } 74 | parent := t.Context.Value(TxCommit) 75 | if parent != nil { 76 | // The parent transaction decides to commit last, so the callback is handed over to the parent transaction 77 | err := parent.(*TxCommitContext).AddCallback(t.callbacks...) 78 | if err != nil { 79 | panic(err) 80 | } 81 | return 82 | } 83 | for _, callback := range t.callbacks { 84 | callback() 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /initialize/mysql.go: -------------------------------------------------------------------------------- 1 | package initialize 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/ZiRunHua/LeapLedger/global/constant" 7 | 8 | _ "github.com/go-sql-driver/mysql" 9 | "gorm.io/driver/mysql" 10 | "gorm.io/gorm" 11 | "gorm.io/gorm/logger" 12 | "gorm.io/gorm/schema" 13 | ) 14 | 15 | type _mysql struct { 16 | Path string `yaml:"Path"` 17 | Port string `yaml:"Port"` 18 | Config string `yaml:"Config"` 19 | DbName string `yaml:"DbName"` 20 | Username string `yaml:"Username"` 21 | Password string `yaml:"Password"` 22 | } 23 | 24 | func (m *_mysql) dsn() string { 25 | return m.Username + ":" + m.Password + "@tcp(" + m.Path + ":" + m.Port + ")/" + m.DbName + "?" + m.Config 26 | } 27 | 28 | func (m *_mysql) do() error { 29 | var err error 30 | mysqlConfig := mysql.Config{ 31 | DSN: m.dsn(), // DSN data source name 32 | DefaultStringSize: 191, // string 类型字段的默认长度 33 | SkipInitializeWithVersion: false, // 34 | } 35 | var db *gorm.DB 36 | db, err = reconnection[*gorm.DB]( 37 | func() (*gorm.DB, error) { 38 | return gorm.Open(mysql.New(mysqlConfig), m.gormConfig()) 39 | }, 10, 40 | ) 41 | 42 | if err != nil { 43 | return err 44 | } 45 | sqlDb, _ := db.DB() 46 | sqlDb.SetMaxIdleConns(50) 47 | sqlDb.SetMaxOpenConns(50) 48 | sqlDb.SetConnMaxLifetime(5 * time.Minute) 49 | db.InstanceSet("gorm:table_options", "ENGINE=InnoDB") 50 | Db = db 51 | return nil 52 | } 53 | 54 | func (m *_mysql) gormConfig() *gorm.Config { 55 | config := &gorm.Config{ 56 | SkipDefaultTransaction: true, 57 | NamingStrategy: schema.NamingStrategy{ 58 | SingularTable: true, 59 | }, 60 | DisableForeignKeyConstraintWhenMigrating: true, 61 | TranslateError: true, 62 | } 63 | switch Config.Mode { 64 | case constant.Debug: 65 | config.Logger = logger.Default.LogMode(logger.Info) 66 | case constant.Production: 67 | config.Logger = logger.Default.LogMode(logger.Silent) 68 | default: 69 | panic("error Mode") 70 | } 71 | return config 72 | } 73 | -------------------------------------------------------------------------------- /global/nats/manager/enter.go: -------------------------------------------------------------------------------- 1 | package manager 2 | 3 | import ( 4 | "path/filepath" 5 | 6 | "github.com/ZiRunHua/LeapLedger/global" 7 | "github.com/ZiRunHua/LeapLedger/global/constant" 8 | "github.com/ZiRunHua/LeapLedger/initialize" 9 | "github.com/nats-io/nats.go/jetstream" 10 | "go.uber.org/zap" 11 | ) 12 | 13 | var ( 14 | natsConn = initialize.Nats 15 | js jetstream.JetStream 16 | ) 17 | 18 | var ( 19 | taskManage *taskManager 20 | eventManage *eventManager 21 | dlqManage *dlqManager 22 | 23 | TaskManage TaskManager 24 | EventManage EventManager 25 | DlqManage DlqManager 26 | ) 27 | 28 | var natsLogPath = filepath.Join(constant.LogPath, "nats") 29 | 30 | var ( 31 | taskLogger *zap.Logger 32 | eventLogger *zap.Logger 33 | dlqLogger *zap.Logger 34 | ) 35 | 36 | func init() { 37 | var err error 38 | js, err = jetstream.New(natsConn) 39 | if err != nil { 40 | panic(err) 41 | } 42 | taskLogger, err = global.Config.Logger.New(natsTaskLogPath) 43 | if err != nil { 44 | panic(err) 45 | } 46 | eventLogger, err = global.Config.Logger.New(natsEventLogPath) 47 | if err != nil { 48 | panic(err) 49 | } 50 | dlqLogger, err = global.Config.Logger.New(dlqLogPath) 51 | if err != nil { 52 | panic(err) 53 | } 54 | 55 | if taskManage != nil { 56 | taskManage = &taskManager{taskMsgHandler: taskManage.taskMsgHandler} 57 | } else { 58 | taskManage = &taskManager{} 59 | } 60 | TaskManage = taskManage 61 | err = taskManage.init(js, taskLogger) 62 | if err != nil { 63 | panic(err) 64 | } 65 | 66 | if eventManage != nil { 67 | eventManage = &eventManager{eventMsgHandler: eventManage.eventMsgHandler} 68 | } else { 69 | eventManage = &eventManager{} 70 | } 71 | EventManage = eventManage 72 | err = eventManage.init(js, taskManage, eventLogger) 73 | if err != nil { 74 | panic(err) 75 | } 76 | 77 | dlqManage = &dlqManager{} 78 | DlqManage = dlqManage 79 | err = dlqManage.init(js, []dlqRegisterStream{taskManage, eventManage}, dlqLogger) 80 | if err != nil { 81 | panic(err) 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /api/response/common.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | import ( 4 | "github.com/ZiRunHua/LeapLedger/global/db" 5 | accountModel "github.com/ZiRunHua/LeapLedger/model/account" 6 | userModel "github.com/ZiRunHua/LeapLedger/model/user" 7 | "github.com/ZiRunHua/LeapLedger/util/dataTool" 8 | "time" 9 | ) 10 | 11 | type CommonCaptcha struct { 12 | CaptchaId string 13 | PicBase64 string 14 | CaptchaLength int 15 | OpenCaptcha bool 16 | } 17 | 18 | type Id struct { 19 | Id uint 20 | } 21 | 22 | type CreateResponse struct { 23 | Id uint 24 | CreatedAt time.Time 25 | UpdatedAt time.Time 26 | } 27 | 28 | type Token struct { 29 | Token string 30 | TokenExpirationTime time.Time 31 | } 32 | 33 | type TwoLevelTree struct { 34 | Tree []Father 35 | } 36 | 37 | type Father struct { 38 | NameId 39 | Children []NameId 40 | } 41 | 42 | type NameId struct { 43 | Id uint 44 | Name string 45 | } 46 | 47 | type NameValue struct { 48 | Name string 49 | Value int 50 | } 51 | 52 | type PageData struct { 53 | page int 54 | limit int 55 | count int 56 | } 57 | 58 | type ExpirationTime struct { 59 | ExpirationTime int 60 | } 61 | 62 | type List[T any] struct { 63 | List []T 64 | } 65 | 66 | func getUsernameMap(ids []uint) (map[uint]string, error) { 67 | var nameList dataTool.Slice[uint, struct { 68 | ID uint 69 | Username string 70 | }] 71 | err := db.Db.Model(&userModel.User{}).Where("id IN (?)", ids).Find(&nameList).Error 72 | if err != nil { 73 | return nil, err 74 | } 75 | result := make(map[uint]string) 76 | for _, s := range nameList { 77 | result[s.ID] = s.Username 78 | } 79 | return result, nil 80 | } 81 | 82 | func getAccountNameMap(ids []uint) (map[uint]string, error) { 83 | var nameList dataTool.Slice[uint, struct { 84 | ID uint 85 | Name string 86 | }] 87 | err := db.Db.Model(&accountModel.Account{}).Where("id IN (?)", ids).Find(&nameList).Error 88 | if err != nil { 89 | return nil, err 90 | } 91 | result := make(map[uint]string) 92 | for _, s := range nameList { 93 | result[s.ID] = s.Name 94 | } 95 | return result, nil 96 | } 97 | -------------------------------------------------------------------------------- /api/request/common.go: -------------------------------------------------------------------------------- 1 | package request 2 | 3 | import ( 4 | "github.com/ZiRunHua/LeapLedger/global" 5 | "github.com/ZiRunHua/LeapLedger/global/constant" 6 | "github.com/pkg/errors" 7 | "time" 8 | ) 9 | 10 | type IncomeExpense struct { 11 | IncomeExpense constant.IncomeExpense `json:"Income_expense"` 12 | } 13 | type Name struct { 14 | Name string 15 | } 16 | type Id struct { 17 | Id uint 18 | } 19 | 20 | type PageData struct { 21 | Offset int `binding:"gte=0"` 22 | Limit int `binding:"gt=0"` 23 | } 24 | 25 | type PicCaptcha struct { 26 | Captcha string `binding:"required"` 27 | CaptchaId string `binding:"required"` 28 | } 29 | 30 | type CommonSendEmailCaptcha struct { 31 | Email string `binding:"required,email"` 32 | Type constant.UserAction `binding:"required,oneof=register forgetPassword"` 33 | PicCaptcha 34 | } 35 | 36 | type TimeFrame struct { 37 | StartTime time.Time 38 | EndTime time.Time 39 | } 40 | 41 | func (t *TimeFrame) CheckTimeFrame() error { 42 | if t.EndTime.Before(t.StartTime) { 43 | return errors.New("时间范围错误") 44 | } 45 | if t.StartTime.AddDate(2, 2, 2).Before(t.EndTime) { 46 | return global.ErrTimeFrameIsTooLong 47 | } 48 | return nil 49 | } 50 | 51 | // 格式化日时间 将StartTime置为当日第一秒 endTime置为当日最后一秒 52 | func (t *TimeFrame) FormatDayTime() (startTime time.Time, endTime time.Time) { 53 | year, month, day := t.StartTime.Date() 54 | startTime = time.Date(year, month, day, 0, 0, 0, 0, t.StartTime.Location()) 55 | year, month, day = t.EndTime.Date() 56 | endTime = time.Date(year, month, day, 23, 59, 59, 0, t.EndTime.Location()) 57 | return 58 | } 59 | 60 | func (t *TimeFrame) SetLocal(l *time.Location) { 61 | t.StartTime, t.EndTime = t.StartTime.In(l), t.EndTime.In(l) 62 | } 63 | 64 | func (t *TimeFrame) ToUTC() { 65 | t.StartTime, t.EndTime = t.StartTime.UTC(), t.EndTime.UTC() 66 | } 67 | 68 | // 信息类型 69 | type InfoType string 70 | 71 | // 今日交易统计 72 | var TodayTransTotal InfoType = "todayTransTotal" 73 | 74 | // 本月交易统计 75 | var CurrentMonthTransTotal InfoType = "currentMonthTransTotal" 76 | 77 | // 最近交易数据 78 | var RecentTrans InfoType = "recentTrans" 79 | 80 | type AccountId struct { 81 | AccountId uint 82 | } 83 | -------------------------------------------------------------------------------- /util/dataTool/slice.go: -------------------------------------------------------------------------------- 1 | package dataTool 2 | 3 | // Slice的拓展方法 4 | type Slice[K comparable, V any] []V 5 | 6 | // ToMap slice转map 7 | /** 8 | 示例: 9 | var userList dataTools.Slice[uint, User] 10 | userList = []User{ 11 | {ID: 1, Name: "test1", Password: "test2"}, 12 | {ID: 2, Name: "test2", Password: "test3"}, 13 | {ID: 3, Name: "test3", Password: "test3"}, 14 | } // 或者 global.GvaDb.Limit(5).Find(&userList) 15 | userMap := userList.ToMap(func(user User) uint { 16 | return user.ID 17 | }) 18 | fmt.Println(userMap) //将得到 map[1:{1 test1 test2} 2:{2 test2 test3}] 19 | */ 20 | func (s Slice[K, V]) ToMap(getKey func(V) K) (result map[K]V) { 21 | result = make(map[K]V) 22 | for _, v := range s { 23 | result[getKey(v)] = v 24 | } 25 | return 26 | } 27 | 28 | // ExtractValues 提取slice中的值 29 | /** 30 | 示例: 31 | var userList dataTools.Slice[uint, User] 32 | userList = []User{ 33 | {ID: 1, Name: "test1", Password: "test2"}, 34 | {ID: 2, Name: "test2", Password: "test3"}, 35 | } // 或者 global.GvaDb.Limit(2).Find(&userList) 36 | userIds := userList.ExtractValues(func(user User) uint { 37 | return user.ID 38 | }) 39 | fmt.Println(userIds) //将得到 [1 2] 40 | */ 41 | func (s Slice[K, V]) ExtractValues(getVal func(V) K) (result []K) { 42 | result = make([]K, len(s), len(s)) 43 | for i, v := range s { 44 | result[i] = getVal(v) 45 | } 46 | return 47 | } 48 | 49 | func (s *Slice[K, V]) Reverse() { 50 | n := len(*s) 51 | for i := 0; i < n/2; i++ { 52 | (*s)[i], (*s)[n-1-i] = (*s)[n-1-i], (*s)[i] 53 | } 54 | } 55 | 56 | func (s Slice[K, V]) CopyReverse() Slice[K, V] { 57 | list := make(Slice[K, V], len(s), len(s)) 58 | copy(list, s) 59 | list.Reverse() 60 | return list 61 | } 62 | 63 | func Reverse[V any](s []V) { 64 | for i := 0; i < len(s)/2; i++ { 65 | s[i], s[len(s)-1-i] = s[len(s)-1-i], s[i] 66 | } 67 | } 68 | 69 | func CopyReverse[V any](s []V) []V { 70 | list := make([]V, len(s), len(s)) 71 | copy(list, s) 72 | Reverse(list) 73 | return list 74 | } 75 | 76 | func ToMap[Slice ~[]V, V any, K comparable](s Slice, getKey func(V) K) (result map[K]V) { 77 | result = make(map[K]V) 78 | for _, v := range s { 79 | result[getKey(v)] = v 80 | } 81 | return 82 | } 83 | -------------------------------------------------------------------------------- /api/v1/common.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "github.com/ZiRunHua/LeapLedger/api/request" 5 | "github.com/ZiRunHua/LeapLedger/api/response" 6 | "github.com/ZiRunHua/LeapLedger/global" 7 | "github.com/ZiRunHua/LeapLedger/global/nats" 8 | "github.com/gin-gonic/gin" 9 | "github.com/mojocn/base64Captcha" 10 | "github.com/pkg/errors" 11 | ) 12 | 13 | type CommonApi struct { 14 | } 15 | 16 | var captchaStore = base64Captcha.DefaultMemStore 17 | 18 | // Captcha 19 | // 20 | // @Tags Common 21 | // @Produce json 22 | // @Success 200 {object} response.Data{Data=response.CommonCaptcha} 23 | // @Router /public/captcha [get] 24 | func (p *PublicApi) Captcha(c *gin.Context) { 25 | driver := base64Captcha.NewDriverDigit( 26 | global.Config.Captcha.ImgHeight, global.Config.Captcha.ImgWidth, global.Config.Captcha.KeyLong, 0.7, 27 | 80, 28 | ) 29 | cp := base64Captcha.NewCaptcha(driver, captchaStore) 30 | id, b64s, _, err := cp.Generate() 31 | if err != nil { 32 | response.FailWithMessage("验证码获取失败", c) 33 | return 34 | } 35 | response.OkWithDetailed( 36 | response.CommonCaptcha{ 37 | CaptchaId: id, 38 | PicBase64: b64s, 39 | CaptchaLength: global.Config.Captcha.KeyLong, 40 | }, "验证码获取成功", c, 41 | ) 42 | } 43 | 44 | // SendEmailCaptcha 45 | // 46 | // @Tags Common 47 | // @Accept json 48 | // @Produce json 49 | // @Param body body request.CommonSendEmailCaptcha true "data" 50 | // @Success 200 {object} response.Data{Data=response.ExpirationTime} 51 | // @Router /public/captcha/email/send [post] 52 | func (p *PublicApi) SendEmailCaptcha(ctx *gin.Context) { 53 | var requestData request.CommonSendEmailCaptcha 54 | if err := ctx.ShouldBindJSON(&requestData); err != nil { 55 | response.FailToParameter(ctx, err) 56 | return 57 | } 58 | 59 | if false == captchaStore.Verify(requestData.CaptchaId, requestData.Captcha, true) { 60 | response.FailWithMessage("验证码错误", ctx) 61 | return 62 | } 63 | 64 | isSuccess := nats.PublishTaskWithPayload( 65 | nats.TaskSendCaptchaEmail, nats.PayloadSendCaptchaEmail{ 66 | Email: requestData.Email, Action: requestData.Type, 67 | }, 68 | ) 69 | if !isSuccess { 70 | response.FailToError(ctx, errors.New("发送失败")) 71 | } 72 | response.OkWithData(response.ExpirationTime{ExpirationTime: global.Config.Captcha.EmailCaptchaTimeOut}, ctx) 73 | } 74 | -------------------------------------------------------------------------------- /docs/beforeDocsMake/renameModel/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "go/ast" 6 | "go/parser" 7 | "go/token" 8 | "io/fs" 9 | "os" 10 | "path/filepath" 11 | "strings" 12 | 13 | "github.com/ZiRunHua/LeapLedger/global/constant" 14 | ) 15 | 16 | func main() { 17 | handleDir(filepath.Clean(constant.RootDir + "/api/request/")) 18 | handleDir(filepath.Clean(constant.RootDir + "/api/response/")) 19 | } 20 | func handleDir(path string) { 21 | _ = filepath.Walk( 22 | path, func(path string, info fs.FileInfo, err error) error { 23 | if info.IsDir() { 24 | return nil 25 | } 26 | handleFile(path) 27 | return nil 28 | }, 29 | ) 30 | return 31 | } 32 | func handleFile(path string) { 33 | file, err := os.OpenFile(path, os.O_RDWR, 0666) 34 | defer file.Close() 35 | if err != nil { 36 | panic(err) 37 | } 38 | reader := bufio.NewReader(file) 39 | var lines []string 40 | for { 41 | line, err := reader.ReadString('\n') 42 | if err != nil { 43 | if err.Error() == "EOF" { 44 | break 45 | } 46 | panic(err) 47 | } 48 | lines = append(lines, line) 49 | } 50 | fileSet := token.NewFileSet() 51 | astFile, err := parser.ParseFile(fileSet, path, nil, parser.AllErrors) 52 | if err != nil { 53 | panic(err) 54 | } 55 | 56 | for _, decl := range astFile.Decls { 57 | genDecl, ok := decl.(*ast.GenDecl) 58 | if !ok { 59 | continue 60 | } 61 | if genDecl.Tok != token.TYPE { 62 | continue 63 | } 64 | for _, spec := range genDecl.Specs { 65 | structSpec, ok := spec.(*ast.TypeSpec) 66 | if !ok { 67 | continue 68 | } 69 | structType, ok := structSpec.Type.(*ast.StructType) 70 | if !ok { 71 | continue 72 | } 73 | lineNumber := fileSet.Position(structType.End()).Line - 1 74 | if !strings.Contains(lines[lineNumber], "// @name") { 75 | lines[lineNumber] = strings.TrimSpace(lines[lineNumber]) + " // @name " + structSpec.Name.Name + "\n" 76 | } 77 | } 78 | } 79 | file, err = os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, 0666) 80 | if err != nil { 81 | panic(err) 82 | } 83 | defer file.Close() 84 | writer := bufio.NewWriter(file) 85 | for _, line := range lines { 86 | _, err = writer.WriteString(line) 87 | if err != nil { 88 | panic(err) 89 | } 90 | } 91 | err = writer.Flush() 92 | if err != nil { 93 | panic(err) 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /global/cron/scheduler.go: -------------------------------------------------------------------------------- 1 | package cron 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "github.com/ZiRunHua/LeapLedger/global/lock" 7 | "github.com/ZiRunHua/LeapLedger/global/nats" 8 | "go.uber.org/zap" 9 | "runtime/debug" 10 | ) 11 | 12 | const lockKeyPrefix = "cron:" 13 | const publishLockKeyPrefix = lockKeyPrefix + "publish:" 14 | const taskPublishLockKeyPrefix = publishLockKeyPrefix + "task:" 15 | 16 | func PublishTask(task nats.Task) func() { 17 | return MakeOnceJob( 18 | lock.Key(taskPublishLockKeyPrefix+string(task)), 19 | func() error { 20 | isSuccess := nats.PublishTask(task) 21 | if !isSuccess { 22 | return errors.New("publish fail") 23 | } 24 | return nil 25 | }, 26 | ) 27 | } 28 | 29 | func PublishTaskWithPayload[T nats.PayloadType](task nats.Task, payload T) func() { 30 | return MakeOnceJob( 31 | lock.Key(taskPublishLockKeyPrefix+string(task)), 32 | func() error { 33 | isSuccess := nats.PublishTaskWithPayload[T](task, payload) 34 | if !isSuccess { 35 | return errors.New("publish fail") 36 | } 37 | return nil 38 | }, 39 | ) 40 | } 41 | 42 | func PublishTaskWithMakePayload[T nats.PayloadType](task nats.Task, makePayload func() (T, error)) func() { 43 | return MakeOnceJob( 44 | lock.Key(taskPublishLockKeyPrefix+string(task)), 45 | func() error { 46 | payload, err := makePayload() 47 | if err != nil { 48 | return err 49 | } 50 | isSuccess := nats.PublishTaskWithPayload[T](task, payload) 51 | if !isSuccess { 52 | return errors.New("publish fail") 53 | } 54 | return nil 55 | }, 56 | ) 57 | } 58 | 59 | func MakeOnceJob(key lock.Key, f func() error) func() { 60 | return MakeJobFunc( 61 | func() error { 62 | l := lock.New(key) 63 | err := l.Lock(context.Background()) 64 | if err != nil { 65 | if errors.Is(err, lock.ErrLockOccupied) { 66 | return nil 67 | } 68 | return err 69 | } 70 | defer l.Release(context.Background()) 71 | return f() 72 | }, 73 | ) 74 | } 75 | 76 | func MakeJobFunc(f func() error) func() { 77 | return func() { 78 | defer func() { 79 | r := recover() 80 | if r != nil { 81 | logger.Error("job exec panic", zap.Any("panic", r), zap.Stack(string(debug.Stack()))) 82 | } 83 | }() 84 | err := f() 85 | if err != nil { 86 | logger.Error("job exec error", zap.Error(err)) 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /model/transaction/statisticModel.go: -------------------------------------------------------------------------------- 1 | package transactionModel 2 | 3 | import ( 4 | commonModel "github.com/ZiRunHua/LeapLedger/model/common" 5 | "gorm.io/gorm" 6 | "time" 7 | ) 8 | 9 | type statisticModel interface { 10 | GetUpdatesValue(amount, count int) map[string]interface{} 11 | GetDate(tradeTime time.Time) time.Time 12 | TableName() string 13 | } 14 | 15 | type Statistic struct { 16 | Date time.Time `gorm:"primaryKey;type:TIMESTAMP"` 17 | Amount int 18 | Count int 19 | commonModel.BaseModel 20 | } 21 | 22 | func (s *Statistic) GetUpdatesValue(amount, count int) map[string]interface{} { 23 | return map[string]interface{}{ 24 | "amount": gorm.Expr("amount + ?", amount), 25 | "count": gorm.Expr("count + ?", count), 26 | } 27 | } 28 | 29 | func (s *Statistic) GetDate(t time.Time) time.Time { 30 | return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()) 31 | } 32 | 33 | func ExpenseAccumulate( 34 | tradeTime time.Time, accountId uint, userId uint, categoryId uint, amount int, count int, tx *gorm.DB, 35 | ) error { 36 | var err error 37 | var accountSta ExpenseAccountStatistic 38 | err = accountSta.Accumulate(tradeTime, accountId, amount, count, tx) 39 | if err != nil { 40 | return err 41 | } 42 | var accountUserSta ExpenseAccountUserStatistic 43 | err = accountUserSta.Accumulate(tradeTime, accountId, userId, categoryId, amount, count, tx) 44 | if err != nil { 45 | return err 46 | } 47 | var categorySta ExpenseCategoryStatistic 48 | err = categorySta.Accumulate(tradeTime, accountId, categoryId, amount, count, tx) 49 | if err != nil { 50 | return err 51 | } 52 | return err 53 | } 54 | 55 | func IncomeAccumulate( 56 | tradeTime time.Time, accountId uint, userId uint, categoryId uint, amount int, count int, tx *gorm.DB, 57 | ) error { 58 | var err error 59 | var accountSta IncomeAccountStatistic 60 | err = accountSta.Accumulate(tradeTime, accountId, amount, count, tx) 61 | if err != nil { 62 | return err 63 | } 64 | var accountUserSta IncomeAccountUserStatistic 65 | err = accountUserSta.Accumulate(tradeTime, accountId, userId, categoryId, amount, count, tx) 66 | if err != nil { 67 | return err 68 | } 69 | var categorySta IncomeCategoryStatistic 70 | err = categorySta.Accumulate(tradeTime, accountId, categoryId, amount, count, tx) 71 | if err != nil { 72 | return err 73 | } 74 | return err 75 | } 76 | -------------------------------------------------------------------------------- /model/product/productTransactionCategoryModel.go: -------------------------------------------------------------------------------- 1 | package productModel 2 | 3 | import ( 4 | "github.com/ZiRunHua/LeapLedger/global" 5 | "github.com/ZiRunHua/LeapLedger/global/constant" 6 | commonModel "github.com/ZiRunHua/LeapLedger/model/common" 7 | ) 8 | 9 | type TransactionCategory struct { 10 | ID uint `gorm:"primary_key"` 11 | ProductKey string `gorm:"uniqueIndex:unique_name,priority:1"` 12 | IncomeExpense constant.IncomeExpense `gorm:"size:8;comment:'收支类型';uniqueIndex:unique_name,priority:2"` 13 | Name string `gorm:"size:64;uniqueIndex:unique_name,priority:3"` 14 | commonModel.BaseModel 15 | } 16 | 17 | func (tc *TransactionCategory) TableName() string { 18 | return "product_transaction_category" 19 | } 20 | 21 | func (tc *TransactionCategory) IsEmpty() bool { 22 | return tc == nil || tc.ID == 0 23 | } 24 | 25 | func (tc *TransactionCategory) SelectById(id uint) error { 26 | return global.GvaDb.First(tc, id).Error 27 | } 28 | 29 | func (tc *TransactionCategory) GetMap(productKey Key) (map[uint]TransactionCategory, error) { 30 | transCategoryMap := make(map[uint]TransactionCategory) 31 | var prodTransCategory TransactionCategory 32 | rows, err := global.GvaDb.Model(&prodTransCategory).Where( 33 | "product_key = ? ", productKey, 34 | ).Rows() 35 | if err != nil { 36 | return nil, err 37 | } 38 | for rows.Next() { 39 | err = global.GvaDb.ScanRows(rows, &prodTransCategory) 40 | if err != nil { 41 | return nil, err 42 | } 43 | transCategoryMap[prodTransCategory.ID] = prodTransCategory 44 | } 45 | return transCategoryMap, nil 46 | } 47 | 48 | func (tc *TransactionCategory) GetIncomeExpenseAndNameMap(productKey Key) ( 49 | result map[constant.IncomeExpense]map[string]TransactionCategory, err error, 50 | ) { 51 | var prodTransCategory TransactionCategory 52 | rows, err := global.GvaDb.Model(&prodTransCategory).Where( 53 | "product_key = ? ", productKey, 54 | ).Rows() 55 | if err != nil { 56 | return 57 | } 58 | result = map[constant.IncomeExpense]map[string]TransactionCategory{} 59 | for rows.Next() { 60 | err = global.GvaDb.ScanRows(rows, &prodTransCategory) 61 | if err != nil { 62 | return 63 | } 64 | if _, exist := result[prodTransCategory.IncomeExpense]; exist == false { 65 | result[prodTransCategory.IncomeExpense] = map[string]TransactionCategory{prodTransCategory.Name: prodTransCategory} 66 | } else { 67 | result[prodTransCategory.IncomeExpense][prodTransCategory.Name] = prodTransCategory 68 | } 69 | } 70 | return 71 | } 72 | -------------------------------------------------------------------------------- /initialize/redis.go: -------------------------------------------------------------------------------- 1 | package initialize 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "time" 7 | 8 | "github.com/ZiRunHua/LeapLedger/global/constant" 9 | "github.com/ZiRunHua/LeapLedger/util" 10 | "github.com/go-redis/redis/v8" 11 | ) 12 | 13 | type _redis struct { 14 | Addr string `yaml:"Addr"` 15 | Password string `yaml:"Password"` 16 | Db int `yaml:"Db"` 17 | LockDb int `yaml:"LockDb"` 18 | } 19 | 20 | var Rdb, LockRdb *redis.Client 21 | 22 | func (r *_redis) do() error { 23 | if len(r.Addr) == 0 { 24 | return nil 25 | } 26 | var err error 27 | Rdb, err = r.getNewRedisClient("", r.Db) 28 | if err != nil { 29 | return err 30 | } 31 | LockRdb, err = r.getNewRedisClient("lock", r.LockDb) 32 | if err != nil { 33 | return err 34 | } 35 | Cache = &util.RedisCache{DB: r.Db, Addr: r.Addr, Password: r.Password} 36 | return Cache.Init() 37 | } 38 | 39 | func (r *_redis) getNewRedisClient(name string, dbNum int) (*redis.Client, error) { 40 | connect := func() (*redis.Client, error) { 41 | db := redis.NewClient(&redis.Options{Addr: r.Addr, Password: r.Password, DB: dbNum}) 42 | ctx, cancel := context.WithTimeout(context.TODO(), time.Second*3) 43 | defer cancel() 44 | return db, db.Ping(ctx).Err() 45 | } 46 | db, err := reconnection[*redis.Client](connect, 3) 47 | if err != nil { 48 | return db, err 49 | } 50 | if Config.Mode == constant.Debug { 51 | db.AddHook(&RedisHook{name: name}) 52 | } 53 | return db, err 54 | } 55 | 56 | type RedisHook struct { 57 | name string 58 | } 59 | 60 | func (rh RedisHook) BeforeProcess(ctx context.Context, cmd redis.Cmder) (context.Context, error) { 61 | if len(rh.name) == 0 { 62 | log.Printf("exec => <%s>\n", cmd) 63 | } else { 64 | log.Printf("%s exec => <%s>\n", rh.name, cmd) 65 | } 66 | return ctx, nil 67 | } 68 | 69 | func (rh RedisHook) AfterProcess(_ context.Context, cmd redis.Cmder) error { 70 | if len(rh.name) == 0 { 71 | log.Printf("finish => <%s>\n", cmd) 72 | } else { 73 | log.Printf("%s finish => <%s>\n", rh.name, cmd) 74 | } 75 | return nil 76 | } 77 | 78 | func (rh RedisHook) BeforeProcessPipeline(ctx context.Context, cmds []redis.Cmder) (context.Context, error) { 79 | if len(rh.name) == 0 { 80 | log.Printf("pipeline exec => %v\n", cmds) 81 | } else { 82 | log.Printf("%s pipeline exec => %v\n", rh.name, cmds) 83 | } 84 | return ctx, nil 85 | } 86 | 87 | func (rh RedisHook) AfterProcessPipeline(_ context.Context, cmds []redis.Cmder) error { 88 | if len(rh.name) == 0 { 89 | log.Printf("pipeline finish => %v\n", cmds) 90 | } else { 91 | log.Printf("%s pipeline finish => %v\n", rh.name, cmds) 92 | } 93 | return nil 94 | } 95 | -------------------------------------------------------------------------------- /model/product/productDao.go: -------------------------------------------------------------------------------- 1 | package productModel 2 | 3 | import ( 4 | "github.com/ZiRunHua/LeapLedger/global" 5 | "github.com/ZiRunHua/LeapLedger/global/constant" 6 | "gorm.io/gorm" 7 | ) 8 | 9 | type ProductDao struct { 10 | db *gorm.DB 11 | } 12 | 13 | func NewDao(db ...*gorm.DB) *ProductDao { 14 | if len(db) > 0 { 15 | return &ProductDao{db: db[0]} 16 | } 17 | return &ProductDao{global.GvaDb} 18 | } 19 | func (pd *ProductDao) SelectByKey(key Key) (result Product, err error) { 20 | return result, pd.db.Where("`key` = ?", key).First(&result).Error 21 | } 22 | 23 | func (pd *ProductDao) SelectBillByKey(key Key) (result Bill, err error) { 24 | return result, pd.db.Where("`product_key` = ?", key).First(&result).Error 25 | } 26 | func (pd *ProductDao) SelectCategoryByName( 27 | key Key, ie constant.IncomeExpense, name string, 28 | ) (result TransactionCategory, err error) { 29 | err = pd.db.Where("product_key = ? AND income_expense = ? AND name = ?", key, ie, name).First(&result).Error 30 | return 31 | } 32 | 33 | func (pd *ProductDao) SelectAllCategoryMappingByCategoryId(categoryId uint) ( 34 | result []TransactionCategoryMapping, err error, 35 | ) { 36 | err = pd.db.Where("category_id = ?", categoryId).Find(&result).Error 37 | return 38 | } 39 | func (pd *ProductDao) GetPtcIdMapping(accountId uint, productKey Key) ( 40 | result map[uint]TransactionCategoryMapping, err error, 41 | ) { 42 | var list []TransactionCategoryMapping 43 | err = pd.db.Where("account_id = ? AND product_key = ?", accountId, productKey).Find(&list).Error 44 | if err != nil { 45 | return 46 | } 47 | result = make(map[uint]TransactionCategoryMapping) 48 | for _, mapping := range list { 49 | result[mapping.PtcId] = mapping 50 | } 51 | return 52 | } 53 | 54 | func (pd *ProductDao) GetIncomeExpenseAndNameMap(productKey Key) ( 55 | result map[constant.IncomeExpense]map[string]TransactionCategory, err error, 56 | ) { 57 | var list []TransactionCategory 58 | err = pd.db.Where("product_key = ? ", productKey).Find(&list).Error 59 | if err != nil { 60 | return 61 | } 62 | result = make(map[constant.IncomeExpense]map[string]TransactionCategory) 63 | for _, category := range list { 64 | if _, exist := result[category.IncomeExpense]; exist == false { 65 | result[category.IncomeExpense] = map[string]TransactionCategory{category.Name: category} 66 | } else { 67 | result[category.IncomeExpense][category.Name] = category 68 | } 69 | } 70 | return 71 | } 72 | func (pd *ProductDao) GetBillHeaderList(productKey Key) (list []BillHeader, err error) { 73 | err = pd.db.Where("product_key = ? ", productKey).Order("id ASC").Find(&list).Error 74 | return 75 | } 76 | -------------------------------------------------------------------------------- /api/v1/ws/msg/reader.go: -------------------------------------------------------------------------------- 1 | package msg 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "io" 7 | "sync" 8 | 9 | "github.com/ZiRunHua/LeapLedger/util/fileTool" 10 | "github.com/gorilla/websocket" 11 | ) 12 | 13 | func NewReader() Reader { 14 | return &reader{MsgHandle: make(map[MsgType]MsgHandler)} 15 | } 16 | 17 | func RegisterHandle[T any](reader Reader, msgType MsgType, handler func(data T) error) { 18 | reader.registerHandle( 19 | msgType, func(bytes []byte) error { 20 | var data T 21 | err := json.Unmarshal(bytes, &data) 22 | if err != nil { 23 | return err 24 | } 25 | return handler(data) 26 | }, 27 | ) 28 | } 29 | 30 | func ForReadAndHandleJsonMsg(reader Reader, conn *websocket.Conn) error { 31 | var err error 32 | for { 33 | err = reader.readJsonMsgToHandle(conn) 34 | if err != nil { 35 | return err 36 | } 37 | } 38 | } 39 | 40 | func ReadBytes(reader Reader, conn *websocket.Conn) ([]byte, error) { 41 | return reader.readBytes(conn) 42 | } 43 | 44 | func ReadFile(reader Reader, conn *websocket.Conn) (io.Reader, error) { 45 | return reader.readFile(conn) 46 | } 47 | 48 | type readMsg struct { 49 | Type MsgType 50 | Data json.RawMessage 51 | } 52 | 53 | type Reader interface { 54 | registerHandle(msgType MsgType, handler MsgHandler) 55 | readJsonMsgToHandle(conn *websocket.Conn) error 56 | readBytes(conn *websocket.Conn) ([]byte, error) 57 | readFile(conn *websocket.Conn) (io.Reader, error) 58 | } 59 | 60 | type reader struct { 61 | Reader 62 | MsgHandle map[MsgType]MsgHandler 63 | lock sync.Mutex 64 | } 65 | 66 | func (mr *reader) registerHandle(msgType MsgType, handler MsgHandler) { 67 | mr.lock.Lock() 68 | defer mr.lock.Unlock() 69 | mr.MsgHandle[msgType] = handler 70 | } 71 | 72 | func (mr *reader) readJsonMsgToHandle(conn *websocket.Conn) error { 73 | var msg readMsg 74 | _, str, err := conn.ReadMessage() 75 | if err != nil { 76 | return err 77 | } 78 | err = json.Unmarshal(str, &msg) 79 | if err != nil { 80 | return err 81 | } 82 | handle, exist := mr.MsgHandle[msg.Type] 83 | if !exist { 84 | return ErrMsgHandleNotExist 85 | } 86 | return handle(msg.Data) 87 | } 88 | 89 | func (mr *reader) readBytes(conn *websocket.Conn) ([]byte, error) { 90 | _, str, err := conn.ReadMessage() 91 | return str, err 92 | } 93 | func (mr *reader) readFile(conn *websocket.Conn) (io.Reader, error) { 94 | _, fileData, err := conn.ReadMessage() 95 | if err != nil { 96 | return nil, err 97 | } 98 | r := bytes.NewBuffer(fileData) 99 | return fileTool.GetUTF8Reader(r, fileData) 100 | } 101 | -------------------------------------------------------------------------------- /test/service/transaction/base_test.go: -------------------------------------------------------------------------------- 1 | package transaction 2 | 3 | import ( 4 | "context" 5 | 6 | _ "github.com/ZiRunHua/LeapLedger/test/initialize" 7 | ) 8 | import ( 9 | "reflect" 10 | "testing" 11 | "time" 12 | 13 | "github.com/ZiRunHua/LeapLedger/global" 14 | "github.com/ZiRunHua/LeapLedger/global/constant" 15 | "github.com/ZiRunHua/LeapLedger/global/nats" 16 | accountModel "github.com/ZiRunHua/LeapLedger/model/account" 17 | transactionModel "github.com/ZiRunHua/LeapLedger/model/transaction" 18 | "github.com/ZiRunHua/LeapLedger/test" 19 | ) 20 | 21 | func TestCreate(t *testing.T) { 22 | transInfo := test.NewTransInfo() 23 | user, err := accountModel.NewDao().SelectUser(transInfo.AccountId, transInfo.UserId) 24 | if err != nil { 25 | t.Fatal(err) 26 | } 27 | builder := transactionModel.NewStatisticConditionBuilder(transInfo.AccountId) 28 | builder.WithUserIds([]uint{transInfo.UserId}).WithCategoryIds([]uint{transInfo.CategoryId}) 29 | builder.WithDate(transInfo.TradeTime, transInfo.TradeTime) 30 | total, err := transactionModel.NewDao().GetIeStatisticByCondition(&transInfo.IncomeExpense, *builder.Build(), nil) 31 | if err != nil { 32 | t.Fatal(err) 33 | } 34 | var trans transactionModel.Transaction 35 | createOption, err := service.NewOptionFormConfig(transInfo, context.TODO()) 36 | if err != nil { 37 | t.Fatal(err) 38 | } 39 | trans, err = service.Create(transInfo, user, transactionModel.RecordTypeOfManual, createOption, context.TODO()) 40 | if err != nil { 41 | t.Fatal(err) 42 | } 43 | time.Sleep(time.Second * 10) 44 | newTotal, err := transactionModel.NewDao().GetIeStatisticByCondition( 45 | &transInfo.IncomeExpense, *builder.Build(), nil, 46 | ) 47 | if err != nil { 48 | t.Fatal(err) 49 | } 50 | 51 | if transInfo.IncomeExpense == constant.Income { 52 | total.Income.Amount += int64(trans.Amount) 53 | total.Income.Count++ 54 | } else { 55 | total.Expense.Amount += int64(trans.Amount) 56 | total.Expense.Count++ 57 | } 58 | if !reflect.DeepEqual(total, newTotal) { 59 | t.Fatal("total not equal", total, newTotal) 60 | } else { 61 | t.Log("pass", total, newTotal) 62 | } 63 | } 64 | 65 | func TestAll(t *testing.T) { 66 | var transaction transactionModel.Transaction 67 | rows, err := global.GvaDb.Model(&transactionModel.Transaction{}).Rows() 68 | defer func() { 69 | err = rows.Close() 70 | if err != nil { 71 | t.Fatal(err) 72 | } 73 | }() 74 | if err != nil { 75 | t.Fatal(err) 76 | } 77 | for rows.Next() { 78 | err = global.GvaDb.ScanRows(rows, &transaction) 79 | if err != nil { 80 | t.Fatal(err) 81 | } 82 | nats.PublishTaskWithPayload(nats.TaskStatisticUpdate, transaction.GetStatisticData(true)) 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /initialize/initialize.go: -------------------------------------------------------------------------------- 1 | package initialize 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "path/filepath" 7 | "time" 8 | 9 | "github.com/ZiRunHua/LeapLedger/global/constant" 10 | "github.com/ZiRunHua/LeapLedger/util" 11 | 12 | "github.com/go-co-op/gocron" 13 | "github.com/nats-io/nats.go" 14 | "go.uber.org/zap" 15 | "golang.org/x/sync/errgroup" 16 | "gopkg.in/yaml.v3" 17 | "gorm.io/gorm" 18 | ) 19 | 20 | type _config struct { 21 | Mode constant.ServerMode `yaml:"Mode"` 22 | Redis _redis `yaml:"Redis"` 23 | Mysql _mysql `yaml:"Mysql"` 24 | Nats _nats `yaml:"Nats"` 25 | Scheduler _scheduler `yaml:"Scheduler"` 26 | Logger _logger `yaml:"Logger"` 27 | System _system `yaml:"System"` 28 | Captcha _captcha `yaml:"Captcha"` 29 | ThirdParty _thirdParty `yaml:"ThirdParty"` 30 | } 31 | 32 | var ( 33 | Config *_config 34 | Cache util.Cache 35 | Db *gorm.DB 36 | Nats *nats.Conn 37 | Scheduler *gocron.Scheduler 38 | RequestLogger *zap.Logger 39 | ErrorLogger *zap.Logger 40 | PanicLogger *zap.Logger 41 | ) 42 | 43 | func init() { 44 | var err error 45 | Config = &_config{ 46 | Redis: _redis{}, Mysql: _mysql{}, Logger: _logger{}, System: _system{}, Captcha: _captcha{}, Nats: _nats{}, 47 | ThirdParty: _thirdParty{WeCom: _weCom{}}, 48 | } 49 | 50 | if err = initConfig(); err != nil { 51 | panic(err) 52 | } 53 | 54 | group, _ := errgroup.WithContext(context.TODO()) 55 | group.Go(Config.Logger.do) 56 | group.Go(Config.Mysql.do) 57 | group.Go(Config.Redis.do) 58 | group.Go(Config.Nats.do) 59 | group.Go(Config.Scheduler.do) 60 | if err = group.Wait(); err != nil { 61 | panic(err) 62 | } 63 | } 64 | 65 | func initConfig() error { 66 | configFileName := os.Getenv("CONFIG_FILE_NAME") 67 | if len(configFileName) == 0 { 68 | configFileName = "config.yaml" 69 | } 70 | configPath := filepath.Join(constant.RootDir, configFileName) 71 | yamlFile, err := os.ReadFile(configPath) 72 | if err != nil { 73 | return err 74 | } 75 | err = yaml.Unmarshal(yamlFile, Config) 76 | if err != nil { 77 | return err 78 | } 79 | return setConfigDefault() 80 | } 81 | func setConfigDefault() error { 82 | if Config.System.LockMode == "" { 83 | Config.System.LockMode = "redis" 84 | } 85 | return nil 86 | } 87 | func reconnection[T any](connect func() (T, error), retryTimes int) (result T, err error) { 88 | defer func() { 89 | if err != nil && retryTimes > 0 { 90 | time.Sleep(time.Second * 3) 91 | result, err = reconnection[T](connect, retryTimes-1) 92 | } 93 | }() 94 | result, err = connect() 95 | return 96 | } 97 | -------------------------------------------------------------------------------- /model/user/userModel.go: -------------------------------------------------------------------------------- 1 | package userModel 2 | 3 | import ( 4 | "errors" 5 | "github.com/ZiRunHua/LeapLedger/global" 6 | "github.com/ZiRunHua/LeapLedger/global/constant" 7 | commonModel "github.com/ZiRunHua/LeapLedger/model/common" 8 | "gorm.io/gorm" 9 | "strconv" 10 | "time" 11 | ) 12 | 13 | type User struct { 14 | ID uint `gorm:"primarykey"` 15 | Username string `gorm:"type:varchar(128);comment:'用户名'"` 16 | Password string `gorm:"type:char(64);comment:'密码'"` 17 | Email string `gorm:"type:varchar(64);comment:'邮箱';unique"` 18 | CreatedAt time.Time `gorm:"type:TIMESTAMP"` 19 | UpdatedAt time.Time `gorm:"type:TIMESTAMP"` 20 | DeletedAt gorm.DeletedAt `gorm:"index;type:TIMESTAMP"` 21 | commonModel.BaseModel 22 | } 23 | 24 | type UserInfo struct { 25 | ID uint 26 | Username string 27 | Email string 28 | } 29 | 30 | func (u *User) SelectById(id uint, selects ...interface{}) error { 31 | query := global.GvaDb.Where("id = ?", id) 32 | if len(selects) > 0 { 33 | query = query.Select(selects[0], selects[1:]...) 34 | } 35 | return query.First(u).Error 36 | } 37 | 38 | func (u *User) GetUserClient(client constant.Client, db *gorm.DB) (clientInfo UserClientBaseInfo, err error) { 39 | var data UserClientBaseInfo 40 | err = db.Model(GetUserClientModel(client)).Where("user_id = ?", u.ID).First(&data).Error 41 | return data, err 42 | } 43 | 44 | func (u *User) IsTourist(db *gorm.DB) (bool, error) { 45 | _, err := NewDao(db).SelectTour(u.ID) 46 | if err != nil { 47 | if errors.Is(err, gorm.ErrRecordNotFound) { 48 | return false, nil 49 | } 50 | return false, err 51 | } 52 | return true, nil 53 | } 54 | 55 | func (u *User) ModifyAsTourist(db *gorm.DB) error { 56 | return db.Model(u).Updates( 57 | map[string]interface{}{ 58 | "username": "游玩家", 59 | "email": "player" + strconv.Itoa(int(u.ID)), 60 | }, 61 | ).Error 62 | } 63 | 64 | func (u *User) GetTransactionShareConfig() (TransactionShareConfig, error) { 65 | data := TransactionShareConfig{} 66 | return data, data.SelectByUserId(u.ID) 67 | } 68 | 69 | type Tour struct { 70 | UserId uint `gorm:"primary"` 71 | Status bool 72 | CreatedAt time.Time `gorm:"type:TIMESTAMP"` 73 | UpdatedAt time.Time `gorm:"type:TIMESTAMP"` 74 | DeletedAt gorm.DeletedAt `gorm:"index;type:TIMESTAMP"` 75 | commonModel.BaseModel 76 | } 77 | 78 | func (u *Tour) TableName() string { 79 | return "user_tour" 80 | } 81 | func (t *Tour) GetUser(db *gorm.DB) (user User, err error) { 82 | err = db.First(&user, t.UserId).Error 83 | return user, err 84 | } 85 | func (t *Tour) Use(db *gorm.DB) error { 86 | if t.Status == true { 87 | return errors.New("tourist used") 88 | } 89 | return db.Model(t).Where("user_id = ?", t.UserId).Update("status", true).Error 90 | } 91 | -------------------------------------------------------------------------------- /util/timeTool/timeFunc.go: -------------------------------------------------------------------------------- 1 | package timeTool 2 | 3 | import "time" 4 | 5 | func SplitMonths(startDate, endDate time.Time) [][2]time.Time { 6 | var months [][2]time.Time 7 | current := startDate 8 | for !current.Equal(endDate) { 9 | current = GetLastSecondOfMonth(startDate) 10 | if current.After(endDate) { 11 | current = endDate 12 | } 13 | months = append(months, [2]time.Time{startDate, current}) 14 | startDate = current.Add(time.Second) 15 | } 16 | return months 17 | } 18 | 19 | func SplitDays(startDate, endDate time.Time) []time.Time { 20 | startDate = time.Date(startDate.Year(), startDate.Month(), startDate.Day(), 0, 0, 0, 0, startDate.Location()) 21 | var days []time.Time 22 | current := startDate 23 | for !current.After(endDate) { 24 | days = append(days, current) 25 | current = current.AddDate(0, 0, 1) 26 | } 27 | return days 28 | } 29 | func GetFirstSecondOfDay(date time.Time) time.Time { 30 | year, month, day := date.Date() 31 | return time.Date(year, month, day, 0, 0, 0, 0, date.Location()) 32 | } 33 | 34 | func GetFirstSecondOfMonth(date time.Time) time.Time { 35 | year, month, _ := date.Date() 36 | return time.Date(year, month, 1, 0, 0, 0, 0, date.Location()) 37 | } 38 | 39 | func GetLastSecondOfMonth(date time.Time) time.Time { 40 | year, month, _ := date.Date() 41 | nextMonth := time.Date(year, month+1, 1, 0, 0, 0, 0, date.Location()) 42 | lastSecond := nextMonth.Add(-time.Second) 43 | return lastSecond 44 | } 45 | 46 | // 获取本周一的第一秒 47 | func GetFirstSecondOfWeek(currentTime time.Time) time.Time { 48 | weekday := currentTime.Weekday() 49 | daysToMonday := time.Duration(0) 50 | if weekday != time.Monday { 51 | daysToMonday = time.Duration(weekday - time.Monday) 52 | if weekday < time.Monday { 53 | daysToMonday += 7 54 | } 55 | } 56 | 57 | monday := currentTime.Add(-daysToMonday * 24 * time.Hour) 58 | monday = time.Date(monday.Year(), monday.Month(), monday.Day(), 0, 0, 0, 0, currentTime.Location()) 59 | return monday 60 | } 61 | 62 | func GetLastSecondOfWeek(t time.Time) time.Time { 63 | weekday := t.Weekday() 64 | daysToSunday := (time.Sunday - weekday) % 7 65 | if daysToSunday < 0 { 66 | daysToSunday += 7 67 | } 68 | sunday := t.AddDate(0, 0, int(daysToSunday)) 69 | sunday = time.Date(sunday.Year(), sunday.Month(), sunday.Day(), 23, 59, 59, 0, sunday.Location()) 70 | return sunday 71 | } 72 | 73 | // 获取今年的第一秒 74 | func GetFirstSecondOfYear(currentTime time.Time) time.Time { 75 | return time.Date(currentTime.Year(), time.January, 1, 0, 0, 0, 0, currentTime.Location()) 76 | } 77 | 78 | func GetLastSecondOfYear(currentTime time.Time) time.Time { 79 | return time.Date(currentTime.Year(), time.December, 31, 23, 59, 59, 0, currentTime.Location()) 80 | } 81 | 82 | func ToDay(t time.Time) time.Time { 83 | return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()) 84 | } 85 | -------------------------------------------------------------------------------- /api/response/func.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | import ( 4 | "fmt" 5 | "github.com/ZiRunHua/LeapLedger/global" 6 | "github.com/gin-gonic/gin" 7 | "github.com/pkg/errors" 8 | "gorm.io/gorm" 9 | ) 10 | 11 | type Data struct { 12 | Data interface{} 13 | Msg string `example:"success"` 14 | } // @name Response 15 | 16 | type NoContent struct { 17 | Data interface{} 18 | Msg string 19 | } // @name NoContent 20 | 21 | func ResponseAndAbort(status int, data interface{}, msg string, ctx *gin.Context) { 22 | ctx.AbortWithStatusJSON( 23 | status, Data{ 24 | data, 25 | msg, 26 | }, 27 | ) 28 | } 29 | 30 | func Response(status int, data interface{}, msg string, ctx *gin.Context) { 31 | ctx.JSON( 32 | status, Data{ 33 | data, 34 | msg, 35 | }, 36 | ) 37 | } 38 | 39 | func Ok(ctx *gin.Context) { 40 | Response(204, map[string]interface{}{}, "操作成功", ctx) 41 | } 42 | 43 | func OkWithMessage(message string, ctx *gin.Context) { 44 | Response(200, map[string]interface{}{}, message, ctx) 45 | } 46 | 47 | func OkWithData(data interface{}, ctx *gin.Context) { 48 | Response(200, data, "查询成功", ctx) 49 | } 50 | 51 | func OkWithDetailed(data interface{}, message string, ctx *gin.Context) { 52 | Response(200, data, message, ctx) 53 | } 54 | 55 | func Fail(ctx *gin.Context) { 56 | ResponseAndAbort(500, map[string]interface{}{}, "服务器睡了(这年龄你睡得着!)", ctx) 57 | } 58 | func FailToParameter(ctx *gin.Context, err error) { 59 | ResponseAndAbort(400, map[string]interface{}{}, "参数错误"+err.Error(), ctx) 60 | } 61 | 62 | func FailToError(ctx *gin.Context, err error) { 63 | logError(ctx, err) 64 | msg := err.Error() 65 | if errors.Is(err, gorm.ErrRecordNotFound) { 66 | msg = "数据未找到" 67 | } 68 | ResponseAndAbort(500, map[string]interface{}{}, msg, ctx) 69 | } 70 | 71 | func FailWithMessage(message string, ctx *gin.Context) { 72 | ResponseAndAbort(500, map[string]interface{}{}, message, ctx) 73 | } 74 | 75 | func FrequentOperation(ctx *gin.Context) { 76 | ResponseAndAbort(500, map[string]interface{}{}, "请勿频繁操作,请稍后再试!", ctx) 77 | } 78 | 79 | func FailWithDetailed(data interface{}, message string, ctx *gin.Context) { 80 | ResponseAndAbort(500, data, message, ctx) 81 | } 82 | 83 | func Forbidden(ctx *gin.Context) { 84 | ResponseAndAbort(403, map[string]interface{}{}, "无权访问", ctx) 85 | } 86 | 87 | func TokenExpired(ctx *gin.Context) { 88 | ResponseAndAbort(401, map[string]interface{}{}, "token expired", ctx) 89 | } 90 | 91 | func logError(ctx *gin.Context, err error) { 92 | reqMethod := ctx.Request.Method 93 | reqPath := ctx.Request.URL.Path 94 | clientIP := ctx.ClientIP() 95 | global.ErrorLogger.Error( 96 | fmt.Sprintf( 97 | "%s %s %s error: %+v\n", 98 | reqMethod, 99 | reqPath, 100 | clientIP, 101 | err, 102 | ), 103 | ) 104 | } 105 | -------------------------------------------------------------------------------- /util/dataTool/map.go: -------------------------------------------------------------------------------- 1 | package dataTool 2 | 3 | import ( 4 | "sync" 5 | ) 6 | 7 | type Map[K comparable, V any] interface { 8 | Load(K) (V, bool) 9 | Store(K, V) 10 | LoadOrStore(K, V) (actual V, loaded bool) 11 | Delete(K) 12 | Range(func(K, V) (shouldContinue bool)) 13 | } 14 | 15 | // SyncMap is type-safe sync.Map 16 | type SyncMap[K comparable, V any] struct { 17 | Map sync.Map 18 | } 19 | 20 | func NewSyncMap[K comparable, V any]() *SyncMap[K, V] { 21 | return &SyncMap[K, V]{} 22 | } 23 | func (t *SyncMap[K, V]) Load(k K) (V, bool) { 24 | value, ok := t.Map.Load(k) 25 | if value != nil { 26 | return value.(V), ok 27 | } 28 | var v V 29 | return v, ok 30 | } 31 | 32 | func (t *SyncMap[K, V]) Store(k K, v V) { 33 | t.Map.Store(k, v) 34 | } 35 | 36 | func (t *SyncMap[K, V]) LoadOrStore(k K, v V) (actual V, loaded bool) { 37 | value, ok := t.Map.LoadOrStore(k, v) 38 | return value.(V), ok 39 | } 40 | 41 | func (t *SyncMap[K, V]) CompareAndSwap(k K, old, new V) (swapped bool) { 42 | return t.Map.CompareAndSwap(k, old, new) 43 | } 44 | 45 | func (t *SyncMap[K, V]) CompareAndDelete(old, new V) (deleted bool) { 46 | return t.Map.CompareAndDelete(old, new) 47 | } 48 | 49 | func (t *SyncMap[K, V]) Delete(key K) { 50 | t.Map.Delete(key) 51 | } 52 | 53 | func (t *SyncMap[K, V]) Range(f func(key K, value V) bool) { 54 | t.Map.Range( 55 | func(key, value any) bool { 56 | return f(key.(K), value.(V)) 57 | }, 58 | ) 59 | } 60 | 61 | type RWMutexMap[K comparable, V any] struct { 62 | mu sync.RWMutex 63 | m map[K]V 64 | } 65 | 66 | func NewRWMutexMap[K comparable, V any]() *RWMutexMap[K, V] { 67 | return &RWMutexMap[K, V]{ 68 | m: make(map[K]V), 69 | } 70 | } 71 | 72 | func (sm *RWMutexMap[K, V]) Load(key K) (value V, ok bool) { 73 | sm.mu.RLock() 74 | defer sm.mu.RUnlock() 75 | value, ok = sm.m[key] 76 | return 77 | } 78 | 79 | func (sm *RWMutexMap[K, V]) Store(key K, value V) { 80 | sm.mu.Lock() 81 | defer sm.mu.Unlock() 82 | sm.m[key] = value 83 | } 84 | 85 | func (sm *RWMutexMap[K, V]) LoadOrStore(key K, value V) (actual V, loaded bool) { 86 | sm.mu.Lock() 87 | defer sm.mu.Unlock() 88 | 89 | if actual, loaded = sm.m[key]; loaded { 90 | return actual, true 91 | } 92 | 93 | sm.m[key] = value 94 | return value, false 95 | } 96 | 97 | func (sm *RWMutexMap[K, V]) Delete(key K) { 98 | sm.mu.Lock() 99 | defer sm.mu.Unlock() 100 | delete(sm.m, key) 101 | } 102 | 103 | func (sm *RWMutexMap[K, V]) Range(f func(key K, value V) (shouldContinue bool)) { 104 | sm.mu.RLock() 105 | defer sm.mu.RUnlock() 106 | 107 | for k, v := range sm.m { 108 | if !f(k, v) { 109 | break 110 | } 111 | } 112 | } 113 | 114 | func (sm *RWMutexMap[K, V]) Len() int { 115 | sm.mu.RLock() 116 | defer sm.mu.RUnlock() 117 | return len(sm.m) 118 | } 119 | -------------------------------------------------------------------------------- /global/nats/event.go: -------------------------------------------------------------------------------- 1 | package nats 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | 8 | "github.com/ZiRunHua/LeapLedger/global/cus" 9 | "github.com/ZiRunHua/LeapLedger/global/db" 10 | "github.com/ZiRunHua/LeapLedger/global/nats/manager" 11 | transactionModel "github.com/ZiRunHua/LeapLedger/model/transaction" 12 | ) 13 | 14 | type Event = manager.Event 15 | 16 | const EventOutbox Event = "outbox" 17 | const EventTransactionCreate Event = "transaction_create_event" 18 | const EventTransactionUpdate Event = "transaction_update_event" 19 | 20 | type EventTransactionUpdatePayload struct { 21 | OldTrans, NewTrans transactionModel.Transaction 22 | } 23 | 24 | const EventTransactionDelete Event = "transaction_delete_event" 25 | 26 | func PublishEvent(event Event) (isSuccess bool) { 27 | return eventManage.Publish(event, []byte{}) 28 | } 29 | 30 | func PublishEventWithPayload[T PayloadType](event Event, fetchTaskData T) (isSuccess bool) { 31 | str, err := json.Marshal(&fetchTaskData) 32 | if err != nil { 33 | return false 34 | } 35 | return eventManage.Publish(event, str) 36 | } 37 | 38 | func SubscribeEvent[T PayloadType](event Event, name string, handleTransaction handler[T]) { 39 | eventManage.SubscribeToNewConsumer( 40 | event, name, func(payload []byte) error { 41 | var data T 42 | if err := fromJson(payload, &data); err != nil { 43 | return err 44 | } 45 | return db.Transaction( 46 | context.TODO(), func(ctx *cus.TxContext) error { 47 | return handleTransaction(data, ctx) 48 | }, 49 | ) 50 | }, 51 | ) 52 | } 53 | 54 | func BindTaskToEvent(event Event, triggerTask Task) { 55 | eventManage.Subscribe( 56 | event, triggerTask, 57 | func(eventData []byte) ([]byte, error) { 58 | return eventData, nil 59 | }, 60 | ) 61 | } 62 | 63 | func BindTaskToEventAndMakePayload[T PayloadType, TriggerTaskDataType PayloadType]( 64 | event Event, triggerTask Task, fetchTaskData func(eventData T) (TriggerTaskDataType, error), 65 | ) { 66 | eventManage.Subscribe( 67 | event, triggerTask, func(eventData []byte) ([]byte, error) { 68 | var data T 69 | if err := fromJson(eventData, &data); err != nil { 70 | return nil, err 71 | } 72 | taskData, err := fetchTaskData(data) 73 | if err != nil { 74 | return nil, err 75 | } 76 | return json.Marshal(taskData) 77 | }, 78 | ) 79 | } 80 | 81 | func PublishEventToOutboxWithPayload[T PayloadType](ctx context.Context, event Event, payload T) error { 82 | if event == EventOutbox { 83 | return errors.New("cannot be TaskOutbox") 84 | } 85 | bytes, err := json.Marshal(payload) 86 | if err != nil { 87 | return err 88 | } 89 | id, err := outboxService.sendToOutbox(db.Get(ctx), outboxTypeEvent, string(event), bytes) 90 | if err != nil { 91 | return err 92 | } 93 | return db.AddCommitCallback(ctx, func() { PublishEventWithPayload(EventOutbox, id) }) 94 | } 95 | -------------------------------------------------------------------------------- /data/database/product.sql: -------------------------------------------------------------------------------- 1 | REPLACE INTO `product` (`key`, `name`, `hide`, `weight`) 2 | VALUES ('AliPay', '支付宝', 0, 0), 3 | ('WeChatPay', '微信支付', 0, 0); 4 | 5 | REPLACE INTO `product_bill` (`product_key`, `encoding`, `start_row`, `date_format`) 6 | VALUES ('AliPay', 'GBK', 23, '2006-01-02 15:04:05'), 7 | ('WeChatPay', 'UTF-8', 17, '2006-01-02 15:04:05'); 8 | 9 | REPLACE INTO `product_bill_header` (`name`, `type`, `product_key`) 10 | VALUES ('交易时间', 'trans_time', 'AliPay'), 11 | ('交易分类', 'trans_category', 'AliPay'), 12 | ('商品说明', 'remark', 'AliPay'), 13 | ('收/支', 'income_expense', 'AliPay'), 14 | ('金额', 'amount', 'AliPay'), 15 | ('交易订单号', 'order_number', 'AliPay'), 16 | ('交易状态', 'trans_status', 'AliPay'), 17 | ('交易时间', 'trans_time', 'WeChatPay'), 18 | ('交易类型', 'trans_category', 'WeChatPay'), 19 | ('商品', 'remark', 'WeChatPay'), 20 | ('收/支', 'income_expense', 'WeChatPay'), 21 | ('金额(元)', 'amount', 'WeChatPay'), 22 | ('交易单号', 'order_number', 'WeChatPay'), 23 | ('当前状态', 'trans_status', 'WeChatPay'); 24 | 25 | REPLACE INTO `product_transaction_category` (`id`, `product_key`, `income_expense`, `name`) 26 | VALUES (1, 'AliPay', 'expense', '餐饮美食'), 27 | (2, 'AliPay', 'expense', '服饰装扮'), 28 | (3, 'AliPay', 'expense', '日用百货'), 29 | (4, 'AliPay', 'expense', '家居家装'), 30 | (5, 'AliPay', 'expense', '数码电器'), 31 | (6, 'AliPay', 'expense', '运动户外'), 32 | (7, 'AliPay', 'expense', '美容美发'), 33 | (8, 'AliPay', 'expense', '母婴亲子'), 34 | (9, 'AliPay', 'expense', '宠物'), 35 | (10, 'AliPay', 'expense', '交通出行'), 36 | (11, 'AliPay', 'expense', '爱车养车'), 37 | (12, 'AliPay', 'expense', '住房物业'), 38 | (13, 'AliPay', 'expense', '酒店旅游'), 39 | (14, 'AliPay', 'expense', '文化休闲'), 40 | (15, 'AliPay', 'expense', '教育培训'), 41 | (16, 'AliPay', 'expense', '医疗健康'), 42 | (17, 'AliPay', 'expense', '生活服务'), 43 | (18, 'AliPay', 'expense', '公共服务'), 44 | (19, 'AliPay', 'expense', '商业服务'), 45 | (20, 'AliPay', 'expense', '公益捐赠'), 46 | (21, 'AliPay', 'expense', '互助保障'), 47 | (22, 'AliPay', 'expense', '投资理财'), 48 | (23, 'AliPay', 'expense', '保险'), 49 | (24, 'AliPay', 'expense', '信用借还'), 50 | (25, 'AliPay', 'expense', '充值缴费'), 51 | (26, 'AliPay', 'expense', '转账红包'), 52 | (27, 'AliPay', 'expense', '亲友代付'), 53 | (28, 'AliPay', 'expense', '账户存取'), 54 | (29, 'AliPay', 'expense', '退款'), 55 | (30, 'AliPay', 'expense', '其他'), 56 | (31, 'AliPay', 'income', '收入'), 57 | (32, 'AliPay', 'income', '转账红包'), 58 | (33, 'AliPay', 'income', '账户存取'), 59 | (34, 'AliPay', 'income', '退款'), 60 | (35, 'AliPay', 'income', '其他'), 61 | (36, 'WeChatPay', 'expense', '商户消费'), 62 | (37, 'WeChatPay', 'expense', '扫二维码付款'), 63 | (38, 'WeChatPay', 'income', '二维码收款'); 64 | -------------------------------------------------------------------------------- /initialize/database/enter.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "fmt" 5 | 6 | _ "github.com/ZiRunHua/LeapLedger/model" 7 | "github.com/pkg/errors" 8 | ) 9 | 10 | import ( 11 | userModel "github.com/ZiRunHua/LeapLedger/model/user" 12 | "github.com/ZiRunHua/LeapLedger/script" 13 | "github.com/ZiRunHua/LeapLedger/service" 14 | "github.com/ZiRunHua/LeapLedger/util" 15 | 16 | _templateService "github.com/ZiRunHua/LeapLedger/service/template" 17 | 18 | "context" 19 | 20 | "github.com/ZiRunHua/LeapLedger/global/cus" 21 | "github.com/ZiRunHua/LeapLedger/global/db" 22 | testInfo "github.com/ZiRunHua/LeapLedger/test/info" 23 | "gorm.io/gorm" 24 | ) 25 | 26 | var ( 27 | userService = service.GroupApp.UserServiceGroup 28 | 29 | commonService = service.GroupApp.CommonServiceGroup 30 | 31 | templateService = service.GroupApp.TemplateServiceGroup 32 | 33 | testUserPassword = _templateService.TmplUserPassword 34 | ) 35 | 36 | func init() { 37 | var err error 38 | ctx := cus.WithDb(context.Background(), db.InitDb) 39 | // init tourist User 40 | err = db.Transaction(ctx, initTourist) 41 | if err != nil { 42 | panic(err) 43 | } 44 | // init test User 45 | err = db.Transaction(ctx, initTestUser) 46 | if err != nil { 47 | panic(err) 48 | } 49 | } 50 | 51 | func initTestUser(ctx *cus.TxContext) (err error) { 52 | tx := db.Get(ctx) 53 | var user userModel.User 54 | user, err = script.User.CreateTourist(ctx) 55 | if err != nil { 56 | return 57 | } 58 | var tourist userModel.Tour 59 | tourist, err = userModel.NewDao(tx).SelectTour(user.ID) 60 | if err != nil { 61 | return 62 | } 63 | err = tourist.Use(tx) 64 | if err != nil { 65 | return 66 | } 67 | err = userService.UpdatePassword(user, util.ClientPasswordHash(user.Email, testUserPassword), ctx) 68 | if err != nil { 69 | return 70 | } 71 | account, _, err := templateService.CreateExampleAccount(user, ctx) 72 | if err != nil { 73 | return 74 | } 75 | token, err := commonService.GenerateJWT(commonService.MakeCustomClaims(user.ID)) 76 | if err != nil { 77 | return 78 | } 79 | testInfo.Data = testInfo.Info{ 80 | UserId: user.ID, 81 | Email: user.Email, 82 | AccountId: account.ID, 83 | Token: token, 84 | } 85 | fmt.Println("test user", testInfo.Data) 86 | return 87 | } 88 | 89 | func initTourist(ctx *cus.TxContext) error { 90 | tx := db.Get(ctx) 91 | _, err := userModel.NewDao(tx).SelectByUnusedTour() 92 | if err == nil { 93 | return nil 94 | } else if !errors.Is(err, gorm.ErrRecordNotFound) { 95 | return err 96 | } 97 | user, err := script.User.CreateTourist(ctx) 98 | if err != nil { 99 | return err 100 | } 101 | _, accountUser, err := templateService.CreateExampleAccount(user, ctx) 102 | if err != nil { 103 | return err 104 | } 105 | err = script.User.ChangeCurrantAccount(accountUser, tx) 106 | if err != nil { 107 | return err 108 | } 109 | return err 110 | } 111 | -------------------------------------------------------------------------------- /api/request/user.go: -------------------------------------------------------------------------------- 1 | package request 2 | 3 | import ( 4 | "crypto/hmac" 5 | "crypto/sha256" 6 | "encoding/hex" 7 | "errors" 8 | "strings" 9 | 10 | "github.com/ZiRunHua/LeapLedger/global" 11 | "github.com/ZiRunHua/LeapLedger/global/constant" 12 | userModel "github.com/ZiRunHua/LeapLedger/model/user" 13 | ) 14 | 15 | type UserLogin struct { 16 | Email string `binding:"required"` 17 | Password string `binding:"required"` 18 | PicCaptcha 19 | } 20 | 21 | type UserRegister struct { 22 | Username string `binding:"required"` 23 | Password string `binding:"required"` 24 | Email string `binding:"required,email"` 25 | Captcha string `binding:"required"` 26 | } 27 | 28 | type UserForgetPassword struct { 29 | Email string `binding:"required,email"` 30 | Password string `binding:"required"` 31 | Captcha string `binding:"required"` 32 | } 33 | 34 | type UserUpdatePassword struct { 35 | Password string `binding:"required"` 36 | Captcha string `binding:"required"` 37 | } 38 | 39 | type UserUpdateInfo struct { 40 | Username string `binding:"required"` 41 | } 42 | 43 | type UserSearch struct { 44 | Id *uint `binding:"omitempty"` 45 | Username string `binding:"required"` 46 | PageData 47 | } 48 | 49 | type UserSendEmail struct { 50 | PicCaptcha 51 | Type constant.UserAction `binding:"required,oneof=updatePassword"` 52 | } 53 | 54 | type UserHome struct { 55 | AccountId uint 56 | } 57 | 58 | type TourApply struct { 59 | DeviceNumber string 60 | Key string 61 | Sign string 62 | } 63 | 64 | func (t *TourApply) CheckSign() bool { 65 | h := hmac.New(sha256.New, []byte(global.Config.System.ClientSignKey)) 66 | h.Write([]byte(t.DeviceNumber + t.Key)) 67 | s := hex.EncodeToString(h.Sum(nil)) 68 | return strings.Compare(t.Sign, s) == 0 69 | } 70 | 71 | type TransactionShareConfigName string 72 | 73 | const ( 74 | FLAG_ACCOUNT TransactionShareConfigName = "account" 75 | FLAG_CREATE_TIME TransactionShareConfigName = "createTime" 76 | FLAG_UPDATE_TIME TransactionShareConfigName = "updateTime" 77 | FLAG_REMARK TransactionShareConfigName = "remark" 78 | ) 79 | 80 | type UserTransactionShareConfigUpdate struct { 81 | Flag TransactionShareConfigName 82 | Status bool 83 | } 84 | 85 | func GetFlagByFlagName(name TransactionShareConfigName) (userModel.Flag, error) { 86 | switch name { 87 | case FLAG_ACCOUNT: 88 | return userModel.FLAG_ACCOUNT, nil 89 | case FLAG_CREATE_TIME: 90 | return userModel.FLAG_CREATE_TIME, nil 91 | case FLAG_UPDATE_TIME: 92 | return userModel.FLAG_UPDATE_TIME, nil 93 | case FLAG_REMARK: 94 | return userModel.FLAG_REMARK, nil 95 | } 96 | return 0, errors.New("flag参数错误") 97 | } 98 | 99 | type UserCreateFriendInvitation struct { 100 | Invitee uint 101 | } 102 | 103 | type UserGetFriendInvitation struct { 104 | IsInvite bool 105 | } 106 | 107 | type UserGetAccountInvitationList struct { 108 | PageData 109 | } 110 | -------------------------------------------------------------------------------- /model/category/categoryModel.go: -------------------------------------------------------------------------------- 1 | package categoryModel 2 | 3 | import ( 4 | "github.com/ZiRunHua/LeapLedger/global" 5 | "github.com/ZiRunHua/LeapLedger/global/constant" 6 | accountModel "github.com/ZiRunHua/LeapLedger/model/account" 7 | commonModel "github.com/ZiRunHua/LeapLedger/model/common" 8 | "gorm.io/gorm" 9 | "time" 10 | ) 11 | 12 | type Category struct { 13 | ID uint `gorm:"comment:'主键';primary_key;" ` 14 | AccountId uint `gorm:"comment:'账本ID';uniqueIndex:unique_name,priority:1"` 15 | FatherId uint `gorm:"comment:'category_father表ID';index" ` 16 | IncomeExpense constant.IncomeExpense `gorm:"comment:'收支类型'"` 17 | Name string `gorm:"comment:'名称';size:128;uniqueIndex:unique_name,priority:2"` 18 | Icon string `gorm:"comment:'图标';size:64"` 19 | Previous uint `gorm:"comment:'前一位'"` 20 | OrderUpdatedAt time.Time `gorm:"comment:'顺序更新时间';not null;default:now();type:TIMESTAMP;"` 21 | CreatedAt time.Time `gorm:"type:TIMESTAMP"` 22 | UpdatedAt time.Time `gorm:"type:TIMESTAMP"` 23 | DeletedAt gorm.DeletedAt `gorm:"index;type:TIMESTAMP"` 24 | commonModel.BaseModel 25 | } 26 | 27 | func (c *Category) SelectById(id uint) error { 28 | return global.GvaDb.First(c, id).Error 29 | } 30 | 31 | func (c *Category) GetFather() (father Father, err error) { 32 | err = global.GvaDb.First(&father, c.FatherId).Error 33 | return 34 | } 35 | 36 | func (c *Category) GetAccount() (result accountModel.Account, err error) { 37 | err = result.SelectById(c.AccountId) 38 | return 39 | } 40 | 41 | func (c *Category) CheckName(_ *gorm.DB) error { 42 | if c.Name == "" { 43 | return global.NewErrDataIsEmpty("交易类型名称") 44 | } 45 | return nil 46 | } 47 | 48 | type Condition struct { 49 | account accountModel.Account 50 | ie *constant.IncomeExpense 51 | } 52 | 53 | func (c *Condition) buildWhere(db *gorm.DB) *gorm.DB { 54 | if c.ie == nil { 55 | return db.Where("account_id = ?", c.account.ID) 56 | } 57 | return db.Where("account_id = ? AND income_expense = ?", c.account.ID, c.ie) 58 | } 59 | 60 | // Mapping 61 | // ParentAccountId - ChildCategoryId unique 62 | // ParentCategoryId - ChildCategoryId unique 63 | // ChildAccountId - ParentCategoryId unique 64 | type Mapping struct { 65 | ID uint `gorm:"primarykey"` 66 | ParentAccountId uint `gorm:"comment:'父账本ID';uniqueIndex:idx_mapping,priority:2"` 67 | ChildAccountId uint `gorm:"comment:'子账本ID';" ` 68 | ParentCategoryId uint `gorm:"comment:'父收支类型ID';index"` 69 | ChildCategoryId uint `gorm:"comment:'子收支类型ID';uniqueIndex:idx_mapping,priority:1"` 70 | CreatedAt time.Time `gorm:"type:TIMESTAMP"` 71 | UpdatedAt time.Time `gorm:"type:TIMESTAMP"` 72 | DeletedAt gorm.DeletedAt `gorm:"index;type:TIMESTAMP"` 73 | commonModel.BaseModel 74 | } 75 | 76 | func (p *Mapping) TableName() string { 77 | return "category_mapping" 78 | } 79 | -------------------------------------------------------------------------------- /router/middleware/middleware.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "bytes" 5 | "github.com/ZiRunHua/LeapLedger/api/response" 6 | apiUtil "github.com/ZiRunHua/LeapLedger/api/util" 7 | "github.com/ZiRunHua/LeapLedger/global" 8 | accountModel "github.com/ZiRunHua/LeapLedger/model/account" 9 | commonService "github.com/ZiRunHua/LeapLedger/service/common" 10 | "github.com/gin-gonic/gin" 11 | "go.uber.org/zap" 12 | "io" 13 | "strconv" 14 | "time" 15 | ) 16 | 17 | func NoTourist() gin.HandlerFunc { 18 | return func(ctx *gin.Context) { 19 | user, err := apiUtil.ContextFunc.GetUser(ctx) 20 | if err != nil { 21 | response.FailToError(ctx, err) 22 | return 23 | } 24 | isTourist, err := user.IsTourist(global.GvaDb) 25 | if err != nil { 26 | response.FailToError(ctx, err) 27 | return 28 | } 29 | if isTourist { 30 | response.FailToError(ctx, global.ErrTouristHaveNoRight) 31 | return 32 | } 33 | ctx.Next() 34 | } 35 | } 36 | 37 | func JWTAuth() gin.HandlerFunc { 38 | return func(ctx *gin.Context) { 39 | claims, err := commonService.GroupApp.ParseToken(apiUtil.ContextFunc.GetToken(ctx)) 40 | if err != nil { 41 | response.TokenExpired(ctx) 42 | return 43 | } 44 | apiUtil.ContextFunc.SetClaims(claims, ctx) 45 | id, err := strconv.Atoi(claims.ID) 46 | if err != nil { 47 | response.TokenExpired(ctx) 48 | return 49 | } 50 | apiUtil.ContextFunc.SetUserId(uint(id), ctx) 51 | ctx.Next() 52 | } 53 | } 54 | 55 | func Recovery(ctx *gin.Context, err any) { 56 | body, _ := io.ReadAll(ctx.Request.Body) 57 | global.PanicLogger.Error( 58 | "[Recovery from panic]", 59 | zap.Any("error", err), 60 | zap.String("method", ctx.Request.Method), 61 | zap.String("url", ctx.Request.RequestURI), 62 | zap.Any("body", body), 63 | ) 64 | response.Fail(ctx) 65 | } 66 | 67 | func RequestLogger(logger *zap.Logger) gin.HandlerFunc { 68 | return func(c *gin.Context) { 69 | start := time.Now() 70 | path := c.Request.URL.Path 71 | query := c.Request.URL.RawQuery 72 | bodyBytes, err := io.ReadAll(c.Request.Body) 73 | c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) 74 | c.Next() 75 | var body string 76 | if err != nil { 77 | body = "" 78 | } else { 79 | body = string(bodyBytes) 80 | } 81 | cost := time.Since(start) 82 | logger.Info( 83 | path, 84 | zap.Int("status", c.Writer.Status()), 85 | zap.String("method", c.Request.Method), 86 | zap.String("path", path), 87 | zap.String("query", query), 88 | zap.String("body", body), 89 | zap.String("ip", c.ClientIP()), 90 | zap.String("user-agent", c.Request.UserAgent()), 91 | zap.String("errors", c.Errors.ByType(gin.ErrorTypePrivate).String()), 92 | zap.Duration("cost", cost), 93 | ) 94 | } 95 | } 96 | func AccountAuth(permission accountModel.UserPermission) gin.HandlerFunc { 97 | return func(ctx *gin.Context) { 98 | if !apiUtil.ContextFunc.CheckAccountPermissionFromParam(permission, ctx) { 99 | return 100 | } 101 | ctx.Next() 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /global/nats/outbox.go: -------------------------------------------------------------------------------- 1 | package nats 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "runtime/debug" 7 | 8 | "github.com/ZiRunHua/LeapLedger/global/cus" 9 | "github.com/ZiRunHua/LeapLedger/global/db" 10 | "github.com/ZiRunHua/LeapLedger/global/nats/manager" 11 | "gorm.io/gorm" 12 | "gorm.io/gorm/clause" 13 | ) 14 | 15 | type outbox struct { 16 | Id uint 17 | ExecType outboxType 18 | Type string 19 | Payload []byte `gorm:"type:TEXT"` 20 | FailErr string `gorm:"type:TEXT"` 21 | gorm.Model 22 | } 23 | type outboxType string 24 | 25 | const outboxTypeTask outboxType = "task" 26 | const outboxTypeEvent outboxType = "event" 27 | 28 | func (o *outbox) TableName() string { 29 | return "core_outbox" 30 | } 31 | func (o *outbox) fail(db *gorm.DB, errStr string) error { 32 | return db.Model(o).Update("fail_err", errStr).Error 33 | } 34 | func (o *outbox) completeReceipt(db *gorm.DB) error { 35 | return db.Delete(o).Error 36 | } 37 | 38 | var outboxService outboxServer 39 | 40 | type outboxServer struct{} 41 | 42 | func (oServer *outboxServer) sendToOutbox(db *gorm.DB, execType outboxType, t string, data []byte) (uint, error) { 43 | o := &outbox{ExecType: execType, Type: t, Payload: data} 44 | err := db.Create(o).Error 45 | if err != nil { 46 | return 0, err 47 | } 48 | return o.Id, nil 49 | } 50 | 51 | func (oServer *outboxServer) getOutboxAndLockById(db *gorm.DB, id uint) (outbox, error) { 52 | var o outbox 53 | err := db.Clauses(clause.Locking{Strength: "UPDATE", Options: clause.LockingOptionsNoWait}).First(&o, id).Error 54 | return o, err 55 | } 56 | 57 | func (oServer *outboxServer) getHandleTransaction(t outboxType) handler[uint] { 58 | var msgHandler func(execType string, payload []byte) error 59 | switch t { 60 | case outboxTypeTask: 61 | msgHandler = func(execType string, payload []byte) error { 62 | h, err := taskManage.GetMessageHandler(manager.Task(execType)) 63 | if err != nil { 64 | return err 65 | } 66 | return h(payload) 67 | } 68 | case outboxTypeEvent: 69 | msgHandler = func(execType string, payload []byte) error { 70 | if eventManage.Publish(manager.Event(execType), payload) { 71 | return nil 72 | } 73 | return errors.New("fail publish event") 74 | } 75 | } 76 | return func(id uint, ctx context.Context) error { 77 | var msgHandleErr error 78 | err := db.Transaction( 79 | ctx, func(ctx *cus.TxContext) error { 80 | tx := ctx.GetDb() 81 | o, err := oServer.getOutboxAndLockById(tx, id) 82 | if err != nil { 83 | if errors.Is(err, gorm.ErrRecordNotFound) { 84 | return nil 85 | } 86 | return err 87 | } 88 | defer func() { 89 | r := recover() 90 | if r != nil { 91 | _ = o.fail(tx, string(debug.Stack())) 92 | } 93 | }() 94 | msgHandleErr = msgHandler(o.Type, o.Payload) 95 | if msgHandleErr != nil { 96 | return o.fail(tx, msgHandleErr.Error()) 97 | } 98 | return o.completeReceipt(tx) 99 | }, 100 | ) 101 | if err != nil { 102 | return err 103 | } 104 | return msgHandleErr 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /service/template/enter.go: -------------------------------------------------------------------------------- 1 | package templateService 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "path/filepath" 7 | 8 | "github.com/ZiRunHua/LeapLedger/global" 9 | "github.com/ZiRunHua/LeapLedger/global/constant" 10 | "github.com/ZiRunHua/LeapLedger/global/cus" 11 | "github.com/ZiRunHua/LeapLedger/global/db" 12 | "github.com/ZiRunHua/LeapLedger/global/nats" 13 | userModel "github.com/ZiRunHua/LeapLedger/model/user" 14 | _accountService "github.com/ZiRunHua/LeapLedger/service/account" 15 | _categoryService "github.com/ZiRunHua/LeapLedger/service/category" 16 | _productService "github.com/ZiRunHua/LeapLedger/service/product" 17 | _userService "github.com/ZiRunHua/LeapLedger/service/user" 18 | "github.com/ZiRunHua/LeapLedger/util" 19 | "github.com/ZiRunHua/LeapLedger/util/rand" 20 | "go.uber.org/zap" 21 | "gorm.io/gorm" 22 | ) 23 | 24 | type Group struct { 25 | template 26 | } 27 | 28 | var ( 29 | GroupApp = &Group{} 30 | errorLog *zap.Logger 31 | ) 32 | 33 | var TmplUserId uint = 1 34 | 35 | const ( 36 | TmplUserEmail = "template@gmail.com" 37 | TmplUserName = "template" 38 | ) 39 | 40 | var TmplUserPassword = rand.String(15) 41 | 42 | var ( 43 | templateService = GroupApp 44 | userService = _userService.GroupApp 45 | accountService = _accountService.GroupApp 46 | categoryService = _categoryService.GroupApp 47 | productService = _productService.GroupApp 48 | ) 49 | 50 | func init() { 51 | var err error 52 | logPath := filepath.Clean(constant.LogPath + "/service/template/error.log") 53 | if errorLog, err = global.Config.Logger.New(logPath); err != nil { 54 | panic(err) 55 | } 56 | 57 | nats.SubscribeTaskWithPayloadAndProcessInTransaction( 58 | nats.TaskCreateTourist, func(t []byte, ctx context.Context) error { 59 | user, err := userService.CreateTourist(ctx) 60 | if err != nil { 61 | return err 62 | } 63 | _, _, err = templateService.CreateExampleAccount(user, ctx) 64 | return err 65 | }, 66 | ) 67 | ctx := cus.WithDb(context.Background(), db.InitDb) 68 | // init template User 69 | err = db.Transaction(ctx, initTemplateUser) 70 | if err != nil { 71 | panic(err) 72 | } 73 | initRank() 74 | } 75 | 76 | func initTemplateUser(ctx *cus.TxContext) (err error) { 77 | tx := db.Get(ctx) 78 | var user userModel.User 79 | // find user 80 | err = tx.First(&user, TmplUserId).Error 81 | if err == nil { 82 | return 83 | } else if !errors.Is(err, gorm.ErrRecordNotFound) { 84 | return 85 | } 86 | // create user 87 | option := userService.NewRegisterOption() 88 | option.WithSendEmail(false) 89 | user, err = userService.Register( 90 | userModel.AddData{ 91 | Email: TmplUserEmail, 92 | Password: util.ClientPasswordHash(TmplUserEmail, TmplUserPassword), 93 | Username: TmplUserName, 94 | }, ctx, 95 | *option, 96 | ) 97 | if err != nil { 98 | return 99 | } 100 | if user.ID != TmplUserId { 101 | TmplUserId = user.ID 102 | } 103 | // create account 104 | _, _, err = templateService.CreateExampleAccount(user, ctx) 105 | if err != nil { 106 | return 107 | } 108 | SetTmplUser(user) 109 | return 110 | } 111 | 112 | func SetTmplUser(user userModel.User) { 113 | TmplUserId = user.ID 114 | } 115 | -------------------------------------------------------------------------------- /util/fileTool/file.go: -------------------------------------------------------------------------------- 1 | package fileTool 2 | 3 | import ( 4 | "encoding/csv" 5 | "errors" 6 | "io" 7 | "path" 8 | "strings" 9 | 10 | "github.com/saintfish/chardet" 11 | "github.com/xuri/excelize/v2" 12 | "golang.org/x/text/encoding/charmap" 13 | "golang.org/x/text/encoding/simplifiedchinese" 14 | "golang.org/x/text/transform" 15 | "gorm.io/gorm" 16 | ) 17 | 18 | func GetUTF8Reader(reader io.Reader, checkBytes []byte) (io.Reader, error) { 19 | encoding, err := DetectEncoding(checkBytes) 20 | if err != nil { 21 | return reader, err 22 | } 23 | switch encoding { 24 | case "ISO-8859-1": 25 | reader = transform.NewReader(reader, charmap.ISO8859_1.NewDecoder()) 26 | case "GB-18030": 27 | reader = transform.NewReader(reader, simplifiedchinese.GBK.NewDecoder()) 28 | } 29 | return reader, nil 30 | } 31 | 32 | func DetectEncoding(data []byte) (string, error) { 33 | detector := chardet.NewTextDetector() 34 | result, err := detector.DetectBest(data) 35 | if err != nil { 36 | return "", err 37 | } 38 | return result.Charset, nil 39 | } 40 | 41 | func GetFileSuffix(filename string) string { 42 | return path.Ext(filename) 43 | } 44 | 45 | func NewRowReader(reader io.Reader, suffix string) (func(yield func([]string) bool), error) { 46 | switch suffix { 47 | case ".csv": 48 | return IteratorsHandleCSVReader(reader) 49 | case ".excel": 50 | return IteratorsHandleEXCELReader(reader) 51 | default: 52 | return nil, errors.New("不支持该文件类型") 53 | } 54 | } 55 | 56 | func IteratorsHandleCSVReader(reader io.Reader) (func(yield func([]string) bool), error) { 57 | return func(yield func([]string) bool) { 58 | csvReader := csv.NewReader(reader) 59 | for { 60 | row, err := csvReader.Read() 61 | if err == io.EOF { 62 | return 63 | } 64 | if err != nil && !errors.Is(err, csv.ErrFieldCount) { 65 | panic(err) 66 | } 67 | if !yield(row) { 68 | return 69 | } 70 | } 71 | }, nil 72 | } 73 | 74 | // 迭代器处理EXCEL 会跳过空行 75 | func IteratorsHandleEXCELReader(reader io.Reader) (func(yield func([]string) bool), error) { 76 | file, err := excelize.OpenReader(reader) 77 | if err != nil { 78 | return nil, err 79 | } 80 | rows, err := file.Rows(file.GetSheetName(1)) 81 | if err != nil { 82 | return nil, err 83 | } 84 | return func(yield func([]string) bool) { 85 | defer func() { 86 | err = rows.Close() 87 | if err != nil { 88 | panic(err) 89 | } 90 | }() 91 | var row []string 92 | var err error 93 | for rows.Next() { 94 | row, err = rows.Columns() 95 | if err != nil { 96 | return 97 | } 98 | if len(row) == 0 { 99 | continue 100 | } 101 | if !yield(row) { 102 | return 103 | } 104 | } 105 | }, nil 106 | } 107 | 108 | func ExecSqlFile(reader io.Reader, db *gorm.DB) error { 109 | sqlBytes, err := io.ReadAll(reader) 110 | if err != nil { 111 | return err 112 | } 113 | 114 | sqlStatements := strings.Split(string(sqlBytes), ";") 115 | for _, stmt := range sqlStatements { 116 | trimmedStmt := strings.TrimSpace(stmt) 117 | if len(trimmedStmt) > 0 { 118 | if err = db.Exec(trimmedStmt).Error; err != nil { 119 | return err 120 | } 121 | } 122 | } 123 | return nil 124 | } 125 | -------------------------------------------------------------------------------- /service/product/bill/aliPay.go: -------------------------------------------------------------------------------- 1 | package bill 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "strconv" 7 | "strings" 8 | "time" 9 | 10 | "github.com/ZiRunHua/LeapLedger/global/constant" 11 | transactionModel "github.com/ZiRunHua/LeapLedger/model/transaction" 12 | "github.com/araddon/dateparse" 13 | ) 14 | 15 | type AliPayReader struct { 16 | aliPayTransactionReader 17 | } 18 | 19 | type aliPayTransactionReader interface { 20 | TransactionReader 21 | checkOrderStatus() bool 22 | setTransCategory() error 23 | setAmount() error 24 | setRemark() 25 | setTradeTime() error 26 | } 27 | 28 | func (r *AliPayReader) readTransaction(t *ReaderTemplate) (bool, error) { 29 | t.currentTransaction = transactionModel.Info{} 30 | if !r.checkOrderStatus(t) { 31 | return true, nil 32 | } 33 | r.setRemark(t) 34 | var execErr error 35 | err := r.setTransCategory(t) 36 | if err != nil { 37 | if errors.Is(err, ErrCategoryCannotRead) { 38 | return true, nil 39 | } 40 | execErr = err 41 | } 42 | err = r.setAmount(t) 43 | if err != nil { 44 | execErr = err 45 | } 46 | err = r.setTradeTime(t) 47 | if err != nil { 48 | execErr = errors.New("读取交易时间错误:" + err.Error()) 49 | } 50 | return false, execErr 51 | } 52 | 53 | func (r *AliPayReader) checkOrderStatus(t *ReaderTemplate) bool { 54 | status := strings.TrimSpace(t.currentRow[t.transDataMapping.TransStatus]) 55 | if status != "交易成功" { 56 | return false 57 | } 58 | return true 59 | } 60 | 61 | func (r *AliPayReader) setTransCategory(t *ReaderTemplate) error { 62 | incomeExpenseStr := strings.TrimSpace(t.currentRow[t.transDataMapping.IncomeExpense]) 63 | var incomeExpense constant.IncomeExpense 64 | if incomeExpenseStr == "收入" { 65 | incomeExpense = constant.Income 66 | } else if incomeExpenseStr == "支出" { 67 | incomeExpense = constant.Expense 68 | } else { 69 | return ErrCategoryCannotRead 70 | } 71 | name := strings.TrimSpace(t.currentRow[t.transDataMapping.TransCategory]) 72 | ptc, exist := t.ptcMapping[incomeExpense][name] 73 | if exist == false { 74 | return ErrCategoryReadFail 75 | } 76 | mapping, exist := t.ptcIdToMapping[ptc.ID] 77 | if exist == false { 78 | return fmt.Errorf("类型\"%s\"%v", name, ErrCategoryMappingNotExist) 79 | } 80 | t.currentTransaction.IncomeExpense = incomeExpense 81 | t.currentTransaction.CategoryId = mapping.CategoryId 82 | return nil 83 | } 84 | 85 | func (r *AliPayReader) setAmount(t *ReaderTemplate) error { 86 | amountFloat, err := strconv.ParseFloat(t.currentRow[t.transDataMapping.Amount], 64) 87 | if err != nil { 88 | return err 89 | } 90 | t.currentTransaction.Amount = int(amountFloat) * 100 91 | return nil 92 | } 93 | 94 | func (r *AliPayReader) setRemark(t *ReaderTemplate) { 95 | t.currentTransaction.Remark = strings.TrimSpace(t.currentRow[t.transDataMapping.Remark]) 96 | } 97 | 98 | func (r *AliPayReader) setTradeTime(t *ReaderTemplate) error { 99 | layout, err := dateparse.ParseFormat(t.currentRow[t.transDataMapping.TradeTime]) 100 | if err != nil { 101 | return err 102 | } 103 | t.currentTransaction.TradeTime, err = time.ParseInLocation( 104 | layout, 105 | t.currentRow[t.transDataMapping.TradeTime], t.location, 106 | ) 107 | return err 108 | } 109 | -------------------------------------------------------------------------------- /service/product/bill/billInfo.go: -------------------------------------------------------------------------------- 1 | package bill 2 | 3 | import ( 4 | "context" 5 | "github.com/ZiRunHua/LeapLedger/global/constant" 6 | "github.com/ZiRunHua/LeapLedger/global/db" 7 | accountModel "github.com/ZiRunHua/LeapLedger/model/account" 8 | productModel "github.com/ZiRunHua/LeapLedger/model/product" 9 | "github.com/ZiRunHua/LeapLedger/util/dataTool" 10 | "github.com/pkg/errors" 11 | "strings" 12 | "time" 13 | ) 14 | 15 | type BillInfo struct { 16 | info productModel.Bill 17 | location *time.Location 18 | billHeaders []productModel.BillHeader 19 | ptcMapping map[constant.IncomeExpense]map[string]productModel.TransactionCategory 20 | transDataMapping transactionDataColumnMapping 21 | ptcIdToMapping map[uint]productModel.TransactionCategoryMapping 22 | status billStatus 23 | } 24 | 25 | type billStatus string 26 | 27 | const statusOfReadInHead, statusOfReadInTransaction billStatus = "read head", "read transaction" 28 | 29 | func (b *BillInfo) init(bill productModel.Bill, account accountModel.Account, ctx context.Context) error { 30 | b.info, b.location, b.status = bill, account.GetTimeLocation(), statusOfReadInHead 31 | dao := productModel.NewDao(db.Get(ctx)) 32 | var err error 33 | b.ptcIdToMapping, err = dao.GetPtcIdMapping(account.ID, bill.ProductKey) 34 | if err != nil { 35 | return err 36 | } 37 | b.ptcMapping, err = dao.GetIncomeExpenseAndNameMap(bill.ProductKey) 38 | if err != nil { 39 | return err 40 | } 41 | b.billHeaders, err = productModel.NewDao(db.Get(ctx)).GetBillHeaderList(b.info.ProductKey) 42 | if err != nil { 43 | return err 44 | } 45 | if len(b.billHeaders) == 0 { 46 | panic("The bill header is not configured") 47 | } 48 | return nil 49 | } 50 | 51 | func (b *BillInfo) setTransDataMapping(header []string, _ context.Context) error { 52 | headerMappedToPtc := dataTool.ToMap( 53 | b.billHeaders, func(v productModel.BillHeader) string { 54 | return v.Name 55 | }, 56 | ) 57 | headerTypeMappedToColumn := map[productModel.BillHeaderType]int{} 58 | for index, name := range header { 59 | name = strings.TrimSpace(name) 60 | if _, exist := headerMappedToPtc[name]; exist == true { 61 | headerTypeMappedToColumn[headerMappedToPtc[name].Type] = index 62 | } 63 | } 64 | 65 | needHeader := []productModel.BillHeaderType{ 66 | productModel.TransCategory, productModel.IncomeExpense, productModel.Amount, productModel.Remark, 67 | productModel.TransTime, productModel.OrderNumber, productModel.TransStatus, 68 | } 69 | for i := range needHeader { 70 | if _, exist := headerTypeMappedToColumn[needHeader[i]]; exist == false { 71 | return errors.Wrap(errors.New(string(needHeader[i]+"数据缺失")), "setTransMapping") 72 | } 73 | } 74 | b.transDataMapping = transactionDataColumnMapping{ 75 | OrderNumber: headerTypeMappedToColumn[productModel.OrderNumber], 76 | TransCategory: headerTypeMappedToColumn[productModel.TransCategory], 77 | IncomeExpense: headerTypeMappedToColumn[productModel.IncomeExpense], 78 | Amount: headerTypeMappedToColumn[productModel.Amount], 79 | Remark: headerTypeMappedToColumn[productModel.Remark], 80 | TradeTime: headerTypeMappedToColumn[productModel.TransTime], 81 | TransStatus: headerTypeMappedToColumn[productModel.TransStatus], 82 | } 83 | return nil 84 | } 85 | -------------------------------------------------------------------------------- /service/thirdparty/ai.go: -------------------------------------------------------------------------------- 1 | package thirdpartyService 2 | 3 | import ( 4 | "context" 5 | "strings" 6 | 7 | "github.com/ZiRunHua/LeapLedger/global" 8 | "github.com/go-resty/resty/v2" 9 | ) 10 | 11 | const AI_SERVER_NAME = "AI" 12 | const API_SIMILARITY_MATCHING = "/similarity/matching" 13 | 14 | type aiServer struct{} 15 | 16 | func (as *aiServer) getBaseUrl() string { 17 | return global.Config.ThirdParty.Ai.GetPortalSite() 18 | } 19 | 20 | func (as *aiServer) IsOpen() bool { 21 | return global.Config.ThirdParty.Ai.IsOpen() 22 | } 23 | 24 | func (as *aiServer) ChineseSimilarityMatching(sourceStr string, targetList []string, ctx context.Context) ( 25 | target string, err error, 26 | ) { 27 | if false == as.IsOpen() { 28 | for _, targetStr := range targetList { 29 | if strings.Compare(sourceStr, targetStr) == 0 { 30 | return targetStr, nil 31 | } 32 | } 33 | return target, nil 34 | } 35 | responseData, err := as.requestChineseSimilarity([]string{sourceStr}, targetList, ctx) 36 | if err != nil { 37 | return 38 | } 39 | minSimilarity := global.Config.ThirdParty.Ai.MinSimilarity 40 | if len(responseData) > 0 && responseData[0].Similarity >= minSimilarity { 41 | return responseData[0].Target, nil 42 | } 43 | return 44 | } 45 | 46 | func (as *aiServer) BatchChineseSimilarityMatching(sourceList, targetList []string, ctx context.Context) ( 47 | map[string]string, error, 48 | ) { 49 | if false == as.IsOpen() { 50 | targetNameMap := make(map[string]struct{}) 51 | for _, targetStr := range targetList { 52 | targetNameMap[targetStr] = struct{}{} 53 | } 54 | result := make(map[string]string) 55 | for _, sourceStr := range sourceList { 56 | if _, exist := targetNameMap[sourceStr]; !exist { 57 | continue 58 | } 59 | result[sourceStr] = sourceStr 60 | } 61 | return result, nil 62 | } 63 | responseData, err := as.requestChineseSimilarity(sourceList, targetList, ctx) 64 | if err != nil { 65 | return nil, err 66 | } 67 | 68 | minSimilarity, result := global.Config.ThirdParty.Ai.MinSimilarity, make(map[string]string) 69 | for _, item := range responseData { 70 | if item.Similarity >= minSimilarity { 71 | result[item.Source] = item.Target 72 | } 73 | } 74 | return result, nil 75 | } 76 | 77 | type chineseSimilarityResponse []struct { 78 | Source, Target string 79 | Similarity float32 80 | } 81 | 82 | func (as *aiServer) requestChineseSimilarity( 83 | SourceList, TargetList []string, 84 | ctx context.Context) (chineseSimilarityResponse, error) { 85 | var response struct { 86 | aiApiResponse 87 | Data chineseSimilarityResponse 88 | } 89 | _, err := resty.New().R().SetContext(ctx).SetBody( 90 | map[string]interface{}{ 91 | "SourceData": SourceList, "TargetData": TargetList, 92 | }, 93 | ).SetResult(&response).Post(as.getBaseUrl() + API_SIMILARITY_MATCHING) 94 | 95 | if err != nil { 96 | return nil, err 97 | } 98 | if false == response.isSuccess() { 99 | return nil, global.NewErrThirdpartyApi(AI_SERVER_NAME, response.Msg) 100 | } 101 | return response.Data, nil 102 | } 103 | 104 | type aiApiResponse struct { 105 | Code int 106 | Msg string 107 | } 108 | 109 | func (a *aiApiResponse) isSuccess() bool { return a.Code == 200 } 110 | -------------------------------------------------------------------------------- /service/thirdparty/email.go: -------------------------------------------------------------------------------- 1 | package thirdpartyService 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "time" 9 | 10 | "github.com/ZiRunHua/LeapLedger/global" 11 | "github.com/ZiRunHua/LeapLedger/global/constant" 12 | userModel "github.com/ZiRunHua/LeapLedger/model/user" 13 | commonService "github.com/ZiRunHua/LeapLedger/service/common" 14 | "github.com/ZiRunHua/LeapLedger/util/rand" 15 | "github.com/pkg/errors" 16 | ) 17 | 18 | var emailTemplate map[constant.Notification][]byte 19 | var emailTemplateFilePath = map[constant.Notification]string{ 20 | constant.NotificationOfCaptcha: filepath.Clean("/template/email/captcha.html"), 21 | constant.NotificationOfRegistrationSuccess: filepath.Clean("/template/email/registerSuccess.html"), 22 | constant.NotificationOfUpdatePassword: filepath.Clean("/template/email/updatePassword.html"), 23 | } 24 | 25 | func init() { 26 | emailTemplate = make(map[constant.Notification][]byte, len(emailTemplateFilePath)) 27 | var err error 28 | for notification, path := range emailTemplateFilePath { 29 | if emailTemplate[notification], err = os.ReadFile(filepath.Clean(constant.DataPath + path)); err != nil { 30 | panic(err) 31 | } 32 | } 33 | } 34 | 35 | func (g *Group) sendCaptchaEmail(email string, action constant.UserAction) error { 36 | captcha := rand.String(6) 37 | expirationTime := time.Second * time.Duration(global.Config.Captcha.EmailCaptchaTimeOut) 38 | err := commonService.Common.SetEmailCaptchaCache(email, captcha, expirationTime) 39 | if err != nil { 40 | return err 41 | } 42 | minutes := int(expirationTime.Minutes()) 43 | content := bytes.Replace(emailTemplate[constant.NotificationOfCaptcha], []byte("[Captcha]"), []byte(captcha), 1) 44 | content = bytes.Replace(content, []byte("[ExpirationTime]"), []byte(fmt.Sprintf("%d分钟", minutes)), 1) 45 | var actionName string 46 | switch action { 47 | case constant.Register: 48 | actionName = "注册" 49 | case constant.ForgetPassword: 50 | actionName = "忘记密码" 51 | case constant.UpdatePassword: 52 | actionName = "修改密码" 53 | default: 54 | return errors.Wrap(global.ErrUnsupportedUserAction, "发送邮箱验证码") 55 | } 56 | content = bytes.Replace(content, []byte("[Action]"), []byte(actionName), 2) 57 | return emailServer.Send([]string{email}, actionName+"验证码", string(content)) 58 | } 59 | 60 | func (g *Group) sendRegisterSuccessEmail(user userModel.User) error { 61 | content := bytes.Replace( 62 | emailTemplate[constant.NotificationOfRegistrationSuccess], []byte("[username]"), []byte(user.Username), 1, 63 | ) 64 | return emailServer.Send([]string{user.Email}, "注册成功", string(content)) 65 | } 66 | 67 | func (g *Group) sendUpdatePasswordEmail(user userModel.User) error { 68 | content := bytes.Replace( 69 | emailTemplate[constant.NotificationOfUpdatePassword], []byte("[username]"), []byte(user.Username), 1, 70 | ) 71 | return emailServer.Send([]string{user.Email}, "修改密码", string(content)) 72 | } 73 | 74 | func (g *Group) sendNotificationEmail(user userModel.User, notification constant.Notification) error { 75 | switch notification { 76 | case constant.NotificationOfUpdatePassword: 77 | return g.sendUpdatePasswordEmail(user) 78 | case constant.NotificationOfRegistrationSuccess: 79 | return g.sendRegisterSuccessEmail(user) 80 | default: 81 | return errors.New("不支持该类型邮箱通知") 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /global/constant/constant.go: -------------------------------------------------------------------------------- 1 | package constant 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/exec" 7 | "path/filepath" 8 | "strings" 9 | ) 10 | 11 | type ServerMode string 12 | 13 | const Debug, Production ServerMode = "debug", "production" 14 | 15 | var ( 16 | RootDir = getRootDir() 17 | LogPath = filepath.Join(RootDir, "log") 18 | DataPath = filepath.Join(RootDir, "data") 19 | ExampleAccountJsonPath = filepath.Clean(DataPath + "/template/account/example.json") 20 | ) 21 | 22 | // IncomeExpense 收支类型 23 | type IncomeExpense string // @name IncomeExpense `example:"expense" enums:"income,expense" swaggertype:"string"` 24 | 25 | const ( 26 | Income IncomeExpense = "income" 27 | Expense IncomeExpense = "expense" 28 | ) 29 | 30 | func (ie *IncomeExpense) QueryIncome() bool { 31 | return ie == nil || *ie == Income 32 | } 33 | 34 | func (ie *IncomeExpense) QueryExpense() bool { 35 | return ie == nil || *ie == Expense 36 | } 37 | 38 | // Client 客户端 39 | type Client string 40 | 41 | var ClientList = []Client{Web, Android, Ios} 42 | 43 | const ( 44 | Web Client = "web" 45 | Android Client = "android" 46 | Ios Client = "ios" 47 | ) 48 | 49 | type Encoding string 50 | 51 | const ( 52 | GBK Encoding = "GBK" 53 | UTF8 Encoding = "UTF8" 54 | ) 55 | 56 | type UserAction string 57 | 58 | const ( 59 | Login UserAction = "login" 60 | Register UserAction = "register" 61 | ForgetPassword UserAction = "forgetPassword" 62 | UpdatePassword UserAction = "updatePassword" 63 | ) 64 | 65 | type CacheTab string 66 | 67 | const ( 68 | LoginFailCount CacheTab = "loginFailCount" 69 | EmailCaptcha CacheTab = "emailCaptcha" 70 | CaptchaEmailErrorCount CacheTab = "captchaEmailErrorCount" 71 | ) 72 | 73 | type Notification int 74 | 75 | const ( 76 | NotificationOfCaptcha Notification = iota 77 | NotificationOfRegistrationSuccess Notification = iota 78 | NotificationOfUpdatePassword Notification = iota 79 | ) 80 | 81 | type LogOperation string 82 | 83 | const ( 84 | LogOperationOfAdd LogOperation = "add" 85 | LogOperationOfUpdate LogOperation = "update" 86 | LogOperationOfDelete LogOperation = "delete" 87 | ) 88 | 89 | // nats 90 | 91 | type Subject string 92 | 93 | func getRootDir() string { 94 | // `os.Getwd()` is avoided here because, during tests, the working directory is set to the test file’s directory. 95 | // This command retrieves the module's root directory instead. 96 | // Source of `go list` usage: https://stackoverflow.com/a/75943840/23658318 97 | rootDir, err := exec.Command("go", "list", "-m", "-f", "{{.Dir}}").Output() 98 | if err == nil { 99 | return strings.TrimSpace(string(rootDir)) 100 | } 101 | // If `go list` fails, it may indicate the absence of a Go environment. 102 | // In such cases, this suggests we are not in a test environment, so fall back to `os.Getwd()` to set `RootDir`. 103 | workDir, err := os.Getwd() 104 | if err != nil { 105 | panic(err) 106 | } 107 | // Validate that the directory exists 108 | _, err = os.Stat(workDir) 109 | if err != nil { 110 | if os.IsNotExist(err) { 111 | panic(fmt.Sprintf("Path:%s does not exists", workDir)) 112 | } 113 | panic(err) 114 | } 115 | return workDir 116 | } 117 | -------------------------------------------------------------------------------- /service/product/bill/weChatPay.go: -------------------------------------------------------------------------------- 1 | package bill 2 | 3 | import ( 4 | "errors" 5 | "strconv" 6 | "strings" 7 | "time" 8 | 9 | "github.com/ZiRunHua/LeapLedger/global/constant" 10 | transactionModel "github.com/ZiRunHua/LeapLedger/model/transaction" 11 | 12 | "github.com/araddon/dateparse" 13 | ) 14 | 15 | type WeChatPayReader struct { 16 | weChatPayTransactionReader 17 | } 18 | 19 | type weChatPayTransactionReader interface { 20 | TransactionReader 21 | checkOrderStatus() bool 22 | setTransCategory() error 23 | setAmount() error 24 | setRemark() 25 | setTradeTime() error 26 | } 27 | 28 | func (r *WeChatPayReader) readTransaction(t *ReaderTemplate) (bool, error) { 29 | t.currentTransaction = transactionModel.Info{} 30 | if !r.checkOrderStatus(t) { 31 | return true, nil 32 | } 33 | r.setRemark(t) 34 | var execErr error 35 | err := r.setTransCategory(t) 36 | if err != nil { 37 | if errors.Is(err, ErrCategoryCannotRead) { 38 | return true, nil 39 | } 40 | execErr = err 41 | } 42 | err = r.setAmount(t) 43 | if err != nil { 44 | execErr = err 45 | } 46 | err = r.setTradeTime(t) 47 | if err != nil { 48 | execErr = errors.New("读取交易时间错误:" + err.Error()) 49 | } 50 | return false, execErr 51 | } 52 | 53 | func (r *WeChatPayReader) checkOrderStatus(t *ReaderTemplate) bool { 54 | status := strings.TrimSpace(t.currentRow[t.transDataMapping.TransStatus]) 55 | if status != "支付成功" && status != "已转账" && status != "已收钱" { 56 | return false 57 | } 58 | return true 59 | } 60 | 61 | func (r *WeChatPayReader) setTransCategory(t *ReaderTemplate) error { 62 | incomeExpenseStr := strings.TrimSpace(t.currentRow[t.transDataMapping.IncomeExpense]) 63 | var incomeExpense constant.IncomeExpense 64 | if incomeExpenseStr == "收入" { 65 | incomeExpense = constant.Income 66 | } else if incomeExpenseStr == "支出" { 67 | incomeExpense = constant.Expense 68 | } else { 69 | return ErrCategoryCannotRead 70 | } 71 | name := strings.TrimSpace(t.currentRow[t.transDataMapping.TransCategory]) 72 | ptc, exist := t.ptcMapping[incomeExpense][name] 73 | if exist == false { 74 | return ErrCategoryReadFail 75 | } 76 | mapping, exist := t.ptcIdToMapping[ptc.ID] 77 | if exist == false { 78 | return ErrCategoryMappingNotExist 79 | } 80 | t.currentTransaction.IncomeExpense = incomeExpense 81 | t.currentTransaction.CategoryId = mapping.CategoryId 82 | return nil 83 | } 84 | 85 | func (r *WeChatPayReader) setAmount(t *ReaderTemplate) error { 86 | amountStr := strings.TrimLeft(t.currentRow[t.transDataMapping.Amount], "¥") 87 | amountFloat, err := strconv.ParseFloat(amountStr, 64) 88 | if err != nil { 89 | return err 90 | } else { 91 | t.currentTransaction.Amount = int(amountFloat) * 100 92 | } 93 | return nil 94 | } 95 | 96 | func (r *WeChatPayReader) setRemark(t *ReaderTemplate) { 97 | t.currentTransaction.Remark = strings.TrimSpace(t.currentRow[t.transDataMapping.Remark]) 98 | } 99 | 100 | func (r *WeChatPayReader) setTradeTime(t *ReaderTemplate) error { 101 | layout, err := dateparse.ParseFormat(t.currentRow[t.transDataMapping.TradeTime]) 102 | if err != nil { 103 | return err 104 | } 105 | t.currentTransaction.TradeTime, err = time.ParseInLocation( 106 | layout, 107 | t.currentRow[t.transDataMapping.TradeTime], t.location, 108 | ) 109 | return err 110 | } 111 | --------------------------------------------------------------------------------