├── .env ├── .gitignore ├── migrations ├── 20210221023242_migrate_name.down.sql └── 20210221023242_migrate_name.up.sql ├── docs └── img │ ├── layers-1.png │ ├── layers-2.png │ └── example-http-db.png ├── pkg ├── pwsql │ ├── relation │ │ ├── mysql │ │ │ ├── init.sql │ │ │ ├── options.go │ │ │ └── gorm_mysql.go │ │ ├── postgresql │ │ │ ├── options.go │ │ │ └── gorm_postgres.go │ │ ├── sql.go │ │ └── gorm_mock.go │ ├── interface.go │ └── nosql │ │ └── firestoreDB │ │ └── firestoreDB.go ├── cache │ ├── redis │ │ ├── options.go │ │ └── redis_cache.go │ └── local │ │ └── bigcache_cache.go ├── message │ ├── options.go │ ├── kafka_tracer.go │ └── kafka_message.go ├── event_store │ └── event_store_db.go ├── operations │ ├── tracer.go │ └── stack_driver.go └── httpserver │ ├── options.go │ └── server.go ├── internal ├── domain │ ├── event │ │ ├── error.go │ │ ├── event_consumer.go │ │ ├── event_producer.go │ │ ├── event_store.go │ │ ├── user_event.go │ │ ├── role_event.go │ │ ├── event_type_mapper.go │ │ └── domain_event.go │ ├── repository │ │ ├── wallet_balance_repository.go │ │ ├── group_repository.go │ │ ├── role_repository.go │ │ ├── user_repository.go │ │ ├── wallet_repository.go │ │ └── currency_repository.go │ ├── entity.go │ ├── entity │ │ ├── walletBalance.go │ │ ├── error.go │ │ ├── currency.go │ │ ├── group.go │ │ ├── wallet.go │ │ ├── user.go │ │ └── role.go │ ├── repository.go │ ├── aggregate │ │ └── aggregate_base.go │ ├── search_query.go │ └── domainerrors │ │ └── error.go ├── adapter │ ├── message │ │ ├── role │ │ │ ├── error.go │ │ │ └── router.go │ │ ├── user │ │ │ ├── error.go │ │ │ └── router.go │ │ └── router.go │ └── http │ │ ├── v1 │ │ ├── role │ │ │ ├── error.go │ │ │ ├── request.go │ │ │ └── response.go │ │ ├── user │ │ │ ├── error.go │ │ │ ├── request.go │ │ │ └── response.go │ │ ├── group │ │ │ ├── error.go │ │ │ ├── request.go │ │ │ └── response.go │ │ ├── wallet │ │ │ ├── error.go │ │ │ ├── request.go │ │ │ └── response.go │ │ ├── currency │ │ │ ├── error.go │ │ │ ├── request.go │ │ │ └── response.go │ │ └── router.go │ │ └── response.go ├── infra │ ├── datasource │ │ ├── cache │ │ │ └── error.go │ │ ├── event_store │ │ │ ├── error.go │ │ │ └── event_store_esdb_impl.go │ │ ├── sql │ │ │ ├── transaction_event_impl.go │ │ │ ├── error.go │ │ │ └── transaction_run_sql_impl.go │ │ ├── nosqlfs │ │ │ ├── transaction_event_impl.go │ │ │ ├── error.go │ │ │ └── transaction_run_firestore_impl.go │ │ └── interface.go │ ├── dto │ │ ├── interface.go │ │ ├── error.go │ │ ├── list.go │ │ ├── utils.go │ │ ├── currency.go │ │ ├── group.go │ │ ├── walletBalance.go │ │ ├── user.go │ │ ├── wallet.go │ │ └── role.go │ ├── role │ │ └── repo_impl.go │ ├── user │ │ └── repo_impl.go │ ├── group │ │ └── repo_impl.go │ ├── wallet │ │ └── repo_impl.go │ ├── currency │ │ └── repo_impl.go │ ├── repository │ │ ├── transaction_repo_impl.go │ │ └── error.go │ └── wallet_balance │ │ └── repo_impl.go ├── application │ ├── group │ │ ├── error.go │ │ └── interface.go │ ├── user │ │ ├── error.go │ │ ├── interface.go │ │ └── dto.go │ ├── wallet │ │ ├── error.go │ │ ├── interface.go │ │ └── dto.go │ ├── currency │ │ ├── error.go │ │ └── interface.go │ ├── role │ │ ├── error.go │ │ └── interface.go │ ├── service.go │ └── utils │ │ └── utils.go └── app │ ├── migrate.go │ └── app.go ├── .env.example ├── .github ├── dependabot.yml └── workflows │ └── ci.yml ├── cmd └── app │ └── main.go ├── tests ├── user │ └── features │ │ └── user.feature ├── role │ ├── features │ │ └── usecase │ │ │ ├── role_deleted.feature │ │ │ ├── role_list_got.feature │ │ │ ├── role_assigned.feature │ │ │ ├── role_updated.feature │ │ │ └── role_created.feature │ ├── sql_test.go │ └── delete_test.go ├── mocks │ ├── EventProducer_mock.go │ ├── EventStore_mock.go │ ├── role │ │ └── RoleService_mock.go │ └── user │ │ └── UserService_mock.go └── utils.go ├── .vscode ├── launch.json └── settings.json ├── integration-test ├── Dockerfile └── integration_test.go ├── Dockerfile ├── LICENSE ├── config └── dev.yml ├── docker-compose.yml ├── .golangci.yml ├── Makefile └── docker-compose.dev.yml /.env: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | tests/report 2 | resources/gcp-credentials.json 3 | -------------------------------------------------------------------------------- /migrations/20210221023242_migrate_name.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS history; -------------------------------------------------------------------------------- /docs/img/layers-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/program-world-labs/DDDGo/HEAD/docs/img/layers-1.png -------------------------------------------------------------------------------- /docs/img/layers-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/program-world-labs/DDDGo/HEAD/docs/img/layers-2.png -------------------------------------------------------------------------------- /docs/img/example-http-db.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/program-world-labs/DDDGo/HEAD/docs/img/example-http-db.png -------------------------------------------------------------------------------- /pkg/pwsql/relation/mysql/init.sql: -------------------------------------------------------------------------------- 1 | GRANT ALL PRIVILEGES ON *.* TO 'user'@'%' WITH GRANT OPTION; 2 | FLUSH PRIVILEGES; 3 | -------------------------------------------------------------------------------- /internal/domain/event/error.go: -------------------------------------------------------------------------------- 1 | package event 2 | 3 | import "errors" 4 | 5 | var ( 6 | ErrNewEventTypeMapper = errors.New("unknown event") 7 | ) 8 | -------------------------------------------------------------------------------- /pkg/pwsql/interface.go: -------------------------------------------------------------------------------- 1 | package pwsql 2 | 3 | import "gorm.io/gorm" 4 | 5 | type ISQLGorm interface { 6 | GetDB() *gorm.DB 7 | Close() error 8 | } 9 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | DISABLE_SWAGGER_HTTP_HANDLER=true 2 | GIN_MODE=release 3 | PG_URL=postgres://user:pass@localhost:5432/postgres 4 | RMQ_URL=amqp://guest:guest@localhost:5672/ 5 | -------------------------------------------------------------------------------- /internal/domain/repository/wallet_balance_repository.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import "github.com/program-world-labs/DDDGo/internal/domain" 4 | 5 | type WalletBalanceRepository interface { 6 | domain.ICRUDRepository 7 | } 8 | -------------------------------------------------------------------------------- /internal/domain/repository/group_repository.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "github.com/program-world-labs/DDDGo/internal/domain" 5 | ) 6 | 7 | type GroupRepository interface { 8 | domain.ICRUDRepository 9 | } 10 | -------------------------------------------------------------------------------- /internal/domain/repository/role_repository.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "github.com/program-world-labs/DDDGo/internal/domain" 5 | ) 6 | 7 | type RoleRepository interface { 8 | domain.ICRUDRepository 9 | } 10 | -------------------------------------------------------------------------------- /internal/domain/repository/user_repository.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "github.com/program-world-labs/DDDGo/internal/domain" 5 | ) 6 | 7 | type UserRepository interface { 8 | domain.ICRUDRepository 9 | } 10 | -------------------------------------------------------------------------------- /internal/domain/repository/wallet_repository.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "github.com/program-world-labs/DDDGo/internal/domain" 5 | ) 6 | 7 | type WalletRepository interface { 8 | domain.ICRUDRepository 9 | } 10 | -------------------------------------------------------------------------------- /internal/domain/repository/currency_repository.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "github.com/program-world-labs/DDDGo/internal/domain" 5 | ) 6 | 7 | type CurrencyRepository interface { 8 | domain.ICRUDRepository 9 | } 10 | -------------------------------------------------------------------------------- /migrations/20210221023242_migrate_name.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS history( 2 | id serial PRIMARY KEY, 3 | source VARCHAR(255), 4 | destination VARCHAR(255), 5 | original VARCHAR(255), 6 | translation VARCHAR(255) 7 | ); -------------------------------------------------------------------------------- /internal/domain/event/event_consumer.go: -------------------------------------------------------------------------------- 1 | package event 2 | 3 | import "context" 4 | 5 | type MessageHandlerFunc func(key string, message string) error 6 | 7 | type Consumer interface { 8 | SubscribeEvent(ctx context.Context, topic string, handler MessageHandlerFunc) error 9 | Close() error 10 | } 11 | -------------------------------------------------------------------------------- /internal/domain/event/event_producer.go: -------------------------------------------------------------------------------- 1 | package event 2 | 3 | import "context" 4 | 5 | type Producer interface { 6 | // PublishEvent publish event to pubsub server. 7 | PublishEvent(ctx context.Context, topic string, event interface{}) error 8 | 9 | // Close connection. 10 | Close() error 11 | } 12 | -------------------------------------------------------------------------------- /internal/domain/entity.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | type IEntity interface { 4 | GetID() string 5 | SetID(string) 6 | } 7 | 8 | type List struct { 9 | Limit int64 `json:"limit"` 10 | Offset int64 `json:"offset"` 11 | Total int64 `json:"total"` 12 | Data []IEntity `json:"data"` 13 | } 14 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: gomod 4 | directory: / 5 | schedule: 6 | interval: daily 7 | open-pull-requests-limit: 10 8 | 9 | - package-ecosystem: "github-actions" 10 | directory: / 11 | schedule: 12 | interval: daily 13 | open-pull-requests-limit: 10 14 | 15 | -------------------------------------------------------------------------------- /internal/domain/event/event_store.go: -------------------------------------------------------------------------------- 1 | package event 2 | 3 | import "context" 4 | 5 | type Store interface { 6 | Store(ctx context.Context, events []DomainEvent, version int) error 7 | Load(ctx context.Context, aggregateID string, version int) ([]DomainEvent, error) 8 | SafeStore(ctx context.Context, events []DomainEvent, expectedVersion int) error 9 | } 10 | -------------------------------------------------------------------------------- /internal/adapter/message/role/error.go: -------------------------------------------------------------------------------- 1 | package role 2 | 3 | import ( 4 | "github.com/program-world-labs/DDDGo/internal/domain/domainerrors" 5 | ) 6 | 7 | const ( 8 | ErrorCodeAdapterMessageRole = domainerrors.ErrorCodeAdapter + domainerrors.ErrorCodeAdapterMessage + domainerrors.ErrorCodeAdapterRole + iota 9 | ErrorCodeCopyToInput 10 | ErrorCodeHandleMessage 11 | ) 12 | -------------------------------------------------------------------------------- /internal/adapter/message/user/error.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "github.com/program-world-labs/DDDGo/internal/domain/domainerrors" 5 | ) 6 | 7 | const ( 8 | ErrorCodeAdapterMessageUser = domainerrors.ErrorCodeAdapter + domainerrors.ErrorCodeAdapterMessage + domainerrors.ErrorCodeAdapterUser + iota 9 | ErrorCodeCopyToInput 10 | ErrorCodeHandleMessage 11 | ) 12 | -------------------------------------------------------------------------------- /cmd/app/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/program-world-labs/DDDGo/config" 7 | "github.com/program-world-labs/DDDGo/internal/app" 8 | ) 9 | 10 | func main() { 11 | // Configuration 12 | cfg, err := config.NewConfig() 13 | if err != nil { 14 | log.Fatalf("Config error: %s", err) 15 | } 16 | 17 | // Run 18 | app.Run(cfg) 19 | } 20 | -------------------------------------------------------------------------------- /internal/infra/datasource/cache/error.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import ( 4 | "github.com/program-world-labs/DDDGo/internal/domain/domainerrors" 5 | ) 6 | 7 | const ( 8 | ErrorCodeCacheSet = domainerrors.ErrorCodeInfraDatasource + domainerrors.ErrorCodeInfraDatasource + domainerrors.ErrorCodeInfraDatasourceCache + iota 9 | ErrorCodeCacheDelete 10 | ErrorCodeCacheGet 11 | ) 12 | -------------------------------------------------------------------------------- /internal/domain/event/user_event.go: -------------------------------------------------------------------------------- 1 | package event 2 | 3 | type UserCreatedEvent struct { 4 | UserName string `json:"user_name"` 5 | Password string `json:"password"` 6 | EMail string `json:"email"` 7 | } 8 | 9 | type UserPasswordChangedEvent struct { 10 | Password string `json:"password"` 11 | } 12 | 13 | type UserEmailChangedEvent struct { 14 | EMail string `json:"email"` 15 | } 16 | -------------------------------------------------------------------------------- /pkg/cache/redis/options.go: -------------------------------------------------------------------------------- 1 | package redis 2 | 3 | import "time" 4 | 5 | // Option -. 6 | type Option func(*Redis) 7 | 8 | // MaxRetries -. 9 | func MaxRetries(attempts int) Option { 10 | return func(c *Redis) { 11 | c.maxRetries = attempts 12 | } 13 | } 14 | 15 | // RetryDelay -. 16 | func RetryDelay(t time.Duration) Option { 17 | return func(c *Redis) { 18 | c.retryDelay = t 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /internal/infra/datasource/event_store/error.go: -------------------------------------------------------------------------------- 1 | package eventstore 2 | 3 | import ( 4 | "github.com/program-world-labs/DDDGo/internal/domain/domainerrors" 5 | ) 6 | 7 | const ( 8 | ErrorCodeCacheSet = domainerrors.ErrorCodeInfraDatasource + domainerrors.ErrorCodeInfraDatasource + domainerrors.ErrorCodeInfraDatasourceEventStore + iota 9 | ErrorCodeResourceNotFound 10 | ErrorCodeEventFormatWrong 11 | ) 12 | -------------------------------------------------------------------------------- /internal/domain/event/role_event.go: -------------------------------------------------------------------------------- 1 | package event 2 | 3 | type RoleCreatedEvent struct { 4 | Name string `json:"name"` 5 | Description string `json:"description"` 6 | Permissions []string `json:"permissions"` 7 | } 8 | 9 | type RoleDescriptionChangedEvent struct { 10 | Description string `json:"description"` 11 | } 12 | 13 | type RolePermissionUpdatedEvent struct { 14 | Permissions []string `json:"permissions"` 15 | } 16 | -------------------------------------------------------------------------------- /internal/domain/entity/walletBalance.go: -------------------------------------------------------------------------------- 1 | package entity 2 | 3 | import "time" 4 | 5 | type WalletBalance struct { 6 | ID string `json:"id"` 7 | WalletID string `json:"walletId"` 8 | CurrencyID string `json:"currencyId"` 9 | Balance uint `json:"balance"` 10 | Decimal uint `json:"decimal"` 11 | CreatedAt time.Time `json:"created_at"` 12 | UpdatedAt time.Time `json:"updated_at"` 13 | DeletedAt time.Time `json:"deleted_at"` 14 | } 15 | -------------------------------------------------------------------------------- /internal/application/group/error.go: -------------------------------------------------------------------------------- 1 | package group 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/program-world-labs/DDDGo/internal/domain/domainerrors" 7 | ) 8 | 9 | const ( 10 | ErrorCodeRepository = domainerrors.ErrorCodeApplication + domainerrors.ErrorCodeApplicationGroup + iota 11 | ErrorCodeValidateInput 12 | ErrorCodeCast 13 | ) 14 | 15 | var ( 16 | ErrValidation = errors.New("validation failed") 17 | ErrCastToEntityFailed = errors.New("cast to entity failed") 18 | ) 19 | -------------------------------------------------------------------------------- /internal/application/user/error.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/program-world-labs/DDDGo/internal/domain/domainerrors" 7 | ) 8 | 9 | const ( 10 | ErrorCodeRepository = domainerrors.ErrorCodeApplication + domainerrors.ErrorCodeApplicationUser + iota 11 | ErrorCodeValidateInput 12 | ErrorCodeCast 13 | ) 14 | 15 | var ( 16 | ErrValidation = errors.New("validation failed") 17 | ErrCastToEntityFailed = errors.New("cast to entity failed") 18 | ) 19 | -------------------------------------------------------------------------------- /internal/application/wallet/error.go: -------------------------------------------------------------------------------- 1 | package wallet 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/program-world-labs/DDDGo/internal/domain/domainerrors" 7 | ) 8 | 9 | const ( 10 | ErrorCodeRepository = domainerrors.ErrorCodeApplication + domainerrors.ErrorCodeApplicationWallet + iota 11 | ErrorCodeValidateInput 12 | ErrorCodeCast 13 | ) 14 | 15 | var ( 16 | ErrValidation = errors.New("validation failed") 17 | ErrCastToEntityFailed = errors.New("cast to entity failed") 18 | ) 19 | -------------------------------------------------------------------------------- /internal/application/currency/error.go: -------------------------------------------------------------------------------- 1 | package currency 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/program-world-labs/DDDGo/internal/domain/domainerrors" 7 | ) 8 | 9 | const ( 10 | ErrorCodeRepository = domainerrors.ErrorCodeApplication + domainerrors.ErrorCodeApplicationCurrency + iota 11 | ErrorCodeValidateInput 12 | ErrorCodeCast 13 | ) 14 | 15 | var ( 16 | ErrValidation = errors.New("validation failed") 17 | ErrCastToEntityFailed = errors.New("cast to entity failed") 18 | ) 19 | -------------------------------------------------------------------------------- /tests/user/features/user.feature: -------------------------------------------------------------------------------- 1 | Feature: 使用者 for usecase 2 | 測試使用者相關的usecase功能 3 | 4 | @TestCaseKey=LA-T1 5 | Scenario: 註冊一個新用戶成功 6 | Given 註冊的使用者不存在 7 | When 註冊一個新用戶 8 | Then 用戶註冊成功 9 | 10 | @TestCaseKey=LA-T2 11 | Scenario: 註冊一個新用戶失敗 12 | Given 註冊的使用者已經存在 13 | When 註冊一個新用戶 14 | Then 用戶註冊失敗 15 | 16 | @TestCaseKey=LA-T3 17 | Scenario: 取得使用者個人資料 18 | Given 使用者已經登入 19 | When 取得使用者個人資料 20 | Then 取得使用者個人資料成功 21 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // 使用 IntelliSense 以得知可用的屬性。 3 | // 暫留以檢視現有屬性的描述。 4 | // 如需詳細資訊,請瀏覽: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Launch Package", 9 | "type": "go", 10 | "request": "launch", 11 | "mode": "auto", 12 | "program": "${workspaceFolder}/cmd/app", 13 | "env": { 14 | "APP_ENV": "dev", 15 | "APP_GCP_CREDENTIALS": "../../resources/gcp-credentials.json" 16 | } 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /internal/application/role/error.go: -------------------------------------------------------------------------------- 1 | package role 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/program-world-labs/DDDGo/internal/domain/domainerrors" 7 | ) 8 | 9 | const ( 10 | ErrorCodeRepository = domainerrors.ErrorCodeApplicationRole + domainerrors.ErrorCodeApplicationRole + iota 11 | ErrorCodeValidateInput 12 | ErrorCodeCast 13 | ErrorCodeApplyEvent 14 | ErrorCodePublishEvent 15 | ) 16 | 17 | var ( 18 | ErrValidation = errors.New("validation failed") 19 | ErrCastToEntityFailed = errors.New("cast to entity failed") 20 | ) 21 | -------------------------------------------------------------------------------- /tests/role/features/usecase/role_deleted.feature: -------------------------------------------------------------------------------- 1 | Feature: 刪除角色 2 | 測試刪除角色相關的usecase功能 3 | 4 | Scenario: 成功刪除角色 5 | Given 提供 6 | When ID存在並嘗試刪除角色 7 | Then 角色成功被刪除 8 | 9 | Examples: 10 | |id| 11 | |role1| 12 | 13 | Scenario: 提供的角色ID不存在 14 | Given 提供不存在的角色ID 15 | When ID不存在並嘗試刪除角色 16 | Then 返回一個錯誤,說明角色ID不存在 17 | 18 | 19 | Scenario: 嘗試刪除一個已經被分配給用戶的角色 20 | Given 提供一個已經被分配給用戶的角色 21 | When ID存在並且角色ID已經被分配給用戶 22 | Then 返回一個錯誤,說明角色已經被分配給用戶 23 | -------------------------------------------------------------------------------- /pkg/pwsql/relation/mysql/options.go: -------------------------------------------------------------------------------- 1 | package mysql 2 | 3 | import "time" 4 | 5 | // Option -. 6 | type Option func(*MySQL) 7 | 8 | // MaxPoolSize -. 9 | func MaxPoolSize(size int) Option { 10 | return func(c *MySQL) { 11 | c.maxPoolSize = size 12 | } 13 | } 14 | 15 | // ConnAttempts -. 16 | func ConnAttempts(attempts int) Option { 17 | return func(c *MySQL) { 18 | c.connAttempts = attempts 19 | } 20 | } 21 | 22 | // ConnTimeout -. 23 | func ConnTimeout(timeout time.Duration) Option { 24 | return func(c *MySQL) { 25 | c.connTimeout = timeout 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /internal/application/wallet/interface.go: -------------------------------------------------------------------------------- 1 | package wallet 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | type IService interface { 8 | // Command 9 | CreateWallet(ctx context.Context, walletInfo *CreatedInput) (*Output, error) 10 | UpdateWallet(ctx context.Context, walletInfo *UpdatedInput) (*Output, error) 11 | DeleteWallet(ctx context.Context, walletInfo *DeletedInput) (*Output, error) 12 | // // Query 13 | GetWalletList(ctx context.Context, walletInfo *ListGotInput) (*OutputList, error) 14 | GetWalletDetail(ctx context.Context, walletInfo *DetailGotInput) (*Output, error) 15 | } 16 | -------------------------------------------------------------------------------- /internal/infra/datasource/sql/transaction_event_impl.go: -------------------------------------------------------------------------------- 1 | package sql 2 | 3 | import ( 4 | "gorm.io/gorm" 5 | 6 | "github.com/program-world-labs/DDDGo/internal/domain" 7 | ) 8 | 9 | var _ domain.ITransactionEvent = (*TransactionEventDataSourceImpl)(nil) 10 | 11 | type TransactionEventDataSourceImpl struct { 12 | DB *gorm.DB 13 | } 14 | 15 | func NewTransactionEventDataSourceImpl(db *gorm.DB) *TransactionEventDataSourceImpl { 16 | return &TransactionEventDataSourceImpl{DB: db} 17 | } 18 | 19 | func (r *TransactionEventDataSourceImpl) GetTx() interface{} { 20 | return r.DB 21 | } 22 | -------------------------------------------------------------------------------- /internal/infra/dto/interface.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | import "github.com/program-world-labs/DDDGo/internal/domain" 4 | 5 | type IRepoEntity interface { 6 | // domain.IEntity 7 | TableName() string 8 | Transform(domain.IEntity) (IRepoEntity, error) 9 | BackToDomain() (domain.IEntity, error) 10 | ParseMap(map[string]interface{}) (IRepoEntity, error) 11 | ToJSON() (string, error) 12 | UnmarshalJSON([]byte) error 13 | GetListType() interface{} 14 | GetPreloads() []string 15 | 16 | // Hook 17 | BeforeCreate() error 18 | BeforeUpdate() error 19 | 20 | GetID() string 21 | SetID(string) 22 | } 23 | -------------------------------------------------------------------------------- /pkg/pwsql/relation/postgresql/options.go: -------------------------------------------------------------------------------- 1 | package postgresql 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | // Option -. 8 | type Option func(*Postgres) 9 | 10 | // MaxPoolSize -. 11 | func MaxPoolSize(size int) Option { 12 | return func(c *Postgres) { 13 | c.maxPoolSize = size 14 | } 15 | } 16 | 17 | // ConnAttempts -. 18 | func ConnAttempts(attempts int) Option { 19 | return func(c *Postgres) { 20 | c.connAttempts = attempts 21 | } 22 | } 23 | 24 | // ConnTimeout -. 25 | func ConnTimeout(timeout time.Duration) Option { 26 | return func(c *Postgres) { 27 | c.connTimeout = timeout 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /internal/application/service.go: -------------------------------------------------------------------------------- 1 | package application 2 | 3 | import ( 4 | "github.com/program-world-labs/DDDGo/internal/application/currency" 5 | "github.com/program-world-labs/DDDGo/internal/application/group" 6 | "github.com/program-world-labs/DDDGo/internal/application/role" 7 | "github.com/program-world-labs/DDDGo/internal/application/user" 8 | "github.com/program-world-labs/DDDGo/internal/application/wallet" 9 | ) 10 | 11 | type Services struct { 12 | User user.IService 13 | Role role.IService 14 | Group group.IService 15 | Wallet wallet.IService 16 | Currency currency.IService 17 | } 18 | -------------------------------------------------------------------------------- /internal/domain/entity/error.go: -------------------------------------------------------------------------------- 1 | package entity 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/program-world-labs/DDDGo/internal/domain/domainerrors" 7 | ) 8 | 9 | const ( 10 | ErrorCodeDomainRole = domainerrors.ErrorCodeDomainRole + iota // 100000 11 | ErrorCodeCastToEvent // 100001 12 | ErrorCodeCast // 100002 13 | ErrorCodeDomainUser = domainerrors.ErrorCodeDomainRole 14 | ) 15 | 16 | var ( 17 | ErrCastToEventFailed = errors.New("cast to event failed") 18 | ErrInvalidEventData = errors.New("invalid event data") 19 | ) 20 | -------------------------------------------------------------------------------- /internal/infra/dto/error.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/program-world-labs/DDDGo/internal/domain/domainerrors" 7 | ) 8 | 9 | const ( 10 | ErrorCodeDtoBase = domainerrors.ErrorCodeInfraDTO + domainerrors.ErrorCodeInfraDTO + domainerrors.ErrorCodeInfraDTOBase + iota 11 | ErrorCodeTransform 12 | ErrorCodeBackToDomain 13 | ErrorCodeToJSON 14 | ErrorCodeDecodeJSON 15 | ErrorCodeInvalidFilterField 16 | ErrorCodeInvalidOrderField 17 | ErrorCodeParseMap 18 | ) 19 | 20 | var ( 21 | ErrParesMapFailed = errors.New("parse map failed") 22 | ErrCastTypeFailed = errors.New("cast type failed") 23 | ) 24 | -------------------------------------------------------------------------------- /internal/infra/datasource/nosqlfs/transaction_event_impl.go: -------------------------------------------------------------------------------- 1 | package nosqlfs 2 | 3 | import ( 4 | "cloud.google.com/go/firestore" 5 | 6 | "github.com/program-world-labs/DDDGo/internal/domain" 7 | ) 8 | 9 | var _ domain.ITransactionEvent = (*TransactionEventDataSourceImpl)(nil) 10 | 11 | type TransactionEventDataSourceImpl struct { 12 | tx *firestore.Transaction 13 | } 14 | 15 | func NewTransactionEventDataSourceImpl(db *firestore.Transaction) *TransactionEventDataSourceImpl { 16 | return &TransactionEventDataSourceImpl{tx: db} 17 | } 18 | 19 | func (r *TransactionEventDataSourceImpl) GetTx() interface{} { 20 | return r.tx 21 | } 22 | -------------------------------------------------------------------------------- /internal/application/role/interface.go: -------------------------------------------------------------------------------- 1 | package role 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | type IService interface { 8 | // Command 9 | CreateRole(ctx context.Context, roleInfo *CreatedInput) (*Output, error) 10 | // AssignRole(ctx context.Context, roleInfo *AssignedInput) (*Output, error) 11 | UpdateRole(ctx context.Context, roleInfo *UpdatedInput) (*Output, error) 12 | DeleteRole(ctx context.Context, roleInfo *DeletedInput) (*Output, error) 13 | // // Query 14 | GetRoleList(ctx context.Context, roleInfo *ListGotInput) (*OutputList, error) 15 | GetRoleDetail(ctx context.Context, roleInfo *DetailGotInput) (*Output, error) 16 | } 17 | -------------------------------------------------------------------------------- /internal/application/user/interface.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | type IService interface { 8 | // Command 9 | CreateUser(ctx context.Context, UserInfo *CreatedInput) (*Output, error) 10 | // AssignRole(ctx context.Context, UserInfo *AssignedInput) (*Output, error) 11 | UpdateUser(ctx context.Context, UserInfo *UpdatedInput) (*Output, error) 12 | DeleteUser(ctx context.Context, UserInfo *DeletedInput) (*Output, error) 13 | // // Query 14 | GetUserList(ctx context.Context, UserInfo *ListGotInput) (*OutputList, error) 15 | GetUserDetail(ctx context.Context, UserInfo *DetailGotInput) (*Output, error) 16 | } 17 | -------------------------------------------------------------------------------- /internal/application/group/interface.go: -------------------------------------------------------------------------------- 1 | package group 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | type IService interface { 8 | // Command 9 | CreateGroup(ctx context.Context, groupInfo *CreatedInput) (*Output, error) 10 | // AssignGroup(ctx context.Context, groupInfo *AssignedInput) (*Output, error) 11 | UpdateGroup(ctx context.Context, groupInfo *UpdatedInput) (*Output, error) 12 | DeleteGroup(ctx context.Context, groupInfo *DeletedInput) (*Output, error) 13 | // // Query 14 | GetGroupList(ctx context.Context, groupInfo *ListGotInput) (*OutputList, error) 15 | GetGroupDetail(ctx context.Context, groupInfo *DetailGotInput) (*Output, error) 16 | } 17 | -------------------------------------------------------------------------------- /internal/adapter/http/v1/role/error.go: -------------------------------------------------------------------------------- 1 | package role 2 | 3 | import ( 4 | "github.com/rs/zerolog" 5 | 6 | "github.com/program-world-labs/DDDGo/internal/domain/domainerrors" 7 | ) 8 | 9 | const ( 10 | ErrorCodeAdapterHTTPRole = domainerrors.ErrorCodeAdapter + domainerrors.ErrorCodeAdapterHTTP + domainerrors.ErrorCodeAdapterRole + iota 11 | ErrorCodeExecuteUsecase 12 | ErrorCodeBindJSON 13 | ErrorCodeCopyToInput 14 | ErrorCodeBindQuery 15 | ErrorCodeValidateInput 16 | ErrorCodeBindURI 17 | ) 18 | 19 | type ErrorEvent struct { 20 | err error 21 | } 22 | 23 | func (a ErrorEvent) MarshalZerologObject(e *zerolog.Event) { 24 | e.Err(a.err).Str("event", "RoleError") 25 | } 26 | -------------------------------------------------------------------------------- /internal/adapter/http/v1/user/error.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "github.com/rs/zerolog" 5 | 6 | "github.com/program-world-labs/DDDGo/internal/domain/domainerrors" 7 | ) 8 | 9 | const ( 10 | ErrorCodeAdapterHTTPUser = domainerrors.ErrorCodeAdapter + domainerrors.ErrorCodeAdapterHTTP + domainerrors.ErrorCodeAdapterUser + iota 11 | ErrorCodeExecuteUsecase 12 | ErrorCodeBindJSON 13 | ErrorCodeCopyToInput 14 | ErrorCodeBindQuery 15 | ErrorCodeValidateInput 16 | ErrorCodeBindURI 17 | ) 18 | 19 | type ErrorEvent struct { 20 | err error 21 | } 22 | 23 | func (a ErrorEvent) MarshalZerologObject(e *zerolog.Event) { 24 | e.Err(a.err).Str("event", "UserError") 25 | } 26 | -------------------------------------------------------------------------------- /internal/adapter/http/v1/group/error.go: -------------------------------------------------------------------------------- 1 | package group 2 | 3 | import ( 4 | "github.com/rs/zerolog" 5 | 6 | "github.com/program-world-labs/DDDGo/internal/domain/domainerrors" 7 | ) 8 | 9 | const ( 10 | ErrorCodeAdapterHTTPGroup = domainerrors.ErrorCodeAdapter + domainerrors.ErrorCodeAdapterHTTP + domainerrors.ErrorCodeAdapterGroup + iota 11 | ErrorCodeExecuteUsecase 12 | ErrorCodeBindJSON 13 | ErrorCodeCopyToInput 14 | ErrorCodeBindQuery 15 | ErrorCodeValidateInput 16 | ErrorCodeBindURI 17 | ) 18 | 19 | type ErrorEvent struct { 20 | err error 21 | } 22 | 23 | func (a ErrorEvent) MarshalZerologObject(e *zerolog.Event) { 24 | e.Err(a.err).Str("event", "GroupError") 25 | } 26 | -------------------------------------------------------------------------------- /pkg/message/options.go: -------------------------------------------------------------------------------- 1 | package message 2 | 3 | import ( 4 | "go.opentelemetry.io/otel/attribute" 5 | ) 6 | 7 | // config represents the configuration options available for subscriber 8 | // middlewares and publisher decorators. 9 | type config struct { 10 | spanAttributes []attribute.KeyValue 11 | } 12 | 13 | // Option provides a convenience wrapper for simple options that can be 14 | // represented as functions. 15 | type Option func(*config) 16 | 17 | // WithSpanAttributes includes the given attributes to the generated Spans. 18 | func WithSpanAttributes(attributes ...attribute.KeyValue) Option { 19 | return func(c *config) { 20 | c.spanAttributes = attributes 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /internal/adapter/http/v1/wallet/error.go: -------------------------------------------------------------------------------- 1 | package wallet 2 | 3 | import ( 4 | "github.com/rs/zerolog" 5 | 6 | "github.com/program-world-labs/DDDGo/internal/domain/domainerrors" 7 | ) 8 | 9 | const ( 10 | ErrorCodeAdapterHTTPWallet = domainerrors.ErrorCodeAdapter + domainerrors.ErrorCodeAdapterHTTP + domainerrors.ErrorCodeAdapterWallet + iota 11 | ErrorCodeExecuteUsecase 12 | ErrorCodeBindJSON 13 | ErrorCodeCopyToInput 14 | ErrorCodeBindQuery 15 | ErrorCodeValidateInput 16 | ErrorCodeBindURI 17 | ) 18 | 19 | type ErrorEvent struct { 20 | err error 21 | } 22 | 23 | func (a ErrorEvent) MarshalZerologObject(e *zerolog.Event) { 24 | e.Err(a.err).Str("event", "WalletError") 25 | } 26 | -------------------------------------------------------------------------------- /pkg/pwsql/nosql/firestoreDB/firestoreDB.go: -------------------------------------------------------------------------------- 1 | package firestoredb 2 | 3 | import ( 4 | "context" 5 | 6 | "cloud.google.com/go/firestore" 7 | ) 8 | 9 | type Firestore struct { 10 | client *firestore.Client 11 | } 12 | 13 | func New(ctx context.Context, projectID string) (*Firestore, error) { 14 | client, err := firestore.NewClient(ctx, projectID) 15 | if err != nil { 16 | return nil, err 17 | } 18 | 19 | return &Firestore{client: client}, nil 20 | } 21 | 22 | func (f *Firestore) GetClient() *firestore.Client { 23 | return f.client 24 | } 25 | 26 | func (f *Firestore) Close() error { 27 | if f.client != nil { 28 | return f.client.Close() 29 | } 30 | 31 | return nil 32 | } 33 | -------------------------------------------------------------------------------- /pkg/event_store/event_store_db.go: -------------------------------------------------------------------------------- 1 | package eventstore 2 | 3 | import ( 4 | "github.com/EventStore/EventStore-Client-Go/v3/esdb" 5 | ) 6 | 7 | type StoreDB struct { 8 | Client *esdb.Client 9 | } 10 | 11 | func NewEventStoreDB(connectString string) (*StoreDB, error) { 12 | // region createClient 13 | settings, err := esdb.ParseConnectionString(connectString) 14 | if err != nil { 15 | return nil, err 16 | } 17 | 18 | // Creates a new Client instance. 19 | client, err := esdb.NewClient(settings) 20 | if err != nil { 21 | return nil, err 22 | } 23 | 24 | return &StoreDB{Client: client}, nil 25 | } 26 | 27 | func (e *StoreDB) Close() error { 28 | return e.Client.Close() 29 | } 30 | -------------------------------------------------------------------------------- /pkg/operations/tracer.go: -------------------------------------------------------------------------------- 1 | package operations 2 | 3 | import ( 4 | "errors" 5 | ) 6 | 7 | var ( 8 | // GoogleCloudOperation -. 9 | ErrJaegerNotSupported = errors.New("jaeger is not supported yet") 10 | ErrBatcherNotSupported = errors.New("batcher is not supported") 11 | ) 12 | 13 | // InitNewTracer -. 14 | func InitNewTracer(host string, _ int, batcher string, sampleRate float64, enabled bool) error { 15 | if !enabled { 16 | return nil 17 | } 18 | 19 | switch batcher { 20 | case "gcp": 21 | GoogleCloudOperationInit(host, sampleRate) 22 | case "jaeger": 23 | return ErrJaegerNotSupported 24 | default: 25 | return ErrBatcherNotSupported 26 | } 27 | 28 | return nil 29 | } 30 | -------------------------------------------------------------------------------- /internal/adapter/http/v1/currency/error.go: -------------------------------------------------------------------------------- 1 | package currency 2 | 3 | import ( 4 | "github.com/rs/zerolog" 5 | 6 | "github.com/program-world-labs/DDDGo/internal/domain/domainerrors" 7 | ) 8 | 9 | const ( 10 | ErrorCodeAdapterHTTPCurrency = domainerrors.ErrorCodeAdapter + domainerrors.ErrorCodeAdapterHTTP + domainerrors.ErrorCodeAdapterCurrency + iota 11 | ErrorCodeExecuteUsecase 12 | ErrorCodeBindJSON 13 | ErrorCodeCopyToInput 14 | ErrorCodeBindQuery 15 | ErrorCodeValidateInput 16 | ErrorCodeBindURI 17 | ) 18 | 19 | type ErrorEvent struct { 20 | err error 21 | } 22 | 23 | func (a ErrorEvent) MarshalZerologObject(e *zerolog.Event) { 24 | e.Err(a.err).Str("event", "CurrencyError") 25 | } 26 | -------------------------------------------------------------------------------- /internal/application/currency/interface.go: -------------------------------------------------------------------------------- 1 | package currency 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | type IService interface { 8 | // Command 9 | CreateCurrency(ctx context.Context, currencyInfo *CreatedInput) (*Output, error) 10 | // AssignCurrency(ctx context.Context, currencyInfo *AssignedInput) (*Output, error) 11 | UpdateCurrency(ctx context.Context, currencyInfo *UpdatedInput) (*Output, error) 12 | DeleteCurrency(ctx context.Context, currencyInfo *DeletedInput) (*Output, error) 13 | // // Query 14 | GetCurrencyList(ctx context.Context, currencyInfo *ListGotInput) (*OutputList, error) 15 | GetCurrencyDetail(ctx context.Context, currencyInfo *DetailGotInput) (*Output, error) 16 | } 17 | -------------------------------------------------------------------------------- /internal/infra/role/repo_impl.go: -------------------------------------------------------------------------------- 1 | package role 2 | 3 | import ( 4 | "github.com/program-world-labs/DDDGo/internal/domain/repository" 5 | "github.com/program-world-labs/DDDGo/internal/infra/datasource" 6 | "github.com/program-world-labs/DDDGo/internal/infra/dto" 7 | base_repository "github.com/program-world-labs/DDDGo/internal/infra/repository" 8 | ) 9 | 10 | var _ repository.RoleRepository = (*RepoImpl)(nil) 11 | 12 | type RepoImpl struct { 13 | base_repository.CRUDImpl 14 | } 15 | 16 | func NewRepoImpl(db datasource.IDataSource, redis datasource.ICacheDataSource, cache datasource.ICacheDataSource) *RepoImpl { 17 | dtoRole := &dto.Role{} 18 | 19 | return &RepoImpl{CRUDImpl: *base_repository.NewCRUDImpl(db, redis, cache, dtoRole)} 20 | } 21 | -------------------------------------------------------------------------------- /internal/infra/user/repo_impl.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "github.com/program-world-labs/DDDGo/internal/domain/repository" 5 | "github.com/program-world-labs/DDDGo/internal/infra/datasource" 6 | "github.com/program-world-labs/DDDGo/internal/infra/dto" 7 | base_repository "github.com/program-world-labs/DDDGo/internal/infra/repository" 8 | ) 9 | 10 | var _ repository.UserRepository = (*RepoImpl)(nil) 11 | 12 | type RepoImpl struct { 13 | base_repository.CRUDImpl 14 | } 15 | 16 | func NewRepoImpl(db datasource.IDataSource, redis datasource.ICacheDataSource, cache datasource.ICacheDataSource) *RepoImpl { 17 | dtoUser := &dto.User{} 18 | 19 | return &RepoImpl{CRUDImpl: *base_repository.NewCRUDImpl(db, redis, cache, dtoUser)} 20 | } 21 | -------------------------------------------------------------------------------- /internal/domain/event/event_type_mapper.go: -------------------------------------------------------------------------------- 1 | package event 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | ) 7 | 8 | type TypeMapper struct { 9 | mapper map[string]reflect.Type 10 | } 11 | 12 | func NewEventTypeMapper() *TypeMapper { 13 | return &TypeMapper{ 14 | mapper: make(map[string]reflect.Type), 15 | } 16 | } 17 | 18 | func (m *TypeMapper) Register(event interface{}) { 19 | t := reflect.TypeOf(event).Elem() 20 | m.mapper[t.Name()] = t 21 | } 22 | 23 | func (m *TypeMapper) NewInstance(eventName string) (event interface{}, err error) { 24 | if t, ok := m.mapper[eventName]; ok { 25 | event = reflect.New(t).Interface() 26 | } else { 27 | err = fmt.Errorf("%w: %s", ErrNewEventTypeMapper, event) 28 | } 29 | 30 | return event, err 31 | } 32 | -------------------------------------------------------------------------------- /internal/infra/group/repo_impl.go: -------------------------------------------------------------------------------- 1 | package group 2 | 3 | import ( 4 | "github.com/program-world-labs/DDDGo/internal/domain/repository" 5 | "github.com/program-world-labs/DDDGo/internal/infra/datasource" 6 | "github.com/program-world-labs/DDDGo/internal/infra/dto" 7 | base_repository "github.com/program-world-labs/DDDGo/internal/infra/repository" 8 | ) 9 | 10 | var _ repository.GroupRepository = (*RepoImpl)(nil) 11 | 12 | type RepoImpl struct { 13 | base_repository.CRUDImpl 14 | } 15 | 16 | func NewRepoImpl(db datasource.IDataSource, redis datasource.ICacheDataSource, cache datasource.ICacheDataSource) *RepoImpl { 17 | dtoGroup := &dto.Group{} 18 | 19 | return &RepoImpl{CRUDImpl: *base_repository.NewCRUDImpl(db, redis, cache, dtoGroup)} 20 | } 21 | -------------------------------------------------------------------------------- /internal/domain/entity/currency.go: -------------------------------------------------------------------------------- 1 | package entity 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/program-world-labs/DDDGo/internal/domain" 7 | ) 8 | 9 | var _ domain.IEntity = (*Currency)(nil) 10 | 11 | type Currency struct { 12 | ID string `json:"id"` 13 | Name string `json:"name"` 14 | Symbol string `json:"symbol"` 15 | WalletBalances []WalletBalance `json:"walletBalances"` 16 | CreatedAt time.Time `json:"created_at"` 17 | UpdatedAt time.Time `json:"updated_at"` 18 | DeletedAt time.Time `json:"deleted_at"` 19 | } 20 | 21 | func (a *Currency) GetID() string { 22 | return a.ID 23 | } 24 | 25 | func (a *Currency) SetID(id string) { 26 | a.ID = id 27 | } 28 | -------------------------------------------------------------------------------- /internal/infra/wallet/repo_impl.go: -------------------------------------------------------------------------------- 1 | package wallet 2 | 3 | import ( 4 | "github.com/program-world-labs/DDDGo/internal/domain/repository" 5 | "github.com/program-world-labs/DDDGo/internal/infra/datasource" 6 | "github.com/program-world-labs/DDDGo/internal/infra/dto" 7 | base_repository "github.com/program-world-labs/DDDGo/internal/infra/repository" 8 | ) 9 | 10 | var _ repository.WalletRepository = (*RepoImpl)(nil) 11 | 12 | type RepoImpl struct { 13 | base_repository.CRUDImpl 14 | } 15 | 16 | func NewRepoImpl(db datasource.IDataSource, redis datasource.ICacheDataSource, cache datasource.ICacheDataSource) *RepoImpl { 17 | dtoWallet := &dto.Wallet{} 18 | 19 | return &RepoImpl{CRUDImpl: *base_repository.NewCRUDImpl(db, redis, cache, dtoWallet)} 20 | } 21 | -------------------------------------------------------------------------------- /internal/infra/currency/repo_impl.go: -------------------------------------------------------------------------------- 1 | package currency 2 | 3 | import ( 4 | "github.com/program-world-labs/DDDGo/internal/domain/repository" 5 | "github.com/program-world-labs/DDDGo/internal/infra/datasource" 6 | "github.com/program-world-labs/DDDGo/internal/infra/dto" 7 | base_repository "github.com/program-world-labs/DDDGo/internal/infra/repository" 8 | ) 9 | 10 | var _ repository.CurrencyRepository = (*RepoImpl)(nil) 11 | 12 | type RepoImpl struct { 13 | base_repository.CRUDImpl 14 | } 15 | 16 | func NewRepoImpl(db datasource.IDataSource, redis datasource.ICacheDataSource, cache datasource.ICacheDataSource) *RepoImpl { 17 | dtoCurrency := &dto.Currency{} 18 | 19 | return &RepoImpl{CRUDImpl: *base_repository.NewCRUDImpl(db, redis, cache, dtoCurrency)} 20 | } 21 | -------------------------------------------------------------------------------- /internal/infra/repository/transaction_repo_impl.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/program-world-labs/DDDGo/internal/domain" 7 | "github.com/program-world-labs/DDDGo/internal/infra/datasource" 8 | ) 9 | 10 | var _ domain.ITransactionRepo = (*TransactionRunRepoImpl)(nil) 11 | 12 | type TransactionRunRepoImpl struct { 13 | tr datasource.ITransactionRun 14 | } 15 | 16 | // NewTransactionRunRepoImpl -. 17 | func NewTransactionRunRepoImpl(tr datasource.ITransactionRun) *TransactionRunRepoImpl { 18 | return &TransactionRunRepoImpl{tr: tr} 19 | } 20 | 21 | // RunTransaction -. 22 | func (r *TransactionRunRepoImpl) RunTransaction(ctx context.Context, f domain.TransactionEventFunc) error { 23 | return r.tr.RunTransaction(ctx, f) 24 | } 25 | -------------------------------------------------------------------------------- /internal/infra/wallet_balance/repo_impl.go: -------------------------------------------------------------------------------- 1 | package wallet 2 | 3 | import ( 4 | "github.com/program-world-labs/DDDGo/internal/domain/repository" 5 | "github.com/program-world-labs/DDDGo/internal/infra/datasource" 6 | "github.com/program-world-labs/DDDGo/internal/infra/dto" 7 | base_repository "github.com/program-world-labs/DDDGo/internal/infra/repository" 8 | ) 9 | 10 | var _ repository.WalletBalanceRepository = (*RepoImpl)(nil) 11 | 12 | type RepoImpl struct { 13 | base_repository.CRUDImpl 14 | } 15 | 16 | func NewRepoImpl(db datasource.IDataSource, redis datasource.ICacheDataSource, cache datasource.ICacheDataSource) *RepoImpl { 17 | dtoWalletBalance := &dto.WalletBalance{} 18 | 19 | return &RepoImpl{CRUDImpl: *base_repository.NewCRUDImpl(db, redis, cache, dtoWalletBalance)} 20 | } 21 | -------------------------------------------------------------------------------- /pkg/httpserver/options.go: -------------------------------------------------------------------------------- 1 | package httpserver 2 | 3 | import ( 4 | "net" 5 | "time" 6 | ) 7 | 8 | // Option -. 9 | type Option func(*Server) 10 | 11 | // Port -. 12 | func Port(port string) Option { 13 | return func(s *Server) { 14 | s.server.Addr = net.JoinHostPort("", port) 15 | } 16 | } 17 | 18 | // ReadTimeout -. 19 | func ReadTimeout(timeout time.Duration) Option { 20 | return func(s *Server) { 21 | s.server.ReadTimeout = timeout 22 | } 23 | } 24 | 25 | // WriteTimeout -. 26 | func WriteTimeout(timeout time.Duration) Option { 27 | return func(s *Server) { 28 | s.server.WriteTimeout = timeout 29 | } 30 | } 31 | 32 | // ShutdownTimeout -. 33 | func ShutdownTimeout(timeout time.Duration) Option { 34 | return func(s *Server) { 35 | s.shutdownTimeout = timeout 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /internal/adapter/http/v1/currency/request.go: -------------------------------------------------------------------------------- 1 | package currency 2 | 3 | type CreatedRequest struct { 4 | Name string `json:"name"` 5 | Symbol string `json:"symbol"` 6 | } 7 | 8 | type ListGotRequest struct { 9 | Limit int `json:"limit" form:"limit" binding:"required"` 10 | Offset int `json:"offset" form:"offset"` 11 | FilterName string `json:"filterName" form:"filterName"` 12 | SortFields []string `json:"sortFields" form:"sortFields"` 13 | Dir string `json:"dir" form:"dir"` 14 | } 15 | 16 | type DetailGotRequest struct { 17 | ID string `json:"id" uri:"id" binding:"required"` 18 | } 19 | 20 | type DeletedRequest struct { 21 | ID string `json:"id" uri:"id" binding:"required"` 22 | } 23 | 24 | type UpdatedRequest struct { 25 | Name string `json:"name"` 26 | Symbol string `json:"symbol"` 27 | } 28 | -------------------------------------------------------------------------------- /internal/domain/entity/group.go: -------------------------------------------------------------------------------- 1 | package entity 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/program-world-labs/DDDGo/internal/domain" 7 | ) 8 | 9 | var _ domain.IEntity = (*Group)(nil) 10 | 11 | type Group struct { 12 | ID string `json:"id"` // Group ID 13 | Name string `json:"name"` // Group Name 14 | Description string `json:"description"` // Group Descript 15 | Users []User `json:"users"` // Group User List 16 | OwnerID string `json:"ownerId"` 17 | Metadata string `json:"metadata"` // json content 18 | CreatedAt time.Time `json:"created_at"` 19 | UpdatedAt time.Time `json:"updated_at"` 20 | DeletedAt time.Time `json:"deleted_at"` 21 | } 22 | 23 | func (a *Group) GetID() string { 24 | return a.ID 25 | } 26 | 27 | func (a *Group) SetID(id string) { 28 | a.ID = id 29 | } 30 | -------------------------------------------------------------------------------- /pkg/pwsql/relation/sql.go: -------------------------------------------------------------------------------- 1 | package relation 2 | 3 | import ( 4 | "errors" 5 | "log" 6 | "time" 7 | 8 | "github.com/program-world-labs/DDDGo/pkg/pwsql" 9 | "github.com/program-world-labs/DDDGo/pkg/pwsql/relation/mysql" 10 | "github.com/program-world-labs/DDDGo/pkg/pwsql/relation/postgresql" 11 | ) 12 | 13 | var err = errors.New("unknown sql type") 14 | 15 | func InitSQL(sqlType string, dsn string, poolMax int, connAttempts int, connTimeout time.Duration) (pwsql.ISQLGorm, error) { 16 | switch sqlType { 17 | case "mysql": 18 | return mysql.New(dsn, mysql.MaxPoolSize(poolMax), mysql.ConnAttempts(connAttempts), mysql.ConnTimeout(connTimeout)) 19 | case "postgresql": 20 | return postgresql.New(dsn, postgresql.MaxPoolSize(poolMax), postgresql.ConnAttempts(connAttempts), postgresql.ConnTimeout(connTimeout)) 21 | default: 22 | log.Fatalf(err.Error()+": %s", sqlType) 23 | } 24 | 25 | return nil, err 26 | } 27 | -------------------------------------------------------------------------------- /integration-test/Dockerfile: -------------------------------------------------------------------------------- 1 | # Step 1: Modules caching 2 | FROM golang:1.21rc3-alpine as modules 3 | COPY go.mod go.sum /modules/ 4 | WORKDIR /modules 5 | RUN go mod download 6 | 7 | # Step 2: Intermediate 8 | FROM alpine:3.14 as intermediate 9 | RUN apk update && \ 10 | apk add --no-cache wget=1.21.1-r1 && \ 11 | wget --progress=dot:giga https://github.com/jwilder/dockerize/releases/download/v0.6.1/dockerize-linux-amd64-v0.6.1.tar.gz && \ 12 | tar -C /usr/local/bin -xzvf dockerize-linux-amd64-v0.6.1.tar.gz && \ 13 | rm dockerize-linux-amd64-v0.6.1.tar.gz 14 | 15 | # Step 3: Tests 16 | FROM golang:1.21rc3-alpine 17 | COPY --from=modules /go/pkg /go/pkg 18 | COPY --from=intermediate /usr/local/bin/dockerize /dockerize 19 | COPY . /app 20 | WORKDIR /app 21 | 22 | RUN go env -w CGO_ENABLED=0 23 | RUN go env -w GOOS=linux 24 | RUN go env -w GOARCH=amd64 25 | 26 | CMD ["go", "test", "-v", "./integration-test/..."] -------------------------------------------------------------------------------- /internal/infra/datasource/sql/error.go: -------------------------------------------------------------------------------- 1 | package sql 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/program-world-labs/DDDGo/internal/domain/domainerrors" 7 | ) 8 | 9 | const ( 10 | ErrorCodeSQLCreate = domainerrors.ErrorCodeInfraDatasource + domainerrors.ErrorCodeInfraDatasource + domainerrors.ErrorCodeInfraDatasourceSQL + iota 11 | ErrorCodeSQLDelete 12 | ErrorCodeSQLUpdate 13 | ErrorCodeSQLUpdateWithFields 14 | ErrorCodeSQLGet 15 | ErrorCodeSQLGetAll 16 | ErrorCodeSQLCreateTx 17 | ErrorCodeSQLDeleteTx 18 | ErrorCodeSQLUpdateTx 19 | ErrorCodeSQLUpdateWithFieldsTx 20 | ErrorCodeSQLCast 21 | ErrorCodeSQLAppendAssociation 22 | ErrorCodeSQLReplaceAssociation 23 | ErrorCodeSQLRemoveAssociation 24 | ErrorCodeSQLGetAssociationCount 25 | ErrorCodeSQLAppendAssociationTx 26 | ErrorCodeSQLReplaceAssociationTx 27 | ErrorCodeSQLRemoveAssociationTx 28 | ) 29 | 30 | var ( 31 | ErrCastToEntityFailed = errors.New("cast to entity failed") 32 | ) 33 | -------------------------------------------------------------------------------- /internal/infra/datasource/nosqlfs/error.go: -------------------------------------------------------------------------------- 1 | package nosqlfs 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/program-world-labs/DDDGo/internal/domain/domainerrors" 7 | ) 8 | 9 | const ( 10 | ErrorCodeSQLCreate = domainerrors.ErrorCodeInfraDatasource + domainerrors.ErrorCodeInfraDatasource + domainerrors.ErrorCodeInfraDatasourceSQL + iota 11 | ErrorCodeSQLDelete 12 | ErrorCodeSQLUpdate 13 | ErrorCodeSQLUpdateWithFields 14 | ErrorCodeSQLGet 15 | ErrorCodeSQLGetAll 16 | ErrorCodeSQLCreateTx 17 | ErrorCodeSQLDeleteTx 18 | ErrorCodeSQLUpdateTx 19 | ErrorCodeSQLUpdateWithFieldsTx 20 | ErrorCodeSQLCast 21 | ErrorCodeSQLAppendAssociation 22 | ErrorCodeSQLReplaceAssociation 23 | ErrorCodeSQLRemoveAssociation 24 | ErrorCodeSQLGetAssociationCount 25 | ErrorCodeSQLAppendAssociationTx 26 | ErrorCodeSQLReplaceAssociationTx 27 | ErrorCodeSQLRemoveAssociationTx 28 | ) 29 | 30 | var ( 31 | ErrCastToEntityFailed = errors.New("cast to entity failed") 32 | ) 33 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | // format all files on save if a formatter is available 3 | "editor.formatOnSave": true, 4 | // I use "goimports" instead of "gofmt" 5 | // because it does the same thing but also formats imports 6 | "go.formatTool": "default", 7 | // go-specific settings 8 | "[go]": { 9 | "editor.formatOnSave": true, 10 | "editor.codeActionsOnSave": { 11 | "source.organizeImports": true 12 | } 13 | }, 14 | "[go.mod]": { 15 | "editor.formatOnSave": true, 16 | "editor.codeActionsOnSave": { 17 | "source.organizeImports": true 18 | } 19 | }, 20 | "sqltools.connections": [ 21 | { 22 | "previewLimit": 50, 23 | "server": "localhost", 24 | "port": 5432, 25 | "driver": "PostgreSQL", 26 | "name": "Dev", 27 | "database": "postgres", 28 | "username": "user", 29 | "password": "pass" 30 | } 31 | ], 32 | "sarif-viewer.connectToGithubCodeScanning": "on" 33 | } 34 | -------------------------------------------------------------------------------- /tests/role/features/usecase/role_list_got.feature: -------------------------------------------------------------------------------- 1 | Feature: 取得角色列表 2 | 測試取得角色列表相關的usecase功能 3 | 4 | Scenario: 成功獲取角色列表 5 | Given 提供 6 | When 嘗試獲取角色列表 7 | Then 應該成功獲取角色列表 8 | 9 | Examples: 10 | |limit|offset| 11 | |10|0| 12 | 13 | Scenario: 提供的limit或offset為負數 14 | Given 提供 15 | When 嘗試獲取角色列表 16 | Then 應該返回一個錯誤,說明limit或offset不能為負數 17 | 18 | Examples: 19 | |limit|offset| 20 | |-1|0| 21 | |10|-1| 22 | 23 | Scenario: 提供的limit為0 24 | Given 提供 25 | When 嘗試獲取角色列表 26 | Then 應該返回一個空的角色列表 27 | 28 | Examples: 29 | |limit|offset| 30 | |0|0| 31 | 32 | Scenario: 提供的offset超過實際角色數量 33 | Given 提供 34 | When 嘗試獲取角色列表 35 | Then 應該返回一個空的角色列表 36 | 37 | Examples: 38 | |limit|offset| 39 | |10|100| 40 | -------------------------------------------------------------------------------- /internal/adapter/message/user/router.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/ThreeDotsLabs/watermill/message" 7 | "github.com/program-world-labs/pwlogger" 8 | 9 | "github.com/program-world-labs/DDDGo/internal/application/user" 10 | "github.com/program-world-labs/DDDGo/internal/domain/event" 11 | ) 12 | 13 | type Routes struct { 14 | e event.TypeMapper 15 | s user.IService 16 | l pwlogger.Interface 17 | } 18 | 19 | func NewUserRoutes(e event.TypeMapper, u user.IService, l pwlogger.Interface) *Routes { 20 | // Register event 21 | e.Register((*event.UserCreatedEvent)(nil)) 22 | e.Register((*event.UserPasswordChangedEvent)(nil)) 23 | e.Register((*event.UserEmailChangedEvent)(nil)) 24 | 25 | return &Routes{e: e, s: u, l: l} 26 | } 27 | 28 | func (u *Routes) Handler(msg *message.Message) error { 29 | log.Println("userRoutes received message", msg.UUID) 30 | 31 | // msg = message.NewMessage(watermill.NewUUID(), []byte("message produced by structHandler")) 32 | return nil 33 | } 34 | -------------------------------------------------------------------------------- /internal/infra/dto/list.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | import ( 4 | "github.com/program-world-labs/DDDGo/internal/domain" 5 | ) 6 | 7 | type List struct { 8 | Limit int `json:"limit"` 9 | Offset int `json:"offset"` 10 | Total int `json:"total"` 11 | Data interface{} `json:"data"` 12 | } 13 | 14 | func (l *List) BackToDomain(model IRepoEntity) (*domain.List, error) { 15 | // Cast Data to []domain.IEntity 16 | var result []domain.IEntity 17 | 18 | for _, item := range l.Data.([]interface{}) { 19 | c, ok := item.(map[string]interface{}) 20 | if !ok { 21 | return nil, nil 22 | } 23 | 24 | d, err := model.ParseMap(c) 25 | 26 | if err != nil { 27 | return nil, err 28 | } 29 | 30 | e, err := d.BackToDomain() 31 | if err != nil { 32 | return nil, err 33 | } 34 | 35 | result = append(result, e) 36 | } 37 | 38 | return &domain.List{ 39 | Limit: int64(l.Limit), 40 | Offset: int64(l.Offset), 41 | Total: int64(l.Total), 42 | Data: result, 43 | }, nil 44 | } 45 | -------------------------------------------------------------------------------- /internal/adapter/http/response.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | 7 | "github.com/gin-gonic/gin" 8 | 9 | domain_errors "github.com/program-world-labs/DDDGo/internal/domain/domainerrors" 10 | ) 11 | 12 | type Response struct { 13 | Code int `json:"code"` 14 | Error string `json:"error"` 15 | Data interface{} `json:"data"` 16 | } 17 | 18 | func HandleErrorResponse(c *gin.Context, err error) { 19 | // Check if the error is of type domain_errors.ErrorInfo 20 | var errorInfo *domain_errors.ErrorInfo 21 | if errors.As(err, &errorInfo) { 22 | c.JSON(http.StatusOK, errorInfo) 23 | 24 | return 25 | } 26 | 27 | // If the error is not of type domain_errors.ErrorInfo, create a new ErrorInfo struct 28 | info := domain_errors.Wrap(domain_errors.ErrorCodeSystem, err) 29 | 30 | c.JSON(http.StatusOK, info) 31 | } 32 | 33 | func SuccessResponse(c *gin.Context, data interface{}) { 34 | c.JSON(http.StatusOK, Response{ 35 | Code: 0, 36 | Error: "", 37 | Data: data, 38 | }) 39 | } 40 | -------------------------------------------------------------------------------- /integration-test/integration_test.go: -------------------------------------------------------------------------------- 1 | package integration_test 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | "os" 7 | "testing" 8 | "time" 9 | 10 | . "github.com/Eun/go-hit" 11 | ) 12 | 13 | const ( 14 | host = "app:8080" 15 | healthPath = "http://" + host + "/healthz" 16 | attempts = 20 17 | ) 18 | 19 | func TestMain(m *testing.M) { 20 | err := healthCheck(attempts) 21 | if err != nil { 22 | log.Fatalf("Integration tests: host %s is not available: %s", host, err) 23 | } 24 | 25 | log.Printf("Integration tests: host %s is available", host) 26 | 27 | code := m.Run() 28 | os.Exit(code) 29 | } 30 | 31 | func healthCheck(attempts int) error { 32 | var err error 33 | 34 | for attempts > 0 { 35 | err = Do(Get(healthPath), Expect().Status().Equal(http.StatusOK)) 36 | if err == nil { 37 | return nil 38 | } 39 | 40 | log.Printf("Integration tests: url %s is not available, attempts left: %d", healthPath, attempts) 41 | 42 | time.Sleep(time.Second) 43 | 44 | attempts-- 45 | } 46 | 47 | return err 48 | } 49 | -------------------------------------------------------------------------------- /internal/infra/dto/utils.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | import ( 4 | "crypto/rand" 5 | "fmt" 6 | "time" 7 | ) 8 | 9 | const idLength = 10 10 | 11 | func generateID() (string, error) { 12 | bytes := make([]byte, idLength) 13 | if _, err := rand.Read(bytes); err != nil { 14 | return "", err 15 | } 16 | 17 | return fmt.Sprintf("%x", bytes), nil 18 | } 19 | 20 | func ParseDateString(data map[string]interface{}) error { 21 | if tm, ok := data["created_at"].(string); ok { 22 | t, err := time.Parse(time.RFC3339Nano, tm) 23 | if err != nil { 24 | return err 25 | } 26 | 27 | data["created_at"] = t 28 | } 29 | 30 | if tm, ok := data["updated_at"].(string); ok { 31 | t, err := time.Parse(time.RFC3339Nano, tm) 32 | if err != nil { 33 | return err 34 | } 35 | 36 | data["updated_at"] = t 37 | } 38 | 39 | if tm, ok := data["deleted_at"].(string); ok { 40 | t, err := time.Parse(time.RFC3339Nano, tm) 41 | if err != nil { 42 | return err 43 | } 44 | 45 | data["deleted_at"] = t 46 | } 47 | 48 | return nil 49 | } 50 | -------------------------------------------------------------------------------- /internal/adapter/http/v1/user/request.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | type CreatedRequest struct { 4 | Username string `json:"username"` 5 | Password string `json:"password"` 6 | EMail string `json:"email"` 7 | DisplayName string `json:"displayName"` 8 | Avatar string `json:"avatar"` 9 | // RoleIDs []string `json:"roleIds"` 10 | // GroupID string `json:"groupId"` 11 | } 12 | 13 | type ListGotRequest struct { 14 | Limit int `json:"limit" form:"limit" binding:"required"` 15 | Offset int `json:"offset" form:"offset"` 16 | FilterName string `json:"filterName" form:"filterName"` 17 | SortFields []string `json:"sortFields" form:"sortFields"` 18 | Dir string `json:"dir" form:"dir"` 19 | } 20 | 21 | type DetailRequest struct { 22 | ID string `json:"id" uri:"id" binding:"required"` 23 | } 24 | 25 | type UpdateRequest struct { 26 | DisplayName string `json:"displayName"` 27 | Avatar string `json:"avatar"` 28 | } 29 | 30 | type DeleteRequest struct { 31 | ID string `json:"id" uri:"id" binding:"required"` 32 | } 33 | -------------------------------------------------------------------------------- /internal/adapter/http/v1/group/request.go: -------------------------------------------------------------------------------- 1 | package group 2 | 3 | type CreatedRequest struct { 4 | Name string `json:"name" binding:"required" example:"GroupA"` 5 | Description string `json:"description" binding:"required" example:"this is for group"` 6 | OwnerID string `json:"ownerId"` 7 | } 8 | 9 | type ListGotRequest struct { 10 | Limit int `json:"limit" form:"limit" binding:"required"` 11 | Offset int `json:"offset" form:"offset"` 12 | FilterName string `json:"filterName" form:"filterName"` 13 | SortFields []string `json:"sortFields" form:"sortFields"` 14 | Dir string `json:"dir" form:"dir"` 15 | } 16 | 17 | type DetailGotRequest struct { 18 | ID string `json:"id" uri:"id" binding:"required"` 19 | } 20 | 21 | type DeletedRequest struct { 22 | ID string `json:"id" uri:"id" binding:"required"` 23 | } 24 | 25 | type UpdatedRequest struct { 26 | Name string `json:"name" binding:"required" example:"admin"` 27 | Description string `json:"description" binding:"required" example:"this is for admin role"` 28 | } 29 | -------------------------------------------------------------------------------- /internal/domain/entity/wallet.go: -------------------------------------------------------------------------------- 1 | package entity 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/program-world-labs/DDDGo/internal/domain" 7 | ) 8 | 9 | type Chain string 10 | 11 | const ( 12 | None Chain = "None" 13 | Bitcoin Chain = "Bitcoin" 14 | Ethereum Chain = "Ethereum" 15 | Polygon Chain = "Polygon" 16 | ) 17 | 18 | var _ domain.IEntity = (*Wallet)(nil) 19 | 20 | type Wallet struct { 21 | ID string `json:"id"` 22 | Name string `json:"name"` 23 | Description string `json:"description"` 24 | Chain Chain `json:"chain"` 25 | Address string `json:"address"` 26 | UserID string `json:"userId"` 27 | WalletBalances []WalletBalance `json:"walletBalances"` 28 | CreatedAt time.Time `json:"created_at"` 29 | UpdatedAt time.Time `json:"updated_at"` 30 | DeletedAt time.Time `json:"deleted_at"` 31 | } 32 | 33 | func (a *Wallet) GetID() string { 34 | return a.ID 35 | } 36 | 37 | func (a *Wallet) SetID(id string) { 38 | a.ID = id 39 | } 40 | -------------------------------------------------------------------------------- /internal/infra/datasource/sql/transaction_run_sql_impl.go: -------------------------------------------------------------------------------- 1 | package sql 2 | 3 | import ( 4 | "context" 5 | 6 | "gorm.io/gorm" 7 | 8 | "github.com/program-world-labs/DDDGo/internal/domain" 9 | "github.com/program-world-labs/DDDGo/internal/infra/datasource" 10 | "github.com/program-world-labs/DDDGo/pkg/pwsql" 11 | ) 12 | 13 | var _ datasource.ITransactionRun = (*TransactionDataSourceImpl)(nil) 14 | 15 | // TransactionDataSourceImpl -. 16 | type TransactionDataSourceImpl struct { 17 | DB *gorm.DB 18 | } 19 | 20 | // NewTransactionDataSourceImpl -. 21 | func NewTransactionRunDataSourceImpl(db pwsql.ISQLGorm) *TransactionDataSourceImpl { 22 | return &TransactionDataSourceImpl{DB: db.GetDB()} 23 | } 24 | 25 | // RunTransaction -. 26 | func (r *TransactionDataSourceImpl) RunTransaction(ctx context.Context, txFunc domain.TransactionEventFunc) error { 27 | // 創建一個新的Runner 28 | runner := func(tx *gorm.DB) error { 29 | // 創建一個新的Transaction 30 | txImpl := NewTransactionEventDataSourceImpl(tx) 31 | // 傳遞ctx到Runner 32 | return txFunc(ctx, txImpl) 33 | } 34 | // 傳遞ctx到RunTransaction 35 | return r.DB.Transaction(runner) 36 | } 37 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Step 1: Modules caching 2 | FROM golang:1.20.5-alpine3.18 as modules 3 | COPY go.mod go.sum /modules/ 4 | WORKDIR /modules 5 | RUN go mod download 6 | 7 | # Step 2: Builder 8 | FROM golang:1.20.5-alpine3.18 as builder 9 | COPY --from=modules /go/pkg /go/pkg 10 | COPY . /app 11 | WORKDIR /app 12 | RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ 13 | go build -tags migrate -o /bin/app ./cmd/app 14 | 15 | # Step 3: Intermediate 16 | FROM alpine:3.14 as intermediate 17 | RUN apk update && \ 18 | apk add --no-cache wget=1.21.1-r1 && \ 19 | wget --progress=dot:giga https://github.com/jwilder/dockerize/releases/download/v0.6.1/dockerize-linux-amd64-v0.6.1.tar.gz && \ 20 | tar -C /usr/local/bin -xzvf dockerize-linux-amd64-v0.6.1.tar.gz && \ 21 | rm dockerize-linux-amd64-v0.6.1.tar.gz 22 | 23 | # Step 4: Final 24 | FROM scratch 25 | COPY --from=builder /app/config /config 26 | COPY --from=builder /app/migrations /migrations 27 | COPY --from=builder /bin/app /app 28 | COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ 29 | COPY --from=intermediate /usr/local/bin/dockerize /dockerize 30 | CMD ["/app"] 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 ProgramWorld 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /internal/adapter/http/v1/role/request.go: -------------------------------------------------------------------------------- 1 | package role 2 | 3 | type CreatedRequest struct { 4 | Name string `json:"name" binding:"required" example:"admin"` 5 | Description string `json:"description" binding:"required" example:"this is for admin role"` 6 | Permissions []string `json:"permissions" binding:"required" example:"read:all,write:all"` 7 | } 8 | 9 | type ListGotRequest struct { 10 | Limit int `json:"limit" form:"limit" binding:"required"` 11 | Offset int `json:"offset" form:"offset"` 12 | FilterName string `json:"filterName" form:"filterName"` 13 | SortFields []string `json:"sortFields" form:"sortFields"` 14 | Dir string `json:"dir" form:"dir"` 15 | } 16 | 17 | type DetailGotRequest struct { 18 | ID string `json:"id" uri:"id" binding:"required"` 19 | } 20 | 21 | type DeletedRequest struct { 22 | ID string `json:"id" uri:"id" binding:"required"` 23 | } 24 | 25 | type UpdatedRequest struct { 26 | Name string `json:"name" binding:"required" example:"admin"` 27 | Description string `json:"description" binding:"required" example:"this is for admin role"` 28 | Permissions []string `json:"permissions" binding:"required" example:"read:all,write:all"` 29 | } 30 | -------------------------------------------------------------------------------- /internal/infra/repository/error.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/program-world-labs/DDDGo/internal/domain/domainerrors" 7 | ) 8 | 9 | const ( 10 | ErrorCodeDatasource = domainerrors.ErrorCodeInfraRepo + domainerrors.ErrorCodeInfraRepoCRUD + iota 11 | ErrorCodeRepoTransform 12 | ErrorCodeRepoBackToDomain 13 | ErrorCodeRepoCast 14 | ErrorCodeRepoCreate 15 | ErrorCodeRepoDelete 16 | ErrorCodeRepoUpdate 17 | ErrorCodeRepoUpdateWithFields 18 | ErrorCodeRepoGet 19 | ErrorCodeRepoGetAll 20 | ErrorCodeRepoSet 21 | ErrorCodeRepoCreateTx 22 | ErrorCodeRepoDeleteTx 23 | ErrorCodeRepoUpdateTx 24 | ErrorCodeRepoUpdateWithFieldsTx 25 | ErrorCodeRepoParseMap 26 | ) 27 | 28 | var ( 29 | ErrCastTypeFailed = errors.New("repo transform failed") 30 | ) 31 | 32 | // func NewDatasourceError(err error) *domainerrors.ErrorInfo { 33 | // var repoError *domainerrors.ErrorInfo 34 | // if errors.As(err, &repoError) { 35 | // code, atoiErr := strconv.Atoi(repoError.Code) 36 | // if atoiErr != nil { 37 | // code = 0 38 | // } 39 | 40 | // return domainerrors.New(fmt.Sprint(ErrorCodeDatasource+code), err.Error()) 41 | // } 42 | 43 | // return domainerrors.New(fmt.Sprint(ErrorCodeDatasource), err.Error()) 44 | // } 45 | -------------------------------------------------------------------------------- /internal/infra/datasource/nosqlfs/transaction_run_firestore_impl.go: -------------------------------------------------------------------------------- 1 | package nosqlfs 2 | 3 | import ( 4 | "context" 5 | 6 | "cloud.google.com/go/firestore" 7 | 8 | "github.com/program-world-labs/DDDGo/internal/domain" 9 | "github.com/program-world-labs/DDDGo/internal/infra/datasource" 10 | firestoredb "github.com/program-world-labs/DDDGo/pkg/pwsql/nosql/firestoreDB" 11 | ) 12 | 13 | var _ datasource.ITransactionRun = (*TransactionDataSourceImpl)(nil) 14 | 15 | // TransactionDataSourceImpl -. 16 | type TransactionDataSourceImpl struct { 17 | client *firestore.Client 18 | } 19 | 20 | // NewTransactionDataSourceImpl -. 21 | func NewTransactionRunDataSourceImpl(db *firestoredb.Firestore) *TransactionDataSourceImpl { 22 | return &TransactionDataSourceImpl{client: db.GetClient()} 23 | } 24 | 25 | // RunTransaction -. 26 | func (r *TransactionDataSourceImpl) RunTransaction(ctx context.Context, txFunc domain.TransactionEventFunc) error { 27 | // 創建一個新的Runner 28 | runner := func(ctx context.Context, tx *firestore.Transaction) error { 29 | // 創建一個新的Transaction 30 | txImpl := NewTransactionEventDataSourceImpl(tx) 31 | // 傳遞ctx到Runner 32 | return txFunc(ctx, txImpl) 33 | } 34 | // 傳遞ctx到RunTransaction 35 | return r.client.RunTransaction(ctx, runner) 36 | } 37 | -------------------------------------------------------------------------------- /internal/adapter/http/v1/user/response.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | application_user "github.com/program-world-labs/DDDGo/internal/application/user" 5 | ) 6 | 7 | type Response struct { 8 | ID string `json:"id"` 9 | Username string `json:"username"` 10 | EMail string `json:"email"` 11 | Avatar string `json:"avatar"` 12 | FirstName string `json:"first_name"` 13 | LastName string `json:"last_name"` 14 | } 15 | 16 | type ResponseList struct { 17 | Offset int64 `json:"offset"` 18 | Limit int64 `json:"limit"` 19 | Total int64 `json:"total"` 20 | Items []Response `json:"items"` 21 | } 22 | 23 | func NewResponse(model *application_user.Output) Response { 24 | return Response{ 25 | ID: model.ID, 26 | Username: model.Username, 27 | EMail: model.EMail, 28 | Avatar: model.Avatar, 29 | } 30 | } 31 | 32 | func NewResponseList(modelList *application_user.OutputList) ResponseList { 33 | responseList := make([]Response, len(modelList.Items)) 34 | for i := range modelList.Items { 35 | responseList[i] = NewResponse(&modelList.Items[i]) 36 | } 37 | 38 | return ResponseList{ 39 | Offset: modelList.Offset, 40 | Limit: modelList.Limit, 41 | Total: modelList.Total, 42 | Items: responseList, 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /internal/domain/repository.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | type ICRUDRepository interface { 8 | GetByID(ctx context.Context, e IEntity) (IEntity, error) 9 | GetAll(ctx context.Context, sq *SearchQuery, e IEntity) (*List, error) 10 | Create(ctx context.Context, e IEntity) (IEntity, error) 11 | Update(ctx context.Context, e IEntity) (IEntity, error) 12 | UpdateWithFields(ctx context.Context, e IEntity, keys []string) (IEntity, error) 13 | Delete(ctx context.Context, e IEntity) (IEntity, error) 14 | 15 | CreateTx(context.Context, IEntity, ITransactionEvent) (IEntity, error) 16 | UpdateTx(context.Context, IEntity, ITransactionEvent) (IEntity, error) 17 | UpdateWithFieldsTx(context.Context, IEntity, []string, ITransactionEvent) (IEntity, error) 18 | DeleteTx(context.Context, IEntity, ITransactionEvent) (IEntity, error) 19 | } 20 | 21 | type ICacheUpdateRepository interface { 22 | Save(ctx context.Context, e IEntity) error 23 | Delete(ctx context.Context, e IEntity) error 24 | } 25 | 26 | type TransactionEventFunc func(context.Context, ITransactionEvent) error 27 | 28 | type ITransactionRepo interface { 29 | RunTransaction(ctx context.Context, f TransactionEventFunc) error 30 | } 31 | 32 | type ITransactionEvent interface { 33 | GetTx() interface{} 34 | } 35 | -------------------------------------------------------------------------------- /config/dev.yml: -------------------------------------------------------------------------------- 1 | app: 2 | name: "ai-service" 3 | version: "0.0.1" 4 | 5 | http: 6 | port: "8080" 7 | 8 | swagger: 9 | host: "localhost:8080" 10 | 11 | kafka: 12 | brokers: "localhost:29092,localhost:29093,localhost:29094" 13 | group_id: "ai-dev" 14 | 15 | esdb: 16 | host: "esdb://localhost:2113?tls=false" 17 | 18 | logger: 19 | project: "" 20 | log_level: "debug" 21 | log_id: "ai-dev" 22 | 23 | sql: 24 | host: "localhost" 25 | port: 5432 26 | user: "user" 27 | password: "pass" 28 | db: "db" 29 | type: "postgresql" 30 | pool_max: 1 31 | connection_attempts: 3 32 | connection_timeout: 1 33 | 34 | # sql: 35 | # host: "localhost" 36 | # port: 3306 37 | # user: "user" 38 | # password: "pass" 39 | # db: "db" 40 | # type: "mysql" 41 | # pool_max: 1 42 | # connection_attempts: 3 43 | # connection_timeout: 1 44 | 45 | redis: 46 | host: "localhost" 47 | port: 6379 48 | password: "" 49 | db: 0 50 | 51 | # nosql: 52 | # host: "localhost" 53 | # port: 27017 54 | # user: "" 55 | # password: "" 56 | # db: "ai-service" 57 | # type: "mongodb" 58 | 59 | storage: 60 | host: "localhost:9000" 61 | bucket: "ai-service" 62 | type: "gcp" 63 | 64 | telemetry: 65 | enabled: true 66 | port: 0 67 | batcher: "gcp" 68 | sample_rate: 1.0 69 | -------------------------------------------------------------------------------- /internal/application/utils/utils.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/go-playground/validator/v10" 9 | ) 10 | 11 | var ( 12 | ErrValidation = errors.New("validation failed") 13 | ) 14 | 15 | func HandleValidationError(structName string, validateErrors validator.ValidationErrors) error { 16 | tagErrors := map[string]func(string) string{ 17 | "required": func(field string) string { 18 | return structName + " " + field + " required" 19 | }, 20 | "lte": func(field string) string { 21 | return structName + " " + field + " exceeds max length" 22 | }, 23 | "alphanum": func(field string) string { 24 | return structName + " " + field + " invalid format" 25 | }, 26 | "custom_permission": func(field string) string { 27 | return structName + " " + field + " invalid permission format" 28 | }, 29 | "oneof": func(field string) string { 30 | return structName + " " + field + " invalid sort field" 31 | }, 32 | } 33 | 34 | var errorMessages []string 35 | 36 | for _, err := range validateErrors { 37 | if specificError, ok := tagErrors[err.Tag()]; ok { 38 | errorMessages = append(errorMessages, specificError(err.Field())) 39 | } else { 40 | errorMessages = append(errorMessages, err.Error()) 41 | } 42 | } 43 | 44 | return fmt.Errorf("%w: %s", ErrValidation, strings.Join(errorMessages, ", ")) 45 | } 46 | -------------------------------------------------------------------------------- /pkg/pwsql/relation/gorm_mock.go: -------------------------------------------------------------------------------- 1 | package relation 2 | 3 | import ( 4 | "database/sql" 5 | "log" 6 | "os" 7 | 8 | "gorm.io/driver/postgres" 9 | "gorm.io/gorm" 10 | "gorm.io/gorm/logger" 11 | "gorm.io/gorm/schema" 12 | 13 | "github.com/program-world-labs/DDDGo/pkg/pwsql" 14 | ) 15 | 16 | var _ pwsql.ISQLGorm = (*MockSQL)(nil) 17 | 18 | type MockSQL struct { 19 | db *gorm.DB 20 | } 21 | 22 | func NewMock(mockdb *sql.DB) *MockSQL { 23 | db, err := gorm.Open( 24 | postgres.New(postgres.Config{ 25 | PreferSimpleProtocol: true, 26 | Conn: mockdb, 27 | }), 28 | &gorm.Config{ 29 | PrepareStmt: true, 30 | Logger: logger.New(log.New(os.Stdout, "\r\n", log.LstdFlags), logger.Config{ 31 | Colorful: true, 32 | LogLevel: logger.Info, 33 | }), 34 | NamingStrategy: schema.NamingStrategy{ 35 | SingularTable: true, 36 | }, 37 | SkipDefaultTransaction: true, 38 | AllowGlobalUpdate: false, 39 | }, 40 | ) 41 | if err != nil { 42 | panic(err) 43 | } 44 | 45 | return &MockSQL{db: db} 46 | } 47 | 48 | func (m *MockSQL) GetDB() *gorm.DB { 49 | return m.db 50 | } 51 | 52 | func (m *MockSQL) Close() error { 53 | if m.db != nil { 54 | sqlDB, err := m.db.DB() 55 | if err != nil { 56 | log.Printf("failed to get sql.DB: %v", err) 57 | 58 | return err 59 | } 60 | 61 | sqlDB.Close() 62 | } 63 | 64 | return nil 65 | } 66 | -------------------------------------------------------------------------------- /tests/role/features/usecase/role_assigned.feature: -------------------------------------------------------------------------------- 1 | Feature: Role assign usecase 2 | 測試使用者指定角色的各種情況 3 | 4 | Scenario: 成功分配角色給用戶 5 | Given 提供 , , 6 | When 嘗試分配角色給用戶 7 | Then 角色應該成功被分配給用戶 8 | 9 | Examples: 10 | |id|userId|assign| 11 | |role1|["user1", "user2"]|true| 12 | 13 | Scenario: 成功取消用戶的角色 14 | Given 提供 , , 15 | When 嘗試取消用戶的角色 16 | Then 角色應該成功被從用戶那裡取消 17 | 18 | Examples: 19 | |id|userId|assign| 20 | |role1|["user1", "user2"]|false| 21 | 22 | Scenario: 提供的角色ID不存在 23 | Given 提供不存在的角色ID 24 | When 嘗試分配角色給用戶 25 | Then 應該返回一個錯誤,說明角色ID不存在 26 | 27 | Scenario: 提供的用戶ID不存在 28 | Given 提供不存在的使用者ID 29 | When 嘗試分配角色給用戶 30 | Then 應該返回一個錯誤,說明用戶ID不存在 31 | 32 | 33 | Scenario: 一次嘗試分配多個用戶到同一角色 34 | Given 提供 , , 35 | When 嘗試分配角色給用戶 36 | Then 角色應該成功被分配給所有用戶 37 | 38 | Examples: 39 | |id|userId|assign| 40 | |role1|["user1", "user2", "user3", "user4", "user5"]|true| 41 | 42 | Scenario: 一次嘗試取消多個用戶的同一角色 43 | Given 提供 , , 44 | When 嘗試取消用戶的角色 45 | Then 角色應該成功被從所有用戶那裡取消 46 | 47 | Examples: 48 | |id|userId|assign| 49 | |role1|["user1", "user2", "user3", "user4", "user5"]|false| -------------------------------------------------------------------------------- /internal/adapter/http/v1/wallet/request.go: -------------------------------------------------------------------------------- 1 | package wallet 2 | 3 | import "github.com/program-world-labs/DDDGo/internal/domain/entity" 4 | 5 | type CreatedRequest struct { 6 | Name string `json:"name" binding:"required" example:"admin"` 7 | Description string `json:"description" binding:"required" example:"this is for admin wallet"` 8 | Chain entity.Chain `json:"chain" binding:"required" example:"Polygon"` 9 | UserID string `json:"userId" binding:"required" example:"abcdef2nopabcdef2nop"` 10 | } 11 | 12 | type ListGotRequest struct { 13 | Limit int `json:"limit" form:"limit" binding:"required"` 14 | Offset int `json:"offset" form:"offset"` 15 | FilterName string `json:"filterName" form:"filterName"` 16 | SortFields []string `json:"sortFields" form:"sortFields"` 17 | Dir string `json:"dir" form:"dir"` 18 | } 19 | 20 | type DetailGotRequest struct { 21 | ID string `json:"id" uri:"id" binding:"required"` 22 | } 23 | 24 | type DeletedRequest struct { 25 | ID string `json:"id" uri:"id" binding:"required"` 26 | } 27 | 28 | type UpdatedRequest struct { 29 | Name string `json:"name" binding:"required" example:"admin"` 30 | Description string `json:"description" binding:"required" example:"this is for admin wallet"` 31 | Chain entity.Chain `json:"chain" binding:"required" example:"Polygon"` 32 | UserID string `json:"userId" binding:"required" example:"abcd-efgh-ijkl-mnop"` 33 | } 34 | -------------------------------------------------------------------------------- /internal/app/migrate.go: -------------------------------------------------------------------------------- 1 | //go:build migrate 2 | 3 | package app 4 | 5 | import ( 6 | "errors" 7 | "log" 8 | "os" 9 | "time" 10 | 11 | "github.com/golang-migrate/migrate/v4" 12 | // migrate tools 13 | _ "github.com/golang-migrate/migrate/v4/database/postgres" 14 | _ "github.com/golang-migrate/migrate/v4/source/file" 15 | ) 16 | 17 | const ( 18 | _defaultAttempts = 20 19 | _defaultTimeout = time.Second 20 | ) 21 | 22 | func init() { 23 | databaseURL, ok := os.LookupEnv("APP_SQL_URL") 24 | if !ok || len(databaseURL) == 0 { 25 | log.Fatalf("migrate: environment variable not declared: APP_SQL_URL") 26 | } 27 | 28 | databaseURL += "?sslmode=disable" 29 | 30 | var ( 31 | attempts = _defaultAttempts 32 | err error 33 | m *migrate.Migrate 34 | ) 35 | 36 | for attempts > 0 { 37 | m, err = migrate.New("file://migrations", databaseURL) 38 | if err == nil { 39 | break 40 | } 41 | 42 | log.Printf("Migrate: postgres is trying to connect, attempts left: %d", attempts) 43 | time.Sleep(_defaultTimeout) 44 | attempts-- 45 | } 46 | 47 | if err != nil { 48 | log.Fatalf("Migrate: postgres connect error: %s", err) 49 | } 50 | 51 | err = m.Up() 52 | defer m.Close() 53 | if err != nil && !errors.Is(err, migrate.ErrNoChange) { 54 | log.Fatalf("Migrate: up error: %s", err) 55 | } 56 | 57 | if errors.Is(err, migrate.ErrNoChange) { 58 | log.Printf("Migrate: no change") 59 | return 60 | } 61 | 62 | log.Printf("Migrate: up success") 63 | } 64 | -------------------------------------------------------------------------------- /pkg/httpserver/server.go: -------------------------------------------------------------------------------- 1 | // Package httpserver implements HTTP server. 2 | package httpserver 3 | 4 | import ( 5 | "context" 6 | "net/http" 7 | "time" 8 | ) 9 | 10 | const ( 11 | _defaultReadTimeout = 5 * time.Second 12 | _defaultWriteTimeout = 5 * time.Second 13 | _defaultAddr = ":80" 14 | _defaultShutdownTimeout = 3 * time.Second 15 | ) 16 | 17 | // Server -. 18 | type Server struct { 19 | server *http.Server 20 | notify chan error 21 | shutdownTimeout time.Duration 22 | } 23 | 24 | // New -. 25 | func New(handler http.Handler, opts ...Option) *Server { 26 | httpServer := &http.Server{ 27 | Handler: handler, 28 | ReadTimeout: _defaultReadTimeout, 29 | WriteTimeout: _defaultWriteTimeout, 30 | Addr: _defaultAddr, 31 | } 32 | 33 | s := &Server{ 34 | server: httpServer, 35 | notify: make(chan error, 1), 36 | shutdownTimeout: _defaultShutdownTimeout, 37 | } 38 | 39 | // Custom options 40 | for _, opt := range opts { 41 | opt(s) 42 | } 43 | 44 | s.start() 45 | 46 | return s 47 | } 48 | 49 | func (s *Server) start() { 50 | go func() { 51 | s.notify <- s.server.ListenAndServe() 52 | close(s.notify) 53 | }() 54 | } 55 | 56 | // Notify -. 57 | func (s *Server) Notify() <-chan error { 58 | return s.notify 59 | } 60 | 61 | // Shutdown -. 62 | func (s *Server) Shutdown() error { 63 | ctx, cancel := context.WithTimeout(context.Background(), s.shutdownTimeout) 64 | defer cancel() 65 | 66 | return s.server.Shutdown(ctx) 67 | } 68 | -------------------------------------------------------------------------------- /pkg/operations/stack_driver.go: -------------------------------------------------------------------------------- 1 | package operations 2 | 3 | import ( 4 | "context" 5 | "log" 6 | 7 | texporter "github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/trace" 8 | "go.opentelemetry.io/contrib/detectors/gcp" 9 | "go.opentelemetry.io/otel" 10 | "go.opentelemetry.io/otel/sdk/resource" 11 | sdktrace "go.opentelemetry.io/otel/sdk/trace" 12 | semconv "go.opentelemetry.io/otel/semconv/v1.4.0" 13 | ) 14 | 15 | // GoogleCloudOperationInit -. 16 | func GoogleCloudOperationInit(projectID string, sampleRate float64) { 17 | ctx := context.Background() 18 | exporter, err := texporter.New(texporter.WithProjectID(projectID)) 19 | 20 | if err != nil { 21 | log.Fatalf("texporter.New: %v", err) 22 | } 23 | 24 | // Identify your application using resource detection 25 | res, err := resource.New(ctx, 26 | // Use the GCP resource detector to detect information about the GCP platform 27 | resource.WithDetectors(gcp.NewDetector()), 28 | // Keep the default detectors 29 | resource.WithTelemetrySDK(), 30 | // Add your own custom attributes to identify your application 31 | resource.WithAttributes( 32 | semconv.ServiceNameKey.String("DDD-API-Service"), 33 | ), 34 | ) 35 | 36 | if err != nil { 37 | log.Fatalf("resource.New: %v", err) 38 | } 39 | 40 | // tp := sdktrace.NewTracerProvider(sdktrace.WithSampler(sdktrace.TraceIDRatioBased(0.0001)), ...) 41 | tp := sdktrace.NewTracerProvider( 42 | sdktrace.WithBatcher(exporter), 43 | sdktrace.WithResource(res), 44 | sdktrace.WithSampler(sdktrace.TraceIDRatioBased(sampleRate)), 45 | ) 46 | defer tp.ForceFlush(ctx) // flushes any pending spans 47 | 48 | otel.SetTracerProvider(tp) 49 | } 50 | -------------------------------------------------------------------------------- /pkg/cache/redis/redis_cache.go: -------------------------------------------------------------------------------- 1 | package redis 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "time" 8 | 9 | "github.com/redis/go-redis/extra/redisotel/v9" 10 | "github.com/redis/go-redis/v9" 11 | ) 12 | 13 | const ( 14 | _defaultMaxRetries = 3 15 | _defaultRetryDelay = time.Second 16 | ) 17 | 18 | // Redis -. 19 | type Redis struct { 20 | maxRetries int 21 | retryDelay time.Duration 22 | 23 | Client *redis.Client 24 | } 25 | 26 | // New -. 27 | func New(dsn string, opts ...Option) (*Redis, error) { 28 | rd := &Redis{ 29 | maxRetries: _defaultMaxRetries, 30 | retryDelay: _defaultRetryDelay, 31 | } 32 | 33 | // Custom options 34 | for _, opt := range opts { 35 | opt(rd) 36 | } 37 | 38 | options, err := redis.ParseURL(dsn) 39 | if err != nil { 40 | return nil, fmt.Errorf("redis - NewRedis - ParseURL: %w", err) 41 | } 42 | 43 | client := redis.NewClient(options) 44 | 45 | for i := 0; i < rd.maxRetries; i++ { 46 | err := client.Ping(context.Background()).Err() 47 | if err == nil { 48 | break 49 | } 50 | 51 | if i == rd.maxRetries-1 { 52 | return nil, fmt.Errorf("redis - NewRedis - maxRetries == 0: %w", err) 53 | } 54 | 55 | time.Sleep(rd.retryDelay) 56 | } 57 | 58 | // Enable tracing instrumentation. 59 | if err := redisotel.InstrumentTracing(client); err != nil { 60 | panic(err) 61 | } 62 | 63 | // Enable metrics instrumentation. 64 | if err := redisotel.InstrumentMetrics(client); err != nil { 65 | panic(err) 66 | } 67 | 68 | rd.Client = client 69 | 70 | return rd, nil 71 | } 72 | 73 | // Close -. 74 | func (r *Redis) Close() { 75 | if r.Client != nil { 76 | err := r.Client.Close() 77 | if err != nil { 78 | log.Printf("failed to close redis client: %v", err) 79 | 80 | return 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /internal/app/app.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "os" 5 | "os/signal" 6 | "syscall" 7 | 8 | "github.com/program-world-labs/pwlogger" 9 | 10 | "github.com/program-world-labs/DDDGo/config" 11 | "github.com/program-world-labs/DDDGo/pkg/operations" 12 | ) 13 | 14 | // Run creates objects via constructors. 15 | func Run(cfg *config.Config) { 16 | var l pwlogger.Interface 17 | // Logger 18 | if cfg.Env.EnvName != "dev" { 19 | l = pwlogger.NewProductionLogger(cfg.Log.Project) 20 | } else { 21 | l = pwlogger.NewDevelopmentLogger(cfg.Log.Project) 22 | } 23 | 24 | // Tracer 25 | err := operations.InitNewTracer(cfg.Telemetry.Host, cfg.Telemetry.Port, cfg.Telemetry.Batcher, cfg.Telemetry.SampleRate, cfg.Telemetry.Enabled) 26 | if err != nil { 27 | l.Panic().Err(err).Str("Tracer", "Run").Msg("InitNewTracer error") 28 | } 29 | 30 | // Http Server 31 | httpServer, err := NewHTTPServer(cfg, l) 32 | if err != nil { 33 | l.Panic().Err(err).Str("app", "Run").Msg("InitializeHTTPServer error") 34 | } 35 | 36 | // Message Router 37 | router, err := NewMessageRouter(cfg, l) 38 | if err != nil { 39 | l.Panic().Err(err).Str("app", "Run").Msg("InitializeMessageRouter error") 40 | } 41 | 42 | // Waiting signal 43 | interrupt := make(chan os.Signal, 1) 44 | signal.Notify(interrupt, os.Interrupt, syscall.SIGTERM) 45 | 46 | select { 47 | case s := <-interrupt: 48 | l.Info().Str("app", "Run").Msgf("Got signal %s, exiting now", s.String()) 49 | case err = <-httpServer.Notify(): 50 | l.Err(err).Str("app", "Run").Msg("httpServer.Notify error") 51 | } 52 | 53 | // Shutdown 54 | err = httpServer.Shutdown() 55 | if err != nil { 56 | l.Err(err).Str("app", "Run").Msg("httpServer.Shutdown error") 57 | } 58 | 59 | // Close message server 60 | router.Close() 61 | } 62 | -------------------------------------------------------------------------------- /internal/adapter/http/v1/group/response.go: -------------------------------------------------------------------------------- 1 | package group 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/program-world-labs/DDDGo/internal/adapter/http/v1/user" 7 | application_group "github.com/program-world-labs/DDDGo/internal/application/group" 8 | ) 9 | 10 | type Response struct { 11 | ID string `json:"id"` 12 | Name string `json:"name"` 13 | Description string `json:"description"` 14 | OwnerID string `json:"ownerId"` 15 | Users []user.Response `json:"users"` 16 | CreatedAt time.Time `json:"createdAt"` 17 | UpdatedAt time.Time `json:"updatedAt"` 18 | DeletedAt time.Time `json:"deletedAt"` 19 | } 20 | 21 | type ResponseList struct { 22 | Offset int64 `json:"offset"` 23 | Limit int64 `json:"limit"` 24 | Total int64 `json:"total"` 25 | Items []Response `json:"items"` 26 | } 27 | 28 | func NewResponse(model *application_group.Output) Response { 29 | userList := make([]user.Response, len(model.Users)) 30 | 31 | for i, v := range model.Users { 32 | value := v 33 | userList[i] = user.NewResponse(&value) 34 | } 35 | 36 | return Response{ 37 | ID: model.ID, 38 | Name: model.Name, 39 | Description: model.Description, 40 | OwnerID: model.OwnerID, 41 | Users: userList, 42 | CreatedAt: model.CreatedAt, 43 | UpdatedAt: model.UpdatedAt, 44 | DeletedAt: model.DeletedAt, 45 | } 46 | } 47 | 48 | func NewResponseList(modelList *application_group.OutputList) ResponseList { 49 | responseList := make([]Response, len(modelList.Items)) 50 | 51 | for i, v := range modelList.Items { 52 | value := v 53 | responseList[i] = NewResponse(&value) 54 | } 55 | 56 | return ResponseList{ 57 | Offset: modelList.Offset, 58 | Limit: modelList.Limit, 59 | Total: modelList.Total, 60 | Items: responseList, 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /internal/adapter/http/v1/role/response.go: -------------------------------------------------------------------------------- 1 | package role 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/program-world-labs/DDDGo/internal/adapter/http/v1/user" 7 | application_role "github.com/program-world-labs/DDDGo/internal/application/role" 8 | ) 9 | 10 | type Response struct { 11 | ID string `json:"id"` 12 | Name string `json:"name"` 13 | Description string `json:"description"` 14 | Permissions []string `json:"permissions"` 15 | Users []user.Response `json:"users"` 16 | CreatedAt time.Time `json:"createdAt"` 17 | UpdatedAt time.Time `json:"updatedAt"` 18 | DeletedAt time.Time `json:"deletedAt"` 19 | } 20 | 21 | type ResponseList struct { 22 | Offset int64 `json:"offset"` 23 | Limit int64 `json:"limit"` 24 | Total int64 `json:"total"` 25 | Items []Response `json:"items"` 26 | } 27 | 28 | func NewResponse(model *application_role.Output) Response { 29 | userList := make([]user.Response, len(model.Users)) 30 | 31 | for i, v := range model.Users { 32 | value := v 33 | userList[i] = user.NewResponse(&value) 34 | } 35 | 36 | return Response{ 37 | ID: model.ID, 38 | Name: model.Name, 39 | Description: model.Description, 40 | Permissions: model.Permissions, 41 | Users: userList, 42 | CreatedAt: model.CreatedAt, 43 | UpdatedAt: model.UpdatedAt, 44 | DeletedAt: model.DeletedAt, 45 | } 46 | } 47 | 48 | func NewResponseList(modelList *application_role.OutputList) ResponseList { 49 | responseList := make([]Response, len(modelList.Items)) 50 | 51 | for i, v := range modelList.Items { 52 | value := v 53 | responseList[i] = NewResponse(&value) 54 | } 55 | 56 | return ResponseList{ 57 | Offset: modelList.Offset, 58 | Limit: modelList.Limit, 59 | Total: modelList.Total, 60 | Items: responseList, 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /tests/role/features/usecase/role_updated.feature: -------------------------------------------------------------------------------- 1 | Feature: 更新角色 2 | 測試更新角色相關的usecase功能 3 | 4 | Scenario: 成功更新角色 5 | Given 提供 , , , 6 | When 嘗試更新角色 7 | Then 角色應該成功被更新 8 | 9 | Examples: 10 | |id|name|description|permissions| 11 | |role1|new_name|new_description|["read:all", "write:all"]| 12 | 13 | Scenario: 提供的角色ID不存在 14 | Given 提供不存在的角色ID 15 | When 嘗試更新角色 16 | Then 應該返回一個錯誤,說明角色ID不存在 17 | 18 | 19 | Scenario: 提供的權限格式不正確 20 | Given 提供 , , , 21 | When 嘗試更新角色 22 | Then 應該返回一個錯誤,說明權限格式不正確 23 | 24 | Examples: 25 | |id|name|description|permissions| 26 | |role1|new_name|new_description|["read:all", "write:all", "invalid:all"]| 27 | 28 | Scenario: 提供的角色名稱或描述長度為最大值 29 | Given 提供 , , , 30 | When 嘗試更新角色 31 | Then 角色應該成功被更新 32 | 33 | Examples: 34 | |id|name|description|permissions| 35 | |role1|eMAWSxvuWc36VAKVFxMmeYHmr70GvI|rfgkzDeNnc69zIDnsxdZTfEazl2sXEfCKhFds6ydEWfzN5pGrRlQa22524xvzLS7gtgKFzqizI4aCxXIB7Vni2uPbWjy4vBntNc9XvnSKvAfqzbMOgmD3jxmKuJGNRO4zfX6HNykFQJfSB4qCu47bE6Uzhzul1uHXcrKQWRR85ziXcHMfu1g4NmMHQBpWiFswexTwn4g|["read:all"]| 36 | 37 | Scenario: 提供的角色名稱或描述長度超過最大值 38 | Given 提供 , , , 39 | When 嘗試更新角色 40 | Then 應該返回一個錯誤,說明角色名稱或描述長度超過最大值 41 | 42 | Examples: 43 | |id|name|description|permissions| 44 | |role1|o8SA8aJMn1EaBjMS3l6UPdLPZ931T9|QTdYDISBUy7YFfrPHAA9R34GHEPmotoGkT9k0JPXbZk5P2vk1WudZbwhVk2KrtgDrRPK9uaPcryLIFdBVL6l4ct2SdyBq7WI0htPXinMhjACuaN7x6RL7rhAeS3Esa6h9kNPoB3mAsFkzr9ysCFnlLajh8a0KlkJcAplKYvPOXVbrnEJ3mdfH3rzIaCxjCM4FU69K7hte|read:all| 45 | |role1|o8SA8aJMn1EaBjMS3l6UPdLPZ931T9c|QTdYDISBUy7YFfrPHAA9R34GHEPmotoGkT9k0JPXbZk5P2vk1WudZbwhVk2KrtgDrRPK9uaPcryLIFdBVL6l4ct2SdyBq7WI0htPXinMhjACuaN7x6RL7rhAeS3Esa6h9kNPoB3mAsFkzr9ysCFnlLajh8a0KlkJcAplKYvPOXVbrnEJ3mdfH3rzIaCxjCM4FU69K7ht|read:all| -------------------------------------------------------------------------------- /tests/mocks/EventProducer_mock.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: internal/domain/event/event_producer.go 3 | 4 | // Package mocks is a generated GoMock package. 5 | package mocks 6 | 7 | import ( 8 | context "context" 9 | reflect "reflect" 10 | 11 | gomock "github.com/golang/mock/gomock" 12 | ) 13 | 14 | // MockProducer is a mock of Producer interface. 15 | type MockProducer struct { 16 | ctrl *gomock.Controller 17 | recorder *MockProducerMockRecorder 18 | } 19 | 20 | // MockProducerMockRecorder is the mock recorder for MockProducer. 21 | type MockProducerMockRecorder struct { 22 | mock *MockProducer 23 | } 24 | 25 | // NewMockProducer creates a new mock instance. 26 | func NewMockProducer(ctrl *gomock.Controller) *MockProducer { 27 | mock := &MockProducer{ctrl: ctrl} 28 | mock.recorder = &MockProducerMockRecorder{mock} 29 | return mock 30 | } 31 | 32 | // EXPECT returns an object that allows the caller to indicate expected use. 33 | func (m *MockProducer) EXPECT() *MockProducerMockRecorder { 34 | return m.recorder 35 | } 36 | 37 | // Close mocks base method. 38 | func (m *MockProducer) Close() error { 39 | m.ctrl.T.Helper() 40 | ret := m.ctrl.Call(m, "Close") 41 | ret0, _ := ret[0].(error) 42 | return ret0 43 | } 44 | 45 | // Close indicates an expected call of Close. 46 | func (mr *MockProducerMockRecorder) Close() *gomock.Call { 47 | mr.mock.ctrl.T.Helper() 48 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Close", reflect.TypeOf((*MockProducer)(nil).Close)) 49 | } 50 | 51 | // PublishEvent mocks base method. 52 | func (m *MockProducer) PublishEvent(ctx context.Context, topic string, event interface{}) error { 53 | m.ctrl.T.Helper() 54 | ret := m.ctrl.Call(m, "PublishEvent", ctx, topic, event) 55 | ret0, _ := ret[0].(error) 56 | return ret0 57 | } 58 | 59 | // PublishEvent indicates an expected call of PublishEvent. 60 | func (mr *MockProducerMockRecorder) PublishEvent(ctx, topic, event interface{}) *gomock.Call { 61 | mr.mock.ctrl.T.Helper() 62 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PublishEvent", reflect.TypeOf((*MockProducer)(nil).PublishEvent), ctx, topic, event) 63 | } 64 | -------------------------------------------------------------------------------- /internal/adapter/http/v1/wallet/response.go: -------------------------------------------------------------------------------- 1 | package wallet 2 | 3 | import ( 4 | "time" 5 | 6 | application_wallet "github.com/program-world-labs/DDDGo/internal/application/wallet" 7 | "github.com/program-world-labs/DDDGo/internal/domain/entity" 8 | ) 9 | 10 | type Response struct { 11 | ID string `json:"id"` 12 | Name string `json:"name"` 13 | Description string `json:"description"` 14 | Chain entity.Chain `json:"chain" binding:"required" example:"Polygon"` 15 | UserID string `json:"userId" binding:"required" example:"abcd-efgh-ijkl-mnop"` 16 | WalletBalances []BalancesResponse `json:"walletBalances"` 17 | CreatedAt time.Time `json:"createdAt"` 18 | UpdatedAt time.Time `json:"updatedAt"` 19 | DeletedAt time.Time `json:"deletedAt"` 20 | } 21 | 22 | type BalancesResponse struct { 23 | ID string `json:"id" gorm:"primary_key"` 24 | WalletID string `json:"walletId" gorm:"index"` 25 | CurrencyID string `json:"currencyId" gorm:"index"` 26 | Balance uint `json:"balance"` 27 | Decimal uint `json:"decimal"` 28 | } 29 | 30 | type ResponseList struct { 31 | Offset int64 `json:"offset"` 32 | Limit int64 `json:"limit"` 33 | Total int64 `json:"total"` 34 | Items []Response `json:"items"` 35 | } 36 | 37 | func NewResponse(model *application_wallet.Output) Response { 38 | return Response{ 39 | ID: model.ID, 40 | Name: model.Name, 41 | Description: model.Description, 42 | Chain: model.Chain, 43 | UserID: model.UserID, 44 | CreatedAt: model.CreatedAt, 45 | UpdatedAt: model.UpdatedAt, 46 | DeletedAt: model.DeletedAt, 47 | } 48 | } 49 | 50 | func NewResponseList(modelList *application_wallet.OutputList) ResponseList { 51 | responseList := make([]Response, len(modelList.Items)) 52 | 53 | for i, v := range modelList.Items { 54 | value := v 55 | responseList[i] = NewResponse(&value) 56 | } 57 | 58 | return ResponseList{ 59 | Offset: modelList.Offset, 60 | Limit: modelList.Limit, 61 | Total: modelList.Total, 62 | Items: responseList, 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /internal/adapter/http/v1/currency/response.go: -------------------------------------------------------------------------------- 1 | package currency 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/program-world-labs/DDDGo/internal/adapter/http/v1/wallet" 7 | application_currency "github.com/program-world-labs/DDDGo/internal/application/currency" 8 | ) 9 | 10 | type Response struct { 11 | ID string `json:"id"` 12 | Name string `json:"name"` 13 | Symbol string `json:"symbol"` 14 | WalletBalances []wallet.BalancesResponse `json:"walletBalances"` 15 | CreatedAt time.Time `json:"createdAt"` 16 | UpdatedAt time.Time `json:"updatedAt"` 17 | DeletedAt time.Time `json:"deletedAt"` 18 | } 19 | 20 | type ResponseList struct { 21 | Offset int64 `json:"offset"` 22 | Limit int64 `json:"limit"` 23 | Total int64 `json:"total"` 24 | Items []Response `json:"items"` 25 | } 26 | 27 | func NewResponse(model *application_currency.Output) Response { 28 | return Response{ 29 | ID: model.ID, 30 | Name: model.Name, 31 | Symbol: model.Symbol, 32 | WalletBalances: func() []wallet.BalancesResponse { 33 | walletBalances := make([]wallet.BalancesResponse, len(model.WalletBalances)) 34 | 35 | for i, v := range model.WalletBalances { 36 | value := v 37 | walletBalances[i] = wallet.BalancesResponse{ 38 | ID: value.ID, 39 | WalletID: value.WalletID, 40 | CurrencyID: value.CurrencyID, 41 | Balance: value.Balance, 42 | Decimal: value.Decimal, 43 | } 44 | } 45 | 46 | return walletBalances 47 | }(), 48 | CreatedAt: model.CreatedAt, 49 | UpdatedAt: model.UpdatedAt, 50 | DeletedAt: model.DeletedAt, 51 | } 52 | } 53 | 54 | func NewResponseList(modelList *application_currency.OutputList) ResponseList { 55 | responseList := make([]Response, len(modelList.Items)) 56 | 57 | for i, v := range modelList.Items { 58 | value := v 59 | responseList[i] = NewResponse(&value) 60 | } 61 | 62 | return ResponseList{ 63 | Offset: modelList.Offset, 64 | Limit: modelList.Limit, 65 | Total: modelList.Total, 66 | Items: responseList, 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /pkg/pwsql/relation/mysql/gorm_mysql.go: -------------------------------------------------------------------------------- 1 | package mysql 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "time" 8 | 9 | "gorm.io/driver/mysql" 10 | "gorm.io/gorm" 11 | "gorm.io/plugin/opentelemetry/tracing" 12 | ) 13 | 14 | const ( 15 | _defaultMaxPoolSize = 1 16 | _defaultConnAttempts = 10 17 | _defaultConnTimeout = time.Second 18 | ) 19 | 20 | // MySQL -. 21 | type MySQL struct { 22 | maxPoolSize int 23 | connAttempts int 24 | connTimeout time.Duration 25 | 26 | db *gorm.DB 27 | } 28 | 29 | // New -. 30 | func New(dsn string, opts ...Option) (*MySQL, error) { 31 | my := &MySQL{ 32 | maxPoolSize: _defaultMaxPoolSize, 33 | connAttempts: _defaultConnAttempts, 34 | connTimeout: _defaultConnTimeout, 35 | } 36 | 37 | // Custom options 38 | for _, opt := range opts { 39 | opt(my) 40 | } 41 | 42 | db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{ 43 | SkipDefaultTransaction: true, 44 | }) 45 | if err != nil { 46 | return nil, fmt.Errorf("failed to connect to database: %w", err) 47 | } 48 | 49 | if err = db.Use(tracing.NewPlugin(tracing.WithoutMetrics())); err != nil { 50 | panic(err) 51 | } 52 | 53 | sqlDB, err := db.DB() 54 | if err != nil { 55 | return nil, fmt.Errorf("failed to get sql.DB: %w", err) 56 | } 57 | 58 | sqlDB.SetMaxIdleConns(my.maxPoolSize) 59 | sqlDB.SetMaxOpenConns(my.maxPoolSize) 60 | 61 | for my.connAttempts > 0 { 62 | err = sqlDB.PingContext(context.Background()) 63 | if err == nil { 64 | break 65 | } 66 | 67 | log.Printf("MySQL is trying to connect, attempts left: %d", my.connAttempts) 68 | 69 | time.Sleep(my.connTimeout) 70 | 71 | my.connAttempts-- 72 | } 73 | 74 | if err != nil { 75 | return nil, fmt.Errorf("postgres - NewMySQL - connAttempts == 0: %w", err) 76 | } 77 | 78 | my.db = db 79 | 80 | return my, nil 81 | } 82 | 83 | func (p *MySQL) GetDB() *gorm.DB { 84 | return p.db 85 | } 86 | 87 | // Close -. 88 | func (p *MySQL) Close() error { 89 | if p.db != nil { 90 | sqlDB, err := p.db.DB() 91 | if err != nil { 92 | log.Printf("failed to get sql.DB: %v", err) 93 | 94 | return err 95 | } 96 | 97 | sqlDB.Close() 98 | } 99 | 100 | return nil 101 | } 102 | -------------------------------------------------------------------------------- /tests/utils.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "reflect" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | var ErrFieldDoesNotExist = errors.New("field does not exist") 12 | var ErrFieldDoesNotMatch = errors.New("field does not match") 13 | 14 | func CompareFields(a, b interface{}, fields []string) error { 15 | va := reflect.ValueOf(a) 16 | vb := reflect.ValueOf(b) 17 | 18 | for _, field := range fields { 19 | fa := va.FieldByName(field) 20 | fb := vb.FieldByName(field) 21 | 22 | if !fa.IsValid() || !fb.IsValid() { 23 | return fmt.Errorf("%w: %s", ErrFieldDoesNotExist, field) 24 | } 25 | 26 | if fa.Interface() != fb.Interface() { 27 | return fmt.Errorf("%w: %s", ErrFieldDoesNotMatch, field) 28 | } 29 | } 30 | 31 | return nil 32 | } 33 | 34 | // assertExpectedAndActual is a helper function to allow the step function to call 35 | // assertion functions where you want to compare an expected and an actual value. 36 | 37 | func AssertExpectedAndActual(a expectedAndActualAssertion, expected, actual interface{}, msgAndArgs ...interface{}) error { 38 | var t asserter 39 | 40 | a(&t, expected, actual, msgAndArgs...) 41 | 42 | return t.err 43 | } 44 | 45 | type expectedAndActualAssertion func(t assert.TestingT, expected, actual interface{}, msgAndArgs ...interface{}) bool 46 | 47 | // assertActual is a helper function to allow the step function to call 48 | // assertion functions where you want to compare an actual value to a 49 | // predined state like nil, empty or true/false. 50 | func AssertActual(a actualAssertion, actual interface{}, msgAndArgs ...interface{}) error { 51 | var t asserter 52 | 53 | a(&t, actual, msgAndArgs...) 54 | 55 | return t.err 56 | } 57 | 58 | type actualAssertion func(t assert.TestingT, actual interface{}, msgAndArgs ...interface{}) bool 59 | 60 | // asserter is used to be able to retrieve the error reported by the called assertion. 61 | type asserter struct { 62 | err error 63 | } 64 | 65 | var ErrAssertionFailed = errors.New("assertion failed") 66 | 67 | // Errorf is used by the called assertion to report an error. 68 | func (a *asserter) Errorf(format string, args ...interface{}) { 69 | msg := fmt.Sprintf(format, args...) 70 | a.err = fmt.Errorf("%w: %s", ErrAssertionFailed, msg) 71 | } 72 | -------------------------------------------------------------------------------- /pkg/pwsql/relation/postgresql/gorm_postgres.go: -------------------------------------------------------------------------------- 1 | package postgresql 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "time" 8 | 9 | "gorm.io/driver/postgres" 10 | "gorm.io/gorm" 11 | "gorm.io/plugin/opentelemetry/tracing" 12 | ) 13 | 14 | const ( 15 | _defaultMaxPoolSize = 1 16 | _defaultConnAttempts = 10 17 | _defaultConnTimeout = time.Second 18 | ) 19 | 20 | // Postgres -. 21 | type Postgres struct { 22 | maxPoolSize int 23 | connAttempts int 24 | connTimeout time.Duration 25 | 26 | db *gorm.DB 27 | } 28 | 29 | // New -. 30 | func New(dsn string, opts ...Option) (*Postgres, error) { 31 | pg := &Postgres{ 32 | maxPoolSize: _defaultMaxPoolSize, 33 | connAttempts: _defaultConnAttempts, 34 | connTimeout: _defaultConnTimeout, 35 | } 36 | 37 | // Custom options 38 | for _, opt := range opts { 39 | opt(pg) 40 | } 41 | 42 | db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{ 43 | SkipDefaultTransaction: true, 44 | }) 45 | if err != nil { 46 | return nil, fmt.Errorf("failed to connect to database: %w", err) 47 | } 48 | 49 | if err = db.Use(tracing.NewPlugin(tracing.WithoutMetrics())); err != nil { 50 | panic(err) 51 | } 52 | 53 | sqlDB, err := db.DB() 54 | if err != nil { 55 | return nil, fmt.Errorf("failed to get sql.DB: %w", err) 56 | } 57 | 58 | sqlDB.SetMaxIdleConns(pg.maxPoolSize) 59 | sqlDB.SetMaxOpenConns(pg.maxPoolSize) 60 | 61 | for pg.connAttempts > 0 { 62 | err = sqlDB.PingContext(context.Background()) 63 | if err == nil { 64 | break 65 | } 66 | 67 | log.Printf("Postgres is trying to connect, attempts left: %d", pg.connAttempts) 68 | 69 | time.Sleep(pg.connTimeout) 70 | 71 | pg.connAttempts-- 72 | } 73 | 74 | if err != nil { 75 | return nil, fmt.Errorf("postgres - NewPostgres - connAttempts == 0: %w", err) 76 | } 77 | 78 | pg.db = db 79 | 80 | return pg, nil 81 | } 82 | 83 | func (p *Postgres) GetDB() *gorm.DB { 84 | return p.db 85 | } 86 | 87 | // Close -. 88 | func (p *Postgres) Close() error { 89 | if p.db != nil { 90 | sqlDB, err := p.db.DB() 91 | if err != nil { 92 | log.Printf("failed to get sql.DB: %v", err) 93 | 94 | return err 95 | } 96 | 97 | sqlDB.Close() 98 | } 99 | 100 | return nil 101 | } 102 | -------------------------------------------------------------------------------- /pkg/cache/local/bigcache_cache.go: -------------------------------------------------------------------------------- 1 | package local 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "time" 7 | 8 | "github.com/allegro/bigcache/v3" 9 | ) 10 | 11 | // BigCache -. 12 | type BigCache struct { 13 | Client *bigcache.BigCache 14 | } 15 | 16 | // New -. 17 | func New() (*BigCache, error) { 18 | config := bigcache.Config{ 19 | // number of shards (must be a power of 2) 20 | Shards: 1024, 21 | 22 | // time after which entry can be evicted 23 | LifeWindow: 10 * time.Second, 24 | 25 | // Interval between removing expired entries (clean up). 26 | // If set to <= 0 then no action is performed. 27 | // Setting to < 1 second is counterproductive — bigcache has a one second resolution. 28 | CleanWindow: 5 * time.Second, 29 | 30 | // rps * lifeWindow, used only in initial memory allocation 31 | MaxEntriesInWindow: 1000 * 10 * 60, 32 | 33 | // max entry size in bytes, used only in initial memory allocation 34 | MaxEntrySize: 500, 35 | 36 | // prints information about additional memory allocation 37 | Verbose: true, 38 | 39 | // cache will not allocate more memory than this limit, value in MB 40 | // if value is reached then the oldest entries can be overridden for the new ones 41 | // 0 value means no size limit 42 | HardMaxCacheSize: 8192, 43 | 44 | // callback fired when the oldest entry is removed because of its expiration time or no space left 45 | // for the new entry, or because delete was called. A bitmask representing the reason will be returned. 46 | // Default value is nil which means no callback and it prevents from unwrapping the oldest entry. 47 | OnRemove: nil, 48 | 49 | // OnRemoveWithReason is a callback fired when the oldest entry is removed because of its expiration time or no space left 50 | // for the new entry, or because delete was called. A constant representing the reason will be passed through. 51 | // Default value is nil which means no callback and it prevents from unwrapping the oldest entry. 52 | // Ignored if OnRemove is specified. 53 | OnRemoveWithReason: nil, 54 | } 55 | 56 | cache, initErr := bigcache.New(context.Background(), config) 57 | if initErr != nil { 58 | log.Fatal(initErr) 59 | } 60 | 61 | return &BigCache{Client: cache}, initErr 62 | } 63 | 64 | // Close -. 65 | func (r *BigCache) Close() { 66 | if r.Client != nil { 67 | r.Client.Close() 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.9' 2 | services: 3 | postgres: 4 | container_name: postgres 5 | image: postgres 6 | volumes: 7 | - pg-data:/var/lib/postgresql/data 8 | environment: 9 | POSTGRES_USER: 'user' 10 | POSTGRES_PASSWORD: 'pass' 11 | POSTGRES_DB: 'db' 12 | ports: 13 | - 5432:5432 14 | # mysql: 15 | # container_name: mysql 16 | # image: mysql 17 | # volumes: 18 | # - mysql-data:/var/lib/mysql 19 | # - ./pkg/pwsql/mysql/init.sql:/docker-entrypoint-initdb.d/init.sql 20 | # environment: 21 | # MYSQL_ROOT_PASSWORD: 'pass' 22 | # MYSQL_DATABASE: 'db' 23 | # MYSQL_USER: 'user' 24 | # MYSQL_PASSWORD: 'pass' 25 | # ports: 26 | # - 3306:3306 27 | redis: 28 | container_name: redis 29 | image: redis 30 | volumes: 31 | - redis-data:/data 32 | ports: 33 | - 6379:6379 34 | 35 | zookeeper: 36 | image: confluentinc/cp-zookeeper:latest 37 | environment: 38 | ZOOKEEPER_CLIENT_PORT: 2181 39 | ZOOKEEPER_TICK_TIME: 2000 40 | ports: 41 | - 22181:2181 42 | 43 | kafka: 44 | image: confluentinc/cp-kafka:latest 45 | depends_on: 46 | - zookeeper 47 | ports: 48 | - 29092:29092 49 | environment: 50 | KAFKA_BROKER_ID: 1 51 | KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181 52 | KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092,PLAINTEXT_HOST://kafka:29092 53 | KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT 54 | KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT 55 | KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 56 | 57 | app: 58 | build: . 59 | container_name: app 60 | image: app 61 | environment: 62 | APP_SQL_URL: 'postgres://user:pass@postgres:5432/postgres' 63 | APP_REDIS_URL: 'redis://redis:6379/0' 64 | APP_KAFKA_URL: 'kafka:29092' 65 | APP_ENV: 'dev' 66 | ports: 67 | - 8080:8080 68 | depends_on: 69 | - postgres 70 | - redis 71 | - kafka 72 | command: /dockerize -wait tcp://postgres:5432 -wait tcp://redis:6379 -wait tcp://kafka:29092 /app 73 | 74 | integration: 75 | build: 76 | context: . 77 | dockerfile: integration-test/Dockerfile 78 | container_name: integration 79 | image: integration 80 | depends_on: 81 | - app 82 | command: /dockerize -wait tcp://app:8080 go test -v ./integration-test/... 83 | 84 | volumes: 85 | pg-data: 86 | redis-data: -------------------------------------------------------------------------------- /internal/adapter/message/role/router.go: -------------------------------------------------------------------------------- 1 | package role 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "log" 7 | "strings" 8 | 9 | "github.com/ThreeDotsLabs/watermill/message" 10 | "github.com/jinzhu/copier" 11 | "github.com/program-world-labs/pwlogger" 12 | 13 | "github.com/program-world-labs/DDDGo/internal/application/role" 14 | "github.com/program-world-labs/DDDGo/internal/domain/domainerrors" 15 | "github.com/program-world-labs/DDDGo/internal/domain/event" 16 | ) 17 | 18 | type Routes struct { 19 | e event.TypeMapper 20 | s role.IService 21 | l pwlogger.Interface 22 | } 23 | 24 | func NewRoleRoutes(e event.TypeMapper, r role.IService, l pwlogger.Interface) *Routes { 25 | // Register event 26 | e.Register((*event.RoleCreatedEvent)(nil)) 27 | e.Register((*event.RoleDescriptionChangedEvent)(nil)) 28 | e.Register((*event.RolePermissionUpdatedEvent)(nil)) 29 | 30 | return &Routes{e: e, s: r, l: l} 31 | } 32 | 33 | func (u *Routes) Handler(msg *message.Message) error { 34 | log.Println("RoleRoutes received message", msg.UUID) 35 | 36 | // Transform message to domain event 37 | domainEvent := &event.DomainEvent{} 38 | err := json.Unmarshal(msg.Payload, domainEvent) 39 | 40 | if err != nil { 41 | return err 42 | } 43 | 44 | // Json Unmarshal: To Domain Event Type 45 | eventType, err := u.e.NewInstance(domainEvent.EventType) 46 | if err != nil { 47 | return err 48 | } 49 | 50 | // data map to JSON 51 | jsonData, err := json.Marshal(domainEvent.Data) 52 | if err != nil { 53 | panic(err) 54 | } 55 | 56 | // Json data to domain event 57 | err = json.Unmarshal(jsonData, &eventType) 58 | if err != nil { 59 | panic(err) 60 | } 61 | 62 | switch domainEvent.EventType { 63 | case "RoleCreatedEvent": 64 | err = u.create(msg.Context(), eventType.(*event.RoleCreatedEvent)) 65 | if err != nil { 66 | return err 67 | } 68 | default: 69 | return domainerrors.Wrap(ErrorCodeHandleMessage, err) 70 | } 71 | 72 | return nil 73 | } 74 | 75 | func (u *Routes) create(ctx context.Context, event *event.RoleCreatedEvent) error { 76 | // Transform event data to service input 77 | // info := role.CreatedInput{} 78 | info := role.UpdatedInput{} 79 | if err := copier.Copy(&info, event); err != nil { 80 | return domainerrors.Wrap(ErrorCodeCopyToInput, err) 81 | } 82 | 83 | info.Permissions = strings.Join(event.Permissions, ",") 84 | 85 | // _, err := u.s.CreateRole(ctx, &info) 86 | _, err := u.s.UpdateRole(ctx, &info) 87 | if err != nil { 88 | return err 89 | } 90 | 91 | return nil 92 | } 93 | -------------------------------------------------------------------------------- /tests/role/sql_test.go: -------------------------------------------------------------------------------- 1 | package role_test 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "testing" 7 | 8 | "github.com/DATA-DOG/go-sqlmock" 9 | "github.com/lib/pq" 10 | "github.com/stretchr/testify/require" 11 | 12 | datasourceSQL "github.com/program-world-labs/DDDGo/internal/infra/datasource/sql" 13 | "github.com/program-world-labs/DDDGo/internal/infra/dto" 14 | "github.com/program-world-labs/DDDGo/pkg/pwsql/relation" 15 | ) 16 | 17 | type test struct { 18 | name string 19 | mock func() 20 | res interface{} 21 | err error 22 | } 23 | 24 | func crudSQL(t *testing.T) (*datasourceSQL.CRUDDatasourceImpl, *sql.DB, sqlmock.Sqlmock) { 25 | t.Helper() 26 | 27 | db, mock, err := sqlmock.New() 28 | if err != nil { 29 | t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) 30 | } 31 | 32 | s := relation.NewMock(db) 33 | ds := datasourceSQL.NewCRUDDatasourceImpl(s) 34 | 35 | return ds, db, mock 36 | } 37 | 38 | func TestCreateRole(t *testing.T) { 39 | t.Parallel() 40 | datasource, _, mock := crudSQL(t) 41 | 42 | tests := []test{ 43 | { 44 | name: "create role", 45 | mock: func() { 46 | mock.ExpectPrepare("^INSERT INTO \"Roles\".*") 47 | mock.ExpectExec("^INSERT INTO \"Roles\".*"). 48 | WithArgs(sqlmock.AnyArg(), "test", "test", pq.Array([]string{"test", "test"}), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg()). 49 | WillReturnResult(sqlmock.NewResult(1, 1)) 50 | }, 51 | res: &dto.Role{ 52 | Name: "test", 53 | Description: "test", 54 | Permissions: []string{"test", "test"}, 55 | }, 56 | err: nil, 57 | }, 58 | } 59 | 60 | for _, tc := range tests { 61 | tc := tc 62 | 63 | t.Run(tc.name, func(t *testing.T) { 64 | t.Parallel() 65 | 66 | tc.mock() 67 | 68 | // now we execute our method 69 | role := &dto.Role{ 70 | Name: "test", 71 | Description: "test", 72 | Permissions: []string{"test", "test"}, 73 | } 74 | value, err := datasource.Create(context.Background(), role) 75 | v, ok := value.(*dto.Role) 76 | require.True(t, ok) 77 | r, ok := tc.res.(*dto.Role) 78 | require.True(t, ok) 79 | 80 | require.Equal(t, v.Name, r.Name) 81 | require.Equal(t, v.Description, r.Description) 82 | require.Equal(t, v.Permissions, r.Permissions) 83 | require.ErrorIs(t, err, tc.err) 84 | // we make sure that all expectations were met 85 | if err := mock.ExpectationsWereMet(); err != nil { 86 | t.Errorf("there were unfulfilled expectations: %s", err) 87 | } 88 | }) 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /internal/domain/aggregate/aggregate_base.go: -------------------------------------------------------------------------------- 1 | package aggregate 2 | 3 | import ( 4 | "reflect" 5 | "strings" 6 | 7 | "github.com/program-world-labs/DDDGo/internal/domain/event" 8 | ) 9 | 10 | type Handler interface { 11 | LoadFromHistory(events []event.DomainEvent) error 12 | ApplyEvent(event *event.DomainEvent) error 13 | ApplyEventHelper(aggregate Handler, event *event.DomainEvent, commit bool) error 14 | // HandleCommand(command interface{}) error 15 | UnCommitedEvents() []event.DomainEvent 16 | ClearUnCommitedEvents() 17 | IncrementVersion() 18 | GetID() string 19 | GetTypeName(source interface{}) (reflect.Type, string) 20 | GetVersion() int 21 | } 22 | 23 | type BaseAggregate struct { 24 | // The aggregate ID 25 | ID string 26 | // The aggregate type 27 | Type string 28 | // The aggregate version 29 | Version int 30 | // The aggregate events 31 | Events []event.DomainEvent 32 | } 33 | 34 | func (b *BaseAggregate) UnCommitedEvents() []event.DomainEvent { 35 | return b.Events 36 | } 37 | 38 | func (b *BaseAggregate) ClearUnCommitedEvents() { 39 | b.Events = []event.DomainEvent{} 40 | } 41 | 42 | func (b *BaseAggregate) IncrementVersion() { 43 | b.Version++ 44 | } 45 | 46 | func (b *BaseAggregate) GetVersion() int { 47 | return b.Version 48 | } 49 | 50 | func (b *BaseAggregate) GetID() string { 51 | return b.ID 52 | } 53 | 54 | func (b *BaseAggregate) ApplyEventHelper(aggregate Handler, event *event.DomainEvent, commit bool) error { 55 | // increments the version in event and aggregate 56 | b.IncrementVersion() 57 | 58 | // set the aggregate type 59 | _, aggregateType := b.GetTypeName(aggregate) 60 | b.Type = aggregateType 61 | 62 | // apply the event itself 63 | err := aggregate.ApplyEvent(event) 64 | if err != nil { 65 | return err 66 | } 67 | 68 | // Check if Need to commit to EventStore and EventPublisher 69 | if commit { 70 | // add the event to the list of uncommitted events 71 | event.SetVersion(b.Version) 72 | _, et := b.GetTypeName(event.Data) 73 | event.SetEventType(et) 74 | b.Events = append(b.Events, *event) 75 | } 76 | 77 | return nil 78 | } 79 | 80 | func (b *BaseAggregate) GetTypeName(source interface{}) (reflect.Type, string) { 81 | rawType := reflect.TypeOf(source) 82 | 83 | // source is a pointer, convert to its value 84 | if rawType.Kind() == reflect.Ptr { 85 | rawType = rawType.Elem() 86 | } 87 | 88 | name := rawType.String() 89 | // we only need the name, not the package 90 | // the name follows the format `package.StructName` 91 | parts := strings.Split(name, ".") 92 | 93 | return rawType, parts[len(parts)-1] 94 | } 95 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | linters-settings: 2 | gci: 3 | local-prefixes: github.com/program-world-labs/DDDGo 4 | dupl: 5 | threshold: 100 6 | errorlint: 7 | errorf: true 8 | errcheck: 9 | check-type-assertions: true 10 | check-blank: true 11 | exhaustive: 12 | check-generated: false 13 | default-signifies-exhaustive: false 14 | funlen: 15 | lines: 80 16 | statements: 40 17 | gocognit: 18 | min-complexity: 20 19 | gocyclo: 20 | min-complexity: 20 21 | goconst: 22 | min-len: 2 23 | min-occurrences: 2 24 | cyclop: 25 | enable: true 26 | max-complexity: 20 # 將最大循環複雜度設置為 20 27 | gomnd: 28 | settings: 29 | mnd: 30 | checks: 31 | - argument 32 | - case 33 | - condition 34 | - operation 35 | - return 36 | govet: 37 | check-shadowing: true 38 | misspell: 39 | locale: US 40 | nestif: 41 | min-complexity: 4 42 | nolintlint: 43 | require-explanation: true 44 | require-specific: true 45 | 46 | linters: 47 | disable-all: true 48 | enable: 49 | - asciicheck 50 | - bodyclose 51 | - cyclop 52 | - dogsled 53 | - dupl 54 | - durationcheck 55 | - errcheck 56 | - errorlint 57 | - exhaustive 58 | - exportloopref 59 | - forbidigo 60 | - funlen 61 | - gci 62 | - gochecknoglobals 63 | - gochecknoinits 64 | - gocognit 65 | - goconst 66 | - gocyclo 67 | - godot 68 | - godox 69 | - goerr113 70 | - gofmt 71 | - goimports 72 | - gomnd 73 | - gomodguard 74 | - goprintffuncname 75 | - gosec 76 | - gosimple 77 | - govet 78 | - ineffassign 79 | - makezero 80 | - misspell 81 | - nakedret 82 | - nestif 83 | - nlreturn 84 | - noctx 85 | - nolintlint 86 | - paralleltest 87 | - predeclared 88 | - revive 89 | - rowserrcheck 90 | - sqlclosecheck 91 | - staticcheck 92 | - stylecheck 93 | - tparallel 94 | - thelper 95 | - typecheck 96 | - unconvert 97 | - unparam 98 | - unused 99 | - wsl 100 | - whitespace 101 | 102 | # disable: 103 | # - exhaustivestruct 104 | # - ifshort 105 | # - goheader 106 | # - prealloc 107 | # - testpackage 108 | # - wrapcheck 109 | 110 | issues: 111 | exclude-rules: 112 | - path: integration-test 113 | linters: 114 | - paralleltest 115 | - godot 116 | - path: internal/controller/http 117 | linters: 118 | - godot 119 | 120 | run: 121 | skip-dirs: 122 | - docs 123 | -------------------------------------------------------------------------------- /internal/adapter/http/v1/router.go: -------------------------------------------------------------------------------- 1 | // Package v1 implements routing paths. Each services in own file. 2 | package v1 3 | 4 | import ( 5 | "net/http" 6 | 7 | "github.com/gin-contrib/pprof" 8 | "github.com/gin-gonic/gin" 9 | "github.com/program-world-labs/pwlogger" 10 | "github.com/prometheus/client_golang/prometheus/promhttp" 11 | swaggerFiles "github.com/swaggo/files" 12 | ginSwagger "github.com/swaggo/gin-swagger" 13 | "go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin" 14 | 15 | "github.com/program-world-labs/DDDGo/config" 16 | "github.com/program-world-labs/DDDGo/docs" 17 | "github.com/program-world-labs/DDDGo/internal/adapter/http/v1/currency" 18 | "github.com/program-world-labs/DDDGo/internal/adapter/http/v1/group" 19 | "github.com/program-world-labs/DDDGo/internal/adapter/http/v1/role" 20 | "github.com/program-world-labs/DDDGo/internal/adapter/http/v1/user" 21 | "github.com/program-world-labs/DDDGo/internal/adapter/http/v1/wallet" 22 | "github.com/program-world-labs/DDDGo/internal/application" 23 | ) 24 | 25 | // type Services struct { 26 | // User application_user.IService 27 | // Role application_role.IService 28 | // } 29 | 30 | // NewRouter -. 31 | // Swagger spec: 32 | // @title AI Service API 33 | // @description Using AI to do something. 34 | // @version 1.0 35 | // @host localhost:8080 36 | // @BasePath /v1 37 | // Swagger base path. 38 | func NewRouter(l pwlogger.Interface, s application.Services, cfg *config.Config) *gin.Engine { 39 | handler := gin.New() 40 | // Init 41 | switch cfg.Env.EnvName { 42 | case "dev": 43 | gin.SetMode(gin.DebugMode) 44 | // Register pprof handlers 45 | pprof.Register(handler) 46 | case "test": 47 | gin.SetMode(gin.TestMode) 48 | case "prod": 49 | gin.SetMode(gin.ReleaseMode) 50 | } 51 | // Options 52 | handler.Use(gin.Logger()) 53 | handler.Use(gin.Recovery()) 54 | handler.Use(otelgin.Middleware(cfg.App.Name)) 55 | 56 | // Swagger 57 | docs.SwaggerInfo.Version = cfg.App.Version 58 | docs.SwaggerInfo.Title = cfg.App.Name 59 | docs.SwaggerInfo.Host = cfg.Swagger.Host 60 | 61 | swaggerHandler := ginSwagger.WrapHandler(swaggerFiles.Handler) 62 | handler.GET("/swagger/*any", swaggerHandler) 63 | 64 | // K8s probe 65 | handler.GET("/healthz", func(c *gin.Context) { c.Status(http.StatusOK) }) 66 | 67 | // Prometheus metrics 68 | handler.GET("/metrics", gin.WrapH(promhttp.Handler())) 69 | 70 | // Routers 71 | h := handler.Group("/v1") 72 | { 73 | user.NewUserRoutes(h, s.User, l) 74 | role.NewRoleRoutes(h, s.Role, l) 75 | group.NewGroupRoutes(h, s.Group, l) 76 | wallet.NewWalletRoutes(h, s.Wallet, l) 77 | currency.NewCurrencyRoutes(h, s.Currency, l) 78 | } 79 | 80 | return handler 81 | } 82 | -------------------------------------------------------------------------------- /pkg/message/kafka_tracer.go: -------------------------------------------------------------------------------- 1 | package message 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/ThreeDotsLabs/watermill/message" 7 | "go.opentelemetry.io/otel" 8 | "go.opentelemetry.io/otel/propagation" 9 | semconv "go.opentelemetry.io/otel/semconv/v1.4.0" 10 | "go.opentelemetry.io/otel/trace" 11 | 12 | "github.com/program-world-labs/DDDGo/internal/domain/domainerrors" 13 | ) 14 | 15 | type KafkaTracer struct { 16 | tracer trace.Tracer 17 | } 18 | 19 | func NewKafkaTracer(groupID string) *KafkaTracer { 20 | return &KafkaTracer{ 21 | tracer: otel.Tracer(groupID), 22 | } 23 | } 24 | 25 | // Trace defines a middleware that will add tracing. 26 | func (t *KafkaTracer) Trace(options ...Option) message.HandlerMiddleware { 27 | return func(h message.HandlerFunc) message.HandlerFunc { 28 | return t.TraceHandler(h, options...) 29 | } 30 | } 31 | 32 | // TraceHandler decorates a watermill HandlerFunc to add tracing when a message is received. 33 | func (t *KafkaTracer) TraceHandler(h message.HandlerFunc, options ...Option) message.HandlerFunc { 34 | tracer := otel.Tracer(domainerrors.GruopID) 35 | config := &config{} 36 | 37 | for _, opt := range options { 38 | opt(config) 39 | } 40 | 41 | spanOptions := []trace.SpanStartOption{ 42 | trace.WithSpanKind(trace.SpanKindConsumer), 43 | trace.WithAttributes(config.spanAttributes...), 44 | } 45 | 46 | return func(msg *message.Message) ([]*message.Message, error) { 47 | // Convert message.Metadata to http.Header 48 | header := make(http.Header) 49 | for k, v := range msg.Metadata { 50 | header.Set(k, v) 51 | } 52 | 53 | // Extract SpanContext from the header 54 | propagator := propagation.TraceContext{} 55 | ctx := propagator.Extract(msg.Context(), propagation.HeaderCarrier(header)) 56 | 57 | spanName := message.HandlerNameFromCtx(ctx) 58 | ctx, span := tracer.Start(ctx, spanName, spanOptions...) 59 | span.SetAttributes( 60 | semconv.MessagingDestinationKindTopic, 61 | semconv.MessagingDestinationKey.String(message.SubscribeTopicFromCtx(ctx)), 62 | semconv.MessagingOperationReceive, 63 | ) 64 | msg.SetContext(ctx) 65 | 66 | events, err := h(msg) 67 | 68 | if err != nil { 69 | span.RecordError(err) 70 | } 71 | 72 | span.End() 73 | 74 | return events, err 75 | } 76 | } 77 | 78 | // TraceNoPublishHandler decorates a watermill NoPublishHandlerFunc to add tracing when a message is received. 79 | func (t *KafkaTracer) TraceNoPublishHandler(h message.NoPublishHandlerFunc, options ...Option) message.NoPublishHandlerFunc { 80 | decoratedHandler := t.TraceHandler(func(msg *message.Message) ([]*message.Message, error) { 81 | return nil, h(msg) 82 | }, options...) 83 | 84 | return func(msg *message.Message) error { 85 | _, err := decoratedHandler(msg) 86 | 87 | return err 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /internal/infra/datasource/interface.go: -------------------------------------------------------------------------------- 1 | package datasource 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/program-world-labs/DDDGo/internal/domain" 8 | "github.com/program-world-labs/DDDGo/internal/infra/dto" 9 | ) 10 | 11 | type IDataSource interface { 12 | Create(context.Context, dto.IRepoEntity) (dto.IRepoEntity, error) 13 | Delete(context.Context, dto.IRepoEntity) (dto.IRepoEntity, error) 14 | Update(context.Context, dto.IRepoEntity) (dto.IRepoEntity, error) 15 | UpdateWithFields(context.Context, dto.IRepoEntity, []string) (dto.IRepoEntity, error) 16 | GetByID(context.Context, dto.IRepoEntity) (dto.IRepoEntity, error) 17 | GetAll(context.Context, *domain.SearchQuery, dto.IRepoEntity) (*dto.List, error) 18 | 19 | CreateTx(context.Context, dto.IRepoEntity, domain.ITransactionEvent) (dto.IRepoEntity, error) 20 | DeleteTx(context.Context, dto.IRepoEntity, domain.ITransactionEvent) (dto.IRepoEntity, error) 21 | UpdateTx(context.Context, dto.IRepoEntity, domain.ITransactionEvent) (dto.IRepoEntity, error) 22 | UpdateWithFieldsTx(context.Context, dto.IRepoEntity, []string, domain.ITransactionEvent) (dto.IRepoEntity, error) 23 | } 24 | 25 | type IAssociationDataSource interface { 26 | AppendAssociation(context.Context, string, dto.IRepoEntity, []dto.IRepoEntity) error 27 | ReplaceAssociation(context.Context, string, dto.IRepoEntity, []dto.IRepoEntity) error 28 | RemoveAssociation(context.Context, string, dto.IRepoEntity, []dto.IRepoEntity) error 29 | GetAssociationCount(context.Context, string, dto.IRepoEntity) (int64, error) 30 | 31 | AppendAssociationTx(context.Context, string, dto.IRepoEntity, []dto.IRepoEntity, domain.ITransactionEvent) error 32 | ReplaceAssociationTx(context.Context, string, dto.IRepoEntity, []dto.IRepoEntity, domain.ITransactionEvent) error 33 | RemoveAssociationTx(context.Context, string, dto.IRepoEntity, []dto.IRepoEntity, domain.ITransactionEvent) error 34 | } 35 | 36 | type IRelationDataSource interface { 37 | IDataSource 38 | IAssociationDataSource 39 | } 40 | 41 | type ICacheDataSource interface { 42 | Get(ctx context.Context, e dto.IRepoEntity, ttl ...time.Duration) (dto.IRepoEntity, error) 43 | Set(ctx context.Context, e dto.IRepoEntity, ttl ...time.Duration) (dto.IRepoEntity, error) 44 | Delete(ctx context.Context, e dto.IRepoEntity) error 45 | DeleteWithKey(ctx context.Context, key string) error 46 | GetListKeys(ctx context.Context, e dto.IRepoEntity) ([]string, error) 47 | GetListItem(ctx context.Context, e dto.IRepoEntity, sq *domain.SearchQuery, ttl ...time.Duration) (*dto.List, error) 48 | DeleteListKeys(ctx context.Context, e dto.IRepoEntity) error 49 | SetListItem(ctx context.Context, e []dto.IRepoEntity, sq *domain.SearchQuery, count int64, ttl ...time.Duration) error 50 | } 51 | 52 | type ITransactionRun interface { 53 | RunTransaction(context.Context, domain.TransactionEventFunc) error 54 | } 55 | -------------------------------------------------------------------------------- /internal/domain/search_query.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | type Page struct { 9 | Limit int `json:"limit" form:"limit" validate:"min=1,max=1000"` 10 | Offset int `json:"offset" form:"offset" validate:"min=0"` 11 | } 12 | 13 | // Filter -. 14 | type Filter struct { 15 | FilterField string `json:"filter_field" form:"filter_field" validate:"required,ne="` 16 | Operator string `json:"operate" form:"operator" validate:"required,oneof=== != > >= < <= in not-in like between"` 17 | Value interface{} `json:"value" form:"value" validate:"required"` 18 | } 19 | 20 | // Sort -. 21 | type Order struct { 22 | OrderField string `json:"order_field" form:"order_field" validate:"required,ne="` 23 | Dir string `json:"dir" form:"dir" validate:"omitempty,oneof=asc desc"` 24 | } 25 | 26 | type SearchQuery struct { 27 | Page Page `json:"page" binding:"required" form:"page" validate:"required"` 28 | Filters []Filter `json:"filters" form:"filters"` 29 | Orders []Order `json:"orders" form:"orders"` 30 | } 31 | 32 | func (sq *SearchQuery) GetWhere() string { 33 | var conditions []string 34 | 35 | for _, filter := range sq.Filters { 36 | switch filter.Operator { 37 | case "==": 38 | conditions = append(conditions, fmt.Sprintf("%s = ?", filter.FilterField)) 39 | case "!=": 40 | conditions = append(conditions, fmt.Sprintf("%s != ?", filter.FilterField)) 41 | case ">": 42 | conditions = append(conditions, fmt.Sprintf("%s > ?", filter.FilterField)) 43 | case ">=": 44 | conditions = append(conditions, fmt.Sprintf("%s >= ?", filter.FilterField)) 45 | case "<": 46 | conditions = append(conditions, fmt.Sprintf("%s < ?", filter.FilterField)) 47 | case "<=": 48 | conditions = append(conditions, fmt.Sprintf("%s <= ?", filter.FilterField)) 49 | case "in": 50 | conditions = append(conditions, fmt.Sprintf("%s IN (?)", filter.FilterField)) 51 | case "not-in": 52 | conditions = append(conditions, fmt.Sprintf("%s NOT IN (?)", filter.FilterField)) 53 | case "like": 54 | conditions = append(conditions, fmt.Sprintf("%s LIKE ?", filter.FilterField)) 55 | case "between": 56 | conditions = append(conditions, fmt.Sprintf("%s BETWEEN ? AND ?", filter.FilterField)) 57 | } 58 | } 59 | 60 | return strings.Join(conditions, " AND ") 61 | } 62 | 63 | func (sq *SearchQuery) GetArgs() []interface{} { 64 | var args []interface{} 65 | for _, filter := range sq.Filters { 66 | args = append(args, filter.Value) 67 | } 68 | 69 | return args 70 | } 71 | 72 | func (sq *SearchQuery) GetOrder() string { 73 | var orders []string 74 | for _, sort := range sq.Orders { 75 | orders = append(orders, fmt.Sprintf("%s %s", sort.OrderField, sort.Dir)) 76 | } 77 | 78 | return strings.Join(orders, ", ") 79 | } 80 | 81 | func (sq *SearchQuery) GetKey() string { 82 | return fmt.Sprintf("%v-%v-%v", sq.Page, sq.Filters, sq.Orders) 83 | } 84 | -------------------------------------------------------------------------------- /tests/mocks/EventStore_mock.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: internal/domain/event/event_store.go 3 | 4 | // Package mocks is a generated GoMock package. 5 | package mocks 6 | 7 | import ( 8 | context "context" 9 | reflect "reflect" 10 | 11 | gomock "github.com/golang/mock/gomock" 12 | event "github.com/program-world-labs/DDDGo/internal/domain/event" 13 | ) 14 | 15 | // MockEventStore is a mock of EventStore interface. 16 | type MockEventStore struct { 17 | ctrl *gomock.Controller 18 | recorder *MockEventStoreMockRecorder 19 | } 20 | 21 | // MockEventStoreMockRecorder is the mock recorder for MockEventStore. 22 | type MockEventStoreMockRecorder struct { 23 | mock *MockEventStore 24 | } 25 | 26 | // NewMockEventStore creates a new mock instance. 27 | func NewMockEventStore(ctrl *gomock.Controller) *MockEventStore { 28 | mock := &MockEventStore{ctrl: ctrl} 29 | mock.recorder = &MockEventStoreMockRecorder{mock} 30 | return mock 31 | } 32 | 33 | // EXPECT returns an object that allows the caller to indicate expected use. 34 | func (m *MockEventStore) EXPECT() *MockEventStoreMockRecorder { 35 | return m.recorder 36 | } 37 | 38 | // Load mocks base method. 39 | func (m *MockEventStore) Load(ctx context.Context, aggregateID string, version int) ([]event.DomainEvent, error) { 40 | m.ctrl.T.Helper() 41 | ret := m.ctrl.Call(m, "Load", ctx, aggregateID, version) 42 | ret0, _ := ret[0].([]event.DomainEvent) 43 | ret1, _ := ret[1].(error) 44 | return ret0, ret1 45 | } 46 | 47 | // Load indicates an expected call of Load. 48 | func (mr *MockEventStoreMockRecorder) Load(ctx, aggregateID, version interface{}) *gomock.Call { 49 | mr.mock.ctrl.T.Helper() 50 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Load", reflect.TypeOf((*MockEventStore)(nil).Load), ctx, aggregateID, version) 51 | } 52 | 53 | // SafeStore mocks base method. 54 | func (m *MockEventStore) SafeStore(ctx context.Context, events []event.DomainEvent, expectedVersion int) error { 55 | m.ctrl.T.Helper() 56 | ret := m.ctrl.Call(m, "SafeStore", ctx, events, expectedVersion) 57 | ret0, _ := ret[0].(error) 58 | return ret0 59 | } 60 | 61 | // SafeStore indicates an expected call of SafeStore. 62 | func (mr *MockEventStoreMockRecorder) SafeStore(ctx, events, expectedVersion interface{}) *gomock.Call { 63 | mr.mock.ctrl.T.Helper() 64 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SafeStore", reflect.TypeOf((*MockEventStore)(nil).SafeStore), ctx, events, expectedVersion) 65 | } 66 | 67 | // Store mocks base method. 68 | func (m *MockEventStore) Store(ctx context.Context, events []event.DomainEvent, version int) error { 69 | m.ctrl.T.Helper() 70 | ret := m.ctrl.Call(m, "Store", ctx, events, version) 71 | ret0, _ := ret[0].(error) 72 | return ret0 73 | } 74 | 75 | // Store indicates an expected call of Store. 76 | func (mr *MockEventStoreMockRecorder) Store(ctx, events, version interface{}) *gomock.Call { 77 | mr.mock.ctrl.T.Helper() 78 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Store", reflect.TypeOf((*MockEventStore)(nil).Store), ctx, events, version) 79 | } 80 | -------------------------------------------------------------------------------- /internal/domain/entity/user.go: -------------------------------------------------------------------------------- 1 | package entity 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/program-world-labs/DDDGo/internal/domain" 7 | "github.com/program-world-labs/DDDGo/internal/domain/aggregate" 8 | "github.com/program-world-labs/DDDGo/internal/domain/event" 9 | ) 10 | 11 | var _ domain.IEntity = (*User)(nil) 12 | var _ aggregate.Handler = (*User)(nil) 13 | 14 | // User -. 15 | type User struct { 16 | aggregate.BaseAggregate 17 | ID string `json:"id"` 18 | Username string `json:"username"` 19 | Password string `json:"password"` 20 | EMail string `json:"email"` 21 | DisplayName string `json:"display_name"` 22 | Avatar string `json:"avatar"` 23 | Roles []*Role `json:"roles" gorm:"many2many:user_roles;"` 24 | Group *Group `json:"group" gorm:"foreignKey:GroupId"` 25 | CreatedAt time.Time `json:"created_at"` 26 | UpdatedAt time.Time `json:"updated_at"` 27 | DeletedAt time.Time `json:"deleted_at"` 28 | } 29 | 30 | func NewUser(uid string) (*User, error) { 31 | return &User{ 32 | ID: uid, 33 | }, nil 34 | } 35 | 36 | func NewUserFromHistory(events []event.DomainEvent) (*User, error) { 37 | user := &User{} 38 | err := user.LoadFromHistory(events) 39 | 40 | if err != nil { 41 | return nil, err 42 | } 43 | 44 | return user, nil 45 | } 46 | 47 | // GetID -. 48 | func (u *User) GetID() string { 49 | return u.ID 50 | } 51 | 52 | // SetID -. 53 | func (u *User) SetID(id string) { 54 | u.ID = id 55 | } 56 | 57 | // LoadFromHistory -. 58 | func (u *User) LoadFromHistory(events []event.DomainEvent) error { 59 | for i := range events { 60 | err := u.ApplyEvent(&events[i]) 61 | if err != nil { 62 | return err 63 | } 64 | } 65 | 66 | return nil 67 | } 68 | 69 | // ApplyEvent -. 70 | func (u *User) ApplyEvent(domainEvent *event.DomainEvent) error { 71 | switch domainEvent.Data.(type) { 72 | case *event.UserCreatedEvent: 73 | return u.applyCreated(domainEvent) 74 | case *event.UserPasswordChangedEvent: 75 | return u.applyPasswordChanged(domainEvent) 76 | case *event.UserEmailChangedEvent: 77 | return u.applyEmailChanged(domainEvent) 78 | default: 79 | return nil 80 | } 81 | } 82 | 83 | func (u *User) applyCreated(domainEvent *event.DomainEvent) error { 84 | eventData, ok := domainEvent.Data.(*event.UserCreatedEvent) 85 | if !ok { 86 | return ErrInvalidEventData 87 | } 88 | 89 | u.Username = eventData.UserName 90 | u.Password = eventData.Password 91 | u.EMail = eventData.EMail 92 | 93 | return nil 94 | } 95 | 96 | func (u *User) applyPasswordChanged(domainEvent *event.DomainEvent) error { 97 | eventData, ok := domainEvent.Data.(*event.UserPasswordChangedEvent) 98 | if !ok { 99 | return ErrInvalidEventData 100 | } 101 | 102 | u.Password = eventData.Password 103 | 104 | return nil 105 | } 106 | 107 | func (u *User) applyEmailChanged(domainEvent *event.DomainEvent) error { 108 | eventData, ok := domainEvent.Data.(*event.UserEmailChangedEvent) 109 | if !ok { 110 | return ErrInvalidEventData 111 | } 112 | 113 | u.EMail = eventData.EMail 114 | 115 | return nil 116 | } 117 | -------------------------------------------------------------------------------- /internal/adapter/message/router.go: -------------------------------------------------------------------------------- 1 | package message 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/ThreeDotsLabs/watermill" 8 | "github.com/ThreeDotsLabs/watermill/message" 9 | "github.com/ThreeDotsLabs/watermill/message/router/middleware" 10 | "github.com/ThreeDotsLabs/watermill/message/router/plugin" 11 | "github.com/program-world-labs/pwlogger" 12 | "go.opentelemetry.io/otel/attribute" 13 | 14 | "github.com/program-world-labs/DDDGo/internal/adapter/message/role" 15 | "github.com/program-world-labs/DDDGo/internal/adapter/message/user" 16 | "github.com/program-world-labs/DDDGo/internal/application" 17 | "github.com/program-world-labs/DDDGo/internal/domain/event" 18 | pkg_message "github.com/program-world-labs/DDDGo/pkg/message" 19 | ) 20 | 21 | // config represents the configuration options available for subscriber 22 | // middlewares and publisher decorators. 23 | type config struct { 24 | spanAttributes []attribute.KeyValue 25 | } 26 | 27 | // Option provides a convenience wrapper for simple options that can be 28 | // represented as functions. 29 | type Option func(*config) 30 | 31 | // WithSpanAttributes includes the given attributes to the generated Spans. 32 | func WithSpanAttributes(attributes ...attribute.KeyValue) Option { 33 | return func(c *config) { 34 | c.spanAttributes = attributes 35 | } 36 | } 37 | 38 | type Router struct { 39 | Handler *pkg_message.KafkaMessage 40 | EventMapper *event.TypeMapper 41 | S application.Services 42 | L pwlogger.Interface 43 | } 44 | 45 | func NewRouter(handler *pkg_message.KafkaMessage, mapper *event.TypeMapper, s application.Services, l pwlogger.Interface) (*message.Router, error) { 46 | // SignalsHandler will gracefully shutdown Router when SIGTERM is received. 47 | // You can also close the router by just calling `r.Close()`. 48 | handler.Router.AddPlugin(plugin.SignalsHandler) 49 | 50 | // Router level middleware are executed for every message sent to the router 51 | handler.Router.AddMiddleware( 52 | // CorrelationID will copy the correlation id from the incoming message's metadata to the produced messages 53 | middleware.CorrelationID, 54 | 55 | // The handler function is retried if it returns an error. 56 | // After MaxRetries, the message is Nacked and it's up to the PubSub to resend it. 57 | middleware.Retry{ 58 | MaxRetries: 3, 59 | InitialInterval: time.Millisecond * 100, 60 | Logger: watermill.NewStdLogger(false, false), 61 | }.Middleware, 62 | 63 | // Recoverer handles panics from handlers. 64 | // In this case, it passes them as errors to the Retry middleware. 65 | middleware.Recoverer, 66 | ) 67 | 68 | // User routes 69 | User := user.NewUserRoutes(*mapper, s.User, l) 70 | Role := role.NewRoleRoutes(*mapper, s.Role, l) 71 | 72 | // Add event handlers 73 | handler.Router.AddNoPublisherHandler("User.Handler", "User", handler.Subscriber, User.Handler) 74 | handler.Router.AddNoPublisherHandler("Role.Handler", "Role", handler.Subscriber, Role.Handler) 75 | 76 | // Now that all handlers are registered, we're running the Router. 77 | // Run is blocking while the router is running. 78 | ctx := context.Background() 79 | if err := handler.Router.Run(ctx); err != nil { 80 | panic(err) 81 | } 82 | 83 | return handler.Router, nil 84 | } 85 | -------------------------------------------------------------------------------- /internal/domain/event/domain_event.go: -------------------------------------------------------------------------------- 1 | package event 2 | 3 | import ( 4 | "reflect" 5 | "strings" 6 | "time" 7 | 8 | "github.com/gofrs/uuid" 9 | ) 10 | 11 | type Event interface { 12 | GetEventType() string 13 | GetData() interface{} 14 | CreatedAt() time.Time 15 | 16 | GetAggregateType() string 17 | GetAggregateID() string 18 | GetVersion() int 19 | 20 | Serialize() (string, error) 21 | Deserialize(string) (interface{}, error) 22 | } 23 | 24 | type DomainEvent struct { 25 | ID uuid.UUID // 事件唯一ID 26 | EventType string // 事件的Type名稱,例如:AccountCreatedEvent 27 | Data interface{} // 事件資料,例如&AccountCreatedEvent{} 28 | CreatedAt time.Time 29 | 30 | AggregateType string // 事件所屬的Aggregate Type,例如AccountCreatedEvent屬於Account 31 | AggregateID string // aggregate儲存在資料庫,相對應的ID 32 | Version int // aggregate的版本 33 | } 34 | 35 | func NewDomainEvent(aggregateID, aggregateType string, version int, data interface{}) *DomainEvent { 36 | _, eventType := GetTypeName(data) 37 | 38 | uid, err := uuid.NewV4() 39 | if err != nil { 40 | return nil 41 | } 42 | 43 | e := &DomainEvent{ 44 | ID: uid, 45 | EventType: eventType, 46 | Data: data, 47 | CreatedAt: time.Now(), 48 | AggregateType: aggregateType, 49 | AggregateID: aggregateID, 50 | Version: version, 51 | } 52 | 53 | return e 54 | } 55 | 56 | func (e *DomainEvent) GetID() uuid.UUID { 57 | return e.ID 58 | } 59 | 60 | // EventType implements the EventType method of the Event interface. 61 | func (e *DomainEvent) GetEventType() string { 62 | return e.EventType 63 | } 64 | func (e *DomainEvent) SetEventType(et string) { 65 | e.EventType = et 66 | } 67 | 68 | // Data implements the Data method of the Event interface. 69 | func (e *DomainEvent) GetData() interface{} { 70 | return e.Data 71 | } 72 | 73 | // Timestamp implements the Timestamp method of the Event interface. 74 | func (e *DomainEvent) SetTimestamp() time.Time { 75 | return e.CreatedAt 76 | } 77 | 78 | // AggregateType implements the AggregateType method of the Event interface. 79 | func (e *DomainEvent) GetAggregateType() string { 80 | return e.AggregateType 81 | } 82 | 83 | // AggregateID implements the AggregateID method of the Event interface. 84 | func (e *DomainEvent) GetAggregateID() string { 85 | return e.AggregateID 86 | } 87 | 88 | // Version implements the Version method of the Event interface. 89 | func (e *DomainEvent) GetVersion() int { 90 | return e.Version 91 | } 92 | 93 | func (e *DomainEvent) SetVersion(v int) { 94 | e.Version = v 95 | } 96 | 97 | // Metadata implements the Metadata method of the Event interface. 98 | // func (e DomainEvent) Metadata() map[string]interface{} { 99 | // return e.metadata 100 | // } 101 | 102 | // GetTypeName of given struct. 103 | func GetTypeName(source interface{}) (reflect.Type, string) { 104 | rawType := reflect.TypeOf(source) 105 | 106 | // source is a pointer, convert to its value 107 | if rawType.Kind() == reflect.Ptr { 108 | rawType = rawType.Elem() 109 | } 110 | 111 | name := rawType.String() 112 | // we need to extract only the name without the package 113 | // name currently follows the format `package.StructName` 114 | parts := strings.Split(name, ".") 115 | 116 | return rawType, parts[1] 117 | } 118 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: pull_request 3 | 4 | jobs: 5 | golangci-lint: 6 | name: runner / golangci-lint 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Check out code into the Go module directory 10 | uses: actions/checkout@v3 11 | - name: golangci-lint 12 | uses: reviewdog/action-golangci-lint@v2 13 | with: 14 | golangci_lint_flags: "--config=.golangci.yml" 15 | 16 | yamllint: 17 | name: runner / yamllint 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@v3 21 | - uses: reviewdog/action-yamllint@v1 22 | with: 23 | fail_on_error: true 24 | reporter: github-pr-review 25 | yamllint_flags: '-d "{extends: default, rules: {truthy: disable}}" .' 26 | 27 | hadolint: 28 | name: runner / hadolint 29 | runs-on: ubuntu-latest 30 | steps: 31 | - uses: actions/checkout@v3 32 | - uses: reviewdog/action-hadolint@v1 33 | with: 34 | fail_on_error: true 35 | reporter: github-pr-review 36 | 37 | check-dependencies: 38 | name: runner / check-dependencies 39 | runs-on: ubuntu-latest 40 | steps: 41 | - uses: actions/checkout@v3 42 | - uses: actions/setup-go@v4 43 | - name: WriteGoList 44 | run: go list -json -m all > go.list 45 | - name: Nancy 46 | uses: sonatype-nexus-community/nancy-github-action@main 47 | continue-on-error: true 48 | 49 | tests: 50 | name: runner / tests 51 | runs-on: ubuntu-latest 52 | steps: 53 | - uses: actions/checkout@v3 54 | - uses: actions/setup-go@v4 55 | with: 56 | go-version: ">=1.18.0" 57 | - name: Install dependencies 58 | run: go mod download 59 | 60 | - name: Unit Tests 61 | run: "go test \ 62 | -v \ 63 | -race \ 64 | -covermode atomic \ 65 | -coverprofile=coverage.txt \ 66 | ./tests/..." 67 | - name: Zip files 68 | run: zip -j tests/report/report.zip tests/report/*.json 69 | - name: Upload report to Jira Zephyr 70 | uses: program-world-labs/jira-zephyr-action@v1 71 | with: 72 | project_key: ${{ secrets.JIRA_ZEPHYR_PROJECT_KEY }} 73 | format: "cucumber" 74 | auth: ${{ secrets.JIRA_ZEPHYR_TOKEN }} 75 | file_path: "tests/report/report.zip" 76 | auto_create_test_cases: "true" 77 | test_cycle: '{"name": "Auto Test Regression", "description": "Regression test cycle 1 to ensure no breaking changes", "jiraProjectVersion": 10000}' 78 | - name: Generate coverage report 79 | run: | 80 | go get github.com/boumenot/gocover-cobertura 81 | go run github.com/boumenot/gocover-cobertura < coverage.txt > coverage.xml 82 | - name: Upload coverage to Codecov 83 | uses: codecov/codecov-action@v3 84 | with: 85 | token: ${{ secrets.CODECOV_TOKEN }} 86 | env_vars: GO_VERSION 87 | fail_ci_if_error: true 88 | files: coverage.xml 89 | flags: unittest 90 | name: codecov-ddd 91 | verbose: true 92 | 93 | # - name: Integration tests 94 | # run: "docker-compose up \ 95 | # --build \ 96 | # --abort-on-container-exit \ 97 | # --exit-code-from integration" 98 | -------------------------------------------------------------------------------- /internal/infra/dto/currency.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | import ( 4 | "encoding/json" 5 | "reflect" 6 | "time" 7 | 8 | "github.com/jinzhu/copier" 9 | "github.com/mitchellh/mapstructure" 10 | "gorm.io/gorm" 11 | 12 | "github.com/program-world-labs/DDDGo/internal/domain" 13 | "github.com/program-world-labs/DDDGo/internal/domain/domainerrors" 14 | "github.com/program-world-labs/DDDGo/internal/domain/entity" 15 | ) 16 | 17 | var _ IRepoEntity = (*Currency)(nil) 18 | 19 | type Currency struct { 20 | ID string `json:"id" gorm:"primary_key"` 21 | Name string `json:"name"` 22 | Symbol string `json:"symbol"` 23 | WalletBalances []WalletBalance 24 | CreatedAt time.Time `json:"created_at" mapstructure:"created_at" gorm:"column:created_at"` 25 | UpdatedAt time.Time `json:"updated_at" mapstructure:"updated_at" gorm:"column:updated_at"` 26 | DeletedAt *gorm.DeletedAt `json:"deleted_at" mapstructure:"deleted_at" gorm:"index;column:deleted_at"` 27 | } 28 | 29 | func (a *Currency) TableName() string { 30 | return "Currency" 31 | } 32 | 33 | func (a *Currency) Transform(i domain.IEntity) (IRepoEntity, error) { 34 | if err := copier.Copy(a, i); err != nil { 35 | return nil, domainerrors.Wrap(ErrorCodeTransform, err) 36 | } 37 | 38 | return a, nil 39 | } 40 | 41 | func (a *Currency) BackToDomain() (domain.IEntity, error) { 42 | i := &entity.Currency{} 43 | if err := copier.Copy(i, a); err != nil { 44 | return nil, domainerrors.Wrap(ErrorCodeBackToDomain, err) 45 | } 46 | 47 | if a.DeletedAt != nil { 48 | i.DeletedAt = a.DeletedAt.Time 49 | } 50 | 51 | return i, nil 52 | } 53 | 54 | func (a *Currency) BeforeCreate() (err error) { 55 | a.ID, err = generateID() 56 | a.UpdatedAt = time.Now() 57 | a.CreatedAt = time.Now() 58 | a.DeletedAt = nil 59 | 60 | return 61 | } 62 | 63 | func (a *Currency) BeforeUpdate() (err error) { 64 | a.UpdatedAt = time.Now() 65 | 66 | return 67 | } 68 | 69 | func (a *Currency) GetID() string { 70 | return a.ID 71 | } 72 | 73 | func (a *Currency) SetID(id string) { 74 | a.ID = id 75 | } 76 | 77 | func (a *Currency) ToJSON() (string, error) { 78 | jsonData, err := json.Marshal(a) 79 | if err != nil { 80 | return "", domainerrors.Wrap(ErrorCodeToJSON, err) 81 | } 82 | 83 | return string(jsonData), nil 84 | } 85 | 86 | func (a *Currency) UnmarshalJSON(data []byte) error { 87 | type Alias Currency 88 | 89 | aux := &struct { 90 | *Alias 91 | }{ 92 | Alias: (*Alias)(a), 93 | } 94 | 95 | if err := json.Unmarshal(data, &aux); err != nil { 96 | return domainerrors.Wrap(ErrorCodeDecodeJSON, err) 97 | } 98 | 99 | return nil 100 | } 101 | 102 | func (a *Currency) ParseMap(data map[string]interface{}) (IRepoEntity, error) { 103 | err := ParseDateString(data) 104 | if err != nil { 105 | return nil, domainerrors.Wrap(ErrorCodeParseMap, err) 106 | } 107 | 108 | var info *Currency 109 | 110 | err = mapstructure.Decode(data, &info) 111 | 112 | if err != nil { 113 | return nil, domainerrors.Wrap(ErrorCodeParseMap, err) 114 | } 115 | 116 | return info, nil 117 | } 118 | 119 | func (a *Currency) GetPreloads() []string { 120 | return []string{} 121 | } 122 | 123 | func (a *Currency) GetListType() interface{} { 124 | entityType := reflect.TypeOf(Role{}) 125 | sliceType := reflect.SliceOf(entityType) 126 | sliceValue := reflect.MakeSlice(sliceType, 0, 0) 127 | 128 | return sliceValue.Interface() 129 | } 130 | -------------------------------------------------------------------------------- /tests/role/delete_test.go: -------------------------------------------------------------------------------- 1 | package role_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/cucumber/godog" 8 | gomock "github.com/golang/mock/gomock" 9 | 10 | application_role "github.com/program-world-labs/DDDGo/internal/application/role" 11 | mock_repo "github.com/program-world-labs/DDDGo/tests/mocks" 12 | mocks "github.com/program-world-labs/DDDGo/tests/mocks/role" 13 | mocks_user "github.com/program-world-labs/DDDGo/tests/mocks/user" 14 | ) 15 | 16 | type ServiceDeleteTest struct { 17 | t *testing.T 18 | mockCtrl *gomock.Controller 19 | 20 | input *application_role.DeletedInput 21 | expect *application_role.Output 22 | repoMock *mocks.MockRoleRepository 23 | userRepoMock *mocks_user.MockUserRepository 24 | transRepoMock *mock_repo.MockITransactionRepo 25 | // service *application_role.ServiceImpl 26 | } 27 | 28 | func (st *ServiceDeleteTest) reset() { 29 | st.input = nil 30 | st.expect = nil 31 | st.repoMock = mocks.NewMockRoleRepository(st.mockCtrl) 32 | st.userRepoMock = mocks_user.NewMockUserRepository(st.mockCtrl) 33 | st.transRepoMock = mock_repo.NewMockITransactionRepo(st.mockCtrl) 34 | } 35 | 36 | func (st *ServiceDeleteTest) givenData(id string) error { 37 | st.input = &application_role.DeletedInput{ 38 | ID: id, 39 | } 40 | st.expect = &application_role.Output{} 41 | 42 | return nil 43 | } 44 | 45 | func (st *ServiceDeleteTest) whenDeleteRole(_ context.Context) error { 46 | return nil 47 | } 48 | 49 | func (st *ServiceDeleteTest) whenDeleteNotExistingRole(_ context.Context) error { 50 | return nil 51 | } 52 | 53 | func (st *ServiceDeleteTest) thenSuccess(_, _, _ string) error { 54 | return nil 55 | } 56 | 57 | func (st *ServiceDeleteTest) whenDeleteHasUserRole(_ context.Context) error { 58 | return nil 59 | } 60 | 61 | func (st *ServiceDeleteTest) InitializeScenario(ctx *godog.ScenarioContext) { 62 | ctx.Before(func(ctx context.Context, sc *godog.Scenario) (context.Context, error) { 63 | st.mockCtrl = gomock.NewController(st.t) 64 | st.reset() 65 | 66 | return ctx, nil 67 | }) 68 | 69 | ctx.After(func(ctx context.Context, sc *godog.Scenario, err error) (context.Context, error) { 70 | st.mockCtrl.Finish() 71 | 72 | return ctx, nil 73 | }) 74 | 75 | ctx.Step(`^提供 (.*?)$`, st.givenData) 76 | ctx.Step(`^ID存在並嘗試刪除角色$`, st.whenDeleteRole) 77 | ctx.Step(`^ID不存在並嘗試刪除角色$`, st.whenDeleteNotExistingRole) 78 | ctx.Step(`^ID存在並且角色ID已經被分配給用戶$`, st.whenDeleteHasUserRole) 79 | ctx.Step(`^角色成功被刪除$`, st.thenSuccess) 80 | } 81 | 82 | // func TestDelete(t *testing.T) { 83 | // t.Parallel() 84 | 85 | // serviceTest := &ServiceDeleteTest{ 86 | // t: t, 87 | // } 88 | 89 | // // Create the report directory 90 | // reportPath := filepath.Join("..", "report", "TestRoleDeleteUsecase.json") 91 | // // Create the directory if it does not exist 92 | // err := os.MkdirAll(filepath.Dir(reportPath), os.ModePerm) 93 | // if err != nil { 94 | // t.Fatal(err) 95 | // } 96 | 97 | // // Run the test suite 98 | // suite := godog.TestSuite{ 99 | // Name: "Delete", 100 | // ScenarioInitializer: serviceTest.InitializeScenario, 101 | // Options: &godog.Options{ 102 | // Format: "cucumber:" + reportPath, 103 | // Paths: []string{"features/usecase/role_deleted.feature"}, 104 | // TestingT: t, 105 | // Output: colors.Colored(os.Stdout), 106 | // }, 107 | // } 108 | 109 | // if suite.Run() != 0 { 110 | // t.Log("non-zero status returned, failed to run feature tests") 111 | // } 112 | // } 113 | -------------------------------------------------------------------------------- /internal/infra/dto/group.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | import ( 4 | "encoding/json" 5 | "reflect" 6 | "time" 7 | 8 | "github.com/jinzhu/copier" 9 | "github.com/mitchellh/mapstructure" 10 | "gorm.io/gorm" 11 | 12 | "github.com/program-world-labs/DDDGo/internal/domain" 13 | "github.com/program-world-labs/DDDGo/internal/domain/domainerrors" 14 | "github.com/program-world-labs/DDDGo/internal/domain/entity" 15 | ) 16 | 17 | var _ IRepoEntity = (*Group)(nil) 18 | 19 | type Group struct { 20 | ID string `json:"id" gorm:"primary_key"` 21 | Name string `json:"name"` 22 | Description string `json:"description"` 23 | Users []User `json:"users" gorm:"foreignKey:GroupID"` 24 | OwnerID string `json:"ownerId"` 25 | Metadata string `json:"metadata"` 26 | CreatedAt time.Time `json:"created_at" mapstructure:"created_at" gorm:"column:created_at"` 27 | UpdatedAt time.Time `json:"updated_at" mapstructure:"updated_at" gorm:"column:updated_at"` 28 | DeletedAt *gorm.DeletedAt `json:"deleted_at" mapstructure:"deleted_at" gorm:"index;column:deleted_at"` 29 | } 30 | 31 | func (a *Group) TableName() string { 32 | return "Group" 33 | } 34 | 35 | func (a *Group) Transform(i domain.IEntity) (IRepoEntity, error) { 36 | if err := copier.Copy(a, i); err != nil { 37 | return nil, domainerrors.Wrap(ErrorCodeTransform, err) 38 | } 39 | 40 | return a, nil 41 | } 42 | 43 | func (a *Group) BackToDomain() (domain.IEntity, error) { 44 | i := &entity.Group{} 45 | if err := copier.Copy(i, a); err != nil { 46 | return nil, domainerrors.Wrap(ErrorCodeBackToDomain, err) 47 | } 48 | 49 | if a.DeletedAt != nil { 50 | i.DeletedAt = a.DeletedAt.Time 51 | } 52 | 53 | return i, nil 54 | } 55 | 56 | func (a *Group) BeforeCreate() (err error) { 57 | a.ID, err = generateID() 58 | a.UpdatedAt = time.Now() 59 | a.CreatedAt = time.Now() 60 | a.DeletedAt = nil 61 | 62 | return 63 | } 64 | 65 | func (a *Group) BeforeUpdate() (err error) { 66 | a.UpdatedAt = time.Now() 67 | 68 | return 69 | } 70 | 71 | func (a *Group) GetID() string { 72 | return a.ID 73 | } 74 | 75 | func (a *Group) SetID(id string) { 76 | a.ID = id 77 | } 78 | 79 | func (a *Group) ToJSON() (string, error) { 80 | jsonData, err := json.Marshal(a) 81 | if err != nil { 82 | return "", domainerrors.Wrap(ErrorCodeToJSON, err) 83 | } 84 | 85 | return string(jsonData), nil 86 | } 87 | 88 | func (a *Group) UnmarshalJSON(data []byte) error { 89 | type Alias Group 90 | 91 | aux := &struct { 92 | *Alias 93 | }{ 94 | Alias: (*Alias)(a), 95 | } 96 | 97 | if err := json.Unmarshal(data, &aux); err != nil { 98 | return domainerrors.Wrap(ErrorCodeDecodeJSON, err) 99 | } 100 | 101 | return nil 102 | } 103 | 104 | func (a *Group) ParseMap(data map[string]interface{}) (IRepoEntity, error) { 105 | err := ParseDateString(data) 106 | if err != nil { 107 | return nil, domainerrors.Wrap(ErrorCodeParseMap, err) 108 | } 109 | 110 | var info *Group 111 | 112 | err = mapstructure.Decode(data, &info) 113 | 114 | if err != nil { 115 | return nil, domainerrors.Wrap(ErrorCodeParseMap, err) 116 | } 117 | 118 | return info, nil 119 | } 120 | 121 | func (a *Group) GetPreloads() []string { 122 | return []string{ 123 | "Users", 124 | } 125 | } 126 | 127 | func (a *Group) GetListType() interface{} { 128 | entityType := reflect.TypeOf(Role{}) 129 | sliceType := reflect.SliceOf(entityType) 130 | sliceValue := reflect.MakeSlice(sliceType, 0, 0) 131 | 132 | return sliceValue.Interface() 133 | } 134 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | include .env.example 2 | export 3 | 4 | LOCAL_BIN:=$(CURDIR)/bin 5 | PATH:=$(LOCAL_BIN):$(PATH) 6 | 7 | # HELP ================================================================================================================= 8 | # This will output the help for each task 9 | # thanks to https://marmelab.com/blog/2016/02/29/auto-documented-makefile.html 10 | .PHONY: help 11 | 12 | help: ## Display this help screen 13 | @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m\033[0m\n"} /^[a-zA-Z_-]+:.*?##/ { printf " \033[36m%-15s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST) 14 | 15 | compose-up: ### Run docker-compose 16 | docker-compose -f docker-compose.dev.yml up -d 17 | .PHONY: compose-up 18 | 19 | compose-up-integration-test: ### Run docker-compose with integration test 20 | docker-compose up --build --abort-on-container-exit --exit-code-from integration 21 | .PHONY: compose-up-integration-test 22 | 23 | compose-down: ### Down docker-compose 24 | docker-compose down --remove-orphans 25 | .PHONY: compose-down 26 | 27 | swag-v1: ### swag init 28 | swag init -g internal/adapter/http/v1/router.go 29 | .PHONY: swag-v1 30 | 31 | run: swag-v1 ### swag run 32 | go mod tidy && go mod download && \ 33 | DISABLE_SWAGGER_HTTP_HANDLER='' GIN_MODE=debug CGO_ENABLED=0 go run -tags migrate ./cmd/app 34 | .PHONY: run 35 | 36 | docker-rm-volume: ### remove docker volume 37 | docker volume rm go-clean-template_pg-data 38 | .PHONY: docker-rm-volume 39 | 40 | lint: ### check by golangci linter 41 | golangci-lint run 42 | .PHONY: linter-golangci 43 | 44 | linter-hadolint: ### check by hadolint linter 45 | git ls-files --exclude='Dockerfile*' --ignored | xargs hadolint 46 | .PHONY: linter-hadolint 47 | 48 | linter-dotenv: ### check by dotenv linter 49 | dotenv-linter 50 | .PHONY: linter-dotenv 51 | 52 | test: ### run test 53 | source .env && go test -v -cover -race ./tests/... 54 | .PHONY: test 55 | 56 | integration-test: ### run integration-test 57 | go clean -testcache && go test -v ./integration-test/... 58 | .PHONY: integration-test 59 | 60 | mock: ### run mockgen 61 | mockgen -source=internal/infra/datasource/interface.go -destination=tests/mocks/DataSource_mock.go -package=mocks 62 | mockgen -source=internal/domain/repository.go -destination=tests/mocks/Repository_mock.go -package=mocks 63 | mockgen -source=internal/domain/event/event_producer.go -destination=tests/mocks/EventProducer_mock.go -package=mocks 64 | mockgen -source=internal/domain/event/event_store.go -destination=tests/mocks/EventStore_mock.go -package=mocks 65 | 66 | mockgen -source=internal/application/user/interface.go -destination=tests/mocks/user/UserService_mock.go -package=user 67 | mockgen -source=internal/domain/repository/user_repository.go -destination=tests/mocks/user/UserRepository_mock.go -package=user 68 | 69 | mockgen -source=internal/application/role/interface.go -destination=tests/mocks/role/RoleService_mock.go -package=role 70 | mockgen -source=internal/domain/repository/role_repository.go -destination=tests/mocks/role/RoleRepository_mock.go -package=role 71 | 72 | 73 | .PHONY: mock 74 | 75 | wire: ### run mockgen 76 | wire ./internal/app 77 | .PHONY: wire 78 | 79 | migrate-create: ### create new migration 80 | migrate create -ext sql -dir migrations 'migrate_name' 81 | .PHONY: migrate-create 82 | 83 | migrate-up: ### migration up 84 | migrate -path migrations -database '$(PG_URL)?sslmode=disable' up 85 | .PHONY: migrate-up 86 | 87 | bin-deps: 88 | GOBIN=$(LOCAL_BIN) go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest 89 | GOBIN=$(LOCAL_BIN) go install github.com/golang/mock/mockgen@latest 90 | -------------------------------------------------------------------------------- /internal/infra/dto/walletBalance.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | import ( 4 | "encoding/json" 5 | "reflect" 6 | "time" 7 | 8 | "github.com/jinzhu/copier" 9 | "github.com/mitchellh/mapstructure" 10 | "gorm.io/gorm" 11 | 12 | "github.com/program-world-labs/DDDGo/internal/domain" 13 | "github.com/program-world-labs/DDDGo/internal/domain/domainerrors" 14 | "github.com/program-world-labs/DDDGo/internal/domain/entity" 15 | ) 16 | 17 | var _ IRepoEntity = (*WalletBalance)(nil) 18 | 19 | type WalletBalance struct { 20 | ID string `json:"id" gorm:"primary_key"` 21 | WalletID string `json:"walletId" gorm:"index"` 22 | CurrencyID string `json:"currencyId" gorm:"index"` 23 | Balance uint `json:"balance"` 24 | Decimal uint `json:"decimal"` 25 | CreatedAt time.Time `json:"created_at" mapstructure:"created_at" gorm:"column:created_at"` 26 | UpdatedAt time.Time `json:"updated_at" mapstructure:"updated_at" gorm:"column:updated_at"` 27 | DeletedAt *gorm.DeletedAt `json:"deleted_at" mapstructure:"deleted_at" gorm:"index;column:deleted_at"` 28 | } 29 | 30 | func (a *WalletBalance) TableName() string { 31 | return "WalletBalance" 32 | } 33 | 34 | func (a *WalletBalance) Transform(i domain.IEntity) (IRepoEntity, error) { 35 | if err := copier.Copy(a, i); err != nil { 36 | return nil, domainerrors.Wrap(ErrorCodeTransform, err) 37 | } 38 | 39 | return a, nil 40 | } 41 | 42 | func (a *WalletBalance) BackToDomain() (domain.IEntity, error) { 43 | i := &entity.Wallet{} 44 | if err := copier.Copy(i, a); err != nil { 45 | return nil, domainerrors.Wrap(ErrorCodeBackToDomain, err) 46 | } 47 | 48 | if a.DeletedAt != nil { 49 | i.DeletedAt = a.DeletedAt.Time 50 | } 51 | 52 | return i, nil 53 | } 54 | 55 | func (a *WalletBalance) BeforeCreate() (err error) { 56 | a.ID, err = generateID() 57 | a.UpdatedAt = time.Now() 58 | a.CreatedAt = time.Now() 59 | a.DeletedAt = nil 60 | 61 | return 62 | } 63 | 64 | func (a *WalletBalance) BeforeUpdate() (err error) { 65 | a.UpdatedAt = time.Now() 66 | 67 | return 68 | } 69 | 70 | func (a *WalletBalance) GetID() string { 71 | return a.ID 72 | } 73 | 74 | func (a *WalletBalance) SetID(id string) { 75 | a.ID = id 76 | } 77 | 78 | func (a *WalletBalance) ToJSON() (string, error) { 79 | jsonData, err := json.Marshal(a) 80 | if err != nil { 81 | return "", domainerrors.Wrap(ErrorCodeToJSON, err) 82 | } 83 | 84 | return string(jsonData), nil 85 | } 86 | 87 | func (a *WalletBalance) UnmarshalJSON(data []byte) error { 88 | type Alias WalletBalance 89 | 90 | aux := &struct { 91 | *Alias 92 | }{ 93 | Alias: (*Alias)(a), 94 | } 95 | 96 | if err := json.Unmarshal(data, &aux); err != nil { 97 | return domainerrors.Wrap(ErrorCodeDecodeJSON, err) 98 | } 99 | 100 | return nil 101 | } 102 | 103 | func (a *WalletBalance) ParseMap(data map[string]interface{}) (IRepoEntity, error) { 104 | err := ParseDateString(data) 105 | if err != nil { 106 | return nil, domainerrors.Wrap(ErrorCodeParseMap, err) 107 | } 108 | 109 | var info *WalletBalance 110 | err = mapstructure.Decode(data, &info) 111 | 112 | if err != nil { 113 | return nil, domainerrors.Wrap(ErrorCodeParseMap, err) 114 | } 115 | 116 | return info, nil 117 | } 118 | 119 | func (a *WalletBalance) GetPreloads() []string { 120 | return []string{} 121 | } 122 | 123 | func (a *WalletBalance) GetListType() interface{} { 124 | entityType := reflect.TypeOf(Role{}) 125 | sliceType := reflect.SliceOf(entityType) 126 | sliceValue := reflect.MakeSlice(sliceType, 0, 0) 127 | 128 | return sliceValue.Interface() 129 | } 130 | -------------------------------------------------------------------------------- /internal/infra/dto/user.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | import ( 4 | "encoding/json" 5 | "reflect" 6 | "time" 7 | 8 | "github.com/jinzhu/copier" 9 | "github.com/mitchellh/mapstructure" 10 | "gorm.io/gorm" 11 | 12 | "github.com/program-world-labs/DDDGo/internal/domain" 13 | "github.com/program-world-labs/DDDGo/internal/domain/domainerrors" 14 | "github.com/program-world-labs/DDDGo/internal/domain/entity" 15 | ) 16 | 17 | var _ IRepoEntity = (*User)(nil) 18 | 19 | type User struct { 20 | ID string `json:"id" gorm:"primary_key"` 21 | Username string `json:"username"` 22 | Password string `json:"password"` 23 | EMail string `json:"email"` 24 | DisplayName string `json:"display_name"` 25 | Avatar string `json:"avatar"` 26 | Enabled bool `json:"enabled"` 27 | Roles []Role `json:"roles" gorm:"many2many:user_roles;"` 28 | Wallets []Wallet `json:"wallets" gorm:"foreignKey:UserID"` 29 | Group Group `json:"group"` 30 | GroupID string `json:"groupId" gorm:"index"` 31 | CreatedAt time.Time `json:"created_at" mapstructure:"created_at" gorm:"column:created_at"` 32 | UpdatedAt time.Time `json:"updated_at" mapstructure:"updated_at" gorm:"column:updated_at"` 33 | DeletedAt *gorm.DeletedAt `json:"deleted_at" mapstructure:"deleted_at" gorm:"index;column:deleted_at"` 34 | } 35 | 36 | func (a *User) TableName() string { 37 | return "Users" 38 | } 39 | 40 | func (a *User) Transform(i domain.IEntity) (IRepoEntity, error) { 41 | if err := copier.Copy(a, i); err != nil { 42 | return nil, domainerrors.Wrap(ErrorCodeTransform, err) 43 | } 44 | 45 | return a, nil 46 | } 47 | 48 | func (a *User) BackToDomain() (domain.IEntity, error) { 49 | i := &entity.User{} 50 | if err := copier.Copy(i, a); err != nil { 51 | return nil, domainerrors.Wrap(ErrorCodeBackToDomain, err) 52 | } 53 | 54 | if a.DeletedAt != nil { 55 | i.DeletedAt = a.DeletedAt.Time 56 | } 57 | 58 | return i, nil 59 | } 60 | 61 | func (a *User) BeforeUpdate() (err error) { 62 | a.UpdatedAt = time.Now() 63 | 64 | return 65 | } 66 | func (a *User) BeforeCreate() (err error) { 67 | a.ID, err = generateID() 68 | a.UpdatedAt = time.Now() 69 | a.CreatedAt = time.Now() 70 | a.DeletedAt = nil 71 | 72 | return 73 | } 74 | 75 | func (a *User) GetID() string { 76 | return a.ID 77 | } 78 | 79 | func (a *User) SetID(id string) { 80 | a.ID = id 81 | } 82 | 83 | func (a *User) ToJSON() (string, error) { 84 | jsonData, err := json.Marshal(a) 85 | if err != nil { 86 | return "", domainerrors.Wrap(ErrorCodeToJSON, err) 87 | } 88 | 89 | return string(jsonData), nil 90 | } 91 | 92 | func (a *User) UnmarshalJSON(data []byte) error { 93 | type Alias User 94 | 95 | aux := &struct { 96 | *Alias 97 | }{ 98 | Alias: (*Alias)(a), 99 | } 100 | 101 | if err := json.Unmarshal(data, &aux); err != nil { 102 | return domainerrors.Wrap(ErrorCodeDecodeJSON, err) 103 | } 104 | 105 | return nil 106 | } 107 | 108 | func (a *User) ParseMap(data map[string]interface{}) (IRepoEntity, error) { 109 | err := ParseDateString(data) 110 | if err != nil { 111 | return nil, domainerrors.Wrap(ErrorCodeParseMap, err) 112 | } 113 | 114 | var info *User 115 | err = mapstructure.Decode(data, &info) 116 | 117 | if err != nil { 118 | return nil, domainerrors.Wrap(ErrorCodeDecodeJSON, err) 119 | } 120 | 121 | return info, nil 122 | } 123 | 124 | func (a *User) GetPreloads() []string { 125 | return []string{"Roles", "Wallets", "Group"} 126 | } 127 | 128 | func (a *User) GetListType() interface{} { 129 | entityType := reflect.TypeOf(Role{}) 130 | sliceType := reflect.SliceOf(entityType) 131 | sliceValue := reflect.MakeSlice(sliceType, 0, 0) 132 | 133 | return sliceValue.Interface() 134 | } 135 | -------------------------------------------------------------------------------- /internal/infra/dto/wallet.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | import ( 4 | "encoding/json" 5 | "reflect" 6 | "time" 7 | 8 | "github.com/jinzhu/copier" 9 | "github.com/mitchellh/mapstructure" 10 | "gorm.io/gorm" 11 | 12 | "github.com/program-world-labs/DDDGo/internal/domain" 13 | "github.com/program-world-labs/DDDGo/internal/domain/domainerrors" 14 | "github.com/program-world-labs/DDDGo/internal/domain/entity" 15 | ) 16 | 17 | type Chain string 18 | 19 | const ( 20 | None Chain = "None" 21 | Bitcoin Chain = "Bitcoin" 22 | Ethereum Chain = "Ethereum" 23 | Polygon Chain = "Polygon" 24 | ) 25 | 26 | var _ IRepoEntity = (*Wallet)(nil) 27 | 28 | type Wallet struct { 29 | ID string `json:"id" gorm:"primary_key"` 30 | Name string `json:"name"` 31 | Description string `json:"description"` 32 | Chain Chain `json:"chain"` 33 | Address string `json:"address"` 34 | UserID string `json:"userId" gorm:"index"` 35 | WalletBalances []WalletBalance `json:"walletBalances" gorm:"foreignKey:WalletID"` 36 | CreatedAt time.Time `json:"created_at" mapstructure:"created_at" gorm:"column:created_at"` 37 | UpdatedAt time.Time `json:"updated_at" mapstructure:"updated_at" gorm:"column:updated_at"` 38 | DeletedAt *gorm.DeletedAt `json:"deleted_at" mapstructure:"deleted_at" gorm:"index;column:deleted_at"` 39 | } 40 | 41 | func (a *Wallet) TableName() string { 42 | return "Wallet" 43 | } 44 | 45 | func (a *Wallet) Transform(i domain.IEntity) (IRepoEntity, error) { 46 | if err := copier.Copy(a, i); err != nil { 47 | return nil, domainerrors.Wrap(ErrorCodeTransform, err) 48 | } 49 | 50 | return a, nil 51 | } 52 | 53 | func (a *Wallet) BackToDomain() (domain.IEntity, error) { 54 | i := &entity.Wallet{} 55 | if err := copier.Copy(i, a); err != nil { 56 | return nil, domainerrors.Wrap(ErrorCodeBackToDomain, err) 57 | } 58 | 59 | if a.DeletedAt != nil { 60 | i.DeletedAt = a.DeletedAt.Time 61 | } 62 | 63 | return i, nil 64 | } 65 | 66 | func (a *Wallet) BeforeCreate() (err error) { 67 | a.ID, err = generateID() 68 | a.UpdatedAt = time.Now() 69 | a.CreatedAt = time.Now() 70 | a.DeletedAt = nil 71 | 72 | return 73 | } 74 | 75 | func (a *Wallet) BeforeUpdate() (err error) { 76 | a.UpdatedAt = time.Now() 77 | 78 | return 79 | } 80 | 81 | func (a *Wallet) GetID() string { 82 | return a.ID 83 | } 84 | 85 | func (a *Wallet) SetID(id string) { 86 | a.ID = id 87 | } 88 | 89 | func (a *Wallet) ToJSON() (string, error) { 90 | jsonData, err := json.Marshal(a) 91 | if err != nil { 92 | return "", domainerrors.Wrap(ErrorCodeToJSON, err) 93 | } 94 | 95 | return string(jsonData), nil 96 | } 97 | 98 | func (a *Wallet) UnmarshalJSON(data []byte) error { 99 | type Alias Wallet 100 | 101 | aux := &struct { 102 | *Alias 103 | }{ 104 | Alias: (*Alias)(a), 105 | } 106 | 107 | if err := json.Unmarshal(data, &aux); err != nil { 108 | return domainerrors.Wrap(ErrorCodeDecodeJSON, err) 109 | } 110 | 111 | return nil 112 | } 113 | 114 | func (a *Wallet) ParseMap(data map[string]interface{}) (IRepoEntity, error) { 115 | err := ParseDateString(data) 116 | if err != nil { 117 | return nil, domainerrors.Wrap(ErrorCodeParseMap, err) 118 | } 119 | 120 | var info *Wallet 121 | err = mapstructure.Decode(data, &info) 122 | 123 | if err != nil { 124 | return nil, domainerrors.Wrap(ErrorCodeParseMap, err) 125 | } 126 | 127 | return info, nil 128 | } 129 | 130 | func (a *Wallet) GetPreloads() []string { 131 | // return []string{"WalletBalances"} 132 | return []string{} 133 | } 134 | 135 | func (a *Wallet) GetListType() interface{} { 136 | entityType := reflect.TypeOf(Role{}) 137 | sliceType := reflect.SliceOf(entityType) 138 | sliceValue := reflect.MakeSlice(sliceType, 0, 0) 139 | 140 | return sliceValue.Interface() 141 | } 142 | -------------------------------------------------------------------------------- /internal/infra/dto/role.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | import ( 4 | "encoding/json" 5 | "reflect" 6 | "time" 7 | 8 | "github.com/jinzhu/copier" 9 | "github.com/lib/pq" 10 | "github.com/mitchellh/mapstructure" 11 | "gorm.io/gorm" 12 | 13 | "github.com/program-world-labs/DDDGo/internal/domain" 14 | "github.com/program-world-labs/DDDGo/internal/domain/domainerrors" 15 | "github.com/program-world-labs/DDDGo/internal/domain/entity" 16 | ) 17 | 18 | var _ IRepoEntity = (*Role)(nil) 19 | 20 | type Role struct { 21 | ID string `json:"id" gorm:"type:varchar(20);primary_key"` 22 | Name string `json:"name"` 23 | Description string `json:"description"` 24 | Permissions pq.StringArray `json:"permissions" gorm:"type:varchar(100)[]"` 25 | Users []User `json:"users" gorm:"many2many:user_roles;"` 26 | CreatedAt time.Time `json:"created_at" mapstructure:"created_at" gorm:"column:created_at"` 27 | UpdatedAt time.Time `json:"updated_at" mapstructure:"updated_at" gorm:"column:updated_at"` 28 | DeletedAt *gorm.DeletedAt `json:"deleted_at" mapstructure:"deleted_at" gorm:"index;column:deleted_at"` 29 | } 30 | 31 | func (a *Role) TableName() string { 32 | return "Roles" 33 | } 34 | 35 | func (a *Role) Transform(i domain.IEntity) (IRepoEntity, error) { 36 | if err := copier.Copy(a, i); err != nil { 37 | return nil, domainerrors.Wrap(ErrorCodeTransform, err) 38 | } 39 | 40 | return a, nil 41 | } 42 | 43 | func (a *Role) BackToDomain() (domain.IEntity, error) { 44 | i := &entity.Role{} 45 | if err := copier.Copy(i, a); err != nil { 46 | return nil, domainerrors.Wrap(ErrorCodeBackToDomain, err) 47 | } 48 | 49 | if a.DeletedAt != nil { 50 | i.DeletedAt = a.DeletedAt.Time 51 | } 52 | 53 | return i, nil 54 | } 55 | 56 | func (a *Role) BeforeUpdate() (err error) { 57 | a.UpdatedAt = time.Now() 58 | 59 | return 60 | } 61 | 62 | func (a *Role) BeforeCreate() (err error) { 63 | a.ID, err = generateID() 64 | a.UpdatedAt = time.Now() 65 | a.CreatedAt = time.Now() 66 | a.DeletedAt = nil 67 | 68 | return 69 | } 70 | 71 | func (a *Role) GetID() string { 72 | return a.ID 73 | } 74 | 75 | func (a *Role) SetID(id string) { 76 | a.ID = id 77 | } 78 | 79 | func (a *Role) ToJSON() (string, error) { 80 | jsonData, err := json.Marshal(a) 81 | if err != nil { 82 | return "", domainerrors.Wrap(ErrorCodeToJSON, err) 83 | } 84 | 85 | return string(jsonData), nil 86 | } 87 | 88 | func (a *Role) UnmarshalJSON(data []byte) error { 89 | type Alias Role 90 | 91 | aux := &struct { 92 | *Alias 93 | }{ 94 | Alias: (*Alias)(a), 95 | } 96 | 97 | if err := json.Unmarshal(data, &aux); err != nil { 98 | return domainerrors.Wrap(ErrorCodeDecodeJSON, err) 99 | } 100 | 101 | return nil 102 | } 103 | 104 | func (a *Role) ParseMap(data map[string]interface{}) (IRepoEntity, error) { 105 | err := ParseDateString(data) 106 | if err != nil { 107 | return nil, domainerrors.Wrap(ErrorCodeParseMap, err) 108 | } 109 | 110 | var info *Role 111 | // Permissions is a slice of string, so we need to decode it manually, data like {read:all,write:all} 112 | // permission, ok := data["permissions"].(string) 113 | // if !ok { 114 | // return nil, NewRoleParseMapError(nil) 115 | // } 116 | 117 | // s := strings.Trim(permission, "{}") // 删除开头和结尾的大括号 118 | // result := strings.Split(s, ",") // 以逗号为分割符,分割字符串 119 | // data["permissions"] = result 120 | 121 | err = mapstructure.Decode(data, &info) 122 | 123 | if err != nil { 124 | return nil, domainerrors.Wrap(ErrorCodeParseMap, err) 125 | } 126 | 127 | return info, nil 128 | } 129 | 130 | func (a *Role) GetPreloads() []string { 131 | return []string{ 132 | "Users", 133 | } 134 | } 135 | 136 | func (a *Role) GetListType() interface{} { 137 | entityType := reflect.TypeOf(Role{}) 138 | sliceType := reflect.SliceOf(entityType) 139 | sliceValue := reflect.MakeSlice(sliceType, 0, 0) 140 | 141 | return sliceValue.Interface() 142 | } 143 | -------------------------------------------------------------------------------- /docker-compose.dev.yml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | services: 3 | postgres: 4 | container_name: postgres 5 | image: postgres 6 | volumes: 7 | - pg-data:/var/lib/postgresql/data 8 | environment: 9 | POSTGRES_USER: "user" 10 | POSTGRES_PASSWORD: "pass" 11 | POSTGRES_DB: "db" 12 | ports: 13 | - 5432:5432 14 | 15 | redis: 16 | container_name: redis 17 | image: redis 18 | volumes: 19 | - redis-data:/data 20 | ports: 21 | - 6379:6379 22 | 23 | dtm: 24 | image: yedf/dtm 25 | environment: 26 | STORE_DRIVER: mysql 27 | STORE_HOST: test_mysql 28 | STORE_USER: root 29 | STORE_PASSWORD: 'pass' 30 | STORE_PORT: 3306 31 | ports: 32 | - '36789:36789' 33 | - '36790:36790' 34 | 35 | # mysql: 36 | # container_name: mysql 37 | # image: mysql 38 | # volumes: 39 | # - mysql-data:/var/lib/mysql 40 | # - ./pkg/pwsql/mysql/init.sql:/docker-entrypoint-initdb.d/init.sql 41 | # environment: 42 | # MYSQL_ROOT_PASSWORD: 'pass' 43 | # MYSQL_DATABASE: 'db' 44 | # MYSQL_USER: 'user' 45 | # MYSQL_PASSWORD: 'pass' 46 | # ports: 47 | # - 3306:3306 48 | 49 | zookeeper: 50 | image: confluentinc/cp-zookeeper:latest 51 | environment: 52 | ZOOKEEPER_CLIENT_PORT: 2181 53 | ZOOKEEPER_TICK_TIME: 2000 54 | ports: 55 | - 22181:2181 56 | 57 | kafka1: 58 | image: confluentinc/cp-kafka:latest 59 | depends_on: 60 | - zookeeper 61 | ports: 62 | - 29092:29092 63 | environment: 64 | KAFKA_BROKER_ID: 1 65 | KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181 66 | KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka1:9092,PLAINTEXT_HOST://localhost:29092 67 | KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT 68 | KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT 69 | KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 3 70 | # KAFKA_REPLICATION_FACTOR: 3 71 | KAFKA_MIN_INSYNC_REPLICAS: 2 72 | KAFKA_AUTO_LEADER_REBALANCE_ENABLE: 'true' 73 | 74 | kafka2: 75 | image: confluentinc/cp-kafka:latest 76 | depends_on: 77 | - zookeeper 78 | ports: 79 | - 29093:29093 80 | environment: 81 | KAFKA_BROKER_ID: 2 82 | KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181 83 | KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka2:9093,PLAINTEXT_HOST://localhost:29093 84 | KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT 85 | KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT 86 | KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 3 87 | # KAFKA_REPLICATION_FACTOR: 3 88 | KAFKA_MIN_INSYNC_REPLICAS: 2 89 | KAFKA_AUTO_LEADER_REBALANCE_ENABLE: 'true' 90 | 91 | kafka3: 92 | image: confluentinc/cp-kafka:latest 93 | depends_on: 94 | - zookeeper 95 | ports: 96 | - 29094:29094 97 | environment: 98 | KAFKA_BROKER_ID: 3 99 | KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181 100 | KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka3:9094,PLAINTEXT_HOST://localhost:29094 101 | KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT 102 | KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT 103 | KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 3 104 | # KAFKA_REPLICATION_FACTOR: 3 105 | KAFKA_MIN_INSYNC_REPLICAS: 2 106 | KAFKA_AUTO_LEADER_REBALANCE_ENABLE: 'true' 107 | 108 | kafka-ui: 109 | container_name: kafka-ui 110 | image: 'provectuslabs/kafka-ui:latest' 111 | ports: 112 | - 8082:8080 113 | environment: 114 | KAFKA_CLUSTERS_0_NAME: local 115 | KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS: kafka1:9092,kafka2:9093,kafka3:9094 116 | 117 | esdb: 118 | image: eventstore/eventstore:latest 119 | command: --insecure --run-projections=All --enable-external-tcp --enable-atom-pub-over-http --start-standard-projections 120 | ports: 121 | - 2113:2113 122 | - 1113:1113 123 | 124 | volumes: 125 | pg-data: 126 | redis-data: 127 | mysql-data: 128 | -------------------------------------------------------------------------------- /internal/domain/entity/role.go: -------------------------------------------------------------------------------- 1 | package entity 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/program-world-labs/DDDGo/internal/domain" 7 | "github.com/program-world-labs/DDDGo/internal/domain/aggregate" 8 | "github.com/program-world-labs/DDDGo/internal/domain/domainerrors" 9 | "github.com/program-world-labs/DDDGo/internal/domain/event" 10 | ) 11 | 12 | var _ domain.IEntity = (*Role)(nil) 13 | var _ aggregate.Handler = (*Role)(nil) 14 | 15 | type Role struct { 16 | aggregate.BaseAggregate `copier:"-"` 17 | ID string `json:"id"` 18 | Name string `json:"name"` 19 | Description string `json:"description"` 20 | Permissions []string `json:"permissions"` 21 | Users []User `json:"users"` 22 | CreatedAt time.Time `json:"created_at"` 23 | UpdatedAt time.Time `json:"updated_at"` 24 | DeletedAt time.Time `json:"deleted_at"` 25 | } 26 | 27 | func NewRole(name, description string, permissions []string) *Role { 28 | return &Role{ 29 | Name: name, 30 | Description: description, 31 | Permissions: permissions, 32 | } 33 | } 34 | 35 | func NewRoleFromHistory(events []event.DomainEvent) (*Role, error) { 36 | role := &Role{} 37 | err := role.LoadFromHistory(events) 38 | 39 | if err != nil { 40 | return nil, err 41 | } 42 | 43 | return role, nil 44 | } 45 | 46 | func (a *Role) GetID() string { 47 | return a.ID 48 | } 49 | 50 | func (a *Role) SetID(id string) { 51 | a.ID = id 52 | } 53 | 54 | // LoadFromHistory -. 55 | func (a *Role) LoadFromHistory(events []event.DomainEvent) error { 56 | for i := range events { 57 | err := a.ApplyEvent(&events[i]) 58 | if err != nil { 59 | return err 60 | } 61 | } 62 | 63 | return nil 64 | } 65 | 66 | // ApplyEvent -. 67 | func (a *Role) ApplyEvent(domainEvent *event.DomainEvent) error { 68 | switch domainEvent.Data.(type) { 69 | case *event.RoleCreatedEvent: 70 | return a.applyCreated(domainEvent) 71 | case *event.RoleDescriptionChangedEvent: 72 | return a.applyDescriptionChanged(domainEvent) 73 | case **event.RolePermissionUpdatedEvent: 74 | return a.applyPermisionUpdated(domainEvent) 75 | default: 76 | return nil 77 | } 78 | } 79 | 80 | func (a *Role) applyCreated(domainEvent *event.DomainEvent) error { 81 | eventData, ok := domainEvent.Data.(*event.RoleCreatedEvent) 82 | if !ok { 83 | return domainerrors.Wrap(ErrorCodeCastToEvent, ErrCastToEventFailed) 84 | } 85 | 86 | a.Name = eventData.Name 87 | a.Description = eventData.Description 88 | 89 | if a.Permissions == nil { 90 | a.Permissions = []string{} 91 | } 92 | 93 | a.Permissions = appendUnique(a.Permissions, eventData.Permissions) 94 | 95 | return nil 96 | } 97 | 98 | func (a *Role) applyDescriptionChanged(domainEvent *event.DomainEvent) error { 99 | eventData, ok := domainEvent.Data.(*event.RoleDescriptionChangedEvent) 100 | if !ok { 101 | return domainerrors.Wrap(ErrorCodeCastToEvent, ErrCastToEventFailed) 102 | } 103 | 104 | a.Description = eventData.Description 105 | 106 | return nil 107 | } 108 | 109 | func (a *Role) applyPermisionUpdated(domainEvent *event.DomainEvent) error { 110 | eventData, ok := domainEvent.Data.(*event.RolePermissionUpdatedEvent) 111 | if !ok { 112 | return domainerrors.Wrap(ErrorCodeCastToEvent, ErrCastToEventFailed) 113 | } 114 | 115 | a.Permissions = eventData.Permissions 116 | 117 | return nil 118 | } 119 | 120 | func appendUnique(slice []string, elements []string) []string { 121 | // Create a map where the keys are the strings in the slice. 122 | // The values don't matter, so we use empty structs as a memory-efficient placeholder. 123 | unique := make(map[string]struct{}, len(slice)) 124 | for _, s := range slice { 125 | unique[s] = struct{}{} 126 | } 127 | 128 | // Append each element in elements to the slice, but only if it's not already in the map. 129 | for _, e := range elements { 130 | if _, exists := unique[e]; !exists { 131 | slice = append(slice, e) 132 | unique[e] = struct{}{} 133 | } 134 | } 135 | 136 | return slice 137 | } 138 | -------------------------------------------------------------------------------- /pkg/message/kafka_message.go: -------------------------------------------------------------------------------- 1 | package message 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "log" 8 | "net/http" 9 | "strings" 10 | 11 | "github.com/Shopify/sarama" 12 | "github.com/ThreeDotsLabs/watermill" 13 | "github.com/ThreeDotsLabs/watermill-kafka/v2/pkg/kafka" 14 | "github.com/ThreeDotsLabs/watermill/message" 15 | "go.opentelemetry.io/otel" 16 | "go.opentelemetry.io/otel/propagation" 17 | 18 | "github.com/program-world-labs/DDDGo/internal/domain/domainerrors" 19 | "github.com/program-world-labs/DDDGo/internal/domain/event" 20 | ) 21 | 22 | var _ event.Producer = (*KafkaMessage)(nil) 23 | 24 | type KafkaMessage struct { 25 | Subscriber *kafka.Subscriber 26 | Publisher *kafka.Publisher 27 | Tracer *KafkaTracer 28 | Router *message.Router 29 | } 30 | 31 | func NewKafkaMessage(brokers []string, groupID string) (*KafkaMessage, error) { 32 | // Create a new Kafka subscriber config. 33 | subscriber, err := kafka.NewSubscriber( 34 | kafka.SubscriberConfig{ 35 | Brokers: brokers, 36 | Unmarshaler: kafka.DefaultMarshaler{}, 37 | OverwriteSaramaConfig: kafka.DefaultSaramaSubscriberConfig(), 38 | ConsumerGroup: groupID, 39 | }, 40 | watermill.NewStdLogger(false, false), 41 | ) 42 | 43 | if err != nil { 44 | return nil, err 45 | } 46 | 47 | // Create a new Kafka publisher config. 48 | config := kafka.DefaultSaramaSyncPublisherConfig() 49 | // 設置acks 50 | config.Producer.RequiredAcks = sarama.WaitForAll // 等於 acks=-1 51 | // 設置retries 52 | config.Producer.Retry.Max = 10 // 重試10次 53 | 54 | // 建立一個新的 OTLP Tracer 55 | tracer := NewKafkaTracer(domainerrors.GruopID) 56 | 57 | publisher, err := kafka.NewPublisher( 58 | kafka.PublisherConfig{ 59 | Brokers: brokers, 60 | Marshaler: kafka.DefaultMarshaler{}, 61 | OverwriteSaramaConfig: config, 62 | // OTELEnabled: true, 63 | // Tracer: tracer, 64 | }, 65 | watermill.NewStdLogger(false, false), 66 | ) 67 | if err != nil { 68 | return nil, err 69 | } 70 | 71 | // Create a new router. 72 | router, err := message.NewRouter(message.RouterConfig{}, watermill.NewStdLogger(false, false)) 73 | if err != nil { 74 | return nil, err 75 | } 76 | 77 | router.AddMiddleware(tracer.Trace()) 78 | 79 | return &KafkaMessage{Subscriber: subscriber, Publisher: publisher, Tracer: tracer, Router: router}, nil 80 | } 81 | 82 | func (k *KafkaMessage) PublishEvent(ctx context.Context, topic string, event interface{}) error { 83 | // Transform event to message 84 | eventBytes, err := json.Marshal(event) 85 | if err != nil { 86 | log.Fatalf("Failed to marshal event: %v", err) 87 | 88 | return err 89 | } 90 | 91 | // Create a new message with event bytes. 92 | msg := message.NewMessage(watermill.NewUUID(), eventBytes) 93 | 94 | // Inject context to metadata 95 | propagator := propagation.TraceContext{} 96 | header := http.Header{} 97 | propagator.Inject(ctx, propagation.HeaderCarrier(header)) 98 | 99 | metadata := make(message.Metadata) 100 | 101 | for k, v := range header { 102 | if len(v) > 0 { 103 | // If there are multiple values, just take the first one. 104 | // You might want to handle this differently depending on your use case. 105 | metadata[k] = v[0] 106 | } 107 | } 108 | 109 | msg.Metadata = metadata 110 | 111 | // Start tracing 112 | var tracer = otel.Tracer(domainerrors.GruopID) 113 | _, span := tracer.Start(ctx, k.structName(k.Publisher)) 114 | 115 | defer span.End() 116 | 117 | // 設置 metadata Event Type 118 | // msg.Metadata.Set("event_type", topic) 119 | 120 | err = k.Publisher.Publish(topic, msg) 121 | if err != nil { 122 | return err 123 | } 124 | 125 | return nil 126 | } 127 | 128 | func (k *KafkaMessage) Close() error { 129 | k.Subscriber.Close() 130 | k.Publisher.Close() 131 | 132 | return nil 133 | } 134 | 135 | func (k *KafkaMessage) structName(v interface{}) string { 136 | if s, ok := v.(fmt.Stringer); ok { 137 | return s.String() 138 | } 139 | 140 | s := fmt.Sprintf("%T", v) 141 | // trim the pointer marker, if any 142 | return strings.TrimLeft(s, "*") 143 | } 144 | -------------------------------------------------------------------------------- /tests/role/features/usecase/role_created.feature: -------------------------------------------------------------------------------- 1 | Feature: 創建角色 2 | 測試創建角色相關的usecase功能 3 | 4 | Scenario Outline: 創建角色成功 5 | 6 | Given 提供 , , 7 | When 創建一個新角色 8 | Then 角色, , ,成功被創建 9 | 10 | Examples: 11 | |name|description|permissions| 12 | |admin|this is admin role|read:all,write:all,delete:all| 13 | |develop|this is develop role|read:all| 14 | |owner|this is owner role|read:all,write:all| 15 | |test|this is test role|read:all,delete:all| 16 | 17 | Scenario Outline: 創建一個已存在的角色名稱 18 | 19 | Given 提供 , , 20 | When 嘗試創建一個已存在的角色名稱 21 | Then 應該返回一個錯誤,說明角色名稱已存在 22 | 23 | Examples: 24 | |name|description|permissions| 25 | |admin|this is admin role|read:all,write:all,delete:all| 26 | 27 | Scenario Outline: 提供的權限格式不正確 28 | 29 | Given 提供 , , 30 | When 帶入有問題的輸入 31 | Then 應該返回一個錯誤,說明權限格式不正確 32 | 33 | Examples: 34 | |name|description|permissions| 35 | |invalid|this is invalid role|read:all,write:all,delete:all,invalid:all| 36 | 37 | Scenario Outline: 提供的名稱格式不正確 38 | 39 | Given 提供 , , 40 | When 帶入有問題的輸入 41 | Then 應該返回一個錯誤,說明名稱格式不正確 42 | 43 | Examples: 44 | |name|description|permissions| 45 | |@|this is invalid role|read:all,write:all,delete:all| 46 | 47 | Scenario Outline: 提供的角色名稱或描述長度為最大值 48 | 49 | Given 提供 , , 50 | When 創建一個新角色 51 | Then 角色, , ,成功被創建 52 | 53 | Examples: 54 | |name|description|permissions| 55 | |eMAWSxvuWc36VAKVFxMmeYHmr70GvI|rfgkzDeNnc69zIDnsxdZTfEazl2sXEfCKhFds6ydEWfzN5pGrRlQa22524xvzLS7gtgKFzqizI4aCxXIB7Vni2uPbWjy4vBntNc9XvnSKvAfqzbMOgmD3jxmKuJGNRO4zfX6HNykFQJfSB4qCu47bE6Uzhzul1uHXcrKQWRR85ziXcHMfu1g4NmMHQBpWiFswexTwn4g|read:all| 56 | 57 | Scenario Outline: 提供的角色描述長度超過最大值 58 | 59 | Given 提供 , , 60 | When 帶入有問題的輸入 61 | Then 應該返回一個錯誤,說明角色描述長度超過最大值 62 | 63 | Examples: 64 | |name|description|permissions| 65 | |o8SA8aJMn1EaBjMS3l6UPdLPZ931T9|QTdYDISBUy7YFfrPHAA9R34GHEPmotoGkT9k0JPXbZk5P2vk1WudZbwhVk2KrtgDrRPK9uaPcryLIFdBVL6l4ct2SdyBq7WI0htPXinMhjACuaN7x6RL7rhAeS3Esa6h9kNPoB3mAsFkzr9ysCFnlLajh8a0KlkJcAplKYvPOXVbrnEJ3mdfH3rzIaCxjCM4FU69K7hte|read:all| 66 | |o8SA8aJMn1EaBjMS3l6UPdLPZ931T9|AQTdYDISBUy7YFfrPHAA9R34GHEPmotoGkT9k0JPXbZk5P2vk1WudZbwhVk2KrtgDrRPK9uaPcryLIFdBVL6l4ct2SdyBq7WI0htPXinMhjACuaN7x6RL7rhAeS3Esa6h9kNPoB3mAsFkzr9ysCFnlLajh8a0KlkJcAplKYvPOXVbrnEJ3mdfH3rzIaCxjCM4FU69K7ht|read:all| 67 | |o8SA8aJMn1EaBjMS3l6UPdLPZ931T9|CQTdYDISBUy7YFfrPHAA9R34GHEPmotoGkT9k0JPXbZk5P2vk1WudZbwhVk2KrtgDrRPK9uaPcryLIFdBVL6l4ct2SdyBq7WI0htPXinMhjACuaN7x6RL7rhAeS3Esa6h9kNPoB3mAsFkzr9ysCFnlLajh8a0KlkJcAplKYvPOXVbrnEJ3mdfH3rzIaCxjCM4FU69K7ht|read:all| 68 | 69 | Scenario Outline: 提供的角色名稱長度超過最大值 70 | 71 | Given 提供 , , 72 | When 帶入有問題的輸入 73 | Then 應該返回一個錯誤,說明角色名稱長度超過最大值 74 | 75 | Examples: 76 | |name|description|permissions| 77 | |o8SA8aJMn1EaBjMS3l6UPdLPZ931T9c|QTdYDISBUy7YFfrPHAA9R34GHEPmotoGkT9k0JPXbZk5P2vk1WudZbwhVk2KrtgDrRPK9uaPcryLIFdBVL6l4ct2SdyBq7WI0htPXinMhjACuaN7x6RL7rhAeS3Esa6h9kNPoB3mAsFkzr9ysCFnlLajh8a0KlkJcAplKYvPOXVbrnEJ3mdfH3rzIaCxjCM4FU69K7ht|read:all| 78 | |o8SA8aJMn1EaBjMS3l6UPdLPZ93GT9c|QTdYDISBUy7YFfrPHAA9R34GHEPmotoGkT9k0JPXbZk5P2vk1WudZbwhVk2KrtgDrRPK9uaPcryLIFdBVL6l4ct2SdyBq7WI0htPXinMhjACuaN7x6RL7rhAeS3Esa6h9kNPoB3mAsFkzr9ysCFnlLajh8a0KlkJcAplKYvPOXVbrnEJ3mdfH3rzIaCxjCM4FU69K7ht|read:all| 79 | |o8SA8aJMn1EaBjMS3l6UPdLPZ93GT9casdfasdfasd|QTdYDISBUy7YFfrPHAA9R34GHEPmotoGkT9k0JPXbZk5P2vk1WudZbwhVk2KrtgDrRPK9uaPcryLIFdBVL6l4ct2SdyBq7WI0htPXinMhjACuaN7x6RL7rhAeS3Esa6h9kNPoB3mAsFkzr9ysCFnlLajh8a0KlkJcAplKYvPOXVbrnEJ3mdfH3rzIaCxjCM4FU69K7ht|read:all| -------------------------------------------------------------------------------- /internal/domain/domainerrors/error.go: -------------------------------------------------------------------------------- 1 | package domainerrors 2 | 3 | import ( 4 | "encoding/json" 5 | "strconv" 6 | 7 | "github.com/pkg/errors" 8 | "go.opentelemetry.io/otel/codes" 9 | "go.opentelemetry.io/otel/trace" 10 | ) 11 | 12 | const ( 13 | GruopID = "PAAC" 14 | // Adapter Layer Error Code Offset. 15 | ErrorCodeAdapter = 100000000 16 | ErrorCodeAdapterHTTP = 010000000 17 | ErrorCodeAdapterMessage = 020000000 18 | ErrorCodeAdapterUser = 001000000 19 | ErrorCodeAdapterRole = 002000000 20 | ErrorCodeAdapterGroup = 003000000 21 | ErrorCodeAdapterWallet = 004000000 22 | ErrorCodeAdapterCurrency = 005000000 23 | // Application Layer Error Code Offset. 24 | ErrorCodeApplication = 200000000 25 | ErrorCodeApplicationUser = 010000000 26 | ErrorCodeApplicationRole = 020000000 27 | ErrorCodeApplicationGroup = 030000000 28 | ErrorCodeApplicationWallet = 040000000 29 | ErrorCodeApplicationCurrency = 050000000 30 | // Domain Layer Error Code Offset. 31 | ErrorCodeDomain = 300000000 32 | ErrorCodeDomainEntity = 010000000 33 | ErrorCodeDomainEvent = 020000000 34 | ErrorCodeDomainUser = 001000000 35 | ErrorCodeDomainRole = 002000000 36 | ErrorCodeDomainGroup = 003000000 37 | ErrorCodeDomainWallet = 004000000 38 | ErrorCodeDomainCurrency = 005000000 39 | // Infra Layer Error Code Offset. 40 | ErrorCodeInfra = 200000000 41 | ErrorCodeInfraDatasource = 010000000 42 | ErrorCodeInfraDTO = 020000000 43 | ErrorCodeInfraRepo = 030000000 44 | // Infra Layer Datasource Error Code. 45 | ErrorCodeInfraDatasourceSQL = 001000000 46 | ErrorCodeInfraDatasourceCache = 002000000 47 | ErrorCodeInfraDatasourceEventStore = 003000000 48 | // Infra Layer DTO Error Code. 49 | ErrorCodeInfraDTOMapper = 001000000 50 | ErrorCodeInfraDTOVO = 002000000 51 | ErrorCodeInfraDTOBase = 003000000 52 | ErrorCodeInfraDTOList = 004000000 53 | // Infra Layer Repository Error Code. 54 | ErrorCodeInfraRepoCRUD = 001000000 55 | ErrorCodeInfraRepoTransaction = 002000000 56 | ErrorCodeInfraRepoUser = 003000000 57 | ErrorCodeInfraRepoRole = 004000000 58 | ErrorCodeInfraRepoGroup = 005000000 59 | ErrorCodeInfraRepoWallet = 006000000 60 | ErrorCodeInfraRepoCurrency = 007000000 61 | 62 | ErrorCodeSystem = 900000000 63 | ) 64 | 65 | type ErrorInfo struct { 66 | Code string `json:"code"` 67 | Message string `json:"error"` 68 | Err interface{} `json:"-"` 69 | } 70 | 71 | // ErrorInfo implements the error interface. 72 | func (e *ErrorInfo) Error() string { 73 | return e.Message 74 | } 75 | 76 | func (e *ErrorInfo) MarshalJSON() ([]byte, error) { 77 | return json.Marshal(&struct { 78 | Code string `json:"code"` 79 | Message string `json:"error"` 80 | }{ 81 | Code: e.Code, 82 | Message: e.Message, 83 | }) 84 | } 85 | 86 | // New returns a new error with an error code and error message. 87 | func New(code string, msg string) *ErrorInfo { 88 | return &ErrorInfo{Code: code, Message: msg} 89 | } 90 | 91 | // Wrap returns a new error with an error code and error message, wrapping an existing error. 92 | func Wrap(errorCode int, err error) *ErrorInfo { 93 | // Check if error code is adapter error code 94 | var group = "" 95 | if errorCode >= ErrorCodeSystem { 96 | group = GruopID 97 | } 98 | 99 | // Check if err is already a ErrorInfo 100 | var e *ErrorInfo 101 | if errors.As(err, &e) { 102 | code, atoiErr := strconv.Atoi(e.Code) 103 | if atoiErr != nil { 104 | code = 0 105 | } 106 | 107 | return &ErrorInfo{ 108 | Code: group + strconv.Itoa(errorCode+code), 109 | Message: e.Message, 110 | Err: errors.WithStack(err)} 111 | } 112 | 113 | return &ErrorInfo{Code: group + strconv.Itoa(errorCode), Message: err.Error(), Err: errors.WithStack(err)} 114 | } 115 | 116 | func WrapWithSpan(errorCode int, err error, span trace.Span) *ErrorInfo { 117 | span.SetStatus(codes.Error, err.Error()) 118 | 119 | return Wrap(errorCode, err) 120 | } 121 | 122 | // Cause returns the underlying cause of an error, if available. 123 | func Cause(err error) error { 124 | return errors.Cause(err) 125 | } 126 | 127 | // IsErrorCode returns true if the given error has the given error code. 128 | func IsErrorCode(err error, code string) bool { 129 | var e *ErrorInfo 130 | if errors.As(err, &e) { 131 | return e.Code == code 132 | } 133 | 134 | return false 135 | } 136 | -------------------------------------------------------------------------------- /tests/mocks/role/RoleService_mock.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: internal/application/role/interface.go 3 | 4 | // Package role is a generated GoMock package. 5 | package role 6 | 7 | import ( 8 | context "context" 9 | reflect "reflect" 10 | 11 | gomock "github.com/golang/mock/gomock" 12 | role "github.com/program-world-labs/DDDGo/internal/application/role" 13 | ) 14 | 15 | // MockIService is a mock of IService interface. 16 | type MockIService struct { 17 | ctrl *gomock.Controller 18 | recorder *MockIServiceMockRecorder 19 | } 20 | 21 | // MockIServiceMockRecorder is the mock recorder for MockIService. 22 | type MockIServiceMockRecorder struct { 23 | mock *MockIService 24 | } 25 | 26 | // NewMockIService creates a new mock instance. 27 | func NewMockIService(ctrl *gomock.Controller) *MockIService { 28 | mock := &MockIService{ctrl: ctrl} 29 | mock.recorder = &MockIServiceMockRecorder{mock} 30 | return mock 31 | } 32 | 33 | // EXPECT returns an object that allows the caller to indicate expected use. 34 | func (m *MockIService) EXPECT() *MockIServiceMockRecorder { 35 | return m.recorder 36 | } 37 | 38 | // CreateRole mocks base method. 39 | func (m *MockIService) CreateRole(ctx context.Context, roleInfo *role.CreatedInput) (*role.Output, error) { 40 | m.ctrl.T.Helper() 41 | ret := m.ctrl.Call(m, "CreateRole", ctx, roleInfo) 42 | ret0, _ := ret[0].(*role.Output) 43 | ret1, _ := ret[1].(error) 44 | return ret0, ret1 45 | } 46 | 47 | // CreateRole indicates an expected call of CreateRole. 48 | func (mr *MockIServiceMockRecorder) CreateRole(ctx, roleInfo interface{}) *gomock.Call { 49 | mr.mock.ctrl.T.Helper() 50 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateRole", reflect.TypeOf((*MockIService)(nil).CreateRole), ctx, roleInfo) 51 | } 52 | 53 | // DeleteRole mocks base method. 54 | func (m *MockIService) DeleteRole(ctx context.Context, roleInfo *role.DeletedInput) (*role.Output, error) { 55 | m.ctrl.T.Helper() 56 | ret := m.ctrl.Call(m, "DeleteRole", ctx, roleInfo) 57 | ret0, _ := ret[0].(*role.Output) 58 | ret1, _ := ret[1].(error) 59 | return ret0, ret1 60 | } 61 | 62 | // DeleteRole indicates an expected call of DeleteRole. 63 | func (mr *MockIServiceMockRecorder) DeleteRole(ctx, roleInfo interface{}) *gomock.Call { 64 | mr.mock.ctrl.T.Helper() 65 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteRole", reflect.TypeOf((*MockIService)(nil).DeleteRole), ctx, roleInfo) 66 | } 67 | 68 | // GetRoleDetail mocks base method. 69 | func (m *MockIService) GetRoleDetail(ctx context.Context, roleInfo *role.DetailGotInput) (*role.Output, error) { 70 | m.ctrl.T.Helper() 71 | ret := m.ctrl.Call(m, "GetRoleDetail", ctx, roleInfo) 72 | ret0, _ := ret[0].(*role.Output) 73 | ret1, _ := ret[1].(error) 74 | return ret0, ret1 75 | } 76 | 77 | // GetRoleDetail indicates an expected call of GetRoleDetail. 78 | func (mr *MockIServiceMockRecorder) GetRoleDetail(ctx, roleInfo interface{}) *gomock.Call { 79 | mr.mock.ctrl.T.Helper() 80 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetRoleDetail", reflect.TypeOf((*MockIService)(nil).GetRoleDetail), ctx, roleInfo) 81 | } 82 | 83 | // GetRoleList mocks base method. 84 | func (m *MockIService) GetRoleList(ctx context.Context, roleInfo *role.ListGotInput) (*role.OutputList, error) { 85 | m.ctrl.T.Helper() 86 | ret := m.ctrl.Call(m, "GetRoleList", ctx, roleInfo) 87 | ret0, _ := ret[0].(*role.OutputList) 88 | ret1, _ := ret[1].(error) 89 | return ret0, ret1 90 | } 91 | 92 | // GetRoleList indicates an expected call of GetRoleList. 93 | func (mr *MockIServiceMockRecorder) GetRoleList(ctx, roleInfo interface{}) *gomock.Call { 94 | mr.mock.ctrl.T.Helper() 95 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetRoleList", reflect.TypeOf((*MockIService)(nil).GetRoleList), ctx, roleInfo) 96 | } 97 | 98 | // UpdateRole mocks base method. 99 | func (m *MockIService) UpdateRole(ctx context.Context, roleInfo *role.UpdatedInput) (*role.Output, error) { 100 | m.ctrl.T.Helper() 101 | ret := m.ctrl.Call(m, "UpdateRole", ctx, roleInfo) 102 | ret0, _ := ret[0].(*role.Output) 103 | ret1, _ := ret[1].(error) 104 | return ret0, ret1 105 | } 106 | 107 | // UpdateRole indicates an expected call of UpdateRole. 108 | func (mr *MockIServiceMockRecorder) UpdateRole(ctx, roleInfo interface{}) *gomock.Call { 109 | mr.mock.ctrl.T.Helper() 110 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateRole", reflect.TypeOf((*MockIService)(nil).UpdateRole), ctx, roleInfo) 111 | } 112 | -------------------------------------------------------------------------------- /tests/mocks/user/UserService_mock.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: internal/application/user/interface.go 3 | 4 | // Package user is a generated GoMock package. 5 | package user 6 | 7 | import ( 8 | context "context" 9 | reflect "reflect" 10 | 11 | gomock "github.com/golang/mock/gomock" 12 | user "github.com/program-world-labs/DDDGo/internal/application/user" 13 | ) 14 | 15 | // MockIService is a mock of IService interface. 16 | type MockIService struct { 17 | ctrl *gomock.Controller 18 | recorder *MockIServiceMockRecorder 19 | } 20 | 21 | // MockIServiceMockRecorder is the mock recorder for MockIService. 22 | type MockIServiceMockRecorder struct { 23 | mock *MockIService 24 | } 25 | 26 | // NewMockIService creates a new mock instance. 27 | func NewMockIService(ctrl *gomock.Controller) *MockIService { 28 | mock := &MockIService{ctrl: ctrl} 29 | mock.recorder = &MockIServiceMockRecorder{mock} 30 | return mock 31 | } 32 | 33 | // EXPECT returns an object that allows the caller to indicate expected use. 34 | func (m *MockIService) EXPECT() *MockIServiceMockRecorder { 35 | return m.recorder 36 | } 37 | 38 | // CreateUser mocks base method. 39 | func (m *MockIService) CreateUser(ctx context.Context, UserInfo *user.CreatedInput) (*user.Output, error) { 40 | m.ctrl.T.Helper() 41 | ret := m.ctrl.Call(m, "CreateUser", ctx, UserInfo) 42 | ret0, _ := ret[0].(*user.Output) 43 | ret1, _ := ret[1].(error) 44 | return ret0, ret1 45 | } 46 | 47 | // CreateUser indicates an expected call of CreateUser. 48 | func (mr *MockIServiceMockRecorder) CreateUser(ctx, UserInfo interface{}) *gomock.Call { 49 | mr.mock.ctrl.T.Helper() 50 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateUser", reflect.TypeOf((*MockIService)(nil).CreateUser), ctx, UserInfo) 51 | } 52 | 53 | // DeleteUser mocks base method. 54 | func (m *MockIService) DeleteUser(ctx context.Context, UserInfo *user.DeletedInput) (*user.Output, error) { 55 | m.ctrl.T.Helper() 56 | ret := m.ctrl.Call(m, "DeleteUser", ctx, UserInfo) 57 | ret0, _ := ret[0].(*user.Output) 58 | ret1, _ := ret[1].(error) 59 | return ret0, ret1 60 | } 61 | 62 | // DeleteUser indicates an expected call of DeleteUser. 63 | func (mr *MockIServiceMockRecorder) DeleteUser(ctx, UserInfo interface{}) *gomock.Call { 64 | mr.mock.ctrl.T.Helper() 65 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteUser", reflect.TypeOf((*MockIService)(nil).DeleteUser), ctx, UserInfo) 66 | } 67 | 68 | // GetUserDetail mocks base method. 69 | func (m *MockIService) GetUserDetail(ctx context.Context, UserInfo *user.DetailGotInput) (*user.Output, error) { 70 | m.ctrl.T.Helper() 71 | ret := m.ctrl.Call(m, "GetUserDetail", ctx, UserInfo) 72 | ret0, _ := ret[0].(*user.Output) 73 | ret1, _ := ret[1].(error) 74 | return ret0, ret1 75 | } 76 | 77 | // GetUserDetail indicates an expected call of GetUserDetail. 78 | func (mr *MockIServiceMockRecorder) GetUserDetail(ctx, UserInfo interface{}) *gomock.Call { 79 | mr.mock.ctrl.T.Helper() 80 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserDetail", reflect.TypeOf((*MockIService)(nil).GetUserDetail), ctx, UserInfo) 81 | } 82 | 83 | // GetUserList mocks base method. 84 | func (m *MockIService) GetUserList(ctx context.Context, UserInfo *user.ListGotInput) (*user.OutputList, error) { 85 | m.ctrl.T.Helper() 86 | ret := m.ctrl.Call(m, "GetUserList", ctx, UserInfo) 87 | ret0, _ := ret[0].(*user.OutputList) 88 | ret1, _ := ret[1].(error) 89 | return ret0, ret1 90 | } 91 | 92 | // GetUserList indicates an expected call of GetUserList. 93 | func (mr *MockIServiceMockRecorder) GetUserList(ctx, UserInfo interface{}) *gomock.Call { 94 | mr.mock.ctrl.T.Helper() 95 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserList", reflect.TypeOf((*MockIService)(nil).GetUserList), ctx, UserInfo) 96 | } 97 | 98 | // UpdateUser mocks base method. 99 | func (m *MockIService) UpdateUser(ctx context.Context, UserInfo *user.UpdatedInput) (*user.Output, error) { 100 | m.ctrl.T.Helper() 101 | ret := m.ctrl.Call(m, "UpdateUser", ctx, UserInfo) 102 | ret0, _ := ret[0].(*user.Output) 103 | ret1, _ := ret[1].(error) 104 | return ret0, ret1 105 | } 106 | 107 | // UpdateUser indicates an expected call of UpdateUser. 108 | func (mr *MockIServiceMockRecorder) UpdateUser(ctx, UserInfo interface{}) *gomock.Call { 109 | mr.mock.ctrl.T.Helper() 110 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUser", reflect.TypeOf((*MockIService)(nil).UpdateUser), ctx, UserInfo) 111 | } 112 | -------------------------------------------------------------------------------- /internal/infra/datasource/event_store/event_store_esdb_impl.go: -------------------------------------------------------------------------------- 1 | package eventstore 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "io" 8 | "strings" 9 | 10 | "github.com/EventStore/EventStore-Client-Go/v3/esdb" 11 | "go.opentelemetry.io/otel" 12 | 13 | "github.com/program-world-labs/DDDGo/internal/domain/domainerrors" 14 | "github.com/program-world-labs/DDDGo/internal/domain/event" 15 | eventstore "github.com/program-world-labs/DDDGo/pkg/event_store" 16 | ) 17 | 18 | const ( 19 | ReadStreadNum = 100 20 | EventSplitNum = 2 21 | ) 22 | 23 | var _ event.Store = (*DBImpl)(nil) 24 | 25 | type DBImpl struct { 26 | esdb *eventstore.StoreDB 27 | mapper *event.TypeMapper 28 | } 29 | 30 | func NewEventStoreDBImpl(esdb *eventstore.StoreDB, mapper *event.TypeMapper) (*DBImpl, error) { 31 | return &DBImpl{esdb: esdb, mapper: mapper}, nil 32 | } 33 | 34 | func (e *DBImpl) Store(ctx context.Context, events []event.DomainEvent, version int) error { 35 | return e.store(ctx, events, version, false) 36 | } 37 | 38 | func (e *DBImpl) SafeStore(ctx context.Context, events []event.DomainEvent, expectedVersion int) error { 39 | return e.store(ctx, events, expectedVersion, true) 40 | } 41 | 42 | func (e *DBImpl) Load(ctx context.Context, streamID string, version int) ([]event.DomainEvent, error) { 43 | return e.loadFrom(ctx, streamID, uint64(version)) 44 | } 45 | 46 | func (e *DBImpl) Close() error { 47 | return e.esdb.Close() 48 | } 49 | 50 | func (e *DBImpl) store(ctx context.Context, events []event.DomainEvent, version int, safe bool) error { 51 | if len(events) == 0 { 52 | return nil 53 | } 54 | 55 | // Build all event records, with incrementing versions starting from the 56 | // original aggregate version. 57 | eventsDatas := make([]esdb.EventData, len(events)) 58 | streamID := events[0].AggregateType + "-" + events[0].AggregateID 59 | 60 | for i, event := range events { 61 | // Create the event record with timestamp. 62 | eventsDatas[i] = esdb.EventData{ 63 | EventID: event.GetID(), 64 | EventType: event.GetEventType(), 65 | ContentType: esdb.ContentTypeJson, 66 | } 67 | 68 | // Marshal event data 69 | if event.Data != nil { 70 | rawData, err := json.MarshalIndent(event.Data, "", "\t") 71 | if err != nil { 72 | return err 73 | } 74 | 75 | eventsDatas[i].Data = rawData 76 | } 77 | } 78 | 79 | // Insert a new aggregate or append to an existing. 80 | var option esdb.AppendToStreamOptions 81 | if safe { 82 | // Append to an Exist and Validate Version 83 | // if version != events[len(eventsDatas)-1].GetVersion() { 84 | // return errors.New("Version not Match") 85 | // } 86 | option = esdb.AppendToStreamOptions{ 87 | ExpectedRevision: esdb.Revision(uint64(version)), 88 | } 89 | } else { 90 | // Insert a New Aggregate 91 | option = esdb.AppendToStreamOptions{ 92 | ExpectedRevision: esdb.Any{}, 93 | } 94 | } 95 | 96 | _, err := e.esdb.Client.AppendToStream(ctx, streamID, option, eventsDatas...) 97 | 98 | return err 99 | } 100 | 101 | func (e *DBImpl) loadFrom(ctx context.Context, id string, version uint64) ([]event.DomainEvent, error) { 102 | // 開始追蹤 103 | var tracer = otel.Tracer(domainerrors.GruopID) 104 | _, span := tracer.Start(ctx, "datasource-eventstore-loadFrom") 105 | 106 | defer span.End() 107 | 108 | streamID := id // use the provided id to construct the stream ID 109 | events := make([]event.DomainEvent, 0) 110 | // region read-from-stream-position 111 | ropts := esdb.ReadStreamOptions{ 112 | From: esdb.Revision(version), 113 | } 114 | 115 | stream, err := e.esdb.Client.ReadStream(context.Background(), streamID, ropts, ReadStreadNum) 116 | if err != nil { 117 | return nil, err 118 | } 119 | 120 | defer stream.Close() 121 | 122 | for { 123 | stream, err := stream.Recv() 124 | if err, ok := esdb.FromError(err); !ok { 125 | if err.Code() == esdb.ErrorCodeResourceNotFound { 126 | return nil, domainerrors.WrapWithSpan(ErrorCodeResourceNotFound, err, span) 127 | } else if errors.Is(err, io.EOF) { 128 | break 129 | } else { 130 | return nil, err 131 | } 132 | } 133 | 134 | // Json Unmarshal: To Domain Event Type 135 | eventType, err := e.mapper.NewInstance(stream.Event.EventType) 136 | if err != nil { 137 | return nil, err 138 | } 139 | 140 | err = json.Unmarshal(stream.Event.Data, &eventType) 141 | if err != nil { 142 | return nil, err 143 | } 144 | 145 | // Get Aggregate Type and ID 146 | parts := strings.Split(stream.Event.StreamID, "-") 147 | if len(parts) != EventSplitNum { 148 | return nil, domainerrors.WrapWithSpan(ErrorCodeEventFormatWrong, err, span) 149 | } 150 | 151 | aggregateType := parts[0] 152 | aggregateID := parts[1] 153 | 154 | // Create Domain Event 155 | e := event.NewDomainEvent( 156 | aggregateID, 157 | aggregateType, 158 | int(stream.Event.EventNumber), 159 | eventType, 160 | ) 161 | // Append to Events 162 | events = append( 163 | events, 164 | *e, 165 | ) 166 | } 167 | 168 | return events, nil 169 | } 170 | -------------------------------------------------------------------------------- /internal/application/wallet/dto.go: -------------------------------------------------------------------------------- 1 | package wallet 2 | 3 | import ( 4 | "errors" 5 | "time" 6 | 7 | "github.com/go-playground/validator/v10" 8 | 9 | "github.com/program-world-labs/DDDGo/internal/application/utils" 10 | "github.com/program-world-labs/DDDGo/internal/domain" 11 | "github.com/program-world-labs/DDDGo/internal/domain/entity" 12 | ) 13 | 14 | type CreatedInput struct { 15 | Name string `json:"name"` 16 | Description string `json:"description" validate:"omitempty"` 17 | Chain entity.Chain `json:"chain"` 18 | UserID string `json:"userId" validate:"required,alphanum,len=20"` 19 | } 20 | 21 | func (c *CreatedInput) Validate() error { 22 | validate := validator.New() 23 | 24 | // 提取錯誤訊息 25 | err := validate.Struct(c) 26 | if err != nil { 27 | var e validator.ValidationErrors 28 | if errors.As(err, &e) { 29 | return utils.HandleValidationError("Wallet", e) 30 | } 31 | 32 | return err 33 | } 34 | 35 | return nil 36 | } 37 | 38 | func (c *CreatedInput) ToEntity() *entity.Wallet { 39 | return &entity.Wallet{ 40 | Name: c.Name, 41 | Description: c.Description, 42 | Chain: c.Chain, 43 | UserID: c.UserID, 44 | } 45 | } 46 | 47 | type UpdatedInput struct { 48 | ID string `json:"id"` 49 | Name string `json:"name"` 50 | Description string `json:"description"` 51 | } 52 | 53 | func (c *UpdatedInput) Validate() error { 54 | validate := validator.New() 55 | 56 | // 提取錯誤訊息 57 | err := validate.Struct(c) 58 | if err != nil { 59 | var e validator.ValidationErrors 60 | if errors.As(err, &e) { 61 | return utils.HandleValidationError("Wallet", e) 62 | } 63 | 64 | return err 65 | } 66 | 67 | return nil 68 | } 69 | 70 | func (c *UpdatedInput) ToEntity() *entity.Role { 71 | return &entity.Role{ 72 | ID: c.ID, 73 | Name: c.Name, 74 | Description: c.Description, 75 | } 76 | } 77 | 78 | type DeletedInput struct { 79 | ID string `json:"id" validate:"required,alphanum,len=20"` 80 | } 81 | 82 | func (i *DeletedInput) Validate() error { 83 | err := validator.New().Struct(i) 84 | // 提取錯誤訊息 85 | if err != nil { 86 | var e validator.ValidationErrors 87 | if errors.As(err, &e) { 88 | return utils.HandleValidationError("Wallet", e) 89 | } 90 | 91 | return err 92 | } 93 | 94 | return nil 95 | } 96 | 97 | type DetailGotInput struct { 98 | ID string `json:"id" validate:"required,alphanum,len=20"` 99 | } 100 | 101 | func (i *DetailGotInput) Validate() error { 102 | err := validator.New().Struct(i) 103 | // 提取錯誤訊息 104 | if err != nil { 105 | var e validator.ValidationErrors 106 | if errors.As(err, &e) { 107 | return utils.HandleValidationError("Wallet", e) 108 | } 109 | 110 | return err 111 | } 112 | 113 | return nil 114 | } 115 | 116 | type ListGotInput struct { 117 | Limit int `json:"limit"` 118 | Offset int `json:"offset"` 119 | FilterName string `json:"filterName" validate:"omitempty,oneof=name"` 120 | SortFields []string `json:"sortFields" validate:"dive,oneof=id name updated_at created_at"` 121 | Dir string `json:"dir" validate:"oneof=asc desc"` 122 | } 123 | 124 | func (i *ListGotInput) Validate() error { 125 | err := validator.New().Struct(i) 126 | // 提取錯誤訊息 127 | if err != nil { 128 | var e validator.ValidationErrors 129 | if errors.As(err, &e) { 130 | return utils.HandleValidationError("Wallet", e) 131 | } 132 | 133 | return err 134 | } 135 | 136 | return nil 137 | } 138 | 139 | func (i *ListGotInput) ToSearchQuery() *domain.SearchQuery { 140 | sq := &domain.SearchQuery{ 141 | Page: domain.Page{ 142 | Limit: i.Limit, 143 | Offset: i.Offset, 144 | }, 145 | } 146 | if i.FilterName != "" { 147 | sq.Filters = append(sq.Filters, domain.Filter{ 148 | FilterField: "name", 149 | Operator: "like", 150 | Value: i.FilterName, 151 | }) 152 | } 153 | 154 | if len(i.SortFields) > 0 { 155 | for _, sortField := range i.SortFields { 156 | sq.Orders = append(sq.Orders, domain.Order{ 157 | OrderField: sortField, 158 | Dir: i.Dir, 159 | }) 160 | } 161 | } 162 | 163 | return sq 164 | } 165 | 166 | func (i *ListGotInput) ToEntity() *entity.Role { 167 | return &entity.Role{} 168 | } 169 | 170 | type Output struct { 171 | ID string `json:"id"` 172 | Name string `json:"name"` 173 | Description string `json:"description"` 174 | Chain entity.Chain `json:"chain"` 175 | UserID string `json:"userId"` 176 | CreatedAt time.Time `json:"createdAt"` 177 | UpdatedAt time.Time `json:"updatedAt"` 178 | DeletedAt time.Time `json:"deletedAt"` 179 | } 180 | 181 | type OutputList struct { 182 | Offset int64 `json:"offset"` 183 | Limit int64 `json:"limit"` 184 | Total int64 `json:"total"` 185 | Items []Output `json:"items"` 186 | } 187 | 188 | func NewOutput(e *entity.Wallet) *Output { 189 | return &Output{ 190 | ID: e.ID, 191 | Name: e.Name, 192 | Description: e.Description, 193 | Chain: e.Chain, 194 | UserID: e.UserID, 195 | CreatedAt: e.CreatedAt, 196 | UpdatedAt: e.UpdatedAt, 197 | DeletedAt: e.DeletedAt, 198 | } 199 | } 200 | 201 | func NewListOutput(e *domain.List) *OutputList { 202 | var outputList []Output 203 | for _, v := range e.Data { 204 | outputList = append(outputList, *NewOutput(v.(*entity.Wallet))) 205 | } 206 | 207 | return &OutputList{ 208 | Offset: e.Offset, 209 | Limit: e.Limit, 210 | Total: e.Total, 211 | Items: outputList, 212 | } 213 | } 214 | -------------------------------------------------------------------------------- /internal/application/user/dto.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/go-playground/validator/v10" 7 | 8 | "github.com/program-world-labs/DDDGo/internal/application/utils" 9 | "github.com/program-world-labs/DDDGo/internal/domain" 10 | "github.com/program-world-labs/DDDGo/internal/domain/entity" 11 | ) 12 | 13 | type CreatedInput struct { 14 | Username string `json:"username" validate:"required,lte=30,alphanum"` 15 | Password string `json:"password" validate:"required"` 16 | EMail string `json:"email" validate:"required,email"` 17 | DisplayName string `json:"displayName" validate:"required,lte=30"` 18 | Avatar string `json:"avatar" validate:"required"` 19 | RoleIDs []string `json:"roleIds"` 20 | GroupID string `json:"groupId"` 21 | } 22 | 23 | func (i *CreatedInput) ToEntity() *entity.User { 24 | roles := make([]*entity.Role, 0) 25 | for _, v := range i.RoleIDs { 26 | roles = append(roles, &entity.Role{ID: v}) 27 | } 28 | 29 | return &entity.User{ 30 | Username: i.Username, 31 | Password: i.Password, 32 | EMail: i.EMail, 33 | DisplayName: i.DisplayName, 34 | Avatar: i.Avatar, 35 | Roles: roles, 36 | } 37 | } 38 | 39 | func (i *CreatedInput) Validate() error { 40 | err := validator.New().Struct(i) 41 | // 提取錯誤訊息 42 | if err != nil { 43 | var e validator.ValidationErrors 44 | if errors.As(err, &e) { 45 | return utils.HandleValidationError("User", e) 46 | } 47 | 48 | return err 49 | } 50 | 51 | return nil 52 | } 53 | 54 | type ListGotInput struct { 55 | Limit int `json:"limit"` 56 | Offset int `json:"offset"` 57 | FilterName string `json:"filterName" validate:"omitempty,oneof=name"` 58 | SortFields []string `json:"sortFields" validate:"dive,oneof=id name updated_at created_at"` 59 | Dir string `json:"dir" validate:"oneof=asc desc"` 60 | } 61 | 62 | func (i *ListGotInput) ToSearchQuery() *domain.SearchQuery { 63 | sq := &domain.SearchQuery{ 64 | Page: domain.Page{ 65 | Limit: i.Limit, 66 | Offset: i.Offset, 67 | }, 68 | } 69 | if i.FilterName != "" { 70 | sq.Filters = append(sq.Filters, domain.Filter{ 71 | FilterField: "name", 72 | Operator: "like", 73 | Value: i.FilterName, 74 | }) 75 | } 76 | 77 | if len(i.SortFields) > 0 { 78 | for _, sortField := range i.SortFields { 79 | sq.Orders = append(sq.Orders, domain.Order{ 80 | OrderField: sortField, 81 | Dir: i.Dir, 82 | }) 83 | } 84 | } 85 | 86 | return sq 87 | } 88 | 89 | func (i *ListGotInput) Validate() error { 90 | err := validator.New().Struct(i) 91 | // 提取錯誤訊息 92 | if err != nil { 93 | var e validator.ValidationErrors 94 | if errors.As(err, &e) { 95 | return utils.HandleValidationError("User", e) 96 | } 97 | 98 | return err 99 | } 100 | 101 | return nil 102 | } 103 | 104 | type DetailGotInput struct { 105 | ID string `json:"id" validate:"required,alphanum,len=20"` 106 | } 107 | 108 | func (i *DetailGotInput) Validate() error { 109 | err := validator.New().Struct(i) 110 | // 提取錯誤訊息 111 | if err != nil { 112 | var e validator.ValidationErrors 113 | if errors.As(err, &e) { 114 | return utils.HandleValidationError("User", e) 115 | } 116 | 117 | return err 118 | } 119 | 120 | return nil 121 | } 122 | 123 | type UpdatedInput struct { 124 | ID string `json:"id" validate:"required,alphanum,len=20"` 125 | DisplayName string `json:"displayName" validate:"required,lte=30"` 126 | Avatar string `json:"avatar" validate:"required"` 127 | } 128 | 129 | func (i *UpdatedInput) Validate() error { 130 | err := validator.New().Struct(i) 131 | // 提取錯誤訊息 132 | if err != nil { 133 | var e validator.ValidationErrors 134 | if errors.As(err, &e) { 135 | return utils.HandleValidationError("User", e) 136 | } 137 | 138 | return err 139 | } 140 | 141 | return nil 142 | } 143 | 144 | func (i *UpdatedInput) ToEntity() *entity.User { 145 | return &entity.User{ 146 | ID: i.ID, 147 | DisplayName: i.DisplayName, 148 | Avatar: i.Avatar, 149 | } 150 | } 151 | 152 | type DeletedInput struct { 153 | ID string `json:"id" validate:"required,alphanum,len=20"` 154 | } 155 | 156 | func (i *DeletedInput) Validate() error { 157 | err := validator.New().Struct(i) 158 | // 提取錯誤訊息 159 | if err != nil { 160 | var e validator.ValidationErrors 161 | if errors.As(err, &e) { 162 | return utils.HandleValidationError("User", e) 163 | } 164 | 165 | return err 166 | } 167 | 168 | return nil 169 | } 170 | 171 | type Output struct { 172 | ID string `gorm:"primary_key;"` 173 | Username string `json:"username"` 174 | EMail string `json:"email"` 175 | DisplayName string `json:"display_name"` 176 | Avatar string `json:"avatar"` 177 | } 178 | 179 | func NewOutput(e *entity.User) *Output { 180 | return &Output{ 181 | ID: e.ID, 182 | Username: e.Username, 183 | EMail: e.EMail, 184 | DisplayName: e.DisplayName, 185 | Avatar: e.Avatar, 186 | } 187 | } 188 | 189 | type OutputList struct { 190 | Offset int64 `json:"offset"` 191 | Limit int64 `json:"limit"` 192 | Total int64 `json:"total"` 193 | Items []Output `json:"items"` 194 | } 195 | 196 | func NewListOutput(e *domain.List) *OutputList { 197 | var outputList []Output 198 | for _, v := range e.Data { 199 | outputList = append(outputList, *NewOutput(v.(*entity.User))) 200 | } 201 | 202 | return &OutputList{ 203 | Offset: e.Offset, 204 | Limit: e.Limit, 205 | Total: e.Total, 206 | Items: outputList, 207 | } 208 | } 209 | --------------------------------------------------------------------------------