├── libs └── .gitkeep ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── src ├── main │ ├── kotlin │ │ └── sample │ │ │ ├── context │ │ │ ├── orm │ │ │ │ ├── PagingList.kt │ │ │ │ ├── OrmActiveMetaRecord.kt │ │ │ │ ├── OrmQueryMetadata.kt │ │ │ │ ├── OrmInterceptor.kt │ │ │ │ ├── Pagination.kt │ │ │ │ ├── Sort.kt │ │ │ │ ├── OrmActiveRecord.kt │ │ │ │ ├── DefaultRepository.kt │ │ │ │ ├── SystemRepository.kt │ │ │ │ ├── OrmRepositoryProperties.kt │ │ │ │ ├── OrmDataSourceProperties.kt │ │ │ │ └── OrmUtils.kt │ │ │ ├── mail │ │ │ │ └── MailHandler.kt │ │ │ ├── report │ │ │ │ ├── ReportFile.kt │ │ │ │ ├── csv │ │ │ │ │ ├── CsvLayout.kt │ │ │ │ │ └── CsvWriter.kt │ │ │ │ └── ReportHandler.kt │ │ │ ├── Dto.kt │ │ │ ├── SimpleObjectProvider.kt │ │ │ ├── security │ │ │ │ ├── SecurityFilters.kt │ │ │ │ ├── SecurityProperties.kt │ │ │ │ └── SecurityActorFinder.kt │ │ │ ├── Entity.kt │ │ │ ├── actor │ │ │ │ ├── ActorSession.kt │ │ │ │ └── Actor.kt │ │ │ ├── DomainHelper.kt │ │ │ ├── Timestamper.kt │ │ │ ├── AppSettingHandler.kt │ │ │ ├── lock │ │ │ │ └── IdLockHandler.kt │ │ │ ├── Repository.kt │ │ │ ├── ResourceBundleHandler.kt │ │ │ └── AppSetting.kt │ │ │ ├── model │ │ │ ├── DomainErrorKeys.kt │ │ │ ├── asset │ │ │ │ ├── Remarks.kt │ │ │ │ ├── AssetErrorKeys.kt │ │ │ │ ├── Asset.kt │ │ │ │ └── CashBalance.kt │ │ │ ├── master │ │ │ │ ├── StaffAuthority.kt │ │ │ │ ├── SelfFiAccount.kt │ │ │ │ ├── Holiday.kt │ │ │ │ └── Staff.kt │ │ │ ├── account │ │ │ │ ├── FiAccount.kt │ │ │ │ ├── Login.kt │ │ │ │ └── Account.kt │ │ │ └── BusinessDayHandler.kt │ │ │ ├── InvocationException.kt │ │ │ ├── controller │ │ │ ├── filter │ │ │ │ └── FilterConfig.kt │ │ │ ├── admin │ │ │ │ ├── AssetAdminController.kt │ │ │ │ ├── SystemAdminController.kt │ │ │ │ └── MasterAdminController.kt │ │ │ ├── RestErrorController.kt │ │ │ ├── system │ │ │ │ └── JobController.kt │ │ │ ├── AccountController.kt │ │ │ ├── LoginInterceptor.kt │ │ │ └── AssetController.kt │ │ │ ├── util │ │ │ ├── Checker.kt │ │ │ ├── Regex.kt │ │ │ ├── Validator.kt │ │ │ ├── TimePoint.kt │ │ │ ├── ConvertUtils.kt │ │ │ ├── DateUtils.kt │ │ │ └── Calculator.kt │ │ │ ├── Application.kt │ │ │ ├── usecase │ │ │ ├── AccountService.kt │ │ │ ├── mail │ │ │ │ └── ServiceMailDeliver.kt │ │ │ ├── ServiceUtils.kt │ │ │ ├── admin │ │ │ │ ├── MasterAdminService.kt │ │ │ │ ├── SystemAdminService.kt │ │ │ │ └── AssetAdminService.kt │ │ │ ├── AssetService.kt │ │ │ └── SecurityService.kt │ │ │ ├── ActionStatusType.kt │ │ │ ├── ValidationException.kt │ │ │ ├── ApplicationSecurityConfig.kt │ │ │ ├── ApplicationConfig.kt │ │ │ └── ApplicationDbConfig.kt │ ├── resources │ │ ├── banner.txt │ │ ├── messages.properties │ │ ├── logback-spring.xml │ │ ├── ehcache.xml │ │ ├── application.yml │ │ └── messages-validation.properties │ └── java │ │ └── sample │ │ └── model │ │ └── constraints │ │ ├── JavaRegex.java │ │ ├── YearEmpty.java │ │ ├── Year.java │ │ ├── Amount.java │ │ ├── AmountEmpty.java │ │ ├── CurrencyEmpty.java │ │ ├── Currency.java │ │ ├── IdStrEmpty.java │ │ ├── IdStr.java │ │ ├── Outline.java │ │ ├── Category.java │ │ ├── NameEmpty.java │ │ ├── AbsAmountEmpty.java │ │ ├── Name.java │ │ ├── AbsAmount.java │ │ ├── Password.java │ │ ├── EmailEmpty.java │ │ ├── Email.java │ │ ├── OutlineEmpty.java │ │ ├── DescriptionEmpty.java │ │ ├── CategoryEmpty.java │ │ └── Description.java └── test │ ├── resources │ ├── application-test.yml │ └── logback.xml │ └── kotlin │ └── sample │ ├── support │ └── MockDomainHelper.kt │ ├── model │ ├── master │ │ ├── StaffAuthorityTest.kt │ │ ├── SelfFiAccountTest.kt │ │ ├── HolidayTest.kt │ │ └── StaffTest.kt │ ├── asset │ │ ├── AssetTest.kt │ │ ├── CashBalanceTest.kt │ │ └── CashflowTest.kt │ └── account │ │ ├── LoginTest.kt │ │ ├── FiAccountTest.kt │ │ └── AccountTest.kt │ └── client │ └── SampleClient.kt ├── .gitignore ├── settings.gradle ├── LICENSE └── gradlew.bat /libs/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | org.gradle.jvmargs=-Dfile.encoding=UTF-8 2 | 3 | kotlinVersion=1.3.72 4 | springBootVersion=2.3.0.RELEASE 5 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jkazama/sample-boot-kotlin/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /src/main/kotlin/sample/context/orm/PagingList.kt: -------------------------------------------------------------------------------- 1 | package sample.context.orm 2 | 3 | /** 4 | * ページング一覧を表現します。 5 | * 6 | * @param 結果オブジェクト(一覧の要素) 7 | */ 8 | data class PagingList(val list: List, val page: Pagination) -------------------------------------------------------------------------------- /src/main/resources/banner.txt: -------------------------------------------------------------------------------- 1 | ====================================================================================== 2 | Start Application [ sample-boot-hibernate ] 3 | -------------------------------------------------------------------------------------- -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-6.4.1-bin.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | -------------------------------------------------------------------------------- /src/test/resources/application-test.yml: -------------------------------------------------------------------------------- 1 | --- 2 | spring: 3 | profiles: test 4 | 5 | logging.config: classpath:logback.xml 6 | 7 | extension: 8 | datasource: 9 | default.jpa.show-sql: true 10 | system.jpa.show-sql: true 11 | security.auth.enabled: false 12 | -------------------------------------------------------------------------------- /src/main/kotlin/sample/context/mail/MailHandler.kt: -------------------------------------------------------------------------------- 1 | package sample.context.mail 2 | 3 | import org.springframework.stereotype.Component 4 | 5 | /** 6 | * メール処理を行います。 7 | * low: サンプルでは概念クラスだけ提供します。実装はSpringが提供するメールコンポーネントを利用してください。 8 | */ 9 | @Component 10 | class MailHandler { 11 | } -------------------------------------------------------------------------------- /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/kotlin/sample/model/DomainErrorKeys.kt: -------------------------------------------------------------------------------- 1 | package sample.model 2 | 3 | /** 4 | * 汎用ドメインで用いるメッセージキー定数。 5 | */ 6 | interface DomainErrorKeys { 7 | companion object { 8 | /** マイナスを含めない数字を入力してください */ 9 | const val AbsAmountZero = "error.domain.AbsAmount.zero" 10 | } 11 | } -------------------------------------------------------------------------------- /src/test/resources/logback.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/main/java/sample/model/constraints/JavaRegex.java: -------------------------------------------------------------------------------- 1 | package sample.model.constraints; 2 | 3 | /** 4 | * 正規表現定数インターフェース。 5 | *

Checker.matchと組み合わせて利用してください。 6 | */ 7 | public interface JavaRegex { 8 | /** Ascii */ 9 | String rAscii = "^\\p{ASCII}*$"; 10 | /** 文字 */ 11 | String rWord = "^(?s).*$"; 12 | } 13 | -------------------------------------------------------------------------------- /src/main/kotlin/sample/context/report/ReportFile.kt: -------------------------------------------------------------------------------- 1 | package sample.context.report 2 | 3 | import sample.context.Dto 4 | 5 | /** ファイルイメージを表現します。 */ 6 | data class ReportFile(val name: String, val data: Collection): Dto { 7 | val size: Int = data.size 8 | companion object { 9 | private const val serialVersionUID = 1L 10 | } 11 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .gradle 2 | /build/ 3 | !gradle/wrapper/gradle-wrapper.jar 4 | bin/ 5 | out/ 6 | 7 | ### STS ### 8 | .apt_generated 9 | .classpath 10 | .factorypath 11 | .project 12 | .settings 13 | .springBeans 14 | 15 | ### IntelliJ IDEA ### 16 | .idea 17 | *.iws 18 | *.iml 19 | *.ipr 20 | 21 | ### NetBeans ### 22 | nbproject/private/ 23 | build/ 24 | nbbuild/ 25 | dist/ 26 | nbdist/ 27 | .nb-gradle/ -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | plugins { 3 | id "org.jetbrains.kotlin.jvm" version "${kotlinVersion}" 4 | id "org.jetbrains.kotlin.plugin.spring" version "${kotlinVersion}" 5 | id "org.jetbrains.kotlin.plugin.jpa" version "${kotlinVersion}" 6 | id "org.springframework.boot" version "${springBootVersion}" 7 | } 8 | } 9 | 10 | rootProject.name = 'sample-boot-kotlin' 11 | -------------------------------------------------------------------------------- /src/main/kotlin/sample/context/Dto.kt: -------------------------------------------------------------------------------- 1 | package sample.context 2 | 3 | import java.io.Serializable 4 | 5 | /** 6 | * DTO(Data Transfer Object)を表現するマーカーインターフェース。 7 | * 8 | *

本インターフェースを継承するDTOは、層(レイヤー)間をまたいで情報を取り扱い可能に 9 | * する役割を持ち、次の責務を果たします。 10 | *

    11 | *
  • 複数の情報の取りまとめによる通信コストの軽減 12 | *
  • 可変情報の集約 13 | *
  • ドメイン情報の転送 14 | *
  • ドメインロジックを持たない、シンプルなバリューオブジェクトの転送 15 | *
16 | */ 17 | interface Dto : Serializable { 18 | } -------------------------------------------------------------------------------- /src/main/resources/logback-spring.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/main/kotlin/sample/context/SimpleObjectProvider.kt: -------------------------------------------------------------------------------- 1 | package sample.context 2 | 3 | import org.springframework.beans.factory.ObjectProvider 4 | 5 | /** 常に単一の値を返す ObjectProvider */ 6 | class SimpleObjectProvider(val value: T? = null): ObjectProvider { 7 | override fun getObject(): T = value!! 8 | override fun getObject(vararg args: Any?): T = value!! 9 | override fun getIfAvailable(): T? = value 10 | override fun getIfUnique(): T? = value 11 | } -------------------------------------------------------------------------------- /src/main/kotlin/sample/InvocationException.kt: -------------------------------------------------------------------------------- 1 | package sample 2 | 3 | /** 4 | * 処理時の実行例外を表現します。 5 | *

復旧不可能なシステム例外をラップする目的で利用してください。 6 | */ 7 | class InvocationException : RuntimeException { 8 | constructor(message: String, cause: Throwable): super(message, cause) {} 9 | constructor(message: String): super(message) {} 10 | constructor(cause: Throwable): super(cause) {} 11 | 12 | companion object { 13 | private const val serialVersionUID: Long = 1 14 | } 15 | } -------------------------------------------------------------------------------- /src/main/kotlin/sample/controller/filter/FilterConfig.kt: -------------------------------------------------------------------------------- 1 | package sample.controller.filter 2 | 3 | import org.springframework.context.annotation.Configuration 4 | import sample.context.security.SecurityFilters 5 | import java.util.ArrayList 6 | import javax.servlet.Filter 7 | 8 | /** 9 | * ServletFilterの拡張実装。 10 | * filtersで返すFilterはSecurityHandlerにおいてActionSessionFilterの後に定義されます。 11 | */ 12 | @Configuration 13 | class FilterConfig : SecurityFilters { 14 | 15 | override fun filters(): List = listOf() 16 | 17 | } -------------------------------------------------------------------------------- /src/main/kotlin/sample/context/security/SecurityFilters.kt: -------------------------------------------------------------------------------- 1 | package sample.context.security 2 | 3 | import javax.servlet.Filter 4 | 5 | /** 6 | * Spring Securityに対するFilter拡張設定。 7 | * 8 | * Filterを追加したい時は本I/Fを継承してBean登録してください。 9 | */ 10 | interface SecurityFilters { 11 | 12 | /** 13 | * Spring SecurityへFilter登録するServletFilter一覧を返します。 14 | * 15 | * 登録したFilterはUsernamePasswordAuthenticationFilter/ActorSessionFilterの後に 16 | * 実行されるのでActorSessionからログイン利用者の情報を取ることが可能です。 17 | */ 18 | fun filters(): List 19 | 20 | } 21 | -------------------------------------------------------------------------------- /src/main/kotlin/sample/context/orm/OrmActiveMetaRecord.kt: -------------------------------------------------------------------------------- 1 | package sample.context.orm 2 | 3 | import sample.context.Entity 4 | import java.time.LocalDateTime 5 | 6 | /** 7 | * OrmActiveRecordに登録/変更メタ概念を付与した基底クラス。 8 | * 本クラスを継承して作成されたEntityは永続化時に自動的なメタ情報更新が行われます。 9 | * @see OrmInterceptor 10 | */ 11 | abstract class OrmActiveMetaRecord( 12 | open var createId: String? = null, 13 | open var createDate: LocalDateTime? = null, 14 | open var updateId: String? = null, 15 | open var updateDate: LocalDateTime? = null 16 | ) : OrmActiveRecord() 17 | -------------------------------------------------------------------------------- /src/main/kotlin/sample/util/Checker.kt: -------------------------------------------------------------------------------- 1 | package sample.util 2 | 3 | /** 4 | * 簡易的な入力チェッカーを表現します。 5 | */ 6 | object Checker { 7 | /** 8 | * 正規表現に文字列がマッチするか。(nullは許容) 9 | * 10 | * 引数のregexにはRegex定数を利用する事を推奨します。 11 | */ 12 | fun match(regex: String, v: Any?): Boolean = 13 | v?.toString()?.matches(regex.toRegex()) ?: true 14 | 15 | /** 文字桁数チェック、max以下の時はtrue。(サロゲートペア対応) */ 16 | fun len(v: String, max: Int): Boolean = 17 | wordSize(v) <= max 18 | 19 | private fun wordSize(v: String): Int = 20 | v.codePointCount(0, v.length) 21 | } -------------------------------------------------------------------------------- /src/main/kotlin/sample/model/asset/Remarks.kt: -------------------------------------------------------------------------------- 1 | package sample.model.asset 2 | 3 | /** 4 | * 摘要定数インターフェース。 5 | */ 6 | interface Remarks { 7 | companion object { 8 | /** 振込入金 */ 9 | const val CashIn = "cashIn" 10 | /** 振込入金(調整) */ 11 | const val CashInAdjust = "cashInAdjust" 12 | /** 振込入金(取消) */ 13 | const val CashInCancel = "cashInCancel" 14 | /** 振込出金 */ 15 | const val CashOut = "cashOut" 16 | /** 振込出金(調整) */ 17 | const val CashOutAdjust = "cashOutAdjust" 18 | /** 振込出金(取消) */ 19 | const val CashOutCancel = "cashOutCancel" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/main/kotlin/sample/context/Entity.kt: -------------------------------------------------------------------------------- 1 | package sample.context 2 | 3 | /** 4 | * ドメインオブジェクトのマーカーインターフェース。 5 | * 6 | *

本インターフェースを継承するドメインオブジェクトは、ある一定の粒度で とりまとめられたドメイン情報と、 7 | * それに関連するビジネスロジックを実行する役割を持ち、 次の責務を果たします。 8 | *

    9 | *
  • ドメイン情報の管理 10 | *
  • ドメイン情報に対する振る舞い 11 | *
12 | * 13 | *

14 | * ドメインモデルの詳細については次の書籍を参考にしてください。 15 | *

20 | */ 21 | interface Entity { 22 | } -------------------------------------------------------------------------------- /src/main/kotlin/sample/model/asset/AssetErrorKeys.kt: -------------------------------------------------------------------------------- 1 | package sample.model.asset 2 | 3 | /** 4 | * 資産の審査例外で用いるメッセージキー定数。 5 | */ 6 | interface AssetErrorKeys { 7 | companion object { 8 | /** 受渡日を迎えていないため実現できません */ 9 | const val CashflowRealizeDay = "error.Cashflow.realizeDay" 10 | /** 既に受渡日を迎えています */ 11 | const val CashflowBeforeEqualsDay = "error.Cashflow.beforeEqualsDay" 12 | 13 | /** 未到来の受渡日です */ 14 | const val CashInOutAfterEqualsDay = "error.CashInOut.afterEqualsDay" 15 | /** 既に発生日を迎えています */ 16 | const val CashInOutBeforeEqualsDay = "error.CashInOut.beforeEqualsDay" 17 | /** 出金可能額を超えています */ 18 | const val CashInOutWithdrawAmount = "error.CashInOut.withdrawAmount" 19 | } 20 | } -------------------------------------------------------------------------------- /src/main/kotlin/sample/context/report/csv/CsvLayout.kt: -------------------------------------------------------------------------------- 1 | package sample.context.report.csv 2 | 3 | import java.util.ArrayList 4 | 5 | 6 | /** 7 | * CSVレイアウトを表現します。 8 | */ 9 | data class CsvLayout( 10 | /** 区切り文字 */ 11 | val delim: Char = ',', 12 | /** クオート文字 */ 13 | val quote: Char = '"', 14 | /** クオート文字を付与しない時はtrue */ 15 | val nonQuote: Boolean = false, 16 | /** 改行文字 */ 17 | val eolSymbols: String = "\r\n", 18 | /** ヘッダ文字列 */ 19 | val header: String? = null, 20 | /** 文字エンコーディング */ 21 | val charset: String = "UTF-8" 22 | ) { 23 | val hasHeader: Boolean = header.isNullOrBlank() 24 | 25 | fun headerCols(): List = 26 | header.orEmpty().splitToSequence(delim).map { it.trim() }.toList() 27 | 28 | } -------------------------------------------------------------------------------- /src/main/kotlin/sample/controller/admin/AssetAdminController.kt: -------------------------------------------------------------------------------- 1 | package sample.controller.admin 2 | 3 | import org.springframework.web.bind.annotation.GetMapping 4 | import org.springframework.web.bind.annotation.RequestMapping 5 | import org.springframework.web.bind.annotation.RestController 6 | import sample.model.asset.CashInOut 7 | import sample.model.asset.FindCashInOut 8 | import sample.usecase.admin.AssetAdminService 9 | import javax.validation.Valid 10 | 11 | /** 12 | * 資産に関わる社内のUI要求を処理します。 13 | */ 14 | @RestController 15 | @RequestMapping("/api/admin/asset") 16 | class AssetAdminController(val service: AssetAdminService) { 17 | 18 | /** 未処理の振込依頼情報を検索します。 */ 19 | @GetMapping("/cio/") 20 | fun findCashInOut(@Valid p: FindCashInOut): List = 21 | service.findCashInOut(p) 22 | 23 | } -------------------------------------------------------------------------------- /src/test/kotlin/sample/support/MockDomainHelper.kt: -------------------------------------------------------------------------------- 1 | package sample.support 2 | 3 | import sample.context.AppSettingHandler 4 | import sample.context.DomainHelper 5 | import sample.context.SimpleObjectProvider 6 | import sample.context.Timestamper 7 | import sample.context.actor.ActorSession 8 | import java.time.Clock 9 | import java.util.* 10 | 11 | 12 | /** モックテスト用のドメインヘルパー */ 13 | class MockDomainHelper( 14 | val mockClock: Clock = Clock.systemDefaultZone(), 15 | val settingMap: MutableMap = mutableMapOf() 16 | ) : DomainHelper( 17 | ActorSession(), Timestamper(mockClock), AppSettingHandler(mockMap = Optional.of(settingMap)) 18 | ) { 19 | fun setting(id: String, value: String): MockDomainHelper { 20 | settingMap[id] = value 21 | return this 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/main/kotlin/sample/context/actor/ActorSession.kt: -------------------------------------------------------------------------------- 1 | package sample.context.actor 2 | 3 | import org.springframework.stereotype.Component 4 | 5 | /** 6 | * スレッドローカルスコープの利用者セッション。 7 | */ 8 | @Component 9 | class ActorSession() { 10 | 11 | /** 利用者セッションへ利用者を紐付けます。 */ 12 | fun bind(actor: Actor): ActorSession { 13 | actorLocal.set(actor) 14 | return this 15 | } 16 | 17 | /** 利用者セッションを破棄します。 */ 18 | fun unbind(): ActorSession { 19 | actorLocal.remove() 20 | return this 21 | } 22 | 23 | /** 有効な利用者を返します。紐付けされていない時は匿名者が返されます。 */ 24 | fun actor(): Actor { 25 | val actor = actorLocal.get() 26 | return actor ?: Actor.Anonymous 27 | } 28 | 29 | companion object { 30 | private val actorLocal = ThreadLocal() 31 | } 32 | 33 | } -------------------------------------------------------------------------------- /src/main/kotlin/sample/context/DomainHelper.kt: -------------------------------------------------------------------------------- 1 | package sample.context 2 | 3 | import org.springframework.stereotype.Component 4 | import sample.context.actor.Actor 5 | import sample.context.actor.ActorSession 6 | 7 | 8 | /** 9 | * ドメイン処理を行う上で必要となるインフラ層コンポーネントへのアクセサを提供します。 10 | */ 11 | @Component 12 | class DomainHelper( 13 | /** スレッドローカルスコープの利用者セッション */ 14 | val actorSession: ActorSession, 15 | /** 日時ユーティリティ */ 16 | val time: Timestamper, 17 | private val settingHandler: AppSettingHandler 18 | ) { 19 | 20 | /** ログイン中のユースケース利用者を取得します。 */ 21 | fun actor(): Actor = actorSession.actor() 22 | 23 | /** アプリケーション設定情報を取得します。 */ 24 | fun setting(id: String): AppSetting = settingHandler.setting(id) 25 | 26 | /** アプリケーション設定情報を設定します。 */ 27 | fun settingSet(id: String, value: String): AppSetting = settingHandler.update(id, value) 28 | 29 | } -------------------------------------------------------------------------------- /src/main/kotlin/sample/Application.kt: -------------------------------------------------------------------------------- 1 | package sample 2 | 3 | import org.springframework.boot.autoconfigure.SpringBootApplication 4 | import org.springframework.boot.runApplication 5 | import org.springframework.cache.annotation.EnableCaching 6 | import org.springframework.context.annotation.Import 7 | import sample.controller.AccountController 8 | import sample.usecase.AccountService 9 | 10 | /** 11 | * アプリケーションプロセスの起動クラス。 12 | *

本クラスを実行する事でSpringBootが提供する組込Tomcatでのアプリケーション起動が行われます。 13 | *

controller / usecase パッケージ配下のみコンポーネントスキャンをおこないます。 14 | */ 15 | @SpringBootApplication(scanBasePackageClasses = [ 16 | AccountController::class, AccountService::class 17 | ]) 18 | @EnableCaching(proxyTargetClass = true) 19 | @Import(ApplicationConfig::class, ApplicationDbConfig::class, ApplicationSecurityConfig::class) 20 | class Application 21 | 22 | fun main(args: Array) { 23 | runApplication(*args) 24 | } 25 | -------------------------------------------------------------------------------- /src/main/kotlin/sample/context/orm/OrmQueryMetadata.kt: -------------------------------------------------------------------------------- 1 | package sample.context.orm 2 | 3 | import java.util.* 4 | import javax.persistence.LockModeType 5 | 6 | /** 7 | * Query 向けの追加メタ情報を構築します。 8 | */ 9 | data class OrmQueryMetadata( 10 | val hints: MutableMap = mutableMapOf(), 11 | var lockMode: Optional = Optional.empty() 12 | ) { 13 | 14 | /** ヒントを追加します。 */ 15 | fun hint(hintName: String, value: Any): OrmQueryMetadata { 16 | this.hints[hintName] = value 17 | return this 18 | } 19 | 20 | /** ロックモードを設定します。 */ 21 | fun lockMode(lockMode: LockModeType): OrmQueryMetadata { 22 | this.lockMode = Optional.ofNullable(lockMode) 23 | return this 24 | } 25 | 26 | companion object { 27 | fun empty() = OrmQueryMetadata() 28 | fun withLock(lockMode: LockModeType) = empty().lockMode(lockMode) 29 | fun withHint(hintName: String, value: Any) = empty().hint(hintName, value) 30 | } 31 | 32 | } -------------------------------------------------------------------------------- /src/main/kotlin/sample/usecase/AccountService.kt: -------------------------------------------------------------------------------- 1 | package sample.usecase 2 | 3 | import org.springframework.cache.annotation.Cacheable 4 | import org.springframework.stereotype.Service 5 | import org.springframework.transaction.annotation.Transactional 6 | import sample.context.orm.DefaultRepository 7 | import sample.model.account.Account 8 | import sample.model.account.Login 9 | import java.util.* 10 | 11 | /** 12 | * 口座ドメインに対する顧客ユースケース処理。 13 | */ 14 | @Service 15 | class AccountService( 16 | val rep: DefaultRepository 17 | ) { 18 | 19 | /** ログイン情報を取得します。 */ 20 | @Transactional(DefaultRepository.BeanNameTx) 21 | @Cacheable("AccountService.getLoginByLoginId") 22 | fun getLoginByLoginId(loginId: String): Optional = Login.getByLoginId(rep, loginId) 23 | 24 | /** 有効な口座情報を取得します。 */ 25 | @Transactional(DefaultRepository.BeanNameTx) 26 | @Cacheable("AccountService.getAccount") 27 | fun getAccount(id: String): Optional = Account.getValid(rep, id) 28 | 29 | } 30 | -------------------------------------------------------------------------------- /src/main/kotlin/sample/ActionStatusType.kt: -------------------------------------------------------------------------------- 1 | package sample 2 | 3 | /** 4 | * 何らかの行為に関わる処理ステータス概念。 5 | */ 6 | enum class ActionStatusType { 7 | /** 未処理 */ 8 | Unprocessed, 9 | /** 処理中 */ 10 | Processing, 11 | /** 処理済 */ 12 | Processed, 13 | /** 取消 */ 14 | Cancelled, 15 | /** エラー */ 16 | Error; 17 | 18 | val isFinish: Boolean 19 | get() = finishTypes.contains(this) 20 | val isUnprocessing: Boolean 21 | get() = unprocessingTypes.contains(this) 22 | val isUnprocessed: Boolean 23 | get() = unprocessedTypes.contains(this) 24 | 25 | companion object { 26 | /** 完了済みのステータス一覧 */ 27 | val finishTypes: List = listOf(Processed, Cancelled) 28 | /** 未完了のステータス一覧(処理中は含めない) */ 29 | val unprocessingTypes: List = listOf(Unprocessed, Error) 30 | /** 未完了のステータス一覧(処理中も含める) */ 31 | val unprocessedTypes: List = listOf(Unprocessed, Processing, Error) 32 | } 33 | } -------------------------------------------------------------------------------- /src/test/kotlin/sample/model/master/StaffAuthorityTest.kt: -------------------------------------------------------------------------------- 1 | package sample.model.master 2 | 3 | import jdk.nashorn.internal.objects.NativeArray.forEach 4 | import org.hamcrest.Matchers.`is` 5 | import org.junit.Assert.assertThat 6 | import org.junit.Test 7 | import sample.EntityTestSupport 8 | 9 | 10 | class StaffAuthorityTest : EntityTestSupport() { 11 | 12 | override fun setupPreset() { 13 | targetEntities(StaffAuthority::class.java) 14 | } 15 | 16 | override fun before() { 17 | tx { 18 | fixtures().staffAuth("staffA", "ID000001", "ID000002", "ID000003").forEach { auth -> auth.save(rep()) } 19 | fixtures().staffAuth("staffB", "ID000001", "ID000002").forEach { auth -> auth.save(rep()) } 20 | } 21 | } 22 | 23 | @Test 24 | fun 権限一覧を検索する() { 25 | tx { 26 | assertThat(StaffAuthority.find(rep(), "staffA").size, `is`(3)) 27 | assertThat(StaffAuthority.find(rep(), "staffB").size, `is`(2)) 28 | } 29 | } 30 | 31 | } 32 | -------------------------------------------------------------------------------- /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[] 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/kotlin/sample/model/master/StaffAuthority.kt: -------------------------------------------------------------------------------- 1 | package sample.model.master 2 | 3 | import sample.context.orm.OrmActiveRecord 4 | import sample.context.orm.OrmRepository 5 | import sample.model.constraints.IdStr 6 | import sample.model.constraints.Name 7 | import javax.persistence.Entity 8 | import javax.persistence.GeneratedValue 9 | import javax.persistence.Id 10 | 11 | 12 | /** 13 | * 社員に割り当てられた権限を表現します。 14 | */ 15 | @Entity 16 | data class StaffAuthority( 17 | /** ID */ 18 | @Id 19 | @GeneratedValue 20 | var id: Long? = null, 21 | /** 社員ID */ 22 | @field:IdStr 23 | val staffId: String, 24 | /** 権限名称。(「プリフィックスにROLE_」を付与してください) */ 25 | @field:Name 26 | val authority: String 27 | ) : OrmActiveRecord() { 28 | companion object { 29 | private const val serialVersionUID = 1L 30 | 31 | /** 口座IDに紐付く権限一覧を返します。 */ 32 | fun find(rep: OrmRepository, staffId: String): List = 33 | rep.tmpl().find("FROM StaffAuthority WHERE staffId=?1", staffId) 34 | } 35 | 36 | } 37 | -------------------------------------------------------------------------------- /src/main/kotlin/sample/usecase/mail/ServiceMailDeliver.kt: -------------------------------------------------------------------------------- 1 | package sample.usecase.mail 2 | 3 | import org.springframework.beans.factory.annotation.Qualifier 4 | import org.springframework.stereotype.Component 5 | import org.springframework.transaction.PlatformTransactionManager 6 | import sample.context.mail.MailHandler 7 | import sample.context.orm.DefaultRepository 8 | import sample.model.asset.CashInOut 9 | import sample.usecase.ServiceUtils 10 | 11 | 12 | /** 13 | * アプリケーション層のサービスメール送信を行います。 14 | *

独自にトランザクションを管理するので、サービスのトランザクション内で 15 | * 呼び出さないように注意してください。 16 | */ 17 | @Component 18 | class ServiceMailDeliver( 19 | private val rep: DefaultRepository, 20 | @Qualifier(DefaultRepository.BeanNameTx) 21 | private val txm: PlatformTransactionManager, 22 | private val mail: MailHandler 23 | ) { 24 | /** トランザクション処理を実行します。 */ 25 | private fun tx(callable: () -> T): T = ServiceUtils.tx(txm, callable) 26 | 27 | /** 出金依頼受付メールを送信します。 */ 28 | fun sendWithdrawal(cio: CashInOut) { 29 | //low: サンプルなので未実装。実際は独自にトランザクションを貼って処理を行う 30 | } 31 | 32 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018-2020 jkazama 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/kotlin/sample/model/asset/Asset.kt: -------------------------------------------------------------------------------- 1 | package sample.model.asset 2 | 3 | import jdk.nashorn.internal.objects.NativeArray.forEach 4 | import sample.util.Calculator 5 | import java.time.LocalDate 6 | import sample.context.orm.OrmRepository 7 | import java.math.BigDecimal 8 | 9 | /** 10 | * 口座の資産概念を表現します。 11 | * asset配下のEntityを横断的に取り扱います。 12 | * low: 実際の開発では多通貨や執行中/拘束中のキャッシュフローアクションに対する考慮で、サービスによってはかなり複雑になります。 13 | */ 14 | data class Asset(val id: String) { 15 | /** 16 | * 振込出金可能か判定します。 17 | * 18 | * 0 <= 口座残高 + 未実現キャッシュフロー - (出金依頼拘束額 + 出金依頼額) 19 | * low: 判定のみなのでscale指定は省略。余力金額を返す時はきちんと指定する 20 | */ 21 | fun canWithdraw(rep: OrmRepository, currency: String, absAmount: BigDecimal, valueDay: LocalDate): Boolean { 22 | val calc = Calculator.of(CashBalance.getOrNew(rep, id, currency).amount) 23 | Cashflow.findUnrealize(rep, id, currency, valueDay).stream().forEach { calc.add(it.amount) } 24 | CashInOut.findUnprocessed(rep, id, currency, true).stream() 25 | .forEach { calc.add(it.absAmount.negate()) } 26 | calc.add(absAmount.negate()) 27 | return 0 <= calc.decimal().signum() 28 | } 29 | } -------------------------------------------------------------------------------- /src/main/kotlin/sample/controller/RestErrorController.kt: -------------------------------------------------------------------------------- 1 | package sample.controller 2 | 3 | import org.springframework.web.context.request.ServletWebRequest 4 | import org.springframework.web.bind.annotation.RequestMapping 5 | import org.springframework.beans.factory.annotation.Autowired 6 | import org.springframework.boot.web.servlet.error.ErrorAttributes 7 | import org.springframework.boot.web.servlet.error.ErrorController 8 | import org.springframework.web.bind.annotation.RestController 9 | 10 | 11 | /** 12 | * REST用の例外ハンドリングを行うController。 13 | * 14 | * application.ymlの"error.path"属性との組合せで有効化します。 15 | * あわせて"error.whitelabel.enabled: false"でwhitelabelを無効化しておく必要があります。 16 | * see ErrorMvcAutoConfiguration 17 | */ 18 | @RestController 19 | class RestErrorController( 20 | private val errorAttributes: ErrorAttributes 21 | ): ErrorController { 22 | 23 | override fun getErrorPath(): String = PathError 24 | 25 | @RequestMapping(PathError) 26 | fun error(request: ServletWebRequest): Map = 27 | this.errorAttributes.getErrorAttributes(request, false) 28 | 29 | companion object { 30 | const val PathError = "/api/error" 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /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[] 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/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[] 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[] 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[] 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/kotlin/sample/context/Timestamper.kt: -------------------------------------------------------------------------------- 1 | package sample.context 2 | 3 | import org.springframework.stereotype.Component 4 | import sample.util.DateUtils 5 | import sample.util.TimePoint 6 | import java.time.Clock 7 | import java.time.LocalDate 8 | import java.time.LocalDateTime 9 | 10 | /** 11 | * 日時ユーティリティコンポーネント。 12 | */ 13 | @Component 14 | class Timestamper( 15 | private val clock: Clock = Clock.systemDefaultZone(), 16 | private val setting: AppSettingHandler? = null 17 | ) { 18 | /** 営業日を返します。 */ 19 | fun day(): LocalDate = 20 | if (setting == null) LocalDate.now(clock) else DateUtils.day(setting.setting(KeyDay).str()) 21 | /** 日時を返します。 */ 22 | fun date(): LocalDateTime = 23 | LocalDateTime.now(clock) 24 | /** 営業日/日時を返します。 */ 25 | fun tp(): TimePoint = 26 | TimePoint(day(), date()) 27 | 28 | /** 29 | * 営業日を指定日へ進めます。 30 | * AppSettingHandlerを設定時のみ有効です。 31 | * @param day 更新営業日 32 | */ 33 | fun proceedDay(day: LocalDate): Timestamper { 34 | setting?.update(KeyDay, DateUtils.dayFormat(day)) 35 | return this 36 | } 37 | 38 | companion object { 39 | const val KeyDay = "system.businessDay.day" 40 | } 41 | } -------------------------------------------------------------------------------- /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 | /** 12 | * 通貨(必須)を表現する制約注釈。 13 | */ 14 | @Documented 15 | @Constraint(validatedBy = {}) 16 | @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER }) 17 | @Retention(RUNTIME) 18 | @ReportAsSingleViolation 19 | @NotBlank 20 | @Size 21 | @Pattern(regexp = "") 22 | public @interface Currency { 23 | String message() default "{error.domain.currency}"; 24 | 25 | Class[] groups() default {}; 26 | 27 | Class[] payload() default {}; 28 | 29 | @OverridesAttribute(constraint = Size.class, name = "max") 30 | int max() default 3; 31 | 32 | @OverridesAttribute(constraint = Pattern.class, name = "regexp") 33 | String regexp() default "^[a-zA-Z]{3}$"; 34 | 35 | @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER }) 36 | @Retention(RUNTIME) 37 | @Documented 38 | public @interface List { 39 | Currency[] value(); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/main/kotlin/sample/usecase/ServiceUtils.kt: -------------------------------------------------------------------------------- 1 | package sample.usecase 2 | 3 | import org.springframework.transaction.PlatformTransactionManager 4 | import org.springframework.transaction.support.TransactionTemplate 5 | import sample.ErrorKeys 6 | import sample.InvocationException 7 | import sample.ValidationException 8 | import sample.context.DomainHelper 9 | import sample.context.actor.Actor 10 | 11 | /** 12 | * Serviceで利用されるユーティリティ処理。 13 | */ 14 | object ServiceUtils { 15 | 16 | /** トランザクション処理を実行します。 */ 17 | fun tx(txm: PlatformTransactionManager, callable: () -> T): T = 18 | TransactionTemplate(txm).execute { 19 | try { 20 | callable() 21 | } catch (e: RuntimeException) { 22 | throw e 23 | } catch (e: Exception) { 24 | throw InvocationException("error.Exception", e); 25 | } 26 | }!! 27 | 28 | /** 匿名以外の利用者情報を返します。 */ 29 | fun actorUser(dh: DomainHelper): Actor { 30 | val actor = dh.actor() 31 | if (actor.roleType.isAnonymous) { 32 | throw ValidationException(ErrorKeys.Authentication) 33 | } 34 | return actor 35 | } 36 | 37 | } 38 | -------------------------------------------------------------------------------- /src/test/kotlin/sample/model/master/SelfFiAccountTest.kt: -------------------------------------------------------------------------------- 1 | package sample.model.master 2 | 3 | import org.hamcrest.Matchers.* 4 | import org.junit.Assert.assertThat 5 | import org.junit.Assert.fail 6 | import org.junit.Test 7 | import sample.EntityTestSupport 8 | import sample.ErrorKeys 9 | import sample.ValidationException 10 | 11 | class SelfFiAccountTest : EntityTestSupport() { 12 | 13 | override fun setupPreset() { 14 | targetEntities(SelfFiAccount::class.java) 15 | } 16 | 17 | override fun before() { 18 | tx { fixtures().selfFiAcc("sample", "JPY").save(rep()) } 19 | } 20 | 21 | @Test 22 | fun 自社金融機関口座を取得する() { 23 | tx { 24 | assertThat(SelfFiAccount.load(rep(), "sample", "JPY"), allOf( 25 | hasProperty("category", `is`("sample")), 26 | hasProperty("currency", `is`("JPY")), 27 | hasProperty("fiCode", `is`("sample-JPY")), 28 | hasProperty("fiAccountId", `is`("xxxxxx")))) 29 | try { 30 | SelfFiAccount.load(rep(), "sample", "USD") 31 | fail() 32 | } catch (e: ValidationException) { 33 | assertThat(e.message, `is`(ErrorKeys.EntityNotFound)) 34 | } 35 | } 36 | } 37 | 38 | } 39 | -------------------------------------------------------------------------------- /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[] 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/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 | /** 12 | * 文字列ID(必須)を表現する制約注釈。 13 | */ 14 | @Documented 15 | @Constraint(validatedBy = {}) 16 | @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER }) 17 | @Retention(RUNTIME) 18 | @ReportAsSingleViolation 19 | @NotBlank 20 | @Size 21 | @Pattern(regexp = "") 22 | public @interface IdStr { 23 | String message() default "{error.domain.idStr}"; 24 | 25 | Class[] groups() default {}; 26 | 27 | Class[] payload() default {}; 28 | 29 | @OverridesAttribute(constraint = Size.class, name = "max") 30 | int max() default 32; 31 | 32 | @OverridesAttribute(constraint = Pattern.class, name = "regexp") 33 | String regexp() default "^\\p{ASCII}*$"; 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 | IdStr[] value(); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /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 | /** 12 | * 概要(必須)を表現する制約注釈。 13 | */ 14 | @Documented 15 | @Constraint(validatedBy = {}) 16 | @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER }) 17 | @Retention(RUNTIME) 18 | @ReportAsSingleViolation 19 | @NotBlank 20 | @Size 21 | @Pattern(regexp = "") 22 | public @interface Outline { 23 | String message() default "{error.domain.outline}"; 24 | 25 | Class[] groups() default {}; 26 | 27 | Class[] payload() default {}; 28 | 29 | @OverridesAttribute(constraint = Size.class, name = "max") 30 | int max() default 200; 31 | 32 | @OverridesAttribute(constraint = Pattern.class, name = "regexp") 33 | String regexp() default JavaRegex.rWord; 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 | Outline[] value(); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/main/kotlin/sample/context/orm/OrmInterceptor.kt: -------------------------------------------------------------------------------- 1 | package sample.context.orm 2 | 3 | import org.springframework.stereotype.Component 4 | import sample.context.Timestamper 5 | import sample.context.actor.ActorSession 6 | 7 | /** 8 | * Entityの永続化タイミングでAOP処理を差し込む Interceptor。 9 | */ 10 | @Component 11 | class OrmInterceptor( 12 | private val session: ActorSession, 13 | private val time: Timestamper 14 | ) { 15 | 16 | /** 登録時の事前差し込み処理を行います。 */ 17 | fun touchForCreate(entity: Any) { 18 | if (entity is OrmActiveMetaRecord<*>) { 19 | val staff = session.actor() 20 | val now = time.date() 21 | entity.createId = staff.id 22 | entity.createDate = now 23 | entity.updateId = staff.id 24 | entity.updateDate = now 25 | } 26 | } 27 | 28 | /** 変更時の事前差し込み処理を行います。 */ 29 | fun touchForUpdate(entity: Any): Boolean { 30 | if (entity is OrmActiveMetaRecord<*>) { 31 | val staff = session.actor() 32 | val now = time.date() 33 | if (entity.createDate == null) { 34 | entity.createId = staff.id 35 | entity.createDate = now 36 | } 37 | entity.updateId = staff.id 38 | entity.updateDate = now 39 | } 40 | return false 41 | } 42 | 43 | } -------------------------------------------------------------------------------- /src/main/kotlin/sample/context/orm/Pagination.kt: -------------------------------------------------------------------------------- 1 | package sample.context.orm 2 | 3 | import sample.context.Dto 4 | import sample.util.Calculator 5 | import java.math.RoundingMode 6 | 7 | /** 8 | * ページング情報を表現します。 9 | */ 10 | data class Pagination( 11 | val page: Int = 1, 12 | val size: Int = DefaultSize, 13 | val total: Long? = null, 14 | var ignoreTotal: Boolean = false, 15 | val sort: Sort = Sort()) : Dto { 16 | 17 | /** 最大ページ数を返します。total設定時のみ適切な値が返されます。 */ 18 | val maxPage: Int = if (total == null) 0 else Calculator.of(total).scale(0, RoundingMode.UP).divideBy(size).intValue() 19 | /** 開始件数を返します。 */ 20 | val firstResult: Int = (page - 1) * size 21 | 22 | /** カウント算出を無効化します。 */ 23 | fun ignoreTotal(): Pagination { 24 | this.ignoreTotal = true 25 | return this 26 | } 27 | 28 | /** ソート指定が未指定の時は与えたソート条件で上書きします。 */ 29 | fun sortIfEmpty(vararg orders: SortOrder): Pagination { 30 | sort.ifEmpty(*orders) 31 | return this 32 | } 33 | 34 | companion object { 35 | private const val serialVersionUID: Long = 1 36 | const val DefaultSize: Int = 100 37 | 38 | fun of(req: Pagination, total: Long): Pagination = 39 | Pagination(page = req.page, size = req.size, total = total, ignoreTotal = false, sort = req.sort) 40 | 41 | } 42 | } -------------------------------------------------------------------------------- /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 | /** 12 | * 各種カテゴリ/区分(必須)を表現する制約注釈。 13 | */ 14 | @Documented 15 | @Constraint(validatedBy = {}) 16 | @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER }) 17 | @Retention(RUNTIME) 18 | @ReportAsSingleViolation 19 | @NotBlank 20 | @Size 21 | @Pattern(regexp = "") 22 | public @interface Category { 23 | String message() default "{error.domain.category}"; 24 | 25 | Class[] groups() default {}; 26 | 27 | Class[] 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 JavaRegex.rAscii; 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 | Category[] value(); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /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[] 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/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[] 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/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 | /** 12 | * 名称(必須)を表現する制約注釈。 13 | * low: 実際は姓名(ミドルネーム)の考慮やモノ系の名称などを意識する必要があります。 14 | */ 15 | @Documented 16 | @Constraint(validatedBy = {}) 17 | @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER }) 18 | @Retention(RUNTIME) 19 | @ReportAsSingleViolation 20 | @NotBlank 21 | @Size 22 | @Pattern(regexp = "") 23 | public @interface Name { 24 | String message() default "{error.domain.name}"; 25 | 26 | Class[] groups() default {}; 27 | 28 | Class[] payload() default {}; 29 | 30 | @OverridesAttribute(constraint = Size.class, name = "max") 31 | int max() default 30; 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 | Name[] value(); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /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[] 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/kotlin/sample/model/master/SelfFiAccount.kt: -------------------------------------------------------------------------------- 1 | package sample.model.master 2 | 3 | import sample.context.orm.OrmActiveRecord 4 | import sample.context.orm.OrmRepository 5 | import sample.model.constraints.Category 6 | import sample.model.constraints.Currency 7 | import sample.model.constraints.IdStr 8 | import javax.persistence.Entity 9 | import javax.persistence.GeneratedValue 10 | import javax.persistence.Id 11 | 12 | 13 | /** 14 | * サービス事業者の決済金融機関を表現します。 15 | * low: サンプルなので支店や名称、名義といったなど本来必須な情報をかなり省略しています。(通常は全銀仕様を踏襲します) 16 | */ 17 | @Entity 18 | data class SelfFiAccount( 19 | /** ID */ 20 | @Id 21 | @GeneratedValue 22 | var id: Long? = null, 23 | /** 利用用途カテゴリ */ 24 | @field:Category 25 | val category: String, 26 | /** 通貨 */ 27 | @field:Currency 28 | val currency: String, 29 | /** 金融機関コード */ 30 | @field:IdStr 31 | val fiCode: String, 32 | /** 金融機関口座ID */ 33 | @field:IdStr 34 | val fiAccountId: String 35 | ) : OrmActiveRecord() { 36 | 37 | companion object { 38 | private const val serialVersionUID = 1L 39 | 40 | fun load(rep: OrmRepository, category: String, currency: String): SelfFiAccount = 41 | rep.tmpl().load("FROM SelfFiAccount a WHERE a.category=?1 AND a.currency=?2", category, currency) 42 | } 43 | 44 | } 45 | -------------------------------------------------------------------------------- /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 | /** 12 | * パスワード(必須)を表現する制約注釈。 13 | * low: 実際の定義はプロジェクトに大きく依存するのでサンプルでは適当にしています。 14 | */ 15 | @Documented 16 | @Constraint(validatedBy = {}) 17 | @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER }) 18 | @Retention(RUNTIME) 19 | @ReportAsSingleViolation 20 | @NotBlank 21 | @Size 22 | @Pattern(regexp = "") 23 | public @interface Password { 24 | String message() default "{error.domain.password}"; 25 | 26 | Class[] groups() default {}; 27 | 28 | Class[] 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 JavaRegex.rAscii; 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 | Password[] value(); 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[] 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/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 | /** 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 | @NotBlank 22 | @Size 23 | @Pattern(regexp = "") 24 | public @interface Email { 25 | String message() default "{error.domain.email}"; 26 | 27 | Class[] groups() default {}; 28 | 29 | Class[] payload() default {}; 30 | 31 | @OverridesAttribute(constraint = Size.class, name = "max") 32 | int max() default 256; 33 | 34 | @OverridesAttribute(constraint = Pattern.class, name = "regexp") 35 | String regexp() default ".*"; 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 | Email[] value(); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/main/kotlin/sample/context/actor/Actor.kt: -------------------------------------------------------------------------------- 1 | package sample.context.actor 2 | 3 | import java.util.* 4 | 5 | /** 6 | * ユースケースにおける利用者を表現します。 7 | */ 8 | data class Actor( 9 | /** 利用者ID */ 10 | val id: String, 11 | /** 利用者が持つ{@link ActorRoleType} */ 12 | val roleType: ActorRoleType, 13 | /** 利用者名称 */ 14 | val name: String = id, 15 | /** 利用者が使用する{@link Locale} */ 16 | val locale: Locale = Locale.getDefault(), 17 | /** 利用者の接続チャネル名称 */ 18 | var channel: String? = null, 19 | /** 利用者を特定する外部情報。(IPなど) */ 20 | var source: String? = null) { 21 | companion object { 22 | /** 匿名利用者定数 */ 23 | val Anonymous: Actor = Actor(id = "unknown", roleType = ActorRoleType.Anonymous) 24 | /** システム利用者定数 */ 25 | val System: Actor = Actor(id = "system", roleType = ActorRoleType.System) 26 | } 27 | } 28 | 29 | /** 30 | * 利用者の役割を表現します。 31 | */ 32 | enum class ActorRoleType { 33 | /** 匿名利用者(ID等の特定情報を持たない利用者) */ 34 | Anonymous, 35 | /** 利用者(主にBtoCの顧客, BtoB提供先社員) */ 36 | User, 37 | /** 内部利用者(主にBtoCの社員, BtoB提供元社員) */ 38 | Internal, 39 | /** システム管理者(ITシステム担当社員またはシステム管理会社の社員) */ 40 | Administrator, 41 | /** システム(システム上の自動処理) */ 42 | System; 43 | 44 | val isAnonymous: Boolean 45 | get() = this == Anonymous 46 | val isSystem: Boolean 47 | get() = this == System 48 | val notSystem: Boolean = !isSystem 49 | } 50 | -------------------------------------------------------------------------------- /src/main/kotlin/sample/util/Regex.kt: -------------------------------------------------------------------------------- 1 | package sample.util 2 | 3 | /** 4 | * 正規表現定数インターフェース。 5 | *

Checker.matchと組み合わせて利用してください。 6 | */ 7 | object Regex { 8 | /** Ascii */ 9 | const val rAscii = "^\\p{ASCII}*$" 10 | /** 英字 */ 11 | const val rAlpha = "^[a-zA-Z]*$" 12 | /** 英字大文字 */ 13 | const val rAlphaUpper = "^[A-Z]*$" 14 | /** 英字小文字 */ 15 | const val rAlphaLower = "^[a-z]*$" 16 | /** 英数 */ 17 | const val rAlnum = "^[0-9a-zA-Z]*$" 18 | /** シンボル */ 19 | const val rSymbol = "^\\p{Punct}*$" 20 | /** 英数記号 */ 21 | const val rAlnumSymbol = "^[0-9a-zA-Z\\p{Punct}]*$" 22 | /** 数字 */ 23 | const val rNumber = "^[-]?[0-9]*$" 24 | /** 整数 */ 25 | const val rNumberNatural = "^[0-9]*$" 26 | /** 倍精度浮動小数点 */ 27 | const val rDecimal = "^[-]?(\\d+)(\\.\\d+)?$" 28 | // see UnicodeBlock 29 | /** ひらがな */ 30 | const val rHiragana = "^\\p{InHiragana}*$" 31 | /** カタカナ */ 32 | const val rKatakana = "^\\p{InKatakana}*$" 33 | /** 半角カタカナ */ 34 | const val rHankata = "^[。-゚]*$" 35 | /** 半角文字列 */ 36 | const val rHankaku = "^[\\p{InBasicLatin}。-゚]*$" // ラテン文字 + 半角カタカナ 37 | /** 全角文字列 */ 38 | const val rZenkaku = "^[^\\p{InBasicLatin}。-゚]*$" // 全角の定義を半角以外で割り切り 39 | /** 漢字 */ 40 | const val rKanji = "^[\\p{InCJKUnifiedIdeographs}々\\p{InCJKCompatibilityIdeographs}]*$" 41 | /** 文字 */ 42 | const val rWord = "^(?s).*$" 43 | /** コード */ 44 | const val rCode = "^[0-9a-zA-Z_-]*$" // 英数 + アンダーバー + ハイフン 45 | } -------------------------------------------------------------------------------- /src/test/kotlin/sample/model/asset/AssetTest.kt: -------------------------------------------------------------------------------- 1 | package sample.model.asset 2 | 3 | import org.hamcrest.Matchers.`is` 4 | import org.junit.Assert.assertThat 5 | import org.junit.Test 6 | import java.time.LocalDate 7 | import sample.model.account.Account 8 | import sample.EntityTestSupport 9 | import java.math.BigDecimal 10 | 11 | //low: 簡易な検証が中心 12 | class AssetTest : EntityTestSupport() { 13 | 14 | override fun setupPreset() { 15 | targetEntities(Account::class.java, CashBalance::class.java, Cashflow::class.java, CashInOut::class.java) 16 | } 17 | 18 | @Test 19 | fun 振込出金可能か判定する() { 20 | // 残高 + 未実現キャッシュフロー - 出金依頼拘束額 = 出金可能額 21 | // 10000 + (1000 - 2000) - 8000 = 1000 22 | tx { 23 | fixtures().acc("test").save(rep()) 24 | fixtures().cb("test", LocalDate.of(2014, 11, 18), "JPY", "10000").save(rep()) 25 | fixtures().cf("test", "1000", LocalDate.of(2014, 11, 18), LocalDate.of(2014, 11, 20)).save(rep()) 26 | fixtures().cf("test", "-2000", LocalDate.of(2014, 11, 19), LocalDate.of(2014, 11, 21)).save(rep()) 27 | fixtures().cio("test", "8000", true).save(rep()) 28 | 29 | assertThat( 30 | Asset("test").canWithdraw(rep(), "JPY", BigDecimal("1000"), LocalDate.of(2014, 11, 21)), 31 | `is`(true)) 32 | assertThat( 33 | Asset("test").canWithdraw(rep(), "JPY", BigDecimal("1001"), LocalDate.of(2014, 11, 21)), 34 | `is`(false)) 35 | } 36 | } 37 | 38 | } 39 | -------------------------------------------------------------------------------- /src/main/kotlin/sample/model/account/FiAccount.kt: -------------------------------------------------------------------------------- 1 | package sample.model.account 2 | 3 | import sample.context.orm.OrmActiveRecord 4 | import sample.context.orm.OrmRepository 5 | import sample.model.constraints.Category 6 | import sample.model.constraints.Currency 7 | import sample.model.constraints.IdStr 8 | import javax.persistence.Entity 9 | import javax.persistence.GeneratedValue 10 | import javax.persistence.Id 11 | 12 | /** 13 | * 口座に紐づく金融機関口座を表現します。 14 | * 15 | * 口座を相手方とする入出金で利用します。 16 | * low: サンプルなので支店や名称、名義といった本来必須な情報をかなり省略しています。(通常は全銀仕様を踏襲します) 17 | */ 18 | @Entity 19 | data class FiAccount( 20 | /** ID */ 21 | @Id 22 | @GeneratedValue 23 | var id: Long? = null, 24 | /** 口座ID */ 25 | @field:IdStr 26 | val accountId: String, 27 | /** 利用用途カテゴリ */ 28 | @field:Category 29 | val category: String, 30 | /** 通貨 */ 31 | @field:Currency 32 | val currency: String, 33 | /** 金融機関コード */ 34 | @field:IdStr 35 | val fiCode: String, 36 | /** 金融機関口座ID */ 37 | @field:IdStr 38 | val fiAccountId: String 39 | ) : OrmActiveRecord() { 40 | companion object { 41 | private const val serialVersionUID = 1L 42 | 43 | fun load(rep: OrmRepository, accountId: String, category: String, currency: String): FiAccount = 44 | rep.tmpl().load("FROM FiAccount a WHERE a.accountId=?1 AND a.category=?2 AND a.currency=?3", 45 | accountId, category, currency) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/main/kotlin/sample/context/AppSettingHandler.kt: -------------------------------------------------------------------------------- 1 | package sample.context 2 | 3 | import org.springframework.cache.annotation.CacheEvict 4 | import org.springframework.cache.annotation.Cacheable 5 | import org.springframework.stereotype.Component 6 | import org.springframework.transaction.annotation.Transactional 7 | import sample.context.orm.SystemRepository 8 | import java.util.* 9 | 10 | /** 11 | * アプリケーション設定情報に対するアクセス手段を提供します。 12 | */ 13 | @Component 14 | class AppSettingHandler( 15 | private val rep: SystemRepository? = null, 16 | private val mockMap: Optional> = Optional.empty() 17 | ) { 18 | /** アプリケーション設定情報を取得します。 */ 19 | @Cacheable(cacheNames = ["AppSettingHandler.appSetting"], key = "#id") 20 | @Transactional(value = SystemRepository.BeanNameTx) 21 | fun setting(id: String): AppSetting { 22 | if (mockMap.isPresent) { 23 | return mockSetting(id) 24 | } 25 | val setting = AppSetting.load(rep!!, id) 26 | setting.hashCode() // for loading 27 | return setting 28 | } 29 | 30 | private fun mockSetting(id: String): AppSetting = 31 | AppSetting(id, "category", "テスト用モック情報", mockMap.get()[id].orEmpty()) 32 | 33 | /** アプリケーション設定情報を変更します。 */ 34 | @CacheEvict(cacheNames = ["AppSettingHandler.appSetting"], key = "#id") 35 | @Transactional(value = SystemRepository.BeanNameTx) 36 | fun update(id: String, value: String): AppSetting = 37 | if (mockMap.isPresent) mockSetting(id) else AppSetting.load(rep!!, id).update(rep, value) 38 | 39 | } -------------------------------------------------------------------------------- /src/main/kotlin/sample/controller/admin/SystemAdminController.kt: -------------------------------------------------------------------------------- 1 | package sample.controller.admin 2 | 3 | import org.springframework.http.ResponseEntity 4 | import org.springframework.web.bind.annotation.* 5 | import sample.context.AppSetting 6 | import sample.context.FindAppSetting 7 | import sample.context.audit.AuditActor 8 | import sample.context.audit.AuditEvent 9 | import sample.context.audit.FindAuditActor 10 | import sample.context.audit.FindAuditEvent 11 | import sample.context.orm.PagingList 12 | import sample.usecase.admin.SystemAdminService 13 | import javax.validation.Valid 14 | 15 | /** 16 | * システムに関わる社内のUI要求を処理します。 17 | */ 18 | @RestController 19 | @RequestMapping("/api/admin/system") 20 | class SystemAdminController(val service: SystemAdminService) { 21 | 22 | /** 利用者監査ログを検索します。 */ 23 | @GetMapping("/audit/actor/") 24 | fun findAuditActor(@Valid p: FindAuditActor): PagingList = 25 | service.findAuditActor(p) 26 | 27 | /** イベント監査ログを検索します。 */ 28 | @GetMapping("/audit/event/") 29 | fun findAuditEvent(@Valid p: FindAuditEvent): PagingList = 30 | service.findAuditEvent(p) 31 | 32 | /** アプリケーション設定一覧を検索します。 */ 33 | @GetMapping("/setting/") 34 | fun findAppSetting(@Valid p: FindAppSetting): List = 35 | service.findAppSetting(p) 36 | 37 | /** アプリケーション設定情報を変更します。 */ 38 | @PostMapping("/setting/{id}") 39 | fun changeAppSetting(@PathVariable id: String, value: String): ResponseEntity = 40 | ResponseEntity.ok().apply { service.changeAppSetting(id, value) }.build() 41 | 42 | } -------------------------------------------------------------------------------- /src/main/kotlin/sample/controller/system/JobController.kt: -------------------------------------------------------------------------------- 1 | package sample.controller.system 2 | 3 | import org.springframework.http.ResponseEntity 4 | import org.springframework.web.bind.annotation.PostMapping 5 | import org.springframework.web.bind.annotation.RequestMapping 6 | import org.springframework.web.bind.annotation.RestController 7 | import sample.usecase.admin.AssetAdminService 8 | import sample.usecase.admin.SystemAdminService 9 | 10 | /** 11 | * システムジョブのUI要求を処理します。 12 | *

/api/system 以降の URL はジョブスケジューラから実行される事を想定しているため、 L/B 等で外部からアクセス不可にしておく必要があります。 13 | * ( よりベターなアプローチは該当処理のみを持ったバッチプロセスとして切り出すか個別認証をかける ) 14 | * low: 通常はバッチプロセス(または社内プロセスに内包)を別途作成して、ジョブスケジューラから実行される方式になります。 15 | * ジョブの負荷がオンライン側へ影響を与えないよう事前段階の設計が重要になります。 16 | * low: 社内/バッチプロセス切り出す場合はVM分散時の情報/排他同期を意識する必要があります。(DB同期/メッセージング同期/分散製品の利用 等) 17 | */ 18 | @RestController 19 | @RequestMapping("/api/system/job") 20 | class JobController( 21 | val asset: AssetAdminService, 22 | val system: SystemAdminService 23 | ) { 24 | /** 営業日を進めます。 */ 25 | @PostMapping("/daily/processDay") 26 | fun processDay(): ResponseEntity = 27 | ResponseEntity.ok().apply { system.processDay() }.build() 28 | 29 | /** 振込出金依頼を締めます。 */ 30 | @PostMapping("/daily/closingCashOut") 31 | fun closingCashOut(): ResponseEntity = 32 | ResponseEntity.ok().apply { asset.closingCashOut() }.build() 33 | 34 | /** キャッシュフローを実現します。 */ 35 | @PostMapping("/daily/realizeCashflow") 36 | fun realizeCashflow(): ResponseEntity = 37 | ResponseEntity.ok().apply { asset.realizeCashflow() }.build() 38 | 39 | } -------------------------------------------------------------------------------- /src/main/kotlin/sample/context/orm/Sort.kt: -------------------------------------------------------------------------------- 1 | package sample.context.orm 2 | 3 | import sample.context.Dto 4 | import java.io.Serializable 5 | 6 | /** 7 | * ソート情報を表現します。 8 | * 複数件のソート情報(SortOrder)を内包します。 9 | */ 10 | data class Sort( 11 | /** ソート条件 */ 12 | val orders: MutableList = mutableListOf() 13 | ) : Dto { 14 | 15 | /** ソート条件を追加します。 */ 16 | fun add(order: SortOrder): Sort { 17 | orders.add(order) 18 | return this 19 | } 20 | 21 | /** ソート条件(昇順)を追加します。 */ 22 | fun asc(property: String): Sort = add(SortOrder.asc(property)) 23 | /** ソート条件(降順)を追加します。 */ 24 | fun desc(property: String): Sort = add(SortOrder.desc(property)) 25 | 26 | /** ソート条件が未指定だった際にソート順が上書きされます。 */ 27 | fun ifEmpty(vararg items: SortOrder): Sort { 28 | if (orders.isEmpty() && items.isNotEmpty()) { 29 | orders.addAll(items) 30 | } 31 | return this 32 | } 33 | 34 | companion object { 35 | private const val serialVersionUID: Long = 1 36 | 37 | /** 昇順でソート情報を返します。 */ 38 | fun ascBy(property: String): Sort = Sort().asc(property) 39 | /** 降順でソート情報を返します。 */ 40 | fun descBy(property: String): Sort = Sort().desc(property) 41 | } 42 | } 43 | 44 | data class SortOrder( 45 | val property: String, 46 | val ascending: Boolean 47 | ) : Serializable { 48 | companion object { 49 | private const val serialVersionUID: Long = 1 50 | 51 | fun asc(property: String) = SortOrder(property, true) 52 | fun desc(property: String) = SortOrder(property, false) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/main/java/sample/model/constraints/OutlineEmpty.java: -------------------------------------------------------------------------------- 1 | package sample.model.constraints; 2 | 3 | import javax.validation.Constraint; 4 | import javax.validation.OverridesAttribute; 5 | import javax.validation.Payload; 6 | import javax.validation.ReportAsSingleViolation; 7 | import javax.validation.constraints.Pattern; 8 | import javax.validation.constraints.Size; 9 | import java.lang.annotation.Documented; 10 | import java.lang.annotation.Retention; 11 | import java.lang.annotation.Target; 12 | 13 | import static java.lang.annotation.ElementType.*; 14 | import static java.lang.annotation.RetentionPolicy.RUNTIME; 15 | 16 | /** 17 | * 概要を表現する制約注釈。 18 | */ 19 | @Documented 20 | @Constraint(validatedBy = {}) 21 | @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER }) 22 | @Retention(RUNTIME) 23 | @ReportAsSingleViolation 24 | @Size 25 | @Pattern(regexp = "") 26 | public @interface OutlineEmpty { 27 | String message() default "{error.domain.outline}"; 28 | 29 | Class[] groups() default {}; 30 | 31 | Class[] 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 | OutlineEmpty[] value(); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/main/java/sample/model/constraints/DescriptionEmpty.java: -------------------------------------------------------------------------------- 1 | package sample.model.constraints; 2 | 3 | import javax.validation.Constraint; 4 | import javax.validation.OverridesAttribute; 5 | import javax.validation.Payload; 6 | import javax.validation.ReportAsSingleViolation; 7 | import javax.validation.constraints.Pattern; 8 | import javax.validation.constraints.Size; 9 | import java.lang.annotation.Documented; 10 | import java.lang.annotation.Retention; 11 | import java.lang.annotation.Target; 12 | 13 | import static java.lang.annotation.ElementType.*; 14 | import static java.lang.annotation.RetentionPolicy.RUNTIME; 15 | 16 | /** 17 | * 備考を表現する制約注釈。 18 | */ 19 | @Documented 20 | @Constraint(validatedBy = {}) 21 | @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER }) 22 | @Retention(RUNTIME) 23 | @ReportAsSingleViolation 24 | @Size 25 | @Pattern(regexp = "") 26 | public @interface DescriptionEmpty { 27 | String message() default "{error.domain.description}"; 28 | 29 | Class[] groups() default {}; 30 | 31 | Class[] 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 | DescriptionEmpty[] value(); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/main/java/sample/model/constraints/CategoryEmpty.java: -------------------------------------------------------------------------------- 1 | package sample.model.constraints; 2 | 3 | import javax.validation.Constraint; 4 | import javax.validation.OverridesAttribute; 5 | import javax.validation.Payload; 6 | import javax.validation.ReportAsSingleViolation; 7 | import javax.validation.constraints.Pattern; 8 | import javax.validation.constraints.Size; 9 | import java.lang.annotation.Documented; 10 | import java.lang.annotation.Retention; 11 | import java.lang.annotation.Target; 12 | 13 | import static java.lang.annotation.ElementType.*; 14 | import static java.lang.annotation.RetentionPolicy.RUNTIME; 15 | 16 | /** 17 | * 各種カテゴリ/区分(必須)を表現する制約注釈。 18 | */ 19 | @Documented 20 | @Constraint(validatedBy = {}) 21 | @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER }) 22 | @Retention(RUNTIME) 23 | @ReportAsSingleViolation 24 | @Size 25 | @Pattern(regexp = "") 26 | public @interface CategoryEmpty { 27 | String message() default "{error.domain.category}"; 28 | 29 | Class[] groups() default {}; 30 | 31 | Class[] 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 | CategoryEmpty[] value(); 47 | } 48 | 49 | } 50 | -------------------------------------------------------------------------------- /src/test/kotlin/sample/model/master/HolidayTest.kt: -------------------------------------------------------------------------------- 1 | package sample.model.master 2 | 3 | import org.hamcrest.Matchers.`is` 4 | import org.hamcrest.Matchers.hasSize 5 | import org.junit.Assert.assertThat 6 | import org.junit.Assert.assertTrue 7 | import org.junit.Test 8 | import sample.EntityTestSupport 9 | import sample.util.DateUtils 10 | import java.time.LocalDate 11 | 12 | 13 | class HolidayTest : EntityTestSupport() { 14 | 15 | override fun setupPreset() { 16 | targetEntities(Holiday::class.java) 17 | } 18 | 19 | override fun before() { 20 | tx { 21 | listOf("2015-09-21", "2015-09-22", "2015-09-23", "2016-09-21") 22 | .map { fixtures().holiday(it) } 23 | .onEach { it.save(rep()) } 24 | } 25 | } 26 | 27 | @Test 28 | fun 休日を取得する() { 29 | tx { 30 | val day = Holiday.get(rep(), LocalDate.of(2015, 9, 22)) 31 | assertTrue(day.isPresent) 32 | assertThat(day.get().day, `is`(LocalDate.of(2015, 9, 22))) 33 | } 34 | } 35 | 36 | @Test 37 | fun 休日を検索する() { 38 | tx { 39 | assertThat(Holiday.find(rep(), 2015), hasSize(3)) 40 | assertThat(Holiday.find(rep(), 2016), hasSize(1)) 41 | } 42 | } 43 | 44 | @Test 45 | fun 休日を登録する() { 46 | val items = listOf("2016-09-21", "2016-09-22", "2016-09-23") 47 | .map { RegHolidayItem(DateUtils.day(it), "休日") } 48 | tx { 49 | Holiday.register(rep(), RegHoliday(year = 2016, list = items)) 50 | assertThat(Holiday.find(rep(), 2016), hasSize(3)) 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/main/kotlin/sample/controller/AccountController.kt: -------------------------------------------------------------------------------- 1 | package sample.controller 2 | 3 | import org.springframework.http.HttpStatus 4 | import org.springframework.http.ResponseEntity 5 | import org.springframework.web.bind.annotation.GetMapping 6 | import org.springframework.web.bind.annotation.RequestMapping 7 | import org.springframework.web.bind.annotation.RestController 8 | import sample.ErrorKeys 9 | import sample.ValidationException 10 | import sample.context.security.SecurityActorFinder 11 | import sample.context.security.SecurityProperties 12 | import sample.usecase.AccountService 13 | 14 | 15 | /** 16 | * 口座に関わる顧客のUI要求を処理します。 17 | */ 18 | @RestController 19 | @RequestMapping("/api/account") 20 | class AccountController( 21 | val securityProps: SecurityProperties 22 | ) { 23 | 24 | /** ログイン状態を確認します。 */ 25 | @GetMapping("/loginStatus") 26 | fun loginStatus(): ResponseEntity = ResponseEntity.ok(true) 27 | 28 | /** 口座ログイン情報を取得します。 */ 29 | @GetMapping("/loginAccount") 30 | fun loadLoginAccount(): LoginAccount = 31 | if (securityProps.auth.enabled) { 32 | val actorDetails = SecurityActorFinder.actorDetails() 33 | .orElseThrow { ValidationException(ErrorKeys.Authentication) } 34 | val actor = actorDetails.actor 35 | LoginAccount(actor.id, actor.name, actorDetails.authorityIds) 36 | } else { // for dummy login 37 | LoginAccount("sample", "sample", listOf()) 38 | } 39 | } 40 | 41 | /** クライアント利用用途に絞ったパラメタ */ 42 | data class LoginAccount( 43 | val id: String, 44 | val name: String, 45 | val authorities: List 46 | ) 47 | -------------------------------------------------------------------------------- /src/main/java/sample/model/constraints/Description.java: -------------------------------------------------------------------------------- 1 | package sample.model.constraints; 2 | 3 | import javax.validation.Constraint; 4 | import javax.validation.OverridesAttribute; 5 | import javax.validation.Payload; 6 | import javax.validation.ReportAsSingleViolation; 7 | import javax.validation.constraints.NotBlank; 8 | import javax.validation.constraints.Pattern; 9 | import javax.validation.constraints.Size; 10 | import java.lang.annotation.Documented; 11 | import java.lang.annotation.Retention; 12 | import java.lang.annotation.Target; 13 | 14 | import static java.lang.annotation.ElementType.*; 15 | import static java.lang.annotation.RetentionPolicy.RUNTIME; 16 | 17 | /** 18 | * 備考(必須)を表現する制約注釈。 19 | */ 20 | @Documented 21 | @Constraint(validatedBy = {}) 22 | @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER }) 23 | @Retention(RUNTIME) 24 | @ReportAsSingleViolation 25 | @NotBlank 26 | @Size 27 | @Pattern(regexp = "") 28 | public @interface Description { 29 | String message() default "{error.domain.description}"; 30 | 31 | Class[] groups() default {}; 32 | 33 | Class[] payload() default {}; 34 | 35 | @OverridesAttribute(constraint = Size.class, name = "max") 36 | int max() default 400; 37 | 38 | @OverridesAttribute(constraint = Pattern.class, name = "regexp") 39 | String regexp() default JavaRegex.rWord; 40 | 41 | @OverridesAttribute(constraint = Pattern.class, name = "flags") 42 | Pattern.Flag[] flags() default {}; 43 | 44 | @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER }) 45 | @Retention(RUNTIME) 46 | @Documented 47 | public @interface List { 48 | Description[] value(); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/main/kotlin/sample/usecase/admin/MasterAdminService.kt: -------------------------------------------------------------------------------- 1 | package sample.usecase.admin 2 | 3 | import org.springframework.beans.factory.annotation.Qualifier 4 | import org.springframework.cache.annotation.Cacheable 5 | import org.springframework.stereotype.Service 6 | import org.springframework.transaction.PlatformTransactionManager 7 | import org.springframework.transaction.annotation.Transactional 8 | import sample.context.DomainHelper 9 | import sample.context.audit.AuditHandler 10 | import sample.context.orm.DefaultRepository 11 | import sample.model.master.Holiday 12 | import sample.model.master.RegHoliday 13 | import sample.model.master.Staff 14 | import sample.model.master.StaffAuthority 15 | import sample.usecase.ServiceUtils 16 | import java.util.* 17 | 18 | 19 | /** 20 | * サービスマスタドメインに対する社内ユースケース処理。 21 | */ 22 | @Service 23 | class MasterAdminService( 24 | private val rep: DefaultRepository, 25 | @Qualifier(DefaultRepository.BeanNameTx) 26 | private val txm: PlatformTransactionManager, 27 | private val audit: AuditHandler 28 | ) { 29 | 30 | /** 社員を取得します。 */ 31 | @Transactional(DefaultRepository.BeanNameTx) 32 | @Cacheable("MasterAdminService.getStaff") 33 | fun getStaff(id: String): Optional = Staff.get(rep, id) 34 | 35 | /** 社員権限を取得します。 */ 36 | @Transactional(DefaultRepository.BeanNameTx) 37 | @Cacheable("MasterAdminService.findStaffAuthority") 38 | fun findStaffAuthority(staffId: String): List = StaffAuthority.find(rep, staffId) 39 | 40 | fun registerHoliday(p: RegHoliday) = 41 | audit.audit("休日情報を登録する") { 42 | ServiceUtils.tx(txm) { 43 | Holiday.register(rep, p) 44 | } 45 | } 46 | 47 | } -------------------------------------------------------------------------------- /src/main/resources/ehcache.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 1800 17 | 18 | 19 | 1000 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 30 29 | 30 | 1000 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 100 45 | 46 | 47 | 10000 48 | 49 | 50 | -------------------------------------------------------------------------------- /src/main/kotlin/sample/usecase/admin/SystemAdminService.kt: -------------------------------------------------------------------------------- 1 | package sample.usecase.admin 2 | 3 | import org.springframework.beans.factory.annotation.Qualifier 4 | import org.springframework.stereotype.Service 5 | import org.springframework.transaction.PlatformTransactionManager 6 | import org.springframework.transaction.annotation.Transactional 7 | import sample.context.AppSetting 8 | import sample.context.DomainHelper 9 | import sample.context.FindAppSetting 10 | import sample.context.audit.* 11 | import sample.context.orm.SystemRepository 12 | import sample.context.orm.PagingList 13 | import sample.context.orm.DefaultRepository 14 | import sample.model.BusinessDayHandler 15 | 16 | /** 17 | * システムドメインに対する社内ユースケース処理。 18 | */ 19 | @Service 20 | class SystemAdminService( 21 | private val dh: DomainHelper, 22 | private val rep: SystemRepository, 23 | private val audit: AuditHandler, 24 | private val businessDay: BusinessDayHandler 25 | ) { 26 | 27 | /** 利用者監査ログを検索します。 */ 28 | @Transactional(SystemRepository.BeanNameTx) 29 | fun findAuditActor(p: FindAuditActor): PagingList = AuditActor.find(rep, p) 30 | 31 | /** イベント監査ログを検索します。 */ 32 | @Transactional(SystemRepository.BeanNameTx) 33 | fun findAuditEvent(p: FindAuditEvent): PagingList = AuditEvent.find(rep, p) 34 | 35 | /** アプリケーション設定一覧を検索します。 */ 36 | @Transactional(SystemRepository.BeanNameTx) 37 | fun findAppSetting(p: FindAppSetting): List = AppSetting.find(rep, p) 38 | 39 | fun changeAppSetting(id: String, value: String) = 40 | audit.audit("アプリケーション設定情報を変更する") { 41 | dh.settingSet(id, value) 42 | } 43 | 44 | fun processDay() = 45 | audit.audit("営業日を進める") { 46 | dh.time.proceedDay(businessDay.day(1)) 47 | } 48 | 49 | } -------------------------------------------------------------------------------- /src/main/kotlin/sample/util/Validator.kt: -------------------------------------------------------------------------------- 1 | package sample.util 2 | 3 | import sample.ValidationException 4 | import sample.Warns 5 | import java.util.function.Consumer 6 | 7 | /** 8 | * 審査例外の構築概念を表現します。 9 | */ 10 | class Validator(val warns: Warns = Warns.init()) { 11 | 12 | /** 審査を行います。validがfalseの時に例外を内部にスタックします。 */ 13 | fun check(valid: Boolean, message: String): Validator { 14 | if (!valid) { 15 | warns.add(message) 16 | } 17 | return this 18 | } 19 | 20 | /** 個別属性の審査を行います。validがfalseの時に例外を内部にスタックします。 */ 21 | fun checkField(valid: Boolean, field: String, message: String): Validator { 22 | if (!valid) { 23 | warns.add(message, field) 24 | } 25 | return this 26 | } 27 | 28 | /** 審査を行います。失敗した時は即時に例外を発生させます。 */ 29 | fun verify(valid: Boolean, message: String): Validator = 30 | check(valid, message).verify() 31 | 32 | /** 個別属性の審査を行います。失敗した時は即時に例外を発生させます。 */ 33 | fun verifyField(valid: Boolean, field: String, message: String): Validator = 34 | checkField(valid, field, message).verify() 35 | 36 | /** 検証します。事前に行ったcheckで例外が存在していた時は例外を発生させます。 */ 37 | fun verify(): Validator { 38 | if (hasWarn()) { 39 | throw ValidationException(warns) 40 | } 41 | return clear() 42 | } 43 | 44 | /** 審査例外を保有している時はtrueを返します。 */ 45 | fun hasWarn(): Boolean = warns.nonEmpty() 46 | 47 | /** 内部に保有する審査例外を初期化します。 */ 48 | fun clear(): Validator { 49 | warns.list.clear() 50 | return this 51 | } 52 | 53 | companion object { 54 | /** 審査処理を行います。 */ 55 | fun validate(proc: (Validator) -> Unit) { 56 | val validator = Validator() 57 | proc(validator) 58 | validator.verify() 59 | } 60 | } 61 | 62 | } -------------------------------------------------------------------------------- /src/main/kotlin/sample/context/orm/OrmActiveRecord.kt: -------------------------------------------------------------------------------- 1 | package sample.context.orm 2 | 3 | import sample.context.Entity 4 | import sample.util.Validator 5 | import java.io.Serializable 6 | 7 | 8 | /** 9 | * ORMベースでActiveRecordの概念を提供するEntity基底クラス。 10 | *

ここでは自インスタンスの状態に依存する簡易な振る舞いのみをサポートします。 11 | * 実際のActiveRecordモデルにはget/find等の概念も含まれますが、それらは 自己の状態を 12 | * 変える行為ではなく対象インスタンスを特定する行為(クラス概念)にあたるため、 13 | * クラスメソッドとして継承先で個別定義するようにしてください。 14 | *

15 |  * public static Optional<Account> get(final OrmRepository rep, String id) {
16 |  *     return rep.get(Account.class, id);
17 |  * }
18 |  *
19 |  * public static Account findAll(final OrmRepository rep) {
20 |  *     return rep.findAll(Account.class);
21 |  * }
22 |  * 
23 | */ 24 | @Suppress("UNCHECKED_CAST") 25 | abstract class OrmActiveRecord : Serializable, Entity { 26 | 27 | /** 審査処理をします。 */ 28 | protected fun validate(proc: (Validator) -> Unit): T { 29 | Validator.validate(proc) 30 | return this as T 31 | } 32 | 33 | /** 34 | * 与えられたレポジトリを経由して自身を新規追加します。 35 | * @param rep 永続化の際に利用する関連[OrmRepository] 36 | * @return 自身の情報 37 | */ 38 | fun save(rep: OrmRepository): T = 39 | rep.save(this as T) 40 | 41 | /** 42 | * 与えられたレポジトリを経由して自身を更新します。 43 | * @param rep 永続化の際に利用する関連[OrmRepository] 44 | */ 45 | fun update(rep: OrmRepository): T = 46 | rep.update(this as T) 47 | 48 | /** 49 | * 与えられたレポジトリを経由して自身を物理削除します。 50 | * @param rep 永続化の際に利用する関連[OrmRepository] 51 | */ 52 | fun delete(rep: OrmRepository): T = 53 | rep.delete(this as T) 54 | 55 | /** 56 | * 与えられたレポジトリを経由して自身を新規追加または更新します。 57 | * @param rep 永続化の際に利用する関連[OrmRepository] 58 | */ 59 | fun saveOrUpdate(rep: OrmRepository): T = 60 | rep.saveOrUpdate(this as T) 61 | 62 | } -------------------------------------------------------------------------------- /src/main/kotlin/sample/context/orm/DefaultRepository.kt: -------------------------------------------------------------------------------- 1 | package sample.context.orm 2 | 3 | import org.springframework.beans.factory.ObjectProvider 4 | import org.springframework.boot.context.properties.ConfigurationProperties 5 | import org.springframework.orm.jpa.JpaTransactionManager 6 | import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean 7 | import org.springframework.stereotype.Repository 8 | import sample.context.DomainHelper 9 | import sample.context.orm.DefaultRepository.Companion.BeanNameEmf 10 | import javax.persistence.EntityManager 11 | import javax.persistence.EntityManagerFactory 12 | import javax.persistence.PersistenceContext 13 | import javax.sql.DataSource 14 | 15 | 16 | /** 標準スキーマのRepositoryを表現します。 */ 17 | @Repository 18 | class DefaultRepository( 19 | dh: ObjectProvider, 20 | interceptor: ObjectProvider, 21 | @PersistenceContext(unitName = BeanNameEmf) 22 | var em: EntityManager? = null 23 | ) : OrmRepository(dh, interceptor) { 24 | 25 | override fun em(): EntityManager = em!! 26 | 27 | companion object { 28 | const val BeanNameDs = "dataSource" 29 | const val BeanNameEmf = "entityManagerFactory" 30 | const val BeanNameTx = "transactionManager" 31 | } 32 | 33 | } 34 | 35 | /** 標準スキーマのDataSourceを生成します。 */ 36 | @ConfigurationProperties(prefix = "extension.datasource.default") 37 | class DefaultDataSourceProperties( 38 | var jpa: OrmRepositoryProperties = OrmRepositoryProperties() 39 | ) : OrmDataSourceProperties() { 40 | 41 | fun entityManagerFactoryBean(dataSource: DataSource): LocalContainerEntityManagerFactoryBean = 42 | jpa.entityManagerFactoryBean(BeanNameEmf, dataSource) 43 | 44 | fun transactionManager(emf: EntityManagerFactory): JpaTransactionManager = 45 | jpa.transactionManager(emf) 46 | } 47 | -------------------------------------------------------------------------------- /src/main/kotlin/sample/context/orm/SystemRepository.kt: -------------------------------------------------------------------------------- 1 | package sample.context.orm 2 | 3 | import org.springframework.beans.factory.ObjectProvider 4 | import org.springframework.boot.context.properties.ConfigurationProperties 5 | import org.springframework.orm.jpa.JpaTransactionManager 6 | import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean 7 | import org.springframework.stereotype.Repository 8 | import sample.context.DomainHelper 9 | import sample.context.orm.SystemRepository.Companion.BeanNameEmf 10 | import javax.persistence.EntityManager 11 | import javax.persistence.EntityManagerFactory 12 | import javax.persistence.PersistenceContext 13 | import javax.sql.DataSource 14 | 15 | 16 | /** システムスキーマのRepositoryを表現します。 */ 17 | @Repository 18 | class SystemRepository( 19 | dh: ObjectProvider, 20 | interceptor: ObjectProvider, 21 | @PersistenceContext(unitName = BeanNameEmf) 22 | var em: EntityManager? = null 23 | ) : OrmRepository(dh, interceptor) { 24 | 25 | override fun em(): EntityManager = em!! 26 | 27 | companion object { 28 | const val BeanNameDs = "systemDataSource" 29 | const val BeanNameEmf = "systemEntityManagerFactory" 30 | const val BeanNameTx = "systemTransactionManager" 31 | } 32 | 33 | } 34 | 35 | /** システムスキーマのDataSourceを生成します。 */ 36 | @ConfigurationProperties(prefix = "extension.datasource.system") 37 | class SystemDataSourceProperties( 38 | var jpa: OrmRepositoryProperties = OrmRepositoryProperties() 39 | ) : OrmDataSourceProperties() { 40 | 41 | fun entityManagerFactoryBean(dataSource: DataSource): LocalContainerEntityManagerFactoryBean = 42 | jpa.entityManagerFactoryBean(BeanNameEmf, dataSource) 43 | 44 | fun transactionManager(emf: EntityManagerFactory): JpaTransactionManager = 45 | jpa.transactionManager(emf) 46 | 47 | } 48 | -------------------------------------------------------------------------------- /src/main/kotlin/sample/util/TimePoint.kt: -------------------------------------------------------------------------------- 1 | package sample.util 2 | 3 | import org.springframework.format.annotation.DateTimeFormat 4 | import java.io.Serializable 5 | import java.time.Clock 6 | import java.time.LocalDate 7 | import java.time.LocalDateTime 8 | import javax.validation.constraints.NotNull 9 | 10 | /** 11 | * 日付と日時のペアを表現します。 12 | *

0:00に営業日切り替えが行われないケースなどでの利用を想定しています。 13 | */ 14 | data class TimePoint( 15 | @field:NotNull 16 | @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) 17 | var day: LocalDate, 18 | @field:NotNull 19 | @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) 20 | var date: LocalDateTime = day.atStartOfDay()) : Serializable { 21 | 22 | /** 指定日付と同じか。(day == targetDay) */ 23 | fun equalsDay(targetDay: LocalDate): Boolean = 24 | day.compareTo(targetDay) == 0 25 | 26 | /** 指定日付よりも前か。(day < targetDay) */ 27 | fun beforeDay(targetDay: LocalDate): Boolean = 28 | day.compareTo(targetDay) < 0 29 | 30 | /** 指定日付以前か。(day <= targetDay) */ 31 | fun beforeEqualsDay(targetDay: LocalDate): Boolean = 32 | day.compareTo(targetDay) <= 0 33 | 34 | /** 指定日付よりも後か。(targetDay < day) */ 35 | fun afterDay(targetDay: LocalDate): Boolean = 36 | 0 < day.compareTo(targetDay) 37 | 38 | /** 指定日付以降か。(targetDay <= day) */ 39 | fun afterEqualsDay(targetDay: LocalDate): Boolean = 40 | 0 <= day.compareTo(targetDay) 41 | 42 | companion object { 43 | private const val serialVersionUID: Long = 1; 44 | 45 | /** TimePointを生成します。 */ 46 | fun now(): TimePoint { 47 | val now = LocalDateTime.now() 48 | return TimePoint(now.toLocalDate(), now) 49 | } 50 | 51 | /** TimePointを生成します。 */ 52 | fun now(clock: Clock): TimePoint { 53 | val now = LocalDateTime.now(clock) 54 | return TimePoint(now.toLocalDate(), now) 55 | } 56 | } 57 | } -------------------------------------------------------------------------------- /src/main/kotlin/sample/controller/LoginInterceptor.kt: -------------------------------------------------------------------------------- 1 | package sample.controller 2 | 3 | import org.aspectj.lang.annotation.After 4 | import org.aspectj.lang.annotation.Aspect 5 | import org.aspectj.lang.annotation.Before 6 | import sample.context.actor.ActorRoleType 7 | import sample.context.actor.Actor 8 | import sample.context.actor.ActorSession 9 | import org.springframework.beans.factory.annotation.Autowired 10 | import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean 11 | import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty 12 | import org.springframework.context.annotation.Configuration 13 | import org.springframework.stereotype.Component 14 | import sample.context.security.SecurityConfigurer 15 | 16 | 17 | /** 18 | * Spring Securityの設定状況に応じてスレッドローカルへ利用者を紐付けるAOPInterceptor。 19 | */ 20 | @Aspect 21 | @Configuration 22 | class LoginInterceptor(private val session: ActorSession) { 23 | 24 | @Before("execution(* *..controller.system.*Controller.*(..))") 25 | fun bindSystem() { 26 | session.bind(Actor.System) 27 | } 28 | 29 | @After("execution(* *..controller..*Controller.*(..))") 30 | fun unbind() { 31 | session.unbind() 32 | } 33 | 34 | /** 35 | * セキュリティの認証設定(extension.security.auth.enabled)が無効時のみ有効される擬似ログイン処理。 36 | * 37 | * 開発時のみ利用してください。 38 | */ 39 | @Aspect 40 | @Component 41 | @ConditionalOnProperty(name = ["extension.security.auth.enabled"], havingValue = "false", matchIfMissing = true) 42 | class DummyLoginInterceptor(private val session: ActorSession) { 43 | 44 | @Before("execution(* *..controller.*Controller.*(..))") 45 | fun bindUser() { 46 | session.bind(Actor("sample", ActorRoleType.User)) 47 | } 48 | 49 | @Before("execution(* *..controller.admin.*Controller.*(..))") 50 | fun bindAdmin() { 51 | session.bind(Actor("admin", ActorRoleType.Internal)) 52 | } 53 | } 54 | 55 | } 56 | -------------------------------------------------------------------------------- /src/main/kotlin/sample/context/lock/IdLockHandler.kt: -------------------------------------------------------------------------------- 1 | package sample.context.lock 2 | 3 | import java.io.Serializable 4 | import java.util.concurrent.locks.ReentrantReadWriteLock 5 | import org.apache.logging.log4j.ThreadContext.containsKey 6 | import org.springframework.stereotype.Component 7 | import sample.InvocationException 8 | import java.util.* 9 | 10 | 11 | /** 12 | * ID単位のロックを表現します。 13 | * low: ここではシンプルに口座単位のIDロックのみをターゲットにします。 14 | * low: 通常はDBのロックテーブルに"for update"要求で悲観的ロックをとったりしますが、サンプルなのでメモリロックにしてます。 15 | */ 16 | @Component 17 | class IdLockHandler() { 18 | private val lockMap = mutableMapOf() 19 | 20 | /** IDロック上で処理を実行します。 */ 21 | fun call(id: Serializable, lockType: LockType, callable: () -> T): T { 22 | if (lockType.isWrite) writeLock(id) else readLock(id) 23 | try { 24 | return callable() 25 | } catch (e: RuntimeException) { 26 | throw e 27 | } catch (e: Exception) { 28 | throw InvocationException("error.Exception", e) 29 | } finally { 30 | unlock(id) 31 | } 32 | } 33 | 34 | private fun writeLock(id: Serializable) { 35 | idLock(id).writeLock().lock() 36 | } 37 | 38 | private fun idLock(id: Serializable): ReentrantReadWriteLock = 39 | lockMap.computeIfAbsent(id) { 40 | ReentrantReadWriteLock() 41 | } 42 | 43 | fun readLock(id: Serializable) { 44 | idLock(id).readLock().lock() 45 | } 46 | 47 | fun unlock(id: Serializable) { 48 | val idLock = idLock(id) 49 | if (idLock.isWriteLockedByCurrentThread) { 50 | idLock.writeLock().unlock() 51 | } else { 52 | idLock.readLock().unlock() 53 | } 54 | } 55 | 56 | } 57 | 58 | /** 59 | * ロック種別を表現するEnum。 60 | */ 61 | enum class LockType { 62 | /** 読み取り専用ロック */ 63 | Read, 64 | /** 読み書き専用ロック */ 65 | Write; 66 | 67 | val isRead: Boolean = !isWrite 68 | 69 | val isWrite: Boolean 70 | get() = this == Write 71 | } 72 | -------------------------------------------------------------------------------- /src/test/kotlin/sample/model/account/LoginTest.kt: -------------------------------------------------------------------------------- 1 | package sample.model.account 2 | 3 | import org.hamcrest.Matchers.* 4 | import org.junit.Assert.* 5 | import org.junit.Test 6 | import sample.EntityTestSupport 7 | import sample.ErrorKeys 8 | import sample.ValidationException 9 | 10 | 11 | class LoginTest : EntityTestSupport() { 12 | override fun setupPreset() { 13 | targetEntities(Login::class.java) 14 | } 15 | 16 | override fun before() { 17 | tx { fixtures().login("test").save(rep()) } 18 | } 19 | 20 | @Test 21 | fun ログインIDを変更する() { 22 | tx { 23 | // 正常系 24 | fixtures().login("any").save(rep()) 25 | assertThat(Login.load(rep(), "any").change(rep(), ChgLoginId("testAny")), allOf( 26 | hasProperty("id", `is`("any")), 27 | hasProperty("loginId", `is`("testAny")))) 28 | 29 | // 自身に対する同名変更 30 | assertThat(Login.load(rep(), "any").change(rep(), ChgLoginId("testAny")), allOf( 31 | hasProperty("id", `is`("any")), 32 | hasProperty("loginId", `is`("testAny")))) 33 | 34 | // 重複ID 35 | try { 36 | Login.load(rep(), "any").change(rep(), ChgLoginId("test")) 37 | fail() 38 | } catch (e: ValidationException) { 39 | assertThat(e.message, `is`(ErrorKeys.DuplicateId)) 40 | } 41 | } 42 | } 43 | 44 | @Test 45 | fun パスワードを変更する() { 46 | tx { 47 | val login = Login.load(rep(), "test").change(rep(), encoder, ChgPassword("changed")) 48 | assertTrue(encoder.matches("changed", login.password)) 49 | } 50 | } 51 | 52 | @Test 53 | fun ログイン情報を取得する() { 54 | tx { 55 | val m = Login.load(rep(), "test") 56 | m.loginId = "changed" 57 | m.update(rep()) 58 | assertTrue(Login.getByLoginId(rep(), "changed").isPresent) 59 | assertFalse(Login.getByLoginId(rep(), "test").isPresent) 60 | } 61 | } 62 | 63 | } 64 | -------------------------------------------------------------------------------- /src/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | --- 2 | spring: 3 | application.name: sample-boot-scala 4 | messages.basename: messages-validation, messages 5 | banner.location: banner.txt 6 | cache.jcache.config: classpath:ehcache.xml 7 | jackson.serialization: 8 | indent-output: true 9 | servlet.multipart: 10 | max-file-size: 20MB 11 | max-request-size: 20MB 12 | jpa.open-in-view: false 13 | 14 | logging.config: classpath:logback-spring.xml 15 | 16 | server: 17 | port: 8080 18 | error: 19 | whitelabel.enabled: false 20 | path: /api/error 21 | 22 | management: 23 | endpoints.web: 24 | base-path: /management 25 | exposure.include: "*" 26 | 27 | extension: 28 | datasource: 29 | default: 30 | url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE 31 | username: 32 | password: 33 | jpa: 34 | package-to-scan: sample.model 35 | hibernate.ddl-auto: create-drop 36 | system: 37 | url: jdbc:h2:mem:system;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE 38 | username: 39 | password: 40 | jpa: 41 | package-to-scan: sample.context 42 | hibernate.ddl-auto: create-drop 43 | security: 44 | auth: 45 | enabled: false 46 | admin: false 47 | cors.enabled: true 48 | mail.enabled: false 49 | datafixture.enabled: true 50 | 51 | --- 52 | spring: 53 | profiles: production 54 | 55 | extension: 56 | datasource: 57 | default: 58 | url: jdbc:oracle:thin:@xxx.xxx.xxx.xxx:1521:xx 59 | username: XXXXXX 60 | password: XXXXXX 61 | jpa: 62 | show-sql: false 63 | hibernate.ddl-auto: none 64 | system: 65 | url: jdbc:oracle:thin:@xxx.xxx.xxx.xxx:1521:xx 66 | username: XXXXXX 67 | password: XXXXXX 68 | jpa: 69 | show-sql: false 70 | hibernate.ddl-auto: none 71 | security: 72 | auth.enabled: true 73 | cors.enabled: false 74 | datafixture.enabled: false 75 | 76 | --- 77 | spring: 78 | profiles: admin 79 | 80 | server.port: 8081 81 | 82 | extension: 83 | security.auth.admin: true 84 | -------------------------------------------------------------------------------- /src/test/kotlin/sample/model/master/StaffTest.kt: -------------------------------------------------------------------------------- 1 | package sample.model.master 2 | 3 | import org.junit.Assert.* 4 | import org.junit.Assert.assertThat 5 | import org.hamcrest.MatcherAssert.* 6 | import org.hamcrest.Matchers.* 7 | import org.junit.Test 8 | import sample.ErrorKeys 9 | import sample.EntityTestSupport 10 | import sample.ValidationException 11 | 12 | class StaffTest : EntityTestSupport() { 13 | 14 | override fun setupPreset() { 15 | targetEntities(Staff::class.java) 16 | } 17 | 18 | override fun before() { 19 | tx { fixtures().staff("sample").save(rep()) } 20 | } 21 | 22 | @Test 23 | fun 社員情報を登録する() { 24 | tx { 25 | // 正常登録 26 | val staff = Staff.register(rep(), encoder, RegStaff("new", "newName", "password")) 27 | assertThat(staff, allOf( 28 | hasProperty("id", `is`("new")), 29 | hasProperty("name", `is`("newName")))) 30 | assertTrue(encoder.matches("password", staff.password)) 31 | 32 | // 重複ID 33 | try { 34 | Staff.register(rep(), encoder, RegStaff("sample", "newName", "password")) 35 | fail() 36 | } catch (e: ValidationException) { 37 | assertThat(e.message, `is`(ErrorKeys.DuplicateId)) 38 | } 39 | } 40 | } 41 | 42 | @Test 43 | fun 社員パスワードを変更する() { 44 | tx { 45 | val changed = Staff.load(rep(), "sample").change(rep(), encoder, ChgPassword("changed")) 46 | assertTrue(encoder.matches("changed", changed.password)) 47 | } 48 | } 49 | 50 | @Test 51 | fun 社員情報を変更する() { 52 | tx { 53 | assertThat( 54 | Staff.load(rep(), "sample").change(rep(), ChgStaff("changed")).name, `is`("changed")) 55 | } 56 | } 57 | 58 | @Test 59 | fun 社員を検索する() { 60 | tx { 61 | assertFalse(Staff.find(rep(), FindStaff("amp")).isEmpty()) 62 | assertTrue(Staff.find(rep(), FindStaff("amq")).isEmpty()) 63 | } 64 | } 65 | 66 | } 67 | 68 | -------------------------------------------------------------------------------- /src/main/kotlin/sample/controller/admin/MasterAdminController.kt: -------------------------------------------------------------------------------- 1 | package sample.controller.admin 2 | 3 | import org.springframework.http.ResponseEntity 4 | import org.springframework.web.bind.annotation.GetMapping 5 | import org.springframework.web.bind.annotation.PostMapping 6 | import org.springframework.web.bind.annotation.RequestMapping 7 | import org.springframework.web.bind.annotation.RestController 8 | import sample.ErrorKeys 9 | import sample.ValidationException 10 | import sample.context.security.SecurityActorFinder 11 | import sample.context.security.SecurityProperties 12 | import sample.model.master.RegHoliday 13 | import sample.usecase.admin.MasterAdminService 14 | import javax.validation.Valid 15 | 16 | /** 17 | * マスタに関わる社内のUI要求を処理します。 18 | */ 19 | @RestController 20 | @RequestMapping("/api/admin/master") 21 | class MasterAdminController( 22 | val service: MasterAdminService, 23 | val securityProps: SecurityProperties 24 | ) { 25 | /** 社員ログイン状態を確認します。 */ 26 | @GetMapping("/loginStatus") 27 | fun loginStatus(): Boolean = true 28 | 29 | /** 社員ログイン情報を取得します。 */ 30 | @GetMapping("/loginStaff") 31 | fun loadLoginStaff(): LoginStaffUI = 32 | when (securityProps.auth.enabled) { 33 | true -> { 34 | val actorDetails = SecurityActorFinder.actorDetails() 35 | .orElseThrow { ValidationException(ErrorKeys.Authentication) } 36 | val actor = actorDetails.actor 37 | LoginStaffUI(actor.id, actor.name, actorDetails.authorityIds) 38 | } 39 | false -> // for dummy login 40 | LoginStaffUI("sample", "sample", listOf()) 41 | } 42 | 43 | /** 休日を登録します。 */ 44 | @PostMapping("/holiday/") 45 | fun registerHoliday(@Valid p: RegHoliday): ResponseEntity = 46 | ResponseEntity.ok().apply { service.registerHoliday(p) }.build() 47 | 48 | } 49 | 50 | /** クライアント利用用途に絞ったパラメタ */ 51 | data class LoginStaffUI( 52 | val id: String, 53 | val name: String, 54 | val authorities: List 55 | ) -------------------------------------------------------------------------------- /src/main/kotlin/sample/context/security/SecurityProperties.kt: -------------------------------------------------------------------------------- 1 | package sample.context.security 2 | 3 | import org.springframework.boot.context.properties.ConfigurationProperties 4 | 5 | /** 6 | * セキュリティ関連の設定情報を表現します。 7 | */ 8 | @ConfigurationProperties(prefix = "extension.security") 9 | data class SecurityProperties( 10 | /** Spring Security依存の認証/認可設定情報 */ 11 | var auth: SecurityAuthProperties = SecurityAuthProperties(), 12 | /** CORS設定情報 */ 13 | var cors: SecurityCorsProperties = SecurityCorsProperties() 14 | ) 15 | 16 | /** Spring Securityに対する拡張設定情報 */ 17 | data class SecurityAuthProperties( 18 | /** リクエスト時のログインIDを取得するキー */ 19 | var loginKey: String = "loginId", 20 | /** リクエスト時のパスワードを取得するキー */ 21 | var passwordKey: String = "password", 22 | /** 認証対象パス */ 23 | var path: List = listOf("/api/**"), 24 | /** 認証対象パス(管理者向け) */ 25 | var pathAdmin: List = listOf("/api/admin/**"), 26 | /** 認証除外パス(認証対象からの除外) */ 27 | var excludesPath: List = listOf("/api/system/job/**"), 28 | /** 認証無視パス(フィルタ未適用の認証未考慮、静的リソース等) */ 29 | var ignorePath: List = listOf("/css/**", "/js/**", "/img/**", "/**/favicon.ico"), 30 | /** ログインAPIパス */ 31 | var loginPath: String = "/api/login", 32 | /** ログアウトAPIパス */ 33 | var logoutPath: String = "/api/logout", 34 | /** 一人が同時利用可能な最大セッション数 */ 35 | var maximumSessions: Int = 2, 36 | /** 37 | * 社員向けモードの時はtrue。 38 | * 39 | * ログインパスは同じですが、ログイン処理の取り扱いが切り替わります。 40 | * 41 | * * true: SecurityUserService 42 | * * false: SecurityAdminService 43 | * 44 | */ 45 | var admin: Boolean = false, 46 | /** 認証が有効な時はtrue */ 47 | var enabled: Boolean = true 48 | ) 49 | 50 | /** CORS設定情報を表現します。 */ 51 | data class SecurityCorsProperties( 52 | var allowCredentials: Boolean = true, 53 | var allowedOrigin: String = "*", 54 | var allowedHeader: String = "*", 55 | var allowedMethod: String = "*", 56 | var maxAge: Long = 3600L, 57 | var path: String = "/**" 58 | ) 59 | -------------------------------------------------------------------------------- /src/main/kotlin/sample/controller/AssetController.kt: -------------------------------------------------------------------------------- 1 | package sample.controller 2 | 3 | import org.springframework.http.ResponseEntity 4 | import org.springframework.web.bind.annotation.GetMapping 5 | import org.springframework.web.bind.annotation.PostMapping 6 | import org.springframework.web.bind.annotation.RequestMapping 7 | import org.springframework.web.bind.annotation.RestController 8 | import sample.ActionStatusType 9 | import sample.context.Dto 10 | import sample.model.asset.CashInOut 11 | import sample.model.asset.RegCashOut 12 | import sample.usecase.AssetService 13 | import java.math.BigDecimal 14 | import java.time.LocalDate 15 | import java.time.LocalDateTime 16 | import javax.validation.Valid 17 | 18 | 19 | /** 20 | * 資産に関わる顧客のUI要求を処理します。 21 | */ 22 | @RestController 23 | @RequestMapping("/api/asset") 24 | class AssetController(private val service: AssetService) { 25 | 26 | /** 未処理の振込依頼情報を検索します。 */ 27 | @GetMapping("/cio/unprocessedOut/") 28 | fun findUnprocessedCashOut(): List = 29 | service.findUnprocessedCashOut().map { CashOutUI.of(it) } 30 | 31 | /** 32 | * 振込出金依頼をします。 33 | * low: RestControllerの標準の振る舞いとしてvoidやプリミティブ型はJSON化されないので注意してください。 34 | * (解析時の優先順位の関係だと思いますが) 35 | */ 36 | @PostMapping("/cio/withdraw") 37 | fun withdraw(@Valid p: RegCashOut): ResponseEntity = 38 | ResponseEntity.ok(service.withdraw(p)) 39 | 40 | } 41 | 42 | /** 振込出金依頼情報の表示用Dto */ 43 | data class CashOutUI( 44 | val id: Long, 45 | val currency: String, 46 | val absAmount: BigDecimal, 47 | val requestDay: LocalDate, 48 | val requestDate: LocalDateTime, 49 | val eventDay: LocalDate, 50 | val valueDay: LocalDate, 51 | val statusType: ActionStatusType, 52 | val updateDate: LocalDateTime, 53 | val cashflowId: Long? = null 54 | ) : Dto { 55 | 56 | companion object { 57 | private const val serialVersionUID = 1L 58 | 59 | fun of(cio: CashInOut): CashOutUI = 60 | CashOutUI(cio.id!!, cio.currency, cio.absAmount, cio.requestDay, 61 | cio.requestDate, cio.eventDay, cio.valueDay, cio.statusType, 62 | cio.updateDate!!, cio.cashflowId) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/main/kotlin/sample/context/orm/OrmRepositoryProperties.kt: -------------------------------------------------------------------------------- 1 | package sample.context.orm 2 | 3 | import org.springframework.boot.autoconfigure.orm.jpa.HibernateProperties 4 | import org.springframework.boot.autoconfigure.orm.jpa.HibernateSettings 5 | import org.springframework.boot.autoconfigure.orm.jpa.JpaProperties 6 | import org.springframework.boot.orm.jpa.EntityManagerFactoryBuilder 7 | import org.springframework.orm.jpa.JpaTransactionManager 8 | import org.springframework.orm.jpa.JpaVendorAdapter 9 | import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean 10 | import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter 11 | import javax.persistence.EntityManagerFactory 12 | import javax.sql.DataSource 13 | 14 | 15 | /** JPA コンポーネントを生成するための設定情報を表現します。 */ 16 | data class OrmRepositoryProperties( 17 | /** スキーマ紐付け対象とするパッケージ。(annotatedClassesとどちらかを設定) */ 18 | var packageToScan: Collection = listOf(), 19 | /** Entityとして登録するクラス。(packageToScanとどちらかを設定) */ 20 | var annotatedClasses: Collection> = listOf(), 21 | var hibernate: HibernateProperties = HibernateProperties() 22 | ) : JpaProperties() { 23 | 24 | fun entityManagerFactoryBean(name: String, dataSource: DataSource): LocalContainerEntityManagerFactoryBean { 25 | val emfBuilder = EntityManagerFactoryBuilder( 26 | vendorAdapter(), properties, null) 27 | val builder = emfBuilder 28 | .dataSource(dataSource) 29 | .persistenceUnit(name) 30 | .properties(hibernate.determineHibernateProperties(getProperties(), HibernateSettings())) 31 | .jta(false) 32 | if (annotatedClasses.isNotEmpty()) { 33 | builder.packages(*annotatedClasses.toTypedArray()) 34 | } else { 35 | builder.packages(*packageToScan.toTypedArray()) 36 | } 37 | return builder.build() 38 | } 39 | 40 | private fun vendorAdapter(): JpaVendorAdapter { 41 | val adapter = HibernateJpaVendorAdapter() 42 | adapter.setShowSql(isShowSql) 43 | if (database != null) { 44 | adapter.setDatabase(database) 45 | } 46 | adapter.setDatabasePlatform(databasePlatform) 47 | adapter.setGenerateDdl(isGenerateDdl) 48 | return adapter 49 | } 50 | 51 | fun transactionManager(emf: EntityManagerFactory): JpaTransactionManager = 52 | JpaTransactionManager(emf) 53 | 54 | } -------------------------------------------------------------------------------- /src/test/kotlin/sample/model/asset/CashBalanceTest.kt: -------------------------------------------------------------------------------- 1 | package sample.model.asset 2 | 3 | import org.hamcrest.Matchers.* 4 | import org.junit.Assert.assertThat 5 | import org.junit.Test 6 | import sample.EntityTestSupport 7 | import java.math.BigDecimal 8 | 9 | //low: 簡易な正常系検証のみ 10 | class CashBalanceTest : EntityTestSupport() { 11 | 12 | override fun setupPreset() { 13 | targetEntities(CashBalance::class.java) 14 | } 15 | 16 | @Test 17 | fun 現金残高を追加する() { 18 | val baseDay = businessDay.day() 19 | tx { 20 | val cb = fixtures().cb("test1", baseDay, "USD", "10.02").save(rep()) 21 | 22 | // 10.02 + 11.51 = 21.53 23 | assertThat(cb.add(rep(), BigDecimal("11.51")).amount, `is`(BigDecimal("21.53"))) 24 | 25 | // 21.53 + 11.516 = 33.04 (端数切捨確認) 26 | assertThat(cb.add(rep(), BigDecimal("11.516")).amount, `is`(BigDecimal("33.04"))) 27 | 28 | // 33.04 - 41.51 = -8.47 (マイナス値/マイナス残許容) 29 | assertThat(cb.add(rep(), BigDecimal("-41.51")).amount, `is`(BigDecimal("-8.47"))) 30 | } 31 | } 32 | 33 | @Test 34 | fun 現金残高を取得する() { 35 | val baseDay = businessDay.day() 36 | val baseMinus1Day = businessDay.day(-1) 37 | tx { 38 | fixtures().cb("test1", baseDay, "JPY", "1000").save(rep()) 39 | fixtures().cb("test2", baseMinus1Day, "JPY", "3000").save(rep()) 40 | 41 | // 存在している残高の検証 42 | val cbNormal = CashBalance.getOrNew(rep(), "test1", "JPY") 43 | assertThat(cbNormal, allOf( 44 | hasProperty("accountId", `is`("test1")), 45 | hasProperty("baseDay", `is`(baseDay)), 46 | hasProperty("amount", `is`(BigDecimal("1000"))))) 47 | 48 | // 基準日に存在していない残高の繰越検証 49 | val cbRoll = CashBalance.getOrNew(rep(), "test2", "JPY") 50 | assertThat(cbRoll, allOf( 51 | hasProperty("accountId", `is`("test2")), 52 | hasProperty("baseDay", `is`(baseDay)), 53 | hasProperty("amount", `is`(BigDecimal("3000"))))) 54 | 55 | // 残高を保有しない口座の生成検証 56 | val cbNew = CashBalance.getOrNew(rep(), "test3", "JPY") 57 | assertThat(cbNew, allOf( 58 | hasProperty("accountId", `is`("test3")), 59 | hasProperty("baseDay", `is`(baseDay)), 60 | hasProperty("amount", `is`(BigDecimal.ZERO)))) 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/main/kotlin/sample/context/orm/OrmDataSourceProperties.kt: -------------------------------------------------------------------------------- 1 | package sample.context.orm 2 | 3 | import com.zaxxer.hikari.HikariDataSource 4 | import org.springframework.boot.context.properties.ConfigurationProperties 5 | import org.springframework.boot.jdbc.DataSourceBuilder 6 | import org.springframework.boot.jdbc.DatabaseDriver 7 | import org.springframework.context.annotation.Configuration 8 | import java.util.* 9 | import javax.sql.DataSource 10 | 11 | /** 12 | * DataSource生成用の設定クラス。 13 | *

継承先で@ConfigurationProperties定義を行ってapplication.ymlと紐付してください。 14 | *

ベース実装にHikariCPを利用しています。必要に応じて設定可能フィールドを増やすようにしてください。 15 | */ 16 | open class OrmDataSourceProperties( 17 | /** ドライバクラス名称 ( 未設定時は url から自動登録 ) */ 18 | var driverClassName: String? = null, 19 | var url: String? = null, 20 | var username: String? = null, 21 | var password: String? = null, 22 | var props: Properties = Properties(), 23 | /** 最低接続プーリング数 */ 24 | var minIdle: Int = 1, 25 | /** 最大接続プーリング数 */ 26 | var maxPoolSize: Int = 20, 27 | /** コネクション状態を確認する時は true */ 28 | var validation: Boolean = true, 29 | /** コネクション状態確認クエリ ( 未設定時かつ Database が対応している時は自動設定 ) */ 30 | var validationQuery: String? = null 31 | ) { 32 | val name: String 33 | get() = this.javaClass.simpleName.replace("Properties".toRegex(), "") 34 | 35 | open fun dataSource(): DataSource { 36 | val dataSource = DataSourceBuilder.create() 37 | .type(HikariDataSource::class.java) 38 | .driverClassName(this.driverClassName()).url(this.url) 39 | .username(this.username).password(this.password) 40 | .build() as HikariDataSource 41 | dataSource.minimumIdle = minIdle 42 | dataSource.maximumPoolSize = maxPoolSize 43 | if (validation) { 44 | dataSource.connectionTestQuery = validationQuery() 45 | } 46 | dataSource.poolName = name 47 | dataSource.dataSourceProperties = props 48 | return dataSource 49 | } 50 | 51 | private fun driverClassName(): String = 52 | if (driverClassName.isNullOrBlank()) { 53 | DatabaseDriver.fromJdbcUrl(url).driverClassName 54 | } else driverClassName.orEmpty() 55 | 56 | private fun validationQuery(): String = 57 | if (validationQuery.isNullOrBlank()) { 58 | DatabaseDriver.fromJdbcUrl(url).validationQuery 59 | } else validationQuery.orEmpty() 60 | } -------------------------------------------------------------------------------- /src/test/kotlin/sample/model/account/FiAccountTest.kt: -------------------------------------------------------------------------------- 1 | package sample.model.account 2 | 3 | import org.hamcrest.Matchers.* 4 | import org.junit.Assert.* 5 | import org.junit.Test 6 | import sample.EntityTestSupport 7 | import sample.ErrorKeys 8 | import sample.ValidationException 9 | 10 | class FiAccountTest : EntityTestSupport() { 11 | 12 | override fun setupPreset() { 13 | targetEntities(FiAccount::class.java, Account::class.java) 14 | } 15 | 16 | override fun before() { 17 | tx { 18 | fixtures().fiAcc("normal", "sample", "JPY").save(rep()) 19 | } 20 | } 21 | 22 | @Test 23 | fun 金融機関口座を取得する() { 24 | tx { 25 | assertThat(FiAccount.load(rep(), "normal", "sample", "JPY"), allOf( 26 | hasProperty("accountId", `is`("normal")), 27 | hasProperty("category", `is`("sample")), 28 | hasProperty("currency", `is`("JPY")), 29 | hasProperty("fiCode", `is`("sample-JPY")), 30 | hasProperty("fiAccountId", `is`("FInormal")))) 31 | try { 32 | FiAccount.load(rep(), "normal", "sample", "USD") 33 | fail() 34 | } catch (e: ValidationException) { 35 | assertThat(e.message, `is`(ErrorKeys.EntityNotFound)) 36 | } 37 | } 38 | } 39 | 40 | @Test 41 | fun Hibernate5_1で追加されたアドホックなJoin検証() { 42 | tx { 43 | fixtures().fiAcc("sample", "join", "JPY").save(rep()) 44 | fixtures().acc("sample").save(rep()) 45 | 46 | val list = rep().tmpl() 47 | .find>("FROM FiAccount fa LEFT JOIN Account a ON fa.accountId = a.id WHERE fa.accountId = ?1", "sample") 48 | .map { mapJoin(it) } 49 | 50 | assertFalse(list.isEmpty()) 51 | val (accountId, name, fiCode) = list[0] 52 | assertThat(accountId, `is`("sample")) 53 | assertThat(name, `is`("sample")) 54 | assertThat(fiCode, `is`("join-JPY")) 55 | } 56 | } 57 | 58 | private fun mapJoin(values: Array): FiAccountJoin { 59 | val fa = values[0] as FiAccount 60 | val a = values[1] as Account 61 | return FiAccountJoin(fa.accountId, a.name, fa.fiCode, fa.fiAccountId) 62 | } 63 | 64 | data class FiAccountJoin( 65 | val accountId: String, 66 | val name: String, 67 | val fiCode: String, 68 | val fiAccountId: String) 69 | } 70 | -------------------------------------------------------------------------------- /src/main/kotlin/sample/usecase/AssetService.kt: -------------------------------------------------------------------------------- 1 | package sample.usecase 2 | 3 | import org.springframework.beans.factory.annotation.Qualifier 4 | import org.springframework.stereotype.Service 5 | import org.springframework.transaction.PlatformTransactionManager 6 | import sample.context.DomainHelper 7 | import sample.context.actor.Actor 8 | import sample.context.audit.AuditHandler 9 | import sample.context.lock.IdLockHandler 10 | import sample.context.lock.LockType 11 | import sample.context.orm.DefaultRepository 12 | import sample.model.BusinessDayHandler 13 | import sample.model.asset.CashInOut 14 | import sample.model.asset.RegCashOut 15 | import sample.usecase.mail.ServiceMailDeliver 16 | 17 | 18 | /** 19 | * 資産ドメインに対する顧客ユースケース処理。 20 | */ 21 | @Service 22 | class AssetService( 23 | private val dh: DomainHelper, 24 | private val rep: DefaultRepository, 25 | @Qualifier(DefaultRepository.BeanNameTx) 26 | private val txm: PlatformTransactionManager, 27 | private val audit: AuditHandler, 28 | private val idLock: IdLockHandler, 29 | private val businessDay: BusinessDayHandler, 30 | private val mail: ServiceMailDeliver 31 | ) { 32 | 33 | /** 匿名を除くActorを返します。 */ 34 | private fun actor(): Actor = ServiceUtils.actorUser(dh) 35 | 36 | /** 37 | * 未処理の振込依頼情報を検索します。 38 | * low: 参照系は口座ロックが必要無いケースであれば@Transactionalでも十分 39 | * low: CashInOutは情報過多ですがアプリケーション層では公開対象を特定しにくい事もあり、 40 | * UI層に最終判断を委ねています。 41 | */ 42 | fun findUnprocessedCashOut(): List = 43 | idLock.call(actor().id, LockType.Read) { 44 | ServiceUtils.tx(txm) { CashInOut.findUnprocessed(rep, actor().id) } 45 | } 46 | 47 | /** 48 | * 振込出金依頼をします。 49 | * low: 公開リスクがあるためUI層には必要以上の情報を返さない事を意識します。 50 | * low: 監査ログの記録は状態を変えうる更新系ユースケースでのみ行います。 51 | * low: ロールバック発生時にメールが飛ばないようにトランザクション境界線を明確に分離します。 52 | * @return 振込出金依頼ID 53 | */ 54 | fun withdraw(p: RegCashOut): Long = 55 | audit.audit("振込出金依頼をします") { 56 | val accId = actor().id 57 | val myParam = p.copy(accountId = accId) // 顧客側はログイン利用者で強制上書き 58 | // low: 口座IDロック(WRITE)とトランザクションをかけて振込処理 59 | val cio = idLock.call(accId, LockType.Read) { 60 | ServiceUtils.tx(txm) { CashInOut.withdraw(rep, businessDay, myParam) } 61 | } 62 | // low: トランザクション確定後に出金依頼を受付した事をメール通知します。 63 | mail.sendWithdrawal(cio) 64 | cio.id!! 65 | } 66 | } -------------------------------------------------------------------------------- /src/main/kotlin/sample/context/Repository.kt: -------------------------------------------------------------------------------- 1 | package sample.context 2 | 3 | import java.io.Serializable 4 | import java.util.* 5 | 6 | /** 7 | * 特定のドメインオブジェクトに依存しない汎用的なRepositoryです。 8 | *

タイプセーフでないRepositoryとして利用することができます。 9 | */ 10 | interface Repository { 11 | 12 | /** 13 | * @return ドメイン層においてインフラ層コンポーネントへのアクセスを提供するヘルパーユーティリティを返します。 14 | */ 15 | fun dh(): DomainHelper 16 | 17 | /** 18 | * プライマリキーに一致する[Entity]を返します。 19 | * @param 戻り値の型 20 | * @param clazz 取得するインスタンスのクラス 21 | * @param id プライマリキー 22 | * @return プライマリキーに一致した{@link Entity}。 23 | */ 24 | fun get(clazz: Class, id: Serializable): Optional 25 | 26 | /** 27 | * プライマリキーに一致する[Entity]を返します。 28 | * @param 戻り値の型 29 | * @param clazz 取得するインスタンスのクラス 30 | * @param id プライマリキー 31 | * @return プライマリキーに一致した{@link Entity}。一致しない時は例外。 32 | */ 33 | fun load(clazz: Class, id: Serializable): T 34 | 35 | /** 36 | * プライマリキーに一致する[Entity]を返します。 37 | * 38 | * ロック付(for update)で取得を行うため、デッドロック回避を意識するようにしてください。 39 | * @param 戻り値の型 40 | * @param clazz 取得するインスタンスのクラス 41 | * @param id プライマリキー 42 | * @return プライマリキーに一致した{@link Entity}。一致しない時は例外。 43 | */ 44 | fun loadForUpdate(clazz: Class, id: Serializable): T 45 | 46 | /** 47 | * プライマリキーに一致する[Entity]が存在するか返します。 48 | * @param 確認型 49 | * @param clazz 対象クラス 50 | * @param id プライマリキー 51 | * @return 存在する時はtrue 52 | */ 53 | fun exists(clazz: Class, id: Serializable): Boolean 54 | 55 | /** 56 | * 管理する[Entity]を全件返します。 57 | * 条件検索などは#templateを利用して実行するようにしてください。 58 | * @param 戻り値の型 59 | * @param clazz 取得するインスタンスのクラス 60 | * @return [Entity]一覧 61 | */ 62 | fun findAll(clazz: Class): List 63 | 64 | /** 65 | * [Entity]を新規追加します。 66 | * @param entity 追加対象[Entity] 67 | * @return 追加した{@link Entity}のプライマリキー 68 | */ 69 | fun save(entity: T): T 70 | 71 | /** 72 | * [Entity]を新規追加または更新します。 73 | * 74 | * 既に同一のプライマリキーが存在するときは更新。 75 | * 存在しない時は新規追加となります。 76 | * @param entity 追加対象[Entity] 77 | */ 78 | fun saveOrUpdate(entity: T): T 79 | 80 | /** 81 | * [Entity]を更新します。 82 | * @param entity 更新対象[Entity] 83 | */ 84 | fun update(entity: T): T 85 | 86 | /** 87 | * [Entity]を削除します。 88 | * @param entity 削除対象[Entity] 89 | */ 90 | fun delete(entity: T): T 91 | 92 | } -------------------------------------------------------------------------------- /src/test/kotlin/sample/model/account/AccountTest.kt: -------------------------------------------------------------------------------- 1 | package sample.model.account 2 | 3 | import org.hamcrest.Matchers.* 4 | import org.junit.Assert.* 5 | import org.junit.Test 6 | import sample.ErrorKeys 7 | import sample.EntityTestSupport 8 | import sample.ValidationException 9 | 10 | class AccountTest : EntityTestSupport() { 11 | 12 | override fun setupPreset() { 13 | targetEntities(Account::class.java, Login::class.java) 14 | } 15 | 16 | override fun before() { 17 | tx { fixtures().acc("normal").save(rep()) } 18 | } 19 | 20 | @Test 21 | fun 口座情報を登録する() { 22 | tx { 23 | // 通常登録 24 | assertFalse(Account.get(rep(), "new").isPresent) 25 | Account.register(rep(), encoder, RegAccount("new", "name", "new@example.com", "password")) 26 | assertThat(Account.load(rep(), "new"), allOf( 27 | hasProperty("name", `is`("name")), 28 | hasProperty("mail", `is`("new@example.com")))) 29 | val login = Login.load(rep(), "new") 30 | assertTrue(encoder.matches("password", login.password)) 31 | // 同一ID重複 32 | try { 33 | Account.register(rep(), encoder, RegAccount("normal", "name", "new@example.com", "password")) 34 | fail() 35 | } catch (e: ValidationException) { 36 | assertThat(e.message, `is`(ErrorKeys.DuplicateId)) 37 | } 38 | } 39 | } 40 | 41 | @Test 42 | fun 口座情報を変更する() { 43 | tx { 44 | Account.load(rep(), "normal").change(rep(), ChgAccount("changed", "changed@example.com")) 45 | assertThat(Account.load(rep(), "normal"), allOf( 46 | hasProperty("name", `is`("changed")), 47 | hasProperty("mail", `is`("changed@example.com")))) 48 | } 49 | } 50 | 51 | @Test 52 | fun 有効口座を取得する() { 53 | tx { 54 | // 通常時取得 55 | assertThat(Account.loadValid(rep(), "normal"), allOf( 56 | hasProperty("id", `is`("normal")), 57 | hasProperty("statusType", `is`(AccountStatusType.Normal)))) 58 | 59 | // 退会時取得 60 | val withdrawal = fixtures().acc("withdrawal") 61 | withdrawal.statusType = AccountStatusType.Withdrawal 62 | withdrawal.save(rep()) 63 | try { 64 | Account.loadValid(rep(), "withdrawal") 65 | fail() 66 | } catch (e: ValidationException) { 67 | assertThat(e.message, `is`("error.Account.loadValid")) 68 | } 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/main/kotlin/sample/context/ResourceBundleHandler.kt: -------------------------------------------------------------------------------- 1 | package sample.context 2 | 3 | import org.springframework.boot.context.properties.ConfigurationProperties 4 | import org.springframework.context.support.ResourceBundleMessageSource 5 | import org.springframework.stereotype.Component 6 | import java.util.* 7 | import java.util.Locale 8 | 9 | 10 | /** 11 | * ResourceBundleに対する簡易アクセスを提供します。 12 | *

本コンポーネントはAPI経由でのラベル一覧の提供等、i18n用途のメッセージプロパティで利用してください。 13 | *

ResourceBundleは単純な文字列変換を目的とする標準のMessageSourceとは異なる特性(リスト概念)を 14 | * 持つため、別インスタンスでの管理としています。 15 | * (spring.messageとは別に指定[extension.messages]する必要があるので注意してください) 16 | */ 17 | @Component 18 | @ConfigurationProperties(prefix = "extension.messages") 19 | class ResourceBundleHandler(private val encoding: String = "UTF-8") { 20 | private val bundleMap: MutableMap = mutableMapOf() 21 | 22 | /** 23 | * 指定されたメッセージソースのResourceBundleを返します。 24 | * 25 | * basenameに拡張子(.properties)を含める必要はありません。 26 | */ 27 | fun get(basename: String): ResourceBundle = 28 | get(basename, Locale.getDefault()) 29 | 30 | @Synchronized 31 | fun get(basename: String, locale: Locale): ResourceBundle { 32 | bundleMap.putIfAbsent(keyname(basename, locale), ResourceBundleFactory.create(basename, locale, encoding)) 33 | return bundleMap[keyname(basename, locale)]!! 34 | } 35 | 36 | private fun keyname(basename: String, locale: Locale): String = 37 | basename + "_" + locale.toLanguageTag() 38 | 39 | /** 40 | * 指定されたメッセージソースのラベルキー、値のMapを返します。 41 | * 42 | * basenameに拡張子(.properties)を含める必要はありません。 43 | */ 44 | fun labels(basename: String): Map = 45 | labels(basename, Locale.getDefault()) 46 | 47 | fun labels(basename: String, locale: Locale): Map { 48 | val bundle = get(basename, locale) 49 | return bundle.keySet().associate { Pair(it, bundle.getString(it)) } 50 | } 51 | 52 | } 53 | 54 | /** 55 | * SpringのMessageSource経由でResourceBundleを取得するFactory。 56 | * 57 | * プロパティファイルのエンコーディング指定を可能にしています。 58 | */ 59 | class ResourceBundleFactory : ResourceBundleMessageSource() { 60 | companion object { 61 | /** ResourceBundleを取得します。 */ 62 | fun create(basename: String, locale: Locale, encoding: String): ResourceBundle { 63 | val factory = ResourceBundleFactory() 64 | factory.defaultEncoding = encoding 65 | return Optional.ofNullable(factory.getResourceBundle(basename, locale)) 66 | .orElseThrow { IllegalArgumentException("指定されたbasenameのリソースファイルは見つかりませんでした。[]") } 67 | } 68 | } 69 | } 70 | 71 | -------------------------------------------------------------------------------- /src/main/kotlin/sample/model/account/Login.kt: -------------------------------------------------------------------------------- 1 | package sample.model.account 2 | 3 | import org.springframework.security.crypto.password.PasswordEncoder 4 | import sample.ErrorKeys 5 | import sample.context.Dto 6 | import sample.context.orm.OrmActiveRecord 7 | import sample.context.orm.OrmRepository 8 | import sample.model.constraints.IdStr 9 | import sample.model.constraints.Password 10 | import java.util.* 11 | import javax.persistence.Entity 12 | import javax.persistence.Id 13 | 14 | /** 15 | * 口座ログインを表現します。 16 | * low: サンプル用に必要最低限の項目だけ 17 | */ 18 | @Entity 19 | data class Login( 20 | /** 口座ID */ 21 | @Id 22 | @field:IdStr 23 | var id: String? = null, 24 | /** ログインID */ 25 | @field:IdStr 26 | var loginId: String, 27 | /** パスワード(暗号化済) */ 28 | @field:Password 29 | var password: String 30 | ) : OrmActiveRecord() { 31 | 32 | /** ログインIDを変更します。 */ 33 | fun change(rep: OrmRepository, p: ChgLoginId): Login { 34 | validate { v -> 35 | val exists = rep.tmpl().get("FROM Login l WHERE l.id<>?1 AND l.loginId=?2", id!!, p.loginId!!).isPresent 36 | v.checkField(!exists, "loginId", ErrorKeys.DuplicateId) 37 | } 38 | return p.bind(this).update(rep) 39 | } 40 | 41 | /** パスワードを変更します。 */ 42 | fun change(rep: OrmRepository, encoder: PasswordEncoder, p: ChgPassword): Login = 43 | p.bind(this, encoder.encode(p.plainPassword)).update(rep) 44 | 45 | companion object { 46 | private const val serialVersionUID = 1L 47 | 48 | /** ログイン情報を取得します。 */ 49 | fun get(rep: OrmRepository, id: String): Optional = 50 | rep.get(Login::class.java, id) 51 | 52 | /** ログイン情報を取得します。 */ 53 | fun getByLoginId(rep: OrmRepository, loginId: String): Optional = 54 | Optional.ofNullable(loginId).flatMap({ rep.tmpl().get("FROM Login l WHERE loginId=?1", it) }) 55 | 56 | /** ログイン情報を取得します。(例外付) */ 57 | fun load(rep: OrmRepository, id: String): Login = 58 | rep.load(Login::class.java, id) 59 | } 60 | 61 | } 62 | 63 | /** ログインID変更パラメタ low: 基本はユースケース単位で切り出す */ 64 | data class ChgLoginId( 65 | @IdStr 66 | val loginId: String? = null 67 | ) : Dto { 68 | fun bind(m: Login): Login { 69 | m.loginId = loginId!! 70 | return m 71 | } 72 | } 73 | 74 | /** パスワード変更パラメタ */ 75 | data class ChgPassword( 76 | @Password 77 | val plainPassword: String? = null 78 | ) : Dto { 79 | fun bind(m: Login, password: String): Login { 80 | m.password = password 81 | return m 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/main/kotlin/sample/context/orm/OrmUtils.kt: -------------------------------------------------------------------------------- 1 | package sample.context.orm 2 | 3 | import java.util.regex.Pattern 4 | import java.util.regex.Pattern.CASE_INSENSITIVE 5 | import java.util.regex.Pattern.compile 6 | import org.apache.commons.lang3.StringUtils.replaceFirst 7 | import org.springframework.data.jpa.repository.support.JpaEntityInformationSupport 8 | import java.io.Serializable 9 | import org.springframework.data.jpa.repository.support.JpaEntityInformation 10 | import org.springframework.util.Assert 11 | import org.springframework.util.StringUtils 12 | import javax.persistence.EntityManager 13 | 14 | 15 | 16 | /** 17 | * Orm 関連のユーティリティを提供します。 18 | */ 19 | object OrmUtils { 20 | 21 | private val IDENTIFIER = "[._[\\P{Z}&&\\P{Cc}&&\\P{Cf}&&\\P{P}]]+" 22 | private val IDENTIFIER_GROUP = String.format("(%s)", IDENTIFIER) 23 | private val VARIABLE_NAME_GROUP_INDEX = 4 24 | private val SIMPLE_COUNT_VALUE = "$2" 25 | private val COMPLEX_COUNT_VALUE = "$3$6" 26 | private val COUNT_REPLACEMENT_TEMPLATE = "select count(%s) $5$6$7" 27 | private val ORDER_BY_PART = "(?iu)\\s+order\\s+by\\s+.*$" 28 | private val COUNT_MATCH: Pattern 29 | get() { 30 | val builder = StringBuilder() 31 | builder.append("(select\\s+((distinct )?(.+?)?)\\s+)?(from\\s+") 32 | builder.append(IDENTIFIER) 33 | builder.append("(?:\\s+as)?\\s+)") 34 | builder.append(IDENTIFIER_GROUP) 35 | builder.append("(.*)") 36 | return compile(builder.toString(), CASE_INSENSITIVE) 37 | } 38 | 39 | /** 指定したクラスのエンティティ情報を返します ( ID 概念含む ) */ 40 | @Suppress("UNCHECKED_CAST") 41 | fun entityInformation(em: EntityManager, clazz: Class): JpaEntityInformation = 42 | JpaEntityInformationSupport.getEntityInformation(clazz, em) as JpaEntityInformation 43 | 44 | /** カウントクエリを生成します。 see QueryUtils#createCountQueryFor */ 45 | fun createCountQueryFor(originalQuery: String): String { 46 | Assert.hasText(originalQuery, "OriginalQuery must not be null or empty!") 47 | val matcher = COUNT_MATCH.matcher(originalQuery) 48 | val variable = if (matcher.matches()) matcher.group(VARIABLE_NAME_GROUP_INDEX) else null 49 | val useVariable = (variable != null && StringUtils.hasText(variable) && !variable.startsWith("new") 50 | && !variable.startsWith("count(") && !variable.contains(",")) 51 | 52 | val replacement = if (useVariable) SIMPLE_COUNT_VALUE else COMPLEX_COUNT_VALUE 53 | val countQuery = matcher.replaceFirst(String.format(COUNT_REPLACEMENT_TEMPLATE, replacement)) 54 | return countQuery.replaceFirst(ORDER_BY_PART.toRegex(), "") 55 | } 56 | 57 | } -------------------------------------------------------------------------------- /src/main/kotlin/sample/model/BusinessDayHandler.kt: -------------------------------------------------------------------------------- 1 | package sample.model 2 | 3 | import org.springframework.beans.factory.ObjectProvider 4 | import org.springframework.cache.annotation.CacheEvict 5 | import org.springframework.cache.annotation.Cacheable 6 | import org.springframework.stereotype.Component 7 | import org.springframework.transaction.annotation.Transactional 8 | import sample.context.SimpleObjectProvider 9 | import sample.context.Timestamper 10 | import sample.context.orm.DefaultRepository 11 | import sample.model.master.Holiday 12 | import sample.model.master.RegHoliday 13 | import sample.util.DateUtils 14 | import java.time.LocalDate 15 | import java.util.* 16 | 17 | /** 18 | * ドメインに依存する営業日関連のユーティリティハンドラ。 19 | */ 20 | @Component 21 | class BusinessDayHandler( 22 | private val time: Timestamper, 23 | private var holidayAccessor: ObjectProvider 24 | ) { 25 | 26 | /** 営業日を返します。 */ 27 | fun day(): LocalDate = time.day() 28 | 29 | /** 営業日を返します。 */ 30 | fun day(daysToAdd: Int): LocalDate { 31 | var day = day() 32 | if (0 < daysToAdd) { 33 | for (i in 0 until daysToAdd) 34 | day = dayNext(day) 35 | } else if (daysToAdd < 0) { 36 | for (i in 0 until -daysToAdd) 37 | day = dayPrevious(day) 38 | } 39 | return day 40 | } 41 | 42 | private fun dayNext(baseDay: LocalDate): LocalDate { 43 | var day = baseDay.plusDays(1) 44 | while (isHolidayOrWeekDay(day)) { 45 | day = day.plusDays(1) 46 | } 47 | return day 48 | } 49 | 50 | private fun dayPrevious(baseDay: LocalDate): LocalDate { 51 | var day = baseDay.minusDays(1) 52 | while (isHolidayOrWeekDay(day)) 53 | day = day.minusDays(1) 54 | return day 55 | } 56 | 57 | /** 祝日もしくは週末時はtrue。 */ 58 | private fun isHolidayOrWeekDay(day: LocalDate): Boolean = 59 | DateUtils.isWeekend(day) || isHoliday(day) 60 | 61 | private fun isHoliday(day: LocalDate): Boolean = 62 | when (holidayAccessor.ifAvailable) { 63 | null -> false 64 | else -> holidayAccessor.getObject().getHoliday(day).isPresent 65 | } 66 | 67 | } 68 | 69 | /** 祝日マスタを検索/登録するアクセサ。 */ 70 | @Component 71 | class HolidayAccessor(val rep: DefaultRepository) { 72 | 73 | @Transactional(DefaultRepository.BeanNameTx) 74 | @Cacheable(cacheNames = ["HolidayAccessor.getHoliday"]) 75 | fun getHoliday(day: LocalDate): Optional = 76 | Holiday.get(rep, day) 77 | 78 | @Transactional(DefaultRepository.BeanNameTx) 79 | @CacheEvict(cacheNames = ["HolidayAccessor.getHoliday"], allEntries = true) 80 | fun register(rep: DefaultRepository, p: RegHoliday) = 81 | Holiday.register(rep, p) 82 | 83 | } 84 | 85 | -------------------------------------------------------------------------------- /src/main/kotlin/sample/context/report/ReportHandler.kt: -------------------------------------------------------------------------------- 1 | package sample.context.report 2 | 3 | import org.springframework.stereotype.Component 4 | import sample.context.report.csv.CsvWriter 5 | import sample.context.report.csv.CsvWrite 6 | import sample.context.report.csv.CsvLayout 7 | import sample.context.report.csv.CsvReader 8 | import sample.context.report.csv.CsvReader.CsvReadLine 9 | import sample.InvocationException 10 | import java.io.* 11 | 12 | 13 | /** 14 | * 帳票処理を行います。 15 | * low: サンプルではCSVのみ提供します。実際は固定長/Excel/PDFなどの取込/出力なども取り扱う可能性があります。 16 | * low: ExcelはPOI、PDFはJasperReportの利用が一般的です。(商用製品を利用するのもおすすめです) 17 | */ 18 | @Component 19 | class ReportHandler { 20 | 21 | /** 22 | * 帳票をオンメモリ上でbyte配列にします。 23 | * 24 | * 大量データ等、パフォーマンス上のボトルネックが無いときはこちらの処理内でレポートを書き出しするようにしてください。 25 | */ 26 | fun convert(logic: ReportToByte): ByteArray { 27 | val out = ByteArrayOutputStream() 28 | try { 29 | DataOutputStream(out).use { 30 | logic.execute(out) 31 | return out.toByteArray() 32 | } 33 | } catch (e: IOException) { 34 | throw InvocationException(e) 35 | } 36 | 37 | } 38 | 39 | /** 40 | * CSVファイルを読み込んで行単位に処理を行います。 41 | * @param data 読み込み対象となるバイナリ 42 | * @param logic 行単位の読込処理 43 | */ 44 | fun readCsv(data: ByteArray, logic: CsvReadLine) { 45 | CsvReader.of(data).read(logic) 46 | } 47 | 48 | fun readCsv(data: ByteArray, layout: CsvLayout, logic: CsvReadLine) { 49 | CsvReader.of(data, layout).read(logic) 50 | } 51 | 52 | /** 53 | * CSVストリームを読み込んで行単位に処理を行います。 54 | * @param ins 読み込み対象となるInputStream 55 | * @param logic 行単位の読込処理 56 | */ 57 | fun readCsv(ins: InputStream, logic: CsvReadLine) { 58 | CsvReader.of(ins).read(logic) 59 | } 60 | 61 | fun readCsv(ins: InputStream, layout: CsvLayout, logic: CsvReadLine) { 62 | CsvReader.of(ins, layout).read(logic) 63 | } 64 | 65 | /** 66 | * CSVファイルを書き出しします。 67 | * @param file 出力対象となるファイル 68 | * @param logic 書出処理 69 | */ 70 | fun writeCsv(file: File, logic: CsvWrite) { 71 | CsvWriter.of(file).write(logic) 72 | } 73 | 74 | fun writeCsv(file: File, layout: CsvLayout, logic: CsvWrite) { 75 | CsvWriter.of(file, layout).write(logic) 76 | } 77 | 78 | /** 79 | * CSVストリームに書き出しします。 80 | * @param out 出力Stream 81 | * @param logic 書出処理 82 | */ 83 | fun writeCsv(out: OutputStream, logic: CsvWrite) { 84 | CsvWriter.of(out).write(logic) 85 | } 86 | 87 | fun writeCsv(out: OutputStream, layout: CsvLayout, logic: CsvWrite) { 88 | CsvWriter.of(out, layout).write(logic) 89 | } 90 | 91 | /** レポートをバイナリ形式で OutputStream へ書き出します。 */ 92 | interface ReportToByte { 93 | fun execute(out: OutputStream) 94 | } 95 | 96 | } 97 | -------------------------------------------------------------------------------- /src/main/kotlin/sample/ValidationException.kt: -------------------------------------------------------------------------------- 1 | package sample 2 | 3 | import java.io.Serializable 4 | 5 | /** 6 | * 審査例外を表現します。 7 | *

ValidationExceptionは入力例外や状態遷移例外等の復旧可能な審査例外です。 8 | * その性質上ログ等での出力はWARNレベル(ERRORでなく)で行われます。 9 | *

審査例外はグローバル/フィールドスコープで複数保有する事が可能です。複数件の例外を取り扱う際は 10 | * Warnsを利用して初期化してください。 11 | */ 12 | class ValidationException : RuntimeException { 13 | val warns: Warns 14 | val list: List 15 | get() = warns.list 16 | 17 | constructor(warns: Warns) : super(warns.head()?.message) { 18 | this.warns = warns 19 | } 20 | 21 | constructor(message: String, field: String? = null, messageArgs: Array = arrayOf()) : super(message) { 22 | this.warns = Warns.init(message, field, messageArgs) 23 | } 24 | 25 | companion object { 26 | private const val serialVersionUID: Long = 1 27 | } 28 | } 29 | 30 | class Warns(val list: MutableList = mutableListOf()) : Serializable { 31 | 32 | fun head(): Warn? = list.firstOrNull() 33 | fun nonEmpty(): Boolean = !list.isEmpty() 34 | 35 | fun add(message: String, field: String? = null, messageArgs: Array = arrayOf()): Warns { 36 | list.add(Warn(message, field, messageArgs)) 37 | return this 38 | } 39 | 40 | companion object { 41 | private const val serialVersionUID: Long = 1 42 | 43 | fun init(message: String? = null, field: String? = null, messageArgs: Array = arrayOf()): Warns = 44 | when { 45 | message.isNullOrBlank() -> Warns() 46 | else -> Warns().add(message.orEmpty(), field, messageArgs) 47 | } 48 | } 49 | } 50 | 51 | class Warn( 52 | /** 審査例外メッセージ */ 53 | val message: String, 54 | /** 審査例外フィールドキー */ 55 | val field: String? = null, 56 | /** 審査例外メッセージ引数 */ 57 | val messageArgs: Array = arrayOf()) : Serializable { 58 | /** フィールドに従属しないグローバル例外時はtrue */ 59 | val global: Boolean = field.isNullOrBlank() 60 | 61 | companion object { 62 | private const val serialVersionUID: Long = 1 63 | } 64 | } 65 | 66 | /** 審査例外で用いるメッセージキー定数 */ 67 | interface ErrorKeys { 68 | companion object { 69 | /** サーバー側で問題が発生した可能性があります */ 70 | const val Exception = "error.Exception" 71 | /** 情報が見つかりませんでした */ 72 | const val EntityNotFound = "error.EntityNotFoundException" 73 | /** ログイン状態が有効ではありません */ 74 | const val Authentication = "error.Authentication" 75 | /** 対象機能の利用が認められていません */ 76 | const val AccessDenied = "error.AccessDeniedException" 77 | 78 | /** ログインに失敗しました */ 79 | const val Login = "error.login" 80 | /** 既に登録されているIDです */ 81 | const val DuplicateId = "error.duplicateId" 82 | 83 | /** 既に処理済の情報です */ 84 | const val ActionUnprocessing = "error.ActionStatusType.unprocessing" 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/main/kotlin/sample/context/AppSetting.kt: -------------------------------------------------------------------------------- 1 | package sample.context 2 | 3 | import org.hibernate.criterion.MatchMode 4 | import sample.context.orm.OrmActiveRecord 5 | import sample.context.orm.OrmRepository 6 | import sample.model.constraints.OutlineEmpty 7 | import java.math.BigDecimal 8 | import java.util.* 9 | import javax.persistence.Entity 10 | import javax.persistence.Id 11 | import javax.validation.constraints.Size 12 | 13 | 14 | /** 15 | * アプリケーション設定情報を表現します。 16 | *

事前に初期データが登録される事を前提とし、値の変更のみ許容します。 17 | */ 18 | @Entity 19 | data class AppSetting( 20 | /** 設定ID */ 21 | @Id 22 | @field:Size(max = 120) 23 | val id: String, 24 | /** 区分 */ 25 | @field:Size(max = 60) 26 | var category: String, 27 | /** 概要 */ 28 | @field:Size(max = 1300) 29 | var outline: String, 30 | /** 値 */ 31 | @field:Size(max = 1300) 32 | var value: String? 33 | ) : OrmActiveRecord() { 34 | 35 | /** 設定情報値を取得します。 */ 36 | fun str(): String = value!! 37 | 38 | fun str(defaultValue: String): String { 39 | return if (value == null) defaultValue else value!! 40 | } 41 | 42 | fun intValue(): Int = value!!.toInt() 43 | fun intValue(defaultValue: Int): Int = 44 | if (value == null) defaultValue else Integer.parseInt(value) 45 | 46 | fun longValue(): Long = value!!.toLong() 47 | fun longValue(defaultValue: Long): Long = 48 | if (value == null) defaultValue else value!!.toLong() 49 | 50 | fun bool(): Boolean = value!!.toBoolean() 51 | fun bool(defaultValue: Boolean): Boolean = 52 | if (value == null) defaultValue else value!!.toBoolean() 53 | 54 | fun decimal(): BigDecimal = BigDecimal(value) 55 | fun decimal(defaultValue: BigDecimal): BigDecimal = 56 | if (value == null) defaultValue else BigDecimal(value) 57 | 58 | /** 設定情報値を設定します。 */ 59 | fun update(rep: OrmRepository, value: String): AppSetting { 60 | this.value = value 61 | return update(rep) 62 | } 63 | 64 | companion object { 65 | private const val serialVersionUID: Long = 1 66 | 67 | /** 設定情報を取得します。 */ 68 | fun get(rep: OrmRepository, id: String): Optional { 69 | return rep.get(AppSetting::class.java, id) 70 | } 71 | 72 | fun load(rep: OrmRepository, id: String): AppSetting { 73 | return rep.load(AppSetting::class.java, id) 74 | } 75 | 76 | /** アプリケーション設定情報を検索します。 */ 77 | fun find(rep: OrmRepository, p: FindAppSetting): List { 78 | return rep.tmpl().findByCriteria(AppSetting::class.java) { criteria -> 79 | criteria 80 | .like(arrayOf("id", "category", "outline"), p.keyword, MatchMode.ANYWHERE) 81 | .result() 82 | } 83 | } 84 | } 85 | } 86 | 87 | data class FindAppSetting( 88 | @field:OutlineEmpty 89 | val keyword: String? = null 90 | ) 91 | -------------------------------------------------------------------------------- /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 | typeMismatch.java.time.LocalTime=時間を入力して下さい。 26 | 27 | # -- Errors [Exception] 28 | error.Exception=サーバー側で問題が発生した可能性があります。 29 | error.EntityNotFoundException=情報が見つかりませんでした。 30 | error.OptimisticLockingFailure=対象情報は他の利用者によって更新されました。 31 | error.Authentication=ログイン状態が有効ではありません。 32 | error.AccessDeniedException=対象機能の利用が認められていません。 33 | error.ServletRequestBinding=適切でない本文フォーマットの要求を受け付けました。 34 | error.HttpMessageNotReadable=適切でない本文フォーマットの要求を受け付けました。 35 | error.HttpMediaTypeNotAcceptable=適切でないメディアタイプの要求を受け付けました。 36 | 37 | # -- Errors [Domain] 38 | error.domain.accountId={max}文字以内で入力してください。 39 | error.domain.idStr={max}文字以内で入力してください。 40 | error.domain.ISODate=日付フォーマットで入力して下さい。 41 | error.domain.ISODateTime=日時フォーマットで入力して下さい。 42 | error.domain.ISOTime=時間フォーマットで入力して下さい。 43 | error.domain.year=yyyyフォーマットで入力して下さい。 44 | error.domain.currency={max}文字の英字を入力してください。 45 | error.domain.mail=正しいメールフォーマットで入力して下さい。 46 | error.domain.category={max}文字以内で入力してください。 47 | error.domain.name={max}文字以内で入力してください。 48 | error.domain.outline={max}文字以内で入力して下さい。 49 | error.domain.description={max}文字以内で入力して下さい。 50 | 51 | error.domain.amount={integer}桁(小数部{fraction}桁)以内の数字を入力してください。 52 | error.domain.absAmount=マイナスを含めない{integer}桁(小数部{fraction}桁)以内の数字を入力してください。 53 | error.domain.AbsAmount.zero=マイナスを含めない数字を入力してください。 54 | error.domain.ratio={integer}桁(小数部{fraction}桁)以内の数字を入力してください。 55 | 56 | # -- Errors [Application] 57 | 58 | error.login=ログインに失敗しました。 59 | error.duplicateId=既に登録されているIDです。 60 | 61 | error.ActionStatusType.unprocessing=既に処理済の情報です。 62 | error.TimePoint.beforeEqualsDay=現在日以降を入力してください。 63 | error.TimePoint.afterEqualsDay=現在日以前を入力してください。 64 | 65 | error.Cashflow.realizeDay=受渡日を迎えていないため実現できません。 66 | error.Cashflow.beforeEqualsDay=既に受渡日を迎えています。 67 | error.CashInOut.withdrawAmount=出金可能額を超えています。 68 | error.CashInOut.afterEqualsDay=未到来の発生日です。 69 | error.CashInOut.beforeEqualsDay=既に発生日を迎えています。 70 | -------------------------------------------------------------------------------- /src/main/kotlin/sample/model/asset/CashBalance.kt: -------------------------------------------------------------------------------- 1 | package sample.model.asset 2 | 3 | import org.springframework.format.annotation.DateTimeFormat 4 | import sample.context.orm.OrmActiveRecord 5 | import sample.context.orm.OrmRepository 6 | import sample.model.constraints.* 7 | import sample.util.Calculator 8 | import java.math.BigDecimal 9 | import java.math.RoundingMode 10 | import java.time.LocalDate 11 | import java.time.LocalDateTime 12 | import javax.persistence.Entity 13 | import javax.persistence.GeneratedValue 14 | import javax.persistence.Id 15 | import javax.validation.constraints.NotNull 16 | 17 | /** 18 | * 口座残高を表現します。 19 | */ 20 | @Entity 21 | data class CashBalance( 22 | /** ID */ 23 | @Id 24 | @GeneratedValue 25 | var id: Long? = null, 26 | /** 口座ID */ 27 | @field:IdStr 28 | val accountId: String, 29 | /** 基準日 */ 30 | @field:NotNull 31 | @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) 32 | val baseDay: LocalDate, 33 | /** 通貨 */ 34 | @field:Currency 35 | val currency: String, 36 | /** 金額 */ 37 | @field:Amount 38 | var amount: BigDecimal, 39 | /** 更新日 */ 40 | @field:NotNull 41 | @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) 42 | var updateDate: LocalDateTime 43 | ) : OrmActiveRecord() { 44 | 45 | /** 46 | * 残高へ指定した金額を反映します。 47 | * low ここではCurrencyを使っていますが、実際の通貨桁数や端数処理定義はDBや設定ファイル等で管理されます。 48 | */ 49 | fun add(rep: OrmRepository, addAmount: BigDecimal): CashBalance { 50 | val scale = java.util.Currency.getInstance(currency).defaultFractionDigits 51 | this.amount = Calculator.of(amount).scale(scale, RoundingMode.DOWN) 52 | .add(addAmount) 53 | .decimal() 54 | return update(rep) 55 | } 56 | 57 | companion object { 58 | private const val serialVersionUID = 1L 59 | 60 | /** 61 | * 指定口座の残高を取得します。(存在しない時は繰越保存後に取得します) 62 | * low: 複数通貨の適切な考慮や細かい審査は本筋でないので割愛。 63 | */ 64 | fun getOrNew(rep: OrmRepository, accountId: String, currency: String): CashBalance { 65 | val baseDay = rep.dh().time.day() 66 | val m = rep.tmpl().get( 67 | "FROM CashBalance c WHERE c.accountId=?1 AND c.currency=?2 AND c.baseDay=?3 ORDER BY c.baseDay DESC", 68 | accountId, currency, baseDay) 69 | return m.orElseGet { create(rep, accountId, currency) } 70 | } 71 | 72 | private fun create(rep: OrmRepository, accountId: String, currency: String): CashBalance { 73 | val now = rep.dh().time.tp() 74 | val m = rep.tmpl().get( 75 | "FROM CashBalance c WHERE c.accountId=?1 AND c.currency=?2 ORDER BY c.baseDay DESC", 76 | accountId, currency) 77 | if (m.isPresent) { // 残高繰越 78 | val prev = m.get() 79 | return CashBalance(accountId = accountId, baseDay = now.day, currency = currency, amount = prev.amount, updateDate = now.date).save(rep) 80 | } else { 81 | return CashBalance(accountId = accountId, baseDay = now.day, currency = currency, amount = BigDecimal.ZERO, updateDate = now.date).save(rep) 82 | } 83 | } 84 | } 85 | } -------------------------------------------------------------------------------- /src/main/kotlin/sample/ApplicationSecurityConfig.kt: -------------------------------------------------------------------------------- 1 | package sample 2 | 3 | import org.springframework.beans.factory.ObjectProvider 4 | import org.springframework.beans.factory.annotation.Qualifier 5 | import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty 6 | import org.springframework.boot.autoconfigure.web.servlet.DispatcherServletAutoConfiguration 7 | import org.springframework.boot.autoconfigure.web.servlet.DispatcherServletRegistrationBean 8 | import org.springframework.boot.context.properties.EnableConfigurationProperties 9 | import org.springframework.context.MessageSource 10 | import org.springframework.context.annotation.Bean 11 | import org.springframework.context.annotation.Configuration 12 | import org.springframework.context.annotation.Import 13 | import org.springframework.core.annotation.Order 14 | import org.springframework.security.authentication.AuthenticationManager 15 | import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity 16 | import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity 17 | import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder 18 | import org.springframework.security.crypto.password.PasswordEncoder 19 | import org.springframework.web.cors.CorsConfiguration 20 | import org.springframework.web.cors.UrlBasedCorsConfigurationSource 21 | import org.springframework.web.filter.CorsFilter 22 | import sample.context.actor.ActorSession 23 | import sample.context.security.* 24 | 25 | 26 | /** 27 | * アプリケーションのセキュリティ定義を表現します。 28 | */ 29 | @Configuration 30 | @EnableConfigurationProperties(SecurityProperties::class) 31 | class ApplicationSecurityConfig { 32 | 33 | /** 34 | * パスワード用のハッシュ(BCrypt)エンコーダー。 35 | * low: きちんとやるのであれば、strengthやSecureRandom使うなど外部切り出し含めて検討してください 36 | */ 37 | @Bean 38 | fun passwordEncoder(): PasswordEncoder = BCryptPasswordEncoder() 39 | 40 | /** CORS全体適用 */ 41 | @Bean 42 | @ConditionalOnProperty(prefix = "extension.security.cors", name = arrayOf("enabled"), matchIfMissing = false) 43 | fun corsFilter(props: SecurityProperties): CorsFilter { 44 | val source = UrlBasedCorsConfigurationSource() 45 | val config = CorsConfiguration() 46 | config.allowCredentials = props.cors.allowCredentials 47 | config.addAllowedOrigin(props.cors.allowedOrigin) 48 | config.addAllowedHeader(props.cors.allowedHeader) 49 | config.addAllowedMethod(props.cors.allowedMethod) 50 | config.maxAge = props.cors.maxAge 51 | source.registerCorsConfiguration(props.cors.path, config) 52 | return CorsFilter(source) 53 | } 54 | 55 | /** Spring Security を用いた API 認証/認可定義を表現します。 */ 56 | @Configuration 57 | @EnableWebSecurity 58 | @EnableGlobalMethodSecurity(prePostEnabled = true, proxyTargetClass = true) 59 | @Import(SecurityConfigurer::class, SecurityProvider::class, SecurityEntryPoint::class, LoginHandler::class, SecurityActorFinder::class) 60 | @Order(org.springframework.boot.autoconfigure.security.SecurityProperties.BASIC_AUTH_ORDER) 61 | internal class AuthSecurityConfig { 62 | 63 | /** Spring Security のカスタム認証プロセス管理コンポーネント。 */ 64 | @Bean 65 | fun authenticationManager(securityConfigurer: SecurityConfigurer): AuthenticationManager = 66 | securityConfigurer.authenticationManagerBean() 67 | 68 | } 69 | 70 | } 71 | 72 | -------------------------------------------------------------------------------- /src/main/kotlin/sample/usecase/SecurityService.kt: -------------------------------------------------------------------------------- 1 | package sample.usecase 2 | 3 | import org.springframework.context.annotation.Bean 4 | import org.springframework.context.annotation.Configuration 5 | import org.springframework.security.core.authority.SimpleGrantedAuthority 6 | import org.springframework.security.core.userdetails.UsernameNotFoundException 7 | import sample.ErrorKeys 8 | import sample.context.security.ActorDetails 9 | import sample.context.security.SecurityAdminService 10 | import sample.context.security.SecurityUserService 11 | import sample.usecase.admin.MasterAdminService 12 | import sample.util.ConvertUtils 13 | import java.util.* 14 | 15 | 16 | /** 17 | * SpringSecurityのユーザアクセスコンポーネントを定義します。 18 | */ 19 | @Configuration 20 | class SecurityService { 21 | 22 | /** 一般利用者情報を提供します。(see SecurityActorFinder) */ 23 | @Bean 24 | fun securityUserService(service: AccountService): SecurityUserService = 25 | object : SecurityUserService { 26 | /** 27 | * 以下の手順で利用口座を特定します。 28 | * 29 | * ログインID(全角は半角に自動変換)に合致するログイン情報があるか 30 | * 口座IDに合致する有効な口座情報があるか 31 | * 32 | * 一般利用者には「ROLE_USER」の権限が自動で割り当てられます。 33 | */ 34 | override fun loadUserByUsername(username: String): ActorDetails = 35 | Optional.ofNullable(username) 36 | .map { ConvertUtils.zenkakuToHan(it)!! } 37 | .flatMap { 38 | service.getLoginByLoginId(it).flatMap { login -> 39 | // account to actorDetails 40 | service.getAccount(login.id!!).map { account -> 41 | val authorities = listOf(SimpleGrantedAuthority("ROLE_USER")) 42 | ActorDetails(account.actor(), login.password, authorities) 43 | } 44 | } 45 | }.orElseThrow { UsernameNotFoundException(ErrorKeys.Login) } 46 | } 47 | 48 | /** 社内管理向けの利用者情報を提供します。(see SecurityActorFinder) */ 49 | @Bean 50 | fun securityAdminService(service: MasterAdminService): SecurityAdminService = 51 | object : SecurityAdminService { 52 | /** 53 | * 以下の手順で社員を特定します。 54 | * 55 | * 社員ID(全角は半角に自動変換)に合致する社員情報があるか 56 | * 社員情報に紐付く権限があるか 57 | * 58 | * 社員には「ROLE_ADMIN」の権限が自動で割り当てられます。 59 | */ 60 | override fun loadUserByUsername(username: String): ActorDetails = 61 | Optional.ofNullable(username) 62 | .map { ConvertUtils.zenkakuToHan(it)!! } 63 | .flatMap { staffId -> 64 | // staff to actorDetails 65 | service.getStaff(staffId).map { staff -> 66 | val authorities = mutableListOf(SimpleGrantedAuthority("ROLE_ADMIN")) 67 | service.findStaffAuthority(staffId).forEach { authorities.add(SimpleGrantedAuthority(it.authority)) } 68 | ActorDetails(staff.actor(), staff.password, authorities) 69 | } 70 | }.orElseThrow({ UsernameNotFoundException(ErrorKeys.Login) }) 71 | } 72 | 73 | } -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%" == "" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%" == "" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if "%ERRORLEVEL%" == "0" goto init 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto init 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :init 68 | @rem Get command-line arguments, handling Windows variants 69 | 70 | if not "%OS%" == "Windows_NT" goto win9xME_args 71 | 72 | :win9xME_args 73 | @rem Slurp the command line arguments. 74 | set CMD_LINE_ARGS= 75 | set _SKIP=2 76 | 77 | :win9xME_args_slurp 78 | if "x%~1" == "x" goto execute 79 | 80 | set CMD_LINE_ARGS=%* 81 | 82 | :execute 83 | @rem Setup the command line 84 | 85 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 86 | 87 | @rem Execute Gradle 88 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 89 | 90 | :end 91 | @rem End local scope for the variables with windows NT shell 92 | if "%ERRORLEVEL%"=="0" goto mainEnd 93 | 94 | :fail 95 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 96 | rem the _cmd.exe /c_ return code! 97 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 98 | exit /b 1 99 | 100 | :mainEnd 101 | if "%OS%"=="Windows_NT" endlocal 102 | 103 | :omega 104 | -------------------------------------------------------------------------------- /src/main/kotlin/sample/model/master/Holiday.kt: -------------------------------------------------------------------------------- 1 | package sample.model.master 2 | 3 | import org.springframework.format.annotation.DateTimeFormat 4 | import sample.context.Dto 5 | import sample.context.orm.OrmActiveMetaRecord 6 | import sample.context.orm.OrmRepository 7 | import sample.model.constraints.* 8 | import sample.util.DateUtils 9 | import java.time.LocalDate 10 | import java.time.LocalDateTime 11 | import java.util.* 12 | import javax.persistence.Entity 13 | import javax.persistence.GeneratedValue 14 | import javax.persistence.Id 15 | import javax.validation.Valid 16 | import javax.validation.constraints.NotNull 17 | 18 | 19 | /** 20 | * 休日マスタを表現します。 21 | */ 22 | @Entity 23 | data class Holiday( 24 | /** ID */ 25 | @Id 26 | @GeneratedValue 27 | var id: Long? = null, 28 | /** 休日区分 */ 29 | @field:Category 30 | val category: String, 31 | /** 休日 */ 32 | @field:NotNull 33 | @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) 34 | val day: LocalDate, 35 | /** 休日名称 */ 36 | @field:Name(max = 40) 37 | val name: String, 38 | override var createId: String? = null, 39 | override var createDate: LocalDateTime? = null, 40 | override var updateId: String? = null, 41 | override var updateDate: LocalDateTime? = null 42 | ) : OrmActiveMetaRecord() { 43 | 44 | companion object { 45 | private const val serialVersionUID = 1L 46 | val CategoryDefault = "default" 47 | 48 | /** 休日マスタを取得します。 */ 49 | fun get(rep: OrmRepository, day: LocalDate, category: String = CategoryDefault): Optional = 50 | rep.tmpl().get("FROM Holiday h WHERE h.category=?1 AND h.day=?2", category, day) 51 | 52 | /** 休日マスタを取得します。(例外付) */ 53 | @JvmOverloads 54 | fun load(rep: OrmRepository, day: LocalDate, category: String = CategoryDefault): Holiday = 55 | rep.tmpl().load("FROM Holiday h WHERE h.category=?1 AND h.day=?2", category, day) 56 | 57 | /** 休日情報を検索します。 */ 58 | fun find(rep: OrmRepository, year: Int, category: String = CategoryDefault): List = 59 | rep.tmpl().find("FROM Holiday h WHERE h.category=?1 AND h.day BETWEEN ?2 AND ?3 ORDER BY h.day", 60 | category, LocalDate.ofYearDay(year, 1), DateUtils.dayTo(year)) 61 | 62 | /** 休日マスタを登録します。 */ 63 | fun register(rep: OrmRepository, p: RegHoliday) { 64 | rep.tmpl().execute("DELETE FROM Holiday h WHERE h.category=?1 AND h.day BETWEEN ?2 AND ?3", 65 | p.category, LocalDate.ofYearDay(p.year!!, 1), DateUtils.dayTo(p.year)) 66 | p.list.forEach { v -> v.create(p).save(rep) } 67 | } 68 | } 69 | 70 | } 71 | 72 | /** 登録パラメタ */ 73 | data class RegHoliday( 74 | @field:Year 75 | val year: Int? = null, 76 | @field:CategoryEmpty 77 | val category: String = Holiday.CategoryDefault, 78 | @Valid 79 | val list: List = listOf()) : Dto 80 | 81 | /** 登録パラメタ(要素) */ 82 | data class RegHolidayItem( 83 | @field:NotNull 84 | @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) 85 | val day: LocalDate? = null, 86 | @field:Name(max = 40) 87 | val name: String? = null 88 | ) : Dto { 89 | fun create(p: RegHoliday): Holiday = 90 | Holiday( 91 | category = p.category, 92 | day = this.day!!, 93 | name = this.name!! 94 | ) 95 | } 96 | -------------------------------------------------------------------------------- /src/main/kotlin/sample/ApplicationConfig.kt: -------------------------------------------------------------------------------- 1 | package sample 2 | 3 | import com.fasterxml.jackson.databind.ObjectMapper 4 | import com.fasterxml.jackson.datatype.hibernate5.Hibernate5Module 5 | import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule 6 | import org.springframework.boot.actuate.health.AbstractHealthIndicator 7 | import org.springframework.boot.actuate.health.Health 8 | import org.springframework.boot.actuate.health.HealthIndicator 9 | import org.springframework.boot.autoconfigure.condition.ConditionalOnBean 10 | import org.springframework.context.MessageSource 11 | import org.springframework.context.annotation.Bean 12 | import org.springframework.context.annotation.Configuration 13 | import org.springframework.context.annotation.Import 14 | import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean 15 | import sample.context.AppSettingHandler 16 | import sample.context.DomainHelper 17 | import sample.context.ResourceBundleHandler 18 | import sample.context.Timestamper 19 | import sample.context.actor.ActorSession 20 | import sample.context.audit.AuditHandler 21 | import sample.context.audit.AuditPersister 22 | import sample.context.lock.IdLockHandler 23 | import sample.context.mail.MailHandler 24 | import sample.context.orm.DefaultRepository 25 | import sample.context.orm.SystemRepository 26 | import sample.context.report.ReportHandler 27 | import sample.model.BusinessDayHandler 28 | import sample.model.DataFixtures 29 | import sample.model.HolidayAccessor 30 | 31 | /** 32 | * アプリケーションにおけるBean定義を表現します。 33 | *

controller / usecase 以外のコンポーネントはこちらで明示的に定義しています。 34 | *

依存コンポーネントが多いものについては Import を併用しています。 35 | */ 36 | @Configuration 37 | class ApplicationConfig { 38 | 39 | /** インフラ層 ( context 配下) のコンポーネント定義を表現します */ 40 | @Configuration 41 | @Import(DomainHelper::class) 42 | internal class PlainConfig { 43 | @Bean 44 | fun timestamper(): Timestamper = Timestamper() 45 | 46 | @Bean 47 | fun actorSession(): ActorSession = ActorSession() 48 | 49 | @Bean 50 | fun resourceBundleHandler(): ResourceBundleHandler = ResourceBundleHandler() 51 | 52 | @Bean 53 | fun appSettingHandler(rep: SystemRepository): AppSettingHandler = AppSettingHandler(rep) 54 | 55 | @Bean 56 | fun auditHandler(session: ActorSession, persister: AuditPersister): AuditHandler = AuditHandler(session, persister) 57 | 58 | @Bean 59 | fun auditPersister(rep: SystemRepository): AuditPersister = AuditPersister(rep) 60 | 61 | @Bean 62 | fun idLockHandler(): IdLockHandler = IdLockHandler() 63 | 64 | @Bean 65 | fun mailHandler(): MailHandler = MailHandler() 66 | 67 | @Bean 68 | fun reportHandler(): ReportHandler = ReportHandler() 69 | 70 | } 71 | 72 | /** ドメイン層 ( model 配下) のコンポーネント定義を表現します */ 73 | @Configuration 74 | @Import(BusinessDayHandler::class, DataFixtures::class) 75 | internal class DomainConfig { 76 | 77 | @Bean 78 | fun holidayAccessor(rep: DefaultRepository): HolidayAccessor = 79 | HolidayAccessor(rep) 80 | } 81 | 82 | @Configuration 83 | internal class WebMVCConfig { 84 | 85 | /** HibernateのLazyLoading回避対応。 see JacksonAutoConfiguration */ 86 | @Bean 87 | fun jsonHibernate5Module(): Hibernate5Module = Hibernate5Module() 88 | 89 | /** BeanValidationメッセージのUTF-8に対応したValidator。 */ 90 | @Bean 91 | fun defaultValidator(message: MessageSource): LocalValidatorFactoryBean { 92 | val factory = LocalValidatorFactoryBean() 93 | factory.setValidationMessageSource(message) 94 | return factory 95 | } 96 | } 97 | 98 | } -------------------------------------------------------------------------------- /src/main/kotlin/sample/model/master/Staff.kt: -------------------------------------------------------------------------------- 1 | package sample.model.master 2 | 3 | import org.hibernate.criterion.MatchMode 4 | import org.springframework.security.crypto.password.PasswordEncoder 5 | import sample.ErrorKeys 6 | import sample.context.Dto 7 | import sample.context.actor.Actor 8 | import sample.context.actor.ActorRoleType 9 | import sample.context.orm.JpqlBuilder 10 | import sample.context.orm.OrmActiveRecord 11 | import sample.context.orm.OrmRepository 12 | import sample.model.constraints.IdStr 13 | import sample.model.constraints.Name 14 | import sample.model.constraints.OutlineEmpty 15 | import sample.model.constraints.Password 16 | import sample.util.Validator 17 | import java.util.* 18 | import javax.persistence.Entity 19 | import javax.persistence.Id 20 | 21 | 22 | /** 23 | * 社員を表現します。 24 | */ 25 | @Entity 26 | data class Staff( 27 | /** ID */ 28 | @Id 29 | @field:IdStr 30 | var id: String, 31 | /** 名前 */ 32 | @field:Name 33 | var name: String, 34 | /** パスワード(暗号化済) */ 35 | @field:Password 36 | var password: String 37 | ) : OrmActiveRecord() { 38 | 39 | fun actor(): Actor { 40 | return Actor(id = id, name = name, roleType = ActorRoleType.Internal) 41 | } 42 | 43 | /** パスワードを変更します。 */ 44 | fun change(rep: OrmRepository, encoder: PasswordEncoder, p: ChgPassword): Staff = 45 | p.bind(this, encoder.encode(p.plainPassword)).update(rep) 46 | 47 | /** 社員情報を変更します。 */ 48 | fun change(rep: OrmRepository, p: ChgStaff): Staff = 49 | p.bind(this).update(rep) 50 | 51 | companion object { 52 | private const val serialVersionUID = 1L 53 | 54 | /** 社員を取得します。 */ 55 | fun get(rep: OrmRepository, id: String): Optional = 56 | rep.get(Staff::class.java, id) 57 | 58 | /** 社員を取得します。(例外付) */ 59 | fun load(rep: OrmRepository, id: String): Staff = 60 | rep.load(Staff::class.java, id) 61 | 62 | /** 社員を検索します。 */ 63 | fun find(rep: OrmRepository, p: FindStaff): List { 64 | val jpql = JpqlBuilder.of("FROM Staff s") 65 | .like(listOf("id", "name"), p.keyword, MatchMode.ANYWHERE) 66 | return rep.tmpl().find(jpql.build(), *jpql.args()) 67 | } 68 | 69 | /** 社員の登録を行います。 */ 70 | fun register(rep: OrmRepository, encoder: PasswordEncoder, p: RegStaff): Staff { 71 | Validator.validate { 72 | it.checkField(!get(rep, p.id!!).isPresent, "id", ErrorKeys.DuplicateId) 73 | } 74 | return p.create(encoder.encode(p.plainPassword)).save(rep) 75 | } 76 | } 77 | 78 | } 79 | 80 | /** 検索パラメタ */ 81 | data class FindStaff( 82 | @field:OutlineEmpty 83 | val keyword: String? = null 84 | ) : Dto 85 | 86 | /** 登録パラメタ */ 87 | data class RegStaff( 88 | @field:IdStr 89 | val id: String? = null, 90 | @field:Name 91 | val name: String? = null, 92 | /** パスワード(未ハッシュ) */ 93 | @field:Password 94 | val plainPassword: String? = null 95 | ) : Dto { 96 | fun create(password: String): Staff = 97 | Staff(id = id!!, name = name!!, password = password) 98 | } 99 | 100 | /** 変更パラメタ */ 101 | data class ChgStaff( 102 | @field:Name 103 | val name: String? = null 104 | ) : Dto { 105 | fun bind(m: Staff): Staff { 106 | m.name = name!! 107 | return m 108 | } 109 | } 110 | 111 | /** パスワード変更パラメタ */ 112 | data class ChgPassword( 113 | @field:Password 114 | val plainPassword: String? = null 115 | ) : Dto { 116 | fun bind(m: Staff, password: String): Staff { 117 | m.password = password 118 | return m 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/main/kotlin/sample/context/security/SecurityActorFinder.kt: -------------------------------------------------------------------------------- 1 | package sample.context.security 2 | 3 | import org.springframework.beans.factory.ObjectProvider 4 | import org.springframework.security.core.Authentication 5 | import org.springframework.security.core.GrantedAuthority 6 | import org.springframework.security.core.context.SecurityContextHolder 7 | import org.springframework.security.core.userdetails.UserDetails 8 | import org.springframework.security.core.userdetails.UserDetailsService 9 | import sample.context.actor.Actor 10 | import java.util.* 11 | import javax.servlet.http.HttpServletRequest 12 | 13 | /** 14 | * Spring Securityで利用される認証/認可対象となるユーザ情報を提供します。 15 | */ 16 | open class SecurityActorFinder( 17 | private val props: SecurityProperties, 18 | private val userService: ObjectProvider, 19 | private val adminService: ObjectProvider 20 | ) { 21 | 22 | /** 現在のプロセス状態に応じたUserDetailServiceを返します。 */ 23 | fun detailsService(): SecurityActorService = 24 | if (props.auth.admin) adminService() else userService.getObject() 25 | 26 | private fun adminService(): SecurityAdminService = adminService.getObject() 27 | 28 | companion object { 29 | 30 | /** 31 | * 現在有効な認証情報を返します。 32 | */ 33 | fun authentication(): Optional = 34 | Optional.ofNullable(SecurityContextHolder.getContext().authentication) 35 | 36 | /** 37 | * 現在有効な利用者認証情報を返します。 38 | * 39 | * ログイン中の利用者情報を取りたいときはこちらを利用してください。 40 | */ 41 | fun actorDetails(): Optional = 42 | authentication() 43 | .filter { it.details is ActorDetails } 44 | .map { it.details as ActorDetails } 45 | } 46 | 47 | } 48 | 49 | /** 50 | * 認証/認可で用いられるユーザ情報。 51 | * 52 | * プロジェクト固有にカスタマイズしています。 53 | */ 54 | data class ActorDetails( 55 | /** ログイン中の利用者情報 */ 56 | val actor: Actor, 57 | /** 認証パスワード(暗号化済) */ 58 | private val password: String, 59 | /** 利用者の所有権限一覧 */ 60 | private val authorities: Collection) : UserDetails { 61 | 62 | val authorityIds: List = authorities.map { it.authority } 63 | 64 | fun bindRequestInfo(request: HttpServletRequest): ActorDetails { 65 | //low: L/B経由をきちんと考えるならヘッダーもチェックすること 66 | actor.source = request.getRemoteAddr() 67 | return this 68 | } 69 | 70 | override fun getUsername(): String { 71 | return actor.id 72 | } 73 | 74 | override fun getPassword(): String { 75 | return password 76 | } 77 | 78 | override fun isAccountNonExpired(): Boolean { 79 | return true 80 | } 81 | 82 | override fun isAccountNonLocked(): Boolean { 83 | return true 84 | } 85 | 86 | override fun isCredentialsNonExpired(): Boolean { 87 | return true 88 | } 89 | 90 | override fun isEnabled(): Boolean { 91 | return true 92 | } 93 | 94 | override fun getAuthorities(): Collection { 95 | return authorities 96 | } 97 | 98 | companion object { 99 | private val serialVersionUID = 1L 100 | } 101 | 102 | } 103 | 104 | /** Actorに適合したUserDetailsService */ 105 | interface SecurityActorService : UserDetailsService { 106 | /** 107 | * 与えられたログインIDを元に認証/認可対象のユーザ情報を返します。 108 | * @see org.springframework.security.core.userdetails.UserDetailsService.loadUserByUsername 109 | */ 110 | override fun loadUserByUsername(username: String): ActorDetails 111 | } 112 | 113 | /** 一般利用者向けI/F */ 114 | interface SecurityUserService : SecurityActorService 115 | 116 | /** 管理者向けI/F */ 117 | interface SecurityAdminService : SecurityActorService 118 | -------------------------------------------------------------------------------- /src/main/kotlin/sample/ApplicationDbConfig.kt: -------------------------------------------------------------------------------- 1 | package sample 2 | 3 | import org.springframework.beans.factory.ObjectProvider 4 | import javax.persistence.EntityManagerFactory 5 | import sample.context.orm.SystemRepository 6 | import org.springframework.beans.factory.annotation.Qualifier 7 | import sample.context.orm.SystemDataSourceProperties 8 | import org.springframework.orm.jpa.JpaTransactionManager 9 | import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean 10 | import sample.context.orm.DefaultRepository 11 | import sample.context.orm.DefaultDataSourceProperties 12 | import org.springframework.context.annotation.Primary 13 | import sample.context.orm.OrmInterceptor 14 | import org.springframework.boot.context.properties.EnableConfigurationProperties 15 | import org.springframework.context.annotation.Bean 16 | import org.springframework.context.annotation.Configuration 17 | import org.springframework.context.annotation.Import 18 | import sample.context.DomainHelper 19 | import sample.context.Timestamper 20 | import sample.context.actor.ActorSession 21 | import javax.sql.DataSource 22 | 23 | 24 | /** 25 | * アプリケーションのデータベース接続定義を表現します。 26 | */ 27 | @Configuration 28 | @EnableConfigurationProperties(DefaultDataSourceProperties::class, SystemDataSourceProperties::class) 29 | class ApplicationDbConfig { 30 | 31 | /** 永続化時にメタ情報の差込を行うインターセプタ */ 32 | @Bean 33 | internal fun ormInterceptor(session: ActorSession, time: Timestamper): OrmInterceptor = 34 | OrmInterceptor(session = session, time = time) 35 | 36 | /** 標準スキーマへの接続定義を表現します。 */ 37 | @Configuration 38 | internal class DefaultDbConfig { 39 | 40 | @Bean 41 | fun defaultRepository(dh: ObjectProvider, interceptor: ObjectProvider): DefaultRepository = 42 | DefaultRepository(dh, interceptor) 43 | 44 | @Bean(name = [DefaultRepository.BeanNameDs], destroyMethod = "close") 45 | @Primary 46 | fun dataSource(props: DefaultDataSourceProperties): DataSource = 47 | props.dataSource() 48 | 49 | @Bean(name = [DefaultRepository.BeanNameEmf]) 50 | @Primary 51 | fun entityManagerFactoryBean( 52 | props: DefaultDataSourceProperties, 53 | @Qualifier(DefaultRepository.BeanNameDs) dataSource: DataSource): LocalContainerEntityManagerFactoryBean = 54 | props.entityManagerFactoryBean(dataSource) 55 | 56 | @Bean(name = [DefaultRepository.BeanNameTx]) 57 | @Primary 58 | fun transactionManager( 59 | props: DefaultDataSourceProperties, 60 | @Qualifier(DefaultRepository.BeanNameEmf) emf: EntityManagerFactory): JpaTransactionManager = 61 | props.transactionManager(emf) 62 | 63 | } 64 | 65 | /** システムスキーマへの接続定義を表現します。 */ 66 | @Configuration 67 | internal class SystemDbConfig { 68 | 69 | @Bean 70 | fun systemRepository(dh: ObjectProvider, interceptor: ObjectProvider): SystemRepository = 71 | SystemRepository(dh, interceptor) 72 | 73 | @Bean(name = [SystemRepository.BeanNameDs], destroyMethod = "close") 74 | fun systemDataSource(props: SystemDataSourceProperties): DataSource = 75 | props.dataSource() 76 | 77 | @Bean(name = [SystemRepository.BeanNameEmf]) 78 | fun systemEntityManagerFactoryBean( 79 | props: SystemDataSourceProperties, 80 | @Qualifier(SystemRepository.BeanNameDs) dataSource: DataSource): LocalContainerEntityManagerFactoryBean = 81 | props.entityManagerFactoryBean(dataSource) 82 | 83 | @Bean(name = [SystemRepository.BeanNameTx]) 84 | fun systemTransactionManager( 85 | props: SystemDataSourceProperties, 86 | @Qualifier(SystemRepository.BeanNameEmf) emf: EntityManagerFactory): JpaTransactionManager = 87 | props.transactionManager(emf) 88 | 89 | } 90 | 91 | } 92 | -------------------------------------------------------------------------------- /src/main/kotlin/sample/usecase/admin/AssetAdminService.kt: -------------------------------------------------------------------------------- 1 | package sample.usecase.admin 2 | 3 | import org.slf4j.LoggerFactory 4 | import org.springframework.beans.factory.annotation.Qualifier 5 | import org.springframework.stereotype.Service 6 | import org.springframework.transaction.PlatformTransactionManager 7 | import org.springframework.transaction.annotation.Transactional 8 | import sample.context.DomainHelper 9 | import sample.context.audit.AuditHandler 10 | import sample.context.lock.IdLockHandler 11 | import sample.context.lock.LockType 12 | import sample.context.orm.DefaultRepository 13 | import sample.model.asset.CashInOut 14 | import sample.model.asset.Cashflow 15 | import sample.model.asset.FindCashInOut 16 | import sample.usecase.ServiceUtils 17 | 18 | /** 19 | * 資産ドメインに対する社内ユースケース処理。 20 | */ 21 | @Service 22 | class AssetAdminService( 23 | private val dh: DomainHelper, 24 | private val rep: DefaultRepository, 25 | @Qualifier(DefaultRepository.BeanNameTx) 26 | private val txm: PlatformTransactionManager, 27 | private val audit: AuditHandler, 28 | private val idLock: IdLockHandler 29 | ) { 30 | 31 | /** 32 | * 振込入出金依頼を検索します。 33 | * low: 口座横断的なので割り切りでREADロックはかけません。 34 | */ 35 | @Transactional(DefaultRepository.BeanNameTx) 36 | fun findCashInOut(p: FindCashInOut): List = 37 | CashInOut.find(rep, p) 38 | 39 | /** 40 | * 振込出金依頼を締めます。 41 | */ 42 | fun closingCashOut() = 43 | audit.audit("振込出金依頼の締め処理をする") { 44 | ServiceUtils.tx(txm) { closingCashOutInTx() } 45 | } 46 | 47 | private fun closingCashOutInTx() = 48 | //low: 以降の処理は口座単位でfilter束ねしてから実行する方が望ましい。 49 | //low: 大量件数の処理が必要な時はそのままやるとヒープが死ぬため、idソートでページング分割して差分実行していく。 50 | CashInOut.findUnprocessed(rep).forEach { cio -> 51 | //low: TX内のロックが適切に動くかはIdLockHandlerの実装次第。 52 | // 調整が難しいようなら大人しく営業停止時間(IdLock必要な処理のみ非活性化されている状態)を作って、 53 | // ロック無しで一気に処理してしまう方がシンプル。 54 | idLock.call(cio.accountId, LockType.Write) { 55 | try { 56 | cio.process(rep) 57 | //low: SQLの発行担保。扱う情報に相互依存が無く、セッションキャッシュはリークしがちなので都度消しておく。 58 | rep.flushAndClear() 59 | } catch (e: Exception) { 60 | log.error("[" + cio.id + "] 振込出金依頼の締め処理に失敗しました。", e) 61 | try { 62 | cio.error(rep) 63 | rep.flush() 64 | } catch (ex: Exception) { 65 | //low: 2重障害(恐らくDB起因)なのでloggerのみの記載に留める 66 | } 67 | 68 | } 69 | } 70 | } 71 | 72 | /** 73 | * キャッシュフローを実現します。 74 | * 75 | * 受渡日を迎えたキャッシュフローを残高に反映します。 76 | */ 77 | fun realizeCashflow() = 78 | audit.audit("キャッシュフローを実現する") { 79 | ServiceUtils.tx(txm) { realizeCashflowInTx() } 80 | } 81 | 82 | private fun realizeCashflowInTx() { 83 | //low: 日回し後の実行を想定 84 | val day = dh.time.day() 85 | Cashflow.findDoRealize(rep, day).forEach { cf -> 86 | idLock.call(cf.accountId, LockType.Write) { 87 | try { 88 | cf.realize(rep) 89 | rep.flushAndClear() 90 | } catch (e: Exception) { 91 | log.error("[" + cf.id + "] キャッシュフローの実現に失敗しました。", e) 92 | try { 93 | cf.error(rep) 94 | rep.flush() 95 | } catch (ex: Exception) { 96 | } 97 | } 98 | } 99 | } 100 | } 101 | 102 | companion object { 103 | val log = LoggerFactory.getLogger(AssetAdminService::class.java)!! 104 | } 105 | 106 | } -------------------------------------------------------------------------------- /src/test/kotlin/sample/model/asset/CashflowTest.kt: -------------------------------------------------------------------------------- 1 | package sample.model.asset 2 | 3 | import org.hamcrest.Matchers.* 4 | import org.junit.Assert.assertThat 5 | import org.junit.Assert.fail 6 | import org.junit.Test 7 | import java.time.LocalDate 8 | import sample.ActionStatusType 9 | import sample.ErrorKeys 10 | import sample.EntityTestSupport 11 | import sample.ValidationException 12 | import java.math.BigDecimal 13 | 14 | //low: 簡易な正常系検証が中心。依存するCashBalanceの単体検証パスを前提。 15 | class CashflowTest : EntityTestSupport() { 16 | 17 | override fun setupPreset() { 18 | targetEntities(Cashflow::class.java, CashBalance::class.java) 19 | } 20 | 21 | @Test 22 | fun キャッシュフローを登録する() { 23 | val baseDay = businessDay.day() 24 | val baseMinus1Day = businessDay.day(-1) 25 | val basePlus1Day = businessDay.day(1) 26 | tx { 27 | // 過去日付の受渡でキャッシュフロー発生 [例外] 28 | try { 29 | Cashflow.register(rep(), fixtures().cfReg("test1", "1000", baseMinus1Day)) 30 | fail() 31 | } catch (e: ValidationException) { 32 | assertThat(e.message, `is`(AssetErrorKeys.CashflowBeforeEqualsDay)) 33 | } 34 | 35 | // 翌日受渡でキャッシュフロー発生 36 | assertThat(Cashflow.register(rep(), fixtures().cfReg("test1", "1000", basePlus1Day)), 37 | allOf( 38 | hasProperty("amount", `is`(BigDecimal("1000"))), 39 | hasProperty("statusType", `is`(ActionStatusType.Unprocessed)), 40 | hasProperty("eventDay", `is`(baseDay)), 41 | hasProperty("valueDay", `is`(basePlus1Day)))) 42 | } 43 | } 44 | 45 | @Test 46 | fun 未実現キャッシュフローを実現する() { 47 | val baseDay = businessDay.day() 48 | val baseMinus1Day = businessDay.day(-1) 49 | val baseMinus2Day = businessDay.day(-2) 50 | val basePlus1Day = businessDay.day(1) 51 | tx { 52 | CashBalance.getOrNew(rep(), "test1", "JPY") 53 | 54 | // 未到来の受渡日 [例外] 55 | val cfFuture = fixtures().cf("test1", "1000", baseDay, basePlus1Day).save(rep()) 56 | try { 57 | cfFuture.realize(rep()) 58 | fail() 59 | } catch (e: ValidationException) { 60 | assertThat(e.message, `is`(AssetErrorKeys.CashflowRealizeDay)) 61 | } 62 | 63 | // キャッシュフローの残高反映検証。 0 + 1000 = 1000 64 | val cfNormal = fixtures().cf("test1", "1000", baseMinus1Day, baseDay).save(rep()) 65 | assertThat(cfNormal.realize(rep()), hasProperty("statusType", `is`(ActionStatusType.Processed))) 66 | assertThat(CashBalance.getOrNew(rep(), "test1", "JPY"), 67 | hasProperty("amount", `is`(BigDecimal("1000")))) 68 | 69 | // 処理済キャッシュフローの再実現 [例外] 70 | try { 71 | cfNormal.realize(rep()) 72 | fail() 73 | } catch (e: ValidationException) { 74 | assertThat(e.message, `is`(ErrorKeys.ActionUnprocessing)) 75 | } 76 | 77 | // 過日キャッシュフローの残高反映検証。 1000 + 2000 = 3000 78 | val cfPast = fixtures().cf("test1", "2000", baseMinus2Day, baseMinus1Day).save(rep()) 79 | assertThat(cfPast.realize(rep()), hasProperty("statusType", `is`(ActionStatusType.Processed))) 80 | assertThat(CashBalance.getOrNew(rep(), "test1", "JPY"), 81 | hasProperty("amount", `is`(BigDecimal("3000")))) 82 | } 83 | } 84 | 85 | @Test 86 | fun 発生即実現のキャッシュフローを登録する() { 87 | val baseDay = businessDay.day() 88 | tx { 89 | CashBalance.getOrNew(rep(), "test1", "JPY") 90 | // 発生即実現 91 | Cashflow.register(rep(), fixtures().cfReg("test1", "1000", baseDay)) 92 | assertThat(CashBalance.getOrNew(rep(), "test1", "JPY"), 93 | hasProperty("amount", `is`(BigDecimal("1000")))) 94 | } 95 | } 96 | 97 | } 98 | -------------------------------------------------------------------------------- /src/main/kotlin/sample/util/ConvertUtils.kt: -------------------------------------------------------------------------------- 1 | package sample.util 2 | 3 | import com.ibm.icu.text.Transliterator 4 | import java.math.BigDecimal 5 | import java.util.* 6 | 7 | /** 各種型/文字列変換をサポートします。(ICU4Jライブラリに依存しています) */ 8 | object ConvertUtils { 9 | private val ZenkakuToHan = Transliterator.getInstance("Fullwidth-Halfwidth") 10 | private val HankakuToZen = Transliterator.getInstance("Halfwidth-Fullwidth") 11 | private val KatakanaToHira = Transliterator.getInstance("Katakana-Hiragana") 12 | private val HiraganaToKana = Transliterator.getInstance("Hiragana-Katakana") 13 | 14 | /** 例外無しにLongへ変換します。(変換できない時はnull) */ 15 | fun quietlyLong(value: Any?): Long? { 16 | try { 17 | return Optional.ofNullable(value).map { java.lang.Long.parseLong(it.toString()) }.orElse(null) 18 | } catch (e: NumberFormatException) { 19 | return null 20 | } 21 | 22 | } 23 | 24 | /** 例外無しにIntegerへ変換します。(変換できない時はnull) */ 25 | fun quietlyInt(value: Any?): Int? { 26 | try { 27 | return Optional.ofNullable(value).map { Integer.parseInt(it.toString()) }.orElse(null) 28 | } catch (e: NumberFormatException) { 29 | return null 30 | } 31 | 32 | } 33 | 34 | /** 例外無しにBigDecimalへ変換します。(変換できない時はnull) */ 35 | fun quietlyDecimal(value: Any?): BigDecimal? { 36 | try { 37 | return Optional.ofNullable(value).map { BigDecimal(it.toString()) }.orElse(null) 38 | } catch (e: NumberFormatException) { 39 | return null 40 | } 41 | } 42 | 43 | /** 例外無しBooleanへ変換します。(変換できない時はfalse) */ 44 | fun quietlyBool(value: Any?): Boolean? = 45 | Optional.ofNullable(value).map { java.lang.Boolean.parseBoolean(it.toString()) }.orElse(false) 46 | 47 | /** 全角文字を半角にします。 */ 48 | fun zenkakuToHan(text: String?): String? = 49 | Optional.ofNullable(text).map { ZenkakuToHan.transliterate(it) }.orElse(null) 50 | 51 | /** 半角文字を全角にします。 */ 52 | fun hankakuToZen(text: String?): String? = 53 | Optional.ofNullable(text).map { HankakuToZen.transliterate(it) }.orElse(null) 54 | 55 | /** カタカナをひらがなにします。 */ 56 | fun katakanaToHira(text: String?): String? = 57 | Optional.ofNullable(text).map { KatakanaToHira.transliterate(it) }.orElse(null) 58 | 59 | /** 60 | * ひらがな/半角カタカナを全角カタカナにします。 61 | * 62 | * low: 実際の挙動は厳密ではないので単体検証(ConvertUtilsTest)などで事前に確認して下さい。 63 | */ 64 | fun hiraganaToZenKana(text: String?): String? = 65 | Optional.ofNullable(text).map { HiraganaToKana.transliterate(it) }.orElse(null) 66 | 67 | /** 68 | * ひらがな/全角カタカナを半角カタカナにします。 69 | * 70 | * low: 実際の挙動は厳密ではないので単体検証(ConvertUtilsTest)などで事前に確認して下さい。 71 | */ 72 | fun hiraganaToHanKana(text: String?): String? = 73 | zenkakuToHan(hiraganaToZenKana(text)) 74 | 75 | /** 指定した文字列を抽出します。(サロゲートペア対応) */ 76 | fun substring(text: String?, start: Int, end: Int): String? { 77 | if (text == null) { 78 | return null 79 | } 80 | val spos = text.offsetByCodePoints(0, start) 81 | val epos = if (text.length < end) text.length else end 82 | return text.substring(spos, text.offsetByCodePoints(spos, epos - start)) 83 | } 84 | 85 | /** 文字列を左から指定の文字数で取得します。(サロゲートペア対応) */ 86 | fun left(text: String?, len: Int): String? = 87 | substring(text, 0, len) 88 | 89 | /** 文字列を左から指定のバイト数で取得します。 */ 90 | fun leftStrict(text: String?, lenByte: Int, charset: String): String? { 91 | if (text == null) { 92 | return null 93 | } 94 | val sb = StringBuilder() 95 | try { 96 | var cnt = 0 97 | for (i in 0 until text.length) { 98 | val v = text.substring(i, i + 1) 99 | val b = v.toByteArray(charset(charset)) 100 | if (lenByte < cnt + b.size) { 101 | break 102 | } else { 103 | sb.append(v) 104 | cnt += b.size 105 | } 106 | } 107 | } catch (e: Exception) { 108 | throw IllegalArgumentException(e) 109 | } 110 | return sb.toString() 111 | } 112 | 113 | } -------------------------------------------------------------------------------- /src/main/kotlin/sample/context/report/csv/CsvWriter.kt: -------------------------------------------------------------------------------- 1 | package sample.context.report.csv 2 | 3 | import org.apache.commons.io.FileUtils 4 | import org.apache.commons.lang3.StringUtils 5 | import sample.InvocationException 6 | import java.io.* 7 | import java.util.* 8 | 9 | 10 | /** 11 | * CSVの書き出し処理をサポートするユーティリティ。 12 | */ 13 | class CsvWriter(val file: File?, val out: OutputStream?, val layout: CsvLayout = CsvLayout()) { 14 | 15 | /** ファイルリソース経由での読み込み時にtrue */ 16 | val fromFile: Boolean = file != null 17 | 18 | /** 19 | * CSV書出処理(上書き)を行います。 20 | * 21 | * CsvWrite#appendRow 呼び出すタイミングでファイルへ随時書き出しが行われます。 22 | * @param logic 23 | */ 24 | fun write(logic: CsvWrite) { 25 | var out: OutputStream? = null 26 | try { 27 | out = if (fromFile) FileUtils.openOutputStream(file) else this.out 28 | val stream = CsvStream(layout, out!!) 29 | logic.execute(stream) 30 | } catch (e: RuntimeException) { 31 | throw e 32 | } catch (e: Exception) { 33 | throw InvocationException(e) 34 | } finally { 35 | if (fromFile) { 36 | closeQuietly(out) 37 | } 38 | } 39 | } 40 | 41 | private fun closeQuietly(closeable: Closeable?) { 42 | try { 43 | closeable?.close() 44 | } catch (ioe: IOException) { 45 | } 46 | 47 | } 48 | 49 | /** 50 | * CSV書出処理(追記)を行います。 51 | * 52 | * CsvWrite#appendRow 呼び出すタイミングでファイルへ随時書き出しが行われます。 53 | * 54 | * ファイル出力時のみ利用可能です。 55 | * @param logic 56 | */ 57 | fun writeAppend(logic: CsvWrite) { 58 | if (!fromFile) 59 | throw UnsupportedOperationException("CSV書出処理の追記はファイル出力時のみサポートされます") 60 | var out: FileOutputStream? = null 61 | try { 62 | out = FileUtils.openOutputStream(file, true) 63 | val stream = CsvStream(layout, out!!) 64 | logic.execute(stream) 65 | } catch (e: RuntimeException) { 66 | throw e 67 | } catch (e: Exception) { 68 | throw InvocationException(e) 69 | } finally { 70 | closeQuietly(out) 71 | } 72 | } 73 | 74 | companion object { 75 | fun of(file: File, layout: CsvLayout = CsvLayout()): CsvWriter = 76 | CsvWriter(file, null, layout) 77 | 78 | fun of(out: OutputStream, layout: CsvLayout = CsvLayout()): CsvWriter = 79 | CsvWriter(null, out, layout) 80 | } 81 | } 82 | 83 | class CsvStream(private val layout: CsvLayout, private val out: OutputStream) { 84 | 85 | init { 86 | if (layout.hasHeader) { 87 | appendRow(layout.headerCols()) 88 | } 89 | } 90 | 91 | fun appendRow(cols: List): CsvStream { 92 | try { 93 | out.write(row(cols).toByteArray(charset(layout.charset))) 94 | out.write(layout.eolSymbols.toByteArray()) 95 | return this 96 | } catch (e: RuntimeException) { 97 | throw e 98 | } catch (e: Exception) { 99 | throw IllegalArgumentException(e) 100 | } 101 | 102 | } 103 | 104 | fun row(cols: List): String { 105 | val row = ArrayList() 106 | for (col in cols) { 107 | if (col is String) { 108 | row.add(escape(col.toString())) 109 | } else { 110 | row.add(col.toString()) 111 | } 112 | } 113 | return StringUtils.join(row, ",") 114 | } 115 | 116 | private fun escape(s: String): String { 117 | if (layout.nonQuote) { 118 | return s 119 | } 120 | val delim = layout.delim 121 | val quote = layout.quote 122 | val quoteStr = quote.toString() 123 | val eol = layout.eolSymbols 124 | return if (StringUtils.containsNone(s, delim, quote) && StringUtils.containsNone(s, eol)) { 125 | quoteStr + s + quoteStr 126 | } else { 127 | quoteStr + StringUtils.replace(s, quoteStr, quoteStr + quoteStr) + quoteStr 128 | } 129 | } 130 | } 131 | 132 | /** CSV出力処理を表現します。 */ 133 | interface CsvWrite { 134 | /** 135 | * @param stream 出力CSVインスタンス 136 | */ 137 | fun execute(stream: CsvStream) 138 | } 139 | -------------------------------------------------------------------------------- /src/main/kotlin/sample/model/account/Account.kt: -------------------------------------------------------------------------------- 1 | package sample.model.account 2 | 3 | import org.springframework.security.crypto.password.PasswordEncoder 4 | import sample.ErrorKeys 5 | import sample.ValidationException 6 | import sample.context.Dto 7 | import sample.context.actor.Actor 8 | import sample.context.actor.ActorRoleType 9 | import sample.context.orm.OrmActiveRecord 10 | import sample.context.orm.OrmRepository 11 | import sample.model.constraints.Email 12 | import sample.model.constraints.IdStr 13 | import sample.model.constraints.Name 14 | import sample.model.constraints.Password 15 | import sample.util.Validator 16 | import java.util.* 17 | import javax.persistence.Entity 18 | import javax.persistence.EnumType 19 | import javax.persistence.Enumerated 20 | import javax.persistence.Id 21 | import javax.validation.constraints.NotNull 22 | 23 | 24 | /** 25 | * 口座を表現します。 26 | * low: サンプル用に必要最低限の項目だけ 27 | */ 28 | @Entity 29 | data class Account( 30 | /** 口座ID */ 31 | @Id 32 | @field:IdStr 33 | var id: String? = null, 34 | /** 口座名義 */ 35 | @field:Name 36 | var name: String, 37 | /** メールアドレス */ 38 | @field:Email 39 | var mail: String, 40 | /** 口座状態 */ 41 | @field:NotNull 42 | @Enumerated(EnumType.STRING) 43 | var statusType: AccountStatusType 44 | ) : OrmActiveRecord() { 45 | 46 | fun actor(): Actor = 47 | Actor(id = id!!, roleType = ActorRoleType.User, name = name) 48 | 49 | /** 口座に紐付くログイン情報を取得します。 */ 50 | fun loadLogin(rep: OrmRepository): Login = 51 | Login.load(rep, id!!) 52 | 53 | /** 口座を変更します。 */ 54 | fun change(rep: OrmRepository, p: ChgAccount): Account = 55 | p.bind(this).update(rep) 56 | 57 | companion object { 58 | private const val serialVersionUID = 1L 59 | 60 | /** 口座を取得します。 */ 61 | fun get(rep: OrmRepository, id: String?): Optional = 62 | rep.get(Account::class.java, id!!) 63 | 64 | /** 有効な口座を取得します。 */ 65 | fun getValid(rep: OrmRepository, id: String): Optional = 66 | get(rep, id).filter { acc -> acc.statusType.valid() } 67 | 68 | /** 口座を取得します。(例外付) */ 69 | fun load(rep: OrmRepository, id: String): Account = 70 | rep.load(Account::class.java, id) 71 | 72 | /** 有効な口座を取得します。(例外付) */ 73 | fun loadValid(rep: OrmRepository, id: String): Account = 74 | getValid(rep, id).orElseThrow { ValidationException("error.Account.loadValid") } 75 | 76 | /** 77 | * 口座の登録を行います。 78 | * 79 | * ログイン情報も同時に登録されます。 80 | */ 81 | fun register(rep: OrmRepository, encoder: PasswordEncoder, p: RegAccount): Account { 82 | Validator.validate { v -> 83 | v.checkField(!get(rep, p.id).isPresent, "id", ErrorKeys.DuplicateId) 84 | } 85 | p.createLogin(encoder.encode(p.plainPassword)).save(rep) 86 | return p.create().save(rep) 87 | } 88 | } 89 | 90 | } 91 | 92 | enum class AccountStatusType { 93 | /** 通常 */ 94 | Normal, 95 | /** 退会 */ 96 | Withdrawal; 97 | 98 | fun valid(): Boolean = this == Normal 99 | fun invalid(): Boolean = !valid() 100 | } 101 | 102 | /** 登録パラメタ */ 103 | data class RegAccount( 104 | @field:IdStr 105 | val id: String? = null, 106 | @field:Name 107 | val name: String? = null, 108 | @field:Email 109 | val mail: String? = null, 110 | /** パスワード(未ハッシュ) */ 111 | @field:Password 112 | val plainPassword: String? = null 113 | ) : Dto { 114 | fun create(): Account = Account( 115 | id = id, 116 | name = name!!, 117 | mail = mail!!, 118 | statusType = AccountStatusType.Normal 119 | ) 120 | 121 | fun createLogin(password: String): Login = Login( 122 | id = id, 123 | loginId = id!!, 124 | password = password 125 | ) 126 | } 127 | 128 | /** 変更パラメタ */ 129 | data class ChgAccount( 130 | @field:Name 131 | val name: String? = null, 132 | @field:Email 133 | val mail: String? = null 134 | ) : Dto { 135 | fun bind(m: Account): Account { 136 | m.name = name!! 137 | m.mail = mail!! 138 | return m 139 | } 140 | } -------------------------------------------------------------------------------- /src/main/kotlin/sample/util/DateUtils.kt: -------------------------------------------------------------------------------- 1 | package sample.util 2 | 3 | import org.apache.commons.lang3.StringUtils 4 | import org.springframework.util.Assert 5 | import java.time.* 6 | import java.time.format.DateTimeFormatter 7 | import java.time.temporal.ChronoField 8 | import java.time.temporal.TemporalAccessor 9 | import java.time.temporal.TemporalQuery 10 | import java.util.* 11 | 12 | 13 | /** 14 | * 頻繁に利用される日時ユーティリティを表現します。 15 | */ 16 | object DateUtils { 17 | private val weekendQuery = WeekendQuery() 18 | 19 | /** 指定された文字列(YYYY-MM-DD)を元に日付へ変換します。 */ 20 | fun day(dayStr: String): LocalDate = 21 | dayOpt(dayStr).orElse(null) 22 | 23 | fun dayOpt(dayStr: String): Optional = 24 | if (StringUtils.isBlank(dayStr)) Optional.empty() 25 | else Optional.of(LocalDate.parse(dayStr.trim(), DateTimeFormatter.ISO_LOCAL_DATE)) 26 | 27 | /** 指定された文字列とフォーマット型を元に日時へ変換します。 */ 28 | fun date(dateStr: String, formatter: DateTimeFormatter): LocalDateTime = 29 | dateOpt(dateStr, formatter).orElse(null) 30 | 31 | fun dateOpt(dateStr: String, formatter: DateTimeFormatter): Optional = 32 | if (StringUtils.isBlank(dateStr)) Optional.empty() 33 | else Optional.of(LocalDateTime.parse(dateStr.trim(), formatter)) 34 | 35 | /** 指定された文字列とフォーマット文字列を元に日時へ変換します。 */ 36 | fun date(dateStr: String, format: String): LocalDateTime = 37 | date(dateStr, DateTimeFormatter.ofPattern(format)) 38 | 39 | fun dateOpt(dateStr: String, format: String): Optional = 40 | dateOpt(dateStr, DateTimeFormatter.ofPattern(format)) 41 | 42 | /** 指定された日付を日時へ変換します。 */ 43 | fun dateByDay(day: LocalDate): LocalDateTime = 44 | dateByDayOpt(day).orElse(null) 45 | 46 | fun dateByDayOpt(day: LocalDate): Optional = 47 | Optional.ofNullable(day).map { it.atStartOfDay() } 48 | 49 | /** 指定した日付の翌日から1msec引いた日時を返します。 */ 50 | fun dateTo(day: LocalDate): LocalDateTime = 51 | dateToOpt(day).orElse(null) 52 | 53 | fun dateToOpt(day: LocalDate): Optional = 54 | Optional.ofNullable(day).map { it.atTime(23, 59, 59) } 55 | 56 | /** 指定された日時型とフォーマット型を元に文字列(YYYY-MM-DD)へ変更します。 */ 57 | fun dayFormat(day: LocalDate): String = 58 | dayFormatOpt(day).orElse(null) 59 | 60 | fun dayFormatOpt(day: LocalDate): Optional = 61 | Optional.ofNullable(day).map { it.format(DateTimeFormatter.ISO_LOCAL_DATE) } 62 | 63 | /** 指定された日時型とフォーマット型を元に文字列へ変更します。 */ 64 | fun dateFormat(date: LocalDateTime, formatter: DateTimeFormatter): String = 65 | dateFormatOpt(date, formatter).orElse(null) 66 | 67 | fun dateFormatOpt(date: LocalDateTime, formatter: DateTimeFormatter): Optional = 68 | Optional.ofNullable(date).map { it.format(formatter) } 69 | 70 | /** 指定された日時型とフォーマット文字列を元に文字列へ変更します。 */ 71 | fun dateFormat(date: LocalDateTime, format: String): String = 72 | dateFormatOpt(date, format).orElse(null) 73 | 74 | fun dateFormatOpt(date: LocalDateTime, format: String): Optional = 75 | Optional.ofNullable(date).map { it.format(DateTimeFormatter.ofPattern(format)) } 76 | 77 | /** 日付の間隔を取得します。 */ 78 | fun between(start: LocalDate?, end: LocalDate?): Optional = 79 | if (start == null || end == null) Optional.empty() 80 | else Optional.of(Period.between(start, end)) 81 | 82 | /** 日時の間隔を取得します。 */ 83 | fun between(start: LocalDateTime?, end: LocalDateTime?): Optional = 84 | if (start == null || end == null) Optional.empty() 85 | else Optional.of(Duration.between(start, end)) 86 | 87 | /** 指定営業日が週末(土日)か判定します。(引数は必須) */ 88 | fun isWeekend(day: LocalDate): Boolean { 89 | Assert.notNull(day, "day is required.") 90 | return day.query(weekendQuery) 91 | } 92 | 93 | /** 指定年の最終日を取得します。 */ 94 | fun dayTo(year: Int): LocalDate = 95 | LocalDate.ofYearDay(year, if (Year.of(year).isLeap) 366 else 365) 96 | 97 | } 98 | 99 | /** 週末判定用のTemporalQuery>Boolean<を表現します。 */ 100 | class WeekendQuery : TemporalQuery { 101 | override fun queryFrom(temporal: TemporalAccessor): Boolean { 102 | val dayOfWeek = DayOfWeek.of(temporal.get(ChronoField.DAY_OF_WEEK)) 103 | return dayOfWeek == DayOfWeek.SATURDAY || dayOfWeek == DayOfWeek.SUNDAY 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/main/kotlin/sample/util/Calculator.kt: -------------------------------------------------------------------------------- 1 | package sample.util 2 | 3 | import java.math.BigDecimal 4 | import java.math.RoundingMode 5 | import java.util.concurrent.atomic.AtomicReference 6 | 7 | /** 8 | * 計算ユーティリティ。 9 | *

単純計算の簡易化を目的とした割り切った実装なのでスレッドセーフではありません。 10 | */ 11 | class Calculator { 12 | private val value = AtomicReference() 13 | /** 小数点以下桁数 */ 14 | private var scale: Int = 0 15 | /** 端数定義。標準では切り捨て */ 16 | private var mode: RoundingMode = RoundingMode.DOWN 17 | /** 計算の都度端数処理をする時はtrue */ 18 | private var roundingAlways: Boolean = false 19 | 20 | /** 計算結果をint型で返します。 */ 21 | fun intValue(): Int = decimal().toInt() 22 | 23 | /** 計算結果をlong型で返します。 */ 24 | fun longValue(): Long = decimal().toLong() 25 | 26 | /** 計算結果をBigDecimal型で返します。 */ 27 | fun decimal(): BigDecimal { 28 | val v = value.get() 29 | return if (v != null) v.setScale(scale, mode) else BigDecimal.ZERO 30 | } 31 | 32 | private constructor(v: Number) { 33 | try { 34 | this.value.set(BigDecimal(v.toString())) 35 | } catch (e: NumberFormatException) { 36 | this.value.set(BigDecimal.ZERO) 37 | } 38 | } 39 | 40 | private constructor(v: BigDecimal) { 41 | this.value.set(v) 42 | } 43 | 44 | /** 45 | * 計算前処理定義。 46 | * @param scale 小数点以下桁数  47 | * @return 自身のインスタンス 48 | */ 49 | fun scale(scale: Int): Calculator = 50 | scale(scale, RoundingMode.DOWN) 51 | 52 | /** 53 | * 計算前処理定義。 54 | * @param scale 小数点以下桁数 55 | * @param mode 端数定義 56 | */ 57 | fun scale(scale: Int, mode: RoundingMode): Calculator { 58 | this.scale = scale 59 | this.mode = mode 60 | return this 61 | } 62 | 63 | /** 64 | * 計算前の端数処理定義をします。 65 | * @param roundingAlways 計算の都度端数処理をする時はtrue 66 | */ 67 | fun roundingAlways(roundingAlways: Boolean): Calculator { 68 | this.roundingAlways = roundingAlways 69 | return this 70 | } 71 | 72 | /** 与えた計算値を自身が保持する値に加えます。 */ 73 | fun add(v: Number): Calculator { 74 | try { 75 | add(BigDecimal(v.toString())) 76 | } catch (e: NumberFormatException) { 77 | } 78 | return this 79 | } 80 | 81 | /** 与えた計算値を自身が保持する値に加えます。 */ 82 | fun add(v: BigDecimal): Calculator { 83 | value.set(rounding(value.get().add(v))) 84 | return this 85 | } 86 | 87 | private fun rounding(v: BigDecimal): BigDecimal = 88 | if (roundingAlways) v.setScale(scale, mode) else v 89 | 90 | /** 自身が保持する値へ与えた計算値を引きます。 */ 91 | fun subtract(v: Number): Calculator { 92 | try { 93 | subtract(BigDecimal(v.toString())) 94 | } catch (e: NumberFormatException) { 95 | } 96 | return this 97 | } 98 | 99 | /** 自身が保持する値へ与えた計算値を引きます。 */ 100 | fun subtract(v: BigDecimal): Calculator { 101 | value.set(rounding(value.get().subtract(v))) 102 | return this 103 | } 104 | 105 | /** 自身が保持する値へ与えた計算値を掛けます。 */ 106 | fun multiply(v: Number): Calculator { 107 | try { 108 | multiply(BigDecimal(v.toString())) 109 | } catch (e: NumberFormatException) { 110 | } 111 | return this 112 | } 113 | 114 | /** 自身が保持する値へ与えた計算値を掛けます。 */ 115 | fun multiply(v: BigDecimal): Calculator { 116 | value.set(rounding(value.get().multiply(v))) 117 | return this 118 | } 119 | 120 | /** 与えた計算値で自身が保持する値を割ります。 */ 121 | fun divideBy(v: Number): Calculator { 122 | try { 123 | divideBy(BigDecimal(v.toString())) 124 | } catch (e: NumberFormatException) { 125 | } 126 | return this 127 | } 128 | 129 | /** 与えた計算値で自身が保持する値を割ります。 */ 130 | fun divideBy(v: BigDecimal): Calculator { 131 | val ret = when { 132 | roundingAlways -> value.get().divide(v, scale, mode) 133 | else -> value.get().divide(v, defaultScale, mode) 134 | } 135 | value.set(ret) 136 | return this 137 | } 138 | 139 | companion object { 140 | /** scale未設定時の除算scale値 */ 141 | const val defaultScale: Int = 18 142 | 143 | /** 開始値0で初期化されたCalculator */ 144 | fun init(): Calculator = Calculator(BigDecimal.ZERO) 145 | 146 | /** 147 | * @param v 初期値 148 | * @return 初期化されたCalculator 149 | */ 150 | fun of(v: Number): Calculator = Calculator(v) 151 | 152 | /** 153 | * @param v 初期値 154 | * @return 初期化されたCalculator 155 | */ 156 | fun of(v: BigDecimal): Calculator = Calculator(v) 157 | 158 | } 159 | 160 | } -------------------------------------------------------------------------------- /src/test/kotlin/sample/client/SampleClient.kt: -------------------------------------------------------------------------------- 1 | package sample.client 2 | 3 | import java.io.IOException 4 | import org.springframework.http.HttpMethod 5 | import com.sun.corba.se.impl.protocol.giopmsgheaders.MessageBase.createRequest 6 | import org.apache.commons.io.IOUtils 7 | import org.junit.FixMethodOrder 8 | import org.junit.Ignore 9 | import org.junit.Test 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.DateUtils 15 | import sample.util.TimePoint 16 | import java.net.URI 17 | import java.util.* 18 | 19 | 20 | /** 21 | * 単純なHTTP経由の実行検証。 22 | *

SpringがサポートするWebTestSupportでの検証で良いのですが、コンテナ立ち上げた後に叩く単純確認用に作りました。 23 | *

「extention.security.auth.enabled: true」の時は実際にログインして処理を行います。 24 | * falseの時はDummyLoginInterceptorによる擬似ログインが行われます。 25 | */ 26 | @FixMethodOrder 27 | @Ignore // 実行時はこの行をコメントアウトしてください。 28 | class SampleClient { 29 | 30 | // 「extention.security.auth.admin: false」の時のみ利用可能です。 31 | @Test 32 | fun 顧客向けユースケース検証() { 33 | val agent = SimpleTestAgent() 34 | agent.login("sample", "sample") 35 | agent.post("振込出金依頼", "/asset/cio/withdraw?accountId=sample¤cy=JPY&absAmount=200") 36 | agent["振込出金依頼未処理検索", "/asset/cio/unprocessedOut/"] 37 | } 38 | 39 | // 「extention.security.auth.admin: true」の時のみ利用可能です。 40 | @Test 41 | fun 社内向けユースケース検証() { 42 | val day = DateUtils.dayFormat(TimePoint.now().day) 43 | val agent = SimpleTestAgent() 44 | agent.login("admin", "admin") 45 | agent["振込入出金依頼検索", "/admin/asset/cio/?updFromDay=$day&updToDay=$day"] 46 | } 47 | 48 | @Test 49 | fun バッチ向けユースケース検証() { 50 | val fromDay = DateUtils.dayFormat(TimePoint.now().day.minusDays(1)) 51 | val toDay = DateUtils.dayFormat(TimePoint.now().day.plusDays(3)) 52 | val agent = SimpleTestAgent() 53 | agent.post("営業日を進める(単純日回しのみ)", "/system/job/daily/processDay") 54 | agent.post("当営業日の出金依頼を締める", "/system/job/daily/closingCashOut") 55 | agent.post("入出金キャッシュフローを実現する(受渡日に残高へ反映)", "/system/job/daily/realizeCashflow") 56 | agent["イベントログを検索する", "/admin/system/audit/event/?fromDay=$fromDay&toDay=$toDay"] 57 | } 58 | 59 | /** 単純なSession概念を持つHTTPエージェント */ 60 | private inner class SimpleTestAgent { 61 | private val factory = SimpleClientHttpRequestFactory() 62 | private var sessionId = Optional.empty() 63 | 64 | @Throws(Exception::class) 65 | fun path(path: String): URI { 66 | return URI(ROOT_PATH + path) 67 | } 68 | 69 | @Throws(Exception::class) 70 | fun login(loginId: String, password: String): SimpleTestAgent { 71 | val res = post("ログイン", "/login?loginId=$loginId&password=$password") 72 | if (res.getStatusCode() === HttpStatus.OK) { 73 | val cookieStr = res.getHeaders().get("Set-Cookie")!![0] 74 | sessionId = Optional.of(cookieStr.substring(0, cookieStr.indexOf(';'))) 75 | } 76 | return this 77 | } 78 | 79 | @Throws(Exception::class) 80 | operator fun get(title: String, path: String): ClientHttpResponse { 81 | title(title) 82 | return dump(request(path, HttpMethod.GET).execute()) 83 | } 84 | 85 | @Throws(Exception::class) 86 | private fun request(path: String, method: HttpMethod): ClientHttpRequest { 87 | val req = factory.createRequest(path(path), method) 88 | sessionId.ifPresent { req.headers.add("Cookie", it) } 89 | return req 90 | } 91 | 92 | @Throws(Exception::class) 93 | fun post(title: String, path: String): ClientHttpResponse { 94 | title(title) 95 | return dump(request(path, HttpMethod.POST).execute()) 96 | } 97 | 98 | fun title(title: String) { 99 | println("------- $title------- ") 100 | } 101 | 102 | @Throws(Exception::class) 103 | fun dump(res: ClientHttpResponse): ClientHttpResponse { 104 | println(String.format("status: %d, text: %s", res.getRawStatusCode(), res.getStatusText())) 105 | try { 106 | System.out.println(IOUtils.toString(res.getBody(), "UTF-8")) 107 | } catch (e: IOException) { 108 | /* nothing. */ 109 | } 110 | 111 | return res 112 | } 113 | 114 | } 115 | 116 | companion object { 117 | private const val ROOT_PATH = "http://localhost:8080/api" 118 | } 119 | 120 | } 121 | --------------------------------------------------------------------------------