backend scaffold
This commit is contained in:
+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,
|
||||
)
|
||||
Reference in New Issue
Block a user