backend scaffold
This commit is contained in:
@@ -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'
|
||||
}
|
||||
+22
@@ -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())
|
||||
}
|
||||
+17
@@ -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>,
|
||||
)
|
||||
+19
@@ -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 }
|
||||
+80
@@ -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)
|
||||
}
|
||||
}
|
||||
+56
@@ -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 '工作台卡片配置';
|
||||
Reference in New Issue
Block a user