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
+16
View File
@@ -0,0 +1,16 @@
dependencies {
implementation project(':platform:platform-web')
implementation project(':platform:platform-security')
implementation project(':platform:platform-persistence')
implementation project(':platform:platform-integration')
implementation project(':integration:mini-clients')
// 11-cross-domain-collaboration.md 的 WorkbenchAppService 示例同时用到 F6ApiClient
// 所以比 01-project-structure.md 的示例多一条 integration/* 依赖(规则本身允许)
implementation project(':integration:f6-adapter')
implementation project(':domains:identity-store-contract')
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'io.micrometer:context-propagation'
}
@@ -0,0 +1,22 @@
package com.continental.retailapp.workbench.api
import com.continental.retailapp.platform.web.ApiResult
import com.continental.retailapp.workbench.api.response.HomepageResponse
import com.continental.retailapp.workbench.application.WorkbenchAppService
import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.tags.Tag
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
@Tag(name = "工作台")
@RestController
@RequestMapping("/api/v1/workbench")
class WorkbenchController(
private val workbenchAppService: WorkbenchAppService,
) {
@Operation(summary = "首页聚合:门店上下文 + 采购 + 保修,单个 tile 失败降级不影响整体")
@GetMapping("/homepage")
fun homepage(): ApiResult<HomepageResponse> =
ApiResult.ok(workbenchAppService.loadHomepage())
}
@@ -0,0 +1,17 @@
package com.continental.retailapp.workbench.api.response
import com.continental.retailapp.identitystore.contract.StoreInfo
import com.continental.retailapp.integration.f6.ProcurementList
import com.continental.retailapp.integration.mini.WarrantySummary
/**
* 首页聚合响应。
*
* 规则:**只要主数据(门店上下文)拿到了,首页接口就返回 `code: 0`**
* 个别 tile 降级不会让整个接口失败——一个外部系统抖动不该让用户连首页都打不开。
*/
data class HomepageResponse(
val store: Tile<StoreInfo>,
val procurement: Tile<ProcurementList>,
val warranty: Tile<WarrantySummary>,
)
@@ -0,0 +1,19 @@
package com.continental.retailapp.workbench.api.response
/**
* 首页每一块(tile)的包装(11-cross-domain-collaboration.md)。
*
* 降级必须**对客户端可见**,不能悄悄返回空数据:客户端要能区分
* "这块真的没数据"和"这块没拉到",才能决定显示空态还是"加载失败,点击重试"。
*/
data class Tile<T>(
val data: T?,
val status: TileStatus,
) {
companion object {
fun <T> ok(data: T) = Tile(data, TileStatus.OK)
fun <T> degraded() = Tile<T>(null, TileStatus.DEGRADED)
}
}
enum class TileStatus { OK, DEGRADED }
@@ -0,0 +1,80 @@
package com.continental.retailapp.workbench.application
import com.continental.retailapp.identitystore.contract.StoreQueryService
import com.continental.retailapp.integration.f6.F6ApiClient
import com.continental.retailapp.integration.mini.O2OClient
import com.continental.retailapp.platform.security.StoreContextHolder
import com.continental.retailapp.workbench.api.response.HomepageResponse
import com.continental.retailapp.workbench.api.response.Tile
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.stereotype.Service
import java.time.Duration
import java.util.concurrent.CompletableFuture
import java.util.concurrent.Executor
import java.util.concurrent.TimeUnit
/**
* 首页并行聚合(11-cross-domain-collaboration.md)。
*
* **总预算不等于各下游超时之和**:三个下游各配 2 秒,并行最坏 2 秒——但那是单次调用,
* 叠上 `@Retry`(3 次)之后单个 tile 最坏可能到 6 秒。所以必须有一个独立于下游配置的
* 接口级总预算,到点就把还没回来的 tile 全部降级返回。没有这道闸,
* 首页最坏耗时是由下游配置的乘积决定的,不可控。
*
* 跨域读走 [StoreQueryService] 这个契约接口,不直接 import identity-store 的内部类型。
*/
@Service
class WorkbenchAppService(
@Qualifier("workbenchExecutor") private val executor: Executor,
private val f6ApiClient: F6ApiClient,
private val o2oClient: O2OClient,
private val storeQueryService: StoreQueryService,
private val storeContextHolder: StoreContextHolder,
) {
private val log = LoggerFactory.getLogger(javaClass)
fun loadHomepage(): HomepageResponse {
// 门店取自 token,不接受请求参数——请求参数可以被篡改
val storeId = storeContextHolder.currentStoreId()
val deadline = System.nanoTime() + TOTAL_BUDGET.toNanos()
val store = supply("store") { storeQueryService.findStore(storeId) }
val procurement = supply("procurement") { f6ApiClient.fetchProcurementList(storeId) }
val warranty = supply("warranty") { o2oClient.fetchWarrantySummary(storeId) }
return HomepageResponse(
store = await(store, deadline, "store"),
procurement = await(procurement, deadline, "procurement"),
warranty = await(warranty, deadline, "warranty"),
)
}
private fun <T : Any> supply(tile: String, block: () -> T?): CompletableFuture<Tile<T>> =
CompletableFuture.supplyAsync(
{
runCatching(block)
.map { if (it == null) Tile.degraded() else Tile.ok(it) }
.getOrElse {
log.warn("tile={} 加载失败,降级", tile, it)
Tile.degraded()
}
},
executor,
)
private fun <T : Any> await(future: CompletableFuture<Tile<T>>, deadlineNanos: Long, tile: String): Tile<T> {
val remaining = deadlineNanos - System.nanoTime()
if (remaining <= 0) return Tile.degraded()
return runCatching { future.get(remaining, TimeUnit.NANOSECONDS) }
.getOrElse {
log.warn("tile={} 超出总预算,降级", tile)
Tile.degraded()
}
}
private companion object {
/** 整个首页接口的总预算。 */
val TOTAL_BUDGET: Duration = Duration.ofSeconds(3)
}
}
@@ -0,0 +1,56 @@
package com.continental.retailapp.workbench.infrastructure.config
import io.micrometer.context.ContextSnapshotFactory
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.core.task.TaskDecorator
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor
import java.util.concurrent.ThreadPoolExecutor
/**
* 首页并行聚合的专用线程池(11-cross-domain-collaboration.md)。
*
* 模块内的 `@Configuration` 一律放 `infrastructure/config/`,不要散在模块根包下——
* 那样它既不属于任何一层,ArchUnit 的分层规则也管不到它。
*
* 四个必须这么写的点:
*
* 1. **必须是专用池**,不能用公共 `@Async` 默认池。共用一个池时,一个下游变慢会把池占满、
* 波及所有异步任务——这正是舱壁要防的事。
* 2. **队列必须有界**。`queueCapacity` 默认是 `Integer.MAX_VALUE`:任务会全部堆进队列,
* 线程数永远不会从 core 涨到 max,然后在某次高峰把堆吃光。
* 3. **`CallerRunsPolicy`**:池满时任务退回 Tomcat 线程自己跑,是一种天然背压——
* 聚合变慢,但不丢请求、不抛 `RejectedExecutionException`。
* 4. **上下文传播装饰器**:把 MDC 里的 traceId 和 `@RequestScope` 的门店上下文搬到子线程。
* 没有它,子线程日志全部断链、`StoreContextHolder` 直接取不到值——
* 这是同步栈下并行聚合最典型的翻车方式。
*/
@Configuration
class WorkbenchExecutorConfig {
@Bean("workbenchExecutor")
fun workbenchExecutor(): ThreadPoolTaskExecutor = ThreadPoolTaskExecutor().apply {
corePoolSize = 8
maxPoolSize = 16
queueCapacity = 32
setThreadNamePrefix("workbench-")
setRejectedExecutionHandler(ThreadPoolExecutor.CallerRunsPolicy())
setTaskDecorator(contextPropagatingTaskDecorator())
setWaitForTasksToCompleteOnShutdown(true)
setAwaitTerminationSeconds(20) // 配合 09 的优雅停机
initialize()
}
/**
* 11 文档里写的是 Micrometer 的 `ContextPropagatingTaskDecorator`。
* 这里用 `ContextSnapshotFactory` 手写等价实现,是为了不额外引一个只用一个类的依赖——
* 语义完全一致:抓当前线程的上下文快照,在子线程里 restore、执行完再关掉。
*/
private fun contextPropagatingTaskDecorator(): TaskDecorator {
val snapshotFactory = ContextSnapshotFactory.builder().build()
return TaskDecorator { runnable ->
val snapshot = snapshotFactory.captureAll()
Runnable { snapshot.wrap(runnable).run() }
}
}
}
@@ -0,0 +1,23 @@
-- workbench 库初始化。
--
-- 工作台目前的数据全部来自实时聚合(F6 / O2O / identity-store),本身没有持久化需求。
-- 这张表是占位:Flyway 的 location 不能是空目录,否则 DomainFlywayConfig 起不来。
-- 它也确实有用——首页卡片的排序/开关将来要落在这里,不必再加一次迁移基线。
create table workbench_tile
(
id bigint not null auto_increment,
store_id bigint not null,
tile_key varchar(64) not null comment 'procurement / warranty / ...',
sort_order int not null default 0,
enabled tinyint(1) not null default 1,
version bigint not null default 0,
created_at datetime(6) not null,
updated_at datetime(6) not null,
created_by varchar(64) null,
updated_by varchar(64) null,
primary key (id),
unique key uk_workbench_tile (store_id, tile_key)
) engine = InnoDB
default charset = utf8mb4
collate = utf8mb4_0900_ai_ci comment '工作台卡片配置';