backend scaffold
This commit is contained in:
@@ -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`)。
|
||||
@@ -0,0 +1,4 @@
|
||||
// 骨架阶段这个模块只占位,见 README.md
|
||||
dependencies {
|
||||
implementation project(':platform:platform-web')
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// 契约模块:只放接口、传输模型和事件,不携带任何技术栈。
|
||||
// ArchUnit 有一条规则盯着它不依赖 Spring Web / JPA(见 10-testing.md)。
|
||||
dependencies {
|
||||
}
|
||||
+20
@@ -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,
|
||||
)
|
||||
+19
@@ -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,
|
||||
)
|
||||
@@ -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'
|
||||
}
|
||||
+56
@@ -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()))
|
||||
}
|
||||
+34
@@ -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))
|
||||
}
|
||||
+20
@@ -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,
|
||||
)
|
||||
+9
@@ -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,
|
||||
)
|
||||
+30
@@ -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?,
|
||||
)
|
||||
+17
@@ -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>,
|
||||
)
|
||||
+8
@@ -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,
|
||||
)
|
||||
+179
@@ -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"
|
||||
}
|
||||
}
|
||||
+18
@@ -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,
|
||||
)
|
||||
+19
@@ -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()
|
||||
}
|
||||
+36
@@ -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 token(12-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) }
|
||||
}
|
||||
}
|
||||
+103
@@ -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()))
|
||||
}
|
||||
+75
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
+36
@@ -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 的 repository(11-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) }
|
||||
}
|
||||
+19
@@ -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>
|
||||
}
|
||||
+77
@@ -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"
|
||||
}
|
||||
}
|
||||
+46
@@ -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)
|
||||
}
|
||||
+34
@@ -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
|
||||
}
|
||||
+33
@@ -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
|
||||
}
|
||||
+38
@@ -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?
|
||||
}
|
||||
+25
@@ -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?
|
||||
}
|
||||
+14
@@ -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
|
||||
}
|
||||
+55
@@ -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
|
||||
}
|
||||
+10
@@ -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?
|
||||
}
|
||||
+37
@@ -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
|
||||
}
|
||||
+11
@@ -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 '用户可访问的门店(授权的唯一事实来源)';
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
-- refresh token,DDL 逐字取自 04-security-auth.md。
|
||||
--
|
||||
-- token_hash 是 char(64) 而不是 varchar:SHA-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;
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package com.continental.retailapp.identitystore
|
||||
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication
|
||||
|
||||
/**
|
||||
* `@DataJpaTest` 需要在包层级上能找到一个 `@SpringBootConfiguration`。
|
||||
* 真正的入口类在 `bootstrap` 模块,而 domain 模块不许反向依赖 bootstrap(01-project-structure.md),
|
||||
* 所以这里给测试单独放一个最小入口,只覆盖本模块的实体与仓储。
|
||||
*/
|
||||
@SpringBootApplication(scanBasePackages = ["com.continental.retailapp.identitystore"])
|
||||
class IdentityStoreTestApplication
|
||||
+155
@@ -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('/') })
|
||||
}
|
||||
}
|
||||
+39
@@ -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 }
|
||||
+125
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
+23
@@ -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())
|
||||
}
|
||||
+13
@@ -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`),
|
||||
* 时区转换由客户端做——服务端一律 UTC(06-api-design.md)。
|
||||
*/
|
||||
data class WebviewTicketResponse(
|
||||
val ticketId: String,
|
||||
val expiresAt: Instant,
|
||||
)
|
||||
+37
@@ -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) }
|
||||
}
|
||||
}
|
||||
+29
@@ -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)
|
||||
}
|
||||
}
|
||||
+20
@@ -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 }
|
||||
+17
@@ -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
|
||||
}
|
||||
+45
@@ -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)
|
||||
}
|
||||
}
|
||||
+21
@@ -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)
|
||||
}
|
||||
+59
@@ -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,
|
||||
)
|
||||
+37
@@ -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
|
||||
}
|
||||
+23
@@ -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 一次性票据';
|
||||
+84
@@ -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) }
|
||||
}
|
||||
}
|
||||
+24
@@ -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,
|
||||
)
|
||||
@@ -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