Merge branch 'feat/cache'
This commit is contained in:
@@ -2,6 +2,7 @@ pub use sea_orm_migration::prelude::*;
|
||||
|
||||
mod m20260526_062833_create_account_table;
|
||||
mod m20260526_110957_create_transcations_table;
|
||||
mod m20260528_110733_create_cache_table;
|
||||
|
||||
pub struct Migrator;
|
||||
|
||||
@@ -11,6 +12,7 @@ impl MigratorTrait for Migrator {
|
||||
vec![
|
||||
Box::new(m20260526_062833_create_account_table::Migration),
|
||||
Box::new(m20260526_110957_create_transcations_table::Migration),
|
||||
Box::new(m20260528_110733_create_cache_table::Migration),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
61
crates/migration/src/m20260528_110733_create_cache_table.rs
Normal file
61
crates/migration/src/m20260528_110733_create_cache_table.rs
Normal file
@@ -0,0 +1,61 @@
|
||||
use sea_orm_migration::{prelude::*, schema::*};
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table("cache")
|
||||
.comment("2 level cache for general use")
|
||||
.if_not_exists()
|
||||
.col(string("category").not_null().comment("Cache category"))
|
||||
.col(string("key").not_null().comment("Cache key"))
|
||||
.col(string("value").not_null().comment("Cache value"))
|
||||
.col(
|
||||
boolean("is_invalidated")
|
||||
.not_null()
|
||||
.comment("Whether the cache is manually invalidated"),
|
||||
)
|
||||
.col(string("expire_at").null().comment("Expiration time"))
|
||||
.col(string("created_at").not_null().comment("Creation time"))
|
||||
.col(string("updated_at").not_null().comment("Last update time"))
|
||||
//
|
||||
.primary_key(Index::create().col("category").col("key"))
|
||||
//
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_cache_category")
|
||||
.table("cache")
|
||||
.col("category")
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_cache_expire_at")
|
||||
.table("cache")
|
||||
.col("expire_at")
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.drop_table(Table::drop().table("cache").to_owned())
|
||||
.await
|
||||
}
|
||||
}
|
||||
24
src-tauri/src/db/entities/cache.rs
Normal file
24
src-tauri/src/db/entities/cache.rs
Normal file
@@ -0,0 +1,24 @@
|
||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0
|
||||
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "cache")]
|
||||
#[allow(dead_code)]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub category: String,
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub key: String,
|
||||
pub value: String,
|
||||
pub is_invalidated: bool,
|
||||
pub expire_at: Option<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
@@ -4,6 +4,7 @@ pub mod prelude;
|
||||
|
||||
pub mod account;
|
||||
pub mod account_category;
|
||||
pub mod cache;
|
||||
pub mod category;
|
||||
pub mod tag;
|
||||
pub mod transaction;
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
pub use super::account::Entity as Account;
|
||||
pub use super::account_category::Entity as AccountCategory;
|
||||
pub use super::cache::Entity as Cache;
|
||||
pub use super::category::Entity as Category;
|
||||
pub use super::tag::Entity as Tag;
|
||||
pub use super::transaction::Entity as Transaction;
|
||||
|
||||
108
src-tauri/src/services/cache/dto.rs
vendored
Normal file
108
src-tauri/src/services/cache/dto.rs
vendored
Normal file
@@ -0,0 +1,108 @@
|
||||
use std::fmt::Display;
|
||||
|
||||
use crate::db::entities::cache::Model as CacheModel;
|
||||
|
||||
use sea_orm::DbErr;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::db::DbServiceError;
|
||||
|
||||
pub enum CacheValue {
|
||||
Ok(String),
|
||||
Expired(String),
|
||||
NotFound,
|
||||
}
|
||||
|
||||
impl From<Option<CacheModel>> for CacheValue {
|
||||
fn from(opt: Option<CacheModel>) -> Self {
|
||||
match opt {
|
||||
Some(model) => model.into(),
|
||||
None => CacheValue::NotFound,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<CacheModel> for (String, CacheValue) {
|
||||
fn from(model: CacheModel) -> Self {
|
||||
let key = model.key.clone();
|
||||
let value = model.into();
|
||||
(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<CacheModel> for CacheValue {
|
||||
fn from(model: CacheModel) -> Self {
|
||||
if let Some(expire_at) = model.expire_at {
|
||||
let expire_at = chrono::DateTime::parse_from_rfc3339(&expire_at)
|
||||
.map(|dt| dt.with_timezone(&chrono::Utc));
|
||||
|
||||
match expire_at {
|
||||
Ok(expire_at) if expire_at < chrono::Utc::now() => {
|
||||
return CacheValue::Expired(model.value);
|
||||
}
|
||||
Ok(_) => {} // Not expired
|
||||
Err(_) => {
|
||||
// warn!("Failed to parse expire_at: {}", model.expire_at);
|
||||
// If parsing fails, treat it as expired to be safe
|
||||
return CacheValue::Expired(model.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CacheValue::Ok(model.value)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum CacheServiceError {
|
||||
#[error("database error: {0}")]
|
||||
DbErr(#[from] DbServiceError),
|
||||
#[error("target expired: {0}")]
|
||||
TargetExpired(String),
|
||||
#[error("Target not found: {0}")]
|
||||
TargetNotFound(String),
|
||||
#[error("Validation error: {0}")]
|
||||
Validation(String),
|
||||
#[error("unknown error: {0}")]
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
impl From<DbErr> for CacheServiceError {
|
||||
fn from(value: DbErr) -> Self {
|
||||
CacheServiceError::DbErr(DbServiceError::from(value))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum CacheCategory {
|
||||
Transaction,
|
||||
TransactionTag,
|
||||
Account,
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
impl From<&str> for CacheCategory {
|
||||
fn from(value: &str) -> Self {
|
||||
match value {
|
||||
"Transaction" => CacheCategory::Transaction,
|
||||
"TransactionTag" => CacheCategory::TransactionTag,
|
||||
"Account" => CacheCategory::Account,
|
||||
other => {
|
||||
// warn!("Unknown cache category: {}", other);
|
||||
CacheCategory::Unknown(other.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for CacheCategory {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let s = match self {
|
||||
CacheCategory::Transaction => "Transaction",
|
||||
CacheCategory::TransactionTag => "TransactionTag",
|
||||
CacheCategory::Account => "Account",
|
||||
CacheCategory::Unknown(other) => other.as_str(),
|
||||
};
|
||||
write!(f, "{}", s)
|
||||
}
|
||||
}
|
||||
6
src-tauri/src/services/cache/mod.rs
vendored
Normal file
6
src-tauri/src/services/cache/mod.rs
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
mod dto;
|
||||
mod repo;
|
||||
mod service;
|
||||
|
||||
pub use dto::*;
|
||||
pub use service::*;
|
||||
176
src-tauri/src/services/cache/repo.rs
vendored
Normal file
176
src-tauri/src/services/cache/repo.rs
vendored
Normal file
@@ -0,0 +1,176 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use migration::Expr;
|
||||
use sea_orm::{ActiveValue::Set, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Value};
|
||||
|
||||
use super::{dto::CacheCategory, service::CacheResult};
|
||||
use crate::db::{
|
||||
Db,
|
||||
entities::cache::{
|
||||
ActiveModel as CacheActiveModel, Column as CacheColumn, Entity as CacheEntity,
|
||||
Model as CacheModel,
|
||||
},
|
||||
};
|
||||
|
||||
type Result<T> = CacheResult<T>;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait CacheRepo: Send + Sync + 'static {
|
||||
async fn get_category(&self, category: CacheCategory) -> Result<HashMap<String, CacheModel>>;
|
||||
async fn get(&self, category: CacheCategory, key: &str) -> Result<Option<CacheModel>>;
|
||||
async fn set_with_tx(
|
||||
&self,
|
||||
tx: &Db,
|
||||
category: CacheCategory,
|
||||
key: &str,
|
||||
value: &str,
|
||||
expire_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
) -> Result<()>;
|
||||
async fn set(
|
||||
&self,
|
||||
category: CacheCategory,
|
||||
key: &str,
|
||||
value: &str,
|
||||
expire_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
) -> Result<()>;
|
||||
async fn invalidate_category(&self, category: CacheCategory) -> Result<()>;
|
||||
async fn invalidate_with_tx(&self, tx: &Db, category: CacheCategory, key: &str) -> Result<()>;
|
||||
async fn invalidate(&self, category: CacheCategory, key: &str) -> Result<()>;
|
||||
async fn set_expire(
|
||||
&self,
|
||||
category: CacheCategory,
|
||||
key: &str,
|
||||
expire_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
) -> Result<()>;
|
||||
}
|
||||
|
||||
pub struct CacheRepoImpl {
|
||||
pub db: DatabaseConnection,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl CacheRepo for CacheRepoImpl {
|
||||
async fn get_category(&self, category: CacheCategory) -> Result<HashMap<String, CacheModel>> {
|
||||
let cache_entries = CacheEntity::find()
|
||||
.filter(CacheColumn::Category.eq(category.to_string()))
|
||||
.all(&self.db)
|
||||
.await?;
|
||||
|
||||
let result = cache_entries
|
||||
.into_iter()
|
||||
.map(|entry| (entry.key.clone(), entry))
|
||||
.collect();
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn get(&self, category: CacheCategory, key: &str) -> Result<Option<CacheModel>> {
|
||||
let cache_entry = CacheEntity::find()
|
||||
.filter(CacheColumn::Category.eq(category.to_string()))
|
||||
.filter(CacheColumn::Key.eq(key.to_string()))
|
||||
.one(&self.db)
|
||||
.await?;
|
||||
|
||||
Ok(cache_entry)
|
||||
}
|
||||
|
||||
async fn set(
|
||||
&self,
|
||||
category: CacheCategory,
|
||||
key: &str,
|
||||
value: &str,
|
||||
expire_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
) -> Result<()> {
|
||||
self.set_with_tx(&(&self.db).into(), category, key, value, expire_at)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn set_with_tx(
|
||||
&self,
|
||||
tx: &Db,
|
||||
category: CacheCategory,
|
||||
key: &str,
|
||||
value: &str,
|
||||
expire_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
) -> Result<()> {
|
||||
let expire_at_str = expire_at.map(|dt| dt.to_rfc3339());
|
||||
let now_str = chrono::Utc::now().to_rfc3339();
|
||||
let active_model = CacheActiveModel {
|
||||
category: Set(category.to_string()),
|
||||
key: Set(key.to_string()),
|
||||
value: Set(value.to_string()),
|
||||
is_invalidated: Set(false),
|
||||
expire_at: Set(expire_at_str),
|
||||
created_at: Set(now_str.clone()),
|
||||
updated_at: Set(now_str),
|
||||
};
|
||||
|
||||
CacheEntity::insert(active_model)
|
||||
.on_conflict(
|
||||
sea_orm::sea_query::OnConflict::columns([CacheColumn::Category, CacheColumn::Key])
|
||||
.update_columns([
|
||||
CacheColumn::Value,
|
||||
CacheColumn::IsInvalidated,
|
||||
CacheColumn::ExpireAt,
|
||||
CacheColumn::UpdatedAt,
|
||||
])
|
||||
.to_owned(),
|
||||
)
|
||||
.exec(tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn invalidate_category(&self, category: CacheCategory) -> Result<()> {
|
||||
CacheEntity::update_many()
|
||||
.col_expr(
|
||||
CacheColumn::IsInvalidated,
|
||||
Expr::value(Value::Bool(Some(true))),
|
||||
)
|
||||
.filter(CacheColumn::Category.eq(category.to_string()))
|
||||
.exec(&self.db)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn invalidate(&self, category: CacheCategory, key: &str) -> Result<()> {
|
||||
self.invalidate_with_tx(&(&self.db).into(), category, key)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn invalidate_with_tx(&self, tx: &Db, category: CacheCategory, key: &str) -> Result<()> {
|
||||
let target_entry = CacheActiveModel {
|
||||
// pks
|
||||
category: Set(category.to_string()),
|
||||
key: Set(key.to_string()),
|
||||
// update
|
||||
is_invalidated: Set(true),
|
||||
..Default::default()
|
||||
};
|
||||
CacheEntity::update(target_entry)
|
||||
.validate()?
|
||||
.exec(tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
async fn set_expire(
|
||||
&self,
|
||||
category: CacheCategory,
|
||||
key: &str,
|
||||
expire_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
) -> Result<()> {
|
||||
let expire_at_str = expire_at.map(|dt| dt.to_rfc3339());
|
||||
let target_entry = CacheActiveModel {
|
||||
// pks
|
||||
category: Set(category.to_string()),
|
||||
key: Set(key.to_string()),
|
||||
// update
|
||||
expire_at: Set(expire_at_str),
|
||||
..Default::default()
|
||||
};
|
||||
CacheEntity::update(target_entry)
|
||||
.validate()?
|
||||
.exec(&self.db)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
123
src-tauri/src/services/cache/service.rs
vendored
Normal file
123
src-tauri/src/services/cache/service.rs
vendored
Normal file
@@ -0,0 +1,123 @@
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
use sea_orm::DatabaseConnection;
|
||||
|
||||
use super::dto::CacheCategory;
|
||||
use crate::{
|
||||
db::Db,
|
||||
services::cache::{CacheServiceError, CacheValue},
|
||||
};
|
||||
|
||||
pub type CacheResult<T> = std::result::Result<T, CacheServiceError>;
|
||||
type Result<T> = CacheResult<T>;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait CacheService: Send + Sync + 'static {
|
||||
async fn get_category(&self, category: CacheCategory) -> Result<HashMap<String, CacheValue>>;
|
||||
async fn get(&self, category: CacheCategory, key: &str) -> Result<CacheValue>;
|
||||
async fn set_with_tx(
|
||||
&self,
|
||||
tx: &Db,
|
||||
category: CacheCategory,
|
||||
key: &str,
|
||||
value: &str,
|
||||
expire_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
) -> Result<()>;
|
||||
async fn set(
|
||||
&self,
|
||||
category: CacheCategory,
|
||||
key: &str,
|
||||
value: &str,
|
||||
expire_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
) -> Result<()>;
|
||||
async fn invalidate_category(&self, category: CacheCategory) -> Result<()>;
|
||||
async fn invalidate_with_tx(&self, tx: &Db, category: CacheCategory, key: &str) -> Result<()>;
|
||||
async fn invalidate(&self, category: CacheCategory, key: &str) -> Result<()>;
|
||||
async fn set_expire(
|
||||
&self,
|
||||
category: CacheCategory,
|
||||
key: &str,
|
||||
expire_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
) -> Result<()>;
|
||||
}
|
||||
|
||||
pub struct CacheServiceImpl<T>
|
||||
where
|
||||
T: super::repo::CacheRepo + ?Sized,
|
||||
{
|
||||
db: DatabaseConnection,
|
||||
repo: Arc<T>,
|
||||
}
|
||||
|
||||
impl CacheServiceImpl<super::repo::CacheRepoImpl> {
|
||||
pub fn new(db: DatabaseConnection) -> Self {
|
||||
Self {
|
||||
db: db.clone(),
|
||||
repo: Arc::new(super::repo::CacheRepoImpl { db }),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl<T> CacheService for CacheServiceImpl<T>
|
||||
where
|
||||
T: super::repo::CacheRepo + ?Sized,
|
||||
{
|
||||
async fn get_category(&self, category: CacheCategory) -> Result<HashMap<String, CacheValue>> {
|
||||
let cache_entries = self.repo.get_category(category).await?;
|
||||
|
||||
let result = cache_entries
|
||||
.into_iter()
|
||||
.map(|(key, model)| (key, model.into()))
|
||||
.collect();
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn get(&self, category: CacheCategory, key: &str) -> Result<CacheValue> {
|
||||
let cache_entry = self.repo.get(category, key).await?;
|
||||
Ok(cache_entry.into())
|
||||
}
|
||||
|
||||
async fn set(
|
||||
&self,
|
||||
category: CacheCategory,
|
||||
key: &str,
|
||||
value: &str,
|
||||
expire_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
) -> Result<()> {
|
||||
self.repo.set(category, key, value, expire_at).await
|
||||
}
|
||||
|
||||
async fn set_with_tx(
|
||||
&self,
|
||||
tx: &Db,
|
||||
category: CacheCategory,
|
||||
key: &str,
|
||||
value: &str,
|
||||
expire_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
) -> Result<()> {
|
||||
self.repo
|
||||
.set_with_tx(tx, category, key, value, expire_at)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn invalidate_category(&self, category: CacheCategory) -> Result<()> {
|
||||
self.repo.invalidate_category(category).await
|
||||
}
|
||||
|
||||
async fn invalidate(&self, category: CacheCategory, key: &str) -> Result<()> {
|
||||
self.repo.invalidate(category, key).await
|
||||
}
|
||||
|
||||
async fn invalidate_with_tx(&self, tx: &Db, category: CacheCategory, key: &str) -> Result<()> {
|
||||
self.repo.invalidate_with_tx(tx, category, key).await
|
||||
}
|
||||
async fn set_expire(
|
||||
&self,
|
||||
category: CacheCategory,
|
||||
key: &str,
|
||||
expire_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
) -> Result<()> {
|
||||
self.repo.set_expire(category, key, expire_at).await
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use crate::services::{
|
||||
AccountsService, AccountsServiceImpl,
|
||||
category::{AccountCategoryService, AccountCategoryServiceImpl},
|
||||
},
|
||||
cache::{CacheService, CacheServiceImpl},
|
||||
transaction::{
|
||||
TransactionService, TransactionServiceImpl,
|
||||
category::{CategoryService, CategoryServiceImpl},
|
||||
@@ -15,6 +16,7 @@ use crate::services::{
|
||||
};
|
||||
|
||||
pub mod accounts;
|
||||
pub mod cache;
|
||||
pub mod transaction;
|
||||
|
||||
pub type AppState = Services<
|
||||
@@ -23,22 +25,25 @@ pub type AppState = Services<
|
||||
dyn TransactionService,
|
||||
dyn CategoryService,
|
||||
dyn TagService,
|
||||
dyn CacheService,
|
||||
>;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Services<AC, A, T, TC, TT>
|
||||
pub struct Services<AC, A, T, TC, TT, C>
|
||||
where
|
||||
AC: AccountCategoryService + ?Sized,
|
||||
A: AccountsService + ?Sized,
|
||||
T: TransactionService + ?Sized,
|
||||
TC: CategoryService + ?Sized,
|
||||
TT: TagService + ?Sized,
|
||||
C: CacheService + ?Sized,
|
||||
{
|
||||
pub account_category: Arc<AC>,
|
||||
pub accounts: Arc<A>,
|
||||
pub transaction: Arc<T>,
|
||||
pub transaction_category: Arc<TC>,
|
||||
pub transaction_tag: Arc<TT>,
|
||||
pub cache: Arc<C>,
|
||||
}
|
||||
|
||||
pub fn create_services(db: DatabaseConnection) -> AppState {
|
||||
@@ -51,6 +56,8 @@ pub fn create_services(db: DatabaseConnection) -> AppState {
|
||||
let transaction_category_service: Arc<dyn CategoryService> =
|
||||
Arc::new(CategoryServiceImpl::new(db.clone()));
|
||||
let transaction_tag_service: Arc<dyn TagService> = Arc::new(TagServiceImpl::new(db.clone()));
|
||||
let cache_service: Arc<dyn CacheService> = Arc::new(CacheServiceImpl::new(db.clone()));
|
||||
|
||||
Services {
|
||||
account_category: account_category_service.clone(),
|
||||
accounts: account_service.clone(),
|
||||
@@ -62,5 +69,6 @@ pub fn create_services(db: DatabaseConnection) -> AppState {
|
||||
)) as Arc<dyn TransactionService>,
|
||||
transaction_category: transaction_category_service,
|
||||
transaction_tag: transaction_tag_service,
|
||||
cache: cache_service,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user