mirror of
https://github.com/mayswind/ezbookkeeping.git
synced 2026-05-19 17:24:26 +08:00
support using duplicate checker to prevent duplicate submissions for new transaction record
This commit is contained in:
@@ -4,10 +4,13 @@ import (
|
||||
"sort"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/duplicatechecker"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/services"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/validators"
|
||||
)
|
||||
|
||||
@@ -203,6 +206,42 @@ func (a *AccountsApi) AccountCreateHandler(c *core.Context) (any, *errs.Error) {
|
||||
mainAccount := a.createNewAccountModel(uid, &accountCreateReq, maxOrderId+1)
|
||||
childrenAccounts := a.createSubAccountModels(uid, &accountCreateReq)
|
||||
|
||||
if settings.Container.Current.EnableDuplicateSubmissionsCheck && accountCreateReq.ClientSessionId != "" {
|
||||
found, remark := duplicatechecker.Container.Get(duplicatechecker.DUPLICATE_CHECKER_TYPE_NEW_ACCOUNT, uid, accountCreateReq.ClientSessionId)
|
||||
|
||||
if found {
|
||||
log.InfofWithRequestId(c, "[accounts.AccountCreateHandler] another account \"id:%s\" has been created for user \"uid:%d\"", remark, uid)
|
||||
accountId, err := utils.StringToInt64(remark)
|
||||
|
||||
if err == nil {
|
||||
accountAndSubAccounts, err := a.accounts.GetAccountAndSubAccountsByAccountId(c, uid, accountId)
|
||||
|
||||
if err != nil {
|
||||
log.ErrorfWithRequestId(c, "[accounts.AccountCreateHandler] failed to get existed account \"id:%d\" for user \"uid:%d\", because %s", accountId, uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
accountMap := a.accounts.GetAccountMapByList(accountAndSubAccounts)
|
||||
mainAccount, exists := accountMap[accountId]
|
||||
|
||||
if !exists {
|
||||
return nil, errs.ErrOperationFailed
|
||||
}
|
||||
|
||||
accountInfoResp := mainAccount.ToAccountInfoResponse()
|
||||
|
||||
for i := 0; i < len(accountAndSubAccounts); i++ {
|
||||
if accountAndSubAccounts[i].ParentAccountId == mainAccount.AccountId {
|
||||
subAccountResp := accountAndSubAccounts[i].ToAccountInfoResponse()
|
||||
accountInfoResp.SubAccounts = append(accountInfoResp.SubAccounts, subAccountResp)
|
||||
}
|
||||
}
|
||||
|
||||
return accountInfoResp, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
err = a.accounts.CreateAccounts(c, mainAccount, childrenAccounts, utcOffset)
|
||||
|
||||
if err != nil {
|
||||
@@ -212,6 +251,7 @@ func (a *AccountsApi) AccountCreateHandler(c *core.Context) (any, *errs.Error) {
|
||||
|
||||
log.InfofWithRequestId(c, "[accounts.AccountCreateHandler] user \"uid:%d\" has created a new account \"id:%d\" successfully", uid, mainAccount.AccountId)
|
||||
|
||||
duplicatechecker.Container.Set(duplicatechecker.DUPLICATE_CHECKER_TYPE_NEW_ACCOUNT, uid, accountCreateReq.ClientSessionId, utils.Int64ToString(mainAccount.AccountId))
|
||||
accountInfoResp := mainAccount.ToAccountInfoResponse()
|
||||
|
||||
if len(childrenAccounts) > 0 {
|
||||
|
||||
@@ -6,10 +6,13 @@ import (
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/duplicatechecker"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/services"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||
)
|
||||
|
||||
// TransactionCategoriesApi represents transaction category api
|
||||
@@ -119,6 +122,28 @@ func (a *TransactionCategoriesApi) CategoryCreateHandler(c *core.Context) (any,
|
||||
|
||||
category := a.createNewCategoryModel(uid, &categoryCreateReq, maxOrderId+1)
|
||||
|
||||
if settings.Container.Current.EnableDuplicateSubmissionsCheck && categoryCreateReq.ClientSessionId != "" {
|
||||
found, remark := duplicatechecker.Container.Get(duplicatechecker.DUPLICATE_CHECKER_TYPE_NEW_CATEGORY, uid, categoryCreateReq.ClientSessionId)
|
||||
|
||||
if found {
|
||||
log.InfofWithRequestId(c, "[transaction_categories.CategoryCreateHandler] another category \"id:%s\" has been created for user \"uid:%d\"", remark, uid)
|
||||
categoryId, err := utils.StringToInt64(remark)
|
||||
|
||||
if err == nil {
|
||||
category, err = a.categories.GetCategoryByCategoryId(c, uid, categoryId)
|
||||
|
||||
if err != nil {
|
||||
log.ErrorfWithRequestId(c, "[transaction_categories.CategoryCreateHandler] failed to get existed category \"id:%d\" for user \"uid:%d\", because %s", categoryId, uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
categoryResp := category.ToTransactionCategoryInfoResponse()
|
||||
|
||||
return categoryResp, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
err = a.categories.CreateCategory(c, category)
|
||||
|
||||
if err != nil {
|
||||
@@ -128,6 +153,7 @@ func (a *TransactionCategoriesApi) CategoryCreateHandler(c *core.Context) (any,
|
||||
|
||||
log.InfofWithRequestId(c, "[transaction_categories.CategoryCreateHandler] user \"uid:%d\" has created a new category \"id:%d\" successfully", uid, category.CategoryId)
|
||||
|
||||
duplicatechecker.Container.Set(duplicatechecker.DUPLICATE_CHECKER_TYPE_NEW_CATEGORY, uid, categoryCreateReq.ClientSessionId, utils.Int64ToString(category.CategoryId))
|
||||
categoryResp := category.ToTransactionCategoryInfoResponse()
|
||||
|
||||
return categoryResp, nil
|
||||
|
||||
@@ -7,10 +7,12 @@ import (
|
||||
orderedmap "github.com/wk8/go-ordered-map/v2"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/duplicatechecker"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/services"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||
)
|
||||
|
||||
@@ -625,6 +627,28 @@ func (a *TransactionsApi) TransactionCreateHandler(c *core.Context) (any, *errs.
|
||||
return nil, errs.ErrCannotCreateTransactionWithThisTransactionTime
|
||||
}
|
||||
|
||||
if settings.Container.Current.EnableDuplicateSubmissionsCheck && transactionCreateReq.ClientSessionId != "" {
|
||||
found, remark := duplicatechecker.Container.Get(duplicatechecker.DUPLICATE_CHECKER_TYPE_NEW_TRANSACTION, uid, transactionCreateReq.ClientSessionId)
|
||||
|
||||
if found {
|
||||
log.InfofWithRequestId(c, "[transactions.TransactionCreateHandler] another transaction \"id:%s\" has been created for user \"uid:%d\"", remark, uid)
|
||||
transactionId, err := utils.StringToInt64(remark)
|
||||
|
||||
if err == nil {
|
||||
transaction, err = a.transactions.GetTransactionByTransactionId(c, uid, transactionId)
|
||||
|
||||
if err != nil {
|
||||
log.ErrorfWithRequestId(c, "[transactions.TransactionCreateHandler] failed to get existed transaction \"id:%d\" for user \"uid:%d\", because %s", transactionId, uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
transactionResp := transaction.ToTransactionInfoResponse(tagIds, transactionEditable)
|
||||
|
||||
return transactionResp, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
err = a.transactions.CreateTransaction(c, transaction, tagIds)
|
||||
|
||||
if err != nil {
|
||||
@@ -634,6 +658,7 @@ func (a *TransactionsApi) TransactionCreateHandler(c *core.Context) (any, *errs.
|
||||
|
||||
log.InfofWithRequestId(c, "[transactions.TransactionCreateHandler] user \"uid:%d\" has created a new transaction \"id:%d\" successfully", uid, transaction.TransactionId)
|
||||
|
||||
duplicatechecker.Container.Set(duplicatechecker.DUPLICATE_CHECKER_TYPE_NEW_TRANSACTION, uid, transactionCreateReq.ClientSessionId, utils.Int64ToString(transaction.TransactionId))
|
||||
transactionResp := transaction.ToTransactionInfoResponse(tagIds, transactionEditable)
|
||||
|
||||
return transactionResp, nil
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package duplicatechecker
|
||||
|
||||
// DuplicateChecker is common duplicate checker interface
|
||||
type DuplicateChecker interface {
|
||||
Get(checkerType DuplicateCheckerType, uid int64, identification string) (bool, string)
|
||||
Set(checkerType DuplicateCheckerType, uid int64, identification string, remark string)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package duplicatechecker
|
||||
|
||||
import (
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||
)
|
||||
|
||||
// DuplicateCheckerContainer contains the current duplicate checker
|
||||
type DuplicateCheckerContainer struct {
|
||||
Current DuplicateChecker
|
||||
}
|
||||
|
||||
// Initialize a duplicate checker container singleton instance
|
||||
var (
|
||||
Container = &DuplicateCheckerContainer{}
|
||||
)
|
||||
|
||||
// InitializeDuplicateChecker initializes the current duplicate checker according to the config
|
||||
func InitializeDuplicateChecker(config *settings.Config) error {
|
||||
if config.DuplicateCheckerType == settings.InMemoryDuplicateCheckerType {
|
||||
checker, err := NewInMemoryDuplicateChecker(config)
|
||||
Container.Current = checker
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
return errs.ErrInvalidDuplicateCheckerType
|
||||
}
|
||||
|
||||
// Get returns whether the same submission has been processed and related remark by the current duplicate checker
|
||||
func (c *DuplicateCheckerContainer) Get(checkerType DuplicateCheckerType, uid int64, identification string) (bool, string) {
|
||||
return c.Current.Get(checkerType, uid, identification)
|
||||
}
|
||||
|
||||
// Set saves the identification and remark to in-memory cache by the current duplicate checker
|
||||
func (c *DuplicateCheckerContainer) Set(checkerType DuplicateCheckerType, uid int64, identification string, remark string) {
|
||||
c.Current.Set(checkerType, uid, identification, remark)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package duplicatechecker
|
||||
|
||||
// DuplicateCheckerType represents duplicate checker type
|
||||
type DuplicateCheckerType uint8
|
||||
|
||||
// Types of uuid
|
||||
const (
|
||||
DUPLICATE_CHECKER_TYPE_DEFAULT DuplicateCheckerType = 0
|
||||
DUPLICATE_CHECKER_TYPE_NEW_ACCOUNT DuplicateCheckerType = 1
|
||||
DUPLICATE_CHECKER_TYPE_NEW_CATEGORY DuplicateCheckerType = 2
|
||||
DUPLICATE_CHECKER_TYPE_NEW_TRANSACTION DuplicateCheckerType = 3
|
||||
)
|
||||
@@ -0,0 +1,43 @@
|
||||
package duplicatechecker
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/patrickmn/go-cache"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||
)
|
||||
|
||||
// InMemoryDuplicateChecker represents in-memory duplicate checker
|
||||
type InMemoryDuplicateChecker struct {
|
||||
cache *cache.Cache
|
||||
}
|
||||
|
||||
// NewInMemoryDuplicateChecker returns a new in-memory duplicate checker
|
||||
func NewInMemoryDuplicateChecker(config *settings.Config) (*InMemoryDuplicateChecker, error) {
|
||||
checker := &InMemoryDuplicateChecker{
|
||||
cache: cache.New(config.DuplicateSubmissionsIntervalDuration, config.InMemoryDuplicateCheckerCleanupIntervalDuration),
|
||||
}
|
||||
|
||||
return checker, nil
|
||||
}
|
||||
|
||||
// Get returns whether the same submission has been processed and related remark
|
||||
func (c *InMemoryDuplicateChecker) Get(checkerType DuplicateCheckerType, uid int64, identification string) (bool, string) {
|
||||
existedRemark, found := c.cache.Get(c.getCacheKey(checkerType, uid, identification))
|
||||
|
||||
if found {
|
||||
return true, existedRemark.(string)
|
||||
}
|
||||
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// Set saves the identification and remark to in-memory cache
|
||||
func (c *InMemoryDuplicateChecker) Set(checkerType DuplicateCheckerType, uid int64, identification string, remark string) {
|
||||
c.cache.Set(c.getCacheKey(checkerType, uid, identification), remark, cache.DefaultExpiration)
|
||||
}
|
||||
|
||||
func (c *InMemoryDuplicateChecker) getCacheKey(checkerType DuplicateCheckerType, uid int64, identification string) string {
|
||||
return fmt.Sprintf("%d|%d|%s", checkerType, uid, identification)
|
||||
}
|
||||
@@ -11,4 +11,5 @@ var (
|
||||
ErrInvalidExchangeRatesDataSource = NewSystemError(SystemSubcategorySetting, 4, http.StatusInternalServerError, "invalid exchange rates data source")
|
||||
ErrInvalidMapProvider = NewSystemError(SystemSubcategorySetting, 5, http.StatusInternalServerError, "invalid map provider")
|
||||
ErrInvalidAmapSecurityVerificationMethod = NewSystemError(SystemSubcategorySetting, 6, http.StatusInternalServerError, "invalid amap security verification method")
|
||||
ErrInvalidDuplicateCheckerType = NewSystemError(SystemSubcategorySetting, 7, http.StatusInternalServerError, "invalid duplicate checker type")
|
||||
)
|
||||
|
||||
+10
-9
@@ -69,15 +69,16 @@ type Account struct {
|
||||
|
||||
// AccountCreateRequest represents all parameters of account creation request
|
||||
type AccountCreateRequest struct {
|
||||
Name string `json:"name" binding:"required,notBlank,max=32"`
|
||||
Category AccountCategory `json:"category" binding:"required"`
|
||||
Type AccountType `json:"type" binding:"required"`
|
||||
Icon int64 `json:"icon,string" binding:"required,min=1"`
|
||||
Color string `json:"color" binding:"required,len=6,validHexRGBColor"`
|
||||
Currency string `json:"currency" binding:"required,len=3,validCurrency"`
|
||||
Balance int64 `json:"balance"`
|
||||
Comment string `json:"comment" binding:"max=255"`
|
||||
SubAccounts []*AccountCreateRequest `json:"subAccounts" binding:"omitempty"`
|
||||
Name string `json:"name" binding:"required,notBlank,max=32"`
|
||||
Category AccountCategory `json:"category" binding:"required"`
|
||||
Type AccountType `json:"type" binding:"required"`
|
||||
Icon int64 `json:"icon,string" binding:"required,min=1"`
|
||||
Color string `json:"color" binding:"required,len=6,validHexRGBColor"`
|
||||
Currency string `json:"currency" binding:"required,len=3,validCurrency"`
|
||||
Balance int64 `json:"balance"`
|
||||
Comment string `json:"comment" binding:"max=255"`
|
||||
SubAccounts []*AccountCreateRequest `json:"subAccounts" binding:"omitempty"`
|
||||
ClientSessionId string `json:"clientSessionId"`
|
||||
}
|
||||
|
||||
// AccountModifyRequest represents all parameters of account modification request
|
||||
|
||||
@@ -74,6 +74,7 @@ type TransactionCreateRequest struct {
|
||||
TagIds []string `json:"tagIds"`
|
||||
Comment string `json:"comment" binding:"max=255"`
|
||||
GeoLocation *TransactionGeoLocationRequest `json:"geoLocation" binding:"omitempty"`
|
||||
ClientSessionId string `json:"clientSessionId"`
|
||||
}
|
||||
|
||||
// TransactionModifyRequest represents all parameters of transaction modification request
|
||||
|
||||
@@ -44,12 +44,13 @@ type TransactionCategoryGetRequest struct {
|
||||
|
||||
// TransactionCategoryCreateRequest represents all parameters of single transaction category creation request
|
||||
type TransactionCategoryCreateRequest struct {
|
||||
Name string `json:"name" binding:"required,notBlank,max=32"`
|
||||
Type TransactionCategoryType `json:"type" binding:"required"`
|
||||
ParentId int64 `json:"parentId,string" binding:"min=0"`
|
||||
Icon int64 `json:"icon,string" binding:"min=1"`
|
||||
Color string `json:"color" binding:"required,len=6,validHexRGBColor"`
|
||||
Comment string `json:"comment" binding:"max=255"`
|
||||
Name string `json:"name" binding:"required,notBlank,max=32"`
|
||||
Type TransactionCategoryType `json:"type" binding:"required"`
|
||||
ParentId int64 `json:"parentId,string" binding:"min=0"`
|
||||
Icon int64 `json:"icon,string" binding:"min=1"`
|
||||
Color string `json:"color" binding:"required,len=6,validHexRGBColor"`
|
||||
Comment string `json:"comment" binding:"max=255"`
|
||||
ClientSessionId string `json:"clientSessionId"`
|
||||
}
|
||||
|
||||
// TransactionCategoryCreateBatchRequest represents all parameters of transaction category batch creation request
|
||||
|
||||
@@ -62,6 +62,11 @@ const (
|
||||
InternalUuidGeneratorType string = "internal"
|
||||
)
|
||||
|
||||
// Duplicate checker types
|
||||
const (
|
||||
InMemoryDuplicateCheckerType string = "in_memory"
|
||||
)
|
||||
|
||||
// User avatar provider types
|
||||
const (
|
||||
GravatarProvider string = "gravatar"
|
||||
@@ -115,6 +120,9 @@ const (
|
||||
defaultLogMode string = "console"
|
||||
defaultLoglevel Level = LOGLEVEL_INFO
|
||||
|
||||
defaultInMemoryDuplicateCheckerCleanupInterval uint32 = 60 // 1 minutes
|
||||
defaultDuplicateSubmissionsInterval uint32 = 300 // 5 minutes
|
||||
|
||||
defaultSecretKey string = "ezbookkeeping"
|
||||
defaultTokenExpiredTime uint32 = 604800 // 7 days
|
||||
defaultTemporaryTokenExpiredTime uint32 = 300 // 5 minutes
|
||||
@@ -196,6 +204,14 @@ type Config struct {
|
||||
UuidGeneratorType string
|
||||
UuidServerId uint8
|
||||
|
||||
// Duplicate Checker
|
||||
DuplicateCheckerType string
|
||||
InMemoryDuplicateCheckerCleanupInterval uint32
|
||||
InMemoryDuplicateCheckerCleanupIntervalDuration time.Duration
|
||||
EnableDuplicateSubmissionsCheck bool
|
||||
DuplicateSubmissionsInterval uint32
|
||||
DuplicateSubmissionsIntervalDuration time.Duration
|
||||
|
||||
// Secret
|
||||
SecretKeyNoSet bool
|
||||
SecretKey string
|
||||
@@ -297,6 +313,12 @@ func LoadConfiguration(configFilePath string) (*Config, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = loadDuplicateCheckerConfiguration(config, cfgFile, "duplicate_checker")
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = loadSecurityConfiguration(config, cfgFile, "security")
|
||||
|
||||
if err != nil {
|
||||
@@ -487,6 +509,30 @@ func loadUuidConfiguration(config *Config, configFile *ini.File, sectionName str
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadDuplicateCheckerConfiguration(config *Config, configFile *ini.File, sectionName string) error {
|
||||
if getConfigItemStringValue(configFile, sectionName, "checker_type") == InMemoryDuplicateCheckerType {
|
||||
config.DuplicateCheckerType = InMemoryDuplicateCheckerType
|
||||
} else {
|
||||
return errs.ErrInvalidDuplicateCheckerType
|
||||
}
|
||||
|
||||
config.InMemoryDuplicateCheckerCleanupInterval = getConfigItemUint32Value(configFile, sectionName, "cleanup_interval", defaultInMemoryDuplicateCheckerCleanupInterval)
|
||||
config.InMemoryDuplicateCheckerCleanupIntervalDuration = time.Duration(config.InMemoryDuplicateCheckerCleanupInterval) * time.Second
|
||||
|
||||
duplicateSubmissionsInterval := getConfigItemUint32Value(configFile, sectionName, "duplicate_submissions_interval", defaultDuplicateSubmissionsInterval)
|
||||
|
||||
config.EnableDuplicateSubmissionsCheck = duplicateSubmissionsInterval > 0
|
||||
|
||||
if duplicateSubmissionsInterval < 1 {
|
||||
duplicateSubmissionsInterval = defaultDuplicateSubmissionsInterval
|
||||
}
|
||||
|
||||
config.DuplicateSubmissionsInterval = duplicateSubmissionsInterval
|
||||
config.DuplicateSubmissionsIntervalDuration = time.Duration(config.DuplicateSubmissionsInterval) * time.Second
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadSecurityConfiguration(config *Config, configFile *ini.File, sectionName string) error {
|
||||
config.SecretKeyNoSet = !getConfigItemIsSet(configFile, sectionName, "secret_key")
|
||||
config.SecretKey = getConfigItemStringValue(configFile, sectionName, "secret_key", defaultSecretKey)
|
||||
|
||||
Reference in New Issue
Block a user