backend scaffold
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
dependencies {
|
||||
// 只依赖 platform-web 里的 AuditContext(见根 README 的"对文档的偏离 #1")
|
||||
api project(':platform:platform-web')
|
||||
|
||||
api 'org.springframework.boot:spring-boot-starter-data-jpa'
|
||||
api 'net.javacrumbs.shedlock:shedlock-spring:6.9.2'
|
||||
implementation 'net.javacrumbs.shedlock:shedlock-provider-jdbc-template:6.9.2'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-cache'
|
||||
implementation 'com.github.ben-manes.caffeine:caffeine'
|
||||
implementation 'org.flywaydb:flyway-core'
|
||||
runtimeOnly 'org.flywaydb:flyway-mysql'
|
||||
runtimeOnly 'com.mysql:mysql-connector-j'
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
package com.continental.retailapp.platform.persistence
|
||||
|
||||
import jakarta.persistence.Column
|
||||
import jakarta.persistence.EntityListeners
|
||||
import jakarta.persistence.MappedSuperclass
|
||||
import jakarta.persistence.Version
|
||||
import org.springframework.data.annotation.CreatedBy
|
||||
import org.springframework.data.annotation.CreatedDate
|
||||
import org.springframework.data.annotation.LastModifiedBy
|
||||
import org.springframework.data.annotation.LastModifiedDate
|
||||
import org.springframework.data.jpa.domain.support.AuditingEntityListener
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
* 审计字段基类,见 03-persistence.md。
|
||||
* 时间统一 UTC 的 [Instant],落库 `datetime(6)`——不要用 LocalDateTime,它不带时区信息。
|
||||
*/
|
||||
@MappedSuperclass
|
||||
@EntityListeners(AuditingEntityListener::class)
|
||||
abstract class BaseEntity {
|
||||
@CreatedDate
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
var createdAt: Instant = Instant.EPOCH
|
||||
|
||||
@LastModifiedDate
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
var updatedAt: Instant = Instant.EPOCH
|
||||
|
||||
@CreatedBy
|
||||
@Column(name = "created_by", updatable = false, length = 64)
|
||||
var createdBy: String? = null
|
||||
|
||||
@LastModifiedBy
|
||||
@Column(name = "updated_by", length = 64)
|
||||
var updatedBy: String? = null
|
||||
}
|
||||
|
||||
/** 需要乐观锁的表继承它,见 12-concurrency-and-scheduling.md。 */
|
||||
@MappedSuperclass
|
||||
abstract class VersionedEntity : BaseEntity() {
|
||||
@Version
|
||||
@Column(name = "version", nullable = false)
|
||||
var version: Long = 0
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package com.continental.retailapp.platform.persistence
|
||||
|
||||
/**
|
||||
* 统一分页响应,见 03-persistence.md / 06-api-design.md。
|
||||
* `pageNum` 从 1 开始,与客户端约定一致。
|
||||
*/
|
||||
data class PageResult<T>(
|
||||
val list: List<T>,
|
||||
val pageNum: Int,
|
||||
val pageSize: Int,
|
||||
val total: Long,
|
||||
val hasMore: Boolean,
|
||||
)
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package com.continental.retailapp.platform.persistence.cache
|
||||
|
||||
import com.github.benmanes.caffeine.cache.Caffeine
|
||||
import org.springframework.cache.CacheManager
|
||||
import org.springframework.cache.annotation.EnableCaching
|
||||
import org.springframework.cache.caffeine.CaffeineCacheManager
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import java.time.Duration
|
||||
|
||||
/**
|
||||
* 本地缓存,见 12-concurrency-and-scheduling.md。只用 Caffeine,暂不引 Redis。
|
||||
*
|
||||
* 代价要如实认识:多副本下各自一份缓存,失效时机不一致。所以
|
||||
* **用户→门店的访问权限、WebView 票据状态一律不缓存**(必须能立即撤销)。
|
||||
*/
|
||||
@Configuration
|
||||
@EnableCaching
|
||||
class CacheConfig {
|
||||
|
||||
@Bean
|
||||
fun cacheManager(): CacheManager = CaffeineCacheManager().apply {
|
||||
setCaffeine(
|
||||
Caffeine.newBuilder()
|
||||
.maximumSize(1_000)
|
||||
.expireAfterWrite(Duration.ofMinutes(10)),
|
||||
)
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package com.continental.retailapp.platform.persistence.config
|
||||
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import java.time.Clock
|
||||
|
||||
/**
|
||||
* 全局 [Clock],见 10-testing.md。
|
||||
* 所有涉及时间的业务代码一律构造注入它,不要写 `Instant.now()`——
|
||||
* 否则"过期判断"这类逻辑只能靠 `Thread.sleep` 测,那是慢测试和随机失败的主要来源。
|
||||
*/
|
||||
@Configuration
|
||||
class ClockConfig {
|
||||
@Bean
|
||||
fun clock(): Clock = Clock.systemUTC()
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package com.continental.retailapp.platform.persistence.config
|
||||
|
||||
import org.flywaydb.core.Flyway
|
||||
import org.springframework.boot.jpa.autoconfigure.EntityManagerFactoryDependsOnPostProcessor
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import javax.sql.DataSource
|
||||
|
||||
/**
|
||||
* 一个 domain 一个 database(MySQL 里 schema ≡ database),所以要跑多个 Flyway 实例,
|
||||
* 见 03-persistence.md。Boot 自带的单实例 Flyway 用 `spring.flyway.enabled=false` 关掉。
|
||||
*
|
||||
* `EntityManagerFactoryDependsOnPostProcessor` 保证迁移在 Hibernate 做 `ddl-auto: validate` 之前跑完,
|
||||
* 否则新表还没建,校验直接失败。
|
||||
*
|
||||
* 注:Boot 4 把这个类从 `org.springframework.boot.autoconfigure.orm.jpa` 挪到了
|
||||
* `org.springframework.boot.jpa.autoconfigure`(autoconfigure 模块拆分的结果)。
|
||||
*
|
||||
* `platform` 库不在 03 文档的清单里,是按 12-concurrency-and-scheduling.md 补上的
|
||||
* (`idempotency_record` 和 `shedlock` 两张公共表)。
|
||||
*/
|
||||
@Configuration
|
||||
class DomainFlywayConfig {
|
||||
|
||||
companion object {
|
||||
val DOMAIN_SCHEMAS = listOf("platform", "identity_store", "workbench", "webview_ticket")
|
||||
}
|
||||
|
||||
@Bean
|
||||
fun domainFlywayMigrations(dataSource: DataSource): DomainFlywayMigrations {
|
||||
DOMAIN_SCHEMAS.forEach { schema ->
|
||||
Flyway.configure()
|
||||
.dataSource(dataSource)
|
||||
.schemas(schema)
|
||||
.defaultSchema(schema)
|
||||
.table("flyway_schema_history")
|
||||
.locations("classpath:db/migration/$schema")
|
||||
.load()
|
||||
.migrate()
|
||||
}
|
||||
return DomainFlywayMigrations
|
||||
}
|
||||
|
||||
@Bean
|
||||
fun flywayEntityManagerFactoryDependsOn(): EntityManagerFactoryDependsOnPostProcessor =
|
||||
object : EntityManagerFactoryDependsOnPostProcessor("domainFlywayMigrations") {}
|
||||
}
|
||||
|
||||
/** 只是一个可被依赖的标记 bean,让 EntityManagerFactory 有东西可以 depends-on。 */
|
||||
object DomainFlywayMigrations
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package com.continental.retailapp.platform.persistence.config
|
||||
|
||||
import com.continental.retailapp.platform.web.AuditContext
|
||||
import org.springframework.beans.factory.ObjectProvider
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import org.springframework.data.domain.AuditorAware
|
||||
import org.springframework.data.jpa.repository.config.EnableJpaAuditing
|
||||
import java.util.Optional
|
||||
|
||||
/**
|
||||
* JPA 审计,见 03-persistence.md。
|
||||
*
|
||||
* 注入的是 platform-web 的 [AuditContext] 接口而不是 platform-security 的 StoreContextHolder
|
||||
* (对文档的有意偏离 #1,理由见 AuditContext 的注释)。
|
||||
*
|
||||
* `runCatching` 是必要的:定时任务、启动期的种子数据这些场景没有 HTTP 请求,
|
||||
* `@RequestScope` 的 bean 取不到,不兜住会直接把写操作打挂。
|
||||
*/
|
||||
@Configuration
|
||||
@EnableJpaAuditing(auditorAwareRef = "auditorAware")
|
||||
class JpaAuditingConfig(private val auditContext: ObjectProvider<AuditContext>) {
|
||||
|
||||
@Bean
|
||||
fun auditorAware(): AuditorAware<String> = AuditorAware {
|
||||
runCatching { auditContext.getObject().userId?.toString() }
|
||||
.getOrNull()
|
||||
.let { Optional.ofNullable(it) }
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package com.continental.retailapp.platform.persistence.scheduling
|
||||
|
||||
import net.javacrumbs.shedlock.core.LockProvider
|
||||
import net.javacrumbs.shedlock.provider.jdbctemplate.JdbcTemplateLockProvider
|
||||
import net.javacrumbs.shedlock.spring.annotation.EnableSchedulerLock
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import org.springframework.jdbc.core.JdbcTemplate
|
||||
import org.springframework.scheduling.annotation.EnableScheduling
|
||||
import javax.sql.DataSource
|
||||
|
||||
/**
|
||||
* 定时任务 + 分布式锁,见 12-concurrency-and-scheduling.md。
|
||||
*
|
||||
* 多副本下 `@Scheduled` 会在每个 Pod 上各跑一次,ShedLock 保证同一时刻只有一个副本真正执行。
|
||||
* `usingDbTime()` 是关键:用数据库时间而不是各 Pod 的本地时间判断锁,避免时钟漂移导致锁形同虚设。
|
||||
*/
|
||||
@Configuration
|
||||
@EnableScheduling
|
||||
@EnableSchedulerLock(defaultLockAtMostFor = "PT10M")
|
||||
class SchedulingConfig {
|
||||
|
||||
@Bean
|
||||
fun lockProvider(dataSource: DataSource): LockProvider = JdbcTemplateLockProvider(
|
||||
JdbcTemplateLockProvider.Configuration.builder()
|
||||
.withJdbcTemplate(JdbcTemplate(dataSource))
|
||||
.withTableName("platform.shedlock")
|
||||
.usingDbTime()
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
-- platform 公共库:幂等记录与 ShedLock 锁表(见 12-concurrency-and-scheduling.md)
|
||||
-- 这两张表不属于任何业务域,所以单独放一个 database。
|
||||
|
||||
create table idempotency_record
|
||||
(
|
||||
id bigint not null auto_increment,
|
||||
idem_key varchar(64) not null comment '客户端传入的幂等键',
|
||||
user_id bigint not null,
|
||||
response text not null comment '首次执行的响应快照,重放时原样返回',
|
||||
created_at datetime(6) not null,
|
||||
primary key (id),
|
||||
unique key uk_idem_key_user (idem_key, user_id),
|
||||
key idx_idempotency_record_created_at (created_at)
|
||||
) engine = InnoDB
|
||||
default charset = utf8mb4
|
||||
collate = utf8mb4_0900_ai_ci comment '幂等记录';
|
||||
|
||||
create table shedlock
|
||||
(
|
||||
name varchar(64) not null,
|
||||
lock_until datetime(6) not null,
|
||||
locked_at datetime(6) not null,
|
||||
locked_by varchar(255) not null,
|
||||
primary key (name)
|
||||
) engine = InnoDB
|
||||
default charset = utf8mb4
|
||||
collate = utf8mb4_0900_ai_ci comment '定时任务分布式锁';
|
||||
Reference in New Issue
Block a user