├── gradle.properties
├── settings.gradle
├── .gitignore
├── gradle
└── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── src
├── test
│ ├── resources
│ │ ├── application-test.yml
│ │ └── application-testweb.yml
│ └── scala
│ │ └── sample
│ │ ├── context
│ │ ├── orm
│ │ │ ├── SortSpec.scala
│ │ │ └── PaginationSpec.scala
│ │ ├── AppSettingSpec.scala
│ │ └── audit
│ │ │ └── AuditActorSpec.scala
│ │ ├── model
│ │ ├── master
│ │ │ ├── HolidaySpec.scala
│ │ │ └── StaffAuthoritySpec.scala
│ │ ├── account
│ │ │ ├── FiAccountSpec.scala
│ │ │ └── AccountSpec.scala
│ │ └── asset
│ │ │ ├── AssetSpec.scala
│ │ │ ├── CashBalanceSpec.scala
│ │ │ ├── CashflowSpec.scala
│ │ │ └── CashInOutSpec.scala
│ │ ├── util
│ │ ├── CalculatorSpec.scala
│ │ ├── ValidatorSpec.scala
│ │ └── ConvertUtilsSpec.scala
│ │ ├── controller
│ │ └── AssetControllerSpec.scala
│ │ ├── ContainerSpecSupport.scala
│ │ ├── UnitSpecSupport.scala
│ │ ├── client
│ │ └── SampleClient.scala
│ │ └── ControllerSpecSupport.scala
└── main
│ ├── scala
│ └── sample
│ │ ├── context
│ │ ├── mail
│ │ │ └── MailHandler.scala
│ │ ├── orm
│ │ │ ├── PagingList.scala
│ │ │ ├── SkinnyOrmMapper.scala
│ │ │ ├── SkinnyOrmMapperWithIdStr.scala
│ │ │ ├── SkinnyOrm.scala
│ │ │ ├── Sort.scala
│ │ │ └── Pagination.scala
│ │ ├── report
│ │ │ ├── ReportFile.scala
│ │ │ └── ReportHandler.scala
│ │ ├── Enums.scala
│ │ ├── Dto.scala
│ │ ├── security
│ │ │ ├── SecurityFilters.scala
│ │ │ ├── SecurityProperties.scala
│ │ │ └── SecurityActorFinder.scala
│ │ ├── actor
│ │ │ ├── ActorSession.scala
│ │ │ └── Actor.scala
│ │ ├── AppSettingHandler.scala
│ │ ├── Entity.scala
│ │ ├── DomainHelper.scala
│ │ ├── Timestamper.scala
│ │ ├── lock
│ │ │ └── IdLockHandler.scala
│ │ ├── ResourceBundleHandler.scala
│ │ ├── AppSetting.scala
│ │ └── audit
│ │ │ ├── AuditEvent.scala
│ │ │ ├── AuditActor.scala
│ │ │ └── AuditHandler.scala
│ │ ├── model
│ │ ├── DomainErrorKeys.scala
│ │ ├── asset
│ │ │ ├── Remarks.scala
│ │ │ ├── AssetErrorKeys.scala
│ │ │ ├── Asset.scala
│ │ │ ├── CashBalance.scala
│ │ │ └── Cashflow.scala
│ │ ├── master
│ │ │ ├── Staff.scala
│ │ │ ├── StaffAuthority.scala
│ │ │ ├── SelfFiAccount.scala
│ │ │ └── Holiday.scala
│ │ ├── account
│ │ │ ├── FiAccount.scala
│ │ │ ├── Login.scala
│ │ │ └── Account.scala
│ │ └── BusinessDayHandler.scala
│ │ ├── util
│ │ ├── Checker.scala
│ │ ├── TimePoint.scala
│ │ ├── Validator.scala
│ │ ├── Calculator.scala
│ │ ├── DateUtils.scala
│ │ └── ConvertUtils.scala
│ │ ├── usecase
│ │ ├── ServiceUtils.scala
│ │ ├── job
│ │ │ └── ServiceJobExecutor.scala
│ │ ├── AccountService.scala
│ │ ├── mail
│ │ │ └── ServiceMailDeliver.scala
│ │ ├── MasterAdminService.scala
│ │ ├── SystemAdminService.scala
│ │ ├── report
│ │ │ └── ServiceReportExporter.scala
│ │ ├── AssetService.scala
│ │ ├── ServiceSupport.scala
│ │ ├── AssetAdminService.scala
│ │ └── SecurityService.scala
│ │ ├── ApplicationDbConfig.scala
│ │ ├── controller
│ │ ├── filter
│ │ │ └── FilterConfig.scala
│ │ ├── RestErrorController.scala
│ │ ├── AccountController.scala
│ │ ├── system
│ │ │ └── JobController.scala
│ │ ├── LoginInterceptor.scala
│ │ ├── admin
│ │ │ ├── AssetAdminController.scala
│ │ │ ├── MasterAdminController.scala
│ │ │ └── SystemAdminController.scala
│ │ ├── AssetController.scala
│ │ └── ControllerSupport.scala
│ │ ├── InvocationException.scala
│ │ ├── Warn.scala
│ │ ├── ErrorKeys.scala
│ │ ├── Application.scala
│ │ ├── ValidationException.scala
│ │ ├── ActionStatusType.scala
│ │ ├── ApplicationConfig.scala
│ │ └── ApplicationSecurityConfig.scala
│ ├── resources
│ ├── banner.txt
│ ├── messages.properties
│ ├── application.conf
│ ├── logback-spring.xml
│ ├── application.yml
│ ├── ehcache.xml
│ └── messages-validation.properties
│ └── java
│ └── sample
│ ├── model
│ └── constraints
│ │ ├── YearEmpty.java
│ │ ├── ISODateEmpty.java
│ │ ├── ISODateTimeEmpty.java
│ │ ├── ISODate.java
│ │ ├── Year.java
│ │ ├── ISODateTime.java
│ │ ├── Amount.java
│ │ ├── AmountEmpty.java
│ │ ├── CurrencyEmpty.java
│ │ ├── Currency.java
│ │ ├── IdStrEmpty.java
│ │ ├── NameEmpty.java
│ │ ├── AbsAmountEmpty.java
│ │ ├── AbsAmount.java
│ │ ├── OutlineEmpty.java
│ │ ├── DescriptionEmpty.java
│ │ ├── IdStr.java
│ │ ├── EmailEmpty.java
│ │ ├── Name.java
│ │ ├── CategoryEmpty.java
│ │ ├── Outline.java
│ │ ├── Category.java
│ │ ├── Description.java
│ │ ├── Email.java
│ │ └── Password.java
│ └── util
│ └── JavaRegex.java
├── LICENSE
└── gradlew.bat
/gradle.properties:
--------------------------------------------------------------------------------
1 | org.gradle.jvmargs=-Dfile.encoding=UTF-8
2 |
--------------------------------------------------------------------------------
/settings.gradle:
--------------------------------------------------------------------------------
1 | rootProject.name = 'sample-boot-scala'
2 |
3 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .gradle
2 | .settings
3 | .classpath
4 | .project
5 | /bin
6 | /build
7 | .idea
8 | .cache-*
9 | *.iml
10 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jkazama/sample-boot-scala/HEAD/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/src/test/resources/application-test.yml:
--------------------------------------------------------------------------------
1 | ---
2 | spring:
3 | profiles: test
4 |
5 | logging.config: classpath:logback-spring.xml
6 |
7 | extension:
8 | security.auth.enabled: false
9 |
--------------------------------------------------------------------------------
/src/test/resources/application-testweb.yml:
--------------------------------------------------------------------------------
1 | ---
2 | spring:
3 | profiles: testweb
4 |
5 | logging.config: classpath:logback-spring.xml
6 |
7 | extension:
8 | security.auth.enabled: false
9 |
--------------------------------------------------------------------------------
/src/main/scala/sample/context/mail/MailHandler.scala:
--------------------------------------------------------------------------------
1 | package sample.context.mail
2 |
3 | /**
4 | * メール処理を行います。
5 | * low: サンプルでは概念クラスだけ提供します。実装はSpringが提供するメールコンポーネントを利用してください。
6 | */
7 | class MailHandler {
8 |
9 | }
--------------------------------------------------------------------------------
/src/main/scala/sample/context/orm/PagingList.scala:
--------------------------------------------------------------------------------
1 | package sample.context.orm
2 |
3 | import sample.context.Dto
4 |
5 | /**
6 | * ページング一覧を表現します。
7 | */
8 | case class PagingList[T](list: Seq[T], page: Pagination) extends Dto
9 |
--------------------------------------------------------------------------------
/src/main/resources/banner.txt:
--------------------------------------------------------------------------------
1 | ======================================================================================
2 | Start Application [ sample-boot-scala ]
3 | --------------------------------------------------------------------------------------
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | distributionBase=GRADLE_USER_HOME
2 | distributionPath=wrapper/dists
3 | distributionUrl=https\://services.gradle.org/distributions/gradle-5.1.1-bin.zip
4 | zipStoreBase=GRADLE_USER_HOME
5 | zipStorePath=wrapper/dists
6 |
--------------------------------------------------------------------------------
/src/main/scala/sample/context/report/ReportFile.scala:
--------------------------------------------------------------------------------
1 | package sample.context.report
2 |
3 | import sample.context.Dto
4 |
5 | /** ファイルイメージを表現します。 */
6 | case class ReportFile(
7 | name: String,
8 | data: Array[Byte]) extends Dto {
9 | def size = data.length
10 | }
11 |
--------------------------------------------------------------------------------
/src/main/scala/sample/model/DomainErrorKeys.scala:
--------------------------------------------------------------------------------
1 | package sample.model
2 |
3 | /** 汎用ドメインで用いるメッセージキー定数 */
4 | trait DomainErrorKeys {
5 | /** マイナスを含めない数字を入力してください */
6 | val AbsAmountZero = "error.domain.AbsAmount.zero"
7 | }
8 | object DomainErrorKeys extends DomainErrorKeys
9 |
--------------------------------------------------------------------------------
/src/main/resources/messages.properties:
--------------------------------------------------------------------------------
1 | # サービスで利用されるラベルメッセージファイルです。
2 |
3 | # -- 定数 [Remarks]
4 | Remarks.cashIn=振込入金
5 | Remarks.cashInAdjust=振込入金(調整)
6 | Remarks.cashInCancel=振込入金(取消)
7 | Remarks.cashOut=振込出金
8 | Remarks.cashOutAdjust=振込出金(調整)
9 | Remarks.cashOutCancel=振込出金(取消)
10 |
11 |
--------------------------------------------------------------------------------
/src/main/scala/sample/context/report/ReportHandler.scala:
--------------------------------------------------------------------------------
1 | package sample.context.report
2 |
3 | /**
4 | * 帳票処理を行います。
5 | * low: サンプルでは概念クラスだけ提供します。実際はCSV/固定長/Excel/PDFなどの取込/出力を取り扱います。
6 | * low: ExcelはPOI、PDFはJasperReportの利用が一般的です。(商用製品を利用するのもおすすめです)
7 | */
8 | class ReportHandler {
9 |
10 | }
--------------------------------------------------------------------------------
/src/main/resources/application.conf:
--------------------------------------------------------------------------------
1 | development {
2 | db {
3 | default {
4 | driver="org.h2.Driver"
5 | url="jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE;MVCC=TRUE"
6 | user=""
7 | password=""
8 | poolInitialSize=2
9 | poolMaxSize=10
10 | }
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/src/main/scala/sample/util/Checker.scala:
--------------------------------------------------------------------------------
1 | package sample.util
2 |
3 | /**
4 | * 簡易的な入力チェッカーを表現します。
5 | */
6 | trait Checker {
7 | /** 文字桁数チェック、max以下の時はtrue。(サロゲートペア対応) */
8 | def len(v: String, max: Int): Boolean = wordSize(v) <= max
9 | private def wordSize(v: String): Int = v.codePointCount(0, v.length)
10 | }
11 | object Checker extends Checker
12 |
--------------------------------------------------------------------------------
/src/main/resources/logback-spring.xml:
--------------------------------------------------------------------------------
1 |
2 |
本インターフェースを継承するDTOは、層(レイヤー)間をまたいで情報を取り扱い可能に 7 | * する役割を持ち、次の責務を果たします。 8 | *
独自にトランザクションを管理するので、サービスのトランザクション内で 呼び出さないように注意してください。 10 | */ 11 | @Component 12 | class ServiceJobExecutor { 13 | 14 | /** トランザクション処理を実行します。 */ 15 | private def tx[T](callable: DBSession => T): T = 16 | DB.localTx(implicit session => callable(session)) 17 | 18 | } -------------------------------------------------------------------------------- /src/main/scala/sample/controller/filter/FilterConfig.scala: -------------------------------------------------------------------------------- 1 | package sample.controller.filter 2 | 3 | import org.springframework.context.annotation.Configuration 4 | 5 | import javax.servlet.Filter 6 | import sample.context.security.SecurityFilters 7 | 8 | /** 9 | * ServletFilterの拡張実装。 10 | * filtersで返すFilterはSecurityHandlerにおいてActionSessionFilterの後に定義されます。 11 | */ 12 | @Configuration 13 | class FilterConfig extends SecurityFilters { 14 | 15 | override def filters(): Seq[Filter] = Seq() 16 | 17 | } -------------------------------------------------------------------------------- /src/main/scala/sample/context/security/SecurityFilters.scala: -------------------------------------------------------------------------------- 1 | package sample.context.security 2 | 3 | import javax.servlet.Filter 4 | 5 | /** 6 | * Spring Securityに対するFilter拡張設定。 7 | *
Filterを追加したい時は本I/Fを継承してBean登録してください。 8 | */ 9 | trait SecurityFilters { 10 | 11 | /** 12 | * Spring SecurityへFilter登録するServletFilter一覧を返します。 13 | *
登録したFilterはUsernamePasswordAuthenticationFilter/ActorSessionFilterの後に 14 | * 実行されるのでActorSessionからログイン利用者の情報を取ることが可能です。 15 | */ 16 | def filters(): Seq[Filter] 17 | 18 | } -------------------------------------------------------------------------------- /src/main/scala/sample/context/orm/SkinnyOrmMapperWithIdStr.scala: -------------------------------------------------------------------------------- 1 | package sample.context.orm 2 | 3 | import skinny.orm.SkinnyCRUDMapperWithId 4 | 5 | trait SkinnyORMMapperWithIdStr[Entity] extends SkinnyCRUDMapperWithId[String, Entity] { 6 | override lazy val defaultAlias = createAlias(aliasName) 7 | def aliasName = getClass.getSimpleName.substring(0, 1).toLowerCase 8 | override def useExternalIdGenerator = true 9 | override def idToRawValue(id: String): String = id 10 | override def rawValueToId(rawValue: Any): String = rawValue.toString 11 | } 12 | -------------------------------------------------------------------------------- /src/test/scala/sample/context/orm/SortSpec.scala: -------------------------------------------------------------------------------- 1 | package sample.context.orm 2 | 3 | import org.junit.runner.RunWith 4 | 5 | import org.scalatest._ 6 | import org.scalatest.junit.JUnitRunner 7 | 8 | @RunWith(classOf[JUnitRunner]) 9 | class SortSpec extends WordSpec with Matchers { 10 | 11 | "ソート検証" should { 12 | "初期化" in { 13 | Sort(List(SortOrder.asc("a"), SortOrder.asc("b"))).orders.nonEmpty should be (true) 14 | Sort.ascBy("a").orders.nonEmpty should be (true) 15 | Sort.descBy("a").orders.nonEmpty should be (true) 16 | } 17 | } 18 | } -------------------------------------------------------------------------------- /src/main/scala/sample/InvocationException.scala: -------------------------------------------------------------------------------- 1 | package sample 2 | 3 | /** 4 | * 処理時の実行例外を表現します。 5 | *
復旧不可能なシステム例外をラップする目的で利用してください。 6 | */ 7 | class InvocationException(val message: String, val cause: Throwable) extends RuntimeException(message, cause) 8 | 9 | object InvocationException { 10 | def apply(message: String, cause: Throwable): InvocationException = new InvocationException(message, cause) 11 | def apply(message: String): InvocationException = apply(message, null) 12 | def apply(cause: Throwable): InvocationException = apply(null, cause) 13 | } 14 | -------------------------------------------------------------------------------- /src/main/scala/sample/context/actor/ActorSession.scala: -------------------------------------------------------------------------------- 1 | package sample.context.actor 2 | 3 | /** スレッドローカルスコープの利用者セッション。 */ 4 | class ActorSession { 5 | val actorLocal = new ThreadLocal[Actor]() 6 | 7 | /** 利用者セッションへ利用者を紐付けます。 */ 8 | def bind(actor: Actor): ActorSession = { 9 | actorLocal.set(actor) 10 | this 11 | } 12 | 13 | /** 利用者セッションを破棄します。 */ 14 | def unbind(): ActorSession = { 15 | actorLocal.remove() 16 | this 17 | } 18 | 19 | /** 有効な利用者を返します。紐付けされていない時は匿名者が返されます。 */ 20 | def actor: Actor = Option(actorLocal.get()).getOrElse(Actor.Anonymous) 21 | } -------------------------------------------------------------------------------- /src/main/scala/sample/context/orm/SkinnyOrm.scala: -------------------------------------------------------------------------------- 1 | package sample.context.orm 2 | 3 | import javax.annotation.{PreDestroy, PostConstruct} 4 | import scalikejdbc.{LoggingSQLAndTimeSettings, GlobalSettings} 5 | import skinny.DBSettings 6 | 7 | /** 8 | * SkinnyのORM初期定義を行います。 9 | */ 10 | class SkinnyOrm { 11 | 12 | @PostConstruct 13 | def initialize() = { 14 | GlobalSettings.loggingSQLAndTime = new LoggingSQLAndTimeSettings( 15 | enabled = true, 16 | singleLineMode = true, 17 | logLevel = 'DEBUG 18 | ) 19 | DBSettings.initialize() 20 | } 21 | 22 | @PreDestroy 23 | def destroy() = DBSettings.destroy() 24 | } 25 | -------------------------------------------------------------------------------- /src/test/scala/sample/model/master/HolidaySpec.scala: -------------------------------------------------------------------------------- 1 | package sample.model.master 2 | 3 | import java.time.LocalDate 4 | 5 | import org.junit.runner.RunWith 6 | import org.scalatest.junit.JUnitRunner 7 | 8 | import sample._ 9 | 10 | @RunWith(classOf[JUnitRunner]) 11 | class HolidaySpec extends UnitSpecSupport { 12 | behavior of "休日管理" 13 | 14 | it should "休日を登録できる" in { implicit session => 15 | Holiday.register(RegHoliday(year = 2015, list = Seq( 16 | RegHolidayItem(LocalDate.of(2015, 11, 3), "test"), 17 | RegHolidayItem(LocalDate.of(2015, 11, 23), "test")))) 18 | Holiday.findAll().size should be (2) 19 | } 20 | 21 | } -------------------------------------------------------------------------------- /src/main/scala/sample/model/asset/AssetErrorKeys.scala: -------------------------------------------------------------------------------- 1 | package sample.model.asset 2 | 3 | /** 資産の審査例外で用いるメッセージキー定数 */ 4 | trait AssetErrorKeys { 5 | /** 受渡日を迎えていないため実現できません */ 6 | val CashflowRealizeDay = "error.Cashflow.realizeDay" 7 | /** 既に受渡日を迎えています */ 8 | val CashflowBeforeEqualsDay = "error.Cashflow.beforeEqualsDay" 9 | 10 | /** 未到来の受渡日です */ 11 | val CashInOutAfterEqualsDay = "error.CashInOut.afterEqualsDay" 12 | /** 既に発生日を迎えています */ 13 | val CashInOutBeforeEqualsDay = "error.CashInOut.beforeEqualsDay" 14 | /** 出金可能額を超えています */ 15 | val CashInOutWithdrawAmount = "error.CashInOut.withdrawAmount" 16 | } 17 | object AssetErrorKeys extends AssetErrorKeys -------------------------------------------------------------------------------- /src/main/scala/sample/Warn.scala: -------------------------------------------------------------------------------- 1 | package sample 2 | 3 | /** 4 | * フィールドスコープの審査例外トークン。 5 | */ 6 | case class Warn(field: Option[String], message: String, messageArgs: Option[Array[AnyRef]]) { 7 | /** フィールドに従属しないグローバル例外時はtrue */ 8 | def global(): Boolean = field.isEmpty 9 | } 10 | object Warn { 11 | def apply(field: String, message: String, messageArgs: Array[AnyRef]): Warn = Warn(Option(field), message, Option(messageArgs)) 12 | def apply(field: String, message: String): Warn = apply(field, message, null) 13 | def apply(message: String, messageArgs: Array[AnyRef]): Warn = apply(null, message, messageArgs) 14 | def apply(message: String): Warn = apply(null, message) 15 | } 16 | -------------------------------------------------------------------------------- /src/main/scala/sample/context/AppSettingHandler.scala: -------------------------------------------------------------------------------- 1 | package sample.context 2 | 3 | import org.springframework.cache.annotation.{CacheEvict, Cacheable} 4 | 5 | /** 6 | * アプリケーション設定情報に対するアクセス手段を提供します。 7 | */ 8 | class AppSettingHandler { 9 | 10 | /** アプリケーション設定情報を取得します。 */ 11 | @Cacheable(cacheNames = Array("AppSettingHandler.appSetting"), key = "#id") 12 | def setting(id: String): AppSetting = AppSetting.load(id) 13 | 14 | /** アプリケーション設定情報を変更します。 */ 15 | @CacheEvict(cacheNames = Array("AppSettingHandler.appSetting"), key = "#id") 16 | def update(id: String, value: String): AppSettingHandler = { 17 | AppSetting.update(id, value) 18 | this 19 | } 20 | 21 | } 22 | -------------------------------------------------------------------------------- /src/main/scala/sample/ErrorKeys.scala: -------------------------------------------------------------------------------- 1 | package sample 2 | 3 | /** 審査例外で用いるメッセージキー定数 */ 4 | trait ErrorKeys { 5 | /** サーバー側で問題が発生した可能性があります */ 6 | val Exception = "error.Exception" 7 | /** 情報が見つかりませんでした */ 8 | val EntityNotFound = "error.EntityNotFoundException" 9 | /** ログイン状態が有効ではありません */ 10 | val Authentication = "error.Authentication" 11 | /** 対象機能の利用が認められていません */ 12 | val AccessDenied = "error.AccessDeniedException" 13 | 14 | /** ログインに失敗しました */ 15 | val Login = "error.login" 16 | /** 既に登録されているIDです */ 17 | val DuplicateId = "error.duplicateId" 18 | 19 | /** 既に処理済の情報です */ 20 | val ActionUnprocessing = "error.ActionStatusType.unprocessing" 21 | } 22 | object ErrorKeys extends ErrorKeys 23 | -------------------------------------------------------------------------------- /src/main/scala/sample/context/Entity.scala: -------------------------------------------------------------------------------- 1 | package sample.context 2 | 3 | /** 4 | * ドメインオブジェクトのマーカーインターフェース。 5 | * 6 | *
本インターフェースを継承するドメインオブジェクトは、ある一定の粒度で とりまとめられたドメイン情報と、 7 | * それに関連するビジネスロジックを実行する役割を持ち、 次の責務を果たします。 8 | *
14 | * ドメインモデルの詳細については次の書籍を参考にしてください。 15 | *
本クラスを実行する事でSpringBootが提供する組込Tomcatでのアプリケーション起動が行われます。 11 | */ 12 | @SpringBootApplication 13 | @EnableCaching(proxyTargetClass = true) 14 | @Import(Array(classOf[ApplicationConfig], classOf[ApplicationDbConfig], classOf[ApplicationSecurityConfig])) 15 | class Application 16 | 17 | object Application { 18 | def main(args: Array[String]) { 19 | SpringApplication.run(classOf[Application], args: _*) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/main/scala/sample/model/master/Staff.scala: -------------------------------------------------------------------------------- 1 | package sample.model.master 2 | 3 | import scalikejdbc._ 4 | import sample.context._ 5 | import sample.context.actor.{Actor, ActorRoleType} 6 | import sample.context.orm.SkinnyORMMapperWithIdStr 7 | 8 | /** 9 | * 社員を表現します。 10 | */ 11 | case class Staff( 12 | /** ID */ 13 | id: String, 14 | /** 名前 */ 15 | name: String, 16 | /** パスワード(暗号化済) */ 17 | password: String) extends Entity { 18 | 19 | def actor: Actor = Actor(id, name, ActorRoleType.Internal) 20 | } 21 | 22 | object Staff extends SkinnyORMMapperWithIdStr[Staff] { 23 | override def extract(rs: WrappedResultSet, rn: ResultName[Staff]): Staff = autoConstruct(rs, rn) 24 | 25 | def get(id: String)(implicit s: DBSession): Option[Staff] = findById(id) 26 | } 27 | -------------------------------------------------------------------------------- /src/main/scala/sample/model/master/StaffAuthority.scala: -------------------------------------------------------------------------------- 1 | package sample.model.master 2 | 3 | import scalikejdbc._ 4 | import sample.context._ 5 | import sample.context.orm.SkinnyORMMapper 6 | 7 | /** 8 | * 社員に割り当てられた権限を表現します。 9 | */ 10 | case class StaffAuthority( 11 | /** ID */ 12 | id: Long, 13 | /** 社員ID */ 14 | staffId: String, 15 | /** 権限名称。(「プリフィックスにROLE_」を付与してください) */ 16 | authority: String) extends Entity 17 | 18 | object StaffAuthority extends SkinnyORMMapper[StaffAuthority] { 19 | override def extract(rs: WrappedResultSet, rn: ResultName[StaffAuthority]): StaffAuthority = autoConstruct(rs, rn) 20 | 21 | /** 口座IDに紐付く権限一覧を返します。 */ 22 | def findByStaffId(staffId: String)(implicit s: DBSession): List[StaffAuthority] = 23 | withAlias(m => findAllBy(sqls.eq(m.staffId, staffId))) 24 | } -------------------------------------------------------------------------------- /src/main/scala/sample/usecase/mail/ServiceMailDeliver.scala: -------------------------------------------------------------------------------- 1 | package sample.usecase.mail 2 | 3 | import org.springframework.beans.factory.annotation.Autowired 4 | 5 | import org.springframework.stereotype.Component 6 | 7 | import sample.context.mail.MailHandler 8 | import sample.model.asset._ 9 | import scalikejdbc._ 10 | 11 | /** 12 | * アプリケーション層のサービスメール送信を行います。 13 | *
独自にトランザクションを管理するので、サービスのトランザクション内で 14 | * 呼び出さないように注意してください。 15 | */ 16 | @Component 17 | class ServiceMailDeliver { 18 | 19 | @Autowired 20 | private var mail: MailHandler = _ 21 | 22 | /** トランザクション処理を実行します。 */ 23 | private def tx[T](callable: DBSession => T): T = 24 | DB.localTx(implicit session => callable(session)) 25 | 26 | /** 出金依頼受付メールを送信します。 */ 27 | //low: サンプルなので未実装。実際は独自にトランザクションを貼って処理を行う 28 | def sendWithdrawal(cio: CashInOut): Unit = Unit 29 | 30 | } -------------------------------------------------------------------------------- /src/main/scala/sample/ValidationException.scala: -------------------------------------------------------------------------------- 1 | package sample 2 | 3 | /** 4 | * 審査例外を表現します。 5 | */ 6 | class ValidationException(val message: String, val list: Seq[Warn]) extends RuntimeException(message) 7 | 8 | object ValidationException { 9 | def apply(field: String, message: String, messageArgs: Array[AnyRef]): ValidationException = 10 | new ValidationException(message, List(Warn(field, message, messageArgs))) 11 | /** フィールドに従属する審査例外を通知するケースで利用してください。 */ 12 | def apply(field: String, message: String): ValidationException = apply(field, message, null) 13 | /** フィールドに従属しないグローバルな審査例外を通知するケースで利用してください。 */ 14 | def apply(message: String): ValidationException = apply(null, message) 15 | /** 複数件の審査例外を通知するケースで利用してください。 */ 16 | def apply(list: Seq[Warn]): ValidationException = new ValidationException(list.headOption.map(_.message).getOrElse("none"), list) 17 | } 18 | -------------------------------------------------------------------------------- /src/main/scala/sample/context/DomainHelper.scala: -------------------------------------------------------------------------------- 1 | package sample.context 2 | 3 | import org.springframework.beans.factory.annotation.Autowired 4 | 5 | import sample.context.actor.{Actor, ActorSession} 6 | 7 | /** 8 | * ドメイン処理を行う上で必要となるインフラ層コンポーネントへのアクセサを提供します。 9 | */ 10 | class DomainHelper { 11 | 12 | /** スレッドローカルスコープの利用者セッションを取得します。 */ 13 | @Autowired 14 | var actorSession: ActorSession = _ 15 | /** 日時ユーティリティを取得します。 */ 16 | @Autowired 17 | var time: Timestamper = _ 18 | @Autowired 19 | var settingHandler: AppSettingHandler = _ 20 | 21 | /** ログイン中のユースケース利用者を取得します。 */ 22 | def actor: Actor = actorSession.actor 23 | 24 | /** アプリケーション設定情報を取得します。 */ 25 | def setting(id: String): AppSetting = settingHandler.setting(id) 26 | 27 | /** アプリケーション設定情報を設定します。 */ 28 | def settingSet(id: String, value: String): Unit = settingHandler.update(id, value) 29 | 30 | } 31 | -------------------------------------------------------------------------------- /src/test/scala/sample/util/CalculatorSpec.scala: -------------------------------------------------------------------------------- 1 | package sample.util 2 | 3 | import scala.math.BigDecimal.RoundingMode 4 | 5 | import org.junit.runner.RunWith 6 | import org.scalatest.{WordSpec} 7 | import org.scalatest.junit.JUnitRunner 8 | import org.scalatest.Matchers 9 | 10 | @RunWith(classOf[JUnitRunner]) 11 | class CalculatorSpec extends WordSpec with Matchers { 12 | 13 | "計算ユーティリティ検証" should { 14 | "四則演算" in { 15 | (Calculator("10.3", 2, RoundingMode.DOWN) + BigDecimal("10.2")).decimal should be (BigDecimal("20.50")) 16 | (Calculator("10.3", 2, RoundingMode.DOWN) - BigDecimal("10.2")).decimal should be (BigDecimal("0.10")) 17 | (Calculator("1.3", 2, RoundingMode.DOWN) * BigDecimal("1.2")).decimal should be (BigDecimal("1.56")) 18 | (Calculator("1", 2, RoundingMode.UP) / BigDecimal("3")).decimal should be (BigDecimal("0.34")) 19 | } 20 | } 21 | 22 | } 23 | -------------------------------------------------------------------------------- /src/main/scala/sample/usecase/MasterAdminService.scala: -------------------------------------------------------------------------------- 1 | package sample.usecase 2 | 3 | import org.springframework.cache.annotation.Cacheable 4 | import org.springframework.stereotype.Service 5 | 6 | import sample.model.master._ 7 | 8 | /** 9 | * サービスマスタドメインに対する社内ユースケース処理。 10 | */ 11 | @Service 12 | class MasterAdminService extends ServiceSupport { 13 | 14 | /** 社員を取得します。 */ 15 | @Cacheable(Array("MasterAdminService.getStaff")) 16 | def getStaff(id: String): Option[Staff] = 17 | tx(implicit session => Staff.get(id)) 18 | 19 | /** 社員権限を取得します。 */ 20 | @Cacheable(Array("MasterAdminService.findStaffAuthority")) 21 | def findStaffAuthority(staffId: String): List[StaffAuthority] = 22 | tx(implicit session => StaffAuthority.findByStaffId(staffId)) 23 | 24 | def registerHoliday(p: RegHoliday): Unit = 25 | audit.audit("休日情報を登録する", 26 | tx(implicit session => Holiday.register(p))) 27 | 28 | } 29 | -------------------------------------------------------------------------------- /src/test/scala/sample/model/account/FiAccountSpec.scala: -------------------------------------------------------------------------------- 1 | package sample.model.account 2 | 3 | import scala.util.{ Failure, Success, Try } 4 | 5 | import org.junit.runner.RunWith 6 | import org.scalatest.junit.JUnitRunner 7 | 8 | import sample._ 9 | import sample.model.DataFixtures 10 | 11 | @RunWith(classOf[JUnitRunner]) 12 | class FiAccountSpec extends UnitSpecSupport { 13 | 14 | behavior of "金融機関口座" 15 | 16 | it should "口座を取得する" in { implicit session => 17 | DataFixtures.saveFiAcc("sample", "cate1", "JPY") 18 | 19 | Try(FiAccount.load("sample", "cate1", "USD")) match { 20 | case Success(v) => fail() 21 | case Failure(e) => e.getMessage should be (ErrorKeys.EntityNotFound) 22 | } 23 | 24 | val acc = FiAccount.load("sample", "cate1", "JPY") 25 | acc.accountId should be ("sample") 26 | acc.category should be ("cate1") 27 | acc.currency should be ("JPY") 28 | } 29 | 30 | } 31 | -------------------------------------------------------------------------------- /src/test/scala/sample/context/audit/AuditActorSpec.scala: -------------------------------------------------------------------------------- 1 | package sample.context.audit 2 | 3 | import java.time.LocalDate 4 | 5 | import org.junit.runner.RunWith 6 | import org.scalatest.junit.JUnitRunner 7 | 8 | import sample._ 9 | import sample.context.actor.ActorRoleType 10 | 11 | @RunWith(classOf[JUnitRunner]) 12 | class AuditActorSpec extends UnitSpecSupport { 13 | 14 | behavior of "利用者監査" 15 | 16 | it should "利用者監査登録/検索" in { implicit session => 17 | val idA = AuditActor.register(RegAuditActor("testA")) 18 | val idB = AuditActor.register(RegAuditActor("testB")) 19 | val idC = AuditActor.register(RegAuditActor("testC")) 20 | AuditActor.finish(idA) 21 | AuditActor.cancel(idB, "取消") 22 | AuditActor.error(idC, "例外") 23 | val list = AuditActor.find(FindAuditActor(roleType = ActorRoleType.Anonymous, fromDay = LocalDate.now(), toDay = LocalDate.now())) 24 | list.list.size should be (3) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/main/scala/sample/controller/RestErrorController.scala: -------------------------------------------------------------------------------- 1 | package sample.controller 2 | 3 | import org.springframework.boot.autoconfigure.web._ 4 | import org.springframework.web.bind.annotation._ 5 | import org.springframework.web.context.request.ServletRequestAttributes 6 | import javax.servlet.http.HttpServletRequest 7 | import org.springframework.beans.factory.annotation.Autowired 8 | 9 | /** 10 | * REST用の例外ハンドリングを行うController。 11 | *
application.ymlの"error.path"属性との組合せで有効化します。 12 | * あわせて"error.whitelabel.enabled: false"でwhitelabelを無効化しておく必要があります。 13 | * see ErrorMvcAutoConfiguration 14 | */ 15 | @RestController 16 | class RestErrorController extends ErrorController { 17 | @Autowired 18 | var errorAttributes: ErrorAttributes = _ 19 | override def getErrorPath() = "/api/error" 20 | 21 | @RequestMapping(Array("/api/error")) 22 | def error(request: HttpServletRequest): java.util.Map[String, Object] = 23 | errorAttributes.getErrorAttributes( 24 | new ServletRequestAttributes(request), false) 25 | 26 | } 27 | -------------------------------------------------------------------------------- /src/main/scala/sample/usecase/SystemAdminService.scala: -------------------------------------------------------------------------------- 1 | package sample.usecase 2 | 3 | import org.springframework.stereotype.Service 4 | 5 | import sample.context._ 6 | import sample.context.audit._ 7 | import sample.context.orm.PagingList 8 | 9 | /** 10 | * システムドメインに対する社内ユースケース処理。 11 | */ 12 | @Service 13 | class SystemAdminService extends ServiceSupport { 14 | 15 | /** 利用者監査ログを検索します。 */ 16 | def findAuditActor(p: FindAuditActor): PagingList[AuditActor] = 17 | tx(implicit session => AuditActor.find(p)) 18 | 19 | /** イベント監査ログを検索します。 */ 20 | def findAuditEvent(p: FindAuditEvent): PagingList[AuditEvent] = 21 | tx(implicit session => AuditEvent.find(p)) 22 | 23 | /** アプリケーション設定一覧を検索します。 */ 24 | def findAppSetting(p: FindAppSetting): Seq[AppSetting] = 25 | tx(implicit session => AppSetting.find(p)) 26 | 27 | def changeAppSetting(id: String, value: String): Unit = 28 | audit.audit("アプリケーション設定情報を変更する", dh.settingSet(id, value)) 29 | 30 | def processDay(): Unit = 31 | audit.audit("営業日を進める", dh.time.proceedDay(businessDay.day(1))) 32 | 33 | } -------------------------------------------------------------------------------- /src/main/scala/sample/model/master/SelfFiAccount.scala: -------------------------------------------------------------------------------- 1 | package sample.model.master 2 | 3 | import scalikejdbc._ 4 | import sample._ 5 | import sample.context._ 6 | import sample.context.orm.SkinnyORMMapper 7 | 8 | /** 9 | * サービス事業者の決済金融機関を表現します。 10 | * low: サンプルなので支店や名称、名義といったなど本来必須な情報をかなり省略しています。(通常は全銀仕様を踏襲します) 11 | */ 12 | case class SelfFiAccount( 13 | /** ID */ 14 | id: Long, 15 | /** 利用用途カテゴリ */ 16 | category: String, 17 | /** 通貨 */ 18 | currency: String, 19 | /** 金融機関コード */ 20 | fiCode: String, 21 | /** 金融機関口座ID */ 22 | fiAccountId: String) extends Entity 23 | 24 | object SelfFiAccount extends SkinnyORMMapper[SelfFiAccount] { 25 | override def extract(rs: WrappedResultSet, rn: ResultName[SelfFiAccount]): SelfFiAccount = autoConstruct(rs, rn) 26 | 27 | def load(category: String, currency: String)(implicit s: DBSession): SelfFiAccount = 28 | SelfFiAccount.withAlias { m => 29 | findBy(sqls.eq(m.category, category).and(sqls.eq(m.currency, currency))).getOrElse( 30 | throw ValidationException(ErrorKeys.EntityNotFound)) 31 | } 32 | } 33 | 34 | -------------------------------------------------------------------------------- /src/test/scala/sample/model/asset/AssetSpec.scala: -------------------------------------------------------------------------------- 1 | package sample.model.asset 2 | 3 | import java.time.LocalDate 4 | 5 | import org.junit.runner.RunWith 6 | import org.scalatest.junit.JUnitRunner 7 | 8 | import sample.UnitSpecSupport 9 | import sample.model.DataFixtures._ 10 | import sample.model.account.AccountStatusType 11 | 12 | //low: 簡易な正常系検証のみ 13 | @RunWith(classOf[JUnitRunner]) 14 | class AssetSpec extends UnitSpecSupport { 15 | 16 | behavior of "資産" 17 | 18 | it should "振込出金可能か判定する" in { implicit session => 19 | saveAcc("test", AccountStatusType.Normal) 20 | saveCb("test", LocalDate.of(2014, 11, 18), "JPY", "10000") 21 | saveCf("test", "1000", LocalDate.of(2014, 11, 18), LocalDate.of(2014, 11, 20)) 22 | saveCf("test", "-2000", LocalDate.of(2014, 11, 19), LocalDate.of(2014, 11, 21)) 23 | saveCio(businessDay, "test", "8000", true) 24 | 25 | Asset("test").canWithdraw("JPY", BigDecimal("1000"), LocalDate.of(2014, 11, 21)) should be (true) 26 | Asset("test").canWithdraw("JPY", BigDecimal("1001"), LocalDate.of(2014, 11, 21)) should be (false) 27 | } 28 | } -------------------------------------------------------------------------------- /src/main/java/sample/model/constraints/YearEmpty.java: -------------------------------------------------------------------------------- 1 | package sample.model.constraints; 2 | 3 | import static java.lang.annotation.ElementType.*; 4 | import static java.lang.annotation.RetentionPolicy.RUNTIME; 5 | 6 | import java.lang.annotation.*; 7 | 8 | import javax.validation.*; 9 | import javax.validation.constraints.*; 10 | 11 | /** 12 | * 年を表現する制約注釈。 13 | */ 14 | @Documented 15 | @Constraint(validatedBy = {}) 16 | @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER }) 17 | @Retention(RUNTIME) 18 | @ReportAsSingleViolation 19 | @Digits(integer = 4, fraction = 0) 20 | @Size 21 | public @interface YearEmpty { 22 | String message() default "{error.domain.year}"; 23 | 24 | Class>[] groups() default {}; 25 | 26 | Class extends Payload>[] payload() default {}; 27 | 28 | @OverridesAttribute(constraint = Size.class, name = "max") 29 | int max() default 4; 30 | 31 | @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER }) 32 | @Retention(RUNTIME) 33 | @Documented 34 | public @interface List { 35 | YearEmpty[] value(); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/main/java/sample/model/constraints/ISODateEmpty.java: -------------------------------------------------------------------------------- 1 | package sample.model.constraints; 2 | 3 | import static java.lang.annotation.ElementType.*; 4 | import static java.lang.annotation.RetentionPolicy.RUNTIME; 5 | 6 | import java.lang.annotation.*; 7 | 8 | import javax.validation.*; 9 | 10 | import org.springframework.format.annotation.DateTimeFormat; 11 | import org.springframework.format.annotation.DateTimeFormat.ISO; 12 | 13 | /** 14 | * ISOフォーマットの日付を表現する制約注釈。 15 | *
YYYY-MM-DDを想定します。 16 | */ 17 | @Documented 18 | @Constraint(validatedBy = {}) 19 | @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER }) 20 | @Retention(RUNTIME) 21 | @ReportAsSingleViolation 22 | @DateTimeFormat(iso = ISO.DATE) 23 | public @interface ISODateEmpty { 24 | String message() default "{error.domain.ISODate}"; 25 | 26 | Class>[] groups() default {}; 27 | 28 | Class extends Payload>[] payload() default {}; 29 | 30 | @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER }) 31 | @Retention(RUNTIME) 32 | @Documented 33 | public @interface List { 34 | ISODateEmpty[] value(); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/test/scala/sample/util/ValidatorSpec.scala: -------------------------------------------------------------------------------- 1 | package sample.util 2 | 3 | import org.junit.runner.RunWith 4 | import org.scalatest._ 5 | import org.scalatest.junit.JUnitRunner 6 | 7 | import sample.ValidationException 8 | 9 | @RunWith(classOf[JUnitRunner]) 10 | class ValidatorSpec extends WordSpec with Matchers { 11 | 12 | "審査ユーティリティ検証" should { 13 | "単純ケース" in { 14 | try { 15 | Validator.validate( 16 | _.verify(true, "success") 17 | .verify(false, "failure") 18 | ) 19 | fail() 20 | } catch { 21 | case e: ValidationException => e.message should be ("failure") 22 | } 23 | } 24 | "フィールドチェックケース" in { 25 | try { 26 | Validator.validate( 27 | _.checkField(true, "a", "success") 28 | .checkField(false, "b", "failureB") 29 | .checkField(false, "c", "failureC") 30 | .checkField(false, "d", "failureD") 31 | ) 32 | fail() 33 | } catch { 34 | case e: ValidationException => 35 | e.list.length should be (3) 36 | e.message should be ("failureB") 37 | } 38 | } 39 | } 40 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015-2018 Junichiro Kazama 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 | 23 | -------------------------------------------------------------------------------- /src/main/java/sample/model/constraints/ISODateTimeEmpty.java: -------------------------------------------------------------------------------- 1 | package sample.model.constraints; 2 | 3 | import static java.lang.annotation.ElementType.*; 4 | import static java.lang.annotation.RetentionPolicy.RUNTIME; 5 | 6 | import java.lang.annotation.*; 7 | 8 | import javax.validation.*; 9 | 10 | import org.springframework.format.annotation.DateTimeFormat; 11 | import org.springframework.format.annotation.DateTimeFormat.ISO; 12 | 13 | /** 14 | * ISOフォーマットの日時を表現する制約注釈。 15 | *
yyyy-MM-dd'T'HH:mm:ss.SSSZを想定します。 16 | */ 17 | @Documented 18 | @Constraint(validatedBy = {}) 19 | @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER }) 20 | @Retention(RUNTIME) 21 | @ReportAsSingleViolation 22 | @DateTimeFormat(iso = ISO.DATE_TIME) 23 | public @interface ISODateTimeEmpty { 24 | String message() default "{error.domain.ISODateTime}"; 25 | 26 | Class>[] groups() default {}; 27 | 28 | Class extends Payload>[] payload() default {}; 29 | 30 | @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER }) 31 | @Retention(RUNTIME) 32 | @Documented 33 | public @interface List { 34 | ISODateTimeEmpty[] value(); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/main/java/sample/model/constraints/ISODate.java: -------------------------------------------------------------------------------- 1 | package sample.model.constraints; 2 | 3 | import static java.lang.annotation.ElementType.*; 4 | import static java.lang.annotation.RetentionPolicy.RUNTIME; 5 | 6 | import java.lang.annotation.*; 7 | 8 | import javax.validation.*; 9 | import javax.validation.constraints.*; 10 | 11 | import org.springframework.format.annotation.DateTimeFormat; 12 | import org.springframework.format.annotation.DateTimeFormat.ISO; 13 | 14 | /** 15 | * ISOフォーマットの日付(必須)を表現する制約注釈。 16 | *
YYYY-MM-DDを想定します。 17 | */ 18 | @Documented 19 | @Constraint(validatedBy = {}) 20 | @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER }) 21 | @Retention(RUNTIME) 22 | @ReportAsSingleViolation 23 | @NotNull 24 | @DateTimeFormat(iso = ISO.DATE) 25 | public @interface ISODate { 26 | String message() default "{error.domain.ISODate}"; 27 | 28 | Class>[] groups() default {}; 29 | 30 | Class extends Payload>[] payload() default {}; 31 | 32 | @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER }) 33 | @Retention(RUNTIME) 34 | @Documented 35 | public @interface List { 36 | ISODate[] value(); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/main/scala/sample/usecase/report/ServiceReportExporter.scala: -------------------------------------------------------------------------------- 1 | package sample.usecase.report 2 | 3 | import java.io.InputStream 4 | 5 | import org.springframework.beans.factory.annotation.Autowired 6 | import org.springframework.stereotype.Component 7 | 8 | import sample.context.report.ReportHandler 9 | import sample.model.asset.FindCashInOut 10 | import scalikejdbc._ 11 | 12 | /** 13 | * アプリケーション層のレポート出力を行います。 14 | *
独自にトランザクションを管理するので、サービスのトランザクション内で 15 | * 呼び出さないように注意してください。 16 | * low: コード量が多くなるため今回のサンプルでは対象外とします。 17 | */ 18 | @Component 19 | class ServiceReportExporter { 20 | 21 | @Autowired 22 | private var report: ReportHandler = _ 23 | 24 | /** トランザクション処理を実行します。 */ 25 | private def tx[T](callable: DBSession => T): T = 26 | DB.localTx(implicit session => callable(session)) 27 | 28 | /** 振込入出金情報をCSV出力します。 */ 29 | //low: バイナリ生成。条件指定を可能にしたオンラインダウンロードを想定。 30 | def exportCashInOut(): Array[Byte] = Array() 31 | 32 | //low: サイズが大きいケースではストリームへ都度書き出しする。 33 | def exportAnyBigData(ins: InputStream, p: FindCashInOut): Unit = Unit 34 | 35 | /** 振込入出金情報を帳票出力します。 */ 36 | //low: 特定のディレクトリへのファイル出力。ジョブ等での利用を想定 37 | def exportFileCashInOut(baseDay: String): Unit = Unit 38 | } -------------------------------------------------------------------------------- /src/main/java/sample/model/constraints/Year.java: -------------------------------------------------------------------------------- 1 | package sample.model.constraints; 2 | 3 | import static java.lang.annotation.ElementType.*; 4 | import static java.lang.annotation.RetentionPolicy.RUNTIME; 5 | 6 | import java.lang.annotation.*; 7 | 8 | import javax.validation.*; 9 | import javax.validation.constraints.*; 10 | 11 | /** 12 | * 年(必須)を表現する制約注釈。 13 | */ 14 | @Documented 15 | @Constraint(validatedBy = {}) 16 | @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER }) 17 | @Retention(RUNTIME) 18 | @ReportAsSingleViolation 19 | @NotNull 20 | @Digits(integer = 4, fraction = 0) 21 | @Size 22 | public @interface Year { 23 | String message() default "{error.domain.year}"; 24 | 25 | Class>[] groups() default {}; 26 | 27 | Class extends Payload>[] payload() default {}; 28 | 29 | @OverridesAttribute(constraint = Size.class, name = "max") 30 | int max() default 4; 31 | 32 | @OverridesAttribute(constraint = Size.class, name = "min") 33 | int min() default 4; 34 | 35 | @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER }) 36 | @Retention(RUNTIME) 37 | @Documented 38 | public @interface List { 39 | Year[] value(); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/main/scala/sample/model/account/FiAccount.scala: -------------------------------------------------------------------------------- 1 | package sample.model.account 2 | 3 | import scalikejdbc._ 4 | import sample._ 5 | import sample.context._ 6 | import sample.context.orm.SkinnyORMMapper 7 | 8 | /** 9 | * 口座に紐づく金融機関口座を表現します。 10 | *
口座を相手方とする入出金で利用します。 11 | * low: サンプルなので支店や名称、名義といった本来必須な情報をかなり省略しています。(通常は全銀仕様を踏襲します) 12 | */ 13 | case class FiAccount( 14 | /** ID */ 15 | id: Long, 16 | /** 口座ID */ 17 | accountId: String, 18 | /** 利用用途カテゴリ */ 19 | category: String, 20 | /** 通貨 */ 21 | currency: String, 22 | /** 金融機関コード */ 23 | fiCode: String, 24 | /** 金融機関口座ID */ 25 | fiAccountId: String) extends Entity 26 | 27 | object FiAccount extends SkinnyORMMapper[FiAccount] { 28 | override def extract(rs: WrappedResultSet, rn: ResultName[FiAccount]): FiAccount = autoConstruct(rs, rn) 29 | 30 | def load(accountId: String, category: String, currency: String)(implicit s: DBSession): FiAccount = 31 | withAlias { m => 32 | findBy( 33 | sqls.eq(m.accountId, accountId) 34 | .and(sqls.eq(m.category, category)) 35 | .and(sqls.eq(m.currency, currency))) 36 | .getOrElse( 37 | throw ValidationException(ErrorKeys.EntityNotFound)) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/main/java/sample/model/constraints/ISODateTime.java: -------------------------------------------------------------------------------- 1 | package sample.model.constraints; 2 | 3 | import static java.lang.annotation.ElementType.*; 4 | import static java.lang.annotation.RetentionPolicy.RUNTIME; 5 | 6 | import java.lang.annotation.*; 7 | 8 | import javax.validation.*; 9 | import javax.validation.constraints.*; 10 | 11 | import org.springframework.format.annotation.DateTimeFormat; 12 | import org.springframework.format.annotation.DateTimeFormat.ISO; 13 | 14 | /** 15 | * ISOフォーマットの日時(必須)を表現する制約注釈。 16 | *
yyyy-MM-dd'T'HH:mm:ss.SSSZを想定します。 17 | */ 18 | @Documented 19 | @Constraint(validatedBy = {}) 20 | @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER }) 21 | @Retention(RUNTIME) 22 | @ReportAsSingleViolation 23 | @NotNull 24 | @DateTimeFormat(iso = ISO.DATE_TIME) 25 | public @interface ISODateTime { 26 | String message() default "{error.domain.ISODateTime}"; 27 | 28 | Class>[] groups() default {}; 29 | 30 | Class extends Payload>[] payload() default {}; 31 | 32 | @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER }) 33 | @Retention(RUNTIME) 34 | @Documented 35 | public @interface List { 36 | ISODateTime[] value(); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/main/java/sample/model/constraints/Amount.java: -------------------------------------------------------------------------------- 1 | package sample.model.constraints; 2 | 3 | import static java.lang.annotation.ElementType.*; 4 | import static java.lang.annotation.RetentionPolicy.RUNTIME; 5 | 6 | import java.lang.annotation.*; 7 | 8 | import javax.validation.*; 9 | import javax.validation.constraints.*; 10 | 11 | /** 12 | * 金額(必須)を表現する制約注釈。 13 | */ 14 | @Documented 15 | @Constraint(validatedBy = {}) 16 | @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER }) 17 | @Retention(RUNTIME) 18 | @ReportAsSingleViolation 19 | @NotNull 20 | @Digits(integer = 16, fraction = 4) 21 | public @interface Amount { 22 | String message() default "{error.domain.amount}"; 23 | 24 | Class>[] groups() default {}; 25 | 26 | Class extends Payload>[] payload() default {}; 27 | 28 | @OverridesAttribute(constraint = Digits.class, name = "integer") 29 | int integer() default 16; 30 | 31 | @OverridesAttribute(constraint = Digits.class, name = "fraction") 32 | int fraction() default 4; 33 | 34 | @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER }) 35 | @Retention(RUNTIME) 36 | @Documented 37 | public @interface List { 38 | Amount[] value(); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/main/java/sample/model/constraints/AmountEmpty.java: -------------------------------------------------------------------------------- 1 | package sample.model.constraints; 2 | 3 | import static java.lang.annotation.ElementType.*; 4 | import static java.lang.annotation.RetentionPolicy.RUNTIME; 5 | 6 | import java.lang.annotation.*; 7 | 8 | import javax.validation.*; 9 | import javax.validation.constraints.Digits; 10 | 11 | /** 12 | * 金額を表現する制約注釈。 13 | */ 14 | @Documented 15 | @Constraint(validatedBy = {}) 16 | @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER }) 17 | @Retention(RUNTIME) 18 | @ReportAsSingleViolation 19 | @Digits(integer = 16, fraction = 4) 20 | public @interface AmountEmpty { 21 | String message() default "{error.domain.amount}"; 22 | 23 | Class>[] groups() default {}; 24 | 25 | Class extends Payload>[] payload() default {}; 26 | 27 | @OverridesAttribute(constraint = Digits.class, name = "integer") 28 | int integer() default 16; 29 | 30 | @OverridesAttribute(constraint = Digits.class, name = "fraction") 31 | int fraction() default 4; 32 | 33 | @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER }) 34 | @Retention(RUNTIME) 35 | @Documented 36 | public @interface List { 37 | AmountEmpty[] value(); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/main/java/sample/model/constraints/CurrencyEmpty.java: -------------------------------------------------------------------------------- 1 | package sample.model.constraints; 2 | 3 | import static java.lang.annotation.ElementType.*; 4 | import static java.lang.annotation.RetentionPolicy.RUNTIME; 5 | 6 | import java.lang.annotation.*; 7 | 8 | import javax.validation.*; 9 | import javax.validation.constraints.*; 10 | 11 | /** 12 | * 通貨を表現する制約注釈。 13 | */ 14 | @Documented 15 | @Constraint(validatedBy = {}) 16 | @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER }) 17 | @Retention(RUNTIME) 18 | @ReportAsSingleViolation 19 | @Size 20 | @Pattern(regexp = "") 21 | public @interface CurrencyEmpty { 22 | String message() default "{error.domain.currency}"; 23 | 24 | Class>[] groups() default {}; 25 | 26 | Class extends Payload>[] payload() default {}; 27 | 28 | @OverridesAttribute(constraint = Size.class, name = "max") 29 | int max() default 3; 30 | 31 | @OverridesAttribute(constraint = Pattern.class, name = "regexp") 32 | String regexp() default "^[a-zA-Z]{3}$"; 33 | 34 | @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER }) 35 | @Retention(RUNTIME) 36 | @Documented 37 | public @interface List { 38 | CurrencyEmpty[] value(); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/main/scala/sample/context/Timestamper.scala: -------------------------------------------------------------------------------- 1 | package sample.context 2 | 3 | import java.time.{Clock, LocalDate, LocalDateTime} 4 | 5 | import org.springframework.beans.factory.annotation.Autowired 6 | 7 | import sample.util._ 8 | 9 | /** 10 | * 日時ユーティリティコンポーネント。 11 | */ 12 | class Timestamper(clock: Clock) { 13 | 14 | @Autowired(required = false) 15 | var setting: AppSettingHandler = _ 16 | 17 | /** 営業日を返します。 */ 18 | def day: LocalDate = Option(setting) match { 19 | case Some(sh) => 20 | DateUtils.day(sh.setting(Timestamper.KeyDay).str()) 21 | case None => LocalDate.now(clock) 22 | } 23 | 24 | /** 日時を返します。 */ 25 | def date: LocalDateTime = LocalDateTime.now(clock) 26 | 27 | /** 営業日/日時を返します。 */ 28 | def tp: TimePoint = TimePoint(day, date) 29 | /** 30 | * 営業日を指定日へ進めます。 31 | *
AppSettingHandlerを設定時のみ有効です。 32 | * @param day 更新営業日 33 | */ 34 | def proceedDay(day: LocalDate): Timestamper = { 35 | Option(setting).map(_.update(Timestamper.KeyDay, DateUtils.dayFormat(day))) 36 | this 37 | } 38 | } 39 | 40 | object Timestamper { 41 | val KeyDay: String = "system.businessDay.day" 42 | def apply(): Timestamper = apply(Clock.systemDefaultZone()) 43 | def apply(clock: Clock): Timestamper = new Timestamper(clock) 44 | } 45 | -------------------------------------------------------------------------------- /src/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | --- 2 | spring: 3 | application.name: sample-boot-scala 4 | aop.proxy-target-class: true 5 | messages.basename: messages-validation, messages 6 | cache.jcache.config: classpath:ehcache.xml 7 | http.multipart: 8 | max-file-size: 20MB 9 | max-request-size: 20MB 10 | 11 | banner.location: banner.txt 12 | 13 | logging.config: classpath:logback-spring.xml 14 | 15 | server: 16 | port: 8080 17 | error: 18 | whitelabel.enabled: false 19 | path: /api/error 20 | 21 | security: 22 | basic.enabled: false 23 | user.password: unused 24 | 25 | 26 | management: 27 | context-path: /api/management 28 | security.enabled: false 29 | health: 30 | diskspace.enabled: true 31 | db.enabled: false 32 | 33 | extension: 34 | security: 35 | auth: 36 | enabled: false 37 | admin: false 38 | cors.enabled: true 39 | mail.enabled: false 40 | datafixture.enabled: true 41 | 42 | --- 43 | spring: 44 | profiles: production 45 | 46 | management.security.enabled: true 47 | 48 | extension: 49 | security: 50 | auth.enabled: true 51 | cors.enabled: false 52 | datafixture.enabled: false 53 | 54 | --- 55 | spring: 56 | profiles: admin 57 | 58 | server.port: 8081 59 | 60 | extension: 61 | security.auth.admin: true 62 | -------------------------------------------------------------------------------- /src/main/scala/sample/context/orm/Sort.scala: -------------------------------------------------------------------------------- 1 | package sample.context.orm 2 | 3 | /** 4 | * ソート情報を表現します。 5 | * 複数件のソート情報(SortOrder)を内包します。 6 | */ 7 | case class Sort(orders: Seq[SortOrder]) { 8 | /** ソート条件を追加します。 */ 9 | def add(order: SortOrder): Sort = copy(orders = orders :+ order) 10 | def +(order: SortOrder): Sort = add(order) 11 | /** ソート条件(昇順)を追加します。 */ 12 | def asc(property: String): Sort = add(SortOrder.asc(property)) 13 | /** ソート条件(降順)を追加します。 */ 14 | def desc(property: String): Sort = add(SortOrder.desc(property)) 15 | /** ソート条件が未指定だった際にソート順が上書きされます。 */ 16 | def ifEmpty(emptyOrders: SortOrder*): Sort = 17 | if (orders.isEmpty && emptyOrders.nonEmpty) copy(orders = emptyOrders) 18 | else this 19 | } 20 | object Sort { 21 | /** 昇順でソート情報を返します。 */ 22 | def ascBy(property: String): Sort = Sort(Seq()).asc(property) 23 | /** 降順でソート情報を返します。 */ 24 | def descBy(property: String): Sort = Sort(Seq()).desc(property) 25 | } 26 | 27 | /** フィールド単位のソート情報を表現します。 */ 28 | case class SortOrder(property: String, ascending: Boolean) { 29 | def ascendingName: String = if (ascending) "asc" else "desc" 30 | } 31 | object SortOrder { 32 | def asc(property: String): SortOrder = SortOrder(property, true) 33 | def desc(property: String): SortOrder = SortOrder(property, false) 34 | } 35 | -------------------------------------------------------------------------------- /src/main/java/sample/model/constraints/Currency.java: -------------------------------------------------------------------------------- 1 | package sample.model.constraints; 2 | 3 | import static java.lang.annotation.ElementType.*; 4 | import static java.lang.annotation.RetentionPolicy.RUNTIME; 5 | 6 | import java.lang.annotation.*; 7 | 8 | import javax.validation.*; 9 | import javax.validation.constraints.*; 10 | 11 | import org.hibernate.validator.constraints.NotBlank; 12 | 13 | /** 14 | * 通貨(必須)を表現する制約注釈。 15 | */ 16 | @Documented 17 | @Constraint(validatedBy = {}) 18 | @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER }) 19 | @Retention(RUNTIME) 20 | @ReportAsSingleViolation 21 | @NotBlank 22 | @Size 23 | @Pattern(regexp = "") 24 | public @interface Currency { 25 | String message() default "{error.domain.currency}"; 26 | 27 | Class>[] groups() default {}; 28 | 29 | Class extends Payload>[] payload() default {}; 30 | 31 | @OverridesAttribute(constraint = Size.class, name = "max") 32 | int max() default 3; 33 | 34 | @OverridesAttribute(constraint = Pattern.class, name = "regexp") 35 | String regexp() default "^[a-zA-Z]{3}$"; 36 | 37 | @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER }) 38 | @Retention(RUNTIME) 39 | @Documented 40 | public @interface List { 41 | Currency[] value(); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/main/scala/sample/controller/AccountController.scala: -------------------------------------------------------------------------------- 1 | package sample.controller 2 | 3 | import org.springframework.beans.factory.annotation.Autowired 4 | 5 | import org.springframework.web.bind.annotation._ 6 | 7 | import sample._ 8 | import sample.context.security._ 9 | import sample.usecase.AccountService 10 | 11 | /** 12 | * 口座に関わる顧客のUI要求を処理します。 13 | */ 14 | @RestController 15 | @RequestMapping(Array("/api/account")) 16 | class AccountController extends ControllerSupport { 17 | 18 | @Autowired 19 | private var service: AccountService = _ 20 | @Autowired 21 | private var securityProps: SecurityProperties = _ 22 | 23 | /** ログイン状態を確認します。 */ 24 | @GetMapping(Array("/loginStatus")) 25 | def loginStatus: Boolean = true 26 | 27 | /** 口座ログイン情報を取得します。 */ 28 | @GetMapping(Array("/loginAccount")) 29 | def loadLoginAccount: LoginAccount = 30 | if (securityProps.auth.enabled) { 31 | SecurityActorFinder.actorDetails.map(details => 32 | LoginAccount(details.actor.id, details.actor.name, details.authorityIds) 33 | ).getOrElse(throw ValidationException(ErrorKeys.Authentication)) 34 | } else LoginAccount("sample", "sample", Seq()) // for dummy login 35 | 36 | } 37 | 38 | /** クライアント利用用途に絞ったパラメタ */ 39 | case class LoginAccount(id: String, name: String, authorities: Seq[String]) 40 | -------------------------------------------------------------------------------- /src/main/java/sample/model/constraints/IdStrEmpty.java: -------------------------------------------------------------------------------- 1 | package sample.model.constraints; 2 | 3 | import static java.lang.annotation.ElementType.*; 4 | import static java.lang.annotation.RetentionPolicy.RUNTIME; 5 | 6 | import java.lang.annotation.*; 7 | 8 | import javax.validation.*; 9 | import javax.validation.constraints.*; 10 | 11 | /** 12 | * 文字列IDを表現する制約注釈。 13 | */ 14 | @Documented 15 | @Constraint(validatedBy = {}) 16 | @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER }) 17 | @Retention(RUNTIME) 18 | @ReportAsSingleViolation 19 | @Size 20 | @Pattern(regexp = "") 21 | public @interface IdStrEmpty { 22 | String message() default "{error.domain.idStr}"; 23 | 24 | Class>[] groups() default {}; 25 | 26 | Class extends Payload>[] payload() default {}; 27 | 28 | @OverridesAttribute(constraint = Size.class, name = "max") 29 | int max() default 32; 30 | 31 | @OverridesAttribute(constraint = Pattern.class, name = "regexp") 32 | String regexp() default "^\\p{ASCII}*$"; 33 | 34 | @OverridesAttribute(constraint = Pattern.class, name = "flags") 35 | Pattern.Flag[] flags() default {}; 36 | 37 | @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER }) 38 | @Retention(RUNTIME) 39 | @Documented 40 | public @interface List { 41 | IdStrEmpty[] value(); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/main/java/sample/model/constraints/NameEmpty.java: -------------------------------------------------------------------------------- 1 | package sample.model.constraints; 2 | 3 | import static java.lang.annotation.ElementType.*; 4 | import static java.lang.annotation.RetentionPolicy.RUNTIME; 5 | 6 | import java.lang.annotation.*; 7 | 8 | import javax.validation.*; 9 | import javax.validation.constraints.Pattern; 10 | import javax.validation.constraints.Size; 11 | 12 | /** 13 | * 名称を表現する制約注釈。 14 | */ 15 | @Documented 16 | @Constraint(validatedBy = {}) 17 | @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER }) 18 | @Retention(RUNTIME) 19 | @ReportAsSingleViolation 20 | @Size 21 | @Pattern(regexp = "") 22 | public @interface NameEmpty { 23 | String message() default "{error.domain.name}"; 24 | 25 | Class>[] groups() default {}; 26 | 27 | Class extends Payload>[] payload() default {}; 28 | 29 | @OverridesAttribute(constraint = Size.class, name = "max") 30 | int max() default 30; 31 | 32 | @OverridesAttribute(constraint = Pattern.class, name = "regexp") 33 | String regexp() default ".*"; 34 | 35 | @OverridesAttribute(constraint = Pattern.class, name = "flags") 36 | Pattern.Flag[] flags() default {}; 37 | 38 | @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER }) 39 | @Retention(RUNTIME) 40 | @Documented 41 | public @interface List { 42 | NameEmpty[] value(); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/main/scala/sample/controller/system/JobController.scala: -------------------------------------------------------------------------------- 1 | package sample.controller.system 2 | 3 | import org.springframework.beans.factory.annotation.Autowired 4 | import org.springframework.http.ResponseEntity 5 | import org.springframework.web.bind.annotation._ 6 | 7 | import sample.controller.ControllerSupport 8 | import sample.usecase._ 9 | 10 | /** 11 | * システムジョブのUI要求を処理します。 12 | * low: 通常はバッチプロセス(または社内プロセスに内包)を別途作成して、ジョブスケジューラから実行される方式になります。 13 | * ジョブの負荷がオンライン側へ影響を与えないよう事前段階の設計が重要になります。 14 | * low: 社内/バッチプロセス切り出す場合はVM分散時の情報/排他同期を意識する必要があります。(DB同期/メッセージング同期/分散製品の利用 等) 15 | */ 16 | @RestController 17 | @RequestMapping(Array("/api/system/job")) 18 | class JobController extends ControllerSupport { 19 | 20 | @Autowired 21 | private var asset: AssetAdminService = _ 22 | @Autowired 23 | private var system: SystemAdminService = _ 24 | 25 | /** 営業日を進めます。 */ 26 | @PostMapping(Array("/daily/processDay")) 27 | def processDay(): ResponseEntity[Void] = 28 | resultEmpty(system.processDay()) 29 | 30 | /** 振込出金依頼を締めます。 */ 31 | @PostMapping(Array("/daily/closingCashOut")) 32 | def closingCashOut(): ResponseEntity[Void] = 33 | resultEmpty(asset.closingCashOut()) 34 | 35 | /** キャッシュフローを実現します。 */ 36 | @PostMapping(Array("/daily/realizeCashflow")) 37 | def realizeCashflow(): ResponseEntity[Void] = 38 | resultEmpty(asset.realizeCashflow()) 39 | 40 | } 41 | -------------------------------------------------------------------------------- /src/main/java/sample/model/constraints/AbsAmountEmpty.java: -------------------------------------------------------------------------------- 1 | package sample.model.constraints; 2 | 3 | import static java.lang.annotation.ElementType.*; 4 | import static java.lang.annotation.RetentionPolicy.RUNTIME; 5 | 6 | import java.lang.annotation.*; 7 | 8 | import javax.validation.*; 9 | import javax.validation.constraints.*; 10 | 11 | /** 12 | * 絶対値の金額を表現する制約注釈。 13 | */ 14 | @Documented 15 | @Constraint(validatedBy = {}) 16 | @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER }) 17 | @Retention(RUNTIME) 18 | @ReportAsSingleViolation 19 | @Digits(integer = 16, fraction = 4) 20 | @DecimalMin("0.00") 21 | public @interface AbsAmountEmpty { 22 | String message() default "{error.domain.absAmount}"; 23 | 24 | Class>[] groups() default {}; 25 | 26 | Class extends Payload>[] payload() default {}; 27 | 28 | @OverridesAttribute(constraint = Digits.class, name = "integer") 29 | int integer() default 16; 30 | 31 | @OverridesAttribute(constraint = Digits.class, name = "fraction") 32 | int fraction() default 4; 33 | 34 | @OverridesAttribute(constraint = DecimalMin.class, name = "value") 35 | String min() default "0.00"; 36 | 37 | @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER }) 38 | @Retention(RUNTIME) 39 | @Documented 40 | public @interface List { 41 | AbsAmountEmpty[] value(); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/main/java/sample/model/constraints/AbsAmount.java: -------------------------------------------------------------------------------- 1 | package sample.model.constraints; 2 | 3 | import static java.lang.annotation.ElementType.*; 4 | import static java.lang.annotation.RetentionPolicy.RUNTIME; 5 | 6 | import java.lang.annotation.*; 7 | 8 | import javax.validation.*; 9 | import javax.validation.constraints.*; 10 | 11 | /** 12 | * 絶対値の金額(必須)を表現する制約注釈。 13 | */ 14 | @Documented 15 | @Constraint(validatedBy = {}) 16 | @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER }) 17 | @Retention(RUNTIME) 18 | @ReportAsSingleViolation 19 | @NotNull 20 | @Digits(integer = 16, fraction = 4) 21 | @DecimalMin("0.00") 22 | public @interface AbsAmount { 23 | String message() default "{error.domain.absAmount}"; 24 | 25 | Class>[] groups() default {}; 26 | 27 | Class extends Payload>[] payload() default {}; 28 | 29 | @OverridesAttribute(constraint = Digits.class, name = "integer") 30 | int integer() default 16; 31 | 32 | @OverridesAttribute(constraint = Digits.class, name = "fraction") 33 | int fraction() default 4; 34 | 35 | @OverridesAttribute(constraint = DecimalMin.class, name = "value") 36 | String min() default "0.00"; 37 | 38 | @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER }) 39 | @Retention(RUNTIME) 40 | @Documented 41 | public @interface List { 42 | AbsAmount[] value(); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/main/java/sample/model/constraints/OutlineEmpty.java: -------------------------------------------------------------------------------- 1 | package sample.model.constraints; 2 | 3 | import static java.lang.annotation.ElementType.*; 4 | import static java.lang.annotation.RetentionPolicy.RUNTIME; 5 | 6 | import java.lang.annotation.*; 7 | 8 | import javax.validation.*; 9 | import javax.validation.constraints.*; 10 | 11 | import sample.util.JavaRegex; 12 | 13 | /** 14 | * 概要を表現する制約注釈。 15 | */ 16 | @Documented 17 | @Constraint(validatedBy = {}) 18 | @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER }) 19 | @Retention(RUNTIME) 20 | @ReportAsSingleViolation 21 | @Size 22 | @Pattern(regexp = "") 23 | public @interface OutlineEmpty { 24 | String message() default "{error.domain.outline}"; 25 | 26 | Class>[] groups() default {}; 27 | 28 | Class extends Payload>[] payload() default {}; 29 | 30 | @OverridesAttribute(constraint = Size.class, name = "max") 31 | int max() default 200; 32 | 33 | @OverridesAttribute(constraint = Pattern.class, name = "regexp") 34 | String regexp() default JavaRegex.rWord; 35 | 36 | @OverridesAttribute(constraint = Pattern.class, name = "flags") 37 | Pattern.Flag[] flags() default {}; 38 | 39 | @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER }) 40 | @Retention(RUNTIME) 41 | @Documented 42 | public @interface List { 43 | OutlineEmpty[] value(); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/main/scala/sample/util/TimePoint.scala: -------------------------------------------------------------------------------- 1 | package sample.util 2 | 3 | import java.time.{Clock, LocalDateTime, LocalDate} 4 | 5 | import sample.model.constraints.{ISODate, ISODateTime} 6 | 7 | /** 8 | * 日付と日時のペアを表現します。 9 | *
0:00に営業日切り替えが行われないケースなどでの利用を想定しています。 10 | */ 11 | case class TimePoint( 12 | /** 日付(営業日) */ 13 | @ISODate 14 | day: LocalDate, 15 | /** 日付におけるシステム日時 */ 16 | @ISODateTime 17 | date: LocalDateTime) { 18 | 19 | /** 指定日付と同じか。(day == targetDay) */ 20 | def equalsDay(targetDay: LocalDate): Boolean = day.compareTo(targetDay) == 0 21 | 22 | /** 指定日付よりも前か。(day < targetDay) */ 23 | def beforeDay(targetDay: LocalDate): Boolean = day.compareTo(targetDay) < 0 24 | 25 | /** 指定日付以前か。(day <= targetDay) */ 26 | def beforeEqualsDay(targetDay: LocalDate): Boolean = day.compareTo(targetDay) <= 0 27 | 28 | /** 指定日付よりも後か。(targetDay < day) */ 29 | def afterDay(targetDay: LocalDate): Boolean = 0 < day.compareTo(targetDay) 30 | 31 | /** 指定日付以降か。(targetDay <= day) */ 32 | def afterEqualsDay(targetDay: LocalDate): Boolean = 0 <= day.compareTo(targetDay) 33 | } 34 | 35 | object TimePoint { 36 | def apply(day: LocalDate): TimePoint = TimePoint(day, day.atStartOfDay()) 37 | def apply(): TimePoint = apply(Clock.systemDefaultZone()) 38 | def apply(clock: Clock): TimePoint = Option(LocalDateTime.now(clock)).map(now => TimePoint(now.toLocalDate(), now)).get 39 | } 40 | -------------------------------------------------------------------------------- /src/main/java/sample/model/constraints/DescriptionEmpty.java: -------------------------------------------------------------------------------- 1 | package sample.model.constraints; 2 | 3 | import static java.lang.annotation.ElementType.*; 4 | import static java.lang.annotation.RetentionPolicy.RUNTIME; 5 | 6 | import java.lang.annotation.*; 7 | 8 | import javax.validation.*; 9 | import javax.validation.constraints.*; 10 | 11 | import sample.util.JavaRegex; 12 | 13 | /** 14 | * 備考を表現する制約注釈。 15 | */ 16 | @Documented 17 | @Constraint(validatedBy = {}) 18 | @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER }) 19 | @Retention(RUNTIME) 20 | @ReportAsSingleViolation 21 | @Size 22 | @Pattern(regexp = "") 23 | public @interface DescriptionEmpty { 24 | String message() default "{error.domain.description}"; 25 | 26 | Class>[] groups() default {}; 27 | 28 | Class extends Payload>[] payload() default {}; 29 | 30 | @OverridesAttribute(constraint = Size.class, name = "max") 31 | int max() default 400; 32 | 33 | @OverridesAttribute(constraint = Pattern.class, name = "regexp") 34 | String regexp() default JavaRegex.rWord; 35 | 36 | @OverridesAttribute(constraint = Pattern.class, name = "flags") 37 | Pattern.Flag[] flags() default {}; 38 | 39 | @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER }) 40 | @Retention(RUNTIME) 41 | @Documented 42 | public @interface List { 43 | DescriptionEmpty[] value(); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/main/java/sample/model/constraints/IdStr.java: -------------------------------------------------------------------------------- 1 | package sample.model.constraints; 2 | 3 | import static java.lang.annotation.ElementType.*; 4 | import static java.lang.annotation.RetentionPolicy.RUNTIME; 5 | 6 | import java.lang.annotation.*; 7 | 8 | import javax.validation.*; 9 | import javax.validation.constraints.*; 10 | 11 | import org.hibernate.validator.constraints.NotBlank; 12 | 13 | /** 14 | * 文字列ID(必須)を表現する制約注釈。 15 | */ 16 | @Documented 17 | @Constraint(validatedBy = {}) 18 | @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER }) 19 | @Retention(RUNTIME) 20 | @ReportAsSingleViolation 21 | @NotBlank 22 | @Size 23 | @Pattern(regexp = "") 24 | public @interface IdStr { 25 | String message() default "{error.domain.idStr}"; 26 | 27 | Class>[] groups() default {}; 28 | 29 | Class extends Payload>[] payload() default {}; 30 | 31 | @OverridesAttribute(constraint = Size.class, name = "max") 32 | int max() default 32; 33 | 34 | @OverridesAttribute(constraint = Pattern.class, name = "regexp") 35 | String regexp() default "^\\p{ASCII}*$"; 36 | 37 | @OverridesAttribute(constraint = Pattern.class, name = "flags") 38 | Pattern.Flag[] flags() default {}; 39 | 40 | @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER }) 41 | @Retention(RUNTIME) 42 | @Documented 43 | public @interface List { 44 | IdStr[] value(); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/main/scala/sample/usecase/AssetService.scala: -------------------------------------------------------------------------------- 1 | package sample.usecase 2 | 3 | import org.springframework.stereotype.Service 4 | 5 | import sample.context.actor.Actor 6 | import sample.context.lock.LockType 7 | import sample.model.asset._ 8 | 9 | /** 10 | * 資産ドメインに対する顧客ユースケース処理。 11 | */ 12 | @Service 13 | class AssetService extends ServiceSupport { 14 | 15 | /** 匿名を除くActorを返します。 */ 16 | override def actor: Actor = ServiceUtils.actorUser(super.actor) 17 | 18 | /** 19 | * 未処理の振込依頼情報を検索します。 20 | * low: CashInOutは情報過多ですがアプリケーション層では公開対象を特定しにくい事もあり、 21 | * UI層に最終判断を委ねています。 22 | */ 23 | def findUnprocessedCashOut: List[CashInOut] = 24 | tx(actor.id, LockType.READ, { implicit session => 25 | CashInOut.findUnprocessed(actor.id) 26 | }) 27 | 28 | /** 29 | * 振込出金依頼をします。 30 | * low: 公開リスクがあるためUI層には必要以上の情報を返さない事を意識します。 31 | * low: 監査ログの記録は状態を変えうる更新系ユースケースでのみ行います。 32 | * low: ロールバック発生時にメールが飛ばないようにトランザクション境界線を明確に分離します。 33 | * @return 振込出金依頼ID 34 | */ 35 | def withdraw(p: RegCashOut): Long = 36 | audit.audit("振込出金依頼をします", { 37 | // 顧客側はログイン利用者で強制上書きして振替 38 | val cio = tx(actor.id, LockType.WRITE, {implicit session => 39 | CashInOut.load(CashInOut.withdraw(businessDay, p.copy(accountId = Some(actor.id)))) 40 | }) 41 | // low: トランザクション確定後に出金依頼を受付した事をメール通知します。 42 | mail.sendWithdrawal(cio) 43 | cio.id 44 | }) 45 | 46 | } 47 | -------------------------------------------------------------------------------- /src/main/java/sample/model/constraints/EmailEmpty.java: -------------------------------------------------------------------------------- 1 | package sample.model.constraints; 2 | 3 | import static java.lang.annotation.ElementType.*; 4 | import static java.lang.annotation.RetentionPolicy.RUNTIME; 5 | 6 | import java.lang.annotation.*; 7 | 8 | import javax.validation.*; 9 | import javax.validation.constraints.*; 10 | 11 | /** 12 | * メールアドレスを表現する制約注釈。 13 | * low: とりあえずHibernateのEmailValidatorを利用しますが、恐らく最終的に 14 | * 固有のConstraintValidatorを作らされる事になると思います。 15 | */ 16 | @Documented 17 | @Constraint(validatedBy = {}) 18 | @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER }) 19 | @Retention(RUNTIME) 20 | @ReportAsSingleViolation 21 | @Size 22 | @Pattern(regexp = "") 23 | public @interface EmailEmpty { 24 | String message() default "{error.domain.email}"; 25 | 26 | Class>[] groups() default {}; 27 | 28 | Class extends Payload>[] payload() default {}; 29 | 30 | @OverridesAttribute(constraint = Size.class, name = "max") 31 | int max() default 256; 32 | 33 | @OverridesAttribute(constraint = Pattern.class, name = "regexp") 34 | String regexp() default ".*"; 35 | 36 | @OverridesAttribute(constraint = Pattern.class, name = "flags") 37 | Pattern.Flag[] flags() default {}; 38 | 39 | @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER }) 40 | @Retention(RUNTIME) 41 | @Documented 42 | public @interface List { 43 | EmailEmpty[] value(); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/main/java/sample/model/constraints/Name.java: -------------------------------------------------------------------------------- 1 | package sample.model.constraints; 2 | 3 | import static java.lang.annotation.ElementType.*; 4 | import static java.lang.annotation.RetentionPolicy.RUNTIME; 5 | 6 | import java.lang.annotation.*; 7 | 8 | import javax.validation.*; 9 | import javax.validation.constraints.*; 10 | 11 | import org.hibernate.validator.constraints.NotBlank; 12 | 13 | /** 14 | * 名称(必須)を表現する制約注釈。 15 | * low: 実際は姓名(ミドルネーム)の考慮やモノ系の名称などを意識する必要があります。 16 | */ 17 | @Documented 18 | @Constraint(validatedBy = {}) 19 | @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER }) 20 | @Retention(RUNTIME) 21 | @ReportAsSingleViolation 22 | @NotBlank 23 | @Size 24 | @Pattern(regexp = "") 25 | public @interface Name { 26 | String message() default "{error.domain.name}"; 27 | 28 | Class>[] groups() default {}; 29 | 30 | Class extends Payload>[] payload() default {}; 31 | 32 | @OverridesAttribute(constraint = Size.class, name = "max") 33 | int max() default 30; 34 | 35 | @OverridesAttribute(constraint = Pattern.class, name = "regexp") 36 | String regexp() default ".*"; 37 | 38 | @OverridesAttribute(constraint = Pattern.class, name = "flags") 39 | Pattern.Flag[] flags() default {}; 40 | 41 | @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER }) 42 | @Retention(RUNTIME) 43 | @Documented 44 | public @interface List { 45 | Name[] value(); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/main/java/sample/model/constraints/CategoryEmpty.java: -------------------------------------------------------------------------------- 1 | package sample.model.constraints; 2 | 3 | import static java.lang.annotation.ElementType.*; 4 | import static java.lang.annotation.RetentionPolicy.*; 5 | 6 | import java.lang.annotation.*; 7 | 8 | import javax.validation.*; 9 | import javax.validation.constraints.Pattern; 10 | import javax.validation.constraints.Size; 11 | 12 | import sample.util.JavaRegex; 13 | 14 | /** 15 | * 各種カテゴリ/区分(必須)を表現する制約注釈。 16 | */ 17 | @Documented 18 | @Constraint(validatedBy = {}) 19 | @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER }) 20 | @Retention(RUNTIME) 21 | @ReportAsSingleViolation 22 | @Size 23 | @Pattern(regexp = "") 24 | public @interface CategoryEmpty { 25 | String message() default "{error.domain.category}"; 26 | 27 | Class>[] groups() default {}; 28 | 29 | Class extends Payload>[] payload() default {}; 30 | 31 | @OverridesAttribute(constraint = Size.class, name = "max") 32 | int max() default 30; 33 | 34 | @OverridesAttribute(constraint = Pattern.class, name = "regexp") 35 | String regexp() default JavaRegex.rAscii; 36 | 37 | @OverridesAttribute(constraint = Pattern.class, name = "flags") 38 | Pattern.Flag[] flags() default {}; 39 | 40 | @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER }) 41 | @Retention(RUNTIME) 42 | @Documented 43 | public @interface List { 44 | CategoryEmpty[] value(); 45 | } 46 | 47 | } 48 | -------------------------------------------------------------------------------- /src/main/java/sample/model/constraints/Outline.java: -------------------------------------------------------------------------------- 1 | package sample.model.constraints; 2 | 3 | import static java.lang.annotation.ElementType.*; 4 | import static java.lang.annotation.RetentionPolicy.RUNTIME; 5 | 6 | import java.lang.annotation.*; 7 | 8 | import javax.validation.*; 9 | import javax.validation.constraints.*; 10 | 11 | import org.hibernate.validator.constraints.NotBlank; 12 | 13 | import sample.util.JavaRegex; 14 | 15 | /** 16 | * 概要(必須)を表現する制約注釈。 17 | */ 18 | @Documented 19 | @Constraint(validatedBy = {}) 20 | @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER }) 21 | @Retention(RUNTIME) 22 | @ReportAsSingleViolation 23 | @NotBlank 24 | @Size 25 | @Pattern(regexp = "") 26 | public @interface Outline { 27 | String message() default "{error.domain.outline}"; 28 | 29 | Class>[] groups() default {}; 30 | 31 | Class extends Payload>[] payload() default {}; 32 | 33 | @OverridesAttribute(constraint = Size.class, name = "max") 34 | int max() default 200; 35 | 36 | @OverridesAttribute(constraint = Pattern.class, name = "regexp") 37 | String regexp() default JavaRegex.rWord; 38 | 39 | @OverridesAttribute(constraint = Pattern.class, name = "flags") 40 | Pattern.Flag[] flags() default {}; 41 | 42 | @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER }) 43 | @Retention(RUNTIME) 44 | @Documented 45 | public @interface List { 46 | Outline[] value(); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/main/java/sample/model/constraints/Category.java: -------------------------------------------------------------------------------- 1 | package sample.model.constraints; 2 | 3 | import static java.lang.annotation.ElementType.*; 4 | import static java.lang.annotation.RetentionPolicy.RUNTIME; 5 | 6 | import java.lang.annotation.*; 7 | 8 | import javax.validation.*; 9 | import javax.validation.constraints.*; 10 | 11 | import org.hibernate.validator.constraints.NotBlank; 12 | 13 | import sample.util.JavaRegex; 14 | 15 | /** 16 | * 各種カテゴリ/区分(必須)を表現する制約注釈。 17 | */ 18 | @Documented 19 | @Constraint(validatedBy = {}) 20 | @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER }) 21 | @Retention(RUNTIME) 22 | @ReportAsSingleViolation 23 | @NotBlank 24 | @Size 25 | @Pattern(regexp = "") 26 | public @interface Category { 27 | String message() default "{error.domain.category}"; 28 | 29 | Class>[] groups() default {}; 30 | 31 | Class extends Payload>[] payload() default {}; 32 | 33 | @OverridesAttribute(constraint = Size.class, name = "max") 34 | int max() default 30; 35 | 36 | @OverridesAttribute(constraint = Pattern.class, name = "regexp") 37 | String regexp() default JavaRegex.rAscii; 38 | 39 | @OverridesAttribute(constraint = Pattern.class, name = "flags") 40 | Pattern.Flag[] flags() default {}; 41 | 42 | @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER }) 43 | @Retention(RUNTIME) 44 | @Documented 45 | public @interface List { 46 | Category[] value(); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/main/java/sample/model/constraints/Description.java: -------------------------------------------------------------------------------- 1 | package sample.model.constraints; 2 | 3 | import static java.lang.annotation.ElementType.*; 4 | import static java.lang.annotation.RetentionPolicy.RUNTIME; 5 | 6 | import java.lang.annotation.*; 7 | 8 | import javax.validation.*; 9 | import javax.validation.constraints.*; 10 | 11 | import org.hibernate.validator.constraints.NotBlank; 12 | 13 | import sample.util.JavaRegex; 14 | 15 | /** 16 | * 備考(必須)を表現する制約注釈。 17 | */ 18 | @Documented 19 | @Constraint(validatedBy = {}) 20 | @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER }) 21 | @Retention(RUNTIME) 22 | @ReportAsSingleViolation 23 | @NotBlank 24 | @Size 25 | @Pattern(regexp = "") 26 | public @interface Description { 27 | String message() default "{error.domain.description}"; 28 | 29 | Class>[] groups() default {}; 30 | 31 | Class extends Payload>[] payload() default {}; 32 | 33 | @OverridesAttribute(constraint = Size.class, name = "max") 34 | int max() default 400; 35 | 36 | @OverridesAttribute(constraint = Pattern.class, name = "regexp") 37 | String regexp() default JavaRegex.rWord; 38 | 39 | @OverridesAttribute(constraint = Pattern.class, name = "flags") 40 | Pattern.Flag[] flags() default {}; 41 | 42 | @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER }) 43 | @Retention(RUNTIME) 44 | @Documented 45 | public @interface List { 46 | Description[] value(); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/main/java/sample/util/JavaRegex.java: -------------------------------------------------------------------------------- 1 | package sample.util; 2 | 3 | /** 4 | * 正規表現定数インターフェース。 5 | *
Checker.matchと組み合わせて利用してください。 6 | */ 7 | public interface JavaRegex { 8 | /** Ascii */ 9 | String rAscii = "^\\p{ASCII}*$"; 10 | /** 英字 */ 11 | String rAlpha = "^[a-zA-Z]*$"; 12 | /** 英字大文字 */ 13 | String rAlphaUpper = "^[A-Z]*$"; 14 | /** 英字小文字 */ 15 | String rAlphaLower = "^[a-z]*$"; 16 | /** 英数 */ 17 | String rAlnum = "^[0-9a-zA-Z]*$"; 18 | /** シンボル */ 19 | String rSymbol = "^\\p{Punct}*$"; 20 | /** 英数記号 */ 21 | String rAlnumSymbol = "^[0-9a-zA-Z\\p{Punct}]*$"; 22 | /** 数字 */ 23 | String rNumber = "^[-]?[0-9]*$"; 24 | /** 整数 */ 25 | String rNumberNatural = "^[0-9]*$"; 26 | /** 倍精度浮動小数点 */ 27 | String rDecimal = "^[-]?(\\d+)(\\.\\d+)?$"; 28 | // see UnicodeBlock 29 | /** ひらがな */ 30 | String rHiragana = "^\\p{InHiragana}*$"; 31 | /** カタカナ */ 32 | String rKatakana = "^\\p{InKatakana}*$"; 33 | /** 半角カタカナ */ 34 | String rHankata = "^[。-゚]*$"; 35 | /** 半角文字列 */ 36 | String rHankaku = "^[\\p{InBasicLatin}。-゚]*$"; // ラテン文字 + 半角カタカナ 37 | /** 全角文字列 */ 38 | String rZenkaku = "^[^\\p{InBasicLatin}。-゚]*$"; // 全角の定義を半角以外で割り切り 39 | /** 漢字 */ 40 | String rKanji = "^[\\p{InCJKUnifiedIdeographs}々\\p{InCJKCompatibilityIdeographs}]*$"; 41 | /** 文字 */ 42 | String rWord = "^(?s).*$"; 43 | /** コード */ 44 | String rCode = "^[0-9a-zA-Z_-]*$"; // 英数 + アンダーバー + ハイフン 45 | } 46 | -------------------------------------------------------------------------------- /src/main/java/sample/model/constraints/Email.java: -------------------------------------------------------------------------------- 1 | package sample.model.constraints; 2 | 3 | import static java.lang.annotation.ElementType.*; 4 | import static java.lang.annotation.RetentionPolicy.RUNTIME; 5 | 6 | import java.lang.annotation.*; 7 | 8 | import javax.validation.*; 9 | import javax.validation.constraints.*; 10 | 11 | import org.hibernate.validator.constraints.NotBlank; 12 | 13 | /** 14 | * メールアドレス(必須)を表現する制約注釈。 15 | * low: とりあえずHibernateのEmailValidatorを利用しますが、恐らく最終的に 16 | * 固有のConstraintValidatorを作らされる事になると思います。 17 | */ 18 | @Documented 19 | @Constraint(validatedBy = {}) 20 | @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER }) 21 | @Retention(RUNTIME) 22 | @ReportAsSingleViolation 23 | @NotBlank 24 | @Size 25 | @Pattern(regexp = "") 26 | public @interface Email { 27 | String message() default "{error.domain.email}"; 28 | 29 | Class>[] groups() default {}; 30 | 31 | Class extends Payload>[] payload() default {}; 32 | 33 | @OverridesAttribute(constraint = Size.class, name = "max") 34 | int max() default 256; 35 | 36 | @OverridesAttribute(constraint = Pattern.class, name = "regexp") 37 | String regexp() default ".*"; 38 | 39 | @OverridesAttribute(constraint = Pattern.class, name = "flags") 40 | Pattern.Flag[] flags() default {}; 41 | 42 | @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER }) 43 | @Retention(RUNTIME) 44 | @Documented 45 | public @interface List { 46 | Email[] value(); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/main/java/sample/model/constraints/Password.java: -------------------------------------------------------------------------------- 1 | package sample.model.constraints; 2 | 3 | import static java.lang.annotation.ElementType.*; 4 | import static java.lang.annotation.RetentionPolicy.RUNTIME; 5 | 6 | import java.lang.annotation.*; 7 | 8 | import javax.validation.*; 9 | import javax.validation.constraints.*; 10 | 11 | import org.hibernate.validator.constraints.NotBlank; 12 | 13 | import sample.util.JavaRegex; 14 | 15 | /** 16 | * パスワード(必須)を表現する制約注釈。 17 | * low: 実際の定義はプロジェクトに大きく依存するのでサンプルでは適当にしています。 18 | */ 19 | @Documented 20 | @Constraint(validatedBy = {}) 21 | @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER }) 22 | @Retention(RUNTIME) 23 | @ReportAsSingleViolation 24 | @NotBlank 25 | @Size 26 | @Pattern(regexp = "") 27 | public @interface Password { 28 | String message() default "{error.domain.password}"; 29 | 30 | Class>[] groups() default {}; 31 | 32 | Class extends Payload>[] payload() default {}; 33 | 34 | @OverridesAttribute(constraint = Size.class, name = "max") 35 | int max() default 256; 36 | 37 | @OverridesAttribute(constraint = Pattern.class, name = "regexp") 38 | String regexp() default JavaRegex.rAscii; 39 | 40 | @OverridesAttribute(constraint = Pattern.class, name = "flags") 41 | Pattern.Flag[] flags() default {}; 42 | 43 | @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER }) 44 | @Retention(RUNTIME) 45 | @Documented 46 | public @interface List { 47 | Password[] value(); 48 | } 49 | 50 | } 51 | -------------------------------------------------------------------------------- /src/main/scala/sample/controller/LoginInterceptor.scala: -------------------------------------------------------------------------------- 1 | package sample.controller 2 | 3 | import org.aspectj.lang.annotation._ 4 | import org.springframework.beans.factory.annotation.Autowired 5 | import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean 6 | import org.springframework.context.annotation.Configuration 7 | import org.springframework.stereotype.Component 8 | import sample.context.actor._ 9 | import sample.context.security.SecurityConfigurer 10 | 11 | /** 12 | * Spring Securityの設定状況に応じてスレッドローカルへ利用者を紐付けるAOPInterceptor。 13 | */ 14 | @Aspect 15 | @Configuration 16 | class LoginInterceptor { 17 | @Autowired 18 | private var session: ActorSession = _ 19 | 20 | @Before("execution(* *..controller.system.*Controller.*(..))") 21 | def bindSystem() = session.bind(Actor.System) 22 | @After("execution(* *..controller..*Controller.*(..))") 23 | def unbind() = session.unbind() 24 | } 25 | 26 | /** 27 | * セキュリティの認証設定(extension.security.auth.enabled)が無効時のみ有効される擬似ログイン処理。 28 | *
開発時のみ利用してください。 29 | */ 30 | @Aspect 31 | @Component 32 | @ConditionalOnMissingBean(Array(classOf[SecurityConfigurer])) 33 | class DummyLoginInterceptor { 34 | @Autowired 35 | private var session: ActorSession = _ 36 | 37 | @Before("execution(* *..controller.*Controller.*(..))") 38 | def bindUser() = session.bind(Actor("sample", ActorRoleType.User)) 39 | 40 | @Before("execution(* *..controller.admin.*Controller.*(..))") 41 | def bindAdmin() = session.bind(Actor("admin", ActorRoleType.Internal)); 42 | } 43 | -------------------------------------------------------------------------------- /src/main/scala/sample/model/master/Holiday.scala: -------------------------------------------------------------------------------- 1 | package sample.model.master 2 | 3 | import java.time.{LocalDate, LocalDateTime} 4 | import scalikejdbc.jsr310._ 5 | import scalikejdbc._ 6 | import sample.context.{Entity, Dto} 7 | import sample.context.orm.SkinnyORMMapper 8 | import sample.util.DateUtils 9 | 10 | /** 11 | * 休日マスタを表現します。 12 | */ 13 | case class Holiday( 14 | /** ID */ 15 | id: Long, 16 | /** 休日区分 */ 17 | category: String, 18 | /** 休日 */ 19 | day: LocalDate, 20 | /** 休日名称 */ 21 | name: String) extends Entity 22 | 23 | object Holiday extends SkinnyORMMapper[Holiday] { 24 | override def extract(rs: WrappedResultSet, rn: ResultName[Holiday]): Holiday = autoConstruct(rs, rn) 25 | 26 | /** 休日マスタを取得します。 */ 27 | def get(day: LocalDate)(implicit s: DBSession): Option[Holiday] = 28 | Holiday.withAlias(m => findBy(sqls.eq(m.day, day))) 29 | 30 | /** 休日マスタを登録します。 */ 31 | def register(p: RegHoliday)(implicit s: DBSession): Unit = { 32 | deleteBy(sqls 33 | .eq(Holiday.column.category, p.categoryStr) 34 | .and.between(Holiday.column.day, LocalDate.ofYearDay(p.year, 1), DateUtils.dayTo(p.year))) 35 | p.list.foreach(v => 36 | createWithAttributes('category -> p.categoryStr, 'day -> v.day, 'name -> v.name)) 37 | } 38 | 39 | } 40 | 41 | case class RegHoliday(category: Option[String] = None, year: Int, list: Seq[RegHolidayItem]) extends Dto { 42 | def categoryStr: String = category.getOrElse("default") 43 | } 44 | 45 | case class RegHolidayItem(day: LocalDate, name: String) extends Dto 46 | -------------------------------------------------------------------------------- /src/test/scala/sample/util/ConvertUtilsSpec.scala: -------------------------------------------------------------------------------- 1 | package sample.util 2 | 3 | import org.junit.runner.RunWith 4 | 5 | import org.scalatest.WordSpec 6 | import org.scalatest.junit.JUnitRunner 7 | 8 | import ConvertUtils._ 9 | import org.scalatest.Matchers 10 | 11 | @RunWith(classOf[JUnitRunner]) 12 | class ConvertUtilsSpec extends WordSpec with Matchers { 13 | 14 | "変換ユーティリティ検証" should { 15 | "例外無視変換" in { 16 | quietlyLong("8") should be (Some(8L)) 17 | quietlyLong("a") should be (None) 18 | quietlyInt("8") should be (Some(8)) 19 | quietlyInt("a") should be (None) 20 | quietlyDecimal("8.3") should be (Some(BigDecimal("8.3"))) 21 | quietlyDecimal("a") should be (None) 22 | quietlyBool("true") should be (true) 23 | quietlyBool("a") should be (false) 24 | } 25 | 26 | "文字列変換" in { 27 | zenkakuToHan("aA19aA19あアア") should be ("aA19aA19あアア") 28 | hankakuToZen("aA19aA19あアア") should be ("aA19aA19あアア") 29 | katakanaToHira("aA19aA19あアア") should be ("aA19aA19あああ") 30 | hiraganaToZenKana("aA19aA19あアア") should be ("aA19aA19アアア") 31 | hiraganaToHanKana("aA19aA19あアア") should be ("aA19aA19アアア") 32 | } 33 | 34 | "桁数操作及びサロゲートペア対応" in { 35 | substring("あ𠮷い", 0, 3) should be ("あ𠮷い") 36 | substring("あ𠮷い", 1, 2) should be ("𠮷") 37 | substring("あ𠮷い", 1, 3) should be ("𠮷い") 38 | substring("あ𠮷い", 2, 3) should be ("い") 39 | left("あ𠮷い", 2) should be ("あ𠮷") 40 | leftStrict("あ𠮷い", 6, "UTF-8") should be ("あ𠮷") 41 | } 42 | } 43 | 44 | } -------------------------------------------------------------------------------- /src/main/scala/sample/controller/admin/AssetAdminController.scala: -------------------------------------------------------------------------------- 1 | package sample.controller.admin 2 | 3 | import java.time.LocalDate 4 | 5 | import scala.beans.BeanProperty 6 | 7 | import org.springframework.beans.factory.annotation.Autowired 8 | import org.springframework.stereotype.Service 9 | import org.springframework.web.bind.annotation._ 10 | 11 | import javax.validation.Valid 12 | import sample._ 13 | import sample.controller.ControllerSupport 14 | import sample.model.asset._ 15 | import sample.model.constraints._ 16 | import sample.usecase.AssetAdminService 17 | import java.beans.SimpleBeanInfo 18 | 19 | /** 20 | * 資産に関わる社内のUI要求を処理します。 21 | */ 22 | @RestController 23 | @RequestMapping(Array("/api/admin/asset")) 24 | class AssetAdminController extends ControllerSupport { 25 | 26 | @Autowired 27 | private var service: AssetAdminService = _ 28 | 29 | /** 未処理の振込依頼情報を検索します。 */ 30 | @GetMapping(Array("/cio/")) 31 | def findCashInOut(@Valid p: FindCashInOutParam): Seq[CashInOut] = 32 | service.findCashInOut(p.convert) 33 | } 34 | 35 | /** FindCashInOutのUI変換パラメタ */ 36 | @SimpleBeanInfo 37 | class FindCashInOutParam { 38 | @CurrencyEmpty 39 | @BeanProperty 40 | var currency: String = _ 41 | @BeanProperty 42 | var statusTypes: Array[String] = Array() 43 | @ISODate 44 | @BeanProperty 45 | var updFromDay: LocalDate = _ 46 | @ISODate 47 | @BeanProperty 48 | var updToDay: LocalDate = _ 49 | def convert: FindCashInOut = 50 | FindCashInOut( 51 | Option(currency), statusTypes.map(ActionStatusType.withName(_)), updFromDay, updToDay) 52 | } 53 | -------------------------------------------------------------------------------- /src/main/scala/sample/model/asset/Asset.scala: -------------------------------------------------------------------------------- 1 | package sample.model.asset 2 | 3 | import java.time.LocalDate 4 | 5 | import sample.context._ 6 | import sample.util.Calculator 7 | import scalikejdbc._ 8 | import scalikejdbc.jsr310.Implicits._ 9 | 10 | /** 11 | * 口座の資産概念を表現します。 12 | * asset配下のEntityを横断的に取り扱います。 13 | * low: 実際の開発では多通貨や執行中/拘束中のキャッシュフローアクションに対する考慮で、サービスによってはかなり複雑になります。 14 | */ 15 | case class Asset(/** 口座ID */ id: String) { 16 | /** 17 | * 振込出金可能か判定します。 18 | *
0 <= 口座残高 + 未実現キャッシュフロー - (出金依頼拘束額 + 出金依頼額) 19 | * low: 判定のみなのでscale指定は省略。余力金額を返す時はきちんと指定する 20 | */ 21 | def canWithdraw(currency: String, absAmount: BigDecimal, valueDay: LocalDate)(implicit s: DBSession, dh: DomainHelper): Boolean = { 22 | 0 <= 23 | (calcUnprocessedCio( 24 | calcUnrealizeCf(calcCashBalance(currency), currency, valueDay), currency) 25 | - absAmount 26 | ).decimal.signum 27 | } 28 | private def calcCashBalance(currency: String)(implicit s: DBSession, dh: DomainHelper): Calculator = 29 | Calculator(CashBalance.getOrNew(id, currency).amount) 30 | private def calcUnrealizeCf(base: Calculator, currency: String, valueDay: LocalDate)(implicit s: DBSession, dh: DomainHelper): Calculator = 31 | Cashflow.findUnrealize(id, currency, valueDay).foldLeft(base)( 32 | (calc, cf) => calc + cf.amount) 33 | private def calcUnprocessedCio(base: Calculator, currency: String)(implicit s: DBSession, dh: DomainHelper): Calculator = 34 | CashInOut.findUnprocessed(id, currency, true).foldLeft(base)( 35 | (calc, cio) => calc - cio.absAmount) 36 | 37 | } -------------------------------------------------------------------------------- /src/test/scala/sample/controller/AssetControllerSpec.scala: -------------------------------------------------------------------------------- 1 | package sample.controller 2 | 3 | import org.mockito.BDDMockito._ 4 | import org.junit.Test 5 | import org.junit.runner.RunWith 6 | import sample.ControllerSpecSupport 7 | import scalikejdbc.DB 8 | import sample.model.DataFixtures 9 | import org.springframework.test.context.junit4.SpringRunner 10 | import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest 11 | import org.springframework.boot.test.mock.mockito.MockBean 12 | import sample.usecase.AssetService 13 | import sample.JsonExpects 14 | import sample.model.asset.CashInOut 15 | import sample.ActionStatusType 16 | 17 | //low: 簡易な正常系検証が中心 ( 現状うまく起動できていないのでコメントアウト ) 18 | //@RunWith(classOf[SpringRunner]) 19 | @WebMvcTest(Array(classOf[AssetController])) 20 | class AssetControllerSpec extends ControllerSpecSupport { 21 | 22 | @MockBean 23 | var service: AssetService = _ 24 | 25 | override def prefix = "/api/asset" 26 | 27 | @Test 28 | def 未処理の振込依頼情報を検索します() = { 29 | given(service.findUnprocessedCashOut).willReturn(resultCashOuts()) 30 | performGet("/cio/unprocessedOut/", 31 | JsonExpects.success() 32 | .value("$[0].currency", "JPY") 33 | .value("$[0].absAmount", 3000) 34 | .value("$[1].absAmount", 4000)); 35 | } 36 | 37 | def resultCashOuts(): List[CashInOut] = List(cio("3000"), cio("4000")) 38 | def cio(absAmount: String): CashInOut = 39 | CashInOut(0, "sample", "JPY", BigDecimal(absAmount), true, null, null, null, null, null, null, null, null, ActionStatusType.Unprocessed, null, null) 40 | 41 | } -------------------------------------------------------------------------------- /src/main/scala/sample/context/orm/Pagination.scala: -------------------------------------------------------------------------------- 1 | package sample.context.orm 2 | 3 | import scala.math.BigDecimal._ 4 | import scala.math.BigDecimal.RoundingMode 5 | 6 | import sample.context.Dto 7 | import sample.util.Calculator 8 | 9 | /** 10 | * ページング情報を表現します。 11 | */ 12 | case class Pagination( 13 | /** ページ数(1開始) */ 14 | page: Int, 15 | /** ページあたりの件数 */ 16 | size: Int, 17 | /** トータル件数 */ 18 | total: Option[Long], 19 | /** トータル件数算出を無視するか */ 20 | ignoreTotal: Boolean, 21 | /** ソート条件 */ 22 | sort: Option[Sort]) extends Dto { 23 | 24 | /** カウント算出を無効化します。 */ 25 | def enableIgnoreTotal(): Pagination = copy(ignoreTotal = true) 26 | /** ソート指定が未指定の時は与えたソート条件で上書きします。 */ 27 | def sortIfEmpty(orders: SortOrder*): Pagination = 28 | copy(sort = Some( 29 | sort 30 | .map(s => if (s.orders.nonEmpty) s.ifEmpty(orders: _*) else s) 31 | .getOrElse(Sort(orders)) 32 | )) 33 | /** 最大ページ数を返します。total設定時のみ適切な値が返されます。 */ 34 | def maxPage: Int = total.map(t => (Calculator(t, 0, RoundingMode.UP) / size).int).getOrElse(0) 35 | /** 開始件数を返します。 */ 36 | def firstResult: Int = (page - 1) * size 37 | } 38 | object Pagination { 39 | val defaultSize: Int = 100 40 | def apply(): Pagination = apply(1) 41 | def apply(page: Int): Pagination = apply(page, defaultSize) 42 | def apply(page: Int, size: Int): Pagination = apply(page, size, null) 43 | def apply(page: Int, size: Int, sort: Sort): Pagination = Pagination(page, size, None, false, Option(sort)) 44 | def apply(req: Pagination, total: Long): Pagination = apply(req.page, req.size, Some(total), false, req.sort) 45 | } 46 | -------------------------------------------------------------------------------- /src/main/scala/sample/ActionStatusType.scala: -------------------------------------------------------------------------------- 1 | package sample 2 | 3 | import com.fasterxml.jackson.annotation.JsonValue 4 | 5 | import sample.context.Enums 6 | 7 | /** 8 | * 何らかの行為に関わる処理ステータス概念。 9 | */ 10 | sealed trait ActionStatusType { 11 | @JsonValue def value: String = this.toString() 12 | /** 完了済みのステータスの時はtrue */ 13 | def isFinish: Boolean = ActionStatusType.finishTypes.contains(this) 14 | /** 未完了のステータス(処理中は含めない)の時はtrue */ 15 | def isUnprocessing: Boolean = ActionStatusType.unprocessingTypes.contains(this) 16 | /** 未完了のステータス(処理中も含める)の時はtrue */ 17 | def isUnprocessed: Boolean = ActionStatusType.unprocessedTypes.contains(this) 18 | } 19 | object ActionStatusType extends Enums[ActionStatusType] { 20 | /** 未処理 */ 21 | case object Unprocessed extends ActionStatusType 22 | /** 処理中 */ 23 | case object Processing extends ActionStatusType 24 | /** 処理済 */ 25 | case object Processed extends ActionStatusType 26 | /** 取消 */ 27 | case object Cancelled extends ActionStatusType 28 | /** エラー */ 29 | case object Error extends ActionStatusType 30 | 31 | override def values = List(Unprocessed, Processing, Processed, Cancelled, Error) 32 | 33 | /** 完了済みのステータス一覧 */ 34 | def finishTypes = List(Processed, Cancelled) 35 | def finishTypeValues: List[String] = finishTypes.map(_.value) 36 | /** 未完了のステータス一覧(処理中は含めない) */ 37 | def unprocessingTypes = List(Unprocessed, Error) 38 | def unprocessingTypeValues: List[String] = unprocessingTypes.map(_.value) 39 | /** 未完了のステータス一覧(処理中も含める) */ 40 | def unprocessedTypes = List(Unprocessed, Processing, Error) 41 | def unprocessedTypeValues: List[String] = unprocessedTypes.map(_.value) 42 | } 43 | -------------------------------------------------------------------------------- /src/main/scala/sample/util/Validator.scala: -------------------------------------------------------------------------------- 1 | package sample.util 2 | 3 | import scala.util.Try 4 | 5 | import sample.{Warn, ValidationException} 6 | 7 | /** 8 | * 審査例外の構築概念を表現します。 9 | *
Java版と異なり副作用を発生させないので、複数のチェックを実施する際は
10 | * メソッドチェインで実行するようにしてください。
11 | */
12 | case class Validator(warns: Seq[Warn]) {
13 |
14 | /** 審査を行います。validがfalseの時に例外を内部にスタックします。 */
15 | def check(valid: Boolean, message: String): Validator =
16 | if (valid) this else Validator(warns :+ Warn(message))
17 |
18 | /** 個別属性の審査を行います。validがfalseの時に例外を内部にスタックします。 */
19 | def checkField(valid: Boolean, field: String, message: String): Validator =
20 | if (valid) this else Validator(warns :+ Warn(field, message))
21 |
22 | /** 審査を行います。失敗した時は即時に例外を発生させます。 */
23 | def verify(valid: Boolean, message: String): Validator =
24 | check(valid, message).verify()
25 |
26 | /** 個別属性の審査を行います。失敗した時は即時に例外を発生させます。 */
27 | def verifyField(valid: Boolean, field: String, message: String): Validator =
28 | checkField(valid, field, message).verify()
29 |
30 | /** 検証します。事前に行ったcheckで例外が存在していた時は例外を発生させます。 */
31 | def verify(): Validator =
32 | if (warns.nonEmpty) throw ValidationException(warns)
33 | else clear()
34 |
35 | /** 審査例外を保有している時はtrueを返します。 */
36 | def hasWarn(): Boolean = warns.nonEmpty
37 |
38 | /** 初期化します。 */
39 | def clear(): Validator = Validator()
40 |
41 | }
42 | object Validator {
43 | def apply(): Validator = Validator(Seq())
44 |
45 | /** 審査処理を行います。 */
46 | def validate(proc: Validator => Validator): Unit =
47 | proc(Validator()).verify()
48 |
49 | def validateTry(proc: Validator => Validator): Try[Unit] = Try(validate(proc))
50 | }
51 |
52 |
--------------------------------------------------------------------------------
/src/test/scala/sample/ContainerSpecSupport.scala:
--------------------------------------------------------------------------------
1 | package sample
2 |
3 | import org.springframework.beans.factory.annotation.Autowired
4 | import org.springframework.test.annotation.DirtiesContext
5 | import org.springframework.test.context.web.WebAppConfiguration
6 | import sample.context.DomainHelper
7 | import sample.model.BusinessDayHandler
8 | import scalikejdbc._
9 | import org.springframework.boot.test.context.SpringBootTest
10 | import org.springframework.test.context.ActiveProfiles
11 | import sample.context.actor.ActorRoleType
12 | import sample.context.actor.Actor
13 | import sample.model.DataFixtures
14 |
15 | /**
16 | * Springコンテナを用いたフルセットの検証用途。
17 | */
18 | //low: メソッド毎にコンテナ初期化を望む時はDirtiesContextでClassMode.AFTER_EACH_TEST_METHODを利用
19 | @SpringBootTest(classes = Array(classOf[Application]))
20 | @ActiveProfiles(Array("test"))
21 | abstract class ContainerSpecSupport {
22 |
23 | @Autowired
24 | protected implicit var dh: DomainHelper = _
25 | @Autowired
26 | protected var businessDay: BusinessDayHandler = _
27 | @Autowired
28 | protected var fixtures: DataFixtures = _
29 |
30 | /** 利用者として擬似ログインします */
31 | protected def loginUser(id: String): Unit =
32 | dh.actorSession.bind(Actor(id, ActorRoleType.User));
33 |
34 | /** 社内利用者として擬似ログインします */
35 | protected def loginInternal(id: String): Unit =
36 | dh.actorSession.bind(Actor(id, ActorRoleType.Internal));
37 |
38 | /** システム利用者として擬似ログインします */
39 | protected def loginSystem(): Unit =
40 | dh.actorSession.bind(Actor.System);
41 |
42 | /** トランザクション処理を実行します。 */
43 | protected def tx[T](callable: DBSession => T): T =
44 | DB.localTx(implicit session => callable(session))
45 |
46 | }
--------------------------------------------------------------------------------
/src/main/scala/sample/model/account/Login.scala:
--------------------------------------------------------------------------------
1 | package sample.model.account
2 |
3 | import org.springframework.security.crypto.password.PasswordEncoder
4 |
5 | import sample._
6 | import sample.context._
7 | import sample.context.orm.SkinnyORMMapperWithIdStr
8 | import scalikejdbc._
9 |
10 | /**
11 | * 口座ログインを表現します。
12 | * low: サンプル用に必要最低限の項目だけ
13 | */
14 | case class Login(
15 | /** 口座ID */
16 | id: String,
17 | /** ログインID */
18 | loginId: String,
19 | /** パスワード(暗号化済) */
20 | password: String) extends Entity
21 |
22 | object Login extends SkinnyORMMapperWithIdStr[Login] {
23 | override def extract(rs: WrappedResultSet, rn: ResultName[Login]): Login = autoConstruct(rs, rn)
24 |
25 | /** ログイン情報を取得します。 */
26 | def getByLoginId(loginId: String)(implicit s: DBSession): Option[Login] =
27 | Login.withAlias(m => findBy(sqls.eq(m.loginId, loginId)))
28 |
29 | /** ログイン情報を取得します。 */
30 | def load(id: String)(implicit s: DBSession): Login =
31 | findById(id).getOrElse(throw ValidationException(ErrorKeys.EntityNotFound))
32 |
33 | /** ログイン情報を登録します。 */
34 | def register(encoder: PasswordEncoder, p: RegAccount)(implicit s: DBSession): String =
35 | Login.createWithAttributes(
36 | 'id -> p.id, 'loginId -> p.id, 'password -> encoder.encode(p.plainPassword))
37 |
38 | /** ログインIDを変更します。 */
39 | def changeLoginId(id: String, loginId: String)(implicit s: DBSession): Unit =
40 | Login.updateById(id).withAttributes('loginId -> loginId)
41 |
42 | /** パスワードを変更します。 */
43 | def changePassword(id: String, encoder: PasswordEncoder, plainPassword: String)(implicit s: DBSession): Unit =
44 | Login.updateById(id).withAttributes('password -> encoder.encode(plainPassword))
45 |
46 | }
47 |
--------------------------------------------------------------------------------
/src/main/resources/ehcache.xml:
--------------------------------------------------------------------------------
1 |
2 |
受渡日を迎えたキャッシュフローを残高に反映します。 49 | */ 50 | def realizeCashflow(): Unit = 51 | audit.audit("キャッシュフローを実現する", 52 | tx(implicit session => 53 | //low: 日回し後の実行を想定 54 | Cashflow.findDoRealize(dh.time.day).foreach(cf => 55 | idLock.call(cf.accountId, LockType.WRITE, 56 | Try(cf.realize()) match { 57 | case Success(v) => // nothing. 58 | case Failure(e) => 59 | logger.error(s"[${cf.id}] キャッシュフローの実現に失敗しました。", e) 60 | Try(cf.error()) match { 61 | case Success(v) => // nothing. 62 | case Failure(ex) => //low: 2重障害(恐らくDB起因)なのでloggerのみの記載に留める 63 | } 64 | } 65 | ) 66 | ) 67 | ) 68 | ) 69 | 70 | } -------------------------------------------------------------------------------- /src/main/scala/sample/usecase/SecurityService.scala: -------------------------------------------------------------------------------- 1 | package sample.usecase 2 | 3 | import org.springframework.boot.autoconfigure.condition._ 4 | import org.springframework.context.annotation.Bean 5 | 6 | import org.springframework.context.annotation.Configuration 7 | import org.springframework.security.core.authority.SimpleGrantedAuthority 8 | import org.springframework.security.core.userdetails.UsernameNotFoundException 9 | 10 | import sample.ErrorKeys 11 | import sample.context.security._ 12 | import sample.util.ConvertUtils 13 | 14 | /** 15 | * SpringSecurityのユーザアクセスコンポーネントを定義します。 16 | */ 17 | @Configuration 18 | class SecurityService { 19 | 20 | /** 一般利用者情報を提供します。(see SecurityActorFinder) */ 21 | @Bean 22 | @ConditionalOnBean(Array(classOf[SecurityConfigurer])) 23 | def securityUserService(service: AccountService): SecurityUserService = 24 | new SecurityUserService { 25 | def loadUserByUsername(username: String): ActorDetails = 26 | Option(username).map(ConvertUtils.zenkakuToHan(_)).flatMap(loginId => 27 | service.getLoginByLoginId(loginId).flatMap(login => 28 | service.getAccount(login.id).map(account => 29 | ActorDetails(account.actor, login.password, List(new SimpleGrantedAuthority("ROLE_USER"))) 30 | ) 31 | ) 32 | ).getOrElse(throw new UsernameNotFoundException(ErrorKeys.Login)) 33 | } 34 | /** 社内管理向けの利用者情報を提供します。(see SecurityActorFinder) */ 35 | @Bean 36 | @ConditionalOnBean(Array(classOf[SecurityConfigurer])) 37 | @ConditionalOnProperty(prefix = "extension.security.auth", name = Array("admin"), matchIfMissing = false) 38 | def securityAdminService(service: MasterAdminService): SecurityAdminService = 39 | new SecurityAdminService { 40 | def loadUserByUsername(username: String): ActorDetails = 41 | Option(username).map(ConvertUtils.zenkakuToHan(_)).flatMap(staffId => 42 | service.getStaff(staffId).map(staff => 43 | ActorDetails(staff.actor, staff.password, 44 | service.findStaffAuthority(staffId).map(sa => new SimpleGrantedAuthority(sa.authority)) 45 | :+ new SimpleGrantedAuthority("ROLE_ADMIN")) 46 | ) 47 | ).getOrElse(throw new UsernameNotFoundException(ErrorKeys.Login)) 48 | } 49 | } -------------------------------------------------------------------------------- /src/main/scala/sample/context/security/SecurityProperties.scala: -------------------------------------------------------------------------------- 1 | package sample.context.security 2 | 3 | import scala.beans.BeanProperty 4 | 5 | import org.springframework.boot.context.properties._ 6 | import java.beans.SimpleBeanInfo 7 | 8 | 9 | 10 | /** セキュリティ関連の設定情報を表現します。 */ 11 | @SimpleBeanInfo 12 | @ConfigurationProperties(prefix = "extension.security") 13 | class SecurityProperties { 14 | /** Spring Security依存の認証/認可設定情報 */ 15 | @BeanProperty 16 | var auth: SecurityAuthProperties = new SecurityAuthProperties() 17 | /** CORS設定情報 */ 18 | @BeanProperty 19 | var cors: SecurityCorsProperties = new SecurityCorsProperties() 20 | } 21 | 22 | /** Spring Securityに対する拡張設定情報。(ScurityConfig#SecurityPropertiesによって管理されています) */ 23 | @SimpleBeanInfo 24 | class SecurityAuthProperties { 25 | /** リクエスト時のログインIDを取得するキー */ 26 | @BeanProperty 27 | var loginKey = "loginId" 28 | /** リクエスト時のパスワードを取得するキー */ 29 | @BeanProperty 30 | var passwordKey = "password" 31 | /** 認証対象パス */ 32 | @BeanProperty 33 | var path = Array("/api/**") 34 | /** 認証対象パス(管理者向け) */ 35 | @BeanProperty 36 | var pathAdmin = Array("/api/admin/**") 37 | /** 認証除外パス(認証対象からの除外) */ 38 | @BeanProperty 39 | var excludesPath = Array("/api/system/job/**") 40 | /** 認証無視パス(フィルタ未適用の認証未考慮、静的リソース等) */ 41 | @BeanProperty 42 | var ignorePath = Array("/css/**", "/js/**", "/img/**", "/**/favicon.ico") 43 | /** ログインAPIパス */ 44 | @BeanProperty 45 | var loginPath = "/api/login" 46 | /** ログアウトAPIパス */ 47 | @BeanProperty 48 | var logoutPath = "/api/logout" 49 | /** 一人が同時利用可能な最大セッション数 */ 50 | @BeanProperty 51 | var maximumSessions: Int = 2 52 | /** 53 | * 社員向けモードの時はtrue。 54 | *
ログインパスは同じですが、ログイン処理の取り扱いが切り替わります。 55 | *
本コンポーネントはAPI経由でのラベル一覧の提供等、i18n用途のメッセージプロパティで利用してください。 15 | *
ResourceBundleは単純な文字列変換を目的とする標準のMessageSourceとは異なる特性(リスト概念)を 16 | * 持つため、別インスタンスでの管理としています。 17 | * (spring.messageとは別に指定[extension.messages]する必要があるので注意してください) 18 | */ 19 | @ConfigurationProperties(prefix = "extension.messages") 20 | class ResourceBundleHandler { 21 | 22 | @BeanProperty 23 | var encoding: String = "UTF-8" 24 | val factory: ResourceBundleFactory = new ResourceBundleFactory() 25 | val bundleMap: scala.collection.mutable.Map[String, ResourceBundle] = scala.collection.mutable.Map() 26 | 27 | def get(basename: String): ResourceBundle = get(basename, Locale.getDefault) 28 | def get(basename: String, locale: Locale): ResourceBundle = 29 | this.synchronized( 30 | bundleMap.getOrElseUpdate(keyname(basename, locale), 31 | factory.create(basename, locale, encoding)) 32 | ) 33 | private def keyname(basename: String, locale: Locale): String = s"${basename}_${locale.toLanguageTag()}" 34 | 35 | /** 36 | * 指定されたメッセージソースのラベルキー、値のMapを返します。 37 | *
basenameに拡張子(.properties)を含める必要はありません。 38 | */ 39 | def labels(basename: String): Map[String, String] = 40 | labels(basename, Locale.getDefault) 41 | def labels(basename: String, locale: Locale): Map[String, String] = { 42 | val bundle = get(basename) 43 | bundle.keySet().asScala.map(key => (key, bundle.getString(key))).toMap 44 | } 45 | 46 | 47 | } 48 | 49 | /** 50 | * SpringのMessageSource経由でResourceBundleを取得するFactory。 51 | *
プロパティファイルのエンコーディング指定を可能にしています。 52 | */ 53 | class ResourceBundleFactory extends ResourceBundleMessageSource { 54 | def create(basename: String, locale: Locale, encoding: String): ResourceBundle = { 55 | this.setDefaultEncoding(encoding) 56 | Option(getResourceBundle(basename, locale)).getOrElse( 57 | throw new IllegalArgumentException("指定されたbasenameのリソースファイルは見つかりませんでした。[]")) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/main/scala/sample/controller/AssetController.scala: -------------------------------------------------------------------------------- 1 | package sample.controller 2 | 3 | import java.time._ 4 | 5 | import scala.beans.BeanProperty 6 | 7 | import org.springframework.beans.factory.annotation.Autowired 8 | import org.springframework.http.ResponseEntity 9 | import org.springframework.web.bind.annotation._ 10 | 11 | import javax.validation.Valid 12 | import sample.ActionStatusType 13 | import sample.model.asset._ 14 | import sample.model.constraints._ 15 | import sample.usecase.AssetService 16 | import java.beans.SimpleBeanInfo 17 | 18 | /** 19 | * 資産に関わる顧客のUI要求を処理します。 20 | */ 21 | @RestController 22 | @RequestMapping(Array("/api/asset")) 23 | class AssetController extends ControllerSupport { 24 | 25 | @Autowired 26 | private var service: AssetService = _ 27 | 28 | /** 未処理の振込依頼情報を検索します。 */ 29 | @GetMapping(value = Array("/cio/unprocessedOut/")) 30 | def findUnprocessedCashOut: Seq[CashOutUI] = 31 | service.findUnprocessedCashOut.map(CashOutUI(_)) 32 | 33 | /** 34 | * 振込出金依頼をします。 35 | * low: RestControllerの標準の振る舞いとしてvoidやプリミティブ型はJSON化されないので注意してください。 36 | * (解析時の優先順位の関係だと思いますが) 37 | */ 38 | @PostMapping(Array("/cio/withdraw")) 39 | def withdraw(@Valid p: RegCashOutParam): ResponseEntity[Long] = 40 | result(service.withdraw(p.convert)) 41 | 42 | } 43 | 44 | /** 振込出金依頼情報の表示用Dto */ 45 | case class CashOutUI(id: Long, currency: String, absAmount: BigDecimal, 46 | requestDay: LocalDate, requestDate: LocalDateTime, eventDay: LocalDate, valueDay: LocalDate, 47 | statusType: ActionStatusType, updateDate: LocalDateTime, cashflowId: Option[Long]) 48 | object CashOutUI { 49 | def apply(cio: CashInOut): CashOutUI = 50 | CashOutUI(cio.id, cio.currency, cio.absAmount, cio.requestDay, cio.requestDate, cio.eventDay, cio.valueDay, 51 | cio.statusType, cio.updateDate, cio.cashflowId) 52 | } 53 | 54 | /** 55 | * RegCashOutのUI連携DTO。 56 | * low: SpringMVCで引数に利用するDtoはJavaBeansの仕様を満たす必要があるため、 57 | * 別途詰め替えクラスを用意する必要があります。(取りまとめてtraitなどで分離すると保守性が上がります) 58 | * ※戻り値で利用するEntity/DTOには必要ありません。 59 | * ※本来BeanPropertyの付与は必要ありませんが、Spring Boot 1.3.RELEASE時点で適切なバインドが 60 | * されない事が確認されたため明示的に付与しています。 61 | */ 62 | @SimpleBeanInfo 63 | class RegCashOutParam { 64 | @Currency 65 | @BeanProperty 66 | var currency: String = _ 67 | @AbsAmount 68 | @BeanProperty 69 | var absAmount: java.math.BigDecimal = _ 70 | def convert: RegCashOut = RegCashOut(None, currency, absAmount) 71 | } 72 | -------------------------------------------------------------------------------- /src/main/scala/sample/controller/admin/MasterAdminController.scala: -------------------------------------------------------------------------------- 1 | package sample.controller.admin 2 | 3 | import java.time.LocalDate 4 | 5 | import scala.beans.BeanProperty 6 | 7 | import org.springframework.beans.factory.annotation.Autowired 8 | import org.springframework.http.ResponseEntity 9 | import org.springframework.web.bind.annotation._ 10 | 11 | import javax.validation.Valid 12 | import sample._ 13 | import sample.context.security._ 14 | import sample.controller.ControllerSupport 15 | import sample.model.constraints._ 16 | import sample.model.master._ 17 | import sample.usecase.MasterAdminService 18 | import java.beans.SimpleBeanInfo 19 | 20 | /** 21 | * マスタに関わる社内のUI要求を処理します。 22 | */ 23 | @RestController 24 | @RequestMapping(Array("/api/admin/master")) 25 | class MasterAdminController extends ControllerSupport { 26 | 27 | @Autowired 28 | private var service: MasterAdminService = _ 29 | @Autowired 30 | private var securityProps: SecurityProperties = _ 31 | 32 | /** 社員ログイン状態を確認します。 */ 33 | @GetMapping(Array("/loginStatus")) 34 | def loginStatus: Boolean = true 35 | 36 | /** 社員ログイン情報を取得します。 */ 37 | @GetMapping(Array("/loginStaff")) 38 | def loadLoginStaff: LoginStaff = 39 | if (securityProps.auth.enabled) { 40 | SecurityActorFinder.actorDetails.map(details => 41 | LoginStaff(details.actor.id, details.actor.name, details.authorityIds) 42 | ).getOrElse(throw ValidationException(ErrorKeys.Authentication)) 43 | } else LoginStaff("sample", "sample", Seq()) // for dummy login 44 | 45 | /** 休日を登録します。 */ 46 | @PostMapping(Array("/holiday/")) 47 | def registerHoliday(@Valid p: RegHolidayParam): ResponseEntity[Void] = 48 | resultEmpty(service.registerHoliday(p.convert)) 49 | } 50 | 51 | /** クライアント利用用途に絞ったパラメタ */ 52 | case class LoginStaff(id: String, name: String, authorities: Seq[String]) 53 | 54 | /** RegHolidayのUI変換パラメタ */ 55 | @SimpleBeanInfo 56 | class RegHolidayParam { 57 | @CategoryEmpty 58 | @BeanProperty 59 | var category: String = _ 60 | @Year 61 | @BeanProperty 62 | var year: Int = _ 63 | @Valid 64 | @BeanProperty 65 | var list: Seq[RegHolidayItemParam] = _ 66 | def convert: RegHoliday = RegHoliday(Option(category), year, list.map(_.convert)) 67 | } 68 | @SimpleBeanInfo 69 | class RegHolidayItemParam { 70 | @ISODate 71 | @BeanProperty 72 | var day: LocalDate = _ 73 | @Name 74 | @BeanProperty 75 | var name: String = _ 76 | def convert: RegHolidayItem = RegHolidayItem(day, name) 77 | } 78 | -------------------------------------------------------------------------------- /src/main/scala/sample/context/AppSetting.scala: -------------------------------------------------------------------------------- 1 | package sample.context 2 | 3 | import scala.BigDecimal 4 | 5 | import sample._ 6 | import sample.context.orm.SkinnyORMMapperWithIdStr 7 | import scalikejdbc._ 8 | 9 | /** 10 | * アプリケーション設定情報を表現します。 11 | *
事前に初期データが登録される事を前提とし、値の変更のみ許容します。 12 | */ 13 | case class AppSetting( 14 | /** 設定ID */ 15 | id: String, 16 | /** 区分 */ 17 | category: Option[String] = None, 18 | /** 概要 */ 19 | outline: Option[String] = None, 20 | /** 値 */ 21 | value: String) extends Entity { 22 | 23 | /** 設定情報値を取得します。 */ 24 | def str(): String = value 25 | def str(defaultValue: String): String = Option(value).getOrElse(defaultValue) 26 | def intValue(): Int = value.toInt 27 | def intValue(defaultValue: Int): Int = Option(value).map(_.toInt).getOrElse(defaultValue) 28 | def longValue(): Long = value.toLong 29 | def longValue(defaultValue: Long): Long = Option(value).map(_.toLong).getOrElse(defaultValue) 30 | def bool(): Boolean = value.toBoolean 31 | def bool(defaultValue: Boolean): Boolean = Option(value).map(_.toBoolean).getOrElse(defaultValue) 32 | def decimal(): BigDecimal = BigDecimal(value) 33 | def decimal(defaultValue: BigDecimal): BigDecimal = Option(value).map(BigDecimal(_)).getOrElse(defaultValue) 34 | } 35 | 36 | object AppSetting extends SkinnyORMMapperWithIdStr[AppSetting] { 37 | override def extract(rs: WrappedResultSet, s: ResultName[AppSetting]): AppSetting = autoConstruct(rs, s) 38 | 39 | /** 設定情報を取得します。 */ 40 | def get(id: String)(implicit s: DBSession = autoSession): Option[AppSetting] = findById(id) 41 | def load(id: String)(implicit s: DBSession = autoSession): AppSetting = 42 | get(id).getOrElse(throw ValidationException(ErrorKeys.EntityNotFound)) 43 | 44 | /** アプリケーション設定情報を検索します。 */ 45 | def find(p: FindAppSetting)(implicit s: DBSession = autoSession): Seq[AppSetting] = 46 | AppSetting.withAlias { m => 47 | findAllBy(sqls.toAndConditionOpt( 48 | p.keyword.map(k => 49 | sqls.like(m.id, p.likeKeyword) 50 | .or(sqls.like(m.category, p.likeKeyword)) 51 | .or(sqls.like(m.outline, p.likeKeyword))) 52 | ).getOrElse(sqls.isNotNull(m.id))) 53 | } 54 | 55 | /** 設定情報値を設定します。 */ 56 | def update(id: String, value: String)(implicit s: DBSession = autoSession): Unit = 57 | updateById(id).withAttributes('value -> value) 58 | 59 | } 60 | 61 | case class FindAppSetting(keyword: Option[String] = None) extends Dto { 62 | def likeKeyword = s"%${keyword.getOrElse("")}%" 63 | } 64 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @if "%DEBUG%" == "" @echo off 2 | @rem ########################################################################## 3 | @rem 4 | @rem Gradle startup script for Windows 5 | @rem 6 | @rem ########################################################################## 7 | 8 | @rem Set local scope for the variables with windows NT shell 9 | if "%OS%"=="Windows_NT" setlocal 10 | 11 | set DIRNAME=%~dp0 12 | if "%DIRNAME%" == "" set DIRNAME=. 13 | set APP_BASE_NAME=%~n0 14 | set APP_HOME=%DIRNAME% 15 | 16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 17 | set DEFAULT_JVM_OPTS="-Xmx64m" 18 | 19 | @rem Find java.exe 20 | if defined JAVA_HOME goto findJavaFromJavaHome 21 | 22 | set JAVA_EXE=java.exe 23 | %JAVA_EXE% -version >NUL 2>&1 24 | if "%ERRORLEVEL%" == "0" goto init 25 | 26 | echo. 27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 28 | echo. 29 | echo Please set the JAVA_HOME variable in your environment to match the 30 | echo location of your Java installation. 31 | 32 | goto fail 33 | 34 | :findJavaFromJavaHome 35 | set JAVA_HOME=%JAVA_HOME:"=% 36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 37 | 38 | if exist "%JAVA_EXE%" goto init 39 | 40 | echo. 41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 42 | echo. 43 | echo Please set the JAVA_HOME variable in your environment to match the 44 | echo location of your Java installation. 45 | 46 | goto fail 47 | 48 | :init 49 | @rem Get command-line arguments, handling Windows variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | 53 | :win9xME_args 54 | @rem Slurp the command line arguments. 55 | set CMD_LINE_ARGS= 56 | set _SKIP=2 57 | 58 | :win9xME_args_slurp 59 | if "x%~1" == "x" goto execute 60 | 61 | set CMD_LINE_ARGS=%* 62 | 63 | :execute 64 | @rem Setup the command line 65 | 66 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 67 | 68 | @rem Execute Gradle 69 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 70 | 71 | :end 72 | @rem End local scope for the variables with windows NT shell 73 | if "%ERRORLEVEL%"=="0" goto mainEnd 74 | 75 | :fail 76 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 77 | rem the _cmd.exe /c_ return code! 78 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 79 | exit /b 1 80 | 81 | :mainEnd 82 | if "%OS%"=="Windows_NT" endlocal 83 | 84 | :omega 85 | -------------------------------------------------------------------------------- /src/main/scala/sample/model/asset/CashBalance.scala: -------------------------------------------------------------------------------- 1 | package sample.model.asset 2 | 3 | import java.time.{LocalDate, LocalDateTime} 4 | import java.util.Currency 5 | 6 | import scala.math.BigDecimal.RoundingMode 7 | 8 | import sample.context._ 9 | import sample.context.orm.SkinnyORMMapper 10 | import sample.util.Calculator 11 | import scalikejdbc.jsr310._ 12 | import scalikejdbc._ 13 | 14 | /** 15 | * 口座残高を表現します。 16 | */ 17 | case class CashBalance( 18 | /** ID */ 19 | id: Long = 0, 20 | /** 口座ID */ 21 | accountId: String, 22 | /** 基準日 */ 23 | baseDay: LocalDate, 24 | /** 通貨 */ 25 | currency: String, 26 | /** 金額 */ 27 | amount: BigDecimal, 28 | /** 更新日時 */ 29 | updateDate: LocalDateTime) extends Entity 30 | 31 | object CashBalance extends SkinnyORMMapper[CashBalance] { 32 | override def extract(rs: WrappedResultSet, rn: ResultName[CashBalance]): CashBalance = autoConstruct(rs, rn) 33 | 34 | /** 35 | * 残高へ指定した金額を反映します。 36 | * low ここではCurrencyを使っていますが、実際の通貨桁数や端数処理定義はDBや設定ファイル等で管理されます。 37 | */ 38 | def add(accountId: String, currency: String, amount: BigDecimal)(implicit s: DBSession, dh: DomainHelper): Unit = 39 | Option(getOrNew(accountId, currency)).map(cb => 40 | updateById(cb.id).withAttributes( 41 | 'amount -> 42 | (Calculator( 43 | cb.amount, 44 | Currency.getInstance(currency).getDefaultFractionDigits, 45 | RoundingMode.DOWN) 46 | + amount).decimal 47 | )) 48 | 49 | /** 50 | * 指定口座の残高を取得します。(存在しない時は繰越保存後に取得します) 51 | * low: 複数通貨の適切な考慮や細かい審査は本筋でないので割愛。 52 | */ 53 | def getOrNew(accountId: String, currency: String)(implicit s: DBSession, dh: DomainHelper): CashBalance = 54 | withAlias(m => 55 | findAllBy( 56 | sqls 57 | .eq(m.accountId, accountId) 58 | .and.eq(m.currency, currency) 59 | .and.eq(m.baseDay, dh.time.day), 60 | Seq(m.baseDay.desc)) 61 | ).headOption.getOrElse(create(accountId, currency)) 62 | private def create(accountId: String, currency: String)(implicit s: DBSession, dh: DomainHelper): CashBalance = 63 | withAlias(m => 64 | findAllBy( 65 | sqls 66 | .eq(m.accountId, accountId) 67 | .and.eq(m.currency, currency), 68 | Seq(m.baseDay.desc)) 69 | ).headOption match { 70 | case Some(prev) => // 繰越 71 | findById(persist(accountId, currency, prev.amount)).get 72 | case None => 73 | findById(persist(accountId, currency, BigDecimal(0))).get 74 | } 75 | private def persist(accountId: String, currency: String, amount: BigDecimal)(implicit s: DBSession, dh: DomainHelper): Long = 76 | createWithAttributes( 77 | 'accountId -> accountId, 78 | 'baseDay -> dh.time.day, 79 | 'currency -> currency, 80 | 'amount -> amount, 81 | 'updateDate -> dh.time.date) 82 | } 83 | -------------------------------------------------------------------------------- /src/main/scala/sample/util/Calculator.scala: -------------------------------------------------------------------------------- 1 | package sample.util 2 | 3 | import java.util.concurrent.atomic.AtomicReference 4 | 5 | import scala.math.BigDecimal.RoundingMode 6 | 7 | /** 8 | * 計算ユーティリティ。 9 | */ 10 | case class Calculator( 11 | /** 現在の計算値 */ 12 | value: AtomicReference[math.BigDecimal], 13 | /** 小数点以下桁数 */ 14 | scale: Int, 15 | /** 端数定義。標準では切り捨て */ 16 | mode: RoundingMode.Value, 17 | /** 計算の都度端数処理をする時はtrue */ 18 | roundingAlways: Boolean) { 19 | /** scale未設定時の除算scale値 */ 20 | private val defaultScale: Int = 18 21 | 22 | private def set(v: BigDecimal): Calculator = { 23 | value.set(rounding(v)) 24 | this 25 | } 26 | 27 | def +(v: BigDecimal) = set(decimal + v) 28 | def +(v: Number) = set(decimal + BigDecimal(v.toString())) 29 | 30 | private def rounding(v: BigDecimal): BigDecimal = 31 | if (roundingAlways) v.setScale(scale, mode) else v 32 | 33 | def -(v: BigDecimal) = set(decimal - v) 34 | def -(v: Number) = set(decimal - BigDecimal(v.toString())) 35 | 36 | def *(v: BigDecimal) = set(decimal * v) 37 | def *(v: Number) = set(decimal * BigDecimal(v.toString())) 38 | 39 | def /(v: BigDecimal) = set(decimal / v) 40 | def /(v: Number) = set(decimal / BigDecimal(v.toString())) 41 | 42 | /** 計算結果をint型で返します */ 43 | def int:Int = decimal.intValue() 44 | 45 | /** 計算結果をlong型で返します。 */ 46 | def long = decimal.longValue() 47 | 48 | /** 計算結果をBigDecimal型で返します。 */ 49 | def decimal: BigDecimal = 50 | Option(value.get()).getOrElse(BigDecimal(0)).setScale(scale, mode) 51 | } 52 | 53 | object Calculator { 54 | def apply(): Calculator = apply(BigDecimal("0")) 55 | def apply(scale: Int, mode: RoundingMode.Value): Calculator = apply(BigDecimal("0"), scale, mode) 56 | def apply(scale: Int, mode: RoundingMode.Value, roundingAlways: Boolean): Calculator = apply(BigDecimal("0"), scale, mode, roundingAlways) 57 | def apply(v: String): Calculator = apply(BigDecimal(v)) 58 | def apply(v: String, scale: Int, mode: RoundingMode.Value): Calculator = apply(BigDecimal(v), scale, mode) 59 | def apply(v: String, scale: Int, mode: RoundingMode.Value, roundingAlways: Boolean): Calculator = apply(BigDecimal(v), scale, mode, roundingAlways) 60 | def apply(v: Number): Calculator = apply(v.toString()) 61 | def apply(v: Number, scale: Int, mode: RoundingMode.Value): Calculator = apply(v.toString(), scale, mode) 62 | def apply(v: Number, scale: Int, mode: RoundingMode.Value, roundingAlways: Boolean): Calculator = apply(v.toString(), scale, mode, roundingAlways) 63 | def apply(v: BigDecimal): Calculator = apply(v, 0, RoundingMode.DOWN) 64 | def apply(v: BigDecimal, scale: Int, mode: RoundingMode.Value): Calculator = apply(v, scale, mode, false) 65 | def apply(v: BigDecimal, scale: Int, mode: RoundingMode.Value, roundingAlways: Boolean): Calculator = 66 | new Calculator(new AtomicReference(v), scale, mode, roundingAlways) 67 | } 68 | -------------------------------------------------------------------------------- /src/main/scala/sample/model/account/Account.scala: -------------------------------------------------------------------------------- 1 | package sample.model.account 2 | 3 | import org.springframework.security.crypto.password.PasswordEncoder 4 | 5 | import com.fasterxml.jackson.annotation.JsonValue 6 | 7 | import sample._ 8 | import sample.context._ 9 | import sample.context.actor.{ Actor, ActorRoleType } 10 | import sample.context.orm.SkinnyORMMapperWithIdStr 11 | import scalikejdbc._ 12 | 13 | /** 14 | * 口座を表現します。 15 | * low: サンプル用に必要最低限の項目だけ 16 | */ 17 | case class Account( 18 | /** 口座ID */ 19 | id: String, 20 | /** 口座名義 */ 21 | name: String, 22 | /** メールアドレス */ 23 | mail: String, 24 | /** 口座状態 */ 25 | statusType: AccountStatusType) extends Entity { 26 | 27 | def actor: Actor = Actor(id, name, ActorRoleType.User) 28 | 29 | /** 口座に紐付くログイン情報を取得します。 */ 30 | def loadLogin(implicit s: DBSession): Login = Login.load(id) 31 | } 32 | 33 | object Account extends AccountMapper { 34 | 35 | /** 有効な口座を取得します。 */ 36 | def getActive(id: String)(implicit s: DBSession): Option[Account] = 37 | findById(id).filter(!_.statusType.inactive) 38 | 39 | /** 有効な口座を返します */ 40 | def loadActive(id: String)(implicit s: DBSession): Account = findById(id) match { 41 | case Some(acc) if acc.statusType.inactive => throw ValidationException("error.Account.loadActive") 42 | case Some(acc) => acc 43 | case None => throw ValidationException(ErrorKeys.EntityNotFound) 44 | } 45 | 46 | /** 47 | * 口座の登録を行います。 48 | *
ログイン情報も同時に登録されます。 49 | */ 50 | def register(encoder: PasswordEncoder, p: RegAccount)(implicit s: DBSession): String = { 51 | Login.register(encoder, p) 52 | createWithAttributes('id -> p.id, 'name -> p.name, 'mail -> p.mail, 53 | 'statusType -> AccountStatusType.Normal.value) 54 | } 55 | 56 | /** 口座を変更します。 */ 57 | def change(id: String, p: ChgAccount)(implicit s: DBSession): Unit = 58 | updateById(id).withAttributes('name -> p.name, 'mail -> p.mail) 59 | } 60 | 61 | trait AccountMapper extends SkinnyORMMapperWithIdStr[Account] { 62 | override def extract(rs: WrappedResultSet, n: ResultName[Account]) = 63 | Account( 64 | id = rs.string(n.id), 65 | name = rs.string(n.name), 66 | mail = rs.string(n.mail), 67 | statusType = AccountStatusType.withName(rs.string(n.statusType))) 68 | } 69 | 70 | /** 口座状態を表現します */ 71 | sealed trait AccountStatusType extends EnumSealed { 72 | @JsonValue def value: String = this.toString() 73 | def inactive: Boolean = (this == AccountStatusType.Withdrawal) 74 | } 75 | object AccountStatusType extends Enums[AccountStatusType] { 76 | /** 通常 */ 77 | case object Normal extends AccountStatusType 78 | /** 退会 */ 79 | case object Withdrawal extends AccountStatusType 80 | 81 | override def values = List(Normal, Withdrawal) 82 | } 83 | 84 | /** 登録パラメタ */ 85 | case class RegAccount(id: String, name: String, mail: String, plainPassword: String) 86 | 87 | /** 変更パラメタ */ 88 | case class ChgAccount(name: String, mail: String) 89 | -------------------------------------------------------------------------------- /src/main/resources/messages-validation.properties: -------------------------------------------------------------------------------- 1 | # サービスで利用される例外メッセージファイルです。 2 | 3 | # -- Bean Validation 4 | javax.validation.constraints.AssertFalse.message = 選択してはいけません。 5 | javax.validation.constraints.AssertTrue.message = 選択してください。 6 | javax.validation.constraints.DecimalMax.message = {value}以下の値を入力してください。 7 | javax.validation.constraints.DecimalMin.message = {value}以上の値を入力してください。 8 | javax.validation.constraints.Digits.message = {integer}桁(小数部{fraction}桁)以内の数字を入力してください。 9 | javax.validation.constraints.Future.message = 未来日時を入力してください。 10 | javax.validation.constraints.Max.message = {value}桁以下で入力してください。 11 | javax.validation.constraints.Min.message = {value}桁以上で入力してください。 12 | javax.validation.constraints.NotNull.message = 入力してください。 13 | javax.validation.constraints.Null.message = 未入力にしてください。 14 | javax.validation.constraints.Past.message = 過去日時を入力してください。 15 | javax.validation.constraints.Pattern.message = 正しい形式で入力してください。 16 | javax.validation.constraints.Size.message = {max}文字以内で入力してください。 17 | org.hibernate.validator.constraints.NotBlank.message = 入力してください。 18 | 19 | # -- Errors [Type] 20 | typeMismatch.java.math.BigDecimal=数字を入力して下さい。 21 | typeMismatch.java.lang.Integer=数字を入力して下さい。 22 | typeMismatch.java.lang.Long=数字を入力して下さい。 23 | typeMismatch.java.time.LocalDate=日付を入力して下さい。 24 | typeMismatch.java.time.LocalDateTime=日時を入力して下さい。 25 | 26 | # -- Errors [Exception] 27 | error.Exception=サーバー側で問題が発生した可能性があります。 28 | error.EntityNotFoundException=情報が見つかりませんでした。 29 | error.OptimisticLockingFailure=対象情報は他の利用者によって更新されました。 30 | error.Authentication=ログイン状態が有効ではありません。 31 | error.AccessDeniedException=対象機能の利用が認められていません。 32 | error.ServletRequestBinding=適切でない本文フォーマットの要求を受け付けました。 33 | error.HttpMediaTypeNotAcceptable=適切でないメディアタイプの要求を受け付けました。 34 | 35 | # -- Errors [Domain] 36 | error.domain.accountId={max}文字以内で入力してください。 37 | error.domain.idStr={max}文字以内で入力してください。 38 | error.domain.ISODate=日付フォーマットで入力して下さい。 39 | error.domain.ISODateTime=日時フォーマットで入力して下さい。 40 | error.domain.year=yyyyフォーマットで入力して下さい。 41 | error.domain.currency={max}文字の英字を入力してください。 42 | error.domain.mail=正しいメールフォーマットで入力して下さい。 43 | error.domain.category={max}文字以内で入力してください。 44 | error.domain.name={max}文字以内で入力してください。 45 | error.domain.outline={max}文字以内で入力して下さい。 46 | error.domain.description={max}文字以内で入力して下さい。 47 | 48 | error.domain.amount={integer}桁(小数部{fraction}桁)以内の数字を入力してください。 49 | error.domain.absAmount=マイナスを含めない{integer}桁(小数部{fraction}桁)以内の数字を入力してください。 50 | error.domain.AbsAmount.zero=マイナスを含めない数字を入力してください。 51 | 52 | # -- Errors [Application] 53 | 54 | error.login=ログインに失敗しました。 55 | error.duplicateId=既に登録されているIDです。 56 | 57 | error.ActionStatusType.unprocessing=既に処理済の情報です。 58 | error.TimePoint.beforeEqualsDay=現在日以降を入力してください。 59 | error.TimePoint.afterEqualsDay=現在日以前を入力してください。 60 | 61 | error.Cashflow.realizeDay=受渡日を迎えていないため実現できません。 62 | error.Cashflow.beforeEqualsDay=既に受渡日を迎えています。 63 | error.CashInOut.withdrawAmount=出金可能額を超えています。 64 | error.CashInOut.afterEqualsDay=未到来の発生日です。 65 | error.CashInOut.beforeEqualsDay=既に発生日を迎えています。 66 | -------------------------------------------------------------------------------- /src/main/scala/sample/context/security/SecurityActorFinder.scala: -------------------------------------------------------------------------------- 1 | package sample.context.security 2 | 3 | import java.util.Collection 4 | 5 | import scala.collection.JavaConverters._ 6 | 7 | import org.springframework.beans.factory.annotation.Autowired 8 | import org.springframework.context.annotation.Lazy 9 | import org.springframework.security.core._ 10 | import org.springframework.security.core.context.SecurityContextHolder 11 | import org.springframework.security.core.userdetails._ 12 | 13 | import javax.servlet.http.HttpServletRequest 14 | import sample.context.actor.Actor 15 | 16 | /** 17 | * Spring Securityで利用される認証/認可対象となるユーザ情報を提供します。 18 | */ 19 | class SecurityActorFinder { 20 | @Autowired 21 | private var props: SecurityProperties = _ 22 | @Autowired 23 | @Lazy 24 | private var userService: SecurityUserService = _ 25 | @Autowired(required = false) 26 | @Lazy 27 | private var adminService: SecurityAdminService = _ 28 | 29 | /** 現在のプロセス状態に応じたUserDetailServiceを返します。 */ 30 | def detailsService: SecurityActorService = 31 | if (props.auth.admin) 32 | Option(adminService).getOrElse( 33 | throw new IllegalStateException("SecurityAdminServiceをコンテナへBean登録してください。")) 34 | else userService 35 | } 36 | 37 | object SecurityActorFinder { 38 | /** 39 | * 現在有効な認証情報を返します。 40 | */ 41 | def authentication: Option[Authentication] = 42 | Option(SecurityContextHolder.getContext().getAuthentication()); 43 | 44 | /** 45 | * 現在有効な利用者認証情報を返します。 46 | *
ログイン中の利用者情報を取りたいときはこちらを利用してください。 47 | */ 48 | def actorDetails: Option[ActorDetails] = 49 | authentication 50 | .filter(_.getDetails().isInstanceOf[ActorDetails]) 51 | .map(_.getDetails().asInstanceOf[ActorDetails]) 52 | } 53 | 54 | /** 55 | * 認証/認可で用いられるユーザ情報。 56 | *
プロジェクト固有にカスタマイズしています。 57 | */ 58 | case class ActorDetails( 59 | /** ログイン中の利用者情報 */ 60 | actor: Actor, 61 | /** 認証パスワード(暗号化済) */ 62 | password: String, 63 | /** 利用者の所有権限一覧 */ 64 | authorities: Seq[GrantedAuthority]) extends UserDetails { 65 | 66 | //low: L/B経由をきちんと考えるならヘッダーもチェックすること 67 | def bindRequestInfo(request: HttpServletRequest): ActorDetails = { 68 | actor.source = Option(request.getRemoteAddr()) 69 | this 70 | } 71 | 72 | override def getUsername(): String = actor.id 73 | override def getPassword(): String = password 74 | override def isAccountNonExpired(): Boolean = return true 75 | override def isAccountNonLocked(): Boolean = return true 76 | override def isCredentialsNonExpired(): Boolean = return true 77 | override def isEnabled(): Boolean = return true 78 | override def getAuthorities(): Collection[GrantedAuthority] = authorities.asJavaCollection 79 | def authorityIds: Seq[String] = authorities.map(_.getAuthority) 80 | } 81 | 82 | /** Actorに適合したUserDetailsService */ 83 | trait SecurityActorService extends UserDetailsService { 84 | /** 85 | * 与えられたログインIDを元に認証/認可対象のユーザ情報を返します。 86 | * @see org.springframework.security.core.userdetails.UserDetailsService#loadUserByUsername(java.lang.String) 87 | */ 88 | override def loadUserByUsername(username: String): ActorDetails 89 | } 90 | 91 | /** 一般利用者向けI/F */ 92 | trait SecurityUserService extends SecurityActorService 93 | 94 | /** 管理者向けI/F */ 95 | trait SecurityAdminService extends SecurityActorService -------------------------------------------------------------------------------- /src/test/scala/sample/model/asset/CashflowSpec.scala: -------------------------------------------------------------------------------- 1 | package sample.model.asset 2 | 3 | import scala.math.BigDecimal 4 | import scala.util.{ Failure, Success, Try } 5 | 6 | import org.junit.runner.RunWith 7 | import org.scalatest.junit.JUnitRunner 8 | 9 | import sample._ 10 | import sample.model.DataFixtures._ 11 | 12 | //low: 簡易な正常系検証が中心。依存するCashBalanceの単体検証パスを前提。 13 | @RunWith(classOf[JUnitRunner]) 14 | class CashflowSpec extends UnitSpecSupport { 15 | 16 | behavior of "キャッシュフロー" 17 | 18 | it should "キャッシュフローを登録する" in { implicit session => 19 | val baseDay = businessDay.day 20 | val baseMinus1Day = businessDay.day(-1) 21 | val basePlus1Day = businessDay.day(1) 22 | // 過去日付の受渡でキャッシュフロー発生 [例外] 23 | Try(Cashflow.register( 24 | RegCashflow("test1", "JPY", BigDecimal("1000"), CashflowType.CashIn, "cashIn", None, baseMinus1Day))) match { 25 | case Success(v) => fail() 26 | case Failure(e) => e.getMessage should be (AssetErrorKeys.CashflowBeforeEqualsDay) 27 | } 28 | // 翌日受渡でキャッシュフロー発生 29 | val cf = Cashflow.load(Cashflow.register( 30 | RegCashflow("test1", "JPY", BigDecimal("1000"), CashflowType.CashIn, "cashIn", None, basePlus1Day))) 31 | cf.amount should be (BigDecimal(1000)) 32 | cf.statusType should be (ActionStatusType.Unprocessed) 33 | cf.eventDay should be (baseDay) 34 | cf.valueDay should be (basePlus1Day) 35 | } 36 | 37 | it should "未実現キャッシュフローを実現する" in { implicit session => 38 | val baseDay = businessDay.day 39 | val baseMinus1Day = businessDay.day(-1) 40 | val baseMinus2Day = businessDay.day(-2) 41 | val basePlus1Day = businessDay.day(1) 42 | CashBalance.getOrNew("test1", "JPY") 43 | 44 | // 未到来の受渡日 [例外] 45 | Try(saveCf("test1", "1000", baseDay, basePlus1Day).realize()) match { 46 | case Success(v) => fail() 47 | case Failure(e) => e.getMessage should be (AssetErrorKeys.CashflowRealizeDay) 48 | } 49 | 50 | // キャッシュフローの残高反映検証。 0 + 1000 = 1000 51 | val cfNormal = saveCf("test1", "1000", baseMinus1Day, baseDay) 52 | cfNormal.statusType should be (ActionStatusType.Unprocessed) 53 | CashBalance.getOrNew("test1", "JPY").amount should be (BigDecimal("0")) 54 | 55 | val cfNormalId = cfNormal.realize() 56 | Cashflow.load(cfNormalId).statusType should be (ActionStatusType.Processed) 57 | CashBalance.getOrNew("test1", "JPY").amount should be (BigDecimal("1000")) 58 | 59 | // 処理済キャッシュフローの再実現 [例外] 60 | Try(Cashflow.load(cfNormalId).realize()) match { 61 | case Success(v) => fail() 62 | case Failure(e) => e.getMessage should be (ErrorKeys.ActionUnprocessing) 63 | } 64 | 65 | // 過日キャッシュフローの残高反映検証。 1000 + 2000 = 3000 66 | val cfPast = saveCf("test1", "2000", baseMinus2Day, baseMinus1Day) 67 | Cashflow.load(cfPast.realize()).statusType should be (ActionStatusType.Processed) 68 | CashBalance.getOrNew("test1", "JPY").amount should be (BigDecimal("3000")) 69 | } 70 | 71 | it should "発生即実現のキャッシュフローを登録する" in { implicit session => 72 | CashBalance.getOrNew("test1", "JPY") 73 | CashBalance.getOrNew("test1", "JPY").amount should be (BigDecimal(0)) 74 | Cashflow.register( 75 | RegCashflow("test1", "JPY", BigDecimal("1000"), CashflowType.CashIn, "cashIn", None, businessDay.day)) 76 | CashBalance.getOrNew("test1", "JPY").amount should be (BigDecimal(1000)) 77 | } 78 | } -------------------------------------------------------------------------------- /src/main/scala/sample/util/DateUtils.scala: -------------------------------------------------------------------------------- 1 | package sample.util 2 | 3 | import java.time._ 4 | import java.time.format.DateTimeFormatter 5 | import java.time.temporal.{ChronoField, TemporalAccessor, TemporalQuery} 6 | 7 | /** 8 | * 頻繁に利用される日時ユーティリティを表現します。 9 | */ 10 | trait DateUtils { 11 | private val WeekendQuery: WeekendQuery = new WeekendQuery() 12 | 13 | /** 指定された文字列(YYYY-MM-DD)を元に日付へ変換します。 */ 14 | def day(dayStr: String): LocalDate = dayOpt(dayStr).getOrElse(null) 15 | def dayOpt(dayStr: String): Option[LocalDate] = 16 | Option(dayStr).map(d => LocalDate.parse(d.trim(), DateTimeFormatter.ISO_LOCAL_DATE)) 17 | 18 | /** 指定された文字列とフォーマット型を元に日時へ変換します。 */ 19 | def date(dateStr: String, formatter: DateTimeFormatter): LocalDateTime = dateOpt(dateStr, formatter).getOrElse(null) 20 | def dateOpt(dateStr: String, formatter: DateTimeFormatter): Option[LocalDateTime] = 21 | Option(dateStr).map(dt => LocalDateTime.parse(dt.trim, formatter)) 22 | 23 | /** 指定された文字列とフォーマット文字列を元に日時へ変換します。 */ 24 | def date(dateStr: String, format: String): LocalDateTime = date(dateStr, DateTimeFormatter.ofPattern(format)) 25 | def dateOpt(dateStr: String, format: String): Option[LocalDateTime] = dateOpt(dateStr, DateTimeFormatter.ofPattern(format)) 26 | 27 | /** 指定された日付を日時へ変換します。*/ 28 | def dateByDay(day: LocalDate): LocalDateTime = dateByDayOpt(day).getOrElse(null) 29 | def dateByDayOpt(day: LocalDate): Option[LocalDateTime] = Option(day).map((v) => v.atStartOfDay()) 30 | 31 | /** 指定した日付の翌日から1msec引いた日時を返します。 */ 32 | def dateTo(day: LocalDate): LocalDateTime = dateToOpt(day).getOrElse(null) 33 | def dateToOpt(day: LocalDate): Option[LocalDateTime] = Option(day).map((v) => v.atTime(23, 59, 59)) 34 | 35 | /** 指定された日時型とフォーマット型を元に文字列(YYYY-MM-DD)へ変更します。 */ 36 | def dayFormat(day: LocalDate): String = dayFormatOpt(day).getOrElse(null) 37 | def dayFormatOpt(day: LocalDate): Option[String] = Option(day).map((v) => v.format(DateTimeFormatter.ISO_LOCAL_DATE)) 38 | 39 | /** 指定された日時型とフォーマット型を元に文字列へ変更します。 */ 40 | def dateFormat(date: LocalDateTime, formatter: DateTimeFormatter): String = dateFormatOpt(date, formatter).getOrElse(null) 41 | def dateFormatOpt(date: LocalDateTime, formatter: DateTimeFormatter): Option[String] = Option(date).map((v) => v.format(formatter)) 42 | 43 | /** 指定された日時型とフォーマット文字列を元に文字列へ変更します。 */ 44 | def dateFormat(date: LocalDateTime, format: String): String = dateFormatOpt(date, format).getOrElse(null) 45 | def dateFormatOpt(date: LocalDateTime, format: String): Option[String] = Option(date).map((v) => v.format(DateTimeFormatter.ofPattern(format))) 46 | 47 | /** 日付の間隔を取得します。 */ 48 | def between(start: LocalDate, end: LocalDate): Option[Period] = 49 | if (start == null || end == null) Option.empty 50 | else Option(Period.between(start, end)) 51 | 52 | /** 日時の間隔を取得します。 */ 53 | def between(start: LocalDateTime, end: LocalDateTime): Option[Duration] = 54 | if (start == null || end == null) Option.empty 55 | else Option(Duration.between(start, end)) 56 | 57 | /** 指定営業日が週末(土日)か判定します。(引数は必須) */ 58 | def isWeekend(day: LocalDate): Boolean = day.query(WeekendQuery) 59 | 60 | /** 指定年の最終日を取得します。 */ 61 | def dayTo(year: Int): LocalDate = LocalDate.ofYearDay(year, if (Year.of(year).isLeap()) 366 else 365) 62 | 63 | } 64 | object DateUtils extends DateUtils 65 | 66 | /** 週末判定用のTemporalQuery>Boolean<を表現します。 */ 67 | class WeekendQuery extends TemporalQuery[Boolean] { 68 | override def queryFrom(temporal: TemporalAccessor): Boolean = 69 | Array(DayOfWeek.SATURDAY, DayOfWeek.SUNDAY).contains( 70 | DayOfWeek.of(temporal.get(ChronoField.DAY_OF_WEEK))) 71 | } 72 | -------------------------------------------------------------------------------- /src/main/scala/sample/ApplicationConfig.scala: -------------------------------------------------------------------------------- 1 | package sample 2 | 3 | import org.springframework.beans.factory.annotation.Autowired 4 | import org.springframework.boot.actuate.health._ 5 | import org.springframework.boot.actuate.health.Health.Builder 6 | import org.springframework.context.MessageSource 7 | import org.springframework.context.annotation._ 8 | import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder 9 | import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean 10 | import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter 11 | import com.fasterxml.jackson.databind.SerializationFeature 12 | import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule 13 | import com.fasterxml.jackson.module.scala.DefaultScalaModule 14 | import sample.context.Timestamper 15 | import sample.model.BusinessDayHandler 16 | import sample.context.actor.ActorSession 17 | import sample.context.ResourceBundleHandler 18 | import sample.context.AppSettingHandler 19 | import sample.context.audit.AuditHandler 20 | import sample.context.audit.AuditPersister 21 | import sample.context.lock.IdLockHandler 22 | import sample.context.mail.MailHandler 23 | import sample.context.report.ReportHandler 24 | import sample.context.DomainHelper 25 | 26 | /** 27 | * アプリケーションにおけるBean定義を表現します。 28 | *
クラス側でコンポーネント定義していない時はこちらで明示的に記載してください。 29 | *
case classで作成されたコンポーネントやコンストラクタ引数を取るものなどもこちらで定義されます。 30 | */ 31 | @Configuration 32 | class ApplicationConfig { 33 | @Bean 34 | def timestamper(): Timestamper = Timestamper() 35 | @Bean 36 | def actorSession(): ActorSession = new ActorSession() 37 | @Bean 38 | def resourceBundleHandler(): ResourceBundleHandler = new ResourceBundleHandler(); 39 | @Bean 40 | def appSettingHandler(): AppSettingHandler = new AppSettingHandler(); 41 | @Bean 42 | def auditHandler(): AuditHandler = new AuditHandler(); 43 | @Bean 44 | def auditPersister(): AuditPersister = new AuditPersister(); 45 | @Bean 46 | def idLockHandler(): IdLockHandler = new IdLockHandler(); 47 | @Bean 48 | def mailHandler(): MailHandler = new MailHandler(); 49 | @Bean 50 | def reportHandler(): ReportHandler = new ReportHandler(); 51 | @Bean 52 | def domainHelper(): DomainHelper = new DomainHelper(); 53 | } 54 | 55 | @Configuration 56 | class WebMVCConfig { 57 | 58 | /** BeanValidationメッセージのUTF-8に対応したValidator。 */ 59 | @Bean 60 | def defaultValidator(message: MessageSource): LocalValidatorFactoryBean = { 61 | val factory = new LocalValidatorFactoryBean() 62 | factory.setValidationMessageSource(message) 63 | factory 64 | } 65 | 66 | /** Scalaの型に対応したObjectMapper */ 67 | @Bean 68 | def scalaObjectMapper(): Jackson2ObjectMapperBuilder = 69 | Jackson2ObjectMapperBuilder.json() 70 | .autoDetectFields(true) 71 | .indentOutput(true) 72 | .featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) 73 | .modules(DefaultScalaModule) 74 | .modulesToInstall(classOf[JavaTimeModule]) 75 | } 76 | 77 | /** 拡張ヘルスチェック定義を表現します。 */ 78 | @Configuration 79 | class HealthCheckConfig { 80 | /** 営業日チェック */ 81 | @Bean 82 | def dayIndicator(time: Timestamper, businessDay: BusinessDayHandler): HealthIndicator = 83 | new AbstractHealthIndicator { 84 | override def doHealthCheck(builder: Builder): Unit = { 85 | builder.up() 86 | .withDetail("day", businessDay.day) 87 | .withDetail("dayMinus1", businessDay.day(-1)) 88 | .withDetail("dayPlus1", businessDay.day(1)) 89 | .withDetail("dayPlus2", businessDay.day(2)) 90 | .withDetail("dayPlus3", businessDay.day(3)) 91 | } 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/main/scala/sample/util/ConvertUtils.scala: -------------------------------------------------------------------------------- 1 | package sample.util 2 | 3 | import scala.util.control.Breaks 4 | 5 | import com.ibm.icu.text.Transliterator 6 | 7 | /** 各種型/文字列変換をサポートします。(ICU4Jライブラリに依存しています) */ 8 | trait ConvertUtils { 9 | private val ZenkakuToHan: Transliterator = Transliterator.getInstance("Fullwidth-Halfwidth") 10 | private val HankakuToZen: Transliterator = Transliterator.getInstance("Halfwidth-Fullwidth") 11 | private val KatakanaToHira: Transliterator = Transliterator.getInstance("Katakana-Hiragana") 12 | private val HiraganaToKana: Transliterator = Transliterator.getInstance("Hiragana-Katakana") 13 | 14 | /** 例外無しにLongへ変換します。(変換できない時はNone) */ 15 | def quietlyLong(value: Any): Option[Long] = try { 16 | Option(value).map(_.toString().toLong) 17 | } catch { 18 | case e: Exception => Option.empty 19 | } 20 | 21 | /** 例外無しにIntへ変換します。(変換できない時はNone) */ 22 | def quietlyInt(value: Any): Option[Int] = try { 23 | Option(value).map(_.toString().toInt) 24 | } catch { 25 | case e: Exception => Option.empty 26 | } 27 | 28 | /** 例外無しにBigDecimalへ変換します。(変換できない時はNone) */ 29 | def quietlyDecimal(value: Any): Option[BigDecimal] = try { 30 | Option(value).map(v => BigDecimal(v.toString())) 31 | } catch { 32 | case e: Exception => Option.empty 33 | } 34 | 35 | /** 例外無しBooleanへ変換します。(変換できない時はfalse) */ 36 | def quietlyBool(value: Any): Boolean = try { 37 | Option(value).map(_.toString().toBoolean).getOrElse(false) 38 | } catch { 39 | case e: Exception => false 40 | } 41 | 42 | /** 全角文字を半角にします。 */ 43 | def zenkakuToHan(text: String): String = 44 | Option(text).map(ZenkakuToHan.transliterate(_)).getOrElse(null) 45 | 46 | /** 半角文字を全角にします。 */ 47 | def hankakuToZen(text: String): String = 48 | Option(text).map(HankakuToZen.transliterate(_)).getOrElse(null) 49 | 50 | /** カタカナをひらがなにします。 */ 51 | def katakanaToHira(text: String): String = 52 | Option(text).map(KatakanaToHira.transliterate(_)).getOrElse(null) 53 | 54 | /** 55 | * ひらがな/半角カタカナを全角カタカナにします。 56 | *
low: 実際の挙動は厳密ではないので単体検証(ConvertUtilsSpec)などで事前に確認して下さい。 57 | */ 58 | def hiraganaToZenKana(text: String): String = 59 | Option(text).map(HiraganaToKana.transliterate(_)).getOrElse(null) 60 | 61 | /** 62 | * ひらがな/全角カタカナを半角カタカナにします。 63 | *
low: 実際の挙動は厳密ではないので単体検証(ConvertUtilsSpec)などで事前に確認して下さい。 64 | */ 65 | def hiraganaToHanKana(text: String): String = 66 | Option(text).map(v => zenkakuToHan(hiraganaToZenKana(v))).getOrElse(null) 67 | 68 | /** 指定した文字列を抽出します。(サロゲートペア対応) */ 69 | def substring(text: String, start: Int, end: Int): String = 70 | Option(text).map(v => { 71 | val spos = v.offsetByCodePoints(0, start) 72 | val epos = if (text.length < end) text.length else end 73 | v.substring(spos, v.offsetByCodePoints(spos, epos - start)) 74 | }).getOrElse(null) 75 | 76 | /** 文字列を左から指定の文字数で取得します。(サロゲートペア対応) */ 77 | def left(text: String, len: Int): String = substring(text, 0, len) 78 | 79 | /** 文字列を左から指定のバイト数で取得します。 */ 80 | def leftStrict(text: String, lenByte: Int, charset: String): String = { 81 | val sb = new StringBuilder() 82 | var cnt: Int = 0 83 | val scope = new Breaks 84 | scope.breakable { 85 | for (i <- 0 until text.length) { 86 | val v = text.substring(i, i + 1) 87 | val blen = v.getBytes(charset).length 88 | if (lenByte < cnt + blen) { 89 | scope.break() 90 | } else { 91 | sb.append(v) 92 | cnt += blen 93 | } 94 | } 95 | } 96 | sb.toString() 97 | } 98 | } 99 | object ConvertUtils extends ConvertUtils 100 | -------------------------------------------------------------------------------- /src/main/scala/sample/controller/admin/SystemAdminController.scala: -------------------------------------------------------------------------------- 1 | package sample.controller.admin 2 | 3 | import java.time.LocalDate 4 | 5 | import scala.beans.BeanProperty 6 | 7 | import org.springframework.beans.factory.annotation.Autowired 8 | import org.springframework.http.ResponseEntity 9 | import org.springframework.web.bind.annotation._ 10 | 11 | import javax.validation.Valid 12 | import javax.validation.constraints.NotNull 13 | import sample._ 14 | import sample.context._ 15 | import sample.context.actor._ 16 | import sample.context.audit._ 17 | import sample.context.orm.PagingList 18 | import sample.controller._ 19 | import sample.model.constraints._ 20 | import sample.usecase.SystemAdminService 21 | import java.beans.SimpleBeanInfo 22 | 23 | /** 24 | * システムに関わる社内のUI要求を処理します。 25 | */ 26 | @RestController 27 | @RequestMapping(Array("/api/admin/system")) 28 | class SystemAdminController extends ControllerSupport { 29 | 30 | @Autowired 31 | private var service: SystemAdminService = _ 32 | 33 | /** 利用者監査ログを検索します。 */ 34 | @GetMapping(Array("/audit/actor/")) 35 | def findAuditActor(@Valid p: FindAuditActorParam): PagingList[AuditActor] = 36 | service.findAuditActor(p.convert) 37 | 38 | /** イベント監査ログを検索します。 */ 39 | @GetMapping(Array("/audit/event/")) 40 | def findAuditEvent(@Valid p: FindAuditEventParam): PagingList[AuditEvent] = 41 | service.findAuditEvent(p.convert) 42 | 43 | /** アプリケーション設定一覧を検索します。 */ 44 | @GetMapping(Array("/setting/")) 45 | def findAppSetting(@Valid p: FindAppSettingParam): Seq[AppSetting] = 46 | service.findAppSetting(p.convert) 47 | 48 | /** アプリケーション設定情報を変更します。 */ 49 | @PostMapping(Array("/setting/{id}")) 50 | def changeAppSetting(@PathVariable id: String, value: String): ResponseEntity[Void] = 51 | resultEmpty(service.changeAppSetting(id, value)) 52 | } 53 | 54 | /** FindAuditActorのUI変換パラメタ */ 55 | @SimpleBeanInfo 56 | class FindAuditActorParam { 57 | @IdStrEmpty 58 | @BeanProperty 59 | var actorId: String = _ 60 | @CategoryEmpty 61 | @BeanProperty 62 | var category: String = _ 63 | @DescriptionEmpty 64 | @BeanProperty 65 | var keyword: String = _ 66 | @NotNull 67 | @BeanProperty 68 | var roleType: String = "USER" 69 | @BeanProperty 70 | var statusType: String = _ 71 | @ISODate 72 | @BeanProperty 73 | var fromDay: LocalDate = _ 74 | @ISODate 75 | @BeanProperty 76 | var toDay: LocalDate = _ 77 | @NotNull 78 | @BeanProperty 79 | var page: PaginationParam = new PaginationParam() 80 | def convert: FindAuditActor = 81 | FindAuditActor(Option(actorId), Option(category), Option(keyword), 82 | ActorRoleType.withName(roleType), 83 | Option(statusType).map(ActionStatusType.withName(_)), 84 | fromDay, toDay, page.convert) 85 | } 86 | 87 | /** FindAuditEventのUI変換パラメタ */ 88 | @SimpleBeanInfo 89 | class FindAuditEventParam { 90 | @CategoryEmpty 91 | @BeanProperty 92 | var category: String = _ 93 | @DescriptionEmpty 94 | @BeanProperty 95 | var keyword: String = _ 96 | @BeanProperty 97 | var statusType: String = _ 98 | @ISODate 99 | @BeanProperty 100 | var fromDay: LocalDate = _ 101 | @ISODate 102 | @BeanProperty 103 | var toDay: LocalDate = _ 104 | @NotNull 105 | @BeanProperty 106 | var page: PaginationParam = new PaginationParam() 107 | def convert: FindAuditEvent = 108 | FindAuditEvent(Option(category), Option(keyword), 109 | Option(statusType).map(ActionStatusType.withName(_)), 110 | fromDay, toDay, page.convert) 111 | } 112 | 113 | /** FindAppSettingのUI変換パラメタ */ 114 | @SimpleBeanInfo 115 | class FindAppSettingParam { 116 | @DescriptionEmpty 117 | @BeanProperty 118 | var keyword: String = _ 119 | def convert: FindAppSetting = FindAppSetting(Option(keyword)) 120 | } 121 | 122 | -------------------------------------------------------------------------------- /src/main/scala/sample/ApplicationSecurityConfig.scala: -------------------------------------------------------------------------------- 1 | package sample 2 | 3 | import org.springframework.context.annotation.Configuration 4 | import org.springframework.boot.context.properties.EnableConfigurationProperties 5 | import sample.context.security.SecurityProperties 6 | import org.springframework.security.crypto.password.PasswordEncoder 7 | import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder 8 | import org.springframework.context.annotation.Bean 9 | import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty 10 | import org.springframework.web.filter.CorsFilter 11 | import org.springframework.web.cors.UrlBasedCorsConfigurationSource 12 | import org.springframework.web.cors.CorsConfiguration 13 | import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity 14 | import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity 15 | import org.springframework.core.annotation.Order 16 | import sample.context.security.SecurityConfigurer 17 | import org.springframework.security.authentication.AuthenticationManager 18 | import sample.context.security.SecurityProvider 19 | import sample.context.security.SecurityEntryPoint 20 | import sample.context.security.LoginHandler 21 | import sample.context.security.SecurityActorFinder 22 | 23 | /** 24 | * アプリケーションのセキュリティ定義を表現します。 25 | */ 26 | @Configuration 27 | @EnableConfigurationProperties(Array(classOf[SecurityProperties])) 28 | class ApplicationSecurityConfig { 29 | 30 | /** パスワード用のハッシュ(BCrypt)エンコーダー。 */ 31 | //low: きちんとやるのであれば、strengthやSecureRandom使うなど外部切り出し含めて検討してください 32 | @Bean 33 | def passwordEncoder(): PasswordEncoder = new BCryptPasswordEncoder(); 34 | 35 | /** CORS全体適用 */ 36 | @Bean 37 | @ConditionalOnProperty(prefix = "extension.security.cors", name = Array("enabled"), matchIfMissing = false) 38 | def corsFilter(props: SecurityProperties): CorsFilter = { 39 | val source = new UrlBasedCorsConfigurationSource() 40 | val config = new CorsConfiguration() 41 | config.setAllowCredentials(props.cors.allowCredentials); 42 | config.addAllowedOrigin(props.cors.allowedOrigin); 43 | config.addAllowedHeader(props.cors.allowedHeader); 44 | config.addAllowedMethod(props.cors.allowedMethod); 45 | config.setMaxAge(props.cors.maxAge); 46 | source.registerCorsConfiguration(props.cors.path, config); 47 | new CorsFilter(source); 48 | } 49 | 50 | } 51 | 52 | /** Spring Security を用いた API 認証/認可定義を表現します。 */ 53 | @Configuration 54 | @EnableWebSecurity 55 | @EnableGlobalMethodSecurity(prePostEnabled = true, proxyTargetClass = true) 56 | @ConditionalOnProperty(prefix = "extension.security.auth", name = Array("enabled"), matchIfMissing = true) 57 | @Order(org.springframework.boot.autoconfigure.security.SecurityProperties.ACCESS_OVERRIDE_ORDER) 58 | class AuthSecurityConfig { 59 | 60 | /** Spring Security 全般の設定 ( 認証/認可 ) を定義します。 */ 61 | @Bean 62 | @Order(org.springframework.boot.autoconfigure.security.SecurityProperties.ACCESS_OVERRIDE_ORDER) 63 | def securityConfigurer(): SecurityConfigurer = new SecurityConfigurer(); 64 | 65 | /** Spring Security のカスタム認証プロセス管理コンポーネント。 */ 66 | @Bean 67 | def authenticationManager(): AuthenticationManager = securityConfigurer().authenticationManagerBean(); 68 | 69 | /** Spring Security のカスタム認証プロバイダ。 */ 70 | @Bean 71 | def securityProvider(): SecurityProvider = new SecurityProvider(); 72 | 73 | /** Spring Security のカスタムエントリポイント。 */ 74 | @Bean 75 | def securityEntryPoint(): SecurityEntryPoint = new SecurityEntryPoint(); 76 | 77 | /** Spring Security におけるログイン/ログアウト時の振る舞いを拡張するHandler。 */ 78 | @Bean 79 | def loginHandler(): LoginHandler = new LoginHandler(); 80 | 81 | /** Spring Security で利用される認証/認可対象となるユーザ情報を提供します。 */ 82 | @Bean 83 | def securityActorFinder(): SecurityActorFinder = new SecurityActorFinder(); 84 | 85 | } 86 | 87 | -------------------------------------------------------------------------------- /src/test/scala/sample/client/SampleClient.scala: -------------------------------------------------------------------------------- 1 | package sample.client 2 | 3 | import scala.util.{Try, Success, Failure} 4 | import java.net.URI 5 | import org.apache.commons.io.IOUtils 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | import org.junit.runners.JUnit4 9 | import org.springframework.http.HttpMethod 10 | import org.springframework.http.HttpStatus 11 | import org.springframework.http.client.ClientHttpRequest 12 | import org.springframework.http.client.ClientHttpResponse 13 | import org.springframework.http.client.SimpleClientHttpRequestFactory 14 | import sample.util._ 15 | 16 | /** 17 | * 単純なHTTP経由の実行検証。 18 | *
SpringがサポートするControllerSpecSupportでの検証で良いのですが、コンテナ立ち上げた後に叩く単純確認用に作りました。 19 | *
「extention.security.auth.enabled: true」の時は実際にログインして処理を行います。 20 | * falseの時はDummyLoginInterceptorによる擬似ログインが行われます。 21 | */ 22 | @RunWith(classOf[JUnit4]) 23 | class SampleClient { 24 | 25 | // 「extention.security.auth.admin: false」の時のみ利用可能です。 26 | @Test 27 | def 顧客向けユースケース検証: Unit = { 28 | val agent = new SimpleTestAgent() 29 | agent.login("sample", "sample") 30 | agent.post("振込出金依頼", "/asset/cio/withdraw?accountId=sample¤cy=JPY&absAmount=200") 31 | agent.get("振込出金依頼未処理検索", "/asset/cio/unprocessedOut/") 32 | } 33 | 34 | // 「extention.security.auth.admin: true」の時のみ利用可能です。 35 | @Test 36 | def 社内向けユースケース検証: Unit = { 37 | val day = DateUtils.dayFormat(TimePoint().day) 38 | val agent = new SimpleTestAgent() 39 | agent.login("admin", "admin") 40 | agent.get("振込入出金依頼検索", s"/admin/asset/cio/?updFromDay=${day}&updToDay=${day}") 41 | } 42 | 43 | @Test 44 | def バッチ向けユースケース検証: Unit = { 45 | val fromDay = DateUtils.dayFormat(TimePoint().day.minusDays(1)) 46 | val toDay = DateUtils.dayFormat(TimePoint().day.plusDays(3)) 47 | val agent = new SimpleTestAgent(); 48 | agent.post("営業日を進める(単純日回しのみ)", "/system/job/daily/processDay") 49 | agent.post("当営業日の出金依頼を締める", "/system/job/daily/closingCashOut") 50 | agent.post("入出金キャッシュフローを実現する(受渡日に残高へ反映)", "/system/job/daily/realizeCashflow") 51 | agent.get("イベントログを検索する", s"/admin/system/audit/event/?fromDay=${fromDay}&toDay=${toDay}") 52 | } 53 | 54 | } 55 | 56 | /** 単純なSession概念を持つHTTPエージェント */ 57 | class SimpleTestAgent { 58 | private val RootPath = "http://localhost:8080/api" 59 | private val factory = new SimpleClientHttpRequestFactory() 60 | private var sessionId: Option[String] = None; 61 | 62 | def path(urlPath: String): URI = new URI(s"${RootPath}${urlPath}") 63 | def dumpTitle(title: String): SimpleTestAgent = { 64 | println("------- " + title + "------- ") 65 | this 66 | } 67 | def dump(res: ClientHttpResponse): ClientHttpResponse = { 68 | println(s"status: ${res.getRawStatusCode()}, text: ${res.getStatusText()}") 69 | Try(println(IOUtils.toString(res.getBody(), "UTF-8"))) match { 70 | case Success(v) => // nothing. 71 | case Failure(e) => println(e.getMessage) 72 | } 73 | res 74 | } 75 | 76 | def get(title: String, urlPath: String): ClientHttpResponse = { 77 | dumpTitle(title).dump(request(urlPath, HttpMethod.GET).execute()) 78 | } 79 | private def request(urlPath: String, method: HttpMethod): ClientHttpRequest = { 80 | val req = factory.createRequest(path(urlPath), method) 81 | sessionId.map((jsessionId) => req.getHeaders.add("Cookie", jsessionId)) 82 | req 83 | } 84 | def post(title: String, urlPath: String): ClientHttpResponse = 85 | dumpTitle(title).dump(request(urlPath, HttpMethod.POST).execute()) 86 | 87 | def login(loginId: String, password: String): SimpleTestAgent = 88 | post("ログイン", "/login?loginId=" + loginId + "&password=" + password) match { 89 | case res if res.getStatusCode == HttpStatus.OK => 90 | val cookieStr = res.getHeaders().get("Set-Cookie").get(0) 91 | sessionId = Option(cookieStr.substring(0, cookieStr.indexOf(';'))) 92 | this 93 | case res => this 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/main/scala/sample/context/audit/AuditEvent.scala: -------------------------------------------------------------------------------- 1 | package sample.context.audit 2 | 3 | import java.time.{ LocalDateTime, LocalDate } 4 | 5 | import org.apache.commons.lang3.StringUtils 6 | 7 | import sample._ 8 | import sample.context._ 9 | import sample.context.orm._ 10 | import sample.util._ 11 | import scalikejdbc.jsr310.Implicits._ 12 | import scalikejdbc._ 13 | 14 | /** 15 | * システムイベントの監査ログを表現します。 16 | */ 17 | case class AuditEvent( 18 | /** ID */ 19 | id: Long = -1L, // autogen 20 | /** カテゴリ */ 21 | category: String, 22 | /** メッセージ */ 23 | message: String, 24 | /** 処理ステータス */ 25 | statusType: ActionStatusType, 26 | /** エラー事由 */ 27 | errorReason: Option[String] = None, 28 | /** 処理時間(msec) */ 29 | time: Option[Long] = None, 30 | /** 開始日時 */ 31 | startDate: LocalDateTime, 32 | /** 終了日時(未完了時はNone) */ 33 | endDate: Option[LocalDateTime] = None) extends Entity 34 | 35 | object AuditEvent extends AuditEventMapper { 36 | 37 | /** イベント監査ログを検索します。 */ 38 | def find(p: FindAuditEvent)(implicit session: DBSession, dh: DomainHelper): PagingList[AuditEvent] = { 39 | PagingList(AuditEvent.withAlias(m => 40 | findAllByWithLimitOffset( 41 | sqls.toAndConditionOpt( 42 | Some(sqls.between(m.startDate, p.fromDay.atStartOfDay(), DateUtils.dateTo(p.toDay))), 43 | p.statusType.map(stype => sqls.eq(m.statusType, stype.value)), 44 | p.category.map(sqls.eq(m.category, _)), 45 | p.keyword.map(k => 46 | sqls.like(m.message, p.likeKeyword).or(sqls.like(m.errorReason, p.likeKeyword))) 47 | ).getOrElse(sqls.empty), 48 | p.page.size, p.page.firstResult, 49 | Seq(m.startDate.desc) 50 | )), p.page) 51 | } 52 | 53 | /** イベント監査ログを登録します。 */ 54 | def register(p: RegAuditEvent)(implicit session: DBSession = autoSession, dh: DomainHelper): Long = 55 | AuditEvent.createWithAttributes( 56 | 'category -> p.category, 57 | 'message -> ConvertUtils.left(p.message, 300), 58 | 'statusType -> ActionStatusType.Processing.value, 59 | 'startDate -> dh.time.date) 60 | 61 | /** 利用者監査ログを完了状態にします。 */ 62 | def finish(id: Long)(implicit session: DBSession = autoSession, dh: DomainHelper): Unit = { 63 | val now = dh.time.date 64 | val m = findById(id).getOrElse(throw ValidationException(ErrorKeys.EntityNotFound)) 65 | updateById(id).withAttributes( 66 | 'statusType -> ActionStatusType.Processed.value, 67 | 'endDate -> now, 68 | 'time -> DateUtils.between(m.startDate, now).get.toMillis()) 69 | } 70 | 71 | /** 利用者監査ログを取消状態にします。 */ 72 | def cancel(id: Long, errorReason: String)(implicit session: DBSession = autoSession, dh: DomainHelper): Long = { 73 | val now = dh.time.date 74 | val m = findById(id).getOrElse(throw ValidationException(ErrorKeys.EntityNotFound)) 75 | updateById(id).withAttributes( 76 | 'statusType -> ActionStatusType.Cancelled.value, 77 | 'endDate -> now, 78 | 'time -> DateUtils.between(m.startDate, now).get.toMillis()) 79 | } 80 | 81 | /** 利用者監査ログを例外状態にします。 */ 82 | def error(id: Long, errorReason: String)(implicit session: DBSession = autoSession, dh: DomainHelper): Long = { 83 | val now = dh.time.date 84 | val m = findById(id).getOrElse(throw ValidationException(ErrorKeys.EntityNotFound)) 85 | updateById(id).withAttributes( 86 | 'statusType -> ActionStatusType.Error.value, 87 | 'errorReason -> StringUtils.abbreviate(errorReason, 250), 88 | 'endDate -> now, 89 | 'time -> DateUtils.between(m.startDate, now).get.toMillis()) 90 | } 91 | } 92 | 93 | trait AuditEventMapper extends SkinnyORMMapper[AuditEvent] { 94 | override def extract(rs: WrappedResultSet, n: ResultName[AuditEvent]) = 95 | AuditEvent( 96 | id = rs.long(n.id), 97 | category = rs.string(n.category), 98 | message = rs.string(n.message), 99 | statusType = ActionStatusType.withName(rs.string(n.statusType)), 100 | errorReason = rs.stringOpt(n.errorReason), 101 | time = rs.longOpt(n.time), 102 | startDate = rs.localDateTime(n.startDate), 103 | endDate = rs.localDateTimeOpt(n.endDate)) 104 | } 105 | 106 | /** 検索パラメタ */ 107 | case class FindAuditEvent( 108 | category: Option[String] = None, 109 | keyword: Option[String] = None, 110 | statusType: Option[ActionStatusType] = None, 111 | fromDay: LocalDate, 112 | toDay: LocalDate, 113 | page: Pagination = Pagination()) extends Dto { 114 | def likeKeyword = s"%${keyword.getOrElse("")}%" 115 | } 116 | 117 | /** 登録パラメタ */ 118 | case class RegAuditEvent( 119 | category: String, 120 | message: String) extends Dto 121 | object RegAuditEvent { 122 | def apply(message: String): RegAuditEvent = RegAuditEvent("default", message) 123 | } 124 | -------------------------------------------------------------------------------- /src/main/scala/sample/controller/ControllerSupport.scala: -------------------------------------------------------------------------------- 1 | package sample.controller 2 | 3 | import java.net.URLEncoder 4 | import java.util.Locale 5 | 6 | import scala.beans.BeanProperty 7 | import scala.collection.JavaConverters._ 8 | import scala.util.{ Try, Success, Failure } 9 | 10 | import org.apache.commons.io._ 11 | import org.apache.commons.lang3.StringUtils 12 | import org.springframework.beans.factory.annotation.Autowired 13 | import org.springframework.context.MessageSource 14 | import org.springframework.http._ 15 | import org.springframework.web.multipart.MultipartFile 16 | 17 | import javax.servlet.http.HttpServletResponse 18 | import sample._ 19 | import sample.context._ 20 | import sample.context.actor.ActorSession 21 | import sample.context.orm._ 22 | import sample.context.report.ReportFile 23 | import java.beans.SimpleBeanInfo 24 | 25 | /** UIコントローラの基底クラス。 */ 26 | class ControllerSupport { 27 | @Autowired 28 | var msg: MessageSource = _ 29 | @Autowired 30 | var label: ResourceBundleHandler = _ 31 | @Autowired 32 | var time: Timestamper = _ 33 | @Autowired 34 | var session: ActorSession = _ 35 | 36 | /** i18nメッセージ変換を行います。 */ 37 | protected def msg(message: String): String = msg(message, session.actor.locale) 38 | protected def msg(message: String, locale: Locale): String = msg.getMessage(message, Array(), locale) 39 | 40 | /** 41 | * リソースファイル([basename].properties)内のキー/値のMap情報を返します。 42 | *
API呼び出し側でi18n対応を行いたい時などに利用してください。 43 | */ 44 | protected def labels(basename: String): Map[String, String] = labels(basename, session.actor.locale) 45 | protected def labels(basename: String, locale: Locale): Map[String, String] = label.labels(basename, locale) 46 | 47 | /** 48 | * 戻り値を生成して返します。(戻り値がプリミティブまたはnullを許容する時はこちらを利用してください) 49 | * ※nullはJSONバインドされないため、クライアント側でStatusが200にもかかわらず例外扱いされる可能性があります。 50 | */ 51 | protected def result[T](command: => T): ResponseEntity[T] = resultObject(command) 52 | protected def resultObject[T](t: T): ResponseEntity[T] = ResponseEntity.status(HttpStatus.OK).body(t) 53 | protected def resultMap[T](key: String, t: T): ResponseEntity[java.util.Map[String, T]] = resultObject(Map(key -> t).asJava) 54 | protected def resultMap[T](t: T): ResponseEntity[java.util.Map[String, T]] = resultMap("result", t) 55 | protected def resultEmpty(command: => Unit = () => ()): ResponseEntity[Void] = { 56 | command 57 | ResponseEntity.status(HttpStatus.OK).build() 58 | } 59 | 60 | /** ファイルアップロード情報(MultipartFile)をReportFileへ変換します。 */ 61 | protected def uploadFile(file: MultipartFile): ReportFile = 62 | uploadFile(file, Array()) 63 | 64 | /** 65 | * ファイルアップロード情報(MultipartFile)をReportFileへ変換します。 66 | *
acceptExtensionsに許容するファイル拡張子(小文字統一)を設定してください。 67 | */ 68 | protected def uploadFile(file: MultipartFile, acceptExtensions: Array[String]): ReportFile = 69 | if (FilenameUtils.isExtension( 70 | StringUtils.lowerCase(file.getOriginalFilename), acceptExtensions)) { 71 | Try(ReportFile(file.getOriginalFilename(), file.getBytes())) match { 72 | case Success(v) => v 73 | case Failure(e) => throw ValidationException("file", "アップロードファイルの解析に失敗しました") 74 | } 75 | } else throw ValidationException("file", "アップロードファイルには[{0}]を指定してください", Array(StringUtils.join(acceptExtensions))) 76 | 77 | /** 78 | * ファイルダウンロード設定を行います。 79 | *
利用する際は戻り値をUnitで定義するようにしてください。 80 | */ 81 | protected def exportFile(res: HttpServletResponse, file: ReportFile): Unit = 82 | exportFile(res, file, MediaType.APPLICATION_OCTET_STREAM_VALUE) 83 | protected def exportFile(res: HttpServletResponse, file: ReportFile, contentType: String): Unit = 84 | Try(URLEncoder.encode(file.name,"UTF-8").replace("+", "%20")) match { 85 | case Success(filename) => 86 | res.setContentLength(file.size) 87 | res.setContentType(contentType) 88 | res.setHeader("Content-Disposition", "attachment; filename=" + filename) 89 | IOUtils.write(file.data, res.getOutputStream()) 90 | case Failure(e) => throw ValidationException("ファイル名が不正です") 91 | } 92 | } 93 | 94 | /** ページング系のUI変換パラメタ */ 95 | @SimpleBeanInfo 96 | class PaginationParam { 97 | @BeanProperty 98 | var page: Int = 1 99 | @BeanProperty 100 | var size: Int = Pagination.defaultSize 101 | @BeanProperty 102 | var total: Long = _ 103 | @BeanProperty 104 | var ignoreTotal: Boolean = _ 105 | @BeanProperty 106 | var sort: SortParam = new SortParam() 107 | def convert: Pagination = Pagination(page, size, Option(total), ignoreTotal, Option(sort).map(_.convert)) 108 | } 109 | @SimpleBeanInfo 110 | class SortParam { 111 | @BeanProperty 112 | var orders: Array[SortOrderParam] = Array() 113 | def convert: Sort = Sort(if (orders != null) orders.map(_.convert) else Seq()) 114 | } 115 | @SimpleBeanInfo 116 | class SortOrderParam { 117 | @BeanProperty 118 | var property: String = _ 119 | @BeanProperty 120 | var ascending: Boolean = _ 121 | def convert: SortOrder = SortOrder(property, ascending) 122 | } 123 | 124 | -------------------------------------------------------------------------------- /src/test/scala/sample/ControllerSpecSupport.scala: -------------------------------------------------------------------------------- 1 | package sample 2 | 3 | import org.junit.Before 4 | import org.slf4j.Logger 5 | import org.slf4j.LoggerFactory 6 | import org.springframework.beans.factory.annotation.Autowired 7 | import org.springframework.test.web.servlet._ 8 | import org.springframework.test.web.servlet.request.MockMvcRequestBuilders._ 9 | import org.springframework.test.web.servlet.result.MockMvcResultMatchers._ 10 | import org.springframework.test.web.servlet.setup.MockMvcBuilders 11 | import org.springframework.web.context.WebApplicationContext 12 | import sample.context.Timestamper 13 | import sample.model.BusinessDayHandler 14 | import sample.model.DataFixtures 15 | import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder 16 | import org.springframework.web.util.UriComponentsBuilder 17 | import org.hamcrest.Matcher 18 | import org.springframework.web.util.UriComponents 19 | import org.springframework.http.MediaType 20 | import org.springframework.test.context.ActiveProfiles 21 | 22 | /** 23 | * Spring コンテナを用いた Web 検証サポートクラス。 24 | *
Controller に対する URL 検証はこちらを利用して下さい。 25 | *
本クラスを継承したテストクラスを作成後、以下のアノテーションを付与してください。 26 | *
27 | * {@literal @}RunWith(SpringRunner.class)
28 | * {@literal @}WebMvcTest([テスト対象クラス].class)
29 | *
30 | * {@literal @}WebMvcTest 利用時は標準で {@literal @}Component や {@literal @}Service 等の 31 | * コンポーネントはインスタンス化されないため、必要に応じて {@literal @}MockBean などを利用して代替するようにしてください。 32 | */ 33 | @ActiveProfiles(Array("testweb")) 34 | abstract class ControllerSpecSupport { 35 | protected val logger: Logger = LoggerFactory.getLogger("ControllerTest") 36 | 37 | @Autowired 38 | protected var mvc: MockMvc = _ 39 | 40 | protected val mockTime: Timestamper = Timestamper(); 41 | protected val mockBusinessDay: BusinessDayHandler = new BusinessDayHandler { time = mockTime } 42 | protected val fixtures: DataFixtures = 43 | new DataFixtures { 44 | encoder = new BCryptPasswordEncoder() 45 | businessDay = mockBusinessDay 46 | } 47 | 48 | def uri(path: String): String = s"${prefix}${path}" 49 | 50 | def uriBuilder(path: String): UriComponentsBuilder = 51 | UriComponentsBuilder.fromUriString(uri(path)); 52 | 53 | def prefix: String = "/" 54 | 55 | /** Get 要求を投げて結果を検証します。 */ 56 | def performGet(path: String, expects: JsonExpects): ResultActions = 57 | performGet(uriBuilder(path).build(), expects) 58 | def performGet(uri: UriComponents, expects: JsonExpects): ResultActions = 59 | perform( 60 | get(uri.toUriString()).accept(MediaType.APPLICATION_JSON), 61 | expects.expects.toList) 62 | 63 | /** Get 要求 ( JSON ) を投げて結果を検証します。 */ 64 | def performJsonGet(path: String, content: String, expects: JsonExpects): ResultActions = 65 | performJsonGet(uriBuilder(path).build(), content, expects) 66 | def performJsonGet(uri: UriComponents, content: String, expects: JsonExpects): ResultActions = 67 | perform( 68 | get(uri.toUriString()).contentType(MediaType.APPLICATION_JSON).content(content).accept(MediaType.APPLICATION_JSON), 69 | expects.expects.toList) 70 | 71 | /** Post 要求を投げて結果を検証します。 */ 72 | def performPost(path: String, expects: JsonExpects): ResultActions = 73 | performPost(uriBuilder(path).build(), expects) 74 | def performPost(uri: UriComponents, expects: JsonExpects): ResultActions = 75 | perform( 76 | post(uri.toUriString()).accept(MediaType.APPLICATION_JSON), 77 | expects.expects.toList) 78 | 79 | /** Post 要求 ( JSON ) を投げて結果を検証します。 */ 80 | def performJsonPost(path: String, content: String, expects: JsonExpects): ResultActions = 81 | performJsonPost(uriBuilder(path).build(), content, expects) 82 | def performJsonPost(uri: UriComponents, content: String, expects: JsonExpects): ResultActions = 83 | perform( 84 | post(uri.toUriString()).contentType(MediaType.APPLICATION_JSON).content(content).accept(MediaType.APPLICATION_JSON), 85 | expects.expects.toList) 86 | 87 | def perform(req: RequestBuilder, expects: Seq[ResultMatcher]): ResultActions = { 88 | var result = mvc.perform(req) 89 | expects.foreach(result.andExpect) 90 | result 91 | } 92 | } 93 | 94 | /** JSON 検証をビルダー形式で可能にします */ 95 | class JsonExpects { 96 | var expects = scala.collection.mutable.ListBuffer[ResultMatcher](); 97 | def value(key: String, expectedValue: Any): JsonExpects = { 98 | this.expects += jsonPath(key).value(expectedValue) 99 | this 100 | } 101 | def matcher[T](key: String, matcher: Matcher[T]): JsonExpects = { 102 | this.expects += jsonPath(key).value(matcher) 103 | this 104 | } 105 | def empty(key: String): JsonExpects = { 106 | this.expects += jsonPath(key).isEmpty() 107 | this 108 | } 109 | def notEmpty(key: String): JsonExpects = { 110 | this.expects += jsonPath(key).isNotEmpty() 111 | this 112 | } 113 | def array(key: String): JsonExpects = { 114 | this.expects += jsonPath(key).isArray() 115 | this 116 | } 117 | def map(key: String): JsonExpects = { 118 | this.expects += jsonPath(key).isMap() 119 | this 120 | } 121 | } 122 | object JsonExpects { 123 | // 200 OK 124 | def success(): JsonExpects = { 125 | var v = new JsonExpects() 126 | v.expects += status().isOk() 127 | v 128 | } 129 | // 400 Bad Request 130 | def failure(): JsonExpects = { 131 | var v = new JsonExpects() 132 | v.expects += status().isBadRequest() 133 | v 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/main/scala/sample/context/audit/AuditActor.scala: -------------------------------------------------------------------------------- 1 | package sample.context.audit 2 | 3 | import java.time.{ LocalDateTime, LocalDate } 4 | 5 | import org.apache.commons.lang3.StringUtils 6 | 7 | import sample._ 8 | import sample.context._ 9 | import sample.context.actor.ActorRoleType 10 | import sample.context.orm._ 11 | import sample.util._ 12 | import scalikejdbc._ 13 | import scalikejdbc.jsr310.Implicits._ 14 | 15 | /** 16 | * システム利用者の監査ログを表現します。 17 | */ 18 | case class AuditActor( 19 | /** ID */ 20 | id: Long = -1L, // autogen 21 | /** 利用者ID */ 22 | actorId: String, 23 | /** 利用者役割 */ 24 | roleType: ActorRoleType, 25 | /** 利用者ソース(IP等) */ 26 | source: Option[String] = None, 27 | /** カテゴリ */ 28 | category: String, 29 | /** メッセージ */ 30 | message: String, 31 | /** 処理ステータス */ 32 | statusType: ActionStatusType, 33 | /** エラー事由 */ 34 | errorReason: Option[String] = None, 35 | /** 処理時間(msec) */ 36 | time: Option[Long] = None, 37 | /** 開始日時 */ 38 | startDate: LocalDateTime, 39 | /** 終了日時(未完了時はNone) */ 40 | endDate: Option[LocalDateTime] = None) extends Entity 41 | 42 | object AuditActor extends AuditActorMapper { 43 | 44 | /** 利用者監査ログを検索します。 */ 45 | def find(p: FindAuditActor)(implicit session: DBSession, dh: DomainHelper): PagingList[AuditActor] = 46 | PagingList(AuditActor.withAlias(m => 47 | findAllByWithLimitOffset( 48 | sqls.toAndConditionOpt( 49 | Some(sqls.between(m.startDate, p.fromDay.atStartOfDay(), DateUtils.dateTo(p.toDay))), 50 | Some(sqls.eq(m.roleType, p.roleType.value)), 51 | p.statusType.map(stype => sqls.eq(m.statusType, stype.value)), 52 | p.category.map(sqls.eq(m.category, _)), 53 | p.actorId.map(aid => 54 | sqls.like(m.actorId, p.likeActorId).or(sqls.like(m.source, p.likeActorId))), 55 | p.keyword.map(key => 56 | sqls.like(m.message, p.likeKeyword).or(sqls.like(m.errorReason, p.likeKeyword))) 57 | ).getOrElse(sqls.empty), 58 | p.page.size, p.page.firstResult, 59 | Seq(m.startDate.desc) 60 | )), p.page) 61 | 62 | /** 利用者監査ログを登録します。 */ 63 | def register(p: RegAuditActor)(implicit session: DBSession, dh: DomainHelper): Long = 64 | Some(dh.actor).map(actor => 65 | AuditActor.createWithAttributes( 66 | 'actorId -> actor.id, 67 | 'roleType -> actor.roleType.value, 68 | 'source -> actor.source, 69 | 'category -> p.category, 70 | 'message -> ConvertUtils.left(p.message, 300), 71 | 'statusType -> ActionStatusType.Processing.value, 72 | 'startDate -> dh.time.date) 73 | ).get 74 | 75 | /** 利用者監査ログを完了状態にします。 */ 76 | def finish(id: Long)(implicit session: DBSession, dh: DomainHelper): Unit = { 77 | val now = dh.time.date 78 | val m = findById(id).getOrElse(throw ValidationException(ErrorKeys.EntityNotFound)) 79 | updateById(id).withAttributes( 80 | 'statusType -> ActionStatusType.Processed.value, 81 | 'endDate -> now, 82 | 'time -> DateUtils.between(m.startDate, now).get.toMillis()) 83 | } 84 | 85 | /** 利用者監査ログを取消状態にします。 */ 86 | def cancel(id: Long, errorReason: String)(implicit session: DBSession, dh: DomainHelper): Long = { 87 | val now = dh.time.date 88 | val m = findById(id).getOrElse(throw ValidationException(ErrorKeys.EntityNotFound)) 89 | updateById(id).withAttributes( 90 | 'statusType -> ActionStatusType.Cancelled.value, 91 | 'endDate -> now, 92 | 'time -> DateUtils.between(m.startDate, now).get.toMillis()) 93 | } 94 | 95 | /** 利用者監査ログを例外状態にします。 */ 96 | def error(id: Long, errorReason: String)(implicit session: DBSession, dh: DomainHelper): Long = { 97 | val now = dh.time.date 98 | val m = findById(id).getOrElse(throw ValidationException(ErrorKeys.EntityNotFound)) 99 | updateById(id).withAttributes( 100 | 'statusType -> ActionStatusType.Error.value, 101 | 'errorReason -> StringUtils.abbreviate(errorReason, 250), 102 | 'endDate -> now, 103 | 'time -> DateUtils.between(m.startDate, now).get.toMillis()) 104 | } 105 | } 106 | 107 | trait AuditActorMapper extends SkinnyORMMapper[AuditActor] { 108 | override def extract(rs: WrappedResultSet, n: ResultName[AuditActor]) = 109 | AuditActor( 110 | id = rs.long(n.id), 111 | actorId = rs.string(n.actorId), 112 | roleType = ActorRoleType.withName(rs.string(n.roleType)), 113 | source = rs.stringOpt(n.source), 114 | category = rs.string(n.category), 115 | message = rs.string(n.message), 116 | statusType = ActionStatusType.withName(rs.string(n.statusType)), 117 | errorReason = rs.stringOpt(n.errorReason), 118 | time = rs.longOpt(n.time), 119 | startDate = rs.localDateTime(n.startDate), 120 | endDate = rs.localDateTimeOpt(n.endDate)) 121 | } 122 | 123 | /** 検索パラメタ */ 124 | case class FindAuditActor( 125 | actorId: Option[String] = None, 126 | category: Option[String] = None, 127 | keyword: Option[String] = None, 128 | roleType: ActorRoleType, 129 | statusType: Option[ActionStatusType] = None, 130 | fromDay: LocalDate, 131 | toDay: LocalDate, 132 | page: Pagination = Pagination()) extends Dto { 133 | def likeActorId = s"%${actorId.getOrElse("")}%" 134 | def likeKeyword = s"%${keyword.getOrElse("")}%" 135 | } 136 | 137 | /** 登録パラメタ */ 138 | case class RegAuditActor( 139 | category: String, 140 | message: String) extends Dto 141 | object RegAuditActor { 142 | def apply(message: String): RegAuditActor = RegAuditActor("default", message) 143 | } 144 | -------------------------------------------------------------------------------- /src/main/scala/sample/context/audit/AuditHandler.scala: -------------------------------------------------------------------------------- 1 | package sample.context.audit 2 | 3 | import scala.util.{ Try, Success, Failure } 4 | 5 | import org.slf4j.Logger 6 | import org.slf4j.LoggerFactory 7 | import org.springframework.beans.factory.annotation.Autowired 8 | 9 | import sample._ 10 | import sample.context._ 11 | import sample.context.actor.ActorSession 12 | import scalikejdbc._ 13 | 14 | /** 15 | * 利用者監査やシステム監査(定時バッチや日次バッチ等)などを取り扱います。 16 | *
暗黙的な適用を望む場合は、AOPとの連携も検討してください。 17 | *
対象となるログはLoggerだけでなく、システムスキーマの監査テーブルへ書きだされます。 18 | * (開始時と完了時で別TXにする事で応答無し状態を検知可能) 19 | */ 20 | class AuditHandler { 21 | val LoggerActor: Logger = LoggerFactory.getLogger("Audit.Actor") 22 | val LoggerEvent: Logger = LoggerFactory.getLogger("Audit.Event") 23 | val LoggerSystem: Logger = LoggerFactory.getLogger(classOf[AuditHandler]) 24 | 25 | @Autowired 26 | private var session: ActorSession = _ 27 | @Autowired 28 | private var persister: AuditPersister = _ 29 | 30 | /** 与えた処理に対し、監査ログを記録します。 */ 31 | def audit[T](message: String, callable: => T): T = 32 | audit("default", message, callable) 33 | def audit[T](category: String, message: String, callable: => T): T = { 34 | logger.trace(msg(message, "[開始]")) 35 | val start = System.currentTimeMillis() 36 | try { 37 | val v = 38 | if (session.actor.roleType.system) callEvent(category, message, callable) 39 | else callAudit(category, message, callable); 40 | logger.info(msg(message, "[完了]", Some(start))) 41 | v 42 | } catch { 43 | case e: ValidationException => 44 | logger.warn(msg(message, "[審例]", Some(start))) 45 | throw e 46 | case e: RuntimeException => 47 | logger.error(msg(message, "[例外]", Some(start))) 48 | throw e 49 | case e: Exception => 50 | logger.error(msg(message, "[例外]", Some(start))) 51 | throw InvocationException(ErrorKeys.Exception, e) 52 | } 53 | } 54 | 55 | private def logger: Logger = if (session.actor.roleType.system) LoggerEvent else LoggerActor 56 | 57 | private def msg(message: String, prefix: String, startMillis: Option[Long] = None): String = { 58 | val actor = session.actor 59 | val sb = new StringBuilder(s"${prefix} ") 60 | if (actor.roleType.notSystem) { 61 | sb.append(s"[${actor.id}] ") 62 | } 63 | sb.append(message); 64 | if (startMillis.isDefined) { 65 | sb.append(s" [${System.currentTimeMillis() - startMillis.get} ms]") 66 | } 67 | return sb.toString() 68 | } 69 | 70 | def callAudit[T](category: String, message: String, callable: => T): T = { 71 | var id: Option[Long] = Option.empty 72 | try { 73 | Try({id = Option(persister.startActor(RegAuditActor(category, message)))}) match { 74 | case Success(v) => // nothing 75 | case Failure(ex) => LoggerSystem.error(ex.getMessage, ex) 76 | } 77 | callable 78 | } catch { 79 | case e: ValidationException => 80 | Try(id.map(persister.cancelActor(_, e.getMessage))) match { 81 | case Success(v) => // nothing 82 | case Failure(ex) => LoggerSystem.error(ex.getMessage, ex) 83 | } 84 | throw e 85 | case e: Exception => 86 | Try(id.map(persister.errorActor(_, e.getMessage))) match { 87 | case Success(v) => // nothing 88 | case Failure(ex) => LoggerSystem.error(ex.getMessage, ex) 89 | } 90 | throw e 91 | } 92 | } 93 | 94 | def callEvent[T](category: String, message: String, callable: => T): T = { 95 | var id: Option[Long] = Option.empty 96 | try { 97 | Try({id = Option(persister.startEvent(RegAuditEvent(category, message)))}) match { 98 | case Success(v) => // nothing 99 | case Failure(ex) => LoggerSystem.error(ex.getMessage, ex) 100 | } 101 | callable 102 | } catch { 103 | case e: ValidationException => 104 | Try(id.map(persister.cancelEvent(_, e.getMessage))) match { 105 | case Success(v) => // nothing 106 | case Failure(ex) => LoggerSystem.error(ex.getMessage, ex) 107 | } 108 | throw e 109 | case e: Exception => 110 | Try(id.map(persister.errorEvent(_, e.getMessage))) match { 111 | case Success(v) => // nothing 112 | case Failure(ex) => LoggerSystem.error(ex.getMessage, ex) 113 | } 114 | throw e 115 | } 116 | } 117 | } 118 | /** 119 | * 監査ログをシステムスキーマへ永続化します。 120 | */ 121 | class AuditPersister { 122 | @Autowired 123 | private implicit var dh: DomainHelper = _ 124 | def startActor(p: RegAuditActor)(implicit s: DBSession = AuditActor.autoSession): Long = 125 | AuditActor.register(p) 126 | def finishActor(id: Long)(implicit s: DBSession = AuditActor.autoSession): Unit = 127 | AuditActor.finish(id) 128 | def cancelActor(id: Long, errorReason: String)(implicit s: DBSession = AuditActor.autoSession): Unit = 129 | AuditActor.cancel(id, errorReason) 130 | def errorActor(id: Long, errorReason: String)(implicit s: DBSession = AuditActor.autoSession): Unit = 131 | AuditActor.error(id, errorReason) 132 | def startEvent(p: RegAuditEvent)(implicit s: DBSession = AuditEvent.autoSession): Long = 133 | AuditEvent.register(p) 134 | def finishEvent(id: Long)(implicit s: DBSession = AuditEvent.autoSession): Unit = 135 | AuditEvent.finish(id) 136 | def cancelEvent(id: Long, errorReason: String)(implicit s: DBSession = AuditEvent.autoSession): Unit = 137 | AuditEvent.cancel(id, errorReason) 138 | def errorEvent(id: Long, errorReason: String)(implicit s: DBSession = AuditEvent.autoSession): Unit = 139 | AuditEvent.error(id, errorReason) 140 | } 141 | -------------------------------------------------------------------------------- /src/test/scala/sample/model/asset/CashInOutSpec.scala: -------------------------------------------------------------------------------- 1 | package sample.model.asset 2 | 3 | import java.time.LocalDate 4 | import scala.util.{ Try, Success, Failure } 5 | import org.junit.runner.RunWith 6 | import org.scalatest.junit.JUnitRunner 7 | import sample.ActionStatusType 8 | import sample.UnitSpecSupport 9 | import sample.model.DataFixtures._ 10 | import sample.model.account.AccountStatusType 11 | import sample.model.asset._ 12 | import scalikejdbc._ 13 | import sample.model.DomainErrorKeys 14 | import sample.ErrorKeys 15 | 16 | //low: 簡易な正常系検証が中心。依存するCashflow/CashBalanceの単体検証パスを前提。 17 | @RunWith(classOf[JUnitRunner]) 18 | class CashInOutSpec extends UnitSpecSupport { 19 | val ccy = "JPY" 20 | val accId = "test" 21 | 22 | behavior of "振込入出金依頼" 23 | 24 | override def postBefore = {implicit s: DBSession => 25 | // 残高1000円の口座(test)を用意 26 | saveSelfFiAcc(Remarks.CashOut, ccy) 27 | saveAcc(accId, AccountStatusType.Normal) 28 | saveFiAcc(accId, Remarks.CashOut, ccy) 29 | saveCb(accId, businessDay.day, ccy, "1000") 30 | } 31 | 32 | it should "振込入出金を検索する" in { implicit session => 33 | val baseDay = businessDay.day 34 | val basePlus1Day = businessDay.day(1) 35 | val basePlus2Day = businessDay.day(2) 36 | saveCio(businessDay, accId, "300", true) 37 | //low: ちゃんとやると大変なので最低限の検証 38 | CashInOut.find(findParam(baseDay, basePlus1Day)).size should be (1) 39 | CashInOut.find(findParam(baseDay, basePlus1Day, ActionStatusType.Unprocessed)).size should be (1) 40 | CashInOut.find(findParam(baseDay, basePlus1Day, ActionStatusType.Processed)).size should be (0) 41 | CashInOut.find(findParam(basePlus1Day, basePlus2Day, ActionStatusType.Unprocessed)).size should be (0) 42 | } 43 | def findParam(fromDay: LocalDate, toDay: LocalDate, statusTypes: ActionStatusType*) = 44 | FindCashInOut(Some(ccy), statusTypes, fromDay, toDay) 45 | 46 | it should "振込出金依頼をする" in { implicit session => 47 | val baseDay = businessDay.day 48 | val basePlus3Day = businessDay.day(3) 49 | // 超過の出金依頼 [例外] 50 | Try(CashInOut.withdraw(businessDay, RegCashOut(Some(accId), ccy, BigDecimal("1001")))) match { 51 | case Success(v) => fail() 52 | case Failure(e) => e.getMessage should be (AssetErrorKeys.CashInOutWithdrawAmount) 53 | } 54 | // 0円出金の出金依頼 [例外] 55 | Try(CashInOut.withdraw(businessDay, RegCashOut(Some(accId), ccy, BigDecimal("0")))) match { 56 | case Success(v) => fail() 57 | case Failure(e) => e.getMessage should be (DomainErrorKeys.AbsAmountZero) 58 | } 59 | // 通常の出金依頼 60 | val normal = CashInOut.load( 61 | CashInOut.withdraw(businessDay, RegCashOut(Some(accId), ccy, BigDecimal("300")))) 62 | normal.accountId should be (accId) 63 | normal.currency should be (ccy) 64 | normal.absAmount should be (BigDecimal("300")) 65 | normal.withdrawal should be (true) 66 | normal.requestDay should be (baseDay) 67 | normal.eventDay should be (baseDay) 68 | normal.valueDay should be (basePlus3Day) 69 | normal.targetFiCode should be (s"${Remarks.CashOut}-${ccy}") 70 | normal.targetFiAccountId should be (s"FI${accId}") 71 | normal.selfFiCode should be (s"${Remarks.CashOut}-${ccy}") 72 | normal.selfFiAccountId should be ("xxxxxx") 73 | normal.statusType should be (ActionStatusType.Unprocessed) 74 | normal.cashflowId should be (None) 75 | 76 | // 拘束額を考慮した出金依頼 [例外] 77 | Try(CashInOut.withdraw(businessDay, RegCashOut(Some(accId), ccy, BigDecimal("701")))) match { 78 | case Success(v) => fail() 79 | case Failure(e) => e.getMessage should be (AssetErrorKeys.CashInOutWithdrawAmount) 80 | } 81 | } 82 | 83 | it should "振込出金依頼を取消する" in { implicit session => 84 | // CF未発生の依頼を取消 85 | val normal = saveCio(businessDay, accId, "300", true) 86 | normal.cancel() 87 | CashInOut.load(normal.id).statusType should be (ActionStatusType.Cancelled) 88 | 89 | // 発生日を迎えた場合は取消できない [例外] 90 | val today = saveCio(businessDay, accId, "300", true, Some(businessDay.day)) 91 | Try(today.cancel()) match { 92 | case Success(v) => fail() 93 | case Failure(e) => e.getMessage should be (AssetErrorKeys.CashInOutBeforeEqualsDay) 94 | } 95 | } 96 | 97 | it should "振込出金依頼を例外状態とする" in { implicit session => 98 | // CF未発生の依頼を取消 99 | val normal = saveCio(businessDay, accId, "300", true) 100 | normal.error() 101 | CashInOut.load(normal.id).statusType should be (ActionStatusType.Error) 102 | 103 | // 処理済の時はエラーにできない [例外] 104 | val processed = saveCio(businessDay, accId, "300", true, Some(businessDay.day)) 105 | processed.process() 106 | Try(CashInOut.load(processed.id).error()) match { 107 | case Success(v) => fail() 108 | case Failure(e) => e.getMessage should be (ErrorKeys.ActionUnprocessing) 109 | } 110 | } 111 | 112 | it should "発生日を迎えた振込入出金をキャッシュフロー登録する" in { implicit session => 113 | val baseDay = businessDay.day 114 | val basePlus3Day = businessDay.day(3) 115 | // 発生日未到来の処理 [例外] 116 | val future = saveCio(businessDay, accId, "300", true) 117 | Try(future.process()) match { 118 | case Success(v) => fail() 119 | case Failure(e) => e.getMessage should be (AssetErrorKeys.CashInOutAfterEqualsDay) 120 | } 121 | // 発生日到来処理 122 | val normal = saveCio(businessDay, accId, "300", true, Some(baseDay)) 123 | val processed = CashInOut.load(normal.process()) 124 | processed.statusType should be (ActionStatusType.Processed) 125 | processed.cashflowId.isDefined should be (true) 126 | val cf = Cashflow.load(processed.cashflowId.get) 127 | cf.accountId should be (accId) 128 | cf.currency should be (ccy) 129 | cf.amount should be (BigDecimal("-300")) 130 | cf.cashflowType should be (CashflowType.CashOut) 131 | cf.remark should be (Remarks.CashOut) 132 | cf.eventDay should be (baseDay) 133 | cf.valueDay should be (baseDay) 134 | cf.statusType should be (ActionStatusType.Processed) 135 | } 136 | 137 | } -------------------------------------------------------------------------------- /src/main/scala/sample/model/asset/Cashflow.scala: -------------------------------------------------------------------------------- 1 | package sample.model.asset 2 | 3 | import java.time.{LocalDate, LocalDateTime} 4 | 5 | import scala.util.{ Success, Failure } 6 | 7 | import com.fasterxml.jackson.annotation.JsonValue 8 | 9 | import sample._ 10 | import sample.context._ 11 | import sample.context.orm.SkinnyORMMapper 12 | import sample.util.Validator 13 | import scalikejdbc._ 14 | import scalikejdbc.jsr310.Implicits._ 15 | 16 | /** 17 | * 入出金キャッシュフローを表現します。 18 | * キャッシュフローは振込/振替といったキャッシュフローアクションから生成される確定状態(依頼取消等の無い)の入出金情報です。 19 | * low: 概念を伝えるだけなので必要最低限の項目で表現しています。 20 | * low: 検索関連は主に経理確認や帳票等での利用を想定します 21 | */ 22 | case class Cashflow( 23 | /** ID */ 24 | id: Long = 0, 25 | /** 口座ID */ 26 | accountId: String, 27 | /** 通貨 */ 28 | currency: String, 29 | /** 金額 */ 30 | amount: BigDecimal, 31 | /** 入出金 */ 32 | cashflowType: CashflowType, 33 | /** 摘要*/ 34 | remark: String, 35 | /** 発生日 */ 36 | eventDay: LocalDate, 37 | /** 発生日時 */ 38 | eventDate: LocalDateTime, 39 | /** 受渡日 */ 40 | valueDay: LocalDate, 41 | /** 処理種別 */ 42 | statusType: ActionStatusType) extends Entity { 43 | 44 | /** キャッシュフローを実現(受渡)可能か判定します。 */ 45 | def canRealize(implicit dh: DomainHelper): Boolean = 46 | dh.time.tp.afterEqualsDay(valueDay) 47 | 48 | /** キャッシュフローを処理済みにして残高へ反映します。 */ 49 | def realize()(implicit s: DBSession, dh: DomainHelper): Long = 50 | Validator.validateTry(v => 51 | v.verify(canRealize, AssetErrorKeys.CashflowRealizeDay) 52 | .verify(statusType.isUnprocessing, ErrorKeys.ActionUnprocessing) 53 | ) match { 54 | case Success(v) => 55 | Cashflow.updateById(id).withAttributes( 56 | 'statusType -> ActionStatusType.Processed.value) 57 | CashBalance.add(accountId, currency, amount) 58 | id 59 | case Failure(e) => throw e 60 | } 61 | 62 | /** 63 | * キャッシュフローをエラー状態にします。 64 | *
処理中に失敗した際に呼び出してください。 65 | * low: 実際はエラー事由などを引数に取って保持する 66 | */ 67 | def error()(implicit s: DBSession, dh: DomainHelper): Unit = 68 | Validator.validateTry(v => 69 | v.verify(statusType.isUnprocessed, ErrorKeys.ActionUnprocessing) 70 | ) match { 71 | case Success(v) => 72 | Cashflow.updateById(id).withAttributes( 73 | 'statusType -> ActionStatusType.Error.value) 74 | case Failure(e) => throw e 75 | } 76 | } 77 | 78 | object Cashflow extends CashflowMapper { 79 | 80 | /** キャッシュフローを取得します。 */ 81 | def load(id: Long)(implicit s: DBSession): Cashflow = 82 | findById(id).getOrElse(throw ValidationException(ErrorKeys.EntityNotFound)) 83 | 84 | /** 指定受渡日時点で未実現のキャッシュフロー一覧を検索します。 */ 85 | def findUnrealize(accountId: String, currency: String, valueDay: LocalDate)(implicit s: DBSession): List[Cashflow] = 86 | withAlias(m => 87 | findAllBy( 88 | sqls 89 | .eq(m.accountId, accountId) 90 | .and.eq(m.currency, currency) 91 | .and.le(m.valueDay, valueDay) 92 | .and.in(m.statusType, ActionStatusType.unprocessingTypeValues), 93 | Seq(m.id))) 94 | 95 | /** 指定受渡日で実現対象となるキャッシュフロー一覧を検索します。 */ 96 | def findDoRealize(valueDay: LocalDate)(implicit s: DBSession): List[Cashflow] = 97 | withAlias(m => 98 | findAllBy( 99 | sqls 100 | .eq(m.valueDay, valueDay) 101 | .and.in(m.statusType, ActionStatusType.unprocessedTypeValues), 102 | Seq(m.id))) 103 | 104 | /** 105 | * キャッシュフローを登録します。 106 | * 受渡日を迎えていた時はそのまま残高へ反映します。 107 | */ 108 | def register(p: RegCashflow)(implicit s: DBSession, dh: DomainHelper): Long = 109 | Validator.validateTry(v => 110 | v.checkField(dh.time.tp.beforeEqualsDay(p.valueDay), "valueDay", AssetErrorKeys.CashflowBeforeEqualsDay) 111 | ) match { 112 | case Success(v) => 113 | create(p).map(cf => if (cf.canRealize) cf.realize() else cf.id).get 114 | case Failure(e) => throw e 115 | } 116 | private def create(p: RegCashflow)(implicit s: DBSession, dh: DomainHelper): Option[Cashflow] = 117 | findById(createWithAttributes( 118 | 'accountId -> p.accountId, 119 | 'currency -> p.currency, 120 | 'amount -> p.amount, 121 | 'cashflowType -> p.cashflowType.value, 122 | 'remark -> p.remark, 123 | 'eventDay -> p.eventDay.getOrElse(dh.time.day), 124 | 'eventDate -> dh.time.date, 125 | 'valueDay -> p.valueDay, 126 | 'statusType -> ActionStatusType.Unprocessed.value)) 127 | 128 | } 129 | 130 | trait CashflowMapper extends SkinnyORMMapper[Cashflow] { 131 | override def extract(rs: WrappedResultSet, n: ResultName[Cashflow]) = 132 | Cashflow( 133 | id = rs.long(n.id), 134 | accountId = rs.string(n.accountId), 135 | currency = rs.string(n.currency), 136 | amount = rs.bigDecimal(n.amount), 137 | cashflowType = CashflowType.withName(rs.string(n.cashflowType)), 138 | remark = rs.string(n.remark), 139 | eventDay = rs.localDate(n.eventDay), 140 | eventDate = rs.localDateTime(n.eventDate), 141 | valueDay = rs.localDate(n.valueDay), 142 | statusType = ActionStatusType.withName(rs.string(n.statusType))) 143 | } 144 | 145 | /** キャッシュフロー種別。 low: 各社固有です。摘要含めラベルはなるべくmessages.propertiesへ切り出し */ 146 | sealed trait CashflowType extends EnumSealed { 147 | @JsonValue def value: String = this.toString() 148 | } 149 | object CashflowType extends Enums[CashflowType] { 150 | /** 振込入金 */ 151 | case object CashIn extends CashflowType 152 | /** 振込出金 */ 153 | case object CashOut extends CashflowType 154 | /** 振替入金 */ 155 | case object CashTransferIn extends CashflowType 156 | /** 振替出金 */ 157 | case object CashTransferOut extends CashflowType 158 | 159 | override def values = List(CashIn, CashOut, CashTransferIn, CashTransferOut) 160 | } 161 | 162 | /** 入出金キャッシュフローの登録パラメタ。 (発生日未指定時は営業日を設定) */ 163 | case class RegCashflow( 164 | accountId: String, currency: String, amount: BigDecimal, cashflowType: CashflowType, 165 | remark: String, eventDay: Option[LocalDate] = None, valueDay: LocalDate) extends Dto 166 | --------------------------------------------------------------------------------