backend scaffold

This commit is contained in:
Guangfei.Zhao
2026-08-17 15:31:27 +08:00
commit 84fc2c0677
159 changed files with 10542 additions and 0 deletions
@@ -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'
}
@@ -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
}
@@ -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,
)
@@ -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)),
)
}
}
@@ -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()
}
@@ -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 一个 databaseMySQL 里 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
@@ -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) }
}
}
@@ -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 '定时任务分布式锁';