feat: Kotlin 语法约束——只当语法糖用,不当新范式用

新增 docs/13-kotlin-style.md,并把它落成可执行的构建门禁,而不是一份只能靠
自觉遵守的建议。两条准入标准:只当语法糖(不引入新的思维模型)、每条规则必须
说得出它挡住的具体故障。

- KotlinSyntaxScanTest(源码扫描):禁 !! / lateinit / suspend / kotlinx.coroutines
  / RxJava / Reactor / data class Entity。走源码文本而非 ArchUnit,是因为这些在
  字节码上看不出来——!! 编译成 Intrinsics.checkNotNull(...) 调用,和其他 null
  检查混在一起。
- ArchitectureRulesTest 加「禁止工具类」:只禁 Utils/Util/Helper/Common 四个
  后缀,不禁 XxxManager——Spring 自身大量使用这个命名,一刀切会误伤。
- PersistedEntity + requiredId:把「从 repository 读出来的实体必有 id」变成
  一个有名字的类型,替掉 AuthAppService / LocalSeedDataConfig 里 7 处 .id!!。
- 5 个 Entity 实现 PersistedEntity。

两条故意不自动强制的写进了文档:companion object 靠 review 判断(现存 14 处
用法均合法,硬规则只会误伤);Entity 可被 Hibernate 代理目前没守住——未 apply
kotlin("plugin.jpa"),Entity 是 final 类,fetch = LAZY 会静默降级为 eager,现
因为零懒加载关联而未暴露。

docs/01-project-structure.md 顺带补了 Gradle 版本行的双向约束说明;README 修正
了 12 篇→13 篇文档、10 条→11 条规则,并补上工具类禁名。

验证:./gradlew build -PexcludeIntegration 通过。ArchitectureRulesTest 11 项、
KotlinSyntaxScanTest、NativeQueryScanTest 全绿;两条新规则均以「植入一个故意
违规、确认它变红」的方式验证过不是空转。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
Guangfei.Zhao
2026-09-30 16:01:22 +08:00
co-authored by Claude Code
parent 54f3b35245
commit aca7487be2
14 changed files with 347 additions and 23 deletions
@@ -10,6 +10,7 @@ import com.continental.retailapp.identitystore.infrastructure.persistence.UserEn
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.persistence.requiredId
import com.continental.retailapp.platform.security.AccessTokenIssuer
import com.continental.retailapp.platform.web.BusinessException
import com.continental.retailapp.platform.web.ErrorCode
@@ -70,13 +71,13 @@ class AuthAppService(
user.lockedUntil = null
val storeId = resolveCurrentStore(user)
val roles = roleNamesOf(user.id!!, storeId)
val roles = roleNamesOf(user.requiredId, storeId)
user.currentStoreId = storeId
countLogin("success")
return LoginResponse(
accessToken = accessTokenIssuer.issue(user.id!!, storeId, roles),
refreshToken = refreshTokenService.issue(user.id!!),
accessToken = accessTokenIssuer.issue(user.requiredId, storeId, roles),
refreshToken = refreshTokenService.issue(user.requiredId),
user = user.toResponse(),
)
}
@@ -91,14 +92,14 @@ class AuthAppService(
val user = userJpaRepository.findById(rotated.userId).orElseThrow { InvalidRefreshTokenException() }
if (user.status != UserStatus.ACTIVE) {
// 账号在这期间被停用:连带把该用户所有令牌作废,不给它继续刷的机会
refreshTokenService.revokeAllByUser(user.id!!)
refreshTokenService.revokeAllByUser(user.requiredId)
throw InvalidRefreshTokenException()
}
val storeId = resolveCurrentStore(user)
user.currentStoreId = storeId
return TokenResponse(
accessToken = accessTokenIssuer.issue(user.id!!, storeId, roleNamesOf(user.id!!, storeId)),
accessToken = accessTokenIssuer.issue(user.requiredId, storeId, roleNamesOf(user.requiredId, storeId)),
refreshToken = rotated.refreshToken,
)
}
@@ -121,7 +122,7 @@ class AuthAppService(
* 授权可能在两次登录之间被收回,直接信任 `current_store_id` 就是越权。
*/
private fun resolveCurrentStore(user: UserEntity): Long {
val userId = user.id!!
val userId = user.requiredId
user.currentStoreId
?.let { storeRepository.findAccessibleStore(userId, it) }
?.let { return it.id }
@@ -164,7 +165,7 @@ class AuthAppService(
meterRegistry.counter("auth.login", "result", result, "reason", reason).increment()
}
private fun UserEntity.toResponse() = UserResponse(id = id!!, username = username, displayName = displayName)
private fun UserEntity.toResponse() = UserResponse(id = requiredId, username = username, displayName = displayName)
private companion object {
const val MAX_FAILED_ATTEMPTS = 5
@@ -6,6 +6,7 @@ import com.continental.retailapp.identitystore.infrastructure.persistence.UserEn
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 com.continental.retailapp.platform.persistence.requiredId
import org.slf4j.LoggerFactory
import org.springframework.boot.ApplicationRunner
import org.springframework.context.annotation.Bean
@@ -63,8 +64,8 @@ class LocalSeedDataSeeder(
)
userStoreJpaRepository.saveAll(
listOf(
UserStoreEntity(userId = user.id!!, storeId = stores[0].id!!, roles = "STORE_MANAGER"),
UserStoreEntity(userId = user.id!!, storeId = stores[1].id!!, roles = "STAFF"),
UserStoreEntity(userId = user.requiredId, storeId = stores[0].requiredId, roles = "STORE_MANAGER"),
UserStoreEntity(userId = user.requiredId, storeId = stores[1].requiredId, roles = "STAFF"),
),
)
log.info("已写入本地种子数据:用户 {} / 门店 {} 家", SEED_USERNAME, stores.size)
@@ -1,6 +1,7 @@
package com.continental.retailapp.identitystore.infrastructure.persistence
import com.continental.retailapp.platform.persistence.BaseEntity
import com.continental.retailapp.platform.persistence.PersistedEntity
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.GeneratedValue
@@ -36,11 +37,11 @@ class RefreshTokenEntity(
@Column(name = "replaced_by_token_id")
var replacedByTokenId: Long? = null,
) : BaseEntity() {
) : BaseEntity(), PersistedEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
var id: Long? = null
override var id: Long? = null
fun isExpired(now: Instant): Boolean = !expiresAt.isAfter(now)
}
@@ -1,5 +1,6 @@
package com.continental.retailapp.identitystore.infrastructure.persistence
import com.continental.retailapp.platform.persistence.PersistedEntity
import com.continental.retailapp.platform.persistence.VersionedEntity
import jakarta.persistence.Column
import jakarta.persistence.Entity
@@ -25,9 +26,9 @@ class StoreEntity(
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 16)
var status: StoreStatus = StoreStatus.ACTIVE,
) : VersionedEntity() {
) : VersionedEntity(), PersistedEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
var id: Long? = null
override var id: Long? = null
}
@@ -1,5 +1,6 @@
package com.continental.retailapp.identitystore.infrastructure.persistence
import com.continental.retailapp.platform.persistence.PersistedEntity
import com.continental.retailapp.platform.persistence.VersionedEntity
import jakarta.persistence.Column
import jakarta.persistence.Entity
@@ -45,11 +46,11 @@ class UserEntity(
@Column(name = "locked_until")
var lockedUntil: Instant? = null,
) : VersionedEntity() {
) : VersionedEntity(), PersistedEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
var id: Long? = null
override var id: Long? = null
fun isLocked(now: Instant): Boolean = lockedUntil?.isAfter(now) == true
}
@@ -1,6 +1,7 @@
package com.continental.retailapp.identitystore.infrastructure.persistence
import com.continental.retailapp.platform.persistence.BaseEntity
import com.continental.retailapp.platform.persistence.PersistedEntity
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.GeneratedValue
@@ -29,9 +30,9 @@ class UserStoreEntity(
*/
@Column(name = "roles", nullable = false, length = 255)
var roles: String = "STAFF",
) : BaseEntity() {
) : BaseEntity(), PersistedEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
var id: Long? = null
override var id: Long? = null
}
@@ -1,6 +1,7 @@
package com.continental.retailapp.webviewticket.infrastructure.persistence
import com.continental.retailapp.platform.persistence.BaseEntity
import com.continental.retailapp.platform.persistence.PersistedEntity
import com.continental.retailapp.webviewticket.domain.model.TicketStatus
import com.continental.retailapp.webviewticket.domain.model.WebviewTicket
import jakarta.persistence.Column
@@ -31,11 +32,11 @@ class WebviewTicketEntity(
@Column(name = "expires_at", nullable = false)
var expiresAt: Instant,
) : BaseEntity() {
) : BaseEntity(), PersistedEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
var id: Long? = null
override var id: Long? = null
}
/**