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
+26
View File
@@ -0,0 +1,26 @@
# bff-orchestration
面向客户端的编排层:把多个 domain 的用例拼成"一个屏幕一个接口"的形状。
## 现在为什么是空的
首页聚合当前落在 `domains/workbench` 里(见 `WorkbenchAppService`),因为它只跨了
`identity-store` + 两个外部系统,用 domain 自己的 application 层就够了。
**在出现第二个"必须跨多个 domain 才能拼出来、且明显只服务于某一个客户端屏幕"的接口之前,
这个模块保持为空。** 提前把编排逻辑搬进来只会多一层转发。
## 什么时候开始往里写
同时满足这几条时,把编排从 domain 里搬进来:
- 一个接口需要 3 个以上 domain 的数据,且这些 domain 之间没有业务上的从属关系;
- 响应结构明显是为某个客户端页面定制的(换个端就得换一套);
- 编排逻辑放在任何一个 domain 里都显得越界。
## 写进来时的约束
- 只能依赖各 domain 的 `-contract` 模块和 `platform-*`,**不能依赖任何 domain 的实现模块**;
- 自己不建表、不写 Flyway 迁移——BFF 没有自己的数据;
- 并行聚合照 `workbench` 的做法:专用有界线程池 + 上下文传播 + 接口级总超时预算
(见 `../../conti-docs/backend/11-cross-domain-collaboration.md`)。
+4
View File
@@ -0,0 +1,4 @@
// 骨架阶段这个模块只占位,见 README.md
dependencies {
implementation project(':platform:platform-web')
}
@@ -0,0 +1,4 @@
// 契约模块:只放接口、传输模型和事件,不携带任何技术栈。
// ArchUnit 有一条规则盯着它不依赖 Spring Web / JPA(见 10-testing.md)。
dependencies {
}
@@ -0,0 +1,20 @@
package com.continental.retailapp.identitystore.contract
/**
* identity-store 对外发布的读能力,见 11-cross-domain-collaboration.md。
*
* 契约设计四条规则里最重要的两条:
* 1. 只承诺调用方真正需要的最小能力——这里出现的每个方法都是未来的约束;
* 2. [StoreInfo] 是独立的数据结构,不是 `StoreEntity` 的别名,不跟着表结构一起长。
*/
interface StoreQueryService {
fun listStoresByUserId(userId: Long): List<StoreInfo>
fun findStore(storeId: Long): StoreInfo?
}
data class StoreInfo(
val storeId: Long,
val name: String,
val code: String,
)
@@ -0,0 +1,19 @@
package com.continental.retailapp.identitystore.contract
import java.time.Instant
/**
* 门店已切换,见 11-cross-domain-collaboration.md。
*
* 事件类型必须定义在契约模块,否则订阅方要 import 发布方的内部类型,模块边界就破了。
*
* 可靠性边界要如实认识:`ApplicationEvent` 是**进程内、内存中**的,
* 应用在发布后、监听器执行前崩溃这个事件就永久丢了。所以它只用于"丢了不致命"的副作用
* (作废票据、清缓存、记审计),不能用于扣款、发货这类丢了会造成不一致的操作。
*/
data class StoreSwitchedEvent(
val userId: Long,
val fromStoreId: Long?,
val toStoreId: Long,
val occurredAt: Instant,
)
+26
View File
@@ -0,0 +1,26 @@
apply plugin: 'org.jetbrains.kotlin.kapt'
dependencies {
implementation project(':platform:platform-web')
implementation project(':platform:platform-security')
implementation project(':platform:platform-persistence')
implementation project(':platform:platform-observability')
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-validation'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.mapstruct:mapstruct:1.6.3'
kapt 'org.mapstruct:mapstruct-processor:1.6.3'
testImplementation 'com.ninja-squad:springmockk:5.0.1'
// Boot 4 把测试切片按技术栈拆成了独立模块:@DataJpaTest / @AutoConfigureTestDatabase
// 不再随 spring-boot-starter-test 一起来,要单独引这个 starter
testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test'
testImplementation 'org.springframework.boot:spring-boot-testcontainers'
testImplementation 'org.testcontainers:testcontainers-junit-jupiter'
testImplementation 'org.testcontainers:testcontainers-mysql'
testRuntimeOnly 'com.mysql:mysql-connector-j'
}
@@ -0,0 +1,56 @@
package com.continental.retailapp.identitystore.api
import com.continental.retailapp.identitystore.api.request.LoginRequest
import com.continental.retailapp.identitystore.api.request.RefreshTokenRequest
import com.continental.retailapp.identitystore.api.response.LoginResponse
import com.continental.retailapp.identitystore.api.response.MeResponse
import com.continental.retailapp.identitystore.api.response.TokenResponse
import com.continental.retailapp.identitystore.application.AuthAppService
import com.continental.retailapp.platform.security.StoreContextHolder
import com.continental.retailapp.platform.web.ApiResult
import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.tags.Tag
import jakarta.validation.Valid
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
/**
* 认证端点。路径与响应体是照着客户端已实现的契约写的(04-security-auth.md),
* **后端对齐客户端,不是反过来**。
*
* Controller 只做参数校验 + 调用 `application`,不含任何业务分支。
*/
@Tag(name = "认证")
@RestController
@RequestMapping("/api/v1/auth")
class AuthController(
private val authAppService: AuthAppService,
private val storeContextHolder: StoreContextHolder,
) {
@Operation(summary = "登录,返回 access token / refresh token 与用户信息")
@PostMapping("/login")
fun login(@Valid @RequestBody request: LoginRequest): ApiResult<LoginResponse> =
ApiResult.ok(authAppService.login(request))
/** 免认证:调它的时候 access token 恰好已经过期,要求带有效 token 就成了死锁。 */
@Operation(summary = "用 refresh token 换新的 access token(轮换)")
@PostMapping("/refresh")
fun refresh(@Valid @RequestBody request: RefreshTokenRequest): ApiResult<TokenResponse> =
ApiResult.ok(authAppService.refresh(request.refreshToken))
@Operation(summary = "登出,撤销该 refresh token")
@PostMapping("/logout")
fun logout(@Valid @RequestBody request: RefreshTokenRequest): ApiResult<Unit> {
authAppService.logout(request.refreshToken)
return ApiResult.ok()
}
/** 客户端冷启动第一个调用的接口,必须轻量且只读。 */
@Operation(summary = "当前登录用户与所在门店")
@GetMapping("/me")
fun me(): ApiResult<MeResponse> =
ApiResult.ok(authAppService.me(storeContextHolder.currentUserId()))
}
@@ -0,0 +1,34 @@
package com.continental.retailapp.identitystore.api
import com.continental.retailapp.identitystore.api.response.StoreContextResponse
import com.continental.retailapp.identitystore.api.response.StoreResponse
import com.continental.retailapp.identitystore.application.StoreAppService
import com.continental.retailapp.platform.web.ApiResult
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.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
@Tag(name = "门店")
@RestController
@RequestMapping("/api/v1/stores")
class StoreController(
private val storeAppService: StoreAppService,
) {
@Operation(summary = "查询当前用户可访问的门店列表")
@GetMapping("/accessible")
fun listAccessibleStores(): ApiResult<List<StoreResponse>> =
ApiResult.ok(storeAppService.listAccessibleStores())
/**
* 这是少数几个"门店 id 来自路径参数"的接口之一,
* 所以 `switchStore` 内部**第一件事就是校验可访问性**04-security-auth.md 越权规则第 2 条)。
*/
@Operation(summary = "切换当前门店,返回新的门店上下文与重新签发的 access token")
@PostMapping("/{storeId}/switch")
fun switchStore(@PathVariable storeId: Long): ApiResult<StoreContextResponse> =
ApiResult.ok(storeAppService.switchStore(storeId))
}
@@ -0,0 +1,20 @@
package com.continental.retailapp.identitystore.api.request
import jakarta.validation.constraints.NotBlank
import jakarta.validation.constraints.Size
/**
* 登录请求。契约见 04-security-auth.md 的端点表——客户端已按此实现,后端对齐客户端。
*
* `password` 只在这里出现一次,不会进日志:`logback-spring.xml` 里配了兜底脱敏,
* 但真正的防线是"永远不打印整个请求体"08-observability.md)。
*/
data class LoginRequest(
@field:NotBlank(message = "用户名不能为空")
@field:Size(max = 64, message = "用户名过长")
val username: String,
@field:NotBlank(message = "密码不能为空")
@field:Size(max = 128, message = "密码过长")
val password: String,
)
@@ -0,0 +1,9 @@
package com.continental.retailapp.identitystore.api.request
import jakarta.validation.constraints.NotBlank
/** `POST /api/v1/auth/refresh` 与 `POST /api/v1/auth/logout` 共用同一个请求体。 */
data class RefreshTokenRequest(
@field:NotBlank(message = "refreshToken 不能为空")
val refreshToken: String,
)
@@ -0,0 +1,30 @@
package com.continental.retailapp.identitystore.api.response
/** 用户对外视图。不含 `passwordHash`、`failedAttempts` 这些内部字段。 */
data class UserResponse(
val id: Long,
val username: String,
val displayName: String,
)
/** `POST /api/v1/auth/login` 的响应:`{ accessToken, refreshToken, user }`。 */
data class LoginResponse(
val accessToken: String,
val refreshToken: String,
val user: UserResponse,
)
/** `POST /api/v1/auth/refresh` 的响应:`{ accessToken, refreshToken }`。 */
data class TokenResponse(
val accessToken: String,
val refreshToken: String,
)
/**
* `GET /api/v1/auth/me` 的响应:`{ user, currentStoreId }`。
* 客户端冷启动会调它恢复会话,所以它必须是只读的轻量查询。
*/
data class MeResponse(
val user: UserResponse,
val currentStoreId: Long?,
)
@@ -0,0 +1,17 @@
package com.continental.retailapp.identitystore.api.response
/**
* 切店返回的完整门店上下文(04-security-auth.md)。
*
* 返回的是上下文而不是空 body`accessToken` 里的 `storeId` 决定了后续所有请求的数据范围,
* 只改客户端本地状态不换 token,等于门店没真的切过去。
*
* `menus` 只是 UI 便利——**它不构成安全边界**,真正的边界是各端点上的 `@PreAuthorize`。
* 菜单权限模型 04 文档自己标了"待补充",这里先留一个按角色推导的最小实现。
*/
data class StoreContextResponse(
val accessToken: String,
val store: StoreResponse,
val roles: List<String>,
val menus: List<String>,
)
@@ -0,0 +1,8 @@
package com.continental.retailapp.identitystore.api.response
/** 门店对外视图,字段与客户端已实现的契约一致(06-api-design.md)。 */
data class StoreResponse(
val id: Long,
val name: String,
val code: String,
)
@@ -0,0 +1,179 @@
package com.continental.retailapp.identitystore.application
import com.continental.retailapp.identitystore.api.request.LoginRequest
import com.continental.retailapp.identitystore.api.response.LoginResponse
import com.continental.retailapp.identitystore.api.response.MeResponse
import com.continental.retailapp.identitystore.api.response.TokenResponse
import com.continental.retailapp.identitystore.api.response.UserResponse
import com.continental.retailapp.identitystore.infrastructure.persistence.StoreRepository
import com.continental.retailapp.identitystore.infrastructure.persistence.UserEntity
import com.continental.retailapp.identitystore.infrastructure.persistence.UserJpaRepository
import com.continental.retailapp.identitystore.infrastructure.persistence.UserStatus
import com.continental.retailapp.platform.observability.audit.Audited
import com.continental.retailapp.platform.security.AccessTokenIssuer
import com.continental.retailapp.platform.web.BusinessException
import com.continental.retailapp.platform.web.ErrorCode
import io.micrometer.core.instrument.MeterRegistry
import org.slf4j.LoggerFactory
import org.springframework.http.HttpStatus
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.Clock
import java.time.Duration
/**
* 登录 / 刷新 / 登出 / me,见 04-security-auth.md。
*
* 两条贯穿本类的规则:
*
* 1. **失败响应必须无差别**。"用户不存在""密码错误""账号被锁"对外都是同一句
* "用户名或密码错误"、同一个错误码。区分开来等于送给攻击方一个用户名枚举接口。
* 2. **密码和令牌不进日志**。日志里只出现 `userId`,不出现 `username`PII)、更不出现密码。
*/
@Service
class AuthAppService(
private val userJpaRepository: UserJpaRepository,
private val storeRepository: StoreRepository,
private val passwordEncoder: PasswordEncoder,
private val accessTokenIssuer: AccessTokenIssuer,
private val refreshTokenService: RefreshTokenService,
private val meterRegistry: MeterRegistry,
private val clock: Clock,
) {
private val log = LoggerFactory.getLogger(javaClass)
@Audited(action = "LOGIN")
@Transactional
fun login(request: LoginRequest): LoginResponse {
val now = clock.instant()
val user = userJpaRepository.findByUsername(request.username)
// 用户不存在也要走一次 BCrypt:否则"不存在"的响应会明显更快,
// 时间差本身就是一个可用的用户名枚举信号。
if (user == null) {
passwordEncoder.matches(request.password, DUMMY_HASH)
return failLogin("USER_NOT_FOUND")
}
if (user.status != UserStatus.ACTIVE) {
return failLogin("DISABLED")
}
if (user.isLocked(now)) {
return failLogin("LOCKED")
}
if (!passwordEncoder.matches(request.password, user.passwordHash)) {
registerFailure(user)
return failLogin("BAD_PASSWORD")
}
user.failedAttempts = 0
user.lockedUntil = null
val storeId = resolveCurrentStore(user)
val roles = roleNamesOf(user.id!!, storeId)
user.currentStoreId = storeId
countLogin("success")
return LoginResponse(
accessToken = accessTokenIssuer.issue(user.id!!, storeId, roles),
refreshToken = refreshTokenService.issue(user.id!!),
user = user.toResponse(),
)
}
/**
* 刷新。轮换只换 refresh token 和 access token**不换门店**——
* 门店取自 `user.current_store_id`,否则每次刷新都会把用户弹回默认门店。
*/
@Transactional
fun refresh(rawRefreshToken: String): TokenResponse {
val rotated = refreshTokenService.rotate(rawRefreshToken)
val user = userJpaRepository.findById(rotated.userId).orElseThrow { InvalidRefreshTokenException() }
if (user.status != UserStatus.ACTIVE) {
// 账号在这期间被停用:连带把该用户所有令牌作废,不给它继续刷的机会
refreshTokenService.revokeAllByUser(user.id!!)
throw InvalidRefreshTokenException()
}
val storeId = resolveCurrentStore(user)
user.currentStoreId = storeId
return TokenResponse(
accessToken = accessTokenIssuer.issue(user.id!!, storeId, roleNamesOf(user.id!!, storeId)),
refreshToken = rotated.refreshToken,
)
}
@Audited(action = "LOGOUT")
@Transactional
fun logout(rawRefreshToken: String) = refreshTokenService.revoke(rawRefreshToken)
/** 客户端每次冷启动都会调,必须是只读的轻量查询——不要在这里做任何写操作。 */
@Transactional(readOnly = true)
fun me(userId: Long): MeResponse {
val user = userJpaRepository.findById(userId).orElseThrow {
BusinessException(ErrorCode.UNAUTHORIZED, "登录状态已失效,请重新登录", HttpStatus.UNAUTHORIZED)
}
return MeResponse(user = user.toResponse(), currentStoreId = user.currentStoreId)
}
/**
* 选门店:优先沿用上次的,但**必须重新校验它现在还可访问**——
* 授权可能在两次登录之间被收回,直接信任 `current_store_id` 就是越权。
*/
private fun resolveCurrentStore(user: UserEntity): Long {
val userId = user.id!!
user.currentStoreId
?.let { storeRepository.findAccessibleStore(userId, it) }
?.let { return it.id }
return storeRepository.findStoresByUserId(userId).firstOrNull()?.id
?: throw BusinessException(
ErrorCode.NO_STORE_PERMISSION,
"当前账号未被授权任何门店,请联系管理员",
HttpStatus.FORBIDDEN,
)
}
private fun roleNamesOf(userId: Long, storeId: Long): List<String> =
storeRepository.findRoleNames(userId, storeId)
?.split(',')
?.map { it.trim() }
?.filter { it.isNotEmpty() }
.orEmpty()
private fun registerFailure(user: UserEntity) {
user.failedAttempts += 1
if (user.failedAttempts >= MAX_FAILED_ATTEMPTS) {
user.lockedUntil = clock.instant().plus(LOCK_DURATION)
user.failedAttempts = 0
log.warn("用户 {} 连续登录失败 {} 次,已锁定 {} 分钟", user.id, MAX_FAILED_ATTEMPTS, LOCK_DURATION.toMinutes())
}
}
/** 所有失败分支收敛到这一个出口,保证对外文案与错误码完全一致。 */
private fun failLogin(internalReason: String): Nothing {
countLogin("failure", internalReason)
throw BusinessException(ErrorCode.UNAUTHORIZED, "用户名或密码错误", HttpStatus.UNAUTHORIZED)
}
/**
* 登录成功率埋点(08-observability.md)。
* `reason` 只有有限几个取值,不会把标签基数打爆。
*/
private fun countLogin(result: String, reason: String = "none") {
meterRegistry.counter("auth.login", "result", result, "reason", reason).increment()
}
private fun UserEntity.toResponse() = UserResponse(id = id!!, username = username, displayName = displayName)
private companion object {
const val MAX_FAILED_ATTEMPTS = 5
val LOCK_DURATION: Duration = Duration.ofMinutes(15)
/**
* 用户不存在时拿来空跑一次 BCrypt 的假哈希(明文是随机的,没人知道)。
* 目的只是消掉时间差,它永远不会匹配成功。
*/
const val DUMMY_HASH = "{bcrypt}\$2a\$12\$C6UzMDM.H6dfI/f/IKcEe.3XkkkkkkkkkkkkkkkkkkkkkkkkkkkkO"
}
}
@@ -0,0 +1,18 @@
package com.continental.retailapp.identitystore.application
import com.continental.retailapp.platform.web.BusinessException
import com.continental.retailapp.platform.web.ErrorCode
import org.springframework.http.HttpStatus
/**
* refresh token 不可用的统一出口。
*
* 对外只有一句"登录状态已失效"——不区分"没见过这个 token""已经被轮换掉了""过期了"。
* 这三种情况在服务端走完全不同的分支(见 [RefreshTokenService.rotate]),
* 但对外必须长得一模一样,否则调用方可以拿响应差异来探测令牌是否存在。
*/
class InvalidRefreshTokenException : BusinessException(
code = ErrorCode.UNAUTHORIZED,
message = "登录状态已失效,请重新登录",
httpStatus = HttpStatus.UNAUTHORIZED,
)
@@ -0,0 +1,19 @@
package com.continental.retailapp.identitystore.application
/**
* 角色 → 菜单的最小映射。
*
* **菜单不是安全边界**:它只决定客户端画不画那个入口。真正的边界是各端点上的
* `@PreAuthorize`——即便菜单里没有,直接调接口也必须被拦住(04-security-auth.md)。
* 完整的菜单权限模型 04 文档标了"待补充",这里先给一个够跑通链路的实现。
*/
object MenuCatalog {
private val BY_ROLE = mapOf(
"STORE_MANAGER" to listOf("workbench", "procurement", "warranty", "report", "staff"),
"STAFF" to listOf("workbench", "warranty"),
)
fun menusOf(roles: List<String>): List<String> =
roles.flatMap { BY_ROLE[it].orEmpty() }.distinct()
}
@@ -0,0 +1,36 @@
package com.continental.retailapp.identitystore.application
import com.continental.retailapp.identitystore.infrastructure.persistence.RefreshTokenJpaRepository
import net.javacrumbs.shedlock.spring.annotation.SchedulerLock
import org.slf4j.LoggerFactory
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Component
import org.springframework.transaction.annotation.Transactional
import java.time.Clock
/**
* 清理过期的 refresh token12-concurrency-and-scheduling.md)。
*
* 定时任务放 `application` 层:它就是一个由时钟而不是 HTTP 请求触发的用例。
*
* - `@SchedulerLock` 不能省:`@Scheduled` 在每个 Pod 上都会独立执行,2 个副本就跑 2 遍。
* 删除本身是幂等的,但保持"所有定时任务都加锁"这条规则比逐个判断更可靠。
* - cron 用 3:17 而不是 3:00:整点是全世界定时任务的尖峰,错开几分钟没有业务代价。
* - 自己兜住异常:`@Scheduled` 抛异常只会被 Spring 记一条日志,不会重试,也不会告警。
*/
@Component
class RefreshTokenCleanupJob(
private val repository: RefreshTokenJpaRepository,
private val clock: Clock,
) {
private val log = LoggerFactory.getLogger(javaClass)
@Scheduled(cron = "0 17 3 * * *")
@SchedulerLock(name = "refreshTokenCleanup", lockAtLeastFor = "PT1M", lockAtMostFor = "PT10M")
@Transactional
fun cleanup() {
runCatching { repository.deleteExpiredBefore(clock.instant()) }
.onSuccess { log.info("清理过期 refresh token,删除 {} 条", it) }
.onFailure { log.error("清理过期 refresh token 失败", it) }
}
}
@@ -0,0 +1,103 @@
package com.continental.retailapp.identitystore.application
import com.continental.retailapp.identitystore.infrastructure.persistence.RefreshTokenEntity
import com.continental.retailapp.identitystore.infrastructure.persistence.RefreshTokenJpaRepository
import com.continental.retailapp.platform.security.JwtProperties
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.security.MessageDigest
import java.security.SecureRandom
import java.time.Clock
import java.time.temporal.ChronoUnit
import java.util.Base64
import java.util.HexFormat
/** 轮换结果:明文 refresh token 只在这里往外传一次,之后就只剩 hash 了。 */
data class RotatedTokens(val userId: Long, val refreshToken: String)
/**
* refresh token 的签发、轮换、撤销,逐条按 04-security-auth.md。
*
* refresh token 是**不透明随机串**而不是 JWT:它需要能被立即撤销,
* 而自包含的 JWT 在过期前撤不掉。库里只存 SHA-256 摘要。
*
* [Clock] 走构造注入而不是 `Instant.now()`,测试里才能用 `Clock.fixed` 精确控制过期边界(10-testing.md)。
*/
@Service
class RefreshTokenService(
private val repository: RefreshTokenJpaRepository,
private val props: JwtProperties,
private val clock: Clock,
) {
private val log = LoggerFactory.getLogger(javaClass)
private val random = SecureRandom()
@Transactional
fun issue(userId: Long): String {
val rawToken = generateOpaqueToken()
repository.save(
RefreshTokenEntity(
userId = userId,
tokenHash = sha256Hex(rawToken),
expiresAt = clock.instant().plus(props.refreshTokenTtlDays, ChronoUnit.DAYS),
),
)
return rawToken // 返回明文给客户端,库里只有 hash
}
@Transactional
fun rotate(rawToken: String): RotatedTokens {
val existing = repository.findByTokenHash(sha256Hex(rawToken))
?: throw InvalidRefreshTokenException() // 从来不存在的 token
// 重放检测:这个 token 存在,但已经被轮换掉了 —— 说明它很可能已泄漏,
// 因为正常客户端拿到新 token 后不会再用旧的。撤销该用户全部 refresh token,强制重新登录。
//
// "从来没见过的 token" 和 "已撤销的 token" 必须分成两个分支,
// 合成一个 `?: throw` 会让重放检测形同虚设。
if (existing.revokedAt != null) {
repository.revokeAllByUserId(existing.userId, clock.instant())
log.warn("检测到 refresh token 重放,已撤销用户 {} 的全部 refresh token", existing.userId)
throw InvalidRefreshTokenException()
}
if (existing.isExpired(clock.instant())) {
throw InvalidRefreshTokenException()
}
val newRawToken = generateOpaqueToken()
val rotated = repository.save(
RefreshTokenEntity(
userId = existing.userId,
tokenHash = sha256Hex(newRawToken),
expiresAt = clock.instant().plus(props.refreshTokenTtlDays, ChronoUnit.DAYS),
),
)
existing.revokedAt = clock.instant()
existing.replacedByTokenId = rotated.id
return RotatedTokens(userId = existing.userId, refreshToken = newRawToken)
}
@Transactional
fun revoke(rawToken: String) {
repository.findByTokenHash(sha256Hex(rawToken))
?.takeIf { it.revokedAt == null }
?.let { it.revokedAt = clock.instant() }
// 登出场景:token 不存在或已撤销都视为成功,不给调用方任何"这个 token 存不存在"的信号
}
/** 强制下线(改密码、检测到异常登录时用)。 */
@Transactional
fun revokeAllByUser(userId: Long): Int = repository.revokeAllByUserId(userId, clock.instant())
/** 256 bit 随机 + URL-safe base64,够长到不可枚举。 */
private fun generateOpaqueToken(): String {
val bytes = ByteArray(32)
random.nextBytes(bytes)
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)
}
private fun sha256Hex(raw: String): String =
HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(raw.toByteArray()))
}
@@ -0,0 +1,75 @@
package com.continental.retailapp.identitystore.application
import com.continental.retailapp.identitystore.api.response.StoreContextResponse
import com.continental.retailapp.identitystore.api.response.StoreResponse
import com.continental.retailapp.identitystore.application.mapper.StoreMapper
import com.continental.retailapp.identitystore.contract.StoreSwitchedEvent
import com.continental.retailapp.identitystore.infrastructure.persistence.StoreRepository
import com.continental.retailapp.identitystore.infrastructure.persistence.UserJpaRepository
import com.continental.retailapp.platform.observability.audit.Audited
import com.continental.retailapp.platform.security.AccessTokenIssuer
import com.continental.retailapp.platform.security.StoreContextHolder
import com.continental.retailapp.platform.web.BusinessException
import com.continental.retailapp.platform.web.ErrorCode
import org.springframework.context.ApplicationEventPublisher
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.Clock
/**
* 门店列表与切店。
*
* 命名说明:11-cross-domain-collaboration.md 里这段逻辑叫 `StoreSwitchAppService`
* 04-security-auth.md 里叫 `StoreAppService`。骨架统一用后者——
* "看得到哪些店"和"切到哪家店"本来就是一组用例,拆成两个 service 只会让上下文来回传。
*/
@Service
class StoreAppService(
private val storeRepository: StoreRepository,
private val storeMapper: StoreMapper,
private val userJpaRepository: UserJpaRepository,
private val accessTokenIssuer: AccessTokenIssuer,
private val storeContextHolder: StoreContextHolder,
private val events: ApplicationEventPublisher,
private val clock: Clock,
) {
@Transactional(readOnly = true)
fun listAccessibleStores(): List<StoreResponse> =
storeMapper.toResponseList(storeRepository.findStoresByUserId(storeContextHolder.currentUserId()))
@Audited(action = "SWITCH_STORE")
@Transactional
fun switchStore(targetStoreId: Long): StoreContextResponse {
val userId = storeContextHolder.currentUserId()
// 1. 必须校验目标门店对当前用户可访问 —— 否则任何人改一下 URL 里的 id 就能进别人的门店。
// 校验合进查询条件("查不到"即"没权限"),而不是查出来再判断。
val store = storeRepository.findAccessibleStore(userId, targetStoreId)
?: throw BusinessException(ErrorCode.STORE_NOT_ACCESSIBLE, "无权访问该门店", HttpStatus.FORBIDDEN)
val roles = storeRepository.findRoleNames(userId, targetStoreId)
?.split(',')?.map { it.trim() }?.filter { it.isNotEmpty() }
.orEmpty()
val fromStoreId = storeContextHolder.storeId
userJpaRepository.findById(userId).ifPresent { it.currentStoreId = targetStoreId }
// 2. 重新签发 access token —— 新 token 里的 storeId 是目标门店。
// 这一步不能省:token 里的 storeId 决定了后续所有请求的数据范围,
// 只改客户端本地状态不换 token,等于门店没真的切过去。
// refresh token 不跟着换:它绑定的是身份,不是门店。
val accessToken = accessTokenIssuer.issue(userId, targetStoreId, roles)
// 3. 切店会使旧门店下的 WebView 票据失效。走领域事件,
// 不由 identity-store 直接改 webview-ticket 的表(11-cross-domain-collaboration.md)。
events.publishEvent(StoreSwitchedEvent(userId, fromStoreId, targetStoreId, clock.instant()))
return StoreContextResponse(
accessToken = accessToken,
store = storeMapper.toResponse(store),
roles = roles,
menus = MenuCatalog.menusOf(roles),
)
}
}
@@ -0,0 +1,36 @@
package com.continental.retailapp.identitystore.application
import com.continental.retailapp.identitystore.contract.StoreInfo
import com.continental.retailapp.identitystore.contract.StoreQueryService
import com.continental.retailapp.identitystore.infrastructure.persistence.StoreRepository
import org.springframework.cache.annotation.Cacheable
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
/**
* 契约实现:别的 domain 想知道门店信息,只能通过这个接口,
* 不能直接注入 identity-store 的 repository11-cross-domain-collaboration.md)。
*
* 返回的是契约模型 [StoreInfo] 而不是 Entity/投影——契约模型是独立的数据结构,
* 让它跟着 `StoreEntity` 一起长,等于把内部表结构变成了对外承诺。
*/
@Service
class StoreQueryServiceImpl(
private val storeRepository: StoreRepository,
) : StoreQueryService {
@Transactional(readOnly = true)
override fun listStoresByUserId(userId: Long): List<StoreInfo> =
storeRepository.findStoresByUserId(userId).map { StoreInfo(it.id, it.name, it.code) }
/**
* 门店基础信息读多写少、变更不敏感,适合本地缓存(12-concurrency-and-scheduling.md)。
*
* 注意对比:**用户对门店的访问权限绝不能缓存**——权限收回必须立刻生效,
* 所以 [listStoresByUserId] 和 `findAccessibleStore` 上都没有 `@Cacheable`。
*/
@Cacheable(cacheNames = ["storeBasicInfo"], key = "#storeId")
@Transactional(readOnly = true)
override fun findStore(storeId: Long): StoreInfo? =
storeRepository.findStoreById(storeId)?.let { StoreInfo(it.id, it.name, it.code) }
}
@@ -0,0 +1,19 @@
package com.continental.retailapp.identitystore.application.mapper
import com.continental.retailapp.identitystore.api.response.StoreResponse
import com.continental.retailapp.identitystore.infrastructure.persistence.StoreView
import org.mapstruct.Mapper
/**
* 投影 → Response 的转换,编译期由 MapStruct 生成实现(06-api-design.md)。
*
* 放在 `application` 层而不是 `api/mapper/`:它的入参是 `infrastructure` 里的投影类型,
* 放进 `api` 就等于让 `api` 依赖 `infrastructure`,会被 ArchUnit 判红。
*/
@Mapper(componentModel = "spring")
interface StoreMapper {
fun toResponse(view: StoreView): StoreResponse
fun toResponseList(views: List<StoreView>): List<StoreResponse>
}
@@ -0,0 +1,77 @@
package com.continental.retailapp.identitystore.infrastructure.config
import com.continental.retailapp.identitystore.infrastructure.persistence.StoreEntity
import com.continental.retailapp.identitystore.infrastructure.persistence.StoreJpaRepository
import com.continental.retailapp.identitystore.infrastructure.persistence.UserEntity
import com.continental.retailapp.identitystore.infrastructure.persistence.UserJpaRepository
import com.continental.retailapp.identitystore.infrastructure.persistence.UserStoreEntity
import com.continental.retailapp.identitystore.infrastructure.persistence.UserStoreJpaRepository
import org.slf4j.LoggerFactory
import org.springframework.boot.ApplicationRunner
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.context.annotation.Profile
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.transaction.annotation.Transactional
/**
* 本地开发用的种子数据:一个 demo 用户 + 两家门店。
*
* **刻意不写进 Flyway 脚本**:迁移脚本在 dev/uat/prod 也会跑,
* 一个带已知密码的账号进到线上库就是一个后门。放在 `@Profile("local")` 的 runner 里,
* 跑在别的 profile 上时它根本不会被实例化。
*
* 账号:`demo` / `demo1234`(仅 local)。
*/
@Configuration
@Profile("local")
class LocalSeedDataConfig {
@Bean
fun localSeedDataRunner(seeder: LocalSeedDataSeeder) = ApplicationRunner { seeder.seed() }
}
@Configuration
@Profile("local")
class LocalSeedDataSeeder(
private val userJpaRepository: UserJpaRepository,
private val storeJpaRepository: StoreJpaRepository,
private val userStoreJpaRepository: UserStoreJpaRepository,
private val passwordEncoder: PasswordEncoder,
) {
private val log = LoggerFactory.getLogger(javaClass)
@Transactional
fun seed() {
if (userJpaRepository.findByUsername(SEED_USERNAME) != null) {
return
}
val stores = storeJpaRepository.saveAll(
listOf(
StoreEntity(name = "上海徐汇店", code = "SH-XH-001"),
StoreEntity(name = "上海浦东店", code = "SH-PD-002"),
),
)
val user = userJpaRepository.save(
UserEntity(
username = SEED_USERNAME,
passwordHash = requireNotNull(passwordEncoder.encode(SEED_PASSWORD)),
displayName = "演示店长",
currentStoreId = stores.first().id,
),
)
userStoreJpaRepository.saveAll(
listOf(
UserStoreEntity(userId = user.id!!, storeId = stores[0].id!!, roles = "STORE_MANAGER"),
UserStoreEntity(userId = user.id!!, storeId = stores[1].id!!, roles = "STAFF"),
),
)
log.info("已写入本地种子数据:用户 {} / 门店 {} 家", SEED_USERNAME, stores.size)
}
private companion object {
const val SEED_USERNAME = "demo"
const val SEED_PASSWORD = "demo1234"
}
}
@@ -0,0 +1,46 @@
package com.continental.retailapp.identitystore.infrastructure.persistence
import com.continental.retailapp.platform.persistence.BaseEntity
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.GeneratedValue
import jakarta.persistence.GenerationType
import jakarta.persistence.Id
import jakarta.persistence.Table
import java.time.Instant
/**
* 刷新令牌,见 04-security-auth.md。
*
* 只存 SHA-256 十六进制摘要,**永远不存明文**:库被拖走也换不出 access token。
* 摘要长度固定 64,所以列类型是 `char(64)` 而不是 varchar。
*
* `revokedAt` + `replacedByTokenId` 一起支撑轮换与重放检测:
* 一条已经 revoke 的令牌再次被使用,说明它被人截获重放了,
* 此时把该用户名下所有令牌全部作废(见 [RefreshTokenService.rotate])。
*/
@Entity
@Table(name = "refresh_token", schema = "identity_store")
class RefreshTokenEntity(
@Column(name = "user_id", nullable = false)
var userId: Long,
@Column(name = "token_hash", nullable = false, length = 64, unique = true)
var tokenHash: String,
@Column(name = "expires_at", nullable = false)
var expiresAt: Instant,
@Column(name = "revoked_at")
var revokedAt: Instant? = null,
@Column(name = "replaced_by_token_id")
var replacedByTokenId: Long? = null,
) : BaseEntity() {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
var id: Long? = null
fun isExpired(now: Instant): Boolean = !expiresAt.isAfter(now)
}
@@ -0,0 +1,34 @@
package com.continental.retailapp.identitystore.infrastructure.persistence
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Modifying
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import org.springframework.stereotype.Repository
import java.time.Instant
@Repository
interface RefreshTokenJpaRepository : JpaRepository<RefreshTokenEntity, Long> {
fun findByTokenHash(tokenHash: String): RefreshTokenEntity?
/**
* 检测到重放时一次性作废该用户的所有令牌。
*
* 用批量 update 而不是"查出来逐条改":重放场景下要的是尽快关门,
* 而且一个用户的令牌可能有几十条(多端登录 + 轮换历史)。
*/
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query(
"""
update RefreshTokenEntity t set t.revokedAt = :now
where t.userId = :userId and t.revokedAt is null
""",
)
fun revokeAllByUserId(@Param("userId") userId: Long, @Param("now") now: Instant): Int
/** 清理任务用:过期且已经没有保留价值的令牌直接删(12-concurrency-and-scheduling.md)。 */
@Modifying
@Query("delete from RefreshTokenEntity t where t.expiresAt < :before")
fun deleteExpiredBefore(@Param("before") before: Instant): Int
}
@@ -0,0 +1,33 @@
package com.continental.retailapp.identitystore.infrastructure.persistence
import com.continental.retailapp.platform.persistence.VersionedEntity
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.EnumType
import jakarta.persistence.Enumerated
import jakarta.persistence.GeneratedValue
import jakarta.persistence.GenerationType
import jakarta.persistence.Id
import jakarta.persistence.Table
enum class StoreStatus { ACTIVE, CLOSED }
/** 门店。 */
@Entity
@Table(name = "store", schema = "identity_store")
class StoreEntity(
@Column(name = "name", nullable = false, length = 128)
var name: String,
@Column(name = "code", nullable = false, length = 32)
var code: String,
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 16)
var status: StoreStatus = StoreStatus.ACTIVE,
) : VersionedEntity() {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
var id: Long? = null
}
@@ -0,0 +1,38 @@
package com.continental.retailapp.identitystore.infrastructure.persistence
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import org.springframework.stereotype.Repository
@Repository
interface StoreJpaRepository : JpaRepository<StoreEntity, Long>, StoreRepository {
@Query(
"""
select s.id as id, s.name as name, s.code as code
from StoreEntity s
join UserStoreEntity us on us.storeId = s.id
where us.userId = :userId and s.status = com.continental.retailapp.identitystore.infrastructure.persistence.StoreStatus.ACTIVE
order by s.id
""",
)
override fun findStoresByUserId(@Param("userId") userId: Long): List<StoreView>
@Query(
"""
select s.id as id, s.name as name, s.code as code
from StoreEntity s
join UserStoreEntity us on us.storeId = s.id
where us.userId = :userId and s.id = :storeId
and s.status = com.continental.retailapp.identitystore.infrastructure.persistence.StoreStatus.ACTIVE
""",
)
override fun findAccessibleStore(@Param("userId") userId: Long, @Param("storeId") storeId: Long): StoreView?
@Query("select s.id as id, s.name as name, s.code as code from StoreEntity s where s.id = :storeId")
override fun findStoreById(@Param("storeId") storeId: Long): StoreView?
@Query("select us.roles from UserStoreEntity us where us.userId = :userId and us.storeId = :storeId")
override fun findRoleNames(@Param("userId") userId: Long, @Param("storeId") storeId: Long): String?
}
@@ -0,0 +1,25 @@
package com.continental.retailapp.identitystore.infrastructure.persistence
/**
* 门店查询口径,`application` 依赖的是这个接口而不是 [StoreJpaRepository]02-layering.md)。
*
* [findAccessibleStore] 把权限校验合进了查询条件——"查不到"就是"没权限"
* 而不是先查出来再判断。后者很容易漏判,见 04-security-auth.md 的越权规则第 2 条。
*/
interface StoreRepository {
fun findStoresByUserId(userId: Long): List<StoreView>
/** 目标门店对该用户可访问才返回;不可访问返回 null。切店前必须过这一关。 */
fun findAccessibleStore(userId: Long, storeId: Long): StoreView?
fun findStoreById(storeId: Long): StoreView?
/**
* 返回逗号分隔的原始角色串,解析交给 `application`。
*
* 对 04 文档的小改名:文档里叫 `findRoles` 并直接返回 `List<String>`
* 但角色在库里就是一个逗号串,拆分是业务转换、不该藏在 repository 的方法名背后。
*/
fun findRoleNames(userId: Long, storeId: Long): String?
}
@@ -0,0 +1,14 @@
package com.continental.retailapp.identitystore.infrastructure.persistence
/**
* Spring Data 接口投影:只声明这次查询需要的字段,Hibernate 只 select 这几列。
*
* 用它而不是直接返回 [StoreEntity],是为了让 `application` 拿到的东西不带 Entity 的
* 生命周期(游离态/懒加载)和无关字段——不需要额外写一个类,接口本身就是契约。
* 见 02-layering.md。
*/
interface StoreView {
val id: Long
val name: String
val code: String
}
@@ -0,0 +1,55 @@
package com.continental.retailapp.identitystore.infrastructure.persistence
import com.continental.retailapp.platform.persistence.VersionedEntity
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.EnumType
import jakarta.persistence.Enumerated
import jakarta.persistence.GeneratedValue
import jakarta.persistence.GenerationType
import jakarta.persistence.Id
import jakarta.persistence.Table
import java.time.Instant
enum class UserStatus { ACTIVE, DISABLED }
/**
* 用户,见 04-security-auth.md。
*
* 几个约定:
* - 密码只存 BCrypt 哈希,字段名叫 `passwordHash` 而不是 `password`,减少误打日志的概率;
* - `failedAttempts` / `lockedUntil` 实现"连续失败 5 次锁定 15 分钟";
* - `currentStoreId` 记住用户上次选的门店,刷新 token 时用它重签,不至于一刷新就掉回默认门店。
*/
@Entity
@Table(name = "user", schema = "identity_store")
class UserEntity(
@Column(name = "username", nullable = false, length = 64)
var username: String,
@Column(name = "password_hash", nullable = false, length = 100)
var passwordHash: String,
@Column(name = "display_name", nullable = false, length = 64)
var displayName: String,
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 16)
var status: UserStatus = UserStatus.ACTIVE,
@Column(name = "current_store_id")
var currentStoreId: Long? = null,
@Column(name = "failed_attempts", nullable = false)
var failedAttempts: Int = 0,
@Column(name = "locked_until")
var lockedUntil: Instant? = null,
) : VersionedEntity() {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
var id: Long? = null
fun isLocked(now: Instant): Boolean = lockedUntil?.isAfter(now) == true
}
@@ -0,0 +1,10 @@
package com.continental.retailapp.identitystore.infrastructure.persistence
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
@Repository
interface UserJpaRepository : JpaRepository<UserEntity, Long> {
fun findByUsername(username: String): UserEntity?
}
@@ -0,0 +1,37 @@
package com.continental.retailapp.identitystore.infrastructure.persistence
import com.continental.retailapp.platform.persistence.BaseEntity
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.GeneratedValue
import jakarta.persistence.GenerationType
import jakarta.persistence.Id
import jakarta.persistence.Table
/**
* 用户 ↔ 门店的授权关系。这张表是"能不能看这家店"的唯一事实来源。
*
* 刻意用 `userId` / `storeId` 裸字段而不是 `@ManyToOne`
* 授权判定全是按 id 的存在性查询,建关联只会让 Hibernate 多拉两张表。
*/
@Entity
@Table(name = "user_store", schema = "identity_store")
class UserStoreEntity(
@Column(name = "user_id", nullable = false)
var userId: Long,
@Column(name = "store_id", nullable = false)
var storeId: Long,
/**
* 逗号分隔,写进 JWT 的 `roles` claim。
* 角色挂在"用户 × 门店"上而不是用户上:同一个人在 A 店是店长、在 B 店可能只是接待。
*/
@Column(name = "roles", nullable = false, length = 255)
var roles: String = "STAFF",
) : BaseEntity() {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
var id: Long? = null
}
@@ -0,0 +1,11 @@
package com.continental.retailapp.identitystore.infrastructure.persistence
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
/**
* `user_store` 在授权查询里只作为 join 出现,本身不需要仓储;
* 建它是为了写入方(本地种子数据、后续的授权管理用例)有一个正常的入口。
*/
@Repository
interface UserStoreJpaRepository : JpaRepository<UserStoreEntity, Long>
@@ -0,0 +1,64 @@
-- identity_store 库初始化:用户、门店、用户↔门店授权关系。
--
-- 约定(03-persistence.md):
-- * 时间列一律 datetime(6),存 UTC;应用侧对应 Instant。
-- * 每张表都带 BaseEntity 的四个审计列;需要乐观锁的再加 version。
-- * 字符集 utf8mb4 + utf8mb4_0900_ai_ci,跟 MySQL 8.4 默认保持一致。
--
-- 种子数据不写在这里,见 infrastructure/config/LocalSeedDataRunner(只在 local profile 生效)。
create table `user`
(
id bigint not null auto_increment,
username varchar(64) not null,
password_hash varchar(100) not null comment 'BCrypt(strength=12),永远不存明文',
display_name varchar(64) not null,
status varchar(16) not null default 'ACTIVE',
current_store_id bigint null comment '上次选中的门店,刷新 token 时据此重签',
failed_attempts int not null default 0,
locked_until datetime(6) null comment '连续失败 5 次后锁定 15 分钟',
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_user_username (username)
) engine = InnoDB
default charset = utf8mb4
collate = utf8mb4_0900_ai_ci comment '用户';
create table store
(
id bigint not null auto_increment,
name varchar(128) not null,
code varchar(32) not null,
status varchar(16) not null default 'ACTIVE',
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_store_code (code)
) engine = InnoDB
default charset = utf8mb4
collate = utf8mb4_0900_ai_ci comment '门店';
create table user_store
(
id bigint not null auto_increment,
user_id bigint not null,
store_id bigint not null,
roles varchar(255) not null default 'STAFF' comment '逗号分隔;角色是"用户×门店"维度的',
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_user_store (user_id, store_id),
key idx_user_store_store_id (store_id)
) engine = InnoDB
default charset = utf8mb4
collate = utf8mb4_0900_ai_ci comment '用户可访问的门店(授权的唯一事实来源)';
@@ -0,0 +1,26 @@
-- refresh tokenDDL 逐字取自 04-security-auth.md。
--
-- token_hash 是 char(64) 而不是 varcharSHA-256 十六进制摘要长度固定,
-- 定长列在唯一索引上更省空间、比较也更快。库里没有明文,拖库换不出 access token。
create table refresh_token
(
id bigint not null auto_increment,
user_id bigint not null,
token_hash char(64) not null,
expires_at datetime(6) not null,
revoked_at datetime(6),
replaced_by_token_id bigint,
-- BaseEntity 的四个审计列,缺任何一个第一次插入就会失败,见 03-persistence.md
created_at datetime(6) not null,
updated_at datetime(6) not null,
created_by varchar(64),
updated_by varchar(64),
primary key (id),
unique key uk_refresh_token_hash (token_hash),
key idx_refresh_token_user_id (user_id),
key idx_refresh_token_expires_at (expires_at),
constraint fk_refresh_token_user foreign key (user_id) references `user` (id)
) engine = InnoDB
default charset = utf8mb4
collate = utf8mb4_0900_ai_ci;
@@ -0,0 +1,11 @@
package com.continental.retailapp.identitystore
import org.springframework.boot.autoconfigure.SpringBootApplication
/**
* `@DataJpaTest` 需要在包层级上能找到一个 `@SpringBootConfiguration`。
* 真正的入口类在 `bootstrap` 模块,而 domain 模块不许反向依赖 bootstrap01-project-structure.md),
* 所以这里给测试单独放一个最小入口,只覆盖本模块的实体与仓储。
*/
@SpringBootApplication(scanBasePackages = ["com.continental.retailapp.identitystore"])
class IdentityStoreTestApplication
@@ -0,0 +1,155 @@
package com.continental.retailapp.identitystore.application
import com.continental.retailapp.identitystore.fixtures.aJwtProperties
import com.continental.retailapp.identitystore.fixtures.aRefreshTokenEntity
import com.continental.retailapp.identitystore.infrastructure.persistence.RefreshTokenEntity
import com.continental.retailapp.identitystore.infrastructure.persistence.RefreshTokenJpaRepository
import io.mockk.every
import io.mockk.mockk
import io.mockk.slot
import io.mockk.verify
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.security.MessageDigest
import java.time.Clock
import java.time.Instant
import java.time.ZoneOffset
import java.util.HexFormat
/**
* 04-security-auth.md 里 refresh token 的三条分支:正常轮换、重放检测、过期。
*
* 重放检测那条是这套设计的关键——"从来没见过的 token"和"已撤销的 token"
* 必须走不同分支,合并成一个 `?: throw` 就等于没做重放检测。
*/
class RefreshTokenServiceTest {
private val now = Instant.parse("2026-01-01T00:00:00Z")
private val clock = Clock.fixed(now, ZoneOffset.UTC)
private val repository = mockk<RefreshTokenJpaRepository>()
private val service = RefreshTokenService(repository, aJwtProperties(), clock)
private fun sha256Hex(raw: String): String =
HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(raw.toByteArray()))
@Test
fun `签发时库里只存摘要,明文只返回给调用方`() {
val saved = slot<RefreshTokenEntity>()
every { repository.save(capture(saved)) } answers { firstArg() }
val rawToken = service.issue(userId = 7L)
assertEquals(sha256Hex(rawToken), saved.captured.tokenHash)
assertNotEquals(rawToken, saved.captured.tokenHash)
assertEquals(7L, saved.captured.userId)
// TTL 30 天,来自 JwtProperties.refreshTokenTtlDays
assertEquals(now.plusSeconds(30 * 24 * 3600), saved.captured.expiresAt)
}
@Test
fun `轮换后旧令牌被撤销并指向新令牌`() {
val rawToken = "old-raw-token"
val existing = aRefreshTokenEntity(id = 1L, userId = 7L, tokenHash = sha256Hex(rawToken))
every { repository.findByTokenHash(sha256Hex(rawToken)) } returns existing
every { repository.save(any()) } answers { firstArg<RefreshTokenEntity>().also { it.id = 2L } }
val rotated = service.rotate(rawToken)
assertEquals(7L, rotated.userId)
assertNotEquals(rawToken, rotated.refreshToken)
assertEquals(now, existing.revokedAt)
assertEquals(2L, existing.replacedByTokenId)
verify(exactly = 0) { repository.revokeAllByUserId(any(), any()) }
}
@Test
fun `从未存在过的令牌直接拒绝,不牵连该用户其他令牌`() {
every { repository.findByTokenHash(any()) } returns null
assertThrows(InvalidRefreshTokenException::class.java) { service.rotate("never-existed") }
// 关键:随便伪造一个串就能把别人全部踢下线的话,这里就成了 DoS 入口
verify(exactly = 0) { repository.revokeAllByUserId(any(), any()) }
}
@Test
fun `重放已撤销的令牌会撤销该用户的全部令牌`() {
val rawToken = "leaked-raw-token"
val revoked = aRefreshTokenEntity(
userId = 7L,
tokenHash = sha256Hex(rawToken),
revokedAt = now.minusSeconds(60),
)
every { repository.findByTokenHash(sha256Hex(rawToken)) } returns revoked
every { repository.revokeAllByUserId(7L, now) } returns 3
assertThrows(InvalidRefreshTokenException::class.java) { service.rotate(rawToken) }
verify(exactly = 1) { repository.revokeAllByUserId(7L, now) }
verify(exactly = 0) { repository.save(any()) }
}
@Test
fun `过期的令牌拒绝轮换`() {
val rawToken = "expired-raw-token"
val expired = aRefreshTokenEntity(
userId = 7L,
tokenHash = sha256Hex(rawToken),
// 边界:expiresAt 恰好等于 now 就算过期
expiresAt = now,
)
every { repository.findByTokenHash(sha256Hex(rawToken)) } returns expired
assertThrows(InvalidRefreshTokenException::class.java) { service.rotate(rawToken) }
verify(exactly = 0) { repository.save(any()) }
// 过期不是安全事件,只是自然到期,不该连坐该用户其他令牌
verify(exactly = 0) { repository.revokeAllByUserId(any(), any()) }
}
@Test
fun `登出时撤销令牌`() {
val rawToken = "active-raw-token"
val existing = aRefreshTokenEntity(userId = 7L, tokenHash = sha256Hex(rawToken))
every { repository.findByTokenHash(sha256Hex(rawToken)) } returns existing
service.revoke(rawToken)
assertEquals(now, existing.revokedAt)
}
@Test
fun `登出一个不存在的令牌不报错`() {
every { repository.findByTokenHash(any()) } returns null
// 不抛异常本身就是断言:登出接口不能泄露"这个 token 存不存在"
service.revoke("whatever")
}
@Test
fun `登出已撤销的令牌不会覆盖原撤销时间`() {
val firstRevokedAt = now.minusSeconds(600)
val existing = aRefreshTokenEntity(userId = 7L, revokedAt = firstRevokedAt)
every { repository.findByTokenHash(any()) } returns existing
service.revoke("already-revoked")
assertEquals(firstRevokedAt, existing.revokedAt)
}
@Test
fun `签发的明文令牌足够长且不重复`() {
every { repository.save(any()) } answers { firstArg() }
val tokens = (1..50).map { service.issue(userId = 7L) }
assertEquals(50, tokens.toSet().size)
// 32 字节 URL-safe base64 去 padding = 43 个字符
assertTrue(tokens.all { it.length == 43 }) { "令牌长度异常:${tokens.first().length}" }
assertNull(tokens.firstOrNull { it.contains('+') || it.contains('/') })
}
}
@@ -0,0 +1,39 @@
package com.continental.retailapp.identitystore.fixtures
import com.continental.retailapp.identitystore.infrastructure.persistence.RefreshTokenEntity
import com.continental.retailapp.platform.security.JwtProperties
import java.time.Instant
import java.util.Base64
/** 32 字节,刚好过 [JwtProperties.validate] 的下限——测试里不要用更短的串。 */
const val TEST_JWT_SECRET_RAW = "conti-test-secret-key-32bytes!!!"
fun testJwtSecretBase64(raw: String = TEST_JWT_SECRET_RAW): String =
Base64.getEncoder().encodeToString(raw.toByteArray())
fun aJwtProperties(
activeKeyId: String = "v1",
keys: Map<String, String> = mapOf("v1" to testJwtSecretBase64()),
accessTokenTtlMinutes: Long = 30,
refreshTokenTtlDays: Long = 30,
) = JwtProperties(
activeKeyId = activeKeyId,
keys = keys,
accessTokenTtlMinutes = accessTokenTtlMinutes,
refreshTokenTtlDays = refreshTokenTtlDays,
)
fun aRefreshTokenEntity(
id: Long? = 1L,
userId: Long = 1L,
tokenHash: String = "0".repeat(64),
expiresAt: Instant = Instant.parse("2026-02-01T00:00:00Z"),
revokedAt: Instant? = null,
replacedByTokenId: Long? = null,
) = RefreshTokenEntity(
userId = userId,
tokenHash = tokenHash,
expiresAt = expiresAt,
revokedAt = revokedAt,
replacedByTokenId = replacedByTokenId,
).also { it.id = id }
@@ -0,0 +1,125 @@
package com.continental.retailapp.identitystore.infrastructure.persistence
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Tag
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase
import org.springframework.boot.test.context.TestConfiguration
import org.springframework.boot.testcontainers.service.connection.ServiceConnection
import org.springframework.context.annotation.Bean
import org.springframework.test.context.TestPropertySource
import org.testcontainers.mysql.MySQLContainer
/**
* 仓储层的集成测试,见 10-testing.md
* **仓储不 mock**。JPQL 写错、`@Query` 投影字段对不上、跨表 join 的门店过滤漏掉,
* 这些全都只有真数据库能发现,H2 的方言差异反而会给出假的绿灯。
*
* 打了 `@Tag("integration")`:本地快速验证用 `./gradlew build -PexcludeIntegration` 跳过,
* CI 上跑完整 `./gradlew test`Runner 需要能起 Docker)。
*/
@Tag("integration")
@DataJpaTest
// 必须显式关掉"用内存库替换数据源",否则 Boot 会把 Testcontainers 顶掉,测试悄悄跑在 H2 上
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@TestPropertySource(
properties = [
// 表结构由 Flyway 建,和 dev/uat/prod 完全同一套脚本;ddl-auto 只做校验。
// 这样"实体和迁移脚本对不上"会在测试里就暴露,而不是等到部署时 validate 失败。
"spring.flyway.enabled=true",
"spring.flyway.locations=classpath:db/migration/identity_store",
"spring.flyway.schemas=identity_store",
"spring.jpa.hibernate.ddl-auto=validate",
"spring.jpa.properties.hibernate.jdbc.time_zone=UTC",
],
)
class StoreJpaRepositoryTest {
@Autowired
private lateinit var storeJpaRepository: StoreJpaRepository
@Autowired
private lateinit var userStoreJpaRepository: UserStoreJpaRepository
private var activeStoreId: Long = 0
private var otherStoreId: Long = 0
private var closedStoreId: Long = 0
@BeforeEach
fun seed() {
activeStoreId = storeJpaRepository.save(
StoreEntity(name = "静安门店", code = "SH-JA-001", status = StoreStatus.ACTIVE),
).id!!
otherStoreId = storeJpaRepository.save(
StoreEntity(name = "徐汇门店", code = "SH-XH-001", status = StoreStatus.ACTIVE),
).id!!
closedStoreId = storeJpaRepository.save(
StoreEntity(name = "已关停门店", code = "SH-XX-999", status = StoreStatus.CLOSED),
).id!!
userStoreJpaRepository.save(UserStoreEntity(userId = 1L, storeId = activeStoreId, roles = "STORE_MANAGER"))
userStoreJpaRepository.save(UserStoreEntity(userId = 1L, storeId = closedStoreId, roles = "STAFF"))
// 用户 2 只能看徐汇:越权查询的对照组
userStoreJpaRepository.save(UserStoreEntity(userId = 2L, storeId = otherStoreId, roles = "STAFF"))
}
@Test
fun `只返回该用户有权限且在营的门店`() {
val stores = storeJpaRepository.findStoresByUserId(1L)
assertEquals(listOf(activeStoreId), stores.map { it.id })
assertEquals("静安门店", stores.single().name)
}
@Test
fun `没有任何授权的用户拿到空列表`() {
assertEquals(emptyList<Long>(), storeJpaRepository.findStoresByUserId(999L).map { it.id })
}
@Test
fun `查询自己有权限的门店能查到`() {
assertNotNull(storeJpaRepository.findAccessibleStore(userId = 1L, storeId = activeStoreId))
}
@Test
fun `查询别人的门店查不到`() {
// 切店接口的安全性完全押在这个查询上:查不到就返回 STORE_NOT_ACCESSIBLE
assertNull(storeJpaRepository.findAccessibleStore(userId = 1L, storeId = otherStoreId))
}
@Test
fun `已关停门店即使有授权也查不到`() {
assertNull(storeJpaRepository.findAccessibleStore(userId = 1L, storeId = closedStoreId))
}
@Test
fun `角色按用户与门店的组合返回`() {
assertEquals("STORE_MANAGER", storeJpaRepository.findRoleNames(1L, activeStoreId))
assertEquals("STAFF", storeJpaRepository.findRoleNames(1L, closedStoreId))
// 同一个人在不同门店可以是不同角色,所以 roles 挂在 user_store 上而不是 user 上
assertNull(storeJpaRepository.findRoleNames(1L, otherStoreId))
}
@Test
fun `按 id 查门店不做权限过滤`() {
// 这个方法给跨域契约 StoreQueryService 用,只查基础信息;
// 权限过滤是调用方的责任,不能靠它兜底
assertEquals("徐汇门店", storeJpaRepository.findStoreById(otherStoreId)?.name)
}
@TestConfiguration
class ContainerConfig {
/** `@ServiceConnection` 自动把容器的 jdbc url / 账号密码接到 DataSource 上,不用手写 DynamicPropertySource。 */
@Bean
@ServiceConnection
fun mysqlContainer(): MySQLContainer =
MySQLContainer("mysql:8.4")
// 库名要和实体上的 schema = "identity_store" 对上
.withDatabaseName("identity_store")
}
}
+11
View File
@@ -0,0 +1,11 @@
dependencies {
implementation project(':platform:platform-web')
implementation project(':platform:platform-security')
implementation project(':platform:platform-persistence')
// 跨域只走契约模块:这里需要的是 StoreSwitchedEvent,不是 identity-store 的内部类型
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'
}
@@ -0,0 +1,23 @@
package com.continental.retailapp.webviewticket.api
import com.continental.retailapp.platform.web.ApiResult
import com.continental.retailapp.webviewticket.api.response.WebviewTicketResponse
import com.continental.retailapp.webviewticket.application.WebviewTicketAppService
import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.tags.Tag
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
@Tag(name = "WebView 票据")
@RestController
@RequestMapping("/api/v1/webview")
class WebviewTicketController(
private val webviewTicketAppService: WebviewTicketAppService,
) {
/** 端点上不接受 storeId 参数:门店来自 token,见 [WebviewTicketAppService]。 */
@Operation(summary = "换取一次性 WebView 票据")
@PostMapping("/tickets")
fun issueTicket(): ApiResult<WebviewTicketResponse> =
ApiResult.ok(webviewTicketAppService.issueTicket())
}
@@ -0,0 +1,13 @@
package com.continental.retailapp.webviewticket.api.response
import java.time.Instant
/**
* 换票响应。
* `expiresAt` 是 [Instant],序列化成 ISO-8601 UTC`2026-01-01T03:00:00Z`),
* 时区转换由客户端做——服务端一律 UTC06-api-design.md)。
*/
data class WebviewTicketResponse(
val ticketId: String,
val expiresAt: Instant,
)
@@ -0,0 +1,37 @@
package com.continental.retailapp.webviewticket.application
import com.continental.retailapp.identitystore.contract.StoreSwitchedEvent
import com.continental.retailapp.webviewticket.domain.repository.WebviewTicketRepository
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Component
import org.springframework.transaction.annotation.Propagation
import org.springframework.transaction.annotation.Transactional
import org.springframework.transaction.event.TransactionPhase
import org.springframework.transaction.event.TransactionalEventListener
/**
* 切店后作废旧门店下的 WebView 票据(11-cross-domain-collaboration.md)。
*
* 三个写法各解决一个具体问题,缺一个都会出事:
*
* - `@TransactionalEventListener(AFTER_COMMIT)`:普通 `@EventListener` 是同步、在同一事务内执行的,
* 会出现"切店事务回滚了但票据已经作废"。`AFTER_COMMIT` 保证只在主事务真正提交后才触发。
* - `@Transactional(REQUIRES_NEW)``AFTER_COMMIT` 阶段原事务已提交,此时**没有活跃事务**,
* 不开新事务的话这里的写操作行为不可控。
* - `runCatching` + 记日志:监听器抛异常**不会**回滚主事务(它已经提交了),
* 只会让这次副作用静默丢失。必须显式捕获并留下能排查的日志。
*/
@Component
class StoreSwitchedListener(
private val ticketRepository: WebviewTicketRepository,
) {
private val log = LoggerFactory.getLogger(javaClass)
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Transactional(propagation = Propagation.REQUIRES_NEW)
fun onStoreSwitched(event: StoreSwitchedEvent) {
runCatching { ticketRepository.revokeActiveTickets(event.userId) }
.onSuccess { log.info("切店后作废 webview 票据 userId={} count={}", event.userId, it) }
.onFailure { log.error("切店后作废 webview 票据失败 userId={}", event.userId, it) }
}
}
@@ -0,0 +1,29 @@
package com.continental.retailapp.webviewticket.application
import com.continental.retailapp.platform.security.StoreContextHolder
import com.continental.retailapp.webviewticket.api.response.WebviewTicketResponse
import com.continental.retailapp.webviewticket.domain.service.IssueWebviewTicketService
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
/**
* 换票用例。业务规则在 domain 层,这里只负责事务边界、上下文取值和 → Response 的转换。
*
* 门店取自 [StoreContextHolder](也就是签名过的 JWT claim),
* **不接受调用方传进来的 storeId**——请求参数可以被任意篡改,token 里的不行。
*/
@Service
class WebviewTicketAppService(
private val issueWebviewTicketService: IssueWebviewTicketService,
private val storeContextHolder: StoreContextHolder,
) {
@Transactional
fun issueTicket(): WebviewTicketResponse {
val ticket = issueWebviewTicketService.issue(
userId = storeContextHolder.currentUserId(),
storeId = storeContextHolder.currentStoreId(),
)
// 领域模型 → Response 的转换在 application 层
return WebviewTicketResponse(ticket.ticketId, ticket.expiresAt)
}
}
@@ -0,0 +1,20 @@
package com.continental.retailapp.webviewticket.domain.model
import java.time.Instant
/**
* WebView 票据(领域模型)。
*
* 注意这是一个纯 Kotlin 类:没有 `@Entity`、没有 Spring 注解。
* 换票的规则(复用/过期/作废)能脱离 Spring 和数据库单独编译和测试,
* 这是 `webview-ticket` 保留 domain 层的全部理由(02-layering.md)。
*/
data class WebviewTicket(
val ticketId: String,
val storeId: Long,
val userId: Long,
val status: TicketStatus,
val expiresAt: Instant,
)
enum class TicketStatus { ISSUED, CONSUMED, EXPIRED, INVALIDATED }
@@ -0,0 +1,17 @@
package com.continental.retailapp.webviewticket.domain.repository
import com.continental.retailapp.webviewticket.domain.model.WebviewTicket
/**
* 仓储接口定义在 domain 层、实现在 infrastructure 层——依赖倒置在这里的体现:
* 领域规则不认识 JPA,只认识"能拿到票据、能存票据"。
*/
interface WebviewTicketRepository {
fun findActiveTicket(userId: Long, storeId: Long): WebviewTicket?
fun save(ticket: WebviewTicket)
/** 切店后作废该用户名下所有仍然有效的票据,返回受影响条数。 */
fun revokeActiveTickets(userId: Long): Int
}
@@ -0,0 +1,45 @@
package com.continental.retailapp.webviewticket.domain.service
import com.continental.retailapp.webviewticket.domain.model.TicketStatus
import com.continental.retailapp.webviewticket.domain.model.WebviewTicket
import com.continental.retailapp.webviewticket.domain.repository.WebviewTicketRepository
import java.time.Clock
import java.time.Duration
import java.util.UUID
/**
* 换票规则,见 02-layering.md。
*
* **没有 `@Service` 注解**domain 层不依赖 Spring(有一条 ArchUnit 规则盯着)。
* 它变成 bean 的方式是 `infrastructure/config/DomainServiceConfig` 里显式 `@Bean`。
*
* [Clock] 构造注入而不是 `Instant.now()`:否则"过期判断"只能靠 `Thread.sleep` 硬等着测。
*/
class IssueWebviewTicketService(
private val ticketRepository: WebviewTicketRepository,
private val clock: Clock,
) {
fun issue(userId: Long, storeId: Long): WebviewTicket {
val existing = ticketRepository.findActiveTicket(userId, storeId)
if (existing != null &&
existing.status == TicketStatus.ISSUED &&
existing.expiresAt.isAfter(clock.instant())
) {
return existing // 已有有效票据,直接复用,不重复签发
}
val ticket = WebviewTicket(
ticketId = UUID.randomUUID().toString(),
storeId = storeId,
userId = userId,
status = TicketStatus.ISSUED,
expiresAt = clock.instant().plus(TICKET_TTL),
)
ticketRepository.save(ticket)
return ticket
}
private companion object {
/** 票据只用来完成一次跳转,5 分钟足够,短 TTL 本身就是一种防护。 */
val TICKET_TTL: Duration = Duration.ofMinutes(5)
}
}
@@ -0,0 +1,21 @@
package com.continental.retailapp.webviewticket.infrastructure.config
import com.continental.retailapp.webviewticket.domain.repository.WebviewTicketRepository
import com.continental.retailapp.webviewticket.domain.service.IssueWebviewTicketService
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import java.time.Clock
/**
* domain 层的类不带 Spring 注解,成为 bean 的方式是在这里显式声明(02-layering.md)。
*
* 多写这几行换来的是:领域规则那一层可以脱离 Spring 单独编译和测试。
* 模块内部的 `@Configuration` 一律放在 `infrastructure/config/`。
*/
@Configuration
class DomainServiceConfig {
@Bean
fun issueWebviewTicketService(repository: WebviewTicketRepository, clock: Clock) =
IssueWebviewTicketService(repository, clock)
}
@@ -0,0 +1,59 @@
package com.continental.retailapp.webviewticket.infrastructure.persistence
import com.continental.retailapp.platform.persistence.BaseEntity
import com.continental.retailapp.webviewticket.domain.model.TicketStatus
import com.continental.retailapp.webviewticket.domain.model.WebviewTicket
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.EnumType
import jakarta.persistence.Enumerated
import jakarta.persistence.GeneratedValue
import jakarta.persistence.GenerationType
import jakarta.persistence.Id
import jakarta.persistence.Table
import java.time.Instant
@Entity
@Table(name = "webview_ticket", schema = "webview_ticket")
class WebviewTicketEntity(
@Column(name = "ticket_id", nullable = false, length = 36, unique = true)
var ticketId: String,
@Column(name = "store_id", nullable = false)
var storeId: Long,
@Column(name = "user_id", nullable = false)
var userId: Long,
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 16)
var status: TicketStatus,
@Column(name = "expires_at", nullable = false)
var expiresAt: Instant,
) : BaseEntity() {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
var id: Long? = null
}
/**
* Entity ↔ 领域模型的转换只在 `infrastructure` 里发生。
* Entity 到 [WebviewTicketRepositoryImpl] 为止,不会出现在它的返回值里(02-layering.md)。
*/
fun WebviewTicketEntity.toDomain() = WebviewTicket(
ticketId = ticketId,
storeId = storeId,
userId = userId,
status = status,
expiresAt = expiresAt,
)
fun WebviewTicket.toEntity() = WebviewTicketEntity(
ticketId = ticketId,
storeId = storeId,
userId = userId,
status = status,
expiresAt = expiresAt,
)
@@ -0,0 +1,37 @@
package com.continental.retailapp.webviewticket.infrastructure.persistence
import com.continental.retailapp.webviewticket.domain.model.TicketStatus
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Modifying
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import org.springframework.stereotype.Repository
import java.time.Instant
@Repository
interface WebviewTicketJpaRepository : JpaRepository<WebviewTicketEntity, Long> {
fun findByUserIdAndStoreIdAndStatus(
userId: Long,
storeId: Long,
status: TicketStatus,
): WebviewTicketEntity?
/**
* 切店后作废票据。用批量 update 而不是查出来逐条改:
* 作废必须立即生效(票据状态是绝对不能缓存的东西),走一条 SQL 最直接。
* 枚举在 JPQL 里写全限定名,避免依赖参数默认值这种在 Spring Data 里不稳的写法。
*/
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query(
"""
update WebviewTicketEntity t
set t.status = com.continental.retailapp.webviewticket.domain.model.TicketStatus.INVALIDATED
where t.userId = :userId
and t.status = com.continental.retailapp.webviewticket.domain.model.TicketStatus.ISSUED
""",
)
fun invalidateActiveTickets(@Param("userId") userId: Long): Int
fun deleteByExpiresAtBefore(before: Instant): Int
}
@@ -0,0 +1,23 @@
package com.continental.retailapp.webviewticket.infrastructure.persistence
import com.continental.retailapp.webviewticket.domain.model.TicketStatus
import com.continental.retailapp.webviewticket.domain.model.WebviewTicket
import com.continental.retailapp.webviewticket.domain.repository.WebviewTicketRepository
import org.springframework.stereotype.Repository
@Repository
class WebviewTicketRepositoryImpl(
private val jpaRepository: WebviewTicketJpaRepository,
) : WebviewTicketRepository {
// Entity 到这里为止,不会出现在返回值里
override fun findActiveTicket(userId: Long, storeId: Long): WebviewTicket? =
jpaRepository.findByUserIdAndStoreIdAndStatus(userId, storeId, TicketStatus.ISSUED)?.toDomain()
override fun save(ticket: WebviewTicket) {
jpaRepository.save(ticket.toEntity())
}
override fun revokeActiveTickets(userId: Long): Int =
jpaRepository.invalidateActiveTickets(userId)
}
@@ -0,0 +1,24 @@
-- webview_ticket 库初始化。
--
-- 票据是"短命 + 一次性"的:TTL 5 分钟,用完置 CONSUMED,切店置 INVALIDATED。
-- status 上有索引是因为"查该用户当前有效票据"是最热的查询。
create table webview_ticket
(
id bigint not null auto_increment,
ticket_id varchar(36) not null comment '对外暴露的票据标识(UUID),不暴露自增主键',
store_id bigint not null,
user_id bigint not null,
status varchar(16) not null,
expires_at datetime(6) not null,
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_webview_ticket_id (ticket_id),
key idx_webview_ticket_user_status (user_id, status),
key idx_webview_ticket_expires_at (expires_at)
) engine = InnoDB
default charset = utf8mb4
collate = utf8mb4_0900_ai_ci comment 'WebView 一次性票据';
@@ -0,0 +1,84 @@
package com.continental.retailapp.webviewticket.domain.service
import com.continental.retailapp.webviewticket.domain.model.TicketStatus
import com.continental.retailapp.webviewticket.domain.repository.WebviewTicketRepository
import com.continental.retailapp.webviewticket.fixtures.aWebviewTicket
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotEquals
import org.junit.jupiter.api.Test
import java.time.Clock
import java.time.Instant
import java.time.ZoneOffset
/**
* 领域服务的单测:不起 Spring,不连数据库,毫秒级跑完。
* 这正是 `webview-ticket` 保留 domain 层换来的东西(02-layering.md / 10-testing.md)。
*/
class IssueWebviewTicketServiceTest {
private val now = Instant.parse("2026-01-01T00:00:00Z")
private val clock = Clock.fixed(now, ZoneOffset.UTC)
private val repository = mockk<WebviewTicketRepository>()
private val service = IssueWebviewTicketService(repository, clock)
@Test
fun `已有未过期票据时直接复用,不重复签发`() {
val existing = aWebviewTicket(expiresAt = now.plusSeconds(60))
every { repository.findActiveTicket(1L, 100L) } returns existing
val result = service.issue(userId = 1L, storeId = 100L)
assertEquals(existing, result)
verify(exactly = 0) { repository.save(any()) }
}
@Test
fun `票据已过期时签发新票据`() {
val expired = aWebviewTicket(ticketId = "old", expiresAt = now.minusSeconds(1))
every { repository.findActiveTicket(1L, 100L) } returns expired
every { repository.save(any()) } returns Unit
val result = service.issue(userId = 1L, storeId = 100L)
assertNotEquals("old", result.ticketId)
assertEquals(TicketStatus.ISSUED, result.status)
// 边界:expiresAt 恰好等于 now 也算过期(isAfter 是严格大于)
assertEquals(now.plusSeconds(300), result.expiresAt)
verify(exactly = 1) { repository.save(result) }
}
@Test
fun `票据已被作废时签发新票据`() {
val invalidated = aWebviewTicket(
ticketId = "old",
status = TicketStatus.INVALIDATED,
// 故意让它还没到期:这里要验证的是"状态不对"这一条,而不是过期那条
expiresAt = now.plusSeconds(60),
)
every { repository.findActiveTicket(1L, 100L) } returns invalidated
every { repository.save(any()) } returns Unit
val result = service.issue(userId = 1L, storeId = 100L)
assertNotEquals("old", result.ticketId)
assertEquals(TicketStatus.ISSUED, result.status)
verify(exactly = 1) { repository.save(result) }
}
@Test
fun `没有任何票据时签发新票据`() {
every { repository.findActiveTicket(1L, 100L) } returns null
every { repository.save(any()) } returns Unit
val result = service.issue(userId = 1L, storeId = 100L)
assertEquals(1L, result.userId)
assertEquals(100L, result.storeId)
assertEquals(TicketStatus.ISSUED, result.status)
assertEquals(now.plusSeconds(300), result.expiresAt)
verify(exactly = 1) { repository.save(result) }
}
}
@@ -0,0 +1,24 @@
package com.continental.retailapp.webviewticket.fixtures
import com.continental.retailapp.webviewticket.domain.model.TicketStatus
import com.continental.retailapp.webviewticket.domain.model.WebviewTicket
import java.time.Instant
/**
* 测试数据统一走构建器函数,见 10-testing.md
* 每个测试只覆盖自己关心的那一两个字段,其余给默认值。
* 这样以后 [WebviewTicket] 加字段,只需要改这一处。
*/
fun aWebviewTicket(
ticketId: String = "ticket-1",
storeId: Long = 100L,
userId: Long = 1L,
status: TicketStatus = TicketStatus.ISSUED,
expiresAt: Instant = Instant.parse("2026-01-01T00:05:00Z"),
) = WebviewTicket(
ticketId = ticketId,
storeId = storeId,
userId = userId,
status = status,
expiresAt = expiresAt,
)
+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 '工作台卡片配置';