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
@@ -0,0 +1,23 @@
package com.continental.retailapp.webviewticket.api
import com.continental.retailapp.platform.web.ApiResult
import com.continental.retailapp.webviewticket.api.response.WebviewTicketResponse
import com.continental.retailapp.webviewticket.application.WebviewTicketAppService
import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.tags.Tag
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
@Tag(name = "WebView 票据")
@RestController
@RequestMapping("/api/v1/webview")
class WebviewTicketController(
private val webviewTicketAppService: WebviewTicketAppService,
) {
/** 端点上不接受 storeId 参数:门店来自 token,见 [WebviewTicketAppService]。 */
@Operation(summary = "换取一次性 WebView 票据")
@PostMapping("/tickets")
fun issueTicket(): ApiResult<WebviewTicketResponse> =
ApiResult.ok(webviewTicketAppService.issueTicket())
}
@@ -0,0 +1,13 @@
package com.continental.retailapp.webviewticket.api.response
import java.time.Instant
/**
* 换票响应。
* `expiresAt` 是 [Instant],序列化成 ISO-8601 UTC`2026-01-01T03:00:00Z`),
* 时区转换由客户端做——服务端一律 UTC06-api-design.md)。
*/
data class WebviewTicketResponse(
val ticketId: String,
val expiresAt: Instant,
)
@@ -0,0 +1,37 @@
package com.continental.retailapp.webviewticket.application
import com.continental.retailapp.identitystore.contract.StoreSwitchedEvent
import com.continental.retailapp.webviewticket.domain.repository.WebviewTicketRepository
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Component
import org.springframework.transaction.annotation.Propagation
import org.springframework.transaction.annotation.Transactional
import org.springframework.transaction.event.TransactionPhase
import org.springframework.transaction.event.TransactionalEventListener
/**
* 切店后作废旧门店下的 WebView 票据(11-cross-domain-collaboration.md)。
*
* 三个写法各解决一个具体问题,缺一个都会出事:
*
* - `@TransactionalEventListener(AFTER_COMMIT)`:普通 `@EventListener` 是同步、在同一事务内执行的,
* 会出现"切店事务回滚了但票据已经作废"。`AFTER_COMMIT` 保证只在主事务真正提交后才触发。
* - `@Transactional(REQUIRES_NEW)``AFTER_COMMIT` 阶段原事务已提交,此时**没有活跃事务**,
* 不开新事务的话这里的写操作行为不可控。
* - `runCatching` + 记日志:监听器抛异常**不会**回滚主事务(它已经提交了),
* 只会让这次副作用静默丢失。必须显式捕获并留下能排查的日志。
*/
@Component
class StoreSwitchedListener(
private val ticketRepository: WebviewTicketRepository,
) {
private val log = LoggerFactory.getLogger(javaClass)
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Transactional(propagation = Propagation.REQUIRES_NEW)
fun onStoreSwitched(event: StoreSwitchedEvent) {
runCatching { ticketRepository.revokeActiveTickets(event.userId) }
.onSuccess { log.info("切店后作废 webview 票据 userId={} count={}", event.userId, it) }
.onFailure { log.error("切店后作废 webview 票据失败 userId={}", event.userId, it) }
}
}
@@ -0,0 +1,29 @@
package com.continental.retailapp.webviewticket.application
import com.continental.retailapp.platform.security.StoreContextHolder
import com.continental.retailapp.webviewticket.api.response.WebviewTicketResponse
import com.continental.retailapp.webviewticket.domain.service.IssueWebviewTicketService
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
/**
* 换票用例。业务规则在 domain 层,这里只负责事务边界、上下文取值和 → Response 的转换。
*
* 门店取自 [StoreContextHolder](也就是签名过的 JWT claim),
* **不接受调用方传进来的 storeId**——请求参数可以被任意篡改,token 里的不行。
*/
@Service
class WebviewTicketAppService(
private val issueWebviewTicketService: IssueWebviewTicketService,
private val storeContextHolder: StoreContextHolder,
) {
@Transactional
fun issueTicket(): WebviewTicketResponse {
val ticket = issueWebviewTicketService.issue(
userId = storeContextHolder.currentUserId(),
storeId = storeContextHolder.currentStoreId(),
)
// 领域模型 → Response 的转换在 application 层
return WebviewTicketResponse(ticket.ticketId, ticket.expiresAt)
}
}
@@ -0,0 +1,20 @@
package com.continental.retailapp.webviewticket.domain.model
import java.time.Instant
/**
* WebView 票据(领域模型)。
*
* 注意这是一个纯 Kotlin 类:没有 `@Entity`、没有 Spring 注解。
* 换票的规则(复用/过期/作废)能脱离 Spring 和数据库单独编译和测试,
* 这是 `webview-ticket` 保留 domain 层的全部理由(02-layering.md)。
*/
data class WebviewTicket(
val ticketId: String,
val storeId: Long,
val userId: Long,
val status: TicketStatus,
val expiresAt: Instant,
)
enum class TicketStatus { ISSUED, CONSUMED, EXPIRED, INVALIDATED }
@@ -0,0 +1,17 @@
package com.continental.retailapp.webviewticket.domain.repository
import com.continental.retailapp.webviewticket.domain.model.WebviewTicket
/**
* 仓储接口定义在 domain 层、实现在 infrastructure 层——依赖倒置在这里的体现:
* 领域规则不认识 JPA,只认识"能拿到票据、能存票据"。
*/
interface WebviewTicketRepository {
fun findActiveTicket(userId: Long, storeId: Long): WebviewTicket?
fun save(ticket: WebviewTicket)
/** 切店后作废该用户名下所有仍然有效的票据,返回受影响条数。 */
fun revokeActiveTickets(userId: Long): Int
}
@@ -0,0 +1,45 @@
package com.continental.retailapp.webviewticket.domain.service
import com.continental.retailapp.webviewticket.domain.model.TicketStatus
import com.continental.retailapp.webviewticket.domain.model.WebviewTicket
import com.continental.retailapp.webviewticket.domain.repository.WebviewTicketRepository
import java.time.Clock
import java.time.Duration
import java.util.UUID
/**
* 换票规则,见 02-layering.md。
*
* **没有 `@Service` 注解**domain 层不依赖 Spring(有一条 ArchUnit 规则盯着)。
* 它变成 bean 的方式是 `infrastructure/config/DomainServiceConfig` 里显式 `@Bean`。
*
* [Clock] 构造注入而不是 `Instant.now()`:否则"过期判断"只能靠 `Thread.sleep` 硬等着测。
*/
class IssueWebviewTicketService(
private val ticketRepository: WebviewTicketRepository,
private val clock: Clock,
) {
fun issue(userId: Long, storeId: Long): WebviewTicket {
val existing = ticketRepository.findActiveTicket(userId, storeId)
if (existing != null &&
existing.status == TicketStatus.ISSUED &&
existing.expiresAt.isAfter(clock.instant())
) {
return existing // 已有有效票据,直接复用,不重复签发
}
val ticket = WebviewTicket(
ticketId = UUID.randomUUID().toString(),
storeId = storeId,
userId = userId,
status = TicketStatus.ISSUED,
expiresAt = clock.instant().plus(TICKET_TTL),
)
ticketRepository.save(ticket)
return ticket
}
private companion object {
/** 票据只用来完成一次跳转,5 分钟足够,短 TTL 本身就是一种防护。 */
val TICKET_TTL: Duration = Duration.ofMinutes(5)
}
}
@@ -0,0 +1,21 @@
package com.continental.retailapp.webviewticket.infrastructure.config
import com.continental.retailapp.webviewticket.domain.repository.WebviewTicketRepository
import com.continental.retailapp.webviewticket.domain.service.IssueWebviewTicketService
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import java.time.Clock
/**
* domain 层的类不带 Spring 注解,成为 bean 的方式是在这里显式声明(02-layering.md)。
*
* 多写这几行换来的是:领域规则那一层可以脱离 Spring 单独编译和测试。
* 模块内部的 `@Configuration` 一律放在 `infrastructure/config/`。
*/
@Configuration
class DomainServiceConfig {
@Bean
fun issueWebviewTicketService(repository: WebviewTicketRepository, clock: Clock) =
IssueWebviewTicketService(repository, clock)
}
@@ -0,0 +1,59 @@
package com.continental.retailapp.webviewticket.infrastructure.persistence
import com.continental.retailapp.platform.persistence.BaseEntity
import com.continental.retailapp.webviewticket.domain.model.TicketStatus
import com.continental.retailapp.webviewticket.domain.model.WebviewTicket
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.EnumType
import jakarta.persistence.Enumerated
import jakarta.persistence.GeneratedValue
import jakarta.persistence.GenerationType
import jakarta.persistence.Id
import jakarta.persistence.Table
import java.time.Instant
@Entity
@Table(name = "webview_ticket", schema = "webview_ticket")
class WebviewTicketEntity(
@Column(name = "ticket_id", nullable = false, length = 36, unique = true)
var ticketId: String,
@Column(name = "store_id", nullable = false)
var storeId: Long,
@Column(name = "user_id", nullable = false)
var userId: Long,
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 16)
var status: TicketStatus,
@Column(name = "expires_at", nullable = false)
var expiresAt: Instant,
) : BaseEntity() {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
var id: Long? = null
}
/**
* Entity ↔ 领域模型的转换只在 `infrastructure` 里发生。
* Entity 到 [WebviewTicketRepositoryImpl] 为止,不会出现在它的返回值里(02-layering.md)。
*/
fun WebviewTicketEntity.toDomain() = WebviewTicket(
ticketId = ticketId,
storeId = storeId,
userId = userId,
status = status,
expiresAt = expiresAt,
)
fun WebviewTicket.toEntity() = WebviewTicketEntity(
ticketId = ticketId,
storeId = storeId,
userId = userId,
status = status,
expiresAt = expiresAt,
)
@@ -0,0 +1,37 @@
package com.continental.retailapp.webviewticket.infrastructure.persistence
import com.continental.retailapp.webviewticket.domain.model.TicketStatus
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Modifying
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import org.springframework.stereotype.Repository
import java.time.Instant
@Repository
interface WebviewTicketJpaRepository : JpaRepository<WebviewTicketEntity, Long> {
fun findByUserIdAndStoreIdAndStatus(
userId: Long,
storeId: Long,
status: TicketStatus,
): WebviewTicketEntity?
/**
* 切店后作废票据。用批量 update 而不是查出来逐条改:
* 作废必须立即生效(票据状态是绝对不能缓存的东西),走一条 SQL 最直接。
* 枚举在 JPQL 里写全限定名,避免依赖参数默认值这种在 Spring Data 里不稳的写法。
*/
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query(
"""
update WebviewTicketEntity t
set t.status = com.continental.retailapp.webviewticket.domain.model.TicketStatus.INVALIDATED
where t.userId = :userId
and t.status = com.continental.retailapp.webviewticket.domain.model.TicketStatus.ISSUED
""",
)
fun invalidateActiveTickets(@Param("userId") userId: Long): Int
fun deleteByExpiresAtBefore(before: Instant): Int
}
@@ -0,0 +1,23 @@
package com.continental.retailapp.webviewticket.infrastructure.persistence
import com.continental.retailapp.webviewticket.domain.model.TicketStatus
import com.continental.retailapp.webviewticket.domain.model.WebviewTicket
import com.continental.retailapp.webviewticket.domain.repository.WebviewTicketRepository
import org.springframework.stereotype.Repository
@Repository
class WebviewTicketRepositoryImpl(
private val jpaRepository: WebviewTicketJpaRepository,
) : WebviewTicketRepository {
// Entity 到这里为止,不会出现在返回值里
override fun findActiveTicket(userId: Long, storeId: Long): WebviewTicket? =
jpaRepository.findByUserIdAndStoreIdAndStatus(userId, storeId, TicketStatus.ISSUED)?.toDomain()
override fun save(ticket: WebviewTicket) {
jpaRepository.save(ticket.toEntity())
}
override fun revokeActiveTickets(userId: Long): Int =
jpaRepository.invalidateActiveTickets(userId)
}
@@ -0,0 +1,24 @@
-- webview_ticket 库初始化。
--
-- 票据是"短命 + 一次性"的:TTL 5 分钟,用完置 CONSUMED,切店置 INVALIDATED。
-- status 上有索引是因为"查该用户当前有效票据"是最热的查询。
create table webview_ticket
(
id bigint not null auto_increment,
ticket_id varchar(36) not null comment '对外暴露的票据标识(UUID),不暴露自增主键',
store_id bigint not null,
user_id bigint not null,
status varchar(16) not null,
expires_at datetime(6) not null,
created_at datetime(6) not null,
updated_at datetime(6) not null,
created_by varchar(64) null,
updated_by varchar(64) null,
primary key (id),
unique key uk_webview_ticket_id (ticket_id),
key idx_webview_ticket_user_status (user_id, status),
key idx_webview_ticket_expires_at (expires_at)
) engine = InnoDB
default charset = utf8mb4
collate = utf8mb4_0900_ai_ci comment 'WebView 一次性票据';
@@ -0,0 +1,84 @@
package com.continental.retailapp.webviewticket.domain.service
import com.continental.retailapp.webviewticket.domain.model.TicketStatus
import com.continental.retailapp.webviewticket.domain.repository.WebviewTicketRepository
import com.continental.retailapp.webviewticket.fixtures.aWebviewTicket
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotEquals
import org.junit.jupiter.api.Test
import java.time.Clock
import java.time.Instant
import java.time.ZoneOffset
/**
* 领域服务的单测:不起 Spring,不连数据库,毫秒级跑完。
* 这正是 `webview-ticket` 保留 domain 层换来的东西(02-layering.md / 10-testing.md)。
*/
class IssueWebviewTicketServiceTest {
private val now = Instant.parse("2026-01-01T00:00:00Z")
private val clock = Clock.fixed(now, ZoneOffset.UTC)
private val repository = mockk<WebviewTicketRepository>()
private val service = IssueWebviewTicketService(repository, clock)
@Test
fun `已有未过期票据时直接复用,不重复签发`() {
val existing = aWebviewTicket(expiresAt = now.plusSeconds(60))
every { repository.findActiveTicket(1L, 100L) } returns existing
val result = service.issue(userId = 1L, storeId = 100L)
assertEquals(existing, result)
verify(exactly = 0) { repository.save(any()) }
}
@Test
fun `票据已过期时签发新票据`() {
val expired = aWebviewTicket(ticketId = "old", expiresAt = now.minusSeconds(1))
every { repository.findActiveTicket(1L, 100L) } returns expired
every { repository.save(any()) } returns Unit
val result = service.issue(userId = 1L, storeId = 100L)
assertNotEquals("old", result.ticketId)
assertEquals(TicketStatus.ISSUED, result.status)
// 边界:expiresAt 恰好等于 now 也算过期(isAfter 是严格大于)
assertEquals(now.plusSeconds(300), result.expiresAt)
verify(exactly = 1) { repository.save(result) }
}
@Test
fun `票据已被作废时签发新票据`() {
val invalidated = aWebviewTicket(
ticketId = "old",
status = TicketStatus.INVALIDATED,
// 故意让它还没到期:这里要验证的是"状态不对"这一条,而不是过期那条
expiresAt = now.plusSeconds(60),
)
every { repository.findActiveTicket(1L, 100L) } returns invalidated
every { repository.save(any()) } returns Unit
val result = service.issue(userId = 1L, storeId = 100L)
assertNotEquals("old", result.ticketId)
assertEquals(TicketStatus.ISSUED, result.status)
verify(exactly = 1) { repository.save(result) }
}
@Test
fun `没有任何票据时签发新票据`() {
every { repository.findActiveTicket(1L, 100L) } returns null
every { repository.save(any()) } returns Unit
val result = service.issue(userId = 1L, storeId = 100L)
assertEquals(1L, result.userId)
assertEquals(100L, result.storeId)
assertEquals(TicketStatus.ISSUED, result.status)
assertEquals(now.plusSeconds(300), result.expiresAt)
verify(exactly = 1) { repository.save(result) }
}
}
@@ -0,0 +1,24 @@
package com.continental.retailapp.webviewticket.fixtures
import com.continental.retailapp.webviewticket.domain.model.TicketStatus
import com.continental.retailapp.webviewticket.domain.model.WebviewTicket
import java.time.Instant
/**
* 测试数据统一走构建器函数,见 10-testing.md
* 每个测试只覆盖自己关心的那一两个字段,其余给默认值。
* 这样以后 [WebviewTicket] 加字段,只需要改这一处。
*/
fun aWebviewTicket(
ticketId: String = "ticket-1",
storeId: Long = 100L,
userId: Long = 1L,
status: TicketStatus = TicketStatus.ISSUED,
expiresAt: Instant = Instant.parse("2026-01-01T00:05:00Z"),
) = WebviewTicket(
ticketId = ticketId,
storeId = storeId,
userId = userId,
status = status,
expiresAt = expiresAt,
)