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 @@
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")
}
}